在 Linux 系统中,动态库(.so)之所以能实现 “一份代码、多进程共享”,核心依赖两大底层机制:进程虚拟地址空间和动态链接技术。你是否好奇:动态库为何能被多个进程同时使用而不冲突?程序运行时如何找到动态库的函数地址?虚拟地址空间又是如何为动态库 “腾地方” 的? 今天我们就从进程地址空间的本质入手,层层拆解动态链接的核心原理,结合实战操作,带你彻底搞懂动态库从 “被找到” 到 “被共享” 再到 “被调用” 的完整流程。下面就让我么正式开始吧!
在聊动态库加载之前,必须先明确一个核心概念:进程虚拟地址空间。现代操作系统中,每个进程都拥有独立的虚拟地址空间(通常是 64 位系统下的 0x0000000000000000 到 0xFFFFFFFFFFFFFFFF),进程访问的所有 “内存地址” 都是虚拟地址,而非物理内存的真实地址。
直接访问物理内存存在三大问题:
虚拟地址空间通过 “页表映射” 解决了这些问题:
我们可以用一个生动的比喻理解:虚拟地址空间是进程的 “专属舞台”,物理内存是后台的 “道具仓库”,页表是 “舞台与仓库的映射清单”。进程在 “舞台” 上表演(执行代码),需要的 “道具”(数据)通过清单从仓库调取,不同进程的 “舞台” 互不干扰。
64 位 Linux 系统中,进程虚拟地址空间的布局大致如下(从低地址到高地址):

其中,共享库区(mmap 区域) 是动态库的 “专属地盘”。操作系统会将动态库加载到这个区域,多个进程可通过页表映射到同一份物理内存的动态库代码,实现 “共享”。
答案是:有!
现代编译器采用 “平坦模式” 编译程序,ELF 文件(可执行程序、动态库、目标文件)在编译链接阶段就已经完成了 “虚拟地址编址”。也就是说,ELF 文件中的代码和数据,在未加载到内存时就已经分配了虚拟地址。
我们用readelf -h查看动态库的 ELF 头,验证这一点:
# 查看C标准库的ELF头
readelf -h /lib/x86_64-linux-gnu/libc-2.31.so | grep -E "Entry point|Type"输出:
Type: DYN (Shared object file) # 类型:动态库
Entry point address: 0x27000 # 入口点虚拟地址Entry point address: 0x27000:这是动态库的入口函数(_init)的虚拟地址,在编译时就已确定。这意味着:动态库加载时,操作系统只需将库的虚拟地址范围 “映射” 到物理内存,无需修改库的代码(因为代码采用 “位置无关编址” PIC),即可让进程通过虚拟地址访问库函数。
进程创建时,内核会为其分配mm_struct(内存描述符)和多个vm_area_struct(虚拟内存区域描述符),这些结构的初始化数据全部来自 ELF 文件的程序头表(Program Header Table)。
vm_area_struct会描述虚拟地址空间中的一个连续区域(如代码区、数据区、共享库区),记录区域的起始地址、长度、权限(可读 / 可写 / 可执行)等。vm_area_struct,描述动态库在共享库区的虚拟地址范围,并通过页表将其映射到物理内存中的动态库代码和数据。 我们可以用cat /proc/self/maps查看当前进程的虚拟内存区域分布:
# 查看当前shell进程的虚拟内存布局
cat /proc/$$/maps | grep -E "libc|mmap"输出(关键部分):
7f8b4d800000-7f8b4d9c0000 r--p 00000000 08:01 131346 /lib/x86_64-linux-gnu/libc-2.31.so
7f8b4d9c0000-7f8b4db70000 r-xp 001c0000 08:01 131346 /lib/x86_64-linux-gnu/libc-2.31.so # 代码段(r-xp:读+执行)
7f8b4db70000-7f8b4dbc0000 r--p 00370000 08:01 131346 /lib/x86_64-linux-gnu/libc-2.31.so
7f8b4dbc0000-7f8b4dbc4000 rw-p 003c0000 08:01 131346 /lib/x86_64-linux-gnu/libc-2.31.so # 数据段(rw-p:读+写) 可以看到,libc.so被加载到7f8b4d800000起始的虚拟地址区域,且代码段和数据段有明确的权限设置。
动态库的加载过程本质是 “文件映射 + 地址解析”,核心解决两个问题:进程如何找到动态库、多个进程如何共享动态库。
动态库本质是磁盘上的一个 ELF 文件,进程要访问动态库,首先需要将其 “映射” 到自己的虚拟地址空间。这个过程类似 “打开文件”,但不是读取文件内容到内存缓冲区,而是通过mmap系统调用将文件的磁盘地址直接映射到进程的虚拟地址空间。
ld-linux.so),由动态链接器负责加载程序依赖的动态库。LD_LIBRARY_PATH环境变量、/etc/ld.so.conf配置文件、/etc/ld.so.cache缓存,找到动态库的磁盘路径(如/lib/x86_64-linux-gnu/libc.so.6)。open系统调用打开动态库文件,获取文件描述符。mmap系统调用,将动态库的代码段、数据段等映射到进程的共享库区(虚拟地址空间)。vm_area_struct,并更新页表,将动态库的虚拟地址映射到物理内存(或磁盘文件,采用 “按需加载” 策略)。这个过程可以用一张图直观理解:

实战验证:用 mmap 手动映射动态库
我们可以用mmap系统调用手动映射动态库,模拟动态链接器的核心操作:
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>
#include <sys/stat.h>
int main() {
const char *lib_path = "/lib/x86_64-linux-gnu/libc-2.31.so";
// 1. 打开动态库文件
int fd = open(lib_path, O_RDONLY);
if (fd < 0) {
perror("open");
return 1;
}
// 2. 获取文件大小
struct stat st;
if (fstat(fd, &st) < 0) {
perror("fstat");
close(fd);
return 1;
}
off_t lib_size = st.st_size;
printf("libc.so size: %ld bytes\n", lib_size);
// 3. 映射动态库到虚拟地址空间(共享库区)
void *lib_addr = mmap(NULL, lib_size, PROT_READ, MAP_PRIVATE, fd, 0);
if (lib_addr == MAP_FAILED) {
perror("mmap");
close(fd);
return 1;
}
printf("libc.so mapped to virtual address: %p\n", lib_addr);
// 4. 解除映射,关闭文件
munmap(lib_addr, lib_size);
close(fd);
return 0;
}编译运行:
gcc mmap_lib.c -o mmap_lib
./mmap_lib输出:
libc.so size: 2029592 bytes
libc.so mapped to virtual address: 0x7f9a0b400000 可以看到,动态库被成功映射到0x7f9a0b400000(共享库区)的虚拟地址,这与我们之前通过/proc/$$/maps看到的地址范围一致。
多个进程使用同一个动态库时,物理内存中只需要保留一份动态库的代码(只读),这是通过虚拟内存的Copy-On-Write(写时复制) 机制实现的:
这个机制的核心优势是:节省内存。例如,100 个进程都使用libc.so,物理内存中只需保留一份libc.so的代码(约 2MB),而不是 100 份(约 200MB)。
我们编写两个简单程序,都依赖libc.so,然后查看它们的虚拟内存映射:
程序 1:test1.c
#include <stdio.h>
int main() {
printf("test1: libc.so printf address: %p\n", printf);
getchar(); // 暂停,方便查看
return 0;
}程序 2:test2.c
#include <stdio.h>
int main() {
printf("test2: libc.so printf address: %p\n", printf);
getchar(); // 暂停,方便查看
return 0;
}编译运行:
gcc test1.c -o test1
gcc test2.c -o test2
# 打开两个终端,分别运行test1和test2
# 终端1
./test1
# 输出:test1: libc.so printf address: 0x7f8b4d9e75a0
# 终端2
./test2
# 输出:test2: libc.so printf address: 0x7f8b4d9e75a0 可以看到,两个进程中printf函数的虚拟地址完全相同(0x7f8b4d9e75a0)。这意味着它们的页表都映射到同一份物理内存的printf函数代码,实现了代码共享。
我们通过下面这张图片来总结一下:

动态库能被加载到任意虚拟地址并正常运行,核心是因为编译时使用了-fPIC参数生成了位置无关代码(Position Independent Code)。
位置无关代码是指:代码的执行不依赖于其在内存中的绝对地址,而是通过 “相对地址” 或 “间接寻址” 访问函数和变量。
例如,动态库中的函数调用,不会直接使用绝对地址(如0x7f8b4d9e75a0),而是使用 “相对于当前指令的偏移量” 或 “通过全局偏移表(GOT)间接访问”。
如果动态库的代码是 “位置相关” 的(依赖固定的绝对地址),那么:
PIC 解决了这个问题,让动态库可以 “按需加载” 到任意虚拟地址,极大提高了灵活性。
我们分别编译带-fPIC和不带-fPIC的动态库,观察差异:
(1)不带-fPIC编译动态库:
# 编写简单动态库
echo "int add(int a, int b) { return a + b; }" > libadd.c
# 不带-fPIC编译(警告)
gcc -shared libadd.c -o libadd_no_pic.so输出警告:
/usr/bin/ld: /tmp/cc8Z7X7a.o: warning: relocation against `__stack_chk_fail' in read-only section `.text'
/usr/bin/ld: warning: creating DT_TEXTREL in a PIE 警告表明:不带-fPIC的动态库会生成 “文本重定位(DT_TEXTREL)”,即代码段需要修改,无法实现真正的位置无关。
(2)带-fPIC编译动态库:
# 带-fPIC编译(无警告)
gcc -fPIC -shared libadd.c -o libadd_pic.so无警告,生成的动态库是纯 PIC 的,可加载到任意虚拟地址。
(3)查看动态库的重定位类型:
# 查看不带-fPIC的动态库(有DT_TEXTREL)
readelf -d libadd_no_pic.so | grep TEXTREL
# 输出: 0x0000000000000016 (TEXTREL) 0x0
# 查看带-fPIC的动态库(无DT_TEXTREL)
readelf -d libadd_pic.so | grep TEXTREL
# 无输出,说明无文本重定位 这验证了:只有带-fPIC编译的动态库,才是真正的位置无关代码,支持任意地址加载。
动态库加载到虚拟地址空间后,程序如何调用库中的函数?这就是动态链接的核心:符号解析 + 地址重定位。
与静态链接(编译时重定位)不同,动态链接的重定位发生在程序运行时,主要依赖两个关键结构:全局偏移表(GOT) 和过程链接表(PLT)。
程序的代码段(.text)是只读的,动态链接时不能直接修改代码中的函数调用地址。为了解决这个问题,动态链接采用了 “间接寻址” 方案:
由于 GOT 位于可写的数据段,动态链接器可以在运行时修改 GOT 中的地址,无需修改只读的代码段。
GOT(Global Offset Table)是一个数组,每个元素存储一个动态库函数或全局变量的实际虚拟地址。GOT 位于可写的数据段(.got 或.got.plt),动态链接器会在动态库加载后,填充 GOT 中的地址。
我们用readelf -S查看可执行程序的 GOT 段:
# 编译一个依赖动态库的程序
gcc test1.c -o test1
# 查看GOT段
readelf -S test1 | grep -E "got|GOT"输出:
[24] .got PROGBITS 0000000000600fc0 00000fc0
[25] .got.plt PROGBITS 0000000000601000 00001000.got:存储全局变量的地址。.got.plt:存储动态库函数的地址,与 PLT 配合使用。
PLT(Procedure Linkage Table)是一组桩代码(stub),每个桩代码对应一个动态库函数。程序调用动态库函数时,先跳转到对应的 PLT 桩代码,再由桩代码通过 GOT 查找实际地址并跳转。
printf为例):call printf指令,实际跳转到printf@plt(PLT 桩代码)。printf@plt首先检查.got.plt中对应的条目: _dl_runtime_resolve函数,解析printf的实际地址,并填充到 GOT 中。printf实际地址。printf函数,完成后返回程序代码。这个过程被称为 “延迟绑定(Lazy Binding)”—— 函数地址的解析推迟到第一次调用时,避免程序启动时解析所有函数,提高启动速度。
我们用objdump -d反汇编可执行程序,查看printf@plt的桩代码:
objdump -d test1 | grep -A 10 "printf@plt"输出:
0000000000400520 <printf@plt>:
400520: f3 0f 1e fa endbr64
400524: f2 ff 25 d6 0a 20 00 bnd jmpq *0x200ad6(%rip) # 601000 <printf@GLIBC_2.2.5>
40052b: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)0x200ad6(%rip):RIP 相对寻址,指向.got.plt中的printf条目(地址0x601000)。0x601000存储的是printf@plt的下一条指令地址,桩代码会跳转到动态链接器解析地址;解析完成后,0x601000会被更新为printf的实际地址。 我们再用gdb调试,观察 GOT 条目的变化:
gdb test1
(gdb) start # 启动程序
(gdb) p &printf@plt # 查看printf@plt的地址
$1 = (<text variable, no debug info> *) 0x400520
(gdb) x/xw 0x601000 # 查看GOT中printf对应的条目(未调用前)
0x601000: 0x00400526 # 指向printf@plt的下一条指令
(gdb) call printf("hello") # 第一次调用printf
hello
(gdb) x/xw 0x601000 # 再次查看GOT条目(已解析)
0x601000: 0x7f8b4d9e75a0 # 指向printf的实际地址 完美验证了延迟绑定的过程:第一次调用后,GOT 条目被更新为printf的实际地址,后续调用无需再解析。
结合前面的知识点,我们梳理动态链接的完整流程:
ld-linux.so)。.dynamic段),找到所有需要加载的动态库(如libc.so)。_dl_runtime_resolve)。__libc_start_main,初始化 C 运行时环境,最终调用main函数。printf),跳转到对应的 PLT 桩代码。_dl_runtime_resolve,解析函数实际地址,填充到 GOT 表,跳转执行函数。main函数返回,__libc_start_main调用exit函数。_fini)。 动态链接器加载动态库时,需要按特定顺序查找库文件。如果找不到,会报 “error while loading shared libraries: libxxx.so: cannot open shared object file: No such file or directory” 错误。
动态链接器查找动态库的优先级的顺序:
DT_RPATH段(编译时通过-rpath指定,已过时)。LD_LIBRARY_PATH(临时生效,开发常用)。/etc/ld.so.cache(通过ldconfig生成)。/lib、/lib64、/usr/lib、/usr/lib64。适用于开发测试,重启终端后失效:
# 临时添加当前目录到动态库查找路径
export LD_LIBRARY_PATH=./:$LD_LIBRARY_PATH
# 运行程序
./test1适用于长期使用的库:
# 拷贝动态库到/usr/lib(需要root权限)
sudo cp libmystdio.so /usr/lib
# 更新系统缓存(可选)
sudo ldconfig避免拷贝库文件,方便更新:
# 创建软链接到/usr/lib
sudo ln -s $(pwd)/libmystdio.so /usr/lib/libmystdio.so
# 更新缓存
sudo ldconfig适用于自定义库路径,推荐生产环境使用:
# 创建自定义配置文件
sudo echo "$(pwd)" > /etc/ld.so.conf.d/mystdio.conf
# 更新系统缓存(必须执行,使配置生效)
sudo ldconfig 我们以自定义动态库libmystdio.so为例,验证方案 4:
# 1. 制作自定义动态库(参考之前的my_stdio.c和my_string.c)
gcc -fPIC -shared my_stdio.c my_string.c -o libmystdio.so
# 2. 配置动态库路径
sudo echo "$(pwd)" > /etc/ld.so.conf.d/mystdio.conf
sudo ldconfig
# 3. 编译测试程序
gcc main.c -lmystdio -o main
# 4. 运行程序(无需设置LD_LIBRARY_PATH)
./main 程序正常运行,说明动态链接器成功通过配置文件找到了libmystdio.so。
用ldd命令可以查看可执行程序依赖的所有动态库,以及它们的查找结果:
ldd main输出:
linux-vdso.so.1 => (0x00007fffacbbf000)
libmystdio.so => /home/user/test/libmystdio.so (0x00007f8917335000) # 找到自定义库
libc.so.6 => /lib64/libc.so.6 (0x00007f8916f67000)
/lib64/ld-linux-x86-64.so.2 (0x00007f8917905000) 如果某个库显示 “not found”,说明动态链接器找不到该库,可通过上面的 4 种方案解决。
最后我们用一张图来总结一下函数调用的过程:

