本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:这个编译器用C++从零实现,专为编译原理课程设计打造,支持将类Pascal风格的源程序(如pas.dat、pas1.dat)分两步翻译成可在8086处理器上运行的汇编代码。第一步做词法扫描、语法解析和语义检查,生成标准四元式序列并记录在res.txt里;第二步把四元式逐条映射为符合8086指令集规范的汇编语句,输出pas.asm等.asm文件。整个流程不依赖外部工具链,所有模块——包括词法分析器、语法语义分析器、四元式生成器、汇编代码生成器——都封装在独立头文件中,主逻辑集中在main.cpp。配套提供可直接双击运行的课设.exe、VS2019工程文件(.sln/.vcxproj)、调试符号.pdb、多组测试用例及对应汇编结果,开箱即用也方便逐层调试和教学演示。适合理解编译前端如何产出中间表示、后端如何基于中间表示生成目标代码。

1. 项目概述:一个“看得见摸得着”的编译流程教学载体

你有没有在学《编译原理》时,对着龙书里那张经典的“前端→中间表示→后端”三层架构图发过呆?词法分析器怎么把一串字符切分成token?语法树到底长什么样?四元式里的op arg1 arg2 result四个字段,到底是怎么从if语句或者赋值语句里“长”出来的?更关键的是——这些抽象的中间结构,最后怎么变成一条条实实在在的MOV AX, [BX+SI]ADD AX, BX这样的8086汇编指令?这台用C++写的简易编译器,就是为解决这些“看不见、摸不着”的困惑而生的。它不是玩具,也不是伪代码演示,而是一个可运行、可调试、可逐层观察、可修改复现的完整两阶段翻译系统。核心关键词非常明确:C++编译器、8086汇编、四元式、词法分析、语法分析——这五个词,就是它全部的技术骨架和教学价值锚点。

它的设计目标极其务实:不追求支持完整的Pascal标准,而是聚焦于课程教学中最典型的语法结构——变量声明、整型常量、算术表达式(含括号优先级)、赋值语句、if-then-else条件分支、while循环,以及最基础的过程调用(无参数)。所有这些,都严格映射到8086实模式下的内存模型:数据段(DS)存放变量,代码段(CS)存放指令,堆栈段(SS)管理过程调用。整个流程完全脱离现代工具链——没有Clang、没有LLVM、不调用任何外部汇编器或链接器。词法分析器自己手写状态机,语法分析器用递归下降(Recursive Descent),语义检查靠符号表手动维护,四元式生成是语法分析过程中的自然副产品,而最终的汇编输出,则是一次对8086寻址方式、寄存器约束、指令格式的硬核落地。我当年带学生做课设时反复强调:这个项目的价值,不在于它能编译多复杂的程序,而在于它让你亲手“拧开”编译器的每一颗螺丝,看清里面齿轮如何咬合。当你双击运行课设.exe,看到pas.asm里第一行写着DATA SEGMENT,最后一行是END START,中间夹着十几条清晰对应的MOVADDJMP指令时,那种“原来如此”的通透感,是任何PPT都无法替代的。

2. 整体架构与设计思路拆解:为什么是“两阶段+四元式”?

2.1 为什么必须分两阶段?——解耦是理解复杂性的唯一路径

很多初学者会问:既然最终目标是生成汇编,那为什么不直接让语法分析器一边解析一边吐汇编代码?这看似直截了当,但实际会迅速陷入灾难。想象一下,你在解析一个嵌套很深的if (a > b) then if (c < d) then x := y + z else ...时,要同时处理语法结构判断、变量作用域检查、类型兼容性验证、临时变量分配、跳转标签生成、寄存器使用规划……所有这些逻辑混杂在一起,代码会像一团打结的毛线,任何一个微小改动都可能引发连锁崩溃。这个项目采用经典的前端/后端分离架构,其底层逻辑非常朴素:把“理解程序含义”和“生成机器指令”这两件性质完全不同的事,交给两个独立模块去完成,中间用一种清晰、规范、与具体硬件无关的“通用语言”来沟通——这就是四元式(Quadruple)

