资讯动态

从零实现小型C++编译器:编译原理核心流程实践

发布时间:2026/9/8 17:01:49 来源:尧图企业网站定制
简介一份完整的编译原理课程设计项目使用C在VS2019中实现了一个小型编译程序可将类高级语言源程序先翻译为四元式再生成基于8086的汇编代码。资源适合计算机专业学生、编译器入门开发者作为课设参考与代码学习模板。压缩包共47个文件涵盖源代码.cpp/.h、Visual Studio工程文件.sln/.vcxproj、调试生成物.obj/.pdb、可执行文件以及两阶段生成的.asm、.med、.dat等中间与结果文件整体大小约42.74MB。目前已有2752人学习下载。项目按词法分析、语法语义分析、汇编生成等模块划分代码结构清晰并附带一个用于验证的res.txt示例作者课设成绩为优秀可帮助读者快速理解四元式生成与8086汇编输出的完整流程也可在此基础上扩展自己的编译器功能。如需课程设计报告可联系作者进一步获取。 先交代一个背景我当时拿到“编译原理课程设计实现一个小型编译程序C实现”这个题目时第一反应跟大多数人一样——去网上找现成的源码。结果翻了一圈要么是几百行糊在一起的“能跑就行”代码要么是Flex/Bison生成的框架代码看起来高大上但你问它为什么这么写讲不清楚。纠结了一周之后我决定自己从零开始写用C一行一行把词法分析、语法分析、语义分析和代码生成拼起来。这段时间我最大的感受是编译原理那些看起来抽象的概念比如Token流、递归下降、符号表、三地址码在上机实现一遍之后会变成特别自然的东西。这篇博文就记录一下整个小型编译程序的设计思路、核心实现和踩坑经验给正在做类似课程设计的人一个参考。这个项目面向的读者有两类一类是正在做编译原理课程设计、需要从零开始写一个小型编译器的同学另一类是理论学得还行、但一直没搞清楚“编译器到底是怎么把代码跑起来的”的人。项目完全用C实现不依赖任何工具生成代码所有模块手写整体的完整流程是源代码字符串 - Token流 - 语法树 - 带符号表的中级表示 - 栈式虚拟机指令 - 执行输出。1. 开题前想清楚的三件事语言规模、模块划分、数据流1.1 定义一个小而完整的语言选题后第一个要决策的问题不是用什么算法而是“这个编译器要编译什么语言”。我见过不少课设项目一上来就照着C语言的语法抄试图支持指针、结构体、函数、数组结果写了不到一半就烂尾。说实话课程设计的时间有限能体现编译原理的核心流程才是关键语言特性应该做减法。我最终定义了一个mini语言支持以下几种语句变量声明int a;double b;赋值语句a 1 2 * 3;输出语句print a;和print some text;输入语句input a;条件语句if (a 0) { ... } else { ... }循环语句while (a 10) { a a 1; }表达式支持加、减、乘、除、括号、一元负号、以及 ! 这类比较运算这个语言能写出来的程序已经相当丰富了比如求斐波那契数列、判断素数、找最大值这些典型的算法题目都能在里面写。但语法元素只有30种左右控制在一个月内能完成的体量。说实话这个规模对课程设计来说刚好太小的语言体现不出编译的完整流程太大的语言容易卡在某个细节上出不来。1.2 分层架构每个模块的输入和输出必须清晰编译器的经典结构是“前端后端”前端负责把源代码变成中间表示后端负责把中间表示变成目标代码。对于课设来说我建议把这一步再细分让每个模块都能单独验证。我采用的模块划分方式模块输入输出Lexer 词法分析器源代码字符串Token流Parser 语法分析器Token流AST语法树SemanticAnalyzer 语义分析器AST带符号表和类型信息的ASTCodeGenerator 代码生成器AST栈式虚拟机指令队列VirtualMachine 虚拟机指令队列运行结果ErrorReporter 错误报告器各阶段收集的错误格式化报错信息这个分层的好处非常实际调试的时候词法出错不会牵扯语法语法出错不会牵扯语义。编译器的每一层都能独立验证可以把出错范围缩小到一层之内。另外我建议每一层之间通过明确的接口连接比如Lexer产出一个vectorTokenParser拿到这个vectorToken之后才开始工作不要在Parser里再嵌一个词法函数。1.3 为什么选C很多同学会纠结用Java还是Python还是C我的建议是如果题目没有硬性要求就看你最熟的语言。但C做这个事情有一个天然优势它跟你写的编译器之间有一种“同构感”——C程序最终也要经过词法分析、语法分析、中间代码生成你在C里写一个编译器相当于在用“机器能听懂的语言”描述“机器如何听懂语言”这个过程。而且C的指针和引用非常适合构建语法树这样的层次结构std::vector、std::unordered_map这些容器管理Token流和符号表也很顺手。性能上解释执行一个小型程序C的速度基本可以忽略不计。2. 词法分析器从字符串到Token流2.1 Token结构的设计词法分析做的事情本质上是把原始字符串“切碎”切出来的每一块叫Token同时给每个Token打上类别标签。这个过程很像把一篇文章按词拆分并标注这个词是名词、动词还是形容词。我定义的Token类型如下enum class TokenType { ID, // 标识符变量名 INT, // int关键字 DOUBLE, // double关键字 NUMBER, // 数值常量 STRING, // 字符串常量 PLUS, MINUS, STAR, SLASH, // - * / ASSIGN, // EQ, NEQ, LT, GT, LEQ, GEQ, // ! LPAREN, RPAREN, LBRACE, RBRACE, // ( ) { } SEMICOLON, // ; IF, ELSE, WHILE, PRINT, INPUT, // 关键字 END // 文件结束标记 }; struct Token { TokenType type; std::string lexeme; // 原始文本 double value; // 当type为NUMBER时存放数值 int line; // 行号 int column; // 列号 };注意我把行号和列号直接放进了Token结构里。这个看起来不起眼的设计实际却在后面的语法错误报告里帮了大忙。报错信息能精确到第几行第几列调试效率比只说“第2行有错”高了一个档次。2.2 手动扫描的循环结构词法分析器的核心就是一个大循环逐个字符扫描。我的实现是这样的Token Lexer::nextToken() { skipWhitespaceAndComments(); if (isAtEnd()) return makeToken(TokenType::END, ); char c peek(); if (std::isalpha(c) || c _) { return lexIdentifier(); } if (std::isdigit(c)) { return lexNumber(); } if (c ) { return lexString(); } return lexOperator(); }skipWhitespaceAndComments负责跳过空格、换行、以及//注释和/* */注释同时维护line_和column_两个计数器。这个函数被很多人当成“无脑代码”但恰恰是这里最容易出问题。比如注释没有正确闭合时词法分析器会一直扫描到文件结尾如果不做检查最后会索引越界。2.3 识别数字区分int和double识别数字时要注意小数点和整数部分都要正确处理。我用的办法是先扫描整数部分如果遇到小数点再把小数点后面的数字也吃掉并把Token类型标为NUMBER后面靠value字段区分类型语法分析阶段根据变量声明的类型做匹配。Token Lexer::lexNumber() { size_t start pos_; bool isDouble false; while (std::isdigit(peek())) advance(); if (peek() .) { isDouble true; advance(); while (std::isdigit(peek())) advance(); } // 关键检查数字后面紧跟字母说明用户写错了变量名 if (std::isalpha(peek()) || peek() _) { std::string bad peekRemainingIdentifier(); error(非法数字字面量: bad); } std::string text source_.substr(start, pos_ - start); Token t makeToken(TokenType::NUMBER, text); t.value std::stod(text); return t; }这里有一处我踩过的坑如果数字后面直接跟字母比如123abc词法分析器如果只把123当作NUMBER返回那么后面的abc会被识别成一个标识符语法分析阶段会报“缺分号”之类的错问题会被掩盖得很深。最佳做法是在词法阶段直接报“非法数字字面量”把错误定位到源头。2.4 运算符的识别等号与双等号的边界运算符识别的细节在于两个字符的运算符和单字符运算符之间的区分。比如和和。我采用“当前字符 下一个字符”联合判断的方式Token Lexer::lexOperator() { char c advance(); switch (c) { case : return makeToken(TokenType::PLUS, ); case -: return makeToken(TokenType::MINUS, -); case *: return makeToken(TokenType::STAR, *); case /: return makeToken(TokenType::SLASH, /); case : if (peek() ) { advance(); return makeToken(TokenType::EQ, ); } return makeToken(TokenType::ASSIGN, ); case !: if (peek() ) { advance(); return makeToken(TokenType::NEQ, !); } return makeToken(TokenType::UNKNOWN, !); case : if (peek() ) { advance(); return makeToken(TokenType::LEQ, ); } return makeToken(TokenType::LT, ); // ... } }这里有个值得注意的设计取舍!是完整运算符但如果用户输入了一个单独的!编译器应该报错而不是把这个错误字符当成垃圾丢掉。我在UNKNOWN类型里保留它的原始文本方便ErrorReporter输出“无法识别的字符: !”。3. 递归下降语法分析从Token流到语法树3.1 为什么不用Yacc而是手写递归下降这个选择可能是我在整个项目里最有主见的决定。Flex和Bison是工业级工具生成代码的效率和准确性远高于手写但课程设计的价值恰恰在于“亲手实现一遍”。如果只是配置一下Bison文件然后自动生成做完之后你对语法分析的理解仍然停留在“知道有这么个工具”的层面。手写递归下降你会真正理解左递归、回溯、预测、FIRST集合这些概念在代码里是怎么体现的。递归下降的核心思路每个非终结符对应一个函数函数内部按照产生式右侧的内容依次匹配Token或者调用其他非终结符的函数。如果产生式有多个候选分支就通过“前瞻一个Token”来决定走哪个分支。3.2 表达式优先级的文法设计表达式的优先级处理教科书上的做法是引入多个层次的非终结符。我用的文法如下expr : term ((|-) term)* term : factor ((*|/) factor)* factor : NUMBER | ID | (expr) | -factor这个文法的含义很直接expr由若干个term用加减连接term由若干个factor用乘除连接优先级通过层层包装自然体现。和-的优先级最低*和/高一层括号和一元负号最高。左结合性则通过while循环实现解析1 - 2 - 3时会得到((1 - 2) - 3)而不是(1 - (2 - 3))。对应的C代码ASTNode* Parser::parseExpr() { ASTNode* node parseTerm(); while (match(TokenType::PLUS) || match(TokenType::MINUS)) { TokenType op previous().type; ASTNode* right parseTerm(); node new BinaryOpNode(op, node, right); } return node; } ASTNode* Parser::parseTerm() { ASTNode* node parseFactor(); while (match(TokenType::STAR) || match(TokenType::SLASH)) { TokenType op previous().type; ASTNode* right parseFactor(); node new BinaryOpNode(op, node, right); } return node; } ASTNode* Parser::parseFactor() { if (match(TokenType::MINUS)) { return new UnaryOpNode(TokenType::MINUS, parseFactor()); } if (match(TokenType::NUMBER)) { return new NumberNode(previous().value); } if (match(TokenType::ID)) { return new VarNode(previous().lexeme); } if (match(TokenType::LPAREN)) { ASTNode* inner parseExpr(); expect(TokenType::RPAREN, 缺少右括号 )); return inner; } error(无法解析的因子); return nullptr; }这种写法的妙处在于不需要引入任何符号优先级表也不需要在运行时做运算符栈的优先级比较程序的结构就是优先级的结构。难怪《编译原理》第三版里反复强调文法设计的重要性真正上手之后才理解这句话的分量。3.3 错误恢复自带“跳过”能力的match函数递归下降分析器最容易卡死的问题是当Token不匹配时如果只是简单报错停止那么一个源代码里如果同时有3个语法错误你得修改、编译、运行、再看错误循环3次才能把错误清干净。这个问题靠panic mode错误恢复来解决。我的做法是封装了一个expect函数当期望的Token类型不匹配时记录错误并执行一个跳过策略把所有Token一直跳到下一条语句的分号或右大括号为止。这样一次编译就能收集到尽可能多的错误bool Parser::expect(TokenType type, const std::string msg) { if (peek().type type) { advance(); return true; } error(msg 第 std::to_string(peek().line) 行); synchronize(); // 跳到下一条语句 return false; } void Parser::synchronize() { while (!isAtEnd()) { if (previous().type TokenType::SEMICOLON) return; switch (peek().type) { case TokenType::IF: case TokenType::WHILE: case TokenType::PRINT: case TokenType::RBRACE: return; default: advance(); } } }关于synchronize这个函数我当时调试时踩过一个特别隐蔽的坑如果跳过Token的逻辑设置得不好会导致位置不推进然后Parser在同一个位置反复报错陷入死循环。后来总结出一个经验无论走哪个分支每轮循环至少要向advance()一次才能保证进度前移。这一点看起来微不足道但能卡掉80%的新手。3.4 语句解析把控制流翻译成AST节点表达式处理清楚之后语句的解析就比较容易了。我用一个parseStatement函数来分派不同的语句类型每种语句对应一个解析函数parseIfStmt解析if (cond) stmt else stmt用IfNode保存条件和两个分支parseWhileStmt解析while (cond) stmt用WhileNode保存条件和循环体parsePrintStmt解析print expr;或print string;parseAssignStmt解析ID expr;解析赋值语句时要注意一个问题第一Token是ID时可能是赋值语句也可能是一个表达式语句。我个人倾向于在mini语言里不支持裸表达式语句也就是说1 2;这种写法直接报语法错误所有以ID开头的语句都当作赋值语句处理。这样Grammar简单很多也更适合初学。4. 语义分析让语法树变得“有意义”4.1 为什么不能把符号表掉在Parser里很多同学图省事会在Parser阶段直接维护一个符号表边解析边填表。我一开始也这么干但很快就发现代码越来越乱语法分析的职责是检查“结构对不对”语义分析的职责是检查“逻辑合不合理”。这两个问题的关注点完全不同混在一起会让Parser代码膨胀而且一旦后面要扩展作用域或类型系统改动会非常痛苦。所以我选择先把AST完整构建出来再单独用一次遍历做语义检查。这个设计的好处非常明显Parser的代码保持纯粹语义分析的逻辑集中在一个文件里几十年积累的工程经验确实是有道理的。4.2 符号表的实现方式符号表的核心功能是记录“变量在哪个作用域、什么类型、对应哪个运行时槽位”。在mini语言里作用域主要有全局作用域和{}块作用域两种。我的实现采用了作用域链的方式struct Symbol { std::string name; Type type; // Type::INT 或 Type::DOUBLE int slot; // 变量在运行时的存储槽位 bool initialized; // 是否初始化过 int declLine; // 声明所在行号用于报错 }; class Scope { public: Scope* parent; std::unordered_mapstd::string, Symbol symbols; bool contains(const std::string name) const; bool add(const Symbol sym); bool lookup(const std::string name, Symbol out) const; };关于作用域链的实现要注意一点lookup查找变量时必须先查当前作用域再逐层向上查父作用域直到查不到为止。在同一个作用域内重复声明同名变量属于错误但子作用域可以声明与父作用域同名的变量这是合法的遮蔽shadowing。这个设计决定了语义分析的规则。4.3 语义检查的类型和时机我实现的语义检查包括四类未声明变量在遍历AST时如果遇到VarNode且符号表里查不到就报“变量未声明”重复声明在声明语句中如果当前作用域已存在同名变量就报“变量重复声明”类型不匹配赋值语句中如果右侧表达式求值类型与左侧变量类型不一致就报“类型不匹配”但这里我用的是一个比较宽松的规则double变量可以接收int表达式的值int变量如果接收到double类型的值则报错未初始化变量使用检查VarNode的initialized标记在使用变量时如果发现尚未赋值就报“变量未初始化”的警告。其中类型推导的逻辑集中在BinaryOpNode的访问函数里Type SemanticAnalyzer::inferBinaryType(const BinaryOpNode* node) { Type lt getType(node-left); Type rt getType(node-right); if (lt Type::DOUBLE || rt Type::DOUBLE) { return Type::DOUBLE; } return Type::INT; }这种类型提升规则跟C语言一致只要有一个操作数是double结果就是double否则是int。这样做的好处是生成中间代码时所有数值都能统一转换成double存储虚拟机不需要维护复杂的类型信息。4.4 程序结构信息如何传给后端语义分析完成后AST节点上要附带两类关键信息类型信息和变量的slot编号。slot编号的作用是告诉虚拟机这个变量应该放在“运行时的哪个格子”里。我在遍历作用域时给每个变量分配一个递增的int编号比如第一个变量slot是0第二个是1以此类推。这样中间代码生成阶段一个变量名就映射成一个整数索引后面的虚拟机只需要面对slot不再需要处理变量名。5. 中间代码与虚拟机让程序真正跑起来5.1 栈式虚拟机的指令集设计课程设计里生成中间代码最直观的选择就是栈式虚拟机指令。栈式指令的特点是操作数都放在一个栈上指令从栈顶弹出操作数计算结果再压回栈里。它跟真实CPU的寄存器架构相比效率低一些但结构极其简单非常适合展示“代码是如何被一步步解释执行的”。我设计的指令集非常精简指令含义PUSH_NUM把一个常量压入操作数栈PUSH_VAR把一个变量的值压入操作数栈STORE_VAR弹出栈顶值写入指定变量GET_INPUT读取用户输入存储到指定变量PRINT_VAL弹出栈顶值并打印PRINT_STR打印一个字符串常量ADD/SUB/MUL/DIV弹出两个操作数计算后压回GT/GE/LT/LE/EQ/NEQ比较两个操作数压入0或1JMP无条件跳转到指定指令序号JMP_FALSE弹出栈顶值为0则跳转LABEL跳转目标标记HALT程序结束给一个小例子赋值语句a 1 2 * 3;生成的指令序列是这样PUSH_NUM 1 PUSH_NUM 2 PUSH_NUM 3 MUL ADD STORE_VAR a // a 的 slot 假设为 0实际指令里存的是 STORE_VAR 0可以看到这个执行过程跟我们在编译原理课上讲的逆波兰表达式几乎一模一样栈式虚拟机本质上就是一个“带变量的计算器”。5.2 从AST生成指令的方式代码生成阶段其实就是对AST做一次后序遍历。比如遇到BinaryOpNode先递归生成左子节点的指令再生成右子节点的指令最后输出该运算符对应的指令。这里有一个细节左子节点的指令必须先生成因为栈式执行时后压入栈的值会先被弹出。以减法为例std::vectorInstruction CodeGenerator::genBinaryOp(const BinaryOpNode* node) { auto left gen(node-left); auto right gen(node-right); auto vec left; vec.insert(vec.end(), right.begin(), right.end()); vec.push_back(makeInstruction(opFor(node-op), node-op)); return vec; }5.3 控制流指令if和while的跳转处理生成if语句的指令时用到JMP和JMP_FALSE两种跳转指令。关键点在于label的编号管理。我用一个labelCounter_每次遇到一个if或while就生成一个唯一的编号std::vectorInstruction CodeGenerator::genIfStmt(const IfNode* node) { auto condCode gen(node-condition); int elseLabel newLabel(); int endLabel newLabel(); auto result condCode; result.push_back(makeInstruction(OpCode::JMP_FALSE, elseLabel)); auto thenCode gen(node-thenBranch); result.insert(result.end(), thenCode.begin(), thenCode.end()); result.push_back(makeInstruction(OpCode::JMP, endLabel)); result.push_back(makeInstruction(OpCode::LABEL, elseLabel)); auto elseCode gen(node-elseBranch); result.insert(result.end(), elseCode.begin(), elseCode.end()); result.push_back(makeInstruction(OpCode::LABEL, endLabel)); return result; }没有else分支时直接把elseLabel和endLabel合并成一个减少跳转次数。while循环的处理逻辑类似要额外注意一点条件判断放在循环体前面条件为假时跳到循环结束条件为真时执行循环体之后跳回条件判断处。5.4 虚拟机的执行循环虚拟机的运行逻辑非常直白一个for循环不停取指令、执行指令。执行时维护一个操作数栈std::vectordouble operands_和一组局部变量std::vectordouble locals_。STORE_VAR slot执行时从栈顶弹出值并写入locals_[slot]而PUSH_VAR slot执行时读取locals_[slot]并压栈。void VirtualMachine::run() { for (size_t pc 0; pc instructions_.size(); pc) { const Instruction ins instructions_[pc]; switch (ins.op) { case OpCode::PUSH_NUM: operands_.push_back(ins.numValue); break; case OpCode::PUSH_VAR: operands_.push_back(locals_[ins.varIndex]); break; case OpCode::STORE_VAR: locals_[ins.varIndex] pop(); break; case OpCode::ADD: { double rhs pop(); double lhs pop(); operands_.push_back(lhs rhs); break; } case OpCode::JMP: pc ins.target - 1; break; case OpCode::JMP_FALSE: if (pop() 0.0) { pc ins.target - 1; } break; case OpCode::HALT: return; default: break; } } }特别注意JMP的实现因为外层循环执行完pc所以实际跳转时需要把pc设置为target - 1。这个“减1”的细节坑了我不少时间因为指令计数编号是从0开始的而跳转目标在生成指令时往往会先标记好位置。如果你也遇到跳转乱跳、循环不结束的问题优先检查这里。5.5 一个mini程序的完整实例整个流程跑通之后我可以直接用mini语言写一个计算0到10之和的程序int sum; int i; sum 0; i 1; while (i 10) { sum sum i; i i 1; } print sum;经过Lexer、Parser、SemanticAnalyzer、CodeGenerator之后生成的中间代码会以极快速度执行完毕输出55。这一个小程序验证了词法、语法、语义、变量存储、控制流、运算和输出整个链路是否通畅。看到控制台打印出正确的55时我长出了一口气那种持续了一个多月的“心里悬着一块石头”的感觉彻底落地了。6. 测试与调试课程设计里那些隐藏最深的坑6.1 坑一错误恢复造成的死循环前面说了panic mode的synchronize实现如果写得不好会在错误位置原地踏步。我遇到的实际场景是这样的一个if语句少了左括号Parser报错后跳过Token但synchronize里恰好把左括号跳过了右括号也跳过了结果跳到了条件表达式内部把整个结构弄乱了后面的解析函数继续报错最终在同一个循环里产生了一百多条错误信息。解决办法就是我在3.3里写的那条原则synchronize每调用一轮必须保证至少消费一个Token否则就强制advance()一次。我把这个判断写成了一个断言放进synchronize开头调试时很快就能定位问题。6.2 坑二比较运算优先级与JMP_FALSE的交互另一个让我纠结了两天的Bug发生在if条件的中间代码分析环节。如果条件比较运算生成的指令优先级有误JMP_FALSE就会弹出一个错误的值。举个例子if (a 0) { ... }正确的指令是PUSH_VAR a PUSH_NUM 0 GT JMP_FALSE endLabel但我一开始把GT的比较结果压栈顺序搞反了导致a 0被计算成了0 a。问题难查就难在它不影响编译流程只是运行结果反了。最后我用一个简单的测试程序输出了多个比较表达式的结果逐一跟手算值对比才锁定了操作数弹出顺序的问题。这里我悟出一个很重要的调试技巧栈式虚拟机的每条指令执行前栈里的元素个数其实是可预测的。比如ADD执行前栈里至少要有2个元素执行后减少1个。我在虚拟机的run()里加了一个#ifdef DEBUG模式的检查函数在每条指令执行后比对栈深度变化是否符合预期一旦发现栈元素残留就立刻打印指令序号和当前指令内容。这个机制帮我快速梳理出了一大批代码生成阶段的数据流Bug。6.3 坑三变量声明与赋值的初始化状态丢失早期版本里我并没有给Symbol添加initialized字段导致下面的代码能在编译期顺利通过int a; print a;运行时a的slot里是一个未初始化的double值0.0输出0看起来好像没问题但这其实掩盖了一个语义层面应该被捕获的错误。后来我在语义分析阶段加了initialized标记并要求在使用变量时必须先赋值成功补救了一处容易留下隐患的Bug。顺便说一下很多真实编译器对这种“未定义行为”的处理方式都不一样但课程设计阶段宁可严格一点也不要放过明显不合理的代码。6.4 值得专门设计的测试用例课程设计报告里“测试用例设计”是必不可少的一部分。我的经验是分层设计测试用例层次越分明报告越有说服力测试层次测试输入示例期望结果词法错误int a 123abc;报“非法数字字面量”精确定位行列语法错误if (a 0 { print a; }报“缺少右括号”并跳过整条语句语义错误print undefined_var;报“变量未声明”语义错误int a; int a;报“变量重复声明”类型错误int a; double b; a b;报“类型不匹配”运行行为含while循环的程序循环次数正确输出完全正确把这6类用例做全、做扎实课程设计答辩时拿着测试表和报错截图比单纯说“我的编译器能编”更有说服力。7. 如果让我重做这个课设我会在这些地方花更多时间写完整套编译程序再复盘有一些经验心得值得分享。如果时间倒流我会把重心放到AST节点类设计上先花一晚上设计好所有节点类的接口和字段把节点类型、源位置信息、类型信息全部规划完整再动手写Parser。实际上我一开始是边写Parser边改节点类导致好多处重复修改。过早跳进编码确实容易让自己陷入细节里出不来先想清楚数据结构真的能省掉大量返工。另外编译原理这门课的目的不完全是“学会写编译器”而是通过编译器这条线把字符串处理、树形结构、栈、哈希表、指令执行这些零散的知识全部串起来。写完这个课设之后再看平时用的IDE、解释器、脚本语言我会有一种“原来它们内部大概就是这样的”直觉感。如果你正在做这个课设我建议你一定不要只满足于“跑通Demo”而要把每个模块单独测试一遍记录每个Bug的根因。期末答辩时你把自己排错的过程讲清楚老师通常不会问得太深因为你已经用行动证明了你是真的理解了。最后分享一个小技巧给编译器加一个--dump-tokens和--dump-ast的命令行参数把Token流和AST结构打印出来。调试词法问题时看Token流调试语法问题时看AST这是最快缩小问题范围的办法。这个习惯一直延续到现在我写别的解释器、模板引擎也都会先加一个类似的中间表示导出功能越早能看到中间产物找Bug的速度就越快。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价