目录
一、如何理解链接与加载
链接与加载
- 链接是在程序编译阶段,由链接器将多个目标文件、依赖库进行符号解析与地址重定位,合并代码段、数据段等节区,为程序中的指令、变量、函数分配固定的虚拟地址,并生成具有规范格式的可执行文件(如 ELF)的过程。链接完成后,可执行文件中已包含完整的段布局信息与虚拟地址规划,存储于磁盘中,等待运行。
- 加载是用户启动程序(如双击执行)时,操作系统内核读取磁盘上的可执行文件,解析其程序头表,根据文件中预设的虚拟地址,为程序创建独立进程,初始化 task_struct 与 mm_struct 结构,建立虚拟内存区域与页表映射,将文件中的各个段按指定地址映射到进程的虚拟地址空间。首次加载需从磁盘读取文件并完成内存布局初始化,耗时相对较长;后续再次启动时,系统会利用文件缓存机制直接从内存读取数据,无需重复访问磁盘,加载速度显著提升。加载完成后,CPU 中的程序计数器 EIP/RIP 指向程序入口虚拟地址,通过 MMU 结合 CR3 寄存器查询页表,将虚拟地址翻译为物理地址,实现对物理内存的访问,程序正式进入执行阶段。
链接器与加载器
- 链接器(Linker): 链接器是编译过程中的核心系统工具,属于编译链的一部分。它负责将编译器生成的多个目标文件(.o)以及静态库、动态库等依赖文件进行符号解析、地址重定位、段合并,为程序中的代码、数据分配确定的虚拟地址,并最终生成格式规范、可被操作系统识别的可执行文件(如 ELF 格式)。链接器工作在程序运行之前,输出结果是存储在磁盘上的可执行文件,不涉及内存执行与进程创建。
- 加载器(Loader): 加载器是操作系统内核中的一段程序逻辑,属于操作系统的内存管理与进程管理组件。当用户启动程序时,加载器负责解析磁盘上的可执行文件格式,读取程序头表中的段信息与虚拟地址布局,为程序创建独立进程,初始化 PCB(task_struct)与虚拟地址空间结构体(mm_struct),建立页表映射,并将可执行文件中的代码段、数据段等按指定虚拟地址映射到进程地址空间,完成程序从磁盘到内存的装载过程,使程序具备被 CPU 执行的条件。
二、理解静态链接
我们都知道静态链接的本质是把库中的代码拷贝到我们的可执行文件中。
无论是自己的 .o ,还是静态库中的 .o ,本质都是把 .o 文件进行连接的过程,所以:研究静态链接,本质就是研究 .o 文件是如何链接的。


我们分别在 main.c 文件和 run.c 文件中写了些代码,其中在 run.c 文件中写了 run 函数的定义,在 main.c 中调用这个 run 函数,下面我们先将这两个文件编译成 .o 文件,然后使用反汇编进行查看:


观察上面两张图片,现在我有几个问题:
1. call 是干啥的?它指的是具体函数吗?
- call 是 x86-64 汇编里的函数调用指令,作用是跳转到目标函数执行,执行完后再回到当前位置继续往下走。在 .o 文件里,它还不知道具体函数地址,第一个 main.o 文件中,前两个 call 指令代表分别要调用main.c文件中的 printf 和 run 函数,同样地址是占位符。run.o 文件中的 call 则表示要调用run.c文件中的 printf 函数。
2. call 指令前面的一堆 00 00 00 00 是什么?
- 这些 00000000 并不是真实的地址,而是占位地址,也就是说在不是真实地址之前,这些0只是有占位的作用,并不是 printf 和 run 函数的真实地址,链接器会在最后一步将其替换为真实地址。这是 .o 文件的典型特征:没有虚拟地址,只有展位地址,等链接后才会变成 0x400440 这种固定虚拟地址。
3. 没链接前,main.o 和 run.o 里的函数互相认识吗?
- 完全不认识,它们是"互相看不见"的状态。main.o 里只知道"我要调用一个叫 run 的函数",但不知道 run 在哪、长什么样。run.o 里也不知道自己会被谁调用,只知道"我要调用 printf ",同时也不知道 printf 在哪。它们只保留符号名(run 、 printf),等待链接器去"牵线搭桥"。也就是说在未链接前,不同 .o 文件里的函数互不认识,只知道对方的符号名,所有地址都未确定;只有经过链接器的符号解析和重定位,才能变成可执行文件里的真实虚拟地址,让函数互相找到对方。
- 这里突然想起来上一篇文章中的一些疑问,我们都知道可执行程序和 .o 文件都是ELF格式的文件,但是它们用反汇编查看完全不一样:

