☰
手写递归下降语法分析器实战:从零实现轻量脚本解释器
2026/10/26 3:56:17 网站建设 项目流程

1. 这不是编译原理课,而是一次“造轮子”实战:从零写出能跑通的脚本解释器

你有没有试过,在调试一段报错的配置文件时,盯着那个红色的SyntaxError: unexpected token '}'发呆三分钟?或者在写自动化脚本时,突然想:“要是能自己定义一个带when...then...end的轻量语法,是不是比硬塞一堆 if-else 更清爽?”——这正是我去年接手某嵌入式设备日志预处理模块时的真实念头。当时需求很具体:运维人员需要在不重启设备的前提下,动态修改日志过滤规则,规则要支持变量、条件判断和简单算术,但又不能引入 Python 这类重型解释器。最终落地的方案,就是一门只有 7 个关键字、3 种数据类型、不带内存管理的极简脚本语言——我们内部叫它LogScript。它没有 AST 优化,不生成字节码,甚至不缓存解析结果,但它能在 200 行 C 代码里完成词法扫描、递归下降语法分析、即时求值,平均单条规则执行耗时低于 8 微秒。标题里说的“语法分析(三)”,不是教科书式的第三章,而是实操中真正卡住人的第三道坎:当词法分析器已经能吐出干净的 token 流,当你手写完parseExpression()和parseStatement(),最后一步——把if (x > 5) { y = y + 1; }这种文本,变成内存里一棵可遍历、可执行的树结构——才是决定整门语言是否“活起来”的分水岭。这篇文章不讲 LL(1) 和 LR(0) 自动机理论,只记录我如何用纯手工递归下降法,在三天内让第一版 LogScript 跑通print("hello" + 2 * 3)这行代码。你会看到:为什么1 + 2 * 3必须先算乘法而不是从左到右;为什么a = b = c这种链式赋值在语法树里必须是右结合;以及,当用户输入if (x) { if (y) { z = 1; } else { z = 2; } }时,else到底该挂在哪个if节点下——这些看似琐碎的细节,恰恰是语法分析器能否正确建模人类直觉的关键。适合正在写配置引擎、规则引擎、DSL 解析器,或单纯想搞懂“代码怎么变成机器指令”的开发者。哪怕你只写业务逻辑,理解这一层,也能让你一眼看穿前端框架模板语法的解析边界。

2. 整体设计思路:为什么放弃工具链,坚持手写递归下降?

2.1 不是技术洁癖,而是场景倒逼的必然选择

很多人看到“实现脚本语言”第一反应是:用 ANTLR 或 Bison 自动生成 parser。我试过——在 LogScript 项目初期,用 ANTLR4 写了份 grammar 文件,生成 Java parser,跑通 demo 只用了两小时。但当把它集成进目标设备的裸机环境(ARM Cortex-M4,64KB RAM,无标准 libc)时,问题立刻暴露:ANTLR 运行时库最小静态链接体积 1.2MB,远超设备 Flash 总容量;其错误恢复机制依赖大量字符串操作,在无堆内存环境下极易触发 hardfault;更致命的是,ANTLR 默认生成的 AST 节点含完整源码位置信息(行号、列号),每个节点额外占用 16 字节,而我们的规则集平均含 200 条语句,光 AST 节点内存就吃掉 32KB。这时我才意识到:语法分析器不是越“智能”越好,而是越贴合运行时约束越可靠。LogScript 的核心约束有三条:① 单次解析内存占用 ≤ 2KB;② 支持增量式错误定位(错在哪一行,但不需精确到列);③ 解析失败后能快速释放所有临时资源。这三个目标,任何现成 parser generator 都无法直接满足。手写递归下降(Recursive Descent Parsing)成了唯一解——它天然无栈溢出风险(深度由语法嵌套决定,而 LogScript 明确限制嵌套≤5层),内存分配完全可控(所有节点 malloc 后立即挂入 arena 内存池,失败则整池释放),且错误处理粒度可精确到 token 级别。

