编译器基础回顾:编译器前端、中间表示与后端
各位同学好,我是这门课的主讲。今天咱们聊聊编译器的基础知识。你可能会问:做存算一体架构,为什么还要学编译器?
我直接告诉你答案:没有编译器,再牛的硬件也只是块废铁。存算一体芯片的特殊性,决定了它离不开一套定制化的编译工具链。所以,咱们得先把编译器这老本行吃透。
好,开始今天的正题。
编译器前端:词法分析、语法分析、语义分析
编译器前端,说白了就是把高级语言(比如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翻译成目标机器代码。这一步包含两个核心任务:
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的交叉版本
在存算一体项目中,工具链的定制化程度很高。比如:
- 需要添加自定义指令的支持
- 需要调整链接脚本,把数据放在特定的存储区域
- 需要修改运行时库,适配硬件的特殊内存模型
这些工作,说白了就是让编译器“认识”你的硬件。没有这一步,你的存算一体芯片就是一块漂亮的石头。
本章知识体系总览
下面这张图,我把编译器的基础架构画了出来。你可以看到数据从前端流向后端的完整路径:
这张图把今天讲的内容串起来了。你仔细看,数据从左到右流动,每一步都在做“翻译”和“优化”。
好了,编译器基础就回顾到这里。下一章我们会深入存算一体架构,看看传统编译器在这里遇到了哪些挑战,以及我们怎么去解决它们。
想要更多资料,联系可可