☰
C语言实现小型编译器:从词法分析到中间代码生成
2026/10/12 19:41:27 网站建设 项目流程

简介:基于C语言实现的小型编译程序课程设计资源包,面向编译原理学习者及高校课程设计学生,完整演示了从源代码到四元式中间表示的编译实现流程,涵盖词法分析、语法分析、语义分析与中间代码生成等关键环节。资源共4个文件,压缩包仅14KB,包含C语言源程序、C++辅助文件、说明文档及LICENSE许可,md文档用于快速理解设计思路,c/cpp代码供直接阅读与二次开发。已有288人学习下载,适合作为编译原理课程设计的参考实现或入门实践模板。借助包内源码,可学习四元式(OP,A,B,C)结构定义、正则规则到有限状态自动机的词法识别、递归下降语法分析,以及常量折叠等简单优化技巧,为后续目标代码生成打下扎实基础。 好,先想清楚一个前提:用 C 语言实现一个小型编译程序,不是在造一个能跟 GCC 扳手腕的产品,而是把「源代码怎么变成机器能懂的东西」这条链路亲手走通一遍。如果你正面临课程设计、考研复试项目,或者入职后要写解释器/DSL,这个题目的价值在于:它逼你把词法分析、语法分析、符号表、中间代码生成这些黑匣子一个个拆开,并且因为用的是 C,你没法靠语言本身的封装绕坑——内存、指针、字符串缓冲全部裸奔在你面前。本文按我实际做过的路径,从词法到代码生成给出一套可复现的最小实现方案,顺带把最容易翻车的几个位置提前指出来。

2. 词法分析器:从字符流到 token 表的三个关键选择

词法分析是整个编译程序的地基。它干的事很朴素:把源码字符串切成一个个有意义的词素,比如int a = 10 + b;切出int、a、=、10、+、b、;。这块看似简单,但有几个选型会直接影响你后面所有代码的写法。

2.1 token 结构体与关键字表:先用 C 把词的形态定死

我见过不少初学者一上来就写一个get_token()返回字符串,然后后面到处用strcmp比较。这种做法不是不行,但会让语法分析阶段的代码变成一堆字符串比较的泥潭。更稳的做法是先定义一套 token 类型枚举,每个 token 带着类型、值、源码位置三件套。

/* token.h */ #ifndef TOKEN_H #define TOKEN_H #define MAX_ID_LEN 64 #define MAX_TOKENS 4096 typedef enum { T_EOF, T_NUM, /* 整数常量 */ T_ID, /* 变量名,如 a、b */ T_KW, /* 关键字,如 int、return */ T_PLUS, T_MINUS, T_STAR, T_SLASH, T_LPAREN, T_RPAREN, T_SEMI, /* ; */ T_ASSIGN /* = */ } TokenType; typedef struct { TokenType type; int value; /* T_NUM 时存数值 */ char text[MAX_ID_LEN]; /* 原始拼写,关键字和变量名都用 */ int line, col; /* 报错时告诉你位置 */ } Token; #endif

我把T_KW(关键字)和T_ID(标识符)分开,而不是让关键字作为标识符的一个子集,原因是后续涉及变量名与关键字冲突的检查。line和col一定要有——等你在 300 行代码里定位一个语法错误时,就知道位置信息有多救命。

关键字表用静态字符串数组即可,这个规模下不需要哈希。

/* lexer.c 片段 */ static const char *keywords[] = { "int", "return", "if", "else", "while" }; #define NUM_KEYWORDS (sizeof(keywords) / sizeof(keywords[0])) static int lookup_keyword(const char *s) { for (int i = 0; i < NUM_KEYWORDS; i++) { if (strcmp(keywords[i], s) == 0) return 1; } return 0; }

为什么不用哈希或二分?因为小型编译程序的关键字一般不超过 20 个,线性查找一次也就 20 次strcmp,耗时可以忽略。这里强行引入哈希表只会增加代码量和出错面,得不偿失。如果你后续要支持更多关键字,再换成 bsearch 也来得及。

