编译原理课设实战:C-语言词法分析与语法分析器完整实现
发布时间:2026/10/12 17:30:14 锦皓数字建站

简介这份资源是面向计算机专业学生与编译原理学习者的课程设计项目包聚焦C-语言子集的词法分析与语法分析实现适合正在做课程设计或希望动手理解编译器前端流程的读者。压缩包共22个文件约25KB以cpp与h源码为主体另有txt测试用例与结果输出、json编辑器配置及md说明文档源码目录中可看到词法分析器、字符扫描器、记号定义与语法分析器等模块并附有词法分析结果与语法分析结果文件便于对照验证。项目围绕记号识别、BNF/EBNF语法规则与语法树构建展开读者可借此梳理扫描器与解析器的协作方式尝试增改关键字、运算符或调试非法输入观察输出变化。目前已有125人学习下载适合作为编译原理实验的参考实现与二次开发起点。1. 从一份 C- 语言编译器课设说起词法分析器和语法分析器到底怎么落地如果你正在做编译原理课程设计大概率会遇到一个尴尬局面课本上把有限自动机、LL(1) 分析表、递归下降讲得头头是道但真让你从零写一个能跑通int main() { return 0; }的 C- 语言词法分析和语法分析器却不知道从哪下手。这份「编译原理课程设计自制 C- 语言词法分析和语法分析器」压缩包就是一份可以直接编译运行的完整工程包含src目录下的LexicalAnalyzer.cpp、CharScanner.cpp、SyntaxParser.cpp、Token.cpp等核心实现以及test1.txt、test2.txt、cminus.txt等测试输入和语法规则文件。它解决的不是「编译器怎么造」这种宏大命题而是「课设怎么交、代码怎么跑、结果怎么对」这三个具体问题。适合正在赶编译原理实验的本科生也适合想快速回顾词法/语法分析实现细节的开发者。下面我按实际拆包和编译的顺序把这份资源讲透。2. 拆开压缩包先看什么工程结构与 C- 语言子集定义2.1 目录树里每个文件的实际作用拿到压缩包后别急着编译先把目录结构理清楚。这份工程的布局是典型的 VS Code C 项目核心文件分布如下路径类型作用src/LexicalAnalyzer.cpp/.h源码词法分析主逻辑驱动扫描器输出 Token 流src/CharScanner.cpp/.h源码字符级扫描负责读入源文件、跳过空白和注释src/Token.cpp/.h源码Token 数据结构定义含类型枚举和值src/SyntaxParser.cpp/.h源码语法分析主逻辑递归下降或 LL(1) 实现src/Test.cpp/.h源码测试入口串联词法和语法分析cminus.txt规则C- 语言子集的语法规则定义test1.txt/test2.txt输入待分析的 C- 语言源代码样例LexicalAnalyzer-Result.txt输出词法分析结果逐行列出 TokenSyntaxParser-Result.txt输出语法分析结果展示语法树或分析过程.vscode/配置tasks.json定义编译任务launch.json定义调试配置这里要特别注意cminus.txt它不是源代码而是语法规则的定义文件。C- 语言是 C 语言的一个教学子集通常只保留int、void两种类型支持if、else、while、return和函数定义去掉了指针、结构体、浮点数等复杂特性。语法分析器会读取这个文件来构建分析表或指导递归下降流程。2.2 为什么选 C- 语言而不是完整 C 语言很多人第一次做课设会想直接分析标准 C 代码结果卡在预处理指令、宏定义、复杂声明上。C- 语言的设计初衷就是规避这些坑它的文法规模小LL(1) 或递归下降都能处理不会出现左递归和回溯爆炸。常见做法是把 C- 语言的 BNF 写成类似下面的形式放在cminus.txt里供解析器读取program - declaration-list declaration-list - declaration declaration-list | declaration declaration - var-declaration | fun-declaration var-declaration - type-specifier ID ; | type-specifier ID [ NUM ] ; type-specifier - int | void fun-declaration - type-specifier ID ( params ) compound-stmt params - param-list | void param-list - param , param-list | param param - type-specifier ID | type-specifier ID [ ] compound-stmt - { local-declarations statement-list }这份文法没有左递归declaration-list和param-list都通过「先写一个再递归」的方式展开适合递归下降。如果你用的是 LL(1) 分析表需要先计算 FIRST 集和 FOLLOW 集再填预测分析表。工程里的SyntaxParser.cpp大概率走的是递归下降路线因为代码量更可控调试也更直观。2.3 编译前必须确认的三件事在终端里敲编译命令之前先检查环境。这份工程依赖 C 标准库没有第三方依赖所以只要系统里有g或clang就能编译。我一般会按下面三步走# 第一步确认编译器版本建议 g 9 以上 g --version # 第二步进入 src 目录查看所有源文件 cd src ls *.cpp # 第三步尝试编译输出可执行文件到上级目录 g -stdc17 -o ../compiler LexicalAnalyzer.cpp CharScanner.cpp Token.cpp SyntaxParser.cpp Test.cpp编译命令里的-stdc17是保险起见工程里可能用了std::optional或结构化绑定。如果报错提示找不到某个头文件检查.vscode/c_cpp_properties.json里的includePath是否包含了src目录。编译成功后会在项目根目录生成compiler可执行文件接下来就可以跑测试了。提示如果用的是 Windows MinGW把g换成g.exe路径分隔符也要相应调整。VS Code 的tasks.json里通常已经配好了编译任务按CtrlShiftB可以直接触发。3. 词法分析器怎么跑通从字符流到 Token 序列3.1 CharScanner 与 LexicalAnalyzer 的分工词法分析的核心思路是「字符扫描器 词法分析器」两层结构。CharScanner负责最底层的字符读取它打开源文件维护当前行号和列号提供getChar()和peekChar()接口跳过空格、制表符、换行和注释。LexicalAnalyzer则调用CharScanner根据读到的字符判断 Token 类型组装成Token对象后输出。这种分层的好处是职责清晰CharScanner不关心 Token 是什么只管字符流LexicalAnalyzer不关心文件怎么读只管从字符流里识别有意义的符号。常见做法是在LexicalAnalyzer.cpp里写一个getToken()函数用switch或状态机处理不同起始字符// LexicalAnalyzer.cpp 核心逻辑示意 Token LexicalAnalyzer::getToken() { char ch scanner-getChar(); // 跳过空白字符 while (isspace(ch)) { ch scanner-getChar(); } // 标识符或关键字以字母或下划线开头 if (isalpha(ch) || ch _) { std::string name; while (isalnum(ch) || ch _) { name ch; ch scanner-getChar(); } scanner-ungetChar(ch); // 多读一个字符要退回 // 查关键字表决定是 ID 还是保留字 if (keywords.count(name)) { return Token(TokenType::KEYWORD, name); } return Token(TokenType::ID, name); } // 数字常量以数字开头 if (isdigit(ch)) { std::string num; while (isdigit(ch)) { num ch; ch scanner-getChar(); } scanner-ungetChar(ch); return Token(TokenType::NUM, num); } // 运算符和分隔符单字符或双字符 switch (ch) { case : return Token(TokenType::PLUS, ); case -: return Token(TokenType::MINUS, -); case *: return Token(TokenType::TIMES, *); case /: return Token(TokenType::OVER, /); case : { char next scanner-getChar(); if (next ) return Token(TokenType::LE, ); scanner-ungetChar(next); return Token(TokenType::LT, ); } // 其他运算符和分隔符类似处理 case EOF: return Token(TokenType::ENDFILE, EOF); default: return Token(TokenType::ERROR, std::string(1, ch)); } }这段代码的关键点有三个一是ungetChar的实现它让扫描器可以「多读一个字符再退回」这是处理标识符和数字边界的基础二是关键字表的查找通常用std::unordered_setstd::string存int、void、if、else、while、return这六个保留字三是错误处理遇到无法识别的字符返回ERRORToken而不是直接崩溃。3.2 用 test1.txt 验证词法输出编译完成后跑第一个测试用例。假设test1.txt的内容是一段简单的 C- 代码int main(void) { int x; x 10; return x; }运行命令./compiler test1.txt程序会读取test1.txt调用词法分析器逐行输出 Token。对照LexicalAnalyzer-Result.txt里的预期结果你应该看到类似下面的输出KEYWORD: int ID: main LPAREN: ( KEYWORD: void RPAREN: ) LBRACE: { KEYWORD: int ID: x SEMI: ; ID: x ASSIGN: NUM: 10 SEMI: ; KEYWORD: return ID: x SEMI: ; RBRACE: } ENDFILE: EOF如果输出里出现了ERRORToken说明源文件里有词法分析器不认识的字符。常见情况是中文分号、全角括号或者注释没有正确跳过。检查CharScanner.cpp里的注释处理逻辑确认//和/* */两种注释都能被正确跳过。3.3 参数怎么改扩展关键字和运算符课设通常要求能扩展。如果你想增加一个新的关键字比如for需要改两个地方一是在LexicalAnalyzer.cpp的关键字表里加入for二是在Token.h的TokenType枚举里加一个FOR类型如果语法分析器需要区分的话。如果只是词法层面识别不加枚举也行关键字表里有了就会输出KEYWORD: for。增加运算符同理。比如要支持在switch里加一个case 的分支判断下一个字符是不是是就返回PLUSASSIGN否则退回并返回PLUS。改完之后重新编译用新的测试文件验证。我一般会准备一个「边界测试文件」里面故意放上a1、a、a--这种组合看词法分析器能不能正确切分。注意ungetChar只能退回一个字符。如果你在代码里连续调用了两次getChar再想退回两个需要自己扩展缓冲区。常见做法是用一个std::stackchar或者固定大小的回退缓冲区。4. 语法分析器怎么接上递归下降构建语法树4.1 从 Token 流到语法树的驱动逻辑词法分析器输出 Token 序列后语法分析器的工作就是「消费」这些 Token按照cminus.txt里的文法规则构建语法树。递归下降是最容易手写的方案每个非终结符对应一个函数函数内部根据当前 Token 决定走哪条产生式。SyntaxParser.cpp里通常有一个parse()入口调用parseProgram()然后层层递归下去。核心机制是「前瞻一个 Token」语法分析器维护一个当前 Token每次进入一个非终结符函数时先看当前 Token 是什么再决定走哪个分支。比如parseDeclaration()看到int或void就继续解析声明看到}就返回。这种「看一个 Token 做决定」的方式就是 LL(1) 的朴素实现。// SyntaxParser.cpp 递归下降核心逻辑示意 void SyntaxParser::parseDeclaration() { // 当前 Token 是 int 或 void进入变量声明或函数声明 TokenType type currentToken.getType(); if (type TokenType::KEYWORD (currentToken.getValue() int || currentToken.getValue() void)) { // 先吃掉类型关键字 Token typeToken currentToken; advance(); // 读取下一个 Token // 下一个必须是标识符 if (currentToken.getType() ! TokenType::ID) { error(期望标识符实际得到: currentToken.getValue()); return; } std::string name currentToken.getValue(); advance(); // 根据下一个 Token 判断是变量声明还是函数声明 if (currentToken.getType() TokenType::LPAREN) { // 函数声明type ID ( params ) compound-stmt parseFunctionDeclaration(typeToken, name); } else if (currentToken.getType() TokenType::SEMI || currentToken.getType() TokenType::LBRACKET) { // 变量声明type ID ; 或 type ID [ NUM ] ; parseVariableDeclaration(typeToken, name); } else { error(声明格式错误意外 Token: currentToken.getValue()); } } else { error(期望类型关键字 int 或 void实际得到: currentToken.getValue()); } }这段代码展示了递归下降的三个关键操作advance()读取下一个 TokencurrentToken始终指向当前待处理的 Tokenerror()在遇到不符合文法的输入时报告错误并尝试恢复。错误恢复策略通常是「跳过 Token 直到遇到分号或右花括号」这样一次分析能报出多个错误而不是遇到第一个就退出。4.2 语法树节点设计与输出格式语法树节点通常用一个结构体或类表示包含节点类型、子节点列表和可选的属性值。SyntaxParser-Result.txt里的输出格式可能是缩进树形也可能是前序遍历序列。常见做法是定义一个TreeNode结构// 语法树节点定义示意 struct TreeNode { std::string nodeType; // 节点类型如 Program、VarDecl std::string value; // 叶子节点的值如标识符名、数字 std::vectorTreeNode* children; // 子节点列表 TreeNode(std::string type, std::string val ) : nodeType(type), value(val) {} // 前序遍历打印语法树 void print(int indent 0) { for (int i 0; i indent; i) std::cout ; std::cout nodeType; if (!value.empty()) std::cout : value; std::cout std::endl; for (auto* child : children) { child-print(indent 1); } } };用test1.txt跑语法分析输出应该是一棵以Program为根的树下面挂着VarDecl、FunDecl、CompoundStmt等节点。对照SyntaxParser-Result.txt检查节点类型和层级关系是否正确。如果树的结构不对优先检查parseDeclaration()里对LPAREN和SEMI的判断逻辑这是最容易搞混的地方。4.3 错误恢复与报错信息语法分析器不能一遇到错误就退出否则用户改一个错要重新编译一次。常见做法是在error()函数里记录错误信息然后调用synchronize()跳过 Token 直到遇到「同步点」——通常是分号、右花括号或int/void关键字。这样一次运行能报出多个语法错误。// 错误恢复示意 void SyntaxParser::synchronize() { // 跳过 Token 直到遇到分号、右花括号或文件结束 while (currentToken.getType() ! TokenType::SEMI currentToken.getType() ! TokenType::RBRACE currentToken.getType() ! TokenType::ENDFILE) { advance(); } // 如果当前是分号吃掉它继续 if (currentToken.getType() TokenType::SEMI) { advance(); } }报错信息要包含行号和列号这样用户能快速定位。CharScanner里维护的line和column变量就是干这个用的。在error()里输出第 X 行第 Y 列: 期望 Z实际得到 W比单纯说「语法错误」有用得多。提示递归下降的深度受调用栈限制。如果测试文件里有几百层嵌套的括号可能会栈溢出。课设级别的输入一般不会触发但如果你要分析大文件考虑改成显式栈的 LL(1) 表驱动方式。5. 避坑与排查编译、运行、结果对不上的常见问题5.1 编译报错「undefined reference」现象链接阶段报undefined reference to LexicalAnalyzer::getToken()或类似错误。原因编译命令里漏掉了某个.cpp文件或者头文件里的函数声明没有对应的实现。常见情况是Token.cpp被遗漏因为它的名字看起来像纯头文件。解决用g -stdc17 -o compiler src/*.cpp一次性编译所有源文件避免手动列举遗漏。如果某个函数确实没实现检查对应的.cpp文件是否在src目录下。5.2 词法分析结果里出现乱码或多余 Token现象LexicalAnalyzer-Result.txt里出现了ERROR: ?或者 Token 数量比预期多。原因源文件编码不是 UTF-8或者CharScanner没有正确处理\r\n换行。Windows 下编辑的test1.txt可能带 BOM 头导致第一个字符被识别为非法字符。解决用 VS Code 把测试文件另存为「UTF-8 无 BOM」格式。在CharScanner.cpp里加一个判断如果文件开头是0xEF 0xBB 0xBF就跳过这三个字节。另外确认isspace()能处理\r不能的话手动加一个case \r: break;。5.3 语法分析器陷入死循环现象程序跑test2.txt时卡住不输出CPU 占用飙升。原因递归下降里某个函数在遇到意外 Token 时没有advance()导致currentToken永远不变递归无法推进。常见于parseStatement()里遇到未知 Token 时直接返回但没有消费掉这个 Token。解决在每个非终结符函数的入口和出口加日志打印当前 Token 和函数名。找到卡住的那个函数后确保所有分支要么advance()要么调用synchronize()。最保险的做法是在parse()主循环里加一个计数器如果连续 100 次没有消费 Token 就强制报错退出。5.4 语法树输出与预期不符现象SyntaxParser-Result.txt里的树结构缺少某些节点或者层级关系反了。原因parseDeclaration()里对函数声明和变量声明的判断条件写反了或者parseCompoundStmt()没有正确处理local-declarations和statement-list的分界。解决对照cminus.txt里的文法逐条检查。compound-stmt - { local-declarations statement-list }意味着左花括号后先尝试解析局部声明直到遇到不是int/void开头的 Token 为止然后切换到语句列表。如果局部声明和语句的边界判断错了树就会缺节点。5.5 VS Code 调试配置不生效现象按 F5 启动调试提示「找不到任务」或「program 路径不存在」。原因.vscode/launch.json里的program路径写的是绝对路径换一台机器就失效了。或者tasks.json里的label和launch.json里的preLaunchTask不匹配。解决把program改成${workspaceFolder}/compiler用相对路径。确认tasks.json里的label是buildlaunch.json里的preLaunchTask也是build。如果还是不行直接在终端里手动编译运行调试配置只是锦上添花。6. 进阶玩法用符号表把语义检查串起来课设做到这一步词法和语法都跑通了但离「编译器」还差一个语义分析。最实用的进阶方向是加一个符号表在语法分析过程中记录变量和函数的声明信息然后在引用时检查是否已声明、类型是否匹配。这比单纯输出语法树更有含金量答辩时也更有话说。符号表的实现不复杂用一个std::unordered_mapstd::string, Symbol就行Symbol里存类型、种类变量/函数、作用域层级。在parseVariableDeclaration()里插入符号在parseExpression()里查找符号。如果查找失败就报「未声明的标识符」如果类型不匹配就报「类型错误」。// 符号表与语义检查示意 struct Symbol { std::string name; std::string type; // int 或 void std::string kind; // variable 或 function int scopeLevel; // 作用域层级0 为全局 }; class SymbolTable { std::unordered_mapstd::string, std::vectorSymbol table; public: void insert(const Symbol sym) { table[sym.name].push_back(sym); } Symbol* lookup(const std::string name) { auto it table.find(name); if (it table.end() || it-second.empty()) return nullptr; return it-second.back(); // 返回最近声明的符号 } void enterScope() { /* 作用域层级加一 */ } void exitScope() { /* 移除当前层级的符号 */ } };在parseDeclaration()里解析到变量名后调用symbolTable.insert({name, type, variable, currentScope})。在parseExpression()里遇到标识符时调用symbolTable.lookup(name)如果返回空指针就报错。作用域处理用栈式结构进入compound-stmt时enterScope()退出时exitScope()把当前层级的符号删掉。验证方法是准备一个「语义错误测试文件」里面故意写x y 1;但y没声明看程序能不能报出「未声明的标识符 y」。再写一个int f(void) { return; }看能不能报「void 函数不应返回值」。这两个用例过了符号表就算落地了。从那以后我每次做课设都会先把符号表的最小可用版本搭起来再回头补语法树输出。因为符号表是「能讲出故事」的部分——它把词法、语法、语义串成了一条线答辩时老师问「你这个编译器能做什么」你可以直接说「能检测未声明变量和类型不匹配」比单纯说「能输出语法树」有说服力得多。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。