编译器基础回顾:传统编译器架构

聊编译器之前,我先问大家一个问题:你写的一行C代码,到底是怎么变成机器指令的?

这个问题我当年刚入行时也琢磨了很久。后来带我的老工程师说了一句话,我到现在还记得——「编译器就是个翻译官,但它不是直译,是意译。」

好,咱们今天就把这个「翻译官」的工作拆开看看。

传统编译器三段式架构

传统编译器,说白了就是三个部分:前端、中端、后端。这个划分不是拍脑袋想出来的,是几十年的工程经验沉淀下来的。

核心思想:前端负责「理解你」,中端负责「优化它」,后端负责「适配机器」。

我画了一张图,帮你直观理解这三者的关系:

传统编译器三段式架构 前端 词法分析 语法分析 语义分析 生成 AST 中端 IR 优化 循环变换 内联展开 生成优化后 IR 后端 指令选择 寄存器分配 指令调度 生成机器码 AST → IR IR → 机器码 源代码 目标机器码

前端:从源码到中间表示

前端干的事,就是把你的源代码「读懂」。它分三步走:

  • 词法分析:把代码拆成一个个「单词」,也就是 token。比如 int a = 10; 会被拆成 inta=10;
  • 语法分析:把这些 token 按照语法规则组装成一棵树——抽象语法树(AST)。
  • 语义分析:检查类型对不对、变量有没有声明、作用域是否合法。

我的经验:前端这块,最容易出问题的其实是语义分析。我记得有一次调一个 OpenCL 内核,死活编译不过,最后发现是隐式类型转换搞的鬼。从那以后,我写编译器前端时,对类型系统格外小心。

中端:优化的主战场

中端是编译器里最「值钱」的部分。它不关心你用的是 C 还是 Fortran,也不关心目标芯片是 x86 还是 ARM。它只关心一件事——怎么让 IR 跑得更快。

中端优化的常见手段:

  • 死代码消除:删掉那些永远不会执行的代码。
  • 常量传播:a = 3 + 5 直接变成 a = 8
  • 循环优化:循环展开、循环交换、循环分块——这些在异构计算里特别重要。
  • 内联展开:把函数调用直接替换成函数体,减少调用开销。

关键点:中端优化的好坏,直接决定了最终代码的性能。我见过太多团队,前端后端都做得不错,但中端优化没做好,性能差了好几倍。

后端:适配目标机器

后端负责把优化后的 IR 翻译成目标机器的指令。这里要考虑的事情很杂:

  • 指令选择:同样的操作,不同芯片有不同的指令来实现。比如加法,x86 有 ADD,ARM 有 ADD,但 DSP 可能用 MAC
  • 寄存器分配:寄存器就那么几十个,怎么把成千上万的变量塞进去?塞不下的就得 spill 到内存。
  • 指令调度:调整指令顺序,让流水线不 stall。

注意:后端优化和芯片架构强相关。比如 GPU 上,寄存器分配的策略和 CPU 完全不同。GPU 有大量线程,寄存器压力更大,分配不好性能直接崩。

LLVM 与 GCC 对比

说到编译器,绕不开两个名字:GCC 和 LLVM。我两个都用过,说说我的感受。

对比维度 GCC LLVM
架构 整体式编译器,前后端耦合较紧 三段式架构,前后端通过 IR 解耦
IR 设计 GIMPLE + RTL,多层 IR LLVM IR,单一统一 IR
优化能力 传统优化强,成熟稳定 现代优化丰富,Pass 机制灵活
异构支持 需要额外插件或分支 原生支持,如 NVPTX、AMDGPU
调试体验 错误信息较晦涩 错误信息友好,工具链完善
许可证 GPL Apache 2.0

我个人习惯用 LLVM 做异构计算编译器开发。为什么?

第一,LLVM 的 IR 设计得特别干净。你写一个 Pass,只需要关心 IR 本身,不用管前端是什么语言、后端是什么芯片。这在异构计算场景下太重要了——你想想看,你的编译器可能要同时支持 CPU、GPU、FPGA,如果每个后端都要重新写一套优化,那不得累死?

第二,LLVM 的 Pass 机制非常灵活。你可以写一个自定义的优化 Pass,插到优化流水线里。我在做 GPU 编译器时,就写过几个针对 GPU 的 Pass,专门处理线程束级优化和共享内存分配。

避坑指南:我曾经在 GCC 上做过一个异构计算项目,结果发现 GCC 对 GPU 后端的支持非常有限。最后不得不自己写了一个 GCC 分支,维护成本极高。后来切到 LLVM,三个月就搞定了。所以,如果你要做异构计算编译器,我建议优先考虑 LLVM。

中间表示(IR)的作用

IR 是编译器的「通用语言」。前端把源代码翻译成 IR,中端在 IR 上做优化,后端把 IR 翻译成机器码。

IR 为什么这么重要?说白了,它解决了编译器最大的痛点——语言和架构的多样性

你想想看,世界上有几百种编程语言,几千种芯片架构。如果没有 IR,每支持一种新语言,就要为每种芯片重写一遍编译器。有了 IR,你只需要写一个前端把语言翻译成 IR,再写一个后端把 IR 翻译成机器码。组合起来,就是 N × M 的问题变成了 N + M。

LLVM IR 有几个特点:

  • 静态单赋值形式(SSA):每个变量只赋值一次。这听起来有点绕,但好处是数据流分析变得特别简单。
  • 类型系统完善:IR 里每个值都有类型,方便做类型检查和优化。
  • 人类可读:LLVM IR 可以用文本形式展示,方便调试。

举个例子,下面这段 C 代码:

int add(int a, int b) {
    return a + b;
}

对应的 LLVM IR 是这样的:

define i32 @add(i32 %a, i32 %b) {
  %result = add i32 %a, %b
  ret i32 %result
}

你看,i32 表示 32 位整数,%a%b 是虚拟寄存器,add 指令直接对应加法操作。清晰明了。

核心观点:IR 是编译器的「中间人」。它让前端和后端可以独立演进,也让跨语言、跨平台的优化成为可能。在异构计算里,IR 更是连接 CPU、GPU、FPGA 等不同计算单元的桥梁。

嗯,到这里,编译器基础就回顾得差不多了。下一节我们会深入 LLVM IR 的细节,看看它在异构计算场景下是怎么发挥作用的。


专注资料整理