news 2026/9/23 7:20:04

手写C语言子集编译器:从词法分析到栈机代码生成全流程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写C语言子集编译器:从词法分析到栈机代码生成全流程

简介:基于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_exprparse_assignparse_assignparse_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开始排。这个地址规划直接写进符号表,代码生成阶段不关心变量是全局还是局部,只拿着符号表里的addrLOADWSTOREW指令——这就是“语义分析阶段把名字变成地址”的典型做法。

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弹出栈顶值写入addr
LOADAaddr把地址本身压栈(数组名/取地址)
ADD / SUB / MUL / DIV / MOD弹出b、a,压入a op b
LT / LE / GT / GE / EQ / NE比较后压入0或1
NOT / NEG逻辑取反 / 算术取反
JMPtarget无条件跳转
JZtarget弹出栈顶,为零则跳转
CALLfunc_index调用函数
RETframe_size返回并回收frame_size字节栈帧

内存布局用最简单的静态方案:0x00000x0FFF留给代码区,0x10000x1FFF放全局变量,0x2000开始是栈区。虚拟机执行器里用一个int数组模拟内存,栈顶指针sp0x2000开始向下增长(这是真实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_jmpend_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而不是voidchar。把这三步固定成习惯,至少能省掉一半的编译报错调试时间。

5.5 栈机上程序崩溃:RET后栈高度没还原

现象:生成的指令序列在虚拟机上运行,简单运算结果全对,可程序一调用函数就偶尔崩溃,或者返回后局部变量被篡改。

原因:一次在生成函数返回代码时,我只发了RET回收当前函数的栈帧,但没有调整sp到调用前的正确高度。在栈机模型里,CALL压入了返回地址和实参,函数返回时必须一次性把实参、局部变量、临时计算区全部弹出,恢复到调用那一条指令之前的栈顶位置。如果RETframe_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_self1c0_self2的运行结果是否一致。如果一致,说明你的编译器能正确编译包含它自身的这套代码。

为了达到自举,我在写编译器源码时刻意保持“C子集方言”的自我约束:不用for循环(只用while)、不用switch(只用if-else链)、不用struct赋值、不用static函数限定符外的特性。这个约束从第一行代码就开始执行,也正是因为这种克制,编译器源码才能运行在自己产出的那些指令上,否则写的时候很舒服,自举的时候会碰得头破血流。

等到自举跑通的那天,你手上就已经是一个完整的工具链了:从源码字符,到Token流,再到栈机指令,最后驱动的还是你自己定义的虚拟机。到这时候再看VS Code或gcc的报错信息,你已经能分清哪一层出了问题,不再需要靠试错不停地乱改代码。我自己做完这个方向之后,一个很深的习惯是:编译器报错先读第一行,链接器报错先查函数名拼写,运行时崩溃先把栈高度和pc打出来。这三个习惯救了我后面所有编程工作的时间——把黑匣子拆开了,就不是玄学了。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/23 7:19:41

Supermemory:为AI应用打造长期记忆层,从部署到实战

最近一直在折腾给AI应用加“长期记忆”这件事。早期聊天机器人那种“关掉窗口就失忆”的状态实在太难受了——每次重新开会话&#xff0c;都得把背景重新讲一遍&#xff0c;仿佛对面坐着一个非常热情但记性极差的新同事。我试着用向量数据库自己搭RAG&#xff0c;但折腾来折腾去…

作者头像 李华
网站建设 2026/9/23 7:19:32

BLE主机与从机怎么选?从连接关系看主从一体模块的工程价值

在BLE终端开发中&#xff0c;主机&#xff08;Central&#xff09;和从机&#xff08;Peripheral&#xff09;的选择&#xff0c;实际上决定了设备如何发现对方、谁主动建立连接以及后续数据如何交互。常见的传感器、按键、外设等终端通常采用从机方式&#xff0c;通过广播等待…

作者头像 李华
网站建设 2026/9/23 7:18:45

基于 Java Spring Boot 的化妆品推荐系统设计与实现

温馨提示&#xff1a;本人主页置顶文章(点我)开头有 CSDN 平台官方提供的学长联系方式的名片&#xff01; 1. 项目背景与意义 随着人们生活水平的不断提高&#xff0c;化妆品已成为日常消费的重要组成部分。面对市场上琳琅满目的化妆品品牌和种类&#xff0c;消费者往往难以快…

作者头像 李华
网站建设 2026/9/23 7:17:25

芝加哥时间与CST/CDT时区换算:消除歧义与代码实现

芝加哥现在几点&#xff1f;这问题听起来简单&#xff0c;真要对答案的时候很多人会懵一下。原因不是你不会查时间&#xff0c;而是查时间的时候会碰到两个缩写&#xff1a;CST 和 CDT。你要是直接搜索“CST”&#xff0c;结果往往五花八门&#xff0c;甚至可能搜出仿真软件 CS…

作者头像 李华
网站建设 2026/9/23 7:17:11

Java直接内存原理与JVM管理机制详解

1. 直接内存的本质与Java内存模型的关系直接内存&#xff08;Direct Memory&#xff09;是Java中一个容易被误解的概念。很多人以为它完全不受JVM管控&#xff0c;实际上情况要复杂得多。直接内存本质上是通过Java的NIO包中ByteBuffer.allocateDirect()方法分配的内存区域&…

作者头像 李华
网站建设 2026/9/23 7:16:39

打印机驱动安装全攻略:四种方法详解与避坑指南

打印机这东西&#xff0c;平时安安静静待在角落&#xff0c;一旦罢工&#xff0c;整个办公室都能听见有人喊“谁把驱动删了”。我见过太多人抱着打印机说明书翻半天&#xff0c;最后还是在网上随便下了一个来路不明的驱动包&#xff0c;结果装完系统蓝屏。也见过有人明明插着US…

作者头像 李华