跳到主要内容Linux 动静态库与 ELF 加载全解析:从制作到底层原理 | 极客日志C
Linux 动静态库与 ELF 加载全解析:从制作到底层原理
综述由AI生成深入解析 Linux 下动静态库的区别与制作流程,涵盖编译链接原理及 ELF 文件格式结构。详细阐述了静态库合并代码与动态库运行时加载的机制,包括位置无关代码 PIC、全局偏移表 GOT 及过程连接表 PLT 的作用。通过对比分析,帮助开发者理解从目标文件到可执行程序的完整链接加载过程,解决库依赖与版本兼容问题。
云间运维4.6K 浏览 
在 Linux 开发中,库是绕不开的核心概念 —— 它是封装好的可复用代码,让开发者无需从零实现基础功能,大幅提升开发效率。但你是否好奇:静态库(.a)和动态库(.so)有何区别?动态库为什么会出现'找不到'的报错?可执行程序是如何加载库并运行的?ELF 文件又藏着怎样的底层秘密?
一、什么是库?动静态库的核心差异
库是写好的现有的,成熟的,可以复用的代码,例如我们在 C 语言中使用的 printf、scanf 等函数就被称为库函数,它们的实现在 C 标准库中,因此我们可以直接去使用这些函数。库是二进制形式的可复用代码,本质是编译后的目标文件(.o)的集合。
现实中每个程序都要依赖很多基础的底层库,不可能每个人的代码都从零开始,因此库的存在意义非同寻常。下面先让我们看一看 Linux 下的 C 标准库:

Linux 下库分为两类,核心差异在于'链接时机'和'资源占用':
| 类型 | 后缀(Linux) | 核心特点 | 优势 | 劣势 |
|---|
| 静态库 | .a | 编译链接时,库代码直接合并到可执行程序,运行时无需依赖库文件 | 运行独立、无需额外依赖 | 可执行文件体积大、库更新需重新编译 |
| 动态库 | .so | 编译仅记录函数入口地址,运行时才加载库代码,多程序可共享同一份库内存 | 文件体积小、库更新无需重编 | 运行依赖库文件、加载有轻微开销 |
windows 下静态库后缀为 .lib,动态库后缀为 .dll
举个直观例子:用静态库编译的 a.out,大小可能有 10MB(包含库代码);用动态库编译的 a.out 可能仅 1MB(仅包含库引用),但运行时必须找到对应的 .so 文件。
二、静态库:制作与使用(一步到位)
静态库是一组预先编译好的二进制目标代码(.o/.obj 文件) 的打包集合,里面包含了可复用的函数、数据或类的实现。你可以把它理解成一个'代码工具箱':在程序编译的链接阶段,编译器会把你用到的静态库中的代码直接复制到最终生成的可执行文件里,适合无需频繁更新的基础功能封装。这也对应其名字中'静态'二字。
- 核心特征:静态库的代码是一次性嵌入到可执行文件中的,最终的可执行文件不依赖外部库文件就能独立运行。
2.1、静态库的工作原理(编译链接流程)
要理解静态库,先看完整的编译链接流程(以 C 语言为例):

