7.动态链接和加载
前面了解了静态链接,以及一点点动态链接的东西。
这部分继续动态链接和加载。
1. 动态链接
为了实现库和应用程序分开,一个重要的技术是生成位置无关代码。
比如说一个函数 foo,静态地址为 0x40000000,用 movq $0x40000000, %rax
然后 call *(%rax)
这样调用函数并不是位置无关的。这样操作地址是写死的。
一个可行的方法是外部库函数的调用通过查表来实现。即 call TABLE(x),在运行时填表。
有了查表这个机制的时候,就有这么个情况,一个有趣的问题,编译器遇到函数调用,应该翻译成哪种指令?比如一个 hello.c 会调用 foo 和 printf
如果 foo 来自同一个动态链接库
foo 函数实现在另一个模块 foo.c 中
foo.c 和 hello.c 链接成 a.out
call foo
如果 foo 来自另一个动态链接库
call TABLE(foo)
hello.c 在源代码层次,是分不清是在 foo.c 还是在 foo.so 中的。对于调用 foo.c 中的情况,是不需要查表的,call 的时候直接相对跳转就能跳过去了,foo.so 中的函数,是必须要查表的,在程序刚刚加载的时候,根本不知道 foo 的地址在哪里,.so 可能被加载到任何位置。
如果把所有的调用都翻译成查表,当然可以实现,但是并不好。
C 语言这个语言里,在编译的时候无法区分谁最后会链接到一起,只有链接的时候才知道。从历史的角度看,没有动态链接,没有查表。
这种情况下,发明了 PLT(Procedure Linkage Table) ,比如库函数,我们调用 printf,编译时有 call printf,翻译成 call printf@PLT,然后加一段代码 PLT,在 PLT 里面查表,PLT 里面 jmp *TABLE[printf] ,查的这个表就是 GOT(Global Offset Table)
反汇编的 printf 可以看到翻译成 puts@plt
2. 实现动态加载
3. ELF 动态链接和加载的细节
ELF 和前面的小的 dl 格式无本质区别,但是在工程实践上还有海量的细节。
可执行文件就是状态机一个初始状态的描述,我们想可视化的看到,一定会有这么个工具。即解析这个文件,打印出一些信息给我们查看,即 ldd
工具。ldd 假装加载一个程序,解析依赖的二进制库。
比如 ldd /bin/ls
vdso 不陷入内核的系统调用,pcre2 正则表达式匹配的库,还有 /lib64/ld-linux-x86-64.so.2 这是个加载器,这个程序在依赖的库加载完成之前是不能执行的,所以需要这个加载器来加载依赖库。
下面的内容是真实的更多的细节。
一个问题,execve 执行后初始状态是什么?execve 后的一瞬间,这个程序就开始运行了吗?
用 gdb 调试动态链接的程序,starti 可以看到,马上要执行的是 /lib64/ld-linux-x86-64.so.2
中的 _start()
事实上这是程序的加载器。(了解到这一步,对动态链接和程序加载以及了解的比较好了)
更多的问题,为什么是 /lib64/ld-linux-x86-64.so.2
的,能不能换成自己的?
What are the first a few steps exected after execve() of a ELF dynamic link binary?
How can I compile an ELF binary that use an alternative dynamic loader than the default ld.so?
这部分代码 作为一个 loader,打印一个 loader,这段代码如果能跑起来,那么任何一段 C 语言代码都能跑起来,我们可以实现任何事情。编译成一个 .so。
重新思考 PLT 的设计。为什么要用动态链接,而不是在动态的时候静态链接。即,不要查表,查 PLT,直接跳转。明显好处是变快了,可以少一次间接跳转,但是在初始加载的时候付出了很大代价,如果有几万个跳转,那得把所有跳转都得 patch 一遍,如果对于一个运行时间很短的程序,这个代价是很大的。
所以进一步思考,是否可以设计一种新的二进制格式文件,两种方法都支持,谁快选谁。
如果不那么在乎性能,这种方式的好处是省空间,这点并不显然。如果 libc 有 1MB 的代码,所有的程序都链接 libc,但是整个操作系统里只有一份这个代码,通过虚拟内存的页面共享实现,物理内存里只有一份拷贝。但是如果做了链接,每个跳转的地址都被改成了具体的地址,算来算去,这部分的性能也不是那么重要,就保留了这个设计。
代码的问题解决了,还有数据的问题。
最后更新于