小型C编译器源码全解析:从词法分析到x86-64汇编生成

发布时间:2026/10/16 1:05:43
小型C编译器源码全解析:从词法分析到x86-64汇编生成
简介一份小型C编译器实现的完整源代码被封装在86个文件、约210KB的压缩包中面向编译原理学习者与系统软件开发者是深入理解C语言底层机制的实用学习素材。源码以C源文件50个.c和头文件12个.h为主体辅以工程配置、批处理脚本及说明文档覆盖了编译器从词法分析、语法分析、语义分析、中间优化到代码生成的主要模块并包含错误处理与符号表管理实现。已有478人学习下载读者可对照实现跟踪预处理、表达式解析、语句处理及x86汇编输出等关键环节并借此掌握指针操作、结构体、函数调用等C语言特性在编译器层面的处理方式。无论是准备编译原理课程实践还是希望提升代码调试与优化能力的开发者都能从这份紧凑的代码中获得参考。1. 为什么要啃这份小型C编译器源码会写 C 不代表懂 C身边不少同事和同学都栽在同一个问题上编译原理课上了个寂寞龙书能看懂前两章但真要自己写一个能跑的 C 语言编译器脑子里立刻一片空白。这份小型 C 编译器源码正是用来填这个空白的——它把「词法分析→语法分析→语义分析→中间代码→x86-64 汇编生成→链接运行」的完整链路压缩在几千行代码里没有多余的抽象和设计模式就是直白的 C 代码硬碰硬。适合两类人一类是想把编译原理从「听懂」变成「写得出」的初学者另一类是做课程设计或面试项目时需要一个可扩展底座的开发者。我拿到后先跑通、再拆结构、最后动手改整个过程踩了不少坑这篇笔记就把怎么跑、怎么读、哪里容易翻车一次讲清楚。2. 词法与语法分析手写 Tokenizer 和递归下降KR 子集怎么切2.1 从 Token 流开始手写词法分析器而不是上 lex拿到源码后第一个让我意外的决定是项目没有用 lex/flex 生成词法分析器而是用纯手写的next_token()。问过作者思路理由很实际lex 虽然能快速产出 Token 流但生成的代码是个黑匣子调试时你不知道它内部状态机卡在哪手写版每识别一个 Token 就有一个明确的 C 函数调用栈出错可以直接打日志。对于教学型编译器这种「可读性优先」的选择比「生成效率优先」重要得多。手写 Tokenizer 的核心是get_token()循环它维护一个全局current_token和lookahead缓冲遇到空白跳过遇到字母或下划线开始收集标识符遇到数字收集整数常量遇到运算符则用「最大吃进」原则处理。比如输入必须先读到再向后看一位发现是合并成一个 LE_OP Token如果是后跟空格就只返回 LT。这个「向后看一位」的处理是词法最容易写错的地方漏掉它会把ab拆成a b语法分析直接崩。typedef enum { TOK_EOF, TOK_IDENT, TOK_INT, TOK_LT, TOK_LE, TOK_GT, TOK_GE, TOK_EQ, TOK_NE, TOK_ASSIGN, TOK_PLUS, TOK_MINUS, TOK_STAR, TOK_SLASH, TOK_LPAREN, TOK_RPAREN, TOK_LBRACE, TOK_RBRACE, TOK_SEMI, TOK_COMMA, TOK_RETURN, TOK_IF, TOK_ELSE, TOK_WHILE, TOK_INT_KEYWORD } TokenKind; static TokenKind get_token(void) { while (isspace(cur_char)) advance(); /* 跳过空白 */ if (isalpha(cur_char) || cur_char _) { /* 收集标识符再查关键字表判断是不是 int/if/return */ return lookup_keyword(); } if (isdigit(cur_char)) { /* 连续收集数字解析为整数值存入 token_val */ return TOK_INT; } switch (cur_char) { case : advance(); if (cur_char ) { advance(); return TOK_LE; } return TOK_LT; default: /* 单字符运算符和分隔符逐一分发 */ return map_single_char(); } }这段代码的逻辑顺序是有讲究的先处理空白再区分标识符与关键字然后处理整数字面量最后才是运算符。参数上要注意lookup_keyword()查表时遇到int应返回TOK_INT_KEYWORD而不是标识符这个决策直接影响后面对变量声明的解析。我一般会在每个case分支里加一条打印语句把当前字符和返回的 Token 类型打到 stderr肉眼确认边界情况。比如ab这种连续加号要正确吃成a b就只能靠最大吃进规则兜底没有捷径。2.2 递归下降与表达式优先级左结合怎么用小函数吃掉语法分析部分用的是经典递归下降表达式处理采用「优先级攀升」写法而不是教科书式的前缀递归。两者对比如下分层递归函数需要为每个优先级写一个函数代码量大但模式固定优先级攀升用一个循环加一个min_prec参数代码少很多而且左结合靠while循环天然实现不容易写错。这个小项目选了前者还是后者我读源码时发现它两种都出现过——初版是分层写法后面 commit 重构成了攀升写法原因是给表达式加和||时分层版本要新增两个函数还要改上一层的右递归调用而攀升版本只需要加两张表。static ASTNode *parse_expr(int min_prec) { ASTNode *left parse_primary(); /* 先解析操作数 */ if (left NULL) return NULL; while (1) { int prec get_binop_prec(current_token); if (prec min_prec) break; /* 优先级不够就停 */ TokenKind op current_token; advance(); ASTNode *right parse_expr(prec 1); /* 右操作数要用更高优先级 */ if (right NULL) return NULL; left make_binop_node(op, left, right); /* 左结合新节点挂左边 */ } return left; }这个函数的精妙之处在于prec 1这个参数。它保证了右操作数遇到同优先级运算符时不会被吞掉比如a - b - c解析a后遇到-右操作数以prec2去解析b此时再遇到-优先级等于 2 小于 2循环退出于是右操作数只是b拼出(a-b)外层循环再处理下一个-最终得到(a-b)-c左结合成立。如果写成parse_expr(prec)结果就是(a-(b-c))语义完全错了。这也是递归下降最容易考核的一个点。赋值表达式要单独处理因为赋值是右结合的而且左边必须是左值。项目里常见处理是顶层parse_assign_expr()先看当前 Token 是不是标识符再向后看一个 Token如果是就构造赋值节点否则回退以表达式解析。回退操作依赖lookahead_token缓冲在初版实现里这个回退如果忘了恢复指针会连续报十几个语法错误修复方式是在advance()里统一维护一个prev_token回退时直接赋值回去代价小且直观。2.3 语法错误恢复让编译器报错而不崩语法错误恢复是很多初学者忽略的部分。没有错误恢复的解析器遇到第一个语法错误就 panic你改完一个错重新编译又会冒出新的错效率极低。项目里采用的策略是 Token 级别的同步当解析函数发现预期之外的 Token 时记录错误信息然后跳过 Token 直到遇到分号、右花括号或某个同步点。这个同步点的选择直接影响错误报告质量选;太细会重复报错选}太粗会漏报。实践下来一条语句内只报第一次错误到分号为止同步效果最好。static void synchronize_after_error(void) { while (current_token ! TOK_SEMI current_token ! TOK_RBRACE current_token ! TOK_EOF) { advance(); } if (current_token TOK_SEMI) advance(); /* 把分号也吃掉避免重复报错 */ }同步逻辑里最忌讳的是把advance()写成递归调用因为嵌套表达式报错时会产生连锁反应。我看到一个 bug 记录解析if (a b) { ... }时条件部分少了右括号恢复函数跳到下一个分号结果把整个语句块跳过错误恢复变成了错误吞没。后来改成只跳 Token 不跳结构即遇到{后不自动跳过到}而是交给语句块解析函数去恢复问题就消失了。这段经验直接写在了源码注释里值得留意。3. 语义分析与中间表示符号表作用域和三地址码怎么组装3.1 符号表嵌套作用域、类型标记和遮蔽规则的落地语义分析的核心是符号表。项目里符号表不是简单的哈希表而是一个作用域栈每个{ }块对应一层作用域层内用链表存符号。查找时从栈顶往栈底搜插入时只插当前层退出块时整层弹出。这种设计的可读性极好对应了 C 的「内层遮蔽外层」规则。比如在函数里定义局部变量int i;又在内层块里定义int i;内层块的赋值只影响内层符号块结束后外层i恢复可见。typedef struct Symbol { char *name; Type type; /* TYPE_INT / TYPE_PTR / TYPE_ARRAY */ int offset; /* 变量在栈帧中的偏移负值表示 rbp 以下 */ int is_global; /* 全局变量走 .data/.bss 段不走栈帧 */ struct Symbol *next; } Symbol; typedef struct Scope { Symbol *head; struct Scope *parent; } Scope; static Symbol *lookup_symbol(const char *name) { for (Scope *s current_scope; s ! NULL; s s-parent) { for (Symbol *sym s-head; sym ! NULL; sym sym-next) { if (strcmp(sym-name, name) 0) return sym; } } return NULL; }这段实现里有一个信息很微妙type字段在初版里只有TYPE_INT没有指针和数组类型导致后面加数组支持时一堆代码要改。教训是符号表从第一天就该带一个类型字段哪怕当时只支持int也要留好扩展位。offset字段存的是栈帧偏移规则是赋值阶段统一分配、代码生成阶段统一读取两者若交错执行会出现同一个变量在两个函数里地址不同的 bug。is_global则决定变量存在.bss段还是栈帧里全局变量在链接时要加符号前缀防止和库冲突。一个值得注意的细节重复定义检查只在当前作用域插入时做不做跨作用域的「重复定义忽略」。也就是说int a; { int a; }合法但在同一个作用域里写两个int a;会报错。这符合 C 标准但有些编译器为了兼容老代码会放宽这个检查项目里没有放宽说明设计者坚定地站在语法严谨的一侧。3.2 三地址码短路求值和自增自减的翻译陷阱语义分析阶段产出的中间表示是三地址码每条指令形如t1 t2 op t3操作数是虚拟寄存器或常量。代码生成器看到这种扁平结构极其开心因为不用再递归遍历 AST只需要顺序扫描指令列表。项目里定义了一个Instr结构体op字段用枚举表示ADD、SUB、MUL、DIV、JMP、JZ等result字段是临时变量编号left和right是操作数。翻译赋值语句时先在符号表里查到目标变量的偏移再查操作数是否为常量如果是常量就直接生成MOV指令加载到result否则生成ADD/SUB等运算指令。static void gen_assignment(const char *var_name, ASTNode *expr) { Symbol *sym lookup_symbol(var_name); /* 先查符号查不到报未定义 */ TempVar temp gen_expr(expr); /* 递归生成表达式的三地址码 */ /* 生成指令把 temp 的值存到符号对应的栈位置 */ Instr ins; ins.op MOV_LOCAL; ins.result.name strdup(var_name); ins.left temp; ins.right.val sym-offset; /* 栈偏移作为立即数 */ emit_instr(ins); }参数里这个sym-offset很容易被误用如果变量是全局的符号表里的offset字段存的是无意义的默认值 0生成代码就会把值写到地址 0直接段错误。正确做法是生成阶段再查一次sym-is_global根据符号是否全局决定生成MOV [ripaddr]还是MOV [rbpoffset]。这个 bug 困扰了我整整半天最后是给符号表加了一个断言发现offset0时强制报错才定位。自增自减的翻译是三地址码里另一个容易翻车的点。i的正确语义是「先取旧值再自增」翻译成两条指令t1 i; i i 1;表达式值的载体是t1。而i是「先自增再取新值」翻译成i i 1; t1 i;。如果在翻译i时只生成一条i i 1再把i当作结果那么int j i;得到的是新值而不是旧值直接语义错误。项目里专门为和--设计了PREFIX_INC和POSTFIX_INC两个标记位三地址码阶段就区分开代码生成阶段再各自翻译这种「语义信息前移」的做法值得模仿。3.3 dump 中间代码肉眼验证语义的最快路径读这个项目时我发现它提供了一个-t参数可以在生成汇编前把三地址码打印出来。这不是可有可无的附加功能而是调试编译器最关键的路径。你写完表达式翻译逻辑如果用汇编调试要对着mov、add指令反推语义是否正确但看三地址码t2 t1 5一眼就能看出加法操作数对不对比汇编直观一个量级。实际输出格式大致如下t1 a t2 5 t3 t1 t2 a t3我拿到源码后第一时间在main.c里加了一个dump_tac 1的开关编译任何测试用例都先看中间代码。特别是涉及指针运算时比如p 4到底应该是t2 p 4按字节偏移还是t2 p 16按 int 大小偏移三地址码阶段就能看出来。-t开关在排查短路逻辑问题时也很有用a b如果生成的是t3 t1 t2说明短路没实现如果生成的是jz跳转说明短路正确。4. 代码生成与运行时从三地址码到 x86-64 的实际落地4.1 寄存器分配的朴素策略全部用内存槽搬移代码生成器的设计选择非常务实不搞寄存器分配算法所有局部变量、临时变量一律放在内存栈槽运算时再把操作数加载到rax和rdx。这种策略在性能上是「原地踏步」但换来了巨大的正确性红利——你永远不用担心寄存器被覆盖因为每个变量有唯一的内存地址。对于一个教学性质的编译器来说正确性比性能重要得多因为学生的调试成本才是最大的成本。static void emit_binop(Instr *ins) { /* 二元运算的固定套路左操作数进 rax右操作数进 rdx */ fprintf(out, mov rax, [rbp - %d]\n, ins-left_offset); if (ins-right_is_const) { fprintf(out, mov rdx, %d\n, ins-right_const); } else { fprintf(out, mov rdx, [rbp - %d]\n, ins-right_offset); } switch (ins-op) { case OP_ADD: fprintf(out, add rax, rdx\n); break; case OP_SUB: fprintf(out, sub rax, rdx\n); break; case OP_MUL: fprintf(out, imul rax, rdx\n); break; case OP_DIV: fprintf(out, cqo\n); fprintf(out, idiv rdx\n); break; } fprintf(out, mov [rbp - %d], rax\n, ins-result_offset); }这里有个隐患就是idiv指令。x86-64 的无符号除法用div有符号除法用idiv而idiv的被除数默认是rdx:rax组成的 128 位寄存器对。执行idiv rdx前必须用cqo把rax按符号扩展填充到rdx否则rax是负数时除出来是垃圾。初版代码漏掉了cqo导致所有负数除以正数的测试全部翻车。项目里对每个DIV指令都强制插一条cqo这在正确性上是对的只是会让代码体积变大编译器不做优化时无所谓。临时变量分配也走了朴素路线每条三地址码指令的result都会分配一个新的栈槽不重合、不复用。这意味着abc会消耗至少 3 个栈槽两个中间结果加一个最终结果表达式一长栈帧就膨胀。但因为所有槽位只在当前函数内使用栈帧大小可以在编译期静态算出来链接时不会出问题。-fomit-frame-pointer这种优化在教学项目里不必考虑保持rbp帧指针反而让gdb调试时更容易看清调用栈。4.2 栈帧布局与 System V 对齐为什么 call 前 rsp 是 16 的倍数栈帧的生成规则是每个函数入口先push rbp再把rbp指向当前栈顶然后sub rsp, N给局部变量腾空间。这个N必须是 16 的倍数因为 System V ABI 规定在call指令执行之前rsp必须按 16 字节对齐。如果函数入口push rbp之后rsp变成 8 对齐而你不补 8 个字节直接call别的函数被调函数的栈帧就会错位所有变量的偏移全部漂移表现为随机段错误。static void emit_function_prologue(int local_bytes) { fprintf(out, push rbp\n); fprintf(out, mov rbp, rsp\n); /* local_bytes 向上取整到 16 的倍数 */ int aligned (local_bytes 15) ~15; fprintf(out, sub rsp, %d\n, aligned); }对齐计算里的(local_bytes 15) ~15是个标准技巧加 15 再按位取反 15 做与运算效果等效于向上取整。为什么必须在编译器层面做这件事而不是依赖链接器或操作系统因为rsp对齐是运算时约束代码生成器是唯一知道每个函数局部变量总大小的地方。项目里曾经有个 bug 记录非常典型一个函数有 1 个int局部变量局部大小是 4sub rsp, 4后rsp变成 12紧接着调用printfprintf 内部的 SIMD 指令因栈未对齐崩溃。修复方式就是统一向上对齐到 16。栈帧偏移布局也有规范参数列表在rbp 16起rbp8是返回地址局部变量在rbp - 4、rbp - 8递减临时变量接着局部变量往下排。代码生成器维护一个stack_offset计数器每分配一个变量就把计数器减4或8指针类型并写入符号表的offset字段。这个计数器在函数结束时重置不跨函数共享否则全局变量的栈偏移会错乱。4.3 不依赖 libc 的运行时_start 与 syscall 接管小编译器最容易忽略的环节是运行时。如果把生成的汇编交给gcc链接gcc 会默认链接 libc入口点是_start而不是mainprintf这些函数也要依赖 libc 符号。对教学编译器来说这引入了一个巨大的不确定性你的代码生成器生成call printf但printf的参数个数、浮点寄存器规范你完全没控制。项目采取的做法是接管_start生成汇编时附加一段固定模板直接调syscall实现exit这样整条工具链只需要系统自带的as和ld。.global _start .section .text _start: call main mov rax, 60 /* syscall 号exit */ mov rdi, 0 /* 退出码 */ syscall这段汇编的作用是把main的返回值当成进程退出码。参数里rax60是 Linux x86-64 的 exit 系统调用号rdi传退出码syscall触发内核处理。生成汇编时我会用 heredoc 把它拼在输出文件末尾然后调用as --64 -o prog.o prog.s和ld -o prog prog.o完成链接。这里的优点是不依赖任何第三方库缺点是printf只能自己实现或放弃——项目里配套提供了极简的putchar和write系统调用封装能打印整数和字符串但浮点完全不支持。如果测试用例里出现3.14这样的字面量词法阶段就会直接报错这点在设计测试集时要心里有数。为什么要用ld而不是gcc因为gcc默认生成 PIE位置无关可执行文件需要额外的重定位逻辑而ld直接用_start入口没有这些动态链接的复杂性。这个决策让整个工具链可以在没有任何第三方依赖的干净环境里复现正是这个资源最实用的一点。5. 测试驱动与常见问题复现这套编译器时容易翻车的几个点5.1 测试脚本搭建回归测试比盯着汇编更快项目仓库里肯定带了test/目录里面是一堆.c文件和对应的期望输出。但直接手动跑这些用例效率太低我会先把它们整理成一个可重复执行的回归脚本。脚本的逻辑很直接遍历每个测试用例编译、运行、比对退出码或 stdout任何一个环节不匹配就打印失败信息并记录。#!/bin/bash # test.sh——轻量级回归测试 PASS0 FAIL0 for src in test/*.c; do base$(basename $src .c) ./build/compiler $src -o build/$base.s 2 build/$base.err if [ $? -ne 0 ]; then echo [compile-fail] $base FAIL$((FAIL1)) continue fi as --64 -o build/$base.o build/$base.s ld -o build/$base build/$base.o ./build/$base build/$base.out if diff -q build/$base.out test/$base.expected /dev/null; then PASS$((PASS1)) else echo [output-diff] $base FAIL$((FAIL1)) fi done echo PASS$PASS FAIL$FAIL脚本里三个步骤各有讲究编译器的-o参数必须同时接汇编输出路径-t参数可以在此处追加汇编器as的--64强制 64 位模式否则现代发行版默认可能是 32 位兼容模式ld不需要-dynamic-linker参数因为我们接管了_start没有动态链接依赖。diff比对 stdout 是最严格但不灵活的验证方式一旦编译器输出格式改了就得同步改.expected文件我在实际使用中会把「正常输出」和「错误输出」分开比对避免在调试词法错误时误报功能失败。5.2 避坑四个高频踩坑记录坑一变量遮蔽后的寄存器残留现象一个函数里有外层变量int i;内层块里也有int i;在内层块给i赋值后外层i的值意外变化。原因符号表查找返回了内层的Symbol但代码生成阶段offset字段被覆盖成了内层块的偏移值。如果内层块的栈帧布局计算错误比如内层块没有独立分配rbp内层i的地址和外层i重叠赋值就写到了错误的位置。解决在语义分析阶段为每个块分配独立的栈偏移区间块结束时回滚stack_offset计数器。这个操作必须和符号表出栈同步执行顺序是先回滚偏移再出栈否则符号表里还存着旧的offset值。坑二自增自减的副作用顺序现象int j i i;得到的结果和预期不同时对时错看起来像玄学。原因三地址码生成阶段i的翻译顺序是「先取旧值到临时量再自增原变量」。但两个i的临时量生成顺序和指令发射顺序不一致导致第二次i读到的是第一次自增后的值。解决强制所有后置自增/自减在翻译时先为左操作数生成一个隐式的临时副本所有对该变量的读操作都引用临时副本写操作统一在表达式求值完成后执行。这个设计在代码生成器里要严格下单双操作数不能图省事直接读原变量。坑三递归函数爆栈现象写一个简单递归求斐波那契数列fib(10)正常fib(20)直接段错误gdb看调用栈全是mov指令。原因函数的栈帧没有正确对齐。初版代码在call之前没有保证rsp16 字节对齐递归调用次数一多每次多错位 8 字节最终某个被调函数内部访问局部变量时踩到非法的栈地址。解决把第 4.2 节的对齐逻辑应用到每一个函数入口并在测试脚本里加一个递归深度 1000 的用例专门验证对齐。这个用例应该进入回归测试的常驻清单防止后续改动又把对齐破坏。坑四全局变量在汇编里的符号冲突现象编译包含多个.c文件的测试时链接器报符号重定义错误但源码里明明没有重复定义。原因全局变量名在汇编层没有做重命名。比如两个文件都用int temp;生成汇编里都有temp: .zero 4链接时撞名。解决代码生成器在输出全局变量时统一加前缀比如var_temp或者用文件名做命名空间区分。项目里采用的是拼接前缀方案代价是汇编可读性下降但链接时绝对不会撞名。5.3 调试手段-t 看中间代码、-s 看汇编除了测试脚本编译器自身提供的两个 dump 开关是调试利器。-t打印三地址码-s打印最终汇编。我的习惯是三层对照先跑-t看语义是否正确再跑-s看指令选择是否正确最后跑生成的可执行文件看运行结果。如果三层结果不一致就能快速把问题定位到「语义分析错误」还是「代码生成错误」而不是盲目地猜。对汇编调试还有一个救命招数在生成汇编的每条指令后面加一行注释内容是对应的三地址码指令。比如add rax, rdx后面写; t3 t1 t2。这个习惯能让汇编的可读性大幅提升排查偏移量错误时效率翻倍。项目源码里如果没开这个注释开关自己加也就十几行代码的事。6. 从教学编译器到顺手工具三个低成本进阶技巧6.1 单步跟踪生成的汇编拿到这个编译器后我第一个积累下来的技巧是用gdb对生成的汇编做单步跟踪。把断点设在main函数入口用layout asm打开汇编视图再用stepi逐指令执行。这个方法对理解栈帧变化极其有效你亲眼看着push rbp之后rsp变化sub rsp, N之后局部变量落位执行mov [rbp-4], rax时内存窗口里出现期望值——「栈」从一个抽象概念变成可视的东西。具体操作是gdb ./progbreak mainset disassembly-flavor intellayout asm然后stepi。带参数调试也别忘了生成的汇编里main的入口没有处理argc/argv所以程序参数会原样传给_start如果编译器不支持main的参数这部分要先用空参数测试。6.2 插桩调用链快速定位语义错位代码生成器最麻烦的问题不是语法错误而是语义错位——比如某个逻辑表达式被翻译成了算术运算。这时候在生成的汇编里人为插入调试桩是最快的定位手段。我会在代码生成器的emit_binop函数里加一段临时代码在生成add指令前输出一条mov rdi, ins-op和call debug_trace_op其中的debug_trace_op是手写的汇编子程序把操作码写到一个全局缓冲区里。调试桩和正式代码的隔离很重要。我在项目里用宏控制#define DEBUG_TRACE 1时插入插桩指令为 0 时全部跳过。这套做法让我在一个a b被翻译成a b的 bug 里十分钟定位到问题——三地址码完全正确汇编里出现jle而不是jl一眼就看出来是代码生成器的比较指令映射写错了。6.3 尾递归消除的实验体会优化器的第一个坑把这个编译器改成「能用」之后的下一步我尝试给它加了尾递归消除这是优化器入门的第一个经典实验。规则简单当函数return f(x)且f是当前函数自身时可以复用当前栈帧直接跳到函数入口而不是重新call。实现要点是先扫描函数体内的所有返回语句识别尾调用形式然后在汇编生成时把尾调用翻译成jmp加参数重设。这个实验让我明白了一个关键问题尾递归消除的正确性前提是函数参数和局部变量已经全部保存在栈帧里不会因为跳转而丢失。我的第一个实现把所有参数放在寄存器里试图加速结果递归到第二层参数就被覆盖深度一高立即翻车。从那以后我每次改代码生成器都会强制走一遍「三地址码 dump → 汇编 dump → gdb 单步」的完整链路确认任何优化都以可靠为底线再做。这个习惯让我的调试成本降低了一大半也希望帮到你——你先别急着加优化把基础链路跑通跑稳才是这个小型编译器源码给到的最实在的东西。本文还有配套的精品资源点击获取