一、基础知识回顾
动静态库的制作之前已经讲过了,博客链接在这:动静态库的制作
在开始之前,先简单回顾一下两个概念:
- 静态库 (
.a文件):本质上是多个目标文件(.o)的打包集合。链接时,链接器会从静态库中提取被程序引用到的目标文件,并将其代码和数据复制到最终的可执行文件中。 - 动态库 (
.so文件,Windows 下为.dll):编译时并不把库代码复制进可执行文件,而是在可执行文件中记录对动态库的依赖关系(如需要哪些符号、库文件名等)。在程序运行时,由动态链接器将动态库加载到进程的地址空间中,并完成符号绑定。
下面我们就来详细拆解这两种库链接到进程的完整流程。
二、从源代码到可执行文件:编译链接全过程
无论是静态链接还是动态链接,程序从源代码到可执行文件都要经过四个阶段:预处理、编译、汇编、链接。
2.1 预处理(Preprocessing)
处理以 # 开头的指令,例如 #include、#define、条件编译等。将头文件内容展开、宏替换,生成 .i 文件。
2.2 编译(Compilation)
将预处理后的代码翻译成汇编语言,生成 .s 文件。编译器会进行词法、语法、语义分析以及优化,但此时仍然没有涉及库的链接。
2.3 汇编(Assembly)
将汇编代码翻译成机器指令,生成目标文件(.o)。目标文件是二进制格式(如 ELF),包含机器码、数据以及符号表(记录函数和全局变量的名字及地址占位符)。
2.4 链接(Linking)
这是库发挥作用的关键阶段。链接器(通常是 ld)将多个目标文件和库合并成一个可执行文件。链接器的主要工作包括:
- 符号解析:将目标文件中对未定义符号(如调用外部函数)的引用与库或其他目标文件中的定义关联起来。
- 重定位:由于多个目标文件合并后地址会发生变化,链接器需要修正代码中的地址引用,确保指向正确的最终地址。
- 生成可执行文件:根据是静态链接还是动态链接,决定如何组织代码和数据。
三、静态库如何链接到进程
3.1 静态链接详解
假设我们有一个程序 main.c,它调用了静态库 libmymath.a 中的函数 add()。编译命令通常为:
bash
gcc main.c -L. -lmymath -o main_static
在链接阶段,链接器会执行以下操作:
- 扫描目标文件
main.o,发现其中有一个未解析的符号add。 - 链接器开始在命令行指定的库(
libmymath.a)中查找这个符号。静态库其实是一个归档文件,里面包含多个.o文件(比如add.o、sub.o等)。 - 链接器从静态库中提取出包含
add定义的add.o,并将它和main.o一起合并到最终的可执行文件中。 - 如果
add.o还依赖其他符号(例如调用了另一个库中的函数),链接器会继续递归查找,直到所有符号都被解析或报错。 - 最后,链接器对所有目标文件进行重定位,将相对地址调整为最终的可执行文件中的地址,并生成可执行文件。
关键点 :静态库的代码在链接时被完整复制到了可执行文件中。因此,可执行文件不再依赖原来的 .a 文件,可以独立运行。
3.2 静态链接的可执行文件在内存中的映射
当静态链接的可执行文件被操作系统加载运行时,其代码段(.text)和数据段(.data、.bss)会被映射到进程的虚拟地址空间中。整个过程如下:
- 加载器 (
execve系统调用)读取可执行文件的 ELF 头,确定各个段的大小和虚拟地址。 - 操作系统在进程的虚拟地址空间中为这些段分配虚拟内存区域(VMA),并将文件内容映射到内存(使用
mmap)。 - 由于所有代码和数据都包含在可执行文件中,没有额外的共享库需要加载,因此进程地址空间相对简单:代码段、数据段、堆、栈等。
- 进程开始执行入口点(通常是
_start),然后调用main。
因为静态链接将所有代码都包含在可执行文件中,不存在动态库映射到共享区的问题。但这也导致每个程序都包含一份库代码的副本,浪费磁盘和内存。
3.3 优缺点
- 优点:可执行文件独立性强,不依赖外部库,部署简单,启动速度快(无需动态链接过程)。
- 缺点:可执行文件体积大;如果多个程序使用同一个静态库,每个程序都有一份库代码,造成内存浪费;库更新后需要重新编译所有依赖它的程序。
四、动态库如何链接到进程
动态库的链接和使用比静态库复杂得多,它涉及编译期链接、加载期映射和运行期符号解析等多个阶段。下面我们逐步深入。
4.1 动态链接的过程
编译动态链接的程序时,命令可能如下:
bash
gcc main.c -L. -lmymath -o main_dynamic
其中 libmymath.so 是动态库。链接器在链接阶段的行为与静态链接不同:
- 链接器检查
main.o中的未解析符号(如add)。 - 它不会从动态库中提取代码,而是在可执行文件中记录 :
- 需要依赖的动态库文件名(如
libmymath.so)。 - 需要从该库中解析的符号列表(如
add)。 - 一些重定位信息(用于运行期修正地址)。
- 需要依赖的动态库文件名(如
- 最终生成的可执行文件只包含程序自己的代码和数据,不包含动态库的代码。
我们可以用 ldd 命令查看动态链接程序依赖的库:
bash
ldd main_dynamic
# 输出中会包含 libmymath.so => /path/to/libmymath.so (0x00007f...)
4.2 动态链接器的工作
当动态链接的可执行文件被加载运行时,操作系统会启动一个特殊的程序------动态链接器 (在 Linux 上通常是 /lib64/ld-linux-x86-64.so.2)。动态链接器负责完成以下任务:
- 加载动态库:根据可执行文件中记录的信息,找到并加载所有依赖的动态库到进程的虚拟地址空间。
- 符号解析与重定位:将程序中对外部符号的引用与动态库中实际定义的地址绑定起来,并修正代码中的地址。
- 执行初始化代码:调用动态库的构造函数(如果有)。
- 跳转到程序入口 :最终将控制权交给可执行文件的入口点,开始执行
main。
动态链接器本身也是一个共享库,它由内核在加载可执行文件时自动映射到进程地址空间中。
4.3 动态库映射到进程的共享区
这是本文的重点:动态库是如何映射到进程的"共享区"的?
4.3.1 进程虚拟内存布局
在 Linux 下,一个进程的虚拟地址空间大致布局如下(从高地址到低地址):

其中,内存映射区域 (Memory Mapping Region)就是我们常说的"共享区"。这个区域通常位于栈和堆之间,用于存放通过 mmap 系统调用映射的内容,包括动态库、文件映射、匿名映射等。
4.3.2 动态库加载与映射
动态链接器在加载动态库时,会通过 mmap 系统调用将动态库文件的内容映射到进程的共享区。具体步骤如下:
- 打开动态库文件 :动态链接器使用
open系统调用打开libmymath.so。 - 映射只读段 :将动态库的代码段(
.text)和只读数据段(.rodata)以只读、共享的方式映射到进程的共享区。这样多个进程可以共享同一份物理内存中的代码,节省内存。 - 映射可写段 :将动态库的数据段(
.data)和 BSS 段以**私有、写时复制(Copy-on-Write, COW)**的方式映射。这意味着多个进程最初共享同一物理页,但当某个进程尝试修改数据时,内核会为该进程复制一份物理页,使其修改不影响其他进程。 - 记录映射地址:动态链接器会记录每个动态库在进程地址空间中的加载基地址,用于后续符号解析。
可以通过查看 /proc/<pid>/maps 文件来观察进程的内存映射,其中会看到类似这样的条目:

第一列是虚拟地址范围,第二列是权限(r=读,w=写,x=执行,p=私有),第三列是偏移,第四列是设备号,第五列是 inode,最后一列是文件路径。可以看到动态库被映射到了共享区。
4.3.3 物理内存共享与写时复制
- 代码段共享 :所有加载同一个动态库的进程,其代码段映射到同一块物理内存。这得益于虚拟内存机制:不同进程的虚拟地址可能不同,但通过页表映射到相同的物理页框。这大大节省了内存。
- 数据段写时复制:动态库的全局变量等可写数据初始时也共享物理内存,但当进程尝试写入时,内核会触发缺页异常,为该进程分配一块新的物理页,复制原数据,并更新页表,使该进程的写操作只影响自己的副本。
4.3.4 GOT 与 PLT:运行期符号解析
动态库映射到共享区后,还需要解决一个问题:程序在编译时并不知道动态库中函数的具体地址(因为库加载地址可能每次不同)。这就是通过 GOT(全局偏移表) 和 PLT(过程链接表) 机制实现的。
- PLT:位于可执行文件的代码段中,每个外部函数对应一个 PLT 条目。当程序调用外部函数时,首先跳转到 PLT 条目。
- GOT:位于数据段中,存储外部函数或全局变量的实际地址。GOT 条目初始时指向 PLT 中的下一条指令。
- 延迟绑定(Lazy Binding) :默认情况下,动态链接器采用延迟绑定,即函数第一次被调用时才解析其地址。
- 第一次调用
add()时,程序跳转到 PLT 条目,PLT 跳转到 GOT 条目,GOT 初始指向 PLT 中的解析代码,该代码调用动态链接器解析add的实际地址,并更新 GOT 条目。 - 之后再次调用
add(),PLT 直接通过 GOT 跳转到实际函数地址,不再经过解析。
- 第一次调用
通过 GOT/PLT,程序可以在运行期动态解析符号,而无需在加载时立即解析所有符号,提高了启动速度。
4.4 优缺点
- 优点 :
- 可执行文件体积小。
- 多个进程共享同一份动态库代码,节省内存。
- 库更新时无需重新编译程序(只要接口兼容)。
- 缺点 :
- 运行时需要加载动态库,启动稍慢(但延迟绑定缓解了这一点)。
- 依赖外部环境,如果动态库缺失或版本不兼容会导致程序无法运行。
五、重点工具和命令
为了深入理解上述过程,以下工具和命令非常有用:
| 工具/命令 | 用途 |
|---|---|
gcc -static |
强制静态链接 |
ldd <program> |
查看动态链接程序依赖的共享库 |
readelf -d <program> |
查看动态段信息,包括依赖库和重定位表 |
readelf -S <program> |
查看节区信息 |
readelf -l <program> |
查看程序头(段信息),用于了解加载到内存的段 |
objdump -d <program> |
反汇编,观察 PLT 和 GOT 相关代码 |
nm <library> |
查看库中的符号表 |
/proc/<pid>/maps |
查看进程的内存映射,观察共享库加载地址 |
strace <program> |
跟踪系统调用,可以看到 mmap 映射共享库的过程 |
LD_DEBUG=libs <program> |
查看动态链接器加载库的详细信息 |
通过这些工具,你可以亲自验证动态库映射到共享区的过程。
六、实例演示
下面我们用一个简单的例子来演示静态库和动态库的链接以及进程内存映射。
6.1 准备代码和库
mymath.h
c
#ifndef MYMATH_H
#define MYMATH_H
int add(int a, int b);
int sub(int a, int b);
#endif
add.c
c
#include "mymath.h"
int add(int a, int b) {
return a + b;
}
sub.c
c
#include "mymath.h"
int sub(int a, int b) {
return a - b;
}
main.c
c
#include <stdio.h>
#include "mymath.h"
int main() {
printf("3 + 2 = %d\n", add(3, 2));
printf("3 - 2 = %d\n", sub(3, 2));
return 0;
}
6.2 生成静态库和动态库
生成目标文件:
bash
gcc -c add.c sub.c
生成静态库 libmymath.a:
bash
ar rcs libmymath.a add.o sub.o
生成动态库 libmymath.so:
bash
gcc -shared -fPIC -o libmymath.so add.c sub.c
6.3 静态链接并运行
bash
gcc main.c -L. -lmymath -o main_static
./main_static
使用 ls -lh 可以看到 main_static 较大。用 ldd main_static 会提示不是动态可执行文件。
6.4 动态链接并运行
bash
gcc main.c -L. -lmymath -o main_dynamic
export LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH # 让动态链接器找到库
./main_dynamic
用 ldd main_dynamic 可以看到 libmymath.so 的依赖。
6.5 观察进程内存映射
运行 main_dynamic 并在另一个终端查找其 PID,然后查看 /proc/<pid>/maps:
bash
cat /proc/<pid>/maps | grep libmymath
你会看到类似如下输出:

这清楚地表明动态库被映射到了进程的共享区(内存映射区域),并且代码段是只读可执行,数据段是私有可写(写时复制)。
七、总结
理解这些底层机制,不仅有助于编写更高效的程序,还能在排查链接错误、内存问题时更加得心应手。希望这篇博客能帮你彻底搞懂库与进程之间的"亲密关系"。