前端(词法+语法+语义分析)只负责一件事:确保源程序在语法和语义上是合法的,并将其内在逻辑结构,忠实地、无歧义地翻译成一系列标准化的操作指令。它不关心这些操作最终要用MOV AX, BX还是MOV EAX, EBX来实现,也不关心x := y + z这个加法该用哪个寄存器暂存结果。它的输出,就是一份干净的、按执行顺序排列的“操作清单”,也就是res.txt里记录的四元式序列。而后端(代码生成器)则扮演一个纯粹的“翻译官”角色:它只认四元式这种格式,对源语言一无所知,它的全部工作,就是根据每一条四元式的op(操作符)、arg1(第一个操作数)、arg2(第二个操作数)、result(结果)这四个字段,查阅一张预定义的“映射规则表”,然后机械地、确定性地拼出对应的8086汇编语句。这种解耦带来的好处是立竿见影的:你可以单独测试前端,用pas.dat输入,检查res.txt里的四元式是否符合预期;也可以单独测试后端,手工编辑res.txt,看它能否稳定地生成正确的.asm文件。这种模块化,是工程实践和教学演示的生命线。

2.2 为什么选四元式?——它是最适合教学的中间表示

在编译原理中,中间表示(IR)有多种形态:三地址码(Three-Address Code)、抽象语法树(AST)、控制流图(CFG)、甚至SSA形式。对于一个面向本科教学的简易编译器,四元式是经过深思熟虑后的最优解。它比三地址码多了一个显式的result字段,使得数据依赖关系一目了然;它比AST更扁平、更线性,避免了树形结构遍历的复杂性;它又比CFG更贴近原始语义,无需额外构建基本块和边。更重要的是,它的结构与8086汇编的“操作-源-源-目的”思维高度契合。我们来看一个真实例子:源码x := a + b * c。前端分析后,不会直接生成一条MOV x, a+b*c(8086根本没有这种指令),而是会生成三条四元式:

(*, b, c, t1)
(+, a, t1, t2)
(:=, t2, _, x)

这里,t1t2是编译器自动引入的临时变量。后端看到第一条(*, b, c, t1),立刻知道:需要把bc加载到寄存器,执行乘法,再把结果存入[t1]的内存地址。它不需要理解*在数学上的含义,只需要查表:“*操作 → MOV AX, [b]MUL WORD PTR [c]MOV [t1], AX”。这种一一映射的确定性,正是教学项目最需要的。相比之下,如果用AST,你需要先遍历树找到叶子节点bc,再找到它们的父节点*,再找到父节点的父节点+……逻辑链条太长,容易迷失在指针跳跃中。而四元式,就是一张平铺在纸上的、按时间顺序执行的“任务清单”,每一步都清晰、独立、可验证。

2.3 为什么锁定8086?——回归计算本质的“降维打击”

选择8086作为目标平台,绝非怀旧或妥协,而是一种极具教学智慧的“降维打击”。现代x86-64处理器拥有数百条复杂指令、多级缓存、乱序执行、SIMD寄存器……这些对理解编译原理的核心思想,反而是巨大的干扰噪音。8086则不同:它只有14个16位寄存器(AX/BX/CX/DX, SI/DI, BP/SP, IP, CS/DS/ES/SS),指令集精炼(MOV, ADD, SUB, MUL, DIV, CMP, JE, JNE, JL, JG, CALL, RET等),内存寻址方式明确(直接、寄存器间接、基址变址等),且运行在最简单的实模式下,没有保护、没有分页、没有虚拟内存。这意味着,当你在pas.asm里看到MOV AX, [BX+SI]时,你完全可以精确地在脑海中模拟出CPU是如何将BXSI相加,得到物理地址,再从那个地址读取一个字(word)到AX寄存器的全过程。这种“所见即所得”的确定性,是学习代码生成环节的基石。它强迫你思考:一个变量x,在内存里占几个字节?它的地址是静态分配的还是动态计算的?MUL指令的结果放在AX还是DX:AXCALL指令如何压栈保存返回地址?这些问题,在8086上都有唯一、明确、可验证的答案。而一旦你吃透了这套逻辑,迁移到更复杂的平台,就只是“加法”而非“重构”。

