文章目录
- 编译和链接【三】
- 前言
- 系列文章入口
- 编译过程
- 词法分析
- 语法分析
- 语义分析
- 生成中间代码
- 汇编
- 链接
编译和链接【三】
前言
在我大一的时候, 我使用VC6.0对C语言程序进行编译链接和运行 , 然后我接触了VS, Qt creator等众多IDE, 这些IDE界面友好, 使用方便, 例如我最喜欢的VS,一键编译运行。对于大一的我,不需要了解编译的整个过程就可以运行,这无疑是非常棒的,并且增加了我对编程的兴趣,同时也简化了我后续的软件开发, 我只需要关心业务和功能代码即可。
但是今天, 我不想“逃课了”,欢迎来到我的频道,本系列 将会介绍编译中的一系列细节。
在正式开始之前,我要推荐两本书,一本是《程序员的自我修养》,另一本是《鲸书》,这两本书对编译的整个过程做了非常详细,非常完备的介绍,但是恰恰如此,我想很多时候,很多知识在工作上是用不到的,也许这句话在很多年多的我会反驳,但是站在工作一年的现在,我将会给你介绍,我所了解的编译和链接。
系列文章入口
关注我~持续更新
编译和链接【一】
编译和链接【二】
编译过程
在汇编语言中,通常是以段作为标识,来完成某个功能,例如,你可能会看到这样的代码:
start:# code...goto errHandleerrHandle:# code...
上面的代码片里就是在某种情况下,发生出错的时候,统一的跳转到错误处理函数里,而从C/C++的翻译单元到汇编文件的转换,其实就是把C/C++代码中的函数,代码块转换为汇编代码中的代码段,而C/C++程序里的全局变量,静态变量和常量则转换为汇编中的数据段和只读段
总体来说,编译过程可分为以下6步:
- 词法分析
- 语法分析
- 语义分析
- 中间代码生成
- 汇编代码生成
- 目标代码生成
词法分析
这是编译的第一步,主要用来解析程序里的语句。整个过程利用状态机的思想,将语句从左到右进行扫描,一个字符一个字符地读入,然后解析并标识这些字符,最终会分解为最小记号单元:token,常见的token如下:
- 关键字:auto, const, constexpr, consteval等
- 各种标识符:函数名,变量名等
- 字面量:字符串等
- 运算符:+, **-**等
- 分隔符:;(用于标识语句结束)等
例如你有这样的语句:
sum = a + b / c;
经过扫描处理后,会分解为8个token, “sum”, “=”, “a”, “+”, “b”, “/”, “c”, “;”
在我大一学习C语言的时候,常常会使用中文符号,这个时候就会发生编译错误,其实就是发生在这个阶段。
语法分析
语法分析的核心是构建语法树,也就是根据之前分解的token来解析,看看能否构成语法树,例如上个阶段的语句就能构成:
在解析构建的过程中, 如果发现不能构建成语法树,那么就会报语法错误:syntax error,例如缺少语句结束符号等。
语义分析
如果说,语法分析仅仅是语法上的校验,那么语义分析则是对各种表达式,语句进行检查,例如:函数传参是否匹配,重载决议等,再或者是使用某个变量,但是未定义的情况;或者除数为零了; break在循环语句或switch语句之外出现了;或者在循环语句之外发现 了continue语句;一般都会报语义上的错误或警告。
生成中间代码
在上个阶段,还是以语法树的形式进行存储,我们需要把其转换为中间代码,这种形式类似于伪代码,常见的有:三地址码,P-代码等。
而你的疑惑可能是,既然有了语法树,为什么要转换为中间代码呢?
答案很简单,中间代码是一维线性序列,编译器可以方便的将中间代码翻译为目标代码,例如,我有以下的程序:
#include <iostream>
#include <string>using namespace std;#define MESSAGE "hello world!"#pragma message("build cpp project")int main()
{int a = 3, b = 4;int c = a + b;return 0;
}
使用gcc的命令:-fdump-tree-gimple
可以生成中间代码,这个时候会在你的当前目录下生成一个后缀为gimple的文件,例如我生成的结果
以上的代码则被转换为:
main ()
{int D.39926;{int a;int b;int c;a = 3;b = 4;c = a + b;D.39926 = 0;return D.39926;}D.39926 = 0;return D.39926;
}
中间码一般和平台是无关的,如果你想将C程序编译为X86平台下的可执行文件,那么最后一步就是根据X86指令集,将中间代码翻译为X86汇编程序。
如果你想编译成在ARM平台上运行的可执行文件,那么就要参考ARM指令集,根据ATPCS规则分配寄存器,将中间代码翻译成ARM汇编程序。
根据上面的三地址码,我们就可以尝试使用汇编指令实现
section .dataa dd 3b dd 4c dd 0section .textglobal _start_start:; Load the value of 'a' into the EAX registermov eax, [a]; Add the value of 'b' to the EAX registeradd eax, [b]; Store the result in 'c'mov [c], eax; Exit the programmov eax, 1 ; syscall number for sys_exitxor ebx, ebx ; exit code 0int 0x80 ; call kernel
汇编
这个过程就是利用汇编器来把前阶段生成的汇编文件翻译为目标文件,主要工作是参考对应架构的指令集来完成,同时生成一些其他信息。整个过程使用图表形式进行展现:
链接
通过编译生成的可重定位的目标文件,都是以零地址为链接起始地址进行链接的,也就是说,编译期在将源文件翻译成可重定位的目标文件的过程中,将代码翻译为二进制指令以后,会从零地址开始把各个指令序列放到代码段中,每个函数的入口也是从零地址开始偏移。
让我们看看目标文件的section信息
你可以看出,不过是地址还是偏移地址,都是从0开始的,这也符合我在开头所说。
那么,如果大家都想把自己放在零地址,链接器又是怎么将各个目标文件组装在一起的呢?
答案很简单,链接器把各个目标文件组装在一起后,我们需要重新修改各个目标文件中变量和函数的地址,这个过程就是:重定位
而一个疑惑就显然地产生了,链接器是怎么知道哪些函数/变量需要重定位呢?
显然,我们需要打表,也就是把需要重定位的符号专门的收集起来,生成一个重定位表,并且以section的形式保存到每个可重定位目标文件中即可。
当然,一个符号,不管其是否需要重定位,都需要收集起来,这就是:符号表,也以section的形式保存到可重定位目标文件中。
在链接器组装过程中,函数的地址发生了变化,在链接器组装之后,需要重新计算和更新函数的新地址,这个过程就是重定位。