【CSDN编者按】自去年苹果自研M1芯片发布之后,激发了无数用户的体验热情,与此同时,也吸引大批开发者在M1上开启探索模式。其中,国外一位资深操作系统移植专家HectorMartin发起了一项名为「AsahiLinux」项目,通过众筹的方式为苹果M1系列新机移植Linux系统。

当前,这一项目自启动至今已有2个月的时间,Martin也在AsahiLinux官网上最新发布了移植进展及首份报告,面对整个尝试的过程,该份报告指出,“让M1支持Linux真的太难了!”

接下来,让我们将共同通过这份报告,快速了解移植Linux的痛点所在!

作者|

译者|弯月

出品|CSDN(ID:CSDNnews)

以下为译文:

欢迎各位阅读我们的第一份《AsahiLinux进度报告》!文本将向你播报该项目的最新进展的。

支持新型的Linux系统芯片绝非易事!我们希望通过本文,各位能够了解为了让Linux在新设备上运行,我们在幕后付出的艰辛。

术语说明

在这篇报告中,我们会提到一系列的术语:AArch64、ARM64和ARMv8-A。

AArch64指的是64位ARM体系结构指令集;

ARM64是Linux为64位ARM提供的支持;

ARMv8-A是包含AArch64的ARMCPU体系结构规范。

这些术语的含义略有不同,但是在文本中,你可以将它们全部理解为“64位ARM”。


项目的起始

AsahiLinux项目于今年年初正式启动,但当时我们都在等待一个关键的部分:在苹果芯片系统上引导其他内核的支持。虽然该功能早已进入开发文档,而且大部分都已实现,但还缺少最后一个关键部分:对于kmutilconfigure-boot命令的支持,只有通过这个命令才能安装非苹果内核。但这个问题未能阻止我们前进,为了将操作系统移植到一个没有文档记录的平台,第一步要做的就是建立文档记录!

苹果芯片Macs的启动方式与传统PC完全不同。它的工作方式更类似于嵌入式平台(比如安卓手机,iOS设备等),但是引入了许多特别的机制。然而,苹果已经采取了一些措施,让启动过程更贴近英特尔芯片的Mac操作系统,因此人们对实际的运转方式充满了困惑。例如,根据传统的经验来看,苹果芯片Mac根本不能通过外的存储启动。苹果芯片Mac的引导程序也无法显示图形用户界面,并且“引导程序选择器”实际上是一个全屏的macOS应用,而不是引导程序的一部分。

因此,为了在这些计算机上运行自己的内核,首先我们必须确认启动过程的工作方式,内部SSD上分区和卷的布局方式,并找出与PC的区别。该文档不仅对我们的项目有帮助,而且也可以作为希望更好地了解计算机工作原理的所有macOS用户的参考文档。

2021年2月版的《苹果平台安全指南》()中记载了部分功能及其基本原理。


连接两个世界的桥梁

搭载苹果芯片的Mac启动过程没有遵循任何现有标准。它是定制的苹果机制,从iOS设备的早期阶段慢慢发展起来的。

而在苹果之外,64位ARM世界基本上可以分成两大互相竞争的标准:UEFI+ACPI(主要在运行Windows或Linux的服务器上使用)和ARM64Linux引导协议+设备树(在小型系统上使用,并得到了U-Boot等的支持)。我们需要为AsahiLinux选择其中一种标准,并找到一种方法在苹果和我们的世界之间架起桥梁。

UEFI和ACPI是非常复杂的庞然大物,通常仅适用于大型ARM系统。这些标准主要由UEFII论坛的委员会控制。x86PC世界比较单一,但ARM世界则极为多样化,系统芯片拥有各种各样的设计,为的是满足其上硬件的不同要求。因此,如果想增加对新SoC的支持,则必须修改这些标准,给那些特殊的硬件添加“绑定”。对于ACPI而言,这项工作既昂贵又缓慢,这就是为什么ACPI几乎从来不在Windows以外的小型嵌入式系统上使用。对于我们来说,这个选择行不通。

各种各样的小型嵌入式ARMLinux系统几乎都采用了设备树(DeviceTree)标准,比如大多数安卓设备的启动都采用了这种方式。设备树比ACPI简单得多,因为设备树纯粹是一堆描述硬件的数据,而ACPI表则结合了数据和代码。如今,设备树绑定的权威是Linux内核树内部维护的文档,这意味着我们可以在编写Linux驱动程序本身的同时,修改这些标准。因此,AsahiLinux的启动过程也采用了这种模型。

有意思的是,苹果针对苹果芯片的设备建立了苹果版的设备树,名叫苹果设备树(AppleDeviceTree)!这是因为苹果和开放的设备树标准都建立在开放固件规范(包括旧款Mac在内的许多PowerPC系统都采用了该规范)之上。不幸的是,尽管这意味着ADT对于嵌入式Linux开发人员来说并不陌生,但我们还是不能直接使用它们,原因在于二进制格式不同,而且如果没有关于数据含义的高级信息,两种格式之间就无法自动转换。而在规范之上,各个设备的实际绑定完全不一样。虽然Linux和macOS在PowerPCMac上的工作方式相同,并且可以兼容,但Linux和苹果在ARM领域已经分别发展了十多年。试图统一苹果和Linux处理设备树的方式将是一场噩梦。

为了让苹果使用设备树,我们正在开发m1n1,这是一款苹果芯片电脑的引导程序。它的目标是尽可能多地处理“苹果风格”的东西,减轻Linux或其他下游产品的负担。

你可以将m1n1添加到Linux内核的前面(对于最简单的固定内核,只需要运行即可),然后使用苹果的工具kmutil将其安装到Mac上,它就会负责启动Linux所需的一切处理。使用m1n1引导Linux的大致过程如下:

初始化主CPU,并应用chickenbit设置,使其正常工作。

