在前面的任务中,我们处理的都是"一个萝卜一个坑"的情况——每个符号在整个程序中只有一个定义。但现实往往更复杂。让我们从一个你可能在CSAPP中见过的例子开始:
// foo.c
int x;
// bar.c
int x;如果你读过教材第七章,应该记得这两个未初始化的全局变量在链接时会被合并成一个。书中将它们称为"弱符号",并解释说C编译器会把未初始化的全局变量放入COMMON块,允许链接器在最后统一处理。
现在,试着编译链接这两个文件:
gcc -c foo.c bar.c
gcc foo.o bar.o -o prog你很可能会看到:
/usr/bin/ld: bar.o:(.bss+0x0): multiple definition of `x';
foo.o:(.bss+0x0): first defined here
发生了什么?难道教材错了?
教材当然没错——它描述的是一种历史上广泛使用的链接行为。但在过去十年里,C编译器的默认设置悄然发生了变化。从GCC 10开始,编译器默认启用了-fno-common选项。这个选项改变了未初始化全局变量的处理方式:它们不再进入COMMON块,而是直接在.bss节中获得确定的定义。
这不是一个任意的改动。容忍同名符号的隐式合并,在小型程序中或许方便,但随着代码库规模增长,它会掩盖真正的错误。想象一下,两个模块各自定义了int config,但含义完全不同,链接器却默默地把它们合并了——这会导致难以追踪的运行时问题。
Note
如果你想在现代编译器上重现教材中的行为,可以显式指定gcc -fcommon。但在本实验中,我们遵循现代默认行为:除非程序员明确标记,所有全局变量都被视为独立的定义。
那么,当我们确实需要"可覆盖的默认实现"时该怎么办?这就是弱符号真正发挥作用的地方。
考虑这样一个场景:你在编写一个通用库,提供一些默认的错误处理函数。但你希望用户能够根据自己的需要替换这些实现。如果使用普通的全局定义,用户就无法提供自己的版本。这时,弱符号提供了一种优雅的解决方案:
// logger.c (库代码)
__attribute__((weak))
void log_error(const char* msg) {
// 默认实现:输出到stderr
fprintf(stderr, "Error: %s\n", msg);
}
// main.c (用户代码)
void log_error(const char* msg) {
// 用户自定义:写入日志文件
write_to_log_file(msg);
}这里的__attribute__((weak))告诉编译器:"我提供了一个实现,但如果链接时遇到同名的普通定义,请使用那个而不是我的。"
在FLE格式中,我们用不同的符号标记来区分这两类定义:
📤:普通的全局符号,表达"这就是该符号的定义"📎:弱符号,表达"如果没有更好的选择,可以用我"
基于上述理解,你的链接器需要处理三种典型情况。让我们逐一看看每种情况背后的逻辑:
情况一:两个普通定义相遇
// a.c
int config = 10;
// b.c
int config = 20;这显然是一个错误。两个模块都明确地定义了config,但它们的值不同。链接器无法也不应该猜测应该用哪个。此时你的链接器应该向标准错误输出报错信息,格式为:Multiple definition of strong symbol: <符号名>
例如:Multiple definition of strong symbol: global_var
情况二:普通定义遇到弱定义
// lib.c
__attribute__((weak))
void init() { /* 默认实现 */ }
// main.c
void init() { /* 用户实现 */ }这正是弱符号机制的设计意图。链接器应该选择普通定义,忽略弱定义。无论这两个符号以什么顺序被扫描到,结果都应该一致。
情况三:多个弱定义相遇
// module_a.c
__attribute__((weak))
int debug = 0;
// module_b.c
__attribute__((weak))
int debug = 1;当多个弱定义共存时,它们的语义是"如果没人提供明确的实现,随便用我们中的一个就行"。链接器选择任意一个即可,通常选择第一个遇到的。这不算错误,所以不应该报错。
Tip
在实现时,你可以维护一个全局符号表。每当遇到一个新符号时,检查表中是否已经存在同名符号。如果存在,根据新旧符号的类型应用上述规则:
- 都是普通定义 → 报错
- 一个普通一个弱 → 保留普通的
- 都是弱定义 → 保留现有的(或任意选择)
除了全局符号的冲突,我们还需要处理static关键字带来的可见性问题:
// foo.c
static int counter = 0; // 只在foo.c内可见
// bar.c
static int counter = 0; // 只在bar.c内可见这两个counter虽然名字相同,但它们在不同的"作用域"中——一个属于foo.c,一个属于bar.c。它们最终会占据内存中不同的位置,互不干扰。
在FLE格式中,这类符号用🏷️标记。链接器需要确保它们不会混淆。一个实用的做法是在内部给它们加上文件名前缀,比如把foo.c中的counter记为foo.c::counter,把bar.c中的记为bar.c::counter。这样在处理重定位时,每个文件都能正确找到自己的符号。
Important
在真实代码中,你会遇到一些编译器自动生成的局部符号,比如.LC0、.LC1这样的字符串常量标签。不同文件可能生成同名的标签,但它们指向的是不同的字符串。这也是需要通过前缀区分的情况。
到目前为止,我们假设每个被引用的符号都能在某个目标文件中找到定义。但现实中由于各种各样的原因,链接器可能在链接过程中无法找到某个符号的定义。例如,用户可能忘记链接某个目标文件。
当你的链接器完成所有符号决议后,如果发现仍有未解析的符号引用,应该向标准错误输出报错信息,格式为:Undefined symbol: <符号名>
例如:
Undefined symbol: _local_var
Tip
测试脚本采用较为宽松的匹配方式,只要检测到 stderr 中的某一行包含符合格式的字符串即认为测试通过。你可以:
- 通过
throw std::runtime_error(err_message)抛出异常,实验框架会自动捕获并输出错误信息到标准错误流 - 直接向
stderr输出错误信息(使用std::cerr或fprintf),然后调用exit(1)中止程序
现在,请在src/student/ld.cpp中实现这些符号决议规则。你可能需要修改符号表的设计,添加一些辅助函数来判断符号类型和应用决议逻辑。
完成后,运行测试来验证:
make test_4