2.2 手写状态机读 token:绕过正则与 flex 的工程化理由

很多教材会推荐用 flex 生成词法分析器,但我更建议你手写。原因有三:一是 flex 生成的 C 代码可读性差,报错信息不友好;二是你没法在扫描过程中灵活插入符号表登记逻辑;三是作为课程设计,手写状态机是明显的加分项,评阅老师一眼就能看出你是真懂还是调包。

这里给出一个核心实现思路:把整个源码读进内存,用pos指针逐字符扫描,配合line和col追踪位置。不要边读文件边用getchar()做词法分析,否则字符回退问题(后面 5.1 会讲)会把你折磨到怀疑人生。

/* lexer.c 片段 */ static char *src; /* 整个源码,读入内存 */ static int pos = 0; /* 当前字符下标 */ static int line = 1; static int col = 0; Token get_token(void) { Token tk; tk.line = line; tk.col = col; /* 跳过空白和换行 */ while (src[pos] == ' ' || src[pos] == '\t') { pos++; col++; } if (src[pos] == '\n') { line++; col = 0; pos++; return get_token(); } if (src[pos] == '\0') { tk.type = T_EOF; return tk; } /* 数字:只支持十进制整数,先不做溢出检测 */ if (isdigit(src[pos])) { tk.type = T_NUM; tk.value = 0; while (isdigit(src[pos])) { tk.value = tk.value * 10 + (src[pos] - '0'); pos++; col++; } return tk; } /* 标识符或关键字 */ if (isalpha(src[pos]) || src[pos] == '_') { int len = 0; while (isalnum(src[pos]) || src[pos] == '_') { if (len < MAX_ID_LEN - 1) tk.text[len++] = src[pos]; pos++; col++; } tk.text[len] = '\0'; tk.type = lookup_keyword(tk.text) ? T_KW : T_ID; return tk; } /* 运算符和界符 */ switch (src[pos]) { case '+': tk.type = T_PLUS; pos++; col++; break; case '-': tk.type = T_MINUS; pos++; col++; break; case '*': tk.type = T_STAR; pos++; col++; break; case '/': tk.type = T_SLASH; pos++; col++; break; case '(': tk.type = T_LPAREN; pos++; col++; break; case ')': tk.type = T_RPAREN; pos++; col++; break; case ';': tk.type = T_SEMI; pos++; col++; break; case '=': tk.type = T_ASSIGN; pos++; col++; break; default: fprintf(stderr, "第 %d 行第 %d 列:无法识别的字符 '%c'\n", line, col, src[pos]); exit(1); } return tk; }

这里有几个关键参数需要说明。MAX_ID_LEN定为 64,意味着变量名最长 63 个字符,超过部分被截断——实际写代码时没人用这么长的变量名,但截断逻辑必须有,否则strcpy会把内存写穿。isdigit、isalpha接收的是 int 类型,而src[pos]是 char,当字符为负值时(非 ASCII 扩展字符)行为是未定义的,所以源文件里最好只允许 ASCII 字符,遇到中文字符直接报错。

数字扫描我特意没做溢出检测,因为 10 * value + digit 在 value 接近 INT_MAX 时会溢出成负数。真实场景里你至少应该判断value > (INT_MAX - digit) / 10,但这个小程序里先记录这个坑,等测试集跑完再补。

2.3 符号表初成型:标识符登记与作用域标记

词法分析阶段其实还不需要完整的符号表,但你应该先把“登记变量”这个动作想清楚。我的做法是:词法阶段只负责把 token 流生成出来,符号表在语法分析和语义检查阶段填写。不过 token 结构体和符号表条目的设计可以提前对齐。

