程序员的自我修养——链接、装载与库

一句话总结:国内唯一系统讲解”程序从源代码到运行时内存”全链路的经典著作——从编译、链接、装载到运行库实现,一层层揭开 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 内存不够怎么办

内存管理的演进:

  1. 直接使用物理内存 —— 不安全,程序间互相影响
  2. 分段(Segmentation) —— 按逻辑段隔离,但有外部碎片
  3. 分页(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 静态链接的本质

为什么需要链接? 因为现代软件是模块化的,每个模块单独编译,模块之间互相调用函数、引用全局变量。编译时不知道其他模块中符号的地址,只能暂时搁置,等到链接时再修正。

链接的两个核心任务:

  1. 符号解析(Symbol Resolution):找到每个符号引用对应的定义
  2. 重定位(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 空间与地址分配

链接器如何把多个目标文件的段合并成一个可执行文件?

两种策略:

  1. 按序叠加:按顺序把所有 .text 拼起来,所有 .data 拼起来 —— 简单但段太多
  2. 相似段合并:把所有 .text 合并成一个大的 .text,所有 .data 合并成 .data —— 现代链接器的做法

合并后,链接器为每个符号分配最终的虚拟地址。

4.2 符号解析与重定位

重定位的过程:

  1. 收集所有目标文件的符号,建立全局符号表
  2. 合并相似段,分配虚拟地址
  3. 遍历每个重定位条目,计算符号的最终地址
  4. 修正指令中的地址偏移

重定位类型(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 装载的方式

程序很大,内存有限,不可能把整个程序都装进内存:

  1. 覆盖装入:程序员手动管理,把程序分成块,需要哪块装哪块 —— 古老,复杂度高
  2. 页映射:以页为单位(通常 4KB),只把当前需要的页装入内存,通过页表映射虚拟地址 —— 现代操作系统标准做法

页错误(Page Fault):

  • 当 CPU 访问一个尚未加载的虚拟页时,触发页错误异常
  • 操作系统接管:分配物理页、从磁盘读取对应内容、建立页表映射
  • 返回用户态,重新执行刚才那条指令 —— 程序感知不到整个过程

6.3 从操作系统角度看装载

创建一个进程并运行程序的过程:

  1. 创建进程(分配 PID、进程描述符等)
  2. 读取 ELF 文件头,了解段布局
  3. 建立虚拟地址空间映射(建立页表,但不加载内容)
  4. 设置指令指针为 ELF 的入口地址
  5. 开始执行 → 第一条指令就触发页错误 → 操作系统加载代码页 → 继续执行

关键点:程序启动时并没有把所有代码和数据都读进内存,而是用到哪页读哪页,这就是所谓的”按需分页”(Demand Paging)。


第7章:动态链接——共享库的工作原理

7.1 为什么要动态链接

静态链接的问题:

  1. 浪费磁盘和内存:每个程序都有一份 libc,磁盘上重复存储,运行时也各占一份内存
  2. 更新困难:库升级了,所有使用它的程序都要重新链接
  3. 模块间协作不灵活

动态链接把链接过程推迟到运行时:程序分成主模块和多个共享库(.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 动态链接的过程

  1. 动态链接器自举:动态链接器本身也是个共享库,它怎么加载自己?—— 它被设计成可以在任意地址运行,自己把自己加载起来
  2. 装载共享对象:遍历可执行文件依赖的共享库,递归加载,把所有共享库的段映射到进程地址空间
  3. 重定位和初始化:对所有需要重定位的符号进行解析和填充,执行各共享库的初始化代码
  4. 控制权交给程序:跳转到可执行文件的入口点

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 共享库查找路径

动态链接器按以下顺序找共享库:

  1. 环境变量 LD_LIBRARY_PATH 指定的路径
  2. /etc/ld.so.cache 缓存的路径(由 ldconfig 更新)
  3. /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 堆分配算法

堆分配器需要解决的问题:在大块内存中分配和释放不同大小的小块,尽量减少碎片,提高效率。

常见算法:

  1. 空闲链表:把空闲块用链表连起来,分配时遍历找合适的块

    • 问题:释放时容易产生外部碎片
  2. 位图:把堆分成大小相等的块,用位图标记每块是否空闲

    • 优点:快,释放简单
    • 问题:内部碎片
  3. 对象池:针对特定大小的对象预分配一批

    • 很多分配器对小对象用这个思路
  4. ptmalloc(glibc 使用的分配器,基于 dlmalloc):

    • 小于 128KB 的分配:通过 brk 扩展堆顶
    • 大于 128KB 的分配:通过 mmap 分配匿名页
    • 多线程下有 per-thread arena,减少锁竞争
  5. 其他: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++ 全局构造与析构

这是检验学习成果的最佳方式——看懂了不代表理解了,能自己写出来才算真懂。


四、关键概念速查表

概念一句话解释
目标文件编译后的中间产物,结构和可执行文件几乎一样
ELFLinux 下的可执行/目标文件格式
PEWindows 下的可执行文件格式
.text代码段,存放指令
.data数据段,存放已初始化的全局变量
.bss未初始化全局变量,不占文件空间
符号链接的基本单位,函数和全局变量都是符号
重定位修正指令中引用的地址
静态链接链接时把所有模块合成一个可执行文件
动态链接运行时才链接共享库
PIC地址无关代码,共享库代码的要求
GOT全局偏移表,存符号地址,实现 PIC
PLT过程链接表,实现函数的延迟绑定
页错误访问未加载的页时触发,由操作系统处理
虚拟内存每个进程独立的地址空间,通过页表映射到物理内存
运行库程序运行的基础支撑库,提供入口、I/O、堆管理等

五、个人收获与可行动点

核心收获

  1. 建立了程序运行的完整心智模型:从源代码到编译、链接、装载、运行,每一步发生了什么、为什么这么做,有了清晰的认知
  2. 底层知识反哺上层开发:理解内存布局、链接原理后,遇到奇怪的编译错误、链接错误、内存问题时,不再是瞎猜试错,而是有方向地排查
  3. “为什么”比”是什么”更重要:每个机制的设计都是为了解决特定问题,理解设计动机才能真正掌握

可行动点

  • 用 readelf、objdump、nm 工具分析几个实际的目标文件,验证书中内容
  • 写一个简单的 dlopen 插件系统,练习显式动态链接
  • 尝试实现一个简化版的 malloc,理解堆分配算法
  • 阅读 Mini CRT 相关章节,跟着动手实现
  • 用 strace/ltrace 跟踪程序运行,观察系统调用和库调用

六、关联知识


读书建议:这本书不需要从头到尾一口气读完。前4章(编译、目标文件、静态链接)是基础,必须吃透。第6-7章(装载、动态链接)是核心难点,建议反复读。后面的运行库章节可以按需翻阅。读完最好的检验方式是:用工具去分析真实的程序,看看是不是和书里说的一样。