编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

    • 入门教程

    • 综合案例

    • 专栏博客

      • README
      • 进程虚拟地址空间
      • 栈与堆底层对决
      • 指针本质与多级解引
      • 指针运算底层真相
      • 函数指针与回调机制
      • 限定符与指针语义
      • 补码与位运算原理
      • IEEE754浮点本质
      • 数组与指针的纠葛
      • 结构体对齐与优化
      • 字符串存储与安全
      • 预处理器宏与条件编译
      • 编译到汇编全流程
        • 目录
        • 1. 案例引入
          • 1.1 一个"幽灵变量"
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构概览
          • 2.1 编译流水线总图
          • 2.2 gcc 的参数坐标系
          • 2.3 链接器不在
        • 3. 编译四阶段全景
          • 3.1 预处理:.c → .i
          • 3.2 编译:.i → .s
          • 3.3 汇编:.s → .o
          • 3.4 链接:.o → executable
        • 4. 六步流水线
          • 4.1 词法分析
          • 4.2 语法分析:从令牌到AST
          • 4.3 语义分析
          • 4.4 中间代码生成:GIMPLE与SSA
          • 4.5 优化流水线
          • 4.6 代码生成
        • 5. 汇编读写实战
          • 5.1 函数序言与尾声解码
          • 5.2 x86-64 SysV ABI 调用约定速查
          • 5.3 控制流汇编
          • 5.4 栈帧在汇编中的样子
        • 6. 优化等级对比实测
          • 6.1 三种汇编
          • 6.2 死代码消除
          • 6.3 内联展开的决策边界
          • 6.4 循环展开与向量化
        • 7. volatile 与编译器优化
          • 7.1 volatile 的真正语义
          • 7.2 变量去哪了
          • 7.3 嵌入式寄存器
          • 7.4 volatile 不是原子操作
        • 8. 综合案例串讲
          • 8.1 案例真相揭晓
          • 8.2 一行 C 代码的编译之旅
          • 8.3 面试高频问题清单
          • 8.4 编译调试速查卡
      • 链接器符号与重定位
      • 静态库与动态库对比
      • Make与CMake构建
      • 文件IO与系统调用
      • 动态内存管理揭秘
      • 未定义行为与防御
      • C工程化与设计哲学
    • 标准集库

  • Cpp入门到精通

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

  • CodeX
  • C语言入门精通
  • 专栏博客
杨充
2026-06-10
目录

编译到汇编全流程

# 13.编译到汇编全流程

编译四阶段 .c→.i→.s→.o、词法分析/语法分析/语义分析/中间代码生成/优化/目标代码生成六步、gcc -S 读汇编、函数序言 push rbp; mov rbp, rsp 与尾声 leave; ret、-O0/-O2/-O3 优化等级对汇编的影响、volatile 阻止编译器优化删除的案例

# 目录

  • 1. 案例引入
    • 1.1 一个"幽灵变量"
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构概览
    • 2.1 编译流水线总图
    • 2.2 gcc 的参数坐标系
    • 2.3 链接器不在
  • 3. 编译四阶段全景
    • 3.1 预处理:.c → .i
    • 3.2 编译:.i → .s
    • 3.3 汇编:.s → .o
    • 3.4 链接:.o → executable
  • 4. 六步流水线
    • 4.1 词法分析
    • 4.2 语法分析:从令牌到AST
    • 4.3 语义分析
    • 4.4 中间代码生成:GIMPLE与SSA
    • 4.5 优化流水线
    • 4.6 代码生成
  • 5. 汇编读写实战
    • 5.1 函数序言与尾声解码
    • 5.2 x86-64 SysV ABI 调用约定速查
    • 5.3 控制流汇编
    • 5.4 栈帧在汇编中的样子
  • 6. 优化等级对比实测
    • 6.1 三种汇编
    • 6.2 死代码消除
    • 6.3 内联展开的决策边界
    • 6.4 循环展开与向量化
  • 7. volatile 与编译器优化
    • 7.1 volatile 的真正语义
    • 7.2 变量去哪了
    • 7.3 嵌入式寄存器
    • 7.4 volatile 不是原子操作
  • 8. 综合案例串讲
    • 8.1 案例真相揭晓
    • 8.2 一行 C 代码的编译之旅
    • 8.3 面试高频问题清单
    • 8.4 编译调试速查卡

# 1. 案例引入

# 1.1 一个"幽灵变量"

先看一段在某嵌入式设备上跑了半年的代码,固件升级换了一个 GCC 版本后,设备间歇性死机——不是每次必死,但一天能死三四次:

// sensor_reader.c —— 传感器数据采集线程
#include <stdbool.h>

static int g_sensor_value = 0;
static bool g_data_ready = false;

/* ISR: 传感器中断服务程序 */
void sensor_isr(void) {
    g_sensor_value = read_sensor_register();   // 读取传感器值
    g_data_ready = true;                       // 标记数据就绪
}