- 可执行程序(如 a.out)已经完成了链接,链接器为所有指令、函数已经分配了固定的虚拟地址(逻辑地址),比如 0x400440 这种。反汇编时看到的地址就是进程运行时真正使用的虚拟地址,call 指令里也已经是目标函数的真实地址。
- 而 .o 目标文件还没进行链接,所以没有任何虚拟地址,所有地址都是从 0 开始的相对偏移,只表示指令在函数/节内部的位置。 call 指令里的机器码是 e8 00 00 00 00 ,后面的 00 00 00 00 是地址占位符,等待链接器在链接阶段替换成目标函数的真实虚拟地址。我们要弄清这两者的区别。
为了进一步弄清楚链接阶段是如何将全0的占位地址变成可执行文件里的真实虚拟地址,我们继续使用 readelf -s 命令读取一下这两个 ELF 文件中的符号表(Symbol Table),也就是文件里所有符号(函数、变量、段等)的详细信息,包括符号的地址、类型、作用域、所在位置等。

这是 main.c 编译成 main.o 后的符号表,第一个核心符号是第四行的 main 函数,类型是 FUNC (函数),全局可见,位于 .text 段(Ndx=1),是 main() 函数的符号。第二个核心符号是下面的puts,类型是 NOTYPE,全局可见, Ndx=UND (未定义),对应代码里的 printf("hello world!\n") (编译器优化后,printf 被替换成了 puts)。第三个核心符号是 run ,类型是 NOTYPE ,全局可见,Ndx=UND (未定义),对应代码里的 run(); 函数的调用。

这是 run.c 编译成 run.o 后的符号表,第一个核心符号是 run ,类型是FUNC(函数),全局可见,位于 .text 段(Ndx=1),是 run() 函数的实现符号。第二个核心符号是 puts ,类型也是NOTYPE,全局可见,Ndx=UND(未定义),对应代码0里的 printf("running...\n") (同样被优化为 puts)。
问题:
1. NOTYPE 是什么意思?为什么会是 NOTYPE ?
- 在 ELF 符号表的 Type 列里, NOTYPE 表示这个符号的类型未被明确指定,通常用于未定义的外部符号。比如 puts 和 run(在 main.o )里,它们在当前 .o 文件里只有调用、没有实现,编译器不知道它到底是函数、变量还是其他,所以标记为 NOTYPE,等链接时再确定具体类型。但是比如 main 和 run,它们在当前 .o 文件里有完整实现,所以类型是 FUNC,表示"这是一个函数"。
- 当我们在 main.c 里写 run(); 时,编译器只知道"我要调用一个叫 run 的东西",但看不到 run 的实现,所以无法确定它是函数、变量还是宏,只能先标记为 NOTYPE。链接时,链接器才会找到 run.o 里 run 的实现(类型是 FUNC),然后把 main.o 里 run 的 NOTYPE 修正为 FUNC,并绑定到真实地址。
2. UND 是什么意思?
- UND 是 Ndx (Section Index)列的特殊取值,全称是Undefined(未定义)。它表示这个符号在当前 .o 文件里只有引用、没有实现,需要在链接阶段去其他文件或库中找到它的定义。main.o 里的 run 和 puts 都是 UND,run 是在 run.o 里实现的,main.o 只调用它,所以未定义。puts(即 printf)是在 C 标准库(libc.so)里实现的,main.o 只调用它,所以未定义。run.o 里的 puts 也是 UND,同样是调用了 C 标准库的 puts,自己没有实现。
3. Value 列里的 0000000000000000 是什么意思?
- Value 列里的 0000000000000000 和之前看到的 call 机器码里的 00 00 00 00 本质是一样的,表示没有实际地址,所以固定填 0,表示这个符号还没有位置,等链接再分配。
弄清楚这些概念之后,我们下一步就将这两个 .o 文件链接成可执行程序,然后再安装上面的指令观察是否有变化:

