简介:这是一份面向编译器与解释器入门学习者的C语言实践资源,围绕「用500多行代码实现微型解释器」展开,适合已掌握C语言基础语法、希望理解词法分析、语法分析与AST构建等编译原理核心概念的开发者练手。压缩包共10个文件,约25KB,以7个Markdown文档为主体,系统梳理解释器构造思路与关键步骤,另含1个C源码文件、1个.try示例脚本及1个license授权文件,结构轻量、便于快速通读与二次修改。目前已有229人学习下载,可作为编译原理课程的配套实验素材。读者可从中获得从词法分析到执行阶段的完整实现脉络,理解标记流、抽象语法树与逐行解释的运行机制,并借助示例脚本验证解释器行为,在有限代码量内体会精简设计与错误处理的取舍,为深入编译器设计打下基础。
1. 500 行 C 语言写一个微型解释器,到底能跑多远
很多人第一次听到「用 C 语言写解释器」,脑子里浮现的是几万行的编译器工程,觉得这是只有科班出身才敢碰的东西。但 tryC 这个方向恰恰相反:它把词法分析、语法分析、AST 求值压缩到 500 多行 C 代码里,跑通一个支持变量、四则运算、括号、比较和条件分支的微型解释器。你不需要先啃完《编译原理》,只要熟悉 C 语言基础、指针和结构体,就能跟着把整条链路走一遍。
它解决的不是「造一个生产级语言」的问题,而是让你真正理解一行1 + 2 * 3从字符串变成结果,中间到底发生了什么。适合两类人:一类是刚学完 C 语言基础、想找个能落地的项目把指针和内存管理练熟的;另一类是想搞懂解释器黑匣子、但被大部头教材劝退的从业者。500 行不是噱头,它意味着每个函数你都能读懂,每处内存分配你都能追踪,翻车了也能自己查。
2. 解释器的四段流水线:从字符流到结果
2.1 为什么是「词法 → 语法 → AST → 求值」而不是边读边算
最常见的错误做法是拿到字符串直接switch字符,遇到数字就atoi,遇到+就弹栈计算。这种写法在只支持1+2时能跑,一旦加上括号、优先级、变量赋值就彻底失控。原因很简单:字符层面没有「结构」,1+2*3和(1+2)*3在字符流里长得几乎一样,你没法在扫描阶段就知道谁先算。
正规做法是把过程切成四段,每段只干一件事。词法分析(Lexer)负责把"x = 1 + 2 * 3"切成[IDENT(x), ASSIGN, NUM(1), PLUS, NUM(2), STAR, NUM(3), EOF]这样的 token 序列,它不关心语法对不对,只关心「这是不是一个合法的数字/标识符/运算符」。语法分析(Parser)拿着 token 序列,按优先级和结合性规则搭出一棵抽象语法树(AST),比如*的节点会成为+的子节点。求值器(Evaluator)递归遍历这棵树,自底向上算出结果。
这样分层的好处是每一层都能单独测试:Lexer 喂字符串看 token 对不对,Parser 喂 token 看树形对不对,Evaluator 喂树看数值对不对。哪一层出问题一目了然,不用在一坨switch里大海捞针。500 行的预算下,这个结构依然是最省心的,因为每层代码量都在 100 行上下,加起来刚好。
2.2 用枚举和结构体定义 token 与 AST 节点
先定数据结构,这是整个解释器的地基。token 类型用枚举,token 本身用结构体带上类型和值。AST 节点用带kind标签的结构体,配合联合体(union)存不同节点的数据。这里有个血泪经验:union 用起来省内存,但调试时容易看错字段,新手可以先全部用独立字段,跑通再优化。
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <ctype.h> typedef enum { TOK_NUM, TOK_IDENT, TOK_PLUS, TOK_MINUS, TOK_STAR, TOK_SLASH, TOK_LPAREN, TOK_RPAREN, TOK_ASSIGN, TOK_EQ, TOK_LT, TOK_GT, TOK_IF, TOK_ELSE, TOK_EOF } TokKind; typedef struct { TokKind kind; long value; // TOK_NUM 时有效 char name[32];// TOK_IDENT 时有效 } Token; typedef enum { NODE_NUM, NODE_VAR, NODE_BINOP, NODE_ASSIGN, NODE_IF } NodeKind; typedef struct Node { NodeKind kind; // NODE_NUM long value; // NODE_VAR / NODE_ASSIGN char name[32]; // NODE_BINOP TokKind op; struct Node *left, *right; // NODE_IF struct Node *cond, *then_branch, *else_branch; } Node;Token里value和name同时存在会浪费一点空间,但 500 行规模下完全无所谓,换来的是调试时不用记 union 当前激活的是哪个字段。Node把所有可能用到的指针都摊开,NODE_NUM只用value,NODE_BINOP只用op/left/right,其余为 NULL。这种「胖节点」写法在微型解释器里是划算的,因为节点总数撑死几百个,内存不是瓶颈,可读性才是。
参数上要注意name[32]这个长度:标识符超过 31 字符会被截断。如果你打算支持长变量名,把它改成char *name配合strdup,但那样就得多写释放逻辑。500 行的目标下,固定数组是更稳的选择,代价是变量名别起太长。
2.3 词法分析:把字符串切成 token 的 60 行
Lexer 的核心是一个游标pos,从头扫到尾,跳过空白,识别数字、标识符和符号。关键字(if、else)在识别出标识符后再查表判断,这是最省事的做法。
typedef struct { const char *src; int pos; } Lexer; static Token make_token(TokKind k, long v, const char *name) { Token t; t.kind = k; t.value = v; if (name) { strncpy(t.name, name, 31); t.name[31] = '\0'; } else t.name[0] = '\0'; return t; } Token next_token(Lexer *lx) { while (isspace(lx->src[lx->pos])) lx->pos++; char c = lx->src[lx->pos]; if (isdigit(c)) { long v = 0; while (isdigit(lx->src[lx->pos])) v = v * 10 + (lx->src[lx->pos++] - '0'); return make_token(TOK_NUM, v, NULL); } if (isalpha(c) || c == '_') { char buf[32]; int i = 0; while (isalnum(lx->src[lx->pos]) || lx->src[lx->pos] == '_') buf[i++] = lx->src[lx->pos++]; buf[i] = '\0'; if (strcmp(buf, "if") == 0) return make_token(TOK_IF, 0, NULL); if (strcmp(buf, "else") == 0) return make_token(TOK_ELSE, 0, NULL); return make_token(TOK_IDENT, 0, buf); } lx->pos++; switch (c) { case '+': return make_token(TOK_PLUS, 0, NULL); case '-': return make_token(TOK_MINUS, 0, NULL); case '*': return make_token(TOK_STAR, 0, NULL); case '/': return make_token(TOK_SLASH, 0, NULL); case '(': return make_token(TOK_LPAREN, 0, NULL); case ')': return make_token(TOK_RPAREN, 0, NULL); case '=': return make_token(TOK_ASSIGN, 0, NULL); case '<': return make_token(TOK_LT, 0, NULL); case '>': return make_token(TOK_GT, 0, NULL); case '\0': return make_token(TOK_EOF, 0, NULL); } fprintf(stderr, "lex error: unexpected char '%c'\n", c); exit(1); }逻辑上,next_token每次调用返回一个 token 并推进pos。数字用累乘累加解析,标识符用缓冲区收集后查关键字表。符号直接switch映射。注意lx->pos++在switch之前就执行了,所以每个case里不用再推进游标,这个顺序别写反,否则会漏字符。
参数说明:buf[32]和 token 里的name[32]保持一致,避免拷贝时越界。exit(1)是最粗暴的错误处理,生产环境当然要返回错误码,但微型解释器里直接退出能让你第一时间看到出错位置。如果你想让解释器在 REPL 里不崩,把exit换成返回一个TOK_EOF并打印错误即可。
3. 递归下降搭 AST:优先级和结合性怎么落到代码里
3.1 用函数层级表达优先级,比查表更直观
语法分析用递归下降,核心思想是「每个优先级一个函数」。表达式文法的层级从低到高是:赋值 → 比较 → 加减 → 乘除 → 一元 → 原子。低优先级函数调用高优先级函数,这样1 + 2 * 3在解析+时,右操作数会先被parse_mul吃掉2 * 3,自然形成正确的树形。
typedef struct { Lexer lx; Token cur; } Parser; static void advance(Parser *p) { p->cur = next_token(&p->lx); } static Node *parse_expr(Parser *p); // 赋值层 static Node *parse_cmp(Parser *p); // 比较层 static Node *parse_add(Parser *p); // 加减层 static Node *parse_mul(Parser *p); // 乘除层 static Node *parse_atom(Parser *p); // 原子层 static Node *new_node(NodeKind k) { Node *n = calloc(1, sizeof(Node)); n->kind = k; return n; } static Node *parse_atom(Parser *p) { if (p->cur.kind == TOK_NUM) { Node *n = new_node(NODE_NUM); n->value = p->cur.value; advance(p); return n; } if (p->cur.kind == TOK_IDENT) { Node *n = new_node(NODE_VAR); strncpy(n->name, p->cur.name, 31); advance(p); return n; } if (p->cur.kind == TOK_LPAREN) { advance(p); Node *n = parse_expr(p); if (p->cur.kind != TOK_RPAREN) { fprintf(stderr, "parse error: expected ')'\n"); exit(1); } advance(p); return n; } fprintf(stderr, "parse error: unexpected token %d\n", p->cur.kind); exit(1); } static Node *parse_mul(Parser *p) { Node *left = parse_atom(p); while (p->cur.kind == TOK_STAR || p->cur.kind == TOK_SLASH) { TokKind op = p->cur.kind; advance(p); Node *n = new_node(NODE_BINOP); n->op = op; n->left = left; n->right = parse_atom(p); left = n; } return left; } static Node *parse_add(Parser *p) { Node *left = parse_mul(p); while (p->cur.kind == TOK_PLUS || p->cur.kind == TOK_MINUS) { TokKind op = p->cur.kind; advance(p); Node *n = new_node(NODE_BINOP); n->op = op; n->left = left; n->right = parse_mul(p); left = n; } return left; }parse_mul里的while循环处理左结合性:8 / 4 / 2会先建(8/4)的节点,再把它作为左子节点建/2,得到(8/4)/2 = 1,而不是8/(4/2) = 4。这就是左结合在代码里的体现——循环里始终把已建好的left往左挂。
parse_atom是递归的出口,遇到数字、变量或左括号就返回。括号的处理是「吃掉左括号,递归解析整个表达式,再要求右括号」,这样括号内的内容会独立成子树,优先级自然最高。
参数上,new_node用calloc而不是malloc,因为后面求值时会检查left/right是否为 NULL,calloc帮你把指针清零,省得手动初始化每个字段。代价是稍微慢一点,但解释器启动阶段这点开销可以忽略。
3.2 赋值和 if 语句:把语句层接进表达式层
到这一步表达式已经能跑了,但还缺变量赋值和条件分支。赋值比较特殊,它的左边必须是变量,右边是表达式,所以单独一层parse_expr处理。
static Node *parse_expr(Parser *p) { if (p->cur.kind == TOK_IDENT) { // 预读一个 token 判断是不是赋值 Token save = p->cur; Lexer save_lx = p->lx; advance(p); if (p->cur.kind == TOK_ASSIGN) { advance(p); Node *n = new_node(NODE_ASSIGN); strncpy(n->name, save.name, 31); n->right = parse_expr(p); return n; } // 不是赋值,回退 p->cur = save; p->lx = save_lx; } return parse_cmp(p); } static Node *parse_cmp(Parser *p) { Node *left = parse_add(p); while (p->cur.kind == TOK_EQ || p->cur.kind == TOK_LT || p->cur.kind == TOK_GT) { TokKind op = p->cur.kind; advance(p); Node *n = new_node(NODE_BINOP); n->op = op; n->left = left; n->right = parse_add(p); left = n; } return left; }这里用了一个「保存-回退」技巧:看到标识符先存下当前 token 和 lexer 状态,往前看一个 token,如果是=就走赋值分支,否则恢复状态走比较分支。这是递归下降里处理「需要预读」的常见手法,代价是要保存Lexer结构体(里面只有一个pos,拷贝很便宜)。
if语句的解析放在顶层,因为它返回的不是值而是控制流。常见做法是让parse_if解析if (cond) expr else expr,把三个部分分别存进NODE_IF的cond/then_branch/else_branch。注意else是可选的,解析完then_branch后要检查当前 token 是不是TOK_ELSE,不是就留空。
4. 求值器与变量表:递归遍历 AST 的 80 行
4.1 用数组当变量表,够用且好调试
变量存储最简单的方案是一个固定大小的数组,每个元素是「名字 + 值」。查找用线性扫描,500 行规模下变量不会超过几十个,线性查找比哈希表省事得多,而且调试时能直接打印整个表。
typedef struct { char name[32]; long value; } Var; typedef struct { Var vars[128]; int count; } Env; static long *env_find(Env *env, const char *name) { for (int i = 0; i < env->count; i++) if (strcmp(env->vars[i].name, name) == 0) return &env->vars[i].value; return NULL; } static long *env_define(Env *env, const char *name) { if (env->count >= 128) { fprintf(stderr, "runtime error: too many variables\n"); exit(1); } strncpy(env->vars[env->count].name, name, 31); env->vars[env->count].value = 0; return &env->vars[env->count++].value; }env_find返回指针而不是值,这样赋值时可以直接*ptr = value,读的时候*ptr取值。返回 NULL 表示变量未定义,求值器遇到就报错。env_define在变量首次赋值时创建条目,初始值设 0,避免未初始化读。
参数上vars[128]是硬上限,超过就报错退出。如果你要支持更多变量,把这个数字调大或者改成动态数组。name[32]和前面 token、AST 节点保持一致,避免拷贝时长度不匹配。
4.2 递归求值:每个节点类型一个分支
求值函数eval接收节点和环境,返回long。它按kind分派:数字直接返回,变量查表,二元运算递归求左右再算,赋值先求右边再写回,if 先求条件再选分支。
static long eval(Node *n, Env *env) { switch (n->kind) { case NODE_NUM: return n->value; case NODE_VAR: { long *v = env_find(env, n->name); if (!v) { fprintf(stderr, "runtime error: undefined variable '%s'\n", n->name); exit(1); } return *v; } case NODE_BINOP: { long l = eval(n->left, env); long r = eval(n->right, env); switch (n->op) { case TOK_PLUS: return l + r; case TOK_MINUS: return l - r; case TOK_STAR: return l * r; case TOK_SLASH: if (r == 0) { fprintf(stderr, "runtime error: division by zero\n"); exit(1); } return l / r; case TOK_EQ: return l == r; case TOK_LT: return l < r; case TOK_GT: return l > r; default: fprintf(stderr, "bad op\n"); exit(1); } } case NODE_ASSIGN: { long val = eval(n->right, env); long *slot = env_find(env, n->name); if (!slot) slot = env_define(env, n->name); *slot = val; return val; } case NODE_IF: { long c = eval(n->cond, env); if (c) return n->then_branch ? eval(n->then_branch, env) : 0; else return n->else_branch ? eval(n->else_branch, env) : 0; } } return 0; }NODE_BINOP里先递归求左右子树,这是后序遍历,保证子表达式先算完。除零检查放在这里而不是解析阶段,因为除数可能是变量,只有运行时才知道值。比较运算返回 0 或 1,正好能当if的条件用。
NODE_ASSIGN先求右边再写左边,顺序不能反,否则x = x + 1会读到旧值。env_find找不到就env_define,这样变量不需要提前声明,用起来像脚本语言。
NODE_IF里对then_branch和else_branch做了 NULL 检查,因为else可能不存在。条件非零走 then,否则走 else,都没有就返回 0。
4.3 主循环:读一行、解析、求值、打印
把上面所有部件串起来,主函数就是一个 REPL:读一行,初始化 lexer 和 parser,解析出 AST,求值,打印结果。
int main(void) { char line[1024]; Env env; env.count = 0; while (1) { printf("> "); if (!fgets(line, sizeof(line), stdin)) break; Parser p; p.lx.src = line; p.lx.pos = 0; advance(&p); Node *root = parse_expr(&p); if (p.cur.kind != TOK_EOF) { fprintf(stderr, "parse error: trailing input\n"); continue; } long result = eval(root, &env); printf("%ld\n", result); } return 0; }fgets读一行,sizeof(line)防止溢出。每次循环重建Parser,但Env在循环外,所以变量能跨行保留。解析完检查是不是到了TOK_EOF,不是说明有多余输入,报错但继续循环,不退出。
advance(&p)在解析前先取第一个 token,这是递归下降的惯例——p.cur始终是「当前待处理的 token」。如果你忘了这一步,parse_expr会拿到未初始化的cur,行为随机,这是新手最容易翻车的地方之一。
5. 避坑与排查:500 行里最容易翻车的 5 个点
5.1 现象:输入1 + 2输出乱码或崩溃
原因通常是Parser的cur没初始化就调用parse_expr。advance必须在解析前调用一次,否则p.cur.kind是栈上的垃圾值,switch走到未定义分支。
解决:在main里advance(&p)紧跟Parser初始化,或者在parse_expr开头加断言assert(p->cur.kind >= 0 && p->cur.kind <= TOK_EOF)。后者能在调试阶段第一时间定位。
5.2 现象:8 / 4 / 2算出来是 4 而不是 1
这是结合性写反了。如果parse_mul里写成n->left = parse_atom(p); n->right = left;再left = n,就变成右结合,结果错误。
解决:循环里始终把已建好的left挂到新节点的左边,新解析的右操作数挂右边。左结合的本质是「从左往右攒」,每次新运算符都把之前的结果当左子树。
5.3 现象:变量赋值后读出来还是 0
原因多半是env_find返回了值而不是指针,赋值时改的是副本。或者env_define每次赋值都新建条目,读的时候查到的是旧条目。
解决:env_find必须返回long *,赋值走*slot = val。env_define只在找不到时才调用,找到就复用。调试时打印env.count和每个变量的名字值,一眼能看出是不是重复定义。
5.4 现象:if (1) 2 else 3解析报错
常见原因是parse_if里解析完then_branch后没检查TOK_ELSE就直接返回,导致else被当成多余输入。或者else分支的解析没递归调用parse_expr,只吃了一个原子。
解决:parse_if的结构应该是「吃if、吃(、解析条件、吃)、解析 then、如果当前是else就吃else再解析 else」。每一步都要检查 token 类型,不匹配就报错并打印当前 token 的 kind,方便定位。
5.5 现象:长表达式跑着跑着栈溢出
递归下降的深度和表达式嵌套层数成正比。((((...))))嵌套几百层就会爆栈,因为每层括号都会递归进parse_atom → parse_expr → parse_cmp → parse_add → parse_mul → parse_atom。
解决:微型解释器里可以限制嵌套深度,比如在parse_atom里加一个全局计数器,超过 256 就报错退出。或者把递归改成显式栈的迭代版本,但那样代码量会翻倍,500 行预算下不划算。实际使用中,手写表达式很少超过几十层嵌套,加个深度检查就够。
6. 从 500 行往外扩:加字符串、加循环、加函数调用
跑通四则运算和 if 之后,你手里已经有一个能用的骨架。往外扩最自然的方向是字符串类型,因为很多场景需要打印文本。做法是在Token和Node里加TOK_STR和NODE_STR,值用char *存,求值时返回指针而不是long。这要求把eval的返回类型改成带标签的Value结构体,工作量大概多 100 行,但结构不变。
加while循环更简单,因为语法和if几乎一样,只是求值时循环执行body直到条件为假。注意循环里要防止死循环,可以在eval里加一个步数计数器,超过一百万步就报错退出,这是调试时的后悔药。
加函数调用是最有挑战的一步,需要引入作用域链和调用栈。常见做法是Env改成链表,每个函数调用压一个新Env,查找变量时从当前层往外找。这一步会让代码量突破 500 行,但如果你前面四章都亲手敲过,加函数只是时间问题。
我自己的习惯是每加一个特性就先写三个测试用例:一个正常输入、一个边界输入(比如空表达式、除零)、一个错误输入(未定义变量)。跑通了再往下走,这样出问题能立刻定位到是哪次改动引入的。500 行的项目最怕的就是一口气加五个特性然后一起调试,那时候你连哪行代码是新的都分不清。希望帮到你。
本文还有配套的精品资源,点击获取