编译器基础回顾:编译器前端、中间表示与后端

各位同学好,我是这门课的主讲。今天咱们聊聊编译器的基础知识。你可能会问:做存算一体架构,为什么还要学编译器?

我直接告诉你答案:没有编译器,再牛的硬件也只是块废铁。存算一体芯片的特殊性,决定了它离不开一套定制化的编译工具链。所以,咱们得先把编译器这老本行吃透。

好,开始今天的正题。

编译器前端:词法分析、语法分析、语义分析

编译器前端,说白了就是把高级语言(比如C、Python)翻译成一种中间表示。它分三步走:

1. 词法分析

这一步干的事很简单:把源代码拆成一个个“单词”,也就是Token。

比如你写了一句:

int a = 10 + 20;

词法分析器会把它拆成:

  • int → 关键字
  • a → 标识符
  • = → 赋值运算符
  • 10 → 数字常量
  • + → 加法运算符
  • 20 → 数字常量
  • ; → 分号

我刚开始做编译器时,觉得这步太简单了。后来在存算一体项目中,发现指令集里有些特殊操作码,词法分析器根本不认识。嗯,那会儿我花了一周才把词法规则改好。

小提示: 词法分析器通常用正则表达式或自动机实现。如果你要自定义指令集,记得先扩展词法规则。

2. 语法分析

词法分析完了,接下来要检查这些Token组合起来是否符合语法规则。这一步会生成一棵抽象语法树(AST)。

举个例子,上面那句代码的AST大概长这样:

Assignment
├── Variable: a
├── Operator: =
└── BinaryOp: +
    ├── Constant: 10
    └── Constant: 20

语法分析器会检查:赋值语句左边是不是变量?运算符两边是不是表达式?如果写成 int a = + 10 20;,语法分析器就会报错。

我个人习惯在语法分析阶段就做一些简单的错误恢复,这样用户不会因为一个分号没写就收到几十条错误信息。

3. 语义分析

语法对了,但意思对不对?这就是语义分析要干的事。

它会检查:

  • 变量有没有声明?
  • 类型是否匹配?
  • 函数调用参数个数对不对?

比如你写了 a = "hello" + 10;,语法上没问题,但语义上字符串和整数不能直接相加,编译器就会报类型错误。

注意: 在存算一体架构中,语义分析阶段需要额外检查数据精度和计算模式。我曾经遇到过一个问题:一个矩阵乘法操作,输入数据位宽不匹配,结果在硬件上跑出了错误结果。这种问题在语义分析阶段就应该被捕获。

中间表示(IR)

前端处理完,生成的是高级IR。后端处理完,生成的是低级IR。中间表示是编译器的核心枢纽。

为什么需要IR?你想想看,如果编译器直接从前端跳到后端,那每支持一种新硬件,就得重写整个编译器。有了IR,前端只负责生成IR,后端只负责把IR翻译成目标代码,前后端解耦,复用性大大提升。

常见的IR有:

  • LLVM IR:三地址码形式,支持SSA(静态单赋值)
  • GIMPLE:GCC的中间表示
  • Tree IR:一些专用编译器的树形表示

我个人比较偏爱LLVM IR,因为它设计得很干净,而且优化pass非常丰富。在存算一体项目中,我经常在LLVM IR层面插入自定义的优化pass,比如把连续的乘加操作合并成一个MAC指令。

下面是一个简单的LLVM IR示例:

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

你看,每条指令都清晰明了:操作码、类型、操作数。这就是IR的魅力。

核心观点: IR是编译器的“通用语言”。你设计的存算一体指令集,最终都要映射到IR上。所以,IR的设计质量直接决定了编译器的灵活性和优化空间。

编译器后端:代码生成与优化

后端负责把IR翻译成目标机器代码。这一步包含两个核心任务:

1. 指令选择

