2025-05-13 18:23:25 +08:00
2025-05-11 21:41:34 +08:00
2025-05-10 23:03:10 +08:00
2025-05-13 18:23:25 +08:00
2025-05-09 10:12:53 +08:00
2025-04-28 17:45:17 +08:00
2025-05-07 11:14:17 +08:00
2025-04-22 16:54:33 +08:00
2025-05-11 21:41:34 +08:00

编译器架构设计方案

编译器主要模块划分

编译器可以划分为多个相对独立的模块,每个模块各司其职,按照顺序将源代码逐步翻译为目标虚拟机指令集。主要模块包括:

词法分析器Lexical Analyzer

词法分析器负责从源码文本中识别最小的语法单元单词符号Token。它逐字符读取源代码跳过无用的空白和注释根据定义的词法规则将字符序列切分为有意义的Token序列。词法分析器的职责包括

  • 符号识别:识别标识符、关键字、数字字面量、字符串字面量、运算符(如+-=等)、标点符号(如逗号、括号等)以及缩进或换行等控制符号(如果语法基于缩进)。
  • 过滤无关字符忽略空格、换行在非显式有意义场景下和注释内容不生成相应Token。
  • 错误检测:当出现无法识别的字符或不合法的序列时,报告词法错误(例如不支持的字符,或字符串未正确闭合等)。

词法分析的输出是按顺序排列的Token列表提供给下游的语法分析器使用。

语法分析器Syntax Parser

语法分析器根据语言的语法规则将Token序列解析为抽象语法树ASTAbstract Syntax Tree。语法分析器的职责包括

  • 语法结构识别按照语言文法将Token序列组织成语法结构。例如识别表达式、语句、函数定义、条件块、循环块、模块块等结构。对于自定义DSL类似YAML的语法如果采用缩进表示层级语法分析器需要结合缩进Token来确定层次结构类似Python语法的块级缩进
  • AST构建创建对应的AST节点对象构造出反映源代码层次结构的树形表示。每个节点表示源码中的一个构造如二元表达式、赋值语句、函数声明等
  • 语法错误处理如果Token序列不符合语法规则如遇到意外的Token或缺少某个符号报告语法错误。语法分析器可以尝试错误恢复如跳过一定Tokens到语句末尾以继续解析后续部分从而发现更多错误。

语法分析的输出是编译器的中间表示——AST它以树形结构表示整个源代码的语法结构。

语义分析器Semantic Analyzer

语义分析器在AST的基础上检查程序的语义正确性并进行必要的语义处理。其主要职责包括

  • 符号解析:建立符号表Symbol Table登记各种标识符的信息包括变量、函数、模块等。分模块、分作用域检查每个标识符的声明和定义:例如变量在使用前是否已声明,函数调用时对应函数是否存在等。对于支持模块的语言,语义分析需要处理跨模块引用,如CommonTasks.add_numbers应解析到CommonTasks模块下名为add_numbers的函数定义。
  • 类型检查(如果语言是静态类型或计划支持类型检查):检查表达式、赋值、函数参数/返回值等类型匹配是否正确。例如将一个函数返回值赋给变量时,类型是否兼容;算术运算的操作数类型是否可用等。如果当前语言未强制类型,可暂跳过严格类型检查,但设计上保留接口以便未来加入类型系统。
  • 语义规则验证:包括检查控制流语义(如return语句是否在函数内、循环控制语句如break/continue是否在循环内使用)、常量表达式求值(必要时进行常量折叠优化)等。
  • 注解AST在AST节点上附加语义信息例如每个表达式节点注入类型信息每个变量/函数节点链接到其符号表条目。为后续代码生成做好准备。

语义分析阶段如发现语义错误(未声明的变量、函数不存在、类型不匹配等),将报告错误并阻止后续正确的代码生成。语义分析完成后,输出可以是注释丰富的AST(带有类型和符号引用),同时维护好符号表,供后续阶段使用。

指令生成器Code Generator

指令生成器将经过检查的AST转化为目标虚拟机的指令序列即字节码或中间代码。它的主要职责包括

  • 代码选择为AST中每种结构生成等价的虚拟指令序列。例如表达式运算生成对应的算术或位运算指令条件和循环结构生成相应的分支跳转指令函数调用生成调用和返回指令等。
  • 资源分配:为变量、常量、临时值分配虚拟机运行时所需的资源。例如给每个变量分配一个内存槽位或栈位置,函数则分配栈桢空间。指令生成阶段会利用符号表信息确定每个变量/函数地址或索引,用于生成LOAD/STORE等内存指令的操作数。
  • 指令序列输出:以适当的格式输出虚拟机指令列表。可以选择直接构建一个指令对象列表(在内存中运行),或者生成汇编文本/二进制文件供自定义虚拟机加载执行。例如,遇到函数定义时,产生相应的函数入口标签和终止的返回指令;遇到函数调用时,按照调用约定生成参数入栈指令和调用指令;遇到算术表达式则按操作次序生成LOAD和算术计算指令等。
  • 优化(可选):在生成指令时做简单优化,如删除无效代码、常量折叠、简单的代数化简等(更复杂的优化可在将来引入独立优化模块,这里只考虑基础的直接翻译)。

