C++ 中的编译和链接

关于 C++ 的编译和链接, 掌握的知识总是零零散散,这里做个输出,也总结一下自己的思考和学习。

1. 常见编译器

对于最常见的 GCC:

  • GCC:GNU Compiler Collection(GNU 编译器集合)的缩写,可以理解为一组 GNU 操作系统中的编译器集合,可以用于编译 C、C++、Java、Go、Fortran、Pascal、Objective-C 等语言。
  • gcc:GCC(编译器集合)中的 GNU C Compiler(C编译器)
  • g++:GCC(编译器集合)中的 GNU C++ Compiler (C++编译器)

简单来说,gcc 调用了 GCC 中的 C Compiler,而 g++ 调用了 GCC 中的 C++ Compiler,对于 .c.cpp 文件,gcc 分别当作 C 和 CPP 文件编译,而 g++ 则统一当作 CPP 文件编译。实际上 gcc/g++ 命令是相应后台程序的包装(例如 C 的预编译和编译程序都是 cc1,C++ 则是 cc1plus),它会根据不同的参数要求去调用预编译,比如编译 cc1、汇编器 as、链接器 ld 等。

另外,在 Windows 系统中常见的是:

  • MSVS:微软的 Microsoft Visual C++ 带的编译器,Visual Studio 自带,编译 cl.exe、链接器 link.exe、目标文件查看工具 dumpbin 等用的就是它的工具链;
  • MinGW:Minimalist GNU for Windows 是 GCC 编译器移植到 Windows 下的编译器,安装后在 Win 环境下使用的 gcc、g++、gdb、objdump、nm 命令是它的工具链;

其他的可以参考 <一文搞懂C/C++常用编译器>

后文案例主要是在 Linux (Windows 下的 WSL)下用 gcc/g++ 执行的。

2. 构建步骤

从源文件到可运行文件,需要经历多个步骤:

2.1 预编译 Preprocess

预编译过程主要处理源文件中的以 # 开始的预编译指令和注释:

  • 将所有的 #define 删除,并展开所有的宏定义;
  • 处理所有条件预编译指令,比如 #if#ifdef#elif#else#endif
  • 处理 #include 预编译指令,将被包含的文件插入(直接复制)到该预编译指令的位置。注意,这个过程是递归进行的,也就是说被包含的文件可能还包含其他文件;
  • 删除所有注释 ///**/
  • 添加行号和文件名标识,比如 #2 "helo.c" 2,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号;
  • 保留所有的 #pragma 编译器指令,因为编译器需要使用;

我们用两个示例文件如下,其中有本地符号,也引用了外部符号:

c++ 复制代码
// hello.cpp 文件,其中有本地符号,也有引用了外部的符号,外部符号放在 a.cpp 中定义了
int g_init_var = 1;
extern int g_ref_var;  // 外部分超

int func1(int i) { return i; }
int func2(int& i);  // 外部符号

int main(void) {
  static int static_var = 2;
  static int static_var2;

  int a = 3;
  int b;

  b = func1(g_init_var + static_var + static_var2 + g_ref_var + a);
  func2(b);

  return b;
}

// a.cpp,其中对应了hello.cpp中引用的外部符号
int g_ref_var = 4;

void func2(int& i) { i += 1; }

然后预编译:

bash 复制代码
gcc -E hello.cpp -o hello.ii

这里 -E 表示只进行预编译,生成的 .ii 预编译文件打开是这样的,注释被移除了:

如果引入 iostream 之类的系统头文件,就会发现文件开头会多出几万行,都是递归引入的 iostream 头信息。

2.2 编译 Compilation

编译就是把预处理完的文件进行一系列词法分析、语法分析、语义分析及优化后,产生相应的汇编代码 Assembly Code:

bash 复制代码
gcc -S hello.ii -o hello.s

编译 .ii 文件生成了汇编代码文件 .s,可以打开看一下,里面都是汇编指令,另外如果留意一下,可以发现一些符号决议的端倪:

编译过程分为多个步骤:

  1. 词法分析,经过扫描器 Scanner 对词法进行分析,将源码的字符分割为一系列记号;
  2. 语法分析 ,经过语法分析器 Grammar Parser 将扫描器产生的记号进行语法分析,产生语法树 Syntax Tree; 比如一个赋值语句可以分解为下面的语法树:
  3. 语义分析,经过语义分析器 Semantic Analyzer 分析产生的语法树,语法分析仅仅是完成了对表达式的语法层面的分析,但是它并不了解这个语句是否真正有意义,比如将浮点型赋值给一个指针将会产生一个静态语义 Static Semantic 的错误,而动态语义 Dynamic Semantic 需要在运行时才会确定,比如将 0 作为除数。经过语义分析后,语法树的表达式(含符号和数字)都被标识了类型,如果有些类型需要做隐式转换,语义分析程序会在语法树中插入相应的转换节点。另外,语义分析对符号表里的符号类型也做了更新;
  4. 源代码优化 ,经过源代码级优化器 Source Code Optimizer 进行源代码级别的优化,比如 (2+6) 这样的表达式就可以被优化掉,因为它的值在编译期就可以被确定。经过优化后将语法树转换成机器无关的中间代码;
  5. 代码生成与优化,经过代码生成器 Code Generator 将中间代码转换为目标机器代码。因为不同机器具有不同的字长、寄存器、整数数据类型和浮点数据类型等,代码优化器 Target Code Optimizer 会将目标机器代码进行优化,比如选择合适的寻址方式、使用位移来代替乘法运算、删除多余的指令等;

经过这些步骤,将预编译文件生成了特定目标机器上的汇编代码,目标机器取决于 CPU 架构、操作系统等,比如 x86、ARM、RISC-V 架构的 CPU 使用的指令集也不同,Win、Linux、MacOS 操作系统上的系统调用机制不同,相互之间的汇编代码也不能相互通用,比如 x86 的 CPU 上如果要生成 ARM 机器能理解的汇编语言,就需要使用交叉编译器,或者在虚拟机环境下进行编译。

举个例子,比如在 x86 的 64 位 Ubuntu 上编译的 hello.o 目标文件:

  1. 在 ARM Android 手机设备上无法运行,因为指令集不兼容;
  2. 复制到 Windows 设备上无法运行,因为文件格式不识别;
  3. 复制到 x86 的 32 位系统上无法运行,因为 64 位指令不兼容;

2.3 汇编 Assembly

汇编是汇编器根据汇编指令和机器指令的对照表,将编译阶段得到的 .s 汇编文件翻译成二进制机器指令 Machine Code。每条汇编语句对应一条或几条机器指令,不仅如此,还要经过符号处理(汇编语句会引用外部符号)、段组织等步骤,最终生成 .o 目标文件:

bash 复制代码
# 等价于 as hello.s -o hello.o,gcc -c是调用的as命令
gcc -c hello.s -o hello.o

由于此时汇编代码翻译成的机器码会引用外部符号,而汇编器并不知道这些外部符号的具体地址,所以汇编器会将这些外部符号的地址暂时填 0(占位符机器码)。在链接的时候,这些外部符号的地址在符号决议环节才会被替换为真正的符号地址。

对于目标文件,会在下面单独进行解释。

2.4 链接 Linking

链接是编译的最后一步,会将前面编译好的目标文件、系统库、第三方库的 .o/.a/.so 文件连接起来,把各个模块之间相互引用的符号处理好,使得各个模块之间能够正确地衔接,最后组成一个可执行文件 Executable File。

bash 复制代码
# 将两个目标文件链接生成可执行文件
gcc hello.o a.o -o main

前面的预编译、编译、汇编可以用一个 -c 命令直接完成,也可直接把源码给到 gcc 命令,直接完成链接并生成可执行文件:

链接中,目标文件之间相互的拼合实际上是目标文件之间对地址的引用,即对函数和变量的地址的引用。每个函数或变量都有自己独特的名字,才能避免链接过程中不同变量和函数之间的混淆。函数和变量统称为符号 Symbol,函数名或变量名就是符号名 Symbol Name。

