☰
编译原理实验通关指南:flex+bison与手写递归下降实践
2026/10/2 22:55:51 网站建设 项目流程

简介:一份面向编译原理课程设计的实验资源,围绕PL/0教学编译器的词法分析、语法分析与语义处理改造展开,适合广工及同类高校需要完成保留字扩展实验的学生使用。内容明确要求新增ELSE、FOR、TO、DOWNTO、RETURN五个保留字,加入+=、-=、++、--运算符,同时把不等号#改为<>,并为条件语句补充ELSE子句,能够较完整地体现编译前端从识别到语法、语义处理的联动修改过程。压缩包共24个文件,644KB,主要包含cpp/h源代码、Word实验报告、exe可执行程序以及pdb、obj、ilk等Visual C++工程调试辅助文件,便于打开工程直接对照源码与运行结果。目前已有789人学习下载。借助这份紧凑的资源,可快速定位保留字和运算符的词法定义位置、ELSE子句在语法与语义模块中的插入点,并通过实验报告梳理改造思路,适合作为课程实验、期末复习或答辩前的参考资料。

1. 广工编译原理实验:先搞清楚这门课到底要你做什么

编译原理实验是广工计算机学院里最容易被当成“玄学”的一门课,其实它要你动手的只有一件事:把一段源代码变成计算机能执行的目标代码,哪怕只是中间表示。很多人的误区是以为要背一堆自动机理论,实际做的时候才发现,词法分析就是“正则匹配”,语法分析就是“递归下降或LR表”,并没有那么抽象。这门实验通常分三到四个阶段,从词法分析到语法分析,再到语义分析和中间代码,每一阶段都会给一个可运行的验收程序。适合两类人:一是要应付实验报告和验收的同学,二是想真正搞懂编译器前端的从业者。我下面按广工常见实验要求,把从环境搭建到出结果的完整路径走一遍,每一步都有可以直接复现的命令和代码。

2. 环境与工具选型:用 flex+bison 还是手写递归下降

2.1 三个主流方案的取舍

广工编译原理实验允许的语言和工具比较宽,常见做法有三种。第一,用 C/C++ 配合 flex 和 bison,这是最经典、资料最多的方案,也是我推荐优先使用的。第二,用纯 C++ 或 Java 手写词法分析和递归下降解析器,适合实验限定“不许用生成器”的年份。第三,直接用 LLVM 工具链做中间代码和目标代码,但本科阶段很少要求到那一步,一般在高级选修课里才出现。

从验收角度讲,flex+bison 生成的代码统一、错误处理清晰,老师调起测试用例来也顺手。从学习角度讲,手写一遍你对状态机和递归下降的理解确实更深。我的建议是:如果实验不禁止生成器,就用 flex+bison;如果明确要求手写,那就手写词法 + 手写递归下降,不要硬套 LR 构造表。两种路线我都带人走过,踩过的坑会在第 5 章集中列出来。

2.2 用 flex+bison 跑通最小可运行示例

先把工具装上,在 Ubuntu/Debian 下执行:

sudo apt update sudo apt install flex bison gcc make

装完以后,建一个工作目录,里面放三个文件:lexer.l、parser.y、Makefile。下面是一个能计算四则运算的最小示例。先写lexer.l:

%{ #include <stdio.h> #include "parser.tab.h" %} %% [0-9]+ { yylval = atoi(yytext); return NUMBER; } "+" { return PLUS; } "-" { return MINUS; } "*" { return TIMES; } "/" { return DIVIDE; } "(" { return LPAREN; } ")" { return RPAREN; } \n { return NEWLINE; } [ \t] { /* 忽略空白 */ } . { fprintf(stderr, "Unknown char: %s\n", yytext); return -1; } %% int yywrap(void) { return 1; }

再写parser.y:

%{ #include <stdio.h> void yyerror(const char *s); %} %token NUMBER PLUS MINUS TIMES DIVIDE LPAREN RPAREN NEWLINE %left PLUS MINUS %left TIMES DIVIDE %% program: program line | /* empty */ ; line: expr NEWLINE { printf("Result: %d\n", $1); } ; expr: expr PLUS expr { $$ = $1 + $3; } | expr MINUS expr { $$ = $1 - $3; } | expr TIMES expr { $$ = $1 * $3; } | expr DIVIDE expr { $$ = $1 / $3; } | LPAREN expr RPAREN { $$ = $2; } | NUMBER { $$ = $1; } ; %% int yyerror(const char *s) { fprintf(stderr, "Error: %s\n", s); return 0; } int main(void) { return yyparse(); }

编译命令:

bison -d parser.y flex lexer.l gcc parser.tab.c lex.yy.c -o calc

这里bison -d会同时生成parser.tab.c和parser.tab.h,头文件里声明了 token 的宏定义,lexer.l里#include "parser.tab.h"就靠它。flex lexer.l生成lex.yy.c,里面包含词法分析函数yylex(),语法解析器会回调它。最后一行把两个生成文件一起编译成可执行文件calc。

运行一下:输入3+4*2再回车,应该输出11,因为乘号优先级更高。如果没有输出或者报语法错误,多半是 token 编号不一致,检查parser.tab.h和lexer.l里返回的 token 名称是否完全一样。

2.3 构建命令和 Makefile 中的参数细节

每次手动敲三条编译命令很烦,而且容易漏参数。建议直接用 Makefile,我常用的写法:

CC = gcc CFLAGS = -Wall -g OBJS = parser.tab.o lex.yy.o calc: $(OBJS) $(CC) $(OBJS) -o calc parser.tab.c parser.tab.h: parser.y bison -d parser.y lex.yy.c: lexer.l flex lexer.l clean: rm -f calc parser.tab.c parser.tab.h lex.yy.c *.o

注意几个容易被忽略的参数。bison -d的-d是必须的,否则没有头文件,编译lex.yy.c时找不到 token 定义。如果报错undefined reference to 'yywrap',就是链接时缺了字符串表函数,可以加-lfl链接 flex 库,或者像我在lexer.l里那样自己写一个yywrap返回 1。另外,你写的parser.y里如果没有定义main,链接也会报undefined reference to main,所以上面示例里我把main放在parser.y尾部。

2.4 手写递归下降方案何时更合适

如果你所在的广工实验室要求不能使用生成器,或者你想把原理吃透,那就必须手写。词法部分可以写一个循环扫描字符流,遇到字母开头就累计成标识符,遇到数字就累计成整数。语法部分最常用的是递归下降:给每个非终结符写一个函数,函数里根据当前 token 类型决定调用哪个分支。比如一个简单的表达式文法expr -> term ( ( '+' | '-' ) term )*,可以写成:

int expr() { int left = term(); while (tok == PLUS || tok == MINUS) { int op = tok; next(); int right = term(); left = (op == PLUS) ? left + right : left - right; } return left; }

这种写法的好处是代码结构和你写的文法一一对应,调试时很容易定位;坏处是如果文法里有公因子或左递归,需要先手动改写。我第一次手写时就是忘了处理while条件里tok == PLUS之后要调用next(),结果死循环吞掉了所有 token。手写方案没有生成器帮你检测冲突,所以每写一个函数都要测试一个对应的输入用例。

3. 词法分析实验:从正则表达式到能跑的状态机

3.1 词法 token 的类型和代码组织

词法分析是编译实验的第一关,它要做的事就是读入源文件字符流,切分成一个又一个 token,同时记录每个 token 的种类、值、行号列号。在广工实验里,token 一般分五类:关键字(if、else、while)、标识符(变量名和函数名)、常量(整数、浮点数、字符串)、运算符(+、-、*、/、==、!=)、界符(分号、逗号、括号)。这些 token 的定义必须放在一个公共头文件里,比如token.h,否则词法分析器和语法分析器各搞一套,接口对不上。

实际写代码时,我会把 token 类型设成一个枚举,再配一个结构体保存文本和值:

typedef enum { TOKEN_IF, TOKEN_ELSE, TOKEN_WHILE, TOKEN_IDENTIFIER, TOKEN_NUMBER, TOKEN_PLUS, TOKEN_MINUS, TOKEN_MUL, TOKEN_DIV, TOKEN_LPAREN, TOKEN_RPAREN, TOKEN_SEMICOLON, TOKEN_EOF } TokenType; typedef struct { TokenType type; char text[256]; int value; int line; int col; } Token;

然后在词法主循环里,每调用一次getToken()就返回一个Token。实验要求高一点的话,还要支持==和=的区别,以及<=、>=之类的复合运算符。

3.2 用 flex 精确描述词法规则:优先级与最长匹配

flex 的规则格式是“模式 + 动作”,模式是正则表达式,动作是在匹配成功后执行的 C 代码。写规则时最重要的两个原则:第一,关键字规则必须写在标识符规则之前;第二,flex 默认按“最长匹配”来决定哪个规则命中,如果两个规则匹配到同样长度的文本,则排在前面的规则优先。

例如下面的lexer.l片段:

%% "if" { return TOKEN_IF; } "else" { return TOKEN_ELSE; } "while" { return TOKEN_WHILE; } [a-zA-Z_][a-zA-Z0-9_]* { yylval.str = strdup(yytext); return TOKEN_IDENTIFIER; } [0-9]+ { yylval.value = atoi(yytext); return TOKEN_NUMBER; } "==" { return TOKEN_EQ; } "=" { return TOKEN_ASSIGN; }

为什么关键字要放在前面?因为if本身也匹配标识符规则[a-zA-Z_][a-zA-Z0-9_]*,但 flex 看到两个规则都能匹配if时,会优先选择更靠前的规则。如果把标识符规则放在前面,那if、else就永远是标识符,关键字就废了。==和=也是一样的道理:如果在先写上=规则,输入==时,flex 按最长匹配会选择==,因为==的长度是 2,=是 1,所以即使顺序反了也不会错。但为了可读性,我习惯把复合运算符放在前面。

3.3 手写词法分析器的状态机实现

不用 flex 时,词法分析器就是一个确定有限自动机。你可以写一个全局的 token 类型判断函数,也可以用状态转移表。状态转移表适合机器生成,但手写时维护很痛苦,我更推荐直接分情况处理。

下面是一个识别整数和标识符的简化版手写函数:

Token getToken() { Token tok; // 跳过空白 while (isspace(current)) { advance(); } // 数字 if (isdigit(current)) { StringBuilder sb; while (isdigit(current)) { append(&sb, current); advance(); } tok.type = TOKEN_NUMBER; tok.value = atoi(sb.buf); return tok; } // 标识符或关键字 if (isalpha(current) || current == '_') { StringBuilder sb; while (isalnum(current) || current == '_') { append(&sb, current); advance(); } if (strcmp(sb.buf, "if") == 0) tok.type = TOKEN_IF; else if (strcmp(sb.buf, "else") == 0) tok.type = TOKEN_ELSE; else tok.type = TOKEN_IDENTIFIER; strcpy(tok.text, sb.buf); return tok; } // 运算符 switch (current) { case '+': advance(); tok.type = TOKEN_PLUS; return tok; case '=': advance(); if (current == '=') { advance(); tok.type = TOKEN_EQ; } else tok.type = TOKEN_ASSIGN; return tok; } }

这段逻辑里最大的坑是“偷看下一个字符”。处理==时,advance()后必须立即判断current,如果模式=,就把current消耗掉;这里很容易漏掉advance()导致无限循环。另一个坑是字符串构建器,记得在末尾补\0,否则strcmp读到越界内存。

3.4 行号记录与错误恢复

词法分析器除了返回 token,还要记录行号和列号,因为语法分析器和后续的报错会用到。在 flex 里,你可以在动作里维护全局变量line和col,遇到换行时line++、col=0,否则col++。手写时同理,在advance()函数里更新它们。错误恢复也有讲究:遇到不认识的字符,最简单的做法是打印“illegal character”然后跳过它,但不要直接退出。因为编译实验的测试用例里常常夹杂着注释或预编译指令,如果一碰到不认识的字就终止,后面所有测试都会挂掉。我一般会这样写:

default: fprintf(stderr, "Line %d: unexpected char '%c'\n", line, current); advance(); break;

这样词法分析器能继续往下走,语法分析器也会收到错误信息,最后的错误统计会好看很多,验收也不会因为一个字符就整体崩溃。

4. 语法分析实验:从文法改写到一个能跑通的解析器

4.1 文法设计:消除左递归与优先级

语法分析的核心是把 token 流按文法组织成语法树。你从实验指导书里拿到的文法往往是“教学文法”,比如:

expr → expr + term | term term → term * factor | factor factor → ( expr ) | number

这种左递归文法可以直接用在 bison 里,因为 yacc/bison 采用 LR 分析,天然支持左递归。但如果要求手写递归下降,就必须先改写成 LL(1) 形式,消除左递归,变成:

expr → term rest rest → + term rest | ε term → factor rest2 rest2 → * factor rest2 | ε factor → ( expr ) | number

改写的本质是把左递归变成右递归,同时保留左结合性。很多同学在这里翻车,因为改写后的文法虽然能识别同样的语言,但如果rest的语义动作没写对,加减法的结合律会变反。我会在 4.3 节给出一个处理方案。

4.2 用 bison 实现完整语法规则

bison 里用%left声明优先级可以避免写繁琐的层次文法。前面的计算器已经展示了左右递归的写法。这里再来一个带符号和语句的版本,对应广工实验里常见的“赋值语句 + 表达式”:

%token IDENTIFIER NUMBER ASSIGN SEMICOLON %left PLUS MINUS %left TIMES DIVIDE %% program : program statement | /* empty */ ; statement : IDENTIFIER ASSIGN expr SEMICOLON { printf("Assign %s = %d\n", $1, $3); } ; expr : expr PLUS expr { $$ = $1 + $3; } | expr MINUS expr { $$ = $1 - $3; } | expr TIMES expr { $$ = $1 * $3; } | expr DIVIDE expr { $$ = $1 / $3; } | IDENTIFIER { $$ = lookup($1); } | NUMBER { $$ = $1; } ; %%

这里IDENTIFIER的语义值$1默认是字符串指针,你需要把它转换成变量值。所以词法规则要用yylval.str = strdup(yytext),在语法规则里用lookup($1)查符号表。参数%left PLUS MINUS声明了加法和减法的优先级,并且它们左结合;%left TIMES在下面一行,优先级更高,所以乘法会先归约。如果你把%left写成了%right,那1-2-3就会算成1-(2-3)=2,这是新手最容易犯的错误。

4.3 手写递归下降解析器:以表达式为例

手写递归下降时,我把每个非终结符写成一个函数,每个函数开头先检查当前 token 是否属于它的 FIRST 集。下面是改写后的表达式子项term_rest的处理,重点在循环里控制结合性:

int expr() { int left = term(); while (tok == PLUS || tok == MINUS) { int op = tok; next(); int right = term(); left = (op == PLUS) ? left + right : left - right; } return left; } int term() { int left = factor(); while (tok == TIMES || tok == DIVIDE) { int op = tok; next(); int right = factor(); left = (op == TIMES) ? left * right : left / right; } return left; }

很多教材把rest写成独立的函数,那个版本很容易掉进空产生式死循环。我的习惯是直接用while循环匹配同一优先级的运算符,每读到一个运算符就递归下降到下一优先级,计算完再更新左值。这样写出来的代码直觉上就是左结合,也不需要额外的ε处理。如果输入的表达式很长,这个方案每次循环只next()一次,不会无限消耗输入。

4.4 构建 AST 节点并输出

上面的计算器只在产生式里直接计算数值,但实验要求往往要生成抽象语法树(AST)。AST 的结构通常是:

typedef struct Node { NodeType type; union { struct { struct Node* left; struct Node* right; } binary; char* name; int value; } data; } Node;

在 bison 动作里创建节点:

expr: expr PLUS expr { $$ = makeNode(BINARY_OP, '+', $1, $3); }

然后写一个printAST(Node*)递归遍历。我建议在调试阶段先打印 AST,再用一个eval(Node*)计算值。比如输入2+3*4,AST 根是一个+,左边是叶子2,右边是*节点。打印出来大概是这样:

+ ├── 2 └── * ├── 3 └── 4

如果打印出的结构和你手算的优先级不一样,那一定是语法规则里优先级或递归写错了。AST 输出是验证语法分析正确性最直接的手段,比看printf中间结果更可靠。

5. 常见问题排查:你的程序为什么一到测试用例就翻车

5.1 现象:关键字被识别成标识符

原因:词法规则顺序错了,标识符正则写在关键字前面。

解决:调整顺序,把关键字规则放在标识符规则之前。如果用的是手写词法,在识别完字母序列后,不要直接返回标识符,先查一张关键字表:

if (strcmp(buf, "if") == 0) return TOKEN_IF; else if (strcmp(buf, "else") == 0) return TOKEN_ELSE; else return TOKEN_IDENTIFIER;

5.2 现象:bison 报 shift/reduce 冲突

原因:文法有二义性,比如没声明运算优先级,或表达式嵌套时出现空产生式。

解决:给 token 声明%left和%right,并确保优先级按从低到高排列。如果还是冲突,检查是不是%prec用错了。我的经验是:先注释掉冲突的规则,逐条加回来,每次只加一条,再用bison -v生成.output文件看冲突所在状态,那里面能定位到具体是哪两个规则打架。

5.3 现象:手写递归下降死循环或栈溢出

原因:某个解析函数在遇到空串时没有消耗 token,比如while (tok == PLUS)内部忘记next(),或者term_rest处理ε时无限调用自身。

解决:在函数开头打核心不变量:每次调用必须消耗至少一个 token,除非是遇到期望的结束符。我习惯在每个解析函数第一行加一条fprintf(stderr, "Enter %s: %s\n", __func__, tokenText(tok));,一旦死循环,立即看打印停在哪一行。

5.4 现象:编译链接时报yylex或yyerror未定义

原因:flex生成的文件里的yylex符号在bison里被重命名了,或者yyerror只声明没定义。

解决:确认lexer.l和parser.y里使用了相同的全局函数名。如果编译命令里用了%option reentrant,则函数签名会变复杂,需要查阅 flex/bison 对应版本的文档。最简单的办法是先用默认模式,别开reentrant或locations这种高级选项,跑通再改。

5.5 现象:运行一开始就崩溃,错误信息指向yyin或yyrestart

原因:yyin文件指针没初始化,或者程序代码里错误地使用了yyrestart。

解决:yyparse默认从标准输入读,如果想从文件读,在main里写:

extern FILE *yyin; yyin = fopen(argv[1], "r"); if (!yyin) { perror("open file failed"); return 1; } return yyparse();

如果使用yyrestart,一定要在调用前确保yyin已经打开。还有一次我遇到的是项目里同时引用了两个头文件,yyin被声明成char*,导致运行时乱跳,后来加#include <stdio.h>才解决。

6. 进阶验证技巧:用符号表和中间代码把整个实验串起来

6.1 用 AST 可视化验证语法树

完成语法分析后,不要急着写中间代码,先把 AST 打印出来,对照每一个测试用例人工检查。我常用一个递归打印函数,输出括号嵌套形式:

void printAST(Node *n) { if (!n) return; if (n->type == NUM) printf("%d", n->value); else if (n->type == ID) printf("%s", n->name); else if (n->type == BINOP) { printf("(%c ", n->op); printAST(n->left); printf(" "); printAST(n->right); printf(")"); } }

输入2+3*4应该输出(+ 2 (* 3 4)),如果输出(* (+ 2 3) 4),那就是优先级处理反了。这一步能过滤掉一半以上的逻辑错误。

6.2 用符号表验证变量信息

符号表是语义分析的核心,也常被当成实验的最后加分项。你可以在语法分析过程中,每遇到一个IDENTIFIER ASSIGN expr就把变量名和当前值登记进一个哈希表。表达式中用到标识符时,先从符号表查值,查不到就报“undefined variable”。写一个简单的线性表就够了:

typedef struct Symbol { char name[64]; int value; } Symbol; Symbol table[100]; int tableSize = 0;

然后在处理赋值语句时table[i].value = $3,处理表达式中的标识符时遍历查找。这样整个实验从词法到语义就串成了一条完整的工具链。最后建议你做一个总调试脚本,把input.txt、lexer.l、parser.y、make命令和期望输出放在一起,每次改完代码跑一遍,回归测试比手工敲命令可靠得多。

我自己的习惯是每个实验阶段结束前,强制自己写一个test_all.sh,把所有老师可能给的测试用例跑一遍,并记录输出。这样验收时哪怕临时加一个边界用例,我也能快速定位是哪个模块出了问题。如果从头到尾只会在命令行里手动敲几个输入,那实验做得再好,考试或答辩时也容易手忙脚乱。这条建议来自一个在广工被测试用例挂掉过的学长,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询