指令生成器输出的结果是虚拟指令列表,它完成了从高级源语言到自定义虚拟机指令集的翻译。这些指令可以交由虚拟机解释执行,或者进一步封装成可加载的模块文件。

各模块的输入/输出格式

为了让各模块解耦并协同工作,需要定义清晰的接口,即各阶段输入和输出的数据格式:

  • 词法分析器:输入是源码的原始文本字符串;输出是Token列表List<Token>。每个Token包含类型、字面量值及位置信息。
  • 语法分析器输入是词法分析器生成的Token列表输出是**抽象语法树AST**的根节点表示整个源码的结构。AST由各类AST节点按树形组织。
  • 语义分析器输入是初步的AST以及初始的符号表输出是注解过的AST和填充完整的符号表。如果有语义错误则输出错误信息而不产生有效AST。注解AST指在节点上附加类型、作用域等语义信息。
  • 指令生成器输入是通过语义检查的AST以及符号表输出是虚拟机指令列表可以是一个Java内的数据结构List<Instruction>)或者文本形式的指令序列。每条指令包含操作码和操作数,构成最终可执行的字节码序列。

(在设计上,也可以在语义分析和指令生成之间增加中间代码表示(IR)优化模块的输入输出但当前方案下暂未引入独立的IR阶段。

数据结构与类设计建议

为了实现上述各模块需要设计合理的数据结构和类。以下是主要的Java类和结构建议

Token类

Token表示词法分析得到的最小语法单元。可以设计一个Token类包含以下属性:

  • typeToken类型例如标识符、关键字、数字、字符串、运算符等可用枚举表示
  • lexemeToken在源代码中的字面文本。例如标识符的名字数字的文本形式符号本身等。
  • valueToken的值可选用于存储已解析的数值、字符串字面量的实际值等。例如数字"20.2"解析成一个Number类型的值20.2。
  • linecolumn位置信息记录Token在源码中的行号和列号用于错误报告和调试。

例如,可以定义枚举TokenType包括IDENTIFIER、NUMBER、STRING、KEYWORD、OPERATOR、INDENT、DEDENT、NEWLINE等根据DSL具体情况扩展。词法分析时对每个识别出的符号创建一个Token实例设置其类型和lexeme等。

AST节点类层次

**抽象语法树(AST)**可以用面向对象的类层次来表示。设计时通常有一个AST节点的基类以及按语法元素分类的子类。建议的类设计

  • 抽象基类 ASTNode包含所有AST节点共有的属性和方法。例如

    • sourceLocation:源代码位置(行列范围)便于错误提示。
    • accept(ASTVisitor v)如果考虑使用访问者模式进行遍历可以预留accept方法。
    • (如果需要类型系统)type:该节点表达式的计算类型。
  • 表达式类派生自ASTNode用于表示表达式的节点。例如

    • LiteralNode 字面量节点:包含字面量的值和类型(如数值、字符串等)。
    • VariableNode 变量节点:包含变量名,可能还有引用的符号表条目用于指代其声明。
    • BinaryOpNode 二元运算节点包含运算符类型以及左、右子表达式AST节点。
    • UnaryOpNode 一元运算节点(如负号、逻辑非),包含运算符和子表达式。
    • CallNode 函数调用节点包含被调用函数的标识函数名或引用和实参表达式列表。若DSL支持模块命名空间可以在节点中区分函数所属模块或在语义分析时将其解析为函数Symbol引用。
  • 语句类派生自ASTNode用于表示可执行的语句结构。例如

    • AssignNode 赋值语句节点包含左侧的目标一般是VariableNode和右侧的表达式AST节点表示形如变量 = 表达式的赋值操作。
    • IfNode 条件语句节点包含条件表达式以及then分支和else分支的语句块可以是AST节点列表或一个BlockNode封装
    • WhileNodeForNode 循环节点包含循环条件表达式以及循环体语句块节点。根据DSL具体语法选择命名若DSL有特定循环关键字就对应命名LoopNode泛指循环)。
    • ReturnNode 返回语句节点:可能包含返回值表达式节点(如果函数有返回值),或无子节点表示空返回。
    • FunctionCallStmtNode 函数调用语句节点:用于表示独立的一条调用语句(当调用的返回值不被使用时也可视作一条语句),内部可复用CallNode来表示调用的结构。
    • PrintNode 打印语句节点如果DSL支持print等输出功能,可以有专门节点,或者也可以视为特殊的函数调用节点。
    • BlockNode可选代码块节点包含一系列顺序执行的子语句节点列表。对于缩进结构的DSL可以用BlockNode表示缩进构成的语句序列从而AST层次清晰地反映作用域。
  • 模块与函数

    • ModuleNode 模块节点:表示一个模块/命名空间,包含模块名和该模块内定义的函数列表。方便组织多模块代码,以及支持模块间调用。
    • FunctionNode 函数定义节点包含函数名、形参列表、返回类型如果有、函数体通常是一个BlockNode等属性。形参列表可为List<ParamNode>其中ParamNode包含参数名及类型等信息。FunctionNode也可记录所在模块方便区分同名函数。

