王道C++班级参考资料
——
Linux部分卷2GNU工具集
节1编译器工具链

最新版本V3.0
王道C++团队
COPYRIGHT ⓒ 2021-2024. 王道版权所有

编译工具链

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。

GCC 和 Clang

Gn!

GCC (GNU Compiler Collection):

  1. GCC最初代表GNU C Compiler,专指GUN项目下开发的C语言编译器。但随着 GNU 项目的发展,它现在代表GNU Compiler Collection,因为现在它已经可以支持C、C++、Objective-C、Fortran、Ada、Go和D等多种语言。

  2. GCC是Linux操作系统下的默认和标准编译器,也是Linux环境下最主流的编译器。

Clang (C Language Family for the Next Generation):

  1. Clang是一个C、C++、Objective-C和Objective-C++编程语言的编译器前端,基于LLVM项目。(该项目组还开源了一个强大的编译器后端LLVM,著名的编程语言Rust就是基于这个编译器后端来实现它的编译器的)

  2. Clang最著名的特点就是人性化的错误和警告信息,在这一点上它也确实超过了GCC。

GCC 和 Clang是Linux平台下两个最主流的C/C++编译器,相对而言:

  1. GCC是更传统、更成熟的选择,稳定可靠。

  2. Clang则以其高性能、出色的错误报告著名。

不过它们之间的兼容性很高,差异性不大,基本上可以实现无缝互相迁移。所以我们在学习时任选一个,在工作中,公司用哪个都没有太大关系。

安装gcc

Gn!

如果你还没有装GCC,可以使用下列指令安装:

具体而言:

  1. gcc特指GCC当中的C语言编译器,而且我们也使用gcc指令来编译C语言代码。

  2. gdb是一种程序调试工具,类似VS中的Debug模式。

  3. g++特指GCC当中的C++编译器,到了C++阶段,我们就使用g++指令来编译C++代码。

怎么查看是否安装成功呢?可以使用以下指令:

gcc的版本只要在4.5以上就没有问题。

生成可执行程序的过程

Gn!

早在C语言课程的开始,我们就已经知道一个.c源文件是如何生成一个可执行文件。我们把这个过程称之为:编译和链接

整个过程如下图所示:

C程序编译和链接的过程-图

按照细致的小过程来说,这个过程包含:预处理、编译、汇编、链接四个小过程。

按照大体上的流程来说,这个过程包含:编译和链表两个大过程。其中编译又包含:预处理、编译(狭义上的编译)、汇编三个小过程。

在以往VS当中,我们只需要点击一个"启动"按钮,集成开发环境就会自动帮助我们完成编译和链接的过程,并且直接启动程序。

到了Linux命令行当中,显然没有这种按钮了,只有指令。GCC为每一个小过程,都提供了相关的指令来完成,下面我们讲解一下每个过程需要的指令。

预处理过程指令

Gn!

预处理阶段对应的gcc指令是:

其中-E选项表示仅激活预处理阶段,预处理阶段会去掉代码中的注释,以及执行宏展开、文件包含、条件编译等操作。

-o是输出文件的选项,后面紧跟前面处理操作得到的文件名。预处理后得到的文件,它的后缀名可以指示为".i",它仍然是一个符合C语言语法的源代码文件。

我们可以通过这个指令来进行预处理.c源文件,通过查看.i文件的内容,理解预处理过程中预处理器的行为。

编译过程指令

Gn!

如果你希望得到一个.s汇编代码文件,可以执行以下指令:

其中-S选项表示激活预处理和编译(狭义)阶段:

  1. 如果输入.c文件,那么会同时执行预处理和编译(狭义)两个阶段。

  2. 如果输入.i文件,那么只会执行编译(狭义)阶段。

汇编代码可以视为机器语言的符号表示(助记符),虽然仍然是字符信息,但已经很难读懂了,而再往后汇编过程得到的就是纯粹的二进制机器指令了。

汇编语言不是我们应该学习掌握的内容,但大家最好能够记住以下结论:

  1. 函数调用的过程,就是栈帧压栈以及弹栈的过程。

  2. 编译后,诸如变量名、数组名等程序调试信息就会被抛弃,转而会被真实地址替代。(但函数名标识符还在)

  3. 汇编代码中call指令用于调用函数,后面往往会跟上被调用函数的函数名。如果函数名后面有@PLT,则代表需要通过链接查找此函数的实现。

