Linux:编译器gcc/g++ 与 Makefile 构建
1. 背景知识
1.1 编译器是什么
编译器就是一个翻译工具,专门把程序员写的高级语言代码(C/C++/Java 等),翻译成计算机能直接看懂的二进制机器指令。
作用
- 把人能看懂的代码,变成电脑能执行的程序。电脑不认识
if、for、cout,只认识 0 和 1,编译器负责做这个转换。 - 检查语法错误。代码写错了,编译器会报错,告诉你哪里不对。
- 生成可执行文件。编译完成后,会得到
.exe或 Linux 下的可执行程序,双击 / 运行就能用。
1.2 gcc和g++的区别
gcc
gcc 是 GNU Compiler Collection(GNU 编译器套件) 的简称,默认用于编译 C 语言 程序。它会按 C 语言的语法规则进行编译,不会自动链接 C++ 相关库。
g++
g++ 是专门针对 C++ 的编译器,本质也是 gcc,但默认启用 C++ 语法检查,并自动链接 C++ 标准库(如 iostream、string 等)。
总结来说就是:写 C 用 gcc,写 C++ 用 g++,g++可以兼容gcc。
1.3 程序的编译链接过程
因为计算机无法直接看懂人类的语言,所以会有一个程序的编译链接过程,就是把程序员写的源代码,经过预处理、编译、汇编、链接四步,一步步转换成计算机可直接运行的可执行程序的完整过程。
- 预处理:处理
#include、#define等宏指令,展开头文件、替换宏、删除注释。 - 编译:把预处理后的代码翻译成汇编语言。
- 汇编:把汇编代码翻译成二进制机器码,生成目标文件(.o/.obj)。
- 链接:把多个目标文件和系统库拼在一起,解决函数调用地址,最终生成可执行程序。
2. gcc 编译选项
我们用一个文件来逐步讲解这四个过程的编译选项:

这个 code.c 文件当中包含了头文件、宏、注释以及正常的语句。如果我们直接执行指令: gcc code.c 的话,编译器会直接将 code.c 编译成一个可执行程序,但因为我们要一步一步的观察编译器所做的四个工作,所以需要加上特殊指令。
2.1 预处理
实例: gcc -E code.c -o code.i
选项 “-E”,该选项的作用是让 gcc 在预处理结束后停止编译过程。
选项 “-o” 是指目标文件,“.i” 文件为已经过预处理的 C 原始程序。

这个 code.i 就是编译器帮我们生成的预处理之后的文件,接着我们使用 vim 将 code.i 打开:

打开之后大家会看到非常一大串的代码,代码行数来到了八百多行,其实这就是将头文件 stdio.h 展开后的内容。然后大家会发现,一开始在代码中的 M ,已经被替换成了对应的数字 100 ,并且注释也消失不见了。
2.2 编译
在这个阶段中,gcc 首先要检查代码的规范性、是否有语法错误等,以确定代码的实际要做的工作,在检查无误后,gcc 把代码翻译成汇编语言。
用户可以使用 -S 选项来进行查看,该选项只进行编译而不进行汇编,生成汇编代码。
实例:gcc -S code.i -o code.s

这就是执行完编译指令后,形成的汇编语言。
2.3 汇编
汇编阶段是把编译阶段生成的 .s 文件转成目标文件。
大家在此可使用选项 -c 就可看到汇编代码已转化为 .o 的二进制目标代码了。
.o 的意思就是:可重定位目标二进制文件。
实例: gcc -c code.s -o code.o

大家把这个文件打开之后,就会看到许多“乱码”,这个文件里的 “乱码” 本质就是二进制机器码被文本编辑器强行显示成字符的结果。当我们用 Vim 这类文本编辑器打开它时,编辑器会尝试把每一个字节都解释成 ASCII / UTF-8 字符。但机器码里的很多字节值(比如 0x00、0x01 这类控制字符、非打印字符)在字符集中没有对应可显示的符号,就会显示成乱码、特殊符号或者空白。图中能看到的 ELF、GCC、hello world %d、code.c、main、printf 这些字符串,是文件里的元信息、字符串常量和符号表,它们是可读文本,所以能正常显示,而其他机器指令部分就表现为乱码。
2.4 链接
在成功编译之后,就进入了链接阶段。
实例: gcc code.o -o code.exe
此时我们运行这个 code.exe 就得到了我们代码中执行之后的结果。

