ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

小型C编译器源码解析:词法分析到代码生成全流程

小型C编译器源码解析:词法分析到代码生成全流程 简介一套小型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. 递归下降解析器怎么把12*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_termparse_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 创建嵌套成树后根节点拿到整棵树的拥有权。需要释放时从根开始递归 freevoid 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_exprvoid 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。如果漏掉cqordx 里是一堆垃圾高位除法结果直接错。源码这里的 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生成编译器可执行文件cccc读取一个 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而不是0x0glibc 里 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 编译器代码的价值不在于它多完整而在于它把所有编译原理的知识点压缩到了一个能跑、能改、能单步调试的范围内。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进