☰
编译原理实验指南:Flex+Bison实现词法语法分析及避坑
2026/10/10 1:38:13 网站建设 项目流程

简介:一份广东工业大学编译原理课程实验报告,围绕 PL/0 编译程序的修改与扩大展开,完整覆盖词法分析、语法分析、代码生成、错误处理、符号表管理和运行时存储组织等核心模块。报告以 PL/0 语言为对象,详细说明如何增加 ELSE、FOR、TO、DOWNTO、RETURN 等保留字及 *=、/=、++、--、&、||、!等运算符,并给出对应文法、语法图和语义规则,适合正在学习编译原理或需要完成类似课程设计的学生参考。资源为 1 个 doc 文档,压缩包大小 565KB,内容包含实验要求、设计方案、主要成分描述、测试用例与开发完成情况,便于直接对照修改思路和调试流程。已有 1587 人学习下载,适用于高校计算机专业编译原理实验、课程报告撰写和 PL/0 编译器实现入门。

1. 一份某高校编译原理实验报告.doc,藏着比教材更值的参数和踩坑

标题里写着《某高校编译原理实验报告.doc》的那份文件,乍看只是一份课程作业。但把它打开细读,它就是一台微型编译器的完整设计稿:词法分析、语法分析、语义动作、符号表管理,四层结构全浓缩在几十页文档里。对常年写业务代码、偶尔碰编译器的工程师来说,它的价值不在排版,而在那些参数组合和失败记录——教材只讲文法理论,报告必须回答“shift 冲突怎么消”“yylval 什么时候才有干净数据”。

这份文档适合三类人:要在一周内跑通编译原理实验的学生;工作中需要写 DSL 解析器或 SQL 解析,却没系统学过词法语法分析的开发者;打算把 Yacc/Bison 技术栈引入内部工具的团队。接下来的顺序是:先立理念,再上手复现,最后排错。

2. 编译原理实验到底在做什么:实验矩阵、工具链选型与环境搭建

拿到一份实验报告,先别急着看代码。报告的价值在于它暴露了“实验设计者认为哪些能力是必须验收的”。把这层结构拆开,你才知道自己要把时间花在哪。

2.1 词法、语法、语义:一份报告对应的三个实验层次

编译原理实验通常不是一次做出来的,而是按三层递进:词法分析、语法分析、语义分析。每层有独立的输入输出和验收标准。下面的表是我从多份课程报告里归纳出来的通用实验矩阵:

实验层次输入输出验收标准
词法分析源程序文本Token 序列关键字、标识符、数字、符号无漏辨
语法分析Token 序列语法树或错误报告无 shift/reduce conflict,合法输入全接受
语义分析语法树 + 符号表检查后的中间表示变量未定义、重复声明、类型不匹配能报错

词法对应正则语言,语法对应上下文无关文法,语义对应程序逻辑。这三层最大的坑是“以为上一层没问题,结果下一层跑不通”。我见过最典型的案例:词法分析器把所有空白字符都返回成 Token,语法分析器每读一个空格就多一个节点,整个 AST 里全是空白节点——这种问题你在词法层不设规则根本查不出来。

2.2 为什么选 Flex + Bison 而不是手写递归下降

课程实验里最常见的工具链是 Flex(lex 的开源实现)做词法生成器,Bison(yacc 的 GNU 实现)做语法生成器。选它而不是手写递归下降,有三个现实理由:

第一,声明式文法。你只需要写产生式和语义动作,Bison 会生成 LALR(1) 解析器,不需要手动管理预测集合和回溯栈。第二,冲突可视化。Bison 加--verbose会输出parser.output,明确告诉你哪些状态机位置有 shift/reduce conflict,这是手写解析器根本不具备的调试能力。第三,语义动作直接嵌在文法里,用$$、$1传递属性值,和编译原理教材里的属性文法一一对应,报告也好写。

手写递归下降更适合文法是 LL(1) 的场合,但碰上左递归就要改写文法,而且错误恢复要靠自己写。对课程实验来说,Bison 的冲突报告能直接变成报告里的“难点分析”,这是加分项,不用白不用。