链接过程中很关键的一部分就是符号的管理,每一个目标文件都会有一个相应的符号表 Symbol Table,这个表里面记录了目标文件中所用到的所有符号。每个定义的符号都有个对应的值,叫做符号值 Symbol Value,对于变量和函数来说,符号值就是它们的地址。

链接分为好几个阶段:

  1. 地址和空间分配 Address and Storage Allocation,为最终输出文件(可执行文件或共享库)中的各个段分配运行时内存地址(虚拟地址)和确定它们在文件中的布局(大小和偏移量),同类型的段(比如代码段)会被分配在一起,即相似段合并。
  2. 符号决议 Symbol Resolution,检查所有输入目标文件的符号表,将导入符号和导出符号进行决议(或者说地址绑定),确定所有符号的引用关系,确保每个符号引用都能找到唯一且匹配的定义,最终生成一个符号表 Symbol Table,其中所有符号引用都已绑定到具体的定义地址(此时地址可能是临时的或基于段的偏移量)。
  3. 重定位 Relocation,根据前两个阶段的结果(段的最终地址和符号的最终地址),修改代码和数据中的具体地址引用(地址常量、函数调用目标地址、全局变量地址),将其替换为真正的相对偏移或者绝对地址;

这三个阶段环环相扣,使得编译器生成的、各自独立的目标文件能够正确地合并为一个可执行文件 .out 或动态链接库 .so

顺便提一句,某种程度上动态链接库和可执行文件内部结构是一样的都是 ELF 格式,有的工具可以让动态链接库作为程序运行。

3. 目标文件 .o / .obj

一个源文件对应编译生成一个可重定位目标文件 .o / .obj,其中包含对其他文件或库中定义的符号 symbol(如函数、变量)的引用,此时符号引用还未决议和重定位,只是一个待填的空,这里可以看看目标文件里的结构和其中的符号表。

3.1 目标文件结构

此时生成的目标文件,可以使用 objdump -h 查看其中的段(Windows 上可以用 MSVC 的 dumpbin /headers 查看):

从上述的结果我们可以看出 hello.o 一共有 8 个段,分别是:

  1. .text 代码段 Code Section,源码编译后的机器指令通常被放在代码段;
  2. .data 数据段 Data Section,已经初始化的全局变量和局部静态变量数据通常放在数据段;
  3. .bss 未初始化数据段 BSS,未初始化全局变量、局部静态变量通常放在未初始化数据段,只申请内存而不占据文件内容;
  4. .rodata 只读数据段,一般是程序里面的只读变量(如 const 修饰的变量)和字符串常量;
  5. .comment.note.GNU-stack.note.gnu.property.eh_frame 为编译器信息段、堆栈提示段、编译信息段、异常处理段,是一些编译辅助信息和错误处理相关逻辑;
  6. .symtab 静态符号表,包含所有符号(全局、局部、未定义等)。

在每个段的下面都有一行信息,是该段属性的描述,含义如下:

  • CONTENTS 占据文件内容,除了未初始化数据段,其他都会占据文件内容;
  • ALLOC 运行时申请内存,除了编译信息和异常处理段之类的辅助信息的段,其他都会申请内存;
  • LOAD 可作为装载数据使用;
  • RELOC 加载时可重定位;
  • READONLY 内容只读,除了数据段和未初始化数据段,都会包含这个属性;
  • CODE 内容为机器码,比如代码段就会包含这个属性;
  • DATA 可读写程序数据;

可以发现未初始化数据段 .bss 是只申请内存而不占据文件内容的,因为 .bss 是未初始化的全局变量和局部静态变量的预留位置,需要在进程创建的时候申请对应内存空间,在目标文件中不占用位置。

另外,图中的 VMA 虚拟地址和 LMA 加载地址都为 0(0x0000000000000000),这是因为此时还没有进行重定位,需要在链接为动态库或可执行文件后才会有值,可以从下面动态链接库的图中看到 VMA 和 LMA 已经有值了。

3.2 符号表

