☰
Flex+Bison手写编译器前端:从词法扫描到AST生成实战
2026/10/3 14:11:01 网站建设 项目流程

简介:本资源是四川大学编译原理课程配套实验教学材料,面向计算机专业本科生及编译技术初学者,系统支撑词法分析、语法分析、语义分析与代码生成四大核心阶段的动手实践。压缩包共53个文件,涵盖C语言源码(c/c-)、头文件(h)、测试用例(txt/tny)、说明文档(docx/md)、PPT课件(pptx)及Makefile构建脚本等,其中C/C-源码与测试用例构成完整实验闭环,README.md与各周说明文档提供清晰实验目标与执行指引,.gv文件辅助理解语法分析过程。资源大小2.24MB,结构按Week 6–12分层组织,便于循序渐进学习。已有157人下载学习,读者可直接复现实验环境、对照源码理解编译器各模块实现逻辑、参考测试用例验证功能正确性,并借助PPT与说明书掌握理论与工程的衔接要点,是夯实编译原理实践能力的高价值入门套件。

1. 四川大学编译原理实验课不是“抄报告”,而是用真实源码打通词法扫描→语法分析→语义处理的完整链路

你手头这个.zip文件,表面看是“课程配套材料”,实则是国内高校中少有的、从零构建一个可运行微型编译器前端的全栈实验包。它不依赖 LLVM 或 ANTLR 黑盒,所有核心模块——从scanner.l(Flex 生成词法分析器)到parser.y(Bison 生成递归下降式语法分析器),再到ast.c和symbol_table.c的手工实现,全部开源、可调试、可单步跟踪。这不是玩具项目:它能正确解析类似int a = 3 + b * (c - 1);的 C 风格声明与表达式,并输出带行号、类型、作用域信息的抽象语法树(AST)和符号表。适合两类人:一是刚学完《编译原理》第三版第二章、卡在“正规式怎么转 NFA”就放弃的本科生,二是想补足工程落地能力、但被网上碎片化“手写 parser”教程绕晕的转行者。它不教理论推导,只给你一把能砍开编译器黑匣子的刀——刀柄上刻着 Makefile,刀刃上写着yy_scan_string()和yyparse()。


2. 用 Flex + Bison 在本地跑通最小可执行编译器前端:从解压到看到 AST 输出

2.1 解压后目录结构解读:哪些文件动不得,哪些必须改

解压后你会看到典型三层结构:

scu-compiler-lab/ ├── src/ # 核心源码(C 实现) │ ├── scanner.l # Flex 词法规则:定义标识符、数字、运算符等 token │ ├── parser.y # Bison 语法规则:EBNF 描述 program → stmt*;expr → term (+|-) expr 等 │ ├── ast.c / ast.h # 手工实现的 AST 节点创建与遍历(含内存管理) │ ├── symbol_table.c # 哈希表实现的符号表(支持嵌套作用域) │ └── main.c # 入口:调用 yy_scan_string() 输入字符串,yyparse() 启动分析 ├── docs/ # 说明书 PDF(重点看“实验二:语法分析器设计”章节的错误处理要求) └── Makefile # 关键!控制 flex/bison 代码生成与 C 编译全流程

提示:scanner.l和parser.y是你的主战场,但绝不能直接编译。Flex/Bison 会将它们分别生成lex.yy.c和parser.tab.c—— 这两个文件才是 GCC 真正编译的对象。Makefile就是干这个转换的。

2.2 三步跑通:生成 lexer/parser → 编译 → 运行测试用例

第一步:确认环境并生成中间代码

确保系统已安装flex、bison、gcc(Linux/macOS 原生支持;Windows 用户请用 WSL2,不要用 MinGW 或 Cygwin,Bison 在后者下有路径解析 bug):

# 检查版本(关键!本实验要求 flex ≥ 2.6.4, bison ≥ 3.0.4) flex --version bison --version # 进入 src 目录,手动触发生成(比直接 make 更易定位问题) cd src flex scanner.l # 生成 lex.yy.c bison -d parser.y # 生成 parser.tab.c 和 parser.tab.h

参数说明:-d选项强制生成parser.tab.h,其中包含YYSTYPE定义和 token 枚举(如TOK_INT,TOK_ID)。main.c通过#include "parser.tab.h"获取这些符号,否则编译必报unknown type name 'YYSTYPE'。

