Skip to content

Latest commit

 

History

History
121 lines (82 loc) · 5.48 KB

File metadata and controls

121 lines (82 loc) · 5.48 KB

任务三:处理多种地址引用方式

在任务二中,我们实现了绝对重定位——直接将符号的地址填入引用位置。这对于访问全局变量很自然,但程序中还有其他类型的引用。让我们从函数调用开始。

函数调用为何特殊

考虑这样一个程序:

// lib.c
int add(int x) {
    return x + 1;
}

// main.c
extern int add(int);

int main() {
    return add(41);
}

main函数调用add时,编译器会生成一条call指令。在x86-64上,这条指令的格式是:

e8 [4字节的偏移量]

这里的"偏移量"不是目标函数的绝对地址,而是相对于下一条指令的距离。假设call指令位于0x400010add函数位于0x4000A0,那么偏移量应该是:

0x4000A0 - (0x400010 + 5) = 0x8B

为什么要减去0x400010 + 5?因为当CPU执行call指令时,程序计数器已经指向了下一条指令的位置,也就是当前指令地址加上指令长度(1字节操作码 + 4字节操作数)。

这种设计有两个重要的优点。首先,只要保持代码内部的相对位置不变,整个程序可以被加载到内存的任何位置。你不需要在加载时修改这些指令,它们天然是位置无关的。其次,对于大多数程序来说,函数之间的距离不会超过2GB(32位有符号整数的范围),这意味着4字节的偏移量就足够了,节省了空间。

在FLE格式中,这类重定位用.rel标记:

{
    ".text": [
        "📤: main 20 0",
        "🔢: f3 0f 1e fa 55 48 89 e5 bf 29 00 00 00 e8",
        "❓: .rel(add - 4)",
        "🔢: 5d c3"
    ]
}

对应的Relocation结构体会有类型R_X86_64_PC32(PC表示Program Counter,即程序计数器),以及一个附加值-4

相对重定位的计算

相对重定位的计算公式是:

$$ \text{偏移量} = S + A - P $$

其中S是目标符号的地址,A是附加值(addend),P是重定位位置的地址(即call指令操作数所在的位置)。

让我们理解一下这个附加值的作用。在上面的例子中,P指向call指令的操作数位置,比如0x400014。但CPU执行时,程序计数器已经指向0x400019(下一条指令)。所以实际的跳转距离应该是相对于0x400019的,也就是S - 0x400019

现在注意到P = 0x400014,而0x400019 = P + 5。但我们不想在每次计算时都写S - (P + 5),所以编译器把这个-5的一部分(具体是-4)预先编码在附加值中。这样公式就统一成了S + A - P,其中A = -4

Note

你可能会问,为什么是-4而不是-5?这是因为重定位位置P本身就比指令起始位置多了1(操作码的长度)。详细的推导是:实际跳转距离 = S - (指令起始 + 5) = S - (P - 1) - 5) = S - P - 4。所以当我们写成S + A - P的形式时,A = -4

从实现的角度,你需要做的是:在全局符号表中找到目标符号的地址S,计算重定位位置的地址P(基址 + 节内偏移),然后按照公式计算偏移量,将结果以小端序写入4个字节。

当32位不够用时

现在让我们看另一个场景。假设你的程序中有一个指针数组:

int numbers[] = {1, 2, 3, 4};
int *pointers[] = {&numbers[0], &numbers[1], &numbers[2], &numbers[3]};

这里的pointers数组存储的是地址。在64位系统上,指针是8字节的。这意味着我们不能再用32位的重定位了——我们需要完整的64位地址。

在FLE格式中,这用.abs64标记:

{
    ".data.rel.local": [
        "📤: pointers 32 0",
        "❓: .abs64(numbers + 0)",
        "❓: .abs64(numbers + 4)",
        "❓: .abs64(numbers + 8)",
        "❓: .abs64(numbers + 12)"
    ]
}

对应的重定位类型是R_X86_64_64。它的计算方式和32位绝对重定位类似,只是不需要截断——直接使用完整的64位地址。

你可能会想,既然有了64位的重定位,为什么还要用32位的?答案是效率。如果所有的地址引用都用8字节,程序的体积会显著增大。实际上,对于大多数引用(比如函数调用、访问全局变量),32位就足够了。只有在真正需要存储完整指针的地方,比如指针数组、函数指针表等,我们才使用64位。

处理不同重定位类型的策略

现在你的链接器需要处理三种重定位类型:

  • R_X86_64_32R_X86_64_32S:32位绝对地址(任务二已完成)
  • R_X86_64_PC32:32位相对地址(本任务新增)
  • R_X86_64_64:64位绝对地址(本任务新增)

一个清晰的实现方式是根据重定位类型分支处理。对于每个重定位项,首先查找目标符号的地址,然后根据类型选择相应的计算方式。注意32位的重定位写入4字节,64位的重定位写入8字节,都使用小端序。

你可能还需要考虑如何组织代码使其易于扩展。现在我们有三种类型,但未来可能还会有更多。将不同类型的处理逻辑封装成独立的函数或分支,会让代码更清晰。

Tip

在调试相对重定位时,打印每一步的计算过程会很有帮助:目标符号地址S、重定位位置P、附加值A、最终计算得到的偏移量。这能帮你快速发现是哪个环节出了问题。特别要注意符号的问题——偏移量可能是负数,确保你用有符号整数类型来存储和计算。

完成后,运行测试来验证:

make test_3