/* symtab.h */ #define SYM_MAX 256 typedef struct { char name[MAX_ID_LEN]; int declared; /* 是否已声明 */ int addr; /* 模拟内存地址,偏移单位字节 */ } SymEntry; static SymEntry symtab[SYM_MAX]; static int sym_count = 0; int lookup_symbol(const char *name) { for (int i = 0; i < sym_count; i++) { if (strcmp(symtab[i].name, name) == 0) return i; } return -1; } int add_symbol(const char *name) { int idx = lookup_symbol(name); if (idx >= 0) return idx; if (sym_count >= SYM_MAX) { fprintf(stderr, "符号表溢出,最多支持 %d 个变量\n", SYM_MAX); exit(1); } strncpy(symtab[sym_count].name, name, MAX_ID_LEN - 1); symtab[sym_count].name[MAX_ID_LEN - 1] = '\0'; symtab[sym_count].addr = sym_count * 4; /* 每个变量占 4 字节 */ sym_count++; return sym_count - 1; }

addr字段是给后面的中间代码生成用的。我设计的是简化内存模型:从地址 0 开始,每个变量固定占 4 字节,这样生成伪汇编时可以直接用变量名对标地址。sym_count * 4这种分配策略虽然粗糙,但对于小型编译程序完全够用。

注意strncpy不会自动补结尾的'\0',所以我在后面手动补了一位。这是 C 语言字符串函数的经典陷阱,所有用到strncpy的地方都要检查这个。符号表数组大小SYM_MAX设为 256 意味着最多支持 256 个变量,真写大型程序不够,但课程设计级别的测试样例绰绰有余。

3. 递归下降语法分析:表达式文法怎么设计才不翻车

语法分析是把 token 流按文法组织成语法树(或者直接驱动翻译)。我选递归下降法,因为它和文法规则一一对应,C 语言写出来天然是函数调用树,也最容易配合后续的中间代码生成。

3.1 文法与优先级:先定义死两级还是三级

考虑表达式a = 1 + 2 * 3;,2 * 3必须先算,这反映在文法里就是优先级层次。我用的是一条简化文法:

program -> stmt_list stmt -> ID ASSIGN expr SEMI expr -> term ( ( PLUS | MINUS ) term )* term -> factor ( ( STAR | SLASH ) factor )* factor -> NUM | ID | LPAREN expr RPAREN

注意我把expr写成term开头再用循环拼接,而不是教科书里常见的左递归形式expr -> expr + term | term。因为递归下降法直接翻译左递归规则会导致无限递归:parse_expr()第一件事是调用parse_expr(),栈瞬间溢出。用循环改写是标准做法,也是在 C 里最容易实现的方案。

这个文法里有两个优先级层次:expr对应加减,term对应乘除。如果需要支持一元负号(-x),再在factor里加一层。三级优先级对小型语言来说已经够用,盲目增加层次会让parse_factor里的分支越来越复杂。

3.2 递归下降实现表达式:每个函数对应一个非终结符

有了 token 流数组tok[]和当前位置pos_tok,每个非终结符写一个解析函数。

/* parser.c */ Token tok[MAX_TOKENS]; int pos_tok = 0; void expect(TokenType t) { if (tok[pos_tok].type == t) { pos_tok++; } else { fprintf(stderr, "第 %d 行:期望 %d,实际 %d(位置 %d)\n", tok[pos_tok].line, t, tok[pos_tok].type, pos_tok); exit(1); } } void parse_factor(void) { if (tok[pos_tok].type == T_NUM || tok[pos_tok].type == T_ID) { pos_tok++; /* 吃掉数字或变量名 */ } else if (tok[pos_tok].type == T_LPAREN) { pos_tok++; /* 吃掉 ( */ parse_expr(); /* 括号里是完整表达式 */ expect(T_RPAREN); } else { fprintf(stderr, "第 %d 行:factor 位置出现非法 token\n", tok[pos_tok].line); exit(1); } } void parse_term(void) { parse_factor(); /* 先解析第一部分 */ while (tok[pos_tok].type == T_STAR || tok[pos_tok].type == T_SLASH) { pos_tok++; /* 吃掉运算符 */ parse_factor(); /* 再解析右侧 */ } } void parse_expr(void) { parse_term(); while (tok[pos_tok].type == T_PLUS || tok[pos_tok].type == T_MINUS) { pos_tok++; parse_term(); } }

