打算开一个系列,写一些零零散散但自学了很久的东西——C/C++除了基本语法之外,还需要知道的东西——C++项目的编译与构建

这一系列采用的编程环境为 Linux 系统,编译器为 gcc 11.2.0。(Windows 下的 mingw 作为两种平台间的畸形产物,曾经给我的学习造成了极大的困扰,尤其是静态库动态库部分)

如果使用 IDE 比如 VS 或 CLion 的话,这些东西可能都不需要了解,同样也可以写好 C++,但是我不喜欢使用 IDE 这种糊里糊涂,不清楚到底发生了什么的编程方式,更喜欢先搞懂每一步发生了什么,然后可以为了提高效率而使用 IDE,而非一直保持一种稀里糊涂的状态。

1. 编译过程的拆分

对于 c/c++编程,从源代码文件变成可执行文件,大致需要以下几步:

  1. 预处理(Pre-Processing),预处理器(preprocessor)处理#include #define等内容,把头文件 copy 到源文件中等,注意这种 include 是递归的,并且这里存在一个问题:gcc 如何找到头文件。
  2. 编译(Compiling),得到的文件是以汇编语言写的,可读。
  3. 汇编(Assembling),得到的文件是二进制格式,不可读。编译和汇编主要是分析源代码中的内容,然后转换为目标文件,由于没学过编译原理,这部分不做详述。
  4. 链接(Linking),静态库和动态库的处理体现在链接过程中,链接器(linker)把目标文件和库一起打包变成可执行文件。这里存在一个问题:如何找到所有的目标文件以及库的位置。

注:

  • c/c++的编译过程和 java、python 等语言是截然不同;
  • c 和 c++在这里并没有什么差异,接下来的小实验都使用 c 进行。

2. gcc 实验

从最简单的 hello.c 出发

hello.c
1
2
3
4
5
6
#include <stdio.h>

int main(){
printf("hello, world\n");
return 0;
}

2.1 预处理

对于 gcc 来说,它会调用的预处理的工具叫做 cpp,全称为 C Pre-Processor(C 预处理器),是一个与 C 编译器独立的小程序,不是 C Plus Plus。

gcc 使用-E选项可以让编译过程在预处理步骤完成之后停止

1
gcc -E hello.c -o hello.i

注意必须指明输出到文件 hello.i,否则会把预处理结果直接输出到终端,建议的文件后缀为.i

得到的 hello.i 文件内容很长,仍然是可读的:文件前面的绝大部分都是 stdio.h 或者其他头文件中的,原本的 hello.c 的内容在最后几行

hello.i
1
2
3
4
5
6
...
# 3 "hello.c"
int main(){
printf("hello,world\n");
return 0;
}

2.2 编译

这是整个编译过程的核心步骤,gcc 调用的处理程序一般是 cc。

gcc 使用-S选项可以让编译过程在编译步骤完成之后停止

1
2
3
4
5
6
gcc -S hello.c -o hello.s
# 不加-o可以默认生成同名.s 汇编文件hello.s
# 加-o选项则可以自定义输出文件名

gcc -S hello.i -o hello.s
# 对.c或者.i文件都可以执行上述命令,效果一样

得到的文件是汇编语言的,全文如下

hello.s
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
        .file   "hello.c"
.text
.section .rodata
.LC0:
.string "hello,world"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl $.LC0, %edi
call puts
movl $0, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (GNU) 11.2.0"
.section .note.GNU-stack,"",@progbits

2.3 汇编

gcc 在这一环节调用的工具通常是 as。

gcc 使用-c选项可以让编译过程在汇编步骤完成之后停止

1
2
3
4
5
6
7
gcc -c hello.c -o hello.o
# 使用-c选项会让gcc在第三步汇编之后就暂停
# 不使用-o选项会默认生成同名的.o目标文件

gcc -c hello.i -o hello.o
gcc -c hello.s -o hello.o
# 同样可以从.i .s中间文件出发,效果也是一样的

得到的文件是二进制格式的目标文件。

2.4 链接

链接过程是程序构建过程的最后一步,通常 gcc 调用 ld 来完成。

链接指的是把目标文件组合起来,变成可执行文件。由于当前例子只有单个文件,没有库文件,只会自动链接系统的一些库,暂时不需要关注这些细节。

1
2
3
4
5
6
7
8
9
10
gcc hello.o -o hello
# 从目标文件得到可执行文件

gcc hello.o
# 省略-o选项相当于 -o a.out

