5. 语义分析:类型检查、作用域解析、符号表的建立

好,咱们继续往下走。前面几章我们把词法分析和语法分析讲透了,说白了就是让编译器能“读懂”你的代码——知道哪些是关键字,哪些是变量,以及代码的结构长什么样。但光知道这些还不够,编译器还得明白你写的代码到底有没有“意义”。这就是语义分析要干的事。

我个人习惯把语义分析比作“代码的体检”。语法分析只检查你有没有语法错误,比如括号没配对、分号漏了。但语义分析检查的是更深层的东西:你声明的变量类型对不对?你调用的函数有没有定义?你访问的变量在当前作用域里存不存在?

嗯,这一章我们就来聊聊语义分析的核心三件事:类型检查作用域解析符号表的建立。这三件事其实是交织在一起的,你中有我,我中有你。

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

先说说符号表。你可以把它想象成编译器手里的一本通讯录。每当编译器遇到一个变量、函数、结构体、枚举等标识符时,它就会翻开这本通讯录,查一查这个标识符有没有登记过,登记的信息是什么。

符号表里存什么?最基本的信息包括:

  • 标识符的名字(比如 countmain
  • 类型信息(int、float、struct、函数指针等)
  • 作用域层级(全局、局部、块作用域)
  • 存储类别(static、extern、auto)
  • 地址或偏移量(在栈帧或数据段中的位置)

我在项目中遇到过一个问题:一个大型嵌入式项目,全局变量命名冲突,结果两个模块都定义了一个叫 status 的全局变量,链接阶段报错。这就是符号表管理不当的后果。所以,符号表的设计直接影响编译器的健壮性。

核心要点:符号表是语义分析的基础设施。没有符号表,类型检查和作用域解析就是空中楼阁。

5.2 作用域解析:你的变量在哪儿生效?

作用域,说白了就是变量“能见度”的范围。C语言的作用域规则其实挺直观的:

  • 全局作用域:在所有函数之外定义的变量,整个文件都能访问。
  • 函数作用域:函数内部定义的变量,只在函数内有效。
  • 块作用域:花括号 {} 内定义的变量,只在块内有效。

你想想看,如果编译器不搞作用域解析,那代码里到处都是名字冲突。比如你在一个函数里定义了 int i,在另一个函数里也定义了 int i,编译器怎么区分它们?

编译器是怎么做的呢?它维护一个作用域栈。每进入一个作用域(比如遇到 {),就压入一个新的符号表;每离开一个作用域(遇到 }),就弹出这个符号表。查找变量时,从栈顶往下找,找到为止。

我曾经调试过一个bug:一个同事在 for 循环里定义了一个变量,然后在循环外面又用同样的名字定义了一个变量,结果内层的变量把外层的给“遮蔽”了。代码看起来没问题,但行为完全不对。嗯,这就是作用域解析没搞清楚的典型例子。

小技巧:写代码时尽量避免变量遮蔽。虽然编译器能处理,但代码可读性会变差。我一般会在内层作用域用不同的变量名,或者干脆把内层逻辑抽成单独的函数。

5.3 类型检查:编译器在“对账”

类型检查,就是编译器在检查你的操作是否“合法”。比如你不能把一个 int* 赋值给一个 float 变量,也不能把一个结构体和一个整数做加法。

C语言的类型检查是静态类型检查,也就是说在编译阶段就做完了。运行时不会再有类型检查(不像Python那种动态语言)。

类型检查主要做这几件事:

  1. 检查赋值兼容性:左值和右值的类型是否匹配?
  2. 检查函数调用参数:实参类型和形参类型是否一致?
  3. 检查表达式操作数:比如 + 两边是不是都是数值类型?
  4. 检查指针运算:指针加减整数是合法的,但指针加减指针就不行。

举个例子,下面这段代码在语义分析阶段就会报错:

int a = 10;
char *p = "hello";
a = p;  // 类型不兼容:int 和 char*

编译器看到 a = p 时,会查符号表:a 的类型是 intp 的类型是 char*。然后它发现这两个类型不兼容,于是报错。

我记得有一次,一个同事写了一段代码:

int *ptr;
int arr[10];
ptr = arr;  // 合法:数组名退化为指针
*ptr = 3.14;  // 警告:double 赋值给 int

第二行其实会有一个警告,因为 3.14double 类型,而 *ptrint。虽然C语言允许隐式类型转换,但精度会丢失。编译器会给出警告,但不会报错。这就是类型检查的“宽容”之处——它只检查明显的错误,对于可能丢失精度的转换,它只给警告。

注意:不要忽视编译器的警告!很多bug都是因为类型不匹配导致的。我建议把警告当作错误来处理,在编译选项里加上 -Wall -Werror

5.4 语义分析的整体流程

好了,我们把三个核心概念都过了一遍。现在来看看它们是怎么协同工作的。我画了一张图,帮你理清思路:

语义分析核心流程 语法分析树(AST) 第一步:遍历AST,建立符号表 记录所有标识符的名字、类型、作用域 第二步:作用域解析 维护作用域栈,处理变量遮蔽 第三步:类型检查 检查赋值、函数调用、表达式类型兼容性 关键数据结构 • 符号表(哈希表) • 作用域栈 • 类型系统(类型树) • 错误列表 • 警告列表 (每个作用域一个符号表)

从这张图你可以看到,语义分析不是一步到位的。它先建立符号表,然后做作用域解析,最后做类型检查。但实际实现中,这三步往往是交织进行的——比如在建立符号表的同时,就会做作用域解析;在类型检查时,又会回头查符号表。

5.5 一个完整的例子

咱们来看一个具体的例子,把整个过程串起来:

int global_var = 42;

void func(int param) {
    int local_var = 10;
    {
        int block_var = 20;
        local_var = block_var + param;
    }
    // block_var 在这里不可见
}

编译器处理这段代码时,会经历以下步骤:

  1. 遇到 int global_var = 42;:在全局符号表中添加 global_var,类型为 int,作用域为全局。
  2. 遇到 void func(int param):在全局符号表中添加 func,类型为 void (int)。然后进入函数作用域,创建新的符号表。
  3. 遇到 int local_var = 10;:在当前函数符号表中添加 local_var,类型为 int
  4. 遇到 {:进入块作用域,压入新的符号表。
  5. 遇到 int block_var = 20;:在块符号表中添加 block_var
  6. 遇到 local_var = block_var + param;:先查符号表找 local_var(在函数符号表中找到)、block_var(在块符号表中找到)、param(在函数符号表中找到)。然后检查类型:block_varintparamint,加法合法,结果也是 int。赋值给 local_var(也是 int),类型匹配。
  7. 遇到 }:弹出块符号表,block_var 消失。
  8. 遇到 }:弹出函数符号表,local_varparam 消失。