关键点在于:parse_term里while循环处理a * b * c这种左结合情况,每遇到一个乘除号就再解析一个factor,而不是递归调用自身,所以不会爆栈。expect函数集中处理 token 类型匹配失败的情况——一开始你可能觉得每次多写一个expect很啰嗦,但等到你调试一个括号不匹配的 bug 时,expect里打出的“期望 X,实际 Y”就是最快的定位线索。

这里还要强调一下模块边界:parse_expr只负责检查语法结构是否正确,不负责计算表达式的值。计算是中间代码生成阶段的事。很多入门实现会在parse_factor里顺手做整数运算,这种“边解析边求值”的方案在表达式上能跑通,但一旦引入变量赋值和控制流就完全没法扩展了。

3.3 错误恢复策略:编译程序不能死在第一个错上

如果遇到一个错误就exit(1),用户改错一次就得重跑一次,这在真实使用中不可接受。更实用的策略是恐慌模式恢复:发现错误时跳过当前语句的部分 token,直到遇到一个可信的同步点(比如分号),然后继续解析下一条语句。

void synchronize(void) { while (tok[pos_tok].type != T_EOF && tok[pos_tok].type != T_SEMI) { pos_tok++; } if (tok[pos_tok].type == T_SEMI) pos_tok++; /* 跳过同步点 */ } /* 在 parse_stmt 的调用处这样用 */ void parse_stmt(void) { if (tok[pos_tok].type == T_ID && tok[pos_tok + 1].type == T_ASSIGN) { pos_tok += 2; /* 吃掉 ID = */ parse_expr(); expect(T_SEMI); } else { fprintf(stderr, "第 %d 行:语句格式错误,已跳过该语句\n", tok[pos_tok].line); synchronize(); } }

synchronize是和expect配合使用的:expect负责精准报错,synchronize负责把解析指针推到一个安全位置。一个小技巧是,错误信息里带上当前 token 的行号——如果没有行号,面对一个 100 行的测试程序,你只能在 VS Code 里数花括号碰运气。

我没有选择恢复后继续解析表达式,因为表达式结构一旦断裂,后续匹配括号和运算符的逻辑全是错的。跳过整条语句虽然粗暴,但至少保证编译器不会陷入死循环,而且能把一个文件里的多个独立错误一次性都报出来。

4. 中间代码生成:三地址码的指令设计与符号表落地

语法分析通过之后,下一步是生成中间代码。我选三地址码(three-address code),因为它每条指令最多只有一个运算符和三个操作数,结构与汇编接近,但不用处理寄存器分配、标志位这些底层细节。输出成线性指令序列即可,后续想转成汇编或解释执行都有现成路线。

4.1 三地址码设计:指令集越小越不容易写崩

/* ir.h */ typedef enum { Q_ASSIGN, /* c = a */ Q_ADD, /* c = a + b */ Q_SUB, /* c = a - b */ Q_MUL, /* c = a * b */ Q_DIV, /* c = a / b */ Q_LABEL, /* 标记位置,用于跳转 */ Q_JMP, /* 无条件跳转到 label */ Q_JMPZ /* if a == 0 跳转到 label */ } QuadOp; typedef struct { QuadOp op; char a[32], b[32], c[32]; } Quad; static Quad quads[4096]; static int nquads = 0;

这里a、b是源操作数,c是目标。Q_ASSIGN只用到a和c,Q_JMPZ用到a和c(因为 c 存标签名)。临时变量命名规则是t0、t1、t2……用一个计数器生成。注意我没让label单独做字符串拼接,而是直接用L0、L1这种可读名字。

