简介:一份以C语言实现的小型编译器完整源码,面向想弄懂编译原理的开发者与计算机专业学生,适合用来对照教材逐模块研读。资源压缩包共86个文件,以C源文件(50个.c)和头文件(12个.h)为主体,另有少量配置文件、批处理脚本和说明文档,整体仅210KB,非常轻量。源码覆盖词法分析、语法分析、语义分析、优化与代码生成等核心阶段,并包含ANSI C与x86/68k后端的实现。通过阅读和调试这些代码,可以具体看到符号表管理、表达式求值、语句处理、寄存器分配等环节如何落地;包内同时提供实验批处理与构建脚本,便于自行编译运行,观察每个阶段的输出变化。已有477人学习下载,适合作为编译原理课程的课外补充或个人研究入门材料。
1. 写一个小型C编译器,先搞清楚你要的到底是个什么玩意
如果你是一个C语言初学者或者刚入门的系统软件爱好者,看到“小型C编译器实现的源代码”这种标题,第一反应多半是:这东西高深莫测,代码量肯定几万行起步,根本看不懂。但实际上,当一个编译器被限制在“小型”这个范围时,事情远没有想象中复杂。我自己动手写的这个编译器源码,总共不到四千行C代码,就能完成对C语言一个子集的完整编译,最终生成x86-64汇编并链接成可执行文件。
这个项目能做三件事:把纯文本的C源文件解析成抽象语法树,做基础的语义检查和类型推导,然后生成可运行的汇编代码。它适合谁来看?想搞明白编译器到底是怎么回事的C语言学习者、需要为某个脚本语言设计解释器的嵌入式开发者、以及单纯对“源代码如何变成可执行文件”充满好奇的程序员。看完之后你会明白,编译器和编辑器完全是两回事——编辑器只负责帮你写文本,编译器负责把文本变成机器能懂的东西。
我选择C语言作为被编译对象而不是Java或者Python,是因为C语言本身语法简洁又足够底层,指针和结构体正好能和编译器的核心数据结构天然对应。再加上C标准中有大量现代编译器都支持但严格语法精简后并不影响主流程的语言特性,裁剪起来非常顺手。另一个现实原因是,我自己写编译器这台“编译器”,用的就是C语言,自举的思路从一开始就埋下了伏笔。
2. 从零搭框架:编译器的四个阶段是如何协调工作的
2.1 整个编译流水线的设计
一个编译器的经典流水线包括:词法分析、语法分析、语义分析、代码生成。我写这个小编译器时没有做太复杂的优化,所以中间表示也简化成了一种类似于三地址码的结构体链表,而不是像GCC那样搞出七八层IR。
词法分析器的作用,通俗说就是把源代码拆成“单词”。比如int x = 10 + 5;这一个字符串,会被拆成int、x、=、10、+、5、;这些token。我在实现时维护了一个全局的token列表,每个token包含类型、文本值、行号和列号。行号和列号必须留,否则后面语法报错你根本没法定位源码位置。
语法分析器我用了递归下降法,这是手写编译器最常用也最好维护的解析方案。它本质上就是为每个语法规则写一个函数,例如“表达式”对应parse_expr(),“赋值语句”对应parse_assign_stmt()。这种写法比用lex/yacc自动生成器更加直观,虽然手写的词法分析器要多花点时间,但整个项目可控性强得多,出了问题你知道吗——直接断点进函数看就行,不需要去理解自动机生成工具的展开逻辑。
2.2 为什么我放弃了lex/yacc和LLVM后端
很多教程让你用flex和bison生成词法、语法分析器,再用LLVM的API生成中间码。我必须说,这个路线只适合机器配置好、网速快、环境干净的“标准环境”,一旦你换一台没有这些依赖的机器,或者需要调试某个细节,复杂度立刻上来。
我这个项目从一开始就定了死规矩:除了系统自带的gcc/as/ld之外,不依赖任何外部工具。原因有二:一是为了可移植性,整套源码在任何装着Linux的电脑上都能直接编译运行;二是为了学习价值,自己手写词法分析器和递归下降解析器,才能真正理解那些被工具隐藏掉的细节。
后端方面,我没有走LLVM,而是直接针对x86-64汇编生成目标代码。选择这种方案的好处是,生成的汇编代码人类可读性非常高,你编译完可以直接打开.s文件查看,看到movl %eax, -4(%rbp)这种指令,你能直接感受到程序的数据是如何在栈上流转的。LLVM IR虽然更高级,但对初学者来说反而增加了一层抽象障碍。
3. 核心模块源码解析:从token到汇编的完整实现
3.1 词法分析器:如何把字符流变成token流
这是整个编译器最基础的模块。我把token类型定义成了枚举,用结构体来保存每个token的详细信息。以下是关键源码结构:
typedef enum { TOK_EOF, TOK_INT, TOK_RETURN, TOK_IF, TOK_ELSE, TOK_WHILE, TOK_IDENT, TOK_NUMBER, TOK_ASSIGN, TOK_PLUS, TOK_MINUS, TOK_STAR, TOK_SLASH, TOK_SEMI, TOK_LPAREN, TOK_RPAREN, TOK_LBRACE, TOK_RBRACE, TOK_LT, TOK_GT, TOK_EQ, TOK_EXCLAIM } TokenType; typedef struct { TokenType type; const char *start; int length; int line; int col; } Token;这里的start+length组合比直接复制字符串更高效,因为大部分情况我们只需要比较词法单元是不是某个标识符,没必要分配内存做字符串拷贝。词法分析主循环就是一个大switch,每个case处理一个字符或一个字符族,遇到空白和换行就跳过,遇到字母或下划线就继续读直到非字母数字为止,再做关键字匹配。
有一个细节值得分享:关键字和标识符的判断顺序。我先收集完整的词素,再查表判断是不是关键字。如果反着来,先按字符匹配关键字,很容易在读到intx这种复合标识符时出错。实际代码就十几行,但犯过一次错之后记忆特别深刻。
3.2 递归下降语法分析器:表达式优先级是个难点
语法分析器我写成了一组互相递归调用的函数,基本结构和文法产生式一一对应。表达式解析是里面最讲究的部分,我实现的是经典优先级爬升:
static Node *parse_expr() { return parse_binary_expr(0); } static Node *parse_binary_expr(int min_prec) { Node *left = parse_unary_expr(); for (;;) { TokenType op = peek()->type; int prec = get_binop_prec(op); if (prec < min_prec) break; advance(); Node *right = parse_binary_expr(prec + 1); left = new_binary_node(op, left, right); } return left; }核心技巧在于parse_binary_expr(prec + 1)这里,传的是prec + 1而不是prec。这样做能保证左结合性,让a - b - c被解析成(a - b) - c而不是a - (b - c)。我当时在这里踩了坑,传成prec导致所有同优先级运算符都变成了右结合,生成的代码数学上完全错误。
语法树的节点我用了一个统一的结构体,类型用枚举区分,额外存储子节点指针。没有做太复杂的面向对象式设计,因为C语言本身就是结构体加指针,这样的树结构最轻量:
typedef struct Node { NodeType type; Token token; struct Node *children[3]; TypeInfo *type_info; int offset; } Node;3.3 符号表与语义分析:类型检查不能省
符号表我用了一个简单的链式表,每个作用域对应一张表,子作用域通过指针指回父作用域。查找变量时从当前作用域一路向上找,这模拟了C语言的作用域遮蔽规则。
typedef struct Symbol { char *name; TypeInfo *type; int offset; int is_const; struct Symbol *next; } Symbol; typedef struct Scope { Symbol *head; struct Scope *parent; } Scope;这里有个经验:符号表里一定要保存栈上的偏移量。因为我这个编译器后续生成汇编时,所有局部变量都放在栈上,符号表里的offset字段就是变量在栈帧中相对于%rbp的偏移。如果不存下来,后面生成movl %eax, -8(%rbp)这样的指令时还得再扫一遍所有变量,白白浪费时间。
语义分析阶段做的事情不算多,但必须做全。我做了基本的类型检查——赋值语句左右类型是否兼容,二元运算两侧是否是整数,函数调用的参数个数是否匹配,return语句是否存在于非void函数中。这些检查用递归遍历语法树实现,大概两百行代码,但效果立竿见影:本来很多运行时才能发现的错误,在编译期就挡住了。
3.4 代码生成:基于栈帧的x86-64汇编输出
这是最让我兴奋也最折磨人的模块。我选择x86-64 AT&T风格的汇编,遵循System V调用约定,函数的开头结尾都遵循标准的栈帧设置:
pushq %rbp movq %rsp, %rbp subq $N, %rspN是当前函数所有局部变量占用的总空间,这个数值是在语义分析阶段通过累计每个变量的offset计算出来的。代码生成阶段做的事情就是把抽象语法树翻译成一条条汇编指令,核心思路是一个简单的栈式表达式求值器:生成表达式代码时,把中间结果压栈,遇到运算符再从栈里弹出操作数计算。
比如x = 10 + 5;这句话,我生成的汇编大致长这样:
movl $10, %eax pushq %rax movl $5, %eax popq %rcx addl %ecx, %eax movl %eax, -8(%rbp)这种生成方式性能不高,但胜在实现简单、直观易调试。等整套流程跑通之后,再去考虑寄存器分配和优化,才是合理的学习路径。
4. 调试与踩坑实录:编译器开发中绕不过去的几道坎
4.1 用测试用例驱动开发,而不是写完再看
我开发这个编译器时吃过最大的亏,就是写了一大段代码之后才开始测试,结果第一个测试文件就出了十几个错误,根本无从排查。后来我调整了策略:为每个功能点写一个最小测试用例,每实现一个特性就跑一遍全量测试集。
整个测试集我用shell脚本维护,基本逻辑是这样的:每个.c测试文件,先用我的编译器编译,再用gcc原生编译同一个文件,然后分别运行,比对输出结果是否一致。这样做虽然土,但非常有效。你甚至可以故意写有语法错误的测试用例,验证编译器报错信息是否友好。
4.2 最常见的三个bug类型与排查方法
我整理了一个速查表,基本上编译器开发初期所有问题都能归到这三类:
| bug类型 | 典型表现 | 排查方法 |
|---|---|---|
| 词法分析边界问题 | 变量名最后一个字符被吞掉,或者数字溢出未报错 | 打印token流,检查每个token的start和length |
| 语法分析优先级错误 | 1 + 2 * 3计算出9而不是7 | 用-p参数打印语法树,人工检查树结构 |
| 代码生成栈帧错误 | 函数调用结束后返回值错乱,或栈指针不平衡 | 编译成汇编后用gdb打断点,单步执行并watch寄存器 |
其中栈帧错误的排查最麻烦,因为错误可能不是当前函数引起的,而是调用者没有正确保存某个寄存器。我遇到过两个函数互相递归时,其中一个覆盖了另一个的返回地址,最终导致段错误。排查了整整一个晚上,最后打印出来所有函数的栈帧偏移才发现,原来是某个函数在设置栈帧之前就把参数存到了%rsp下面——这个顺序错误直接把返回地址冲掉了。
4.3 工程化问题:内存管理比算法设计更费心
写编译器最容易被忽视的,就是内存管理。语法树节点、符号表项、字符串字面量,每一个都需要动态分配。我一开始采用随处malloc、用完了就free的策略,结果频繁出现重复释放和泄漏。后来我换成**区域内存池(region-based allocation)**方式,编译每个源文件时维护一个全局的内存池,整个文件编译完一次性释放整池内存。
这个方案简单粗暴又极其有效,因为编译器的生命周期很短——跑完一次编译就退出,程序结束后操作系统会自动回收所有内存。你不需要精确跟踪每个节点的释放时机,只要保证池不无限增长即可。这种方式在真实生产编译器里偶尔也能见到,比如某些语言的前端就是基于arena分配器的。实际写下来,这个决定至少帮我节省了两天调试时间。
5. 后续还能怎么玩:小型编译器的扩展空间
写完这个编译器之后,你可能会有一种意犹未尽的感觉,因为C语言的子集毕竟是子集,很多东西还没有支持。我自己接下来的计划里,有几个非常值得做的扩展方向。
第一个方向是增加指针支持。C语言最具特色的能力就是指针,一旦加了指针,语法树的节点结构、类型系统、以及代码生成中的寻址方式都要跟着变。指针的存取值会涉及%rax和(%rax)这种间接寻址,对初学者理解汇编栈模型非常有帮助。
第二个方向是增加函数调用的多参数支持。目前我只支持最多六个整数参数的函数调用,因为x86-64的System V调用约定里,六个参数以内全部用寄存器传递,超过六个要用栈传。把参数个数上限提升到任意数量,是一个很好的栈帧练习。
第三个方向是做一个简单的优化pass。比如常量折叠——把10 + 5在编译期就算成15,而不是生成两条压栈弹栈汇编指令。这种优化入门成本极低,但能让你直观感受到优化对代码质量的改变。
而如果你有强烈的探索欲,可以把这个编译器移植到Windows平台,把生成的汇编格式从AT&T改成Intel风格,或者把后端换成生成机器码直接写可执行文件,那将是对你整个计算机系统知识的一次全面检验。
最后分享我在这个项目中最深的体会:动手写一个编译器,最大的收获不是把C语言某个子集编译跑通的那种成就感,而是你从此以后再看“源代码”这个词,会多出一个全新的理解维度——源代码不只是给人看的文本,它是一个可以经过严谨流水线变成机械指令的蓝图。这种认知上的转变,才是这个小型C编译器项目最值钱的部分。
本文还有配套的精品资源,点击获取