Skip to content

语法分析与 AST

语法分析在补什么

词法分析结束后,编译器拿到的是一串 Token,但这还不是“程序结构”。

例如同样是一串 Token,编译器还需要知道:

  • 哪些 Token 组成一个函数定义
  • 哪些 Token 组成一个 if
  • 运算符优先级怎么结合
  • 一个表达式的左右边界在哪里

这就是语法分析的任务。

先看一个最小例子

还是用这段代码:

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

Lexer 给 Parser 的输入更像是一串这样的 Token:

KW_INT IDENTIFIER(add) LPAREN KW_INT IDENTIFIER(a) COMMA ...

Parser 的任务不是再扫描字符,而是把这串 Token 组织成层次结构。

最后在概念上会得到这样的树:

Program
  FunctionDecl add
    Parameter a
    Parameter b
    Block
      ReturnStmt
        BinaryExpr(+)
          Name(a)
          Name(b)

这就是 AST 最直观的意义:原来只是排成一行的 Token,现在终于长成了一棵有上下级关系的树。

什么是 AST

AST 是抽象语法树,可以把它理解成“程序的结构化骨架”。

例如,一个函数在 AST 里不再只是文本片段,而会有更明确的字段:

  • 返回类型
  • 函数名
  • 参数列表
  • 函数体

表达式也一样:

  • 二元表达式会有运算符、左操作数、右操作数
  • if 语句会有条件、then 分支、else 分支

说得再直白一点,AST 就是把“排成一条龙的东西”掰成“谁包着谁、谁挂着谁”的结构。

如果没有这棵树,后面的语义分析和 IRBuilder 都要自己重新判断:

  • 这里是不是函数
  • return 的值是什么
  • a + b 的左右两边分别是谁

不然的话,后面的每一层都得重新猜一次结构,大家轮流做同一份苦工。

这个项目的 Parser 是递归下降

当前实现使用手写递归下降 Parser。

它的好处不是“显得高级”,而是特别适合这种第一代项目:

  • 语法规则和代码函数对应关系直接
  • 错误定位清晰
  • 新增语法时改动容易控制

你在代码里能看到很明显的分工:

  • parse_program
  • _parse_top_level_decl
  • _parse_statement
  • _parse_if
  • _parse_while
  • _parse_for
  • 一整套表达式解析函数

这也是为什么递归下降很适合拿来读源码。你不用先补一堆生成器背景,光看函数名,基本就知道它在啃哪块语法。

为什么表达式解析要分层

表达式不是简单从左到右扫过去就行,因为有优先级问题。

例如:

a + b * c

这里应该先算 b * c,再算 a + (...)

所以 Parser 会按优先级拆成几层,例如:

  • 赋值
  • 逻辑或
  • 逻辑与
  • 相等比较
  • 大小比较
  • 加减
  • 乘除模
  • 一元表达式
  • 后缀表达式
  • 基础表达式

这就是递归下降里很常见的“按优先级分层解析”。

你可以把这个过程想成一层层往下问:

  • 这是不是赋值?
  • 如果不是,是不是逻辑或?
  • 如果不是,是不是逻辑与?
  • ...
  • 最后落到字面量、名字或括号表达式

所以“递归下降”别被名字吓到,它没那么玄,很多时候就是一层层往下问:“你到底是哪一种表达式?”

这个项目当前能解析哪些高层结构

从实现上看,当前支持的核心语法包括:

  • 顶层全局变量声明
  • 函数定义
  • 代码块
  • if / else
  • while
  • for
  • return
  • 局部变量声明
  • 表达式语句

表达式方面支持:

  • 字面量
  • 变量名
  • 一元运算
  • 二元运算
  • 赋值
  • 函数调用
  • 数组下标

从源码角度看,这些能力最终都会在 parser.py 里落成不同 AST 节点,而不是停留在字符串层面。

为什么“只按名字调用函数”是个有意限制

当前实现里,函数调用要求被调用者是名字,而不是任意表达式。

这意味着:

  • 支持 foo(1, 2)
  • 不支持函数指针形式的更复杂调用

这不是缺陷记录漏了,而是当前语法和语义边界故意压在更可控的范围内。

这类边界很重要,因为它说明 Parser 现在不是要一口吞掉完整 C,而是先把自己管得住的这块地盘整理明白。

声明器为什么单独处理

当前 Parser 会把类型说明和声明器拆开看。

例如这些:

  • int x
  • int *p
  • char s[8]

虽然它们基础类型只有 intchar,但声明器部分还要表达:

  • 指针层级
  • 数组大小
  • 最终变量名

所以解析类型时,不只是认关键字,还要继续解析声明器形态。

这也是 C 语法对初学者最容易造成困扰的地方之一:类型关键字只是开始,声明器才决定最终这个名字到底是普通变量、指针还是数组。

Parser 的错误处理意味着什么

当 Parser 报错时,它给出的往往不是“程序坏了”这种模糊信息,而是:

  • 期望有 ;
  • 期望有 )
  • 期望类型说明符
  • 当前 Token 不符合该语法位置

这说明 Parser 是按明确语法规则推进的,不是在做模糊猜测。

换句话说,Parser 不是来猜你心思的,它是来严格认结构的。

AST 为什么是后面所有阶段的核心输入

从这一步开始,后面的阶段基本都不再直接依赖原始 Token 序列,而是依赖 AST。

原因很简单:

  • 语义分析更适合在结构化树上做
  • IR Builder 更适合从语句和表达式节点下降
  • AST 也可以直接作为高级后端路径的输入

所以 AST 是当前项目最重要的中间结构之一。

如果你真的想读懂这个项目,AST 这关绕不过去。因为从这里开始,后面很多页面说的都不是文本了,而是在说“这棵树接下来怎么继续被处理”。

如何观察这一层结果

可以直接运行:

python3 -m c_core_compiler input.c --emit-ast

如果词法没问题但语法结构不对,这个阶段通常最能暴露问题。

看 AST 的时候,重点建议观察:

  • 顶层声明有没有被正确区分
  • 表达式结合顺序对不对
  • 控制流语句的层次是否正确
  • 局部变量声明和普通表达式语句有没有被混淆