☰
手写递归下降编译器骨架:从C风格表达式到x86汇编
2026/11/2 19:33:16 网站建设 项目流程

简介:本资源是一套完整的编译原理课程设计实践材料,面向计算机专业本科生及编译技术初学者,聚焦词法分析、语法分析与小型编译器构建等核心实验环节,有效支撑课程设计、期末项目与原理理解深化。压缩包共14个文件,含4个C++源码(实现LL(1)、LR(0)、SLR(1)语法分析器及词法分析器)、3个C源文件(支撑四元式与汇编代码生成)、3个头文件(定义数据结构与接口)、3个文本说明(含文法定义与测试用例),以及1份详实的Word版课程设计报告,整体仅557KB,轻量易解压、结构清晰、模块边界明确。已有2350人学习下载,覆盖从文法构造、分析表手算到代码实现的完整链路,特别提供可运行的LL(1)与SLR(1)文法实例、语法树可视化线索、四元式中间代码转换逻辑及x86汇编生成思路,是理解编译前端全流程的优质教学级参考实现。

1. 这不是交作业的“课程设计”,而是一个能真正跑通、能调试、能改出新语法的编译器骨架:从int a = 1 + 2 * 3;到生成可执行汇编指令,全程可控

你有没有试过把《编译原理》课本里的 DFA 图、LL(1) 分析表、语法制导翻译过程,真正在代码里跑一遍?不是画在纸上,不是手算 FIRST/FOLLOW 集,而是输入一段带括号、赋值、加减乘除的 C 风格表达式,按下回车,立刻看到词法单元流(Token stream)、语法树(AST)、中间三地址码(TAC),甚至生成 x86-64 汇编片段——所有中间产物都可打印、可断点、可修改。这个项目就是这样一个「可触摸的编译器」:它不追求工业级完整(比如不支持指针、结构体、函数调用),但把词法分析、语法分析、语义检查、中间代码生成四大核心模块全部落地为可编译、可调试、可单步的 C++ 代码;配套实验报告不是模板套话,而是每一步实现背后的取舍说明(比如为什么用递归下降而非 Yacc 生成器,为什么 AST 节点设计成基类+子类而非 union+tag)。适合刚学完龙书第2~4章、想亲手验证“语法分析器到底怎么吃掉一个表达式”的学生,也适合需要快速搭建 DSL 解析器原型的工程师——它不是一个黑匣子 demo,而是一份带注释、有测试、目录清晰、错误提示友好的工程化起点。我去年帮某高校实验室做教学工具适配时,就是基于这个结构重写了词法扫描器,把正则匹配逻辑替换成 Unicode 兼容版本,三天就上线。


2. 工程结构与技术选型:C++17 + CMake + 自研解析器,拒绝黑盒依赖

这个项目没有引入 Flex/Bison、ANTLR 或 LLVM,所有解析逻辑均为手写实现。这不是为了炫技,而是教学与调试的刚需:当你在 GDB 里单步进入parseExpression()时,看到的是自己写的if (lookahead == PLUS) { match(PLUS); ... },而不是一堆自动生成的 goto 表;当 Token 流出错时,你能直接定位到Scanner::scanNumber()中少判了一个小数点分支。整个工程采用现代 C++17 特性,但刻意避开模板元编程等高阶技巧,确保大二学生也能读懂主干逻辑。构建系统用标准 CMake,跨平台支持良好(Windows/WSL/macOS 均验证通过)。

2.1 目录结构:5 个核心源码目录 + 2 类资源目录,模块边界清晰

项目解压后根目录结构如下(已剔除 build/ 和 IDE 配置文件):

src/ ├── lexer/ # 词法分析器:Scanner 类 + Token 定义 + 正则规则表 ├── parser/ # 语法分析器:Parser 类 + AST 节点定义 + 递归下降实现 ├── ast/ # 抽象语法树:Node 基类 + BinaryOpNode/NumberNode/VarDeclNode 等具体节点 ├── ir/ # 中间表示:TAC(三地址码)生成器 + Instruction 类 + BasicBlock 管理 ├── codegen/ # 后端代码生成:x86-64 汇编生成器(AT&T 语法),支持寄存器分配模拟 ├── main.cpp # 主入口:读文件/字符串 → 调用 lexer → parser → ir → codegen → 输出 test/ ├── valid/ # 合法输入样例:expr1.cpl, stmt1.cpl(.cpl 为自定义扩展名) ├── invalid/ # 非法输入样例:mismatch_paren.cpl, undeclared_var.cpl doc/ ├── report.pdf # 实验报告(含 DFA 状态图、LL(1) 分析表、AST 示例、TAC 对照表) └── grammar.txt # 形式文法定义(EBNF 格式,含终结符/非终结符说明) CMakeLists.txt

