11 - Dynamic linking and loading

11 - 动态链接和加载

可执行文件 (和 Core Dump) 都是描述进程状态的数据结构。对于 ELF 文件来说,其中最重要的部分是一些 PT_LOAD 的数据段,正确在进程地址空间中 (用 mmap) 映射它们,就能实现程序的加载——就像我们在 Funny Little Executable 里做的那样。

本讲内容:当开发者希望把库函数和应用程序 “分离” 开,但又希望库函数可以被调用,应该怎么办?

补课

静态链接库 LIB

静态链接库就像是一个提前打包好的工具箱。当你写程序的时候,有些功能你可能用不到,但有些功能是需要的。比如,你写一个简单的计算器程序,需要加法、减法等基本运算功能。这些功能可能已经被别人写好了,放在一个静态链接库里面。

在你编译(就是把程序代码变成计算机能理解的指令)程序的时候,编译器(就像一个翻译官,把你的程序代码翻译成计算机能懂的语言)会把静态链接库里面你需要的功能代码直接“拷贝”到你的程序里面。这样,当你运行程序的时候,程序里面就已经包含了这些功能代码,不需要再去别的地方找。

动态链接库(DLL)

动态链接库就好比是一个放在外面的共享工具箱。很多程序都可以用这个工具箱里面的工具。比如,很多软件都需要显示字体,字体相关的功能就可以放在一个动态链接库里面。

当你运行程序的时候,程序会去找到这个动态链接库,然后在运行的时候调用里面的功能。就像你去租用一个工具,只有在需要的时候才去用,用完就还回去。

1. 动态链接:机制

熟悉的 .dll 文件

dynamic...lib

1.1. 为什么需要动态链接

struct exec {
    uint32_t  a_midmag;  // Machine ID & Magic
    uint32_t  a_text;    // Text segment size
    uint32_t  a_data;    // Data segment size
    uint32_t  a_bss;     // BSS segment size
    uint32_t  a_syms;    // Symbol table size
    uint32_t  a_entry;   // Entry point
    uint32_t  a_trsize;  // Text reloc table size
    uint32_t  a_drsize;  // Data reloc table size
};
  • d3dx9_xxx.dll 就这样为每个游戏复制了一份

    • 似乎不是个好想法?

    • 我们希望 “拆解” 应用程序

1.2. “拆解”应用程序(1)

root@LAPTOP-GT06V0GS:~# ldd /bin/ls
        linux-vdso.so.1 (0x00007ffe950d8000)
        libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1 (0x00007f829aa58000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f829a846000)
        libpcre2-8.so.0 => /lib/x86_64-linux-gnu/libpcre2-8.so.0 (0x00007f829a7ac000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f829aac1000)
root@LAPTOP-GT06V0GS:~# file /bin/ls
/bin/ls: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=3eca7e3905b37d48cf0a88b576faa7b95cc3097b, for GNU/Linux 3.2.0, stripped
root@LAPTOP-GT06V0GS:~# file /lib/x86_64-linux-gnu/libselinux.so.1
/lib/x86_64-linux-gnu/libselinux.so.1: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, BuildID[sha1]=5735803ede4ce24cdbee915758550e48cdc285ce, stripped

实现运行库和应用代码分离

  • 应用之间的库共享

    • 每个程序都需要 glibc

    • 但系统里只需要一个副本就可以了

      • 是的,我们可以用 ldd 命令查看

      • 运行库和应用代码还可以分别独立升级

  • 大型项目的分解

    • 改一行代码不用重新链接 2GB 的文件

    • libjvm.so, libart.so, ...

      • NEMU: “把 CPU 插上主板”

1.3. “拆解”应用程序(2)

库的依赖也是一种代码克隆

如果 Linux 应用世界是静态链接的……

  • libc 紧急发布安全补丁 → 重新链接所有应用

所有的二进制都需要修改!

libc 是可以无限移植的,如果有 .iso 标准,就可以移植

