《程序员自我修养》读书总结(四)
Author: Once Day Date: 2026年2月5日
一位热衷于Linux学习和开发的菜鸟,试图谱写一场冒险之旅,也许终点只是一场白日梦...
漫漫长路,有人对你微笑过嘛...
全系列文章可参考专栏: 书籍阅读_Once-Day的博客-CSDN博客
参考文章:
文章目录
- 《程序员自我修养》读书总结(四)
-
-
-
- [4. 静态链接](#4. 静态链接)
-
- [4.1 空间和地址分配](#4.1 空间和地址分配)
- [4.2 符号解析与重定位](#4.2 符号解析与重定位)
- [4.3 COMMON 块](#4.3 COMMON 块)
- [4.4 C++支持](#4.4 C++支持)
- [4.5 静态库链接](#4.5 静态库链接)
- [4.6 链接过程控制](#4.6 链接过程控制)
- [4.7 LD 链接脚本语法](#4.7 LD 链接脚本语法)
- [4.8 BFD库](#4.8 BFD库)
-
-
4. 静态链接
4.1 空间和地址分配
在目标文件经过编译生成后,链接器首先面对的是空间与地址分配问题。每个目标文件内部都包含若干段(text、data、bss 等),如果简单采用按序叠加的方式,将不同目标文件整体顺序排列,虽然实现直接,但会导致段属性混杂、内存利用率不高,也不利于统一权限控制。
因此实际工程中更常见的是"相似段合并"的策略,即将所有目标文件中同类段集中排列,例如把所有 text 段合并为一个大的代码段,再统一放置只读数据和可写数据,从而满足分页对齐与访问权限划分的需求。
链接过程通常可抽象为两个阶段:空间与地址分配,以及符号解析与重定位。
- 第一阶段确定各个输入段在最终可执行文件或共享库中的相对位置与大小,形成完整的段布局;
- 第二阶段则在既定布局基础上,修正所有符号引用,使其指向正确的运行时地址。
两者并非完全独立,符号地址的确定依赖于前一步的地址分配结果,而分配策略也会影响重定位项的数量与类型。
在符号地址确定方面,链接器需要为每个全局符号建立统一的符号表。对于函数或全局变量,其地址等于所在段的基址加上符号在段内的偏移。例如在 ELF 文件中,符号表项 st_value 表示符号相对于所属段的偏移量。当段基址确定后,符号最终虚拟地址即可计算为:
symbol_addr = section_base + st_value;
若存在跨目标文件的引用,链接器还需根据重定位表修正引用位置。例如:
c
extern int g_value;
int foo() {
return g_value + 1;
}
编译阶段仅生成对 g_value 的未解析引用,链接阶段在合并 data 段并确定 g_value 地址后,重定位记录会指示将该地址写回到 foo 中的访存指令操作数。整个过程可以概括为:
读取目标文件
合并相似段
分配虚拟地址
建立全局符号表
处理重定位项
生成最终映像
通过先确定空间布局,再完成符号解析与地址修正,链接器将分散的编译单元组织为结构清晰、权限明确的内存映像,为程序的装载与执行奠定基础。
4.2 符号解析与重定位
在完成段的空间与地址分配之后,链接器进入符号解析与重定位阶段。此时各目标文件内部仍然保留着大量"未决引用",即编译器无法在单个编译单元内确定的符号地址。
这些引用的修正信息集中存放在重定位表(Relocation Table)中。重定位表记录了需要修改的位置、引用的符号、以及重定位类型等关键信息,是连接编译阶段与最终可执行映像之间的桥梁。
符号解析的核心在于构建并遍历全局符号表。链接器会汇总所有输入目标文件中的符号定义与声明,对每一个外部引用进行匹配。
如果在全局符号表中无法找到某个被引用符号的定义,就会触发"未定义符号"(undefined reference)错误。这一过程不仅仅是简单查找,还涉及符号可见性、弱符号(weak symbol)与强符号(strong symbol)的优先级规则,保证最终解析结果符合语言与 ABI 规范。
在确定符号地址后,链接器需要依据重定位类型进行地址修正。绝对寻址(absolute relocation)的修正方式较为直接,即将指令或数据中占位的值替换为符号的实际虚拟地址。例如全局变量访问常采用这种方式:
c
extern int g;
int *p = &g; // 需要写入 g 的绝对地址
此时链接器会将 g 的最终地址写入到 p 所在的位置。若段基址为 0x400000,而 g 在段内偏移为 0x120,则修正值为 0x400120。
相对寻址(relative relocation)则更常见于函数调用与跳转指令,特别是在 x86 架构中使用 PC-relative 方式。修正值并非符号的绝对地址,而是符号地址与当前指令地址之间的差值:
reloc_value = symbol_addr - patch_location;
这种方式的优势在于生成的代码在加载到不同基址时更易实现位置无关(PIC),也是共享库实现的基础。
通过重定位表的精确描述与符号表的统一解析,链接器最终将逻辑上分散的符号引用转化为物理上连续、可执行的内存布局。
4.3 COMMON 块
在传统 ELF 链接模型中,未初始化的全局变量通常被放入所谓的 COMMON 块。这类符号在编译阶段并未分配确定的存储位置,而是仅记录符号名与所需大小,由链接器在最终阶段统一分配到 bss 段。由于编译单元之间可能分别声明同名未初始化变量,链接器必须延迟决策,这种机制也与弱符号(weak symbol)语义密切相关。
弱符号机制允许同名符号在多个目标文件中存在,而不会立即触发冲突。对于未初始化的全局变量,编译器通常将其视为弱符号处理,因为单独从语法层面无法区分"声明"与"定义"。例如:
c
// a.c
int g;
// b.c
int g;
在默认 -fcommon 行为下,这两个 g 会被视为 COMMON 符号,链接器将为其分配一块共享空间,而不会报错。这种策略在早期 C 语言工程中较为常见,但也可能掩盖潜在的重复定义问题。
当存在强符号(strong symbol)时,解析规则具有明确优先级。若两个或以上强符号同名且类型不一致,链接器会直接报"多重定义"错误,因为强符号代表确定且唯一的定义。如果存在一个强符号与若干弱符号,即便类型不一致,也以强符号为准,其余弱符号被忽略。若全部为弱符号且类型不一致,链接器通常选择占用空间最大的定义,以避免数据截断风险,这体现了 COMMON 合并时的保守策略。
这种行为可以通过编译选项进行控制。GCC 提供的 -fno-common 会禁止将未初始化全局变量放入 COMMON 块,而是直接放入 bss 段并作为强符号处理。这样一来,上述示例将触发多重定义错误,从而在链接阶段暴露潜在缺陷。现代编译器(如较新版本的 GCC)已默认采用 -fno-common,强调显式定义与单一定义原则,有助于提升大型工程的可维护性与可移植性。
4.4 C++支持
相较于 C,C++ 在编译阶段会生成更多具备"实例化"特征的代码,例如模板实例、外部内联函数以及虚函数表(vtable)。这些实体往往在多个翻译单元中重复出现,如果不加处理将导致符号冲突或代码膨胀。为此,链接器引入 LinkOnce 或 COMDAT 机制,对语义等价的段进行合并,仅保留一份有效定义。以模板函数为例,每个使用点都可能生成实例代码,但最终链接时只会保留一份实现,其余副本被丢弃,从而在保证 ODR(One Definition Rule)的前提下降低体积。
为了进一步提升裁剪能力,编译器可配合 -ffunction-sections 与 -fdata-sections 选项,将每个函数或数据对象分别放入独立段(如 .text.foo)。链接器再结合 --gc-sections,根据符号引用关系进行"按需保留"。这种函数级别链接能显著减少未被调用代码的体积,尤其适用于嵌入式系统。然而段数量激增会增加重定位与符号处理开销,导致链接时间变长、段表规模膨胀,在大型工程中需权衡取舍。
C++ 还依赖特定段机制完成对象生命周期管理。.init 段(或现代 ELF 中的 .init_array)用于在 main 函数执行前调用全局对象构造函数;.fini(或 .fini_array)则在程序结束阶段触发析构逻辑。编译器会为每个具备静态存储期的对象生成初始化函数,并将其指针写入对应数组段,运行时由启动代码统一遍历调用,过程大致如下:
程序装载
执行 .init_array
调用 main
执行 .fini_array
进程退出
在更底层层面,C++ 的可执行兼容性依赖于 ABI(Application Binary Interface)。ABI 规定了符号修饰(name mangling)规则、对象内存布局、虚函数表结构以及函数调用约定等。例如函数重载会通过符号修饰编码参数类型:
cpp
int add(int, int);
在符号表中可能呈现为 _Z3addii(Itanium ABI)。不同编译器若 ABI 不一致,即便源代码兼容,生成的目标文件也无法互相链接。因此主流平台通常遵循统一 ABI 规范,以保证跨模块、跨库之间的二进制可互操作性。
4.5 静态库链接
静态库本质上是若干目标文件(*.o)的归档集合,通常以 ar 工具生成,扩展名为 .a。例如:
bash
ar rcs libmath.a add.o sub.o mul.o
ar t libmath.a # 查看成员文件
ar x libmath.a # 解包
ar 仅负责打包与索引生成,本身不参与符号解析。静态库内部仍然保持目标文件的独立结构,包含各自的段表、符号表与重定位信息,因此它更像是"容器"而非新的可执行单元。
在链接阶段,ld 会根据未解析符号按需从静态库中提取目标文件。其处理方式并非一次性展开整个库,而是扫描库索引,找到能够提供所需符号定义的成员文件,并将该目标文件整体加入链接过程。这个过程具有"惰性"特征:只有当某个符号在当前未定义集合中出现时,才会触发对应目标文件的引入。因此,库的书写顺序会影响解析结果,常见的规则是"被依赖库写在后面"。
需要注意的是,链接器的最小提取单位是"目标文件"而非"函数"。如果某个 .o 文件中包含多个函数,而其中只有一个被引用,其余函数也会一并进入最终可执行文件。例如:
c
// util.c
void used();
void unused1();
void unused2();
若 used 被调用,则整个 util.o 会被链接,unused1 和 unused2 也随之进入输出文件。这种粒度限制可能造成代码体积膨胀,尤其在工具函数高度集中的库中更为明显。
为缓解这一问题,可以结合函数级别分段(-ffunction-sections)与链接器垃圾回收选项(--gc-sections),或在设计库时将功能拆分为更细粒度的源文件。这样既保持静态库的组织结构,又能在链接阶段实现更精细的裁剪策略,在代码体积与构建效率之间取得平衡。
4.6 链接过程控制
在常规应用开发中,链接规则由编译器驱动并采用系统默认脚本即可满足需求。但在操作系统内核、BIOS 或裸机程序等场景下,开发者往往需要精确控制段布局、入口地址与加载方式,这就要求突破默认链接策略。此类程序通常不依赖标准运行时环境,也不遵循通用进程装载模型,因此必须对可执行映像的结构进行定制。
控制链接行为的方式主要有三类。其一是通过额外命令行参数,例如指定入口符号(-e symbol)、禁止生成动态依赖(-static)、或设置段对齐方式。其二是将指令嵌入目标文件,例如在 PE/COFF 中使用 .drectve 段向链接器传递选项。其三也是最灵活的方式------使用链接脚本(ld script),直接描述段的排列顺序、起始地址与符号定义。通过 ld --verbose 可以查看当前平台的默认脚本,从而在其基础上裁剪或重写。
例如,一个极简程序可以绕过标准 crt 启动代码,自定义入口函数:
bash
gcc -fno-stack-protector -c tiny_hello_world.c
ld -static -e no_main -o tiny_hello_world.out tiny_hello_world.o
此时 no_main 成为程序入口,若未显式链接标准库,输出文件体积会显著减小。进一步地,可编写最小链接脚本:
ld
ENTRY(no_main)
SECTIONS
{
. = 0x400000 + SIZEOF_HEADERS;
tinytest : { *(.text) *(.data) *(.rodata .rodata.*) }
/DISCARD/ : { *(.comment) *(.note.*) *(.eh_frame)}
}
该脚本将代码加载到固定虚拟地址 0x10000,适用于简单实验或裸机加载环境。
需要注意的是,在 Linux 用户态程序中,虚拟地址并非完全自由。系统通常启用 NULL 页保护,限制最小可映射地址(/proc/sys/vm/mmap_min_addr),常见值为 65536(即 0x10000)。这一机制防止空指针解引用被映射为有效内存,从而提升安全性。
因此在用户态手动指定加载地址时,若设置为过低地址(如 0x0),程序将无法正常装载。通过理解链接脚本与内存保护机制之间的关系,可以更深入掌握可执行文件从生成到运行的完整控制链路。
4.7 LD 链接脚本语法
ld 链接脚本由两类基本语句构成:命令语句与赋值语句。命令语句用于描述段布局、入口点与输出格式等结构性规则;赋值语句则用于定义或修改符号值,本质上类似 C 表达式计算。例如:
ld
ENTRY(_start)
_start_addr = 0x8000;
前者声明入口符号,后者将符号 _start_addr 赋值为常量。赋值语句常配合位置计数器 . 使用,用于精确控制段的起始与对齐。
常见命令语句包括 ENTRY(symbol)、SECTIONS { ... }、MEMORY { ... } 等。其中 ENTRY 指定程序入口;MEMORY 描述物理或虚拟内存区域(在嵌入式开发中尤为重要);而 SECTIONS 是脚本核心,用于定义输出段及其映射规则。通过 ld --verbose 可查看系统默认脚本结构,从而理解标准 ELF 可执行文件的段组织方式。
SECTIONS 命令的基本格式如下:
ld
SECTIONS {
. = 0x10000;
.text : { *(.text) }
.data : { *(.data) }
.bss : { *(.bss) }
}
其中 . 表示当前位置计数器;* 为通配符,匹配所有输入目标文件;*(.text) 表示将所有输入文件中的 .text 段合并到输出段 .text。花括号中的规则称为 contents 规则,用于描述"哪些输入段被放入当前输出段"。链接器按顺序解析规则,并在合并过程中更新位置计数器。
更复杂的 contents 规则可以包含排序与对齐控制,例如:
ld
.text : {
*(.text.startup)
*(.text*)
. = ALIGN(16);
}
该规则优先放置启动代码段,再合并所有以 .text 开头的段,最后对齐到 16 字节边界。通过这种方式,可以精确安排启动代码、常规代码及只读数据的位置。理解 SECTIONS 的匹配与布局机制,有助于构建从最小实验程序到完整操作系统内核的定制映像结构。
4.8 BFD库
BFD(Binary File Descriptor library)是 GNU 体系中的底层二进制文件处理库,旨在以统一接口屏蔽不同目标文件格式之间的差异。无论是 ELF、COFF、PE 还是 a.out,在 BFD 抽象层之上都被视为一种"目标对象"。这种设计使得上层工具无需关心具体文件格式细节,从而实现跨平台与多架构支持。BFD 的核心思想是以 bfd 结构体作为句柄,通过统一 API 访问段、符号与重定位信息。
从功能角度看,BFD 提供了目标文件识别、段遍历、符号表读取、重定位信息访问以及目标文件生成等能力。开发者可以通过 bfd_openr 打开文件,通过 bfd_check_format 判断文件类型,再利用 bfd_get_section_by_name 或 bfd_map_over_sections 遍历段结构。
符号解析通常结合 bfd_canonicalize_symtab 完成,将底层符号表转换为统一的内部表示。这种"规范化"(canonicalize)步骤是 BFD 适配多种格式的关键机制。
GNU 工具链广泛依赖 BFD。ld 使用 BFD 读取输入目标文件并生成输出格式;gdb 借助 BFD 解析符号与调试信息;objdump、nm 等分析工具同样通过 BFD 抽象访问二进制内容。可以说,BFD 构成了 GNU 二进制处理体系的公共基础层,使不同工具在支持新架构时仅需扩展 BFD 后端即可。
下面是一个典型的 BFD 使用示例,用于打开 ELF 文件并列出其段名:
c
#include <bfd.h>
#include <stdio.h>
int main(int argc, char **argv) {
bfd *abfd;
asection *sec;
bfd_init();
abfd = bfd_openr(argv[1], NULL);
if (!abfd) return 1;
if (!bfd_check_format(abfd, bfd_object)) {
bfd_close(abfd);
return 1;
}
for (sec = abfd->sections; sec; sec = sec->next) {
printf("section: %s\n", bfd_section_name(sec));
}
bfd_close(abfd);
return 0;
}
该程序通过统一接口遍历所有段,而无需显式解析 ELF 头或节表结构。BFD 的优势在于抽象与可扩展性,但其接口较为底层且复杂,调用流程需要严格遵循初始化与格式检查步骤,才能正确操作不同类型的目标文件。

Once Day
也信美人终作土,不堪幽梦太匆匆......
如果这篇文章为您带来了帮助或启发,不妨点个赞👍和关注!
(。◕‿◕。)感谢您的阅读与支持~~~