1. 从零手写 C 编译器 XCC:为什么我选择 AST 而不是边解析边生成
写一个能跑通int main(){return 0;}的编译器不难,难的是让它支持变量声明、算术运算、if-else、while 循环、函数调用,还要生成能在 Linux 64 位上直接汇编链接的代码。我这次用两天时间搭出了 XCC 的基础部分,核心思路就一句话:先把源码变成 AST,再从 AST 生成目标代码。
为什么不用「边解析边生成」?因为 C 语言里嵌套依赖太多了。比如int k = add(global_var + i, j);,你得先算global_var + i,再算j,然后按 System V ABI 把参数塞进%rdi、%rsi,最后call add。如果解析到一半就吐汇编,遇到嵌套函数调用、while 循环里的条件跳转,寄存器分配和标签管理会乱成一锅粥。AST 相当于先把「要做什么」和「怎么做」解耦:语法分析阶段只负责建树,代码生成阶段再遍历树、分配栈帧、生成标签。
XCC 目前支持的特性包括:int/double/char/char*变量声明、赋值、+ - * /算术、== != < > <= >=比较、if-else、while、全局变量、函数定义与调用、作用域符号表。目标平台是 Linux x86-64,用 Flex 做词法、Bison 做语法,最终输出output.s,再用as和ld链接成可执行文件。
整个项目文件结构如下:
xcc/ ├── lexer.l # 词法分析 ├── parser.y # 语法分析 + AST 构建 ├── ast.h / ast.c # AST 节点定义与创建 ├── ast_codegen.h/.c # AST 遍历生成汇编 ├── symbol.h/.c # 符号表(全局 + 局部作用域) ├── type.h/.c # 类型系统 ├── Makefile └── sample1.c # 测试用例我试过用 AI 辅助写这套东西,一开始用 deepseek-r1,分析能力不错但出码速度确实慢,后来切到 Qwen-code,生成 AST 节点定义和代码生成模板快了很多,而且它会主动建议加测试用例做回归。下面我把可复制的骨架、AST 定义、代码生成模板和排错清单都摊开讲。
2. TaoToken 统一 Key 调度 Qwen-code:前置配置与项目骨架
在开始写代码之前,先把 AI 辅助通道配好。我用的是 TaoToken 的统一 Key 调度,它能把 Qwen-code 这类模型的请求统一走一个 API 入口,省得每个模型单独配 Key。官网在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 入口是 https://taotoken.net/api 。
配置的核心是三件套:Base URL + API Key + Model ID。如果你用 Claude Code 或者 Cline 这类工具,通常在 settings 里填这三个字段。以 Claude Code 的配置为例,在~/.claude/settings.json里写:
{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_API_KEY": "sk-你的TaoToken密钥", "ANTHROPIC_MODEL": "qwen-code" } }如果你用 Codex 的auth.json,格式类似:
{ "base_url": "https://taotoken.net/api", "api_key": "sk-你的TaoToken密钥", "model": "qwen-code" }Cline 的 MCP 配置里也是同样的三件套,Base URL 填https://taotoken.net/api,Model ID 填qwen-code。配好之后,你在编辑器里让 AI 帮你生成 AST 节点结构体、写代码生成函数,请求就会走 TaoToken 的统一通道调度到 Qwen-code。
项目骨架的 Makefile 是关键,它把 Flex、Bison、GCC 串起来:
CC = gcc CFLAGS = -Wall -g OBJS = lexer.o parser.o symbol.o type.o ast.o ast_codegen.o all: compiler compiler: $(OBJS) $(CC) -o $@ $^ lexer.c: lexer.l parser.h flex -o lexer.c lexer.l parser.c parser.h: parser.y bison -v -d -o parser.c parser.y %.o: %.c $(CC) $(CFLAGS) -c $< -o $@ run: compiler sample1.c ./compiler sample1.c as -g output.s -o output.o && ld output.o -o output ./output; echo "Result: $$?" clean: rm -f *.o lexer.c parser.c parser.h compiler output.s output.o output这里有个坑:parser.h是 Bison 生成的,lexer.l里要#include "parser.h"拿 token 定义,所以 Makefile 里必须让lexer.c依赖parser.h,否则第一次编译会报找不到 token。我踩过这个坑,顺序错了就得make clean重来。
符号表的设计分全局和局部两张表。全局表存函数和全局变量,局部表存当前函数的局部变量,用scope_level区分作用域。插入局部变量时,current_offset从-8开始每次减type_size,这样每个变量在栈帧里的偏移就定下来了。symbol_table_enter_scope()和symbol_table_exit_scope()负责作用域进出,退出时把该层的符号弹掉。
3. AST 节点定义与代码生成模板:可复制的核心配置
AST 是整个编译器的中枢。我在ast.h里用一个大union把所有节点类型塞进ASTNode结构体,节点类型用枚举区分:
typedef enum { AST_NUMBER, AST_DOUBLE, AST_STRING, AST_VARIABLE, AST_BINARY_OP, AST_UNARY_OP, AST_EXPRESSION_STMT, AST_DECLARATION, AST_ASSIGNMENT, AST_IF_STMT, AST_WHILE_STMT, AST_BLOCK, AST_RETURN_STMT, AST_PROGRAM, AST_STATEMENT_LIST, AST_FUNCTION_DEF, AST_FUNCTION_CALL, AST_PARAMETER } ASTNodeType;每个节点带line_no用于报错。二元操作节点存op、left、right;声明节点存data_type、name、initializer、is_global;函数定义节点存return_type、name、parameters、body。创建函数统一用ast_create_xxx命名,比如:
ASTNode* ast_create_binary_op(BinaryOp op, ASTNode* left, ASTNode* right, int line_no) { ASTNode* node = malloc(sizeof(ASTNode)); node->type = AST_BINARY_OP; node->line_no = line_no; node->binary_op.op = op; node->binary_op.left = left; node->binary_op.right = right; return node; }代码生成阶段,ast_codegen.c里最关键的是类型判断和寄存器选择。整数运算走%rax/%rbx,浮点运算走%xmm0/%xmm1。我写了个get_expression_type()递归判断表达式类型,遇到AST_BINARY_OP就看左右操作数,只要有一个是double就按浮点处理。
以x < y为例,x是int,y是double,生成的汇编是:
movq -8(%rbp), %rax # 读 x cvtsi2sd %rax, %xmm0 # int 转 double movsd %xmm0, %xmm1 # 保存左操作数 movsd -16(%rbp), %xmm0 # 读 y comisd %xmm0, %xmm1 # 比较 setb %al # x < y 则置位 movzbq %al, %rax # 零扩展函数调用的参数传递按 System V ABI:前 6 个整数参数进%rdi、%rsi、%rdx、%rcx、%r8、%r9,前 8 个浮点参数进%xmm0-%xmm7。我在ast_generate_function_call()里先把所有参数算出来压栈,再按顺序弹到寄存器。这里有个细节:参数求值顺序和寄存器分配顺序要分开处理,否则嵌套调用会覆盖。
栈帧分配用预扫描:calculate_local_vars_size()遍历函数体,把所有局部变量声明的大小加起来,在函数序言里subq $N, %rsp一次性分配。if-else 分支取两边较大值,while 循环体单独算。这样比每个变量单独subq $8高效,也避免了栈对齐问题。
4. 编译运行验证:从 sample1.c 到可执行文件
写个测试用例sample1.c:
int global_var = 100; char* get_name() { return "xxx1"; } int add(int a, int b) { return a + b; } int main() { int x = 1; double y = 5.3; while (x < y) { x = x + 1; } char* name = get_name(); global_var = global_var + 20; int i = x + y; int j = 30; int k = add(global_var + i, j); return k; }执行make run,流程是:./compiler sample1.c生成output.s,然后as -g output.s -o output.o汇编,ld output.o -o output链接,最后./output运行,退出码就是k的值。
生成的output.s里,数据段先放 double 常量和字符串常量:
.section .data .align 8 double_const_1: .double 5.300000 string_const_1: .asciz "xxx1"全局变量global_var也在数据段:
.data global_var: .quad 100main函数被特殊处理成_start入口,这样ld链接后能直接执行。函数序言保存%rbp、设置新栈帧、分配局部变量空间:
_start: pushq %rbp movq %rsp, %rbp subq $48, %rspwhile 循环用标签实现:
.L0: movq -8(%rbp), %rax cvtsi2sd %rax, %xmm0 movsd %xmm0, %xmm1 movsd -16(%rbp), %xmm0 comisd %xmm0, %xmm1 setb %al movzbq %al, %rax testq %rax, %rax jz .L1 # 循环体 jmp .L0 .L1:函数调用add(global_var + i, j)生成:
movq global_var(%rip), %rax pushq %rax movq -32(%rbp), %rax popq %rbx addq %rbx, %rax pushq %rax movq -40(%rbp), %rax pushq %rax popq %rax movq %rax, %rdi popq %rax movq %rax, %rsi call add运行后echo $?能看到退出码。如果k算出来是 151(100+20+1+30),说明整条链路通了。
5. 常见报错排查清单:401、local proxy failed、reading choices、OAuth
配 TaoToken 和跑编译器时,我遇到过几类典型报错,列出来对照排查。
401 Unauthorized:API Key 没填对,或者 Base URL 写成了https://taotoken.net少了/api。检查settings.json或auth.json里的ANTHROPIC_BASE_URL必须是https://taotoken.net/api,Key 以sk-开头。如果 Key 是从别处复制的,注意有没有多余空格。
local proxy failed:本地代理配置冲突。如果你之前配过其他工具的代理,环境变量HTTP_PROXY/HTTPS_PROXY可能指向了一个已经关掉的端口。清掉这些环境变量,或者确认代理服务在跑。TaoToken 的请求走标准 HTTPS,不需要额外代理层。
reading choices 报错:这通常是模型返回格式和客户端预期不一致。检查 Model ID 是否填的qwen-code,有些客户端对模型名大小写敏感。如果用的是 Cline 的 MCP 配置,确认model字段和 TaoToken 支持的模型列表对得上。
OAuth 相关报错:Claude Code 有时会尝试走 OAuth 流程,但 TaoToken 用的是 API Key 认证。在settings.json里显式设置ANTHROPIC_API_KEY后,OAuth 流程会被跳过。如果还报 OAuth 错,检查有没有残留的~/.claude/credentials.json,删掉重试。
编译器本身的报错也要注意:variable 'x' not declared说明符号表查找失败,检查声明是否在symbol_table_insert之前;too many double constants说明常量池满了,把MAX_DOUBLE_CONSTS调大;syntax error in statement看 Bison 的parser.output文件定位冲突规则。
6. 继续扩展 XCC:从基础部分到更完整的编译器
XCC 的基础部分跑通后,下一步可以加数组、指针运算、结构体、更多控制流(for、do-while、switch)。AST 的扩展很直接:加节点类型、加创建函数、在ast_generate_code的 switch 里加分支。符号表要支持嵌套作用域和类型检查,代码生成要处理更复杂的寄存器分配。
如果你也想用 AI 辅助写这类项目,建议把 TaoToken 的 Coding Plan 用起来,长期编码和 Agent 场景下统一 Key 调度能省不少切换成本。模型对话入口在 https://taotoken.net/api ,接入文档在 https://taotoken.net/api-keys ,需要生成 Key 就去控制台。把 Base URL、Key、Model ID 三件套配好,让 Qwen-code 帮你生成 AST 节点和代码模板,你专注在类型系统和 ABI 细节上,效率会高很多。
最后留个实用技巧:每次改完代码生成逻辑,先跑make test-all,三个测试用例覆盖算术、if、while,回归通过再跑sample1.c。汇编链接报错时,用as -g保留调试信息,gdb ./output单步看寄存器,比盯着output.s猜快得多。