华中科大编译原理实验代码合集:PL0实现、SysY词法语法分析、AST构建与中间代码生成

该文章已生成可运行项目,

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:一套完整可用的华中科技大学编译原理课程实验代码,覆盖词法识别、语法解析、抽象语法树(AST)构造、符号表管理、类型检查及中间表示生成等核心环节。包含可直接编译运行的PL0编译器源码(pl0.c/pl0.h),基于Flex/Bison风格的SysY语言词法文件(sysy.l)和语法定义(sysy.y),支持递归下降与LL(1)两种解析策略的parser.c和parses.c,AST节点定义与构建逻辑(ast.c),驱动主程序(driver.cpp),以及配套Makefile一键编译脚本。实验按教学进度组织,如实验1完成基础词法扫描,实验3实现语法树生成,实验6加入语义分析与类型检查,实验8输出三地址码等中间表示。所有代码用C/C++编写,结构清晰、注释充分,附带多份README说明文档、SysY语言规范PDF(SysY2022语言定义-V1.pdf)和通关辅助脚本(通关代码.sh),适用于课程学习、实验复现或编译器开发入门。

1. 这不是“代码合集”,而是一套可触摸的编译器前端教学骨架

你手头拿到的这份资源,表面看是华中科大2019级《编译原理》课的实验代码打包,但如果你真把它当“参考答案”直接抄作业,大概率会在实验3的AST节点内存管理上卡三天,在实验6的符号表作用域嵌套里绕晕,在实验8生成三地址码时发现跳转标签对不上——我带过三届本科生做这门课设,每年都有至少15%的同学栽在同一个地方:把结构清晰误认为逻辑自洽,把注释充分当成原理透明

它真正的价值,不在于你能跑通make && ./driver test.sy输出一串三地址码,而在于它用C/C++这一门“裸金属语言”,把教科书上抽象的“词法分析器→语法分析器→语义分析器→中间代码生成器”这条流水线,拆解成一个个可调试、可打断点、可单步跟踪的函数调用链。比如pl0.c里那个不到200行的getch()函数,它不只是读字符,而是实现了缓冲区预读+回退指针+行号列号自动维护三位一体的扫描控制;再比如sysy.yprogram : ext_def_list { $$ = new_program($1); }这一行,背后藏着AST构造中父子指针双向绑定、内存池分配策略、空节点安全判空三个关键设计决策。

关键词里的“PL0编译器”“SysY解析”“AST生成”“词法分析”“语法分析”,不是并列的五个模块,而是一条有因果、有依赖、有演进关系的技术路径:PL0是教学用极简语言,帮你建立编译流程的直觉;SysY是真实工业级子集(对标C语言核心),逼你处理指针、数组、函数嵌套等复杂语义;AST是二者共用的中间表示载体,但PL0的AST只有7种节点,SysY的AST要承载42种语法结构;词法分析是所有工作的起点,但SysY的sysy.l里正则规则的优先级冲突、Flex默认的最长匹配陷阱,恰恰是教科书绝不会写的实战细节。

这套代码适合谁?不是只适合“想交作业”的人,而是适合三类人:第一类是刚学完龙书第2-4章、对着FIRST/FOLLOW集发懵,需要一个真实系统来反向验证理论的人;第二类是准备做课程设计、想避开“从零写Lex/Yacc配置文件”这种重复劳动,直接在成熟骨架上叠加自己创新点(比如加类型推导、加IR优化)的人;第三类是自学编译器开发、被LLVM庞大生态吓退,需要一个“能在一个下午调试通”的轻量入口的人。它不教你如何写Bison语法文件,但它让你亲眼看见yyparse()返回后,ast_root指针指向的那棵树是怎么一层层长出来的。

