简介这份资源是面向编译原理初学者与C语言进阶学习者的LR(0)语法分析器实现项目聚焦自底向上语法分析这一编译器设计核心环节帮助读者理解上下文无关文法、项集构建、状态机与状态转移表等关键概念。压缩包共14个文件约224KB以cpp源码为核心辅以exe可执行程序、obj与pch等编译中间文件以及dsp、dsw、opt等VC6工程配置文件和pdb、ilk等调试符号文件源码注释较为完整便于对照阅读与直接运行验证。目前已有841人学习下载。通过阅读源码并输入C语言语句片段观察分析过程读者可以掌握LR(0)分析器从读取输入到判定语法是否合法的完整流程理解其处理左递归等场景时的局限并为后续学习LR(1)、LALR(1)等更强大的分析器打下基础适合作为编译技术课程实验与自学实践的参考案例。1. 从一段报错说起为什么每个 C 语言学习者最后都要手写一个语法分析器你写了一个能跑通四则运算的计算器输入12*3得到 7心里挺美。然后你试着输入12*程序直接卡死或者输出一堆乱码。这时候你才意识到词法分析器只负责把字符切成 token它根本不管这些 token 拼在一起合不合法。语法分析器就是干这个的它拿着词法分析器吐出来的 token 流按照一套文法规则去判断结构对不对顺便把表达式算出来或者生成一棵语法树。这件事在 C 语言课程设计里出现的频率极高从翁恺 C 语言练习题到各种编译原理大作业语法分析器几乎是绕不过去的坎。很多人第一反应是去网上找现成代码结果发现要么是几百行的黑匣子看不懂要么是只支持加减乘除稍微复杂一点就翻车。我写这篇文章的目标很明确带你从零手写一个能处理变量赋值、四则运算、括号优先级、错误恢复的递归下降语法分析器代码控制在 300 行以内每一行你都能看懂改得动。适合谁看如果你已经能用 C 语言写个几百行的程序知道指针和结构体怎么用但一提到“文法”“递归下降”“抽象语法树”就头大那这篇就是给你准备的。如果你只是想抄一份交作业那直接跳到第 3 章把代码拿走也行但后面排错的部分建议还是看一眼因为老师大概率会问你参数怎么改。2. 递归下降的底牌文法、FIRST 集和那棵看不见的树2.1 为什么选递归下降而不是 LR 或 yacc语法分析的实现路线大致分两派自顶向下和自底向上。自底向上那套典型代表是 LR 分析器和 yacc/bison 这类工具优点是能处理的文法范围广缺点是状态机跳转表生成出来就是天书调试的时候你根本不知道它为什么移进为什么归约。自顶向下里的递归下降说白了就是给每个非终结符写一个函数函数内部根据当前 token 决定走哪条产生式。它的优势在于代码结构和文法结构一一对应你看着文法就能写出代码出了错直接打断点看调用栈栈里每一层对应哪个非终结符清清楚楚。代价是递归下降只能处理 LL(1) 文法也就是不能有左递归且每个非终结符的候选式首符号集不能冲突。左递归的问题好解决把E - E T | T改写成E - T EE - T E | ε就行。首符号集冲突稍微麻烦一点需要提取左公因子但常见的表达式文法基本不需要动这个手术。我一般会先把文法写出来标好每个非终结符的 FIRST 集再动手写函数。这一步花十分钟后面省两小时调试。2.2 文法设计从表达式到语句的完整规则下面是我在这个语法分析器里用的文法支持变量赋值、加减乘除、括号、比较运算和分号结尾的语句序列program - stmt_list stmt_list - stmt stmt_list | ε stmt - id expr ; | expr ; expr - cmp_expr cmp_expr - add_expr (( | | ) add_expr)* add_expr - mul_expr (( | -) mul_expr)* mul_expr - unary ((* | /) unary)* unary - - unary | primary primary - number | id | ( expr )这个文法没有左递归每个非终结符的候选式首符号集也不冲突。stmt的两个候选式分别以id和number/(/-开头词法分析器能区分。cmp_expr里我把比较运算放在加减之上这样a b c会先算加法再比较符合直觉。注意如果你要支持if和while文法会复杂不少但递归下降的骨架不变只是多几个函数。建议先把表达式跑通再加语句。2.3 词法分析器的最小接口语法分析器不直接读字符它从词法分析器拿 token。我定义了一个极简的 token 结构typedef enum { TOK_NUM, TOK_ID, TOK_PLUS, TOK_MINUS, TOK_STAR, TOK_SLASH, TOK_LPAREN, TOK_RPAREN, TOK_ASSIGN, TOK_SEMI, TOK_GT, TOK_LT, TOK_EQ, TOK_EOF, TOK_ERR } TokenType; typedef struct { TokenType type; double num_val; // 当 type TOK_NUM 时有效 char name[32]; // 当 type TOK_ID 时有效 } Token;词法分析器对外只暴露两个函数next_token()返回下一个 tokenpeek_token()预读但不消耗。递归下降里经常需要看下一个 token 才能决定走哪条产生式预读是刚需。实现上用一个全局的 lookahead 变量缓存即可这部分不是本文重点完整代码里会带上。3. 手写递归下降从 expr() 到 stmt() 的完整代码3.1 表达式求值add_expr 和 mul_expr 怎么写先看最核心的表达式部分。递归下降的写法是每个非终结符对应一个函数函数返回该非终结符推导出的值。对于算术表达式返回值就是计算结果。double expr(void); double primary(void) { Token t peek_token(); if (t.type TOK_NUM) { next_token(); return t.num_val; } else if (t.type TOK_ID) { next_token(); return lookup_var(t.name); // 从符号表取值未定义返回 0 } else if (t.type TOK_LPAREN) { next_token(); double v expr(); expect(TOK_RPAREN); // 吃掉右括号不匹配就报错 return v; } else if (t.type TOK_MINUS) { next_token(); return -primary(); // 一元负号 } syntax_error(primary: unexpected token); return 0; } double mul_expr(void) { double left primary(); for (;;) { Token t peek_token(); if (t.type TOK_STAR) { next_token(); left * primary(); } else if (t.type TOK_SLASH) { next_token(); double right primary(); if (right 0) { syntax_error(division by zero); return 0; } left / right; } else { break; } } return left; } double add_expr(void) { double left mul_expr(); for (;;) { Token t peek_token(); if (t.type TOK_PLUS) { next_token(); left mul_expr(); } else if (t.type TOK_MINUS) { next_token(); left - mul_expr(); } else { break; } } return left; }逻辑说明mul_expr先调primary拿到左操作数然后循环看下一个 token 是不是*或/。是的话消耗掉再调primary拿右操作数算完继续循环。这样写的好处是左结合自然成立8/4/2会先算8/4再除以 2。add_expr同理只是它调的是mul_expr优先级就体现出来了。参数说明lookup_var是符号表查询函数用一个简单的数组或哈希表实现都行。expect函数检查当前 token 类型是否匹配不匹配就调用syntax_error报错并跳过。syntax_error我一般会打印行号和当前 token 内容方便定位。3.2 语句和赋值stmt() 与符号表的配合表达式只能算值要支持x 3 4;这种赋值需要在stmt层面处理。void stmt(void) { Token t peek_token(); if (t.type TOK_ID) { // 可能是赋值也可能是表达式语句预读一个 token 判断 Token next peek_token_n(1); if (next.type TOK_ASSIGN) { next_token(); // 吃掉 id next_token(); // 吃掉 double v expr(); set_var(t.name, v); // 写入符号表 expect(TOK_SEMI); return; } } // 否则按表达式语句处理 double v expr(); (void)v; // 表达式语句的结果丢弃 expect(TOK_SEMI); } void stmt_list(void) { while (peek_token().type ! TOK_EOF) { stmt(); } }逻辑说明stmt先看当前 token 是不是标识符。如果是再预读下一个 token如果是就按赋值处理否则回退到表达式语句。这里需要词法分析器支持任意深度的预读或者至少支持两 token 预读。我一般用环形缓冲区存最近几个 token实现起来不复杂。参数说明set_var和lookup_var共用同一张符号表。符号表用简单的线性数组就行变量数量不会超过几十个。expect(TOK_SEMI)确保语句以分号结尾少了分号会报错并跳到下一个分号这是错误恢复的基本手段。3.3 错误恢复让分析器报错后还能继续跑语法分析器最怕的就是遇到一个错误就整个崩掉。用户输入x 3 ;你至少得告诉他哪一行哪个位置出了问题然后跳过这个语句继续分析下一句。void syntax_error(const char *msg) { fprintf(stderr, [语法错误] 行 %d: %s (当前 token: %s)\n, current_line, msg, token_name(peek_token().type)); // 错误恢复跳到下一个分号或文件结尾 while (peek_token().type ! TOK_SEMI peek_token().type ! TOK_EOF) { next_token(); } if (peek_token().type TOK_SEMI) { next_token(); // 吃掉分号从下一条语句重新开始 } }逻辑说明报错时打印行号和当前 token然后一直消耗 token 直到遇到分号或文件结尾。这样一条语句里的错误不会影响后面的语句。如果错误发生在表达式中间比如x 3 ;primary会报错然后syntax_error跳到分号stmt返回stmt_list继续处理下一行。参数说明current_line由词法分析器在遇到换行时更新。token_name把枚举转成可读字符串方便调试。错误恢复的粒度可以更细比如在primary里报错后只跳到下一个运算符但那样代码复杂度会上升对于课程设计级别的分析器跳到分号足够了。4. 避坑与排查递归下降最容易翻车的五个地方4.1 左递归没消除程序栈溢出现象输入123程序直接崩溃调试器显示栈溢出调用栈里expr反复出现。原因文法里写了expr - expr term递归下降遇到左递归会无限展开每次调用expr都先调自己永远不消耗 token。解决把左递归改写成右递归形式。expr - term exprexpr - term expr | ε。代码里用循环代替递归add_expr里的for(;;)就是消除左递归后的写法。4.2 预读 token 没回退token 被吃掉现象x 3;分析完赋值后分号不见了下一条语句报错。原因在stmt里预读判断是不是赋值时调用了next_token消耗了 token但判断失败后没有回退机制。解决预读必须用peek_token而不是next_token。如果需要看多个 token实现一个带缓冲的预读队列peek_token_n(k)返回第 k 个 token 但不移动读指针。只有确定走哪条产生式后才调next_token消耗。4.3 符号表没初始化变量取值是随机数现象输入y x 1;其中 x 未定义结果 y 得到一个很大的随机数。原因lookup_var在符号表里找不到变量时返回了未初始化的内存或者栈上的垃圾值。解决lookup_var找不到变量时明确返回 0或者报“未定义变量”错误。我一般选择返回 0 并打印警告这样不会中断分析流程。符号表初始化时全部清零。4.4 除零检查漏了程序直接挂掉现象输入1/0;程序崩溃没有错误提示。原因mul_expr里直接做了除法没有检查右操作数是否为零。整数除零在 C 语言里是未定义行为浮点除零会得到 inf 但不会崩溃不过结果没意义。解决在做除法前判断right 0是的话调用syntax_error报“除零错误”并返回 0。这样程序不会崩用户也能看到明确的错误信息。4.5 错误恢复跳过头把合法语句也跳过了现象输入x ; y 2;报错后 y 的赋值也被跳过了。原因syntax_error里的恢复逻辑跳到分号后直接返回但stmt里调用expr失败后没有正确处理返回值导致stmt_list的状态混乱。解决syntax_error跳到分号后确保stmt函数直接返回不要再执行expect(TOK_SEMI)。可以在syntax_error里设置一个全局错误标志stmt检查到这个标志就立即返回。另外恢复时只跳过一个分号不要跳到文件结尾。5. 进阶技巧把语法分析器变成能算能查的交互式工具5.1 加一个 REPL 循环边输入边分析课程设计里通常要求从文件读输入但调试阶段用交互式循环效率高得多。下面这段代码把stdin按行读入每行送给语法分析器int main(void) { char line[256]; init_lexer(); // 初始化词法分析器状态 init_symbols(); // 清空符号表 while (fgets(line, sizeof(line), stdin)) { set_input(line); // 把当前行设为词法分析器的输入 current_line 1; // 行号重置 stmt_list(); // 分析并执行 // 可选打印当前所有变量 dump_symbols(); } return 0; }逻辑说明每次读一行重置词法分析器的输入指针和行号然后调stmt_list分析整行。dump_symbols在每行结束后打印符号表内容方便观察变量变化。这个循环让调试变成交互式的改一个表达式立刻能看到结果。参数说明line缓冲区大小 256 够用了如果表达式特别长可以调到 1024。set_input需要重置词法分析器的内部状态包括读指针、行号和预读缓冲。5.2 用 AST 替代直接求值支持多次遍历直接求值的写法简单但只能算不能改。如果你想把语法分析器扩展成解释器或者需要做常量折叠、死代码消除这类优化就得生成抽象语法树AST。typedef struct ASTNode { TokenType type; double num_val; char name[32]; struct ASTNode *left; struct ASTNode *right; } ASTNode; ASTNode *parse_expr(void) { ASTNode *left parse_mul(); for (;;) { Token t peek_token(); if (t.type TOK_PLUS || t.type TOK_MINUS) { next_token(); ASTNode *node new_node(t.type); node-left left; node-right parse_mul(); left node; } else break; } return left; }逻辑说明每个解析函数返回一个 AST 节点指针而不是直接算值。new_node分配节点并初始化。表达式解析完后得到一棵树你可以写eval(ASTNode*)来求值也可以写print(ASTNode*)来输出中缀表达式甚至写optimize(ASTNode*)做常量折叠。参数说明ASTNode里left和right分别指向左右子节点叶子节点的type是TOK_NUM或TOK_ID。内存管理需要自己处理建议写一个free_ast递归释放。如果不想手动管理内存可以用一个节点池一次性分配几百个节点程序结束时统一释放。5.3 验证方法用三组测试用例覆盖边界写完语法分析器后用下面三组用例验证测试组输入预期结果覆盖点基础运算12*3;7优先级括号与赋值x (12)*3; x;9括号、符号表错误恢复x ; y 2; y;报错后 y2错误恢复第一组验证乘除优先级高于加减。第二组验证括号改变优先级以及变量赋值后能正确读取。第三组验证错误恢复不会影响后续语句。如果这三组都过了基本功能就没问题。我自己的习惯是每加一个功能就往测试文件里追加一行跑一遍看输出对不对。语法分析器的坑大多在边界情况上比如空输入、只有分号、连续运算符这些用测试用例覆盖比手动试效率高得多。希望帮到你。本文还有配套的精品资源点击获取