可执行程序是如何诞生的(一)——概览
目录
零、前言
一、编译与链接
1.1预处理
1.2编译
1.3汇编
1.4链接
二、编译器干了什么?
2.1为什么有编译器
2.2编译器做了什么
2.2.1词法分析
2.2.2语法分析
2.2.3语义分析
2.2.4中间语言生成
2.2.5目标代码的生成与优化
零、前言
关于为什么写这部分文章,主要是博主在面试时,被面试官问到了,突然一种熟悉的陌生感涌上我的心头……,因此博主想把自己读过的一本书整理成博客的形式供自己和大家阅读。对了,博主读的是《程序员的自我修养——链接、装载与库》,这本书非常推荐大家读一下,这对你理解一个可执行程序是如何诞生的有很大的帮助。那么闲言少叙,我们直接进入正题吧。
一、编译与链接
我相信如果你学习过C语言,那么你一定会写下面的这个程序。
#include <stdio.h>
int main()
{
printf("%s", "hello world!\n");
return 0;
}
想必,你也或多或少的听说过一个C语言代码从“源码”到“可以执行文件”需要经历四个步骤,它们分别是:“预处理”、“编译”、“汇编”、“链接”;它们的作用对象以及产出以及各模块之间的关系如下图。

需要说明的是,这个过程也适用于其它版本的操作系统,不过中间生成的文件名可能略有差异,不过过程都是这个过程。下面我们简单的介绍一下这几个过程都完成了什么工作。
1.1预处理
先说结论在这个过程中,完成了一下几个工作:
- 将所有的“#define”删除并将其对应的内容进行展开。
- 处理所有条件预编译指令如:“#ifdef”、“#elif”、“#endif”、“#if”、“#else”等。
- 将所有的“#include”内容递归包含。
- 删除所有的注释。
- 添加行号与文件名标识。
- 保留“#pragma”定义的内容,编译器要进行使用。
想要验证这一部分的内容,就需要使用一些集成功能不那么强的“本地集成编译器”,如果你使用Visual Studio这种集成度较高的编译器那么很难看到这一过程,这里,博主使用Gcc搭配Vscode为大家演示这个过程。
你可以使用如下的指令,让Gcc生成与处理文件后就停止。
gcc -E main.cc -o main.i
先展示没有经过“预处理器”处理的文件:
#include <stdio.h>
#define NUM 1
//这是注释222
int main()
{
//这是注释111
printf("hello world!");
printf("%d",NUM);
return 0;
}
经过预处理后的文件我们之前也说过,如果你包含了头文件,那么头文件将会被展开,这就会导致卫门的代码会变得很多,所以博主这里只讲有意义的一部分以截图的形式表现出来,各位读者如果感兴趣,可以自己去生成一个.i文件,看看生成的文件是不是把对应的头文件递归展开了。

我们可以观察到,原来宏定义的“NUM”被替换成了“1”,之前的注释也被清除掉了。此外代码量从原来的短短几行变成了近千行代码....。
此外,非常需要注意的一点,博主已经多次标红,头文件的展开是递归式的,这就意味如果头文件相互包含则会“死递归”。接下来我为大家演示一下“死递归”。


所以下次,当你包含了N多个文件的时候且代码逻辑没有错误的时候,不妨看看是不是你的头文件相互包含而导致“死递归”了。
1.2编译
这部分的工作将对我们的代码经行词法分析、语义分析、语法分析,关于这三项都完成了什么工作之后我们会介绍,这里先简单说一下概念。将我们代码进行一定程度的优化后,生成.s文件。我们还是先将对应的文件生成出来,看看对应的文件内容是什么样子的。
你可以使用如下指令来生成对应的“汇编代码”。
gcc -S main.cc -o main.s
生成的main.s文件内容如下:
.file "main.cc"
.text
.section .rodata
.LC0:
.string "hello world!"
.LC1:
.string "%d"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
leaq .LC0(%rip), %rax
movq %rax, %rdi
movl $0, %eax
call printf@PLT
movl $1, %esi
leaq .LC1(%rip), %rax
movq %rax, %rdi
movl $0, %eax
call printf@PLT
movl $0, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0"
.section .note.GNU-stack,"",@progbits
.section .note.gnu.property,"a"
.align 8
.long 1f - 0f
.long 4f - 1f
.long 5
0:
.string "GNU"
1:
.align 8
.long 0xc0000002
.long 3f - 2f
2:
.long 0x3
3:
.align 8
4:
这一部分的代码就开始变得晦涩难懂了,当然,我的本意并不是想让大家去理解这段让人头疼的代码,而是想以此为后文做铺垫,如果你对编译这个过程有浓厚的兴趣,那么我推荐你去看看“编译原理”这门课,这里我们就不多说了,因为博主我呀暂时也未深究过,这里就不说一些误人子弟的话了……。
1.3汇编
在这个步骤中,则是将我们编译生成的代码转换成机器可以识别的代码,经过汇编器处理的代码叫做汇编代码,这部分代码没有复杂的语法和语义,也无需进行额外的优化,只需要根据一个特定的“表”将对应的汇编代码转换为机器码。
老规矩我们先看看,生成的代码文件是什么样子,在继续进行讲解。
在生成代码这个一块与前面几个部分有所不同,如果我们直接按照下面列出的指令来生成文件,生成的文件是二进制的是不可读的。
gcc -c main.cc -o main.o

