news 2026/10/15 17:18:02

从零手写小型C编译器:词法、AST与x86-64汇编的完整实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零手写小型C编译器:词法、AST与x86-64汇编的完整实现

简介:一套小型C语言编译器的完整源代码,面向希望深入理解编译原理的开发者、计算机专业学生以及底层系统编程爱好者。项目覆盖词法分析、语法分析、语义分析、优化与代码生成等核心环节,并包含生成汇编代码的实现,可作为学习编译器工作流程、阅读和修改真实代码的起点。

压缩包内共86个文件,大小约210KB,以50个C源文件与12个头文件为主体,还包含汇编文件、Makefile、批量构建脚本、配置文件和说明文档,结构清晰,便于在不同环境下构建和查阅。

目前已有478人学习下载,适合用于课程设计、自主实践或作为开发编译器的参考蓝本。通过分析其中的解析器、符号表管理、中间表示与代码生成模块,可以加深对C语言底层机制及编译全过程的理解,同时积累调试编译错误、优化代码性能的实际经验。

1. 一个小型C编译器:几千行代码就把编译黑匣子掀翻了

编译器在很多开发者心里等同于一本六百页的编译原理教材:先背正则文法和LL/LR表,再学三地址码,最后还要搞寄存器分配,还没动手写代码就劝退一半人。但这套小型C编译器源代码走的是另一条路:不引入Lex/Yacc,不对接LLVM,只靠纯C手写词法分析、递归下降解析和x86-64汇编生成,就能把一份包含变量、指针、循环、函数调用的C子集源码,真正编译成可执行文件跑出结果。它的价值不是“功能全”,而是“链路完整可读”:从源码字符串到Token链表再到AST,最后变成一串push/pop汇编指令,每一步都有明确的数据结构和函数一一对应。对想亲手写一个解析器或DSL的开发者,它是可以直接借鉴的骨架;对刚学完C但还没碰过编译原理的程序员,它把黑匣子从外面掀开了,剩下就是你一行行读源码的事了。

2. 整体架构与目录结构:token、AST、汇编的三段式流水线

2.1 目录只有六七个文件:每个文件只包办一件事

整个工程结构非常克制,打开根目录一共就这么点东西:

. ├── Makefile # 构建与回归测试入口 ├── smallcc.h # 公共头文件:Token、Node、Program定义与函数声明 ├── main.c # 程序入口:读文件、调度tokenize→parse→codegen ├── tokenize.c # 词法分析:把源码字符串切成Token链表 ├── parse.c # 语法分析:把Token链表构造成AST ├── codegen.c # 代码生成:遍历AST并输出x86-64汇编 └── test/ ├── run_test.sh # 一键回归:编译全部测试用例并校验返回值 └── test_*.c # 用miniC子集写成的可执行测试程序

main.c承担的是调度角色:打开文件、把整个源码一次性读到字符串,然后按顺序调用tokenize、parse、codegen,全程不依赖任何第三方库。tokenize.c完成词法分析,把源码字符流切成带位置的Token链表;parse.c构建递归下降解析器,把Token链表变成拥有层级关系的AST;codegen.c遍历AST,往标准输出打印x86-64汇编。它不输出.o文件,也不调用汇编器,汇编文本由调用者重定向保存,后续用系统自带的汇编器和链接器生成可执行文件。

test目录放了一整套回归用例和运行脚本。run_test.sh的常规逻辑是:枚举test目录下所有.c文件,逐个调用smallcc得到.s文件,再用系统的cc编成可执行文件,运行后校验返回值是否等于程序里写的退出码。这种做法是小型编译器开发里最常见的回归姿势——没有测试框架也能保证每次改动后不破坏已有语法,我在给编译器加功能时永远先补一个能跑通的用例,再动解析器。

构建命令只有两条:

make make test

Makefile里就是把main.c、tokenize.c、parse.c、codegen.c编译链接成smallcc,编译选项开启-Wall -Wextra。这算是我个人最推荐的C编译器开发配置:所有未使用变量、隐式类型转换都会被警告打出来,很多代码生成阶段的低级错误在编译自身时就能提前暴露。

2.2 Token、Node、Program:三种结构体分别管哪个阶段

编译过程分成三个阶段,核心数据结构也正好对应三种:Token管词法,Node管语法,Program管整个编译单元。Token的定义:

typedef enum { TK_PUNCT, // 运算符或标点,如 + - * / TK_NUM, // 整数常量 TK_IDENT, // 变量名或函数名 TK_EOF, // 文件结尾 } TokenKind; typedef struct Token { TokenKind kind; char *loc; // 指向源码字符串中的起始位置 int len; // 该token占用字节数 long val; // 仅当kind==TK_NUM时保存整数值 struct Token *next; // 链表下一个节点 } Token;

Token的特点是“扁平”:它只记录词法信息,不做嵌套,也不判断“数字后面跟加号是否合法”——合法性是语法阶段的事。loc和len组合让所有阶段共享同一份源码内存,不需要复制子串;val字段在词法阶段就把数字字符"42"转成long,省得解析器再做字符累加。

Node的定义是另一个关键结构体:

typedef enum { ND_ADD, // 加法 + ND_SUB, // 减法 - ND_MUL, // 乘法 * ND_DIV, // 除法 / ND_NEG, // 一元负号 -x ND_ASSIGN, // 赋值 = ND_LVAR, // 局部变量 ND_NUM, // 整数常量 } NodeKind; typedef struct Node { NodeKind kind; struct Node *lhs; // 左子节点 struct Node *rhs; // 右子节点 long val; // 仅当kind==ND_NUM时保存数值 int offset; // 仅当kind==ND_LVAR时保存栈偏移 } Node;

Node是带左右子树的二叉树结构,加减乘除和赋值都有lhs与rhs,一元负号只用lhs,数字和局部变量是叶子节点、两个指针都置NULL。offset字段记录局部变量在函数栈帧里相对rbp的偏移量,代码生成阶段访问变量唯一靠它。一个1 + 2 * 3表达式会被构造成:根是ND_ADD,左子树是数字1,右子树是ND_MUL,其左子树是2、右子树是3。这种嵌套结构天然表达了优先级,因为代码生成永远先递归处理左右子树、再在当前节点产生运算指令。

Program目前只是一个Node数组的容器,用来按顺序保存函数体里的每一条顶层语句。等后面要加多函数支持,再在这个结构体里追加函数表、参数列表和类型信息。三种结构体各管各的,改词法不影响语法结构,改语法不影响Token布局,模块边界非常清爽。

2.3 主流程:main函数只做三件调度

Token *tokenize(char *src); Program *parse(Token *tok); void codegen(Program *prog);

三个函数签名一个比一个抽象:tokenize吃进裸字符串,吐出Token链表;parse吃进Token链表,吐出AST;codegen吃进AST,往stdout写汇编。main.c里除了读文件,就是按顺序调这三个函数:

int main(int argc, char **argv) { if (argc != 2) { fprintf(stderr, "用法: %s <源文件>\n", argv[0]); return 1; } char *src = read_file(argv[1]); Token *tok = tokenize(src); Program *prog = parse(tok); codegen(prog); return 0; }

read_file是main.c里的静态辅助函数,用fseek加ftell先量文件大小,再一次性读到内存,末尾补一个'\0'。之后tokenize拿到这段连续内存,所有Token的loc都指向它内部,parse和codegen阶段完全不再碰文件系统——整个编译过程几乎零拷贝。

从命令行角度看只有三步:

./smallcc hello.c > hello.s cc -o hello hello.s ./hello

第一步把hello.c编成汇编文件;第二步用系统汇编器加链接器生成可执行文件;第三步跑结果。为什么不直接输出可执行文件?因为在编译器开发早期,把汇编文本打出来、用肉眼对照指令是排错最快的方式。看到push rax、pop rdi一行行出现在终端里,就能直接对应回AST上那个节点,“慢”这一步换来的是后期“快”得多的排错体验。

3. 核心模块拆解:一个表达式从字符流变成机器码的完整旅程

这一章选一个最小但完整的程序int main() { return 42; },跟着它走完三个模块,看每一步的数据结构长什么样。

3.1 tokenize.c:用字符跳跃替代正则表达式

词法分析传统上被认为必须上Lex或正则表达式,但这个项目证明手写完全够用。核心思路是一双“眼睛”:从头到尾扫源码,遇到不同字符跳到不同分支。主循环的关键片段:

// 双字符运算符优先匹配,避免 <= 被切成 < 和 = if (startswith(p, "==") || startswith(p, "!=") || startswith(p, "<=") || startswith(p, ">=")) { cur = cur->next = new_token(TK_PUNCT, p, p + 2); p += 2; continue; } // 标识符与关键字都按 IDENT 切,解析阶段再分辨 if (isalpha(*p) || *p == '_') { char *start = p; while (isalnum(*p) || *p == '_') p++; cur = cur->next = new_token(TK_IDENT, start, p); continue; } // 数字字面量直接转成 long,解析阶段不用再做字符累加 if (isdigit(*p)) { cur = cur->next = new_token(TK_NUM, p, p); cur->val = strtoul(p, &p, 10); cur->len = p - cur->loc; continue; }

这里最值得注意的是双字符优先匹配:如果<=按单字符切,会被拆成<和=两个token,后续递归下降解析会把赋值表达式和比较表达式混在一起,语法错误会非常诡异。这种“最长匹配”原则是手写词法最容易忽略的坑——因为单字符分支写起来太顺手,几乎每个初学者都会忘掉这个先决条件。数字分支的strtoul函数比较省事,它直接吞掉连续数字并推进指针p,连len都不用手工算。标识符分支把return这类关键字也先按IDENT存起来,等到语法分析阶段再通过对token内容做字符串比较来判定是不是关键字,词法层不区分,处理就简单很多。

3.2 parse.c:递归下降与优先级绑定

语法分析是整个工程里最考验思路的部分。它采用递归下降解析,优先级靠“嵌套层级”实现:越外层的函数优先级越低,越内层的函数优先级越高。核心结构是add和mul两个函数形成两层:

// 对应优先级最低的加减法 static Node *add(Token **rest, Token *tok) { Node *node = mul(&tok, tok); while (equal(tok, "+") || equal(tok, "-")) { NodeKind kind = equal(tok, "+") ? ND_ADD : ND_SUB; Node *rhs = mul(&tok, tok->next); Node *nd = new_node(kind, tok); nd->lhs = node; nd->rhs = rhs; node = nd; } *rest = tok; return node; } // 对应优先级更高的乘除法 static Node *mul(Token **rest, Token *tok) { Node *node = unary(&tok, tok); while (equal(tok, "*") || equal(tok, "/")) { NodeKind kind = equal(tok, "*") ? ND_MUL : ND_DIV; Node *rhs = unary(&tok, tok->next); Node *nd = new_node(kind, tok); nd->lhs = node; nd->rhs = rhs; node = nd; } *rest = tok; return node; }

解析1 + 2 * 3的调用路径是这样的:add进入,先调用mul。mul又先调unary,unary看到数字1直接返回ND_NUM节点。mul检查下一个token是+而不是*,知道自己的乘除层结束了,把数字1节点还给add。add看到+,调用mul处理右侧。这次mul遇到数字2后看到*,于是把2、3、*组合成ND_MUL节点,再返回给add。add把左侧ND_NUM和右侧ND_MUL组合成新的ND_ADD。一颗完全符合C语义的语法树就此生成。

这里最容易迷惑的点是:函数调用关系等同于语法优先级,而不是代码逻辑里“先乘后加”。只要把add放在最外层,自然就实现了加法的低优先级;想在中间插入比较运算,就在add和mul之间再嵌一个relational层。整个解析器就靠这种层级一层层包起来,每新增一个优先级就新增一层函数,非常机械、也非常可靠。

3.3 codegen.c:栈式代码生成与x86-64指令选择

代码生成在这个项目里采用非常朴素的栈式模型:每次gen_expr递归结束时,把表达式的值压到栈上;遇到二元运算,弹出右操作数到rdi,弹出左操作数到rax,运算完把结果再压回栈。以加法为例:

case ND_ADD: gen_expr(node->lhs); gen_expr(node->rhs); printf(" pop rdi\n"); printf(" pop rax\n"); printf(" add rax, rdi\n"); printf(" push rax\n"); return;

为什么用push/pop而不是直接分配寄存器?因为这种栈式代码生成完全不需要寄存器分配器。每个子表达式负责把自己的结果留在栈顶,父节点需要时弹出两个操作数用掉,计算完再压回去,栈深度由表达式嵌套深度决定,永远不会冲突。性能不是最好,但正确性最容易保证——教学实现和个人工具恰好最需要这一点。

再看一下int main() { return 42; }完整生成的汇编:

.globl main main: push rbp mov rbp, rsp mov eax, 42 pop rbp ret

push rbp; mov rbp, rsp建立栈帧;mov eax, 42把立即数放入eax,因为x86-64的C调用约定用eax返回int;pop rbp; ret恢复栈帧并返回。注意这里不需要给局部变量预留栈空间,因为main没有变量;遇到有变量的函数,会在prologue里追加sub rsp, 变量总字节数,并在变量生命周期结束时配合offset完成读写。

3.4 分支与循环:控制流的标签跳转实现

表达式处理完,控制流是编译器的另一大块。while循环在AST里对应ND_WHILE节点,cond是循环条件,body是循环体,代码生成用两个标签就解决:

case ND_WHILE: { String *begin = new_label(); String *end = new_label(); printf("%s:\n", begin->data); gen_expr(node->cond); printf(" pop rax\n"); printf(" test rax, rax\n"); printf(" jz %s\n", end->data); gen_stmt(node->body); printf(" jmp %s\n", begin->data); printf("%s:\n", end->data); return; }

逻辑非常直观:先把begin标签打印出来,计算条件后test rax, rax检查结果,如果为零跳到end;否则执行循环体,然后无条件跳回begin。注意在generate body之前绝对不能忘记打印begin标签,我曾经把begin标签放在gen_expr(cond)之后,导致第一次条件判断漏执行,程序一跑就死循环。标签字符串的统一管理也是一个小要点,每次调用new_label返回唯一标号,避免不同循环之间标签重名。

4. 常见问题与避坑:沿着编译流程逐个排查

4.1 链接失败:undefined reference to 'main' 是怎么回事

现象:写了一个最小测试程序,smallcc正常输出汇编,但用cc -o test test.s链接时报undefined reference to 'main'。原因几乎总是codegen阶段生成的函数入口符号不对或被遗漏。在类Unix平台上,函数符号直接使用main,但缺少.globl main指令时,链接器看不到这个符号,于是认为入口不存在。解决:检查生成的.s文件前几行,确认有.globl main;同时确认函数标签main:前没有下划线之类额外前缀。如果两个平台符号规则不一样,最稳妥的做法是在build脚本里做一次平台判断,统一由脚本决定要不要给符号加前缀。

4.2 生成的程序一运行就段错误

现象:编译器能编出可执行文件,但一运行就Segmentation fault。最常见的原因是局部变量的栈偏移算反了。栈是向下增长的,偏移必须是负数,比如rbp - 8,如果你在代码生成时忘了取负,生成了rbp + 8,读写位置就越过了返回地址。解决:先看汇编文件里访问变量的那几行,确认是-4(%rbp)而不是4(%rbp);另一个容易被忽略的地方是函数prologue里有没有正确的push rbp,如果忘记push,rbp和rsp的关系就乱了,后面所有偏移都不成立。我排查这类问题时会用gdb跑一下生成的程序,backtrace看到崩在哪个指令,再对照那条汇编往前推,基本十分钟内能定位。

4.3 优先级翻车:1+2*3 算出来是9

现象:写测试用例1+2*3,程序返回9而不是7。原因很简单:解析器把加法和乘法放在了同一层函数里。当你只写了add一层循环、没有单独拆出mul层的时候,递归下降解析器会把token流从左到右全部按同一个优先级处理,1+2*3就成了(1+2)*3。解决:老老实实把解析器拆成add→mul→unary三层嵌套,让乘法深入一层、优先级更高。想验证这个改动,测试用例里至少要有1+2*3==7和2+3*1==5两组数据,分别测左结合和混合优先级,否则很容易改了一层漏了另一层。

4.4 汇编平台差异:同一份.s在不同机器上表现不同

现象:同一段生成汇编,在自己机器上一切正常,换一个环境就报汇编语法错误。原因是不同平台的汇编器和链接器对C符号的修饰规则不一致:有的直接输出符号名,有的要求全部加前导下划线,有的还要求.type main, @function此类伪指令。解决:最稳的做法是不要把平台相关逻辑散落在codegen.c里,而是在build.sh开头探测当前环境,通过-D选项传一个宏给编译器,比如-DSYMBOL_PREFIX="_",然后codegen里统一用宏拼装符号名。另一个保底办法是显示指定cc -no-pie test.s或调整链接参数,先跑通再谈优化。

5. 二次开发实战:三步教会编译器取模运算

给编译器加一个新运算符,是检验你是否真的吃透这份源码最快的办法。以%取模为例,它的优先级和*、/同一层,改动点集中在parse.c和codegen.c两个文件,加起来不到二十行。

第一步,改parse.c里mul函数,让它同时识别*、/、%三个运算符:

while (equal(tok, "*") || equal(tok, "/") || equal(tok, "%")) { NodeKind kind = ND_MUL; if (equal(tok, "/")) kind = ND_DIV; else if (equal(tok, "%")) kind = ND_MOD; Node *rhs = unary(&tok, tok->next); Node *nd = new_node(kind, tok); nd->lhs = node; nd->rhs = rhs; node = nd; }

第二步,在codegen.c里为ND_MOD生成除法取余指令:

case ND_MOD: gen_expr(node->lhs); gen_expr(node->rhs); printf(" pop rdi\n"); printf(" pop rax\n"); printf(" cqo\n"); printf(" idiv rdi\n"); printf(" push rdx\n"); return;

第三步,写一个最小用例并跑回归:

cat > mod.c <<'EOF' int main() { return 17 % 5; } EOF ./smallcc mod.c > mod.s cc -o mod mod.s ./mod echo $? # 预期输出 2

注意idiv和div的区别:idiv是带符号除法,被除数在rdx:rax组合的128位值中,所以在除法前必须用cqo把rax符号扩展到rdx,否则高位垃圾会让商溢出,CPU直接触发除法异常。余数放在rdx里,push rdx就是取模结果。想在语义上追究也可以:C语言的%是把负商向零取整后得到余数,数学意义上的模运算还要考虑符号修正,这也是为什么严格说应把它叫“取余”而不是“取模”。

从那以后我每次给编译器加新功能,都强制先写一个能被make test捕获的最小用例,再动parse和codegen,跑完make test确认老用例没被影响才算完。这套流程救了我好几次,原本改一处运算符要排查半小时的玄学问题,现在十分钟就能定位。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 17:17:43

论文查重率居高不下?“重复率+AIGC”双保险降重法

论文写完&#xff0c;最怕的就是查重。辛辛苦苦写了几万字&#xff0c;一查重复率百分之三四十&#xff0c;整个人都不好了。更麻烦的是&#xff0c;现在很多高校不仅查重复率&#xff0c;还查AIGC疑似率——AI写作痕迹一旦被判定&#xff0c;轻则要求修改&#xff0c;重则影响…

作者头像 李华
网站建设 2026/10/15 17:17:29

STM32模拟交通灯Keil工程实战:从源码解析到答辩项目改造

简介&#xff1a;这份资源是面向嵌入式初学者与高校课程设计学生的STM32模拟交通灯项目完整资料包&#xff0c;基于Keil μVision开发环境&#xff0c;用C语言实现红绿黄灯定时切换&#xff0c;涉及GPIO控制、定时器中断与状态机设计等知识点&#xff0c;适合作为单片机编程入门…

作者头像 李华
网站建设 2026/10/15 17:14:51

java数组练习

题目1 遍历数组并求和定义一个数组&#xff0c;存储1&#xff0c;2&#xff0c;3&#xff0c;4&#xff0c;5 遍历数组得到每一个元素&#xff0c;求数组里面所有的数据和package com.example;public class Demo {public static void main(String[] args){int[] arr{1,2,3,4,5}…

作者头像 李华
网站建设 2026/10/15 17:14:11

网页消息提醒音模块:合成音与文件音双路径实现

简介&#xff1a;面向网页前端开发者的消息提醒音实现示例包&#xff0c;聚焦HTML5 Audio API与事件监听&#xff0c;适合需要为聊天、管理系统增加新消息提示音的初中级JavaScript学习者。压缩包共3个文件&#xff0c;包含一个mp3提示音源文件、一个jQuery脚本及一个可直接运行…

作者头像 李华
网站建设 2026/10/15 17:14:09

Java Web购物车源码解析:Servlet+JSP+JDBC轻量级MVC实现

简介&#xff1a;面向JavaWeb初学者的购物车系统完整源码包&#xff0c;基于Servlet/JSPSessionJDBC实现&#xff0c;涵盖商品添加删除、数量更新、金额统计等核心交互&#xff0c;同时附有数据库脚本与详细开发笔记&#xff0c;适合课设练习与框架前基础巩固。源码演示了如何通…

作者头像 李华
网站建设 2026/10/15 17:13:43

C#利用EPPlus写入、读取、修改Excel实例:从Interop迁移实战

简介&#xff1a;这份资源面向需要在.NET环境中处理Excel的C#开发者&#xff0c;尤其是从事数据导入导出、报表生成等任务的程序员。内容围绕EPPlus这一开源库展开&#xff0c;覆盖XLSX格式文件的写入、读取与修改三大核心操作&#xff0c;并延伸至样式设置、公式计算、图表创建…

作者头像 李华