上图是动态链接后生成的可执行文件(链接了 main.o 和 run.o)的符号表,分为两个关键表:第一个表是 .dynsym 动态符号表,专门存放动态链接相关符号(主要是未定义、需要从动态库加载的符号)。第二个表是 .symtab 完整符号表,包含所有符号(静态/动态、定义/未定义)。我们可以看到 run 和 main 函数的类型已经由 UND 变为了 FUNC 并且 Value 对应的地址值也从全0变成了有效地址 ,这也说明表示 run 函数和 main 函数已被链接到可执行文件,分配了固定虚拟地址,不再是 UND 。
但是为什么 puts 还是 UND ?
- 核心原因是 puts 是动态链接符号,实现代码在系统的 libc.so 动态库中,没有被复制到可执行文件里。可执行文件只保留对 puts 的引用,标记为 UND(未定义)。程序运行时,动态链接器(ld-linux.so)会加载 libc.so,找到 puts 的真实内存地址,完成地址绑定,让程序能正常调用。其他 UND 符号,如 __libc_start_main 、_end 等,均为系统或动态库符号,也是同理。
如果我们改为静态链接的话,这个 puts 就是 FUNC 了,因为静态链接链接器会把 libc.a(静态库)中 puts 的机器码实现直接复制到你的可执行文件里。Value 列会变成它在可执行文件中的虚拟地址。因为包含了 puts 及依赖的库代码。和我们自己写的 run 、main 一样,被嵌入到可执行文件的代码段中,不再依赖动态库。
所以我们就可以推测我们运行代码时如果出现了 UND 或者未定义符号相关的报错,本质是链接阶段出了问题,而不是编译阶段。原因就是代码里引用了某个符号(函数/变量),但编译器/链接器找不到它的实现代码。
下面我们将程序的反汇编和符号表放在一起看一下:
所以我们可以得出 : 程序在编译生成 .o 时,call 函数地址是未知的,所提先用 0 占位,链接阶段链接器给所有函数、变量分配真正的虚拟地址,它会把所有 .o 拼在一起,给每个函数分配地址,把之前所有 call 0x00000000 的 0 全部替换成真地址,比如 func() 最终地址是 0x401130,链接器就把指令改成 call 0x401130,链接完成后,所有地址都是真实的虚拟地址,程序可以直接运行!这个过程叫地址重定位(Relocation)。
静态链接的本质:
经过上面的详细讲解与演示,我们可以总结出静态链接的本质:(重要)
在程序从源代码到可执行文件的过程中,链接阶段的核心工作就是符号解析与地址重定位。以我们的 main.o 和 run.o 为例: main.c 中只有 run 函数的声明,没有具体实现,因此编译生成的 main.o 里, run 符号会被标记为 UND (未定义),调用 run 的指令地址也只是占位的 0,此时两个目标文件之间互不认识、没有任何关联。到了链接阶段,链接器会先扫描所有参与链接的 .o 文件,收集所有函数、变量等符号信息;当发现 main.o 存在未定义的 run 符号时,就会在其他目标文件中查找匹配的实现,最终在 run.o 里找到 run 函数的完整代码与确定地址。找到之后,链接器会把 main.o 中原本占位的指令地址,替换成 run 函数在最终可执行文件里的真实虚拟地址,同时将 main.o 里 UND 状态的 run 符号修正为已定义状态,让调用指令能够准确跳转到对应位置,从而让不同文件里的函数能够互相找到并正常调用。
静态链接的过程中有加载的过程吗?为什么
- 静态链完全没有程序运行阶段的加载过程 ,它的所有工作都发生在编译链接阶段 。 在编译生成 .o 目标文件后,**链接器会读取静态库(.a/.lib)内部打包的目标文件,将程序实际用到的函数代码完整复制出来,和我们自己的 .o 文件合并成一个独立的可执行文件,同时完成所有函数地址的重定位,把之前的占位地址替换为真实虚拟地址。**这个合并完成后,静态库本身的使命就结束了,它不会以任何形式参与程序运行。 当程序启动时,操作系统只需要加载这一个最终生成的可执行文件即可,文件内部已经包含了程序运行所需的全部代码,不需要再去寻找、读取、映射静态库文件,也不存在任何针对静态库的加载操作。所以静态链接是链接阶段完成代码合并,运行时不加载任何库文件,可执行文件完全独立,不再依赖静态库。
三、理解动态链接
动态库也叫共享库,和静态库不同的是,静态库(.a)在编译期直接嵌入进可执行文件,运行时不独立加载。而动态库/共享库(.so)在编译期不嵌入,运行时由操作系统动态加载,多个进程可共享同一份库文件,节省内存。
这里有个问题 :动态链接的程序是先加载它的程序,还是先加载程序依赖的库?
答案是动态链接器是先加载程序依赖的库,然后再加载序本体本身。在程序启动运行(./a.out或者双击程序)时,操作系统先让动态链接器(ld-linux.so)介入,动态链接器会先解析并加载程序所有依赖的动态库(.so)到内存;等所有依赖库加载完成、符号解析完成后,再把程序本体的代码和数据映射到虚拟地址空间,之后才开始执行入口(_start)。