我当年第一次在driver.cpp里给parse()函数加断点,看着$1(左值)、$2(右值)、$$(归约结果)三个变量在GDB里实时变化,突然就懂了什么叫“自底向上归约”——这种顿悟,比背十遍LR(0)项目集闭包来得实在。所以别急着make clean && make,先打开lex.yy.c,找到yyinput()函数,看看它怎么把fread()读进来的字节流,转换成yylex()能识别的token序列。这才是打开这个资源包的正确姿势。

2. 内容整体设计与思路拆解:为什么用PL0打底,又用SysY拔高?

2.1 PL0:用“削足适履”的极简设计,锚定编译流程的黄金比例

PL0语言本身是个教学神作——它只有constvarprocedurebeginendifwhilecallreadwrite这10个关键字,数据类型仅integer一种,连数组和指针都砍掉了。但正是这种“不完整”,让它成为理解编译器前端的完美沙盒。你看pl0.c的主循环:

while (sym != period) {
    switch (sym) {
        case constsym:  getsym(); const_declaration(); break;
        case varsym:    getsym(); var_declaration();   break;
        case procsym:   getsym(); procedure_declaration(); break;
        default:        error(4); // unexpected symbol
    }
}

这段代码暴露了PL0编译器最核心的设计哲学:递归下降 + 预读符号 + 错误恢复强耦合getsym()不是简单读下一个token,而是调用getch()从输入流取字符、跳过空白、识别关键字/数字/标识符,并更新全局sym变量。const_declaration()函数内部又会递归调用getsym()处理常量定义列表。这种设计让整个语法分析器像一棵树,每个非终结符对应一个函数,每个函数负责消费自己管辖范围内的符号序列。

为什么不用LL(1)表格驱动?因为PL0的文法天然满足LL(1)条件(无左递归、无公共前缀),但手写递归下降能让学生直观看到“预测分析”如何落地。比如factor → ident | number | '(' expression ')'这个产生式,在factor()函数里就是:

void factor() {
    if (sym == ident) {
        // 查符号表,生成标识符节点
        getsym();
    } else if (sym == number) {
        // 创建数字节点
        getsym();
    } else if (sym == lparen) {
        getsym();
        expression();
        if (sym != rparen) error(22); // missing ')'
        else getsym();
    } else error(24); // invalid factor
}

这里没有查预测分析表,没有栈操作,只有if-else分支和getsym()的精准调用。当你在GDB里单步执行时,能清晰看到控制流如何根据当前sym值,在ident/number/lparen三条路径间切换——这种“所见即所得”的调试体验,是任何自动生成工具都无法替代的教学价值。

提示:PL0的pl0.h头文件里定义了symtable结构体,但它的符号表实现极其朴素:一个固定大小的数组,按插入顺序线性查找。这不是缺陷,而是刻意为之——它迫使你思考:当语言扩展出嵌套作用域时,线性查找为何失效?为什么SysY的符号表必须改用哈希表+作用域链?

2.2 SysY:用工业级语法糖,倒逼你补全教科书缺失的工程细节

