编译器前端:从代码到中间表示的“翻译官”

大家好,我是你们的老朋友。今天咱们聊聊编译器前端。

很多人觉得编译器前端就是“词法分析+语法分析”,背背概念就完了。但说实话,我在AI芯片领域摸爬滚打这些年,发现前端才是整个编译器的“门面”。你想想看,如果前端解析错了,后端再怎么优化也是白搭。今天我就把这块掰开揉碎了讲清楚。

词法分析与语法分析:编译器的“眼睛”和“大脑”

先说说词法分析。说白了,就是把你的代码拆成一个个“单词”——我们叫它Token。比如 int a = 10; 这句话,词法分析会把它拆成 inta=10; 这五个Token。

我个人习惯用Flex或者手写状态机来做词法分析。在AI芯片的场景下,我们经常要处理一些特殊的算子语法,比如 @tensor 或者 #pragma 指令。这时候,标准的词法规则就不够用了,得自己扩展。

小技巧: 写词法分析时,记得把注释和空白符过滤掉。我曾经见过一个项目,因为没处理好注释里的特殊字符,导致整个编译流程卡死。嗯,这种坑踩过一次就记住了。

语法分析呢,就是把这些Token按照语法规则组装起来。我常用的是递归下降解析器,因为它直观、好调试。举个例子,解析一个加法表达式:

// 伪代码示例
Expr parseExpr() {
    Expr left = parseTerm();
    while (peek() == '+' || peek() == '-') {
        char op = consume();
        Expr right = parseTerm();
        left = new BinaryExpr(left, op, right);
    }
    return left;
}

这里要注意优先级的问题。比如 a + b * c,你得先解析乘法,再解析加法。我在项目中遇到过,有人把优先级搞反了,结果 2 + 3 * 4 算出来是20,而不是14。这种bug特别隐蔽,因为代码看起来没错,但结果就是不对。

抽象语法树(AST):代码的“骨架”

语法分析的结果,就是一棵抽象语法树。AST把代码的结构信息保留下来,但去掉了那些无关紧要的细节,比如分号、括号之类的。

举个例子,if (x > 0) { y = 1; } 这行代码,AST大概长这样:

IfStmt
├── Condition: BinaryExpr (x > 0)
└── Then: AssignStmt (y = 1)

我习惯把AST节点设计成基类加派生类的形式。基类有个 accept() 方法,方便做访问者模式。这样后续做类型检查、代码生成都很方便。

注意: AST的节点不要设计得太复杂。我曾经见过一个项目,一个节点里塞了十几个字段,结果维护起来简直是噩梦。保持每个节点职责单一,这是我从血泪教训中总结出来的。

符号表管理:编译器的“通讯录”

符号表,说白了就是记录变量、函数、类型这些信息的“大本子”。你在代码里写了个 int a,符号表里就得记下:a 是个int类型,作用域在哪,地址是什么。

在AI芯片的编译器里,符号表尤其重要。因为我们经常要处理张量(tensor)的维度信息、数据布局(layout)这些复杂属性。我习惯用哈希表来实现符号表,查找快,插入也快。

作用域管理是个难点。比如:

{
    int x = 1;
    {
        int x = 2;  // 内层x覆盖外层x
    }
    // 这里x又变回1了
}

我常用的做法是“作用域栈”。进入一个新作用域时,压入一个新表;退出时,弹出这个表。这样查找变量时,从栈顶往下找,就能正确处理覆盖关系。

我曾经犯过一个错误:忘记在退出作用域时清理符号表,结果导致内存泄漏。嗯,这种细节问题,往往要等到跑大模型的时候才会暴露出来。

类型系统与类型推导:编译器的“质检员”

类型系统负责检查你的代码里类型是否匹配。比如你不能把字符串赋值给整数变量。在AI芯片领域,类型系统还要处理张量的形状(shape)和数据类型(dtype)。

类型推导就更有意思了。比如在Python里,你写 a = b + c,编译器得自己推断出 a 的类型。我常用的算法是Hindley-Milner类型推导,它基于约束求解,非常优雅。

举个例子:

def add(x, y):
    return x + y

编译器会推导出:xy 必须是支持加法操作的类型,比如int或float。如果传入一个字符串和一个整数,类型检查就会报错。

核心要点: 类型推导不是万能的。在AI芯片编译器中,我们经常需要用户显式标注张量的形状,因为编译器很难从上下文中推断出所有维度信息。比如 reshape 操作,你不告诉它目标形状,它根本不知道你要干嘛。

中间表示(IR)设计原则:编译器的“通用语言”

IR是编译器前端的输出,也是后端的输入。一个好的IR,能让整个编译器架构清晰、扩展性强。

我总结了几条IR设计原则:

  • 简洁性: IR的指令集不要太多。我见过一个IR有200多种指令,结果后端优化器根本写不完。一般20-50种指令就够用了。
  • SSA形式: 静态单赋值形式,每个变量只赋值一次。这能大大简化数据流分析。我在项目中用过SSA,确实让优化器好写了很多。
  • 类型信息: IR里要保留类型信息,尤其是张量的形状和数据类型。这样后端在做内存分配时,才能知道要分配多大空间。
  • 可扩展性: 设计IR时,要预留扩展点。比如加一个 custom_op 字段,方便以后支持新的算子。

举个例子,一个简单的加法IR可能长这样:

%1 = load @a
%2 = load @b
%3 = add %1, %2
store %3, @c

这里 %1%2%3 都是SSA变量,每个只赋值一次。这样后续做常量传播、死代码删除都非常方便。

我曾经参与过一个项目,IR设计得过于复杂,结果前端和后端开发人员天天吵架。后来我们重新设计了一个精简的IR,大家才消停下来。所以说,IR设计一定要克制,别想着一次把所有功能都塞进去。

知识体系总览

下面这张图,是我自己画的编译器前端知识体系。你可以把它当作一个“地图”,随时回来看看。

编译器前端知识体系 编译器前端 词法分析 语法分析 AST构建 符号表管理 Token生成 正则表达式匹配 状态机实现 错误恢复 语法规则解析 递归下降解析 优先级处理 错误报告 树形结构表示 节点设计模式 访问者模式 树遍历算法 作用域管理 哈希表实现 嵌套作用域 名称解析 输出:中间表示(IR)

这张图把编译器前端的几个核心模块串起来了。从词法分析开始,一步步走到IR生成。每个模块都有自己的职责,但又紧密配合。

好了,今天就聊到这儿。编译器前端这块,说难不难,说简单也不简单。关键是要动手写,光看理论是学不会的。我建议你找个开源项目,比如LLVM或者MLIR,看看人家是怎么实现的。相信我,读一遍源码,比看十遍书都有用。


公众号:蓝海资料掘金营,微信deep3321