3. 核心模块深度解析与实操要点

3.1 词法分析器(Lexer):手写状态机的艺术

词法分析器是整个编译流程的“守门人”,它的任务是将源文件(如pas.dat)里的一维字符流,切割成一个个有明确语义的“记号”(Token),比如KEYWORD_IFIDENTIFIER_xNUMBER_123OPERATOR_PLUS。这个项目没有使用Flex等自动生成工具,而是用纯C++手写了一个基于有限状态机(FSM) 的分析器,这恰恰是教学价值所在——它让你亲眼看到“状态”是如何流转的。

核心数据结构是一个vector<Token>,其中Token结构体包含type(枚举类型)、value(字符串值)、line(行号)三个字段。状态机的实现封装在lexer.h中,其主循环逻辑如下:逐个读取输入字符,根据当前状态(state)和当前字符(ch)的组合,决定下一个状态(next_state)和是否需要产出一个Token。例如,当state == STATE_STARTch是字母时,进入STATE_IDENTIFIER;当state == STATE_IDENTIFIERch是数字或字母时,继续留在该状态并累加字符;当state == STATE_IDENTIFIERch是空格或运算符时,则将已累加的字符串与关键字表(keywords map)比对,若匹配则产出KEYWORD_XXX,否则产出IDENTIFIER。这个过程,本质上就是在模拟一个“识别单词”的大脑。

提示:pas.dat中的关键字(program, begin, end, if, then, else, while, do, var)都是硬编码在keywords map里的。如果你想扩展支持real类型,只需在map里添加"real"KEYWORD_REAL即可,无需改动状态机逻辑。这是手写Lexer最大的灵活性优势。

一个关键的实操细节是空白字符和注释的处理。8086汇编本身不支持///* */风格注释,但为了提升源码可读性,本项目在词法分析阶段就将{...}之间的内容(Pascal风格注释)完全忽略,不产生任何Token。这要求状态机必须有一个专门的STATE_COMMENT,并在遇到{时进入,遇到}时退出。这个看似微小的设计,却极大提升了源码的编写体验,也体现了“用户友好”与“教学严谨”的平衡。

3.2 语法与语义分析器(Parser & Semantic Analyzer):递归下降的“自顶向下”之旅

语法分析器采用经典的递归下降(Recursive Descent) 方法,这与词法分析器的手写风格一脉相承,保证了整个前端的统一性和可理解性。parser.h中定义了一系列以parseXXX()命名的函数,如parseProgram(), parseBlock(), parseStatementList(), parseStatement(), parseExpression()等,它们之间相互调用,形成一棵与源程序语法结构完全一致的“调用树”。

parseExpression()为例,它实现了算术表达式的递归下降解析,并在此过程中完成了语义检查四元式生成。其核心逻辑是:首先调用parseTerm()解析一个项(term),然后循环检查后续是否为+-运算符,如果是,则再次调用parseTerm()获取下一个项,并立即生成一条(+, arg1, arg2, result)四元式,将结果存入一个新临时变量tN。这个过程天然地处理了运算符优先级:parseExpression()只处理+-,而parseTerm()负责*/parseFactor()则负责原子元素(标识符、数字、括号表达式)。这种“函数层级对应语法层级”的设计,让代码逻辑与BNF文法规则几乎一一对应,阅读起来如同在读一本活的语法手册。

