深入理解计算机系统篇之链接(3):符号解析

Posted by WHX on November 22, 2025

深入理解计算机系统篇之链接(3):符号解析

1.摘要

​ 本文讲解链接中最重要的步骤之一:符号解析相关的内容,包括链接器如何进行符号解析,解析规则是什么,这种解析规则可能会产生什么问题等,还有如何使用静态库以及动态库进行符号解析等。

2.链接器的符号解析方法

​ 链接器解析符号的方法是将每个符号引用与输入的可重定位目标文件中的符号表中的一个符号定义关联起来。通俗的讲就是链接器从后面的可重定位目标文件寻找本文件的符号表中那些未定义的符号引用。

注意:只有调用的外部函数或者使用的外部定义的全局变量才算是符号引用,如果只是声明不会产生符号引用,也就不需要与符号定义关联
备注:链接器进行符号解析时也不是在原目标文件上进行修改,而是构建自己的全局符号表,然后对输入目标文件的符号表进行解析处理。
  • 对于局部符号而言,相对简单一些,因为局部符号只能定义与引用在同一目标文件中,编译器只允许它们有唯一的名称,不会造成冲突
  • 对于全局符号的符号解析就复杂得多,首先编译器遇到一个非当前目标文件定义的符号时,会假设符号在其他目标文件中,然后交给链接器处理,然后链接器在所有输入的可重定位目标文件中查找,只有链接器找不到的情况下才会报错。

​ 下面示例验证链接器的符号解析方法

/*sum.c*/
int global_sum = 0;
int add(int a, int b) {
    return a + b;
}
int subtract(int a, int b) {
    return a - b;
}

/*main.c*/
int add(int a, int b);
int subtract(int a, int b);
extern int global_sum;

int main() {
    int a = 5;
    int b = 3;

    int sum = add(a, b);
    int difference = subtract(a, b);

    global_sum = sum;
    return 0;
}

​ 用编译器生成main.o可以看到其中的addsubtract函数以及变量在符号表中都是未定义

greadelf -s main.o

Symbol table '.symtab' contains 13 entries:
   Num:    Value  Size Type    Bind   Vis      Ndx Name
     0: 00000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 00000000     0 FILE    LOCAL  DEFAULT  ABS main.c
     2: 00000000     0 SECTION LOCAL  DEFAULT    1 .text
     3: 00000000     0 SECTION LOCAL  DEFAULT    3 .data
     4: 00000000     0 SECTION LOCAL  DEFAULT    4 .bss
     5: 00000000     0 NOTYPE  LOCAL  DEFAULT    1 $a
     6: 0000005c     0 NOTYPE  LOCAL  DEFAULT    1 $d
     7: 00000000     0 SECTION LOCAL  DEFAULT    5 .comment
     8: 00000000     0 SECTION LOCAL  DEFAULT    6 .ARM.attributes
     9: 00000000    96 FUNC    GLOBAL DEFAULT    1 main
    10: 00000000     0 NOTYPE  GLOBAL DEFAULT  UND add
    11: 00000000     0 NOTYPE  GLOBAL DEFAULT  UND subtract
    12: 00000000     0 NOTYPE  GLOBAL DEFAULT  UND global_sum

​ 当用ld命令链接时,链接器会对这些未定义的引用进行查找,然后生成一个目标文件,可以看到符号表里的函数main和subtract以及变量global_sum已经不是未定义的状态了,而是有了引用地址。

arm-none-eabi-ld -o prog maic.o sum.o
greadelf -s program

Symbol table '.symtab' contains 27 entries:
   Num:    Value  Size Type    Bind   Vis      Ndx Name
     0: 00000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 00008000     0 SECTION LOCAL  DEFAULT    1 .text
     ...
    13: 00008090    48 FUNC    GLOBAL DEFAULT    1 subtract
    ...
    18: 00000000     0 NOTYPE  GLOBAL DEFAULT  UND _start
   	...
    20: 00008000    96 FUNC    GLOBAL DEFAULT    1 main
    ...
    22: 000090c0     4 OBJECT  GLOBAL DEFAULT    3 global_sum
    ...

​ 链接器通过这种方式进行符号解析,在未找到引用的符号后会抛出一个错误信息并终止。如上打印信息中的_start符号在链接后仍然未定义,于是链接器就会报错。

​ 全局符号解析复杂还有一点在于全局符号可能存在重复的定义,这就要求链接器要么同样抛出错误,要么根据某些规则选择一个符号抛弃其他符号,下面介绍链接器是如何解析多个重复的全局符号的。

3.链接器解析多重定义的全局符号

​ 在编译时,编译器会将每个符号的强弱特性输出给汇编器,汇编器放入符号表中。编译器认为函数和已初始化的全局变量属于强符号,未初始化的全局变量属于弱符号。

注意:《深入理解计算机系统》一书中采用的是gcc的cc1作为编译器,默认行为是未初始化的全局变量属于弱符号,在其他编译器的情况下默认行为不一定是这样比如g++的cc1plus编译器认为已初始化和未初始化的全局变量都属于强符号;交叉编译器arm-none-eabi-gcc同样默认行为都属于强符号

​ 验证交叉编译器的默认行为

int global_sum = 0;
int global_diff;

int add(int a, int b) {
    return a + b;
}

int subtract(int a, int b) {
    return a - b;
}

​ 可以看到代码中global_diff未初始化,查看符号表,发现它仍属于强符号。

arm-none-eabi-gcc -c sum.c
greadelf -s sum.o 

