几百行代码实现C编译器:词法语法分析与代码生成核心原理
简介这份资源是一套小型C编译器的完整源代码面向希望深入理解编译原理、想动手实践词法分析、语法分析与代码生成的学习者也适合作为高校编译课程设计或自研编译器的起点。压缩包共86个文件约210KB以50个c源文件与12个h头文件为核心另含8个p、5个txt说明、3个bat批处理及makefile、cfg配置等覆盖词法分析、语法解析、语义检查、优化与目标代码生成等模块并针对386与68系列架构分别实现。已有479人学习下载。通过阅读和修改这些代码读者可以掌握标记识别、抽象语法树构建、符号表管理、常量折叠与窥孔优化等具体技术理解指针、结构体、函数调用等C语言特性的底层处理方式并借助批处理与配置文件完成编译构建是学习编译器工作流程的实用教材。1. 编译器不是黑魔法一个几百行的小型 C 编译器凭什么能跑通很多人第一次听说「用几百行代码实现一个 C 编译器」时第一反应是不信。毕竟 GCC、Clang 动辄几百万行光是词法分析器就够写一个中型项目了。但如果你把「编译器」这个词拆开看它本质上只做一件事把一种形式化的文本翻译成另一种形式化的文本中间顺带做点语义检查。真正让它显得庞大的是优化、平台适配、错误恢复、标准兼容这些工程性负担而不是「翻译」这个核心动作本身。这份小型 C 编译器源码的价值恰恰在这里。它把编译器最不可省略的那条骨架抽了出来——词法分析、语法分析、语义动作、代码生成——每一段都短到你能在一个下午读完但每一段又都真实地在工作。读完它你对「编译器为什么能 work」的理解会从「大概知道有 AST 这回事」变成「我知道那个栈是怎么压的、那个寄存器是怎么分配的」。C语言编译器这个词在搜索里常和「原理」「底层」「手写」绑在一起说明大家真正想搞懂的不是怎么调 API而是这套翻译机制到底怎么闭环。2. 词法与语法为什么先切词再建树而不是一遍扫完2.1 词法分析的本质是「用最长匹配把字符流切成记号」编译器面对的第一件事是输入是一串没有结构的字符输出需要是有结构的记号序列。词法分析器lexer干的就是这个切分工作。它的核心规则只有两条跳过空白和注释然后对每个位置尝试匹配一个记号token匹配时取能匹配的最长那个。常见的实现思路是用一个手写的状态机而不是正则表达式库。原因很实际手写状态机没有依赖、启动快、出错时能精确报出列号。下面这段是典型的记号定义和扫描循环的骨架// 记号类型关键字、标识符、数字、符号、结束 typedef enum { TK_IDENT, TK_NUM, TK_PUNCT, TK_KEYWORD, TK_EOF } TokenKind; typedef struct Token Token; struct Token { TokenKind kind; // 记号类别 Token *next; // 链表下一个方便语法分析顺序取用 int val; // 如果是数字存它的值 char *loc; // 在源码中的起始位置报错用 int len; // 记号长度 }; // 扫描主循环不断从当前位置取出一个记号挂到链表上 Token *tokenize(char *p) { Token head {0}; Token *cur head; while (*p) { // 跳过空白 if (isspace(*p)) { p; continue; } // 数字连续读数字字符边读边算值 if (isdigit(*p)) { cur cur-next new_token(TK_NUM, p, p); char *q p; cur-val strtoul(p, p, 10); cur-len p - q; continue; } // 标识符/关键字字母开头后接字母数字下划线 if (isalpha(*p) || *p _) { char *q p; while (isalnum(*p) || *p _) p; cur cur-next new_token(TK_IDENT, q, p); continue; } // 其余按单字符符号处理 cur cur-next new_token(TK_PUNCT, p, p 1); p; } cur-next new_token(TK_EOF, p, p); return head.next; }这段代码里有两个值得注意的设计。第一记号用链表串起来而不是数组因为语法分析器天然是顺序消费的链表让「取下一个」变成 O(1)不需要维护下标。第二每个记号都带loc和len指向原始源码的位置。这不是装饰而是错误报告的根基——当语法分析说「这里期望一个分号」时它能直接指回用户写的那一行那一列而不是吐一个内部编号。strtoul那行是血泪经验手写数字解析要处理溢出、前导零、进制前缀标准库已经把这些边界都趟过了没必要重造。参数p是输出参数函数返回解析出的值同时把p推进到数字之后这样cur-len才能算对。2.2 语法分析用递归下降是因为 C 的语法天然分层拿到记号序列后语法分析器parser要把它组织成一棵树也就是抽象语法树AST。为什么用递归下降而不是 yacc/bison 那类工具因为 C 的语法结构是分层的表达式里有加减乘除乘除的优先级高于加减括号又能改变优先级。递归下降用「一个函数对应一层优先级」的方式把优先级直接编码进调用关系里读起来和语法定义几乎一一对应。核心机制是每个解析函数负责一层遇到更高优先级的子结构就调用下一层的函数返回时把结果组装成节点。下面是一个只支持加减乘除和括号的表达式解析器// AST 节点二元运算、数字、取负 typedef enum { ND_ADD, ND_SUB, ND_MUL, ND_DIV, ND_NUM, ND_NEG } NodeKind; typedef struct Node Node; struct Node { NodeKind kind; Node *lhs; // 左操作数 Node *rhs; // 右操作数数字节点不用 int val; // 数字节点的值 }; // 解析「乘除层」先取一个一元表达式然后只要看到 * 或 / 就继续结合 Node *mul(void) { Node *node unary(); for (;;) { if (consume(*)) node new_binary(ND_MUL, node, unary()); else if (consume(/)) node new_binary(ND_DIV, node, unary()); else return node; } } // 解析「加减层」先取一个乘除表达式然后只要看到 或 - 就继续结合 Node *expr(void) { Node *node mul(); for (;;) { if (consume()) node new_binary(ND_ADD, node, mul()); else if (consume(-)) node new_binary(ND_SUB, node, mul()); else return node; } }consume的作用是「如果当前记号匹配就吃掉并返回真否则不动」。这个设计让优先级关系变得显式expr调用mul所以乘除永远先于加减被组装成子树mul调用unary所以取负号比乘除绑得更紧。左结合性由for(;;)循环实现——每次把已经建好的node当作左操作数新解析的当作右操作数自然形成左倾的树。我一般会这么理解递归下降的代价它把语法规则写死在函数调用里改语法要改代码不像生成器那样改文法文件就行。但换来的是可读性和可调试性——你可以在任意一层打断点看当前记号是什么、树建到哪了。对于一个小型编译器这个取舍是划算的。3. 语义与代码生成AST 怎么变成能跑的机器码3.1 从 AST 到栈式代码核心是「后序遍历 寄存器栈」有了 AST下一步是生成目标代码。小型编译器通常不直接生成机器码而是生成汇编文本交给系统汇编器去处理。这样既避开了指令编码的细节又能真正跑起来。生成策略是后序遍历先算左子树再算右子树最后做运算。因为运算结果要暂存最自然的做法是用栈。常见的实现思路是维护一个「深度计数器」记录当前有多少个中间值压在栈上。每算完一个子树结果在%rax里如果还要算另一个子树就把%rax压栈算完再弹回来。下面是一个二元运算的生成逻辑// 生成表达式代码结果留在 %rax void gen_expr(Node *node) { switch (node-kind) { case ND_NUM: printf( mov $%d, %%rax\n, node-val); return; case ND_NEG: gen_expr(node-lhs); printf( neg %%rax\n); return; } // 二元运算先算右子树压栈再算左子树弹出右值 gen_expr(node-rhs); printf( push %%rax\n); gen_expr(node-lhs); printf( pop %%rdi\n); switch (node-kind) { case ND_ADD: printf( add %%rdi, %%rax\n); break; case ND_SUB: printf( sub %%rdi, %%rax\n); break; case ND_MUL: printf( imul %%rdi, %%rax\n); break; case ND_DIV: printf( cqo\n idiv %%rdi\n); break; } }这里有个反直觉的点先算右子树再算左子树。原因是 x86 的sub和idiv是有方向的——sub %rdi, %rax算的是%rax - %rdi。如果先算左子树左值在%rax压栈后算右子树右值在%rax弹出左值到%rdi那sub就变成「左减右」还是「右减左」就取决于你怎么放。先算右子树、压栈、再算左子树弹出的是右值到%rdi此时%rax是左值sub %rdi, %rax正好是左减右。这个顺序不是随便定的是被指令语义逼出来的。cqo那行是符号扩展idiv要求被除数是 128 位%rdx:%raxcqo把%rax的符号位扩展到%rdx。漏了这行负数除法就会翻车。这类细节在源码里往往只有一行但少了它整个程序就是错的。3.2 局部变量靠栈帧参数传递靠寄存器约定表达式能算了接下来要支持变量。局部变量存在栈上每个函数有自己的栈帧。函数入口先把%rbp压栈保存然后把%rsp赋给%rbp作为帧基址之后局部变量就用%rbp的负偏移来寻址。// 函数序言保存旧帧基址建立新帧 printf( push %%rbp\n); printf( mov %%rsp, %%rbp\n); printf( sub $%d, %%rsp\n, stack_size); // 给局部变量留空间 // 局部变量赋值值在 %rax存到 %rbp 偏移处 printf( mov %%rax, %d(%%rbp)\n, var-offset); // 函数尾声恢复栈和帧基址 printf( mov %%rbp, %%rsp\n); printf( pop %%rbp\n); printf( ret\n);参数传递遵循 System V AMD64 约定前六个整型参数放%rdi、%rsi、%rdx、%rcx、%r8、%r9超出的压栈。调用函数前要把参数搬到对应寄存器然后call。返回值统一放%rax。这套约定不是编译器作者定的是操作系统和 ABI 定的编译器必须遵守否则就没法和标准库、和系统调用对接。stack_size的计算有个常见做法先遍历一遍 AST 收集所有局部变量给每个分配一个偏移最后把偏移总量对齐到 16 字节。对齐是 ABI 要求不对齐在某些指令比如 SSE 相关的上会直接崩。这个「先收集再分配」的两遍策略是编译器里反复出现的模式——很多信息要等整棵树看完才能定。4. 避坑与排查手写编译器最容易翻车的几个地方4.1 记号链表没设结束标记语法分析读到野指针现象是程序偶尔段错误偶尔又能跑完换个输入就崩。原因通常是词法分析器在末尾没有插入TK_EOF语法分析器在「期望结束」时继续读next读到空指针或已释放内存。解决是强制在tokenize返回前挂一个TK_EOF节点并且所有「取下一个」的地方都先判断kind ! TK_EOF。这个坑的隐蔽性在于短输入可能恰好后面有零值内存看起来正常。4.2 栈深度计数器忘了在分支里归零现象是嵌套表达式算到第三层结果就错或者栈越压越多最后爆掉。原因是生成代码时用了一个全局的depth变量记录栈深度但在if、while这类控制流的分支里两个分支的深度可能不同回到汇合点时没有取最大值或重置。解决是每个语句生成前记录入口深度生成后把depth恢复到入口值或者用「取两分支最大值」的策略。这个坑在只有表达式时不会暴露一加控制流就现形。4.3 除法没做符号扩展负数结果错得离谱现象是-7 / 2得到一个大正数。原因是idiv要求%rdx:%rax组成 128 位被除数只设了%rax没设%rdx高位是垃圾。解决是在idiv前加cqo。这个坑的排查方法是单独测负数除法别只测正数——正数时%rdx恰好是 0 也能对负数是唯一能暴露它的场景。4.4 局部变量偏移算错覆盖了返回地址现象是函数返回时跳到随机地址。原因是给局部变量分配栈空间时偏移从 0 开始往下算但忘了%rbp上方存的是旧的%rbp和返回地址局部变量必须从-8开始且不能越过%rsp。解决是分配时从-8起每分配一个减 8或按类型大小最后sub的总量要覆盖所有变量。这个坑一旦踩中调试器里看栈回溯全是乱的很难定位。4.5 关键字和标识符没区分if被当成变量名现象是写if (x) ...时报「未定义变量 if」。原因是词法分析器把所有字母开头的都归为TK_IDENT没查关键字表。解决是在识别出标识符后拿它的文本去关键字表里比对命中就改kind为TK_KEYWORD。这个坑的教训是词法阶段能确定的分类不要拖到语法阶段否则语法分析器要到处做字符串比较又慢又乱。5. 设计取舍为什么「小」反而让这份源码更值得读读这份小型 C 编译器源码最大的收获不是「我会写编译器了」而是看清了一个事实编译器的复杂度是可以按需裁剪的。它没有做类型检查没有做寄存器分配优化没有做错误恢复甚至可能只支持int一种类型。但正是这些「没做」让「做了」的部分变得清晰可辨。常见的实现思路里寄存器分配是个分水岭。小型编译器用栈来暂存所有中间值简单但慢工业编译器用图着色或线性扫描把值尽量留在寄存器里快但复杂。这份源码选择栈式不是因为作者不知道寄存器分配而是因为栈式生成的代码是「可验证」的——你看着 AST 就能推演出栈的每一次变化出错时能精确定位到哪一层。这种可验证性在教学和自举场景里比性能重要得多。另一个取舍是错误处理。工业编译器会做错误恢复遇到一个语法错误后跳过一段继续解析一次报出多个问题。小型编译器通常遇到第一个错误就退出。这不是偷懒而是因为错误恢复需要维护「同步记号集」复杂度不低而小型编译器的用户往往就是作者自己一次报一个够用了。我一般会这么理解错误恢复是给「不知道自己在写什么」的用户准备的而读这份源码的人恰恰是想知道「编译器在期待什么」。如果你打算基于它做扩展我的建议是从「加一种类型」开始比如加char或指针。这个改动会逼你碰类型系统、碰大小计算、碰类型转换但不会一下子把你淹没在优化里。加完之后你会发现之前那些「看起来是编译器专属」的机制其实都是「在有限信息下做确定决策」的通用套路。从那以后我每次读一个看似庞大的系统都强制自己先找它的「最小闭环」——那个不做就完全跑不起来的核心。找到之后剩下的部分就都是在这个闭环上挂载的工程决策读起来就有了主次。这份小型 C 编译器源码就是编译器领域那个最小闭环的一个干净样本。希望帮到你。本文还有配套的精品资源点击获取