注意到上图 objdump -h 结果里没有展示符号表,并不是 .o 文件没有符号表,而是因为 .symtab 是调试节所以 objdump -h 默认没有展示,用 readelf -S 就可以看到 .symtab 静态符号表:

顺便说一句,.strtab 字符串表 String Table 用来保存普通字符串。

可以用 objdump -t、nm、readelf -s 命令专门查看目标文件中的符号表:

先看看 objdump -t 的返回值:

  1. 第 1 列为符号在目标文件中的偏移地址,可以看到只有在代码段的已初始化静态变量和代码段有地址;
  2. 第 2 列为符号的绑定类型l 表示本地符号 local,只有目标文件内部可见,g 表示全局符号 global(如 func1maing_init_var,Linux 上符号默认对外可见,需要手动在编译时用 -fvisibility=hidden 或用 __attribute__((visibility("hidden"))) 将符号设为外部不可见,在 Win 上符号默认对外不可见,需要用 __declspec(dllexport) 主动将符号标注为外部可见),设为外部不可见之后, objdump -t 或 nm 就找不到这个符号了。
  3. 第 3 列为符号类型F 函数,O 对象,d 节,df 文件名。
  4. 第 4 列为符号所在节 ,特别要注意的是 *UND* 表示未定义符号 undefined,表示外部符号引用(如 func2g_ref_var),这些符号需要在后续的链接阶段进行符号决议,否则就会看到熟悉的 无法解析的外部符号 undefined reference to xxx 报错了,因为链接器找不到对应符号。*ABS* 表示绝对符号 absolute,比如文件名。
  5. 第 5 列为占用大小 ,以字节为单位,可以看到虽然 .bss 节中未初始化静态变量段虽然没有地址,但也会占用大小,后续进程运行时会被初始化为 0 并占用此处记录的大小的内存空间。
  6. 第 6 列为符号名 ,注意:
    1. 这里 .bss 段的静态变量为了避免与其他局部变量发生命名冲突,也经过了名称修饰 Name Mangling;
    2. 对于 C++ 中的函数,为了支持 C++ 的函数重载,也会进行名称修饰,修饰的规则每个编译器不一样,各自有各自的一套规则,一般来说会带上返回值和参数类型,这也是为什么 C++ 调用 C 库中的符号需要 extern "C" 的原因
    3. __stack_chk_fail 是一个编译器插入的用来检测栈溢出等安全问题的栈保护符号;

对于 nm 的返回值:

  1. 第 1、3 列是偏移地址,和之前一样;
  2. 第 2 列是符号类型,可以与 objdump -t 返回值中的属性一一对应:
    1. T 表示全局函数,同前面 gF 属性位于 .text 段的符号。
    2. D 表示全局已初始化数据,同前面 .datagO 属性的符号。
    3. d:已初始化局部静态数据,同前面 .datalO 属性的符号。
    4. b:未初始化数据,同位于 .bss 段的符号。
    5. U:未定义符号,同之前的 *UND*

3.3 重定位表

链接器是通过重定位表 Relocation Table 知道哪些指令需要被调整,以及这些指令如何调整。

前面 readelf -S 结果中的 .rela.text 就是重定位表,它包含了代码段的重定位条目,记录需要调整的外部符号引用,比如 hello.cpp 中调用的 func2,在编译时其实是不知道具体地址的,此时在这个重定位表中就会记下这个符号和其所在的相对偏移,后续在链接的重定位阶段才会知道 func2 的真正地址,再根据重定位表找到之前的符号并回填为真正地址。

对于每个需要重定位的代码段或数据段,都会有一个相应的重定位表,.rela.text 就是针对 .text 段的重定位表。所以有的目标文件会有 .rela.data 就是针对数据段的重定位表。

用 objdump -r 可以查看目标文件的重定位表:

其中的 OFFSET 表示该入口在要被重定位的段中的位置;

3.4 强符号、弱符号

编译器默认函数和初始化了的全局变量为强符号 Strong Symbol,未初始化的全局变量为弱符号 Weak Symbol。也可以通过 GCC 的 __attribute__((weak)) 来定义任何一个强符号为弱符号。

