ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PL/0扫描器与递归下降解析:类别码、最长匹配和LL(1)实践

PL/0扫描器与递归下降解析:类别码、最长匹配和LL(1)实践

简介:东北大学秦皇岛分校编译原理课程实验报告,面向正在学习词法分析、需要完成PL/0语言词法分析程序设计与实现的学生。文档围绕词法分析器的输入输出、单词与类别二元组生成、非法字符串检测等任务展开,结合C++环境说明字符分类函数、关键词表匹配、源文件读取和逐字符处理流程,并附有可参考的实验代码与结果输出思路,便于对照理解GETSYM的工作方式及常见错误处理。压缩包共1个doc文件,约122KB,篇幅集中,适合课程实验复盘、报告撰写参考和代码实现查漏补缺。已有102人学习,对刚接触编译原理、希望把词法分析流程落到实处的读者较有借鉴价值。

1. 词法分析不是“切词”,是给编译器立一套不会歧义的编号

很多人翻开编译原理实验报告,第一反应是照着课本抄一个扫描器,跑通交差。真正值得琢磨的是另一件事:词法分析器输出的为什么不是一堆字符串,而是一堆「单词 + 类别码」的二元组?材料里的 PL/0 扫描器把write编成 20、把标识符统一编成 21、把无符号数编成 22,第一眼看挺反直觉——明明知道单词长什么样,为什么还要多套一层整数编号?答案在于:一旦下游只认这几十个整数,整个前端就拿到了一条极强的不变量,任何记号的身份都能用一次整数比较敲定,字符串比较被彻底赶出了语法分析阶段。这份材料真正能教给人的,不是 C++ 语法,而是这条不变量靠字符分类、关键字查表、最长匹配三条规则共同守住的全过程。

2. 字符分类与关键字查表:扫描器的骨架为什么长这样

字符流的可能性是 128 个 ASCII 码位,而一门语言的记号种类只有几十个。扫描器的全部工作就是把这个无限长的字符序列,收敛到有限的类型集合上。收敛动作分成两层:第一层在字符粒度上判定“这个字符能开启哪类记号”,第二层在记号粒度上判定“这个串到底属于哪个具体类型”。两层之间靠一张表连接。

2.1 五个 isXxx 谓词:判据不是“它是什么”,而是“后面还能接什么”

// 字符级判定:把 ASCII 空间切成互斥的几块,是后续所有分支的前提 bool isLetter(char ch){ // 字母:标识符与关键字的唯一合法起点 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'; }

这五个函数都只接收一个char、返回bool、没有副作用,所以可以被反复调用而不改变扫描状态。它们的定位不是“分类器”,而是“入口判据”:主循环只看当前字符落在哪个谓词里,就决定走哪条处理分支;进入分支之后,同一个谓词又被拿来判断“还能不能继续吞”。

+ - * /被单独拆成isP,而= < > :留在isJ里,这个拆分不是随手写的。+ - * /在语言里永远只占一个字符,而<= >= <> :=都可能由两个字符组成,必须和, ; .这类纯单字符界符走同一条“允许变长”的处理路径。换句话说,谓词的划分依据不是“符号长得像不像”,而是“这个位置有没有可能多读一个字符”。这条区分在真正动手改代码时非常关键,因为一旦把变长和定长混进同一个分支,后面的吞字符循环迟早会把两个相邻的短记号粘成一个长串。

还有一个容易忽略的点:isLetter只覆盖英文字母,没有处理下划线。教材里的 PL/0 标识符确实只允许字母开头,但很多人从别的语言迁移过来,下意识会把_加进去,结果就是关键字表里那 21 项全是纯字母,标识符却出现了下划线,两边规则不一致。要么两边都放行,要么两边都不放行,别只改一半。

2.2 最长匹配:数字分支里那个被低估的 while 循环

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 已经是下一个记号的第一个字符 }

这段循环体现的是词法分析里最核心的一条规则:最长匹配。当扫描器看到一个1,它无法立刻断定这是常数1还是常数123的开头,只能一路往后读,直到读到第一个不可能属于这个记号的字符为止。循环结束时ch停在那个“越界”的字符上,而这个字符没有被消费掉,它必须作为下一个记号的起点被重新判定。continue的作用就是跳过循环体末尾的推进语句,让控制流回到while(ch!='#')重新做一次入口判断。