gcc hello.c
gcc hello.i
gcc hello.s
# 同样可以从.c .i .s文件出发,效果也一样

生成的 hello 为可执行文件,直接运行即可

1
2
./hello
# hello,world

3. 编译过程的简介

3.1 预处理

预处理的工作主要包括:

  1. 文件包含,通常包括系统的头文件和自定义头文件的 include
  2. 宏定义的处理,例如#define A 100在相应的地方进行替换
  3. 条件编译,例如#ifdef,#ifndef,#else,#elif,#endif等,可以结合宏的使用实现部分编译,防止头文件重复包含等
  4. 特殊控制,例如#pragma命令,#error命令等,#pragma once这个命令也可以防止重复导入头文件
  5. 添加行号和文件名标识,清理注释内容等

预处理指令不属于 C/C++ 语言的标准语法,一定意义上也可以说预处理扩展了 C/C++,因此预处理的具体语法和实现,与编译器以及平台相关。

3.2 编译与汇编

这是编译过程的核心部分,没学过相关课程,只能记录一下主要步骤

  1. 词法分析
  2. 语法分析
  3. 语义分析
  4. 代码优化

到这一步为止,我们从源文件(和头文件)得到了目标文件(objective file),通常每一个源文件都应该对应产生一个目标文件。下一阶段是链接,会把目标文件和库文件结合在一起,得到可执行文件(二进制程序)。

为了得到正确的目标文件,这个环节重点检查:语法的正确函数以及变量的声明和使用的正确(这一阶段,只要能在头文件中找到正确的声明即可,并不负责检查外部函数和变量是否被正确实现和定义,可以接收空头支票)。

与之不同的是,下一阶段的链接器并不对源文件进行操作,它面对的就是目标文件和库文件,这个环节需要重点检查的是跨文件的符号(外部函数和外部变量)是否都被实现,并且把它们合并到一起(它需要检查,编译时开出的空头支票是否都被兑现),如果出现了符号未定义或者符号重复定义,就会报链接错误,这是最常见的两个问题。

3.3 链接

链接就是将目标文件和库文件打包、组装成可执行文件的过程。这里库文件就是早已完成的,可重复使用的成熟组件,而刚刚实现的源文件已经被处理为目标文件,等待着被一起打包组装。

链接环节要解决的问题是,怎么让各个不同的模块可以一起工作:有的文件中使用的符号在另一个文件中定义,有的函数具体在另一个文件中实现。链接需要让它们互相认识,重定位,确定符号的对应关系,统一进行地址分配等。

例如hello.c源文件使用了printf这个函数,通过stdio.h这个头文件中的函数声明可以明白它的用法,但是它的具体实现其实是在动态库libc.so.6中,链接的时候需要引用这个库文件。

根据指定的链接库函数的方式不同,链接过程可分为静态链接和动态链接两种,选择静态链接时,相当于把库中需要用到的部分直接拷贝过来;选择动态链接时,相当于仅仅登记一下库的基本信息,等执行的时候再去查找和加载动态库。

  • 静态链接:从库中将需要用到的部分内容直接拷贝到最终的可执行程序中。
    • 优点:可执行文件一旦生成,就与这个库没有联系,自己可以独立运行。
    • 缺点:可执行文件体积较大,有很多重复的空间占用;如果更新升级库中的功能,则所有用到这个库的可执行文件必须重新编译。
  • 动态链接:编译时暂时记录一下动态库的基本信息,但不会拷贝其中的具体内容,等到运行时再去寻找需要的动态库,将其加载到内存中一起执行。
    • 优点:可执行文件体积较小,可以节约空间,多个程序可以共同利用同一个基础的动态库;可以灵活升级动态库的功能,如果保证二进制层面的兼容性,那么用到它的相关程序不需要重新编译。
    • 缺点:可执行文件执行时仍然需要这个动态库,执行时很可能遇到动态库找不到,动态库版本不兼容等问题。

由于两种链接方式对应的文件格式不同,一个具体的库只能被动态链接或只能被静态链接。通常的库可以提供静态库版本和动态库版本,可以灵活选择。

gcc 如果同时找到了一个库的静态库版本和动态库版本,默认情况下会使用动态库,进行动态链接。 关于两种库和链接方式的比较见下文。

在 C 语言层面上,同一个系统上,不同的编译器得到的库一般是通用的;但是在 C++的层面上,即使在同一个系统上,不同的编译器得到的库是很可能不通用的,尤其是 MSVC 和其它编译器之间!(例如基于 MinGW 的 gcc 或 clang)