首页 / 资讯中心 / 文章详情

小型C语言编译器源码拆解:从词法分析到x86-64汇编

小型C语言编译器源码拆解:从词法分析到x86-64汇编 ★ FEATURED ARTICLE
简介这是一份面向编译原理学习者与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 ATT 汇编 ├── 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 构建方式与零第三方依赖构建只依赖一个 C17 编译器和 make没有任何第三方库。Makefile 短到可以背下来CXX : c CXXFLAGS : -stdc17 -Wall -Wextra -g -O0 SRCS : $(wildcard src/*.cpp) OBJS : $(SRCS:src/%.cppbuild/%.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 显式改成带版本号的命令即可。C17 是硬要求代码里用了 std::string_view 和结构化绑定。3. 词法与语法分析手写 Lexer 与递归下降 Parser3.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::vectorNode* stmts; // Block 内的语句序列 std::vectorNode* 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保证 abc 右结合 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)。如果写成递归equalitya 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_mapstd::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 构建、跑通与端到端验证先确认三件事C17 编译器可用、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 pass0; fail0 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$((pass1)) else echo FAIL: $src; fail$((fail1)) fi done echo pass$pass fail$fail5.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算出来是反的。原因在二元运算的求值顺序。左操作数入了栈右操作数在 raxpop 到 rdi 之后必须发sub %rax, %rdi写成sub %rdi, %rax就反了。除法和取模同理还要先 cqo 再 idiv。这类错误生成的汇编看起来完全合法只能靠单元测试兜住。现象五while (1)循环后面的代码不执行或者直接死循环。原因有两个一是循环尾标签和循环头标签写反了跳回了错误位置二是函数末尾没有隐式return 0控制流掉进了下一个函数的标签区。解决是每个函数结束都补一段返回 0 的默认路径标签统一用「函数名 自增编号」生成。注意这五类错误里有四类在生成层也就是 assembler 和链接器会给你报错或运行时崩只有第四类是完全静默的。所以每加一个二元运算符都要配一个正负数的用例别只测正数。6. 二次开发加语法特性与换后端的改造路径想在这个项目上加东西先搞清楚一件事它没有 IRAST 直接生成汇编。这意味着加语法特性和加后端优化是两件事前者改前端后者要动大手术。我一般按下面这个顺序推进。加一个新语法特性比如复合赋值和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 虽然简陋但它能在三秒内告诉你有没有回归这个投入产出比极高。从那以后我每次动这个编译器的前端都强制先跑一遍全量测试再提交哪怕是改一个运算符优先级这种「看起来不可能出错」的地方——因为在编译器里看起来不可能出错的地方往往就是最容易翻车的地方。希望这份拆解能帮你少走几步弯路。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站