☰
四川大学编译原理实验:手写词法分析器与语法解析器实战
2026/10/3 14:11:01 网站建设 项目流程

简介:本资源是四川大学《编译原理》课程配套实验教学材料,面向计算机专业本科生及编译技术初学者,系统支撑词法分析、语法分析、语义分析与代码生成四大核心阶段的动手实践。压缩包共53个文件,涵盖C语言源码(.c/.c-)、头文件(.h)、测试用例(.txt/.tny)、Makefile构建脚本、实验说明文档(.docx)、课程设计PPT(.pptx)及README.md项目指南,2.24MB体积轻量实用,便于本地编译运行与结构化学习。已有157人下载学习,适合课堂同步实践、课程设计参考或自学复现编译器前端组件。资源按周次组织(Week 6–12),每阶段均含可运行代码、测试样例与图文说明,尤其突出tiny语言语法分析、DFA图解(.gv)、三地址码生成等关键实现细节,辅以多版本对比文件(如c1-sample.txt/c1.txt)帮助理解修改逻辑,是理论落地与工程能力培养的优质实操载体。

1. 四川大学编译原理实验课:不是抄报告,是亲手把“hello world”喂给词法分析器嚼碎再吐出语法树

你手头这个.zip文件,表面看是“四川大学编译原理课程-实验课相关代码与实验报告”,但实际它是一套可运行、可调试、可打断点的微型编译器骨架——从hello.c这样的极简 C 子集源码开始,经词法扫描(Lexical Scanner)、语法分析(Parser)、中间代码生成(IR Generation),最终输出类汇编三地址码。它不依赖 LLVM 或 GCC 后端,所有核心逻辑用 C/C++ 手写,Makefile 控制全流程,.y和.l文件直连 Flex/Bison 工具链。这不是教学演示动画,而是学生真正在实验室里一行行改、一次次make clean && make、在 GDB 里看着yytext怎么被切分、yylval如何传进语法栈的实操包。适合刚学完《编译原理》清华大学出版社第三版前四章、正卡在“理论懂了但写不出 scanner”阶段的本科生;也适合想补全编译器工程闭环的嵌入式/系统方向开发者——毕竟,搞懂makefile怎么把.l编译成lex.yy.c,比背诵 LL(1) 文法判定条件更能让你在秋招笔试里多抢 30 秒。


2. 从解压到跑通:用最小命令链验证词法扫描器是否真正“看见”了关键字

2.1 解压后第一眼该盯住哪三个文件?

别急着打开 PDF 实验报告。先unzip "四川大学编译原理课程-实验课相关代码与实验报告-内含源码和说明书.zip",进入主目录后,立刻执行:

ls -la

你会看到类似这样的结构:

├── Makefile ├── src/ │ ├── lexer.l # Flex 词法规则定义(核心!) │ ├── parser.y # Bison 语法规则定义 │ ├── main.c # 主程序入口,调用 yyparse() │ └── utils.h/.c # 错误处理、符号表基础函数 ├── test/ │ └── hello.c # 测试用例:最简 C 子集代码 └── doc/ └── 实验指导书.pdf

提示:lexer.l是整个实验的起点。它定义了int,if,while等关键字如何被识别,数字、标识符、运算符怎么切分。parser.y负责把 lexer 输出的 token 序列组装成语法树。二者通过#include "parser.tab.h"和yylval联动——这是新手最容易断联的“黑匣子”。

2.2 用flex+bison生成扫描器和解析器源码

本实验不提供预编译的lex.yy.c或parser.tab.c,必须本地生成。确保已安装 Flex 和 Bison(Ubuntu/Debian 下sudo apt install flex bison;macOS 用brew install flex bison):

cd src flex lexer.l # 生成 lex.yy.c(词法分析器 C 源码) bison -d parser.y # 生成 parser.tab.c 和 parser.tab.h(-d 表示生成头文件)

执行后检查:

  • lex.yy.c是否存在且大小 > 5KB(太小说明 flex 未成功读取规则)
  • parser.tab.c和parser.tab.h是否成对出现(缺.h会导致main.c编译报unknown type name 'YYSTYPE')

