从零手写小型C编译器:词法、语法到代码生成的完整实现与避坑指南
发布时间:2026/10/2 11:12:41来源:尧图网络
简介这是一份小型C编译器完整源代码面向编译原理学习者、系统软件开发者以及对C语言底层实现感兴趣的编程人员。资源围绕编译器工作的词法分析、语法分析、语义分析、优化和代码生成等核心阶段展开适合用来理解高级语言程序被翻译为可执行代码的完整过程。压缩包共86个文件主体为50个C源文件与12个头文件另含makefile、批处理脚本、配置文件和使用说明包体约210KB结构清晰便于按模块研读。该资源目前已有478人浏览学习。源码中涵盖记号识别、表达式解析、符号表管理、优化及目标代码生成等关键模块读者既可通读整体框架也可针对某一阶段进行修改实验从而加深对C语言类型系统、作用域规则以及编译优化策略的掌握同时借助实际代码理解指针、结构体、函数调用等特性的处理方式为后续开发自定义编译器或调试复杂程序打下基础。1. 为什么说「读一百遍不如亲手写半个编译器」C编译器是大学计算机课程的终极试金石让你把一个中括号和分号满天飞的文本文件变成机器能跑的二进制。不少人靠《编译原理》的龙书啃完了词法分析、语法分析和中间代码但一到手写代码就卡住——考试会画状态图真给一段 C 源码却不知道从哪下手。反过来抱着别人的编译器源码啃又容易被宏定义、目标机抽象、优化遍数搞晕看三行就想睡觉。这个标题「一个小型C编译器实现的源代码」恰恰是中间那条路去掉优化、去掉多目标后端你只需要处理 C 的一个子集唯一目标是让一小段代码可编译、可链接、可运行。亲手写一遍小型 C 编译器真正解决的是「原理都懂但动不了手」的窘境。我见过的真实收益场景至少有三种一是做嵌入式设备里的脚本解析器本质就是实现一个微型编译器和虚拟机二是做 DSL领域专用语言把配置文本编译成 C 函数这套管线完全复用编译原理三是做代码分析与格式化工具LSP 插件里的类型推导、补全第一步就是把源码变成 AST抽象语法树。这些任务都不需要完整 C 标准只需要一个能被剪裁、能看懂、能改的编译器骨架。本文就围绕「用 C 语言写一个能编译 C 子集的小型编译器」这个方向把前端、后端和边界坑讲清楚。2. 编译器骨架长什么样先定子集再写代码所谓「小型 C 编译器」核心不是代码少而是范围小。我们得先明确两个边界语言子集到哪一步为止后端输出到哪种形式。这两个决定决定了后面所有代码结构。2.1 语言子集只留必须的砍掉模糊的实际从业者不会一开始就去实现struct、union、goto和完整的指针运算。我见过的通用做法是先支持这几类语法全局变量仅int和char类型指针仅保留一级指针char*函数支持入参和返回值入参类型限定int、char、char*语句if / else、while、return、{ }块、表达式语句表达式算术 - * / %、比较 ! 、逻辑 || !、赋值变量声明只允许在块开头不允许for (int i 0; ...)这种 C99 风格。为什么这样裁剪原因很实际递归下降解析最怕的是声明和语句混在一起int a 1;在 C 里既可以出现在函数外部也可以出现在块中间如果允许声明随处出现解析器就得在「读到标识符时往前看一个 token 判断是声明还是表达式」之间摇摆一个小型编译器不值得为这个复杂度买单。所以设计成「块开头集中声明」解析器遇到int或char就直接走声明逻辑干净利落。第三个边界是中止编译的条件只要出现未定义的变量、类型不匹配、函数参数个数不对立即报错并退出。不做类型隐式转换不做未定义行为检测。这个小编译器存在的意义是「可预测、可调试」不是「能编译所有合法 C 代码」。2.2 后端选型目标代码生成两条路怎么挑后端直接决定你写多少行代码。常见做法有三种后端方案复杂度可调试性运行依赖直接生成 x86-64 汇编高需处理寄存器分配、栈帧难汇编不易阅读只需 gcc 汇编器生成 C 代码再调用 gcc低把 AST 翻译成 C 源码高中间 C 可以直接阅读必须有 C 编译器生成 LLVM IR高需理解 LLVM API中IR 可读但概念多依赖 LLVM 库如果目标是学习走第 2 条路最划算我们做的其实是一个「C 子集到 C 代码的转译器」AST 遍历后直接输出可读的 C 函数。这样做的好处是语义分析阶段错误更容易追溯——当你发现自己生成的 C 代码在 gcc 下报错时可以直接对比原源码和生成的代码不用和寄存器分配纠缠。等这条路完全跑通再考虑把输出端换成汇编那时你的注意力可以集中在指令选择上。顺着这个思路前面三步就明确了词法分析把源码切成 token语法分析把 token 变成 AST代码生成把 AST 变回 C 源码最后交给系统 gcc 编译链接。## 3. 从零写最小源码词法、语法与生成三步走 在动手之前再明确一次目标我们要写的是一个「把 C 子集源码翻译成 C 源码」的程序。它能处理类似下面的输入 c int add(int a, int b) { return a b; } int main() { int x; x add(1, 2); if (x 0) { return x; } return 0; }把这个输入翻译成等价 C 代码其实如果子集选得保守输出可能和输入几乎一样但重点是中间有 AST 和语义检查。下面按模块拆解。3.1 词法分析器token 类型定义与读取函数词法分析的输出是一个个 token每个 token 至少包含类型和值。我最小实现里只定义这几种typedef enum { TOK_INT, TOK_CHAR, TOK_RETURN, TOK_IF, TOK_ELSE, TOK_WHILE, TOK_IDENT, TOK_NUMBER, TOK_LPAREN, TOK_RPAREN, TOK_LBRACE, TOK_RBRACE, TOK_SEMI, TOK_COMMA, TOK_ADD, TOK_SUB, TOK_MUL, TOK_DIV, TOK_ASSIGN, TOK_EQ, TOK_NE, TOK_LT, TOK_GT, TOK_LE, TOK_GE, TOK_AND, TOK_OR, TOK_NOT, TOK_EOF } TokenType; typedef struct { TokenType type; char text[256]; int line; } Token;具体的读取函数next_token()逻辑如下跳过空白和注释遇到数字读完整数值遇到字母或下划线读标识符遇到运算符则按最长匹配读取——先尝试读、!、、再退化成单字符操作符。一个关键点是统一用text字段保存原始文本数值在语义分析阶段再用atoi()转换这样词法层不用维护全局变量yylval代码更直白。我的实现里刻意不引入状态机而是用switch直接判断当前字符。这个选择基于一条经验小型编译器的词法分析器用状态机只会让代码要多两层间接跳转问题排查反而麻烦。手写switch就是最直观的「当前字符是什么」逻辑。3.2 AST 节点结构用不透明指针封装AST 是连接语法分析器和代码生成器的关键。节点类型不能太少否则语义阶段无法区分「函数调用」和「数组下标」本项目先不做数组但函数调用必须单独区分。我的结构体设计如下typedef struct ASTNode { NodeType type; char value[256]; // 标识符名、操作符或数值的文本 int line; struct ASTNode *child[4]; // 每个节点最多四个子节点 struct ASTNode *next; // 兄弟节点链表 // 语义分析阶段填充以下字段 int var_type; // 0:int, 1:char, 2:char* int var_offset; // 局部变量在栈帧中的偏移符号表计算 } ASTNode;child固定四个子节点是用来存二元操作符的左右操作数、if 的四个分支条件、then、else、next 语句、函数调用的参数链表的头。next指针是把 if 内部的语句块串成链表。这里var_type和var_offset是语义分析后填写的语法分析阶段不需要管——这是用 C 语言写编译器时最容易犯的错在语法分析阶段就试图处理类型信息导致解析器里全是strcmp一坨坨地判断类型。正确做法是语法分析只负责「形状」语义分析负责「含义」。3.3 递归下降语法分析表达式优先级不用优先表表达式语法是递归下降的核心难点。我的实现用「优先级逐层递减」的方式parse_expression()先调用parse_logical_or()再一级级往下调ASTNode *parse_expression(void) { return parse_logical_or(0); } ASTNode *parse_logical_or(int depth) { ASTNode *left parse_logical_and(depth 1); while (current_token.type TOK_OR) { next_token(); ASTNode *right parse_logical_and(depth 1); ASTNode *node new_node(NODE_OR, ||, 0); node-child[0] left; node-child[1] right; left node; } return left; }depth参数是防御性设计限制递归深度防止恶意代码搞出堆栈溢出。往下还有parse_logical_and、parse_equality、parse_relational、parse_additive、parse_multiplicative、parse_unary和parse_primary。每一层只处理本层优先级达到更高优先级时向下递归。这个设计的精妙之处在于不需要优先级表不需要运算优先级判断函数代码顺序本身就是优先级定义。如果你要实现移位、位运算只需在parse_additive和parse_relational之间插入一层。我一向不推荐用运算符优先级表解析原因很简单调试时你无法单步跟踪「为什么这个乘法被归到了加法那层」而递归下降可以把问题精确定位到某个函数。对于语句parse_statement()会根据当前 token 走分支遇到if读表达式然后读两个语句块遇到while类似遇到return读表达式遇到{则循环读语句直到}否则按表达式语句处理。注意if后面的else是可选的这个在递归下降里最简单直接判断当前 token 是不是TOK_ELSE就行不需要处理悬空 else 冲突——因为语法里{}是必须的。3.4 语义分析与符号表遍历 AST 填充类型语法分析只保证形状对不保证含义对。语法分析完成后我需要第二遍遍历 ASTtypedef struct Symbol { char name[256]; int type; // 0:int, 1:char, 2:char* int is_param; int offset; // 栈帧偏移 struct Symbol *next; } Symbol;符号表用链表实现就够——小型编译器不需要哈希表除非你写的子集有大几千个全局变量。你是不是觉得链表查询慢对于这个量级链表的线性查找压根不是瓶颈编译时间多数花在文件读取上。语义分析最关键的处理是作用域函数级作用域里参数和局部变量在同一个表里查找。我的实现是维护一个指针指向「当前函数」在遍历函数体时每当进入一个新的{}块就新建一个子符号表子表挂在参数表后面。查找时先找当前块再逐层往外找。每个ASTNode在构建时就把解析到的符号信息填进var_type和var_offset后续代码生成阶段不需要再查符号表这样能大幅降低代码生成器的复杂度。另一个必须在语义分析阶段做的是赋值类型检查。我的规则很简单int类型只能赋给intchar类型赋给charchar*赋给char*。一旦发现不匹配打印错误消息并设置has_error 1但不在这个阶段退出——继续遍历剩下的 AST把能找到的错误全部报出来。这个设计叫「错误恢复」避免用户改一个错就要重新编译一次。3.5 代码生成往目标 C 里翻译附参数说明代码生成阶段遍历 AST 直接输出文本。以函数定义为例static void gen_function(ASTNode *func) { if (has_error) return; // 函数头func-value 是函数名 fprintf(out, %s(, func-value); // 参数列表 ASTNode *param func-child[1]; while (param) { if (param-var_type 2) fprintf(out, char *); else fprintf(out, char ); fprintf(out, %s, param-value); if (param-next) fprintf(out, , ); param param-next; } fprintf(out, )\n); // 用函数体 AST 节点作为遍历入口 gen_block_body(func-child[2], 1); fprintf(out, \n); }gen_block_body做的事情是遍历ASTNode-next链表根据节点类型分发到对应的gen_if、gen_while、gen_return、gen_expression。表达式代码生成的核心是递归下降的镜像static void gen_expr(ASTNode *node) { if (node-type NODE_NUMBER) { fprintf(out, %s, node-value); } else if (node-type NODE_IDENT) { fprintf(out, %s, node-value); } else if (node-type NODE_ASSIGN) { gen_expr(node-child[0]); fprintf(out, ); gen_expr(node-child[1]); } else if (node-type NODE_ADD node-type NODE_GE) { fprintf(out, (); gen_expr(node-child[0]); fprintf(out, %s , node-value); gen_expr(node-child[1]); fprintf(out, )); } else if (node-type NODE_FUNCALL) { fprintf(out, %s(, node-value); if (node-child[0]) { gen_expr(node-child[0]); } fprintf(out, )); } }注意这里的三个参数约定out指向输出文件node是待生成的 ASTindent表示当前缩进层级由调用方传入用于生成可读的缩进。关键点是表达式里所有二元运算符都加括号避免输出代码运算符优先级出问题——例如生成a b * c时如果你在生成时忘记给两个子表达式加括号输出的可能是a b * c语义对了但这不是你 AST 的本意所以每个二元节点都加括号生成出来的代码优先级必定正确。## 4. 编译执行 qemu 环境验证脚步与四个坑 本章把重点放在「如何确认小型 C 编译器产出的目标是正确」的这一环节。不管你的编译器怎么生成 C 代码最后都得交给系统编译、链接和运行。我在本地用的命令链是这样的 bash ./mycc test/example.c -o /tmp/out.c gcc /tmp/out.c -o /tmp/out /tmp/out echo $?mycc是小编译器可执行文件test/example.c是待编译源码-o /tmp/out.c指定输出的中间 C 代码路径。如果这一串走通说明「字面量翻译」正确。但这只是第一步因为 C 编译器还涉及char到int的隐式提升、参数求值顺序、main退出码这些细节。这个验证环境虽然简单却是后面调试的基础所以我把这个环节里最常见的四个坑写出来。4.1 避坑符号表穿越函数导致局部变量泄露现象写了一个用全局变量的测试程序编译通过但是运行结果完全不对。检查输出代码发现函数里居然引用了另一个函数的局部变量名字。原因符号表实现里当前函数结束时忘了切回全局符号表。我最初把当前符号表指针做成全局变量cur_symtab进入函数时保存调用方的符号表函数结束时恢复。如果忘了恢复下一个函数查找变量时用的还是旧表自然能找到上一个函数的局部变量。更隐蔽的是如果全局变量和函数参数同名参数会先被找到全局变量就被遮蔽了。解决函数定义处理函数的开头保存saved_symtab函数体全部生成完后恢复同时在“构建符号表”和“生成代码”两个阶段都要做同样的保存恢复逻辑。你也可以用一个本地变量而不是全局变量来传递符号表指针但那样遍历逻辑里要处处带着参数代码阅读性会变差。我个人的坚持是函数入口处集中保存、集中恢复并加assert确保嵌套深度匹配。4.2 避坑char类型变量的隐式提升乌龙现象char c; c 65; if (c A) return 1;这行代码编译后输出 Cgcc 再编译运行结果却是返回 0。原因问题出在词法分析把65分析为TOK_NUMBER类型固定为int赋值给char时我的检查规则是「类型不匹配直接报错」但这里类型其实是可以兼容的。我最初想了想为了省事在赋值判断里又加了「允许 int 赋给 char但需要显式检查数值是否在 -128~127 范围内」的逻辑。但因为偷懒没做这个检查导致把 65 以外的大数也放过去了。后来我又改成「不论数值如何只要类型不同就报错」用户必须写(char)65才能通过编译——这对小型编译器教学是合理的因为类型转换本身就是一个学习点。解决词法阶段只把数字塞进节点不绑定类型语义分析阶段赋值时判断两侧类型是否一致不一致就报错并提示用户需要显式转换。4.3 避坑悬空 else 导致的解析错位现象if (a) if (b) return 1; return 0;这段代码错误地弹出了「缺少}」的编译错误。原因语法分析处理悬空 else 时我的parse_statement遇到if后先读条件再读一个语句块然后在「读 else 后语句」时如果当前 token 是}或EOF就直接返回——这个逻辑本身没问题。问题在于我以为「读一个语句块」是指一个有{}的块但 C 语法其实允许单语句无块。所以if (a) if (b) ...这种嵌套第一层 if 的块内其实是另一个 if 语句。我的解析器只读了那个 if 语句没有把它的子语句读完就强行要求一个}于是报错。解决修改解析器让if和else后面的「语句」既可以是{}块也可以是单语句else的可选性通过「当前 token 是否是 else」判断。同时为了保命我在parse_statement入口处加了一个「允许单语句」的参数不再假设一定有大括号。这是编译器实现里最经典的一个错误建议任何人在遇到「奇怪的大括号报错」时先考虑悬空 else。4.4 避坑操作符短路求值未实现现象while (ptr ! 0 ptr[0] a)这段代码在指针为空时仍然解引用指针导致段错误。原因我先实现了逻辑与但把它当普通二元运算先算两边再算与。这在 C 语言里是语义错误——原版 C 要求短路求值左边为假时右边根本不会执行。如果实现成先算后与就踩了未定义行为。解决代码生成逻辑里检测到NODE_AND或NODE_OR时手动生成带if的结构。比如x y先生成if (!x) skip 1再把 y 放进另一个分支。这比三元表达式更可控。在小型编译器里可以偷懒用(x ? y : 0)来生成虽然结构多括了一层但语义正确。## 5. 代码生成深度寄存器分配的简易模拟与栈帧布局 既然目标是「可运行」就不能只停留在「翻译成合法 C」。你生成的 C 代码毕竟还要交给 gcc 编译所以你的输出必须符合 gcc 对栈帧布局和参数传递的预期。在这一章我重点讲「语义分析阶段如何设计栈帧布局」这是决定你的生成代码能否被 gcc 顺利编译的关键。 ### 5.1 函数栈帧参数和局部变量放一起符号表记录偏移 C 语言的函数调用约定里参数和局部变量都被安排在同一个栈帧中。对小型编译器来说最笨也最稳的办法是每个函数在语义分析阶段就计算好「这个函数需要多少局部变量、每个变量相对帧基址的偏移量」并且让局部变量一定分配在参数后面。 c typedef struct FunctionFrame { char *name; int param_count; int local_count; int frame_size; // 参数 局部 暂存 } FunctionFrame;例如void foo(int a, int b) { int c; char d; }参数 a 偏移为 0参数 b 偏移为 4局部 c 偏移为 8d 偏移 9char 占 1。如果你想让生成的 C 代码完全复制这个布局你可以生成一个结构体struct foo_frame { int a; int b; int c; char d; };但更简单的做法是符号表里保存每个变量相对帧基址的偏移代码生成时直接用*(int *)((char *)frame_base offset)来访问——这会把生成的 C 代码搞得很丑可读性变差但正确性更容易验证。我实际采用的是「计算偏移但不强制生成 frame_base 指针而是让 gcc 去做分配」因为在中小型编译器里你生成的代码是给人看的用int、char声明即可偏移量只用于语义检查的重复定义检测。真正需要栈帧偏移的场景是把你的编译器改造成「输出汇编」时那时你得算出每个局部变量在真实栈上的位置。现在用 C 作为目标语言这一步可以偷懒。但你仍然要在符号表里记录每个局部变量的「定义顺序」并且在遇到重复声明时报错。把这个设计做好将来切后端的成本能降低一半。5.2 条件跳转指令不生成 goto用结构化表达编译if和while最直接的方式是生成goto标签但生成目标 C 代码带一堆goto检查起来会很痛苦。我的习惯是只要子集支持{}块就在生成if时用三目运算符或嵌套 if-else 表达。比如if (x 0) { a 1; } else { a 2; }if (x 0) { a 1; } else { a 2; }这个翻译很直白并不需要另外设计跳转指令。只有当你实现continue和break时才需要生成带标签的循环嵌套但那个可以在生成while时顺带维护两层变量。5.3 指针运算只支持加法和取值不支持 p 1 变体若你决定支持char*就必然会遇到p 1这种指针算术。在小型编译器里我建议直接把指针加减定义为非法只允许指针赋地址和指针取值。原因是指针加法要求编译器知道指针所指对象的大小这个信息在语义分析阶段你确实拿到符号表里有类型但生成 C 代码时p 1在 C 里会自动按sizeof(*p)扩展——你本来的语义可能是「前进一个字节」结果 gcc 把这个改成了前进一个char的大小恰好 1如果p是int*就会出大事。所以要么你彻底支持带高度类型匹配的指针运算要么直接就报错。多数小型编译器学习项目选了后者这是在「范围小」和「不误导」之间做的合理取舍。5.4 局部变量初始化简化成声明后单独赋值C 允许int x 5;在声明里直接初始化。这个功能实现成本高解析器要在声明语句里接一个赋值操作。偷懒方案是语法分析时把int x 5;拆成两条——先声明int x;再生成一条赋值语句x 5;。这个拆解在 AST 层面做的不会污染符号表。这样做的额外好处是你在语义分析阶段可以统一处理「赋值类型检查」和「变量已定义检查」不用为声明初始化和普通赋值写两套逻辑。代价是生成的 C 代码稍微啰嗦一点但可读性没问题。## 6. 自举测试法与一个让实现不再黑盒的习惯 代码写出来只是开始验证才是打磨的过程。我强烈建议做一个最小自举测试把个人实现里能够自己「编译」的某个源文件改成符合这个子集的 C 代码用你的编译器编译它看看会不会「自己咬自己」。这种自举测试法能一次性暴露前端、中端和后端里的很多「虚假完成」如果连自己的代码都编不过说明你对这组子集的理解是有偏差的。 自举测试法的具体操作是 1. 精心维护一组 test/*.c 测试文件覆盖算术、控制流、函数调用、指针解引用 2. 每次代码改动后跑一条测试脚本对比「原版 gcc 编译运行的结果」和「你的编译器转译后 gcc 编译运行的结果」两者必须完全一致 3. 在测试脚本里加入 diff -u 对生成的 C 代码做差异比对一旦发现无意的格式变化或语义变化立刻定位到最近一次改动。 我见过不少人写完一个模块就急着写下一个哪个模块有问题根本不知道。有了自举测试套件每改一行代码就能立刻验证这才是编译器开发不至于失控的护身符。 另外我还养成了一个习惯永远在生成的 C 代码里带上足够多的括号不要把优先级交给读代码的人。这个习惯的得来是一次血泪教训——我曾经生成 a b c * d;本意是 a (b c) * d因为忘记加括号被 gcc 抢答成了另一种语义调试了整整一个下午。从那以后我生成任何二元表达式都套一层括号虽然浪费一点可读性但杜绝了这类优先级错误。 小型 C 编译器的实现并不需要一次到位。先把词法、语法、语义三步走通再把代码生成落到「转译成 C」你拥有的就是一个功能完整、可扩展、可阅读的编译器骨架。之后想加 struct、加数组、加优化都可以在这个骨架上按部就班地添砖加瓦。希望这篇文章的理念和踩坑记录能帮你在实现自己的小型 C 编译器时少走一段弯路。 p a hrefhttps://download.csdn.net/download/shania_wang/2607122 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p
网站建设高端定制企业官网