初始化内存管理单元。为了能够使用CPU缓存,这一步是必需的,否则,一切运行速度都会非常慢。

在屏幕上显示AsahiLinux的标志,代替苹果的标志。

禁用watchdogtimer。如果没有这一步,Mac会在大约一分钟后自发重启,因为它以为启动过程被卡住了。

找出需要启动的程序:Linux内核、设备树以及(可选的)包含启动时应用程序的initramfsramdisk(如果已将它们附加到启动时的话)。

初始化所有其他CPU核心,并应用必要的chickenbit,然后让它们在“旋转表”中等待Linux的接管。

从苹果的设备树中获取信息,并修改设备树模板,使二者匹配。这一步是为了修改不同的计算机以及苹果iBoot固件不同版本的设置,例如内存大小、有关帧缓冲区的信息、初始化Linux随机数生成器的种子等。此外,m1n1还添加了一些自己的信息,例如旋转表的详细信息以及内核的命令行参数(视情况而定)。

跳转到Linux,或下一步。

“旋转表”(spin-table)是ARM版Linux在设备树的世界中启动额外的CPU核心的两种标准之一。不依赖于平台特定的驱动的标准方法有两种,所有平台都要使用两者之一。最简单的一种叫做旋转表,其做法是让引导程序事先启用所有CPU核心,然后让它们在一个循环中等待(叫做“旋转”)。为了从循环中释放CPU,Linux需要向内存写入一个值,告诉CPU从何处跳转到内核。对于简单的平台来说这完全没问题,唯一的限制就是没有办法完全停止CPU,因为从引导程序中接管CPU是一次性的。不过可以通过其他机制让CPU进入各种省电模式。我们目前采用了这种方式,有可能以后也会一直延续下去。

另一种方法叫做“PSCI”,这是一个ARM标准,是系统固件提供的服务,即使在Linux运行时,也可以利用它同时控制所有CPU。通常,该操作需要运行在“EL3”(即安全固件,又称TrustZone)上的代码来实现,或者通过运行在“EL2”上的虚拟机监控程序来实现。而操作系统通常运行在EL1上。但是,在ARMv8-A的CPU中,EL3和EL2都是可选的,而且事实证明M1并不支持EL3。M1支持EL2,但是我们希望能在Linux下运行虚拟机,这就要求Linux本身需要运行在EL2中,因此没办法在EL2中运行一个监控程序。这就意味着我们现在还不能使用PSCI,因为PSCI的标准接口不满足我们的需求。以后也许能够出现其他标准方法。也许,只有采用其他方法,才能支持整个系统的睡眠功能,尽管如果细粒度的电源管理足够好的话,我们也许不需要“真正”的全系统睡眠模式,就能获得不错的待机时间(现代设备对于更细粒度的睡眠模式的支持非常好)。不过这个领域仍然在发展,所以只能拭目以待了。

虽然我说过我们要使用设备树,但这并不意味着我们不能使用UEFI!ARM64系统能够同时使用UEFI和设备树进行引导,而且只有这样做,才能像PC那样通过GRUB等引导程序和通用的流程安装和升级内核。但是m1n1并不支持这样做,那么怎么办呢?幸好还有其他途径:U-Boot。U-Boot可以像Linux内核一样引导,所以只需从m1n1中引导U-Boot,然后U-Boot就可以为GRUB和Linux提供良好的UEFI环境。

因此,最终AsahiLinux的引导链大致如下:

m1n1→U-Boot→GRUB→Linux

结合苹果特有的引导链,整个引导过程大致如下:

在冷启动时,M1芯片内的SecureROM启动,并从NOR闪存中加载iBoot1。

iBoot1读取内置SSD中的引导配置,验证系统引导策略,然后选择一个“操作系统”进行引导。在我们看来,AsahiLinux/m1n1对于iBoot1而言就像一个操作系统分区。

iBoot2(操作系统引导程序,它需要位于被引导的操作系统分区内)加载固件供内置设备使用,设置苹果设备树(ADT),并引导一个Mach-O内核(对于我们,就是m1n1)。

m1n1解析ADT,配置更多设备,让整个环境更像Linux,然后设置FDT(展平后的设备树,即二进制格式的设备树),然后引导U-Boot。

U-Boot(其驱动程序位于内置SSD中)读取其配置和下一阶段的代码,然后提供UEFI服务(其中包括转发来自m1n1的设备树)。

GRUB作为标准的UEFI应用,从磁盘分区中引导,就像PC上的GRUB一样。有了GRUB,Linux发行版就可以通过惯常的方式,使用grub-mkconfig和/etc/default/grub等管理内核。

最后,引导Linux内核,所需的信息由从m1n1传过来的设备树提供。

对于习惯了PC的人来说,这个过程可能有点不可思议,但在嵌入式系统中,这种很长的引导链是十分常见的(而且实际上,即使在普通的PC上,UEFI也包含多个阶段,只不过最终用户看不到而已)。例如,DragonBoard410c(一款基于高通的平台)的引导链可能如下:

PBL→SBL→QSEE→QHEE→LK→U-Boot→GRUB→Linux

注意,我们没办法替换iBoot2(它需要苹果的签名),但最终用户的安装过程会自动设置一个最小化的“macOS”,其中包含iBoot2和所有必须的支持文件,为的就是解决这个问题。这些足够让苹果的引导过程将其识别为可引导的OS(只不过没有真正的macOS内核和文件系统)。我们还没有实现安装程序,所以目前开发人员只能通过先整安装macOS,再替换内核的方式来尝试m1n1和LInux。我们编写了一个手把手的快速入门指南(),供想尝鲜的人使用。

目前,我们主要的开发工作是从直接m1n1中加载Linux,不过MarkKettenis在负责U-Boot和OpenBSD的支持工作。

但是m1n1不仅仅是运行Linux。实际上,它本身甚至不是引导程序!


