(三)编译那些事儿:编译 c++

(三)编译那些事儿:编译 c++

1. introduction

程序的编译,主要是这四个阶段: 预处理、编译、汇编、链接。其中预处理就是对头文件进行文本替换为主,唯一出错的点就是头文件找不到,除此之外一般不会出错;而编译阶段涉及到语法内容,是编译出错的主要原因之一,但是大部分出错都很好理解,编译器也能够给出直接的修改意见,唯独模板相关出错需要特别小新;至于汇编则是将汇编代码转为二进制语言的过程,这个是编译器设计的内容,对于一般开发来说也不需要感知,也不会出错;而链接,则是多个目标文件和库文件合并生成可执行文件,涉及符号查找等,也会引发一些链接错误。

因此,编译 c++ 代码时,常见的错误主要集中在:预处理阶段:头文件找不到编译阶段:语法错误、类型不匹配、模板实例化等有关链接阶段:多与符号未定义、重复定义等问题相关。

下面通过若干场景来说明 c++ 编译过程中可能遇到的问题。

2. 预处理阶段

2.1 头文件找不到

在预处理阶段,编译器会根据源文件中的#include指令来查找并包含相应的头文件。如果指定的头文件路径不正确,或者头文件不存在,就会导致编译错误,通常表现为“file not found”或类似的错误信息。

一般而言,头文件都不会给出绝对路径,而是使用相对路径,这种需要要编译器知道头文件的搜索路径。常见的头文件搜索路径包括:

  1. 当前源文件所在目录:编译器会首先在当前源文件所在的目录中查找头文件。
  2. 指定的包含目录:通过编译器选项(如-I)指定的目录,编译器会在这些目录中查找头文件。
  3. 系统默认目录:编译器会在系统默认的头文件目录中查找头文件,例如标准库的头文件所在目录。

如果出现头文件找不到的错误,可以通过以下几种方式来解决:

  1. 检查头文件路径是否正确:确保#include指令中的路径与实际文件路径一致。
  2. 添加头文件搜索路径:使用编译器选项(如-I)来指定包含目录,确保编译器能够找到所需的头文件。
  3. 检查文件是否存在:确保头文件实际存在于指定的路径中。

3. 编译阶段

3.1 预编译头

在编译阶段,是没有头文件概念,那如果多个.cpp都使用了了同一份头文件,那是不是所有翻译单元都会包含这份头文件的内容,导致不同翻译单元有非常多的雷同?

是的,每个翻译单元在编译时都会包含头文件的内容,导致不同翻译单元中可能存在大量重复代码。为了解决这个问题,可以使用预编译头(Precompiled Header, PCH)技术。预编译头允许将常用的头文件预先编译成一个二进制格式的文件,然后在后续的编译过程中直接使用这个预编译头文件,从而减少重复编译的时间和资源消耗。

那么,预编译头文件是如何工作的呢?在编译器支持预编译头的情况下,开发者可以指定一个头文件作为预编译头,编译器会在第一次编译时将该头文件及其包含的内容编译成一个中间文件(通常是二进制格式)。在后续的编译过程中,编译器会直接加载这个预编译头文件,而不是重新处理原始头文件,从而加快编译速度。

但是使用预编译头文件可能会增加翻译单元的体积,因为预编译头文件包含了多个头文件的内容,即使某些源文件并不需要其中的所有内容,编译器仍然会将整个预编译头文件加载到翻译单元中。这可能导致生成的目标文件体积增大,尤其是在预编译头文件包含大量不必要内容时。

什么文件适合放在预编译头中呢?通常,预编译头文件适合包含那些在多个源文件中频繁使用且不经常变化的头文件,例如标准库头文件、第三方库头文件以及项目中常用的自定义头文件。通过将这些常用但不常变化的头文件放入预编译头,可以显著减少编译时间,提高开发效率。

cmake 工程中如何指定预编译头文件呢?在 CMake 中,可以使用target_precompile_headers命令为特定的目标指定预编译头文件。例如:

target_precompile_headers(MyTarget PRIVATE "path/to/precompiled_header.h")

3.2 模板实例化

  • 如果在 A.h 给出模板类的声明,在 A.cpp 中给出模板类的实现,然后在 b.cpp 使用这个模板类。那么会发生什么情况?

在链接阶段会发生符号未定义错误(undefined reference error)。

A.cpp 中的模板类由于没有看到具体的模板参数,因此不会被编译器实例化。B.cpp 中使用该模板类时,编译器需要找到该模板类的定义以进行实例化,但由于 A.cpp 中没有实例化该模板类,链接器无法找到对应的符号定义,导致报错。

如果在 A.cpp 中显式地实例化该模板类,会怎样呢?那就不会报错了,因为 A.cpp 中已经生成了该模板类的实例化代码,链接器可以找到对应的符号定义,从而成功链接。

/*--- A.h ---*/
#pragma once

