做编译原理课程设计时选了C-Minus这个小语言,配合Flex和Bison做语义分析,前前后后踩了不少坑,也把整个流程摸透了。C-Minus是C语言的一个简化子集,保留了变量声明、函数、表达式、选择与循环结构,去掉了指针运算、结构体等进阶语法,非常适合拿来理解编译前端。本文把从词法、语法到语义分析的全过程拆开讲,凡是涉及符号表、类型检查、作用域管理的地方都给出可直接落地的思路和代码片段,目标是让正在做同类项目的人少走弯路。
1. 项目整体设计与思路拆解
1.1 语义分析在整个编译流程里的定位
很多初次接触编译原理的人会把注意力全部放在词法分析和语法分析上,觉得只要Flex能出token、Bison能生成语法树,编译前端就算做完了。实际上词法分析回答的是“这句话由哪些单词组成”,语法分析回答的是“这些单词按照什么结构组合”,而语义分析要回答的是“这个结构本身是否合法、是否有意义”。
举个例子,int a = b + 1;这句代码在词法阶段可以正常切出int、a、=、b、+、1、;,在语法阶段也能按声明语句的规则归约成功。但如果b根本没有被声明过,或者b是一个数组名而不是普通变量,那么这句话在语义上就是错的。语义分析的价值就在这里:它把语法上合法但语义上非法的程序识别出来,阻止它们进入后续的中间代码生成阶段。
在经典的三段式编译前端中,语义分析通常紧接语法分析,建立在语法分析生成的抽象语法树之上。用Bison做语义分析有两种常见路线:一种是在归约动作里直接做检查,加上符号表即可,这种方案对C-Minus这种规模的语言已经绰绰有余;另一种是先让Bison生成完整的抽象语法树,再做一次独立的语义遍历。C-Minus规模不大,我选择的是第一种,因为实现直接、调试方便,而且课程设计一般也要求看到阶段性的输出。
1.2 C-Minus语法子集与语义范围确认
在动手写代码之前,先把C-Minus的语法子集定清楚。不同教材给出的定义略有差异,但核心内容基本一致:
- 数据类型只保留
int和void,支持一维数组 - 函数可以返回
int或void,可以有参数 - 语句类型包括声明语句、赋值语句、
if语句、while语句、return语句、复合语句 - 表达式支持算术运算、关系运算、逻辑运算、函数调用、数组下标访问
这个子集虽然小,但语义检查点一个都不少。需要重点做的检查包括:
- 变量是否先声明后使用
- 函数是否先声明或定义后再调用
- 函数调用参数个数和类型是否匹配
- 赋值两侧类型是否兼容
- 数组下标是否为整数
return表达式类型与函数返回类型是否一致- 条件表达式是否为整数
- 变量是否在作用域内被重复声明
确定范围之后,再来看用Flex和Bison怎么组织代码。
1.3 为什么选择Flex + Bison而不是手写递归下降
手写递归下降解析器当然可行,尤其在C-Minus这种小语言上,写起来也不算特别复杂。但我在实际项目中仍然倾向Flex + Bison,原因有几个。
第一,词法部分用Flex描述比手写状态机直观得多。C-Minus的token不过二十多种,但包含数字、标识符、关键字、运算符、注释,手写还得处理最长匹配、关键字优先级等问题,Flex的正则规则天然支持最长匹配,"if"这样的关键字规则放在{ID}规则前面,就能自动正确识别。
第二,Bison生成的LALR(1)分析器解决了运算符优先级和结合性问题。C-Minus的表达式优先级从低到高依次是逻辑或、逻辑与、相等比较、关系比较、加减、乘除、一元取反、括号与下标。在Bison里声明%left和%right之后,优先级冲突自动消解,不需要在语法树构建时手工处理。
第三,Bison允许在规则中直接嵌入语义动作,这和我们做语义分析的目标天然匹配。归约发生时,动作代码可以访问左部和右部所有符号的属性值,检查类型、查符号表、记录下来,一气呵成。
当然,手写递归下降也有优势:出错信息更灵活、代码完全可控。但就课程设计或中小型项目的语义分析而言,Flex + Bison的开发效率明显更高,生成的解析器也足够健壮。
1.4 工程目录结构与构建流程
项目目录我采用了这样的组织方式:
cminsem/ ├── Makefile ├── scanner.l # Flex词法规则 ├── parser.y # Bison语法及语义动作 ├── symtab.h # 符号表接口 ├── symtab.c # 符号表实现 ├── types.h # 类型定义与语义辅助函数 ├── util.h / util.c # 错误报告、树打印工具Makefile里关键的一行是:
parser.tab.c parser.tab.h: parser.y bison -d -t parser.y scanner.c: scanner.l flex scanner.l这里要记住-d参数必须加,它会生成parser.tab.h,Flex的#include要引用这个头文件才能拿到token编号。-t参数用于打开调试跟踪,对于排查冲突很有用。实际使用中我还会在编译时加-Wall,Bison生成的代码警告非常多,不用-Wall反而会错过一些真正关键的问题。
2. 符号表设计与作用域管理
2.1 符号表数据结构选型
符号表是语义分析的核心数据结构。用最简单的线性列表也能工作,但查找效率是O(n),语句一多性能就下降。用哈希表是常规选择,C-Minus的符号量级不大,哈希表既能保证查找速度,实现成本也不高。
我定义的结构体大致如下:
typedef enum { VOID_TYPE, INT_TYPE } TypeKind; typedef struct Type { TypeKind kind; struct Type *elem; // 如果是数组,elem指向元素类型 int size; // 如果是数组,记录数组长度 } Type; typedef struct Symbol { char *name; Type *type; int kind; // 0表示变量,1表示函数 int paramNum; // 如果是函数,参数个数 struct Symbol *next; } Symbol;每个作用域维护一个哈希表,表中每个桶挂一个链表,用链地址法处理冲突。Type结构里只用VOID_TYPE和INT_TYPE两个基础类型,数组通过指向元素类型的elem指针表达,这种设计在C-Minus的小规模上绰绰有余,也方便后续做类型一致性比较。
哈希函数不用太复杂,简单的字符串求和取模即可:
int hash(const char *name) { int sum = 0; while (*name) sum += *name++; return sum % TABLE_SIZE; }2.2 作用域压栈与弹出时机
C-Minus的作用域规则与C语言基本一致:最外层是全局作用域,每个复合语句或函数体是一个新的局部作用域。实现上用一个作用域栈:
typedef struct Scope { Symbol **table; struct Scope *parent; } Scope; Scope *currentScope;进入作用域时pushScope()创建一个新的哈希表,退出时popScope()销毁。查找符号时从当前作用域开始,逐层向上查找,直到全局作用域找到为止。
这里最关键的判断是每次pushScope()和popScope()的配对。C-Minus的语法定义中,复合语句是一对花括号包裹的语句序列,所以在Compound Stmt规则的动作里,看到左花括号时压入新作用域,右花括号时弹出。
compound_stmt : '{' local_declarations statement_list '}' { popScope(); } | '{' statement_list '}' { popScope(); } ;对应地,压栈操作放在前面的动作里。这里容易踩一个坑:Bison的动作默认在某些位置才执行,如果不加%destructor或中间动作,符号表的内存管理要格外小心。我的做法是给local_declarations这个非终结符的规则加一个空动作来压栈:
local_declarations : /* empty */ { pushScope(); } | local_declarations var_declaration ;这样保证局部声明出现之前作用域已经建立,后续变量声明才能登记到正确的层级中。
2.3 变量声明与函数声明的登记细节
变量声明的语义动作中,要区分普通变量和数组。C-Minus的数组声明形式是int a[10],在Bison规则里可以这样写:
var_declaration : type_specifier ID ';' { if (lookupCurrentScope($2) != NULL) { error("变量 %s 在当前作用域中重复声明", $2); } else { Symbol *sym = malloc(sizeof(Symbol)); sym->name = $2; sym->type = makeType($1); sym->kind = 0; insertSymbol(sym); } } | type_specifier ID '[' NUM ']' ';' { if (lookupCurrentScope($2) != NULL) { error("变量 %s 在当前作用域中重复声明", $2); } else { Symbol *sym = malloc(sizeof(Symbol)); sym->name = $2; sym->type = makeArrayType($1, $4); sym->kind = 0; insertSymbol(sym); } } ;先查当前作用域是否已有同名符号,再执行插入。这里的lookupCurrentScope只查当前层,不向上查找,因为C允许内层变量遮蔽外层同名变量,只有同一作用域内的重复声明才非法。
函数声明登记时,除了函数名本身,还要把参数列表保存下来,供后续调用检查使用。参数可以存成链表或数组,放在Symbol结构的params字段里。需要注意一点:函数体内部的参数名本身也应该在函数作用域内可见,所以处理函数定义时,要先压入函数体作用域,再逐个登记参数,最后再处理函数体里的局部声明。
2.4 作用域遮蔽规则与典型陷阱
C-Minus的遮蔽规则沿用C语言:内层声明的变量可以遮蔽外层同名变量,离开内层后对外层符号的引用恢复可见。这个规则看起来简单,但实现时容易出问题。
我遇到的一个实际案例是这样的:全局声明了int x,某个函数的复合语句内又声明了int x,函数内对x的赋值应该落在局部变量上。如果符号表查找只做了一处判断,没有区分“当前作用域查找”和“全局查找”,很可能就在函数内查到了全局的x,导致检查结果完全错误。
解决办法就是上面写的两层查找函数,一是只查当前作用域的lookupCurrentScope,用于声明时的重复检查;二是从当前到全局逐层找的lookupSymbol,用于引用时的解析。两个函数分开,遮蔽规则就自然实现了。
另一个容易忽略的陷阱是for循环或单语句if的作用域。C-Minus的定义里没有单独的for,但存在if不带花括号的情况。按照语法定义,if的分支可以是一个单条语句,但不是复合语句,不开启新作用域,因此if (a > 0) int b = 1;实际上是合法的吗?并不是。C-Minus的语法规则中,声明语句不能出现在单条语句的位置,Bison的语法规则已经限制了这一点。语义分析阶段不需要额外处理这类问题。
3. 类型检查与语义动作注入
3.1 Bison复合对象的属性传递
Bison在做归约时,可以通过$$给左部非终结符赋值,通过$1、$2等引用右部符号的属性。所有属性都通过一个联合体传递,这个联合体需要在parser.y里用%union定义:
%union { int intVal; char *strVal; Type *typeVal; Symbol *symVal; int exprKind; }表达式的属性需要同时包含类型和值性质(左值或右值)。我为此专门定义一个ExprAttr结构:
typedef struct { Type *type; int isLValue; // 1表示左值,0表示右值 int constVal; // 如果是常量表达式,保存常量值 } ExprAttr;在%union里增加:
struct ExprAttr exprVal;然后表达式的每个产生式都要维护这个属性。比如加法表达式:
expr : expr '+' expr { $$.type = $1.type; $$.isLValue = 0; $$.constVal = 0; if (!isInt($1.type) || !isInt($3.type)) { error("加法运算的操作数必须为整数"); } } ;如果两边都是整型,结果的类型当然也是整型。一旦检测到类型不符合要求,记录错误,但依然要把$$赋一个合理的默认值,否则Bison运行时访问未初始化的属性可能出现意外行为。
3.2 表达式类型推导与错误报告
表达式的类型推导规则在C-Minus里比较明确:
- 标识符表达式:类型为符号表中的声明类型,如果是数组名,则需要根据使用场景做降级处理。C-Minus里数组名作为右值出现时,通常取其首元素类型,不过本项目简化处理,只有在下标访问时才对数组类型做特殊处理。
- 整型字面量:整数类型,右值
- 算术表达式:操作数必须都是整数,结果整数,右值
- 关系表达式:操作数必须都是整数,结果整数(表示布尔)
- 逻辑表达式:与算术类似
- 赋值表达式:左操作数必须是左值,且类型兼容
- 函数调用表达式:返回值类型即函数返回类型
错误报告要尽量给出清楚的信息。我习惯的错误信息格式是:
semantic error at line %d: %s把行号和原因都打出来。行号从哪里来?Flex里可以维护一个行号计数器,每次遇到\n就加1,并暴露给Bison使用:
int curLine = 1; %% \n curLine++;然后在Bison动作里通过curLine报告位置。另一种方式是用yylloc,但Flex默认不自动维护详细位置,简单的行号计数已经够用。
3.3 赋值、函数调用与返回语句的约束
赋值语句的类型规则比较容易忽略的一点是:C-Minus中数组名不能作为赋值目标。也就是说,a = b;如果a是数组,语义错误。同时,只有当赋值左侧的表达式是左值时,赋值才有意义。
assignment_stmt : ID '=' expr ';' { Symbol *sym = lookupSymbol($1); if (sym == NULL) { error("使用未声明的变量 %s", $1); } else if (sym->type->kind == ARRAY && sym->type->size > 0) { error("不能给数组整体赋值"); } else if (!isInt($3.type)) { error("赋值类型不匹配"); } } | ID '[' expr ']' '=' expr ';' { // 数组元素赋值,必须确保下标是整数,元素类型匹配 } ;函数调用的约束主要在参数检查和返回值使用上。调用前先确认函数是否已经声明:
call_expr : ID '(' args ')' { Symbol *sym = lookupSymbol($1); if (sym == NULL) { error("调用未声明的函数 %s", $1); } else if (sym->kind != FUNC) { error("%s 不是函数", $1); } else { checkArguments(sym, $3); } } ;checkArguments逐一比较实参个数、类型与形参是否一致。C-Minus不支持默认参数和可变参数,所以参数个数必须完全相等。这里要特别注意,实参如果是数组名,往往需要把数组类型降级为指向元素类型的指针,但C-Minus里数组并没有严格引入指针运算,因此我采用的策略是直接比较元素类型是否匹配,避免在子集语言里引入C语言完整数组退化规则。
return语句的检查核心是返回类型匹配。函数返回void时,return;是合法的,return expr;非法;函数返回int时,两者都必须带表达式。实现时要能拿到当前函数的返回类型,这需要在进入函数定义时把当前返回类型保存在一个全局变量里,退出时恢复。
3.4 数组与指针相关的语义约束
C-Minus里的数组没有完整的指针运算,但数组下标访问仍然要约束。下标表达式必须是整数类型,被取下标的对象必须是数组类型。比如:
expr : ID '[' expr ']' { Symbol *sym = lookupSymbol($1); if (sym == NULL) { error("使用未声明的变量 %s", $1); } else if (sym->type->kind != ARRAY) { error("%s 不是数组,不能使用下标", $1); } else if (!isInt($3.type)) { error("数组下标必须为整数"); } else { $$.type = sym->type->elem; $$.isLValue = 1; } } ;数组作为函数实参传递时元素类型需要匹配,其他场景中,数组名直接作为普通右值使用应当报错,因为C-Minus不支持完整的数组指针退化的语义。早期我把数组名直接当整型处理,导致int a[10]; int b = a;这样的错误代码通过了检查,后面检查输出结果时才意识到这是一个明显的语义漏洞。把数组作为右值的场景一律判定错误,除非是下标访问或作为实参。
4. 错误恢复与排查技巧实录
4.1 yylval未定义等常见编译错误
Flex和Bison联合使用时,最常遇到的编译错误就是yylval未声明。这是因为Flex生成的词法分析器需要通过全局变量yylval把token的属性传给Bison,而这种类型的定义放在parser.tab.h里。
解决方法是确保在scanner.l里先包含parser.tab.h:
%{ #include "parser.tab.h" #include <string.h> extern int curLine; %}顺序很重要,一定要把parser.tab.h放在前面。如果还涉及自定义的Type、Symbol结构,这些结构的头文件要在parser.tab.h之前或者之后合理放置,视依赖关系而定。实践中我发现把公共结构定义放在独立的types.h里,再让scanner.l和parser.y都包含它,可以避免循环依赖。
另一个常见问题是token编号冲突。Flex会为每个token分配编号,而Bison的%token声明也会生成编号,如果两者不一致会导致词法分析器报出完全错误的token。要避免这个问题,必须以Bison生成的parser.tab.h为准,Flex不要自己定义token编号。好在Flex规则里同一个正则只能返回一个token,不会自己生成编号,只要使用了Bison的头文件就不会出问题。
4.2 移进/归约冲突对语义分析的影响
Bison在生成分析器时如果检测到冲突,会默认采取移进优先策略,对大部分表达式文法来说这个策略刚好符合预期。但是,if语句的悬挂else歧义会出现移进/归约冲突,如果不处理,Bison会默认移进,这样else会绑定最近的if,和C语言标准一致,所以很多时候直接忽略警告也能得出正确行为。
然而,作为语义分析项目,我建议还是认真看一下冲突报告。用bison -v -t parser.y生成.output文件,里面详细列出每个状态里存在冲突的产生式。有一回我把else和另一个关键字写进了同一个优先级声明里,导致一个关系表达式产生了两个移进路径,虽然没有报语法错误,但语法优先级被悄悄改变了,表达式解析结果和预期不符。
排查这类问题时,-t参数配合%debug指令可以在运行时输出分析器状态,能看到每一步移进、归约的动作,非常直观。调试完记得把%debug注释掉,否则输出会产生大量日志。
4.3 Flex与Bison接口协调问题
词法分析器返回的token属性值必须和Bison中声明的类型一致。例如,在Bison里声明:
%token <strVal> ID %token <intVal> NUM那么在Flex中:
{ID} { yylval.strVal = strdup(yytext); return ID; } {NUM} { yylval.intVal = atoi(yytext); return NUM; }这里最容易掉的坑是忘了给strVal分配内存。直接yylval.strVal = yytext;在技术上是错误的,因为yytext指向的缓冲区会在下次调用yylex()时被覆盖。用strdup复制字符串是正确做法。相应地,不需要这个字符串时记得释放内存,否则项目跑长一点就会出现内存泄漏。Bison的%destructor指令可以解决一部分释放问题,但C-Minus这种规模,靠仔细管理也能控制住。
另一个常见问题是运算符和空白符的处理。C-Minus支持多行注释,Flex规则里可以用:
"/*"([^*]|"*"+[^*/])*"*"+"/" { }如果不小心写成了"/*"(.|\n)*"*/",在注释跨多行时也能匹配,但性能极其糟糕。甚至在某些Flex版本里会匹配整个文件直到最后一个*/,导致注释后面的代码全被吞掉。这个问题排查非常痛苦,因为语法错误发生在完全无关的行上。我在项目中改成上面的有限状态正则后,问题就解决了。
4.4 调试技巧:AST打印与跟踪日志
语义分析阶段最容易犯的错是查不到符号或查错符号。为了快速定位,我写了一套简单的AST打印工具,在每次输入文件解析完成后,把整个语法树以缩进方式打印出来,同时标注每个节点的类型信息。这样每个变量引用是否解析成功,一眼就能看出来。
打印伪代码类似这样:
[Program] [VarDecl] int a [FuncDef] int main() [LocalDecl] int i [AssignStmt] i := [BinExpr +] [VarRef i] [Num 1] [CallStmt] display -> [FuncCall] output(int)对照AST和实际代码,语义规则哪里写错了、哪个节点类型被误判,都会立刻暴露。调试时还可以在语义规则里临时加上printf输出,确认是否走到了预期分支。比如怀疑数组下标检查没有执行,就在那条规则里打一行日志,然后在测试代码里故意写一个非整数下标,观察日志是否出现。这种做法虽然原始,但定位问题比单纯靠断点更快。
5. 项目扩展与心得建议
5.1 从语义分析扩展到中间代码生成
做完语义分析后,自然想到往下扩展。C-Minus课程设计常见的下一步是生成三地址码。这里语义分析阶段的成果能直接复用:符号表就是变量地址分配的依据,类型检查通过后就能确定每条语句对应的中间代码操作。
扩展中需要注意的一点是,语义分析阶段会把a = b + 1这类赋值语句分解为“左值解析 + 右值表达式求值 + 赋值动作”,生成三地址码时也会遵循相同顺序。
t1 = b + 1 a = t1我在扩展时把符号表结构增加了一个offset字段,用于记录局部变量在栈帧中的偏移。这样当语义分析过程中登记变量时,中间代码生成阶段就可以直接根据符号表输出汇编风格的伪指令。整个流程串起来之后,语法分析建树、语义分析建表、代码生成查表,逻辑非常清晰。
5.2 做这个项目实际踩过的几个坑
第一个坑是数组声明时用了NUM作为数组长度,但NUM的值如果为0或负数没有检查,后面访问数组元素时可能出现运行时错误。语义分析阶段就应该排除这种情况,在数组类型构造前加一个正数判断,成本极低。
第二个坑是函数体内局部变量和全局变量同名时,函数内对该名字的每次引用都必须保持一致的解析结果。有一次我为了图省事,在某个局部变量声明后直接把符号加入全局哈希表,结果后面的全局引用也解析成了局部变量,执行结果完全错误。正确的做法是必须有层级作用域,不能图省事把所有符号放在一张平表里。
第三个坑是调试时没有统一管理错误计数。语义分析器应当维护一个semanticErrors计数,一旦有错误就递增,最后如果计数大于0,就不生成后续的中间表示或目标代码。这个计数要放在主流程里,不要分散在各个函数中自增,否则后期统计不准确。
第四个坑是分配内存之后没有统一释放,导致大型测试文件跑完内存占用疯涨。虽然课程设计不要求严格内存管理,但作为一个认真对待的项目,建议用valgrind或AddressSanitizer检查一下,把明显的泄漏处理掉。这不仅能让程序更稳定,自己排查逻辑问题的时候也不容易被内存问题干扰。
最后再分享一个小经验:在项目开始时先定好types.h和symtab.h的接口,再写parser.y,比边写边改要轻松得多。语义分析表面上是写Bison动作,实际上大半的功夫都花在数据结构设计和作用域管理上,数据结构稳了,后续自然顺。C-Minus虽然只是一个教学语言,但把它的语义分析完整做一遍,对理解C语言本身的设计取舍会有完全不同的体会。