注意,强符号和弱符号都是针对定义来说的,不是针对符号的引用

c++ 复制代码
extern int ext;  // 既非强符号也非弱符号,是一个外部变量的引用
int weak;        // 弱符号
int strong = 1;  // 强符号
__attribute__((weak)) weak2=2; // 弱符号
void func() { }  // 强符号,函数实现

针对强弱符号的概念,链接器按如下规则处理与选择被多次定义的全局符号

  • 规则1: 不允许强符号被多次定义(即不同的目标文件中不能有同名的强符号),如果有多个强符号定义,则链接器报符号重复定义错误 multiple definition
  • 规则2: 如果一个符号在某个目标文件中是强符号,在其他文件中都是弱符号,那么选择强符号;
  • 规则3: 如果一个符号在所有目标文件中都是弱符号,那么选择其中占用空间最大的一个。

4. 静态链接库 .a / .lib

静态链接库可以认为是多个目标文件的集合或者说容器,这样就不需要在每次链接目标文件的时候敲写很多文件名,方便管理,静态链接库虽然称为库,不如说是一个压缩包,只是把目标文件拼在一起。

bash 复制代码
# 把两个目标文件打包成 .a 静态库
ar rcs libexample.a file1.o file2.o

然后甚至可以用 ar x 将静态库解压缩还原成两个 .o 目标文件,然后如果再有一个 file3.o,可以用 ar -q 往静态链接库里塞一个新的 .o

bash 复制代码
# 把 .a 静态库解开,此时目录下会多出两个.o文件
ar x libexample.a

# 添加一个新的目标文件到 .a 中
ar -q libexample.a file3.o

# 将静态库里一个目标文件移除
ar -q libexample.a file1.o

ar 命令是 archive 的缩写,是归档的意思,已经暗示了静态库其实是归档的含义,只是目标文件的简单归档,没有做符号决议和重定位等操作,段信息也没有合并。链接器在链接静态链接库的时候,会去库里找需要的符号所在的目标文件并解压出来进行链接,也就是说只会提取需要的目标文件出来链接。

所以说,静态链接库只是目标文件的简单集合,或者说归档的容器。

静态库的缺点:

  1. 链接器在链接静态库或者目标文件时是以文件为单位的。若该目标文件中还有其他我们并没有用到的函数,也会一起被链接进可执行文件,造成空间浪费。
  2. 若多个可执行文件都有依赖同一份目标文件,则该目标文件会被合并到每个可执行文件中,即在每个可执行文件中都会存在一个副本,也会造成空间浪费。
  3. 如果一个可执行文件依赖的底层静态库有一个改动,则静态库和可执行文件都需要重新编译,再部署到设备或发布给用户,即全量更新。因为经过链接阶段的地址和空间分配,静态库中的相关目标文件部分会嵌入到可执行文件中,所以全量更新是必须的。

为了解决这些问题,后来人们发明了动态链接和动态库。

5. 动态链接库 .so / .dll

通过下面的命令生成动态库:

bash 复制代码
gcc -fPIC -shared hello.cpp a.cpp -o hello.so

-fPIC 表示位置无关 Position-Independent Code,其中的代码段可加载到内存的任意地址(加个偏移基址即可),而无需修改指令内容。位置无关使得动态库非常适合在多进程环境中使用。多个进程引用同一个动态库时,操作系统只需将库的代码段加载到内存一次,所有进程共享动态库的代码段,而数据段(.data.bss)则为每个进程分配独立的内存空间,确保数据隔离。

在动态链接库的构建过程中,内部符号已在链接阶段完成解析。例如,hello.so 中的代码段、已初始化数据段和 未初始化数据段的内部符号引用已确定相对偏移地址,形成固定的内存布局。

对于外部符号 ,比如一些依赖于其他库或者标准库函数的符号的解析被推迟到运行时,称之为延迟绑定 机制 Lazy Binding,由动态链接器 ld.so 在程序加载时完成,所以在动态库源码修改时,只要导出符号不变,只需重新编译动态库即可,可执行文件不需要重新编译,只需要重启进程,即增量更新

