程序的地址分配
一段程序从本质上讲就是一堆变量和函数,它们的名字(变量名/函数名)本质是一个个的符号,用来指代自己让外部引用。但这种符号 CPU 是不认识的,它只认得内存地址。因此,一段程序要运行,需要将这些方便人理解的符号转化为 CPU 所能理解的内存地址。这个工作落在了链接器的头上。
静态链接
要将文本语言写的程序跑起来,需要先进行编译,这个过程包括预处理、词法/语法/语义分析、IR 生成/代码优化、目标代码生成等。一般程序都包含多个文件,每个文件都是一个编译单元,编译后变为一个目标文件,此时文件里的变量和函数都还没分配地址,引用这些符号的地方暂时先用 0 填充,并在重定位表中做好记录。
接下来该链接器登场了。链接器会遍历所有的目标文件,将各个文件的对应段(text、data、bss 等)进行整合,同时将所有文件中的符号汇聚成一张全局符号表,有了它就可以为所有的符号分配地址了。但不同类型的符号处理方式有所不同。
首先,局部变量的地址在编译阶段就已经确定了(运行时在栈桢中分配,偏移顺序编译时已确定),不需要链接器操心。其次,由于同一编译单元的代码段中的各个函数之间的相对位置是固定的,因此静态函数可采用相对地址方式访问,这个地址也是在编译阶段就已确定,因此也不需要重定位。其余的包括全局变量、静态变量、外部变量、全局函数、外部函数,这些符号的重定位方式都一样,都是根据链接器给它们分配的实际地址和它们的引用所在的地址算出一个地址后回填到引用所在的位置中。
动态链接
静态链接的缺点是:如果有多个程序依赖同一个目标文件,该目标文件需要链接进多个可执行文件中去,同一份代码会在内存中出现多次,这对于宝贵的内存来说是一种极大浪费,故而我们需要一种在内存中共享代码的技术,动态链接技术顺势诞生。
但是,多个进程要依赖同一份代码,而每个进程映射这份共享代码的地址空间可能是不同的。这种情况下,当共享代码要调用另一份共享代码的某个函数时,这个函数地址对于不同进程来说可能就是不一样的,这时问题来了:同一条 call 指令,自然不能对应多个指向地址。
文字有些抽象,画个图就清楚了:
怎么解决呢?
答案是:PIC(Position Independent Code),即地址无关代码技术。多说一句,PIC 技术是为了解决在代码共享情况下,多个共享代码之间存在相互调用导致的地址分配问题。也就是说,当不需要共享代码或只有一份共享代码时是可以不用 PIC 的。
所谓计算机世界里没有加一层抽象解决不了的问题。
PIC 的实现思路是这样的:既然代码是共享的,同一条指令对应的地址只能是同一个,那就引入一个固定地址,然后让不同进程可以往这个固定地址里写入各自映射的真实地址。这个固定地址其实就是 GOT(全局偏移表),而且它是存在数据段里面。即代码段共享,数据段私有(每个进程在物理内存中都各自维护一份)。
其中的关键信息是:GOT 只存在于共享库代码中,每个共享库有自己的 GOT 表(若无外部依赖则没有)。它位于数据段中,当被加载到内存时,在空间布局上与代码段的相对位置是固定的(共享库各个段不和进程的对应段合并)。
此外,由于共享库代码是在运行时共享,意味着程序还在磁盘趴着的时候,对应的符号是没做链接的,这个动作得延迟到程序加载时进行(往 GOT 对应项写入符号分配的真实地址),这便是动态链接名字的来源。
延迟绑定
动态链接把符号链接延迟到加载时进行,随之带来的一个问题是:在加载时要对所有动态库的 GOT 符号进行解析重定位,降低了加载性能。那系统再懒点,改为符号不到使用那一刻绝不解析行不行呢?答案显然是可以的,那便是延迟绑定技术。
虚拟机中有一种简单的延迟绑定技术叫 patch code,它是在符号引用那里写入虚拟机内部的一个符号解析函数的地址,当该符号被调用时,解析函数被调用,虚拟机会加载对应的方法,此时方法地址确定,然后将它写入到对应位置。这个方法需要在运行时修改指令数据,容易产生安全问题。
延迟绑定技术选择继续使用 GOT,不需要修改指令代码。理想情况下,延迟绑定也可以把 GOT 表中待解析符号的地方写入符号解析函数的地址,但问题是,符号解析函数需要知道共享库 ID 和待解析符号 ID 两个参数,怎么传递给它呢?为此,系统又加了一层过程链接表(Procedure Linkage Table, PLT,存储在.plt 段中,是可执行代码),将符号解析变成 3 级跳。如下图:
got 表前 3 项固定,其中第 2 项为共享库 id,第 3 项为符号解析函数地址(这 2 项都是动态链接器在装载共享模块时初始化),从第 4 项开始则为对应的符号所在地址。
我们具体分析下图中的重定位过程:
首先符号引用地方不再是 got 对应项地址了,而是具体符号 @plt 函数地址(记为 plt[x]),图中为 B@plt。
B@plt 函数第 1 条指令跳到 got 第 4 项对应地址中(该项便是存放符号的真实地址的地方),此时该项填的值为 B@plt 函数中的第 2 条指令地址,也就是此时执行 B@plt 函数的第 2 条指令,往栈中压入参数 0(got 前 3 项已被占用,此时 0 代表的是 got 中的第 4 项,即图中的 0x18)。
接着下一条指令执行,跳转到<.plt>样本代码中(记为 plt[0])。
<.plt>代码先是把 got 第 2 项(即共享库 id)入栈,然后跳到符号解析函数那里。符号解析函数从栈中拿到符号 id 和共享库 id,便可定位到对应共享库的地址,并往对应的 got 项(即第 4 项)写入该地址。此时符号解析重定位完成,后续经 B@plt 函数第 1 条指令跳转 1 次即可定位到符号的真实地址,3 跳变回 2 跳。
总结
程序的地址分配由链接器完成,而分配时机有三种:编译期、加载期、运行时。当然还有其他 2 种非链接器分配的方式:程序直接指定(危险操作,一般不用)、通过系统调用函数动态分配内存。
值得注意的是,本文提到的分配地址,都是指虚拟地址。而且共享库(包括动态链接器自身)的地址空间都是通过私有文件映射(mmap)的方式动态分配的。
最后记住一点:没有加一层抽象解决不了的问题,如果有,那就再加一层。
参考资料:
1.极客时间专栏《编程高手必学的内存知识》相应章节;
2.程序员的自我修养-链接、装载与库。
也欢迎关注我的公众号(搜索:Make IT Simple),一起学习成长。
▲ 欢迎关注“Make IT Simple”,一起学习成长
版权声明: 本文为 InfoQ 作者【榕】的原创文章。
原文链接:【http://xie.infoq.cn/article/66444fb868c14bd9e6fc05731】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论