类C语言编译器课程设计:从Flex/Bison到三地址码的完整实践

发布时间:2026/10/3 1:47:26
类C语言编译器课程设计:从Flex/Bison到三地址码的完整实践 简介《编译原理》课程设计所需的类C语言编译器完整项目面向计算机相关专业学生覆盖从代码编辑到目标代码生成的完整编译链路。程序提供图形界面与代码编辑器支持行号高亮、关键字与变量名着色、注释区分和自动补全也可完成撤销、重做等常规编辑并配有常用快捷键编译部分包含词法分析、LR(1)语法分析、语义分析、中间代码优化和汇编代码生成在日志中输出符号表、语法树等关键信息。压缩包共104个文件以cpp源码、h头文件为主体附带Qt运行所需的dll动态库、qm翻译文件、界面ui与png图标素材以及exe可执行程序、txt文档说明整体约25.63MB。已有582人学习下载源码模块划分清晰配套文档和工程文件齐全读者可直接运行查看各阶段结果也便于课程设计参考、功能扩展与编译原理实验对照。1. 类C语言编译器课程设计这可能是你大学四年最值得做的一次如果你正在为一门叫《编译原理》的课程设计发愁题目写着“类C语言编译器源代码文档说明”那你接下来两周的真实处境很可能是这样讲义里词法分析、语法分析、语法制导翻译的部分全看懂了往下一翻是清华大学出版社《编译原理第3版》教材上的状态图和文法示例回到自己电脑前却不知道第一行代码该从哪里写。这个课程设计的核心要的并不是一个工业级编译器而是把课本里那条流水线完整走一遍词法、语法、语义、中间代码最后交出一份能跑的源代码和一份能说清楚的文档说明。它适合两类人一类是必须完成学分、想稳稳过验收的在校生另一类是打算把编译原理当项目经历写进简历的求职或考研人群。下文按我实际做过、也帮人验收过的路线来讲从工具选型拆到具体避坑。2. 先定架构再写字类C编译器的模块划分与工具链选型2.1 Flex/Bison和手写递归下降怎么选看你的子集有多“类”拿到题目的第一反应通常不是写代码而是纠结路线手写还是用工具这个选择题其实由你的类C子集规模决定。手写方案是词法分析器用一个DFA状态机自己实现语法分析器按文法写递归下降。它的好处是代码完全属于自己答辩时老师问每个函数都能对答如流简历里也更好讲代价是表达式优先级、悬空else这些坑要全部自己处理一个加法结合性写错就是一片syntax error。手写方案适合子集极简单、表达式只有加减乘除、语句只有if和while的情况代码量大约1200到1500行就能收工。工具方案是用Flex生成词法分析器、Bison生成LALR语法分析器语义分析和中间代码用手写C代码。它的优势是词法和语法这两层由工具保证正确你只需要专注在AST构建、符号表和中间代码上这也是绝大多数编译原理实验的通行做法。代价是你得额外掌握flex和bison的语法以及一份能解释清楚“Bison的shift/reduce冲突为什么这样解决”的文档说明。我一般的选择是后者FlexBison做前端C语言手写符号表和中间代码生成。理由是课程设计时间有限手写在词法和语法上消耗的调试时间往往超过预期而Flex和Bison把这两层的工程量压缩到两个文件里剩下的时间花在语义检查和三地址码上性价比更高。验收时老师也更容易认可因为模块边界清楚。动手前先检查环境参考命令如下flex --version bison --version gcc --version make --version以Ubuntu 20.04或22.04为例Flex通常在2.6.xBison在3.xgcc在11或12。缺失时安装依赖sudo apt install flex bison build-essential注意Bison 3.x和2.x在部分语法上有差异老教材里的%error-verbose和位置跟踪写法需要微调后面会专门讲。2.2 模块划分与数据流词法、语法、语义、中间表示的四级流水类C编译器可以按教科书里的四个阶段划分模块每个阶段产出一个明确的数据结构前一个的输出就是后一个的输入。模块边界划得越清楚出问题时越容易定位文档说明也越好写。我通常划分成六个文件职责如下表文件职责对外交付lexical.l词法分析字符流到Token流Token序列及行号syntax.y语法分析Token流到语法树AST根节点ast.h / ast.cAST节点定义、构建、打印、释放提供给语法分析和语义分析的统一结构symtab.h / symtab.c符号表作用域链、类型、地址分配符号查询与插入接口codegen.c语义检查与三地址码生成四元式序列main.c组织编译流程、错误统计、入口可执行文件数据流是典型的四级流水源文件字符流进入lexical.l产出带行号的Token序列Token送入Bison定义的文法规则每条规则的动作里构建AST节点AST在语义分析阶段被遍历同时查符号表做类型检查最后再遍历一次AST生成三地址码输出到文件或屏幕。为什么把AST作为前后端的分界因为语法分析和语义分析对数据的需求不同语法分析关心“句子符不符合文法”语义分析关心“名字从哪里来、类型对不对、地址怎么分配”AST把这两者解耦。Bison动作里只负责构建AST不做类型判断类型判断放到对AST的独立遍历中遍历代码可以递归、可以迭代出现问题也好单独测试。main.c里要维护一个全局错误计数词法错误、语法错误、语义错误分别计数三者的数量之和决定最终是否生成中间代码。常见的做法是在main.c里依次调用yyparse()完成语法分析成功后调用sema_check(ast_root)做语义分析再调用gen_code(ast_root)输出三地址码。2.3 把类C子集的定义落成文档能编译什么比怎么编译更重要课程设计最容易翻车的地方不是代码写不出来而是代码写完了文档说明里的“支持范围”和代码实际行为对不上。所以开工前先把子集规格写死后面写代码只是把这个规格兑现。一份稳妥的类C子集通常这样定义够展示编译原理主要知识点又不会把工作量推到失控维度支持内容说明基本类型int、float、voidvoid只用于函数返回类型语句表达式语句、if/else、while、for、return、复合语句不要求break和continue表达式四则运算、取余%、关系比较、逻辑与或非、一元负号、括号支持赋值表达式结果可作右值函数最多8个参数支持递归调用不要求指针传参数组可选一维数组如果题目只要求基础可明确不支持明确不支持的包括指针、结构体、字符串、switch、类型强制转换、位运算、自增自减。为什么把自增自减也砍掉因为i作为表达式的值和副作用管理会引入先序/后序求值问题在中间代码阶段要额外生成一条取左值的指令课程设计规模下收益有限。文档说明里应该包含一份BNF文法、关键字列表、错误信息格式三个部分。BNF可以对照清华大学出版社第三版教材第二章的词法分析和第三章的语法分析来写不必重新发明记号集合关键字列表要跟lexical.l里的规则完全一致错误信息格式约定为“文件名:行号: 错误描述”例如test.c:3: undeclared variable x。这个格式后面所有错误输出都要遵守验收时老师一眼就能看出你的错误处理是有设计的。目录结构建议单独建一个doc目录把文档说明和源代码分开便于提交时打包。按下面命令初始化工程mkdir -p compiler-pj/src compiler-pj/doc compiler-pj/testcases/expect cd compiler-pj/src touch lexical.l syntax.y ast.h ast.c symtab.h symtab.c codegen.h codegen.c main.c3. 用Flex和Bison把类C变成语法树词法、文法与最小构建命令3.1 记号设计与Flex规则保留字、标识符、整数与注释词法分析这一层要做的就是把字符流切分成有意义的记号并给每个记号附带语义值。类C语言需要的记号集合并不大保留字10个左右、运算符20个左右、标识符和常量各一类再加注释和空白跳过规则。一个可用的lexical.l最小实现如下这个文件直接推进Flex处理%{ #include stdio.h #include stdlib.h #include string.h #include syntax.tab.h extern int yylineno; %} %option noyywrap DIGIT [0-9] IDENT [a-zA-Z_][a-zA-Z0-9_]* FLOAT {DIGIT}.{DIGIT}* %% int { return INT; } float { return FLOAT; } void { return VOID; } if { return IF; } else { return ELSE; } while { return WHILE; } for { return FOR; } return { return RETURN; } {IDENT} { yylval.sval strdup(yytext); return IDENTIFIER; } {DIGIT} { yylval.ival atoi(yytext); return INTEGER_CONSTANT; } {FLOAT} { yylval.fval atof(yytext); return FLOAT_CONSTANT; } { return LE; } { return GE; } { return EQ; } ! { return NE; } { return AND; } || { return OR; } { return PLUS; } - { return MINUS; } * { return STAR; } / { return SLASH; } % { return PERCENT; } { return LT; } { return GT; } ! { return NOT; } { return ASSIGN; } ( { return LPAREN; } ) { return RPAREN; } { { return LBRACE; } } { return RBRACE; } ; { return SEMICOLON; } , { return COMMA; } [ \t] ; \n { yylineno; } //[^\n]* ; /*([^*]|\*[^*/])*\*/ ; . { fprintf(stderr, line %d: unexpected character %s\n, yylineno, yytext); yyerror(unexpected character); } %% int yywrap(void) { return 1; }这段规则里有两个关键顺序问题要讲清楚。第一保留字规则必须写在标识符规则前面。Flex的匹配策略是最长匹配优先相同长度时取规则文件中先出现的那条如果把{IDENT}放在int前面那么int永远被识别成标识符关键字就失效了。第二双字符运算符、必须写在单字符运算符前面否则输入时Flex会贪婪地匹配到和两个Token语法层直接报错。yylval的类型由Bison的%union决定这里yylval.sval用来传标识符名字符串yylval.ival传整数常量yylval.fval传浮点常量。strdup分配的内存归AST节点管理后面在AST释放时统一free不要在主程序和Flex规则里各free一次。注释跳过规则里的/*([^*]|\*[^*/])*\*/是教科书上的经典写法它覆盖了多行注释如果只写/*.**/会遇到.不匹配换行的问题注释跨行时词法分析直接终结。行号计数只对\n单独递增不要在[ \t]那条规则里同时处理换行否则一个空行会被算两次。3.2 Bison文法骨架声明、语句与表达式的优先级处理语法分析层的核心是两件事设计文法规则处理冲突。Bison的冲突主要来自表达式优先级和悬空else这两个问题在文法里用优先级声明解决。syntax.y的骨架如下注意%union、%token、%type和优先级声明的组织方式%{ #include stdio.h #include stdlib.h #include string.h #include ast.h extern int yylex(void); extern int yylineno; void yyerror(const char *msg) { fprintf(stderr, line %d: %s\n, yylineno, msg); } %} %union { int ival; float fval; char *sval; struct ASTNode *node; } %token sval IDENTIFIER %token ival INTEGER_CONSTANT %token fval FLOAT_CONSTANT %token INT FLOAT VOID IF ELSE WHILE FOR RETURN %token PLUS MINUS STAR SLASH PERCENT %token LT LE GT GE EQ NE AND OR NOT ASSIGN %token LPAREN RPAREN LBRACE RBRACE SEMICOLON COMMA %type node program function_list function %type node compound_stmt stmt_list stmt %type node expression expression_stmt if_stmt while_stmt for_stmt return_stmt %type node assign_expr or_expr and_expr eq_expr rel_expr add_expr mul_expr unary_expr primary_expr %left OR %left AND %left EQ NE %left LT LE GT GE %left PLUS MINUS %left STAR SLASH PERCENT %right NOT %nonassoc LOWER_THAN_ELSE %nonassoc ELSE %%%left、%right、%nonassoc这条声明链决定了运算符的优先级和结合性。越往后优先级越高OR最低NOT和一元负号为右结合算术乘法比加法高关系和相等性比较处在中间层。LOWER_THAN_ELSE和ELSE这两个非终结符的%nonassoc声明专门处理悬空else配合%prec使用让if (x) if (y) a1; else b2;里的else正确绑定到最近的if。文法主体分三层组织。第一层是程序结构program - function_list一个函数接一个函数第二层是语句覆盖复合语句、if、while、for、return、表达式语句第三层是表达式从赋值表达式一路展开到primary_expr。下面给出if语句和表达式两个关键规则if_stmt: IF LPAREN expression RPAREN statement %prec LOWER_THAN_ELSE { $$ make_if_node($3, $5, NULL); } | IF LPAREN expression RPAREN statement ELSE statement { $$ make_if_node($3, $5, $7); } ; add_expr: mul_expr { $$ $1; } | add_expr PLUS mul_expr { $$ make_binary_node(OP_ADD, $1, $3); } | add_expr MINUS mul_expr { $$ make_binary_node(OP_SUB, $1, $3); } ;$1、$3是Bison规则右边符号对应的语义值在%type声明后它们就是AST节点的指针。make_binary_node是ast.c里提供的构造函数分配节点内存、填入操作符和左右子树。AST的构建全部在这些动作里完成动作里不掺任何类型检查逻辑保持了前后端的边界。%prec LOWER_THAN_ELSE的写法需要解释一下第一条if规则后面没有else它的结束位置存在一个潜在冲突——此时如果后面跟着elseBison面临“归约为无else的if”还是“移进else”的选择。因为LOWER_THAN_ELSE的优先级低于ELSEBison会选择移进也就是让else和最近的if配对这正是C语言的语义。3.3 跑通最小构建流程flex/bison/gcc一条命令到可执行文件写完lexical.l和syntax.y以后编译顺序有讲究必须先跑bison再跑flex因为flex产生的lex.yy.c里#include syntax.tab.h而syntax.tab.h由bison生成。最小构建命令如下bison -d -t syntax.y flex lexical.l gcc -Wall -g -o compiler lex.yy.c syntax.tab.c ast.c symtab.c codegen.c main.c -lfl-d选项让bison生成syntax.tab.hflex里的return INT、return IDENTIFIER这些Token宏都定义在这个头文件里缺了它flex编译不过。-t打开bison的调试信息在yydebug环境变量设置时输出分析栈的移进和归约过程课程设计调试阶段非常有用验收前记得去掉。-lfl链接Flex库提供yywrap等默认实现如果不写-lfl必须在lexical.l末尾自己补一个int yywrap(void){return 1;}否则链接报undefined reference。为了快速验证编译链路通不通跑一个最简程序echo int main(){ int a; a 1 2; return a; } | ./compiler --dump-ast--dump-ast是main.c里注册的调试选项调用ast.c里的print_ast(root)函数把AST以缩进形式打印出来。输出里应该能看到函数节点、声明节点、赋值节点、加法节点和返回节点逐层嵌套。如果这一步能跑通说明词法和语法两层已经打通后续语义和中间代码可以在这条链路上叠加。Bison 3.x和老版本相比有个兼容坑2.x里允许规则动作写成$$ $1时省略类型3.x会要求更严格的类型匹配报错信息类似“$1 of node has no declared type”。解决办法就是老老实实把所有%type node声明完整不要偷懒。4. 语义分析、符号表与中间代码从语法树到三地址码4.1 符号表的结构设计作用域链、类型与地址分配语义分析的第一个基础设施是符号表。类C语言有函数级作用域和块级作用域复合语句可以嵌套所以符号表要支持作用域链。课程设计规模下不需要hash table一个按插入顺序组织的链表加一个作用域栈就够用查找只在多层作用域上做线性扫描性能不是问题结构简单反而好调试。符号表和作用域的典型实现如下typedef enum { TYPE_INT, TYPE_FLOAT, TYPE_VOID, TYPE_FUNC } TypeKind; typedef struct Symbol { char *name; TypeKind type; int offset; /* 局部变量在栈帧中的偏移 */ int is_function; int param_count; struct Symbol *next; } Symbol; typedef struct Scope { Symbol *head; struct Scope *parent; } Scope; static Scope *current_scope NULL; void scope_push(void) { Scope *s (Scope *)malloc(sizeof(Scope)); s-head NULL; s-parent current_scope; current_scope s; } void scope_pop(void) { Scope *s current_scope; current_scope s-parent; /* 这里释放当前作用域内全部Symbol及其name字符串 */ free_scope(s); } Symbol *lookup(const char *name) { for (Scope *s current_scope; s; s s-parent) { for (Symbol *sym s-head; sym; sym sym-next) { if (strcmp(sym-name, name) 0) return sym; } } return NULL; } int insert(const char *name, TypeKind type) { /* 只在当前作用域检查重名允许内层遮蔽外层 */ for (Symbol *sym current_scope-head; sym; sym sym-next) { if (strcmp(sym-name, name) 0) return -1; /* 重复声明 */ } Symbol *sym (Symbol *)malloc(sizeof(Symbol)); sym-name strdup(name); sym-type type; sym-next current_scope-head; current_scope-head sym; return 0; }lookup沿parent指针一层层向上找这对应了C语言的“内层可以看见外层、外层看不见内层”的规则。insert只检查当前作用域因此允许内层声明和外层同名变量形成遮蔽这也是C语言的实际行为。如果课程设计规格不想支持遮蔽把insert改成调用全局lookup就行。offset字段用来给局部变量分配栈帧偏移。最朴素的做法是在进入一个复合语句时维护一个local_offset计数器每插入一个变量分配一个单位的偏移退出作用域时回滚。这个偏移不必真实对应机器栈只要在中间代码里能区分不同变量即可。函数名也作为符号插入符号表is_function和param_count字段记录其函数身份和参数个数调用语句做参数个数检查时要用到。内存管理要在scope_pop里统一释放当前作用域的所有Symbol和name字符串。很多段错误就出在strdup和Symbol的释放配对错乱上后文避坑清单会专门讲。4.2 语法制导翻译写在Bison动作里的类型检查语义检查可以选择两条路一条是在syntax.y的动作里边归约边检查这叫语法制导翻译另一条是等AST完整建立后单独遍历。课程设计推荐后者原因是检查逻辑集中代码路径可控错误信息好带行号而且不用为了类型检查去改动Bison动作。常见的语义检查规则有以下几条可以做成一个表格放进文档说明检查场景规则错误示例变量声明当前作用域内不可重名int a; int a;变量使用使用前必须已声明a 1;而无声明赋值左右类型兼容int可转floatint a; a 1.5;警告或报错算术运算二元运算两个操作数都需数值类型对void做加法return语句返回表达式类型与函数返回类型一致int f(){ return 1.5; }函数调用函数必须已声明且参数个数匹配声明两个参数却传三个具体实现时在codegen.c里写一个sema_check函数递归遍历AST每个节点按操作符类型检查左右子树的类型并向上归约出结果类型。比如OP_ADD节点先递归检查左右子树如果两个子节点类型都是int结果类型就是int如果任一个是float结果类型是float如果任一个是void报“invalid operands to ”。报错要带行号所以AST节点里从词法阶段就要携带行号信息。做法是在lexical.l里维护yylineno在make_binary_node这类构造函数里把yylineno的当前值存进node-line。注意这里有一个坑Bison动作执行时yylineno通常已经指向规则结束位置的行号在表达式跨行时行号会偏大但对课程设计验收而言是可接受的近似。yyerror的默认实现只打印“syntax error”信息量不足改成下面这样void yyerror(const char *msg) { fprintf(stderr, line %d: %s\n, yylineno, msg); }这样所有语法错误统一输出“文件行为单位”的错误信息语义错误在codegen.c里也用同样的格式。比如未声明变量Symbol *sym lookup(node-name); if (sym NULL) { fprintf(stderr, line %d: undeclared variable %s\n, node-line, node-name); error_count; }错误计数由main.c的全局变量统一管理。sema_check每发现一个错误就累加计数遍历结束后主程序根据error_count决定是否继续生成中间代码。4.3 中间代码生成三地址码的样子和临时变量管理中间代码选三地址码四元式结构这是编译原理课程设计最常见的实现代码量可控而且能直观地验证“表达式怎么被拆成一步步运算”。四元式的结构是操作码、两个源操作数、一个目标操作数typedef struct Quad { char op[16]; char arg1[32]; char arg2[32]; char result[32]; } Quad; typedef struct QuadList { Quad *items; int count; int capacity; } QuadList;操作码表需要覆盖以下指令这个表也直接写进文档说明操作码示例含义ADD / SUB / MUL / DIVt1 a b算术运算NEGt1 -a一元负号ASSIGNa t1赋值GOTOgoto L3无条件跳转IF_FALSEif_false t1 goto L3条件为假时跳转LABELL3:跳转目标标记PARAM / CALLcall f, 2函数调用RETURNreturn t1函数返回表达式生成三地址码的递归函数写法如下static int tmp_count 0; static QuadList *program_quads; char *new_temp(void) { char buf[32]; sprintf(buf, t%d, tmp_count); return strdup(buf); } void gen_quad(const char *op, const char *a1, const char *a2, const char *res) { Quad q; strncpy(q.op, op, sizeof(q.op) - 1); snprintf(q.arg1, sizeof(q.arg1), %s, a1 ? a1 : ); snprintf(q.arg2, sizeof(q.arg2), %s, a2 ? a2 : ); snprintf(q.result, sizeof(q.result), %s, res ? res : ); /* quadlist_push 负责扩容并 append 到 program_quads */ quadlist_push(program_quads, q); } char *gen_expr(ASTNode *node) { if (node-kind CONST_NODE) { char buf[32]; snprintf(buf, sizeof(buf), %d, node-ival); return strdup(buf); } if (node-kind VAR_NODE) { return strdup(node-name); } char *left gen_expr(node-left); char *right gen_expr(node-right); char *res new_temp(); const char *op opcode_of(node-op); gen_quad(op, left, right, res); free(left); free(right); return res; }这段代码的逻辑是标准的后序遍历先递归生成左操作数、再递归生成右操作数、把两者的结果放进一个新建临时变量、输出一条四元式。源操作数包含常量时直接传字面值字符串比如输入1 2会生成t1 1 2然后在赋值语句里再把t1赋给目标变量最后赋值表达式可以合并为一条a 1 2那属于表达式合并优化的范畴课程设计不必做。临时变量的命名从t1开始顺序递增new_temp里用了static int tmp_count整个编译过程只会在一个函数内生成一个翻译单元所以计数器不会重置每条临时变量名全局唯一。注意gen_expr里strdup返回的字符串在上一层用完要free否则一个复杂表达式会产生大量野指针内存泄漏在验收时用valgrind一查一个准。if语句的翻译是控制流指令的典型应用。假设条件表达式已经生成到临时变量cond那么if/else的指令序列长这样cond 的三地址码 if_false cond goto L_else then 分支的指令 goto L_end L_else: else 分支的指令 L_end:实现时维护一个label_countL_else和L_end的编号递增生成通过gen_quad(LABEL, NULL, NULL, label)输出标签行。while和for的翻译逻辑同样是用IF_FALSE和GOTO构造循环跳转把这套模板在codegen.c里写死即可。5. 课程设计避坑清单预答辩翻车、内存泄漏与验收玄学5.1 现象Bison报了几十行shift/reduce conflict没看直接交预答辩时老师看了syntax.y文件问“你这几条conflict怎么处理”当场答不上来项目直接被挂到整改名单。原因是文法的表达式层没有声明优先级和结合性E - E E这类二义性文法让Bison的LALR分析表产生大量冲突。Bison默认在冲突时选择移进行为上碰巧能工作但分析表不等于设计意图老师就是要听你解释冲突的来源和解决方案。解决办法很简单把%left OR到%right NOT这条优先级声明链完整写上再把悬空else用%nonassoc LOWER_THAN_ELSE和%prec处理掉。写完之后重新bison看到0 conflicts才算干净。记住一个原则批处理输出里出现的任何一个conflict都要能在文档说明里给出理由。5.2 现象编译器一跑就Segmentation faultgdb查了半天查不出原因第一次跑./compiler test.c就段错误gdb提示崩溃在一个看起来毫无问题的strcmp上一看是Symbol的name指针变成了野指针。原因几乎可以锁定在Bison动作里yylval.sval存放的是strdup(yytext)返回的指针这个指针归Token所有如果在动作里直接$$ make_var_node($1)而make_var_node内部没有复制字符串只保存了指针那么当Token内存被复用或释放后AST里的名字就悬空了。解决方法是确立一条铁律所有字符串进入AST或符号表时必须由AST构造函数做一次所有权转移或深拷贝。我一般让make_var_node内部自己strdup一份Symbol插入时也strdup词法层产生的yylval.sval在规则动作用完即弃。统一用valgrind跑一遍测试用例确保definitely lost为0。5.3 现象关键字if被当成标识符语法错误连成一片输入if (a 0) b 1;直接报syntax error而且错误位置很诡异明明if写得没错。原因是lexical.l里把{IDENT}这条规则写在了关键字规则前面。Flex的最长匹配优先遇到if时有两种匹配关键字规则if匹配长度为2标识符规则[a-zA-Z_][a-zA-Z0-9_]*也匹配长度为2长度相同取先出现的规则结果标识符规则赢了if被当成普通变量名返回IDENTIFIER Token。解决方法是把保留字规则全部移动到{IDENT}规则之前确保关键字规则先被匹配。这条经验在修改子集时特别容易踩比如新增一个auto或const关键字随手加在文件末尾就无效。把关键字列表单独放在flex文件顶部注释写明顺序约定防止后面改乱。5.4 现象报错只有一句“syntax error”定位不到具体行测试时遇到语法错误输出只有syntax error四个字连行号都没有。小程序还能靠肉眼找一个几百行的测试文件直接崩溃。原因是yyerror函数实现得太简陋参数只有固定文案syntax errorBison调用时不会自动追加行号。而且flex里遇到非法字符规则没覆盖到的输入比如或中文符号时也没有产生语法错误只是静默跳过或返回一个无法识别的Token。解决方案分两层第一把yyerror按前面第4章的写法改成打印yylineno第二在flex文件最后加一条兜底规则.把无法识别的字符带行号打印到stderr同时返回一个自定义的非法Token让语法层知道。为了让行号追踪更准确每条产生式动作里也可以用$.first_lineBison位置跟踪但课程设计用全局yylineno足够了。5.5 现象文档说明和提交的源代码严重不一致验收时被问崩写好的文档说明里写着“支持for循环”源码里也确实有for规则但演示时老师改了一个带break的for程序代码直接报语法错误——因为规格里没写break。这类问题的根源是文档说明停留在第一版设计代码在两周里不断调整两边没有同步。很多课程设计的分数线就死在文档说明和代码不一致上。解决方案是在doc目录里建一个“已知限制”清单每次改代码顺手更新。我的习惯是文档说明里专门留一节写“已实现特性”和“未实现特性”两者的边界跟lexical.l和syntax.y里的规则一一对应。验收前花半小时逐个核对关键字列表和flex规则一致文法规则和文档里的BNF一致错误信息格式和代码里所有fprintf一致。这样老师挑刺时你至少能拿出一份自洽的说明书。6. 验收前最后两件事测试用例脚本与Makefile化构建6.1 用Makefile把flex和bison的构建顺序固定下来课程设计提交的源码包里Makefile是默认要求也是老师快速验证的第一入口。手敲那三条gcc命令在开发时可以迭代几次后就会遇到“改了syntax.y忘了重新bison”的低级错误Makefile的依赖关系能把整个构建固化下来。一个针对FlexBison项目的Makefile核心依赖写法如下CC gcc CFLAGS -Wall -g -O0 LEX flex YACC bison OBJS lex.yy.o syntax.tab.o ast.o symtab.o codegen.o main.o compiler: $(OBJS) $(CC) -o $ $(OBJS) -lfl lex.yy.c: lexical.l syntax.tab.h $(LEX) lexical.l syntax.tab.c: syntax.y $(YACC) -d -t syntax.y %.o: %.c $(CC) $(CFLAGS) -c $ clean: rm -f compiler *.o lex.yy.c syntax.tab.c syntax.tab.h这里的依赖关系要特别注意lex.yy.c依赖syntax.tab.h而不是syntax.y因为flex只需要Token宏定义不需要整个syntax.tab.c同时syntax.tab.c依赖syntax.y所以修改了文法后make会先重建syntax.tab.c和syntax.tab.h再触发lex.yy.c重建。如果漏写了lex.yy.c对syntax.tab.h的依赖会出现改Token集合后flex还在用旧头文件链接阶段报一大堆undefined reference。6.2 用测试脚本验证功能而不是手动输一行测一行验收前的功能验证一定要写成可重跑的脚本。具体做法是建一个testcases目录每个测试一个.c文件对应的期望输出放在expect目录然后跑一段循环脚本逐个比对。#!/bin/bash mkdir -p out fail0 for f in testcases/*.c; do name$(basename $f .c) ./compiler $f out/$name.out 21 if diff -u expect/$name.exp out/$name.out /dev/null; then echo PASS: $name else echo FAIL: $name diff -u expect/$name.exp out/$name.out fail1 fi done exit $fail测试用例至少要覆盖这样几类空程序、只含main和return、变量声明与赋值、四则运算的优先级、if/else嵌套、while循环、for循环、函数调用与递归、未声明变量、类型不匹配、缺分号、非法字符、注释跳过、多行注释。期望输出文件里既要有程序的正常运行结果也要有三地址码输出和错误信息具体取决于你的编译器设计成输出什么——这里的关键是每一类行为都要有一个固定的期望文件不能靠人眼判断。6.3 验收前半小时自查清单检查项标准make clean make零错误零warningconflict为0测试脚本全过所有用例PASS无diff差异valgrind跑两个用例无内存泄漏无非法读写文档说明特性列表和源码规则一一对应错误信息格式统一全部是“行号: 描述”源代码文件注释每个模块头部有职责说明我自己的习惯是验收前一天把testcases/expect整个删掉重新运行编译器逐个生成期望输出再用肉眼检查一遍这些输出是否合理确认没有靠运气通过的用例。这一步做完基本就能安稳等验收了。希望帮到你祝你的类C编译器一次跑通所有测试用例。本文还有配套的精品资源点击获取