Skip to content

LV025-符号

其实多数情况下,我们的程序都不会只有一个源文件,大多有很多的源文件,他们都会生成目标文件( Linux 中 GCC 编译后为 .o 文件),但是最终只有一个可执行文件,这个可执行文件与那些目标文件是怎样联系起来的呢?为什么我们可以把不同的模块写在不同的源文件进行模块化开发呢?大多数情况下完全不用考虑这两个问题,它并不会影响我们写程序,但是我觉得吧了解一下总归是好的,这样我们也可以更好的理解链接到底做了什么。

一、符号的概念

1. 什么是符号

数据是保存在内存中的,对于计算机硬件来说,必须知道它的地址才能使用。变量名、函数名等仅仅是地址的一种助记符,目的是在编程时更加方便地使用数据,当源文件被编译成可执行文件后,这些标识符都不存在了,它们被替换成了数据的地址。

Tips:假设变量 a 、 b 、 c 的地址分别为 0x0000 、 0x0004 、 0x0008 ,加法运算的机器指令为 1000 ,赋值运算的机器指令为 1001 ,那么在 C 语言中实现加法运算,

c
c = a + b;

当生成可执行文件后的机器码就如下:

c
1000  0X0000  0X0004  /* 将两个数据相加的值保存在一个临时区域 */
1001  0X0008          /* 将临时区域中的数据复制到地址为 0X1008 的内存中 */

计算机刚刚诞生的时候没有编程语言,人们直接使用机器语言(二进制)编程。现在假设有一种跳转指令,它的二进制形式为 0001 ,如果需要执行地址为 1010 的代码,那么就可以这样写:

c
0001 1010

但是程序序并不是一写好就不再变化,它可能会经常被修改。例如我们在地址 1010 之前插入了其他指令,那么原来的代码就得往后移动,这样的话上面的跳转指令的跳转地址也得相应地调整。

在这个过程中,程我们需要人工重新计算每个子程序或者跳转的目标地址,这种重新计算各个目标地址的过程叫做 重定位( Relocation )。每次程序修改时,这些位置都要重新计算,十分繁琐又耗时,并且很容易出错。如果程序包含了多个源文件,就很可能会有跨文件的跳转,这种人工重定位的方式在程序拥有多个模块时会导致更加严重的问题。

为了解决这些问题,汇编语言( Assembly )诞生了,汇编语言使用接近人类的各种符号和标记来帮助记忆,比如用 jmp 表示跳转指令,用 func 表示一个子程序( C 语言中的函数就是一个子程序)的起始地址,这种符号的方法使得人们从具体的机器指令和二进制地址中解放出来。上边的机器码写成汇编的形式就是:

c
jmp func

这样,不管在 func 之前增加或者减少了多少条指令导致 func 的地址发生了变化,汇编器在每次汇编程序的时候会重新计算 func 这个符号的地址,然后把所有使用到 func 的地方修正为新的地址。

于是,符号( Symbol )这个概念随着汇编语言的普及被人们广泛接受,它用来 表示一个地址,这个地址可能是一段子程序(后来发展为函数)的起始地址,也可以是一个变量的地址。

2. 哪些是符号

在一个C语言程序中,符号就是其实程序中的变量名、函数名。如下程序中,哪些是符号?哪些又是符号的引用?

image-20260324154936356

Tips:局部变量temp分配在栈中,不会在函数外部被引用,因此不是符号定义。

二、模块化开发

我们自己写程序的时候也会将不同功能的代码放入单独的文件,形成模块,这些模块之间相互依赖又相互独立,原则上每个模块都可以单独开发、编译、测试,改变一个模块中的代码不需要编译整个程序。哈哈,不懂就问,这些模块是怎么被联系起来的呢?其实以前只管写,属实不知道其中原因。

在 C 语言中,模块(可以理解为源文件,就是我们写的 .c 文件)之间的依赖关系主要有两种:一种是 模块间的函数调用,另外一种是 模块间的变量访问函数调用需要知道函数的首地址变量访问需要知道变量的地址,所以这两种方式可以归结为一种,那就是模块间的 符号引用。模块间依靠符号来交流,这其实很类似于拼图版,定义符号的模块多出一个区域,引用符号的模块刚好少了那一块区域,两者刚好完美组合。如下图所示:

