☰
小型C编译器源码解析:词法分析到代码生成全流程
2026/10/12 15:37:47 网站建设 项目流程

简介:一套小型C语言编译器的完整源代码,面向希望深入理解编译原理的开发者、计算机专业学生以及想自研编译器的编程爱好者。该实现覆盖编译流程的核心环节——词法分析、语法分析、语义分析、优化与代码生成,代码中涉及抽象语法树构建、符号表管理、类型检查等关键机制,便于对照学习。压缩包共86个文件,以50个C源文件和12个头文件为主,并含若干p文件、makefile、批量构建脚本及说明文档,整体仅210KB,结构紧凑,适合本地快速研读,目前已有478人浏览学习。通过阅读与修改源码,可以直观理解C语言程序从源代码到可执行文件的转换过程,也能为后续编写编译器或调试程序打下扎实基础,且源码模块划分清晰,便于按词法分析、语法分析等阶段查阅对应实现。

1. 小型C编译器源码解剖:从词法分析到代码生成,一份能跑的实验田

GitHub Trending 上偶尔能看到有人把 MS-DOS 源码翻出来做深度解析,逐行讲操作系统的诞生密码;但编译器源码被这么对待的机会反而少。工业级 C 编译器动不动几十万行,容易让人误以为“理解编译器”是件门槛极高的事。实际上,一套能编译整数表达式、变量声明、if/while 的类 C 子集编译器,核心骨干三五千行就能完整跑通。这份小型 C 编译器实现的源代码,就是把词法分析怎么切 token、递归下降怎么建 AST、栈式代码生成怎么吐 x86 汇编这三条线摊开,让你在一个周末里读懂全部核心,还能亲手改出一个新语法。它显然不是“最好用的 C 编译器”,但它是“最能让大学生看懂”的 C 语言编译器源代码,对后端开发和想自己写脚本解析器的人同样值得拆解。

2. 先读懂它怎么切词:词法分析器的数据结构与主循环

词法分析是编译器里最“机械”也最好懂的一层。它做的事情非常朴素:把源代码字符串从左往右扫,按规则切成一串有意义的 token。难点在于切完之后的数据结构怎么设计——结构设计得不好,后面语法分析器用起来处处别扭;结构设计得好,语法层写起来就非常顺。这份源码里 token 结构是理解全部代码的第一把钥匙。

2.1 token 长什么样:为什么要用“类型 + 字面量 + 行号”的二元结构

先看 token 的定义。小型编译器一般不会像工业级那样给每种 token 单独建一个类,而是用枚举加结构体一把梭:

typedef enum { TOK_NUMBER, TOK_IDENT, TOK_KEYWORD, TOK_PLUS, TOK_MINUS, TOK_STAR, TOK_SLASH, TOK_LPAREN, TOK_RPAREN, TOK_SEMI, TOK_ASSIGN, TOK_EOF } TokenKind; typedef struct Token { TokenKind kind; // 这个 token 属于哪一类 char *literal; // 原始字面量,报错时直接打印 int ival; // 数字 token 的解析值 int line; // 行号,语法报错时定位用 } Token;

kind 回答“这是什么”,literal 回答“源码里原文长什么样”,ival 是文字转好的整数值,line 是出错时能指到第几行。为什么要同时保留 literal 和 ival?因为报错时要给用户看原文,而出错的信息可能比 AST 构建更早。很多新手在这里图省事只留一个 char*,等到语法分析阶段 parse 数字时再反复用 atoi,白做两次解析。正确做法是词法阶段就把纯数字文本转换好,语法层拿 ival 直接用。

再看构造 token 的函数。它负责把当前指针位置、类型和行号打包:

Token make_token(TokenKind kind, const char *start, int len, int ival, int line) { Token t; t.kind = kind; t.literal = strndup(start, len); // 拷贝一段文本,后面 parser 还要用 t.ival = ival; t.line = line; return t; }

注意 literal 用 strndup 拷贝,而不是直接指向源文件缓冲区。原因很简单:源文件缓冲区在编译结束后要释放,而 AST 里的错误信息可能要保留到整个编译流程结束。用堆拷贝能避免悬垂指针,代价是多一次 malloc。这份源码里没有到处手写 free,而是在编译结束后统一释放,这是小型项目里很务实的取舍。

2.2 主循环与状态流转:一个函数如何吃掉整份源码

词法分析的核心是 next_token 这个函数,它接收一个指向当前扫描位置的指针,返回下一个 token,同时推进指针。整个实现可以浓缩在一个循环加一个 switch 里:

Token next_token(const char **src) { skip_whitespace_and_comments(src); // 跳过空格、换行、注释 if (**src == '\0') return make_token(TOK_EOF, *src, 0, 0, line); // 数字:直接累加出值 if (isdigit(**src)) { const char *start = *src; int v = 0; while (isdigit(**src)) { v = v * 10 + (**src - '0'); (*src)++; } return make_token(TOK_NUMBER, start, *src - start, v, line); } // 标识符或关键字:先按标识符读完整,再查关键字表 if (isalpha(**src) || **src == '_') { const char *start = *src; while (isalnum(**src) || **src == '_') (*src)++; int len = *src - start; if (is_keyword(start, len)) return make_token(TOK_KEYWORD, start, len, 0, line); return make_token(TOK_IDENT, start, len, 0, line); } // 运算符与标点:switch 直接命中 switch (**src) { case '+': (*src)++; return make_token(TOK_PLUS, *src - 1, 1, 0, line); case '-': (*src)++; return make_token(TOK_MINUS, *src - 1, 1, 0, line); case '*': (*src)++; return make_token(TOK_STAR, *src - 1, 1, 0, line); case '/': (*src)++; return make_token(TOK_SLASH, *src - 1, 1, 0, line); case '(': (*src)++; return make_token(TOK_LPAREN, *src - 1, 1, 0, line); case ')': (*src)++; return make_token(TOK_RPAREN, *src - 1, 1, 0, line); case ';': (*src)++; return make_token(TOK_SEMI, *src - 1, 1, 0, line); case '=': (*src)++; return make_token(TOK_ASSIGN, *src - 1, 1, 0, line); } error("无法识别的字符 '%c',行号 %d", **src, line); return make_token(TOK_EOF, *src, 0, 0, line); // 不可达,但编译器会警告 }

这段代码里最值得品味的是三个分支的判断顺序:数字优先,标识符其次,运算符最后。顺序不是随便排的——数字和标识符都需要“先读一段再判断”,而运算符和标点是单字符,直接 switch 打表更快。判断顺序决定了对123abc这类非法输入的行为:源码的写法会切出123和一个标识符abc,然后在语法分析阶段报“缺少运算符”,而不是在词法阶段报错。这是小型编译器常见的宽容策略,词法层不做过多校验,把错误留给语法层统一处理。

参数变化的套路也要说清楚:如果你想新增一个%求余运算符,只需要在枚举里加 TOK_PERCENT,在 switch 里加case '%',其余代码一行都不用动。这就是把 token 类型集中定义的好处。凡是涉及运算符扩展,你都会回到这个枚举和这个 switch,而不是到处找字符串比较。

2.3 前瞻 token 与注释跳过:两个容易翻车的小设计

词法层有两个天天被新手踩的细节。第一个是“前瞻一个 token”。语法分析器在处理完一个 token 后,往往需要“偷看”下一个 token 才能决定怎么走分支,但又不能真的消费掉它。常见做法是维护一个 lookahead 变量,parser 调 peek_token() 拿值,确认继续了才调 next_token():

Token cur_token; // 当前正在处理的 token Token peek_token(void) { return lookahead; // 已经预取的下一个 token } Token advance_token(void) { Token t = lookahead; lookahead = next_token(&src); return t; }

初始化时先预取一个,之后 parser 全程只碰 lookahead 和 cur_token,词法层和语法层的边界就在这。第二个细节是注释跳过必须放在空白跳过之后。如果写成“先匹配运算符再跳过注释”,遇到a/* comment */b时/会被当成除法运算符返回,后面的注释内容全部变成垃圾 token,整个程序解析直接崩。正确的 skip_whitespace_and_comments 里要先循环跳过空白,再看是否以//或/*开头:

void skip_whitespace_and_comments(const char **src) { while (1) { while (**src == ' ' || **src == '\t' || **src == '\n') (*src)++; if (**src == '/' && (*src)[1] == '/') { while (**src != '\n' && **src != '\0') (*src)++; } else if (**src == '/' && (*src)[1] == '*') { *src += 2; while (!(**src == '*' && (*src)[1] == '/')) { if (**src == '\0') error("注释未闭合"); (*src)++; } *src += 2; } else { break; // 没有空白也没有注释,回到主循环 } } }

这里有个容易漏的边界:块注释要检查文件末尾,否则读到\0后还在循环里找*/,直接越界。用error("注释未闭合")显式拦下来,比让它继续扫描最终崩掉要体面得多。词法层做扎实,后面所有阶段都会感激它。

3. 递归下降解析器:怎么把1+2*3变成一棵树

语法分析器要回答的问题是:给定一串 token,它们按什么规则组合成合法的程序?这份源码用的是递归下降法,它是手写解析器里最直观的一种——每个语法规则对应一个函数,函数之间互相调用,调用的嵌套层次天然反映了运算优先级。

3.1 优先级靠函数嵌套实现:expr、term、factor 三层结构

理解递归下降最核心的一点是:优先级不是靠逻辑判断实现的,而是靠函数之间的调用顺序。乘除比加减优先,就让“加减函数”去调用“乘除函数”,乘除函数再去调用“叶子函数”:

// 加减法:优先级最低,因此处于最外层 Node *parse_expr(void) { Node *node = parse_term(); // 先解析一个 term while (peek_token().kind == TOK_PLUS || peek_token().kind == TOK_MINUS) { Token op = advance_token(); // 消费运算符 Node *rhs = parse_term(); // 再解析一个 term node = make_binop(op, node, rhs); } return node; } // 乘除法:优先级高于加减法 Node *parse_term(void) { Node *node = parse_factor(); while (peek_token().kind == TOK_STAR || peek_token().kind == TOK_SLASH) { Token op = advance_token(); Node *rhs = parse_factor(); node = make_binop(op, node, rhs); } return node; } // 因子:数字或括号表达式,优先级最高 Node *parse_factor(void) { Token tok = advance_token(); if (tok.kind == TOK_NUMBER) return make_number(tok.ival, tok.line); if (tok.kind == TOK_LPAREN) { Node *inner = parse_expr(); // 递归进入完整表达式 expect(TOK_RPAREN); // 必须匹配右括号 return inner; } error("非法的表达式起始 token:%s(行 %d)", tok.literal, tok.line); }

用1 + 2 * 3走一遍:parse_expr 先调 parse_term,parse_term 先调 parse_factor 吃掉1,回头看到*,于是再调 parse_factor 吃掉2,接着看到*后面的3,先组装出2*3这个节点;parse_term 返回后,parse_expr 才看到+,把1和2*3组装成最终的加法节点。整个过程里乘号“抢先”被组合,优先级自然就对了。

这里的 while 循环是关键:它实现的是“左结合”。1-2-3会被解析成(1-2)-3,而不是1-(2-3)。如果你把 while 改成递归调用 parse_expr 去解析右操作数,就会得到右结合,四则运算基本全错。这份源码的写法是对的,改的时候千万别往递归里塞。

3.2 AST 节点设计:叶子、二元运算与内存归属

语法分析的结果是 AST。AST 和 token 流的关系是:token 是“线性”的,AST 是“树形”的。节点结构用带 kind 标签的联合体,是 C 语言里最常规的做法:

typedef enum { NODE_NUM, NODE_BINOP, NODE_VAR } NodeKind; typedef struct Node { NodeKind kind; // 节点类型 Token op; // 二元运算的运算符 token,含行号 int ival; // NODE_NUM 专用:数字值 Symbol *sym; // NODE_VAR 专用:指向符号表项 struct Node *lhs; // NODE_BINOP 专用:左操作数 struct Node *rhs; // NODE_BINOP 专用:右操作数 } Node;

这个设计的精妙之处:token 被整体嵌入 Node,而不是只存一个运算符枚举。报错时可以直接用 node->op.literal 和 node->op.line 定位到源码位置,省得另开一张“节点到 token”的映射表。多数情况下还可以用 node->op.kind 判断运算类型,AST 和 token 流的耦合度极低。

内存管理上,这份源码采用的是“一次性构建、分层释放”策略。所有节点用 malloc 创建,嵌套成树后,根节点拿到整棵树的拥有权。需要释放时,从根开始递归 free:

void free_node(Node *node) { if (!node) return; if (node->kind == NODE_BINOP) { free_node(node->lhs); free_node(node->rhs); } free(node); }

单次编译在小型工具里也可以完全不 free,等进程退出时系统回收。但如果你要把这个编译器嵌进别的程序,内存泄漏会随编译次数累积,那就需要这个 free_node。要不要做内存释放,取决于你的使用场景,这份源码给出了可选的完整实现。

3.3 报错别崩:行号、单字符跳过与“遇错即停”策略

解析器最容易让新手崩溃的地方不是逻辑,而是错误处理。最常见的反面教材是:遇到不认识的 token 直接 assert 或 return NULL,调用方拿到 NULL 后一脸懵。这款实现的做法是统一 error(),打印带行号的信息后退出:

void error(const char *fmt, ...) { va_list ap; va_start(ap, fmt); fprintf(stderr, "编译错误:%d 行:", cur_token.line); vfprintf(stderr, fmt, ap); fprintf(stderr, "\n"); va_end(ap); exit(1); // 小型编译器直接退出,不尝试恢复 }

遇到1 + * 2这种输入,parse_factor 看到*,error() 会打出“第 1 行:非法的表达式起始 token:*”,然后退出。这对教学目的的编译器足够用。工业级编译器会做错误恢复——报错后尝试跳到下一个分号继续解析,好一次性列出多个错误;但那是用几千行额外逻辑换来的“用户友好”,不是核心功能。如果你想要这个体验,可以在 error() 后引入一个“同步点”机制:当出错时丢弃 token 直到遇到分号或右花括号,重置到可继续解析的状态。

注意:单字符跳过策略只适合四则运算级别的表达式。一旦你加了字符串、括号嵌套、多行表达式,错误恢复就变得非常复杂。我见过很多人在教学编译器里花一整个周末打磨错误恢复,最后发现收益远低于投入。小型编译器直接遇错即停,把这部分时间花在代码生成上,性价比高得多。

4. 从 AST 到汇编:符号表、栈上局部变量与指令选择

代码生成是这份源码里“编译器味”最浓的部分。AST 还停留在抽象树层面,代码生成要做的是把它翻译成具体的 x86-64 汇编指令。这里的核心矛盾是:CPU 寄存器只有十几个,而程序里的变量数量可以无限多。所以绝大多数变量不能常住寄存器,必须存到内存栈上,用的时候再加载。符号表的作用,就是把“变量名”映射到“栈上的哪个位置”。

4.1 符号表用链表就够:offset 与作用域管理

变量int a;编译后的本质是什么?是在栈上划出 4 个字节的空间,并记录下a对应的地址偏移。这份源码的符号表是一个简单链表,每个符号保存名字和它相对栈底(帧指针 rbp)的偏移:

typedef struct Symbol { char *name; // 变量名 int offset; // 距 rbp 的偏移,编译期就确定 struct Symbol *next; // 链表指针 } Symbol; static Symbol *scope = NULL; // 当前作用域符号表 static int stack_size = 0; // 已分配的栈空间,向下增长 Symbol *find_var(const char *name) { for (Symbol *s = scope; s; s = s->next) if (!strcmp(s->name, name)) return s; return NULL; // 没找到,由调用方报“未定义变量” }

为什么用链表而不是哈希表?因为小型编译器的变量总数通常只有几十个,线性查找足够快。哈希表能把查找从 O(n) 降到 O(1),但换来的代码量增加和哈希冲突处理,对这个规模完全不划算。真正需要关注的是 offset 的分配方式。x86-64 栈向下增长,所以第一个变量分配在-4(%rbp),第二个在-8(%rbp),依此类推。分配时要考虑对齐:int 占 4 字节,char 占 1 字节,但栈指针本身要保持 16 字节对齐,所以每个符号分配完后,stack_size 要按 8 或 16 字节向上取整。

作用域管理是个经典坑。进入一个花括号块时,要把当前作用域链保存,块内新变量加在表头;退出时恢复旧表:

Symbol *saved_scope = scope; // 处理块内声明,把新符号插入表头 scope = saved_scope; // 退出块,丢弃块内符号

这样同一个函数里int i;和 for 循环里int i;就不会互相污染。如果你跳过这一步,同名变量会出现“第二个 i 的 offset 覆盖第一个”,整个程序的变量访问全部错乱。

4.2 栈式代码生成:一个递归函数直接吐汇编

有了符号表,代码生成可以写得极其简洁。思路是“先求右操作数,压栈,再求左操作数,弹栈到寄存器,执行运算”。看核心的 gen_expr:

void gen_expr(Node *node) { switch (node->kind) { case NODE_NUM: // 常量直接加载进 rax printf(" mov $%d, %%rax\n", node->ival); return; case NODE_VAR: // 变量从栈上读:rbp - offset 处 printf(" mov %d(%%rbp), %%rax\n", node->sym->offset); return; case NODE_BINOP: { // 先求右操作数,压栈保存 gen_expr(node->rhs); printf(" push %%rax\n"); // 再求左操作数,此时 rax 是左值 gen_expr(node->lhs); // 把右操作数弹到 rdi printf(" pop %%rdi\n"); switch (node->op.kind) { case TOK_PLUS: printf(" add %%rdi, %%rax\n"); break; case TOK_MINUS: printf(" sub %%rdi, %%rax\n"); break; case TOK_STAR: printf(" imul %%rdi, %%rax\n"); break; case TOK_SLASH: printf(" cqo\n idivq %%rdi\n"); break; } return; } } }

这个生成策略有两个关键点。第一,运算结果约定永远放在 rax,这是全函数统一的约定,gen_expr 返回后调用方就默认 rax 是结果。第二,除法必须先把被除数符号扩展到 rdx:rax。idivq %rdi执行的是 rdx:rax 除以 rdi,商放 rax,余数放 rdx。如果漏掉cqo,rdx 里是一堆垃圾高位,除法结果直接错。源码这里的 cmp 顺序是完全正确的。

为什么不直接“左操作数进 rax,右操作数加进来”,非要先右压栈再左运算?因为递归下降生成的 AST 不保证左右子树哪个更复杂。你先算左再算右,左操作数(可能是个大表达式)没地方放。压栈是唯一不额外分配寄存器的通用解法。这个小设计就是“栈式代码生成”名字的由来。

4.3 链接与运行:main 入口、printf 的栈对齐与退出码

生成的汇编不是完整程序,还需要一个运行环境。所以代码生成层的输出除了用户代码,还要拼上一个入口点。以下是典型的完整汇编程式:

.globl main main: push %rbp mov %rsp, %rbp sub $32, %rsp ; 为局部变量预留空间 ; ---- 用户代码从这里开始 ---- mov $1, %rax ; ---- 用户代码到这里结束 ---- mov %rbp, %rsp pop %rbp ret

为了让程序能调用 printf,源码实现会把printf的函数调用原样写进汇编,再在开头声明对 libc 的引用。由于生成的汇编最终交给系统自带汇编器与链接器处理,只要用户在汇编里.extern printf,链接时加上-lc,就能直接产出可执行文件。

这里有个非常隐蔽的栈对齐问题。System V 调用约定要求:在 call 指令之前,栈指针 rsp 必须保持 16 字节对齐。如果生成代码里 push 了奇数个 8 字节值,call printf 时 rsp 就会错位 8 字节。表现是“有时崩,有时不崩”——取决于你调用的表达式里压栈次数是奇数还是偶数。这份源码的做法是:在函数序言里根据局部变量占用的栈空间大小做一次 round up,固定把总栈帧大小调成 16 的倍数。理解了这一点,你在写任何 x86-64 汇编时都不会再被“随机崩溃”卡住。

5. 编译它、跑通它:一套可复现的流程与四条避坑记录

拿到这份源码后,第一步不是读代码,而是先把它编译出来、跑通一个最小例子。编译器是自举工程里最特殊的工具——它本身就是个程序,先能跑,才能用来研究它。下面这套流程是我实际走了一遍的,照着敲就能复现。

5.1 从源码到可执行文件的完整命令链

假设源码包的文件布局是src/下若干.c文件,加上一个 Makefile。最常见的构建命令是:

cd src make ./cc examples/test1.c > test1.s gcc test1.s -o test1 -no-pie ./test1 echo $?

make生成编译器可执行文件cc;cc读取一个 C 源文件,输出 x86-64 汇编;gcc负责把汇编链接成最终可执行文件;最后运行并查看退出码。注意-no-pie这个参数,很多人在这一步卡住——默认的 PIE(位置无关可执行文件)模式下,生成的汇编里对全局变量的绝对地址访问会链接失败,报 relocation 错误。如果你的cc输出的汇编大量使用绝对地址,加-no-pie能一劳永逸地绕过这个问题。

如果你想观察汇编长什么样,可以跳过链接:

./cc examples/test1.c | head -50

输出里每一行对应一个指令,代码段开头是main:标签,中间是mov、add、push、pop,跟第 4 章讲的完全对应。看到这段汇编,你就明白“编译器吐汇编”是什么感觉了。

调试编译器的推荐方式是用 GDB 下断点,但更轻量的方式是加--dump-token这类打印开关。如果你要临时看词法层结果,可以给源码的main()加个调试分支,打完 token 就退出,不进入语法分析。我一般会在next_token里加一个fprintf(stderr, "token: %s\n", tok.literal),跑一个小用例后立刻删掉,比拉 GDB 快得多。

5.2 四条避坑记录:现象、原因与解决办法

这份源码我完整拆过两遍,以下是踩过的最典型的四个坑。每条都按“现象 → 原因 → 解决”记录,你改代码时大概率会撞上其中之一。

避坑一:一元负号被当成二元减号

现象:写-3 + 5作为表达式,编译直接报“非法的表达式起始 token:-”。原因是 parse_factor 里没有处理 TOK_MINUS,负号只能被 parse_expr 识别为二元减法,而它的左边应该是操作数,于是报错。解决办法是在 parse_factor 里加一个负号分支:

if (tok.kind == TOK_MINUS) { Node *operand = parse_factor(); // 递归处理后面的操作数 return make_neg(operand, tok.line); }

生成代码时,NODE_NEG 可以翻译成“先求操作数,再 neg %rax”。这个坑的根本原因是对“优先级”理解不透:一元负号其实是 factor 层的一部分,它的优先级高于乘除。

避坑二:关键字和变量名串味

现象:声明int if = 3;后,编译器不报“if 是保留字”,而是报“未定义变量 if”。原因是词法层把 if 识别成 TOK_KEYWORD,语法层在处理声明时只判断了 TOK_IDENT 分支,看到关键字就跳过整个声明。解决有两个方向:在词法器里对关键字直接拒绝作为变量名;或者在语法层加一个显式判断——声明的名字必须是 TOK_IDENT,遇到 TOK_KEYWORD 直接报“保留字不能作变量名”。第二种的报错信息更友好,推荐用这个。这个坑的本质是词法层和语法层的职责边界问题,词法层负责枚举,语法层负责裁决。

避坑三:rsp 栈对齐的偶发崩溃

现象:同样的代码,a + b不崩,a + b * c偶尔崩,崩的位置在调用 printf 的那一行,错误是 SIGSEGV。原因是 call 前 rsp 没有按 16 字节对齐。假设主流程压了 1 次栈,call printf 时 rsp 末位是0x8而不是0x0,glibc 里 printf 用 SSE 指令访问 16 字节对齐的内存时直接段错误。解决方法是先统计整个栈帧内所有 push 的总次数,再加上局部变量区域,保证sub $N, %rsp里的 N 是 16 的倍数。一个临时正则:在你生成的每个函数的main:入口处检查一下,sub的立即数能否被 16 整除;不能让它在栈帧内部“碰运气”。

避坑四:作用域链没恢复导致同名变量错乱

现象:函数开头声明int x = 1;,循环里又声明int x = 2;,循环结束后访问外层 x,读到的是 2 而不是 1。原因是进入循环块时符号表把新 x 挂在表头,退出时没有恢复原来的链表头。解决代码只要三行:

Symbol *saved_scope = scope; // ... 处理块内声明 scope = saved_scope;

这个坑用调试器看变量表特别明显:块退出后符号表里还留着内层 x,查找时先命中内层。记住一条铁律:每进入一个块,保存 scope 指针;每退出一个块,恢复它。这是作用域管理的最小实现,也是教学编译器最容易漏掉的一环。

6. 给它加一个位运算:从改 token 到改生成器的完整实践

如果你已经把前面几章读通,下面这个实践可以检验你的理解:给这个编译器新增一个按位与运算符&。这个功能涉及整个编译链条的所有阶段,正好把词法、语法、代码生成全部串起来。C 语言里&的优先级在加减法和乘除法之间,这个设定会让你真正理解“函数嵌套层数决定优先级”。

6.1 加 token:枚举、switch、生成器三个位置

先在 TokenKind 里加一个TOK_AMP,然后在词法层的 switch 里加对应分支:

case '&': (*src)++; return make_token(TOK_AMP, *src - 1, 1, 0, line);

这是全流程最简单的一步。注意要有意识地保持“斜杠注释”和“逻辑与”的区分——如果你的词法层还没处理&&,现在只需要识别单&,不做短路逻辑运算的扩展。接下来在指令选择阶段(第 4 章的 gen_expr)加一行:

case TOK_AMP: printf(" and %%rdi, %%rax\n"); break;

到这里,如果直接去解析1 & 2,词法层能切出 token,语法层会把它当成非法 token。问题就集中到了优先级表上。

6.2 改优先级:在 expr 和 term 之间插入一层

新加一个 parse_bitwise 函数,放在 parse_expr 和 parse_term 之间:

Node *parse_expr(void) { Node *node = parse_bitwise(); // 原来这里直接调 parse_term while (peek_token().kind == TOK_PLUS || peek_token().kind == TOK_MINUS) { Token op = advance_token(); Node *rhs = parse_bitwise(); node = make_binop(op, node, rhs); } return node; } Node *parse_bitwise(void) { Node *node = parse_term(); while (peek_token().kind == TOK_AMP) { Token op = advance_token(); Node *rhs = parse_term(); node = make_binop(op, node, rhs); } return node; }

这样调用链变成:加减法 → 按位与 → 乘除法 → 因子。1 + 2 & 3在语法上会先解析2 & 3,再和 1 做加法,结果等于 1;而(1 + 2) & 3因为括号的存在,等于 3。写成一个测试用例跑一遍:

./cc examples/test_bitwise.c > test.s gcc test.s -o test -no-pie ./test

预期输出一个是 1,一个是 3。如果第一个输出变成了 1 而不是 3,说明你的 parse_bitwise 被放错了层,继续看调用链。

从编译流程整体看,加一个特性的成本其实很清晰:词法新枚举加一个 switch case,语法新函数加一次调用嵌套,代码生成新指令加一个 switch case。每次改动都必须强制跑一遍旧的测试用例集合,确认这四个数字表达式的输出没有变化,再把新用例加进去。这个“回归测试”习惯,是我拆完这份源码后养成的:编译器是最容易“改一处、坏一片”的代码,优先级表、符号表、栈对齐三者互相耦合,任何一步都可能踩到前面避坑章节里的雷。

从那以后,我每次给这个编译器加语法特性,都强制自己走一遍“改词法 → 改语法 → 改生成 → 跑回归”这四步,不再凭手感直接往 parser 里塞分支。如果你也打算拿它当实验田,建议把这四条命令写进一个 shell 脚本,每次改完直接跑。这份小型 C 编译器代码的价值,不在于它多完整,而在于它把所有编译原理的知识点压缩到了一个能跑、能改、能单步调试的范围内。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询