news 2026/9/2 2:16:45

手写一个迷你编译器:编译原理实验从词法到代码生成详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写一个迷你编译器:编译原理实验从词法到代码生成详解

简介:面向编译原理课程的全套实验资料,围绕中国海洋大学实验一至实验八,覆盖词法分析、语法分析、语义分析、代码生成与优化等核心阶段,适合计算机专业学生及自学者。包内共248个文件,压缩包446.47MB,包括12个c、7个h编译器源码,6个y和4个l的lex/yacc定义,7个docx及5个doc实验报告,另有txt笔记、makefile脚本、可执行exe及大量xz/zst/bz2/zip工具链。已有491人浏览学习。实验从记号拆分、抽象语法树,到递归下降解析器、类型系统与中间代码优化,再到实验八综合编译器项目,形成完整链条;预览中的lex.yy.c、cal.tab.c等关键源码体现典型实现,配套文档与模板便于快速上手,可作为课程设计或实验报告的参考。

1. 这套实验到底在做什么——课程脉络与整体思路

编译原理这门课,很多人一听就觉得是“四大名补”之首,仿佛只有大佬才能驾驭。但真当你把实验一到八完整走下来,会发现它其实是一条非常清晰的线索:从字符流到Token流,从Token流到语法树,从语法树到中间表示,最后落到目标代码。没有哪一步是凭空出现的,每一步都是在给下一步铺路。

这套实验的经典之处就在于它完全覆盖了前端核心流程。实验一通常是词法分析,要求你手写一个扫描器把源代码拆成Token;实验二往往是语法分析,用递归下降或LR方法建立语法树;实验三和四开始进入语义分析,涉及符号表、作用域和类型检查;实验五到六是中间代码生成,通常以四元式或逆波兰形式输出;实验七和八则进入代码生成与优化,虽然不同学校的划分略有差异,但整体脉络基本一致。

说白了,这套实验就是让你亲手实现一个“小号编译器”。不需要你造出GCC或LLVM级别的产物,但你必须理解编译器在拿到一段代码后,究竟是怎么一步一步把它变成可执行形式的。很多人在做实验一的时候觉得简单,写个状态机就完事了;做到实验四开始崩溃,符号表管理、作用域嵌套、类型推导全部涌上来;再往后到中间代码生成,又开始头疼怎么处理控制流和控制栈上变量的生命周期。

我的建议是,做这套实验千万别当成八个独立的小作业去应付,而应该当成一个连续的工程项目。实验一的Token设计会影响实验二语法分析器的写法,实验二的AST节点结构会直接影响实验三语义检查的复杂度,实验三的符号表实现更是实验四类型检查的根基。很多同学的痛苦根源就是每个实验都从零开始,结果前面的设计缺陷在后面全部爆雷。

以我个人的实操经验来说,这套实验最合理的整体设计路线是:语言子集选小一点(比如支持整型、浮点、布尔、if/else、while、函数声明和调用),但每个阶段的实现要完整,宁可功能少也不要东拼西凑。选一个足够小的子集,才能让你有时间把每个阶段做扎实。

2. 核心实验拆解:从词法到语法再到语义

2.1 词法分析:手写状态机还是用Flex

实验一通常是最“轻松”的,因为需求非常明确:输入一段源代码字符串,输出Token序列。比如输入int a = 10 + 20;,你要识别出int是关键字、a是标识符、=是赋值运算符、1020是整型字面量、+是加法运算符、分号是语句结束符。

手写状态机是最常见的做法。核心逻辑就是维护一个“当前状态”,然后逐个字符读入,根据当前状态和读入字符决定状态转移。比如识别数字时,状态可能依次经过“开始数字”“数字中”“小数点后”等状态。写状态机最关键的一点是**前瞻字符(lookahead)**的处理,也就是当你发现当前字符不属于这个Token时,得把它“吐回去”作为下一个Token的开头。很多同学在这里踩坑,经常出现漏字符或重复读字符的问题。

