☰
几百行代码实现C编译器:词法语法分析与代码生成核心原理
2026/10/21 21:07:34 网站建设 项目流程

简介:这份资源是一套小型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:%rax),cqo把%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 编译器源码,就是编译器领域那个最小闭环的一个干净样本。希望帮到你。

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

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

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

立即咨询