【Linux系统】—— 编译器 gcc/g++ 的使用
【Linux系统】—— 编译器 gcc/g++ 的使用
- 1 用 gcc 直接编译
- 2 翻译环境
- 2.1 预处理(进行宏替换)
- 2.2 编译(生成汇编)
- 2.3 汇编(生成机器可识别代码)
- 2.4 链接
- 2.5 记忆小技巧
- 2.6 编译方式
- 2.7 几个问题
- 2.7.1 如何理解条件编译
- 2.7.2 为什么编译器要先将代码翻译成汇编语言
1 用 gcc 直接编译
我们平时学的 C/C++ 代码,都是文本文件,但是我们知道计算机只认识二进制,因此我们需要将C/C++代码翻译成二进制文件
在 Windows 系统中,编辑代码和翻译过程我们都是用 VS
进行的,因为 VS 是集成的IDE环境,那么在 Linux 中我们又该如何完成代码的翻译工作呢?
在 Linux 中,我们用到的编译器是 gcc/g++
,其中gcc
是对C语言
进行编译,g++
是对C++
进行编译。因为 gcc 和 g++ 的指令操作等完全一样,本文主要是用 gcc 进行演示。
我们创建一个 test.c 文件,写上代码:
#include<stdio.h>
int main()
{
printf("hello world\n");
printf("hello Linux\n");
return 0;
}
如何用 gcc 对代码进行编译呢?
指令如下:
「gcc」 「要编译的文件」
gcc 会默认生成一个叫 a.out
的可执行文件
那如果我想指定生成文件的名字呢?
有两种方法
「gcc 」「要编译的文件」 「-o」 「目标文件」
「gcc 」 「-o」 「目标文件」「要编译的文件」
但是,仅仅学会指令是远远不够的,我们学习 gcc/g++ 更重要的是学习翻译过程背后的过程,我们知道,我们写的 C语言 代码最终要形成可执行程序,要经过预处理
、编译
、汇编
、链接
这几个过程,下面我们通过 gcc,进一步认识这四个过程。
2 翻译环境
2.1 预处理(进行宏替换)
预处理阶段主要处理那些源文件中 # 开始的编译指令。比如:# i n c l u d e include include,# d e f i n e define define,处理的规则如下:
- 将所有的 # d e f i n e define define 删除,展开所有的宏定义
- 处理所有的条件编译指令,如:
#if
、#ifdef
、#elif
、#else
、#endif
- 处理 # i n c l u d e include include 预编译指令,将包含的头文件的内容插入到该预编译指令的位置。这个过程是递归进行的,也就是说被包含的头文件也可能包含其他文件。
- 删除所有注释
- 添加行号和文件名标识,方便后续编译器生成调试信息等
我们创建一个code1.c文件、写一段代码
这一小段代码,头文件、宏定义、注释以及条件编译都有了,正好可以看看预处理的效果。
我们如何看到预处理后的结果呢?我们来学一个选项 「-E 」
- 「-E 」:进行程序翻译,在预处理做完时停下来
指令如下:
「gcc 」「-E 」「要编译的文件」 「-o」 「目标文件」
注:预处理后的文件后缀为.i
我们用 vim 打开 code.i
来看看
- 我们发现注释不见了
- 我们定义的宏M和N,预处理后也消失不见了,M直接别替换成100,这叫做宏替换
- 并且
printf("hello N\n")
和printf("no N\n")
只剩下了printf("hello N\n")
。这是因为条件编译,我们定义了N(定义了就行,可以不写值),所以预处理后保留了printf("hello N\n")
- 为什么我们的文件变大了呢?根本原因就是头文件展开。 在编译的时候,只要预处理完了,头文件就可以不需要了。头文件展开的意思就是把你要包含的头文件全部拷贝至你的目标文件里,形成
.i
文件。这不过这个 .i 文件我们现在将其打印出来并且写到文件里了,如果不写的话它就是内存级的,在编译器内部。同时 <stdio.h> 头文件中也包含其他的头文件,因此它会类似递归式的拷贝
。
因此一个你可能只写了几百行的代码,预处理后可能有上千行。
我们 C语言 用到的众多头文件,在系统中默认都是安装了的,一般是存在 /usr/include
路径下
如:我们包含的头文件 <stdio.h> 一般是存在/usr/include/stdio.h
路径下
可以用 vim 打开来看看
里面的代码近 900 行,但是它有条件编译,同时 <stdio.h> 中本身也包含了其他的头文件。
这里,我问大家一个问题,预处理后的 code1.i
还是 C语言 吗?
答案:还是C语言
不过他是一个已经预处理过,是一个干净的C语言了。
2.2 编译(生成汇编)
编辑:将C语音翻译成汇编语言
编译过后,生成的汇编文件后缀为 .s
需要用到命令行选项 「-S 」
- 「-S 」:从现在开始进行程序翻译,在编译步骤做完时停下来 指令如下:
「gcc 」「-S 」「要编译的文件」 「-o」 「目标文件」
我们可以从 .c 到 .s 也可以从 .i 到 .s,因为之前已经做过 .c 到 .i 了,就不再重复做预处理步骤, 直接 .i
到 .s
我们用 vim 打开 code1.s
2.3 汇编(生成机器可识别代码)
汇编是指通过汇编器将汇编代码转变成机器可执行的指令,每一个汇编语句几乎都对应一条机器指令。就是按照汇编指令和机器指令的对照表一一的进行翻译,也不做指令优化。
先直接上指令
「gcc 」「-c 」「要编译的文件」 「-o」 「目标文件」
如:
gcc -c code1.s -o code1.o
- 「-c 」:从现在开始进行程序翻译,在完成汇编后停下来
以 .o
为后缀的文件全称叫:可重定位目标文件,也就是我们所说的目标文件。目标文件在 Windows 系统下是以 .obj
结尾的
目标文件是二进制文件,因此我们打开它是啥都看不懂的
虽说 code1.o
已经是二进制文件,但是它还是无法被执行的。因为目标文件仅仅是将我们自己写的代码编成二进制了,可我们的程序中还包含着许多库方法,如printf、scanf、STL容器,此时我们的程序还没有和库方法关联起来,比如我们用了 printf 方法,可我们根本没有 printf 方法的实现,所以我们的目标文件是跑不动的。
所以我们的程序还要经过最后一步:链接,才能形成可执行文件
2.4 链接
链接过程没有命令行选项
指令如下:
gcc code1.o -o code1
这里我们并没有指定去链接哪个库,因为我们现在的代码里没有使用任何的第三方库,我们用的都是C语言标准库的方法,gcc会帮我们去系统里找我这个程序用了 C语言 的哪个标准库。但如果我们要依赖某个第三方库,就需要指定去链接了,这点我们以后再介绍。
生成可执行序后,程序就可以运行了
2.5 记忆小技巧
好像预处理、编译、汇编这三步的命令行选项很难记?有什么记忆方法吗?
他们分别是 「-E 」「-S 」「-c 」
合起来就是键盘左上角的「esc」键,我只需要记住前两个是大写的就行了。
而预处理、编译、汇编这三步生成的文件后缀又怎么记呢?
他们分别是『.i』『.s』『.o』
连起来就是iso,我们可以记ios,再将后面两个反过来
2.6 编译方式
一般我们在编译文件时,不会像上面一样 .i
、.s
、.o
全部生成一遍,上述这样做只是为了然我们了解整个翻译的过程。
我们编译文件的习惯是将所有的文件生成 .o 文件,再将所有相关的 .o 文件一起打个包生成可执行文件
为什么喜欢这么做呢?
主要原因是:
- 编译器在编译时,不仅仅要形成可执行程序,还可能要形成库(所谓的库其实就是把
.o
文件了个包),如果要形成库的话就不需要编译性成可执行程序 - 我们目前使用的 VS 最终就形成一个可执行程序,但往往实践中可能形成 10 个、100 个可执行程序,可能你有 1000 个源文件,其中 100 个形成程序A、50 个形成程序B、60 个形成程序C……我们需要将所有的 .o 做自由组合,形成多个可执行。在编译角度,我们可先将你们全部变成
.o
,最后如何形成可执行,再自己做组合
为什么要有链接步骤呢?
这是因为我们要站在巨人的肩膀上。
例如我们要用到的输入输出函数,要是自己来写的话那太费劲了,每做一个项目都要自己先敲一个函数出来,而且写出来也不够好,容易出问题。因此C语言将最基本的功能给我们全部开发好,再打成包,这个包就是库
。
解下来我们写代码时,我们只需要将自己的代码编译好,和C语言标准库链接形成可执行就行
有小伙伴可能会问:预处理时不是已经展开头文件了吗?为什么还要链接呢?
预处理展开的仅仅只是声明
,因为头文件时公开
的。
其实我们包的头文件源代码都是公开的,只有声明没有实现
,实现在对应的同名 .c
文件里。.c 文件 C语言 没有给你暴露出来,直接编成库了。
要最终形成可执行,重要的是方法
,而链接就是将方法找到
当然,上述讲的只是一般情况,你要是不喜欢也可以一次就形成可执行文件
2.7 几个问题
2.7.1 如何理解条件编译
我们创建 code.c 文件,写下如下代码
根据我们前面的知识,我们知道此时我们并没有定义M,执行的应是printf("社区版/免费版 version1\n")
语句
我们执行一下看看
gcc 编译时支持我们用 「-D 」 来进行命令行级别对指定源代码进行动态添加宏
如:
定义加写值
- gcc
gcc code.c -o code.exe -DM=1
只定义不写值
- gcc
gcc code.c -o code.exe -DM
gcc不用「-D 」选项定义宏它又会变成免费版
gcc这合理吗?其实是合理的
gcc编译器进行编译时第一步就是预处理,预处理的本质其实就是 让编译器编辑(修改)我们的代码! 既然预处理时,编译器能去注释,能进行宏替换,那么编译器将命令行中的 -DM
解释成 #define M
,并将其当做字符串插入到我的代码当中不过分吧。
「-D 」相当于在命令行给代码定义宏
相信对条件编译大家都能理解,大家不理解的是条件编译的用途。下面我们简单来了解一下条件编译的应用场景
-
对一款软件通过专业读、收费标准等进行区分,使用条件编译,进行代码的动态裁剪:
我们平时看到的某些软件,像VS、Xshell等,往往都分为专业版和社区版(收费版和免费版)。他们两者的区别主要是在功能方面,比如收费版支持100个功能,而免费版只支持50个功能。
这些软件也都是程序员开发的,那么程序员在维护这款软件需要维护几份源代码呢?毕竟这款软件有两个版本。
事实上,如果将同一款软件的免费版和收费版当成两个项目来看,那么公司就需要有两套班子,但其实他们功能上无非就是收费版上做一下功能的裁剪就是免费版。
所以在公司内部我们只需要维护一份源代码即可,最终在发布的时候只需要告诉别人编译这个代码时,编译成免费的还是收费的。怎么才能做到这点呢?我们可以将软件中的功能拆分一下:公共都有的放在一个模块里,需要收费的放在一个模块里,最后用条件编译将其维护起来。
这样一份代码通过条件编译就能对其进行裁剪,从而实现对内只需维护一份源代码,对外实现多份版本的目的。 -
Linux 内核源代码也是采用条件编译进行点裁剪
我们的 Linux 内核,编译好了其实体积还是很大的,但有些功能在很多的小型设备上:嵌入式设备、智能家电等,上面根本就不需要Linux支持那么多功能,这时就可以用条件编译实现代码的动态裁剪
当然,条件编译的功能远远不仅于此,但大多应用场景离我们现在的水平太远,感兴趣的小伙伴可以自行深入了解。
2.7.2 为什么编译器要先将代码翻译成汇编语言
C语言翻译成二进制指令相信大家都能理解,因为机器只认识二进制。但为什么编译器要先将C语言翻译成汇编语言,再将汇编语言翻译成二进制呢?
为什么计算机只认识二进制
简单来说是因为 0 和 1 是最简单的硬件电路,简单就意味着可靠,计算机通过与非门各种各样的门电路组合成各种复杂电路。
这里讲一下计算机的发展史:
计算机都是要进行输入输出的:我们将数据喂给它,它处理完后将结果返回。我们编程的本质就在在控制计算机,我们编译代码其实就是在要求计算机帮我们做这做那
早期的计算机都是非常大的,而且其运算力非常差。早期我们没有编程,控制计算机用的是计算机上的开关,早期的计算机科学家都是在计算机前掰来掰去的,其实就是在通过开关来给计算机输入 0 和 1
后来人们觉得开关的方式不太好,到了五六十年代,人们开始用打孔编程。
打孔纸带
打了孔的地方,光能透过去,我们认为是1,否则为0。《三体》中,叶文洁向外星人发送信号时,手上捏着一条纸带,就是这个东西
但打孔编程本质依然是二进制编程,二进制编程可是很恶心的。而且打孔打错了,纸就报废了,要重新打孔,浪费纸张不说还效率低下。后来人们发明了一种编程语言:汇编语言
用汇编语言控制计算机效率无疑比直接二进制编程高很多。
从我们的汇编语言开始,就需要一个东西:编译器
。因为汇编语言本质上也是文本,所以我们需要一种编译器将汇编语言编译成对应的二进制。
这里有个问题:第一个汇编语言编译器是用什么写的呢?
这个编译器要编译汇编语言,那编译器自己应该用什么语言来写呢?
用汇编吗?你用还没法翻译成二进制的汇编,来将汇编翻译成二进制,这不鸡蛋和鸡吗?
因此第一版编译汇编的编译器,是用二进制写的。先用二进制写一个二进制版的汇编编译器。有了第一个编译器,此时就可以编译汇编语言了,此时我们就可以用汇编语言写一份汇编版本的编译器,第一版的编译器就可以不要了,此后我们就可以用自己语言写的编译器编译自己语言。这个过程叫做编译器的自举过程!
不仅如此,语言也是可以自举的。比如C++推出C++11,但此时的编译器只支持C++98,这时就可用98写个能编11的编译器,再用C++11进行重写
最早期的,比较好的操作系统叫 Unix,它第一版本就是由肯·汤普逊用汇编语言写出来的,后来丹尼斯里奇发明了 C语言,肯·汤普逊和丹尼斯里奇即一起用C语言把 Unix 进行重构,发布的 C语言 版本的 Unix。我什么肯·汤普逊最开始用汇编语言写,因为最开始只有汇编,后来 C语言 出来了,C语言 对应的编译器也诞生了,为了代码本身的可维护性,他就把 Unix 操作系统调整为 C语言 了。
再后来,人们觉得汇编语言也太麻烦,所以基于汇编语言产生了许多分支,编译型语言在那个阶段就开始爆发了。最典型的就是 70 年代产生的 C语言,再到后来的 C++/java/go
现在有了 C语言,C语言 最终肯定也要翻译成二进制。现在的问题是,我们是直接将C语言翻译成二进制还是先翻译成汇编语言再翻译成二进制。
我们肯定会选择方案二。为什么呢?
首先将C语言翻译成汇编语言,毕竟还是从文本到文本,它的翻译难度相对较低;其次,在C语言产生之前,汇编语言已经发展了很多年了,我们只需要将C翻译成汇编,而将汇编翻译成二进制这项工作已经发展的很成熟,可以不用做了,我们可以站在巨人的肩膀上。
如果直接将 C 翻译成二进制,那么翻译的成本会特别高,而且 C++ 等后来者是基于C语言发明出来的,你让我 C++ 怎么办,难道我 C++ 也要直接翻译成二进制吗?
我们要学会站在巨人的肩膀上,计算机每一阶段的发展都经过了十几年,我们要将每一阶段的发展好好用上。
而编译是逆历史的过程:C语言 -> 汇编 -> 二进制
好啦,本期关于编译器 gcc/g++ 就介绍到这里啦,希望本期博客能对你有所帮助。同时,如果有错误的地方请多多指正,让我们在 Linux 的学习路上一起进步!