链接就是把「零散的二进制目标文件 (.o)」和「系统库文件」拼在一起,修补好函数地址,最终生成一个能独立运行的可执行文件 (.exe/ 可执行程序)。
链接的具体作用用下面的例子举例:
printf("hello");
这是一个c语言语句,当汇编后,将代码转化成机器能读懂的二进制语言,此时机器只知道:我要调用一个内存里面的叫 printf 的东西,但不知道:它在内存的哪个位置?所以 .o 是残缺的。
当执行链接的时候,链接器去系统库里找到:printf 真实地址 = 0x7fff1234,然后把你的代码改成:
调用 0x7fff1234
此时地址补齐了 → 才能运行!
2.5 一个小问题

我们使用 ldd 指令去查看 code.exe 这个文件依赖哪些库,其中我们看到了 /lib64/libc.so.6 这就是C语言标准库。这个时候,我们再去回想一下,我要写一个C语言程序,需要哪些东西?1. 编译器;2. 头文件;3. 库文件。 头文件实际上就相当于函数的声明,库文件就相当于函数的定义。所以我们平时在程序中包含头文件,就能使用对应的函数。
那为什么在Linux系统中也能编译运行C语言的程序呢?这就是因为,Linux系统中,包含gcc这个编译器,并且系统中有C语言的头文件以及库文件。
3. 静态库和动态库
3.1 动静态库的概念
静态库
是在编译链接阶段就将库中代码完整复制到可执行文件中的库。它让程序运行时不再依赖库文件,移植更方便,但会使可执行文件体积变大,且库更新后程序必须重新编译才能使用新版本。
动态库
也叫共享库,在程序运行时才加载库代码。它允许多个程序共享同一份库文件,可执行文件体积更小,库更新后程序无需重新编译(兼容前提下),但运行时必须保证系统中存在对应的库文件,否则会报错。
静态库和动态库都有各自特殊的后缀:

在不同的系统下, 后缀也不一样。
当然在Linux系统中,一个库的完整命名规则是:lib[ 库名称 ] .so / .a
也就是说,比如有这样一个库名称:libc-2.16.so 那么这个库的真实名称就是 c-2.16
3.2 静态链接和动态链接
在我们的实际开发中,不可能将所有代码放在一个源文件中,所以会出现多个源文件,而且多个源文件之间不是独立的,而会存在多种依赖关系,如一个源文件可能要调用另一个源文件中定义的函数,但是每个源文件都是独立编译的,即每个.c文件会形成一个.o文件,为了满足前面说的依赖关系,则需要将这些源文件产生的目标文件进行链接,从而形成一个可以执行的程序。这个链接的过程就是静态链接。静态链接的缺点很明显:
- 浪费空间:因为每个可执行程序中对所有需要的目标文件都要有一份副本,所以如果多个程序对同一个目标文件都有依赖,如多个程序中都调用了
printf()函数,则这多个程序中都含有printf.o,所以同一个目标文件都在内存存在多个副本; - 更新比较困难:因为每当库函数的代码修改了,这个时候就需要重新进行编译链接形成可执行程序。但是静态链接的优点就是,在可执行程序中已经具备了所有执行程序所需要的任何东西,在执行的时候运行速度快。
动态链接的出现解决了静态链接中提到的问题。动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。
动态链接其实远比静态链接要常用得多
所以总结一下:
静态链接
静态链接是在编译链接阶段,将静态库(.a/.lib)中的代码完整复制到可执行文件中的链接方式。它的优点是程序运行时不依赖外部库文件,移植性强、运行稳定;缺点是可执行文件体积大,且库更新后必须重新编译才能使用新版本,多个程序共享同一份库代码时会造成冗余。
动态链接
动态链接是在程序运行时才加载动态库(.so/.dll)的链接方式,也叫运行时链接。它的优点是可执行文件体积小,多个程序可共享同一份库文件以节省内存和磁盘空间,库更新后程序无需重新编译即可自动使用新版本(兼容前提下);缺点是运行时必须依赖系统中对应的库文件,否则会报错 “找不到库”,移植时需额外携带或安装依赖库。

