编译原理实验实战:从makefile构建到词法语法语义分析全流程
2026/9/23 7:29:10 网站建设 项目流程

简介:这份资源是四川大学编译原理课程的实验课配套资料,面向正在学习编译原理、需要动手实现编译器各阶段组件的高校学生。内容按周次组织,覆盖词法分析、语法分析、语义分析与代码生成等核心环节,并配有课程设计PPT与说明文档,便于对照理论逐步实践。压缩包共53个文件,约2.24MB,以txt测试用例、h头文件、c与c-源码、tny示例、makefile构建脚本、docx说明文档及pptx课件为主,另含README.md与dfa.gv等辅助文件,可支撑从记号流识别到抽象语法树构建、类型检查直至目标代码生成的完整实验流程。目前已有157人学习下载。读者可借助源码工程与测试用例复现各阶段实验,结合说明文档理解提交要求与实现思路,在动手编写词法扫描器、语法分析器与代码生成模块的过程中巩固编译原理知识,积累编译器组件开发经验。

1. 从一份课程实验包说起:编译原理到底该怎么动手学

很多人对编译原理的印象停留在龙书里那些形式化推导,真正打开一份「四川大学编译原理课程-实验课相关代码与实验报告」的压缩包时,反而不知道从哪里下手。这份资料的核心价值不在于它来自哪所学校,而在于它把词法分析、语法分析、语义处理、中间代码生成这条链路,用可编译、可运行的源码和配套实验报告串了起来。你拿到的不只是答案,而是一套能对照自己实现进度的参照系。适合正在上编译原理实验课、准备用 Java 或 C++ 手写一遍编译器前端、或者想通过 makefile 把多个源文件组织成工程的人。下面我按自己复现这类实验包的顺序,把每一步拆开讲。

2. 实验包结构拆解与源码阅读顺序:先跑通再读懂

2.1 拿到压缩包先做三件事:解压、看目录、找入口

不要一上来就翻源码。我一般先解压到一个纯英文路径下,避免后续 makefile 里出现中文路径导致的玄学报错。然后执行:

unzip 四川大学编译原理课程-实验课相关代码与实验报告.zip -d compiler_lab cd compiler_lab find . -maxdepth 2 -type f | sort

这条命令列出两层目录内的所有文件,目的是快速判断实验包的组织方式。常见结构是src/放源码、doc/report/放实验报告、根目录放MakefileCMakeLists.txt。如果看到lexerparsersemantic这样的子目录,说明实验是按编译阶段拆分的,阅读顺序就应该按阶段走,而不是按文件名的字母序。

参数说明:-maxdepth 2控制递归深度,太深会刷屏,太浅会漏掉关键文件。| sort让输出稳定,方便你截图或记录。

2.2 用 makefile 判断构建入口和依赖关系

热词里频繁出现 makefile 相关的问题,比如「make没有指明目标并且找不到makefile」「生成makefile」,说明很多人卡在构建这一步。拿到实验包后,先确认根目录有没有 Makefile:

ls -la Makefile makefile GNUmakefile 2>/dev/null

如果存在,直接看第一个目标:

head -40 Makefile

重点看三样东西:编译器变量(CCCXX)、源文件列表(SRCSOBJS)、最终目标(all或具体可执行文件名)。一个典型的课程实验 Makefile 长这样:

CXX = g++ CXXFLAGS = -std=c++17 -Wall -g SRCS = src/lexer.cpp src/parser.cpp src/main.cpp OBJS = $(SRCS:.cpp=.o) TARGET = compiler all: $(TARGET) $(TARGET): $(OBJS) $(CXX) $(CXXFLAGS) -o $@ $^ %.o: %.cpp $(CXX) $(CXXFLAGS) -c $< -o $@ clean: rm -f $(OBJS) $(TARGET)

逻辑说明:SRCS列出所有参与编译的源文件,OBJS用替换规则把.cpp换成.oall是默认目标。如果你新增了源文件却没有加进SRCS,链接阶段就会报未定义符号。参数说明:-std=c++17是课程实验常用的标准,-g保留调试信息,方便用 gdb 跟语法分析栈。

提示:如果make报「没有指明目标并且找不到 makefile」,先确认当前目录是否正确,再确认文件名大小写。Linux 下Makefilemakefile都能识别,但MakeFile不行。

2.3 源码阅读顺序:从 main 到 lexer,再到 parser

跑通之后,按调用链读源码。先看main.cpp,找到它如何读取输入、调用词法分析、再调用语法分析。常见写法是:

int main(int argc, char** argv) { if (argc < 2) { std::cerr << "usage: compiler <input_file>" << std::endl; return 1; } Lexer lexer(argv[1]); Parser parser(&lexer); auto ast = parser.parse(); ast->dump(); return 0; }

