☰
C语言手写语法分析器:递归下降、AST与LL(1)排查
2026/10/28 15:10:50 网站建设 项目流程

简介:这份面向编译原理学习者与课程实验需求的C语言LR(0)语法分析器工程,围绕自底向上语法分析的核心环节展开,涉及上下文无关文法表示、项集构造、状态机建立、状态转移表生成以及输入串的接受与报错判定,适合用于编译技术课程实验、课程设计或自学实践。压缩包共14个文件、约224KB,包含cpp源代码、dsp与dsw工程配置、exe可执行程序,以及obj、pch、pdb等编译中间文件和调试信息,较完整地保留了早期VC开发环境下的项目组织方式,便于对照源码、工程配置与运行结果理解分析器结构。目前已有841人学习,说明其在相关学习场景中具有一定参考价值。读者可从注释较完整的源码中梳理LR(0)分析流程,并借助可执行程序输入语句片段,观察状态转移、归约与语法判定结果,进而体会LR(0)在处理左递归和复杂语法结构时的局限,为继续学习LR(1)、LALR(1)等增强方法打下基础。

1. 词法过了语法卡住:C语言语法分析器到底在解决什么

词法分析把a = 1 + 2 * 3;切成ID ASSIGN INT PLUS INT STAR INT SEMI之后,真正决定*先于+计算、if后面必须跟括号、赋值语句必须以分号收尾的,是 C语言写的语法分析器。它不负责识别字符,而是拿着 token 流对照文法,判断结构是否合法,并顺手构造 AST 或直接求值。写解释器、配置 DSL、编译器前端的人都会碰到它;用 C 实现的好处是游标、内存和递归调用全在手里,出问题能定位到具体函数,而不是面对一个黑匣子。下面按“先定文法、再写递归下降、最后排查”的顺序,把一套能跑的最小方案拆开。

2. 文法与接口:把 EBNF 变成 C 语言能执行的约束

2.1 用 EBNF 把目标语言切成 token 与产生式

手写解析器最怕一上来就写函数。我一般先把目标语言压到一个足够小的子集:整数、变量、赋值、if/else、while、块语句、四则运算和括号。文法写成 EBNF,*表示零次或多次,?表示可选:

program -> stmt* EOF stmt -> ID '=' expr ';' | 'if' '(' expr ')' stmt ('else' stmt)? | 'while' '(' expr ')' stmt | '{' stmt* '}' expr -> term (('+' | '-') term)* term -> factor (('*' | '/') factor)* factor -> INT | ID | '(' expr ')'

这份文法已经消除了直接左递归,适合递归下降。每个非终结符对应一个 C 函数,函数体就是产生式右部的展开。真正的约束在 token 层:关键字不能和标识符混在一起,=和==要分开,换行要记录行号。先把 token 类型定死,后面解析函数才有稳定的比较对象。