1.4. “Dependency hell”(依赖地狱)

为了避免动态链接带来的危害:

因为更新是很难考虑到未来的,所以很多时候都是基于当下?

就造成很多依赖一旦更新,就不能被依赖,而这又是递归的...形成一个复杂的网络...

  • 版本冲突:不同依赖项可能需要同一个库的不同版本,导致无法找到一个满足所有依赖的版本。

  • 传递依赖:一个依赖项可能有自己的依赖项,这些传递依赖可能会进一步增加依赖关系的复杂性。

  • 不兼容的更新:当依赖项进行重大版本更新时,可能会引入不兼容的更改,导致现有代码无法正常工作。

  • 循环依赖:依赖关系形成一个闭环,例如 A 依赖 B,B 依赖 C,而 C 又依赖 A,这种情况下很难解决依赖问题。

anyway...还是要感谢动态链接库,不然如果只有静态链接库,这个世界早就崩塌了...

2. mmap 和虚拟内存

2.1. 一个有趣的实验

程序

  • 构造一个非常大的 libbloat.so

    • 我们的例子:100M of nop (0x90)

    • .so 文件是 Shared Object(共享对象) 文件的扩展名,类似于 Windows 系统中的 .dll 文件。它是一种动态链接库文件

实验

  • 创建 1,000 个进程动态链接 libbloat.so 的进程

  • 观察系统的内存占用情况

    • 100MB or 100GB?

    • (如果是后者,直播会立即翻车)

      • Prototypes are easy. Production is hard. (Elon Musk)

共享动态链接库的实验...

在实际的生产环境中,动态链接库的共享机制非常重要。如果共享机制失效,可能导致系统资源浪费,甚至引发系统崩溃

2.2. 共享库的加载

计算机世界里没有魔法

  • 你总能溯源到正确的行为

  • a.out (动态链接) 的第一条指令不在程序里

    • 可以 starti 验证 (在 ld.so 里)

    • 这是 “写死” 在 ELF 文件的 INTERP (interpreter) 段里的

      • (我们甚至可以直接编辑它)

  • a.out 执行时,libc 还没有加载

    • 可以 info proc mappings (pmap) 验证

  • libc 是用 mmap 系统调用加载的

    • 可以 strace 验证

  • 只读方式 mmap 同一个文件,物理内存中只有一个副本

2.3. 背后的机制:虚拟内存管理

地址空间表面是 “若干连续的内存段”

  • 通过 mmap/munmap/mprotect 维护

  • 实际是分页机制维护的 “幻象”

2.4. 操作系统的 Tricks(1)

Virtual Memory

  • 操作系统维护 “memory mappings” 的数据结构

    • 这个数据结构很紧凑 (“哪一段映射到哪里了”)

  • 延迟加载

    • 不到万不得已,不给进程分配内存

  • 写时复制 (Copy-on-Write)

    • fork() 时,父子进程先只读共享全部地址空间

      • Page fault 时,写者复制一份

Best 假设???假设大家一开始都不会去改变

fork() 's tricks...

2.5. 操作系统的 Tricks(2)

Memory Deduplication; Compression & Swapping

  • 反正都是虚拟内存了

    • 悄悄扫描内存

      • 如果有重复的 read-only pages,合并

相当于先前的副本又删除,变成一份 共享...

      • (如果硬件提供 page hash 就更好了)

    • 悄悄扫描内存

      • 发现 cold pages,可以压缩/swap 到硬盘

      • (硬件提供了 Access/Dirty bit)

    • 我们还能悄悄扫描内存做什么?

      • 看看 AIGC 的答案吧!...

3. 实现动态链接

3.1. 实现应用程序的拆解

方案 1: libc.o

  • 在加载时完成重定位

    • 加载 = 静态链接

    • 省了磁盘空间,但没省内存

    • 致命缺点:时间 (链接需要解析很多不会用到的符号)

