简介:编译器前端通常由词法分析与语法分析构成:词法分析器把字符流切分为带类别的Token,语法分析器依据文法判断符号串是否合法,递归下降则把产生式直接映射为递归子程序。理解这条链路,有助于掌握编译前端的基本原理,也能提升课程实验中的源码调试、输入约定检查和错误定位能力。在PL/0语言、C++源码复现及编译原理课程实验等场景中,常见问题往往不在算法本身,而在结束符、全局游标、非法单词检测和边界处理上。围绕一份包含词法分析器与递归下降语法分析器两份源码的实验报告,可拆解实现结构、运行约定与典型避坑点,为实验验收和二次开发提供可操作的参考。
1. 实验验收前一晚:这两份代码到底能跑出什么
课程实验周最常见的画面是这样的:把词法分析器的源码粘进 IDE,改完几个报错能跑了,结果输出是一串没有换行的单词;换到语法分析器,把While改成小写再编译,输入测试串却永远提示“分析失败”。翻回实验报告才发现,坑基本都埋在输入约定和全局状态上,跟算法本身关系不大。
这份资源就是一份编译原理课程实验报告,正文里带两份完整的 C++ 源码:一份是 PL/0 语言的词法分析器,把源程序切成(单词,类别)二元组;另一份是递归子程序语法分析器,按给定文法判断输入串是不是合法句子。报告里还附了测试源文件pl0.txt和运行截图。它适合两类人:正在做编译原理实验、需要对照检查自己实现的人,以及想从零摸一遍编译器前端长什么样的开发者。代码是教学写法,不算工程化,但结构清晰,正好拿来拆。
2. 资源构成与运行环境:两份 C++ 源码怎么落地
整套资源是“一份报告 + 两个独立程序”的结构:词法分析器和语法分析器互不调用,各自main入口、各自编译成可执行文件,分别对应实验 2 和实验 4。报告承担的是说明和结果记录,真正能复现的是那两段源码。理解这一点很重要,因为它决定了你不需要把两份代码拼成一个工程,而是可以拆开单独运行、单独改。
两个程序都跑在 Windows PC 上,用 C++ 编写,依赖只有标准库和 C 风格文件 IO。词法分析器用到<iostream>、<fstream>、<sstream>、<string>、<vector>、<algorithm>,语法分析器用到<stdio.h>、<iostream>、<fstream>、<string>。没有第三方库,也没有构建脚本,是最朴素的单文件程序。
2.1 文件清单与实验对应关系
报告里没有显式给出源文件名,只有代码正文,落盘时需要自己命名。按主函数内容和输入文件,我整理成下面这张表:
| 实验 | 建议文件名 | 输入 | 输出 | 核心实现 |
|---|---|---|---|---|
| 实验 2 词法分析 | lexer.cpp | 同目录下的pl0.txt源程序文本 | 控制台回显源程序,逐行打印单词与类别号 | isLetter/isDigit/isP/isJ/isBlank五个判定函数,ktt类别表 |
| 实验 4 语法分析 | parser.cpp | 键盘输入的符号串 | 打印“句子结构正确”或“分析失败” | E()/A()/B()/C()四个递归子程序,全局游标s |
pl0.txt是词法分析器的测试输入,报告里给的样例包含CONST A=10; VAR B,C;以及故意插入的2B、2E这类非法串,目的就是看程序会不会报错。语法分析器不走文件,直接从标准输入读,这一点在运行方式上要区别对待。
2.2 编译环境与运行步骤
Windows 下用 MinGW 的 g++ 最省事,命令行进到源码目录,先编译词法分析器:
g++ -std=c++11 lexer.cpp -o lexer.exe编译通过后,确保pl0.txt和lexer.exe在同一目录,再运行:
lexer.exe程序用fopen("pl0.txt","r")打开文件,走的是相对路径,所以工作目录不对就会直接拿到空输入。用 Visual Studio 的话,新建空控制台项目,把lexer.cpp加进去,再把pl0.txt放到项目目录或设为调试工作目录,效果一样。
语法分析器单独编译:
g++ -std=c++11 parser.cpp -o parser.exe运行后按提示输入符号串。这里有个前置条件:代码里判断结束用的是a[s]=='#',所以输入串末尾必须带#,否则无论文法对不对都会落到“分析失败”。报告截图里没有把这个约定写清楚,是很多人第一次跑不通的直接原因。
提示:把两个程序放两个目录分别编译,避免
pl0.txt被误删或路径串掉;词法分析器的输出是累积到换行才打印的,源文件最后一行如果没有换行符,那一行的类别信息不会输出。
2.3 输入输出约定:源文件、结束符与二元组格式
词法分析器的输入处理是一段很典型的“一次性读全 + 哨兵结尾”写法。它先用fgetc把整个文件读进string src,再手动追加一个#当作结束标记,然后用pos游标逐个取字符。这段骨架值得单独拿出来看,因为后面所有分支都依赖ch和pos的同步推进:
FILE *fp; fp = fopen("pl0.txt", "r"); ch0 = fgetc(fp); while (ch0 != EOF) { src += ch0; // 整个源程序读进内存 ch0 = fgetc(fp); } src += '#'; // 哨兵,保证主循环能停 cout << src << endl; // 先回显,方便对照 ch = src[pos++]; // 取第一个字符,pos 指向下一个 ste = " "; // 行内类别串,换行时清空参数上,src存的是完整源程序加哨兵,pos是当前读取位置,ch是当前待分析字符,ste是这一行累积下来的“单词|类别”串。主循环条件是while (ch != '#'),所以哨兵不会进入任何分支。每识别完一个单词,ste追加一段|类别号,遇到换行符时把整行ste打印出来并重置为空串。这种“按行聚合输出”的格式,在实验报告里看起来整齐,但调试时不如一个 token 一行直观——后面做二次开发时这是第一个要改的地方。
3. 词法分析器拆解:从字符判定到单词二元组
词法分析器的整体思路是“字符分类 + 最长匹配 + 查表定类别”。它没有用状态机,也没有独立的 Token 结构体,而是用五个布尔函数把字符分成字母、数字、运算符、界符、空白五类,再在主循环里按类别走不同分支,边读边拼字符串,拼完去ktt表里查类别号。这套写法在实验场景下够用,代价是分支之间耦合紧,边界条件容易漏。
3.1 五个字符分类函数的分工
五个判定函数是整份代码的地基,逻辑都很短,但分类边界直接决定了后面能识别什么:
bool isLetter(char ch) { // 只认大小写字母,PL/0 标识符不接受下划线 return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z'); } bool isDigit(char ch) { return ch >= '0' && ch <= '9'; } bool isP(char ch) { // 算术运算符:+ - * / return ch == '+' || ch == '*' || ch == '-' || ch == '/'; } bool isJ(char ch) { // 界符与关系运算符,注意这里包含了 '#' return ch == ',' || ch == ';' || ch == '.' || ch == '(' || ch == ')' || ch == '[' || ch == ']' || ch == '=' || ch == ':' || ch == '<' || ch == '>' || ch == '{' || ch == '}' || ch == '#'; } bool isBlank(char ch) { return ch == ' ' || ch == '\t'; }这组函数的关键参数就一个ch,返回布尔值。isLetter决定了标识符的起头字符,isDigit决定常量的起头,isP把四则运算符单独拎出来做单字符处理,isJ覆盖界符、关系运算符和花括号。注意isJ里包含了#,但主循环又用#当结束符,这两处设计是冲突的——#永远走不到界符分支,ktt表里索引 47 的"#"成了死项。另外isJ还包含{和},说明作者想兼顾注释或块结构,但实际分支里没有对应的处理逻辑,只能作为单字符 token 输出。
注意:这里没有处理
\r。Windows 文本是\r\n换行,\r不满足任何判定函数,会掉出所有分支,被主循环末尾的ch = src[pos++]静默跳过,所以程序不会崩,但也不会报错——这种“能跑但结果不对”的问题最难查。
3.2 主循环里的四条识别路径
主循环按当前字符分流,数字、字母、运算符、界符各走一条,空白单独处理。先看数字和标识符这两条最长匹配路径:
if (isDigit(ch)) { // 常量路径:连续读数字和小数点,最长匹配 int i = 0; while (isDigit(ch) || ch == '.') { str[i++] = ch; ch = src[pos++]; // 读到第一个非数字字符为止 } str[i] = '\0'; ste = ste + "|" + "22"; // 22 是无符号数类别号 cout << str; continue; // ch 已推进,直接回循环头 } else if (isLetter(ch)) { // 标识符/关键字路径:字母开头,后续可接字母数字 int i = 0, j; while (isLetter(ch) || isDigit(ch)) { str[i++] = ch; ch = src[pos++]; } str[i] = '\0'; for (j = 0; j < 21; j++) { // 前 21 项是关键字 if (strcmp(str, ktt[j]) == 0) { ste += "|" + to_string(j); // 命中关键字,记录其索引 break; } } if (j == 21) ste += "|21"; // 未命中,按标识符记 21 cout << str; continue; }数字分支的问题在于“读到非数字就停”,它只保证了10、3.14这类串被完整取出,却没有检查后面紧跟的字符是否合法。测试文件里的2B会被拆成2(类别 22)和B(类别 21)两个 token,程序不但不报错,还会把它们当成两个合法单词输出。报告里写的“发现 2A 这类不能作为单词的字符串”,在这份实现里实际是没有做到的。
标识符分支用strcmp逐个比对前 21 个关键字,命中就记下索引,没命中记 21。这里ste += "|" + to_string(j)是我为了讲清楚逻辑做的等价改写,原代码用的是stringstream ss; ss << ste; ss << j; ss >> ste;,效果一样但绕了一圈。参数j就是类别号,0到20是关键字,21是标识符,这个约定贯穿整份程序。
运算符和界符两条路径相对简单,前者只处理单字符,后者用while (isJ(ch))连续吞掉一串界符再整体查表:
else if (isP(ch)) { // 运算符分支:单字符匹配,查 ktt[34..37] int i = 0, j; str[i++] = ch; str[i] = '\0'; for (j = 34; j < 38; j++) { if (strcmp(str, ktt[j]) == 0) { ste += "|" + to_string(j); break; } } cout << str; ch = src[pos++]; continue; } else if (isJ(ch)) { // 界符分支:连续界符会拼成一串再去查表 int i = 0, j; while (isJ(ch)) { str[i++] = ch; ch = src[pos++]; } str[i] = '\0'; for (j = 23; j < 47; j++) { if (strcmp(str, ktt[j]) == 0) { ste += "|" + to_string(j); break; } } cout << str; continue; }界符分支的连续读取是个隐患:);这种相邻界符会被拼成");"一次查表,ktt里没有这一项,匹配失败,于是既不记录类别也不报错,只把原文打印出来。正确的做法是每个界符单独成一个 token,最长匹配只对多字符运算符(比如:=、<=、..)生效,而不是对所有界符一视同仁。
3.3 类别表 ktt 的索引设计与匹配逻辑
ktt是整份代码的类别字典,48 行、每行 20 字节,索引就是类别号:
char ktt[48][20] = { "and","begin","const","div","do","else","end","function","if","integer", "not","or","procedure","program","read","real","then","type","var","while","write", "标识符","无符号数", ",",";",":",".","(",")","[","]","..","++","--","+","-","*","/", "=","<",">","<>","<=",">=",":=","{","}","#" };按索引切分:0到20是 21 个关键字,21是标识符,22是无符号数,23到47是界符和运算符。这个编号不是随意的,它跟主循环里各个分支的查表区间一一对应:字母分支查[0,21),数字分支直接写死22,运算符分支查[34,38),界符分支查[23,47)。
用一张表同时承载“字典”和“编号”的好处是紧凑,缺点是索引靠硬编码维护。比如你想加一个关键字repeat,得插到 21 之前,后面所有界符的编号全得跟着改,四个查表区间也要同步调整。工程上更常见的做法是用枚举定义类别,字典单独存map<string, TokenType>,编号和字典解耦。实验代码里这么写没问题,二次开发时建议先拆开。
3.4 错误检测的边界在哪里
报告对错误检测的要求是“发现 2A 这类不能作为单词的字符串”,实际实现只做到了“遇到不认识的字符不崩”,离“报错”还有距离。具体来说,这份代码能暴露的问题只有两类:一是str缓冲区写满后越界(标识符超过 19 字符),二是pos越界读(源文件末尾没有#且没读到哨兵)。真正应该报错的场景,比如数字后紧跟字母、界符组合不合法、注释没闭合,都被静默吞掉了。
要让错误检测真正生效,最小改动是在数字分支结束后加一次前瞻判断:
// 数字读完后再看一眼 ch,如果紧跟字母,就是非法单词 if (isLetter(ch)) { cerr << "词法错误:非法数字串,位置 " << pos - 1 << endl; // 跳过整个非法串,避免后续继续误判 while (isLetter(ch) || isDigit(ch)) ch = src[pos++]; }这段代码不改变原有 token 输出,只是在发现2B这类串时往stderr打一条带位置的错误。参数pos - 1是当前字符在src里的下标,因为ch已经推进到下一个字符,减一正好指向非法串的首字符。类似地,界符分支可以在查表失败时补一条报错,而不是默默打印原文。
4. 递归子程序语法分析器拆解:把文法直接翻译成函数
第二个实验走的是另一条路:给定一个文法,为每个非终结符写一个函数,函数体里按产生式右部的顺序匹配字符、递归调用其他非终结符。这就是递归下降分析,思路直白,代码结构和文法几乎一一对应,缺点是要求文法没有左递归,且遇到多个候选产生式时要靠当前字符决定走哪条。
4.1 文法 G[E] 的递归下降直觉
报告里给出的文法是这样的:
G[E]: E → eBaA A → a | bAcB B → dEd | aC C → e | dC终结符集合是{a, b, c, d, e},非终结符是E、A、B、C,开始符号是E。四个产生式的右部首字符分别是:E固定以e开头,A以a或b开头,B以d或a开头,C以e或d开头。首字符两两不冲突,所以每个非终结符只要看当前字符,就能唯一确定走哪条产生式,不需要回溯。
这一点是递归下降能成立的前提。如果A的两个候选都以a开头,就得先试一条、失败再回退,代码复杂度会翻倍。这份实验的文法显然是特意设计过的,保证每个非终结符的 FIRST 集不相交,让实现可以写得非常短。
4.2 E/A/B/C 四个子程序的对应关系
四个函数几乎是产生式的逐字翻译,先看E和A:
void E() { // E → eBaA if (a[s] == 'e') { s++; // 匹配终结符 e B(); // 递归进入 B if (a[s] == 'a') { s++; // 匹配终结符 a A(); // 递归进入 A } } } void A() { // A → a | bAcB if (a[s] == 'b') { s++; // 匹配 b A(); // 递归处理中间的 A if (a[s] == 'c') { s++; // 匹配 c B(); // 递归进入 B } } else if (a[s] == 'a') { s++; // A → a,只消耗一个字符 } }E只有一条产生式,所以函数体是顺序匹配:e、B、a、A,任何一步对不上就什么都不做直接返回。这种“静默失败”是递归下降的常见写法,错误信息统一由调用方根据s的位置来判断。A有两个候选,靠当前字符b或a分流,选b时中间还要再递归一次A,对应产生式里的bAcB。
B和C同理:
void B() { // B → dEd | aC if (a[s] == 'd') { s++; E(); // 嵌套一个完整的 E if (a[s] == 'd') { s++; } } else if (a[s] == 'a') { s++; C(); } } void C() { // C → e | dC if (a[s] == 'd') { s++; C(); // 右递归,深度取决于输入长度 } else if (a[s] == 'e') { s++; } }C是右递归,输入里连续出现d时会一层层压栈,输入过长有栈溢出风险,但实验场景下输入都很短,问题不明显。B的两个候选分别以d和a开头,依赖E和C继续往下匹配。整个调用链的参数只有一个全局游标s,所有函数共享,靠s++推进。
4.3 全局游标 s 与输入缓冲的设计
输入缓冲是一个固定长度的字符数组,游标是全局变量:
char a[10]; // 输入缓冲区,最多容纳 9 个字符 + 结束符 int s = 0; // 全局游标,所有子程序共享 int main() { while (1) { printf("请输入算数表达式(以#键结束):"); scanf("%s", a); // 无长度限制,超过 9 字符会越界 E(); // 从开始符号进入 if (a[s] == '#') { printf("句子结构正确\n"); } else { printf("分析失败\n"); } } return 0; }这段主函数有几个地方会直接导致运行异常。第一,while写成了大写While,标准 C++ 里没有这个关键字,原样编译会报错,必须手动改成小写才能跑。第二,s是全局变量,第一轮分析结束后没有重置,第二轮输入会从上一次的结束位置继续,结果完全错乱。第三,scanf("%s", a)不限制长度,输入超过 9 个字符就写越界。第四,判断成功依赖a[s] == '#',但scanf读入的串里如果没有#,s会停在串尾的\0上,永远判失败。
正确的最小修法是在每轮循环开头把s归零,并在读入后检查长度:
while (1) { s = 0; // 每轮重置游标 printf("请输入符号串(以#结束):"); if (scanf("%9s", a) != 1) break; // 限制最多 9 字符 E(); if (a[s] == '#') { printf("句子结构正确\n"); } else { printf("分析失败,停在位置 %d\n", s); } }参数%9s里的 9 是缓冲区长度减一,给\0留位置;s = 0放在读入之前,保证每轮都从串首开始。把失败时的位置打出来,调试效率会高很多,至少能看出是哪个字符卡住了。
4.4 结果判定与测试输入构造
这套文法最短的合法句子可以这样推:E → eBaA,B选aC,C选e,A选a,拼起来是e+ae+a+a,即eaeaa#。把它输入程序,s会一路推进到#,打印“句子结构正确”。再构造一个反例,比如ea#,E匹配完e后调用B,当前字符是a,走B → aC,C需要e或d,但当前是#,C什么都不做返回,B返回,E接着匹配a失败,最终a[s]停在#之前,输出“分析失败”。
测试时建议先把几个边界情形列出来:最短合法串eaeaa#、带嵌套d的串(比如eaedaeaa#,验证B → dEd路径)、缺少结尾#的串、包含非法字符x的串。每类输入跑一遍,观察s停在哪,比只看“正确/失败”两个结果有用得多。
5. 避坑排查:这两个实验最容易翻车的五个点
这两份代码我前后改过几遍,翻车点集中在编译、状态、边界三类。下面按“现象 → 原因 → 解决”记录五条,都是排查时实际会遇到的。
5.1 语法分析器编译报While未定义
现象:直接把报告里的代码粘进 IDE,编译器提示While不是标识符,或者提示缺少分号。
原因:主函数里写的是While(1),首字母大写,C++ 关键字区分大小写,While不是语言的一部分。这类错误在纸质报告里很容易被忽略,因为人眼读代码时会自动“纠正”成while。
解决:改成小写while (1),同时检查文件里是否还有类似的大小写混用。更稳妥的做法是编译前先过一遍 IDE 的语法高亮,关键字颜色不对的基本就是拼错了。
5.2 第二次输入结果永远错乱
现象:语法分析器第一次输入合法串,输出“正确”;紧接着输入另一个串,不管对错都输出“失败”。
原因:游标s是全局变量,第一轮分析结束后停在上一次串尾,第二轮没有归零,直接从旧位置继续读,相当于跳过了新输入的前半段。
解决:在每轮scanf之前执行s = 0。如果后续要支持文件批量测试,更彻底的做法是把s改成函数参数,通过引用在E()、A()之间传递,避免全局状态。
5.32A、2B这类非法串不报错
现象:词法分析器的测试文件里放了2B、2E,期望看到错误提示,实际输出却是把2和B当成两个正常 token 打印出来。
原因:数字分支的循环条件是while (isDigit(ch) || ch == '.'),读到字母就停,然后直接记录类别 22 并continue,没有回头检查停下来的字符是否合法。标识符分支随后把B当标识符收走,整个非法串被拆成两个合法单元。
解决:在数字分支结束后加前瞻判断,当前字符是字母就报词法错误,并跳过整个非法串;同时在标识符分支开头也加一条规则,标识符不能以数字开头。两条规则合起来,才能覆盖“数字后接字母”和“字母数字混排”两类非法输入。
5.4 换行处理和哨兵冲突导致输出缺行
现象:词法分析器输出里,源文件最后一行没有类别信息;某些行只打印了原文,没有拼上|类别号。
原因:类别串ste是在遇到\n时才输出并清空的,如果文件最后一行没有换行符,ste里的内容永远不会被打印。另外 Windows 的\r\n中\r不满足任何判定函数,会静默跳过,等于把换行位置往后挪了一格。
解决:主循环结束后补一次cout << ste << endl,把残留的行内类别输出掉;或者在读取阶段统一把\r过滤掉,只保留\n作为换行标记。isBlank里也可以加上\r,让空白分支统一处理。
5.5j % 002和数组边界:一个八进制字面量引发的偏差
现象:打印类别表时,每行的换行位置和预期不一致,有时候多换一行,有时候少换一行。
原因:代码里写的是if (j % 002 == 1),002在 C++ 里是八进制字面量,值等于十进制的 2,所以实际效果是j % 2 == 1,奇数索引后换行。作者大概率想写j % 2 == 1,但前导零让它看起来像“两个零一个二”,读代码的人很容易误解。
解决:直接写成j % 2 == 1,去掉前导零。同时检查ktt的打印循环边界:数组声明是char ktt[48][20],实际有 48 项,但打印循环写的是for (int j = 0; j < 47; j++),最后一项"#"不会被打印出来。查表区间也要跟着核对一遍,确认34..37、23..46这些硬编码范围和数组内容对得上。
6. 二次开发:把实验代码改成能讲清楚的编译器前端
实验代码最大的价值不是“能跑”,而是结构足够小,小到你可以逐行改、逐行验证。真要往工程方向走,我建议先做两件事:把词法分析器的输出从“按行聚合”改成“一个 token 一行”,再加一个独立的Token结构体;把语法分析器的全局游标改成参数传递,顺便补上错误位置。这两步做完,代码的可调试性会明显不一样。
6.1 词法分析器:用结构体替掉字符串拼接
原来的ste是字符串拼接,类别号和单词混在一起,后期想按类别过滤很麻烦。可以定义一个简单结构体,让nextToken()每次返回一个 token:
struct Token { string text; // 单词原文 int type; // 类别号,沿用 ktt 的编号 int line; // 所在行号,方便报错定位 }; Token nextToken() { // 跳过空白,记录行号 while (isBlank(ch) || ch == '\r') { if (ch == '\n') line++; ch = src[pos++]; } Token t; t.line = line; // 后续按字符类型分流,填充 t.text 和 t.type return t; }这样主循环变成while (true) { Token t = nextToken(); if (t.type == END) break; tokens.push_back(t); },输出、过滤、单测都方便。line字段是新增的,原代码没有行号概念,加上之后报错信息才能定位到具体行。
6.2 语法分析器:补输入校验和失败位置
语法分析器最需要补的是输入长度校验和失败位置输出。把scanf("%s", a)换成scanf("%9s", a),每轮开始重置s,失败时打印s和当前字符。再进一步,可以把a换成std::string,用s做下标,配合s < input.size()判断越界,彻底避免固定数组的溢出问题。
| 改造点 | 原实现 | 建议改法 | 收益 |
|---|---|---|---|
| 输入缓冲 | char a[10]+scanf("%s") | std::string+getline | 无长度上限,不越界 |
| 游标状态 | 全局int s | 函数参数引用传递 | 多轮分析互不干扰 |
| 失败信息 | 只打印“分析失败” | 打印位置和当前字符 | 定位到具体卡点 |
| 结束判断 | 依赖输入带# | 显式检查s == input.size() | 不强制用户加哨兵 |
表格里的四项可以分步做,先做输入缓冲和游标两项,跑通现有测试用例;再做失败信息和结束判断,行为会变,需要同步更新测试输入。
6.3 验证清单:每次改完跑一遍
改完之后别只看“正确/失败”,按下面这组输入过一遍,能覆盖大部分回归问题:最短合法串eaeaa#、带嵌套的eaedaeaa#、缺哨兵的eaeaa、非法字符开头的xaeaa#、连续两轮输入(验证s是否重置)。词法分析器这边,用pl0.txt原文件跑一遍,再手工造一个含2A、);、超长标识符的文件跑一遍,确认错误提示和 token 边界都对。
从那以后我每次改这类递归下降的代码,都强制先跑一遍“连续两轮输入”的用例——全局状态不重置这个坑,我踩过不止一次,而且每次表现都一样:第一轮对、第二轮错,查半天才发现是游标没归零。希望这份拆解能帮你少走一遍同样的路。
本文还有配套的精品资源,点击获取