本文主题内容
- 理解库的概念以及动静态库的区别
- 掌握 Linux 下静态库的制作与使用
- 掌握 Linux 下动态库的制作与使用
- 理解动态库运行时的搜索路径
- 认识目标文件和 ELF 文件结构
- 理解链接视图与执行视图
- 理解静态链接、程序加载与动态链接
- 理解位置无关码、GOT 与动态库共享
引言:只会使用
gcc main.c,遇到undefined reference或libxxx.so: cannot open shared object file时就很难定位问题。要真正理解库,不能只记住几个编译选项,还需要知道源文件怎样形成目标文件,链接器怎样解析符号和修正地址,操作系统又怎样把 ELF 装入进程地址空间。本文从库的制作开始,一直分析到动态链接的基本原理。
一、库的基本概念
1.1 什么是库
库是已经写好、相对成熟并且可以重复使用的代码集合。一个程序通常会依赖大量基础功能,如果每个项目都从零实现字符串处理、输入输出、网络通信等功能,开发成本会非常高。
库对外通常提供两部分内容:
- 头文件:给编译器提供函数声明、类型和宏定义
- 库文件:保存已经编译完成的二进制代码
从使用者的角度看,只需要包含头文件并在链接时指定对应的库,就可以调用库中提供的函数。
Linux 下常见的库分为两类:
| 类型 | Linux 后缀 | Windows 后缀 | 主要特点 |
|---|---|---|---|
| 静态库 | .a | .lib | 链接时把需要的代码复制进可执行程序 |
| 动态库 | .so | .dll | 运行时加载,多个进程可以共享 |
库的本质是可被其他程序复用的二进制代码。
1.2 库名规则
Linux 库文件通常遵循下面的命名规则:
lib库名.a lib库名.so链接时使用-l选项,需要去掉前缀lib和后缀.a或.so。
例:
# 库文件名为 libmystdio.a 或 libmystdio.sogcc main.c-lmystdio因此,libc.so对应的链接选项是-lc,libpthread.so对应的链接选项是-lpthread。
1.3 编译与链接
源代码不能直接被 CPU 执行,需要先经过编译形成目标文件,再由链接器把目标文件和库组织成可执行程序。
例:
//hello.c#include<stdio.h>voidrun(void);intmain(){printf("hello world!\n");run();return0;}//code.c#include<stdio.h>voidrun(void){printf("running...\n");}gcc-chello.c gcc-ccode.c gcc hello.o code.o-omain其中:
gcc -c只编译,不执行最终链接hello.o和code.o是目标文件- 最后一条命令由链接器解析两个目标文件之间的符号引用并生成可执行程序
二、静态库的制作与使用
2.1 静态库的特点
静态库以.a结尾。程序链接静态库时,链接器会从库中找到当前程序需要的目标模块,并把相关代码合并到最终的可执行程序中。
静态链接具有下面的特点:
- 可执行程序形成后,不再依赖原来的静态库文件
- 程序部署比较方便
- 多个程序会各自保存一份库代码,可执行文件和磁盘占用更大
- 库更新后,使用它的程序需要重新链接
2.2 准备库代码
例:
//my_string.h#pragmaonceintmy_strlen(constchar*str);//my_string.c#include"my_string.h"intmy_strlen(constchar*str){constchar*end=str;while(*end!='\0'){end++;}returnend-str;}先把源文件编译成目标文件:
gcc-cmy_string.c-omy_string.o2.3 使用 ar 生成静态库
GNU 的ar工具可以把多个目标文件归档成一个静态库。
例:
ar-rclibmystring.a my_string.o常用选项:
r:将目标文件插入库中,存在同名成员时进行替换c:库不存在时创建库t:查看库中的成员v:显示详细信息
例:
ar-tvlibmystring.a一个静态库并不是新格式,可以把它理解为若干.o文件的归档。链接器真正处理的仍然是目标文件中的 Section、符号表和重定位信息。
2.4 使用 Makefile 制作静态库
例:
libmystring.a: my_string.o @ar -rc $@ $^ @echo "build $@ done" my_string.o: my_string.c my_string.h @gcc -c my_string.c -o my_string.o .PHONY: clean clean: @rm -f *.o *.a如果需要把库交给其他人使用,还应同时整理头文件和库文件。
例:
mystring/ ├── include/ │ └── my_string.h └── lib/ └── libmystring.a2.5 使用静态库
例:
//main.c#include<stdio.h>#include"my_string.h"intmain(){constchar*str="abcdefg";printf("%s: %d\n",str,my_strlen(str));return0;}gcc main.c -I./mystring/include -L./mystring/lib-lmystring-omain三个选项分别表示:
-I:指定头文件搜索路径-L:指定库文件搜索路径-l:指定要链接的库
执行完成后,即使删除libmystring.a,已经形成的main仍然可以运行,因为需要的代码已经进入可执行程序。
注意:库的链接顺序会影响符号解析。使用传统链接器时,通常把依赖其他目标文件的库放在命令后部,例如gcc main.o -lmystring。
三、动态库的制作与使用
3.1 动态库的特点
动态库以.so结尾。使用动态库的可执行程序不会保存库函数的完整机器码,而是保留动态依赖和相关的符号信息。程序启动时,动态链接器负责把所需动态库映射到进程地址空间并完成必要的重定位。
动态链接具有下面的特点:
- 可执行程序通常更小
- 多个进程可以共享同一份只读库代码
- 库可以独立升级,但必须注意 ABI 兼容
- 程序运行时仍然需要找到对应的动态库
3.2 生成位置无关的目标文件
动态库可能被映射到不同进程地址空间中的不同位置,因此编译库代码时要使用位置无关码。
例:
gcc-fPIC-cmy_string.c-omy_string.o-fPIC表示生成 Position Independent Code,也就是位置无关码。
3.3 生成动态库
例:
gcc-sharedmy_string.o-olibmystring.so对应的 Makefile 可以写成:
libmystring.so: my_string.o @gcc -shared $^ -o $@ my_string.o: my_string.c my_string.h @gcc -fPIC -c my_string.c -o my_string.o .PHONY: clean clean: @rm -f *.o *.so3.4 链接动态库
例:
gcc main.c -I./mystring/include -L./mystring/lib-lmystring-omain这条命令与静态库的写法很相似。如果同一路径中同时存在同名的.so和.a,GCC 默认优先进行动态链接。需要强制静态链接时,可以根据实际环境使用-static,但系统必须提供相应的静态库。
可以使用ldd查看程序的动态依赖。
例:
ldd ./main编译时能找到动态库,只能说明链接器知道它在哪里;程序运行时,动态链接器还需要再次找到这个库。
四、动态库的运行时搜索路径
4.1 典型问题
程序链接成功后,直接运行可能出现下面的错误:
error while loading shared libraries: libmystring.so: cannot open shared object file: No such file or directory使用ldd查看时会看到:
libmystring.so => not found出现这个问题的原因是:-L只负责告诉链接器去哪里找库,并没有永久改变运行时动态链接器的搜索范围。
4.2 临时设置 LD_LIBRARY_PATH
例:
exportLD_LIBRARY_PATH=$PWD/mystring/lib:$LD_LIBRARY_PATH./main该方法只影响当前 shell 及其子进程,适合开发和测试。
4.3 配置系统动态库路径
可以把库目录写入/etc/ld.so.conf或/etc/ld.so.conf.d/下的配置文件,然后执行ldconfig更新缓存。
例:
echo"/opt/mystring/lib"|sudotee/etc/ld.so.conf.d/mystring.confsudoldconfig也可以把动态库安装到/lib、/usr/lib或/usr/local/lib等系统默认搜索路径,但自定义库通常更适合放在独立目录中再进行配置。
4.4 rpath
还可以在链接时把运行时搜索路径记录到可执行程序中。
例:
gcc main.c -L./mystring/lib-lmystring\-Wl,-rpath,'$ORIGIN/mystring/lib'-omain$ORIGIN表示可执行程序所在目录。部署自带动态库的应用时,这种相对路径比较方便。
注意:不要为了省事长期把不可信目录加入全局动态库搜索路径,否则可能加载到错误甚至恶意的同名库。
五、目标文件与 ELF
5.1 什么是目标文件
gcc -c生成的.o文件叫作目标文件。它已经包含机器指令,但其中对外部函数或变量的引用可能还没有确定最终地址,因此暂时不能直接运行。
例:
gcc-chello.cfilehello.o可能得到:
hello.o: ELF 64-bit LSB relocatable, x86-64目标文件的类型是可重定位文件。修改某一个源文件时,只需要重新编译对应的目标文件,最后重新链接即可,这也是大型工程进行增量构建的基础。
5.2 ELF 的四种常见文件
ELF 是 Executable and Linkable Format 的缩写。Linux 下常见的 ELF 文件包括:
- 可重定位文件:
.o - 可执行文件:普通可执行程序
- 共享目标文件:
.so - Core 文件:进程异常时保存的运行上下文
5.3 ELF 的整体结构
ELF 主要由下面几部分组成:
- ELF Header:描述文件类型、目标架构、入口地址以及其他表的位置
- Program Header Table:描述运行时应该怎样把文件映射成 Segment
- Section:保存代码、数据、符号、重定位信息等内容
- Section Header Table:描述各个 Section 的名称、类型、偏移和大小
ELF Header 可以理解为整个文件的索引入口,它告诉工具和操作系统到哪里寻找其他结构。
5.4 常见的 Section
| Section | 作用 |
|---|---|
.text | 保存可执行机器指令 |
.rodata | 保存字符串常量等只读数据 |
.data | 保存已经初始化的全局变量和静态变量 |
.bss | 为未初始化的全局变量和静态变量预留空间 |
.symtab | 保存符号表 |
.strtab | 保存符号名称等字符串 |
.rela.text | 保存需要对代码进行修正的重定位信息 |
.got、.got.plt | 为动态链接保存运行时地址 |
可以使用下面的命令查看 ELF:
readelf-hhello.o readelf-Shello.o readelf-shello.o objdump-dhello.o六、链接视图与执行视图
6.1 链接视图
链接器关注的是 Section。不同目标文件中的同类 Section 会被合并,符号会被解析,重定位位置会被修正。
例如,hello.o中调用了run,但编译hello.c时编译器并不知道run最终位于哪里。目标文件会把run记录为未定义符号,并在重定位表中记录需要修正的位置。链接器找到code.o中的run定义后,再完成地址修正。
例:
readelf-shello.o|greprun readelf-rhello.o如果所有输入文件和库中都没有找到所需定义,就会出现undefined reference。
6.2 执行视图
操作系统加载程序时,更关心 Segment。Segment 会把权限相同、加载方式相近的多个 Section 组织在一起。
例如:
.text和部分只读内容通常进入可读、可执行的 Segment.data和.bss通常进入可读、可写的 Segment- 不需要加载到内存的调试信息可以不进入
LOADSegment
使用下面的命令可以查看程序头表以及 Section 到 Segment 的映射关系:
readelf-l./main6.3 为什么要把 Section 合成 Segment
内存以页为基本管理单位,常见页大小是4KB。如果每个很小的 Section 都单独按页映射,会产生较多页内碎片。把权限相同的 Section 合并成 Segment,可以减少浪费,也便于操作系统统一设置读、写、执行权限。
Section 主要服务于编译和链接,Segment 主要服务于程序加载和运行。
七、静态链接原理
7.1 符号解析
每个目标文件都有自己的符号表。符号可能处于下面两种状态:
- 已定义:当前目标文件提供了函数或变量实体
- 未定义:当前目标文件使用了符号,但定义在其他目标文件或库中
链接器会建立全局符号关系,把未定义符号与其他输入文件中的定义进行匹配。
7.2 Section 合并
链接器会把多个目标文件中的同类 Section 合并,例如把各个.text合成最终代码区,把各个.data合成最终数据区,并为它们分配新的文件偏移和虚拟地址。
7.3 重定位
编译器单独处理某个源文件时,无法知道外部符号的最终地址,因此会先生成占位地址,同时留下重定位项。链接器完成布局和符号解析后,根据重定位表修改指令或数据中的地址。
例:
objdump-drhello.o-d用来反汇编,-r同时显示重定位信息。把二者放在一起观察,就能看到哪条指令需要链接器修正。
静态库的链接仍然遵循这一过程,只不过链接器会先从.a中按需提取提供目标符号的成员,而不是简单地把整个静态库无条件复制进去。
八、ELF 的加载过程
8.1 创建进程地址空间
执行程序时,内核根据 ELF 的 Program Header Table 建立相应的虚拟内存区域。常见区域包括代码区、只读区、数据区、堆、共享区和栈。
Program Header 中的LOAD项描述了:
- 文件中的起始偏移
- 映射到进程中的虚拟地址
- 文件中实际存在的数据长度
- 内存中需要占用的长度
- 读、写、执行权限
.bss在文件中通常不保存大量连续的零,只记录所需内存大小。加载时,内核为它准备相应的零初始化内存,因此p_memsz可能大于p_filesz。
8.2 文件并不是一次性全部读入内存
建立虚拟地址映射后,程序访问某个尚未进入物理内存的页面时会触发缺页异常,内核再把对应内容载入。这种按需加载减少了启动时不必要的 IO。
所以,所谓加载 ELF,更准确地说是先根据 ELF 建立虚拟内存映射和权限关系,随后再由缺页机制按需准备物理页面。
8.3 从入口地址开始执行
ELF Header 中记录入口地址。动态链接程序通常先经过运行时加载器和启动代码,准备参数、环境变量、动态库和 C 运行环境,之后由__libc_start_main等启动流程调用main。
注意:进程最先执行的并不一定是自己编写的main,main只是 C/C++ 运行时完成初始化之后调用的用户入口。
九、动态链接原理
9.1 动态库怎样进入进程地址空间
可执行程序的.interpSection 会指出需要使用的动态加载器,例如:
/lib64/ld-linux-x86-64.so.2内核启动程序后,动态加载器根据可执行程序的动态依赖查找.so,并使用类似mmap的方式把动态库映射到进程地址空间。
同一个动态库可以出现在多个进程各自的虚拟地址空间中,但只读代码页可以映射到同一份物理内存,因此既保持了进程地址空间的独立性,又实现了库代码共享。
9.2 为什么需要位置无关码
不同进程的地址空间布局可能不同,同一个.so不应依赖某个固定虚拟地址。位置无关码尽量使用相对寻址,使代码无论被映射到哪里都可以执行。
如果动态库的只读代码需要为每个进程修改大量绝对地址,就无法安全地共享物理代码页。PIC 把需要运行时确定的地址集中到可写数据结构中,代码本身保持只读和可共享。
9.3 GOT 与间接访问
GOT 是 Global Offset Table,也就是全局偏移表。动态库或可执行程序需要访问外部函数、全局变量时,可以先找到 GOT 中对应的表项,再通过表项中的真实地址完成访问。
动态加载器在重定位阶段把真实地址写入 GOT。之后代码只需要使用相对寻址找到自己的 GOT,再进行一次间接访问。
9.4 PLT 与延迟绑定
PLT 是 Procedure Linkage Table,也就是过程链接表。调用外部函数时,程序通常会经过 PLT,再根据 GOT 中保存的地址跳转到真实函数。
为了减少程序启动时的解析开销,部分函数可以采用延迟绑定:
- 第一次调用时进入动态加载器
- 动态加载器查找真实函数地址
- 把地址写回对应的 GOT 表项
- 后续调用直接跳转到真实函数
如果设置LD_BIND_NOW=1,可以要求动态链接器在启动阶段尽早完成符号解析。
9.5 动态链接过程
从整体上看,动态链接包括:
- 根据依赖项查找并映射动态库
- 为各个模块建立地址关系
- 解析动态符号
- 执行必要的重定位并更新 GOT
- 完成运行环境初始化
- 进入程序入口并最终调用
main
动态库之间也可能存在依赖。动态加载器会继续处理依赖图,但如果出现缺少库、版本不兼容或符号冲突,程序仍可能在启动或第一次调用相关函数时失败。
十、常用排查命令
10.1 查看库和 ELF 信息
# 判断文件类型filelibmystring.so# 查看动态依赖ldd ./main# 查看ELF头readelf-h./main# 查看Sectionreadelf-S./main# 查看Segmentreadelf-l./main# 查看符号readelf-s./main nm-C./main# 查看重定位项readelf-r./main# 反汇编objdump-d./main10.2 常见问题定位
| 现象 | 主要阶段 | 常见原因 |
|---|---|---|
| 头文件找不到 | 预处理/编译 | -I路径错误 |
undefined reference | 链接 | 缺少目标文件或-l,链接顺序错误 |
libxxx.so => not found | 运行加载 | 动态库搜索路径未配置 |
undefined symbol | 动态链接 | 库版本或 ABI 不匹配 |
| 程序段错误 | 运行 | 函数声明与实际 ABI 不一致、地址使用错误 |
十一、总结
库把成熟代码以二进制形式提供给其他程序复用。静态库本质上是目标文件的归档,链接器会按需取出代码并合入可执行程序;动态库则在运行时被动态加载器映射,多个进程可以共享其只读代码页。
从文件格式看,.o、.so和可执行程序都属于 ELF。Section、符号表和重定位表支撑链接过程,Program Header 和 Segment 支撑加载过程。静态链接要解决符号解析、Section 合并与重定位,动态链接还要解决运行时查找、地址无关、GOT/PLT 和库共享问题。
把编译、链接和加载看成连续过程,就能从根本上理解库为什么这样制作、这样使用,也能更快定位链接错误和动态库加载错误。