简介:一套小型C编译器源代码,面向希望从实践层面理解编译原理的C语言学习者、高校学生与进阶开发者。它把词法分析、语法分析、语义分析、基础优化和目标代码生成等环节落实为可阅读、可修改的工程代码,适合用于课程实验、编译器入门研读以及底层机制剖析。压缩包共86个文件,约210KB,以50个C源文件和12个头文件为主体,另含Makefile、批处理脚本、配置文件及说明文本,便于在不同构建环境下组织编译流程。目前已有480人学习。通过梳理其模块划分与实现细节,读者可观察标记识别、抽象语法树构建、符号表管理、错误检测、窥孔优化及针对特定架构的代码生成思路,并进一步理解指针、结构体与函数调用等语言特性的处理方式,为调试编译错误、优化代码性能或尝试自研编译器提供可复用的参考。
1. 词法、语法、语义与代码生成:一个小型 C 编译器到底在做什么
很多人以为 C语言编译器 是庞然大物,几百万行代码、几十个优化阶段,个人根本碰不动。真相有点反直觉:一个能编译自己、能跑通结构体、指针、可变参数的小型 C 编译器,核心逻辑常常不到一万行,把优化全部砍掉后还会更短。它真正解决的问题在于:C 里那些看似自然的写法——隐式类型转换、数组退化、声明语法、栈上变量的生命周期——必须先被逐条固定成确定规则,之后才谈得上生成机器码。把这套规则拆成词法、语法、语义、代码生成四层之后,你再看任何编译器,甚至看自己写的每一行 C,理解方式都会不同。
2. 词法与语法:为什么a+++b能把解析器逼疯
编译器的第一层工作是把字符流变成结构,这一步看似机械,却决定了后面所有错误信息是否可读。这里最容易被低估的两个点:切词必须遵循最长匹配,解析必须能在不回溯的前提下处理运算符优先级。小编译器在这两处偷懒,后面就要用十倍代价补回来。
2.1 字符流到 Token:最长匹配不是“按顺序试第一个”
C 的词法规则里有一条常被忽略:遇到多个可能匹配时,取最长的那个。a+++b会被切成a+++b,也就是(a++) + b,而不是a + (++b);x>>=1必须切出>>=而不是>>加=;a=-b在老式写法里会被切成a =- b,这是 C 的历史遗留问题,现代代码里通常写成a = -b来规避。
一个忠实的实现思路是:把每种 token 写成一个带锚点的正则(或手写匹配函数),在当前位置把所有模式都试一遍,取匹配长度最大的那个。不是“按顺序命中就返回”,顺序敏感的实现迟早会在多字符运算符上出错。
# 最长匹配:在当前位置跑一遍所有 token 模式,取匹配终点最远的那个 TOKEN_PATTERNS = [ (r"[A-Za-z_]\w*", "IDENT"), # 关键字稍后单独判别 (r"0[xX][0-9a-fA-F]+[uUlL]*", "NUMBER"), (r"\d+[uUlL]*", "NUMBER"), (r">>=", "OP"), (r"<<=", "OP"), (r"\+\+", "OP"), (r"->", "OP"), (r">>", "OP"), (r"<<", "OP"), (r"\+", "OP"), (r"-", "OP"), ] def next_token(src, i): best, best_kind = None, None for pat, kind in TOKEN_PATTERNS: m = re.compile(pat).match(src, i) if m and (best is None or m.end() > best.end()): best, best_kind = m, kind # 谁吃得多选谁,与模式的书写顺序无关 if best is None: raise SyntaxError(f"非法字符 {src[i]!r},偏移 {i}") tok = Token(best_kind, best.group(), line_of(src, i), i) return tok, best.end()这段逻辑的核心是那个>比较:只有更长的匹配才能替换当前候选。end()记录的是绝对偏移,因此顺带拿到了 token 的起始位置和行列号,后面报错时能精确指出“第几行第几列出现了什么”。没有位置信息的 token 流是黑匣子,出错只能靠猜。
另一个容易翻车的点在于词法之前的那一层:反斜杠续行要在切词前被吃掉,注释在标准语义里等价于一个空格(所以int/**/x;是合法的),字符串字面量相邻拼接("ab" "cd")也发生在这一层。把这些都塞进next_token里,代码会变得很难维护;分成“预处理行拼接 → 去注释 → 切词”三段,每段只做一件事,调试时能单独印证。
2.2 递归下降与优先级爬升:把优先级表变成函数调用栈
文法里expr := expr '+' term是左递归的,直接照抄成递归函数会无限递归。教科书解法是消除左递归,工程上更常见的做法是优先级爬升:把“当前允许的最低优先级”作为参数往下传,遇到优先级不够的运算符就退回给上层。
// min_prec:本次调用允许吃进来的最低优先级;低于它的运算符一律不吃 Node *parse_binop(int min_prec) { Node *lhs = parse_unary(); // 先取一个操作数(含前缀 ++/--/&/*) for (;;) { Token *op = peek(); int prec = binop_prec(op); // 查表:* / 高于 + -,高于 << >>,高于比较…… if (prec < min_prec) break; // 关键:不满足就退回,把 op 留给上层 next(); // 左结合传 prec + 1:同级运算符不会在右子树里被吃掉 // 赋值、三元这类右结合运算符则传 prec,让同级继续往右递归 Node *rhs = parse_binop(prec + 1); lhs = new_binary(op, lhs, rhs); // 边解析边建树,等价于先算左边 } return lhs; }min_prec是整个函数唯一的“状态”,它替代了一张显式的算符优先表。左结合与右结合只差一个+1:a - b - c必须解析成(a - b) - c,如果右子树也用prec而不是prec + 1,就会得到a - (b - c),减法结果直接错掉,而这类 bug 在只测加法时完全看不出来。赋值=和三目?:是右结合的,a = b = c要解析成a = (b = c),参数就得传prec。
赋值还有个额外约束:左操作数必须是可修改的左值。这个检查不该在语法阶段做,语法只管形状,语义阶段才判定“这个名字能不能被写”。
2.3 声明语法:小编译器最容易失控的一块
表达式语法是简单的,声明语法才是 C 里的陷阱。int *p[10]是“十个指针的数组”,int (*p)[10]是“指向十个 int 的数组的指针”,区别只在一对括号。再往上还有int (*f(int, char))(double):f是一个函数,接受int和char,返回一个函数指针,那个函数接受double返回int。
手工写递归下降时,常见做法是把声明符解析成一串“类型构造器”:读到*就压一个“指针”构造器,读到[10]就压一个“数组”构造器,读到( ... )就压一个“函数”构造器,最后从标识符开始,按从内到外的顺序把这串构造器套上去。所谓“螺旋规则”描述的就是这个顺序:从名字出发,先向右看,遇到括号就掉头向左。
提示:声明解析一旦写错,症状通常不是“报错”,而是“类型大小算错但程序照跑”,直到某天
sizeof的结果和外部模块对不上才暴露。这类问题排查成本极高,值得在语义阶段单独写断言校验。
词法和语法这两层做完,编译器手里就有一棵 AST 了。但 AST 只描述“形状对不对”,完全不保证“意思对不对”——int *p; p + p;形状完美,语义荒谬。这就是下一层的工作。
3. 语义与符号表:类型检查到底在检查什么
语法通过只说明程序“长得像 C”,语义分析才回答“这段代码有没有意义”。小编译器在这一层最常见的错误是把三个正交概念揉成一个字段:作用域(名字在哪里可见)、存储期(变量活多久)、链接属性(符号能不能被别的目标文件看到)。揉在一起,static就会出现各种灵异行为。
3.1 作用域链:一张能一路向上找的哈希表
C 的作用域是嵌套的:文件作用域、函数作用域、块作用域,内层可以遮蔽外层同名变量。实现上就是一条链,每个节点是一层作用域,查找时一路向上,插入时只插当前层。同一层内重复定义是错误(C 没有重载),不同层之间遮蔽是合法行为。
typedef struct Binding { char *name; Type *ty; int is_local; // 1:栈上分配;0:文件作用域,走 .data/.bss int offset; // 局部变量相对 rbp 的负偏移,语义阶段回填 int has_addr; // 被 & 取过地址,代码生成时禁止放进寄存器 int linkage; // 0 无链接 / 1 内部链接(static) / 2 外部链接(extern) struct Binding *next; // 同一桶内的冲突链 } Binding; typedef struct Scope { struct Scope *parent; // 作用域链,查找时逐层向上 Binding *buckets[64]; } Scope; Binding *lookup(Scope *s, const char *name) { for (; s; s = s->parent) // 由内向外,先命中先返回 for (Binding *b = s->buckets[hash(name) % 64]; b; b = b->next) if (!strcmp(b->name, name)) return b; // 内层遮蔽外层,靠遍历顺序保证 return NULL; // 一路到文件作用域都没有,才是未声明 }linkage这个字段单独存在,是因为static int x;出现在文件作用域和函数体里,含义完全不同:前者是“这个符号只在本目标文件可见”,后者是“这个变量放在静态存储区,函数返回后仍然存在”。如果只用一个is_static布尔值,这两个语义就没法区分,链接阶段必然出问题。
offset在语义阶段只是一个占位,真正的数值要等代码生成阶段统计完所有局部变量才能回填。这种“先登记、后回填”的模式在编译器里到处都是:前一层只负责发现“这里需要一个位置”,后一层才决定位置在哪。
3.2 整型提升与寻常算术转换:那些看不见的转换
C 里几乎所有二元运算都伴随着隐式转换,规则分两步:先做整型提升(char、short、位域一律提升到int,前提是int能表示其全部取值),再做寻常算术转换(两边统一到“更宽”的类型,同宽时无符号优先)。
这条规则不是学究式的细节。unsigned char a = 200, b = 100; a + b的结果是 300 而不是 44,因为a、b在相加之前已经提升成int。一个省略提升步骤的编译器会算成 44,而且这个错误在char取值小于 128 时完全看不出来。
// 二元运算前统一两边的类型,缺了这一步,窄类型运算会按位宽截断 Type *usual_arith(Type *a, Type *b) { a = promote(a); // char/short/枚举 -> int,保留有符号性语义 b = promote(b); if (a->size != b->size) // 宽度不同,窄的向宽的看齐 return a->size < b->size ? b : a; if (a->is_unsigned) return a; // 同宽时有符号向无符号看齐 return b; }promote里有个容易忽略的细节:如果int表示不了原来的无符号类型(比如 32 位平台上的unsigned int提升到int会丢范围),就应该提升到unsigned int而不是int。小编译器常在这里一刀切成int,导致大值无符号运算的结果诡异。
指针算术是另一处:p + 1里的1要乘以sizeof(*p),p - q的结果要除以sizeof(*p)才是元素个数。这个缩放因子必须在语义阶段附着在节点上,而不是留到代码生成时临时估算——否则void *和函数指针的算术就没法统一处理。
3.3 左值与右值:能不能取地址决定了变量去哪里
C 的表达式分两种:能取地址的叫左值,不能的叫右值。数组名在大多数上下文里退化成指向首元素的指针,函数名退化成函数指针,这都属于“名字到地址”的转换。
这个区分直接决定代码生成策略。一个局部变量如果从头到尾只被读写、没被取过地址,那它可以舒舒服服地待在寄存器里;一旦出现&x,它的地址就可能被传出去、存起来、在任意时刻被间接改写,编译器必须老老实实把它放在栈上,每次访问都走内存。
所以语义阶段要做的一件事是给每个绑定打上has_addr标记。看起来只是个小字段,实际是“寄存器分配”这个复杂问题的第一道闸门:逃逸分析的最朴素版本就是“取过地址就逃逸”。小编译器不做优化,但仍然需要这个标记,否则&x之后往寄存器里写值、内存里的旧值却还被别处读,程序就会以一种极难复现的方式出错。
注意:
&x出现在死代码里也要算数。小编译器通常不做活跃性分析,看到取地址就标记,宁可多落一次栈,也不要漏掉。这是典型的“用一点性能换正确性”的取舍。
4. 从 AST 到机器码:栈帧、调用约定与寄存器
到了这一层,编译器必须对目标机器做出承诺:局部变量放在哪、函数怎么进怎么出、参数怎么传、返回值放哪。承诺一旦和目标平台的调用约定不一致,程序在单文件内可能完全正常,一链接外部库就崩。这是小编译器最典型的“自测全绿、一用就废”阶段。
4.1 栈帧布局:rbp 链与局部变量的负偏移
绝大多数 x86-64 实现采用帧指针加固定偏移的布局:函数入口保存调用者的rbp,把当前rsp挪到rbp,再往下开一块空间给局部变量。局部变量用[rbp - 4]、[rbp - 8]这样的负偏移访问,参数区在[rbp + 16]往上。
# 函数序言:建立 rbp 链,并把 rsp 对齐到 16 字节边界 push rbp # 保存调用者帧指针,rsp 减 8 mov rbp, rsp # 当前帧的基准点 sub rsp, 32 # 局部变量区,大小必须向上取整到 16 的倍数 mov DWORD PTR [rbp-4], edi # 第一个参数落到局部变量 a 的槽位 mov DWORD PTR [rbp-8], 0 # int b = 0;sub rsp, 32里的 32 不是随便写的:它等于“所有局部变量大小之和向上对齐到 16”。为什么必须是 16 的倍数,下一节会讲。分配偏移时要注意对齐要求:char可以落在任意偏移,int要 4 字节对齐,double和指针要 8 字节对齐。小编译器常见的错误是按声明顺序紧凑排布、不做对齐填充,结果在-O0下访问double时性能暴跌,在某些平台上直接触发对齐异常。
函数出口是入口的镜像:mov rsp, rbp; pop rbp; ret,返回值放在eax(或rax、xmm0,取决于类型)。如果函数声明为返回void,也必须保证调用者不会去读eax里的垃圾值。
4.2 表达式求值:把树压成 rax 的过程
不做寄存器分配的最简策略是“栈机式”代码生成:每个表达式节点的求值结果都落在eax/rax,需要暂存中间值时用push/pop。这种代码质量很差,但正确性极容易保证,非常适合小编译器起步。
# 生成 a * b + c 的代码,结果始终在 eax 里,中间值用栈暂存 mov eax, DWORD PTR [rbp-4] # 左子树 a push rax # 暂存 a mov eax, DWORD PTR [rbp-8] # 右子树 b mov ecx, eax # b 挪到 ecx pop rax # 取回 a,此时 eax = a, ecx = b imul eax, ecx # a * b,结果在 eax add eax, DWORD PTR [rbp-12] # 直接与内存中的 c 相加这段代码暴露了一个关键约束:imul的双操作数形式要求结果寄存器同时是第一个源操作数,所以左右子树的顺序不能随性交换。生成器必须约定“左子树先求值、结果留eax,右子树结果进ecx”,或者用栈保存左值再求右值。
比较运算和逻辑运算的处理方式不同。<、==这类用cmp加setcc,再movzx到 32 位;&&、||必须短路,靠标签和条件跳转实现,不能两边都求值——p != NULL && p->x > 0里,如果先求右边就会解引用空指针。
# p && q 的短路求值:p 为假就直接跳到“结果为 0”的标签 mov eax, DWORD PTR [rbp-4] test eax, eax je .L_false # p 为假,q 根本没被求值 mov eax, DWORD PTR [rbp-8] test eax, eax je .L_false mov eax, 1 jmp .L_end .L_false: xor eax, eax .L_end:4.3 调用约定:六个寄存器、栈对齐与可变参数
x86-64 下普遍遵循的约定是:整数参数按顺序放进rdi、rsi、rdx、rcx、r8、r9,浮点参数走xmm0到xmm7,超出部分从右往左压栈;返回值在rax(浮点在xmm0)。
比参数寄存器更容易被忽略的是栈对齐:执行call指令之前,rsp必须是 16 的倍数。这条规则来自 SSE 指令:movaps要求操作数 16 字节对齐,如果栈一歪,某些 libc 函数内部就会直接触发段错误。而崩溃地点在库函数里,栈回溯一看和你的代码毫无关系,这种 bug 是典型的血泪经验。
# 调用 f(a,b,c,d,e,f,g):前 6 个走寄存器,第 7 个必须压栈 mov edi, DWORD PTR [rbp-4] # a mov esi, DWORD PTR [rbp-8] # b mov edx, DWORD PTR [rbp-12] # c mov ecx, DWORD PTR [rbp-16] # d mov r8d, DWORD PTR [rbp-20] # e mov r9d, DWORD PTR [rbp-24] # f sub rsp, 8 # 先补 8 字节:push 会让 rsp 变成 8 mod 16 push QWORD PTR [rbp-28] # 第 7 个参数 g,此时 rsp 恢复 16 对齐 mov eax, 0 # 可变参数函数约定:al 存用到的向量寄存器个数 call f add rsp, 16 # 弹掉补齐位和栈参数,调用者负责清理mov eax, 0这一行不是玄学:调用可变参数函数(如printf)时,被调函数需要用al判断有多少个向量寄存器被用上,来决定保存多少个xmm。不写这一行,浮点参数可能读出来是垃圾值。同理,栈参数的数量要凑成偶数个 8 字节,缺一个就补一个——这就是前面sub rsp, 8的来历。
结构体传参是这套规则的延伸:小结构体(不超过 16 字节)按字段拆进寄存器,大结构体在调用者栈上分配、传地址,返回值同理。这部分是小编译器最容易做不完整的地方,只支持基本类型和指针是合理的起步选择,但一旦声称支持结构体按值传参,就必须照着规则做完,不能一半寄存器一半栈。
5. 避坑与排查:小编译器最容易翻车的五个地方
写这类编译器时,绝大多数崩溃都不是“算法不会”,而是“某条约定没遵守”。下面五条按踩到概率排序,每条都给出可对照的现象、根因和修法。
5.1 栈没对齐:崩在 libc 里,查无凶手
现象:单文件里所有测试通过,一调用外部库的浮点函数(或带movaps的代码)就段错误,崩溃地址在库里,栈回溯看不到自己写的函数。
原因:call前rsp不是 16 的倍数。函数入口rsp % 16 == 8(返回地址已压栈),push rbp后变 0,sub rsp, N如果 N 不是 16 的倍数,后面所有call就都歪了。
解决:在函数序言里把局部变量区大小向上取整到 16 的倍数;每次call前检查当前rsp是否为 16 对齐,压了奇数个栈参数就补一个sub rsp, 8,调用完add回去。养成“生成call时顺手算一下对齐”的习惯,比事后调试省几十倍时间。
5.2 整型提升缺失:小值全对,大值全错
现象:unsigned char相加、char参与移位、short参与比较时结果不对,但测试用例用小数值(小于 128)跑起来完全正常。
原因:二元运算前没把窄类型提升到int,直接按 8 位或 16 位截断。(char)0xFF == 0xFF这种比较也会因为一边是-1、一边是255而得出反直觉结果。
解决:在语义阶段给每个二元节点插入显式转换节点,让代码生成阶段“看得见”这些转换,而不是靠生成器自觉。转换节点是显式的,断点打上去就能看到类型被提升成了什么。
5.3 取地址逃逸:写进寄存器的值“消失”了
现象:int x = 1; int *p = &x; *p = 2;之后读x仍然是 1,或者更糟——有时是 1 有时是 2,取决于优化开关。
原因:x被分配在寄存器里,&x却给了栈上一个地址,两条路径写的是不同位置。
解决:符号表里加has_addr标记,语义分析遇到一元&就置位;代码生成分配位置时,凡是带标记的变量一律落栈,后续所有访问走[rbp-offset]。这是最朴素的逃逸分析,宁可保守也不要漏。
5.4 词法贪心不足:多字符运算符切错
现象:x >>= 1报语法错误,或者a+++b解析出的结果和预期不一致;把>>=改成>> =反而能过。
原因:切词时按模式书写顺序取第一个匹配,>>排在>>=前面就先命中了。或者手写扫描时遇到>只往后看一位。
解决:严格实现最长匹配,所有候选模式都试一遍取最长的;给每个 token 记录偏移量,出错时打印“偏移 N 附近的 token 序列”,一眼就能看出是被切成了什么。
5.5 结构体对齐算错:跨模块传参时字段错位
现象:单文件里sizeof(struct S)打印出来和外部模块算的不一样,字段偏移对不上,传参后数据错乱,但两边代码看起来一模一样。
原因:结构体成员按声明顺序紧凑排布,没有插入对齐填充;或者结构体整体的对齐值取错,应该是成员里最大的对齐值(并有上限)。
解决:每个成员先按自身对齐要求把当前偏移向上取整,插入填充;结构体总大小再向上取整到整体对齐值的倍数。这条规则必须和外部 ABI 完全一致,否则“能编译、能运行、结果错”就是必然。
6. 设计取舍:为什么小编译器宁可“笨”一点
看完上面几层,会浮现一个疑问:既然知道寄存器分配、指令选择、常量折叠都能让代码更快,为什么这类编译器大多选择“每个表达式都回rax、中间值全压栈”这种朴素方案?答案不在技术难度,而在可验证性。
一个几千行的编译器,最贵的成本不是写,是“改完之后怎么知道没改坏”。栈机式代码生成的最大好处是每个节点的行为局部可推理:想看a + b生成了什么,就看那三条指令,不需要在全局寄存器状态里推演。这换来的是极低的调试成本。等到语法覆盖稳定、测试用例攒够,再换成线性扫描分配寄存器,才有对照基准——先有正确版本,才谈得上优化版本。
| 选择 | 朴素做法 | 进阶做法 | 什么时候该换 |
|---|---|---|---|
| 中间表示 | AST 直接生成汇编 | AST → 三地址码 → 汇编 | 需要做常量折叠、公共子表达式消除时 |
| 遍历次数 | 单遍边解析边生成 | 多遍,中间落 IR | 错误信息要精确到行、或要做优化时 |
| 变量位置 | 全部落栈 | 逃逸分析 + 寄存器分配 | 有性能诉求且测试覆盖足够时 |
| 参数传递 | 只支持基本类型 | 结构体拆分/隐式指针 | 需要和外部库结构体互操作时 |
另一处典型取舍是“要不要自举”。让编译器编译自己,是最狠的一致性测试:任何边角语义没实现全,自举立刻失败。但自举之前必须先能正确处理预处理、#include、多文件编译,门槛不低。常见路线是先支持单文件、无预处理的一个子集,用一批手写用例把语义钉死,再逐步往自举推进。这个过程里,差分测试比单元测试更有效——同一个程序,用系统自带编译器编一份、用自己的编一份,比对运行输出,比逐个断言函数返回值更能发现约定层面的偏差。
还有一条容易被忽视的取舍:错误恢复。小编译器往往遇到第一个语法错误就退出,这在自测时无所谓,但一旦给别人用,第一个错误后面藏着十个错误,用户就得改一次编译一次。把“同步到下一个分号再继续解析”这种最小恢复做进去,成本不高,体验差距巨大。
从那以后,我给这个编译器加任何一条新语法,都强制先写一个最小正例和一个反例,两条都跑过才允许合进去——因为编译器最可怕的不是报错,而是默默生成错误的代码,而后者只有反例能抓住。希望帮到你。
本文还有配套的精品资源,点击获取