LLVM-和-MLIR-编译器构建笔记-全-
LLVM 和 MLIR 编译器构建笔记(全)
001:课程介绍与规划 🎬
在本节课中,我们将介绍一个关于如何使用LLVM和MLIR构建编译器的视频系列。我们将了解课程的目标、内容规划以及主讲人正在开发的新编程语言Serene的背景。
大家好,欢迎来到我的视频系列,主题是如何使用LLVM和MLIR构建编译器。我是Samir,一名对编程语言着迷的软件工程师。我为自己创建一门新编程语言的工作已经进行了两年多。我认为,围绕这个主题开始一个视频系列可能是个好主意,因为它非常有趣,并且在互联网上,特别是关于LLVM和MLIR,有大量优秀文档。尽管如此,你仍然需要阅读大量源代码,进行自己的研究,这并非易事。因此,我在这里录制关于这个主题的第一集。
今天,在这一集中,我将主要谈谈本系列后续的计划,以及我过去两年一直在开发的编程语言的一些历史。
课程目标与内容规划 🗺️
首先,让我们从基本内容开始,这个视频系列是关于什么的。
在系列的其余部分,我们希望创建一门新的编程语言。这门语言的大部分我已经完成,我将向大家展示它,并讨论这门新语言的不同部分和组件。
除了创建编程语言,我的实际目标之一是让这个视频系列成为任何可能对贡献这门名为Serene的语言感兴趣的人的指南。另一个目标是让这个视频系列成为对LLVM和MLIR(LLVM的一个子集)感兴趣的人的教程或指南。LLVM网站上有关于LLVM和MLIR的优秀文档,甚至有一个创建简单语言的教程。然而,这并不意味着阅读那个教程就能理解一切。你仍然需要努力学习,阅读大量源代码,因为即使是LLVM.org上可用的文档也非常庞大,你必须深入研究,在不同社区提问,阅读大量源代码,并且没有明确的路径来充分利用LLVM。
我希望创建这个视频系列,作为每个人学习LLVM和MLIR的指南。
对于系列后续视频的计划是:我不会进行实时编码。我会自己编写一些内容,尝试找出解决方案,解决我面临的一些问题。当我达到某个里程碑时,我会录制一个视频,描述我所做的工作,希望这能成为该部分的指南。例如,我下一集的计划是讨论构建系统,并可能从语言的读取器部分开始,但这一点我们稍后再谈。
今天是2021年7月2日。我计划为每一集在录制时创建一个对应的代码分支。主分支将继续其自身的开发。对于未来观看此视频的任何人,你需要参考每集对应的分支,以找到我将要讨论的内容。我计划长期保留这些分支,以便与视频和每集内容匹配。
另外,我不能自称为专家。我只是一个对这些主题着迷,并且非常喜欢语言设计的人。我进行自己的研究和学习。但如果你发现某些内容引起了你的兴趣,请随时为项目做出贡献。我将非常乐意审阅你的贡献。
语言背景:Serene的演进历程 🔄
让我简要介绍一下这门语言本身的历史,我们将在下一集中详细查看。
在过去的两年里,我一直在致力于创建一门新语言。我最初从非常简单开始,用不同的语言进行了多次实现。我从Java开始,创建了一个非常简单的Lisp解释器,实现起来很容易,并且按预期工作。这只是为了获得更多关于我需要做什么、读取器会是什么样子、或者在设计语言时会面临哪些挑战的经验。我最初通过创建解释器而不是编译器来简单开始。
但渐渐地,当我在不同的实现中添加新功能时,我意识到拥有一个解释器可能很酷、很好用,但它不会与Lisp的其他方言或其他脚本语言(如Python)有太大不同。坦率地说,我创建的任何解释器都无法与Ruby或Python这样的语言竞争,因为它们背后有超过25年的经验支持。
因此,我逐渐决定对不同主题进行更多研究,比如类型系统、语言的各个方面。我利用我的研究成果,使用不同的语言实现了不同的版本,以找出哪个平台可能适合我正在开发的语言。显然,第一个是JVM,它运行良好但并非完美。我转而使用GraalVM和Truffle库,在这方面做了一些工作,看起来很有前景。我在我的博客上写了一篇关于为选择合适平台所做的整个研究的博文。我甚至在Go中实现了另一个解释器,用于引导编译器。
当我接触到LLVM,特别是MLIR时,一切都改变了。LLVM设计得非常优雅、精良,简直让我叹为观止。尤其是当我看到MLIR时,我想,好吧,我不需要其他任何东西了,就是它了,这是最好的选择,让我们开始吧。
在未来的剧集中,我将更深入地讨论LLVM和MLIR,以及我为什么做出这个决定,为什么LLVM是最好的。但现在,简单来说,LLVM之所以好,是因为它是模块化的,并且是作为一个库、一个框架设计的,旨在帮助你创建自己的编译器。LLVM的主要语言是C++,所以显然我们需要在一定程度上了解C++。我自己也不是C++专家,但无论如何我们都会使用它,因为LLVM的API是C++的,并且我们需要了解一点如何使用CMake构建系统。这并不需要你成为专家。
主要的代码存在于GitHub上的代码仓库中。我们有两个主要的仓库。起点简单的是Java实现,那是我很久以前完成的。我在这里做了一些调整,但大部分代码是很久以前完成的。没有分支。
第二个也是最重要的仓库是Serene语言本身。它包含许多不同的分支。我将我在不同分支上完成的其他实现保留了下来,只是作为一种历史记录。比如Go语言的实现、C++实现的第一次尝试,这里应该还有一个Rust的实现。到目前为止,我已经为这门语言创建了足够的基础设施,包括一个读取器、一个语义分析器和几层中间表示语言,我们将在未来的剧集中深入讨论。

目前,我的目标并不是让它拥有任何特定的功能,类型系统是缺失的。目前它能编译的只有函数和整数。但我的目标是将编译器中的每一个功能部件连接起来,使其能够从头到尾编译某个文件,从读取文件到生成实际的二进制文件,并且还要创建一个即时编译器。总的来说,我想要一个编译器的最小可行产品,你知道,就像最简可行产品一样。不,抱歉,我想要一些真正最小化的东西,它只要能工作就行。但这将给我足够的洞察力来了解不同的部分,这样我就不会过度设计任何部分,或者在未来可能需要改变的部分上花费太多时间,老实说,这种情况在我身上发生过很多次。

所以现在,在7月2日,我正处于这个状态,但它已经足够好,我们可以开始讨论读取器、语义分析器以及源代码中不同的类。

参与与联系 🤝
以上就是本集的内容。但如果你有兴趣提供帮助,请联系我。我也会分享我的信息。
我的网站是hcoder.org。我的电子邮件地址是samir@hcoder.org。请随时联系我。另外,如果你对这个主题感兴趣,特别是如果你想了解新剧集的更新,请订阅这个频道。
希望在下集见到你,谢谢。


在本节课中,我们一起学习了这个编译器构建视频系列的目标和整体规划。我们了解到课程旨在通过实际项目Serene语言,引导大家学习LLVM和MLIR的核心应用。我们回顾了Serene语言从简单解释器到基于LLVM/MLIR的编译器架构的演进历程,并明确了当前项目状态和后续学习路径。下一节,我们将开始深入探讨具体的实现,从构建系统和读取器部分入手。
002:基础环境搭建 🛠️
在本节课中,我们将学习如何为Serene编译器项目搭建基础的开发环境。这包括安装必要的依赖项,特别是从源代码编译LLVM,以及了解项目自带的构建脚本。
概述 📋
在开始编译器开发之前,我们需要配置好开发环境。本节将指导你完成LLVM的安装、构建脚本的使用,并对项目源代码结构进行初步了解。
安装依赖项
以下是构建Serene编译器所需的主要依赖项。建议从源代码编译LLVM以获得最佳兼容性。
LLVM项目
LLVM是核心依赖。虽然许多Linux发行版通过包管理器提供预编译的LLVM,但为了获得更好的结果并满足Serene的特定需求(例如后续使用MLIR),建议从源代码编译。
- 版本选择:你可以使用当前的稳定版本(如LLVM 17),也可以使用最新的开发版本。Serene项目基于C++17。
- 编译架构:需要至少支持X86架构。
- 编译模式:建议使用
Release模式进行编译。Debug模式编译时间极长,且会拖慢后续基于LLVM的代码编译速度。
其他工具
除了LLVM,还需要以下工具,它们通常可以通过系统包管理器安装:
- CMake:构建系统生成器。
- Ninja:构建工具。
- ccache:编译缓存工具,能显著加速重复编译过程。
- Valgrind:用于检测内存泄漏等问题的工具。
从源代码编译LLVM
上一节我们介绍了所需的依赖项,本节中我们来看看如何具体从源代码编译LLVM。
- 获取源代码:克隆或下载LLVM的源代码。
- 创建构建目录:在源代码目录外创建一个独立的构建目录。
- 配置CMake:在构建目录中运行CMake命令来生成Ninja构建系统。需要启用一些关键选项。
以下是配置CMake时建议使用的重要选项:
cmake -G Ninja ../llvm-source \
-DCMAKE_BUILD_TYPE=Release \
-DLLVM_TARGETS_TO_BUILD="X86" \
-DLLVM_ENABLE_PROJECTS="clang;lld;mlir;clang-tools-extra;compiler-rt" \
-DCMAKE_C_COMPILER=clang \
-DCMAKE_CXX_COMPILER=clang++
注意:
-DCMAKE_C_COMPILER=clang和-DCMAKE_CXX_COMPILER=clang++这两个选项要求你的系统已安装Clang。如果未安装,可以省略,使用系统默认的GCC编译LLVM,或者先通过包管理器安装Clang。
- 编译与安装:使用
ninja命令进行编译,完成后运行ninja install进行安装。 - 配置环境变量:将LLVM安装目录下的
bin文件夹路径添加到系统的PATH环境变量中。
提示:编译LLVM是一个耗时很长的过程,在一台现代笔记本电脑上可能需要1到1.5小时,请耐心等待。
使用Serene构建脚本
LLVM安装完成后,我们就可以开始构建Serene项目了。项目根目录下提供了一个名为b的构建脚本,它是CMake的封装,简化了构建流程。
脚本功能简介
该脚本接收子命令作为参数,并执行相应的操作。在运行脚本前,请确保位于Serene项目的根目录下。


以下是b脚本支持的一些重要子命令:

./b build:清理旧构建,重新生成构建系统并从头编译所有内容(Debug模式)。./b build-release:功能同上,但使用Release模式编译。./b compile:不重新生成构建系统,仅编译发生更改的文件。在开发过程中最常使用。./b run <参数>:运行已编译的Serene编译器二进制文件,并将<参数>传递给它。./b test:清理并构建整个项目,然后运行所有测试用例。./b memcheck:使用Valgrind工具构建并运行项目,用于调试内存泄漏问题。

示例:构建与运行

首先,使用build命令进行完整构建:

./b build

如果后续修改了源代码,可以使用compile命令进行增量编译,这比重新构建要快得多:

./b compile

编译成功后,可以使用run命令来运行编译器。例如,编译一个Serene源文件并输出其MLIR表示:
./b run --show-mlir /path/to/hello.sn
项目源代码结构简介
在深入了解代码之前,让我们先熟悉一下Serene项目的目录结构,这有助于你更好地导航代码库。
bin/:存放serene.cpp,这是编译器的主入口点。build/和build-release/:由构建脚本生成的临时构建目录。include/serene/:存放所有的头文件(.h、.hpp)。src/serene/:存放所有C++源文件(.cpp)的实现。头文件对应的实现通常放在这里,且文件名与头文件一致。test/:存放所有的测试用例。dev.org:一个Org-mode格式的文件,包含了项目的高层待办事项(TODO)和大量与编译器构建相关的学习资源链接,对于初学者非常有价值。
总结 🎯
本节课我们一起学习了搭建Serene编译器开发环境的基础步骤。我们强调了从源代码编译LLVM的重要性,介绍了项目自带的构建脚本b及其常用命令,并对项目的源代码结构有了初步了解。现在,你的开发环境应该已经准备就绪,可以开始探索和修改Serene编译器的代码了。

在下一节课中,我们将探讨编程语言设计的一些高层基本概念,并分析使用LLVM的优缺点。
003:概述

在本节课中,我们将对LLVM、MLIR以及Serene编译器项目进行一个高层次的概述。这将帮助我们理解整个项目的架构和各个组件的基本工作原理。
通用编译器简介
首先,让我们简要回顾一下通用编译器的基本结构。理解这一点对于后续学习LLVM和MLIR至关重要。
以下是两本推荐的编译器书籍:
- 《Modern Compiler Implementation in ML》:这本书相对精炼(约500页),提供了ML、C和Java三种语言的实现。其中ML版本的实现非常优雅,易于理解,非常适合初学者入门。
- 《Compilers: Principles, Techniques, and Tools》(龙书):这是编译器的经典参考书,内容非常详尽(超过1000页)。建议在有一定基础后作为深入学习资料。
本系列视频假设你已经具备编译器的基础知识。大多数编译器在处理源代码并生成目标代码时,会经历一系列阶段。我们通常将这些阶段分为三大类。
编译器前端
前端负责读取源代码,进行分析,并构建出相应的数据结构。它通常包含以下三个最常见的步骤:
以下是前端的主要组成部分:
- 词法分析器:将源代码字符流分解为一系列有意义的标记。
- 语法分析器:根据词法分析器产生的标记流,检查源代码的语法结构,并构建抽象语法树。
- 语义分析器:对AST进行语义分析,并根据语言的语义规则将其重写为另一个更精确的AST。例如,在Lisp中,一个列表可能被识别为函数调用,语义分析器会将其AST节点重写为函数调用节点。
编译器中端与后端

上一节我们介绍了编译器前端,本节中我们来看看中端和后端。
中端负责将语义正确的AST转换为某种中间表示,并在此之上进行一系列优化,为后端生成目标代码做好准备。不同的编译器有不同的中间表示形式。
后端则负责将优化后的中间表示转换为最终的目标代码。目标代码的形式取决于编译器的设计,可以是机器码、JavaScript或WebAssembly等。
LLVM 简介
现在,让我们将目光转向LLVM。LLVM本身就是一个编译器基础设施项目,其设计完美体现了上述的编译器架构。
LLVM主要分为三个部分:
- 前端:读取源代码(如C、C++、Rust),并生成LLVM中间表示。LLVM IR是一种类似于汇编的、独立于源语言和目标平台的中间语言。
- 优化器:接收LLVM IR,通过一系列称为Pass的优化模块对其进行处理和优化,输出优化后的LLVM IR。你可以将优化器视为一个处理LLVM IR的流水线。
- 后端:接收LLVM IR,并针对不同的目标平台(如x86、ARM、PowerPC、WebAssembly)生成相应的机器码或目标代码。

使用LLVM构建编译器(如Clang、Rust)意味着我们只需实现一个前端,将我们的源代码转换为LLVM IR。之后,我们可以直接利用LLVM提供的、经过充分测试的优化器和多平台后端,从而获得以下优势:
- 支持多平台:借助LLVM的后端,我们的编译器可以轻松支持多种硬件架构。
- 丰富的优化:直接使用LLVM社区提供的海量优化Pass,无需从头实现复杂的优化算法。
- 调试支持:通过正确生成LLVM IR,可以天然获得LLVM提供的调试信息生成能力。


MLIR 简介
了解了LLVM之后,我们来看看MLIR。MLIR是LLVM的一个子项目,它主要工作在“中端”层面,为解决LLVM IR在某些场景下的局限性而生。
许多基于LLVM的语言(如Swift、Julia)发现,直接在LLVM IR上进行高层语义分析和优化并不方便。因此,它们会在LLVM IR之上再构建一层更贴近源语言语义的中间表示。
MLIR正是为了规范化和支持这种需求而诞生的。它提供了一个框架,允许编译器开发者创建自定义的方言。每个方言可以定义自己的类型、操作和语义,从而能够更精确地表达源语言的特性和进行高级优化。
MLIR的工作流程如下:
- 编译器前端生成某种高层IR(不是LLVM IR)。
- 该高层IR被转换为一个或多个MLIR方言。
- 利用MLIR提供的** lowering (降级)基础设施,将这些方言逐步转换为更低级的方言,最终转换为LLVM方言**(这是MLIR内置的、与LLVM IR对应的方言)。
- LLVM方言可以直接转换为标准的LLVM IR,从而接入LLVM的后端流程。

此外,MLIR和LLVM都提供了一个强大的工具:TableGen。它允许我们使用一种声明式语言来描述IR的结构(如操作、类型),然后自动生成大量的C++样板代码,这极大地提升了开发效率。
Serene 编译器流程
最后,我们概述一下Serene编译器自身的架构。Serene是一个将Serene语言编译到LLVM IR和MLIR的编译器。
其工作流程如下图所示,我们将对每个步骤进行简要说明:

以下是Serene编译器的核心步骤:
- 入口点:
serene.c负责解析命令行参数,设置上下文。 - 读取器:读取源文件,生成初始的抽象语法树。
- 语义分析器:遍历AST,根据Serene语言的语义规则重写AST节点(例如,识别列表是函数调用还是定义)。
- 生成SLIR:将语义正确的AST转换为 Serene语言中间表示。SLIR是MLIR的一个自定义方言,其设计旨在与AST节点尽可能直接映射。
- Lowering 到 MLIR 方言:将SLIR降级到MLIR内置的一些方言,我们称此结果为MLIR。
- Lowering 到 LLVM 方言:对MLIR进行分析和优化,然后进一步降级到LLVM方言。
- 生成 LLVM IR:将LLVM方言转换为标准的LLVM IR,并生成目标文件。
- 链接:调用系统C编译器(如Clang或GCC)将生成的目标文件链接成最终的可执行文件。

目前,Serene仍在开发中,仅支持整数类型等最基本的功能。我们将逐步实现更多的语言特性。

总结

本节课我们一起学习了编译器的基础结构,了解了LLVM作为编译器基础设施的组成部分及其优势,认识了MLIR作为构建高层中间表示的强大框架,并概览了Serene编译器的整体工作流程。在接下来的课程中,我们将深入Serene的代码,从读取器开始,详细探讨每个模块的实现。
004:阅读器(Reader)📖
在本节课中,我们将要学习Serene语言编译器的阅读器(Reader)部分。阅读器是编译器的前端组件,负责将文本形式的源代码解析并转换为抽象语法树(AST)这种数据结构。我们将深入探讨其设计思路、核心算法以及具体的代码实现。
概述
阅读器,通常也称为解析器(Parser),是编译器流水线中的关键一环。它的核心任务是将人类可读的源代码文本,转换为计算机程序易于处理的树状数据结构——抽象语法树(AST)。本节课我们将聚焦于Serene语言阅读器的实现细节。
什么是解析器?
简单来说,解析器是一段代码,它读取文本格式的源代码,并从中创建出数据结构。我们称这个数据结构为抽象语法树(Abstract Syntax Tree, AST)。
存在许多不同的算法来构建解析器,适用于不同的用例。但今天我们不深入讨论这些算法,因为大多数时候,开发者会使用库来生成解析器,我们称之为解析器生成器(Parser Generator)。