参数上,i是写入缓冲区str的下标,pos是全局读头位置,两者不共享。这里用了一个长度为 20 的局部数组承接结果,超过长度就会写越界,属于典型的教学代码简化——真实扫描器要么用std::string动态追加,要么在写入前判断i < sizeof(str)-1。

最长匹配的代价是它只认局部信息。1.2.3会被吞成一个记号,1.2.3.4也是,因为循环只判断“是不是数字或点”,从不验证点的数量。更麻烦的是.5这种写法:.本身是isJ的成员,主循环会先把.判成界符送进界符分支,根本轮不到数字分支处理,于是.5被拆成.和5两个记号。这不是 bug,而是规则顺序的必然结果——入口判断的优先级决定了哪些写法被允许。如果确实需要支持.5,只能把数字判定条件改成“数字开头,或者点后面紧跟数字”,代价是入口判据从单字符判断升级成需要向前看一位的判断。

2.3 关键字表 ktt:为什么标识符的类别码刚好是 21

表里的 48 个位置是按类型分段排的,下标本身就是类别码:

下标区间内容说明
0 – 2021 个关键字andbeginconst…whilewrite
21标识符所有用户自定义名字共用这一个码
22无符号数所有数字字面量共用这一个码
23 – 46界符与运算符,;:.()[]..+-*/=<><><=>=:={}
47#结束符,只用于终止扫描,不参与查表

标识符用统一的 21、而不是“21 + 序号”,背后是一条边界划分原则:扫描阶段只回答“这是什么种类”,不回答“这是哪一个”。A和B都是标识符,具体是哪个由后面的符号表去区分。如果把编号做成“每个标识符一个码”,那编号空间就不再是有限的 48 个,而是随源程序规模增长,下游就无法用固定大小的判定表做分派。这条原则在很多真实编译器里被推到极致——类别码是个枚举,标识符的文本被单独存进字符串池,语法分析器拿到的永远是一个小整数。

关键字查表用的是strcmp逐个比对,找到就break,循环变量j的最终值承担了“是否命中”的语义:

for(j=0;j<21;j++){ // 只在关键字段里找 if(strcmp(str,ktt[j])==0){ stringstream ss; ss<<ste<<"|"<<j; // 命中:类别码就是表下标 ss>>ste; break; } } if(j==21){ ste=ste+"|"+"21"; } // 循环跑满仍未命中:说明是标识符

这里依赖“for正常结束则j等于上界”这一语言细节来表达“没找到”。写法在教材里很常见,但它把“查表结果”编码进了循环变量,任何人后来把for改成while、或者加一个if提前break,这个判定就静默失效了。我在自己写这类代码时更倾向于把查表抽成独立函数,命中返回下标,未命中返回-1,调用点只判断返回值,不关心循环怎么写的。

3. 扫描节奏:pos指针、ch超前读与ste缓冲的三方配合

理解一个扫描器,最快的办法是把它的“读头”模型画清楚。这段代码里只有一个真正的状态变量pos,ch是src[pos]的一份快照,ste则是输出侧的累积缓冲区。三者之间的推进约束,决定了程序会不会死循环、会不会丢字符。

3.1ch=src[pos++]这个惯用法埋了什么

主循环的骨架是:

ch=src[pos++]; // 取当前字符,同时把读头推到下一个位置 while(ch!='#'){ // '#' 由 src+='#' 在末尾兜底补上 if(isDigit(ch)){ /* 数字分支,内部自行推进 ch 和 pos */ continue; } else if(isLetter(ch)){ /* 标识符分支,同上 */ continue; } else if(isP(ch)){ /* 运算符分支 */ continue; } else if(isJ(ch)){ /* 界符分支,内部循环推进 */ continue; } else if(isBlank(ch)){ ch=src[pos++]; continue; } else{ /* 换行或其他:刷出缓冲 */ } ch=src[pos++]; // 只有走到这里才由循环体统一推进 }

这套写法的隐含契约是:每条continue之前,分支自己必须负责把ch推进到下一个待判定字符,否则就会原地打转。数字分支靠while里的ch=src[pos++]完成推进,标识符分支同理,运算符分支在手动读了第二个字符后又单独推了一次,界符分支在while(isJ(ch))里推进。运算分支和界符分支的推进逻辑分散在不同位置,一旦有人给某个分支加了个if提前return或者提前continue,读头就不再前进,程序立刻进入死循环。

src+='#'这一步是把结束符手动补到输入串末尾。它的作用是让主循环有一个天然的终止条件——源文件读进来之后不管最后是什么字符,末尾总有一个#在等着。这个技巧省掉了每次读字符时判断EOF的麻烦,但也带来了一个副作用,后面排查章节会细说。

3.2 换行才刷缓冲:ste的累积与二元组的输出时机

ste是一个字符串,每识别出一个记号,就把"|" + 类别码拼到它尾部。真正把它打印出来,是在遇到换行的时候:

else{ // 当前字符既不是数字字母,也不是运算符界符空白 cout<<ste<<endl; // 把本行的类别码序列刷出去 ste=" "; // 重置缓冲,注意带了一个前导空格 }

换行本身不是记号的边界,用换行做刷出单位纯粹是对齐源程序的排版,方便人眼对照。这套“累积 → 换行刷出”的机制在两个地方露出了粗糙:ste被初始化为一个带空格的字符串" ",导致每行输出的开头都多一个空格,拼出来的序列是|20|21|38这种形状;拼接用的是ste=ste+"|"+"22"和stringstream两种方式混写,可读性差但结果一致。

从机制上看,这份缓冲设计暴露出词法分析器的一个真实职责分工:扫描器逐个产出记号,调用方(这里是cout)负责成批消费。教材里的 PL/0 的GETSYM用的是另一个模型——每调用一次GETSYM就返回一个sym,同时把具体的标识符或数值填到全局变量id、num里。那种模型下没有缓冲区,因为语法分析器是主动“拉”记号的;这段代码是主动“推”记号的,所以需要缓冲区。两种模型对词法分析逻辑没有任何影响,只影响接口形态。

3.3 和教材GETSYM的差距:省掉的恰恰是最难的部分

对比 PL/0 原始版的GETSYM,这段实现少了四样东西,每一样都不是可有可无的装饰:

  • 符号表:原始版在识别出标识符后不做任何登记,因为词法层确实不需要;但只要词法层想区分“第一次出现的名字”和“重复定义的名字”,就必须引入符号表结构。
  • 行号计数:GETSYM维护一个line变量,出错时能报出“第几行第几列”。这段实现完全没有行号,出错只能靠人肉数换行。
  • 双字符界符的正确合并:原始版对:=<=>=<>是显式两字符判断,先读一个再看下一个;这段实现靠while(isJ(ch))一路吞,吞得对不对全靠运气。
  • 错误恢复:原始版遇到非法字符会跳过并继续,保证一次扫描能报出多个错误;这段实现遇到不认识的分支直接落到换行逻辑上,等于把错误字符静默丢弃。

四样里的后两个,是这份代码在真实使用中最容易翻车的地方,也是“跑通测试用例”和“实现一个扫描器”之间的分水岭。测试用例里刚好没出现两个相邻界符,所以循环吞字符的行为看起来正常;一旦换成);这种组合,问题立刻暴露。

4. 递归下降:把文法产生式直接抄成函数,凭什么能跑

实验的第二个任务是给一个四非终结符的文法写递归子程序分析器。文法长这样:

G[E]: E → eBaA A → a | bAcB B → dEd | aC C → e | dC

四个非终结符对应四个函数,每个函数体内用一个if-else链对应产生式的多个候选。看起来像是把 BNF 逐字翻译成了代码,这种“翻译”之所以成立,背后有两条硬性条件。

4.1 First 集决定if的先后顺序,也决定了能不能不回溯

对每个非终结符算它的候选首符号集(First 集):

非终结符候选First 集
EeBaA{e}
Aa/bAcB{a}/{b}
BdEd/aC{d}/{a}
Ce/dC{e}/{d}

四条产生式里,凡是候选多于一个的(A、B、C),各自候选的 First 集两两不相交。这意味着解析器只要看当前字符一眼,就能唯一确定该走哪个候选,不需要“先试第一个,失败了退回来再试第二个”。这就是 LL(1) 的定义性条件,也是递归下降能写成无回溯if-else的全部依据。

void A(){ if(a[s]=='b'){ // 候选 bAcB 的 First 集是 {b} s++; // 吃掉终结符 b A(); // 递归展开非终结符 A if(a[s]=='c'){ // 吃掉终结符 c s++; B(); // 递归展开非终结符 B } } else if(a[s]=='a'){ // 候选 a 的 First 集是 {a} s++; // 吃掉终结符 a,产生式结束 } }

函数里的顺序不是随便排的:先判b再判a,是因为这两个字符互斥,谁先谁后都对;但如果把文法改成A → aX | aY,两个候选的 First 集都是{a},这个if-else结构立刻失效——解析器看完a仍然不知道该走哪条路,只能改成“先按 X 解析,失败则回退到a之后重新按 Y 解析”。回退意味着s要能被还原,意味着每个函数都要保存进入时的s值,意味着最坏情况下指数级的重复解析。递归下降的简洁性,是用“文法必须满足 LL(1)”这个约束换来的,不是白给的。

4.2 全局下标s:递归下降的“栈”藏在调用栈里

四个函数都不带参数,共享一个全局数组a和一个全局下标s。s是输入串上的读头,s++的动作就是“消费一个终结符”。这个设计的关键在于:递归调用本身就是栈。

以A的bAcB分支为例,执行顺序是“吃掉b→ 递归解析一个完整的A→ 吃掉c→ 解析一个完整的B”。在吃掉b之后、递归A()之前,解析器必须记住“我回来之后还要吃掉一个c、再解析一个B”。这条待办事项没有被存进任何显式数据结构,它就存在 C 语言调用栈的返回地址里——A()返回时,程序自然回到if(a[s]=='c')那一行继续。

这解释了为什么递归下降不需要手写栈:每个非终结符的“未完成工作”恰好等价于一次函数调用。也解释了为什么它怕深递归——文法嵌套越深,调用栈越深,源程序里一个长得离谱的嵌套表达式就能把栈撑爆。真实编译器在这一点上通常会设一个最大嵌套层数,超了就报“嵌套过深”。

4.3B → dEd里嵌了E:间接递归为什么安全,左递归为什么致命

文法里最值得多看一眼的是B → dEd。E出现在B的产生式内部,而E的第一个产生式又是eBaA,B又回到了E的展开路径里。这种互相引用看起来像是循环,但它和“左递归”是两回事。

判断标准很简单:看递归发生时,读头有没有前进。B → dEd要求先吃掉一个d才去展开E,读头前进了,所以展开E的规模严格小于展开B的规模,递归一定会收敛。而如果写成E → Ea,展开E的第一步又是展开E,读头一个字符都没动,函数会无限自我调用,直到栈溢出。

B → dEd里这个d实际上扮演了分隔符角色,它保证了E的展开边界是明确的。这条规则在很多手写解析器里被反复踩坑:只要把文法写成Expr → Expr + Term,代码就跑不起来,必须改写成Expr → Term (+ Term)*的右递归形式,把“先消费一个终结符”这件事提到递归之前。

5. 排查:这两份实验代码最容易翻车的几个点

把这段代码当“能跑就行”的作业,和把它当“理解扫描器机制”的样本,会踩到完全不同的坑。下面几条是照着这两份代码实际动手改的时候,几乎必然撞上的。

5.1 词法分析器上的踩坑记录

现象一:输入2A不报错,反而顺利输出两个记号。

原因:数字分支的while(isDigit(ch)||ch=='.')遇到A立刻停止,退出时ch停在A上,主循环重新进入入口判断,把A当成标识符的开头正常处理。两个记号都“合法”,错误从来没被触发。实验要求里明确写着要能发现2A这类串,但代码里没有任何一处检查“数字后面能不能紧跟字母”。

解决:在数字分支的while结束、str收尾之后,加一句判断——如果ch仍然是字母,说明数字和字母贴在一起了,报“数字后不能紧跟字母”,并把这个错误连同当前行号一起输出。判断要放在continue之前,否则读头已经交给下一轮了。

现象二:源程序里出现);这种相邻界符时,两个记号被粘成一个,匹配失败后静默丢弃。

原因:界符分支用的是while(isJ(ch)),只要下一个字符还是界符就继续往str里塞。(和)都是isJ的成员,;也是,所以);会被吞成一个两字符的串");",到 23–46 的表段里一比对,找不到任何匹配项。而查找循环结束后,代码没有对“未命中”做任何处理,既没输出也没报错,这两个记号就此消失。

解决:界符的识别顺序必须反过来——先尝试两字符组合(查.. ++ -- <> <= >= :=这七个双字符项),不命中再回退一格、按单字符查表。回退的动作就是把读头退回去一个位置(pos--或等价的s--)。这一步是词法分析里少见的“必须能后退”的场景,写惯了只向前读的扫描器,很容易在这里翻车。

现象三:源程序里出现#就直接截断,后面的内容全部不分析。

原因:主循环的终止条件是while(ch!='#'),而末尾又用src+='#'手动补了一个结束符。如果源程序本身含有#(比如某些教学方言拿它做注释引导符),扫描会在第一个#处停下,文件后半段完全被忽略,而且不报任何错。更麻烦的是isJ把#判成了界符,可界符查表的范围是j<47,第 47 项恰好就是#,永远查不到——两条规则对#的处理互相矛盾,却因为循环提前终止而掩盖了。

解决:把“文件结束”和“记号#”彻底分开。读文件时用真正的 EOF 判断终止,#如果有语法含义就正常走界符分支并给它一个类别码,如果没有含义就报非法字符。用同一个字符身兼二职,是这类扫描器里最难查的一类问题。

5.2 语法分析器上的踩坑记录

现象四:语法分析程序编译不过,报While未声明。

原因:main里写的是While(1){...},首字母大写。C++ 区分大小写,While不是任何已声明的标识符。这类问题在实验报告里很常见,因为代码是手抄或者跨环境粘贴的,肉眼扫过去完全正常。

解决:改成小写while(1)。但更值得顺手处理的是循环本身——while(1)加scanf("%s",a)的组合,一旦用户输入的串长度超过数组边界(这里只有 10 字节),就会直接踩坏相邻内存。正确做法是用fgets加长度限制,或者在scanf里写%9s。这个习惯比起“改个大小写”重要得多,因为数组越界不会当场报错,它会在别的地方以完全无关的形式炸开。

现象五:明明是正确的串,判定失败;或者错误的串,判定成功。

原因:解析函数在匹配失败时什么都不做,直接返回,s不前进,上层函数接着自己的流程走。比如E()里if(a[s]=='e')不成立时,函数直接返回,调用方拿不到任何“这里出错了”的信号,只能靠最后检查a[s]=='#'来判断成败。问题在于,某个分支失败后s停在原地,后续的检查可能恰好落在#上,于是错误输入被判成正确。

解决:让每个解析函数返回布尔值,失败就立刻向上传播,不要等到最外层再统一判断。同时把s的位置和期望字符一起报出来——只报“分析失败”四个字,对定位问题毫无帮助。这里有个血泪经验:调试递归下降时,最有效的办法是在每个函数入口打印一次函数名和当前字符,看一眼调用序列就知道它在哪一步走岔了。

6. 设计取舍:预测式分析把复杂度挪到了哪里

这两份实验代码放在一起看,能看出一个贯穿编译器前端的共同思路:把运行时的搜索成本,提前挪到文法设计阶段一次性付清。

词法层的体现是最长匹配。扫描器看到1时不做任何猜测,一路读到读不动为止,这个规则简单到可以用一个while表达完。代价是它放弃了上下文——1.2.3会被当成一个数,typedef名字在 C 里到底是类型还是标识符,词法层永远分不清,只能交给语法层用上下文判断。用局部贪心换全局简洁,这是词法分析的既定取舍。

语法层的体现是 LL(1)。只要文法满足 First 集不相交,递归下降就能写成无回溯的if-else,每个字符只被看一次,复杂度是线性的。代价是文法必须被改造成 LL(1) 形式:提左公因子、消除左递归、必要时引入新的非终结符。这份实验里的四个产生式天生就是 LL(1) 的,所以代码看起来毫不费力;一旦换成带公共前缀的文法,同一个写法就得加入回溯,最坏情况退化成指数级。

方案语法约束运行时开销适合场景
无回溯递归下降文法必须是 LL(1)每个字符看一次手写解析器、教学语言
带回溯递归下降无最坏指数级文法不规则、输入规模小
表驱动 LL(1)文法必须是 LL(1)查表 + 显式栈自动生成解析器
自底向上移进归约更强,可处理左递归移进归约动作 + 状态栈工业级语言的完整语法

我自己的习惯是:只要动手写扫描器,先把类别码表完整打印出来,逐项核对每个字符落进哪个谓词、每个谓词的分支会不会互相抢字符;写递归下降之前,先把每个非终结符的 First 集列成一张表,确认两两不相交再动键盘。这两步各花不到十分钟,能省掉后面大半天盯着调用序列猜哪里走岔的时间。从那以后我每次写这类解析代码,都强制先把“读头什么时候前进”这件事在纸上标一遍,再落成s++的位置。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进