ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

电子科技大学编译原理实验代码:词法分析到代码生成完整实现

电子科技大学编译原理实验代码:词法分析到代码生成完整实现 简介这份资源是电子科技大学编译原理课程的实验代码合集面向正在学习编译原理、需要动手实现词法分析与语法分析的高校学生及自学者。内容围绕编译器前端核心模块展开包含词法分析器与语法分析器的完整实现涉及token识别、正则匹配、有限状态自动机、抽象语法树构建以及LL、LR等解析策略并配有运行说明文档与可执行程序便于对照验证理论知识的实际落地。资源包共21个文件以cpp与h源码为主另有pas示例、docx说明文档及vcxproj、sln等工程配置压缩包约203KB结构紧凑、模块划分清晰。目前已有2111人学习下载适合作为课程实验参考、满分代码研读与编译器开发入门的实践素材帮助读者理解输入处理、词法分析、语法分析乃至语义分析与代码生成各环节的衔接方式。1. 电子科技大学编译原理实验代码从词法分析到代码生成的完整拆解如果你正在修电子科技大学的编译原理课或者自学龙书想找一套能跑通的参考实现这套实验代码大概率能帮你省下几十个小时的调试时间。编译原理这门课最坑的地方在于理论课上讲的正规式、LL(1)、LR(1)、语法制导翻译落到代码里全是状态机、栈操作和内存管理中间隔着一道巨大的鸿沟。这套代码覆盖了词法分析、语法分析、语义分析、中间代码生成这几个核心实验环节用 C/C 或 Java 实现适合正在做实验但卡在某个环节、或者想对照参考实现理解算法细节的人。它不是那种只贴伪代码的课件而是能直接编译运行的工程代码每个实验模块都有明确的输入输出约定。2. 实验代码的模块划分与核心数据结构2.1 词法分析器从正则表达式到 DFA 的落地词法分析是整个编译器的入口任务是把源代码字符流切分成有意义的 token 序列。电子科大实验通常要求支持关键字、标识符、常数、运算符和界符五类 token并且要能处理注释和空白字符。核心思路是先用手写状态机或者正则表达式描述每种 token 的模式再转换成确定有限自动机DFA来识别。我一般会先把所有 token 的正规式写出来比如标识符是letter(letter|digit)*无符号整数是digit(digit)*然后手工构造 NFA 再确定化成 DFA。但实际写代码时更常见的做法是直接写一个nextToken()函数用switch-case加while循环逐字符扫描。下面是一个典型的词法分析器骨架// lexer.c - 词法分析器核心扫描逻辑 #include ctype.h #include string.h Token nextToken() { Token tok; // 跳过空白字符和注释 while (isspace(ch)) advance(); if (ch /) { advance(); if (ch /) { // 单行注释 while (ch ! \n ch ! EOF) advance(); return nextToken(); // 递归跳过注释后重新取token } else if (ch *) { // 多行注释 advance(); while (!(ch * peek() /) ch ! EOF) advance(); advance(); advance(); // 跳过 */ return nextToken(); } // 不是注释是除号 tok.type TOKEN_DIV; strcpy(tok.value, /); advance(); return tok; } if (isalpha(ch)) { // 标识符或关键字 int len 0; while (isalnum(ch) || ch _) { tok.value[len] ch; advance(); } tok.value[len] \0; tok.type lookupKeyword(tok.value); // 查关键字表 return tok; } if (isdigit(ch)) { // 数字常量 int len 0; while (isdigit(ch)) { tok.value[len] ch; advance(); } tok.value[len] \0; tok.type TOKEN_NUM; return tok; } // 运算符和界符处理... return tok; }这段代码的关键在于advance()和peek()两个辅助函数前者推进读取位置后者预读下一个字符但不移动位置。lookupKeyword用哈希表或简单的字符串数组遍历实现把标识符和关键字区分开。参数方面ch是当前字符peek()返回下一个字符用于处理/*和*/这种双字符符号。常见坑是注释嵌套和字符串里的转义字符处理实验代码里一般不做嵌套注释支持但字符串里的\必须正确处理。2.2 语法分析器递归下降与 LL(1) 的取舍语法分析实验通常要求实现 LL(1) 分析法或者递归下降分析法。电子科大的实验指导书一般会给定一个简化的文法比如赋值语句、if-else、while 循环和表达式。递归下降写起来直观每个非终结符对应一个函数但需要处理左递归问题LL(1) 需要构造预测分析表代码更规整但前期准备工作多。我建议先用手写递归下降把框架搭起来因为调试方便出错时调用栈直接告诉你哪个非终结符出了问题。等逻辑跑通了再考虑改成表驱动的 LL(1) 分析器。下面是表达式语法分析的递归下降实现// parser.c - 表达式递归下降分析 // 文法: expr - term ((|-) term)* // term - factor ((*|/) factor)* // factor - NUM | ID | ( expr ) ASTNode* parseExpr() { ASTNode* left parseTerm(); while (currentToken.type TOKEN_PLUS || currentToken.type TOKEN_MINUS) { Token op currentToken; advanceToken(); // 消费运算符 ASTNode* right parseTerm(); ASTNode* node createBinOpNode(op.type, left, right); left node; // 左结合新节点作为下一轮的左操作数 } return left; } ASTNode* parseTerm() { ASTNode* left parseFactor(); while (currentToken.type TOKEN_MUL || currentToken.type TOKEN_DIV) { Token op currentToken; advanceToken(); ASTNode* right parseFactor(); left createBinOpNode(op.type, left, right); } return left; } ASTNode* parseFactor() { if (currentToken.type TOKEN_NUM) { ASTNode* node createNumNode(atoi(currentToken.value)); advanceToken(); return node; } else if (currentToken.type TOKEN_ID) { ASTNode* node createIdNode(currentToken.value); advanceToken(); return node; } else if (currentToken.type TOKEN_LPAREN) { advanceToken(); ASTNode* node parseExpr(); if (currentToken.type ! TOKEN_RPAREN) { error(缺少右括号); } advanceToken(); return node; } error(意外的token); return NULL; }这段代码用 AST抽象语法树作为中间表示每个parseXxx函数返回一个 AST 节点。advanceToken()从词法分析器取下一个 tokencreateBinOpNode创建二元运算节点。参数方面currentToken是全局的当前 tokenleft和right分别代表左右子树。递归下降的坑在于左递归消除比如expr - expr term必须改写成expr - term ( term)*否则会无限递归导致栈溢出。另一个坑是错误恢复实验代码通常只做简单的报错退出但实际编译器需要同步到下一个分号或右括号继续分析。2.3 语义分析与符号表类型检查与作用域管理语义分析阶段要做的事情包括建立符号表、检查变量是否声明、类型是否匹配、函数调用参数是否一致。电子科大实验一般要求实现一个简单的符号表支持作用域嵌套和变量类型记录。符号表的实现方式有线性表、二叉搜索树和哈希表三种实验规模用哈希表就够了。// symtab.c - 符号表哈希实现 #define HASH_SIZE 211 // 取素数减少冲突 typedef struct Symbol { char name[64]; Type type; // 变量类型 int scopeLevel; // 作用域层级 struct Symbol* next; // 哈希冲突链 } Symbol; Symbol* hashTable[HASH_SIZE]; int hash(const char* name) { unsigned int h 0; while (*name) { h h * 31 *name; // 经典字符串哈希 } return h % HASH_SIZE; } void insertSymbol(const char* name, Type type, int scope) { int idx hash(name); Symbol* sym (Symbol*)malloc(sizeof(Symbol)); strcpy(sym-name, name); sym-type type; sym-scopeLevel scope; sym-next hashTable[idx]; // 头插法 hashTable[idx] sym; } Symbol* lookupSymbol(const char* name, int currentScope) { int idx hash(name); Symbol* sym hashTable[idx]; while (sym ! NULL) { if (strcmp(sym-name, name) 0 sym-scopeLevel currentScope) { return sym; // 找到最近作用域的声明 } sym sym-next; } return NULL; // 未声明 }哈希函数用h * 31 c是常见做法31 是经验素数能减少冲突。insertSymbol用头插法把新符号挂到链表头部lookupSymbol从当前作用域往外层查找。参数scopeLevel记录变量声明时的作用域层级查找时只返回层级小于等于当前层级的符号。坑在于作用域退出时要删除该层级的符号否则内层变量会泄漏到外层。实验代码里通常用一个栈来记录每层插入的符号退出作用域时批量删除。3. 从语法树到中间代码三地址码生成实战3.1 三地址码的格式与生成规则中间代码生成是连接前端和后端的桥梁电子科大实验一般要求生成三地址码Three-Address Code每条指令最多包含一个运算符和三个操作数。常见形式是四元式(op, arg1, arg2, result)比如a b c生成(, b, c, t1)和(, t1, _, a)。四元式的好处是结构统一便于后续优化和目标代码生成。生成三地址码的过程本质上是遍历 AST对每个节点生成对应的指令序列。表达式节点生成计算指令并把结果存入临时变量赋值节点把右值赋给左值控制流节点生成跳转指令。下面是一个简化的四元式生成器// codegen.c - 四元式生成 typedef struct Quad { char op[8]; // 运算符 char arg1[32]; // 第一操作数 char arg2[32]; // 第二操作数 char result[32]; // 结果 } Quad; Quad quadList[1024]; int quadCount 0; int tempCount 0; // 临时变量计数器 char* newTemp() { char* name (char*)malloc(16); sprintf(name, t%d, tempCount); return name; } void genQuad(const char* op, const char* a1, const char* a2, const char* res) { strcpy(quadList[quadCount].op, op); strcpy(quadList[quadCount].arg1, a1); strcpy(quadList[quadCount].arg2, a2); strcpy(quadList[quadCount].result, res); quadCount; } // 遍历AST生成四元式 char* genCode(ASTNode* node) { if (node-type NODE_NUM) { return node-value; // 常量直接返回 } if (node-type NODE_ID) { return node-name; // 变量直接返回 } if (node-type NODE_BINOP) { char* left genCode(node-left); char* right genCode(node-right); char* temp newTemp(); const char* op opToString(node-op); genQuad(op, left, right, temp); return temp; } if (node-type NODE_ASSIGN) { char* rhs genCode(node-right); genQuad(, rhs, _, node-left-name); return node-left-name; } return NULL; }newTemp()每次生成一个新的临时变量名genQuad把四元式追加到全局数组。genCode递归遍历 AST遇到二元运算先递归生成左右子表达式的代码再把结果用一条四元式合并。参数op是运算符字符串arg1和arg2是操作数result是存放结果的变量或临时变量。坑在于临时变量的生命周期管理如果不在基本块结束时释放临时变量四元式数量会爆炸。实验代码里通常不做优化但至少要保证临时变量编号不重复。3.2 控制流语句的翻译if-else 与 while 的回填技术控制流语句的翻译比表达式复杂因为跳转指令的目标地址在生成时可能还不知道需要用到回填backpatching技术。以if (E) S1 else S2为例先生成 E 的代码然后生成条件跳转指令但跳转目标暂时留空等 S1 和 S2 的代码生成后再回填。// 回填技术实现 if-else void genIf(ASTNode* node) { // 生成条件表达式代码 char* cond genCode(node-cond); // 生成条件跳转目标暂空 int jumpFalseIdx quadCount; genQuad(jf, cond, _, ?); // 条件为假时跳转 // 生成 then 分支代码 genCode(node-thenBranch); // then 分支结束后跳过 else 分支 int jumpEndIdx quadCount; genQuad(j, _, _, ?); // 回填 jf 的目标为 else 分支起始位置 char buf[16]; sprintf(buf, %d, quadCount); strcpy(quadList[jumpFalseIdx].result, buf); // 生成 else 分支代码 if (node-elseBranch ! NULL) { genCode(node-elseBranch); } // 回填 j 的目标为整个 if-else 结束位置 sprintf(buf, %d, quadCount); strcpy(quadList[jumpEndIdx].result, buf); }jumpFalseIdx记录条件跳转指令在四元式数组中的下标genQuad(jf, cond, _, ?)生成一条条件为假时跳转的指令目标地址先填?。等 else 分支代码生成完毕后quadCount就是 else 分支的起始位置把它回填到jumpFalseIdx对应的四元式中。jumpEndIdx类似记录 then 分支末尾的无条件跳转目标回填为整个 if-else 的结束位置。参数cond是条件表达式的结果变量buf用于把整数地址转成字符串。坑在于嵌套 if-else 时回填顺序容易搞错建议画图标记每个跳转指令的目标位置。4. 实验代码的编译运行与调试方法4.1 环境配置与编译命令电子科大实验代码通常提供 Makefile 或 CMakeLists.txt在 Linux 环境下用 gcc 或 g 编译。如果拿到的是纯源码没有构建脚本可以手动编译。下面是一组常见的编译命令# 编译词法分析器 gcc -o lexer lexer.c main.c -I./include -Wall -g # 编译语法分析器依赖词法分析器 gcc -o parser parser.c lexer.c ast.c main.c -I./include -Wall -g # 编译完整编译器 gcc -o compiler lexer.c parser.c symtab.c codegen.c main.c -I./include -Wall -g # 运行测试 ./compiler test/test1.c output.quad-I./include指定头文件搜索路径-Wall开启所有警告-g生成调试信息方便 gdb 调试。如果代码用 C 写把gcc换成g链接时注意 C 和 C 混合编译要用extern C。常见坑是头文件重复包含导致重定义用#ifndef守卫或者#pragma once解决。4.2 用 gdb 定位段错误与逻辑错误编译原理实验代码最容易出的问题是段错误Segmentation Fault通常是因为空指针解引用或者数组越界。用 gdb 可以快速定位# 启动调试 gdb ./compiler # 在 gdb 中运行 (gdb) run test/test1.c # 段错误发生后查看调用栈 (gdb) backtrace # 查看具体变量值 (gdb) frame 2 (gdb) print currentToken (gdb) print node-typebacktrace显示函数调用栈frame N切换到第 N 层栈帧print查看变量值。如果错误发生在递归下降分析器里调用栈会很长从最内层往外看找到第一个参数异常的帧。另一个常用技巧是在可疑位置加assert断言比如assert(node ! NULL)让程序在出错点立即停止而不是继续跑飞。提示如果实验代码在 Windows 下用 Visual Studio 编译段错误会表现为访问冲突异常用 VS 的调试器查看调用堆栈效果一样。5. 避坑与常见问题排查5.1 词法分析阶段注释处理和字符转义现象程序在遇到//注释时把后面的代码也吞掉了或者字符串里的\导致词法分析器提前结束字符串。原因单行注释的结束条件是换行符但有些代码文件用\r\n换行只判断\n会导致\r被当作普通字符。字符串转义处理时遇到\后没有跳过下一个字符导致\被识别为字符串结束。解决注释结束判断同时检查\n和\r或者统一把\r过滤掉。字符串扫描时遇到\先advance()再advance()跳过转义字符。5.2 语法分析阶段左递归导致栈溢出现象程序在分析表达式时崩溃gdb 显示调用栈有几千层parseExpr。原因文法写成expr - expr term递归下降分析器直接照搬每次调用parseExpr都先递归调用自己永远不消费 token。解决消除左递归改写成expr - term ( term)*用循环代替递归。或者改用 LL(1) 预测分析表表驱动方式天然没有左递归问题。5.3 语义分析阶段符号表作用域泄漏现象内层作用域声明的变量在外层也能访问到类型检查通过但运行时行为异常。原因符号表只插入不删除退出作用域时没有清理该层级的符号。解决用一个栈记录每层作用域插入的符号退出时弹出并删除。或者给每个符号打上作用域层级标记查找时只返回层级小于等于当前层级的符号。5.4 代码生成阶段临时变量命名冲突现象生成的四元式中出现两个不同的临时变量同名导致后续优化或解释执行时结果错误。原因临时变量计数器在递归调用中被重置或者多个函数各自维护独立的计数器。解决把临时变量计数器设为全局变量所有生成临时变量的地方统一调用newTemp()。如果支持多函数编译在函数名前加前缀区分。5.5 编译链接阶段头文件路径和库依赖现象编译时报undefined reference to lookupKeyword或fatal error: lexer.h: No such file or directory。原因头文件搜索路径没设置或者源文件没有一起编译链接。解决用-I指定头文件目录把所有.c文件列在编译命令里或者写 Makefile 自动处理依赖关系。6. 进阶技巧用测试用例驱动实验代码的验证实验代码写完后怎么确认它真的正确我的习惯是准备一组覆盖边界情况的测试用例从简单到复杂逐步验证。下面是我常用的测试用例分类和对应的验证方法测试类型输入示例预期输出验证重点空输入空文件无token无报错边界处理纯注释// hello无token注释跳过简单表达式a 1 2;四元式序列基本代码生成嵌套括号((ab)*c)-d;正确优先级递归下降空语句;;;跳过空语句错误恢复未声明变量x y 1;报错y未声明符号表查找类型不匹配int a; a str;报错类型错误类型检查嵌套if-elseif(a) if(b) x1; else x2;正确回填回填逻辑验证方法上我一般会写一个简单的脚本对比输出和预期#!/bin/bash # run_tests.sh - 批量测试脚本 PASS0 FAIL0 for testfile in tests/*.c; do expectedtests/$(basename $testfile .c).expected actual/tmp/$(basename $testfile .c).actual ./compiler $testfile $actual 21 if diff -q $expected $actual /dev/null; then echo PASS: $testfile PASS$((PASS1)) else echo FAIL: $testfile diff $expected $actual FAIL$((FAIL1)) fi done echo 通过: $PASS, 失败: $FAIL这个脚本遍历tests/目录下所有.c文件运行编译器并把输出和.expected文件对比。diff -q只返回是否有差异不输出具体内容如果需要看差异详情去掉-q参数。参数$testfile是当前测试文件路径$actual是实际输出临时文件。坑在于错误信息里可能包含文件路径或行号不同环境下输出不一致建议在对比前用sed把路径替换成固定字符串。从那以后我每次写完一个实验模块都强制先跑一遍边界测试用例确认空输入、纯注释、未声明变量这些情况不会让程序崩溃再去看正常输入的结果对不对。这套习惯帮我省下了大量在深夜调试段错误的时间。希望帮到你。本文还有配套的精品资源点击获取
返回列表