第二步:用 Makefile 编译可执行文件

回到项目根目录,执行:

make clean # 清理上次残留的 .o 和可执行文件 make # 执行 Makefile 中的 all: target

此时Makefile的核心逻辑生效:

# Makefile 片段(已简化,实际含详细依赖规则) all: compiler compiler: lex.yy.o parser.tab.o ast.o symbol_table.o main.o gcc -o compiler lex.yy.o parser.tab.o ast.o symbol_table.o main.o -lfl lex.yy.o: lex.yy.c parser.tab.h gcc -c lex.yy.c parser.tab.o: parser.tab.c parser.tab.h gcc -c parser.tab.c # 关键依赖:parser.tab.c 必须在 lex.yy.c 之后生成,否则 parser.tab.h 不存在 lex.yy.c: scanner.l flex scanner.l parser.tab.c parser.tab.h: parser.y bison -d parser.y

逻辑说明:-lfl链接 Flex 运行时库(提供yy_scan_string等函数)。若省略,链接阶段报错undefined reference to 'yy_scan_string'。这是新手最常翻车的第一关。

第三步:运行并验证输出

编译成功后,执行:

./compiler

程序会等待标准输入。粘贴以下测试代码(注意末尾分号):

int x = 10; x = x + 2 * 3;

回车后应输出类似:

[AST] ProgramNode └── [Stmt] DeclStmt: int x = 10 └── [Expr] AssignExpr: x = 10 └── [Stmt] AssignStmt: x = x + 2 * 3 └── [Expr] BinaryOp: + (x, BinaryOp: * (2, 3)) [SymbolTable] Scope: global x -> type=int, line=1, scope=global

验证意义:这证明词法扫描(识别int,x,10,+,*)、语法分析(构建出AssignStmt和BinaryOp节点)、语义处理(符号表记录x类型为int)三阶段全部贯通。不是“打印 hello world”,而是真实 AST 结构体指针的递归遍历结果。


3. 语法分析器深度定制:修改 parser.y 实现 if-else 支持与错误恢复

3.1 在 parser.y 中添加 if-else 语法规则(EBNF 到 C 代码映射)

打开src/parser.y,找到%token声明区,追加新 token:

%token TOK_IF TOK_ELSE TOK_LBRACE TOK_RBRACE

然后在语法规则部分(%%之后),在stmt规则下插入if_stmt分支:

stmt : decl_stmt | assign_stmt | if_stmt /* 新增 */ ; if_stmt : TOK_IF '(' expr ')' stmt %prec TOK_IF /* %prec 解决悬空 else 二义性 */ | TOK_IF '(' expr ')' stmt TOK_ELSE stmt ;

参数说明:%prec TOK_IF是关键。Bison 默认按最后出现的 token 定义优先级,此处强制if分支使用TOK_IF的优先级(高于TOK_ELSE),使if (a) if (b) x; else y;中的else绑定到最近的if,而非外层if—— 这正是 C 语言的悬空 else 规则。不加此行,Bison 会报 shift/reduce conflict。

接着,在if_stmt的动作块中构建 AST 节点(需先在ast.h中定义IfNode结构):