指令集为什么这么小?因为小型语言只需要表达式求值和赋值,控制流可以后续再加if和while的跳转。指令类型每多一种,print_assembly和后续解释器就要多一个分支,调试面积成倍增加。先做到能跑,再扩展指令集,这才是 C 项目的正确节奏。

4.2 语法制导翻译:在递归下降里顺手发指令

最简单的方式是在语法分析的函数里直接生成三地址码。给parse_expr_t加上返回值,每个子表达式返回其结果的存放位置(变量名或临时变量名),父表达式生成一条运算指令。

/* 生成代码时的临时变量分配 */ static int temp_count = 0; char *new_temp(void) { static char buf[32]; /* 静态缓冲区,调用方必须立即拷贝 */ snprintf(buf, sizeof(buf), "t%d", temp_count++); return buf; } void emit(QuadOp op, const char *a, const char *b, const char *c) { if (nquads >= 4096) { fprintf(stderr, "四元式数量超过上限\n"); exit(1); } snprintf(quads[nquads].a, sizeof(quads[nquads].a), "%s", a ? a : ""); snprintf(quads[nquads].b, sizeof(quads[nquads].b), "%s", b ? b : ""); snprintf(quads[nquads].c, sizeof(quads[nquads].c), "%s", c ? c : ""); nquads++; } char *parse_expr_t(void) { char *left = parse_term_t(); while (tok[pos_tok].type == T_PLUS || tok[pos_tok].type == T_MINUS) { int op = tok[pos_tok].type; pos_tok++; char *right = parse_term_t(); char tmp[32]; new_temp_to(tmp); /* 把名字写进 tmp */ emit(op == T_PLUS ? Q_ADD : Q_SUB, left, right, tmp); left = strdup(tmp); /* 注意后续要 free */ } return left; } char *parse_factor_t(void) { char tmp[32]; if (tok[pos_tok].type == T_NUM) { snprintf(tmp, sizeof(tmp), "%d", tok[pos_tok].value); pos_tok++; return strdup(tmp); } if (tok[pos_tok].type == T_ID) { int idx = lookup_symbol(tok[pos_tok].text); if (idx < 0) { fprintf(stderr, "第 %d 行:变量 %s 未声明\n", tok[pos_tok].line, tok[pos_tok].text); exit(1); } pos_tok++; return strdup(tok[pos_tok - 1].text); } if (tok[pos_tok].type == T_LPAREN) { pos_tok++; char *e = parse_expr_t(); expect(T_RPAREN); return e; } fprintf(stderr, "第 %d 行:表达式格式错误\n", tok[pos_tok].line); exit(1); }

这段代码里strdup是必须的。我的new_temp返回静态缓冲区,第二次调用就会覆盖第一次的内容——这是 C 语言里最常见的内存管理踩坑点。如果parse_term_t返回的left指向同一块静态内存,后续emit时拿到的值就已经被改写了。strdup每次分配新内存,配合free才能保证每层表达式的名字互不干扰。

关于查符号表:parse_factor_t里遇到T_ID时立即查符号表,未声明直接报错。这一步把语义检查嵌进了语法翻译过程,省掉了一次独立的语义分析遍历。代价是符号表必须在进入表达式翻译前已经填充完毕——所以声明语句的翻译要先于表达式语句执行。

4.3 从三地址码到可执行:一个 printf 输出器就够

生成完四元式,最后一步是把它翻译成一句话能讲清楚的汇编风格代码。这里我不做真实汇编,而是输出一种简化指令,并提供一个模拟器来执行。

