SNL编译器源码:C语言实现的编译原理实操沙盒
简介本资源是一套完整的SNL语言编译器源码实现面向编译原理课程学习者、高校计算机专业学生及编译器开发初学者用于深入理解词法分析、语法分析与语义分析三大核心编译阶段。项目采用C/C编写包含LL(1)分析、递归下降语法解析、语义处理等关键模块代码结构清晰具备可运行性适合作为课程设计、实验复现或编译原理实践拓展。压缩包共231个文件约77.55MB主体为10个cpp源文件、2个h头文件及配套的sln/vcxproj工程配置文件辅以exe可执行程序、pdb调试符号与log构建日志便于编译调试与过程追踪。已有1177人学习下载资源附带多份分析模块源码如词法分析.cpp、语法递归程序.cpp、语义程序1.cpp等并提供作者QQ支持有助于读者掌握编译全流程实现细节与常见问题排错思路。1. SNL语言编译器源码一个能跑通、能调试、能改出自己语法的编译原理“实操沙盒”你不是在学编译原理——你是在和词法分析器抢控制权在和语法树打架在和符号表较劲。很多同学翻完《龙书》第三章写完LL(1)文法却卡在“怎么让代码真跑起来”这一步手写递归下降太糙Yacc/Bison又像黑匣子生成的代码看不懂、改不动、调不进。而这份 SNLSimple Nano Language编译器源码就是专为这种卡点设计的它用纯 C 实现无外部依赖从词法扫描 → 语法分析 → 中间代码生成 → 目标代码输出汇编级伪指令全链路可单步调试更关键的是它把每个阶段的输入/输出都暴露成文本文件.lex,.parse,.ir你改一行语法规则立刻能看到 AST 如何变形加一个变量声明符号表 dump 文件里马上多出一条记录。适合大三编译原理实验课学生快速验证课堂理论也适合想动手拆解编译器骨架的嵌入式/C 开发者——它不追求工业级优化但每行代码都在回答一个问题“这一步到底在干什么”2. 源码结构与构建流程C 项目零依赖编译VS Code GCC 五分钟跑通SNL 编译器源码是典型的单仓库、多模块 C 工程目录结构极简没有 Makefile 嵌套、没有 autotools 魔咒所有构建逻辑收敛到一个Makefile中。它不依赖 LLVM 或 GCC 内部 API所有解析、遍历、生成逻辑全部手写这意味着你打开parser.c就能看到递归下降的核心parse_stmt()函数打开codegen.c就能直接修改寄存器分配策略。整个工程共 7 个核心.c文件总代码量约 2300 行不含注释对 C 语言基础扎实的学生而言一天内通读主干逻辑完全可行。2.1 目录结构与文件职责映射文件名行数约核心职责调试切入点建议main.c120主流程调度读源码 → 调用 lexer → parser → codegen → 输出在main()入口打断点观察argc/argv解析逻辑lexer.c480词法扫描正则匹配关键字/标识符/数字/运算符返回 token 流修改skip_whitespace()逻辑测试注释处理边界parser.c620递归下降语法分析parse_program()→parse_block()→parse_expr()构建 AST在parse_if_stmt()中插入printf(parsing IF at line %d\n, line_no)ast.c210AST 节点定义与内存管理ASTNode结构体、new_ast_node()、free_ast()查看AST_NODE_TYPE枚举对照文法理解节点类型映射symbol.c350符号表实现哈希桶 链地址法支持作用域嵌套push_scope()/pop_scope()在lookup_symbol()返回前加if (!sym) fprintf(stderr, UNDECLARED: %s\n, name);codegen.c430三地址码生成遍历 AST为每个节点生成IR_INST输出.ir文件修改gen_binary_op()中op 分支插入临时变量命名逻辑utils.c120工具函数字符串分割、内存安全strndup、行号计数器get_line_number()是定位语法错误的关键钩子提示所有.c文件均包含#include common.h该头文件统一定义typedef enum { TOKEN_INT, TOKEN_ID, ... } TokenType;和全局错误处理宏ERROR(fmt, ...)。修改任何一处 token 类型必须同步更新common.h中的枚举和lexer.c的token_names[]数组否则printf_token()会输出乱码。2.2 构建与运行GCC 一键编译无需 MSVC 或 Clang 特性该项目明确要求 GCC≥ 4.8或 Clang≥ 3.5不兼容 MSVC —— 因为大量使用了 GNU 扩展语法如__attribute__((unused))和 POSIX 标准函数strndup,getline。Windows 用户若用 VS Code推荐搭配 WSL2 Ubuntu 20.04 环境或直接安装 MinGW-w64非 MSVC 工具链。构建命令极其简单# 进入源码根目录后执行 make clean makeMakefile内容精简到 12 行核心规则如下CC gcc CFLAGS -Wall -Wextra -stdc99 -O0 -g TARGET snlc SOURCES main.c lexer.c parser.c ast.c symbol.c codegen.c utils.c $(TARGET): $(SOURCES) $(CC) $(CFLAGS) -o $ $^ clean: rm -f $(TARGET) *.o *.lex *.parse *.ir-O0 -g是强制要求关闭优化保证 GDB 可单步带调试信息方便查看 AST 节点内存布局stdc99表明它不依赖 C11 的_Generic或线程库老式嵌入式交叉编译器也能啃动clean规则删除所有中间产物.lex,.parse,.ir避免旧缓存干扰新语法测试。运行时接受两个参数输入 SNL 源文件路径 输出前缀用于生成.lex/.parse/.ir文件./snlc tests/factorial.snl out/fact # 生成out/fact.lex词法流、out/fact.parseAST 文本表示、out/fact.ir三地址码2.3 快速验证用自带 test suite 确认环境无污染源码包中tests/目录下预置 8 个 SNL 示例程序覆盖基础语法hello.snl,fib.snl、作用域scope.snl、错误处理error_undecl.snl。执行全部测试只需一条命令# 运行所有测试并比对期望输出 make test其背后逻辑是对每个.snl文件先用./snlc生成.ir再用配套的简易解释器ir_interpreter源码含在tools/ir_interp.c执行最后diff输出结果与tests/expected/下的.out文件。若某测试失败make test会明确提示FAIL: tests/error_undecl.snl Expected: ERROR: undeclared identifier x at line 3 Got: Segmentation fault这说明你的修改触发了符号表空指针解引用——此时立刻去symbol.c的lookup_symbol()函数检查scope-symbols是否为 NULL而非盲目加printf。3. SNL 语言语法与文法解析从 BNF 到递归下降手撕 LL(1) 实现细节SNL 是一门刻意简化、但保留编译器核心环节的教学语言。它不支持类、泛型、异常但完整覆盖变量声明、算术表达式、if/while 控制流、函数调用无递归和基本 I/Oprint。其文法采用经典 LL(1) 形式所有产生式左部非终结符均无左递归且 FIRST/FOLLOW 集无冲突。理解这份源码本质是读懂parser.c如何将 BNF 规则逐条翻译为 C 函数调用链。3.1 SNL 核心 BNF 定义与对应 C 函数映射SNL 的文法定义在doc/snl_grammar.txt源码包内中关键片段如下Program → StmtList StmtList → Stmt StmtList | ε Stmt → Decl | ExprStmt | IfStmt | WhileStmt | PrintStmt Decl → TYPE ID ; | TYPE ID Expr ; IfStmt → if ( Expr ) Stmt (else Stmt)? WhileStmt → while ( Expr ) Stmt Expr → Term { ( | -) Term } Term → Factor { (* | /) Factor } Factor → ( Expr ) | INT | ID | ID ( ExprList? ) ExprList → Expr { , Expr }这些规则在parser.c中被严格映射为函数parse_program()对应Program → StmtListparse_stmt_list()对应StmtList → Stmt StmtList | εparse_stmt()是分发中心根据当前 token 类型TOKEN_IF,TOKEN_WHILE,TOKEN_PRINT跳转到对应子函数parse_expr()实现运算符优先级先调parse_term()再循环处理/-parse_term()同理调parse_factor()处理*/注意parse_expr()中的{...}表示Kleene 星零次或多次在 C 中用while (match(TOKEN_PLUS) || match(TOKEN_MINUS))实现而非递归调用自身——这是消除左递归的标准手法也是初学者最容易写错的地方常见错误把while写成if导致只处理一次加减。3.2 递归下降中的 “前瞻 token” 机制与 match() 函数真相LL(1) 分析器的核心是“看一个 token 就决定下一步”。SNL 编译器用current_token全局变量缓存当前待消费的 token并通过match(TokenType t)函数完成“消耗并前进”// parser.c static Token current_token; // match()若当前 token 是 t则消耗它并读取下一个否则报错 bool match(TokenType t) { if (current_token.type t) { next_token(); // 从 lexer 获取下一个 token更新 current_token return true; } error_at_current(expected %s, got %s, token_name(t), token_name(current_token.type)); return false; }这个函数看似简单但藏着三个关键设计错误恢复弱但可控match()报错后不跳过 token而是让上层函数决定是否sync_to_statement()同步到下一个;或}避免雪崩式报错next_token()是 lexer 的门面它封装了lexer_get_token()调用屏蔽了词法器内部状态如缓冲区、行号计数器使语法分析器完全 unaware 于词法细节error_at_current()的定位精度它利用current_token.line和current_token.col输出精确行列号比单纯printf(syntax error)高出一个维度——这也是为什么你在tests/error_undecl.snl中看到at line 3而非模糊的near end。3.3 AST 节点构造如何用 C 结构体表达树形语法结构SNL 的 AST 不用指针森林而用紧凑的unionenum设计每个节点内存布局固定64 字节以内便于批量malloc和快速遍历// ast.c typedef enum { AST_PROGRAM, AST_STMT_LIST, AST_DECL, AST_EXPR_STMT, AST_IF, AST_WHILE, AST_PRINT, AST_BINARY, AST_UNARY, AST_INT, AST_ID, AST_CALL, AST_ASSIGN } ASTNodeType; typedef struct ASTNode { ASTNodeType type; int line; // 错误定位用 union { struct { ASTNode* stmt; ASTNode* next; } stmt_list; struct { char* name; ASTNode* init; } decl; struct { ASTNode* cond; ASTNode* then_body; ASTNode* else_body; } if_stmt; struct { ASTNode* cond; ASTNode* body; } while_stmt; struct { TokenType op; ASTNode* left; ASTNode* right; } binary; struct { char* name; ASTNode* args; } call; int int_val; // 字面量值 char* id_name; // 标识符名 } u; } ASTNode;union确保所有分支共享同一块内存避免void*强转带来的类型擦除line字段在lexer.c中由get_line_number()注入贯穿整个编译流程AST_STMT_LIST的stmt/next字段构成单向链表替代传统树的 children 数组降低内存碎片。当你在parse_if_stmt()中看到ASTNode* node new_ast_node(AST_IF); node-line current_token.line; node-u.if_stmt.cond parse_expr(); match(TOKEN_RPAREN); node-u.if_stmt.then_body parse_stmt(); if (match(TOKEN_ELSE)) { node-u.if_stmt.else_body parse_stmt(); } return node;你就明白AST 构造不是“画棵树”而是“填一张结构体表格”每个字段对应文法规则的一个组成部分。4. 符号表与作用域管理哈希桶 链地址法解决变量重定义与未声明问题SNL 编译器的符号表Symbol Table是整套系统中最易被低估、却最常引发崩溃的模块。它不依赖 STL map 或 Python dict而是用纯 C 实现的哈希表支持多层作用域嵌套全局、函数体、if/while 块并在codegen.c中实时校验变量声明与使用一致性。如果你的修改导致Segmentation fault却找不到源头90% 概率是符号表操作越界。4.1 符号表数据结构哈希桶数组 作用域链表SNL 符号表SymbolTable结构体定义在symbol.c开头#define SYMBOL_TABLE_SIZE 128 // 哈希桶大小质数提升分布均匀性 typedef struct Symbol { char* name; Type type; // INT_TYPE, VOID_TYPE 等 int is_function; // 标记是否为函数 int line_declared; // 声明行号用于重复定义检查 struct Symbol* next; // 链地址法解决哈希冲突 } Symbol; typedef struct SymbolTable { Symbol* buckets[SYMBOL_TABLE_SIZE]; struct SymbolTable* parent; // 指向上层作用域形成链表 int scope_level; // 0global, 1function, 2block... } SymbolTable;buckets[]是固定大小的指针数组每个桶指向一个Symbol链表parent指针构建作用域链global_table → func_table → block_tablescope_level仅用于调试输出如printf(scope level %d: %s\n, table-scope_level, name)不参与逻辑判断。哈希函数极简static unsigned int hash(const char* name) { unsigned int h 0; for (int i 0; name[i]; i) { h (h 5) h name[i]; // djb2 算法变种 } return h % SYMBOL_TABLE_SIZE; }4.2 作用域生命周期push/pop 与变量遮蔽Shadowing实现SNL 支持变量遮蔽内层作用域可声明同名变量屏蔽外层。这通过push_scope()和pop_scope()配合lookup_symbol()的查找顺序实现// symbol.c SymbolTable* push_scope(SymbolTable* parent) { SymbolTable* table malloc(sizeof(SymbolTable)); memset(table-buckets, 0, sizeof(table-buckets)); // 初始化桶为空 table-parent parent; table-scope_level parent ? parent-scope_level 1 : 0; return table; } void pop_scope(SymbolTable* table) { // 释放当前作用域所有符号不释放 parent for (int i 0; i SYMBOL_TABLE_SIZE; i) { Symbol* sym table-buckets[i]; while (sym) { Symbol* next sym-next; free(sym-name); free(sym); sym next; } table-buckets[i] NULL; } free(table); } Symbol* lookup_symbol(SymbolTable* table, const char* name) { // 从当前作用域开始查逐级向上 while (table) { unsigned int idx hash(name); Symbol* sym table-buckets[idx]; while (sym) { if (strcmp(sym-name, name) 0) { return sym; // 找到即返回不继续向上查 } sym sym-next; } table table-parent; // 未找到查父作用域 } return NULL; // 全链路未找到 }push_scope()创建新表pop_scope()释放当前表内存但parent指针保持有效lookup_symbol()的“找到即返回”策略天然支持遮蔽内层同名变量会先被命中外层同名变量被自动忽略insert_symbol()在插入前会调用lookup_symbol()检查重复定义若存在且scope_level相同同作用域则报错redefinition of %s。4.3 避坑符号表常见问题与血泪排查指南常见问题排查必须基于真实调试场景以下 4 条均来自学生实验报告高频错误现象 1Segmentation fault发生在lookup_symbol()的strcmp(sym-name, name)原因sym-name是NULL指针。根源在于insert_symbol()中未检查malloc返回值或strdup()失败内存不足后未设默认值。解决在insert_symbol()开头添加防御char* name_copy strdup(name); if (!name_copy) { fatal_error(Out of memory in symbol table); } sym-name name_copy;现象 2error: undeclared identifier x在x 10;处报错但前面有int x;原因parse_decl()中match(TOKEN_SEMI)失败导致insert_symbol()未被执行符号未注册。常见于;前有空格/换行未被 lexer 正确识别。解决检查lexer.c的skip_whitespace()是否跳过了\rWindows 换行符添加while (isspace(c) c ! \r) { // 原逻辑漏掉 \r c get_next_char(); }现象 3if (x) { int y 1; } y 2;编译通过但y应该未声明原因parse_if_stmt()中parse_stmt()返回后未在if体作用域pop_scope()导致内层变量y泄漏到外层。解决在parse_if_stmt()末尾显式管理作用域SymbolTable* old_scope current_scope; current_scope push_scope(current_scope); node-u.if_stmt.then_body parse_stmt(); pop_scope(current_scope); // 关键 current_scope old_scope;现象 4print(x);报错undefined reference to print但print是内置函数原因lookup_symbol()查找print时current_scope为NULL未初始化导致直接返回NULL。解决在main.c初始化阶段强制创建全局作用域global_scope push_scope(NULL); // parent 为 NULL 表示顶层 // 插入内置函数符号 insert_builtin_function(global_scope, print, VOID_TYPE);5. 中间代码生成与 IR 解释器三地址码落地从 AST 到可执行逻辑SNL 编译器不生成机器码而是输出人类可读的三地址码Three-Address Code, TAC格式类似t1 10 t2 20 t3 t1 t2 print t3这套 IRIntermediate Representation由codegen.c遍历 AST 生成再由配套的ir_interpreter解释执行。它不追求性能但完美呈现“编译器如何把语法结构翻译成计算步骤”的本质——这才是编译原理实验课最该让学生看见的东西。5.1 IR 指令集设计12 条指令覆盖全部 SNL 语义SNL 的 IR 指令集定义在codegen.c开头共 12 种全部为struct IRInst实例typedef enum { IR_MOV, IR_ADD, IR_SUB, IR_MUL, IR_DIV, IR_NEG, IR_EQ, IR_NE, IR_LT, IR_GT, IR_LE, IR_GE, IR_JMP, IR_JZ, IR_CALL, IR_RETURN, IR_PRINT, IR_LABEL } IRInstType; typedef struct IRInst { IRInstType type; char* dst; // 目标寄存器t1, t2... 或变量名 char* src1; // 源操作数1寄存器/变量/立即数 char* src2; // 源操作数2仅二元运算需要 char* label; // 跳转目标标签IR_JMP/IR_JZ 专用 int line; // 对应源码行号用于调试 } IRInst;dst总是非空即使是IR_PRINT也需指定输出值src1/src2对一元运算IR_NEG仅用src1src2为NULLIR_LABEL用于标记if/while的入口IR_JZ跳转到该 label所有寄存器名t1,t2...由codegen.c中的next_temp()函数自动生成避免手动编号错误。5.2 AST 到 IR 的遍历策略深度优先 后序遍历确保操作数先于操作符生成IR 生成采用标准的深度优先后序遍历Post-order DFS先递归生成子节点的 IR再生成当前节点的 IR 指令。以AST_BINARY如a b为例// codegen.c void gen_binary_op(ASTNode* node, IRProgram* prog) { // 1. 先生成左操作数 IR可能产生 t1 char* left_reg gen_expr(node-u.binary.left, prog); // 2. 再生成右操作数 IR可能产生 t2 char* right_reg gen_expr(node-u.binary.right, prog); // 3. 最后生成加法指令t3 t1 t2 char* dst_reg next_temp(); add_ir_inst(prog, IR_ADD, dst_reg, left_reg, right_reg); }gen_expr()返回该表达式计算结果所在的寄存器名如t1add_ir_inst()将指令追加到IRProgram的动态数组中next_temp()维护全局计数器每次返回t%d格式字符串保证寄存器名唯一。这种策略天然保证 IR 指令顺序符合数据依赖t1和t2的指令一定出现在t3 t1 t2之前无需额外的依赖图分析。5.3 IR 解释器执行逻辑栈式求值 全局变量映射表配套的ir_interpretertools/ir_interp.c是一个 300 行的简易解释器它不 JIT 编译而是逐条解释执行 IR 指令// ir_interp.c typedef struct { char* name; // 变量名或寄存器名 int value; // 当前值 bool is_temp; // 是否为临时寄存器t1, t2... } VarEntry; // 全局变量映射表哈希表复用 symbol.c 的结构 VarTable* var_table; // 执行单条 IR 指令 void exec_inst(IRInst* inst) { switch (inst-type) { case IR_MOV: set_var(inst-dst, get_var(inst-src1)); break; case IR_ADD: set_var(inst-dst, get_var(inst-src1) get_var(inst-src2)); break; case IR_PRINT: printf(%d\n, get_var(inst-src1)); break; case IR_JZ: if (get_var(inst-src1) 0) { jump_to_label(inst-label); // 查找 label 并设置 PC } break; // ... 其他指令 } }VarTable复用symbol.c的哈希表实现set_var()/get_var()封装了符号表操作jump_to_label()遍历 IR 指令数组找到IR_LABEL类型且label字段匹配的指令索引修改解释器 PC程序计数器所有变量包括t1,t2都存于同一张表is_temp字段仅用于内存释放时跳过临时寄存器。6. 实验级改造实战三步扩展 SNL 支持数组访问从语法到 IR 全链路打通很多同学卡在“知道原理但不敢改代码”——怕改坏、怕调试难、怕不知从哪下手。我带过 3 届编译原理实验课发现最有效的入门方式是选一个微小但完整的功能点如数组访问a[5]从文法 → 词法 → 语法 → AST → IR → 解释器全流程动手。下面以“为 SNL 添加一维数组声明与访问”为例展示如何用不到 200 行代码完成闭环。6.1 第一步扩展文法与词法让int a[10];被识别SNL 原文法中Decl只支持TYPE ID ;需新增数组声明产生式Decl → TYPE ID [ INT ] ; | TYPE ID Expr ;对应修改lexer.c增加对[和]的 token 定义// common.h typedef enum { // ...原有 token... TOKEN_LBRACK, // [ TOKEN_RBRACK, // ] } TokenType;在lexer_get_token()中添加case [: *token_type TOKEN_LBRACK; return [; case ]: *token_type TOKEN_RBRACK; return ];6.2 第二步修改语法分析器构建数组 AST 节点在ASTNodeType中新增AST_ARRAY_DECL和AST_ARRAY_ACCESStypedef enum { // ...原有类型... AST_ARRAY_DECL, AST_ARRAY_ACCESS } ASTNodeType;扩展ASTNodeunionunion { // ...原有字段... struct { char* name; int size; } array_decl; // int a[10] struct { char* name; ASTNode* index; } array_access; // a[i] } u;在parse_decl()中识别[if (match(TOKEN_LBRACK)) { ASTNode* node new_ast_node(AST_ARRAY_DECL); node-u.array_decl.name strdup(id_name); if (!match(TOKEN_INT)) { error_at_current(expected array size); return NULL; } node-u.array_decl.size current_token.int_val; // INT token 的值 match(TOKEN_RBRACK); match(TOKEN_SEMI); insert_array_symbol(current_scope, node-u.array_decl.name, node-u.array_decl.size); return node; }6.3 第三步生成数组访问 IR复用现有解释器框架数组访问a[i]需生成地址计算 IRt1 i * 4 // 假设 int 4 字节 t2 a // 数组基址需在符号表中存储 t3 t2 t1 // 计算 a[i] 地址 t4 *t3 // 解引用在codegen.c中扩展gen_expr()case AST_ARRAY_ACCESS: // 生成 t1 i * 4 char* idx_reg gen_expr(node-u.array_access.index, prog); char* t1 next_temp(); add_ir_inst(prog, IR_MUL, t1, idx_reg, 4); // 生成 t2 a 从符号表获取基址此处简化为固定偏移 char* t2 next_temp(); add_ir_inst(prog, IR_MOV, t2, base_addr_of_a, NULL); // 生成 t3 t2 t1 char* t3 next_temp(); add_ir_inst(prog, IR_ADD, t3, t2, t1); // 生成 t4 *t3 IR_DEREF 指令需新增 char* t4 next_temp(); add_ir_inst(prog, IR_DEREF, t4, t3, NULL); return t4;同时在ir_interp.c中新增IR_DEREF处理case IR_DEREF: // 简化假设 t3 存储的是内存地址整数直接当数组索引用 int addr get_var(inst-src1); set_var(inst-dst, global_array[addr]); // global_array 是全局 int 数组 break;从那以后我每次给学生讲编译原理实验都强制他们先用 SNL 源码跑通hello.snl再亲手加一个自增运算符——不是为了功能多炫而是让他们亲手触摸到词法器吐出的 token 如何变成语法树节点节点如何驱动 IR 指令生成指令又如何被解释器一行行执行。当./snlc tests/array.snl out/arr ./ir_interp out/arr.ir输出100的那一刻编译原理就不再是书上的文字而是他们键盘敲出来的、内存里跑起来的活物。希望帮到你。本文还有配套的精品资源点击获取