SLR(1)语法制导翻译与中间代码生成实战:从文法到四元式完整指南 简介面向编译原理课程学习者这是一份源自北交大的SLR(1)语法制导翻译与中间代码生成完整课程设计资料包覆盖理论到实现全流程可支撑同类实验、课程设计或毕业设计前期的编译器原型验证。压缩包共11个文件核心为9个Java源文件从文法定义、FIRST/FOLLOW集合求解、SLR(1)分析表构造、DFA状态管理到翻译主控程序一应俱全另含1个tys测试输入文件和1份docx实验报告报告内讲述具体实施步骤、遇到的问题与解决方案便于对照代码逐行排查。整包仅345KB轻量易获取适合快速搭建编译原理实验环境。该资源已有300人学习浏览具备良好参考价值。读者通过研读源码与实验记录可理清SLR(1)冲突消解、语法制导翻译规则和中间代码生成的完整实现脉络并获得一套可直接运行、便于二次开发的课程设计范例对深入理解编译器前端与中间表示均有切实帮助。1. 编译原理课设里最难啃的硬骨头SLR(1)语法制导翻译与中间代码生成北交的编译原理课程设计经常出现这个题目基于SLR(1)分析法的语法制导翻译及中间代码生成。它要的不是一个能跑词法分析的 demo而是把一门小型语言从源码字符流一路处理到四元式中间代码的完整前端。很多同学卡在两步一是 SLR(1) 分析表的构造原理没吃透二是分析器出来之后不知道语义动作怎么挂上去——表能建出来中间代码却全是错的。这篇笔记按我实际做这类课设的经验把文法设计、分析表构造、语义栈同步、四元式生成和常见坑一次讲完适合正在写编译原理实验报告、或者想从零复现一个可运行分析器的人。2. 构造SLR(1)分析器从文法设计到ACTION/GOTO表的完整流程2.1 文法设计为什么第一步直接决定分析器能不能建出来SLR(1) 能处理的文法集合大于 LL(1)但不等于所有 CFG 都能用。设计文法时第一个要注意的是二义性最典型的二义性来源是 if-else 悬空。考虑产生式S - if E then S | if E then S else S | other这个文法是二义的因为if E then if E then S else S中的 else 可以匹配内层或外层的 if。构造项目集时会在某个状态出现移进-规约冲突。解决方式是把文法改造成匹配最近的 ifS - M | I其中M - if E then M else M | otherI - if E then M | if E then I else I。悬挂的 if 被单独拆出来冲突就消掉了。另一个常见问题是运算符优先级和结合性。不要写成E - E E | E * E | (E) | id这种平铺文法否则分析表里全是冲突。标准做法是把优先级分层让和*的优先级、左结合性都由文法结构直接体现SLR(1) 分析表构造出来是干净无冲突的E - E T | T T - T * F | F F - (E) | id注意这里保留了左递归SLR(1) 处理左递归没有问题不需要像 LL(1) 那样消除左递归这是 LR 类分析方法的一个重要优势。设计文法时要顺手考虑语义动作的位置。我一般会把每个产生式右部需要归约的语义动作写在产生式末尾这样自底向上归约时动作时机最自然属性栈操作也最简单。如果语义动作必须放在产生式中间SLR(1) 就要引入特殊标记非终结符或改写文法课设一般用不到能避开尽量避开。2.2 FIRST/FOLLOW集合手工计算容易错直接交给程序分析表构造依赖 FIRST 和 FOLLOW 集合。手工算小文法还行文法一扩到十几个产生式就很容易漏。我习惯先把这两个集合的计算写成函数后面的项目集构造和冲突检查都基于它们。计算 FIRST 集合时要特别注意左递归文法会导致朴素递归函数死循环所以必须用迭代或 visited 标记。def compute_first(grammar): # grammar: {E: [[E,,T], [T]], ...}右部为终结符/非终结符列表 # 空串用 表示 first {nt: set() for nt in grammar} changed True while changed: changed False for A, productions in grammar.items(): for production in productions: for symbol in production: if symbol in grammar: # 非终结符把它的FIRST集合不含空串并进来 old_len len(first[A]) first[A] | first[symbol] - {} if len(first[A]) ! old_len: changed True if not in first[symbol]: break # 该符号不能推导出空串停止向后传播 else: # 终结符直接加入 if symbol not in first[A]: first[A].add(symbol) changed True break else: # 产生式右部所有符号都可空则A可推导出空串 if not in first[A]: first[A].add() changed True return first这个实现的逻辑是不断地把右部首符号的 FIRST 集合向左部传播直到所有集合不再变化。关键点在break的时机遇到终结符就直接终止本轮传播遇到非终结符且其 FIRST 集合不含空串时也终止因为后续符号不会对 FIRST(A) 有贡献。changed标志保证循环收敛不会因为左递归陷入死循环。FOLLOW 集合的计算逻辑类似但要把空串排除并且把 A 的 FOLLOW 集合传播到右部末尾的非终结符上def compute_follow(grammar, start_symbol, first): follow {nt: set() for nt in grammar} follow[start_symbol].add($) # 输入结束符 changed True while changed: changed False for A, productions in grammar.items(): for production in productions: for i, B in enumerate(production): if B not in grammar: continue beta production[i1:] if beta: # 把 FIRST(beta)排除空串加入 FOLLOW(B) before len(follow[B]) for s in beta: if s in grammar: follow[B] | first[s] - {} if not in first[s]: break else: follow[B].add(s) break else: # beta 整体可推导出空串继承 A 的 FOLLOW follow[B] | follow[A] if len(follow[B]) ! before: changed True else: # B 是产生式末尾直接继承 A 的 FOLLOW before len(follow[B]) follow[B] | follow[A] if len(follow[B]) ! before: changed True return followA - alpha B beta有两种情况beta 非空时FOLLOW(B) 包含 FIRST(beta)空串除外如果 beta 整体可空还包含 FOLLOW(A)beta 为空时FOLLOW(B) 直接包含 FOLLOW(A)。这个迭代算法同样用changed收敛对课设规模几十个产生式的文法完全没有性能压力。参数调整建议结束符$一定要加到开始符号的 FOLLOW 集合否则分析表最后一行接受状态会缺项。2.3 项目集规范族与分析表构造核心数据结构和冲突检查项目集是 LR 分析的核心黑匣子。一个项目是一个带点产生式比如E - E . T表示已经分析了E期待看到。项目集由 closure 和 goto 两个函数生成。closure 的规则如果项目A - alpha . B beta在集合中那么对B的每个产生式B - gamma项目B - . gamma也要加入集合。goto 则是在项目集内让点越过符号 X找到能到达的下一个项目集。def closure(items, grammar): result set(items) changed True while changed: changed False for item in list(result): # item 形式: (left, right_tuple)right_tuple 中 . 表示分析位置 dot_pos item[1].index(.) if dot_pos len(item[1]) - 1: continue # 规约项目点在最右 symbol item[1][dot_pos 1] if symbol in grammar: for prod in grammar[symbol]: new_item (symbol, (.,) tuple(prod)) if new_item not in result: result.add(new_item) changed True return resultclosure 的每轮循环都会扫描整个项目集直到新增项目不再出现为止。实现细节dot_pos每次用 index 查找性能不高但课设文法规模小不用纠结如果要做大文法可以改成在项目里直接存点位置避免反复 index。分析表的构造规则有四条对项目集 I 中形如A - alpha . a beta的项目a 是终结符ACTION[I, a] shift状态为 goto(I, a)对项目集 I 中形如A - alpha .的规约项目对每个FOLLOW(A)中的终结符 aACTION[I, a] reduce用产生式A - alpha对项目集中的增广产生式S - S .ACTION[I, $] accept对非终结符 XGOTO[I, X] goto(I, X)。填表时如果发现某个格子已经填了内容又填新内容就是冲突。冲突分移进-规约冲突和规约-规约冲突。SLR(1) 的规约动作依赖 FOLLOW 集合FOLLOW 集合过大时容易引入规约-规约冲突这是 SLR(1) 的先天弱点——解决办法是换 LALR(1) 或 LR(1)但课设一般通过改文法就能消掉冲突。注意项目集构造完先写一个函数全表扫描冲突把冲突的状态, 终结符对全部打出来。这一步能省后面大量的调试时间比对着说明书一行行查表可靠得多。2.4 驱动分析器的循环移进、规约、接受、报错分析表到手后驱动循环本身很简单但栈结构要提前设计好。状态栈和符号栈同步压弹每次根据栈顶状态和当前输入符号查 ACTION 表。下面这段是完整的驱动循环骨架我在课设里就直接用这个结构def parse(tokens, action, goto, grammar, semantic_actions): # tokens 末尾要有 $ 结束符状态栈、符号栈、属性栈三栈同步 states [0] symbols [#] attrs [None] ip 0 while True: state states[-1] token tokens[ip] act action[(state, token)] if act[0] shift: # 移进压入输入符号和新的状态终结符属性就是其字符串值 states.append(act[1]) symbols.append(token) attrs.append(token) ip 1 elif act[0] reduce: # 规约弹出产生式右部对应的栈元素再压入左部非终结符 left, right act[1], act[2] # right 是产生式右部符号列表 n len(right) attr_list attrs[-n:] if n 0 else [] if n 0: del attrs[-n:] del states[-n:] del symbols[-n:] # 空产生式 n0 时不需要弹栈但语义动作仍要执行 new_attr semantic_actions[left](attr_list) g goto[(states[-1], left)] symbols.append(left) attrs.append(new_attr) states.append(g) elif act[0] accept: return attrs[-1]states[-1]是栈顶状态token是当前输入符号用这两者查 ACTION 表。移进时把输入符号压入符号栈、新状态压入状态栈attrs.append(token)让终结符的属性直接使用它的字符串值比如 id 变量名或常量字面量。规约时n len(right)算产生式右部符号数attr_list用切片保留右部从左到右的属性顺序如果n为 0空产生式不弹栈语义动作照常执行这是最容易漏掉的分支。semantic_actions[left](attr_list)返回左部非终结符的综合属性最后查 GOTO 表压入新状态。注意accept动作通常落在$符号上也就是 tokens 的最后一个元素。3. 语法制导翻译把语义动作精确挂到归约时机上3.1 为什么自底向上只能用S属性定义属性文法分 S 属性和 L 属性。S 属性的语义规则只使用产生式右部符号的属性不引用左部祖先或兄弟的属性适合自底向上在归约时求值L 属性依赖左兄弟和祖先属性适合自顶向下递归下降。SLR(1) 是自底向上的归约时能拿到的只有产生式右部各符号的属性所以只能做 S 属性。这也是为什么课设里几乎都选择在产生式末尾挂动作——你无法在归约过程中访问还没分析出来的祖先信息。如果确实需要 L 属性逻辑常见做法是引入标记非终结符marker把继承属性的信息通过产生式左部往下传。课设阶段我建议别用它会让分析表状态数暴涨而且很容易在 marker 的归约时机上出错。对赋值语句、表达式、if/while 这几类典型结构S 属性足够了。3.2 属性栈与符号栈的同步模型语义动作需要访问右部符号的属性。属性栈和符号栈同步压弹移进时终结符的属性比如词法值压入属性栈归约时先弹出右部长度个属性按右部从左到右的顺序整理好再执行语义动作最后把左部的综合属性压回属性栈。def make_semantic_stack(parser): # 属性栈独立于状态栈但操作时机与符号栈严格同步 parser.attr_stack [] def get_rhs_attrs(n): # 弹出产生式右部n个符号的属性返回按右部顺序排列的列表 if n 0: return [] attrs parser.attr_stack[-n:] del parser.attr_stack[-n:] return attrs def set_lhs_attr(attr): # 归约完成后压入左部综合属性 parser.attr_stack.append(attr) return get_rhs_attrs, set_lhs_attrget_rhs_attrs返回的是右部从左到右的属性序列因为切片[-n:]本身保留了栈底的顺序不需要反转。这一点和很多教材里写的“弹出再反转”等价但坑在于写法容易乱如果你用pop()循环取 n 次得到的顺序是右部从右到左必须reverse()用切片则天然是正序。选定一种写法就坚持用下去。这个双栈模型在 C 和 Java 课设里结构是一样的区别只是 Java 的 Stack 里要存对象数组C 用 vector 加 union 或 variant 表示属性。3.3 表达式与赋值语句的语义动作四元式在归约中生成以 2.1 节的表达式文法为例。归约发生时右部属性依次是左部符号序列的属性其中运算符的属性通常没用。语义动作要做的是取对应操作数的属性生成一个临时变量输出形如(, E, T, t1)的四元式临时变量名作为左部非终结符的综合属性压栈。temp_counter [0] quad_list [] def newtemp(): temp_counter[0] 1 return ft{temp_counter[0]} def gen(op, arg1, arg2, result): quad_list.append((op, arg1, arg2, result)) def sem_F_id(rhs_attrs): # F - id返回变量名作为 F 的属性 return rhs_attrs[0] def sem_T_F(rhs_attrs): # T - F属性透传 return rhs_attrs[0] def sem_E_T(rhs_attrs): # E - T属性透传 return rhs_attrs[0] def sem_E_plus_T(rhs_attrs): # E - E Trhs_attrs [E1, , T] e1 rhs_attrs[0] t rhs_attrs[2] t1 newtemp() gen(, e1, t, t1) return t1 # E 的属性是存放结果的临时变量 def sem_T_mul_F(rhs_attrs): # T - T * Frhs_attrs [T1, *, F] t1 rhs_attrs[0] f rhs_attrs[2] t2 newtemp() gen(*, t1, f, t2) return t2这个设计里非终结符的属性统一表示“该非终结符对应表达式的计算结果存放位置”对 id 属性就是变量名本身对复合表达式属性就是临时变量名。后面生成赋值语句的四元式(:, t1, , x)时直接把 E 的属性作为 source 即可。newtemp用计数器生成 t1、t2 这样的名字课设阶段不用回收临时变量虽然会浪费一些中间变量但对验证正确性没有影响。3.4 if-else和while的回填控制流四元式要晚一步填地址表达式翻译是线性生成的控制流却要回头填坑。以if E then S1 else S2为例翻译 E 时会生成一条条件跳转四元式但此时 S1 和 S2 的位置还没开始翻译跳转目标未知。标准做法是回填backpatching先把跳转四元式的目标位置空着记录它的四元式序号等 S2 翻译完再回头把真实序号填进去。# 以 if E then S1 else S2 为例这里给的是核心回填逻辑 def sem_if_then_else(rhs_attrs): # rhs_attrs [if, E, then, S1, else, S2] # E 翻译时生成了 jnz E, L1 和 jmp L2但 L1/L2 是占位符 jnz_index rhs_attrs[1][true_list][0] # jnz 所在四元式序号 jmp_index rhs_attrs[1][false_list][0] # jmp 所在四元式序号 next_instr len(quad_list) # S1 翻译完后的下一条四元式序号 # 回填 true_list把 jnz 的目标改成 S1 之后的位置 op, arg1, arg2, _ quad_list[jnz_index] quad_list[jnz_index] (op, arg1, arg2, next_instr) return {next_list: []}回填是课设里最容易翻车的地方漏填一个 list、填了错误的四元式序号、或者回填用的 next_instr 取值时机不对都会让生成的四元式跳转乱套。我的经验是先把整个控制流翻译的四元式骨架在本子上画出来标好每个跳转指令的产生时机和回填时机再动手写语义动作。pending_lists在正式实现里应该作为属性的一部分随综合属性向上传递而不是用全局变量否则嵌套 if-else 会串。4. 中间代码生成四元式的数据结构、符号表与完整输出4.1 为什么四元式是课设的主流选择常见中间表示有三种逆波兰式、三元式、四元式。逆波兰式没有运算符优先级问题但不适合做优化跳转信息也不好表达三元式的每个运算结果用三元式序号引用一旦插入或删除一个三元式所有引用序号都要改调试很难受。四元式用(op, arg1, arg2, result)四个字段固定描述一条运算修改某一条不影响其他条跳转四元式用空字段占位再加回填是课设和很多编译器前端的主流选择。中间表示结构优点缺点逆波兰式操作数栈表达生成最简单跳转/优化困难三元式(op, arg1, arg2)省一个字段序号引用脆弱四元式(op, arg1, arg2, result)结构清晰、可回填多一个字段占内存表里的对比是我实际做完课设后的感受四元式虽然多一个字段但这第四个字段在回填跳转目标时极其好用jmp 指令没有常规 arg2把目标序号放在 result 字段里即可。内存开销在课设级文法上完全不是问题。4.2 四元式的结构定义与gen函数我把四元式定义成 Python 元组方便调试时直接打印。op 字段是操作码arg1 和 arg2 是操作数result 是结果位置。操作数有三种变量名来自符号表、常量字面量、临时变量名t1、t2。跳转类四元式jmp、jnz用 arg1 放条件result 放跳转目标赋值四元式:用 arg1 放源值result 放目标变量。# 四元式(, a, b, t1) # 赋值(:, t1, , x) # 条件跳转(jnz, t0, , L1) # arg1 为真时跳转 # 无条件跳转(jmp, , , L2) # result 为目标四元式序号 def print_quads(quads): for i, q in enumerate(quads): op, a1, a2, r q if op jmp: print(f{i}: {op} {r}) elif op jnz: print(f{i}: {op} {a1}, {r}) else: print(f{i}: ({op}, {a1}, {a2}, {r}))打印格式是按课设报告常见的“序号四元式”风格来的。调试时我强烈建议加这个打印函数并且把每个四元式前的序号看成隐含标号这样回填的目标一眼就能对得上。如果说明书要求输出三地址码格式把括号去掉、用逗号分隔打印即可本质一样。4.3 符号表从名字到地址的映射符号表至少需要记录变量名、类型如果有类型检查、分配的临时地址或编号。课设里经常用偏移量表示变量存储位置比如从 0 开始每个变量占一个单位class SymbolTable: def __init__(self): self.table {} self.offset 0 def lookup(self, name): return self.table.get(name) def insert(self, name, typeint): if name not in self.table: self.table[name] {name: name, type: type, offset: self.offset} self.offset 1 return self.table[name]offset每次加 1代表变量在运行时存储区的相对位置。如果要处理作用域比如块结构最简做法是每进入一个{}压一个符号表栈退出时弹出查找时从栈顶往下查。但很多课设要求里根本没有作用域全局单表就够了——不要在不需要的地方堆复杂度。符号表和四元式生成是两个独立模块语义动作只往符号表插入变量、从符号表查地址不直接操作表内部结构。4.4 一个可运行的最小翻译流程从源码到四元式的完整链路课设包的源码拿到手第一件事不是找 main 函数而是先定位三个模块的分界线词法分析器、分析表构造、语义动作表。词法分析负责把源码字符串变成 token 序列语法分析只看 token 类型语义动作用 token 的值。这样如果说明书要求扩展词法比如加注释、加关键字语法部分不用动。def compile_source(source, scanner, parser): tokens scanner.scan(source) quad_list.clear() parser.parse(tokens) print(符号表:) for name, entry in symbol_table.table.items(): print(f {name}: {entry[type]}, offset{entry[offset]}) print(四元式序列:) print_quads(quad_list)compile_source就是整个课设的入口函数。quad_list、symbol_table在单次编译中可以用模块级单例因为语义动作函数分散在各处共用一组表最省事。token 序列末尾一定要放$这是驱动循环 accept 的触发条件忘了它分析器会越界读 token。5. SLR(1)实验避坑记录冲突、栈顺序与回填时机这一章写我在复现这类课设时实际踩过的坑每个都按现象、原因、解决整理。分析表构造的算法本身在教材里写得很清楚真正耗时间的全是这些边界细节。5.1 分析表出现移进-规约冲突集中在if-else现象填 ACTION 表时某个(state, token)格子第一次填了 shift第二次又填 reduce程序报警。位置通常在包含 if 的产生式对应的状态里。原因文法有二义性最常见是悬空 else。SLR(1) 用 FOLLOW 集合决定规约动作而 else 在 FOLLOW 集合里导致看到 else 时既想移进匹配内层 if 的 else又想规约先完成外层 if 的动作。解决改写文法把悬挂的 if 单独处理S - M | I的拆分方式或者用优先级声明让 else 都归最近的 if。课设里推荐改文法因为说明书一般要求 SLR(1) 分析表无冲突光写“用优先级解决”在报告里不好交代。5.2 归约时属性栈弹出顺序反了导致操作数颠倒现象生成的四元式把操作数对调源程序x a b生成了 b a t1而不是 a b t1。原因归约时从属性栈弹出的第一个属性是产生式右部最右边的符号如果直接按弹出顺序当右部从左到右的属性用就反了。解决两种写法选一种并坚持。方法一是attr_list attrs[-n:]取切片保留原顺序方法二是循环 pop 之后 reverse。建议所有语义动作统一从attr_list取属性不要直接碰属性栈。5.3 空串产生式导致状态栈和属性栈不同步现象遇到A - 这种空产生式归约时栈没弹出任何元素但语义动作执行了之后查 GOTO 表的状态索引对不上。原因空串产生式右部长度是 0驱动循环里如果写死了“弹出 n 个元素再压入左部”n0 时没有弹任何状态但左部还是要压栈、GOTO 还是要查这个两步逻辑没分开处理。解决驱动循环里把“弹出右部符号”和“压入左部符号”写成两个独立步骤弹出步骤根据 n 是否为 0 跳过压入步骤无条件执行。空串的语义动作要在压入左部属性之前执行返回值作为左部属性。5.4 回填跳转目标时next_instr取错时机现象if 语句生成的 jmp 四元式目标指向了 if 自身死循环或者跳过了 else 块。原因用len(quad_list)取下一指令位置作为回填目标但这个取值时机如果发生在 S1 翻译之前取到的是 jnz 自己的位置必须在 S1 翻译完之后再取。解决语义动作层面保证时序先翻译 E生成 jnz/jmp再翻译 S1S1 翻译完之后取len(quad_list)作为回填值此时 S1 的四元式都已经入表值才是正确的。建议把回填目标的取值统一封装成next_instr()函数所有语义动作都走它避免裸用len(quad_list)。5.5 调试手段把分析栈和四元式表每一步都打出来现象程序报错或者生成乱码四元式但不知道从哪一步开始出错。原因分析器是个状态机中间状态不可见出错时只能看到最终错误难以定位。解决在驱动循环里加一个调试开关每处理一个 token 就打印当前状态栈、符号栈、剩余输入串和最近生成的四元式。debug True def log_step(states, symbols, ip, tokens, quads, last_n3): if not debug: return print(f状态栈: {states}) print(f符号栈: {symbols}) print(f剩余输入: {tokens[ip:]}) print(f最近四元式: {quads[-last_n:]})这些日志拨开黑匣子状态栈让你看到当前在哪个 LR 状态符号栈告诉你已经分析了哪些文法符号剩余输入告诉你驱动指针在哪四元式切片告诉你语义动作到底产出过什么。出错时拿第一次行为异常的日志做对比通常五分钟就能定位是分析表错、语义动作错还是回填错。6. 把课设做成能演示的完整体四元式解释器验证与扩展方向光生成四元式没法直观证明它正确——你看到的只是一串(, a, b, t1)但你敢保证a b * c生成的顺序符合优先级最有效的验证办法是给四元式写一个极简解释器直接执行它拿已知答案的程序跑一遍对结果。解释器只有几十行但让说明书里多一张“测试用例与运行结果”的表比干贴四元式有说服力。def interpret(quads, sym_values): # sym_values: {a: 1, b: 2, c: 3, ...} temps {} pc 0 while pc len(quads): op, a1, a2, r quads[pc] if op : temps[r] sym_values[a1] sym_values[a2] elif op -: temps[r] sym_values[a1] - sym_values[a2] elif op *: temps[r] sym_values[a1] * sym_values[a2] elif op /: temps[r] sym_values[a1] // sym_values[a2] elif op :: sym_values[r] temps[a1] elif op jmp: pc r - 1 elif op jnz: if temps[a1] ! 0: pc r - 1 pc 1 return sym_values解释器按四元式序号顺序执行临时变量存在 temps 字典里用户变量在 sym_values 里。pc r - 1是因为循环末尾会pc 1所以跳转目标为 r 时要把 pc 先置成 r-1。这个细节容易错写完先用一个最简 if-else 程序验证跳转。测试程序从低到高排纯表达式求值覆盖优先级赋值和表达式混合覆盖临时变量链if-else 和 while 覆盖回填嵌套控制流覆盖回填嵌套。每组都要预先算好正确答案解释器跑完直接对比把四元式序列和输出结果一起截图放进说明书。还有余力的话建议做常量折叠在 gen 函数里检查两个操作数是否都是常量是就直接算出结果不生成四元式。代码量最小但四元式数量明显变少报告里容易写对比。选这条路之前先把测试用例全部跑通每加一个功能就全量回归一遍。我个人的习惯是把调试开关和解释器放同一个 main 里改完语义动作立刻跑测试不用等最后一起验证。这类课设最怕中途推翻重来——分析表、语义动作、回填互相牵连。希望这个从文法到四元式的落地过程能帮到你少走我走过的弯路。本文还有配套的精品资源点击获取