Skip to content

Compiler

编译器怎么给变量分配寄存器

C++编译流程

image-20231019093714449

预处理(Preprocessing) → 编译(Compilation) → 汇编(Assembly) → 链接(Linking)

Preprocessing

  • 处理所有以 # 开头的指令,例如:
  • #include → 展开头文件;
  • #define → 宏替换;
  • #ifdef / #ifndef → 条件编译;
  • 删除注释;
  • 生成纯净的源代码(文本级替换)。
g++ -E main.cpp -o main.i

pragma once

#pragma once 编译器记录该文件的文件路径或 inode(唯一标识);如果再次包含相同文件(即使通过不同路径引用),会直接跳过。不是 C++ 标准语法(属于编译器扩展)但几乎所有现代编译器都支持(GCC、Clang、MSVC、ICC 都支持)

Compilation

g++ -S main.i -o main.s
  • 词法分析 (Lexical Analysis):将代码切分成一个个 Token(如关键字、标识符、运算符)。
  • 语法分析 (Syntax Analysis):检查语法是否正确,并生成 抽象语法树 (AST)
  • 语义分析 (Semantic Analysis):检查类型匹配、作用域等(例如:不能把字符串赋值给整数)。
  • 中间代码生成 (IR Generation):生成一种独立于具体 CPU 架构的代码(如 LLVM IR)。
  • 代码优化 (Optimization):根据 -O2-O3 标志进行优化(如删除无用代码、循环展开)。
  • 生成汇编代码

Assembly

g++ -c main.s -o main.o

把汇编指令翻译成机器码;

生成符号表(Symbol Table),记录函数和变量的名字及其相对地址。

Linking

g++ main.o other.o -o main

符号解析(symbol resolution);

地址重定位(relocation);

把外部库函数(如 printf)的引用解析成实际地址;

静态链接 (Static Linking):将库代码(.a / .lib)直接拷贝到可执行文件中。文件较大,但独立性强。

动态链接 (Dynamic Linking):仅记录库的名字(.so / .dll),运行时由操作系统加载。文件小,但依赖环境。

合并多个目标文件生成最终可执行文件。

链接方式

https://hansimov.gitbook.io/csapp/part2/ch07-linking

不是在编译时的 linking 阶段 做的,而是在程序运行时(runtime) 由操作系统的 动态链接器(dynamic linker/loader) 完成的。

在最终可执行文件(ELF)的“动态段”(.dynamic)中,写入:Needed library: libmylib.so

静态链接

把库代码直接复制进可执行文件(如 .a)。在编译过程发生。linux 的连接器是 ld ,做2个工作

  • 符号解析symbol resolution:每个符号引用(用了函数)正好和一个符号定义(定义了函数)关联
  • 重定位:然后修改所有对这些符号的引用,使得它们指向这个内存位置

符号解析

C++有强符号和弱符号

  • 强符号: 一般是有定义的全局函数 / 全局变量
  • 弱符号
  • 未初始化的全局变量“旧风格”下会被当成 common/弱符号
  • 显式标记为 __attribute__((weak)) 的符号

根据强弱符号的定义,Linux 链接器使用下面的规则来处理多重定义的符号名:

  • 规则 1:不允许有多个同名的强符号。
  • 规则 2:如果有一个强符号和多个弱符号同名,那么选择强符号。
  • 规则 3:如果有多个弱符号同名,那么从这些弱符号中任意选择一个。

重定位

合并同类项:

  • 把所有 .text 拼成大的 .text
  • 把所有 .data 拼成大的 .data
  • 把所有 .bss 拼成大的 .bss
  • … 这些“拼起来”的大段,就是将来可执行文件里的段

给它们排“座位”(分配运行时地址):

  • 比如:可执行文件里的 .text 最终从 0x400000 开始,
  • 某个输入文件 a.o.text 占前 0x300 字节,
  • 下一个 b.o.text 接在 0x300 之后……
  • .data.bss 也类似。
  • 每个符号的运行时地址 = 所在段的基址 + 段内偏移。

动态链接

implicit linking

程序启动时由系统自动加载动态库.

编译器不会把库内容拷进去,只在可执行文件里写下“依赖信息”。程序运行时再加载共享库(如 .so / .dll)。 当执行 ./app 时:

  • 操作系统的动态链接器(Linux 是 ld-linux.so,Windows 是 ntdll.dll/kernel32.dll)启动;
  • 它会去找所需的 .so / .dll 文件;动态链接器读取 .dynamic 区段
  • 加载进进程的内存;把库文件 mmap 进内存;
  • 修正符号地址(relocation);
  • 如果是 eager binding, 马上进行符号解析(比如找到 foo() 的真实地址),填写GOT;
  • 如果是 lazy binding, 等程序第一次调用的时候解析。
  • 然后再跳到 main() 执行。
