简介这是一份基于C语言实现小型编译程序的课程设计资源面向编译原理课程学生及需要完成编译实验的开发者核心目标是演示如何将高级语言源代码逐步转换为四元式中间表示并提供一个可运行的编译框架。资源中注释清晰按词法分析、语法分析、语义分析与四元式生成四个阶段组织代码同时采用有限状态自动机识别单词、上下文无关文法构建抽象语法树还包含基础的错误处理与简单优化思路是理解编译器工作流程的直观范例。压缩包内共4个文件包括C源文件、C源文件、Markdown说明文档和License文件压缩后仅14KB内容紧凑、便于快速阅读。该资源已有288人学习。借助源码和配套文档读者可以掌握记号流拆分、递归下降解析、类型与作用域检查、中间代码生成等关键技能并了解常量折叠等优化手段对课程设计答辩、实验报告撰写或后续扩展优化器都有切实帮助。1. 小型编译程序到底在编译什么先划定输入、输出与三条边界用 C 语言写一个小型编译程序最容易被低估的不是语法分析而是内存管理。一个典型的课程级编译器输入是一段简化语法的源码变量声明、赋值、if/while输出是直接解释执行的结果或一段自定义指令流它不要求生成机器码但词法、语法、符号表、执行这几层都要端到端跑通。这篇笔记面向两类人刚学完 C 语言、想用一个 2000 行左右的项目检验指针功底的人以及学过编译原理但没手写过编译器的人。拖住进度的往往是词法分析里的字符串转义、符号表里的内存泄漏、递归下降里混淆和这些是 C 语言特有的坑和编译理论关系不大。我的路线是把文法限定死只支持变量、表达式、if/else、while、return按“词法 → 语法 → 符号表 → 执行 → 调试”五段拆每段都有能直接抄的代码。三条边界也先定死单文件输入、解释执行或字节码输出、遇到第一个错误报行号并停止。2. 词法分析手写扫描器把源码切成 Token 的 C 实现2.1 为什么用手写扫描器而不是 Flex词法分析的任务是把源码字符串切成一串 Token。很多教程开篇就让你用 Flex/Lex但我建议课程级项目直接手写。Flex 生成的代码动辄上千行状态机被拉平成一张大表一旦规则匹配顺序错了调试起来完全是个黑匣子。而小型编译程序的词法规则通常不超过 30 条手写一个next_token函数也就 200 行出错时翻回去看指针移到了哪里一目了然。另一个选择理由是批量读文件。我一般用fread把整个源文件一次性读进内存缓冲区末尾补一个\0而不是fgetc逐字符读。这样字符串游标天然就是一个char *pnext_token里只需要移动指针、判断字符区间省掉了文件 I/O 对每次扫描的干扰。缓冲区大小可以先开 1 MB按实际长度截断后再扩展也不心疼。文件缓冲区这个东西在自己动手写编译器时远比想象中重要它是所有后续解析操作的地基。手写扫描器的主循环看起来简单真正的麻烦集中在三个边界关键字和标识符的区分、多字符运算符的最长匹配、字符串和注释里的转义与换行。下面逐个给代码。2.2 Token 定义与扫描器主循环Token 的结构体决定了解析阶段的书写体验。我的定义是这样typedef enum { TOK_IDENT, TOK_NUMBER, TOK_STRING, TOK_INT, TOK_FLOAT, TOK_IF, TOK_ELSE, TOK_WHILE, TOK_RETURN, TOK_EOF, TOK_PLUS, TOK_MINUS, TOK_STAR, TOK_SLASH, TOK_EQ, TOK_NE, TOK_LT, TOK_GT, TOK_LE, TOK_GE, TOK_ASSIGN, TOK_SEMI, TOK_COMMA, TOK_LPAREN, TOK_RPAREN, TOK_LBRACE, TOK_RBRACE } TokenType; typedef struct { TokenType type; int line; union { char *sval; double fval; } u; } Token;line字段从词法阶段就一直带着语法分析报错时直接打印line %d省去后期从 AST 反查位置的操作。union 里sval和fval共用一块内存因为一个 Token 不可能同时是字符串和数字但sval指向的内存是strndup出来的谁负责释放要认真约定这个到符号表章节再展开。扫描器主循环的核心是“指针游走 最长匹配”。骨架如下typedef struct { char *src; size_t pos; int line; } Scanner; static Token next_token(Scanner *sc) { const char *p sc-src; skip_blanks_and_comments(p, sc-line); Token tok; memset(tok, 0, sizeof(tok)); tok.line sc-line; if (*p \0) { tok.type TOK_EOF; return tok; } if (isalpha(*p) || *p _) { const char *start p; while (isalnum(*p) || *p _) p; TokenType kw kw_lookup(start, p - start); if (kw ! TOK_IDENT) { tok.type kw; } else { tok.type TOK_IDENT; tok.u.sval strndup(start, p - start); } return tok; } if (isdigit(*p)) { const char *start p; int has_dot 0; while (isdigit(*p) || (*p . !has_dot)) { has_dot | (*p .); p; } if (*p e || *p E) { p; if (*p || *p -) p; while (isdigit(*p)) p; } tok.type TOK_NUMBER; tok.u.fval strtod(start, NULL); return tok; } switch (*p) { case : if (p[1] ) { tok.type TOK_LE; p 2; } else { tok.type TOK_LT; p 1; } break; case : if (p[1] ) { tok.type TOK_EQ; p 2; } else { tok.type TOK_ASSIGN; p 1; } break; /* 其余运算符与括号同理 */ default: error_at(sc-line, unexpected character %c, *p); } return tok; }这段代码有三个参数细节值得说明。第一kw_lookup返回TOK_IDENT表示“不是关键字”这是故意让一个返回值同时承担“失败”和“普通标识符”两种语义省掉一个布尔字段实现时按长度先过滤再strncmp避免长字符串逐位比较的开销。第二数字扫描里has_dot只允许一个小数点防止1.2.3被整段吞进strtod否则错误会推迟到执行阶段才暴露排查起来非常痛苦。第三strtod本身接受1e5和1.5e-3前面的e/E分支只是保证指数符号也算进数字长度让“1e 后面没数字”这类输入能被error_at立刻捕获。运算符部分必须做最长匹配。a b如果被切成a、、、b语法分析阶段会报“表达式后出现意外等号”但回看源码怎么都对这种报错最玄学本质是词法层吞字符太着急。、!、、都遵循同一规则先看下一个字符能否组成双字符运算符能则吞两个不能才退化成单字符。2.3 字符串、注释与行号维护的三个边界字符串字面量要不要进小型编译程序我强烈建议加因为调试信息打印比如print(x %f)会让执行结果可读很多。解析字符串的代码是另一个指针循环但多了两个规则遇到\n报错而不是吞掉遇到\做转义映射。case : { p; size_t cap 64, len 0; char *sbuf malloc(cap); for (;;) { if (*p \0) error(unterminated string); if (*p \n) error(newline in string, line %d, sc-line); if (*p ) { p; break; } if (*p \\) { p; switch (*p) { case n: sbuf[len] \n; p; break; case t: sbuf[len] \t; p; break; case \\: sbuf[len] \\; p; break; case : sbuf[len] ; p; break; default: error(bad escape \\%c, *p); } } else { sbuf[len] *p; } if (len 1 cap) { cap * 2; sbuf realloc(sbuf, cap); } } tok.type TOK_STRING; tok.u.sval sbuf; return tok; }动态缓冲区从 64 字节起步快满时翻倍比定长数组省心。最容易翻车的是转义后的赋值sbuf[len] \n写入的是换行符本身而不是\和n两个字符。如果这里不做映射打印时字符串内容会和源码不一致这种 bug 靠肉眼极难发现。注释处理放在skip_blanks_and_comments里//跳到行尾/*跳到*/块注释内部遇到\n必须让line自增。行号维护的规则是扫描循环里碰到\n就line。这里不要用scanf/fscanf做输入解析格式化输入对空白和转义的控制太弱指针游走虽然原始但可控性最高。另外提醒一句strndup是 POSIX 函数Linux 和 macOS 下直接用Windows 上 MSVC 没有这个接口要用_strdup或自己封装一个长度截断拷贝函数否则跨平台编译会直接翻车。Token 的sval所有权也是个约定问题。我的约定是扫描器产生的字符串由语法分析器持有转成 AST 节点时转移所有权AST 销毁时统一释放。如果中间某处提前返回错误分支统一走die()清理。这里混用strdup和直接赋值指针后面几乎必然出现 double free 或泄漏是 C 语言实现编译程序最经典的血泪经验。3. 语法分析递归下降法解析表达式与语句的 C 源码骨架3.1 文法设计与优先级层级进入语法分析前要先把文法写死。写法是关键因为文法就是语法分析的“参数表”优先级和结合性全藏在文法层级里。我的经验是从低到高定义层级赋值、比较、加法、乘法、一元、原子。写成 EBNF 是expr assign assign equality ( assign)? equality relational (( | !) relational)* relational add (( | | | ) add)* add mul (( | -) mul)* mul unary ((* | /) unary)* unary ( | -)? primary primary number | ident | ( expr )注意assign是右结合的a b 3解析成a (b 3)所以分支里递归调用parse_assign而不是parse_equality。比较、加减乘除都写成(运算符 操作数)*的循环而不是add add mul这种左递归形式——后者在递归下降里会让parse_add第一件事就调用自己永远没有出口。改成for(;;)消费同层运算符才是标准解法。这版文法的精巧点在于expr只有一个入口assign所以“if 条件里不能直接写赋值”这种语义限制可以靠文法本身挡住。如果不设assign层级if (x 3)会通过语法分析执行时才发现 x 被悄悄改掉定位成本高得多。3.2 表达式解析从乘除到比较再到赋值AST 节点我设计得比较简单一个Node结构体配合type区分字面量、变量、运算符和控制流typedef enum { ND_NUM, ND_VAR, ND_ASSIGN, ND_ADD, ND_SUB, ND_MUL, ND_DIV, ND_EQ, ND_NE, ND_LT, ND_GT, ND_LE, ND_GE, ND_IF, ND_WHILE, ND_BLOCK, ND_RETURN } NodeType; typedef struct Node Node; struct Node { NodeType type; Node *lhs, *rhs; char *name; double val; Node *cond, *then, *els, *body; Node **stmts; int n_stmts; };表达式解析器按文法层级的函数名组织每个函数只消费自己那一层的前缀。这是递归下降最优雅也最好上手的地方static Node *parse_expr(Parser *p) { return parse_assign(p); } static Node *parse_assign(Parser *p) { Node *node parse_equality(p); if (consume(p, TOK_ASSIGN)) { Node *rhs parse_assign(p); // 右结合 node new_node(ND_ASSIGN, node, rhs); } return node; } static Node *parse_equality(Parser *p) { Node *node parse_relational(p); for (;;) { if (consume(p, TOK_EQ)) node new_node(ND_EQ, node, parse_relational(p)); else if (consume(p, TOK_NE)) node new_node(ND_NE, node, parse_relational(p)); else return node; } } static Node *parse_relational(Parser *p) { Node *node parse_add(p); for (;;) { if (consume(p, TOK_LT)) node new_node(ND_LT, node, parse_add(p)); else if (consume(p, TOK_LE)) node new_node(ND_LE, node, parse_add(p)); else if (consume(p, TOK_GT)) node new_node(ND_GT, node, parse_add(p)); else if (consume(p, TOK_GE)) node new_node(ND_GE, node, parse_add(p)); else return node; } } static Node *parse_add(Parser *p) { Node *node parse_mul(p); for (;;) { if (consume(p, TOK_PLUS)) node new_node(ND_ADD, node, parse_mul(p)); else if (consume(p, TOK_MINUS)) node new_node(ND_SUB, node, parse_mul(p)); else return node; } } static Node *parse_mul(Parser *p) { Node *node parse_unary(p); for (;;) { if (consume(p, TOK_STAR)) node new_node(ND_MUL, node, parse_unary(p)); else if (consume(p, TOK_SLASH)) node new_node(ND_DIV, node, parse_unary(p)); else return node; } } static Node *parse_unary(Parser *p) { if (consume(p, TOK_PLUS)) return parse_unary(p); if (consume(p, TOK_MINUS)) return new_node(ND_SUB, new_node(ND_NUM, 0, NULL), parse_unary(p)); return parse_primary(p); } static Node *parse_primary(Parser *p) { Token *t peek(p); if (t-type TOK_NUM) { Node *n new_node(ND_NUM); n-val t-u.fval; next(p); return n; } if (t-type TOK_IDENT) { Node *n new_node(ND_VAR); n-name t-u.sval; // 所有权从 Token 转移到 Node next(p); return n; } if (consume(p, TOK_LPAREN)) { Node *n parse_expr(p); expect(p, TOK_RPAREN); return n; } error(syntax error near line %d, t-line); return NULL; }整个表达式解析器不超过 70 行每层职责单一。consume表示“当前 Token 匹配则吃掉并前进否则返回 false”peek向前看一个 Tokenexpect用于括号配对不匹配就报错。错误位置直接打印t-line因为 Token 从词法阶段就带着行号。一元负号我用0 - x表示AST 里少了一种节点类型解释器那边就少一个分支。代价是-a * b会解析成(0-a)*b还是0-(a*b)看文法层级parse_unary返回的0-a直接作为parse_mul的左操作数结果正是(0-a)*b符合数学直觉。a * -b的右操作数parse_unary(p)会生成0-b结果a*(0-b)也没问题。统一规则配合层级不会出优先级错误。new_node里我用calloc分配所有字段自动归零不需要逐个初始化。parse_assign里ND_ASSIGN的左节点直接来自parse_equality所以x 1 2的 AST 左边是ND_VAR(x)右边是ND_ADD。如果左边出了(a b) 3parse_equality能产生ND_ADD节点语法层不拦执行阶段看到左节点类型不是ND_VAR会直接报“赋值目标不是变量”这是语义检查的活。3.3 语句解析if、while、代码块与 return表达式解析完成后语句层只是往上包一层static Node *parse_stmt(Parser *p) { Token *t peek(p); if (t-type TOK_IF) { consume(p, TOK_IF); expect(p, TOK_LPAREN); Node *n new_node(ND_IF); n-cond parse_expr(p); expect(p, TOK_RPAREN); n-then parse_stmt(p); if (consume(p, TOK_ELSE)) n-els parse_stmt(p); return n; } if (t-type TOK_WHILE) { consume(p, TOK_WHILE); expect(p, TOK_LPAREN); Node *n new_node(ND_WHILE); n-cond parse_expr(p); expect(p, TOK_RPAREN); n-body parse_stmt(p); return n; } if (t-type TOK_RETURN) { consume(p, TOK_RETURN); Node *n new_node(ND_RETURN); n-rhs parse_expr(p); expect(p, TOK_SEMI); return n; } if (t-type TOK_LBRACE) { consume(p, TOK_LBRACE); Node *n new_node(ND_BLOCK); n-stmts NULL; n-n_stmts 0; while (!peek_is(p, TOK_RBRACE)) { Node *s parse_stmt(p); if (n-n_stmts % 8 0) { n-stmts realloc(n-stmts, (n-n_stmts 8) * sizeof(Node *)); } n-stmts[n-n_stmts] s; } expect(p, TOK_RBRACE); return n; } Node *n parse_expr(p); expect(p, TOK_SEMI); return n; }这一段难度不大但有一个容易被忽略的问题if (x) if (y) a 1; else b 2;中的else会就近绑定内层if。递归下降天然实现最近匹配因为else分支在里层parse_stmt返回前就被消费了如果先存then再回头看外层else就需要额外记录 Token 位置做回溯那才是真正的麻烦。语句块用Node **stmts加n_stmts动态增长解释执行时数组的随机访问比链表舒服得多。4. 符号表与作用域链式哈希表和内存所有权怎么定4.1 链式哈希表的结构与查找实现符号表是编译程序的内存管理主战场。如果只用数组存(name, value)对查找是线性扫描几十个变量还能忍但加上嵌套作用域后每层都要重新线性扫描复杂度肉眼可见变差。我的标准实现是链式哈希表默认 256 个桶冲突用单向链表挂查找时先算哈希再沿链表比较字符串。typedef struct Sym Sym; struct Sym { char *name; double value; int is_func; int depth; // 定义时所在作用域深度 Sym *next; // 同桶中的下一个 }; typedef struct Scope Scope; struct Scope { Sym *buckets[256]; Scope *parent; }; typedef struct SymTable { Scope *globals; Scope *current; int depth; // 当前作用域深度 } SymTable;为什么is_func要单独拎出来因为函数名和变量在 AST 里都表现为ND_VAR但执行阶段对函数名取地址、调用、读取变量值是完全不同的操作不标记就无法区分。depth字段记录符号在哪个作用域深度定义调试时能快速判断“这个变量是不是当前作用域冒出来的”。查找时沿作用域链向上走Sym *sym_lookup(Scope *scope, const char *name) { for (Scope *sc scope; sc; sc sc-parent) { Sym *sym bucket_find(sc-buckets, name); if (sym) return sym; } return NULL; }bucket_find的哈希函数我建议用简化版 FNV-1a遍历字符串的每个字节hash ((hash ^ c) * 16777619u) % 256。变量名通常很短复杂哈希和简单哈希差距不大选一个实现简单、碰撞率可接受的即可。要注意strcmp只能在长度相同的符号之间比较所以桶里挂多个符号时先比较strlen再做strcmp能省掉不少无意义调用。4.2 作用域链 push/pop 与变量遮蔽作用域在进入函数主体、进入代码块时创建退出时销毁。常见的实现是void scope_push(SymTable *st) { Scope *sc calloc(1, sizeof(Scope)); sc-parent st-current; st-current sc; st-depth; } void scope_pop(SymTable *st) { Scope *sc st-current; if (!sc) return; st-current sc-parent; st-depth--; for (int i 0; i 256; i) { Sym *sym sc-buckets[i]; while (sym) { Sym *next sym-next; free(sym-name); free(sym); sym next; } } free(sc); }变量遮蔽靠sym_lookup天然实现查找时从当前作用域往上走撞到的第一个同名符号就是赢家。内部代码块里定义int x外层还有个float x内部对x的读写只会命中内层的符号。最容易写错的地方是插入新符号时只检查当前作用域而不检查上层导致外层符号被悄悄忽略。正确行为是“查找走链插入只进当前桶”所以sym_put不要去查上层直接往st-current-buckets塞。我见过一个典型翻车符号表用全局数组模拟进入函数就把函数参数当普通变量写进同一个表结果函数内的sum和外层的sum互相覆盖执行结果随调用顺序变化。作用域链的意义就是把“定义可见范围”显式化用 push/pop 的栈式结构来承载。4.3 内存所有权谁分配谁释放这个项目里最值回票价的部分其实是内存所有权约定。Token 里的sval、AST 节点里的name、符号表里的Sym-name三处都会指向字符串。我的约定是所有字符串只分配一次并且严格单一所有。具体流程是词法阶段strndup出的标识符名字在parse_primary里转移到 AST 节点Token 本身不再释放语法分析结束、进入执行阶段前AST 节点里的name指针传给sym_put符号表strdup一份副本保存AST 销毁时释放自己的name符号表销毁时释放自己的副本。这样 Token、AST、符号表各自持有独立的拷贝互不干扰谁都不会在对方的地盘上free。如果符号表不拷贝而是直接收编 AST 的指针语法分析报错提前退出的路径就会漏释放如果两处持有同一个指针并各自free就是 double free。最伤的情况是错误分支直接exit(1)操作系统回收全部内存valgrind 也不报开发者以为内存管理做得很好直到换成优雅退出才暴雷。实操建议是全项目用calloc初始化为零、统一走单一所有者、错误分支退出前做一轮清理。这三条能省掉 90% 的内存调试时间。5. 执行与排查解释执行怎么选五个高频坑现象到解决5.1 解释执行还是中间指令流两条路径的取舍执行层有两种常见做法一种是直接对 AST 递归求值函数里维护当前作用域和变量值另一种是先遍历 AST 生成指令流类三地址码或逆波兰再用一个小循环执行指令。对课程级项目我的建议是无脑选 AST 解释执行理由很简单代码量少一半调试时能直接打印 AST 结构报错能定位到源码行号。指令流的优势是执行性能更好、能复用到后期目标代码生成但小型编译程序本来就不是为性能写的。维度AST 解释执行指令流解释执行实现工作量低中调试可读性高直接看树低要看指令码扩展为目标代码难易适合规模2000 行内长期演进项目如果目标是“把编译程序这个主题做透”选 AST 解释目标是“后续做后端优化”选指令流。我的项目选前者。5.2 执行上下文与变量读写的关键路径解释执行的骨架是一个eval(Node *node, Context *ctx)static double eval(Node *node, Context *ctx) { if (!node) return 0; switch (node-type) { case ND_NUM: return node-val; case ND_VAR: { Sym *sym sym_lookup(ctx-current, node-name); if (!sym) error(undeclared variable %s at line %d, node-name, node-line); return sym-value; } case ND_ASSIGN: { Node *target node-lhs; if (target-type ! ND_VAR) error(invalid assignment target at line %d, target-line); Sym *sym sym_lookup(ctx-current, target-name); if (!sym) error(undeclared variable %s, target-name); sym-value eval(node-rhs, ctx); return sym-value; } case ND_ADD: return eval(node-lhs, ctx) eval(node-rhs, ctx); case ND_MUL: return eval(node-lhs, ctx) * eval(node-rhs, ctx); case ND_LT: return eval(node-lhs, ctx) eval(node-rhs, ctx); /* 其余运算符照猫画虎 */ case ND_IF: { double c eval(node-cond, ctx); if (c ! 0) return eval(node-then, ctx); if (node-els) return eval(node-els, ctx); return 0; } case ND_WHILE: { double r 0; while (eval(node-cond, ctx) ! 0) r eval(node-body, ctx); return r; } case ND_BLOCK: { scope_push(ctx); double r 0; for (int i 0; i node-n_stmts; i) r eval(node-stmts[i], ctx); scope_pop(ctx); return r; } case ND_RETURN: return eval(node-rhs, ctx); default: error(unknown node type %d, node-type); } }判断条件用c ! 0而不是c是因为我们把所有数字都存成 double整型和浮点没有区分。这个取舍的代价是如果你要做严格类型检查这里必须区分 int 和 float 的节点类型工作量立刻翻倍。小型编译程序阶段我宁可全部用 double省下来的时间拿去做更完整的控制流。ND_BLOCK在执行块语句时scope_push/scope_pop正是符号表章节设计的用途。注意循环体每执行一次就创建和销毁一次Scope对象虽然每次 push/pop 都要 calloc/free但万次循环级别完全感觉不到。5.3 五个高频坑现象、原因、解决第一条标识符被截断。现象源码里定义了一个长度为 80 的变量名执行时总报undeclared variable。原因词法分析把标识符读进char buf[64]超长部分被丢掉插入符号表的名字和查找时的名字不一致。解决用strndup动态分配或用snprintf检查截断长度长度不够直接报错而不是静默截断。第二条和混淆但编译器不报错。现象if (x 3)通过编译程序不按预期走x 值被改。原因文法里assign层级允许expr出现在条件里被当作赋值吃掉。解决条件位置用parse_equality而不是parse_expr同时执行阶段检查赋值左侧必须是ND_VAR两条都做最稳。第三条递归下降栈溢出。现象对几百层嵌套的表达式如111...1程序直接 Segmentation Fault。原因parse_add的for(;;)循环是安全的但parse_unary遇到- - - - ... x时是递归调用没有终止条件parse_primary的(嵌套也没有限制。解决在 Parser 里加depth_remaining字段每递归一层减一归零就报“expression too deep”把无意义的递归变成显式错误。第四条函数返回后局部变量全消失。现象在函数 A 里调用函数 B返回后 A 的局部变量变成未定义。原因函数调用时scope_push为 B 建了新作用域返回时scope_pop销毁了 B 的作用域但 A 的current指针没恢复——调用前没有保存现场。解决调用函数前保存ctx-current返回后恢复或把Context设计成每次调用时重新构建执行环境。第五条输出结果有0.30000000000000004这种浮点尾巴。现象print(0.1 0.2)打印出很长的小数。原因double 的二进制表示问题不是编译器的 bug。解决内建print用printf(%g, v)隐藏无意义尾数。不要试图用%.10f修约除非你的目标是定点数语法。这条常被当成编译器写错了其实是浮点在 C 语言里的世界规律。6. 验证与打磨最小测试集配合 GDB 定位段错误的三个命令6.1 一份覆盖边界的验收测试清单测试集不用多但每条要打中一个边界。我用一个 shell 脚本驱动./minic每个用例有源文件和期望输出跑完 diff 一下cat test/01_assign.c EOF int a; a 1; print(a); EOF ./minic test/01_assign.c out/01_assign.out diff out/01_assign.out tests/01_assign.expected echo PASS输入片段验证目标int a; a 1; print(a);变量声明、赋值、输出float x; x 1.5 0.25; print(x);浮点字面量与加法while (i 3) { i i 1; } print(i);while 循环与块作用域if (1 2) print(10); else print(20);比较运算符与 if/elseif (f 4) print(f);错误用例赋值作为条件应报错print(1 2);布尔结果与运算符x 1 2 * 3; print(x);优先级测试前六条必须全绿最后一条错误用例要能稳定报invalid assignment target这才说明语法分析和执行阶段的防护都生效了。我的习惯是先写测试清单再写编译器每实现一个功能模块就补一条用例用脚本驱动整个开发过程。6.2 GDB 定位段错误的三个命令与习惯小型编译器最烦的就是段错误。三个命令基本够用gdb ./minic后直接run test.cvscode 调试面板里等价于 F5崩溃后先bt看调用栈十有八九是eval里某个node-lhs是空指针然后frame N切到出问题的帧p node-type看节点类型p node-lhs看左操作数。最多再来一次x/4bx node看节点内存的原始字节能区分野指针和未初始化字段。我的个人习惯是只在eval和parse_xxx这类函数入口加断言assert(node ! NULL)而不是在每个分支里判断空指针。断言命中时 GDB 停下来的位置就是第一现场比事后打印日志快得多。我也养成了“改一处跑一次测试集”的流程优先让错误用例先报错再去补实现。这个方向的项目最难修的不是算法逻辑而是 C 语言内存管理留下的后账提前把作用域和字符串所有权定清楚比多写两百行代码有用。希望帮到你。本文还有配套的精品资源点击获取