☰
C语言语法分析器实战:词法分层、递归下降与Pratt绑定力表
2026/10/28 15:10:21 网站建设 项目流程

简介:面向编译原理课程学习者与编译器设计入门者的 C 语言 LR(0) 语法分析器实践包,聚焦自底向上分析中的文法建模、项集闭包构造、状态机生成与转移表驱动流程,帮助读者把上下文无关文法、归约和接受状态等抽象概念落到可运行代码上,并体会 LR(0) 分析表与规约过程的对应关系。压缩包共 14 个文件,约 224KB,以 cpp 源码、VC6 的 dsp/dsw 工程文件、exe 可执行程序以及 pdb、obj、pch、ilk 等调试中间文件为主,便于直接编译运行和单步观察。已有 841 人学习,说明它在课程实验、课程设计选题与编译技术自学中具有一定参考价值。源码注释较完整,可输入 C 语言语句片段,观察分析器如何读入符号、查表转移并判定语法是否正确,也能借此理解 LR(0) 的局限及向 LR(1)、LALR(1) 扩展的思路,适合课程设计、实验复现与编译底层机制梳理。

1. C 语言语法分析器不是编译原理课作业:先看一个线上解析翻车现场

线上配置表达式里多写了一个?,整条规则加载失败,日志只有一行parse error at line 7。那一刻你才会认真对待 C 语言语法分析器:它不是编译原理课上的作业,而是决定报错能不能定位、DSL 能不能扩、热更新会不会把进程带崩的基础设施。

我见过太多项目把解析写成几百行if/else,上线后每加一个运算符就要改三处,最后没人敢动。更稳的做法是词法器与语法分析器分层,表达式用 Pratt 绑定力表,语句用递归下降,错误恢复用同步集。这套组合在嵌入式配置解析、脚本引擎、协议描述语言里反复被验证过,C 语言写起来也不重。

下面按“Token 契约、递归下降主干、优先级表、避坑、诊断与回归”五段推进,代码按 C11 写,能直接编译成一个小型解析器,解析 C 语言的一个子集:声明、表达式、if/while/return和代码块。

2. 把 Token 契约钉死:词法器与语法分析器的边界怎么切

语法分析器最怕的不是文法复杂,而是词法器偷偷替解析器做决定。比如把>>永远合成一个右移 token,模板嵌套就解不开;把*一律当乘号,声明里的指针就丢了。边界一旦模糊,后面每加一条语法都要回头改词法,血泪经验基本都来自这里。

我一般让词法器只做三件事:跳过空白与注释、做最长匹配、记录位置。Token 里只放解析需要的字段,不把 AST 指针、符号表项塞进去,否则词法器和语法分析器会互相污染,调试时像拆黑匣子。

2.1 Token 结构体里该放哪几个字段

一个够用的 Token 至少要有类型、源串切片、行列号,以及数字字面量的值。字符串和标识符不复制源串,只存start + len,后面要转成符号表键时再复制到 arena。这样词法扫描是零分配的,十万行代码也不会在 malloc 上抖。

/* token.h:词法单元与扫描器接口 */ typedef enum { TK_EOF, TK_IDENT, TK_NUMBER, TK_STRING, TK_LPAREN, TK_RPAREN, TK_LBRACE, TK_RBRACE, TK_SEMI, TK_COMMA, TK_ASSIGN, TK_QUESTION, TK_COLON, TK_PLUS, TK_MINUS, TK_STAR, TK_SLASH, TK_PERCENT, TK_EQ, TK_NE, TK_LT, TK_LE, TK_GT, TK_GE, TK_NOT, TK_TILDE, TK_AMP, TK_PIPE, TK_CARET, TK_SHL, TK_SHR, TK_LAND, TK_LOR, TK_IF, TK_ELSE, TK_WHILE, TK_RETURN, TK_INT, TK_CHAR, TK_VOID, TK_UNKNOWN } TokenKind; typedef struct { TokenKind kind; const char *start; /* 指向源串,不拥有内存 */ int len; /* 源串切片长度 */ int line, col; /* 从 1 开始,报错用 */ long ival; /* 仅 TK_NUMBER 有效 */ } Token; typedef struct { const char *src; size_t len, pos; int line, col; } Scanner; void scan_init(Scanner *s, const char *src); Token scan_next(Scanner *s);

