您现在的位置是:首页 >技术交流 >C++:Article:链接器(二):符号决议网站首页技术交流

C++:Article:链接器(二):符号决议

John_xx 2023-06-08 16:00:03
简介C++:Article:链接器(二):符号决议


在上篇文章中,我们介绍了 链接器基本概念,我们知道所有的应用程序否是连接器将所需要的一个个简单的目标文件汇集起来形成的。
比如:我们在 list 中实现了一种特定的链表数据结构,其他模块需要使用这种链表,这就是模块直接的依赖。

  1. 确保目标文件能找到符号定义 (Symbol Resolution) 符号决议
    🚀 链接器的其中一项重要任务就是 确保提供链接器进行链接的目标文件集合之间依赖是成立的(也就是说不会出现在被依赖的模块中链接器找不到需要依赖的接口)。

  2. 可执行程序的生成
    🚀 链接器会首先将程序每个模块当中目标文件集合链接成库,然后在将各个库进行链接最终形成可执行程序。

  3. 重定位
    🚀 在完成 符号决议和生成可执行文件之后,链接器需要对可执行文件进行重定位。

下面我们围绕上面三个部分来详细的讲解下每一个过程。

1. C++源文件都有些什么

一个典型的C++源文件中,该文件中的变量可以划分为两类。

  • 全局变量:只要程序没结束运行,全局变量都可以随时使用。(注意:静态的全局变量生命周期也等于程序的运行周期,只是这种全局变量只能在所 被定义的文件当中使用,对其它文件不可见)。
  • 局部变量:局部变量的生命周期和全局变量的生命周期不同,局部变量只能在相应的函数内部使用,当函数调用完毕后,局部变量也不能使用 。
// 1. 定义未初始化的全局变量
int x_global_uninit;
// 2. 定义初始化的全局变量
int x_global_init = 1;
// 3. 定义未初始化的全局私有变量,该变量只能在当前文件中使用
static int y_global_unint;
// 4. 定义未初始化的全局私有变量,该变量只能在当前文件中使用
static int y_global_init = 2;
// 5. 声明全局变量,但是该变量在其他文件中定义
extern int z_global;
// 6. 声明函数,该函数在其他文件中定义
int fn_a(int x, int y);
// 7. 函数定义,因为使用static 修饰,该函数只能在当前文件中使用
static int fn_b(int x)
{
	return x+1;
}
// 8. 函数定义,该函数可以被其他文件使用
int fn_c(int x_local)
{
	// 9. 未初始化的局部变量
	int y_local_unint;
	// 10. 已初始化的局部变量
	int y_local_init = 3;
	// 11. 全局变量,局部变量以及函数的使用
	x_global_uninit = fn_a(x_local,x_global_init);
	y_local_uninit = fn_a(x_local,y_local_init);
	y_local_uninit +=  fn_b(z_global);
	return (y_global_uninit+ y_local_uninit);
}

1.1 . 目标文件里有什么

编译器的任务就是把人类可以理解的代码 转换成机器可以执行的机器指令,源文件编译后形成对应的目标文件本质上可以分成两部分

  1. 代码部分:计算机可以执行的机器指令,也就是源文件中定义的所有函数,比如上图中:fn_a() fn_b() 等
  2. 数据部分:源文件中定义的全局变量,如果是已经初始化后的全局变量,该全局变量的值也存在于数据部分。

2. 符号表 Symbol table

编译器在编译过程中遇到外部定义的全局变量或函数时,只要能在当前文件中找到其声明即可,编译器就会认为是正确的。
寻找变量的定义就被留给了 链接器,链接器的一项重要任务就是要确定所使用的变量要有其唯一的定义。虽然这项工作留给了链接器,但是为了让链接器工作的轻松一点,编译器还是多做了一点工作,这部分工作就是 符号表(Symbol table)。 那么符号表保存的是什么了 ?