void print_assembly(void) { for (int i = 0; i < nquads; i++) { Quad *q = &quads[i]; switch (q->op) { case Q_ASSIGN: printf(" LOAD %s\n STORE %s\n", q->a, q->c); break; case Q_ADD: printf(" LOAD %s\n ADD %s\n STORE %s\n", q->a, q->b, q->c); break; case Q_SUB: printf(" LOAD %s\n SUB %s\n STORE %s\n", q->a, q->b, q->c); break; case Q_MUL: printf(" LOAD %s\n MUL %s\n STORE %s\n", q->a, q->b, q->c); break; case Q_DIV: printf(" LOAD %s\n DIV %s\n STORE %s\n", q->a, q->b, q->c); break; default: break; } } }

这段输出对应的执行模型是:LOAD x把变量 x 的值压入栈,ADD y弹出栈顶与 y 相加后压回,STORE t1弹出栈顶存入 t1。用符号名代替寄存器地址,实现时可以维护一张name -> value的映射表。这条路线比直接生成 x86 汇编简单得多,也更适合验证语义是否正确。

注意print_assembly里的LOAD和STORE参数顺序是我自己定义的。你在自己的实现里要写清楚每个伪指令的语义,否则过两天自己都看不懂。我一般会在代码文件头部注释里写一段伪指令规范,比在代码里到处加注释更有效。

5. 编译程序调试避坑:5 个让 C 实现崩溃的典型错误

这部分全是实操中反复出现的坑。每一条我都按「现象 → 原因 → 解决」来说,照着对照能省下你几个晚上的排查时间。

5.1 词法分析器丢字符

现象:输入1+2;,识别出的数字是1,但+变成+2的第一个字符,或者运算符莫名缺失。

原因:用getchar()边读边扫时,为了判断数字结束多读了一个字符(比如读到+),这个字符没有回退机制,被直接丢弃。ungetc()只能回退一个字符,多层回退或交替回退加前读会互相覆盖。文件缓冲区的处理一旦没走对,这种 bug 就随时冒头。

解决:统一走“整文件读入内存 + 下标扫描”路线(就是 2.2 的做法)。如果坚持用流式读取,务必用ungetc实现精确回退,并且每次回退后立即读回,中间不能夹其他读操作。我自己的血泪经验是:词法分析器绝不边读边处理边界,先把fread或者fgets拿到的缓冲区当作唯一数据源。

5.2 左递归文法导致爆栈

现象:输入a = 1 + 2;,程序在parse_expr里不断递归,最后栈溢出,报Segmentation fault或者直接卡死。

原因:把文法里expr -> expr + term | term这条左递归规则原封不动翻译成递归下降函数,parse_expr每次都调用自己,没有任何基线条件。

解决:用右结合循环改写。parse_expr内先parse_term(),然后while(遇到 + 或 -)继续parse_term()。同一个模式适用于所有优先级层级。顺便说一句,真正无限制的嵌套表达式(比如括号套括号套 100 层)是会让所有递归下降实现爆栈的,但正常代码不可能这么写。

5.3 临时变量名被覆盖

现象:生成的中间代码里t0出现了几十次,但每个t0对应的值来源不同,执行结果错乱。

原因:new_temp()返回静态缓冲区指针,两次调用指向同一个地址。比如parse_expr_t先得到left = "t0",再parse_term_t后生成right = new_temp(),此时left可能已经变成"t1"了。

解决:两个层面。一是new_temp提供出参版本:void new_temp_to(char *out),调用方保证缓冲区足够大;二是所有跨函数传递的临时变量名必须strdup拷贝。后者的代价是内存泄漏风险,所以定制小型语言时要定好规则:临时变量名在emit之后就没人再用了,可在emit内部做一次完全复制,释放由后续统一管理。这里其实是 C 语言指针使用的典型场景,宁可多写一层拷贝,也不要赌调用顺序。

5.4 错误恢复变成死循环

现象:测试程序里有一个括号不匹配,编译器输出几百条重复错误,最后打印“四元式数量超过上限”退出。