参数说明:bison -d是关键。若漏掉-d,Bison 只生成.c,不生成.h,而lexer.l中#include "parser.tab.h"就会失败。很多同学卡在这一步,以为是环境问题,其实是命令少了一个字母。

2.3 编译链接:Makefile 里藏着三处必须手动校验的路径

回到项目根目录,查看Makefile内容(不要直接make!先读):

CC = gcc CFLAGS = -Wall -g -I./src SRC_DIR = ./src OBJ_DIR = ./build TARGET = compiler $(TARGET): $(OBJ_DIR)/main.o $(OBJ_DIR)/lex.yy.o $(OBJ_DIR)/parser.tab.o $(CC) $^ -o $@ -lfl $(OBJ_DIR)/%.o: $(SRC_DIR)/%.c | $(OBJ_DIR) $(CC) $(CFLAGS) -c $< -o $@ $(OBJ_DIR)/lex.yy.o: $(SRC_DIR)/lex.yy.c $(CC) $(CFLAGS) -c $< -o $@ $(OBJ_DIR)/parser.tab.o: $(SRC_DIR)/parser.tab.c $(CC) $(CFLAGS) -c $< -o $@ $(OBJ_DIR): mkdir -p $@ .PHONY: clean clean: rm -rf $(OBJ_DIR) $(TARGET)

重点校验三处:

  1. CFLAGS中-I./src:确保#include "utils.h"能被找到;
  2. $(OBJ_DIR)/lex.yy.o的依赖项是$(SRC_DIR)/lex.yy.c,而非lexer.l—— 这意味着flex必须提前手动运行,Makefile 不自动调用flex;
  3. 链接时-lfl:这是 Flex 库,提供yywrap()等基础函数,缺它会报undefined reference to 'yywrap'。

验证命令:

mkdir -p build gcc -Wall -g -I./src -c src/main.c -o build/main.o gcc -Wall -g -I./src -c src/lex.yy.c -o build/lex.yy.o gcc -Wall -g -I./src -c src/parser.tab.c -o build/parser.tab.o gcc build/main.o build/lex.yy.o build/parser.tab.o -o compiler -lfl

如果这四行能成功执行,说明环境和源码完全就绪。

2.4 运行测试:让hello.c在你的终端里“被编译”一次

准备测试输入test/hello.c(内容通常为):

int main() { int a = 10; if (a > 5) { return a; } }

执行:

./compiler test/hello.c

预期输出(取决于实验要求):

  • 若只做词法分析:打印INT,IDENTIFIER main,LBRACE,INT,IDENTIFIER a,ASSIGN,NUMBER 10, ... 一串 token;
  • 若完成语法分析:输出缩进格式的语法树(如(FuncDef (Type INT) (Ident main) ...))或三地址码(如t1 = 10,t2 = a > 5,if t2 goto L1)。

关键观察点:当./compiler test/hello.c报错时,第一行错误信息永远来自main.c中的yyparse()调用位置,而非lexer.l或parser.y。这意味着:错误定位要先看main.c的printf("Parse error at line %d\n", yylineno);是否启用,再顺藤摸瓜查yylineno为何没更新——这往往暴露了lexer.l中yylineno++的缺失或位置错误。


3. 语法分析器调试:为什么yyparse()总是返回 1?三步定位文法冲突

3.1 理解yyparse()返回值的工程含义