了解了动态链接的原理后,我们再对比静态链接,总结两种链接方式的优缺点和适用场景。
特性 | 静态链接(.a) | 动态链接(.so) |
|---|---|---|
链接时机 | 编译时 | 运行时 |
可执行文件体积 | 大(包含库代码) | 小(仅包含函数地址表) |
运行依赖 | 无(独立运行) | 依赖动态库文件 |
内存占用 | 高(多个进程多份副本) | 低(代码段共享) |
更新维护 | 需重新编译程序 | 直接替换动态库 |
启动速度 | 快(无运行时解析) | 慢(需解析函数地址) |
灵活性 | 低(库更新需重编) | 高(支持版本切换) |
编译参数 | -static | -fPIC -shared |
curl、wget)。 我们用之前的main.c程序,分别进行静态链接和动态链接,对比结果:
gcc main.c libmystdio.so -o main_dynamic -L. -lmystdio
ls -l main_dynamic
# 输出:-rwxrwxr-x 1 user user 8600 11月 8 15:30 main_dynamic# 制作静态库
ar -rc libmystdio.a my_stdio.o my_string.o
# 静态链接(-static)
gcc main.c libmystdio.a -o main_static -L. -lmystdio -static
ls -l main_static
# 输出:-rwxrwxr-x 1 user user 835880 11月 8 15:31 main_static运行验证:
# 动态链接程序(依赖libmystdio.so)
./main_dynamic
# 静态链接程序(无依赖,可删除库文件)
rm -f libmystdio.so
./main_static静态链接程序依然能正常运行,而动态链接程序会报错(库文件被删除),完美体现了两种链接方式的核心差异。
动态库的加载与动态链接,看似复杂,但只要抓住 “虚拟地址空间” 和 “延迟绑定” 两个核心,就能逐步拆解其底层逻辑。理解这些原理,不仅能帮助我们解决开发中常见的 “库找不到”“版本冲突” 等问题,还能让我们更深入地理解操作系统的内存管理和程序执行机制。 如果你在实际开发中遇到动态库相关的疑难问题,欢迎在评论区交流~ 也可以尝试用本文介绍的工具(
readelf、objdump、ldd、gdb)分析自己的程序,加深对动态链接原理的理解!