start/len的设计有个直接好处:报错时能把原始片段打印出来,而不是只给一个枚举值。ival目前只支持十进制整数,真要做完整 C 还要处理后缀U/L和进制前缀,但那是词法细节,先把契约固定下来。

line/col不要省。等到解析器报“expected ;”时,没有列号就只能让用户自己数,体验会差一个档次。列号按字节算还是按字符算要提前定,UTF-8 源文件里按字节算会导致中文注释后的列号偏移,我一般按字节算但在报错时只显示行号和行内片段,避免争议。

2.2 用 C 写一个能回退的最长匹配词法器

最长匹配是词法器的核心规则:先看两个字符的运算符,再看单字符。a+++b必须切成a、++、+、b,而不是a、+、++、b,这直接决定后面的表达式解析结果。把这条做对,后面能少一半玄学问题。

/* scanner.c:只保留主干,省略字符串转义和完整关键字表 */ #include "token.h" #include <ctype.h> #include <stdlib.h> #include <string.h> static int is_ident_start(int c) { return isalpha(c) || c == '_'; } static int is_ident_part(int c) { return isalnum(c) || c == '_'; } static Token make_token(Scanner *s, TokenKind k, size_t start, size_t end, int line, int col) { Token t = {0}; t.kind = k; t.start = s->src + start; t.len = (int)(end - start); t.line = line; t.col = col; return t; } static TokenKind keyword_lookup(const char *p, int len) { if (len == 2 && !strncmp(p, "if", 2)) return TK_IF; if (len == 4 && !strncmp(p, "else", 4)) return TK_ELSE; if (len == 5 && !strncmp(p, "while", 5)) return TK_WHILE; if (len == 6 && !strncmp(p, "return", 6)) return TK_RETURN; if (len == 3 && !strncmp(p, "int", 3)) return TK_INT; if (len == 4 && !strncmp(p, "char", 4)) return TK_CHAR; if (len == 4 && !strncmp(p, "void", 4)) return TK_VOID; return TK_IDENT; } Token scan_next(Scanner *s) { /* 跳过空白、换行和 // 行注释 */ for (;;) { if (s->pos >= s->len) return make_token(s, TK_EOF, s->pos, s->pos, s->line, s->col); char c = s->src[s->pos]; if (c == ' ' || c == '\t' || c == '\r') { s->pos++; s->col++; continue; } if (c == '\n') { s->pos++; s->line++; s->col = 1; continue; } if (c == '/' && s->pos + 1 < s->len && s->src[s->pos + 1] == '/') { while (s->pos < s->len && s->src[s->pos] != '\n') { s->pos++; s->col++; } continue; } break; } size_t start = s->pos; int line = s->line, col = s->col; char c = s->src[s->pos]; if (is_ident_start(c)) { s->pos++; while (s->pos < s->len && is_ident_part(s->src[s->pos])) s->pos++; int len = (int)(s->pos - start); TokenKind k = keyword_lookup(s->src + start, len); return make_token(s, k, start, s->pos, line, col); } if (isdigit(c)) { s->pos++; while (s->pos < s->len && isdigit(s->src[s->pos])) s->pos++; Token t = make_token(s, TK_NUMBER, start, s->pos, line, col); t.ival = strtol(s->src + start, NULL, 10); return t; } /* 双字符运算符优先,保证最长匹配 */ if (s->pos + 1 < s->len) { char d = s->src[s->pos + 1]; TokenKind k = TK_UNKNOWN; if (c == '=' && d == '=') k = TK_EQ; else if (c == '!' && d == '=') k = TK_NE; else if (c == '<' && d == '=') k = TK_LE; else if (c == '>' && d == '=') k = TK_GE; else if (c == '<' && d == '<') k = TK_SHL; else if (c == '>' && d == '>') k = TK_SHR; else if (c == '&' && d == '&') k = TK_LAND; else if (c == '|' && d == '|') k = TK_LOR; if (k != TK_UNKNOWN) { s->pos += 2; s->col += 2; return make_token(s, k, start, s->pos, line, col); } } TokenKind k; switch (c) { case '(': k = TK_LPAREN; break; case ')': k = TK_RPAREN; break; case '{': k = TK_LBRACE; break; case '}': k = TK_RBRACE; break; case ';': k = TK_SEMI; break; case ',': k = TK_COMMA; break; case '=': k = TK_ASSIGN; break; case '?': k = TK_QUESTION; break; case ':': k = TK_COLON; break; case '+': k = TK_PLUS; break; case '-': k = TK_MINUS; break; case '*': k = TK_STAR; break; case '/': k = TK_SLASH; break; case '%': k = TK_PERCENT; break; case '<': k = TK_LT; break; case '>': k = TK_GT; break; case '!': k = TK_NOT; break; case '~': k = TK_TILDE; break; case '&': k = TK_AMP; break; case '|': k = TK_PIPE; break; case '^': k = TK_CARET; break; default: k = TK_UNKNOWN; break; } s->pos++; s->col++; return make_token(s, k, start, s->pos, line, col); }

