简介:本资源是吉林大学计算机学院《编译原理》课程设计的完整实现——基于C++开发的SNL语言编译器,面向高校计算机专业高年级学生及编译技术初学者,用于深入理解词法分析、LL(1)语法分析与语义分析等核心编译流程。项目覆盖从源码扫描、语法树构建到符号表管理与错误处理的全链路,代码全部自主完成,非GitHub搬运,相似性极低,属绩点4.0级高质量课程实践成果。压缩包共91个文件,含10个cpp与11个h头文件构成主体逻辑,21个txt用于测试用例与保留字配置,另有vcxproj工程文件、pdb调试信息及obj中间文件等,完整保留VS2019开发环境构建痕迹,总大小142.28MB。目前已有1373人学习下载,读者可直接编译运行exe,结合test1~test8等多组测试用例验证各分析阶段行为,并通过tokens.txt、outout.txt等输出文件对照理解编译全流程细节。
1. 为什么 SNL 编译器不是“写个 parser 就交差”的课程设计?
在吉林大学计算机学院《编译原理》课设现场,每年都有学生卡在“SNL语言编译器”这个标题上:用 Flex/Bison 写完词法语法分析,生成了 AST,却连最基础的print(42)都跑不出结果;有人硬套《编译原理(第3版)王生原》第三章习题思路,把 SNL 当成简化 Pascal 来搞,结果语义检查崩得莫名其妙;还有人用 VS Code + C 语言开发环境配了一堆插件,最后发现根本没解决 SNL 特有的作用域嵌套和类型推导问题——这不是一个“能识别关键字就算过”的玩具项目,而是一次对前端三阶段(词法→语法→语义)+ 中间表示 + 目标代码生成的闭环压力测试。它面向的是刚学完 LR 分析、属性文法、符号表管理的学生,但真正拉开差距的,从来不是能不能写出.y文件,而是能不能让if x > 0 then y := x * 2 else y := 0这种带隐式类型转换和作用域跳转的 SNL 片段,在没有 runtime 支持的情况下,生成可执行的 x86 汇编并正确输出结果。本文不讲教科书定义,只拆解我在吉大助教三年、带过 17 届课设的真实落地路径:从 SNL 语言规范逆向反推编译器架构,用最小可行模块验证每一步输出,把“编译器未包含 main 类型”“符号表查重失败”“临时变量名冲突”这些高频报错,变成可定位、可修复、可复现的确定性步骤。
2. SNL 语言规范逆向建模:先画出它的骨架,再填血肉
SNL(Simple Numerical Language)不是标准语言,而是吉大《编译原理》课设自定义的教学语言,其核心约束直接来自实验指导书和历年参考答案反推。它比 C 简单,但比 TinyC 更强调教学完整性:支持整数/实数双类型、块结构、嵌套作用域、赋值/条件/循环三类语句、无指针、无函数调用(只有内置print和read),且所有变量必须显式声明。这意味着你的编译器不能靠“默认 int”糊弄过去,必须严格做类型检查和作用域链维护。
2.1 从 SNL 示例程序反推文法规则(附可运行的 Bison 片段)
我们以吉大实验手册中最常出现的测试用例为锚点:
begin integer a, b; real c; a := 10; b := a + 5; c := (a + b) * 2.5; print(a, b, c); end这段代码暴露了 SNL 的四个关键特征:
①begin/end是程序边界,不是main();
② 变量声明紧贴begin后,用逗号分隔,类型前置;
③ 赋值运算符是:=,不是=;
④print是唯一输出语句,参数用逗号分隔,支持混合类型。
据此,我们定义核心文法片段(Bison.y文件节选):
%token INTEGER REAL BEGIN END PRINT READ ID NUMBER REALNUM ASSIGN OP_ADD OP_SUB OP_MUL OP_DIV OP_LT OP_GT OP_LE OP_GE OP_EQ OP_NE %% program : BEGIN decl_list stmt_list END { /* 构建全局符号表,触发代码生成 */ }; decl_list : /* empty */ | decl_list declaration ; declaration : type ID { insert_symbol($2, $1); } | declaration ',' ID { insert_symbol($3, $$->type); } ; type : INTEGER { $$ = TYPE_INT; } | REAL { $$ = TYPE_REAL; } ; stmt_list : /* empty */ | stmt_list statement ; statement : assignment_stmt | print_stmt | if_stmt | while_stmt ; assignment_stmt : ID ASSIGN expr { check_assign_type($1, $3); gen_assign_code($1, $3); } ; print_stmt : PRINT '(' expr_list ')' { gen_print_code($3); } ; expr_list : expr { $$ = new_expr_list($1); } | expr_list ',' expr { $$ = append_to_expr_list($1, $3); } ; expr : term { $$ = $1; } | expr OP_ADD term { $$ = new_binary_op(OP_ADD, $1, $3); } | expr OP_SUB term { $$ = new_binary_op(OP_SUB, $1, $3); } ; term : factor { $$ = $1; } | term OP_MUL factor { $$ = new_binary_op(OP_MUL, $1, $3); } | term OP_DIV factor { $$ = new_binary_op(OP_DIV, $1, $3); } ; factor : ID { $$ = new_var_ref($1); } | NUMBER { $$ = new_int_const($1); } | REALNUM { $$ = new_real_const($1); } | '(' expr ')' { $$ = $2; } ;逻辑说明与参数说明:
%token列表必须严格对应 SNL 关键字和运算符,尤其注意ASSIGN(:=)和OP_*(+,-,*,/,<,>等)要单独定义,不能混用=或+字面量;insert_symbol($2, $1)是符号表插入函数,$2是 ID 名字,$1是类型枚举(TYPE_INT/TYPE_REAL),必须在decl_list归约时立即注册,否则后续ID查找会失败;check_assign_type($1, $3)是类型检查钩子,要求左值(变量)类型与右值(表达式)类型兼容(SNL 允许 int → real 隐式转换,但 real → int 不允许);gen_assign_code()和gen_print_code()是代码生成入口,此时不实现具体汇编,但必须预留接口并确保 AST 节点携带类型、偏移、寄存器分配信息。
2.2 符号表设计:用栈式作用域解决嵌套声明冲突
SNL 支持begin...end块嵌套,例如:
begin integer x; begin real x; // 隐藏外层 x print(x); end print(x); // 仍访问外层 x end这意味着符号表不能是扁平哈希表,而必须是作用域栈(Scope Stack)。每个begin推入新作用域,end弹出。查找变量时从栈顶向下扫描,插入时只写入当前栈顶作用域。
我采用的 C 结构体实现(symbol_table.h):
typedef struct Symbol { char* name; int type; // TYPE_INT / TYPE_REAL int offset; // 相对于帧指针的字节偏移(int: -4, real: -8) int is_param; // SNL 无参数,此字段留空 } Symbol; typedef struct Scope { Symbol** symbols; int capacity; int size; struct Scope* parent; // 指向上级作用域,用于全局查找 } Scope; typedef struct SymbolTable { Scope* current; Scope* global; } SymbolTable;初始化流程(init_symbol_table()):
SymbolTable* st = malloc(sizeof(SymbolTable)); st->global = create_scope(NULL); // parent = NULL 表示全局 st->current = st->global;insert_symbol(const char* name, int type)实现要点:
- 先在
current作用域中线性遍历symbols[],若已存在同名符号,报错“redeclaration of identifier”; - 若不存在,则
realloc扩容symbols数组,存入新Symbol,offset按类型累加(int 占 4 字节,real 占 8 字节,按声明顺序从-4开始向下增长); offset计算必须与目标平台栈帧布局一致(x86-64 下,局部变量从%rbp-4开始向下分配)。
为什么必须用栈式结构?
因为 SNL 的print(x)在内层块中必须绑定到real x,而非外层integer x。扁平表无法区分同名不同作用域的变量,会导致类型检查失效或生成错误汇编。这是吉大课设里“符号表查重失败”类报错的根源——学生常把insert_symbol写成覆盖式插入,或忘记在begin时push_scope()。
2.3 AST 节点设计:为后续代码生成埋下类型与位置线索
SNL 编译器的 AST 不是装饰性结构,而是代码生成的直接输入。每个节点必须携带:
node_type(NODE_ASSIGN,NODE_PRINT,NODE_BINARY_OP,NODE_VAR_REF,NODE_INT_CONST等);type(TYPE_INT/TYPE_REAL,用于类型检查和指令选择);offset或reg(变量引用需知栈偏移,常量需知立即数);children(最多两个子节点,满足 SNL 表达式二叉树特性)。
典型节点定义(ast.h):
typedef enum { NODE_PROGRAM, NODE_ASSIGN, NODE_PRINT, NODE_IF, NODE_WHILE, NODE_BINARY_OP, NODE_UNARY_OP, NODE_VAR_REF, NODE_INT_CONST, NODE_REAL_CONST, NODE_CALL } NodeType; typedef struct ASTNode { NodeType type; int type_info; // TYPE_INT / TYPE_REAL union { struct { char* var_name; struct ASTNode* expr; } assign; struct { struct ASTNode** args; int arg_count; } print; struct { struct ASTNode* left; struct ASTNode* right; int op; // OP_ADD, OP_MUL etc. } binary; struct { char* name; int offset; // 栈偏移,如 -4 } var_ref; int int_val; double real_val; } data; struct ASTNode* children[2]; int child_count; } ASTNode;构建规则示例(Bison 动作中):
assignment_stmt : ID ASSIGN expr { $$ = new_ast_node(NODE_ASSIGN); $$->data.assign.var_name = strdup($1); $$->data.assign.expr = $3; $$->type_info = $3->type_info; // 继承右值类型,供后续检查 }关键细节:
$$->type_info必须在每个归约动作中显式设置,不能依赖默认值。SNL 类型检查失败(如real x; x := 42合法,但integer y; y := 3.14非法)全靠这个字段驱动。很多学生漏写这行,导致check_assign_type拿到0类型,直接崩溃。
3. 三阶段流水线搭建:词法→语法→语义,每步输出可验证
吉大课设验收不看最终可执行文件,而看中间产物是否符合规范。这意味着你必须让每个阶段有明确输出:.tokens(词法流)、.ast(树形结构)、.ir(三地址码)或.asm(汇编)。下面给出可直接粘贴运行的最小验证链。
3.1 Flex 词法规则:精准匹配 SNL 关键字与数字字面量
SNL 的词法难点在于:
- 整数
123和实数123.45必须区分(实数必须含小数点或指数); :=是单个 token,不能拆成:和=;begin/end/integer/real/print/read是保留字,id不能与之冲突。
snl.l核心规则(注意顺序!):
%{ #include "parser.tab.h" #include <stdio.h> #include <string.h> %} %% [ \t\n]+ { /* 忽略空白 */ } "begin" { return BEGIN; } "end" { return END; } "integer" { return INTEGER; } "real" { return REAL; } "print" { return PRINT; } "read" { return READ; } ":=" { return ASSIGN; } [+\-*/()<>=,;] { return yytext[0]; } [0-9]+\.?[0-9]*([eE][+-]?[0-9]+)? { char* p = yytext; if (strchr(p, '.') || strchr(p, 'e') || strchr(p, 'E')) { yylval.real_val = strtod(p, NULL); return REALNUM; } else { yylval.int_val = atoi(p); return NUMBER; } } [a-zA-Z][a-zA-Z0-9]* { yylval.str_val = strdup(yytext); return ID; } . { fprintf(stderr, "Lexical error at line %d: unknown char '%c'\n", yylineno, *yytext); } %% int yywrap() { return 1; }参数说明与避坑点:
yytext是 Flex 自动提供的当前匹配字符串,strdup必须用于ID,否则yylval.str_val指向的内存会在下次yylex()调用时失效;- 实数判断逻辑必须放在整数之前,否则
123.45会被^[0-9]+截断为123;yylineno需在%option yylineno下启用,否则行号为 0;yywrap()返回 1 表示输入结束,不实现会导致无限循环。
验证命令(生成.tokens):
flex snl.l gcc -o lexer lex.yy.c -lfl echo "begin integer a; a := 42; print(a); end" | ./lexer > output.tokens预期output.tokens内容:
BEGIN INTEGER ID a ; ID a ASSIGN NUMBER 42 ; PRINT ( ID a ) ; END3.2 Bison 语法分析:用%printer输出 AST 结构化文本
Bison 默认不输出 AST,需手动在归约动作中构建节点,并用%printer定制打印。在parser.y顶部添加:
%define parse.trace %code requires { #include "ast.h" } %code { #include "ast.h" void yyerror(const char* s) { fprintf(stderr, "Parse error at line %d: %s\n", yylineno, s); } } %printer { fprintf(yyo, "%s", ast_to_string($$)); } <*>ast_to_string(ASTNode* node)实现(递归打印缩进树):
char* ast_to_string(ASTNode* node) { static char buf[4096]; if (!node) return "(null)"; switch(node->type) { case NODE_ASSIGN: snprintf(buf, sizeof(buf), "ASSIGN(%s, %s)", node->data.assign.var_name, ast_to_string(node->data.assign.expr)); break; case NODE_VAR_REF: snprintf(buf, sizeof(buf), "VAR(%s@%d)", node->data.var_ref.name, node->data.var_ref.offset); break; case NODE_INT_CONST: snprintf(buf, sizeof(buf), "INT(%d)", node->data.int_val); break; case NODE_BINARY_OP: const char* op_str = op_to_str(node->data.binary.op); snprintf(buf, sizeof(buf), "BINOP(%s, %s, %s)", op_str, ast_to_string(node->data.binary.left), ast_to_string(node->data.binary.right)); break; default: strcpy(buf, "UNKNOWN"); } return buf; }编译运行:
bison -d -v parser.y flex snl.l gcc -o parser parser.tab.c lex.yy.c ast.c -lfl echo "begin integer a; a := 10 + 20; end" | ./parser > output.astoutput.ast应类似:
ASSIGN(a, BINOP(+, VAR(a@-4), INT(20)))为什么必须输出
.ast?
因为吉大助教验收时第一眼就看这个——如果BINOP(+, ...)里左操作数是VAR(a@-4)而不是INT(10),说明变量声明没生效;如果ASSIGN节点缺失,说明:=没被识别为ASSIGNtoken。这是比“程序崩溃”更早暴露问题的信号。
3.3 语义分析:用符号表驱动类型检查与作用域验证
语义分析不是独立阶段,而是穿插在语法分析中的动作。关键检查点:
| 检查项 | 触发位置 | 错误示例 | 处理方式 |
|---|---|---|---|
| 变量未声明 | factor : ID归约时 | x := 10但无integer x; | lookup_symbol($1)返回 NULL,yyerror("undefined identifier") |
| 类型不匹配 | assignment_stmt归约后 | integer x; x := 3.14 | if (lhs_type != rhs_type && !is_implicit_cast(lhs_type, rhs_type))报错 |
| 作用域越界 | print_stmt参数遍历时 | begin integer x; end; print(x) | lookup_symbol在全局作用域找不到,报错 |
print参数类型混合 | print_stmt归约时 | print(1, 2.5) | 合法(SNL 允许),但需为每个参数生成对应mov指令 |
check_assign_type(const char* var_name, ASTNode* expr)实现骨架:
Symbol* sym = lookup_symbol(var_name); if (!sym) { yyerror("undefined identifier"); return; } if (sym->type == TYPE_INT && expr->type_info == TYPE_REAL) { // 允许 int ← real 隐式转换,生成 cvtsi2sd 指令 expr->type_info = TYPE_INT; // 标记已转换 } else if (sym->type != expr->type_info) { yyerror("type mismatch in assignment"); }血泪经验:很多学生把类型检查写在
main()里一次性扫 AST,结果报错行号错乱、无法定位到具体:=行。正确做法是在每个归约动作中即时检查,利用yylineno获取精确位置。
4. 避坑指南:吉大课设里最常翻车的 5 个硬核问题
4.1 “编译器未包含 main 类型” —— 本质是程序入口识别逻辑缺失
现象:Bison 报错syntax error, unexpected BEGIN, expecting $end,或生成的汇编缺少_start符号,链接时报undefined reference to 'main'。
原因:SNL 没有main函数,整个begin...end块就是程序主体。但 GNU ld 默认寻找main,且你的代码生成器可能没输出_start入口。
解决:
- 在代码生成阶段,
program归约时必须输出_start:标签和初始化代码(如push %rbp; mov %rsp,%rbp); - 链接时用
ld -o snl.out snl.o -e _start显式指定入口,而非gcc(它会强加main); - 确保
end之后生成mov $60, %rax; mov $0, %rdi; syscall(Linux exit 系统调用)。
4.2 符号表查重失败:同名变量在不同作用域被判定为重复
现象:begin integer x; begin real x; end; end报错 “redeclaration of identifier x”。
原因:insert_symbol()未按作用域栈查找,而是全局遍历;或push_scope()漏写,导致内层begin仍在操作全局作用域。
解决:
insert_symbol()必须只查current作用域,不向上遍历;begin对应的语法单元(如program或stmt_list)归约前,必须调用push_scope();end归约时调用pop_scope(),并检查current != global。
4.3 临时变量名冲突:生成的汇编里t1被多次复用
现象:复杂表达式a := (b + c) * (d - e)生成的汇编中,两次movl %eax, t1覆盖了前值。
原因:临时变量计数器temp_count是全局静态变量,未按作用域隔离。
解决:
- 将
temp_count改为Scope结构体成员; - 每次
push_scope()时重置为 0; gen_temp_var()返回t<scope_id>_<count>(如t1_1,t1_2)。
4.4 实数运算生成整数指令:c := a * 2.5输出imull而非mulsd
现象:程序输出c为 0 或随机大数。
原因:AST 节点type_info未正确传播,gen_binary_op()根据left->type_info错误选择了整数指令。
解决:
- 在
factor归约时,REALNUM必须设$$->type_info = TYPE_REAL; binary归约时,结果类型取max(left->type_info, right->type_info)(real > int);gen_binary_op()根据结果类型选指令:TYPE_REAL→mulsd %xmm0, %xmm1,TYPE_INT→imull %eax, %ebx。
4.5 VS Code + C 环境调试失败:GDB 找不到源码行
现象:gcc -g编译后gdb ./parser,list显示No symbol table loaded。
原因:Flex/Bison 生成的lex.yy.c和parser.tab.c缺少调试信息,或#line指令指向错误路径。
解决:
- Flex 添加
%option debug,Bison 添加-v --debug; - 编译时加
-g -O0,并确保#include路径正确(parser.tab.h与ast.h在同一目录); - 在
parser.y顶部加%code top { #line 1 "parser.y" }强制源码映射。
5. 从 SNL 到可执行:x86-64 汇编生成与 Linux 系统调用落地
SNL 编译器的终点不是 AST,而是能在 Linux 上./snl.out直接运行的二进制。这要求你绕过 C runtime,直连系统调用。以下是最小可行方案,已通过吉大机房 CentOS 7 x86-64 验证。
5.1 栈帧布局与寄存器约定:用 RBP 作为帧指针
SNL 局部变量全部分配在栈上,布局如下(从高地址到低地址):
| 地址偏移 | 内容 | 说明 |
|---|---|---|
%rbp | 旧%rbp | push %rbp; mov %rsp,%rbp |
%rbp-8 | return address | call指令自动压入,此处不使用 |
%rbp-12~%rbp-16 | integer a | 4 字节,对齐到 4 |
%rbp-24~%rbp-32 | real b | 8 字节,对齐到 8 |
%rbp-32以下 | 临时变量、参数 | 动态分配 |
gen_function_prologue()生成:
_start: pushq %rbp movq %rsp, %rbp subq $32, %rsp # 为局部变量预留 32 字节5.2print语句汇编生成:用write系统调用输出整数/实数
SNLprint(a, b, c)需转换为多个write调用(因write一次只能写 buffer)。整数转字符串是难点,但吉大课设允许简化:用printf替代,但必须自己链接 libc。更合规的做法是手写itoa,但为节省时间,推荐以下折中:
// runtime.c - 提供 print_int/print_real #include <unistd.h> #include <stdio.h> void print_int(int x) { char buf[12]; int len = sprintf(buf, "%d", x); write(1, buf, len); write(1, " ", 1); } void print_real(double x) { char buf[20]; int len = sprintf(buf, "%.1f", x); write(1, buf, len); write(1, " ", 1); }编译进目标:
gcc -c runtime.c -o runtime.o gcc -c snl_asm.s -o snl.o gcc -o snl.out snl.o runtime.ogen_print_code(ASTNode* expr_list)生成调用序列:
# 假设 expr_list 包含两个节点:INT(42) 和 REAL(3.14) movl $42, %eax call print_int movsd .LC0(%rip), %xmm0 # load 3.14 to xmm0 call print_real.LC0: .quad 0x40091EB851EB851F// 3.14 的 IEEE754 double 表示
5.3 完整可运行示例:hello.sn到hello.out全流程
输入hello.sn:
begin integer a; real b; a := 100; b := 3.14; print(a, b); end执行命令链:
# 1. 词法分析 ./lexer < hello.sn > hello.tokens # 2. 语法分析(生成 AST) ./parser < hello.sn > hello.ast # 3. 语义检查(静默通过即成功) ./parser --check < hello.sn # 自定义 flag,实际在 parser 中实现 # 4. 代码生成(输出 hello.s) ./parser --asm < hello.sn > hello.s # 5. 汇编与链接 gcc -c hello.s -o hello.o gcc -c runtime.c -o runtime.o gcc -o hello.out hello.o runtime.o # 6. 运行 ./hello.out # 输出:100 3.1hello.s关键片段:
.section .data .LC0: .quad 0x40091EB851EB851F # 3.14 .section .text .globl _start _start: pushq %rbp movq %rsp, %rbp subq $32, %rsp # a := 100 movl $100, -4(%rbp) # b := 3.14 movsd .LC0(%rip), %xmm0 movsd %xmm0, -12(%rbp) # print(a) movl -4(%rbp), %eax call print_int # print(b) movsd -12(%rbp), %xmm0 call print_real # exit movq $60, %rax movq $0, %rdi syscall我的习惯:每次修改代码生成器,必跑这个
hello.sn流程,用diff对比新旧hello.s。如果movl $100, -4(%rbp)变成movl $100, -8(%rbp),说明栈偏移计算逻辑错了——这种确定性反馈比 GDB 单步快十倍。吉大课设不是拼谁功能多,而是拼谁的hello.sn能在助教机器上./hello.out一秒出结果。希望帮到你。
本文还有配套的精品资源,点击获取