简介面向计算机相关专业学生与初学者的编译原理课程设计资源聚焦使用Flex与Bison对Cminus语言完成词法与语法分析。压缩包共14个文件以C源文件、头文件为主同时包含lexical.l词法规则、syntax.y语法规则、可执行程序parser、多个测试输入样例以及实验报告和README整体仅328KB结构清晰便于对照阅读与修改。已有106人学习下载代码经测试运行成功配有完整文档说明适合作为课程作业、项目演示或入门Flex/Bison的参考范例。读者可从中获得完整的词法/语法分析实现思路、项目文件组织方式与实验报告模板也能在此基础上扩展功能加深对编译前端各阶段的理解。资源面向具备一定C语言基础、正在接触编译原理课程设计的读者可直接运行体验或按需修改。1. Flex 与 Bison 手写 Cminus 编译器前端这套代码为什么值得你拆开看很多人在编译原理课设里第一次接触 Flex 和 Bison真正动手时才发现教材上的例子是计算器老师布置的是 Cminus中间隔着十万八千里。这套代码把词法分析lexical.l、语法分析syntax.y、语法树构建tree.h到主程序集成全串起来了而且不是玩具级——它处理了 Cminus 的函数声明、变量声明、数组、条件分支、循环、赋值表达式和语句块答辩平均分能到 96不是刷出来的。对准备做课设、毕设或者想补编译器前端短板的同学来说它的价值在于不是看代码实现了什么而是看一个完整的前端怎么把 Flex 生成的分析器、Bison 生成的解析器和手工写的语法树结构粘合在一起。新手能顺着 README 和实验报告跑通流程熟手则可以直接把它当脚手架替换文法、扩展语义。2. 词法分析器 lexical.l从正则到 token 的完整拆解2.1 文件结构Flex 的三个段与 token 定义lexical.l 是 Flex 的输入文件里面的结构固定分为三段定义段、规则段、用户代码段。定义段里用%{ %}包裹 C 语言头文件和外部变量声明——我拿到代码会先看这段因为决定了一个 token 以什么形式交给语法分析器规则段里左侧是正则右侧是匹配后执行的 C 动作。这套代码里Cminus 的保留字if、else、while、return 等单独写了规则匹配时还要注意是否匹配否则赋值号和等于号就会打架。Flex 的匹配原则是取最长匹配所以id的正则[a-zA-Z_][a-zA-Z0-9_]*会优先于关键字规则匹配if——因此关键字规则必须写在变量规则前遇到if才会先被关键字规则截获。这个顺序是词法分析最常见的坑拿到代码先检查这里能少走很多弯路。2.2 核心规则段的动作设计为什么每个规则都要返回 token规则段每条规则右侧的 C 代码核心职责是向解析器返回 token。比如数字的处理调用strtol把yytext转成长整型存入yylval然后返回INT。这个yylval是关键——Bison 生成的解析器靠它把词法分析的结果传给语法规则。如果漏写yylval直接 return token语法分析器拿到 token 类型却没有数值后面的表达式求值、常量折叠全都会乱。我一般还会在字符串、注释匹配规则里加上行号记录lineno因为 Cminus 的报错信息需要行号定位这是课设答辩时老师必问的细节。下面把词法分析中最常用的数字和标识符规则抽出来看%{ #include stdio.h #include stdlib.h #include syntax.tab.h extern int yylval; int lineno 1; %} digit [0-9] letter [a-zA-Z_] id {letter}({letter}|{digit})* number {digit} %% {number} { yylval atoi(yytext); return INT; } {id} { return ID; } [ \t] { /* 跳过空白 */ } \n { lineno; } { return ASSIGNOP; } { return EQ; } { return PLUS; } . { printf(line %d: unknown char %s\n, lineno, yytext); return ERROR; } %%逻辑说明数字规则里atoi转出整数值并放入yylval语法分析器的expr: INT规则才能通过$1取到真实数值标识符规则这里先返回ID字符串本身存在yytext里如果需要构建符号表一般在动作里用strdup(yytext)复制一份再交给后续处理。关键字规则写在变量规则之前Flex 按规则顺序逐条匹配确保if匹配关键字规则而不是id规则。参数说明return ERROR不是必须的但加上它可以让语法分析器收到错误 token 后触发错语处理而不是死循环——这是排查「程序匹配到非法字符后卡死」的关键。lineno是全局变量在语法分析报错时通过tree节点保存行号调试时一眼看出是哪行出了问题。2.3 配套的 syntax.tab.h 与 lex.yy.cFlex 输出的两级产物lex.yy.c是 Flex 直接生成的 C 文件不需要手改syntax.tab.h由 Bison 生成里面定义了所有 token 的宏编号比如#define INT 258lexical.l 在%{ %}段 include 它才能让返回的 token 和语法分析器对得上。这里有个顺序问题先用 bison 生成 syntax.tab.h 和 syntax.tab.c再用 flex 生成 lex.yy.c最后一起编译。如果先跑 flex 再跑 bisonlexical.l 里 include 的 syntax.tab.h 还不存在编译直接报错。README 里如果有构建顺序说明照着走即可没有的话按 bison 先、flex 后的顺序做不会错。3. 语法分析器 syntax.y从 BNF 到语法树的完整映射3.1 文法设计的优先级与结合性声明%left、%right、%nonassoc这三行声明决定了表达式文法在 Bison 里如何解决移进-归约冲突。Cminus 里乘除优先级高于加减加减左结合所以常见的写法是%left PLUS MINUS %left TIMES DIVIDE %nonassoc UMINUS%left声明同一行内的运算符优先级相同先声明的优先级低。这里PLUS MINUS在TIMES DIVIDE之前所以a b * c会归约为a (b * c)。%nonassoc UMINUS的意思是负号不能连续出现--a直接报语法错误。如果需要支持负数可以把UMINUS声明为%right并且给表达式加一条规则expr: MINUS expr %prec UMINUS。这套代码里如果没有一元负号说明文法按 Cminus 最简版实现想扩展的话这就是第一个入手点。3.2 程序结构规则$1、$2、$3 如何组装字段语法规则右侧的每个语法成分编号$1、$2、$3对应$$的字段赋值顺序。以函数定义为例——这可能是整个语法分析器里最长的一条规则function_def: type_specifier ID LPAREN params RPAREN compound_stmt { FunctionDef *fd malloc(sizeof(FunctionDef)); fd-name strdup($2); fd-return_type $1; fd-params $4; fd-body $6; $$ (struct Node*) fd; } ;这里的$$是这条规则归约后返回的语法树节点$1是type_specifier对应的类型节点$2是函数名的字符串因为 ID 词法规则返回IDtoken语义值存的是char*$4是params归约出的参量表节点$6是compound_stmt归约出的语句块节点。注意动作代码里$4和$6的顺序和规则右侧符号的次序完全对应写错一个就串位。这里用strdup($2)而不是$$ $2是为了防止后续$2所在的临时空间被复用导致函数名指针悬空。参数说明type_specifier可以是int、float、void在语法规则里通常定义为type_specifier: INT { $$ (struct Node*)TypeInt; }返回预定义的全局节点。如果做了类型检查FunctionDef里还要加一个type字段因为 Cminus 支持int和void函数返回类型不对时要在语义分析阶段报错。3.3 语句块的拼接技巧链式链表如何在归约中生长Cminus 的compound_stmt是一系列局部声明和语句的合集Bison 的每次归约只能处理一条语句所以常见的做法是用链表把同层语句串起来。这里会看到类似这样的写法stmt_list: stmt_list stmt { StmtNode *node malloc(sizeof(StmtNode)); node-stmt $2; node-next NULL; StmtList *list (StmtList*)$1; while (list-last-next) list-last list-last-next; list-last-next node; list-last node; $$ (struct Node*)list; } | stmt { StmtList *list malloc(sizeof(StmtList)); list-head $1; list-last $1; $$ (struct Node*)list; } ;第一条规则处理「已有语句列表再加一条」的情况通过last指针避免每次追加都从头遍历第二条规则是基础情况初始化列表头尾。这比每次从head遍历到尾要高效得多——虽然 Cminus 的代码量小但老师问「为什么用last指针」时能答出「避免链表的 O(n) 追加开销」印象分直接拉满。3.4 中缀表达式的树节点构建左结合如何体现在归约动作里表达式如果是a b c左结合要求归约成(a b) c。Bison 的%left自动保证结合方向但归约动作里需要把$1、$2、$3组装成二叉树节点。常见写法expr: expr PLUS expr { BinExpr *node malloc(sizeof(BinExpr)); node-op OP_PLUS; node-left (ExprNode*)$1; node-right (ExprNode*)$3; $$ (struct Node*)node; } ;这里$2是运算符本身$1和$3是左右操作数节点。因为 Bison 的归约发生在移进足够符号且优先级满足时所以a b * c会先归约b * c再归约整体加法——树的根节点是加号右子节点是乘号和 C 语言的语义一致。如果文法里没有%left声明这种写法会导致大量冲突轻则警告重则分析器行为不可预测。4. 语法树节点 tree.h 与主程序 main.c把 Bison 的输出变成一棵活树4.1 节点类型的统一接口为什么所有节点都强制转成 Node看 tree.h 会发现一个共性几乎所有结构体都包含一个NodeKind kind字段作为第一个成员并且用(struct Node*)强转。这是 C 语言里实现「继承」的经典手段——不依赖 C 的多态而是靠结构体内存布局只要第一个字段相同强转后就能安全访问公共属性。syntax.y 里$$统一声明为struct Node*而实际分配的是FunctionDef、StmtList、BinExpr等结构体靠的就是这个技巧。这种设计在编译器、解释器代码里很常见比 int 型节点编号更灵活因为节点可以直接挂着孩子指针遍历时只需判定kind决定如何递归。// tree.h 中的关键定义 enum NodeKind { NODE_FUNC_DEF, NODE_STMT_LIST, NODE_BIN_EXPR, NODE_ASSIGN, NODE_VAR_DECL, NODE_IF, NODE_WHILE, NODE_RETURN }; typedef struct Tree { enum NodeKind kind; int lineno; struct Tree *next; // 兄弟节点链 } Node; typedef struct BinExpr { enum NodeKind kind; int op; // OP_PLUS、OP_MINUS... Node *left, *right; } BinExpr;逻辑说明kind放在每个结构体的第一个成员位置让Node指针可以安全地强转成任何子结构指针而不丢数据。lineno在任一节点里都有语法报错定位时直接在任意树节点上取行号。参数说明next字段用来串兄弟节点对应stmt_list里的链表结构。如果做遍历比如打印语法树用next遍历同一层的语句用left/right遍历表达式子树两种指针不冲突——这是设计得比较清楚的一版。4.2 main.c 的调用顺序yyparse 之前和之后各做了什么main.c 的工作可以分成三段调用yyparse之前初始化解析器和打开输入文件调用yyparse时它自动从yyin读取内容经由yylex词法分析逐个返回 token之后拿到root全局变量指向的语法树根节点调用printTree之类的函数输出树结构。这里的yyparse是 Bison 生成的入口不能自己写yylex是 Flex 生成的入口二者通过yylval交换数据。如果 main.c 里在yyparse之后马上printf(parse success)说明这版的语法树可能只做校验不打印如果调用了printTree(root)则说明这一版的目标是可生成、可打印的抽象语法树。#include stdio.h #include tree.h #include syntax.tab.h extern FILE *yyin; extern int yyparse(void); extern Node *root; int main(int argc, char *argv[]) { if (argc 1) { yyin fopen(argv[1], r); if (!yyin) { perror(open input file); return 1; } } int ret yyparse(); if (ret 0) { printf(parse OK\n); printTree(root, 0); // 按缩进打印语法树 } else { fprintf(stderr, parse failed\n); } if (yyin) fclose(yyin); return 0; }逻辑说明extern Node *root;里的root是 syntax.y 里的全局变量在program规则归约时赋值。yyin是 Flex 预设的输入文件指针不赋值默认从 stdin 读——命令行传文件路径是常规用法。ret 0代表语法分析成功非 0 代表遇到语法错误这取决于 Bison 的默认行为。参数说明printTree(root, 0)里的第二个参数控制缩进深度每递归一层加 2 空格输出效果类似FunctionDef: int main Params: none CompoundStmt: VarDecl: int a BinExpr: PLUS ID: a INT: 3这种可视化的价值很大——语法树建对了语义分析才有得玩否则后续的类型检查、中间代码生成全是在错误的地基上盖楼。4.3 编译运行方式gcc 怎么把 flex 和 bison 的产物拼到同一进程最稳的一条编译命令是把lex.yy.c、syntax.tab.c、main.c三个文件一起编译链接时需要带上-lflFlex 库或改用-lfl对应的函数。具体命令bison -d syntax.y -o syntax.tab.c flex lexical.l -o lex.yy.c gcc lex.yy.c syntax.tab.c main.c -o cminus_parser -lfl参数说明-d让 bison 生成 syntax.tab.h-o指定输出文件名也可以不加Bison 默认生成 syntax.tab.c 和 syntax.tab.h。-lfl提供yywrap的实现如果代码里没定义yywrap链接会报错如果没有 Flex 库可以在 lexical.l 末尾写int yywrap(){ return 1; }绕过。三者编译顺序无所谓但 bison 必须在 flex 之前执行一次生成头文件否则 lexical.l include 失败。5. 避坑指南Flex/Bison/Cminus 常见的 5 个致命问题5.1 编译时报错syntax.tab.h: No such file or directory现象执行 gcc 编译时lexical.l 或 main.c 里 include 的 syntax.tab.h 找不到。原因直接跑 gcc没先执行 bison -d 生成头文件或者 bison 输出文件名不是 syntax.tab.h。解决先跑bison -d syntax.y -o syntax.tab.c确认同目录下有 syntax.tab.h 再编译如果 bison 命令没指定-o默认输出syntax.tab.c和syntax.tab.h注意文件名差异。5.2 运行时报错Segmentation Fault现象输入一个合法 Cminus 程序编译生成的可执行文件直接段错误。原因大多出在语法树节点分配上——某个规则的$$没赋值导致上层节点拿到野指针或者表达式归约时左子树/右子树取反访问空指针。解决先打开 core dump 看 gdb 栈定位是哪个规则崩的检查该规则的动作里是否每个分支都分配了节点。一条通用经验是所有$$分支都必须在动作里明确赋值不能出现「没有动作」的空规则不做归约。5.3 语法分析器对合法输入报错syntax error near unexpected token现象同一个输入文件用课程示例的 test 跑通过自己写的 test 就报错。原因最常见的坑是和的规则顺序——如果把赋值号的规则写在等于号后面Flex 在匹配时会先匹配到返回 ASSIGNOP再匹配到双重错误。其次注释、空白、行号处理也可能吞掉 token。解决在 lexical.l 里将的规则放在之前——不是逻辑顺序而是 Flex 的规则顺序就是匹配优先级先写的赢。这是最容易被忽略的 Flex 特性。5.4 死循环解析一个错误程序时程序卡住现象输入非法字符或非法表达式程序不退出CPU 占用高。原因词法规则遇到无法匹配的字符时没有对应的错误规则返回 ERROR tokenFlex 默认的规则把无法匹配的字符直接丢弃但 yylex 又被循环调用形成空转。解决在规则段末尾加一条万能规则. { printf(unknown char %s at line %d\n, yytext, lineno); return ERROR; }让词法器明确返回错误 token由语法分析器统一处理终止循环。5.5 语法树打印乱码或错层现象printTree 输出的缩进错乱或者节点类型显示不对。原因Node*转换成具体子类型时强转方向错误——比如把BinExpr当成VarDecl访问kind字段读出来不匹配打印函数的分支逻辑就乱走。解决打印逻辑不要只靠kind判断还应该对每个具体的 kind 访问对应结构体字段。如果节点结构体设计上没有kind字段只能按位置猜类型这种设计在排错时很痛苦——换掉这种定义统一加kind字段最省心。6. 进阶技巧给语法树加打印外的验证能力——从 parse OK 到语义检查的路径先演示一个我常用的调试技巧——修改 main.c让它除了打印语法树还额外做一次「常量表达式折叠」验证// main.c 中新增 evaluate 函数 int evalExpr(Node *node) { if (!node) return 0; if (node-kind NODE_BIN_EXPR) { BinExpr *be (BinExpr*)node; int l evalExpr(be-left); int r evalExpr(be-right); switch (be-op) { case OP_PLUS: return l r; case OP_MINUS: return l - r; case OP_TIMES: return l * r; case OP_DIVIDE: return r ? l / r : 0; } } else if (node-kind NODE_INT) { return ((IntNode*)node)-value; } return 0; }这个函数递归计算纯常量表达式的值可以在语法树上验证12*3是否得到 7。它不做变量查找所以只在没有标识符的表达式上有效但足以验证语法树结构是否正确——结构错了数值结果一定错。我把这个函数接在parse OK之后跑通一个测试文件只需要 3 秒比 gdb 打断点直观得多。接下来是把这个前端向「可编译」方向推进的关键步骤——语义检查。这版 syntax.y 里已经构建了类型为FunctionDef的节点但还没有符号表和类型检查。要扩展第一件事是在 tree.h 里加一个符号表结构或者直接用最简单的哈希表存char*到Type的映射。常见的做法是每次进入compound_stmt时创建一个新的作用域链表套链表实现嵌套查表。代码量不大但能让你在答辩时展示「前端不只会解析还能分辨声明与使用不匹配」。关于测试方法我习惯准备三份输入一份语义正确的比如函数调用前声明过、一份类型不匹配的把 int 赋值给 void 函数、一份语法错误的少个分号或括号不匹配。用这三份跑一遍能快速定位大多数问题。如果这套代码里没有测试用例你可以在input1.c、input2.c、input3.c的基础上自己构造文件名正好对应三个场景加起来跑完一遍就知道分析器覆盖了哪些语法路径。最后说一个我自己的习惯——从那以后每次拿到这套 FlexBison 的代码我都会先花十分钟跑一遍bison -d→flex→gcc这三个命令确认基础构建没问题再开始改文法。省下这几个小时后面调 bug 的时间能缩短一半。希望这次拆解能帮到正在为编译原理课设掉头发的你。本文还有配套的精品资源点击获取