这里的可执行程序 code.exe 显示的就是动态链接Dynamic linked。这就说明,gcc在编译的时候,默认的就是动态链接。
而编译程序的时候,我们通常使用动态库和动态链接,这是最佳选择。

如果使用静态库进行静态链接的话,我们会发现,静态链接得到的可执行程序 code-static 的内存占用量非常大,是静态链接的一百多倍。
当然想要使用静态库进行静态链接,必须要先安装静态库,一般默认是没有安装的,下面是安装指令:yum install glibc-static libstdc++-static -y
4. 自动化构建-make/Makefile
4.1 背景知识
make 是一个自动化构建工具,Makefile 是它的规则配置文件。一个工程中的源文件不计数,其按类型、功能、模块分别放在若干个目录中,makefile 定义了一系列的规则来指定,哪些文件需要先编译,哪些文件需要后编译,哪些文件需要重新编译,甚至于进行更复杂的功能操作。我们在 Makefile 里写好,然后执行 make,工具就会自动按规则完成编译、链接等操作,不用每次手动输入一长串命令,这就是自动化构建。
因此,会不会写 makefile,从一个侧面说明了一个人是否具备完成大型工程的能力
makefile 带来的好处就是 ——“自动化编译”,一旦写好,只需要一个 make 命令,整个工程完全自动编译,极大的提高了软件开发的效率。
一般来说,大多数的 IDE 都有这个命令,比如:Delphi 的 make,Visual C++ 的 nmake,Linux 下 GNU 的 make。可见,makefile 都成为了一种在工程方面的编译方法。make 和 makefile 两者搭配使用,完成项目自动化构建。
生动形象一点的理解就是:make就像黑心老板,只看结果不看过程;makefile就像任劳任怨的牛马,make有什么需求,makefile文件里就要写什么。
4.2 基本使用
首先我们创建一个文本文件 Makefile ,使用 vim 打开之后,写入这样的语句:

这就表示,要通过 code.c 文件形成一个 code.exe 文件,如何形成呢?就在下面写入我们的指令代码。
接着使用 make 语句,编译器自动执行我们刚刚在Makefile文件中写的内容, 就生成了 code.exe 可执行程序。

这个可执行程序就是可以正常运行的:

4.3 依赖关系和依赖方法
4.3.1 基本概念
那么在上图中,我们makefie中的第一行和第二行指令,就分别代表依赖关系和依赖方法:

这表示 code.exe 这个文件,依赖于 code.c 这个文件,那么依赖方法就是下面的指令。
标准的格式如下图所示:
目标文件: 依赖文件1 依赖文件2 ...
命令(必须以 Tab 开头,不能用空格)
要注意这里从第一行末尾按下Enter键进入第二行,当要写依赖方法的时候,必须要先按下Tab键,不能使用空格。这是语法规定:如果这一行没有 Tab,make 会把它当成 “普通命令” 强行执行。当你只写了一个依赖关系和依赖方法的时候,如果没加 Tab ,可能不会出现什么问题,但如果makefile文件里面有多组依赖关系和依赖方法,那程序可能就崩溃了。
另外,依赖关系和依赖方法都必须具有合理性。
这就好比:你是一个大学生名叫张三,今天已经是月底了,你想打电话给你爸张大要生活费,这个时候,你说:老登,给我爆点金币玩玩,你爸张大可能会飞奔到学校给你一巴掌,但是如果你诚恳真挚的说:爸,月底了没钱了,能不能给我下个月的生活费。这时候你爸就会给你打钱。
这就是依赖方法的合理性。
那如果证明依赖关系的合理性呢?你还是大学生张三,月底了你又没钱了,但是你没有给你爸张大打电话,而是给你舍友李四的老爸打了电话,你诚恳真挚的说:李叔叔,你能不能给我点生活费。虽然你很诚恳,但李叔叔还是可能会以为你是不是神经病。
这就是依赖关系的合理性。
4.3.2 多级依赖(链式依赖)
工程中会形成依赖链,make 会递归处理:

比如我们将一个可执行程序的每个流程都写进 Makefile 文件当中,这就形成了依赖链。并且递归的过程是这样的:
当你执行 make(默认生成第一个目标 code.exe)时,它会递归检查依赖,步骤如下:
-
要生成
code.exe- 发现依赖
code.o - 检查
code.o是否存在 / 是否比code.exe新 - 如果需要更新,就先去处理
code.o的规则
- 发现依赖
-
要生成
code.o- 发现依赖
code.s - 检查
code.s是否存在 / 是否比code.o新 - 如果需要更新,就先去处理
code.s的规则
- 发现依赖
-
要生成
code.s- 发现依赖
code.i - 检查
code.i是否存在 / 是否比code.s新 - 如果需要更新,就先去处理
code.i的规则
- 发现依赖
-
要生成
code.i- 发现依赖
code.c - 检查
code.c是否存在 / 是否比code.i新 - 如果需要更新,就执行
gcc -E code.c -o code.i生成code.i
- 发现依赖
-
回溯执行
code.i生成后,回到code.s规则,执行gcc -S code.i -o code.scode.s生成后,回到code.o规则,执行gcc -c code.s -o code.ocode.o生成后,回到code.exe规则,执行gcc code.o -o code.exe
此时再执行make指令,就会生成 code.i、code.s、code.o 和 code.exe 四个文件:

上面解释的仅仅是递归的流程,但是实际上,make会在递归依赖的时候帮我们维护一个推导栈:

要注意的是:不带参数的 make 只会默认构建第一个目标(以及它的依赖链),其他目标都需要手动指定,因为在这里我们Makefile文件中的第一个目标是 code.exe ,而这个目标有自己的依赖链,所以可以自动执行。但如果 code.exe 不是第一个目标的时候,我们就需要显式调用 ,写成:make code.exe 才行。
4.4 清理
在 Linux 工程中,编译会生成大量中间文件(.i、.s、.o)和可执行文件,这些文件不仅占用磁盘空间,还可能因增量编译导致 “脏状态”—— 比如旧的中间文件与新代码、新编译选项或库版本不兼容,引发编译结果不一致、符号重复 / 未定义等问题;同时,残留文件也会干扰版本管理与源码分发。
因此需要通过make clean这类操作清理所有编译产物,重置编译状态,保证项目目录清爽、编译结果可靠,避免残留文件带来的各类问题。
而将clean声明为.PHONY 伪目标,可确保清理命令总能被执行,避免与同名真实文件冲突。
4.4.1 .PHONY伪目标
.PHONY 是 Makefile 里的一个关键字,用来声明一个目标不是真实文件,而是一个 “要执行的操作”。
因为Make 工具默认认为:目标 = 要生成的文件
如果你写:
clean:
rm -f code.exe
Make 会以为你要生成一个叫 clean 的文件。
如果你的目录里真的有一个文件叫 clean,Make 发现文件已存在,且没有依赖更新,就会拒绝执行命令,输出:make: 'clean' is up to date.
这就会导致清理命令无法执行。但是加上.PHONY 之后:
.PHONY: clean
clean:
rm -f code.exe
此时Make 就知道:clean 不是文件,只是一个操作名。
于是:
- 不管有没有同名文件
- 不管文件新不新
- 只要输入 make clean,就一定执行命令
这就是伪目标的作用。

并且 clean 是一个伪目标(被 .PHONY 声明),它不属于默认构建的目标链,也不是生成文件的步骤。所以如果 clean 不是在第一个目标,那么只有当你显式指定目标名(make clean)时,Make 才会去执行 clean 下面的 rm 命令,删除所有编译产物。
那么 .PHONY 到底是怎么做到让系统可以无条件的一定一直执行命令呢?
那么首先大家要明白一件事情,就是: 文件 = 文件内容 + 文件属性

其次大家看,当我连续输入 make 的时候,系统就说 code.exe is up to date 意思是:当前文件已经是最新了,不能再继续make了。系统之所以能判断出来这个可执行程序已经是最新了,是因为它会去拿 code.c 和 code.exe 的 Modify 时间进行比较。
因为如果一个 code.c 文件的内容被修改之后,Modify 时间就会同步修改,此时修改之后的 code.c 的 Modify 时间就会比原来的 code.exe 的 Modify 时间更新,系统就会认为你做出了修改,那就要给你重新编译,形成新的可执行程序。