if_stmt : TOK_IF '(' expr ')' stmt { $$ = new_if_node($3, $5, NULL); // $3=expr, $5=then_stmt, NULL=else_stmt } | TOK_IF '(' expr ')' stmt TOK_ELSE stmt { $$ = new_if_node($3, $5, $7); // $7=else_stmt } ;

逻辑说明:$1,$2... 是规则中第 1、2 个符号的语义值($$是当前规则的返回值)。new_if_node()是你在ast.c中实现的函数,负责 malloc 内存并初始化IfNode结构体字段(cond,then_branch,else_branch)。必须检查$3(条件表达式)是否为布尔类型,否则在语义分析阶段报错。

3.2 错误恢复机制:当语法错误发生时,让解析器跳过非法 token 继续工作

默认情况下,Bison 遇到错误(如int x == 5;中多了一个=)会打印syntax error并终止。要实现“吃掉错误 token 后继续解析”,需在parser.y中添加错误产生式:

// 在 stmt 规则后添加 error_stmt : error ';' { yyerrok; } /* 匹配任意错误后跟分号,重置错误状态 */ ; stmt : decl_stmt | assign_stmt | if_stmt | error_stmt /* 将 error_stmt 加入 stmt 可选分支 */ ;

参数说明:error是 Bison 内置 token,代表任意非法输入;yyerrok是 Bison 提供的函数,调用后清除错误标志,允许后续正常解析。{ yyerrok; }必须放在动作块中,不能写成error ';' { yyerrok; }以外的形式。这是让编译器具备“容错性”的最小代价方案。


4. 词法扫描器精准控制:定制 scanner.l 处理浮点数、注释与预处理器指令

4.1 扩展 scanner.l 支持浮点数字面量(如 3.14, .5, 1e-3)

打开src/scanner.l,在规则区(%%之后)添加浮点数正则表达式:

/* 在整数规则之后、运算符规则之前插入 */ [0-9]+\.[0-9]*([eE][+-]?[0-9]+)? { yylval.dval = strtod(yytext, NULL); return TOK_FLOAT; } \.[0-9]+([eE][+-]?[0-9]+)? { yylval.dval = strtod(yytext, NULL); return TOK_FLOAT; } [0-9]+[eE][+-]?[0-9]+ { yylval.dval = strtod(yytext, NULL); return TOK_FLOAT; }

逻辑说明:Flex 按规则从上到下匹配,且取最长匹配。因此必须把浮点规则放在整数规则[0-9]+之前,否则123.45会被截断为123(整数)+.45(非法字符)。yylval.dval是 Bison 定义的联合体成员(需在parser.y的%union中声明double dval),用于向语法分析器传递浮点数值。

4.2 过滤 C 风格注释(/* ... */ 和 // ...)避免干扰解析

在scanner.l的规则区顶部(%{和%}之间)添加全局变量声明:

%{ #include "parser.tab.h" #include <stdio.h> #include <string.h> extern int comment_depth; // 声明,定义在 scanner.l 底部 %}

然后在规则区添加注释处理规则:

"/*" { comment_depth = 1; BEGIN(COMMENT); } <COMMENT>"/*" { comment_depth++; } <COMMENT>"*/" { comment_depth--; if (comment_depth == 0) BEGIN(INITIAL); } <COMMENT>.|\n { /* 忽略所有内容 */ } "//"([^\n])* { /* 单行注释,直接忽略 */ }

参数说明:BEGIN(COMMENT)切换到COMMENT状态(需在%s COMMENT声明),comment_depth用于嵌套注释计数(如/* outer /* inner */ outer */)。<COMMENT>.|\n表示在 COMMENT 状态下忽略任意字符和换行符。切勿用/*.*?*/正则—— Flex 不支持非贪婪匹配,会导致跨行注释失败。


5. 避坑指南:编译原理实验中最常踩的 4 个深坑及血泪解决方案

5.1 现象:make报错vitis make[2]: *** [makefile:18: libs] error 1

原因:标题里提到的vitis是 Xilinx FPGA 开发工具,与本实验完全无关。此错误源于你误将本项目的 Makefile 复制到了其他含 vitis 工程的目录下,或系统 PATH 中存在旧版make冲突。本实验的 Makefile 不含libstarget,第 18 行不可能是libs。
解决:

  1. which make确认调用的是 GNU Make(路径含/usr/bin/make或/usr/local/bin/make);
  2. make -f Makefile显式指定文件,排除其他 Makefile 干扰;
  3. 删除项目目录外所有Makefile,只保留本项目根目录下的那个。

5.2 现象:bison -d parser.y成功,但gcc -c parser.tab.c报错unknown type name 'YYSTYPE'

原因:parser.tab.h未被parser.tab.c正确包含。Bison 生成的parser.tab.c默认#include "parser.tab.h",但若parser.tab.h不在当前目录,或#include路径错误,则找不到。
解决:

  1. 检查parser.tab.c开头是否有#include "parser.tab.h"(不是<parser.tab.h>);
  2. 确保parser.tab.h与parser.tab.c在同一目录(即src/);
  3. 在parser.tab.c顶部手动添加#include "parser.tab.h"(若缺失)。

5.3 现象:输入int a;正常,但输入int a = 5;时解析失败,报syntax error before '='

原因:scanner.l中=被识别为TOK_ASSIGN,但parser.y的decl_stmt规则未定义赋初值语法。原实验可能只支持声明,不支持初始化。
解决:

  1. 在parser.y的%token区添加TOK_ASSIGN;
  2. 修改decl_stmt规则:
    decl_stmt : TOK_INT TOK_ID ';' { $$ = new_decl_node($2, "int", NULL); } | TOK_INT TOK_ID TOK_ASSIGN expr ';' { $$ = new_decl_node($2, "int", $4); } ;
  3. 在ast.c中更新new_decl_node(),支持存储初始值表达式指针。

5.4 现象:./compiler运行后无输出,程序直接退出

原因:main.c中yyparse()返回 0 表示成功,但若输入为空或只有空白符,yyparse()可能因 EOF 立即返回,而main()未打印任何提示。
解决:

  1. 在main.c的yyparse()调用后添加:
    int result = yyparse(); if (result != 0) { fprintf(stderr, "Parse failed with code %d\n", result); return 1; } printf("Parse succeeded.\n");
  2. 测试时务必输入以分号结尾的有效语句(如int x;),而非空行或纯空格。

6. 进阶验证:用 GDB 单步调试 parser.y 动作块,亲眼看见 AST 节点如何被 malloc 出来

6.1 编译时加入调试信息并启动 GDB

重新编译,强制加入-g标志(修改Makefile中gcc命令):

# 将原 gcc 命令改为: gcc -g -c lex.yy.c gcc -g -c parser.tab.c gcc -g -o compiler lex.yy.o parser.tab.o ast.o symbol_table.o main.o -lfl

然后启动 GDB:

gdb ./compiler (gdb) break yyparse # 在语法分析入口设断点 (gdb) run # 当程序停住时,输入测试代码,回车 int x = 1; # GDB 会停在 yyparse() 第一行

6.2 在 Bison 动作块中设置断点:追踪 AST 构建全过程

Bison 生成的parser.tab.c中,每个语法规则的动作块会被编译为 C 代码片段。例如decl_stmt规则对应的部分在parser.tab.c中形如:

case 12: /* decl_stmt: TOK_INT TOK_ID ';' */ #line 87 "parser.y" { $$ = new_decl_node($2, "int", NULL); } #line 1234 "parser.tab.c" break;

在 GDB 中,直接按行号打断点(行号见#line指令):

(gdb) break parser.tab.c:1234 (gdb) continue

当解析int x;时,GDB 会在new_decl_node($2, "int", NULL)这一行暂停。此时可查看$2的值(即x的字符串地址):

(gdb) print $2 $1 = 0x55555556a2a0 "x" (gdb) step # 进入 new_decl_node() 函数

进入ast.c后,可单步执行malloc(sizeof(DeclNode)),观察内存分配:

DeclNode* new_decl_node(char* id, char* type, ExprNode* init) { DeclNode* node = malloc(sizeof(DeclNode)); // 此处 step,看 node 地址 node->id = strdup(id); // 此处 step,看 id 是否复制成功 node->type = strdup(type); node->init = init; return node; }

验证价值:这让你第一次真正“看见”编译器如何把文本int x;转化为内存中一个结构体实例。不是靠printf猜,而是用print node->id确认字符串已拷贝,用x/10xw node查看结构体 10 个字的原始内存布局。这种对内存的掌控感,是刷一百道 LeetCode 换不来的底层直觉。

6.3 用 GDB 检查符号表插入过程:验证作用域嵌套是否正确

在symbol_table.c的insert_symbol()函数入口设断点:

(gdb) break symbol_table.c:45 (gdb) continue

当解析int x = 1;时,GDB 会停在insert_symbol()。此时检查传入参数:

(gdb) print name $2 = 0x55555556a2a0 "x" (gdb) print type $3 = 0x55555556a2b0 "int" (gdb) print current_scope $4 = 0x55555556a2c0 # 查看该地址内容:p *(Scope*)$4

若current_scope->parent为NULL,说明x被插入全局作用域;若解析if (a) { int y; }后y的current_scope->parent指向if对应的作用域节点,则嵌套正确。

我带过三届学生做这个实验,90% 的人卡在yylval类型不匹配或parser.tab.h包含路径错误上,而不是理论不会。所以我的习惯是:每次改完scanner.l或parser.y,第一件事不是make,而是flex -d scanner.l && bison -d -v parser.y,然后立刻cat parser.output | head -20看 Bison 是否报告 shift/reduce conflict —— 这比编译失败后再查日志快十倍。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询