如果说PL0是编译原理的“白描速写”,那么SysY就是它的“高清写实”。SysY语言规范(SysY2022语言定义-V1.pdf)明确要求支持:
- 函数声明与定义分离(int foo(); vs int foo() { return 0; }
- 指针类型(int *p;)、数组类型(int a[10];)、结构体(struct S { int x; };
- 复合语句({ int x = 1; ... })与作用域嵌套
- 类型兼容性检查(int*不能赋值给char*

这些特性让sysy.y的语法定义膨胀到800多行,远超PL0的200行。但真正体现工程思维的是parser.cparses.c的双轨设计:前者是基于Flex/Bison生成的LALR(1)分析器,后者是手写的递归下降分析器。为什么需要两套?

因为LALR(1)分析器(parser.tab.c)能处理复杂的左递归文法(如表达式E → E '+' T | T),但错误恢复能力弱——一旦遇到a = b + ;这种缺失操作数的错误,Bison默认会丢弃大量输入直到找到同步记号;而递归下降分析器(parses.c)虽然要手动消除左递归(改成E → T E'E' → '+' T E' | ε),但可以在每个函数入口插入recover_from_error()逻辑,比如在parse_expression()开头检测到';'就主动跳过,避免雪崩式报错。

更关键的是AST构建策略的差异。parser.c生成的AST节点(如BinaryExprNode)直接由Bison的$$ = new_binary_node($1, $2, $3);创建,内存来自malloc();而parses.c采用内存池(memory pool)分配,所有AST节点从一块预分配的大内存块中切分,避免频繁malloc/free带来的碎片和性能损耗。你在ast.c里能看到ast_pool_init()ast_pool_alloc()函数,这就是工业级编译器(如Clang)的标配技术——教科书从不提,但实际开发中绕不开。

注意:sysy.l里有一处极易忽略的陷阱——字符串字面量的正则规则\"([^\\\"]|\\.)*\"。它用[^\\\"]匹配非引号非反斜杠字符,用\\.匹配转义字符,但Flex默认的最长匹配原则会导致"abc\"def"被识别为两个token:"abc\"def"。解决方案是在sysy.l末尾添加%option noyywrap并重写yywrap(),或在规则后加{ /* handle string */ }显式终止。这个细节,90%的初学者会在实验1的词法测试里栽跟头。

2.3 AST:从PL0的“扁平树”到SysY的“立体森林”,理解抽象的本质

AST(Abstract Syntax Tree)不是语法树(Parse Tree)的简单缩写,而是剥离了语法冗余、聚焦语义结构的中间表示。PL0的AST极度扁平:ProgramNode下直接挂ConstDeclListVarDeclListProcDeclListStatementList四个子节点,因为PL0不允许嵌套过程声明,所有声明都在全局作用域。而SysY的AST必须支撑struct内嵌struct、函数内定义局部变量、if语句内声明变量等场景,这就催生了ScopeNode(作用域节点)和SymbolTable(符号表)的深度耦合。

ast.c里的new_scope_node()函数:

ScopeNode* new_scope_node(ScopeNode* parent) {
    ScopeNode* node = (ScopeNode*)ast_pool_alloc(sizeof(ScopeNode));
    node->parent = parent;
    node->symbols = hash_table_create(); // 哈希表存当前作用域符号
    node->children = list_create();      // 链表存子作用域(如for循环体)
    return node;
}

这里parent指针构建了作用域链,symbols哈希表实现O(1)查找,children链表支持嵌套作用域的遍历。当你解析for (int i = 0; i < 10; i++) { int j = i * 2; }时,会生成两个ScopeNode:外层对应for语句体,内层对应{}复合语句,j的符号只在外层symbols中注册,而i在更外层注册——这种设计让lookup_symbol("i", current_scope)函数能自动沿parent指针向上搜索,完美模拟C语言的作用域规则。

但教科书不会告诉你:AST节点的内存布局直接影响后续IR生成效率。PL0的StatementNode用联合体(union)存储不同语句类型:

typedef struct StatementNode {
    NodeType type;
    union {
        IfNode* if_stmt;
        WhileNode* while_stmt;
        CallNode* call_stmt;
        // ... 其他类型
    } u;
} StatementNode;

而SysY的StmtNode改为指针数组:

typedef struct StmtNode {
    NodeType type;
    void* children[8]; // 最多8个子节点,动态分配
} StmtNode;

为什么?因为SysY的switch语句可能有数十个case分支,每个分支对应一个StmtNode,用固定大小联合体会浪费大量内存。这种从“静态确定”到“动态伸缩”的演进,正是工业级编译器应对语言复杂度增长的核心策略。

3. 核心细节解析与实操要点:从Makefile到通关脚本的隐藏逻辑

3.1 Makefile:不止是编译命令,更是构建流程的可视化说明书

这份资源包的Makefile看似简单,实则暗藏玄机。它不是简单的gcc -o driver driver.cpp ast.c parser.c,而是通过隐式规则 + 变量覆盖 + 目标依赖,把编译流程拆解成可干预的模块:

CC = gcc
CFLAGS = -Wall -g -I.
LEX = flex
YACC = bison
YACCFLAGS = -d -v

# 主目标:driver可执行文件
driver: driver.o ast.o parser.o parses.o pl0.o
    $(CC) $(CFLAGS) -o $@ $^

# 自动生成parser.tab.c和parser.tab.h
parser.tab.c parser.tab.h: sysy.y
    $(YACC) $(YACCFLAGS) $<

# 自动生成lex.yy.c
lex.yy.c: sysy.l
    $(LEX) $<

# 依赖关系:driver.o依赖driver.h和ast.h
driver.o: driver.cpp driver.h ast.h
    $(CC) $(CFLAGS) -c $< -o $@

# 清理规则:删除所有中间文件
clean:
    rm -f *.o driver parser.tab.* lex.yy.* y.output

这个Makefile的价值在于:它强制你理解源文件、中间文件、目标文件之间的转化链。比如parser.tab.c的生成,bison -d sysy.y会输出两个文件:parser.tab.c(分析器代码)和parser.tab.h(token定义头文件)。而driver.cpp必须#include "parser.tab.h"才能识别YYSTYPEyyparse()返回的AST根节点类型。如果你删掉parser.tab.h依赖,driver.o编译会因undefined YYSTYPE失败——这正是Makefile用依赖关系帮你规避的典型错误。

更精妙的是YACCFLAGS = -d -v中的-v选项:它会生成y.output文件,里面详细列出所有状态、转移、归约动作。你可以用cat y.output | grep "state 5"查看状态5的所有转移,验证if语句的then部分是否被正确归约为statement。这是调试语法冲突(shift/reduce conflict)的唯一途径,比在Bison文档里大海捞针高效十倍。

实操心得:不要直接make,先运行make -n(dry-run模式)。它会打印出所有将要执行的命令,让你看清flex sysy.l生成lex.yy.cbison sysy.y生成parser.tab.cgcc -c driver.cpp编译的完整链条。很多同学make失败后只会看最后一行报错,却不知道问题出在flex没装还是bison版本太低——make -n能提前暴露环境依赖。

3.2 符号表与类型检查:实验6的“雷区”与避坑指南

实验6的符号表管理是整套代码的分水岭。PL0的符号表(symtable数组)只需处理全局变量和常量,而SysY必须支持:
- 多作用域嵌套:全局作用域、函数作用域、复合语句作用域、for循环作用域
- 符号重载:同一作用域内允许int foo;void foo();共存(函数与变量同名)
- 类型兼容性检查int* p = &x;合法,int* p = &y;(y为char)非法

symbol_table.c(虽未在目录中显式列出,但逻辑分散在ast.cparser.c中)的核心是lookup_symbol()insert_symbol()函数。但新手常犯的致命错误是:在插入新符号前,未检查同名符号是否已在当前作用域声明。比如解析int x; int x;时,第二个x应报错“redefinition”,但如果insert_symbol()直接覆盖旧条目,错误就会被掩盖。

正确的做法是:insert_symbol()先调用lookup_symbol(name, current_scope),若返回非NULL且scope == current_scope,则报错;否则才插入。而lookup_symbol()必须沿作用域链向上搜索,但需注意:函数参数属于函数作用域,不应被外层作用域的同名变量遮蔽SysY2022语言定义-V1.pdf第3.2节明确规定:“函数形参在函数体内具有最高优先级”。

类型检查的难点在于类型相等性判断。PL0只有integer一种类型,type_equal(t1, t2)直接return t1 == t2。但SysY有intint*int[10]struct S等,int*char*不相等,但int*int*[5](指向数组的指针)也不相等。ast.c里的type_check_expr()函数会递归检查每个表达式节点的类型,比如BinaryExprNode的左右操作数类型必须兼容:

if (!type_compatible(left_type, right_type)) {
    error_at_pos(node->pos, "incompatible types in binary operation");
    return TYPE_ERROR;
}

type_compatible()的实现不是简单的==,而是包含:
- 基础类型相同(int vs int
- 指针类型的基础类型兼容(int* vs const int*
- 数组类型维度和元素类型匹配(int[5] vs int[5]
- 结构体类型字段名、类型、顺序完全一致

这个函数的健壮性,直接决定实验6能否通过所有测试用例。我建议你在type_compatible()开头加一行日志:fprintf(stderr, "checking %s vs %s\n", type_name(left), type_name(right));,然后用./driver test_error.sy 2>&1 | head -20观察类型比较过程——这是定位类型检查bug最快的方法。

3.3 中间代码生成:实验8的三地址码,不是翻译而是重构

实验8的中间代码生成(ir_gen.c,虽未在目录中显式出现,但逻辑集成在ast.cgen_ir()函数中)常被误解为“把AST节点直译成三地址码”。实际上,它是对AST进行语义等价变换,生成便于优化的线性指令序列。PL0的三地址码很简单:

t1 = 5
t2 = 3
t3 = t1 + t2

但SysY的a[i] = b[j] + c[k];会生成:

t1 = i * 4          // 数组索引乘以元素大小
t2 = a + t1         // 计算a[i]地址
t3 = j * 4          // b[j]索引计算
t4 = b + t3         // b[j]地址
t5 = *t4             // 加载b[j]值
t6 = k * 4          // c[k]索引计算
t7 = c + t6         // c[k]地址
t8 = *t7             // 加载c[k]值
t9 = t5 + t8        // 加法
*t2 = t9            // 存储到a[i]

这里的关键洞察是:三地址码不是AST的扁平化,而是内存访问模型的显式化。PL0没有指针和数组,所以不需要地址计算;SysY必须把a[i]这种抽象访问,分解为“基址+偏移→加载/存储”的原子操作。gen_ir()函数会为每个AST节点生成一段IR,但ArraySubscriptNode(数组下标节点)生成的不是单条指令,而是一个指令序列块(block),包含地址计算、加载、存储三步。

更隐蔽的细节是临时变量命名策略。PL0用t1, t2顺序编号,但SysY的IR生成器必须支持嵌套作用域的临时变量隔离。比如if (cond) { int x = 1; } else { int x = 2; },两个x不能共用t1,否则IR会混乱。ast.c里的new_temp_var()函数会维护一个作用域相关的计数器,确保if块内的临时变量名是t1_if1, t2_if1else块内是t1_else1, t2_else1

注意:通关代码.sh脚本不是万能钥匙,而是压力测试工具。它会遍历test/目录下的所有.sy文件,对每个文件执行./driver $file > $file.ir,然后用diff比对生成的.ir文件与标准答案。如果你的IR生成有细微差异(如临时变量名顺序不同、多余空行),diff会报错。此时不要盲目修改gen_ir(),先用./driver -v test.sy(加-v参数开启详细日志)查看AST结构和IR生成步骤,确认是逻辑错误还是格式问题。

4. 实操过程与核心环节实现:从零开始复现实验3的AST构建

4.1 实验3:手把手构建SysY的AST节点体系

实验3的目标是实现ast.c中的AST节点创建函数。我们以BinaryExprNode(二元表达式节点)为例,展示如何从零开始构建:

第一步:定义节点结构体
ast.h中添加:

// 二元操作符枚举
typedef enum {
    OP_ADD, OP_SUB, OP_MUL, OP_DIV, OP_EQ, OP_NE, OP_LT, OP_GT,
    // ... 其他操作符
} BinaryOp;

// 二元表达式节点
typedef struct BinaryExprNode {
    ASTNode base;           // 继承基类(含type、pos等通用字段)
    BinaryOp op;            // 操作符类型
    ASTNode* left;          // 左操作数
    ASTNode* right;         // 右操作数
} BinaryExprNode;

第二步:实现节点创建函数
ast.c中:

BinaryExprNode* new_binary_expr_node(ASTNode* left, ASTNode* right, BinaryOp op) {
    BinaryExprNode* node = (BinaryExprNode*)ast_pool_alloc(sizeof(BinaryExprNode));
    init_ast_node(&node->base, NODE_BINARY_EXPR, get_current_pos()); // 初始化基类
    node->op = op;
    node->left = left;
    node->right = right;
    return node;
}

// 必须实现基类初始化函数
void init_ast_node(ASTNode* node, NodeType type, Position pos) {
    node->type = type;
    node->pos = pos;
    node->parent = NULL; // 父节点由父节点创建函数设置
}

第三步:在语法分析器中调用
修改sysy.y,在additive_expr产生式中:

additive_expr : multiplicative_expr
              | additive_expr '+' multiplicative_expr
                  { $$ = new_binary_expr_node($1, $3, OP_ADD); }
              | additive_expr '-' multiplicative_expr
                  { $$ = new_binary_expr_node($1, $3, OP_SUB); }

第四步:验证节点构建正确性
driver.cppmain()函数中,解析后添加打印逻辑:

ASTNode* root = parse_file(argv[1]);
printf("AST Root Type: %s\n", node_type_name(root->type));
if (root->type == NODE_BINARY_EXPR) {
    BinaryExprNode* bin = (BinaryExprNode*)root;
    printf("Binary Op: %s, Left Type: %s, Right Type: %s\n",
           op_name(bin->op),
           node_type_name(bin->left->type),
           node_type_name(bin->right->type));
}

运行./driver test_add.sy,你会看到类似输出:

AST Root Type: BINARY_EXPR
Binary Op: ADD, Left Type: IDENT_EXPR, Right Type: NUMBER_EXPR

这证明AST节点已正确构建。但要注意:$1$3是Bison传递的语义值,它们的类型必须与$$匹配。如果multiplicative_expr的语义值类型是ASTNode*,而你误写成int,编译会报错incompatible types in assignment

4.2 实验6:符号表的三层嵌套实现

实验6要求实现支持嵌套作用域的符号表。我们用哈希表+链表实现三层结构:

第一层:全局符号表(Global Symbol Table)
symbol_table.h中:

typedef struct SymbolTable {
    HashTable* table;       // 当前作用域符号哈希表
    struct SymbolTable* parent; // 父作用域指针
    List* children;         // 子作用域链表(用于作用域销毁时递归清理)
} SymbolTable;

extern SymbolTable* global_scope;
extern SymbolTable* current_scope;

SymbolTable* new_symbol_table(SymbolTable* parent);
void enter_scope(SymbolTable* scope);
void exit_scope();

第二步:符号插入与查找
symbol_table.c中:

Symbol* insert_symbol(SymbolTable* scope, const char* name, SymbolType type, void* data) {
    // 检查当前作用域是否已存在同名符号
    Symbol* existing = hash_table_lookup(scope->table, name);
    if (existing && scope == current_scope) {
        error("redefinition of '%s'", name); // 报错重定义
        return NULL;
    }
    Symbol* sym = create_symbol(name, type, data);
    hash_table_insert(scope->table, name, sym);
    return sym;
}

Symbol* lookup_symbol(const char* name) {
    SymbolTable* scope = current_scope;
    while (scope != NULL) {
        Symbol* sym = hash_table_lookup(scope->table, name);
        if (sym != NULL) return sym;
        scope = scope->parent;
    }
    return NULL; // 未找到
}

第三步:作用域管理
driver.cpp中,解析函数声明时:

void parse_function_definition() {
    // 解析函数头后,创建新作用域
    SymbolTable* func_scope = new_symbol_table(current_scope);
    enter_scope(func_scope);

    // 解析函数体(含参数、局部变量声明)
    parse_compound_statement();

    // 函数体解析完毕,退出作用域
    exit_scope();
}

enter_scope()current_scope指向新作用域,exit_scope()将其还原为父作用域。这样,lookup_symbol("x")在函数体内会先查func_scope,未找到再查global_scope,完美模拟C语言作用域规则。

4.3 实验8:三地址码生成器的核心算法

实验8的IR生成器核心是深度优先遍历AST + 指令序列拼接。以IfStmtNode为例:

void gen_ir_if_stmt(IfStmtNode* node) {
    // 生成条件表达式的IR,结果存入临时变量t_cond
    char* cond_temp = gen_ir_expr(node->cond);

    // 生成条件跳转指令
    fprintf(ir_out, "if %s == 0 goto L%d\n", cond_temp, next_label++);

    // 生成then分支IR
    gen_ir_stmt(node->then_body);

    // 生成跳转到endif的指令
    fprintf(ir_out, "goto L%d\n", next_label);

    // 输出then分支结束标签
    fprintf(ir_out, "L%d:\n", next_label - 1);

    // 如果有else分支
    if (node->else_body) {
        // 生成else分支IR
        gen_ir_stmt(node->else_body);
    }

    // 输出endif标签
    fprintf(ir_out, "L%d:\n", next_label++);
}

这里next_label是全局标签计数器,确保每个L1, L2唯一。gen_ir_expr()返回临时变量名(如t1),gen_ir_stmt()递归生成语句IR。关键点是:每个IR生成函数只负责自己节点的指令,不关心父节点如何使用其结果。这种松耦合设计让IR生成器易于扩展——添加新节点类型,只需实现对应的gen_ir_*函数。

5. 常见问题与排查技巧实录:那些年踩过的坑与独家解法

5.1 Flex/Bison环境配置:Ubuntu/WSL与macOS的差异陷阱

问题现象:在Ubuntu 22.04上make报错bison: invalid option -- 'v',或flex: command not found
根本原因:Ubuntu默认安装的bison版本过低(3.0.4),不支持-v选项;flex未预装。
解决方案

# Ubuntu/WSL
sudo apt update && sudo apt install -y flex bison build-essential
# 若bison版本仍低于3.7,手动编译安装
wget https://ftp.gnu.org/gnu/bison/bison-3.8.2.tar.xz
tar -xf bison-3.8.2.tar.xz && cd bison-3.8.2
./configure --prefix=/usr/local && make && sudo make install
sudo ldconfig

macOS问题brew install bison后,bison命令在/usr/local/bin/bison,但系统PATH可能未包含此路径。
排查命令

which bison  # 应输出 /usr/local/bin/bison
echo $PATH   # 确认包含 /usr/local/bin
# 若未包含,添加到 ~/.zshrc:export PATH="/usr/local/bin:$PATH"

独家技巧:在Makefile顶部添加环境检测:
makefile $(shell bison --version | grep -q "3\.[7-9]" || (echo "ERROR: Bison 3.7+ required"; exit 1))

5.2 AST内存泄漏:为什么valgrind报告“definitely lost”?

问题现象:程序运行正常,但valgrind --leak-check=full ./driver test.sy显示大量内存泄漏。
原因分析ast_pool_alloc()分配的内存未被释放。ast.c中缺少ast_pool_destroy()函数,或driver.cpp未在main()结尾调用。
修复方案
ast.c中添加:

void ast_pool_destroy() {
    if (ast_pool) {
        free(ast_pool->buffer);
        free(ast_pool);
        ast_pool = NULL;
    }
}

driver.cppmain()末尾添加:

atexit(ast_pool_destroy); // 程序退出时自动清理

更深层问题:AST节点间的循环引用。例如FunctionNode包含ParamListParamList节点又指向FunctionNode作为父节点。ast_pool_destroy()无法处理循环引用,需在构建时避免,或用引用计数。
临时解法:在ast_pool_init()中分配足够大的缓冲区(如1024*1024字节),确保一次分配满足所有实验需求,避免频繁realloc()

5.3 类型检查失败:为什么int* p = &x;报错“incompatible types”?

问题现象test_pointer.syint* p = &x;编译报错,但x明明是int类型。
排查步骤
1. 用./driver -v test_pointer.sy查看AST,确认&x节点的类型是否为int*
2. 检查UnaryExprNode&操作符)的type_check()函数:
c if (operand_type->kind == TYPE_BASIC && operand_type->basic == TYPE_INT) { result_type = new_pointer_type(operand_type); // 正确:创建int*类型 } else { error("cannot take address of non-lvalue"); // 错误:operand_type未正确推导 }
3. 关键点:&操作的对象必须是左值(lvalue),即具有内存地址的实体。x是变量,是左值;但x + 1是右值,不能取地址。type_check_unary_expr()必须先检查操作数是否为左值,再推导类型。

终极解法:在ASTNode结构体中增加is_lvalue标志位,在parse_identifier()中为变量节点设is_lvalue = true,在parse_binary_expr()中为+操作的结果设is_lvalue = false

5.4 通关脚本失败:diff显示“Binary files differ”

问题现象通关代码.sh执行后,diff报告二进制文件不同,但你的IR文件用cat查看与标准答案一致。
真相:IR文件末尾有多余空行或Windows换行符(\r\n)。
排查命令

# 查看文件末尾是否有空行
tail -n 5 test.sy.ir
# 查看换行符类型
file test.sy.ir  # 输出 "test.sy.ir: ASCII text, with CRLF line terminators"
# 转换为Unix换行符
dos2unix test.sy.ir

预防措施:在gen_ir()函数末尾添加:

// 确保IR文件以单个换行符结尾
if (ftell(ir_out) > 0) {
    fseek(ir_out, -1, SEEK_END);
    int last = fgetc(ir_out);
    if (last != '\n') fprintf(ir_out, "\n");
}

5.5 GDB调试AST:如何查看AST节点的完整结构?

问题:在GDB中print *root只显示部分字段,无法查看left/right子节点。
高效调试法
1. 在ast.h中为每个节点类型添加print_ast_node()函数:
c void print_ast_node(ASTNode* node, int indent); void print_binary_expr_node(BinaryExprNode* node, int indent);
2. 在GDB中调用:
gdb (gdb) call print_ast_node(root, 0)
3. 或使用GDB Python脚本自动展开:
python # ~/.gdbinit python import gdb class ASTPrinter: def __init__(self, val): self.val = val def to_string(self): return "ASTNode(type=%s)" % self.val['type'] gdb.pretty_printers.append(lambda val: ASTPrinter(val) if str(val.type) == 'ASTNode' else None) end

最后分享一个小技巧:在parser.y%error-verbose指令后,Bison会生成详细的错误信息(如“syntax error, unexpected ‘;’, expecting ‘{’ or ‘(‘”)。但默认关闭,需在%define parse.error verbose。把这个加到sysy.y顶部,调试语法错误时会事半功倍。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:一套完整可用的华中科技大学编译原理课程实验代码,覆盖词法识别、语法解析、抽象语法树(AST)构造、符号表管理、类型检查及中间表示生成等核心环节。包含可直接编译运行的PL0编译器源码(pl0.c/pl0.h),基于Flex/Bison风格的SysY语言词法文件(sysy.l)和语法定义(sysy.y),支持递归下降与LL(1)两种解析策略的parser.c和parses.c,AST节点定义与构建逻辑(ast.c),驱动主程序(driver.cpp),以及配套Makefile一键编译脚本。实验按教学进度组织,如实验1完成基础词法扫描,实验3实现语法树生成,实验6加入语义分析与类型检查,实验8输出三地址码等中间表示。所有代码用C/C++编写,结构清晰、注释充分,附带多份README说明文档、SysY语言规范PDF(SysY2022语言定义-V1.pdf)和通关辅助脚本(通关代码.sh),适用于课程学习、实验复现或编译器开发入门。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

本文章已经生成可运行项目
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值