背景
目前业界云平台一般会提供 Windows 虚拟机和 Linux 虚拟机,用户可以在这些虚拟机上部署符合 Windows生态和 Linux 生态的服务和应用。MacOS 作为 Apple 生态的基本操作系统,MacOS 虚拟化的实现将会为使用 Apple 生态的用户带来很大的便利,如直接在 MacOS 的虚拟机上进行测试、构建iOS 应用,甚至使用 MacOS 虚拟机作为办公环境等。
目前社区对于 Intel x86 Mac mini 平台的 MacOS虚拟化方案已经比较成熟,随着 2020年 Apple 推出自研的 ARM64 M1芯片,Mac mini 增加了对 ARM64 平台的支持,后续 Apple 将逐渐从 Intel x86 芯片转向自研的 ARM64 芯片。相对于 Intel x86 平台,社区对 ARM64 平台的 MacOS 虚拟化方案还在探索和起步阶段,于是字节跳动 STE 团队和 App Infra 团队率先进行了深入的研究,本文将与大家分享针对采用 ARM64 M1 芯片的 Mac mini M1 机型虚拟化方案的探索和实现。
方案介绍
什么是 Mac mimi 虚拟化
Mac mini 虚拟化是指在一台 Mac mini 物理机上模拟出多个互相隔离的 Mac mini 虚拟机,使得每个虚拟机用户都认为自己在一台真正的物理机上以独占的方式运行。
整个方案中,我们采用了云平台常见的 Linux + KVM(Kernel-based Virtual Machine)+ QEMU(Quick EMUlator) 这样全开源的虚拟化软件栈,以方便把控和实现对 Mac mini M1 机型(包括 CPU / MEM /片上设备/外设等)的全方位模拟。在 Mac mini M1 的虚拟化实现中,我们基于 QEMU 既有的机型框架实现了对 Mac mini M1 的软件模拟(TCG, Tiny Code Generator)支持,又通过 KVM 为Mac mini M1 提供了ARM64平台硬件虚拟化能力的支持。
Linux
Asahilinux 对 M1 机型上的硬件设备做了足够多的逆向工程分析,并根据推出来的硬件逻辑来尝试实现linux下的各设备驱动,目前已实现了对 M1 机型的基本支持,其中对 M1 GPU的支持也在不断完善中。Asahilinux这部分工作也在不断的合入Linux upstream。
我们采用Asahilinux作为运行在M1裸机上的操作系统。依赖Asahilinux,我们可以获得M1 的 linux 运行时物理机环境,并以此为基础逐步构建和实现M1虚拟化所需的虚拟化软件栈。
QEMU
QEMU 作为机型/系统模拟的常用软件,构造了机型模拟的基本框架,并在此基础上提供了不同平台上很多具体机型的模拟实现,比如 x86 平台的 piix 和 Q35 机型,ARM 的 virt 和 raspi2 等。在 M1 机型的虚拟化中我们需要基于 ARM64 通用机型,构造一个新的 M1 机型,并集成到 QEMU 提供的机型模拟框架中。
QEMU 提供的基本模拟方式是纯软件的,即对于每一条虚拟机内部执行的指令都需要退出到 QEMU 的上下文来翻译执行(TCG),该方式性能很低。KVM 可以帮助我们解决 QEMU 纯软件模拟带来的低性能问题。
KVM
KVM 作为 Linux 下支持虚拟化的内核模块,将虚拟化场景下的硬件加速功能通过 ioctl 的方式导出给QEMU使用。在 KVM 的支持下,虚拟机内部的常用指令将不再需要退出到QEMU来模拟执行,而是在虚拟机的 CPU 上下文中直接执行,只有个别特权指令才需要退出到物理机上,由物理机来具体执行。对于虚拟机的内存访问,也不再需要 QEMU 进行页表翻译,而是直接依赖硬件实现的两级页表翻译机制(虚拟机内部页表实现 Guest Virtual Address向Guest Physical Address的转化,使用stage2 page table 实现 Guest Physical Address向Host Physical Address的转化),从而实现内存的快速访问。
M1机型的模拟
了解了基本的虚拟化软件栈后,我们开始考虑如何模拟一台真正的 Mac mini M1。那么,模拟 M1 机型包括模拟哪些内容呢?
M1 VCPU
QEMU 中使用线程来模拟 VCPU,即每一个 VCPU 对应一个单独的 QEMU 线程;使用必要的数据结构记录 VCPU 相关的信息,如寄存器/运行状态等。区别于其他机型的 VCPU,M1 需要增加的特有支持如下:1)多个 M1 特有寄存器和对应的读写逻辑;2)对 Apple PMGR(电源管理模块)的模拟。M1 通过 PMGR 来实现多核(SMP,symmetric multiprocessing)的唤醒和管理;
3)Cluster 内和 Cluster 间的 Fastipi 通信机制;
M1 内存布局
M1 的内存布局,是指将VM的整个地址空间按照设备和用途进行合理的划分,如 Flash device、CPU 相关的固件、中断控制器、Spec 设备地址、MMIO 和 PCI 地址范围以及虚拟内存等。在 QEMU 层设置 M1 机型的内存布局需要注意以下几个问题:1)注意 CPU 地址域和设备地址域的关系;2)System Memory 范围不能跟 DTB(Device Tree Blob) 中各设备的物理地址空间冲突;3)需要连续的足够大的地址范围来映射 System Memory;
System Memory 作为虚拟机的主存,在启动初期会被用来存放内核镜像、设备树以及Ramdisk等,这些内容的加载地址也需要额外配置,注意不能存在交叉,以避免内存中内容互相覆盖。
M1 设备模拟
区别于 M1 物理机,M1 虚拟机的存储和网络主要采用虚拟化场景下常见的 virtio-pci 设备,如 virtio-blk / virtio-net 设备等。我们需要在模拟的 M1 机型上插上 PCIE 的线,其下接各种 PCI / PCIE 设备。另外,为了让设备的中断能够顺利分配、触发并递送到目的 VCPU 中,还需要模拟 M1 的中断控制器 AIC。
AIC 是 M1 Apple Interrupt Controller 的简称, 主要负责中断掩码/中断状态设置、接受/递送中断等。对 AIC 的模拟主要是对上述的功能的模拟,可以通过 Asahilinux 的 AIC 驱动部分的代码逻辑来推断出 AIC 具体地址空间的含义,从而实现 AIC 的模拟。对 AIC 的模拟中我们使用了 MMIO(Memory-Mapped IO) 空间来实现对 AIC 内部相关硬件地址的访问逻辑。
对 M1 新插上的 PCIE 总线的模拟基于 QEMU 提供的通用 PCIE 线——gpex-pcihost 来实现,在通用的 gpex-pcihost 模型的基础上,我们需要根据 M1 的 DTB 文件来制定 M1 PCIE 总线配置空间和bar空间,以及其下 PCI 设备的 PCI 配置空间和 Bar 地址空间范围。
整体的中断设计如下:
将模拟的 M1 PCIE (gpex-pcihost )总线的 4 个 gpio_out pin 依次连接到 AIC 的 gpio-in pin 上去。gpex-pcihost 通过该连线传递中断给AIC;
AIC 有 cpu_num 个 gpio_out pin ,这些引脚分别连接到各个 CPU 上,AIC 通过该连线递送中断给 CPU。
至此,我们已经在 QEMU 层面实现了对 M1 的 CPU、PMGR、AIC、PCIE 总线等设备的基本模拟,可以通过 QEMU 命令在 PCIE 线上插上所需的 PCI 设备,如 virtio-blk、virtio-net 等,来尝试启动 MacOS 虚拟机了。
虚拟化下的MacOS和启动
既然已经完成了对 M1 虚拟机所需要的基本组件的模拟,接下来我们考虑如何在模拟的 M1 机型上启动真正的 MacOS 。
预启动阶段
MacOS 的启动流程是怎样的呢?MacOS 在 M1 物理机上的启动流程如下(更多详见 M1 启动过程:
- 机器上电,由 Boot ROM 验证并加载位于 Flash 中的 LLB(Low Level Bootloader)
- LLB 设置安全策略、解密/认证并加载位于 preboot volume 中的 iBoot 文件
- iBoot 根据 DTB 对 M1 固件和设备做早期的初始化,验证并加载 KernelCache(KernelCache 是 MacOS 启动镜像,类似于 linux 的 Image.gz),更新并加载 DTB
- 进入 MacOS 内核启动流程。
其中无论是 LLB 还是 iBoot 都是闭源的,这为我们完整的模拟 M1 启动带来了难处。为了能成功的在 QEMU / KVM 虚拟化场景下启动 M1,我们对 MacOS 的启动过程做了简化,割舍掉 LLB 和 iBoot 阶段,直接由 QEMU 来完成早期设备的初始化(如 CPU 状态等)、KernelCache 文件的解析与加载、DTB 的更新与加载等。
在 QEMU 完成早期初始化和预加载后,虚拟机启动时刻的 System Memory 地址空间布局状态如下:其中,各文件的含义如下:
- KernelCache:包含了 XNU 内核和 kext 内核扩展,是 MacOS 的内核镜像
- Ramdisk:HFS 格式的 rootfs,可作为 MacOS 的启动盘使用,非必需(当使用 virtio-blk 盘作为启动盘时,可取消 Ramdisk 的使用)
- DTB:M1 的设备树,MacOS 内核需要通过 DTB 来获取到 M1 的硬件信息,QEMU 模拟的 M1 设备信息(如中断路由和设备地址信息等)要和 DTB 中维护的信息一致
- xnu_arm64_boot_args:Macos 内核的启动参数
随后 QEMU 将 Primary VCPU 的 pc 寄存器设置为 KernelCache 中的 start 函数地址,开始VCPU 的运行。
启动流程
MacOS 在 QEMU / KVM 虚拟化场景下整体的启动流程如图,大致分如下四个阶段:
1. QEMU 阶段
该阶段在 QEMU 中实现,主要是为 MacOS 的运行做准备,具体包括 VCPU 线程的创建,各设备状态的初始化,System Memory 的加载映射(如KernelCache、 Ramdisk、DTB),bootargs 的创建和 DTB 数据的更新等,随后将 VCPU0 的 pc 寄存器指向 MacOS XNU 内核的_start 函数,VCPU0 开始执行。
2. 早期初始化阶段
在 MacOS 启动早期的汇编代码阶段,_start 函数设置了内核启动早期的中断/异常向量表和页表等,并对内核参数进行解析,配置 MMU 和 vbar 等。
3. Kernel 阶段
在 arm_init 和 kernel_bootstrap 中, 主要是第一个内核线程启动前的准备工作,如进行 KernelCache 解析、DTB 转化,console 设置,per cpu data 设置和 process 初始化、系统内存的初始化以及内核各个子系统的启动。
kernel_bootstrap_thread/bsd 中,主要是启动其他的系统维护线程构筑 MacOS 的运行时环境,如初始化 I/O Kit 框架,初始化 BSD 子系统。其间还会根据 DTB 的配置来设置 SMP,唤醒其他 VCPU;其中, I/O Kit 是MacOS XNU 内核为设备驱动程序提供的完整的运行时环境,用户可以基于 I/O Kit 提供的面向对象能力来快速高效地编写自己的驱动程序。
在 bsdinit 中,初始化 BSD 核心子系统, 挂载 rootfs 并启动 /sbin/launched。
4. 用户态服务和进程
/sbin/launched 进程的 pid 为1,为第一个用户态进程,它根据预定的安排或者实际的需要加载位于 /System/Library/LaunchAgents 和 /System/Library/LaunchDeamons 下的其他应用程序或作业,包括守护程序如 atd、crond、inetd 等,以及代理程序如 GUI shell、 Terminal shell 等。
至此 MacOS M1 基本启动已经完成。
虚拟化下完整的MacOS 软件栈
QEMU/KVM 虚拟化场景下的 MacOS 整体架构图如下,从下到上依次是M1物理机、支持 M1 各类型设备的 Asahilinux 内核、提供硬件加速功能的内核模块 KVM、实现 M1 机型模拟的用户态 QEMU,运行在 QEMU 上的 M1 Guest OS。
其中,M1 Guest OS 在 XNU 内核的基础上会加载多个 Kext 以实现完整的 MacOS 功能;既支持 APFS 格式的根文件系统,也支持 HFS 格式的 Ramdisk 根文件系统;dyld 类似于 linux 上的 ld,负责加载和链接应用程序,dyld_shared_cache 提供了系统中各进程共享的基础库缓存,有效地降低了各进程通用库的内存占用率。
后续规划和展望
目前我们已经完成了 QEMU / KVM 虚拟化场景下 MacOS M1 的基本启动,后续需要有更多的优化从功能完备性、性能以及兼容性的角度来展开。
更完善的MacOS 运行时环境
目前我们的方案在 QEMU 层实现了 M1 基本设备的模拟、在 KVM 层实现了硬件加速功能、在 MacOS 中实现了部分系统服务的启动,然而整个方案与一台真正的 MacOS 还有一定的距离,如默认启动的数百个系统服务、友好的 GUI 界面、更完备的设备支持以及更好的用户支持度等等,后续将持续优化。
兼容性
在整个虚拟化方案中为了实现 MacOS 的启动,我们对 KernelCache 里的 XNU 内核和 Kext 部分进行了热 Patch。后续需要实现合理的热 patch 的工具来完成不同的 MacOS 版本热 Patch 的高效定制。
性能问题
模拟 M1 机型时,像 PMGR 和 AIC 这样的设备,我们目前是直接在 QEMU 里进行模拟的,这会使得虚拟机对 PMGR 和 AIC 的操作,都需要退回到 QEMU 中来进行具体行为的模拟,后续可考虑将这些组件集成到 KVM 中,减少用户态和内核的切换,减少虚拟化开销,提高 VM 的性能。