ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

广工编译原理实验:从词法分析到中间代码生成的全链路实战

广工编译原理实验:从词法分析到中间代码生成的全链路实战 简介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 但不过度使用面向对象特性。实验代码量一般在 20004000 行之间如果非要上设计模式反而增加调试成本。一个符号表用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)这个配置只做了三件事指定 C17 标准、把三个源文件编成一个叫lexer的可执行文件、把头文件目录指到include。C17 标准足够支持实验所需的一切特性不需要上 C20 的边缘功能因为某些实验室的 GCC 版本比较旧C20 支持不完整。如果你后续要加入语法分析模块只需要在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::vectorstd::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::vectorToken lex(const std::string input) { std::vectorToken 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[i1] /) { 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::vectorToken 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打印语法树的目的不是装点而是让你在验收时能清晰讲解“你的分析器到底怎么理解这段代码”。很多同学被老师一问“你的程序是怎么把a5对应到语法树上的”就哑火就是因为只有代码没有可视化输出。把树结构打印出来贴在报告里是性价比极高的一步。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::vectorstd::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虽然这不是真汇编但它会让你的实验报告看起来多了一层“目标代码生成”的雏形也会让老师知道你思考到了更远的地方。但前提是前面词法、语法、符号表的代码足够稳不要为了追进度把前面几章的漏洞留在那里。编译原理实验说到底是一个系统工程前面每一层偷的懒都会在后面某个阶段变成几何级数的返工代价。希望帮到你。本文还有配套的精品资源点击获取
返回列表