V3.0
王道C++班级参考资料<br />——<br />Linux部分卷2GNU工具集<br/>节1编译器工具链<br/><br/>最新版本V3.0
<br>王道C++团队<br/>COPYRIGHT ⓒ 2021-2024. 王道版权所有编译工具链GCC 和 Clang安装gcc生成可执行程序的过程预处理过程指令编译过程指令汇编过程指令链接过程指令gcc指令其它常用选项-I选项条件编译#if 预处理指令#ifdef和#ifndef预处理指令条件编译的作用The End
Gn!
在Windows平台下,我们进行开发使用的工具是Visual Studio,它是一种集成开发环境 (IDE: Integrated Development Environment)
集成开发环境可以极大地方便我们程序员编写程序,同时,VS环境下的MSVC编译器套件,也专门提供了针对Windows平台的最佳性能和兼容性优化,所以对于C/C++程序员而言,如果就在Windows平台下进行开发工作,VS确实是比较好的一个选择了。
但转移到Linux平台,Visual Studio和它的MSVC编译器套件就不无法使用了,因为它们都是针对图形界面和Windows操作系统设计的。
在Linux环境下,我们选择编译工具链,又叫软件开发工具包(SDK: Software Development Kit)进行开发。
Linux平台下最常见常用的编译工具链有两种:GCC 和 Clang,我们使用的是 GCC。
Gn!
GCC (GNU Compiler Collection):
GCC最初代表GNU C Compiler,专指GUN项目下开发的C语言编译器。但随着 GNU 项目的发展,它现在代表GNU Compiler Collection,因为现在它已经可以支持C、C++、Objective-C、Fortran、Ada、Go和D等多种语言。
GCC是Linux操作系统下的默认和标准编译器,也是Linux环境下最主流的编译器。
Clang (C Language Family for the Next Generation):
Clang是一个C、C++、Objective-C和Objective-C++编程语言的编译器前端,基于LLVM项目。(该项目组还开源了一个强大的编译器后端LLVM,著名的编程语言Rust就是基于这个编译器后端来实现它的编译器的)
Clang最著名的特点就是人性化的错误和警告信息,在这一点上它也确实超过了GCC。
GCC 和 Clang是Linux平台下两个最主流的C/C++编译器,相对而言:
GCC是更传统、更成熟的选择,稳定可靠。
Clang则以其高性能、出色的错误报告著名。
不过它们之间的兼容性很高,差异性不大,基本上可以实现无缝互相迁移。所以我们在学习时任选一个,在工作中,公司用哪个都没有太大关系。
Gn!
如果你还没有装GCC,可以使用下列指令安装:
xxxxxxxxxx
11sudo apt install gcc gdb g++ #同时安装gcc、gdb以及g++
具体而言:
gcc
特指GCC当中的C语言编译器,而且我们也使用gcc
指令来编译C语言代码。
gdb
是一种程序调试工具,类似VS中的Debug模式。
g++
特指GCC当中的C++编译器,到了C++阶段,我们就使用g++
指令来编译C++代码。怎么查看是否安装成功呢?可以使用以下指令:
xxxxxxxxxx
11gcc -v #查看GCC版本
gcc的版本只要在
4.5
以上就没有问题。
Gn!
早在C语言课程的开始,我们就已经知道一个
.c
源文件是如何生成一个可执行文件。我们把这个过程称之为:编译和链接。整个过程如下图所示:
按照细致的小过程来说,这个过程包含:预处理、编译、汇编、链接四个小过程。
按照大体上的流程来说,这个过程包含:编译和链表两个大过程。其中编译又包含:预处理、编译(狭义上的编译)、汇编三个小过程。
在以往VS当中,我们只需要点击一个"启动"按钮,集成开发环境就会自动帮助我们完成编译和链接的过程,并且直接启动程序。
到了Linux命令行当中,显然没有这种按钮了,只有指令。GCC为每一个小过程,都提供了相关的指令来完成,下面我们讲解一下每个过程需要的指令。
Gn!
预处理阶段对应的gcc指令是:
xxxxxxxxxx
11gcc -E hello.c -o hello.i # -E选项,表示激活预处理过程,生成预处理后的文件
其中
-E
选项表示仅激活预处理阶段,预处理阶段会去掉代码中的注释,以及执行宏展开、文件包含、条件编译等操作。
-o
是输出文件的选项,后面紧跟前面处理操作得到的文件名。预处理后得到的文件,它的后缀名可以指示为".i",它仍然是一个符合C语言语法的源代码文件。我们可以通过这个指令来进行预处理
.c
源文件,通过查看.i
文件的内容,理解预处理过程中预处理器的行为。
Gn!
如果你希望得到一个
.s
汇编代码文件,可以执行以下指令:xxxxxxxxxx
11gcc -S hello.i -o hello.s # —S选项,表示激活预处理和编译两个过程,会生成汇编代码文件
其中
-S
选项表示激活预处理和编译(狭义)阶段:
如果输入
.c
文件,那么会同时执行预处理和编译(狭义)两个阶段。如果输入
.i
文件,那么只会执行编译(狭义)阶段。汇编代码可以视为机器语言的符号表示(助记符),虽然仍然是字符信息,但已经很难读懂了,而再往后汇编过程得到的就是纯粹的二进制机器指令了。
汇编语言不是我们应该学习掌握的内容,但大家最好能够记住以下结论:
函数调用的过程,就是栈帧压栈以及弹栈的过程。
编译后,诸如变量名、数组名等程序调试信息就会被抛弃,转而会被真实地址替代。(但函数名标识符还在)
汇编代码中
call
指令用于调用函数,后面往往会跟上被调用函数的函数名。如果函数名后面有@PLT
,则代表需要通过链接查找此函数的实现。
Gn!
汇编过程会将汇编语言代码,转换成机器指令(也就是二进制数据1010...)
如果你希望直接得到一个
.o
目标文件,也就是直接执行一个广义上的编译过程,可以直接执行以下指令:xxxxxxxxxx
11gcc -c hello.s -o hello.o # -c选项,激活预处理、编译和汇编三个过程,生成目标文件 (广义上的编译)
-c
选项会根据输入文件的类型,自动激活预处理、编译(狭义)和汇编三个过程。所以-c
选项意味着你可以输入.c
、.i
、.s
三种类型的文件。注:
.o
目标文件当中是纯粹的二进制数据,人肉眼就完全看不懂它所表示的信息了。补充几个常用的命令
文件底层存储的数据都是二进制数据,不管这个文件是所谓二进制文件还是文本文件。
有些时候我们会希望以十六进制的方式来读文件的二进制数据,以更方便的阅读二进制数据,有以下方式:
在Vim编辑器中通过指令
:%!xxd
将二进制信息转换成十六进制表示,然后使用:%!xxd -r
指令还可以回到二进制模式。还可以直接使用shell指令
xxd 文件名
来直接查看一个二进制文件的十六进制表示。这个指令类似十六进制的cat指令。除此之外,我们还可以了解一个
nm
指令,基于以下代码:xxxxxxxxxx
311// header.h 头文件代码
2extern int external_global_var;
3void external_function(void);
4// header.h 头文件代码
5
6// main.c源代码文件
78// 通过包含头文件获取外部全局变量和函数的声明
9
10// 已初始化的全局变量
11int global_var_initialized = 100;
12
13// 未初始化的全局变量
14int global_var_uninitialized;
15
16// 函数的定义
17void my_function(void) {
18printf("This my_function is defined\n");
19}
20
21int main(int argc, char *argv[]){
22printf("hello world!\n");
23printf("global_var_initialized = %d\n", global_var_initialized);
24printf("global_var_uninitialized = %d\n", global_var_initialized);
25printf("external_global_var = %d\n", external_global_var);
26external_function();
27my_function();
28
29return 0;
30}
31// main.c源代码文件
nm
指令用于展示目标文件(.o文件)、库文件以及可执行文件中的全局符号信息,即展示全局符号表。下图是一个目标文件的符号表:此表的内容包括:
符号地址,每个符号名在目标文件或内存中的地址或偏移量(如果需要链接或动态链接则往往地址为空)。
符号类型,比如:
T
代表符号在代码段,一般就是表示一个函数。
U
表示那些未定义的,需要链接的符号,比如未链接的函数、全局变量等。
B
或b
代表未初始化的数据段(BSS 段),用于未初始化的全局变量。
D
或d
代表已初始化数据段,用于已初始化的全局变量。
R
或r
代表只读数据段。符号名称,主要是符号的实际名称,如函数名、全局变量名或者其它标识符的名字。
利用
nm
指令可以排查程序的一些链接上的错误,比如对一个.o
目标文件执行nm xxx.o | grep -E "U"
指令,可以检查目标文件中还有哪些未链接的符号(函数、全局变量等)。比如上述给到的示例代码,虽然可以通过编译生成目标文件,但如果不链接额外的其它目标文件,显然会链接失败无法生成可执行程序。
于是我们还需要一个".c"源文件来包含头文件,并且给出全局变量的定义以及函数的定义,参考代码如下:
xxxxxxxxxx
7123
4int external_global_var = 888;
5void external_function(void){
6printf("external_function.\n");
7}
这样生成两个目标".o"文件,并且将它们链接到一起生成可执行程序,就可以真正生成一个可执行程序了。
Gn!
链接过程可以把多个
.o
目标文件、库文件等相关的文件组合起来,生成一个可执行程序。链接过程主要的作用就是将函数标识符和它的真实地址关联起来。也就是说,直到链接阶段C程序才会检查函数的实现是否存在,是否可以找到,所以只要和函数名相关的问题普遍都是链接错误。
链接过程的指令如下:
xxxxxxxxxx
31gcc hello.c # 生成可执行程序,未指定可执行程序的名称,默认生成a.out
2gcc hello.c -o hello # 生成可执行程序hello
3gcc hello.o main.o -o main # 将两个目标文件链接组合生成一个可执行程序
不加任何选项,直接使用
gcc
指令,该指令可能激活预处理、编译、汇编和链接四个步骤。大多数情况下,我们都会选择使用该指令,一步到位。
Gn!
-Wall添加警告信息:
gcc指令在默认情况下不输出编译过程中的警告信息(或者只输出部分),你可以指令下列选项表示输出所有的警告信息。
xxxxxxxxxx
11gcc hello.c -o hello -Wall
该指令推荐在生成可执行程序时加上。
使用GCC在Linux环境下开发时,应该重视警告信息,而不是无视,因为gcc的警告级别其实很高,不会随便就报警。
某些对程序员要求比较高的公司,在Linux环境下开发C/C++时,会强制程序员将警告当成报错一样进行处理和消灭。在我看来,这确实是一个好的规范。
比如下列代码:
xxxxxxxxxx
12
3int test(void){
4
5} // 忘记写return表示函数返回值
6
7int main(void){
8int a = 10; // a局部变量定义了但没有使用
9
10int b = 20;
11if(b = 0){} // 赋值号作为判等号使用
12
13int c = test; // 忘记写函数调用的()运算符
14
15int d;
16printf("d = %d\n", c); // 使用了未初始化的局部变量
17return 0;
18}
上述代码使用指令
gcc main.c -o main
直接执行编译链接时,很多明显有问题的写法都没有被警告提示出来,这是不好的。但一旦加上-Wall
选项,GCC编译器会明确指出这些警告。所以使用
-Wall
选项并解决所有的警告,不仅仅是代码规范的问题,还可以帮助程序员避免很多隐性的错误。-O0,-O1,-O2,-O3编译器优化级别:
编译器的4个优化级别,-O0表示不优化,-O1为默认值,开发时常选择,-O2为生产环境下常用的优化级别,-O3的优化级别最高。
-O3的优化手段比较激进,生产环境一般不会选择。
-g添加调试信息:
在编译生成汇编代码文件的过程中,代码中像变量名这样的信息就被丢弃了,会被直接的内存地址偏移量替换。
所以可执行程序中必然不会存在诸如变量名这样的调试信息,但调试程序对程序员而言几乎是必须的。于是我们就可以使用下列指令,指示编译器在编译的过程中生成调试相关的信息:
xxxxxxxxxx
11gcc hello.c -o hello -g
注意:建议大家开发时总是添加上该选项,而且一旦想要调试程序,该选项必须加上!
-D[macro_name]定义宏:
相当于在文件的开头加了
#define macro_name
-D[macro_name]=value定义宏:
相当于在文件的开头加了
#define macro_name value
Gn!
-I
(大写字母i)选项也非常常用,在介绍它之前,我们先复习一个知识点,关于头文件包含的两种方式:
<>
方式:表示搜索头文件时,总是去操作系统头文件目录中寻找,而不会查找其它任何目录。
所以这种方式,是包含C语言标准库头文件使用的,不能用于包含用于自定义头文件。
在Linux系统下,这个文件夹默认是"/usr/include"
""
方式:表示先在当前目录"."中寻找头文件,如果找不到再去操作系统头文件目录中寻找。
所以这种方式,一般用于包含用户自定头文件。
当然,它也可以用于包含标准库头文件,虽然一般不推荐这么做。
在实际开发中,我们往往会将头文件和源文件放在不同的目录中,比如:
src目录中存放源代码
.c
文件header目录中存放头文件
.h
文件虽然我们可以在包含头文件时,加入头文件的路径:
xxxxxxxxxx
11这种方法不常使用,因为一旦程序的代码文件发生位置调整,代码内容也要随即调整,牵扯很大,太麻烦了。
一个更好的做法是使用
-I
指令,在编译时指定头文件的目录,如下所示:xxxxxxxxxx
11gcc hello.c -o hello -I../header
-I
选项的作用实际上是:改变头文件包含语法的搜索目录优先级,总是优先去搜索该选项指定的目录,搜索不到时,才按照既定的搜索的路径搜索。比如:
<>
方式:表示搜索头文件时,总是先去"../header"下搜索,搜索不到时,再去操作系统头文件目录中寻找。
""
方式:表示搜索头文件时,总是先去"../header"下搜索,搜索不到再去当前目录"."中寻找头文件,如果还找不到再去操作系统头文件目录中寻找。注:可以通过
cpp -v
命令查看系统的 include 目录。
Gn!
所谓条件编译,就是在预处理阶段决定包含还是排除某些代码片段。主要涉及以下预处理指令:
#if
#ifdef
#ifndef
下面逐一讲解一下:
Gn!
#if
用于在预处理阶段根据条件决定是否包含或者排除某些代码片段。预处理指令
#if
和#endif
通常与#else
和#elif
(这是#else if
的缩写)一起使用来创建复杂的条件编译指令。这些指令从格式上非常类似于C语言中的if选择
,而实际上逻辑上也确实是一样的。这里是一些基本的使用方法:
基本 #if:
xxxxxxxxxx
312// 当条件为真(非0)时包含的代码
3如果常量表达式非0,预处理器会包含
#if
和#endif
之间的代码。#if 和 #else:
xxxxxxxxxx
512// 条件为真时包含的代码
34// 条件为假时包含的代码
5如果常量表达式非0,预处理器会包含
#if
和#else
之间的代码;如果常量表达式是0,它会包含
#else
和#endif
之间的代码。#if, #elif, 和 #else:
xxxxxxxxxx
712// 常量表达式1为真时包含的代码
34// 常量表达式1为假且常量表达式2为真时包含的代码
56// 上述条件均为假时包含的代码
7非常类似于"多分支if-else",允许比较多个条件。
注:所谓的常量表达式就是能够在编译时期就确定取值的表达式,比如宏定义、字面值等。
#if
预处理指令比较常用于调试程序,比如:xxxxxxxxxx
71// 当代码上线不需要输出调试信息时,将DEBUG值设置为0
2...
34// 以下内容属于调试过程需要打印的调试信息
5printf("i = %d\n", i);
6printf("j = %d\n", j);
7当然,这种写法需要先改动源代码,然后才能在调试与不调试之间切换。那么如果忘记修改源代码,然后把调试信息输出到生产环境中,岂不是很尴尬?
所以我们还有一个更好的办法——使用预处理运算符
defined
(注意是defined,不是define)所谓预处理运算符,指的是仅在预处理阶段生效的一种运算符。
defined
的运算符的含义如下:xxxxxxxxxx
312// 调试信息代码
3"#if defined(DEBUG)"整体表示:
如果宏DEBUG被定义了,那么#if判断为真,则包含调试信息代码
如果宏DEBUG没有被定义,那么#if判断为假,则排除调试信息代码
注意:只要宏DEBUG被定义了,#if判断就为真,至于宏DEBUG有无值,值是多少都不重要!
具体怎么控制代码是否包含调试代码呢?如果像下面这种方式,虽然可以实现:
xxxxxxxxxx
51// 通过该行宏定义有无控制是否包含调试信息代码
2...
34// 调试信息代码
5但如果是这样做,那和之前就没有任何区别了。
实际的工作中应该怎么做呢?
提示:gcc指令的
-D
选项可以定义宏。于是我们只需要在编译代码时,使用下列指令:
xxxxxxxxxx
11gcc hello.c -o hello.i -DDEBUG #加上该指令就会包含调试信息代码
这种语法显然是非常好用的,但
#if defined(DEBUG)
还是显得有点太长太啰嗦了,能不能简化一下写法呢?当然可以简写,这就是
#ifdef
预处理指令。
Gn!
#ifdef DEBUG
就是#if defined(DEBUG)
的简写形式,效果是完全一样的。格式如下:xxxxxxxxxx
312// 调试信息代码
3含义是:
如果宏DEBUG被定义了,那么#ifdef判断为真,则包含调试信息代码
如果宏DEBUG没有被定义,那么#ifdef判断为假,则排除调试信息代码
既然有判断宏是否定义,然后选择包含代码,那么反过来就有:
如果宏没有被定义,就选择包含代码。这就是我们早就很熟悉的,在头文件包含中使用的指令——
ifndef
它的格式如下:
xxxxxxxxxx
312...
3作用刚好相反,含义是:
如果宏没有被定义,那么#ifndef判断为真,则包含中间的代码
如果宏已经被定义了,那么#ifndef判断为假,则排除中间的代码
Gn!
在上面,我们演示了利用条件编译为调试提供便利。但显然,条件编译的作用远不止此,下面是其它一些常见的应用:
编写可移植的程序
C/C++的可移植性是比较差的,这主要是因为不同系统平台必然会有自身独特的系统调用,所以不同平台实现相同功能时往往需要写不同的代码。
这时组织和管理这些代码,就变得很麻烦了。条件编译就为这个过程提供了很大的便利性:
xxxxxxxxxx
712...
34...
56...
7正如我们之前课程当中提到的,Linux和Windows系统当中换行符和路径分隔符是不同的,于是就可以写出下列代码:
xxxxxxxxxx
121// 定义路径变量
2char *path;
3// 定义换行符变量
4char *new_line;
5// 根据不同操作系统设置不同的换行符和路径分隔符
6// 对于 Windows 系统
7new_line = "\r\n";
8path = "C:\\path\\to\\file.txt";
9// 对于 Linux 和其他系统
10new_line = "\n";
11path = "/path/to/file.txt";
12在这个案例中, 会根据WIN32、MAC_OS 或 LINUX是否定义宏,从而决定包含哪部分代码。我们可以在程序的开头,定义这三个宏中的一个,从而选择一个特定的操作系统。
为宏提供默认定义
我们可以检测一个宏是否被定义了,如果没有,则提供一个默认的定义:
xxxxxxxxxx
3123用户可以根据自己的需求来指定一个值,但如果用户没有指定,则使用默认值,以使得程序能够正常运行。
避免头文件重复包含
多次包含同一个头文件,可能会导致编译错误(比如,头文件中包含类型的定义)。因此,我们应该避免重复包含头文件。使用 #ifndef 和 #define 可以轻松实现这一点:
xxxxxxxxxx
13123
4typedef struct {
5int id;
6char name[25];
7char gender;
8int chinese;
9int math;
10int english;
11} Student;
12
13注意:
这种头文件保护语法,也有人叫"防御式声明"、"包含卫士"等,了解一下。
宏命令一般会包含公司的域名以及文件名,具体如何命令可以参考公司以往代码。
由于C++有诸如内联函数、模板等独特的语法,所以在C++中头文件保护会显得更加重要。当然在写C代码时,我们也强烈推荐大家使用头文件保护语法。
现代的高级编译器都支持
#pragma once
放在头文件的头部,以实现头文件保护的功能。比如我们现在使用的GCC,以前使用的MSVC,包括Clang,都支持这个语法。但它毕竟不属于C语言标准规范的一部分,使用前要斟酌考虑。临时屏蔽代码,尤其是屏蔽包含注释的代码
我们可以用条件编译临时屏蔽一段代码,这就相当于多行注释,如下:
xxxxxxxxxx
312// 多行注释被注释起来的代码
3除此之外,条件编译还可以用于屏蔽包含注释的代码。
我们不能用 /*...*/ 注释掉已经包含 /*...*/ 注释的代码。但是我们可以用 #if 指令来实现:
xxxxxxxxxx
312包含/*...*/注释的代码
3这种屏蔽方式,我们称之为"条件屏蔽"。条件屏蔽也是一种实现多行注释的方式。