用Flex生成器也可以,但我的建议是第一遍先手写一遍,哪怕写得粗粝一些也要自己过一遍。原因很简单:手写能让你真正理解“最长匹配”和“最大吞食原则”——编译器在扫描的时候,永远尽可能多地匹配字符,比如a++只会被识别成标识符a和自增运算符++,绝不会拆成a++。另外实验二甚至更后面的实验,你大概率还是要手写语法分析器,到时候你的语义动作可能要和手写词法器紧密配合。

2.2 语法分析:递归下降与LR的取舍

语法分析是整个编译原理的“分水岭”。实验二和实验三往往就是卡住最多人的地方。

递归下降分析是最符合人类直觉的方法:为每个非终结符写一个函数,函数内部根据下一个Token的类型决定走哪条产生式。好处是代码直观、调试容易、报错信息好控制;坏处是文法必须满足LL(1)条件,也就是不能有左递归,且FIRST集不能有冲突。

我在做实验二的时候踩过一个经典坑:表达式文法没消除左递归。比如expr -> expr + term这种写法,如果直接翻译成代码就是死循环,因为expr()函数第一步就会调用自己。正确做法是把文法改写成右递归形式:

expr -> term expr_tail expr_tail -> + term expr_tail | ε term -> factor term_tail term_tail -> * factor term_tail | ε factor -> ( expr ) | NUMBER

代码实现上就是每个非终结符对应一个函数,每个函数里根据当前Token决定走哪条分支,遇到终结符就匹配并消费Token,遇到非终结符就调用对应函数。整个过程非常像“猜谜”,你根据当前看到的一个符号,决定后续整个句子的走向。

LR分析方法(比如用Yacc或Bison)更适合处理复杂的算子优先级和结合性,但调试起来比较痛苦,一个state冲突的报错信息可能要查很久才能找到原因。我的建议是,如果你是刚开始做整套实验,优先选递归下降,因为它能让你在后续语义分析阶段更容易维护。

这里补充一个关键细节:在递归下降里处理运算符优先级,不用像文法书里那样把所有层次都写出来,可以直接在expr_tail里维护一个precedence变量做运算符优先级比较,用“Pratt Parser”的思路实现。这种方式的代码量更少,而且后续扩展一元运算、比较运算、逻辑运算都非常方便。

2.3 语义分析与符号表:你未来的第二个女朋友

实验三到实验四,基本就是符号表大展身手的阶段。符号表要管的事情很杂:每个标识符的类型是什么、作用域从哪开始到哪结束、同名变量在不同作用域里怎么区分、函数参数的类型列表是什么。

常见的实现方式是用“作用域链”结构:一个栈,每进入一个作用域就压入一层表(哈希表),退出作用域时弹出。查找标识符时从栈顶往下逐层找,保证内层能“看见”外层,但外层看不到内层。这个模型理解起来不复杂,但实现中有很多细节。

我在符号表上做过最值得说的一件事,是用value字段同时存类型和运行时值。实验一里词法分析返回的Token就带有一个literal字段,语法分析时这个字面量会被塞进AST的叶子节点,而在语义分析阶段,符号表会把变量名映射到类型和值信息。这样后面做中间代码生成时,需要的所有信息都已经就位了。

还有一个很多人忽略的点:类型检查的错误恢复。如果检测到int x = "hello";这种类型不匹配,你不能直接报错然后终止编译,而应该打印错误信息后把x的类型设成目标类型(比如int),这样后续代码还能继续编译下去,一次运行能暴露更多错误。这在做实验四的时候能帮你省很多事——你不用每次修完一个错误重新跑一遍全流程。

2.4 中间代码生成与目标代码生成

实验五到六,核心是生成中间表示。很多课程会要求生成四元式或三元式,格式类似result = arg1 op arg2。这一段实验的关键是临时变量管理

比如表达式a + b * c,乘法优先级更高,需要先生成临时变量存乘积,再做加法:

t1 = b * c t2 = a + t1