把IR指令映射到目标机器的指令。比如IR里的加法操作,在x86上可能映射成ADD指令,在ARM上映射成ADD指令,在存算一体芯片上可能映射成MAC指令。

这里有个坑:指令选择不是一一对应的。有时候一条IR指令可以拆成多条机器指令,有时候多条IR指令可以合并成一条机器指令。我建议你在设计指令选择器时,多用模式匹配,把常见的指令组合提前识别出来。

2. 寄存器分配

IR里用的是虚拟寄存器,数量不限。但真实硬件上的寄存器是有限的。寄存器分配算法要决定:哪些变量放在寄存器里,哪些变量溢出到内存里。

常用的算法有:

  • 图着色算法:把寄存器分配问题转化为图着色问题
  • 线性扫描算法:效率高,适合即时编译

我曾经在存算一体芯片上做寄存器分配时,发现硬件只有16个通用寄存器,但IR里用了50多个虚拟寄存器。那会儿我不得不优化溢出策略,把频繁使用的变量优先分配寄存器,性能提升了30%。

3. 指令调度

指令调度是为了提高指令级并行度。比如:

// 原始顺序
a = b + c
d = e + f
g = a + d

// 重排后
a = b + c
d = e + f
// 这里a和d的计算没有依赖,可以并行
g = a + d

在存算一体架构中,指令调度尤其重要。因为存算一体芯片通常有多个计算单元,如果指令调度得好,可以同时利用多个单元,实现真正的并行计算。

交叉编译与工具链

交叉编译,就是在一种平台上编译出另一种平台能运行的代码。比如你在x86的电脑上编译出ARM芯片上跑的程序。

为什么需要交叉编译?因为很多嵌入式芯片、存算一体芯片的资源有限,跑不了完整的编译器。所以我们在开发机上编译好,再下载到目标芯片上运行。

一个完整的交叉编译工具链包括:

  • 交叉编译器:如 arm-linux-gnueabi-gcc
  • 交叉链接器:如 arm-linux-gnueabi-ld
  • 交叉汇编器:如 arm-linux-gnueabi-as
  • 运行时库:如 glibc 或 newlib
  • 调试工具:如 gdb 的交叉版本
实战建议: 搭建交叉编译工具链时,我建议你用crosstool-NG或者Buildroot这类工具,它们能自动处理很多依赖问题。我自己第一次手动搭建时,折腾了整整三天才搞定,后来发现用工具十分钟就配好了。

在存算一体项目中,工具链的定制化程度很高。比如:

  • 需要添加自定义指令的支持
  • 需要调整链接脚本,把数据放在特定的存储区域
  • 需要修改运行时库,适配硬件的特殊内存模型

这些工作,说白了就是让编译器“认识”你的硬件。没有这一步,你的存算一体芯片就是一块漂亮的石头。

本章知识体系总览

下面这张图,我把编译器的基础架构画了出来。你可以看到数据从前端流向后端的完整路径:

编译器基础架构总览 编译器前端 词法分析 源代码 → Token序列 语法分析 Token → 抽象语法树 语义分析 类型检查、作用域 中间表示 (IR) LLVM IR 三地址码 / SSA 优化Pass 常量折叠、死代码消除 自定义Pass 编译器后端 指令选择 IR → 目标指令 寄存器分配 虚拟寄存器 → 物理寄存器 指令调度 重排指令,提高并行度 代码生成 输出目标机器码 数据流向:源代码 → 词法分析 → 语法分析 → 语义分析 → IR → 指令选择 → 寄存器分配 → 指令调度 → 目标代码 交叉编译:在开发机(x86)上编译出目标芯片(ARM/RISC-V/存算一体)可执行的代码

这张图把今天讲的内容串起来了。你仔细看,数据从左到右流动,每一步都在做“翻译”和“优化”。

好了,编译器基础就回顾到这里。下一章我们会深入存算一体架构,看看传统编译器在这里遇到了哪些挑战,以及我们怎么去解决它们。


想要更多资料,联系可可