程序员的自我修养——链接、装载与库
一句话总结:国内唯一系统讲解”程序从源代码到运行时内存”全链路的经典著作——从编译、链接、装载到运行库实现,一层层揭开 C/C++ 程序背后的运行机制。
一、本书定位与价值
为什么读这本书
每个程序员每天都在用编译器、链接器,但绝大多数人对”代码变成可执行文件”的过程只有模糊认知:
gcc main.c -o main到底做了什么?- 链接报错
undefined reference到底是什么意思? - 程序运行时内存里长什么样?堆和栈怎么分布?
- 动态链接库(.so / .dll)是怎么工作的?
malloc和free背后是怎样一套机制?
这些问题看似”底层”、“没用”,但一旦你遇到诡异的链接错误、内存泄漏、性能瓶颈,懂底层机制的人能快速定位问题,不懂的人只能瞎猜试错。
与同类书的对比
| 书籍 | 侧重 | 与本书关系 |
|---|---|---|
| 《深入理解计算机系统》(CSAPP) | 计算机系统全景,更宽 | 本书在链接/装载/运行库方面更深入 |
| 《Linkers & Loaders》(John Levine) | 链接装载理论,更偏旧 | 本书更贴近 Linux/Windows 实际实现 |
| 《程序员的自我修养》 | 链接、装载、库三者串联 | 中文资料中最系统的一本 |
二、全书结构总览
全书分为 4 大部分,共 13 章,从”程序是怎么跑起来的”这个最基本的问题出发,层层深入:
第1部分 简介(第1章)
└── 温故而知新 —— 硬件、操作系统、内存、线程基础
第2部分 静态链接(第2-5章)
├── 编译和链接 —— 从源代码到目标文件的全过程
├── 目标文件里有什么 —— ELF/COFF 文件格式详解
├── 静态链接 —— 符号解析、重定位、地址分配
└── Windows PE/COFF —— Windows 平台的二进制格式
第3部分 装载与动态链接(第6-9章)
├── 可执行文件的装载与进程 —— 进程地址空间、页映射
├── 动态链接 —— PIC、PLT、GOT、动态链接器工作原理
├── Linux 共享库的组织 —— 版本管理、符号版本、查找路径
└── Windows 下的动态链接 —— DLL 机制、导出导入表
第4部分 库与运行库(第10-13章)
├── 内存 —— 堆与栈、内存分配算法
├── 运行库 —— CRT、glibc、MSVC 运行时实现
├── 系统调用与 API —— Linux 系统调用、Windows API
└── 运行库实现 —— 亲手实现一个 Mini CRT
三、核心知识点详解
第1章:温故而知新——基础知识回顾
1.1 从 Hello World 说起
一个最简单的 C 程序:
#include <stdio.h>
int main() {
printf("Hello World\n");
return 0;
}它从代码到运行经历了:预处理 → 编译 → 汇编 → 链接 → 装载 → 执行。本书就是把这每一步拆开来讲。
1.2 操作系统做什么
- 进程抽象:让每个程序以为自己独占全部内存和 CPU
- 虚拟内存:通过分页机制隔离进程地址空间
- 设备驱动:统一硬件访问接口
1.3 内存不够怎么办
内存管理的演进:
- 直接使用物理内存 —— 不安全,程序间互相影响
- 分段(Segmentation) —— 按逻辑段隔离,但有外部碎片
- 分页(Paging) —— 把内存分成固定大小的页(通常 4KB),通过页表映射虚拟地址到物理地址,解决了碎片问题,还实现了页面换入换出
分页的核心优势:
- 地址空间隔离:每个进程有独立的虚拟地址空间
- 内存共享:不同进程可以映射同一物理页(如共享库)
- 内存保护:不同页面可设置不同权限(读/写/执行)
1.4 线程基础
- 线程是 CPU 调度的基本单位,进程是资源分配的基本单位
- 线程安全:互斥量、信号量、临界区、读写锁
- 可重入函数 vs 线程安全函数:可重入只是线程安全的子集
第2章:编译和链接——被隐藏了的过程
2.1 四步拆解 gcc hello.c -o hello
hello.c → [预编译] → hello.i → [编译] → hello.s → [汇编] → hello.o → [链接] → hello
(cpp) (ccl) (as) (ld)
| 步骤 | 工具 | 输入 | 输出 | 做了什么 |
|---|---|---|---|---|
| 预编译 | cpp | .c | .i | 宏展开、头文件包含、条件编译、删除注释 |
| 编译 | ccl | .i | .s | 词法分析 → 语法分析 → 语义分析 → 代码优化 → 汇编代码 |
| 汇编 | as | .s | .o | 汇编指令翻译成机器指令,生成目标文件 |
| 链接 | ld | 多个 .o + 库 | 可执行文件 | 地址分配、符号解析、重定位 |
2.2 编译器做了什么
编译过程是经典的”前端+后端”架构:
源代码 → 词法分析 → 语法分析 → 语义分析 → 中间代码生成 → 代码优化 → 目标代码生成
\_______________________________________/ \__________________________/
前端(与语言相关) 后端(与目标平台相关)
- 词法分析:把字符流切成 token(关键字、标识符、数字、运算符等)
- 语法分析:把 token 组织成语法树(AST)
- 语义分析:检查类型是否匹配、做类型转换、标注语法树
- 中间代码:如三地址码、LLVM IR,与平台无关,便于优化
- 代码优化:死代码删除、常量折叠、循环优化、内联等
- 目标代码生成:生成特定平台的汇编代码
2.3 静态链接的本质
为什么需要链接? 因为现代软件是模块化的,每个模块单独编译,模块之间互相调用函数、引用全局变量。编译时不知道其他模块中符号的地址,只能暂时搁置,等到链接时再修正。
链接的两个核心任务:
- 符号解析(Symbol Resolution):找到每个符号引用对应的定义
- 重定位(Relocation):修正所有引用符号的指令中的地址
第3章:目标文件里有什么——ELF 格式详解
3.1 目标文件是什么
目标文件(.o)是源代码编译后的产物,结构和可执行文件几乎一样,只是还没有经过链接,地址还没确定。
Linux 下目标文件和可执行文件都使用 ELF(Executable and Linkable Format)格式,主要有四种类型:
- 可重定位文件(Relocatable):
.o文件,用于链接 - 可执行文件(Executable):可以直接运行的程序
- 共享目标文件(Shared Object):
.so动态链接库 - 核心转储文件(Core Dump):进程崩溃时的内存快照
3.2 ELF 文件结构
┌─────────────────┐
│ ELF Header │ 文件头:描述文件整体属性
├─────────────────┤
│ .text 段 │ 代码段:存放指令
│ .data 段 │ 数据段:已初始化的全局变量和静态变量
│ .bss 段 │ BSS段:未初始化的全局变量(不占文件空间)
│ .rodata 段 │ 只读数据段:字符串常量、const变量
│ .symtab 段 │ 符号表:所有符号的信息
│ .strtab 段 │ 字符串表:符号名、段名等字符串
│ .rel.text 段 │ 重定位表:需要重定位的位置
│ ... │
├─────────────────┤
│ Section │
│ Header Table │ 段表:描述每个段的信息
└─────────────────┘
3.3 关键段解析
代码段(.text):
- 存放编译后的机器指令
- 只读,可执行
- 多个进程可以共享同一份代码段内存
数据段(.data):
- 存放已初始化的全局变量和静态变量
- 可读写,每个进程有独立副本
BSS段(.bss):
- Block Started by Symbol
- 存放未初始化的全局变量和静态变量
- 不占用文件空间,只是记录大小,运行时由操作系统清零
- 为什么不放在 .data?因为未初始化的值都是 0,存到文件里浪费空间
区分 BSS 的小技巧:全局变量未初始化 → .bss;初始化了 → .data;局部变量 → 栈。static 局部变量也在 .data/.bss。
3.4 符号(Symbol)
链接的基本单位是符号。每个符号有:名字、类型、大小、所在段、值(地址偏移)。
符号分类:
- 定义在本文件的全局符号:可以被其他文件引用
- 引用其他文件的全局符号:本文件中声明为 extern 的
- 本文件内的局部符号:static 函数、static 变量,只在本文件可见
- 段名符号:值为段的起始地址
强符号与弱符号:
- 强符号:函数定义、初始化的全局变量
- 弱符号:未初始化的全局变量、
__attribute__((weak))标记的 - 规则:多个强符号不能重名;强符号可以覆盖弱符号;多个弱符号选最大的那个
COMMON 块:未初始化的全局变量为什么放在 COMMON 而不是 .bss?因为链接时可能多个弱符号大小不同,需要选最大的,所以在链接前不能确定最终大小,只能先标记为 COMMON,链接后再分配到 .bss。
第4章:静态链接——如何把多个模块拼起来
4.1 空间与地址分配
链接器如何把多个目标文件的段合并成一个可执行文件?
两种策略:
- 按序叠加:按顺序把所有 .text 拼起来,所有 .data 拼起来 —— 简单但段太多
- 相似段合并:把所有 .text 合并成一个大的 .text,所有 .data 合并成 .data —— 现代链接器的做法
合并后,链接器为每个符号分配最终的虚拟地址。
4.2 符号解析与重定位
重定位的过程:
- 收集所有目标文件的符号,建立全局符号表
- 合并相似段,分配虚拟地址
- 遍历每个重定位条目,计算符号的最终地址
- 修正指令中的地址偏移
重定位类型(x86 为例):
R_386_32:绝对地址重定位,直接填入符号的绝对地址R_386_PC32:相对地址重定位,填入符号地址相对于当前指令的偏移
4.3 静态库链接
静态库(.a 文件)本质上就是一堆 .o 文件的打包(ar 归档)。
链接静态库的规则:
- 链接器按顺序扫描目标文件和静态库
- 只有”当前有未解析符号引用了库中的某个 .o”时,才把那个 .o 链接进来
- 所以静态库的链接顺序很重要:被依赖的库要放在后面
常见坑:
gcc main.c -lm -o main如果 main.c 用到了 math.h 里的函数但 -lm 放在前面会链接失败吗?不会,因为 gcc 会把库文件放后面处理。但如果是多个库互相依赖,就要注意顺序。
4.4 C++ 相关的链接问题
- 名字修饰(Name Mangling):C++ 支持函数重载,所以编译器会给函数名加上参数类型等信息编码,形成独一无二的符号名
- extern “C”:告诉编译器按 C 的方式命名符号,不进行 name mangling,这样 C 和 C++ 代码才能互相调用
- 重复代码消除:模板、内联函数可能在多个编译单元都生成代码,链接时需要消除重复(COMDAT 段 / 弱符号机制)
- 全局构造与析构:C++ 全局对象的构造函数要在 main 之前执行,析构在 exit 时执行,靠 .init/.fini 段和特殊机制实现
第5章:Windows PE/COFF
Windows 下的可执行文件格式是 PE(Portable Executable),源于 COFF。核心概念和 ELF 类似:
- PE 文件头 → ELF Header
- 节(Section)→ 段(Segment)
- 导入表/导出表 → 动态符号表
- PE 有 “基地址” 概念,默认加载地址由链接器指定
第6章:可执行文件的装载与进程
6.1 进程虚拟地址空间
每个进程都有自己独立的虚拟地址空间(32 位下 4GB,64 位下大得多)。
32 位 Linux 进程地址空间典型分布:
0xFFFFFFFF ──┐
│ 内核空间(1GB / 3GB 划分方式)
0xC0000000 ──┘
│ 栈(从高地址向低地址增长)
│ ...
│ 共享库映射区(mmap)
│ 堆(从低地址向高地址增长)
│ BSS 段
│ 数据段 .data
0x08048000 │ 代码段 .text
│ 保留区(0地址附近,捕获空指针)
0x00000000 ──┘
6.2 装载的方式
程序很大,内存有限,不可能把整个程序都装进内存:
- 覆盖装入:程序员手动管理,把程序分成块,需要哪块装哪块 —— 古老,复杂度高
- 页映射:以页为单位(通常 4KB),只把当前需要的页装入内存,通过页表映射虚拟地址 —— 现代操作系统标准做法
页错误(Page Fault):
- 当 CPU 访问一个尚未加载的虚拟页时,触发页错误异常
- 操作系统接管:分配物理页、从磁盘读取对应内容、建立页表映射
- 返回用户态,重新执行刚才那条指令 —— 程序感知不到整个过程
6.3 从操作系统角度看装载
创建一个进程并运行程序的过程:
- 创建进程(分配 PID、进程描述符等)
- 读取 ELF 文件头,了解段布局
- 建立虚拟地址空间映射(建立页表,但不加载内容)
- 设置指令指针为 ELF 的入口地址
- 开始执行 → 第一条指令就触发页错误 → 操作系统加载代码页 → 继续执行
关键点:程序启动时并没有把所有代码和数据都读进内存,而是用到哪页读哪页,这就是所谓的”按需分页”(Demand Paging)。
第7章:动态链接——共享库的工作原理
7.1 为什么要动态链接
静态链接的问题:
- 浪费磁盘和内存:每个程序都有一份 libc,磁盘上重复存储,运行时也各占一份内存
- 更新困难:库升级了,所有使用它的程序都要重新链接
- 模块间协作不灵活
动态链接把链接过程推迟到运行时:程序分成主模块和多个共享库(.so / .dll),运行时由动态链接器把它们链接起来。
7.2 地址无关代码(PIC)
共享库要能被加载到任意地址,这就要求代码是地址无关的。
四种地址引用方式:
| 引用类型 | 示例 | PIC 处理方式 |
|---|---|---|
| 模块内函数调用 | call foo(同模块) | 相对跳转,无需修改 |
| 模块内数据访问 | mov var, %eax(同模块) | 用 GOT + PC 相对寻址 |
| 模块外函数调用 | call bar(其他模块) | 用 PLT(过程链接表)间接跳转 |
| 模块外数据访问 | mov ext_var, %eax(其他模块) | 用 GOT(全局偏移表)间接寻址 |
GOT(Global Offset Table):
- 数据段中的一个表,保存所有全局符号的地址
- 代码通过 GOT 间接访问全局变量/函数
- 因为 GOT 在数据段,动态链接器可以修改它的内容而不影响代码段的共享
- 代码通过
当前 PC + 偏移量找到 GOT 条目,所以代码是地址无关的
PLT(Procedure Linkage Table):
- 代码段中的一段桩函数,每个外部函数对应一个 PLT 条目
- 第一次调用时,跳转到动态链接器去解析函数地址,写入 GOT
- 第二次及以后调用,直接跳转到 GOT 中保存的地址
- 实现了延迟绑定(Lazy Binding):函数第一次调用时才解析,不调用就不解析,加快启动速度
延迟绑定的代价:第一次调用稍慢(需要解析符号)。但通常程序启动时有大量函数从未被调用,延迟绑定总体上更快。
7.3 动态链接的过程
- 动态链接器自举:动态链接器本身也是个共享库,它怎么加载自己?—— 它被设计成可以在任意地址运行,自己把自己加载起来
- 装载共享对象:遍历可执行文件依赖的共享库,递归加载,把所有共享库的段映射到进程地址空间
- 重定位和初始化:对所有需要重定位的符号进行解析和填充,执行各共享库的初始化代码
- 控制权交给程序:跳转到可执行文件的入口点
7.4 显式运行时链接
程序可以在运行时主动加载和卸载共享库:
dlopen():打开一个共享库dlsym():查找符号地址dlerror():获取错误信息dlclose():关闭共享库
这就是插件系统的基础机制。
第8章:Linux 共享库的组织
8.1 共享库版本管理
SO-NAME 命名规范:libname.so.x.y.z
x:主版本号,不兼容升级时递增y:次版本号,向下兼容的新增功能时递增z:发布版本号,bug修复,不改变接口
实际系统中通常是符号链接:
libfoo.so → libfoo.so.2 → libfoo.so.2.1.0
libfoo.so:给链接器用的(-lfoo 找的就是这个)libfoo.so.2:SO-NAME,给动态链接器用的(运行时找这个)libfoo.so.2.1.0:实际的库文件
8.2 共享库查找路径
动态链接器按以下顺序找共享库:
- 环境变量
LD_LIBRARY_PATH指定的路径 /etc/ld.so.cache缓存的路径(由ldconfig更新)/lib、/usr/lib等系统默认路径
ldd命令:查看一个程序依赖哪些共享库,以及它们在哪里。
第9章:Windows 下的动态链接
Windows 的 DLL 机制和 Linux 的共享库原理类似,但实现细节不同:
- DLL 需要显式
__declspec(dllexport)导出符号 - 有导出表和导入表
- DLL 的入口点是
DllMain - “DLL HELL”(DLL 地狱):不同版本 DLL 覆盖导致的兼容性问题
第10章:内存——堆与栈
10.1 栈(Stack)
- 用于保存函数调用帧(局部变量、返回地址、保存的寄存器等)
- 由编译器自动管理,函数调用时增长,返回时收缩
- 从高地址向低地址增长(大多数架构)
- 大小有限(通常几 MB),超出会栈溢出
函数调用惯例(Calling Convention):
- cdecl:C 默认,参数从右往左压栈,调用者清理栈
- stdcall:Windows API 常用,被调用者清理栈
- fastcall:前两个参数用寄存器传递,更快
- thiscall:C++ 成员函数,this 指针在 ecx 寄存器
10.2 堆(Heap)
- 用于动态内存分配(malloc/free, new/delete)
- 从低地址向高地址增长
- 大小理论上受限于虚拟地址空间和物理内存
- 由程序员手动管理(容易泄漏、重复释放、越界)
10.3 堆分配算法
堆分配器需要解决的问题:在大块内存中分配和释放不同大小的小块,尽量减少碎片,提高效率。
常见算法:
-
空闲链表:把空闲块用链表连起来,分配时遍历找合适的块
- 问题:释放时容易产生外部碎片
-
位图:把堆分成大小相等的块,用位图标记每块是否空闲
- 优点:快,释放简单
- 问题:内部碎片
-
对象池:针对特定大小的对象预分配一批
- 很多分配器对小对象用这个思路
-
ptmalloc(glibc 使用的分配器,基于 dlmalloc):
- 小于 128KB 的分配:通过 brk 扩展堆顶
- 大于 128KB 的分配:通过 mmap 分配匿名页
- 多线程下有 per-thread arena,减少锁竞争
-
其他:tcmalloc(Google)、jemalloc(FreeBSD/Redis)等更现代的分配器
第11章:运行库
11.1 什么是运行库
运行库(Runtime Library)是程序运行时依赖的基础库,提供:
- 基础 C 函数:memcpy、strlen、printf 等
- 程序入口:
_start函数,初始化环境后调用 main - 堆管理:malloc/free
- 文件 I/O:fopen/fread/fwrite 等
- 错误处理:errno
- 线程支持:线程局部存储等
11.2 C++ 全局构造与析构
全局对象在 main 之前构造,在 exit 之后析构。
glibc 的实现思路:
.ctors段(构造函数)和.dtors段(析构函数)- 程序启动时遍历
.ctors段中的函数指针数组,依次调用 __cxa_atexit注册析构函数,exit 时逆序调用
11.3 fread 的实现剖析
书中以 fread 为例,层层深入分析了标准库 I/O 的实现:
- fread 有缓冲区,不是每次调用都 read 系统调用
- 缓冲区减少系统调用次数,提高效率
- 涉及缓冲管理、换行符转换(文本模式)、锁等细节
第12章:系统调用与 API
12.1 系统调用原理
用户态程序不能直接操作硬件,必须通过系统调用进入内核态。
x86 Linux 系统调用实现:
- 传统方式:
int 0x80软中断 - 新式:
sysenter指令(更快) - 参数通过寄存器传递(ebx=arg1, ecx=arg2, …)
- 返回值在 eax 中
特权级:
- x86 有 4 个特权级(Ring 0-3),Linux 只用 Ring 0(内核)和 Ring 3(用户)
- 系统调用从 Ring 3 进入 Ring 0,返回时回到 Ring 3
12.2 Windows API
Windows API 是 Windows 提供的系统级接口,封装了系统调用和系统服务。
第13章:运行库实现——Mini CRT
全书的压轴部分:从零实现一个迷你 C 运行库(Mini CRT),涵盖:
- 程序入口和初始化
- 堆的实现(malloc/free)
- 文件 I/O 基本操作
- 格式化字符串(基本 printf)
- 基本输入输出
- C++ new/delete
- 基本 string
- C++ 全局构造与析构
这是检验学习成果的最佳方式——看懂了不代表理解了,能自己写出来才算真懂。
四、关键概念速查表
| 概念 | 一句话解释 |
|---|---|
| 目标文件 | 编译后的中间产物,结构和可执行文件几乎一样 |
| ELF | Linux 下的可执行/目标文件格式 |
| PE | Windows 下的可执行文件格式 |
| .text | 代码段,存放指令 |
| .data | 数据段,存放已初始化的全局变量 |
| .bss | 未初始化全局变量,不占文件空间 |
| 符号 | 链接的基本单位,函数和全局变量都是符号 |
| 重定位 | 修正指令中引用的地址 |
| 静态链接 | 链接时把所有模块合成一个可执行文件 |
| 动态链接 | 运行时才链接共享库 |
| PIC | 地址无关代码,共享库代码的要求 |
| GOT | 全局偏移表,存符号地址,实现 PIC |
| PLT | 过程链接表,实现函数的延迟绑定 |
| 页错误 | 访问未加载的页时触发,由操作系统处理 |
| 虚拟内存 | 每个进程独立的地址空间,通过页表映射到物理内存 |
| 运行库 | 程序运行的基础支撑库,提供入口、I/O、堆管理等 |
五、个人收获与可行动点
核心收获
- 建立了程序运行的完整心智模型:从源代码到编译、链接、装载、运行,每一步发生了什么、为什么这么做,有了清晰的认知
- 底层知识反哺上层开发:理解内存布局、链接原理后,遇到奇怪的编译错误、链接错误、内存问题时,不再是瞎猜试错,而是有方向地排查
- “为什么”比”是什么”更重要:每个机制的设计都是为了解决特定问题,理解设计动机才能真正掌握
可行动点
- 用
readelf、objdump、nm工具分析几个实际的目标文件,验证书中内容 - 写一个简单的
dlopen插件系统,练习显式动态链接 - 尝试实现一个简化版的 malloc,理解堆分配算法
- 阅读 Mini CRT 相关章节,跟着动手实现
- 用
strace/ltrace跟踪程序运行,观察系统调用和库调用
六、关联知识
- 《SICP》-计算机程序的构造和解释-第二版 —— 从更高层次看程序的本质
- 《程序员修炼之道》-从小工到专家 —— 程序员职业素养的另一维度
- 《实现模式》-Implementation Patterns-Kent Beck —— 代码层面的设计决策
- 计算机组成原理、操作系统原理 —— 本书内容的前置知识
- 编译原理 —— 本书第2章的延伸领域
读书建议:这本书不需要从头到尾一口气读完。前4章(编译、目标文件、静态链接)是基础,必须吃透。第6-7章(装载、动态链接)是核心难点,建议反复读。后面的运行库章节可以按需翻阅。读完最好的检验方式是:用工具去分析真实的程序,看看是不是和书里说的一样。