就像我们上面已经有了可执行程序 main.exe 了,为什么里面的 puts 还是 UND 未定义的呢?
- 那是因为在编译生成可执行文件(main.exe)的阶段,puts 等外部函数符号必然是显示为 UND(Undefined)是动态链接的标准设计。 编译阶段动态链接器(ld-linux.so)不会在编译期把库代码链接进你的程序。所以,在可执行文件内部, puts 的地址永远是空的(0),状态显示为 UND。真正的链接发生在"程序运行"的时候,程序运行阶段时,当我们双击运行程序时,操作系统的加载器(Loader)会介入,读取程序的依赖清单(比如 libc.so ),找到对应的动态库文件,加载到内存,解析符号,把 puts 等所有 UND 符号,替换为动态库中函数的真实内存地址。之后,程序才开始正式执行。所以动态库优先加载,程序本体后加载。
图一:

动态库在磁盘上,动态库(.so 文件)本质上就是一个普通的磁盘文件。它开始时静静地躺在磁盘里,和你的 main.exe 一样,只是一堆二进制数据。当进程加载启动后,**内核的动态链接器会把它从磁盘读取到物理内存中。**一旦被加载进内存,它就不再是磁盘上的死文件了,而是变成了物理内存里的一段数据。和普通进程的代码段、数据段一样,都是物理内存里的实体。并且因为它是共享库,系统内存中只会存一份 libc.so ,然后让所有用到它的进程(比如进程A、进程B)去共享这一份内存。下一步就是最关键的一步 : 建立"映射"(MMU + 页表),因为进程A不知道动态库在物理内存的具体位置,所以必须建立映射关系:首先内核建立页表项,操作系统维护一张页表。它会记录:"动态库 XXX.so 在物理内存的第 X 页"。然后内核会给进程A的 mm_struct(虚拟地址空间) 中划分一块区域(共享区)。最后通过页表,把进程A虚拟地址空间里的"共享区",映射到物理内存中存放 XXX.so 的那一页。这样当进程A运行到需要调用 puts 时,程序发出请求执行call puts 。CPU 使用 puts 对应的虚拟地址(属于进程A的共享区)。MMU(内存管理单元)查页表,发现这个虚拟地址对应的是物理内存里的 XXX.so 数据。最终成功访问,CPU 直接去物理内存中读取动态库的代码并执行。
疑问1 : 物理内存中是物理地址还是虚拟地址?
物理内存里存储的程序/数据,本质上都是"物理内存"的实体,使用的是物理地址;但我们在程序中看到的一切(代码、变量、指令),都是"虚拟地址"。当你的程序(main.exe)、动态库 (libxxx.so)被加载进物理内存后,它们在硬件层面上占用的是物理内存的物理地址。也就是说:物理内存是真实存在的一块硬件区域。每一块内存都有一个 物理地址(Physical Address)。操作系统内核管理进程时,就是直接操作这块物理内存。所以库、程序、数据在物理内存中,使用的就是物理地址。为什么我会觉得是"虚拟地址"?因为我们平时看到的一切(指令访问、变量寻址、代码跳转)全部都是虚拟地址(Virtual Address)。这些地址全部都是进程自己的虚拟地址空间里的编号。进程永远不知道物理内存的真实物理地址,它只知道我在我的虚拟地址空间里运行。操作系统通过页表(Page Table)把进程的"虚拟地址"映射到硬件的"物理地址"。
疑问2 : 为什么动态库(.so 文件)会被映射到进程虚拟地址空间中的共享区?首先我们要清楚每一个进程都有自己独立的进程虚拟地址空间。每一个进程也都有自己独立的代码区、数据区、堆区、栈区,以及自己的共享区(动态库映射区)。举个例子 : printf 函数属于库函数,它是在动态库中的,因为 printf 这种函数同时可能会被多个进程使用,并且它自己又不是一个独立程序,所以动态库文件必须自己先加载到物理内存(只存一份,省空间),再让每个进程把它"映射进自己的虚拟地址空间"(也就是共享区),这样每个进程都能通过自己的虚拟地址,透过页表,去访问这同一份动态库。所以会被映射到各个进程的虚拟地址空间中的共享区中。

