LWN:看一下动态链接过程!

Linux News搬运工

共 4588字,需浏览 10分钟

 · 2024-04-11

关注了就能看到更多这么棒的文章哦~

A look at dynamic linking

By Daroc Alden
February 13, 2024
Gemini translation
https://lwn.net/Articles/961117/

动态链接程序是现代 Linux 系统的关键组件,负责设置大多数进程的地址空间。虽然随着最初推动动态链接的因素变得越来越没有那么重要了,使得静态链接二进制文件变得越来越流行,但是动态链接仍然是默认设置。本文会介绍一下动态链接程序为执行程序做准备工作时的采取的步骤。

调用链接程序(linker)

当 Linux 内核受命执行一个程序时,它会查看文件头以确定其是哪种类型的程序。然后,内核查阅自身针对 shell 脚本和本机二进制文件的内置规则,以及 binfmt_misc 设置(这是允许用户注册自定义程序解释器的一个内核特性)以确定如何处理该程序。以 “#!” 开头 的文件被标识为作为该行其余部分中指定的解释器的输入。此功能就是让内核显得可以直接执行 shell 脚本的原因 — 实际上它执行的是一个解释器,并将脚本作为参数传递。另一方面,以 “\x7fELF”开头的文件被识别为 ELF 文件。内核首先查看该文件是否包含 程序标头 中的 PT_INTERP 元素。若存在,则此元素表明该程序是动态链接的。

PT_INTERP 元素还指定程序期望哪个动态链接程序进行链接 — 又称解释器,令人困惑。在 Linux 中,动态链接程序通常存储在特定体系结构的路径下,例如 /lib64/ld-linux-x86-64.so.2. 不允许链接程序本身动态链接,以避免无限嵌套。找到动态链接程序后,内核会以与其他任何静态链接可执行文件相同的 方式 设置其初始地址空间,并给它一个指向要执行的程序的打开的文件描述符。

重定位

动态链接程序的工作是对进程的地址空间进行安排,使其既包含主可执行文件本身,还包含该文件所依赖的所有库。对于现代可执行文件来说,这几乎肯定会加载 位置无关代码(position-independent code),这种代码设计用于从非固定基本地址运行。许多代码段仍然需要知道内存的不同部分(section)的位置,因此位置无关可执行文件包含一份 “重定位(relocations)”列表:动态链接程序需用内存中各种组件的实际地址来修补的二进制文件中特定位置。在大多数程序中,大部分重定位都是对 全局偏移表 (GOT) 的修补,其中包含指向全局变量或已加载的段(section),还有链接时常量(link-time)的指针。

允许动态链接程序将不同的组件加载至不同的地址能够起到几个作用。最早的位置无关代码形式存在于使用基址寄存器访问内存地址的体系结构中,使得一个程序的多个副本能够以不同的地址驻留在内存中。动态地址转换(dynamic address translation)的发明使得该动机变得无关紧要了。现代工具链针对 地址空间布局随机化 (ASLR) 使用了这个灵活功能,允许动态链接程序向所选位置添加一个随机值。静态链接带有位置无关代码的程序(包括动态链接程序本身)都将会加载到内核选择的随机地址上。因此,动态链接程序的第一个主要任务是读取它自己的程序标头并对其本身进行重定位。此过程包括用全局结构和函数的位置,来修补后续代码 — 在进行这些重定位之前,链接器会小心不从其他编译单元调用任何函数或访问任何全局变量。

动态链接器进行重定位后,就可以调用其特定平台的函数来执行特定操作系统的设置。在 Linux 中,它调用 brk() 来设置进程的数据段,包括为其自身状态分配一些空间,并通知 malloc() 将分配放在新分配的空间中(它与程序最终的堆是分开的)。此时, malloc() 引用了一个临时实现,该实现甚至无法释放内存,该实现会在动态链接器识别并对所需依赖项应用重定位时来使用。

完成依赖项识别的第一步是设置链接映射(link map) — 即由 dlinfo() 使用的记录信息结构。完成后,动态链接器会找到内核映射的 vDSO,一个共享对象包括可以在无需切换至内核的情况下为某些系统调用服务的代码,这是一种常用于 gettimeofday() 系统调用的技术。动态链接器将 vDSO 放入链接映射中,以便处理其他依赖项的链接时所使用的同一代码可以处理调用 vDSO 的共享对象。

到此,似乎可以实际读取和链接程序所依赖的共享对象了,但还需要完成一个步骤。用户可以通过在 LD_PRELOAD 环境变量中指定共享对象来在运行时覆盖函数。通过这种方式覆盖函数可以用于许多目的,例如,对应用程序进行调试、使用备用分配器(例如 Boehm-Demers-Weiser 型保守垃圾回收器 或 jemalloc)、或使用诸如 libfaketime 之类的工具来伪造该应用程序的时间和日期。动态链接器首先解析预加载库,这样在以后链接库时,它可以直接向它们发送已覆盖的函数定义。

