编译与链接相关知识
本文主要总结一些关于编译与链接的相关知识,内容来自于:
- 《深入理解计算机系统》第三版
- 《程序员的自我修养》
- 极客时间——编程高手必学的内存知识
前言
首先先看一下GCC
编译过程的一个分解:如下图
cpp
:预编译阶段处理宏定义、条件编译、头文件、删掉注释等生成 .i 中间文件
gcc
:编译阶段进行词法分析、语法分析、语义分析及优化生成相关汇编文件
as
:汇编器将汇编代码转化为机器指令,一条语句几乎对应一条汇编指令
ld
:链接器将.o目标文件合并生成可执行的二进制文件
一、预编译
方式:
gcc -E hello.c -o hello.i
cpp hello.c > hello.i
预编译阶段要处理的事情:
- 将所有的
#define
删除,并且展开宏定义; - 处理所有的条件编译指令,如:
#if #ifdef #elif #else #endif
等; - 处理
#include
,将预包含的文件插入到预编译指令的位置,注意,这个过程是递归进行的; - 删除所有的注释
// /**/
- 添加行号和文件名标识,比如
#2 hello.c 2
,方便在编译时编译器产生调试时用的行号信息和报错时输出的行号; - 保留所有的
#pragma
编译器指令,因为编译器要使用他们;
二、编译
- 编译过程就是要将预处理完生成的
.i
文件进行一系列的词法分析、语法分析、语义分析及优化后产生汇编文件。 - 编译过程主要做了:扫描(词法分析)、语法分析、语义分析、源代码优化、代码生成和目标代码优化,如下图:
1. 词法分析
词法分析主要是将源代码通过扫描器Scanner
,生成一系列记号Token
-
主要流程:
-
源代码输入到
Scanner
,利用扫描器将源代码的字符串序列分割成一系列的记号Tokens
;- 扫描器运用一种类似于有限状态机的算法来将源文件字符串序列分割,完成词法扫描。
-
这些记号一般可以分为:关键字、标识符、字面量(包含数字、字符串等)和特殊符号(
+ -
等); -
识别记号时,扫描器还会完成其他工作,如将标识符存放到符号表中,将数字、字符串常量存放到文字表中,方便后面使用;
-
-
结果:
2. 语法分析
接下来语法分析器Grammar Parser
将对扫描器产生的记号进行语法分析,从而产生语法树。
语法分析产生的语法树就是以表达式为节点的树,如下图:
- C语言的一个语句是一个表达式,而复杂语句就是很多表达式的组合。
- 如果出现了表达式不合法,如括号不匹配、表达式中缺少操作符,编译器就会报告语法分析阶段的错误。
3. 语义分析
-
语义分析是由**语义分析器(Semantic Analyzer)**完成的。这个阶段主要是来判断语句是否有意义,例如两个指针的乘法运算是没有意义的。
-
语义分析分为:
- 静态语义,编译器所能分析的,在编译期确定的;
- 包括声明和类型的匹配、类型的转换。
- 动态语义,运行期才能确定的;
- 比如将0作为除数是一个运行时的语义错误。
- 静态语义,编译器所能分析的,在编译期确定的;
-
经过语义分析阶段,语法树会被标上类型。
三、汇编
- 汇编阶段是将.S文件变成.o文件。(这里暂时不做过多介绍)
四、链接
1. 链接的介绍
- 链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件是可以被放到内存中执行的。
- 根据执行时期可分为:
- 编译时链接:生成二进制可执行文件时,称为静态链接;
- 加载时链接:在二进制文件被加载到内存中时,这种被称为动态链接;
- 这种情况是在二进制文件保留符号,在加载时再把符号解析成真实的内存地址,这种被称为动态链接;
- 运行时链接:由应用程序来执行,这种也被称为动态链接。
- 在运行期间解析符号。这种情况会把符号的解析延迟到最后不得不做时才去做符号的解析,这也是动态链接的一种。
2. 静态链接
在linux
下,输入一组可重定位的目标文件,经过ld
链接器,生成一个完全链接、可加载和可运行的可执行目标文件。在这个过程中会把不同类型的段进行组合。
- 静态链接主要完成了两个任务:
- 符号解析:这里的主要目的是将每个符号引用和符号定义关联起来,也就是每一个符号引用关联一个符号表条目。
- 重定位:编译器和汇编器生成从地址0开始的代码和数据节。链接器通过把每个符号定义和内存地址关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得他们指向这个内存位置。(所谓重定位,就是当被调用者的地址变化了,要让调用者知道新的地址是什么)
3. 目标文件及格式
链接器操作的是目标文件,所以我们需要了解目标文件以及它的格式。
- 目标文件的三种形势:
- 可重定位的目标文件
- 可执行的目标文件
- 共享目标文件
- 目标文件的格式:
Windows
:可移植可执行(Portable Executable PE
)macOS-X
:`Mach-O``X86_64 Linux/Unix
:可执行可链接格式(Executable and Linkable Format ELF
)
我们只讨论ELF
格式
3.1 可重定位的目标文件
ELF
头:- 以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。
ELF
头的剩余部分包含帮助链接器语法分析和解释目标文件的信息,其中包括了:ELF
头的大小- 目标文件的类型(可重定位、可执行、共享)
- 机器类型(
x86_64
) - 节头部表的文件偏移,节头部表中条目的大小和数量。
- 节头部表:描述了不同节的大小和位置
.text
.rodata
.data
:已初始化的全局和静态C变量。.bss
:未初始化的全局和静态C变量,以及已经初始化为0的全局和静态变量。在目标文件中这个节不占据任何实际的空间,它仅仅是一个占位符。在目标文件中,未初始化变量不需要占据任何实际的磁盘空间。运行时,在内存中分配这些变量,初始值为0。symtab
:一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。一些程序员错误地认为必须通过-g
选项来编译一个程序,才能得到符号表信息。实际上,每个可重定位目标文件在.symtab
中都有一张符号表(除非程序员特意用 STRIP 命令去掉它)。然而,和编译器中的符号表不同,.symtab
符号表不包含局部变量的条目。.rel.text
:一个.text
节中位置的列表,当链接器把这个目标文件和其他目标文件进行组合的时候需要修改这里面这些位置。.rel.data
:被模块引用或定义的所有全局变量的重定位信息。一般而言,任何已初始化的全局变量,如果它的初始值是一个全局变量地址或者外部定义函数的地址,都需要被修改。.debug
:一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的 C 源文件。只有以 - g 选项调用编译器驱动程序时,才 会得到这张表。.line
:原始 C 源程序中的行号和 .text 节中机器指令之间的映射。只有以 -g 选项调用编译器驱动程序时,才会得到这张表。.strtab
:一个字符串表,其内容包括.symtab
和.debug
节中的符号表,以及节头部中的节名字。字符串表就是以 null 结尾的字符串的序列。
4. 符号和符号表
符号
每个可重定位目标模块 m 都有一个符号表,它包含 m 定义和引用的符号的信息。在链接器的上下文中,有三种不同的符号:
- 由模块 m 定义并能被其他模块引用的全局符号。全局链接器符号对应于非静态的 C 函数和全局变量。
- 由其他模块定义并被模块 m 引用的全局符号。这些符号称为外部符号,对应于在其他模块中定义的非静态 C 函数和全局变量。
- 只被模块 m 定义和引用的局部符号。它们对应于带 static 属性的 C 函数和全局变量。这些符号在模块 m 中任何位置都可见,但是不能被其他模块引用
局部变量并不被链接器所关注。
符号表
-
符号表是由汇编器构成的,使用编译器输出到汇编语言 .s 文件中的符号。
-
符号表是放在
.symtab
这个节中的,它的一个条目如下:-
typedef struct { int name; /* String table offset */ char type:4, /* Function or data (4 bits) */ binding:4; /* Local or global (4 bits) */ char reserved; /* Unused */ short section; /* Section header index */ long value; /* Section offset or absolute address */ long size; /* Object size in bytes */ } Elf64_Symbol;
- name 是字符串表中的字节偏移,指向符号的以 null 结尾的字符串名字。
- value 是符号的地址。对于可重定位的模块来说,value 是距定义目标的节的起始位置的偏移。对于可执行目标文件来说,该值是一个绝对运行时地址。
- size 是目标的大小(以字节为单位)。
- type 通常要么是数据,要么是函数。符号表还可以包含各个节的条目,以及对应原始源文件的路径名的条目。所以这些目标的类型也有所不同。
- binding 字段表示符号是本地的还是全局的。
- section 字段表示每个符号都被分配到目标文件的某个节,该字段也是一个到节头部表的索引。
-
有三个特殊的伪节(
pseudosection
),它们在节头部表中是没有条目的:ABS
代表不该被重定位的符号;UNDEF
代表未定义符号,也就是在本目标模块中引用,但是却在其他地方定义的符号;COMMON
表示还未被分配位置的未初始化的数据目标。对于COMMON
符号,value 字段给出对齐要求,而 size 给出最小的大小。注意,只有可重定位目标文件中才有这些伪节,可执行目标文件中是没有的。
-
COMMON
和.bss
的区别很细微。现代的GCC
版本根据以下规则来将可重定位目标文件中的符号分配到COMMON
和.bss
中:COMMON
:未初始化的全局变量.bss
:未初始化的静态变量,以及初始化为 0 的全局或静态变量
-
5. 符号解析
链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表中的一个确定的符号定义关联起来。
- 局部符号:编译器为其创建唯一的名字以保证在当前模块中只有一个定义。
- 外部符号:编译器会为假设其在其他模块中定义,生成一个链接器符号表条目交给链接器处理。
- 全局符号:由于多个模块间可能会有重名的全局符号,所以需要对重名情况作处理。
链接器如何解析多重定义的全局符号呢?
Linux 链接器使用下面的规则来处理多重定义的符号名:
- **规则 1:**不允许有多个同名的强符号。
- **规则 2:**如果有一个强符号和多个弱符号同名,那么选择强符号。
- **规则 3:**如果有多个弱符号同名,那么从这些弱符号中任意选择一个。
6. 重定位
一旦链接器完成了符号解析这一步,就把代码中的每个符号引用和正好一个符号定义(即它的一个输入目标模块中的一个符号表条目)关联起来。此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小。现在就可以开始重定位步骤了,在这个步骤中,将合并输入模块,并为每个符号分配运行时地址。重定位由两步组成:
- **重定位节和符号定义。**在这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节。例如,来自所有输入模块的. data 节被全部合并成一个节,这个节成为输出的可执行目标文件的. data 节。然后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址了。
- 重定位节中的符号引用。在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。要执行这一步,链接器依赖于可重定位目标模块中称为重定位条目(relocation entry)的数据结构,我们接下来将会描述这种数据结构。
符号引用地址的替换:
- 局部变量的地址链接器不关心,由栈来管理
- 静态函数不需要重定位,因为和执行单元代码都在.text段,相对位置在编译的时候就能确定了,因为链接器合并中间文件时相对位置不会变。
- 静态变量需要重定位,因为和编译单元代码段.text分属不同的section,在.data,链接器合并文件时会重新排布,所以需要重定位;
- 全局变量/函数,外部变量/函数都是需要被重定位的,大致方法就是:
编译器先用0占位符号、链接重定位表找符号、定位符号地址、然后在当前代码段计算RIP相对偏移位置填上。具体就是:- 编译器:生成机器码、符号0占位
- 链接器:合并文件,分配符号地址,给符号地址写回编译出的代码
7. 可执行目标文件
可执行目标文件的格式类似于可重定位目标文件的格式。ELF 头描述文件的总体格式。它还包括程序的入口点(entry point),也就是当程序运行时要执行的第一条指令的地址。因为可执行文件是完全链接的(已被重定位),所以它不再需要 .rel 节。
.init
:定义了一个小函数,叫做_init
,程序的初始化代码会调用它;段头部表
:也叫程序头部表,ELF 可执行文件被设计得很容易加载到内存,可执行文件的连续的片(chunk)被映射到连续的内存段。程序头部表(program header table)描述了这种映射关系。
8. 加载与运行
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6DodL0Z4-1680446576580)(编译与链接.assets/image-20220612111343074.png)]
重定位),所以它不再需要 .rel 节。
.init
:定义了一个小函数,叫做_init
,程序的初始化代码会调用它;段头部表
:也叫程序头部表,ELF 可执行文件被设计得很容易加载到内存,可执行文件的连续的片(chunk)被映射到连续的内存段。程序头部表(program header table)描述了这种映射关系。