image-20260302193427162

通过符号将多个模块拼接为一个独立的程序 的过程就叫做 链接( Linking )。我们可以将符号看做是链接中的粘合剂,整个链接过程正是基于符号才能正确完成。

所有的符号都保存在符号表 .symtab 中,它一个结构体数组,每个数组元素都包含了一个符号的信息,包括符号名、符号在段中的偏移、符号大小(符号所占用的字节数)、符号类型等。在符号表中的符号包括:

  • 全局符号,也就是函数和全局变量,它们可以被其他目标文件引用。

  • 外部符号( External Symbol ),也就是在当前文件中使用到、却没有在当前文件中定义的全局符号。

  • 局部符号,也就是局部变量。它们只在函数内部可见,对链接过程没有作用,所以链接器往往也忽略它们。

  • 段名,这种符号往往由编译器产生,它的值就是该段的起始地址,比如 .text 、 .data 等。

确切地说,真正的符号名字是保存在字符串表 .strtab 中的,符号表仅仅保存了当前符号在字符串表中的偏移。

三、符号决议

当要进行链接时,链接器首先扫描所有的目标文件,获得各个段的长度、属性、位置等信息,并将目标文件中的所有(符号表中的)符号收集起来,统一放到一个全局符号表。同时,链接器会将目标文件中的各个段合并到可执行文件,并计算出合并后的各个段的长度、位置、虚拟地址等。

在目标文件的符号表中,保存了各个符号在段内的偏移,生成可执行文件后,原来各个段( Section )起始位置的虚拟地址就确定了下来,这样,使用起始地址加上偏移量就能够得到符号的地址(在进程中的虚拟地址)。这种计算符号地址的过程被称为 符号决议( Symbol Resolution )。

重定位表 .rel.text 和 .rel.data 中保存了需要重定位的全局符号以及重定位入口,完成了符号决议,链接器会根据重定位表调整代码中的地址,使它指向正确的内存位置。至此,可执行文件就生成了,链接器的任务也随之完成。

四、强弱符号

1. 强弱符号是什么?

1.1 一个例子

  • main.c
c
#include <stdio.h>

extern void fun1(void);
int a = 20;
int main(int argc, char *argv[])
{
    fun1();
    printf("a = %d\n", a);
    return 0;
}
  • fun1.c
c
#include <stdio.h>
int a = 10;
void fun1(void)
{
    printf("fun1 a=%d\n", a);
}
  • 编译

在终端执行以下命令:

shell
gcc *.c -Wall    # 编译程序

会看到终端有如下信息输出:

shell
/tmp/ccjAxzo8.o:(.data+0x0):  a'被多次定义
/tmp/ccxtucvP.o:(.data+0x0):第一次在此定义
collect2: error: ld returned 1 exit status

这是一种 符号重复定义(Multiple Definition) 的错误,是因为在多个源文件中定义了名字相同的全局变量,并且都将它们初始化了,这种符号的定义可以被称为 强符号

1.2 定义

在 C 语言中,编译器默认函数和初始化了的全局变量为 强符号(Strong Symbol),未初始化的全局变量为 弱符号(Weak Symbol)。强符号强在它拥有确切的数据,变量有值,函数有函数体;弱符号弱在它还未被初始化,没有确切的数据。

Tips:在开发者没有显示指定时,编译器对强弱符号的定义会有一些默认行为,同时开发者也可以对符号进行指定,使用 "__attribute__((weak))" 来声明一个符号为弱符号。

2. 怎么处理?

2.1 基本规则

链接器会按照如下的 规则 处理被多次定义的强符号和弱符号:

  • 不允许强符号被多次定义,即不同的目标文件中不能有同名的强符号;如果有多个强符号,那么链接器会报符号重复定义错误。

  • 如果一个符号在某个目标文件中是强符号,在其他文件中是弱符号,那么选择强符号。

  • 如果一个符号在所有的目标文件中都是弱符号,那么选择其中占用空间最大的一个。这个其实很好理解,编译器不知道编程者的用意,选择占用空间大的符号至少不会造成诸如溢出、越界等严重后果。

