☰
小型C语言编译器源码拆解:从词法分析到x86-64汇编
2026/10/29 20:31:21 网站建设 项目流程

简介:这是一份面向编译原理学习者与C语言进阶开发者的C语言编译器源码,覆盖词法分析、语法分析、语义检查、基本优化与目标代码生成等环节,适合对照教材梳理编译流程,也可作为课程实验或自研编译器的起点。压缩包共86个文件、约210KB,其中50个C源文件与12个头文件承载核心实现,另含预处理与汇编输出相关代码、说明文档、批处理构建脚本和配置文件,整体轻量,便于通读、裁剪与调试。已有480人学习。通过阅读和修改代码,读者可以观察符号表管理、抽象语法树构造、表达式求值、寄存器分配与窥孔优化等实现细节,理解指针、结构体、函数调用等特性在编译流程中的处理方式,并借助随附说明与构建脚本完成编译验证和二次开发,积累排错与性能优化经验。

1. 从一份「能跑但不敢改」的源码说起

拿到这份小型 C 语言编译器源码时,我第一反应是把它当成教学玩具:这类项目十有八九是把上千行逻辑塞进一个 main 函数,用巨型 switch 硬扛。实际翻下来不是。词法、语法、语义、代码生成被拆成独立的 .cpp,阶段之间只靠 Token 序列和 AST 两个数据结构传递,边界清晰到你可以单独把 lexer 拎出来做单元测试。它支持的 C 子集不算大——int、指针、数组、函数、if/while/for、函数调用,没有结构体和浮点——但一个带递归和指针运算的 .c 文件确实能被它编译成 x86-64 汇编,再由系统工具链链接成真正跑得起来的可执行文件。适合谁?想把「int a = 1 + 2;」这句话从头到尾拆成 mov 和 add 的人,以及想在自己项目里塞一个表达式求值引擎的人。

2. 整体架构与目录:从 .c 到汇编的五段流水线

2.1 一条编译流水线是怎么分层的

这个项目最值得先看的是它的阶段划分,而不是任何一段具体代码。它把整个编译过程切成五段,每段的输入输出都是明确定义的数据结构,中间不共享全局状态。这种切法的好处是调试时你可以把任意一段的输出打印出来,单独验证,不用一路单步到底。

阶段输入输出对应源码调试时看什么
词法分析源文件字符流Token 序列src/lexer.cpp每个 Token 的 kind 和 text
语法分析Token 序列AST 根节点src/parser.cppAST 的树形打印
语义分析AST回填类型和栈偏移的 ASTsrc/sema.cpp符号表内容、变量 offset
代码生成标注后的 AST.s 汇编文本src/codegen.cpp生成的汇编逐行
汇编链接.s 文件可执行文件调用系统 cc链接器报错

这么切有一个隐含约束:AST 节点在语义分析阶段是被就地修改的,类型指针和栈偏移直接写回节点,代码生成阶段不再做任何查表判断。这是个很实用的取舍——省掉了一遍遍历,代价是语义分析和代码生成必须严格按顺序执行,不能单独跑代码生成。我在自己项目里也这么干,因为这阶段的性能根本不重要,好调试才是第一位的。

真正让这套流水线能落地的,是阶段之间的数据结构足够简单。Token 只有五个字段,AST 节点是个带十几个可选指针的 struct,没有虚函数、没有访问者模式、没有智能指针。初学者容易觉得这写法「不够现代」,但你改语法的时候就知道好处了:加一个节点类型,编译器会直接告诉你哪些 switch 没覆盖到。

2.2 目录结构与各文件职责

目录组织得很平,没有过度分包。头文件集中在 include/,实现集中在 src/,运行时支持单独放 runtime/,测试用例按语言特性分组。

mini-cc/ ├── Makefile ├── include/ │ ├── token.h Token 结构与词法种类枚举 │ ├── ast.h AST 节点、节点种类枚举 │ ├── type.h 类型表示:int、指针、数组、函数 │ └── symtab.h 符号表、作用域链、变量对象 ├── src/ │ ├── main.cpp 命令行入口:读文件、串流水线、输出 .s │ ├── lexer.cpp 字符流到 Token │ ├── parser.cpp 递归下降,Token 到 AST │ ├── type.cpp 类型构造与比较 │ ├── symtab.cpp 作用域进出、变量登记与查找 │ ├── sema.cpp 类型检查、隐式转换插入、栈偏移分配 │ └── codegen.cpp AST 到 x86-64 AT&T 汇编 ├── runtime/ │ └── runtime.c 最小的运行时支持,目前只有打印和退出 └── tests/ ├── basic/ 算术、赋值、比较 ├── control/ if、while、for ├── func/ 函数定义、调用、递归 └── run_tests.sh 批量编译 + 运行 + 比对输出

