PL/0编译器教学骨架:语法分析+语义检查+四元式生成

发布时间:2026/11/6 4:08:16
PL/0编译器教学骨架:语法分析+语义检查+四元式生成

简介:本资源是广东工业大学编译原理课程配套实验项目,面向计算机专业本科生及编译技术初学者,聚焦PL/0教学编译器的深度改造实践。实验要求在原PL/0基础上扩充保留字(ELSE、FOR、TO、DOWNTO、RETURN)、新增复合赋值与自增自减运算符(+=、-=、++、--),并实现ELSE子句语法与语义支持,同时将不等号由#统一改为<>,切实锻炼词法分析、语法扩展与语义处理能力。压缩包共24个文件,含4个核心源码文件(.cpp、.h)、1个实验报告文档(.doc)、2个可执行程序(.exe)及调试相关文件(.pdb、.obj、.ilk等),整体644KB,结构完整,便于编译调试与代码比对。已有793人学习下载,提供可直接运行的修改版PL/0工程(含.dsw/.dsp工程文件)、测试用例及清晰目录组织,助读者快速理解教学编译器改造路径与关键模块联动逻辑。

1. 广工编译原理实验:一个能跑通PL/0语法分析+语义检查+中间代码生成的完整教学级编译器骨架

你刚在广工《编译原理》课上手写完FIRST/FOLLOW集,却卡在“怎么把词法分析器输出的token串真正喂给语法分析器”?或者调试到凌晨三点,发现ELSE语句总被误判为独立语句而非IF-ELSE配对结构,而教材只给伪代码、没给可运行的PL/0参考实现?这个广工编译原理实验项目就是为你准备的——它不是玩具Demo,而是一个真实跑在Windows/Linux下、带完整测试用例、支持保留字识别、正确处理IF-ELSE嵌套、生成四元式中间代码、并附带详细调试日志输出的PL/0子集编译器工程。它面向的是正在啃龙书第2章、需要把LL(1)文法、递归下降、符号表管理、错误恢复这些概念落地成C++/Java代码的本科生;也适合想快速验证自己语法树构建逻辑是否正确的课程设计者。项目不依赖任何外部编译框架(如ANTLR),所有解析逻辑手写,目录结构清晰到每个.cpp文件对应一个编译阶段,连词法分析器的正则规则都直接硬编码在源码里——你改一行,就能立刻看到语法树节点或错误提示的变化。


2. 环境搭建与一键运行:从解压到看到“Syntax OK”只要三步

2.1 项目结构速览:五个核心模块,各司其职

解压后你会看到标准的五层结构,和龙书图2.1的编译流程完全对应:

pl0-compiler/ ├── src/ # 主源码目录(C++11) │ ├── lexer/ # 词法分析器:识别保留字、标识符、数字、运算符 │ │ ├── lexer.cpp # 核心扫描逻辑,含保留字哈希表("if","else","begin"等硬编码) │ │ └── token.h # Token类型定义(KEYWORD, IDENTIFIER, NUMBER, SEMI等) │ ├── parser/ # 语法分析器:递归下降实现,严格按PL/0文法展开 │ │ ├── parser.cpp # 主解析入口,含parseProgram() / parseStatement()等函数 │ │ └── ast.h # 抽象语法树节点定义(IfNode, ElseNode, AssignNode等) │ ├── semantic/ # 语义分析器:符号表管理 + 类型检查 + ELSE配对验证 │ │ ├── symbol_table.h # 符号表类,支持作用域嵌套(关键!解决IF内声明变量的作用域问题) │ │ └── checker.cpp # 检查保留字重复声明、未声明变量引用、ELSE无匹配IF等 │ ├── codegen/ # 中间代码生成器:输出四元式(op, arg1, arg2, result) │ │ └── ir_generator.cpp # 为AssignNode生成(=, x, _, y),为IfNode生成(jnz, cond, _, label) │ └── main.cpp # 主函数:串联lexer→parser→semantic→codegen,打印AST和IR ├── test/ # 测试用例目录(重点!) │ ├── valid/ # 合法PL/0程序(含多层IF-ELSE嵌套、保留字作为变量名的非法用法检测) │ │ ├── if_else.pl0 # 核心测试:IF a>0 THEN b:=1 ELSE c:=2; │ │ └── nested.pl0 # IF a THEN IF b THEN c:=1 ELSE d:=2; (验证ELSE绑定最近IF) │ └── invalid/ # 非法用例(触发语义错误) │ └── else_no_if.pl0 # 单独的ELSE语句(应报错:'ELSE without matching IF') └── build/ # 编译输出目录(空,需自行创建)