例如目标文件 a.o 定义全局变量 a 为 int 类型,占用 4 个字节,目标文件 b.o 定义 a 为 double 类型,占用 8 个字节,那么被链接后,符号 a 占用 8 个字节,但是自己进行测试的时候,似乎编译器是选择弱符号自己所在文件中的类型,而且 同一个源文件中不允许定义名称相同但是数据类型不同的全局变量

Tips:在 GCC 中,可以通过 __attribute__((weak)) 来强制定义任何一个符号为弱符号。但是 __attribute__((weak)) 只对链接器有效,对编译器不起作用,编译器不区分强符号和弱符号,只要在一个源文件中定义两个相同的符号,不管它们是强是弱,都会报重复定义错误。

2.2 一个实例

  • main.c
c
#include <stdio.h>

extern void fun1(void);
double a;
int main(int argc, char *argv[])
{
    fun1();
    a = 0.3;
    printf("a = %f, sizeof(a)=%ld\n", a, sizeof(a));
    return 0;
}
  • fun1.c
c
#include <stdio.h>
int a;
void fun1(void)
{
    a = 20;
    printf("fun1 a=%d, sizeof(a)=%ld\n", a, sizeof(a));

}
  • 编译

在终端执行以下命令:

shell
gcc *.c -Wall    # 编译程序 
./a.out          # 执行可执行程序

会看到终端有如下信息输出:

shell
fun1 a=20, sizeof(a)=4
a = 0.300000, sizeof(a)=8

3. 有什么用?

我们在开发库时可以 使用强符号覆盖弱符号,即可以将某些符号定义为弱符号,这样就能够被用户定义的强符号覆盖,从而使得程序可以使用自定义版本的函数,增加了很大的灵活性。

  • main.c
c
#include <stdio.h>

extern void fun1(void);
__attribute__((weak)) int a = 20;
int main(int argc, char *argv[])
{
    fun1();
    printf("a = %d\n", a);
    return 0;
}
  • fun1.c
c
#include <stdio.h>
int a = 10;
void fun1(void)
{
    printf("fun1 a=%d\n", a);
}
  • 编译运行

在终端执行以下命令:

shell
gcc *.c -Wall    # 编译程序 
./a.out          # 执行可执行程序

会看到终端有如下信息输出:

shell
fun1 a=10
a = 10

可以看到,强符号覆盖了弱符号,最后输出的 a 的值都是 10 。

4. 一个坑

按理来说,强弱符号在链接过程中,强符号一定会取代弱符号,但是,事实并非如此我们创建几个文件用来测试:LV01_GCC_COMPILE/04_symbol

我们这里写一个 makefile 来做不同的编译流程,来看一下坑在哪里:

makefile
all: demo1 demo2 demo3 demo4
# 第(1)种情况:直接全部编译,main.c strong_symbol.c weak_symbol.c
demo1:
	gcc -g -o demo1.out main.c strong_symbol.c weak_symbol.c -Wall
	rm -rf *.o

# 第(2)种情况:直接全部编译,main.c strong_symbol.c weak_symbol.c
demo2:
	gcc -g -o demo2.out main.c weak_symbol.c strong_symbol.c -Wall
	rm -rf *.o

# 第(3)种情况:strong_symbol.c weak_symbol.c编译成.a静态库,打包顺序为 weak_symbol.o strong_symbol.o,然后再和main.c进行链接
demo3:
	gcc -c weak_symbol.c strong_symbol.c
	ar -r libsymbol_demo3.a weak_symbol.o strong_symbol.o
	gcc -o demo3.out main.c -lsymbol_demo3 -L.
	rm -rf *.o