方案 2: libc.so (shared object)

  • 编译器生成位置无关代码

    • 加载 = mmap

    • 但函数调用时需要额外一次查表

  • 这才对:映射同一个 libc.so,内存中只需要一个副本

3.2. 动态加载:A Layer of Indirection

重定向...

编译时,动态链接库调用 = 查表

call  *TABLE[printf@symtab]

链接时,收集所有符号,“生成” 符号信息和相关代码:

#define foo@symtab     1
#define printf@symtab  2
...

void *TABLE[N_SYMBOLS];

void load(struct loader *ld) {
    TABLE[foo@symtab] = ld->resolve("foo");
    TABLE[foo@printf] = ld->resolve("printf");
    ...
}

3.3. dlbox:再次“实现”binutils

编译和链接

  • 偷 GNU 工具链就行

    • ld = objcopy (偷来的)

    • as = GNU as (偷来的)

解析和加载

  • 剩下的就需要自己动手了

    • readdl (readelf)

    • objdump

    • 同样可以山寨 addr2line, nm, objcopy, ...

  • 加载器就是 ELF 中的 “INTERP”

root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec11/dlbox# ./dlbox interp main.dl
Hello 1
Hello 2
Hello 3
Hello 4

知识点:

  1. 动态链接(Dynamic Linking):在程序运行时,动态链接器(Dynamic Linker)会将可执行文件和共享库(如 .so 文件)链接在一起。这种链接方式允许程序在运行时加载所需的库,而不是在编译时将所有代码嵌入到可执行文件中。

  2. 加载器(Loader):加载器负责将程序和所需的共享库加载到内存中,并解析符号(如函数和变量的地址)。

  3. 符号解析(Symbol Resolution):动态链接器需要解析程序中引用的符号,找到这些符号在共享库中的实际地址。dlbox 的 interp 命令可能实现了简单的符号解析功能。

  4. interp 是一个命令:用于解释执行动态链接文件(如 main.dl)。它的主要作用是模拟动态链接器和加载器的行为,解析和执行文件中的指令。

3.4. 我们实现了什么?

我们 “发明” 了 GOT (Global Offset Table)!

  • 对于每个需要动态解析的符号,GOT 中都有一个位置

  • ELF: Relocation section “.rela.dyn”

  • objdump 查看 3fe0 这个 offset ,位于 “GOT”:

    • printf("%p\n", printf); 看到的不是真正的 printf

    • *(void **)(base + 0x3fe0) 才是

    • 我们可以设置一个 “read watch point”,看看谁读了它

3.5. 动态链接的主要功能

实现代码的动态链接和加载

  • main (.o) 调用 printf (.so)

  • main (.o) 调用 foo (.o)

难题:怎么决定到底要不要查表?

int printf(const char *, ...);
void foo();
  • 是在同一个二进制文件 (链接时确定)?还是在库中 (运行时加载)?

3.6. 历史遗留问题:先编译 后链接

编译器的选择 1: 全部查表跳转

  • 调用个 foo 都多查一次表,性能我不能忍

编译器的选择 2: 全部直接跳转

  • %rip: 00005559892b7000

  • libc.so.6: 00007fdcdf800000

    • 相差了 2a8356549000

    • 4-byte 立即数放不下,无论如何也跳不过去

3.7. 还能怎么办

为了性能,“全部直接跳转” 是唯一选择

e8 00 00 00 00      call <reloc>
  • 如果这个符号在链接时发现是 printf (来自动态加载),就在 a.out 里 “合成” 一段小代码

printf@plt:
    jmp *PRINTF_OFFSET(%rip)
  • 我们发明了 PLT (Procedure Linkage Table)!

3.8. PLT:没能解决数据的问题

对于 extern int x,我们不能 “间接跳转”!

  • x = 1, 同一个 .so (或 executable)

mov $1, offset_of_x(%rip)
  • x = 1, 另一个 .so

mov GOT[x], %rdi	// 必须要查一次表?
mov $1, (%rdi)

-fPIC...性能会变得很糟糕...