扫描器没有回退指针,是因为最长匹配只需要看固定两个字符,不需要真正的回溯。遇到<<=这种三字符运算符时,这个写法会先切出<<再切=,对表达式解析够用;如果要支持复合赋值,把双字符判断扩成三字符表即可。s->col每次加 1 按字节算,遇到 UTF-8 会偏,但报错行内片段仍然可读。

解析器侧只需要一个cur和advance,词法器每次返回新 Token。expect失败时不要推进,这样上层可以做同步恢复。

/* parser.h:解析器与 Token 流的最小接口 */ typedef struct { Scanner lex; Token cur; Arena *arena; int had_error; } Parser; static void advance(Parser *p) { p->cur = scan_next(&p->lex); } static int check(Parser *p, TokenKind k) { return p->cur.kind == k; } static int match(Parser *p, TokenKind k) { if (!check(p, k)) return 0; advance(p); return 1; } static Token expect(Parser *p, TokenKind k, const char *what) { Token t = p->cur; if (!check(p, k)) { fprintf(stderr, "%d:%d: expected %s, got '%.*s'\n", t.line, t.col, what, t.len, t.start); p->had_error = 1; return t; /* 不推进,交给同步逻辑 */ } advance(p); return t; }

expect返回 Token 而不是布尔值,是为了让调用方在成功时能拿到位置信息构造 AST 节点。失败时不推进是错误恢复的关键:如果一失败就吞 token,后面可能连“缺分号”还是“缺右括号”都分不清。

3. 递归下降主干:表达式、语句、声明分别落到哪个函数

