☰
吉林大学编译原理实战:Lex/Yacc/C手写编译器全流程
2026/10/13 4:38:57 网站建设 项目流程

简介:本资源是吉林大学编译原理课程的完整实践配套包,面向计算机专业本科生、考研备考学生及编译器技术初学者,聚焦词法分析、语法解析、中间代码生成与目标代码实现等核心能力训练。压缩包共27个文件,涵盖4个Java源码(Compiler.java、DoToken.java等)、9个class字节码、5个Word实验报告(含5517-0611小组三周实操报告)、1个PDF书稿《编译程序的设计与实现》、1个PPT实验要求说明、1个RAR源码压缩包及SNL语言介绍等教学文档,类型丰富、层次清晰,便于从理论理解到代码调试再到报告撰写全流程学习。资源大小为4.86MB,轻量易下载,结构组织合理,源码与文档严格对应实验进度,支持边学边调、对照验证。目前已有1207人学习下载,是系统掌握编译器设计原理与工程实现的高价值教学实践材料。

1. 吉林大学编译原理设计代码+实验报告:一套能跑通、能调试、能讲清词法/语法分析全流程的实战材料

你是不是也经历过——教材里讲LL(1)分析表像天书,课设要求手写一个能识别a+b*c的简单表达式编译器,结果卡在词法分析器输出空token、语法分析栈死循环、或者生成的四元式根本对不上课本例题?这份来自吉林大学课程实践环节的真实交付物,不是PPT拼凑的“理论演示”,而是一套完整闭环的可执行工程:从.lex和.yacc源码、Makefile构建脚本、带断点注释的C语言中间代码生成器,到每步都附有输入/输出截图与错误溯源说明的实验报告。它专为「想亲手把编译流程走通一遍」的本科生和自学开发者准备——不需要你重写整个前端,但要求你能改一个正则规则让标识符支持下划线,能调一个FIRST集计算逻辑让预测分析表不报冲突,能在报告里说清为什么if (x>0) y=1; else y=0;的语法树节点顺序必须是IfStmt → Cond → ThenPart → ElsePart。这不是答案抄写本,而是你调试时能翻出对应行号、查错时能定位到具体状态转移、答辩前能对着报告反推自己代码缺陷的“编译原理实操黑匣子”。


2. 项目结构与核心模块解析:看清lex/yacc/C三件套如何咬合工作

这套资源最值得细拆的,是它把教科书里割裂开的“词法→语法→语义”三阶段,用真实文件依赖关系焊死在了一起。整个工程目录不是扁平堆砌,而是按编译流水线分层组织:src/下放.l(Lex)和.y(Yacc)定义;gen/存放由Bison自动生成的parser.tab.c和parser.tab.h;main.c作为驱动入口,调用yyparse()并接管符号表管理;report/里的PDF不仅记录结果,更在关键步骤旁嵌入GDB调试命令和内存dump片段。这种结构不是为了好看,而是让你在修改语法时,能立刻意识到:改了.y文件后必须重新运行bison -d,否则parser.tab.h里声明的yylval类型会和main.c中定义的struct YYSTYPE不匹配——这是新手翻车率最高的链路断裂点。

2.1 词法分析器(.l文件):正则规则背后的优先级陷阱

src/lexer.l采用Flex标准语法,但它的正则书写方式暗藏教学意图。例如标识符定义为[a-zA-Z_][a-zA-Z0-9_]*,而数字字面量为[0-9]+,两者看似无冲突,但若将数字规则写在标识符之前(如先匹配[0-9]+再匹配标识符),则abc123会被截成abc和123两个token——这恰恰是实验报告第3.2节重点剖析的“最长匹配原则失效案例”。实际代码中,规则严格按长关键字优先、保留字优先于标识符、数字优先于标识符中含数字部分排序:

%{ #include "parser.tab.h" extern YYSTYPE yylval; %} %% "if" { yylval.str = strdup(yytext); return IF; } "else" { yylval.str = strdup(yytext); return ELSE; } [a-zA-Z_][a-zA-Z0-9_]* { yylval.str = strdup(yytext); return ID; } [0-9]+ { yylval.num = atoi(yytext); return NUM; } "+" { return '+'; } "-" { return '-'; } "*" { return '*'; } "/" { return '/'; } "(" { return '('; } ")" { return ')'; } ";" { return ';'; } [ \t\n] { /* 忽略空白 */ } . { fprintf(stderr, "非法字符: %c\n", *yytext); exit(1); } %%

提示:yylval是Flex与Yacc共享的语义值载体,其类型由%union在.y文件中定义。此处str和num字段必须与parser.tab.h中YYSTYPE结构体成员完全一致,否则编译会报incompatible type。常见错误是忘记在.l顶部#include "parser.tab.h",导致yylval被默认为int型。

2.2 语法分析器(.y文件):LR(1)冲突的显式消解与FIRST/FOLLOW集验证

src/parser.y采用Yacc/Bison语法,核心在于%left '+' '-'和%left '*' '/'声明了运算符结合性与优先级。但真正体现教学深度的是对dangling else问题的处理——它没有依赖Yacc默认的“移进优先”,而是通过显式引入%nonassoc和嵌套非终结符来强制语法树结构:

%{ #include <stdio.h> #include <stdlib.h> #include "ast.h" extern int yylex(); extern int yyparse(); extern char* yytext; void yyerror(const char* s); %} %union { struct ASTNode* node; char* str; int num; } %token <str> ID %token <num> NUM %token IF ELSE WHILE DO %token <str> THEN %token ';' '(' ')' '{' '}' %type <node> program stmt_list stmt expr term factor if_stmt while_stmt %nonassoc LOWER_THAN_ELSE %nonassoc ELSE %% program: stmt_list { /* 根节点处理 */ }; stmt_list: stmt | stmt_list stmt ; stmt: if_stmt | while_stmt | expr ';' ; if_stmt: IF '(' expr ')' stmt %prec LOWER_THAN_ELSE | IF '(' expr ')' stmt ELSE stmt ; while_stmt: WHILE '(' expr ')' stmt ; expr: term { $$ = $1; } | expr '+' term { $$ = make_binary_op('+', $1, $3); } | expr '-' term { $$ = make_binary_op('-', $1, $3); } ; term: factor { $$ = $1; } | term '*' factor { $$ = make_binary_op('*', $1, $3); } | term '/' factor { $$ = make_binary_op('/', $1, $3); } ; factor: ID { $$ = make_id_node($1); } | NUM { $$ = make_num_node($1); } | '(' expr ')' { $$ = $2; } ; %% void yyerror(const char* s) { fprintf(stderr, "语法错误: %s at line %d, near '%s'\n", s, yylineno, yytext); }

这段代码的关键在于%nonassoc LOWER_THAN_ELSE和%prec LOWER_THAN_ELSE的配对使用:当解析器遇到if A then if B then C else D时,ELSE的优先级被设为LOWER_THAN_ELSE,而if_stmt的%prec声明使其在ELSE出现时选择“移进”而非“归约”,从而强制形成if A then (if B then C else D)结构。实验报告附录B提供了手动计算该文法FIRST集与FOLLOW集的完整过程,并用表格对比了未加%nonassoc时Bison生成的冲突报告(shift/reduce conflict on ELSE)与加约束后的干净输出。

2.3 中间代码生成(main.c与AST模块):从语法树到四元式的映射逻辑

main.c并非简单调用yyparse()就结束,而是实现了完整的AST遍历与代码生成闭环。ast.h定义了抽象语法树节点类型,ast.c中generate_code()函数按后序遍历递归生成四元式。以赋值语句x = a + b * c为例,其AST结构为:

AssignNode ├── left: IdNode("x") └── right: BinaryOpNode('+') ├── left: IdNode("a") └── right: BinaryOpNode('*') ├── left: IdNode("b") └── right: IdNode("c")

generate_code()对每个节点返回临时变量名(如t1,t2),并在全局四元式列表中追加记录:

// ast.c 片段 char* generate_code(struct ASTNode* node, struct QuadList* list) { if (!node) return NULL; switch(node->type) { case NODE_ID: return strdup(node->id.name); case NODE_NUM: return strdup(node->num.val); case NODE_BINARY_OP: char* left = generate_code(node->binop.left, list); char* right = generate_code(node->binop.right, list); char* temp = malloc(16); sprintf(temp, "t%d", ++temp_count); // 追加四元式:OP, ARG1, ARG2, RESULT append_quad(list, node->binop.op, left, right, temp); return temp; case NODE_ASSIGN: char* rhs = generate_code(node->assign.rhs, list); append_quad(list, "=", rhs, "", node->assign.lhs); return NULL; default: return NULL; } }

注意:四元式生成必须严格遵循AST遍历顺序。若将generate_code()改为先处理右子树再左子树,则a + b * c可能生成t1 = b * c; t2 = a + t1; x = t2(正确),也可能生成t1 = a + b; t2 = t1 * c; x = t2(错误)。实验报告第4.5节用GDB单步跟踪展示了node->binop.left与node->binop.right的访问时序,并附有内存地址打印证明遍历路径。


3. 构建与调试全流程:从make clean到GDB断点追踪AST生成

拿到压缩包后别急着解压——先确认你的环境是否满足硬性依赖。这套代码基于GNU工具链设计,不兼容MinGW或MSVC,必须在Linux/macOS终端中操作。我测试过Ubuntu 20.04、CentOS 7.9和macOS Monterey(需Xcode Command Line Tools),所有环境均需预装flex 2.6.4+、bison 3.0.4+、gcc 7.5+。Windows用户请用WSL2,别试图用Cygwin——Bison在Cygwin下生成的parser.tab.c常因路径分隔符问题导致#include "parser.tab.h"失败。

3.1 一键构建:Makefile里的隐式规则与依赖陷阱

项目根目录的Makefile不是简单罗列gcc命令,而是利用Make的隐式规则自动关联.l→.c、.y→.c转换。关键在于%.c: %.l和%.c: %.y这两条模式规则:

# Makefile 片段 CC = gcc LEX = flex YACC = bison CFLAGS = -Wall -g -I. # 隐式规则:.l文件生成.c %.c: %.l $(LEX) -o $@ $< # 隐式规则:.y文件生成.c和.h %.c %.h: %.y $(YACC) -d -o $*.c $< # 主目标依赖链 compiler: main.o lexer.c parser.c ast.o $(CC) $(CFLAGS) -o $@ $^ main.o: main.c parser.tab.h ast.h lexer.c: lexer.l parser.c parser.tab.h: parser.y clean: rm -f *.o compiler lexer.c parser.c parser.tab.h *.out .PHONY: clean

执行make时,Make会自动检测lexer.l和parser.y的时间戳,仅在源文件变更时触发重编译。但新手常踩的坑是:修改.y后只运行bison -d parser.y却忘了make clean。因为parser.c和parser.tab.h由同一命令生成,若旧parser.c存在且时间戳新于parser.y,Make会跳过重建,导致main.c中#include "parser.tab.h"引用的仍是旧版头文件,编译时报unknown type name 'YYSTYPE'。血泪经验:只要动了.l或.y,第一反应就是make clean && make。

3.2 输入测试用例:从hello.world到嵌套if-else的边界验证

test/目录下预置了5个典型测试用例,覆盖教学大纲全部难点:

文件名功能关键验证点
test1.expr基础算术表达式1+2*3是否生成t1=2*3; t2=1+t1而非t1=1+2; t2=t1*3
test2.assign变量赋值与引用a=1; b=a+2;是否正确建立符号表并查重
test3.if单分支条件语句if (x>0) y=1;的AST是否包含CondNode与ThenPart
test4.ifelse双分支嵌套if (a) if (b) c=1; else d=1;是否按dangling else规则归约
test5.error故意构造错误if (x) y=1缺分号,验证错误恢复机制

运行方式统一为:./compiler < test/test1.expr。输出包含三部分:词法分析token流(带行号)、语法分析成功/失败提示、生成的四元式列表。例如test3.if应输出:

[LINE 1] IF token [LINE 1] ( token [LINE 1] ID(x) token [LINE 1] > token [LINE 1] NUM(0) token [LINE 1] ) token [LINE 1] ID(y) token [LINE 1] = token [LINE 1] NUM(1) token [LINE 1] ; token 语法分析成功 四元式: (>, x, 0, t1) (if_false, t1, _, 4) (=, 1, _, y)

提示:若输出中出现syntax error但肉眼看不出问题,请检查test/文件末尾是否有隐藏的UTF-8 BOM头。用file test3.if命令确认编码,用sed -i '1s/^\xEF\xBB\xBF//' test3.if清除BOM。

3.3 GDB深度调试:在AST节点创建处设置条件断点

当四元式生成错误时,光看输出不够,必须进GDB单步。我在ast.c的make_binary_op()函数首行加断点,但发现每次+、-、*、/都会中断,效率太低。于是改用条件断点,只在处理乘法时暂停:

$ gdb ./compiler (gdb) b ast.c:45 if op == '*' Breakpoint 1 at 0x40123a: file ast.c, line 45. (gdb) r < test/test1.expr Starting program: /path/to/compiler < test/test1.expr ... Breakpoint 1, make_binary_op (op=42 '*', left=0x5555557572a0, right=0x5555557572d0) at ast.c:45 45 struct ASTNode* node = malloc(sizeof(struct ASTNode)); (gdb) p *(struct ASTNode*)left $1 = {type = NODE_ID, id = {name = 0x555555757260 "b"}, ...} (gdb) p *(struct ASTNode*)right $2 = {type = NODE_ID, id = {name = 0x555555757290 "c"}, ...}

通过p命令打印左右子节点内容,可立即确认b * c的AST结构是否正确。若发现right是NULL,说明词法分析阶段c被误判为非法字符——此时要切回lexer.l检查正则规则是否遗漏了字母范围。


4. 常见问题排查:五个真实翻车现场与救火方案

这套资源在吉林大学多届学生中实测,以下问题是高频报错点。每一条都来自某开发者提交的调试日志,解决方案经GDB验证有效。

4.1 现象:make报错parser.tab.h: No such file or directory

原因:bison未安装或版本过低(<3.0),导致bison -d parser.y命令失败,parser.tab.h未生成。
解决:

  • 运行bison --version确认版本,Ubuntu用户执行sudo apt install bison,macOS用户用brew install bison;
  • 手动执行bison -d src/parser.y,观察是否输出conflicts: 1 shift/reduce等警告——若有,说明文法存在二义性,需按报告附录B修正%left声明;
  • 检查src/parser.y顶部%{与%}之间是否漏写#include <stdio.h>,缺失会导致bison生成的.c文件编译失败,进而中断.h生成。

4.2 现象:程序运行时Segmentation fault (core dumped)

原因:yylval类型不匹配引发内存越界。典型场景是lexer.l中yylval.str = strdup(yytext),但parser.y的%union未定义str字段,导致yylval被当作int处理,strdup()返回的指针被截断为低32位。
解决:

  • 对照parser.y的%union块与lexer.l中所有yylval.xxx赋值,确保字段名和类型完全一致;
  • 在main.c开头添加printf("sizeof(YYSTYPE)=%zu\n", sizeof(YYSTYPE));,若输出为4(32位)而预期是8(64位指针),说明%union未生效,检查%union是否被%{包裹或位置错误;
  • 临时在lexer.l中加入fprintf(stderr, "yylval.str=%p\n", yylval.str);,确认指针值是否合理(非0且非极小值)。

4.3 现象:if-else语句总是被解析为if A then B,else部分丢失

原因:parser.y中%nonassoc ELSE声明缺失,或if_stmt规则未正确使用%prec绑定优先级。
解决:

  • 检查parser.y是否包含%nonassoc LOWER_THAN_ELSE和%nonassoc ELSE两行;
  • 确认if_stmt第一条规则末尾有%prec LOWER_THAN_ELSE,第二条无此声明;
  • 运行bison -v src/parser.y生成parser.output文件,搜索state 12(通常为if状态),查看其中ELSE对应的action是shift还是reduce——应为shift,若显示reduce说明优先级未生效。

4.4 现象:四元式中出现(_, _, _, _)空操作,或临时变量名重复(如t1出现两次)

原因:temp_count全局变量未初始化或未在每次generate_code()调用前重置。
解决:

  • 在main.c的main()函数开头添加temp_count = 0;;
  • 将temp_count声明为static int temp_count = 0;而非全局变量,避免多文件链接冲突;
  • 在generate_code()入口添加printf("Enter generate_code for node %d\n", node->type);,确认函数是否被意外递归调用。

4.5 现象:test5.error中if (x) y=1未报错,反而生成了四元式

原因:语法分析器未启用错误恢复机制,yyparse()在遇到if (x) y=1(缺;)时直接返回失败,但main.c未检查返回值,继续执行后续代码。
解决:

  • 在main.c中yyparse()调用后添加:
    if (yyparse() != 0) { fprintf(stderr, "语法分析失败,退出\n"); return 1; }
  • 检查parser.y中%error-verbose是否启用,未启用时yyerror()只打印syntax error,启用后会输出syntax error, unexpected $undefined, expecting ';'等详细信息。

5. 实验报告精读指南:如何把PDF变成你的调试地图

实验报告不是交差文档,而是你调试时的“活体说明书”。它把每个实验步骤拆解为输入→预期输出→实际输出→差异分析→修复动作五层结构。比如词法分析章节,它不只告诉你“[a-zA-Z_][a-zA-Z0-9_]*匹配标识符”,而是给出一个对照表,列出abc,a1b2,_test,123abc四个输入,要求你手写匹配结果,并在报告附录中提供Flex的-d调试模式输出(即flex -d lexer.l生成的lex.yy.c中yy_flex_debug开启后的状态转移日志)。这意味着,当你发现_test未被识别时,可以直接翻到报告第2.3节,对照其提供的yy_flex_debug日志片段,确认是正则未覆盖下划线,还是[a-zA-Z_]中的_被转义符吃掉了。

5.1 报告中的“错误注入”设计:主动制造bug来训练debug能力

这份报告最反常规的设计,是在第3.4节“语法分析调试”中,故意提供一份有bug的parser.y副本(命名为parser_buggy.y),要求学生用bison -v比对parser.output与parser_buggy.output,找出shift/reduce conflict数量从0变为2的原因。经分析,parser_buggy.y中删除了%left '*' '/'声明,导致a*b+c产生歧义:既可归约为(a*b)+c也可a*(b+c)。报告不直接给答案,而是引导你:

  1. 运行bison -v parser_buggy.y生成parser_buggy.output;
  2. 搜索文件中state 5(乘法相关状态),查看'+'的action是shift还是reduce;
  3. 对比正常版parser.output中同一状态,发现'+'在正常版为shift(因*优先级高),而在buggy版为reduce(因无优先级声明,Yacc按默认移进优先处理);
  4. 结论:必须用%left显式声明,否则LR(1)分析器无法区分运算符层级。

这种设计逼你直面编译器内部状态,而不是背诵“左结合”定义。

5.2 四元式验证表:用手工计算反推代码逻辑

报告第4章“中间代码生成”附有一页A4纸大小的验证表,针对test4.ifelse用例,要求你手算if (a) if (b) c=1; else d=1;的四元式序列,并与程序输出逐行比对。表格列包括:

步骤AST节点计算逻辑预期四元式实际输出是否一致
1if (a)条件判断计算a值,跳转至else块(if_false, a, _, L1)(if_false, a, _, 8)✓
2if (b)嵌套条件在then块内计算b(if_false, b, _, L2)(if_false, b, _, 12)✓
3c=1赋值直接生成=四元式(=, 1, _, c)(=, 1, _, c)✓

当你发现第5行“else块跳转地址”不一致时(预期L2,实际15),说明generate_code()中else分支的标号生成逻辑有误——此时回到ast.c搜索else_label变量,发现它被声明为全局静态变量却未在每次if_stmt处理前重置,导致嵌套时标号复用。这个bug在纯代码阅读中极易忽略,但验证表用数值差异把它钉死在眼前。

5.3 符号表实现细节:从哈希桶到作用域链的落地

报告第5.2节“符号表管理”没有停留在“用哈希表存储变量”层面,而是给出了symtab.h中struct SymbolTable的完整定义,包括buckets[HASH_SIZE]数组、struct SymbolEntry* next指针、以及最关键的struct SymbolTable* parent作用域链指针。它用一个具体例子说明作用域链如何工作:

  • 全局作用域插入int x;→global_table->buckets[hash("x")]指向x节点;
  • 进入if块后调用push_scope()→ 新建local_table,其parent指向global_table;
  • 在if块中声明int x;→local_table->buckets[hash("x")]指向新x节点;
  • 查找x时,先查local_table,命中则返回,否则沿parent链查global_table。

报告要求你修改lookup_symbol()函数,在if块中访问全局x时输出global.x而非local.x。这迫使你理解:作用域链不是为了解决重名,而是为了实现遮蔽(shadowing)——局部变量遮蔽全局变量,是C语言的核心机制,而不仅是编译器的便利设计。

从那以后我每次写符号表代码,都强制走一遍push_scope()→insert()→lookup()→pop_scope()全链路,并用printf("Scope depth: %d\n", scope_depth);打印当前作用域层级。哪怕只是临时调试,这个习惯让我避开了至少三次因parent指针未正确赋值导致的段错误。希望帮到你。

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

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

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

立即咨询