main.cpp 只做三件事:解析命令行参数拿到输入文件路径、依次调用四个阶段、把 codegen 的输出写到 stdout 或指定文件。它不持有任何编译状态,所有上下文都通过参数传递。这种写法在小型项目里很常见,好处是你想把它嵌进别的程序时,只要不调 main 就行。

type.cpp 和 symtab.cpp 是两个容易被忽略但很关键的文件。类型系统用的是一个带 kind 的 struct 加指针链,int 是单例,指针类型在遇到*时现场构造。符号表则是作用域链,每个块一个作用域,带 parent 指针向上查找。这两块如果不单独拆出来,sema.cpp 会迅速膨胀到无法维护。

2.3 构建方式与零第三方依赖

构建只依赖一个 C++17 编译器和 make,没有任何第三方库。Makefile 短到可以背下来:

CXX := c++ CXXFLAGS := -std=c++17 -Wall -Wextra -g -O0 SRCS := $(wildcard src/*.cpp) OBJS := $(SRCS:src/%.cpp=build/%.o) TARGET := build/minicc $(TARGET): $(OBJS) $(CXX) $(CXXFLAGS) -o $@ $^ build/%.o: src/%.cpp @mkdir -p build $(CXX) $(CXXFLAGS) -c -o $@ $< clean: rm -rf build

-O0 -g是有意为之。编译器自己跑得快不快无所谓,你要的是能 gdb 进去单步看 AST 构建过程。等你要拿它编译大文件了再考虑开优化,那时候瓶颈通常也不在编译速度上。-Wall -Wextra建议保留,AST 的 switch 漏分支、有符号无符号比较这类问题,靠警告能拦下大半。

提示:如果你的系统默认 c++ 指向的版本太老,把 CXX 显式改成带版本号的命令即可。C++17 是硬要求,代码里用了 std::string_view 和结构化绑定。

3. 词法与语法分析:手写 Lexer 与递归下降 Parser

3.1 Token 定义与词法扫描

词法层的设计目标是「足够定位错误」。除了种类和文本,每个 Token 都带行号和列号,报错时能直接指到源码位置。这一点在你自己写编译器时千万别省,否则用户看到一个「语法错误」却不知道错在哪一行,体验会非常差。

// include/token.h enum class TK { Ident, // 标识符:变量名、函数名 Num, // 整数字面量 Keyword, // int return if else while for Punct, // 运算符与分隔符 Str, // 字符串字面量,目前只给 runtime 的打印用 Eof // 结束标记,解析器靠它收敛 }; struct Token { TK kind; std::string text; // 原始文本,报错时原样回显 long value; // Num 的数值,其他种类填 0 int line; int col; };

扫描主循环的骨架如下。这里的顺序很讲究:先跳空白和注释,再判断标识符、数字,最后才是符号。多字符运算符必须比单字符先匹配,否则!=会被拆成!和=,解析器拿到的 Token 流就完全错了。

// src/lexer.cpp Token Lexer::next() { skip_space_and_comment(); // 吃掉空格、换行、// 行注释和 /* */ 块注释 Token t; t.line = line_; t.col = col_; if (at_end()) return {TK::Eof, "", 0, line_, col_}; // 标识符或关键字:首字符是字母或下划线 if (isalpha(peek()) || peek() == '_') { t.text = read_ident(); t.kind = is_keyword(t.text) ? TK::Keyword : TK::Ident; return t; } // 十进制整数,溢出直接按 long 截断,够教学用例用 if (isdigit(peek())) { t.kind = TK::Num; t.value = read_number(); return t; } // 双字符运算符优先,避免被拆开 for (const char* op : {"==", "!=", "<=", ">=", "&&", "||"}) { if (match(op)) { t.kind = TK::Punct; t.text = op; return t; } } // 剩下的都是单字符符号 t.kind = TK::Punct; t.text = std::string(1, advance()); return t; }

skip_space_and_comment里有个小细节值得注意:块注释没闭合时要报错并带上起始行号,不能默默吃到文件尾。这种「注释吞掉半个文件」的 bug 一旦发生,现象是解析器报一个莫名其妙的语法错误,排查起来能耗掉半天。

3.2 递归下降与 AST 节点

AST 节点用一个 struct 打天下,不为每种节点建一个类。这是小型编译器的常见做法,代价是字段冗余,好处是代码生成阶段写一个 switch 就能覆盖全部节点,加新节点时编译器会替你检查。

// include/ast.h enum class NK { Num, Var, Binary, Assign, Call, Return, If, While, Block, Func }; struct Node { NK kind; Node* lhs = nullptr; // 左操作数 / 条件 Node* rhs = nullptr; // 右操作数 Node* then = nullptr; // if 的真分支 / while 的循环体 Node* els = nullptr; // if 的假分支 std::vector<Node*> stmts; // Block 内的语句序列 std::vector<Node*> args; // Call 的实参 std::string name; // 变量名或函数名 long value = 0; // Num 的字面值 Type* ty = nullptr; // 语义分析回填 int offset = 0; // 局部变量相对 rbp 的偏移 };

解析器的每个函数对应文法里的一条产生式,函数名就是非终结符名。expr()是最外层入口,一路往下拆到primary(),中间任何一层匹配成功就把节点往上返回。这种写法读起来和文法几乎一一对应,改语法时改哪个函数一目了然。

// src/parser.cpp Node* Parser::expr() { return assign(); } // 赋值是右结合的,所以单独一层,右侧递归回 assign 而不是 equality Node* Parser::assign() { Node* node = equality(); if (consume_if("=")) { Node* rhs = assign(); // 注意:递归 assign,保证 a=b=c 右结合 return new_binary(NK::Assign, node, rhs); } return node; } // 相等比较是左结合的,用循环吃掉所有同级运算符 Node* Parser::equality() { Node* node = relational(); for (;;) { if (consume_if("==")) node = new_binary(NK::Binary, node, relational()); else if (consume_if("!=")) node = new_binary(NK::Binary, node, relational()); else return node; } }

consume_if的语义是「匹配就前进并返回 true,不匹配就原地不动返回 false」。这个函数是递归下降的核心工具,所有可选结构都靠它。它内部要负责跳过注释,但注释在词法层已经处理掉了,所以这里只需要比对文本。

3.3 优先级与结合性:为什么赋值要单独一层

递归下降里最容易写错的就是结合性。左结合的运算符(加减乘除、比较)用循环,右结合的运算符(赋值)用递归,这两者不能混。上面assign()里右侧递归回assign而不是equality,就是为了让a = b = c解析成a = (b = c)。如果写成递归equality,a = b = c会先解析出a = b,然后剩下的= c无处安放,直接报语法错误。

整条优先级从低到高排下来是这样的,每一层调用下一层,最底层是 primary:

层级函数运算符结合性
1assign=右
2equality==!=左
3relational<<=>>=左
4add+-左
5mul*/%左
6unary-!*&右(前缀)
7primary数字、标识符、括号、函数调用—

primary()是唯一处理括号的地方:遇到(就递归回expr(),吃掉),把里面的子树原样返回。这一句递归让任意深度的括号嵌套都能工作,也是递归下降最能体现「文法即代码」的地方。

4. 语义分析与代码生成:符号表、栈帧与 x86-64 汇编

4.1 符号表、作用域链与变量解析

符号表用最朴素的作用域链:进入一个块就压一个新作用域,离开就弹掉。查找从当前作用域开始,顺着 parent 一路往上,找到第一个同名变量就停。这正好对应 C 语言的就近遮蔽规则。

// include/symtab.h struct Obj { // 一个变量或函数的描述 std::string name; Type* ty; int offset; // 局部变量:相对 rbp 的负偏移 bool is_local; }; struct Scope { std::unordered_map<std::string, Obj*> vars; Scope* parent; }; // 从内向外找,找不到返回 nullptr,由调用方决定报错还是当函数名处理 Obj* Scope::find(const std::string& name) { for (Scope* s = this; s; s = s->parent) { auto it = s->vars.find(name); if (it != s->vars.end()) return it->second; } return nullptr; }

这里有个必须提前想清楚的问题:C 语言里函数名和变量名共用一个命名空间,所以函数也要登记到全局作用域。项目里的做法是在解析完函数定义后立刻把函数对象塞进全局作用域,这样递归调用才能找到自己。忘了这一步的典型现象是「递归函数调用自己时报未定义标识符」,第一次遇到会非常困惑。

4.2 类型推导与隐式转换

类型表示用一个带 kind 的 struct,指针和数组靠 base 指针链起来。语义分析阶段做两件事:给每个表达式节点回填类型,以及在需要的地方插入隐式转换节点。这个项目只支持 int 和指针,所以类型检查很简单,主要是判断「指针加整数」和「指针减指针」这两种情况。

常见做法是在 AST 里加一个隐式转换节点,而不是在代码生成阶段临时判断。这样代码生成只需要看节点类型,不用再关心原始表达式的类型是什么。代价是多一次遍历,但逻辑清晰得多。我一般会先把所有隐式转换插入完,再进代码生成,避免两件事搅在一起。

4.3 栈帧布局:局部变量到底存在哪

局部变量的地址在语义分析阶段就确定下来,相对 rbp 的负偏移,从 -8 开始往下排。为了简单,每个变量不管实际多大都占 8 字节槽位,最后整个栈帧按 16 字节对齐。这个对齐是 System V ABI 的硬要求,不满足的话调用外部函数时可能直接崩。

// src/sema.cpp void assign_lvar_offsets(Function* fn) { int offset = 0; for (Obj* var : fn->locals) { offset += 8; // 每个局部变量占一个 8 字节槽 var->offset = -offset; // 相对 rbp 的负偏移 } // 栈帧大小按 16 字节向上取整,调用前 rsp 必须 16 字节对齐 fn->stack_size = (offset + 15) / 16 * 16; }

函数参数的偏移是正的,从 16(%rbp) 开始,因为调用者的返回地址和保存的 rbp 占了最上面 16 字节。这个细节如果记错,现象是「函数第一个参数永远是垃圾值」,但代码看起来完全正常,属于典型的血泪经验。

4.4 从 AST 到 x86-64 汇编:表达式与控制流

代码生成用的是最朴素的栈式求值:左操作数算完压栈,右操作数算完放 rax,然后把左操作数弹到 rdi,最后按运算符发指令。没有寄存器分配,没有窥孔优化,生成的汇编又长又慢,但逻辑一目了然,非常适合拿来学习。

// src/codegen.cpp void gen_expr(Node* node) { switch (node->kind) { case NK::Num: printf(" mov $%ld, %%rax\n", node->value); // 立即数要加 $ return; case NK::Var: // 局部变量从 rbp 负偏移处取,8 字节 load printf(" mov %d(%%rbp), %%rax\n", node->offset); return; case NK::Binary: { gen_expr(node->lhs); printf(" push %%rax\n"); // 左操作数入栈 gen_expr(node->rhs); printf(" pop %%rdi\n"); // 左操作数弹到 rdi,右操作数在 rax // 减法和除法有方向性,必须是 rdi 减/除 rax if (node->op == "-") printf(" sub %%rax, %%rdi\n"); else if (node->op == "/") printf(" ...\n"); // 除法需要 cqo + idiv printf(" mov %%rdi, %%rax\n"); // 结果统一放回 rax return; } } }

控制流靠标签实现。if生成两个标签,条件为假跳到 else 标签;while生成循环头、循环体、循环尾三个标签。标签名用全局自增计数器保证唯一,while里再套while也不会撞名。

// if 语句的标签生成骨架 int else_id = next_label(); printf(" # if\n"); gen_expr(node->cond); printf(" cmp $0, %%rax\n"); printf(" je .L.else.%d\n", else_id); // 条件为假跳到 else gen_stmt(node->then); printf(" jmp .L.end.%d\n", else_id); printf(".L.else.%d:\n", else_id); if (node->els) gen_stmt(node->els); printf(".L.end.%d:\n", else_id);

5. 构建、跑通与排查:从 make 到第一个可执行文件

5.1 构建、跑通与端到端验证

先确认三件事:C++17 编译器可用、make 可用、系统里有能汇编 .s 的 C 工具链。满足之后,整个流程只有三步。

# 1. 构建编译器本体,产物在 build/minicc make # 2. 拿一个测试文件走一遍,输出汇编 ./build/minicc tests/basic/add.c > /tmp/add.s # 3. 汇编链接成可执行文件,runtime.c 提供最小的打印支持 cc -o /tmp/add /tmp/add.s runtime/runtime.c /tmp/add

第二步是把编译器当成一个「.c 进、.s 出」的过滤器,这样设计的好处是你可以拿它的输出和人工写的汇编对比,也可以直接丢给汇编器验证语法。第三步把运行时支持一起链进去,是因为生成的代码里调用了打印函数,不链接会报未定义符号。

想批量验证,用项目自带的脚本:

#!/usr/bin/env bash # tests/run_tests.sh:编译每个 .c,运行,和同名的 .expected 比对 set -e pass=0; fail=0 for src in tests/*/*.c; do base="${src%.c}" ./build/minicc "$src" > /tmp/t.s cc -o /tmp/t /tmp/t.s runtime/runtime.c if /tmp/t | diff -q - "${base}.expected" > /dev/null; then pass=$((pass+1)) else echo "FAIL: $src"; fail=$((fail+1)) fi done echo "pass=$pass fail=$fail"

5.2 五类高频翻车现场:现象、原因、解决

现象一:汇编器报「junk at end of line」。原因基本只有一个:输出格式化字符串时忘了转义百分号。printf 里写"mov %rax, %rbp",%r被当成格式符吃掉了,生成出来的汇编缺字符。解决是把所有寄存器前的%写成%%,改完先肉眼扫一遍 codegen.cpp 里所有 printf。

现象二:程序跑完在函数返回时段错误。原因通常是栈没对齐。System V ABI 要求 call 指令执行前 rsp 是 16 字节对齐,你的栈帧大小如果不是 16 的倍数,调用外部函数时就会错位。解决是把 stack_size 向上取整到 16 的倍数,在函数序言里一次性sub $N, %rsp。

现象三:两个变量值互相串了。原因多半是作用域弹出时没恢复,或者嵌套块的变量 offset 分配重复。表现是内层块改了变量,外层读出来也变了。解决是每次进入块先保存当前 offset 游标,离开时恢复,变量登记只在当前作用域里做。

现象四:a - b算出来是反的。原因在二元运算的求值顺序。左操作数入了栈,右操作数在 rax,pop 到 rdi 之后必须发sub %rax, %rdi,写成sub %rdi, %rax就反了。除法和取模同理,还要先 cqo 再 idiv。这类错误生成的汇编看起来完全合法,只能靠单元测试兜住。

现象五:while (1)循环后面的代码不执行,或者直接死循环。原因有两个:一是循环尾标签和循环头标签写反了,跳回了错误位置;二是函数末尾没有隐式return 0,控制流掉进了下一个函数的标签区。解决是每个函数结束都补一段返回 0 的默认路径,标签统一用「函数名 + 自增编号」生成。

注意:这五类错误里有四类在生成层,也就是 assembler 和链接器会给你报错或运行时崩,只有第四类是完全静默的。所以每加一个二元运算符,都要配一个正负数的用例,别只测正数。

6. 二次开发:加语法特性与换后端的改造路径

想在这个项目上加东西,先搞清楚一件事:它没有 IR,AST 直接生成汇编。这意味着加语法特性和加后端优化是两件事,前者改前端,后者要动大手术。我一般按下面这个顺序推进。

加一个新语法特性,比如复合赋值+=和for循环,标准路径是四步。先改 lexer,+=是多字符运算符,加进双字符匹配表;再改 parser,+=可以在 assign 那层展开成a = a + b,不用新增节点;for循环最省事的做法是在 parser 里直接降级成Block{ init; While{ cond, Block{ body; step } } },这样 codegen 一行不用改。

改动涉及文件验证方法
+=-=token.h、lexer.cpp、parser.cpp写a += 3和a = a + 3两个用例比对输出
for循环parser.cpp(降级为 while)三层嵌套 for,确认标签不冲突
break/continueparser.cpp、codegen.cpp循环内 break,看跳转标签是否落到循环尾
新后端新增 ir.cpp、codegen_xxx.cpp用同一批 tests 跑回归

换后端是个更有意思的练习。我的建议是先在 AST 和 codegen 之间插一层三地址码 IR,每条指令最多一个运算符,带显式临时变量。有了 IR,你可以先写一个解释器跑通语义,再写后端生成目标代码,两边对比结果。这样做会多花两三天,但后面加常量折叠、死代码消除这些优化时,你会庆幸自己当初没偷懒直接在 AST 上改。

还有一个容易被忽略的点:测试。这个项目的 tests 目录按语言特性分组,加特性时一定要同时加对应的 .c 和 .expected。我见过太多人改完 parser 只手动跑一个例子,回头把别的特性改坏了都不知道。run_tests.sh 虽然简陋,但它能在三秒内告诉你有没有回归,这个投入产出比极高。

从那以后我每次动这个编译器的前端,都强制先跑一遍全量测试再提交,哪怕是改一个运算符优先级这种「看起来不可能出错」的地方——因为在编译器里,看起来不可能出错的地方往往就是最容易翻车的地方。希望这份拆解能帮你少走几步弯路。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询