如果你用递归下降的方式做语法分析,生成中间代码可以完全镶嵌在语法分析过程中:每解析完一个子表达式就生成对应的四元式,并返回一个“结果变量名”(可能是一个字符串如t1,或是一个数字ID如%3)。这样生成的代码是对应的后序遍历顺序,天然满足运算优先级。

实验七到八进入代码生成,常见目标形态是栈式虚拟机指令集。这种指令集的好处是执行模型非常简单:每个指令在栈上弹出操作数、计算、压回结果。比如a + b可能翻译成PUSH a; PUSH b; ADD; STORE c。我在做实验七的时候发现,只要中间表示生成得够规范,翻译成栈式指令几乎就是机械翻译,每个四元式对应一到三条指令。

很多人做到这里会感叹:“原来编译原理学的那些理论,真的能拼出一个能跑的程序来。”这话不假。但前提是你前面几步没有把数据结构设计歪。

3. 实操过程与关键细节:一个算术表达式的完整旅程

3.1 从源码到Token:词法规则的写法

拿一个最简单的例子来做全套流程展示:输入int a = 10 + 20;

第一步词法分析,规则概括成一张表:

Token类型匹配模式示例
KEYWORDintfloatifwhilereturnint
IDENTIFIER字母或下划线开头,后续字母数字下划线a
NUMBER数字序列,可选小数点1020
OPERATOR+-*/===!=<<==
SEPARATOR;,(){};

手写扫描的时候,我习惯用一个全局的cursor指针(或索引)指向当前读到的位置。每轮循环先跳过空白字符,然后根据当前字符类型进入不同分支。识别数字的分支是:只要后续还是数字或小数点,就继续吞并。识别标识符的分支则是:先判断是否命中关键字,命中就返回KEYWORD类型,否则返回IDENTIFIER。

这里要特别留意一个细节:关键字和标识符的区分。int在语言里是关键字,但intx却是合法标识符。你必须在识别完整个单词之后再查表,不能读到i就以为遇到关键字。我先做完整单词识别,再查关键字表,这样就完全没问题。

3.2 递归下降解析:从Token列表到AST

拿到Token序列后,进入语法分析。以上面的Token为例,语法分析的预期结果是一棵这样的AST:

VariableDecl ├── type: int ├── name: a └── init: BinaryOp ├── op: + ├── left: Literal(10) └── right: Literal(20)

递归下降的代码长这样:

// expr -> term (('+' | '-') term)* ASTNode* expr() { ASTNode* node = term(); while (current_token.type == TOK_PLUS || current_token.type == TOK_MINUS) { Token op = current_token; advance(); ASTNode* right = term(); node = create_binary_op(op, node, right); } return node; }

注意这个循环写法,就是最典型的“EBNF消除左递归”后的代码形态。它表达的意思是:expr是先解析一个term,然后只要看到+-,就继续解析下一个term并组装成左结合(left-associative)的二叉树。

这里还需要处理一个问题:如果用传统的“优先级递降法”,每个优先级的函数内部都要写这种while循环。如果表达式再包含比较运算符、逻辑运算符,就会多出好几层函数嵌套。我建议用“Pratt解析器”的思路简化:给每个运算符设置left_binding_power(左绑定力),然后循环比较下一个运算符的绑定力。这样做代码量会明显减少,后续添加新的二元运算符只需要一行配置,而不是再套一层函数。

3.3 语义检查与四元式生成

AST构建完成之后,进入语义检查阶段。遍历AST,对每个节点检查:

  • 二元运算的两个操作数类型是否兼容。整数加减法没问题;浮点和整型混合时,如果要严格模式就报错,宽松模式就进行隐式转换。
  • 变量是否声明过。如果在符号表里找不到某个标识符,报“未声明变量”。
  • 分支条件是否为布尔类型。if语句的条件必须是布尔值,如果是if (a)a是整型,有些语言允许非零即真,有些语言直接报类型错误。

类型检查通过后,生成四元式。对于表达式节点,输出格式如下:

语句编号 | 结果变量 | 左操作数 | 运算符 | 右操作数 1 | t1 | 10 | + | 20

然后添加赋值指令:

2 | a | t1 | := | (空)

四元式可以用一个结构体数组存储,每个字段是字符串空指针,符号表里的变量可能直接映射成“栈索引”或“内存地址”,这样后续代码生成会简单很多。

3.4 目标代码生成:栈式虚拟机的指令

四元式转栈式指令,每一行对照翻译即可:

四元式栈式指令
t1 = b * cPUSH b; PUSH c; MUL; POP t1
t2 = a + t1PUSH a; PUSH t1; ADD; POP t2
a = t2PUSH t2; POP a

但要留意:真正的编译器不会真的把每个临时变量都存回内存再取出来,那样会浪费大量内存和指令。实做时可以偷个懒,只要临时变量只被使用一次,就让它一直待在栈上,不执行POPPUSH,直接用栈顶数据参与运算。我第一次做代码生成时没做这种优化,生成的指令数量多了将近一倍,后面加上“临时变量引计数”机制后才优化下来。

做完这一步,一个简单程序就能完整跑通:源码输入 -> Token序列 -> AST -> 类型检查 -> 四元式 -> 栈式指令 -> 在虚拟机上执行。我第一次把整个流程跑通时,输出结果和手算一致,那种成就感是其他课程给不了的。

4. 常见问题与排查技巧实录

4.1 语法分析死循环:左递归还没消除

症状:程序运行后卡死,或者用不了几步就栈溢出。

原因:文法里存在左递归,比如expr -> expr + term这种产生式被直接翻译成函数调用,导致无限递归。

排查方法:先检查产生式,看是否存在“某个非终结符的第一个符号就是它自己”的情况。把文法改写为右递归或EBNF循环形式,代码里用while循环代替函数递归。

4.2 符号表作用域错乱:变量越权访问

症状:另一个作用域的变量被错误地解析到了,或者声明在函数里的变量被函数外访问。

原因:符号表作用域链没有正确“压栈”和“弹栈”。进入函数体时忘了压入一层新作用域,退出时忘了弹出。

排查方法:在每次进入复合语句块(花括号内)时,明确执行scope_enter(),离开时执行scope_exit()。可以在调试模式下打印当前作用域栈的深度,对照代码的缩进层次检查是否匹配。

4.3 Token丢失或重复:前瞻字符没处理好

症状:词法分析结果少了一个Token,或者多出了一个意想不到的Token。

原因:读取到一个Token结束字符时,直接把它消费掉了,没有作为下一个Token的起始字符。

排查方法:写一个统一的next_char()peek_char()接口。peek_char()不移动索引,next_char()才移动。状态机里判断“当前Token结束”之后,先peek_char()确认下一个字符属于后续Token,再通过unread逻辑回退,或者直接把索引指向上一个字符的位置。

4.4 类型检查误报:布尔和整型的经典混淆

症状:if (1)被报类型错误,但你的语言设计允许非零即真;或者while (x)中的x是整型,却被要求必须是布尔型。

原因:语义分析阶段把条件表达式的类型检查写得太严格。

排查方法:先确认你的语言规格。如果你参考的语言是C,那if (1)合法,编译器只需要检查条件表达式类型是算术类型即可;如果你的语言是严格类型语言(类似Java或Rust),那么必须要求布尔类型。实验文档通常会写明允许哪些隐式转换,照着文档做就不会错。

4.5 中间代码顺序错乱:控制流语句的标签管理混乱

症状:if语句的两个分支会同时执行,或while循环只执行一次。

原因:跳转指令的目标标签生成不唯一,或者条件跳转的方向反了。常见于多个if语句嵌套时标签名重复。

排查方法:用全局计数器生成标签,每生产一个新标签就label_id++。条件跳转指令要仔细检查语义:JMP_IF_FALSE L1表示条件为假时跳转到L1;JMP_IF_TRUE L1表示条件为真时跳转。调试时,可以把生成的指令和标签打印出来,人工模拟执行一两条路径,对照预期看跳转是否对得上。

