ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

手写五阶段类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(),监听Ctrl+Space触发补全;paintEvent()中用QPainter手绘行号区(宽度动态计算,支持 9999 行);关键字高亮靠预定义QSet<QString>({"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::vector<std::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是全局QSet<QString>,初始化在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::map<std::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::set<Item> Parser::gotoSet(const std::set<Item>& I, const std::string& X) { std::set<Item> 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::map<std::string, SymbolEntry>,SymbolEntry含type(INT/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()内部维护quadruplesvector,printQuadruples()会在日志窗口输出全部中间代码。SymbolTable的offset计算从-4开始(第一个局部变量),每声明一个int加-4,char加-1,数组按dimSize * sizeof(type)计算。

2.5 优化与目标代码:基本块划分 + 简单常量传播如何落地

optimizer.cpp实现两级优化:基本块划分和局部常量传播。基本块划分算法扫描四元式序列,以goto、if、label为边界,将线性四元式切分成BasicBlock对象,每个含instructionsvector 和successors(后继块索引)。常量传播在每个基本块内进行:维护std::map<std::string, std::string>constMap,记录已知为常量的变量(如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中isArray=true,dimSize=10,size=40,而a[0]的地址计算为[ebp-44](假设前面有其他变量),避免你猜内存布局。

3.2 错误提示的精准性:为什么编译器未包含main类型是个伪命题

网络热词里常搜到“编译器未包含main类型”,这其实是典型误解。本编译器不要求源文件必须含main函数——它只做翻译,不链接。test.cpp中的测试用例void foo(){int x=1;}能成功编译生成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)。快捷键全部绑定到QAction:Ctrl+N新建(newFile())、Ctrl+O打开(openFile())、Ctrl+S保存(saveFile())、F5编译(compile())、Ctrl+Z撤销(edit->undo())、Ctrl+Y重做(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")分割行,若最后一行无\n,strtok会返回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为0,a[5]地址计算为[ebp+0+5*4] = [ebp+20],而非预期的[ebp-4-20] = [ebp-24]。
解决:在SymbolTable::pushScope()中,currentOffset初始化为-4,且每次insert()后currentOffset -= size。验证:int a[10];应占 40 字节,a的offset为-4,a[0]地址为[ebp-4],a[5]为[ebp-4+20] = [ebp+16]——等等,这不对!正确应为a[5]地址 =base + 5 * sizeof(int)=[ebp-4] + 20=[ebp+16],但栈向下增长,[ebp+16]是向上越界!修正:offset应为负偏移,a[5]地址 =[ebp + offset + 5 * 4],所以offset必须是负数,且a的offset设为-44(预留 40 字节 + 4 字节对齐),则a[5]=[ebp-44+20] = [ebp-24]。因此currentOffset初始-4,insert()时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 a=1;return a;}这类纯计算。文档说明中明确标注:“本编译器生成目标代码需配合外部链接器使用”。


5. 进阶技巧:如何用这个编译器反向验证你的《编译原理》课后习题答案

5.1 把王生原《编译原理》第三章习题变成可执行的测试用例

教材第三章大量练习 LR 分析表构造(如习题 3.5 的文法E → E+T | T; T → T*F | F; F → (E) | id)。与其手动画状态图,不如把它喂给本编译器验证。步骤:

  1. 修改grammar.h,将习题文法替换为G,调整startSymbol = "E";
  2. 在syntax_analysis.cpp的buildParsingTable()前加logInfo("Building LR(1) table for homework grammar...");;
  3. 编译运行,点击“生成分析表”,观察日志页输出的状态数和冲突数;
  4. 若冲突数 > 0,说明文法非 LR(1),需按教材方法改写(如提取左公因子、消除左递归);
  5. 用test.cpp中的testString = "id+id*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, size=4 Function foo Scope: x: INT, offset=-4, size=4 // 遮蔽全局 x Block Scope (inside foo): x: INT, offset=-4, size=4 // 遮蔽 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) // a+3 => 5+3 => 8 (=, 16, _, c) // b*2 => 8*2 => 16

日志页并排显示,你能确认b和c的值被提前计算,且c的最终值16与(a+3)*2 = (5+3)*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(立即数 5)
  • add eax, 3对应05 03 00 00 00
  • imul eax, 2对应6B C0 02
    这证明generateCode()确实按 x86 指令集手册生成了正确 opcode。若你发现imul被生成为F7 E8(imul eax),说明代码中用了imul eax而非imul eax, imm32,需检查genQuad()对*操作的处理分支。

从那以后我每次教学生写编译器,都强制他们先用这个包跑通int main(){int a=1,b=2,c=a+b;return c;},然后打开日志页,指着“语义分析”页的Symbol Table说:“看见没?c的offset是-12,因为a占-4,b占-8,c占-12——这就是栈帧,不是玄学。” 希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进