前言
我们在日常生活中总是说一个源程序要先经过编译然后才能运行,源文件是给人看的,要把它翻译成二进制文件机器才能看懂。emmm,感觉有点抽象,今天我们就来聊一聊编译这回事儿。
一. 程序的翻译环境和执行环境
一个源程序想跑起来必须经过两个环境处理:翻译环境和执行环境
翻译环境
翻译环境中有两个非常重要的工具,一个是编译器,一个是链接器,这两个工具分别完成两个过程,即编译和链接。
像VS的编译器是cl.exe,链接器是link.exe,这两个文件都可以在安装的路径底下找到。
注意我这里的说法哟,我说的是“VS的编译器”,难道VS不是一个编译器吗?还真不是。
VS是一个集成开发环境,比如DEV C++,CodeBlocks这些我们都把他们叫集成开发环境。一个集成开发环境包括编辑,编译,链接,调试这样一些功能。不同得集成开发环境得各种组件可能不一样,比如VS得编辑器是cl.exe,而CodeBlocks用的是gcc。
执行环境
执行环境就是用来运行可执行程序得,通常就是我们的操作系统
整个过程可用这样一张图表示
二.详解翻译环境
翻译包含两个步骤,编译+链接,我们平常很少提到链接这个过程,其实经常说的编译就是指的翻译阶段,这里为了讲清楚细节从而将它细分出来。
一个程序中的所有.c文件会经过编译器单独编译生成对应的目标文件。这种目标文件在Windows环境下后缀名是.obj,在Linux环境是.o。下文我用到的文件后缀名都是Linux环境下的命名方式。
得到目标文件后,链接器会把这些目标文件和链接库链接在一起生成可执行程序。
整个过程如下:
下面来详细讲解编译和链接这两个过程
2.1编译
编译又可以细分为三步:预编译(也叫预处理),编译和汇编
2.1.1预编译
这步主要干三件事:
- 头文件的包含
- define定义标识符的替换
- 注释删除
以这样一个源文件为例:
#include <stdio.h>
#define SZ 10
//这是一个测试样例
int main()
{
for (int i = 0; i < SZ; i++)
{
printf("%d ", i);
}
return 0;
}
那么它经过预处理阶段后就会变成这样
xxxxxxxxxxxxxxx
xxxxxxxxxxxxxxx
xxxxxxxxxxxxxxx
xxxxxxxxxxxxxxx
int main()
{
for (int i = 0; i < 10; i++)
{
printf("%d ", i);
}
return 0;
}
其中“xxxxxxxx”是头文件里的内容,非常复杂,有几百行,同时我们还发现注释消失了,而且SZ
被替换成了10,这就是预处理阶段做的3件事。
总的来说,预处理就是做一些文本替换的工作
这个过程在VS这种集成开发环境下不太好验证,有兴趣的老铁可以在B站上搜相关的教程,在VScode上搭载gcc编译器,通过命令行的方式操作验证。
2.1.2编译
这个阶段是把c语言代码转换成汇编代码,放到一个xxx.s的文件中
没错,编译器不是直接将C代码转换成二进制指令的,中间还要先转换成汇编代码然后再向二进制语言过渡。
这个过程要干这些事:
- 语法分析
- 词法分析
- 语义分析
- 符号汇总
这些过程都很复杂,有兴趣可以看看《程序员的自我修养》,里面有详细的讲解。
这里简单提一下符号汇总,因为后面还会用到它。
符号汇总顾名思义,就是把符号收集起来,不过要加个限定,是把收集全局的符号。
以同一个工程中的test.c和add.c为例(下文会一直使用这个例子)
前面说过,编译是对各个.c文件进行单独地处理,所以会对test.c和add.c分开汇总符号。
test.s收集到的符号就是Add和main
add.s收集到的符号只有Add
2.1.3汇编
这一步会把汇编指令转换成二进制指令
经过转换后就得到了若干个目标文件,以上面的例子为例,就是test.o和add.o。
这个过程中还发生了重要的一步,形成符号表
在上一步编译阶段汇总了符号,这里就会把符号形成一个表,每个符号既有名字也有地址
例如:
test.o的符号表为
名称 | 地址 |
---|---|
Add | 0x000 |
main | 0x104 |
(因为Add在test.c中只有声明,所以找不到该函数的有效地址,故用空指针代替)
add.o的符号表为
名称 | 地址 |
---|---|
Add | 0x100 |
到了这步可能你还是不明白收集符号有什么意义,别着急,继续往下看。
2.2链接
这一步主要干两件事
1.合并段表
2.符号表合并和重定位
合并段表
目标文件已经是二进制文件了,这种目标文件是有格式的,以Linux环境下的目标文件为例,目标文件的格式elf这种格式,目标文件被分成一段一段的,可执行程序的格式也是elf,合并段表可简单理解为将对应的段合并形成新的段
符号表合并和重定位
例如,test.o有Add和main两个符号,add.o有Add这一个符号,那么合并后的符号表就包含Add和main两个符号。main好说,它的地址就是0x104,那么Add呢?这个时候就要符号重定位了,Add的地址应该是那个有效地址0x100
名称 | 地址 |
---|---|
Add | 0x100 |
main | 0x104 |
符号表用来干什么的呢?千呼万唤开出来。现在揭晓答案。
在链接这个阶段,符号表合并合并好之后,链接器会检查各文件内的符号是否在符号表中。比如我因为手误,在main函数里面写成了add()函数,链接器在符号表中查找,发现没有这个符号,就会给你报错。
至此也就能解释为什么同一个工程中的不同文件里面的全局变量/函数能互相使用了,就是因为符号表这个东西记录了各符号的地址,所以能通过符号表找到对应的函数和变量。
在我以前的文章中讲到过static的用法,若是在函数或全局变量前加上static,其它文件就无法使用它,这是因为改变了它的外部链接属性,现在理解起来就很清晰了。
static的用法
其它的文件中能不能访问这个符号,就是由该符号的链接属性决定的。正是因为它具有外部链接属性,才会进入符号表,通过符号表将它和其它文件链接起来,而改变外部链接属性后,这个符号压根就不会进入符号表,外部自然就访问不到它了。
三.总结
所有内容可用这样一张图来概括
本次分享结束,欢迎评论区留下宝贵意见。