简介:这是一份《编译原理》课程设计的完整资源包,面向计算机专业本科生或需要实现类C语言编译器的开发者。程序自带图形界面与简易编辑器,支持代码行号、关键字与变量名高亮、注释区分及自动补全,同时提供新建、打开、保存、撤销重做等常用操作,并带有未保存提示与快捷键支持,使用体验接近主流编辑器。编译模块完整覆盖词法分析、LR(1)语法分析、语义分析、中间代码优化与目标代码生成,运行后输出汇编文件及详细日志,可直观查看符号表、LR分析表、语法树和中间代码。这份资源不仅包含可直接运行的exe程序,还提供8个cpp与7个h源代码、工程配置、说明文档以及配套界面素材,压缩包共104个文件,含25个dll运行依赖、30个png图片和ui/txt文档等,整体约25.63MB,目录结构清晰,便于对照学习和二次开发。目前已有583人浏览学习,适合用作课程设计参考、实验复现或编译器原理进阶训练的实用材料。
1. 类C语言编译器课程设计:从词法到汇编全跑通的源码包
以「编译原理」课程设计收尾时,很多人手里只有词法分析和语法分析的半截代码,答辩一问「目标代码呢」就卡壳。这份源码包是少见的完整版:类C语言编译器,从词法分析、LR(1)语法分析、语义分析、优化到目标代码生成五个阶段全部打通,编译完成直接在原文件路径生成汇编语言文件,并在运行日志里输出每个阶段的中间结果。外搭一个 Qt 5 写的图形编辑器,行号、关键字高亮、变量名高亮、自动补全、未保存提示都能直接用。适合三类人:马上要交编译原理实验但骨架没搭起来的;想照源码理解五个阶段数据流动的;以及想给自己编译器加个像样 GUI 的。
2. 编辑器与文件操作:Qt 5 界面层的行号、高亮与未保存提示
2.1 为什么编译器课程设计要带一个图形编辑器
编译器和编辑器的区别,很多人答辩时说不清:编辑器负责输入侧体验,编译器负责从词法到目标代码的整条链路。这个包把两者拆开了——main.cpp管程序入口,mainwindow.cpp管主窗口和编译流程调度,edit.cpp管编辑器控件。我拿到源码包第一件事就是看文件清单,mainwindow.cpp是典型的 Qt 主窗口文件,edit.cpp承担编辑器,lexcal_analysis.cpp、syntax_analysis.cpp、optimizer.cpp、object_code.cpp四兄弟对应编译前端到后端的四个核心模块。分工清楚,适合照着讲架构。
文件清单里还有opengl32sw.dll和Qt5Gui.dll,它们是 Qt 5 GUI 程序的运行依赖。Qt5Gui.dll管窗口和文本绘制,opengl32sw.dll是 Qt 的 OpenGL 软件渲染后端——虚拟机、远程桌面、没有独显的机器上,界面渲染靠它兜底。打包发布时这两个 dll 要和 exe 放同目录,很多同学下载源码包后自己 Qt 环境没配好,双击起不来,多半就是 dll 路径问题,后面第 5 章专门讲。
源码文件与模块的对应关系,整理成一张表比较好讲:
| 源码文件 | 对应模块 | 主要职责 |
|---|---|---|
| main.cpp | 程序入口 | 创建 QApplication,启动主窗口 |
| mainwindow.cpp | 主窗口 | 菜单栏、编译流程调度、运行日志面板 |
| edit.cpp | 编辑器控件 | 行号、高亮、自动补全、编辑操作 |
| lexcal_analysis.cpp | 词法分析 | 单词符号串、符号表 |
| syntax_analysis.cpp | 语法分析 | LR(1) 分析表、分析过程、语法树 |
| optimizer.cpp | 优化 | 基本块划分、优化后中间代码 |
| object_code.cpp | 目标代码生成 | 输出汇编语言文件 |
| test.cpp | 测试 | 自测用例,可独立编译验证 |
编译流程从编辑器里点一下按钮触发:文件保存后,mainwindow依次调词法、语法、语义、优化、目标代码模块,日志窗口实时输出编译信息。这个「编辑器 + 日志面板 + 编译按钮」的组合,比纯命令行工具更适合课程设计答辩展示。
2.2 关键字高亮与自动补全:词表驱动的前端交互
编辑器部分要支持的辅助功能不少:显示代码行号、高亮关键字、高亮变量名、区分注释、自动补全。Qt 里做代码高亮的常规方案是继承QSyntaxHighlighter,重写highlightBlock,对编辑器里的每一行文本做规则匹配。这个包走的也是这条路,只是把规则表拆成了词表。
高亮规则的核心逻辑大概是这样的:
class Highlighter : public QSyntaxHighlighter { public: explicit Highlighter(QTextDocument *parent = nullptr); protected: void highlightBlock(const QString &text) override; private: struct Rule { QRegularExpression pattern; QTextCharFormat format; }; QList<Rule> rules; }; void Highlighter::initRules() { // 关键字整词匹配:\b 是单词边界,避免 ifx 被误认成 if Rule keywordRule; keywordRule.pattern = QRegularExpression("\\b(int|char|if|else|while|for|return|break)\\b"); keywordRule.format = keywordFormat; // 蓝色加粗 rules.append(keywordRule); // 单行注释 // 和多行注释 /* */ Rule commentRule; commentRule.pattern = QRegularExpression("//[^\n]*|/\\*.*\\*/"); commentRule.format = commentFormat; // 灰色斜体 rules.append(commentRule); // 变量名:非关键字的标识符 Rule varRule; varRule.pattern = QRegularExpression("\\b[a-zA-Z_][a-zA-Z0-9_]*\\b"); varRule.format = varFormat; // 默认色或自定义色 rules.append(varRule); }把规则按顺序塞进rules列表,highlightBlock里逐个匹配并设置文本格式。两条QRegularExpression的细节值得注意:\\b是单词边界断言,匹配if不会误伤ifx;//[^\n]*匹配到行尾,/\\*.*\\*/处理跨行注释。高亮变量名前先过关键字规则的关键在于顺序——关键字规则先执行,变量名规则后执行时会自动跳过已被高亮的区域,这是QSyntaxHighlighter的默认行为。
自动补全的常见做法是挂一个QCompleter,词表来自关键字表加符号表。关键字表是固定的那十几个保留字,符号表是用户当前文件里出现过的标识符集合。用户在编辑器里敲了前几个字符,补全器基于前缀匹配弹候选列表。这块和编译器本体解耦,只依赖词法分析产出的符号表,做起来不复杂,答辩时能演示出「智能」效果。
2.3 文件操作与未保存提示:dirty 标记和事件拦截
文件操作要覆盖新建、打开、保存、另存为,外加一个容易被忽略的细节:文件修改后未保存时,标题栏出现*号,关闭程序或打开新文件时弹确认框。这个机制的通用名字叫 dirty 标记,Qt 里直接用QTextDocument::isModified()就能拿到状态,关键是拦截时机。
closeEvent里处理关闭确认,是课程设计里最容易漏的一块:
void MainWindow::closeEvent(QCloseEvent *e) { if (editor->document()->isModified()) { QMessageBox::StandardButton ret = QMessageBox::warning( this, "文件尚未保存", "当前文件尚未保存,确定要关闭吗?", QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel); if (ret == QMessageBox::Cancel) { e->ignore(); // 取消关闭 return; } if (ret == QMessageBox::Save) { saveFile(); // 走保存逻辑 } } e->accept(); // 放行关闭 }保存成功后要做两件事:清掉 dirty 标记,更新标题栏。标题栏加*的思路是保存时把窗口标题设成「文件名」,文档内容一变就重新设置成「*文件名」,这样用户一眼能看到当前状态。打开新文件前也要做同样的检查,逻辑可以抽成一个tryOpenFile()函数,避免在菜单响应里重复写三遍判断。
快捷键映射是编辑器的基本盘,这套界面的常见映射是这样:
| 操作 | 快捷键 | 说明 |
|---|---|---|
| 新建文件 | Ctrl+N | 重置编辑器与 dirty 状态 |
| 打开文件 | Ctrl+O | 弹文件对话框 |
| 保存 | Ctrl+S | 写回当前路径 |
| 另存为 | Ctrl+Shift+S | 换路径保存 |
| 撤销 / 重做 | Ctrl+Z / Ctrl+Y | 编辑器标准操作 |
| 全选 / 复制 / 粘贴 | Ctrl+A / Ctrl+C / Ctrl+V | 最常用三个 |
快捷键用QAction的setShortcut挂到菜单项上,和菜单共用一个动作实例,这是 Qt 的标准玩法。日志面板在mainwindow里一般是个QTextEdit或QPlainTextEdit,词法、语法、优化模块往里追加文本即可。文件一旦保存,点编译按钮,整条编译链路就开始跑了,下一章看词法和语法这两个最硬核的部分。
3. 词法分析与语法分析:LR(1) 驱动循环和语法树构建
3.1 词法分析:单词分类、符号表与非法字符处理
lexcal_analysis.cpp是整条编译链路的第一个关卡,输入是类 C 源文件的字符流,输出是两类东西:单词符号串和符号表。单词按类 C 语言习惯分成五类——保留字(int、char、if、else、while、return 等)、标识符(变量名和函数名)、常数(整数)、运算符(+ - * / < > = 等)、界符(( ) { } ; ,)。每识别出一个单词,就压进单词符号串序列,同时把标识符登记进符号表。
单词类型和符号表条目的结构常见成这样:
enum TokenType { TK_IDENT, // 标识符 TK_NUMBER, // 整数常数 TK_KEYWORD, // 保留字 TK_OPERATOR, // 运算符 TK_DELIMITER,// 界符 TK_EOF }; struct SymbolEntry { std::string name; // 名字 int type; // 变量 / 数组 / 函数 int line; // 首次出现行号 int scope; // 作用域层次 int addrOffset; // 语义分析阶段回填的地址偏移 };符号表在词法阶段先登记名字和行号,type和addrOffset等语义分析阶段再回填。这样做的好处是词法分析不用关心类型推导,只负责把「长什么样的单词」认出来,类型信息留给后面。标识符个数在类 C 程序里通常几十到上百个,用std::map<std::string, int>按名字查表足够快,没必要上哈希表。
识别标识符和整数的核心逻辑是一个字符扫描循环:
Token scanIdentifierOrNumber() { std::string lexeme; // 以字母或下划线开头:标识符 if (isalpha(currentChar()) || currentChar() == '_') { while (isalnum(currentChar()) || currentChar() == '_') { lexeme += currentChar(); advance(); } // 先查关键字表,命中就是保留字 auto it = keywordTable.find(lexeme); if (it != keywordTable.end()) return Token(TK_KEYWORD, lexeme, line); // 否则是标识符,登记符号表 int idx = symbolTable.lookupOrInsert(lexeme, line); return Token(TK_IDENT, lexeme, line, idx); } // 以数字开头:整数常数 if (isdigit(currentChar())) { while (isdigit(currentChar())) { lexeme += currentChar(); advance(); } return Token(TK_NUMBER, lexeme, line); } // ... 运算符与界符分支 }识别时两个边界要处理:一是关键字和标识符的区分,必须在完整收集 lexeme 之后查关键字表,不能遇到 i 就认为遇到关键字;二是数字后面紧跟字母时属于非法词法,比如123abc,常见做法是报错并跳过整个非法串,否则错误恢复很容易死循环。很多同学在这里只报错不 skip,结果编译器卡在同一行反复报错,这是词法分析最常见的翻车点。
3.2 语法分析:为什么选 LR(1) 以及移进-归约主循环
语法分析阶段是整个课程设计的硬骨头,syntax_analysis.cpp要产出三样东西:LR(1) 分析表、语法分析过程记录、语法树。选 LR(1) 而不是 LL(1),理由很实在:类 C 语言的表达式优先级和左结合性,用 LL(1) 文法表达要拆成 expr、term、factor 三层,而且左递归文法会让 LL(1) 直接失控;LR(1) 能力更强,同样的文法不用大改,分析表驱动,归约时机由表决定,代码结构更规整。代价是分析表比 SLR、LALR 大一圈,对课程设计来说完全可接受。
LR(1) 分析表由 ACTION 和 GOTO 两张表组成:ACTION 表按[当前状态][当前输入终结符]查动作,动作是移进、归约、接受或报错;GOTO 表按[当前状态][非终结符]查出归约后跳转到的状态。移进-归约驱动循环是所有 LR 分析器的骨架:
// LR(1) 分析驱动:移进-归约主循环 bool LRParser::parse(const std::vector<Token>& tokens, SyntaxTree& tree) { std::stack<int> stateStack; // 状态栈 std::stack<SyntaxNode*> symStack; // 符号栈,节点挂在语法树上 stateStack.push(0); // 初始状态 0 size_t ip = 0; // 输入指针 while (true) { int state = stateStack.top(); int symbol = tokens[ip].type; // 当前输入符号 TableAction act = actionTable[state][symbol]; if (act.type == SHIFT) { // 移进:状态和单词同时入栈 stateStack.push(act.nextState); symStack.push(buildLeaf(tokens[ip])); ip++; } else if (act.type == REDUCE) { // 归约:按产生式右部长度弹栈,左部走 GOTO const Production& prod = productions[act.prodIndex]; SyntaxNode* parent = buildInternalNode(prod); for (int i = 0; i < prod.rhsSize(); i++) { stateStack.pop(); parent->children.insert(parent->children.begin(), symStack.top()); symStack.pop(); } symStack.push(parent); int curState = stateStack.top(); stateStack.push(gotoTable[curState][prod.lhs]); } else if (act.type == ACCEPT) { tree.root = symStack.top(); return true; } else { logError(tokens[ip].line, "unexpected token"); return false; // 错误恢复可从这里扩展 } } }归约动作里最容易写错的一点:弹栈时子节点插到children的开头,因为栈里符号顺序是右部从左到右,依次弹出后先弹的反而是右边的符号,必须用insert(begin())保证语法树子节点顺序和源码一致。buildInternalNode里要把产生式左部和子节点数记下来,语法树画出来才完整。
分析过程记录怎么做?常见做法是在循环里把每一步的栈顶状态、当前输入、执行的动作追加到一个std::vector<std::string>,最后整体写入运行日志。答辩时老师最爱问「如果语法出错怎么办」,LR(1) 的报错其实比 LL(1) 简单——ACTION 表里查不到动作就是错误,错误信息带上行号和当前符号就能定位。错误恢复一般用恐慌模式:跳过输入符号直到遇到同步记号(分号、右花括号),重新进入分析循环,能一次报出多处语法错误,比报一个就停体验好得多。
4. 语义分析、优化与目标代码生成:从符号地址表到汇编文件
4.1 语义分析:符号地址表、数组偏移与函数调用
语法分析拿到语法树之后,语义分析要回答三个问题:每个标识符是什么类型、每个标识符分配在哪、函数调用参数对不对。syntax_analysis.cpp产出的语法树在这里被遍历一遍,生成符号地址表。地址分配用静态分配还是栈式分配,取决于目标代码风格。这套包的类 C 语言支持函数过程调用和数组,变量表项和数组表项在结构上要有所区分:
struct AddrEntry { std::string name; int type; // TYPE_VAR / TYPE_ARRAY / TYPE_FUNC int scope; // 0 全局,1 函数内 int offset; // 相对基址的偏移 int size; // 占用字节数 }; // 数组地址计算:arr[index] 的首地址 // base + index * elementSize int calcArrayAddr(int base, int index, int elementSize) { return base + index * elementSize; }数组偏移是这阶段的高频 bug 点:int a[5]的size是5 * sizeof(int),而a[3]的地址是base + 3 * sizeof(int),不是base + 3 * 5。语义分析阶段要做的事是把数组声明里的元素个数和元素大小都记下来,后续取下标时统一走calcArrayAddr。函数调用的语义检查则管两件事:实参个数和形参列表是否匹配、函数是否已声明。类 C 语言不要求变量先声明后使用的话,符号表里要允许前向引用,但函数必须预先声明,否则无法确定参数个数。
符号地址表还有一个作用:它是中间代码生成的地图。四元式里的变量操作数直接用offset表示,生成目标代码时就知道往哪个内存地址存取。这里把符号表、符号地址表两个概念分清,答辩时能讲明白「词法阶段的符号表和语义阶段的地址表是同一张表的不同视图」这个点,属于加分项。
4.2 中间代码与优化:四元式、基本块划分与常数折叠
中间代码用四元式是编译器课程设计的常见选择,结构直观、翻译简单:一个(op, arg1, arg2, result)表示一条运算或控制指令。optimizer.cpp的工作对象就是四元式序列。四元式的数据结构长这样:
struct Quad { OpCode op; // ADD / SUB / MUL / JMP / JZ / CALL / ... Operand arg1; // 源操作数 1,变量或常数 Operand arg2; // 源操作数 2 Operand result; // 目标操作数,变量或临时变量 }; // 表达式 a + b * c 翻译成两条四元式: // t1 = b * c // t2 = a + t1优化阶段先做基本块划分,再在块内做局部优化。基本块划分的规则只有三条:第一条四元式是入口;跳转目标四元式是入口;跳转指令的下一条四元式是入口。从入口到下一条入口之前是一个基本块。入口集合用std::set<int>标记,扫一遍四元式就能切干净。
std::vector<BasicBlock> splitBasicBlocks(const std::vector<Quad>& quads) { std::set<int> leaders; leaders.insert(0); // 第一条指令必然是入口 for (size_t i = 0; i < quads.size(); i++) { if (quads[i].op == JMP || quads[i].op == JZ || quads[i].op == JNZ) { leaders.insert(quads[i].result.index); // 跳转目标是入口 if (i + 1 < quads.size()) leaders.insert(i + 1); // 跳转后下一条是入口 } } std::vector<BasicBlock> blocks; int start = -1; for (size_t i = 0; i < quads.size(); i++) { if (leaders.count(i)) { if (start >= 0) blocks.push_back(BasicBlock(quads, start, i)); start = i; } } if (start < quads.size()) blocks.push_back(BasicBlock(quads, start, quads.size())); return blocks; }切块后块内优化三个常规操作:常数折叠、复写传播、死代码删除。常数折叠最直观,t1 = 3 * 4直接在编译期算成t1 = 12;死代码删除则是删掉后面没被引用的赋值。这三项属于「看着不明显、实际很有用」的优化,而且每项都能在运行日志里展示优化前后对比,答辩时直接贴数据。
| 中间代码(优化前) | 优化后 | 优化项 |
|---|---|---|
| t1 = 3 * 4 | t1 = 12 | 常数折叠 |
| x = 1; y = x; z = y | x = 1; z = x | 复写传播 |
| dead = 99;(dead 未被引用) | 删除该行 | 死代码删除 |
4.3 目标代码生成:从四元式到汇编文本
object_code.cpp把优化后的四元式翻译成汇编语言文件,输出位置在源文件同目录,文件名一般沿用源文件名、扩展名换成.asm。目标代码生成最简单的策略是:变量全部放内存单元,临时变量用寄存器或一个固定临时区,每条四元式翻译成几条汇编指令。
四元式到汇编的分发逻辑是一个大 switch:
std::string genAsmForQuad(const Quad& q) { switch (q.op) { case ADD: // 常见做法:先把 arg1 加载到累加器,再加 arg2,结果存 result return " LOAD " + operandText(q.arg1) + "\n" " ADD " + operandText(q.arg2) + "\n" " STORE " + operandText(q.result) + "\n"; case MOV: return " LOAD " + operandText(q.arg1) + "\n" " STORE " + operandText(q.result) + "\n"; case JMP: return " JMP label" + std::to_string(q.result.index) + "\n"; case CALL: return " CALL func" + std::to_string(q.result.index) + "\n"; default: return " ; unsupported op " + std::to_string(q.op) + "\n"; } }每条四元式翻译成三条左右汇编指令是「能跑」的底线,寄存器分配的优化空间很大,但课程设计里首先要保证正确性。函数调用的处理要额外小心:调用者要先把实参写到约定位置,CALL 指令自带返回地址压栈,被调函数在栈帧里给局部变量分配空间,返回时把return s的 s 放到返回值约定位置。这些约定在汇编文件里要看到成对的「保存现场 / 恢复现场」指令,否则函数递归调用会直接栈溢出。
运行日志在目标代码生成阶段做两件事:输出生成了哪个.asm文件,以及每个阶段的中间结果行数统计,比如「词法分析:128 个单词」「语法分析:42 步归约」「优化:删除 3 条死代码」。这些统计信息既是给用户看的,也是自己排查问题的线索——优化删多了,说明中间代码生成有冗余;删少了,看看是不是基本块划分漏了入口。
5. 编译原理课程设计的常见问题与排查:五个翻车点
课程设计源码包下载下来跑不通,绝大多数不是算法问题,而是环境、路径、入口这些细节。下面五条是拆这个包和同学复现时最常见的坑,每条按「现象 → 原因 → 解决」记录。
5.1 DLL 缺失与 Qt 运行环境:exe 起不来
现象:双击编译好的 exe,弹窗提示「由于找不到 Qt5Gui.dll,无法继续执行代码」,或者界面能出来但花屏、闪退。
原因:程序是动态链接 Qt 5 的 GUI 库,Qt5Gui.dll负责文本和窗口绘制,opengl32sw.dll是 OpenGL 软件渲染后端。exe 运行时按「exe 所在目录 → 系统 PATH」的顺序找 dll,dll 没放在 exe 同目录就会报缺失。虚拟机或没独显的机器上,即使 dll 齐了,OpenGL 硬件加速失败也得靠opengl32sw.dll软渲染兜底。
解决:把Qt5Gui.dll和opengl32sw.dll放到 exe 同目录是第一步;如果还提示缺Qt5Core.dll、Qt5Widgets.dll,去本机 Qt 安装目录的bin文件夹把对应文件拷过来,版本要和编译时用的 Qt 一致。更省事的方式是发布时改用静态编译 Qt,但课程设计没必要折腾,dll 同目录足够。
5.2 入口报错:「编译器未包含 main 类型」
现象:在 Qt Creator 里打开项目源码包直接构建,编译器报「未包含 main 类型」,或者链接阶段报「无法解析的外部符号 main」。
原因:两种常见情况。一是.pro工程文件里SOURCES漏写了main.cpp,Qt Creator 只编译了mainwindow.cpp和edit.cpp,没有程序入口;二是main函数签名写成了void main(),C++ 标准要求返回int,而 Qt 程序的入口要创建QApplication再进入事件循环。
解决:先检查.pro文件的SOURCES列表是否包含main.cpp,如果工程是从源码手动新建的,这一步特别容易漏。再看main.cpp里入口函数是否长这样:
#include <QApplication> #include "mainwindow.h" int main(int argc, char *argv[]) { QApplication a(argc, argv); MainWindow w; w.show(); return a.exec(); // 进入 Qt 事件循环 }漏掉QApplication的话程序能编译,但控件初始化会崩,运行期才报错。入口函数这个位置,值得在动手改代码前先确认三遍。
5.3 中文路径导致的汇编文件「消失」
现象:编译日志显示「目标代码生成完成」,但打开源文件所在文件夹找不到.asm文件;或者日志里直接报「无法打开输出文件」。
原因:源文件路径含中文或空格时,Qt 的 QString 默认 UTF-8 编码,而 C 标准库的fopen在 Windows 上走本地代码页,两种编码混用导致路径错位。典型场景是桌面建了个「编译原理实验」文件夹,文件存里面就触发。
解决:统一用 Qt 的QFile、QDir处理路径,不要混用fopen。输出文件路径用「源文件所在目录 + 同文件名 .asm」拼接,写完文件后在日志里打印绝对路径,方便核对。我习惯在日志面板里加一行「输出文件:xxxx.asm,绝对路径:C:/...」,看到绝对路径心里就有底了。
5.4 LR(1) 移进-归约冲突:改文法比调表更省事
现象:生成 LR(1) 分析表时程序提示「状态 N 存在移进-归约冲突」,或语法分析跑到一半栈越来越长、就是不归约,最后内存暴涨。
原因:文法有歧义。最典型的是悬空 else——if (a) if (b) c; else d;里的 else 既可以配内层 if 也可以配外层 if,LR(1) 分析表在这个状态里既想移进又想归约。运算符优先级没在文法里体现时也一样,a + b * c会不知道先归约加法还是先移进乘法。
解决:优先改文法,把表达式拆成expr → term → factor三层,优先级自然就立住了;悬空 else 用「最近匹配」规则,在冲突状态里固定选择移进。实在不想动文法,就给冲突的产生式声明优先级和结合性,让分析表生成器自动消解。改文法后要重新生成分析表,并跑一遍之前的测试用例,防止改了这处坏了那处。
5.5 数组偏移算错,地址全乱
现象:a[3]存取结果不对,或者数组声明后编译出的汇编代码里地址比预期大得多/小得多。
原因:语义分析阶段计算数组元素地址时,把元素个数当成了元素大小。int a[5]每个元素占 4 字节,a[3]的地址偏移应该是3 * 4 = 12,如果按3 * 5 = 15算,越界了两个字节,连续数组布局整体错位。另一个常见错误是下标从 1 开始计,类 C 语言数组下标必须从 0 开始。
解决:数组地址统一走base + index * elementSize,elementSize是sizeof(元素类型),在符号表里给数组单独记elementSize和count两个字段,而不是只记总字节数。测试用例里加一组边界访问,a[0]、a[4](最后一个合法下标)、a[5](越界),三个都验证过再继续往后调。
6. 从输入到汇编:一份测试用例把五个阶段跑一遍
6.1 测试用例与每阶段对照
拿到源码包或者照着思路自己实现后,最重要的事是设计一份能覆盖五个阶段的测试用例。太简单的代码走不完函数调用,太复杂的又不容易定位问题。下面这份用例基本够用:有函数定义、有数组、有循环、有表达式优先级,全部特性一次性覆盖:
int sum(int n) { int s = 0; while (n > 0) { s = s + n; n = n - 1; } return s; } int a[5]; int main() { a[0] = 10; a[1] = sum(3); return a[0] + a[1]; }跑完之后逐个阶段对照输出:词法分析阶段应该看到sum、s、n、a出现在符号表里,int、while、return被识别为关键字;语法分析阶段日志里要有完整的移进-归约步骤,最后生成一棵根节点为main函数定义的语法树;语义分析阶段的符号地址表里,sum是函数类型,a是数组类型、占 20 字节;中间代码应该能看到CALL sum和数组下标的四元式;目标代码生成阶段在源文件同目录出现.asm文件,里面能找到函数调用的保存现场、恢复现场指令。
6.2 把每阶段输出存成文本再 diff
这个调试习惯帮我在编译器课设里少熬了三个通宵:把每个阶段的输出落成独立文本文件,改一次代码就对比一次差异。做法很简单——mainwindow里加一个调试开关,开启后词法分析把单词序列和符号表写到lexical_out.txt,语法分析把分析过程写到syntax_out.txt,优化阶段把基本块划分和优化后中间代码写到optimized_out.txt,三个文本和一个.asm放在同一目录。
改完文法或优化规则后,用 diff 工具逐文件对比旧输出和新输出。比如优化器加了一条复写传播规则,对比optimized_out.txt能看到少了哪些中间行;如果语法分析行为变多了,说明文法改动影响了归约步骤,可能引入了新的移进-归约冲突。这种「改一行、看三处输出」的习惯,比只盯着编译日志猜要高效得多。
以前我调编译器实验,总在目标代码生成阶段翻车,生成出的汇编一跑就错,又不知道是词法、语法还是语义环节埋的雷。后来每次改完文法或者改完优化规则,都强制自己走一遍这五步输出对比——先看词法符号表,再对语法分析过程,确认基本块划分和优化后的中间代码,最后才检查汇编。从那以后编译器课设的调试基本都是按小时计,不再通宵。希望帮到你。
本文还有配套的精品资源,点击获取