// 模板类声明,没有具体实现
template <typename T>
class A {
  public:
    A();
    void f();
};

/*--- A.cpp ---*/
#include "A.h"
#include <iostream>

template <typename T>
A<T>::A() {
    std::cout << "A ctor\n";
}

template <typename T>
void A<T>::f() {
    std::cout << "A::f\n";
}

// 显式实例化,确保在本 TU 生成 A<int>
template class A<int>;
/*--- B.cpp ---*/
#include "A.h"
int main() {
    A<int> a; // 使用模板类 A<int>,此时链接器可以找到 A<int> 的定义
    A<double> aa; // 如果没有在 A.cpp 中实例化 A<double>,这里会报符号未定义错误
    a.f();
    return 0;
}

因此,如果模板类的实现放在 cpp 文件中,必须在该 cpp 文件中显式实例化所需的模板参数,才能确保链接器能够找到对应的符号定义,避免符号未定义错误。这种做法能够限制外部对模板类的使用范围,但也增加了维护成本,因为每次需要使用新的模板参数时,都必须在实现文件中添加相应的显式实例化代码。

通常情况下,这种限制都是不必要的,所以都会将模板类的实现一般都放在头文件中,以便于编译器在需要时进行实例化。

但模板实例化存在一定开销,为了减少多个翻译单元中重复实例化相同模板的开销,可以使用显示实例化(explicit instantiation)技术。通过在一个翻译单元中显式实例化模板,可以避免在其他翻译单元中重复实例化相同的模板,从而减少编译时间和生成的代码体积。即在A.h同时给出模板类的声明和定义,再额外给出某个具体类型的显示实例化声明:

/*--- A.h ---*/
#pragma once
#include <iostream>

// 模板类声明,没有具体实现
template <typename T>
class A {
public:
    A() { std::cout << "A ctor\n"; }
    void f() { std::cout << "A::f\n"; }
};
extern template class A<int>; // 显式实例化声明

/*--- A.cpp ---*/
#include "A.h"

// 显式实例化,确保在本 TU 生成 A<int>
template class A<int>;

/*--- B.cpp ---*/
#include "A.h"
int main() {
    A<int> a; // 使用模板类 A<int>,此时链接器可以找到 A<int> 的定义
    a.f();
    return 0;
}

4. 链接阶段

  • 重复定义: c++的每一个源文件(.cpp)在经过预处理阶段的头文件的文本替换后,在编译时会被视为一个独立的翻译单元(translation unit)。每个翻译单元都会被编译成一个目标文件(object file),然后这些目标文件会在链接阶段被合并成最终的可执行文件或库文件。换句话说,头文件的概念仅存在于预处理阶段,编译、汇编、链接阶段,并不存在头文件的概念。

目标文件中包含了该翻译单元中定义的所有函数和变量的符号信息。如果在不同的翻译单元中定义了相同名称的函数或变量,并且这些定义没有使用inline关键字或其他方式进行区分,那么在链接阶段就会出现重复定义的问题。

  • 符号未定义: c++程序在编译时会将函数和变量的声明与定义分开处理。如果在某个翻译单元中引用了一个函数或变量(即通过头文件引入了函数、变量的声明),但该函数或变量的定义不在当前翻译单元中,链接器需要在其他目标文件或库文件中找到该符号的定义。如果链接器无法找到该符号的定义,就会报出符号未定义的错误。这通常发生在以下几种情况:
  1. 忘记链接某个库文件,导致其中的符号无法找到。
  2. 函数或变量的定义被条件编译排除,导致链接器找不到对应的符号。
  3. 函数或变量的定义在另一个翻译单元中,但该翻译单元没有被正确编译或链接。

如果库的函数希望被其他地方使用,一般需要对外暴露函数的定义,即通过头文件进行函数的暴露,并确保在链接阶段包含相应的库文件。

4.1 如果在 A.cpp 和 B.cpp 分别定义和声明了同一个函数void foo(),并且都没有使用inline关键字,那么在链接阶段会发生什么情况?为什么?

在链接阶段会发生重复定义错误(multiple definition error)。

这是因为 C++ 规定同一个函数在整个生成目标中(可执行文件、静态库、动态库)只能有一个定义。如果在同一个生成目标中 A.cpp 和 B.cpp 中都定义了void foo(),链接器在合并目标文件时会发现有两个相同名称的函数定义,导致冲突,从而报错。

inline关键字的语义是允许函数在多个翻译单元中有多个定义,但这些定义必须是相同的。另外,inline和是否内联无关,主要是为了允许函数在多个翻译单元中定义而不引发链接错误。inline的实现又和static不同,static是将函数的链接性限制在当前翻译单元内,而inline允许跨翻译单元的多重定义。尽管两者都可以避免重复定义错误,但它们的语义和使用场景不同。