yyparse()是 Bison 生成的解析器入口函数,其返回值有严格语义:

  • 0:成功解析完整个输入(遇到YYEOF);
  • 1:语法错误(如if (x缺少右括号);
  • 2:内存分配失败(极少见)。

当你反复得到return 1,说明parser.y定义的文法无法匹配当前lexer.l输出的 token 流。这不是代码 bug,而是文法设计与词法输出不匹配——比如lexer.l把==识别为单个EQtoken,但parser.y中却写成expr EQ expr,而实际lexer.l输出的是两个EQ(即==),就会导致归约失败。

3.2 开启 Bison 调试模式:让语法树“开口说话”

修改parser.y头部,加入调试开关:

%define parse.error verbose %debug %error-verbose

并在main.c中启用调试输出:

extern int yydebug; int main(int argc, char *argv[]) { if (argc != 2) { /* ... */ } yydebug = 1; // 关键!开启 Bison 调试日志 FILE *fp = fopen(argv[1], "r"); if (!fp) { /* ... */ } yyin = fp; int result = yyparse(); printf("Parse result: %d\n", result); fclose(fp); return result; }

重新编译运行:

make clean && make && ./compiler test/hello.c

你会看到类似输出:

Starting parse Entering state 0 Reading a token: Next token is token INT () Shifting token INT () Entering state 2 Reading a token: Next token is token IDENTIFIER () ... Reducing stack by rule 5 (line 45): -> $@1

逻辑说明:这些日志显示了 LR(1) 分析器的状态转移过程。“Shifting” 表示移进,“Reducing” 表示归约。当卡在某个状态迟迟不归约,或反复Reading a token却不Shifting,说明当前 token 不在该状态的 ACTION 表中——即文法未覆盖该 token 组合。

3.3 用bison -v生成详细分析表,定位 shift/reduce 冲突

在src/目录下执行:

bison -v parser.y

会生成parser.output文件。用less parser.output查看,重点搜索conflict:

State 17 conflicts: 1 shift/reduce ... state 17 expr: expr . '+' expr expr: expr . '-' expr expr: expr . '*' expr '+' shift, and go to state 20 '-' shift, and go to state 21 '*' shift, and go to state 22 '+' reduce using rule 5 (expr) '-' reduce using rule 5 (expr)

这表示:在 state 17,遇到+时,既可以移进(期待后续expr),也可以按 rule 5 归约(把已有的expr当作完整表达式)。这就是典型的优先级/结合性缺失。

解决方法(在parser.y顶部添加):

%left '+' '-' %left '*' '/' %right UMINUS /* 用于负号 -5 */

然后删除旧的parser.tab.*,重新bison -d parser.y。%left告诉 Bison:当冲突发生时,优先选择shift(即把+当作运算符而非归约边界),并赋予左结合性。

参数说明:%left和%right不是语法糖,而是直接改写 Bison 内部的冲突解决策略。没有它们,a + b + c会被错误地解析为a + (b + c)(右结合),而数学要求左结合。


4. 避坑指南:编译原理实验中最常踩的 5 个“血泪坑”

4.1 现象:make报错makefile:18: *** No rule to make target 'libs'

原因:Makefile第 18 行写了libs:目标,但当前目录下既无libs文件,也无对应规则。常见于学生从其他项目复制 Makefile 时未清理冗余目标。
解决:打开Makefile,定位第 18 行,删除整行libs:及其后续所有以 Tab 开头的命令行;或确认是否真需构建libs,若是,则补全libs: $(OBJ_DIR)/utils.o等依赖。

4.2 现象:lexer.l中printf("KEYWORD: %s\n", yytext);输出乱码或截断

原因:yytext是 Flex 内部指针,指向扫描缓冲区,不能长期持有或跨函数使用。若在lexer.l中将其赋值给全局变量char* last_token,下次扫描时该内存已被覆盖。
解决:立即拷贝内容。改为strncpy(last_token, yytext, sizeof(last_token)-1); last_token[sizeof(last_token)-1] = '\0';,且last_token必须声明为static char last_token[256];。

4.3 现象:yyparse()成功返回 0,但语法树为空或节点缺失

原因:parser.y中的语义动作({ ... }块)未正确设置$$(当前产生式左部值)。例如stmt: IF '(' expr ')' stmt { $$ = new_if_node($3, $5); },若$3(expr)或$5(stmt)本身为NULL,new_if_node可能返回NULL,导致父节点丢失。
解决:在每个语义动作中加空值检查:

stmt: IF '(' expr ')' stmt { if ($3 == NULL || $5 == NULL) { YYERROR; // 主动报错,而非静默返回 NULL } $$ = new_if_node($3, $5); }

4.4 现象:test/hello.c中int a = 10;被识别为INT IDENTIFIER = NUMBER ;,但parser.y中decl: TYPE IDENTIFIER ASSIGN NUMBER SEMI规则不触发

原因:lexer.l中=和==、!=等混淆。典型错误是:

"==" { return EQ; } "=" { return ASSIGN; } "!=" { return NE; } "=" { return ASSIGN; } // ❌ 重复定义!Flex 按最长匹配优先,但此处两条 "=" 规则冲突

解决:删除重复规则,确保每个 pattern 唯一;用flex -v lexer.l查看警告,Flex 会提示"=" can match..."。

4.5 现象:在 macOS 上编译报错ld: library not found for -lfl

原因:macOS 自带的libfl位于非标准路径,且新版 Xcode 命令行工具默认不链接。
解决:两种方案任选其一:
① 安装 Homebrew Flex 并强制使用:

brew install flex export PATH="/opt/homebrew/opt/flex/bin:$PATH" # Apple Silicon # 或 export PATH="/usr/local/opt/flex/bin:$PATH" # Intel make clean && make

② 修改Makefile,用绝对路径链接:

# 替换原链接行 $(TARGET): $(OBJ_DIR)/main.o $(OBJ_DIR)/lex.yy.o $(OBJ_DIR)/parser.tab.o gcc $^ -o $@ /opt/homebrew/opt/flex/lib/libfl.a # Apple Silicon 路径

5. 进阶技巧:用 GDB 逐行跟踪yylex()如何切分while (i < 10)

5.1 设置断点前的关键准备:让yylex()可见且可停

默认情况下,Flex 生成的lex.yy.c中yylex()函数被声明为static int yylex(void),GDB 无法直接break yylex。需在lexer.l顶部添加:

%{ #include "parser.tab.h" #include <stdio.h> // 👇 强制取消 static 修饰 #undef yylex int yylex(void); %}

然后重新生成:flex lexer.l。此时lex.yy.c中的yylex声明变为int yylex(void),GDB 可识别。

5.2 GDB 调试实战:三步锁定while识别逻辑

假设test/while.c内容为:

while (i < 10) { i = i + 1; }

启动 GDB:

gdb ./compiler (gdb) break yylex (gdb) run test/while.c

首次停在yylex()入口。用step逐行执行,重点关注:

  • yy_c_buf_p指针位置(当前扫描字符);
  • yytext内容(当前匹配文本);
  • yyleng长度(匹配长度)。

典型流程:

  1. yy_c_buf_p指向'w'→ 匹配规则"while"→yytext = "while"→return WHILE;
  2. yy_c_buf_p移至' '(空格)→ 匹配[ \t\n]+→yytext = " "→return 0(跳过空白);
  3. yy_c_buf_p移至'('→ 匹配'('→return LPAREN;

技巧:在 GDB 中用print (char*)yytext查看当前 token 字符串;用x/10c yy_c_buf_p查看后续 10 字符原始内容。你会发现:while被识别后,yy_c_buf_p自动跳过 5 字节,精准停在空格上——这就是 Flex 的input()函数在底层控制的指针移动。

5.3 用yy_scan_string()注入动态字符串,绕过文件 IO 调试

不想每次改test/while.c再重跑?在main.c中临时替换文件读取逻辑:

#include <string.h> // ... int main(int argc, char *argv[]) { // 注释掉原文件读取 // FILE *fp = fopen(argv[1], "r"); // yyin = fp; // 改为内存字符串扫描 const char *test_code = "while (i < 10) { i = i + 1; }"; yy_scan_string(test_code); // Flex 提供的 API int result = yyparse(); printf("Parse result: %d\n", result); // fclose(fp); // 不需要 fclose return result; }

重新编译运行,GDB 断点直接落在内存字符串上,修改test_code字符串即可秒级验证新规则。

5.4 一个真实教训:我曾花 7 小时 debugyylineno不更新

那是我第一次实现#line指令支持。lexer.l中写了:

"#line"[ \t]+[0-9]+ { sscanf(yytext, "#line %d", &yylineno); // ❌ 错误:yytext 格式为 "#line 123",sscanf 会失败! }

实际yytext是#line 123(带空格),sscanf读不到数字。正确写法是:

"#line"[ \t]+[0-9]+ { char *p = yytext + 5; // 跳过 "#line" while (*p == ' ' || *p == '\t') p++; yylineno = atoi(p); }

后来我在lexer.l顶部加了一行#define DEBUG_LEXER,并在每个规则末尾加:

{ printf("LINE %d: %s -> %s\n", yylineno, yytext, "<KEYWORD>"); }

——从此所有 token 都带行号输出,yylineno问题再没出现过。

希望帮到你。

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

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

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

立即咨询