处理硬件问题

m1n1诞生于mini,后者是我为任天堂Wii的安全CPU编写的一个最小化环境。它很适合拿来做各种试验,以及作为BootMii的后端。如果你手里有Wii,而且还听说过BootMii,那么当你在BootMii的菜单中时,ARMCPU上运行的就是mini。

那么,这跟苹果芯片上的引导程序有什么关系呢?实际上,mini只不过是在32位裸金属ARM系统上运行的一个非常简单的软件,不包含任何外部库和依赖。因此,它非常适合构建裸金属代码,于是我们将其移植到了AArch64和苹果芯片上,并改名为m1n1。但更重要的是,mini和m1n1都有一个秘密武器:由于mini作为固件在一个单独的处理器上运行,而这个处理器需要主CPU负责控制,而且根据以前针对Wii的硬件研究成果,mini内置了一个RPC代理,可以通过串口访问。这就意味着你可以从一台开发计算机上对mini和m1n1进行“远程控制”,甚至可以从交互式的shell中进行()。所以更恰当的描述是,m1n1是一个硬件实验工具,恰好能作为Linux引导程序使用。

所以说,这个平台特别适合硬件学习,而且适合寻找苹果的私有特征。例如,

这段脚本()测试了一个特殊的苹果特性:给CPU添加一些x86专有的浮点配置比特,用于加速Rosettax86模拟。

这个脚本()能够搜索所有苹果定制的CPU寄存器,并输出它们的值和访问限制。

这个脚本()能够自动找出怎样通过苹果专有的监控程序配置寄存器来实施这些访问限制。

当然,还有这个脚本()能够引导Linux内核,并通过串口输出。

将一台M1MacMini引导至m1n1需要大约7秒,而且所有这些脚本都可以交互式运行,无需重启(除非你把机器搞崩溃了)。m1n1还能加载自身,所以m1n1的开发周期非常快:只需用kmutil安装一次m1n1,以后重启后只需加载最新的m1n1即可。

我们使用m1n1为苹果的自定义ARM指令集、苹果专用的系统寄存器以及苹果中断控制器等硬件建立了文档。

以后,我们会继续给m1n1添加更多特性,让它成为更强大的研究工具。其中一个特别激动人心的目标就是,将其变成一个非常薄的虚拟机监控程序,能够启动macOS,并拦截macOS对于M1硬件的访问。如此一来,我们无需反编译,就能调查苹果的驱动程序的工作方式,还能通过合法的渠道进行调查,而且比跟踪复杂的私有驱动程序的代码效率高很多。一些人可能知道这种方法,因为之前nouveau就成功地通过此方法,对NVidia的GPU进行了逆向工程,但当时他们使用的是Linux驱动程序,而且只修改了内核,没有采用虚拟机监控程序。

但是等一下,这一切都需要串口。但是M1的Mac哪儿有串口?好问题!


UART登场!

对于新系统的底层开发,串口几乎是不可避免的。串口(有时也称为UART端口)是最简单的通信硬件,对于底层调试工具来说非常方便。通过串口发送消息只需要几条CPU指令,所以我们在非常早期就可以建立串口通信,作为开发的文本终端使用。

当然,现代PC曾经有过RS-232串口,但那些都是过去了。在许多嵌入式系统(如绝大多数家用路由器)的内部依然有低电压串口,但需要拆开外壳才能连接,或者是直接位于主板上的测试点。那么M1Macs是什么情况呢?

事实证明,M1Mac的确有一个串口,而且不需要拆机就能访问——通过某个USB-C口!但是要想启用串口,在必须通过USB-PD发送某些特殊的命令。USB-PD(USB供电)是TypeC端口上的一种协议,使用“配置频道(ConfigurationChannel)”针脚。按照USB标准的一贯作风,它在工程上的设计也有点过,实际能完成的工作远不止供电——它不仅能用于配置电压、识别充电器,还能用于识别线材、识别适配器、切换模式(如DisplayPort),在这里还被作为一个频道,发送苹果专属的配置消息。这些消息可以让Mac将其串口暴露在某个特定的TypeC端口的两个针脚上。其他的便利功能还有远程重启系统(对于快速开发来说是必不可少的),切换成DFU恢复模式,访问I2C之类的内部总线,等等。

我们的第一个启用串口的解决方案是vdmtool()。这套工具包括一根使用Arduino的自制电缆,一片USB-PDPHY(接口)芯片,还有一些1.2V的串口适配器。虽然这些东西只需要一点DIY技能就可以自制,但对于没有制作硬件经验的人来说并不是太现实。制作过程有许多麻烦:市面上没有能支持所有必须的TypeC信号的USB-PDPHY电路板,1.2VUART适配器也非常罕见,等等。

因此,我们想出了第二个解决方案:如果你正好有两台M1Macs,那就完美了!你只需要一根TypeC线(SuperSpeed/)和macvdmtool()。这个macOS上的小应用可以将一台M1机器变成另一台的串口调试终端,这样你就可以运行m1n1脚本,并从macOS直接引导Linux内核了。苹果的API可以将Mac自己的端口配置成串口模式,还可以发送必要的消息,将远程Mac配置成串口模式,这样不需要自定义硬件就可以实现这一切。

最后,尽管硬件串口是底层调试和开发的最佳方案,但是它也有局限性:速度非常慢,最快只有150kB/s。但是M1Mac还可以作为普通的USB设备使用(就像iPhone一样),我们可以将它作为USB串口设备(CDC-ACM),在绝大多数操作系统上,这种设备无需驱动就可以使用。这样就能提供USB的全部带宽,而且可以使用正常的TypeC线(或TypeC到TypeA转接线)连接到任何电脑。USB还提供了流控制,因此即使接收端没有准备好接受数据,也不至于丢失数据。这种方式的缺点是,它需要更复杂的驱动代码,所以不适合调试非常底层的问题。但只要能得到m1n1的支持,就足以进行任何后续的工作,而且我们可以使用已有的串口支持很方便地开发更复杂的驱动代码,因为这些Mac上的TypeC接口可以同时传输UART串口和USB的信号。额外的带宽和性能对于上面提到的监控程序的开发非常有帮助,而且还能加快加载Linux内核的速度,因为目前内核加载受到了串口带宽的限制。预计接下来几个星期内m1n1就会支持该功能,敬请期待!


