拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从零手写小型C编译器:词法、语法到代码生成的完整实现与避坑指南

从零手写小型C编译器:词法、语法到代码生成的完整实现与避坑指南

简介:这是一份小型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 填充类型

语法分析只保证形状对,不保证含义对。语法分析完成后,我需要第二遍遍历 AST:

typedef 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类型只能赋给int,char类型赋给char,char*赋给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是待生成的 AST;indent表示当前缩进层级,由调用方传入,用于生成可读的缩进。关键点是表达式里所有二元运算符都加括号,避免输出代码运算符优先级出问题——例如生成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;这行代码编译后输出 C,gcc 再编译,运行结果却是返回 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 偏移为 8,d 偏移 9(char 占 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 href="https://download.csdn.net/download/shania_wang/2607122" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>
返回列表