前言:上一篇介绍了动静态库的制作,也简单认识了 ELF 文件。接着这篇接看看函数调用是怎样链接起来的,磁盘上的程序怎样运行起来,以及动态库为什么能被多个进程共享。
1.静态链接是怎么完成的
1.1链接前的目标文件
还是用上篇简单的程序,main 调用另一个文件中的 my_string,再把返回的字符串打印出来。
head.h:
c
#pragma once
#include <stdio.h>
const char* my_string();
code.c:
c
#include "head.h"
const char* my_string()
{
return "hello Linux!\0";
}
main.c:
c
#include "head.h"
int main()
{
printf("%s\n", my_string());
return 0;
}
先看链接之前的 main.o:
bash
objdump -d main.o

可以看到两条 call,但后面用于跳转的位移还是占位值。编译 main.c 时,编译器知道要调用 my_string,却还不知道它在最终程序中会被安排到哪里。
于是先生成调用指令,同时记下:这个位置以后需要根据 my_string 的位置进行修正。 这些待处理的位置和对应的符号,记录在重定位信息中。
再看看符号表:
bash
readelf -s main.o

main 在这个文件中有定义,而 my_string 和 puts 的 Ndx 是 UND,表示当前目标文件里没有它们的定义,需要到其他地方寻找。
在源码中我们写的是 printf,这里出现 puts,是因为编译器把这种输出字符串再换行的写法改成了 puts 调用。
1.2找到函数,再把调用位置补好
多个目标文件各自都有代码和数据,链接器要先把需要的内容组织到一起。

像图中这样,把各个目标文件里相同用途的节组织到输出文件中,并为它们安排位置。代码放在哪里、数据放在哪里,都需要在这一步确定下来;.bss 则记录运行时需要的空间,不用在磁盘文件里放上一大块零。
接着就能把引用和定义对应起来:main.o 需要 my_string,code.o 正好提供了这个函数,链接器就把两者联系起来。这个过程叫符号解析。
位置确定以后,再根据重定位信息修正相应的引用,让调用能够到达目标函数,这就是重定位。这也是为什么 .o 是可重定向文件的一种。
查看链接后的符号表:
bash
readelf -s main.exe

这份程序中,my_string 的地址已经是 0x40052d。再查看反汇编:
bash
objdump -d main.exe

main 中对应的 call 已经指向 0x40052d <my_string>。这里的指令保存的是相对位移,CPU 从下一条指令的位置算出要跳到哪里。也就是说,链接器已经把原来空着的跳转信息补好了。
图中的地址对应这一份可执行文件,重新编译或调整链接顺序后,具体数值可能不同,观察它们之间的关系就行。
这样,my_string 的代码已经放进了可执行文件,运行时不需要再去打开 libmydio.a。不过图里的 puts 仍然通过 puts@plt 调用,它使用的是动态库,后面再接着看这部分。
2.程序加载进内容的过程
2.1程序还在磁盘上,为什么就有地址了
刚才还没运行程序,就已经能看到 my_string 的地址。这个地址来自链接时对程序布局的安排。
程序里的虚拟地址可以提前安排,实际使用哪块物理内存,要由操作系统在运行时管理。
下面寻址的核心概念:
| 名称 | 表示什么 |
|---|---|
| 文件偏移 | 某段内容在磁盘文件中,距离文件开头有多少字节 |
| 虚拟地址 | 程序运行时,在自己的地址空间里使用的地址 |
| 物理地址 | 内容实际落在物理内存的什么位置 |
上面的图中的 0x40052d 是给函数安排的虚拟地址。程序运行时,只要把相应的虚拟地址映射到存放函数代码的物理页,就可以找到这些指令。
这是具体的过程抽象图:

2.2建立映射,然后开始执行
执行程序时,exec 会让内核读取 ELF 文件,根据其中的信息建立新程序的内存布局。前面介绍的程序头表就在这里派上用场。
它描述了哪些部分需要加载、内容在文件里的位置、应该放到什么虚拟地址,以及是否可读、可写、可执行。内核主要按其中的可加载段,也就是 PT_LOAD,建立映射;像 .bss 需要的额外空间,则在内存中准备为零。