通向企鹅之路

所有这些工具都很好,但毕竟我们的目标是运行Linux。那么,怎样将Linux移植到一个全新的平台上?当然,在整个过程中,很大一部分需要编写新的驱动程序,但有一些事情需要先完成。我们管这些事情叫做“铺路”。

铺路非常重要,不仅因为它是在机器上运行操作系统所需的其他工作的基础,而且因为它需要为机器特有的特性的工作方式设置标准。它是紧密联系操作系统最深处的一些底层代码,而且与一般的驱动程序不同,它通常需要修改Linux中各个平台共通的部分。这就需要与负责相应的子系统的Linux维护者们协调,并找出所有人都同意的解决方式。

这里面的水非常深。在最初的M1支持补丁中,我们需要更改一个与SPARC64架构支持相关的文件!Linux开发的一个独特的特性是,Linux内核没有稳定的驱动API/ABI,因此Linux内核的内部设计一直在持续改进和重构。这就是说,如果在某个架构上支持的某个功能需要修改其他架构,那么这种修改是完全可行的,而且通常都被视为正确的做法。但这也意味着维护Linux的分叉或不属于上游内核的第三方驱动变得非常困难。

AsahiLinux的目标不仅是将Linux移植到苹果芯片上,而且还要以开源社区驱动项目的形式进行,与整个Linux社区合作,将我们的工作推送到官方的Linux内核中。在嵌入式ARM的领域中,这种方式非常罕见,因为绝大多数开发Linux移植版的公司都在忙于应付最终期限,所以他们会创建一个Linux分叉,然后在上面进行所有开发,完全脱离了上游的社区。等到他们想把修改合并到官方Linux内核中时,通常由于两个分叉分别开发的时间过久,因此导致合并的难度非常高。其设计决策也可能与Linux的哲学背道而驰,从而无法被上游接受。最终,许多代码只能重写,只追求短期结果而忽视长期可维持性的做法导致许多开发时间白白浪费。

我们不想重蹈覆辙,所以我们的方法就是尽可能早地合并到上游,并从第一天开始就与整个社区合作。因此,我们已经与上游Linux维护者一起工作,而且有好几个Linux的关键开发人员都在我们的AsahiLinux的IRC频道中!

为了确保可以在任何系统上引导Linux,有五项工作必须完成:

CPU

内存管理单元(MMU)

中断控制器

系统时钟

某种控制台,在这里是串口控制台

在绝大多数AArch64系统中,前四个非常标准:Linux不需要任何改动就能运行到启动基本的控制台这一步。话虽如此,但苹果的系统芯片就喜欢我行我素……所以我们还有许多工作要做!


关闭再打开

与八九十年代的设计相比,现代CPU是工程上的奇迹。过去,CPU的工作只不过是执行简单的算术运算、读写内存,以及做决策而已,按照顺序一步步做,从不停顿。没有电源管理,没有缓存,没有多核心,也几乎不支持浮点数。

但时代变了,如今的CPU变得越来越强大,消耗的电力也越来越少。这是怎么实现的?一部分要归功于集成电路制造的进步。还有一部分要归功于CPU设计方面的巨大进步。现在一个CPU的核心就能同时运行多条指令,预测未来并提前执行,如果预测错误就回滚,还能将经常使用的数据或预测即将使用的数据保留下来,甚至可以动态的将一部分CPU打开或关闭以节省电力。

但是,如此复杂的设计带来了两个问题:预料之外的特性,以及bug。现在的操作系统需要更多地对CPU的细节进行微管理,甚至连应用程序软件都需要注意,不要对CPU做出不实际的假设。

从九十年代就开始使用计算机的人可能还记得Windows95和Windows98。我们无法在新的电脑上使用这些操作系统,因为CPU的温度会迅速上升,而且会持续保持高温,即使电脑几乎没有运转也是一样。原因就在于,这些操作系统在无所事事时也会让CPU运行一个无限的循环。因此,即使无所事事,CPU也是100%处于“使用中”的状态!旧的CPU没有“闲置”的状态:如果没有工作可做,就会浪费掉。没有电源管理,所以即使无所事事也不会省电。

当然,现在我们都已经习惯了闲置的CPU能够省电。操作系统在无所事事时会告诉CPU在某种程度上停止工作,然后等待一个事件(由外界发送的、表示需要开始工作的事件)。在x86PC上,这一操作由HLT(停机)指令负责;在Windows95时代,曾经有一个叫做“Cpuidle”的软件,能够在无限循环中运行HLT,在没有工作时将CPU转入低功耗模式,从而节约电力并降低CPU温度。现代操作系统已经内置了该功能,而且ARM的CPU也实现了同样的机制,指令名为“WFI”,意为“等待中断”(WaitForInterrupt)。

现代CPU在调用HLT或WFI时不仅会停止运行指令,还会关闭一部分核心的供电,以节省更多的电力。停止时钟的技术叫做“clock-gating”,断电的技术叫做“power-gating”。但是这样做是有代价的:power-gating会导致CPU丢失数据。关键的数据必须保持在有电的电路中,或者移动到有电的备份存储中。正常情况下,这些指令不会导致可见的数据丢失,CPU可能会丢弃一些不再需要的数据,但会保证不丢失软件正常工作所需的数据。