库函数调用的本质
当我们的程序运行在自己的虚拟地址空间里,正常执行自己的代码。当执行到 printf 这种库函数时,程序就从自己的代码区跳到共享区里去找 printf 的实现。库已经被映射到这个虚拟地址空间里了,所以调用就是在同一个地址空间内直接跳转。整个过程都在你进程的内存里,不需要跳出,也不需要切换到别的空间。这就是库函数调用的本质。
动态库的相对地址

动态库为了随时进行加载,为了支持并映射到任意进程的任意位置,对动态库中的各个方法进行统一编址,采用相对编址的方案进行编制的 (其实可执行程序也一样,都要遵守平坦模式,只不过exe是直接加载的)。
- 进程的虚拟地址空间角度 : 动态库在编译时,不是固定在某个虚拟地址的。它假设自己是从地址 0 开始的,里面所有函数、变量的地址都是相对于基址的偏移量。例如:puts 距离库起始地址的偏移 = 0x1234,printf 距离库起始地址的偏移 = 0x5678库内部的跳转指令,都是基于这个相对偏移,所以这个地址也叫做逻辑偏移地址。进程加载库时,只要给它一个"基址",它就能运行,当动态库被加载到某个进程的共享区时,内核给它分配一个虚拟基址(比如 0x7ffff7a00000)。库内部所有函数的真实虚拟地址 = 基址 + 偏移量,所以库可以被加载到任何进程的任何虚拟地址,因为它内部只用偏移,不关心基址,这就是动态库的核心。这是在进程的角度来讲的,因为是在进程的虚拟地址空间中。
- 动态库本身角度 : 同样在动态库本身的视角来看,动态库本身在物理内存中也是同样逻辑,在物理内存中也是:库的物理基址 + 偏移量 = 物理地址,但进程永远不会直接用物理地址,它只通过虚拟地址 + 页表去查物理地址。所以物理层同样用相对偏移,只是进程不知道。
使用这种编址方式的目的就是为了实现动态库的共享与复用!如果不用这种相对偏移的方式,动态库就必须被加载到固定的内存地址。一旦多个程序同时需要加载动态库,就很容易出现地址冲突,而且也无法实现多个进程共用同一个动态库实例的效果。而通过"基址 + 偏移"的方式,无论动态库被加载到哪个进程的哪个内存地址,只要知道了基址和内部偏移,就能精准定位到目标内容,实现了一次编译,到处运行,共享复用。
举个例子:

