【Linux】库制作与原理:从静态库、动态库到 ELF 加载全过程(附实操与图解)
本文环境:x86-64 Linux,GCC + GNU Binutils(ar / readelf / objdump)。
文中所有命令输出均为真实运行结果,示例代码放在文末,可直接复现。
本文重点:
- 动静态库的制作
- 动静态库的使用
- 动态库的查找
- 可执行程序的 ELF 格式
- 可执行程序的加载过程
- 虚拟地址空间与动态库加载的过程
1. 什么是库
库是写好的、成熟的、可以复用的代码。现实开发中,每个程序都会依赖大量底层库——不可能每个人都从零开始写 printf、写网络协议栈,所以库的存在意义非常大。
本质上,库是一种可执行代码的二进制形式,可以被操作系统载入内存执行。它由一个或多个目标文件(.o)组成,使用者只需要拿到头文件 + 库文件就能使用,源码可以不公开。
| 类型 | Linux | Windows |
|---|---|---|
| 静态库 | .a | .lib |
| 动态库(共享库) | .so | .dll |

库在 C 程序构建流程中的位置
库文件的命名规则:lib + 库名 + 后缀,例如 libmymath.a、libmymath.so,库名是 mymath,链接时写 -lmymath。
1.1 静态库与动态库的区别

静态链接 vs 动态链接
| 对比项 | 静态库 .a | 动态库 .so |
|---|---|---|
| 链接时机 | 编译链接阶段,代码被拷贝进可执行文件 | 程序运行时,由动态链接器加载 |
| 可执行文件体积 | 大 | 小 |
| 运行时依赖 | 无 | 必须能找到对应 .so |
| 内存占用 | 每个进程一份副本 | 多个进程共享一份代码 |
| 库升级 | 需要重新链接所有程序 | 替换 .so 即可(ABI 不变时) |
2. 示例代码
后续所有实验都基于下面这个最小的数学库:
目录结构:
demo/
├── main.c
└── mylib/
├── include/
│ └── mymath.h
└── src/
├── add.c
└── sub.c
mylib/include/mymath.h:
#ifndef MYMATH_H
#define MYMATH_H
int add(int a, int b);
int sub(int a, int b);
#endif
mylib/src/add.c:
#include "mymath.h"
int add(int a, int b) { return a + b; }
mylib/src/sub.c:
#include "mymath.h"
int sub(int a, int b) { return a - b; }
main.c:
#include <stdio.h>
#include "mymath.h"
int main(void) {
printf("10 + 3 = %d\n", add(10, 3));
printf("10 - 3 = %d\n", sub(10, 3));
return 0;
}
3. 静态库的制作与使用

静态库的制作与使用
3.1 制作
# ① 把库源码编译成目标文件
gcc -c mylib/src/add.c mylib/src/sub.c -Imylib/include
# ② 用 ar 把目标文件归档成静态库
ar -rcs libmymath.a add.o sub.o
# 查看静态库里有哪些目标文件
ar -t libmymath.a
ar 是 GNU 归档工具,常用参数:
r(replace):把文件插入归档,已存在则替换c(create):归档不存在时自动创建,不给出提示s:写入符号索引,加快链接时的符号查找t:列出归档中的文件;v:显示详细信息
可以看出,静态库本质就是一堆 .o 文件打成的包。
3.2 使用
gcc main.c -o app_static -Imylib/include -L. -lmymath
三个关键选项:
| 选项 | 含义 | 示例 |
|---|---|---|
-I | 头文件搜索路径 | -Imylib/include |
-L | 库文件搜索路径 | -L. |
-l | 要链接的库名(去掉 lib 前缀和后缀) | -lmymath |
如果库和头文件已经安装到系统目录(如
/usr/include、/usr/lib),则-I和-L可以省略,但-l永远不能省(libc 除外,gcc 默认链接)。
运行结果:

制作并使用静态库
注意: 当同一目录下同时存在 libmymath.a 和 libmymath.so 时,gcc 默认优先使用动态库。想强制静态链接,可以加 -static(此时连 libc 也会被静态链接进来)。
4. 动态库的制作与使用

动态库的制作
4.1 制作
# ① 生成位置无关码(Position Independent Code)
gcc -fPIC -c mylib/src/add.c mylib/src/sub.c -Imylib/include
# ② 生成共享库
gcc -shared -o libmymath.so add.o sub.o
-fPIC:生成与位置无关的代码,库被加载到进程地址空间的任意位置都能正确运行(原理见第 8 节)。-shared:告诉 gcc 输出共享库,而不是可执行程序。
4.2 使用:编译通过,运行却失败?
gcc main.c -o app_dyn -Imylib/include -L. -lmymath
./app_dyn