当我们几乎在M1上成功引导Linux时,出现了一个问题:每次引导过程即将结束时就会立即崩溃。实际上,它似乎是在执行完WFI指令之后崩溃的:它跳转到了一个零地址,而没有者却返回到调用函数。为什么?

我们发现,M1的默认运行模式中,WFI可以做两件事情:或者是clock-gate,或者是power-gate。实际上,它会根据某种启发式的方法来决定执行哪种。不幸的是,当它决定执行power-gate时,CPU就会丢失所有寄存器的内容,除了栈指针和指令计数器之外。Linux并没有预料到这件事情发生。因此,我们只能添加一个非常丑陋的补丁,因为任何其他AArch64的CPU都不会这样做,Linux也没有任何机制能针对特定的系统芯片替换WFI闲置循环。因此只能在通用的Linux代码中针对特定的CPU进行处理。

不过,多亏了我们给CPU中的苹果专有寄存器建立了文档,并且记录了CPU正常工作所需的chickenbit序列,我们发现有一个特殊的寄存器可以用来覆盖该行为,保证WFI永远不会执行power-gate,从而让Linux正常运行。我们只需要在m1n1中将该寄存器设置为正确的值,就能解决问题!这是最好的修复:m1n1负责处理问题,因此不需要对Linux打补丁。

你也许想问,这样做会不会影响系统的功耗。不要怕!这并不意味着无法使用M1的power-gating功能。Linux通过一个名为cpuidle的子系统支持更深层次的CPU省电模式。Linux可以通过该子系统,将CPU设置成更深层的省电模式,而该子系统的驱动程序能完美地保证在CPU丢失信息后能正确恢复信息。因此,我们需要做的就是编写一个cpuidle驱动,将M1改回power-gating模式(如果Linux的内部算法更好的话,也许我们可以跳过M1的启发式算法),直接在驱动程序中执行WFI,然后在返回核心Linux代码之前恢复CPU的数据。通过Linux的方法管理CPU省电。

这也展示了我们的开发过程中一个非常重要的部分。在处理没有文档的设备时,最简单的方法就是保留原有软件(macOS)的做法。但是,其他操作系统或固件的做法也许并不适合Linux。因此,我们要首先理解系统的优点,然后才能决定哪种方法最适合Linux。如果我们简单地照搬macOS的做法(在主CPU闲置循环中支持powergating模式),却没有研究相关的CPU寄存器,就会给Linux打一个非常丑陋的补丁,而错过这种干净的解决方法。后者的确需要更多时间,但我们认为这样做是值得的!

这并不是CPU给我们带来的唯一惊喜,不过其余的话题就以后再说吧。下面我们来讨论下一个话题:内存管理。


投递失败的信退回给发信人

事实证明,串口只能在内存管理单元启用之前使用。这很不幸,因为内存管理单元会改变访问内存的方式,包括访问UART设备的方式,但这个问题很难调试,因为MMU是预先配置好,然后一次性打开的。如果里面出了问题,你很难找到问题在哪儿。

最后发现,是由于M1对于设备的内存管理非常苛刻。

所有现代操作系统内核的核心都是内存管理单元。它是CPU的一部分,负责隔离正在运行的进程、管理虚拟内存(交换文件或交换分区)、将磁盘上的文件映射到内存、在线程和进程之间共享数据等功能。它负责将多个虚拟内存地址空间(应用程序和内核拥有的内存地址的概念)映射到物理地址空间(系统中硬件的实际内存地址)。在这里,“内存”既包括实际的RAM,也包括作为内存映射I/O(MMIO)出现的设备。UART是MMIO设备。

在大多数平台上,普通内存和MMIO之间是有区别的。我们可以认为,普通内存(即RAM)以某些合理的方式运行,例如在写入数据后再读取,则永远会返回写入的数据。但是使用MMIO来接收命令并返回状态和数据的硬件却不一样,所以它们的行为和正常的RAM不一样。CPU可以对内存访问指令进行重新排序和缓存,但如果针对MMIO访问进行这些操作,那就会导致一系列问题,因为驱动程序依赖精确地控制何时要发送数据、何时要接收数据。MMU负责这个区别:内核有一个配置比特,表明内存是普通内存,还是设备内存。

但是,当然,如今这一切都变得复杂得多。有访问权限问题、不同的缓存模式,还有不同类型的设备内存。在AArch64上,映射设备内存有四种方式:GRE,nGRE,nGnRE,和nGnRnE。字母G、RheE代表系统被允许或不被允许(字母n表示)的三件事情:

G(Gather):将多个写操作收集到一个写操作中。例如,CPU可以将两个相邻的8位写操作合并成一个16位写操作。

R(Re-order):对写操作重新排序。如果依次向两个相距很远的地址写入,那么CPU可能会用相反的顺序写入。

E(Early):提前写入。系统可能会在数据到达目标设备之前就告诉CPU写入完成,从而让CPU继续执行后面的代码。在x86的世界中这个操作称为“postedwrite”。

绝大多数驱动和设备在G和R启用的情况下都会出问题,所以除了非常特殊的驱动之外,很少有驱动会使用这两个模式。但是,提前写入(E)实际上是PC的标准,因为它是PCI规范的强制要求。因此,几乎所有驱动都能够处理该操作。鉴于此,AArch64Linux会将所有I/O内存映射成nGnRE,同时允许提前终止。这在其他设备上没有问题。许多设备可能并不支持postedwrite,但那样的话,它们会简单地将访问当作nGnRnE处理。设备可以提供比软件要求更严格的保证,只要设备的行为与软件要求的同样严格,就不会出问题。

我们发现,M1的内部总线结构会强制所有访问使用nGnRnE模式。如果尝试使用nGnRE模式,则会放弃写操作,而系统会发出SError(系统错误)信号。最初由于无意中从另一个项目引入的一个CPU配置,它错误地禁用了错误报告功能,我们并没有看到这些SError。(但即使不是因为这个错误的配置,由于UART损坏,我们也无法看到错误,不过至少会让系统在UART写入后停止工作,而不是默默地丢弃它们并继续运行)。