Symbol table '.symtab' contains 13 entries:
   Num:    Value  Size Type    Bind   Vis      Ndx Name
     0: 00000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 00000000     0 FILE    LOCAL  DEFAULT  ABS sum.c
     2: 00000000     0 SECTION LOCAL  DEFAULT    1 .text
     3: 00000000     0 SECTION LOCAL  DEFAULT    3 .data
     4: 00000000     0 SECTION LOCAL  DEFAULT    4 .bss
     5: 00000000     0 NOTYPE  LOCAL  DEFAULT    4 $d
     6: 00000000     0 NOTYPE  LOCAL  DEFAULT    1 $a
     7: 00000000     0 SECTION LOCAL  DEFAULT    5 .comment
     8: 00000000     0 SECTION LOCAL  DEFAULT    6 .ARM.attributes
     9: 00000000     4 OBJECT  GLOBAL DEFAULT    4 global_sum
    10: 00000004     4 OBJECT  GLOBAL DEFAULT    4 global_diff
    11: 00000000    48 FUNC    GLOBAL DEFAULT    1 add
    12: 00000030    48 FUNC    GLOBAL DEFAULT    1 subtract

​ 知道了编译器会为每个符号赋予强弱属性,那么在链接器解析多重定义的全局符号时,根据强弱属性有以下规则进行解析:

  1. 不允许多个同名的强符号,链接器必然报错。
  2. 如果一个强符号和多个弱符号同名,那么链接器选择强符号作为符号定义,其他符号引用都是强符号。
  3. 如果多个同名的弱符号,那么链接器随意选择一个弱符号作为符号定义。

​ 对于符号的解析规则及方法大致有了了解,不过我们目前为止只是在探讨简单的用链接器去链接几个可重定位目标文件的情况;而真实的项目中不可能会只有几个可重定位目标文件,而是大量的可重定位目标文件所形成的静态库,通过链接静态库的方式实现对符号的解析,下面讲一下如何与静态库链接。

4.与静态库链接

​ 静态库是一种称为存档的特殊文件格式,它包含了一组可重定位目标文件的集合,有一个头部来描述每个成员的大小和位置。链接器与静态库的链接方式是需要哪个符号引用,就链接该符号所定义的目标文件

​ 下图展示链接器对静态库链接的方式,main文件中调用math库的add与c库中的printf,在链接静态库时,链接器只链接库中被使用到的可重定位目标文件,这样可以节省很多空间。

​ 了解了如何与静态库链接,接下来看看链接器是如何用静态库来解析引用的。

5.链接器使用静态库解析引用

​ 在符号解析阶段,链接器从左到右的顺序扫描出现在命令行中的目标文件以及库文件。在扫描中维护一个可重定位目标文件的集合E,一个未解析的符号(引用了但是尚未定义)集合U,以及一个前面已经定义的符号集合D,初始时集合E、U、D均为空。然后开始扫描:

  1. 对于命令行中的每个输入文件f,判断是目标文件还是库文件,如果是目标文件,把f添加到E中,根据文件f的符号表更新U与D。
  2. 如果f是库文件,链接器开始尝试对U中的未解析符号进行查找。如果库文件中的某个目标文件m中定义了一个符号解析U中的符号,那么m文件也被加入到E中,并修改U和D的集合,把m中定义以及未解析的符号添加到集合中。对库文件中的所有目标文件都做一遍这样的操作,知道U与D不在发生变化,然后任何不包含在E中的目标文件都被丢掉,继续处理下一个输入文件。
  3. 最后链接器处理完所有文件,如果U非空,表示还有未解析的符号,于是报错。否则讲E中所有目标文件合并,构建输出可执行文件。

​ 这种实现方式就要求程序员必须严格考虑输入文件的调用关系,否则会导致某个符号引用未解析而报错。

​ 静态库的链接与普通的可重定位目标文件类似,因为静态库就是一个可重定位目标文件的集合,下面看一下动态库的符号解析有什么区别。

6.与动态库链接

​ 动态库与静态库的区别在于它不需要静态链接时就将用到的目标文件复制到程序后面,极大的节省了空间资源。静态链接时如果链接器查找到一个UND的符号引用在动态库中,那么它不会修改这个符号的UND,而是将这个符号标记成动态符号,然后将这个符号写入到 .dynsym/.rela.dyn 等表中。等到运行加载时由动态链接器找到并加载UND符号定义所在的目标文件,把它加载到某个地址中,然后找到相关的符号定义,把最终地址写入表中。用下面一张图来展示如何与动态库进行链接:

7.总结

​ 符号解析就是找到未定义符号的定义位置。链接器默认采用从左到右扫描的方式进行符号解析,而且它有一个缺陷是定义符号的库或者文件要在引用这个符号的目标文件之前,就会导致引用的符号找不到定义的位置。

​ 在链接器解析全局变量时,如果有重复定义的全局变量,可以根据符号的强弱规则来选择最后保留哪一个。

​ 最后。与静态库和动态库链接的时期不同,如果编译选项不加--shared生成可执行文件,表示只需要静态链接即可,链接器静态的对所有可重定位目标文件以及静态库进行合并节区与符号解析,最后生成可执行文件就可以执行。但如果开启--shared编译选项,也就是需要与动态库进行链接,除了静态链接时期链接器会做一部分链接工作,然后把剩余未定义的且在动态库能找到的符号标记成动态符号,最终在运行时通过动态链接器进行加载动态库,动态符号解析等。