简介:PL/0是编译原理课程中常用的教学型编译程序,本套资料以广东工业大学编译原理实验为背景,要求在其词法分析、语法分析和语义处理程序的基础上完成多项扩充:加入保留字ELSE、FOR、TO、DOWNTO、RETURN,增加运算符+=、-=、++、--,将不等号#改为<>,并为条件语句补充ELSE子句。压缩包共24个文件,以源代码、可执行程序、调试符号、测试代码及实验报告文档为主,另有工程配置与辅助文件,整体仅644KB,便于快速下载和本地验证。平台显示已有789人学习/下载,可帮助正在完成词法分析、语法分析和语义处理修改任务的读者对标参考。随附的实验报告和源代码能直观展示各功能点的改动位置,测试代码与调试文件支持运行观察,其中实验报告梳理了各分析程序的修改思路,源代码可对照检查保留字与运算符的添加方法,测试代码便于验证ELSE子句等改动效果,适合作为编译原理实验的实践参考。
1. 广工编译原理实验:这门课到底在考什么
如果你以为编译原理实验就是背背概念、考前抄抄代码,那你大概率会在验收现场被问得哑口无言。广工的编译原理实验课,核心不是让你手写一个能上线的 GCC,而是逼着你在两周内把“源代码到可执行程序”这条链路亲手打通一遍:词法分析、语法分析、语义分析、中间代码生成,每一步都得有能跑的代码和能说得清的设计理由。很多同学第一次做实验时,连“编译器不是黑匣子”这个基本认知都没有,上来就搜完整代码,结果一验收就露馅。这门课真正适合的读者很明确——计算机系大三学生、准备考研复试的人、以及工作中想补编译底层逻辑的工程师。不要指望靠背实验报告混过去,这篇笔记会从环境选型讲到避坑,给出一条能照做、能验收、能说清楚的落地路径。
2. 实验环境与工具链选型:别在第一步就把自己坑了
2.1 语言选型:C++ 还是 Java,为什么广工常见做法是用 C++
很多同学在语言上犹豫,尤其是看到热搜里“java+编译原理”这个组合。Java 写编译器确实舒服——对象化表达符号表、自动内存管理、容器类丰富,写出来的代码结构清晰。但广工实验课的传统验收方式更偏向 C/C++,原因是教学代码和参考实现多数是 C 系,实验指导书的示例代码、历届学长留下的资料、甚至老师课堂上随手写的伪代码,全默认你懂 C 系语法。你用 Java 写了,验收时老师让你解释某个指针行为,你没法答;反过来,你用 C++ 写,参考 Java 版思路做对象化设计,反而两头通吃。
我一般会建议选择 C++ 但不过度使用面向对象特性。实验代码量一般在 2000~4000 行之间,如果非要上设计模式,反而增加调试成本。一个符号表用unordered_map就能搞定,一个 token 流用vector就能存——用最朴素的手段完成功能,是实验课拿高分的正确姿势。C++ 的唯一劣势是字符串处理不如 Java 顺手,但编译器前端的字符串处理量不大,斤斤计较没有意义。
如果你已经用 Java 写了词法分析,想继续用 Java 做完整个实验,也不是不行,只是要注意两点:一是运行时依赖要写清楚,验收机器上得有对应 JRE;二是代码风格向 C 系靠拢,减少 Java 特有的语法糖,避免老师追问时露怯。编译原理实验考的是逻辑链路的完整性,不是语言炫技。
2.2 构建工具:从 Makefile 到 CMake,选一个你能立刻上手的
实验课不需要引入复杂的构建系统,但完全没有构建脚本也是灾难。常见兼做法是直接上 CMake,因为广工机房和实验室的 Linux 环境里 CMake 是标配,而 Windows 下的同学用 MinGW + CMake 也能顺利编过。一个最小可用的CMakeLists.txt长这样:
cmake_minimum_required(VERSION 3.10) project(compiler_lab) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(lexer src/lexer.cpp src/token.cpp src/symbol_table.cpp ) target_include_directories(lexer PRIVATE include)这个配置只做了三件事:指定 C++17 标准、把三个源文件编成一个叫lexer的可执行文件、把头文件目录指到include。C++17 标准足够支持实验所需的一切特性,不需要上 C++20 的边缘功能,因为某些实验室的 GCC 版本比较旧,C++20 支持不完整。如果你后续要加入语法分析模块,只需要在add_executable里追加源文件。
提示:实验室的 GCC 可能是 7.x 或 8.x,写代码时尽量避开需要 GCC 10+ 才能编译的语法,比如约定俗成的
#include <numeric>里的gcd函数在某些旧版本里就不可用。
如果你不想用 CMake,纯 Makefile 也可以是,但至少要保证一条make命令能完成全量编译。不要用 IDE 的“一键构建”然后交项目文件夹——验收老师不会安装你的 IDE 插件,更不会等它加载完项目。命令行能跑的构建脚本,是实验项目的基本体面。
2.3 第三方工具:Flex/Bison 用不用,取决于你愿不愿意冒险
每届都有学生纠结是否用 Flex 和 Bison。我的建议很直接:除非你已读过《Flex与Bison》前四章且能独立手写小例子,否则不要碰。原因是工具生成的代码是 C 风格的巨型数组和跳转表,出问题时你很难读懂生成的.c文件。而实验课的核心考察点恰恰是“你能不能讲清楚这个过程”,工具生成的代码你讲不清,验收就尴尬了。
但这不等于工具链没用。正确的用法是:用 Flex 生成一个临时 token 流,拿来验证自己手写的词法分析器输出是否一致;或者用 Bison 生成一个参考语法树,用来和自己手写的递归下降分析结果做对照。工具是验证工具,不是交付物。广工的实验课更看重一份能逐行解释的代码,哪怕是 300 行的手写词法分析器,也比 3000 行生成的表格驱动代码受欢迎。
3. 词法分析实验:从正则到 DFA 的最小可运行代码
3.1 为什么先写词法分析:它是实验一,也是分水岭
词法分析在实验课里通常是第一个交付物,它的验收标准是:给定一个源文件,输出一个 token 流,token 种类包括关键字、标识符、整数常量、运算符、分隔符和注释。这看起来简单,但它是一次分水岭——能把词法分析写得干净的人,后面几个实验基本不会翻车;而词法分析就靠“把所有字符 switch-case 一遍”糊弄过去的人,到语法分析时会被活活逼疯。
词法分析的核心是“识别—归类—跳过空白和注释”三步,其中识别环节理论上要讲清楚正则表达式到 NFA、NFA 到 DFA 的转换过程。实验报告里可以写这个过程,但代码实现上很少有人真的去构建状态转换表,常见做法是直接用“最长匹配 + 关键字优先”的手写逻辑。
3.2 最小词法分析代码:一个能跑通的 C++ 实现
下面这段代码是刚过验收标准的最小实现,去掉了符号表存储,只输出 token 类型和值。你可以把它作为骨架,再往上加自己的功能。
#include <iostream> #include <string> #include <vector> #include <cctype> enum TokenType { TK_ID, TK_NUM, TK_KEYWORD, TK_OP, TK_EOF }; struct Token { TokenType type; std::string text; int line; }; std::vector<std::string> keywords = {"int", "return", "if", "else", "while"}; bool isKeyword(const std::string& s) { for (auto& kw : keywords) { if (s == kw) return true; } return false; } TokenType getOpType(const std::string& s) { // 简单起见,所有运算符统一为 TK_OP return TK_OP; } std::vector<Token> lex(const std::string& input) { std::vector<Token> tokens; int i = 0; int line = 1; while (i < input.size()) { char c = input[i]; if (isspace(c)) { if (c == '\n') line++; i++; continue; } if (c == '/') { // 处理注释, 遇到 / 时要向后看一个字符 if (i + 1 < input.size() && input[i+1] == '/') { while (i < input.size() && input[i] != '\n') i++; continue; } } if (std::isalpha(c) || c == '_') { int start = i; while (i < input.size() && (std::isalnum(input[i]) || input[i] == '_')) i++; std::string word = input.substr(start, i - start); TokenType t = isKeyword(word) ? TK_KEYWORD : TK_ID; tokens.push_back({t, word, line}); continue; } if (std::isdigit(c)) { int start = i; while (i < input.size() && std::isdigit(input[i])) i++; tokens.push_back({TK_NUM, input.substr(start, i - start), line}); continue; } // 运算符和分隔符 tokens.push_back({TK_OP, std::string(1, c), line}); i++; } tokens.push_back({TK_EOF, "", line}); return tokens; }这段代码的逻辑说明:外层while循环是总控制器,line变量负责记录当前行号,方便后续报错时定位。处理空白时跳过字符并累计换行;处理注释时检测到//后直接跳到行尾而不输出 token;处理标识符时采用了“先扫完再判断”的策略——先连续读入字母、数字、下划线,再查关键字表决定是关键字还是标识符。数字识别只处理了十进制整数,小数和负数留给扩展。
有两个参数设计你想清楚。第一个是isKeyword的查表策略:我在代码里用线性查找,因为 C++ 程序的关键字数量一般不超过 50 个,线性表和哈希表在实际速度上没有可感知差异,线性表还省去了构建哈希表的代码量。第二个是运算符的处理策略:这段代码把+,-,*,/,=等全部归为TK_OP,如果你想区分赋值和相等判断,必须在拿到=后向后多读一个字符,判断下一个是不是=——这就是最长匹配的雏形,代码里没展开,但实验报告要写清楚。
3.3 验收级扩展:把“手写正则”改为“手写状态机”
如果你不想被老师问住,至少要理解背后的状态转换关系。你完全可以把上面的while循环改成一张状态表:每个状态对应一组字符转移,比如S0下看到字母转移到S1(标识符状态),看到数字转移到S2(数字状态)。理论上这就是 NFA 到 DFA 的压缩。但在实操中,手写状态机会让代码量膨胀三倍以上,收获的只有“看起来更专业”这一项。
我倾向的建议是:代码保持上面的简单结构,但实验报告的“设计说明”部分必须画一张 DFA 状态图,写明各个状态的含义和转换条件。验收老师更看重你纸上解释和代码实现的一致性,只要代码逻辑能对应上状态图,就不会刁难你。
3.4 词法分析实验报告:别只贴代码,要写清楚两个表
报告是实验课很重要的一环,很多同学代码跑通了但报告写得一塌糊涂。最有用的做法是提交一个 token 类型表(列出自定义的 token、对应的正则表达式、代码落点)和一个测试用例表(列出你覆盖了哪些边界情况:行尾注释、连续运算符、中文报错信息)。这两个表不用写得多花哨,用 Markdown 表格就够了,但必须真实对应你代码里真的有处理的逻辑。一句话,报告要能勾住验收老师的注意力,让他觉得你是在解决问题而不是在应付实验。
4. 语法分析实验:递归下降与 LR(1) 的实战取舍
4.1 语法分析为什么难:你要在“能实现”和“该实现”之间做选择
词法分析是线性扫描,语法分析是树形推导,复杂度直接上一个台阶。广工语法分析实验一般有两种方向:递归下降法和 LR(1) 分析法。很多同学翻开教材第二章,看到 LL(1) 文法和 LR(1) 分析表的构造过程就头皮发麻,尤其是“编译原理清华大学出版社第三版第二章答案”这种搜索词在期末常被刷上热搜,侧面说明大家普遍卡在文法和分析表的理解上。
但实验验收的重点不是让你默写分析表,而是让你用代码证明“你能把一段源程序识别成语法树”。所以选型逻辑很简单:如果你能熟练写出 FIRST/FOLLOW 集的求解脚本,那你可以尝试 LL(1) 或递归下降;如果你连“产生式”和“文法”都停留在概念层面,那强行做 LR(1) 等于给自己挖坑——LR 分析表生成的代码极其抽象,调试时根本没法定位错误。常见做法是选递归下降,因为它的代码结构和文法产生式一一对应,出问题可以顺着函数调用栈查。
4.2 递归下降分析器骨架:用代码对应文法规则
假设你的 C 子集文法包含:程序 → 声明列表;声明列表 → 声明 | 声明列表声明;声明 → 类型 ID;类型 → int | float。对应到代码上,递归下降就是为每个非终结符写一个函数。下面是一段最小可运行的骨架:
#include <iostream> #include <vector> #include <string> #include <cassert> // 假设已经完成了词法分析, 拿到了 token 流 struct Token { std::string text; std::string type; }; std::vector<Token> tokens; int current = 0; Token& lookahead() { return tokens[current]; } void advance() { if (current < tokens.size() - 1) current++; } bool check(const std::string& type) { return lookahead().type == type; } bool match(const std::string& type) { if (check(type)) { advance(); return true; } return false; } // 非终结符: 类型 void parseType() { if (match("TK_KEYWORD")) { // 是 int 或 float, 已消耗 } else { std::cerr << "line " << lookahead().line << ": 期望类型关键词" << std::endl; throw std::runtime_error("syntax error"); } } // 非终结符: 声明 void parseDeclaration() { parseType(); if (!match("TK_ID")) { std::cerr << "line " << lookahead().line << ": 期望标识符" << std::endl; throw std::runtime_error("syntax error"); } // 可选: 检查是否有初始化 = 表达式 if (check("TK_OP") && lookahead().text == "=") { advance(); // 简化: 只接受一个数字作为初始化 if (!match("TK_NUM")) { throw std::runtime_error("期望数值"); } } } // 非终结符: 声明列表 void parseDeclarationList() { while (current < tokens.size() - 1) { parseDeclaration(); // 可选: 检查分号 if (!match("TK_OP") || tokens[current-1].text != ";") { // 允许最后一个声明不带分号 break; } } } // 入口 void parse() { parseDeclarationList(); if (!check("TK_EOF")) { std::cerr << "line " << lookahead().line << ": 存在未归约的 token" << std::endl; throw std::runtime_error("syntax error"); } }这段代码的执行逻辑是:parseDeclarationList用while循环不断调用parseDeclaration,直到 token 流耗尽;parseDeclaration严格按照“类型 + 标识符 +(可选初始化)”的规则消耗 token;任何一步不匹配就抛出异常并附带行号。特别注意match函数消耗 token 后,需要从tokens[current-1]拿消费掉的 token 文本验证是不是分号——这是一个典型的“向前看”操作,对应了 LL(1) 里“根据 lookahead 决定产生式”的思想。
参数设计上,递归下降分析器有两个关键调整点。第一是“跟随产生式”的集合,上面parseDeclaration里可选的=初始化部分,就是通过check函数向后看一个 token 决定走哪条路,这要求你的文法不能有左递归,否则就会无限递归下去。第二是错误恢复策略,这个骨架里用了最暴力的“一遇错误就 throw”,实际实验代码中,更好的做法是记录错误并尝试从下一个分号处恢复解析,这样一次能报出多个语法错误,验收印象分高很多。
4.3 如果你非要写 LR(1):最小路径与必要参数
我理解部分同学有“想搞难的”的心态,或觉得自己递归下降没挑战。如果你要去写 LR(1),常见可靠的路径不是纯手写分析表,而是用工具生成:先用 Bison 写一份.y文件,再用bison -d -v生成.tab.c和.output文件。.output文件里有完整的分析表,你在实验报告里截图它,能证明你理解 LR 的移进—归约过程。
如果你真要手写 LR(1),那至少要做三步准备:第一步,求出所有产生式的 FIRST 集和 FOLLOW 集,这一步用脚本做,避免手算错漏;第二步,构造 LR(1) 项目集规范族,这一步需要画项目集图,是代码前最关键的设计文档;第三步,把项目集压缩成 ACTION/GOTO 表,再用二维数组写进代码。这三步每一步都有各自的地狱级 debug 点,你的实验时间规划至少要给这三步留出两周中的一半。我的判断是:如果实验课总周期只有一周,不要选择这条路径,递归下降已经能让你们班大多数人挂掉,你不必用高难度动作证明自己。
4.4 实验课上的实用技巧:把语法树打印出来
写完递归下降分析器后,最值得做的事是加一个打印函数,用缩进把调用关系可视化。比如输入int a = 5;时,打印这棵树:
Program Declaration Type: int ID: a Init: 5打印语法树的目的不是装点,而是让你在验收时能清晰讲解“你的分析器到底怎么理解这段代码”。很多同学被老师一问“你的程序是怎么把a=5对应到语法树上的”就哑火,就是因为只有代码没有可视化输出。把树结构打印出来贴在报告里,是性价比极高的一步。
5. 避坑:广工编译原理实验的五个典型翻车现场
5.1 token 流末尾的 EOF 被吞掉,导致语法分析死循环
现象:词法分析器单独跑没问题,但一接入语法分析的while循环,程序就卡住不输出,或者越界访问数组。
原因:词法分析器在源文件末尾没有补 EOF token,语法分析的parseDeclarationList循环条件current < tokens.size() - 1永远无法满足边界判断,直接访问tokens[current]就越界了。很多同学测试时只看转移结果,没检查 token 流里最后一个元素是什么。
解决:在词法分析的lex函数返回前,强行push_back一个{TK_EOF, "", line},同时语法分析里所有advance前都判断current < tokens.size()。这不是锦上添花,是必须做的事——没有 EOF 标记,任何循环型分析器都没法知道自己该停了。
5.2 关键字和标识符的判定优先级反了,导致int被识别成 ID
现象:输入int a = 5;,第一个 token 被识别成标识符而不是关键字,导致语法分析期望的TK_KEYWORD永远匹配不上。
原因:这是词法分析里最经典的翻车。你按“先识别字母串,再查表”的思路没错,但很多人是先判断“是否等于 int”,再判断“是否为字母串”,而实际输入integer时,它不等于int,于是落进标识符分支——看起来正确,但遇到intx这种变量名时,又会被误判成关键字。
解决:识别逻辑必须是“最长匹配 + 后验证”,先在空白符或运算符处切断单词,再用完整单词查表。简单说,你要先扫到非字母数字字符,才拿到一个完整单词,然后用这个单词去查关键字表。不能边扫边查——边扫边查会把intx拆成int和x两个 token。
5.3 注释处理时的“向后多看一眼”把标点吞掉了
现象:源文件里出现a = 5; // comment,语法分析时报错“期望分号”或“存在未归约的 token”。
原因:注释//的处理逻辑只判断了当前字符是/,没有验证下一个字符也是/,于是单个/被当成注释的一部分吞掉了。或者反过来,你把/*块注释的开始标记和//行注释混淆,块注释没处理行尾的*/,导致注释跨越了几行,行号全部错乱。
解决:写注释处理分支时,必须同时处理//和/*两种类型,且每个分支的结束条件都要写清楚://以换行符结束,/*以*/结束,并且在块注释内部遇到连续*时要不断回溯。更关键的是,你要专门准备一个测试文件,里面包含5/2这种数学运算、//注释、/*注释、以及http://这种字符串里的双斜杠——这种测试文件的通过率基本就能检验你的注释处理是否合格。
5.4 符号表作用域用单个 map 实现,变量重名全乱了
现象:输入int a; { int a; a = 1; } a = 2;,你的程序在内层作用域把a赋值为 1 后,外层a也跟着变了。
原因:符号表只用了一个全局unordered_map,没有处理作用域压栈。这在大三编译原理实验里特别常见,因为实验内容通常只要求“声明检查”,老师课上常说“当前作用于符号表”,但学生写代码时直接用全局 map 存了所有变量。
解决:至少用一个栈结构,每进入一个{}块压一个新 map,离开时弹栈。查变量时从栈顶往下找,插入时只在栈顶操作。这几十行代码就能避免所有作用域相关的低级错误,而它在实验报告里也是有分量的内容——因为它属于“语义分析”的范畴,是承上启下的关键设计。
5.5 验收时被问“你这段代码哪里体现了 DFA”,答不上来
现象:程序能跑通,但老师指着你的词法分析代码问“你的状态转换在哪里”,你解释说“我在 while 循环里用了 if 判断”,老师留下一句“那你这不叫 DFA”然后给了低分。
原因:实验指导书明确要求“基于 DFA 实现词法分析”,但你用while + if写的识别逻辑本质上还是“手写分类器”,结构上没有显式的状态变量。很多学生以为“结果正确即正确”,但实验课验收确实会看机制。
解决:至少要让代码里出现一个state变量,比如int state = 0;,然后在while循环里根据当前字符跳转 state。即使你心里清楚这个state只是那三四种情况,但纸面上要有状态转移的模样。更进一步的做法是定义enum State { ST_START, ST_ID, ST_NUM, ST_OP };,代码里用switch(state)分派处理——这才能在验收时对答如流:“这是简化后的 DFA,状态有四个,转移条件写在代码里。”
6. 中间代码生成:用最小 C 子集打通全链路的验证方法
中间代码生成是实验的最后一关,但它往往不需要你写很长的代码,而是要用一个简洁的思路证明“编译器全链路是通的”。我习惯用的中间代码形式是“三地址码”,每条指令最多三个操作数和一个运算符,例如t1 = a + 5。从语法树到三地址码的翻译并不复杂,你只需要为每个表达式临时变量编号:
int tempCount = 0; std::string newTemp() { return "t" + std::to_string(++tempCount); } std::vector<std::string> code; // 存放三地址码 // 简化版: 把表达式生成三地址码 std::string genExpr(const std::string& left, const std::string& op, const std::string& right) { std::string temp = newTemp(); code.push_back(temp + " = " + left + " " + op + " " + right); return temp; }这段代码的逻辑说明:genExpr函数每处理一个二元运算就生成一个新的临时变量,把左右操作数和一个运算符拼成一条三地址指令,并返回临时变量名供上层使用。这本质上就是“语法制导翻译”的代码实现——你在递归下降分析到表达式节点时调用genExpr,最后得到的code向量就是中间代码。
验证全链路是否打通的方法是写一个极小的测试程序,包含变量声明、赋值、算术表达式和打印语句。你不用真的生成汇编,只要把三地址码按顺序输出到文件,形式上就完成了“源代码 → token → 语法树 → 中间代码”的完整链路。我在完成这个阶段时养成了一个习惯:每一个编译实验阶段都保留一份测试用例文档,每跑通一个就复制存档,最后汇报时把从词法到中间代码的输入输出放在同一个文档里,一页一页翻给老师看,既不用现场重新演示程序,又能体现整条链路的连续性。
如果你还想更进一步,可以尝试把三地址码输出成类似汇编的格式,比如把t1 = a + 5写成ADD t1, a, 5,虽然这不是真汇编,但它会让你的实验报告看起来多了一层“目标代码生成”的雏形,也会让老师知道你思考到了更远的地方。但前提是前面词法、语法、符号表的代码足够稳,不要为了追进度把前面几章的漏洞留在那里。编译原理实验说到底是一个系统工程,前面每一层偷的懒,都会在后面某个阶段变成几何级数的返工代价。希望帮到你。
本文还有配套的精品资源,点击获取