AST类需要体现出源码的结构和层次。以上只是建议可根据DSL具体特性调整或增加节点类型。例如若DSL采用YAML风格缩进而无显式{}则BlockNode的生成可依据缩进Token产生。

符号表与Symbol类

**符号表Symbol Table**用于在语义分析阶段记录和查询标识符的语义信息。可以设计一个符号表类(或接口)以及符号类来封装信息:

  • 符号表通常可以按作用域分层。典型做法是在每进入一个新作用域如进入一个函数、进入一个代码块如if/while内部创建一个新的符号表或符号表的嵌套结构。符号查找时先查当前作用域找不到再向上层作用域查找。这可以通过符号表类中保存对父级符号表的引用实现。
  • 每个符号表可以使用一个哈希表(如Map<String, Symbol>存储名字到Symbol对象的映射以便快速查找。

Symbol类则代表一个符号(标识符)的信息。可能有多种符号类型,例如变量符号、函数符号、模块符号等,可以用继承或字段区分:

  • 公共字段:name(符号名称),kind(符号种类枚举:变量、函数、模块、参数等),scope(所属作用域或符号表引用)。
  • 变量符号:包含变量名、类型、在运行时的存储分配信息(比如相对于栈帧起始的偏移量、所在寄存器或全局内存地址)。当代码生成时会用到偏移量或索引来定位变量的位置。
  • 函数符号包含函数名、返回类型、参数列表参数也是符号有名称和类型、以及指向函数AST节点或其指令起始位置的引用。还可以包含该函数所属模块的信息。
  • 模块符号:包含模块名以及模块内符号表(用于存储该模块下的函数和全局符号)。模块本身也可视为一种符号,便于在跨模块调用时通过模块符号找到其内部内容。

符号表在语义分析时填充在遇到变量声明时将Symbol加入当前符号表函数定义时将Symbol加入模块符号表模块定义时在全局注册模块符号等等。在语义分析阶段进行标识符解析时通过符号表来验证标识符是否存在以及获取附加信息比如变量类型用于类型检查

虚拟机指令表示

最终生成的虚拟机指令可以用适当的数据结构表示以方便Java代码输出和操作。例如

  • 定义一个指令类 Instruction,包含操作码和操作数字段:
    • opcode操作码可用枚举类型定义全部指令集指令如ADD、SUB、AND、OR、LOAD、STORE、JMP、JMP_IF、CALL、RET、PUSH、POP等。
    • operands:操作数列表,根据指令不同存储整数、浮点数、字符串(用于符号引用)等。例如 LOAD_CONST 20.2 的操作数是一个浮点常量20.2LOAD_VAR x 操作数可以是变量在符号表中的索引或内存地址;CALL f, n 可能有操作数表示被调函数标识符或地址以及参数个数n。
  • 可以为不同指令定义子类或者令Instruction包含所有可能字段如 int operand1, double operand2 等但只使用与该opcode相关的字段。
  • 另一种方式是简单使用字符串或枚举加参数直接拼装指令文本,例如 String codeLine = "LOAD_VAR " + index; 最终组合成可供虚拟机解析执行的文本。但使用类结构可以更安全、结构化地生成和优化指令序列。

此外,可以设计一个容器类如ProgramModuleCode来存放指令列表和相关信息。例如一个Program类包含模块名、指令列表、常量池、调试符号信息等。这样可以方便地序列化、保存到文件或加载执行。

通过上述数据结构设计编译器各阶段可以清晰地交付和操作相应的数据词法分析产生Token对象语法分析组装AST节点语义分析利用Symbol对象检查语义代码生成产生Instruction列表。这些类在Java中应尽量设计为不可变或只在构造时修改如Token、AST节点以保证编译过程的稳定性。

开发顺序建议

开发应遵循编译流程顺序,分阶段实现和测试每个模块,逐步集成。建议的实施顺序:

  1. 实现词法分析器首先编写Lexer模块。根据语言定义列出需要识别的Token类型和模式编写解析逻辑。可采用手写状态机或正则表达式匹配的方式逐字符扫描。实现完成后通过简单源码片段测试输入一段源代码字符串输出Token列表检查是否正确识别了标识符、关键字、数字等忽略了空白和注释。例如测试单行赋值语句、函数头等看Token序列是否符合预期。必要时设计单元测试用例比如给定字符串"x = 5"期望输出TokensIDENTIFIER("x"), OPERATOR("="), NUMBER(5)。如果DSL使用缩进语法也需测试不同行和缩进输出正确的INDENT/DEDENT/NEWLINE Token序列。

  2. 实现语法分析器在Lexer正确工作的前提下编写Parser模块。可采用递归下降算法手写解析器为每个语法规则编写一个方法或其他解析算法如LL(1)预测分析)。首先设计或参考文法规则,明确语法层次。例如:

    • 程序 = 模块列表
    • 模块 = module 模块名: 缩进的函数定义列表 假设DSL用冒号+缩进表示从属关系)
    • 函数定义 = function 函数名(参数列表): 缩进的语句列表
    • 语句 = 赋值语句 | 条件语句 | 循环语句 | 返回语句 | 函数调用语句 | 打印语句 ...
    • 表达式 = 算术表达式 | 函数调用 | 字面量 | 变量 等 根据文法编写相应的解析函数。例如parseExpression处理表达式优先级parseStatement根据下一个Token判断语句类型并调用相应子解析。逐步实现各子模块组装AST节点。

    语法分析阶段需要经常测试可以从简单的语句开始测试AST生成是否正确然后逐步增加复杂结构。比如

    • 测试表达式解析:输入如a + b * 2检查生成的AST节点树形结构BinaryOpNodes嵌套符合期望的优先级。
    • 测试简单语句:如赋值x = 1是否构造出AssignNode子节点正确。
    • 测试控制结构:如ifwhile语句的AST包含条件和子块节点。
    • 测试函数定义一个简单函数解析后AST的FunctionNode有正确的参数列表和函数体子树。 可以编写调试输出函数将AST以缩进形式打印便于人工校验结构。持续迭代发现解析错误及时修正文法规则或解析实现。
  3. 实现语义分析器在能够成功构造AST后开发Semantic Analyzer。首先设计符号表数据结构然后遍历AST进行检查

    • 符号表建立比如先遍历所有模块和函数声明将函数Symbol加入模块符号表支持前向引用的语言需要先收集声明。然后再遍历函数内部将参数和局部变量声明加入对应符号表。
    • 语义检查第二轮或在同一遍历中检查每个AST节点的语义
      • 对每个变量引用节点(VariableNode)在符号表中查找其声明Symbol不存在则报错存在则可以在该节点上附加符号信息方便代码生成
      • 对函数调用节点(CallNode)检查函数名解析首先根据是否有模块前缀解析模块符号再查找函数Symbol。校验参数个数和类型是否匹配函数签名。
      • 检查控制流如ReturnNode是否在函数内返回类型与函数声明的返回类型匹配循环中的Break/Continue如果支持是否在循环体内。
      • 类型检查(如需要):表达式节点,根据子节点类型推导结果类型,检查赋值两边类型兼容等等。
    • 注解/标注将解析出的符号和类型信息记录回AST或Symbol表。例如每个VariableNode关联它所引用的SymbolCallNode关联目标函数的Symbol方便后续直接使用已经解析的地址或类型信息。
      测试语义分析需要构造各种场景:
    • 正确的代码应通过检查,符号表内容正确。
    • 刻意制作错误代码,如引用未声明变量、函数调用参数错误等,验证是否能捕获并输出适当错误信息(包含行号和错误原因)。 对于通过的代码可打印符号表或在AST节点附加的信息核对是否准确如变量Symbol的偏移、类型函数Symbol的参数列表等
  4. 实现指令生成器语义检查正确后实现将AST转换为虚拟机指令。可使用AST遍历(如递归或访问者模式)生成代码:

    • 考虑函数为最基本的编译单位对每个函数的AST生成一段指令序列。可以在函数Symbol中记录起始指令的索引或地址。
    • 表达式代码生成:采用后序遍历,对每个表达式节点生成指令:
      • 例如BinaryOpNode先生成左子表达式的指令序列把结果压入栈再生成右子表达式指令结果压栈最后根据运算符opcode生成对应算术指令如ADD。如果是短路逻辑运算符还需特殊处理跳转。
      • VariableNode变量取值根据符号表偏移生成LOAD_VAR offset指令,将变量值压栈。
      • LiteralNode生成LOAD_CONST value指令。
      • CallNode先计算每个实参表达式将值按顺序压栈然后生成调用指令CALL func。调用指令的操作数可以是被调用函数在全局的地址/索引,或者符号名由虚拟机在运行时链接。调用完毕后,约定返回值会压入栈顶。
    • 语句代码生成:依照语句类型生成:
      • AssignNode对右侧表达式生成指令结果在栈顶然后根据左侧变量偏移生成STORE_VAR offset指令将值弹栈存入变量。
      • IfNode生成条件表达式指令接着是一条条件跳转指令JMP_IF_FALSE跳过then块then块语句生成如果有else块则再插入无条件跳转跳过else块的指令以及相应的标签处理else块语句生成。需要适当设置跳转目标地址在生成过程中可能需要回填先生成占位跳转再知道目标位置后回填
      • WhileNode需要创建循环起始标签生成条件表达式接条件跳转指令跳出循环跳转到循环后位置; 循环体指令; 最后插入跳转回循环开头的指令形成闭环; 回填跳出循环的目标位置到之前的条件跳转指令。
      • ReturnNode如果有返回值表达式则先生成其代码将值压栈然后生成RET指令操作数可能用于返回值或直接弹栈返回。约定RET执行时虚拟机会将栈顶值作为返回值处理。
      • PrintNode或调用printf等生成参数表达式后调用输出函数的指令可视作特殊的函数调用或特定的I/O指令
    • 汇编/输出:将生成的指令序列输出。在测试阶段,可以先输出人类可读的指令文本(模拟汇编形式),以验证逻辑。例如:
      LOAD_VAR 0      ; 将局部变量0 (input_number) 压栈  
      LOAD_CONST 20.2 ; 将常数20.2 压栈  
      CALL CommonTasks.add_numbers, 2 ; 调用函数,加两个参数  
      STORE_VAR 1     ; 弹栈将结果存入局部变量1 (sum_result)  
      
      通过编译简单函数并打印指令序列,人工核对指令逻辑是否正确,尤其关注跳转指令的目标是否正确计算,函数调用前后栈平衡是否正确等。如果有自己的虚拟机执行环境,进一步可以将生成的指令送入虚拟机运行,看输出结果是否符合源代码预期。
  5. 集成和整体测试各模块分别验证后整合完整的编译流程。从词法分析到代码生成编写一个总控程序给定源码文件依次调用lexer、parser、semantic、codegen最终产出可执行指令。编写多个样例程序做集成测试

    • 简单算术运算、条件、循环、小函数调用等,编译后在虚拟机上运行,核对运行结果。
    • 包含一些语法/语义错误的代码,看是否在编译期得到合理的错误输出。
    • 多模块调用的程序,验证跨模块函数调用的正确性。 通过循序渐进的测试与调试,保证各阶段正确衔接。