5. 从实验一到八,我的工程经验沉淀

整套实验做完,收获最大的不是“我知道编译器怎么回事了”,而是“我知道一个规模不大的编译系统应该怎么组织它的代码”。

说实话,这套实验锻炼的设计能力比算法能力更多。比如你要决定AST节点结构用统一结构体(一个类型字段加一个union)还是每个节点类型单独定义结构体、用继承或组合方式管理;你要决定符号表是集中式全局管理还是分布在各节点中;你要决定中间表示用数组还是链表存储。这些设计决策没有绝对的对错,但每个选择都会在下个阶段的代码量上给你反馈。

再分享一个实用技巧:每个实验开始前,先用一晚上把所有阶段的接口定义好。比如预先定义好Token结构、ASTNode结构、Symbol结构、Quadruple结构,并约定它们的创建和销毁函数。这样你做实验一的时候,已经把实验三要用的数据结构模板搭好了。后面每个实验只是往既定骨架里填充逻辑,就不会返工。

只要你愿意静下心来按“词法 -> 语法 -> 语义 -> 中间代码 -> 代码生成”这条主线走一遍,并在每个阶段留下干净的接口和足够的调试输出,到实验五之后你会觉得越来越顺,甚至开始想给自己的小语言加加法器、字符串、数组这些扩展功能。这其实就是编译原理实验想带给你最核心的东西:把一个看似玄乎的“魔法”拆成一个一个可以落地、可以调试、可以扩展的工程问题。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/2 2:16:23

浩天seo培训:如何让网站快速收录

如何让网站快速收录是所有seo都要面对和想要解决的问题&#xff0c;昆明seo培训就来分享下自己的一些经验,希望对各位seo的网站收录有一定的帮助和启发。首先决定网站是否收录的因素主要有&#xff1a;网站域名、服务器主机、网站代码、文章内容、外链建设。下面我细分介绍一下…

作者头像 李华
网站建设 2026/9/2 2:16:19

嵌入式开发实战:继电器模块原理、驱动与智能控制项目指南

继电器模块&#xff0c;这个在嵌入式开发中看似基础却至关重要的“开关”&#xff0c;是连接数字世界与物理世界的桥梁。无论是智能家居中的灯光控制、工业自动化中的电机启停&#xff0c;还是智能小车上的执行机构驱动&#xff0c;都离不开它。对于初学者而言&#xff0c;继电…

作者头像 李华
网站建设 2026/9/2 2:16:11

LLM训练数据版权合规:从Anthropic诉讼看技术应对

大型语言模型训练过程中的版权合规问题&#xff0c;正在从法务部门的讨论清单&#xff0c;变成一线 AI 工程师必须面对的技术挑战。最近索尼音乐与华纳查佩尔起诉 Anthropic 的事件&#xff0c;就是一个非常典型的案例&#xff1a;它表面上是一场版权诉讼&#xff0c;但背后牵出…

作者头像 李华
网站建设 2026/9/2 2:15:57

R语言网络分析实战:从2018全球贸易数据到ERGM建模

简介&#xff1a;这份针对2018年国际贸易网络分布分析的R语言资源&#xff0c;面向经济学、社会学及复杂网络研究者&#xff0c;解决从原始贸易数据到网络指标计算与模型解读的完整流程落地问题。资源包内仅有1个R脚本&#xff0c;却集中覆盖网络密度、平均路径长度、传递性、互…

作者头像 李华
网站建设 2026/9/2 2:15:27

高校C题库解压整理与刷题指南:从资源到能力的转化

简介&#xff1a;面向初学与进阶C语言的学生&#xff0c;这份题库压缩包提供了大量编程练习&#xff0c;帮助通过亲手编码巩固变量、循环、数组、函数、指针、结构体与文件操作等核心知识点。压缩包共385个文件&#xff0c;主体为317个C源码文件&#xff0c;另含exe可执行程序、…

作者头像 李华