/* 主循环中的消费者 */
void main_loop(void) {
    int local_copy;
    while (1) {
        if (g_data_ready) {
            local_copy = g_sensor_value;
            process_data(local_copy);
            g_data_ready = false;
        }
        // ... 其他工作 ...
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

现象:

  • 旧固件(GCC 7.5 -Os):稳定运行,6 个月零故障
  • 新固件(GCC 12.1 -Os):间歇性死机——process_data 收到的值是 0(传感器的默认值),而不是 ISR 写入的读数
  • 添加 printf 调试日志后:问题消失(经典的"Heisenbug")
  • g_data_ready 明明被 ISR 设为 true 了,但 main_loop 好像永远看不到

用 JTAG 调试器观察 g_data_ready 的内存位置——值确实是 true。那为什么 main_loop 里的 if (g_data_ready) 不走进去?

# 1.2 顺藤摸到根因

带着 JTAG dump 往深挖:

  • 假设 1:是不是 ISR 没写成功?—— JTAG 看内存,g_data_ready = 1,ISR 工作正常。
  • 假设 2:是不是 main_loop 被优化得寄存器缓存了 g_data_ready?—— 看汇编!
$ arm-none-eabi-gcc -Os -S sensor_reader.c -o sensor_reader.s
1

main_loop 的汇编(关键部分):

main_loop:
    ldr  r0, .LCPI0_0        @ r0 = &g_data_ready
    ldrb r1, [r0]            @ r1 = *g_data_ready (加载一次!)
    cmp  r1, #0
    beq  .L_skip             @ 如果 false,跳到 skip

    @ 注意:编译器发现 g_data_ready 在循环体内没被修改(它不知道 ISR 会改)
    @ 于是把它提升到循环外 —— 只加载一次,后面都用寄存器里的 r1
    @ 这就是 "Loop-invariant code motion" (LICM) 优化

.L_loop:
    @ ... 循环体 ...
    b    .L_loop             @ 永远循环

.L_skip:
    @ ... 不处理数据的路径 ...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

凶手找到了:编译器优化 g_data_ready 的读取——它分析 main_loop 的代码,发现 g_data_ready 在循环内部从未被写入(编译器不知道 ISR 的存在),于是"好心"地把 g_data_ready 的读取提升到循环外,只读一次。此后 if (g_data_ready) 永远看到的是初始值 false。

  • 假设 3:为什么旧编译器没问题?—— GCC 7.5 的 LICM 优化在这个 case 上恰好没触发(取决于寄存器压力和指令调度决策,这是启发式算法的内部行为,不保证稳定)。

  • 假设 4:为什么加 printf 问题消失?—— printf 是一个外部函数调用,编译器不知道它的副作用,因此保守地假设它可能修改任何全局变量——所以 g_data_ready 必须在 printf 之后重新加载。这就是典型的"Heisenbug"成因。

修复:一行代码:

static volatile int g_sensor_value = 0;     // ← 加 volatile
static volatile bool g_data_ready = false;  // ← 加 volatile
1
2

volatile 告诉编译器:"每次访问这个变量都必须从内存读取,不要缓存到寄存器里"。

这段事故里藏着至少 8 个原理点:

① g_data_ready 为什么被提升到循环外?这是哪种优化?       → 第 6.2 节
② volatile 到底做了什么,为什么不加就出错?               → 第 7 章
③ 编译器的优化 pass 是怎么组织的?                       → 第 4.5 节
④ .c 文件是怎么一步步变成机器码的?                       → 第 3 章
⑤ GCC 的 -O0/-O2/-Os 各自做了什么?                      → 第 6 章
⑥ 我怎么自己看编译器生成的汇编?                          → 第 5 章
⑦ 编译器内部看到了怎样的"中间表示"?                      → 第 4.4 节
⑧ volatile 能替代原子操作吗?                            → 第 7.4 节
1
2
3
4
5
6
7
8

# 1.3 我们要回答什么

这个事故就是本篇的主线案例。我们带着上面 8 个问号往下走,每讲完一段原理就解开一两个;最后在第 8 章把案例彻底剖开。

本篇路线:

架构总图 (第 2 章)
   ↓
编译四阶段 .c→.i→.s→.o (第 3 章) ─→ 解开"gcc 四个字母干了什么"
   ↓
编译器内部六步 (第 4 章) ─→ 解开"词法/语法/语义/IR/优化/代码生成"
   ↓
汇编读写实战 (第 5 章) ─→ 解开"自己读 .s 文件"
   ↓
优化等级对比 (第 6 章) ─→ 解开"-O0/-O2/-O3 到底差在哪" ← 本篇核心
   ↓
volatile (第 7 章) ─→ 解开"为什么优化会'误杀'变量"
   ↓
综合案例 (第 8 章) ─→ 彻底剖开 + 速查卡
1
2
3
4
5
6
7
8
9
10
11
12
13

📌 本篇定位:编译原理是连接"你写的 C 代码"和"CPU 执行的机器指令"的唯一桥梁。理解编译器做了什么优化、为什么做、以及什么时候"做过头了"——这是从"能用 C 写代码"到"能写出编译器不敢乱优化的 C 代码"的分水岭。

# 2. 架构概览

# 2.1 编译流水线总图

从 .c 源代码到可执行文件,GCC 经过 4 个阶段,对应 4 个子程序:

hello.c                     hello.i                     hello.s                     hello.o
┌──────────┐               ┌──────────┐               ┌──────────┐               ┌──────────┐
│ C 源代码  │─── cpp ───► │ 预处理后  │─── cc1 ───► │ 汇编代码  │─── as ───► │ 目标文件  │
│ #include │  (预处理器)   │ 纯C文本   │  (编译器)    │ .s 文件   │  (汇编器)   │ .o 文件   │
│ #define  │               │           │              │           │              │           │
└──────────┘               └──────────┘               └──────────┘               └─────┬─────┘
                                                                                       │
                                                                              ┌───────▼───────┐
                                                                              │   可执行文件    │
                                                                              │   hello       │
                                                                              │ (ELF 格式)    │
                                                                              └───────────────┘
                                                                                    ▲
                                                                                    │
                                                                              ld (链接器)
                                                                              ─────────────
                                                                              合并 .o + .so
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

四个阶段的命令对应:

# 一步到位
$ gcc hello.c -o hello

# 分步执行(等于上面一步)
$ gcc -E hello.c -o hello.i          # 阶段1: 预处理  (cpp)
$ gcc -S hello.i -o hello.s          # 阶段2: 编译    (cc1)
$ gcc -c hello.s -o hello.o          # 阶段3: 汇编    (as)
$ gcc hello.o -o hello               # 阶段4: 链接    (ld)

# 查看每个阶段的产物
$ head -50 hello.i                    # 看预处理后的"纯C"
$ cat hello.s                         # 看编译器生成的汇编(最关键!)
$ objdump -d hello.o                  # 看目标文件的机器码
$ readelf -h hello                    # 看可执行文件的ELF头
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 2.2 gcc 的参数坐标系

GCC 的参数数以百计,但对于"理解编译流水线",只需掌握这张坐标图:

         ┌────────────────────────────────────────────────────┐
         │              控制"停在哪"的参数                       │
         │                                                    │
         │  gcc -E        停在预处理后,输出 .i               │
         │  gcc -S        停在编译后,输出 .s  ← 最常用!      │
         │  gcc -c        停在汇编后,输出 .o                │
         │  (无)          走完全程,输出可执行文件              │
         └────────────────────────────────────────────────────┘

         ┌────────────────────────────────────────────────────┐
         │              控制"怎么编译"的参数                    │
         │                                                    │
         │  -O0           不优化(调试用)                     │
         │  -O1           基本优化                            │
         │  -O2           标准优化(生产默认)                  │
         │  -O3           激进优化(含向量化、更多内联)         │
         │  -Os           体积优先优化                         │
         │  -Ofast        速度优先(牺牲标准兼容性)             │
         │  -Og           调试友好的优化                       │
         └────────────────────────────────────────────────────┘

         ┌────────────────────────────────────────────────────┐
         │              控制"输出什么信息"的参数                 │
         │                                                    │
         │  -v            打印编译全流程的子命令(cc1/as/ld)   │
         │  -###          只打印命令不执行(dry run)          │
         │  -fdump-tree-all  输出所有优化pass后的GIMPLE树      │
         │  -fdump-rtl-all   输出所有RTL pass后的中间表示       │
         │  -fverbose-asm   汇编中附带变量名注释               │
         └────────────────────────────────────────────────────┘
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
28
29
30

# 2.3 链接器不在

本章聚焦在**.c → .o** 的编译过程。链接器做的事(符号解析、重定位、段合并)本质上是"把多个 .o 和 .so 拼成一个 ELF 文件",它属于链接原理范畴(专栏后续章节覆盖)。

但理解下面这一点很重要:编译器在看到一个 .c 文件时,完全不知道其他 .c 文件里有什么——它能做的优化仅限于"编译单元内"(除非开启 LTO,Link-Time Optimization)。这个边界在理解"为什么编译器不敢优化某些跨文件调用"时至关重要。

# 3. 编译四阶段全景

# 3.1 预处理:.c → .i

预处理器已经在 第 12 章 详细讲解。这里做最小回顾——用一段代码跑完预处理:

// hello.c
#include <stdio.h>
#define GREETING "Hello, World!"
#define REPEAT(n, thing) n thing

int main() {
    REPEAT(printf, (GREETING));
    return 0;
}
1
2
3
4
5
6
7
8
9
$ gcc -E hello.c -o hello.i
$ wc -l hello.i
# 845 lines —— 大部分是 #include <stdio.h> 展开的内容
1
2
3

预处理后的 hello.i(只截取我们自己代码的部分):

# 1 "hello.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 31 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 32 "<command-line>" 2
# 1 "hello.c"
# 1 "/usr/include/stdio.h" 1 3 4
# 27 "/usr/include/stdio.h" 3 4
   /* ... 800 多行 stdio.h 的内容 ... */
# 6 "hello.c" 2

int main() {
    printf ("Hello, World!");   // ← 宏全部展开完毕
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

关键观察:

  • #include <stdio.h> 被递归展开为 800+ 行
  • 宏 GREETING → "Hello, World!"
  • 宏 REPEAT(printf, (GREETING)) → printf ("Hello, World!")
  • 注释被移除

# 3.2 编译:.i → .s

这是本章的核心——编译器(cc1)把 C 代码翻译成汇编:

$ gcc -S hello.i -o hello.s
# 或直接从 .c 生成
$ gcc -S hello.c -o hello.s
1
2
3

hello.s(x86-64, -O0):

    .section    .rodata
.LC0:
    .string    "Hello, World!"       @ rodata 段的字符串

    .text                             @ 代码段
    .globl    main                    @ main 是全局符号
    .type     main, @function

main:
    pushq     %rbp                    @ 保存旧栈帧基址
    movq      %rsp, %rbp              @ 建立新栈帧
    leaq      .LC0(%rip), %rdi        @ 第一个参数 = "Hello, World!" 的地址
    call      puts@PLT                @ 调用 puts(printf 被优化成了 puts!)
    movl      $0, %eax                @ 返回值 = 0
    popq      %rbp                    @ 恢复旧栈帧
    ret                               @ 返回
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

编译器做的关键决定:

  1. printf("Hello, World!\n") → 优化成了 puts("Hello, World!")——因为格式串没有格式化占位符,puts 更高效(不需要解析格式串)
  2. 字符串字面量放到了 .rodata 段
  3. 函数入口自动加了序言(prologue:push rbp + mov rbp, rsp)和尾声(epilogue:pop rbp + ret)

# 3.3 汇编:.s → .o

汇编器(as)把汇编助记符翻译成机器码:

$ gcc -c hello.s -o hello.o
1
$ objdump -d hello.o                # 反汇编看机器码

hello.o:     file format elf64-x86-64

Disassembly of section .text:
0000000000000000 <main>:
   0:   55                      push   %rbp
   1:   48 89 e5                mov    %rsp,%rbp
   4:   48 8d 3d 00 00 00 00    lea    0x0(%rip),%rdi
   b:   e8 00 00 00 00          call   b+0x1
  10:   b8 00 00 00 00          mov    $0x0,%eax
  15:   5d                      pop    %rbp
  16:   c3                      ret
1
2
3
4
5
6
7
8
9
10
11
12
13

对应关系:

汇编 机器码 长度 说明
push %rbp 55 1 字节 push 指令单字节编码
mov %rsp,%rbp 48 89 e5 3 字节 REX.W + MOV + ModR/M
lea 0x0(%rip),%rdi 48 8d 3d 00 00 00 00 7 字节 RIP-relative 寻址
call puts@PLT e8 00 00 00 00 5 字节 call 相对偏移(待重定位)
mov $0x0,%eax b8 00 00 00 00 5 字节 mov imm32 → eax
pop %rbp 5d 1 字节 pop 单字节编码
ret c3 1 字节 ret 单字节编码

注意:call 指令中的 00 00 00 00 是占位符——汇编器还不知道 puts 在最终可执行文件里的地址(那是链接器的事),所以填 0 并在 .rela.text 重定位表里做了标记。

# 3.4 链接:.o → executable

链接器把多个 .o 和 .so 拼成可执行文件:

$ gcc hello.o -o hello
# 链接器做了:
# 1. 把 hello.o + crt1.o + crti.o + crtn.o + libc.so 合并
# 2. 解析 puts@PLT → 找到 glibc 中 puts 的实际地址
# 3. 给每个段分配最终的虚拟地址
# 4. 生成 ELF header / program headers / section headers
1
2
3
4
5
6
$ objdump -d hello | grep -A7 '<main>:'
0000000000401126 <main>:
  401126:       55                      push   %rbp
  401127:       48 89 e5                mov    %rsp,%rbp
  40112a:       48 8d 3d d3 0e 00 00    lea    0xed3(%rip),%rdi    # 已重定位!
  401131:       e8 ea fe ff ff          call   401020 <puts@plt>   # 已重定位!
  401136:       b8 00 00 00 00          mov    $0x0,%eax
  40113b:       5d                      pop    %rbp
  40113c:       c3                      ret
1
2
3
4
5
6
7
8
9

对比 .o 和最终可执行文件——lea 和 call 的地址从 0x00000000 变成了真实地址。这就是链接器的核心工作:重定位。

# 4. 六步流水线

# 4.1 词法分析

词法分析器(lexer)把字符串切分成"令牌(token)"。用 GCC 的 gcc -fdump-tree-original-raw 或 Clang 的 clang -Xclang -dump-tokens 可以查看:

int x = 42 + y;
1

词法分析输出:

int          → 关键字 "int"
[空格]       → 忽略
x            → 标识符 "x"
[空格]       → 忽略
=            → 运算符 "="
[空格]       → 忽略
42           → 整数字面量 "42"
[空格]       → 忽略
+            → 运算符 "+"
[空格]       → 忽略
y            → 标识符 "y"
;            → 分号 ";"
1
2
3
4
5
6
7
8
9
10
11
12

GCC 的词法分析器在 gcc/c/c-lex.c 中实现。它使用手工编写的递归下降解析器,而不是 yacc/lex 生成器(Clang 也是手工写的——现代编译器都倾向手工编写以获得更好的错误信息和性能)。

词法分析阶段的错误示例:

int x = 42 @ y;    // GCC: error: stray '@' in program
                   // 词法分析器不认识 '@',直接报错
1
2

# 4.2 语法分析:从令牌到AST

语法分析器(parser)把令牌流组织成抽象语法树(AST):

int x = 42 + y;

            AST:
         ┌─────────┐
         │  INIT   │  (声明+初始化)
         ├─────────┤
      ┌──┤ type int│
      │  │ name  x │
      │  │ init  ──┼────┐
      │  └─────────┘    │
      │              ┌──▼──────┐
      │              │  PLUS   │  (加法表达式)
      │              ├─────────┤
      │          ┌───┤ lhs: 42 │
      │          │   │ rhs: y  │
      │          │   └─────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

GCC 的语法分析器:

  • C 前端是递归下降解析器(gcc/c/c-parser.c)
  • 通过 c_parser_* 系列函数解析各种 C 语法结构
  • 用 libcpp 管理令牌流和预处理

语法错误示例:

int x = ;    // GCC: error: expected expression before ';' token
             // parser 期望在 '=' 之后看到一个表达式,但只看到了 ';'
1
2

GCC 的"消化策略":遇到语法错误后,parser 不会立刻退出——它会尝试错误恢复(跳过令牌直到找到分号或花括号),以便在一次编译中报告多个错误:

$ echo 'int a = ; int b = ; int c;' | gcc -x c -fsyntax-only -
<stdin>:1:9: error: expected expression before ';' token
<stdin>:1:18: error: expected expression before ';' token
# 两个错误都报告了,不是看到第一个就退出
1
2
3
4

# 4.3 语义分析

语义分析检查"语法上正确但不合理"的东西:

int x = "hello" + 42;   // 语法正确,语义错误
// GCC: error: invalid operands to binary + (have 'char *' and 'int')
1
2

GCC 的语义分析核心数据结构——tree:

GCC 使用一个统一的树形数据结构 tree 来表示从声明到表达式的所有东西。在 gcc/tree.h 中定义:

// GCC 内部的 tree 节点(极简表示)
// int x = 42 + y;
//
// VAR_DECL "x"
//   type: INTEGER_TYPE "int"
//   init: PLUS_EXPR
//           left:  INTEGER_CST 42
//           right: VAR_DECL "y"

// 用 GCC 的 -fdump-tree-original 查看
1
2
3
4
5
6
7
8
9
10
$ gcc -fdump-tree-original test.c -o /dev/null
$ cat test.c.005t.original   # GIMPLE 中间表示
1
2

语义分析做的事:

  1. 类型检查:运算符的操作数类型是否兼容
  2. 符号表构建:记录所有声明的变量和函数
  3. 作用域解析:x 指的是哪个作用域的 x
  4. 隐式转换引用:int + double → 需要把 int 提升为 double

# 4.4 中间代码生成:GIMPLE与SSA

疑惑:C/C++ 语法极其复杂,编译器怎么在保持正确性的同时做优化?

论证:编译器用**中间表示(IR, Intermediate Representation)**来拆解复杂度——GCC 的 IR 叫 GIMPLE。

GIMPLE 的三地址码形式:每个操作最多一个运算符 + 最多三个操作数:

// 原始 C 代码
int foo(int a, int b) {
    return (a + b) * (a - b) - 42;
}
1
2
3
4

GIMPLE 表示(简化):

foo (int a, int b)
{
  int D.1234;
  int D.1235;
  int D.1236;

  D.1234 = a + b;        // 三地址码形式
  D.1235 = a - b;
  D.1236 = D.1234 * D.1235;
  return D.1236 - 42;
}
1
2
3
4
5
6
7
8
9
10
11

复杂表达式被"打平"成若干个简单的三地址码操作。这就是编译器的"普通话"——后续所有的优化 pass 都在这套语言上工作。

SSA(Static Single Assignment)形式:每个变量只被赋值一次:

// 原始 C
int bar(int x) {
    x = x + 1;
    x = x * 2;
    return x;
}

// SSA 形式:每个版本一个名字
int bar(int x) {
    int x_1 = x_0 + 1;
    int x_2 = x_1 * 2;
    return x_2;
}
1
2
3
4
5
6
7
8
9
10
11
12
13

SSA 为什么强:每个变量只有唯一定义点,数据流的分析变得精确(不需要做"must-alias"推理)。现代编译器的几乎所有优化(常量传播、死代码消除、值编号)都基于 SSA 形式。

用 GCC dump 看 SSA:

$ gcc -fdump-tree-all test.c -o /dev/null
$ ls test.c.*
test.c.005t.original      # 原始 GIMPLE
test.c.006t.gimple        # GIMPLE (SSA 化前)
test.c.011t.cfg           # 控制流图
test.c.015t.ssa           # SSA 形式  ← 这里!
test.c.042t.fre1          # 值编号 / 冗余消除
test.c.100t.optimized     # 所有优化后的最终 GIMPLE
1
2
3
4
5
6
7
8

# 4.5 优化流水线

疑惑:-O2 到底做了哪些优化?为什么比 -O0 快那么多?

论证:GCC 的优化不是一个大黑盒,而是一个 pass 流水线(pass pipeline)——上百个小优化依次执行:

$ gcc -O2 -fdump-passes test.c -o /dev/null
# 输出所有 pass 的名字和顺序(约 300 个)
1
2

核心优化 pass 分类:

类别 代表 pass 干什么
标量优化 FRE (值编号), SCCP (稀疏条件常量传播), DCE (死代码消除) 在单线程内消除冗余计算
循环优化 LICM (循环不变量外提), IVOpt (归纳变量优化), 循环展开 把循环内的不变量提到循环外
内联 IPA Inline (跨过程内联) 把函数体嵌入调用点
向量化 SLP (超字级并行), Loop Vectorize 把标量操作转成 SIMD
指令级 指令调度, 寄存器分配, peephole 为特定 CPU 微架构优化
内存 DSE (死存储消除), 别名分析 消除对同一地址的冗余读写

Pass 流水线示意(-O2):

GIMPLE 生成
  → SSA 构建
  → CCP (条件常量传播)
  → DCE (死代码消除)       ← 消除常量传播中变死的代码
  → forwprop (前向替换)    ← y=x, z=y+1 → z=x+1
  → PRE (部分冗余消除)     ← a*b 重复计算 → 只算一次
  → → → 循环优化阶段
  → LICM (循环不变量外提)  ← 就是它把 g_data_ready 提出循环!
  → IVOpt (归纳变量优化)   ← for(i=0;i<N;i++) arr[i]=i → 用指针代替
  → Loop unrolling (循环展开)
  → → → 标量优化阶段
  → FRE (值编号)
  → → → 回到RTL层
  → combine (指令合并)
  → RA (寄存器分配)
  → peephole2 (窥孔优化)   ← mov r0,r1; mov r1,r2 → mov r0,r2
  → final (输出汇编)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

每个 pass 都是"机会主义"的——它看到一个可以优化的模式就动手,看不到就跳过。这意味着:代码的微小变化(如添加一个 printf)可能会改变某个 pass 的决策,导致完全不同的汇编输出。这就是"安全编码"的意义——不要依赖编译器的特定优化行为。

# 4.6 代码生成

优化后的 GIMPLE 转换为 RTL (Register Transfer Language),然后在 RTL 层面做最后的"翻译":

  1. 指令选择:把 GIMPLE 的三地址码映射到目标 CPU 的指令——i + j → x86 的 lea 或 add
  2. 指令调度:重排指令顺序,使 CPU 流水线不停顿
  3. 寄存器分配:决定哪个变量放哪个寄存器、哪个"溢出"到栈上——这是编译器中最复杂的部分之一(图着色算法)
int add3(int a, int b, int c, int d, int e, int f, int g) {
    return a + b + c + d + e + f + g;  // 7 个参数,x86-64 只有 6 个寄存器传参
}
1
2
3
add3:
    # 前6个参数在 rdi, rsi, rdx, rcx, r8, r9
    # 第7个参数 g 在栈上
    add     edi, esi              # a + b
    add     edi, edx              # + c
    add     edi, ecx              # + d
    add     edi, r8d              # + e
    add     edi, r9d              # + f
    add     edi, DWORD PTR [rsp+8]  # + g (从栈读取)
    mov     eax, edi
    ret
1
2
3
4
5
6
7
8
9
10
11

寄存器分配的可视化效果:同一个函数在 -O0 下每个变量单独放栈上,在 -O2 下全放寄存器——指令数可能差 3~5 倍。

# 5. 汇编读写实战

# 5.1 函数序言与尾声解码

每个 C 函数在汇编中都有标准的"序言(prologue)"和"尾声(epilogue)":

int add(int a, int b) {
    int sum = a + b;
    return sum;
}
1
2
3
4
add:
    # ===== 序言 =====
    push    rbp         ; 1. 保存调用者的栈帧基址
    mov     rbp, rsp    ; 2. 把当前栈顶设为新栈帧基址

    # ===== 函数体 =====
    mov     DWORD PTR [rbp-20], edi   ; 把参数 a (edi) 存到栈上 a的位置
    mov     DWORD PTR [rbp-24], esi   ; 把参数 b (esi) 存到栈上 b的位置
    mov     edx, DWORD PTR [rbp-20]   ; 加载 a (没用!-O0 的冗余)
    mov     eax, DWORD PTR [rbp-24]   ; 加载 b
    add     eax, edx                  ; sum = a + b
    mov     DWORD PTR [rbp-4], eax    ; 把 sum 存到栈上

    # ===== 尾声 =====
    mov     eax, DWORD PTR [rbp-4]    ; 返回值 = sum
    pop     rbp         ; 3. 恢复调用者的栈帧基址
    ret                 ; 4. 返回(弹出返回地址并跳转)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

-O0 生成的代码很"啰嗦"——每个变量都有独立的栈槽,load/store 频繁。这不是真实世界的性能代码,但它忠实反映了 C 代码的结构,方便调试。

同一函数 -O2 版:

add:
    lea     eax, [rdi + rsi]   ; 一行!lea 同时做加法和结果传值
    ret
1
2
3

序言和尾声中,ebp/rbp 可以省略:如果函数足够简单(不到处跳转、不使用 alloca/VLA),GCC/Clang 会用 -fomit-frame-pointer(-O1 以上默认开启)省略 rbp——这样子 rbp 就变成一个通用寄存器,更高效。

# -fomit-frame-pointer 版的 add (无 rbp)
add:
    lea     eax, [rdi + rsi]
    ret
# push rbp / pop rbp 都没有了,函数体只有 3 字节!
1
2
3
4
5

# 5.2 x86-64 SysV ABI 调用约定速查

理解 C 代码怎么变成汇编,必须知道 ABI 的调用约定:

整数/指针参数(最多 6 个用寄存器,超过的压栈):

参数位置 寄存器
第 1 个 RDI
第 2 个 RSI
第 3 个 RDX
第 4 个 RCX
第 5 个 R8
第 6 个 R9
第 7+ 个 栈 (从右到左)

浮点参数(最多 8 个用寄存器):

参数位置 寄存器
第 1-8 个 XMM0 - XMM7

返回值:

类型 寄存器
整数/指针 RAX
浮点 XMM0
128 位整数 RAX + RDX

调用者保存 vs 被调用者保存:

寄存器 谁保存 含义
RAX, RCX, RDX, RSI, RDI, R8-R11 调用者 调用后可能被改,调用者需自行保存
RBX, RBP, R12-R15 被调用者 如果被调用者要使用,必须先 push,返回前 pop
XMM0-XMM15 全部是调用者保存

这些约定直接影响你能看到的汇编——如果看一个函数的汇编,rdi 被用之前没有赋过值,那它就是第一个参数。

# 5.3 控制流汇编

if-else:

int max(int a, int b) {
    if (a > b)
        return a;
    else
        return b;
}
1
2
3
4
5
6
max:
    cmp     edi, esi        ; 比较 a 和 b
    jle     .L_else         ; 如果 a <= b,跳到 else
.L_then:
    mov     eax, edi        ; return a
    ret
.L_else:
    mov     eax, esi        ; return b
    ret
1
2
3
4
5
6
7
8
9

while 循环:

int sum_to(int n) {
    int s = 0;
    while (n > 0) {
        s += n;
        n--;
    }
    return s;
}
1
2
3
4
5
6
7
8
sum_to:
    xor     eax, eax        ; s = 0
    test    edi, edi        ; 检查 n
    jle     .L_done         ; 如果 n <= 0,直接返回
.L_loop:
    add     eax, edi        ; s += n
    sub     edi, 1          ; n--
    jne     .L_loop         ; 如果 n != 0,继续循环
.L_done:
    ret
1
2
3
4
5
6
7
8
9
10

GCC -O2 的聪明之处:在这个循环中,编译器识别出 n 从参数值递减到 0,不需要 cmp 指令——利用 sub 指令已经设置的 ZF(零标志位),直接用 jne 判断。

# 5.4 栈帧在汇编中的样子

int compute(int x, int y) {
    int buf[4] = {x, y, x+y, x-y};
    int result = buf[0] + buf[1] + buf[2] + buf[3];
    return result;
}
1
2
3
4
5
compute:
    push    rbp
    mov     rbp, rsp
    sub     rsp, 32          ; 栈上分配 32 字节 (16 字节对齐)

    ; buf[0..3] 初始化
    mov     DWORD PTR [rbp-16], edi     ; buf[0] = x
    mov     DWORD PTR [rbp-12], esi     ; buf[1] = y
    lea     eax, [rdi+rsi]              ; x+y
    mov     DWORD PTR [rbp-8], eax      ; buf[2] = x+y
    sub     edi, esi
    mov     DWORD PTR [rbp-4], edi      ; buf[3] = x-y

    ; result = buf[0] + buf[1] + buf[2] + buf[3]
    mov     eax, DWORD PTR [rbp-16]
    add     eax, DWORD PTR [rbp-12]
    add     eax, DWORD PTR [rbp-8]
    add     eax, DWORD PTR [rbp-4]

    leave              ; 等价于 mov rsp, rbp; pop rbp
    ret
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

栈帧布局(-O0):

高地址
┌──────────────────────┐
│  调用者的 rbp         │  ← rbp + 8
├──────────────────────┤
│  返回地址             │  ← rbp + 16 (调用者在 call 之前 push 的)
├──────────────────────┤  ← 调用者的 rbp (最初)
│  保存的旧 rbp         │  ← push rbp 写的
├──────────────────────┤  ← rbp (sub rsp, 32 之后)
│  buf[3] (x-y)        │  ← rbp - 4
│  buf[2] (x+y)        │  ← rbp - 8
│  buf[1] = y          │  ← rbp - 12
│  buf[0] = x          │  ← rbp - 16
│  (padding)           │
├──────────────────────┤  ← rsp
低地址
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

-O2 版:编译器意识到 buf[0]+buf[1]+buf[2]+buf[3] = x + y + (x+y) + (x-y) = 3x + y,直接优化成:

compute:
    lea     edx, [rdi+rdi*2]  ; edx = 3*x
    lea     eax, [rdx+rsi]    ; eax = 3*x + y
    ret                       ; 连栈帧都没有!buf 被完全优化掉了
1
2
3
4

# 6. 优化等级对比实测

# 6.1 三种汇编

用实际代码展示 -O0 / -O2 / -O3 的差异:

// bench.c
int sum_array(int* arr, int n) {
    int total = 0;
    for (int i = 0; i < n; i++) {
        total += arr[i];
    }
    return total;
}
1
2
3
4
5
6
7
8
$ gcc -O0 -S bench.c -o bench_O0.s -fverbose-asm
$ gcc -O2 -S bench.c -o bench_O2.s -fverbose-asm
$ gcc -O3 -S bench.c -o bench_O3.s -fverbose-asm
1
2
3

-O0 输出(39 行汇编,逐行对应 C):

sum_array:
    push    rbp
    mov     rbp, rsp
    mov     QWORD PTR [rbp-24], rdi    # arr
    mov     DWORD PTR [rbp-28], esi    # n
    mov     DWORD PTR [rbp-8], 0       # total = 0
    mov     DWORD PTR [rbp-4], 0       # i = 0
    jmp     .L_cond
.L_body:
    mov     eax, DWORD PTR [rbp-4]     # 加载 i
    cdqe
    lea     rdx, 0[0+rax*4]            # i * 4
    mov     rax, QWORD PTR [rbp-24]    # 加载 arr
    add     rax, rdx                   # &arr[i]
    mov     eax, DWORD PTR [rax]       # arr[i]
    add     DWORD PTR [rbp-8], eax     # total += arr[i]
    add     DWORD PTR [rbp-4], 1       # i++
.L_cond:
    mov     eax, DWORD PTR [rbp-4]     # 加载 i
    cmp     eax, DWORD PTR [rbp-28]    # i < n ?
    jl      .L_body
    mov     eax, DWORD PTR [rbp-8]     # return total
    pop     rbp
    ret
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

-O2 输出(12 行汇编):

sum_array:
    test    esi, esi              # 检查 n <= 0
    jle     .L_zero
    lea     eax, [rdi+rsi*4]      # end_ptr = arr + n
    xor     edx, edx              # total = 0
.L_loop:
    add     edx, DWORD PTR [rdi]  # total += *arr
    add     rdi, 4                # arr++
    cmp     rdi, rax              # arr < end_ptr ?
    jne     .L_loop
    mov     eax, edx              # return total
    ret
.L_zero:
    xor     eax, eax              # return 0
    ret
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

关键优化:归纳变量消除——i 这个计数器被干掉了,直接用指针 arr 的推进来模拟循环。

-O3 输出(18 行汇编,但可能用了 SSE/AVX 向量化):

sum_array:
    test    esi, esi
    jle     .L_zero
    mov     rax, rdi
    lea     ecx, [rsi-1]
    lea     rdx, [rdi+rcx*4+4]
    cmp     rdx, rax
    jbe     .L_small
    ; ---- SSE 向量化部分 ----
    pxor    xmm0, xmm0
    mov     r8, rdi
    and     r8, 15               # 对齐检查
    ; ... SSE 向量加法 ...
    ; ---- 标量尾部 ----
.L_tail:
    ...
.L_small:
    ; 小数组直接用标量
    ...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

性能对比(对 100万元素数组):

优化等级 耗时 关键优化
-O0 ~2.1 ms 无
-O2 ~0.35 ms 归纳变量消除、指针优化
-O3 ~0.12 ms 循环展开 + SSE 向量化(4 个 int 一起加)
-Ofast ~0.08 ms 加上 -ffast-math(允许不精确的浮点结合律)

# 6.2 死代码消除

常量折叠(constant folding):

int compute(void) {
    int x = 60 * 60 * 24;           // 常量表达式
    int y = strlen("hello");        // strlen 也是常量!(GCC 内置)
    return x / y;
}
1
2
3
4
5
# -O0:
compute:
    push    rbp
    mov     rbp, rsp
    mov     DWORD PTR [rbp-4], 86400   # 86400 = 60*60*24
    mov     DWORD PTR [rbp-8], 5       # strlen("hello") = 5
    mov     eax, 86400
    cdq
    idiv    DWORD PTR [rbp-8]           # 86400 / 5
    pop     rbp
    ret

# -O2:
compute:
    mov     eax, 17280                 # 86400/5 直接算好!17280
    ret
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

所有计算在编译期完成,运行时只剩一个 mov。

死代码消除(DCE, Dead Code Elimination):

int foo(int x) {
    int a = expensive_computation();   // 费时计算
    return x * 2;                      // 但 a 从未被使用!
}
1
2
3
4
# -O2:
foo:
    lea     eax, [rdi+rdi]    # x*2,压根不调 expensive_computation
    ret
1
2
3
4

编译器发现 a 的值永远不会被读取(没有副作用),于是整个计算被删除。这就是第 1 章案例的优化本质。

# 6.3 内联展开的决策边界

static int tiny(int x) { return x + 1; }

int outer(int a, int b) {
    return tiny(a) + tiny(b);
}
1
2
3
4
5
# -O2(tiny 被内联了):
outer:
    lea     eax, [rdi+rsi+2]   # (a+1) + (b+1) = a+b+2
    ret
1
2
3
4

GCC 的内联决策基于启发式算法——它估算函数体的"代价"(cost),与调用开销对比:

inline cost < call cost → 内联
inline cost >= call cost → 不内联(除非 __attribute__((always_inline)))
1
2

查看内联决策:

$ gcc -O2 -fdump-ipa-inline test.c -o /dev/null
$ cat test.c.*i.inline* | grep -E "(Inlining|not inlining)"
# 输出:Inlining tiny into outer  ← GCC 决定内联 tiny
1
2
3

# 6.4 循环展开与向量化

循环展开(loop unrolling):把循环体复制 N 份,减少分支和比较指令:

void copy(int* dst, int* src, int n) {
    for (int i = 0; i < n; i++)
        dst[i] = src[i];
}
1
2
3
4
# -O3 循环展开(每次迭代复制 4 个 int)
.L_unrolled:
    mov     eax, DWORD PTR [rsi]
    mov     DWORD PTR [rdi], eax           # dst[0] = src[0]
    mov     eax, DWORD PTR [rsi+4]
    mov     DWORD PTR [rdi+4], eax         # dst[1] = src[1]
    mov     eax, DWORD PTR [rsi+8]
    mov     DWORD PTR [rdi+8], eax         # dst[2] = src[2]
    mov     eax, DWORD PTR [rsi+12]
    mov     DWORD PTR [rdi+12], eax        # dst[3] = src[3]
    add     rsi, 16                         # src += 4
    add     rdi, 16                         # dst += 4
    sub     edx, 4                          # 剩余计数 -= 4
    jne     .L_unrolled
1
2
3
4
5
6
7
8
9
10
11
12
13
14

向量化(auto-vectorization):用 SIMD 指令一次处理多个数据:

# -O3 自动向量化版(一次复制 4 个 int = 128 位 SSE)
.L_vectorized:
    movdqu  xmm0, XMMWORD PTR [rsi]     # 加载 4 个 int
    movdqu  XMMWORD PTR [rdi], xmm0     # 存储 4 个 int
    add     rsi, 16
    add     rdi, 16
    sub     edx, 4
    jne     .L_vectorized
1
2
3
4
5
6
7
8

循环展开也是 x86-64 那条,向量化用 movdqu 一次 128 位。如果再开 AVX2 (-mavx2),一次可以处理 8 个 int(256 位)。

查看向量化报告:

$ gcc -O3 -fopt-info-vec test.c
test.c:5:3: optimized: loop vectorized using 16 byte vectors
test.c:8:3: optimized: loop vectorized using 32 byte vectors   # AVX2
1
2
3

# 7. volatile 与编译器优化

# 7.1 volatile 的真正语义

volatile 告诉编译器三件事:

1. 每次读取必须从内存读,不能用寄存器缓存
2. 每次写入必须写回内存,不能延迟或合并
3. volatile 变量的读写顺序之间不可重排(但与非volatile变量之间可以!)
1
2
3
volatile int flag = 0;

// 线程 1
void wait_for_flag(void) {
    while (flag == 0) {     // 每次迭代都从内存读 flag
        /* busy-wait */
    }
}

// 线程 2 (ISR 或另一个线程)
void set_flag(void) {
    flag = 1;               // 立即写回内存
}
1
2
3
4
5
6
7
8
9
10
11
12
13

不加 volatile,-O2 优化后的 wait_for_flag:

wait_for_flag:
    mov     eax, DWORD PTR flag[rip]    # 只读一次!
    test    eax, eax
    jne     .L_done
.L_infinite:
    jmp     .L_infinite                 # 死循环!再也不读 flag 了
.L_done:
    ret
1
2
3
4
5
6
7
8

加 volatile 后:

wait_for_flag:
.L_loop:
    mov     eax, DWORD PTR flag[rip]    # 每次迭代都从内存加载
    test    eax, eax
    je      .L_loop                     # 如果 flag==0,继续
    ret
1
2
3
4
5
6

# 7.2 变量去哪了

疑惑:一个变量如果在编译期被完全优化掉,它"去哪了"?

论证:

int dead_store(void) {
    int x = 42;
    x = 100;            // 第一次赋的值被覆盖,死存储
    return x;           // 或者 x 根本没被读,整个计算都死了
}
1
2
3
4
5
# -O2: x 完全消失了!
dead_store:
    mov     eax, 100    # 直接返回 100
    ret
1
2
3
4

变量的"命运"取决于:

  • 被完全优化掉:值直接内联到使用处,连寄存器/栈槽都不占
  • 被提升到寄存器:在整个函数体里 x 就是一个寄存器,不占内存
  • 被优化到栈:-O0 默认,每个变量都在栈上有槽位

用 gdb 调试优化后的代码是场噩梦——局部变量可能在任何时刻"消失":

(gdb) p x
$1 = <optimized out>    # GDB 告诉你:编译器把 x 优化没了
1
2

这就是为什么调试时用 -Og(而不是 -O0 或 -O2):它在保留调试体验的同时做了一些基本优化。

# 7.3 嵌入式寄存器

在嵌入式开发中,volatile 是不可或缺的:

// 微控制器的 GPIO 寄存器映射(0x40020000 是 GPIOA 基址)
#define GPIOA_ODR  (*(volatile uint32_t*)0x40020014)  // 输出数据寄存器

// LED 闪烁
void led_blink(int count) {
    for (int i = 0; i < count; i++) {
        GPIOA_ODR |= (1 << 5);      // 点亮 PA5
        delay(500);
        GPIOA_ODR &= ~(1 << 5);     // 熄灭 PA5
        delay(500);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12

不加 volatile 的后果:

# -O2(没有 volatile):
led_blink:
    mov     r0, #0x40020014
    ldr     r1, [r0]           # 只加载一次!
    orr     r1, r1, #0x20      # 设置 bit 5
    ; ⚠️ 被优化掉的写入:编译器发现 GPIOA_ODR 的值没被"代码"修改
    ; 于是认为第二次写入的值和第一次一样,直接删除了!
    bl      delay
    ; ⚠️ 缺少 "熄灭" 的代码!
    bl      delay
    sub     r2, r2, #1
    bne     ...
1
2
3
4
5
6
7
8
9
10
11
12

硬件寄存器必须标记 volatile——编译器的优化逻辑是"软件视角",不知道硬件在另一边读写。这是嵌入式领域"所有寄存器映射指针必须 volatile"的根源。

# 7.4 volatile 不是原子操作

疑惑:既然 volatile 保证每次都从内存读写,那是不是意味着 volatile int x 是线程安全的?

论证——经典反例:

volatile int counter = 0;

// 两个线程同时执行 counter++
void thread_inc(void) {
    counter++;     // 这仍然是三步:读→加→写,不是原子的!
}
1
2
3
4
5
6
thread_inc:
    mov     eax, DWORD PTR counter[rip]   # 读 (线程 A 读 = 0)
    add     eax, 1                         # 加
    mov     DWORD PTR counter[rip], eax    # 写 (线程 A 写 = 1)
    ret

    # 如果线程 B 在线程 A 的"读"之后、"写"之前读了 counter:
    # 线程 A 读 = 0, 线程 B 读 = 0
    # 线程 A 写 = 1, 线程 B 写 = 1
    # counter = 1(丢失了一次递增!)
1
2
3
4
5
6
7
8
9
10

结论:

  • volatile 是编译器的约束,不是 CPU/缓存的约束
  • x86 的 counter++ 在汇编中仍然是 mov + add + mov 三条指令,中间可被中断
  • 真正的线程安全需要 _Atomic (C11) 或 std::atomic (C++)
  • volatile = "每次从内存读";atomic = "读→改→写 是不可分割的"

# 8. 综合案例串讲

# 8.1 案例真相揭晓

回到第 1 章嵌入式传感器采集的八个疑问,逐条作答:

疑问 答案
① g_data_ready 为什么被提升到循环外? 第 6.2 节:LICM 优化——编译器发现循环内不写它,就提到循环外只读一次
② volatile 到底做了啥? 第 7.1:强制每次从内存读/写,禁止缓存到寄存器
③ 优化 pass 怎么组织的? 第 4.5:GCC 有约 300 个 pass,按顺序组成流水线
④ .c 怎么变成机器码? 第 3 章:cpp→cc1→as→ld 四阶段
⑤ -O0/-O2/-Os 各做了什么? 第 6 章:LOCM/内联/向量化等,差异可达 20~30 倍性能
⑥ 怎么看汇编? 第 5 章:gcc -S / -fverbose-asm / objdump -d
⑦ 中间表示长什么样? 第 4.4:GIMPLE 三地址码 + SSA 形式
⑧ volatile 能替代原子操作吗? 第 7.4:不能——它不保证读-改-写的原子性

完整的根因链条:

ISR 修改 g_data_ready (内存)
   ↓
main_loop:
   LICM pass 发现 g_data_ready 在循环体内"未被写入"
   → 把读取提升到循环外 → 只读一次 (寄存器缓存)
   → 之后永远看不到 ISR 的更新
   ↓
死机/丢数据
1
2
3
4
5
6
7
8

修复方案:

方案 A:加 volatile(正确,最简)

static volatile bool g_data_ready = false;
1

方案 B:用原子操作(更正确,但更重)

#include <stdatomic.h>
static atomic_bool g_data_ready = false;

// ISR:
atomic_store(&g_data_ready, true);

// main_loop:
if (atomic_load(&g_data_ready)) { ... }
1
2
3
4
5
6
7
8

atomic 不仅防止编译器重排,还插入内存屏障(memory barrier),阻止 CPU 的乱序执行——比 volatile 更强但更慢。

方案 C:memory barrier 宏(嵌入式常用)

#define MEMORY_BARRIER()  __asm__ __volatile__("" ::: "memory")

// main_loop:
while (1) {
    if (g_data_ready) {
        MEMORY_BARRIER();   // 告诉编译器:所有内存可能被改了
        ...
    }
}
1
2
3
4
5
6
7
8
9

把"可能被外部修改"的负担从变量定义转移到特定的代码位置。

# 8.2 一行 C 代码的编译之旅

把 int x = 42; 这一行从字符到机器码串起来:

"int x = 42;"

  ├─ 词法分析 (lexer)
  │   令牌: [关键字:"int"] [标识符:"x"] [运算符:"="] [整数:"42"] [";"]
  │
  ├─ 语法分析 (parser)
  │   AST: DECL(type=int, name="x", init=INTEGER_CST(42))
  │
  ├─ 语义分析
  │   检查: int 类型 OK, 42 可以赋值给 int
  │   符号表: 添加 "x" → INTEGER_TYPE, 局部变量, 栈偏移 = -4
  │
  ├─ GIMPLE 生成
  │   x = 42;
  │
  ├─ SSA 化
  │   x_1 = 42;
  │
  ├─ 优化 (常量传播)
  │   发现 x 的所有使用处都可以直接替换为 42
  │   → x 被消除,其使用处直接写死 42
  │   → 如果 x 从未被使用 → 整行代码被 DCE 删除
  │
  ├─ RTL 生成 (如果 x 没被优化掉)
  │   (set (reg:SI ax) (const_int 42))
  │
  ├─ 寄存器分配
  │   eax ← 42
  │
  └─ 最终汇编
      movl    $42, -4(%rbp)     # -O0: 存在栈上
      movl    $42, %eax         # -O2: 可能只存在于寄存器,或完全消失
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
28
29
30
31
32

# 8.3 面试高频问题清单

1. .c 到可执行文件经历了哪几个阶段?

预处理 .c→.i(cpp:宏展开、头文件包含)、编译 .i→.s(cc1:词法→语法→语义→IR→优化→代码生成)、汇编 .s→.o(as:助记符→机器码)、链接 .o→executable(ld:合并 .o/.so、符号解析、重定位)。

2. 编译器的优化 pipeline 做了什么?最关键的有哪些?

标量优化(常量传播、死代码消除、值编号),循环优化(LICM、归纳变量消除、循环展开),跨过程优化(内联),向量化(SIMD)。LICM 就是第 1 章案例的元凶——它把循环不变量提到循环外。

3. -O0/-O2/-O3 的区别?

-O0:不优化,变量全部在栈上,方便调试。-O2:开启几乎所有大小合理的优化(内联、LICM、常量传播、死代码消除等)。-O3:在 -O2 基础上加循环展开、向量化、更激进的内联。-Os:体积优先。生产代码默认 -O2。

4. 怎么读汇编?push rbp; mov rbp, rsp 是什么?

函数序言(prologue):保存调用者的栈帧基址,建立自己的栈帧。对应的尾声(epilogue)是 pop rbp(或 leave)+ ret。-fomit-frame-pointer(-O1 以上默认)会省略这一对。

5. volatile 的作用是什么?什么时候必须用它?

告诉编译器:每次读写必须走内存,不能缓存到寄存器。三大场景:① 内存映射硬件寄存器(嵌入式 GPIO 等)② ISR 与主循环共享的全局变量 ③ setjmp/longjmp 之间被修改的局部变量。volatile 不能替代原子操作,它不保证读-改-写的原子性。

6. volatile 和 atomic 的区别?

volatile 只约束编译器优化(每次从内存读写),不插入内存屏障。atomic (C11 _Atomic) 保证操作的原子性和内存序。多线程同步必须用 atomic,嵌入式硬件寄存器用 volatile。

7. 编译器什么时候会优化掉我的变量?

当它发现变量的值在编译期可知(常量折叠)、或者从定义到使用从未被读取(死代码消除)、或者使用处的值可以通过寄存器/立即数表示(栈→寄存器提升)。用 -Og 调试可以保留变量。

8. GCC IR (GIMPLE) 是什么?

GIMPLE 是 GCC 的中间表示,形式是简化的 C(三地址码:每个操作最多一个运算符 + 三个操作数)。所有优化 pass 都在 GIMPLE 上工作。之后再转换为 RTL(寄存器传输语言)做目标相关的优化和代码生成。可以用 -fdump-tree-all 看所有 pass 后的 GIMPLE。

9. "被优化掉的变量"怎么找回来调试?

gcc -Og(优化 + 调试友好)、gdb 中 p var 出现 <optimized out> 说明它被优化掉了。对于裸嵌入式:加 volatile 阻止优化;加 log/printf 也是一种"保持变量存活"的手段(虽然这是副作用,正是第 1 章的 Heisenbug 成因)。

10. 循环向量化是怎么自动发生的?

-O3(或显式加 -ftree-vectorize)自动分析循环——如果循环体是"对连续数组元素做相同运算",且没有循环携带依赖,就生成 SIMD 指令(SSE/AVX)。-fopt-info-vec 可以看到向量化报告。

# 8.4 编译调试速查卡

分阶段查看:

# 看预处理输出
gcc -E source.c -o source.i
gcc -E -dM source.c            # 只看所有宏定义

# 看汇编(⭐ 最常用)
gcc -S source.c -o source.s
gcc -S -fverbose-asm source.c  # 汇编中带变量名和C代码注释
gcc -S -masm=intel source.c    # 用 Intel 语法(默认是 AT&T)

# 看目标文件
objdump -d object.o             # 反汇编 .text 段
objdump -t object.o             # 符号表
objdump -h object.o             # 段头表
objdump -r object.o             # 重定位表
nm object.o                     # 简洁版符号表

# 看 ELF 结构
readelf -h executable           # ELF 头
readelf -l executable           # 程序头(加载器用)
readelf -S executable           # 节头(链接器用)
readelf -s executable           # 符号表
size executable                 # text/data/bss 分段大小
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

看 GCC 优化决策:

# 看所有优化 pass 及顺序
gcc -O2 -fdump-passes source.c -o /dev/null

# 看每个 pass 后的 GIMPLE 树
gcc -O2 -fdump-tree-all source.c -o /dev/null
# 生成 source.c.* 文件,想看的 pass 都可以读

# 具体看内联决策
gcc -O2 -fdump-ipa-inline source.c

# 看向量化报告
gcc -O3 -fopt-info-vec source.c

# 看优化总览
gcc -O2 -fopt-info source.c

# 看 GCC 到底调了什么命令
gcc -v source.c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

汇编对照技巧:

# 生成带 C 源代码交叉引用的汇编(最调试友好)
gcc -g -O2 -S -fverbose-asm source.c

# Clang 的对应命令
clang -S -emit-llvm source.c          # 输出 LLVM IR
clang -S source.c                     # 输出汇编
1
2
3
4
5
6

下一篇:14.链接器符号解析与重定位 —— 我们已经知道".c 怎么变成 .o",下一步进入链接阶段:为什么 undefined reference to 'foo'?static 函数和全局函数在符号表里有什么区别?动态库和静态库的链接差异是什么?

上次更新: 2026/06/11, 09:01:44
预处理器宏与条件编译
链接器符号与重定位

← 预处理器宏与条件编译 链接器符号与重定位→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式