语义检查则紧密耦合在这个解析过程中。例如,在parseIdentifier()中,当识别出一个标识符x时,分析器会立即查询当前作用域的符号表(Symbol Table)。符号表是一个map<string, SymbolInfo>,其中SymbolInfo结构体包含typeINT, REAL等)、offset(在数据段中的偏移量)、isDeclared(是否已声明)等字段。如果x未被声明(isDeclared == false),则报错“Undeclared identifier ‘x’”,并记录在错误日志中。这个检查发生在语法分析的“当下”,而不是事后扫描,确保了错误定位的精准性。同样,在parseAssignment()中,当解析到x := y + z时,会检查xyz的类型是否兼容(例如,不允许int := real),并在生成四元式前完成所有类型推导。

注意:符号表的实现采用了“作用域栈”(Scope Stack)机制。每次进入一个begin...end块或一个procedure时,就push一个新的空map;离开时,就pop掉它。这样,内层作用域可以“遮蔽”(shadow)外层同名变量,完美模拟了Pascal的词法作用域规则。这是理解变量生命周期的关键。

3.3 四元式生成器(Quadruple Generator):中间表示的“心脏”

四元式生成并非一个独立的模块,而是语法分析过程中的“副产品”,是parseXXX()函数在成功识别出某个语法结构后,顺手“写”下来的操作指令。quadruple.h中定义了Quadruple结构体和一个全局的vector<Quadruple>容器quads。每当需要生成一条四元式,就调用emit(op, arg1, arg2, result)函数,它会创建一个新的Quadruple对象并push_backquads中。

生成的时机非常关键。例如,在parseIfStatement()中:
1. 解析完if后的condition表达式,会生成一系列四元式来计算条件值,并最终得到一个布尔结果(存储在临时变量tN中)。
2. 然后,emit(JE, tN, "_", label_else),生成一条“如果tN为假则跳转到label_else”的四元式。
3. 接着,递归调用parseStatement()解析then分支的语句,这些语句生成的四元式会紧跟其后。
4. 最后,emit(JMP, "_", "_", label_end),生成一条无条件跳转到label_end的四元式,以跳过else分支。
5. 如果存在else分支,则在label_else:之后,继续解析else部分的语句。

可以看到,四元式序列不仅记录了“做什么”,还精确地记录了“什么时候做”(通过跳转指令)和“跳到哪里去”(通过标签)。res.txt文件就是quads容器的文本化快照,每一行对应一条四元式,格式为op,arg1,arg2,result。这是整个项目最核心的“证据链”,是连接前端与后端的唯一桥梁。调试时,如果你发现生成的汇编代码逻辑错误,第一步永远是打开res.txt,检查这里的四元式序列是否符合你的预期。如果四元式错了,问题一定出在前端;如果四元式是对的,那问题就一定在后端的翻译逻辑里。

3.4 汇编代码生成器(Code Generator):从四元式到8086指令的硬核映射

后端的codegen.h是整个项目技术含量最高的部分,它承担着将抽象的四元式,精确、高效、无歧义地翻译成8086汇编代码的重任。其核心是一个巨大的switch(op)语句,针对每一种四元式操作符op,提供一套定制化的汇编生成模板。

我们以最常用的:=(赋值)操作为例。四元式(:=, a, _, x)意味着“把a的值赋给x”。生成器需要考虑a的类型:
- 如果a是标识符(变量),则需MOV AX, [a](从a的内存地址加载值);
- 如果a是数字常量,则需MOV AX, 123(直接加载立即数);
- 如果a是临时变量t1,则需MOV AX, [t1](临时变量也分配在数据段);
- 最后,无论a是什么,都需要MOV [x], AX(将AX中的值存入x的内存地址)。

这个过程涉及对arg1字段的“类型识别”和“寻址方式选择”,是代码生成器最核心的智能所在。再看+操作:(+, a, b, t1)。生成器首先需要为t1分配一个唯一的内存地址(在数据段中预留一个DW ?空间),然后生成:

MOV AX, [a]
ADD AX, [b]
MOV [t1], AX

这里,ADD指令要求两个操作数不能同时为内存操作数,所以必须先将a加载到寄存器,再与b相加。这种对8086指令集约束的深刻理解,是生成正确代码的前提。

一个至关重要的设计是标签(Label)的管理JE, JNE, JMP等跳转四元式中的result字段,存储的是一个字符串标签(如"L1", "L2")。代码生成器维护一个map<string, int>,记录每个标签在最终.asm文件中的行号。当遇到JE t1 _ L2时,它先生成CMP AX, 0(比较t1是否为0),然后生成JE L2。但此时L2标签可能尚未定义(因为else分支的代码还在后面),所以生成器会先在当前位置留下一个“占位符”,待后续遇到L2:定义时,再回填正确的行号。这个“前向引用”(forward reference)的处理,是汇编器最基本也是最重要的功能之一,本项目用一个简单的vector<pair<string, int>>来记录所有待解析的跳转指令及其在.asm文件中的位置,完美复现了这一经典机制。

4. 实操过程与核心环节实现

4.1 从零开始:构建你的第一个测试用例

让我们以pas.dat为例,走一遍完整的实操流程。首先,你需要理解pas.dat的内容结构。一个典型的测试用例长这样:

program test;
var a, b, c: integer;
begin
    a := 10;
    b := 20;
    c := a + b;
    if c > 25 then
        c := c - 5
    else
        c := c + 5;
end.

这是一个标准的Pascal风格程序,声明了三个整型变量,进行了赋值、算术运算和条件判断。现在,打开Visual Studio 2019,加载项目.sln文件。在解决方案资源管理器中,你会看到main.cpp是入口点。main()函数的逻辑非常清晰:

int main(int argc, char* argv[]) {
    string input_file = "pas.dat"; // 默认输入
    string output_asm = "pas.asm";
    string output_res = "res.txt";

    if (argc > 1) input_file = argv[1]; // 支持命令行参数
    if (argc > 2) output_asm = argv[2];
    if (argc > 3) output_res = argv[3];

    Lexer lexer(input_file);
    vector<Token> tokens = lexer.tokenize(); // 第一步:词法分析

    Parser parser(tokens);
    bool parse_success = parser.parseProgram(); // 第二步:语法语义分析

    if (!parse_success) {
        cout << "Parsing failed. Check errors above." << endl;
        return 1;
    }

    // 将四元式序列写入 res.txt
    ofstream res_out(output_res);
    for (const auto& quad : quads) {
        res_out << quad.op << "," << quad.arg1 << "," << quad.arg2 << "," << quad.result << endl;
    }
    res_out.close();

    // 生成汇编代码
    CodeGenerator codegen;
    codegen.generate(output_asm); // 第三步:代码生成

    cout << "Compilation successful! Output: " << output_asm << endl;
    return 0;
}

编译并运行课设.exe,你将在项目根目录下看到三个新文件:res.txt, pas.asm, 和pas.lst(如果启用了列表文件生成)。打开res.txt,你应该能看到类似下面的四元式序列:

(:=,10,_,a)
(:=,20,_,b)
(+,a,b,c)
(>,c,25,L1)
(JE,c,_,L1)
(:=,c,_,t1)
(-,t1,5,c)
(JMP,_,_,L2)
(L1:,_,_,_)
(:=,c,_,t2)
(+,t2,5,c)
(L2:,_,_,_)

这已经是一个完整的、可执行的逻辑流。接下来,打开pas.asm,你会看到:

; Generated by C++ Compiler for 8086
DATA SEGMENT
    a DW ?
    b DW ?
    c DW ?
    t1 DW ?
    t2 DW ?
DATA ENDS

