2026/8/24 19:40:36

从零实现C语言编译器:词法分析到中间代码生成全解析

从零实现C语言编译器:词法分析到中间代码生成全解析 最近在整理硬盘时翻到一个大学时期写的C语言编译器项目。当时为了弄懂一个简单的printf(“Hello, World\n”);是如何从文本变成机器指令的几乎翻遍了图书馆里所有关于编译原理的“龙书”“虎书”。现在回头看那个项目代码虽然简陋但把词法分析、语法分析、语义检查、中间代码生成到目标代码输出的整个链路都跑通了。它没有复杂的优化也不支持完整的C99标准但它清晰地揭示了一个核心事实编译器的本质不是一个高深莫测的黑盒而是一套将人类可读的“意图”翻译成机器可执行的“动作”的确定性规则集合。很多人对编译器的印象停留在“GCC”、“Clang”这些庞然大物觉得开发编译器是少数天才的领域。但如果你亲手实现过一个哪怕只能处理四则运算和变量赋值的“玩具”编译器你就会发现那些复杂的工业级工具其底层逻辑与你写的几百行代码是相通的。它们只是在可靠性、性能、语言特性支持度和错误处理上做到了极致。今天我们就以“实现一个简单C语言编译器”为目标抛开厚重的理论书直接进入源码层面看看一个编译器是如何被一步步构建出来的。我们不会满足于“是什么”而要深究“为什么”——为什么词法分析要用有限自动机为什么语法分析常用递归下降中间代码为什么常常选择一种抽象的形式理解这些设计背后的权衡比单纯记忆编译步骤更有价值。1. 先想清楚我们的“简单编译器”要做什么不做什么在动手写第一行代码之前最重要的不是选择什么语言用C写编译器或者用Python写编译器都可以而是明确边界。一个试图面面俱到的编译器项目99%会中途夭折。我们的目标是教学和理解而非替代GCC。首先定义我们的“C语言”子集我们称它为“MiniC”。它可能只包含以下部分数据类型仅支持int类型。这能避开浮点数处理、结构体、联合体等复杂的内存对齐和操作问题。语句支持变量声明、赋值表达式、算术运算,-,*,/、关系运算,,,!、if-else条件分支、while循环。这些是构成程序逻辑的基本骨架。函数至少支持一个main函数。这是程序的入口。进阶可以尝试支持自定义函数调用但初期这会将复杂度提升一个数量级涉及调用约定、栈帧管理。输入/输出我们可以选择不支持标准库如printf而是实现一条特殊的“输出”语句例如print a;将其直接翻译为特定的系统调用或运行时库函数。这能让我们专注于编译过程本身而不是实现一个完整的运行时环境。其次确定编译器的输出目标汇编代码生成x86或ARM等平台的汇编代码然后用系统汇编器如nasm,as和链接器生成可执行文件。这是最“真实”的路径能让你彻底理解机器如何工作但需要熟悉目标汇编指令集。中间代码如三地址码生成一种抽象的、与机器无关的指令序列。这是许多教学编译器如斯坦福的CS143课程项目的选择。它的好处是分离了前端语言分析和后端代码生成与优化逻辑更清晰。解释执行不生成代码而是边分析边执行。这更像一个解释器但对于理解语义分析如求值非常直观。虚拟机字节码生成自定义字节码然后编写一个小的虚拟机来执行。Java的JVM、Python的PVM都是这个思路。这平衡了难度和完整性。为了最具教学意义我们选择一条折中路径前端词法、语法、语义生成一种简单的三地址码中间表示然后为这个中间表示编写一个简单的解释器来执行。这样我们既能完整走完编译流程又无需深入特定机器的汇编细节。最后明确项目结构一个典型的简单编译器源码会按阶段组织目录minic_compiler/ ├── src/ │ ├── lexer/ # 词法分析器 │ ├── parser/ # 语法分析器 │ ├── ast/ # 抽象语法树定义 │ ├── semantic/ # 语义分析类型检查、符号表 │ ├── ir/ # 中间代码生成 │ ├── interpreter/ # 中间代码解释器或 codegen/ 用于生成汇编 │ └── main.c # 主程序串联流程 ├── include/ # 头文件 ├── samples/ # 测试用的 MiniC 程序 └── Makefile这个结构不是固定的但它清晰地体现了编译器的管道Pipeline模型数据流从一个模块流向下一个。2. 从字符流到单词流词法分析器的核心是状态机词法分析Lexical Analysis是编译器的第一道关卡。它的任务极其单纯读入源代码的字符流char stream识别出一个一个有意义的单词token并过滤掉空格、换行、注释等无关字符。为什么需要这一步因为语法分析器处理的是单词的序列而不是杂乱的字符。想象一下如果语法分析器直接面对“int a 10 b;\n”这串字符它需要自己判断哪里是关键字哪里是标识符哪里是数字哪里是运算符这会让逻辑变得无比复杂。词法分析器就是来做这个“预处理”的它输出类似[KEYWORD_INT, IDENTIFIER(“a”), OPERATOR(), NUMBER(10), OPERATOR(), IDENTIFIER(“b”), SEMICOLON]的序列。如何实现核心是有限自动机Finite Automaton。你可以手写一个大的switch-case或if-else状态机也可以使用工具如flex根据规则自动生成。为了理解原理我们看一个手写识别整数的简单状态机// 伪代码展示状态迁移思想 Token get_number_token(FILE* src) { int state 0; char lexeme[MAX_LEXEME_LEN]; int pos 0; char c; while ((c get_next_char(src)) ! EOF) { switch (state) { case 0: // 初始状态 if (isdigit(c)) { lexeme[pos] c; state 1; // 进入“数字中”状态 } else { // 不是数字回退字符交给其他token逻辑处理 unget_char(c); return ERROR_TOKEN; } break; case 1: // 数字中 if (isdigit(c)) { lexeme[pos] c; // 继续收集数字 } else { // 遇到非数字字符数字结束 unget_char(c); // 把这个字符放回去留给下一个token lexeme[pos] \0; return make_token(TOKEN_NUMBER, lexeme, atoi(lexeme)); } break; } } // 处理文件结束时的情况 if (state 1) { lexeme[pos] \0; return make_token(TOKEN_NUMBER, lexeme, atoi(lexeme)); } return ERROR_TOKEN; }在实际的简单编译器源码中词法分析器lexer.c通常会定义一个Token结构体和一个全局的next_token()函数。// include/token.h typedef enum { TOKEN_EOF, TOKEN_IDENTIFIER, TOKEN_NUMBER, // 关键字 TOKEN_INT, TOKEN_IF, TOKEN_ELSE, TOKEN_WHILE, TOKEN_RETURN, // 运算符 TOKEN_PLUS, TOKEN_MINUS, TOKEN_STAR, TOKEN_SLASH, TOKEN_ASSIGN, // ‘‘ TOKEN_EQ, // ‘‘ TOKEN_NE, // ‘!‘ TOKEN_LT, TOKEN_GT, // 分隔符 TOKEN_LPAREN, TOKEN_RPAREN, TOKEN_LBRACE, TOKEN_RBRACE, TOKEN_SEMICOLON, TOKEN_COMMA } TokenType; typedef struct Token { TokenType type; char* lexeme; // 单词的原始字符串 int line; // 所在行号用于错误报告 union { int int_value; // 数字token的值 char* str_value; // 标识符名字 } literal; } Token; // src/lexer/lexer.c Token next_token(FILE* src) { skip_whitespace_and_comments(src); char c peek_char(src); // 预读一个字符 if (c EOF) return make_token(TOKEN_EOF, “”, current_line); if (isdigit(c)) return scan_number(src); if (isalpha(c) || c ‘_’) return scan_identifier_or_keyword(src); // 处理运算符和分隔符 switch (c) { case ‘‘: advance(src); if (peek_char(src) ‘‘) { advance(src); return make_token(TOKEN_EQ, “”, current_line); } else return make_token(TOKEN_ASSIGN, “”, current_line); case ‘;‘: advance(src); return make_token(TOKEN_SEMICOLON, “;”, current_line); // ... 其他字符处理 default: // 无法识别的字符报错 compile_error(current_line, “Unexpected character: %c”, c); return make_token(TOKEN_ERROR, “”, current_line); } }关键点scan_identifier_or_keyword函数会先收集一个完整的标识符如“total”然后去关键字表里查找。如果匹配如“int”就返回关键字TOKEN_INT否则返回TOKEN_IDENTIFIER。这个设计避免了为每个关键字写独立的识别逻辑。3. 从单词流到语法树语法分析是规则的递归验证语法分析Parsing接收词法分析器产生的Token流并验证它是否符合我们为 MiniC 定义的语法规则Grammar同时构建出程序的层次化结构——抽象语法树Abstract Syntax Tree, AST。为什么是树形结构因为程序本身是嵌套的一个while语句包含一个条件表达式和一个循环体语句块一个算术表达式a b * c体现了运算符的优先级*比绑定更紧。树形结构能完美地表示这种嵌套关系。如何定义语法我们使用上下文无关文法CFG来描述。例如Program - Function Function - Type Identifier ‘(‘ ‘)’ ‘{‘ Statement* ‘}‘ Type - ‘int’ Statement - ‘if’ ‘(‘ Expression ‘)’ Statement (‘else’ Statement)? | ‘while’ ‘(‘ Expression ‘)’ Statement | ‘return’ Expression ‘;’ | Expression? ‘;’ // 表达式语句 | Type Identifier (‘‘ Expression)? ‘;’ // 声明语句 Expression - Identifier ‘‘ Expression // 赋值 | LogicalOr LogicalOr - LogicalAnd (‘||’ LogicalAnd)* LogicalAnd - Equality (‘’ Equality)* Equality - Relational ((‘’ | ‘!’) Relational)* Relational - Additive ((‘’ | ‘’) Additive)* Additive - Multiplicative ((‘’ | ‘-’) Multiplicative)* Multiplicative - Primary ((‘*’ | ‘/’) Primary)* Primary - NUMBER | IDENTIFIER | ‘(‘ Expression ‘)’注这是一个极度简化的文法忽略了运算符优先级和结合性的完整处理但展示了递归下降的思想递归下降分析法是最直观、最适合手写编译器前端的语法分析方法。它的核心思想是为文法中的每一条规则非终结符编写一个对应的解析函数。这个函数的工作就是“吃掉”符合该规则的 Token 序列并返回对应的 AST 节点。让我们看看parser.c中解析if语句和加法表达式的函数可能长什么样// src/parser/parser.c // 假设我们有全局的 Token current_token 和 next_token() 函数 ASTNode* parse_statement() { ASTNode* node NULL; if (current_token.type TOKEN_IF) { node parse_if_statement(); } else if (current_token.type TOKEN_WHILE) { node parse_while_statement(); } else if (current_token.type TOKEN_INT) { node parse_declaration_statement(); } else { node parse_expression_statement(); } return node; } ASTNode* parse_if_statement() { consume(TOKEN_IF); // 消耗掉 ‘if’ token consume(TOKEN_LPAREN); ASTNode* condition parse_expression(); // 解析条件表达式 consume(TOKEN_RPAREN); ASTNode* then_branch parse_statement(); // 解析 then 分支 ASTNode* else_branch NULL; if (current_token.type TOKEN_ELSE) { consume(TOKEN_ELSE); else_branch parse_statement(); } return create_if_ast_node(condition, then_branch, else_branch); } // 解析加法表达式 (处理 和 -) ASTNode* parse_additive_expression() { ASTNode* node parse_multiplicative_expression(); // 先解析更高优先级的乘法项 while (current_token.type TOKEN_PLUS || current_token.type TOKEN_MINUS) { Token op current_token; consume(current_token.type); // 消耗掉运算符 ASTNode* right parse_multiplicative_expression(); node create_binary_op_ast_node(op.type, node, right); } return node; }关键点parse_additive_expression中的while循环巧妙地处理了左结合性如a b - c被解析为((a b) - c)。递归下降的代码结构几乎就是文法规则的直译这是它易于理解和实现的主要原因。构建出的 AST 节点需要定义相应的数据结构在ast.h中typedef enum { NODE_PROGRAM, NODE_FUNCTION, NODE_IF, NODE_WHILE, NODE_ASSIGN, NODE_BINARY_OP, NODE_VARIABLE, NODE_NUMBER } NodeType; typedef enum { OP_ADD, OP_SUB, OP_MUL, OP_DIV, OP_EQ, OP_NE, OP_LT, OP_GT } BinaryOp; typedef struct ASTNode { NodeType type; int line; union { // 对于二元操作 a b struct { BinaryOp op; struct ASTNode* left; struct ASTNode* right; } binary_op; // 对于 if 语句 struct { struct ASTNode* condition; struct ASTNode* then_branch; struct ASTNode* else_branch; } if_stmt; // 对于变量或数字 struct { char* name; } variable; // 标识符名字 struct { int value; } number; // 对于赋值 a b struct { char* name; struct ASTNode* value; } assignment; } data; } ASTNode;语法分析器最终会返回一个代表整个程序的 AST 根节点通常是NODE_PROGRAM或NODE_FUNCTION。4. 赋予意义语义分析与符号表语法正确的程序不一定有意义。int a “hello”;语法上可能是一个“声明语句”但语义上是错误的类型不匹配。语义分析Semantic Analysis的任务就是给 AST 赋予意义进行上下文相关的检查。核心工作有两项类型检查确保运算符两边的类型兼容函数调用参数匹配赋值左右类型一致等。在我们的 MiniC 中由于只有int类型这部分大大简化但依然要检查是否对非整数使用了算术运算。管理符号表这是语义分析乃至后续代码生成的核心数据结构。它记录了程序中所有标识符变量、函数名的信息类型、作用域、内存位置或临时编号等。符号表如何工作它是一个栈式结构以支持作用域Scope。// src/semantic/symbol.h typedef struct Symbol { char* name; Type type; // 在我们的例子里就是 TYPE_INT int scope_level; // 后续代码生成可能需要的信息如分配的寄存器编号或栈帧偏移量 int offset; } Symbol; typedef struct SymbolTable { HashMap* map; // 用于快速查找符号名 - Symbol* struct SymbolTable* prev; // 指向外层作用域的符号表 int scope_level; } SymbolTable; SymbolTable* current_scope; void enter_scope() { SymbolTable* new_scope create_symbol_table(); new_scope-prev current_scope; new_scope-scope_level current_scope ? current_scope-scope_level 1 : 0; current_scope new_scope; } void exit_scope() { SymbolTable* old current_scope; current_scope current_scope-prev; destroy_symbol_table(old); } Symbol* lookup_symbol(char* name) { SymbolTable* scope current_scope; while (scope) { Symbol* sym hashmap_get(scope-map, name); if (sym) return sym; scope scope-prev; } return NULL; // 未找到 } void define_symbol(char* name, Type type) { if (hashmap_contains(current_scope-map, name)) { // 重复定义错误 semantic_error(“Redefinition of symbol ‘%s’”, name); return; } Symbol* sym create_symbol(name, type, current_scope-scope_level); // 计算 offset 等... hashmap_put(current_scope-map, name, sym); }语义分析器会遍历 AST。当遇到变量声明int a;时调用define_symbol将其加入当前作用域的符号表。当遇到变量使用a 10;时调用lookup_symbol检查该变量是否已定义。如果未定义则报“未声明的标识符”错误。为什么语义分析和语法分析常常交织在递归下降的语法分析过程中我们可以在解析到声明语句时直接调用define_symbol在解析表达式遇到标识符时直接查找。这种模式称为“语法制导的翻译”。但对于更清晰的分层架构也可以先构建完整的 AST再进行一次独立的语义分析遍历。5. 从树到线性指令中间代码生成AST 很适合分析和检查但不太适合直接生成目标代码。中间代码Intermediate Representation, IR是一种更接近机器指令、但依然与具体 CPU 架构无关的表示。三地址码Three-Address Code是一种常见的 IR每条指令最多涉及三个“地址”可以是变量、常量或临时变量。为什么需要 IR分离关注点前端语言相关和后端机器相关通过 IR 解耦。你可以为不同的源语言写不同的前端为不同的目标机器写不同的后端只要它们都生成/理解同一种 IR。便于优化许多机器无关的优化如常量传播、公共子表达式消除在 IR 上进行比在 AST 或汇编上更容易。三地址码示例对于 MiniC 语句a b c * 2;可能生成t1 c * 2 t2 b t1 a t2每一条指令都是一个简单的操作。在我们的简单编译器里IR 可以定义为一组指令枚举和结构体// src/ir/ir.h typedef enum { IR_ADD, IR_SUB, IR_MUL, IR_DIV, IR_ASSIGN, // 赋值 IR_JUMP, // 无条件跳转 IR_JUMP_IF_TRUE, IR_JUMP_IF_FALSE, // 条件跳转 IR_LABEL, // 标号 IR_RETURN, IR_PRINT // 我们自定义的输出指令 } IROp; typedef struct IRInstruction { IROp op; char* dest; // 目标操作数临时变量或真实变量 char* src1; // 源操作数1 char* src2; // 源操作数2对于二元操作 char* label; // 用于跳转指令的标号名 struct IRInstruction* next; } IRInstruction; typedef struct IRFunction { char* name; IRInstruction* instructions; // 可能还需要局部变量表等信息 } IRFunction;IR 生成器ir_generator.c的工作是遍历带有语义信息的 AST并生成线性的 IR 指令列表。这个过程是递归的// 伪代码生成加法表达式 b c 的 IR IRInstruction* gen_add_expr(ASTNode* node) { // 假设 node 是二元操作节点 IRInstruction* left_ir generate_ir(node-data.binary_op.left); IRInstruction* right_ir generate_ir(node-data.binary_op.right); // 创建一个新的临时变量名如 t0, t1... char* temp_var new_temp(); // 创建一条 IR_ADD 指令 IRInstruction* add_inst create_ir_inst(IR_ADD, temp_var, get_result_var(left_ir), get_result_var(right_ir)); // 将 left_ir, right_ir 和 add_inst 链接起来 append_ir_list(current_ir_list, left_ir); append_ir_list(current_ir_list, right_ir); append_ir_list(current_ir_list, add_inst); // 返回代表该表达式结果的“位置”这里是临时变量名 return make_result_ir(temp_var); }对于控制流if,whileIR 生成需要创建标号Label和跳转指令。例如if (cond) stmt1 else stmt2可能生成... // 计算 cond 的代码结果放在某个临时变量 t_cond JUMP_IF_FALSE t_cond, label_else ... // stmt1 的 IR 代码 JUMP label_end LABEL label_else: ... // stmt2 的 IR 代码 LABEL label_end:6. 最后一步解释执行或代码生成有了 IR我们就来到了编译器的后端。对于教学项目编写一个 IR 解释器是最快看到结果的方式。IR 解释器本质上是一个基于栈或寄存器的虚拟机。它维护一个存储变量值的环境符号表到值的映射然后顺序执行 IR 指令列表。// src/interpreter/interpreter.c typedef struct { HashMap* env; // 变量名 - 值int // 可能还需要一个临时变量值的栈或数组 } ExecutionContext; int interpret_instruction(IRInstruction* inst, ExecutionContext* ctx) { switch (inst-op) { case IR_ADD: { int val1 get_value(ctx, inst-src1); int val2 get_value(ctx, inst-src2); int result val1 val2; set_value(ctx, inst-dest, result); break; } case IR_ASSIGN: { int val get_value(ctx, inst-src1); set_value(ctx, inst-dest, val); break; } case IR_JUMP_IF_FALSE: { int cond get_value(ctx, inst-src1); if (!cond) { // 跳转到 inst-label 指向的指令 return find_label_index(inst-label); } break; } case IR_PRINT: { int val get_value(ctx, inst-src1); printf(“%d\n”, val); break; } // ... 其他指令 } return NEXT_INSTRUCTION; // 继续执行下一条 } void interpret(IRFunction* func) { ExecutionContext ctx; init_context(ctx); IRInstruction* ip func-instructions; // 指令指针 while (ip) { int next interpret_instruction(ip, ctx); if (next NEXT_INSTRUCTION) { ip ip-next; } else { // 跳转需要根据 next 找到目标指令这里简化处理 ip get_instruction_at_index(func, next); } } free_context(ctx); }这样我们就完成了一个完整的“编译-执行”流程源代码 - Token流 - AST - IR - 解释执行。如果想生成真实的汇编代码呢那么你需要一个代码生成器codegen.c它将 IR 指令映射到目标架构的汇编指令并处理寄存器分配、栈帧管理、函数调用约定等复杂问题。例如t1 a b可能被翻译成mov eax, [ebp - 4] ; 加载变量 a 的值到寄存器 eax add eax, [ebp - 8] ; 加上变量 b 的值 mov [ebp - 12], eax ; 将结果存到临时变量 t1 的位置这是另一个深水区但原理是直接的为每种 IR 操作码编写一个生成对应汇编指令序列的函数。7. 把碎片拼成整体主流程与工程化思考最后我们需要一个main.c来串联所有模块并处理一些工程细节// src/main.c int main(int argc, char** argv) { if (argc 2) { fprintf(stderr, “Usage: %s source_file.minic\n”, argv[0]); return 1; } FILE* source fopen(argv[1], “r”); if (!source) { perror(“Failed to open source file”); return 1; } // 1. 词法分析 init_lexer(source); // 2. 语法分析 构建 AST ASTNode* program_ast parse_program(); // 3. 语义分析 构建符号表 init_symbol_table(); semantic_analysis(program_ast); // 4. 生成中间代码 IRFunction* ir_func generate_ir(program_ast); // 5. (可选) 中间代码优化 // optimize_ir(ir_func); // 6. 后端解释执行 或 生成汇编 #ifdef INTERPRET_MODE interpret(ir_func); #else generate_assembly(ir_func, “output.asm”); // 然后调用外部汇编器和链接器: nasm -f elf output.asm gcc -o output output.o #endif // 清理资源 destroy_ast(program_ast); destroy_ir(ir_func); destroy_symbol_table(); fclose(source); return 0; }回顾与进阶方向实现这样一个简单的编译器你已经触摸到了编译技术的核心骨架。但工业级编译器还有巨大的鸿沟需要跨越错误恢复与报告在词法、语法、语义的任何阶段遇到错误不应直接崩溃而应尝试恢复并继续分析收集尽可能多的错误信息反馈给用户。更丰富的类型系统引入float,char, 数组、指针、结构体。函数与作用域实现函数调用、参数传递、返回值、递归。优化在 IR 层面进行常量折叠、死代码消除、循环优化等。目标代码生成与优化寄存器分配算法如图着色、指令选择、窥孔优化。内存管理如果语言支持动态内存malloc/free需要实现或链接到运行时库。给实践者的建议从极小核心开始先让12*3能算出7。再逐步添加if、变量、循环。编写大量测试用例每一个新功能都对应一组输入输出测试。这能帮你快速定位回归错误。使用解析器生成工具可选当你理解了递归下降的原理后可以尝试使用flex和bison或ANTLR来替代手写的词法/语法分析器它们能处理更复杂的文法并自动生成解析代码。阅读经典开源代码TCCTiny C Compiler是一个极佳的学习对象它完整、相对简单、且是真实的编译器。编译器的世界远不止于此但通过亲手实现这个“迷你”版本你获得的不再是对黑盒的敬畏而是一种深刻的掌控感——你理解了从高级语言到机器指令这条漫长道路上每一个关键的中转站是如何工作的。下次当你使用gcc -O2时你看到的将不再是一个魔法命令而是一系列精妙协作的模块它们背后的思想与你刚刚构建的这个简单引擎一脉相承。