Ⅱ. gcc 和 g++
0x00 引入:在 Linux 中如何编写 C/C++ 程式?
💭 以下是 C 和 C++ 的 Hello,World 示例程序编译的方式:
$ gcc [文件名] # 编译C $ g++ [文件名] # 编译C++
此外,如果你输入"g++ test.cpp" 时显示并没有这样的指令,可以安装一下:
sudo yum install -y gcc-c++
我们知道,gcc 是不能用来编译 C++ 代码的,它只能用来编译 C 语言的代码:
$ gcc test.cpp ❌ $ gcc test.c ✅
(尝试用 gcc 编译 C++ 代码)
但是, g++ 是可以用来编译 C 语言的,这个相信大家是可以理解的,因为 C++ 是 C 的超集:
$ g++ test.c ✅ $ g++ test.cpp ✅
0x01 程序的翻译过程
程序(文本)要转换成机器语言(二进制),翻译有以下四个步骤:
① 预处理
② 编译
③ 汇编
④ 链接
这个我们在前面已经讲过了,我们本节主要讲解预处理部分,并且对链接部分进行补充。
对于编译和汇编部分,本节我们只对他们做一个简单的讲解(当然并不是说它们不重要)
❓ 思考:我们知道文本要翻译成二进制的原因是计算机只认识二进制,那你有没有想过:
为什么计算机只认识二进制?
其实并不是计算机只认识二进制,而是计算机当中的各种硬件只认识二进制。
在计算机刚被设计的时候都是存储两派信息,常见的比如触发器这样的硬件设备,
实际上只能存储电信号的 "有无" 或者 "正负" 这样的概念,至于为什么选择二进制。
我再做一个补充,其实从计算机发明到现在,历史上也出现过其它进制的计算机。
比如苏联的三进制计算机,只不过二进制计算机更简单,最后称为主流了。
所以,我们的计算机只认识二进制,因为它的各种硬件都是二进制的。
💡 答案:组成计算机的各种组件,只认识二进制。
0x01 预处理过程
📚 预处理:a. 宏替换 b. 头文件展开 c. 去注释 d. 条件编译
Linux 的 gcc 是如何进行上面的过程的呢?我们先看预处理的过程。
💬 我们来修改一下 test.c 源文件,让它有头文件、宏、注释和条件编译:
此时我们直接用 gcc 编译一下代码,"gcc + 要编译的文件" 默认生成的可执行程序叫 a.out。
顺便一提,如果你不想让生成的可执行程序为叫 a.out,你想指定名称,可以加上 -o 选项:
这样是直接一步到位地获得了可执行程序,可是我们现在想观察预处理,
也就是说我们只想让 gcc 完成预处理的操作,我们可以加上 "杠大易" 的选项。
gcc -E test.c -o test.i # -E: 从现在开始给我进行程序的翻译,当预处理完成就停下来 # -o test.i:将最后的结果写到 test.i 中
然后我们 gcc 打开 test.c,冒号进入底行模式后输入 vs test.i,就可以分屏观察:
给人最明显的感受就是 —— test.i 明显要比 test.c 大很多。
但实际上观察后发现代码反而比 test.i 要少很多,并且我们翻到开头可以发现有大量的引入:
我们写代码写的 .h 文件 .c 文件,当你编译的时候实际上是把你的 .h 代码全部拷贝到 .c 中的。
为了防止重复包含头文件,我们在《树锯结构》专栏还提到了用 #pragma once 防止重复包含。
观察 test.o 前面部分后我们发现 Linux 中 C语言标准头文件在 /usr/include 下,我们这就去看看:
$ ls /usr/include
一般都会安装在 usr/include 目录下,当然!不排除以后会出现安装在其他目录下的可能性。
现在你只需要记住:以前我们编写C语言代码时 #include<stdio.h> 时,其实并不是说 #include 就一定能成功,前提是你平台必须得装了你引入的头文件,不然也没东西在你的源文件中展开。
当然,我们最熟悉的头文件莫过于 stdio.h:
📌 注意:编译器内部都必须通过一定的方式,知道你所包含的头文件所在的路径。
现在在回头想一想,为什么一个新的语法老的编译器不支持的问题。其根本原因是因为老的编译器配套的头文件和库,压根就没有这一套东西。我们在装 VS 编译器的时所谓的环境安装勾选 C/C++ 后,实际上就是在给你装 C语言 C++ 的头文件和库。
🔍 观察:我们再来比对一下它们的代码部分有什么差别:
此时,就完成了预处理的工作。
我们刚才的条件编译只保留了 release,现在我们如果 #define 出一个 DEBUG,
再重新编译打开比对,保留的就不是 "hello,release!" 而是 "hello, debug!" 了,这里就不演示了。
❓ 思考:程序进行完预处理操作后,还是C语言吗?
💡 答案:仍然是C语言!你看这些代码你还认不认识,它当然还是C语言了。
换言之,这个预处理工作仅仅是为了让程序翻译的更顺畅,帮助我们做了一些文本替换处理操作而已。比如宏替换(当然,例子里面我不小心忘记了在代码中用到宏,所以自然前后也没有替换)、去掉给人读的注释(机器才懒得读你写的注释呢,跟它也没有半毛钱关系)、根据条件编译的结果把不要的选项去掉……最后它还是C语言,只不过时一份干净的C语言。你可以理解为 "文章的润色" 。
📚 命令格式:
gcc [选项] 要编译的文件 [选项] [目标文件]
预处理(进行宏替换):
预处理功能主要包括宏定义、文件包含、条件编译、去注释等。
预处理指令以 # 号开头的代码行。
实例:gcc -E hello.c -o hello.i
选项:"-E" 该选项的作用是让 gcc 在预处理结束过后停止编译过程。
选项:“-o" 是指目标文件, "i" 文件为已经过预处理的C初始程序。
0x03 编译过程
编译的核心工作就是将C语言翻译成汇编语言!
如果看不懂也没有关系,你只要知道 —— 这一步会让你的C语言代码大变样!
$ gcc -S test.i -o test.s
# -S: 从现在开始进行程序的编译,当我们编译完成之后,就停下来!
这次我们从 test.i 开始走,当然也可以是 test.c,那会重新走一遍预处理的过程然后再编译。
(汇编语言的后缀一般都叫 .s,所以我们这里取名为 .s,我们前面章节也说过 Linux 的类型和文件后缀没有关系,这里你用 .lbwnb 都没有人拦你,只是叫 .s 更符合常理)
输入完上面命令后就形成了 test.s 的文件:
之后用 vim 打开 test.s 之后会让人很头大:
这是 x86 环境下的汇编指令,其中有一些汇编的助记符,即使看不明白也没有关系。
但是你可以发现代码的数量 从刚才 test.i 的八百多行,变成了现在短短的 45 行!
0x04 汇编过程(简单了解)
该过程是将汇编语言翻译翻译成二进制文件。
准确来说应该是 "可重定位二进制文件",它一般以 .o 结尾,VS 下是 .obj 结尾的:
值得一提的是,这里的 "重定位" 和我们前面说的 "重定向" 是完全没有任何关系的,就像雷锋和雷峰塔、Java 和 Javascript 一样完全没有任何关系。
$ gcc -c test.s -o test.o # -c:从现在开始进行程序的编译,当我们汇编结束之后,就停下来!
此时就已经是二进制了,gcc 打开后会是很大的一坨乱码。
我们可以用一些二进制查看工具去查看,但是我相信已经没有地球人可以看懂了。
刚才的汇编语言确实有人可以看懂,但这里我直接说没有人能直接看懂应该不过分吧:
$ ./test.o
虽然现在代码已经是二进制的了,但是仍然是不能运行的:
其原因也很简单,因为这里面有些符号目前还没有和系统关联起来。
0x05 链接过程
所有的包含头文件的操作,本质是因为想使用头文件所声明的方法!
$gcc test.o -o mytest
而这最后一步,隐含的就是链接我们自己的程序和库,形成可执行程序!
当然了,直接到程序的翻译过程:
$ gcc test.c -o mytest.c
一步就可以到位了,我们之前是为了研究一步步的过程,所以又是 -E 又是 -S 又是 -c 的。
0x06 巧记程序的翻译过程
我们先来回故前三个翻译过程,按顺序分别为预处理、编译和汇编。
$ gcc -E test.c -o test.i # 预处理 $ gcc -S test.i -o test.s # 编译 $ gcc -e test.s -o test.o # 汇编
这里有个记忆方法:预处理 (E) → 编译 (S)→ 汇编 (c) ,三个过程就是 。
如果你记不得,可以看看你键盘的左上角就行了(当然前提你要记住程序翻译过程的顺序)。
另外它们形成的临时文件为 test.i、test.s、test.o,也同样有个记忆的方法:
(国际标准化组织:ISO)
当然,iso 也是镜像文件的后缀,如果你比较熟悉也可以拿这个记。
ISO(Isolation)文件一般以iso为扩展名,是复制光盘上全部信息而形成的镜像文件
Ⅲ. 函数库(Function library)
0x00 引入:你早就与库有着千丝万缕的联系
我们的C程序中,并没有定义 printf 的函数实现,且在预编译中包含的 stdio.h 中也只有该函数的声明,而没有定义函数的实现。那么是在哪里实 printf 函数的呢?
系统把这些函数实现都被做到名为 libc.so.6 的库文件中去了,在没有特别指定时,gcc 会到系统默认的搜索路径 /usr/lib 下进行查找,也就是链接到 libc.so.6 库函数中去,这样就能实现函数 printf 了,而这也就是链接的作用。
从我们敲下第一行代码,打印 Hello,World 时就已经和库有着千丝万缕的联系了。
看上去像是我们在写代码打印这个语句,实际上是调用了大佬写的 prinf 打印函数。
"其实我们无形中就已经站在了巨人的肩膀上"
你自己用的C语言头文件,从库中取的所有的函数,全部都需要在头文件当中声明,在库文件当中实现,然后在编译的时候再把你的可执行程序和库文件关联起来,此时关联之后,才能形成可执行程序。否则一切都是扯淡!
如果今天我们是在写 C++ 代码,C++ 对应的库也必须在安装 gcc 的时候都必须具备好,我们所说的装环境,实际上不仅仅在装环境。比如我们在装 VS2022 的时候我们不仅仅在装 VS2022,你也在装头文件也在装环境中所支持的语言。
0x01 头文件与库文件(Header file and Library file)
头文件:给我们提供了可以使用的方法,所有的开发环境,具有语法提示,本质是通过头文件帮我们搜索的。
库文件:给我们提供了可以使用的方法的实现,以供链接,形成我们自己的可执行程序。
0x02 动态库与静态库(Dynamic library and static library)
我们必须承认一个事实,计算机存在两类库:一类库叫动态库,一类库叫静态库。
静态库:Linux (.so),Windows (.dll) —— 动态链接
静态库:Linux (.a),Windows (,lib) —— 静态链接
静态链接:将库中的相关代码,直接拷贝到自己的可执行程序中。
动态链接:
优点:大家共享一个库,可以节省资源。
缺点:一旦库丢失,会导致几乎所有的程序失效!
那 gcc 中如何体现呢?
形成的可执行程序体积一定是不一样的,静态链接体积大,动态链接体积小。
那么我们在 Linux 中用 gcc 编译程序
默认情况下形成的的可执行程序就是动态链接的:
如果你想进行静态链接,你需要在编译代码时在后面加上 -static 选项:
$ gcc test.c -o mytest2 -static # 静态链接
0x03 静态库的安装
此时如果出现了像下面这样找不到的情况:
那么你就需要安装一下静态库,记得切换到 root 下去安装。
🔧 安装 C 的静态库:
# sudo yum install -y glibc-static
🔧 安装 C++ 静态库:
# sudo yum install -y libstdc++-static
0x04 动态链接和静态链接推荐使用哪个?
默认是动态链接,我们也更推荐动态链接,
因为生成体积小,无论是编译时间还是占资源的成本,一般都比静态链接要好。
但这并不是绝对的!如果你要发布一款软件是动态链接的,程序短小精悍但库相对显得累赘,
如果此时你发布这款软件就不想带库了,你把它静态链接就是完全合适的。