#include <iostream>
#include "mylib.h"
int main() {
    foo();  // 函数定义在 libmylib.so 中
}

第一次调用外部函数时,通过 PLT(Procedure Linkage Table)和 GOT(Global Offset Table)间接跳转

链接器在那一刻查找并缓存地址;

PLT: 用于函数调用的中转表,存储的是:一段间接跳转指令

printf@PLT  GOT[printf] ()   resolver  写入真实地址

GOT: 存放外部符号(函数、全局变量)真实地址的表。程序通过读取 GOT 来间接访问外部符号。

explicit linking

程序运行过程中手动加载库

dlopen 打开,然后 dlsym 找到函数地址,然后执行

编译选项

fvisibility

-fvisibility=hidden 告诉编译器:默认情况下,所有符号(函数、变量)在共享库中都不导出为可见符号

Bsymbolic

默认情况下,即使库内部函数之间相互调用,编译器也可能通过 PLT/GOT 调用,在运行时重新解析函数地址。这会造成两个问题:

  • 性能损耗(多一次 PLT 跳转)
  • 符号冲突风险(库内部函数被外部符号覆盖)

-Bsymbolic 告诉链接器:库内部的符号引用在链接时就绑定,不走运行时重定位。

LTO 是“链接时优化”,让编译器在链接阶段对所有目标文件进行整体优化,而不仅仅在编译单个 .cpp 文件时优化。

  • 编译器在生成 .o 文件时,保留中间表示(IR, Intermediate Representation)

使用方法:

  • 编译选项 -flto
  • 或者CMAKE里 set(CMAKE_INTERPROCEDURAL_OPTIMIZATION TRUE)

Optimization Level

  • O0 :不进行任何优化
  • O1: 启用基本的优化,保证不显著增加编译时间
  • 删除未使用的代码
  • 局部变量寄存器分配
  • O2: 启用绝大多数安全且通用的优化,不牺牲稳定性。
  • 启用循环优化(循环展开、循环不变代码外提);
  • 启用内联优化(inline 展开);
  • 启用指令调度与寄存器重命名
  • 启用全局子表达式消除
  • O3: 启用更激进的优化,可能导致代码尺寸增大
  • 启用更深入的循环展开,Loop peeling
  • 启用矢量化(自动 SIMD 优化)vectorization
  • 启用函数内联的更高阈值
  • 启用更多推测性优化
  • Ofast: 极限优化
  • 忽略严格的 IEEE 754 浮点运算规则
  • 允许编译器假设没有 NaN、没有负零、没有溢出
  • 不保证精确的浮点结果或标准兼容性

其他选项

  • Os : 启用大多数 -O2 的优化,但禁用那些会显著增加代码体积的优化。
  • Og: 为调试友好优化

优化方式

Volatile

使用 volatile 关键字可以保证一个变量不被优化。

Loop unrolling

是编译器通过复制循环体内部的代码来减少循环控制开销、提高指令级并行性(ILP)的优化技术。

Loop peeling

循环剥离就是把循环的前几次(或后几次)迭代单独拿出来执行

for (int i = 0; i < n; ++i) {
    if (i == 0)
        a[i] = 0;
    else
        a[i] = a[i - 1] + 1;
}

变成

a[0] = 0;                  // peel out first iteration
for (int i = 1; i < n; ++i)
    a[i] = a[i - 1] + 1;   // clean loop body, no branching

Loop Fusion

循环融合是把多个循环合并成一个,减少循环开销并提高缓存局部性。

inline内联

内联(inline)本质上是一种编译期优化建议(hint),而不是命令

因素 说明
函数体积 太大则不内联(例如超过几十条指令)
递归函数 一般不内联(除非尾递归)
函数复杂度 有循环、异常、静态变量等 → 不内联
是否虚函数 虚函数调用点通常运行时绑定,无法内联(除非 devirtualized)
是否跨编译单元(跨文件) 编译器看不到定义就无法内联(除非启用 LTO)

内联可能反而更慢, 因为

  • 增加代码体积
  • 造成 instruction cache miss
  • 破坏分支预测

避免多重定义:

头文件里为什么经常写 inline:避免多重定义

Tail Call Optimization, TCO

如果编译器检测到函数是尾递归形式,它可以把“递归调用”优化为“循环跳转”,