解析器生成器非常普遍,大多数编程语言都使用它们。使用解析器生成器时,你需要用一种特定的语法(如BNF)来描述你的语言,然后将这个语法描述传递给生成器,它就会为你生成一个能够解析该语言的解析器。它们通常经过充分测试,性能良好。
然而,在某些罕见情况下,你不需要使用解析器生成器,Serene语言就是其中之一。因为Serene是一种Lisp方言,其源代码本身就已经具有结构化的形式。当你编写Lisp代码时,实际上就是在填充一个数据结构。因此,源代码已经是“半解析”的状态,我们不需要词法分析器(Lexer),可以直接开始将其解析为AST。

Serene阅读器的算法



在Serene中,我们采用的算法相当简单,可以称为 LL(1.5)(其中的1.5是个玩笑)。其工作原理如下:



- 我们从最左边的字符(即第一个字符)开始。
- 一次读取一个字符。
- 所谓的“1.5”是因为有时需要向前查看两个字符,所以平均下来是1.5个字符。
- 我们遍历整个源代码,并决定如何解析它。



这个解析器的时间复杂度最好情况是 O(N),最坏情况是 O(2N)。目前,这个解析器尚未完全实现所有功能,但它已经能够解析我们构建编译器所需的最小功能集。


代码结构解析

现在,让我们来看看Serene阅读器的具体代码实现。如果你还记得之前的课程,我们讨论过Serene的目录树结构。
位置信息(Location)

在 include/Serene/Reader 目录下,阅读器包含两个主要部分:一是与位置相关的代码,二是解析器本身。


我们先快速浏览一下位置信息部分。这里有两个数据结构:Location 和 LocationRange。

Location:指向源代码中的一个特定点。LocationRange:一个位置范围,包含开始和结束两个位置。

我们更常使用 LocationRange 而不是直接的 Location,因为Lisp中的一个表达式总是从某个位置开始,并在某个位置结束。知道其范围比只知道起点更有意义,这样我们在报告错误时就不需要回退重新解析源代码。

有几个实用的函数:
increaseLocation:将给定的位置向前移动一个字符。decreaseLocation:将给定的位置向后移动一个字符。


我们在解析器中使用这两个函数来跟踪在源代码中的当前位置。它们的实现很简单,主要是维护行号和列号的计数器。

阅读器类(Reader Class)



重要的部分在 reader.h 文件中。Reader 类是阅读器的主要入口点。它不是一个基类,但目前我们有两种阅读器:一种从字符串读取,另一种从文件读取。



Reader 类的主要成员和公共接口包括:
currentChar:存储当前正在查看的字符。inputStream:需要解析的输入源代码流。getChar:读取下一个字符并返回。如果skipWhitespace参数为真,它会跳过所有空白字符直到遇到下一个有效字符。ungetChar:将当前字符“放回”流中,以便其他函数可以读取它。isValidForIdentifier:检查一个字符是否可以作为符号(Symbol)的有效组成部分。AST:这是阅读器的实际输出,一个存储节点的向量,最终将被返回。


最重要的部分是几个“读取”函数,每个函数负责从源代码中读取特定类型的表达式并创建对应的AST节点。
公共接口很简单:
- 创建一个
Reader并设置输入。 - 调用
read函数,它解析源代码并返回一个Result<AST>。 to_string函数(未来应改名为dump)用于将AST输出到流中。
还有一个 FileReader 类,它在内部使用 Reader 类,但改为从文件读取输入。目前我们没有使用抽象类,因为公共接口很简单。



此外,还有一个独立的 read 函数,它创建阅读器、设置输入、进行读取并返回AST。目前,我将其视为阅读器在此阶段的唯一公共入口点。

核心读取函数实现


让我们跳过一些显而易见的实现,重点讨论几个核心的读取函数。


readExpr 函数



成员函数 readExpr 是一个分发器函数。它从输入中读取一个字符(不跳过空白字符),然后立即将其放回。这是因为在某些情况下,读取函数需要自己读取第一个字符(例如读取符号时)。然后,它根据读取到的字符决定调用哪个具体的读取函数:
- 如果是开括号
(,调用readList读取列表。 - 如果是文件结束符,返回空指针。
- 否则,调用
readSymbol读取符号。


未来,当我们想添加向量、映射等语言特性时,会扩展这个 switch 语句。


readSymbol 函数



readSymbol 可能是最简单的。我们将数字也视为符号,但它们具有不同的语义,所以需要小心处理。



基本流程:
- 读取第一个字符。如果是有效的标识符字符,则继续;否则报错退出(未来会有更优雅的错误处理系统)。
- 如果第一个字符是
-,它可能是一个负数。我们需要向前多看一个字符,如果确实是数字,则调用readNumber函数。 - 如果第一个字符是数字,直接调用
readNumber。 - 否则,创建一个位置范围,循环读取整个符号序列。
- 如果序列不为空,就使用
make函数创建一个符号节点。

make 函数是创建AST节点的主要入口点。它接收要创建的节点类型(如 Symbol)以及构造该节点所需的参数。所有表达式类的构造函数第一个参数都是位置信息。
readNumber 函数
readNumber 函数返回一个节点,它接受一个参数来指示读取的是负数还是正数。它也处理浮点数。



流程:
- 将数字作为字符串读取,并附带位置信息。
- 进行一些检查以确定它是否是浮点数。
- 最后,使用
make函数根据字符串创建一个数字节点。


readList 函数



readList 函数略有不同。我们首先使用当前位置信息创建一个空列表节点。makeAndCast 函数创建一个节点并将其转换为给定的类型(这里是 List),这样我们就可以使用 List 类的成员函数。


流程:
- 读取一个字符。
- 如果是文件结束符,意味着用户从未关闭列表,这是一个错误。
- 如果是闭括号
),意味着列表读取完毕,返回该列表节点。 - 如果字符不是以上两种,则读取一个表达式(通过递归调用
readExpr),并将其追加到列表中。


列表本质上是一个表达式的序列。列表本身也是一个表达式,因此嵌套列表也是允许的。我们不断重复这个过程,直到遇到闭括号,然后返回节点。




主 read 函数与结果类型


除了具体的读取函数,Reader 类的 read 成员函数是最重要的,但它也非常简单。


流程:
- 获取一个字符,跳过空白字符。
- 检查是否为文件结束符。
- 如果不是,则读取第一个表达式并将其推入
AST成员变量中。 - 循环直到文件结束,不断读取表达式并加入
AST。 - 最后,基于
AST创建一个Result并返回。



那么,Result 是什么?如果你熟悉函数式编程,可以把它看作一种 Monad。简单来说,Result 是一个可以容纳两种类型值的对象:成功时它持有类型 T 的值,失败时它持有类型 E 的值(通常是错误信息)。




实现上,它内部使用了一个 std::variant(类似联合体)。我们可以通过调用 ok() 函数或直接在 if 条件中使用它来判断它当前持有哪种类型的值,并通过 getValue() 或 getError() 获取实际的值。
至于AST和节点,在 Expr 命名空间中,Expression 是所有AST节点的抽象基类。Node 是 std::shared_ptr<Expression> 的别名。AST本身是 std::vector<Node> 的别名。而 MaybeAST 则是 Result<AST, error> 的类型别名。


测试与使用


我们的阅读器目前相当简单,我们为它编写了一些测试用例。在测试中,我们调用 read 函数,传入包含表达式的字符串。如果返回的不是失败,我们就将实际值从 MaybeAST 中移动出来,然后进行断言测试。


你也可以通过Serene的命令行工具来使用阅读器。例如,运行构建脚本并传递命令来解析一个输入文件并输出AST:
./build/bin/serc --emit ast /path/to/input/file

每个AST节点都有一个 to_string 函数,可以将节点转换为其字符串表示形式,便于调试。

此外,如果你提供 --debug 标志,它会输出所有的调试日志。你还可以通过 --debug-only reader 来限制只输出阅读器相关的日志条目,这有助于你调试对阅读器所做的修改。



我们在源代码中使用一个名为 READER_LOG 的宏来记录阅读器日志,它会在调试构建中输出详细信息。


总结


本节课我们一起学习了Serene编译器阅读器的设计与实现。我们了解到阅读器如何将Lisp风格的源代码直接解析为抽象语法树,这得益于Lisp语言本身的结构化特性。我们深入探讨了其简单的LL(1.5)算法、用于跟踪源代码位置的数据结构、以及几个核心的读取函数(readExpr、readSymbol、readNumber、readList)是如何协同工作的。我们还介绍了用于表示成功或失败结果的 Result 类型,并展示了如何测试和使用阅读器。这个阅读器虽然目前功能最小化,但为后续的语义分析、MLIR lowering等步骤奠定了坚实的基础。
005:抽象语法树
在本节课中,我们将要学习编译过程中的一个核心数据结构——抽象语法树。我们将了解它的定义、结构,并深入探讨其具体实现,特别是如何用C++类来表示AST中的各种节点。
概述
在上一节中,我们介绍了词法分析器和语法分析器如何将源代码字符串转换为一种数据结构。本节中,我们来看看这种数据结构——抽象语法树。AST是源代码抽象语法结构的树状表示,是后续语义分析和代码生成的基础。
什么是抽象语法树?
抽象语法树是由节点组成的树形结构,每个节点代表了源代码中的一个语法片段。与具体的语法树不同,AST省略了一些语法细节(如括号、分号),更专注于程序的结构。
以下是一段伪代码示例:
(def main (fn () 4))
(prn (main))
如果我们将这段源代码传递给分析器,它会生成对应的AST。下图展示了这段代码可能对应的AST结构(为清晰起见,两行代码被表示为两个独立的子树):
- 左边的树对应第一行
(def main (fn () 4))。其根节点是一个列表,包含三个子节点:符号def、符号main以及另一个列表。这个内层列表又包含三个子节点:符号fn、一个空列表和数字4。 - 右边的树对应第二行
(prn (main))。其根节点也是一个列表,包含两个子节点:符号prn以及另一个列表。这个内层列表包含一个子节点:符号main。
目前,这个树只包含语法结构信息,没有语义信息。例如,我们不知道main是一个函数,也不知道4是一个数字。语法分析器能捕获像括号不匹配这样的语法错误,但无法识别“用数字2作为函数调用”这类语义错误。语义检查将在后续的语义分析阶段完成。
表达式基类
为了开始实现AST,我们首先需要定义一个所有AST节点的基类,称为Expression(表达式)。
在Lisp系语言中,有一个核心理念:一切都是表达式。表达式与语句不同:表达式总会求值出一个结果(例如 a + 3),而语句只是执行一个操作,不产生值(例如 a = 3)。在Serene(我们构建的编译器)中,所有代码结构都被建模为表达式。
以下是Expression基类的核心定义(位于 include/Serene/Expr/Expression.h):
namespace serene { namespace expr {
// 表达式类型枚举
enum class ExpressionType {
Symbol,
List,
Number,
Def,
Error,
Fn,
Call
};
// 表达式基类
class Expression {
protected:
LocationRange location; // 源代码中的位置信息
public:
Expression(LocationRange &loc);
virtual ~Expression() = default;
// 返回此节点的具体类型(如Symbol, List)
virtual ExpressionType getType() const = 0;
// 返回节点的字符串表示(用于调试)
virtual llvm::StringRef toString() const = 0;
// 语义分析函数(下节介绍)
virtual MaybeNode analyze() = 0;
// 生成中间代码函数(后续介绍)
virtual void generateIR() = 0;
LocationRange &getLocation() { return location; }
};
} }
此外,文件中还定义了一些在项目中广泛使用的类型别名,以简化代码:
// Node 是指向任意表达式节点的智能指针
using Node = std::shared_ptr<Expression>;
// MaybeNode 表示一个可能成功(包含Node)也可能失败(包含错误树)的操作结果
using MaybeNode = llvm::Expected<Node>;
// AST 本身就是一个Node的向量
using AST = std::vector<Node>;
using MaybeAST = llvm::Expected<AST>;
为了统一创建节点,我们提供了辅助函数:
// 创建节点,返回基类指针 (Node)
template <typename T, typename... Args>
static Node make(Args &&...args) {
return std::make_shared<T>(std::forward<Args>(args)...);
}
// 创建节点,并直接转换为具体类型的指针
template <typename T, typename... Args>
static std::shared_ptr<T> make_and_cast(Args &&...args) {
return std::make_shared<T>(std::forward<Args>(args)...);
}
具体节点类型实现
现在,让我们看看如何实现具体的表达式节点。所有节点类都必须继承自Expression基类,并实现其纯虚函数。


符号节点
符号(例如变量名、函数名)是最简单的节点之一。以下是Symbol类的核心部分:

class Symbol : public Expression {
public:
llvm::StringRef name; // 符号的名称
Symbol(LocationRange &loc, llvm::StringRef name)
: Expression(loc), name(name) {}
ExpressionType getType() const override {
return ExpressionType::Symbol;
}
llvm::StringRef toString() const override {
// 返回符号的字符串表示,如 "symbol:main"
return llvm::formatv("symbol:{0}", name);
}
// 用于LLVM风格的类型转换
static bool classof(const Expression *e) {
return e->getType() == ExpressionType::Symbol;
}
// ... 其他函数(analyze, generateIR)暂不展开
};

classof函数是配合LLVM自定义的类型转换系统使用的。它允许我们高效、安全地进行类型向下转换(例如,将一个Expression*转换为Symbol*),而无需使用C++的RTTI机制。
数字节点
数字节点用于表示整数字面量。在初始实现中,我们将其值存储为字符串,在需要时再转换为int64_t。


class Number : public Expression {
std::string number; // 数字字符串
bool isNegative;
bool isFloat; // 当前未使用,为未来扩展预留
public:
Number(LocationRange &loc, llvm::StringRef num)
: Expression(loc), number(num.str()) {
isNegative = num.startswith("-");
isFloat = num.contains('.');
}
ExpressionType getType() const override {
return ExpressionType::Number;
}
int64_t toInt64() const {
// 将字符串转换为整数
int64_t val;
llvm::StringRef(number).getAsInteger(10, val);
return val;
}
llvm::StringRef toString() const override {
return llvm::formatv("number:{0}", number);
}
static bool classof(const Expression *e) {
return e->getType() == ExpressionType::Number;
}
// ...
};
列表节点
列表是Lisp语言的核心,也是AST中最重要的复合节点。一个列表包含多个子表达式(节点)。
class List : public Expression {
AST elements; // 子节点列表
public:
// 构造空列表
List(LocationRange &loc) : Expression(loc) {}
// 构造包含单个节点的列表
List(LocationRange &loc, Node n) : Expression(loc) {
elements.push_back(std::move(n));
}
// 构造包含多个节点的列表
List(LocationRange &loc, AST els) : Expression(loc), elements(std::move(els)) {}
ExpressionType getType() const override {
return ExpressionType::List;
}
// 获取列表中指定索引处的节点(可能不存在)
llvm::Optional<Node> at(size_t index) {
if (index >= elements.size()) {
return llvm::None; // 索引越界,返回空
}
return elements[index]; // 返回节点
}
// 获取从指定索引开始的子列表
AST from(size_t index) {
if (index >= elements.size()) {
return {}; // 返回空AST
}
return AST(elements.begin() + index, elements.end());
}
// 向列表追加一个节点
void append(Node n) {
elements.push_back(std::move(n));
}
llvm::StringRef toString() const override {
if (elements.empty()) {
return "()";
}
std::string str = "(";
for (const auto &elem : elements) {
str += elem->toString().str() + " ";
}
str.back() = ')'; // 替换最后一个空格
return str;
}
static bool classof(const Expression *e) {
return e->getType() == ExpressionType::List;
}
// ...
};
列表的elements成员是一个AST(即std::vector<Node>),这使得列表可以嵌套,从而形成树形结构,这正是“抽象语法树”中“树”的体现。
总结

本节课中,我们一起学习了抽象语法树的核心概念。我们了解到AST是源代码语法结构的树形表示,其节点对应程序中的各种语法元素。我们深入探讨了如何用C++类层次结构来实现AST,定义了所有节点的基类Expression,并具体实现了Symbol、Number和List这几个基础节点类型。列表节点能够包含其他节点,从而构成了树形嵌套关系。我们还介绍了LLVM工具库中一些有用的组件,如StringRef、Expected和Optional,以及LLVM风格的类型转换机制。这些基础数据结构为后续的语义分析和代码生成阶段打下了坚实的基础。
006:语义分析器 🧠
在本节课中,我们将要学习编译器的下一个关键阶段:语义分析。我们将探讨语义分析器的作用,它如何检查程序的逻辑正确性,以及如何通过重写抽象语法树(AST)来为后续的代码生成阶段做准备。
概述
在之前的课程中,我们介绍了读取器和AST构建器。它们负责将源代码转换为表示其语法结构的AST。然而,语法正确的程序在逻辑上可能仍然是无意义的。语义分析器的任务就是确保程序在逻辑上也是正确的,例如检查类型是否匹配,以及识别诸如“调用一个数字”这类语义错误。
关于前几集问题的解答

在开始之前,我们先回答几个关于上一集的问题。

为什么没有实现链表?


在上一集的实现中,列表是围绕std::vector的包装器。我们的目标是先创建一个简单的编译器,然后在此基础上构建。目前,我们不需要实现一个真正的链表。在未来的类型系统阶段,我们将需要创建一个MLIR类型系统中的列表类型,这与C++的链表不同。


为什么使用std::vector而不是LLVM集合?




LLVM提供了许多替代标准库类型的数据结构,例如llvm::SmallVector。这些替代品通常更快、更高效。目前,为了代码的清晰易懂,我们使用了大家熟悉的std::vector。未来在重构代码时,我们会将其替换为LLVM的替代类型。
什么是语义分析?



语义分析阶段会检查AST,确保程序符合语言规范,在逻辑上是正确的。例如,考虑以下代码片段:


(4 main)
从语法上看,这是一个有效的列表。但从语义上看,它试图调用一个数字(4),这是没有意义的。语义分析器的工作就是捕获这类错误。
语义分析中的重写


在我们的Serene编译器中,语义分析阶段不仅进行错误检查,还会对AST进行重写。我们通过识别特定的模式,将通用的列表节点替换为更具语义的专用节点。
以下是一个例子,展示了语义分析前后的AST变化:
源代码:
(def main (fn [] (prn (main))))
原始AST (语法表示):
List
├── Symbol: def
├── Symbol: main
└── List
├── Symbol: fn
├── List: [] ; 参数列表
└── List
├── Symbol: prn
└── List
└── Symbol: main
重写后的AST (语义表示):
Def
├── Symbol: main
└── Fn
├── List: [] ; 参数列表
└── Call
├── Target: prn ; 解析后的符号
└── Args: [Call
├── Target: main ; 解析后的符号
└── Args: []]
通过重写,我们将嵌套的列表结构转换为了Def、Fn和Call等节点,这使得AST更清晰,也更容易进行类型检查和后续的MLIR代码生成。
语义分析器的工作原理