可以查看一下动态库的段信息:

动态链接库通常包含一个动态符号表 .dynsym,记录了动态链接所需的导出符号和导入符号。动态字符串表 .dynstr 会存储这些符号的名称。这些表为动态链接器提供了必要的信息,以便在运行时完成符号决议和重定位。

表中还有一些带有 gotplt 关键字的表,是提供符号重定位和延迟绑定相关信息的表。

另外,经过链接,得到的可执行文件或共享库已经被重定位过了,不再需要重定位表,因此 .rela.text.rela.data 没有了;同时,VMA 和 LMA 已经被具体赋值,不再是 0。

5.1 动态库链接方式

显式运行时链接 Explicit run-time Linking

在代码中通过 dlopendlfree 的方式加载、释放动态链接库,在 Win 上使用 LoadLibraryFreeLibrary ,这种使用代码显式加载动态库的方式称为显式运行时链接 Explicit run-time Linking,这种方式比较灵活,可以在代码中精准地控制加载和卸载动态库的所有细节。

优势是程序在编译链接时完全不需要动态库的参与,程序在运行时可以根据需要有选择性地进行加载或卸载动态库,即使在运行时对某个动态库的加载失败也不会导致程序中止。

隐式载入时链接 Implicit load-time Linking

隐式加载 Implicit load-time Linking 是最常用的方式,通的 C++ 标准库等系统中的库都是采用隐式加载的,程序在编译时需要添加 -l 选项链接到动态库。在程序启用时,系统会自动查找并加载对应的动态库。

隐式加载的优势是代码简单,不需要在代码中处理加载动态库的各种细节。但缺点是要求在编译时动态库也要参与链接,在编译时和运行时都需要保证动态库是可以找到并且使用的,编译时无法找到则编译失败,运行时无法找到和使用则程序无法启动。


网上的帖子大多深浅不一,甚至有些前后矛盾,在下的文章都是学习过程中的总结,如果发现错误,欢迎留言指出,如果本文帮助到了你,别忘了点赞支持一下,你的点赞是我更新的最大动力!~

参考文档:

  1. 一文搞懂C/C++常用编译器
  2. 程序员的自我修养
  3. 计算机体系结构
  4. 动态链接库与静态链接库有什么区别

PS:本文同步更新于在下的博客 Github - SHERlocked93/blog 系列文章中,欢迎大家关注我的公众号 CPP下午茶,直接搜索即可添加,持续为大家推送 CPP 以及 CPP 周边相关优质技术文,共同进步,一起加油~

另外可以加入「前端下午茶交流qun」,vx 搜索 sherlocked_93 加我,备注 1,我拉你~

相关推荐
Java技术小馆9 分钟前
RPC vs RESTful架构选择背后的技术博弈
后端·面试·架构
爱学习的茄子1 小时前
【踩坑实录】React Router从入门到精通:我的前端路由血泪史
前端·javascript·面试
Jackson_Mseven1 小时前
🎯 面试官:React 并发更新怎么调度的?我:Lane 就是调度界的 bitmap!
前端·react.js·面试
有冠希没关系2 小时前
Ffmpeg滤镜
c++
Goboy2 小时前
温故而知新,忆 Spring Bean 加载全流程
后端·面试·架构
闻缺陷则喜何志丹3 小时前
【并集查找 虚拟节点】P1783 海滩防御|省选-
数据结构·c++·洛谷·并集查找·虚拟节点
用户6853000754753 小时前
双指针法解决力扣922题:按奇偶排序数组II的完整指南
c++
CodeWithMe4 小时前
【读书笔记】《C++ Software Design》第十章与第十一章 The Singleton Pattern & The Last Guideline
开发语言·c++·设计模式
UP_Continue4 小时前
C++--List的模拟实现
开发语言·c++
小赵小赵福星高照~4 小时前
iOS UI视图面试相关
ui·ios·面试