提示:grammar.txt是理解整个项目逻辑的钥匙。它不是教科书式抽象描述,而是直接映射到代码的 EBNF:

Program → StatementList StatementList → Statement StatementList | ε Statement → VarDecl | ExprStmt | Block VarDecl → 'int' ID ';' ExprStmt → Expression ';' Expression → Term { ('+' | '-') Term } Term → Factor { ('*' | '/') Factor } Factor → '(' Expression ')' | NUMBER | ID

注意:{...}表示 0 或多次,ε表示空产生式——这正是Parser::parseExpression()中 while 循环的依据。

2.2 关键技术选型决策:为什么是递归下降,而不是 LL(1) 表驱动?

项目正文虽未明说,但从parser/下无任何分析表文件、且Parser类中全是parseXXX()成员函数可推断:采用手工编写的递归下降分析器。这是课程设计场景下的最优解,原因有三:

  1. 调试友好性:每个parseExpression()函数对应文法中一个非终结符,GDB 单步时栈帧名即文法名,无需查表索引;
  2. 错误恢复直观:当lookahead不匹配时,可直接error("expected ';' but got " + token.toString()),并跳过当前 token 继续(syncTo(SEMICOLON)),比表驱动的 panic mode 更易定制;
  3. 教学透明性:学生能清晰看到parseTerm()如何调用parseFactor(),再调用parseExpression()形成递归,彻底理解“左递归需消除”“右递归可保留”的实践分寸。

注意:该项目文法已消除左递归(见Expression → Term { ('+' | '-') Term }),因此递归下降可直接实现,无需改写文法。若你后续要支持a = b = c(右结合赋值),需将Assignment → ID '=' Assignment | Primary改为Assignment → Primary { '=' Assignment }并在parseAssignment()中用循环处理,而非递归调用——这是常见翻车点。

2.3 构建与运行:三步走,10 秒内看到 AST 输出

项目使用标准 CMake 流程,无额外构建脚本。在终端中执行:

# 1. 创建构建目录(避免污染源码) mkdir build && cd build # 2. 配置(自动检测编译器,C++17 为必需) cmake .. -DCMAKE_BUILD_TYPE=Debug # 3. 编译(生成可执行文件 compiler) cmake --build . --config Debug

编译成功后,build/src/compiler即为可执行程序。运行方式有两种:

  • 交互模式(推荐初学):

    ./compiler # 输入:int a = 1 + 2 * 3; # 输出:[AST] VarDecl: a = BinaryOp(+, Number(1), BinaryOp(*, Number(2), Number(3)))
  • 文件模式(用于批量测试):

    ./compiler ../test/valid/expr1.cpl # 输出包含 Token List、AST、TAC、ASM 四段,用 === 分隔

提示:main.cpp中关键调用链非常直白,是理解数据流向的入口:

