【Linux系统】—— 编译器 gcc/g++ 的使用

【Linux系统】------ 编译器 gcc/g++ 的使用

  • [1 用 gcc 直接编译](#1 用 gcc 直接编译)
  • [2 翻译环境](#2 翻译环境)
    • [2.1 预处理(进行宏替换)](#2.1 预处理(进行宏替换))
    • [2.2 编译(生成汇编)](#2.2 编译(生成汇编))
    • [2.3 汇编(生成机器可识别代码)](#2.3 汇编(生成机器可识别代码))
    • [2.4 链接](#2.4 链接)
    • [2.5 记忆小技巧](#2.5 记忆小技巧)
    • [2.6 编译方式](#2.6 编译方式)
    • [2.7 几个问题](#2.7 几个问题)
      • [2.7.1 如何理解条件编译](#2.7.1 如何理解条件编译)
      • [2.7.2 为什么编译器要先将代码翻译成汇编语言](#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 文件,写上代码:

c 复制代码
#include<stdio.h>
 
int main()
{
     printf("hello world\n");
     printf("hello Linux\n");
     return 0;
}

如何用 gcc 对代码进行编译呢?

指令如下:

  • 「gcc」 「要编译的文件」

gcc 会默认生成一个叫 a.out 的可执行文件

那如果我想指定生成文件的名字呢?

有两种方法

  1. 「gcc 」「要编译的文件」 「-o」 「目标文件」
  2. 「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 来看看

  1. 我们发现注释不见了
  2. 我们定义的宏M和N,预处理后也消失不见了,M直接别替换成100,这叫做宏替换
  3. 并且printf("hello N\n")printf("no N\n")只剩下了printf("hello N\n") 。这是因为条件编译,我们定义了N(定义了就行,可以不写值),所以预处理后保留了printf("hello N\n")
  4. 为什么我们的文件变大了呢?根本原因就是头文件展开。 在编译的时候,只要预处理完了,头文件就可以不需要了。头文件展开的意思就是把你要包含的头文件全部拷贝 至你的目标文件里,形成 .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 文件一起打个包生成可执行文件

为什么喜欢这么做呢?
主要原因是:

  1. 编译器在编译时,不仅仅要形成可执行程序,还可能要形成库 (所谓的库其实就是把 .o 文件了个包),如果要形成库的话就不需要编译性成可执行程序
  2. 我们目前使用的 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 」 来进行命令行级别对指定源代码进行动态添加宏

如:

定义加写值

  • gccgcc code.c -o code.exe -DM=1

只定义不写值

  • gccgcc 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 的学习路上一起进步!

相关推荐
laimaxgg6 分钟前
Linux关于华为云开放端口号后连接失败问题解决
linux·运维·服务器·网络·tcp/ip·华为云
浪小满8 分钟前
linux下使用脚本实现对进程的内存占用自动化监测
linux·运维·自动化·内存占用情况监测
Ritsu栗子13 分钟前
代码随想录算法训练营day35
c++·算法
东软吴彦祖22 分钟前
包安装利用 LNMP 实现 phpMyAdmin 的负载均衡并利用Redis实现会话保持nginx
linux·redis·mysql·nginx·缓存·负载均衡
好一点,更好一点23 分钟前
systemC示例
开发语言·c++·算法
卷卷的小趴菜学编程44 分钟前
c++之List容器的模拟实现
服务器·c语言·开发语言·数据结构·c++·算法·list
年轮不改44 分钟前
Qt基础项目篇——Qt版Word字处理软件
c++·qt
艾杰Hydra1 小时前
LInux配置PXE 服务器
linux·运维·服务器
多恩Stone1 小时前
【ubuntu 连接显示器无法显示】可以通过 ssh 连接 ubuntu 服务器正常使用,但服务器连接显示器没有输出
服务器·ubuntu·计算机外设
慵懒的猫mi1 小时前
deepin分享-Linux & Windows 双系统时间不一致解决方案
linux·运维·windows·mysql·deepin