非终结符First 集Follow 集
programID, if, while, {, EOFEOF
stmtID, if, while, {ID, if, while, {, }, EOF, else
exprINT, ID, (), ;, {, EOF, else
termINT, ID, (+, -, ), ;, {, EOF, else
factorINT, ID, (*, /, +, -, ), ;, {, EOF, else

这张表不是摆设。stmt的候选分支首符集互不相交,if分支和ID分支不会打架;else出现在 Follow 集里,说明if语句结束后可以安全地看下一个 token 是不是else。如果两个候选分支的 First 集有交集,递归下降就得靠回溯或提取左公因子,复杂度立刻上去。

token 结构体不需要花哨,但字段要够用:

typedef enum { TOK_EOF, TOK_ID, TOK_INT, TOK_ASSIGN, TOK_PLUS, TOK_MINUS, TOK_STAR, TOK_SLASH, TOK_LPAREN, TOK_RPAREN, TOK_LBRACE, TOK_RBRACE, TOK_SEMI, TOK_IF, TOK_ELSE, TOK_WHILE, TOK_ERROR } TokenKind; typedef struct { TokenKind kind; const char *lexeme; /* 指向词法器内部缓冲,生命周期要小心 */ int line; int col; long value; /* 仅 TOK_INT 有效 */ } Token; typedef struct Lexer Lexer; Token lexer_next(Lexer *lx);

line和col是后面报错定位的命根子,词法器在跳过空白和注释时必须同步更新。value只对整数字面量有意义,别在别的 token 上读它。lexeme如果指向词法器的临时缓冲区,解析器就不能长期保存这个指针;要么在 token 构造时拷进 arena,要么保证词法器缓冲在整次解析期间不被覆盖。这个坑在后面第 5 章还会展开。

2.2 消除左递归与提取左公因子:手写解析器的前置手术

递归下降不能直接吃左递归。产生式expr -> expr '+' term | term写成 C 函数就是:

AstNode *parse_expr(Parser *p) { AstNode *left = parse_expr(p); /* 一进来就递归,token 一个没消费 */ /* ... */ }

调用会原地打转,直到栈溢出。标准变换是把左递归改成右递归加循环:

A -> A α | β 等价于 A -> β A' A' -> α A' | ε

落到表达式就是:

expr -> term expr_rest expr_rest -> ('+' | '-') term expr_rest | ε

手写时更常见的做法是不生成expr_rest函数,直接在parse_expr里用while循环消费运算符。这样既消除了左递归,又自然得到左结合。提取左公因子也是同理:stmt -> ID '=' expr ';' | ID '(' args ')' ';'这种前缀相同的分支,先读一个ID,再根据下一个 token 决定走赋值还是调用。递归下降的预测能力来自“看 enough token 再决定”,不是来自回溯。

做这一步时,最好把改后的文法重新算一遍 First/Follow。比如stmt的if分支里,else是可选的,else不会出现在stmt的 First 集里,但会出现在 Follow 集里。手写代码里对应的是:

AstNode *then_branch = parse_stmt(p); AstNode *else_branch = NULL; if (match(p, TOK_ELSE)) { else_branch = parse_stmt(p); }

match只在前瞻 token 是TOK_ELSE时消费,否则原样返回。这个“可选”判断依赖的就是 Follow 集:当前 token 不在else上,说明if语句到此结束。别小看这一点,很多“解析完 if 后莫名报错”都是因为把可选分支写成了强制expect。

2.3 词法与语法接口:Token 结构体和预读缓冲

解析器不需要自己扫描字符,但需要稳定的“当前 token”和“往前看一个”的能力。我一般给 Parser 结构体只放词法器指针和当前 token,不搞多层 lookahead,因为这份文法 LL(1) 足够:

typedef struct { Lexer *lx; Token cur; int had_error; } Parser; static void advance(Parser *p) { p->cur = lexer_next(p->lx); } static int check(Parser *p, TokenKind k) { return p->cur.kind == k; } static int match(Parser *p, TokenKind k) { if (check(p, k)) { advance(p); return 1; } return 0; }

advance是唯一移动游标的地方,所有消费都必须经过它。check只看不消费,match条件消费。把这三个函数收拢在一个文件里,后面排查“为什么报错位置总在下一个 token”时,只需要盯住advance的调用点。参数上,Parser里的had_error用于错误恢复后继续解析,不要一报错就exit,否则测试用例只能看到第一个错误。

词法器返回TOK_EOF后,继续调用lexer_next应该稳定返回TOK_EOF,而不是越界读缓冲区。这个约定要写进词法器的实现里:文件尾指针不动,每次返回同一个 EOF token。解析器的很多循环用while (!check(p, TOK_EOF))收尾,EOF 不稳定就会变成死循环或随机崩溃。

3. 递归下降落地:一个非终结符一个 C 函数

3.1 解析器骨架与游标管理:match/expect/peek 三件套

骨架先把错误处理定下来。expect在期望 token 没出现时记录位置并返回一个占位 token,解析继续往下走,由调用方决定是否同步到分号或右花括号:

static Token expect(Parser *p, TokenKind k, const char *what) { if (check(p, k)) { Token t = p->cur; advance(p); return t; } fprintf(stderr, "line %d:%d: expected %s, got token %d\n", p->cur.line, p->cur.col, what, p->cur.kind); p->had_error = 1; Token bad = p->cur; bad.kind = TOK_ERROR; return bad; }

expect不消费错误 token 是故意的。当前 token 不是期望值时,消费它可能把后面的同步点一起吃掉;保留在原地,让上层parse_stmt或parse_block决定跳到;还是}。what参数是给错误信息用的自然语言描述,比如"';'",不要传"semicolon"这种和词法 token 对不上的词。

parse_program的骨架很短:

AstNode *parse_program(Parser *p) { advance(p); /* 装入第一个 token */ AstNode *block = ast_block(); while (!check(p, TOK_EOF) && !p->had_error) { AstNode *s = parse_stmt(p); if (s) ast_block_append(block, s); else break; } return block; }

这里advance在进入循环前调用一次,把cur从零值变成第一个真实 token。如果词法器在构造时已经预读,这一步就要改成对应的初始化函数。关键是全程序只有一处“第一次装 token”,避免某个分支里cur还是空的就开始check。

3.2 语句解析:block、if、while、assign 的落点

语句解析是递归下降里最容易写乱的部分,因为分支多、嵌套深。我一般按 First 集顺序写if链,每个分支入口先match关键字,匹配成功再expect后续符号:

static AstNode *parse_stmt(Parser *p) { if (match(p, TOK_LBRACE)) { AstNode *b = ast_block(); while (!check(p, TOK_RBRACE) && !check(p, TOK_EOF)) { AstNode *s = parse_stmt(p); if (!s) break; ast_block_append(b, s); } expect(p, TOK_RBRACE, "'}'"); return b; } if (match(p, TOK_IF)) { expect(p, TOK_LPAREN, "'('"); AstNode *cond = parse_expr(p); expect(p, TOK_RPAREN, "')'"); AstNode *then_s = parse_stmt(p); AstNode *else_s = NULL; if (match(p, TOK_ELSE)) { else_s = parse_stmt(p); } return ast_if(cond, then_s, else_s); } if (match(p, TOK_WHILE)) { expect(p, TOK_LPAREN, "'('"); AstNode *cond = parse_expr(p); expect(p, TOK_RPAREN, "')'"); AstNode *body = parse_stmt(p); return ast_while(cond, body); } if (check(p, TOK_ID)) { Token name = expect(p, TOK_ID, "identifier"); expect(p, TOK_ASSIGN, "'='"); AstNode *value = parse_expr(p); expect(p, TOK_SEMI, "';'"); return ast_assign(name.lexeme, value); } fprintf(stderr, "line %d:%d: unexpected token in statement\n", p->cur.line, p->cur.col); p->had_error = 1; return NULL; }

match(TOK_LBRACE)已经把左花括号消费掉,所以块内循环直接从下一条语句开始。while循环的退出条件是TOK_RBRACE或 EOF,前者由expect消费,后者用于防止文件提前结束导致死循环。

if的else采用“就近匹配”:then_s递归解析完一个语句后,如果当前 token 是else,就归当前if。这和 C 语言的 dangling else 规则一致,不需要额外维护栈。注意parse_stmt在遇到ID时先读名字再expect '=',如果实际是函数调用或多变量声明,这里会报错;这份文法只支持赋值,报错是预期行为,不要为了“兼容”偷偷加分支把文法搞乱。

块语句返回AST_BLOCK,内部用动态数组挂子节点。空块{}也合法,循环一次都不进,直接expect '}'。如果块里某条语句返回NULL,说明已经报错,直接 break,避免在错误状态上继续构造半截 AST。

3.3 表达式解析:优先级爬升还是分层递归

表达式用分层递归最直观:parse_expr处理加减,parse_term处理乘除,parse_factor处理原子和括号。每层用while循环消费同层运算符,天然左结合:

static AstNode *parse_expr(Parser *p) { AstNode *node = parse_term(p); while (check(p, TOK_PLUS) || check(p, TOK_MINUS)) { Token op = p->cur; advance(p); AstNode *rhs = parse_term(p); node = ast_binary(op.kind, node, rhs); } return node; } static AstNode *parse_term(Parser *p) { AstNode *node = parse_factor(p); while (check(p, TOK_STAR) || check(p, TOK_SLASH)) { Token op = p->cur; advance(p); AstNode *rhs = parse_factor(p); node = ast_binary(op.kind, node, rhs); } return node; } static AstNode *parse_factor(Parser *p) { if (check(p, TOK_INT)) { Token t = p->cur; advance(p); return ast_int(t.value, t.line); } if (check(p, TOK_ID)) { Token t = p->cur; advance(p); return ast_var(t.lexeme, t.line); } if (match(p, TOK_LPAREN)) { AstNode *inner = parse_expr(p); expect(p, TOK_RPAREN, "')'"); return inner; } fprintf(stderr, "line %d:%d: expected expression\n", p->cur.line, p->cur.col); p->had_error = 1; return ast_int(0, p->cur.line); /* 占位,避免上层解引用 NULL */ }

parse_expr里node = ast_binary(op.kind, node, rhs)的旧 node 作为左子树,新 rhs 作为右子树,所以1 - 2 - 3会变成(1 - 2) - 3。如果写成递归parse_expr -> parse_expr '-' term,不仅左递归,结合性也会变成右结合,减法结果直接翻车。

parse_factor在错误时返回一个占位整数节点而不是NULL,是为了让上层继续构造 AST,最后由had_error统一决定退出码。这个占位节点不会被求值,但能避免“错误恢复时到处判空”的连锁崩溃。

运算符更多时,分层递归的函数会变多,可以换成优先级爬升:一张{token, 优先级, 结合性}表加一个循环。对这份只有四则运算的文法,分层递归更好读,函数名直接对应文法非终结符,调试时栈帧也清楚。

方案适合场景代价
分层递归运算符少、优先级固定每加一层多一个函数
优先级爬升运算符多、需要表驱动循环边界和结合性要写对
预测分析表文法稳定、想自动生成需要 First/Follow 和表生成器

4. AST 与表达式:让语法分析器输出可遍历的结构

4.1 AST 节点设计与构造/释放

语法分析器不能只判断对错,还要把结构留给后面的求值或代码生成。AST 节点用 tagged union,节点种类枚举和文法非终结符对应,但不要一个产生式一个节点类型,按语义合并:

typedef enum { AST_INT, AST_VAR, AST_BIN, AST_ASSIGN, AST_IF, AST_WHILE, AST_BLOCK } AstKind; typedef struct AstNode AstNode; struct AstNode { AstKind kind; int line; union { long ival; struct { const char *name; } var; struct { TokenKind op; AstNode *lhs, *rhs; } bin; struct { const char *name; AstNode *value; } assign; struct { AstNode *cond, *then_s, *else_s; } if_s; struct { AstNode *cond, *body; } while_s; struct { AstNode **items; int count, cap; } block; } u; };

line每个节点都带,后面报“变量未定义”时能指回源码行。name是const char *,必须指向稳定内存,不能直接存词法器里的临时指针。

内存管理我一般用 arena:解析过程中所有节点和字符串都从 arena 分配,解析结束一次性释放。错误恢复时不会出现“一半节点已 malloc、一半没释放”的烂摊子:

typedef struct ArenaBlock { struct ArenaBlock *next; size_t used; size_t cap; unsigned char data[]; } ArenaBlock; typedef struct { ArenaBlock *head; } Arena; void *arena_alloc(Arena *a, size_t n) { const size_t align = 8; n = (n + align - 1) & ~(align - 1); 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"); 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; }

align取 8 是为了让long、指针和double都不越界;cap小于 4096 时统一给 4096,减少小块分配次数。arena_alloc返回的内存不对齐到 16 字节,如果后面要放 SIMD 类型再调大。字符串拷贝用arena_strdup,内部先strlen再arena_alloc,最后memcpy,保证 AST 里的变量名不依赖词法器缓冲。

4.2 用访问者/递归打印验证 AST

AST 建完先别急着求值,打印成 S-表达式最方便回归测试。递归函数按节点种类分派:

static void ast_print_indent(const AstNode *n, int depth) { if (!n) return; for (int i = 0; i < depth; i++) putchar(' '); switch (n->kind) { case AST_INT: printf("(int %ld)\n", n->u.ival); break; case AST_VAR: printf("(var %s)\n", n->u.var.name); break; case AST_BIN: printf("(bin %d\n", n->u.bin.op); ast_print_indent(n->u.bin.lhs, depth + 2); ast_print_indent(n->u.bin.rhs, depth + 2); printf(")\n"); break; case AST_ASSIGN: printf("(assign %s\n", n->u.assign.name); ast_print_indent(n->u.assign.value, depth + 2); printf(")\n"); break; case AST_IF: printf("(if\n"); ast_print_indent(n->u.if_s.cond, depth + 2); ast_print_indent(n->u.if_s.then_s, depth + 2); ast_print_indent(n->u.if_s.else_s, depth + 2); printf(")\n"); break; case AST_WHILE: printf("(while\n"); ast_print_indent(n->u.while_s.cond, depth + 2); ast_print_indent(n->u.while_s.body, depth + 2); printf(")\n"); break; case AST_BLOCK: printf("(block\n"); for (int i = 0; i < n->u.block.count; i++) ast_print_indent(n->u.block.items[i], depth + 2); printf(")\n"); break; } }

打印时缩进两格,嵌套结构一眼能看出。AST_BIN直接打印op的枚举值在调试期够用,正式测试可以换成"+", "-"的字符串表。这个函数不修改 AST,参数const AstNode *,后面加语义检查时可以直接复用。

4.3 用测试用例锁住文法:从 token 流到期望 AST 文本

解析器最容易在改文法时悄悄回归。准备一组输入和期望输出,用脚本跑:

#!/usr/bin/env bash # 每行: 输入文件|期望的 S-表达式前缀 set -u fail=0 while IFS='|' read -r src expect; do got=$(printf '%s\n' "$src" | ./parser 2>/dev/null) if ! printf '%s' "$got" | grep -qF "$expect"; then echo "FAIL: $src" echo " expect: $expect" echo " got: $got" fail=1 fi done <<'CASES' a = 1 + 2 * 3;|(assign a (bin 5 (int 1) (bin 6 (int 2) (int 3)))) if (a) b = 1; else b = 2;|(if (var a) (assign b (int 1)) (assign b (int 2))) while (a) { b = b - 1; }|(while (var a) (block (assign b (bin 4 (var b) (int 1))))) CASES exit $fail

grep -qF做子串匹配,避免 S-表达式的空白差异导致假失败;真正严格时可以换成 Python 脚本做结构化比较。set -u防止变量拼写错误静默通过。退出码0表示全过,1表示至少一条失败,CI 里直接可用。

测试用例要覆盖空输入、缺分号、多余 token、嵌套if、负数(如果文法没一元负号,负数应该报错而不是被当成0 - 1)。每个用例都对应一个文法分支,改文法时先跑这组,比肉眼盯代码可靠。

5. 语法分析器排查:五类翻车现场与定位手法

5.1 一改文法就栈溢出:左递归没有消除

现象:输入1+2,程序没有输出,调试器里parse_expr重复出现几百层,最后段错误。

原因:产生式写成了expr -> expr '+' term | term,C 函数第一行就递归调用自己,token 游标没有前进。

解决:把直接左递归改成循环或右递归。手写解析器优先用循环:

AstNode *node = parse_term(p); while (check(p, TOK_PLUS)) { advance(p); node = ast_binary(TOK_PLUS, node, parse_term(p)); }

改完在parse_expr入口加一个断言:每次进入函数后必须至少消费一个 token 或直接返回,否则打印当前 token 并 abort。这个断言在开发期能第一时间抓住间接左递归。

5.2 减法算反:结合性写成了右结合

现象:1 - 2 - 3求值得到2,期望-4;打印 AST 发现是1 - (2 - 3)。

原因:parse_expr写成了递归调用自身:

AstNode *rhs = parse_expr(p); /* 错误:右结合且左递归 */

解决:同层运算符用while循环,左操作数累积到node,右操作数只调用下一层parse_term。改完用 AST 打印确认形状是((1-2)-3)。除法和减法都要求左结合,乘除同样处理。

5.3 报错位置总在下一个 token:游标提前消费

现象:输入a = 1缺分号,报错说“expected ';', got EOF”,行号却是第 2 行;或者报错指向=后面的 token。

原因:expect之前调用了advance,或者match在失败时也消费了 token。另一种常见情况是词法器在换行时先line++再返回上一行的 token,导致位置整体偏移。

解决:把所有消费收拢到advance,match失败不移动游标;expect报错使用p->cur的位置,而不是lexer_next预读的位置。词法器里line和col在读到\n时更新,token 的位置取起始字符的位置,不要取结束位置。

5.4 空产生式导致死循环:可选分支没有前进

现象:解析if (a) ;或空块时卡死,CPU 占满。

原因:某个可选分支匹配了零个 token 就返回,外层while又用同一个 token 继续循环。比如parse_stmt在无法识别时返回NULL,但调用方没有 break。

解决:每个解析函数要么消费 token,要么明确返回“没有进展”。块循环里加保护:

int before = p->cur.kind; AstNode *s = parse_stmt(p); if (!s || p->cur.kind == before) break;

更根本的做法是检查文法里所有ε产生式:只有当前 token 在 Follow 集里才允许走空分支,否则报错并同步到;或}。

5.5 AST 里的变量名变成乱码:存了词法器临时指针

现象:解析多个赋值后,打印 AST 发现前面的变量名被后面的覆盖,或者释放词法器后访问崩溃。

原因:Token.lexeme指向词法器内部复用的缓冲区,ast_assign(name.lexeme, ...)直接保存了指针。词法器读到下一个标识符时覆盖了同一块内存。

解决:在构造 AST 时把名字拷进 arena:

char *arena_strdup(Arena *a, const char *s) { size_t n = strlen(s) + 1; char *p = arena_alloc(a, n); memcpy(p, s, n); return p; }

所有长期保存的字符串都走这个函数。释放时只释放 arena 的块,不逐个free节点。开发期开 AddressSanitizer 跑测试用例,悬垂指针和越界读会直接报出来。

6. 用 LL(1) 预测表交叉验证手写解析器

手写递归下降写完后,我习惯再用 First/Follow 生成一张 LL(1) 预测表,不是为了替换代码,而是做交叉检查。表里每个[非终结符, token]单元格最多只能有一个产生式;如果出现两个,说明文法有冲突,手写代码里的if顺序只是把冲突藏起来了。

以stmt为例,表片段大致是:

非终结符IDifwhile{}EOF
stmtID '=' expr ';'if ...while ...{ stmt* }报错报错
exprterm expr_rest报错报错报错εε

expr在)、;、}、EOF 上走空产生式,这正好对应parse_expr里while循环不进入、直接返回term的行为。如果表里expr在)上有两个候选,手写代码就会在某个嵌套表达式里提前消费右括号,表现为“括号不匹配”的假报错。

同步集也用 Follow 集来做错误恢复:parse_stmt报错后,一直advance到;、}或 EOF,再返回上层。这样一次输入能报出多个错误,而不是第一个错误就停。参数上,同步集不要包含ID和if,否则会把下一条合法语句一起吃掉。

这套方案值不值得做,取决于你要不要长期维护文法。如果只是解析一个固定配置文件,手写两百行递归下降足够;如果文法会频繁加语句、加运算符,先把 First/Follow 表和测试用例建起来,再改解析函数,返工次数会少很多。我自己的习惯是:每加一条产生式,先补一个期望 AST 文本的用例,再动 C 代码;跑不过就先看 token 流,不要一上来就怀疑递归函数。希望帮到你。

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

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

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

立即咨询