聪明的读者可能注意到了这里的一个有趣的细节:M1系统芯片具有PCIe!实际上,某些内部设备是PCIe设备(例如MacMini上的以太网),而且M1Mac可以借助Thunderbolt连接到任何PCIe设备。难道这些设备不使用postedwrite吗?确实,它们的确会使用!实际上,M1要求PCI设备必须使用nGnRE映射,同时会拒绝nGnRnE写操作。

这带来了一个难题。Linux没有将内存映射为nGnRnE的框架。我们可以引入一个临时补丁,以便在任何地方都使用nGnRnE(而不是nGnRE模式),但是那样就不可能支持需要nGnRE的PCIe设备。于是,我们针对上游交互展开了第一项测试:我们必须开发一种完全定制的机制,将内存映射为nGnRnE,然后一种方法指示Linux将其用于苹果芯片平台上的非PCI设备,同时仍然允许PCI驱动程序使用nGnRE模式。而且,我们必须以一种干净,精心设计的方式来实现,同时还需要在不破坏现有代码和照顾到其他非苹果设备之间取得平衡,并与负责这些子系统的维护者达成共识。

最后,在与多个子系统和多个补丁修订版的内核维护者进行了数周的讨论之后,我们确定了如下方法:

引入ioremap_np()。在所有架构上,通常Linux都会使用通用的ioremap()函数映射MMIO设备内存。还有一些不十分严格的其他变种,例如ioremap_wt()。我们添加了一个新的变种,能特别低指定请求non-posted内存映射。

实现ioremap_np()在ARM64上使用nGnRnE模式(其他架构目前不会实现该模式,尽管这种模式对它们也许也有用。)

引入nonposted-mmio设备树属性。这也可以用来将设备树中的特定总线标记为需要ioremap_np()。

让Linux设备树子系统在查找设备时自动选择nonposted-mmio模式,并将其变成一个描述MMIO资源结构(IORESOURCE_MEM_NONPOSTED)中的一个标志。

编写两个高层APIdevm_ioremap_resource()和of_iomap(),自动解释该标志,并将其“升级”成一个ioremap_np()。

修改需要在M1系统芯片上使用的驱动程序,确保它们调用这些API,而不是调用原始的ioremap()。

为此,我们需要对直接使用ioremap()的驱动程序进行一些重构,但由于只需要针对在M1上构建的硬件进行重构,所以只需要修改几个驱动程序。如今的绝大多数PCI驱动都直接调用ioremap(),而且所有这些都可以通过Thunderbolt适配器在M1电脑上使用;因此这些驱动都不需要改动,因为默认的ioremap()依然适用于仍然请求nGnRE模式的驱动程序。

在修改的过程中,我们意识到,Linux缺少有关ioremap()各种模式的文档,也没有关于I/O读写函数的文档。于是,我与ArndBergmann一起添加了部分缺少的文档()。

有趣的是,由于这部分改动针对的是通用“简单总线”设备,因此这意味着我们必须将补丁提交给核心设备树规范及其架构。值得庆幸的是,由于设备树是一个开放的社区驱动项目,因此只需提交几个GitHubPR即可!


这就是AIC

在具有多个核心的系统上,中断请求控制器还有另一项工作:处理处理器间中断(inter-processorinterrupt,IPI)。有时,在一个核心上运行的软件需要引起另一个核心的注意。有了IPI,这种操作就不难实现了:中断控制器提供了一种机制,一个核心可以向中断控制器发送请求,然后中断控制器将其作为中断转发给另一个核心。没有IPI,多核系统将无法正常工作。

大多数AArch64系统都采用了标准的中断控制器,称为通用中断控制器(GenericInterruptController,GIC)。这是一个非常复杂且功能强大的中断控制器,有许多高级特性,如中断优先级、虚拟化等。如此一来,Linux就不需要在AArch64系统上实现自己的irqchips作为主中断控制器了。

你可能已经猜到了,苹果依然特行独立。他们设计了自己的苹果中断控制器(AIC)。我们不得不对该硬件进行反向工程,然后为Linux编写自己的irqchip驱动程序!不过幸运的是,AIC其实非常简单。根据macOS/iOS(XNU)的一些开源文档(虽然有些过时),并通过试错的方式对硬件进行了一番探索,我们终于弄明白了一切,并编写了Linux驱动程序。

等一下,还有一个问题。Linux需要IPI才能正确工作。具体来说,Linux使用了7种不同的IPI:它希望能够从一个CPU核心向另一个核心发送7种不同种类的中断请求,并将它们当作不同的事件处理。AArch64系统上的任何IRQ控制器都能支持这种细粒度的IPI分离,但不幸的是AIC不支持:它只能支持两种,而且实际上,这两种的使用方式还不一样(一个用于发送给其他CPU,一个用于核心给自己发送的“自身IPI”)。为了确保Linux正常工作,我们需要实现一个“虚拟”中断控制器。对于每个CPU核心上不同种类的待定事件,AIC驱动程序内部最多能管理32个事件,它会将这些事件全部发送给对应于该核心的硬件IPI。当IPI到达该核心时,它会先检查有哪些待定事件,然后将待定事件当作不同的IPI发送给Linux。Linux的其余部分就会认为这是一个能够针对每CPU最多处理32个IPI的中断控制器,尽管其硬件只支持两个(实际上我们只用到了一个)。

