到目前为止,你的链接器处理的都是松散的目标文件——命令行上列出哪些文件,就链接哪些文件,没有选择的余地。但在真实的软件开发中,我们经常需要使用库(library)。
想象一下,你正在开发一个需要处理JSON的程序。你找到了一个JSON解析库,它包含几十个函数,涵盖了解析、序列化、查询、修改等各种功能。但你的程序可能只需要其中的两三个函数——比如解析JSON字符串和查询特定字段。
如果我们把整个库的所有目标文件都链接进来,你的可执行文件会包含大量永远不会被调用的代码。这不仅浪费磁盘空间,也浪费内存——操作系统需要加载这些无用的代码到内存中。更糟糕的是,如果库的某个部分引用了你的系统上不存在的其他库,链接就会失败,即使你根本不需要那部分功能。
这就是静态库存在的意义。静态库是一个归档文件,包含多个目标文件。链接器会分析你的程序实际使用了哪些符号,然后只从库中提取那些定义了这些符号的目标文件。这个过程称为按需链接(selective linking)。
在Unix系统中,静态库通常使用.a扩展名(archive的缩写)。你可以用ar命令创建和管理这些文件。一个归档文件本质上是多个目标文件打包在一起,加上一个索引(也叫符号表)。
在我们的实验中,归档文件也是一个FLEObject,但它的type字段是".ar"。与普通目标文件不同,归档文件的内容不是代码和数据的节,而是一个成员列表——每个成员本身是一个完整的目标文件(FLEObject)。
当实验框架加载一个归档文件时,它会将FLE格式解析成这样的结构:
FLEObject archive = {
.name = "libjson.a",
.type = ".ar",
.members = {
FLEObject {
.name = "json_parse.o",
.type = ".obj",
// ... 这是一个完整的目标文件 ...
},
FLEObject {
.name = "json_query.o",
.type = ".obj",
// ...
},
// ... 更多成员 ...
}
};你的链接器接收到的输入是std::vector<FLEObject>,其中可能混合着普通目标文件和归档文件。你需要检查每个输入对象的type字段来决定如何处理它。对于type为".obj"的对象,像之前一样直接处理。对于type为".ar"的对象,应用按需链接算法。
按需链接的核心是一个迭代的符号解析过程。让我们通过一个例子来理解。
假设你的程序引用了函数parse_json:
// main.c
extern void parse_json(const char*);
int main() {
parse_json("{\"key\": \"value\"}");
return 0;
}你链接时指定了libjson.a。这个库包含三个目标文件,每个文件定义一些函数:
json_parse.o:定义parse_json,引用allocate_memoryjson_memory.o:定义allocate_memory和free_memoryjson_serialize.o:定义serialize_json
链接器应该这样工作:
第一轮扫描:链接器处理main.o,发现它引用了parse_json但没有定义。这个符号进入"未解析符号"列表。
第二轮扫描:链接器扫描libjson.a,查找哪些成员定义了未解析的符号。发现json_parse.o定义了parse_json,于是提取这个目标文件。
第三轮扫描:处理刚提取的json_parse.o,发现它引用了allocate_memory。这个符号进入"未解析符号"列表。
第四轮扫描:再次扫描库,发现json_memory.o定义了allocate_memory,提取它。
第五轮扫描:处理json_memory.o,发现没有新的未解析符号。
结束:链接器检查是否还有未解析符号。如果有,报错;如果没有,继续生成可执行文件。
注意json_serialize.o从未被提取——因为程序不需要序列化功能。这就是按需链接的价值。
在实现时,你需要解决几个问题。
第一个是如何组织扫描过程。一个可行的策略是维护一个工作队列,初始时包含命令行指定的所有目标文件。每当从库中提取一个目标文件,就将它加入队列。不断处理队列中的文件,收集它们定义的符号和引用的符号,直到队列为空且没有新的未解析符号。
第二个是库的扫描顺序。传统的Unix链接器要求库的顺序是有意义的——如果liba.a依赖libb.a,那么命令行上liba.a必须出现在libb.a之前。这是因为链接器只扫描每个库一次。更现代的链接器可能会多次扫描库,直到不再有新的符号被解析。你可以选择实现哪种策略,但至少需要支持基本的单次扫描。
第三个是如何区分库和普通的目标文件。在命令行解析时,你需要检查输入文件的类型。如果是归档文件(.type == ".ar"),使用按需链接;如果是普通目标文件(.type == ".obj"),直接包含。
Note
在真实的链接器中,库通常通过-l标志指定(如-ljson会查找libjson.a),而目标文件直接列在命令行上。库和目标文件的处理方式不同:目标文件总是被完全包含,而库是按需包含的。这个设计允许程序员精确控制链接行为。
有一个有趣的边界情况需要考虑。假设库中有两个目标文件互相依赖:
a.o:定义func_a,引用func_bb.o:定义func_b,引用func_a
如果你的程序只引用func_a,链接器会提取a.o,发现需要func_b,提取b.o,发现需要func_a……等等,func_a不是已经有了吗?
这提醒我们在实现时需要注意:当从库中提取目标文件时,不要重复提取已经提取过的成员。你可以维护一个集合,记录哪些成员已经被包含。
Tip
一个实用的测试方法是创建一个简单的库,包含两三个相互依赖的目标文件,然后编写测试程序来验证按需链接是否正确工作。从简单的情况开始,逐步增加复杂度,比如添加多级依赖、循环依赖等场景。
完成后,运行测试来验证:
make test_7如果所有测试都通过了,恭喜你——你已经实现了一个功能完整的静态链接器!它不仅能够正确地组合程序、解析符号、处理重定位,还能生成具有现代安全特性的可执行文件,并且支持按需链接。
最后,运行完整的测试套件来确认所有功能都正常工作:
make test链接器是一个看似简单但内涵丰富的工具。现代的生产级链接器(如GNU ld或LLVM lld)还有许多我们在这个实验中没有涉及的特性:动态链接、位置无关代码、符号版本管理、链接时优化等等。如果你对这些话题感兴趣,实验文档的进阶内容部分提供了一些探索的方向。