汇编过程指令

Gn!

汇编过程会将汇编语言代码,转换成机器指令(也就是二进制数据1010...)

如果你希望直接得到一个.o目标文件,也就是直接执行一个广义上的编译过程,可以直接执行以下指令:

-c选项会根据输入文件的类型,自动激活预处理、编译(狭义)和汇编三个过程。所以-c选项意味着你可以输入.c.i.s三种类型的文件。

注:.o目标文件当中是纯粹的二进制数据,人肉眼就完全看不懂它所表示的信息了。

补充几个常用的命令

文件底层存储的数据都是二进制数据,不管这个文件是所谓二进制文件还是文本文件。

有些时候我们会希望以十六进制的方式来读文件的二进制数据,以更方便的阅读二进制数据,有以下方式:

  1. 在Vim编辑器中通过指令:%!xxd将二进制信息转换成十六进制表示,然后使用:%!xxd -r指令还可以回到二进制模式。

  2. 还可以直接使用shell指令xxd 文件名来直接查看一个二进制文件的十六进制表示。这个指令类似十六进制的cat指令。

除此之外,我们还可以了解一个nm指令,基于以下代码:

nm 指令用于展示目标文件(.o文件)、库文件以及可执行文件中的全局符号信息,即展示全局符号表。下图是一个目标文件的符号表:

目标文件的符号表-图

此表的内容包括:

  1. 符号地址,每个符号名在目标文件或内存中的地址或偏移量(如果需要链接或动态链接则往往地址为空)。

  2. 符号类型,比如:

    1. T代表符号在代码段,一般就是表示一个函数。

    2. U表示那些未定义的,需要链接的符号,比如未链接的函数、全局变量等。

    3. Bb 代表未初始化的数据段(BSS 段),用于未初始化的全局变量。

    4. Dd 代表已初始化数据段,用于已初始化的全局变量。

    5. Rr 代表只读数据段。

  3. 符号名称,主要是符号的实际名称,如函数名、全局变量名或者其它标识符的名字。

利用nm指令可以排查程序的一些链接上的错误,比如对一个.o目标文件执行nm xxx.o | grep -E "U"指令,可以检查目标文件中还有哪些未链接的符号(函数、全局变量等)。

比如上述给到的示例代码,虽然可以通过编译生成目标文件,但如果不链接额外的其它目标文件,显然会链接失败无法生成可执行程序。

于是我们还需要一个".c"源文件来包含头文件,并且给出全局变量的定义以及函数的定义,参考代码如下:

这样生成两个目标".o"文件,并且将它们链接到一起生成可执行程序,就可以真正生成一个可执行程序了。

链接过程指令

Gn!

链接过程可以把多个.o目标文件、库文件等相关的文件组合起来,生成一个可执行程序。链接过程主要的作用就是将函数标识符和它的真实地址关联起来。

也就是说,直到链接阶段C程序才会检查函数的实现是否存在,是否可以找到,所以只要和函数名相关的问题普遍都是链接错误。

链接过程的指令如下:

不加任何选项,直接使用gcc指令,该指令可能激活预处理、编译、汇编和链接四个步骤。大多数情况下,我们都会选择使用该指令,一步到位。

gcc指令其它常用选项

Gn!

-Wall添加警告信息:

gcc指令在默认情况下不输出编译过程中的警告信息(或者只输出部分),你可以指令下列选项表示输出所有的警告信息。

该指令推荐在生成可执行程序时加上。

使用GCC在Linux环境下开发时,应该重视警告信息,而不是无视,因为gcc的警告级别其实很高,不会随便就报警。

某些对程序员要求比较高的公司,在Linux环境下开发C/C++时,会强制程序员将警告当成报错一样进行处理和消灭。在我看来,这确实是一个好的规范。

比如下列代码:

上述代码使用指令gcc main.c -o main直接执行编译链接时,很多明显有问题的写法都没有被警告提示出来,这是不好的。但一旦加上-Wall选项,GCC编译器会明确指出这些警告。