大家可以很清楚的看到,code.exe 的Modify时间比 code.c 的 Modify 时间更新。所以系统就认为,源文件没有做出修改,就没必要进行重新编译,否则就会浪费资源浪费内存。
所以 Modify 时间就是 文件内容 的修改时间,而文件属性的修改时间就是 Change :

比如我现在把 code.exe 的拥有者的读权限去掉了。那么 code.exe 的 Change 就发生了新的变化。但如果我是直接修改了 code.exe 的内容的话,其实大家会发现 Modify 和 Change 的时间都会变化,因为修改内容会直接影响 Modify 的时间,而因为内容修改了,所以文件的大小也会被修改,同时Modify时间也算是文件的一个属性,所以会进一步影响 Change 的时间。
说到这里,大家应该就能明白,之所以 ,PHONY 能让命令可以一直执行,实际上就是让 gcc/g++ 忽略新旧文件的Modify时间的新旧关系。
4.5 makefile中的语法细节
4.5.1 取消回显

大家会发现,当我们调用 make 命令的时候,显示器上会打印出我们在 makefile 文件中的依赖方式,如果不想要打印出来,我们可以使用这个方式:

即在依赖方式前面加上 @ 符号,那么再使用 make 命令:

那么执行 code.exe 的文件因为被修饰,所以没有回显,而clean的依赖方式依然回显了出来。
通过这种方式,我们就可以让我们的 make 指令变得更加可视化:


4.5.2 定义变量
因为目标文件和依赖文件有的时候需要频繁更改,为了节省时间提高效率,我们可以使用定义变量的方式:

要注意格式,定义的变量名不需要类型,并且变量名都可以自定义,主要是在依赖关系中的写法问题,一定要加上 $( ) ,意思是提取这个变量的内容:

除了这个写法,我们还有另一种写法:

在Linux系统当中,还有一些内置变量,比如:$@: 代表目标文件名。 $^: 代表依赖文件列表,所以我们除了可以使用自定义变量名,还可以使用系统中的内置变量。
4.5.3 其他的语法

现在有一个场景,我们有多个文件需要全部编译链接成可执行程序,那么在makefile文件当中,我们是应该要把这所有的文件都一一输入在依赖文件当中吗?那么如果有100个文件,1000个文件呢?
所以这时候就有了新的语法:

这里的 shell ls *.c 的意思就是:调用 shell 命令里面的 ls ,让它把当前目录下的所有以 .c 为后缀的文件全部展示出来,再搭配上 SRC=$( ) ,就相当于把这些文件全部提取到 SRC 这个变量中。我们还写了一段测试代码,看看是否真的把这些文件提取到SRC中了。

结果确实如此。然后我们再使用批量化处理的逻辑即可编译链接。
这只是一种写法,但不是最优写法,最优写法应该是这样的:

这份 Makefile
- 先通过
BIN=direct.exe定义最终可执行文件名 - 用
SRC=$(wildcard *.c)自动获取当前目录下所有.c源文件 - 再通过
OBJ=$(SRC:.c=.o)将所有.c文件名批量替换为对应的.o目标文件名 - 最终可执行文件
$(BIN)依赖所有.o文件,通过gcc $^ -o $@将所有.o链接为direct.exe -
模式规则
%.o:%.c实现 “任意.o文件都依赖同名.c文件”,通过gcc -c $<批量将每个.c单独编译为.o 。$<的意思就是: 自动取出当前规则的「第一个依赖文件」,在模式规则中用来代表正在编译的 .c 源文件。 - 最后通过
.PHONY: clean声明伪目标,执行make clean时可一键删除所有.o中间文件和direct.exe
除此之外,我们还能利用自定义变量名将这一份 Makefile 文件写成一个通用模板:

这就是一个普通的 Makefile 文件的通用模板。
本文到此结束,感谢各位读者的阅读,如果有讲解的错误或者不到位的地方,欢迎各位读者的批评和指正。
更多推荐




所有评论(0)