原因:synchronize()的跳转条件写错。比如只在while里检查tok[pos_tok].type == T_SEMI,但当前 token 恰好是T_EOF,循环永远不会推进;或者同步点选择得太少,解析器跳不出出错的区域。

解决:synchronize里必须同时检查T_EOF,并且要有“至少前进一个 token”的保障。再加一道保险:设置最大错误数(比如 10 个),超过后打印“错误过多,编译终止”并退出。这道保险不是用来兜底的,而是防止打印缓冲刷屏影响定位真正的问题。

5.5 未声明变量等到代码生成才报错

现象:a = b + 1;中b未声明,编译器运行时出现“符号表查无此项”或者生成了错误的地址。

原因:符号表查询放在emit之后,导致语义错误在四元式已经生成一半时才爆出,此时nquads状态已经乱了。

解决:把符号表查询提前到parse_factor_t(见 4.2)——在生成任何中间代码之前,所有T_ID必须先过lookup_symbol,查不到直接exit(1)。既然是小编译程序,“编译失败”并不是丢人的事,明确报错比吞掉错误继续跑更符合用户预期。这里多说一句:为符号表声明和引用各加一个计数器,诊断信息会更完整,因为你能区分“确实未声明”和“作用域里不可见”两种情况。

6. 从能跑到能用:一个最小测试集与 gdb 验证习惯

程序写完了,怎么证明它是对的?我的习惯是准备两个文件:一个放合法用例,一个放非法用例,然后每次改完代码跑一遍。

test_ok.c内容我一般这么设计:

int a; int b; a = 1 + 2 * 3; b = (a + 4) * 2; return 0;

合法用例要覆盖:数字常量、变量赋值、括号改变优先级、连续加减乘除。test_err.c则准备三类错误:未声明变量、缺分号、右括号缺失。跑非法用例时,重点不是看程序是否退出,而是看错误信息里的行号能不能指到正确位置——这个是靠tok[pos_tok].line打出来的,如果跑到后面几行才报错,说明词法分析的line计数出了问题。

调试时的 gdb 命令我固定用这几条:

gdb ./compiler break parse_factor_t run test_ok.c print pos_tok print tok[pos_tok].type print tok[pos_tok].text next

print tok[pos_tok].type能直接看到当前 token 的类型,配合next单步走到递归深处,很快能定位状态机卡在哪个分支。C 语言调试不依赖 IDE 的高亮提示,gdb 的断点加打印就是最快的定位手段。如果你发现某一行代码改完后所有测试都过,但一加新语法就崩,优先怀疑新增分支里是否有strcpy越界或数组下标越界,用valgrind ./compiler test_ok.c跑一遍会比肉眼快得多。

另一个提升可靠性的习惯是在关键位置加断言。比如emit函数里,nquads每次增加前检查是否小于 4096;parse_factor_t里,pos_tok增加后立刻检查是否大于MAX_TOKENS。这些assert在发布前可以留到#ifdef里,但开发期不要删。我自己的习惯是:宁可运行时崩溃暴露问题,也不要让非法状态悄悄传播到几百行之后才爆。

最后,给临时变量命名加个上限检查。如果生成的t变量超过 1000 个,说明表达式翻译逻辑里有死循环或漏了pos_tok推进。这种异常跑不到编译结束,打印中间四元式列表就能看出来——我一般会在print_assembly里顺带打印nquads,用来快速判断代码生成是否异常膨胀。

以上这套做法,从词法分析到中间代码生成,再到测试和调试,基本就是我用 C 语言实现小型编译程序的完整路径。这种工程看起来难,但每一步拆开都有可验证的产出:词法阶段有 token 列表,语法阶段能画出表达式树,代码生成阶段有伪汇编。沿着这条链路走一遍,你收获的不仅仅是一个能跑的小编译器——你对“一段字符串是怎么变成可执行指令的”这个过程的理解,会彻底从原来的黑匣子变成白盒。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询