1、编译流程
C语言经典的“Hello World”小程序几乎是每个程序员闭着眼睛都能写出来的,基本成了入门教程和开发环境的默认标准,代码如下:
#include <stdio.h> int main() { printf("Hello World\n"); return 0; }
如果在 Windows 下使用 Visual Studio 来编译,那么可以直接点击运行(Run)按钮或者构建(Build)按钮,在工程目录下就会看到生成的 .exe 程序。
如果在 Linux 下使用 GCC 来编译,使用最简单的$gcc demo.c命令,就可以在当前目录下看到 a.out。
事实上,从源代码生成可执行文件可以分为四个步骤,分别是预处理(Preprocessing)、编译(Compilation)、汇编(Assembly)和链接(Linking)。下图是 GCC 生成 a.out 的过程:
1.1、预处理(Preprocessing)
预处理过程主要是处理那些源文件和头文件中以#开头的命令,比如 #include、#define、#ifdef 等。预处理的规则一般如下:
- 将所有的
#define删除,并展开所有的宏定义。 - 处理所有条件编译命令,比如 #if、#ifdef、#elif、#else、#endif 等。
- 处理
#include命令,将被包含文件的内容插入到该命令所在的位置,这与复制粘贴的效果一样。注意,这个过程是递归进行的,也就是说被包含的文件可能还会包含其他的文件。 - 删除所有的注释
//和/* ... */。 - 添加行号和文件名标识,便于在调试和出错时给出具体的代码位置。
- 保留所有的
#pragma命令,因为编译器需要使用它们。
预处理的结果是生成.i文件。.i文件也是包含C语言代码的源文件,只不过所有的宏已经被展开,所有包含的文件已经被插入到当前文件中。当你无法判断宏定义是否正确,或者文件包含是否有效时,可以查看.i文件来确定问题。
在 GCC 中,可以通过下面的命令生成.i文件:
$gcc -E demo.c -o demo.i
-E表示只进行预编译。
在 Visual Studio 中,在当前工程的属性面板中将“预处理到文件”设置为“是”,如下图所示:
然后点击“运行(Run)”或者“构建(Build)”按钮,就能在当前工程目录中看到 demo.i 。
1.2、编译(Compilation)
编译就是把预处理完的文件进行一些列的词法分析、语法分析、语义分析以及优化后生成相应的汇编代码文件。编译是整个程序构建的核心部分,也是最复杂的部分之一,涉及到的算法较多,我们并不打算深入讨论,有兴趣的读者请查看《编译原理》。
在 GCC 中,可以使用下面的命令生成.s文件:
$gcc -S demo.i -o demo.s
或者
$gcc -S demo.c -o demo.s
在 Visual Studio 中,不用进行任何设置就可以在工程目录下看到 demo.asm 文件。
1.3、汇编(Assembly)
汇编的过程就是将汇编代码转换成可以执行的机器指令。大部分汇编语句对应一条机器指令,有的汇编语句对应多条机器指令,我们在《C语言内存精讲》中的《一个程序在计算机中到底是如何运行的》一节对汇编语言进行了简单的解释。
汇编过程相对于编译来说比较简单,没有复杂的语法,也没有语义,也不需要做指令优化,只是根据汇编语句和机器指令的对照表一一翻译就可以了。
汇编的结果是产生目标文件,在 GCC 下的后缀为.o,在 Visual Studio 下的后缀为.obj。
1.4、链接(Linking)
目标文件已经是二进制文件,与可执行文件的组织形式类似,只是有些函数和全局变量的地址还未找到,程序不能执行。链接的作用就是找到这些目标地址,将所有的目标文件组织成一个可以执行的二进制文件。
预处理和汇编的过程都比较简单,有了上面的介绍,相信大家很容易理解。
编译的过程最为复杂,可以细分为词法分析、语法分析、语义分析和指令优化,这里涉及到诸多算法以及正则表达式,我们并不打算深入分析,也没必要,有兴趣的读者请自行查阅《编译原理》。
而目标文件的结构、可执行文件的结构、链接的过程是我们要重点研究的,它能够让我们明白多文件编程以及模块化开发的原理,这是大型项目开发的基石。
最后需要说明的是:汇编的过程非常简单,仅仅是查表翻译,我们通常把它作为编译过程的一部分,不再单独提及。这样,源文件经过预处理、编译和链接就生成了可执行文件。
2、目标文件和可执行文件
编译器编译源代码后生成的文件叫做目标文件(Object File),例如 Visual Studio 下的.obj,或者 GCC 下的.o。
从文件结构上来讲,目标文件已经是二进制文件,它与可执行文件的组织形式非常类似,只是有些变量和函数的地址还未确定,程序不能执行。链接的一个重要作用就是找到这些变量和函数的地址。
另外需要明确的是:编译是针对单个源文件的,有几个源文件就会生成几个目标文件,并且在生成过程中不受其他源文件的影响。也就是说,不管当前工程中有多少个源文件,编译器每次只编译一个源文件、生成一个目标文件。
2.1、Windows和Linux下的文件格式
现在PC平台上流行的可执行文件格式主要是 Windows 下的 PE(Portable Executable)和 Linux 下的 ELF(Executable Linkable Format),它们都是 COFF(Common File Format)格式的变种。
COFF 是 Unix V3首先提出的规范,微软在此基础上制定了 PE 格式标准,并将它用于 Windows。后来 Unix V4 又在 COFF 的基础上引入了 ELF 格式,被 Linux 广泛使用。这也就是为什么 Windows 和 Linux 上的可执行文件如此相似的主要原因,因为它们都是源于同一种可执行文件格式 COFF。
从广义上讲,目标文件与可执行文件的存储格式几乎是一样的,我们可以将它们看成是同一种类型的文件,在 Windows 下,将它们统称为 PE 文件,在 Linux 下,将它们统称为 ELF文件。
另外,动态链接库(DLL,Dynamic Linking Library)(Windows 下的.dll和 Linux 下的.so)和静态链接库(Static Linking Library)(Windows 下的.lib和 Linux 下的.a)也是按照可执行文件的格式存储的。
静态链接库稍有不同,它是把多个目标文件捆绑在一起形成一个文件,再加上一些索引,你可以简单地把它理解为一个包含了很多目标文件的包。
其他不太常见的可执行文件格式还有 Intel/Microsoft 的 OMF(Object Module Format)、Unix a.out、MS-DOS .COM 等。
在 Linux 的 ELF 标准中,主要包含以下四类文件:
| 文件类型 | 说明 | 实例 |
|---|---|---|
| 可重定位文件 (Relocatable File) |
这类文件包含了代码和数据,可以被用来链接成为可执行文件或动态链接库。静态链接库其实也是可重定位文件。 | Linux 下的 .o 和 .a,Windows 下的 .obj 和 .lib。 |
| 可执行文件 (Executable File) |
这类文件包含了可以直接执行的程序。 | Windows 下的 .exe,Linux 下的可执行文件没有固定的后缀,一般不写。 |
| 共享目标文件 (Shared Object File) |
这种文件包含了代码和数据,可以在以下两种情况下使用:一种是链接器可以使用这种文件跟其他的可重定位文件和共享目标文件链接,产生新的目标文件;第二种是动态连接器可以将几个共享目标文件与可执行文件结合,作为进程的一部分来运行。 | Linux 下的 .so,Windows 下的 .dll。 |
| 核心转储文件 (Core Dump File) |
当进程意外终止时,系统可以将该进程的地址空间的内容以及其他信息保存到核心转储文件。 | Linux 下的 core dump。 |
2.2、目标文件的组织形式
从整体上看,编译生成的目标文件被划分成了多个部分,每个部分叫做一个段(Section)。下图是 Linux GCC 生成的目标文件的格式:
段名大都以.作为前缀,表示这些名字是系统保留的。下面是对各个部分的说明:
| 段 名 | 说 明 |
|---|---|
| ELF Header | 文件头,描述了整个目标文件的属性,包括是否可执行、是动态链接还是静态链接、入口地址是什么、目标硬件、目标操作系统、段表偏移等信息。 |
| .text | 代码段,存放编译后的机器指令,也即各个函数的二进制代码。一个C语言程序由多个函数构成,C语言程序的执行就是函数之间的相互调用。 |
| .data | 数据段,存放全局变量和静态变量。 |
| .rodata | 只读数据段,存放一般的常量、字符串常量等。 |
| .rel.text. rel.data |
重定位段,包含了目标文件中需要重定位的全局符号以及重定位入口。 |
| .comment | 注释信息段,存放的是编译器的版本信息,比如“GCC:(GUN) 4.2.0”。 |
| .debug | 调试信息。 |
| .line | 调试时的行号表,即源代码行号与编译后指令的对应表。 |
| Section Table | 段表,描述了 ELF 文件包含的所有段的信息,比如段的名字、段的长度、在文件中的偏移、读写权限以及其他属性。可以说,ELF 文件的段结构是由段表来决定的,编译器、链接器和装载器都是依靠段表来定位和访问各个段的。 |
| .strtab | 字符串表,保存了 ELF 文件用到的字符串,比如变量名、函数名、段名等。因为字符串的长度往往是不定的,所以用固定的结构来表示它比较困难,常见的做法就是把字符串集中起来存放到一个表中,然后使用字符串在表中的偏移来引用字符串。 |
| .symtab | 符号表,保存了全局变量名、局部变量名、函数名等在字符串表中的偏移。 |
除了这些系统保留的段名,应用程序也可以使用其它名字定义自己的段,比如可以在 ELF 文件中插入一个叫做music的段来保存 MP3 音乐。应用程序自定义的的段不建议使用.作为前缀,否则容易和系统保留段发生冲突。
2.3、可执行文件的组织形式
可执行文件的组织形式和目标文件非常类似,也被划分成多个部分,如下图所示:
图中左半部分是可执行文件的结构:带阴影的是可执行文件增加的一些段,另外可执行文件删除了可重定位段(.rel.text和.rel.data)以及段表(Section Table)。
总体来说,目标文件包含了10个左右的段,而可执行文件包含了将近30个左右的段,上面的两张图只列出了一些关键段,剩下的段都隐藏在“Other Data(其他数据)”。
图中右半部分是进程的地址空间,这在《Linux下C语言程序的内存布局(内存模型)》一节已经进行了详细讲解。
不同颜色的箭头表明了可执行文件应该被加载到地址空间的哪一个区域,可以发现,操作系统并不是为每个段都分配一个区域,而是将多个具有相同权限的段合并在一起,加载到同一个区域。
站在文件结构的角度,可执行文件包含了众多的段(Section),每个段都有不同的作用;站在加载和执行的角度,所有的段都是数据,操作系统只关心数据的权限,只要把相同权限的数据加载到同一个内存区域,程序就能正确执行。
常见的数据权限无外乎三种:只读(例如 .rodata 只读数据段)、读写(例如 .data 数据段)、读取和执行(例如 .text 代码段),我们将一块连续的、具有相同权限的数据称为一个 Segment,一个 Segment 由多个权限相同的 Section 构成。
不巧的是,“Segment”也被翻译为“段”,但这里的段(Segment)是针对加载和执行的过程。
在 Linux 下,相信很多读者都遇到过一种叫做Segment fault(段错误)的错误,这种错误发生在程序执行期间,在编译和链接时无法检测,一般都是代码的权限不足导致的。例如:
#include <stdio.h>
char *str = "c.biancheng.net";
int main()
{
str[1] = \'@\';
return 0;
}
程序执行到 6 行时就会出现“Segment fault(段错误)”,这是因为字符串 str 保存在地址空间的常量区,只能读取,不能写入,而修改字符串显然是越权操作。
在目标文件中,段表(Section Table)用来描述各个 Section 的信息,包括它的名字、长度、在文件中的偏移、读写权限等,通过段表可以详细地了解目标文件的结构。
而在可执行文件中,段表被删除了,取代它的是程序头表(Program Header Table);程序头表用来描述各个 Segment 的信息,包括它的类型、偏移、在进程虚拟地址空间中的起始地址、物理装载地址、长度、权限等。操作系统就是根据程序头表将可执行文件加载到内存,并为各个 Segment 分配内存空间、确定起止地址。
也就是说,可执行文件不再关注具体的文件结构,而是关注程序的加载和执行过程。
由于可执行文件在加载时实际上是被映射的虚拟地址空间,所以可执行文件很多时候又被叫做映像文件(Image)。
2.4、段(Section)的合并
编译器生成的是目标文件,而我们最终需要的是可执行文件,链接(Linking)的作用就是将多个目标文件合并成一个可执行文件。
在链接过程中,链接器会将多个目标文件中的代码段、数据段、调试信息等合并成可执行文件中的一个段。段的合并仅仅是一个简单的叠加过程,如下图所示:
除了合并有用的段(例如代码段、数据段等),链接器还会删除多余的段(例如重定位段、段表等),增加其他段(例如程序头表等)。
3、链接
3.1、链接器的由来
几十年以前,计算机刚刚诞生,人们编写程序时,将所有的代码都写在同一个源文件中,经过长期的积累,程序包含了数百万行的代码,以至于人们无法维护这个程序了。于是人们开始寻找新的方法,迫切地希望将程序源代码分散到多个文件中,一个文件一个模块,以便更好地阅读和维护,这个时候,链接器就粉墨登场了。
3.2、一切都是地址
我们知道,数据是保存在内存中的,对于计算机硬件来说,必须知道它的地址才能使用。变量名、函数名等仅仅是地址的一种助记符,目的是在编程时更加方便地使用数据,当源文件被编译成可执行文件后,这些标识符都不存在了,它们被替换成了数据的地址。
假设变量 a、b、c 的地址分别为 0X1000、0X1004、0X1008,加法运算的机器指令为 1010,赋值运算的机器指令为 1110,那么在C语言中实现加法运算的代码为:
c = a + b;
生成可执行文件后的机器码为:
1010 0X1000 0X1004 //将两个数据相加的值保存在一个临时区域 1110 0X1008 //将临时区域中的数据复制到地址为0X1008的内存中
编译器和链接器的一项重要任务就是将助记符替换成地址。
3.3、汇编语言的诞生
任何程序的执行,最终都要依靠计算机硬件来完成。现代计算机硬件都是大规模集成电路,它只认识高低两个电平(电压),高电平一般为 5V,用1表示,低电平一般为 0V,用0表示。也就是说,在计算机底层,没有文字、数字、图像、视频等丰富多彩的可视化元素,只有 0 和 1 两个二进制数字,这就是机器语言。
计算机刚刚诞生的时候没有编程语言,人们直接使用机器语言(二进制)编程。现在假设有一种跳转指令,它的二进制形式为 0001,如果需要执行地址为 1010 的代码,那么可以这样写:
0001 1010
所谓跳转,就是在执行当前代码块时转而执行其他的代码块。从本质上讲,C语言中的函数就是一个代码块,当发生函数调用时,就会执行其他的代码块,这个过程就是通过跳转指令来完成的。
那么现在问题来了,程序并不是一写好就永远不变化的,它可能会经常被修改。比如我们在地址 1010 之前插入了其他指令,那么原来的代码就得往后移动,上面的跳转指令的跳转地址也得相应地调整。
在这个过程中,程序员需要人工重新计算每个子程序或者跳转的目标地址,这种重新计算各个目标地址的过程叫做重定位(Relocation)。每次程序修改时,这些位置都要重新计算,十分繁琐又耗时,并且很容易出错。
如果程序包含了多个源文件,就很可能会有跨文件的跳转,这种人工重定位的方式在程序拥有多个模块时会导致更加严重的问题。
没办法,这种黑暗的程序员生活是没办法容忍的,于是先驱们发明了汇编语言(Assembly),这相比机器语言来说是个很大的进步。
汇编语言使用接近人类的各种符号和标记来帮助记忆,比如用jmp表示跳转指令,用func表示一个子程序(C语言中的函数就是一个子程序)的起始地址,这种符号的方法使得人们从具体的机器指令和二进制地址中解放出来。
将上面的机器指令使用汇编代码来书写:
jmp func
这样,不管在 func 之前增加或者减少了多少条指令导致 func 的地址发生了变化,汇编器在每次汇编程序的时候会重新计算 func 这个符号的地址,然后把所有使用到 func 的地方修正为新的地址,整个过程不需要人工参与。对于一个有成千上百个类似的符号的程序,人们终于摆脱了这种低级的繁琐的计算地址的工作,用一句政治口号来说就是“极大地解放了生产力”。
符号(Symbol)这个概念随着汇编语言的普及被广泛接受,它用来表示一个地址,这个地址可能是一段子程序(后来发展为函数)的起始地址,也可以是一个变量的地址。
3.4、C语言的诞生
汇编语言的主要作用是为机器指令提供了助记符,大部分汇编代码和机器指令是一一对应的,这在汇编被发明的初期确实令程序员非常欣喜。
后来随着软件规模的日渐庞大,代码量开始疯长,汇编语言的缺点逐渐暴露出来。汇编虽然提供了多种符号,但它依然非常接近计算机硬件,程序员要考虑很多细节问题和边界问题,并且不利于模块化开发,所以后来人们发明了C语言。
C语言是比汇编更加高级的编程语言,极大地提高了开发效率,以加法为例,C语言只需要一条语句,汇编却需要四五条。
3.5、模块化开发
现代软件的规模往往都很大,动辄数百万行代码,程序员需要把它们分散到成百上千个模块中。这些模块之间相互依赖又相互独立,原则上每个模块都可以单独开发、编译、测试,改变一个模块中的代码不需要编译整个程序。
在C语言中,一个模块可以认为是一个源文件(.c 文件)。
在程序被分隔成多个模块后,需要解决的一个重要问题是如何将这些模块组合成一个单一的可执行程序。在C语言中,模块之间的依赖关系主要有两种:一种是模块间的函数调用,另外一种是模块间的变量访问。
函数调用需要知道函数的首地址,变量访问需要知道变量的地址,所以这两种方式可以归结为一种,那就是模块间的符号引用。
模块间依靠符号来“通信”类似于拼图版,定义符号的模块多出一个区域,引用符号的模块刚好少了那一块区域,两者刚好完美组合。如下图所示:
这种通过符号将多个模块拼接为一个独立的程序的过程就叫做链接(Linking)。
4、符号
链接(Linking)就是通过符号将各个模块组合成一个独立的程序的过程。
链接的主要内容就是把各个模块之间的相互引用部分处理好,使得各个模块能够正确地衔接。链接器所做的主要工作跟前面提到的“人工调整地址”本质上没有什么两样,只不过现代的高级语言拥有诸多的特性,使得编译器和链接器更为复杂,功能更为强大,但从原理上来讲,无非是找到符号的地址,或者把指令中使用到的地址加以修正。这个过程称为符号决议(Symbol Resolution)或者重定位(Relocation)。
对于简单的C语言程序,链接过程如下图所示。每个模块的源文件(.c 和 .h)先被编译成目标文件,再和系统库一起链接成可执行文件。库(Library)其实是一组目标文件的包,是将一些最常用的代码编译成目标文件后打包存放。
系统库这个概念比较模糊,专业一点应该叫做运行时库(Runtime Library)。“运行时”就是程序运行期间,“运行时库”包含了程序运行期间所需要的基本函数,是程序运行不可或缺的,例如输入输出函数 printf()、scanf(),内存管理函数 malloc()、free() 等。
链接过程并没有想象中的复杂,它还是一个比较容易理解的概念。
假设一个程序有两个模块 main.c 和 module.c,我们在 module.c 中定义了函数 func(),并在 main.c 中进行了多次调用,当所有模块被编译成一个可执行文件后,每一处对 func() 函数的调用都会被替换为一个绝对地址。但由于每个模块都是单独编译的,编译器在处理 main.c 时并不知道 func() 的地址,所以需要把这些调用 func() 的指令的目标地址搁置,等到最后链接的时候再由链接器将这些地址修正。
如果没有链接器,我们必须手工修正 func() 的地址。当 module.c 被修改并重新编译时,func() 的地址极有可能改变,那么在 main.c 中所有使用到 func() 函数的地方,都要全部重新调整地址。这些繁琐的工作将成为程序员的噩梦。
有了链接器,我们可以直接调用其他模块中的函数而无需知道它们的地址,因为在链接的时候,链接器会根据符号 func 自动去 module.c 模块查找 func 的地址,然后将 main.c 模块中所有使用到 func 的指令重新修正,让它们的目标地址成为真正的 func() 函数的地址。
这种在程序运行之前确定符号地址的过程叫做静态链接(Static Linking);如果需要等到程序运行期间再确定符号地址,就叫做动态链接(Dynamic Linking)。
Windows 下的 .dll 或者 Linux 下的 .so 必须要嵌入到可执行程序、作为可执行程序的一部分运行,它们所包含的符号的地址就是在程序运行期间确定的,所以称为动态链接库(Dynamic Linking Library)。
变量和函数一样,都是符号,都需要确定它的地址。例如在 a.c 中有一个 int 类型的全局变量 var,现在需要在 b.c 中对它赋值 42,对应的C语言代码是:
var = 100;
对应的汇编代码为:
mov 0x2a, var
mov 用来将一份数据移动到一个存储位置,这里表示将 0x2a 移动到 var 符号所代表的位置,也就是对 var 变量赋值。
当被编译成目标文件后,得到如下的机器指令:
c705 00000000 0000002a
由于在编译时不知道变量 var 的地址,编译器将这条 mov 指令的目标地址设置为 0,等到将目标文件 a.o 和 b.o 链接起来的时候,再由链接器对其进行修正。
假设生成可执行文件后变量 var 的地址为 0x1100,那么上面的机器指令就变为:
c705 00001100 0000002a
这种地址修正的过程就是前面提到的重定位,每个需要被修正的地方叫做一个重定位入口(Relocation Entry)。重定位所做的工作就是给程序中每个这样的绝对地址引用的位置“打补丁”,使它们指向正确的地址。
4.1、符号的概念
函数和变量在本质上是一样的,都是地址的助记符,在链接过程中,它们被称为符号(Symbol)。链接器的一个重要任务就是找到符号的地址,并对每个重定位入口进行修正。
我们可以将符号看做是链接中的粘合剂,整个链接过程正是基于符号才能正确完成。
在《目标文件里面有什么,它是如何组织的》一节中讲到,目标文件被分成了多个部分,其中有一个叫做符号表(Symbol Value),它的段名是.symtab。符号表记录了当前目标文件用到的所有符号,包括:
(1)全局符号,也就是函数和全局变量,它们可以被其他目标文件引用。
(2)外部符号(External Symbol),也就是在当前文件中使用到、却没有在当前文件中定义的全局符号。
(3)局部符号,也就是局部变量。它们只在函数内部可见,对链接过程没有作用,所以链接器往往也忽略它们。
(4)段名,这种符号往往由编译器产生,它的值就是该段的起始地址,比如.text、.data等。
对链接来说,最值得关注的是全局符号,也就是上面的第一类和第二类,其它符号都是次要的。
所有的符号都保存在符号表.symtab中,它一个结构体数组,每个数组元素都包含了一个符号的信息,包括符号名、符号在段中的偏移、符号大小(符号所占用的字节数)、符号类型等。
确切地说,真正的符号名字是保存在字符串表.strtab中的,符号表仅仅保存了当前符号在字符串表中的偏移。
4.2、符号决议(Symbol Resolution)
当要进行链接时,链接器首先扫描所有的目标文件,获得各个段的长度、属性、位置等信息,并将目标文件中的所有(符号表中的)符号收集起来,统一放到一个全局符号表。
在这一步中,链接器会将目标文件中的各个段合并到可执行文件,并计算出合并后的各个段的长度、位置、虚拟地址等。
在目标文件的符号表中,保存了各个符号在段内的偏移,生成可执行文件后,原来各个段(Section)起始位置的虚拟地址就确定了下来,这样,使用起始地址加上偏移量就能够得到符号的地址(在进程中的虚拟地址)。
这种计算符号地址的过程被称为符号决议(Symbol Resolution)。
重定位表.rel.text和.rel.data中保存了需要重定位的全局符号以及重定位入口,完成了符号决议,链接器会根据重定位表调整代码中的地址,使它指向正确的内存位置。
至此,可执行文件就生成了,链接器完成了它的使命。
4.3、全局变量和局部变量
在《C语言内存精讲》中的《Linux下C语言程序的内存布局(内存模型)》一节讲到,当程序被加载到内存后,全局变量要在数据区(全局数据区)分配内存,局部变量要在栈上分配内存。
数据区在程序运行期间一直存在,全局变量的位置不会改变,地址也是固定的,所以在链接时就能够计算出全局变量的地址。而栈区内存会随着函数的调用不断被分配和释放,局部变量的地址不能预先计算,必须等到发生函数调用时才能确定,所以链接过程会忽略局部变量。
关于局部变量的定位,在《一个函数在栈上到底是怎样的》中已经进行了讲解,就是 ebp 加上偏移量,这在编译阶段就能给出计算公式(一条简单的语句),程序运行后,只要执行这条语句,就能够得到局部变量的地址。
总结起来,链接的一项重要任务就是确定函数和全局变量的地址,并对每一个重定位入口进行修正。
4.4、强符号和弱符号
我们在编写代码的过程中经常会遇到一种叫做符号重复定义(Multiple Definition)的错误,这是因为在多个源文件中定义了名字相同的全局变量,并且都将它们初始化了。
例如,在 a.c 中定义了全局变量 global:
int global = 10;
在 b.c 中又对 global 进行了定义:
int global = 20;
那么在链接时就会出现下面的错误:
b.o: multiple definition of `global\' a.o: first defined here
这种符号的定义可以被称为强符号。
在C语言中,编译器默认函数和初始化了的全局变量为强符号(Strong Symbol),未初始化的全局变量为弱符号(Weak Symbol)。强符号之所以强,是因为它们拥有确切的数据,变量有值,函数有函数体;弱符号之所以弱,是因为它们还未被初始化,没有确切的数据。
链接器会按照如下的规则处理被多次定义的强符号和弱符号:
(1)不允许强符号被多次定义,也即不同的目标文件中不能有同名的强符号;如果有多个强符号,那么链接器会报符号重复定义错误。
(2)如果一个符号在某个目标文件中是强符号,在其他文件中是弱符号,那么选择强符号。
(3)如果一个符号在所有的目标文件中都是弱符号,那么选择其中占用空间最大的一个。
比如目标文件 a.o 定义全局变量 global 为 int 类型,占用4个字节,目标文件 b.o 定义 global 为 double 类型,占用8个字节,那么被链接后,符号 global 占用8个字节。请尽量不要使用多个不同类型的弱符号,否则有时候很难发现程序错误。
在 GCC 中,可以通过__attribute__((weak))来强制定义任何一个符号为弱符号。假设现在有下面的一段代码:
extern int ext;
int weak1;
int strong = 100;
__attribute__((weak)) weak2 = 2;
int main()
{
return 0;
}
weak1 和 weak2 是弱符号,strong 和 main 是强符号,而 ext 既非强符号也非弱符号,它是一个对外部变量的引用(使用)。
为了加深理解,我们不妨再来看一个多文件编程的例子。
main.c 源码:
#include <stdio.h>
//弱符号
__attribute__((weak)) int a = 20;
__attribute__((weak)) void func(){
printf("C Language\n");
}
int main()
{
printf("a = %d\n", a);
func();
return 0;
}
module.c 源码:
#include <stdio.h>
//强符号
int a = 9999;
void func()
{
printf("c.biancheng.net\n");
}
在 GCC 中,使用下面的命令来运行程序:
$gcc main.c module.c
$./a.out
a = 9999
c.biancheng.net
在 main.c 中,a 和 func 都是弱符号,在 module.c 中,a 和 func 都是强符号,强符号会覆盖弱符号,所以链接器最终会使用 module.c 中的符号,输出结果也印证了这一点。
需要注意的是,__attribute__((weak))只对链接器有效,对编译器不起作用,编译器不区分强符号和弱符号,只要在一个源文件中定义两个相同的符号,不管它们是强是弱,都会报“重复定义”错误。请看下面代码:
#include <stdio.h>
__attribute__((weak)) int a = 20;
int a = 9999;
int main()
{
printf("a = %d\n", a);
return 0;
}
这段代码在编译阶段就会报错,编译器会认为变量 a 被定义了两次,属于重复定义。
弱符号对于库来说十分有用,我们在开发库时,可以将某些符号定义为弱符号,这样就能够被用户定义的强符号覆盖,从而使得程序可以使用自定义版本的函数,增加了很大的灵活性。
5、引用
所谓引用(Reference),是指对符号的使用。在下面的代码中:
int a = 100, b = 200, c;
c = a + b;
第一行是符号定义,第二行是符号引用。
目前我们所看到的符号引用,在所有目标文件被链接成可执行文件时,它们的地址都要被找到,如果没有符号定义,链接器就会报符号未定义错误,这种被称为强引用(Strong Reference)。
与之相对应的还有一种弱引用(Weak Reference),如果符号有定义,就使用它对应的地址,如果没有定义,也不报错。
链接器处理强引用和弱引用的过程几乎是一样的,只是对于未定义的弱引用,链接器不认为它是一个错误,一般默认其为 0(地址为 0),或者是一个特殊的值,以便程序代码能够识别。
在变量声明或函数声明的前面加上__attribute__((weak))就会使符号变为弱引用。比如下面这段代码:
#include <stdio.h>
__attribute__((weak)) extern int a;
__attribute__((weak)) extern void func(); //也可以不写extern
int main()
{
printf("&a: %d, func: %d\n", &a, func);
printf("a = %d\n", a);
func();
return 0;
}
我们可以将它编译成一个可执行文件,GCC 并不会报链接错误。但是当程序运行时,输出&a: 0, func: 0后就会发生段错误(Segment Fault),这是因为符号 a 和 func 的地址都为 0,这个地址是禁止访问的。
一个改进的例子是:
#include <stdio.h>
__attribute__((weak)) extern int a;
__attribute__((weak)) extern void func();
int main()
{
printf("&a: %d, func: %d\n", &a, func);
if(&a)
{
printf("a = %d\n", a);
}
else
{
printf("a is undefined!\n");
}
if(func)
{
func();
}
else
{
printf("func() is undefined!\n");
}
return 0;
}
运行结果:
&a: 0, func: 0 a is undefined! func() is undefined!
代码中需要判断的是地址,不是值,所以变量 a 前面需要加&;而函数名本身就表示地址,所以 func 前边不需要&。
弱引用和强引用非常利于程序的模块化开发,我们可以将程序的扩展模块定义为弱引用,当我们将扩展模块和程序链接在一起时,程序就可以正常使用;如果我们去掉了某些模块,那么程序也可以正常链接,只是缺少了某些功能,这使得程序的功能更加容易裁剪和组合。