2.3 最小环境搭建:安装、验证与目录结构

环境搭建本身很简单,但版本差异会在后面咬人。在 Debian/Ubuntu 这类 Linux 发行版上,一条命令装齐:

sudo apt install flex bison gcc make # macOS 用 homebrew,装完后注意 PATH 顺序 brew install flex bison # 验证版本 flex --version bison --version

flex --version正常输出 2.6.x,bison --version输出 3.8.x。如果 macOS 上bison还是系统自带的 2.3 老版本,后续%prec行为会有细微差异,建议把 homebrew 的 bin 目录放到 PATH 前面。

实验目录结构我一般按这样组织:

compiler-lab/ ├── lexer.l # Flex 词法规则 ├── parser.y # Bison 文法与语义动作 ├── ast.h # AST 与符号表头文件 ├── ast.c # AST 节点构造 ├── Makefile └── tests/ ├── simple.c # 合法样例 └── error.c # 非法样例

Makefile 是进一步避免“手动编译依赖错乱”的关键。下面这份可以直接抄:

CC = gcc CFLAGS = -g -Wall -Wno-unused-function FLEX = flex BISON = bison all: compiler compiler: lex.yy.c parser.tab.c ast.c $(CC) $(CFLAGS) -o $@ lex.yy.c parser.tab.c ast.c -lfl lex.yy.c: lexer.l parser.tab.h $(FLEX) --header-file=lex.yy.h -o lex.yy.c lexer.l parser.tab.c parser.tab.h: parser.y $(BISON) -d -t --verbose -o parser.tab.c parser.y clean: rm -f compiler lex.yy.c lex.yy.h parser.tab.c parser.tab.h parser.output test: all ./compiler tests/simple.c

注意-d让 Bison 生成parser.tab.h,Flex 的lexer.l要包含它才能拿到 token 枚举;-t让 Bison 在语法错误时打印调试信息到 stderr;--verbose生成parser.output。-lfl是链接 flex 库,有的发行版需要改成-ll,具体看你的环境,uname -a或平台差异会体现在这里。

3. 用 Flex 写词法分析器:一个能跑通的最小 .l 文件

词法分析器是整个实验的地基。地基歪了,语法分析器再漂亮也白搭。这一章我给你一个能直接抄的最小样例,然后把正则优先级、最长匹配、编译调试这三件事讲透。

3.1 声明区、规则区与用户代码区:完整样例

一个.l文件由三个%%分隔区域组成:声明区、规则区、用户代码区。下面是支持类 C 语言四则运算和变量声明的最简版本:

%{ /* 声明区:C 头文件与全局变量 */ #include <stdio.h> #include "parser.tab.h" /* Bison -d 生成的 token 宏定义 */ %} %option noyywrap %option yylineno /* 正规定义区 */ digit [0-9] letter [a-zA-Z] id {letter}({letter}|{digit})* ws [ \t\n]+ %% /* 规则区:每条规则 = 正则 { 动作 } */ "int" { return INT; } /* 关键字优先于标识符 */ "return" { return RETURN; } {id} { yylval.name = strdup(yytext); return ID; } {digit}+ { yylval.num = atoi(yytext); return NUM; } "+" { return PLUS; } "-" { return MINUS; } "*" { return TIMES; } "/" { return DIVIDE; } "(" { return LPAREN; } ")" { return RPAREN; } ";" { return SEMICOLON; } "{" { return LBRACE; } "}" { return RBRACE; } "=" { return ASSIGN; } {ws} { /* 跳过空白,不 return */ } %% /* 用户代码区:错误处理与测试入口 */ void yyerror(const char *s) { fprintf(stderr, "line %d: %s\n", yylineno, s); } int main(int argc, char **argv) { if (argc > 1) { yyin = fopen(argv[1], "r"); if (!yyin) { perror(argv[1]); return 1; } } int token; while ((token = yylex())) { printf("token %d, text: %s\n", token, yytext); } return 0; }

逐段解释:%{ %}之间是 C 代码,可以写#include和全局变量。%option noyywrap告诉 Flex 不要生成yywrap()函数,避免链接 undefined reference;%option yylineno让 Flex 自动记录当前行号,yyerror里能打印出行号。

规则区每行是一条产生式:最左侧是正则,花括号里是 C 动作。yytext是 Flex 提供的指针,指向本次匹配的文本。yylval是 Bison 生成的全局联合体,用来把语义值从词法分析器传给语法分析器——这里先记住它,第 4 章会详细讲。

特别提醒:{ws}这条规则必须存在,否则连续空格会让解析器走默认规则。Flex 的默认规则是把无法匹配的字符原样拷贝到stdout,不加{ws}你会在输出里看到一堆被“透传”的空格,干扰所有测试结果。

3.2 正则优先级与最长匹配:三个必调的细节参数

Flex 的匹配规则有两条铁律:多个规则都能匹配同一段输入时,匹配最长者赢;匹配长度相同,排在前面的规则赢。所以"int"必须写在{id}前面,否则int会被匹配成标识符。

下面是词法层最常见的优先级表:

规则写法匹配行为说明
"int"只匹配字面量 int带引号是字面量匹配
{id}匹配字母开头的字母数字串会被最长匹配影响
{digit}+匹配纯数字串浮点数要单独写规则
\/\*匹配/*注释符号需要转义
<<EOF>>输入结束标记不写它,文件末尾行为未定义

三个必调参数:

第一,--nodefault。命令行加这个选项后,Flex 不再为无法匹配的字符生成“原样拷贝”的默认动作,而是调用yyerror报错。中文注释、全角符号、非法字符都会显式暴露,而不是悄悄污染输出。

第二,--header-file=lex.yy.h。生成头文件方便其他模块引用yylex的返回值常量,避免在.l文件里硬编码 token 数字。

第三,--prefix。如果你在一个大项目里嵌入了多个 Flex 生成的扫描器,%option prefix="my_"可以把yylex、yytext这些符号改名成my_lex、my_text,防止符号冲突。课程实验用不上,但做内部工具链时迟早会遇到。

3.3 编译调试:把 .l 变成可执行文件的完整命令链

没有 parser.y 时,我们也可以单独调试词法层。临时给.l文件加一套枚举来替代parser.tab.h:

# 用 --nodefault 生成报错版 flex --nodefault -o lexer.c lexer.l gcc -g -Wall lexer.c -o lexer -lfl echo "int x = 1 + 2;" | ./lexer

-o lexer.c指定输出文件名,避免不同平台默认生成lex.yy.c造成困惑。gcc 的-lfl链接 flex 库,如果你的环境没有 libfl.a,多半是只装了 flex 本体没装 fl 库,Debian 系要sudo apt install libfl-dev。

调试时最有用的是给每个 token 做名字映射,而不是打印数字:

const char *token_names[] = { [INT] = "INT", [RETURN] = "RETURN", [ID] = "ID", [NUM] = "NUM", [PLUS] = "PLUS", [MINUS] = "MINUS", }; printf("token %s, text: %s\n", token_names[token], yytext);

常见报错和处理方法:

报错原因解决
undefined reference toyylval引用了yylval但没有parser.tab.h或%union临时用int yylval;全局变量,或先写 parser.y
unput函数不存在规则里调用unput但 Flex 生成时没启用这通常不是课程实验需要,直接改写规则
yywrapundefined没加%option noyywrap加上这个选项

4. 用 Bison 做语法分析:文法规则、优先级与语义动作

词法层把字符串切成 Token,语法层要把 Token 序列按文法归约成结构。Bison 是 LALR(1) 解析器生成器,它处理左递归文法的能力很强,但二义性和优先级问题需要你显式解决。

4.1 从 BNF 到 Bison 规则:最小 parser.y 骨架

Bison 文件的骨架和 Flex 类似,也是三段式:声明区、规则区、用户代码区。下面的parser.y配合第 3 章的lexer.l可以组成一个能解析简单声明和表达式的完整编译器骨架:

%{ #include <stdio.h> #include <stdlib.h> #include <string.h> #include "ast.h" extern int yylex(void); extern int yylineno; void yyerror(const char *s); %} %union { int num; char *name; struct ast_node *node; } %token <num> NUM %token <name> ID %token INT RETURN %token PLUS MINUS TIMES DIVIDE %token LPAREN RPAREN SEMICOLON LBRACE RBRACE ASSIGN %type <node> program stmt expr %left PLUS MINUS %left TIMES DIVIDE %% program: LBRACE stmt_list RBRACE { $$ = make_block($2); } ; stmt_list: stmt { $$ = $1; } | stmt_list stmt { $$ = make_seq($1, $2); } ; stmt: INT ID ASSIGN expr SEMICOLON { $$ = make_decl($2, $4); } | expr SEMICOLON { $$ = make_expr_stmt($1); } | RETURN expr SEMICOLON { $$ = make_return($2); } ; expr: expr PLUS expr { $$ = make_binary('+', $1, $3); } | expr MINUS expr { $$ = make_binary('-', $1, $3); } | expr TIMES expr { $$ = make_binary('*', $1, $3); } | expr DIVIDE expr { $$ = make_binary('/', $1, $3); } | LPAREN expr RPAREN { $$ = $2; } | NUM { $$ = make_num($1); } | ID { $$ = make_var($1); } ; %% void yyerror(const char *s) { fprintf(stderr, "line %d: %s\n", yylineno, s); }

%union定义了语义值的联合体。%token <num> NUM表示NUM这个终结符的语义值类型是int,%token <name> ID表示ID的语义值是字符串指针。这些类型要和lexer.l里的yylval.name、yylval.num严格对应,否则就是类型错位。

规则区的$1、$2、$3对应规则右侧第 1、2、3 个符号的语义值,$$是规则左侧非终结符归约后的语义值。%type <node> program stmt expr用来声明这些非终结符的$$类型是ast_node *,没有这行,Bison 默认把所有$$当 int,make_binary返回的指针就会被截断,运行时会很难查。

注意stmt_list用的是左递归:stmt_list: stmt_list stmt。LALR(1) 能高效处理左递归;右递归版本stmt: stmt stmt_list会让解析器栈深线性增长,大测试文件会直接爆栈。

4.2 %left、%right 与 %prec:让 0 conflicts 成为可能

文法expr: expr PLUS expr本身就是二义的,a + b + c既可以是(a+b)+c也可以是a+(b+c)。LALR(1) 解析器在这个位置遇到 shift/reduce conflict,默认选择 shift,结果就是右结合,和常规四则运算的左结合相反。

解决方案是给运算符声明优先级和结合性:

%left PLUS MINUS %left TIMES DIVIDE %right UMINUS expr: ... | MINUS expr %prec UMINUS { $$ = make_neg($2); } ;

%left表示左结合,当解析器面对expr PLUS expr PLUS expr时,读入第二个PLUS后优先 reduce 第一个PLUS;%right表示右结合,赋值运算符a = b = c用%right ASSIGN。%prec UMINUS给单目负号单独指定优先级,绕开MINUS作为二元运算符时的规则。

优先级声明的排序决定结合强度:文件里从上到下优先级递增,TIMES DIVIDE在PLUS MINUS之后,所以乘除优先级高于加减。如果声明顺序反过来,a + b * c就会被解析成(a+b)*c,这是 report 里最容易被发现却最不容易被找出原因的 bug。

查看冲突的方式:

bison -t --verbose -o parser.tab.c parser.y grep -n "conflict" parser.output

parser.output会列出每个状态机的冲突位置和涉及的规则。看到State N conflicts: 2 shift/reduce时,去parser.output里定位那几个.所在产生式,十有八九是某个运算符没声明优先级。

4.3 语义动作落地:把归约变成符号表操作

语法树本身不“理解”程序,语义动作才是把树变成可检查对象的地方。这步要做一个ast.h定义节点:

/* ast.h */ #ifndef AST_H #define AST_H typedef enum { AST_BINOP, AST_NUM, AST_VAR, AST_DECL, AST_RETURN, AST_BLOCK } NodeKind; typedef struct ast_node { NodeKind kind; int num; /* AST_NUM 的值 */ char *name; /* AST_VAR / AST_DECL 变量名 */ char op; /* AST_BINOP 运算符 */ struct ast_node *left, *right; struct ast_node *next; /* AST_BLOCK 的语句链表 */ } ast_node; ast_node *make_num(int n); ast_node *make_var(char *s); ast_node *make_binary(char op, ast_node *l, ast_node *r); ast_node *make_decl(char *name, ast_node *init); ast_node *make_seq(ast_node *first, ast_node *second); #endif

对应的节点构造函数在ast.c里,例如make_binary:

ast_node *make_binary(char op, ast_node *l, ast_node *r) { ast_node *n = malloc(sizeof(ast_node)); n->kind = AST_BINOP; n->op = op; n->left = l; n->right = r; return n; }

语义动作在 Bison 里就是归约时执行的函数调用,例如expr PLUS expr归约成make_binary('+', $1, $3)。同一时刻,Bison 把$1和$3两个子树合并到新节点,最终在program归约时得到整棵 AST。

这里必须提一个常见坑:yylval.name = strdup(yytext);里的strdup不能在make_var里再丢一次引用。如果make_var内部又strdup一次,AST 里保存的指针就和 token 缓冲区完全分离,不会出现“yytext 被覆盖导致变量名变成下一个 token”的玄学问题,但也意味着每次make_var都泄漏一次内存。课程实验不查内存泄漏还好,做内部工具就要注意,报告里可以写“当前实现未做内存释放”作为已知限制,比被 review 出来强。

5. 编译原理实验报告必踩的五个坑:现象、原因与解决

这一章是整份.doc里最有含金量的部分。这五条是我自己和不少同行在跑课程设计时反复踩过的,每一条都可以直接搬进报告的“问题分析”小节。

5.1 yylval 里的垃圾值:乱码变量名的真凶

现象:语法分析器运行不报错,但生成的 AST 里变量名是乱码,数字值变成巨大的随机数。打印yylval.num,每次运行结果都不一样。

原因:flex 规则只写了return ID;,没有在返回前给yylval.name赋值。yylval是 Bison 生成的全局联合体,不会自动清零,上一次 token 留下的字节会被当成下一次的语义值。

解决:每条涉及语义值的规则都显式赋值。yylval.name = strdup(yytext);和yylval.num = atoi(yytext);一个都不能少。另外,yytext指向的是 Flex 内部缓冲区,下一次yylex()调用会被覆盖,AST 里保存变量名必须strdup,直接存yytext指针的话,整个 AST 最后都是同一个字符串。

5.2 0 conflicts 是假象:运行时翻车的 shift/reduce

现象:bison -d编译通过,没看到冲突警告。但输入a + b * c时结果总偏一边,或者错误报告定位到奇怪的行号。

原因:Bison 默认把 shift/reduce conflict 当作 warning 而不是 error,它会在冲突处自动选择 shift。所以即使出现冲突,生成代码也能跑,但语法树的优先级完全不是你想的样。

解决:编译时主动看输出里的conflicts: N提示,用bison --verbose生成parser.output,检查冲突所在状态。给每个运算符按优先级从低到高声明%left和%right。如果你在 CI 里跑实验,可以加-Werror把冲突升级成错误,一劳永逸。

5.3 注释规则写错:词法分析器卡死

现象:输入里出现/*后,程序既不报错也不返回,CPU 占用拉满。

原因:规则里写了"/*"想匹配注释开始,但 Flex 最长匹配会一直向后搜索*/,如果文件没有闭合注释,它会反复扫描整个缓冲区,表现为死循环。

解决:用 Flex 的状态机做注释。进入注释后用%x COMMENT新状态匹配,遇到*/再回到初始状态。给%x COMMENT里的.和\n加兜底规则,并在<<EOF>>规则里报“注释未闭合”。这样词法分析器永远不会无限循环,报告里还能写“错误恢复”加分。

%x COMMENT %% "/*" { BEGIN COMMENT; } <COMMENT>"*/" { BEGIN INITIAL; } <COMMENT>. { /* 注释内容 */ } <COMMENT>\n { } <COMMENT><<EOF>> { yyerror("unterminated comment"); return 0; }

5.4 报告 .doc 和代码不同步:最容易被扣分

现象:代码里已经支持取模运算,报告里的支持语法表没有;报告写了错误恢复,测试文件里却找不到任何非法输入样例。答辩时问到一个输出细节,代码能跑但报告对不上。

原因:大多数人先写代码后补报告,时间一长就记混;还有人把报告当模板,不同实验共用一份,造成文不对题。

解决:把git log --oneline放进报告附录,每个功能点标注对应 commit。报告里每张表都从测试用例反推,先列出输入样例和期望输出,再写代码。提交前做一次回归,用报告里的测试用例逐条跑,哪怕只是echo "int a = 1;" | ./compiler这种冒烟测试。这条在课程评分里掉分最冤。

5.5 EOF 没处理:合法程序最后一行段错误

现象:合法程序也能跑,但最后一行解析完就段错误。gdb 里yyparse调用栈有个空指针。

原因:Flex 遇到文件末尾返回 0,Bison 把 0 当作输入结束标记。但如果文法要求程序必须以}结束,而你的词法规则把最后的}吞进了空白规则,Bison 会一直等下一个 token,顶层规则没匹配到就报错退出。