CODE SEGMENT
ASSUME CS:CODE, DS:DATA
START:
    MOV AX, DATA
    MOV DS, AX

    MOV AX, 10
    MOV [a], AX

    MOV AX, 20
    MOV [b], AX

    MOV AX, [a]
    ADD AX, [b]
    MOV [c], AX

    MOV AX, [c]
    CMP AX, 25
    JLE L1

    MOV AX, [c]
    SUB AX, 5
    MOV [c], AX
    JMP L2

L1:
    MOV AX, [c]
    ADD AX, 5
    MOV [c], AX

L2:
    MOV AH, 4CH
    INT 21H
CODE ENDS
END START

实测心得:第一次看到这段汇编时,我特意用DOSBox加载了pas.asm,用ml.exe(Microsoft Macro Assembler)进行汇编,再用link.exe链接,最后用debug单步执行,亲眼看着AX寄存器里的值从10变成20,再到30,最后变成25。这种“从C++代码到8086寄存器”的全链路贯通感,是任何理论学习都无法给予的震撼。

4.2 关键参数与配置详解

项目的可配置性主要体现在config.h(如果存在)或main.cpp的顶部宏定义中。虽然这是一个简易编译器,但一些关键参数的设定,直接影响生成代码的质量和可调试性。

首先是临时变量命名规则。在quadruple.h中,有一个全局计数器static int temp_counter = 0;。每次调用newTemp()函数时,它就递增并返回t+temp_counter的字符串。这个设计简单有效,但如果你希望临时变量名更具可读性(比如t_add_1, t_if_cond_2),可以修改newTemp()函数,让它根据上下文生成描述性名称。这在调试大型程序时非常有用。

其次是数据段内存布局策略。当前实现是将所有变量(包括临时变量)都声明在DATA SEGMENT中,用DW ?(Define Word)预留空间。这是一种最简单、最安全的策略,因为8086实模式下,数据段大小是固定的(64KB),所有变量地址都是静态可知的。但这也意味着,如果你的程序有成千上万个临时变量,数据段可能会溢出。一个进阶的优化是引入“栈帧”(Stack Frame)概念,将临时变量分配在运行时堆栈上(SS:SP),但这会显著增加代码生成器的复杂度,超出了本项目的教学范围。

最后是错误处理级别。项目目前实现了基础的语法错误(Syntax Error at line X)和语义错误(Undeclared identifier 'x')。你可以很容易地扩展它,加入更友好的错误提示,比如在Undeclared identifier错误后,自动列出当前作用域内所有已声明的变量,帮助学生快速定位拼写错误。这只需要在报错函数中,遍历当前symbol_table并打印即可。

4.3 VS2019工程配置与调试技巧

Visual Studio 2019是该项目的官方开发环境,其强大的调试器是学习编译器内部工作原理的绝佳工具。以下是我总结的几条高效调试技巧:

  1. 断点设置在“关键决策点”:不要在main()开头就下断点。应该在parseExpression()的入口、emit()函数、codegen.generate()的循环体内设置断点。这样,你可以单步执行,亲眼看到一个+操作是如何被识别、如何生成两条MOV和一条ADD的。

  2. 利用“监视窗口”(Watch Window):在调试过程中,将tokensquadssymbol_table等关键容器拖入监视窗口。你可以实时展开查看它们的内容。例如,在parseIfStatement()执行到一半时,监视quads.size(),就能立刻知道到目前为止生成了多少条四元式。

  3. “内存窗口”(Memory Window)观察符号表symbol_table是一个map,其内部结构是红黑树。在调试时,右键点击symbol_table,选择“转到内存”,你就能看到它在内存中的原始布局,这对于理解STL容器的底层实现很有帮助。

  4. 使用“即时窗口”(Immediate Window)进行交互式查询:在断点暂停时,在即时窗口中输入? lexer.get_current_line(),可以立刻看到当前正在分析的源码行号;输入? parser.current_token().value,可以查看当前即将被处理的Token的值。这是一种非常高效的“探针式”调试方法。

  5. 生成调试信息(.pdb):项目默认生成.pdb文件,这是VS调试器的“地图”。确保在项目属性中,“配置属性”→“常规”→“调试信息格式”设置为/Zi,并且“C/C++”→“常规”→“调试信息格式”也设置为/Zi。有了.pdb,你才能在汇编代码层面进行源码级调试,看到C++的for循环对应哪几条JMP指令。

