编译原理实战:词法分析与算符优先语法分析源码详解
发布时间:2026/10/12 2:52:44 锦皓数字建站

简介《西南交大编译原理课程设计词法分析器与语法分析器》是一份面向计算机专业学生的课程设计报告适合正在学习编译原理、需要完成词法/语法分析器作业或想理解编译器前端实现细节的读者。报告完整呈现了词法分析器的结构设计包括源程序输入缓冲区、数据预处理子程序、扫描缓冲区、状态转换图并详细说明了全局变量与各功能子程序的实现思路如GETCHAR、GETBC、CONCAT、LETTER、DIGIT、RESERVE、RETRACT等。同时给出了保留字表的设计方法和基于C语言的程序实现帮助读者掌握将源代码拆分为词法单元、构造抽象语法树的基础流程。资源为单份docx文档大小443KB目前已147人学习浏览。通过对照报告中的源代码、函数说明与状态转换逻辑读者可以快速迁移到自己的编译原理课程设计中理解保留字识别、字符回送等关键机制为后续语法分析打下扎实基础。1. 编译原理实验资源拆解一份能跑通词法分析和算符优先语法分析的全套源码做编译原理课程设计最烦的不是写代码是理论课讲了一堆状态转换图、LL(1)、LR(1)真到上机时不知道从哪里下手。这份西南交大的编译原理课程设计报告正好落在“够用”的区间一段 C 语言词法分析器完整源码加一段 C 语言算符优先语法分析器完整源码。词法部分覆盖了保留字、标识符、常数和运算符的识别语法部分用算符优先分析法把赋值语句、输出语句、表达式求值、变量表管理串起来了。适合两类人一类是正在做编译原理实验、想要一份能直接跑通并讲清楚原理的参考实现另一类是工作后想快速回忆词法分析和算符优先分析全流程的从业者。下面按我拆项目的习惯把两份源码的逻辑、参数、坑和复现步骤逐一过一遍。2. 词法分析器实现拆解七个子程序、保留字表和字符缓冲区的取舍2.1 全局变量与保留字表种别码为什么从 0 编到 46词法分析器的核心数据只有四样ch存当前字符、strToken累积单词、buffer做输入缓冲区、Key数组做保留字表。原代码里这几个全局变量的初始化值得注意#define N 47 char ch\0; // 存放最新读进的源程序字符 char strToken[20]\0; // 存放构成单词符号的字符串 char buffer[257]\0; // 字符缓冲区 struct keyType{ char keyname[256]; int value; }Key[N]{{$ID,0},{$INT,1},{auto,2},{break,3},{case,4}, /* ...省略中间 38 项... */ {?,44},{clear,45},{#,46}};buffer申请了 257 个字节是按“256 个有效字符 1 个结束符”设计的。保留字表从$ID和$INT开始编号0 给标识符1 给整数常量2 到 33 给 C 语言关键字34 到 46 给运算符和特殊符号。这样设计的直接好处是词法分析器每次识别完一个单词只需要在Key数组里做一次strcmp命中就返回种别码不命中就是标识符。这里有个容易被忽略的设计取舍运算符 - * / % , ; ( ) ? #和关键字放在同一张表里。这意味着Reserve()函数既负责查关键字也负责查单字符运算符。肉眼看上去“保留字表”名不副实但对这份课程设计来说一张表解决单词到种别码的全部映射代码量最小教学上也更直观。代价在 2.3 节会看到但凡源程序里出现表外符号就会被误判成标识符。2.2 六个字符处理子程序GETBC、CONCAT、RETRACT 的协作逻辑词法分析器的主流程不复杂复杂的是字符怎么读、怎么拼、怎么回退。原报告设计了 6 个基础子程序对应到源码是下面这组函数void GetChar(){ int i; if(strlen(buffer)0){ chbuffer[0]; for(i0;i256;i) buffer[i]buffer[i1]; // 整体左移模拟取出队首字符 } else ch\0; } void GetBC(){ // 读一个非空白字符到 ch 中 int i; while(strlen(buffer)){ i0; chbuffer[i]; for(;i256;i) buffer[i]buffer[i1]; if(ch! ch!\nch!\0) break; } } void ConCat(){ // 把 ch 连接到 strToken 之后 char temp[2]; temp[0]ch; temp[1]\0; strcat(strToken,temp); } void Retract(){ // 把 ch 中的字符回送到缓冲区 int i; if(ch!\0){ buffer[256]\0; for(i255;i0;i--) buffer[i]buffer[i-1]; buffer[0]ch; } ch\0; }GetChar和GetBC都是“取字符 缓冲区左移”的操作区别只在GetBC会循环跳过空格和换行。这里有个细节GetBC判断的空白字符只写了空格和换行没有处理制表符\t所以测试文本里一旦出现 Tabch就会带着\t进入后续判断既不是字母也不是数字直接被else分支当成一个独立单词处理。后面避坑章节会再提到。Retract的回退只能回退一个字符且回退方式是把ch插到buffer最前面。为什么必须回退因为识别标识符时是“多看一个字符再判断”的读到字母后继续读直到下一个字符既不是字母也不是数字才知道单词结束这个“多读的字符”得还给缓冲区。这个单字符回退在教学实现里够用但遇到连续两个需要回退的场景就露馅了比如ab这类双目运算符会在识别a之后把回退再识别时又把回退逻辑上能跑但并不会被当成一个整体运算符而是被拆成和两个单词。2.3 ReturnWord 主流程一个单词从识别到种别码的完整路径词法分析器的核心函数是ReturnWord()每次调用返回一个keyType结构包含单词字符串和种别码。完整逻辑分三条路字母开头走标识符/保留字分支数字开头走常数分支其余字符走单字符符号分支。keyType ReturnWord(){ strcpy(strToken,\0); int c; keyType tempkey; GetBC(); // 跳过空白取第一个有效字符 if(chAchZ||chachz){ ConCat(); // 首字符入 strToken GetChar(); // 读下一个字符 while(Letter()||Digit()){ ConCat(); GetChar(); } Retract(); // 多读的那个字符退回缓冲区 cReserve(); // 查保留字表 strcpy(tempkey.keyname,strToken); if(c0) tempkey.value0; // 非保留字按标识符处理 else tempkey.valueKey[c].value; } else if(ch0ch9){ ConCat(); GetChar(); while(Digit()){ ConCat(); GetChar(); } Retract(); strcpy(tempkey.keyname,strToken); tempkey.value1; // 整数常量种别码固定为 1 } else { ConCat(); // 单字符运算符或界符 strcpy(tempkey.keyname,strToken); tempkey.valueReserve(); } return tempkey; }标识符分支里Reserve()返回的是Key数组下标而不是种别码所以后面还要再套一层Key[c].value。这里有个隐蔽的坑当Reserve()返回 0 时代码判断的是c0把它当成“非保留字”处理。可$ID的种别码也是 0于是“标识符”和“保留字表第一项”共用了一个 0 值。逻辑上说得通因为 0 就代表普通标识符但阅读代码时容易误以为Reserve()的返回值可以直接当种别码用实际上它在命中保留字时返回的是下标不命中时才返回 0。原报告给出的主函数测试输入是if(a0) abc;。按 2.1 节的保留字表一对照就能发现问题不在表里。于是会被else分支拼成单词Reserve()查不到返回 0最终以$ID的身份混进单词流。也就是说这份词法分析器自己附带的测试样例里就藏着一个未识别符号运行结果里会出现一个奇怪的 ID。课程设计能做到这个程度已经算完整但从“能不能用于真实词法分析”角度看保留字表和运算符表都只是 C 语言子集复现时要有这个心理预期。3. 算符优先语法分析器优先关系矩阵、归约栈和变量表的联调3.1 文法和优先关系矩阵1、-1、0、2、3 这些数到底表示什么语法分析器用的是算符优先分析方法对应文法如下S → vE | E? | clear E → ET | E-T | T T → T*F | T/F | F F → (E) | v | cv表示变量c表示常数?是输出语句的触发符号。算符优先分析的核心是优先关系矩阵原代码里定义了一个 14×14 的二维数组行和列都是单词种别码。矩阵值只用五个数0、1、-1、2、3。原报告没有逐项解释这些值的含义我按代码行为反推如下矩阵值MainHandle 中的行为实际语义1执行 Handle() 归约栈顶符号优先级高于当前输入需要归约其他0、-1、2、3将当前输入符号压入归约栈栈顶符号优先级不高于输入先移进0移进非法组合也先移进错误留到归约阶段暴露2移进主要用于括号对左右括号相遇时由后续逻辑处理3移进结束符场景的特殊标记也就是说这个实现把“归约还是移进”压缩成了一次! 1判断等于 1 就归约否则一律移进。这种简化是教学级的优先矩阵没有区分“错误”和“应归约”遇到非法输入串时移进会一直发生直到归约阶段在Handle()里打印“变量未定义”之类的提示。算符优先分析的经典做法是用、、三种关系做决策这里压缩成数值比较后代码短了但可读性也差了。我拆这份报告时花最多时间的反而是把这 14 行矩阵和wordType种别码表逐一对应起来。3.2 单词串、归约栈和变量表三条核心数据结构语法分析阶段有三条核心数据流wordStack装词法分析产出的单词串mainStack是归约栈varTable是变量表。三者互相配合输入串先整体转成单词串然后主循环不停从单词串取单词、压入归约栈、按优先矩阵决定移进还是归约归约过程中维护变量表。struct VarWord{ char varname[M]; char value[M]; bool flag; // 变量是否赋值 }; struct VarTable{ VarWord elem[M]; int len; } varTable; struct OperateStack{ WordType elem[M]; int len; }; OperateStack mainStack; // 归约栈 OperateStack wordStack; // 单词串VarWord的value是char数组这决定了表达式的求值方式不是用浮点或整型栈直接算而是每次归约完把结果转成字符串存回变量值。比如归约ba时Handle()里atoi()取出两边数值相加后再itoa()写回归约栈里的word。字符串作为变量值的载体好处是统一了变量查找和结果存储坏处是只能处理整型表达式遇到3.14或字符串类型就得改结构。课程设计面对的是简单赋值语句这个设计完全够用但后续想扩展时要清楚边界在哪。CheckvarTable()负责查变量AddvarTable()负责加变量。这里的变量表实现是线性数组没有做符号表通常该有的作用域嵌套。也就是说如果输入串里给同一个变量名赋两次值第二次赋值不会新增表项而是直接覆盖value字段这个行为是符合简单解释器预期的。3.3 Handle() 归约分支赋值、运算、输出、结束四种归约场景Handle()是语法分析器的核心按归约栈栈顶或栈顶附近元素的种别码分派逻辑。代码较长我按分支拆开看。常量和变量的归约最简单// 常量归约 if(mainStack.elem[mainStack.len-1].value10){ mainStack.elem[mainStack.len-1].value13; } // 变量归约 else if(mainStack.elem[mainStack.len-1].value9){ mainStack.elem[mainStack.len-1].value13; iCheckvarTable(mainStack.elem[mainStack.len-1].word); if(i0){ printf(\n 变量 %s 未定义,mainStack.elem[mainStack.len-1].word); return false; } else strcpy(mainStack.elem[mainStack.len-1].word,varTable.elem[i].value); }这两个分支做的事一样把常量和变量统一归约为$N种别码 13。变量归约时还要查变量表如果变量没定义就报错。注意这里是打印了错误并 return false但调用方并没有接管这个返回值这个缺陷在 4.3 节细说。赋值归约分支判断的是mainStack.elem[mainStack.len-2].value1也就是归约栈倒数第二个元素是号第三个元素是左值变量。处理方式是查变量表不存在就新增变量并记录值已存在则覆盖值然后把左值位置的单词改成右值的结果栈长度减 2。运算归约分支更直接value3对应号value5对应*号只实现了加法和乘法else if(mainStack.elem[mainStack.len-2].value3){ // 加法归约 int a,b; aatoi(mainStack.elem[mainStack.len-1].word); batoi(mainStack.elem[mainStack.len-3].word); aab; itoa(a,mainStack.elem[mainStack.len-3].word,10); mainStack.lenmainStack.len-2; }注意减法、除法虽然在文法里有ET | E-T | T*F | T/F但Handle()里只写了value3和value5两个运算分支。也就是说这份代码实际能正确求值的只有加减中的加法、乘除中的乘法。输入b-a时-号value4和/号value6不会触发任何归约分支最终在输出语句判断时发现栈顶不是$N程序直接静默结束。这是教材型代码常见现象文法写了一整套实现只覆盖了演示需要的子集。复现时想跑减法需要照着加法分支补一个value4的处理。3.4 MainHandle 主循环priority 判断为什么只看一个值主循环决定了整个分析过程的节奏bool MainHandle(){ while(wordStack.len) if(priority[mainStack.elem[mainStack.len-1].value][wordStack.elem[0].value]!1) AddmainStack(GetWord()); // 移进 else{ if(mainStack.len){ printf(\n); for(int i0;imainStack.len;i) printf((%s,%d),mainStack.elem[i].word,mainStack.elem[i].value); Handle(); } break; } if(mainStack.len) MainHandle(); return true; }每次循环取“归约栈栈顶元素的种别码”作为矩阵行取“单词串首元素的种别码”作为矩阵列。查矩阵结果不是 1 就把单词压栈结果是 1 就调用Handle()做一次归约。归约完成后break跳出当前while然后递归调用自身重新扫描。这样设计的好处是归约一次就重新决策一次避免在同一个循环里连续归约导致栈状态判断错位。打印语句在每一次归约前都会输出当前归约栈例如(#,12)(a,9)(,1)(5,10)。这个输出对调试极其有用能直观看到每次归约前栈里有什么、栈顶是谁。我在复现时基本靠这行输出判断“程序当前走到哪一步了”比打断点还快。原报告里测试a5时归约栈的打印形态和这个格式完全吻合说明这个递归扫描机制是稳定可靠的。但也正因为每次归约都break再递归递归深度受输入单词数影响遇到很长的表达式可能产生较深的调用栈不过课程设计场景碰不到这个极限。4. 编译原理课程设计常见问题与避坑五条真实翻车记录4.1 带空格的源程序进了 scanf 就断现象在语法分析器主函数里输入a 5程序只读到了a然后单词串只有一项归约栈卡在#和a上不报错也不结束之后输入的任何内容都进不了分析流程。原因scanf(%s, buffer)遇到空格就停止读取buffer里装的是第一段a后续的 5残留在键盘缓冲区里。原报告的测试样例if(a0) abc;虽然含分号和括号恰恰没有空格所以这段代码在原始场景下能跑通稍一改动输入格式就翻车。解决把读取方式换成gets(buffer)或者fgets(buffer, 257, stdin)。fgets会连空格一起读入注意它会保留末尾换行符读完后手动把\n清掉。词法分析器里的GetBC()已经能跳过换行所以这个处理对两套代码都适用。4.2 未知符号被词法分析器当成标识符现象输入a:b;这种带冒号的语句冒号:在保留字表里不存在。词法分析器把:拼成单词后Reserve()返回 0于是:被当成变量进入语法分析器的变量表随后报“变量未定义”。原因词法分析器else分支对所有非字母数字的字符无差别调用ConCat()和Reserve()Reserve()查不到就返回 0而 0 恰是$ID的种别码。也就是说“查不到的符号”和“普通标识符”被复用成了同一种码。解决最直接的做法是把要支持的运算符和界符全部补进Key表让所有字符都有明确种别码。若要做得更健壮在else分支里对Reserve()0的情况单独报“无法识别的字符”而不是默默生成一个标识符。参考真实编译器词法错误应当直接终止分析并给出行列号而不是把错误符号伪装成合法单词送进语法分析阶段。4.3 变量未定义报错后程序继续跑现象输入a1; b?故意先不给b赋值。程序打印“变量 b 未定义”但没有停下来后面可能继续打印乱七八糟的归约栈甚至输出一个错误的值。原因Handle()里变量归约分支返回了false但MainHandle()调用Handle()时没有检查返回值。错误被打印后就当没发生过归约栈继续推进后续操作全部建立在错误状态上。解决两处改动。一是Handle()返回false时在MainHandle()里直接return false并向上传递二是main()对GetwordStack()和MainHandle()的返回结果做判断失败就跳出主循环。原报告的代码对两个函数的返回值都没有接收这是结构性问题复现时建议先把错误传播链路补上。4.4 VC6.0 能编译的代码在 gcc 下报错现象拿到源码后用 gcc 编译报bool未定义、itoa未定义、getch未定义。原因这份代码是照着 Visual C 6.0 的环境写的。bool在 C89 标准里不是关键字VC6.0 对 C 和 C 的界限模糊能编译通过itoa是 Windows 平台的非标准函数gcc 里要换成sprintfgetch来自conio.hLinux 下没有这个头文件。解决bool直接改成int用0/1代替true/falseitoa(a, buf, 10)改成sprintf(buf, %d, a)getch()改成getchar()。改完这三处代码在 Linux 和 macOS 下都能编过。原报告里词法分析器主函数用getch()做“按任意键逐个输出单词”改成getchar()后要注意回车会被当一次按键所以要多按一次测试时别误以为程序卡住。4.5 缓冲区末尾空白字符导致空 token现象用fgets读入一行输入后末尾带\n。词法分析器读完最后一个有效字符后GetBC()在缓冲区只剩换行符时跳出循环ch里存的是\n。ReturnWord()的三条分支都不命中直接走else把\n当成一个单词返回产生一个空 token。原因GetBC()的循环条件是strlen(buffer)缓冲区读空就退出但退出时ch残留了最后一次读到的空白字符。ReturnWord()没有在GetBC()之后判断ch\0于是残留字符被当成有效单词。解决两处配合。一是在fgets读入后手动buffer[strcspn(buffer, \n)] \0去掉末尾换行二是在ReturnWord()的GetBC()之后加一句if(ch\0)直接返回空 token主循环里遇到空 token 就continue。我在复现时两处都改了才能保证连续输入多组测试语句不串场。5. 把这份课程设计跑起来编译环境、测试样例和预期输出5.1 源码整理与编译注意事项原报告是 docx 文档源码混杂在排版里直接复制会带上多余空格和全角符号。我一般先把两段代码分别存成lexer.c和parser.c用sed或编辑器清理行首行尾空白。核心函数不要动只改四类东西bool改int、itoa改sprintf、getch改getchar、scanf改fgets。# 词法分析器单独编译 gcc lexer.c -o lexer # 语法分析器单独编译 gcc parser.c -o parser词法分析器可以独立运行它会逐词打印单词和种别码语法分析器内部已经内嵌了词法分析函数直接编译即可。编译时如果报strcpy相关警告是ReturnWord()里strcpy(strToken,\0)的写法不够规范不影响运行。conio.h在 gcc 下不存在直接删掉包含行getch一并处理。5.2 词法分析器测试原始样例与补充样例原始测试输入if(a0) abc;中有一个未纳入保留字表的符号。第一次跑通时先按原样输入观察输出里出现(,0)这个异常单词能够帮助理解“表外符号全部落进标识符”的行为。确认机制后再补一组覆盖完整保留字的样例去验证核心功能# 输入 buffer: int x; if(x1) xx1; # 预期输出节选 int 18 x 0 ; 41 if 17 ( 42 x 0 0 # 不在表内会被拆成 和 两个单词被拆成两个也是这个设计的固有缺陷原报告没有覆盖这种多字符运算符。做课程设计答辩时能主动讲出这个缺陷并给出修复方向反而是加分项。5.3 语法分析器测试赋值、输出、表达式求值与错误触发语法分析器的测试输入从原报告里可以稳定复现三组场景。场景一变量赋值并输出输入串a5 单词串(a,9)(,1)(5,10)(#,12) 归约栈(#,12)(a,9)(,1)(5,10)归约过程中先对5做常量归约变成$N再对a做变量归约此时变量未定义按代码逻辑会报“变量 a 未定义”但如果先输入过a5第二次进入就不会报错。赋值归约完成后变量表里a5。场景二表达式求值ba10 b?b?里的?触发输出语句归约分支打印表达式的值为 15。如果b不在变量表里则打印变量 b 未定义。这里注意一个顺序问题必须先执行ba10再执行b?因为输出分支只认归约栈顶是$N而$N的来源是变量归约或常数归约。场景三错误触发c?c未定义变量归约时报错。原实现里这个错误不中断程序但可以看到归约栈打印停在(c,9)位置这也能帮你验证改完错误传播后的行为差异。5.4 对原报告的三个小修补跑通原始代码后我建议按下面三个方向做最小修补让两段代码的行为更接近真实编译器第一词法分析器主循环里遇到value0且strToken只有一个字符且该字符不是字母数字时直接打印“无法识别的字符”并跳过。第二语法分析器Handle()所有分支补上return true并在MainHandle()里检查Handle()返回值失败就退出递归。第三把、、、!这四个比较运算符补进Key表并为它们分配 47 到 50 的种别码。这三处做完原报告的两段代码就从“演示级”提升到了“能用一小段 C 语言子集做表达式求值”的程度。6. 把课程设计改造成能长期使用的简易解释器三个方向第一输入通道从固定字符串改成文件读取。原实现把buffer固定成一个全局字符数组主函数里strcpy写入这意味着输入只能是运行时敲进去的一行短代码。我一般会改成FILE *fp fopen(argv[1], r)用fgets循环读入文件行每读一行就调用一次GetwordStack()和MainHandle()这样就能批量分析多行程序。改的时候注意词法分析器的buffer是静态数组每次读入新行前要memset(buffer, 0, 257)否则上一行残留的字符会串到下一行。第二把itoa换成sprintf的同时顺便把值类型从字符串改成整型栈。原实现为了省事变量表的value用char数组存每次运算都做一次atoi再itoa。如果后续要加浮点数或布尔表达式这套字符串中转方案会很别扭。更合理的做法是变量表直接存int value归约栈的word字段只在调试打印时用这样加法归约就能写成a b的整数相加不再需要字符串转换。第三补全文法里的减法、除法和clear语句。减法归约复制加法分支改运算符即可除法要加一个除零判断clear语句在wordType里已经预留了种别码 11但Handle()里没有对应分支补一个清空变量表并重置栈的分支就能支持。做完这三步这份课程设计就从“答辩能过”变成了“真能算表达式的小工具”。从那以后我每拆一份课程设计源码都强制先跑一遍原始代码看它真实行为再对照报告里的“设计目的”逐条核对哪些实现了、哪些只是写了文字。很多报告看起来功能齐全实际能跑的只有 60%这份西南交大的报告能跑的部分已经算高比例了。希望这份拆解能帮你在复现时少走几个坑。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。