这段代码说明实验的入口是文件参数,词法分析器持有文件流,语法分析器持有词法分析器指针。读的时候重点看LexernextToken()Parserparse(),这两个函数决定了整个实验的骨架。实验报告里通常会有对应的状态转移图或文法产生式,对照着看能省很多时间。

3. 词法分析与语法分析的最小可运行实现:把实验报告里的表格变成代码

3.1 词法分析器:从正则到 DFA 的落地写法

实验报告里一般会给一张 token 表,列出关键字、运算符、界符和标识符的正则。落地时不需要真的写一个 DFA 生成器,手写一个带回溯的扫描器就够课程实验用。核心逻辑是:

Token Lexer::nextToken() { skipWhitespaceAndComments(); if (eof()) return Token{TokenType::END, ""}; char c = peek(); if (isalpha(c)) return scanIdentifierOrKeyword(); if (isdigit(c)) return scanNumber(); return scanOperatorOrDelimiter(); }

逻辑说明:先跳过空白和注释,再按首字符分派。scanIdentifierOrKeyword读出完整单词后查关键字表,命中就是关键字,否则是标识符。参数说明:关键字表建议用std::unordered_set<std::string>,查询 O(1),比一串if-else好维护。

实验报告里常考的坑是「最长匹配」和「回退」。比如>=不能拆成>===不能拆成两个=。我的做法是在scanOperatorOrDelimiter里先看两个字符的组合,再回退到单字符。

3.2 语法分析器:递归下降适合手写,LL(1) 适合对照实验报告

课程实验的语法分析通常要求实现 LL(1) 或递归下降。递归下降更直观,每个非终结符对应一个函数:

ASTNode* Parser::parseExpr() { ASTNode* left = parseTerm(); while (match(TokenType::PLUS) || match(TokenType::MINUS)) { Token op = previous(); ASTNode* right = parseTerm(); left = new BinaryOp(op, left, right); } return left; }

逻辑说明:parseExpr处理加减,parseTerm处理乘除,parseFactor处理括号和数字,三层递归自然实现优先级。参数说明:match函数在匹配成功时前进 token 指针,失败时不前进,这样while循环能正确判断是否继续。

如果你用的是 LL(1) 预测分析表,实验报告里会给出 FIRST 集和 FOLLOW 集。落地时把表存成二维数组或map<pair<非终结符, 终结符>, 产生式>,然后用一个显式栈模拟递归。两种方式都行,但递归下降更容易调试,因为调用栈就是语法树。

3.3 用 makefile 把多个阶段串起来:一次构建,分步测试

课程实验通常要求每个阶段能单独输出结果。我一般会在 Makefile 里加几个伪目标:

test-lexer: $(TARGET) ./$(TARGET) --lexer tests/input1.txt test-parser: $(TARGET) ./$(TARGET) --parser tests/input1.txt test-all: test-lexer test-parser

逻辑说明:通过命令行参数控制程序只跑到词法或语法阶段,方便对照实验报告里的输出。参数说明:--lexer--parser需要在main.cpp里解析,用简单的字符串比较即可。这样你改完词法分析器,不用重新跑整个编译流程就能验证 token 序列是否正确。

4. 语义分析与中间代码生成:实验报告里最容易翻车的部分

4.1 符号表设计:作用域和类型检查的基石

语义分析的核心是符号表。课程实验一般要求支持变量声明、类型检查和简单的作用域。我常用栈式符号表:

class SymbolTable { std::vector<std::unordered_map<std::string, Symbol>> scopes; public: void enterScope() { scopes.emplace_back(); } void exitScope() { scopes.pop_back(); } bool declare(const std::string& name, const Symbol& sym) { auto& top = scopes.back(); if (top.count(name)) return false; top[name] = sym; return true; } Symbol* lookup(const std::string& name) { for (auto it = scopes.rbegin(); it != scopes.rend(); ++it) { auto found = it->find(name); if (found != it->end()) return &found->second; } return nullptr; } };

逻辑说明:enterScopeexitScope对应花括号的进入和退出,lookup从内层向外层查找,实现变量遮蔽。参数说明:declare返回false表示重复声明,实验报告里通常要求报错。

血泪经验:很多人忘记在函数参数和局部变量之间区分作用域,导致同名参数覆盖全局变量时查不到。我的做法是函数体进入时先enterScope,把参数声明进去,再处理函数体。

4.2 中间代码生成:三地址码的模板写法

中间代码生成通常要求输出三地址码或四元式。以表达式a = b + c * d为例,递归下降生成三地址码:

std::string Parser::genExpr(ASTNode* node) { if (node->isLeaf()) return node->value; std::string left = genExpr(node->left); std::string right = genExpr(node->right); std::string temp = newTemp(); emit(temp + " = " + left + " " + node->op + " " + right); return temp; }