5. 常见问题与排查技巧实录

5.1 “四元式没错,但汇编跑不起来!”——后端翻译陷阱

这是最常见、也最容易让人抓狂的问题。现象是:res.txt里的四元式看起来逻辑完美,但生成的pas.asm在DOSBox里用ml汇编时报错,或者汇编成功但运行结果不对。

问题速查表

现象 可能原因 排查与解决
error A2006: undefined symbol : L1 标签L1JE指令中被引用,但在.asm文件中从未被定义(即没有L1:这一行)。 检查res.txt中是否有L1:开头的四元式(opL1:)。如果没有,说明parseIfStatement()中的else分支解析失败,或者emit("L1:", ...)调用被遗漏。在parser.h中搜索L1,确认其生成逻辑。
error A2070: invalid instruction operands ADD AX, [a+b]这类非法寻址。8086不支持两个内存操作数相加。 这是代码生成器的典型错误。检查codegen.h+操作的模板,确认它是否总是先MOV一个操作数到寄存器,再ADD另一个。错误的模板可能是ADD [result], [arg1],这违反了指令约束。
程序运行后,变量值始终为0 数据段未被正确初始化,或MOV DS, AX指令后,DS指向了错误的段。 检查pas.asm的开头部分。确保MOV AX, DATAMOV DS, AX这两条指令紧挨着,并且DATA SEGMENT的定义在CODE SEGMENT之前。一个常见的低级错误是,DATA SEGMENT被不小心写在了CODE SEGMENT内部。

我踩过的坑:有一次,我发现while循环生成的汇编代码死循环了。调试发现,res.txtJMP L1的跳转目标L1被错误地生成在了循环体的末尾,而不是循环体的开头。根源在于parseWhileStatement()函数中,emit("JMP", "_", "_", loop_start_label)这行代码,被错误地放在了parseStatement()(解析循环体)之后,而不是之前。修正位置后,问题立刻解决。这再次印证了:四元式序列的顺序,就是程序执行的顺序,一丝一毫都不能错。

5.2 “词法分析卡住了!”——状态机死循环

现象是:运行课设.exe后,程序长时间无响应,CPU占用率100%。这几乎可以肯定是词法分析器的状态机进入了死循环。

根本原因与修复
状态机的每个状态,都必须有明确的“出口”。最常见的死循环发生在STATE_COMMENT状态。如果源码中有一个{,但后面永远没有}来闭合,那么状态机就会永远停留在STATE_COMMENT,不断读取字符,却找不到退出条件。

修复方法是在Lexer::tokenize()的主循环中,加入一个最大读取字符数限制。例如:

int max_chars = 100000; // 设置一个合理的上限
int chars_read = 0;
while ((ch = fgetc(file)) != EOF && chars_read < max_chars) {
    chars_read++;
    // ... 状态机逻辑 ...
}
if (chars_read >= max_chars) {
    error("Source file too large or unterminated comment.");
    return tokens;
}

这个简单的防护,能瞬间将一个无限挂起的bug,转化为一个清晰、可定位的错误提示。

5.3 “变量值对不上!”——符号表与内存布局错位

现象是:源码中a := 10,但运行后a的值却是其他随机数。