解决:给词法分析器正常返回 0,文法的顶层规则不要要求多个结束终结符。调试时设置export YYDEBUG=1,Bison 会打印每次状态转移,EOF 相关的问题一眼就能定位。

6. 把实验报告.doc 变成编译原理工具箱:三个进阶动作

跑通词法和语法只是第一步,真正让一份报告从“及格”变“优秀”,在于语义检查和错误恢复。这里给三个可以直接落地的进阶动作。

6.1 用符号表驱动语义检查:从 AST 到“未定义变量”报错

AST 只表达了语法结构,语义检查要在 AST 上做一次遍历。最基础也最有说服力的是检查变量未定义。用链表符号表实现:

typedef struct sym_entry { char *name; int type; struct sym_entry *next; } sym_entry; sym_entry *symtab = NULL; sym_entry *sym_lookup(const char *name) { for (sym_entry *p = symtab; p; p = p->next) if (strcmp(p->name, name) == 0) return p; return NULL; } void sym_insert(const char *name, int type) { sym_entry *e = malloc(sizeof(sym_entry)); e->name = strdup(name); e->type = type; e->next = symtab; symtab = e; }

在 Bison 的动作里,make_decl调用sym_insert,make_var调用sym_lookup查不到就yyerror。这一步把实验报告从“能识别语法”升级到“能诊断错误”,答辩时最有说头。