- 编译:把单个源文件编译成目标文件(.o)(只编译不链接,生成二进制机器码)。
- 打包:用工具(如 Linux 的
ar)把多个目标文件打包成静态库。
- 链接:编译器将主程序的目标文件和静态库中的相关代码复制并合并,生成最终的可执行文件。
一旦链接完成,静态库就和可执行文件'解绑'了 —— 即使删除静态库,可执行文件依然能正常运行。
2.2、静态库制作步骤
假设我们有两个源文件 my_stdio.c(封装文件 IO)和 my_string.c(封装字符串函数),要制作成静态库 libmystdio.a:
1、编写源文件与头文件
- 头文件
my_stdio.h/my_string.h:声明库函数(如 mfopen、my_strlen);
- 源文件
my_stdio.c/my_string.c:实现库函数。
2、编译生成目标文件(.o)
将源文件编译为可重定位目标文件(不链接,仅编译):
gcc -c my_stdio.c my_string.c
3、用 ar 命令打包为静态库
如何将多个 .o 文件打包成静态库呢?Linux 下我们可以用 ar 命令来完成:
ar:Linux 下的归档工具,用于将多个 .o 文件打包成静态库(.a 文件),核心参数:
r:替换 / 添加文件到归档库中。
c:创建归档库(若不存在则新建)。
s:生成索引(提高链接效率)。
ar -rc libmystdio.a my_stdio.o my_string.o
这里有两点需要注意:对于静态库和动态库来说,我们保存的名字都是以 lib+ 库名 + 后缀 构成的,也就是说,对于静态库 libc.a 来说,它的库名就是 c,这也就是我们说的 C 标准库,同理对于动态库 libc.so.6 来说,它的库名也是 c,后面的 .6 是版本号。这些库名在我们使用动静态时有它们的作用。
上面我们使用 ar 工具归档时只有两个 .o 文件,写的时候还很简单;但如果我们要归档一百甚至一千个 .o 文件呢?如果一个个的把文件名写上去就太麻烦了,我们可以使用 Makefile 来帮助我们进行归档,可以大大简化我们的工作:
CC = gcc
CFLAGS = -Wall -O2 -fPIC
LIB_NAME = libmylib.a
SRC_FILES = $(wildcard *.c)
OBJ_FILES = $(patsubst %.c, %.o, $(SRC_FILES))
all: $(LIB_NAME)
$(LIB_NAME): $(OBJ_FILES)
ar rcs $@ $^
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
clean:
rm -rf $(OBJ_FILES) $(LIB_NAME)
.PHONY: all clean
4、整理库文件
mkdir -p stdc/include stdc/lib
cp *.h stdc/include
cp *.a stdc/lib
2.3、静态库使用方法
当我们使用静态库时,需告诉编译器'头文件在哪'、'库文件在哪'、'库名是什么':
gcc main.c -lmystdio
gcc main.c -I./stdc/include -L./stdc/lib -lmystdio
-I:指定头文件搜索路径;
-L:指定库文件搜索路径;
-l:指定库名(核心规则:libxxx.a → 库名 xxx)。
那么为什么当我们使用 C 标准库(比如 stdio.h、stdlib.h 对应的库)时却不需要告诉编译器这些消息呢?核心原因:C 标准库是编译器 / 系统的'内置标配'
简单来说,C 标准库是 C 语言生态的基础组成部分,编译器和操作系统在设计时就已经将它的相关信息内置为默认配置,而自定义静态库是你自己新增的、编译器'不认识'的资源,因此需要手动告知。
关键特性:编译后生成的可执行程序可独立运行,删除静态库不影响(库代码已合并)。
2.4、静态库的优缺点
- 独立运行:可执行文件不依赖外部库,部署时无需附带库文件,移植性强。
- 运行速度快:代码直接嵌入可执行文件,无需运行时加载外部库,减少了动态链接的开销。
- 版本稳定:编译时确定了库的版本,不会出现'库版本不兼容'的问题。
- 可执行文件体积大:每个使用静态库的程序都会复制一份库代码,多个程序同时运行会占用更多内存。
- 更新麻烦:如果静态库有 bug 或需要升级,必须重新编译所有使用该库的程序。
- 编译时间长:链接阶段需要复制库代码,大型项目编译耗时增加。
一个可执行程序可能用到许多的库,这些库运行有的是静态库,有的是动态库,而我们的编译默认为动态链接库,只有在该库下找不到动态 .so 的时候才会采用同名静态库。这时我们也可以使用 gcc 的 -static 强转设置链接静态库。
三、动态库:制作、使用与'找不到'问题
要理解动态库(也叫'共享库'),可以先和之前的静态库对比:静态库是编译时把代码复制到程序里,而动态库是运行时才加载到内存、多个程序共享同一份代码—— 这是它的核心特点。
3.1、什么是动态库
动态库是一组预先编译好的二进制代码集合,动态库的核心是'运行时加载':
- 程序编译链接阶段,不会把动态库的代码复制到可执行文件中,只会记录'需要用到哪个动态库的哪些函数';
- 程序运行时,由操作系统的'动态链接器'负责将动态库加载到内存,程序再从内存中调用动态库的代码;
- 多个程序可以共享同一份加载到内存的动态库(因此也叫'共享库')。
制作时需生成位置无关码(PIC),使用时需解决'动态库查找路径'问题,适合频繁更新或多程序共享的功能。
在 Windows 中动态库的后缀是 .dll(Dynamic Link Library),同时会生成对应的导入库(.lib)(注意:Windows 的 .lib 可能是静态库,也可能是动态库的导入库,需区分)
动态库的流程和静态库核心差异在'链接时机'和'代码复用方式':
- 位置无关代码(PIC):动态库必须编译为'位置无关代码',这样加载到内存任意地址都能正常运行(静态库不需要);
- 运行时加载:可执行文件仅包含动态库的'引用信息',运行时动态链接器(如 Linux 的
ld-linux.so)才会找到并加载动态库到内存;
- 内存共享:同一份动态库在内存中只加载一次,多个程序共享这一份代码,操作系统用'引用计数'管理(加载一次计数 + 1,所有程序退出后计数为 0,才释放内存)。
3.2、动态库制作步骤
我们基于上面静态库同样的源文件,制作动态库 libmystdio.so:
1、编译生成位置无关目标文件(PIC)
在制作动态库时,我们在编译生成.o 文件时需要加上 -fPIC 选项生成位置无关码,确保库可加载到任意内存地址:
gcc -fPIC -c my_stdio.c my_string.c
2、链接生成动态库
在制作静态库时我们需要使用了 ar 工具,制作动态库时我们可以使用 gcc,带上 -shared 选项指定生成共享库格式:
gcc -o libmystdio.so my_stdio.o my_string.o -shared
3.2 动态库的使用
需要注意的是,当我们存在同名静态库和动态库时,gcc 默认链接动态库:
我们可以使用 ldd 命令查看当前可执行程序依赖的动态库:
但是我们可以发现我们对应的 mystdio 库后面对应的是 not found,与标准库后对应的不同,这是为什么呢?我们明明在链接时指定了对应库文件的路径。接下来先让我们运行一下可执行程序来看一下:
可以看到,虽然说我们链接时没有出错,但程序运行时却发生找不到文件的报错,这是因为系统默认只在 /usr/lib 等目录找动态库,当前目录的 libmystdio.so 不在搜索路径里。解决这个问题方法有 3 种:
- 修改配置文件永久生效:在
/etc/ld.so.conf.d/ 下新建配置文件(如 mylib.conf),写入动态库所在路径,然后执行 sudo ldconfig 更新缓存。
将动态库复制到系统库目录或者在系统的库目录建立软链接(需 root 权限):
sudo cp libmath.so /usr/lib
export LD_LIBRARY_PATH=./
为什么使用静态库时不需要这么复杂呢?这就在于使用静态库是在链接时将对应的代码从静态库中拷贝到我们的最后的可执行程序中,而动态库则是在我们运行可执行文件时才会去链接对应的动态库,至于它们是如何链接的我们后面再说。
3.3 动态库的优缺点
- 可执行文件体积小:仅记录动态库引用,不复制代码,程序安装包更轻便;
- 内存共享:同一份动态库在内存中只加载一次,多个程序共享,节省内存资源;
- 更新维护方便:只需替换动态库文件,无需重新编译所有依赖它的程序;
- 版本灵活:可通过'版本号后缀'(如
libmath.so.1)实现多版本共存。
- 依赖问题:程序运行时必须找到对应的动态库,否则会报错('找不到 xxx.dll' 是 Windows 常见问题);
- 运行时开销:动态链接器加载库、解析函数地址需要额外时间,比静态库启动稍慢;
- 版本兼容风险:若动态库更新后接口变化(如函数参数修改),依赖它的程序可能崩溃('DLL 地狱' 问题)。
动态库是'运行时共享'的二进制代码包,核心优势是节省内存、便于更新,适合多程序复用、需频繁迭代的场景;但要注意处理依赖问题和版本兼容。
3.4 动态库 vs 静态库的核心区别与补充
| 维度 | 静态库(.a/.lib) | 动态库(.so/.dll) |
|---|
| 链接时机 | 编译时链接(代码复制) | 运行时链接(仅引用) |
| 可执行文件体积 | 大(包含库代码) | 小(仅含引用) |
| 内存占用 | 多程序复用重复占用 | 多程序共享同一份,占用少 |
| 部署方式 | 无需带库,独立运行 | 需附带库文件,依赖路径 |
| 更新成本 | 需重新编译所有程序 | 替换库文件即可 |
在 Linux 中 gcc/g++ 等编译器默认使用动态库,如果非要使用静态库在链接时需要加上 -static 选项,当然,若是只有静态库时不需要加 -static 选项也会链接静态库。
四、ELF 格式:链接与加载的核心
上面我们了解了动静态库的制作以及使用,那么它们具体是如何链接到我们的可执行程序上的呢?这里有一个关键的概念:ELF 文件。下面让我们来认识一下什么是 ELF 文件。
无论是 .o 文件、可执行程序,还是 .so 动态库,本质都是ELF(Executable and Linkable Format)文件—— 它是 Linux 下统一的二进制文件格式,决定了文件如何被链接和加载。
- 可重定位文件(.o):编译后的目标文件,需链接合并为可执行程序;
- 可执行文件(a.out):可直接运行,包含完整的程序逻辑和加载信息;
- 共享目标文件(.so):动态库,运行时被加载到内存;
- 核心转储(core):进程崩溃时的内存快照,用于调试。
4.1 ELF 的核心结构
ELF 文件由四部分组成,关键是'链接视图'和'执行视图':
- ELF 头(ELF Header):文件开头,记录文件类型、机器架构、入口地址、程序头表 / 节头表位置;
- 程序头表(Program Header Table):执行视图(加载时用),描述如何将文件加载到内存(合并节为段);
- 节头表(Section Header Table):链接视图(编译链接时用),描述文件中的节(如
.text 代码节、.data 数据节);
- 节(Section):文件的基本单位,核心节包括:
.text:存储机器指令(只读);
.data:已初始化的全局变量 / 静态变量;
.bss:未初始化的全局变量 / 静态变量(仅占地址,不占文件空间);
.symtab:符号表(函数名、变量名与地址的映射);
.reloc:重定位表(记录需修正地址的符号)。
4.2 多个 ELF 的.o 形成可执行
我们的 .o 文件是 EFL,又多个 .o 形成的可执行文件也是 ELF,也就是说,其实多个.o 文件链接形成可执行文件实际上就是多个 ELF 合并为一个 ELF,如下图所示:
每个 .o 文件包含多个节(.text、.data、.bss、.rodata 等),链接器会将所有 .o 中 同类型、同属性的节合并成一个大节:
- 所有
.o 的 .text (机器码)→ 可执行文件的 .text ;
- 所有
.o 的 .data (已初始化全局变量)→ 可执行文件的 .data ;
- 所有
.o 的 .bss (未初始化全局变量)→ 可执行文件的 .bss ;
- 所有
.o 的 .rodata (只读数据,如字符串常量)→ 可执行文件的 .rodata 。
合并后的每个节都有访问属性(比如可读、可写、可执行),链接器会把属性兼容的多个节打包成一个段(Segment),生成'程序头表(Program Header Table)'描述这些段 —— 这是生成可执行文件的核心一步。而这些段也就是我们经常说的代码段、数据段等等。
段是加载器的最小操作单位,加载器会按段的属性把对应的节加载到内存的指定区域。
我们可以用 readelf 命令查看 ELF 文件的信息:
readelf -h a.out
readelf -l a.out
readelf -S a.out
我们可以查看一下可执行文件的程序头表和节头表,下面是可执行程序中的段:
可以看到在我们的可执行程序中一共有 13 个段,每一个段后面是该段中包含的节;下面是我们可执行程序中的节:
这个合并工作也已经在形成 ELF 的时候,合并方式已经确定了,具体合并原则被记录在了 ELF 的 程序头表 (Program header table) 中
Section 合并的主要原因是为了减少页面碎片,提高内存使用效率。如果不进行合并,假设页面大小为 4096 字节(内存块基本大小,加载,管理的基本单位),如果 .text 部分为 4097 字节,.init 部分为 512 字节,那么它们将占用 3 个页面,而合并后,它们只需 2 个页面。
- 此外,操作系统在加载程序时,会将具有相同属性的
section 合并成一个大大的 segment,这样就可以实现不同的访问权限,从而优化内存管理和权限访问控制。
对于程序头表和节头表又有什么用处呢,ELF 文件提供了 2 个不同的视图(视角)来让我们理解这两个部分:
- 链接视图 (Linking view) - 对应节头表
Section header table:文件结构的粒度更细,将文件按功能模块的差异进行划分,静态链接分析的时候一般关注的是链接视图,能够理解 ELF 文件中包含的各个部分的信息。
- 执行视图 (execution view) - 对应程序头表
Program header table:告诉操作系统如何加载可执行文件,完成进程内存的初始化。一个可执行程序的格式中,一定有 program header table。
- 说白了就是:一个在链接时作用,一个在运行加载时作用。
我们可以在 ELF Header 中找到文件的基本信息,以及可以看到 ELF Header 是如何定位程序头表和节头表的:
4.3 平坦模式编址
平坦模式编址(Flat Memory Model)是现代操作系统(如 Linux/Unix)最核心的内存编址方式,也是 ELF 可执行文件能被简单加载、运行的关键基础。
4.3.1 先搞懂:平坦模式编址是什么?
平坦模式编址是一种内存地址管理方式,核心特征是:整个进程的虚拟地址空间是一个单一、连续、无分段的线性地址空间,CPU 访问内存时只需使用一个'绝对的线性地址',无需通过'段基址 + 偏移量'的方式拼接地址(对比早期的'分段编址模式')。
- 分段模式:内存像多本分开的书,找内容需要先指定'哪本书(段)'+'第几页(偏移)';
- 平坦模式:内存像一本完整的大书,找内容只需指定'全局第几页(单一地址)'。
4.3.2 平坦模式编址的好处
平坦模式下,链接器生成可执行文件时,就已经按'最终要加载到内存的虚拟地址空间'分配了所有节(.text/.data 等)的虚拟地址(比如 0x401000),这些地址直接写在 ELF 文件中。加载时,加载器只需把 ELF 的代码 / 数据'映射'到这些预设的虚拟地址,无需修改文件中的任何地址(即无运行时重定位)
平坦模式让可执行文件在磁盘上就'自带'加载后的虚拟地址,加载时只需直接映射到这些虚拟地址,无需修改地址本身,仅需完成虚拟→物理的硬件映射,加载效率拉满。
而在 ELF Header 中存储的 Entry point address 存储的是:可执行文件加载到内存后,CPU 开始执行第一条指令的「虚拟地址」(平坦模式下的绝对线性地址)。简单来说:它是操作系统加载完 ELF 文件后,'告诉 CPU 该从哪里开始干活'的地址 —— 就像一本书的'正文第一页页码',CPU 拿到这个地址就直接跳过去执行指令。
要注意的是它不是 main() 函数的地址(核心误区)
你写的 main() 函数是程序的'业务入口',但不是 CPU 执行的'第一个入口':Entry point address 指向的是编译器自动插入的启动函数(x86_64 Linux 下是 _start,x86 是 _start/_entry);这个启动函数(_start)负责:初始化进程运行环境(设置栈、传递命令行参数 / 环境变量、初始化全局变量)→ 调用 main() 函数 → main() 执行完后,处理退出逻辑(调用 exit())。
- 对于
.o 目标文件:e_entry 值为 0(因为 .o 无法直接运行,无执行入口);
- 对于共享库(
.so):e_entry 通常也为 0(共享库是被其他程序加载的,无独立执行入口);
- 仅对于 ELF 可执行文件(
ET_EXEC 类型):e_entry 是有效的、非 0 的虚拟地址。
在操作系统中所使用的地址都是虚拟地址,操作系统会通过 CR3 寄存器进行虚拟地址到物理地址的转换。
操作系统加载可执行程序时,先为进程创建 PCB(进程控制块),再在 PCB 中初始化 mm_struct(内存管理结构体),接着基于 ELF 文件的 LOAD 段信息,通过 mm_struct 构建进程的虚拟地址空间(包括页表):
五、链接与加载:从.o 到运行的完整流程
理解了 ELF,就能搞懂'编译链接→加载运行'的核心逻辑,分为静态链接和动态链接两种场景。
5.1 静态链接:编译时合并与地址重定位
无论是自己的 .o, 还是静态库中的 .o,本质都是把 .o 文件进行链接的过程,所以:研究静态链接,本质就是研究.o 是如何链接的。
我们可以用 objdump 命令查看编译后的 .o 目标文件:
我们可以看到这里的 call 指令,它们对应之前调用的函数,但是你会发现他们的跳转地址都被设成了 0。那这是为什么呢?其实就是在编译 main.c 的时候,编译器是完全不知道我们使用的函数的存在的,比如他们位于内存的哪个区块,代码长什么样都是不知道的。因此,编辑器只能将这两个函数的跳转地址先暂时设为 0。
那么这个地址会在什么时候被修正?链接的时候!为了让链接器将来在链接时能够正确定位到这些被修正的地址,在代码块(.data)中还存在一个重定位表,这张表将来在链接的时候,就会根据表里记录的地址将其修正。
静态链接是将多个 .o 文件(包括静态库中的 .o)合并为一个可执行文件的过程:
- 合并节:将所有
.text 节合并为一个总 .text 节,.data 节同理;
- 符号解析:查找所有未定义的符号(如函数调用、全局变量),匹配其他
.o 中的定义;
- 地址重定位:修正符号的跳转地址(比如
.o 中函数调用地址为 0,链接时替换为合并后的实际地址)。
所以链接其实就是将编译之后的所有目标文件连同用到的一些静态库运行时库组合,拼装成一个独立的 executable 文件。其中就包括我们之前提到的地址修正,当所有模块组合在一起之后,链接器会根据我们的.o 文件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从而修正它们的地址。这其实就是静态链接的过程。
所以,链接过程中会涉及到对 .o 中外部符号进行地址重定位,这也就是为什么 .o 文件被叫做可重定位文件。
5.2 动态链接:运行时加载与延迟绑定
动态链接避免了静态链接的文件体积过大问题,核心是'运行时加载库并动态修正地址'。
动态链接其实远比静态链接要常用得多。比如我们查看下 test 这个可执行程序依赖的动态库,会发现它就利用到了一个 C 动态链接库:
这里的 libc.so 是 C 语言的标准库,里面提供了常用的标准输入输出、文件、字符串处理等等这些功能。那为什么编译器默认不使用静态链接呢?
静态链接会将编译产生的所有目标文件,连同用到的各种库,合并形成一个独立的可执行文件,它不需要额外的依赖就可以运行。照理来说应该更加方便才对是吧?
静态链接最大的问题在于生成的文件体积大,并且相当耗费内存资源。随着软件复杂度的提升,我们的操作系统也越来越臃肿,不同的软件就有可能都包含了相同的功能和代码,显然会浪费大量的硬盘空间。
这个时候,动态链接的优势就体现出来了,我们可以将需要共享的代码单独提取出来,保存成一个独立的动态链接库,等到程序运行的时候再将它们加载到内存,这样不但可以节省空间,因为同一个模块在内存中只需要保留一份副本,可以被不同的进程所共享。那么动态链接到底是如何工作的?
首先要交代一个结论,动态链接实际上将链接的整个过程推迟到了程序加载的时候。比如我们去运行一个程序,操作系统会首先将程序的数据代码连同它用到的一系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,操作系统会根据当前地址空间的使用情况为它们动态分配一段内存。当动态库被加载到内存以后,一旦它的内存地址被确定,我们就可以去修正动态库中的那些函数跳转地址了。
5.2.1 可执行程序的初始化
我们知道,在 C/C++ 程序中,当程序开始执行时,它会跳转到 _start 函数,这是一个由 C 运行时库(通常是 glibc)或链接器(如 ld)提供的特殊函数。在 _start 函数中,会执行一系列初始化操作,这些操作包括:
- 设置堆栈:为程序创建一个初始的堆栈环境。
- 初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位置,并清零未初始化的数据段。
- 调用
__libc_start_main:一旦动态链接完成, _start 函数会调用 __libc_start_main(这是 glibc 提供的一个函数)。 __libc_start_main 函数负责执行一些额外的初始化工作,比如设置信号处理函数、初始化线程库(如果使用了线程)等。
- 调用
main 函数:最后, __libc_start_main 函数会调用程序的 main 函数,此时程序的执行控制权才正式交给用户编写的代码。
- 处理
main 函数的返回值:当 main 函数返回时, __libc_start_main 会负责处理这个返回值,并最终调用 _exit 函数来终止程序。
**动态链接:**这是关键的一步, _start 函数会调用动态链接器的代码来解析和加载程序所依赖的动态库(shared libraries)。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调用和变量访问能够正确地映射到动态库中的实际地址。
动态链接器:动态链接器(如 ld-linux.so)负责在程序运行时加载动态库。当程序启动时,动态链接器会解析程序中的动态库依赖,并加载这些库到内存中。
环境变量和配置文件:Linux 系统通过环境变量(如 LD_LIBRARY_PATH)和配置文件(如 /etc/ld.so.conf 及其子配置文件)来指定动态库的搜索路径。这些路径会被动态链接器在加载动态库时搜索。
缓存文件:为了提高动态库的加载效率,Linux 系统会维护一个名为 /etc/ld.so.cache 的缓存文件。该文件包含了系统中所有已知动态库的路径和相关信息,动态链接器在加载动态库时会首先搜索这个缓存文件。
上述过程描述了 C/C++ 程序在 main 函数之前执行的一系列操作,但这些操作对于大多数程序员来说是透明的。我们通常只需要关注 main 函数中的代码,而不需要关心底层的初始化过程。然而,了解这些底层细节有助于我们更好地理解程序的执行流程和调试问题。
5.2.2 程序和动态库的映射
动态库为了随时进行加载,为了支持并映射到任意进程的任意位置,对动态库中的方法也采用平坦模式编址,因此动态库文件也是 ELF 文件。
动态库也是一个文件,要访问也是要被先加载,要加载也是要被打开的,让我们的进程找到动态库的本质:也是文件操作,不过我们访问库函数,通过虚拟地址进行跳转访问的,所以需要把动态库映射到进程的地址空间中,如下图所示:
库已经被我们映射到了当前进程的地址空间中,所以:访问库中任意方法,只需要知道库的起始虚拟地址(加载基址)+ 方法偏移量即可定位库中的方法,库的虚拟起始地址和库中每一个方法的偏移量()我们都知道了,而且整个调用过程,是从代码区跳转到共享区,调用完毕在返回到代码区,整个过程完全在进程地址空间中进行的。
动态库编译时采用PIC(位置无关代码),所有内部符号(如 printf)都以'相对偏移'存储,而非固定绝对地址 —— 这是为了适配'加载基址不固定'的特点:
- 不同进程加载同一个
libc.so 时,分配的加载基址可能不同(比如进程 A 是 0x7f8a9b700000,进程 B 是 0x7f9c8d100000);
- 但
printf 的相对偏移量始终是 0x554c0,只需用'当前进程的加载基址 + 固定偏移',就能得到正确的绝对地址;
- 平坦模式的大地址空间保证了每个进程都能找到空闲区间作为加载基址,且不会冲突。
5.2.3 全局偏移量表 GOT(global offset table)
也就是说,我们的程序运行之前,先把所有库加载并映射,所有库的起始虚拟地址都应该提前知道,然后对我们加载到内存中的程序的库函数调用进行地址修改,在内存中二次完成地址设置 (这个叫做加载地址重定位),但是我们知道代码区的内容在进程中是只读的,不可以进行修改,所以:动态链接采用的做法是在 .data(可执行程序或者库自己)中专门预留一片区域用来存放函数的跳转地址,它也被叫做全局偏移表 GOT,表中每一项都是本运行模块要引用的一个全局变量或函数的地址。
由于代码段只读,我们不能直接修改代码段。但有了 GOT 表,代码便可以被所有进程共享。但在不同进程的地址空间中,各动态库的绝对地址、相对位置都不同。反映到 GOT 表上,就是每个进程的每个动态库都有独立的 GOT 表,所以进程间不能共享 GOT 表。
在调用函数的时候会首先查表,然后根据表中的地址来进行跳转,这些地址在动态库加载的时候会被修改为真正的地址。
这种方式实现的动态链接就被叫做 PIC。换句话说,我们的动态库不需要做任何修改,被加载到任意内存地址都能够正常运行,并且能够被所有进程共享,这也是为什么之前我们给编译器指定 -fPIC 参数的原因,PIC= 相对编址+GOT。
5.2.4 库间依赖和延迟绑定
不仅仅有可执行程序调用库,库也会调用其他库!库之间是有依赖的,如何做到库和库之间互相调用也是与地址无关的呢?和可执行一样,库中也有 GOT 表!这也就是为什么大家都是 ELF 的格式!
由于动态链接在程序加载的时候需要对大量函数进行重定位,这一步显然是非常耗时的。为了进一步降低开销,我们的操作系统还做了一些其他的优化,比如延迟绑定,或者也叫PLT(过程连接表(Procedure Linkage Table))。与其在程序一开始就对所有函数进行重定位,不如将这个过程中推迟到函数第一次被调用的时候,因为绝大多数动态库中的函数可能在程序运行期间一次都不会被使用到。
思路是:GOT 中的跳转地址默认会指向一段辅助代码,它也被叫做桩代码 /stup。在我们第一次调用函数的时候,这段代码会负责查询真正函数的跳转地址,并且去更新 GOT 表。于是我们再次调用函数的时候,就会直接跳转到动态库中真正的函数实现。
- GOT:存储符号的实际地址(位于可读写的
.data 节,可动态修改);
- PLT:避免直接修改只读的
.text 节,第一次调用函数时,通过 PLT 触发动态链接器解析符号地址,更新到 GOT,后续调用直接从 GOT 获取地址(延迟绑定)。
六、核心总结与适用场景
动静态库选择建议
- 用静态库:程序需独立运行(无依赖)、库功能稳定不更新(如基础工具库);
- 用动态库:多程序共享库(节省内存)、库需频繁更新(如业务逻辑库)、可执行文件体积敏感。
核心知识点梳理
- 库的本质:二进制可复用代码,静态合并、动态加载;
- ELF 格式:链接与加载的基础,区分链接视图(节)和执行视图(段);
- 链接:静态重定位(编译时)、动态重定位(运行时,GOT/PLT);
- 加载:ELF 映射到虚拟地址空间,动态链接器解析依赖库,最终调用
main。
实用命令附录
| 功能 | 命令 |
|---|
| 制作静态库 | ar -rc libxxx.a xxx.o yyy.o |
| 制作动态库 | gcc -fPIC -shared -o libxxx.so xxx.o |
| 查看 ELF 头 | readelf -h xxx.o/a.out/libxxx.so |
| 查看动态库依赖 | ldd a.out |
| 反汇编代码节 | objdump -d a.out |
| 更新动态库缓存 | sudo ldconfig |
通过本文,你不仅掌握了动静态库的制作与使用,更理解了从编译链接到加载运行的底层逻辑。库与 ELF 是 Linux 开发的基础,吃透这些知识,能帮你解决大部分'链接错误''库找不到''加载失败'等问题,为后续深入内核、驱动开发打下基础。
相关免费在线工具
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown转HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
- HTML转Markdown
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
- JSON 压缩
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
- JSON美化和格式化
将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online