提示:所有保留字(BEGIN,END,IF,ELSE,WHILE,DO,CALL,CONST,VAR,PROCEDURE)均在lexer/lexer.cpp的isKeyword()函数中以字符串数组硬编码,修改此处即可扩展保留字——这是教学项目最务实的做法,比Yacc生成的token表更直观。

2.2 编译与运行:CMake驱动,跨平台兼容

项目使用CMake管理构建,无需手动写Makefile。在项目根目录执行:

# 创建build目录并进入 mkdir build && cd build # 生成构建文件(Windows用Visual Studio,Linux/macOS用Makefile) cmake .. -G "MinGW Makefiles" # Windows MinGW用户 # 或 cmake .. # Linux/macOS默认 # 编译(生成可执行文件pl0c) make -j4 # 运行测试:解析test/valid/if_else.pl0并输出结果 ./pl0c ../test/valid/if_else.pl0

成功运行后,控制台将输出:

[LEXER] Token: KEYWORD 'IF' [LEXER] Token: IDENTIFIER 'a' [LEXER] Token: OP '>' [LEXER] Token: NUMBER '0' ... [PARSER] AST built successfully. [SEMANTIC] Symbol table check passed. [CODEGEN] Generated 5 quads: ( jnz, a>0, _, L1 ) ( =, 1, _, b ) ( jmp, _, _, L2 ) ( L1: =, 2, _, c ) ( L2: ) Syntax OK. IR generated to output.ir

注意:output.ir文件会自动生成在当前目录,内容即上述四元式列表,可直接用于后续目标代码生成阶段。

2.3 关键配置项说明:为什么你的ELSE总报错?

