Skip to content

Latest commit

 

History

History
159 lines (106 loc) · 6.77 KB

File metadata and controls

159 lines (106 loc) · 6.77 KB

任务四:处理符号冲突

在前面的任务中,我们处理的都是"一个萝卜一个坑"的情况——每个符号在整个程序中只有一个定义。但现实往往更复杂。让我们从一个你可能在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::cerrfprintf),然后调用 exit(1) 中止程序

现在,请在src/student/ld.cpp中实现这些符号决议规则。你可能需要修改符号表的设计,添加一些辅助函数来判断符号类型和应用决议逻辑。

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

make test_4