简介:《编译原理学习指导》面向计算机专业本科生、考研复习者以及希望夯实编译器基础的开发者,围绕词法分析、语法分析(LL、递归下降与LR)、语义分析、运行时环境、中间代码生成和代码优化等核心环节梳理学习路径,并涉及正则表达式、自动机与抽象语法树等关键概念。文档重点评介《编译原理》(龙书)、《现代编译程序设计》和《编译原理及实践》三本经典教材,比较三者在理论深度与实践侧重上的差异,提示借助Tiny C以及Lex、Yacc理解编译器构建流程,对教材选择困难、LR算法理解吃力或想通过小型编译器项目巩固理论的读者较有针对性。资源包共1个doc文件,大小约34KB,篇幅紧凑,便于快速浏览、打印标注和课堂同步使用。目前已有153人学习,可作为建立编译原理知识框架、安排复习顺序与规划实践的参考。
1. 三本书摆在一张桌上,先想清楚你要解决哪个问题
编译原理这门课最劝退的地方不在理论本身,而在于翻开书全是集合、闭包和自动机,合上书不知道自己能写出什么。我带过几轮课程设计,最常见的翻车现场是:教材从头读到尾,LR 分析表会手推,真让写两百行扫描器就卡住。问题出在路径上——编译原理不是「读完再动手」,而是每学一块理论就立刻用最小可运行的产物把它钉死:正则表达式对应一个 flex 扫描器,文法对应一棵语法树,语法制导定义对应一次属性计算。这篇按我自己的踩坑顺序,把三本经典教材的适用阶段、flex 与 bison 的配法、递归下降为什么必须先手写、以及语法树怎么变成堆栈式中间代码摊开讲。
2. 三本教材怎么排顺序:龙书打底、实践收口、现代设计补课
网上对教材的答案通常是「先读龙书」,这话对一半人是有害的。龙书的真正价值在于它开篇就用一个完整的小例子把前端到后端串了一遍,让你先看见地图再看地形;它的短板是把代码优化铺得太开,本科阶段一头扎进数据流分析很容易出不来。另外两本恰好补的是龙书不擅长的部分——一个补现代语言特性,一个补完整实现。三本书不是替代关系,是流水线上的三道工序,顺序错了就会反复返工。
2.1 三本书各自解决什么问题
先把定位摆清楚,比空谈「哪本最好」有用得多。下表是我这几年给不同基础的人排书时反复改出来的版本,判断维度只有三个:你现在缺的是全局图景、工程细节,还是完整实现经验。
| 教材 | 强项 | 短板 | 适合阶段 | 过关标准 |
|---|---|---|---|---|
| 《编译原理》(俗称龙书) | 开篇用一个例子串起全流程;理论体系几十年没大变;运行时环境一章讲得极细 | 优化部分过深,本科读不完也不必读完 | 入门第一本 | 能画出从源码到目标代码的完整流程图,能解释每一步为什么存在 |
| 《编译原理及实践》 | 边讲原理边实现一个小型 C 子集编译器;对 Lex/Yacc 的说明罕见地详细 | 深度不如龙书,部分章节点到为止 | 第二本,配合课程设计 | 能跑通书里的扫描器和语法分析器,能说清每个模块的输入输出 |
| 《现代编译程序设计》 | 偏工程,覆盖垃圾回收、面向对象语言编译、函数式语言编译等现代话题 | 前置要求高,直接当入门书会劝退 | 第三本,按需挑章 | 能说清自己项目里该不该引入某种内存管理或调用约定 |
龙书最值得先读的是第一章和运行时环境那一章。第一章让你知道编译器有哪些阶段、每个阶段的输入输出是什么,这是后面所有细节的挂靠点;运行时环境那一章讲存储组织、存储分配策略、非局部名字的访问、参数传递、符号表、动态存储分配,这些东西你写普通业务代码时天天在用,却很少深究内部怎么完成,读完会有一批「原来这个黑匣子是这么转的」的瞬间。这两章读透,后面词法语法再难也有个坐标系。
《编译原理及实践》的价值在「完整」。它带着你从零写一个能跑的小型编译器,扫描器、递归下降分析器、符号表、代码生成一路走到底。国内不少教材的问题是从定义到定义,读者看完不知道这些东西有什么用;这本至少在每一章末尾都留下了一个能编译运行的东西。它对手工工具 Lex 和 Yacc 的讲解也是我见过最细的,很多教材只提一句「可用工具生成」,它会把规则文件怎么写、优先级怎么处理、常见冲突怎么消都讲清楚。
《现代编译程序设计》的「现代」二字是关键。传统教材里看不到垃圾回收的具体算法,看不到面向对象语言的虚函数表怎么布局,看不到函数式语言的闭包怎么实现——这些内容在解释执行语言流行之后才真正变成刚需。如果你只是应付本科课程,这本可以只挑垃圾回收和运行时组织两章;如果你打算自己动手做一个带自动内存管理的语言,这两章得反复读。
2.2 一条八周能走完的路线
选型定完,剩下的是把时间切开。我给课程设计排的默认节奏是八周,每周有可验收的产出,验收不过就不往下走。这个节奏对自学同样适用,只是可以把周期拉长到十二周。
第一阶段两周,只读龙书的前几章,目标是建立图景。这两周不写代码,但要动手画:把「源程序 → 词法单元流 → 语法树 → 中间代码 → 目标代码」这条链画出来,每一环标注输入输出和典型算法。画不出来说明没读懂,回去重读。这一阶段结束时你应该能回答:为什么词法分析要单独抽出来,不直接塞进语法分析里。
第二阶段四周,跟着《编译原理及实践》实现。顺序是扫描器 → 递归下降分析器 → 符号表 → 三地址码或堆栈式中间代码。每完成一个模块就写三到五个测试用例,用例要覆盖正常路径和至少一个错误路径。这四周是最容易卡住的阶段,卡住时不要回去重读理论,先看错误信息定位到具体模块,再翻对应章节。
第三阶段两周,按需读《现代编译程序设计》。如果你做的语言有对象或闭包,读对应章节;如果只是过程式语言,读垃圾回收和运行时组织即可。这一阶段的产出是一份短文档:你的编译器在内存管理上做了什么选择,为什么这么选,代价是什么。
2.3 中文版还是英文版
这个问题被问得太多,我的判断标准很简单:理论部分读中文,术语和公式回查英文。三本经典教材的中文版翻译质量整体可用,读理论的效率远高于啃原文;但术语对照必须自己建一张表,比如 reduction、derivation、handle、viable prefix 这些词在不同译本里译法不统一,做题和看论文时会打架。
具体做法是:读中文版时手边开一个纯文本文件,遇到拿不准的术语就把中英文并列记一行,遇到公式推导看不懂就去找英文版对应段落,公式本身没有语言障碍,难的是作者行文的逻辑连接词。我一般会建议在第二周结束前把这张术语表建到五十条以上,后面读任何资料都顺。
别为了「显得专业」硬读原版。理解理论的实质比理解表面的文字重要得多,这句话放在这里依然成立。
3. 词法分析落地:从正则到 DFA,再用 flex 生成第一个扫描器
词法分析是整门课里最容易「以为自己会了」的部分。手写一个能跑的分词函数,任何一个写过几个月代码的人都能做到;但课程要你掌握的是从正则表达式到有限自动机的那条转换链,以及为什么必须绕这么一圈。这条链不长:正则表达式 → NFA(Thompson 构造)→ DFA(子集构造)→ 最小化 DFA。每一步都有明确的算法,也都有明确的代价。
3.1 为什么不能跳过自动机直接写代码
直接写代码的问题是没法维护。假设你要支持的关键字从十个涨到五十个,手写一堆if/else分支还能勉强应付;但一旦涉及「最长匹配」和「回退一个字符」,手写逻辑就开始出错。正则表达式是声明式的,它只描述「什么形态算一个词法单元」,不描述怎么匹配;DFA 是执行式的,它是一张状态转移表,读一个字符跳一个状态。中间那层自动机理论,干的就是把声明翻译成执行。
NFA 到 DFA 的子集构造,本质是把「同时处于多个状态」这件事摊平成「一个状态集合」。这一步会带来状态数膨胀,最坏情况是指数级,但实际语言的词法规则不会走到最坏情况。DFA 最小化则是把等价状态合并,让表变小。这三步在纸上手推一遍是必要的,推完你才会明白:为什么 lex 家族的工具都要求你写正则而不是写状态机——因为人是写不好状态机的,工具写得比人好。
有一个常被忽略的细节:词法分析阶段就要开始维护行号和列号。很多课程设计到最后报错只能报「语法错误」,没法报「第 12 行第 5 列」,就是因为扫描阶段没存位置信息,后面想补已经来不及。
3.2 用 flex 生成扫描器
flex 的规则文件分三段:定义段、规则段、用户代码段。定义段放头文件、全局变量和正则的具名定义;规则段一行一条「模式 + 动作」;用户代码段放 main 和辅助函数。下面这个版本我用来做课程设计的第一版扫描器,能跑通一个小型 C 子集的词法部分。
%{ #include <stdio.h> #include <stdlib.h> #include <string.h> /* token 编号从 258 起,避开单字符 ASCII,方便和 bison 侧对齐 */ enum { TOK_IF = 258, TOK_ELSE, TOK_WHILE, TOK_INT, TOK_ID, TOK_NUM, TOK_EQ, TOK_LE, TOK_GE }; static int line_no = 1; /* 报错定位全靠它,扫描阶段就得开始维护 */ %} %option noyywrap DIGIT [0-9] ID [a-zA-Z_][a-zA-Z0-9_]* NUM {DIGIT}+(\.{DIGIT}+)? %% "if" { return TOK_IF; } "else" { return TOK_ELSE; } "while" { return TOK_WHILE; } "int" { return TOK_INT; } "==" { return TOK_EQ; } "<=" { return TOK_LE; } ">=" { return TOK_GE; } {ID} { printf("ID(%s) ", yytext); return TOK_ID; } {NUM} { printf("NUM(%s) ", yytext); return TOK_NUM; } [-+*/=<>(){},;] { printf("OP(%s) ", yytext); return yytext[0]; } [ \t]+ { /* 空白不产生 token,直接吞掉 */ } \n { line_no++; } . { fprintf(stderr, "lex error at line %d: %s\n", line_no, yytext); } %% int main(int argc, char **argv) { if (argc > 1 && !(yyin = fopen(argv[1], "r"))) { perror(argv[1]); return 1; } while (yylex() != 0) { } /* noyywrap 时扫到文件尾返回 0 */ printf("\n"); return 0; }保存成scanner.l,两行命令就能出可执行文件:
flex -o lex.yy.c scanner.l # 生成 C 源文件,-o 指定输出名 cc -O2 -o scanner lex.yy.c # 编译,-O2 对状态机跳转有明显收益 ./scanner demo.c生成阶段有个参数值得单独说:flex --header-file=scanner.h scanner.l,它会额外吐一个头文件,里面是 token 宏和yylex声明。bison 侧的.y文件直接 include 这个头,两边 token 编号就不会对不上——我见过太多课程设计因为手抄 token 编号抄错,调了两天才发现是 258 写成了 259。
规则段有三条隐性规则,不写进注释里很容易翻车。第一,flex 采用最长匹配,同等长度下选规则表里靠前的那条,所以关键字规则必须写在{ID}之前,否则if永远被识别成标识符。第二,yytext指向的是内部缓冲区,需要长期保存的字符串必须strdup,否则下一轮匹配就被覆盖。第三,%option noyywrap省掉了yywrap函数的桩,但也意味着只能扫一个文件,多文件输入要自己维护栈。
3.3 手写扫描器与生成器的边界
工具不是万能的。什么时候该用 flex,什么时候该手写,我给一张对照表,这张表在课程设计答辩时被问过好几次,答清楚了说明你真的用过。
| 维度 | flex 生成 | 手写扫描器 |
|---|---|---|
| 开发速度 | 规则写完基本就能跑 | 慢三到五倍 |
| 可维护性 | 加关键字改一行正则 | 改分支逻辑,容易漏 |
| 最长匹配 | 工具保证 | 手写容易漏回退 |
| 错误恢复 | 弱,非法字符只能报错退出 | 可做跳过、补全、继续 |
| 与解析器耦合 | 需要头文件对齐 token | 完全自己控制 |
| 性能 | 状态表跳转,稳定 | 依赖写法,写得好略快 |
结论是:词法规则稳定的语言用生成器,需要复杂错误恢复的交互式场景手写。课程设计一般属于前者,所以别在这上面耗时间,把精力留给语法分析。
验证扫描器有一组必测用例:关键字与标识符的边界(if与ifx)、最长匹配(<=不能被切成<和=)、数字与点的歧义(1.该不该是一个浮点数)、注释跨行、非法字符。跑完这五组,扫描器基本可以交付。
4. 语法分析的两条路:递归下降手写,LR 交给 bison
语法分析是编译原理里劝退率最高的一章,LR 尤其。很多人卡在「活前缀」「句柄」这些概念上就放弃了,其实这里有个认知错位:课程要求你理解 LR 分析表怎么构造,但不要求你手写一个 LR 分析器。这两件事的难度差一个数量级。真正该手写的是递归下降,因为它简单、直观、错误信息友好,而且在无数真实场景里够用。
4.1 LL 与 LR 的选型理由
LL 是自顶向下,从开始符号出发,每次选一个产生式往下推,靠看下一个 token 决定选哪条。LR 是自底向上,从左到右扫输入,把符号往栈里移,凑够一个产生式的右部就归约。表达力上 LR 严格强于 LL:左递归文法 LL 处理不了,LR 天然支持;公共前缀 LL 要提左公因子,LR 不用。
但表达力不是唯一指标。手写解析器时,LL 的错误信息能精确到「第 12 行,期望),实际遇到;」,LR 生成的解析器通常只能给一句「syntax error」。对于课程设计这种要演示、要写报告的场合,错误信息的质量直接影响评分。所以我的建议是:递归下降必须自己写一遍,LR 用工具生成、理解原理即可。
递归下降是 LL(1) 的手写形式。它有一个硬性前提:文法不能有左递归,不能有公共前缀。左递归会导致函数无限调用自己,公共前缀会导致预测失败。改造方法固定:左递归改写成循环,公共前缀提取成新非终结符。这两步是纯机械操作,练三五道题就熟了。
4.2 递归下降手写
下面这个解析器处理赋值、while、if 和四则表达式,词法部分用正则手写,方便和前面的 flex 版本对照。代码不长,但每个方法对应一条文法产生式,结构一目了然。
import re # 正则里 == 必须排在单字符 = 前面,否则会被切碎 TOKEN_RE = re.compile(r""" (?P<WS>\s+) | (?P<NUM>\d+(?:\.\d+)?) | (?P<ID>[A-Za-z_]\w*) | (?P<OP>==|<=|>=|[-+*/=<>(){},;]) """, re.VERBOSE) KEYWORDS = {"if", "else", "while", "int"} def tokenize(src): """切成 (kind, text) 列表,末尾补 EOF 让解析器有统一收口条件。""" pos, out = 0, [] while pos < len(src): m = TOKEN_RE.match(src, pos) if not m: raise SyntaxError(f"非法字符 {src[pos]!r},位置 {pos}") pos = m.end() kind, text = m.lastgroup, m.group() if kind == "WS": continue if kind == "ID" and text in KEYWORDS: kind = text.upper() # 关键字单独成类,解析时不必再查表 out.append((kind, text)) out.append(("EOF", "")) return out class Parser: def __init__(self, tokens): self.toks, self.i = tokens, 0 def peek(self): return self.toks[self.i][0] def peek_text(self): return self.toks[self.i][1] def eat(self, kind): if self.peek() != kind: raise SyntaxError(f"期望 {kind},实际 {self.peek()}({self.peek_text()})") tok = self.toks[self.i] self.i += 1 return tok def eat_op(self, ch): """单字符运算符单独校验,避免 ( 和 ) 混用同一套 eat。""" if self.peek() != "OP" or self.peek_text() != ch: raise SyntaxError(f"期望 {ch!r},实际 {self.peek_text()!r}") self.i += 1 # expr := term (('+' | '-') term)* def expr(self): node = self.term() while self.peek() == "OP" and self.peek_text() in ("+", "-"): op = self.eat("OP")[1] node = ("bin", op, node, self.term()) return node # term := factor (('*' | '/') factor)* def term(self): node = self.factor() while self.peek() == "OP" and self.peek_text() in ("*", "/"): op = self.eat("OP")[1] node = ("bin", op, node, self.factor()) return node # factor := NUM | ID | '(' expr ')' def factor(self): if self.peek() == "NUM": return ("num", float(self.eat("NUM")[1])) if self.peek() == "ID": return ("var", self.eat("ID")[1]) if self.peek() == "OP" and self.peek_text() == "(": self.eat_op("(") node = self.expr() self.eat_op(")") return node raise SyntaxError(f"表达式里出现意外 token:{self.toks[self.i]}") # stmt := ID '=' expr ';' | WHILE '(' expr ')' block | IF '(' expr ')' block def stmt(self): if self.peek() == "ID": name = self.eat("ID")[1] self.eat_op("=") e = self.expr() self.eat_op(";") return ("assign", name, e) if self.peek() == "WHILE": self.eat("WHILE") self.eat_op("(") cond = self.expr() self.eat_op(")") return ("while", cond, self.block()) if self.peek() == "IF": self.eat("IF") self.eat_op("(") cond = self.expr() self.eat_op(")") return ("if", cond, self.block()) raise SyntaxError(f"语句只能以标识符 / while / if 开头,实际 {self.toks[self.i]}") def block(self): self.eat_op("{") body = [] while not (self.peek() == "OP" and self.peek_text() == "}"): body.append(self.stmt()) self.eat_op("}") return ("block", body) def program(self): body = [] while self.peek() != "EOF": body.append(self.stmt()) return ("block", body) if __name__ == "__main__": src = """ x = 1 + 2 * 3; while (x > 0) { x = x - 1; } """ print(Parser(tokenize(src)).program())几处关键设计。表达式用三层函数拆开,expr管加减、term管乘除、factor管括号和原子,这正好对应文法的优先级层级,不需要额外的优先级表。循环代替左递归:expr := expr '+' term改写成先取term再while循环,栈深度从 O(n) 降到 O(1),长表达式不会爆栈。eat_op单独抽出来是因为括号、分号、等号都是 OP 类,混着eat("OP")会让错误信息变成「期望 OP」,等于没说。
这个解析器是 LL(1) 的,判断靠peek()一个 token。stmt里三条分支的首 token 互不相同(ID / WHILE / IF),所以不会歧义。如果加else,if后面跟不跟else就要靠更复杂的手段,或者干脆在这个位置引入一个小的优先级规则。
4.3 用 bison 生成 LR 解析器
bison 的规则文件和 flex 结构类似:声明段、规则段、用户代码段。声明段里%token声明终结符,%left/%right声明结合性和优先级,%define api.value.type指定语义值类型。下面这个版本和前面的扫描器配套。
%{ #include <stdio.h> #include <stdlib.h> int yylex(void); void yyerror(const char *s); %} %define parse.error verbose /* 默认报错太模糊,调冲突时一定要开 */ %define api.value.type {double} /* 整个语言的语义值统一用 double,够用 */ %token TOK_IF TOK_ELSE TOK_WHILE TOK_INT %token TOK_ID TOK_NUM TOK_EQ TOK_LE TOK_GE %left TOK_EQ '<' '>' %left '+' '-' %left '*' '/' %right UMINUS %% program: stmt_list ; stmt_list: /* 空 */ | stmt_list stmt ; stmt: TOK_ID '=' expr ';' { printf("assign\n"); } | expr ';' { printf("expr stmt\n"); } ; expr: expr '+' expr { $$ = $1 + $3; } | expr '-' expr { $$ = $1 - $3; } | expr '*' expr { $$ = $1 * $3; } | expr '/' expr { $$ = $1 / $3; } | expr '>' expr { $$ = ($1 > $3); } | '-' expr %prec UMINUS { $$ = -$2; } | '(' expr ')' { $$ = $2; } | TOK_NUM { $$ = $1; } ; %% void yyerror(const char *s) { fprintf(stderr, "parse error: %s\n", s); } int main(void) { return yyparse(); }构建流程是两步串起来的:
bison -d -v -o parser.tab.c parser.y # -d 生成 parser.tab.h 供扫描器 include # -v 生成 parser.output,冲突信息全在里面,这是调冲突的唯一依据 cc -O2 -o calc parser.tab.c lex.yy.c%left和%right的顺序有讲究:越靠后优先级越高。所以乘除在一行、加减在下一行,乘除自然就优先。%prec UMINUS是给一元负号单独指定优先级,否则-2*3会被解析成-(2*3)而不是(-2)*3——数值上一样,但语义树不同,涉及副作用时就会出错。
语义动作里的$1、$3指的是产生式右部第几个符号的值,$$是左部的值。这里有个时机问题:动作在归约时执行,此时右部符号已经全部出栈,所以能安全读到它们的值。很多人第一次写 bison 会以为动作是在匹配到该符号时立刻执行,结果在递归产生式里拿到空值,这是一个典型的翻车点。
4.4 冲突排查
bison 报冲突时不要猜,直接看parser.output。文件里会列出每个状态的所有项和冲突动作。shift/reduce 冲突最常见的三个来源:悬空 else、表达式优先级没声明、文法本身有二义性。悬空 else 的解法是给else更高的移进优先级,或者改写文法;表达式优先级用%left解决;文法二义性只能改写。
如果冲突数量是状态数的量级,别急着一个个消,先怀疑文法写错了。我见过一个课程设计报了 47 个冲突,最后发现是stmt_list写成了右递归,改回左递归后冲突降到 2 个。冲突数量本身就是一个诊断信号。
5. 排查清单:五个让编译器课程设计卡三天的坑
课程设计的坑有一个共同特征:报错信息离真正的原因很远。你看到的是「语法错误」,实际原因是扫描器的 token 编号对不上;你看到的是「段错误」,实际原因是语义动作里访问了已经出栈的符号。下面五条是我这些年被问得最多、也是我自己踩过的,按「现象 → 原因 → 解决」写清楚。
5.1 工具链与环境类
坑一:关键字被识别成标识符。
现象:输入if (x > 0),解析器报「期望 ID,实际 ID」,或者干脆把if当成变量名处理,语义检查时报「未定义的变量 if」。
原因:flex 的最长匹配加规则顺序。{ID}这条正则能匹配if,如果它的规则写在"if"之前,且两者匹配长度相同,flex 选靠前的那条,于是关键字永远进不了关键字分支。
解决:把所有关键字规则集中写在{ID}之前,并在文件里加一行注释标明顺序不可调。更稳的做法是用一张关键字哈希表,{ID}匹配后查表决定返回TOK_IF还是TOK_ID,这样加关键字不用改规则段,也不会因为插入位置出错。
坑二:Windows 上找不到 flex 和 bison。
现象:命令行敲flex提示「不是内部或外部命令」,或者装完之后bison报缺少m4。
原因:这两个工具原生面向 Unix 系环境,Windows 没有对应的可执行文件;bison 的构建还依赖 m4 宏处理器,只装 bison 不装 m4 会在生成阶段失败。
解决:装一个 POSIX 兼容层,cygwin 里自带flex.exe和bison.exe,装的时候把 devel 分类下的包勾上;或者用 Windows 自带的 Linux 子系统,包管理器一行命令装齐。不要去找所谓「绿色版」,版本对不上时生成的代码和头文件会不匹配,报错信息完全误导。
5.2 文法与语义动作类
坑三:递归下降写成左递归,程序直接卡死。
现象:解析一个稍长的表达式时进程无响应,或者栈溢出崩溃;短表达式却正常。
原因:expr := expr '+' term这种写法在递归下降里就是函数自己调自己,第一个 token 还没消费就再次进入expr,无限递归。短表达式之所以「正常」,往往是因为它走的是不需要递归的那条分支,属于假象。
解决:改写成循环。先取一个term,然后while看下一个 token 是不是+或-,是就再取一个term并组合成新节点。这个改法对所有二元运算符都适用,且把递归深度从输入长度降到文法层级。
坑四:bison 报 syntax error,但完全看不出位置。
现象:解析器对任何稍微复杂一点的输入都只说一句syntax error,不知道是哪个 token、哪个状态出的问题。
原因:bison 默认的错误信息极简,且没有维护位置信息。词法侧如果不记录行号列号,语法侧就算想报也报不出来。
解决:三步。第一,.y文件里加%define parse.error verbose,错误信息会带上期望的 token 列表;第二,flex 侧维护line_no并在每次返回 token 时把位置写进全局变量;第三,在yyerror里把这个全局变量打出来。三步做完,错误信息会从一句话变成「第 12 行第 5 列,期望)或,,实际遇到;」,调试效率差好几倍。
坑五:LR 分析表手推对了,程序跑出来结果全错。
现象:按教材手推的分析表逐格核对无误,但程序对1+2*3求值得到 9 而不是 7。
原因:语义动作的执行时机理解错了。bison 的动作在归约时执行,归约发生时右部符号已经全部出栈,$1、$3取的是它们入栈时存下的语义值。如果文法有优先级声明缺失,1+2*3会被归约成(1+2)*3,表本身没错,错的是文法没有告诉解析器乘号优先。
解决:先查 `%left
本文还有配套的精品资源,点击获取