news 2026/10/21 2:56:21

小型C编译器源码实战:从词法分析到x86-64汇编的完整实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
小型C编译器源码实战:从词法分析到x86-64汇编的完整实现

简介:这份资源是一套小型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。每次改完编译器跑一遍,能快速发现回归。从那以后我每次动代码生成模块,都强制先跑一遍这个脚本再提交,省得改了一个指令把之前能跑的用例搞挂。希望这份源码和拆解能帮你把编译器的骨架真正跑起来,后面加什么功能都是在这个骨架上长肉。

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

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

SatDump 卫星信号解码实战:137 MHz 起收 NOAA 云图,30 分钟跑通

SatDump 卫星信号解码实战&#xff1a;137 MHz 起收 NOAA 云图&#xff0c;30 分钟跑通 【免费下载链接】SatDump A generic satellite data processing software. 项目地址: https://gitcode.com/GitHub_Trending/sa/SatDump SatDump 卫星信号解码工具&#xff0c;从 S…

作者头像 李华
网站建设 2026/10/21 2:55:51

如何根据企业需求选择合适的AI工作台

1. 明确企业需求在选择AI工作台之前&#xff0c;首先要明确企业的需求。这包括但不限于&#xff1a;数据处理能力&#xff1a;企业需要处理的数据量大小和类型。模型开发工具&#xff1a;是否需要支持多种编程语言和框架。集成能力&#xff1a;是否需要与其他业务系统或工具集成…

作者头像 李华
网站建设 2026/10/21 2:54:32

PyTorch高光谱数据加载全指南:Dataset、DataLoader与踩坑优化

简介&#xff1a;面向 PyTorch 与高光谱图像处理学习者的代码示例包&#xff0c;演示如何借助 DataLoader 高效加载多通道高光谱数据&#xff0c;解决 HSI 通道多、数据量大的批处理与预处理难题。压缩包共 7 个文件&#xff0c;包含 3 个 Python 脚本、2 个 .mat 数据文件及 2…

作者头像 李华
网站建设 2026/10/21 2:54:32

Spring Boot+Vue在线教育平台开发全流程与避坑指南

做毕业设计选“在线教育平台”这类题目的人特别多&#xff0c;原因很简单&#xff1a;业务贴近日常、功能边界清晰、前后端分离的技术栈正好是主流&#xff0c;不管是展示代码还是写论文&#xff0c;都有足够的素材可挖。但我也见过不少同学把这类项目做成“半成品”——视频传…

作者头像 李华
网站建设 2026/10/21 2:54:31

Git不是版本管理工具,而是内容寻址文件系统

1. Git不是版本管理工具&#xff0c;是内容寻址文件系统我见过太多人学Git学到中途放弃&#xff0c;问题出在出发点就错了。大多数人把Git当成"带历史记录的网盘"&#xff0c;于是所有的困惑都从这里开始&#xff1a;为什么commit不是提交个文件夹&#xff1f;为什么…

作者头像 李华
网站建设 2026/10/21 2:54:15

Python选择排序详解:从原理到优化,彻底搞懂O(n²)算法

1. 先聊两句&#xff1a;为什么你现在就该弄懂选择排序很多初学者学 Python 算法时&#xff0c;第一个接触的排序往往是冒泡排序&#xff0c;因为教材里它总是排在最前面。但说实话&#xff0c;真正让人“一眼看穿排序是怎么回事”的&#xff0c;我觉得是选择排序。选择排序的核…

作者头像 李华