排查路径
1. 检查res.txt:确认(:=,10,_,a)这条四元式是否存在。如果不存在,问题在前端。
2. 检查pas.asm:找到MOV [a], AX这条指令。确认a DW ?的声明是否在DATA SEGMENT中,并且MOV AX, 10是否在它之前执行。
3. 终极验证:用debug单步:在DOSBox中,用debug pas.exe加载程序。输入u 0:100(反汇编代码段),找到MOV [a], AX的地址。然后输入d ds:0(显示数据段开头),找到a的偏移量(假设是0000),再输入r ax查看AX寄存器的值。最后,输入d ds:0000,查看a的内存地址里是否真的被写入了000A(10的十六进制)。如果AX000A,但内存里是0000,那一定是MOV [a], AX这条指令没被执行,或者DS寄存器指向了错误的段。

这个层层递进的排查法,是每一个编译器开发者都必须掌握的基本功。它教会你,当高级语言的抽象失效时,如何沉到最底层的寄存器和内存去寻找真相。

6. 扩展与进阶:从课设走向真实世界

这个项目虽然是一个课程设计,但它的架构和思想,是通往更广阔天地的坚实跳板。如果你已经吃透了它,下一步可以尝试这些方向:

6.1 引入“三地址码”(TAC)作为中间表示

四元式是教学友好的,但工业级编译器更常用三地址码(TAC),因为它更紧凑。你可以修改quadruple.h,让Quadruple结构体只保留op, arg1, arg2, result四个字段,但将op的语义从“四元式操作”改为“TAC操作”,并相应调整emit()函数的签名。这会让你更深入地理解IR的演进。

6.2 实现简单的“寄存器分配”

当前的代码生成器,对所有操作都使用AX寄存器,效率低下。你可以引入一个简单的“寄存器分配器”,维护一个vector<bool>来标记AX, BX, CX, DX的占用状态,并在生成MOV指令时,智能地选择一个空闲寄存器。这将是学习编译优化的第一步。

6.3 支持“过程调用”(Procedure Call)

pas.dat中已经包含了procedure关键字的占位。你可以扩展parseProcedure()函数,让它不仅能解析过程声明,还能在调用时生成CALLRET指令,并管理好堆栈帧(PUSH BP, MOV BP, SP, POP BP)。这将带你进入函数调用约定(Calling Convention)的世界。

6.4 移植到现代平台

虽然8086是教学利器,但你也可以挑战一下,将后端代码生成器的目标,从8086汇编,改为x86-64汇编(AT&T或Intel语法),或者甚至生成LLVM IR。这需要你重写codegen.h中所有的switch(op)分支,但前端(词法、语法、四元式)可以完全复用。这正是现代编译器(如Clang)所采用的“同一前端,多后端”(One Frontend, Multiple Backends)架构的精髓。

最后再分享一个小技巧:在main.cpp中,将#define DEBUG_MODE 1,然后在关键函数里加入#ifdef DEBUG_MODE ... #endif包裹的cout语句。这样,你就可以在不修改核心逻辑的情况下,随时开启或关闭详细的内部状态输出,让调试过程变得无比轻松。这个项目真正的魅力,不在于它完成了什么,而在于它为你打开了一扇门,门后,是计算机科学最核心、最迷人的一片疆域——程序如何被理解,又如何被创造。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:这个编译器用C++从零实现,专为编译原理课程设计打造,支持将类Pascal风格的源程序(如pas.dat、pas1.dat)分两步翻译成可在8086处理器上运行的汇编代码。第一步做词法扫描、语法解析和语义检查,生成标准四元式序列并记录在res.txt里;第二步把四元式逐条映射为符合8086指令集规范的汇编语句,输出pas.asm等.asm文件。整个流程不依赖外部工具链,所有模块——包括词法分析器、语法语义分析器、四元式生成器、汇编代码生成器——都封装在独立头文件中,主逻辑集中在main.cpp。配套提供可直接双击运行的课设.exe、VS2019工程文件(.sln/.vcxproj)、调试符号.pdb、多组测试用例及对应汇编结果,开箱即用也方便逐层调试和教学演示。适合理解编译前端如何产出中间表示、后端如何基于中间表示生成目标代码。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

更多推荐