返回「计算机、信息技术与工程」

C++ 编译流程

更多
Markdown 结构化数据
本文目录 19 个章节

C++ 编译流程

传统 C++ 编译五阶段

1. 预处理(Preprocessing)

  • 工作:宏展开、条件编译、头文件插入。
  • 命令示例clang -E hello.cpp -o hello.i
  • 产物hello.i —— 纯文本 C/C++ 源码,所有 #include 与宏均已解析。

2. 编译(Front-end Compilation)

  • 工作:词法分析 → 语法分析 → 语义检查 → 生成内部 IR(GCC:GIMPLE/RTL;Clang:LLVM IR)。
  • 命令示例clang -c hello.i -o hello.o
  • 产物:若未显式输出汇编,则直接得到 目标文件 hello.o(ELF relocatable object,包含机器指令、符号表、重定位记录)。
  • Clang 前端把 AST 降低成 LLVM IR(隐式在内存中,不写磁盘)。
  • LLVM 后端再把 IR 直接翻译成机器指令(对应您如果输出汇编会看到的那些助记符)。

3. 目标级优化(Machine-code Optimization,可选)

  • 工作:寄存器分配、指令调度、内联、常量合并等。
  • 触发方式-O1/-O2/-O3 等优化级别。
  • 产物:优化后的 .o,与步骤 2 同一格式但指令序列已改写。

4. 汇编(Assembly,可选显式步)

  • 目的:生成人类可读汇编文本或从汇编文本生成 .o
  • 查看汇编clang -S hello.cpp -o hello.s
  • 再汇编as hello.s -o hello_asm.o
  • 差异.s 为助记符文本;.o 为二进制机器码。
  • LLVM 后端会“就地”组装(assemble)这些指令成机器码字节,连同符号表/重定位信息一起打包进 .o

5. 链接 / 归档

  • 可执行clang hello.o -o hello → 链接器分配地址、解析外部符号并合并库。
  • 静态库ar rcs libhello.a hello.o → 仅把多个 .o 归档,无地址分配。

示例:从源文件到库 / 可执行

源文件 hello.cpp

// hello.cpp
#include <cstdio>
#define SQUARE(x) ((x)*(x))

static const char* MSG = "3² = %d\n";

int square(int x) {
    return x * x;
}

int main() {
    int v = square(3);
    std::printf(MSG, v);
    return 0;
}

(1) 预处理

clang -E hello.cpp -o hello.i

产物: hello.i(文本),所有的 #include、宏都已展开,可能看起来像:

# C++ 编译流程
## 1 "<built-in>"
## 1 "<command-line>"
## 1 "hello.cpp"
static const char* MSG = "3² = %d\n";

int square(int x) {
    return x * x;
}

int main() {
    int v = square(3);
    std::printf("3² = %d\n", v);
    return 0;
}
  • SQUARE(3) 已被直接替换为 ((3)*(3))(本例没用到);
  • 头文件 <cstdio> 展开为底层实现和声明(大量代码,已被剥掉示例)。

(2) 编译(词法→语法→AST→目标代码)

clang -O2 -c hello.cpp -o hello.o

hello.o 已含机器指令,可用 objdump -d hello.o 反汇编查看。

  1. 词法分析:把源文本切成一连串 token(标识符、字面量、操作符……)
  2. 语法分析:把 token 按 C++ 语法规则组装成 AST(抽象语法树)
  3. 语义检查:类型检查、名称解析、模板实例化
  4. 生成“目标代码”:Clang 会在内存中直接把 AST 降低为机器指令的中间数据结构(GCC 用 GIMPLE/RTL,Clang 用 LLVM IR 或内部指令序列),但它不会再产出一个文件,除非你指定输出。

如果你让它直接产出一个“目标”文件:

clang -O0 -c hello.i -o hello.o
  • 结果hello.o 是一个 ELF relocatable object(或在 Emscripten 下是 Wasm relocatable object),里面已经包含了机器指令、符号表、重定位信息。
  • Clang 并不会产出可读的中间文本,这里的“目标代码”实际就是硬编码在二进制的机器指令。

对该步骤进行进一步拆分如下:

词法/语法分析 → AST

  • AST(抽象语法树):Clang 内部表示,不直接生成到磁盘。
clang -cc1 -ast-dump hello.cpp -fsyntax-only
  • 上面命令会在终端输出类似:
TranslationUnitDecl ...
 ├─FunctionDecl square
 │   ├─ParmVarDecl x
 │   └─CompoundStmt
 │       └─ReturnStmt
 │           └─BinaryOperator '*'
 └─FunctionDecl main
     ├─CompoundStmt
     │   └─CXXOperatorCallExpr '<<'
     │       ├─...

生成 LLVM IR 文本(.ll

clang -O0 -emit-llvm -S hello.cpp -o hello.ll

查看 hello.ll

; ModuleID = 'hello.cpp'
source_filename = "hello.cpp"
target datalayout = "..."
target triple = "x86_64-unknown-linux-gnu"

@_ZSt4cout = external dso_local global %class.std::ostream, align 8

; 定义 square
define dso_local i32 @_Z6squarei(i32 %x) #0 {
entry:
  %mul = mul nsw i32 %x, %x
  ret i32 %mul
}