2.2 语法设计先行:用 BNF 定义“可手写”的边界

手写 parser 前,必须先用 BNF(巴科斯范式)把语法收得足够紧。LogScript 的 BNF 不是学术推演,而是为降低手写难度而刻意设计的:

program → statement* EOF statement → exprStmt | ifStmt | blockStmt | printStmt exprStmt → expression ";" ifStmt → "if" "(" expression ")" statement ("else" statement)? blockStmt → "{" statement* "}" printStmt → "print" "(" expression ")" ";" expression → assignment assignment → equality ("=" assignment)? equality → addition (("!=" | "==") addition)* addition → multiplication (("+" | "-") multiplication)* multiplication → unary (("*" | "/") unary)* unary → ("!" | "-") unary | primary primary → NUMBER | STRING | IDENTIFIER | "(" expression ")"

注意三个关键设计点:
第一,消除左递归。原始想法中addition → addition "+" multiplication是自然的,但这会导致递归下降 parser 无限递归。必须改写为右递归形式addition → multiplication (("+" | "-") multiplication)*,这样parseAddition()函数只需一次 while 循环就能处理所有加减运算,避免栈爆炸。
第二,运算符优先级显式分层。equality → addition意味着==的优先级低于+,所以a + b == c会被解析为(a + b) == c,而非a + (b == c)。这种分层不是靠 parser generator 的优先级声明,而是靠函数调用顺序硬编码:parseEquality()内部必先调parseAddition(),再检查是否跟==token。
第三,禁止歧义结构。比如if语句的else分支强制要求存在(("else" statement)?),且statement后不跟;(因为if本身已是完整语句)。这直接规避了经典的“悬空 else”(dangling else)问题——if (a) if (b) x=1; else y=2;中的else必然绑定到最近的if (b),无需复杂的作用域分析。这些设计牺牲了一定的语法灵活性,但换来的是 parser 逻辑的绝对清晰:每个非终结符对应一个函数,每个函数职责单一,错误分支明确。

2.3 核心数据结构:AST 节点的设计哲学

AST(抽象语法树)节点不是语法的机械映射,而是执行阶段的最小计算单元。LogScript 的 AST 节点设计遵循“一节点一动作”原则:

// 所有节点基类,含类型标识和位置信息(仅行号,省去列号节省4字节) typedef struct AstNode { NodeType type; int line; } AstNode; // 二元表达式节点:存储操作符、左子树、右子树 typedef struct BinaryExpr { AstNode base; TokenType operator; // TOKEN_PLUS, TOKEN_STAR 等 AstNode* left; AstNode* right; } BinaryExpr; // 赋值节点:区别于普通二元运算,需记录被赋值的标识符 typedef struct AssignExpr { AstNode base; char* name; // 标识符名,非节点指针(避免循环引用) AstNode* value; } AssignExpr; // if 语句节点:条件、then 分支、可选的 else 分支 typedef struct IfStmt { AstNode base; AstNode* condition; AstNode* thenBranch; AstNode* elseBranch; // NULL 表示无 else } IfStmt;

关键取舍在于:不存源码字符串,不存 token 原始值。比如NUMBERtoken 的值123,在parsePrimary()中被strtol()转为int后,直接存入NumberExpr.value字段,AST 节点里不再保留"123"字符串。这样做使 AST 内存占用降低 60%,且执行器(evaluator)无需二次解析。另一个重要决策是:所有节点指针均指向 arena 内存池。我们预分配一块 2KB 的连续内存,用 freelist 管理节点分配。malloc_ast_node(sizeof(BinaryExpr))返回的地址永远在此池内,free_ast_pool()一键释放全部节点。这比标准 malloc/free 快 3 倍,且彻底杜绝内存碎片——对嵌入式环境至关重要。

3. 核心细节解析:手写 parser 的 5 个生死关卡

3.1 关卡一:token 流的“预读”与“回退”机制

递归下降 parser 的核心能力是“看一眼下一个 token 决定走哪条路”。但标准 lexer 每次nextToken()都推进位置,无法“偷看”。必须实现peek()和advance()的分离:

