简介:这份资源是一套小型C编译器的完整源代码,面向希望深入理解编译原理、动手实践编译流程的开发者与计算机专业学生。它把词法分析、语法分析、语义分析、优化与代码生成等阶段落实为可读可改的代码,适合作为学习编译器内部运作的入门教材,也便于在此基础上调试编译错误、尝试优化策略。压缩包共86个文件,以50个c源文件与12个h头文件为主体,另含8个p、5个txt说明、3个bat构建脚本及makefile、cfg配置等,覆盖词法、语法、符号表、表达式、代码生成与目标机适配等模块,整体约210KB,结构紧凑便于通读。目前已有479人学习下载。通过分析并修改这套源码,读者可掌握指针、结构体、函数调用等C特性的处理方式,理解从源码到可执行程序的每一步,为自行开发编译器或深入底层机制打下实践基础。
1. 从一份能跑通的小型C编译器源码说起
很多人学编译原理时都有个错觉:语法树、三地址码、寄存器分配这些概念背得滚瓜烂熟,真让你写一个能编译int main(){return 0;}的编译器,却不知道从哪下手。这份小型C编译器源码就是冲着这个痛点来的——它把词法分析、语法分析、语义检查、中间代码生成、目标代码生成整条链路都实现了一遍,代码量控制在一个周末能通读的范围内。适合两类人:一类是刚学完编译原理想找个能跑的参照物,另一类是写过解释器但没碰过代码生成、想看看后端到底怎么把AST变成汇编的开发者。它不追求支持完整C标准,而是把编译器最核心的骨架搭出来,让你能顺着源码把每个阶段串起来。
2. 编译器整体架构:从源码到汇编的五个阶段
2.1 分层设计与数据流走向
这份编译器的架构是典型的教科书式流水线,但比纯理论多了一层可运行的工程结构。整个编译过程被拆成五个独立阶段,每个阶段有明确的输入输出,阶段之间通过数据结构传递,而不是靠全局变量硬连。
源代码字符串 ↓ 词法分析器(Lexer) Token 流 ↓ 语法分析器(Parser) 抽象语法树(AST) ↓ 语义分析器(Semantic Analyzer) 带类型标注的 AST + 符号表 ↓ 中间代码生成器(IR Generator) 三地址码序列 ↓ 目标代码生成器(Code Generator) x86-64 汇编文本这种分层的好处是每一层都能单独测试。比如你改词法规则,只要保证Token流格式不变,后面所有阶段都不用动。我见过不少教学编译器把词法和语法揉在一个函数里,改一个关键字要翻三百行代码,那种结构拿来学习可以,拿来二次开发就是灾难。
2.2 目录结构与模块职责
源码目录大致是这样组织的:
src/ lexer.c/h 词法分析:字符流 → Token parser.c/h 语法分析:Token → AST ast.c/h AST 节点定义与操作 symbol.c/h 符号表:变量、函数、作用域管理 sema.c/h 语义分析:类型检查、作用域解析 ir.c/h 中间代码:三地址码生成 codegen.c/h 目标代码:x86-64 汇编输出 main.c 驱动入口:串联各阶段 tests/ *.c 测试用例 Makefile每个模块一个.c配一个.h,头文件只暴露必要接口。比如lexer.h只暴露lexer_init()和lexer_next_token(),外部拿不到内部状态。这种设计在二次开发时特别省心——你想换一个语法分析器,只要新解析器能消费Token流并产出AST,其他模块完全不用改。
2.3 驱动入口的串联逻辑
main.c是整个编译器的调度中心,逻辑很直白:
// main.c - 编译器驱动入口 int main(int argc, char **argv) { if (argc < 2) { fprintf(stderr, "用法: %s <源文件.c>\n", argv[0]); return 1; } // 1. 读取源文件到内存 char *source = read_file(argv[1]); if (!source) return 1; // 2. 词法分析:初始化并逐Token消费 lexer_init(source); // 3. 语法分析:构建AST ASTNode *root = parse_program(); if (!root) { fprintf(stderr, "语法分析失败\n"); return 1; } // 4. 语义分析:类型检查与符号表填充 if (!sema_check(root)) { fprintf(stderr, "语义错误\n"); return 1; } // 5. 中间代码生成 IRList *ir = ir_generate(root); // 6. 目标代码生成,输出汇编 codegen_emit(ir, stdout); // 7. 清理资源 ir_free(ir); ast_free(root); free(source); return 0; }这段代码里每个函数调用对应一个编译阶段,参数传递很清晰:source是原始字符串,root是AST根节点,ir是三地址码链表。注意sema_check返回布尔值,失败时直接退出,不会带着错误AST继续往下走——这是编译器该有的严谨。很多教学实现省掉语义分析直接生成代码,结果变量未声明也能编译通过,那种代码拿来学流程可以,拿来改就是给自己挖坑。
3. 词法与语法分析:Token流和AST怎么构建
3.1 词法分析器的状态机实现
词法分析的核心是一个字符级状态机。它从源字符串里逐个读字符,根据当前字符类型决定进入哪个状态,最终产出一个Token。Token结构体定义如下:
// lexer.h - Token 类型与结构 typedef enum { TOK_INT, // int 关键字 TOK_RETURN, // return 关键字 TOK_IDENT, // 标识符 TOK_NUMBER, // 数字字面量 TOK_PLUS, // + TOK_MINUS, // - TOK_STAR, // * TOK_SLASH, // / TOK_LPAREN, // ( TOK_RPAREN, // ) TOK_LBRACE, // { TOK_RBRACE, // } TOK_SEMI, // ; TOK_EOF, // 文件结束 } TokenType; typedef struct { TokenType type; char *lexeme; // 原始文本,如 "x"、"123" int line; // 行号,报错用 } Token;lexer_next_token()是主循环,每次调用返回下一个Token。内部维护一个pos指针在源字符串上移动,跳过空白和注释,然后根据首字符分派:
// lexer.c - 核心分派逻辑(简化版) Token lexer_next_token(void) { skip_whitespace_and_comments(); char c = source[pos]; if (c == '\0') return make_token(TOK_EOF, "", line); // 标识符或关键字:字母或下划线开头 if (isalpha(c) || c == '_') { int start = pos; while (isalnum(source[pos]) || source[pos] == '_') pos++; char *text = strndup(source + start, pos - start); // 关键字匹配 if (strcmp(text, "int") == 0) return make_token(TOK_INT, text, line); if (strcmp(text, "return") == 0) return make_token(TOK_RETURN, text, line); return make_token(TOK_IDENT, text, line); } // 数字字面量 if (isdigit(c)) { int start = pos; while (isdigit(source[pos])) pos++; return make_token(TOK_NUMBER, strndup(source + start, pos - start), line); } // 单字符运算符和分隔符 pos++; switch (c) { case '+': return make_token(TOK_PLUS, "+", line); case '-': return make_token(TOK_MINUS, "-", line); case '*': return make_token(TOK_STAR, "*", line); case '/': return make_token(TOK_SLASH, "/", line); case '(': return make_token(TOK_LPAREN, "(", line); case ')': return make_token(TOK_RPAREN, ")", line); case '{': return make_token(TOK_LBRACE, "{", line); case '}': return make_token(TOK_RBRACE, "}", line); case ';': return make_token(TOK_SEMI, ";", line); default: fprintf(stderr, "第%d行: 未知字符 '%c'\n", line, c); exit(1); } }这里有几个值得注意的细节。strndup用来复制Token文本,避免后续指针移动导致内容被覆盖。行号line在跳过换行符时递增,报错时能定位到具体行。关键字匹配用的是最朴素的strcmp链,对小型编译器来说够用,如果你要支持几十个关键字,常见做法是换成哈希表或完美哈希。
3.2 递归下降解析器构建AST
语法分析用的是递归下降法,每个非终结符对应一个解析函数。表达式解析用优先级爬升处理运算符结合性,语句解析则按{和;切分。
AST节点定义:
// ast.h - AST 节点类型 typedef enum { AST_NUM, // 数字字面量 AST_VAR, // 变量引用 AST_BINOP, // 二元运算 AST_RETURN, // return 语句 AST_FUNC_DEF, // 函数定义 AST_BLOCK, // 语句块 } ASTKind; typedef struct ASTNode { ASTKind kind; union { int num; // AST_NUM char *var_name; // AST_VAR struct { // AST_BINOP char op; struct ASTNode *left, *right; } binop; struct { // AST_RETURN struct ASTNode *expr; } ret; struct { // AST_FUNC_DEF char *name; struct ASTNode *body; } func; struct { // AST_BLOCK struct ASTNode **stmts; int count; } block; }; } ASTNode;解析函数示例——解析return语句:
// parser.c - 解析 return 语句 ASTNode *parse_return(void) { expect(TOK_RETURN); // 消费 'return' ASTNode *expr = parse_expr(); // 解析返回表达式 expect(TOK_SEMI); // 消费 ';' ASTNode *node = ast_new(AST_RETURN); node->ret.expr = expr; return node; }expect()是辅助函数,检查当前Token类型是否匹配,不匹配就报错退出。parse_expr()内部用优先级爬升处理+、-、*、/的优先级和左结合性。这种写法比生成式语法分析器手写起来更直观,调试时在parse_expr里打个断点就能看到表达式树怎么一层层搭起来。
3.3 符号表与作用域管理
符号表用链表加作用域栈实现。进入函数体时压入新作用域,退出时弹出。每个符号记录名字、类型、在栈帧中的偏移量。
// symbol.h - 符号表条目 typedef struct Symbol { char *name; Type type; // 目前只支持 int int stack_offset; // 相对于 rbp 的偏移 struct Symbol *next; // 同一作用域内链表 } Symbol; typedef struct Scope { Symbol *symbols; struct Scope *parent; } Scope;查找变量时从当前作用域往上逐层找,找不到就报「未声明标识符」。stack_offset在语义分析阶段分配,每个局部变量占4字节(int大小),偏移量从-4开始递减。这个偏移量后面代码生成阶段直接用来生成movl指令。
4. 语义分析与代码生成:类型检查和x86-64汇编输出
4.1 语义分析的类型检查逻辑
语义分析遍历AST,做三件事:填充符号表、检查变量是否声明、检查类型是否匹配。目前只支持int类型,所以类型检查主要是确认表达式两边都是整型。
// sema.c - 表达式类型检查 Type sema_expr(ASTNode *node) { switch (node->kind) { case AST_NUM: return TYPE_INT; case AST_VAR: { Symbol *sym = scope_lookup(node->var_name); if (!sym) { fprintf(stderr, "语义错误: 未声明变量 '%s'\n", node->var_name); exit(1); } return sym->type; } case AST_BINOP: { Type lt = sema_expr(node->binop.left); Type rt = sema_expr(node->binop.right); if (lt != TYPE_INT || rt != TYPE_INT) { fprintf(stderr, "语义错误: 运算符 '%c' 需要整型操作数\n", node->binop.op); exit(1); } return TYPE_INT; } default: return TYPE_INT; } }这段代码递归下降检查表达式类型,遇到变量引用就查符号表。如果变量没声明,直接报错退出。sema_check()在函数定义层面还会做重复定义检查——同一个作用域里同名变量声明两次会报错。这些检查看起来简单,但少了它们,后面代码生成阶段就会生成引用未分配栈空间的指令,运行时直接段错误,调试起来非常痛苦。
4.2 三地址码中间表示
中间代码用三地址码形式,每条指令最多一个运算符,结果存到临时变量。IR指令结构:
// ir.h - 三地址码指令 typedef enum { IR_ADD, IR_SUB, IR_MUL, IR_DIV, IR_ASSIGN, // t = x IR_RETURN, // return x IR_LABEL, // 标签 } IROp; typedef struct IRInst { IROp op; char *dest; // 目标临时变量或变量名 char *src1; char *src2; // 二元运算的第二个操作数 struct IRInst *next; } IRInst;从AST生成IR的过程是后序遍历:先递归生成左右子表达式的IR,再生成当前节点的运算指令。比如return a + b;会生成:
t1 = a t2 = b t3 = t1 + t2 return t3临时变量t1、t2、t3由IR生成器自动分配编号。这种展开方式让后续代码生成变得简单——每条IR指令对应一条或几条汇编指令,不需要在代码生成阶段再处理嵌套表达式。
4.3 x86-64汇编生成与栈帧布局
代码生成阶段把IR翻译成AT&T格式的x86-64汇编。函数序言和尾声固定:
# 函数序言 pushq %rbp movq %rsp, %rbp subq $N, %rsp # N 为局部变量总大小,16字节对齐 # 函数尾声 movq %rbp, %rsp popq %rbp ret局部变量通过-offset(%rbp)访问,临时变量也分配在栈上。return语句把表达式结果放入%eax,然后跳转到尾声。二元运算的代码生成:
// codegen.c - 二元运算汇编生成 void codegen_binop(IRInst *inst) { // 加载左操作数到 %eax load_operand(inst->src1, "%eax"); // 右操作数在 %ecx load_operand(inst->src2, "%ecx"); switch (inst->op) { case IR_ADD: fprintf(out, " addl %%ecx, %%eax\n"); break; case IR_SUB: fprintf(out, " subl %%ecx, %%eax\n"); break; case IR_MUL: fprintf(out, " imull %%ecx, %%eax\n"); break; case IR_DIV: // 除法需要 cdq 扩展符号位 fprintf(out, " cltd\n"); fprintf(out, " idivl %%ecx\n"); break; } // 结果存回目标变量 store_operand(inst->dest, "%eax"); }load_operand根据操作数是变量还是立即数生成不同的movl指令。除法用cltd把%eax符号扩展到%edx:%eax,再执行idivl,这是x86-64有符号除法的标准写法。如果你把cltd漏了,负数除法会得到错误结果——这个坑我在第一次写代码生成时踩过,调试了半天才发现是符号扩展没做。
4.4 编译与运行验证
项目根目录有Makefile,直接make就能编译出ccompiler可执行文件。测试流程:
# 编译编译器本身 make # 编译一个测试用例 ./ccompiler tests/return_42.c > out.s # 用 gcc 汇编并链接 gcc -o out out.s # 运行,查看退出码 ./out echo $?tests/return_42.c内容就是int main(){return 42;},运行后echo $?应该输出42。如果输出其他值,说明代码生成某处有问题。常见排查路径:先看out.s里main函数的序言是否正确,再看return语句有没有把值放进%eax,最后检查栈帧大小是否对齐。
5. 避坑与常见问题:编译失败时先查这几处
5.1 段错误:栈帧未对齐或变量偏移错误
现象:编译出的程序运行时直接段错误,gdb显示崩溃在函数序言或变量访问指令。
原因:x86-64 ABI要求%rsp在函数调用时16字节对齐。如果subq $N, %rsp里的N不是16的倍数,调用printf等库函数时可能崩溃。另外局部变量偏移量计算错误会导致访问越界。
解决:在代码生成阶段把栈帧大小向上取整到16的倍数。变量偏移量从-4开始,每个变量占4字节,总大小算完后(size + 15) & ~15对齐。
5.2 除法结果错误:漏掉符号扩展
现象:正数除法正常,负数除法结果不对,比如-7 / 2得到-2而不是-3。
原因:x86-64的idivl要求被除数放在%edx:%eax组成的64位寄存器对里。如果只设置了%eax而没设置%edx,高位是随机值,除法结果自然错。
解决:在执行idivl前加cltd指令,把%eax的符号位扩展到%edx。这个指令在AT&T格式里就是cltd,别写成cdq(那是32位写法)。
5.3 变量未声明却编译通过
现象:源码里用了未声明的变量,编译器不报错,生成的汇编访问了一个不存在的栈位置,运行时行为不可预测。
原因:语义分析阶段没有做符号表查找,或者查找失败后没有终止编译。
解决:在sema_expr的AST_VAR分支里强制查符号表,找不到就exit(1)。不要试图「容错」继续生成代码,编译器不是解释器,带着错误AST往下走只会让问题更难定位。
5.4 临时变量命名冲突
现象:复杂表达式编译后结果错误,检查IR发现两个不同的临时变量用了同一个名字。
原因:IR生成器用全局计数器分配临时变量编号,但递归生成时计数器没有正确递增,或者嵌套表达式复用了编号。
解决:确保每次调用new_temp()都递增全局计数器,并且IR指令里的dest字段用strdup复制名字,不要直接存指针。我一般会在IR生成完后加一个断言,检查所有临时变量名唯一。
5.5 汇编输出缺少.section .note.GNU-stack
现象:用gcc链接时报警告missing .note.GNU-stack section implies executable stack。
原因:生成的汇编文件没有声明栈不可执行,链接器默认给可执行栈,现代系统会警告。
解决:在汇编输出末尾加上.section .note.GNU-stack,"",@progbits。这行不影响程序行为,但能消除警告,也符合安全实践。
6. 二次开发:从支持int到支持更多类型
这份编译器的扩展点很清晰,想加新功能基本不用动整体架构。最直接的改造是支持char类型和指针,再进一步可以加数组和函数调用。
6.1 扩展类型系统
目前Type枚举只有TYPE_INT,加TYPE_CHAR和TYPE_PTR需要改三处:词法分析器识别char关键字,语法分析器在声明里接受类型名,语义分析器做类型兼容检查。代码生成阶段,char用movb而不是movl,指针用movq。我一般会先加一个type_size()函数,返回类型占用的字节数,栈帧分配和偏移计算都依赖它。
// 扩展类型大小计算 int type_size(Type t) { switch (t) { case TYPE_CHAR: return 1; case TYPE_INT: return 4; case TYPE_PTR: return 8; default: return 4; } }6.2 加入函数调用与参数传递
函数调用需要处理参数传递。x86-64 System V ABI规定前6个整型参数放%rdi、%rsi、%rdx、%rcx、%r8、%r9,超过6个的压栈。IR层面加IR_CALL指令,代码生成时按顺序加载参数寄存器,然后call函数名。返回值在%eax,调用方负责清理栈上参数。
改造顺序建议:先在语法分析里支持函数调用表达式,再在语义分析里检查函数是否声明、参数个数是否匹配,最后在代码生成里实现寄存器分配。每一步都写一个最小测试用例,比如先让int add(int a, int b){return a+b;}能编译,再让main里调用add(1,2)能返回3。
6.3 验证改造是否正确的技巧
改完编译器后,怎么确认生成的代码是对的?我的习惯是拿gcc -S生成的汇编做对照。写一个同样功能的C文件,用gcc -S -O0生成汇编,然后对比自己的编译器输出。不需要完全一致,但关键指令序列应该相似——比如函数序言、参数加载、返回值设置这几处。如果差异很大,先查ABI相关的部分。
另一个技巧是写一个测试脚本,自动编译运行一批用例并检查退出码:
#!/bin/bash # run_tests.sh - 批量验证编译器输出 pass=0 fail=0 for src in tests/*.c; do expected=$(basename "$src" .c | sed 's/.*_//') # 文件名末尾数字为期望退出码 ./ccompiler "$src" > /tmp/out.s 2>/dev/null if [ $? -ne 0 ]; then echo "编译失败: $src" fail=$((fail+1)) continue fi gcc -o /tmp/out /tmp/out.s 2>/dev/null /tmp/out actual=$? if [ "$actual" -eq "$expected" ]; then pass=$((pass+1)) else echo "结果错误: $src 期望 $expected 实际 $actual" fail=$((fail+1)) fi done echo "通过 $pass, 失败 $fail"这个脚本按文件名末尾的数字作为期望退出码,比如return_42.c期望退出码42。每次改完编译器跑一遍,能快速发现回归。从那以后我每次动代码生成模块,都强制先跑一遍这个脚本再提交,省得改了一个指令把之前能跑的用例搞挂。希望这份源码和拆解能帮你把编译器的骨架真正跑起来,后面加什么功能都是在这个骨架上长肉。
本文还有配套的精品资源,点击获取