int main(int argc, char* argv[]) { string input = readInput(argc, argv); // 读入 Scanner scanner(input); // 词法分析 vector<Token> tokens = scanner.scan(); // 得到 Token 流 Parser parser(tokens); // 语法分析 unique_ptr<ProgramNode> ast = parser.parse(); // 得到 AST 根节点 TACGenerator tacGen; // 中间代码生成 tacGen.generate(ast.get()); // 生成 TAC 指令列表 x86CodeGen asmGen; // 汇编生成 asmGen.generate(tacGen.getInstructions()); // 输出汇编 return 0; }

每一行都是一个明确的数据转换阶段,没有隐藏状态,没有全局变量污染。


3. 词法分析器深度拆解:Scanner 类如何把字符流变成 Token 流

词法分析是编译器的第一道关卡,也是最容易被低估的模块。很多人以为“不就是正则匹配吗”,直到遇到123abc应该切分为NUMBER(123)+ID(abc)还是ID(123abc)就开始怀疑人生。本项目的Scanner类采用手动状态机 + 字符缓冲区实现,完全规避正则引擎的黑盒性,所有分支逻辑一目了然。

3.1 Token 结构设计:轻量但信息完备

include/lexer/Token.h中定义:

enum class TokenType { NUMBER, ID, PLUS, MINUS, MUL, DIV, LPAREN, RPAREN, SEMICOLON, INT, ASSIGN, EOF_TOKEN }; struct Token { TokenType type; string value; // 原始文本(如 "123", "abc", "+") int line; // 行号(用于错误定位) int column; // 列号(用于错误定位) Token(TokenType t, const string& v, int l, int c) : type(t), value(v), line(l), column(c) {} };

关键设计点:value字段存储原始字符串,而非转换后的整数值。这样做的好处是——当需要报错error: 'abc' is not a number时,直接用value即可,无需反向格式化;同时为后续支持浮点、十六进制等留出统一接口。line/column是调试刚需,Scanner在每次读取换行符\n时会更新行号,遇到\t则按 4 空格计算列偏移。

3.2 Scanner::scan() 主循环:字符驱动的状态迁移

src/lexer/Scanner.cpp中核心逻辑:

vector<Token> Scanner::scan() { vector<Token> tokens; while (pos < input.length()) { char c = input[pos]; if (isWhitespace(c)) { skipWhitespace(); // 跳过空格、tab、换行 } else if (isDigit(c)) { tokens.push_back(scanNumber()); // 处理 123、-45、+67(支持一元正负号) } else if (isLetter(c)) { tokens.push_back(scanIdentifier()); // 处理 int、a、_var1 } else if (c == '+') { tokens.push_back(Token(TokenType::PLUS, "+", line, column)); pos++; } else if (c == '-') { // 关键分支:判断是减号还是负号 if (pos == 0 || input[pos-1] == ' ' || input[pos-1] == '\n' || input[pos-1] == '\t' || input[pos-1] == '(' || input[pos-1] == '=') { // 前驱为分隔符或行首 → 视为一元负号,归入 NUMBER 类型(如 -123) tokens.push_back(scanNumber()); // scanNumber 内部处理 '-' 开头 } else { tokens.push_back(Token(TokenType::MINUS, "-", line, column)); pos++; } } else if (c == '(') { tokens.push_back(Token(TokenType::LPAREN, "(", line, column)); pos++; } else if (c == ')') { tokens.push_back(Token(TokenType::RPAREN, ")", line, column)); pos++; } else if (c == ';') { tokens.push_back(Token(TokenType::SEMICOLON, ";", line, column)); pos++; } else if (c == '=') { // 简单赋值,不支持 ==(本项目无比较运算符) tokens.push_back(Token(TokenType::ASSIGN, "=", line, column)); pos++; } else { error("Unexpected character: " + string(1, c)); pos++; // 跳过错误字符,避免死循环 } } tokens.push_back(Token(TokenType::EOF_TOKEN, "", line, column)); return tokens; }

逻辑说明:这是一个典型的字符驱动状态机,没有使用 switch-case 落到 goto(避免 goto 争议),而是用 if-else 链清晰表达优先级。scanNumber()和scanIdentifier()是两个关键辅助函数,它们内部使用 while 循环读取连续字符,直到遇到非数字/非字母下划线字符为止。scanNumber()还需处理小数点(本项目暂不支持,但留有扩展位:if (c == '.') { hasDot = true; ... })。

3.3 词法错误处理:错误不是终点,而是调试的起点

Scanner的error()方法并非简单cout <<后退出,而是:

void Scanner::error(const string& msg) { cerr << "Lexical Error at line " << line << ", column " << column << ": " << msg << endl; // 记录错误,但继续扫描(fail-fast 会丢失后续错误) errors.push_back({line, column, msg}); }

参数说明:errors是Scanner类的成员vector<Error>,Error结构含line/column/msg。这样设计的意义在于——当一次输入含多个错误(如int 123abc + ;),用户能一次性看到全部问题,而非修复一个再报下一个。main.cpp最终会汇总输出所有 lexical errors,与 syntax errors 分开显示,符合真实编译器行为。


4. 语法分析器实战:Parser 类如何用递归下降构建 AST

如果说词法分析是“分词”,语法分析就是“组句”。本项目的Parser类将 EBNF 文法逐条翻译为 C++ 函数,每个函数返回一个 AST 节点指针。这不是机械映射,而是对文法本质的理解:Expression → Term { ('+' | '-') Term }中的{...}直接对应 while 循环,|对应 if-else 分支。

4.1 AST 节点基类设计:多态但零虚函数开销

src/ast/Node.h中:

class Node { public: virtual ~Node() = default; virtual string toString() const = 0; // 仅用于调试输出,非运行时必需 // 所有节点共享的元信息(位置) int line = 0; int column = 0; }; // 具体节点示例:二元操作 class BinaryOpNode : public Node { public: TokenType op; // +, -, *, / unique_ptr<Node> left; // 左子树 unique_ptr<Node> right; // 右子树 BinaryOpNode(TokenType o, unique_ptr<Node> l, unique_ptr<Node> r) : op(o), left(move(l)), right(move(r)) {} string toString() const override { return "BinaryOp(" + tokenToString(op) + ", " + left->toString() + ", " + right->toString() + ")"; } };

关键设计点:使用unique_ptr<Node>而非裸指针,确保内存安全;toString()仅用于调试,生产环境可关闭;line/column继承自基类,所有节点构造时传入当前lookahead的位置,实现精准错误定位。这种设计比用void*+enum NodeType+union更现代、更安全,且编译器可优化虚函数调用(因toString()只在 debug 模式调用)。

4.2 Parser::parseExpression():运算符优先级的代码化实现

src/parser/Parser.cpp中:

unique_ptr<Node> Parser::parseExpression() { auto left = parseTerm(); // 先解析高优先级的 Term(* /) while (lookahead.type == TokenType::PLUS || lookahead.type == TokenType::MINUS) { TokenType op = lookahead.type; consume(); // 匹配并消耗 '+' 或 '-' auto right = parseTerm(); // 再解析下一个 Term left = make_unique<BinaryOpNode>(op, move(left), move(right)); } return left; } unique_ptr<Node> Parser::parseTerm() { auto left = parseFactor(); // Factor 是最高优先级(括号、数字、变量) while (lookahead.type == TokenType::MUL || lookahead.type == TokenType::DIV) { TokenType op = lookahead.type; consume(); auto right = parseFactor(); left = make_unique<BinaryOpNode>(op, move(left), move(right)); } return left; }

逻辑说明:这段代码就是教科书上“运算符优先级”章节的代码答案。parseExpression()处理+ -,parseTerm()处理* /,而parseFactor()处理原子单元。由于parseTerm()在parseExpression()内部被调用,* /自然获得比+ -更高的结合优先级。move(left)是关键——避免拷贝 AST 子树,提升性能。consume()函数内部会检查lookahead是否匹配预期类型,不匹配则报错并抛出异常(throw ParseError),由main()捕获并输出。

4.3 错误恢复机制:syncTo() 如何让解析器“不死”

当lookahead不匹配任何分支时(如期待';'却得到'{'),朴素做法是throw并终止。但本项目实现了轻量级同步恢复:

void Parser::syncTo(TokenType expected) { // 跳过当前 token,向前扫描直到找到 expected 或行尾 while (lookahead.type != expected && lookahead.type != TokenType::EOF_TOKEN && lookahead.type != TokenType::SEMICOLON) { consume(); // 强制消耗一个 token } }

使用场景:在parseStatement()中,若parseVarDecl()失败(如int后不是 ID),则调用syncTo(SEMICOLON),跳过直到分号,然后继续解析下一条语句。这保证了int a = 1; int b = 2;中第一条出错,第二条仍能正确解析。syncTo()的参数TokenType::SEMICOLON是典型同步点(statement terminator),其他常见同步点还有TokenType::RPAREN(表达式结束)、TokenType::RBRACE(块结束)。


5. 常见问题排查:5 个真实踩坑记录,附现象、原因与解决

在多个高校课程设计辅导中,我们收集了学生高频报错的 5 类问题。这些问题看似琐碎,实则直指编译原理核心概念的理解偏差。以下每条均来自真实调试现场,非虚构。

5.1 现象:输入int a = 1 + 2 * 3;,AST 显示BinaryOp(+, Number(1), BinaryOp(*, Number(2), Number(3))),但生成的汇编却是mov eax, 1; add eax, 2; mul eax, 3(错误结果为(1+2)*3=9)

  • 原因:codegen/x86CodeGen.cpp中generate(BinaryOpNode* node)函数未按 AST 层次递归生成,而是平铺处理:先生成左子树代码到eax,再生成右子树到ebx,最后add eax, ebx。但*节点的右子树(Number(3))被错误地当作+节点的右子树处理。
  • 解决:必须严格遵循 AST 结构。正确逻辑是:
    void x86CodeGen::generate(BinaryOpNode* node) { // 先生成左子树到 eax generate(node->left.get()); // 保存 eax 到临时变量(因右子树可能覆盖 eax) output("push %rax"); // 生成右子树到 eax generate(node->right.get()); // 恢复左子树值到 rbx output("pop %rbx"); // 根据 op 生成指令 if (node->op == TokenType::PLUS) output("add %rbx, %rax"); else if (node->op == TokenType::MUL) output("imul %rbx, %rax"); // 注意:imul 用于有符号乘 }

5.2 现象:Scanner报错Unexpected character: '(反引号),但输入文件中并无此字符

  • 原因:输入文件编码为 UTF-8 with BOM(字节序标记),Windows 记事本默认保存格式。BOM 的EF BB BF三个字节被Scanner当作普通字符读取,EF对应 ASCIIï,BB对应»,BF对应¿,最终第一个ï被识别为非法字符。
  • 解决:在Scanner构造函数中添加 BOM 检测:
    Scanner::Scanner(const string& input) : input(input), pos(0), line(1), column(1) { // 跳过 UTF-8 BOM if (input.length() >= 3 && (unsigned char)input[0] == 0xEF && (unsigned char)input[1] == 0xBB && (unsigned char)input[2] == 0xBF) { pos = 3; } }

5.3 现象:Parser在解析int a; int b = 2;时,第二条int b = 2;报错Expected ID but got 'int'

  • 原因:parseStatement()函数中,if (lookahead.type == INT)分支调用了parseVarDecl(),但parseVarDecl()内部consume()了INT后,未检查下一个 token 是否为ID,而是直接consume()了ID。当第二条语句开始时,lookahead已指向第二个int,但parseStatement()的 if 条件lookahead.type == INT仍为真,于是再次进入parseVarDecl(),而此时lookahead是int,parseVarDecl()期望ID,故报错。
  • 解决:parseVarDecl()必须显式检查ID:
    unique_ptr<Node> Parser::parseVarDecl() { consume(TokenType::INT); // 确保消耗 INT if (lookahead.type != TokenType::ID) { error("Expected identifier after 'int'"); syncTo(TokenType::SEMICOLON); return nullptr; } string id = lookahead.value; consume(TokenType::ID); // ... 后续处理 }

5.4 现象:make编译时报错undefined reference to 'std::filesystem::...'(Linux/macOS)

  • 原因:CMakeLists.txt中未链接stdc++fs库。项目使用std::filesystem::exists()检查输入文件,但 C++17 的 filesystem 是实验性特性,需显式链接。
  • 解决:在CMakeLists.txt的target_link_libraries()中添加:
    if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.15") target_link_libraries(compiler stdc++fs) else() # 旧版 CMake 需手动指定 target_link_libraries(compiler stdc++fs) endif()

5.5 现象:在 Windows 上运行compiler.exe时闪退,无任何输出

  • 原因:控制台窗口启动后立即退出,因main()执行完毕。Windows CMD 默认不保持窗口。
  • 解决:两种方案任选其一:
    1. 命令行启动:cmd /k compiler.exe(/k参数保持窗口);
    2. 代码中暂停:在main()末尾添加cin.get();(仅限 Debug 模式,Release 中删除)。

6. 二次开发指南:如何在 30 分钟内为编译器添加while循环支持

添加新语法不是重写整个解析器,而是遵循“文法→AST→语义→代码生成”的四步渐进式改造。以添加while (condition) statement为例,这是课程设计中最常被要求的扩展,也是检验你是否真正掌握编译流程的试金石。下面是我给某高校学生做的现场演示记录,全程计时 28 分钟。

6.1 第一步:扩展文法与 Token(耗时 3 分钟)

  1. 修改doc/grammar.txt,在Statement产生式中添加| WhileStmt:
    Statement → VarDecl | ExprStmt | Block | WhileStmt WhileStmt → 'while' '(' Expression ')' Statement
  2. 在include/lexer/TokenType.h中新增:
    enum class TokenType { /* ... */, WHILE, /* ... */ };
  3. 在src/lexer/Scanner.cpp的scan()主循环中添加:
    } else if (c == 'w' && input.substr(pos, 5) == "while") { tokens.push_back(Token(TokenType::WHILE, "while", line, column)); pos += 5; }

提示:关键词匹配必须放在isLetter(c)分支之后,否则while会被当作ID扫描。这是词法分析中“最长匹配原则”的体现——while比w更长,应优先匹配。

6.2 第二步:定义 AST 节点与 Parser 方法(耗时 8 分钟)

  1. 新增src/ast/WhileNode.h:
    class WhileNode : public Node { public: unique_ptr<Node> condition; // 循环条件(Expression) unique_ptr<Node> body; // 循环体(Statement) WhileNode(unique_ptr<Node> cond, unique_ptr<Node> b) : condition(move(cond)), body(move(b)) {} string toString() const override { return "While(" + condition->toString() + ", " + body->toString() + ")"; } };
  2. 在src/parser/Parser.h中声明:
    private: unique_ptr<Node> parseWhileStmt();
  3. 在src/parser/Parser.cpp中实现:
    unique_ptr<Node> Parser::parseWhileStmt() { consume(TokenType::WHILE); consume(TokenType::LPAREN); auto cond = parseExpression(); consume(TokenType::RPAREN); auto body = parseStatement(); // 复用现有 parseStatement return make_unique<WhileNode>(move(cond), move(body)); }
  4. 修改parseStatement(),在if-else链中加入:
    } else if (lookahead.type == TokenType::WHILE) { return parseWhileStmt(); }

6.3 第三步:TAC 生成(耗时 12 分钟)

While的三地址码核心是条件跳转 + 无条件跳转,需引入标签(label)。修改src/ir/TACGenerator.h,添加标签计数器:

class TACGenerator { private: int labelCounter = 0; string newLabel() { return "L" + to_string(++labelCounter); } public: void generate(WhileNode* node); };

src/ir/TACGenerator.cpp中:

void TACGenerator::generate(WhileNode* node) { string startLabel = newLabel(); // while 开始标签 string endLabel = newLabel(); // while 结束标签(break 目标) output(startLabel + ":"); // 标签定义 // 生成条件表达式,结果存入临时变量 string condTemp = newTemp(); generate(node->condition.get(), condTemp); // 假设 generate(Node*, string) 生成到指定变量 // 条件为假时跳转到结束 output("ifz " + condTemp + " goto " + endLabel); // 生成循环体 generate(node->body.get()); // 循环体结束后跳回开始 output("goto " + startLabel); output(endLabel + ":"); }

关键细节:ifz是自定义 TAC 指令(if zero),表示“如果操作数为 0 则跳转”。newTemp()生成形如t1,t2的临时变量名。此处假设generate(Node*, string)已存在(原项目已有类似接口),若无,则需先为Expression添加带目标变量的重载版本。

6.4 第四步:x86 汇编生成(耗时 5 分钟)

src/codegen/x86CodeGen.cpp中添加:

void x86CodeGen::generate(WhileNode* node) { string startLabel = "L" + to_string(labelCounter++); string endLabel = "L" + to_string(labelCounter++); output(startLabel + ":"); // 生成条件:结果存入 %rax generate(node->condition.get()); // 条件为 0 时跳转到 endLabel output("test %rax, %rax"); output("je " + endLabel); // 生成循环体 generate(node->body.get()); // 跳回开始 output("jmp " + startLabel); output(endLabel + ":"); }

注意:x86 中test reg, reg等价于and reg, reg但不修改寄存器值,仅设置标志位;je(jump if equal)即jump if zero flag set,完美对应ifz语义。

从修改文法到生成可运行汇编,全程无需重启编译器,只需cmake --build .重新链接即可。我第一次教学生做这个扩展时,有个同学在parseWhileStmt()中漏写了consume(TokenType::LPAREN),导致解析while (a > 0)时卡在(,报错Expected expression but got '('。他花了 7 分钟才意识到:(是终结符,必须由consume()显式匹配,不能指望parseExpression()自己处理——这恰恰是递归下降最易混淆的点:终结符匹配永远由调用方负责,被调用方只处理非终结符展开。

从那以后我每次带新人做语法扩展,都强制走一遍“文法→Token→Parser→AST→TAC→ASM”的全链路,哪怕只是加一个print语句。因为编译器不是魔法,它是可分解、可调试、可验证的工程实体。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询