这里的 +0xe 就是相对偏移地址。它的含义就是 "从 run 函数的起始地址开始,往后偏移 0xe 个字节,就是我要跳的目标。
它又和前面的机器码里的 e8 00 00 00 00 有什么关系?
- e8 是 call 指令的操作码,告诉 CPU 这是一个调用。后面那四个 00 00 00 00,是占位符。它的含义是"这里本来应该填一个地址,但现在还不知道填什么,先填 0 占位,等后面来补。"在 .o 文件里编译器只知道相对偏移,所以生成了 +0xe。但它不知道 run 函数的最终绝对地址,所以把跳转地址写成了 00 00 00 00 。随后在链接阶段链接器(linker)会确定 run 函数的最终绝对地址。用这个绝对地址,加上 +0xe,计算出目标地址。把计算出的目标地址,替换掉原来的 00 00 00 00 。比如假设链接器确定 run 函数的起始地址是 0x400500 。偏移量是 0xe。那么目标地址 = 0x400500 + 0xe = 0x40050E。链接器会把机器码里的 00 00 00 00 替换成 0x40050E 对应的字节。这样,CPU 执行的时候,就能直接跳到 0x40050E 了。
图二:

这张图完整地还原了动态库从磁盘到进程运行的全链路。我们按照 4 个步骤,把这个过程从头到尾拆解开:
- 阶段一 :磁盘定位与加载(准备阶段),在进程运行前,库文件静静地躺在磁盘里,进程还没出生。首先是内核要先找到库文件,当我们运行程序时,内核根据程序的依赖信息(比如 libc.so),去磁盘上查找。这一步会通过 dentry (目录项) 和 inode(索引节点)找到磁盘上的库文件数据块。inode 就像库的"身份证",记录着库在磁盘上的具体位置。接下来库就要被加载进物理内存中,内核 libc.so 从磁盘读取到物理内存中。此时内核会维护一个内核缓冲区。如果是第一个进程加载 libc,它会从磁盘读入内存;如果是第二个进程加载,直接用内存里已有的,不重复读磁盘。现在,物理内存里有了一份完整的 libc 代码和数据。
- 阶段二:建立映射(核心关联),现在库在物理内存里,但进程还看不见它。内核必须建立一座"桥"。这座桥就是页表映射,操作系统为当前进程建立页表。把物理内存里的 libc 代码页,和进程虚拟地址空间里的"共享区"页框对应起来。这就是"映射"。通过页表,进程的虚拟地址就有了通往物理内存库数据的路径。然后就会获取库的虚拟基址,操作系统给每个进程分配一个随机的虚拟地址作为这段共享区的起始点(基址)。比如上图中假设映射后,库的起始虚拟地址是0x44332211。
- 阶段三:链接与执行(运行时刻),现在映射建好了,但程序里的 call 指令还不知道怎么跳。下面就涉及到指令地址计算,在编译期我们的代码里写了 call puts。编译器不知道 puts 在哪,只知道 puts 相对于库的基址的偏移量。puts 距离库起始地址的偏移是 0x112233 。此时汇编代码显示为:call libc.so@0x112233 。然后就是运行时动态链接阶段(最终地址),程序运行到这一行指令时,动态链接器(ld-linux)介入。它把刚才分配好的库虚拟基址(0x44332211)加上函数偏移量(0x112233 )。最终计算出真实地址:0x44332211 + 0x112233 = 0x45454544。原来的 call 指令被修补,变成了直接调用这个计算出的绝对虚拟地址。
- 阶段四:CPU 执行指令,CPU 拿到计算好的真实虚拟地址。通过 MMU(内存管理单元)查页表。页表告诉 CPU 这个虚拟地址对应的是物理内存里那一份 libc 代码。CPU 执行库里的 puts 函数,打印内容。