动态库编译成功,却运行失败
很多初学者会在这里卡住:明明 -L. 告诉了 gcc 库在哪,为什么运行还找不到?
原因是:-L 只是告诉编译期的链接器 ld 去哪找库,它只在可执行文件里记下一句「我需要 libmymath.so」(DT_NEEDED),并不记录库的位置。程序运行时负责加载库的是另一个程序——动态链接器 ld-linux-x86-64.so.2,它有自己的一套查找规则。
ldd 命令可以查看程序依赖哪些动态库、以及这些库是否找得到,libmymath.so => not found 一目了然。
5. 动态库的查找
5.1 查找顺序

动态链接器 ld.so 查找.so 的顺序
动态链接器按以下顺序查找(详见 man ld.so):
- ELF 中的
DT_RPATH(仅在没有DT_RUNPATH时生效,已不推荐) - 环境变量
LD_LIBRARY_PATH - ELF 中的
DT_RUNPATH - 缓存文件
/etc/ld.so.cache(由ldconfig根据/etc/ld.so.conf生成) - 系统默认目录
/lib、/usr/lib(64 位系统还有/lib64、/usr/lib64等)
5.2 让程序找到动态库的 4 种方法
方法一:拷贝到系统默认目录(需要 root,会污染系统,不推荐用于自己的测试库)
sudo cp libmymath.so /usr/lib/
方法二:在系统默认目录建立软链接
sudo ln -s $(pwd)/libmymath.so /usr/lib/libmymath.so
方法三:设置环境变量 LD_LIBRARY_PATH(只对当前 shell 生效,适合调试)
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:$(pwd)
方法四:配置 /etc/ld.so.conf.d/,再执行 ldconfig(永久生效)
echo "$(pwd)" | sudo tee /etc/ld.so.conf.d/mymath.conf
sudo ldconfig
补充:用 RUNPATH 把搜索路径写进程序本身(随程序发布时最推荐)
gcc main.c -o app_rpath -Imylib/include -L. -lmymath -Wl,-rpath,'$ORIGIN'
$ORIGIN 表示「可执行文件所在目录」,这样把程序和 .so 放在一起拷到任何地方都能运行。

两种方式让程序找到动态库
5.3 静态链接 vs 动态链接:体积对比

静态链接 vs 动态链接:体积对比
全静态链接的程序把 libc 里用到的代码也拷贝了进来,体积是动态链接版本的约 49 倍。这也是为什么系统里绝大多数程序都选择动态链接。
6. 可执行程序的 ELF 格式
在 Linux 中,目标文件(.o)、可执行程序、动态库(.so)、内核转储(core dump) 用的都是同一种格式——ELF(Executable and Linkable Format)。
$ file app_dyn libmymath.so add.o
app_dyn: ELF 64-bit LSB pie executable, x86-64, ..., dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, ...
libmymath.so: ELF 64-bit LSB shared object, x86-64, ..., dynamically linked, ...
add.o: ELF 64-bit LSB relocatable, x86-64, ...
| ELF 类型 | 说明 | 示例 |
|---|---|---|
| 可重定位文件(REL) | 尚未链接,地址未确定 | add.o |
| 可执行文件(EXEC / DYN-PIE) | 可直接运行 | app_dyn |
| 共享目标文件(DYN) | 动态库 | libmymath.so |
| 核心转储(CORE) | 进程崩溃时的内存快照 | core |
6.1 ELF 的组成
一个 ELF 文件由四部分组成:
- ELF Header:文件的「身份证」,描述文件类型、目标机器、入口地址,以及两张表的位置。
- Program Header Table(程序头表):描述段(Segment),告诉加载器如何把文件映射进内存。可执行文件和动态库必须有。
- Sections(节):真正的代码和数据,如
.text、.data、.bss、.rodata、.symtab等。 - Section Header Table(节头表):描述每个节的名字、位置、大小,链接器和调试器使用。
常见的节:
| 节 | 内容 |
|---|---|
.text | 机器指令(代码) |
.rodata | 只读数据,如字符串常量 |
.data | 已初始化的全局 / 静态变量 |
.bss | 未初始化的全局 / 静态变量(文件中不占空间,只记录大小) |
.symtab / .strtab | 符号表 / 字符串表 |
.dynsym / .dynstr | 动态链接用的符号表 |
.rela.* | 重定位信息 |
.plt / .got | 动态库函数调用的跳转表(见第 8.3 节) |
.dynamic | 动态链接信息(依赖哪些库、RUNPATH 等) |
.interp | 动态链接器的路径 |
6.2 ELF Header

