手写小型C编译器:从词法分析到代码生成的完整实践指南

📅 发布时间:2026/10/10 16:14:20
手写小型C编译器:从词法分析到代码生成的完整实践指南
简介面向希望深入理解编译器实现原理的开发者这份资源提供一个小型C编译器的完整源代码。它以C语言编写清晰展示词法分析、语法分析、语义分析、优化与代码生成等核心阶段适合作为编译原理课程配套实践也适合想研究指针、结构体、函数调用等C特性底层处理的读者持续阅读和改造。压缩包共86个文件大小约210KB其中50个c源文件与12个h头文件构成主体实现8个p文件及txt、bat、cfg、makefile等辅助文档与构建脚本目录结构便于按阶段学习。目前已有478人学习下载在编译学习圈内具备一定参考价值。通过阅读和修改这份代码可以直观理解源代码如何逐步转换为目标机器码获得调试编译错误、优化代码生成流程的实践经验是一份兼顾原理讲解与动手验证的入门资料。1. 一个小型C编译器它不是玩具而是一条能跑通全链路的“最小可编译”路径当你想在资源受限的设备里动态编译一段C语言过滤脚本又不想把整套交叉工具链塞进去时或者你啃了三个月编译原理教材却连一个能跑的最小实现都没有时一份“小型C编译器的源代码”就是性价比最高的切入点。别把“小型”理解成简陋它只是把C裁剪成几十个核心语法结构代码量控制在几千行但词法、语法、语义、代码生成、运行时一个都不少。它能编译斐波那契递归能编译简单指针操作甚至能编译自己。这篇文章我按可复现的落地路径从边界定义讲到避坑细节适合所有想亲手写一个编译器的人。2. 先定义“小”C子集、目标平台与三个必须能跑的测试用例2.1 一个“小型C编译器”的源代码到底该覆盖哪些C语法很多人动手前最常犯的错是把自己当成 ISO C 标准委员会来设计。一个小型C编译器要解决的是特定场景编译一段行为可预测、没有动态分配和复杂类型转换的C语言程序。我给项目定边界时通常只保留这些内容基本类型int、char、指针的一元*和操作表达式加减乘除、取模、比较、逻辑与或非、赋值语句if/else、while、for、return、复合语句{}函数支持递归调用参数按值传递全局变量、局部变量、作用域遮蔽这个子集不是拍脑袋定的。它保留了变量生命周期、栈帧、递归、分支跳转这些编译器里最核心的机制同时砍掉了struct、union、switch、goto和浮点。砍完之后源码量能少三分之一而你仍然能体会到“从C语言到汇编”的全过程。如果你后面想支持结构体再往类型系统里加模板也来得及但一开始不要贪多。2.2 目标平台的选择生成汇编、生成C还是直接解释执行同一个“小型C编译器”实现方式不同源代码结构会差很多。常见做法有三种生成汇编代码例如 x86-64 ATT 语法再用系统自带as汇编器链接成可执行文件。这是最接近真实编译器的路线能让你看到每条C语句对应的机器指令但必须处理寄存器破坏、栈帧布局和函数调用ABI。生成C代码然后交给系统已有的 GCC/Clang 处理。这种“转译器”实现快但容易让你绕过后端细节学到一半卡在“到底是谁在做代码生成”的错觉里。生成自定义字节码在自带虚拟机上执行。适合做脚本语言但如果你想弄懂物理机器的运行机制这步收益不够直接。我一般会建议方案1。小型编译器不需要像 GCC 那样做优化前期用栈式代码生成法完全够用。目标平台一旦选定后面源码里的所有栈帧偏移、参数传递顺序都要围绕它来写半路换平台会极其痛苦。2.3 代码目录怎么分每个源代码文件负责什么我习惯把整个工程切得很薄每个.c文件只负责一件事方便出问题时快速定位。目录结构通常是这样文件职责关键输出lexer.c读取源码字符流切分成 TokenToken数组parser.c递归下降解析 Token构建 ASTExpr/Stmt结构semantic.c符号表管理、作用域、类型检查带类型和偏移变量的 ASTcodegen.c遍历 AST 输出汇编文本.s汇编文件main.c命令行入口、文件读写、错误输出可执行驱动这只是一个建议布局。很多人也会把语义检查并入 parser小型编译器完全可以这么做。关键是不要让 codegen 直接去读 Token 流否则每个变量查找都要重新解析一遍出错时根本分不清是哪一层的问题。另外一个小型编译器通常不需要预处理器。#include、#define会把你拖进另一个大坑。我的做法是测试代码里不用宏头文件手动展开。这样词法分析器可以简单不少。测试用例从项目第一天就要存在。下面三个会陪你到最后/* test1.c —— 最基本的算术和返回码 */ int main() { return 1 2 * 3; }/* test2.c —— 递归与函数调用验证栈帧 */ int fib(int n) { if (n 1) return n; return fib(n - 1) fib(n - 2); } int main() { return fib(5); }/* test3.c —— 局部变量与指针操作 */ int main() { int x; int *p; x 42; p x; return *p; }这三个用例的意义不只是“跑通”。test2的递归验证了每次调用的活动记录是否正确如果栈帧偏移算错退出码不会是 5而是段错误test3验证了一元运算符和指针存取路径。test1则保证最简单的路径没有废掉。整个实现过程中只要这三个全过我就敢继续加新语法特性。3. 从源代码到 Token词法分析器这样写不容易踩坑3.1 Token 类型定义和关键字表词法分析器的输入是一段字符串输出是一串“词法记号”。容易被忽略的是Token 不能只存“单词”它必须携带源代码中的行列号。没有行列号的 Token 流后面语法分析报错就只能靠猜。typedef enum { TK_INT, TK_CHAR, TK_IF, TK_ELSE, TK_WHILE, TK_FOR, TK_RETURN, TK_IDENT, TK_NUMBER, TK_LPAREN, TK_RPAREN, TK_LBRACE, TK_RBRACE, TK_SEMI, TK_ASSIGN, TK_EQ, TK_NE, TK_LT, TK_LE, TK_GT, TK_GE, TK_PLUS, TK_MINUS, TK_STAR, TK_SLASH, TK_PERCENT, TK_EOF } TokenKind; typedef struct Token { TokenKind kind; char *text; /* 词素如 fib、42 */ int line, col; /* 源码位置用于报错 */ } Token;这里的列号不建议省略。当输入是int foo(;)时没有行列号只能打印“语法错误”有了行列号就能直接指出“第 5 行第 8 列”。我把 Token 存在动态数组里初始容量 256超过就扩容。早期不用纠结性能但动态扩容一定要做否则大型测试文件会越界崩溃。3.2 一个手写的字符扫描主循环词法分析器我不推荐用 flex。flex 用正则驱动生成一堆 C 代码后很难插入调试出了问题也很难追踪。手写next_token()反而更好控。static Token next_token(Lexer *lx) { Token t {0}; while (isspace(lx-src[lx-pos])) { if (lx-src[lx-pos] \n) { lx-line; lx-col 1; } else { lx-col; } lx-pos; } t.line lx-line; t.col lx-col; int start lx-pos; if (isdigit(lx-src[lx-pos])) { while (isdigit(lx-src[lx-pos])) { lx-pos; lx-col; } t.kind TK_NUMBER; t.text strndup(lx-src start, lx-pos - start); return t; } if (isalpha(lx-src[lx-pos]) || lx-src[lx-pos] _) { while (isalnum(lx-src[lx-pos]) || lx-src[lx-pos] _) { lx-pos; lx-col; } t.text strndup(lx-src start, lx-pos - start); t.kind lookup_keyword(t.text); return t; } switch (lx-src[lx-pos]) { case : lx-pos; if (lx-src[lx-pos] ) { lx-pos; t.kind TK_EQ; } else t.kind TK_ASSIGN; break; case /: if (lx-src[lx-pos 1] /) { while (lx-src[lx-pos] ! \n lx-src[lx-pos] ! \0) { lx-pos; lx-col; } return next_token(lx); } t.kind TK_SLASH; lx-pos; break; /* 其余单字符操作符类似 */ default: t.kind TK_EOF; break; } t.text strndup(lx-src start, lx-pos - start); return t; }逻辑说明这个函数的核心是“最长匹配”。每次先跳过空白并更新行列号数字连续读字母或下划线开头的连续字符作为标识符再通过lookup_keyword区分关键字遇到/时先判断是不是//注释是则跳过到行尾并递归调用自身否则作为除号。所有操作符分支最后统一把t.text截出来。参数说明lx-pos是当前扫描下标lx-line和lx-col在遇到\n时重置。Windows 的\r\n情况下我把\r当普通空白丢弃这样行号不会错位。如果以后要支持十六进制数0x1F需要在数字分支里增加对0x的判断否则0x1F会被切成数字0和标识符x1F。3.3 词法分析三个必踩的边界负数、复合赋值和注释负数是一个常见误判-42里的-是运算符不是数字的一部分。如果你在词法里把-42合并成一个负数 Token那么a - 42里减号和数字中间有空格还说得通但a-42就切错了。正确做法是让 parser 在遇到一元运算符时自己处理负号词法层只输出TK_MINUS。复合赋值、-这类需要勤劳地加到 Token 枚举里。很多小型编译器实现到后期会突然发现x 1没法写只能手动降级成x x 1。我建议在一开始就支持哪怕最终没有用到parser 和 codegen 也留好接口以后加更省事。注释部分最关键的是多行注释/* */。最简单的实现是遇到/*就持续扫描直到*/里面的内容全部忽略。但有两点要注意一是不支持嵌套注释C 标准也不允许二是遇到 EOF 还没闭合时需要显式报“未结束的注释”否则会在文件末尾静默停止后续源码全部被吞。别问我为什么强调我在这里翻过车。验证词法器是否正常最直接的方式是在 main 里做一个-t参数把所有 Token 以(kind, line, col, text)的格式打印出来。这样一旦 parser 行为怪异你就能确认它收到的 Token 流是否符合预期。4. 递归下降与符号表语法分析是整份源代码里最容易翻车的黑匣子4.1 用优先级爬升处理表达式而不是写死三层函数到了语法分析阶段很多教学源码喜欢用“表达式 → 项 → 因子”的三层函数。一旦加入比较和逻辑运算代码量会迅速膨胀。我常用的方案是优先级爬升Precedence Climbing。它用一张运算符优先级表驱动表达式解析函数不到 40 行。static Expr *parse_binary(Parser *p, int min_prec) { Expr *lhs parse_unary(p); while (1) { TokenKind op p-tok.kind; int prec get_binary_prec(op); if (prec min_prec) break; next_token(p); Expr *rhs parse_binary(p, prec 1); lhs new_binary_expr(op, lhs, rhs); } return lhs; } static int get_binary_prec(TokenKind k) { switch (k) { case TK_STAR: case TK_SLASH: return 40; case TK_PLUS: case TK_MINUS: return 35; case TK_LT: case TK_LE: case TK_GT: case TK_GE: return 30; case TK_EQ: case TK_NE: return 25; case TK_ASSIGN: return 5; default: return -1; } }逻辑说明parse_binary先解析一个一元表达式作为左操作数然后循环看下一个运算符。如果它的优先级比当前门槛高就递归解析右侧构造新的二叉节点。右结合运算符如赋值递归时用prec 1左结合运算符加减乘除也用prec 1这是一个关键细节。这里我必须说清楚赋值是右结合递归参数应该是prec 1而加减乘除是左结合递归参数应该用prec 1才能让同优先级运算符从左往右结合。上面的代码统一用了prec 1实际上对左结合运算符也是正确的因为a - b - c会先递归解析右侧b - c但由于优先级门槛同样是 35递归时遇到第二个减号其优先级等于min_prec条件prec min_prec为假于是继续贪心最终形成左结合。所以prec 1是保守且安全的写法代价是“同优先级连续运算时多一层递归”对小型实现完全可接受。参数说明min_prec可以控制子表达式的停止位置。调用parse_binary(p, 0)能吃掉所有二元运算符如果你只想解析到加法层就把 min_prec 设为 36这样乘法和除法会停下来留给外层处理。这个参数是优先级爬升的灵魂。4.2 语句级语法声明、赋值、if、while、return真正让 parser 显得像黑匣子的是语句之间的边界。我最早写的版本里声明和表达式没有严格区分int x 3;被解析成两个独立表达式导致代码生成阶段频繁找不到变量。最小语句解析可以这样组织static Stmt *parse_stmt(Parser *p) { if (p-tok.kind TK_IF) return parse_if(p); if (p-tok.kind TK_WHILE) return parse_while(p); if (p-tok.kind TK_FOR) return parse_for(p); if (p-tok.kind TK_RETURN) return parse_return(p); if (is_type(p-tok.kind)) return parse_decl(p); return parse_expr_stmt(p); }每个具体解析函数在末尾必须调用expect(TK_SEMI)否则x 3 y 4这种漏分号输入会被解析成两个表达式的一部分。这里有个血泪经验不要用通用的“预期分号”报错蒙混过关最好专门提示“上一行赋值语句缺少分号”并把当前 token 的行列号带上。if和while的解析要注意悬空 else 问题。我的做法是parse_if在遇到else时递归解析一个 if 或复合语句这样else会就近匹配符合 C 标准。但如果你的测试里包含了空语句;解析循环时要注意别让空语句把循环体吃掉。4.3 符号表作用域、类型检查和变量偏移的落地办法符号表是编译器源码里最容易“翻车”的地方。小型编译器不需要引入红黑树或复杂哈希一条链表加作用域栈就够。typedef struct Symbol { char *name; Type type; /* INT / CHAR / PTR */ int offset; /* 栈帧相对偏移在语义分析阶段统一计算 */ int is_global; struct Symbol *next; } Symbol; typedef struct Scope { Symbol *head; struct Scope *parent; } Scope; Symbol *lookup_scope(Scope *sc, char *name) { for (Symbol *s sc-head; s; s s-next) { if (strcmp(s-name, name) 0) return s; } return sc-parent ? lookup_scope(sc-parent, name) : NULL; }逻辑说明Scope是一个单向链表新变量插到头部查找时从当前作用域向 parent 逐级搜索。这样内层作用域的同名变量能遮蔽外层变量符合 C 的作用域规则。参数说明offset是最需要统一口径的字段。我建议在语义分析阶段就把它算完parser 阶段只挂名字和类型codegen 阶段只读不写。如果语义分析和代码生成各自维护一套偏移同一个变量在两个阶段数值不一致你会看到一个极其诡异的 bug声明时明明存在生成代码时却偏移到了别人的栈空间。语义检查不要等代码生成才做。至少要做三件简单的事未声明变量、函数返回值类型不匹配、break出现在循环外。其中第三种需要遍历 AST 时维护一个“当前是否在循环里”的上下文状态这种逻辑写在 parser 里会疯的写在独立的walk_ast()里才清晰。5. 代码生成与运行时把 AST 变成可执行文件以及五个常见问题避坑5.1 代码生成策略先放弃寄存器分配靠压栈换正确性代码生成器是决定项目能不能冒烟的最后一步。别一上来就学 GCC 的寄存器分配常见做法是先写一个“栈式代码生成”每计算一个子表达式左操作数压栈右操作数算到%rax再弹出左操作数到%rdi执行运算。static void gen_binary(BinaryExpr *bin) { gen_expr(bin-left); printf( pushq %%rax\n); gen_expr(bin-right); printf( movq %%rax, %%rdi\n); printf( popq %%rax\n); switch (bin-op) { case TK_PLUS: printf( addq %%rdi, %%rax\n); break; case TK_MINUS: printf( subq %%rdi, %%rax\n); break; case TK_STAR: printf( imulq %%rdi, %%rax\n); break; case TK_SLASH: printf( cqto\n idivq %%rdi\n); break; } }逻辑说明这里使用 ATT 汇编语法。gen_expr(left)执行完%rax里是左值立即压栈然后递归生成右操作数%rax被右值覆盖最后弹回左值到%rax%rdi存放右值。除法需要cqto指令把%rax符号扩展到%rdx:%rax否则负数除法会得到错误结果。参数说明如果你遵循 System V ABI函数参数保存在%rdi/%rsi/%rdx/%rcx等寄存器中。如果代码生成器在表达式计算中随意覆盖%rdi那么函数调用前的参数传递就会出错。最常见做法是函数入口先保存参数到栈帧局部变量之后所有对参数的引用都访问栈内存不再碰寄存器。这会牺牲一点效率但换来了确定性和可调试性。5.2 局部变量的栈帧布局栈帧布局是后端最需要抠细节的地方。我给每个函数生成这样的开场和收尾pushq %rbp movq %rsp, %rbp subq $frame_size, %rsp ... movq %rbp, %rsp popq %rbp retframe_size在语义分析阶段由函数内所有局部变量和临时变量的总大小计算得出。第一个局部变量放在-4(%rbp)第二个放-8(%rbp)以此类推。这样每次引用一个局部变量只需要根据它的offset生成一条movq -4(%rbp), %rax。这里有一个关键参数System V ABI 要求%rsp在函数调用点按 16 字节对齐。如果你把frame_size直接设为局部变量总大小而总大小不是 16 的倍数调用printf时会栈对齐错乱表现为随机段错误。我的习惯是把frame_size向上取整到 16 的倍数。比如 3 个int需要 12 字节实际subq $16, %rsp这样安全得多。5.3 五个常见问题避坑下列五条全是小型C编译器实现过程中反复出现的问题按“现象 → 原因 → 解决”列出。现象 1同一函数里的局部变量在代码生成后变成垃圾值。原因语义分析阶段用全局local_offset给变量赋偏移但处理{}块级作用域时没有把local_offset恢复回进入作用域前的值。解决进入复合语句时保存当前local_offset退出时恢复。这个保存和恢复必须成对出现否则偏移量会单调增长后面的变量会越界。现象 2表达式2 3 * 4得到 20。原因优先级表里乘法和加法确实分了优先级但递归解析第二个操作数时用了小于等于的终止条件导致3 * 4没有被完整吸收。解决优先级爬升里检查if (prec min_prec) break;时要保证乘法的递归调用拿到min_prec 40 1或者等价的门槛不能是 40。这个“差一错误”是优先级实现最常见翻车点。现象 3函数调用参数顺序反了foo(a, b)里a收到的是b的值。原因C 语言参数按从左到右求值但常见做法是从右往左压栈。如果求值循环是从左往右压栈参数槽位就错位。解决先一次性求值所有实际参数并存到临时列表然后按从右到左的顺序生成压栈指令或者直接把每个参数写到自己的参数槽位基于%rbp偏移不要在栈上依赖压栈顺序。现象 4字符串常量老是报“未定义的标签”。原因字符串字面量在 codegen 时被输出到了.data段但等.text段里的指令执行leaq引用它时链接器找不到对应标签。解决建立一个字符串池每个字符串分配一个编号.L.str0、.L.str1在汇编文件的.data段统一输出然后在.text段用leaq .L.str0(%rip), %rax载入地址。顺序不要反先出数据段再出文本段否则标签定义在引用之后也能工作但可读性很差。现象 5if语句没有else但 else 分支后面的代码被跳过。原因条件跳转标签设计不对。常见错误是“条件成立跳到 then 块的末尾”但 then 块和下一语句共用同一个标签。解决为每个if分配两个标签L_if_else和L_if_end。条件为假时跳L_if_elsethen 块结束后无条件跳L_if_end如果没有 elseL_if_else就放在L_if_end的位置。这样可以保证所有标签都有明确的归属点。这五条并不是全部但它们覆盖了从语义分析到后端应用最常出问题的区域。每修完一条建议回到test2.c跑一遍因为递归程序对栈帧和跳转最敏感。6. 验证你的编译器从“能编译 hello”到“能编译自己”一个编译器写完后最容易骗自己的行为是只跑一个 hello.c。我常用的验证起点是差分测试同一份测试程序分别交给系统 gcc 和你的小型编译器编译然后对比退出码和 stdout。如果输出不一致下面这个脚本会直接提示失败import subprocess, glob, sys for test in glob.glob(tests/*.c): subprocess.run([gcc, test, -o, ref_bin]) subprocess.run([./minic, test, -o, my_bin]) r1 subprocess.run([ref_bin], capture_outputTrue) r2 subprocess.run([my_bin], capture_outputTrue) if (r1.returncode, r1.stdout) ! (r2.returncode, r2.stdout): sys.exit(fFAIL: {test}) print(all passed)脚本逻辑和参数都很简单用同样流程编译出两个二进制再比较返回码和标准输出。注意不要只看 stdout很多程序的验收结果是通过main的 return 体现的。第二个验证技巧是自举尝试让 minic 编译一个真实的源文件比如自己写的lexer.c。这一步能快速暴露“只支持教学子集”的乐观偏差。如果 lexer 里的strndup、char数组、指针操作跑不通你就知道边界该往哪里补。不必追求完整自举先从最容易的部分开始一个文件一个文件推过去。第三个具体技巧是给 AST 每个节点保存源码行号。parser 新建节点时把line填进去代码生成阶段报“栈偏移非法”时就能直接定位到 C 源码某一行。这个信息配合一个-d打印 AST 的开关能省掉大量盲调时间。我最初不以为然直到一次递归函数崩溃在汇编里才后悔没早点加。这就是我多年写小型编译器的习惯宁可多花一小时写测试框架也不要在凌晨两点对着汇编发呆。希望帮到你。本文还有配套的精品资源点击获取