北邮编译原理课程设计:Flex+Bison实现MiniC微型编译器
简介本资源是北京邮电大学《编译原理课程设计》的完整实践项目源码包面向计算机专业本科生及编译技术初学者聚焦Pascal语言编译器全流程实现覆盖词法分析、语法分析、语义处理、中间代码生成与目标代码ASM/BIN输出等核心环节。压缩包共83个文件以19个C源文件cpp、21个头文件h支撑编译器主框架8个Pascal测试用例pas验证功能8个汇编文件asm与8个二进制可执行文件bin体现代码生成结果辅以资源脚本rc、rez、工程配置dsw、vcxproj、filters及图形界面组件bmp、cur整体仅123KB轻量但结构完整。已有1194人学习下载资源包含可直接编译运行的ZSCPascal集成环境含虚拟机模块、语法树可视化辅助、多测试用例recursivitate、whiletest、casetest等及配套符号表与错误处理机制是理解编译器内部构造与动手构建小型编译系统的优质教学范例。1. 北邮编译原理课程设计不是抄实验报告而是亲手造一个能跑通的“微型编译器黑匣子”“北邮编译原理课程设计”这八个字在某高校计算机学院的秋季学期末几乎等同于一段集体记忆实验室灯光亮到凌晨、终端里反复报错的syntax error at line 37、词法分析器输出了一堆不认识的 token、中间代码生成后发现跳转地址全错……它从来不是对龙书《Compilers: Principles, Techniques, and Tools》某章的复述而是一次强制性的“从零缝合式实践”——你得把词法分析、语法分析、语义检查、中间代码生成这四块硬骨头用 C 或 C 一钉一铆地焊在一起最后让一个自定义的极简语言比如叫 MiniC 或 PL0 扩展版真正跑出结果。这不是写个 parser 就交差的作业而是要让输入一段if (x 5) y x * 2;最终在控制台打印出y 12。适合谁刚啃完 LL(1)/LR(0) 表但手还没热的本科生想补上“理论落地最后一公里”的考研党还有那些简历写着“熟悉编译流程”却没 debug 过一遍符号表冲突的应届生。它不考你背算法考你能不能在 300 行 yacc 规则里定位一个移进-归约冲突能不能在 AST 节点里塞进类型信息而不崩掉内存。2. 用 Flex Bison 在本地跑通 MiniC 的最小闭环从空文件夹到可执行的四步链课程设计最常踩的第一个坑是过早陷入“我要写多牛的优化”。实际路径非常朴素先让最简语法动起来再一层层加功能。我们以某高校近年采用的 MiniC 子集为例支持 int 变量、赋值、加减乘除、if-else、while、单行注释整个工具链完全基于 GNU 工具链无需额外安装 IDE 插件或云服务。2.1 第一步初始化项目结构与基础构建脚本新建目录minic-compiler结构如下必须严格minic-compiler/ ├── src/ │ ├── lexer.l # Flex 词法规则 │ ├── parser.y # Bison 语法规则 │ ├── ast.h/.c # AST 节点定义与构造函数 │ ├── symtab.h/.c # 符号表实现哈希表 or 链表 │ └── main.c # 解析入口 错误处理主循环 ├── test/ │ └── hello.mc # 测试用例int a 5; int b a 3; print(b); └── Makefile提示Bison 默认生成.tab.c和.tab.hFlex 生成lex.yy.c所有源码必须放在src/下否则 Makefile 无法正确定位依赖。很多同学因目录错一级make报undefined reference to yyparse却查不出原因。2.2 第二步写最简 lexer.l —— 先让关键字和数字活过来%{ #include stdio.h #include parser.tab.h // 必须包含 Bison 生成的头文件 extern int yylval; // 用于传递 token 值如数字字面量 %} %% [ \t\n] ; /* 忽略空白 */ int { return INT; } if { return IF; } else { return ELSE; } while { return WHILE; } print { return PRINT; } [a-zA-Z_][a-zA-Z0-9_]* { yylval strdup(yytext); return IDENTIFIER; } [0-9] { yylval atoi(yytext); return NUMBER; } { return EQ; } ! { return NE; } [\-*/;(){}\n] { return *yytext; } . { fprintf(stderr, Lexer: unknown char %c\n, *yytext); } %%逻辑说明%{ %}中的 C 代码会被原样复制到生成的lex.yy.c开头用于声明外部变量和头文件yylval是 Flex 与 Bison 之间传递语义值的桥梁当匹配到NUMBER时atoi(yytext)把字符串转成整数存入yylvalBison 的$$ $1就能拿到这个值IDENTIFIER的处理用了strdup因为yytext是 Flex 内部缓冲区指针下一词法单元就会被覆盖不拷贝会导致后续语义动作访问野指针——这是新手最常翻车的内存错误之一。2.3 第三步写 parser.y —— 构建语法骨架与 AST 根节点%{ #include stdio.h #include stdlib.h #include ast.h #include symtab.h extern int yylex(); extern int yyparse(); extern FILE *yyin; void yyerror(const char *s); // 全局 AST 根节点 struct ASTNode *root NULL; %} %union { int num; char *str; struct ASTNode *node; } %token num NUMBER %token str IDENTIFIER %token INT IF ELSE WHILE PRINT EQ NE %token num - * / ; ( ) { } \n %type node program stmt_list stmt expr term factor %% program: stmt_list { root $1; } ; stmt_list: stmt { $$ new_stmt_list($1, NULL); } | stmt_list stmt { $$ new_stmt_list($1, $2); } ; stmt: INT IDENTIFIER ; { $$ new_decl_stmt($2); } | IDENTIFIER expr ; { $$ new_assign_stmt($1, $3); } | IF ( expr ) stmt { $$ new_if_stmt($3, $5, NULL); } | IF ( expr ) stmt ELSE stmt { $$ new_if_stmt($3, $5, $7); } | WHILE ( expr ) stmt { $$ new_while_stmt($3, $5); } | PRINT ( expr ) ; { $$ new_print_stmt($3); } ; expr: term { $$ $1; } | expr term { $$ new_binary_op(, $1, $3); } | expr - term { $$ new_binary_op(-, $1, $3); } ; term: factor { $$ $1; } | term * factor { $$ new_binary_op(*, $1, $3); } | term / factor { $$ new_binary_op(/, $1, $3); } ; factor: NUMBER { $$ new_num_node($1); } | IDENTIFIER { $$ new_id_node($1); } | ( expr ) { $$ $2; } ; %% void yyerror(const char *s) { fprintf(stderr, Parse error at line %d: %s\n, yylineno, s); }参数说明%union定义了所有$1,$2,$$可能承载的数据类型必须与ASTNode结构体字段对齐new_*_stmt()等函数需在ast.c中实现返回struct ASTNode*每个节点必须 malloc 分配不能用栈变量地址yylineno是 Flex 自动维护的行号变量开启方式是在lexer.l的%{ %}块中加#define YY_INPUT(buf,result,max_size) { ... }并启用%option yylineno此处省略但实操必须加否则报错无行号IF ( expr ) stmt ELSE stmt规则中$7是第二个stmt下标从 1 开始计数极易数错——建议在规则后加注释/* $1IF, $2(, $3expr, $4), $5then-stmt, $6ELSE, $7else-stmt */。2.4 第四步Makefile —— 一行 make 全链路编译CC gcc CFLAGS -Wall -g -Isrc/ LEX flex YACC bison TARGET minic SRCDIR src SOURCES $(SRCDIR)/main.c $(SRCDIR)/ast.c $(SRCDIR)/symtab.c LEX_SRC $(SRCDIR)/lexer.l YACC_SRC $(SRCDIR)/parser.y $(TARGET): $(SOURCES) $(SRCDIR)/lex.yy.c $(SRCDIR)/parser.tab.c $(CC) $(CFLAGS) -o $ $^ $(SRCDIR)/lex.yy.c: $(LEX_SRC) $(LEX) -o $ $ $(SRCDIR)/parser.tab.c $(SRCDIR)/parser.tab.h: $(YACC_SRC) $(YACC) -d -o $ $ clean: rm -f $(SRCDIR)/lex.yy.c $(SRCDIR)/parser.tab.c $(SRCDIR)/parser.tab.h $(TARGET) .PHONY: clean关键点-d参数给 Bison否则不会生成parser.tab.h导致#include parser.tab.h报错$(SRCDIR)/parser.tab.c依赖$(YACC_SRC)但$(SRCDIR)/parser.tab.h也必须列为目标Bison 一次生成两个文件否则main.c编译时找不到YYSTYPE定义所有.c文件必须显式列在$(SOURCES)中不能用$(wildcard *.c)否则ast.c修改后make不会触发重编译。3. 符号表与语义检查为什么int a; print(a);能过但print(b);必须报错语法正确 ≠ 语义正确。课程设计的分水岭就卡在能否实现带作用域的符号表和基础语义检查。很多同学的 parser 能接受int a; int a;重复定义或让未声明变量b直接参与运算——这在真实编译器中是致命缺陷也是评分重点。3.1 用哈希链表实现两级作用域符号表某高校参考实现采用开地址哈希 链表解决冲突每个作用域一个独立哈希表外加一个作用域栈管理嵌套// symtab.h #define HASH_SIZE 101 struct Symbol { char *name; int type; // TYPE_INT, TYPE_VOID int scope_level; // 0global, 1func, 2block... struct Symbol *next; }; struct SymbolTable { struct Symbol *buckets[HASH_SIZE]; int scope_level; struct SymbolTable *parent; // 指向上级作用域 }; extern struct SymbolTable *current_scope; struct SymbolTable* create_symbol_table(int level, struct SymbolTable *parent); void enter_scope(); void exit_scope(); int insert_symbol(const char *name, int type); int lookup_symbol(const char *name); // 返回 type-1 表示未找到逻辑说明insert_symbol()必须先调用lookup_symbol(name)检查是否已存在同名符号同一作用域内若存在则返回 0失败并报错enter_scope()创建新表parent指向当前current_scope然后current_scope new_tableexit_scope()则将current_scope恢复为current_scope-parentlookup_symbol()需从current_scope开始向上遍历parent链模拟词法作用域查找规则就近原则不能只查当前表。3.2 在 AST 遍历中注入语义检查逻辑语义检查不写在 parser.y 里那会污染语法逻辑而是在 AST 构建完成后用深度优先遍历DFS统一检查// ast.c 中的语义检查入口 int semantic_check(struct ASTNode *node) { if (!node) return 1; switch(node-type) { case NODE_DECL_STMT: if (insert_symbol(node-u.decl.name, TYPE_INT) 0) { fprintf(stderr, Error: redeclaration of %s at line %d\n, node-u.decl.name, node-line); return 0; } break; case NODE_ASSIGN_STMT: if (lookup_symbol(node-u.assign.id) -1) { fprintf(stderr, Error: use of undeclared variable %s at line %d\n, node-u.assign.id, node-line); return 0; } // 检查右值表达式类型是否兼容此处简化为只允许 int if (!check_expr_type(node-u.assign.expr)) return 0; break; case NODE_ID_NODE: if (lookup_symbol(node-u.id.name) -1) { fprintf(stderr, Error: undeclared identifier %s at line %d\n, node-u.id.name, node-line); return 0; } break; } // 递归检查子节点 for (int i 0; i node-child_count; i) { if (!semantic_check(node-children[i])) return 0; } return 1; }参数说明每个 AST 节点结构体必须带int line字段由 Flex 的yylineno在创建节点时传入new_id_node()函数里要写node-line yylinenocheck_expr_type()是类型推导函数对NODE_NUM_NODE返回TYPE_INT对NODE_ID_NODE调用lookup_symbol()查类型对NODE_BINARY_OP检查左右操作数是否都为TYPE_INTNODE_IF_STMT和NODE_WHILE_STMT的条件表达式必须是布尔型但 MiniC 通常用int模拟0 为假非 0 为真所以只需检查其子表达式是否为TYPE_INT即可。3.3 为什么if (x) { int y; } print(y);必须报错这是作用域检查的典型场景。y在if块内声明其scope_level为 1假设 global 是 0print(y)在块外lookup_symbol(y)从current_scopelevel 0开始查找不到y于是报错。但如果漏了parent链遍历只查当前表就会误判为“未声明”而实际是“越界访问”。血泪经验在enter_scope()后立刻printf(enter level %d\n, current_scope-scope_level)在exit_scope()前printf(exit level %d\n, current_scope-scope_level)运行测试用例时看输出是否符合预期嵌套层次。4. 中间代码生成与解释执行把 AST 翻译成三地址码并逐条跑语法语义通过后课程设计进入高光环节生成可执行的中间表示IR。北邮风格不强求生成汇编而是用三地址码Three-Address Code, TAC——每条指令最多一个运算符形如t1 a b、if t1 goto L2。它足够简单又足够接近真实编译流程。4.1 TAC 指令集设计8 条核心指令撑起全部逻辑某高校标准要求实现以下指令全部小写无空格指令格式说明t1 t2赋值t1 t2 t3加法-t1 t2 - t3减法*t1 t2 * t3乘法/t1 t2 / t3除法gotogoto L1无条件跳转ifif t1 goto L2条件跳转t1 非 0 时跳printprint t1打印整数注意t1,t2是临时变量t 数字L1,L2是标签L 数字所有 label 必须全局唯一。不要用label1、loop_start这类名字必须是L1,L2,L3... 连续编号。4.2 为 if-else 生成 TAC标签编号与跳转逻辑的硬核细节if (a b) x 1; else x 2;的 TAC 生成是教学重点也是最容易写错的地方。正确序列必须是L1: if t1 goto L2 t2 2 goto L3 L2: t2 1 L3: ...对应生成逻辑伪代码void gen_if_else(struct ASTNode *node) { char *cond_label new_label(); // L1 char *then_label new_label(); // L2 char *end_label new_label(); // L3 // 生成条件判断先算 a b 得到 t1再 if t1 goto L2 struct TACInstr *cond_tac gen_cond_expr(node-u.if_stmt.cond); emit(if %s goto %s, cond_tac-result, then_label); // else 分支跳过 then直接到 end emit(goto %s, end_label); emit_label(cond_label); // L1: // then 分支 emit_label(then_label); // L2: gen_stmt(node-u.if_stmt.then_body); // end 标签 emit_label(end_label); // L3: if (node-u.if_stmt.else_body) { gen_stmt(node-u.if_stmt.else_body); } }关键参数new_label()必须维护一个全局静态计数器static int label_count 0;每次返回L%d格式字符串用sprintf(buf, L%d, label_count)emit_label()输出L1:冒号必须emit()输出指令如goto L3两者不能混淆gen_cond_expr()需支持,,,!生成形如t1 a b的指令MiniC 通常把比较结果作为 int1 或 0emit(goto %s, end_label)必须在emit_label(cond_label)之前否则L1:后紧跟goto L3then 分支永远不执行。4.3 解释执行器用数组模拟寄存器逐条执行 TACTAC 不是给 CPU 跑的而是自己写个解释器Interpreter来跑。核心是两个数组#define MAX_TEMP 1000 #define MAX_LABEL 100 int temp_values[MAX_TEMP] {0}; // t0, t1, t2... 的值 int label_addresses[MAX_LABEL] {0}; // L1, L2... 对应的指令索引 struct TACInstr *tac_list[MAX_TAC] {NULL}; // 所有 TAC 指令指针 int tac_count 0; // 当前指令总数 // 解释执行主循环 void interpret_tacs() { int pc 0; // program counter while (pc tac_count) { struct TACInstr *instr tac_list[pc]; if (!instr) { pc; continue; } if (strcmp(instr-op, ) 0) { int val get_value(instr-arg1); set_temp(instr-result, val); } else if (strcmp(instr-op, ) 0) { int v1 get_value(instr-arg1); int v2 get_value(instr-arg2); set_temp(instr-result, v1 v2); } else if (strcmp(instr-op, goto) 0) { pc label_addresses[get_label_index(instr-arg1)] - 1; // -1 因为后面 pc } else if (strcmp(instr-op, if) 0) { int val get_value(instr-arg1); if (val ! 0) { pc label_addresses[get_label_index(instr-arg2)] - 1; } } else if (strcmp(instr-op, print) 0) { printf(%d\n, get_value(instr-arg1)); } pc; } }逻辑说明get_value()处理三种情况t1→ 查temp_values[]a→ 查符号表得地址再查temp_values[]123→ 直接返回数字set_temp(t1, 42)就是temp_values[1] 42t后数字直接作下标label_addresses[]在解释器启动前必须预扫描一遍所有emit_label()记录每个L1出现在tac_list[]的第几个位置索引goto L3的pc更新是label_addresses[2] - 1因为pc会自动加 1所以要提前减 1 才能精准跳到L3:那条指令。5. 避坑北邮编译原理课程设计的 4 个高频翻车现场与后悔药课程设计不是线性流程而是不断回溯、修改、重编译的螺旋。以下是某高校助教整理的 4 个最高频、最耗时的翻车点按“现象→原因→解决”给出可立即执行的后悔药。5.1 现象make报错undefined reference to yylex但lexer.l明明写了原因Flex 生成的lex.yy.c没有被编译进最终可执行文件。常见于两种情况一是Makefile中$(SOURCES)没包含lex.yy.c二是lex.yy.c生成路径错误比如 Flex 默认输出到当前目录但你的Makefile期望它在src/下。解决在Makefile中确认$(SOURCES)是否包含$(SRCDIR)/lex.yy.c运行make -n查看实际执行的 gcc 命令确认lex.yy.c是否在编译参数中强制指定 Flex 输出路径$(LEX) -o $(SRCDIR)/lex.yy.c $确保和Makefile路径一致。5.2 现象if (a) { int b; } print(b);不报错但print(c);却报“undeclared”原因符号表lookup_symbol()只查当前作用域没向上遍历parent链。b在if块内声明print(b)在块外current_scope是 global 表查不到b于是报错但c根本没声明过也报同样错导致你以为逻辑对了——其实b本该在块内就报“重复声明”块外报“越界访问”但现在全混成一种错。解决在lookup_symbol()开头加调试输出printf(lookup %s in scope %d\n, name, current_scope-scope_level);确保循环是for (struct SymbolTable *s current_scope; s; s s-parent)用测试用例int a; { int a; }验证内层int a应报重定义证明作用域隔离生效。5.3 现象TAC 生成了t1 a b但解释器运行时报segmentation fault原因a和b是标识符get_value()试图用temp_values[atoi(a)]访问数组——atoi(a)返回 0于是读temp_values[0]但a实际存储在符号表中不是临时变量。解决get_value()必须区分三类输入以t开头sscanf(str, t%d, idx); return temp_values[idx];全是数字return atoi(str);其他变量名int addr lookup_symbol(str); return temp_values[addr];符号表中存的是temp_values下标在insert_symbol()时为每个变量分配一个唯一temp_values下标如next_temp_addr存入符号表struct Symbol的int addr字段。5.4 现象while (a 10) { a a 1; }死循环解释器卡死原因goto和if指令中的 label 地址没正确绑定。emit_label(L1)只是往tac_list[]写了一条LABEL类型指令但label_addresses[]数组没更新导致interpret_tacs()中label_addresses[get_label_index(L1)]返回 0pc被设为 -1然后pc变成 0无限循环。解决在emit_label(char *lbl)函数中除了tac_list[tac_count] new_label_instr(lbl)还必须int idx get_label_index(lbl); // L1→0, L2→1... label_addresses[idx] tac_count - 1; // 当前指令索引就是 label 地址get_label_index()用哈希表或线性搜索将L1映射到整数 0确保label_addresses[]大小足够MAX_LABEL≥ label 总数运行前加断言assert(label_addresses[i] 0 label_addresses[i] tac_count);。6. 从“能跑”到“跑得稳”三个让 TA 一眼看出你下了功夫的硬核技巧做到上面五章你的编译器已经能通过基础测试。但想拿高分、想让代码经得起 TA 拿valgrind扫描、想为后续做 LLVM 后端打基础这三个技巧是分水岭。它们不增加功能但让整个系统从“玩具”变成“工程级雏形”。6.1 用valgrind --leak-checkfull做内存审计根治 90% 的崩溃课程设计最大的隐形杀手是内存泄漏和野指针。malloc了char*没freeASTNode节点malloc了没在free_ast()中递归释放都会导致valgrind报definitely lost。某高校曾统计超 60% 的“随机崩溃”源于此。实操步骤编译时加-gCFLAGS -g让valgrind显示行号运行valgrind --leak-checkfull --show-leak-kindsall ./minic test/hello.mc重点看definitely lost和still reachabledefinitely lostmalloc后指针丢失必须补freestill reachable全局指针如root,current_scope没释放属于正常但要在main()结尾加free_ast(root); free_symtab();。关键技巧为每个malloc配对free并在free后置NULL。例如struct ASTNode *node malloc(sizeof(*node)); // ... use node free(node); node NULL; // 防止二次释放valgrind不会报node NULL但能防止if (node) free(node)时误释放野指针。6.2 给 AST 节点加line和col字段让错误提示精准到列Error: undeclared variable a太粗糙。TA 期待看到Error at test/hello.mc:5:12: use of undeclared variable a。这需要你在每个 AST 节点中存位置并在 Flex 中获取列号。Flex 中获取列号在lexer.l的%{ %}块中加int yycol 1; #define YY_USER_ACTION \ yycol 1; \ for (char *p yytext; p yytext yyleng; p) { \ if (*p \t) yycol ((yycol - 1) / 8 1) * 8 1; \ else if (*p \n) { yycol 1; yylineno; } \ else yycol; \ }然后在new_*_node()中node-line yylineno; node-col yycol - yyleng; // 减去当前 token 长度得到起始列最后在yyerror()和语义检查报错中用fprintf(stderr, Error at %s:%d:%d: ...\n, filename, node-line, node-col)。6.3 用#ifdef DEBUG控制 AST 打印调试时不删代码提交时自动关闭开发时疯狂printf(AST: %s\n, node-type)但提交代码不能留一堆printf。用宏开关最干净// ast.h #ifdef DEBUG #define DEBUG_PRINT(fmt, ...) fprintf(stderr, [DEBUG] fmt \n, ##__VA_ARGS__) #else #define DEBUG_PRINT(fmt, ...) #endif // 在 AST 构造函数中 DEBUG_PRINT(new_assign_stmt: %s ..., id);编译时开发gcc -DDEBUG -g ...提交gcc -g ...无-DDEBUG所有DEBUG_PRINT被预处理器删掉终极习惯我一般会在main.c开头加#ifndef DEBUG #pragma GCC diagnostic ignored -Wunused-variable #pragma GCC diagnostic ignored -Wunused-function #endif避免-Wall对未使用的调试函数报错让提交版代码干干净净。希望帮到你。本文还有配套的精品资源点击获取