用C语言实现迷你解释器:从词法分析到表达式求值

发布时间:2026/9/7 9:41:33
用C语言实现迷你解释器:从词法分析到表达式求值 那天下午我盯着一个简单的数学表达式(1 2) * 3突然冒出一个念头如果不用现成的计算器或编程语言只靠最基础的C语言我能不能写个程序让它理解并算出结果这个看似简单的需求背后其实藏着一个完整的解释器核心——词法分析、语法解析、表达式求值。很多人觉得解释器是编译原理里高深莫测的东西但当我真正动手用C写了一个迷你版本后发现它的核心逻辑远比想象中直观。今天我就带你从零开始用不到200行C代码实现一个能处理四则运算和变量的迷你解释器。这不是什么玩具项目而是理解编程语言如何工作的绝佳入口。1. 先搞清楚解释器到底在做什么很多人一听到“解释器”就想到Python或JavaScript引擎那种庞然大物但其实解释器的核心任务很简单把人类可读的代码转换成机器可执行的操作。我们的迷你解释器要处理这样的输入x 1 2 * 3 y x 5 print y它需要完成三个关键步骤1.1 词法分析把字符串切成有意义的单词想象你在读一句话首先要识别出哪些是名词、动词、形容词。解释器也一样它要把x 1 2切成[x, , 1, , 2]这样的标记序列。每个标记都有类型标识符变量名x,y,count数字字面量123,3.14运算符,-,*,/赋值符关键字print1.2 语法分析理解句子结构知道每个单词的意思后还要理解语法。x 1 2不是随机单词排列而是一个赋值语句把1 2的结果赋给变量x。语法分析会构建抽象语法树AST / \ x / \ 1 21.3 解释执行按树结构计算结果有了语法树就可以递归地求值先计算1 2得到3然后赋值给x。2. 从词法分析开始如何切分代码字符串词法分析器Lexer是解释器的第一道关卡它的任务是把连续的字符流分解成有意义的标记。2.1 定义标记类型首先用枚举定义所有可能的标记类型typedef enum { TOKEN_EOF, // 文件结束 TOKEN_IDENT, // 标识符变量名 TOKEN_NUMBER, // 数字 TOKEN_ASSIGN, // 赋值 TOKEN_PLUS, // 加 TOKEN_MINUS, // 减 - TOKEN_MULTIPLY, // 乘 * TOKEN_DIVIDE, // 除 / TOKEN_LPAREN, // 左括号 ( TOKEN_RPAREN, // 右括号 ) TOKEN_PRINT // 打印关键字 } TokenType;每个标记除了类型还需要记录具体的值typedef struct { TokenType type; char text[32]; // 标记的原始文本 double number; // 如果是数字存储数值 } Token;2.2 实现标记切分逻辑词法分析器的核心是一个状态机根据当前字符决定如何切分Token get_next_token(const char** input) { // 跳过空白字符 while (isspace(**input)) { (*input); } // 处理文件结束 if (**input \0) { return (Token){TOKEN_EOF, , 0}; } // 处理数字 if (isdigit(**input)) { char* end; double value strtod(*input, end); Token token {TOKEN_NUMBER, , value}; strncpy(token.text, *input, end - *input); token.text[end - *input] \0; *input end; return token; } // 处理标识符和关键字 if (isalpha(**input)) { const char* start *input; while (isalnum(**input)) { (*input); } int length *input - start; Token token; strncpy(token.text, start, length); token.text[length] \0; if (strcmp(token.text, print) 0) { token.type TOKEN_PRINT; } else { token.type TOKEN_IDENT; } return token; } // 处理单个字符的运算符 Token token; token.text[0] **input; token.text[1] \0; switch (**input) { case : token.type TOKEN_ASSIGN; break; case : token.type TOKEN_PLUS; break; case -: token.type TOKEN_MINUS; break; case *: token.type TOKEN_MULTIPLY; break; case /: token.type TOKEN_DIVIDE; break; case (: token.type TOKEN_LPAREN; break; case ): token.type TOKEN_RPAREN; break; default: token.type TOKEN_EOF; break; // 未知字符 } (*input); return token; }这个函数每次调用都会从输入字符串中提取下一个标记并更新输入指针的位置。3. 语法分析从标记序列到语法树词法分析给了我们单词列表现在需要理解语法结构。这里采用递归下降分析法这是最直观的语法分析方法。3.1 定义语法树节点首先定义语法树节点的结构typedef struct ASTNode { enum { NODE_NUMBER, // 数字字面量 NODE_IDENT, // 标识符 NODE_BINOP, // 二元操作 NODE_ASSIGN, // 赋值 NODE_PRINT // 打印语句 } type; union { double number; // 数字值 char ident[32]; // 标识符名 struct { // 二元操作 int op; // 操作符类型 struct ASTNode* left; struct ASTNode* right; } binop; struct { // 赋值 char ident[32]; struct ASTNode* value; } assign; struct { // 打印 struct ASTNode* expr; } print; }; } ASTNode;3.2 实现语法分析器语法分析器的核心是理解运算符优先级。对于表达式1 2 * 3乘法应该比加法先计算所以语法分析要反映这种优先级。// 当前处理的标记 Token current_token; const char* input_ptr; // 前进到下一个标记 void next_token() { current_token get_next_token(input_ptr); } // 解析表达式处理加减法优先级最低 ASTNode* parse_expression(); // 解析因子数字、标识符或括号表达式 ASTNode* parse_factor() { if (current_token.type TOKEN_NUMBER) { ASTNode* node malloc(sizeof(ASTNode)); node-type NODE_NUMBER; node-number current_token.number; next_token(); return node; } if (current_token.type TOKEN_IDENT) { ASTNode* node malloc(sizeof(ASTNode)); node-type NODE_IDENT; strcpy(node-ident, current_token.text); next_token(); return node; } if (current_token.type TOKEN_LPAREN) { next_token(); // 吃掉左括号 ASTNode* expr parse_expression(); if (current_token.type ! TOKEN_RPAREN) { printf(错误期望右括号\n); exit(1); } next_token(); // 吃掉右括号 return expr; } printf(错误意外的标记 %s\n, current_token.text); exit(1); } // 解析项处理乘除法 ASTNode* parse_term() { ASTNode* node parse_factor(); while (current_token.type TOKEN_MULTIPLY || current_token.type TOKEN_DIVIDE) { ASTNode* new_node malloc(sizeof(ASTNode)); new_node-type NODE_BINOP; new_node-binop.op current_token.type; new_node-binop.left node; next_token(); // 吃掉运算符 new_node-binop.right parse_factor(); node new_node; } return node; } // 解析表达式处理加减法 ASTNode* parse_expression() { ASTNode* node parse_term(); while (current_token.type TOKEN_PLUS || current_token.type TOKEN_MINUS) { ASTNode* new_node malloc(sizeof(ASTNode)); new_node-type NODE_BINOP; new_node-binop.op current_token.type; new_node-binop.left node; next_token(); // 吃掉运算符 new_node-binop.right parse_term(); node new_node; } return node; } // 解析语句赋值或打印 ASTNode* parse_statement() { if (current_token.type TOKEN_PRINT) { ASTNode* node malloc(sizeof(ASTNode)); node-type NODE_PRINT; next_token(); // 吃掉print node-print.expr parse_expression(); return node; } if (current_token.type TOKEN_IDENT) { char ident[32]; strcpy(ident, current_token.text); next_token(); // 吃掉标识符 if (current_token.type TOKEN_ASSIGN) { ASTNode* node malloc(sizeof(ASTNode)); node-type NODE_ASSIGN; strcpy(node-assign.ident, ident); next_token(); // 吃掉 node-assign.value parse_expression(); return node; } else { // 如果不是赋值回退并作为表达式解析 input_ptr - strlen(ident); // 简单回退实际需要更精确处理 next_token(); return parse_expression(); } } return parse_expression(); }这种递归下降的解析方法直观地反映了语言的语法结构加减法调用乘除法乘除法调用因子因子处理最基本的元素。4. 解释执行让语法树活起来有了语法树执行就变得很直接——递归地遍历树并计算每个节点的值。4.1 变量环境管理首先需要管理变量存储typedef struct Variable { char name[32]; double value; struct Variable* next; } Variable; Variable* variables NULL; double get_variable(const char* name) { Variable* var variables; while (var) { if (strcmp(var-name, name) 0) { return var-value; } var var-next; } return 0.0; // 未定义的变量返回0 } void set_variable(const char* name, double value) { Variable* var variables; while (var) { if (strcmp(var-name, name) 0) { var-value value; return; } var var-next; } // 新建变量 var malloc(sizeof(Variable)); strcpy(var-name, name); var-value value; var-next variables; variables var; }4.2 递归求值器现在实现核心的求值函数double evaluate(ASTNode* node) { switch (node-type) { case NODE_NUMBER: return node-number; case NODE_IDENT: return get_variable(node-ident); case NODE_BINOP: switch (node-binop.op) { case TOKEN_PLUS: return evaluate(node-binop.left) evaluate(node-binop.right); case TOKEN_MINUS: return evaluate(node-binop.left) - evaluate(node-binop.right); case TOKEN_MULTIPLY: return evaluate(node-binop.left) * evaluate(node-binop.right); case TOKEN_DIVIDE: { double right evaluate(node-binop.right); if (right 0.0) { printf(错误除零\n); exit(1); } return evaluate(node-binop.left) / right; } default: return 0.0; } case NODE_ASSIGN: { double value evaluate(node-assign.value); set_variable(node-assign.ident, value); return value; } case NODE_PRINT: { double value evaluate(node-print.expr); printf(%g\n, value); return value; } default: return 0.0; } }4.3 主循环读取-解析-执行最后把整个流程串起来void interpret(const char* code) { input_ptr code; next_token(); // 读取第一个标记 while (current_token.type ! TOKEN_EOF) { ASTNode* statement parse_statement(); evaluate(statement); // 简单的内存清理实际项目需要更完善 free(statement); } }现在我们可以测试这个迷你解释器了int main() { const char* code x 1 2 * 3\n y (x 5) / 2\n print y\n print x y * 2; interpret(code); return 0; }运行结果应该是5.5 165. 从玩具到实用还需要补什么我们这个迷你解释器虽然功能完整但离实用还有距离。如果要用于真实场景还需要考虑以下几个方面5.1 错误处理与恢复现在的解释器遇到错误直接退出实际需要更友好的错误处理// 更好的错误处理示例 void expect_token(TokenType expected) { if (current_token.type ! expected) { fprintf(stderr, 语法错误期望 %d得到 %s\n, expected, current_token.text); // 应该尝试错误恢复而不是直接退出 } }5.2 内存管理目前的内存管理很简陋实际需要完整的AST节点释放函数变量环境的清理避免内存泄漏5.3 更多语言特性可以逐步添加控制流if/while函数定义和调用数组和数据结构标准库函数5.4 性能优化当前版本每次解析执行可以优化为字节码编译虚拟机执行JIT编译优化6. 为什么值得亲手实现一个解释器你可能想问现在有这么多成熟的编程语言为什么还要自己写解释器我认为至少有三个层面的价值理解层面亲手实现后你对编程语言的理解会完全不同。当你再写a b * c时能清晰地看到背后的语法树和求值过程。调试层面遇到复杂表达式的问题时你能想象解释器会如何一步步计算这种直觉对调试很有帮助。扩展层面很多项目需要领域特定语言DSL这时候解释器技能就派上用场了。比如配置文件解析、业务规则引擎、模板渲染等。这个200行的迷你解释器是一个起点它展示了编程语言最核心的机制。虽然简单但包含了词法分析、语法分析、解释执行这三个关键环节。下次当你使用Python或JavaScript时不妨想想你写的每行代码背后都有一个类似的但复杂得多的解释器在辛勤工作。理解这个机制会让你成为一个更深刻的技术人。