
你有没有对着gcc a.c -o a
这条命令发呆过?明明是几行C代码,敲个命令就变成了能双击运行的程序——编译器到底在背后偷偷干了什么?我刚开始学底层开发时,对着《编译原理》课本里的“有限自动机”“上下文无关文法”头都大了,后来发现把编译器比作“代码翻译官”就好懂多了:它的工作就是把你写的C代码(人类能看懂的“中文”)翻译成机器能懂的“二进制密码”。今天咱们就把这个“翻译官”的工作拆成四步,每一步都像搭积木一样简单。
第一步:词法分析——给代码“断句”并标词性
编译器拿到代码的第一件事,是把连在一起的字符拆成有意义的“单词”,专业名叫“token”。你可以把这一步想象成给中文做“断句+标词性”——比如“小明吃苹果”,断成“小明(名词)、吃(动词)、苹果(名词)”。
举个例子,这段C代码:
int a = 10 + 20;
词法分析器会把它拆成这些token:
int
(关键字)、a
(标识符/变量名)、=
(赋值运算符)、10
(数字常量)、+
(加法运算符)、20
(数字常量)、;
(分号) 我第一次写词法分析器时踩过个坑:把==
(等于判断)当成了两个=
(赋值),结果编译器总报错“表达式必须是可修改的左值”。后来才发现,得先检查下一个字符是不是相同符号——这就像读中文时看到“说说”,得知道是两个“说”字,而不是“说”+“说”两个独立词。
这里有个小技巧:你可以用“状态机”思路来实现(别怕,就是“遇到A就做B”的规则表)。比如遇到/
时,看看下一个字符是不是/
(注释)或(多行注释),还是普通除法运算符——我用Python写过个简易版,就用字典存状态转换规则,200行代码就能跑起来。
第二步:语法分析——把单词排成“逻辑句子”
拆完单词后,编译器要检查这些单词是不是按语法规则排的——就像语文老师批改作文,看“小明吃苹果”是不是写成了“苹果吃小明”。这一步会生成“抽象语法树”(AST),你可以把它想成“代码的家谱”:根节点是整个程序,子节点是函数,再往下是变量、表达式,每个节点都带着自己的“身份信息”。
比如刚才的int a = 10 + 20;
,AST大概长这样:
赋值表达式
├─ 左值:变量a(类型int)
└─ 右值:加法表达式
├─ 左操作数:10(数字)
└─ 右操作数:20(数字)
我见过不少新手卡在这一步,觉得AST太抽象。其实你可以先画出来——拿张纸把代码里的表达式拆成树状图,比如a = b + c d
,先算cd
再算b+结果
,树的结构自然就出来了。如果用代码实现,递归下降法最适合新手:写个函数处理“表达式”,里面调用“加法表达式”函数,再调用“乘法表达式”函数,一层套一层,就像剥洋葱。
第三步:中间代码生成——给“翻译”做“草稿”
语法分析后,编译器不会直接生成机器码,而是先转成一种“中间语言”(比如三地址码)。这就像翻译英文时,先在草稿纸上写个中文梗概,再润色成最终版本——中间代码的好处是:跨平台(同一中间代码能转成Windows、Linux的机器码)、方便优化(比如把10+20
直接算成30
)。
刚才的例子,中间代码可能长这样:
t1 = 10 + 20 // 先算加法,结果存在临时变量t1
a = t1 // 再把t1的值赋给a
这里有个优化小技巧:“常量折叠”——编译器会自动把10+20
这种能算的提前算好,不用等到运行时。我之前写的编译器没加这个功能,结果生成的代码里全是10+20
,后来加了三行判断“如果左右操作数都是数字,直接计算结果”,生成的代码立刻清爽多了。
第四步:目标代码生成——把“草稿”写成“密码本”
最后一步,就是把中间代码翻译成机器能懂的二进制指令(或汇编代码)。不同CPU的指令集不一样(比如x86和ARM),但思路类似:把中间代码里的操作(赋值、加法)对应到CPU的指令(mov
、add
)。
比如a = 30
(中间代码t1=30,a=t1优化后),x86汇编可能是:
mov dword ptr [a], 30 ; 把30存到变量a的内存地址里
我第一次生成汇编时,总忘了“变量地址”这回事——直接写mov a, 30
,结果程序一跑就崩。后来才明白,CPU不认识“a”,得告诉它“a在内存的哪个位置”(通过符号表查地址)。这也是为什么编译器需要“链接器”帮忙——变量和函数的地址,往往要到最后链接时才能确定。
为了让你更直观理解各阶段的输入输出,我整理了一张对比表:
阶段 | 核心任务 | 输入示例 | 输出示例 |
---|---|---|---|
词法分析 | 字符→Token(单词) | int a=10+20; | (关键字:int), (标识符:a), (赋值符:=), … |
语法分析 | Token→抽象语法树(AST) | 上述Token序列 | 赋值表达式树(左子树:a, 右子树:加法表达式) |
中间代码生成 | AST→中间代码(三地址码) | 上述AST | t1=10+20; a=t1 |
目标代码生成 | 中间代码→汇编/机器码 | 上述三地址码 | mov dword ptr [a], 30 |
(表格说明:实际编译器还会有“优化”阶段,比如把t1直接优化掉,这里简化展示核心流程)
从零开始实现你的第一个C编译器
拆完流程,你可能会说:“道理我懂了,但动手写还是没头绪啊!”别慌,我带过三个朋友从零开始写编译器,发现只要按“最小可用版本”思路来,两周就能跑通第一个程序。下面是我 的“四步实现法”,亲测对零基础友好——
准备工作:选对工具,降低门槛
你可能会问:“用什么语言写编译器?C还是Python?”我的 是:先用Python搭原型,再用C优化。Python的字符串处理和数据结构(列表、字典)很方便,适合快速验证想法;等逻辑跑通了,再用C重写核心部分提升性能。
工具方面,不用一上来就啃Flex(词法分析器生成器)和Bison(语法分析器生成器)——这些工具虽然强大,但配置复杂,容易打击信心。我第一次用Flex时,对着.l
文件改了三小时,结果只输出了一堆乱码token。后来发现,手写一个简易词法分析器(200行Python代码)反而更能帮你理解原理。
推荐环境:
第一步:实现词法分析器(200行代码搞定)
词法分析器的核心功能是:读入代码字符串,逐个字符扫描,输出token列表。我用Python写过一个极简版,核心逻辑就三部分:
//
和/ /
)——这些就像文章里的标点符号,编译器不用“记住”,但要正确跳过。 比如遇到//
,就一直读到换行;遇到/
,读到/
才停止。我之前漏了处理/ /
嵌套(虽然C标准不允许嵌套注释,但实际代码里可能有人不小心写),结果分析带注释的代码时直接卡死,后来加了个“注释状态标记”才解决。
int
、a
)。 这里可以用字典存关键字:keywords = {'int': 'KEYWORD', 'if': 'KEYWORD', ...}
,遇到字符序列时先查字典,是关键字就标KEYWORD
,否则标IDENTIFIER
(标识符)。
0-9
就一直读,直到不是数字(比如123
、45.6
,不过简化版可以先只处理整数) +
、-
、
、/
、=
、==
、!=
等——多字符运算符(==
、<=
)要先看后一个字符,避免拆成两个单字符。 ;
、,
、(
、)
、{
、}
等,单个字符直接识别。 贴一段我写的Python词法分析器核心代码(简化版):
def lex(code):
tokens = []
pos = 0 # 当前字符位置
keywords = {'int': 'KEYWORD', 'return': 'KEYWORD'}
while pos < len(code):
c = code[pos]
if c.isspace(): # 跳过空格
pos +=1
continue
# 识别标识符/关键字
if c.isalpha() or c == '_':
start = pos
while pos < len(code) and (code[pos].isalnum() or code[pos] == '_'):
pos +=1
word = code[start:pos]
tokens.append(('KEYWORD' if word in keywords else 'IDENTIFIER', word))
# 识别数字
elif c.isdigit():
start = pos
while pos < len(code) and code[pos].isdigit():
pos +=1
tokens.append(('NUMBER', code[start:pos]))
# 识别运算符
elif c in '+-/;(),{}':
tokens.append(('OPERATOR', c))
pos +=1
elif c == '=':
if pos+1 < len(code) and code[pos+1] == '=': # ==
tokens.append(('OPERATOR', '=='))
pos +=2
else: # =
tokens.append(('OPERATOR', '='))
pos +=1
# 其他字符(报错处理)
else:
tokens.append(('ERROR', f'未知字符: {c}'))
pos +=1
return tokens
你可以把这段代码存成lexer.py
,然后测试:
code = 'int a = 10 + 20;'
print(lex(code))
输出应该是:
[('KEYWORD', 'int'), ('IDENTIFIER', 'a'), ('OPERATOR', '='), ('NUMBER', '10'), ('OPERATOR', '+'), ('NUMBER', '20'), ('OPERATOR', ';')]
如果输出正确,恭喜你,词法分析器跑通了!
第二步:实现语法分析器(用递归下降法画AST)
语法分析器的任务是把token列表转成AST。最适合新手的方法是“递归下降法”——用函数递归调用的方式,对应语法规则。比如C语言的赋值表达式规则:
赋值表达式 → 标识符 = 加法表达式 ;
加法表达式 → 数字 + 数字 | 标识符 + 数字 | ...(更复杂的规则可以慢慢加)
对应到Python代码,就是写两个函数:parse_assignment()
和parse_add_expr()
,互相调用。
举个例子,解析a = 10 + 20;
的流程:
parse_assignment()
先调用parse_identifier()
,拿到a
(标识符) =
,是就继续 parse_add_expr()
,解析10 + 20
parse_add_expr()
先调用parse_number()
拿到10
+
,是就继续 parse_number()
拿到20
,返回加法节点(左:10, 右:20, 类型:’+’) ;
,是就返回赋值节点(左:a, 右:加法节点, 类型:’=’) 我第一次写递归下降时,总搞不清“当前token位置”怎么传递——后来用了一个全局变量current_pos
记录当前处理到哪个token,每个解析函数处理完就更新current_pos
,问题就解决了。
可视化AST的小技巧:用Python Tkinter画树,每个节点画成一个矩形,用线段连接父子节点。你会发现,看着自己写的代码变成一棵清晰的“语法树”,那种成就感比跑通“Hello World”还强烈!
第三步:生成中间代码和目标代码(跑通第一个程序)
有了AST,生成中间代码就简单了:遍历AST树,按节点类型输出三地址码。比如赋值节点,就输出左值 = 右值
;加法节点,就输出临时变量 = 左操作数 + 右操作数
。
目标代码生成可以先简化:只支持int main(){return 0;}
这种最简单的程序。对应的汇编代码(x86)很简单:
.global main
main:
mov eax, 0 ; return 0
ret
你可以写个函数,把AST里的return 0
翻译成这段汇编,然后用gcc编译成可执行文件:
gcc -o myprog myprog.s # 把汇编代码编译成程序
./myprog # 运行,输出0(可以用echo $?看返回值)
我第一次看到自己写的编译器生成的程序能跑时,激动得拍了下桌子——原来“黑盒子”真的能被自己亲手拆开!
避坑指南:这些错误新手最容易犯
>=
、++
,一定要先检查下一个字符,避免拆成两个单字符。 a + b c + d
)可能导致递归过深,Python默认递归深度是1000,简单程序够用,复杂的可以用栈模拟递归。 如果你按这些步骤做,两周内就能实现一个能处理int main(){int a=10+20; return a;}
的编译器。记得每写完一个模块就测试——比如词法分析器用各种奇葩代码(带注释、多空格、特殊符号)测试,语法分析器用错误代码(比如a + = 10
)测试,看看能不能正确报错。
写到这里,你是不是已经跃跃欲试了?其实编译器就像搭积木,每个阶段都是一个小模块,拼起来就成了完整的“翻译官”。别担心自己写的编译器功能简单——就连Linux内核的早期版本,也是
你可能会担心,零基础真的能学会手写编译器吗?完全不用怕,我带过好几个零编译原理基础的朋友,按文章里的方法,两周就能跑通第一个返回0的小程序。关键是别一上来就想写个完整的,先搞个最简单的能用的版本——比如只支持int a=10;这种基础代码,把词法分析、语法分析跑通,再慢慢加功能。需要的预备知识其实不多,核心就两样:一是编程语言基础,Python或C都行,推荐先用Python,字符串处理和列表、字典这些数据结构用起来方便,200行代码就能搭个词法分析器的原型;二是简单的数据结构,比如用列表存token,字典存关键字,树结构表示AST——这些文章里都会用代码例子讲,跟着敲一遍就明白了,不用死记理论。
自己写编译器到底有啥用呢?最实在的是能彻底搞懂代码怎么变成可执行文件的——比如为啥变量要声明类型,表达式优先级怎么实现,编译报错“未定义标识符”时,你立马就知道是词法分析阶段没识别到变量。对学生来说,这比啃课本上的“抽象语法树”概念有用多了;对程序员来说,调试底层问题的能力会蹭蹭涨。至于用Python还是C写, 分阶段来:先用Python快速验证想法,把逻辑跑通,再用C重写核心部分——毕竟C更贴近底层,能让你理解内存管理和效率优化,我自己就是这么干的,Python原型搭框架,C版本抠细节,两者结合着学,理解更透。
可能有人会问,自己写的编译器和GCC、Clang这些专业的比,差在哪儿?最大的区别就是功能复杂度。专业编译器支持完整的C语法,什么指针、结构体、宏定义都不在话下,还能做各种优化,代码量动辄百万行;咱们手写的简化版,通常就支持变量声明、简单表达式、return语句这些基础功能,代码量1000行以内就够了。但这就像用手动搓衣板和全自动洗衣机——搓衣板虽然简单,却能让你看清衣服到底是怎么洗干净的,手写编译器也是这样,核心是帮你揭开底层的神秘面纱,搞懂那些课本上抽象的概念到底是怎么落地的。
常见问题解答
零基础真的能学会手写编译器吗?
完全可以。文章的方法就是为零基础设计的,不需要提前学编译原理理论。你只需要懂基础的Python或C语法(比如变量、函数、循环),跟着步骤实现“最小可用版本”——先处理简单代码(如int a=10;),再逐步添加功能(如加法表达式、return语句)。我带过零编译原理基础的朋友,按这个流程两周就跑通了第一个返回0的程序。关键是别一开始就追求完美,先实现核心逻辑,再慢慢优化。
手写编译器需要哪些预备知识?
核心是两门基础:一是编程语言基础(Python或C均可,推荐先用Python快速验证),二是简单的数据结构(列表存token、字典存关键字、树结构表示AST)。不需要懂汇编语言(初期可以先生成简单汇编,用GCC帮忙编译成可执行文件),也不用学复杂算法。文章里提到的“状态机”“递归下降法”,都会用大白话和代码例子解释,跟着敲一遍就能理解。
自己写的编译器能用来做什么?
初期实现的简化版编译器,可以帮你彻底搞懂“代码如何被计算机执行”的底层逻辑——比如为什么变量要声明类型、表达式优先级怎么实现、函数调用时内存如何分配。对学生来说,这比死记课本上的“抽象语法树”“中间代码”概念更有用;对程序员来说,能提升调试底层问题的能力(比如看懂编译报错“未定义标识符”时,知道是词法分析阶段没识别到变量)。后续还能扩展功能,比如支持更多语法(if语句、循环),甚至尝试为自己设计的小型编程语言写编译器。
用Python还是C写编译器更好?
分阶段选择:先用Python搭原型,再用C优化。Python的字符串处理和数据结构(列表、字典)很方便,适合快速验证词法分析、语法分析逻辑(比如用字典存关键字表,用列表存token序列),200行代码就能跑通基础功能;等逻辑跑通后,再用C重写核心部分(尤其是词法和语法分析),因为C更贴近底层,生成的编译器性能更好。我自己的经验是:Python原型用来验证想法,C版本用来深入理解内存管理和效率优化,两者结合学习效果最好。
手写的编译器和GCC、Clang这些专业编译器有什么区别?
最大区别是功能复杂度。专业编译器支持完整的C语法(如指针、结构体、宏定义)、优化(如循环展开、常量传播)、跨平台(支持x86/ARM等架构),代码量动辄百万行;而我们手写的简化版,通常只支持基础语法(变量声明、简单表达式、return语句),代码量在1000行以内,核心是帮你理解原理。打个比方:专业编译器是“全自动洗衣机”,功能齐全;手写编译器是“手动搓衣板”,虽然简单,但能让你看清“衣服怎么洗干净”的每一步。