现在,让我们深入代码,看看语义分析器是如何工作的。

入口点
语义分析器的入口点是一个名为analyze的函数。它接收一个设置上下文和一个AST,并返回一个AnalyzeResult,其中包含处理后的AST或错误列表。

// 位于 `include/reader/semantic_analysis.h`
AnalyzeResult analyze(SettingContext &ctx, AST &tree);
核心逻辑


analyze函数的核心逻辑是遍历AST中的每个节点,并调用其analyze成员函数。根据返回的MaybeNode结果,我们决定是重写节点、保留原节点还是记录错误。
// 简化后的核心循环逻辑
AST new_ast;
std::vector<ErrorPtr> errors;

for (auto &node : tree) {
auto maybe_node = node->analyze(ctx);
if (maybe_node.isSuccess()) {
auto new_node = maybe_node.getValue();
if (new_node != nullptr) {
// 情况1:需要重写,使用新节点
new_ast.push_back(new_node);
} else {
// 情况2:无需重写,保留原节点
new_ast.push_back(node);
}
} else {
// 情况3:分析失败,记录错误
errors.push_back(maybe_node.getError());
}
}

if (errors.empty()) {
return AnalyzeResult::success(new_ast);
} else {
return AnalyzeResult::failure(errors);
}
表达式节点的分析
每个表达式节点(如Symbol、Number、List)都必须实现analyze接口。
-
Symbol和Number节点:它们直接返回一个包含nullptr的成功结果,表示不需要重写。MaybeNode Symbol::analyze(SettingContext &ctx) { return MaybeNode::success(nullptr); // 无需重写 } -
List节点:这是重写发生的地方。List::analyze会检查列表的第一个元素是否是特殊形式(如def,fn)或函数调用,并调用相应的make函数来创建新的语义节点。MaybeNode List::analyze(SettingContext &ctx) { if (this->elements.empty()) { return MaybeNode::success(nullptr); } auto first = this->elements[0]; // 检查是否为特殊形式或函数调用 if (auto sym = llvm::dyn_cast<Symbol>(first.get())) { if (sym->name == "def") { return Def::make(ctx, this); } else if (sym->name == "fn") { return Fn::make(ctx, this); } else { // 视为函数调用 return Call::make(ctx, this); } } // 如果第一个元素不是符号,也视为函数调用 return Call::make(ctx, this); }
新的语义节点类型


在语义分析阶段,我们引入了三种新的节点类型:Def、Fn和Call。



Def 节点

Def节点表示一个绑定定义,例如(def x 10)。它包含一个绑定名称和一个值表达式。
make函数:Def::make函数负责验证输入列表的格式(必须恰好有三个元素),并创建Def节点。它还会在当前的语义作用域中创建这个绑定。// 伪代码,展示Def::make的核心职责 MaybeNode Def::make(SettingContext &ctx, List *list) { // 1. 检查参数数量是否为3 // 2. 验证第一个元素是'symbol'且名为'def' // 3. 验证第二个元素是'symbol'(绑定名) // 4. 分析第三个元素(值) // 5. 在当前作用域创建绑定 // 6. 返回新的Def节点 }
Fn 节点
Fn节点表示一个函数定义(或匿名函数)。它包含函数名(可为空)、参数列表和函数体。
make函数:Fn::make函数验证参数,分析函数体,并创建Fn节点。如果函数是通过def定义的,Def::make会调用setName来设置函数名。// 伪代码,展示Fn::make的核心职责 MaybeNode Fn::make(SettingContext &ctx, List *list) { // 1. 检查参数数量至少为2 // 2. 验证第一个元素是'symbol'且名为'fn' // 3. 获取第二个元素作为参数列表 // 4. 获取剩余元素作为函数体 // 5. 对函数体进行语义分析 // 6. 返回新的Fn节点 }
Call 节点

Call节点表示一个函数调用。它包含一个目标表达式(要调用的函数)和一个参数列表。

make函数:Call::make函数分析调用目标(可能是一个需要解析的符号,或者是另一个表达式的结果),分析所有参数,并创建Call节点。// 伪代码,展示Call::make的核心职责 MaybeNode Call::make(SettingContext &ctx, List *list) { // 1. 检查列表非空 // 2. 分析第一个元素(调用目标) // 3. 如果目标是符号,在作用域中解析它 // 4. 分析剩余元素作为参数 // 5. 返回新的Call节点 }
与编译器交互

你可以使用编译器的semantic动作来查看经过语义分析后的AST。

./serene hello.sr -action semantic -o output


这将输出重写后的AST,其中原始的列表节点已被替换为Def、Fn和Call等语义节点。
总结
本节课中,我们一起学习了编译器语义分析阶段的核心内容。
- 语义分析的作用:确保语法正确的程序在逻辑上也是有效的,检查类型和语义规则。
- AST重写:通过识别模式(如
def、fn、函数调用),将通用的List节点重写为具有明确语义的节点(Def、Fn、Call),这极大地简化了后续的代码生成工作。 - 新的节点类型:我们详细介绍了
Def、Fn和Call节点的结构及其make函数的工作流程。 - 错误处理:语义分析器会收集所有遇到的语义错误,而不是在第一个错误处停止。
语义分析是连接前端语法分析和后端代码生成的关键桥梁。它产生的富含语义信息的AST,将使我们下一阶段的工作——将其转换为MLIR中间表示——变得更加直接。

在下一节课中,我们将探讨Serene上下文、命名空间和作用域,这些概念对于管理符号解析和绑定至关重要。敬请期待!
007:上下文与命名空间
在本节课中,我们将学习Serene编译器的两个核心概念:上下文 和 命名空间。它们是编译器全局状态管理和代码组织的基础单元。理解它们对于后续学习LLVM和MLIR的集成至关重要。
上一节我们介绍了编译器的不同子系统,如读取器、解析器和语义分析器。本节中,我们来看看支撑这些组件的核心基础设施。
命名空间:编译的基本单元
命名空间是Serene语言中最重要的概念之一,因为它是编译的基本单元。它的主要作用是:
- 组织代码:将不同的名称归类到不同的“桶”中,以避免名称冲突。
- 定义编译范围:编译器以命名空间为单位进行编译。
与Python等语言中的“模块”不同,在Serene中,命名空间直接对应编译过程。当我们要求编译器编译程序时,实际上是要求它编译特定的命名空间。
工作原理示例:
假设我们要求编译器编译命名空间 foo。编译器会:
- 在文件系统中查找名为
foo.srn的文件。 - 读取文件并生成抽象语法树。
- 对AST运行语义分析器。
- 最终为目标架构生成代码(例如一个对象文件)。
通常一个程序会包含多个命名空间,编译器会分别编译它们,然后在链接阶段将这些对象文件链接成最终的可执行文件。
重要特性:
- 命名空间通常映射到文件系统中的一个文件,但并非总是如此(例如在REPL交互环境中可以动态创建)。
- 命名空间维护着自身的状态,包括作用域和环境。
上下文:编译器的全局状态
这里有一个容易混淆的地方:源代码中存在三个不同的“上下文”:
- Serene上下文:编译器整体的全局状态持有者。
- LLVM上下文:用于生成LLVM IR。
- MLIR上下文:用于生成MLIR代码。
这三个概念本质相同,但服务于不同层级。Serene上下文是最高级的,它拥有并管理着另外两个上下文。
Serene上下文的核心职责:
- 持有命名空间表(作为缓存)。
- 管理当前的编译阶段和目标架构。
- 未来可能持有原始类型等全局信息。
源码解析:Serene上下文
让我们开始查看 SereneContext 类的源码。首先,我们定义了一个枚举来表示编译器的当前阶段:
enum class CompilationPhase {
AST,
SemanticAnalysis,
SLIR,
LIR,
MLIR,
LLVMIR,
Object,
NoOptimization
};
这个枚举允许我们设置编译器的工作模式,例如仅转储AST,或运行到生成MLIR为止。
以下是 SereneContext 类的简化结构:


class SereneContext {
private:
std::unique_ptr<llvm::LLVMContext> llvmContext;
std::unique_ptr<mlir::MLIRContext> mlirContext;
std::unique_ptr<mlir::PassManager> passManager;
std::string targetTriple;
llvm::StringMap<std::shared_ptr<NameSpace>> namespaces;
std::string currentNS;
CompilationPhase phase = CompilationPhase::NoOptimization;
public:
// 构造函数:初始化上下文和Pass管理器
SereneContext();
// 注册新命名空间
bool insertNS(std::shared_ptr<NameSpace> ns);
// 设置当前正在处理的命名空间
bool setCurrentNS(const llvm::StringRef &nsName);
// 获取当前命名空间
std::shared_ptr<NameSpace> getCurrentNS();
// 根据名称获取命名空间
std::shared_ptr<NameSpace> getNS(const llvm::StringRef &nsName);
// 设置编译阶段(可能触发Pass的添加)
void setOperationPhase(CompilationPhase newPhase);
};
关键成员解析:
namespaces:一个将命名空间名称映射到其共享指针的缓存表。这避免了重复加载和处理相同的命名空间。currentNS:存储当前正在处理的命名空间名称。在多线程设计中,每个线程可能有自己当前的命名空间。setOperationPhase:此函数根据编译阶段,可能需要向Pass管理器添加不同的优化或 lowering pass。例如,在生成MLIR后,需要添加 lowering 到LLVM IR的pass。
创建上下文的官方方式是使用工厂函数:
std::unique_ptr<SereneContext> makeSereneContext();
源码解析:命名空间
接下来,我们查看 NameSpace 类的结构。它代表了一个可编译的代码单元。
首先定义两个重要的结果类型:
using MaybeModule = Result<std::unique_ptr<llvm::Module>, bool>;
using MaybeModuleOp = Result<mlir::ModuleOp, bool>;
Result 类型用于表示可能成功(返回模块)或失败(目前用布尔值占位,未来会改为错误类型)的操作。
NameSpace 类的简化结构如下:
class NameSpace {
private:
SereneContext &ctx;
std::atomic<uint64_t> fnCounter; // 用于生成匿名函数唯一名称
std::unique_ptr<Expression> tree; // 该命名空间的AST
bool initialized = false;
std::string name;
llvm::Optional<std::string> filename;
Environment<std::string, NodePtr> semaEnv; // 语义分析环境
Environment<llvm::StringRef, mlir::Value> symTable; // 符号表(用于JIT等)
public:
NameSpace(SereneContext &ctx, llvm::StringRef name,
llvm::Optional<llvm::StringRef> filename = llvm::None);
// 获取和设置AST
Expression &getTree();
void setTree(std::unique_ptr<Expression> t);
// 生成IR(可能是SLIR, MLIR, LIR)
MaybeModuleOp generate();
// 编译到LLVM IR
MaybeModule compileToLLVM();
// 运行Pass管理器
void runPasses(mlir::ModuleOp module);
};
关键成员解析:
fnCounter:一个原子计数器,用于为匿名函数生成唯一名称(例如serene.fn.3)。semaEnv:语义分析环境。它是一个从字符串(符号名)到AST节点(NodePtr)的映射。在语义分析阶段,当我们定义一个新绑定时(如(def x 5)),会将符号"x"和其分析后的值存入此环境。在后续遇到该符号时(如在函数调用中),从此环境查找其定义。symTable:符号表。它是一个从字符串引用到MLIR值(mlir::Value)的映射,用于编译的后期阶段(如JIT编译)。generate()和compileToLLVM():这两个核心函数负责将命名空间的AST转换为不同层级的IR,我们将在后续课程详细讨论。
创建命名空间的官方方式:
std::shared_ptr<NameSpace> makeNameSpace(SereneContext &ctx,
llvm::StringRef name,
llvm::Optional<llvm::StringRef> filename = llvm::None,
bool setCurrent = false);
源码解析:环境
Environment 类是一个通用模板类,用于实现具有层级结构的作用域。它在语义分析和符号管理中起着关键作用。
以下是其简化实现:
template <typename K, typename V>
class Environment {
private:
Environment *parent;
llvm::DenseMap<K, V> pairs; // 存储键值对
public:
Environment(Environment *p = nullptr) : parent(p) {}
// 查找键值:先在当前环境找,找不到则向父环境查找
llvm::Optional<V> lookup(const K &key) {
auto it = pairs.find(key);
if (it != pairs.end()) {
return it->second;
}
if (parent != nullptr) {
return parent->lookup(key);
}
return llvm::None; // 未找到
}
// 插入键值对:插入当前环境,可能遮蔽父环境中的同名键
bool insertSymbol(const K &key, const V &value) {
pairs[key] = value;
return true; // 目前总是成功
}
};
环境层级与遮蔽:
环境支持父子关系,形成层级链。查找时,会从当前环境向根环境递归查找。插入时,如果当前环境已存在该键,则覆盖;如果父环境存在同名键,则当前环境的插入操作会“遮蔽”父环境中的绑定。例如:
- 父环境中
foo -> 4 - 在当前环境中插入
foo -> 5 - 那么,在当前环境中查找
foo会得到5,父环境的4被遮蔽。
总结
本节课中我们一起学习了Serene编译器的两个核心基础设施:
- 命名空间:作为编译的基本单元,它组织代码、管理自身AST、并维护语义环境和符号表。一个命名空间通常对应一个源文件,并最终可编译为一个LLVM模块。
- Serene上下文:作为编译器的全局状态管理器,它持有所有命名空间的缓存、管理当前编译阶段和目标架构,并拥有LLVM和MLIR的上下文。
我们还了解了环境这个通用组件,它通过层级结构实现了作用域的概念,是语义分析和符号管理的基石。

现在,您应该能够阅读并理解语义分析阶段的大部分代码了。从下一节课开始,我们将深入探讨LLVM和MLIR的集成,学习如何将AST逐步转换为可执行的机器代码。
008:MLIR基础
在本节课中,我们将要学习MLIR的基础知识。我们将了解什么是MLIR、为什么它对于编译器构建如此重要,以及它的核心概念,如方言、操作、属性和区域。这些知识将为我们后续在Serene编译器中实际使用MLIR打下基础。
上一节我们介绍了编译器前端的工作流程。本节中我们来看看一个强大的中间表示框架——MLIR。
为什么选择MLIR?
在为我的新语言寻找合适的平台时,我尝试了JVM、Go等不同平台,最终得出结论:LLVM是构建编译器的最佳选择。它是一个用于创建语言和编译器的库集合。
然而,LLVM IR的抽象层级过低,类似于汇编指令,将高级语言概念映射到LLVM IR上非常困难。我需要在其之上创建抽象层来分解问题。
MLIR的出现解决了这个问题。简单来说,MLIR是一个用于构建具有自定义IR的编译器的框架。它帮助我在LLVM IR之上创建所需的抽象层,从而将复杂问题分解为更小、更易管理的部分。
MLIR的名字可能代表多层中间表示。它允许我们在不同层级创建抽象,层层递进,直到问题简化到我们可以推理和解决的程度。
MLIR语言概述
MLIR本身围绕一种语言工作,这种语言用于描述不同的方言。
- 基于SSA形式:MLIR语言基于静态单赋值形式。这意味着IR中的变量必须先定义后初始化,且只能赋值一次,之后不能重新赋值。在MLIR中,我们可以将SSA值视为绑定到值的名称。
- 类型系统:MLIR语言是强类型的。与LLVM IR不同,MLIR的类型不是硬编码的。我们可以基于MLIR提供的API创建自己的类型和类型系统。
- 上下文无关:MLIR语言本身只是一堆文本。只要语法有效,MLIR就可以处理它。MLIR并不一定理解你写的代码具体做什么,但可以验证语法。当你创建自己的方言时,MLIR不一定知道其语义。
核心概念:方言
MLIR中的抽象层被称为方言。


每个方言是操作、自定义类型和一些元数据的集合。我们可以将不同的方言组合使用来解决问题。
MLIR的一个主要优势在于,它试图在统一的API和规则下,整合不同编译器项目各自创建的IR。例如,Flang编译器就使用了MLIR。这意味着我可以在自己的Serene编译器中利用他人已经实现的功能。

MLIR本身提供了一些基础方言,例如:
builtin:内置方言,提供基础功能。func:用于函数调用等操作。scf:提供结构化控制流操作,如循环和条件分支。async:用于构建异步基础设施。
使用这些现成的方言,我可以专注于与我的语言和编译器相关的核心问题,而无需重复实现通用功能。
核心概念:操作与属性
方言是操作的集合。操作是MLIR中的基本抽象单元。
- 操作:它不是一条指令,而是一个抽象概念。操作通常以SSA值的形式返回结果。我们可以完全用C++编写操作,也可以使用TableGen来定义,后者会为我们生成包括C++实现在内的大部分代码。
- 属性:这是MLIR中一个特有的概念。属性是编译时常量,是静态的。与操作的输入参数(运行时值)不同,属性值在编译时就必须确定。
操作可以拥有自定义的验证器和打印机。默认情况下,MLIR会使用通用版本,但我们可以提供自定义的验证器来确保操作结构的正确性,以及自定义的打印机来将操作序列化为文本格式。
核心概念:块与区域
块和区域是组织代码结构的重要概念。
- 块:块是一系列没有分支的直线指令序列。进入一个块后,将顺序执行其中的所有指令,直到退出。这在编译器领域是一个基本概念。
- 区域:区域是块的有序列表。每个区域可以包含一个或多个块。块和区域可以相互嵌套。你可以将区域类比为Java或C++等语言中的代码块。
LLVM IR只有块的概念,而MLIR引入了区域,使得对高级语言结构(如带有then和else分支的if语句)的建模变得更加直观和容易。
通行基础设施
LLVM和MLIR都提供了通行基础设施。
其工作方式是:我们生成IR(LLVM IR或MLIR),然后运行一系列称为“通行”的处理器。每个通行会对IR进行特定的转换或分析,例如常量折叠、死代码消除等。通行管理提供了相关的API。
在MLIR中,通行管理器是多线程的,这是一个很大的优势。
在Serene编译器中,我计划将大部分语义分析逻辑转移到通行基础设施中。首先,将AST节点一对一地映射到SLIR操作,然后使用不同的通行对其进行类型检查和分析,再通过模式重写将其“降级”到其他方言。
我们可以使用TableGen来编写这些模式和重写规则,这比直接编写C++代码要简单得多。
操作定义规范
ODS是使用TableGen定义操作的方式。
以下是来自MLIR官方教程中Toy方言的一个示例:
// 包含基础定义
include "mlir/IR/OpBase.td"
// 定义Toy方言
def Toy_Dialect : Dialect {
let name = "toy";
let cppNamespace = "toy";
}
// 定义Toy操作的基础类
class Toy_Op<string mnemonic, list<Trait> traits = []> :
Op<Toy_Dialect, mnemonic, traits>;
// 定义一个具体的常量操作
def ConstantOp : Toy_Op<"constant"> {
let summary = "constant operation";
let arguments = (ins F64Attr:$value);
let results = (outs F64Tensor);
let assemblyFormat = "$value attr-dict `:` type(results)";
}
如你所见,用这种格式编写操作比直接编写C++代码要简单得多。在构建时,MLIR和TableGen工具会根据这些定义生成所需的C++代码。
MLIR语法示例
让我们看一个MLIR代码的一般语法示例:
%result = some_dialect.blah(%x#3)
{some.attribute = true, another.attribute = 3}
: (!some_dialect.example_type) -> (!some_dialect.typeS, !some_dialect.typeC)
loc("main":10:8)
%result:定义一个本地SSA值,此处该操作返回两个值。some_dialect.blah:调用some_dialect方言中的blah操作。%x#3:使用SSA值%x的第三个值作为输入参数。{...}:内是属性映射。: (...):指定输入参数的类型。-> (...):指定操作返回结果的类型。loc(...):指定该操作在源代码中的位置信息。
块与区域示例
以下是如何在MLIR中定义函数、区域和块的示例:
// 定义一个函数
func @example(%arg0: i64, %arg1: i1) -> i64 {
// 这是一个区域(函数体)
^bb0:
// 这是一个块
%c = constant 42 : i64
br ^bb3(%c : i64) // 跳转到块bb3,并传递%c作为参数
^bb3(%input: i64):
// 块bb3,接收一个参数%input
%result = addi %input, %input : i64
return %result : i64
}
通过区域和块的嵌套,我们可以方便地建模高级语言结构。
Serene编译器中的实际应用


在Serene编译器中,我们首先将源代码转换为自定义的SLIR方言。例如,一个简单的Serene函数:
// Serene 代码
(fn main [] 3)
(fn main-one [a b c] 3)
会被转换为SLIR:
// SLIR 表示
serene.fn @main() -> i64 {
%0 = serene.value {value = 0} : () -> i64
serene.return %0 : i64
}
serene.fn @main_one(%arg0: i64, %arg1: i64, %arg2: i64) -> i64 {
%0 = serene.value {value = 0} : () -> i64
serene.return %0 : i64
}
然后,我们使用MLIR的通行管理器,将SLIR降级到std和builtin等标准方言:
// 降级后的 MLIR (std 方言)
module {
func @main() -> i64 {
%c0 = constant 0 : i64
return %c0 : i64
}
func @main_one(%arg0: i64, %arg1: i64, %arg2: i64) -> i64 {
%c0 = constant 0 : i64
return %c0 : i64
}
}
接着,进一步降级到LLVM方言,最终生成标准的LLVM IR:
// 生成的 LLVM IR
define i64 @main() {
ret i64 0
}
define i64 @main_one(i64 %0, i64 %1, i64 %2) {
ret i64 0
}
至此,我们就可以利用LLVM的后端来生成目标代码和最终的可执行文件了。
推荐资源


以下是我强烈推荐的学习资源:
- MLIR工程师的演讲视频(在课程视频的资源部分有链接),它们非常精彩但篇幅较短。
- MLIR官方语言参考手册,它详细描述了MLIR语言的所有细节,应作为主要参考资料。
- 关于编译器中块概念的资料,如果需要可以查阅。
- 在系列课程第1集中提到的两本编译器设计书籍(“龙书”和“虎书”),特别是“虎书”较为简短,“龙书”则是编译器设计的经典著作。

本节课中我们一起学习了MLIR的基础知识,包括其动机、核心概念(方言、操作、属性、块、区域)以及通行基础设施。我们还看到了MLIR语法示例和它在Serene编译器中的实际应用流程。理解这些基础对于后续实际进行IR生成和转换至关重要。
009:IR(SLIR)生成 🧠
在本节课中,我们将学习如何为我们的编译器生成中间表示(IR)。我们将创建一个名为SLIR(Serene Language Intermediate Representation)的简单IR,它直接映射自抽象语法树(AST)。我们将使用MLIR的TableGen工具来定义方言和操作,并最终生成IR。
概述
上一节我们讨论了语义分析,本节我们将看看如何将分析后的AST转换为IR。IR生成是连接前端(解析、分析)和后端(优化、代码生成)的关键桥梁。我们将创建一个简单的方言,包含几个基础操作,并使用MLIR的构建器(Builder)来生成IR。
定义方言与操作
为了生成IR,我们首先需要定义一个MLIR方言(Dialect)。方言是一组相关操作的集合。我们将使用MLIR的ODS(Operation Definition Specification)框架,通过TableGen来定义我们的方言和操作,这比直接用C++定义要简单得多。
以下是定义我们方言(serene)和三个基础操作(value, fn, return)的ODS文件核心内容:
// 定义 serene 方言
def Serene_Dialect : Dialect {
let name = "serene";
let cppNamespace = "serene";
let summary = "Serene Language Dialect";
let description = [{
This is a minimal dialect for the Serene compiler wiring.
}];
}
// 定义所有 serene 操作的基础类
class Serene_Op<string mnemonic, list<Trait> traits = []> :
Op<Serene_Dialect, mnemonic, traits>;


// 定义 value 操作,用于表示一个整数值
def ValueOp : Serene_Op<"value"> {
let summary = "integer value operation";
let arguments = (ins I64Attr:$value);
let results = (outs I64);
let verifier = [{ return ::verify(*this); }];
let builders = [
OpBuilder<(ins "int64_t":$value), [{
build($_builder, $_state, value);
}]>
];
}
// 定义 fn 操作,用于表示函数定义
def FnOp : Serene_Op<"fn", [IsolatedFromAbove]> {
let summary = "function operation";
let arguments = (ins StrAttr:$name,
DictionaryAttr:$args,
OptionalAttr<StrAttr>:$visibility);
let regions = (region AnyRegion:$body);
let results = (outs I64);
}
// 定义 return 操作,用于从函数返回
def ReturnOp : Serene_Op<"return", [HasParent<"FnOp">, Terminator]> {
let summary = "return operation";
let arguments = (ins AnyType:$operand);
let assemblyFormat = "attr-dict $operand `:` type($operand)";
}
代码解释:
Serene_Dialect: 定义了方言的基本信息,如名称和C++命名空间。Serene_Op: 是一个辅助类,所有具体操作都继承自它,简化了定义。ValueOp: 表示一个整数值。它有一个I64类型的属性($value)作为输入,并产生一个I64类型的结果。我们还定义了一个自定义的构建器(builder)方法。FnOp: 表示函数定义。它包含函数名、参数字典、可见性等属性,并拥有一个区域(region)来表示函数体。IsolatedFromAbove是一个特质(Trait),表示此区域与上层作用域隔离。ReturnOp: 表示返回语句。HasParent<"FnOp">特质确保它只出现在FnOp内部,Terminator特质表示它是一个基本块的终结操作。assemblyFormat定义了该操作在文本格式中的打印样式。
配置构建与生成C++代码
定义了ODS文件后,我们需要配置CMake,使用TableGen工具将其转换为实际的C++头文件和源文件。
以下是在CMakeLists.txt中的关键配置:
# 设置TableGen,从 .td 文件生成 C++ 代码
mlir_tablegen(SereneOps.h.inc -gen-op-decls)
mlir_tablegen(SereneOps.cpp.inc -gen-op-defs)
mlir_tablegen(SereneDialect.h.inc -gen-dialect-decls)
mlir_tablegen(SereneDialect.cpp.inc -gen-dialect-defs)
# 将生成的文件添加为库的依赖
add_dependencies(sereneCore SereneOpsGen SereneDialectGen)
TableGen会为我们生成 SereneOps.h.inc、SereneDialect.h.inc 等文件,其中包含了操作和方言的类声明与定义。在我们的C++代码中,只需包含这些生成的文件即可使用定义好的方言和操作。
在编译器上下文中加载方言
我们的编译器状态由SereneContext类管理。在它的构造函数中,我们需要加载我们将要使用的所有MLIR方言。
SereneContext::SereneContext() {
// 加载 serene 方言和 MLIR 标准方言
getMLIRContext().loadDialect<serene::SereneDialect>();
getMLIRContext().loadDialect<mlir::StandardOpsDialect>();
}
这样,SereneContext就持有了一个MLIRContext,并且预先加载了必要的方言,为后续的IR生成做好了准备。
生成IR:从AST到MLIR模块
IR生成的核心入口是Namespace类的generate函数。它遍历该命名空间下的AST,为每个节点生成对应的IR操作,并将它们组装成一个MLIR模块。
以下是生成过程的关键步骤:
llvm::Expected<mlir::OwningModuleRef> Namespace::generate() {
// 1. 为当前MLIR上下文创建一个构建器(Builder)
mlir::OpBuilder builder(&getContext());
// 2. 创建一个空的MLIR模块,位置暂时未知
auto unknownLoc = builder.getUnknownLocation();
auto module = mlir::ModuleOp::create(unknownLoc, getName());
// 3. 获取当前命名空间的AST并遍历
auto &tree = getTree();
for (auto &node : tree) {
// 为每个AST节点生成IR,并添加到模块中
if (auto expr = node->generateIR(*this, module)) {
// 生成成功,expr可能是一个操作结果
} else {
// 处理生成错误
return expr.takeError();
}
}
// 4. 验证整个模块的IR是否符合规则
if (failed(module.verify())) {
// 验证失败,发出错误信息
emitError(...);
module.erase();
return makeError(...);
}
// 5. (可选) 在此可以运行一些MLIR优化或转换Pass
// passManager.run(module.get());
// 6. 返回拥有该模块的智能指针
return mlir::OwningModuleRef(module);
}
流程解析:
- 创建构建器:
OpBuilder是创建MLIR操作的工厂,它需要一个MLIRContext。 - 创建模块:所有操作最终都必须包含在一个
ModuleOp中。 - 遍历AST生成IR:这是最核心的一步。我们调用每个AST节点(表达式)的
generateIR方法。该方法使用构建器创建对应的MLIR操作,并将其插入到正确的位置(例如,函数体内部)。 - 验证模块:生成完成后,调用
verify()确保IR的结构和类型是合法的。 - 运行Pass:我们可以在此插入MLIR的Pass管理器,对生成的IR进行优化或 lowering( lowering 指将高级IR转换为更低级IR的过程)。
- 返回结果:使用
OwningModuleRef智能指针返回模块,它会在离开作用域时自动销毁模块。
具体AST节点的IR生成示例
让我们看看几个具体AST节点(如数字字面量、函数定义)是如何实现generateIR方法的。
数字字面量(NumberExpr)的IR生成:
// 数字节点生成一个 `serene.value` 操作
mlir::Value NumberExpr::generateIR(serene::Namespace &ns,
mlir::ModuleOp &module) const {
auto &builder = ns.getBuilder();
// 将源码位置转换为MLIR位置
auto loc = getLocation().toMLIRLocation(ns);
// 使用构建器创建 `serene.value` 操作,传入整数值
auto valueOp = builder.create<serene::ValueOp>(loc, getValue());
// 返回该操作产生的 SSA 值
return valueOp.getResult();
}
函数定义(DefExpr)的IR生成(简化版):
mlir::LogicalResult DefExpr::generateIR(serene::Namespace &ns,
mlir::ModuleOp &module) const {
auto &builder = ns.getBuilder();
auto loc = getLocation().toMLIRLocation(ns);
// 1. 准备函数参数的类型属性列表
llvm::SmallVector<mlir::NamedAttribute, 4> args;
for (auto &arg : getArgs()) {
auto *sym = llvm::dyn_cast<SymbolExpr>(arg.get());
if (!sym) { /* 错误处理 */ }
// 为每个参数创建 (名字, 类型) 的属性对,当前类型固定为 i64
auto typeAttr = builder.getTypeAttr(builder.getI64Type());
args.emplace_back(builder.getNamedAttr(sym->getName(), typeAttr));
}
// 2. 创建 `serene.fn` 操作
auto fnOp = builder.create<serene::FnOp>(
loc,
builder.getI64Type(), // 返回类型
getName(), // 函数名
builder.getDictionaryAttr(args), // 参数字典
"public" // 可见性
);
// 3. 获取函数的区域(body)并创建入口块
auto &body = fnOp.body();
auto *entryBlock = new mlir::Block();
body.push_back(entryBlock);
builder.setInsertionPointToStart(entryBlock);
// 4. 在函数体内生成代码(这里简化为生成一个常量0并返回)
auto constOp = builder.create<serene::ValueOp>(loc, 0);
auto returnVal = constOp.getResult();
// 5. 创建 `serene.return` 操作
builder.create<serene::ReturnOp>(loc, returnVal);
// 6. 将函数操作添加到模块中
module.push_back(fnOp);
return mlir::success();
}
查看生成的IR
生成IR后,我们可以将其以文本形式打印出来进行检查。在MLIR中,我们可以控制打印的细节,比如是否包含调试位置信息。
// 打印模块,并启用调试信息以显示位置
module.print(llvm::outs(), mlir::OpPrintingFlags().enableDebugInfo());


启用调试信息后,打印出的IR可能如下所示:
module {
serene.fn @main(%arg0: i64) -> i64 {
%0 = serene.value 0 : i64 loc(#loc1)
serene.return %0 : i64 loc(#loc2)
} loc(#loc0)
}
loc(#loc0) = "main.sr" line:0 column:0
loc(#loc1) = "main.sr" line:1 column:10
loc(#loc2) = "main.sr" line:1 column:15
我们可以看到:
serene.fn定义了一个函数。serene.value创建了一个常量值。serene.return返回一个值。注意它的打印格式受我们定义的assemblyFormat影响,非常简洁。- 末尾的
loc(...)表是位置别名,将#loc0、#loc1等映射到具体的文件、行、列位置,这使得IR在保持可读性的同时包含了完整的源码位置信息。
总结

本节课我们一起学习了编译器IR生成的核心过程。我们使用MLIR的TableGen工具定义了一个简单的serene方言及其操作,在编译器上下文中加载它,然后通过遍历AST,利用MLIR的OpBuilder将每个节点转换为对应的IR操作,最终组装成一个完整的、可验证的MLIR模块。这个过程为我们后续进行IR转换、优化以及生成LLVM IR打下了坚实的基础。下一节,我们将探讨如何利用MLIR的Pass基础设施来分析和转换我们刚刚生成的SLIR。
010:Pass基础设施 🛠️




在本节课中,我们将要学习MLIR中Pass(通行证)的基础设施。Pass是编译器中执行转换和优化的核心单元。理解Pass的工作原理是后续进行方言降级(Lowering)的关键前提。

概述



上一节我们简要介绍了如何在MLIR中创建一个新的方言(SLIR),并使用它生成中间表示(IR)。这是进入MLIR世界的第一步。作为下一步,我们需要使用这个方言,并将其降级到MLIR内置的其他方言,甚至直接降级到LLVM IR。



但在进行降级之前,我们需要更多地了解Pass基础设施。因此,本节课将专注于讲解Pass。


什么是Pass?🤔




Pass是LLVM和MLIR中都有的一个概念,它是两者中进行转换和优化的基本单位。虽然两者都有Pass的概念且非常相似(API几乎相同),但由于MLIR是一种更高级的IR,可以包含LLVM IR所没有的操作和结构,这使得MLIR中的Pass与LLVM中的略有不同。但其核心概念是相同的:Pass是一个用于将一种形式的IR转换或优化为另一种形式的实体。



今天我们将主要讨论MLIR中的Pass,但所讲的大部分内容也适用于LLVM Pass。




编译器中的Pass




如果我们观察任何编译器,其主要工作流程是:读取代码 -> 进行处理 -> 生成结果。这个“进行处理”的过程,从宏观上看很简单:读取文件,将源代码转换为抽象语法树(AST),再将AST转换为某种IR,如此循环,直到得到目标代码。




显然,整个编译过程可以分解为更小的逻辑片段。我们可以将其抽象地视为一个函数管道(Pipeline)或函数组合。每个函数负责读取前一个函数的输出作为输入,进行一些优化或转换,生成新的输出,并将其传递给下一个函数。


基本上,我们现在讨论的每个封装了特定逻辑的抽象单元,就类似于Pass。因此,Pass是MLIR和LLVM中在IR层面进行操作、读取相应IR、进行转换并生成新IR的抽象。




为了分类和组合这些Pass,我们需要Pass管理器(Pass Manager),或者我喜欢称之为管道(Pipeline)(MLIR文档有时也这样称呼)。我们可以用不同的Pass创建转换管道,每个Pass都包含对IR进行某种转换的逻辑。我们可以将它们分组到Pass管理器中,创建管道,甚至可以嵌套多个管道以实现复杂的转换流程。





Pass的类型与创建



在MLIR中,Pass作用于操作(Operation)层面。操作是转换的主要抽象单元。一个Pass一次操作一个操作(取决于Pass类型)。我们不能编写作用于特定属性或跨操作的Pass。




创建Pass主要有两种方式:纯C++或通过ODS(操作描述规范)。目前Serene编译器中的所有Pass都是专门用于将SLIR和其他方言相互降级或降级到LLVM IR的,因此尚未使用ODS。但未来使用ODS编写Pass可能会比C++更方便,因为它能避免大量样板代码。




操作特定Pass(Operation-Specific Pass)





这种Pass只针对特定类型的操作。以下是一个定义操作特定Pass的示例(取自官方文档):



class MyFunctionPass : public PassWrapper<MyFunctionPass, OperationPass<FuncOp>> {
void runOnOperation() override {
// 获取当前操作,这里保证是FuncOp类型
FuncOp op = getOperation();
// 可以遍历操作内部的嵌套操作并进行转换
op.walk([&](Operation *nestedOp) {
// 对嵌套操作进行处理
});
}
};




在这个例子中,MyFunctionPass 只作用于 FuncOp(函数操作)。当Pass管理器在IR上应用Pass时,每当遇到一个 FuncOp,就会对这个操作应用此Pass。



操作无关Pass(Operation-Agnostic Pass)




这种Pass可以运行在任何操作上,其逻辑不针对特定操作。定义方式与操作特定Pass类似,区别在于不传递模板参数来指定操作类型。




class MyGenericPass : public PassWrapper<MyGenericPass, OperationPass<>> {
void runOnOperation() override {
// 获取当前操作,这是一个通用的Operation指针
Operation *op = getOperation();
// 在此进行转换
}
};


Pass的规则



Pass需要遵循一系列规则,主要是为了适应MLIR的多线程执行模型,避免数据竞争和不必要的问题。例如:
- Pass不应拥有全局可变状态。
- Pass不应修改其当前正在处理的操作之外的其他操作的状态(除非该操作嵌套在当前操作内)。
建议查阅官方文档以了解完整的规则列表。




Pass注册


注册Pass不是强制性的。注册的主要目的是允许通过命令行接口(CLI)以文本形式构造和调用特定的Pass。在Serene编译器中,由于现有的Pass都是降级所必需的,因此没有注册它们。但注册Pass的一个常见用例是:将一组Pass分组(例如,用于优化级别O2),然后通过一个CLI标志启用整个Pass管理器,这非常方便。

Pass管理器与管道



Pass管理器用于组织和运行Pass管道。以下是如何定义和使用Pass管理器的示例(基于官方文档):


// 创建一个顶层的Pass管理器,作用于MLIR模块(ModuleOp)
PassManager pm(ctx);
pm.addPass(std::make_unique<MyModulePass>());


// 创建一个嵌套的Pass管理器,仅作用于特定方言的模块操作
OpPassManager &nestedModulePM = pm.nest<ModuleOp>();
nestedModulePM.addPass(std::make_unique<MyDialectModulePass>());



// 进一步嵌套,创建一个作用于函数操作的Pass管理器
OpPassManager &nestedFunctionPM = nestedModulePM.nest<FuncOp>();
nestedFunctionPM.addPass(std::make_unique<MyFunctionPass>());

// 获取要转换的MLIR模块(例如 `moduleOp`)
// 运行Pass管道,转换是原地(in-place)进行的
if (failed(pm.run(moduleOp))) {
// 处理失败情况
}




通过这种方式,我们创建了一个Pass管理器的树形结构(管道树),每一层针对IR树的不同层级进行操作。最终,顶层的Pass管理器会在模块上启动,依次应用所有Pass。


其他相关实体




除了Pass,Pass基础设施中还有其他重要实体:
- 分析(Analysis):类似于Pass,但不进行转换。它们的主要目的是收集关于IR的度量和数据,Pass可以查询这些分析结果来辅助其转换逻辑。
- Pass仪器(Pass Instrumentation):允许我们钩入(Hook into)Pass基础设施。它提供了一些钩子函数(例如,在运行一个Pass之前/之后,在开始一个管道之前/之后),我们可以在这里运行自定义代码(例如,打印每次Pass转换后的IR)。MLIR本身就提供了一些很好的相关功能。




总结



本节课我们一起学习了MLIR中Pass基础设施的核心概念。我们了解了Pass是编译器中进行IR转换和优化的基本单元,以及它在编译管道中的作用。我们探讨了两种主要的Pass类型(操作特定和操作无关),学习了创建Pass的基本方法,并了解了如何使用Pass管理器来组织和运行复杂的转换管道。此外,我们还简要介绍了分析与Pass仪器。

掌握这些知识是下一节学习方言降级(Lowering) 的关键基础。在下一节课中,我们将看到如何具体应用Pass来将我们的SLIR方言逐步降级到LLVM IR。
011:SLIR降级
在本节课中,我们将学习如何将自定义的SLIR方言降级到MLIR的其他方言,特别是标准方言和LLVM方言。这是将我们的高级中间表示转换为可执行代码的关键步骤。
概述
在前几节中,我们创建了一个编译器,它读取类Lisp语言的源代码,生成抽象语法树,进行语义分析,并最终生成我们自定义的SLIR方言。在上一节中,我们介绍了MLIR的Pass基础设施。本节中,我们将利用Pass基础设施,将SLIR方言中的操作转换为MLIR标准方言中的操作,并最终降级到LLVM方言。
什么是方言降级?
MLIR允许我们创建自定义方言,每种方言可能专用于特定任务。降级是指将一个方言中的操作转换为另一个方言中的操作的过程。我们的最终目标是将SLIR转换为LLVM方言,然后由LLVM后端生成目标代码。通常,这不是直接转换,而是通过一个或多个中间方言逐步完成。
MLIR提供了一个名为“方言转换”的框架来简化此过程,它构建在Pass基础设施之上。使用它需要完成两件必需的事情和一件可选的事情:
- 目标转换:定义我们希望转换到的目标方言。
- 重写模式:定义如何将源方言中的特定操作重写为目标方言中的操作。
- 类型转换器(可选):如果我们的方言有自定义类型,需要定义如何将这些类型转换为目标方言中的类型。
此外,还有完全转换与部分转换的概念。完全转换会转换整个方言;部分转换则只转换一部分操作,将剩余操作留给后续的Pass处理。
实现降级Pass
现在,我们来看看如何实现将SLIR降级到标准方言的Pass。
Pass的注册与结构
首先,我们需要在Pass管理器中注册并添加我们的降级Pass。在我们的编译器上下文中,根据不同的编译阶段,我们向Pass管理器添加不同的Pass。
// 这是一个示例,展示如何将降级Pass添加到Pass管理器
if (compilationPhase == “MLIR”) {
passManager.addPass(createSLIRLoweringToMLIRPass());
}
if (compilationPhase == “LIR”) {
passManager.addPass(createSLIRLoweringToMLIRPass());
passManager.addPass(createStdToLLVMDialectPass());
}
我们的降级Pass是一个操作特定的Pass,它只处理模块操作。其核心是 runOnOperation 函数。
设置转换目标与合法性
在 runOnModule 函数中,我们首先创建转换目标,并标记哪些方言或操作是“合法”的。
void SLIRToMLIRPass::runOnModule() {
mlir::ModuleOp module = getOperation();
mlir::ConversionTarget target(getContext());
// 将标准方言标记为合法目标
target.addLegalDialect<mlir::StandardOpsDialect>();
// 将我们的SLIR方言标记为非法,意味着必须转换它
target.addIllegalDialect<serene::sereneDialect>();
// 可以标记特定操作为合法(例如,某些操作想留到后续Pass处理)
// target.addLegalOp<serene::PrintOp>();
}
标记一个操作为“合法”意味着如果Pass没有提供该操作的重写模式,Pass管理器可以接受它。标记为“非法”则意味着必须提供重写模式将其转换,否则Pass会失败。
定义重写模式
接下来,我们需要为SLIR中的每个操作定义重写模式。重写模式是一个继承自 OpRewritePattern 的结构体,它需要实现 matchAndRewrite 函数。
以下是处理 ValueOp(表示一个常数值)的重写模式示例:
struct ValueOpLowering : public mlir::OpRewritePattern<serene::ValueOp> {
ValueOpLowering(mlir::MLIRContext *ctx) : OpRewritePattern<serene::ValueOp>(ctx) {}
mlir::LogicalResult matchAndRewrite(serene::ValueOp op,
mlir::PatternRewriter &rewriter) const override {
// 1. 从ValueOp中获取常数值和位置信息
mlir::Attribute valueAttr = op->getAttr(“value”);
mlir::Location loc = op.getLoc();
// 2. 由于模块顶层不能直接放置常量操作,我们将其包装在一个函数中
llvm::SmallVector<mlir::Type, 1> argTypes; // 无参数
mlir::Type returnType = rewriter.getI64Type(); // 返回i64类型
auto funcType = rewriter.getFunctionType(argTypes, returnType);
// 3. 创建一个函数操作
auto funcOp = rewriter.create<mlir::FuncOp>(loc, “unique_name”, funcType);
mlir::Block *entryBlock = funcOp.addEntryBlock();
rewriter.setInsertionPointToStart(entryBlock);
// 4. 在函数体内创建标准方言的常量操作
auto constOp = rewriter.create<mlir::ConstantIntOp>(loc, valueAttr.cast<mlir::IntegerAttr>().getInt(), 64);
// 5. 创建返回操作
rewriter.create<mlir::ReturnOp>(loc, constOp.getResult());
// 6. 将函数设置为私有(因为这个包装函数是内部使用的)
funcOp.setPrivate();
// 7. 删除原始的、非法的ValueOp
rewriter.eraseOp(op);
return mlir::success();
}
};
代码解释:
- 我们创建了一个匿名函数来包装常量值。
- 在函数体内,使用标准方言的
ConstantIntOp来创建常量。 - 最后,用
ReturnOp返回该常量值,并删除原始的ValueOp。
类似地,我们需要为 FnOp(函数定义操作)定义重写模式。在当前的简化实现中,我们暂时忽略函数体,只生成一个返回固定值(如3)的函数框架。在未来的完善中,我们需要遍历函数体AST并生成相应的操作。
应用转换


定义好重写模式后,我们将它们添加到模式集中,并应用部分转换。
void SLIRToMLIRPass::runOnModule() {
// ... 设置target ...
mlir::OwningRewritePatternList patterns;
patterns.insert<ValueOpLowering, FnOpLowering>(&getContext());
if (mlir::failed(mlir::applyPartialConversion(module, target, std::move(patterns)))) {
signalPassFailure();
return;
}
}
applyPartialConversion 函数会尝试应用所有提供的重写模式。如果任何标记为“非法”的操作没有被成功转换,函数会失败,我们随之通知Pass管理器。
从标准方言到LLVM方言
将SLIR降级到标准方言后,下一步是将标准方言降级到LLVM方言。这个过程更加直接,因为MLIR已经为我们提供了内置的转换模式。
void StdToLLVMDialectPass::runOnModule() {
mlir::ModuleOp module = getOperation();
mlir::ConversionTarget target(getContext());
// 将LLVM方言标记为合法目标
target.addLegalDialect<mlir::LLVM::LLVMDialect>();
// 将模块操作标记为合法(顶级容器)
target.addLegalOp<mlir::ModuleOp>();
// 创建类型转换器(当前为空,因为我们没有自定义类型)
mlir::LLVMTypeConverter typeConverter(&getContext());
// 使用MLIR内置工具填充标准方言到LLVM方言的转换模式
mlir::OwningRewritePatternList patterns;
mlir::populateStdToLLVMConversionPatterns(typeConverter, patterns);
// 应用完全转换,所有内容都必须转换到LLVM方言
if (mlir::failed(mlir::applyFullConversion(module, target, std::move(patterns)))) {
signalPassFailure();
return;
}
}
populateStdToLLVMConversionPatterns 是MLIR提供的一个辅助函数,它自动为许多标准方言操作添加了到LLVM方言的重写模式,极大地简化了我们的工作。
运行与验证
通过命令行工具,我们可以指定不同的编译阶段来观察降级过程:
--emit slir:仅生成原始的SLIR,不运行任何Pass。--emit mlir:运行SLIR到标准方言的降级Pass,输出标准方言的MLIR。--emit lir:运行SLIR到标准方言,再到LLVM方言的降级Pass,输出LLVM方言的MLIR。
以下是一个简单Serene程序降级后的输出示例:





原始SLIR:
module {
serene.fn @main() -> i64 {
serene.value 42 : i64
}
}

降级到标准方言后:
module {
func private @main() -> i64 {
%0 = constant 42 : i64
return %0 : i64
}
}
降级到LLVM方言后:
module {
llvm.func private @main() -> i64 {
%0 = llvm.mlir.constant(42 : i64) : i64
llvm.return %0 : i64
}
}
总结



在本节课中,我们一起学习了MLIR中方言降级的核心概念和实现方法。我们首先了解了转换目标、重写模式和类型转换器这三个关键组件。然后,我们逐步实现了将自定义SLIR方言降级到MLIR标准方言的Pass,详细剖析了如何为ValueOp和FnOp定义重写模式。最后,我们利用MLIR的内置支持,轻松地将标准方言进一步降级到了LLVM方言。这个过程为我们最终生成LLVM IR乃至目标代码奠定了坚实的基础。在下一节课中,我们将探索如何从LLVM方言生成真正的LLVM IR。
012:目标代码生成 🎯
在本节课中,我们将学习编译器流程的最后一步:目标代码生成。我们将了解如何将LLVM IR转换为机器码,并最终链接成可执行文件。
概述
到目前为止,我们已经构建了一个基于LLVM的编译器前端。它读取源代码,解析为AST,进行语义检查,生成我们自定义的MLIR方言(SLIR),并将其降级为LLVM IR。然而,一个完整的编译器需要生成最终的可执行文件,而不仅仅是中间表示。本节将介绍如何完成这一过程。
目标代码生成基础
上一节我们介绍了如何将SLIR降级为LLVM IR。本节中,我们来看看如何将LLVM IR进一步编译成目标平台的原生代码。
在真实场景中,用户期望从编译器获得一个可以直接运行的可执行文件,而不是需要手动处理的中间文件。常见的做法是将代码编译成目标文件,然后使用链接器将它们链接在一起,形成最终的可执行文件。
目标文件剖析
目标文件是二进制文件,主要包含三种实体:符号、重定位和内容。
以下是目标文件的核心组成部分:
- 符号:代表程序中的全局实体(如函数、全局变量)。每个符号有一个名称和一个值(通常是其在内容中的偏移量)。已定义的符号在源代码中有具体实现,未定义的符号则引用外部定义。
- 重定位:在链接阶段对内容进行的计算和修改操作。例如,将某个内存地址设置为某个符号的值加上一个偏移量。其公式可以表示为:
目标地址 = 符号地址 + 偏移量。 - 内容:编译生成的机器码块,代表进程加载到内存后的数据布局。主要包含:
- 代码段:存放生成的机器指令。
- 数据段:存放已初始化的全局和静态变量。
- 只读数据段:存放字符串字面量、跳转表等。
- BSS段:存放未初始化的全局和静态变量(通常默认初始化为0)。
链接过程
链接器的主要工作是解析所有符号,并将多个目标文件合并成一个可执行文件。其基本流程如下:
- 读取所有输入的目标文件。
- 构建一个全局符号表,尝试解析所有未定义的符号(找到其定义)。
- 将所有目标文件的内容按类型排序并拼接起来。
- 对所有内容应用重定位操作,修正符号引用地址。
- 将最终结果写入一个可执行文件(如Linux下的ELF格式)。
代码实现:从LLVM IR到目标文件
了解了理论基础后,我们来看看如何在代码中实现目标文件生成。请注意,以下代码仅为演示,在实际项目中(尤其是对于像Serene这样的Lisp语言)可能会被更复杂的即时编译方案取代。
核心函数是 dump_as_object,它负责将一个命名空间(包含AST)编译成目标文件。
// 伪代码示例:将LLVM模块编译为目标文件
void dump_as_object(Namespace& ns, const std::string& output_path) {
// 1. 将命名空间编译为LLVM IR模块
auto maybe_module = ns.compileToLLVM();
if (!maybe_module) { /* 处理错误 */ return; }
auto module = std::move(maybe_module.get());
// 2. 设置目标三元组(例如 "x86_64-pc-linux-gnu")
module->setTargetTriple("x86_64-pc-linux-gnu");
// 3. 根据三元组创建目标机器
std::string error;
auto target = llvm::TargetRegistry::lookupTarget(module->getTargetTriple(), error);
// ... 错误检查
llvm::TargetMachine* target_machine = target->createTargetMachine(...);
// 4. 设置数据布局
module->setDataLayout(target_machine->createDataLayout());
// 5. 创建输出文件流
std::error_code ec;
llvm::raw_fd_ostream dest(output_path, ec);
// 6. 配置PassManager以生成目标文件
llvm::legacy::PassManager pass;
if (target_machine->addPassesToEmitFile(pass, dest, nullptr, llvm::CGFT_ObjectFile)) {
// ... 处理错误
}
// 7. 运行Pass,生成目标文件
pass.run(*module);
dest.flush();
}
链接目标文件
生成目标文件后,下一步是将其链接为可执行文件。主要有两种方法:
以下是两种常见的链接方式:
- 直接使用链接器(推荐):例如,将LLD作为库链接到你的编译器中。这种方式控制力强,依赖少。
// 伪代码:使用LLD库进行链接 std::vector<const char*> args = {"ld.lld", "-o", "output_exe", "input.o", "-lc"}; bool success = lld::elf::link(args, llvm::outs(), llvm::errs(), false, false); - 调用外部C编译器驱动:例如,通过Clang驱动来调用链接器。这种方式实现简单,但会引入对另一个编译器的依赖。
// 伪代码:使用Clang驱动进行链接 clang::driver::Driver driver; std::vector<const char*> args = {"clang", "input.o", "-o", "output_exe"}; std::unique_ptr<clang::driver::Compilation> comp(driver.BuildCompilation(args)); // ... 执行编译作业
对于追求独立性和效率的编译器,将LLD作为库直接使用是更优的选择。尽管需要处理平台特定的路径和库依赖,但它避免了额外的外部依赖。
AOT与JIT编译
本节讨论的生成可执行文件的过程属于提前编译。然而,对于Serene这样的Lisp语言,其宏系统需要在编译时执行代码,这就引入了即时编译的需求。
- AOT:在程序运行前完成全部编译。
- JIT:在程序运行时动态编译代码。


两者并不互斥。我们可以在编译时使用JIT来执行宏展开,然后将最终结果进行AOT编译,生成可执行文件。这也是Serene编译器后续的发展方向。
总结

本节课中我们一起学习了编译器后端的关键步骤——目标代码生成与链接。我们了解了目标文件的组成(符号、重定位、内容),链接器的工作原理,并查看了将LLVM IR生成目标文件以及链接成可执行文件的示例代码。虽然示例代码是临时的,但它清晰地展示了从高级IR到原生二进制文件的完整通路。对于静态语言编译器,至此核心框架已搭建完成。接下来,我们将转向更复杂的主题,探索如何为Serene这样的语言实现即时编译功能。


延伸阅读:如果你对链接器和目标文件格式的细节感兴趣,强烈推荐阅读系列文章《Linker Essay》(作者是Gold链接器的开发者),它对此有极为深入和精彩的讲解。链接已附在课程资源中。
013:源码管理器 🗂️


在本节课中,我们将学习如何为我们的编译器实现一个源码管理器。源码管理器负责加载、管理和跟踪编译器处理的所有源代码文件。我们将了解为什么需要它,并查看一个基于LLVM源码管理器设计的简化版本。




概述






上一节我们介绍了代码生成,并成功将Serene代码编译成了可执行文件。本节中,我们来看看编译器前端的一个重要组件——源码管理器。它的核心作用是统一管理所有源代码文件,包括从文件系统加载、处理导入关系以及为后续的解析和语义分析提供数据。









LLVM源码管理器简介






LLVM自带一个源码管理器,但其设计主要服务于Clang编译器,与C/C++语言特性(如#include预处理指令)深度绑定。因此,对于我们的Serene语言前端,直接使用它不够灵活。



LLVM源码管理器的主要组件包括:
SourceManager类:管理一个SrcBuffer的向量。SrcBuffer结构体:封装一个内存缓冲区(MemoryBuffer),存储文件内容,并提供一些辅助功能,如按行分割文本、根据行号或指针获取位置。- 诊断处理器:允许前端注册一个回调函数来处理源码管理器报告的错误。
- 包含目录(Include Directories):用于查找头文件的路径列表。




由于其与Clang的强关联性,我们决定参考其设计,实现一个更适合Serene的版本。



我们的源码管理器实现






我们的源码管理器基于LLVM版本进行了简化和定制,移除了不必要的通用性,并集成了我们自己的错误处理机制。






核心数据结构








以下是我们的SrcBuffer结构体的关键部分:








struct SrcBuffer {
std::unique_ptr<MemoryBuffer> buffer;
std::vector<const char *> line_starts; // 行起始位置
llvm::Optional<LocationRange> import_loc; // 导入位置(替换了LLVM的include_loc)
// ... 其他辅助方法,如 getLineNumber
};









我们的SourceManager类核心成员如下:








class SourceManager {
std::vector<SrcBuffer> buffers; // 缓冲区列表
llvm::StringMap<unsigned> ns_table; // 命名空间名到缓冲区ID的映射
std::vector<std::string> load_paths; // 文件加载路径(类似include目录)
// ... 移除了通用的诊断处理器
};








主要功能与方法







以下是源码管理器提供的主要功能:










- 添加加载路径:通过
addLoadPath方法,用户可以指定查找.serene文件的目录。 - 查找并读取命名空间:这是最重要的方法
readNamespace。它接收一个命名空间名称(如serene.example),将其转换为文件路径(如serene/example.serene),然后在load_paths中查找该文件。 - 管理缓冲区:找到文件后,将其内容读入一个新的
SrcBuffer,并分配一个缓冲区ID(从1开始)。同时,在ns_table中记录命名空间与缓冲区ID的映射。 - 错误处理:如果文件未找到或读取失败,方法会返回一个
ErrorTree类型的错误,而不是抛出异常。这与我们编译器其他部分的错误处理策略一致。







工作流程解析









readNamespace函数的工作流程可以概括为以下几个步骤:





- 路径转换与查找:调用
findFileInLoadPaths,将命名空间名转换为文件路径,并在加载路径中顺序查找。 - 文件读取:找到文件后,使用LLVM的
MemoryBuffer机制将文件内容加载到内存。 - 创建缓冲区:将内存缓冲区添加到
buffers向量中,并获取其ID。 - 记录映射:在
ns_table中建立命名空间名到缓冲区ID的映射。 - 调用解析器:将缓冲区内容传递给解析器(Reader),生成抽象语法树(AST)。
- 语义分析与树扩展:对AST运行语义分析器(Semantic Analyzer),如果成功,则调用命名空间的
expandTree方法,将分析后的AST节点加入到该命名空间的AST节点列表中。 - 返回结果:最终返回一个指向该命名空间对象的共享指针。










与编译器其他部分的集成








为了让源码管理器生效,我们需要对现有代码做一些调整:







- 上下文集成:
Context类现在持有一个SourceManager实例。 - 解析器适配:
Reader的read函数被重载,现在可以接受一个MemoryBuffer的引用作为输入,从而直接从源码管理器的缓冲区中读取内容。 - 主入口点更改:编译器的主入口点(如
serene.cpp)不再手动处理文件加载。它现在直接调用context.getSourceManager().readNamespace(...)来启动编译流程。







总结






本节课我们一起学习了源码管理器(Source Manager)的概念与实现。我们了解到,虽然LLVM提供了相关的组件,但为了更好的适配性,我们基于其思想实现了一个定制版本。我们的源码管理器核心职责是管理源代码文件的生命周期:根据命名空间名定位文件、加载内容、维护文件缓存以及处理导入关系。通过集成源码管理器,我们的编译器前端结构变得更加清晰和模块化,为后续支持多文件编译和更复杂的模块系统打下了基础。下一节,我们将开始探讨即时编译(JIT)的基础概念,为最终实现Serene的JIT功能做准备。
014:JIT基础 🚀
在本节课中,我们将要学习即时编译(JIT)的基础知识。JIT是许多现代语言和运行时环境的核心组件,理解其工作原理对于构建动态语言编译器至关重要。


什么是即时编译?⚡





简单来说,即时编译就是在运行时进行编译。更准确地说,JIT是一种按需编译技术,当我们需要执行某段代码时,才在那一刻将其编译成可执行的形式。


解释器和运行时环境通常使用JIT来加速执行。例如,Java的JVM、某些Python实现以及LuaJIT都使用了JIT技术。
JIT的工作原理 🛠️



为了对JIT的工作方式有一个概览,我们可以从高层次来看:我们有一些输入代码,将其送入JIT引擎,引擎会输出某种形式的目标代码,然后我们执行这个结果。




输入代码可以是任何形式:常规源代码、抽象语法树(AST)、字节码或某种中间表示(IR)。输出同样可以是任何形式,最常见的是本地机器码。JIT的核心在于,它在我们需要执行代码的那一刻,才将代码编译成目标形式。



为了展示更多细节,让我们来看一个具体的例子。这个例子基于LLVM技术栈,与我们未来的实现类似。






- 源代码:我们有一段源代码。
- 解析器:源代码被解析,生成抽象语法树(AST)。
- 语义分析器:确保AST在语义上是正确的。
- JIT引擎:AST被送入JIT引擎。
- IR生成器:将AST转换为中间表示(IR),可以是MLIR或LLVM IR。
- Pass管理器:对IR运行一系列优化Pass。
- 对象层:将优化后的模块编译为本地代码,并在运行时链接所需的库。
- 输出:生成本地代码,可以保存为可执行文件或共享库,也可以直接执行。

这个流程的复杂度可以调整。例如,Pass管理器中的优化Pass越多,编译过程就越长。同样,如果语言有庞大的标准库,对象层在启动时预加载这些库也会增加延迟。因此,JIT引擎的设计需要根据具体的使用场景(如REPL环境、编译器内部或运行时)进行权衡。




为何需要JIT?🤔



上一节我们介绍了JIT的基本流程,本节中我们来看看为什么我们的编译器需要JIT。对于静态语言(如C++、Rust),编译器在编译期就将所有代码编译为本地机器码,因此通常不需要JIT。但对于Lisp这样的动态语言,情况则不同。


Lisp有一个强大的特性:宏。宏在编译期运行,它们接收代码并生成新的代码表达式。为了在编译期展开宏并执行宏定义的函数,我们需要一个能够按需编译和执行的引擎——这就是JIT。

通过将编译器本身设计为一个JIT引擎,我们可以模糊编译期和运行期的界限。任何源代码(无论是用户程序还是编译器自身的宏)都可以通过同一个JIT管道处理,按需生成代码并执行。这为构建灵活、可扩展的语言系统提供了强大的抽象。




JIT的设计考量 ⚖️





设计JIT引擎时,需要考虑多种因素和权衡。以下是几个关键点:

- 性能与延迟:在REPL环境中,用户输入后希望立即看到结果,因此JIT的编译延迟必须非常低。这意味着需要减少优化Pass的数量。而在离线编译场景中,则可以接受更长的编译时间以换取更好的运行时性能。
- 启动开销:如果JIT引擎需要预加载大型标准库,其启动时间会变长。这需要根据JIT是作为长期运行的服务(如语言运行时)还是短时任务来设计。
- 分层编译:像Java那样采用两阶段编译(先编译为字节码,运行时再JIT编译为本地码)是一种常见设计。这有助于缩短初始编译时间,并允许基于运行时信息进行更激进的优化。
- 跨平台支持:如果中间表示(如LLVM IR字节码)是平台无关的,那么只需为每个目标平台实现JIT引擎,就能轻松支持新架构。
基于LLVM/MLIR的JIT实现方案 🧪


在LLVM和MLIR生态中,有几种现成的JIT方案可供选择,我尝试了三种主要方法:

- MLIR JIT:MLIR自带一个基于LLVM ORCv2库的JIT引擎。它接收MLIR模块并生成本地代码,功能强大,但对我们的某些特定场景可能存在限制。
- LLVM的LLJIT与LazyLLJIT:这是LLVM自带的JIT实现,同样基于ORCv2。
LLJIT直接编译模块,而LazyLLJIT则延迟编译,只在函数被调用时才生成其代码。它们接收LLVM IR模块。 - 自定义JIT引擎:由于现有方案在某些方面不符合Serene编译器的需求,我最终选择直接使用LLVM ORCv2 API构建自定义的JIT引擎。这需要深入理解ORCv2的层(Layer)架构,但提供了最大的灵活性。
在后续课程中,我们将首先探讨MLIR JIT,然后研究LLVM的JIT方案,最后分享构建自定义JIT引擎的经验。
总结 📚

本节课中我们一起学习了即时编译(JIT)的基础知识。我们了解了JIT是一种按需编译技术,它在运行时(或按需时)将代码编译为可执行形式。我们探讨了JIT的基本工作原理、为何动态语言(特别是Lisp)编译器需要JIT、设计JIT时需要考虑的权衡因素,以及基于LLVM/MLIR实现JIT的几种可能方案。理解JIT是构建现代语言运行时和交互式开发环境的关键一步。
015:LLVM ORC JIT 🚀
在本节课中,我们将要学习LLVM ORC JIT API。ORC(On-Request Compilation)是LLVM中用于即时编译(JIT)的现代、灵活的API,它取代了旧的MCJIT。我们将通过理解其核心概念和查看示例代码,来学习如何使用它构建一个简单的JIT引擎。
概述与背景
在过去的几个月里,我一直在尝试使用不同的方法构建一个最小化的JIT引擎。最终,我成功实现了一个非常精简的JIT引擎。它的主要功能是能够编译命名空间。目前,它在插入命名空间时会立即进行编译(即时编译),虽然这并非最佳方案,但作为一个最小化实现是可以接受的。未来我计划对其进行改进。


我还实现了重新加载命名空间的功能。例如,在一个REPL环境中,用户可能要求重新加载某个命名空间,引擎需要能够管理命名空间的不同版本,因为可能有些代码还在引用旧版本。

在开发过程中,我经常需要考虑Serene语言的各种规范细节。因此,现在是时候开始正式编写Serene语言的规范了。我计划以Scheme语言规范为基础,保留我喜欢的部分,添加缺失的功能,并移除不喜欢的部分,最终形成Serene的规范。


在深入JIT引擎的实现之前,我们需要更好地理解ORCv2。
ORC JIT 核心概念 📚
ORC JIT的设计围绕一系列核心概念。理解这些术语对于阅读文档和示例代码至关重要。以下是几个关键概念:

- 执行会话:一个
ExecutionSession代表一个正在运行的JIT程序会话。它包含了JIT动态链接库、错误报告以及内存化(memoization)等所有相关组件。你可以将其视为JIT引擎的运行实例。 - JIT地址:
JITTargetAddress是指向已编译代码内存地址的简单包装类型,本质上是一个指针。 - JIT动态链接库:
JITDylib类代表一个包含JIT代码的单元,类似于动态链接库。在我们的场景中,一个命名空间编译后通常会对应一个JITDylib。它本质上是一个符号表,记录了编译代码中的符号(如函数名)及其在内存中的位置。 - 物化单元:
MaterializationUnit是ORC实现惰性(延迟)编译的关键。当我们向JIT引擎添加代码(如一个LLVM模块)时,我们可以不立即编译它,而是创建一个MaterializationUnit来“持有”这个模块的定义。只有当后续真正需要查找(lookup)或调用某个符号时,引擎才会要求该单元“物化”(即编译)对应的代码并放入内存。 - 物化责任:
MaterializationResponsibility用于跟踪MaterializationUnit。它在JITDylib和MaterializationUnit之间充当桥梁,负责通知双方物化过程是成功还是失败。


JITDylib、MaterializationUnit和MaterializationResponsibility这三个概念的组合非常强大。默认情况下,使用它们可以实现“按需编译”,即在符号查找时进行编译。ORC甚至提供了更惰性的方式,可以将编译延迟到第一次调用函数时才进行。

- 内存管理器:
MemoryManager负责JIT引擎的内存管理,如分配和释放用于存放编译代码的内存。ORC默认提供了几种内存管理器,例如SectionMemoryManager,它能满足大多数用例。 - 层:ORC的整个设计都围绕“层”的概念。一个JIT引擎是由多个层连接而成的管道。代码从一端输入,经过各个层的处理(如编译、链接、转换),最终在另一端产生结果(如可执行代码)。这种设计极其灵活和强大。
- 资源追踪器:
ResourceTracker是ORC用于追踪已编译代码资源的答案。当你向JIT添加一个模块时,可以用它来追踪。你可以通过它来移除已编译的代码。 - 线程安全模块:
ThreadSafeModule是普通LLVM模块的包装器,它提供了线程安全的访问方式。在与ORC JIT交互时,我们主要使用ThreadSafeModule。
ORC 高级API与使用方式 🛠️
ORC提供了基于层的设计,允许我们构建自己的JIT引擎。同时,它也内置了两个可以直接使用的JIT引擎:LLJIT 和 LLLazyJIT。

- LLJIT:一个开箱即用的JIT引擎,使用ORC API构建,可以进行一定程度的定制。它不是完全惰性的,在查找符号时就会编译该符号对应的代码。
- LLLazyJIT:继承自
LLJIT,但更加惰性。它直到第一次调用函数时,才会编译该函数的代码。

我们有两种主要方式来创建JIT引擎:

- 包装
LLJIT或LLLazyJIT。这是我们当前实现所采用的方式。 - 直接使用ORC API从头构建自己的JIT引擎。我也在仓库中尝试了这种方式。






未来,我们可能会结合这两种方式。

代码示例解析 💻



现在,让我们通过LLVM源码中的例子来看看LLJIT的基本用法。



示例1:最简单的LLJIT使用

以下是一个使用LLJIT编译并运行一个简单加法函数的例子。







// 创建一个简单的LLVM IR模块,其中包含一个`add1`函数
ThreadSafeModule createDemoModule() {
// ... 创建模块和函数的LLVM IR代码 ...
auto M = std::make_unique<Module>("test", Ctx);
// ... 构建函数体:`return argument + 1` ...
return ThreadSafeModule(std::move(M), std::make_unique<LLVMContext>());
}








int main() {
// 初始化LLVM
InitializeNativeTarget();
InitializeNativeTargetAsmPrinter();
// 创建LLJIT实例
auto JIT = LLJITBuilder().create();
if (!JIT) { /* 处理错误 */ }
// 创建演示模块
auto M = createDemoModule();
// 将模块添加到JIT引擎中
if (auto Err = JIT->addIRModule(std::move(M))) { /* 处理错误 */ }
// 查找`add1`符号
auto Add1Sym = JIT->lookup("add1");
if (!Add1Sym) { /* 处理错误 */ }
// 获取函数地址并转换为函数指针
auto *Add1 = (int (*)(int))Add1Sym->getAddress();
// 调用JIT编译的函数
int Result = Add1(42);
printf("Result: %d\n", Result); // 输出:Result: 43
return 0;
}





这段代码清晰地展示了使用LLJIT的流程:
- 初始化LLVM。
- 使用
LLJITBuilder创建JIT实例。 - 创建包含目标代码的
ThreadSafeModule。 - 通过
addIRModule将模块加入引擎。 - 通过
lookup查找符号,这会触发编译。 - 获取函数地址,进行类型转换后调用。





LLJITBuilder提供了多种成员函数,允许我们定制JIT引擎,例如设置目标机器、数据布局、自定义编译层或链接层等。






示例2:使用转换层转储目标文件



这个例子展示了如何在编译过程中介入,将生成的目标代码转储到文件中。




int main() {
// ... 初始化与创建JIT ...
auto &JT = *JIT;
// 获取JIT引擎中的对象转换层
auto *ObjLinkingLayer = JT.getObjectLinkingLayer();
// 向转换层添加一个回调函数,在目标代码生成后将其写入文件
ObjLinkingLayer->setTransform(
[](std::unique_ptr<MemoryBuffer> Obj) -> std::unique_ptr<MemoryBuffer> {
// 将Obj中的内容写入文件 `output.o`
// ...
return std::move(Obj); // 将缓冲区返回给下一层
});
// ... 添加模块、查找符号、调用函数 ...
}
这里的关键是getObjectLinkingLayer()和setTransform。转换层允许我们在编译流水线的特定阶段插入自定义逻辑,这充分体现了ORC层的强大和灵活性。






示例3:定义绝对符号

有时,JIT编译的代码需要与宿主程序(C++代码)中定义的变量或函数交互。这可以通过定义“绝对符号”来实现。
int main() {
// ... 初始化与创建JIT ...
// 在C++程序中定义的变量
bool InitializerRunFlag = false;
bool InitializerRan = false;
// 获取主JIT动态链接库
auto &MainJD = JIT->getMainJITDylib();
// 将C++变量的地址定义为JIT中的符号
// 1. 定义 `InitializerRunFlag` 符号
if (auto Err = MainJD.define(
absoluteSymbols({{Mangle("InitializerRunFlag"),
JITEvaluatedSymbol::fromPointer(&InitializerRunFlag)}}))) {
/* 处理错误 */
}
// 2. 定义 `InitializerRan` 符号
if (auto Err = MainJD.define(
absoluteSymbols({{Mangle("InitializerRan"),
JITEvaluatedSymbol::fromPointer(&InitializerRan)}}))) {
/* 处理错误 */
}
// ... 添加包含引用这些符号的IR模块 ...
// JIT编译的代码现在可以通过这些符号名访问到C++变量`InitializerRunFlag`和`InitializerRan`
}
这种方法非常有用。例如,在实现Serene的标准库时,部分功能可以用高效的C++实现,然后通过定义绝对符号的方式暴露给JIT编译的Serene代码使用,而无需将其编译为LLVM IR。


总结



本节课我们一起学习了LLVM ORC JIT的基础知识。我们首先了解了ORC JIT的核心概念,包括ExecutionSession、JITDylib、MaterializationUnit、层(Layers)等。然后,我们探讨了ORC提供的两种高级JIT引擎LLJIT和LLLazyJIT,以及两种构建JIT的方式。最后,我们通过分析三个LLVM源码中的示例,具体学习了如何使用LLJIT添加模块、查找符号、调用函数,以及如何利用转换层和绝对符号实现更高级的功能。


ORC JIT的设计非常出色,它将成为我们编译器的重要组成部分。在接下来的课程中,我们将继续深入ORC,并逐步将其集成到Serene编译器中。
016:ORC分层架构详解 🏗️
在本节课中,我们将深入学习LLVM ORC JIT编译器的核心设计概念——分层架构。我们将探讨什么是分层、它们如何协同工作,并通过具体示例理解如何构建一个简单的JIT引擎。
在上一节中,我们介绍了LLVM提供的两种JIT引擎:LLJIT和Lazy JIT。为了更深入地理解和使用它们,我们需要剖析构成这些引擎的基本组件。本节我们将聚焦于分层架构,这是ORC设计的基石。
什么是分层?🧱
在ORC设计中,分层是构建JIT引擎的基本模块。我们可以将多个分层连接起来,形成一个数据处理管道,从而创建一个JIT引擎。
可以将分层视为一个数据管道。每个分层接收特定格式的输入(例如AST、LLVM IR或编译结果),并将其转换为另一种形式。
分层是可组合的,但组合顺序至关重要。每个分层都有其特定的要求和接口,下游分层的输入必须与上游分层的输出格式兼容。
在ORC设计中,每个分层都持有对下一个分层(下游分层)的引用。这形成了一个有向的层次结构。
分层架构可以用下图表示:
JIT 引擎
|
+------+------+
| |
输入类型A 输入类型B
| |
分层A 分层B
| |
+------+------+
|
分层C
|
分层D
|
分层E (生成目标代码)
例如,一个JIT引擎可能支持两种输入类型:AST和对象文件。分层A负责处理AST输入,将其转换为分层C期望的格式。分层B负责处理对象文件输入,执行相同的转换。最终,分层C接收统一格式的输入,进行进一步处理,并依次传递给分层D和E。分层E最终将代码编译为目标代码,使其可供查找和调用。
分层本身是一个抽象概念。我们可以根据不同的目标定义具有不同功能的分层。
为了在实践中理解分层,我们将分析LLVM示例中的Kaleidoscope JIT。
示例一:基础Kaleidoscope JIT 🔧
在上一节我们讨论了LLJIT和Lazy JIT。但在Kaleidoscope示例中,我们创建了自己的引擎,其功能与LLJIT类似。理解这个简单的引擎有助于我们更好地理解JIT。
以下是创建 KaleidoscopeJIT 类的核心步骤:
- 创建执行会话:
ExecutionSession代表一个正在运行的JIT会话。 - 设置数据布局和符号管理器:
DataLayout定义目标平台的数据布局。MangleAndInterner管理内存中符号的名称。 - 定义分层:我们使用两个LLVM提供的分层:
IRCompileLayer(编译层) 和ObjectLinkingLayer(对象链接层)。
以下是关键代码结构:
class KaleidoscopeJIT {
private:
std::unique_ptr<ExecutionSession> ES;
DataLayout DL;
MangleAndInterner Mangle;
std::unique_ptr<RTDyldObjectLinkingLayer> ObjectLayer;
std::unique_ptr<IRCompileLayer> CompileLayer;
JITDylib &MainJD;
// ...
};

在构造函数中,我们按顺序创建并链接这些分层:

// 1. 创建最底层的对象链接层
ObjectLayer = std::make_unique<RTDyldObjectLinkingLayer>(
*ES, []() { return std::make_unique<SectionMemoryManager>(); });
// 2. 创建编译层,并指定其下游分层是对象链接层
CompileLayer = std::make_unique<IRCompileLayer>(
*ES, *ObjectLayer, std::make_unique<ConcurrentIRCompiler>());
// 3. 获取主JIT动态库并添加生成器
MainJD = ES->createBareJITDylib("<main>");
MainJD.addGenerator(...);
对象链接层 是我们管道中的最后一层,负责链接目标代码。它需要一个内存管理器,这里使用了简单的 SectionMemoryManager。
编译层 是我们的第一层。它接收LLVM IR模块,将其编译为目标代码,然后将结果传递给其下游分层(即对象链接层)。编译层不需要知道下游分层的具体类型,只需知道有一个分层可以接收其输出。
addModule 成员函数展示了如何向引擎添加模块:
Error addModule(ThreadSafeModule TSM, ResourceTrackerSP RT = nullptr) {
if (!RT)
RT = MainJD.getDefaultResourceTracker();
return CompileLayer->add(RT, std::move(TSM));
}
它调用编译层的 add 函数,传入资源追踪器和一个线程安全的LLVM模块。资源追踪器用于跟踪分配的资源,以便后续释放。
lookup 函数用于查找已编译的符号:
Expected<JITEvaluatedSymbol> lookup(StringRef Name) {
return ES->lookup({&MainJD}, Mangle(Name.str()));
}
总结这个示例的架构:
输入: LLVM IR模块
|
IR编译层 (CompileLayer)
| (编译为目标代码)
对象链接层 (ObjectLayer)
| (链接)
可用目标代码
编译层将LLVM IR编译为目标代码,然后传递给对象链接层进行链接,最终生成可执行的目标代码。
示例二:添加优化分层 ⚡
大多数代码与第一个示例相同,但我们添加了一个新的分层:IRTransformLayer (IR转换层)。这个分层也是LLVM提供的。
在构造函数中,我们调整了分层顺序:
// 1. 对象链接层 (最后)
ObjectLayer = ...;
// 2. 编译层 (中间)
CompileLayer = ...;
// 3. 优化层 (最前),其下游是编译层
OptimizeLayer = std::make_unique<IRTransformLayer>(
*ES, *CompileLayer, optimizeModule);
现在,优化层是我们管道的起点。在 addModule 函数中,我们调用优化层而不是编译层:
Error addModule(ThreadSafeModule TSM, ResourceTrackerSP RT = nullptr) {
if (!RT)
RT = MainJD.getDefaultResourceTracker();
// 传递给优化层
return OptimizeLayer->add(RT, std::move(TSM));
}
我们传递给优化层一个函数 optimizeModule,该函数负责对LLVM模块应用优化遍:
Expected<ThreadSafeModule> optimizeModule(ThreadSafeModule TSM,
const MaterializationResponsibility &R) {
TSM.withModuleDo([](Module &M) {
// 创建Pass管理器
PassManager PM;
// 添加一些优化Pass
PM.addPass(Pass1);
PM.addPass(Pass2);
// 运行Pass管理器
PM.run(M);
});
return std::move(TSM); // 返回优化后的模块
}
withModuleDo 方法允许我们以线程安全的方式访问内部的LLVM Module 对象。我们在其中创建Pass管理器,添加优化Pass,并运行它们。
在Kaleidoscope的使用场景中,处理顶层表达式时:
- 将表达式编译为LLVM IR模块。
- 调用
addModule将该模块添加到JIT引擎。此时,模块会依次经过优化层、编译层和对象链接层的处理。 - 使用
lookup查找编译后的函数符号。 - 将符号地址转换为函数指针并调用。
- 关键步骤:调用
ResourceTracker->remove()。这指示JIT引擎移除与该资源追踪器关联的模块定义。因为Kaleidoscope中所有顶层匿名表达式都使用相同的函数名,如果不移除旧的定义,就无法重新定义同名符号。
总结第二个示例的三层架构:
输入: LLVM IR模块
|
IR优化层 (OptimizeLayer) - 应用优化Pass
| (优化后的LLVM IR模块)
IR编译层 (CompileLayer) - 编译为目标代码
| (目标代码)
对象链接层 (ObjectLayer) - 链接
|
可用目标代码
总结 📝
本节课我们一起深入探讨了LLVM ORC JIT的分层架构。
- 分层是ORC JIT的构建块:它们像管道一样连接,每个分层负责一项特定的转换任务。
- 分层是可组合和可扩展的:我们可以通过添加、移除或重新排列分层来定制JIT引擎的功能。例如,我们可以轻松地添加支持AST的输入层,或插入分析、插桩等中间层。
- 设计优雅且强大:分层设计使得JIT引擎的架构清晰、易于理解和扩展。它分离了关注点,例如将优化、编译和链接逻辑放在不同的分层中。

在未来的课程中,我们将学习如何定义自己的自定义分层,以进一步扩展JIT引擎的能力,满足特定的编译需求。
017:自定义ORC层 🧱
在本节课中,我们将学习如何为基于ORC的JIT引擎创建自定义层。我们将通过创建两个具体的层来理解其工作原理:一个用于添加命名空间(Namespace),另一个用于添加抽象语法树(AST)。自定义层允许我们将任何程序表示形式(如AST、字节码)集成到JIT编译流程中。
上一节我们介绍了ORC层的基本概念,它们是构建JIT引擎的基石。本节中,我们将深入探讨如何创建自定义层。
项目进展与重构
在开始之前,先同步一下项目进展。经过前17集的努力,我们已经完成了一个具备基本功能的编译器框架。这个框架包含了JIT引擎、MLIR、诊断系统、语义分析器、解析器等核心组件。虽然每个组件的功能尚不完善(例如,错误处理仅打印信息),但我们的目标——将它们连接起来形成一个可工作的基本设计——已经实现。

为了便于未来开发者与LLVM项目协作,我重构了项目结构,使其遵循与LLVM源码树相似的目录组织方式。同时,我也重构了构建系统,为每个组件添加了独立的CMake目标,这使得从源码安装编译器更加清晰和可定制。在达到一个里程碑后,进行代码审查、清理、文档更新和编写测试是确保代码质量的重要步骤。完成这些重构后,我们将专注于改进各个组件,并开始设计具体的编程语言规范。



ORC层回顾




让我们快速回顾一下ORC层的关键概念。一个基于ORC的JIT引擎由一系列层(Layer)构成,这些层形成一个层次结构。每一层只了解其下游的层,并通过调用下游层的接口来传递计算结果。

层将不同类型的程序表示(如AST、LLVM IR)包装在物化单元(Materialization Unit)中。这些单元存储在JIT动态链接库(JITDylib)里。每个物化单元负责描述其包装的定义(即符号),并包含如何将这些定义物化(Materialize) 的逻辑。物化责任(Materialization Responsibility) 对象则负责跟踪这些定义,并在符号被成功物化或发生错误时通知JITDylib。
创建自定义层


要创建一个自定义层,我们需要完成以下两步:
- 创建一个物化单元,用于物化我们特定的程序表示(例如命名空间或AST)。
- 创建层类本身。虽然层类不需要继承特定基类,但按照ORC的惯例,它通常包含
add、emit和getInterface这三个核心成员函数。





接下来,我们将通过代码示例来具体说明。我们有两个示例层:NSLayer(用于添加命名空间)和 ASTLayer(用于添加AST)。让我们先从更简单的 NSLayer 开始。




1. 命名空间层(NSLayer)


在我们的编译器中,一个命名空间(Namespace)包含一组函数和符号,通常对应一个源文件。首先,我们需要一个函数将命名空间编译成LLVM IR。ORC使用 ThreadSafeModule 来包装LLVM模块,以支持线程安全操作。



以下是 NSLayer 类的定义框架:
class NSLayer {
public:
NSLayer(SerContext& ctx, Layer& baseLayer, Mangle& mangler, DataLayout& dl);
Error add(ResourceTrackerSP rt, StringRef nsName);
Error emit(MaterializationResponsibility R, Namespace* ns);
MaterializationUnit::Interface getInterface(Namespace* ns);
private:
SerContext& Ctx;
Layer& BaseLayer;
Mangle& Mangler;
DataLayout& DL;
};
- 构造函数:接收并保存上下文、下游层、名字修饰器和数据布局的引用。
add函数:这是层的主要接口,用于向JIT引擎添加新的命名空间。它接收一个资源追踪器(ResourceTracker)和命名空间名称。emit函数:由物化单元调用,负责将命名空间编译成LLVM IR并传递给下游层。getInterface函数:用于生成物化单元的接口,描述该单元包含哪些符号。

add 函数详解






add 函数是向JIT引擎添加内容的入口。其工作流程如下:
- 通过命名空间名称,从上下文(
SerContext)中读取并创建对应的Namespace对象。这个过程可能涉及从磁盘读取文件、解析、语义分析等。 - 使用传入的
ResourceTracker获取关联的JITDylib。 - 创建一个自定义的物化单元
NSMaterializationUnit,并将当前层和刚创建的Namespace对象传递给它。 - 调用
JITDylib的define函数,将这个物化单元和资源追踪器一起注册进去。这样,JIT引擎就知道如何找到和物化这个命名空间包含的符号了。

物化单元(NSMaterializationUnit)








物化单元继承自 MaterializationUnit,核心是实现 materialize 和 discard 函数。
class NSMaterializationUnit : public MaterializationUnit {
public:
NSMaterializationUnit(NSLayer& layer, Namespace* ns)
: MaterializationUnit(layer.getInterface(ns)), Layer(layer), NS(ns) {}
StringRef getName() const override { return "NSMaterializationUnit"; }
void materialize(std::unique_ptr<MaterializationResponsibility> R) override {
Layer.emit(std::move(R), NS);
}
void discard(const JITDylib& JD, const SymbolStringPtr& Sym) override {
// 处理符号废弃逻辑,例如当函数被重写时
}
private:
NSLayer& Layer;
Namespace* NS;
};
materialize:当JIT引擎需要该单元中的符号时,会调用此函数。它简单地调用所属层的emit函数。discard:当符号不再需要或被覆盖时调用,用于清理资源。在初始实现中可能为空。- 构造函数:调用层的
getInterface函数来获取该命名空间暴露的符号列表,并传递给基类构造函数。



emit 函数与 getInterface 函数










emit函数:接收物化责任和Namespace对象。它调用compileNS函数将命名空间编译成LLVM IR(一个ThreadSafeModule),然后将这个模块连同物化责任一起传递给下游层(BaseLayer)的emit函数。getInterface函数:遍历命名空间的环境(存储全局定义的符号表),为每个顶级定义(如函数)创建一个符号。它使用Mangle对符号名进行修饰,并设置适当的标志(如可调用Callable、已导出Exported),最后返回一个包含这些符号的MaterializationUnit::Interface。这个接口告诉JIT引擎该物化单元具体提供了哪些符号。



2. 抽象语法树层(ASTLayer)



ASTLayer 的结构与 NSLayer 非常相似,主要区别在于它处理的是单个AST节点,并且需要在特定命名空间的上下文中进行编译。




其核心函数 compileAST 接收一个命名空间和一个AST节点,将该AST添加到命名空间的AST树中,然后请求命名空间从特定偏移量开始编译(即只编译新加的AST,但可以引用上下文中已有的符号)。





ASTLayer 的 add、emit、getInterface 函数以及对应的 ASTMaterializationUnit 的实现模式与 NSLayer 完全一致,只是操作的对象从 Namespace 变成了 AST 节点。






在JIT引擎中集成自定义层






最后,我们看看如何在JIT引擎实例中组织这些层。以下是一个示例引擎的初始化片段:
class ExampleJIT {
...
ObjectLayer ObjLayer;
CompileLayer CompileLayer;
TransformLayer TransformLayer;
NSLayer NSLayer;
ASTLayer ASTLayer;
...
ExampleJIT()
: CompileLayer(...),
TransformLayer(CompileLayer, ...),
NSLayer(ctx, TransformLayer, mangler, dataLayout),
ASTLayer(ctx, TransformLayer, mangler, dataLayout) {
// 建立层级的上下游关系
// NSLayer 和 ASTLayer 并行,下游是 TransformLayer
// TransformLayer 下游是 CompileLayer
// CompileLayer 下游是 ObjLayer
}
};
在这个层次结构中,NSLayer 和 ASTLayer 作为最上层,它们的输出(LLVM IR)传递给 TransformLayer(可能进行一些IR转换),然后依次经过 CompileLayer(生成目标代码)和 ObjectLayer(处理目标文件),最终生成可执行代码。


总结



本节课中我们一起学习了如何为ORC JIT引擎创建自定义层。我们了解到:
- 自定义层的核心是物化单元,它封装了特定程序表示(如命名空间、AST)及其物化逻辑。
- 层本身是一个协调者,通过
add方法接收输入,通过emit方法将处理结果传递给下游层,并通过getInterface方法声明其提供的符号。 - 创建自定义层的过程清晰且模块化,这使得扩展JIT引擎以支持新的程序表示形式变得非常简单。

虽然我们在初始的编译器框架中使用了更简单的设计,但理解如何构建这些自定义层为我们未来增强JIT引擎的功能(例如支持增量编译、字节码加载等)打下了坚实的基础。ORC层的抽象设计非常精妙,它将复杂的JIT编译过程分解为一系列简单、可组合的步骤。
018:JIT引擎实现(第一部分)🚀
在本节课中,我们将学习如何为Serin语言实现一个基础的JIT引擎。我们将这个引擎命名为“Halley”。本节将重点介绍其整体架构、核心类的设计以及如何创建引擎实例、添加命名空间和AST。
概述
经过前几节对JIT基础概念的讨论,本节我们将把理论付诸实践,构建一个可工作的JIT引擎实现。虽然这是最简实现,但足以将编译器的各个部分连接起来。我们的实现包装了LLVM的LLJIT和LLLazyJIT,并包含对象缓存层。


Halley引擎设计



我们的JIT引擎实现围绕Halley类展开。它包装了LLVM的JIT基础设施,并提供了两种操作模式。


核心类型定义
首先,我们定义了一些核心类型,用于错误处理和函数指针包装。LLVM使用llvm::Expected类型来处理可能出错的操作。

using MaybeJit = llvm::Expected<std::unique_ptr<Halley>>;
using MaybeJitPtr = llvm::Expected<JitPtr>;

对象缓存层
为了实现对象缓存,我们继承了LLVM的llvm::ObjectCache类,并重写了两个关键函数。



以下是需要重写的函数:
notifyObjectCompiled: 当模块编译完成并生成对象缓冲区时,此函数被调用以缓存该缓冲区。getObject: 当需要某个模块的已编译对象时,此函数被调用以从缓存中查询并返回。



缓存存储在一个std::map中,键是模块标识字符串,值是llvm::MemoryBuffer的唯一指针。


Halley 类

Halley类是我们的JIT引擎核心。它目前直接包装了llvm::JIT基类指针,这虽然可行,但限制了我们对LLLazyJIT特有功能的使用。未来我们需要一个更好的包装类型。




类的主要属性包括:
engine: 指向LLVM JIT引擎基类的唯一指针。cache: 我们自定义对象缓存层的唯一指针。jit_target_machine_builder: 用于创建目标机器的数据结构。data_layout: 对数据布局的引用。active_ns: 当前执行所在的活跃命名空间的共享指针。lazy_mode: 指示当前是否处于惰性(延迟)编译模式的标志。




构造函数接收上下文、目标机器构建器和数据布局。但我们通常使用静态的make函数来创建实例。




重要的成员函数有:
lookup: 用于在已编译代码中查找符号地址。addNS: 向引擎添加一个完整的命名空间进行编译。addAST: 向当前活跃命名空间添加一个AST(抽象语法树)进行增量编译。dumpToObjectFile: 用于调试,将编译后的对象数据转储到文件。



实现细节



现在,让我们深入lib/serin/jit/halley.cpp文件,看看这些功能是如何实现的。







创建引擎实例
makeHalley函数是创建Halley实例的入口点。它根据Serin上下文中的配置(如目标三元组)来设置引擎。
创建过程主要步骤如下:
- 从上下文获取目标三元组,创建
JITTargetMachineBuilder。 - 调用
Halley::make静态函数,传入构建器和上下文。 - 在
make函数内部,获取目标数据布局。 - 创建
Halley实例。 - 设置ORC(On-Request Compilation)层:包括对象链接层和编译层。
- 对象链接层使用
RTDyldObjectLinkingLayer和SectionMemoryManager。 - 编译层使用
IRCompileLayer,并根据上下文设置优化级别。
- 对象链接层使用
- 根据
lazy_mode标志,创建LLLazyJIT(惰性模式)或LLJIT(急切模式)引擎实例。 - 将引擎设置到
Halley实例中。 - 向引擎的主JIT动态库添加生成器。
- 返回创建好的
Halley实例。



添加命名空间

addNS函数负责将一个完整的命名空间添加到JIT引擎中进行编译。



其工作流程如下:
- 根据命名空间名称和当前JIT动态库计数,生成一个唯一的JIT动态库名称(例如
user1,user2)。 - 为这个命名空间创建一个新的JIT动态库。
- 将命名空间编译为目标机器代码(LLVM IR)。
- 将生成的LLVM模块包装成线程安全模块。
- 打包函数参数:这是一个关键步骤,它遍历模块中的所有函数,将它们包装进一个具有统一调用接口的函数中。这简化了后续的符号查找和调用。我们将在下一节详细讨论。
- 使用
addIRModule方法将包装后的模块添加到对应的JIT动态库中。
添加AST


addAST函数用于向当前活跃的命名空间增量地添加AST。这在交互式REPL环境中非常有用。
其工作流程如下:
- 获取当前活跃的命名空间。
- 将新的AST追加到该命名空间的AST树中。
- 计算新AST在树中的起始偏移量。
- 从该偏移量开始,编译命名空间树中新增的部分(而无需重新编译整个树)。
- 同样,将编译得到的模块进行“函数参数打包”处理。
- 获取该活跃命名空间对应的最新JIT动态库。
- 将打包后的模块添加到这个动态库中。


符号查找


lookup函数是调用已编译代码的入口。它根据Serin语言中的符号表达式来查找对应的函数地址。


查找过程如下:
- 从符号中解析出它所属的命名空间名称。
- 从上下文中获取该命名空间对象。
- 获取该命名空间对应的最新JIT动态库。
- 由于我们进行了“函数参数打包”,需要根据原始符号名生成打包后的函数名。
- 在JIT动态库中查找这个打包后的函数名,获取其地址。
- 将地址转换为统一的函数指针类型并返回。调用者可以使用这个指针来执行编译后的代码。




总结

本节课我们一起学习了Serin编译器JIT引擎“Halley”的第一部分实现。我们了解了它的整体设计,包括对象缓存层和核心的Halley类。我们详细探讨了如何创建引擎实例,以及如何通过addNS和addAST函数向引擎添加代码。最后,我们看到了如何通过lookup函数来查找并获取已编译函数的地址,为实际调用做好准备。




当前实现是一个功能可用的最小集合,它成功地将LLVM的JIT功能与我们的编译器前端连接起来。在下一节课中,我们将深入探讨“函数参数打包”这个关键技术的实现细节,它是实现统一函数调用接口的核心。
019:JIT引擎(第二部分)🚀
在本节课中,我们将继续探讨JIT引擎,特别是它在Serene语言编译器中的角色和实现。我们将了解Serene作为动态语言与静态语言编译器的区别,并深入分析JIT引擎如何在编译时和运行时工作,以及如何包装函数以便统一调用。
概述
在上一节中,我们介绍了Serene的JIT引擎基础实现。本节我们将着眼于更宏观的图景,探讨JIT引擎在Serene编译器架构中的位置,并解释为何需要包装函数。我们还将查看MLIR JIT引擎中的相关代码,以理解其工作原理。
Serene与其他编程语言的区别
首先,Serene是一种Lisp方言。作为Lisp,它本质上是动态的。
这与C++、Rust或Go等语言的编译器不同。例如,当你使用Clang这样的C编译器时,它会读取项目中的所有源代码,传递给解析器,编译为某种中间表示,最终生成目标代码并输出到二进制文件。这个过程在之前的课程中讨论过。

C编译器在编译时从不运行你的代码。你的代码只在运行时执行。因此,编译时(编译器活跃并编译代码的时期)和运行时(编译后的代码运行的时期)之间有清晰的界限。


但Serene不同,因为它是一种Lisp。我们需要在编译时也运行代码。这意味着我们必须处理编译时的执行,因此我们的编译时在某种程度上可以与运行时相同。

在核心上,Serene就是JIT引擎。我们将改进JIT引擎,为其添加功能,并在编译时运行它,以实现与静态编译器相同的效果。然后,我们也可以在运行时运行同一个JIT引擎,从而模糊编译时和运行时的界限。


Serene编译器架构图景

为了展示JIT引擎的位置,我绘制了一个简化的架构图。

Serene编译器有两个主要的入口点:一个是CLI接口(如serene c),另一个是REPL环境。


CLI接口流程
当使用CLI接口编译一个命名空间时,CLI程序会将命名空间名称解析为文件,读取文件内容,并通过read函数传递给解析器以获取AST。然后,它将AST传递给语义分析阶段,生成经过验证的AST。
此时,验证后的AST包含了语义上正确的AST,代表了输入程序(命名空间)。在REPL的情况下,输入是用户提交的表单。
正如我们在JIT引擎第一部分所见,我们的JIT引擎有两个函数:addAST和addNS。我们可以调用这两个函数中的任何一个,将验证后的AST添加到JIT引擎中。
当你调用这些函数时,我们会将AST间接编译为LLVM IR(通过SLIR或MLIR和LLVM IR)。在上一集的结尾,我们完成了将一切编译为LLVM IR,并将其编译为原生代码添加到JIT引擎中,但跳过了中间部分。让我们回到验证后的AST步骤。
最终,当我们将宏的概念引入Serene时,在这一步中,我们将在AST中查找宏。宏是在编译时运行的函数,它返回有效的Lisp表单,我们用返回值替换宏调用。由于这发生在编译时,这是扩展编译器的一种简洁方式。
我们将在未来讨论宏的细节,但现在只需知道它是一个在编译时运行并返回有效Lisp表单列表的函数。
我们尝试在验证后的AST中查找任何宏调用。如果存在宏调用,我们将查找编译所需的符号。这些符号应该已经被编译过,并可以在我们的JIT库中找到。
如果你还记得上一集,每个命名空间可能附加了几个JIT库。因此,我们必须查找所有JIT库,找到所需的符号。如果找不到,我们需要向用户抛出错误。如果找到了,我们将执行分配给该符号的函数。由于它是一个宏,我们将得到一个Lisp表单返回。然后,我们将再次解析它,得到一个新的验证后AST。这个循环将持续进行,直到AST中没有宏为止。这个过程称为宏展开。
处理完所有宏后,我们将经历与上一集相同的过程:将所有内容编译为LLVM IR,将任何顶层函数包装到一个新函数中,并最终将所有内容编译为目标代码(原生代码)。
在这种情况下,由于我们在编译时运行JIT引擎,我们将执行一个名为compile或dump的函数,它将代码转储为二进制格式。这个二进制文件可以是可执行文件、共享库等。
在REPL的情况下,我们将执行代码。由于我们已经将其包装在一个函数调用中,执行代码意味着调用该函数,并将结果打印给用户。最后,由于是REPL,我们将循环读取更多输入表单,并将其传递给解析器和语义分析。

这就是我设想的方式。最终,我们将拥有一个功能强大的JIT引擎,在编译时运行以编译代码并为我们生成二进制文件,就像任何其他编译器一样。这为我们打开了诸多可能性。例如,我们可以在运行时运行同一个JIT引擎,从而不再有编译时,使得运行时和编译时相同。这将使我们的JIT引擎表现得像一个解释器。我们可以传递一些Serene代码,它将即时运行代码。
这非常令人兴奋,它使得Serene非常灵活。另一种可能性是为不同目的并行运行多个JIT引擎。
这个单一特性使得Serene非常令人兴奋和灵活,但同时也使得编译器本身比静态语言更复杂,因为我们必须处理更多细节,尤其是在尝试编译这些内容时。
这就是为什么在过去两个月里,我试图理解一些关于在MLIR中实现JIT引擎的想法,但实现起来有些困难。不过,我最终会达到目标。

查看MLIR JIT引擎代码
现在,让我们来看一些在上一集中跳过的代码。请注意,我将展示MLIR JIT引擎中的相同代码,而不是Serene的版本,因为为了保持基础编译器的连接,我禁用了Serene版本中的某些部分。但如果你之后查看代码仓库,你会发现它们非常相似。

我们需要转到LLVM项目目录下的MLIR/include/MLIR/ExecutionEngine。这里有一个头文件ExecutionEngine.h。

这是MLIR的JIT引擎。它读取MLIR并执行MLIR代码。它非常类似于我们拥有的JIT引擎,也包装了LLVM,并有一个对象缓存。我选择展示这个是因为我实际上在我们的JIT引擎中使用了一些这段代码,我修改它以适用于命名空间,并添加了addAST和addNS函数,但加入了一些Serene特定的内容。我们的JIT引擎就是围绕这个版本构建的。
这里有两个查找函数:一个是正常查找符号,另一个是查找相同符号的打包版本。我还会展示其定义。有一个invokePacked函数,但正如你所见,这里有一个术语“packed”,这些函数中常见。


首先,我们需要看一些数据结构。第一个是名为Argument的数据结构,它代表我们传递给函数的所有参数。

它有一个名为pack的成员函数,接收一个包含不透明指针和类型T的值的参数向量。它所做的只是将该类型的指针添加到参数向量中。由于向量只包含不透明指针,这应该没问题。
类似地,我们有另一个名为Result的结构,代表我们尝试调用的函数的返回值。
它持有一个指向返回值的指针。

retainResultType函数用于在调用函数前获取结果类型,它只是返回指向结果值属性的指针。

最后,最重要的函数是invoke。正如我在图表中展示的,每当我们将某些内容添加到JIT引擎时,我们可以使用invoke函数来实际调用编译后的代码并执行函数。
它接收一个函数名和一个参数集合。我们创建一个SmallVector(LLVM中的一种类型,比std::vector更高效),其中包含不透明指针。我们调用参数提取器上的pack成员函数,将所有参数打包成一个不透明指针向量。然后,我们调用invokePacked函数,传递适配器名称(基本上是修改后的函数名)和新的不透明指针向量。
到目前为止很简单,没什么特别的。顺便说一下,既然我们在讨论调用过程,我们假设已经打包了函数。我将在最后展示如何打包函数。首先,让我们看看如何调用一个打包函数。

如果我们查看invokePacked版本,基本上,我们获取名称作为第一个参数,以及指向参数的不透明指针向量作为第二个参数。
然后,我们使用lookupPacked来查找函数名。由于我们打包函数的方式是将其包装在另一个函数中,我们给那个包装函数一个新名称(我稍后会展示)。lookupPacked就是进行查找,但它会查找那个包装函数,而不是被包装的函数。
如果函数指针不存在(即没有这样的符号),则返回错误。否则,调用该函数指针并返回参数数据。最后,返回成功。如果写入错误,那也没关系,因为如果我们正常调用函数,它也会抛出一些异常。它以不同的方式返回返回类型,我稍后会展示其工作原理。
但基本上,这就是我们在JIT引擎中调用函数的方式。棘手的部分是如何包装函数,但调用它并不难。
调用函数示例
让我们举一个例子来说明invoke函数如何工作。假设我们有一个函数f,它接收一个类型为i32的参数,并返回一个i32结果。
我们使用invoke函数的方式是:首先,我们需要一个变量来存储结果值。由于我们知道它返回i32,我们定义一个类型为i32的变量,初始化为0。然后,我们使用invoke函数调用f函数,传递名称作为字符串,以及唯一的参数(例如42)。最后,我们使用之前定义的result函数来标记我们想要存储函数调用结果的位置。
我们在这里定义了一个result变量,这就是我们想要存储返回值的地方。这就是我们标记的方式。基本上,当我们打包函数时,我们将创建一个指向所有参数的指针数组,最后一个元素将是指向返回类型的指针(如果不是void)。因此,最后一个参数将是指向我们想要存储返回值的变量的指针。
这就是我们使用invoke的方式。现在让我们看看如何实际打包内容。

如何打包函数
以下是实际打包内容的函数packFunctionArguments,我们向它传递一个LLVM模块。
首先,如果你觉得这看起来有点令人畏惧,不用担心。如果你不理解这个函数中的某些概念,完全没关系。这都关于LLVM IR及其工作原理。在未来的几集中,我们将讨论MLIR和LLVM,你最终会理解它们。但现在,主要目的是让你了解我们如何包装函数。细节我们将在未来讨论,或者你可以自行学习。
我们获取LLVM上下文,创建构建器对象(基本上是一个为我们创建LLVM IR的对象)。然后,我们创建一个密集集合(类似于集合的LLVM类型),用于保存指向函数类型(LLVM函数类型)的指针,并将其命名为interfaceFunctions。
每当我们处理一个函数时,我们将其插入到这个集合中,以跟踪我们已经处理了哪些函数。然后,我们将遍历模块中所有我们想要打包的函数。对于每个函数,如果它只是一个声明(即签名,定义在其他地方),我们就不关心它,因为它们是外部的,可能已经被打包了。
如果我们已经处理过该函数,就直接跳过。

接下来是魔法发生的地方。我们创建一个新的函数类型,称之为newType。它是一个返回void的函数类型,唯一的参数是一个指向i8指针的指针(即指向字节指针的指针)。基本上,这里是一个指向i8指针数组的指针。
如果函数名是f,我们将把它包装起来,并将包装函数命名为MLIR_$f,以避免任何冲突。因此,我们有一个新的函数类型,我们打包名称的方式非常简单:只是在名称前添加MLIR_字符串作为前缀。
然后,我们将该函数添加到模块中(尚未添加函数体)。我们创建函数本身(函数体),首先将其插入到我们已有的集合中以进行跟踪。
接下来是函数体部分。我们创建基本块(入口块),将其添加到刚刚创建的函数中。然后,我们让构建器开始将指令插入到基本块中。
我们创建一个类型为值指针的SmallVector,将其命名为args。这里的8表示预分配8个槽位。然后,我们遍历所有函数参数,为每个参数创建一个指针,并使用该指针。正如我之前描述的,我们将创建一个参数指针数组,最后一个是指向返回类型的指针。这就是我们实际创建返回类型的方式。
我们创建对函数的调用。这是实际调用函数的地方。现在,我们创建了函数表(函数签名),创建了基本块,处理了所有参数,最后创建了对原始函数的调用。这就是我们包装函数的方式。
我们获取结果。如果结果类型不是void(即函数实际返回值),我们为返回类型创建一个新指针作为该函数的最后一个参数。我们查看最后一个参数,如果它是一个指针,我们只需将值存储在该指针中。
你可能不知道什么是store、load等。再次强调,我将在不久的将来展示。但为了理解这些,我们需要了解一些编译器基础知识,如控制流图(CFG)和数据流图等。最好先学习这些,然后再讨论LLVM IR,这样会更容易理解。
但现在,你已经了解了为什么要包装函数以及如何包装的大局观。最后,由于我们的包装函数不返回任何内容,我们创建一个返回指令。
这就是我们实际包装函数的方式。但为什么我们要这样做呢?
第一个原因是,我们需要包装Serene中的所有顶层表单。因为在Lisp中,你可以直接在文件中编写类似这样的列表,没有什么阻止你这样做。这类似于Python这样的动态语言,你可以直接开始编写文件并传递给解释器。我们应该能够编译所有这些,因此我们将包装任何顶层表达式(它有返回类型)。我们将其包装在一个函数中,调用它,并获取其返回类型。这是一个原因。

另一个原因是,当我们想要使用invoke函数时,为所有函数提供统一的签名会更容易,这样我们可以以相同的方式调用所有内容,而不必猜测返回类型。这将由JIT引擎处理,而不是由我们处理。
总结
本节课中,我们一起学习了JIT引擎在Serene编译器中的完整图景。我们探讨了Serene作为动态语言与静态编译器的区别,分析了JIT引擎在编译时和运行时的工作流程,并深入了解了如何通过包装函数来实现统一的函数调用接口。我们还查看了MLIR JIT引擎中的相关代码,理解了函数打包和调用的基本原理。
通过本课程,你现在应该对JIT引擎在编译器架构中的关键作用有了更清晰的认识,并为后续学习更高级的编译器概念(如控制流图、数据流分析和优化)奠定了基础。在接下来的课程中,我们将转向其他重要主题,如使用TableGen生成错误信息,并逐步构建更强大的Serene编译器。

感谢你的学习,我们下节课再见!
020:未来路线图 🗺️



在本节课中,我们将回顾本系列视频已取得的成果,并展望未来的学习和发展方向。我们将讨论当前编译器设计的局限性,以及为支持动态语言(如Serene)而计划进行的架构调整。


概述


截至目前,我们已经构建了一个具备即时编译和提前编译功能的基础编译器。它包含了所有主要的组件和模块,尽管功能尚属基础,但各组件已正确连接并协同工作。

上一节我们介绍了编译器的整体架构,本节中我们来看看未来的改进计划。



当前成果回顾

我们讨论了LLVM、MLIR以及与之相关的一些概念,例如Pass管理。我们创建了自己的MLIR方言(Dialect),并成功将其降级(Lower)到LLVM IR。我们还深入探讨了JIT引擎的工作原理、代码生成以及LLVM和MLIR的其他一些概念。

虽然我们尚未有机会讨论编译器的核心基础,如控制流图(CFG)、数据流分析或数据流分析框架(DA等),但我们涵盖了词法分析、语法分析、语义分析等非常基础的内容。


因此,我们目前实现的是一个典型的静态编译器设计。


设计思路的转变

大约两周前,我在开发Serene编译器时意识到,当前的实现非常适合静态编译器,但并不适合像Serene这样的动态语言编译器。

目前,当编译器启动时,我们在旁边启动一个新的JIT引擎,并与之通信,用于执行宏展开等动态操作。但这并非我理想中真正动态的编译方式。


新的设计方向


我研究了一些其他Lisp实现、Scheme变体及其他语言的实现后,决定对当前设计进行一些更改。最重要的一项改变是:


将整个系统围绕JIT引擎本身构建。

这意味着JIT引擎将成为最先启动的组件,并用于处理所有任务:从解析、代码生成、目标代码生成到运行时链接等一切操作。

这个决定本身将给我们的设计带来巨大变化。不过,到目前为止我们所讨论的内容仍然适用。如果你正在开发静态语言或静态编译器,完全无需更改设计。但如果你像我一样,希望创建一个动态编译器,就需要进行这些调整。

系列课程规划


鉴于这是第20集,一个整数节点,现在是结束本视频系列第一部分的好时机。

对于第二部分,由于我将更改设计,我们将讨论第一部分中错过的一些编译器核心基础知识。我们将创建一些基础工具和实用程序,以帮助我们进一步学习LLVM和MLIR。我们将提升在LLVM和MLIR方面的技能,创建一些方言、Pass管理器、优化Pass等。



过去两年,我一直在研究类型系统,特别是类型系统的数学基础。我阅读了大量相关的论文和书籍。我曾考虑启动一个与本系列平行的视频系列来讨论这个话题,但尚未决定这是否是个好主意。
无论如何,在第二部分中,我希望有足够的时间,并且我的研究能朝着正确的方向进展,以便在讲解第二部分(这部分将更侧重于编译器的通用概念)的同时,也能讨论类型系统的数学理论。这将需要一些时间,在此期间,我将完成新设计的定稿,并开始新设计的实现工作。这样,我就能在第三部分拥有更多素材,并更专注于Serene语言本身。

总结




本节课中我们一起回顾了已构建的基础编译器,并展望了未来的发展路线。我们认识到当前静态编译器设计的局限性,并提出了为支持动态语言而转向以JIT引擎为核心的新架构。未来的学习将分为两部分:第二部分将夯实编译器基础并深入LLVM/MLIR工具链;第三部分将聚焦于新设计的实现与Serene语言的特性。

请分享你的反馈,这将极大地帮助我改进内容。如果你有兴趣与我一同参与Serene项目,请与我联系。我们第二部分再见。

浙公网安备 33010602011771号