readelf -h app_dyn
- Magic:前 4 个字节
7f 45 4c 46即\x7f E L F,内核靠它识别 ELF 文件。 - Type:
DYN (Position-Independent Executable),现代 gcc 默认生成 PIE 程序。 - Entry point:程序入口地址
0x10a0,对应的是_start而不是main。
6.3 两种视图:Section 与 Segment

ELF 文件的两种视图
ELF 被设计成同时服务两类「读者」:
- 链接视图(Section):给链接器看。粒度细,便于符号解析、重定位。
- 执行视图(Segment):给加载器(内核 + 动态链接器)看。把权限相同的多个节合并成一个段,一次性
mmap进内存。
为什么要合并?因为内存按页(通常 4KB)管理、按页设置权限。如果每个节单独映射,一个只有几十字节的节也要独占一页,造成大量浪费。合并后:
- 减少页内碎片
- 减少
mmap次数 - 统一设置页权限:代码段
R E、只读数据段R、数据段RW
用 readelf -l 可以看到段与节的对应关系:

readelf -l app_dyn
可以清楚看到:第 03 号段(R E,可读可执行)包含了 .init .plt .text .fini 这些代码节;第 05 号段(RW)包含了 .data .bss .got 等可写节。
7. 可执行程序的加载过程

从敲下回车到 main() 的过程
当在 shell 中敲下 ./app_dyn 时,大致发生了这些事:
- bash
fork()+execve():创建子进程,并用新程序替换它。 - 内核解析 ELF:校验 Magic,读取程序头表。
- 内核映射
PT_LOAD段:根据每个 LOAD 段的偏移、虚拟地址、权限建立映射。注意,这里只是建立虚拟地址到文件的映射,真正的物理页要到第一次访问时触发缺页中断才会加载(按需分页)。 - 加载动态链接器:内核发现有
PT_INTERP段(/lib64/ld-linux-x86-64.so.2),于是把它也映射进来,并把控制权交给动态链接器,而不是程序自己的_start。 - 动态链接器工作:读取
.dynamic中的DT_NEEDED,按第 5 节的顺序找到libmymath.so、libc.so.6并映射进内存,然后完成重定位。 - 跳转到程序入口
_start:调用__libc_start_main,执行初始化(.init、全局构造函数),准备好argc / argv / envp。 - 进入
main()。
静态链接的程序没有
PT_INTERP段,第 4、5 步直接跳过,内核映射完就跳到_start。
7.1 编译后的程序在加载前就有「地址」吗?
有。看 readelf -l 的输出,每个段都有 VirtAddr,objdump -d 反汇编时每条指令前面也有地址,例如 119b: call 1070 <add@plt>。
这说明:编译器在生成程序时,就已经按「虚拟地址空间」的布局给代码和数据编好了地址(PIE 程序是从 0 开始的相对地址,加载时再加上一个随机基址)。这就是平坦模式(flat model)下的逻辑地址 = 虚拟地址。
所以「虚拟地址空间」并不只是操作系统的概念,编译器、链接器、加载器、操作系统都在遵守同一套约定:
- 编译器:按虚拟地址给指令和数据编址
- 加载器:按 Program Header 把段放到对应虚拟地址
- 操作系统:通过页表把虚拟地址映射到物理内存
- CPU:执行时只认虚拟地址,由 MMU 完成转换
8. 虚拟地址空间与动态库加载
8.1 动态库被放在哪里?
写一个小程序,在运行时打印自己的 /proc/<pid>/maps,可以看到真实的内存布局,整理如下:

进程虚拟地址空间
关键观察:
- 可执行文件本体被映射在低地址(
0x55f9...),分成r--p / r-xp / r--p / rw-p多段,与程序头表中的 LOAD 段一一对应。 libmymath.so、libc.so.6、ld-linux-x86-64.so.2都被映射在栈和堆之间的共享区(mmap 区)。- 每次运行这些基址都不同——这是 ASLR(地址空间布局随机化) 的效果。
8.2 为什么动态库必须是位置无关的(-fPIC)
既然动态库每次被加载的地址都不同,那么库里面如果写死了「add 函数在 0x7f750ec0c0f9」,换个地址就全错了。
传统做法是加载时修改代码段里的地址(加载时重定位),但这样代码页被改写后,每个进程都要有一份自己的副本,共享就失去了意义。
PIC 的做法是:
- 库内部的跳转全部使用相对于当前指令(RIP)的偏移,无论库加载在哪里,相对距离都不变。
- 需要访问外部符号(其他库的函数 / 全局变量)时,不直接写地址,而是通过数据段中的 GOT(全局偏移表)间接访问。运行时只需要修改 GOT(数据页,每个进程本来就各有一份),代码页保持不变,可以被所有进程共享。