即使是给AIC这样简单的中断控制器编写驱动也不是一件易事。中断处理有许多方面需要处理,哪怕代码中有一点错误,就会引发令人苦恼的heisenbugs,这种bug只在罕见的特定事件序列发生时才会出现,但一旦出现就会导致整个操作系统宕机,因此调试几乎是不可能的。在中断处理程序中,输出调试信息非常需要技巧,因为改变时机就可能导致bug消失,也可能导致整个系统过慢而无法使用。而添加一个软件IPI多路复用器会导致情况更加复杂,因为我们不得不用软件来模拟本应由硬件来处理的东西,这样一旦出错就会由于竞争条件而丢失IPI。

在尝试理解这些细节以确保AIC代码正确时,我发现自己陷入了无底洞:我不得不研究AArch64上的内存顺序和内存屏障等细节,甚至还发现了ARM64Linux原子操作实现中的一个细微的错误!当然,这是另外一个话题。如果你想了解更多信息,我推荐看一看WillDeacon的演讲,比如这篇()和这篇()。特别是,此提交()回答了很多问题,Will还回答了我剩余的一些疑问。我对内存模型和AIC代码的健全性很有信心,这可以避免在调试过程中感到困扰。试想一下,如果我们必须追踪一个微妙的GPU挂起问题,而由于AIC驱动的竞争条件问题,这种问题只有在游戏中做某些事情时才会发生(但只是偶尔会发生,并且需要一个小时才能重现)!

不知是好是坏,M1特别善长暴露这种小bug。它的乱序执行极其强大,所以那些竞合条件是在其他CPU上从来不会发生的。在调试一个早期的m1n1问题时,我们甚至观察到了乱序执行(正确地)超出了中断处理程序的范围……代码才执行到了处理程序的一半,就已经输出了调试信息!问题的深层原因是因为MMU中的一个微小的错误配置。从这个问题你可以看出,核心系统的各个部分是紧密联系的,而且调试非常困难。

有意思的是,M1芯片实际上带有标准的GIC。具体来说,它能够原生地将GIC的底层比特虚拟化,供虚拟机系统使用!这样就可以实现更高性能的中断处理,因为没有这个功能,虚拟机的监控程序就不得不模拟中断控制器的每个细节,意味着每个中断都需要调用多个监控程序中的代码并返回。但奇怪的是,macOS的监控程序框架()并不支持该功能(至少在本文撰写时如此),因此虚拟机的监控程序依然需要使用软件完整模拟GIC。我们已经测试过这一点了,并证明了可行,现在正在与MarcZyngier合作,在这些芯片上运行虚拟机;他已经成功地实现了在M1Mac上运行的AsahiLinux内核上运行的KVM中启动Linux虚拟机。性能测试还为时尚早,但我们希望,如果macOS不支持这个功能,那么只要其他部分完成,原生的Linux-on-Linux虚拟机就会比Linux-on-macOS虚拟机更快,特别是对于IPI很多的负载。


过度繁琐的FIQ

AArch64包含一个特殊的系统时钟规格,M1也按照我们期待的方式实现了该标准。但是有一个平台特定的比特:时钟需要通过某个IRQ控制器发送中断。在GIC系统中当然是通过GIC发送(尽管每个系统使用的中断编号可能不同)。因此,在苹果芯片中,就应该通过AIC发送。

但是,触发时钟中断并要求AIC告诉我们等待的中断的话……结果什么都得不到。什么?苹果又一次为我们带来了惊喜……你看,M1的时钟完全没办法发送IRQ。实际上,他们只发送FIQ。

当我们说AArch64CPU只有一个IRQ线的时候,我们并没有提及它的兄弟:FIQ线。FIQ(FastInterruptRequest,快速中断请求)是另一个中断机制。这里的“快速”指的是它们比旧的AArch32系统工作得稍稍快一点,但在AArch64上,这点区别已经不再:FIQ和IRQ实际上是相同的。在GIC系统中,操作系统可以配置每个中断,决定它们通过IRQ还是FIQ发送。而绝大多数AArch64系统都保留了FIQ作为安全监视器(TrustZone),所以Linux无法使用它。因此,Linux完全不使用FIQ。AArch64Linux如果收到一个FIQ就会宕机,它也从不会期待收到FIQ。

没有FIQ的支持,M1上就没有时钟,所以别无选择。这是为了苹果芯片而必须做出的另一个重大修改。添加FIQ的支持很容易(最简单的方式只需要机械地将IRQ的处理方式复制过来,同样地处理FIQ即可),但是具体的细节很麻烦,包括决定如何为不需要的系统处理FIQ,以及是否要在所有地方启用FIQ,还是在不需要的地方禁用。

最后,在思考了几种方法,并进行了几轮迭代之后,LinuxARM64团队的MarkRutland主动承担了这个任务,负责给Linux添加FIQ支持。

而M1拥有的是一些额外的特殊功能,用于处理虚拟机操作系统的时钟中断(因为这是让虚拟机正常工作的必要条件)。我们也对此作了逆向工程,并将其用在了Marc运行KVM的工作中。

在针对核心FIQ支持的补丁之外,我们还决定将FIQ分发给AIC驱动中的下游设备驱动(即使严格来说它们并不是AIC的一部分),以实现这些路径之间更紧密的耦合。如果我们决定改变通过IRQ发送AICIPI的做法,改成通过FIQ发送“快速IPI”,那么这个决定将会派上用场。

历史遗留下来的问题

能够在设备上运行Linux固然很好,但如果没办法与之交互怎么办?为了能访问dmesg日志并通过控制台与Linux交互,我们需要M1上的UART驱动程序。UART有好几个变种,最流行的是PC上的标准UART16550,现在几乎所有ARM系统芯片都集成了这个标准。但毕竟是苹果,他们肯定会搞自己的标准……对吧?

没有!但是,用的不是16550……M1用的居然是……三星的UART?

