第4集:编译器的工作流程概览

学习目标

通过本集的学习,你将能够:

  • 理解编译器的前端、中端、后端划分
  • 说出编译过程的7个主要阶段
  • 用图解法描述完整的编译流程
  • 区分黑箱和白箱视角

4.1 前端、中端、后端

现代编译器通常被划分为三个部分:前端、中端和后端。这种设计让编译器更容易移植和扩展。

前端(Front End)

  • 任务:分析源代码,理解程序的意思
  • 工作:词法分析、语法分析、语义分析
  • 特点:与源语言相关,与目标机器无关
  • 目标:生成中间表示(IR)

中端(Middle End / 优化器)

  • 任务:对中间表示进行优化
  • 工作:代码优化、循环优化、内联等
  • 特点:既不依赖源语言,也不依赖目标机器
  • 目标:让程序跑得更快、更小

后端(Back End)

  • 任务:把中间表示转换成目标机器码
  • 工作:指令选择、寄存器分配、指令调度
  • 特点:与目标机器相关,与源语言无关
  • 目标:生成高效的机器码

让我们用一个工厂流水线来类比:

┌─────────────────────────────────────────────────────────────────┐
│                         编译器工厂                               │
├──────────────────┬──────────────────┬───────────────────────────┤
│   前端车间       │   中端车间       │      后端车间            │
│                  │                  │                           │
│  ┌──────────┐   │  ┌──────────┐   │   ┌──────────┐          │
│  │源代码    │ → │  │中间表示  │ → │   │目标代码  │          │
│  └──────────┘   │  └──────────┘   │   └──────────┘          │
│                  │                  │                           │
│ 词法分析         │  常量折叠        │   指令选择               │
│ 语法分析         │  死代码消除      │   寄存器分配             │
│ 语义分析         │  循环优化        │   指令调度               │
│ 生成IR           │  内联展开        │   生成机器码             │
└──────────────────┴──────────────────┴───────────────────────────┘

这种划分的好处

  1. 可移植性好:想支持新的源语言?只需写新前端
  2. 可扩展性强:想支持新的目标机器?只需写新后端
  3. 优化共享:中端优化可以被所有前端和后端共享

4.2 编译过程的7个阶段

让我们详细看看编译器内部的7个阶段:

源代码
   │
   ▼
┌──────────────┐
│  1. 词法分析  │ ← 把字符流转换成 Token 流
└──────┬───────┘
       │
       ▼
┌──────────────┐
│  2. 语法分析  │ ← 把 Token 流转换成语法树
└──────┬───────┘
       │
       ▼
┌──────────────┐
│  3. 语义分析  │ ← 检查语义错误,生成 IR
└──────┬───────┘
       │
       ▼
┌──────────────┐
│  4. 中间代码  │ ← 生成中间表示(IR)
│     生成     │
└──────┬───────┘
       │
       ▼
┌──────────────┐
│  5. 机器无关  │ ← 优化中间代码
│    优化      │
└──────┬───────┘
       │
       ▼
┌──────────────┐
│  6. 目标代码  │ ← 把 IR 转换成目标代码
│     生成     │
└──────┬───────┘
       │
       ▼
┌──────────────┐
│  7. 机器相关  │ ← 优化目标代码
│    优化      │
└──────┬───────┘
       │
       ▼
   目标代码

让我们用一个具体的例子来追踪:int result = 1 + 2;

阶段 1:词法分析

输入:字符流

'int result = 1 + 2;'

输出:Token 流

[INT, ID("result"), ASSIGN, NUMBER(1), PLUS, NUMBER(2), SEMICOLON]

阶段 2:语法分析

输入:Token 流
输出:语法树

        =
       / \
 result   +
         / \
        1   2

阶段 3:语义分析

检查:

  • result 是否已声明?
  • 12 都是整数,可以相加
  • 赋值的类型匹配

输出:带语义信息的 AST

阶段 4:中间代码生成

输出:三地址码

t1 = 1 + 2
result = t1

阶段 5:机器无关优化

优化后:

result = 3

(常量折叠,直接算出结果)

阶段 6:目标代码生成

输出:汇编(x86)

mov $3, %rax
mov %rax, result

阶段 7:机器相关优化

可能优化成:

mov $3, result

(直接存储,省去寄存器)

4.3 图解完整编译流程

让我们用一张大图来展示完整的编译流程:

┌──────────────────────────────────────────────────────────────┐
│                        源程序文件                             │
│                    (hello.c 等)                              │
└────────────────────┬─────────────────────────────────────────┘
                     │
                     ▼
         ┌───────────────────────────┐
         │      预处理器              │  ← 处理 #include、#define 等
         └───────────┬───────────────┘
                     │
                     ▼
         ┌───────────────────────────┐
         │      词法分析器            │  ← 字符 → Token
         └───────────┬───────────────┘
                     │
                     ▼
         ┌───────────────────────────┐
         │      语法分析器            │  ← Token → 语法树
         └───────────┬───────────────┘
                     │
                     ▼
         ┌───────────────────────────┐
         │      语义分析器            │  ← 检查错误、添类型
         └───────────┬───────────────┘
                     │
                     ▼
         ┌───────────────────────────┐
         │    中间代码生成器          │  ← 生成 IR
         └───────────┬───────────────┘
                     │
                     ▼
         ┌───────────────────────────┐
         │    机器无关优化器          │  ← 优化 IR
         └───────────┬───────────────┘
                     │
                     ▼
         ┌───────────────────────────┐
         │    目标代码生成器          │  ← IR → 汇编
         └───────────┬───────────────┘
                     │
                     ▼
         ┌───────────────────────────┐
         │       汇编器               │  ← 汇编 → 机器码
         └───────────┬───────────────┘
                     │
                     ▼
         ┌───────────────────────────┐
         │       链接器               │  ← 合并多个文件
         └───────────┬───────────────┘
                     │
                     ▼
         ┌───────────────────────────┐
         │      可执行文件            │
         │    (a.out, hello.exe)     │
         └───────────────────────────┘

4.4 黑箱 vs 白箱

黑箱视角(Black Box)

  • 只看输入输出:源代码 → 编译器 → 可执行文件
  • 不关心内部:编译器是怎么工作的?我不知道
  • 适合使用者:大部分程序员只需要会用编译器就行
   源代码
     │
     ▼
┌──────────┐
│  编译器  │  ← 黑箱,不知道里面在干嘛
└────┬─────┘
     │
     ▼
  可执行文件

白箱视角(White Box)

  • 了解内部结构:打开编译器,看每个阶段在做什么
  • 理解原理:词法分析、语法分析、优化...
  • 适合本课程:我们就是要打开这个黑箱!
   源代码
     │
     ▼
┌─────────────────────────┐
│ ┌─────┐ ┌─────┐ ┌─────┐│
│ │词法 │ │语法 │ │优化 ││  ← 白箱,看清每一步
│ │分析 │ │分析 │ │器  ││
│ └─────┘ └─────┘ └─────┘│
└───────────┬─────────────┘
            │
            ▼
         可执行文件

4.5 用 GCC 实际看看

让我们用 GCC 来看看实际的编译过程。假设我们有一个文件 hello.c

#include <stdio.h>

int main() {
    printf("Hello, Compiler!\n");
    return 0;
}

只预处理(-E)

gcc -E hello.c -o hello.i

输出 hello.i:展开了头文件,处理了宏

只编译到汇编(-S)

gcc -S hello.c -o hello.s

输出 hello.s:汇编语言代码

只编译到目标文件(-c)

gcc -c hello.c -o hello.o

输出 hello.o:机器码目标文件

完整编译

gcc hello.c -o hello

输出 hello:可执行文件

4.6 自测一下

问题 1

编译器的前端主要负责什么?
A) 生成机器码
B) 分析源代码,理解程序含义
C) 优化目标代码
D) 以上都是

问题 2

编译过程的正确顺序是?
A) 词法分析 → 语法分析 → 语义分析 → 代码生成
B) 语法分析 → 词法分析 → 语义分析 → 代码生成
C) 代码生成 → 语义分析 → 语法分析 → 词法分析
D) 随便什么顺序都可以

问题 3

把编译器分成前端、中端、后端有什么好处?

问题 4

什么是"黑箱视角"和"白箱视角"?


答案

  1. B
  2. A
  3. 可移植性好,支持新语言或新机器只需改动一部分;优化可以共享
  4. 黑箱视角只看输入输出,不关心内部;白箱视角了解内部结构和每个阶段的工作

4.7 下集预告

下一集,我们将聆听:历史上的编译器传奇

我们会了解:

  • 第一个编译器是怎么来的
  • Fortran 诞生的故事
  • C语言与 UNIX 的革命
  • LLVM 是如何崛起的

准备好了吗?我们下集见!


参考资料

  • 《编译原理》(龙书)第 2 章
  • GCC 官方文档
  • LLVM 架构文档
« 上一篇 编程语言家族树 下一篇 » 历史上的编译器传奇