深入理解链接与加载:从静态库到动态库的全流程解析
文章目录
- 一、什么是库
- 二、静态库
- 2.1 静态库生成
- 2.2 静态库使用
- 三、动态库
- 3.1 动态库生成
- 3.2 动态库使用
- 3.3 库运行搜索路径
- 3.3.1 无法找到动态库
- 3.3.2 解决方案
- 五、目标文件
- 六、ELF 文件
- 七、ELF从形成到加载轮廓
- 7.1 ELF形成可执行
- 7.2 ELF可执行文件加载
- 八、理解链接与加载
- 8.1 静态链接
- 8.2 ELF 加载与进程地址空间
- 8.2.1 虚拟地址 / 逻辑地址
- 8.2.2 重新理解进程虚拟地址空间
- 8.3 动态链接与动态库加载
- 8.3.1 进程如何 “看到” 动态库?
- 8.3.2 进程如何 “共享” 库的?
- 8.3.3 动态链接的链接
- 8.3.3.1 概要
- 8.3.3.2 我们的可执行程序被编译器 “动了手脚”
- 8.3.3.3 动态库中的相对地址
- 8.3.3.4 我们的程序,怎么和库具体映射起来的?
- 8.3.3.6 全局偏移量表 GOT(Global Offset Table)
- 8.3.3.7 库间依赖(简单说明即可)
- 8.3.4 总结
一、什么是库
库是写好的现有的,成熟的,可以复用的代码。现实中每个程序都要依赖很多基础的底层库,不可能每个人的代码都从零开始,因此库的存在意义非同寻常。
本质上来说库是一种可执行代码的二进制形式,可以被操作系统载入内存执行。库有两种:
- 静态库.a[Linux]、.lib[windows]
- 动态库.so[Linux]、.dll[windows]
二、静态库
- 静态库(.a):程序在编译链接的时候把库的代码链接到可执行文件中,程序运行的时候将不再需要静态库。
- 一个可执行程序可能用到许多的库,这些库运行有的是静态库,有的是动态库,而我们的编译默认为动态链接库,只有在该库下找不到动态.so的时候才会采用同名静态库。我们也可以使用gcc的-static强转设置链接静态库。
2.1 静态库生成
// Makefile
libmystdio.a:my_stdio.o my_string.o@ar -rc $@ $^@echo "build $^ to $@ ... done"%.o:%.c@gcc -c $<@echo "compling $< to $@ ... done".PHONY:clean
clean:@rm -rf *.a *.o stdc*@echo "clean ... done".PHONY:output
output:@mkdir -p stdc/include@mkdir -p stdc/lib@cp -f *.h stdc/include@cp -f *.a stdc/lib@tar -czf stdc.tgz stdc@echo "output stdc ... done"
ar
是gnu
归档工具,rc
表示(replace and create)
$ ar -tv libmystdio.a
rw-rw-r-- 1000/1000 2848 Oct 29 14:35 2024 my_stdio.o
rw-rw-r-- 1000/1000 1272 Oct 29 14:35 2024 my_string.o
t
:列出静态库中的文件v:verbose
详细信息
2.2 静态库使用
// main.c,引入库头文件
#include "my_stdio.h"
#include "my_string.h"
#include <stdio.h>int main()
{const char *s = "abcdefg";printf("%s: %d\n", s, my_strlen(s));mFILE *fp = mfopen("./log.txt", "a");if (fp == NULL) return 1;mfwrite(s, my_strlen(s), fp);mfwrite(s, my_strlen(s), fp);mfwrite(s, my_strlen(s), fp);mfclose(fp);return 0;
}
- 场景 1:头文件和库文件安装到系统路径下
$ gcc main.c -lmystdio
- 场景 2:头文件和库文件与源文件同路径
$ gcc main.c -L. -lmymath
- 场景 3:头文件和库文件有独立路径
$ gcc main.c -I头文件路径 -L库文件路径 -lmymath
-L
:指定库路径-I
:指定头文件搜索路径-l
:指定库名- 测试目标文件生成后,静态库删掉,程序照样可以运行
- 关于
-static
选项,稍后介绍 - 库文件名称和引入库的名称:去掉前缀
lib
,去掉后缀.so
,·a
,如:libc.so->c
三、动态库
- 动态库(.so):程序在运行的时候才去链接动态库的代码,多个程序共享使用库的代码。
- 一个与动态库链接的可执行文件仅仅包含它用到的函数入口地址的一个表,而不是外部函数所在目标文件的整个机器码
- 在可执行文件开始运行以前,外部函数的机器码由操作系统从磁盘上的该动态库中复制到内存中,这个过程称为动态链接(dynamic linking)
- 动态库可以在多个程序间共享,所以动态链接使得可执行文件更小,节省了磁盘空间。操作系统采用虚拟内存机制允许物理内存中的一份动态库被要用到该库的所有进程共用,节省了内存和磁盘空间。
3.1 动态库生成
// Makefile
libmystdio.so:my_stdio.o my_string.ogcc -o $@ $^ -shared%.o:%.cgcc -fPIC -c $<.PHONY:clean
clean:@rm -rf *.so *.o stdc*@echo "clean ... done".PHONY:output
output:@mkdir -p stdc/include@mkdir -p stdc/lib@cp -f *.h stdc/include@cp -f *.so stdc/lib@tar -czf stdc.tgz stdc@echo "output stdc ... done"
- shared:表示生成共享库格式
- fPIC:产生位置无关码(position independent code)
- 库名规则:libxxx.so
3.2 动态库使用
- 场景1:头文件和库文件安装到系统路径下
$ gcc main.c -lmystdio
- 场景2:头文件和库文件与源文件同路径(从左到右搜索 -L 指定的目录)
$ gcc main.c -L. -lmymath
- 场景3:头文件和库文件有独立路径
$ gcc main.c -I头文件路径 -L库文件路径 -lmymath
- 查看库/可执行程序的依赖(以 libmystdio.so 为例)
$ ldd libmystdio.so linux-vdso.so.1 => (0x00007ffffacbbf000) libc.so.6 => /lib64/libc.so.6 (0x00007f8917335000) /lib64/ld-linux-x86-64.so.2 (0x00007f8917905000) // 场景2的完整操作示例(目录文件列表 + 编译 + 运行)
// 以场景2为例
$ ll
total 24
-rwxrwxr-x 1 zkp zkp 8592 Jun 8 16:29 libmystdio.so
-rw-rw-r-- 1 zkp zkp 359 Jun 8 16:29 main.c
-rw-rw-r-- 1 zkp zkp 447 Jun 8 16:29 my_stdio.h
-rw-rw-r-- 1 zkp zkp 447 Jun 8 16:29 my_string.h $ gcc main.c -L. -lmystdio $ ll
total 36
-rwxrwxr-x 1 zkp zkp Jun 8 16:29 a.out
-rwxrwxr-x 1 zkp zkp 8592 Jun 8 16:29 libmystdio.so
-rw-rw-r-- 1 zkp zkp 359 Jun 8 16:29 main.c
-rw-rw-r-- 1 zkp zkp 447 Jun 8 16:29 my_stdio.h
-rw-rw-r-- 1 zkp zkp 447 Jun 8 16:29 my_string.h zkp@zkp:~/linux/25/6/8/lib$ ./a.out
...
3.3 库运行搜索路径
3.3.1 无法找到动态库
$ ldd a.outlinux-vdso.so.1 => (0x00007ffff4d396000)libmystdio.so => not foundlibc.so.6 => /lib64/libc.so.6 (0x00007fa2aef30000)/lib64/ld-linux-x86-64.so.2 (0x00007fa2af2fe000)
3.3.2 解决方案
- 拷贝
.so
文件到系统共享库路径下,一般指/usr/lib、/usr/local/lib、/lib64
或者开篇指明的库路径等 - 向系统共享库路径下建立同名软连接
- 更改环境变量:
LD_LIBRARY_PATH
- ldconfig方案:配置
/etc/ld.so.conf.d/
,ldconfig
更新
五、目标文件
编译和链接这两个步骤,在Windows下被我们的IDE封装的很完美,我们一般都是一键构建非常方便,但一旦遇到错误的时候呢,尤其是链接相关的错误,很多人就束手无策了。在Linux下,我们之前也学习过如何通过gcc编译器来完成这一系列操作。
接下来我们深入探讨一下编译和链接的整个过程,来更好的理解动静态库的使用原理。
先来回顾下什么是编译呢?编译的过程其实就是将我们程序的源代码翻译成CPU能够直接运行的机器代码。
比如:在一个源文件 hello.c
里便简单输出"helloworld!”,并且调用一个run
函数,而这个函数被定义在另一个原文件code.c
中。这里我们就可以调用gcc -c
来分别编译这两个原文件。
// hello.c
#include<stdio.h>
void run();
int main() {printf("hello world!\n");run();return 0;
}
// code.c
#include<stdio.h>
void run() {printf("running...\n");
}
# bash
// 编译两个源文件
$ gcc -c hello.c
$ gcc -c code.c
$ ls
code.c code.o hello.c hello.o
可以看到,在编译之后会生成两个扩展名为.o
的文件,它们被称作目标文件。要注意的是如果我们修改了一个源文件,那么只需要单独编译它这一个,而不需要浪费时间重新编译整个工程。目标文件是一个二进制的文件,文件的格式是ELF
,是对二进制代码的一种封装。
$ file hello.o
hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
## file命令⽤于辨识⽂件类型。
六、ELF 文件
要理解编译链接的细节,我们不得不了解一下 ELF 文件(Executable and Linkable Format)。以下是 ELF 文件的核心要点:
ELF 文件的四种类型
以下四种文件均属于 ELF 格式:
- 可重定位文件(Relocatable File):即
xxx.o
文件,包含适合与其他目标文件链接,以创建可执行文件或共享库的代码和数据。 - 可执行文件(Executable File):即运行的程序,可直接加载到内存执行。
- 共享目标文件(Shared Object File):即
xxx.so
文件,用于动态链接(如系统共享库)。 - 内核转储(Core Dumps):存放进程崩溃时的执行上下文,由
dump
信号触发生成。
ELF 文件的组成结构
一个 ELF 文件由以下四部分组成:
- ELF 头(ELF header):位于文件起始位置,描述文件的核心特性(如架构、版本),用于定位文件其他部分(如程序头表、节头表的位置)。
- 程序头表(Program header table):列举所有有效 段(segments) 及其属性(如起始位置、偏移、长度),加载器依赖它分割并加载二进制数据到内存。
- 节头表(Section header table):描述文件内的 节(sections),记录节的类型、大小、偏移等元信息,链接器和调试工具依赖它解析文件结构。
- 节(Section):ELF 的基本组成单位,存储特定类型数据(如代码、数据、符号表等),不同节承担不同功能。
最常见的节
ELF 文件通过不同节组织数据,典型节包括:
- 代码节(
.text
):存储机器指令,是程序的执行核心。 - 数据节(
.data
):存储已初始化的全局变量和局部静态变量。
七、ELF从形成到加载轮廓
7.1 ELF形成可执行
step-1
:将多份C/C++
源代码,翻译成为目标.o
文件step-2
:将多份.o
文件section进行合并
注意:- 实际合并是在链接时进行的,但是并不是这么简单的合并,也会涉及对库合并,此处不做过多追究
7.2 ELF可执行文件加载
- 一个ELF会有多种不同的Section,在加载到内存的时候,也会进行Section合并,形成segment
- 合并原则:相同属性,比如:可读,可写,可执行,需要加载时申请空间等.
- 这样,即便是不同的Section,在加载到内存中,可能会以segment的形式,加载到一起
- 很显然,这个合并工作也已经在形成ELF的时候,合并方式已经确定了,具体合并原则被记录在了
ELF的程序头表(Program header table)
中
为什么要将 section 合并成为 segment
- Section 合并的主要原因是为了减少页面碎片,提高内存使用效率。如果不进行合并,假设页面大小为 4096 字节(内存块基本大小,加载,管理的基本单位),如果
text
部分为 4097 字节,.init
部分为 512 字节,那么它们将占用 3 个页面,而合并后,它们只需 2 个页面。 - 此外,操作系统在加载程序时,会将具有相同属性的 section 合并成一个大的 segment,这样就可以实现不同的访问权限,从而优化内存管理和权限访问控制。
对于程序头表和节头表又有什么用呢,其实 ELF 文件提供 2 个不同的视图 / 视角来让我们理解这两个部分:
- 链接视图 (Linking view) - 对应节头表 Section header table
- 文件结构的粒度更细,将文件按功能模块的差异进行划分,静态链接分析的时候一般关注的是链接视图,能够理解 ELF 文件中包含的各个部分的信息。
- 为了空间布局上的效率,将来在链接目标文件时,链接器会把很多节(section)合并,规整成可执行的段(segment)、可读写的段、只读段等。合并了后,空间利用率就高了,否则,很小的很小的一段,未来物理内存页浪费太大(物理内存分页分配一般都是整数倍一块给你,比如 4k),所以,链接器趁着链接就把小块们都合并了。
- 执行视图 (execution view) - 对应程序头表 Program header table
- 告诉操作系统,如何加载可执行文件,完成进程内存的初始化。一个可执行程序的格式中,一定有 program header table。
- 说白了就是:一个在链接时作用,一个在运行加载时作用。
从 链接视图 来看:
- 命令
readelf -S hello.o
可以帮助查看 ELF 文件的节头表。 .text节
:是保存了程序代码指令的代码节。.data节
:保存了初始化的全局变量和局部静态变量等数据。.rodata节
:保存了只读的数据,如一行 C 语言代码中的字符串。由于.rodata
节是只读的,所以只能存在于一个可执行文件的只读段中。因此,只能是在 text 段(不是 data 段)中找到.rodata
节。.BSS节
:为未初始化的全局变量和局部静态变量预留位置.symtab节
: Symbol Table 符号表,就是源码里面那些函数名、变量名和代码的对应关系。.got.plt节
(全局偏移表 - 过程链接表):.got
节保存了全局偏移表。.got
节和.plt
节一起提供了对导入的共享库函数的访问入口,由动态链接器在运行时进行修改。对于 GOT 的理解,我们后面会说。- 使用
readelf
命令查看.so
文件可以看到该节。
从 执行视图 来看:
- 告诉操作系统哪些模块可以被加载进内存。
- 加载进内存之后哪些分段是可读可写,哪些分段是只读,哪些分段是可执行的。
我们可以在 ELF 头 中找到文件的基本信息,以及可以看到 ELF 头是如何定位程序头表和节头表的。例如我们查看下 hello.o 这个可重定位文件的主要信息:
八、理解链接与加载
8.1 静态链接
核心定义
静态链接是 编译阶段 将所有依赖的目标文件(.o
)和库(.a
)合并为单个可执行文件的过程。链接器会解决 符号引用(函数 / 变量在哪里)和 地址重定位(代码 / 数据放哪里)。
详细流程
- 符号收集:
- 遍历所有输入文件(.o、.a),提取符号表(函数名、变量名、地址),构建全局符号表。
- 区分三类符号:
- 定义符号(如
main
函数的实现); - 未定义符号(如调用的
printf
,需从库中查找); - 局部符号(如文件内的静态变量,仅当前文件可见)。
- 定义符号(如
- 符号解析:
- 对未定义符号,从静态库(
.a
)中提取对应目标文件(如libc.a
中包含printf
的.o
),加入链接队列。
- 对未定义符号,从静态库(
- 地址重定位:
- 计算每个符号的最终虚拟地址(如
main
函数在合并后的代码段偏移)。 - 修改指令中的占位地址(如
call printf
中的临时地址 → 实际地址)。
- 计算每个符号的最终虚拟地址(如
- 节合并:
- 将所有输入文件的同名节合并(如
.text
节合并为一个大代码段,.data
节合并为数据段),按页对齐(如 4KB)优化内存布局。
- 将所有输入文件的同名节合并(如
示例验证(以 hello.o
+ code.o
为例)
- 编译命令:
gcc -c hello.c # 生成 hello.o(含 main,调用 run)
gcc -c code.c # 生成 code.o(含 run 的实现)
gcc -o app hello.o code.o # 静态链接,合并为 app
- 符号变化:
hello.o
中的run
(未定义符号)→ 链接后变为code.o
中run
的实际地址。app
的符号表中,main
和run
均为定义符号,无未定义项。
8.2 ELF 加载与进程地址空间
8.2.1 虚拟地址 / 逻辑地址
核心概念
- 逻辑地址:ELF 文件中记录的地址(如 .text 节的偏移 0x1000),是链接器生成的相对地址。
- 虚拟地址:程序加载到内存后,CPU 看到的地址(如 0x401000),由加载器分配,通过 MMU 映射到物理内存。
为什么需要虚拟地址?
- 隔离性:每个进程的虚拟地址空间独立,避免相互干扰(如进程 A 的
0x1000
和进程 B 的0x1000
对应不同物理内存)。 - 灵活性:加载器可将 ELF 的段映射到任意物理内存,只需保证虚拟地址连续(如
.text
段虚拟地址0x400000~0x401000
,物理地址可能分散)。
示例:ELF 文件与虚拟地址的映射
- ELF 文件中,
.text
节的偏移是0x100
(逻辑地址)。 - 加载后,加载器将
.text
段映射到虚拟地址0x400000
,因此指令call 0x100
(逻辑地址)会被修正为call 0x400000 + 0x100 = 0x400100
(虚拟地址)。
8.2.2 重新理解进程虚拟地址空间
经典布局(Linux x86-64)
虚拟地址范围 | 用途 | 读写权限 |
---|---|---|
0x00000000 | 空指针保护区(避免空指针访问) | 无 |
0x400000 (低地址) | 代码段(.text ) | 只读、可执行 |
0x600000 | 数据段(.data 、.bss ) | 可读写 |
0x7fffffff | 栈(Stack) | 可读写 |
0x80000000 以上 | 堆(Heap)+ 内存映射区(mmap) | 可读写(堆) |
关键特性
- 地址空间不连续:
- 堆和栈向中间增长,中间的 “空洞” 可通过
mmap
动态映射文件(如动态库)。
- 堆和栈向中间增长,中间的 “空洞” 可通过
- 随机化(ASLR):
- 每次启动程序,代码段、堆、栈的虚拟地址随机偏移,增加安全性(避免缓冲区溢出攻击)。
ELF 加载的影响
- 每次启动程序,代码段、堆、栈的虚拟地址随机偏移,增加安全性(避免缓冲区溢出攻击)。
- 加载器根据 ELF 的程序头表(描述段的虚拟地址、权限、偏移),调用
mmap
将 ELF 的段映射到虚拟地址空间:.text
段 → 虚拟地址0x400000
(只读、可执行);.data
段 → 虚拟地址0x600000
(可读写);.bss
段 → 虚拟地址0x601000
(运行时清零,可读写)。
8.3 动态链接与动态库加载
核心动机
静态链接导致 代码冗余(多个程序重复嵌入 printf
等库代码)和 更新困难(库升级需重新编译所有程序)。动态链接让库(.so
)运行时共享,可执行文件仅记录 “如何找到库” 的信息。
8.3.1 进程如何 “看到” 动态库?
- 编译标记:
gcc -o app main.c -L. -lmyso # 链接时记录对 libmyso.so 的依赖
可执行文件的 动态段(.dynamic
) 会记录:
- 动态库的名字(
libmyso.so
); - 符号依赖(如
my_function
)。
- 加载器介入:
程序启动时,动态链接器(ld.so) 自动加载:- 先加载 app 本身;
- 再根据
.dynamic
段的信息,查找并加载依赖的.so
(如libmyso.so
、libc.so.6
)。
8.3.2 进程如何 “共享” 库的?
内存共享机制
- 同一个
.so
,多个进程共享物理内存:
动态库的.text
段是只读的,操作系统通过内存页共享(Copy-On-Write)让多个进程复用同一段物理内存。 - 数据段独立:
每个进程的.data
段是私有的(可读写),修改时触发页复制,保证进程间数据隔离。
示例:libc.so.6
的共享
- 进程 A 和进程 B 都依赖
libc.so.6
:- 两者的
.text
段(如printf
的代码)映射到同一块物理内存; - 两者的
.data
段(如errno
变量)是独立的物理内存副本。
- 两者的
8.3.3 动态链接的链接
8.3.3.1 概要
动态链接的核心是 “延迟绑定”:函数第一次被调用时,才解析其地址(而非启动时全解析),提升性能。
8.3.3.2 我们的可执行程序被编译器 “动了手脚”
编译动态链接的程序时,编译器会插入 胶水代码(PLT/GOT):
- PLT(Procedure Linkage Table,过程链接表):trampoline 代码,负责跳转到 GOT 中的地址。
- GOT(Global Offset Table,全局偏移表):存储共享库函数的实际地址(运行时由
ld.so
填充)。
示例:调用 printf
的指令变化
- 静态链接:
call 0x401234
(直接跳转到printf
的地址)。 - 动态链接:
call PLT[printf]
(先跳转到 PLT,再通过 GOT 找地址)。
8.3.3.3 动态库中的相对地址
动态库编译时需生成 位置无关代码(PIC,Position-Independent Code):
- 指令中使用相对寻址(如
call %rip+0x100
),而非绝对地址,确保库可加载到任意虚拟地址。
8.3.3.4 我们的程序,怎么和库具体映射起来的?
- 启动时初始化:
ld.so
加载动态库后,会修正其 重定位表(.rel.plt
),记录哪些 GOT 项需要填充地址。 - 第一次调用
printf
时(延迟绑定):call PLT[printf]
→ 跳转到 PLT 的胶水代码;- PLT 检查 GOT [printf] 是否已填充:
- 若未填充:跳转到
ld.so
的_dl_runtime_resolve
函数,解析printf
的地址并写入 GOT; - 若已填充:直接跳转到 GOT 中的地址(
printf
的实际地址)。
- 若未填充:跳转到
8.3.3.6 全局偏移量表 GOT(Global Offset Table)
GOT 是一个数组,每个条目对应一个共享库函数的地址:
- GOT[0]:保留,指向动态链接器的一些信息(如重定位表地址);
- GOT[1]:指向动态链接器的符号解析函数(
_dl_runtime_resolve
); - GOT[2…]:存储用户函数的地址(如
printf
、malloc
)。
GOT 的内存布局(简化):
GOT地址: 0x602000
+--------+-------------------+
| 0x602000 | 指向动态链接器信息 |
| 0x602008 | 指向 _dl_runtime_resolve |
| 0x602010 | printf 的实际地址 | (初始为0,第一次调用时填充)
| 0x602018 | malloc 的实际地址 |
+--------+-------------------+
8.3.3.7 库间依赖(简单说明即可)
动态库也可能依赖其他动态库(如 libmyso.so
依赖 libutil.so
):
ld.so
会递归解析依赖,先加载底层库(如libutil.so
),再加载上层(libmyso.so
)。- 每个库的 GOT 会独立填充,确保符号解析正确。
8.3.4 总结
动态链接的核心优势:
- 节省内存:多个进程共享同一份库的代码段。
- 灵活更新:替换.so 文件即可升级库(兼容时无需重启程序)。
- 延迟绑定:启动更快(仅解析实际用到的函数)。
代价:
- 运行时开销(符号解析、页共享的管理);
- 调试复杂(需跟踪动态链接过程)。
最终归纳:链接与加载的本质
链接:编译时解决 “符号在哪里”,合并代码并修正地址。
加载:运行时解决 “代码放哪里”,映射内存并初始化环境。
静态 vs 动态:静态是 “编译时缝合”,动态是 “运行时共享”,各有取舍。