你看,整个过程就像在玩一个“入栈出栈”的游戏。每个作用域对应一个符号表,进入时创建,离开时销毁。

个人经验:如果你自己写一个玩具编译器,我建议先把符号表和作用域栈实现好。这两个东西是语义分析的骨架。类型检查可以慢慢加,但符号表一旦设计错了,后面改起来很痛苦。

5.6 常见陷阱与避坑指南

最后,我分享几个我在实际项目中踩过的坑:

  • 陷阱一:忘记处理函数原型。有些编译器在遇到函数声明(原型)时,只记录函数名和参数类型,不检查函数体。但如果你在调用时参数类型不匹配,编译器可能只给警告,不报错。这会导致运行时崩溃。我曾经因为这个bug查了整整两天。
  • 陷阱二:结构体类型检查不严格。C语言允许你把一个结构体赋值给另一个结构体,只要它们类型相同。但如果你用 typedef 定义了两个名字不同但结构相同的结构体,编译器会认为它们不兼容。嗯,这是C语言类型系统的“强”之处,也是“坑”之处。
  • 陷阱三:隐式类型转换的副作用。比如 int a = 3.14; 这种代码,编译器只给警告,但实际运行时 a 的值是 3,小数部分被截断了。如果你不注意,可能会在计算中引入精度误差。

避坑指南:我曾经在一个嵌入式项目中,因为一个 intunsigned int 的比较,导致了一个极其隐蔽的bug。两个值都是正数,但 int 被隐式转换为 unsigned int 后,负数变成了一个很大的正数。从那以后,我写代码时都会显式地做类型转换,绝不依赖隐式转换。

好了,这一章的内容就到这里。语义分析是编译器中“承上启下”的一环——它把语法分析的结果转化为带有类型和作用域信息的中间表示,为后面的代码生成打下基础。理解好符号表、作用域和类型检查,你就掌握了编译器设计的核心。


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