现在,动态链接器已经具备完成程序地址空间排列所需的一切条件。从正在加载的程序开始,链接器在程序头中查找 DT_NEEDED 声明,这些声明表明程序依赖另一个共享对象。链接器会递归搜索这些 DT_NEEDED 声明,构建程序的任何传递依赖项所需的所有共享对象的列表。 DT_NEEDED 条目可以包含绝对路径,但也可以包含通过查阅 LD_LIBRARY_PATH 环境变量中的目录或一组默认目录来解析的相对路径。然后,动态链接器反向遍历这个依赖项的列表,这样,某一共享对象的依赖项在其加载之前就已经加载好了。

对于每个共享对象,链接器都会打开已解析的文件,将其加载到地址空间中新分配(并使用 ASLR 随机化处理过)的位置,然后执行共享对象头中列出的重定位集。然后,它将共享对象添加到链接映射中。

其中的例外是动态链接器自身。程序允许将动态链接器依赖为库,以提供诸如 dlinfo() 之类的功能,但是如果链接器在循环中间对自己应用重定位,它就会损坏,因此它将自身排除在依赖项列表之外。如上所述,它已经对自己应用了重定位。但是,链接器偶尔会使用可通过预加载库覆盖的函数。因此,一旦程序的所有依赖项都已放入地址空间,链接器就会对自己最后进行一次重定位,以便现在它可以引用其使用的任何函数的已覆盖版本。现在,动态链接器使用的 malloc() 实现从到此为止使用的简化实现切换到程序的其余部分使用的通用实现。

随着所有共享库的加载,动态链接器已经完成了大部分工作。但是,在跳转到主程序之前,它会设置 线程本地存储 (TLS),并执行由 C 库要求的任何初始化。然后,它把内核提供程序的参数状态及其环境恢复出来,并跳转到程序的入口点。

用户代码

人们可能会认为动态链接器的职责在主程序开始时就已结束,但事实并非如此。它不仅必须对运行时需要的 dlopen() 新共享对象的请求进行服务,而且还有一部分工作会在所加载程序的生命周期内运行:更新过程链接表 (PLT, Procedure Linkage Table)。

尽管程序可以简单地使用重定位来直接修补程序正文中的 CALL 指令,但是这样的“文本重定位(text relocation)”会导致两个性能问题。首先,由于所需的重定位数将取决于调用给定函数的次数(可能很大),因此将这些重定位最初应用于共享对象可能较慢。其次,由于文本重定位会弄脏包含程序可执行代码的内存页面,因此运行同一程序的不同进程不再能够共享相同的底层内存,从而增加了程序的内存使用情况。这些性能问题意味着动态链接器的维护者 普遍反对 进行文本重定位。

通过创建一个 PLT — 一个特殊的独立部分,其中包含对每个外部定义函数的间接引用,现代编译器和链接器可以解决这个问题。从程序内部调用这些函数会编译为对 PLT 的调用,然后 PLT 中包含一个跳转到外部函数的真实位置的指令。PLT 也可以直接使用文本重定位,但大多数体系结构改为通过第二个 GOT(从程序的正常 GOT 分离,在其自己称为 “.plt.got” 的 section 中)中存储的函数指针进行间接跳转。对于无法以紧凑方式对跳转到地址空间的任意部分进行编码的体系结构来说,分离函数指针很有用,但对于一项最终提升性能的技巧来说也很有用:延迟链接。

bcc28f3e995527805a50ab257daa645d.webp

与那些指向数据、在程序开始运行之前需要解析的重定位不同(因为动态链接器无法知道何时会访问它们),PLT 的 GOT 中的重定位不必立即应用。动态链接器最初用来自动态链接器自身的一个函数的地址来填充 PLT 的 GOT。此函数查询链接映射以确定有问题的外部符号位于何处,然后重写 PLT 的 GOT 的相应条目。仅对实际调用的外部函数执行链接,而且仅在程序启动后执行。对于大多数程序来说,这可以提高性能并使程序的初始启动速度更快。

可以使用 LD_BIND_NOW 环境变量,或通过使用 “-z now” 链接器选项编译程序来关闭延迟链接。就新发布的 glibc 版本 2.39 来说,glibc 的动态链接器还支持重写 PLT 元素以使用直接跳转而不是间接跳转,用在一些关注性能的系统上。报道 文章中公开宣称,引入 PLT 重写是出于安全动机,但 一位评论者 指出,前两种方法的存在表明 PLT 重写最有用之处在于作为性能调整设置。然而,有一个好处是可以禁用延迟链接:它允许 "迁移只读(Relocation Read-Only, RELRO)",这是一种安全缓解措施,这种情况下动态链接器在填入所有的 GOT(和 PLT 的 GOT)后将其重新映射为只读,从而阻止攻击覆盖它们以控制进程的控制流。

对于大多数程序而言,动态链接器基本上是不可见的,但它在建立每个进程的地址空间方面发挥了至关重要的作用。大多数程序员绝不会需要与它设置程序运行的具体过程打交道。但是,动态链接器明显的稳定性掩盖了其中大量令人意外的复杂性,这是为了确保能够有效准备程序以便执行。

全文完
LWN 文章遵循 CC BY-SA 4.0 许可协议。

欢迎分享、转载及基于现有协议再创作~

长按下面二维码关注,关注 LWN 深度文章以及开源社区的各种新近言论~



浏览 6
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报