简单来说,static就是表面内部链接,而inline是允许多重定义但要求定义一致。static的话很有可能修饰的函数在不同翻译单元中实现不一样,而inline修饰的函数在不同翻译单元中实现必须是一样的。

也就是说,使用inline的话,在多个翻译单元会有同一份定义,但是这些定义在链接阶段的时候会做特殊处理,进行合并,从而避免重复定义错误。因此使用inline和增加运行时二进制体积没有直接关系,只有真正的内联展开才会影响运行时的二进制体积,但是这个和inline关键字本身没有直接关系,是否内联展开是编译器的优化决策,但是inline关键字一般建议编译器进行内联展开。

4.2 未定义符号错误

对于gcc/clang编译器来说,一般符号都是全部导出的,除非使用-fvisibility=hidden来隐藏符号,否则一般不会出现符号未定义错误。但是对于msvc编译器来说,符号的导出和隐藏是通过__declspec(dllexport)__declspec(dllimport)来控制的。如果在使用动态链接库时,没有正确地使用这些关键字,就可能导致符号未定义错误。

比如说,假设有一个动态链接库mylib.dll,其中定义了一个函数void foo(),并且使用了__declspec(dllexport)来导出该函数:

// moduleA - mylib.h
#ifdef MYLIB_EXPORTS
#define MYLIB_API __declspec(dllexport)
#else
#define MYLIB_API __declspec(dllimport)
#endif

MYLIB_API void foo();

// moduleA mylib.cpp
#include "mylib.h"
void foo() {
    // 函数实现
}

// .exe main.cpp
#include "mylib.h"
int main() {
    foo(); // 调用动态链接库中的函数
    return 0;
}

如果在编译mylib.dll时没有定义MYLIB_EXPORTS宏,那么foo()函数就不会被正确导出,导致在链接阶段出现符号未定义错误。为了解决这个问题,需要确保在编译动态链接库时定义了MYLIB_EXPORTS宏,以正确导出符号。

那为什么 msvc 和 gcc 有这个表现差异呢?这个就是设计理念的区别了,gcc/clang 采用的是默认导出所有符号的设计理念,而 msvc 则采用了默认不导出符号的设计理念。这样做的好处是可以更好地控制符号的导出和隐藏,避免不必要的符号暴露,从而减少命名冲突和二进制体积。也许是windows平台开发的时候,重命名冲突问题更严重一些,所以msvc采用了这种设计理念。

4.3 链接优化

链接优化(Link Time Optimization, LTO)是一种在链接阶段进行的优化技术,旨在通过跨翻译单元的分析和优化来提高程序的性能和减少代码体积。LTO 允许编译器在链接阶段对整个程序进行全局优化,而不仅仅局限于单个翻译单元,从而实现更有效的优化策略。

在将.obj合并成可执行文件或库文件时,链接器可以利用 LTO 技术对整个程序进行分析,识别出跨翻译单元的优化机会。例如,LTO 可以识别出未使用的函数和变量,并将其从最终的可执行文件中移除,从而减少代码体积。此外,LTO 还可以进行函数内联、循环展开等优化操作,以提高程序的运行效率。

但是链接优化偶尔会引发错误,我就曾经遇到过某一个静态变量的开启链接优化后,发现没有使用,就优化掉了,但是这个静态变量的构造函数承载着某些重要的初始化逻辑,结果导致程序运行时出现异常行为。

为了解决这个问题,可以采取以下几种方法:

  1. 使用属性标记:某些编译器允许使用特定的属性标记来防止特定的变量或函数被优化掉。在gcc/clang中,可以使用__attribute__((used))来标记变量或函数,确保它们在链接阶段不会被优化掉。例如:
static int important_var __attribute__((used)) = initialize_important_var();

msvc中,可以使用__declspec(selectany)来标记变量,防止其被优化掉。例如:

struct S { S() { /* 重要初始化 */ } };
static S s;

// 提供一个 C 风格 wrapper(避免 name mangling)
extern "C" void touch_s_wrapper() { (void)&s; }

// 在任何需要强制链接此 TU 的地方(或同一 TU 中)写 pragma
#pragma comment(linker, "/INCLUDE:touch_s_wrapper")
  1. 显式引用:在代码中显式引用那些重要的静态变量或函数,以确保它们在链接阶段不会被优化掉。

  2. 禁用 LTO:如果某些变量或函数确实需要保留,但无法通过属性标记或显式引用来实现,可以考虑在编译选项中禁用 LTO,以避免优化掉这些重要的代码。




    Enjoy Reading This Article?

    Here are some more articles you might like to read next:

  • (三)内核那些事儿:CPU中断和信号
  • (二)内核那些事儿:程序启动到运行的完整过程
  • (一)内核那些事儿:从硬件抽象到系统服务的完整框架
  • (七)内核那些事儿:操作系统对网络包的处理
  • (五)内核那些事儿:系统和程序的交互