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)
库的依赖也是一种代码克隆
震惊世界的 xz-utils (liblzma) 投毒事件
JiaT75 甚至悄悄绕开了 oss-fuzz
JiaT75 从 2021 年开始参与开源项目,逐渐在社区中建立信任。
2022 年 4 月,JiaT75 向 xz 项目提交了看似无害的补丁
Lasse Collin 最终同意让 JiaT75 成为项目的维护者
security...research...向 Linux 开源代码投毒是可行的?
如果 Linux 应用世界是静态链接的……
libc 紧急发布安全补丁 → 重新链接所有应用
所有的二进制都需要修改!
“Compatible” 是个有些微妙的定义
“Dependency hell”
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
知识点:
动态链接(Dynamic Linking):在程序运行时,动态链接器(Dynamic Linker)会将可执行文件和共享库(如 .so 文件)链接在一起。这种链接方式允许程序在运行时加载所需的库,而不是在编译时将所有代码嵌入到可执行文件中。
加载器(Loader):加载器负责将程序和所需的共享库加载到内存中,并解析符号(如函数和变量的地址)。
符号解析(Symbol Resolution):动态链接器需要解析程序中引用的符号,找到这些符号在共享库中的实际地址。dlbox 的 interp 命令可能实现了简单的符号解析功能。
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