多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

C语言实现小型编译器:从词法分析到四元式的完整指南

C语言实现小型编译器:从词法分析到四元式的完整指南 简介面向编译原理课程设计与C语言进阶实践这份资源以小型编译程序的完整实现为主线覆盖词法分析、语法分析、语义分析与四元式生成等核心环节适合计算机专业学生或需要动手理解编译过程的开发者参考。压缩包共4个文件包含C、C源码与MD格式说明文档及LICENSE文件整体仅14KB代码量精炼便于快速阅读与调试。已有288人学习浏览属于轻量但典型的课程设计范例。从源码与文档中可学到如何设计有限状态自动机识别标记、构建递归下降语法分析器以及将表达式和语句转化为四元式中间表示的具体实现思路同时附带错误处理与代码组织方面的参考对完成同类作业或深入理解编译器工作流程很有帮助。1. 这个标题在讲什么为什么 C 语言仍是编译原理实验的默认答案基于 C 语言实现一个小型编译程序是编译原理课程设计里出现频率最高的题目之一要求不高但范围不小从读入源码文件开始经过词法分析、语法分析、符号表管理最后生成可运行的中间代码或目标代码。它能解决的问题很具体——搞懂编译器不是翻阅龙书的玄学而是亲手把字符流一步步变成机器能懂的指令流顺带把 C 语言的指针、内存管理和文件操作全部实练一遍。适合正在做课程设计的学生、想补编译原理短板的在职开发者以及想用 C 语言验证科班知识的人。2. 编译器五段式管线从字符流到目标码C 语言版骨架怎么搭2.1 五段式骨架每段的输入、输出与职责边界一个完整编译器通常被拆成五段词法分析、语法分析、语义分析、中间代码生成、目标代码生成。课程设计的小型编译器一般砍掉最后一层的复杂优化把目标定在「生成四元式中间代码 简单目标代码」上。段与段之间是严格的接力关系前一段的输出是后一段的输入这决定了你的 C 语言数据结构从一开始就要设计对后面改结构体牵扯面非常大。词法分析读入的是整个源码文件输出是 Token 流语法分析吃 Token 流输出是语法树——但很多教学编译器为了减少代码量选择在递归下降分析的同时直接生成四元式跳过显式建树。符号表是全局公共设施词法分析阶段登记标识符语法分析阶段查重和补充类型信息。这个「边分析边生成」的做法对小型编译程序完全够用也是大多数课程设计参考答案采用的路子。语义分析在小编译器里通常不单独设段变量是否声明、类型是否匹配靠符号表在语法分析过程中同步检查。这样做的缺点是代码耦合度高优点是代码量小、答辩时容易讲清楚。真正需要单独检查的只有一件事——变量未声明就使用这个错误必须挂在符号表上才能统一拦住。我一般会在写代码前先画一张管线图把每段的输入输出写在纸上原因很实际C 语言不像 Python 或 Java 那样有现成的容器和垃圾回收机制Token 流怎么存放、符号表怎么扩容、四元式数组怎么动态增长这些在动笔前就得定下来否则写一半重构血泪经验。2.2 为什么选 C指针、内存管理与贴近底层的控制力先回答一个很多人会问的问题编译原理课用 Java 或 Python 的项目也不少为什么教材和课程设计还是偏爱 C 语言三个理由。第一编译器本质上是文本处理程序C 语言指针和数组正好用来操作字符缓冲区、动态增长的结构体符号表用链表还是动态数组、Token 流用 realloc 扩容这些练习本身就是系统编程的基本功。第二真实世界的编译器——TCC、Lua、早期 GCC——都是用 C 写的读它们的源码能直接对照课程知识换成别的语言就少了一层参照。第三C 语言库函数数量少反而逼你把每个环节的数据结构和算法亲手实现不会出现「调库调完什么都不懂」的情况。C 语言的典型取舍在这类项目里体现得很充分内存分配用 malloc/realloc释放要自己管理所有权根本绕不开 C 语言内存管理字符串处理靠 strcmp、strncpy 这类 C 语言字符串函数读取源码文件用 fopen/fgetc依赖标准库的文件缓冲区而不是每次读一个字符都发生一次系统调用。这些恰好是 C 语言编程里最容易考到、也最容易出错的地方。如果你打算拿这份代码去答辩老师大概率会追问「缓冲区怎么设计的」「内存有没有泄漏」后者我会在第五章专门展开。主流程骨架代码如下它把各模块的调用顺序固定下来后续填实现时只需要往对应函数里加内容/* 编译器主流程从源码文件到四元式中间代码 */ #include stdio.h #include stdlib.h static FILE *src; /* 源码文件句柄词法分析用 */ int main(int argc, char *argv[]) { if (argc 2) { fprintf(stderr, 用法: %s 源码文件\n, argv[0]); return 1; } src fopen(argv[1], r); if (src NULL) { perror(打开源码文件失败); return 1; } init_lexer(src); /* 1. 词法分析初始化准备读取 */ init_symbol_table(); /* 2. 符号表清零容量预分配 */ init_quad_list(); /* 3. 四元式动态数组初始化 */ program(); /* 4. 递归下降分析入口 */ print_quads(); /* 5. 输出中间代码 */ print_symbols(); /* 6. 输出符号表便于查错 */ fclose(src); free_all(); /* 统一释放动态内存防泄漏 */ return 0; }逻辑说明main 只做四件事打开文件、初始化三个全局设施、调用语法分析入口 program()、最后把结果打印出来。参数说明上有几个细节值得注意——argc 小于 2 时的报错写到 stderr避免错误混进标准输出fopen 失败后用 perror 打印系统错误原因方便区分是路径问题还是权限问题free_all() 把所有 malloc 出来的内存集中释放比零散 free 更容易保证不泄漏。初始化顺序不严格要求谁先谁后但建议固定不变减少排错时的变量。3. 词法分析器实现Token 结构体、保留字表与手写扫描循环3.1 Token 结构体设计与保留字表行号和原文都要留词法分析器要回答的问题只有一个给定一个字符它是某个单词的开头吗如果是这个单词有多长、属于哪一类所以 Token 结构体至少要有四样东西类型、单词原文、行号、以及数字字面量的值。行号容易被初学者忽略但语法分析报错时「第几行」全靠它漏掉行号的编译器调试起来会让人抓狂。/* Token 类型关键字、标识符、数字、运算符、文件结束、错误 */ typedef enum { TOK_IDENT, /* 标识符变量名、函数名 */ TOK_NUMBER, /* 整型数字字面量 */ TOK_KEYWORD, /* 保留字int if else while 等 */ TOK_OP, /* 运算符 - * / */ TOK_EOF, /* 文件结束 */ TOK_ERROR /* 无法识别的字符 */ } TokenType; typedef struct { TokenType type; char lexeme[64]; /* 单词原文最长 63 字符加结束符 */ int line; /* 单词所在行号报错用 */ int value; /* 仅数字字面量使用存整数值 */ } Token;参数说明lexeme 定长数组是最省事的做法代价是标识符长度超限时需要截断处理如果不想定长可以用 char* 加 malloc但每次扫描都要算长度、分配、拷贝还要记得释放课程设计没必要。line 字段初始化时要赋当前行号很多翻车案例是这里漏赋值导致所有报错行号都变成 0。value 字段用 int 对应整型字面量想支持浮点得改成 double 外加一个标志位一般不用做这个扩展。保留字表用静态数组加 strcmp 线性查找就够不需要上哈希/* 保留字表按字典序排列查找走线性扫描 */ static const char *keywords[] { else, float, if, int, return, void, while }; #define KEYWORD_COUNT (sizeof(keywords) / sizeof(keywords[0])) static int is_keyword(const char *s) { for (int i 0; i KEYWORD_COUNT; i) { if (strcmp(s, keywords[i]) 0) return 1; } return 0; }说明这里用 strcmp 而不是 strncmp因为传入的 s 是已经用 \0 结尾的 lexeme不会越界。KEYWORD_COUNT 用 sizeof 计算后面增减关键字不用改第二处。线性查找性能足够源码里关键字出现次数最多几百次为这个上哈希属于过早优化。3.2 扫描主循环最长匹配、回退字符与文件缓冲区的配合词法分析的核心循环可以概括成「看一个字符决定走哪条分支多读的字符推回去」。推回操作用 ungetc(c, src) 实现它依赖 stdio 的文件缓冲区把字符留在内存里而不是真的回到磁盘——所以 C 语言读文件天然快fgetc 看似一次读一个字节底层其实是整块读入缓冲。手写扫描器时这里最容易出细节问题多读了一个字符不推回Token 边界就会错位推回太频繁缓冲区也可能出问题。先写跳过空白和注释的辅助函数它直接决定行号统计是否正确/* 跳过空白与注释返回下一个有效字符 */ static int skip_ws_and_comment(void) { int c; for (;;) { c fgetc(src); if (c /) { /* 可能是注释或除法 */ int next fgetc(src); if (next /) { /* 行注释跳到行尾 */ while ((c fgetc(src)) ! \n c ! EOF) { } if (c \n) current_line; continue; } else if (next *) { /* 块注释一直找到结束符 */ c fgetc(src); while (c ! EOF) { if (c \n) current_line; int d fgetc(src); if (c * d /) break; c d; } if (c EOF) error(块注释未闭合); continue; } else { /* 普通除法推回 next */ ungetc(next, src); return /; } } if (c \n) current_line; /* 统一在这里分行号 */ if (c || c \t || c \r) continue; return c; /* EOF 或有效字符都到这里 */ } }逻辑说明行号只在一个地方递增就是 \n 出现时这样行注释和块注释内的换行也不会漏统计。注释结束符的判断用两个字符配对写完记得自测「没闭合的注释要报错而不是死循环」。参数方面c 声明为 int 而不是 char这是 fgetc 返回值的要求——EOF 是负数用 char 会截断成其他字符这个细节我会在第五章再提醒一次。然后是主扫描函数/* 读取下一个 Token主扫描循环 */ Token next_token(void) { Token tok; memset(tok, 0, sizeof(tok)); tok.line current_line; int c skip_ws_and_comment(); /* 已经跳过空白和注释 */ if (isalpha((unsigned char)c) || c _) { /* 标识符或保留字 */ int len 0; while (isalnum((unsigned char)c) || c _) { if (len 63) /* 超长部分截断保证不越界 */ tok.lexeme[len] (char)c; c fgetc(src); } if (c ! EOF) ungetc(c, src); /* 多读的字符推回 */ tok.lexeme[len] \0; tok.type is_keyword(tok.lexeme) ? TOK_KEYWORD : TOK_IDENT; return tok; } if (isdigit((unsigned char)c)) { /* 整型字面量 */ int val 0; while (isdigit((unsigned char)c)) { val val * 10 (c - 0); c fgetc(src); } if (c ! EOF) ungetc(c, src); snprintf(tok.lexeme, sizeof(tok.lexeme), %d, val); tok.type TOK_NUMBER; tok.value val; return tok; } /* 运算符先试双字符 ! 再退回单字符 */ if (c || c ! || c || c ) { int next fgetc(src); if (next ) { snprintf(tok.lexeme, sizeof(tok.lexeme), %c%c, c, next); tok.type TOK_OP; return tok; } if (next ! EOF) ungetc(next, src); tok.lexeme[0] (char)c; tok.lexeme[1] \0; tok.type TOK_OP; return tok; } if (c EOF) { tok.type TOK_EOF; strcpy(tok.lexeme, EOF); return tok; } snprintf(tok.lexeme, sizeof(tok.lexeme), 未识别字符 %c, c); tok.type TOK_ERROR; return tok; }逻辑说明三个分支都严格遵循「最长匹配」——标识符会一直吃合法字符直到遇到分隔符数字会把连续数字全部吃掉再推回运算符优先匹配双字符。注意我统一在推回前判断了c ! EOF否则 ungetc 收到 EOF 属于未定义行为在部分编译器上会静默出错。参数说明isalpha、isdigit 传参时要转成 unsigned char这是 ctype 函数的安全用法避免负数下标问题lexeme 在截断后仍然写 \0保证字符串函数永远安全。4. 递归下降与四元式语法分析、符号表和中间代码的配合4.1 递归下降分析器为什么课程设计不选 yacc很多课程允许用 lex/yacc 生成分析器但我建议手写递归下降理由很直接yacc 生成的解析器对你是个黑匣子一旦状态冲突报错排查成本比手写高一个数量级。递归下降把一个产生式对应成一个 C 函数语法分析规则和代码结构一一对应答辩时老师一问「 if 语句怎么匹配」你能直接指出代码行。代价是要自己处理文法设计——最典型的是左递归不改写就会死循环下面这段就是改写后的表达式文法。/* 表达式递归下降E - T { (|-) T } * 每个函数通过 out 参数返回本次解析结果的变量名 */ static void expr(char *out) { char left[32], right[32], tmp[32]; term(left); /* 解析第一个 T */ while (lookahead.type TOK_OP (lookahead.lexeme[0] || lookahead.lexeme[0] -)) { char op[8]; snprintf(op, sizeof(op), %c, lookahead.lexeme[0]); advance(); /* 消耗运算符推进 Token */ term(right); /* 解析右侧 T */ snprintf(tmp, sizeof(tmp), t%d, next_temp()); emit_quad(op, left, right, tmp); /* tmp left op right */ snprintf(left, sizeof(left), %s, tmp); /* 结果参与后续运算 */ } snprintf(out, 32, %s, left); }逻辑说明原始文法 E - E T 是左递归递归下降会无限调用自己改写为 E - T { (|-) T } 后用 while 循环表达「任意多个加减项」彻底避免栈溢出。每个函数通过 out 参数回传结果名而不是直接 return char*是为了避免返回局部数组地址形成悬垂指针。参数说明lookahead 是全局 Token作为当前待处理符号advance() 每次只消费一个 Token同时更新 lookahead——这两个变量必须严格配对否则分析位置错乱我在第五章会讲具体表现。term 和 factor 的结构与 expr 完全相同factor 负责处理标识符、数字字面量和括号这里不重复贴。4.2 符号表动态数组和链表怎么选符号表是语义检查的依靠设计上两条路动态数组适合「查询为主」的场景链表适合「作用域不断嵌套推入弹出」的场景。课程设计的小型语言作用域最多两层全局 函数体用动态数组加 scope 字段就够了链表反而要在出作用域时逐个删除代码更碎。typedef struct { char name[64]; /* 变量名作为主键 */ int type; /* INT_TYPE0, FLOAT_TYPE1, VOID_TYPE2 */ int scope; /* 0全局1第一个局部作用域 */ int offset; /* 相对活动记录基址的偏移目标生成用 */ } Symbol; typedef struct { Symbol *items; /* 动态数组首地址 */ int count; /* 当前符号数 */ int capacity; /* 已分配容量 */ } SymbolTable; /* 查找线性扫描找到返回下标否则返回 -1 */ static int find_symbol(const char *name) { for (int i 0; i g_symtab.count; i) { if (strcmp(g_symtab.items[i].name, name) 0) return i; } return -1; } /* 插入容量不足先倍增扩容再写入 */ static int add_symbol(const char *name, int type, int scope) { if (g_symtab.count g_symtab.capacity) { int cap g_symtab.capacity ? g_symtab.capacity * 2 : 16; g_symtab.items realloc(g_symtab.items, cap * sizeof(Symbol)); if (g_symtab.items NULL) { error(符号表扩容失败); return -1; } g_symtab.capacity cap; } Symbol *s g_symtab.items[g_symtab.count]; snprintf(s-name, sizeof(s-name), %s, name); s-type type; s-scope scope; s-offset 0; /* offset 由语义分析阶段统一分配 */ return g_symtab.count - 1; }逻辑说明realloc 的返回值必须重新赋给 items而不是沿用旧指针——这是 C 语言内存管理里最经典的坑realloc 移动内存块后旧指针直接失效。初始容量 16后续每次翻倍摊还复杂度是 O(1)。参数说明offset 字段现在用不到但在目标代码生成阶段要算变量在栈帧里的位置所以提前留好scope 字段除了区分全局变量和局部变量还在报错时提示「变量作用域」答辩时很加分。注意查找没有区分 scope小型语言里两个同名不同作用域的变量按先找到者处理如果要做严格作用域规则得在 find 时加一层 scope 过滤代码量不大但容易被忽略。4.3 四元式三地址码的数据结构与生成时机四元式是中间代码的常见形式每条指令由 op、arg1、arg2、result 四个字段组成本质是三地址码。它离目标代码近、又能跨指令集复用教学编译器几乎都选它作为输出终点。数据结构定义如下/* 四元式op arg1 arg2 result即三地址码 */ typedef struct { char op[8]; /* - * / jmp j 等 */ char arg1[32]; /* 第一操作数变量名、常量或临时量 */ char arg2[32]; /* 第二操作数单目运算填空串 */ char result[32]; /* 结果通常是一个新的临时变量 */ } Quad;生成时机有两个选择语法分析过程中边归约边生成或者先建语法树再遍历生成。后者结构清晰但要多实现一套树节点和遍历逻辑前者代码紧凑、中间结果不需要落树更符合「小型」的定位。我通常选前者但会把 emit_quad 单独封装成函数这样以后想改成先建树再生成也能隔离改动面。看一个表达式 a b c * 2 生成的四元式序列就能直观理解三地址码的形态序号oparg1arg2result1*c2t12bt1t23t2a三条指令对应一个表达式每个子表达式的结果都落入新的临时变量 t1、t2。这个设计的好处是每条指令至多一次运算翻译成汇编时几乎一一对应坏处是临时变量数量膨胀所以进阶阶段会做临时变量合并——那部分在第六章提一句初版不要花时间做。5. 避坑与排查编译器课程设计最容易翻车的 5 个点这一章是真实做题才会遇到的排错经验每条都按「现象 → 原因 → 解决」记录。我见过太多小组把八成时间耗在最后一步才发现是前端的低级问题所以这些坑值得提前看。5.1 标识符超长导致缓冲区越界程序崩溃或输出乱码现象源码里写了一个特别长的变量名编译器运行到一半崩溃或者符号表里出现乱码符号。原因扫描循环里没有检查 len 是否超过 63fgetc 不停往里写最后还在越界位置写 \0破坏了相邻内存。解决写入前判断if (len 63)超长部分继续读完但不再写入循环结束后统一在 len 处写 \0。我的习惯是同时用下标保护宏定义缓冲区大小比如#define LEXEME_MAX 64避免魔法数字散落各处。5.2 advance 和 ungetc 不配对Token 流静默错位现象语法分析读到的运算符总是偏一个 Token表达式a b被解析成a和b两个独立项中间没有加法。原因某处扫描多读了一个字符后没有 ungetc 推回或者语法分析里手动移动了 lookahead 而没有调用 advance。Token 流一旦错位后续所有分析都在错误的节奏上进行错误信息还经常指向错误行。解决约定「只有 advance() 能推进 Token只有扫描器内部能用 ungetc」语法分析代码里不允许直接操作 lookahead 的指针或下标。在 advance 里加一行调试宏开关开启后打印消费的 Token 原文排查时一秒定位。5.3 左递归文法未消除递归下降直接栈溢出现象程序一解析表达式就卡死控制台疯狂输出然后段错误gdb 里 bt 看到 expr 函数反复出现在栈帧里。原因文法写成 E - E T 这种左递归形式递归下降每次调用 expr 都先再调一次 expr永不走到终结符栈被撑爆。解决改写为 E - T { (|-) T }用循环替代左递归。写任何一条文法前先检查左部符号是不是出现在产生式最左边如果是要么拆成等价的循环写法要么用 EBNF 的重复语法。这是语法分析阶段最值得花十分钟提前想清楚的问题。5.4 换行符污染行号报错定位全偏现象在 Windows 上编写的源码文件编译器报错行号与实际行号差好几行或者块注释跨行后行号完全混乱。原因Windows 文本文件换行是 \r\n 两个字符如果只在 \n 加行号\r 会被当成普通空白跳过逻辑上没错但如果在注释处理里也依赖行号递增某个分支少处理了 \r行号就对不齐。解决跳过空白时统一处理 、\t、\r、\n行号只在 \n 单独加一。文件打开方式用文本模式即fopen(path, r)而不是 rb让标准库先把 \r\n 转换掉省掉手工处理。5.5 realloc 返回值没接住悬垂指针和内存泄漏一起出现现象编译大一点的文件时符号表内容错乱或者 valgrind 报 invalid read / definitely lost。原因realloc失败或移动内存块后旧指针失效但代码里还在用旧指针读写另外插入符号时如果 realloc 失败直接返回count 已经增加符号表状态就不一致。解决realloc 结果必须赋值给 items 并判空失败时给出错误信息而不是继续写。小程序也要养成在退出前统一 free 的习惯把清理集中在 free_all() 里Valgrind 看结果会干净很多。至于找泄漏或越界我一般用 gdb 工具调试 C 语言程序的段错误gdb ./mycc运行后bt看栈帧十次有九次能直接指出问题函数。6. 验证与进阶最小测试集锁定回归、错误恢复与常量折叠做编译器最容易犯的错是「改一处坏一片」。语法分析加了一个新的语句类型结果原来能编译的表达式崩了——这种回归靠人眼盯不出来必须有一组最小测试集自动跑。我的做法是建 tests/ 目录里面放十几个小文件每个覆盖一个语法点再手工确认一遍期望输出存成 golden 文件之后每改一次代码就全量跑一遍。测试输入期望输出覆盖点a 1 2;t0 1 2; a t0基本运算与赋值if (a 3) b 4;条件跳转四元式分支与回填while (i 10) i i 1;循环入口标签与回边循环结构int x; x x 1;符号表登记与查重变量声明与使用回归脚本用 bash 写十几行就够遍历每个用例用 diff 对比输出失败时打印用例名和差异位置。这样跑一轮下来改动是否破坏原有功能一清二楚。两个值得做的进阶点按投入产出排序。第一是错误恢复。现在大多数实现的词法或语法错误都是「报错即停」体验很差panic mode 的做法是遇到错误后跳过一批 Token直到碰见分号或右花括号这类同步点再继续分析。实现量不大却是答辩时能讲出亮点的部分。第二是常量折叠。四元式生成后扫一遍如果某条四元式的 arg1 和 arg2 都是数字字面量就直接在编译期算出结果替换进去比如 t1 2 * 3 直接变成 t1 6。这个优化在四元式数组上就能做不需要建数据流分析框架。我现在接手任何编译器代码第一件事永远是先把测试集补起来再读实现——没有回归保护的编译器改动就是在赌博。这个习惯救过我很多次尤其是课程设计后期临时加功能的时候。希望帮到你。本文还有配套的精品资源点击获取
返回列表