所以使用-Wall选项并解决所有的警告,不仅仅是代码规范的问题,还可以帮助程序员避免很多隐性的错误。

-O0,-O1,-O2,-O3编译器优化级别:

编译器的4个优化级别,-O0表示不优化,-O1为默认值,开发时常选择,-O2为生产环境下常用的优化级别,-O3的优化级别最高。

-O3的优化手段比较激进,生产环境一般不会选择。

-g添加调试信息:

在编译生成汇编代码文件的过程中,代码中像变量名这样的信息就被丢弃了,会被直接的内存地址偏移量替换。

所以可执行程序中必然不会存在诸如变量名这样的调试信息,但调试程序对程序员而言几乎是必须的。于是我们就可以使用下列指令,指示编译器在编译的过程中生成调试相关的信息:

注意:建议大家开发时总是添加上该选项,而且一旦想要调试程序,该选项必须加上!

-D[macro_name]定义宏:

相当于在文件的开头加了#define macro_name

-D[macro_name]=value定义宏:

相当于在文件的开头加了#define macro_name value

-I选项

Gn!

-I(大写字母i)选项也非常常用,在介绍它之前,我们先复习一个知识点,关于头文件包含的两种方式:

  1. <>方式:表示搜索头文件时,总是去操作系统头文件目录中寻找,而不会查找其它任何目录。

    1. 所以这种方式,是包含C语言标准库头文件使用的,不能用于包含用于自定义头文件。

    2. 在Linux系统下,这个文件夹默认是"/usr/include"

  2. ""方式:表示先在当前目录"."中寻找头文件,如果找不到再去操作系统头文件目录中寻找。

    1. 所以这种方式,一般用于包含用户自定头文件。

    2. 当然,它也可以用于包含标准库头文件,虽然一般不推荐这么做。

在实际开发中,我们往往会将头文件和源文件放在不同的目录中,比如:

  1. src目录中存放源代码.c文件

  2. header目录中存放头文件.h文件

虽然我们可以在包含头文件时,加入头文件的路径:

这种方法不常使用,因为一旦程序的代码文件发生位置调整,代码内容也要随即调整,牵扯很大,太麻烦了。

一个更好的做法是使用-I指令,在编译时指定头文件的目录,如下所示:

-I选项的作用实际上是:

改变头文件包含语法的搜索目录优先级,总是优先去搜索该选项指定的目录,搜索不到时,才按照既定的搜索的路径搜索。比如:

  1. <>方式:表示搜索头文件时,总是先去"../header"下搜索,搜索不到时,再去操作系统头文件目录中寻找。

  2. ""方式:表示搜索头文件时,总是先去"../header"下搜索,搜索不到再去当前目录"."中寻找头文件,如果还找不到再去操作系统头文件目录中寻找。

注:可以通过 cpp -v 命令查看系统的 include 目录。

条件编译

Gn!

所谓条件编译,就是在预处理阶段决定包含还是排除某些代码片段。主要涉及以下预处理指令:

  1. #if

  2. #ifdef

  3. #ifndef

下面逐一讲解一下:

#if 预处理指令

Gn!

#if用于在预处理阶段根据条件决定是否包含或者排除某些代码片段。

预处理指令 #if#endif 通常与 #else#elif(这是 #else if 的缩写)一起使用来创建复杂的条件编译指令。这些指令从格式上非常类似于C语言中的if选择,而实际上逻辑上也确实是一样的。

这里是一些基本的使用方法:

基本 #if:

如果常量表达式非0,预处理器会包含 #if#endif 之间的代码。

#if 和 #else:

如果常量表达式非0,预处理器会包含 #if#else 之间的代码;

如果常量表达式是0,它会包含 #else#endif 之间的代码。

#if, #elif, 和 #else:

非常类似于"多分支if-else",允许比较多个条件。

注:所谓的常量表达式就是能够在编译时期就确定取值的表达式,比如宏定义、字面值等。

#if预处理指令比较常用于调试程序,比如:

当然,这种写法需要先改动源代码,然后才能在调试与不调试之间切换。那么如果忘记修改源代码,然后把调试信息输出到生产环境中,岂不是很尴尬?

所以我们还有一个更好的办法——使用预处理运算符defined(注意是defined,不是define)

