简介:这是一份面向编译原理学习者与C语言进阶开发者的开源级小型C编译器源码,适合希望从零理解词法分析、语法分析、语义检查、优化与代码生成全流程的读者。资源包共86个文件,以50个C源文件与12个头文件为核心,辅以8个Pascal文件、5个文本说明、3个批处理脚本及Makefile、配置文件等,整体约210KB,结构紧凑,便于逐模块研读。内容覆盖词法扫描、抽象语法树构建、符号表管理、类型检查、窥孔优化以及针对386与68系列架构的代码生成,并包含预处理、错误诊断与内存管理等支撑模块。已有479人学习下载,读者可借此掌握指针、结构体、函数调用等特性的编译处理方式,积累调试编译错误与优化代码性能的实战经验,是开发自有编译器或深入C语言底层机制的理想起点。
1. 从一份小型C编译器源码说起:它到底能跑通什么
如果你写过int main() { return 0; },大概率想过一个问题:这行字符到底是怎么变成机器能执行的指令的?很多人翻过《编译原理》的龙书,词法分析、语法分析、语义分析、中间代码、目标代码这套流程背得滚瓜烂熟,但真让你从零写一个能编译 C 子集的编译器,往往卡在“知道理论但不知道代码长什么样”。这份小型 C 编译器源码就是冲着这个断层来的——它把一整套前端到后端的流程压缩到可读的代码量里,能编译带变量声明、算术表达式、条件分支、循环和函数调用的 C 子集,最终生成可执行的汇编或中间表示。
它适合三类人:正在学编译原理、需要一份能跑通的参考实现来对照课本的学生;想给自己项目加个 DSL 或脚本引擎、需要理解编译器工程结构的开发者;以及想搞清楚“编译器到底怎么处理作用域和类型”的从业者。源码本身不依赖庞大的 LLVM 或 GCC 框架,常见做法是自带词法分析器、递归下降语法分析器、符号表和简单的代码生成器,整个链路是自洽的,你能从入口一路跟到输出,不会掉进某个第三方库的黑匣子里。
2. 编译器骨架拆解:词法、语法、符号表怎么串起来
2.1 词法分析器:把字符流切成 Token
词法分析是整个编译器的入口,干的事很朴素:读入源码字符流,跳过空白和注释,识别出关键字、标识符、数字、运算符和分隔符,输出一个 Token 序列。这份源码里通常用一个Token结构体保存类型和值,再用一个Lexer结构体维护当前读取位置和行号。核心逻辑是一个next_token()函数,每次调用返回下一个 Token。
// lexer.h - 词法分析器核心结构 typedef enum { TOKEN_INT, TOKEN_IDENT, TOKEN_NUMBER, TOKEN_PLUS, TOKEN_MINUS, TOKEN_STAR, TOKEN_SLASH, TOKEN_LPAREN, TOKEN_RPAREN, TOKEN_LBRACE, TOKEN_RBRACE, TOKEN_IF, TOKEN_ELSE, TOKEN_WHILE, TOKEN_RETURN, TOKEN_EOF, TOKEN_UNKNOWN } TokenType; typedef struct { TokenType type; char *value; // 标识符或数字的字面量 int line; // 行号,报错时定位用 } Token; typedef struct { const char *src; // 源码起始指针 int pos; // 当前读取位置 int line; // 当前行号 } Lexer; Token lexer_next(Lexer *lexer);这段代码的关键在于Token里带了line字段。很多教学实现为了省事不记录行号,结果语法报错时只能告诉你“出错了”,没法定位到具体哪一行,调试体验极差。lexer_next的实现一般是一个大循环:先跳过空白字符和//、/* */注释,然后根据首字符判断是数字、字母还是符号。数字走一条分支解析整数,字母走一条分支先收集完整标识符再查关键字表,符号则直接映射到对应的 TokenType。
参数上唯一需要留意的是pos和line的同步更新。每消费一个换行符,line必须加一,否则报错行号会漂移。常见做法是在跳过空白的那段循环里统一处理换行计数,而不是散落在各个分支里,这样不容易漏。
2.2 递归下降语法分析:表达式优先级怎么落地
语法分析器拿到的是一串 Token,要把它组织成抽象语法树(AST)。这份源码用的是递归下降法,优点是代码结构和文法规则几乎一一对应,可读性强。表达式解析是重点,因为要处理优先级:乘除高于加减,括号最高。
// parser.c - 表达式解析,体现优先级层次 ASTNode *parse_expression(Parser *p) { return parse_additive(p); } ASTNode *parse_additive(Parser *p) { ASTNode *left = parse_multiplicative(p); while (p->cur.type == TOKEN_PLUS || p->cur.type == TOKEN_MINUS) { TokenType op = p->cur.type; advance(p); // 消费运算符 ASTNode *right = parse_multiplicative(p); left = make_binary_node(op, left, right); } return left; } ASTNode *parse_multiplicative(Parser *p) { ASTNode *left = parse_primary(p); while (p->cur.type == TOKEN_STAR || p->cur.type == TOKEN_SLASH) { TokenType op = p->cur.type; advance(p); ASTNode *right = parse_primary(p); left = make_binary_node(op, left, right); } return left; }这里的分层思路是:parse_expression调parse_additive,parse_additive调parse_multiplicative,parse_multiplicative调parse_primary。每一层只处理自己那档优先级的运算符,遇到更高优先级的就往下调,返回后再决定是否继续组合。parse_primary负责处理数字、标识符和括号表达式,是递归的终点。
这种写法比用运算符优先级表加栈的算法更容易调试,因为调用栈直接反映了优先级层次。翻车点在于左递归:如果你把文法直接写成expr -> expr + term,递归下降会无限递归。解决办法就是像上面这样,用循环消费同优先级的运算符,而不是递归调用自己。
2.3 符号表与作用域:变量查找的后悔药
符号表负责记录变量和函数的声明信息,包括名字、类型、作用域层级。这份源码里通常用一个链表或动态数组实现,每个作用域是一个独立的表,进入{}时压入新作用域,离开时弹出。
// symtab.c - 作用域链式符号表 typedef struct Symbol { char *name; Type type; int scope_level; struct Symbol *next; } Symbol; typedef struct Scope { Symbol *symbols; struct Scope *parent; } Scope; Symbol *symtab_lookup(Scope *scope, const char *name) { while (scope != NULL) { Symbol *s = scope->symbols; while (s != NULL) { if (strcmp(s->name, name) == 0) return s; s = s->next; } scope = scope->parent; // 当前作用域找不到,往上层找 } return NULL; // 未声明 }symtab_lookup从当前作用域开始,逐层向上查找,找到就返回,找不到返回 NULL。这个逻辑对应 C 语言的作用域规则:内层可以遮蔽外层的同名变量,但外层看不到内层的变量。插入符号时只往当前作用域插,不往父作用域插,这样离开作用域后变量自然失效。
需要留意的参数是scope_level。有些实现会用它来判断变量是否在同一层级,但更稳妥的做法是直接比较Scope指针,因为层级数字在嵌套复杂时容易算错。另外,函数参数的作用域处理是个常见坑:参数应该属于函数体那一层作用域,而不是函数外层的全局作用域,否则参数名会污染全局。
3. 从 AST 到可执行结果:代码生成与运行验证
3.1 遍历 AST 生成汇编或中间代码
AST 建好之后,代码生成器要做的是后序遍历:先处理子节点,再处理父节点。对于表达式节点,生成的是把操作数加载到寄存器、执行运算、把结果留在寄存器里的指令序列。对于语句节点,生成的是控制流跳转和函数调用。
// codegen.c - 表达式代码生成(以 x86 风格伪汇编为例) void gen_expr(ASTNode *node, FILE *out) { if (node->type == NODE_NUMBER) { fprintf(out, " mov $%d, %%eax\n", node->int_val); return; } if (node->type == NODE_BINARY) { gen_expr(node->left, out); fprintf(out, " push %%eax\n"); // 左操作数入栈保存 gen_expr(node->right, out); fprintf(out, " pop %%ebx\n"); // 右操作数算完后取回左操作数 switch (node->op) { case TOKEN_PLUS: fprintf(out, " add %%ebx, %%eax\n"); break; case TOKEN_MINUS: fprintf(out, " sub %%ebx, %%eax\n"); break; case TOKEN_STAR: fprintf(out, " imul %%ebx, %%eax\n"); break; case TOKEN_SLASH: fprintf(out, " cdq\n idiv %%ebx\n"); break; } return; } // 变量引用、函数调用等分支略 }这段代码的核心策略是用栈来保存中间结果。先生成左子树的代码,结果在%eax里,压栈;再生成右子树,结果也在%eax里;然后弹出左操作数到%ebx,此时%eax是右操作数,%ebx是左操作数,执行运算后结果留在%eax。除法需要cdq把%eax符号扩展到%edx:%eax,再用idiv,否则会溢出或结果错误。
如果你用的是中间表示(比如三地址码)而不是直接生成汇编,思路类似,只是把寄存器换成了临时变量。常见做法是先定义一套 IR 指令结构,再写一个 IR 到汇编的翻译层,这样后端换目标平台时不用动前端。
3.2 编译并运行一个测试用例
源码包一般会带一个Makefile或构建脚本。常见流程是先编译编译器本身,再用它编译测试文件。
# 构建编译器 make # 用编译器编译一个测试文件 ./mycc test/hello.c -o hello.s # 汇编并链接 gcc hello.s -o hello ./hello echo $? # 查看返回值make会根据Makefile里的规则编译各个.c文件并链接成可执行文件。./mycc是编译出来的编译器,-o指定输出汇编文件。第二步用系统gcc把汇编变成可执行文件,这一步是必要的,因为小型编译器通常只负责生成汇编,不负责汇编和链接。最后运行并检查返回值,如果测试用例是return 42;,echo $?应该输出 42。
参数上要注意-o的输出路径。有些实现默认输出到标准输出,有些默认写文件,看源码里main函数怎么处理命令行参数。如果编译报错,先确认测试文件用的是编译器支持的 C 子集,比如不支持#include、不支持浮点数、不支持结构体,那就别拿一个用了这些特性的文件去测。
3.3 验证编译器正确性的几个手段
光跑通一个hello.c不够,要验证正确性得系统性地测。常见做法是准备一组覆盖不同语法特性的测试用例,每个用例有预期返回值,跑完对比实际返回值。
| 测试类型 | 用例示例 | 预期结果 |
|---|---|---|
| 算术优先级 | return 2 + 3 * 4; | 14 |
| 括号 | return (2 + 3) * 4; | 20 |
| 条件分支 | if (1) return 10; else return 20; | 10 |
| 循环 | int i=0; while(i<5) i=i+1; return i; | 5 |
| 函数调用 | int add(int a,int b){return a+b;} return add(3,4); | 7 |
| 作用域 | int x=1; { int x=2; } return x; | 1 |
跑完这些用例,基本能确认词法、语法、语义和代码生成的主干是通的。如果某个用例挂了,先看是编译期报错还是运行结果不对。编译期报错查词法和语法,运行结果不对查代码生成和作用域处理。
4. 避坑与排查:小型编译器最容易翻车的几个地方
4.1 词法分析把==识别成两个=
现象:写if (a == b)时编译器报语法错误,或者行为异常。原因:词法分析器在遇到=时只返回一个TOKEN_ASSIGN,没有向前看一个字符判断是否是==。解决:在符号识别分支里对双字符运算符做前瞻,遇到=时检查下一个字符,如果是=就消费两个字符返回TOKEN_EQ,否则返回TOKEN_ASSIGN。同理要处理!=、<=、>=。
4.2 递归下降遇到左递归导致栈溢出
现象:编译一个简单表达式时程序崩溃,调用栈极深。原因:文法写成expr -> expr + term,递归下降直接照搬,parse_expr一进来就调parse_expr,无限递归。解决:把左递归改成循环,像 2.2 节那样用while消费同优先级运算符,只在需要下降优先级时才递归调用下一层函数。
4.3 符号表作用域没弹出,变量泄漏到外层
现象:内层{}里声明的变量,出了{}还能访问,或者同名变量冲突。原因:进入作用域时压入了新Scope,但离开时忘了弹出,或者插入符号时插到了父作用域。解决:在语法分析处理{}时严格配对压入和弹出,插入符号只操作当前Scope。可以在Scope结构里加个level字段,调试时打印出来确认层级正确。
4.4 代码生成时寄存器被覆盖
现象:复杂表达式计算结果不对,简单表达式正常。原因:生成右子树代码时用了%eax,把左子树留在%eax的结果覆盖了,但没有提前压栈保存。解决:在生成右子树之前先把左子树结果压栈,右子树算完后再弹回另一个寄存器。如果表达式嵌套很深,要注意栈的平衡,确保每次push都有对应的pop。
4.5 测试用例超出编译器支持的子集
现象:拿一个用了#include <stdio.h>或浮点数的 C 文件去编译,报一堆错。原因:小型编译器通常只支持 C 的一个子集,不支持预处理指令、标准库、浮点、结构体等。解决:先看源码的README或测试目录,确认支持的特性范围,用符合子集的测试文件。如果确实需要更多特性,那得自己扩展词法、语法和代码生成,不是改个参数能解决的。
5. 进阶玩法:扩展编译器支持新特性与自动化测试
5.1 加一个for循环需要动哪些地方
假设你想让这个编译器支持for (int i=0; i<n; i=i+1) { ... },需要改三处。第一,词法分析器加TOKEN_FOR关键字,在关键字表里注册"for"。第二,语法分析器加parse_for函数,解析for后面的括号里的初始化、条件、步进三个部分,然后解析循环体,组装成 AST 节点。第三,代码生成器处理这个新节点,常见做法是把for脱糖成while的形式:先执行初始化,然后生成条件判断和循环体,循环体末尾执行步进再跳回条件判断。
// 语法分析里 for 的解析骨架 ASTNode *parse_for(Parser *p) { expect(p, TOKEN_FOR); expect(p, TOKEN_LPAREN); ASTNode *init = parse_statement(p); // 初始化部分 ASTNode *cond = parse_expression(p); // 条件 expect(p, TOKEN_SEMICOLON); ASTNode *step = parse_expression(p); // 步进 expect(p, TOKEN_RPAREN); ASTNode *body = parse_block(p); // 循环体 return make_for_node(init, cond, step, body); }代码生成时,for节点可以转成等价的while:先生成init,然后生成一个标签标记条件判断位置,生成cond并在为假时跳转到结束标签,生成body,生成step,跳回条件标签,最后放置结束标签。这样不用在代码生成层单独处理for的控制流,复用while的逻辑就行。
5.2 用脚本自动化跑回归测试
手动一个个跑测试用例太慢,写个脚本批量跑并对比结果。下面是一个 bash 脚本的骨架,遍历tests/目录下的.c文件,每个文件第一行注释里写预期返回值,编译运行后对比。
#!/bin/bash # run_tests.sh - 批量回归测试 PASS=0 FAIL=0 for src in tests/*.c; do expected=$(head -1 "$src" | grep -oP 'expect:\K\d+') ./mycc "$src" -o /tmp/test.s 2>/dev/null if [ $? -ne 0 ]; then echo "COMPILE FAIL: $src" FAIL=$((FAIL+1)) continue fi gcc /tmp/test.s -o /tmp/test_bin 2>/dev/null /tmp/test_bin actual=$? if [ "$actual" -eq "$expected" ]; then PASS=$((PASS+1)) else echo "FAIL: $src expected=$expected actual=$actual" FAIL=$((FAIL+1)) fi done echo "Passed: $PASS, Failed: $FAIL"脚本里head -1取测试文件第一行,用grep -oP 'expect:\K\d+'提取预期值,这要求测试文件第一行写成// expect: 42这样的格式。编译失败直接计为 FAIL 并跳过运行。运行后取$?作为实际返回值,和预期对比。这个脚本能帮你在改编译器代码后快速确认没有引入回归。
我自己的习惯是每加一个特性,先往tests/里丢三五个对应用例,跑一遍脚本确认全绿,再动手改下一处。有一次改代码生成时不小心把减法操作数的顺序搞反了,就是靠这个脚本在几秒内发现return 5 - 3;返回了 -2 而不是 2。从那以后我每次动代码生成相关逻辑,都强制先跑一遍回归脚本再继续。希望这份源码和这些排查思路能帮到你。
本文还有配套的精品资源,点击获取