我们在看这个图,我有个疑问,就是如果有多个库文件同时在物理内存中,那操作系统会对这些库文件进行管理吗?怎么管理的?
- 答案是会管理的,而且同样也是按照 先描述,再组织 的方式进行管理,库文件(比如 libc.so)在物理内存中是通过 vm_area_struct 来管理的,但它管理的是"库的内容",而不是"库的位置"。也就是说物理内存中的库文件,是被当作一段"数据"来管理的。这段数据的管理,依赖于 vm_area _struct 这个结构体。但是,vm_area_struct 本身是属于"进程"的,而不是属于"库文件"的。PCB 里存 mm_struct,mm_struct 存进程的"虚拟地址空间信息",虚拟地址空间分成很多区(代码区,数据区),每个区用一个 vm_area_struct 描述。vm_area_struct 描述的是"进程虚拟地址空间里的一段区域",不是描述库本身。动态库(如 libc.so)被映射进进程的虚拟地址空间后,内核会给它分配一段 vm_area_struct。这个 vm_area_struct 结构体通过可以用来管理每个库文件,但是本质上是属于进程的,不是属于库的。
先描述 : 库文件(比如 libc.so)被加载到物理内存后,系统会为它创建一个 vm_area_struct 结构体,这个结构体是用来描述库的内容,库的属性,库的位置。简单说,物理内存中的库文件,就是通过 vm_area_struct 来描述的,就像你进程中的代码区、数据区一样。
再组织 : Linux 用"双向链表 + 红黑树"组织进程的所有 vm_area_struct 结构体。链表用于遍历,红黑树用于快速按地址查找。这就是底层的组织形式。
动态链接的本质
动态链接的本质是将符号解析与地址重定位的核心工作从编译链接阶段推迟到程序加载或运行阶段,通过动态加载器完成动态库的查找、加载与地址修正:可执行文件仅保留对库函数的符号引用与依赖信息,不直接复制库代码,动态库以位置无关代码设计,可被加载到进程虚拟地址空间的任意位置,运行时再完成符号绑定与地址重定位,最终实现多进程间代码共享、内存占用节省以及库文件的独立更新,让程序与库实现解耦。
这里我们需要注意的是静态链接和动态链接都涉及到符号解析与地址重定位,只是时机、方式和场景完全不同。
- 静态链接中的符号解析与重定位是在编译后的链接阶段(生成可执行文件时)完成。链接器扫描所有
.o文件和静态库,找到每个符号(比如外部函数、全局变量)的定义位置。为所有代码和数据段分配固定虚拟地址。将之前编译时留下的占位地址(比如call 0x0)替换为真实的虚拟地址。最终可执行文件包含所有代码,运行时不再需要任何链接操作。 - 动态链接中的符号解析与重定位是在推迟到程序加载时或运行时(由动态链接器完成)。可执行文件只保留符号名和依赖库信息,运行时动态链接器(如
ld-linux.so)去查找.so文件,解析符号对应的地址。动态库被加载到进程地址空间的任意位置(基地址不固定)。根据实际加载基地址,修正库内的函数跳转、变量访问地址。同时修正可执行文件中对库函数的调用地址。程序和库是分离的,运行时才完成地址绑定。
四、动静态库的对比
| 对比维度 | 静态链接(Static Linking) | 动态链接(Dynamic Linking) |
|---|---|---|
| 链接时机 | 编译链接阶段(生成可执行文件时) | 程序加载 / 运行时 |
| 库代码处理 | 直接复制到可执行文件中 | 不复制,只保留符号引用 |
| 符号解析 + 重定位 | 链接阶段一次性完成 | 推迟到加载 / 运行时完成 |
| 可执行文件大小 | 较大 | 较小 |
| 运行依赖 | 完全独立,不依赖任何库文件 | 必须依赖外部动态库(.so/.dll) |
| 运行时加载库 | 不加载任何库 | 必须加载并映射动态库 |
| 内存共享 | 不共享,每个程序独占库代码 | 多进程共享同一份库代码 |
| 库更新 | 必须重新编译程序 | 直接替换库文件即可生效 |
静态链接:编译时拷贝 + 定位,运行时完全独立
动态链接:运行时加载 + 定位,运行时依赖库文件
五、总结
本文系统讲解了程序链接与加载的核心机制。静态链接在编译阶段完成符号解析与地址重定位,将库代码直接复制到可执行文件中;动态链接则推迟到运行时,通过动态加载器完成库加载和地址绑定,实现多进程共享。文章详细分析了.o文件与可执行文件的ELF格式差异,通过符号表解析了静态链接的地址重定位过程,并深入剖析了动态库从磁盘加载到内存映射的全链路机制。最后对比了动静态链接在链接时机、内存共享、更新维护等方面的本质区别,揭示了现代操作系统管理程序执行环境的底层原理。
谢谢大家的观看!