; 定义 main
define dso_local i32 @_Z4main() #0 {
entry:
  ; 调用 square(3)
  %call = call i32 @_Z6squarei(i32 3)
  ; 打印
  %0 = call %class.std::ostream* @_ZStlsISt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_PKc(
            %class.std::ostream* @_ZSt4cout, i8* getelementptr inbounds ([7 x i8], [7 x i8]* @.str, i32 0, i32 0))
  ; ...
  ret i32 0
}
  • .ll人可读的 LLVM IR。
  • 包含函数签名(mangled name)、基本块、指令(mul, call, ret)等。
  • LLVM IR 是一种SSA 形式的中间表示,既包含类型信息,又支持跨平台优化。

生成 LLVM Bitcode(.bc

clang -O0 -emit-llvm -c hello.cpp -o hello.bc
  • hello.bc二进制形式的 LLVM IR(bitcode),比 .ll 更紧凑。
  • 可以通过 llvm-dis hello.bc -o hello_dis.ll “反编译”回文本形式;file hello.bc 会显示 “LLVM IR bitcode”。

优化(目标代码级)

在上一步,你可以加 -O2 或者使用链接时的 LTO:

clang -O2 -c hello.i -o hello_opt.o
## 或者
clang -flto -O2 -c hello.i -o hello_lto.o
  • 作用:编译器会对刚生成的指令作 寄存器分配、指令调度、常量合并、内联 等优化,甚至在 -flto 模式下跨不同 .o 文件做优化。
  • 产物:仍然是一个 .o,但机器指令序列已经被重写以提高性能或减小体积。

(3) 汇编(生成机器码/目标机器文件 .o

从源到 ELF 对象

clang -O2 -c hello.cpp -o hello.o
  • hello.o 是一个 ELF relocatable object,包含已编译的机器码(x86_64 指令)、符号表、本地/全局符号、重定位信息。

  • 可用 readelf -h hello.o / readelf -S hello.o 查看节区 (.text, .data, .rela.text, etc.);

  • nm hello.o 查看符号列表(包括 _Z6squarei, _Z4main 等)。

可手动生成可读的汇编指令

如果你想看到可读的汇编指令,可以让编译器先输出汇编再汇编成 .o

## 先生成汇编代码
clang -O2 -S hello.i -o hello.s

## hello.s (示例 x86-64 汇编)
    .text
    .globl square
square:
    movl    %edi, %eax
    imull   %edi, %eax
    ret

    .globl main
main:
    pushq   %rbp
    movq    %rsp, %rbp
    subq    $16, %rsp
    movl    $3, -4(%rbp)            # int v = 3;
    movl    -4(%rbp), %edi
    call    square
    movl    %eax, -8(%rbp)         # v = square(3)
    movl    -8(%rbp), %esi
    leaq    .LC0(%rip), %rdi       # address of format string
    movl    $0, %eax
    call    printf
    movl    $0, %eax
    leave
    ret

    .section    .rodata
.LC0:
    .string "3² = %d\n"

## 再汇编成机器码
as hello.s -o hello.o
  • hello.s人可读的汇编语言,每条指令对应一条固定模式的机器指令。
  • as(汇编器)把汇编指令翻译成二进制机器码、打包进 .o,并生成重定位表与符号表。

(4) 链接(将 .o 组装成可执行/库)

打包静态库 (.a)

ar rcs libhello.a hello.o
  • ELF 静态库libhello.a 是一个普通的 UNIX .ar archive,成员是 ELF .oar 只是把若干 .o 打包成一个 .a archive,不做地址分配,只保存重定位记录与符号,留待用户的链接阶段使用。

链接成可执行程序

## 可执行
clang hello.o -o hello
  • 可执行:链接器(ld)会把所有需要的 .o 和系统库(libclibm 等)合并,重定位所有外部符号,把机器码铺到最终内存地址,然后产出 ELF 可执行文件 hello

编译和汇编步骤的关系

当执行

clang -O2 -c hello.cpp -o hello.o

Clang 做了下面两件事:

  • Codegen:把 C++ 源码(经过 AST 和 IR)“编译”成汇编层面上的指令序列(相当于您看到的 hello.s 内容)。
  • Assemble & Pack:再把那些“汇编指令”翻译成机器码,并生成一个包含了这些码流的目标文件 hello.o

逻辑上是两步,实践中常被合并

  • 编译 (front-end + back-end)
    • 前端:词法 / 语法 / 语义分析,生成 LLVM IR(Clang)或等价内部表示。
    • 后端:将 IR 翻译成目标体系结构的指令序列,并立即调用内部汇编器把指令装配成目标文件。
    • 触发方式:clang -c foo.cpp -o foo.o

如果进行显示汇编:

  • 先用 -S 将指令序列写出为人可读汇编文本 .s
  • 再手动调用 as(或 clang -c foo.s -o foo.o)生成目标文件。