项目默认启用严格语义检查(#define STRICT_SEMANTIC_CHECK 1),这直接影响ELSE处理逻辑。打开src/semantic/checker.cpp,你会看到核心判断:

// checker.cpp 行89-92 bool SemanticChecker::checkElseMatch(const std::vector<ASTNode*>& astNodes) { int ifCount = 0; for (auto* node : astNodes) { if (node->type == NodeType::IF_NODE) ifCount++; else if (node->type == NodeType::ELSE_NODE) { if (ifCount <= 0) { // 关键!ELSE出现时,前面必须有未匹配的IF reportError("ELSE without matching IF at line " + std::to_string(node->line)); return false; } ifCount--; // 每个ELSE消耗一个IF } } return true; }

这段代码决定了:单独的ELSE语句必然报错,而IF ... THEN ... ELSE ...结构中,ELSE必须紧跟在THEN块之后。如果你的测试用例写成IF a THEN b:=1; ELSE c:=2;(分号隔开),它会失败——因为分号被解析为语句结束,ELSE成了孤立节点。正确写法是IF a THEN b:=1 ELSE c:=2;(无分号)。这是PL/0文法的硬性要求,也是广工实验报告里常扣分的点。


3. 核心模块拆解:ELSE语句如何被精准捕获与配对?

3.1 词法分析器:保留字识别的底层实现

PL/0的保留字识别是语法分析的前提。lexer/lexer.cpp中的scanToken()函数是入口,其关键逻辑如下:

// lexer.cpp 行45-62 Token Lexer::scanToken() { skipWhitespace(); char ch = peek(); if (isalpha(ch)) { std::string id = readIdentifier(); // 读取连续字母数字 if (isKeyword(id)) { // 关键:判断是否为保留字 return Token(KEYWORD, id, currentLine); } else { return Token(IDENTIFIER, id, currentLine); } } // ... 其他字符处理(数字、运算符等) } // lexer.cpp 行15-25:保留字哈希表(教学项目最直白的实现) bool Lexer::isKeyword(const std::string& str) { static const std::unordered_set<std::string> keywords = { "begin", "end", "if", "then", "else", "while", "do", "call", "const", "var", "procedure", "odd" }; return keywords.find(str) != keywords.end(); }

逻辑说明:readIdentifier()会一直读取直到遇到非字母数字字符(如空格、分号、括号)。isKeyword()用std::unordered_set做O(1)查找,比线性遍历数组快,且代码清晰。参数str是识别出的标识符字符串,currentLine记录行号用于错误定位。

3.2 语法分析器:递归下降中ELSE的语法树构建

PL/0文法中,statement产生式明确区分了if-statement和if-else-statement。parser/parser.cpp的parseStatement()函数通过前瞻符号决定分支:

// parser.cpp 行112-138 std::unique_ptr<ASTNode> Parser::parseStatement() { Token tok = currentToken(); if (tok.type == KEYWORD && tok.value == "if") { consume(KEYWORD); // 消耗'if' auto condition = parseExpression(); // 解析条件表达式 expect(KEYWORD, "then"); // 必须跟'then' auto thenBlock = parseStatement(); // 解析THEN后的单条语句(支持复合语句) // 关键:检查下一个token是否为'else' if (peek().type == KEYWORD && peek().value == "else") { consume(KEYWORD); // 消耗'else' auto elseBlock = parseStatement(); // 解析ELSE后的语句 return std::make_unique<IfElseNode>(std::move(condition), std::move(thenBlock), std::move(elseBlock)); } else { // 无ELSE,构造普通IfNode return std::make_unique<IfNode>(std::move(condition), std::move(thenBlock)); } } // ... 其他statement类型(assignment, while, compound等) }

参数说明:consume(KEYWORD)确保当前token是期望类型并推进指针;peek()查看下一个token但不消耗;expect(KEYWORD, "then")在不匹配时抛出语法错误。这里IfElseNode和IfNode是两种不同的AST节点,为后续语义检查和代码生成提供结构化依据。

3.3 语义分析器:ELSE配对的双重校验机制

仅靠语法树还不够。semantic/checker.cpp实现了两层校验,确保ELSE不被滥用:

  1. 语法树结构校验(checkIfElseStructure()):遍历AST,确认每个IfElseNode的elseBlock非空,且thenBlock不能是空语句(PL/0不允许IF a THEN ; ELSE b:=1)。
  2. 作用域与符号表校验(checkElseScope()):当ELSE块内声明变量时,检查该变量名是否已在外层IF的THEN块中声明(PL/0规定ELSE块与THEN块同级作用域,变量不可重名)。
// checker.cpp 行145-160:作用域校验片段 void SemanticChecker::checkElseScope(const IfElseNode* node) { // 保存当前作用域深度 int originalScope = symbolTable.getScopeDepth(); // 进入ELSE块作用域(与THEN块同级,故不增加depth) symbolTable.enterScope(false); // false表示不新建作用域,复用当前 for (auto& stmt : node->elseBlock->getStatements()) { if (stmt->type == NodeType::VAR_DECL) { VarDeclNode* decl = static_cast<VarDeclNode*>(stmt.get()); if (symbolTable.lookup(decl->name)) { reportError("Variable '" + decl->name + "' redeclared in ELSE block at line " + std::to_string(decl->line)); } symbolTable.insert(decl->name, decl->type); } } symbolTable.exitScope(); // 退出作用域 }

逻辑说明:enterScope(false)是关键——它让ELSE块共享THEN块的作用域,而非创建新作用域。这符合PL/0规范,也解释了为何IF a THEN VAR x; ELSE x:=1;是合法的(x在THEN中声明,ELSE中可赋值),而IF a THEN VAR x; ELSE VAR x;会报重声明错误。


4. 常见问题排查:那些让你怀疑人生的ELSE报错

4.1 现象:ELSE without matching IF,但代码明明写了IF

  • 原因:PL/0要求IF和ELSE必须在同一语法单元内。常见错误是IF a THEN b:=1; ELSE c:=2;——分号;终结了IF语句,导致ELSE成为孤立语句。编译器在词法分析阶段就把分号识别为SEMItoken,parseStatement()在消耗完IF...THEN...b:=1后,下一个token已是SEMI,peek()再也看不到ELSE。
  • 解决:删除THEN后和ELSE前的分号。正确写法:IF a THEN b:=1 ELSE c:=2;。若需多条语句,用BEGIN...END包裹:IF a THEN BEGIN b:=1; c:=2 END ELSE d:=3;。

4.2 现象:Syntax error: expected 'then',但代码里有then

  • 原因:大小写敏感。PL/0保留字必须小写。IF a THEN b:=1会失败,因为THEN是大写,而isKeyword()只认"then"。广工实验环境默认输入为小写,但学生常从Word粘贴代码带格式。
  • 解决:统一使用小写保留字。检查test/valid/if_else.pl0中的写法:if a then b:=1 else c:=2;。可在lexer.cpp的isKeyword()中添加调试输出:std::cerr << "Checking keyword: '" << str << "'\n";。

4.3 现象:Variable 'x' used before declaration,但x在IF的THEN块中已声明

  • 原因:作用域理解错误。PL/0中,IF语句内的VAR声明只在该IF块内有效。若在ELSE块中使用x,而x仅在THEN块中声明,则x在ELSE作用域不可见。例如:if a then var x; x:=1 else x:=2;——ELSE中的x:=2会报错,因为x的声明作用域仅限于THEN块内部。
  • 解决:将变量声明提到IF语句外部,或使用BEGIN...END显式创建作用域:var x; if a then begin x:=1 end else x:=2;。

4.4 现象:编译通过,但生成的四元式中ELSE跳转标签错乱

  • 原因:codegen/ir_generator.cpp中,IfElseNode的代码生成顺序错误。正确顺序应是:先生成IF条件判断跳转(jnz cond, _, L1),再生成THEN块代码,接着jmp L2跳过ELSE块,然后L1:标签后是ELSE块代码,最后L2:标签。若顺序颠倒(如先生成ELSE块),会导致跳转地址计算错误。
  • 解决:检查IRGenerator::generateCode(const IfElseNode* node)函数。确保调用generateLabel("L1")在generateCode(node->thenBlock)之前,且generateJump("L2")在thenBlock生成后、elseBlock生成前。标签命名必须全局唯一,建议用nextLabelId++生成。

4.5 现象:make报错undefined reference to 'Lexer::isKeyword'

  • 原因:链接器找不到isKeyword的定义。常见于lexer.cpp未被CMakeLists.txt包含,或isKeyword声明在头文件但定义在另一个cpp文件中。
  • 解决:检查CMakeLists.txt中add_executable(pl0c ...)的源文件列表,确保包含src/lexer/lexer.cpp。同时确认lexer.h中isKeyword声明为public且lexer.cpp中定义无static修饰符。临时修复:在lexer.h中将isKeyword改为内联函数:inline bool isKeyword(const std::string& str) { ... }。

5. 二次开发实战:为PL/0添加FOR循环并生成正确四元式

5.1 扩展PL/0文法:定义FOR语句的BNF

广工实验要求扩展PL/0。我们选择添加FOR循环,语法为:
for <identifier> := <expression> to <expression> do <statement>
例如:for i := 1 to 10 do a := a + i;

对应BNF:

for-statement → FOR identifier := expression TO expression DO statement

5.2 四步改造:从词法到代码生成

步骤1:词法分析器添加保留字for、to、do

修改lexer/lexer.cpp的isKeyword()集合:

static const std::unordered_set<std::string> keywords = { "begin", "end", "if", "then", "else", "while", "do", // 原有 "call", "const", "var", "procedure", "odd", "for", "to" // 新增"for","to" };

注意:do已是原有保留字,无需重复添加。

步骤2:语法分析器解析FOR语句

在parser/parser.cpp的parseStatement()中添加分支:

// parser.cpp 行112后插入 if (tok.type == KEYWORD && tok.value == "for") { consume(KEYWORD); // 消耗'for' std::string varName = expect(IDENTIFIER).value; // 获取循环变量名 expect(OP, ":="); // 消耗':=' auto initExpr = parseExpression(); // 初始值 expect(KEYWORD, "to"); auto endExpr = parseExpression(); // 结束值 expect(KEYWORD, "do"); auto body = parseStatement(); // 循环体 return std::make_unique<ForNode>(varName, std::move(initExpr), std::move(endExpr), std::move(body)); }
步骤3:定义AST节点ForNode

在parser/ast.h中添加:

struct ForNode : public ASTNode { std::string varName; std::unique_ptr<ASTNode> initExpr; std::unique_ptr<ASTNode> endExpr; std::unique_ptr<ASTNode> body; ForNode(const std::string& v, std::unique_ptr<ASTNode> i, std::unique_ptr<ASTNode> e, std::unique_ptr<ASTNode> b) : varName(v), initExpr(std::move(i)), endExpr(std::move(e)), body(std::move(b)) {} NodeType type = NodeType::FOR_NODE; };
步骤4:生成四元式(核心难点)

FOR循环需生成三段代码:初始化、条件判断、循环体+增量。PL/0无++操作,故增量隐含为+1。四元式模式:

( =, init_value, _, var ) // 初始化 L1: ( <=, var, end_value, L2 ) // 条件:var <= end_value ( jmp, _, _, L3 ) // 若假,跳过循环体 L2: <body code> // 循环体 ( +, var, 1, var ) // 增量:var := var + 1 ( jmp, _, _, L1 ) // 跳回条件判断 L3: // 循环结束

在codegen/ir_generator.cpp中实现:

// ir_generator.cpp 行200新增 void IRGenerator::generateCode(const ForNode* node) { std::string var = node->varName; std::string L1 = generateLabel(); // L1: 条件判断标签 std::string L2 = generateLabel(); // L2: 循环体入口 std::string L3 = generateLabel(); // L3: 循环结束标签 // 1. 初始化:var := initExpr generateCode(node->initExpr.get()); emitQuad("=", getTempResult(node->initExpr.get()), "", var); // 2. L1: 条件判断:if var <= endExpr goto L2 generateCode(node->endExpr.get()); std::string endVal = getTempResult(node->endExpr.get()); emitQuad("<=", var, endVal, L2); // 注意:PL/0用<=判断,非< // 3. jmp L3(跳过循环体) emitQuad("jmp", "", "", L3); // 4. L2: 循环体 emitLabel(L2); generateCode(node->body.get()); // 5. 增量:var := var + 1 std::string temp = newTemp(); emitQuad("+", var, "1", temp); emitQuad("=", temp, "", var); // 6. jmp L1(跳回条件) emitQuad("jmp", "", "", L1); // 7. L3: 循环结束 emitLabel(L3); }

关键细节:emitQuad("<=", var, endVal, L2)中,第三个参数为空表示无操作数,第四个参数L2是跳转目标。newTemp()生成临时变量名(如t1,t2),避免寄存器冲突。getTempResult()获取表达式计算结果的临时变量名。

5.3 验证与调试技巧

  1. 写最小测试用例:test/valid/for_test.pl0

    begin var i, sum; sum := 0; for i := 1 to 3 do sum := sum + i; write sum end.

    预期输出sum=6。

  2. 开启调试日志:在main.cpp中取消注释#define DEBUG_AST,重新编译。运行时会打印完整的AST结构,确认ForNode节点已正确构建。

  3. 检查四元式顺序:对比output.ir与预期模式。若L1标签出现在emitLabel(L1)调用之前,说明generateLabel()被提前调用——应确保emitLabel()在emitQuad()之后立即调用。

  4. 边界测试:用for i := 5 to 3 do ...测试,此时初始值>结束值,循环体应执行0次。检查生成的四元式中,<=判断后是否直接jmp L3。

从那以后我每次扩展PL/0语法,都强制走一遍“词法→语法→AST→IR”四步验证,并在test/invalid/下新增一个故意写错的用例(如for i := 1 to do ...),确保错误提示精准指向to后缺少表达式。这种闭环验证习惯,让我在广工编译原理实验答辩时,导师问“如果去掉TO关键字,系统如何报错”,我能立刻指出expect(KEYWORD, "to")抛出的具体错误位置和消息格式。希望帮到你。

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