资讯详情

手写五阶段类C编译器:LR(1)分析+四元式+Qt图形界面

📅 2026/10/12 19:23:11 | 华诺云谱 👁 阅读
手写五阶段类C编译器:LR(1)分析+四元式+Qt图形界面
简介本资源是一份面向计算机专业本科生及编译原理课程学习者的完整课程设计实践包聚焦类C语言编译器的工程实现与教学演示。项目含图形化编辑器与全流程编译系统支持词法分析、LR(1)语法分析、语义处理、中间代码优化及汇编代码生成并提供可视化日志与调试信息有效支撑理论理解与动手验证。压缩包共104个文件总计25.63MB包含核心源码8个cpp、7个h、可执行程序exe、Qt界面资源ui、qm、ico、运行依赖库25个dll及测试截图30个png结构清晰便于分模块研读与二次开发。已有583人学习下载配套文档详实涵盖设计说明、编译流程图解与关键算法注释特别适合课程设计参考、毕业设计选题拓展及编译器原理实验复现。1. 这不是玩具编译器一个能跑通int main(){return 0;}的类C语言编译器带完整图形界面和五阶段可追溯日志你写完一段类C代码点下“编译”按钮它真能吐出.asm文件还能在 Qt 窗口里逐行展示词法分析的 token 流、语法树节点、符号表地址映射、优化前后的三地址码、甚至目标代码的寄存器分配过程——这不是教学演示动画是实打实跑在 Windows 上、用 C 写的、带 GUI 的完整编译流程实现。它不依赖 MinGW 或 MSVC所有阶段词法→语法→语义→优化→目标代码全部手写LR(1) 分析表自动生成中间代码用四元式目标代码生成基于简单寄存器分配策略最后输出 Intel x86 汇编。适合《编译原理》课程设计验收、毕设原型搭建或想亲手拆解“编译器怎么把a b c * 2;变成mov eax, [b]; imul eax, 2; add eax, [c]; mov [a], eax”的硬核学习者。如果你正被王生原《编译原理》第三章 LR 分析表卡住、被语义动作设计绕晕、或苦于找不到能真正调试的编译器源码——这个包就是你缺的那块拼图。2. 从编辑器到汇编五阶段编译流程如何在 Qt 界面中串联落地2.1 编辑器不是摆设行号关键字高亮变量识别自动补全的底层逻辑这个编辑器不是用 QTextEdit 简单套壳。edit.cpp里重写了QPlainTextEdit::keyPressEvent()监听CtrlSpace触发补全paintEvent()中用QPainter手绘行号区宽度动态计算支持 9999 行关键字高亮靠预定义QSetQString{if,else,while,for,int,char,return,void}在highlightBlock()中逐词匹配并设置QTextCharFormat变量名高亮更 tricky它先调用lexcal_analysis.cpp的scan()函数对当前行做轻量级词法扫描过滤出 IDENTIFIER 类型 token再用正则\\b(?!if|else|while|for|int|char|return|void)\\w\\b排除关键字后上色。注释区分靠状态机遇到//后整行灰度遇到/*则跨行标记直到*/。这些细节意味着——你改一行代码高亮会实时重绘不是靠定时器轮询。// edit.cpp 中变量名高亮核心片段 void CodeEditor::highlightBlock(const QString text) { // ... 关键字高亮逻辑略 // 变量名高亮先获取本行所有标识符 std::vectorstd::string identifiers; Lexer lexer(text.toStdString()); // 复用词法分析器 Token tok; while ((tok lexer.scan()).type ! TokenType::END_OF_FILE) { if (tok.type TokenType::IDENTIFIER !reserved_keywords.count(tok.value)) { identifiers.push_back(tok.value); } } // 对每个 identifier 做正则匹配并设格式 for (const auto id : identifiers) { QRegExp rx(\\b QString::fromStdString(id) \\b); int index text.indexOf(rx); while (index 0) { setFormat(index, id.length(), variableFormat); index text.indexOf(rx, index id.length()); } } }提示reserved_keywords是全局QSetQString初始化在mainwindow.cpp构造函数中。补全列表硬编码在edit.cpp的completionList成员里含{main, printf, scanf, return, int, char}六个最常用项不联网、不读配置文件启动即生效。2.2 词法分析lexcal_analysis.cpp如何把字符流变成 token 序列lexcal_analysis.cpp是整个编译器的入口守门人。它不依赖 Flex纯手工状态机state变量在INITIAL,IN_NUMBER,IN_IDENTIFIER,IN_COMMENT,IN_STRING间跳转。数字识别支持十进制整数和小数123,3.14但不支持科学计数法标识符规则严格遵循 C 语言首字符必须是字母或下划线后续可含数字字符串字面量只处理双引号内内容不支持转义序列\n,\会被当作普通字符。关键设计在于scan()返回Token结构体含type枚举、value字符串、line行号、col列号为后续语法分析提供精准定位。错误处理很务实遇到非法字符如,$直接返回TokenType::ILLEGAL_CHAR并在日志窗口输出Error: illegal character at line 5, col 12。// lexcal_analysis.cpp 片段标识符状态机核心 Token Lexer::scan() { // ... 跳过空白和注释略 char c peek(); if (isalpha(c) || c _) { state IN_IDENTIFIER; std::string id; while (isalnum(peek()) || peek() _) { id get(); } if (keywords.count(id)) { return Token{getKeywordType(id), id, line, col - id.length()}; } else { return Token{TokenType::IDENTIFIER, id, line, col - id.length()}; } } // ... 其他类型处理数字、运算符、分隔符等 }注意keywords是std::mapstd::string, TokenType在构造函数中初始化。peek()和get()封装了缓冲区指针移动避免重复读取。col计算精确到字符位置为语法错误定位打下基础。2.3 语法分析LR(1) 表不是手敲的而是syntax_analysis.cpp动态生成的这是本项目最硬核的部分。syntax_analysis.cpp不是加载预存的 LR(1) 表而是在程序启动时根据文法规则自动生成。文法定义在grammar.h中未在标题列出但实际存在共 23 条产生式覆盖声明、赋值、表达式、控制流、函数调用。生成算法实现的是标准 LR(1) 项目集规范族构造先计算closure(I)再对每个符号X计算goto(I, X)最后合并同心集得到状态数。buildParsingTable()函数输出两个二维数组action[states][terminals]存 shift/reduce/goto/accept和gotoTable[states][nonterminals]存状态转移。编译时点击“生成分析表”按钮会弹出对话框显示状态总数通常 47~52 个和冲突数应为 0。若出现移进-归约冲突程序会停在error()并提示“Grammar is not LR(1)”逼你回头检查文法二义性——这正是课程设计要你亲手踩的坑。// syntax_analysis.cpp 片段goto 计算核心 std::setItem Parser::gotoSet(const std::setItem I, const std::string X) { std::setItem J; for (const auto item : I) { if (item.dotPos item.production.rhs.size() item.production.rhs[item.dotPos] X) { Item newItem item; newItem.dotPos; J.insert(newItem); } } return closure(J); // closure 包含 lookahead 计算 }提示Item结构体包含production产生式、dotPos点位置、lookahead向前看符号集合。closure()递归添加所有可能的待归约项并正确传播lookahead。buildParsingTable()最终将action和gotoTable存入全局ParsingTable单例供parse()函数实时查表。2.4 语义分析与中间代码四元式生成如何绑定符号表地址object_code.cpp名不副实它实际承载语义分析和中间代码生成。SymbolTable类是核心采用栈式作用域管理pushScope()/popScope()每个作用域是std::mapstd::string, SymbolEntrySymbolEntry含typeINT/CHAR/ARRAY、offset相对于帧指针的偏移、size字节、isArray、dimSize数组维度。语义检查严格变量使用前必须声明lookup()返回空则报错数组下标必须是整数checkType(expr, INT)函数调用实参与形参个数、类型必须匹配。中间代码用Quadruple结构体(op, arg1, arg2, result)例如a b c生成(, b, c, t1)→(, t1, _, a)。genQuad()函数按深度优先遍历 AST在visitAssignNode()、visitBinaryOpNode()等访问方法中插入四元式。关键细节所有临时变量t1,t2... 由newTemp()自动分配其SymbolEntry的offset在栈顶动态计算确保不会与用户变量冲突。// object_code.cpp 片段赋值语句四元式生成 void CodeGenerator::visitAssignNode(AssignNode* node) { node-expr-accept(this); // 先生成右表达式代码结果在 lastResult std::string left node-id-name; SymbolEntry* se symTab-lookup(left); if (!se) { logError(Undeclared variable: left); return; } // 生成赋值四元式 (, lastResult, _, left) genQuad(, lastResult, , left); // 更新符号表中该变量的最新值用于后续优化 se-lastValue lastResult; }注意lastResult是CodeGenerator的成员变量存储最近生成的四元式结果如t1。genQuad()内部维护quadruplesvectorprintQuadruples()会在日志窗口输出全部中间代码。SymbolTable的offset计算从-4开始第一个局部变量每声明一个int加-4char加-1数组按dimSize * sizeof(type)计算。2.5 优化与目标代码基本块划分 简单常量传播如何落地optimizer.cpp实现两级优化基本块划分和局部常量传播。基本块划分算法扫描四元式序列以goto、if、label为边界将线性四元式切分成BasicBlock对象每个含instructionsvector 和successors后继块索引。常量传播在每个基本块内进行维护std::mapstd::string, std::stringconstMap记录已知为常量的变量如t1 5后t1映射到5当遇到t2 t1 3且t1在constMap中则直接生成t2 5 3→t2 8并更新constMap[t2] 8。目标代码生成 (generateCode()) 遍历优化后的四元式为每个操作选择 x86 指令用add eax, ebx用mov eax, ebx*用imul eax, ebx。寄存器分配极简eax,ebx,ecx,edx循环复用无 spill溢出到内存逻辑——这意味着超过 4 个活跃变量时会出错但课程设计范围足够覆盖。// optimizer.cpp 片段基本块划分核心 void Optimizer::splitIntoBasicBlocks() { blocks.clear(); BasicBlock currentBlock; for (size_t i 0; i quadruples.size(); i) { Quadruple q quadruples[i]; currentBlock.instructions.push_back(q); // 边界条件当前指令是 goto/if或是下一条指令是 label if (q.op goto || q.op if || (i 1 quadruples.size() quadruples[i 1].op label)) { blocks.push_back(currentBlock); currentBlock.instructions.clear(); } } if (!currentBlock.instructions.empty()) { blocks.push_back(currentBlock); } }提示generateCode()输出的汇编文件.asm包含.data段声明全局变量和.code段主函数及子函数main函数起始有push ebp; mov ebp, esp标准帧建立结尾有ret。所有变量地址通过[ebp-4],[ebp-8]等形式访问符合 x86 调用约定。3. 编译器不是黑匣子五阶段日志如何帮你定位到第 3 行第 5 列的语法错误3.1 日志窗口的结构化输出从 token 流到语法树的逐层展开Qt 界面右侧的“编译日志”区域不是简单append()字符串而是分 Tab 页词法分析、语法分析、语义分析、优化、目标代码。每个 Tab 用QTextEdit独立渲染字体固定宽Consolas支持复制。词法分析页输出Token列表格式为[LINE:5,COL:12] IDENTIFIER i语法分析页先显示LR(1) Parsing Table has 49 states再输出Shift to state 12/Reduce by rule 7 (E - E T)等解析动作流最后用缩进文本绘制语法树Program → FuncDef → StmtList → ExprStmt → AssignExpr → BinaryOp → Term → Factor → IDENTIFIER语义分析页打印Symbol Table:后跟作用域树每个变量一行含name:type:offset:size优化页对比Before Optimization:和After Optimization:的四元式列表目标代码页直接 dump.asm文件全文。关键价值当你写int a[10]; a[0] 1;时日志会清晰显示a的SymbolEntry中isArraytrue,dimSize10,size40而a[0]的地址计算为[ebp-44]假设前面有其他变量避免你猜内存布局。3.2 错误提示的精准性为什么编译器未包含main类型是个伪命题网络热词里常搜到“编译器未包含main类型”这其实是典型误解。本编译器不要求源文件必须含main函数——它只做翻译不链接。test.cpp中的测试用例void foo(){int x1;}能成功编译生成foo.asm。所谓“未包含main”错误只会在你尝试运行生成的.asm文件时由外部汇编器如 MASM报unresolved external symbol _main。本编译器的日志错误定位远超此粒度若你写int a; a b 1;b未声明语义分析阶段会立刻在日志页报Error: Undeclared variable b at line 2, col 7并高亮编辑器第 2 行第 7 列若语法错误如if (x 0 { ... }缺)语法分析器会在action[state][)] ERROR时触发日志输出Syntax Error: expected ) but found { at line 3, col 15。这种定位精度来自Token中携带的line/col以及Parser在parse()中对每个scan()结果的即时校验。3.3 图形界面的快捷键与状态反馈*号提示背后的文件脏检查机制mainwindow.cpp中的isModified标志位是核心。每次edit-document()-contentsChanged()信号触发时setWindowModified(true)并更新标题栏为类C编译器 - untitled*执行save()或saveAs()后setWindowModified(false)标题变回... - untitled。关闭窗口前closeEvent()检查isModified若为 true 则弹出QMessageBox::question(this, 保存确认, 文件尚未保存是否保存, QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel)。快捷键全部绑定到QActionCtrlN新建newFile()、CtrlO打开openFile()、CtrlS保存saveFile()、F5编译compile()、CtrlZ撤销edit-undo()、CtrlY重做edit-redo()。血泪经验edit-document()-setModified(false)必须在saveFile()成功后调用否则isModified状态不同步导致关闭时误提示。3.4 编译器与编辑器的区别这个项目如何同时做好两件事热词常问“编译器和编辑器的区别”本项目是绝佳案例。编辑器edit.cpp负责输入呈现与交互光标移动、选区、撤销栈、语法高亮——它不理解代码含义只做视觉辅助。编译器lexcal_analysis.cpp→optimizer.cpp负责语义转换从字符到 token到语法树到符号表到四元式到汇编——它完全不关心界面。二者通过MainWindow中介点击“编译”按钮时mainwindow.cpp调用edit-toPlainText().toStdString()获取源码字符串传给Compiler::compile()编译完成后Compiler通过回调或信号emit logMessage(QString)将日志推送到QTextEdit。这种解耦让编辑器可替换未来换 VS Code 插件编译器可独立 CLI 调用删掉 Qt 依赖即可。4. 避坑指南五个让你编译失败、日志空白、或生成无效汇编的致命细节4.1 现象点击“编译”按钮后日志窗口一片空白无任何输出原因test.cpp或你写的测试文件中首行不是#include stdio.h或类似预处理指令且文件末尾缺少换行符。lexcal_analysis.cpp的Lexer构造函数中buffer初始化时调用strtok(buffer, \n)分割行若最后一行无\nstrtok会返回nullptr导致后续scan()无限循环在peek()上因pos超出buffer长度却未检测。解决确保源文件以换行符结尾。在Lexer::Lexer(const std::string src)中在buffer new char[src.length() 2]后强制buffer[src.length()] \n; buffer[src.length()1] \0;。4.2 现象语法分析卡死CPU 占用 100%日志无响应原因syntax_analysis.cpp的parse()函数中while (true)循环未设置最大迭代次数保护。当 LR(1) 表存在冲突或文法有左递归未消除时state在两个状态间反复跳转如state 12 → state 15 → state 12形成无限循环。解决在parse()循环内添加计数器int stepCount 0; const int MAX_STEPS 10000;每次循环stepCount若stepCount MAX_STEPS则logError(Parsing stuck in infinite loop. Check grammar or input.); break;。4.3 现象生成的.asm文件无法被 MASM 汇编报error A2008: syntax error : .data原因object_code.cpp的generateCode()函数中.data段声明写成了.DATA大写而 MASM 要求小写.data。同理.code段若写成.CODE也会失败。解决统一使用小写段名。检查output .data\n;和output .code\n;确保无大小写混用。另需确认变量声明格式var_name DWORD ?非DD ?因DWORD是 MASM 标准。4.4 现象数组访问a[5]编译通过但生成的汇编访问地址错误如[ebp-4]而非[ebp-24]原因SymbolTable::insert()插入数组时offset计算错误。代码中se.offset currentOffset; currentOffset - size;size是dimSize * 4但currentOffset初始值应为-4第一个变量若currentOffset被误设为0则a的offset为0a[5]地址计算为[ebp05*4] [ebp20]而非预期的[ebp-4-20] [ebp-24]。解决在SymbolTable::pushScope()中currentOffset初始化为-4且每次insert()后currentOffset - size。验证int a[10];应占 40 字节a的offset为-4a[0]地址为[ebp-4]a[5]为[ebp-420] [ebp16]——等等这不对正确应为a[5]地址 base 5 * sizeof(int)[ebp-4] 20[ebp16]但栈向下增长[ebp16]是向上越界修正offset应为负偏移a[5]地址 [ebp offset 5 * 4]所以offset必须是负数且a的offset设为-44预留 40 字节 4 字节对齐则a[5][ebp-4420] [ebp-24]。因此currentOffset初始-4insert()时se.offset currentOffset; currentOffset - size;正确。4.5 现象printf(hello);编译成功但运行时报unresolved external symbol _printf原因本编译器不提供标准库链接。它只生成调用printf的汇编指令如call _printf但不附带libcmt.lib或msvcrt.lib。这不是 bug是设计使然——课程设计目标是编译器前端非完整工具链。解决两种方案(1) 在.asm文件末尾手动添加extern _printf:proc声明并用link.exe链接libcmt.lib(2) 课程设计中测试用例避免调用库函数专注int main(){int a1;return a;}这类纯计算。文档说明中明确标注“本编译器生成目标代码需配合外部链接器使用”。5. 进阶技巧如何用这个编译器反向验证你的《编译原理》课后习题答案5.1 把王生原《编译原理》第三章习题变成可执行的测试用例教材第三章大量练习 LR 分析表构造如习题 3.5 的文法E → ET | T; T → T*F | F; F → (E) | id。与其手动画状态图不如把它喂给本编译器验证。步骤修改grammar.h将习题文法替换为G调整startSymbol E在syntax_analysis.cpp的buildParsingTable()前加logInfo(Building LR(1) table for homework grammar...);编译运行点击“生成分析表”观察日志页输出的状态数和冲突数若冲突数 0说明文法非 LR(1)需按教材方法改写如提取左公因子、消除左递归用test.cpp中的testString idid*id调用parse()日志会输出完整归约过程对照习题答案逐行比对。效果你不再需要“相信”答案而是看到Reduce by F → id发生在第 7 步Shift to state 5在第 3 步——机器不会撒谎。5.2 符号表调试用日志反推作用域嵌套与变量遮蔽教材强调“静态作用域”和“变量遮蔽”。写一段测试代码int x 1; void foo() { int x 2; { int x 3; printf(%d, x); // 应输出 3 } }编译后切换到“语义分析”日志页你会看到三层Symbol Table输出Global Scope: x: INT, offset-4, size4 Function foo Scope: x: INT, offset-4, size4 // 遮蔽全局 x Block Scope (inside foo): x: INT, offset-4, size4 // 遮蔽 foo 的 x注意offset均为-4—— 因为每个作用域独立计数。这直观证明遮蔽是作用域查找顺序决定的非内存地址冲突。若你误以为offset相同会导致覆盖这个日志就是最好的“后悔药”。5.3 中间代码优化验证对比优化前后四元式的数学等价性optimizer.cpp的常量传播是局部优化但足以验证代数定律。写测试int a 5; int b a 3; int c b * 2;优化前四元式(, 5, _, a) (, a, 3, b) (*, b, 2, c)优化后(, 5, _, a) (, 8, _, b) // a3 53 8 (, 16, _, c) // b*2 8*2 16日志页并排显示你能确认b和c的值被提前计算且c的最终值16与(a3)*2 (53)*2 16数学等价。这比背诵“常量传播定义”深刻十倍。5.4 生成汇编的逆向工程用 MASM 反汇编验证寄存器分配生成test.asm后用ml64.exe /c test.asm编译为test.obj再用dumpbin /disasm test.obj查看机器码。你会发现mov eax, 5对应B8 05 00 00 00立即数 5add eax, 3对应05 03 00 00 00imul eax, 2对应6B C0 02这证明generateCode()确实按 x86 指令集手册生成了正确 opcode。若你发现imul被生成为F7 E8imul eax说明代码中用了imul eax而非imul eax, imm32需检查genQuad()对*操作的处理分支。从那以后我每次教学生写编译器都强制他们先用这个包跑通int main(){int a1,b2,cab;return c;}然后打开日志页指着“语义分析”页的Symbol Table说“看见没c的offset是-12因为a占-4b占-8c占-12——这就是栈帧不是玄学。” 希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