
简介基于C语言编译器是一份完整的编译原理课程设计项目面向需要完成词法分析、语法分析、中间代码生成与优化的计算机专业学生。项目采用lex与yacc完成词法与语法分析并构建语法树用C实现语法树解析、中间代码生成及错误检测随后通过Python脚本将中间代码转换为MIPS汇编最终可在PCSpim模拟器上运行整套流程覆盖了编译器前端到后端的核心环节。压缩包共54个文件以C/C源码、lex/yacc文法文件、Python脚本、可执行程序及Visual Studio工程配置为主并附带测试用例、输出日志和说明文档整体大小仅5.1MB便于快速查阅和二次开发。已有505人学习下载对正在做编译器课程设计或希望理解编译全流程的同学具有直接的参考价值。1. 基于C语言编译器一次把编译全流程跑通如果你写过几百行C语言代码大概率会对“编译器”产生过这种好奇我敲的int a 1 2;到底是经过什么路径变成机器能跑的东西的市面上讲编译原理的教材动辄几百页最劝退的是上来就讲正则文法和LR分析表让大多数人停在了第一章。但真实运营一个C语言编译器并不需要先掌握全部编译理论——它是一条清晰的流水线字符流、记号流、语法树、目标代码。这篇文章要带你实现的是基于C语言的一个“子集编译器”能编译 int、char、一维数组、函数、if/while/return、加减乘除和比较运算输出一个栈机指令序列再用你亲手写的解释器把它跑起来。整个过程大约六百行C代码不需要依赖任何第三方库在Linux、macOS或Windows上的gcc环境里都能编译运行。适合已经能独立写两百行C程序、想往底层走一步的人也适合被“编译器”两个字吓住、想用最小代价把原理落地的新手。2. 词法分析手写扫描器与Token流把源码变成有名字的符号2.1 为什么这个编译器要手写词法分析器而不是直接上Lex初次接触编译原理的人最容易踩的第一个坑就是迷信工具链正则表达式、Lex/Flex、ANTLR配置一长串才发现这些工具本身的学习成本比手写还高。一个C语言子集的词法分析器核心只是“读字符、分组、分类”这三个动作手写扫描器大约五十行核心代码就能覆盖全部需求。更重要的是手写词法分析器让报错信息可控我们能记录每个记号出现的行号在“第几行第几列、遇到什么意外字符”这个粒度上给用户提示这是黑匣子工具很难做到的。我一般会先定义Token类型。C语言的词法单元就那几类关键字、标识符、数字常量、运算符、分隔符、文件结束符。不需要一次性支持全部C99先把子集要用的类型列全typedef enum { TK_INT, TK_CHAR, TK_VOID, TK_IDENT, TK_NUM, TK_IF, TK_ELSE, TK_WHILE, TK_RETURN, TK_SEMI, TK_COMMA, TK_LPAREN, TK_RPAREN, TK_LBRACE, TK_RBRACE, TK_LBRACKET, TK_RBRACKET, TK_ASSIGN, TK_PLUS, TK_MINUS, TK_STAR, TK_SLASH, TK_PERCENT, TK_ANDAND, TK_OROR, TK_NOT, TK_EQ, TK_NE, TK_LT, TK_LE, TK_GT, TK_GE, TK_EOF } TokenKind; typedef struct { TokenKind kind; const char *start; /* 指向源码缓冲区中的起始位置 */ int len; /* 这个记号占几个字符 */ int line; /* 所在行号用来报错 */ } Token;Token结构里不必复制字符串记录start指针和len就够用。关键字表和标识符区分怎么做常见做法是“先按标识符读出来再到关键字表里查”。注意C语言是区分大小写的Int不是int查表时不要用不区分大小写的比较。2.2 扫描器核心实现最长匹配与跳过空白词法分析器的核心函数是next_token每调用一次从源码缓冲区的当前位置向后扫描返回下一个Token。编写时重点关注三件事空白和注释怎么跳过、数字和标识符怎么读、多字符运算符怎么匹配。static int is_alpha(int c) { return (c a c z) || (c A c Z) || c _; } static int is_digit(int c) { return c 0 c 9; } static TokenKind lookup_keyword(const char *s, int len) { /* 简化的关键字表查询匹配 int/char/if/else/while/return/void */ } int next_token(Token *tok, const char *src, int *pos) { int line tok-line; while (src[*pos] || src[*pos] \t || src[*pos] \n) { if (src[*pos] \n) line; (*pos); } if (src[*pos] / src[*pos 1] /) { while (src[*pos] src[*pos] ! \n) (*pos); return next_token(tok, src, pos); } if (src[*pos] / src[*pos 1] *) { (*pos) 2; while (!(src[*pos] * src[*pos 1] /)) { if (src[*pos] \n) line; if (!src[*pos]) { fprintf(stderr, line %d: unterminated comment\n, line); exit(1); } (*pos); } (*pos) 2; return next_token(tok, src, pos); } if (is_digit(src[*pos])) { int start *pos; while (is_digit(src[*pos])) (*pos); tok-kind TK_NUM; tok-start src start; tok-len *pos - start; tok-line line; return 0; } if (is_alpha(src[*pos])) { int start *pos; while (is_alpha(src[*pos]) || is_digit(src[*pos])) (*pos); tok-kind lookup_keyword(src start, *pos - start); tok-start src start; tok-len *pos - start; tok-line line; return 0; } /* 多字符运算符必须在单字符运算符之前判断 */ if (src[*pos] src[*pos 1] ) { tok-kind TK_EQ; *pos 2; return 0; } if (src[*pos] ! src[*pos 1] ) { tok-kind TK_NE; *pos 2; return 0; } if (src[*pos] src[*pos 1] ) { tok-kind TK_LE; *pos 2; return 0; } if (src[*pos] src[*pos 1] ) { tok-kind TK_GE; *pos 2; return 0; } if (src[*pos] src[*pos 1] ) { tok-kind TK_ANDAND; *pos 2; return 0; } if (src[*pos] | src[*pos 1] |) { tok-kind TK_OROR; *pos 2; return 0; } switch (src[*pos]) { case : tok-kind TK_ASSIGN; break; case : tok-kind TK_PLUS; break; case -: tok-kind TK_MINUS; break; case *: tok-kind TK_STAR; break; case /: tok-kind TK_SLASH; break; case %: tok-kind TK_PERCENT; break; case !: tok-kind TK_NOT; break; case : tok-kind TK_LT; break; case : tok-kind TK_GT; break; case ;: tok-kind TK_SEMI; break; case ,: tok-kind TK_COMMA; break; case (: tok-kind TK_LPAREN; break; case ): tok-kind TK_RPAREN; break; case {: tok-kind TK_LBRACE; break; case }: tok-kind TK_RBRACE; break; case [: tok-kind TK_LBRACKET; break; case ]: tok-kind TK_RBRACKET; break; case \0: tok-kind TK_EOF; break; default: fprintf(stderr, line %d: unexpected character %c\n, line, src[*pos]); exit(1); } (*pos); tok-start src (*pos) - 1; tok-len 1; tok-line line; return 0; }这段代码有几个关键设计第一注释和空白在扫描阶段直接丢弃外层循环用递归调用next_token继续读下一个Token这是最直观的写法第二数字只实现了十进制整数字面量不支持十六进制和浮点数对子集编译器来说是合理的裁剪第三多字符运算符的判断必须先于单字符运算符否则遇到时先匹配了后面就少了一个字符。注意递归调用next_token处理空白和注释遇到极长注释时可能消耗栈深度。实际工程里可以改成循环但教学用的子集编译器完全够用。我就是用这种简单写法跑完上千行测试代码没出过问题。这里值得提一个新手经常怀疑的问题为什么词法分析阶段不直接生成语法树因为词法分析只解决“这是什么字”不解决“这些字怎么组成语言结构”。比如if是关键字还是标识符扫描阶段查表确定但if后面必须跟表达式、else要和哪个if配对那是语法分析阶段的事。把这两个阶段分开每一层的责任才清晰。2.3 词法错误处理让报错可定位而不是一崩了事词法层最容易翻车的场景有两个未闭合的块注释、源码里出现全角字符或不可见字符。未闭合的块注释会导致扫描器一直读下去直到文件结尾如果代码里没有结尾检查就会读到缓冲区后面越界。上面的代码在处理/*时做了if (!src[*pos])的检查遇到文件结束没有*/就报错退出这就是“宁可报错不要崩溃”的基本素养。另一个细节行号追踪。next_token的入口处把行号记入tok-line每跳过换行就自增。这样词法错误、语法错误都能报出“line N”的信息配合VS Code或命令行gcc环境调试的时候你能立刻定位到源码位置。很多人在这个阶段喜欢把源码放在一个超长的字符串里测试我建议直接读文件逐行追踪行号避免“字符串字面量里的换行”干扰报错定位。3. 语法分析递归下降解析C子集符号表在这里绑定3.1 为什么选递归下降而不是LALR自动机等词法分析跑通真正决定编译器骨架的是语法分析策略。我强烈建议用递归下降它把语法规则直接写成C函数一个非终结符对应一个函数看代码的人能把函数调用关系映射回文法调试时拿一个测试用例跟读一遍函数调用栈往往比看状态转移表直观得多。比如表达式a 1 * 2的解析过程是parse_expr调parse_assignparse_assign调parse_equality……一层层往下直到最底层的parse_operand读出标识符或数字常量。每个函数只有两个职责按当前Token决定调用哪个子函数以及检查当前Token是否匹配期望的文法终结符。这个编译器我特意不构建显式的AST节点。语法分析的同时直接向代码缓冲区发指令因为栈机的执行模型是顺序的if/while/return这类控制流用跳转指令就能表达。这样省掉了AST内存管理也让代码生成和语法绑定得更紧——但代价是不能做多趟优化。对教学子集来说这是有价值的取舍。3.2 表达式解析用优先级函数消除左递归C的表达式要处理 - * / % ! ||和一元负号、取反还得保证乘除先于加减、比较先于赋值。教科书里的标准做法是写成多层函数嵌套但层数多了代码会很难维护。我采用的方案是把每个运算符绑定一个优先级数值只用一个parse_binop函数处理。static int prec_of(TokenKind k) { switch (k) { case TK_OROR: return 1; case TK_ANDAND: return 2; case TK_EQ: case TK_NE: return 3; case TK_LT: case TK_LE: case TK_GT: case TK_GE: return 4; case TK_PLUS: case TK_MINUS: return 5; case TK_STAR: case TK_SLASH: case TK_PERCENT: return 6; default: return 0; } } static void parse_operand(void) { if (tok.kind TK_NUM) { emit(ICONST, tok.val); next(); } else if (tok.kind TK_IDENT) { Sym *s scope_find(tok.name); if (!s) error(undefined variable %s, tok.name); emit(s-is_array ? LOADA : LOADW, s-addr); next(); } else if (tok.kind TK_LPAREN) { next(); parse_assign(); expect(TK_RPAREN); } else if (tok.kind TK_NOT || tok.kind TK_MINUS) { TokenKind op tok.kind; next(); parse_operand(); emit(op TK_NOT ? NOT : NEG); } else { error(unexpected token in expression); } } static void parse_binop(int min_prec) { parse_operand(); while (1) { int p prec_of(tok.kind); if (p min_prec) break; TokenKind op tok.kind; next(); parse_binop(p 1); emit(BINOP, op); } } static void parse_assign(void) { if (tok.kind TK_IDENT) { Sym *s scope_find(tok.name); if (s next_is(TK_ASSIGN)) { next(); /* 吃掉 */ parse_assign(); /* 赋值右结合 */ emit(s-is_array ? STOREA : STOREW, s-addr); return; } } parse_binop(1); }这套代码的关键是parse_binop(min_prec)的循环控制先解析一个操作数然后看当前运算符优先级是否不低于外层要求的min_prec如果是就继续向右递归。parse_binop(p 1)里的p 1保证左结合性——遇到相同优先级的运算符时右侧操作数递归时要求更高优先级因此会把左侧已经解析好的结果保留在栈上等新的运算符进来后按顺序计算。我把赋值单独放在parse_assign里等号右边的表达式递归调用parse_assign而不是parse_binop这样就实现了赋值的右结合。a b 2可以先算b 2再给a赋值符合C语义。要特别提醒的是scope_find找不到变量时必须报错。早期版本我为了省事把它当成0号地址的全局变量处理结果拼写错误被静默掩盖程序输出完全乱套排查了整整一个下午。这种时候“报错”比“容错”值钱得多。3.3 符号表作用域绑定和类型信息从哪来符号表是语法分析器手里的账本。int a;声明时登记名字和类型a 1;使用时查表拿地址。这个编译器用链表实现每个Sym结构保存名字、类型、地址、作用域深度还有一个指向下一符号的指针。进入函数体的花括号时压入一个作用域层级声明变量时在当前层级链头部插入新符号出花括号时把链表恢复到进入前的状态。typedef struct Sym Sym; struct Sym { char name[64]; int type; /* TY_INT 或 TY_CHAR */ int addr; /* 全局或栈帧内偏移 */ int is_array; int array_len; Sym *next; }; static Sym *scope_head NULL; static int scope_depth 0; static Sym *scope_find(const char *name) { for (Sym *s scope_head; s; s s-next) if (strcmp(s-name, name) 0) return s; return NULL; } static void scope_push(void) { scope_depth; } static void scope_pop(void) { scope_depth--; while (scope_head scope_head-depth scope_depth) scope_head scope_head-next; } static Sym *scope_add(const char *name, int type) { Sym *s calloc(1, sizeof(Sym)); strcpy(s-name, name); s-type type; s-depth scope_depth; s-next scope_head; scope_head s; return s; }scope_pop里通过检查depth把当前作用域的符号一次性移除比维护“当前层符号栈”再逐个弹出要省事。函数参数怎么处理进入函数体时先把每个参数按顺序scope_add到最外层作用域再scope_push创建局部变量层。这样参数在函数体里和普通局部变量用法完全一致不需要特殊标记。符号表在声明时还需决定int占4字节、char占1字节的布局。变量地址从当前函数栈帧底部往高地址分配。全局变量则从0x1000开始排。这个地址规划直接写进符号表代码生成阶段不关心变量是全局还是局部只拿着符号表里的addr发LOADW或STOREW指令——这就是“语义分析阶段把名字变成地址”的典型做法。4. 栈机代码生成从语法树到指令序列在你自己的虚拟机上跑起来4.1 为什么先做栈机而不是直接生成x86汇编直接生成x86-64汇编需要掌握寄存器分配、ABI调用约定、栈帧布局这些知识叠加在一起会让编译器开发的难度陡增。栈机stack machine是更好的切入点所有运算都在一个操作数栈上进行指令本身就是ICONST压入常量、ADD弹出两个数相加再压入结果这类极其简化的操作。栈机模拟的正是CPU和单片机里真实的堆栈行为理解了栈机再回头看“函数调用时栈帧怎么压入和弹出”就清晰了。这个编译器生成的指令序列是一个int数组奇数位存操作码偶数位存操作数或跳转目标。我没有把它输出成文本汇编再重新解析而是直接运行时内存布局这样虚拟机执行器的代码特别短也避免了两段代码之间的格式转换bug。4.2 指令集与运行时内存布局先定义指令集这是整个编译后端的地基指令操作数执行效果ICONSTn把立即数n压栈LOADW / LOADBaddr从地址addr读取int/char并压栈STOREW / STOREBaddr弹出栈顶值写入addrLOADAaddr把地址本身压栈数组名/取地址ADD / SUB / MUL / DIV / MOD无弹出b、a压入a op bLT / LE / GT / GE / EQ / NE无比较后压入0或1NOT / NEG无逻辑取反 / 算术取反JMPtarget无条件跳转JZtarget弹出栈顶为零则跳转CALLfunc_index调用函数RETframe_size返回并回收frame_size字节栈帧内存布局用最简单的静态方案0x0000到0x0FFF留给代码区0x1000到0x1FFF放全局变量0x2000开始是栈区。虚拟机执行器里用一个int数组模拟内存栈顶指针sp从0x2000开始向下增长这是真实RISC处理器常见的方向符合“向下生长”的惯例。4.3 代码生成器的核心函数代码生成器不是什么黑匣子它本质上是把语法分析器解析过程中遇到的结构翻译成指令的直译器。每次parse_binop识别出一个二元运算符就发一条BINOP指令每次parse_if进入条件分支就发一条JZ和若干条JMP。下面这段是if语句的生成逻辑重点看标签怎么回填static int new_label(void) { return idx; } static void fix_label(int label, int target) { code[label] target; } static void gen_if_stmt(void) { next(); /* 吃掉 if */ expect(TK_LPAREN); parse_assign(); /* 生成条件表达式代码 */ expect(TK_RPAREN); emit(JZ, 0); /* 先留空跳转目标 */ int else_jmp idx - 1; stmt(); /* then 分支 */ emit(JMP, 0); int end_jmp idx - 1; fix_label(else_jmp, idx); /* else 从当前位置开始 */ if (tok.kind TK_ELSE) { next(); stmt(); } fix_label(end_jmp, idx); /* 整个 if 的出口 */ }emit(JZ, 0)时操作数填0是因为此时还不知道else分支从哪里开始。等then分支的指令生成完毕idx恰好是下一条指令的位置就用fix_label把它回填。这种“先生成占位数、后回填地址”的技术贯穿整个控制流代码生成理解它之后while和函数调用的跳转都是同一套路。再来看while循环逻辑上比if多一个回边跳转static void gen_while_stmt(void) { next(); expect(TK_LPAREN); int loop_start new_label(); /* 循环体起点 */ parse_assign(); expect(TK_RPAREN); emit(JZ, 0); int exit_jmp idx - 1; stmt(); emit(JMP, loop_start); fix_label(exit_jmp, idx); }这里有个容易忽略的细节loop_start记录的是parse_assign生成第一条条件指令之前的位置而不是while关键字的位置。这样每次循环先把条件重新评估一遍和C语义一致。最后看一下函数调用的栈帧约定。这个编译器用的调用规约是调用者先把实参从左到右压栈然后发CALL指令被调函数的参数通过LOADW读取栈帧中相对位置的值函数返回前用RET frame_size把整个栈帧收回。因为栈机的sp是运行时动态变化的我没法在编译期确定一个参数在栈里的绝对地址所以参数寻址也是用“当前栈基址固定偏移”的方案static void gen_call_po(int idx) { Function *f functions[idx]; int nargs f-nargs; for (int i nargs - 1; i 0; i--) { parse_assign(); } emit(CALL, idx); }参数从左到右压栈后最后一个压入的是最左侧参数这样被调函数读取第一个参数时偏移最小。嵌入C编译器后端的函数传参位置偏移一旦压栈顺序和读取顺序不一致函数参数就会全部错位这是新手在自己写的时候常犯的错。5. 编译器开发五大翻车现场现象、原因与修复5.1 注释里的宏定义被预处理顺序错了整段源码全解不开现象源码的注释里写了一个类似/* define MAX 10 */的文本结果报出“MAX未定义”的错误或者更夸张整个函数体莫名其妙消失。原因词法分析时我把宏替换逻辑放在了注释剔除之前。扫描器先看到了define三个字就触发预处理器而真正的/*注释标记还没来得及被跳过。解决调整预处理顺序先完整剔除注释和空白再做宏文本替换。实际操作里我在词法扫描器进入next_token时第一步永远是跳过空白和注释等到返回一个普通标识符Token时才去查宏表。顺序修正之后这类问题彻底绝迹。这也是为什么词法阶段拥有独立代码库会更好——处理顺序错了后面全崩。5.2 else悬垂else永远嫁给最近的if现象嵌套if的代码在else分支行为诡异地反转。比如if (a) if (b) x 1; else x 2;在a为假时本不该执行任何操作实际却执行了x 2。原因C语法里else总是匹配最近的未闭合if。我的gen_if_stmt在解析完一个完整if后立即把end_jmp回填为当前idx但当if的 then 分支是一个不含else的嵌套if时外层 if 回填的end_jmp指向了内层 if 的else处而不是整棵语法树的正确出口。解决不要每个if都立刻回填最后跳转目标。正确的做法是让嵌套if先把自己的完整结构生成完再让外层if获得最终出口。实现时我引入了一个 label 栈else_jmp和end_jmp都压入栈中等整个 if 结构结束后再从栈顶弹出回填。代码生成和递归下降并行时“先处理子结构再回填标签”的顺序约定务必在所有控制流生成代码中保持一致。5.3和写成一样表达式解析把赋值当比较现象if (a b)在语法层完全通过运行时却像“玄学”——a 被悄悄改成了b的值条件恒为真。原因我在parse_binop里把TK_ASSIGN当成一个二元运算符并且赋予了一个不低的优先级。这样a b被解析成“把a和b压栈、比较相等”然后发一条EQ指令。它不报错可是语义完全错了。解决把赋值从二元运算符优先级表里拿出来单独放在parse_assign函数处理并且让表达式的入口优先调用parse_assign。这样操作的优先级永远低于逻辑或||并且天然右结合。写完这个修复后我又加上了一条“赋值的左操作数必须是可写左值”的检查在一元表达式里直接拒绝1 2这类代码。5.4 main未定义是编译错误还是链接错误必须分清现象我写的测试文件里有一个函数缺了返回值类型编译时报“编译器未包含main类型”或undefined reference to main。但源码里明明写了main函数。原因这是新手阶段最容易混搅的两个阶段。编译错误发生在词法/语法/语义分析而链接错误发生在所有源文件编译成目标代码之后由链接器统一寻找入口点。如果你的main函数名拼错成了mian编译器本身不会报main相关的错是最后链接阶段报undefined reference to main。反过来编译器未包含main类型这类报错多半是你在main前面写错了返回类型比如void main在某些严格模式下就会被拒绝。解决建立一个固定的排查顺序——第一步看报错是来自编译器还是链接器命令行下gcc报错会标明文件名和行号链接器报错一般只有函数名第二步如果报undefined reference检查拼写和是否把main写进了条件编译#ifdef里第三步如果报类型错误确认返回类型是int而不是void或char。把这三步固定成习惯至少能省掉一半的编译报错调试时间。5.5 栈机上程序崩溃RET后栈高度没还原现象生成的指令序列在虚拟机上运行简单运算结果全对可程序一调用函数就偶尔崩溃或者返回后局部变量被篡改。原因一次在生成函数返回代码时我只发了RET回收当前函数的栈帧但没有调整sp到调用前的正确高度。在栈机模型里CALL压入了返回地址和实参函数返回时必须一次性把实参、局部变量、临时计算区全部弹出恢复到调用那一条指令之前的栈顶位置。如果RET的frame_size和实际压栈数量不一致就会像C语言里“栈不平衡”一样把调用者的栈底数据冲掉。解决在虚拟机执行器里加一个高度校验每次CALL执行前记录sp执行RET后立即检查sp是否等于调用前的值。校验失败直接报错并打印当前pc和函数索引。这样每个函数调用一发错立刻能在上千条指令里定位到是哪个函数、哪个返回点。修复的方式是按函数参数个数和局部变量总大小精确计算RET参数这也是我之前一直劝人在写编译器时先在栈机里打印“每个入口和出口的sp值”的原因。6. 验证与自举让编译器编译自己才算真正闭环6.1 用测试套件驱动开发合法程序、非法程序、运行时行为三条线编译器开发最忌讳“只拿一个helloworld测通了就当完成”。我给自己定了一条规矩每加一个语法特性至少要同时加三个测试。第一类是合法程序验证能编译正确执行第二类是非法程序验证会报错而不是静默产出错误代码第三类是运行时行为测试比如冒泡排序c语言、字符串逆序c语言这类经典算法验证最终计算结果和gcc编译出来的结果一致。/* tests/runtime/bubble_sort.c */ int sort(int *arr, int n) { int i, j, tmp; i 0; while (i n - 1) { j 0; while (j n - i - 1) { if (arr[j] arr[j 1]) { tmp arr[j]; arr[j] arr[j 1]; arr[j 1] tmp; } j j 1; } i i 1; } return 0; } int main() { int a[6]; a[0] 5; a[1] 2; a[2] 9; a[3] 1; a[4] 7; a[5] 3; sort(a, 6); return a[0] * 100000 a[1] * 10000 a[2] * 1000 a[3] * 100 a[4] * 10 a[5]; }测试脚本做的事很简单用你的编译器编译这个测试文件在虚拟机上运行得到返回码然后和执行同样逻辑的gcc编译版本做diff。测试套件挂在make test下每次改完代码生成器跑一遍绿色通过才继续下一个特性。这一段背后是一个很朴素但极有用的工具diff。你不需要猜测你的栈机行为是否和C标准一致只需要验证“同一个输入输出与gcc一致”就能把测试面铺开。6.2 从“能运行”走向“能优化”常量折叠与窥孔优化的最小实现生成出来的指令序列往往带有大量冗余比如int a 1 2;会生成三条指令压入1、压入2、执行ADD。虽然结果正确但效率低。这里可以做一个最小但非常直观的优化pass常量折叠在编译期就把常量表达式的值算出来。/* 在指令序列上做一趟常量折叠按指令对扫描 */ static int fold_constants(int *code, int n, int *err) { int w 0; for (int i 0; i n; i 2) { if (w 2 code[w - 2] ICONST) { int left code[w - 1]; if (code[i] ICONST) { int right code[i 1]; int op code[i 2]; if (is_binop(op)) { code[w - 2] ICONST; code[w - 1] eval_const(left, right, op); i 2; continue; } } } code[w] code[i]; code[w] code[i 1]; } return w; }这个pass做的事情很单纯从左往右扫描指令序列如果发现“ICONST、ICONST、BINOP”三段连续指令就在编译期把结果算出来替换成一条ICONST。它在真实编译器里对应的是常数传播和常量折叠是优化中最容易理解的一档。跑完这个pass之后1 2直接变成ICONST 3指令少了两条。配合一个简单的窥孔优化把连续两条JMP合并成一条就能让虚拟机跑得明显快一点。这些都是“编译器优化”题面下最简单却最能提升信心的切入点。6.3 自举让这个编译器编译自己自举是编译器开发里最迷人的验收标准用你写的C编译器去编译这个编译器自身的C源码。这个目标最初听起来像“用铁做斧头再用斧头砍出下一把斧头”但具体操作其实不玄学。整个自举分三步第一步用宿主gcc编译你的编译器源码得到一个可执行的c0第二步用c0去编译同一个源码得到可执行文件c0_self1第三步让c0_self1再编译源码得到c0_self2比较c0_self1和c0_self2的运行结果是否一致。如果一致说明你的编译器能正确编译包含它自身的这套代码。为了达到自举我在写编译器源码时刻意保持“C子集方言”的自我约束不用for循环只用while、不用switch只用if-else链、不用struct赋值、不用static函数限定符外的特性。这个约束从第一行代码就开始执行也正是因为这种克制编译器源码才能运行在自己产出的那些指令上否则写的时候很舒服自举的时候会碰得头破血流。等到自举跑通的那天你手上就已经是一个完整的工具链了从源码字符到Token流再到栈机指令最后驱动的还是你自己定义的虚拟机。到这时候再看VS Code或gcc的报错信息你已经能分清哪一层出了问题不再需要靠试错不停地乱改代码。我自己做完这个方向之后一个很深的习惯是编译器报错先读第一行链接器报错先查函数名拼写运行时崩溃先把栈高度和pc打出来。这三个习惯救了我后面所有编程工作的时间——把黑匣子拆开了就不是玄学了。希望帮到你。本文还有配套的精品资源点击获取