首页 / 资讯中心 / 文章详情

递归下降分析法实战:从词法分析到四元式生成的编译器前端设计

递归下降分析法实战:从词法分析到四元式生成的编译器前端设计 ★ FEATURED ARTICLE
简介一份编译原理课程设计报告聚焦简单文法编译器前端的设计与实现。资源面向计算机专业学生与编译器设计初学者系统讲解词法分析、递归下降语法分析、语义分析及四元式中间代码生成并覆盖常量、数组、if-else、while 等文法扩展可作为课程设计、实验报告或期末项目的参考模板。包体为单个 docx 文档共 1 个文件大小约 381KB内容包含设计任务与要求、算法总体流程、数据结构、程序流程图、实验结果及结论等完整章节。目前已有 693 人学习浏览具备一定的实践参考价值。通过阅读这份课程设计报告读者可掌握编译器前端的模块划分与实现思路理解递归子程序法如何同时完成语法和语义分析并了解从源代码到中间代码再到目标代码的完整编译路径对完成同类课程设计和巩固编译原理知识很有帮助。1. 从零搭一个编译器前端递归下降之外还该关注什么很多人学编译原理时觉得最难的不是文法推导而是“把理论变成能跑的程序”。这份课程设计资源恰好卡在最关键的位置它用递归下降子程序法实现了词法分析、语法分析、语义分析和四元式中间代码生成并且把状态转换关系、关键字表、界符表全部外置成文件——这意味着改文法不需要重写代码而是改配置。这个设计思路即使在今天做脚本语言解释器或 DSL 解析器时依然适用。项目还扩展了常量定义、数组结构、if-else 语句、while 语句并且附带了一个递归子程序栈的动态观察窗口每走一步都能看到当前调了哪个子程序、生成了哪条四元式。如果你正在做编译原理课程设计或者想快速理解一个真实编译器前端的分层结构这份资源值得拆开来看。2. 词法分析模块状态转换、关键字与界符表的文件化设计2.1 为什么词法分析要把状态转换关系放到文件里多数编译器教材在讲词法分析时都是直接写一个 switch-case 或 if-else 组成的扫描器遇到一个字符判断一次代码长且难维护。这份设计给出的方案是把所有的状态转换关系、关键字表、界符表都保存为文件词法分析器启动时先读取这些文件生成对应的数据结构再按文件中的状态转换关系逐个扫描字符切分单词。这样做有一个直接好处扩展文法时词法层不用改 C 代码只需要在文件里增加新的关键字或界符重新启动程序即可。对于课程设计来说这意味着答辩时可以现场演示“新增一个保留字不重新编译就生效”的效果属于明显的亮点。常见做法是使用两个文件一个保存关键字映射一个保存界符映射。关键字表记录了program, var, integer, begin, end, if, else, while, real, bool, char, arr, con, of这些词及其对应的内部编码界符表则覆盖了运算符、分隔符、括号、赋值号、关系运算符等例如 - * / : , [ ] { } ; ( ) : . 。词法分析器每读出一个标识符就去关键字表中查一下是否匹配匹配则返回关键字编码否则返回标识符编码。2.2 扫描器的典型实现结构假设我们用 C 语言实现扫描器的核心循环大致是这个样子int next_token(FILE *src, Token *tok) { int state 0; char buf[128]; int len 0; while ((ch fgetc(src)) ! EOF) { // 根据状态转换表决定下一个状态 int next_state state_table[state][char_class(ch)]; if (next_state -1) break; // 当前状态无法继续结束一个单词 state next_state; buf[len] ch; } // 按终态类型返回 if (state STATE_ID) { int code lookup_keyword(buf); tok-type (code 0) ? code : TK_IDENT; strcpy(tok-text, buf); return 0; } if (state STATE_NUM) { tok-type TK_NUMBER; tok-value atof(buf); return 0; } // 更多终态处理... }走一遍这个循环就清楚state_table[state][char_class(ch)]是核心它把当前的自动机状态和输入字符类别映射到下一个状态。char_class把字符归类为数字、字母、界符、空白等几类这样状态表就不是 128 列而是十几列能压得很小。lookup_keyword查关键字表查到就直接把 Token 类型改成关键字编码查不到就按标识符处理。参数说明状态表可以定义成int state_table[MAX_STATE][CLASS_NUM]其中 MAX_STATE 取 30 到 50 够用CLASS_NUM 一般取 8 到 12。终态用正数编码非终态用负数或 0 区分读文件时按行解析即可。这里的状态转移判断顺序很关键先判断能否继续不能继续才把当前字符回退到输入流否则会丢字符导致后续 Token 错位。2.3 Token 数据结构与符号表联动Token 序列生成后语法分析器会持续消费这些 Token。这里要注意一个容易被忽略的事实词法分析器返回的还只是“单词的类别”至于这个标识符是变量名还是数组名需要语法分析阶段查符号表才能确定。所以 Token 结构体至少要包含三部分单词类型编码、单词字符串本身、数值如果是常量。见到的课程设计代码里很多人只在 Token 里存了类型编码字符串内容直接丢弃结果到语义分析时发现符号名没了不得不回头改词法接口。这个 $坑$ 很典型设计 Token 结构时最好一开始就按下面这个模式来typedef struct { int type; // Token 类型编码 char text[128]; // 单词原文 union { int ival; double fval; char *sval; } value; // 常数值 int line; // 所在行号排错用 } Token;行号字段在 $踩坑$ 时极其有用后面排查语法错误时能直接定位到源程序第几行而不是在冗长的 Token 列表里人工找位置。3. 递归下降子程序法语法分析同时生成四元式3.1 文法设计与子程序划分的对应关系递归下降分析的本质是把文法产生式直接翻译成函数的互相调用。这份设计的基本文法定义如下PROGRAM program id SUB_PROGRAM . SUB_PROGRAM VARIABLE COM_SENTENCE VARIABLE var ID_SEQUENCE : TYPE ; VARIABLE | con ID_SEQUENCE : cons ; VARIABLE | arr id cons { of cons } ; | ε ID_SEQUENCE id { , id } TYPE integer | real | char | bool COM_SENTENCE begin SEN_SEQUENCE end SEN_SEQUENCE EVA_SENTENCE { ; EVA_SENTENCE } EVA_SENTENCE id : EXPRESSION EXPRESSION EXPRESSION TERM | TERM TERM TERM * FACTOR | FACTOR FACTOR id | cons | ( EXPRESSION )观察这个文法就能发现每个左部非终结符都对应一个分析函数PROGRAM()、SUB_PROGRAM()、VARIABLE()、ID_SEQUENCE()、TYPE()、COM_SENTENCE()、EVA_SENTENCE()、EXPRESSION()、TERM()、FACTOR()。函数体直接按产生式的右部顺序去匹配 Token——遇到终结符就调用match()校验当前单词遇到非终结符就调用对应的子程序函数。递归下降对文法的要求是每个产生式不能有公共前缀否则无法仅凭当前 Token 决定走哪条分支。这份设计的VARIABLE产生式产生三个分支靠的是前看一个单词当前 Token 是var走第一条是con走第二条是arr走第三条。这就是 LL(1) 的思想向前看一个 Token 决定采用哪个产生式。3.2 语义动作在语法分析函数里插入四元式生成逻辑递归下降的另一个优势是语法分析和语义分析可以同步进行不必先建完整语法树再遍历语义。只要在合适的位置插入“语义动作”代码即可。四元式结构定义得很经典四元组外加一个跳转字段typedef struct { int op; // 操作符编码如 ADD, SUB, MUL, JMP, JZ, ASSIGN int arg1; // 第一个操作数可能是符号表索引或常量 int arg2; // 第二个操作数为 -1 表示空 int result; // 结果存放的符号表索引或临时变量编号 } Quad;考虑赋值语句x : a b * c语法分析走到EXPRESSION - EXPRESSION TERM这一层时先递归分析出a和b * c的值然后执行语义动作生成两条四元式(*, b, c, t1) (, a, t1, t2)然后在EVA_SENTENCE层生成第三条(:, t2, -1, x)生成临时变量的方式是维护一个临时变量计数器每需要一个新的临时寄存器就令编号加一而不是复用旧编号——这样做的好处是四元式序列执行顺序和源程序天然一致后续做目标代码生成时不需要重新排序。3.3 一个典型的递归下降函数实现下面这段代码演示了FACTOR函数的基本结构以及语义动作的插入位置void FACTOR() { if (token-type TK_IDENT) { // 语义动作把标识符作为因子值存起来 int id_index find_symbol(token-text); push_factor(id_index); match(TK_IDENT); } else if (token-type TK_NUMBER) { // 语义动作把常量作为因子值存起来 int const_index add_const(token-value); push_factor(const_index); match(TK_NUMBER); } else if (token-type LPAREN) { match(LPAREN); EXPRESSION(); match(RPAREN); } else { error(FACTOR 语法错误); } }看到这里读者能做出自己的判断push_factor是一个栈操作把当前因子的符号表索引压入语义栈。当TERM函数遇到乘号时从栈里弹出右操作数和左操作数生成乘法四元式再把结果压回栈中。这种基于栈的属性传递方式模仿了 LR 分析中继承属性和综合属性的传递过程是递归下降里最实用的一种做法。需要特别提醒一个细节FACTOR()里对(的处理在匹配右括号之后左括号内整个表达式的结果已经压入语义栈因此右括号后不需要额外 push否则语义栈会多一个多余元素生成的中间代码会多出错误的操作数。4. 文法扩展常量、数组、if-else 与 while 的语法语义联动4.1 常量定义文法从con到符号表登记在基本文法上扩展的第一项是常量定义文法为VARIABLE | con ID_SEQUENCE : cons ; VARIABLEcon分支与var分支在语法结构上几乎一致差异只在语义动作变量定义在符号表中分配存储位置常量定义则把值记录在符号表。void VARIABLE() { if (token-type KW_CON) { match(KW_CON); ID_SEQUENCE(); match(:); // 语义动作此处应该是常量类型 match(KW_CONS); match(;); VARIABLE(); } }现实扩展中ID_SEQUENCE识别一串名字后语义动作需要为每个名字都做常量登记。常见做法是先用一个名字缓冲区收集序列中的所有名字等到:后读到类型时再统一为缓冲区的每个名字登记类型和常量属性。这样做比每读一个名字就登记一次更严谨因为后者在读到类型之前无法确定类型信息。从数据结构角度说定义一张常量表或复用一个符号表增加一个is_const标记字段即可。4.2 数组结构文法下标与维度信息的展开数组文法扩展是这份设计里语义动作最复杂的一部分文法定义成VARIABLE | arr id cons { of cons } ;它允许定义类似arr a 10 of 20这种带维度信息的数组结构识别后需要在符号表登记数组名、每个维度的上下界并计算数组占用的总存储单元数。void parse_array_decl() { int dims[10][2]; // 最多支持 10 维每维存上下界 int dim_count 0; match(KW_ARR); match(TK_IDENT); // 读数组名 match(KW_OF); while (token-type ! ;) { dims[dim_count][0] expect_const(); // 下界 dims[dim_count][1] expect_const(); // 上界 dim_count; if (token-type ,) match(,); } // 语义动作登记数组信息到符号表 int total_units 1; for (int i 0; i dim_count; i) total_units * (dims[i][1] - dims[i][0] 1); register_array(name, dims, dim_count, total_units); match(;); }这里容易$踩坑$的是数组元素的访问在FACTOR中出现时需要识别下标表达式。递归下降设计里一般把访问动作放在FACTOR函数中当读到标识符后再判断下一个 Token 是否为左中括号若是则递归分析下标表达式最后生成取数组元素的四元式。注意下标表达式的结果是一个值而数组元素是一个内存位置两者不能混用同一个符号表索引否则后面目标代码生成时无法区分寻址模式。4.3 if-else 与 while四元式中的跳转指令与回填控制流语句的本质是跳转而递归下降里最难的就是跳转地址回填。设计里用了一个常见的栈来记录待回填的四元式编号。先看 if-else 的处理方式void IF_STATEMENT() { match(KW_IF); EXPRESSION(); // 计算条件表达式 int jz_quad gen_quad(JZ, -1, -1, temp); // 条件为假时跳转 match(KW_THEN); STATEMENT(); // then 分支 int jmp_quad gen_quad(JMP, -1, -1, 0); // 无条件跳转 match(KW_ELSE); // 回填把 jz 的跳转目标指向这里 backpatch(jz_quad, current_quad_count()); STATEMENT(); // else 分支 backpatch(jmp_quad, current_quad_count()); }先看jz_quad生成时的跳转目标位置是未知的后续调用backpatch填充再看jmp_quad则要跳过整个 else 分支因此必须在解析 else 分支之前先生成这条跳转最后再回填。如果不回填四元式里的跳转地址为 0目标代码必然跳转错误轻则死循环重则访问非法内存地址这也是一个高频$翻车$点。while 语句采用同样的套路条件要求的四元式编号记下来无条件跳转回循环条件的起始位置当条件不成立时跳出循环体。设计的唯一区别是 while 不用生成 else 分支的回填只需要一个循环起始标记和两个跳转地址。四元式里跳转地址统一指四元式数组下标而不直接填物理地址这样后续做代码优化和调整指令顺序时可以随意移动四元式而不必同步改跳转目标这个设计值得吸收进自己的实现里。4.4 递归子程序栈边分析边展示调用情况设计里做了一件很讨巧的事每进入一个递归下降子程序就把函数名压入一个观察栈退出时弹出。同时在界面上显示当前 Token 和最近生成的四元式。这个栈的作用不是文法分析本身必需的而是为调试和展示服务的。void enter_proc(char *name) { call_stack[stack_top] name; printf(进入 %s当前单词: %s\n, name, token-text); } void exit_proc() { printf(退出 %s\n, call_stack[--stack_top]); }在每个分析函数的入口和出口处各调用enter_proc和exit_proc就能获得完整的调用序列。从教学角度看这个方法的价值在于让“递归下降”从抽象概念变成可见的过程输入一行a : b c * d能看到FACTOR被反复调用、压栈弹栈的完整路径。这个功能在答辩演示时效果相当直观。5. 避坑与排查从词法到四元式的常见失败模式5.1 单词切分时丢字符导致后续 Token 全部错位现象输入a10时词法分析输出的 Token 序列变成a、10中间的不见了语法分析直接报错。原因扫描器在进入一个单词的终态后多读了一个不属于当前单词的字符却没有执行ungetc回退导致这个字符永久丢失。最常见的是读完整数后把后续的也读进来做了一次字符分类判断然后直接丢弃。解决在从状态表中发现无法继续转移时调用ungetc(ch, src); break;把当前字符退回输入流。建议在每次终态退出分支里都检查一遍是否有多读或漏读用一条最简短的测试表达式验证例如a:b1;如果三个 Token 的类型都正确再测复杂表达式。5.2 四元式里的跳转地址没有回填现象程序解析if x0 then y:1 else y:2生成的中间代码执行时总是跳过某一条分支或者无条件跳转落到奇怪的位置。原因生成JMP和JZ四元式时跳转目标位置是未知的代码里跳过了backpatch调用或者回填时机滞后到所有四元式生成完之后导致地址偏了一位。解决严格遵守“先出四元式占位、后回填真实地址”的顺序。在 IF 语句开始处用int saved current_quad_count();记住位置然后在条件四元式生成后把需回填列表保存在一个栈里遇到相应结束标记时统一回填。验证方法是打印生成的四元式序列手工检查每条跳转指令的目标地址是否指向正确的下一个四元式编号。5.3 语法分析停在某个非终结符上进入死循环现象输入一个错误的表达式如a : b)程序不报错而是不断递归调用FACTOR和EXPRESSION界面上的递归子程序栈越来越长最终栈溢出。原因某个分支选择条件写得过宽使得当前 Token 既能进入某个非终结符分支又在分支内部不消耗 Token。例如EXPRESSION函数里判断当前 Token 是(就调用FACTOR但FACTOR发现)无法匹配直接返回然后在EXPRESSION层继续选择同样的分支形成无限循环。解决每个递归下降函数进入后先保存当前 Token 位置函数返回前检查 Token 是否被消费如果没消费且进入了同一函数立即报错退出。更严格的做法是给每个非终结符函数加一个递归深度计数器超过预设阈值比如 100 层直接抛异常终止分析。5.4 数组文法扩展后原有变量定义无法识别现象在VARIABLE函数中加入了arr分支后原来var a: integer;的代码开始报语法错误。原因新增分支时把VARIABLE()的起始判断逻辑写成if (token-type v)之类的错误比较方式导致var关键字落入con分支判断而con分支要求下一个 Token 是标识符匹配失败直接报错。解决文法分支判断必须基于 Token 类型不能基于字符首字母。在新增分支后用三组测试用例分别覆盖三种定义语句var开头、con开头、arr开头确保每个分支都能正确走通且互不干扰。其实还有一条更隐蔽的规则VARIABLE产生式存在递归——每个分支末尾又调用VARIABLE()这意味着当三种定义全部识别完必须保证程序能顺利退出递归方法是判断下一个 Token 是否为begin如果是就直接返回不进入变量定义分支。5.5 目标代码生成时数组下标访问的寻址模式出错现象生成的汇编代码对普通变量和数组元素采用同样的寻址方式导致数组元素访问时取到的是下标值本身而不是数组内容。原因符号表中没有记录名字到底是普通变量还是数组四元式里引用数组元素时没有附加下标信息目标代码生成器无法区分。解决在符号表里的每条符号记录上加一个种类字段kind取VAR或ARRAY。四元式中引用数组元素时操作数存数组符号表中的地址另附一个偏移量字段目标代码生成时遇 ARRAY 类型就把偏移量叠加到基址寄存器上。做完这些之后用一段包含数组定义的代码跑一遍对比手写的正确结果可以快速定位问题。6. 最后一步用递归子程序栈做全流程正确性验证拿到这份资源之后与其急着改功能不如先把整套流程跑通再动手扩展。我习惯的做法是先准备一段覆盖全部文法的测试代码写完后逐条分析执行路径并验证输出结果。考虑下面这段测试程序program demo; var a, b: integer; con price: real; arr board 3 of 10; begin a : 5; b : a * 2 1; if b 8 then b : board[1]; else b : 0; while a 3 do a : a 1; end.验证方法分三个步骤第一步看词法分析输出的 Token 序列检查program、var、con、arr、while这些关键字是否都被正确识别没有滑入标识符类别第二步在语法语义分析界面上运行上述代码观察递归子程序栈能否按预期的层次顺序压栈弹栈——具体来说进入while语句时栈上依次出现PROGRAM - SUB_PROGRAM - COM_SENTENCE - SEN_SEQUENCE - EVA_SENTENCE - EXPRESSION - ...的结构中途如果出现某个函数嵌套了两次以上且处于同一调用层级说明递归路径出了问题第三步检查生成的四元式序列中跳转指令的目标位置是否合理以及数组取元素的四元式是否正确地出现在赋值语句的右操作数位置。调试控制流语句实用的技巧是专门往代码里塞入嵌套的 if-else 和 while嵌套深度至少两层因为单层的跳转回填相对简单漏回填比较容易看出来而两层以上能暴露跳转目标计算中的偏移错误。有一次我在扩展 while 语句时把层数一加到三层条件跳转的目标全部指向同一个四元式排查后才发现是同一份回填栈被两个语句共用没有在语句结束时弹出栈帧。此后每写一个循环或分支扩展我都强制自己先跑一遍双层嵌套的测试用例再确认回填栈已经被清空。从整体上看这套设计的前端功能已经足够完整词法分析、递归下降、四元式生成、数组与流程控制扩展都覆盖到了。如果你正在做类似课程设计可以优先考虑在它的基础上替换可视化界面或者扩展自定义文法的用户接口。把理解递归子程序栈的运行方式作为第一步其余模块的定位就会清晰起来这正是这份资源最值得珍惜的部分。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站