多个进程共享同一份动态库的物理内存
8.3 PLT / GOT:动态库函数是怎么被调用到的
先看重定位信息在各个阶段的变化:

重定位: 从 main.o 到 PLT/GOT
- 编译后
main.o:call add的目标地址还是空的,只留下一条重定位项R_X86_64_PLT32 add,意思是「等链接时填上add的 PLT 入口」。 - 链接后
app_dyn:call已经被改成call 1070 <add@plt>,同时在 GOT 中预留了槽位,对应的重定位项是R_X86_64_JUMP_SLOT add,等运行时由动态链接器填入真实地址。
完整的调用流程:

PLT / GOT 调用流程
main执行call add@plt,跳到 PLT 中的一小段桩代码。- 桩代码执行
jmp *GOT[add],到 GOT 表里取目标地址。 - 第一次调用时,GOT 槽中还不是
add的真实地址,而是指向解析逻辑,于是进入动态链接器的_dl_runtime_resolve。 - 动态链接器在已加载的库中查找符号
add,找到后回填到 GOT 槽。 - 跳转到
libmymath.so中真正的add执行。之后再调用add,第 2 步直接就能拿到真实地址,只多一次间接跳转。
这种「用到时才解析」的策略叫延迟绑定(Lazy Binding),可以加快程序启动速度。
补充:许多发行版出于安全考虑默认开启
-z now(Full RELRO),在程序启动时就解析全部符号,然后把 GOT 设为只读,防止 GOT 被篡改。机制不变,只是解析时机提前。可以用LD_BIND_NOW=1 ./app_dyn观察同样的效果。
8.4 静态库为什么不需要这些?
静态链接时,add.o、sub.o 的代码被直接拷贝进可执行文件,链接器在链接阶段就知道它们最终的地址,直接把 call 的目标改成真实函数地址:
$ nm app_static | grep -E ' T (add|sub|main)'
00000000000011a2 T add
0000000000001149 T main
00000000000011ba T sub
add 和 main 在同一个可执行文件里,不需要 PLT / GOT,也不需要运行时查找。
9. 总结
| 问题 | 结论 |
|---|---|
| 库是什么 | 一组可复用的目标文件,以二进制形式发布 |
| 静态库怎么做 | gcc -c 生成 .o,ar -rcs 打包 |
| 动态库怎么做 | gcc -fPIC -c 生成位置无关的 .o,gcc -shared 生成 .so |
| 怎么使用库 | -I 头文件路径,-L 库路径,-l 库名 |
| 动态库运行找不到 | -L 只管编译期;运行期看 LD_LIBRARY_PATH / RUNPATH / ld.so.cache / 系统目录 |
| ELF 两种视图 | Section 给链接器,Segment 给加载器;同权限的节合并成段 |
| 程序如何加载 | 内核映射 LOAD 段 → 交给 ld.so → 加载依赖库 + 重定位 → _start → main |
| 动态库为何能共享 | PIC 让代码页与加载地址无关,外部地址集中放在可写的 GOT 中 |
| 函数如何跨库调用 | call xxx@plt → jmp *GOT → 首次由 ld.so 解析并回填 |
常用命令速查
ar -t libxxx.a # 查看静态库里的目标文件
nm -D libxxx.so # 查看动态库导出的符号
ldd ./app # 查看程序依赖的动态库
file ./app # 查看文件类型
readelf -h ./app # ELF 头
readelf -S ./app # 节头表(Section)
readelf -l ./app # 程序头表(Segment)及映射关系
readelf -d ./app # 动态段:NEEDED、RUNPATH 等
readelf -r ./app # 重定位表
objdump -d ./app # 反汇编
cat /proc/<pid>/maps # 查看进程的内存映射
附录:一键复现
把第 2 节的代码放好后,在 demo/ 目录下新建 Makefile:
INC := -Imylib/include
SRC := mylib/src/add.c mylib/src/sub.c
.PHONY: static shared clean
static:
gcc -c $(SRC) $(INC)
ar -rcs libmymath.a add.o sub.o
gcc main.c -o app_static $(INC) -L. -lmymath -static
rm -f add.o sub.o
./app_static
shared:
gcc -fPIC -c $(SRC) $(INC)
gcc -shared -o libmymath.so add.o sub.o
gcc main.c -o app_dyn $(INC) -L. -lmymath -Wl,-rpath,'$$ORIGIN'
rm -f add.o sub.o
./app_dyn
clean:
rm -f *.o *.a *.so app_static app_dyn
然后执行 make static 或 make shared 即可。注意 Makefile 中命令前必须是 Tab,$$ORIGIN 中的 $$ 是 Makefile 对 $ 的转义。
如果本文对你有帮助,欢迎点赞、收藏、关注~有问题欢迎在评论区交流。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)