ICS 与 8086 复习 15:编译链接 ELF 符号与重定位
两个源文件都能单独编译成功,为什么放在一起仍会出现 undefined reference?原因是“把每个文件翻译成机器代码”和“把跨文件引用接起来”属于不同阶段。复习链接时,先回答两个问题:这个名字由谁定义,它最终位于哪里。
本文使用课程中的 Linux ELF、IA32语境。Windows 的 PE、8086 实验中的 DOS EXE 与这里共享部分思想,但文件结构和重定位细节不能直接照搬。
四个阶段各自产生什么
预处理处理头文件包含和宏等;编译把预处理后的 C 程序转换为汇编;汇编把汇编代码生成可重定位目标;链接将所需目标文件和库组合起来。
| 阶段 | 常见结果 | GCC 分阶段选项 |
|---|---|---|
| 预处理 | .i |
-E |
| 编译 | .s |
-S |
| 汇编 | .o |
-c |
| 链接 | 可执行文件或共享目标 | 常规驱动命令 |
选项 -c 并不只“编译一下”:它会完成产生 .o 所需的前面阶段,但停止在链接之前。以下命令展示Linux工具链各阶段的用法,实际结果取决于源程序和工具链配置:
gcc -m32 -c main.c -o main.o
gcc -m32 -c add.c -o add.o
gcc -m32 main.o add.o -o demo
readelf -S main.o
readelf -s main.o
readelf -r main.o
-m32 需要相应的 32 位工具链和开发库。去掉它后可能生成 x86-64 文件,寄存器、重定位类型和 ABI 都会随之改变。
ELF 为什么同时谈节和段
节主要服务链接视图:.text 放代码,.rodata 常放只读数据,.data 常放有初值的可写数据,.bss 常对应零初始化的静态存储对象,符号表和重定位节记录链接所需信息。
段主要服务装载视图:程序头告诉加载器哪些文件区域映射到哪些虚拟地址、需要什么访问权限。一个可装入段可包含若干节。节头表与程序头表不是同一张表。
.bss 具有内存大小,却通常不在文件里逐字节存储对应的零值,因此不能说“.bss 不占空间”而不说明指的是文件数据字节还是装载后的内存。完整字段定义可查 ELF 装载规范。
符号解析先确定引用对应哪个定义
设有两个源文件:
/* main.c */
extern int total;
int add(int, int);
int main(void) {
total = add(3, 5);
return total;
}
/* add.c */
int total = 0;
static int calls;
int add(int a, int b) {
++calls;
return a + b;
}
main.o 引用了 total 和 add,它们的定义在 add.o;calls 具有内部链接,仅供本翻译单元使用。函数中的自动局部变量通常不是供跨文件解析的链接器符号,不能把“C 中有一个变量名”直接等同于“链接器必须在所有目标中解析这个名字”。
缺少 add.o,链接器找不到引用对应的定义;两个文件都提供普通的 total 定义,则可能出现多重定义错误。
符号表一行应该先看哪些列
读取 readelf -s 时,先找名字,再依次看类型、绑定、所在节以及值和大小。OBJECT 常对应数据对象,FUNC 对应函数;LOCAL/GLOBAL/WEAK 描述绑定;UND 表示这个文件没有提供定义。UND 不等于整个程序一定出错,因为后续输入或动态链接可能提供定义。
假定某可重定位文件的符号 total 显示值为 10H、大小 4、类型 OBJECT、绑定 GLOBAL,并指向某个已知数据节。这通常表示它在该节内偏移 10H,并不是已经得到进程虚拟地址 00000010H,更不是物理内存地址。若输出节首址最终为 0804A000H,且该输入片段恰好没有额外前移,则该对象地址才能进一步算为 0804A010H。
符号值的含义还受文件类型和特殊节索引影响。例如 COMMON 的值可描述对齐要求,ABS 表示绝对符号。不能对符号表所有行都统一执行“节首址加 Value”。这也是题目必须同时给出符号类型、索引和文件阶段的原因。
COMMON 不能与 WEAK 混同
课件区分了强定义、ELF 弱绑定符号和 COMMON 符号。老式 -fcommon 行为下,文件作用域的 int x; 这类暂定定义可能产生 COMMON 符号,链接器再合并或与其他定义决议;这并不意味着它的 ELF 绑定属性就是 WEAK。
复现旧例子要显式考虑工具链选项。GCC 从 10 起默认采用 -fno-common,多个源文件都写 int x; 可能直接造成链接错误。正确组织跨文件全局对象的常用方式,是头文件写 extern int x;,仅在一个 .c 文件定义它。GCC 官方迁移说明明确记录了这一变化。
遇到“某个变量究竟放 .bss 还是 COMMON”的题,先看题设版本、选项和符号表,而不是只凭“没写初值”就作答。
重定位补的是引用位置中的值
符号已经选定定义,还要安排输出地址,并修改代码或数据里的引用。记:S 为符号最终地址,A 为加数,P 为重定位字段地址。IA32 两种基本形式为:
R_386_32 :写入 S+A
R_386_PC32 :写入 S+A−P
P 是待补写字段的位置,不一定是指令起始地址,也不一定直接等于 CPU 执行跳转时采用的下一指令地址。
课件的 call 算例能把这一区别说清楚。设 main 首址为 08048380H,call 在函数内偏移 6,目标 swap 地址为 08048394H。IA32 的这条近相对调用长 5 字节:
call起始地址 = 08048386H
4字节位移字段地址P = 08048387H
下一指令地址 = 0804838BH
目标地址S = 08048394H
机器执行所需位移为 08048394H−0804838BH=9。相应 ELF 重定位加数为 −4,于是 S+(−4)−P=9。按小端写入后,完整指令是:
E8 09 00 00 00
若把 P 错认成下一指令地址,又额外减 4,就会重复修正。这是重定位计算题最值得检查的一步。
再看一个绝对引用:假定 buf 地址为 08049620H,某 4 字节指针初值应指向 buf[1],且 int 占 4 字节,那么 S 为 08049620H,A 为 4,写入值 08049624H,字节顺序为 24 96 04 08。指针对象自身存放在哪里,与它所保存的地址值,是两个问题。
链接题的易错点
不要把所有局部 C 变量都认成 ELF 的 LOCAL 符号,也不要把 .o 中未定义符号的零值看成真实空指针。先按语言层次和文件阶段分类,再判断引用关系。另一个常见遗漏是对齐:合并两个函数时,后一个函数未必紧接前一个最后一字节;若题设要求四字节对齐,应先补齐地址,再计算相对位移。
重定位是依据条目处理特定位置,不能扫描文件里所有“看起来像地址”的数值然后统一改写。普通整数常量与地址字面值可能拥有相同位串;只有重定位记录及其对应语义,才能说明哪一处需要链接器修改。
自测与简答
.o中已经存在机器指令,为什么它还可能不能独立执行?R_386_PC32中 P 是什么?extern int x;与文件作用域的int x;是否等价?
答案:① 跨模块引用可能未解析,地址可能待重定位,且未形成完整装载映像。② 待重定位字段的地址。③ 不等价;前者通常只是引用外部对象的声明,后者是暂定定义,其目标文件处理还受 -fcommon/-fno-common 等条件影响。
资料对应
主要依据《3.程序转换与指令系统-V1.0.pptx》第 23—28 页,以及《5.程序的链接与加载执行-V1.0.pptx》第 4—49、60—77 页。相对调用保留课件中的地址以便复核;符号示例重新编写。第 5 份课件封面误标“第四章”,本文按文件名和课程大纲记为第 5 章。
讨论这篇文章
关于《ICS 与 8086 复习 15:编译链接 ELF 符号与重定位》的补充、不同意见或延伸话题,都可以留在这里。
滚动到这里时自动载入留言。