不优雅的解决方法

  • -fPIC 默认会为所有 extern 数据增加一层间接访问

    • __attribute__((visibility("hidden")))

3.9. dsym

dsym 项目是一个关于动态链接库(.so 文件)的实验,用于观察多个共享库之间的数据共享行为。

root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec11/dsym# ./main
liba: x = 1
main: &stderr = 0x7f0fa16786a0
main: &x      = 0x7f0fa16a4010
B   : &stderr = 0x7f0fa16786a0
B   : &x      = 0x7f0fa16a4010

动态链接库(Dynamic Shared Libraries)

  • 共享库的概念:共享库(如 .so 文件)允许多个程序共享同一份代码和数据,节省内存和磁盘空间。

  • 动态链接:动态链接是指在程序运行时,由动态链接器(如 ld.so)将程序和共享库链接在一起。

  • 符号解析:动态链接器需要解析程序中引用的符号(如函数和变量),并将其映射到共享库中的实际地址。

全局变量的共享

  • 全局变量的地址:输出显示了全局变量 x 和标准错误输出 stderr 的地址。这些地址在不同的模块(如 main 和 libb)中是相同的,表明它们是共享的。

  • 数据共享:通过共享库,多个模块可以访问和修改同一个全局变量。这在多模块系统中非常有用,但也需要注意线程安全和并发问题。

内存布局和地址空间

  • 内存布局:输出显示了 stderr 和 x 的内存地址。这些地址表明程序和共享库在内存中的布局。

  • 虚拟内存:操作系统使用虚拟内存技术,将物理内存映射到进程的虚拟地址空间。每个进程都有自己的虚拟地址空间,动态链接器负责将共享库映射到这个空间中。

运行时符号解析

  • 符号解析机制:动态链接器在运行时解析符号,将程序中引用的符号映射到共享库中的实际地址。这个过程涉及到符号表的查找和地址映射。

  • 延迟绑定:某些符号解析可能在首次使用时才进行,这种机制称为延迟绑定(Lazy Binding)。这可以提高程序的启动速度。

export LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH是一条在 Linux 系统中设置环境变量的命令,具体来说,它用于设置 LD_LIBRARY_PATH环境变量。这条命令的作用是将当前目录(.)添加到动态链接器(ld.so)查找共享库(.so文件)的路径中。

3.10. LD_PRELOAD

动态链接的便捷机制...

自适应环境,调整行为

计算机的世界里没有黑魔法...

一个神奇的 “hook” 机制

  • 允许 “preload” 一个自己的库

    • 当然,没有魔法

    • LD_PRELOAD 会传递给 ld-linux.so

    • 这是一个环境变量...

可以改变库函数变成自己的函数👇

  • 我们可以在运行时,用一个自己的库替换掉某个库

LD_PRELOAD=./mylib.so ./a.out

LD_PRELOAD是一个环境变量,用于指定一个或多个动态链接库( .so 文件),这些库会在程序启动时被优先加载。加载的库中的函数会优先于标准库中的同名函数被调用,从而实现对程序行为的拦截和修改。

root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec11/ldpreload# ./test_program
Testing malloc/free hooks...
Allocated 100 bytes at 0x55ba218c86b0
Allocated 200 bytes at 0x55ba218c8720
Allocated 300 bytes at 0x55ba218c87f0
  • 动态加载:在程序启动时,动态链接器会先加载 LD_PRELOAD 指定的库。

  • 函数拦截:如果库中定义了与标准库中同名的函数,这些函数会被优先调用,从而实现对标准函数的拦截。

  • 符号解析:动态链接器会解析符号,将程序中的函数调用映射到 LD_PRELOAD 库中的函数。

4. 总结

Take-away Messages: 找到正确的思路,我们就能在复杂的机制中找到主干:在动态链接的例子里,我们试着自己实现动态链接和加载——在这个过程中,我们 “发明” 了 ELF 中的重要概念,例如 Global Offset Table, Procedure Linkage Table 等。

Last updated