多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

SNL语言编译器课程设计:从文法到目标代码的完整实践指南

SNL语言编译器课程设计:从文法到目标代码的完整实践指南 简介面向编译原理课程设计与SNL语言编译器开发的学习参考源自吉林大学2022年课程设计作者使用C独立完成覆盖词法、语法、语义三阶段分析最终取得4.0绩点并与常见Github项目保持低相似度适合计算机专业学生对照实现或改进。整个压缩包共91个文件体积142.28MB以C源码和头文件、Visual Studio工程文件为主同时附有编译中间文件、调试符号、多组TXT测试用例及运行日志能够还原从源码构建到输出token、语法树和错误报告的全过程。已有1373人学习下载。读者可从中获取词法分析器、LL(1)语法分析器、符号表管理、语义检查等关键模块的组织方式还附带覆盖正确与错误场景的测试用例便于定位和对比自己的实现思路。1. SNL语言编译器为什么课程设计从文法开始就决定了成败每年做编译原理课程设计总有一批人被SNL编译器卡在第一个周五拿到题目先想“编译器多复杂啊”然后去找现成的C语言编译器源码打开一看几千行的yacc文件直接放弃。SNL语言是吉林大学计算机学院编译原理课程设计里定义的一门教学语言它比C小得多没有预处理、没有指针运算、没有结构体但词法分析、语法分析、语义分析、中间代码生成、目标代码生成一个环节都不少。反直觉的结论是语言越小每个模块越要自己从零搭网上那些简化版的MiniC、TinyC反而帮不上忙因为SNL的文法和调用约定和你拿到的课设文档不一致你改它还不如自己写。这篇文章沿着文法设计、词法与语法分析、语义分析、避坑、目标代码验证这条路线把这门课设里真正决定成败的点逐个拆开。2. 从文法到工程骨架先划定SNL语法边界再动手写编译器2.1 读懂SNL文法说明书EBNF产生式里藏着模块边界SNL语言课程设计一般会随题目发一本文法说明核心是一段EBNF扩展巴科斯范式或者yacc风格的产生式。不要跳过去直接写代码文法是整个编译器的需求文档词法分析要识别哪些符号、语法分析要调用哪些函数、语义分析要维护哪些属性全都能从产生式里推出来。常见的SNL课设文法会这样定义程序结构program :: program_id declaration_part compound_stmt . declaration_part :: type_declare_part var_declare_part subprogram_declare_part compound_stmt :: begin stmt_list end stmt_list :: stmt { ; stmt }这里的program_id是程序名标识符declaration_part负责类型、变量、函数的声明compound_stmt是主程序体。注意版本差异有的学校把入口规定成名叫main的procedure有的直接规定program后面跟的标识符就是程序入口还有的完全模仿Pascal用主程序块当入口。这直接影响后面“编译器未包含main类型”这类报错逻辑放在哪个阶段我见过不少组在语法分析里查入口结果声明和主程序分开定义时检查顺序出错。读文法时我习惯做三件事第一把所有终结符摘出来合并成语义词法单元表比如保留字、运算符、界符第二把所有非终结符按“声明部、表达式部、语句部”分组这样后续递归下降函数的文件划分就出来了第三圈出有左递归或者公共前缀的产生式比如表达式链这是LL(1)文法要处理的冲突点。把这三条写在设计文档里再开工后面少返工一轮。2.2 前后端拆分做词法语法分析时先想好中间表示SNL编译器最常见的架构错误是把词法、语法、语义、代码生成全部搅在同一个文件里。课设代码量通常在两千行到四千行之间全塞一起不是不能跑而是每改一个功能都要把整个文件读一遍。正确的做法是分前后端前端负责词法、语法、语义分析输出中间表示后端负责从中间表示生成目标代码教学编译器一般不做复杂优化前端才是评分重点。中间表示的选择上SNL课设有三条常见路线。第一条是只生成抽象语法树AST语义检查和代码生成都遍历AST第二条是语法分析后先生成AST再做一次遍历生成四元式三地址码第三条是直接用语法制导翻译边语法分析边生成四元式。我一般推荐第二条因为三地址码贴近汇编语义目标代码生成阶段不用再应付复杂表达式树而且是编译原理课程里四元式教学标准写起来最不容易出歧义。课程设计里中间表示是整个项目的“接口契约”。前端输出什么形态、后端从什么形态开始接必须在动工之前固定下来。我们当时定的协议是AST节点带类型属性和符号表指针语义分析阶段把类型信息填进节点然后遍历AST生成四元式序列后端只认四元式。这样前端三个模块可以并行开发只要AST节点定义评审过一次就行。2.3 最小工程骨架目录、头文件和构建脚本一次配齐SNL编译器推荐用C写文件划分按上一节的模块边界走。最少需要的目录结构snl-compiler/ ├── include/ │ ├── token.h │ ├── ast.h │ ├── symbol.h │ ├── parser.h │ ├── sema.h │ └── codegen.h ├── src/ │ ├── lexer.cpp │ ├── parser.cpp │ ├── sema.cpp │ ├── codegen.cpp │ └── main.cpp ├── tests/ │ ├── case_01_empty.snl │ ├── case_02_expr.snl │ └── case_03_recursion.snl └── MakefileCXX g CXXFLAGS -stdc17 -Wall -Wextra -g TARGET snlc SRCS src/lexer.cpp src/parser.cpp src/sema.cpp src/codegen.cpp src/main.cpp OBJS $(SRCS:.cpp.o) all: $(TARGET) $(TARGET): $(OBJS) $(CXX) $(CXXFLAGS) -o $ $^ src/%.o: src/%.cpp $(CXX) $(CXXFLAGS) -c -o $ $ clean: rm -f $(OBJS) $(TARGET)Makefile里-g调试选项务必保留编译器的每个阶段都可能段错误没有GDB基本靠猜。CXXFLAGS的-Wall -Wextra也别摘未初始化变量在编译器项目里是常态警告开着你才能早发现。tests目录从第一天就建起来每完成一个语法特性就落一个测试用例这个习惯后面价值极大。构建脚本本身不是难点真正需要想清楚的是主程序流程main.cpp里依次调用词法分析、语法分析、语义分析、代码生成每个阶段把结果传给下一个阶段任何一个阶段报错就停止后续阶段。别为了“尽量多输出”而跳过错误继续分析那样AST残缺后端拿到半棵树只会崩溃得更难看。3. 词法与语法分析用递归下降把SNL源码变成抽象语法树3.1 Token类型表与词法分析器一个状态机搞定全部记法词法分析是SNL编译器里门槛最低但最容易出低级bug的模块。它的任务是读入源文件字符流输出Token序列每个Token包含类型、值、行号、列号。SNL的记法通常包含保留字、标识符、无符号整数、字符串常量、单字符运算符、双字符运算符和注释。先定义Token类型枚举// include/token.h enum class TokenType { // 保留字 KW_PROGRAM, KW_TYPE, KW_VAR, KW_FUNCTION, KW_PROCEDURE, KW_BEGIN, KW_END, KW_IF, KW_THEN, KW_ELSE, KW_WHILE, KW_DO, KW_READ, KW_WRITE, KW_INTEGER, KW_CHAR, KW_ARRAY, KW_OF, KW_RETURN, // 标识符与常量 IDENT, NUMBER, STRING, // 运算符与界符 OP_ASSIGN, OP_EQ, OP_NEQ, OP_LT, OP_GT, OP_LE, OP_GE, OP_PLUS, OP_MINUS, OP_MUL, OP_DIV, LPAREN, RPAREN, LBRACKET, RBRACKET, SEMI, COMMA, COLON, DOT, // 特殊情况 COMMENT, END_OF_FILE };保留字表用哈希集合预存每读一个字母序列先查集合命中就是保留字否则是标识符。核心循环是这样的// src/lexer.cpp std::vectorToken Lexer::tokenize() { std::vectorToken tokens; while (true) { skipWhitespaceAndComments(); if (peek() \0) { tokens.push_back({TokenType::END_OF_FILE, , line, col}); break; } if (isalpha(peek())) { std::string word readWhile(isalnum); if (keywordTable.count(word)) tokens.push_back({keywordToType(word), word, line, col}); else tokens.push_back({TokenType::IDENT, word, line, col}); } else if (isdigit(peek())) { std::string num readWhile(isdigit); tokens.push_back({TokenType::NUMBER, num, line, col}); } else { TokenType t matchOperatorOrDelimiter(); if (t TokenType::COMMENT) continue; tokens.push_back({t, currentLexeme(), line, col}); } } return tokens; }这段代码的逻辑很直接但三个细节决定能不能用第一skipWhitespaceAndComments里要处理“//”行注释和“{ }”或“/* */”块注释读到注释末尾时要同时更新行号计数器漏了行号后面所有报错全错位第二matchOperatorOrDelimiter必须先匹配双字符运算符比如“:”“”“”再匹配单字符顺序反了会把“:”拆成冒号和等号两个Token第三字符串常量必须单独一个状态循环遇到反斜杠转义字符不能直接结束字符串否则源文件里写个“\n”就崩。词法器最容易翻车的位置是指针越界。readWhile的终止条件要在进入前判断peek()是否为EOF很多同学用while循环内先取值再判断到文件末尾peek再往前进一位就会读到未定义字符。词法分析器是后续所有阶段的输入源宁可慢一点把异常处理写全也不要为了省代码牺牲健壮性。3.2 递归下降语法分析每一个产生式对应一个解析函数递归下降是SNL语法分析最主流的实现方式因为它和EBNF产生式一一对应调试时看函数栈就是看文法展开路径。每个非终结符写一个解析函数函数开头expect对应的终结符然后按产生式右侧逐个解析。表达式部分要先消除左递归标准做法是把左递归改成循环或者拆成优先级层级// src/parser.cpp // expr :: simple_expr { ( | | | | | ) simple_expr } std::unique_ptrExprNode Parser::parseExpr() { auto left parseSimpleExpr(); while (peekIs({TokenType::OP_LT, TokenType::OP_GT, TokenType::OP_LE, TokenType::OP_GE, TokenType::OP_EQ, TokenType::OP_NEQ})) { TokenType op currentToken().type; advance(); auto right parseSimpleExpr(); left std::make_uniqueBinaryExprNode(op, std::move(left), std::move(right)); } return left; } // simple_expr :: term { ( | - ) term } std::unique_ptrExprNode Parser::parseSimpleExpr() { auto left parseTerm(); while (peekIs({TokenType::OP_PLUS, TokenType::OP_MINUS})) { TokenType op currentToken().type; advance(); auto right parseTerm(); left std::make_uniqueBinaryExprNode(op, std::move(left), std::move(right)); } return left; }这段代码里所有二元运算符都左结合遇到“1 2 3”会先构造成“(12)3”符合多数课设要求。但如果课设文档要求某些运算符右结合比如赋值或者幂运算就要单独处理。while循环内部每次都要检查peekIs防止取到END_OF_FILE后继续匹配运算符否则读到程序末尾时解析函数会访问空Token。保留字和标识符的歧义是SNL解析的一个大坑。比如“IF”同时可能是保留字如果你不小心把它写进了标识符的接受路径那变量名叫If或者if的用例就会在语法分析阶段直接报错。递归下降里判断一个Token是不是保留字应该在词法阶段就完成标注语法分析阶段不查字符串内容只看TokenType代码会更干净。3.3 AST节点设计让语法树同时撑起语义分析和代码生成AST节点设计是贯穿SNL编译器三个阶段的公共地基。节点既要保留源代码的位置信息行号列号用于报错又要留出语义分析阶段填类型、符号表指针、常量值属性的空间。常见设计是三类节点的继承结构// include/ast.h enum class NodeKind { PROGRAM, DECL_LIST, VAR_DECL, TYPE_DECL, FUNC_DECL, PARAM_LIST, STMT_LIST, ASSIGN_STMT, IF_STMT, WHILE_STMT, READ_STMT, WRITE_STMT, EXPR_BINARY, EXPR_NUMBER, EXPR_VAR, EXPR_CALL, EXPR_STRING }; struct ASTNode { NodeKind kind; int line, col; }; struct ProgramNode : ASTNode { std::string name; std::vectorstd::unique_ptrDeclNode decls; std::unique_ptrStmtNode body; }; struct BinaryExprNode : ASTNode { TokenType op; std::unique_ptrExprNode left, right; // 语义分析阶段填充的属性 TypeInfo* typeInfo nullptr; };用继承结构的好处是每个阶段可以按NodeKind去dispatch而不用在同一个struct里堆几十个字段。缺点就是dynamic_cast有点多运行时开销在课设阶段无所谓关键是让编译器在编译期帮你检查类型安全。我见过用单一结构体加union硬怼的项目写起来快但后面加属性时每一处访问都要改维护成本很高。AST节点里最核心的是每个表达式节点都保留TokenType而不是字符串这样语义分析判断“加法操作数必须是数值类型”时只需要检查op和左右子节点的类型属性不需要去解析运算符字符串。字符串到类型的转换在词法分析阶段做一次后面全是类型安全的枚举比较。4. 语义分析与中间代码生成符号表、类型检查和四元式的联动4.1 符号表与作用域栈插入和查找的顺序不能乱语义分析阶段第一件事是建符号表。SNL语言有全局作用域和函数/过程嵌套子作用域每个函数内部还能声明局部变量。用作用域栈实现最直观进入一个新的函数体时压入新作用域函数体结束时弹出。插入操作永远发生在当前栈顶作用域查找操作从栈顶往下逐层查这是标准规则顺序反了就会出现“局部变量找不到”或者“全局变量被局部定义遮蔽”的玄学问题。// src/sema.cpp class ScopeStack { std::vectorstd::unordered_mapstd::string, Symbol scopes; public: void pushScope() { scopes.emplace_back(); } void popScope() { scopes.pop_back(); } bool insert(const std::string name, const Symbol sym) { auto top scopes.back(); if (top.count(name)) return false; // 重定义 top.emplace(name, sym); return true; } const Symbol* lookup(const std::string name) const { for (auto it scopes.rbegin(); it ! scopes.rend(); it) { auto found it-find(name); if (found ! it-end()) return found-second; } return nullptr; } };Symbol结构体至少要包含符号种类变量、常量、函数、过程、数组、类型名、数据类型、数组维度信息、函数参数列表、函数返回值类型、还有代码生成阶段需要的数据槽偏移量。insert返回false的逻辑是检测重复定义同一作用域里同名符号必须报“redefined”错误这是5.4节会展开的坑。lookup返回的是const指针语义分析阶段只读不写避免后续代码生成阶段意外修改符号表内容。作用域栈的压栈时机值得注意必须在遍历函数体的声明部分之前压栈而不是在遇到函数名时压栈。因为函数声明里的参数列表属于这个函数的作用域参数类型检查需要查当前作用域里的类型定义如果压栈晚了参数声明里的类型名会解析到全局作用域可能查错类型。4.2 类型检查与语义动作把静态语义挂进遍历过程类型检查是语义分析的主线任务覆盖三类问题声明语句里的类型合法性、表达式里操作数类型匹配、赋值和参数传递时的类型兼容。SNL教学语言一般只有integer、char和数组类型检查策略相对简单但判断逻辑必须集中不要在AST每个节点上零散地写一堆类型判断。标准做法是每个visit函数返回一个TypeInfo一路向上传递TypeInfo* Sema::visitExpr(ExprNode* node) { switch (node-kind) { case NodeKind::EXPR_NUMBER: { node-typeInfo getType(integer); return node-typeInfo; } case NodeKind::EXPR_BINARY: { auto* bin static_castBinaryExprNode*(node); auto* ltype visitExpr(bin-left.get()); auto* rtype visitExpr(bin-right.get()); if (bin-op TokenType::OP_ADD || bin-op TokenType::OP_SUB || bin-op TokenType::OP_MUL || bin-op TokenType::OP_DIV) { if (!isNumeric(ltype) || !isNumeric(rtype)) error(arithmetic operand type mismatch, bin-line, bin-col); node-typeInfo getType(integer); } else { node-typeInfo getType(boolean); } return node-typeInfo; } case NodeKind::EXPR_VAR: { auto* var static_castVarExprNode*(node); const Symbol* sym scopeStack.lookup(var-name); if (!sym) error(undefined variable var-name, var-line, var-col); node-typeInfo sym-type; return node-typeInfo; } default: return visitExprDefault(node); } }这段代码把类型信息的传递逻辑串起来了。数值字面量直接标记为integer二元表达式的左右操作数先递归访问拿到类型再做算术运算符的类型检查。这里有一个常见误用不少同学在visitExpr里看到NUMBER节点就直接返回integer忽略了字符常量和字符串的区分导致后续代码生成阶段把char当成int生成输出结果完全错乱。类型检查与语义动作的挂载方式是语法树遍历一遍访问声明节点时插入符号表访问表达式节点时做类型检查访问语句节点时检查控制流合法性比如while后面的表达式必须是booleanwrite参数个数与类型必须匹配。整个过程对AST是单遍遍历所以设计AST节点时务必保证所有子节点都通过unique_ptr正确连接遍历时不要出现空指针就万事大吉——空指针在语义分析阶段崩掉比在代码生成阶段崩掉容易定位多了。4.3 四元式生成三地址码的格式与临时变量管理语义分析通过后AST已经带上了完整的类型信息这时就可以遍历AST生成四元式。常见四元式格式是四元组(op, arg1, arg2, result)表示result arg1 op arg2。运算符包括加减乘除、关系运算、跳转、函数调用、数组读写、输入输出。表达式生成是核心部分采用递归遍历表达式树每个子树生成到临时变量std::string CodeGen::generateExpr(ExprNode* node) { switch (node-kind) { case NodeKind::EXPR_NUMBER: { std::string temp newTemp(); emit(ASSIGN, node-literalValue, _, temp); return temp; } case NodeKind::EXPR_BINARY: { auto* bin static_castBinaryExprNode*(node); std::string left generateExpr(bin-left.get()); std::string right generateExpr(bin-right.get()); std::string result newTemp(); emit(operatorName(bin-op), left, right, result); return result; } case NodeKind::EXPR_VAR: { return node-varName; } default: throw CodeGenError(unsupported expression kind); } }临时变量管理是四元式生成里最容易出问题的点。newTemp每次分配一个全新的T1、T2、T3编号不要复用已经分配过的临时变量除非你做了活跃变量分析。课设阶段不做寄存器分配临时变量数量上限一般也不会超过几百个分配新编号的开销可以忽略。这里有一个典型的翻车案例两个表达式子树都用T1做中间结果合并到上层表达式时T1被覆盖生成的四元式算出来的结果完全不对而且只在特定输入下才会暴露调了半天才发现是临时变量复用导致。四元式序列的数据结构用vector 即可Quadruple里四个字段都可以是字符串。跳转指令的target先用标号占位等整个函数的四元式全部生成结束后再回填实际行号。SNL课设的if、while语句翻译一般要求回填技术别在前端就写死跳转目标否则一旦在if-else里插入新的中间代码跳转行号全部错位。5. 避坑SNL编译器开发中最常见的5类翻车现场5.1 报错“编译器未包含main类型”入口检查放错了位置现象SNL源程序里明明有程序主体但编译器在语义分析阶段报错提示缺少main入口或“编译结果为未包含main类型”有的实现甚至能编译通过但运行时没有任何输出。原因入口检查的位置不对。入口检查应该放在符号表构建完成后检查的是“是否存在名为main的procedure或function”而不是在语法分析阶段看程序名。很多同学的词法语法分析里把program后面的标识符直接当成main但课设文档通常规定入口是一个叫main的函数或过程。如果文法允许声明和主程序分开而且main是procedure声明的一部分那么入口检查必须查符号表且必须在符号表所有声明处理完之后再做。解决在语义分析的最后一步对全局作用域做一次扫描确认名称为main的符号存在且类型为procedure或function按课设文档规定。同时检查main是否带参数带参数要报错。这个检查放在所有作用域弹出之后、四元式生成之前顺序不能颠倒。我们当时在main检查里漏了“main不能带参数”这一条测试用例里有人写了带参数的main生成的代码在调用约定上和普通procedure混在一起运行起来直接段错误。5.2 注释和字符串里的特殊字符让词法分析崩溃现象源文件里只要出现包含关键字、运算符的字符串常量比如write(if ab)词法分析器输出的Token序列就错乱语法分析跟着报出莫名其妙的错误或者注释没结束就跑到文件尾程序卡死。原因词法分析器的状态机没有把字符串常量和注释当作独立状态处理。写Token循环时只检查了普通字符的走向遇到双引号没有切换到一个“字符串内部”状态字符串里的“if”“”就被当成普通Token输出。块注释的处理如果只读到“*/”的第一个字符就停止漏掉后面字符也会把注释内容解析为代码。解决词法分析主循环里单独处理STRING状态遇到双引号进入字符串收集循环直到遇到下一个未转义的双引号字符串里不解析任何保留字和运算符。注释处理同样行注释“//”直接读到换行符块注释要记录开始行号和嵌套计数如果文法支持嵌套注释。写完后用带各种特殊字符的用例压一遍词法器包括空字符串、连续注释、字符串里包含“//”这几个用例能挡住大部分状态机缺陷。5.3 赋值与比较混用条件表达式被解析成赋值节点现象课设文法用“:”表示赋值、用“”表示比较时parser里如果不区分这两种Token赋值语句和if条件就会被错误归类。原因SNL有Pascal血统赋值运算符和相等比较运算符是两套Token。递归下降解析赋值语句时先解析左值标识符然后expect赋值运算符解析if条件时表达式解析器里遇到“”应该生成比较节点但有的实现用同一个Token类型收编了“:”和“”或者忽略了对赋值运算符的专门处理导致if a b then被拆成“a : b”。解决在词法阶段就把“:”和“”分开成OP_ASSIGN和OP_EQ两个Token。如果课设文档里赋值也用“”C风格那赋值语句的解析要单独放在Statement层表达式里的“”歧义通过上下文判断赋值号左边必须是变量或数组元素而比较运算符两侧都是表达式。无论如何不要在词法层合并这两个运算符否则语法分析阶段要为每一种上下文做二次判断坑会越踩越深。5.4 函数重声明把符号表覆盖了前面类型全白查现象语义分析阶段先检查了某个变量的类型没有问题后面某个变量或者函数用了同名符号前面那个变量的类型信息就变了代码生成阶段按新类型生成完全错误的代码。原因符号表insert实现里没有查重最新insert的同名符号直接覆盖旧条目。这在函数声明和变量声明混排的SNL程序中很容易触发比如先声明了一个全局变量max后面又声明了一个函数max函数内部引用max时按作用域栈查到的是函数自身的符号没问题但全局代码里的max就变成了函数的地址类型全乱。解决insert之前先查当前作用域是否已有同名条目。注意只查当前作用域不查外层这是“局部变量可以遮蔽全局变量”的语言规则。如果当前作用域已存在同名符号直接报重定义错误并跳过插入。这个检查必须放在作用域栈的push之后、所有类型检查之前让重复定义尽早暴露不要让错误漂移到后面的代码生成阶段。5.5 四元式操作数颠倒语法树遍历顺序带来的玄学问题现象生成的中间代码逻辑上看起来对但运行结果总是反的比如“a - b”输出成了“b - a”“a / b”结果小于1。原因生成四元式时先把右子树存进临时变量再把左子树存进去最后生成的指令操作数顺序写反了。递归下降解析表达式时parseExpr生成的是左结合的树遍历时习惯从左子树开始访问但如果代码里先递归右子树再递归左子树操作数进临时变量的顺序就反转了。当两个操作数都是变量时看不出来一旦有一侧是复杂表达式结果就错。解决在generateExpr里先递归left再递归right严格按照这个顺序把操作数写入四元式。另一种做法是生成树节点时固定left和right的相对位置并在代码生成阶段写一个assert检查如果BinaryExprNode里left是空指针直接报错而不是静默地生成错误指令。我后来给四元式生成加了opcode级的自检遇到“SUB”一类不可交换运算符时对比左右操作数类型一旦发现两边类型相同但来源可疑就打个警告这样能快速定位顺序类问题。6. 目标代码生成与自测让四元式变成真正可运行程序的验证技巧6.1 把C语言当“伪汇编”用gcc收尾把优化交给编译器SNL课程设计的目标代码生成如果不想从零实现MIPS汇编器最稳的路线是定义C语言为伪汇编把四元式逐条翻译成C语句然后调用本机gcc编译器生成可执行文件。这个方案在课设里是被广泛接受的因为教学编译器评分重点是前端正确性后端能吐出可运行的C代码就算完整。翻译规则很简单四元式的result对应一个C临时变量arg1和arg2对应C表达式跳转指令对应goto和标号。// 四元式: (ADD, T1, T2, T3) - int T3 T1 T2; // 四元式: (JMP, _, _, label) - goto label; // 四元式: (ASSIGN, 3, _, T4) - int T4 3;生成的C代码里所有临时变量统一声明在函数开头避免C语言“变量声明必须在语句之前”带来的顺序问题。每个SNL函数翻译成一个C函数SNL入口main翻译成C的main函数。至于优化不要在四元式层面做生成C代码后让gcc的-O2去干这件事编译器的优化不是这门课设的重点你能把每个SNL语句准确映射成C语句就已经拿到后端高分。6.2 从空程序到递归函数一组渐进自测清单验证SNL编译器最有效的方法是准备一组从易到难的测试用例按依赖顺序逐步推进。我建议从空程序开始逐步增加特性每个测试用例独立成文件不要一个用例测多个特性。第一个用例是空程序只声明main过程体和begin/end用于验证编译流程完整跑通。第二个用例是整数常量赋值和write输出验证ASSIGN和输出语句的翻译正确。第三个用例是加减乘除表达式验证运算符优先级和临时变量分配这个用例能发现5.5节的操作数颠倒问题。第四个用例是if-else验证条件跳转的标号回填。第五个用例是while循环验证循环体跳转距离计算。第六个用例是函数或过程调用以及参数传递验证符号表参数列表的翻译。最后上递归函数用例比如阶乘或者斐波那契这一关通过说明调用栈和返回值处理都没问题。6.3 坚持从最小的用例开始一个反复发生的血泪教训我做这个课设时最大的失误是在自测顺序上总觉得“等所有功能写完了再一起测”。结果前端三个模块堆在一起词法分析开始就报错排查了一个下午才发现是字符串常量的状态机漏了转义处理——这个bug自己单独测几分钟就能定位混在完整程序里根本看不出来。后来我把测试用例固定成上一节那个序列每次提交新特性前先跑前面的用例再跑新的用例出问题马上知道是哪个模块、哪个文法产生式引入的。还有一个收获是给每个测试用例都留了“期望输出”不是程序员的直觉输出而是拿标准实现跑一遍之后记录的权威输出。生成目标代码后用diff对比期望输出不靠肉眼检查。这样才能在验收时一次跑过。这门课设最需要的其实不是多么高深的编译算法而是每一步都留脚印、每个模块都能单独验证的工程习惯。希望这些经验帮到你至少让你在午夜调试时少一点对着段错误发呆的时间。本文还有配套的精品资源点击获取
返回列表