gcc编译器,以及源文件的翻译
gcc与翻译
- 1、翻译的过程
- 1.1、预处理
- 1.1.1、两个小问题
- 1.2、编译
- 1.3、汇编
- 1.4、链接
- 1.5、一个小细节
- 2、条件编译
- 2.1、条件编译举例
- 2.2、理解条件编译
- 2.3、为什么要有条件编译
- 3、翻译的整个过程的由来
- 3.1、指令集简单理解
- 3.2、开关
- 3.3、二进制纸带编程
- 3.4、汇编语言
- 3.5、C语言等高级语言
- 4、编译器与语言的自举过程
- 5、链接的初步理解
- 5.1、库的简单认识
- 5.2、链接的简单认识
- 5.3、感性认识:动态库 vs 静态库 & 动态链接 vs 静态链接
- 5.3.1、第一个故事
- 5.3.2、第二个故事
- 5.3.3、总结
- 5.4、两个小细节
编译器的作用,是把源文件翻译成可执行二进制文件。其中:
- gcc:只能编译C程序
- g++:可以编译C / C++程序
1、翻译的过程
翻译的过程,大致分为:
- 预处理
- 编译
- 汇编
- 链接
1.1、预处理
预处理阶段,编译器对源代码进行:
- 头文件展开
- 宏替换
- 注释替换成空格
- 条件编译(之后会提及)
当前我们以准备好的code.c源代码为例,code.c里有头文件、宏、注释:
预处理
执行指令:gcc -E code.c,就完成了code.c的预处理工作:
但是这样只能将编译结果放在打印屏幕上。
我们可以执行指令:gcc -E code.c -o code.i,将预处理后的内容放到新建的文件code.i中:
vim打开code.i文件,就可以看到预处理之后的结果。
这里我们用到的选项有:
- -E:编译器开始翻译源文件,完成预处理就停下
- -o:将预处理结果,写入code.i文件
1.1.1、两个小问题
头文件展开?
我们之前认为,预处理阶段编译器会将头文件展开,可“展开”这一行为到底有什么含义?
我们可以vim打开文件/usr/include/stdio.h,通过将/usr/include/stdio.h文件与code.i进行对比,我们可以发现有许多相似之处。
Linux下编译器进行头文件展开,本质上是将/usr/include目录下同名头文件内容,拷贝到源文件中,从而进行头源合并。
其实学到后面我们就能体会到,只有预处理阶段才需要头文件。
预处理后的文件还是C语言文件吗?
答案是还是的,只不过更简洁了:注释被去掉了,宏被替换成了具体的内容,头文件也被替换拷贝上了具体的内容。
1.2、编译
编译的过程,是将预处理完的文件翻译成汇编语言的文件。
指令为:gcc -S code.i -o code.s
其中,
- -S:编译器开始翻译源文件,完成编译就停止。意味着我们不仅可以对已经完成预处理的code.i使用-S选项完成编译,还可以对源文件code.c使用-S选项一键编译。
- code.s:汇编语言文件的后缀一般是.s。
1.3、汇编
汇编的过程,是将汇编语言文件处理成目标文件(.o / .obj),目标文件又称为可重定位二进制目标文件。
指令为:gcc -c code.c -o code.o
其中,
- -c:编译器开始翻译文件,完成汇编就停止。
但是,我们无法执行code.o文件。比如在code.o文件中,我们想使用printf()函数,可是code.o文件里并没有printf()函数的定义。这时我们就需要将code.o与C标准库链接形成一个可执行文件。
1.4、链接
当我们执行当前目标文件需要第三方库的时候,我们就需要将当前文件与第三方库进行链接。
指令为:
gcc code.o -o code.exe
也可以一步到位:
gcc code.c -o code.exegcc -o code.exe code.c
这时code.exe就可以执行了。
1.5、一个小细节
-c选项
以后我们更不常使用预处理、编译指令,更常使用汇编指令生成目标文件。这是因为当我们有很多文件需要同时链接成一个可执行程序时,如果某一些文件需要修改,我们就可以针对那些文件专门进行修改、处理成目标文件,再链接在一起。
这样对于单个文件的操作,就避免了多次链接,并且使得修改过程更清晰,从而提高效率。
2、条件编译
预处理阶段编译器做的其中一件事是:处理条件编译。
2.1、条件编译举例
这里给出一段包含条件编译的C语言程序main.c:
我们可以简单解释上面出现的条件编译语句:
预处理阶段,编译器如果识别到宏V1,Version1()语句会被保留,Version2()语句会被裁剪;如果没有识别到宏V1,Version2()语句会被保留,Version1()语句会被裁剪。而#endif则会提醒编译器结束条件编译。
如何验证上面的解释?生成main.i文件查看即可。
main.c包含宏V1:
main.c不包含宏V1:
2.2、理解条件编译
通过上面的学习我们知道,条件编译的功能是在预处理阶段提示编译器进行代码裁剪。
其实,井号#以及之后的内容,比如#ifdef #else #endif,都属于预处理符,是为了提醒编译器要进行代码裁剪,以及提醒编译器如何裁剪代码。
2.3、为什么要有条件编译
我们之前使用的vs 2022,以及我们现在使用的Xshell,其实使用的是免费版 / 社区版,是不收费的。
而像vs 2022、Xshell及其它开发环境(软件),除了不收费的版本,还有付费版 / 企业版。
我们很容易想到,这些软件的免费版,相当于付费版去除了某些功能,只保留了基础的功能。
那么问题来了:这些软件大多数都是由C / C++语言编写的,那么这些设计并维护软件的厂商,到底是选择维护
- 一份免费版代码
- 一份付费版代码
还是选择只维护付费版代码,然后想办法给付费的用户开放所有功能,而给普通用户阉割掉一些高级功能?
当然是选择后者。因为选择前者意味着要维护两份代码(甚至多份代码),投入成本高;而选择后者,只需要维护好一份代码即可。
这时,条件编译裁剪代码的功能就派上用场了。
付费用户(不包含宏FREE)输出的结果:
注意到当前代码其实是没有宏FREE的。我们可以不通过vim改写代码,而是直接在Bash命令行上输入指令:gcc main.c -o main -DFREE=1,就可以添加宏FREE进入main.c:
这是免费用户(包含宏)的输出结果。
条件编译,不仅可以实现软件商业化的目的,还可以对源代码进行多功能开发,使之使用各种不同的场景。比如Linux源代码就使用了条件编译,所以Linux系统可以运作在服务器、车载系统、电视,以及"板子"上。
3、翻译的整个过程的由来
也许有人会好奇,源文件为什么要经过预处理、编译、汇编、链接这么多操作才能翻译成可执行文件(不可以直接由源文件直接一步到位翻译成可执行文件吗)?
这就需要我们稍微简单地回顾编译器的历史,以及编程语言的历史了。
3.1、指令集简单理解
首先我们可以来想象一个场景:
刚出生的小孩,什么也不会:不会站、不会坐、不会走路、不会抓握,也听不懂人说的话。经过父母一年的训练,这个小孩学会了基本的动作:坐、站、走路、抓握,以及语言的输入与输出。有一天,小孩的叔叔来到他家,对小孩说:小孩乖哦,给叔叔拿一杯水。小孩听到了叔叔的话,于是进行了一系列动作:站起来、走到桌前、拿起水杯、走到叔叔声旁,然后递水给叔叔。
在这个场景中,
- 小孩对应计算机中的CPU
- 父母给小孩进行训练,对应技术人员给一块裸的CPU硅片刻上指令集
- 叔叔给小孩发号施令,对应向计算机输入指令
- 小孩进行一系列动作,对应计算机CPU进行一系列计算操作
所以我们知道了,计算机CPU上存在指令集,指令集包含一系列操作指令。
由于计算机CPU只能读懂二进制代码,指令集包含的操作指令就是二进制代码。
3.2、开关
计算机最开始出现时,使用的是"开关"这么一个使用的技术。我们可以理解为:程序员利用通电与断电,表示出一个个二进制序列,进而表示一个个操作指令;将这些操作指令(以及数据)输入计算机,计算机就会进行一系列计算操作,得出结果。
但是,开关技术就将一些具体的功能写死了,换句话说,想要实现一种功能,就必须特别实现一种操作体系。这就导致,就算多种操作体系之间有相同的操作,我们也无法实现操作的复用,只能有几个问题就单独设计几个操作体系。
3.3、二进制纸带编程
当时的科学家觉得开关太死板了,于是他们想出了另一种办法:二进制纸带编程。
二进制纸带编程,就是在纸带上"打洞",将二进制指令刻在纸带上,然后通过传感器扫描纸带,将指令输入计算机进行操作,得出结果。
这意味着,操作指令的设计在纸带上完成就可以了,计算机只管对输入的指令进行操作。二进制纸带编程一定程度上提高了效率。
可是另一个不可忽视的问题日渐凸显:纸带上指令的设计,需要我们清楚操作对应的二进制指令。二进制指令都是01组成的序列,抽象无比,难以记忆。
有没有一种方法,能够将操作的二进制指令与操作本身的一些特性对应起来,以方便理解,比如用操作的单词代表操作指令?
3.4、汇编语言
后来人们又发明了汇编语言。汇编语言,就利用了指令的英文单词替代了指令的二进制序列。比如:
- mov:移动数据
- add:相加
- push:压栈
- call:调用函数
英文单词可比01序列方便记忆多了。汇编语言相当于对二进制指令集做了一层包装。
但是汇编语言还是麻烦了:我们可能需要几十条指令,才能完成一个简单的操作,比如变量的创建。
还有没有方法,使得程序的设计更贴合人类的语言习惯?
所以,C语言应运而生。
3.5、C语言等高级语言
C语言的使用更符合人类的语言习惯。然而C语言也有缺点与不足。
随着时代的发展,C++、java、python、PHP等一些高级语言也逐渐登上计算机编程历史舞台。
所以,C语言源文件的翻译并没有直接一步到位。我们可以将C语言翻译成汇编语言,然后借助之前积累的有关汇编语言的技术,最终完成源文件向可执行程序的转变。
4、编译器与语言的自举过程
汇编语言,本质上是一套规则。我们只有借助规则,才能设计出程序乃至项目,从而完成我们预期的目标。所以是先有汇编语言。
然而,当汇编语言刚出现的时候,由于还没有针对汇编语言的编译器,我们还无法对编写的汇编语言进行翻译,得到可执行文件。
这时,我们可以用低一级的语言,即二进制代码,设计出第一代汇编语言编译器。
以后,第一代汇编语言编译器逐渐完善,支持新功能,我们就可以按照新功能设计出第二代汇编语言;第二代汇编语言就可以设计出第二代汇编语言编译器;第二代汇编语言编译器又支持了新功能,就可以设计出第三代汇编语言…
也就是说,编译器支持新功能,汇编语言就可以增加新语法;增加了新语法的汇编语言,又可以反过来设计新的编译器。这就是编译器与语言的自举过程。
同理,C语言的第一代编译器是由汇编语言编写的,接下来我们就可以通过C语言与C语言编译器的自举,来进行C语言与C语言编译器的迭代更新。
5、链接的初步理解
源文件翻译的链接阶段中,需要将众多.o文件与链接库链接。那么这个库到底是什么?
5.1、库的简单认识
我们之前就提到过:我们在源文件代码中调用了printf()函数,而我们没有在源文件定义printf()函数,所以我们必须链接当前环境提供的C语言标准库,在标准库中有printf()的定义。
这个printf()的定义,也许是曾经某个参与C语言开发的顶级大佬设计的。现如今全世界的编写程序的人都在使用这个大佬写的printf(),而不是自己定义一个printf()。
所以,库本质上是程序员之间的一种协作方式。库能提高编程效率。
那么库在哪里?
其中,libc.so就属于C标准库。其实libc.so是C语言的动态库。
windows下也有库。以下是Linux与windows的库后缀的区分:
| 系统 | 动态库 | 静态库 |
|---|---|---|
| Linux | .so | .a |
| windows | .ddl | .lib |
我们也可以分析出Linux下库命名的规则:
5.2、链接的简单认识
当我们创建了一个C语言文件soft.c,编译后对可执行文件执行:ldd soft,我们会观察到:
ldd指令的作用是查看当前程序依赖的库。其实,有不少指令都依赖C / C++标准库。
我们之前提到过,库分为动态库和静态库,其中:
- 动态库:通常与程序进行动态链接
- 静态库:通常与程序进行静态链接
目前的知识,依旧无法支持我们较为全面地了解动态库 / 动态链接,与静态库 / 静态链接,我们就来感性认识它们:
5.3、感性认识:动态库 vs 静态库 & 动态链接 vs 静态链接
5.3.1、第一个故事
你是小于,你以优异的成绩考入了临川二中。中考之后的暑假,你既惊喜,又担忧,担忧什么呢:临川二中以半军事化管理方式、考试很多闻名当地,学校是肯定不能带手机的;而你又想很喜欢上网,很喜欢玩游戏。
于是你向老爸求助,老爸装模做样地说:“想玩游戏那怎么行~我给你介绍隔壁小帅,小帅今年高考,考入了清北大学。你向他取取经!”
于是小于你与小帅学长见了面。你对小帅学长说,你不担心自己的成绩,你只担心自己上学期间上不了网玩不了游戏。小帅学长说那好办,就直接给了你条详细的地址,那里是学校附近一所网吧。
开学后,小于你平时照常上课,周日时,你给自己制定了一个计划:
- 9:00 ~ 10:00 写作文
- 10:00 ~ 11:00 学习化学
- 11:00 ~ 12:00 学习数学
- 12:00 ~ 12:30 吃饭
- 12:30 ~ 14:30玩游戏
- 14:30 ~ 16:30 睡觉
- ······
问题是,学校不让带手机,学校里就玩不了游戏。你想起了小帅学长给的地址。你按照地址来到了网吧。你跟老板说你要上网,老板就领你到号码为1234的电脑前,让你开始上网。于是你度过了一个愉快的下午。
渐渐地,班上同学,以及其他班上同学都知道了这家网吧,纷纷都偷偷来这家网吧上网玩游戏.。后来的某一次月考,不少班级的成绩一落千丈,包括小于你所在的班级。临川二中的老师们都很生气,一问才发现成绩变差的学生都去过那家网吧频繁上网。于是老师们联名举报,帽子大院的帽子叔叔们就将网吧查封了。最终,同学们,包括小于,都上不了忘了。
在上面的故事中,
- 小于和每一个同学就是目标文件
- 临川二中就是内存
- 小于周天的一个个行程就是目标文件对应源文件的要执行的代码
- 网吧就是动态库;号码1234的电脑就是执行代码需要的库函数
- 小帅学长就是编译器中的链接器;小帅学长向小于提供网吧的地址,就是链接器对目标文件进行动态链接
其实,以这个故事类比动态链接,还是不太严谨。后面我们对于动、静态库会有更详细的讲解。
在这个故事中,我们也会发现动态链接的一个很突出的缺点:如果网吧被查封了,就上不了网;同理,如果动态库丢失,编译成的程序会无法执行。
5.3.2、第二个故事
网吧被查封了,小于你很苦恼:别人上网吧成绩掉了,我成绩又没掉,网吧没了我还怎么上网?
于是小于你告诉老爸,叫老爸想想办法。给力的老爸直接找到校长,软磨硬泡,让校长答应了在你的宿舍桌上安装电脑;与此同时,老爸联系了之前网吧被查封了的网吧老板,将你之前用过的1234号电脑直接买了过来,装在了你的宿舍。这样你就有了一台自己的电脑。
其他同学听说了,也想效仿小于你在宿舍装一台电脑。渐渐地,所有同学的桌前都有了一台电脑。
在这个故事中,
- 电脑就是静态库,一台台被需要的电脑,组成了静态库
- 老爸和网吧老板,就是(使用静态库的)链接器
- 给小于和其他每一个同学装电脑,就是在给目标文件进行静态链接
5.3.3、总结
讲到这,我们也许就能对动态库与静态库、动态链接与静态链接有了一个大概的认识:
- 动态链接,就是将库函数的地址写入到目标文件,然后与动态库链接到一起做成可执行程序。其中相同的库函数,在不同的程序中,是可以共享的。
- 静态链接,就是将库函数直接拷贝到目标文件,做成可执行程序。若不同的程序需要相同的库函数,那么连接后每一个程序都有独属于自己的库函数,尽管这些库函数都是完全一样的。
那么动、静态链接也就可以简单做一个比较:
| 连接方式 | 动态链接 | 静态链接 |
|---|---|---|
| 依赖库的程度 | 动态库丢失或者出问题,链接后的程序无法执行 | 静态库中库函数拷贝到了程序中 |
| 文件大小 | 较小 | 较大 |
5.4、两个小细节
如果删除C动态库,会怎样?
结果是很可能大部分指令用不了。因为大部分指令都是C / C++编写的。
gcc默认使用的是动态链接
我们可以理解为,系统认为动态链接形成的可执行程序体积更小,存储使用效率更高,于是系统默认就用动态链接。
强制进行静态链接需要加上选项-static。
有人可能静态链接完毕后,发现C / C++程序无法执行。大概率是系统没有静态C / C++库,可以搜索指令安装静态库。