编译原理实验实战:从makefile构建到词法语法语义分析全流程
简介这份资源是四川大学编译原理课程的实验课配套资料面向正在学习编译原理、需要动手实现编译器各阶段组件的高校学生。内容按周次组织覆盖词法分析、语法分析、语义分析与代码生成等核心环节并配有课程设计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/放实验报告、根目录放Makefile或CMakeLists.txt。如果看到lexer、parser、semantic这样的子目录说明实验是按编译阶段拆分的阅读顺序就应该按阶段走而不是按文件名的字母序。参数说明-maxdepth 2控制递归深度太深会刷屏太浅会漏掉关键文件。| sort让输出稳定方便你截图或记录。2.2 用 makefile 判断构建入口和依赖关系热词里频繁出现 makefile 相关的问题比如「make没有指明目标并且找不到makefile」「生成makefile」说明很多人卡在构建这一步。拿到实验包后先确认根目录有没有 Makefilels -la Makefile makefile GNUmakefile 2/dev/null如果存在直接看第一个目标head -40 Makefile重点看三样东西编译器变量CC、CXX、源文件列表SRCS或OBJS、最终目标all或具体可执行文件名。一个典型的课程实验 Makefile 长这样CXX g CXXFLAGS -stdc17 -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链接阶段就会报未定义符号。参数说明-stdc17是课程实验常用的标准-g保留调试信息方便用 gdb 跟语法分析栈。提示如果make报「没有指明目标并且找不到 makefile」先确认当前目录是否正确再确认文件名大小写。Linux 下Makefile和makefile都能识别但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; }这段代码说明实验的入口是文件参数词法分析器持有文件流语法分析器持有词法分析器指针。读的时候重点看Lexer的nextToken()和Parser的parse()这两个函数决定了整个实验的骨架。实验报告里通常会有对应的状态转移图或文法产生式对照着看能省很多时间。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_setstd::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 集。落地时把表存成二维数组或mappair非终结符, 终结符, 产生式然后用一个显式栈模拟递归。两种方式都行但递归下降更容易调试因为调用栈就是语法树。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::vectorstd::unordered_mapstd::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; } };逻辑说明enterScope和exitScope对应花括号的进入和退出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()每次返回t1、t2……参数说明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 现象语义分析报「变量未声明」但明明声明了原因符号表作用域退出太早或者声明语句在语义分析之后才处理。解决确认enterScope和exitScope的配对。在declare和lookup里加日志打印当前作用域层数和变量名。常见错误是在处理if语句时提前exitScope导致else分支看不到变量。5.5 现象中间代码生成的结果和实验报告对不上但逻辑没错原因临时变量命名规则不同或者指令顺序不同。实验报告可能用t1、t2你的程序用_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_outputTrue, textTrue ) actual result.stdout.strip() expected Path(expected_file).read_text().strip() if actual ! expected: print(fFAIL: {input_file}) print(fexpected:\n{expected}) print(factual:\n{actual}) return False print(fPASS: {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_outputTrue捕获标准输出和错误textTrue返回字符串而不是字节。sys.exit的返回值可以直接用在 CI 里。这个脚本的好处是你改完词法分析器后跑一遍立刻知道有没有破坏已有用例。我一般把它放在实验包根目录命名为run_tests.py然后在 Makefile 里加一个check目标check: $(TARGET) python3 run_tests.py这样每次make check就能自动验证。最后一个习惯交实验报告前把make clean make make check完整跑一遍确认从零构建也能通过。编译原理实验的坑大多不在算法本身而在构建和测试的细节里。希望帮到你。本文还有配套的精品资源点击获取