递归下降写起来像把文法抄成函数,难点不在函数多,而在文法要先改。C 的表达式文法左递归,直接翻译会栈溢出;声明和表达式共享(、*、标识符这些前缀,需要符号表区分“类型名”还是“变量名”。我一般先把文法改成 EBNF,再写函数,函数名和文法非终结符一一对应,调试时看调用栈就知道卡在哪条规则。

3.1 先改文法再写代码:消除左递归与提取公因子

原始表达式规则expr -> expr + term是左递归,递归下降会无限调用自己。改成expr -> term expr_tail,expr_tail用循环处理+/-,栈深度只跟括号嵌套有关。加减乘除这种二元运算交给 Pratt 表更省事,但语句和声明仍然适合递归下降。

原始规则改写后目的
expr -> expr + term | termexpr -> term { (+ | -) term }消除左递归,循环处理同级运算
stmt -> if ( expr ) stmt else stmt | ...保留递归,else就近匹配悬空 else 用“最近未匹配 if”规则
decl -> type ident ;decl -> type declarator { , declarator } ;支持多变量声明
primary -> ident | number | ( expr )增加type_name分支sizeof(T)与sizeof expr要区分

声明里的type_name分支需要符号表:遇到T * x;时,如果T是 typedef 名,就按声明解析;否则按乘法表达式解析。这个歧义在 C 里没法只靠文法解决,必须在解析过程中维护一个 typedef 名字集合。常见做法是在进入作用域时压栈,遇到typedef声明就把名字登记进去,离开作用域弹栈。

3.2 AST 节点与 arena 内存约定

AST 节点不要用malloc一个一个分配,后面释放会变成灾难。我一般用一个 arena:整块申请,整块释放,解析失败直接丢掉整个 arena,不需要为每个节点写析构函数,相当于给错误路径留了后悔药。

/* ast.h:节点类型与 arena 分配 */ typedef enum { ND_NUM, ND_IDENT, ND_BIN, ND_UNARY, ND_ASSIGN, ND_COND, ND_CALL, ND_IF, ND_WHILE, ND_RETURN, ND_BLOCK, ND_DECL, ND_ERROR } NodeKind; typedef struct Node Node; struct Node { NodeKind kind; int line, col; union { struct { long val; } num; struct { const char *name; int len; } ident; struct { int op; Node *lhs, *rhs; } bin; struct { int op; Node *operand; } unary; struct { Node *cond, *then, *els; } cond; struct { Node *callee; Node **args; int nargs; } call; struct { Node *cond, *then, *els; } ifs; struct { Node *cond, *body; } whiles; struct { Node *expr; } ret; struct { Node **items; int count, cap; } block; struct { const char *name; int len; Node *init; } decl; } u; }; typedef struct ArenaBlock ArenaBlock; struct ArenaBlock { ArenaBlock *next; size_t used, cap; char data[]; }; typedef struct { ArenaBlock *head; } Arena; void *arena_alloc(Arena *a, size_t n); void arena_free(Arena *a); Node *new_node(Arena *a, NodeKind k, int line, int col);

arena_alloc按 8 字节对齐,块大小默认 4KB,超过 4KB 的申请单独给一块。new_node负责清零和填位置,避免每个构造点都写一遍memset。ND_ERROR是错误恢复时占位用的,后面遍历 AST 时遇到它直接跳过,不会让整个分析流程断掉。

/* ast.c:arena 分配与节点构造 */ #include "ast.h" #include <stdlib.h> #include <string.h> #include <stdio.h> void *arena_alloc(Arena *a, size_t n) { n = (n + 7) & ~(size_t)7; if (!a->head || a->head->used + n > a->head->cap) { size_t cap = n > 4096 ? n : 4096; ArenaBlock *b = malloc(sizeof(ArenaBlock) + cap); if (!b) { perror("arena_alloc"); exit(1); } b->next = a->head; b->used = 0; b->cap = cap; a->head = b; } void *p = a->head->data + a->head->used; a->head->used += n; return p; } void arena_free(Arena *a) { ArenaBlock *b = a->head; while (b) { ArenaBlock *next = b->next; free(b); b = next; } a->head = NULL; } Node *new_node(Arena *a, NodeKind k, int line, int col) { Node *n = arena_alloc(a, sizeof(Node)); memset(n, 0, sizeof(*n)); n->kind = k; n->line = line; n->col = col; return n; }

arena_alloc里sizeof(ArenaBlock)不包含柔性数组data,所以实际块大小是头部加cap,没有越界。arena_free只释放块链,不逐个释放节点,调用方不能在 arena 释放后继续持有 AST 指针。这套约定要在头文件里写清楚,否则后面有人往节点里塞malloc的字符串,释放时就会漏。

3.3 语句与声明的下降函数骨架

顶层用parse_translation_unit循环,遇到类型关键字或已知 typedef 名走声明,否则走语句。语句里{进入复合语句,if/while/return各自解析,最后兜底表达式语句。这个骨架不追求完整 C 语法,但结构清晰,加for、switch时照着加分支就行。

/* parser.c:语句与声明的递归下降骨架 */ static Node *parse_expr(Parser *p, int min_bp); static Node *parse_stmt(Parser *p); static Node *parse_decl(Parser *p); static int is_type_keyword(TokenKind k) { return k == TK_INT || k == TK_CHAR || k == TK_VOID; } static void block_push(Arena *a, Node *block, Node *item) { if (block->u.block.count == block->u.block.cap) { int cap = block->u.block.cap ? block->u.block.cap * 2 : 8; Node **items = arena_alloc(a, sizeof(Node *) * (size_t)cap); if (block->u.block.items) memcpy(items, block->u.block.items, sizeof(Node *) * (size_t)block->u.block.count); block->u.block.items = items; block->u.block.cap = cap; } block->u.block.items[block->u.block.count++] = item; } static Node *parse_compound(Parser *p) { Node *n = new_node(p->arena, ND_BLOCK, p->cur.line, p->cur.col); while (!check(p, TK_RBRACE) && !check(p, TK_EOF)) { Node *item; if (is_type_keyword(p->cur.kind)) item = parse_decl(p); else item = parse_stmt(p); if (item) block_push(p->arena, n, item); } expect(p, TK_RBRACE, "}"); return n; } static Node *parse_stmt(Parser *p) { if (match(p, TK_LBRACE)) return parse_compound(p); if (match(p, TK_IF)) { Node *n = new_node(p->arena, ND_IF, p->cur.line, p->cur.col); expect(p, TK_LPAREN, "("); n->u.ifs.cond = parse_expr(p, 0); expect(p, TK_RPAREN, ")"); n->u.ifs.then = parse_stmt(p); if (match(p, TK_ELSE)) n->u.ifs.els = parse_stmt(p); return n; } if (match(p, TK_WHILE)) { Node *n = new_node(p->arena, ND_WHILE, p->cur.line, p->cur.col); expect(p, TK_LPAREN, "("); n->u.whiles.cond = parse_expr(p, 0); expect(p, TK_RPAREN, ")"); n->u.whiles.body = parse_stmt(p); return n; } if (match(p, TK_RETURN)) { Node *n = new_node(p->arena, ND_RETURN, p->cur.line, p->cur.col); if (!check(p, TK_SEMI)) n->u.ret.expr = parse_expr(p, 0); expect(p, TK_SEMI, ";"); return n; } Node *e = parse_expr(p, 0); expect(p, TK_SEMI, ";"); return e; }

parse_compound用while而不是递归处理语句列表,避免深层代码块把调用栈打穿。block_push扩容时复制旧数组,旧数组留在 arena 里不释放,解析阶段内存只增不减,换来的是一条线性分配路径,定位泄漏时只看 arena 块数就行。parse_stmt在if分支里先读条件再读语句,else按就近匹配处理,和 C 的规则一致。

parse_decl需要处理int a = 1, b;这种多声明,还要在遇到typedef时把名字登记到符号表。声明和语句共享标识符前缀,所以parse_translation_unit必须先查类型关键字或 typedef 集合,不能无脑走语句。

4. 表达式优先级别写成一串 if:Pratt 解析器的绑定力表参数

表达式是语法分析器里最容易写乱的部分。用递归下降按优先级层层套函数,parse_add调parse_mul调parse_unary,加一个运算符就要动一串函数。Pratt 解析器把优先级收进一张绑定力表,前缀、中缀、后缀各走各的分支,C 的十五级优先级也能用一张表压住。

4.1 绑定力表怎么填:C 运算符优先级与结合性

绑定力数值越大,结合越紧。左结合运算符在右侧解析时用bp + 1,右结合用bp,这样a - b - c会解析成(a - b) - c,a = b = c会解析成a = (b = c)。逗号最低,赋值次低,条件表达式在赋值之上,逻辑或与非依次往上。

运算符绑定力结合性备注
,1左最低,只在括号或参数里显式处理
=2右赋值是右结合
?:3右中间表达式按逗号级别解析
||4左短路逻辑或
&&5左短路逻辑与
|6左按位或
^7左按位异或
&8左按位与
== !=9左相等
< <= > >=10左关系
<< >>11左移位
+ -12左加减
* / %13左乘除取模
一元+ - ! ~ * &14右前缀
()[]++--15左后缀,最高

这张表要和词法器的 TokenKind 对上。TK_STAR在中缀位置是乘法,在前缀位置是指针解引用,Pratt 解析器靠“当前位置”区分,不靠词法切两种 token。TK_AMP同理,中缀是按位与,前缀是取地址。

/* parser.c:绑定力表与中缀优先级查询 */ typedef enum { BP_NONE = 0, BP_COMMA = 1, BP_ASSIGN = 2, BP_COND = 3, BP_LOR = 4, BP_LAND = 5, BP_BOR = 6, BP_BXOR = 7, BP_BAND = 8, BP_EQ = 9, BP_REL = 10, BP_SHIFT = 11, BP_ADD = 12, BP_MUL = 13, BP_UNARY = 14, BP_POSTFIX = 15 } BindingPower; static int infix_bp(TokenKind k) { switch (k) { case TK_COMMA: return BP_COMMA; case TK_ASSIGN: return BP_ASSIGN; case TK_QUESTION: return BP_COND; case TK_LOR: return BP_LOR; case TK_LAND: return BP_LAND; case TK_PIPE: return BP_BOR; case TK_CARET: return BP_BXOR; case TK_AMP: return BP_BAND; case TK_EQ: case TK_NE: return BP_EQ; case TK_LT: case TK_LE: case TK_GT: case TK_GE: return BP_REL; case TK_SHL: case TK_SHR: return BP_SHIFT; case TK_PLUS: case TK_MINUS: return BP_ADD; case TK_STAR: case TK_SLASH: case TK_PERCENT: return BP_MUL; default: return BP_NONE; } }

infix_bp只回答“这个 token 能不能当二元运算符,优先级多少”,不负责构造节点。这样加运算符只改表和 switch 两处,不会碰解析循环。BP_NONE用 0,任何有效绑定力都大于 0,循环条件可以写成bp == BP_NONE || bp < min_bp。

4.2 一元、后缀、三元与赋值右结合的处理顺序

Pratt 主循环先解析前缀,再不断看当前 token 是不是中缀。遇到?要特殊处理,因为它中间那段表达式可以包含逗号;遇到赋值要把右结合参数设成bp而不是bp + 1。后缀调用和下标可以放在前缀之后、中缀循环之前,也可以并进中缀表,我一般并进循环,统一用BP_POSTFIX。

/* parser.c:Pratt 主循环与前缀解析 */ static Node *parse_expr(Parser *p, int min_bp) { Node *lhs = parse_prefix(p); for (;;) { TokenKind k = p->cur.kind; int bp = infix_bp(k); if (bp == BP_NONE || bp < min_bp) break; if (k == TK_QUESTION) { advance(p); /* 吃掉 ? */ Node *then = parse_expr(p, BP_COMMA); expect(p, TK_COLON, ":"); Node *els = parse_expr(p, BP_COND); /* 右结合 */ Node *n = new_node(p->arena, ND_COND, p->cur.line, p->cur.col); n->u.cond.cond = lhs; n->u.cond.then = then; n->u.cond.els = els; lhs = n; continue; } advance(p); /* 吃掉运算符 */ int rbp = (k == TK_ASSIGN) ? bp : bp + 1; Node *rhs = parse_expr(p, rbp); Node *n = new_node(p->arena, k == TK_ASSIGN ? ND_ASSIGN : ND_BIN, p->cur.line, p->cur.col); n->u.bin.op = k; n->u.bin.lhs = lhs; n->u.bin.rhs = rhs; lhs = n; } return lhs; } static Node *parse_prefix(Parser *p) { Token t = p->cur; switch (t.kind) { case TK_NUMBER: advance(p); return new_num(p->arena, t.ival, t.line, t.col); case TK_IDENT: advance(p); return new_ident(p->arena, t.start, t.len, t.line, t.col); case TK_LPAREN: { advance(p); Node *e = parse_expr(p, BP_COMMA); expect(p, TK_RPAREN, ")"); return e; } case TK_PLUS: case TK_MINUS: case TK_NOT: case TK_TILDE: case TK_STAR: case TK_AMP: { advance(p); Node *operand = parse_expr(p, BP_UNARY); Node *n = new_node(p->arena, ND_UNARY, t.line, t.col); n->u.unary.op = t.kind; n->u.unary.operand = operand; return n; } default: fprintf(stderr, "%d:%d: unexpected token in expression\n", t.line, t.col); p->had_error = 1; advance(p); /* 跳过坏 token,避免死循环 */ return new_node(p->arena, ND_ERROR, t.line, t.col); } }

赋值用bp做右侧最小值,a = b = c会先解析a,遇到=后右侧以 2 为下限继续,b遇到=时2 >= 2成立,继续右结合,得到a = (b = c)。条件表达式右侧也用BP_COND,保证a ? b : c ? d : e解析成a ? b : (c ? d : e)。前缀解析里*和&按一元处理,避免和乘法、按位与冲突。

sizeof是 C 里另一个坑:sizeof(int)和sizeof x都要支持,前者括号里是类型名,后者是表达式。解析到sizeof后看下一个 token 和符号表,如果括号内是类型关键字或 typedef 名,按类型解析;否则按表达式解析。这个判断要放在前缀分支里,不能等括号解析完再猜。

5. 避坑与排查:手写 C 语言语法分析器最容易翻车的五个地方

解析器出问题,现象往往离根因很远:报错行号对不上、AST 多一层、死循环不退出。下面五条是我在几个手写解析器项目里反复遇到的,按“现象 → 原因 → 解决”记,排查时直接对号入座。

5.1 词法与文法边界上的三个翻车点

现象一:a+++b被解析成a + (++b),结果和预期相反。原因是词法器没有做最长匹配,先切了+再切++,或者解析器把后缀++当成了前缀。解决是把双字符、三字符运算符放在单字符之前匹配,并在 Pratt 循环里把++/--归到后缀,只有出现在前缀位置时才按前缀处理。测试用例直接写a+++b、a---b、a+++++b,跑一遍 AST 打印就能看出来。

现象二:T * x;有时当声明,有时当乘法,报错位置飘忽。原因是T是 typedef 名还是普通标识符没有在解析时确定。C 的语法歧义不能靠回溯解决,必须在符号表里维护 typedef 名字集合,进入作用域压栈、离开弹栈。声明解析器看到T在集合里就按类型名走,否则按表达式走。这个集合要和预处理、头文件包含顺序一致,否则同一段代码在不同包含顺序下解析结果不同。

现象三:else绑到了错误的if上,语义悄悄变了。原因是语句解析器在if分支里先解析then,再无条件找else,没有按“就近未匹配”规则处理。递归下降里这不是 bug,是文法本身就这样;要避免的是在then里把外层else吃掉。解决办法是parse_stmt解析then时只调一次语句函数,else只跟最近一层if结合。加一条测试:if (a) if (b) x(); else y();,打印 AST 看else挂在哪层。

5.2 内存与错误恢复上的两个翻车点

现象四:解析失败后进程内存一直涨,反复加载配置就 OOM。原因是每个 AST 节点单独malloc,失败路径只释放了部分节点,后面干脆不释放。解决是全部节点走 arena,解析失败时直接arena_free整块丢掉,成功时把 arena 所有权交给上层。arena 内部块链用next串起来,释放时从头遍历,节点里的字符串也复制进 arena,不要混用strdup。

现象五:一个坏 token 导致解析器死循环,日志刷满磁盘。原因是expect失败后没有推进,上层又在一个while里反复调用同一个解析函数,当前 token 永远不变。解决办法是错误恢复时至少做一次advance,或者跳到同步集(;、}、EOF)。parse_prefix的default分支必须推进,parse_compound的循环条件要包含TK_EOF,否则文件截断时会卡住。排查时在advance里加计数器,超过源文件长度两倍就abort,能快速定位没有消费 token 的路径。

6. 让解析器可诊断:错误恢复与用 bison 做差分回归

解析器能不能上线,看的不是正常代码跑得多顺,而是坏代码报错有多准。panic mode 恢复的核心是同步集:遇到错误先记一条诊断,然后跳到下一个;或},从那里继续解析,避免一个错误引发几十条连锁报错。同步集不要拍脑袋,按语句边界和块边界来定,;管表达式语句,}管复合语句,EOF 兜底。