逻辑说明:后序遍历语法树,先算左右子表达式,再生成一条新指令。newTemp()每次返回t1t2……参数说明:emit把指令追加到全局列表,最后统一输出。实验报告里常要求给出每条指令的序号和操作数,这个模板直接对应。

注意:三地址码的临时变量编号不要复用,否则优化阶段会出错。课程实验虽然不要求优化,但养成不复用的习惯能避免后面加功能时翻车。

4.3 用实验报告对照输出:怎么判断自己写对了

实验报告里一般会给出几个测试用例的预期输出。我的做法是把程序输出重定向到文件,再用diff对比:

./compiler tests/input1.txt > my_output.txt diff my_output.txt tests/expected1.txt

如果diff没有输出,说明完全一致。如果有差异,先看是 token 序列、语法树还是三地址码的差异。常见差异是空白字符和换行,实验报告里的输出可能用空格对齐,而你的程序用制表符。这种不影响逻辑,但交报告前最好统一。

5. 避坑与排查:编译原理实验里那些让人后悔药的瞬间

5.1 现象:make 报错「make: *** No rule to make target 'xxx.o'」

原因:Makefile 里的源文件路径和实际路径不一致,或者文件扩展名写错。常见于从 Windows 拷贝到 Linux 后大小写变化。

解决:用ls确认文件真实路径,检查 Makefile 里的SRCS是否拼写正确。如果是路径问题,把SRCS改成相对路径或绝对路径。

5.2 现象:词法分析器把123abc识别成一个标识符

原因:scanNumber只读了数字就返回,没有检查后面是否紧跟字母。课程实验里通常要求数字后面不能直接跟字母。

解决:在scanNumber返回前peek下一个字符,如果是字母就报错或按最长匹配继续读。具体看实验报告的要求。

5.3 现象:语法分析器在嵌套括号时栈溢出或死循环

原因:递归下降没有正确消费 token,或者match函数在失败时也前进了指针。

解决:在match里加断言,失败时打印当前 token 和期望 token。用gdb打断点在parseFactor,看递归深度。如果是 LL(1) 表驱动,检查预测分析表里有没有空项。

5.4 现象:语义分析报「变量未声明」,但明明声明了

原因:符号表作用域退出太早,或者声明语句在语义分析之后才处理。

解决:确认enterScopeexitScope的配对。在declarelookup里加日志,打印当前作用域层数和变量名。常见错误是在处理if语句时提前exitScope,导致else分支看不到变量。

5.5 现象:中间代码生成的结果和实验报告对不上,但逻辑没错

原因:临时变量命名规则不同,或者指令顺序不同。实验报告可能用t1t2,你的程序用_t1_t2

解决:先确认逻辑等价,再调整命名规则。如果实验报告要求严格一致,把newTemp的前缀改成报告里的格式。不要为了对齐输出而改逻辑,那是本末倒置。

6. 进阶技巧:用脚本自动化验证实验报告里的每个用例

课程实验的测试用例往往有几十个,手动跑不现实。我一般写一个 Python 脚本批量验证:

import subprocess import sys from pathlib import Path def run_case(compiler, input_file, expected_file): result = subprocess.run( [compiler, str(input_file)], capture_output=True, text=True ) actual = result.stdout.strip() expected = Path(expected_file).read_text().strip() if actual != expected: print(f"FAIL: {input_file}") print(f"expected:\n{expected}") print(f"actual:\n{actual}") return False print(f"PASS: {input_file}") return True if __name__ == "__main__": compiler = "./compiler" tests_dir = Path("tests") passed = 0 total = 0 for input_file in sorted(tests_dir.glob("input*.txt")): expected_file = tests_dir / input_file.name.replace("input", "expected") if not expected_file.exists(): continue total += 1 if run_case(compiler, input_file, expected_file): passed += 1 print(f"{passed}/{total} passed") sys.exit(0 if passed == total else 1)

逻辑说明:遍历tests目录下所有input*.txt,找到对应的expected*.txt,运行编译器并对比输出。参数说明:capture_output=True捕获标准输出和错误,text=True返回字符串而不是字节。sys.exit的返回值可以直接用在 CI 里。

这个脚本的好处是,你改完词法分析器后跑一遍,立刻知道有没有破坏已有用例。我一般把它放在实验包根目录,命名为run_tests.py,然后在 Makefile 里加一个check目标:

check: $(TARGET) python3 run_tests.py

这样每次make check就能自动验证。最后一个习惯:交实验报告前,把make clean && make && make check完整跑一遍,确认从零构建也能通过。编译原理实验的坑大多不在算法本身,而在构建和测试的细节里。希望帮到你。

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

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

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

立即咨询