所谓预处理运算符,指的是仅在预处理阶段生效的一种运算符。

defined的运算符的含义如下:

"#if defined(DEBUG)"整体表示:

  1. 如果宏DEBUG被定义了,那么#if判断为真,则包含调试信息代码

  2. 如果宏DEBUG没有被定义,那么#if判断为假,则排除调试信息代码

注意:只要宏DEBUG被定义了,#if判断就为真,至于宏DEBUG有无值,值是多少都不重要!

具体怎么控制代码是否包含调试代码呢?如果像下面这种方式,虽然可以实现:

但如果是这样做,那和之前就没有任何区别了。

实际的工作中应该怎么做呢?

提示:gcc指令的-D选项可以定义宏。

于是我们只需要在编译代码时,使用下列指令:

这种语法显然是非常好用的,但#if defined(DEBUG)还是显得有点太长太啰嗦了,能不能简化一下写法呢?

当然可以简写,这就是#ifdef预处理指令。

#ifdef和#ifndef预处理指令

Gn!

#ifdef DEBUG就是#if defined(DEBUG)的简写形式,效果是完全一样的。格式如下:

含义是:

  1. 如果宏DEBUG被定义了,那么#ifdef判断为真,则包含调试信息代码

  2. 如果宏DEBUG没有被定义,那么#ifdef判断为假,则排除调试信息代码

既然有判断宏是否定义,然后选择包含代码,那么反过来就有:

如果宏没有被定义,就选择包含代码。这就是我们早就很熟悉的,在头文件包含中使用的指令——ifndef

它的格式如下:

作用刚好相反,含义是:

  1. 如果宏没有被定义,那么#ifndef判断为真,则包含中间的代码

  2. 如果宏已经被定义了,那么#ifndef判断为假,则排除中间的代码

条件编译的作用

Gn!

在上面,我们演示了利用条件编译为调试提供便利。但显然,条件编译的作用远不止此,下面是其它一些常见的应用:

编写可移植的程序

C/C++的可移植性是比较差的,这主要是因为不同系统平台必然会有自身独特的系统调用,所以不同平台实现相同功能时往往需要写不同的代码。

这时组织和管理这些代码,就变得很麻烦了。条件编译就为这个过程提供了很大的便利性:

正如我们之前课程当中提到的,Linux和Windows系统当中换行符和路径分隔符是不同的,于是就可以写出下列代码:

在这个案例中, 会根据WIN32、MAC_OS 或 LINUX是否定义宏,从而决定包含哪部分代码。我们可以在程序的开头,定义这三个宏中的一个,从而选择一个特定的操作系统。

为宏提供默认定义

我们可以检测一个宏是否被定义了,如果没有,则提供一个默认的定义:

用户可以根据自己的需求来指定一个值,但如果用户没有指定,则使用默认值,以使得程序能够正常运行。

避免头文件重复包含

多次包含同一个头文件,可能会导致编译错误(比如,头文件中包含类型的定义)。因此,我们应该避免重复包含头文件。使用 #ifndef 和 #define 可以轻松实现这一点:

注意:

  1. 这种头文件保护语法,也有人叫"防御式声明"、"包含卫士"等,了解一下。

  2. 宏命令一般会包含公司的域名以及文件名,具体如何命令可以参考公司以往代码。

  3. 由于C++有诸如内联函数、模板等独特的语法,所以在C++中头文件保护会显得更加重要。当然在写C代码时,我们也强烈推荐大家使用头文件保护语法。

  4. 现代的高级编译器都支持#pragma once放在头文件的头部,以实现头文件保护的功能。比如我们现在使用的GCC,以前使用的MSVC,包括Clang,都支持这个语法。但它毕竟不属于C语言标准规范的一部分,使用前要斟酌考虑。

临时屏蔽代码,尤其是屏蔽包含注释的代码

我们可以用条件编译临时屏蔽一段代码,这就相当于多行注释,如下:

除此之外,条件编译还可以用于屏蔽包含注释的代码。

我们不能用 /*...*/ 注释掉已经包含 /*...*/ 注释的代码。但是我们可以用 #if 指令来实现:

这种屏蔽方式,我们称之为"条件屏蔽"。条件屏蔽也是一种实现多行注释的方式。

The End