简介这份资源是面向高校计算机专业学生的编译原理课程设计项目聚焦C-语言子集的词法分析与语法分析器实现适合正在学习编译原理、需要完成课程设计或希望动手理解编译器前端流程的学习者。压缩包共22个文件约25KB以cpp与h源码文件为主辅以txt测试用例与结果输出、json编辑器配置及md说明文档源码目录中可看到词法扫描、记号定义、语法解析等模块的分离实现便于对照阅读与二次修改。目前已有125人学习下载。通过该项目读者可以完整走通从字符流到记号流、再到语法结构输出的分析链路观察词法分析结果与语法分析结果文件并尝试增加关键字或运算符来验证分析器的行为变化从而加深对扫描器与解析器协作方式的理解为后续学习编译器后端或编程语言实现打下实践基础。1. 从一份 C- 语言课设压缩包说起词法分析器和语法分析器到底怎么落地很多人学编译原理课本上状态转换图、FIRST/FOLLOW 集、LL(1) 分析表背得滚瓜烂熟一到课程设计要交一个能跑的词法分析器和语法分析器就卡壳。这份「编译原理课程设计自制 C- 语言词法分析和语法分析器」压缩包就是一份可以直接编译运行的 C 实现覆盖了从字符扫描、Token 定义、词法分析到语法分析结果输出的完整链路。它面向的是正在做编译原理实验、需要交课设或者想亲手拆一遍编译器前端的学生和初级工程师。解压后你会看到src目录下CharScanner.cpp、LexicalAnalyzer.cpp、SyntaxParser.cpp等源文件以及test1.txt、test2.txt、cminus.txt这些测试输入和语法规则文件还有两份结果输出LexicalAnalyzer-Result.txt和SyntaxParser-Result.txt可以直接对照验证。下面我按「这份代码怎么组织、怎么编译跑通、参数怎么改、哪里容易翻车」的顺序拆一遍。2. 源码结构与编译链路从 CharScanner 到 SyntaxParser 的数据流2.1 四个核心模块的职责划分拿到一个 C 课设项目先别急着改代码把文件依赖关系理清楚能省掉后面大量返工。这份项目的src目录里头文件和实现文件是成对出现的职责边界相当清晰文件职责关键类型/函数CharScanner.h/.cpp字符级扫描维护读取位置、行号、回退字符读取、peek、advanceToken.h/.cpp定义 Token 种类枚举与 Token 结构体TokenType、Token 值/类型/行号LexicalAnalyzer.h/.cpp调用 CharScanner产出 Token 序列主循环、关键字匹配、数字/标识符识别SyntaxParser.h/.cpp消费 Token 序列按文法递归下降各非终结符对应的 parse 函数Test.h/.cpp测试入口串联词法和语法分析main 逻辑、文件读写这个分层是教科书式的做法CharScanner只管字符LexicalAnalyzer只管把字符拼成 TokenSyntaxParser只管 Token 序列是否符合文法。常见做法是把这三层写在一个文件里改一处牵动全身这份代码拆开之后你要加关键字只需要动LexicalAnalyzer要改文法只需要动SyntaxParser互不干扰。Token.h里通常会有一个枚举列出所有 Token 类型比如INT、IF、ELSE、WHILE、RETURN、ID、NUM、PLUS、MINUS、ASSIGN、LPAREN、RPAREN、LBRACE、RBRACE、SEMI、EOF等。这个枚举是词法分析和语法分析之间的契约两边都依赖它所以改动时要同步。2.2 编译与运行VS Code 配置和手动编译两条路项目里带了.vscode目录包含tasks.json、launch.json、c_cpp_properties.json、settings.json说明作者是在 VS Code 里开发的。如果你也用 VS Code直接打开文件夹tasks.json里一般会定义 build 任务。但更稳妥的方式是手动用 g 编译避免配置文件路径和你本机不一致导致找不到编译器。先确认你的环境有 gg --version然后在项目根目录执行编译把src下所有 cpp 一起编g -stdc11 -g -o compiler \ src/CharScanner.cpp \ src/Token.cpp \ src/LexicalAnalyzer.cpp \ src/SyntaxParser.cpp \ src/Test.cpp \ -I src这里-stdc11是因为课设代码常用到enum class、auto、范围 for 等特性-I src让编译器能找到同目录的头文件-g保留调试信息方便后面用 gdb 或 VS Code 断点排查。编译成功后当前目录会出现compiler可执行文件。运行时要确认输入文件路径。项目里test1.txt、test2.txt在根目录而Test.cpp里的文件读取路径可能是相对路径也可能是硬编码的绝对路径。先看一眼Test.cpp的 main 函数// Test.cpp 中典型的入口逻辑 int main() { // 常见写法硬编码输入输出文件名 LexicalAnalyzer lexer(test1.txt); lexer.run(); lexer.dumpResult(LexicalAnalyzer-Result.txt); SyntaxParser parser(LexicalAnalyzer-Result.txt); parser.parse(); parser.dumpResult(SyntaxParser-Result.txt); return 0; }如果路径是硬编码的你直接./compiler运行即可如果它读的是命令行参数那就./compiler test1.txt。跑完之后对比生成的LexicalAnalyzer-Result.txt和项目自带的同名文件如果内容一致说明你的编译环境和作者一致后面改代码就有基准了。提示如果编译报「找不到 Token.h」检查-I后面的路径是不是src以及头文件里 include 写的是Token.h还是../src/Token.h两者对应的编译命令不同。2.3 词法分析主循环关键字表和最长匹配LexicalAnalyzer.cpp的核心是一个循环不断从CharScanner取字符跳过空白和注释然后根据首字符判断这是标识符、数字还是运算符。标识符和关键字的区分靠一张关键字表常见实现是一个std::mapstd::string, TokenType或者一串 if-else 比较。// LexicalAnalyzer.cpp 中识别标识符/关键字的典型逻辑 Token LexicalAnalyzer::nextToken() { skipWhitespaceAndComments(); char c scanner.peek(); if (isalpha(c) || c _) { std::string lexeme; while (isalnum(scanner.peek()) || scanner.peek() _) { lexeme scanner.advance(); } // 关键字表查找命中则返回对应关键字类型否则是普通标识符 auto it keywords.find(lexeme); if (it ! keywords.end()) { return Token(it-second, lexeme, scanner.line()); } return Token(TokenType::ID, lexeme, scanner.line()); } // 数字、运算符、分隔符的处理省略 }这段逻辑的关键参数是关键字表keywords的内容。如果你要新增一个关键字比如for就在初始化关键字表的地方加一行keywords[for] TokenType::FOR;同时在Token.h的枚举里加FOR在SyntaxParser里加对应的文法处理。三处缺一不可漏掉任何一处都会导致要么词法识别成 ID要么语法分析遇到未知 Token 直接报错。数字识别要注意前导零和多位整数。课设级别的 C- 语言一般只要求整数不要求浮点所以遇到.就当运算符处理。如果你的测试用例里有007这种确认代码是用std::stoi还是自己累加前者会正常转成 7后者取决于实现。3. 语法分析器的递归下降实现文法、FIRST 集与错误恢复3.1 C- 语言文法与 cminus.txt 的对应关系cminus.txt这个文件大概率是文法规则的定义可能是 BNF 形式也可能是给程序读的简化格式。C- 语言C-minus是编译原理教材里常用的教学语言典型文法包括program - declaration-list declaration-list - declaration declaration-list | ε declaration - var-declaration | fun-declaration var-declaration - type-specifier ID ; | type-specifier ID [ NUM ] ; fun-declaration - type-specifier ID ( params ) compound-stmt params - param-list | void compound-stmt - { local-declarations statement-list } statement - expression-stmt | compound-stmt | selection-stmt | iteration-stmt | return-stmt递归下降要求每个非终结符对应一个函数函数内部根据当前 Token 决定走哪条产生式。判断依据就是 FIRST 集比如declaration遇到int或void就进var-declaration或fun-declaration遇到{就进compound-stmt。// SyntaxParser.cpp 中声明解析的典型分支 void SyntaxParser::parseDeclaration() { Token t current(); if (t.type TokenType::INT || t.type TokenType::VOID) { // 需要向前看一个 Token 区分变量声明和函数声明 Token next lookahead(1); if (next.type TokenType::ID) { Token afterId lookahead(2); if (afterId.type TokenType::LPAREN) { parseFunDeclaration(); } else { parseVarDeclaration(); } } } else { error(declaration 期望 int 或 void); } }这里用到了lookahead(k)也就是向前看 k 个 Token。递归下降处理 C- 语言时var-declaration和fun-declaration都以type-specifier ID开头必须看到 ID 后面的符号是(还是;或[才能区分所以至少需要 2 个 Token 的前瞻。如果你的SyntaxParser只维护了一个当前 Token就需要在 Token 流上做缓冲常见做法是用std::vectorToken一次性读入所有 Token然后用下标索引。3.2 语法分析结果输出与 AST 构建SyntaxParser-Result.txt里展示的可能是缩进的语法树也可能是每条产生式的匹配轨迹。如果是树形输出说明SyntaxParser在解析过程中构建了 AST 节点如果只是产生式序列那就是纯识别器不建树。要判断是哪种打开SyntaxParser-Result.txt看格式。如果是类似program declaration-list declaration fun-declaration int main ( ) compound-stmt { }那就是建了树每个节点有子节点。如果是parseDeclaration - fun-declaration parseFunDeclaration - type-specifier ID ( params ) compound-stmt那就是轨迹输出。两种做法对课设都合格但建树的做法更容易扩展成后续的语义分析或代码生成。如果你要自己加一个 AST 节点类型比如给if-else加一个IfStmt节点需要在SyntaxParser.h里定义节点结构在parseSelectionStmt里创建节点并挂到父节点上最后在输出函数里加对应的打印分支。改动量不大但要注意节点内存管理课设级别用裸指针加手动 delete 或者用std::unique_ptr都行后者更省心。3.3 错误恢复遇到非法 Token 时怎么办递归下降的弱点是一旦遇到语法错误如果不做恢复整个解析就中断了。课设级别的常见做法是打印错误信息后跳过当前 Token继续解析下一个声明或语句这样一份测试文件里的多个错误能一次性报出来。// 简单的错误恢复报告后跳到下一个分号或右花括号 void SyntaxParser::error(const std::string msg) { std::cerr 语法错误 [行 current().line ]: msg std::endl; // 跳过 Token 直到遇到分号、右花括号或文件结束 while (current().type ! TokenType::SEMI current().type ! TokenType::RBRACE current().type ! TokenType::EOF_TOKEN) { advance(); } if (current().type TokenType::SEMI) advance(); }这种恢复策略不完美可能把合法代码也跳过去但对于课设演示足够。如果你想让错误报告更精确可以在每个 parse 函数入口记录当前 Token 的行号和类型出错时把期望的 Token 集合和实际 Token 都打出来这样调试时一眼能看出是文法写错了还是测试用例写错了。4. 避坑与排查编译、路径、Token 枚举和文法冲突的常见翻车点4.1 编译报「undefined reference」——链接时漏了 cpp现象编译命令只写了g -o compiler src/Test.cpp报一堆undefined reference to LexicalAnalyzer::...。原因C 编译分编译和链接两步只编Test.cpp的话其他 cpp 里的函数定义没有参与链接。解决把所有用到的 cpp 都列在 g 命令里或者用通配符g -stdc11 -o compiler src/*.cpp -I src。如果src下有多个main函数比如每个模块都有测试 main通配符会报重复定义这时要手动排除。4.2 运行后结果文件为空——输入路径不对现象./compiler跑完没报错但LexicalAnalyzer-Result.txt是空的或者只有一行。原因Test.cpp里读的test1.txt是相对路径而你的工作目录不是项目根目录导致文件打开失败但代码没检查ifstream.is_open()。解决在打开文件后加if (!fin.is_open()) { std::cerr 无法打开输入文件; return 1; }然后确认运行时的当前目录。用pwd看一下或者把输入文件路径改成绝对路径。4.3 新增关键字后语法分析报「未知 Token」现象在LexicalAnalyzer里加了for关键字词法分析结果里for被识别成FOR类型但语法分析一遇到FOR就报错。原因Token.h的枚举加了FORLexicalAnalyzer的关键字表也加了但SyntaxParser的parseStatement里没有处理FOR的分支默认走到 error。解决在parseStatement的 switch 或 if-else 链里加case TokenType::FOR: parseForStmt(); break;并实现parseForStmt。三处同步改缺一不可。4.4 递归下降栈溢出——左递归没消除现象程序一跑就崩溃报stack overflow或者直接段错误。原因文法里存在左递归比如expression - expression term递归下降会无限递归。解决把左递归改写成右递归或循环。常见做法是expression - term { (|-) term }在代码里用 while 循环处理运算符而不是递归调用自身。检查cminus.txt里的表达式文法如果有直接左递归先消除再写代码。4.5 结果文件和自带的不一致——行号或空白处理差异现象自己跑出来的LexicalAnalyzer-Result.txt和项目自带的对比Token 序列一样但行号差 1或者多了/少了某些空白 Token。原因CharScanner对换行符的计数时机不同或者词法分析器把注释也当 Token 输出了。解决先确认自带结果文件是不是用同一份代码生成的。如果是逐行 diff定位第一个不一致的 Token然后看CharScanner里line变量是在advance时递增还是在peek到\n时递增。注释处理通常是在skipWhitespaceAndComments里跳过不产出 Token如果你的实现产出了注释 Token结果自然不同。5. 进阶玩法用测试用例反推文法边界再动手改一个关键字课设代码跑通只是起点真正能帮你理解编译原理的是拿它做实验。我一般会先拿test1.txt和test2.txt做基准然后自己写一个test3.txt故意包含边界情况嵌套三层的if-else、空语句块{}、连续多个分号;;;、标识符超长、数字前导零。跑一遍看词法分析结果里 Token 类型对不对再看语法分析是报错还是正常接受。如果报错先别改代码先翻cminus.txt看文法里有没有定义这种情况。比如空语句块{}如果文法里compound-stmt - { local-declarations statement-list }且statement-list - ε那空块是合法的如果statement-list不允许为空那报错就是文法本身的问题不是代码 bug。这一步能帮你区分「实现错误」和「文法限制」。确认文法边界之后动手加一个关键字是最有效的练习。以加for为例完整改动清单如下文件改动Token.h枚举加FORLexicalAnalyzer.cpp关键字表加keywords[for] TokenType::FOR;SyntaxParser.h声明void parseForStmt();SyntaxParser.cppparseStatement加FOR分支实现parseForStmtcminus.txt文法加for-stmt - for ( expression ; expression ; expression ) statement改完重新编译写一个包含for (i 0; i 10; i i 1) { x x 1; }的测试文件跑通词法和语法分析再对比结果文件里的 Token 序列和语法树结构。如果语法树里for-stmt节点下面挂了三个表达式和一个语句块说明改动生效。验证方法还有一个故意写一个不合法的for比如少一个分号for (i 0 i 10; i i 1)看错误恢复能不能报出行号并继续解析后面的语句。如果报错后直接退出说明错误恢复没覆盖到parseForStmt内部需要在循环条件解析里加同步点。从那以后我每次拿到一份课设代码都强制先跑通自带测试用例、diff 结果文件、再写一个边界用例这三步走完才动手改代码。希望帮到你。本文还有配套的精品资源点击获取