# 第(4)种情况:strong_symbol.c weak_symbol.c 编译成.a静态库,打包顺序为 strong_symbol.o weak_symbol.o,然后再和main.c进行链接
demo4:
	gcc -c weak_symbol.c strong_symbol.c
	ar -r libsymbol_demo4.a strong_symbol.o weak_symbol.o
	gcc -o demo4.out main.c -lsymbol_demo4 -L.
	rm -rf *.o

clean:
	@rm -rf *.a *.so *.out

.PHONY: clean

我们直接看结论:

image-20260302195804856

我们直接编译所有文件没问题,但是当我们将两个文件打包成.a 库的时候,坑产生了,这里打包的时候,弱符号的 .o 在前,强符号的.o 在后 的时候,强符号竟然没有替换弱符号,反过来就成功替换了。主要是这个坑,在写库的时候,若是有强弱符号的替换,一定要注意!!!

五、强弱引用

1. 基本概念

除了强符号和弱符号的区别之外,GNUC 还有一个特性就是强引用和弱引用,我们知道的是,编译器在编译阶段只负责将源文件编译成目标文件(即二进制文件),然后由链接器对所有二进制文件进行链接操作。

在分离式编译中,当编译器检查到当前使用的函数或者变量在本模块中仅有声明而没有定义时,编译器直接使用这个符号,将工作转交给链接器,链接器则负责根据这些信息找到这些函数或者变量的实体地址,因为在程序执行时,程序必须确切地知道每个函数和全局变量的地址。如果没有找到该符号的实体,就会报 undefined reference 错误,这种符号之间的引用被称为 强引用(Strong Reference)。编译器默认所有的变量和函数为强引用。

如果没有定义,也不报错,这种引用就叫 弱引用(Weak Reference),在变量声明或者函数声明前边加上 __attribute__((weak)) 就会使符号变为弱引用。注意这里是声明而不是定义,既然是引用,那么就是使用其他模块中定义的实体,对于函数而言,我们可以使用这样的写法:

c
__attribute__((weak)) void func(void);

链接器处理强引用和弱引用的过程几乎是一样的,只是对于未定义的弱引用,链接器不认为它是一个错误,一般默认其为 0 (地址为 0 ),或者是一个特殊的值,以便程序代码能够识别。

2. 使用实例

  • main.c
c
#include <stdio.h>

extern void fun1(void);
__attribute__((weak)) extern int a;
int main(int argc, char *argv[])
{
    fun1();
    printf("&a = %p\n", &a);
    printf("a = %d\n",a);
    return 0;
}
  • fun1.c
c
#include <stdio.h>

void fun1(void)
{
    printf("fun1 a=%d\n", a);
}
  • 编译

在终端执行以下命令:

shell
gcc *.c -Wall    # 编译程序 
./a.out          # 运行可执行程序

会看到有如下信息输出:

shell
fun1
&a = (nil)
段错误 (核心已转储)

在程序,变量 a 是没有进行定义的,直接使用了 extern 来进行声明,当有 __attribute__((weak)) 时表示 a 为弱符号,这个时候再引用 a 的时候就是弱引用了,所以即便没有定义,编译也不会报错,但是程序执行一部分后,需要使用 a 的值得时候,就开始报错了。这是因为 a 的地址是 0 ,这个地址是禁止被访问的。

若是不想让它报错,可以加一个判断即可,主函数可修改如下:

c
#include <stdio.h>

extern void fun1(void);
__attribute__((weak)) extern int a;
int main(int argc, char *argv[])
{
    fun1();
    printf("&a = %p\n", &a);
    if(&a)
    {
        printf("a = %d\n", a);
    }
    else
    {
        printf("a is undefined!\n");
    }
    return 0;
}

3. 小结

弱引用和强引用非常利于程序的模块化开发,我们可以将程序的扩展模块定义为弱引用,当我们将扩展模块和程序链接在一起时,程序就可以正常使用;如果我们去掉了某些模块,那么程序也可以正常链接,只是缺少了某些功能,这使得程序的功能更加容易裁剪和组合。

参考资料:

C 语言强、弱符号,强、弱引用 - 牧野星辰 - 博客园

计算机系统基础 链接器之符号和符号表_链接符号表-CSDN博客