原文链接 ARM64 Boot Camp: ARM64EC and ARM64X Explained
关于ARM64EC和ARM64X的十个简要事实的中文翻译:
- ARM64EC 不是新的指令集,它使用与Windows 10 for ARM、Google Pixel Android手机或Apple Silicon Macbook相同的64位ARMv8指令集。它是一种替代的ABI(应用程序二进制接口),用于ARM64,提供与为64位x64编译的非本机二进制文件(即“外来二进制文件”)的互操作性。
- ARM64EC 编译的代码以“仿真兼容”的方式编译,因此得名ARM64EC。ARM64EC代码可以轻松调用仿真的x64代码,反之亦然,无需任何源代码更改,因为所有繁重的工作都由编译器、链接器、C运行时和操作系统无缝完成。这与传统机制(如PInvoke或JNI)形成对比,后者显式要求源代码修改以实现互操作性。
- ARM64X 是标准Windows PE(便携式可执行文件)文件格式的ARM64扩展,允许ARM64代码和仿真的Intel x64代码在同一二进制文件中互操作,这与“二选一”的胖二进制方法不同。这被称为混合二进制文件。ARM64X格式向后兼容旧操作系统,如Windows 10 for ARM和旧的调试器和开发工具,尽管互操作性扩展将被忽略,只有ARM64代码字节将被暴露。
- 几乎每个在Windows 11 on ARM中发布的64位二进制文件都是作为ARM64X构建的,允许它们被经典的ARM64应用程序和仿真的x64应用程序使用。
- ARM64EC 函数编译为ARM64字节码,导出一个名为快速前向序列(FFS)的Intel兼容入口点,其中包含x64代码字节的存根。FFS与GetProcAddress()兼容,允许旧的Intel应用程序和游戏进行热补丁NTDLL.DLL和其他系统二进制文件,完全不知道函数体实际上是ARM64。可以将ARM64EC函数视为具有ARM64主体和x64外壳的预编译x64函数。
- Visual Studio 2022 版本17.4及更高版本正式支持ARM64EC代码生成和ARM64X二进制文件的发出。早期版本的VS只有部分和不完整的支持——不要使用它们!与Visual Studio配对时,构建ARM64EC应用程序时应使用Windows SDK的22621版本。最好始终使用最新的Visual Studio和SDK,目前是Visual Studio 2022 17.8.5和Windows SDK构建26020。
- Windows 11 for ARM 内核维护一个每进程的“EC位图”,标记地址空间的每个4K页面是本机还是外来。每当EXE或DLL加载或卸载到进程中、地址空间分配或释放,或应用程序本身设置JIT缓冲区并希望指定它将为哪个架构进行JIT时,该位图会更新。可以在运行时探测进程的地址空间(使用Windows SDK公开的RtlIsEcCode()函数)以确定给定地址是外来还是本机。
- Windows 11 中的64位Intel x64仿真围绕ARM64X二进制文件和ARM64EC互操作性构建。这与Windows 10中32位Intel x86仿真实现的方式非常不同,后者由WOW64层调解互操作性。
- 使用Visual Studio中的新ARM64EC构建目标,您可以在几分钟内轻松将x64应用程序重新构建为ARM64EC。这要归功于ARM64EC和x64仿真器的紧密耦合所实现的增量移植,允许您移植单个函数并保留其他未移植的函数,同时在每一步都有一个可运行的工作应用程序。将大型应用程序的代码移植到新架构传统上是一个“全有或全无”的过程,需要数周或数月的努力才能使新移植的应用程序正确启动和运行。在某些情况下,如我在20世纪90年代参与Microsoft Office移植到PowerPC时,端到端移植花费了超过2年,最终作为Microsoft Office 98发布。
- ARM64EC 使用一个名为软内在函数(softintrins)的库,该库由SDK的softintrin.h头文件和softintrin.lib静态库实现(也主要由我实现)。软内在函数允许包含Intel内在函数的旧C/C++源代码与本机ARM64编译器一起编译,甚至支持如__cpuid()等Intel内在函数,以及大约500个SSE内在函数——这使得增量移植成为可能。一段源代码,无论是编译为x64并仿真,还是编译为ARM64EC,在运行时的行为都将相同,因为它的部分内容被逐步移植。
深入探讨
作为微软的工程师之一,我和我的同事Pedro、Pavel以及其他几位一起帮助设计和实现了ARM64EC。我可以从第一视角向你介绍ARM64EC是什么,作为开发人员如何以及何时使用它,我们为什么选择不同于以往仿真实现的设计路径,以及哪些未解决的错误仍然让我夜不能寐。
让我们倒回几十年。在Windows 2.0或3.1的16位时代,Windows的分发非常简单:操作系统安装在名为C:\Windows的目录中,大多数用户模式系统组件(“系统DLL”)位于子目录C:\Windows\System中。当CPU和操作系统仅支持一种单一的ISA(即16位8086)时,这种方法效果很好。但当你的CPU支持一些新的指令集和执行模式(例如Intel 80386引入的32位保护模式)并且你想发布一个支持16位和32位Intel二进制文件的操作系统(如Windows NT)时,会发生什么呢?
当操作系统被移植到新架构(如32位x86、64位x64或ARM64)并需要处理多种指令集和外来二进制文件时,有几种选择:
- 什么都不做。我们已经知道Windows RT没有外来二进制文件支持,只提供了一套有限的本地ARM内置应用程序,并要求第三方开发人员将他们的应用程序移植到ARM上才能在Windows RT上运行。当没有足够的开发人员参与(如RT的情况),操作系统最终对付费消费者来说价值不大,最终消亡。
- 仅用户模式仿真。Linux用户熟悉通过QEMU(现在甚至包括Rosetta 2)的用户模式仿真。仿真允许像ARM64版的Linux运行为ARM32或x86编译的ELF二进制文件。QEMU或Rosetta既充当二进制加载器,又充当虚拟CPU,仿真该外来二进制文件。系统调用被“编组”或“转换”到本地主机操作系统,因为内核模式和内核驱动程序本身是本地的而不是仿真的。这种方法是我测试QEMU与Rosetta和微软仿真器的一种方式——通过编写小的x86或x64测试二进制文件并在QEMU的仿真下运行它们,以查看其准确性。
- 胖二进制文件 + 用户模式仿真。这是苹果每次转换CPU架构时的方法——从68K到PowerPC,从PowerPC到Intel x86/x64,从Intel x86/x64到ARM64。他们的“胖二进制”格式包含多个“切片”代码,每个切片包含不同ISA(如PowerPC、x86或ARM64)的字节码。大多数操作系统本身都编译为胖二进制文件。当启动非本地应用程序时,操作系统(在他们的情况下是macOS)将调用仿真器(68020仿真器、Rosetta或Rosetta 2)来运行每个胖二进制文件的外来切片。顾名思义,胖二进制文件比纯本地二进制文件更大,因为它们包含两个甚至三个代码切片。这种方法增加了整个操作系统的磁盘占用,并使应用程序的分发也变得更大。胖二进制文件(或苹果称之为“通用二进制文件”)的一个优点是最终用户只看到一个二进制文件,不必担心下载哪个架构的二进制文件。
- 多个二进制文件 + 用户模式仿真。这是微软自Windows NT时代以来30多年的方法。微软不使用胖二进制文件;相反,它将不同架构的二进制文件分开放置在不同的子目录中。即,微软的方法是将同一二进制文件的多个变体(每个不同ISA的一个变体)放入不同的子目录中。当Windows 10 for ARM在2018年发布时,它包含了不少于3个大多数操作系统二进制文件的副本——一个目录集用于本地ARM64,一个用于32位ARM32/Thumb2,一个用于32位x86!
历史背景
让我们更深入地了解微软过去对外来二进制文件支持的实现。…
由于某种原因,微软选择将System32子目录作为本地系统二进制文件的固定位置。因此,在64位Windows XP、64位Windows 7,直到今天的64位Windows 11中,32位二进制文件被移动到新的C:\Windows\SysWOW64目录,而新的本地64位系统二进制文件被放置在现有的C:\Windows\System32中。
明白了吗?WOW64 == 32位二进制文件,System32 == 64位二进制文件!对于Windows 10和11 on ARM,还有第三个子目录C:\Windows\SysArm32,顾名思义,存放32位ARM/Thumb2系统二进制文件。而且,C:\Windows\SysWOW64中的32位x86二进制文件在Intel版和ARM64版的Windows 11中是相同的,因为32位x86在两者中都被认为是“外来的”。
你可以很容易地看到这一点。所有Windows 11版本的任务管理器中都有一个新列,称为“架构”,不仅显示给定运行进程的“位数”(32位或64位),还显示其ISA(指令集架构)。如果你在AMD Ryzen或Intel Core i7系统上运行,位数和ISA之间的区别是无关紧要的,因为你要么以32位x86进程运行,要么以64位x64进程运行;没有其他组合。例如,在我的AMD 5950X机器上,如果我打开任务管理器并转到“详细信息”选项卡,你可以看到我有几个命令行提示符(CMD.EXE)实例作为32位进程运行,还有几个作为64位进程运行:
即使在没有“架构”列的XP或Windows 7中,你也可以根据“图像路径名”列(如上所述的CMD.EXE)判断二进制文件所在的目录路径。32位的CMD.EXE实例从C:\Windows\SysWOW64目录启动,而本地64位版本从C:\Windows\System32启动,如我上面所述。
巧妙启动新进程/machine
有一种巧妙的方法可以在存在多个二进制文件变体时启动特定架构的命令提示符。Windows的START命令用于启动新进程,有一个/MACHINE选项可以强制新进程使用特定的架构:
注意,它接受“x86”、“amd64”、“arm”和“arm64”,而没有“arm64ec”,因为那不是一个单独的架构!
专业提示:微软在其各种工具中对架构的命名往往不一致,使用“amd64”和“x64”可以互换,“arm”、“ARM32”和“Thumb2”也可以互换。所以只需理解amd64或(AMD64)== x64,arm(或ARM)== ARM32(在Windows上实际上是Thumb2)。明白了吗?:-)
那么,如果你尝试在ARM64设备上启动CMD.EXE的4种不同架构会是什么样子呢?让我们试试吧!在我的三星Pro 360上使用Windows 11 SV2 Nickel,我使用4种不同的/MACHINE选项启动了4个CMD.EXE实例。4个命令行窗口打开后看起来是一样的,所以让我们看看任务管理器显示了什么:
正如预期的那样,本地ARM64实例从C:\Windows\System32启动,仿真的x86实例从C:\Windows\SysWOW64启动,32位ARM实例从C:\Windows\SysArm32启动。
但看看所谓的x64实例,它显示为“ARM64(x64兼容)”,并声称与本地ARM64版本是相同的二进制文件。这显然不是添加另一个新子目录以包含额外二进制文件的通常模式。在Windows 11中,你不会找到例如C:\Windows\SysAmd64子目录。为什么会这样呢?…
显然,我们解决了这个问题,如今即使是ARM64上的Microsoft Office也是使用ARM64EC构建的,以便与为Intel x64编译的旧Office插件互操作。你可以在任务管理器中轻松验证,所有Office应用程序显示为“ARM64(x64兼容)”而不仅仅是“ARM64”:
那么,这是否意味着ARM64上的CMD.EXE和Microsoft Office不是本地ARM64?它们是仿真的x64吗?让我们回到大约2019年中期...
ARM64EC的诞生
如果WOW64不可行,文件重定向不可行,胖二进制文件也不可行,那么还剩下什么呢?我们已经知道这并不是一个完全未解决的问题,因为用户模式QEMU以及Wine已经存在于Linux上,用于运行为其他CPU架构和/或Windows编写的非本地用户模式应用程序。“非本地用户模式应用程序”是这些用例与Windows中x64仿真的关键相似之处。
我已经在原型设计一个名为xtabase的用户模式64位x64解释器,试图模拟一个64位用户模式仿真器。我走了一条类似于用户模式QEMU的路径:我将x64解释器编译为本地ARM64二进制文件,然后使用它手动加载并修复一个x64测试二进制文件,然后从二进制文件的入口点开始解释为x64。Windows操作系统完全不知道发生了什么,因为仿真器完成了所有工作。这对于运行超级简单的玩具二进制文件非常有用,因为Visual Studio允许你将单个C函数编译并构建为独立的二进制文件。如果你有像8皇后基准测试这样不需要输入并且只返回单个整数结果的简单测试代码,这种方法非常完美,使我能够在几个月内拥有一个工作正常的x64解释器,能够运行小型测试函数以验证正确性。
但是,当那个x64二进制文件调用printf()、malloc()、GetTickCount()或其他需要在另一个二进制文件(如UCRTBASE.DLL或KERNEL32.DLL)中找到的运行时函数时会发生什么呢?我使用了函数调用编组的常见技巧,这在用户模式QEMU或任何托管代码(如.NET或Java)调用本地系统代码时也会使用。xtabase获取函数调用参数(或在GetTickCount的情况下,没有参数),然后对目标函数进行本地函数调用。当本地函数返回时,返回值被放回仿真状态,仿真在调用后的点继续。
要以这种方式仿真一个相对简单的Windows“hello world” x64二进制文件,只需要编组大约40个C运行时和Win32函数:显然有对printf()的调用,如果你将测试EXE的入口点直接指向main()函数本身,那么是的,你只需要编组printf()调用。要运行更复杂的东西或基准测试,你可能还需要编组malloc()、free()、GetTickCount()、fopen()、fread和其他一些常见的API。通过这种方式,我能够引导运行小型基准测试,这些基准测试实际上读取时钟、计算性能并将输出显示到屏幕上。
要真正能够运行未修改的“hello world”,你还需要编组C运行时启动代码在初始化过程中调用的所有系统调用,最终调用main()函数。然后还有C运行时关闭代码,当main()返回时隐式调用——想想exit()函数和一堆文件关闭操作。所以总共有大约40个函数。这基本上就是Wine在Linux上所做的——多年来,他们只是将越来越多的Win32函数编组到Linux系统调用中,以至于今天Wine能够运行许多Windows 10应用程序,甚至依赖于DirectX的应用程序。
当WOW64层必须在32位用户模式和64位系统调用之间编组参数时,也会进行函数调用编组。指针等参数需要从类型__ptr32扩展到类型__ptr64,类似地,“指针大小的整数”如uintptr_t和intptr_t参数需要进行零扩展和符号扩展。任何编写过Windows消息循环的人都熟悉SendMessage()函数,但有多少人意识到lParam的宽度不是固定的?lParam参数的LPARAM类型实际上是一个指针大小的整数,因此在从32位模式传递到64位模式时必须进行扩展,如你在Windows SDK中的定义所见:
另一个在WOW64中仿真32位的方面是必须使用两个堆栈:32位x86堆栈,其中所有内容都是4字节对齐的,指针是4字节宽的;以及64位本地ARM64堆栈(按硬件设计)是16字节对齐的,指针是8字节宽的。32位x86代码和本地ARM64代码不能在内存中共享数据,甚至不能在同一个堆栈上共享数据,因为数据布局不同。所有数据结构也必须进行编组,而不仅仅是指向它们的指针。
在64位系统上仿真32位因此非常混乱(这也是为什么花了很多年才使初始的32位x86仿真在ARM64上运行良好的原因)。但我的目标不是仿真x86,而是建模x64。64位应用程序有其自身的差异和调用约定,如下表所示,比较了32位x86、64位x64和64位ARM64,你可以看到有很大的不同: