简介:这是一份基于Pascal文法的编译器课程设计完整工程资源,面向编译原理课程学生、毕业设计人员及希望动手实现编译器的开发者,用于理解词法分析、语法分析、语义检查与代码生成的整体实现流程。资源围绕Pascal语言形式文法展开,从符号分解到语句规约,再到类型检查和目标汇编输出,均有对应源码与说明;同时配有巴科斯范式规则、标识符与常量定义、函数过程、控制结构等理论要点,便于对照代码逐层掌握编译器构建方法。压缩包共8.18MB,包含140个文件,其中C与C++源代码、汇编输出展示核心逻辑,构建脚本可重建工程,可执行程序方便直接运行验证,文档梳理设计步骤与扩展思路,工程配置文件保留完整开发环境。已有288人学习下载。资源还覆盖课程设计常见扩展要求,如条件语句、循环语句、类型定义、过程与函数及嵌套定义,能帮助完成Pascal编译器课设,并加深对编译原理各环节的实际理解。附带完整Pascal文法说明与编译器设计步骤,清晰呈现从规则定义、解析算法到语义处理、代码生成的完整链路。
1. 基于 Pascal 文法的编译器:课程设计源码包里最该先看的那行汇编输出
解压这份资源之后,第一眼看到 target.asm 的人,多半会以为它是某个项目的编译产物,其实它本身就是这个课程设计要交付的核心成果。基于 Pascal 文法的编译器是一套手写 C 实现:词法、语法、语义、代码生成四条链路齐全,目标是把 Pascal 子集源码翻译成 x86 系汇编文本,而不是止步于教材里的状态机图。它适合正在做编译原理课程设计、需要一份能跑通整体骨架的学生,也适合想搞清楚「编译器」和「编辑器」到底差在哪的初学者——编辑器只做文本编辑,编译器做的是从文法到机器可执行文本的全套换算。Pascal 文法在这个场景下的价值很明确:语法严谨、类型系统完整、有过程和函数的嵌套定义,难度刚好卡在能做出完整度又不至于陷进黑匣子的位置。下面我们从工程实物开始拆,把每条链路对应到文件,再把坑提前标出来。
2. 工程结构解读:从文件清单反推编译器架构
2.1 解压后先认清这些文件:哪些是主干,哪些是噪音
拿到压缩包,先别急着把整个目录拖进 IDE。这份工程的文件名长得很「事故现场」,但恰好能反推出作者的开发路径。我按角色把它们分成三类。
| 文件 | 角色 | 处理建议 |
|---|---|---|
| complier.cbp | Code::Blocks 工程文件,作者的主开发环境 | 保留,用 Code::Blocks 打开它 |
| target.asm | 代码生成器输出的汇编文本,编译器的最终产物 | 保留,验证编译器是否正确工作的核心证据 |
| todo.bat | 作者留的一键脚本或待办入口 | 右键用文本编辑器打开,确认内容后再决定要不要运行 |
| CMakeCCompilerId.c、feature_tests.c、CMakeDetermineCompilerABI_C.bin、CMakeCXXCompiler.cmake、cmake.check_cache 等 | CMake 自动生成的探测文件 | 可以删除,不影响代码运行 |
需要注意一个细节:包里有 CMakeCCompilerId.c 和 feature_tests.c 这类 CMake 探测文件,说明作者至少在某台机器上跑过 CMake 构建,但没有清理缓存就把整个目录打进压缩包了。这不算坏事,反而提醒你:编译时不要做全目录添加文件,否则这些探测文件会被一起编译。它们里面可是有 main 函数或特殊入口的,会成为后面提到的翻车点。
按我自己的习惯,拿到这种包第一件事是先用 Code::Blocks 打开 complier.cbp,确认<Target>里的 Output 字段指向可执行文件,然后直接 Build。这个 cbp 文件名拼写了 compiler 少一个 p,是作者原样命名的,不要手痒去改,除非你把工程引用一并改了。
2.2 词法分析器的落地方式:手写扫描器而不是 LEX 生成的代码
工程里没有 lex.yy.c、y.tab.c 这类 LEX/YACC 产物,也没有 ANTLR 生成的 Java 文件,说明作者用的是手写词法分析器加递归下降解析器。这条路对课程设计是最稳的:不依赖生成工具版本,调试时能单步跟踪每个字符的消费过程。
词法分析器的核心工作是把字符流切成 token 流。Pascal 的 token 类别比 C 要多一点,因为关键字多,还有:=、<=、>=、<>、..这种两字符操作符。常见做法是维护一个关键字查找表,先按最长匹配读一个单词,查表判断是关键字还是用户标识符。下面是一个 getToken 的骨架,很多手写编译器都按这个模式组织:
typedef enum { TOK_EOF, TOK_IDENT, TOK_NUMBER, TOK_STRING, TOK_BEGIN, TOK_END, TOK_IF, TOK_THEN, TOK_ELSE, TOK_WHILE, TOK_DO, TOK_VAR, TOK_CONST, TOK_TYPE, TOK_ASSIGN, TOK_EQ, TOK_NEQ, TOK_LT, TOK_LE, TOK_GT, TOK_GE, TOK_PLUS, TOK_MINUS, TOK_STAR, TOK_SLASH, TOK_LPAREN, TOK_RPAREN, TOK_SEMI, TOK_DOT, TOK_DOTDOT, TOK_COLON, TOK_COMMA } TokenType; typedef struct { TokenType type; char lexeme[256]; int line; int col; } Token; int getToken(const char* src, int* pos, Token* tok) { skipWhitespaceAndComments(src, pos); // 处理空格、换行、{ } 和 (* *) 注释 if (src[*pos] == '\0') { tok->type = TOK_EOF; return 0; } if (isalpha(src[*pos])) { int start = *pos; while (isalnum(src[*pos])) (*pos)++; int len = *pos - start; strncpy(tok->lexeme, src + start, len); tok->lexeme[len] = '\0'; tok->type = lookupKeyword(tok->lexeme); // 查关键字表 return 1; } if (isdigit(src[*pos])) { /* 数字解析,Pascal 里实数和整数都用同一套 */ return parseNumber(src, pos, tok); } /* 两字符操作符优先判断,比如 := 要在 : 单独出现之前拦截 */ return parseOperator(src, pos, tok); }这段代码里有几个关键点。lookupKeyword 查表决定 token 类型,Pascal 关键字如 BEGIN、IF、WHILE 都在表里,查不到就返回 TOK_IDENT。parseOperator 里必须先判断两字符操作符,比如读到:后要看后一个字符是不是=,如果是=才返回 TOK_ASSIGN,否则返回 TOK_COLON。..和.也是同样的逻辑。这个先两字符后单字符的匹配顺序是词法分析最常见的坑,顺序反了,:=会被拆成:和=,后面语法分析直接乱套。
我一般还会把 line 和 col 记录在 token 里,因为课程设计答辩时,老师一定会问「你的编译器报错能定位到行列吗」,有这个字段,错误信息就能写成line 12, col 5: unexpected token,比只报一行字符串看着专业得多。
2.3 递归下降语法分析器:文法到 AST 的映射方法
既然没有 YACC,语法分析基本是递归下降。Pascal 文法转成 EBNF 之后,每个非终结符对应一个解析函数,函数返回一个 AST 节点。比如语句的解析会写成下面这种结构,我在多个课程设计里都见过类似实现:
typedef struct AstNode { int nodeType; // 节点类型:IF_STMT / WHILE_STMT / ASSIGN_STMT / EXPR ... struct AstNode* cond; // 条件表达式节点 struct AstNode* thenBranch; struct AstNode* elseBranch; struct AstNode* body; // while 循环体 char ident[64]; // 赋值语句或声明里的变量名 int line, col; // 出错定位用 } AstNode; AstNode* parseIf(void) { AstNode* node = allocNode(); node->nodeType = IF_STMT; expect(TOK_IF); node->cond = parseExpression(); expect(TOK_THEN); node->thenBranch = parseStatement(); if (peekToken()->type == TOK_ELSE) { nextToken(); node->elseBranch = parseStatement(); } return node; } AstNode* parseStatement(void) { Token* t = peekToken(); switch (t->type) { case TOK_IF: nextToken(); return parseIf(); case TOK_WHILE: nextToken(); return parseWhile(); case TOK_BEGIN: nextToken(); return parseCompoundStmt(); default: nextToken(); return parseAssignStmt(); } }parseIf 的逻辑对应 Pascal 的if cond then stmt [else stmt],注意 else 分支是可选的,要用 peekToken 看一眼,不能直接 expect(TOK_ELSE),否则if a then b;这种无 else 的语句会报错。parseStatement 是递归下降的调度中心,它根据当前 token 决定走哪个分支,这就是为什么词法分析器必须给出准确的 token 类型,一旦 token 分错,这里就全乱了。
递归下降对左递归文法是敏感的,比如表达式a + b + c如果写成expr ::= expr + term,直接翻译会让 parseExpression 无限递归。常见的消法是把左递归转成右递归循环,或者用 Pratt 解析处理运算优先级。课程设计里我建议先把加减乘除的优先级写成 EBNF 的层级关系,比如expr -> term { (+|-) term }、term -> factor { (*|/) factor },然后按这个层级写解析函数。这份资源里 if/while 的 AST 节点定义是齐的,你照着扩展 case 或 repeat 语句时,只要遵循同一套节点结构就行。
3. 完整构建链路:从 Pascal 源码跑到 target.asm 的每一步
3.1 先跑通最小流程:构建命令、输入输出约定
拿到工程的第一步是把它跑起来。打开 complier.cbp 之前,先看一眼 todo.bat 写的什么,如果它只是echo提示,那就直接在 Code::Blocks 里 Build。假设源码按常见课程设计拆法分成 lexer.c、parser.c、semantic.c、codegen.c 和 main.c,手动构建的命令大致是这样:
cd /d 源码解压目录 gcc -std=c11 -O0 -Wall -g lexer.c parser.c semantic.c codegen.c main.c -o pascalc.exe pascalc.exe test.pas type target.asm这里的-O0是刻意关掉优化,因为编译器课程设计生成的汇编是给人看的,优化一开,寄存器分配和指令顺序会变得难以对应源代码。-g是为了你自己调试时能用 GDB 单步跟踪。pascalc.exe 运行后,读入 test.pas,在源码同目录下输出 target.asm。如果你的环境没有 GCC,用 Code::Blocks 自带的 MinGW 也一样,记住了在这个阶段别引入额外工具链。
如果你更习惯命令行,也可以用 MSYS2 的 GCC 或 VS Code 配编译器插件来跑,但要注意这份工程是 Code::Blocks 组织的,VS Code 直接打开 cbp 没用,你得拿 gcc 手动编译源码文件。我没法确认 main.c 的确切文件名,但用法一样:把所有.c文件都作为输入传给 gcc,让链接器自己找入口。
3.2 中间表示与汇编文本:为什么直接发射 asm 而不是先做 IR
很多教材把代码生成分成两派:一派先生成三地址码或四元式等中间表示,再翻译成汇编;另一派直接在语义分析完成后就往输出缓冲区里写汇编文本。这份资源从文件命名看走的是后者——目标就是一个 target.asm 文本文件。直接发射汇编的缺点是优化空间小,但课程设计阶段的优势非常实在:你能直观看到每条 Pascal 语句映射成了什么指令,答辩时老师问「while 怎么实现的」,你直接翻汇编给他看。
代码生成器一般是一个 emit 函数簇,每个函数向全局缓冲区追加一行汇编文本。常见做法是:
static int labelCounter = 0; int newLabel(void) { return ++labelCounter; } void emitLabel(int id) { fprintf(out, "L%d:\n", id); } void emitOp(const char* op, const char* dst, const char* src) { fprintf(out, "\t%s %s, %s\n", op, dst, src); } void emitJump(const char* cond, int label) { fprintf(out, "\t%s L%d\n", cond, label); }这个 emit 层的参数设计有一个容易被忽略的点:newLabel 的计数器必须全局唯一,不能在每次生成语句时从 0 开始。否则外层 if 生成 L3、L4,内层 while 又生成 L3、L4,跳转目标全乱。我见过不少翻车案例都是标签重复导致的,后面避坑章节还会细说。
至于目标汇编的格式,看生成的是 Intel 风格还是 AT&T 风格,这取决于作者在 emit 函数里怎么拼接。GCC 自带的 as 默认 AT&T,NASM 用 Intel,后续你需要把 target.asm 再汇编成目标文件时,先确认这一点,否则编译器会把mov eax, ebx和mov %ebx, %eax混着当合法指令。
3.3 if 和 while 的汇编跳转模式:一张对照表说清控制流
条件语句的代码生成有固定套路,归纳起来是这么几个模板。假设条件表达式已经算好,结果放在某个临时变量或寄存器里:
| 源语句 | 汇编结构 |
|---|---|
| if cond then S1 | 计算 cond;条件为假跳 L_end;执行 S1;L_end: |
| if cond then S1 else S2 | 条件为假跳 L_else;执行 S1;跳 L_end;L_else: 执行 S2;L_end: |
| while cond do S | L_loop: 计算 cond;条件为假跳 L_end;执行 S;跳 L_loop;L_end: |
| repeat S until cond | L_loop: 执行 S;计算 cond;条件为假跳 L_loop;L_end: |
while 的标准结构有个重要特征:循环体执行完后必须有一条无条件跳转跳回 L_loop 重新判断条件。很多人第一次写会漏掉这条向后跳,结果变成循环体只执行一次。repeat-until 则反过来了,它没有开头判断,先执行 S,再计算 cond,cond 为假就跳回 L_loop,为真就落到 L_end。这个模板在后续扩展语法时可以直接复用。
参考 while 的代码生成函数:
void genWhile(AstNode* node) { int loopLabel = newLabel(); int endLabel = newLabel(); emitLabel(loopLabel); // 循环入口 genExpr(node->cond); // 生成计算 cond 的指令,结果放 regA emitJump("JZ", endLabel); // 条件为假跳出循环 genStmt(node->body); // 循环体 emitJump("JMP", loopLabel); // 无条件跳回条件判断 emitLabel(endLabel); // 循环出口 }JZ 是通用记法,实际发射时可能是jz,语义是上一条比较指令结果为 0 时跳转。注意 genExpr 之后发射的指令里,寄存器 regA 会保存 cond 的计算结果。这里的约定必须前后一致:词法分析不关心寄存器,但代码生成里每个节点用什么寄存器、临时变量放哪个 slot,这些都是全局约定,改一处就要全部同步。课程设计阶段最忌在代码生成器里做寄存器分配优化,先用几个固定寄存器把语义做对。编译器优化那是后话,现在谈 JIT 和优化属于给自己加戏,先把mov和jmp对齐了再说。
4. 语义分析与类型检查:让 target.asm 不只是「看着像对的」
4.1 符号表与作用域:Pascal 嵌套过程是怎么查变量的
Pascal 允许过程/函数嵌套定义,这在 C 里没有对应物。因此符号表不能是单层的线性表,必须做成栈式作用域:每进入一个过程就 push 一层新作用域,退出时 pop。查变量时从当前层开始向上逐层查找,直到找到为止。符号表条目和监督它的作用域结构大致如下:
typedef struct Symbol { char name[64]; int type; // SYM_INT / SYM_BOOL / SYM_REAL / SYM_ARR ... int offset; // 相对当前栈帧基址的偏移 int isParam; // 0 局部变量,1 参数 int declaredLine; // 声明行,重定义检查用 struct Symbol* next; } Symbol; typedef struct Scope { Symbol* head; struct Scope* parent; } Scope; Scope* currentScope; Symbol* lookup(const char* name) { for (Scope* s = currentScope; s != NULL; s = s->parent) { for (Symbol* sym = s->head; sym != NULL; sym = sym->next) { if (strcmp(sym->name, name) == 0) return sym; } } return NULL; }lookup 从 currentScope 逐层向上查,这个行为对应的是 Pascal 的静态作用域规则。offset 字段是代码生成阶段用的,每个局部变量在栈帧里的位置要在声明时就确定,比如第一个整型局部变量 offset 是 -4,第二个是 -8,以此类推。参数则从另一个方向分配,通常在栈里为正偏移或从参数区底部算。这个 offset 的分配顺序如果出错,生成的汇编访问到的就是别人的变量,程序运行结果是玄学级别的错误,查起来非常痛苦。
还有声明重定义的检查要挂在 procedure/function 定义的入口:先遍历当前层的 head,如果同名符号已经在当前层存在,直接报错。注意只查当前层,不查外层,因为内层可以遮蔽外层变量,这是 Pascal 的合法行为。
4.2 类型系统与静态检查:强类型语言在 C 实现里的取舍
Pascal 是强类型语言,赋值、运算、传参都要做类型兼容性检查。课程设计不需要把整个类型系统都搬过来,一般处理到整型、实型、布尔型和数组就够。类型检查最常见的实现方式是在遍历 AST 时返回一个类型标志,然后做规则匹配:
int typeCheckExpr(AstNode* node) { if (node->nodeType == NUMBER_NODE) return TYPE_INT; if (node->nodeType == REAL_NODE) return TYPE_REAL; if (node->nodeType == IDENT_NODE) { Symbol* s = lookup(node->ident); if (s == NULL) { fprintf(stderr, "undefined identifier %s\n", node->ident); return TYPE_ERR; } return s->type; } if (node->nodeType == BINOP_NODE) { int lt = typeCheckExpr(node->left); int rt = typeCheckExpr(node->right); if (lt == TYPE_REAL || rt == TYPE_REAL) { if (!isArithNode(node)) { reportTypeError(node); return TYPE_ERR; } return TYPE_REAL; } if (lt == TYPE_INT && rt == TYPE_INT) return TYPE_INT; reportTypeError(node); return TYPE_ERR; } return TYPE_ERR; }类型检查时要留意 Pascal 和 C 的差异:Pascal 里整型可以赋值给实型变量,实型不能隐式赋给整型,而 C 里 float 赋给 int 是静默截断。如果抄 C 的习惯去翻译 Pascal 的赋值检查,就会把x := 1.5这种本该报错的代码放过去。另外,and or not在 Pascal 里是逻辑运算符,不能按 C 的&& || !去映射,不然短路求值的行为和 Pascal 语义也对不上。
还有布尔类型:Pascal 的 if 条件必须是布尔表达式,C 里任何非零整数都能当条件。语义分析阶段要在 if 节点的 cond 上强查type == TYPE_BOOL,这个检查能让很多运行时才暴露的问题提前到编译期暴露,也是答辩老师喜欢问的点。
4.3 函数与过程调用:参数传递和栈帧约定的简化方案
Pascal 的过程调用和函数调用都要处理参数传递、调用现场保存、返回值传递。课程设计里常用一种简化的调用约定:调用方先把实参的值压栈,然后 call 目标地址,被调方通过偏移从栈里取参数,返回值放固定寄存器,下面是典型的调用序列:
| 步骤 | 指令作用 |
|---|---|
| 调用方压栈参数 | 从右到左或从左到右依次 push(要和被调方的取参顺序一致) |
| call 指令 | 隐式保存返回地址,跳转到目标过程入口 |
| 被调方保存现场 | push ebp;mov ebp, esp |
| 取参数 | 用[ebp + 8]、[ebp + 12]等偏移访问第一个、第二个参数 |
| 返回值 | 将结果写入固定寄存器,如 EAX |
| 恢复现场并返回 | mov esp, ebp;pop ebp;ret |
| 调用方清理栈 | add esp, 参数总字节数 |
这里最容易乱的是参数偏移。第一个参数在ebp + 8,第二个在ebp + 12,每压一个参数占 4 字节。如果压参顺序是从左到右,而取参偏移却按从右到左计算,传进去的值和实际取到的值会错位,程序结果就像被随机打散了一样。我建议在工程里固定一个规则:所有参数从左到右压栈,被调方第一个参数固定用[ebp + 8]访问,这样代码生成逻辑统一,也方便你在 target.asm 里人肉核对参数顺序。
嵌套过程访问外层变量是另一个难点。Pascal 允许内层过程看外层过程的局部变量,这需要维护静态链或 display 表。课程设计如果嫌复杂,常做一个折衷:内层过程引用外层变量时,通过符号表查到该变量在外层栈帧中的 offset,然后在调用时把外层的帧基址一并传过去。这个机制最少要加一个隐藏参数,生成的汇编里会看到 call 之前先 push 一个额外的栈帧指针。如果资源本身没有覆盖嵌套访问,这属于可选的加分项,做的时候单独验证,别和普通参数混在一起测。
5. 常见问题与排查:从 CMake 缓存到汇编输出的 5 个典型翻车现场
5.1 构建与工程配置类问题
现象:用 Code::Blocks 直接 File Open 打开整个解压目录,Build 时报multiple definition of main,或者报undefined reference to main。
原因:目录里有 CMake 生成的 feature_tests.c 和 CMakeCCompilerId.c,这些文件自带 main 或特殊入口符号。Code::Blocks 如果按目录添加文件,就会把它们一起加入编译,导致多个 main 冲突;反过来,如果只打开了某个 .cbp 但源码文件没挂全,又会出现找不到 main。
解决:不要按目录打开工程,而是 File Open 选中 complier.cbp,让工程自己决定编译哪些文件。在工程里 Project Properties 里把feature_tests.c、CMakeCCompilerId.c等带 CMake 前缀的文件排除,或者直接把这些缓存文件从源码目录里删掉再重新 Build。我自己的习惯是删,因为它们在运行时毫无用途。
现象:在 Windows 下用 MSVC 环境打开工程,报「编译器未包含 main 类型」或链接阶段找不到入口。
原因:这份工程是为 GCC/Code::Blocks 组织的,MSVC 的工程格式和入口约定不同,尤其是手写的 cbp 里如果没有为 MSVC 生成 solution,直接拿 VS 打开会识别不到入口函数。
解决:老老实实装 MinGW 或用 MSYS2 的 gcc 编译。如果你非要用 VS Code,也是配置 gcc 插件来调编译命令,而不是让 MSVC 的 cl.exe 去解析 GCC 风格的源码结构。先把 todo.bat 用文本编辑打开看看作者有没有写构建命令,通常批处理里会留下他常用的入口。
5.2 代码生成与运行时问题
现象:程序编译通过,生成的 target.asm 看起来有模有样,但跑起来 if-else 永远走 then 分支,else 分支形同虚设。
原因:跳转指令的标签绑定错了。最常见的是条件计算的假出口标签在生成时没有分配新编号,而是复用了 if 语句前一个标签的编号。生成出来的汇编长这样:jz L_end; jmp L_end,那 else 分支的代码落在 L_end 后面,自然永远不会被跳转进入。
解决:对照 target.asm 手工检查 if-else 三段:cond 计算、then 块、else 块。标准形态是条件为假跳 L_else、then 块末尾无条件跳 L_end、L_else 之后是 else 块。如果缺了 then 块末尾那行jmp L_end,就会发生执行完 then 后又落入 else 的情况。修复方法是在 genIf 里严格按模板发射三条跳转。
现象:while 循环要么一次都不执行,要么死循环。
原因:两种相反的错误。一次都不执行,是条件为真的跳转目标写到了循环入口之前,导致判断完直接跳过循环体;死循环,是条件为假的出口标签没绑上,或者循环体末尾缺少jmp L_loop,使得条件判断永远落在同一个 false 结果上。
解决:打印 target.asm,对着模板逐行比对。死循环先找循环体末尾有没有无条件跳回 L_loop 的指令;一次都不执行就看 JZ 的目标是不是紧跟在循环体之后的 L_end。这两类错误在汇编文本里一眼就能看出来,用 debugger 反而绕。
现象:嵌套 if 或 if 套 while 时,内层代码的跳转目标跑到外层去了。
原因:labelCounter 在进入内层语句时被重置,或者标签生成函数用了固定编号,比如写死 L1、L2,没有用 newLabel。一个函数里的局部标签和外层语句的标签撞车。
解决:贴代码生成器里所有L%d的打印处,确认编号全部来自 newLabel()。newLabel 返回的计数器是全工程唯一的,嵌套再深也不会重复。如果作者已经用了计数器,那问题多半出在某个 gen 函数里手写了常量标签号,改成调用 newLabel 就好。
现象:Pascal 源码里参数是整型,生成的汇编却把参数当指针访问,结果改了调用方局部变量。
原因:参数偏移计算错误。被调方用[ebp + 8]取第一个参数,实参压栈顺序和符号表里的 offset 分配对不上,或者把参数区当成局部变量区分配了负偏移。
解决:写一个最小调用程序function f(x: integer): integer; begin f := x + 1; end;,进 GDB 在被调函数入口打断点,打印 ebp 往上的栈内存,核对第一个参数的实际位置。然后把符号表里参数 offset 的分配逻辑改成「从 ebp + 8 开始,每参数 +4」,与局部变量(从 ebp-4 往下)严格分边界。从那以后我每次加新语法都强制走一遍同样的最小测试流程,不再相信肉眼比对汇编能一次通过。
6. 增量扩展技巧:把 repeat-until 装进现有骨架的正确姿势
如果这份工程已经支持 if 和 while,想扩展 repeat-until 是最划算的练习,因为它的代码生成模式和 while 只差一步。完整步骤分两处:Parser 里在 parseStatement 的 switch 中加一个 TOK_REPEAT 分支,指向新写的 parseRepeatStmt;代码生成器里加一个 genRepeat 函数。repeat 的语义是「先执行循环体,直到条件为真退出」,对应汇编结构就是循环体后判断条件,条件为假跳回循环体开头,为真落到出口:
AstNode* parseRepeatStmt(void) { AstNode* node = allocNode(); node->nodeType = REPEAT_STMT; expect(TOK_REPEAT); node->body = parseStatement(); // repeat 的循环体是语句序列,常是复合语句 expect(TOK_UNTIL); node->cond = parseExpression(); return node; } void genRepeat(AstNode* node) { int loopLabel = newLabel(); int endLabel = newLabel(); emitLabel(loopLabel); genStmt(node->body); genExpr(node->cond); emitJump("JZ", loopLabel); // 条件为假就继续,注意和 while 相反 emitLabel(endLabel); }和 while 的 genWhile 对比一下,区别只在跳转方向:while 是条件为假用 JZ 跳出,repeat 是条件为假用 JZ 跳回。方向一颠倒,语义就从「前置判断」变成「后置判断」。这个对照关系写清楚,以后扩展 for 循环也是同理——for 可以拆成初始赋值加 while,完全复用现有代码。
测试时我建议写一个计数器程序验证:i := 0; repeat i := i + 1 until i >= 3;,跑完后在 target.asm 里看紧跟在加法指令后面的跳转方向。如果跳回的是循环体开头,说明正确。从那以后我每拿到一个编译器课程设计源码,第一件事就是跑一遍这个最小循环,看一眼 target.asm 是不是真的符合语义,不看这个,整个包装得再完整我心里也没底。希望帮到你。
本文还有配套的精品资源,点击获取