所以,我们先将代码编译成可执行文件,而后“反汇编”将代码转换成带有机器码的汇编文件。
gcc -o main main.cc #生成可执行文件main
objdump -d main > disassembly.s #反汇编生成带有机器码的汇编文件

图6中的代码,博主只截取了一部分,如果都进行截取了有几分影响观感。
实际上,最后CPU执行的内容就是根据我们的机器码进行执行的,这种机器码是最低级且对CPU来说最容易执行的代码。任何语言编写的程序最后都会转换为机器码,送给CPU执行。
我们上文所说的那个可以将汇编代码转换为机器码的对照表实际上就是如图7所以的表。如果你仔细观察图6中的机器码,你就会发现,机器码实际上都是二进制指令,每一行二进制指令由两部分组成中间用冒号进行分隔,冒号的左边表示将要进行的操作,冒号的右边表示操作对象的地址。

这样的"表"有很多,但是是可以穷举完的。稍后我们会讲解,为什么需要汇编指令,以及为什么会有C语言这样的高级语言诞生。
此外,说句题外话,如果你仔细观察,你会发现我们使用gcc工具生成不同阶段的代码实际上是与我们使用的选项有关系,比如我们只想生成完成预处理阶段的代码,我们只需要使用-E选项即可,如果我们想生成汇编代码,我们只需要使用-S选项,如果我们想要生成目标文件,那么我们只需要使用-c选项即可。但是实际上我们的gcc只不过是将能完成这几个功能的可执行程序进行了包装。
- 比如我们想编译一个源文件我们可以直接调用这个程序/usr/lib/gcc/i486-linux-gnu/4.1/cc1
- 再比如我们想预处理一个源文件后就停下来我们可以调用这个程序/usr/bin/cpp。
- 再比如我们想将源文件处理为目标文件,我们可以调用/usr/lib/gcc/x86_64-linux-gnu/11/cc1
这里就不给大家进行演示了,演示的结果与上面给大家展示的结果相近。
1.4链接
这部分需要进行的工作是将我们的目标文件和静态库进行链接,这里暂时先不展开讲,后面会着重讲讲这一部分具体做了什么工作。现在,你只需要知道,目标文件通常是不可以直接运行的,为什么说通常不是,请看我写的一个没什么用的代码。
#define NUM 1
int main()
{
int a=20;
int b=10;
int c = a+b;
return 0;
}
这个代码处理成目标文件之后,可以直接运行。

可以看到,示例代码编译成为目标文件后运行并没有报错,但是我们之前编译生成的“hello world”程序运行却报错了。示例代码与图8想要说明的是并不是所有的目标文件都不可以直接运行,如果你的代码中没有任何需要连接的文件,那么当然可以直接执行,但是如果有需要连接的文件很显然就不能够直接执行。
但是,绝大多数情况下,一个正常的,能完成一定功能的代码肯定是需要包含一些库的,所以如果你说汇编之后的代码不能够直接执行,这句话站在工程的角度来说似乎也没有什么错。
二、编译器干了什么?
2.1为什么有编译器
在讲解编译器之前,我们需要先回顾一下历史,在很久以前,人们没发明汇编语言之前,人们还是使用纸带打孔的方式进行代码编写的时候,人们需要自己定义一套逻辑规范,还记得博主在本文1.3部分,讲解二进制机器码时,提到的二进制机器码的特点吗:每一条二进制机器码由冒号进行分隔,冒号的左边是你想要进行的操作的代号,冒号的右边是你想操作对象的地址。
很明显,在通信尚不发达的20世纪,想让所有从事代码编写工作的科学家都使用相同的“操作代号”是不可能的,所以你就会发现实验室A使用的是A标准的操作代号,实验室B使用的是B标准的操作代号。在实验室A中操作代号0001可能代表我要跳转到某一个地址处,在实验室B中操作代号0001可能代表我要进行一个加法运算。甚至一些极端的情况下,实验室内部都是用不同的操作代号标准。
这种情况就造成了一个问题,如果我们想基于某个实验室的标准进行开发上层软件,那么当你这个软件脱离这个实验室环境的时候八成是用不了的,这就是我们常常提到的“可移植性”的问题,显然,当时百花齐放式的标准很难和“可移植性”挂上关系。
这个问题的解决方案,其实就和计算机中解决解耦性难题的思路是一样的——“增加一层中间层”,这个时候汇编就应运而生。而且,各羊头硬件厂商联合指定了一套ISA标准,这个标准约束的是汇编指令转换成机器码这个过程,所有硬件厂商都应遵守(这也是为什么Java能够说出“一次编写,到处运行”口号的必要支持)只要所有的厂商按照同一套标准编写汇编语言指令集,我们的代码就可以实现跨平台。但是实际上,Linux体系标准和Windows体系标准是不一样的,这也就注定了——汇编语言不能完全实现跨平台。
所以为了实现同一份代码在Linux上可以跑并且在Linux平台上也可以跑,就需要在添加一层软件层,这一层就是我们的高级语言所在的层。这一层包含系统调用,我们的高级语言实际上就是封装了这些系统调用,当在Linux操作系统体系下就是用Linux的系统调用,当在Windows操作系统体系下就是用Windows系统调用完成功能。而这些底层调用实际上就和汇编语言有关系了,我们之前也说了,在同一套操作系统体系下的标准是基本一致的,如果不一致就需要对应的硬件厂商去向上支持我们的系统调用,这样我们高级语言就不用去关心操作系统平台所带来的差异了。

