文章目录
-
- 一、动态库为什么比静态库更常用
- 二、动态库也是文件
- 三、动态库加载的整体流程
- [四、从磁盘 .so 到物理内存](#四、从磁盘 .so 到物理内存)
- 五、从物理内存到进程虚拟地址空间
- 六、多个进程如何共享同一个动态库
- 七、共享的是代码,不是什么都共享
- 八、为什么动态库加载地址不固定
- [九、使用 /proc 查看动态库映射](#九、使用 /proc 查看动态库映射)
- [十、使用 pmap 查看进程映射](#十、使用 pmap 查看进程映射)
- 十一、动态链接和静态链接再对比一次
- 十二、为什么动态库需要后续重定位
- 十三、总结
前面我们已经知道,动态库 .so 不是在编译链接阶段完整拷贝进可执行程序,而是在程序运行时被动态链接器加载。
那么问题来了:磁盘上的 libxxx.so 到底是怎么进入进程地址空间的?多个程序都依赖同一个动态库时,系统真的会给每个进程都复制一份库代码吗?为什么说动态库可以节省内存?
这篇文章就围绕"动态库如何映射到进程地址空间"展开。

一、动态库为什么比静态库更常用
静态链接会把用到的库代码合并进可执行文件。这样程序可以独立运行,但也带来两个问题:
- 可执行文件变大。
- 多个程序使用同一个库时,会重复保存同一份代码。
假设系统里有 100 个程序都用到了 C 标准库。如果每个程序都把 libc 的代码静态链接进去,磁盘和内存都会浪费大量空间。
动态库的思路是:
text
把公共代码单独放在 .so 文件中;
程序运行时再加载;
多个进程尽量共享同一份库代码。
这就是动态库广泛使用的重要原因。
二、动态库也是文件
先明确一点:动态库不是内存里凭空出现的东西,它首先是磁盘上的一个普通文件。
例如:
bash
ls -l /lib64/libc.so.6
file /lib64/libc.so.6
你会发现它是一个 ELF shared object。
所以加载动态库的第一步,仍然是文件操作:
text
找到 .so 文件
打开 .so 文件
读取 ELF 信息
把需要的 segment 映射进内存
只不过这个过程通常由动态链接器和内核协作完成,程序员平时不直接感知。
三、动态库加载的整体流程
一个动态链接程序启动时,大致流程如下:
text
1. 内核加载主程序 ELF
2. 发现主程序需要动态链接器
3. 动态链接器读取主程序依赖的 .so 列表
4. 找到每个 .so 文件
5. 将 .so 的代码段、数据段等映射到进程地址空间
6. 完成必要的符号解析和重定位
7. 进入程序 main 函数

如果第 4 步找不到库,就会出现我们前一篇讲的:
text
libxxx.so => not found
如果能找到库,下一步就是把库映射到进程地址空间中。
四、从磁盘 .so 到物理内存
当程序依赖 libmyc.so 时,动态链接器会找到磁盘上的库文件。
然后系统会把库中需要加载的 segment 放入物理内存,尤其是代码段 .text 对应的内容。
可以先粗略理解为:
text
磁盘 libmyc.so
-> 加载到物理内存中的若干页
现代系统很多时候还会用文件映射、按需分页等机制,并不是一开始就把整个库所有内容都读入内存。但从理解动态库共享的角度,我们先抓住核心:
text
库文件的内容最终要对应到物理内存页。
五、从物理内存到进程虚拟地址空间
进程不能直接使用物理地址。每个进程看到的是自己的虚拟地址空间。
所以动态库加载后,还需要在当前进程的虚拟地址空间中划出一段区域,用来映射这份库。
可以理解为:
text
进程虚拟地址空间中的一段共享区
-> 通过页表
-> 映射到物理内存中的 libmyc.so 代码页
例如,进程 A 中 libmyc.so 可能映射到:
text
0x7f1000000000 ~ 0x7f1000010000
这只是进程 A 自己看到的虚拟地址范围。
真正的物理内存在哪里,进程并不直接关心。CPU 会通过页表完成转换。
六、多个进程如何共享同一个动态库
假设进程 A 和进程 B 都依赖 libmyc.so。
进程 A 启动时:
text
磁盘 libmyc.so -> 物理内存代码页
进程 A 的虚拟共享区 -> 映射到这些物理页
进程 B 启动时,如果系统发现这份库的代码页已经在物理内存中,就不必再加载一份完整代码。
它只需要:
text
给进程 B 分配自己的虚拟共享区
让进程 B 的页表也映射到同一批物理代码页
于是形成:
text
进程 A 虚拟地址 0x7f1000000000 -> 同一份物理库代码
进程 B 虚拟地址 0x7f2000000000 -> 同一份物理库代码
注意:
text
两个进程的虚拟地址可以不同;
但它们可以映射到同一份物理内存。

这就是动态库节省内存的关键。
七、共享的是代码,不是什么都共享
动态库中并不是所有内容都能随便共享。
一般来说:
text
只读代码段可以共享;
只读数据可以共享;
可写数据通常不能直接在进程间共享。
原因很简单:如果动态库中的全局变量被多个进程共享,那进程 A 修改变量会影响进程 B,这显然不符合普通进程隔离原则。
所以动态库的可写数据部分通常会为每个进程提供独立映射,或者通过写时拷贝等机制保证进程隔离。
动态库节省内存主要依赖:
text
代码段只读,可以被多个进程共享。
这也是为什么动态库代码要尽量做到位置无关,不能随便修改代码段本身。
八、为什么动态库加载地址不固定
每个进程都有自己的虚拟地址空间。不同进程加载了不同的程序、不同的库,地址空间中空闲区域也不同。
所以动态链接器不能假设所有进程都把 libmyc.so 放到同一个虚拟地址。
它通常会在当前进程地址空间中选择一段合适的空闲区域,把动态库映射进去。
这就带来一个关键问题:
text
动态库如果被加载到任意地址,里面的函数调用和全局变量访问怎么保证正确?
这就是下一篇 GOT/PIC 要解决的问题。
九、使用 /proc 查看动态库映射
Linux 提供了 /proc/[pid]/maps,可以查看进程地址空间映射。
先运行一个程序,让它保持一段时间:
bash
./main
如果程序很快退出,可以在代码中加 sleep(100)。
然后查看:
bash
pidof main
cat /proc/进程PID/maps
你会看到类似:
text
7f... r-xp ... /lib64/libc.so.6
7f... r--p ... /lib64/libc.so.6
7f... rw-p ... /lib64/libc.so.6
这些行说明 libc.so.6 的不同部分被映射到了进程虚拟地址空间中,并且权限不同:
text
r-xp:可读可执行,通常对应代码段
r--p:只读
rw-p:可读可写,通常对应数据段
这能非常直观地看到动态库确实进入了进程地址空间。
十、使用 pmap 查看进程映射
也可以使用:
bash
pmap 进程PID
它会以更简洁的形式展示进程内存映射。
例如:
bash
pmap $(pidof main)
可以看到主程序、堆、栈、动态库等区域。
学习动态库时,/proc/[pid]/maps 是非常有价值的观察工具。
十一、动态链接和静态链接再对比一次
静态链接:
text
库代码在链接阶段进入可执行文件;
程序运行时不再找库;
多个程序各自包含一份库代码。
动态链接:
text
可执行文件记录依赖;
程序运行时加载 .so;
多个进程可以共享同一份库代码物理页。
动态链接本质上把一部分链接工作推迟到了程序加载和运行阶段。
这带来灵活性,也带来复杂性。
十二、为什么动态库需要后续重定位
动态库被映射到进程地址空间后,它的加载地址才真正确定。
但库代码中可能需要访问:
text
库内函数
库内全局变量
其他动态库函数
主程序中的符号
有些地址必须在加载后才能知道。
所以动态链接器还要做符号解析和重定位,让这些引用指向正确位置。
问题是:代码段通常是只读且共享的,不能每个进程都直接修改代码段里的地址。
因此动态链接需要一种更巧妙的机制:
text
把可能变化的地址放到可写表中,代码通过查表跳转。
这张表就是 GOT,全局偏移表。
十三、总结
动态库加载不是简单地"把 .so 拷贝进进程"。更准确的理解是:
text
动态链接器找到 .so
内核把 .so 的 segment 映射进进程虚拟地址空间
多个进程可以通过各自页表映射到同一份物理代码页
动态链接器再完成必要的符号解析和重定位
动态库节省内存的关键在于:只读代码段可以共享。
但动态库加载地址不固定,这就要求动态库不能把绝对地址写死在代码里。下一篇我们继续讲 GOT 与 PIC,看动态库如何做到"加载到哪里都能运行"。