简介:面向编译原理课程实验与期末报告的参考文档,内容围绕“无符号数词法分析程序”展开,包含实验目的、文法规则、程序流程图、Java实现代码与运行结果分析。文档以无符号数识别为主线,系统梳理词法分析的基本思想、无符号整数与实数的文法定义、比例因子处理逻辑等关键知识点,适合计算机相关专业学生在完成编译原理实验或复习词法分析模块时对照参考。资源为单个PDF文件,大小约201KB,便于下载后直接阅读或打印。目前已有1562人浏览学习,适用于从入门到进阶的编译原理学习者。通过该文档可掌握词法分析程序从规则设计到编码实现的全过程,理解输入字符流识别、数字与小数点及指数部分的判别思路,同时可直接借鉴实验报告的结构组织与代码写法,提高实验报告撰写效率。
1. 先说一个反直觉的结论:编译原理不是“背”出来的,是“写”出来的
不少人在拿到《编译原理-太原理工大学[参照].pdf》这份资料后,第一反应是把它当作考前突击的背诵手册——词法分析、语法分析、语义分析、中间代码生成,每章背几个概念,做几道清华大学出版社第三版的课后题,就觉得这门课稳了。等到实验课要自己写一个词法分析器,或者给一个微型语言做递归下降解析,才发现课本上的定义和工程能跑通的代码之间,隔着一条巨大的鸿沟。
这里的核心问题在于:编译原理是一门“理论先行、实践验证”的课,文法和自动机是工具,不是目的。你真正要掌握的能力是——拿到一段字符串,能设计出有限自动机去识别它;拿到一个文法,能构造出预测分析表去解析它;拿到一个表达式,能生成三地址码去描述它。太原理工大学的这份参照PDF之所以叫“参照”,就是因为它是教学的纲领性文件,告诉你这门课覆盖哪些知识点、实验要求达到什么程度,但它不会替你做实验。
这篇笔记的任务,就是顺着这份PDF的骨架,把它涉及的理论点逐个落地成能复现的操作步骤:该学什么、该怎么练、实验怎么做、参数怎么调、坑在哪里。不管你是刚开课的大三学生,还是准备期末突击的考研党,按下面的路线走一遍,比你把PDF翻三遍都管用。
2. 这份“参照PDF”到底在讲什么:从词法到代码生成的完整骨架
2.1 词法分析:正规式、NFA/DFA与最小化,把“字符串”变成“单词”
词法分析是编译器的第一个阶段,也是大多数实验课的起点。它的任务简单到可以用一句话描述:读入源程序的字符流,识别出一个个具有独立含义的单词符号(关键字、标识符、常数、运算符、界符),并把它们归类、编码、输出。
但这句话背后藏着三个必须动手才算掌握的知识点:
第一个是正规式到NFA的转换。正规式是描述单词结构的工具,比穷举字符串集合优雅得多。比如标识符的正规式是letter(letter|digit)*,你要能画出对应的NFA状态图,理解*意味着可以回到前一状态继续读入字符。实际做实验时,绝大多数人不是不会画图,而是不会把图画成状态转移表——二维数组的行是状态,列是输入符号,值是下一状态。这一步跨不过去,后面写代码无从谈起。
第二个是NFA到DFA的确定化,即子集构造法。NFA允许一个状态在某输入符号下转移到多个状态,这在工程实现里没法直接用,必须把状态的集合作为DFA的单个状态。算法核心是求ε-closure(空串闭包)和move(读入符号后的可达状态集合)。做这一步实验时建议先用小例子手算,比如识别a(a|b)*b的NFA,画出完整的子集构造过程,再对比课本答案。手算三遍以上,你对“状态集合”这个概念才会有肌肉记忆。
第三个是DFA的最小化,即分割法。课本上的算法是先把状态分为接受状态和非接受状态两组,然后反复检查每个状态组在某个输入符号下是否转移到同一组,若不一致则分割。这个算法的执行过程非常机械,非常适合用代码实现,也适合出考题——太原理工大学的期末卷子喜欢考“最小化前后状态数对比”,本质上是在考察你对等价状态的理解。
写词法分析器时有一个容易被忽略的工程细节:最长匹配原则。比如输入ifx,如果词法规则里既有关键字if,又有标识符规则,那么ifx应该被识别为标识符而不是关键字if后面跟着x。实现上,每次识别单词时,扫描指针要一直向前推进到无法继续匹配为止,记录最后一个接受状态的位置,然后回退到这个位置。这个处理不到位,后面所有语法阶段都会跟着错。
2.2 语法分析:从上下文无关文法到LL(1)与LR(1),为什么它是最难啃的一章
语法分析是编译原理的核心章节,也是最劝退的章节。它的任务是根据词法分析产出的单词序列,按照文法规则判断句子是否符合语法,并构建语法树。这章的知识密度极高:上下文无关文法、最左推导与最右推导、二义性、LL(1)文法、递归下降分析、LR(0)/SLR(1)/LR(1)/LALR(1)分析表、移进-归约冲突与展望信息。
很多人在这一章翻车,原因是学习方法错了——试图直接背诵各种复杂文法和分析表的构造步骤,而不理解它们是在解决什么问题。
先看自顶向下分析。LL(1)的意思是:从左到右扫描输入(L),最左推导(L),向前看一个符号(1)即可确定用哪个产生式。要构造LL(1)分析表,必须会算三个集合:FIRST、FOLLOW、SELECT。这三个集合是死套路,但太原理工大学的作业题喜欢出“给定文法,计算FIRST和FOLLOW集”,因为算得对不对直接暴露你是否理解了推导过程。算FIRST时要注意:如果产生式右部以终结符开头,直接加入;如果以非终结符开头,递归加入该非终结符的FIRST;如果非终结符能推导出空串,还要继续看下一个符号。FOLLOW的起点是开始符号的FOLLOW里要加入结束符#,这个细节十个人里有三个会漏。
再看自底向上分析。LR(1) 系列的难点在于构造项目集规范族。一个项目是文法产生式带上圆点标记,比如E → E·+T表示已经识别了E,期望看到+。构造项目集时,闭包计算和转移函数的两步操作必须完全机械化。做实验时我一般建议用表格记录:左边是项目集编号,中间是读入符号后的转移,右边是该项目集包含的所有项目。表格一旦分层画清楚,基本不会乱。
这章的真正难点是两类冲突的解决。移进-归约冲突(shift/reduce conflict)和归约-归约冲突(reduce/reduce conflict)是SLR(1)分析表构造失败的根本原因。考试和实验里常见的操作是:给你一个有冲突的文法,要求你通过添加展望信息改造成LR(1)文法,或者直接判定它不是任何LR类文法。做判断时最快的方法不是画完整分析表,而是看文法里是否存在左递归或公共左因子——这两种结构在自底向上分析中极易引发歧义。
2.3 语法制导翻译与中间代码:三地址码和符号表,编译器的“胶水层”
语法分析只解决“句子对不对”的问题,接下来要把“对”的句子翻译成中间表示,这就是语法制导翻译要做的事。这一章的实践性极强,也是真正区分“背过书”和“懂编译”的分水岭。
核心概念是语法制导定义(SDD)和语法制导翻译方案(SDT)。SDD 是文法产生式关联属性规则的集合,属性分为综合属性和继承属性。综合属性在自底向上分析中自然计算——归约时从子节点求父节点;继承属性在自顶向下分析中更自然——推导时从父节点传给子节点。考试和实验里最常见的题目是:对表达式2 * 3 + 4,写出带属性注释的分析树。动手做一遍,你才会明白为什么E → E+T的E.val = E1.val + T.val这条规则必须写在归约动作里。
三地址码是中间代码最常用的形式。每条三地址码最多包含三个运算分量(两个操作数,一个结果),例如t1 = 2 * 3和t2 = t1 + 4。翻译表达式到三地址码的关键是为临时变量编号,这需要在语法制导动作里维护一个变量计数器。实验时最常见的错误是忘记按运算优先级生成临时变量,导致生成的代码顺序与原表达式语义不一致。检验方法很简单:把生成的三地址码用简单的后续代码模拟执行一遍,对比结果就知道有没有错。
符号表的管理在这一章开始变得重要。符号表不是“一个数组存变量名”那么简单,它需要管理作用域——内层作用域的变量声明不能污染外层,同名变量在不同作用域内指向不同条目。常见实现是作用域栈(scope stack):进入一个块时压栈一个新表,退出时弹栈。查名字时从栈顶向下逐个表查找。TS说过“变量声明全串了”的坑,九成是作用域栈的入栈出栈时机设错了。
用太原理工大学的真题风格来检测你的掌握程度:给定一段 C 语言片段int a; { int a; a = 1; } a = 2;,画出符号表的入栈出栈过程,以及每次赋值时a对应哪个符号表条目。这个题能全对,符号表这块就算过关了。
2.4 代码优化与目标代码生成:PDF最后几章为什么决定你的上限
前几章是编译器的“主体骨架”,但如果这门课只讲前半部分,它本质上还是“翻译器”而不是“编译器”。代码优化和目标代码生成,才是让编译器产生高质量目标代码的临门一脚。
代码优化的常规手段包括:常量折叠(2 + 3直接算成5)、公共子表达式消除(同一表达式计算两次,只算一次并存临时变量)、复写传播(a = b; …用a的地方直接换成b)、死代码消除(计算结果不被引用的语句删除)。这些优化在基本块内做叫局部优化,在基本块之间做叫全局优化。考试里最常见的题型是:对给定的三地址码序列,找出基本块,画出流图,标出公共子表达式,然后写出优化后的三地址码序列。
做这类题有一个定型化的步骤:先把三地址码按基本块划分(领头语句是某个跳转目标的语句或紧跟在跳转后的语句),然后在每个基本块内扫描每条语句,查该运算是否已存在(两个操作数和运算符完全相同)。这个扫描可以用一个哈希表实现,键是(运算符, 左操作数, 右操作数),值是最新一次赋值的临时变量。实现完记得做考题反推:优化前后的语句数对比不超过20%,说明你把优化做的太保守了;超过50%,要检查是否错误地消除了一个有副作用的函数调用。
目标代码生成这章,核心是寄存器分配和指令选择。太原理工大学的实验不会要求你生成真实的汇编代码,但会要求你掌握简单的寄存器分配算法——比如基于活跃性分析的最简单策略:给每个基本块维护活跃变量集合,优先把不活跃变量的寄存器让给新变量。这里的“活跃性分析”是反向数据流分析,从基本块末尾向前扫描,变量被引用则标记活跃,被定义则赋值为不活跃。反向扫描很多人第一次做会搞反方向,记住口诀:活跃性看“引用在前、定义在后”。
PDF的最后部分如果提到中间代码的树结构表示和有向无环图(DAG),别跳过。DAG 是压缩局部冗余的好工具,同一个表达式子树只构建一次。笔试中画表达式a + b * (a + b)的 DAG 是经典送分题,但很多人在合并公共子表达式时把结点标号搞错,白白丢分。做法是把每个运算结点用(运算符, 左子编号, 右子编号)三元组做键,建表查重,重复的引用同一个编号。
3. 把PDF变成能力:一份按周执行的“预习→复现→实验”路线
3.1 先定骨架:这份PDF对应什么教材和章节重点
太原理工大学的编译原理课程通常配套清华大学出版社第三版教材(就是热词里反复出现的《编译原理清华大学出版社第三版》),这份PDF作为教学参照,章节顺序大体是:引论→词法分析→语法分析→语法制导翻译→中间代码生成→代码优化→目标代码生成。拿到PDF后,第一件事不是从头看,而是先看它的目录和每一章的“学习要求”。这些要求往往直接回应考试范围和实验深度。
举例来说,如果某一章的参照要求写着“掌握正规式、有限自动机及其相互转换”,那么考试大概率会出一道10到15分的构造题,实验大概率要求你用程序实现一个DFA。如果写着“了解全局优化”,那么考试只会出概念题或选择题,不必去死磕复杂的算法细节。
我的建议是:用一张纸记录章节映射表。左边列PDF的章节标题,右边列教材对应章节和课后题号,再标一列“考试权重(高/中/低)”和“实验关联(有/无)”。做完这个动作,你对课程重心会有一个全局判断,不会再把时间平均分配。
3.2 用Python写一个最小词法分析器:跟着PDF第二章的算法复现DFA
有了理论地基,就可以进入动手环节了。词法分析器是第一个值得完整实现的实验,不建议直接用Lex或Flex工具一把梭,而是先用普通代码结合“状态转移表”实现一次,因为你只有手动实现过一次DFA模拟器,才能真正看懂工具生成的代码在做什么。
下面是一个支持识别标识符、无符号整数、运算符和界符的最小词法分析器骨架,用纯 Python 实现,模拟的是 DFA 的状态转移过程:
import re # 状态转移表:行 = 当前状态,列 = 字符类别,值 = 下一状态 # 字符类别:0=字母,1=数字,2=运算符,3=界符,4=空格/换行,5=其他(非法) # 状态含义:0=初始,1=标识符,2=整数,3=运算符,4=结束,-1=出错 TRANS = [ # 0 1 2 3 4 5 [ 1, 2, 3, 0, 0, -1], # 状态0 [ 1, 1, -1, -1, -1, -1], # 状态1(标识符态,数字也可以继续) [ -1, 2, -1, -1, -1, -1], # 状态2(整数态) [ -1, -1, -1, -1, -1, -1], # 状态3(运算符态,这里简单处理为单字符) ] # 接受的终止状态:1=标识符,2=整数,3=运算符 ACCEPT = {1: "IDENT", 2: "INT", 3: "OP"} def classify_char(c): if c.isalpha() or c == '_': return 0 if c.isdigit(): return 1 if c in '+-*/=<>!': return 2 if c in '(){};,[]': return 3 if c in ' \t\n': return 4 return 5 def tokenize(source): tokens = [] i = 0 n = len(source) while i < n: # 跳过空白 if classify_char(source[i]) == 4: i += 1 continue state = 0 last_accept = (None, None) # (位置, 状态) j = i while j < n: ctype = classify_char(source[j]) if state in TRANS and 0 <= ctype < len(TRANS[state]): state = TRANS[state][ctype] else: state = -1 if state in ACCEPT: last_accept = (j + 1, state) elif state == -1: break j += 1 if last_accept[0] is None: # 无法识别,跳过该字符(实际编译器必须报错) i += 1 continue end_pos, acc_state = last_accept lexeme = source[i:end_pos] tokens.append((ACCEPT[acc_state], lexeme)) i = end_pos return tokens if __name__ == "__main__": src = "x = 10 + abc;" for tok in tokenize(src): print(tok)这段代码最核心的是两个机制:状态转移表驱动和最长匹配+回退。转移表决定了一个状态在读入某类字符后跳到哪个状态,所有的识别逻辑都浓缩在数组里,而不是散落在 if-else 中;最长匹配体现在last_accept变量——每当进入接受状态就记录下来,扫描继续向前,直到走到死路(-1状态)才停止,最终回退到最后一个接受位置。这意味着abc123会被整体识别为一个标识符,而不是先识别abc再识别123。
写完后一定要跑这几个测试用例:ifx(验证关键字与标识符的最长匹配)、x1=2(验证标识符后的数字)、a+-b(验证连续运算符是否能正确切分)。教材配套的第三章课后题里有一道“将以下正规式转换为DFA”的题,建议转换成转移表后用这个模拟器验证识别结果是否一致。转移表本身就是DFA的另一种表达形式,能把转移表和状态图画到一一对应,这一章的基础就真的打牢了。
3.3 手写递归下降分析器:把LL(1)变成能跑的代码
词法分析器产出的 token 流要交给语法分析器。这里给出一个最易上手的递归下降分析器示例,它能解析一个包含变量声明和赋值语句的极简语言。递归下降的本质:每个非终结符对应一个函数,函数体里按产生式右部的顺序调用其他函数或匹配终结符,遇到冲突时尝试回溯。
以下代码实现了下面这个极简文法的部分功能:
program → stmt_list stmt_list → stmt { stmt } stmt → assign assign → ID = expr ; expr → term { (+|-) term } term → factor { (*|/) factor } factor → ID | NUM | ( expr )class RecursiveDescentParser: def __init__(self, tokens): self.tokens = tokens self.pos = 0 self.errors = [] def peek(self): return self.tokens[self.pos] if self.pos < len(self.tokens) else None def match(self, expected_type): token = self.peek() if token and token[0] == expected_type: self.pos += 1 return token else: self.errors.append(f"位置{self.pos}: 期望 {expected_type}, 实际 {token}") return None def parse_program(self): while self.pos < len(self.tokens): self.parse_stmt() if self.errors: print("解析失败") for e in self.errors: print(e) else: print("解析成功") def parse_stmt(self): self.parse_assign() def parse_assign(self): var_tok = self.match("IDENT") if var_tok: op_tok = self.match("OP") if op_tok and op_tok[1] == '=': self.parse_expr() self.match("DELIM") # 分号 def parse_expr(self): self.parse_term() while self.peek() and self.peek()[0] == "OP" and self.peek()[1] in ('+', '-'): self.pos += 1 self.parse_term() def parse_term(self): self.parse_factor() while self.peek() and self.peek()[0] == "OP" and self.peek()[1] in ('*', '/'): self.pos += 1 self.parse_factor() def parse_factor(self): token = self.peek() if token and token[0] in ("IDENT", "INT"): self.pos += 1 elif token and token[0] == "DELIM" and token[1] == '(': self.pos += 1 self.parse_expr() self.match("DELIM") # ) else: self.errors.append(f"位置{self.pos}: 无法解析的因子 {token}") if __name__ == "__main__": tokens = [ ("IDENT", "a"), ("OP", "="), ("INT", "10"), ("DELIM", ";"), ("IDENT", "b"), ("OP", "="), ("IDENT", "a"), ("OP", "+"), ("INT", "2"), ("DELIM", ";") ] parser = RecursiveDescentParser(tokens) parser.parse_program()这段代码最值得注意的地方是while循环处理左递归的消除。原文法中expr → expr + term是左递归,直接写成parse_expr里先调用parse_expr会无限递归;改成expr → term {(+|-) term}后,parse_expr先解析一个term,然后用while循环反复判断下一个 token 是否+/-,如果是就继续解析下一个term。这种写法对应了理论课上的“消除左递归”操作,把左递归变成了右递归或迭代。
测试用例建议覆盖三种情况:正常赋值语句、两个运算符连写(如a = b + * c,期望报错)、括号不匹配。递归下降分析器有个天然的弱点:它无法处理需要大范围回溯的语法结构,所以如果你的文法有两个产生式都以同一个终结符开头且第二个符号不同,必须保证向前看一个 token 足够决策。这就是教材里“LL(1)文法”这个限定条件的实践含义。
对这个实验,重点检查你的match函数是否在错误发生时推进了pos。很多初版代码在误匹配后不推进位置,导致死循环或错误定位。我的做法是:匹配失败时也强制前进一个 token,避免死循环,错误信息保留给用户,这样不会无限循环。
3.4 实验报告怎么写才能不返工:测试用例与异常分支
太原理工大学的编译原理实验通常要求交付:源码、可运行程序、测试用例和实验报告。大多数人把时间花在源码上,报告草草了事,结果被退回重写。实际上报告的比重远超你想象——老师要从报告里判断你是真理解还是抄的代码。
报告的核心内容应该是三块:文法与设计、处理流程、测试结果与分析。文法与设计部分要写出你实现的语言的上下文无关文法,并说明它为什么是LL(1)或LR(1)的;测试用例部分不能只贴输入输出,要解释每个用例覆盖了哪个边界条件,比如a=1+2*3;验证运算符优先级,a=(1+2)*3;验证括号处理,a=;验证错误恢复。这个解释过程是老师判断你是否真正会做语法分析实验的关键,也是期末笔试“写出分析过程”题的预演。
异常分支的处理也值得单列一节。你的词法分析器遇到非法字符(如@和#)是报错还是跳过?语法分析器遇到不可匹配的 token 是立即终止还是尽可能恢复到同步点?描述清楚这些决策,比贴一大堆代码更能体现工程能力。如果老师要求用 Flex/Bison 工具或有更复杂的 C 语言子集实现,把上一小节的 Python 代码翻译过去并不难,核心的状态转移表、token 流和递归下降函数结构完全不变。
4. 编译原理常见翻车现场:五个高频踩坑与排查思路
4.1 词法分析器“多吞字符”:最长匹配与回退没处理好
现象:输入if(x>0),识别出来的第一个 token 居然是if(x而不是if加(。原因:状态转移表中标识符状态允许括号字符也跳转,或者你的识别循环没有记录最后一次接受状态位置,一路走到死路才停止,把后续字符都吞进了同一个 token。
解决:把字符分类做严,括号、分号等界符属于独立类别,标识符状态遇到界符必须回退。同时加last_accept机制,记录读到过的最后一个接受状态的位置和状态编号,在遇到不可转移的字符时回退到那个位置。排查时在识别循环里打印每个字符进入的状态和last_accept值,一眼能看到吞到哪了。
4.2 FIRST/FOLLOW集算错导致LL(1)分析表冲突
现象:用教材上的经典文法计算FIRST和FOLLOW,手算没问题,但写程序算出来的分析表里有冲突项。
原因:多数人的算法漏了“产生式右部多个符号串”的传播。例如A → B C,C的FIRST能否加入A的FIRST,取决于B是否可推导出空串;而FOLLOW的传播则需要多重迭代,直到所有集合不再变化为止。用程序实现时,循环终止条件是“本轮没有任何集合发生变化”,而不是“按产生式序号扫了一遍”。
解决:把FIRST和FOLLOW的计算独立成两个函数,FOLLOW里用while changed: changed = False反复迭代,每次传播都标记集合是否扩张。测试时用教材第二章和第三章的课后题答案做对照,特别是涉及空串产生的ε产生式的文法,最容易遗漏。
4.3 LR(1)项目集规范族画到一半就乱了
现象:手工构造 LR(1) 分析表的作业,项目集画到第6个就开始丢项目或重复编号,导致后面的 ACTION/GOTO 表完全对不上。
原因:项目集的闭包计算里,“展望符号”的传播比大多数人想象的复杂。A → α·Bβ, a中B对应的产生式项目的展望不是a,而是FIRST(βa)——这里是整串加展望的FIRST,不是单个符号。很多人把FIRST(β)和a混在一起算,出错也不奇怪。
解决:画项目集时,用一个表格先列出每个项目集中产生式编号、圆点位置、展望符号、来源项目集和来源符号,再画转移边。每个项目集都套这个模板,画完立刻核对“每个状态的所有项目展望符号总和是否等于课本对应的项目集”,不一致就当场纠正,不等画完再查。
4.4 符号表作用域处理不对,变量声明全串了
现象:测试程序中有同名变量a,外层赋值后进入内层块再给a赋值,退出后外层的a值变成了内层的值。
原因:符号表实现时没有用作用域栈,或者变量入表的时机不对——声明和定义的入表时机不同。在某些实现里,只要变量名出现在代码中就在当前顶层表中插入条目,导致内层变量直接覆盖外层条目。
解决:改用嵌套符号表或作用域栈。每次遇到{时向栈中压入一张新表,遇到}时弹出。查询时从栈顶向下逐表查找,找到即返回;插入时只插入当前栈顶表。在实验测试里专门设计一段嵌套作用域代码,把每个作用域中变量的对应表条目编号打印出来,比对结果即可快速定位。
4.5 实验报告只贴代码不贴测试,答辩被问到怀疑人生
现象:代码能跑通几个示例,报告里没有测试用例设计和结果分析。答辩时老师随便换一个输入,程序直接出错答不上来。
原因:测试用例只是“能跑就行”,没有覆盖边界条件,也没有思考过哪些输入会导致状态机走入非接受状态、哪些 token 序列会让递归下降分析器报错。
解决:实验报告固定分为三部分:核心代码片段(不要全文粘贴)、测试用例表(输入、期望输出、实际输出、覆盖的知识点)、异常输入处理说明。写测试用例时至少覆盖五类:正常语句、运算符优先级、括号嵌套、错误输入(缺分号、括号不匹配、非法字符)、最长匹配测试。答辩时老师问什么,直接翻到这个表,每条用例都能自圆其说。
5. 进阶:从“会考试”到“真看懂”——用一个月做一个能编译自己语言的最小编译器
如果前面的实验都顺利通过,你大概率已经掌握了“翻译”的部分。但要真正理解编译原理,还需要一个综合性的收尾项目:为一种自定义的极简语言写一个包含词法分析、语法分析、中间代码生成的最小编译器。这个项目不需要产生真实汇编,产出三地址码即可。
我建议的语言设计如下:支持整数变量声明(let a = 10;)、赋值语句(a = a + 1;)、while循环(while (a < 10) { a = a + 1; })。语法分析部分用递归下降实现,中间代码生成采用语法制导翻译——在语法分析的函数中直接调用emit函数输出三地址码。维护一个全局的临时变量计数器,遇到每个运算生成新的临时变量编号。
下面给出中间代码生成的骨架:
class CodeGen: def __init__(self): self.temp_count = 0 self.label_count = 0 self.codes = [] def new_temp(self): self.temp_count += 1 return f"t{self.temp_count}" def new_label(self): self.label_count += 1 return f"L{self.label_count}" def emit(self, op, arg1=None, arg2=None, result=None): self.codes.append((op, arg1, arg2, result)) def gen_arith(self, op, left_val, right_val): temp = self.new_temp() self.emit(op, left_val, right_val, temp) return temp # 生成 while 循环的三地址码 # 形式: # L1: if a < 10 goto L2 # goto L3 # L2: a = a + 1 # goto L1 # L3: def gen_while(self, cond_func, body_func): L1 = self.new_label() L2 = self.new_label() L3 = self.new_label() self.codes.append(("LABEL", L1, None, None)) # 假设 cond_func 返回(左值,关系运算符,右值) left, op, right = cond_func() self.emit("if", left, right, None) self.codes.append(("GOTO_FALSE", L3, None, None)) self.codes.append(("LABEL", L2, None, None)) body_func() self.codes.append(("GOTO", L1, None, None)) self.codes.append(("LABEL", L3, None, None)) def output(self): for c in self.codes: print(c) if __name__ == "__main__": gen = CodeGen() # 模拟生成:将 a = a + 1 嵌入 while (a < 10) 循环 gen.gen_while( cond_func=lambda: ("a", "<", "10"), body_func=lambda: gen.emit("+", "a", "1", "a") ) gen.output()这个骨架里有几个关键点。emit函数统一管理所有中间代码的输出格式,便于后续统一做优化;gen_while中if与GOTO_FALSE的组合是为了避免在生成条件表达式时立即生成跳转,而是用标准的两步跳转结构,等条件计算结果出现后再填充目标标签——如果你在写真正的编译器,这对应“回填”(backpatching)技术。GOTO_FALSE的意思就是条件不成立时跳转到循环出口,这个一目了然的命名方式减少了自己排查错跳转时的认知负担。
把这个项目和课内实验串起来,你会得到一份完整的综合实践链路:词法分析输出的 token 流进入递归下降分析器,分析器每归约一个产生式就调用CodeGen生成对应三地址码,最后把生成的三地址码用一组手写的测试程序逐个模拟执行。走到这一步,你才算是真正“做了一遍编译原理”,而不只是“看了一遍PDF”。验证方法很简单:给这段代码输入let a = 10; while (a < 10) { a = a + 1; },看输出三地址码是否符合预期执行逻辑。如果模拟执行计数得到a=10,而预期是a最终循环不执行,说明条件分支生成有误——这种亲手调试的机会,比考前多刷三套卷子有价值得多。
回看我的学习过程,最庆幸的一件事就是没有停留在“看懂课本”,而是每个阶段都强行自己写一遍代码。那个时期的血泪经验,后来都成了排查问题时的直觉——看到词法分析异常,先怀疑转移表,再怀疑回退逻辑;看到三地址码生成顺序不对,先检查临时变量计数器,再检查优先级处理。这种直觉直接决定了你以后看真正的编译器源码(比如 TinyCC、LCC)时是举重若轻还是寸步难行。希望这篇笔记能帮你少走一些我走过的弯路。
本文还有配套的精品资源,点击获取