ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

手写C语言编译器实战:从词法分析到栈式虚拟机

手写C语言编译器实战:从词法分析到栈式虚拟机 简介这是一份面向计算机专业学生与编译原理课程设计学习者的C语言编译器完整实现方案围绕词法分析、语法分析、中间代码生成与优化、目标代码生成等核心环节展开适合正在完成课程设计或希望深入理解编译流程的读者参考。压缩包共54个文件约5.1MB包含cpp与h源码、c与l词法文件、y语法文件、obj与pdb编译产物、asm汇编码、py脚本及txt说明文档等覆盖从源码到可执行程序的完整工程结构。项目借助lex与yacc完成词法分析与语法分析并生成语法树使用C解析语法树、生成中间代码并实现错误检测与优化再通过Python处理中间代码生成MIPS汇编码最终可在PCSpim模拟器上运行。目前已有505人学习下载读者可从中获取编译器各阶段的实现思路、模块划分方式与调试排错经验适合作为课程设计参考与编译原理实践素材。1. 从「基于C语言编译器」说起一个被低估的硬核练手方向很多人第一次看到「基于C语言编译器」这个说法会下意识以为是要用 C 语言去写一个编译器或者干脆把它和「C语言编译器」这个工具混为一谈——毕竟日常里我们说的「装个 C 语言编译器」指的是 gcc、clang、MSVC 这类现成的工具链。但真正让这个方向有价值的是另一层意思把编译器当成一个可以拆开、可以自己动手重建的系统用 C 语言作为实现语言从词法、语法一路做到能跑出目标代码。这件事听起来离业务很远可它恰恰是少数几个能同时训练数据结构、内存管理、递归下降、栈式机模型和工程调试的题目做完一遍你对「代码到底怎么变成机器能跑的东西」会从玄学变成可解释的流程。这篇文章面向的是想真正动手写一个能跑的小型编译器、又不想一上来就被 LLVM 那种体量劝退的从业者。我会按「先立住理论、再动手复现」的顺序把词法分析、语法分析、语义检查、三地址码生成、栈式虚拟机执行这条链路拆开讲每一步都给可抄的代码骨架和参数说明。读完你应该能自己写出一个支持变量声明、算术表达式、if/while 和函数调用的最小编译器并且知道每一步最容易在哪里翻车。2. 编译器的四段流水线为什么我坚持手写而不是直接上工具2.1 词法、语法、语义、代码生成各自负责什么一个能跑的最小编译器本质上是把源字符串逐层降级成可执行结构。第一层是词法分析把int a 1 2;切成int、a、、1、、2、;这样的 token 序列同时丢掉空格和注释。第二层是语法分析把 token 序列按文法组织成抽象语法树AST比如把1 2变成一个BinaryExpr(, 1, 2)节点。第三层是语义检查确认变量先声明后使用、类型匹配、函数参数个数对得上。第四层是代码生成把 AST 翻译成三地址码或栈式指令交给虚拟机执行。这四层之所以要分开是因为每一层的输入输出边界清晰调试时能单独定位。我见过太多人把词法和语法揉在一个函数里结果遇到一个括号不匹配的报错根本分不清是切词切错了还是文法写错了。分开之后词法层只对字符负责语法层只对 token 负责语义层只对 AST 负责出问题时逐层打印中间结果定位速度差一个数量级。选 C 语言来实现理由也很实际编译器本身要频繁操作指针、链表、动态数组和递归C 能让你直接看到内存布局写错了段错误会立刻教你做人。用高级语言写当然更快但你会错过「AST 节点怎么分配、怎么释放、递归深度多大会爆栈」这些真正长本事的细节。2.2 用 C 写词法分析器状态机与 token 结构词法分析器的核心是一个状态机读一个字符、决定下一步状态、必要时回退。下面是一个能处理标识符、整数、运算符和分隔符的最小实现骨架。#include ctype.h #include stdio.h #include stdlib.h #include string.h typedef enum { TOK_INT, TOK_IDENT, TOK_NUM, TOK_PLUS, TOK_MINUS, TOK_STAR, TOK_SLASH, TOK_ASSIGN, TOK_SEMI, TOK_LPAREN, TOK_RPAREN, TOK_LBRACE, TOK_RBRACE, TOK_EOF, TOK_UNKNOWN } TokenType; typedef struct { TokenType type; char text[64]; // 标识符或数字的字面量 int value; // 数字 token 的整数值 int line; // 行号报错时定位用 } Token; static const char *src; // 当前扫描位置 static int cur_line 1; // 跳过空白同时维护行号 static void skip_ws(void) { while (*src || *src \t || *src \n) { if (*src \n) cur_line; src; } } Token next_token(void) { skip_ws(); Token t {0}; t.line cur_line; if (*src \0) { t.type TOK_EOF; return t; } if (isalpha(*src) || *src _) { int n 0; while (isalnum(*src) || *src _) t.text[n] *src; t.text[n] \0; t.type (strcmp(t.text, int) 0) ? TOK_INT : TOK_IDENT; return t; } if (isdigit(*src)) { int v 0; while (isdigit(*src)) v v * 10 (*src - 0); t.type TOK_NUM; t.value v; return t; } switch (*src) { case : t.type TOK_PLUS; break; case -: t.type TOK_MINUS; break; case *: t.type TOK_STAR; break; case /: t.type TOK_SLASH; break; case : t.type TOK_ASSIGN; break; case ;: t.type TOK_SEMI; break; case (: t.type TOK_LPAREN; break; case ): t.type TOK_RPAREN; break; case {: t.type TOK_LBRACE; break; case }: t.type TOK_RBRACE; break; default: t.type TOK_UNKNOWN; break; } return t; }这段代码里几个参数值得说清楚。text[64]是标识符缓冲实际项目里应该改成动态分配或加大到 256否则遇到长变量名会截断。value只在数字 token 里有效其他类型不读它。line是给报错用的没有行号的编译器在调试时基本等于黑匣子。skip_ws里对\n单独计数是因为后面语法报错要精确到行。一个常见误区是把关键字识别放在语法层。关键字本质上是「长得像标识符但被保留的词」在词法层用strcmp一次性判定语法层就只需要处理TOK_INT这种明确类型逻辑干净很多。另一个坑是数字溢出上面用int累加输入99999999999会静默溢出生产级实现要加范围检查或改用long long。2.3 递归下降语法分析把 token 流变成 AST语法分析我用递归下降因为它的代码结构和文法几乎一一对应新手最容易看懂。先定义 AST 节点类型。typedef enum { NODE_NUM, NODE_VAR, NODE_BINOP, NODE_ASSIGN, NODE_IF, NODE_WHILE, NODE_BLOCK, NODE_DECL } NodeKind; typedef struct Node { NodeKind kind; int op; // 运算符 - * / int value; // NODE_NUM 的数值 char name[64]; // NODE_VAR / NODE_DECL 的变量名 struct Node *left; struct Node *right; struct Node *cond; // if / while 的条件 struct Node *body; // if / while 的循环体 struct Node *next; // 语句链表的下一条 } Node; static Token cur; // 当前前瞻 token static void advance(void) { cur next_token(); } static Node *new_node(NodeKind k) { Node *n calloc(1, sizeof(Node)); n-kind k; return n; } // 表达式处理加减左结合 static Node *parse_expr(void); static Node *parse_primary(void) { if (cur.type TOK_NUM) { Node *n new_node(NODE_NUM); n-value cur.value; advance(); return n; } if (cur.type TOK_IDENT) { Node *n new_node(NODE_VAR); strcpy(n-name, cur.text); advance(); return n; } if (cur.type TOK_LPAREN) { advance(); Node *n parse_expr(); if (cur.type ! TOK_RPAREN) { fprintf(stderr, line %d: expected )\n, cur.line); exit(1); } advance(); return n; } fprintf(stderr, line %d: unexpected token\n, cur.line); exit(1); } static Node *parse_term(void) { Node *left parse_primary(); while (cur.type TOK_STAR || cur.type TOK_SLASH) { Node *n new_node(NODE_BINOP); n-op (cur.type TOK_STAR) ? * : /; advance(); n-left left; n-right parse_primary(); left n; } return left; } static Node *parse_expr(void) { Node *left parse_term(); while (cur.type TOK_PLUS || cur.type TOK_MINUS) { Node *n new_node(NODE_BINOP); n-op (cur.type TOK_PLUS) ? : -; advance(); n-left left; n-right parse_term(); left n; } return left; }这里的关键设计是parse_expr和parse_term分层保证乘除优先级高于加减。cur是全局前瞻 tokenadvance每次向前读一个。parse_primary处理括号时递归调用parse_expr这样(12)*3能正确解析成先加后乘。参数上要注意op用字符表示运算符简单直观但如果以后要支持、这种多字符运算符就得改成枚举。name[64]同样有截断风险。递归下降最大的坑是左递归文法比如把表达式写成expr - expr term直接翻译成代码会无限递归必须改写成循环形式上面while循环就是标准解法。3. 从 AST 到可执行三地址码生成与栈式虚拟机3.1 三地址码为什么比直接生成汇编更适合练手AST 到机器码之间我强烈建议插一层三地址码TAC。三地址码的形式是t1 a b每条指令最多一个运算符、三个操作数。它的好处是结构规整方便做常量折叠、公共子表达式消除这类优化和具体 CPU 解耦你生成的 TAC 可以喂给任何后端调试时打印出来一目了然比看汇编舒服得多。生成 TAC 的过程就是后序遍历 AST。遇到NODE_BINOP先递归生成左右子树的 TAC拿到两个临时变量名再发一条新指令。临时变量用一个自增计数器命名t0、t1、t2这样。typedef struct { char op; // 运算符0 表示赋值 char dst[16]; // 目标 char src1[16]; // 左操作数 char src2[16]; // 右操作数 } TAC; static TAC code[4096]; static int code_len 0; static int tmp_cnt 0; static void emit(char op, const char *dst, const char *s1, const char *s2) { TAC *t code[code_len]; t-op op; strncpy(t-dst, dst, 15); strncpy(t-src1, s1, 15); strncpy(t-src2, s2, 15); } // 返回该子树结果所在的临时变量名 static void gen_expr(Node *n, char *out) { if (n-kind NODE_NUM) { sprintf(out, %d, n-value); return; } if (n-kind NODE_VAR) { strcpy(out, n-name); return; } if (n-kind NODE_BINOP) { char l[16], r[16]; gen_expr(n-left, l); gen_expr(n-right, r); sprintf(out, t%d, tmp_cnt); emit(n-op, out, l, r); return; } }code[4096]是固定上限练手够用真实编译器要用动态数组。tmp_cnt全局自增保证临时变量不重名。gen_expr把结果写进out参数调用方负责提供缓冲区这种「输出参数」风格在 C 里很常见但要小心缓冲区大小sprintf写t%d最多几个字符char[16]足够。一个容易忽略的点是常量折叠。1 2其实可以在生成阶段直接算成3省一条指令。做法是在NODE_BINOP里判断左右是否都是NODE_NUM是就直接算。这个优化不加也能跑但加上之后你能直观看到 TAC 条数减少是理解「优化到底在优化什么」的最好入口。3.2 栈式虚拟机二十行代码跑起你的第一个程序有了 TAC执行引擎可以做得极简。栈式虚拟机的模型是所有操作数从栈顶取结果压回栈顶。变量存在一个数组里用名字映射到下标。#define STACK_MAX 1024 #define VAR_MAX 256 static int stack[STACK_MAX]; static int sp 0; static int vars[VAR_MAX]; static char var_names[VAR_MAX][64]; static int var_cnt 0; static int var_slot(const char *name) { for (int i 0; i var_cnt; i) if (strcmp(var_names[i], name) 0) return i; strcpy(var_names[var_cnt], name); return var_cnt; } static int resolve(const char *s) { if (s[0] t isdigit(s[1])) return atoi(s 1) 1000; // 临时变量区 if (isdigit(s[0]) || (s[0] - isdigit(s[1]))) return atoi(s); return var_slot(s); } static void run(void) { for (int i 0; i code_len; i) { TAC *t code[i]; int a resolve(t-src1); int b resolve(t-src2); int d resolve(t-dst); int va (a 1000) ? stack[a - 1000] : (a 0 ? a : vars[a]); int vb (b 1000) ? stack[b - 1000] : (b 0 ? b : vars[b]); int r 0; switch (t-op) { case : r va vb; break; case -: r va - vb; break; case *: r va * vb; break; case /: r vb ? va / vb : 0; break; case 0: r va; break; // 赋值 } if (d 1000) stack[d - 1000] r; else vars[d] r; } }resolve把操作数字符串映射成存储位置临时变量映射到stack的 1000 号以上区间普通变量映射到vars数组数字字面量直接返回负值表示立即数。run顺序执行每条 TAC按op分派运算。这里有个设计取舍临时变量和普通变量分开存储是为了避免临时变量污染变量表。真实实现里更常见的做法是统一用栈帧函数调用时压栈、返回时弹栈。上面这个简化版不支持函数但足够跑通int a 1 2 * 3;这类程序。除零我直接返回 0生产环境应该抛运行时错误。4. 避坑与排查手写编译器最容易翻车的五个地方4.1 段错误AST 节点没初始化就访问现象程序在解析阶段随机崩溃gdb 回溯指向parse_primary或gen_expr。原因new_node用了calloc还好但如果换成mallocleft、right、next全是野指针递归访问时直接段错误。解决统一用calloc或者malloc之后手动把指针字段置 NULL。我一般会在new_node里加一句memset省得后面到处补。4.2 无限递归文法写成左递归现象解析123时栈溢出程序卡死。原因把表达式文法直接写成expr - expr term递归下降遇到左递归会无限调用自己。解决改写成循环parse_expr里先解析一个term然后while吃掉后续的/-。这是递归下降的经典约束写文法时就要避开左递归。4.3 临时变量重名计数器作用域搞错现象嵌套表达式的结果互相覆盖(12)*(34)算出来是错的。原因tmp_cnt如果是局部变量每次递归都从 0 开始生成的临时变量名重复。解决tmp_cnt必须是全局或贯穿整个生成过程的上下文保证每次sprintf出来的名字唯一。这个坑很隐蔽因为单层表达式不会暴露一嵌套就翻车。4.4 变量未声明就使用语义检查缺失现象程序里写a 1;但没写int a;虚拟机里var_slot自动创建了变量程序照跑不误。原因没有独立的语义检查阶段变量表在运行时才建立。解决在生成 TAC 之前遍历 AST维护一个已声明变量集合遇到NODE_VAR就查表查不到直接报错并给出行号。语义检查越早做运行时越干净。4.5 缓冲区溢出标识符和数字没做长度检查现象输入一个 100 字符的变量名程序行为异常或崩溃。原因text[64]、name[64]这些固定缓冲区在strcpy时溢出。解决词法层扫描标识符时加长度上限超过就报错或者改用动态分配。练手阶段至少要把strcpy换成strncpy并手动补\0这是血泪经验别等线上崩了才想起来。5. 进阶技巧用差分测试验证你的编译器写到能跑之后怎么确认它是对的我一般用差分测试拿同一段源码分别喂给你的编译器和 gcc比较运行结果。具体做法是准备一批小程序每个程序打印一个整数比如int main(){ int a3; int b4; printf(%d, a*b1); }你的编译器跑出结果gcc 编译同一段代码也跑出结果两者必须一致。# 批量差分测试脚本骨架 for f in tests/*.c; do gcc $f -o /tmp/ref 2/dev/null ref$(/tmp/ref) mine$(./mycc $f 2/dev/null) if [ $ref ! $mine ]; then echo MISMATCH: $f ref$ref mine$mine fi done这个脚本遍历tests/下所有.c文件gcc 编译后运行拿到参考输出你的编译器也跑一遍不一致就打印出来。参数上要注意测试用例要覆盖边界比如负数、除零、深层嵌套括号、多变量赋值。差分测试的价值在于你不需要手动推导每个程序的正确结果让 gcc 当裁判你只需要盯着不一致的用例去查。我自己的习惯是每加一个语法特性就往tests/里丢五个用例跑一遍差分。有一次加了while循环差分测试立刻抓出一个循环条件求值顺序的 bug手动测根本发现不了。写编译器这件事后悔药就是测试用例提前备好比事后 debug 省太多时间。希望帮到你。本文还有配套的精品资源点击获取
返回列表