6.1 panic mode 同步集与 bison 差分回归

/* parser.c:错误恢复时跳到同步集 */ static void synchronize(Parser *p) { while (!check(p, TK_EOF)) { if (check(p, TK_SEMI)) { advance(p); return; } if (check(p, TK_RBRACE)) return; advance(p); } }

同步集只包含;、}和 EOF 三类 token,够用且不容易误吞。;消费掉,因为当前语句已经坏了;}不消费,留给上层复合语句收尾。每恢复一次只报一条错误,had_error置位,最后统一返回退出码。

手写解析器的优先级表容易和标准 C 有偏差,我一般用同一组测试用例跑两套实现:手写 Pratt 和 bison 生成的 LALR 解析器。bison 文法只写表达式子集,动作里输出 AST 的括号形式,然后和手写解析器的输出逐行 diff。

%token IDENT NUMBER %left '+' '-' %left '*' '/' %right '=' %% expr: expr '+' expr | expr '-' expr | expr '*' expr | expr '/' expr | IDENT '=' expr | '(' expr ')' | IDENT | NUMBER ; %%

这份文法故意保留歧义,靠%left/%right解决冲突,正好用来校验手写绑定力表。跑a = b + c * d、a - b - c、a = b = c三组,输出分别是(= a (+ b (* c d)))、(- (- a b) c)、(= a (= b c)),和手写解析器对不上就说明绑定力或结合性填错了。

cc -std=c11 -Wall -Wextra -g scanner.c ast.c parser.c main.c -o cparse ./cparse tests/expr_01.c > hand.ast bison -d expr.y && cc -std=c11 expr.tab.c lex.c -o bparse ./bparse tests/expr_01.c > bison.ast diff -u hand.ast bison.ast

编译选项里-Wall -Wextra要开,未使用参数和符号比较能提前暴露不少问题。差分测试只覆盖表达式,语句和声明还是靠手写用例,但优先级这类最容易错的部分已经能被机器盯住了。

我自己的习惯是每加一个运算符,先改绑定力表,再补三条测试:左结合、右结合、和相邻优先级混用。踩过的最贵一次坑是<<和<的绑定力填反,线上表达式结果全错但没报错,最后靠差分测试才定位。希望帮到你。

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

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

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

立即咨询