int factorial_tail(int n, int acc = 1) {
    if (n <= 1) return acc;
    return factorial_tail(n - 1, n * acc);  // ✅ 尾递归:最后一步就是调用自己
}

编译选项

  • -march=native 根据当前编译机器的 CPU 型号,启用所有本地支持的指令集优化。
  • -flto Link Time Optimization
  • -funroll-loops 尽可能展开循环体(unroll loops)

C++语言特性

编译

lambda

编译期生成匿名类(可调用操作符),处理捕获列表

函数重载

通过命名修饰实现

动态多态

有virtual关键字的时候生成虚函数表

构造的时候插入虚函数表指针

模板实例化

为每个模板实例生成独立的代码

类型推导

auto,decltype 确定变量类型

inline

决定是否为实际内联函数,直接插入函数体

TMP

计算模板元编程

static assert

计算断言

链接

内联变量

一个变量在多个翻译单元有定义,最终可执行文件中只有一个

链接器在链接时,把所有同名弱符号合并为一个

内部链接

C++ 里“内部链接(internal linkage)”的意思是:这个名字只在当前翻译单元(一个 .cpp 及其包含的头文件展开后)里可见;别的 .cpp 即使写了同名声明,也不是同一个实体。

  • const int N = 10;✅ 内部链接(默认)

  • static int g_counter;

  • 匿名命名空间 namespace { ... }

  • constexpr int K = 123; // ✅ 一般也是内部链接(行为类似 const)

  • inline 变量(C++17+)的链接属性通常是外部链接,但允许多处定义并合并成一个实体

  • cpp void f() { static int s = 0; // 具有静态存储期:每个 TU 中 f 各自一份 s }

命名空间作用域static inline(C++17+ 也一样)时,结论很简单:

static 让它具有内部链接(每个翻译单元一份);inline 在这里基本“白写/多余”。

static const终极总结表

代码写法 类型 结果 存放位置 (如果合法)
static const int a; 基础类型 编译错误 -
static const int a = 10; 基础类型 合法 .rodata (或优化为立即数)
static const MyObj obj; 有构造函数的类 合法 (调默认构造) .bss (运行时构造)
类内 static const int x; 仅声明 链接错误 (如果使用) -

结构体

Structure Layout

结构体的内存布局由成员的排列 + 对齐规则决定。编译器会在成员之间插入padding(填充字节)以满足每个成员的对齐要求

  1. 每个成员的地址必须是它对齐数的整数倍。 例如 int 必须放在 4 的倍数地址上。
  2. 编译器会自动插入填充字节,保证下一个成员满足对齐。
  3. 结构体整体大小也要对齐到最大成员的对齐数的整数倍。
  4. 如果有嵌套结构体,嵌套结构体本身的起始位置也要按其对齐要求对齐。

ABI基础

ABI (Application Binary Interface)是程序在编译后、二进制级别上相互协作的规则。API 是源代码层面(编译前);ABI 是机器码层面(编译后)

Calling Convention

例如在 x86-64 Linux System V ABI 下, rdi, rsi, rdx, rcx, r8, r9 是函数参数, 返回值 rax

Name Mangling

void foo(int);
void foo(double);

C++ 支持函数重载, 如果没有名字修饰,这两个函数的符号都叫 foo,链接器会搞混。编译后

foo(int)     _Z3fooi
foo(double)  _Z3food

Itanium C++ ABI name mangling(GCC、Clang 通用标准)

_Z 是前缀,表示这是一个 C++ 修饰名, 3foo 函数名长度 + 名字, v 是类型 void

禁用名字修饰:

extern "C" void foo(int);

C 语言没有函数重载,不需要 mangling

Object Layout

class A {
    int x;
    virtual void f();
};

内存布局

[ vptr | x ]
  • vptr:虚表指针(指向类的虚函数表)

  • x:成员变量

多态情况:

  • 基类子对象在派生类对象的最前面
  • 如果有虚函数,每个最左基类放一个 vptr
struct Base { int a; };
struct Derived : Base { double b; };
// 布局: | Base::a (4) | padding (4) | Derived::b (8) |

多继承

struct A { int a; };
struct B { int b; };
struct C : A, B { int c; };
//布局: | A::a | B::b | C::c |

虚继承 (virtual inheritance)

虚继承(virtual inheritance)就是为了解决这个重复继承的问题:此时,C 只会保留 一份 Base

struct Base {
    int x = 10;
};
struct A : virtual Base {};
struct B : virtual Base {};
struct C : A, B {};
/*  内存布局
| A::vbptr | A::a |
| B::vbptr | B::b |
| Base::x  |
| C::c     |       */

vtable

polymorphism 是通过 “虚函数机制” 实现的。当类中有 virtual 函数 时,编译器会

  • 给类添加一个隐藏成员指针(称为 vptr,virtual pointer);
  • vptr 指向一张虚函数表(vtable);
  • 表里存储着该类的虚函数的实际地址

每个对象只存一个指针(vptr),指向它所属类的虚表。vtable 是类级别的共享结构,而 vptr 是对象级别的实例指针。

-fdump-class-hierarchy 加上这个可以输出一个 class 文件, 里面有vtable, g++14后用 -fdump-lang-class

RTTI

RTTI(Run-Time Type Information)是 C++ 提供的一套机制,用于在运行时识别对象的真实类型。即使你通过一个 Base* 指针在操作对象,也可以知道它真正指向的是 Derived

  • type_info 对象, 每个多态类型(即有虚函数的类)都有一个唯一的 std::type_info 实例
  • 在虚表(vtable)中,第二项就是指向这个 RTTI 对象的指针。

POD

Plain Old Data (POD = trivial + standard layout)是一种“像 C 一样简单”的类型,它的内存布局与 C struct 相同,没有构造函数、析构函数或虚函数。POD 类型可以安全地被 memcpy、memset、与 C 代码共享内存布局. C++20 起,不推荐再说“POD”,改用:std::is_trivial_v<T>

模板实例化机制

声明阶段(模板定义)

当你写下:

template<typename T>
T add(T a, T b);

编译器只是记录模板的模式,不会生成任何机器码。

当你调用:

int x = add(1, 2);      // T=int
double y = add(1.1, 2.2); // T=double

编译器会为每种类型各生成一份“模板实例”

避免重复实例化的机制

模板定义通常放在头文件中

每个 .cpp 引用它时都会看到模板定义。 为防止重复生成代码,编译器使用 “模板实例化合并机制”:

  • 每个 .cpp 文件中都会可能实例化模板
  • 链接器(linker)阶段会去重(COMDAT folding),只保留一份实例化代码。COMDAT(Common Data) 是一种链接器机制, 允许多个目标文件中定义相同符号时,只保留一份副本

零开销抽象(zero-cost abstraction)

使用高级语言特性(如类、模板、智能指针等)时,不会在运行时引入额外性能开销——编译器会在编译期优化掉所有不必要的中间层。What you don’t use, you don’t pay for

EBO(Empty Base Optimization)

当一个类以“基类”的形式继承一个空类(没有非静态数据成员),编译器通常可以不给这个空基类分配额外的对象存储空间,从而让对象更小。

C++ 要保证:不同对象必须有不同地址。 因此即使一个类什么成员都没有,它的对象大小也至少是 1

如果你把空类当成员放进另一个类里,它也要占 1 字节

如果空类是基类:

struct B : Empty {
  int x;
};

编译器可以把 Empty 的那 1 字节“压掉”,让:

  • 通常 sizeof(B) == sizeof(int)(仍要满足对齐)

(细节涉及 ABI/对象模型,但结论就是:空基类可被折叠到派生类中。)

注意: 同一个空基类类型继承两次:至少一个不能被压掉

如果通过不同类型包装就行:

struct E1 : Empty {}; 
struct E2 : Empty {}; 
struct D : E1, E2 { int x; }; // 可能两个都 EBO

虚继承/多继承等复杂情况可能影响布局

多数情况下 EBO 仍可能发生,但布局更复杂,不能想当然。

no_unique_address

C++20 起有 [[no_unique_address]] 更直接

struct S {
  [[no_unique_address]] Empty e; // 允许不占空间(如果可行)
  int x;
};

比如实现policy based design

#include <mutex>

struct NoLock {
  void lock() noexcept {}
  void unlock() noexcept {}
};

struct MutexLock {
  std::mutex m;
  void lock()   { m.lock(); }
  void unlock() { m.unlock(); }
};

template<class LockPolicy>
class Counter {
  [[no_unique_address]] LockPolicy lock_;  // NoLock 时通常不占空间
  int v_ = 0;
public:
  void inc() {
    lock_.lock();
    ++v_;
    lock_.unlock();
  }
  int get() const { return v_; }
};

还有

#include <cassert>
#include <cstddef>

struct NoCheck {
  static void check(bool) noexcept {}
};

struct AssertCheck {
  static void check(bool ok) { assert(ok); }
};

template<class CheckPolicy>
class BufView {
  int* p_;
  std::size_t n_;
public:
  BufView(int* p, std::size_t n) : p_(p), n_(n) {}
  int& at(std::size_t i) {
    CheckPolicy::check(i < n_);
    return p_[i];
  }
};

头文件

头文件里写什么

  • 类内定义的成员函数(隐式就是 inline
  • 模板通常必须放头文件(需要在使用点可见),多 TU 各自实例化后链接器会合并/选择等价定义
  • inline 自由函数(非模板)
  • static 自由函数(内部链接)
  • constexpr 函数: 通常放头文件,因为它经常需要被调用方在编译期求值

TU

一个 TU = 一个 .cpp 文件经过预处理(展开 #include、替换宏、处理条件编译)之后得到的那份“完整源代码”这份代码会被编译器当作一个独立单位去编译,生成一个目标文件

  • 宏只在 TU 内生效:同一个宏在不同 TU 可能值不同
  • 模板实例化通常发生在使用点的 TU:所以模板定义常放头文件,让每个 TU 都能看到。

include中 <> 的区别

搜索路径顺序不同:"" 先当前目录/相对路径再系统路径;<> 通常只系统 include 路径

static关键字

  • 在函数外:static = 内部链接(internal linkage)。只在当前翻译单元(TU)可见。其他 .cpp 即使 extern 也看不到它们。
  • 把实现细节“私有化”,避免符号对外暴露风险。或用匿名命名空间 namespace {}(效果类似内部链接)。

  • 在函数内:static = 静态存储期的局部变量(只初始化一次,生命周期到程序结束)

头文件里只有模板,多个 .cpp include 会不会导致 multiple definition

真正会产生链接符号的是模板实例化

当某个 TU 用到了 foo<int>,编译器会在该 TU 里生成一份 foo<int> 的代码

如果多个 TU 都用到了 foo<int>,就会生成多份“相同的实例化代码”。模板实例化在多个 TU 里重复生成是被设计允许的,链接器负责“去重/合并”。

头文件污染

一个头文件把不必要的依赖、符号、宏“传播”给所有包含它的翻译单元,导致:

  • 编译变慢(每个 TU 都重复解析大量头)
  • 耦合变强(A 改一点,包含它的很多文件都要重编)

具体表现:

  • 头文件 include 了很多“重”头
  • 在头文件里 using namespace ...;
  • 在头文件里定义宏、或 include 会引入大量宏
  • 把实现细节放进头文件

如何降低编译依赖:

  • 能前置声明就别 include (成员是指针/引用:T*T&

  • 用 PIMPL 隔离重依赖

// foo.h
#include <memory>
class Foo {
public:
  Foo();
  ~Foo();            // 放 cpp 定义
  void run();
private:
  struct Impl;
  std::unique_ptr<Impl> pimpl;
};

C++20 Modules

//mymath.cppm
export module mymath;
export int add(int a, int b) { return a + b; }
// main.cpp
import mymath;
int main() {
  return add(1, 2);
}

优势

  • 编译更快、更可缓存

  • 隔离宏污染: 不会把对方的宏、私有实现细节随意带进来

  • 更强封装: 你可以只 export 需要的声明,其余实现留在模块内部(不 export)

BMI

BMI = Binary Module Interface(也常写 Built Module Interface),就是把一个 模块接口单元export module X; ...)编译成一种二进制“接口描述”文件,供别的翻译单元 import X; 时快速读取。

模块时代:编译顺序变得重要:你必须先把被 import 的模块接口编译出 BMI,才能编译依赖它的源文件。

  • 步骤 1:编译模块接口单元 → 生成 BMI
  • 步骤 2:编译导入者(importer)→ 读取 BMI 做语义分析
  • 步骤 3:链接

基础题

预处理/宏相关(偏坑题但常见)

14) #include 的顺序会影响什么?

要点: 宏定义、条件编译、平台差异;依赖顺序会导致隐藏 bug;建议头文件自洽(include 自己需要的头)。

15) 什么是 include 循环依赖?怎么解决?

要点: A include B,B include A 导致不完整类型/编译错误;用前置声明、拆分接口、降低耦合、PIMPL。


现代 C++/构建系统(进阶)

16) 什么是“翻译单元(TU)”?它和 .cpp 的关系?

要点: 预处理后形成的编译输入;一个 .cpp + 展开的所有头 = 一个 TU;宏和 include 都在 TU 内生效。

17) 解释 PCH(预编译头)能解决什么问题?有什么代价?

要点: 提升编译速度;对构建系统要求高;缓存失效策略;不等于解决依赖设计问题。