☰
手写编译器:词法分析、递归下降语法分析与符号表课程设计全解析
2026/10/14 9:29:55 网站建设 项目流程

简介:吉林大学编译原理课程配套资料包,面向计算机专业学生与编译器设计入门者,覆盖词法分析、语法分析、语义分析及代码生成等核心环节,同时涵盖课堂实验要求与教学课件。资源共二十七个文件,包括Java源码、编译后class文件、多份实验报告与模板、SNL语言介绍、实验要求PPT及编译原理书稿PDF等,整体约四点八六兆字节,结构清晰,便于按模块检索与查阅。已有一千二百零七人学习下载,内容兼具理论与实战:Compiler工程源码可供逐行研读,分周实验报告记录从词法分析、语法分析到语义处理与代码生成的实现过程,配套模板与书稿则便于规范撰写、深入理解编译器内部工作机制,适合课程设计参考、期末复习、考研准备及自学提升,同时可为编译原理实验教学提供完整实例。

1. 拿到吉林大学编译原理设计代码和实验报告.zip:先弄明白里面装着什么

一个名为“吉林大学编译原理设计代码+实验报告.zip”的压缩包递到手里,大多数人此时的状态是:要么正在赶编译原理实验,要么想提前看看一个可运行的课程设计长什么样。解压后核心资产通常是两类:一份能编译运行的编译器代码,一份对着课程评分点写的实验报告。代码覆盖词法分析、语法分析、符号表和中间代码生成这条完整链路,报告则解释每一步为什么这么选、怎么测试、有什么坑。这篇笔记不为某个具体 zip 背书,只把这套方案背后的选型理由、可复现代码和最容易翻车的细节拆开讲。读完你能自己判断:这个方向值不值得做,哪些部分必须重写,哪些参数和边界坑会卡住大多数人。

2. 编译前端四段式:词法、语法、语义、中间代码怎么组合不返工

2.1 语言选型先定死:C++ 和 Java 的取舍决定你后面三天的工作量

解压后第一件事不是看代码,而是确认这份交付物用什么语言写的。课程设计通常不出三种选择:C++、Java、Python。选型标准不是谁写起来快,而是你答辩时能不能把符号表和作用域结构讲清楚。

C++ 的优势是贴近编译原理教材伪代码,struct 定义 Token、枚举定义类型,老师环境里 g++ 基本是标配,性能也可控。缺点是字符串和缓冲区要自己管,一旦按字节读取输入,中文注释直接变乱码。Java 的优势是 HashMap 现成,符号表不用自己造轮子,Vector 管理作用域栈也直观;缺点是代码行数偏多,报告贴代码时显得冗长。Python 写词法一天能出活,但你要在报告里解释为什么符号表用 dict 而不是自己实现哈希表,这反而暴露出原理层面积累不够。

我一般倾向 C++,但不是因为 C++ 比 Java 高级,而是实验报告里“数据结构设计”一节需要画图:struct Token 加 enum TokenType 十行代码就能画清楚,换成 Java 的 class 和 getter/setter,图会大两倍。如果你更熟 Java 就坚持 Java,不要为了所谓主流临时换语言。课程设计判分核心是“你自己能讲清每行代码在做什么”,语言本身不纳入评分。选定语言后,main 函数的三件事要固定:读入源文件、建 Lexer、建 Parser,最后把 Token 流和四元式写到一个输出文件里,这条主流程从第一天就定好,后面所有调试都围绕它。

2.2 语法分析策略:递归下降比 LR 生成器更适合课程设计

拿到了代码先看语法分析器怎么实现。如果看到几十个互相调用的函数,每个函数名对应一个非终结符,这是手写递归下降;如果看到 YACC/Bison 或 CUP 的 .y/.cup 文件,那是借助生成器做 LALR。两个方案都常见,但翻车概率完全不一样。

递归下降的原理最朴素:每个非终结符写一个解析函数,函数体按产生式右部顺序调用其他解析函数,终结符直接与当前 Token 比对。这种写法的天然优势有三个:出错时调用栈就是回溯路径,在每个函数入口打印“enter parseExpr”,哪层断了肉眼就看得出来;优先级通过函数嵌套层级表达,加一个运算符只是加一个函数;代码与文法一一对应,报告里放一张对照表就够。缺点是需要先把左递归文法改写成 EBNF,但表达式文法的改写工作量不大。

LR 生成器能处理更广的文法,但冲突报告对初学者是黑匣子——报错只告诉你这里不确定是移进还是规约,然后呢?你改优先级声明,可能修了 A 又坏了 B,最后靠玄学调通。答辩时老师最喜欢问“这个 state 是怎么生成的”,多数人答不上来。所以课程设计阶段,表达式和语句控制流都用手写递归下降最稳,生成器可以放在报告“进阶思考”里提一句,作为对比论证选型理由,反而加分。

中间代码生成不必一上来做完整汇编。常见做法是输出三地址码(四元式),格式为 (op, arg1, arg2, result),例如 (ADD, a, b, t1)。每行都能对应回源码一小段,验证阶段拿用例人工比对就能确认正确性。有的实现先建 AST 再遍历生成,结构清晰但代码量大;简化做法是语法分析的同时输出四元式,省掉 AST 构建与遍历。报告里必须说明你选了哪种以及为什么——因为答辩问题常从这里出。

2.3 符号表与作用域:十个课程设计九个在这里出问题

符号表的实现水平,往往决定这份代码能跑多复杂的输入。最简单的符号表是哈希表,key 是名字,value 是类型和值。问题是课程设计必然涉及作用域:循环体、if 块、函数体都会引入新作用域。平铺哈希表里,内层变量和外层变量重名时,内层定义会覆盖外层条目;作用域退出后外层变量找不回来,这是必挂的 bug。

常见做法是作用域栈:vector 或链表,每个元素是一个 unordered_map。进入作用域时 push 空表,声明变量写入栈顶;查找时从栈顶往下逐个表查,查到就返回;退出时 pop 顶表。“从顶往下查”的顺序就是词法作用域的定义,代码只有十几行,但报告里必须画出来,这是符号表设计的得分点。

另一个隐蔽坑是往符号表里塞临时变量。生成三地址码会造 t1、t2 这类临时名,如果把它们也写入符号表,报告统计变量数量时会多出一堆垃圾,查错时分不清哪些是用户变量哪些是内部产物。我的做法是临时名不落符号表,只在四元式里出现。类型检查方面,课程设计最低要求是赋值语句两端类型一致、变量使用前必须已声明、加减乘除的操作数类型为 int 或 float。函数参数检查可以不做,但报告里要写“选择不实现的理由”,而不是漏写。

3. 手写词法分析器:状态机驱动的 Token 扫描与错误恢复代码

3.1 一个最小可用的 Token 定义与扫描器主循环

词法分析器读入字符流,输出 Token 流。Token 定义我一般这样写:

enum TokenType { TOK_ID, TOK_NUM, TOK_KEYWORD, TOK_PLUS, TOK_MINUS, TOK_STAR, TOK_SLASH, TOK_LPAREN, TOK_RPAREN, TOK_LBRACE, TOK_RBRACE, TOK_SEMI, TOK_ASSIGN, TOK_EOF, TOK_ERROR }; struct Token { TokenType type; std::string text; // 实际词素,例如 "count" 或 "3.14" int line; int col; };

Token 结构里最重要的是 line 和 col。语法分析阶段报“第 3 行缺少分号”时,靠的就是这两个字段。很多人图省事只存 text,最后报错只有“缺少分号”四个字,老师没法定位,这属于报告的减分点。text 字段保存原始词素,后续查符号表、数字转 double 都靠它;关键字单独列一个枚举值,但 text 仍然保留原样,方便打印调试。

扫描器主循环长这样,它处理四类输入:空白与注释、标识符与关键字、数字、运算符与定界符:

class Lexer { public: Lexer(const std::string& src) : src(src), pos(0), line(1), col(1) {} Token getNextToken() { skipWhitespaceAndComments(); // 跳过空格、换行和 // 注释 if (pos >= src.size()) return makeToken(TOK_EOF, ""); const int startLine = line, startCol = col; char c = src[pos]; if (isalpha(c) || c == '_') return readIdOrKeyword(startLine, startCol); if (isdigit(c)) return readNumber(startLine, startCol); ++pos; ++col; switch (c) { case '+': return makeToken(TOK_PLUS, "+", startLine, startCol); case '-': return makeToken(TOK_MINUS, "-", startLine, startCol); case '*': return makeToken(TOK_STAR, "*", startLine, startCol); case '/': return makeToken(TOK_SLASH, "/", startLine, startCol); case '(': return makeToken(TOK_LPAREN, "(", startLine, startCol); case ')': return makeToken(TOK_RPAREN, ")", startLine, startCol); case '{': return makeToken(TOK_LBRACE, "{", startLine, startCol); case '}': return makeToken(TOK_RBRACE, "}", startLine, startCol); case ';': return makeToken(TOK_SEMI, ";", startLine, startCol); case '=': return makeToken(TOK_ASSIGN, "=", startLine, startCol); default: logError("无法识别的字符 '" + std::string(1, c) + "'", startLine, startCol); return getNextToken(); // 跳过非法字符后继续扫描 } } private: Token readIdOrKeyword(int line, int col) { std::string id; while (pos < src.size() && (isalnum(src[pos]) || src[pos] == '_')) { id.push_back(src[pos]); ++pos; ++col; } // 先按标识符读完,再查关键字表 if (keywordTable.count(id)) return makeToken(TOK_KEYWORD, id, line, col); return makeToken(TOK_ID, id, line, col); } Token readNumber(int line, int col) { std::string num; while (pos < src.size() && isdigit(src[pos])) { num.push_back(src[pos]); ++pos; ++col; } // 小数点后必须跟数字,否则点留给运算符处理 if (pos < src.size() && src[pos] == '.' && pos + 1 < src.size() && isdigit(src[pos + 1])) { num.push_back('.'); ++pos; ++col; while (pos < src.size() && isdigit(src[pos])) { num.push_back(src[pos]); ++pos; ++col; } } return makeToken(TOK_NUM, num, line, col); } void skipWhitespaceAndComments() { while (pos < src.size()) { char c = src[pos]; if (c == ' ' || c == '\t') { ++pos; ++col; } else if (c == '\n') { ++pos; ++line; col = 1; } else if (c == '/' && pos + 1 < src.size() && src[pos + 1] == '/') { while (pos < src.size() && src[pos] != '\n') ++pos; } else break; } } Token makeToken(TokenType type, const std::string& text, int line, int col) { return Token{type, text, line, col}; } std::string src; size_t pos; int line, col; static const std::set<std::string> keywordTable; };

这段代码里有三个关键逻辑值得你抄的时候多想一层。

第一个是关键字的识别方式:先把字符流读成完整标识符,再去 keywordTable 查。如果反过来——先匹配关键字再当标识符读——代码会变成一串 if-else,新增关键字要改函数体,而且“intx”会被误判成关键字拼标识符,属于常见词法错误。keywordTable 本身用 std::set 初始化,内容是 int、float、if、else、while 这类保留字。

第二个是数字边界:我检查小数点后第一个字符必须是数字才继续读。这样输入“a = 1.2.3;”时,词法层读到 1.2 就停下,下一个点是运算符,语法层报“意外的点”,错误定位比把 1.2.3 整个读成一个非法数字清晰得多。报告里专门写这一条,说明“词法层不吞掉歧义字符”的设计原则,是加分项。

第三个是非法字符的错误恢复:默认分支记一条错误日志,然后递归调用 getNextToken 继续扫。这叫 panic mode 的简化版——跳过非法字符、继续后面的词法分析。另一种做法是返回 TOK_ERROR 让语法层处理,后者信息更准但语法层要多写分支。课程设计我建议用跳过加日志,因为语法层的错误处理已经够头大了。

3.2 换行计数与注释处理的三个容易看漏的细节

词法层最容易出问题的地方不是主逻辑,而是辅助细节。第一个是换行计数的时机:必须在跳过 '\n' 的那一刻同步把 col 重置为 1,顺序写反,后续 Token 的行号或列号整体偏移,语法层报错位置全部错位。第二个是注释边界:只支持 // 不支持 /*/ 是可以接受的,但要在报告里写明支持范围;如果输入里出现 “/” 后跟 “”,词法器应把它当成除号加乘号,而不是尝试匹配块注释。第三个是字符串字面量:大部分课程设计要求里根本没有字符串类型,不要让词法器去处理双引号,遇到就报“不支持的语法”,这比硬拼一个处理逻辑更稳。

这三条不能独立于主流程,你得把它们写进 skipWhitespaceAndComments 和主扫描分支里反复测。我的测试办法是准备一个 dirty.txt,故意混入全角空格、Tab、空行、连续注释行和一个非法字符 @,看 Token 流的行号列号是否对得上。这个 dirty.txt 本身可以作为实验报告测试用例的一部分,边角料工作量但老师会看到。

4. 递归下降语法分析:消除左递归后如何把优先级写进代码

4.1 从文法到函数:EBNF 大括号写法直接映射 while 循环

写递归下降前,先把文法整理成不含左递归的形式。以表达式为例,原始左递归文法:

expr -> expr + term | expr - term | term term -> term * factor | term / factor | factor factor -> ID | NUM | '(' expr ')'

直接按这个写 parseExpr 会死循环——函数第一次调用就递归调用自己。常规做法是改写成 EBNF 形式,用花括号表示“零个或多个”:

expr -> term { ('+' | '-') term } term -> factor { ('*' | '/') factor } factor -> ID | NUM | '(' expr ')'

EBNF 里的花括号对应代码里的 while 循环,这比“先改写成右递归再用递归”更接近人的思考。语法分析函数的外壳一般是:

class Parser { public: explicit Parser(Lexer& lexer) : lexer(lexer), cur(lexer.getNextToken()) {} bool parseProgram() { while (cur.type != TOK_EOF) { if (!parseStmt()) return false; } return true; } private: bool parseStmt() { // 只处理赋值语句:ID = expr ; 这是课程设计最常见的最小集 if (cur.type == TOK_ID && peekNext().type == TOK_ASSIGN) { advance(); // 吃掉 ID advance(); // 吃掉 '=' if (!parseExpr()) return false; if (cur.type == TOK_SEMI) { advance(); return true; } error("缺少分号"); return false; } error("语句必须以标识符开始"); return false; } // 其余函数见 4.2 };

这里有个细节:parseStmt 里做“提前看两个 Token”的判断,是为了区分赋值语句和表达式语句。如果连表达式语句都不要,文法可以再简化成只有赋值语句,但报告里要写明“本设计只支持赋值表达式”作为范围约定。课程设计验收常给一段含加减乘除和括号的赋值代码,这个范围够用。peekNext() 的实现要小心:它不能真正消费 Token,常见做法是预先缓存下一个 Token,或者复制当前指针位置再调 getNextToken 后恢复。

4.2 优先级靠函数嵌套实现:parseExpr 到 parsePrimary 的分层代码

优先级不需要任何特殊标记,靠函数调用层级关系。加减法在最高层,乘除法在中间层,括号和原子表达式在最底层:

bool parseExpr() { return parseAdditive(); } bool parseAdditive() { if (!parseMultiplicative()) return false; while (cur.type == TOK_PLUS || cur.type == TOK_MINUS) { advance(); if (!parseMultiplicative()) return false; } return true; } bool parseMultiplicative() { if (!parsePrimary()) return false; while (cur.type == TOK_STAR || cur.type == TOK_SLASH) { advance(); if (!parsePrimary()) return false; } return true; } bool parsePrimary() { if (cur.type == TOK_NUM || cur.type == TOK_ID) { advance(); return true; } if (cur.type == TOK_LPAREN) { advance(); if (!parseExpr()) return false; if (cur.type != TOK_RPAREN) { error("缺少右括号"); return false; } advance(); return true; } error("非法的表达式起始 Token: " + cur.text); return false; }

这段代码把“a + b * c”解析成 a 加 (b 乘 c) 的原因就在函数层级:parseAdditive 先调 parseMultiplicative,后者把 b * c 整体消费掉,回到 parseAdditive 时当前 Token 已经是语句结束符。如果把乘除放在高层、加减放在底层,得到的就是优先计算加减,文法语义整个反了。

结合性也不需要额外标记:while 循环从左到右处理同级运算符,天然得到左结合。“a - b - c”被解析成 (a - b) - c,符合多数语言语义。右结合运算符(如链式赋值)才需要改成递归调用而不是循环,课程设计不做链式赋值就不必考虑。

括号的实现要领在 parsePrimary:遇到 '(' 时递归调用 parseExpr,之后必须检查右括号。经典死循环场景是输入“( a + b )”时 parsePrimary 把 '(' 消费掉后,里面 parseExpr 正常返回,但如果漏了检查 TOK_RPAREN,当前 Token 停在 ')',外层函数可能把它当成乘法或结束符,报错信息完全对不上位置。我见过很多次这种翻车,最后报告里写“注意右括号检查”一行带过,实际调了半天。

4.3 语义动作:在正确位置输出四元式而不是等整棵树建完

语法分析到这里还没有真正生成任何东西。为了让实验报告里有拿得出手的验证结果,我习惯在递归下降的同时做语义动作——直接在 parsePrimary 返回时输出四元式,而不是先建 AST 再遍历。

一个简化的做法是:parseMultiplicative 里每次循环遇到 * 或 / 时,调 getTemp() 拿一个新临时名,输出一条 (MUL, leftText, rightText, tN) 或 (DIV, ...),然后把 tN 当作本层“当前结果”继续向上传。加减同理。每个 parseExpr 函数结束时返回一个“当前值表达式”的字符串。这样“1 + 2 * 3;”的输出就是:

(MUL, 2, 3, t1) (ADD, 1, t1, t2)

这个输出是实验报告验证章节最有力的截图素材。语义动作的坑在于临时名计数器必须全局唯一——如果在 parseExpr 里用局部变量存临时名,多层递归会把同一个 t1 覆盖掉。解决办法是把临时名计数器和当前层的结果字符串都放进 Parser 的成员变量或显式参数里,不要用局部变量裸传。

5. 避坑清单:课程设计最常见的 5 个翻车点和排查路径

5.1 四个阅读源码时最易踩中的隐蔽坑

现象一:解析带多层嵌套括号的表达式时程序直接崩溃或卡死。原因通常是 parsePrimary 里右括号检查缺失,递归无法返回最终栈溢出;表现为编译能过但运行瞬间崩溃。解决:按 4.2 节写法,在 '(' 分支后强制加 TOK_RPAREN 检查,并加一行调试输出打印当前 Token 文本,崩溃前能看到最后一个被消费的字符。打开栈回溯定位也快,但关键还是先修检查逻辑。

现象二:源码里出现中文或全角符号时 Token 流乱码、行号偏移。原因:词法器按单字节读入,中文字符被拆成两个字节,第一个字节可能正好落进 isalpha 或数字分支,产生莫名标识符。解决:在默认分支增加“字符编码大于 127 直接报错”的判断,把中文当非法字符处理;实验报告写明“本设计仅支持 ASCII 源码”,这不是缺陷而是范围约定,老师能接受。

现象三:符号表查找变量名错乱,内层作用域退出后外层同名变量找不回来。原因:符号表是平铺哈希表,没有作用域栈。解决:改用 vector<unordered_map<string, Symbol>>,查找时从结尾往前遍历;进入复合语句 push 一层,退出时 pop。这段代码十几行,却是报告里唯一能画出“作用域链”图的地方,别省。

现象四:语法分析报错停在一个错误就退出,老师给一个含三处错误的测试程序只能看到第一处。原因:没有做错误恢复。解决:在 parseStmt 失败时写一个 synchronize(),跳过后续 Token 直到遇到分号,然后再尝试解析下一条语句。加分技巧是 synchronize 之前保留已解析出的四元式,不要清空,这样部分结果也能打印。

5.2 交付物层面的坑:解压、编译环境与报告版本

现象五:从 zip 解压后源码中文乱码,或实验报告 docx 打开是空的。原因:Windows 下压缩包文件名编码与 Linux 解压工具不一致;docx 可能是 WPS 保存的格式兼容问题。解决:交付前统一把文件名改成纯 ASCII——main.cpp、lexer.h、report.docx 这种,避免解压环境差异;报告另存一份 PDF 备份,有的老师用手机看 docx 会排版错乱,PDF 不会。

编译环境坑更隐蔽:代码里用了 C++17 的 std::optional,老师机器上的 g++ 4.8 编不过。解决:写报告时在“环境说明”一节明确列出“g++ (Ubuntu 9.4.0)”及编译命令 g++ -std=c++11 main.cpp -o compiler,同时把代码控制在 C++11 特性以内。交付前做最后一次干净编译:删掉 build 目录,从 zip 重新解压,在另一台没有你本地环境的机器上编译一遍。这个步骤能过滤掉 90% 的“在我电脑上明明能跑”。

如果你拿到的是别人的代码,还有一个避雷动作:grep 一下代码里有没有个人信息、旧项目路径或与课程无关的注释——比如从开源项目拷来的注释头。实验报告里一旦被看出代码来源和报告描述不匹配,风险远比你想象的大。常见做法是用你自己的命名风格重写核心模块,保留思路但不逐行照抄。代码文件组织上,我一般拆成五个文件:main.cpp、lexer.h、parser.h、symtab.h、ir.h,功能边界清楚,报告的文件清单表也好看。

6. 让实验报告替你说话:从 Token 截图到四元式的验证闭环

6.1 测试用例矩阵:不只测“能跑”,要测边界和错误路径

写完代码后的第一个动作,是建一个测试目录,里面至少放五个输入文件:normal.txt 是正常赋值表达式;priority.txt 专门测“a + b * c - d / e”;nested.txt 测多层括号;empty.txt 是空程序,验证词法器能不能立刻返回 EOF 而不崩溃;error.txt 含缺失分号和未声明变量,验证语法层错误恢复是否按预期工作。

每个文件跑完,把 Token 流、四元式输出、报错信息三个结果存盘。报告里放一张测试用例表,列五列:用例名、输入摘要、预期行为、实际行为、状态。这张表是老师判断工作量最直接的证据。

用例文件输入要点预期行为常见翻车点
normal.txta = 1 + 2;输出 ADD 四元式分号消费后位置不对
priority.txta = b + c * d;MUL 先于 ADD函数层级写反
nested.txta = (1 + (2 + 3)) * 4;括号递归正常返回右括号检查缺失
empty.txt空文件直接返回 EOF空指针解引用
error.txtb = ;报错并恢复词法吞掉非法 Token

6.2 报告结构里的三张图和三张表

实验报告不需要花哨,但要有三个可视化元素:词法分析的 DFA 状态图、符号表作用域链图、语法产生式与函数对照表。前两张画得再粗糙也比纯文字好——它们直接证明你理解了原理而不只是照抄代码。函数对照表可以简化为两列,左列产生式右列函数名,五到八行就能看明白。

另一条血泪经验是:报告里写“遇到的问题与解决”时,不要只写“解决了 bug”。按现象、原因、解决三步写,和正文避坑清单同构。你把四个真实 bug 按这个格式写上去,比十页原理描述有价值。最后在“运行说明”里写清楚编译命令一行和测试命令一行,老师复现不需要猜测。

我自己的习惯是交付前把报告重新读一遍,每看到一个模块描述,就打开对应源码确认它真的是这么实现的。曾经有一次报告里写了“支持链式赋值”,代码根本没实现,答辩演示时被当场打脸,那个教训让我把“报告与代码逐句核对”固化成流程。这个方向真正值钱的地方不在 zip 本身,而在于你能否把它的思路改造成自己能讲清楚的结构。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询