🚀🚀🚀

  1. 该目标文件中引用的全局变量以及函数
  2. 该目标文件中定义的全局变量以及函数
    如 标题2所知,编译器在编译过程中,每次遇到一个全局变量或者函数名都会在符号表中添加一项,最终编译器会统计出如下所示的一张读好表

在这里插入图片描述

2.1. 符号表的位置

符号表被编译器很贴心的放在目标文件中,因此一个目标 文件可以理解为下图所示的三段
在这里插入图片描述

2.2. 符号的决议

在上一节符号表中,我们知道符号表给链接器提供了两种信息。

  1. 一个是当前目标文件可以提供给其他目标文件使用的符号。
  2. 另一个是其他目标文件需要提供给当前目标文件使用的符号。

2.3. 符号决议过程

如下图所示,假设链接器需要链接下面三个文件。链接器会一次扫描每一个给定的目标文件,同时链接器还维护了两个集合,一个是已定义符号集合D,另一个是未定义的集合U,下面是链接器进程符号决议的过程:
在这里插入图片描述

  1. 对于当前目标文件,查找其符号表,并将已定义的符号并添加到已定义符号集合D中。
  2. 对于当前目标文件,查找其符号表,将每一个当前目标文件引用的符号与已定义符号集合D进行对比,如果该符号不在集合D中则将其添加到未定义符号集合U中。
  3. 当所有文件都扫描完成,如果未定义符号集合U不为空,则说明当前输入的目标文件中有未定义符号错误,链接器就会报错,整个编译过程就会终止。

3. 实例说明

// 伪代码
// math.h
#include<iostream>
int add(int a, int b);

// math.cpp
#include<iostream>
int add(int a, int b)
{
	return a+b;
}
// main.cpp
#include<iostream>
#include "math.h"
int main()
{
	int sum = add(1,2);
	std::cout << sum << std::endl;
	return 0;
}

链接过程如下:
编译器在 链接 main.o 和 math.o 文件时。

  1. matth.o 目标文件有add() 函数符号,首先会在当前文件查找定义,结果当前文件就存在add() 函数定义,所以直接将符号add 添加到已定义集合D中
  2. main.o 目标文件也有add() 函数符号,首先会在当前文件中查找定义,结果当前文件不存在add() 函数定义,然后在集合D中查找是否有定义,结果找到了符号的定义
  3. 当完成 main.o 和 math.o 两个目标文件链接后,编译终止,生成可执行文件。

在这里插入图片描述

3.1. 意外出现

假设你不小心将 math.cpp 中的add函数注释了,但是main.cpp 仍然引用了add() 函数符号,当你在编译的时候,就会报很经典的
undefined reference to add(int, int) 错误。
在这里插入图片描述

现在我们来分析下产生这个错误的原因。

  • 编译器发现你写的代码 main.o 中引用了外部定义定义的函数(通过检查目标文件 main.o 中的符号表得到的信息),所以链接器开始寻址这个add()符号到底是在哪里定义的。
  • 链接器先去目标文件 main.o 的符号表中查找,没有找到 add() 符号的定义。
  • 转而链接器去其他目标文件符号表中查找,通用没有找到add() 函数符号的定义
  • 链接器在查找了所有目标文件的符号表后都没找到add() 函数符号,因此链接器停止工作并报出 undefined reference to add(int, int)

3.2 总结排查

所以根据前面几节的介绍讲解,你已经很清楚的知道链接器符号决议整个过程,当出现 未定义符号错误时,你可以进行如下排查

  1. main.cpp 中对add函数的函数名是否书写正确
  2. 链接命令中是否包含了 math.o ,如果没有,那么需要添加上该目标文件
  3. 如果链接命令没有问题,查看 math.cpp 中关于 add函数的定义是否存在问题
  4. 如果是C和C++的混合编程,确保相应的位置添加 extern C
    一般情况下,经过这几个步骤的排查,基本能解决上述问题
风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。