编译器的职责就是完成高级语言代码转换到汇编语言代码这个任务。
讲完了为什么有编译器我们再说说编译器都干了什么?
2.2编译器做了什么
博主暂且用一张图来表述编译都需要干什么。

我们一段示例代码为例来进行讲解。
arry[index]=(index+4)*(2+6)
2.2.1词法分析
我们的这段代码经过扫描器(Scanner)扫描后分割成一系列记号(token)。
记号 | 类型 |
arry | 标识符 |
[ | 左方括号 |
index | 标识符 |
] | 右方括号 |
= | 赋值 |
( | 左圆括号 |
index | 标识符 |
+ | 加号 |
4 | 数字 |
) | 右圆括号 |
* | 乘号 |
( | 左圆括号 |
2 | 数字 |
+ | 加号 |
6 | 数字 |
) | 右圆括号 |
词法分析产生的符号一般可以分为这样几类:关键字、标识符、字面量(数字、字符串等)、特殊符号(加号、减号等)。
2.2.2语法分析
经过语法分析后构建的语法树如图:

构建语法树的过程并不是本文的重点,如果你想了解语法树的构建过程可以自行去查阅一下。总之,语法分析后的结果就是图11。
2.2.3语义分析
实际上,语义分析分为静态语义分析和动态语义分析。静态语义分析指的是在编译时就可以确定语义,通常是分析声明、类型匹配、类型转换等。动态语义指的是在运行时才能确定语义。比如说除0错误就是发生在运行时发生的。
语义分析后的语法树被标注了类型信息,如果需要对某个表达式的结果做隐式类型转换也是在这个时期执行的。

2.2.4中间语言生成
某些可以在编译时期就可以确定的表达式会被直接优化掉。在本文的例子中,表达式arry[index]=(index+4)*(2+6)中的“2+6”显然可以直接被优化为8,而不需要再创建一个“树杈”。在本文中提到的例子比较简单,但是实际开发中的代码可能会十分复杂,如果这个时候在树上进行优化,那并不是一件容易的事,所以这个时候就会将树结构转换为中间代码,中间代码是语法树的顺序表示。中间代码在不同的编译器中有不同的表现形式,这里我们使用“三地址码”来说明。
基本的三地址码如:X = Y op Z,op表示想要进行的操作,比如加减乘除。我们的例子转换为如下代码。
t1=2+6
t2=index+4
t3=t2*t1
arry[index]=t3
优化后的代码:
t2 = index + 4
t2 = t2 * 8
arry[index] = t2
优化后的代码降低了变量创建和销毁的开销。但是此时我们还没有对类型占用空间进行分配与管理,因为此时生成的中间代码是平台无关的,既没有平台信息也没有寄存器信息。

2.2.5目标代码的生成与优化
在这一过程中,我们的代码开始与平台建立联系,并会根据目标机器为需要存储的类型进行空间的分配与管理。将2.2.4中优化后的代码转换为汇编后的结果如下图:

经过目标代码优化器后的代码:

//等效C代码
int edx = index; // movl index, %edx
int eax = 32 + edx * 8; // leal 32(,%edx,8), %eax
array[edx] = eax; // movl %eax, array(,%edx,4)
经过词法分析、语法分析、语义分析、源码优化、目标代码优化后生成的代码似乎已经足够“精简”,但是现在还有一个问题就是:我们的index、array在哪里,如果在相同的文件中,那一切都好说,如果不在一个文件中,就需要我们进行“链接”,至于什么是“链接”我们在后面的文章中在做讲解。