6.2 报告里值得认真写的三件事:状态图、冲突表、测试矩阵

写报告时不要只堆代码,要写能证明你“真正理解和排错”的内容。

第一,词法状态转换图。不要截图代码,画一张状态表:当前状态、输入字符、下一状态、动作。哪怕只用 Markdown 表格也能写清楚。

第二,冲突消除前后对比。把bison --verbose生成的parser.output里冲突数目的变化记下来,截图保留 before(N conflicts)和 after(0 conflicts)两行,附上你加的%left声明。这是答辩时最扎实的成果证据。

第三,测试用例矩阵。列出用例名、输入片段、期望结果、实际结果、覆盖的语法点。参见第 5 章的思路:

用例输入片段期望结果实际结果覆盖点
T01 变量声明int a = 1;输出声明节点通过声明语句
T02 加法优先级a = 1 + 2 * 3;*先算通过运算符优先级
T03 未定义变量x = 1;line 1: undefined variable x通过语义检查
T04 注释未闭合/* abcline 1: unterminated comment通过错误恢复

我最早交实验报告时习惯先写代码再补文档,结果答辩时被问到一个输出细节完全答不上来;后来把顺序反过来,先让每个测试用例跑通,再按测试矩阵补报告,效率高很多。如果你也在补这门课,或者想给自己的工具链加一个能诊断语法的入口,按这个顺序跑一次 Flex+Bison,会比照着教材空啃省力很多。希望帮到你。

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

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

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

立即咨询