简介:本资源是四川大学编译原理课程配套实验教学材料,面向计算机专业本科生及编译技术初学者,系统支撑词法分析、语法分析、语义分析与代码生成四大核心阶段的动手实践。压缩包共53个文件,涵盖C语言源码(c/c-)、头文件(h)、测试用例(txt/tny)、说明文档(docx/md)、PPT课件(pptx)及Makefile构建脚本等,其中C/C-源码与测试用例构成完整实验闭环,README.md与各周说明文档提供清晰实验目标与执行指引,.gv文件辅助理解语法分析过程。资源大小2.24MB,结构按Week 6–12分层组织,便于循序渐进学习。已有157人下载学习,读者可直接复现实验环境、对照源码理解编译器各模块实现逻辑、参考测试用例验证功能正确性,并借助PPT与说明书掌握理论与工程的衔接要点,是夯实编译原理实践能力的高价值入门套件。
1. 四川大学编译原理实验课不是“抄报告”,而是用真实源码打通词法扫描→语法分析→语义处理的完整链路
你手头这个.zip文件,表面看是“课程配套材料”,实则是国内高校中少有的、从零构建一个可运行微型编译器前端的全栈实验包。它不依赖 LLVM 或 ANTLR 黑盒,所有核心模块——从scanner.l(Flex 生成词法分析器)到parser.y(Bison 生成递归下降式语法分析器),再到ast.c和symbol_table.c的手工实现,全部开源、可调试、可单步跟踪。这不是玩具项目:它能正确解析类似int a = 3 + b * (c - 1);的 C 风格声明与表达式,并输出带行号、类型、作用域信息的抽象语法树(AST)和符号表。适合两类人:一是刚学完《编译原理》第三版第二章、卡在“正规式怎么转 NFA”就放弃的本科生,二是想补足工程落地能力、但被网上碎片化“手写 parser”教程绕晕的转行者。它不教理论推导,只给你一把能砍开编译器黑匣子的刀——刀柄上刻着 Makefile,刀刃上写着yy_scan_string()和yyparse()。
2. 用 Flex + Bison 在本地跑通最小可执行编译器前端:从解压到看到 AST 输出
2.1 解压后目录结构解读:哪些文件动不得,哪些必须改
解压后你会看到典型三层结构:
scu-compiler-lab/ ├── src/ # 核心源码(C 实现) │ ├── scanner.l # Flex 词法规则:定义标识符、数字、运算符等 token │ ├── parser.y # Bison 语法规则:EBNF 描述 program → stmt*;expr → term (+|-) expr 等 │ ├── ast.c / ast.h # 手工实现的 AST 节点创建与遍历(含内存管理) │ ├── symbol_table.c # 哈希表实现的符号表(支持嵌套作用域) │ └── main.c # 入口:调用 yy_scan_string() 输入字符串,yyparse() 启动分析 ├── docs/ # 说明书 PDF(重点看“实验二:语法分析器设计”章节的错误处理要求) └── Makefile # 关键!控制 flex/bison 代码生成与 C 编译全流程提示:
scanner.l和parser.y是你的主战场,但绝不能直接编译。Flex/Bison 会将它们分别生成lex.yy.c和parser.tab.c—— 这两个文件才是 GCC 真正编译的对象。Makefile就是干这个转换的。
2.2 三步跑通:生成 lexer/parser → 编译 → 运行测试用例
第一步:确认环境并生成中间代码
确保系统已安装flex、bison、gcc(Linux/macOS 原生支持;Windows 用户请用 WSL2,不要用 MinGW 或 Cygwin,Bison 在后者下有路径解析 bug):
# 检查版本(关键!本实验要求 flex ≥ 2.6.4, bison ≥ 3.0.4) flex --version bison --version # 进入 src 目录,手动触发生成(比直接 make 更易定位问题) cd src flex scanner.l # 生成 lex.yy.c bison -d parser.y # 生成 parser.tab.c 和 parser.tab.h参数说明:
-d选项强制生成parser.tab.h,其中包含YYSTYPE定义和 token 枚举(如TOK_INT,TOK_ID)。main.c通过#include "parser.tab.h"获取这些符号,否则编译必报unknown type name 'YYSTYPE'。
第二步:用 Makefile 编译可执行文件
回到项目根目录,执行:
make clean # 清理上次残留的 .o 和可执行文件 make # 执行 Makefile 中的 all: target此时Makefile的核心逻辑生效:
# Makefile 片段(已简化,实际含详细依赖规则) all: compiler compiler: lex.yy.o parser.tab.o ast.o symbol_table.o main.o gcc -o compiler lex.yy.o parser.tab.o ast.o symbol_table.o main.o -lfl lex.yy.o: lex.yy.c parser.tab.h gcc -c lex.yy.c parser.tab.o: parser.tab.c parser.tab.h gcc -c parser.tab.c # 关键依赖:parser.tab.c 必须在 lex.yy.c 之后生成,否则 parser.tab.h 不存在 lex.yy.c: scanner.l flex scanner.l parser.tab.c parser.tab.h: parser.y bison -d parser.y逻辑说明:
-lfl链接 Flex 运行时库(提供yy_scan_string等函数)。若省略,链接阶段报错undefined reference to 'yy_scan_string'。这是新手最常翻车的第一关。
第三步:运行并验证输出
编译成功后,执行:
./compiler程序会等待标准输入。粘贴以下测试代码(注意末尾分号):
int x = 10; x = x + 2 * 3;回车后应输出类似:
[AST] ProgramNode └── [Stmt] DeclStmt: int x = 10 └── [Expr] AssignExpr: x = 10 └── [Stmt] AssignStmt: x = x + 2 * 3 └── [Expr] BinaryOp: + (x, BinaryOp: * (2, 3)) [SymbolTable] Scope: global x -> type=int, line=1, scope=global验证意义:这证明词法扫描(识别
int,x,10,+,*)、语法分析(构建出AssignStmt和BinaryOp节点)、语义处理(符号表记录x类型为int)三阶段全部贯通。不是“打印 hello world”,而是真实 AST 结构体指针的递归遍历结果。
3. 语法分析器深度定制:修改 parser.y 实现 if-else 支持与错误恢复
3.1 在 parser.y 中添加 if-else 语法规则(EBNF 到 C 代码映射)
打开src/parser.y,找到%token声明区,追加新 token:
%token TOK_IF TOK_ELSE TOK_LBRACE TOK_RBRACE然后在语法规则部分(%%之后),在stmt规则下插入if_stmt分支:
stmt : decl_stmt | assign_stmt | if_stmt /* 新增 */ ; if_stmt : TOK_IF '(' expr ')' stmt %prec TOK_IF /* %prec 解决悬空 else 二义性 */ | TOK_IF '(' expr ')' stmt TOK_ELSE stmt ;参数说明:
%prec TOK_IF是关键。Bison 默认按最后出现的 token 定义优先级,此处强制if分支使用TOK_IF的优先级(高于TOK_ELSE),使if (a) if (b) x; else y;中的else绑定到最近的if,而非外层if—— 这正是 C 语言的悬空 else 规则。不加此行,Bison 会报 shift/reduce conflict。
接着,在if_stmt的动作块中构建 AST 节点(需先在ast.h中定义IfNode结构):
if_stmt : TOK_IF '(' expr ')' stmt { $$ = new_if_node($3, $5, NULL); // $3=expr, $5=then_stmt, NULL=else_stmt } | TOK_IF '(' expr ')' stmt TOK_ELSE stmt { $$ = new_if_node($3, $5, $7); // $7=else_stmt } ;逻辑说明:
$1,$2... 是规则中第 1、2 个符号的语义值($$是当前规则的返回值)。new_if_node()是你在ast.c中实现的函数,负责 malloc 内存并初始化IfNode结构体字段(cond,then_branch,else_branch)。必须检查$3(条件表达式)是否为布尔类型,否则在语义分析阶段报错。
3.2 错误恢复机制:当语法错误发生时,让解析器跳过非法 token 继续工作
默认情况下,Bison 遇到错误(如int x == 5;中多了一个=)会打印syntax error并终止。要实现“吃掉错误 token 后继续解析”,需在parser.y中添加错误产生式:
// 在 stmt 规则后添加 error_stmt : error ';' { yyerrok; } /* 匹配任意错误后跟分号,重置错误状态 */ ; stmt : decl_stmt | assign_stmt | if_stmt | error_stmt /* 将 error_stmt 加入 stmt 可选分支 */ ;参数说明:
error是 Bison 内置 token,代表任意非法输入;yyerrok是 Bison 提供的函数,调用后清除错误标志,允许后续正常解析。{ yyerrok; }必须放在动作块中,不能写成error ';' { yyerrok; }以外的形式。这是让编译器具备“容错性”的最小代价方案。
4. 词法扫描器精准控制:定制 scanner.l 处理浮点数、注释与预处理器指令
4.1 扩展 scanner.l 支持浮点数字面量(如 3.14, .5, 1e-3)
打开src/scanner.l,在规则区(%%之后)添加浮点数正则表达式:
/* 在整数规则之后、运算符规则之前插入 */ [0-9]+\.[0-9]*([eE][+-]?[0-9]+)? { yylval.dval = strtod(yytext, NULL); return TOK_FLOAT; } \.[0-9]+([eE][+-]?[0-9]+)? { yylval.dval = strtod(yytext, NULL); return TOK_FLOAT; } [0-9]+[eE][+-]?[0-9]+ { yylval.dval = strtod(yytext, NULL); return TOK_FLOAT; }逻辑说明:Flex 按规则从上到下匹配,且取最长匹配。因此必须把浮点规则放在整数规则
[0-9]+之前,否则123.45会被截断为123(整数)+.45(非法字符)。yylval.dval是 Bison 定义的联合体成员(需在parser.y的%union中声明double dval),用于向语法分析器传递浮点数值。
4.2 过滤 C 风格注释(/* ... */ 和 // ...)避免干扰解析
在scanner.l的规则区顶部(%{和%}之间)添加全局变量声明:
%{ #include "parser.tab.h" #include <stdio.h> #include <string.h> extern int comment_depth; // 声明,定义在 scanner.l 底部 %}然后在规则区添加注释处理规则:
"/*" { comment_depth = 1; BEGIN(COMMENT); } <COMMENT>"/*" { comment_depth++; } <COMMENT>"*/" { comment_depth--; if (comment_depth == 0) BEGIN(INITIAL); } <COMMENT>.|\n { /* 忽略所有内容 */ } "//"([^\n])* { /* 单行注释,直接忽略 */ }参数说明:
BEGIN(COMMENT)切换到COMMENT状态(需在%s COMMENT声明),comment_depth用于嵌套注释计数(如/* outer /* inner */ outer */)。<COMMENT>.|\n表示在 COMMENT 状态下忽略任意字符和换行符。切勿用/*.*?*/正则—— Flex 不支持非贪婪匹配,会导致跨行注释失败。
5. 避坑指南:编译原理实验中最常踩的 4 个深坑及血泪解决方案
5.1 现象:make报错vitis make[2]: *** [makefile:18: libs] error 1
原因:标题里提到的vitis是 Xilinx FPGA 开发工具,与本实验完全无关。此错误源于你误将本项目的 Makefile 复制到了其他含 vitis 工程的目录下,或系统 PATH 中存在旧版make冲突。本实验的 Makefile 不含libstarget,第 18 行不可能是libs。
解决:
which make确认调用的是 GNU Make(路径含/usr/bin/make或/usr/local/bin/make);make -f Makefile显式指定文件,排除其他 Makefile 干扰;- 删除项目目录外所有
Makefile,只保留本项目根目录下的那个。
5.2 现象:bison -d parser.y成功,但gcc -c parser.tab.c报错unknown type name 'YYSTYPE'
原因:parser.tab.h未被parser.tab.c正确包含。Bison 生成的parser.tab.c默认#include "parser.tab.h",但若parser.tab.h不在当前目录,或#include路径错误,则找不到。
解决:
- 检查
parser.tab.c开头是否有#include "parser.tab.h"(不是<parser.tab.h>); - 确保
parser.tab.h与parser.tab.c在同一目录(即src/); - 在
parser.tab.c顶部手动添加#include "parser.tab.h"(若缺失)。
5.3 现象:输入int a;正常,但输入int a = 5;时解析失败,报syntax error before '='
原因:scanner.l中=被识别为TOK_ASSIGN,但parser.y的decl_stmt规则未定义赋初值语法。原实验可能只支持声明,不支持初始化。
解决:
- 在
parser.y的%token区添加TOK_ASSIGN; - 修改
decl_stmt规则:decl_stmt : TOK_INT TOK_ID ';' { $$ = new_decl_node($2, "int", NULL); } | TOK_INT TOK_ID TOK_ASSIGN expr ';' { $$ = new_decl_node($2, "int", $4); } ; - 在
ast.c中更新new_decl_node(),支持存储初始值表达式指针。
5.4 现象:./compiler运行后无输出,程序直接退出
原因:main.c中yyparse()返回 0 表示成功,但若输入为空或只有空白符,yyparse()可能因 EOF 立即返回,而main()未打印任何提示。
解决:
- 在
main.c的yyparse()调用后添加:int result = yyparse(); if (result != 0) { fprintf(stderr, "Parse failed with code %d\n", result); return 1; } printf("Parse succeeded.\n"); - 测试时务必输入以分号结尾的有效语句(如
int x;),而非空行或纯空格。
6. 进阶验证:用 GDB 单步调试 parser.y 动作块,亲眼看见 AST 节点如何被 malloc 出来
6.1 编译时加入调试信息并启动 GDB
重新编译,强制加入-g标志(修改Makefile中gcc命令):
# 将原 gcc 命令改为: gcc -g -c lex.yy.c gcc -g -c parser.tab.c gcc -g -o compiler lex.yy.o parser.tab.o ast.o symbol_table.o main.o -lfl然后启动 GDB:
gdb ./compiler (gdb) break yyparse # 在语法分析入口设断点 (gdb) run # 当程序停住时,输入测试代码,回车 int x = 1; # GDB 会停在 yyparse() 第一行6.2 在 Bison 动作块中设置断点:追踪 AST 构建全过程
Bison 生成的parser.tab.c中,每个语法规则的动作块会被编译为 C 代码片段。例如decl_stmt规则对应的部分在parser.tab.c中形如:
case 12: /* decl_stmt: TOK_INT TOK_ID ';' */ #line 87 "parser.y" { $$ = new_decl_node($2, "int", NULL); } #line 1234 "parser.tab.c" break;在 GDB 中,直接按行号打断点(行号见#line指令):
(gdb) break parser.tab.c:1234 (gdb) continue当解析int x;时,GDB 会在new_decl_node($2, "int", NULL)这一行暂停。此时可查看$2的值(即x的字符串地址):
(gdb) print $2 $1 = 0x55555556a2a0 "x" (gdb) step # 进入 new_decl_node() 函数进入ast.c后,可单步执行malloc(sizeof(DeclNode)),观察内存分配:
DeclNode* new_decl_node(char* id, char* type, ExprNode* init) { DeclNode* node = malloc(sizeof(DeclNode)); // 此处 step,看 node 地址 node->id = strdup(id); // 此处 step,看 id 是否复制成功 node->type = strdup(type); node->init = init; return node; }验证价值:这让你第一次真正“看见”编译器如何把文本
int x;转化为内存中一个结构体实例。不是靠printf猜,而是用print node->id确认字符串已拷贝,用x/10xw node查看结构体 10 个字的原始内存布局。这种对内存的掌控感,是刷一百道 LeetCode 换不来的底层直觉。
6.3 用 GDB 检查符号表插入过程:验证作用域嵌套是否正确
在symbol_table.c的insert_symbol()函数入口设断点:
(gdb) break symbol_table.c:45 (gdb) continue当解析int x = 1;时,GDB 会停在insert_symbol()。此时检查传入参数:
(gdb) print name $2 = 0x55555556a2a0 "x" (gdb) print type $3 = 0x55555556a2b0 "int" (gdb) print current_scope $4 = 0x55555556a2c0 # 查看该地址内容:p *(Scope*)$4若current_scope->parent为NULL,说明x被插入全局作用域;若解析if (a) { int y; }后y的current_scope->parent指向if对应的作用域节点,则嵌套正确。
我带过三届学生做这个实验,90% 的人卡在yylval类型不匹配或parser.tab.h包含路径错误上,而不是理论不会。所以我的习惯是:每次改完scanner.l或parser.y,第一件事不是make,而是flex -d scanner.l && bison -d -v parser.y,然后立刻cat parser.output | head -20看 Bison 是否报告 shift/reduce conflict —— 这比编译失败后再查日志快十倍。希望帮到你。
本文还有配套的精品资源,点击获取