ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

【Linux】库制作与原理:从静态库、动态库到 ELF 加载全过程(附实操与图解)

【Linux】库制作与原理:从静态库、动态库到 ELF 加载全过程(附实操与图解)

本文环境:x86-64 Linux,GCC + GNU Binutils(ar / readelf / objdump)。
文中所有命令输出均为真实运行结果,示例代码放在文末,可直接复现。

本文重点:

  • 动静态库的制作
  • 动静态库的使用
  • 动态库的查找
  • 可执行程序的 ELF 格式
  • 可执行程序的加载过程
  • 虚拟地址空间与动态库加载的过程

1. 什么是库

库是写好的、成熟的、可以复用的代码。现实开发中,每个程序都会依赖大量底层库——不可能每个人都从零开始写printf、写网络协议栈,所以库的存在意义非常大。

本质上,库是一种可执行代码的二进制形式,可以被操作系统载入内存执行。它由一个或多个目标文件(.o)组成,使用者只需要拿到头文件 + 库文件就能使用,源码可以不公开。

类型LinuxWindows
静态库.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:

#ifndefMYMATH_H#defineMYMATH_Hintadd(inta,intb);intsub(inta,intb);#endif

mylib/src/add.c:

#include"mymath.h"intadd(inta,intb){returna+b;}

mylib/src/sub.c:

#include"mymath.h"intsub(inta,intb){returna-b;}

main.c:

#include<stdio.h>#include"mymath.h"intmain(void){printf("10 + 3 = %d\n",add(10,3));printf("10 - 3 = %d\n",sub(10,3));return0;}

3. 静态库的制作与使用

静态库的制作与使用

3.1 制作

# ① 把库源码编译成目标文件gcc-cmylib/src/add.c mylib/src/sub.c -Imylib/include# ② 用 ar 把目标文件归档成静态库ar-rcslibmymath.a add.o sub.o# 查看静态库里有哪些目标文件ar-tlibmymath.a

ar是 GNU 归档工具,常用参数:

  • r(replace):把文件插入归档,已存在则替换
  • c(create):归档不存在时自动创建,不给出提示
  • s:写入符号索引,加快链接时的符号查找
  • t:列出归档中的文件;v:显示详细信息

可以看出,静态库本质就是一堆.o文件打成的包。

3.2 使用

gcc main.c-oapp_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-cmylib/src/add.c mylib/src/sub.c -Imylib/include# ② 生成共享库gcc-shared-olibmymath.so add.o sub.o
  • -fPIC:生成与位置无关的代码,库被加载到进程地址空间的任意位置都能正确运行(原理见第 8 节)。
  • -shared:告诉 gcc 输出共享库,而不是可执行程序。

4.2 使用:编译通过,运行却失败?

gcc main.c-oapp_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):

  1. ELF 中的DT_RPATH(仅在没有DT_RUNPATH时生效,已不推荐)
  2. 环境变量LD_LIBRARY_PATH
  3. ELF 中的DT_RUNPATH
  4. 缓存文件/etc/ld.so.cache(由ldconfig根据/etc/ld.so.conf生成)
  5. 系统默认目录/lib、/usr/lib(64 位系统还有/lib64、/usr/lib64等)

5.2 让程序找到动态库的 4 种方法

方法一:拷贝到系统默认目录(需要 root,会污染系统,不推荐用于自己的测试库)

sudocplibmymath.so /usr/lib/

方法二:在系统默认目录建立软链接

sudoln-s$(pwd)/libmymath.so /usr/lib/libmymath.so

方法三:设置环境变量LD_LIBRARY_PATH(只对当前 shell 生效,适合调试)

exportLD_LIBRARY_PATH=$LD_LIBRARY_PATH:$(pwd)

方法四:配置/etc/ld.so.conf.d/,再执行ldconfig(永久生效)

echo"$(pwd)"|sudotee/etc/ld.so.conf.d/mymath.confsudoldconfig

补充:用 RUNPATH 把搜索路径写进程序本身(随程序发布时最推荐)

gcc main.c-oapp_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)。

$fileapp_dyn libmymath.so add.o app_dyn: ELF64-bit LSB pie executable, x86-64,..., dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2,... libmymath.so: ELF64-bit LSB shared object, x86-64,..., dynamically linked,... add.o: ELF64-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时,大致发生了这些事:

  1. bashfork()+execve():创建子进程,并用新程序替换它。
  2. 内核解析 ELF:校验 Magic,读取程序头表。
  3. 内核映射PT_LOAD段:根据每个 LOAD 段的偏移、虚拟地址、权限建立映射。注意,这里只是建立虚拟地址到文件的映射,真正的物理页要到第一次访问时触发缺页中断才会加载(按需分页)。
  4. 加载动态链接器:内核发现有PT_INTERP段(/lib64/ld-linux-x86-64.so.2),于是把它也映射进来,并把控制权交给动态链接器,而不是程序自己的_start。
  5. 动态链接器工作:读取.dynamic中的DT_NEEDED,按第 5 节的顺序找到libmymath.so、libc.so.6并映射进内存,然后完成重定位。
  6. 跳转到程序入口_start:调用__libc_start_main,执行初始化(.init、全局构造函数),准备好argc / argv / envp。
  7. 进入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 调用流程

  1. main执行call add@plt,跳到 PLT 中的一小段桩代码。
  2. 桩代码执行jmp *GOT[add],到 GOT 表里取目标地址。
  3. 第一次调用时,GOT 槽中还不是add的真实地址,而是指向解析逻辑,于是进入动态链接器的_dl_runtime_resolve。
  4. 动态链接器在已加载的库中查找符号add,找到后回填到 GOT 槽。
  5. 跳转到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 Tadd0000000000001149 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-tlibxxx.a# 查看静态库里的目标文件nm-Dlibxxx.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 对$的转义。

如果本文对你有帮助,欢迎点赞、收藏、关注~有问题欢迎在评论区交流。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进