第一代iPhone采用了三星的系统芯片,即使苹果自豪地宣布他们切换到了自己的设计,底层脱离三星的速度也要慢半拍。“苹果芯片”与其他系统芯片一样,包含来自许多其他公司授权的知识产权核心。例如,M1的USB控制器来自Synopsys,其硬件的芯片来自Rockchip、TI和NXP。甚至在苹果将制造商从三星换成台积电以后,一些三星的东西依然留在芯片中。UART的设计一直保留至今。我们不知道这是否意味着M1中包含三星的知识产权,也许只不过是苹果照搬了三星的设计来保证软件兼容性(严格来说UART并不难设计),但不论如何,今天的Exynos芯片和苹果芯片依然有共通点。

Linux已经有了三星UART的驱动程序。但问题在于(当然会有问题):“三星UART”并非只有一个,而是有好几个略有不同的、互不兼容的变种,而至于苹果使用的变种,Linux上的三星UART驱动并不支持。

支持许多同一硬件的变种的驱动程序会变得非常混乱,像三星UART这样古老的驱动程序更是如此。更糟糕的是,Linux中的串口子系统还是Linux早期的版本,这就带来了另一个问题:古老的代码。所以,最大的问题在于集成新UART变种的支持,同时不能让代码变得更乱。这就意味着要做重构和清理!例如,Linux有一个古老的概念叫做串口类型,暴露给用户空间(意味着这些类型只能添加而不能删除,因为用户空间API必须维持向后兼容性),但是这与现代Linux中的设备处理方式完全不同。用户空间完全没有理由知道串口类型是什么,即使知道,也不应该使用TTYAPI和固定的列表来访问(这就是sysfs存在的原因)。每个已有的三星UART变种都有自己的端口类型(甚至还有一个从来没有实现过的未使用类型),但显然我们并不想添加另一种类型……所以我们重构了驱动程序,给UART变种添加了一个内部标识,与那些暴露给用户空间的端口类型完全无关。对于这个古老的API来说,苹果的UART会被识别为16550,反正这个API也不会有人用。

另一个困难是这些变种处理中断的方式。较老的三星UART有两个独立的中断输出,分别用于发送和接收,由系统中不同的中断控制器负责。新的Exynos变种会在内部处理,在UART中有一个很小的中断控制器,负责处理各种中断类型,将所有中断作为同一个发送给系统的IRQ控制器。苹果的变种也是这样,但与之并不兼容,还添加了不同的寄存器,所以必须编写不同的代码路径。

在此之上,该UART变种仅支持边沿触发的中断。边沿触发(edge-triggered)中断是一种仅在事件发生时立即触发的中断。例如,当UART发送缓冲区清空时。与此相对的叫做状态触发,只要特定条件为真,状态触发中断就会触发。由于种种原因,状态触发中断的处理更为简单,所以大多数现代系统都选择了状态触发。尽管AIC自己用的是状态触发中断,而且UART自己的中断也是状态触发,但是驱动它的内部事件(例如当传输或接收缓冲区为空或满时)却采用了边沿触发的方式!其他的三星UART类型支持两种模式,而Linux采用了状态触发模式。这就导致了通过UART传输数据的Linux代码造成了一个问题:现有的代码只能打开传输器,然后就无所事事了。由于一切都配置为状态传输模式,而传输缓冲区为空时会立即触发一个中断,而驱动程序中的中断处理器会使用即将传输的数据填充缓冲区。在边沿触发模式下就不能这么做,因为触发时缓冲区已经为空了,而不是即将为空。此时不会有任何事情发生,驱动程序也不会发送任何数据。我们必须让驱动程序在数据可以发送到设备时,“立即”处理传输缓冲区,因为只有第一批数据发送之后才会引发中断触发,从而请求更多数据。

应付UART的这些奇怪的特性尤其麻烦,因为我们在使用m1n1进行试验时,m1n1本身就是通过UART控制的。尝试研究设备的工作方式,而通信设备本身就是该设备,这就非常麻烦!不过幸好这些工作都完成了,如今m1n1可以正常工作了。

还有另一个驱动程序需要进行同样的处理,不过需要使用完全不同的路线。M1芯片中的I2C硬件来自!似乎M1中还包含一些来自PowerPC的遗产,而其I2C外设是基于PWRficient芯片的,包括AmigaOneX1000中使用的芯片。Linux支持那个平台,但是现有的驱动的功能非常薄弱。幸运的是,在联系了驱动的作者之后,发现他手里依然有能正常工作的X1000,可以帮助测试补丁。我们还获得了该芯片的硬件文档,这样我们就能改进驱动程序,并添加能够在X1000上正常工作的特性(如中断支持),同时添加支持M1所需的改动。由于该驱动是启用全速USBType-C端口的必要条件,所以这个工作早晚要做。

终于能见到企鹅了!

作为一部“给Linux铺路”的鸿篇大论,最后我们来看一看怎样让Linux的帧缓冲控制台在M1上工作。不过你可能要失望了,这个结尾并不长。

在PC上,UEFI固件会设置一个帧缓冲区,因此即使没有合适的显示驱动,也可以通过一个名为efifib的驱动来正常运行Linux。苹果芯片Mac的运行方式与之相同:iBoot会设置一个帧缓冲区供操作系统使用。我们需要做的就是使用通用的simplefb驱动,无需任何改动就能运行良好。我们只需在文档中记录一些设备树绑定方面的改动,因为虽然代码支持,但文档中并没有。

于是,在所有工作之后,只需在设备树中添加几行,就能将黑屏变成这样:

现在,m1n1能够完美地处理一切,获取iBoot提供的帧缓冲区的信息(宽度、高度、像素格式、步长和基址),并放到设备树中,供Linux使用。

当然,这只是一个固件提供的帧缓冲区。由于它并不是正常的显示驱动,所以还不能改变分辨率、处理显示热插拔,甚至也不能让显示器休眠。对于开发和演示来说足够了,但我们还需要编写一个合适的显示控制器。