简介:一份面向编译原理课程设计的完整报告资源,适合计算机专业学生、入门开发者理解编译器前端整体流程。内容围绕简单文法编译器前端展开,覆盖词法分析、基于递归下降子程序法的语法与语义分析、四元式中间代码生成,并扩展了常量、数组、if-else、while 等文法,同时包含递归子程序栈跟踪与后端目标代码生成模块,能够帮助读者串联从源码到中间代码再到目标代码的完整编译链路。压缩包内为 1 个 docx 文档,大小约 381KB,报告包含设计任务、算法与数据结构、程序流程图、实验结果及分工说明,结构清晰便于课程设计参考或复习编译原理知识点。已有 694 人学习下载,适合需要完成同类课设、撰写课程报告或梳理编译器前端实现思路的学习者。
1. 一个“简单”文法编译器前端:先想清楚你要产出什么再动手
很多人听到“编译器前端”四个字,第一反应是龙书、自动机、正则表达式,仿佛是个需要翻越几座大山才能完成的工程。但实际上,如果你只是给一门小脚本语言做词法分析和语法分析,把源代码变成一棵能验证的抽象语法树,用 flex 和 bison 这套经典工具链,一个下午就能跑通全流程。一个简单文法编译器前端的设计,核心不是写代码,而是回答两个问题:Token 怎么切、文法怎么归约。这篇我按自己给一个支持变量声明、赋值、整数表达式和打印语句的小语言做前端的思路来讲,从 Token 设计、文法设计、符号表一路讲到错误恢复和 REPL 调试。适合正在学编译原理的学生,也适合想给自己的配置格式写解析器、又不想手撸状态机的工程师。先立住边界:这里说的“前端”,指语义分析之前的词法分析、语法分析和符号表,目标不是生成可执行文件,而是产出一棵结构正确的 AST,以及一个能把树打印出来给你看的验证工具。
2. 词法分析设计:用 flex 把源文件切成干净的 Token 流
2.1 flex 文件三段式结构与 Token 类型规划
flex 的核心是一个.l文件,分三段:定义段(%{ %}里放 C 代码和宏,外面放正则缩写)、规则段(%%之后,模式加动作)、用户代码段(第二个%%之后,放 C 函数)。我习惯把 Token 类型放在 bison 的.y文件里用%union定义,flex 这边只负责 include bison 生成的头文件,然后在每个动作里 return 对应的 Token 编号。
下面是我给一个小语言写词法器时的完整 flex 文件,支持let、print、四则运算、括号和行注释:
%{ #include <stdio.h> #include <stdlib.h> #include <string.h> #include "mini_lang.tab.h" extern int yylineno; %} digit [0-9] letter [a-zA-Z_] ident {letter}({letter}|{digit})* %option noyywrap %option yylineno %% "let" { return LET; } "print" { return PRINT; } "+" { return PLUS; } "-" { return MINUS; } "*" { return STAR; } "/" { return SLASH; } "(" { return LPAREN; } ")" { return RPAREN; } ";" { return SEMI; } "=" { return ASSIGN; } {ident} { yylval.str = strdup(yytext); return IDENT; } {digit}+ { yylval.num = atoi(yytext); return INT; } [ \t\r]+ { /* 跳过空白 */ } "//"[^\n]* { /* 跳过行注释 */ } \n { /* 行号由 yylineno 选项自动维护 */ } . { fprintf(stderr, "line %d: illegal character \"%s\"\n", yylineno, yytext); } %%这段代码里有几个值得说透的点。第一,关键字的规则必须写在标识符规则前面,因为 flex 的匹配策略是:匹配到最长串的规则优先,长度相同则先出现的规则优先。如果把{ident}放在"let"之前,let会被识别成 IDENT,语法分析直接翻车。第二,yylval是一个 union,成员由%union定义,赋值前要想清楚当前 Token 的类型。第三,strdup分配的内存要记得在节点销毁时释放,否则跑一个长脚本就是一场内存泄漏灾难。
Token 类型规划我遵循两条原则:每个运算符是独立 Token,紧凑且好查;关键字单独成 Token,而不是在标识符阶段做字符串比较。这样文法规则写出来接近自然语言,bison 处理起来也干净。
2.2 关键字、标识符与整数字面量的正则写法与匹配优先级
flex 的正则写法和 awk 类似,字符类、转义、重复操作符都有。一个容易踩的细节是{ident}展开后是{letter}({letter}|{digit})*,中间不能有空格,否则 flex 会把空格当成模式的一部分,轻则匹配不到,重则直接报错。
另一个容易埋雷的点是负数的处理。负号应该作为 MINUS 运算符交给文法,而不是在词法里把-123合并成一个 INT。如果你试图在词法层识别负数,后面处理1-2时会非常痛苦——lexer 不应该根据上下文决定 Token 内容。我见过有同学把负数在词法层处理,结果表达式优先级全乱,1-2*3被解析成1-(2*3)都算好的,更离谱的是1--2直接词法报错。
整数字面量用{digit}+配合atoi转换就够用。但如果你的目标平台是 64 位,我建议把yylval里的数字字段定义成long long,再用封装函数做转换:
long long convert_int(const char *s, int *ok) { char *end = NULL; long long v = strtoll(s, &end, 10); if (end == s || *end != '\0') { *ok = 0; return 0; } *ok = 1; return v; }atoi对溢出是静默的,123456789012345678转出来的结果你根本不知道为什么不对。用strtoll加ok标志,溢出时返回失败,由语法层决定是报错还是走错误恢复。
2.3 行号列号维护与非法字符的兜底处理
行号用%option yylineno自动维护,列号就得自己算。常见做法是维护一个全局列号变量,在每次动作执行前通过YY_USER_ACTION宏更新。配合 bison 的%locations,可以拿到精确的yylloc:
#define YY_USER_ACTION \ do { \ yylloc.first_line = yylineno; \ yylloc.first_column = yylpos; \ yylpos += yyleng; \ yylloc.last_line = yylineno; \ yylloc.last_column = yylpos; \ } while(0)这里yylpos是全局 int,每次匹配完累加yyleng。注意换行规则里要手动把yylpos归零,否则你会看到报错提示里行号在变、列号一直疯涨。如果你不想启用%locations,也可以把这些信息塞进yylval,但那样每个 Token 都要携带行号列号,语义值结构会膨胀,不划算。
非法字符我选择在.规则里打印错误而不是直接 return。这样词法分析不会因为一个未知字符停掉整个流程,语法阶段可以继续走错误恢复。但要在头文件里设一个错误标志位,语法分析结束后检查它,决定整个编译流程是否终止,避免后面冒出一连串莫名其妙的语法错误。
3. 语法分析设计:用 bison 把 Token 流组装成 AST
3.1 文法优先级与结合性:从二义性到 %left 和 %right
词法负责切 Token,语法负责按文法归约。bison 用的是 LALR(1),本质是一个移进-归约自动机。最经典的坑是表达式二义性:1+2*3如果文法写成expr: expr PLUS expr,既可以归约成(1+2)*3,也可以归约成1+(2*3),bison 会报 shift/reduce 冲突。解决方式有两种,一是把文法分层写成 expr、term、factor,二是直接用%left%right声明优先级。
我推荐先写分层文法,直观而且扩展性好。基础表达式文法如下:
%left PLUS MINUS %left STAR SLASH %% expr : expr PLUS term { $$ = make_binop('+', $1, $3); } | term { $$ = $1; } ; term : term STAR factor { $$ = make_binop('*', $1, $3); } | factor { $$ = $1; } ; factor : INT { $$ = make_int($1); } | IDENT { $$ = make_var($1); } | LPAREN expr RPAREN { $$ = $2; } ;%left PLUS MINUS表示加号和减号左结合且处于同一优先级,%left STAR SLASH声明乘除优先级更高。%left和%right的区别在于结合方向:2-3-4左结合归约为(2-3)-4,右结合则归约为2-(3-4)。赋值运算符如果要做右结合,就写%right ASSIGN。优先级声明的次序从上到下递增,写反了结果完全反掉。我犯过把乘法写在加法下面的错误,调试时看着 AST 里1+2*3建成了BINOP(+): INT(1), BINOP(*): INT(2), INT(3),但实现里面优先级低了,实际归约出来加在乘号上面。
如果要做单目负号,需要在%union里加一个 UMINUS 标记并用%prec指定:
%right UMINUS factor : MINUS factor %prec UMINUS { $$ = make_neg($2); } ;%prec UMINUS告诉 bison,这条MINUS factor规则的优先级按 UMINUS 算,而不是按 MINUS 算。这样-2*3会被解析成(-2)*3还是-(2*3),就由声明的次序决定了。
3.2 语义动作与 AST 节点构建:$$、$1、$2 的对应关系
bison 的每条文法规则可以跟一段 C 代码,归约时执行。$1、$2对应规则右侧第几个符号的属性值,$$是左侧非终结符的属性。属性类型由%union加%type声明。如果你的 union 里有int、char *、AstNode *,那么%type <node> expr term factor告诉 bison 这些非终结符的语义值是AstNode *。
语义动作里我建议只做一件事:构造 AST 节点。不要在里面求值、做类型检查,更不要打印中间结果。原因是归约顺序对用户不可见,底层是 LALR 自动机,不是你写规则时的视觉顺序,所有副作用都会以你没想到的顺序执行。求值和类型检查放到 AST 建好以后单独遍历,职责清晰,调试也方便。以make_binop为例:
AstNode *make_binop(char op, AstNode *left, AstNode *right) { AstNode *n = malloc(sizeof(AstNode)); if (!n) { fprintf(stderr, "out of memory\n"); exit(1); } n->kind = NODE_BINOP; n->op = op; n->left = left; n->right = right; return n; }构造函数的名字和参数顺序要固定,make_binop(op, left, right)从写完第一天起就没改过。这样语义动作里$$ = make_binop('+', $1, $3);一眼就能看懂哪个参数是什么。
3.3 shift/reduce 与 reduce/reduce 冲突的排查方式
bison 默认在冲突时选移进,所以很多冲突不会直接报错,而是安静地产生了错误的文法。排查手段是加-v参数生成.output文件,里面描述每个状态机的状态、当前可能的移进和归约项。我看这个文件的习惯是先搜conflicts:那行,看到1 shift/reduce conflict后,再定位到具体状态,问题通常集中在某个状态既能归约某非终结符又能移进某运算符。
reduce/reduce 冲突更严重,代表文法存在真正的二义性,bison 会默认选先出现的规则,结果往往不符合直觉。我遇到过最典型的场景是 statement 既能归约成 expr 也能归约成 assign,而let x = expr里的 expr 又允许 IDENT,导致let x = y在归约 y 时,不知道该把 y 归约成 expr 还是 assign 的一部分。这种问题靠%prec解决不了,只能改写文法,让两条产生式的前缀不相交,或者调整词法层面的 Token 设计。
4. 符号表与前端输出:让语法树带上有意义的信息
4.1 符号表的作用域设计:链表式与作用域栈
前端除了产出 AST,还要处理符号表。简单文法里我遇到的第一个选择是:用哈希表还是链表。标准答案是作用域栈加每层一个哈希表,但简单文法用链表就够了。链表的好处是实现简单、作用域进出就是压栈弹栈,缺点是查找是 O(n),小语言无所谓。
我将符号表设计成三层操作:enter_scope创建新作用域,exit_scope销毁当前作用域,declare_var和lookup在链上从顶层往下找。先记录符号名,类型信息留到后面扩展:
typedef struct Symbol { char *name; int type; struct Symbol *next; } Symbol; typedef struct Scope { Symbol *head; struct Scope *parent; } Scope; void enter_scope(void); /* 压栈 */ void exit_scope(void); /* 弹栈 */ int declare_var(const char *name, int type); /* 返回0表示重复声明 */ Symbol *lookup(const char *name); /* 未找到返回NULL */声明检查放在 bison 语义动作里做,而不是在 AST 遍历阶段做。原因是归约stmt: LET IDENT ASSIGN expr SEMI时,IDENT 和 expr 都已经归约完成,此时声明变量,后面同一作用域里再引用它,lookup才能查到。如果放到遍历阶段,你还要在树里维护作用域信息,复杂得多。
4.2 AST 结构体设计与节点类型表
AST 节点我倾向统一结构体加 kind 字段,而不是每种节点一个结构体。统一结构体的好处是遍历代码好写,坏处是浪费一点内存,小前端无所谓。节点字段:kind 区分类型,op 存运算符字符,num 存整数值,name 存变量名,left 和 right 挂子节点。这样make_int、make_var、make_binop这些构造函数只需要分配内存填字段。
typedef enum { NODE_INT, NODE_VAR, NODE_BINOP, NODE_ASSIGN, NODE_PRINT } NodeKind; typedef struct AstNode { NodeKind kind; int num; char *name; char op; struct AstNode *left; struct AstNode *right; } AstNode;节点类型和文法规则的对应关系,我习惯写进代码注释。NODE_BINOP 对应 expr、term、factor 三条归约规则,op 字段区分加减乘除;NODE_ASSIGN 对应LET IDENT ASSIGN expr SEMI;NODE_PRINT 对应PRINT expr SEMI。这种映射表跟着代码走不会过时,单独维护文档写不了几次就没人看了。
构造函数里有个细节:我用小写字符'p'代表加号而不是'+',因为'+'在后面打印 AST 时容易和字符串拼接混淆。其实用什么符号都行,关键是全项目统一。
4.3 把 AST 打印成可读的树:前端验证的第一道工具
AST 打印是前端写完以后第一个要做的验证工具,没有它你只能靠 yydebug 看归约过程,不仅啰嗦而且难读。打印函数思路很直接:递归遍历节点,每个节点带缩进。我统一约定先打印左子树再打印右子树,但赋值节点和打印节点只有一个子节点,需要特殊处理:
void print_ast(AstNode *node, int depth) { if (!node) return; for (int i = 0; i < depth; i++) printf(" "); switch (node->kind) { case NODE_INT: printf("INT(%d)\n", node->num); break; case NODE_VAR: printf("VAR(%s)\n", node->name); break; case NODE_BINOP: printf("BINOP(%c)\n", node->op); break; case NODE_ASSIGN: printf("ASSIGN\n"); break; case NODE_PRINT: printf("PRINT\n"); break; } print_ast(node->left, depth + 1); print_ast(node->right, depth + 1); }输入let x = 1 + 2 * 3; print x;,期望输出是这样:
ASSIGN VAR(x) BINOP(+) INT(1) BINOP(*) INT(2) INT(3) PRINT VAR(x)如果打印出来乘法节点跑到外面去了,说明优先级声明写错;如果左子树和右子树颠倒,说明语义动作参数传反。树的形状对了,前端就完成了一大半。
5. 编译器前端避坑实录:五个最容易翻车的环节
5.1 关键字被识别成标识符:flex 匹配优先级与规则顺序
现象:let x = 1;报语法错误,错误位置在 IDENT 而不是 LET。
原因:flex 文件里{ident}规则写在"let"前面。flex 在匹配长度相同的情况下,先出现的规则优先,let被归为 IDENT。
解决:把关键字规则放在{ident}之前。更隐蔽的同类问题是标识符前缀,let_abc因为匹配长度更长,不会被"let"抢占,这属于最长匹配规则,不算冲突。但如果你把{ident}写在了关键字前面,连let都受影响,是最容易自查的初级错误。
5.2 开启 noyywrap 的链接错误与运行时崩溃
现象:链接阶段报undefined reference to 'yywrap',或者程序运行到文件末尾直接崩溃。
原因:flex 生成的扫描器读到 EOF 时会调用yywrap。默认情况下yywrap需要外部定义,旧习惯是在用户代码段自己写一个函数。但如果你同时开了%option noyywrap,两份定义冲突,要么链接失败,要么行为异常。
解决:要么去掉%option noyywrap并自己写int yywrap(void) { return 1; },要么保留%option noyywrap且不写该函数。推荐后者,flex 会自动处理 EOF,少写一个函数少一个坑。如果你的程序要处理多个输入缓冲,那应该走yywrap加YY_NEW_FILE的老路,那是另一种场景。
5.3 $$ 类型默认 int 导致指针截断与段错误
现象:AST 打印全是乱码,或者访问node->left直接段错误。
原因:语义动作里$$ = make_binop(...)返回AstNode *,但%type没声明 expr、term、factor 的语义类型,bison 按默认 int 处理。64 位平台上指针被截断成 32 位整数,后面解引用必挂。
解决:%type <node> expr term factor stmt program逐个声明。检查方法是在.y文件里搜每个非终结符,确认都出现在%type行。这是我调得最久的一个坑,表面像 C 指针问题,实际是 bison 类型声明问题。
5.4 语法错误后死循环:错误恢复的配置与 yyerrok 的作用
现象:输入一个错误语句后不退出,刷几百行语法错误然后卡死。
原因:yyerror返回后 bison 进入错误恢复模式。如果文法里没有 error token,且yyerror里没有正确丢弃输入,扫描器会在同一个错误 Token 上反复尝试。
解决:在文法里增加错误恢复规则:
stmt : error SEMI { yyclearin; yyerrok; } ;遇到错误时丢弃到分号处再继续。yyerrok表示错误已经处理完,恢复错误标志;yyclearin丢弃当前 lookahead。还有一个关键点:yyerror函数里不要调用exit,尤其是不要每遇到一个错误就退出一次。错误恢复的目标是报完一条错误还能继续,而不是让错误连环爆炸。
5.5 超前扫描覆盖 yylval:lookahead 的语义值丢失
现象:let x = 1; print x;报 x 未声明,但单步调试发现 IDENT 的yylval.str是空指针。
原因:bison 在移进前要读 lookahead,如果所有动作都依赖yylval,lookahead 扫描可能把上一个 Token 的yylval覆盖。具体来说,归约stmt: LET IDENT ASSIGN expr SEMI时,expr 归约完成后 bison 读下一个 lookahead,这个读操作修改了yylval,再取 IDENT 的字符串时已经拿到被覆盖的值。
解决:语义动作里立刻strdup保存字符串,不要延迟到后续规则再取。yylval是共享的全局变量,不是你的私有字段,拿到就复制才是正解。我写语义动作的固定习惯是:$$ = make_assign(strdup($2), $4);,从来不用$2去存一个别名引用。
6. 进阶:把前端封装成可交互 REPL,用 AST 打印验证文法设计
最后这一步不是锦上添花,而是我每次改文法时的固定动作:把解析器封装成一个 REPL,输入一行、解析一行、打印 AST 一行。这个工具比任何单元测试都能更快暴露文法问题,因为你可以随手敲任意的表达式看树的形态。
REPL 的核心是把输入从文件流改成字符串缓冲。flex 提供了yy_scan_string,调用后 lexer 从字符串里取字符,不再读yyin。每次循环用yy_scan_string创建新缓冲,解析完用yy_delete_buffer释放,就不会出现上次 EOF 状态残留导致第二次输入无响应的问题:
char buf[1024]; while (1) { printf("mini> "); if (!fgets(buf, sizeof(buf), stdin)) break; YY_BUFFER_STATE bs = yy_scan_string(buf); yyparse(); yy_delete_buffer(bs); }另一个实用技巧是开 yydebug。在调用yyparse前设置extern int yydebug; yydebug = 1;,bison 会把整个移进回头、归约过程打印到 stderr,每行带状态编号和动作。输出非常啰嗦,但配合.output文件里的状态表,能精确定位冲突出在哪个状态。我经常先用一句话的输入跑 debug,比如let x=1,看它在哪一步停滞。这种黑匣子式的调试法,比对着文法发呆高效得多。
错误定位也要跟上。启用%locations加YY_USER_ACTION维护yylloc后,错误信息格式我统一成[line:col] syntax error near token X。这个格式在终端里醒目,也方便编辑器跳转。REPL 的完整流程就是读一行、词法分析、语法分析、AST 落库、打印树、循环,任何一环出错都不影响下一行输入。
这几年做解析器,我总结的教训是:AST 打印永远放在最前面。文法改动了,第一件事不是跑测试断言,而是打印 AST。树的形状对不对,比任何测试都直观。如果你正在为文法对错发愁,先写一个 AST 打印器,把输入喂进去看一眼树,问题多半就明朗了。希望帮到你。
本文还有配套的精品资源,点击获取