图中用 libc.so 举例。程序文件和动态库都可以通过这种文件映射,与进程的地址空间联系起来。
对于文件映射,还能沿着文件对象等信息找到背后的磁盘文件。这样,操作系统就知道某个虚拟地址范围,对应文件中的哪一部分内容。
加载时可以先把这些关系建立好,等程序访问到某个尚未准备好的页面,再通过缺页处理把所需内容准备进来。程序很大,也不必一开始就把整个文件全部读进物理内存。
真正执行指令、访问数据时,由 CPU 的 MMU 按页表完成虚拟地址到物理地址的转换。
内存布局准备好以后,还要确定从哪条指令开始执行。
ELF 文件头里记录了入口地址,可以用 readelf -h main.exe 查看 Entry point address。本例的入口是 0x400440,对应 _start;随后由启动代码进入我们写的 main。
对于本例这样的动态链接程序,中间还有动态链接器参与。启动过程可以顺着下面看:
- 内核读取可执行文件,建立所需的映射、栈等运行环境。
- 根据 ELF 中
PT_INTERP指定的路径,准备并先运行动态链接器。本例使用/lib64/ld-linux-x86-64.so.2。 - 动态链接器找到程序依赖的共享库,建立映射,完成启动所需的地址修正。
- 把执行权交给程序入口
_start,再由启动代码进入main。
这里内核和动态链接器共同完成了启动准备,程序开始执行自己的代码时,所需的库已经能在它的地址空间里访问了。
3.动态库怎样被加载和调用
3.1多个进程怎样共享一份库
先看看这个程序依赖哪些动态库:
bash
ldd main.exe

自己的 my_string 已经静态链接进程序,但输出功能仍然需要 libc.so.6。所以,使用了一个静态库,不代表整个程序都是静态链接的。
动态链接时,生成可执行文件的阶段会留下所需库、符号等信息,并安排好相关的调用入口。等运行时库的加载位置确定下来,再把需要的地址补好。
动态库本身也是 ELF 文件,动态链接器可以读取它的信息,把其中的代码和数据映射到进程的地址空间。程序通过虚拟地址,就能访问库中的内容。
如果好几个进程都要使用同一个库,代码没有必要在物理内存中各放一份。

像图中这样,进程 A 和进程 B 各自维护页表,却可以把相应的虚拟地址映射到同一批物理页。这样,两个进程都能执行库中的代码。
共享的主要是只读代码等内容;各个进程需要修改的普通数据,仍然各自保持独立。 比如库里有一个可写的全局变量,一个进程修改它,不会顺带改掉另一个进程的那份值。
两个进程也不要求把库放在相同的虚拟地址。页表把它们分别联系到同一份物理代码页,就能达到共享的效果。
3.2通过 GOT 和 PLT 找到函数
库已经映射进来了,接下来就是找到要调用的函数。
对于库中一个普通的已定义函数,可以先按下面的关系理解它的位置:
函数运行时的虚拟地址 = 库的加载基址 + 函数在库中的相对位置。
动态链接器根据符号名找到函数的定义,再结合实际加载位置,得到运行时的虚拟地址。
如果每个进程都把这些可能不同的地址改进库的代码里,共享同一份代码就不方便了。因此,需要把可以共同使用的代码 和运行时才确定的地址分开处理。
这就用到了 GOT,Global Offset Table,全局偏移表。可以把它看成一张存放地址的表,需要在运行时确定的目标地址,放到对应表项中。代码通过相对寻址等方式找到这张表,再从表里取出目标地址。

因为代码区是只读的,所以需要这个表来更新并查找动态库里面的方法地址:

这样,地址变化时主要更新相关表项,代码就能够适应不同的加载位置。这也是前面制作动态库时使用 -fPIC,生成位置无关代码的作用。各个进程有自己的地址表和可写数据,同时共享库的代码。

库和库之间也会相互调用,的 liba.so 也可以调用 libb.so 里的函数:在自己的 GOT 表项里记录目标函数的地址,再通过这个地址找到对方。可执行程序调用动态库、动态库调用另一个动态库,都有这样的联系。
再回头看前面反汇编中的 puts@plt,这里的 PLT,Procedure Linkage Table,过程链接表,可以理解成一组负责跳转的短代码。
在这种调用方式中,main 先调用 puts 对应的 PLT 入口,PLT 再读取 GOT 中的地址,跳到真正的函数。
如果采用延迟绑定 ,还可以把某个外部函数的地址解析,留到第一次调用它时再做。以 puts 为例:
- 第一次调用 :进入 PLT,读取 GOT;此时表项会引向解析流程,由动态链接器找到
puts,把得到的地址写入对应表项,然后执行函数。 - 后续调用:仍然经过 PLT 和 GOT,但表项已经保存了目标地址,可以直接跳过去,不用再重复查找。
所以,PLT 提供跳转入口,GOT 保存跳转所需的地址,动态链接器负责找到真正的目标。这个过程可以对照 x64 PLT 的说明 来看。
完