在任务一中,我们学会了如何读取符号表。现在是时候让链接器真正发挥作用了——把多个目标文件组合成一个可以运行的程序。
让我们从一个简单的例子开始理解这个过程。假设有这样两个文件:
// foo.c
const char message[] = "Hello, World!";
// main.c
extern const char message[];
void _start() {
int sum = 0;
for (int i = 0; i < 10; i++) {
sum += message[i];
}
// ... 退出系统调用 ...
}编译器会为每个文件生成一个目标文件。foo.fo的内容可能是这样的:
{
"type": ".obj",
".rodata": [
"📤: message 14 0",
"🔢: 48 65 6c 6c 6f 2c 20 57 6f 72 6c 64 21 00"
]
}而main.fo包含:
{
"type": ".obj",
".text": [
"📤: _start 32 0",
"🔢: 31 c0 31 d2 0f be 88",
"❓: .abs32s(message + 0)", // 这里需要message的地址
"🔢: 48 ff c0 01 ca 48 83 f8 0a 75 ee ..."
]
}注意main.fo中有个❓标记——这是一个待解决的引用。代码想要访问message数组,但现在它还不知道这个数组会被放在内存的哪里。这正是链接器要解决的问题。
链接器本质上是在回答三个问题:
第一个问题:每个符号最终在内存的什么位置?
这需要我们规划整个程序的内存布局。程序运行时需要加载到内存中,我们需要决定从哪个地址开始,以及各个部分按什么顺序排列。
在CSAPP第七章中,你可能见过Linux系统通常将程序加载到从0x400000开始的虚拟地址空间。我们也采用这个约定。想象一下,我们要把两个文件的内容"拼接"成一块连续的内存区域:
0x400000: [main.fo的.text节] (32字节)
0x400020: [foo.fo的.rodata节] (14字节)
这样,message符号原本在foo.fo的.rodata节偏移0处,现在就对应内存地址0x400020。当然,实际实现时你还需要考虑其他的节,比如.data节、.bss节等,但核心思路是一样的。
第二个问题:如何找到每个符号的定义?
这就是符号解析的过程。在上面的例子中,main.fo使用了message符号,而foo.fo定义了它。链接器需要建立一个全局的视角,知道每个符号在哪里定义、最终位于什么地址。
一种自然的做法是维护一个全局符号表。当你处理每个目标文件时,检查它定义了哪些符号,记录下这些符号的最终地址。这样,当遇到未定义的符号引用时,你就可以查表找到它的地址。
第三个问题:如何填补那些空缺的地址引用?
这就是重定位。回到我们的例子,main.fo中有一行:
"❓: .abs32s(message + 0)"这告诉链接器:"这里需要message的地址,请帮我填上。"链接器需要做的是:
- 在全局符号表中查找
message,得到地址0x400020 - 根据重定位类型(这里是
R_X86_64_32S,表示32位有符号绝对地址),计算要填入的值 - 将这个值写入相应的位置
你在CSAPP第七章中应该见过不同类型的重定位。在这个任务中,我们先处理最直接的一种:绝对重定位。对于R_X86_64_32和R_X86_64_32S这两种重定位类型,计算方式是一样的——直接使用符号的地址,然后截断到32位。
Note
R_X86_64_32和R_X86_64_32S的区别在于如何验证截断的合法性。前者要求高32位全为0(零扩展兼容),后者要求高32位与第32位相同(符号扩展兼容)。在本任务的测试用例中,这个差异不会造成实际影响,你可以暂时忽略它们的区别。
Tip
x86-64架构使用小端序(little-endian)。这意味着一个32位的地址0x00400020在内存中的存储顺序是20 00 40 00。在填写重定位值时需要注意这一点。
完成上述三步后,我们得到了一块包含完整机器码的内存映像。最后一步是将它包装成可执行文件的格式。可执行文件与目标文件的主要区别之一就是它包含程序头(Program Header),描述程序运行时的内存布局。对于这个任务,我们可以使用一种简单的策略:把所有内容放在一个段中。创建一个程序头描述这个段的位置、大小和权限:
ProgramHeader {
.name = ".load",
.vaddr = 0x400000, // 加载到此地址
.size = 46, // 总大小
.flags = PHF::R | PHF::W | PHF::X // 暂时赋予所有权限
}在这个任务中,你需要生成一个程序头,将所有合并后的内容作为一个整体加载到内存。可以将所有输入节的内容合并到一个输出节中,比如命名为.load。这个节对应一个内存段,基址设为0x400000,权限暂时设为rwx(可读、可写、可执行)。
此外,可执行文件必须指定一个入口点——程序开始执行的位置。按照惯例,这通常是名为_start的函数。在符号表中找到它,将其地址设为入口点。
Note
节与段的基本概念
你可能注意到FLEObject中既有shdrs(section headers,节头)又有phdrs(程序头)。简单来说,节是链接器组织代码和数据的方式,程序头描述加载器如何将这些内容加载到内存中。在FLE中,每个节对应一个程序头(段)。
在这个任务中,我们暂时把所有内容放在一个段中,并赋予了读、写、执行的全部权限。这不是最终的方案——真实的系统中,代码和数据应该分开存放,并设置不同的权限。但在这个阶段,我们先专注于让程序运行起来。你会在后续任务中逐步完善这个布局。
建议你在实现的时候,不要过于简单地认为整个程序只会有一段,而是要稍微考虑一下多个段的情况。这一点对于你完成后续任务将会很有帮助。
Tip
在实现链接器的过程中,一种可行的思路是采用多遍扫描的方法:
- 第一次扫描:遍历所有目标文件,将它们的节内容拼接起来。在拼接过程中,记录下每个节在最终内存中的起始位置。
- 第二次扫描:再次遍历所有目标文件,这次收集它们定义的符号。由于你已经知道每个节的最终位置,就可以计算出每个符号的最终地址,并将它们加入全局符号表。
- 第三次扫描:处理所有的重定位项。现在全局符号表已经完整,你可以查找每个被引用的符号,计算要填入的值,更新相应的内存位置。
最后生成程序头,并设置入口点,得到最终的 FLEObject。
可以思考:为什么要采取这样的扫描顺序?有没有其他更好的方法?
请在 src/student/ld.cpp 中实现你的链接器。
Tip
Start simple, move fast!
在实现过程中,你可以先处理只有一个输入文件的简单情形,确保基本流程正确后,再扩展到多个文件。
使用 readfle 工具检查输出文件的格式是否正确,或者在过程中打印调试信息(比如节的合并过程、符号的新地址、重定位的处理过程)也会对调试很有帮助。参见调试指南。
完成后,运行测试来验证:
make test_2