ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

编译原理课设实战:TINY语法树生成与自动机压缩解压全解析

编译原理课设实战:TINY语法树生成与自动机压缩解压全解析 简介编译原理课程设计资源包完整覆盖C源程序压缩与解压、自动机模拟、文法问题处理器以及TINY扩充语言语法树生成四个核心模块并配套源代码、文档说明、实验报告和可执行程序适用于计算机相关专业学生的课程设计、期末作业或毕设参考也适合初学者对照工程源码理解编译原理的落地实现。ZIP压缩包共二百七十二个文件核心代码以五十二份C源文件和二十三份头文件为主三十二个动态库与十一个可执行程序便于直接运行和二次调试十八份PDF与十份Word文档提供实验报告、设计文档等说明资料另有图片截图、词法文件、工程配置及压缩样本等辅助内容资源总大小约七十二点五五MB。现有一百四十三人浏览学习提供私聊答疑与远程教学支持。所有代码均测试运行成功答辩评审平均分达九十六分压缩解压模块还附带多种类型源程序的压缩样本可供验证不同模块目录划分清晰用户可在项目说明文档指引下快速上手也可将其作为工程模板扩展其他功能兼具学习与项目参考价值。1. 这门编译原理作业的真正难点不是“写个编译器”而是把四个模块串成一套能交付的完整工程拿到这条标题时第一反应大概率是“又是一份可抄的课设作业”。但真做过的都知道编译原理课设的分数不取决于语法树画得多漂亮而取决于你的源代码、文档说明、实验报告和可执行文件是不是一套能自洽的东西。标题里塞了四件事C源程序压缩解压、自动机、文法问题处理器、TINY扩充语言的语法树生成。拆开看每一项都是教材第二章到第四章的经典题但合在一起就是一个迷你编译工具链词法分析用自动机实现语法分析生成TINY扩充语言的AST压缩解压又反过来依赖自动机状态转移。这篇笔记按我实际做完并交过作业的顺序来写先讲TINY扩充语言语法树怎么从零搭起来再讲自动机驱动的压缩解压方案然后说文法问题处理器怎么帮助你排查左递归和公共左因子最后是把这些模块整合进实验报告和可执行交付时的踩坑记录。适合正在做编译原理课设、需要自己动手而不是抄整包代码的人也适合想复习编译原理前四章核心知识点的C开发者。2. TINY扩充语法树生成先把词法、语法和AST的数据结构一次想清楚TINY是很多编译原理教材配套的示例语言原版只有几种类别的tokenif、while、repeat、until、read、write等保留字加上数字、标识符和几个运算符。所谓“扩充”通常是在原版基础上加上for循环、else分支、逻辑运算符、比较运算符、数组声明之类的语法。这里的核心难点不是语法本身而是词法分析器、语法分析器和AST节点之间的数据结构要一次设计对否则后面“压缩解压”和“文法问题处理器”接进来时到处打补丁。2.1 为什么选TINY而不是直接写个C子集教材配套、文法够小、可变着花样扩充常见做法是选TINY扩充语言作为课设对象理由很实际。第一清华大学出版社那一版编译原理教材的示例就是从TINY风格语言开始的词法、语法部分有不少现成思路可参照但又不至于直接抄到代码。第二TINY的文法规模适中手写递归下降解析器大概几百行C就能覆盖而写C子集要处理指针声明、数组维度、类型推导光词法规则就翻一倍。第三“扩充”两个字给了你灵活的发挥空间加一个for循环、加一个逻辑与、加数组下标访问都可以写进实验报告的“设计亮点”里工作量可控且答辩有东西可讲。我更推荐先把文法用巴科斯范式写清楚再动手写代码。常见的做法是在原版TINY文法上进行扩充例如statement :: if-stmt | while-stmt | repeat-stmt | for-stmt | read-stmt | write-stmt | assign-stmt for-stmt :: for ident : expr to expr do statement expr :: simple-expr (relop simple-expr)? simple-expr:: term (addop term)* term :: factor (mulop factor)* factor :: (expr) | number | ident | ident[expr]这里我刻意加入了for循环和数组下标。这样语法树生成时能覆盖顺序、选择、循环、赋值、表达式、数组访问六类节点实验报告写“语法树生成覆盖全部语句类型”也站得住。2.2 词法器从手写转移表到自动机状态转移TINY的token类别很少不需要上flex手写一个状态机驱动的词法器更贴合课设要求。定义token枚举enum class TokenType { IDENT, NUMBER, IF, ELSE, WHILE, REPEAT, UNTIL, FOR, TO, DO, READ, WRITE, ASSIGN, LT, LTE, GT, GTE, EQ, NEQ, PLUS, MINUS, TIMES, DIV, LPAREN, RPAREN, LBRACKET, RBRACKET, SEMI, EOF };词法器核心是一个逐字符读取的状态转移函数。常见做法是维护一个state整数从起始状态0开始遇到字母进入标识符状态遇到数字进入数字状态遇到或再读一位判断是单字符运算符还是双字符运算符std::vectorToken lex(const std::string src) { std::vectorToken tokens; size_t pos 0; while (pos src.size()) { char c src[pos]; if (isspace(c)) { pos; continue; } if (isalpha(c)) { size_t start pos; while (pos src.size() (isalnum(src[pos]) || src[pos] _)) pos; std::string word src.substr(start, pos - start); // 先查保留字表查不到就是标识符 auto it keywordMap.find(word); tokens.push_back(it ! keywordMap.end() ? Token{it-second, word, start} : Token{TokenType::IDENT, word, start}); } else if (isdigit(c)) { size_t start pos; while (pos src.size() isdigit(src[pos])) pos; tokens.push_back(Token{TokenType::NUMBER, src.substr(start, pos - start), start}); } else if (c ) { pos; if (pos src.size() src[pos] ) { pos; tokens.push_back(Token{TokenType::EQ, , pos - 2}); } else tokens.push_back(Token{TokenType::ASSIGN, , pos - 1}); } // 其它运算符分支省略逻辑相同 } tokens.push_back(Token{TokenType::EOF, , src.size()}); return tokens; }这里的关键参数是keywordMap。我一般用std::unordered_mapstd::string, TokenType初始化所有保留字这样查表是O(1)而且扩充新关键字时只改这一处。另一个值得注意的细节是行号记录我建议在Token结构体里保存源文件中的偏移量而不是行号因为后面生成语法树要回填源码位置用偏移量做排序和比对更方便行号可以在出错信息打印时再算。2.3 递归下降语法分析与AST节点设计语法树生成的核心是递归下降解析和AST节点类层次。AST节点我用继承体系基类存节点类型和源码位置派生类存具体语义数据struct ASTNode { NodeType type; size_t pos; // 对应token在源码中的偏移量 virtual ~ASTNode() default; }; struct StmtNode : ASTNode { StmtKind kind; // 语句子类型 }; struct ExprNode : ASTNode { ExprKind kind; std::string name; // 标识符名或运算符 int intVal; // 当节点是数字常量时有效 std::vectorstd::unique_ptrASTNode children; };递归下降解析器对每个非终结符写一个成员函数返回std::unique_ptrASTNode。这个模式很朴素但正因为朴素内存管理不容易漏。不推荐直接在课堂上用shared_ptr因为AST是树形所有权结构unique_ptr足够且不会引入循环引用问题。解析表达式时注意运算符优先级先写parseAssignment再写parseExpr处理关系运算最后parseSimpleExpr处理加减、parseTerm处理乘除。一个容易犯的错是把赋值语句的和相等判断的混在一起词法器已经区分了ASSIGN和EQ语法分析器必须严格按token类型走。2.4 语法树的可视化输出括号文本和dot格式两种导出语法树生成不能只是内部数据结构作业要求里通常要“生成语法树”最直观的展示方式是输出嵌套括号文本再用工具转成图。我实现了两个导出函数void printTree(const ASTNode* node, std::ostream out, int depth 0) { if (!node) return; out std::string(depth * 2, ) nodeTypeName(node-type); if (!node-name.empty()) out [ node-name ]; out \n; for (const auto child : node-children) { printTree(child.get(), out, depth 1); } }这个函数把AST变成带缩进的文本树适合放进实验报告。另外导出Graphviz dot格式可以让答辩演示更直观做法是遍历AST每个节点生成一个唯一ID父子关系用parent - child;串起来。参数上注意转义点标识符里的下划线在dot里没问题但如果标识符是中文就需要做URL编码否则graphviz会解析失败。到这里TINY扩充语言的语法树生成已经完整闭环。从词法器到AST导出核心代码量大约五六百行这个体量适合作为课设主体也适合在文档说明里分模块画架构图。3. 用自动机做C源程序压缩与解压状态机驱动的串级压缩方案课设题目里“C源程序的压缩和解压”看起来是数据压缩题但如果直接用zlib实验报告会显得没有技术含量。更贴合编译原理课设的做法是把压缩建立在自动机和词法单元识别上先识别出C源码中的注释、字符串字面量、空白再把有效代码token流做压缩。这样压缩器本质是一个状态机驱动的预处理器解压器做逆变换。3.1 思路注释、字符串字面量、空白之外的token才算有效载荷直接压缩源文件字节的压缩率通常很低因为源码里大量空白和注释是冗余。自动机的价值在于状态机可以正确区分//注释、/* */注释、字符串字面量中的//、以及预处理指令里的#。如果不用状态机靠正则匹配字符串字面量里的//必然翻车。我的方案是两阶段第一阶段用自动机把源文件转成“压缩中间格式”第二阶段对中间格式应用简单的字典压缩或LZ类压缩。解压是逆过程先解压缩回中间格式再用状态机恢复空白和注释。这里自动机的主要作用是保证无损任何字符串字面量内容都不该被误判成注释中文字符任何注释内容也不该被误判成代码。3.2 压缩器的状态机与输出缓冲设计压缩器核心状态机有五个状态enum class CppState { CODE, // 普通代码 LINE_COMMENT, // // 注释 BLOCK_COMMENT, // /* */ 注释 STRING, // ... 字符串 CHAR_LITERAL // ... 字符常量 };逐字符转移关键在遇到时检查前一位是不是\遇到/时看下一位是/还是*std::string compressCppSource(const std::string raw) { std::string out; CppState state CppState::CODE; for (size_t i 0; i raw.size(); i) { char c raw[i]; switch (state) { case CppState::CODE: if (c / i 1 raw.size() raw[i 1] /) { state CppState::LINE_COMMENT; out //; i; } else if (c / i 1 raw.size() raw[i 1] *) { state CppState::BLOCK_COMMENT; out /*; i; } else if (c ) { state CppState::STRING; out c; } else if (c \) { state CppState::CHAR_LITERAL; out c; } else { out c; } break; case CppState::LINE_COMMENT: out c; if (c \n) state CppState::CODE; break; // 其余状态类似原样输出直到遇到结束分隔符 } } return out; }这段代码的策略是“保留内容不压缩”真正压缩发生在压缩中间格式那一步。参数上值得关注的是空白字符处理我最后只在程序结构、函数边界、字符串字面量之外压缩连续空白为一个空格因为C语法允许token之间用单个空格分隔但int a;里int和a之间必须有分隔。另一个坑是#include行不能把include前的空格去掉因为预处理指令对行首的#敏感。3.3 解压器同步状态转移恢复原文件严格地说如果压缩阶段把注释整个丢弃解压就无法恢复原文件不满足“无损”。如果保留注释和字符串字面量内容那么压缩率主要来自空白压缩和字典匹配。所以解压逻辑比较简单恢复空白、恢复被字典替换的重复片段。但有一个细节源码里的\续行符在压缩阶段必须保留否则跨行宏定义会错位。更简单的做法是定义一种中间码本把常见关键字、运算符、标准库头文件映射成单字节编号。比如#include映射成0x01std::映射成0x02再对映射后的字节流做LZ77式压缩。这样解压器先解LZ77再查码本恢复文本。码本本身是固定的不随文件变化解压器不需要额外存储。3.4 为什么不建议直接上zlib以及什么时候该换如果只看压缩率zlib的deflate算法秒杀任何基于token的简单方案。但课设评分的重点通常是“有没有体现编译原理知识”自动机识别源码中的词法单元是更贴近课程主题的实现思路所以我倾向于手动实现自动机加简单字典压缩只在实验报告里说明“如果追求高压缩率可替换为zlib层”。这里的取舍是如果作业题目明确写了“压缩率越高越好”那就该上zlib或LZ4自动机只做预处理。如果题目强调的是“用自动机实现”那压缩率不是第一位。实际做的时候我在压缩器里留了一个开关useZlibFallback为true时只在token序列上做zlib压缩这样两种策略都能在答辩时演示。4. 文法问题处理器检测左递归、提取公共左因子、生成First/Follow集合“文法问题处理器”这个名词在标准教材里没有固定定义我理解为给出一份文法自动检测其中存在哪些不利于自顶向下分析的问题并给出解决方案。重点在两个问题上左递归导致递归下降解析器死循环、公共左因子导致回溯无法确定分支。另外还应该能生成First和Follow集合因为LL(1)分析表的构造依赖它们这也是实验报告里必须体现的知识点。4.1 文法问题处理器到底处理哪些问题我实现的处理器接收一个BNF风格文法文件例如expr - expr term | term term - factor * factor | factor factor - ( expr ) | number处理器输出三类信息直接左递归、间接左递归、公共左因子。直接左递归是形如A - A ...的产生式间接左递归是A - B ...; B - A ...。这两种情况在递归下降解析器中都会造成无穷递归。公共左因子是两条产生式以同一个符号开头比如term - factor * factor | factor解析器无法预读决定走哪条分支。4.2 数据结构与算法步骤文法用产生式集合表示struct Production { std::string lhs; std::vectorstd::vectorstd::string alternatives; };检测左递归的算法是构建依赖图。对每个非终结符A遍历所有产生式A - X1 X2 ...如果X1是非终结符就在图中加一条A - X1的边。最后做拓扑排序或DFS判环找到环就说明存在左递归。直接左递归的判定更简单A - A ...。消除直接左递归的通用做法是引入新非终结符例如把A - Aα | β改写成A - βAA - αA | ε。处理器要能把这种改写结果输出成新文法方便直接转成代码。我在实现里把重写规则做成可调参数rewriteDirectLeftRecursion(bool)默认开启关闭时只报告问题不自动改写便于展示“先诊断后治疗”的过程。4.3 输出问题报告、补丁建议、First/Follow表以下是生成First集合的参考思路。对每个非终结符看其产生式右部首符号终结符直接加入First非终结符则递归合并其First集合若该非终结符能推导出ε还要继续看下一个符号。std::mapstd::string, std::setstd::string computeFirstSets( const std::vectorProduction grammar) { std::mapstd::string, std::setstd::string first; bool changed true; while (changed) { changed false; for (const auto prod : grammar) { const auto alts prod.alternatives; for (const auto alt : alts) { size_t i 0; bool allHaveEpsilon true; for (; i alt.size(); i) { const std::string sym alt[i]; if (isTerminal(sym)) { if (first[prod.lhs].insert(sym).second) changed true; allHaveEpsilon false; break; } else { size_t before first[prod.lhs].size(); first[prod.lhs].insert(first[sym].begin(), first[sym].end()); if (first[prod.lhs].size() ! before) changed true; if (first[sym].count(epsilon) 0) { allHaveEpsilon false; break; } } } if (allHaveEpsilon) { if (first[prod.lhs].insert(epsilon).second) changed true; } } } } return first; }这里一个容易出错的地方是非终结符的epsilon判断如果A - B C且B的First集合不含epsilon那C是否可空就不影响A的First集合只有所有符号都可空时epsilon才加入A的First集合。我的代码里用allHaveEpsilon标记一旦遇到不可空符号就break这个逻辑要配合调试打印确认。处理器最终输出的实验报告材料包括检测到的左递归位置、消除左递归后的产生式、每个非终结符的First和Follow集合以及由这些集合生成的LL(1)预测分析表。这些输出是实验报告里最有含金量的部分比贴代码更能证明你理解了文法理论。5. 避坑语法树、压缩解压和自动机常见的5个翻车现场这部分是血泪经验。每条按“现象 - 原因 - 解决”描述都是我自己调试时真实踩过的坑。5.1 语法树生成的括号文本树少了一层括号现象expressions嵌套多层的测试用例里括号文本树显示(a b) * c变成a b * c运算符优先级显示错了。原因是递归下降解析表达式的parseExpr在遇到右括号时提前返回没有把(和)作为显式表达式节点记录。解决在AST里增加ParenthesizedExpr节点类型(解析成功后创建该节点把内部表达式作为子节点这样括号在语法树里成为结构信息而不是被丢弃的装饰符号。5.2 压缩解压后字符串字面量里的//被当成注释现象源码里有url https://example.com;压缩后变成到https:就断掉解压完全错乱。原因是压缩状态机在STRING状态里没有处理\转义看到\时直接退出字符串状态。解决在字符串处理分支里增加转义判断读到\时把下一个字符原样输出并跳过不参与状态转移判断。这个跳过逻辑要用一个escapeCount记录连续反斜杠的奇偶性否则\\\这种双重转义会算错。5.3 文法问题处理器把非终结符误判成终结符现象给定文法stmt - if expr then stmt处理器报告if是未定义非终结符。原因是终结符与否的判定依赖isTerminal函数我一开始用“首字母是否大写”判断而文法里保留字if是小写结果全部被当成终结符。解决把终结符集合显式定义成两个来源一是字形本身是标点或数字二是在文法文件头部用%token声明的保留字。这个设计让文法文件可控性大大提高。5.4 TINY扩充语言里else悬空匹配现象if a then if b then c else d按就近匹配原则应该把else匹配给内层if但递归下降解析器默认匹配给了外层。原因是parseIfStmt在处理else分支时没有限制“当前token必须是这个if语句对应的else”。解决在语法分析器里维护一个pendingElseDepth计数进入if语句时增加遇到else时先判断是否有未闭合的if只有最内层if能消费该else。这个计数器的范围是递归调用栈上下文我把它作为成员变量传递而不是全局变量避免多文件并发解析时串状态。5.5 可执行文件在评测环境运行崩溃现象本地VSCode配置的C/C环境跑得好好的交上去的ast_dump可执行文件在老师电脑上双击崩溃控制台输出中文乱码。原因是代码里用了std::wcout输出中文语法树节点名而老师评估系统代码页不是UTF-8。解决所有输出统一用std::cout配合UTF-8字符串不在Windows默认GBK代码页下用wchar_t输出。如果必须用中文字段名就在代码开头调用setlocale(LC_ALL, )。这只是个玄学问题但每年都有同学栽在这里。6. 验证用好这几个测试套路语法树和压缩解压可以放心交作业最后一章给一个高效验证方法用“黄金测试文件”做回归。在项目根目录放一个tests/文件夹里面按模块命名test_ast.tny、test_compress.cpp、test_grammar.txt。每种语言写一个覆盖所有语法特性的小用例然后写一个shell脚本跑全量验证检查输出与预期文件是否一致。#!/bin/bash set -e # 语法树期望输出文件 test_ast.expected ./tinymake tests/test_ast.tny /tmp/actual.ast diff -u tests/test_ast.expected /tmp/actual.ast /tmp/ast.diff || { echo AST mismatch; cat /tmp/ast.diff; exit 1; } # 压缩解压往返验证 ./compress_cpp tests/test_compress.cpp /tmp/compressed.bin ./decompress_cpp /tmp/compressed.bin /tmp/decompressed.cpp diff -u tests/test_compress.cpp /tmp/decompressed.cpp echo all tests passed这个脚本要在文档说明里写明如何运行它同时也是实验报告中“测试方法”一节的内容。压缩解压的验证重点是无损round-trip压缩再解压后的文件必须与原文件逐字节相同。这里不能用只对比文本内容的方式必须用diff -u或cmp因为换行符差异也会算不一致。最后一个实用技巧是给可执行分包时带上README.md里面写清楚“先运行make再运行./run_tests.sh”。实验报告里附上关键数据结构定义和自动机状态转移表。如果做到这一步这个课设方案就能稳定拿到不错的分数了。我的习惯是在交作业前把每个测试文件都故意改坏一个字符确认测试脚本能发现错误。这个习惯救过我很多次——有一次压缩器把字符串里的\t误吞了如果不是回归测试捕到交上去就是功能缺失。希望这份梳理能帮你少走这些弯路祝你顺手跑通整个流程。本文还有配套的精品资源点击获取
返回列表