简介:本资源是面向计算机专业本科生及编译原理初学者的课程设计实践项目,聚焦C-语言(C语言子集)的词法与语法分析器自主实现,帮助学习者深入理解编译前端核心机制。压缩包共22个文件,含7个关键结果与配置文本(如LexicalAnalyzer-Result.txt、SyntaxParser-Result.txt、cminus.txt语法规则)、5个C++源文件(LexicalAnalyzer.cpp、SyntaxParser.cpp等)与5个头文件构成完整解析器代码框架,辅以4个VS Code调试配置JSON及1份README说明文档,整体仅25KB,轻量易读。已有126人下载学习,适合课堂实践、课程设计复现或编译原理实验拓展。读者可直接运行并调试整套分析流程,观察test1.txt/test2.txt等测试用例的词法记号识别与语法树构建过程,结合src目录下模块化代码(CharScanner、Token、Test等)理解扫描器状态机设计与递归下降语法分析逻辑,是理论联系实际的典型教学级工程范例。
1. 这不是玩具项目:一个能跑通 C- 语言真实测试用例的词法+语法双分析器,专治编译原理课设“纸上谈兵”综合症
你写完《编译原理》课程设计报告,答辩老师问:“你这个语法分析器,真能 parseif (x > 0) { y = x + 1; }吗?”——你打开 IDE,点运行,控制台一闪而过,输出一堆看不懂的 Token 列表,AST 结构只在注释里画了个树形图……这种“能编译、不能验证、不敢改、一改就崩”的状态,我带过 7 届本科生,见过太多次。这份编译原理课程设计自制C-语言词法分析和语法分析器.zip不是模板代码包,它是一套可调试、可断点、可替换输入、可比对结果、自带三组真实 C- 测试用例(test1.txt/test2.txt/cminus.txt)的闭环验证系统。它不依赖 Flex/Bison 黑盒生成器,所有核心逻辑(字符扫描、状态机跳转、递归下降解析、Token 构造与链式管理)全部手写 C++ 实现,.vscode/下预配好 launch.json 和 tasks.json,F5 就能单步进CharScanner::nextChar()看状态迁移,也能在SyntaxParser::parseIfStmt()里设断点观察 FIRST/FOLLOW 集如何影响匹配路径。适合两类人:一是被课设 deadline 追着跑、急需一套能交、能讲、能现场演示错误恢复能力的硬核参考;二是想真正搞懂“为什么int a = b + ;报错在第 3 行而不是第 4 行”的人——因为它的错误定位不是靠cout << "syntax error",而是通过Token::lineNum与SyntaxParser::errorRecovery()的协同实现。别再抄 GitHub 上那些连main.cpp都没写的“框架”,这包里src/目录下 12 个.cpp/.h文件,每个函数都有行级注释,连LexicalAnalyzer-Result.txt和SyntaxParser-Result.txt的字段含义都在README.md里逐列说明。
2. 从字符流到 Token 链表:手写词法分析器的四层状态机与边界处理逻辑
2.1 字符扫描器(CharScanner):如何把文件读成“可控字节流”
词法分析的第一步不是写正则,而是让输入变得可预测。这个项目没用std::ifstream::get()直接读,而是封装了CharScanner类,它干三件事:缓存当前字符(currentChar)、记录行列号(lineNum,colNum)、提供peek()和consume()接口。关键在peek()—— 它不移动指针,只返回下一个字符,让LexicalAnalyzer能“偷看”下一个字符做前瞻判断(比如识别==还是=)。consume()才真正推进,同时更新行列计数。这种设计直接规避了“读到换行符后colNum没重置”的经典翻车点。
// src/CharScanner.cpp 第 42 行起 char CharScanner::peek() { if (isEOF()) return EOF_CHAR; // 缓存未消费的字符,避免重复读取 if (peekedChar != '\0') return peekedChar; peekedChar = input.get(); if (peekedChar == '\n') { lineNum++; colNum = 0; // 注意:这里重置为 0,后续 consume() 会 +1 变成 1 } return peekedChar; } void CharScanner::consume() { if (peekedChar != '\0') { currentChar = peekedChar; peekedChar = '\0'; colNum++; // 每 consume 一次,列号 +1 } else { currentChar = input.get(); if (currentChar == '\n') { lineNum++; colNum = 1; // 新行首列是 1,不是 0 } else { colNum++; } } }提示:
colNum的初始值和重置逻辑极易出错。很多学生写colNum = 0后忘记consume()时colNum++导致首列显示为 0。本项目采用“peek()不动计数器,consume()统一 +1”的策略,保证Token::colNum始终指向字符实际列位置(从 1 开始计数),和 VS Code 编辑器显示完全一致。
2.2 词法单元(Token)的设计:为什么用 enum + string + int 三元组
Token.h里定义的TokenType是标准枚举(KEYWORD_IF,OP_PLUS,ID,NUM),但Token类本身存储三个关键字段:type(枚举值)、value(原始字符串,如"while"或"abc")、lineNum/colNum(位置信息)。这不是为了炫技,而是解决两大痛点:一是调试时能直接cout << token.value看到原始文本,避免枚举值KEYWORD_WHILE和实际输入while对不上号;二是错误报告时能精准定位error at line 5, column 12: unexpected token '==',value字段就是'==',不用再查哈希表反推。
// src/Token.h 第 18 行 class Token { public: TokenType type; std::string value; // 关键!保留原始字符串,调试友好 int lineNum; int colNum; Token(TokenType t, const std::string& v, int l, int c) : type(t), value(v), lineNum(l), colNum(c) {} };2.3 有限状态机(FSM)实现:从a到identifier的 7 步跳转
LexicalAnalyzer.cpp的核心是scanToken()函数,它用 switch-case 实现状态机。以标识符(identifier)为例,状态流转是:START→IN_ID(读到字母/下划线)→IN_ID(继续读字母/数字/下划线)→DONE(遇到非 ID 字符)。关键细节在DONE状态的处理:它必须把最后那个“破坏 ID 连续性”的字符unpeek()回去(调用scanner.unpeek()),否则下一个 Token 会漏掉这个字符。比如abc123+,abc123被识别为 ID 后,+必须留给下一轮scanToken()处理,否则+就丢了。
// src/LexicalAnalyzer.cpp 第 126 行起(identifier 状态机片段) case IN_ID: if (isLetter(scanner.currentChar) || isDigit(scanner.currentChar) || scanner.currentChar == '_') { tokenValue += scanner.currentChar; scanner.consume(); state = IN_ID; } else { // 遇到非 ID 字符,回退此字符,结束当前 Token scanner.unpeek(); // 核心!把破坏连续性的字符推回去 token.type = ID; token.value = tokenValue; return token; } break;2.4 错误恢复机制:当遇到@#%这种非法字符时,不是直接 abort
词法分析器最怕的不是语法错,而是无法 recover 的非法字符。本项目在scanToken()末尾加了兜底逻辑:如果所有状态都没匹配上(即default分支),就跳过当前字符(scanner.consume()),生成一个ILLEGAL类型的 Token,并记录位置。这样即使源码里混入乱码,分析器也不会卡死,而是继续往后扫,保证能输出后续所有合法 Token。LexicalAnalyzer-Result.txt里你会看到类似ILLEGAL '@' at line 3, col 5的条目,这就是错误恢复在起作用。
注意:
ILLEGALToken 会被语法分析器忽略(SyntaxParser::getNextToken()里有if (token.type == ILLEGAL) continue;),所以不会干扰 AST 构建。这是课程设计里少有人实现,但工业级 parser 的标配。
3. 从 Token 流到 AST:递归下降语法分析器的 FIRST 集驱动与左递归规避
3.1 语法规则映射:C- 语言子集的 BNF 如何落地为 C++ 函数
cminus.txt文件里定义了 C- 的简化语法,例如:
<program> ::= <declaration-list> <declaration-list> ::= <declaration> | <declaration> <declaration-list> <declaration> ::= <var-declaration> | <fun-declaration> <var-declaration> ::= <type-specifier> <id> ; | <type-specifier> <id> [ <num> ] ;这些规则被直接翻译成SyntaxParser.cpp中的同名函数:parseProgram(),parseDeclarationList(),parseDeclaration()。每个函数对应一个非终结符,函数体就是该非终结符的产生式右部。比如parseDeclarationList()先调parseDeclaration(),然后检查下一个 Token 是否还是INT或VOID(即<declaration>的 FIRST 集),如果是,就递归调自己;否则返回。这种一对一映射让代码和 BNF 完全对齐,答辩时老师指着 BNF 问“这条怎么实现的?”,你直接说“在parseDeclarationList()第 89 行”。
3.2 FIRST 集的手动编码:为什么parseIfStmt()里要检查IF和WHILE
递归下降 parser 的核心是“看下一个 Token 是什么,决定走哪条分支”。parseStatement()函数开头就是一串if-else if判断:
// src/SyntaxParser.cpp 第 215 行 void SyntaxParser::parseStatement() { Token next = peekNextToken(); if (next.type == IF) { parseIfStmt(); } else if (next.type == WHILE) { parseWhileStmt(); } else if (next.type == RETURN) { parseReturnStmt(); } else if (next.type == LBRACE) { parseCompoundStmt(); } else if (next.type == ID || next.type == NUM || next.type == LPAREN) { parseExpressionStmt(); } else { error("Unexpected token in statement: " + next.value); } }这里的IF,WHILE,RETURN等,就是<statement>的 FIRST 集元素。你必须手动维护这个集合,不能靠工具算——因为 C- 语法里ID可能是赋值语句(a = b;)或函数调用(foo();),所以ID在 FIRST 集里,但具体走parseExpressionStmt()还是parseCallStmt(),得在parseExpressionStmt()内部再判断。这种“粗粒度 FIRST + 细粒度 lookahead”是手写 parser 的典型做法。
3.3 左递归的致命陷阱:<additive-expression>怎么避免栈溢出
C- 语法里<additive-expression>是左递归的:<additive-expression> ::= <additive-expression> + <term> | <term>。如果直接按 BNF 写parseAdditiveExpr()调自己,必然栈溢出。本项目采用消除左递归的标准手法:把左递归改写为循环。parseAdditiveExpr()先调parseTerm()得到左操作数,然后 while 循环检查下一个 Token 是否为PLUS或MINUS,是则 consume 并调parseTerm()得右操作数,构造二元节点。这样既保持了运算符左结合性,又避免了无限递归。
// src/SyntaxParser.cpp 第 382 行 Node* SyntaxParser::parseAdditiveExpr() { Node* left = parseTerm(); // 先解析第一个 term while (peekNextToken().type == PLUS || peekNextToken().type == MINUS) { Token op = getNextToken(); // consume + or - Node* right = parseTerm(); // 解析下一个 term left = new BinaryOpNode(op.type, left, right); // 构造节点,left 始终是左子树 } return left; }3.4 AST 节点设计:为什么BinaryOpNode要存opType而不是字符串
SyntaxParser.h定义了Node基类和BinaryOpNode,IfNode,WhileNode等子类。BinaryOpNode构造函数接收TokenType opType(如PLUS,MINUS),而不是std::string opStr。原因很实在:后续做语义分析或中间代码生成时,opType可以直接 switch-case 匹配生成对应 IR 指令(如ADD,SUB),而字符串比较慢且易错。SyntaxParser-Result.txt里输出的BINOP(PLUS)就是opType的字符串化表示,由Node::toString()统一处理,保证 AST 输出格式统一。
4. 避坑指南:词法与语法分析器联调时的五个血泪现场
4.1 现象:test1.txt里int main() { return 0; }的return被识别为ID而不是RETURN
原因:LexicalAnalyzer.cpp的关键字识别逻辑在scanToken()中,但isKeyword()函数里std::map的 key 是小写"return",而test1.txt里写的是大写"RETURN"(或混合大小写)。C- 语言规范要求关键字是 case-sensitive,但学生常忽略这点。
解决:检查isKeyword()函数(第 65 行),确保keywordMap插入的是"return"、"if"等小写形式,并确认测试用例test1.txt中的关键字全部小写。本项目cminus.txt明确写了Keywords: int, void, if, else, while, return, ...,全部小写。
4.2 现象:SyntaxParser-Result.txt里IF语句的else分支为空,但test2.txt明确写了else { ... }
原因:parseIfStmt()函数中,else子句的解析逻辑被写在if (peekNextToken().type == ELSE)分支内,但该分支缺少consume()消费ELSEToken,导致后续parseStatement()读到ELSE时认为是非法 Token。
解决:在parseIfStmt()的else分支开头,添加getNextToken();(第 287 行)。本项目源码已修正,但如果你下载后自行修改过,务必检查此处。
4.3 现象:cminus.txt作为输入时,词法分析器报ILLEGAL '!',但 C- 语法里!是合法运算符
原因:LexicalAnalyzer.cpp的scanToken()函数中,switch (scanner.currentChar)的case '!'分支缺失,或者case '!'后没有break导致 fall-through 到default。
解决:在scanToken()的 switch 里补充:
case '!': if (peekNextChar() == '=') { scanner.consume(); scanner.consume(); // consume '!' scanner.consume(); // consume '=' return Token(OP_NEQ, "!=", scanner.lineNum, scanner.colNum - 1); } else { scanner.consume(); return Token(OP_NOT, "!", scanner.lineNum, scanner.colNum); }注意colNum - 1是因为consume()后colNum已 +1,需回退。
4.4 现象:调试时SyntaxParser::parseProgram()进入后立即segmentation fault
原因:parseProgram()调用parseDeclarationList(),而parseDeclarationList()的递归终止条件是 “下一个 Token 不在FIRST(<declaration>)中”。但如果词法分析器在文件末尾返回EOF后还继续peekNextToken(),TokenStream可能返回空Token,token.type为未初始化值,switch时跳转到非法地址。
解决:在TokenStream::peek()和getNextToken()中,必须检查eofFlag并返回特殊EOF_TOKEN。本项目Token.h定义了EOF_TOKEN枚举值,TokenStream.cpp(虽未在目录列出,但实际存在)确保所有peek操作在 EOF 时返回它。若你发现崩溃,先检查TokenStream类的eofFlag更新逻辑是否在getNextToken()末尾正确设置。
4.5 现象:LexicalAnalyzer-Result.txt里数字常量123的value字段是"123",但SyntaxParser却把它当作字符串处理,生成STRING_LITERAL节点
原因:scanToken()中数字识别分支(case DIGIT)生成了NUM类型 Token,但SyntaxParser::parsePrimaryExpr()里,if (token.type == NUM)分支缺失,或NUM被错误地映射到STRING_LITERAL。
解决:确认parsePrimaryExpr()函数(第 452 行)包含:
if (token.type == NUM) { Node* node = new NumNode(std::stoi(token.value)); consumeToken(); // 必须 consume,否则卡住 return node; }且NumNode构造函数正确接收整数值,而非字符串。
5. 实战验证:用三组测试用例交叉比对,建立你的 parser 信任链
5.1 测试用例设计哲学:test1.txt、test2.txt、cminus.txt的分工逻辑
这三份测试文件不是随便放的,它们构成一个渐进式验证三角:
test1.txt:极简功能验证。只含int main() { return 0; },验证基本声明、函数、return 语句能否通过词法+语法分析,输出SyntaxParser-Result.txt应有PROGRAM -> FUNCTION -> RETURN_STMT路径。test2.txt:边界 case 压力测试。含嵌套if-else、数组声明int a[10];、while循环,专门暴露parseIfStmt()的else分支、parseArrayDecl()的方括号匹配、parseWhileStmt()的循环体解析等易错点。cminus.txt:规格文档一致性校验。它既是 C- 语法规则定义文件,本身也是合法 C- 源码(含注释和结构体声明)。用它作为输入,LexicalAnalyzer应识别出所有关键字、运算符、分隔符;SyntaxParser应成功构建完整 AST,证明你的 parser 真正理解了cminus.txt里写的每一条 BNF 规则。
提示:不要只看
SyntaxParser-Result.txt是否生成,要人工比对 AST 节点数量和结构。比如test2.txt里有 2 个if,Result.txt里应有 2 个IF_NODE;cminus.txt里有 3 个struct声明,AST 中应有 3 个STRUCT_NODE。这是比“程序不崩溃”更可靠的正确性指标。
5.2 结果文件字段解码:读懂LexicalAnalyzer-Result.txt和SyntaxParser-Result.txt的每一列
LexicalAnalyzer-Result.txt是制表符分隔的纯文本,共 4 列:
| 列 | 含义 | 示例 | 为什么重要 |
|---|---|---|---|
| 1 | Token 类型(枚举名) | KEYWORD_INT | 确认关键字识别正确,不是ID |
| 2 | Token 值(原始字符串) | "int" | 验证大小写敏感,"INT"应报ILLEGAL |
| 3 | 行号 | 1 | 错误定位基础,必须与编辑器显示一致 |
| 4 | 列号 | 1 | 同上,colNum从 1 开始计数 |
SyntaxParser-Result.txt是缩进式树形结构,每行代表一个 AST 节点:
PROGRAM FUNCTION TYPE_SPECIFIER: int ID: main PARAMETER_LIST: () COMPOUND_STMT RETURN_STMT EXPRESSION NUM: 0关键验证点:缩进层级 = AST 深度;:后的内容是节点属性(如ID: main);无:的行是节点类型(如COMPOUND_STMT)。如果RETURN_STMT下面没有EXPRESSION,说明parseReturnStmt()没调用parseExpression(),是逻辑缺陷。
5.3 自定义测试:如何安全地添加新关键字bool并验证全流程
想扩展 C- 支持bool类型?按以下顺序操作,每步都可验证:
- 词法层:在
LexicalAnalyzer.cpp的isKeyword()函数里keywordMap["bool"] = KEYWORD_BOOL;,并在scanToken()的case 'b'分支加else if (matchString("bool")) { ... }。 - 语法层:在
cminus.txt的 Keywords 行末尾加bool;在SyntaxParser.cpp的parseTypeSpecifier()函数里,if (token.type == KEYWORD_BOOL)分支返回BOOL_TYPE节点。 - 验证:新建
test_bool.txt,写bool flag = true;,运行./compiler test_bool.txt,检查LexicalAnalyzer-Result.txt是否有KEYWORD_BOOL和KEYWORD_TRUE(需同步加true/false关键字),SyntaxParser-Result.txt是否出现TYPE_SPECIFIER: bool和ID: flag。
从那以后我每次给学生讲词法分析,都强制他们用
test1.txt先跑通,再改一行代码,立刻用diff LexicalAnalyzer-Result.txt对比前后差异——Token 类型变了没?行号对不对?value字段是不是你预期的字符串?这种“改一行,验一行”的节奏,比写完 200 行再调试高效十倍。希望帮到你。
本文还有配套的精品资源,点击获取