Skip to content

语义分析与符号表

为什么语法正确还不够

一段代码即使语法上能被解析成 AST,也不代表它真的合法。

例如:

int main() {
    x = 1;
    return 0;
}

这段代码从语法角度看得过去,但如果 x 没定义,它在语义上就是错的。

这就是语义分析要接手的地方:程序长得像程序了,但还不代表它说的话真的通。

语义分析在检查什么

语义分析主要检查“结构是否合理、名字是否有效、规则是否被满足”。

在当前项目里,重点包括:

  • 顶层名字不能重复
  • 参数名不能重复
  • 局部变量名不能重复
  • 变量必须先定义再使用
  • 调用的函数必须存在
  • 参数数量必须匹配
  • 全局初始化必须是常量表达式
  • 赋值目标必须是合法左值
  • 函数必须显式出现 return

这些问题如果一直拖到后端甚至拖到最终程序运行时才暴露,排查会痛苦很多,因为那时候你离问题的原始位置已经很远了。

什么是符号表

符号表可以理解成一个“名字登记本”。

它回答的问题是:

  • 这个名字是否存在
  • 它是变量还是函数
  • 它在哪个作用域里有效
  • 如果是函数,它有几个参数

当前项目用 SymbolTable 来管理作用域栈,语义分析时不断:

  • 进入新作用域
  • 注册新名字
  • 查找已有名字
  • 离开作用域

作用域为什么必须显式管理

看下面这个例子:

int main() {
    int x = 1;
    {
        int y = x + 1;
        return y;
    }
}

这里:

  • x 在外层块中定义
  • y 只在内层块中有效

如果没有作用域栈,编译器就很容易糊涂:到底现在这个 x 能不能看见,那个 y 是不是已经出了作用域,都会变得说不清。

所以语义分析走到一个代码块时,就像进了一个新房间:

  • 进去时先记一层作用域
  • 出来时把这一层名字收掉

不然里外两个房间的变量就会混在一起。

当前项目为什么先收集顶层名字

语义分析在真正检查函数体前,会先把顶层声明扫描一遍。

这样做有两个很实际的好处:

  • 能尽早发现顶层重名
  • 能支持函数之间互相调用

不然的话,前面函数一旦调用后面才定义的函数,语义层就得一边走一边猜,代码会拧巴很多。

什么是“赋值目标必须合法”

不是所有表达式都能出现在赋值号左边。

例如:

  • x = 1 可以
  • arr[i] = 1 可以
  • *p = 1 可以
  • (a + b) = 1 不可以

当前项目明确把可赋值目标限制为三类:

  • 名字
  • 数组下标
  • 解引用表达式

这就是左值检查。名字听着有点书本味,但意思其实很朴素:赋值号左边必须真的是一个“能被写进去”的地方。

为什么全局初始化被限制为常量表达式

当前实现要求全局变量初始化必须能在编译时算出来。

例如:

  • int x = 3 + 4; 可以
  • int x = foo(); 不可以

这类限制很常见,说白了就是先不给后端添太多麻烦。全局初始化如果什么都能写,后面要处理的情况会一下子变多。

内建函数为什么要在语义层注册

当前项目有一个最小内建函数 print_int

它的作用很实际:先让示例程序能把结果打出来,而不用立刻实现完整标准库支持。

既然它要像普通函数一样被调用,那么最自然的做法就是在语义层把它注册进符号系统里。

这样后面遇到 print_int(...) 时,语义层就不用额外开后门,直接按普通函数调用去检查就行。

为什么这一层特别重要

语义层就是很多“看起来挺像回事、其实根本不合法”的代码第一次被拦下来的地方。

它的价值在于:

  • 错误暴露更早
  • 报错位置更贴近人类理解
  • 后续 IR 和后端可以假设输入已经过基本清洗

后面几层因此会轻松很多,因为它们收到的已经不是一堆乱七八糟的半成品了。

如何理解这一层在工程上的作用

如果没有语义分析,那么:

  • IR Builder 要自己额外防很多非法情况
  • 后端也要兜底更多异常输入
  • 错误往往会在更远的地方炸开

所以语义层做的事可以很土味地理解成一句话:它先把前面的脏活拦住,别让后面几层继续替它擦屁股。