这一开发顺序遵循由浅入深、分模块实现的原则,每完成一阶段都验证其输出,再进入下一阶段,从而快速定位和修复问题,确保最终构建出正确的编译器。

错误处理建议

良好的错误处理对编译器尤为重要,应提供清晰的错误信息且在可能情况下尽量恢复继续分析,以发现更多错误。以下是各阶段的错误处理建议:

  • 词法分析错误如果Lexer遇到非法字符或不匹配任何已知Token规则的文本片段应报告词法错误。可以抛出自定义异常LexicalException携带位置信息和错误原因或者返回一个特殊的ERROR Token。由于词法错误往往会导致无法继续解析该错误一般是不可恢复的停止进一步编译。对于可跳过的简单错误如不支持的转义符Lexer可选择跳过字符继续但总体上应中止并报告让用户修改源码。

  • 语法分析错误当Parser检测到Token序列不符合语法期望例如缺少右括号、遇到不合法的关键字顺序应抛出SyntaxException或记录错误消息。错误信息应包括出现错误的源码位置和预期的语法构造。例如“Syntax Error: 在第10行: 缺少右括号 ')', 在func(a, b处”。
    为了提高用户体验Parser可以实现简单的错误恢复策略:

    • Panic模式跳过一定范围的Token直到可能的语句边界例如跳到下一行换行或分号后再继续解析。这允许找到后续语句的错误而不被前一处错误完全阻断。
    • 插入或删除在容错模式下Parser可假设缺失某符号并继续如遇到缺少的右括号可以假装看到一个)),同时记录一条错误。
      基于实现复杂度考虑初期实现可在报错后终止解析。但设计上应使Parser易于扩展错误恢复功能。所有语法错误都应汇总报告错误信息应当语句清晰、指明问题和位置。
  • 语义分析错误在AST检查过程中如发现语义不一致应记录语义错误并标记编译失败。典型语义错误及处理:

    • 未声明标识符如变量或函数使用前未声明。在VariableNode或CallNode分析时检测报告错误“Semantic Error: 未定义的变量x位于第N行第M列” 或 “函数Foo未定义” 等。
    • 重复定义:如同一作用域内变量重名,函数重复定义。应在插入符号表时检查,如果已存在同名符号则报告错误以及位置。
    • 类型不匹配:如赋值语句左右类型不兼容,函数调用实参类型与形参类型不符等。报告应指出期望类型和实际类型。
    • 其他语义约束:如return语句不在函数内使用,break不在循环中使用。这些情况在检测到时,也应报告错误和发生位置。
      语义错误通常可以在一次遍历中收集多个而不马上终止编译。例如可以将所有错误信息存入一个列表,语义分析遍历结束后,如果列表非空,则一起输出所有错误。但是,对于某些致命错误(比如后续分析依赖的信息缺失),也可以选择中止。
      错误消息应避免晦涩技术术语,尽量友好说明问题。例如:“类型错误:尝试将字符串赋值给数值变量 'count' (第20行)”等。
  • 错误报告统一与模型:建议定义一个通用的错误表示结构,例如CompileError类,包含错误类型(词法/语法/语义、错误消息、源代码位置等。编译过程中捕获到的错误都转换为CompileError对象并收集。在编译结束时若有错误列表则按照出现顺序输出所有错误信息并终止代码生成。
    在IDE或命令行环境中错误输出格式可以统一为“文件名:行:列: 错误类型: 描述”。这种模型便于将来拓展(如引入更多错误类型、警告类型等)。

总之,错误处理应贯彻“早发现、早报告”和“清晰定位”的原则。前端检测(词法/语法/语义)的错误比运行期错误更容易理解,因此编译器应尽量完善前端检查,让用户在编译阶段就修复问题。设计良好的错误处理模块能显著提升编译器的可用性和健壮性。

可扩展性建议

在编译器架构设计中,应考虑未来可能的功能扩展,例如优化器、调试支持、类型检查的加强等。通过模块化和面向对象设计,可以使编译器易于扩充而不需推倒重来。以下是几个方面的扩展建议:

优化扩展

目前的编译流程直接从AST生成虚拟机指令。为了将来支持代码优化可以在架构中预留**中间表示(IR)**或优化阶段:

  • 中间表示可在语义分析之后引入一个中间代码生成阶段将AST转换为一种更低级但易于优化的表示形式例如三地址码、控制流图、四元组等。然后在此IR上进行优化处理如常量折叠、死代码消除、公共子表达式消除等再将优化后的IR翻译为最终指令。由于当前直接生成虚拟机指令也能工作中间表示阶段可以以后按需添加。确保AST结构设计和代码生成模块松耦合有助于将来插入IR阶段而影响最小。
  • 模块化优化如果暂不采用中间表示也可以在现有AST基础上进行优化。设计Visitor模式方便新增遍历。例如可以新增OptimizationVisitor遍历AST对某些模式进行变换如乘2替换为左移1位指令等。通过访问者不需修改原有Parser或AST结构即可插入优化流程。
  • 可配置开关:架构上可以让优化阶段可选启用,如提供编译选项控制是否运行优化。当扩展优化功能后,不影响原有正常编译,只在需要时插入额外步骤。

总的来说,通过引入清晰的中间层和访问者模式,优化器可以作为一个插件式模块加入,提高代码运行效率。

调试支持扩展

为了支持将来的调试和分析,需要在编译时生成必要的调试符号信息,并设计编译器输出使之便于调试:

  • 调试符号信息:在代码生成阶段,可以收集源代码与指令的对应关系。例如维护一个映射,记录每条虚拟机指令对应的源代码行号、所在函数。当运行在调试模式的虚拟机遇到断点或异常时,可以通过该映射还原源码位置。这要求编译器在输出指令时附带这些元数据(可以存在一个专门的调试信息区段或者一张表)。
  • 变量和符号信息:为了在调试时查看变量值,编译器应保留变量的符号表或名称到运行时存储位置的映射。可以在输出中包含局部变量表结构,使调试器在检查内存或栈时知道哪个槽位对应哪个变量名。同样地,函数的符号也可保留用于更友好的堆栈调用信息。
  • 调试选项:编译器设计中可以加入调试选项,比如-g开启调试符号生成。当此选项启用时,上述额外的符号/行号信息会打包进输出;未启用时则省略这些信息以优化体积和速度。
  • 友好的错误和日志:在非运行时的调试,例如编译错误场景,编译器本身也可提供日志可视化AST的功能帮助调试编译器或DSL程序。例如在调试模式下输出详细的解析过程、符号表状态等。这些都可通过配置开关实现不影响正常编译路径。

通过这些设计,将来可以实现单步执行、断点、查看变量等调试功能,使自定义语言的开发体验更接近成熟语言环境。

类型检查扩展

如果当前DSL类型系统简单比如可能只有数值类型或弱类型未来可能引入更丰富的类型检查,包括自定义数据类型、类型推导、函数重载等。为此,在现有设计中可以做好准备:

  • 类型信息在AST和Symbol中的存储即使暂时只有基本类型也可以在AST节点和Symbol类中加入类型字段。例如LiteralNode记载其类型(整型、浮点型等)Variable Symbol记录声明类型或推导类型。这样将来增加类型时不需大改数据结构。
  • 类型检查模块化:目前语义分析或代码生成可能对类型未严格要求,但将来可以把类型检查作为语义分析中的独立步骤。例如引入一个TypeChecker访问AST根据上下文推导类型、检测不一致。通过将类型检查逻辑集中未来扩展如增加类型转换规则、泛型等会更容易管理。
  • 错误和警告机制随着类型系统复杂化可能出现类型相关的警告例如隐式类型转换的精度丢失等。因此错误报告机制应能区分严重错误和一般警告允许编译器在遇到非致命问题时提示但继续编译。这可以通过扩展CompileError类加入级别属性实现。
  • 标准库类型支持未来或许增加更多内建类型字符串、布尔、集合等和相应操作。编译器符号表可设计为易于注册内建类型和函数比如启动时预先载入一些核心库Symbol。如果有类型检查需要让这些内建符号携带正确的类型签名。

通过以上扩展措施,编译器能够从当前的简单类型逐步演进到强类型系统,而不用推翻既有架构。

其它设计扩展考虑

  • 模块和多文件编译:如果将来需要支持多文件编译或模块单独编译,编译器架构应考虑分步编译和链接。例如可以实现每个模块单独编译成中间表示或字节码文件,然后通过链接器组合。这要求符号表设计支持全局符号解析、未定义符号留待链接解决等。当前实现可以简单假设所有源码一次性编译,未来可逐步转为更灵活的编译流程。
  • 后端移植当前后端目标是自定义虚拟机指令集。若未来考虑输出其他形式例如直接生成Java字节码或机器码可以在后端模块中采用接口/抽象工厂模式,使得不同的CodeEmitter实现对应不同目标。当增加新后端时,复用前端,只需实现新的指令选择和输出模块。这也是扩展性的一环。
  • 性能与并行对于大型源码将来可考虑词法和语法分析的性能比如引入多线程编译不同模块。设计时保持模块边界清晰有助于并行化例如每个模块AST可并行语义分析再汇总符号表。虽然目前可能不需要但良好的模块划分为将来性能优化留出空间。

综上,编译器架构在初始设计时就应秉承高内聚、低耦合原则,各阶段通过清晰的数据接口衔接。这样在需要添加新功能时,可以在原有框架基础上拓展新模块或替换某模块的实现,而不影响其他部分。例如,可以方便地插入一个优化器、启用调试信息生成、增强类型系统。这种可扩展性设计确保编译器能够随着用户需求的演进而演化,而无需推倒重写。

编译流程示例:代码片段完整编译过程

下面通过一个简单示例展示从源代码到虚拟机指令的完整编译过程。示例源代码片段为:

sum_result = CommonTasks.add_numbers(input_number, 20.2)

假定这行代码位于某个函数体内,作用是调用模块CommonTasks中的函数add_numbers,传入参数input_number和字面量20.2,将返回值赋给本地变量sum_result。编译过程各阶段处理如下:

  1. 源代码:编译器读取到这一行源代码字符串:

    sum_result = CommonTasks.add_numbers(input_number, 20.2)
    

    (在完整源码中,这可能缩进于一个函数定义下。)

  2. 词法分析Token序列Lexer按规则将该行拆解成Token序列

    • 标识符sum_result → Token(TYPE=IDENTIFIER, lexeme="sum_result")
    • 运算符= → Token(TYPE=OPERATOR, lexeme="=")
    • 标识符CommonTasks → Token(TYPE=IDENTIFIER, lexeme="CommonTasks")
    • 分隔符. → Token(TYPE=DOT, lexeme=".")
    • 标识符add_numbers → Token(TYPE=IDENTIFIER, lexeme="add_numbers")
    • 分隔符( → Token(TYPE=LPAREN, lexeme="(")
    • 标识符input_number → Token(TYPE=IDENTIFIER, lexeme="input_number")
    • 分隔符, → Token(TYPE=COMMA, lexeme=",")
    • 数字字面量20.2 → Token(TYPE=NUMBER, lexeme="20.2", value=20.2)
    • 分隔符) → Token(TYPE=RPAREN, lexeme=")")
    • (行结束)换行或缩进减少 → Token(TYPE=NEWLINE, lexeme="\n") 等(具体取决于语法对换行的处理)

    词法分析输出的Token列表按顺序传递给Parser。对于本行无非法字符词法分析顺利完成。

  3. 语法分析ASTParser根据语法规则识别出这是一个赋值语句带有一个函数调用表达式。构造出的AST抽象语法树结构可以表示为

    AssignNode
    ├── target: VariableNode("sum_result")
    └── value: CallNode
          ├── func: "CommonTasks.add_numbers"  (可能拆分为 Module="CommonTasks", FuncName="add_numbers")
          └── args:
              ├── VariableNode("input_number")
              └── LiteralNode(20.2)
    

    解释:

    • 根节点是一个AssignNode表示赋值操作。
    • AssignNode的target子节点是VariableNode("sum_result"),表示将值存入的目标变量。
    • AssignNode的value子树是一棵函数调用表达式。CallNode表示调用CommonTasks.add_numbers,它包含对被调用函数的引用以及参数列表。
    • 在CallNode下args列表有两个元素VariableNode("input_number")表示第一个实参引用了变量input_numberLiteralNode(20.2)表示第二个实参是20.2这个字面量常数。

    AST很好地反映了语句的层次结构赋值语句的右侧本身是一个复合的函数调用表达式。此阶段Parser已确保语法正确例如括号配对、逗号分隔等都符合规则。如果有语法错误此时Parser会报错而无法生成上述AST。

  4. 语义分析Semantic Analyzer对AST进行符号和类型检查

    • 符号表在进入当前函数时已包含其参数列表以及局部变量声明。我们假设符号表中已存在符号:input_number(函数参数),sum_result(事先声明的本地变量)。如果sum_result尚未声明那么在此赋值前应报语义错误“未声明变量sum_result”。
    • 解析函数调用符号:CommonTasks.add_numbers应拆解为模块名和函数名。首先在全局/模块符号表中查找模块CommonTasks,如果找到,再在该模块的符号表中查找函数add_numbers。假设在其他源码中有对CommonTasks模块及其函数的声明那么符号表能解析成功得到函数符号包含其参数类型列表、返回类型等
    • 检查函数参数匹配:假设add_numbers函数期望两个参数,第一个类型与input_number类型匹配例如都是数值型第二个参数应接受一个数值常量20.2(浮点型)。如果类型不符(比如input_number是整型但函数需要浮点型),可以在此报类型警告或错误。如果类型系统不严格,也可以允许自动转换。
    • 将符号信息注入ASTVariableNode("input_number")绑定到符号表中input_number的Symbol实例包含其类型、偏移等VariableNode("sum_result")绑定到sum_result的Symbol。CallNode可能存储解析到的函数Symbol引用包括其在虚拟机中的入口地址或一个符号ID参数类型签名等
    • 其他检查:确认CommonTasks.add_numbers函数是否有返回值。如果函数声明有返回赋值接收返回是合理的如果函数为void却被用在赋值将报告语义错误。这里假设add_numbers有返回值。

    经过语义分析这行代码没有发现错误AST节点被注解上了相关符号和类型信息符号表也准备好供代码生成使用。例如符号表可能提供input_number在当前函数栈帧偏移0的位置sum_result偏移1的位置函数CommonTasks.add_numbers对应全局函数索引或入口地址等信息。

  5. 指令生成Code Generator遍历AST生成目标虚拟机指令序列。按照前述AST

    • 对AssignNode进行代码生成时先处理右侧的CallNode(因为需要计算出值再赋给左侧)。
    • 生成函数调用代码:
      1. 参数1 (VariableNode("input_number")): 生成加载变量指令,将input_number值压入栈。例如: LOAD_VAR 0 ; 将偏移0处的变量 input_number 压入栈 操作数0是input_number`在当前作用域的偏移由符号表提供。LOAD_VAR指令属于内存操作将局部变量读取到计算栈
      2. 参数2 (LiteralNode(20.2)): 生成加载常量指令将20.2压入栈,例如:
        LOAD_CONST 20.2 ; 将常数20.2 压入栈
        编译器可能在常量池维护20.2并引用之,这里为简化直接写值)
      3. 调用 (CallNode本身): 现在实参已按顺序在栈上,生成函数调用指令。比如:
        CALL CommonTasks.add_numbers, 2 ; 调用CommonTasks模块的add_numbers函数传入2个参数
        该指令会跳转到指定函数执行,完成后将返回值压入栈顶。这里操作数标识了被调用函数,可以是一个引用索引、地址,或模块+函数名称组合由虚拟机解析。参数个数2可能用于调用约定虚拟机根据此清理参数等
    • 函数调用完成后,虚拟机栈顶留下了add_numbers的返回值。接下来生成AssignNode左侧赋值的代码 4. 赋值目标 (VariableNode("sum_result")): 赋值将把栈顶值存入目标变量。根据符号表,sum_result是当前函数偏移1的局部变量于是生成
      STORE_VAR 1 ; 将栈顶值弹出并存入偏移1处的局部变量 sum_result
      STORE_VAR指令弹出一个值存入指定位置实现赋值操作。完成后栈顶返回值被清除且写入变量。

    汇总以上步骤,最终的指令序列(伪代码形式)如下:

    LOAD_VAR 0            ; 载入 input_number (参数1)
    LOAD_CONST 20.2       ; 载入常数20.2    (参数2)
    CALL CommonTasks.add_numbers, 2   ; 调用函数,计算 add_numbers(input_number, 20.2)
    STORE_VAR 1           ; 将返回值存入 sum_result
    

    这段指令完成了源代码要求的功能:调用函数并获取结果到目标变量。若在函数末尾,还可能需要相应的RET指令,但这里仅示范单条语句的翻译。

  6. 虚拟机执行(编译输出的利用):编译器将上述指令输出到目标格式(内存或二进制文件)。之后用户的自定义虚拟机可以载入并解释执行这段指令。例如,在运行时当执行到CALL CommonTasks.add_numbers时,虚拟机跳转到事先编译好的CommonTasks.add_numbers函数指令序列执行,完成后将返回值压栈,再继续后续STORE_VAR 1。执行完毕后虚拟机的栈帧中偏移1处sum_result的位置就存放了计算结果。由此可见源代码中的意图已经通过编译后的指令得到实现。

这个示例展示了编译器如何分阶段处理源代码词法分析获得Token流语法分析形成AST语义分析确保符号合法并注解信息最后代码生成转化为低级指令。整个过程中各模块各尽其职将高级DSL逐步翻译为自定义虚拟机可以理解和执行的形式。

Description
No description provided
Readme 3.3 MiB
Languages
Java 99.9%
PowerShell 0.1%