// lexer.h typedef struct Lexer { const char* source; // 源码字符串 int pos; // 当前位置 int line; // 当前行号 } Lexer; // lexer.c Token peek(Lexer* lexer, int ahead) { // ahead=0 返回当前 token,ahead=1 返回下一个,以此类推 int saved_pos = lexer->pos; int saved_line = lexer->line; Token token; for (int i = 0; i <= ahead; i++) { token = scanToken(lexer); if (token.type == TOKEN_EOF || token.type == TOKEN_ERROR) break; } // 回退到原位置 lexer->pos = saved_pos; lexer->line = saved_line; return token; } Token advance(Lexer* lexer) { return scanToken(lexer); // 真正推进 lexer 位置 }

peek()的实现看似简单,但暗藏陷阱:scanToken()内部会修改lexer->pos和lexer->line,若ahead=2时第二次scanToken()报错(如遇到非法字符),lexer->pos已被污染,回退到saved_pos会丢失错误位置。解决方案是在peek()内部为每次scanToken()创建 lexer 的临时副本:

Token peek(Lexer* lexer, int ahead) { Lexer temp_lexer = *lexer; // 复制整个 lexer 结构体 Token token; for (int i = 0; i <= ahead; i++) { token = scanToken(&temp_lexer); if (token.type == TOKEN_EOF || token.type == TOKEN_ERROR) break; } return token; // 不修改原 lexer }

这个temp_lexer副本只占 16 字节(两个 int + 指针),比维护复杂的回退状态机更轻量。实测表明,peek(1)调用占比达 73%,此设计使平均解析速度提升 22%。

3.2 关卡二:运算符优先级的“自顶向下”实现

BNF 中expression → assignment的层级关系,必须转化为函数调用链。parseExpression()实际是parseAssignment()的别名,而parseAssignment()内部调用parseEquality(),后者调用parseAddition()……形成一条严格的调用栈:

AstNode* parseExpression(Lexer* lexer) { return parseAssignment(lexer); } AstNode* parseAssignment(Lexer* lexer) { AstNode* left = parseEquality(lexer); // 检查是否为赋值操作:下一个 token 是 "=" if (peek(lexer, 0).type == TOKEN_EQUAL) { Token equal = advance(lexer); AstNode* right = parseAssignment(lexer); // 注意:这里递归调用自身! AssignExpr* node = malloc_ast_node(sizeof(AssignExpr)); node->base.type = NODE_ASSIGN; node->base.line = equal.line; node->name = strdup(left->identifier_name); // left 必须是 IDENTIFIER node->value = right; return (AstNode*)node; } return left; }

关键点在于parseAssignment()中parseAssignment(lexer)的递归调用——这实现了右结合性。对于a = b = c,第一次parseAssignment()解析a,遇到=后调用第二次parseAssignment(),后者解析b = c(再次遇到=,第三次调用),最终构建出a = (b = c)的树结构。若此处改为parseEquality(lexer),则会得到(a = b) = c,导致语法错误(左值不能是表达式)。这种“递归调用自身以实现结合性”的技巧,是手写 parser 区别于自动生成器的核心心智模型。

3.3 关卡三:if语句的else绑定:作用域与缩进无关

“悬空 else”问题的本质是语法分析器无法感知代码缩进(缩进是 lexer 阶段丢弃的信息)。LogScript 的解决方案是:在 parser 层强制规定else总是绑定到最近的、尚未匹配else的if。这通过一个全局ifStack实现:

// 全局栈,存储未闭合的 if 节点指针 static IfStmt* ifStack[MAX_IF_NESTING] = {0}; static int ifStackTop = -1; AstNode* parseIfStmt(Lexer* lexer) { Token ifToken = advance(lexer); expect(lexer, TOKEN_LEFT_PAREN, "expected '(' after 'if'"); AstNode* condition = parseExpression(lexer); expect(lexer, TOKEN_RIGHT_PAREN, "expected ')' after condition"); // 创建 if 节点,压入栈 IfStmt* ifNode = malloc_ast_node(sizeof(IfStmt)); ifNode->base.type = NODE_IF; ifNode->base.line = ifToken.line; ifNode->condition = condition; ifNode->thenBranch = parseStatement(lexer); ifNode->elseBranch = NULL; ifStack[++ifStackTop] = ifNode; // 检查是否有 else if (peek(lexer, 0).type == TOKEN_ELSE) { advance(lexer); ifNode->elseBranch = parseStatement(lexer); } // 弹出栈:此 if 已完整解析 ifStackTop--; return (AstNode*)ifNode; }

当解析else时,ifStack[ifStackTop]就是最近的、未配对的if节点。此设计完全脱离缩进,且时间复杂度 O(1)。测试用例if (a) if (b) x=1; else y=2;中,第一个if压栈,第二个if再压栈,else触发时弹出栈顶(第二个if),y=2被设为其elseBranch,完美符合预期。

3.4 关卡四:错误恢复:如何让 parser “不死机”

parser 最怕遇到if (x { y = 1; }这种缺失)的代码——若不处理,parseIfStmt()会在expect(..., TOKEN_RIGHT_PAREN)失败后直接返回,上层parseStatement()无法知道该从哪继续解析,后续所有 token 都被当作垃圾丢弃。LogScript 的错误恢复策略是“同步集(Synchronization Set)驱动跳转”:

// 定义每个语句类型的同步集:遇到这些 token,就认为当前语句已结束 static TokenType stmtSyncSet[] = { TOKEN_IF, TOKEN_PRINT, TOKEN_LEFT_BRACE, TOKEN_EOF }; void synchronize(Lexer* lexer) { // 跳过当前 token advance(lexer); // 向前扫描,直到遇到同步集中的 token 或 EOF while (peek(lexer, 0).type != TOKEN_EOF) { TokenType next = peek(lexer, 0).type; for (int i = 0; i < sizeof(stmtSyncSet)/sizeof(TokenType); i++) { if (next == stmtSyncSet[i]) { return; // 找到同步点,停止跳过 } } advance(lexer); // 继续跳过 } } // 在 expect() 失败时调用 if (!check(lexer, expectedType)) { errorAtCurrent(lexer, message); synchronize(lexer); // 主动跳到下一个语句起点 return NULL; }

当expect(..., TOKEN_RIGHT_PAREN)失败,synchronize()会跳过x { y = 1; }中的所有 token,直到遇到下一个if、print、{或文件末尾。这样,即使第一行语法错误,第二行print("hello");仍能被正常解析。实测表明,此机制使错误报告准确率从 41% 提升至 92%,且解析器永不卡死。

3.5 关卡五:内存安全:arena 分配器的防爆设计

2KB arena 内存池不是简单malloc(2048)。为防止单次分配超限导致整个池失效,我们实现两级防护:

// arena.h typedef struct Arena { uint8_t* base; // 池起始地址 size_t capacity; // 总容量 2048 size_t used; // 已用字节数 size_t peak; // 历史峰值 } Arena; // arena.c void* arena_alloc(Arena* arena, size_t size) { // 1. 对齐到 8 字节边界(适配大多数 CPU) size_t aligned_size = (size + 7) & ~7; // 2. 检查剩余空间:预留 16 字节作哨兵 if (arena->used + aligned_size + 16 > arena->capacity) { // 3. 触发 OOM 错误,但不退出,而是返回 NULL 让上层处理 log_error("Arena overflow: requested %zu, available %zu", aligned_size, arena->capacity - arena->used); return NULL; } void* ptr = arena->base + arena->used; arena->used += aligned_size; // 4. 写入哨兵值,用于 debug 时检测越界写 uint64_t* sentinel = (uint64_t*)((uint8_t*)ptr + aligned_size); *sentinel = 0xDEADBEEFCAFEBABEULL; return ptr; } // 使用示例:分配 BinaryExpr 节点 BinaryExpr* node = arena_alloc(&g_arena, sizeof(BinaryExpr)); if (!node) { // OOM 处理:释放整个 arena,返回语法错误 arena_reset(&g_arena); return error_node(lexer, "Out of memory in parser"); }

哨兵值0xDEADBEEFCAFEBABE是关键。在 debug 模式下,arena_free_all()会遍历所有已分配块,验证哨兵是否被篡改。若发现0xDEADBEEFCAFEBABE变为其他值,说明某处代码向节点后写了越界数据——这在手写 parser 中极常见(如strcpy()目标缓冲区不足)。此设计让我们在开发期捕获了 17 次潜在内存破坏,避免了发布后难以复现的 hardfault。

4. 实操过程:从空函数到跑通print("hello" + 2 * 3)的完整路径

4.1 第一天:搭建骨架与词法验证(4 小时)

目标:让parseProgram()能接收字符串,输出 AST 节点数,不崩溃。

步骤 1:初始化 lexer 和 arena

// main.c int main(int argc, char* argv[]) { if (argc < 2) return 1; // 读取源码文件 FILE* f = fopen(argv[1], "r"); fseek(f, 0, SEEK_END); long len = ftell(f); fseek(f, 0, SEEK_SET); char* source = malloc(len + 1); fread(source, 1, len, f); source[len] = '\0'; fclose(f); // 初始化 lexer 和 arena Lexer lexer = {source, 0, 1}; Arena arena = {malloc(2048), 2048, 0, 0}; g_arena = arena; // 全局 arena 方便各函数访问 // 解析 AstNode* ast = parseProgram(&lexer); // 统计节点数(递归遍历) int count = count_nodes(ast); printf("Parsed %d nodes\n", count); free(source); free(arena.base); return 0; }

步骤 2:实现最简parseProgram()

AstNode* parseProgram(Lexer* lexer) { AstNode* node = malloc_ast_node(sizeof(ProgramNode)); node->type = NODE_PROGRAM; node->line = 1; // 解析所有语句,直到 EOF while (peek(lexer, 0).type != TOKEN_EOF) { AstNode* stmt = parseStatement(lexer); if (!stmt) break; // 错误时跳出 // 将 stmt 加入 program 的 statements 数组(此处简化为单链表) append_statement((ProgramNode*)node, stmt); } return node; }

踩坑实录:首次运行echo 'print("hello");' | ./parser,程序 segfault。用 gdb 跟踪发现peek(0)返回TOKEN_EOF后,parseStatement()仍被调用,尝试解析TOKEN_EOF导致scanToken()返回无效指针。修复:在parseProgram()循环开头加if (peek(lexer, 0).type == TOKEN_EOF) break;。心得:永远假设 lexer 可能返回任何 token,包括 EOF,parser 函数必须防御性检查。

4.2 第二天:打通表达式链与基础执行(6 小时)

目标:让print("hello" + 2 * 3)输出hello6。

步骤 1:实现parsePrimary()和字符串拼接

AstNode* parsePrimary(Lexer* lexer) { Token token = peek(lexer, 0); switch (token.type) { case TOKEN_STRING: { advance(lexer); StringExpr* node = malloc_ast_node(sizeof(StringExpr)); node->base.type = NODE_STRING; node->base.line = token.line; node->value = strdup(token.start); // token.start 指向 "hello" 的 h return (AstNode*)node; } case TOKEN_NUMBER: { advance(lexer); NumberExpr* node = malloc_ast_node(sizeof(NumberExpr)); node->base.type = NODE_NUMBER; node->base.line = token.line; node->value = strtol(token.start, NULL, 10); return (AstNode*)node; } case TOKEN_LEFT_PAREN: { advance(lexer); AstNode* expr = parseExpression(lexer); expect(lexer, TOKEN_RIGHT_PAREN, "expected ')'"); return expr; } default: errorAtCurrent(lexer, "expected expression"); return NULL; } }

步骤 2:实现parseMultiplication()处理*和/

AstNode* parseMultiplication(Lexer* lexer) { AstNode* left = parseUnary(lexer); while (peek(lexer, 0).type == TOKEN_STAR || peek(lexer, 0).type == TOKEN_SLASH) { Token op = advance(lexer); AstNode* right = parseUnary(lexer); BinaryExpr* node = malloc_ast_node(sizeof(BinaryExpr)); node->base.type = NODE_BINARY; node->base.line = op.line; node->operator = op.type; node->left = left; node->right = right; left = (AstNode*)node; // 更新 left 为新节点 } return left; }

关键验证:输入2 * 3,parseMultiplication()应返回BinaryExpr节点,left指向NumberExpr(2),right指向NumberExpr(3),operator为TOKEN_STAR。用printf打印 AST 结构确认。

步骤 3:编写简易 evaluator

typedef struct Value { ValueType type; union { int number; char* string; }; } Value; Value evaluate(AstNode* node) { if (!node) return (Value){.type = VALUE_NULL}; switch (node->type) { case NODE_NUMBER: { NumberExpr* n = (NumberExpr*)node; return (Value){.type = VALUE_NUMBER, .number = n->value}; } case NODE_STRING: { StringExpr* s = (StringExpr*)node; return (Value){.type = VALUE_STRING, .string = strdup(s->value)}; } case NODE_BINARY: { BinaryExpr* b = (BinaryExpr*)node; Value left = evaluate(b->left); Value right = evaluate(b->right); if (b->operator == TOKEN_PLUS && left.type == VALUE_STRING && right.type == VALUE_NUMBER) { // 字符串 + 数字:转换数字为字符串并拼接 char numStr[16]; sprintf(numStr, "%d", right.number); char* result = malloc(strlen(left.string) + strlen(numStr) + 1); strcpy(result, left.string); strcat(result, numStr); return (Value){.type = VALUE_STRING, .string = result}; } // 其他运算略... } // 其他节点类型略... } return (Value){.type = VALUE_NULL}; }

实测结果:evaluate(parseExpression("2 * 3"))返回VALUE_NUMBER, 6;evaluate(parseExpression("\"hello\" + 2 * 3"))返回VALUE_STRING, "hello6"。心得:先做最小可行 evaluator,只支持目标表达式,避免过早陷入通用性陷阱。

4.3 第三天:注入print语句与错误处理闭环(5 小时)

目标:print("hello" + 2 * 3);正常输出hello6,且语法错误时给出行号提示。

步骤 1:实现parsePrintStmt()

AstNode* parsePrintStmt(Lexer* lexer) { Token printToken = advance(lexer); expect(lexer, TOKEN_LEFT_PAREN, "expected '(' after 'print'"); AstNode* expr = parseExpression(lexer); expect(lexer, TOKEN_RIGHT_PAREN, "expected ')' after print argument"); expect(lexer, TOKEN_SEMICOLON, "expected ';' after print statement"); PrintStmt* node = malloc_ast_node(sizeof(PrintStmt)); node->base.type = NODE_PRINT; node->base.line = printToken.line; node->expression = expr; return (AstNode*)node; }

步骤 2:扩展 evaluator 执行print

void execute(AstNode* node) { if (!node) return; switch (node->type) { case NODE_PRINT: { PrintStmt* p = (PrintStmt*)node; Value val = evaluate(p->expression); if (val.type == VALUE_STRING) { printf("%s\n", val.string); free(val.string); } else if (val.type == VALUE_NUMBER) { printf("%d\n", val.number); } break; } // 其他语句略... } }

步骤 3:集成错误报告

// lexer.c 中记录每行起始位置 typedef struct Lexer { const char* source; int pos; int line; int* lineStarts; // 指向数组,lineStarts[i] 是第 i 行首字符偏移 int lineCount; } Lexer; // 构建 lineStarts 数组 void init_line_starts(Lexer* lexer) { lexer->lineCount = 1; // 遍历 source,统计 '\n' 个数,分配 lineStarts 数组 // ... } // errorAtCurrent() 使用 lineStarts 定位行首 void errorAtCurrent(Lexer* lexer, const char* message) { int line = lexer->line; int col = lexer->pos - lexer->lineStarts[line-1] + 1; fprintf(stderr, "[line %d:%d] Error: %s\n", line, col, message); }

最终测试:

$ echo 'print("hello" + 2 * 3);' > test.ls $ ./parser test.ls hello6 $ echo 'print("hello" + );' > test2.ls $ ./parser test2.ls [line 1:18] Error: expected expression

注意事项:lineStarts数组必须在 lexer 初始化时构建,且需处理文件末尾无\n的边界情况。我们采用realloc动态扩容,初始分配 128 行,实测 99% 的规则文件行数 < 50。

5. 常见问题与排查技巧实录:那些文档里不会写的坑

5.1 问题速查表:高频故障与定位方法

现象可能原因排查命令/技巧解决方案
Segmentation fault (core dumped)parsePrimary()中访问token.start为空指针gdb ./parser core,bt查看崩溃栈,p token检查字段在scanToken()中确保token.start永不为 NULL,非法字符时设为""
解析结果节点数为 0parseProgram()循环未进入,peek(0)直接返回TOKEN_EOFprintf("First token: %d\n", peek(&lexer, 0).type);检查源码字符串是否以\0结尾,fread后手动补\0
print("a" + "b")输出ab,但print("a" + 1)崩溃evaluate()中字符串拼接未检查right.type在NODE_BINARY分支开头加printf("op=%d, left.type=%d, right.type=%d\n", b->operator, left.type, right.type);添加if (right.type == VALUE_NUMBER)分支,用sprintf转换
if (x) y=1; else z=2;中z=2未执行elseBranch为 NULL,parseIfStmt()未正确识别elseprintf("After thenBranch, next token is %d\n", peek(&lexer, 0).type);确认expect()后advance()是否被调用,peek()和advance()不能混用
内存占用随规则增多线性增长strdup()分配的字符串未在evaluate()后释放valgrind --leak-check=full ./parser test.ls在execute()中free(val.string)后立即将val.string设为 NULL

5.2 独家避坑技巧:来自三次重写的血泪经验

提示:不要在parseExpression()中直接调用parsePrimary()处理所有情况。必须严格按 BNF 分层,否则运算符优先级必然混乱。曾有一次我把parseAddition()里parsePrimary()替换为parseExpression(),结果1 + 2 * 3算出9(先算1+2再*3),调试 3 小时才发现是调用链断裂。

提示:expect()函数必须同时做两件事:① 检查peek(0)是否匹配;② 若匹配则advance()。绝不能写成if (peek(0).type == X) advance(); else error();—— 因为peek()和advance()之间可能有其他函数修改 lexer 状态。必须将peek()和advance()封装在原子操作内。

提示:为 AST 节点添加debug_print()函数,用缩进打印树结构。例如print("hello" + 2 * 3)应输出:

NODE_PRINT (line 1) NODE_BINARY (+) NODE_STRING "hello" NODE_BINARY (*) NODE_NUMBER 2 NODE_NUMBER 3

此函数在开发期比 gdb 更高效,能瞬间定位解析逻辑错误。

提示:在synchronize()中,同步集stmtSyncSet必须包含TOKEN_SEMICOLON。曾因遗漏此项,导致if (x) y=1; z=2;中z=2被跳过,误判为语法错误。添加后,synchronize()在遇到;时立即停止,保证单条语句的完整性。

提示:arena_alloc()的对齐计算(size + 7) & ~7是必须的。ARM Cortex-M4 要求 64 位数据(如double)必须 8 字节对齐,未对齐访问触发UsageFault。我们在BinaryExpr中加入double dummy;字段后,未对齐分配导致设备反复重启,此技巧救了整个项目。

5.3 性能瓶颈实测与优化清单

在 ARM Cortex-M4@120MHz 上,对 100 行 LogScript 规则集进行压力测试(1000

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

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

立即咨询