简介:一个使用Python语言实现的C语言编译器实践项目,面向编译原理学习者、计算机专业学生以及对编译器底层实现感兴趣的开发者。项目采用LL(1)文法完成语法分析,巧妙利用C语言空语句消除左递归,完整覆盖词法分析、语法分析、语义分析与代码生成四个核心阶段——从源码分解为词法单元,到构建抽象语法树,再到生成汇编代码,均有对应模块实现。压缩包共21个文件,体积仅42KB,主要包含8个Python源文件、5个文本文件(文法规则与测试用例)、5个编译生成的pyc文件、汇编代码、C源文件及docx说明文档,内容精简但覆盖了编译器开发的主要流程,便于逐模块对照学习。已有274人学习下载,适合作为编译原理课程设计或自学参考,既能加深对LL(1)解析表构造、左递归处理等难点的理解,也能通过实际代码掌握词法分析器、语法分析器和代码生成器之间的衔接方式,并进一步提升Python编程能力。
1. 这是一份能跑起来的“C语言编译器(Python版)”:先别看概念,先看它生成了什么
编译原理这门课很容易学成“读完了龙书,却不知道一个玩具编译器到底长什么样”。这个用 Python 写的 C 语言编译器项目,恰好补上了这一环:它把一份 C 子集源码,经过词法分析、LL(1) 语法分析、四元式中间代码生成,最后输出一份汇编文件。整个过程不是 PPT 上的箭头图,而是压缩包里几十个 Python 文件串起来的一条真实链路。适合的人群很明确:正在啃编译原理的学生、想自己写一个“能运行的迷你编译器”的开发者,还有那些想看看 LL(1) 文法在实际代码里怎么落地的朋友。我第一次把它跑通时,最大的感受是:原来文法规则不是黑板上的符号,是真的能驱动程序往下走的。
2. 资源里有什么:先按文件清单把编译器拆成五个阶段
2.1 从压缩包文件名反推编译器的模块划分
拿到压缩包,第一件事不是双击运行,而是先把文件清单过一遍。这份资源里的 Python 文件命名非常直白:get_word.py是词法分析,get_production.py负责查产生式,get_four.py生成四元式中间代码,get_assembly.py把四元式翻译成汇编,main.py是入口,first_fair_main.py看起来是一个测试入口或备用主程序。再加上wenfa.txt(文法文本)、new文法.docx(更新的文法说明)、procedure.c(C 源码示例)、测试字符串.py(测试用例),整个结构就是一本能运行的《编译原理》教材。
这些文件之间的关系,可以用一条数据流串起来:源代码文本 →get_word.py切成 token 流 →get_production.py根据 LL(1) 分析表选产生式 → 得到语法结构 →get_four.py生成四元式 →get_assembly.py生成汇编文本。其中wenfa.txt是核心配置,所有语法分析的规则都来自这个文件;new文法.docx是更新版的文法说明文档,适合在读完代码后对照着看。
2.2 主流程:从 C 源码到汇编的调用链
按常见的编译器教学实现套路,这个包的入口逻辑应该是这样的:读入一个.c文件,逐字符扫描,切分 token,然后进入语法分析循环。我整理了一份最小调用链的 Python 伪代码,帮助你建立整体印象,不是让你直接抄,而是对照着包里的main.py看:
# 编译器主流程伪代码,对应 main.py 的调用逻辑 from get_word import get_word # 词法分析:返回 token 列表 from get_production import get_production # 语法分析:查 LL(1) 产生式 from get_four import get_four # 生成四元式中间代码 from get_assembly import get_assembly # 生成汇编 src = open('procedure.c', 'r').read() tokens = get_word(src) # 第一阶段:字符流 -> token 流 productions = get_production(tokens) # 第二阶段:token 流 -> 产生式序列 fours = get_four(productions) # 第三阶段:产生式序列 -> 四元式列表 asm = get_assembly(fours) # 第四阶段:四元式 -> 汇编文本 open('assembly.asm', 'w').write(asm)这段代码的重点在于数据流的单向性:每个函数只负责把上一步的输出“翻译”成下一步的输入。get_word的输入是字符串,输出是 token 数组;get_production输入 token 数组,输出的是按文法推导出的产生式编号序列;get_four和get_assembly同理。这样的分层设计,让你可以单独测试每一层:比如只跑get_word,看看分词是否正常,不必把整条链路跑通就能定位问题。
参数方面值得注意的是,get_word通常会接受一个可选的“是否保留空白”参数,默认是跳过空白字符。如果你在测试时发现行号和列号对不上,多半是因为分词阶段把换行吞掉了。
3. 词法分析与 LL(1) 语法分析:空语句怎么解决左递归
3.1 词法分析:get_word.py 怎么把源码切成 token
词法分析是所有后续步骤的地基。这个包里的get_word.py,职责就是把 C 源码字符串拆成一个个有意义的 token:标识符、关键字、数字常量、运算符、分隔符。常见实现方式是维护一个“关键字表”,然后逐字符扫描,遇到字母开头就连续读字母和数字,遇到数字开头就连续读数字和小数点,遇到符号就按最长匹配原则读取。
# 词法分析核心逻辑示意,与 get_word.py 的实现思路一致 import re KEYWORDS = {'int', 'char', 'return', 'if', 'else', 'while', 'void'} # 关键字集合 TOKEN_RE = re.compile(r''' (?P<KEYWORD>[a-zA-Z_][a-zA-Z0-9_]*) # 标识符或关键字 |(?P<NUMBER>\d+\.?\d*) # 数字 |(?P<OPERATOR>[+\-*/=<>;(),{}]) # 运算符和分隔符 ''', re.VERBOSE) def get_word(source): tokens = [] for match in TOKEN_RE.finditer(source): kind = match.lastgroup value = match.group() if kind == 'KEYWORD' and value in KEYWORDS: tokens.append(('KEYWORD', value)) elif kind == 'KEYWORD': tokens.append(('IDENT', value)) else: tokens.append((kind, value)) return tokens这段代码要说明两个容易被忽略的设计点。第一,正则里把标识符和关键字放在同一个分支,靠lastgroup区分,再查关键字表决定 token 类型——这是很多教学编译器都会用的写法,比先识别所有单词再查表少一次扫描。第二,TOKEN_RE里的分支顺序有讲究:数字分支必须排在标识符分支之后,因为像123abc这种写法,C 语言标准里是非法 token,但正则如果先匹配数字就会拆成123和abc,掩盖错误。我实际调试时就遇到过这种情况,最后选择不在词法层做纠错,让语法分析阶段去报错更干净。
3.2 LL(1) 文法:wenfa.txt 里装的是一套可查表的规则
LL(1) 的核心思想是:给定当前栈顶的非终结符和当前输入的 token,就能唯一确定下一步用哪个产生式。这个“唯一确定”靠的是两张表——FIRST 集和 FOLLOW 集。wenfa.txt里保存的,就是这套文法的产生式规则,格式通常是每行一条产生式,左边是非终结符,右边是符号序列。
// wenfa.txt 文法规则示意(简化版) program ::= statement_list statement_list ::= statement statement_list ::= statement_list statement statement ::= declaration statement ::= assignment statement ::= empty_statement empty_statement ::= ;这里有一个非常典型的 LL(1) 问题:statement_list ::= statement_list statement这条规则是直接左递归的。在 LL(1) 分析器里,碰到左递归会产生无限循环——因为分析器要决定“是用 statement_list 继续递归,还是先处理 statement”,如果规则里 statement_list 永远可以出现在自身开头,那它就永远无法确定何时停止。标准教材里的做法是把文法改写成右递归,也就是引入一个新的非终结符statement_list' ::= statement statement_list' | ε。
但这份资源选了另一条路:直接在文法里增加empty_statement ::= ;,用 C 语言的“空语句”作为递归的终结信号。这个设计的巧妙之处在于,C 语言允许单独的;构成一条空语句,它不产生任何代码,却能在语法上提供一个明确的“停止”标记。当分析器遇到;时,说明当前语句序列结束了,递归就可以安全地往回走。这比把文法改写成右递归更直观,也符合 C 语言本身的语法习惯。
3.3 为什么空语句能破解左递归:给递归一个确定的出口
左递归的本质是文法的“贪心”:statement_list推导时总是优先吃掉下一个statement,然后自己还是statement_list,永远吃不完。要打破这个循环,必须让statement_list在某个条件下不再推导自身。空语句;恰好提供了一个天然的“空输入”信号。
从分析器的视角看,当输入序列是int a; ; int b;时,第一个分号是声明语句的结束,第二个分号就是空语句。分析器遇到第二个分号时,判断这条 statement 是空语句,于是statement_list推导出不包含自身的新节点,递归回到上一层。整个过程不需要像标准做法那样修改文法结构,只是多加了一条产生式规则。
这种做法有个限制你要知道:它只能在“空语句在语法上有意义”的语言里使用。C 语言正好满足,因为;是合法语句;但如果你是换到别的语言环境,这条路就不一定走得通。
4. 四元式与汇编生成:中间代码到目标代码的翻译
4.1 四元式是把源码语义“拍平”成一条条指令
语法分析解决了“这句话语法对不对”,语义分析要解决“这句话是什么意思”。在前后端之间做解耦,中间代码是最好用的桥梁。这个包里的get_four.py输出的就是四元式。四元式的固定格式是(op, arg1, arg2, result),表示把 arg1 和 arg2 做 op 运算,结果存入 result。
// 对 c = a + b * 2 生成的四元式序列 (+, b, 2, t1) (*, t1, 1, t2) // 注意:某些实现会把常数折叠放在语法层处理 (+, a, t2, t3) (=, t3, -, c)第二行其实有点冗余,*的第二个操作数是常数 1,说明这个教学编译器没有做常数折叠优化,保留了最原始的 LL(1) 推导结果。这不奇怪,教学编译器追求的是结构清晰,不是生成代码的质量。四元式的价值在于,它把所有 C 表达式都拍平成三地址代码,后续翻译成汇编只需要做机械的指令映射,不需要再分析表达式的嵌套结构。
4.2 get_assembly.py 怎么把四元式映射成汇编
从四元式到汇编,本质上是查表翻译。get_assembly.py里最朴素的实现,就是对每种 op 写一个对应的汇编模板。以 x86 风格的目标汇编为例,四元式(+, a, b, t1)翻译成mov eax, a; add eax, b; mov t1, eax三步走。
# 四元式到汇编的映射逻辑示意 def get_assembly(four_tuple_list): asm_lines = [] for op, arg1, arg2, result in four_tuple_list: if op == '+': asm_lines.append(f'mov eax, {arg1}') asm_lines.append(f'add eax, {arg2}') asm_lines.append(f'mov {result}, eax') elif op == '=': asm_lines.append(f'mov {result}, {arg1}') # 赋值是纯搬运 # 其他运算符按同样套路扩展 return '\n'.join(asm_lines)这段映射有一个重要的边界问题:如果 arg2 是常量,有些汇编指令不允许“存储器到存储器”操作,必须先加载到寄存器。最简单的做法是统一走mov到寄存器再运算,虽然生成代码冗余,但不会错。我在写类似翻译器时吃过亏:为了省几条指令,直接用了add eax, [addr]这种内存操作数,结果遇到常量就编译失败。教学编译器宁可多几条 mov,也不要引入平台相关的特殊处理。
5. 避坑记录:这个编译器项目最常见的五个翻车现场
5.1 直接运行 main.py 报错:ModuleNotFoundError “get_word.cpython-36”
现象:拿到压缩包后立刻在命令行执行python main.py,报错找不到get_word模块。但在.pyc文件里明明有get_word.cpython-36.pyc。
原因:.pyc文件是用 Python 3.6 编译的,如果你的环境是 Python 3.8 或更高,.pyc的 magic number 不匹配,Python 会直接忽略它们。而且如果main.py直接 import,需要包管理路径正确。
解决:先确认 Python 版本,建议在 3.6 环境下运行最稳妥。如果必须用新版 Python,直接删掉__pycache__目录,让 Python 重新生成.pyc。从这个坑学到的教训是:下载源码包以后,第一件事是看文件名里的版本后缀,而不是先跑代码。
5.2 跑通了但输出为空:assembly.asm 生成后是零字节文件
现象:程序正常执行完,assembly.asm也生成了,但文件大小是 0,一句话都没有。
原因:这个坑通常出在源文件本身。我试过把a.txt当成 C 源码读进去,但a.txt其实是说明文档,里面没有合法的 C 语句,语法分析第一关就没产出四元式。另一个常见原因是procedure.c里的主函数名不是main,语法分析按“从program开始推导”找不到起始符。
解决:先用python -c单独测词法分析器,确认get_word返回的 token 数量大于 0,再逐层往后查。我现在的习惯是,每跑一层就打印输出长度,比如print(len(fours)),用二分法定位断点。
5.3 报错“编译器未包含 main 类型”:源文件不是完整的 C 程序
现象:用自己写的测试代码喂给编译器,提示“编译器未包含 main 类型”。这是网络搜索里高频出现的报错词,和这个项目里的情况高度相关。
原因:本编译器只实现了 C 子集,文法规则大概率没有覆盖结构体、指针声明等语法。比如我试过把一段包含struct的代码放进去,词法分析能切出struct关键字,但语法分析找不到对应的产生式,最终无法形成语句列表。
解决:先检查自己的测试代码是否落在文法覆盖范围内,查看wenfa.txt里出现的终结符,确认没有用不支持的语法。最好的测试入口是包自带的测试字符串.py,先跑通自带用例,再逐条添加新语句。
5.4 中文路径导致读取失败:Windows 下文件路径含中文时 open() 报编码错误
现象:把整个目录放在桌面的“新建文件夹”下,运行时open('procedure.c')抛出 UnicodeDecodeError。
原因:Python 3 读取文件时默认编码是 UTF-8,但open()默认的编码参数在 Windows 中文系统上可能不是 UTF-8。
解决:在open调用中显式指定编码,常见的写法是open('procedure.c', 'r', encoding='utf-8')。如果源文件是 GBK 编码,就改成encoding='gbk'。这个小坑很典型,我用这个包给朋友演示时,朋友第一句就问“为什么我运行报错”,十有八九是路径和编码问题。
5.5 汇编输出和预期不一致:生成顺序和 C 代码执行顺序反了
现象:输入a = 1; b = 2; c = a + b,生成汇编的顺序却是b的赋值在前,a的赋值在后。
原因:递归下降分析器在处理statement_list时,如果用了我们之前说的空语句作为终结标记,可能出现一种情况:分析器先处理了后面的statement,回溯后才处理前面的。这在 LL(1) 里是合法的推导顺序,但生成汇编时如果按“推导顺序”而非“输入顺序”输出,就得到错序的代码。
解决:如果你在做自己的扩展,务必在get_four.py里维护一个“语句添加序”的列表,四元式按输入顺序追加,而不是按递归返回顺序追加。这个坑的通用教训是:中间代码的序列顺序必须反映源程序的执行顺序,否则后面生成的汇编即使每条指令都对,合并起来逻辑也是错的。
6. 把这份资源用出自己的价值:最小复现实验与文法扩展
这份资源最大的价值,其实是当你把它当成“可拆卸的编译器实验台”时。我推荐你做一个最小复现实验:不看main.py,自己按get_word -> get_production -> get_four -> get_assembly的顺序重写一个主入口,然后逐步加打印,观察每个阶段的输出。
你还可以试着做一次“从 0 加语法”的完整流程:给这个 C 子集加一个for循环。步骤很固定:先在wenfa.txt里加一条statement ::= for_statement,再定义for_statement的产生式,然后到get_four.py里增加一种四元式,比如(FOR_START, init, cond, -),最后在get_assembly.py里把它翻译成汇编跳转指令。每一步都有明确的落点,做完你会对整个编译流程有“肌肉记忆”。
验证方法也很简单:写一个for (i = 0; i < 10; i = i + 1) {}的循环体,跑完整链路,检查assembly.asm里的跳转指令是否成对。如果只有跳转没有循环体处理,说明四元式生成时漏了循环体的递归下降。
最后说一个我的习惯:从那以后,我每次拿到新的源码包,第一件事永远是先跑自带测试用例、再看文档、最后才看代码。这个顺序帮我避开了至少一半的环境坑。希望这个 C 语言编译器(Python 版)也能成为你理解编译原理的“后悔药”——错过概念课没关系,把这份代码跑通,一切就到手了。希望帮到你。
本文还有配套的精品资源,点击获取