Windows Internals学习:WoW64 文件系统重定向(File System Redirection)详解
1. 为什么需要文件系统重定向
先想一个问题:64 位 Windows 系统里,\Windows\System32 这个目录名字,从 32 位时代一直沿用到现在,里面放的是原生 64 位的系统文件(DLL、EXE 等)。这是为了兼容性考虑——如果换个目录名,很多写死了路径的老程序、脚本、注册表项全都会失效,迁移成本太高。
但这就带来一个矛盾:一个 32 位程序运行在 64 位系统上时,如果它去访问 C:\Windows\System32\xxx.dll,它真正需要的其实是32 位版本的 xxx.dll(因为 32 位代码没法直接调用 64 位的 DLL,位数不匹配,加载了也用不了),可是这个路径下放的却是 64 位版本。
WoW64 解决这个矛盾的办法就是:在系统调用层面做拦截和路径替换。因为 WoW64 本身就拦截了所有从 32 位程序发出的系统调用,所以它可以顺手把这些调用里涉及路径的参数,在真正传给内核之前,替换成对应的"32 位专属路径"。这个过程叫做文件系统重定向(File System Redirection)。
整个逻辑可以用下面这张图来理解:
2. 重定向规则表:Table 8-14 的含义
下表列出了几组会被重定向的典型路径。注意"Architecture(架构)"这一列指的是目标进程(也就是发起调用的那个 32 位/ARM32 进程)的架构类型,不同架构对应不同的重定向目标:
| 原始路径 | 目标进程架构 | 实际重定向到 |
|---|---|---|
c:\windows\system32 |
X86 运行在 AMD64 主机上 | C:\Windows\SysWow64 |
c:\windows\system32 |
X86 运行在 ARM64 主机上 | C:\Windows\SyChpe32(如果目标文件在 SyChpe32 中不存在,则退回到 C:\Windows\SysWow64) |
c:\windows\system32 |
ARM32 | C:\Windows\SysArm32 |
%ProgramFiles% |
Native(原生,即 64 位程序自己) | C:\Program Files |
%ProgramFiles% |
X86 | C:\Program Files (x86) |
%ProgramFiles% |
ARM32 | C:\Program Files (Arm) |
%CommonProgramFiles% |
Native | C:\Program Files\Common Files |
%CommonProgramFiles% |
X86 | C:\Program Files (x86) |
%CommonProgramFiles% |
ARM32 | C:\Program Files (Arm)\Common Files |
C:\Windows\regedit.exe |
X86 | C:\Windows\SysWow64\regedit.exe |
C:\Windows\regedit.exe |
ARM32 | C:\Windows\SysArm32\regedit.exe |
C:\Windows\LastGood\System32 |
X86 | C:\Windows\LastGood\SysWow64 |
C:\Windows\LastGood\System32 |
ARM32 | C:\Windows\LastGood\SysArm32 |
从这张表可以看出两类重定向机制:
- 目录路径重定向:像
system32 → SysWow64这种,是把具体的目录整体换掉; - 环境变量重定向:像
%ProgramFiles%这种,是系统环境变量本身在 32 位进程和 64 位进程里被设置成了不同的值。也就是说,一个 32 位程序去读%ProgramFiles%这个环境变量时,读到的字符串本身就已经是C:\Program Files (x86),而不是通过路径替换实现的——这是两种不同的实现思路,但效果都是"让 32 位程序自动去访问适合它的那份文件"。
有一点需要特别注意:SyChpe32这条规则只在"X86 程序运行在 ARM64 主机"这种特殊场景下才生效,这对应的是前面提到过的 CHPE(编译混合可移植可执行文件)技术,目的是让 x86 代码在 ARM 平台上跑得更快。而且它还带了一个"回退(fallback)"逻辑:如果 SyChpe32 目录下没有这个文件,才会退而求其次去 SysWow64 找。
3. 例外目录:哪些路径不会被重定向
出于兼容性和安全性的考虑,\Windows\System32 下有几个特殊的子目录,被排除在重定向机制之外——也就是说,32 位程序访问这几个目录时,实际访问的就是真正的(原生的)那个目录,不会被偷偷换成别的路径:
%windir%\system32\catroot和%windir%\system32\catroot2%windir%\system32\driverstore%windir%\system32\drivers\etc%windir%\system32\hostdriverstore%windir%\system32\logfiles%windir%\system32\spool
这些目录大多是存放驱动、安全目录、网络配置(比如drivers\etc里就有大名鼎鼎的hosts文件)这类跨位数共享的数据,如果被重定向了,32 位程序和 64 位程序看到的内容就会不一致,可能引发安全隐患或者功能异常,所以系统专门把它们排除在外。
4. 按线程控制重定向开关
WoW64 还提供了两个 API,允许开发者按线程(per-thread)粒度手动开启/关闭这套重定向机制:
Wow64DisableWow64FsRedirection:关闭当前线程的文件系统重定向Wow64RevertWow64FsRedirection:恢复(撤销上面那次关闭操作)
4.1 实现原理:TLS 槽位存开关状态
这个开关的实现方式很简单:把一个"启用/禁用"的标志值,存放在 **TLS(线程本地存储)的第 8 号索引(index 8)**里。WoW64 内部有一个叫 RedirectPath 的函数,每次要做路径重定向判断之前,都会先去查一下这个 TLS 槽位里的值,如果是"禁用"状态,就跳过重定向逻辑,直接使用原始路径。
可以理解成一个简单的全局开关变量,只不过这个"全局"是限定在"当前线程"范围内的,不会影响进程里的其他线程:
RedirectPath 的行为={执行路径重定向if TLS[8] = enabled不做任何改动,直接使用原路径if TLS[8] = disabled \text{RedirectPath 的行为} = \begin{cases} \text{执行路径重定向} & \text{if TLS[8] = enabled} \\ \text{不做任何改动,直接使用原路径} & \text{if TLS[8] = disabled} \end{cases} RedirectPath 的行为={执行路径重定向不做任何改动,直接使用原路径if TLS[8] = enabledif TLS[8] = disabled
4.2 为什么不推荐用这套开关机制
虽然这两个 API 用起来很直接,但书中特别提醒:这套机制存在一些隐患,主要体现在:
- 延迟加载的 DLL(delay-loaded DLL):这类 DLL 不是程序启动时就加载的,而是运行到某个函数第一次被调用时才临时加载。如果调用
Wow64DisableWow64FsRedirection之后、还没恢复之前,恰好触发了某个 DLL 的延迟加载,那么这次加载也会被"连带"影响,找不到该找的 32 位 DLL。 - 通过公共文件对话框(Common File Dialog)打开文件:这个对话框内部也依赖路径解析逻辑,同样会被这个全局禁用状态干扰。
- 国际化(internationalization)相关的问题:涉及语言资源文件加载时同样可能受影响。
根本原因在于:一旦调用了Wow64DisableWow64FsRedirection,重定向不仅对开发者自己写的那行代码失效,系统内部做任何"内部加载"操作时也会一并失效。这就导致有些原本只存在于 64 位目录下的文件,会因为重定向被关闭而"找不到",引发意料之外的加载失败。
也正因为如此,书中建议:更安全的做法是使用后面要讲的%SystemRoot%\Sysnative这种始终一致、显式指定的路径,而不是去开关这个全局标志位。这样可以做到"精确制导"——只对你想要访问 64 位资源的那一次调用生效,不会影响到其他代码路径。
5. Sysnative:给 32 位程序开的一条"直达"通道
前面讲到,32 位程序访问 System32 会被自动重定向到 SysWow64,这在大多数情况下是我们想要的行为。但如果某个 32 位程序恰恰就是想访问真正的 64 位系统文件呢(比如它自己有能力处理 64 位镜像,或者只是想读取/枚举 64 位系统目录下的内容)?
为此,系统专门开了一条"后门通道":\Windows\Sysnative。
这是一个虚拟目录——注意,它在磁盘上并不真实存在,你在资源管理器里翻遍 C:\Windows 也找不到一个叫 Sysnative 的实体文件夹。它只是 WoW64 重定向逻辑里的一个特殊标记路径:任何从 32 位程序发出的、访问这个路径的 I/O 请求,都会被自动豁免重定向,直接指向真正的 System32 目录。
也就是说:
32位程序访问 C:\Windows\System32\xxx → 实际被重定向访问 C:\Windows\SysWow64\xxx (常规行为)
32位程序访问 C:\Windows\Sysnative\xxx → 实际访问 C:\Windows\System32\xxx(真正的64位目录,不重定向)
这条路径只对 32 位(WoW64)进程有意义——64 位程序本来就不会被重定向,所以它访问 Sysnative 也没什么特殊效果,甚至这个虚拟路径对 64 位进程可能根本不可见。
6. 整体串联:一次路径解析的完整流程
把上面讲的几块拼起来,一个 32 位程序发起一次带路径参数的系统调用(比如 CreateFile)时,WoW64 内部大致会走这样一套判断流程:
7. 小结
文件系统重定向机制解决的核心矛盾是:同名的系统目录,在 32 位程序和 64 位程序眼里必须对应到不同的物理位置。WoW64 通过拦截系统调用中的路径参数,按照目标进程的架构(X86 on AMD64、X86 on ARM64、ARM32 等)来做对应的路径替换,同时用环境变量(如 %ProgramFiles%)在不同位数进程里取不同值的方式,实现了另一类重定向。
为了避免破坏一些跨位数共享的关键目录(驱动库、hosts 文件、打印后台处理等),系统显式列出了一批例外目录,不参与重定向。
对开发者而言,虽然系统提供了 Wow64DisableWow64FsRedirection / Wow64RevertWow64FsRedirection 这一对 API 用来按线程临时关闭重定向,但由于它是一个"全局生效"的开关,容易牵连到延迟加载 DLL、公共文件对话框、国际化资源加载等系统内部逻辑,因此更推荐使用 \Windows\Sysnative 这个虚拟直达路径,做到只在需要的那一次调用上精确豁免重定向,而不影响其他代码路径的正常行为。
AMD64 平台上的 x86 模拟(X86 Simulation on AMD64 Platforms)详解
1. 先搞清楚这一节在讲什么
前面几节讲的是 WoW64 的"管理层"——怎么给 32 位进程/线程准备好双份 PEB、TEB、栈,怎么做路径重定向。这些都是架构无关的通用逻辑。
而这一节要讲的,是架构相关的那一部分:具体到 AMD64(也就是 x86-64,我们平时说的 64 位 Intel/AMD 处理器)这个平台上,Simulator(二进制翻译器)具体是怎么把 CPU 从 64 位模式切换到 32 位模式去执行 32 位指令的。这个 Simulator 在 AMD64 平台上的实现,是一个叫 Wow64cpu.dll 的模块。
它的接口设计得非常精简,核心只干一件事:让 CPU 从当前的 64-bit long mode 切换到 32-bit compatibility mode 去跑 32 位代码,等 32 位代码需要陷入内核(系统调用或中断)时,再切回来。
2. 背景知识:为什么"切换 CPU 模式"是必要的
AMD64 架构的 CPU 有一个概念叫 long mode(长模式),也就是真正的 64 位运行模式。在这个模式下,CPU 还细分出两种子模式:
- 64-bit mode:标准的 64 位代码执行模式
- compatibility mode(兼容模式):CPU 虽然整体处于 long mode,但针对某个代码段,可以让它按照 32 位(甚至 16 位)的语义去执行指令、解析寄存器宽度等等
这个"到底按哪种模式解释当前指令",是由 CPU 当前使用的代码段(Code Segment,简称 CS)所对应的段描述符(Segment Descriptor)里的一个标志位决定的,不是靠一条普通指令临时告诉 CPU"接下来按 32 位来"。也就是说,模式切换本质上是"换了一个不同属性的代码段来执行代码",而不是修改某个寄存器的某个位那么简单。
这就引出了下文要讲的核心手段:call gate(调用门)。
3. 初始化阶段:决定用哪种系统调用方式
在 Simulator 进程初始化的函数里,第一件事是决定要不要启用 快速系统调用接口(fast system call interface)。这个决定依赖于系统里是否存在软件 MBEC(Mode Based Execute Control,基于模式的执行控制,这是一种和内存执行权限相关的 CPU/Hypervisor 特性,具体细节会在后面讨论 CPU 安全特性的章节展开,这里先不深入)。
简单理解:MBEC 涉及"64 位模式的页面和 32 位模式的页面是否可以有不同的可执行权限"这类安全强化机制,如果硬件/软件支持它,WoW64 就能用更快、更安全的系统调用路径;如果不支持,就退回到相对慢一点、但兼容性更好的路径。
4. BtCpuSimulate:模拟真正开始的入口
当 WoW64 核心(也就是我们前面讲的架构无关的那部分)决定要开始执行 32 位代码时,它会调用 Simulator 暴露出来的一个接口函数:BtCpuSimulate。这个函数就是整个模拟流程真正的起点,它按顺序做了下面这几件事:
4.1 构建 WoW64 栈帧
Simulator 会基于WoW64 核心提供的 32 位 CPU 上下文(也就是前面讲过的、存放在原生栈栈底 WoW64 CPU Area Information 里的那个 X86_NT5_CONTEXT 结构),在 32 位栈上构建出一个"栈帧"(stack frame)。
这个栈帧的作用,可以类比成:在 32 位代码即将开始跑之前,先把它需要看到的初始寄存器状态、返回地址之类的信息,按照 32 位调用约定的格式,摆放到 32 位栈的正确位置上,确保 32 位代码一启动就能看到一个"合法"的执行环境。
4.2 初始化 Turbo thunks 数组
接下来,Simulator 会初始化一个叫 Turbo thunks 数组的东西,专门用来快速分发(dispatch)系统调用。
这里的"thunk"是一个常见的系统编程术语,指的是一小段"胶水代码",用来做参数转换、模式切换之类的中转工作。“Turbo” 这个前缀暗示了它是为了性能优化而设计的一条快速路径——32 位程序发起系统调用时,不用每次都走一套完整、繁琐的通用流程,而是可以通过这个预先建好的数组,直接定位到对应的处理逻辑,减少开销。
4.3 设置 FS 段寄存器指向 32 位 TEB
Simulator 接着会把 FS 段寄存器设置为指向该线程的 32 位 TEB。
这一步至关重要:在 x86 编程模型里,FS 寄存器一直是用来快速访问 TEB 的(这是从 Win32 时代延续下来的约定,32 位代码里访问 fs:[0] 之类的写法就是在读 TEB/TIB 相关字段)。前面讲过,一个 WoW64 线程同时拥有 64 位 TEB 和 32 位 TEB,并且两者在内存里紧挨着。现在切换到 32 位模式执行代码之前,必须把 FS 指向 32 位那份 TEB,这样 32 位代码里所有依赖 FS 寄存器访问 TEB 的逻辑才能正常工作,不会读错内存。
4.4 设置调用门(call gate),指向 32 位段
最后一步是本节的核心机制:Simulator 会建立一个调用门(call gate),目标指向一个 32 位的代码段(通常这个段的选择子是 0x20)。
调用门是 x86/x86-64 架构里 GDT(全局描述符表)中的一种特殊描述符类型,它的作用是提供一种受控的、可以改变权限级别或执行模式的"跳转通道"。相比普通的 jmp 或 call 指令只是简单地跳到某个地址,通过调用门跳转时,CPU 会额外去检查这个门描述符里记录的"目标代码段"的属性——如果目标段被标记为 32 位段,CPU 就会自动把执行模式切换为 32-bit compatibility mode,这个切换是 CPU 硬件层面自动完成的,不需要软件再单独发一条"改模式"的指令。
准备好调用门之后,Simulator 会做两件事:
- 切换栈:把当前使用的栈从原生(64 位)栈切换到刚才准备好的 32 位(WoW64)栈;
- 发出一次远跳转(far jump),跳转目标就是最终的 32 位入口点。
第一次执行时,这个入口点被设置为 32 位版本的LdrInitializeThunk——这是 loader(加载器)在线程刚开始跑用户代码之前,要执行的一个初始化函数,负责完成 32 位那一侧的加载器初始化工作(比如通知加载器新线程已经就绪等等)。
5. CPU 硬件层面到底发生了什么
上面讲的是 Simulator 软件层面做的准备工作,现在从 CPU 硬件视角,把"远跳转触发模式切换"这件事讲清楚。
当 CPU 执行这条 far jump 指令、且目标是一个调用门时,CPU 会:
- 从调用门描述符里读出真正的目标代码段选择子和偏移;
- 检查这个目标代码段的描述符,发现它被标记为32 位代码段;
- 由于当前 CPU 处于 long mode,但目标段是 32 位属性,CPU 就会把当前的执行模式切换成 compatibility mode(32 位兼容模式);
- 从这一刻起,CPU 开始按照 32 位语义去解码和执行后续指令——寄存器默认宽度变成 32 位、寻址模式变成 32 位寻址等等。
这里有一个重要的边界条件需要强调:代码执行一旦进入 32 位模式,只有在发生中断(interrupt)或者系统调用被分发(system call dispatch)的时候,才会退出 32 位模式,重新切回 64 位模式。也就是说,正常的 32 位用户态代码执行过程中,CPU 会一直"老老实实"地按 32 位模式跑,直到它自己想要陷入内核(比如调用了某个 Win32 API,内部最终触发了系统调用),这时才会有一次模式切换的"逃逸"发生。
用一张图总结这套切换流程:
6. 特殊情况:第一次切换用的是 IRET,不是 far call
这一节末尾有个很容易被忽略但很关键的细节:第一次切换到 32 位模式时,Simulator 用的并不是"远跳转(far jump)"这类跳转指令,而是 IRET(Interrupt Return,中断返回)指令。
为什么第一次要特殊处理?原因在于寄存器初始化的完整性问题:
- 普通的远跳转/远调用,只会改变 CS(代码段)和 EIP(指令指针) 这两样东西,CPU 里其他通用寄存器(包括那些"易失性寄存器",即调用约定里不保证跨调用保留的寄存器)以及 EFLAGS(标志寄存器) 都不会被这类跳转指令自动改动或初始化。
- 而
IRET指令本身的语义是"从中断/异常处理程序返回",它的设计目的就是要从栈上一次性恢复完整的执行上下文,包括 CS、EIP,以及 EFLAGS 寄存器(这是普通远跳转做不到的)。
因为这是线程第一次进入 32 位模式,此刻 32 位那一侧的寄存器状态是"空白"的(还没有任何合法值),必须要有一种手段能把 全部通用寄存器 + EFLAGS 一次性、完整地设置成一个已知的初始值,这正是IRET能做而普通远跳转做不到的事情。所以只有第一次切换用IRET,之后线程在 32 位/64 位之间反复切换时(比如每次系统调用返回后要回到 32 位代码继续跑),因为寄存器状态已经是"活的、有意义的"了,就可以用普通的远跳转/远调用方式来完成切换,不需要再走IRET这条更"重"的路径。
用一个简单的对比表格来说明两者的差异:
| 切换方式 | 能恢复的内容 | 使用场景 |
|---|---|---|
| far jump / far call | CS、EIP(以及少量段相关信息) | 线程已经运行过一段时间,寄存器状态是连续、有效的,只需要切模式和切段 |
| IRET | CS、EIP、EFLAGS,以及栈上保存的完整上下文 | 线程第一次进入32位模式,所有寄存器(含EFLAGS)都需要从零被完整初始化 |
7. 小结
AMD64 平台上的 x86 Simulator(Wow64cpu.dll)要解决的核心问题是:如何让一颗运行在 64 位 long mode 下的 CPU,临时切换到 32 位兼容模式去执行 32 位指令,并且能在需要的时候(系统调用或中断发生时)安全地切回来。
它的做法依赖 x86-64 架构提供的硬件机制——调用门(call gate):通过预先在描述符表里配置好一个指向 32 位代码段的调用门(通常选择子是 0x20),再执行一条远跳转指令跳向这个调用门,CPU 就会在硬件层面自动完成模式切换,软件不需要手动"翻转某个开关位"。
在真正跳转之前,Simulator 还要做好一系列配套准备:根据 WoW64 核心提供的 32 位 CPU 上下文构建好 32 位栈帧、初始化用于快速分发系统调用的 Turbo thunks 数组、把 FS 段寄存器指向 32 位 TEB(保证 32 位代码里访问 TEB 的逻辑不出错),最后才切栈、跳转。
而线程第一次进入 32 位模式时,因为所有寄存器(尤其是 EFLAGS)都还没有被初始化,普通的远跳转无法胜任这个"完整初始化"的任务,所以这一次特殊情况下用的是 IRET 指令,它能一次性从栈上恢复出完整的执行上下文;后续再切换时,寄存器状态已经是连续、有效的了,才会改用效率更高的远跳转/远调用方式。
WoW64 注册表重定向(Registry Redirection)详解
一、问题的根源:32位和64位组件抢同一个注册表位置
应用程序和各种系统组件,通常都会把自己的配置信息写进注册表。而这些配置信息最常见的写入时机,就是组件在安装过程中被注册的那一刻。
问题出在这里:如果同一个组件,既发布了一个 32 位版本的二进制文件,又发布了一个 64 位版本的二进制文件,并且这两个版本都被安装、都执行了注册流程,那么它们默认情况下会尝试往同一个注册表位置去写配置信息。结果就是:后安装(或者说后注册)的那一个版本,会直接覆盖掉前一个版本写下的注册信息,因为两者写的其实是同一把钥匙、同一把锁。
这带来的实际后果很直观:假设你先装了 32 位版本的某个组件,它把自己的 COM 注册信息写进了注册表;接着你又装了这个组件的 64 位版本,64 位版本的安装程序同样按照"标准路径"往注册表写自己的注册信息——如果两者用的是完全相同的路径,64 位版本的信息就会把 32 位版本的信息整个覆盖掉。等到 32 位程序再想去读取它自己期望的那份配置时,读到的却是 64 位版本留下的数据,格式、含义可能完全对不上,程序直接出问题。
二、解决思路:把注册表"劈成"两个视图
要解决这个问题,同时又不能要求 32 位程序修改一行代码(因为大量老旧的 32 位程序根本不可能再重新编译、重新适配),微软采用的办法是:在系统层面,把注册表逻辑上劈成两个视图(View)——一个叫 Native 视图(原生,也就是给 64 位程序看的那份),一个叫 WoW64 视图(专门给 32 位程序看的那份)。
默认规则很简单:
- 32 位组件访问注册表时,默认看到的是 WoW64 视图
- 64 位组件访问注册表时,默认看到的是 Native 视图
这样一来,32 位程序的状态和 64 位程序的状态(如果两者都存在的话)就被彻底隔离开了,各自有各自安全、独立的执行环境,互不干扰,而且这一切对 32 位程序本身完全透明——它感觉不到任何变化,它以为自己一直在访问"唯一的那份注册表"。
三、这个"视图切换"具体是怎么实现的:拦截 + 路径翻译
要实现这种透明的重定向,关键在于 WoW64 系统调用层。前面我们讲过,这一层会拦截所有由 32 位进程发起的系统调用。当它拦截到的是打开或创建注册表键这一类系统调用时,它会做一件事:把调用者传进来的键路径,翻译(改写)成指向 WoW64 视图的路径——当然前提是调用者没有明确要求要访问 64 位视图(这个"明确要求"的方式我们后面会讲到)。
那 WoW64 是怎么知道,“这个路径到底需不需要重定向、该怎么重定向"的呢?靠的是多棵树形数据结构,这些树里记录了一份清单,标明了哪些注册表键和子键是共享的(shared),哪些是拆分的(split)。这里有一个概念需要单独说一下:锚点树节点(anchor tree node)——它定义了"系统应该从哪个位置开始执行重定向”,可以理解成一棵树里那些"分岔口",从这个节点往下,路径才需要被改写。
WoW64 真正执行重定向的位置,一共只有两个:
HKLM\SOFTWAREHKEY_CLASSES_ROOT
四、“拆分键"和"共享键”:不是整棵树都要重定向
需要特别澄清的一点是:并不是这两个根键下面的所有内容都会被重定向。这两个根键下面的子键,实际上分成两类:
- 拆分键(Split Key):这类子键,32位视图和64位视图各自拥有一份独立的存储,彼此互不影响,改一个不会影响另一个。
- 共享键(Shared Key):这类子键,32位和64位程序访问到的其实是同一份数据,没有被拆开。
也就是说,重定向是有选择性的、按需进行的,而不是"一刀切"地把整个注册表都复制一份。
对于每一个"拆分键",在锚点节点所标记的那个位置下面,WoW64 都会创建一个专门的子键: - 对 x86 应用程序来说,这个子键叫
WoW6432Node - 对 ARM32 应用程序来说,这个子键叫
WowAA32Node
32 位程序专属的配置信息,就存放在这个特殊命名的子键之下。而注册表里其他所有没有被特殊标记为"拆分"的部分(比如HKLM\SYSTEM),则依然是32位和64位程序共享的,不存在两份视图的区别。
4.1 用一棵树直观理解拆分点长什么样
以最典型的 HKLM\SOFTWARE 为例,重定向发生前后,逻辑视图大致是这样的:
从这张图能看出:"拆分"这件事发生在具体的子键这一级,而不是在根键这一级;根键(HKLM\SOFTWARE)本身只是标记"从这里开始,往下要按拆分/共享的规则去区分对待"的那个锚点。
五、一个贴心的额外功能:自动改写路径字符串
WoW64 层为了配合前面讲过的文件系统重定向(32位程序访问 System32 会被自动改成访问 SysWOW64),还专门做了一个小设计:
如果一个 x86 32位应用程序,往注册表里写入一个 REG_SZ 或者 REG_EXPAND_SZ 类型的值,并且这个值的内容恰好以 %ProgramFiles% 或者 %CommonProgramFiles% 这两个字符串开头,WoW64 会自动把实际写进去的内容改成 %ProgramFiles(x86)% 或 %CommonProgramFiles(x86)%——这样一来,这个值展开之后,指向的目录才能跟文件系统重定向的逻辑对得上(因为 32 位程序的程序文件确实是装在 Program Files (x86) 目录下的,而不是 Program Files 目录)。
这里有个容易被忽略的细节:这个自动改写是有严格条件的——32 位程序必须一字不差地(包括大小写)写入 %ProgramFiles% 或 %CommonProgramFiles% 这两个具体字符串,改写逻辑才会生效;只要有任何不一致(哪怕只是大小写不同),WoW64 都不会去改写它,而是原样把这个值写进注册表,不做任何特殊处理。
六、如果我就是想跳过重定向、明确访问某个视图,该怎么办
对于那些明确知道自己想要访问哪个视图的程序(比如一个 32 位的系统管理工具,可能就是想去读 64 位视图下的某些系统级配置),Windows 在几个注册表相关的 API 上提供了专门的标志位,让调用者可以显式指定:
RegOpenKeyExRegCreateKeyExRegOpenKeyTransactedRegCreateKeyTransactedRegDeleteKeyEx
这几个 API 都支持传入以下两个标志之一:KEY_WOW64_64KEY:不管调用者本身是 32 位程序还是 64 位程序,都强制打开 64位视图下的键;同时,这个标志还会顺带关闭前面提到的那个"自动改写%ProgramFiles%"的拦截逻辑。KEY_WOW64_32KEY:不管调用者本身是 32 位程序还是 64 位程序,都强制打开 32位视图下的键。
也就是说,这两个标志给了程序一个"逃生舱口":正常情况下走默认的自动重定向规则就够用,但只要传了这两个标志之一,程序就能主动绕开默认规则,精确控制自己到底要访问哪一份视图。
七、完整的重定向决策流程图
把前面讲到的所有规则汇总成一张判断流程图,模拟"WoW64 系统调用层拦截到一次注册表打开/创建请求"之后,到底会怎么处理这次请求:
八、用 C++ 完整模拟一遍重定向逻辑
下面这份代码,用纯标准库模拟了整套注册表重定向的核心决策逻辑:包括路径是否需要重定向的判断、WoW6432Node/WowAA32Node 的插入、显式标志位的优先级处理,以及 %ProgramFiles% 字符串的自动改写规则。代码不依赖任何 Windows 头文件,可以直接用支持 C++17 的编译器编译运行,方便直观地理解整个决策过程。
// registry_redirection_sim.cpp
// 模拟 WoW64 注册表重定向的核心决策逻辑
// 编译方式: g++ -std=c++17 -O2 registry_redirection_sim.cpp -o demo
// 运行方式: ./demo
#include <cstdint>
#include <iostream>
#include <string>
#include <vector>
#include <set>
#include <algorithm>
#include <cctype> // 用于大小写不敏感比较时的字符处理
// ------------------------------------------------------------------
// 枚举: 调用者显式请求的视图类型
// 对应 RegOpenKeyEx 等API里可以传入的 KEY_WOW64_64KEY / KEY_WOW64_32KEY 标志
// ------------------------------------------------------------------
enum class ExplicitView {
None, // 没有显式指定,走默认的自动重定向规则
Force64, // 显式指定 KEY_WOW64_64KEY,强制访问64位(Native)视图
Force32 // 显式指定 KEY_WOW64_32KEY,强制访问32位(WoW64)视图
};
// ------------------------------------------------------------------
// 枚举: 调用者进程本身的位数
// ------------------------------------------------------------------
enum class CallerBitness {
Bit32, // 32位程序
Bit64 // 64位程序
};
// ------------------------------------------------------------------
// 枚举: 目标架构,决定重定向节点的名字
// (x86程序用WoW6432Node, ARM32程序用WowAA32Node)
// ------------------------------------------------------------------
enum class TargetArch {
X86,
Arm32
};
// ------------------------------------------------------------------
// 结构体: 描述一次注册表打开/创建请求
// ------------------------------------------------------------------
struct RegistryRequest {
std::string key_path; // 调用者传入的原始注册表路径,例如 "HKLM\\SOFTWARE\\SomeVendor"
CallerBitness caller_bits; // 发起调用的进程是32位还是64位
ExplicitView explicit_view; // 是否显式指定了视图标志
TargetArch arch; // 目标架构(用于决定插入WoW6432Node还是WowAA32Node)
};
// ------------------------------------------------------------------
// 一个非常简化的"锚点树"模拟:
// 用一个 set 存放所有已知的"拆分键"完整路径(大小写不敏感,这里简化为全部转小写比较)
// 真实系统里这是一棵真正的树结构,这里为了演示简化成一个集合做前缀匹配判断
// ------------------------------------------------------------------
class AnchorTree {
public:
// 添加一个被标记为"拆分键"的完整路径
void AddSplitKey(const std::string& path) {
split_keys_.insert(ToLower(path));
}
// 判断给定路径是否命中某个已知的拆分键(做前缀匹配:
// 只要请求路径等于某个拆分键路径,或者是其子路径,就认为需要重定向)
bool IsUnderSplitKey(const std::string& path) const {
std::string lower_path = ToLower(path);
for (const auto& split : split_keys_) {
// 情况1: 路径完全等于这个拆分键本身
if (lower_path == split) return true;
// 情况2: 路径是这个拆分键的子路径,即以 "拆分键路径\\" 开头
if (lower_path.size() > split.size() &&
lower_path.compare(0, split.size(), split) == 0 &&
lower_path[split.size()] == '\\') {
return true;
}
}
return false;
}
private:
std::set<std::string> split_keys_;
// 把字符串统一转成小写,用于大小写不敏感的比较
static std::string ToLower(const std::string& s) {
std::string result = s;
std::transform(result.begin(), result.end(), result.begin(),
[](unsigned char c) { return std::tolower(c); });
return result;
}
};
// ------------------------------------------------------------------
// 判断给定路径是否落在两个重定向根键之下: HKLM\SOFTWARE 或 HKEY_CLASSES_ROOT
// ------------------------------------------------------------------
bool IsUnderRedirectionRoot(const std::string& path) {
// 这里做一个简单的大小写不敏感的前缀检查
auto starts_with_ci = [](const std::string& s, const std::string& prefix) {
if (s.size() < prefix.size()) return false;
for (size_t i = 0; i < prefix.size(); ++i) {
if (std::tolower(static_cast<unsigned char>(s[i])) !=
std::tolower(static_cast<unsigned char>(prefix[i]))) {
return false;
}
}
return true;
};
return starts_with_ci(path, "HKLM\\SOFTWARE") ||
starts_with_ci(path, "HKEY_CLASSES_ROOT");
}
// ------------------------------------------------------------------
// 核心函数: 根据请求信息,计算出WoW64最终会实际访问的注册表路径
// 这是整个重定向逻辑的核心,对应原书描述的拦截+路径翻译过程
// ------------------------------------------------------------------
std::string ResolveRedirectedPath(const RegistryRequest& req, const AnchorTree& tree) {
// 第一步: 只有32位程序发起的调用,才需要考虑重定向问题
// (64位程序默认直接访问Native视图,不涉及本文讨论的重定向逻辑)
if (req.caller_bits == CallerBitness::Bit64 &&
req.explicit_view == ExplicitView::None) {
// 64位程序且未显式指定视图 -> 直接原样访问,无重定向
return req.key_path;
}
// 第二步: 处理显式标志,显式标志的优先级最高,直接决定结果,不再走后续的默认规则
if (req.explicit_view == ExplicitView::Force64) {
// KEY_WOW64_64KEY: 强制访问64位视图,路径不做任何拆分节点插入
return req.key_path;
}
if (req.explicit_view == ExplicitView::Force32) {
// KEY_WOW64_32KEY: 强制访问32位视图
// 即使这个路径本身不在重定向根键之下,也按规则插入拆分节点
// (简化处理: 仅在命中重定向根键时才插入节点,其余情况原样返回)
if (IsUnderRedirectionRoot(req.key_path) && tree.IsUnderSplitKey(req.key_path)) {
std::string node_name =
(req.arch == TargetArch::X86) ? "WoW6432Node" : "WowAA32Node";
return InsertNodeAfterRoot(req.key_path, node_name);
}
return req.key_path;
}
// 第三步: 走到这里说明是"未显式指定标志"的普通32位程序调用,按默认规则处理
// 3.1 先判断路径是否位于两个重定向根键之下
if (!IsUnderRedirectionRoot(req.key_path)) {
// 不在重定向范围内 -> 32位和64位共享同一份数据,原样返回
return req.key_path;
}
// 3.2 判断这个具体子键是不是"拆分键"
if (!tree.IsUnderSplitKey(req.key_path)) {
// 是共享键 -> 不重定向
return req.key_path;
}
// 3.3 命中拆分键 -> 插入对应架构的重定向节点
std::string node_name =
(req.arch == TargetArch::X86) ? "WoW6432Node" : "WowAA32Node";
return InsertNodeAfterRoot(req.key_path, node_name);
}
// ------------------------------------------------------------------
// 辅助函数: 在重定向根键之后插入节点名
// 例如: "HKLM\SOFTWARE\SomeVendor" + "WoW6432Node"
// -> "HKLM\SOFTWARE\WoW6432Node\SomeVendor"
// 这里简化处理,统一插在 "HKLM\SOFTWARE\" 或 "HKEY_CLASSES_ROOT\" 之后
// ------------------------------------------------------------------
std::string InsertNodeAfterRoot(const std::string& path, const std::string& node_name) {
const std::string root1 = "HKLM\\SOFTWARE";
const std::string root2 = "HKEY_CLASSES_ROOT";
if (path.size() >= root1.size() &&
path.compare(0, root1.size(), root1) == 0) {
// 在 "HKLM\SOFTWARE" 后面插入节点名,再拼上剩余部分
return root1 + "\\" + node_name + path.substr(root1.size());
}
if (path.size() >= root2.size() &&
path.compare(0, root2.size(), root2) == 0) {
return root2 + "\\" + node_name + path.substr(root2.size());
}
// 理论上不会走到这里(调用前已经判断过IsUnderRedirectionRoot)
return path;
}
// ------------------------------------------------------------------
// 模拟 %ProgramFiles% 字符串自动改写规则
// 输入: 32位程序尝试写入的 REG_SZ / REG_EXPAND_SZ 值内容
// 输出: WoW64实际会写入注册表的值内容
// ------------------------------------------------------------------
std::string RewriteProgramFilesValue(const std::string& original_value, bool is_64key_flag) {
// 如果显式带了 KEY_WOW64_64KEY 标志,这个自动改写逻辑会被整体关闭
if (is_64key_flag) {
return original_value;
}
// 必须精确匹配 "%ProgramFiles%" 或 "%CommonProgramFiles%" 这两个前缀
// 注意: 原书强调必须一字不差(包括大小写),这里用区分大小写的比较来体现这一点
const std::string prog_files = "%ProgramFiles%";
const std::string common_prog_files = "%CommonProgramFiles%";
if (original_value.compare(0, prog_files.size(), prog_files) == 0) {
// 命中,替换前缀为 %ProgramFiles(x86)%,保留后面剩余的部分不变
return "%ProgramFiles(x86)%" + original_value.substr(prog_files.size());
}
if (original_value.compare(0, common_prog_files.size(), common_prog_files) == 0) {
return "%CommonProgramFiles(x86)%" + original_value.substr(common_prog_files.size());
}
// 没有精确匹配到这两个字符串开头 -> 原样写入,不做任何改写
return original_value;
}
// ------------------------------------------------------------------
// 演示主函数
// ------------------------------------------------------------------
int main() {
// 构建一棵简化的锚点树,标记出哪些子键是"拆分键"
AnchorTree tree;
tree.AddSplitKey("HKLM\\SOFTWARE\\SomeVendor"); // 这个子键被标记为拆分键
tree.AddSplitKey("HKEY_CLASSES_ROOT\\SomeVendor.App"); // 这个也是拆分键
// 注意: "HKLM\SOFTWARE\SomeSharedKey" 没有加入拆分键集合,代表它是共享键
std::cout << "===== 测试1: 32位程序默认访问,命中拆分键 -> 应插入WoW6432Node =====\n";
{
RegistryRequest req{"HKLM\\SOFTWARE\\SomeVendor\\Settings",
CallerBitness::Bit32, ExplicitView::None, TargetArch::X86};
std::cout << "原始路径: " << req.key_path << "\n";
std::cout << "实际访问: " << ResolveRedirectedPath(req, tree) << "\n\n";
}
std::cout << "===== 测试2: 32位程序默认访问,命中共享键 -> 不重定向 =====\n";
{
RegistryRequest req{"HKLM\\SOFTWARE\\SomeSharedKey\\Config",
CallerBitness::Bit32, ExplicitView::None, TargetArch::X86};
std::cout << "原始路径: " << req.key_path << "\n";
std::cout << "实际访问: " << ResolveRedirectedPath(req, tree) << "\n\n";
}
std::cout << "===== 测试3: 路径不在重定向根键之下 -> 不重定向 =====\n";
{
RegistryRequest req{"HKLM\\SYSTEM\\CurrentControlSet",
CallerBitness::Bit32, ExplicitView::None, TargetArch::X86};
std::cout << "原始路径: " << req.key_path << "\n";
std::cout << "实际访问: " << ResolveRedirectedPath(req, tree) << "\n\n";
}
std::cout << "===== 测试4: 32位程序显式指定KEY_WOW64_64KEY -> 强制访问64位视图 =====\n";
{
RegistryRequest req{"HKLM\\SOFTWARE\\SomeVendor\\Settings",
CallerBitness::Bit32, ExplicitView::Force64, TargetArch::X86};
std::cout << "原始路径: " << req.key_path << "\n";
std::cout << "实际访问: " << ResolveRedirectedPath(req, tree) << "\n\n";
}
std::cout << "===== 测试5: ARM32程序命中拆分键 -> 应插入WowAA32Node =====\n";
{
RegistryRequest req{"HKEY_CLASSES_ROOT\\SomeVendor.App\\shell",
CallerBitness::Bit32, ExplicitView::None, TargetArch::Arm32};
std::cout << "原始路径: " << req.key_path << "\n";
std::cout << "实际访问: " << ResolveRedirectedPath(req, tree) << "\n\n";
}
std::cout << "===== 测试6: %ProgramFiles% 字符串自动改写 =====\n";
{
std::string v1 = "%ProgramFiles%\\MyApp\\bin";
std::string v2 = "%CommonProgramFiles%\\Shared";
std::string v3 = "%programfiles%\\WrongCase"; // 大小写不匹配,不应被改写
std::string v4 = "C:\\SomeOtherPath"; // 完全不相关,不应被改写
std::cout << "写入 \"" << v1 << "\" -> 实际写入 \""
<< RewriteProgramFilesValue(v1, false) << "\"\n";
std::cout << "写入 \"" << v2 << "\" -> 实际写入 \""
<< RewriteProgramFilesValue(v2, false) << "\"\n";
std::cout << "写入 \"" << v3 << "\" -> 实际写入 \""
<< RewriteProgramFilesValue(v3, false) << "\" (大小写不匹配,未改写)\n";
std::cout << "写入 \"" << v4 << "\" -> 实际写入 \""
<< RewriteProgramFilesValue(v4, false) << "\" (前缀不匹配,未改写)\n";
std::cout << "带KEY_WOW64_64KEY标志时写入 \"" << v1 << "\" -> 实际写入 \""
<< RewriteProgramFilesValue(v1, true) << "\" (标志关闭了自动改写)\n";
}
return 0;
}
代码逻辑说明(配合注释一起看):
RegistryRequest结构体把一次注册表请求需要的四项关键信息打包在一起:请求的原始路径、调用者是 32 位还是 64 位、有没有显式传标志、目标架构是 x86 还是 ARM32。AnchorTree类用一个std::set<std::string>简化模拟了真实系统里那棵"锚点树",它只回答一个问题:给定一个路径,它是不是落在某个已知的"拆分键"底下(包括这个拆分键自己,或者它的任意子路径)。ResolveRedirectedPath是整个程序的核心,严格按照文档里讲的优先级顺序判断:先看有没有显式标志(优先级最高,直接决定结果)→ 再看路径是否落在两个重定向根键之下 → 再看具体命中的是拆分键还是共享键 → 最后才决定要不要插入WoW6432Node/WowAA32Node。InsertNodeAfterRoot负责实际的字符串拼接,把节点名插到HKLM\SOFTWARE或HKEY_CLASSES_ROOT后面,模拟真实系统里"路径翻译"这一步具体是怎么改写字符串的。RewriteProgramFilesValue单独模拟了%ProgramFiles%自动改写的规则,特别演示了"大小写必须精确匹配"和"KEY_WOW64_64KEY标志会关闭这个自动改写"这两个容易被忽略的细节。main函数里设计了 6 组测试用例,分别覆盖:命中拆分键、命中共享键、根本不在重定向范围内、显式强制访问 64 位视图、ARM32 架构使用不同节点名、以及%ProgramFiles%改写规则的各种边界情况。
编译运行之后,可以清楚看到每种情况下最终解析出来的路径分别是什么,帮助建立起对整套重定向规则的直观认识。
WoW64 系统调用(System Calls)机制详解
一、32位程序眼中的"操作系统":一套专属的 DLL
对于运行在 WoW64 环境下的 32 位应用程序来说,它们要跟操作系统打交道的方式,跟原生 64 位程序表面上看几乎一样:它们调用的依然是 Ntdll.dll、User32.dll、Gdi32.dll 这些熟悉的系统库函数。区别在于,这些程序实际链接、调用的,是这几个 DLL 的专属 32 位版本,它们被放在一个专门的目录里:\Windows\Syswow64。
除了这三个最核心的库之外,还有一些负责进程间通信的 DLL,也有对应的 32 位版本放在这个目录下——比如 Rpcrt4.dll(RPC 运行时库)。
从 32 位程序自己的视角来看,它调用系统库函数的方式跟原生 64 位程序完全没有区别:这些系统例程要么能直接在用户态把活干完(不需要打扰内核),要么就需要内核帮忙,这时候就得真正发起一次系统调用。而所有这一切复杂的底层切换逻辑,对 32 位程序来说都是透明的——它压根感觉不到自己其实是在一个"模拟环境"里运行。
二、系统调用的第一步:"桩函数"如何选择走哪条路
当 32 位版本的系统库需要真正发起一次系统调用时,它走的路径依然是通过桩函数(stub function)——这一点跟原生 64 位 Ntdll 里的桩函数在结构上是类似的:都是先把系统调用索引号放进某个寄存器。
但接下来关键的分歧点出现了:原生 64 位的桩函数,会真正执行那条系统调用指令(比如 syscall),直接切进内核;而 32 位版本的桩函数不会这样做。它虽然同样先把系统调用号放进寄存器,但接下来调用的是 WoW64 系统调用分发器(WoW64 system call dispatcher),而不是真正的内核系统调用指令。
那 32 位桩函数是怎么知道该跳到哪里去找这个分发器的呢?答案就是我们前面文章讲过的那个变量:Wow64Transition——它是 WoW64 核心在进程初始化阶段(第8步)填好、存放在 32位版本 Ntdll 里的一个导出变量,里面存的正是这个分发器的入口地址。整套机制到这里就闭环了:初始化阶段埋下的这颗"指针种子",正是系统调用真正发生时被用到的那把钥匙。
三、WoW64 系统调用分发器:从"模拟世界"跳回"真实世界"
这个分发器本身,具体实现在平台相关的模拟器里——对于 x86 on AMD64 这条路径来说,就是 wow64cpu.dll。
分发器要做的第一件事,是发起另一次远跳转(far jump),把 CPU 执行模式从"模拟状态"切换回原生 64 位执行模式——这个动作本质上是退出模拟,让 CPU 真正切回 64 位模式去执行接下来的一系列处理逻辑。
紧接着,二进制翻译器(binary translator,也就是这个模拟器组件自己)要做两件配套的事情:
- 把栈切换成 64 位栈——因为接下来要用 64 位代码去处理这次调用,必须先有一个合适的 64 位栈可用。
- 保存旧的 CPU 上下文——也就是把切换之前,32 位模拟执行时的寄存器状态先存起来,这样将来还能切回去继续模拟 32 位代码。
四、参数转换:Thunking 到底在转换什么
栈和上下文准备好之后,接下来要处理的是这次系统调用携带的参数。这一步的核心动作,原文用了一个专门的术语:thunking(转换/瘦身转发)。
之所以需要 thunking,根本原因在于:32位 ABI 和 64位 ABI 是两套不同的规则。ABI(Application Binary Interface,应用程序二进制接口)定义的是:函数调用时,数据结构、指针、各种取值,到底应该按什么样的方式传参、按什么样的方式在机器码层面被访问。32 位程序按照 32 位 ABI 的规则去准备好参数(比如指针就是 4 字节宽),但真正要执行的内核代码却是 64 位的,期望看到的是符合 64 位 ABI 规则的参数(指针是 8 字节宽)——两者对不上,所以中间必须有一层转换逻辑,把 32 位形式的参数,转换成 64 位代码能正确理解的形式,这层转换逻辑就是 thunking 要干的事情。这也是为什么 thunking 机制能"让按照 32 位 ABI 执行的机器码,和 64 位代码顺利协同工作"。
五、两种 Thunking 策略:Turbo thunks 与完整转换
模拟器在实现 thunking 的时候,其实用了两种不同复杂度的策略,具体用哪一种,取决于这个 API 处理的参数到底"简不简单"。
5.1 Turbo thunks:给"简单参数"走的快速通道
对于那些不涉及复杂数据结构、只处理简单输入输出值(比如单纯的整数、句柄这类)的 API,模拟器用的是一种叫 Turbo thunks 的机制——这是一批内置在模拟器里的小型转换例程,专门针对这类简单场景做优化。
Turbo thunks 的特点是:转换完参数之后,直接调用对应的原生 64 位 API,不需要经过更复杂的中间层。可以理解成,这是给"最常见、最简单"的那批系统调用开的一条绿色通道,图的就是快。
5.2 复杂 API:交给 Wow64SystemServiceEx 处理
对于那些涉及复杂数据结构的 API(比如参数里包含指向嵌套结构体的指针、变长数组这类),简单的 Turbo thunks 就不够用了,需要一个更完整的机制来处理,这就是 Wow64SystemServiceEx 例程负责的工作。
它做的事情具体是:
- 从系统调用索引里,提取出正确的 WoW64 系统调用表编号——这一步跟我们之前讲过的系统调用号拆分(
TableIndex+ServiceIndex)逻辑是呼应的,只不过这里针对的是 WoW64 层自己维护的那套表。 - 根据提取出来的编号,调用正确的 WoW64 系统调用函数去完成真正的转换和分发工作。
5.3 WoW64 系统调用函数的命名规律
这里有个很好记的命名规律:WoW64 层自己实现的这些系统调用函数,具体代码实现在 WoW64 核心库和 Wow64win.dll 里,它们的名字跟对应的原生系统调用完全一样,只是多了一个 wh- 前缀。
举个具体例子:原生系统调用叫 NtCreateFile,那么它对应的 WoW64 版本,函数名就是 whNtCreateFile。这个命名习惯让阅读和调试相关代码的人,能一眼看出"这是某个原生系统调用在 WoW64 层的转换版本"。
六、转换完成之后:正式调用原生系统调用,再切回模拟世界
无论走的是 Turbo thunks 这条快速通道,还是 Wow64SystemServiceEx 这条完整流程,等参数的转换工作正确完成之后,模拟器接下来要做的事情就很直接了:发起对应的原生64位系统调用——这时候才是真正意义上,请求穿透进了 NT 内核。
等这次原生系统调用返回之后,还有最后一步收尾工作:如果这次调用有输出参数(也就是内核会往某些参数指向的内存里写结果,供调用者读取),WoW64 需要把这些输出参数再做一次转换(thunk),方向正好和进去的时候相反——把内核按 64 位格式写好的结果,转换回 32 位程序能正确理解的 32 位格式。
这一切都做完之后,模拟器重新启动模拟(restarts the simulation)——也就是说,控制权重新交还给 32 位代码的模拟执行环境,32 位程序会觉得,自己刚才那次系统调用只是"正常返回"了而已,完全感知不到中间这一大段"跳出模拟、切栈、转换参数、真正调用、再转换回来、重新进入模拟"的复杂过程。
七、完整流程图:一次 32位系统调用的完整生命周期
把上面讲的每一步串成一张流程图,完整展现"32位程序发起一次系统调用"到"拿到结果继续执行"的全过程:
八、Turbo thunks 与 Wow64SystemServiceEx 对比
| 对比维度 | Turbo thunks | Wow64SystemServiceEx |
|---|---|---|
| 适用场景 | 简单输入输出值,无复杂数据结构 | 涉及复杂/嵌套数据结构的API |
| 实现位置 | 模拟器内部的小型转换例程 | 需要查WoW64系统调用表后分发 |
| 调用路径 | 转换后直接调用原生64位API | 先定位whNtXxx函数,再由它完成转换和调用 |
| 命名规律 | 无特定统一命名前缀 | 对应函数名带 wh- 前缀,如 whNtCreateFile |
| 性能特点 | 更快,专为高频简单调用优化 | 相对更重,但能处理复杂参数结构 |
九、用 C++ 完整模拟一遍系统调用的转换分发过程
下面这份代码,用一个简化但完整的方式,模拟了 WoW64 系统调用从"32位桩函数发起调用"到"最终拿到结果返回"的整个决策与转换过程,包括 Turbo thunks 与 Wow64SystemServiceEx 两条路径的分支选择、以及 wh- 前缀函数命名规则的体现。代码不依赖任何 Windows 头文件,纯标准库实现,可以直接用支持 C++17 的编译器编译运行。
// wow64_syscall_sim.cpp
// 模拟 WoW64 系统调用分发与参数thunking过程
// 编译方式: g++ -std=c++17 -O2 wow64_syscall_sim.cpp -o demo
// 运行方式: ./demo
#include <cstdint>
#include <iostream>
#include <string>
#include <vector>
#include <unordered_map>
#include <functional>
#include <variant>
// ------------------------------------------------------------------
// 用一个简化的"参数描述"结构,模拟一次系统调用携带的参数信息
// is_complex_structure 为 true 时代表参数里包含复杂/嵌套的数据结构,
// 需要走 Wow64SystemServiceEx 这条完整路径;否则走 Turbo thunks 快速通道
// ------------------------------------------------------------------
struct SyscallParams {
std::string api_name; // 32位程序调用的原始API名字,例如 "NtCreateFile"
bool is_complex_structure; // 是否涉及复杂数据结构
std::vector<int32_t> in_values; // 简化模拟:输入参数值(32位格式)
bool has_output_param; // 是否存在需要转换回32位格式的输出参数
};
// ------------------------------------------------------------------
// 模拟"原生64位系统调用"的执行结果
// ------------------------------------------------------------------
struct NativeCallResult {
int64_t status_code; // 模拟返回的状态码
std::vector<int64_t> out_values; // 64位格式的输出参数(如果有的话)
};
// ------------------------------------------------------------------
// 模拟真正的原生64位系统调用实现
// 在真实系统里,这一步会真正陷入内核;这里只是简单地把输入值做一次
// "放大"处理来模拟64位世界和32位世界数据表示上的差异
// ------------------------------------------------------------------
NativeCallResult InvokeNativeSyscall(const std::string& native_api_name,
const std::vector<int64_t>& in_values_64) {
std::cout << " [内核] 真正执行原生64位系统调用: " << native_api_name << "\n";
NativeCallResult result;
result.status_code = 0; // 模拟 STATUS_SUCCESS
// 模拟内核往输出参数里写结果:这里简单地把每个输入值乘以2作为演示
for (int64_t v : in_values_64) {
result.out_values.push_back(v * 2);
}
return result;
}
// ------------------------------------------------------------------
// 把32位参数"放大"转换成64位格式,模拟进入内核前的thunking方向
// (真实系统里这一步远比这复杂,可能涉及指针重定位、结构体字段对齐调整等)
// ------------------------------------------------------------------
std::vector<int64_t> ThunkParamsTo64(const std::vector<int32_t>& in_values_32) {
std::vector<int64_t> result;
result.reserve(in_values_32.size());
for (int32_t v : in_values_32) {
// 简化模拟: 直接做符号扩展,把32位值放进64位容器里
result.push_back(static_cast<int64_t>(v));
}
return result;
}
// ------------------------------------------------------------------
// 把内核返回的64位输出值转换回32位格式,模拟返回时的反向thunking
// ------------------------------------------------------------------
std::vector<int32_t> ThunkParamsTo32(const std::vector<int64_t>& out_values_64) {
std::vector<int32_t> result;
result.reserve(out_values_64.size());
for (int64_t v : out_values_64) {
// 简化模拟: 截断为32位(真实场景中会有边界检查和错误处理)
result.push_back(static_cast<int32_t>(v));
}
return result;
}
// ------------------------------------------------------------------
// Turbo thunks 处理路径: 针对简单参数的快速通道
// 直接转换参数后调用原生API,不经过额外的表查询步骤
// ------------------------------------------------------------------
NativeCallResult HandleViaTurboThunk(const SyscallParams& params) {
std::cout << "[Turbo Thunk] 处理简单API: " << params.api_name << "\n";
std::cout << " 参数简单,直接转换并调用原生64位API\n";
std::vector<int64_t> in64 = ThunkParamsTo64(params.in_values);
// 原生API名字和32位API名字一致,Turbo thunk没有特殊前缀规则
return InvokeNativeSyscall(params.api_name, in64);
}
// ------------------------------------------------------------------
// 模拟 WoW64 系统调用表: key是API名字,value是对应的"wh-"前缀处理函数
// 这个表模拟的正是 Wow64SystemServiceEx 内部"根据系统调用索引
// 找到正确WoW64系统调用函数"这个查表过程
// ------------------------------------------------------------------
class Wow64ServiceTable {
public:
// 注册一个复杂API对应的WoW64处理函数(即whNtXxx风格的函数)
void Register(const std::string& api_name,
std::function<NativeCallResult(const SyscallParams&)> handler) {
table_[api_name] = std::move(handler);
}
// 根据API名字查表并执行对应的wh-函数
NativeCallResult Dispatch(const SyscallParams& params) const {
auto it = table_.find(params.api_name);
if (it == table_.end()) {
std::cout << " [错误] 未在WoW64系统调用表中找到对应处理函数: "
<< params.api_name << "\n";
return NativeCallResult{-1, {}};
}
return it->second(params);
}
private:
std::unordered_map<std::string, std::function<NativeCallResult(const SyscallParams&)>> table_;
};
// ------------------------------------------------------------------
// 模拟一个具体的 wh- 前缀函数: whNtCreateFile
// 对应真实系统里"实现在WoW64核心库/Wow64win.dll里,
// 名字与原生调用相同但带wh-前缀"的那批函数
// 这里额外演示它如何处理复杂数据结构(用一个嵌套vector模拟)
// ------------------------------------------------------------------
NativeCallResult whNtCreateFile(const SyscallParams& params) {
std::cout << " [whNtCreateFile] 正在处理复杂数据结构的参数转换\n";
std::cout << " (例如: 32位OBJECT_ATTRIBUTES结构体 -> 64位OBJECT_ATTRIBUTES结构体)\n";
std::vector<int64_t> in64 = ThunkParamsTo64(params.in_values);
// 转换完成后,调用真正的原生系统调用(去掉wh前缀,即NtCreateFile)
return InvokeNativeSyscall("NtCreateFile", in64);
}
// ------------------------------------------------------------------
// Wow64SystemServiceEx: 复杂API处理路径的总入口
// 对应文档描述的"提取WoW64系统调用表编号,调用正确的WoW64系统调用函数"
// ------------------------------------------------------------------
NativeCallResult Wow64SystemServiceEx(const SyscallParams& params,
const Wow64ServiceTable& table) {
std::cout << "[Wow64SystemServiceEx] 处理复杂API: " << params.api_name << "\n";
std::cout << " 第1步: 从系统调用索引提取WoW64系统调用表编号(此处以API名字模拟查表)\n";
std::cout << " 第2步: 查表定位到对应的 wh- 前缀函数并调用\n";
return table.Dispatch(params);
}
// ------------------------------------------------------------------
// 整个WoW64系统调用分发器的总入口
// 对应文档里 "wow64cpu.dll 里实现的分发器" 这个角色
// ------------------------------------------------------------------
NativeCallResult Wow64SyscallDispatcher(const SyscallParams& params,
const Wow64ServiceTable& table) {
std::cout << "\n===== 32位程序发起系统调用: " << params.api_name << " =====\n";
std::cout << "[分发器] 退出模拟,切换到64位执行模式\n";
std::cout << "[分发器] 切换到64位栈,保存32位模拟态的CPU上下文\n";
NativeCallResult result;
// 根据参数复杂度选择处理路径,这一步对应文档里两种thunking策略的分支
if (params.is_complex_structure) {
result = Wow64SystemServiceEx(params, table);
} else {
result = HandleViaTurboThunk(params);
}
// 如果有输出参数,需要做反向转换(64位 -> 32位)
if (params.has_output_param && !result.out_values.empty()) {
std::vector<int32_t> out32 = ThunkParamsTo32(result.out_values);
std::cout << "[分发器] 将输出参数从64位格式转换回32位格式\n";
std::cout << " 转换后的32位输出值: ";
for (int32_t v : out32) std::cout << v << " ";
std::cout << "\n";
}
std::cout << "[分发器] 重新启动模拟,控制权交还给32位代码\n";
std::cout << " 最终状态码: " << result.status_code << "\n";
return result;
}
int main() {
// 建立WoW64系统调用表,注册whNtCreateFile这个"复杂API"的处理函数
Wow64ServiceTable table;
table.Register("NtCreateFile", whNtCreateFile);
// ----------------------------------------------------------
// 场景1: 简单API调用,例如查询某个简单句柄状态,走Turbo thunks
// ----------------------------------------------------------
SyscallParams simple_call;
simple_call.api_name = "NtQuerySimpleValue";
simple_call.is_complex_structure = false;
simple_call.in_values = {10, 20};
simple_call.has_output_param = true;
Wow64SyscallDispatcher(simple_call, table);
// ----------------------------------------------------------
// 场景2: 复杂API调用,例如NtCreateFile,参数含复杂结构体,
// 走 Wow64SystemServiceEx -> whNtCreateFile 这条路径
// ----------------------------------------------------------
SyscallParams complex_call;
complex_call.api_name = "NtCreateFile";
complex_call.is_complex_structure = true;
complex_call.in_values = {100, 200, 300};
complex_call.has_output_param = true;
Wow64SyscallDispatcher(complex_call, table);
// ----------------------------------------------------------
// 场景3: 复杂API但WoW64系统调用表里没有注册对应处理函数
// 演示查表失败的情况
// ----------------------------------------------------------
SyscallParams unregistered_call;
unregistered_call.api_name = "NtUnknownComplexApi";
unregistered_call.is_complex_structure = true;
unregistered_call.in_values = {1};
unregistered_call.has_output_param = false;
Wow64SyscallDispatcher(unregistered_call, table);
return 0;
}
代码逻辑说明(配合注释一起看):
SyscallParams结构体模拟了一次系统调用携带的信息:调用的 API 名字、参数是否涉及复杂数据结构、输入值列表、是否有需要转换回来的输出参数。这个"是否涉及复杂数据结构"的标志,正是决定走 Turbo thunks 还是Wow64SystemServiceEx这两条路径的分水岭。ThunkParamsTo64/ThunkParamsTo32这一对函数,用简化的方式(符号扩展/截断)模拟了真正的 thunking 过程——真实系统里这一步远比这复杂,可能涉及指针重定位、结构体字段对齐差异的调整,这里只做最基本的示意。HandleViaTurboThunk对应文档里说的"简单参数走快速通道":直接转换参数、直接调用原生 API,不需要额外查表。Wow64ServiceTable类模拟的正是"WoW64 系统调用表"这个概念,用一个哈希表把 API 名字映射到对应的处理函数,模拟了"根据系统调用索引找到正确的 WoW64 系统调用函数"这个查表动作。whNtCreateFile是一个具体的wh-前缀函数范例,展示了命名规则(Nt前缀的原生调用对应wh前缀的 WoW64 版本),并且在转换完参数之后,转而调用真正的NtCreateFile(去掉wh前缀)。Wow64SystemServiceEx函数体现了文档里描述的两步流程:先"提取表编号"(这里简化为直接按 API 名字查表),再"调用正确的处理函数"。Wow64SyscallDispatcher是整个模拟的总入口,对应真实系统里wow64cpu.dll里那个分发器的角色:先打印"退出模拟、切栈、保存上下文"这几个前置步骤,再根据参数复杂度分流到两条 thunking 路径之一,调用完之后处理输出参数的反向转换,最后"重新启动模拟"收尾。main函数设计了三个场景:简单 API(走 Turbo thunks)、复杂 API(走Wow64SystemServiceEx+whNtCreateFile)、以及一个"没有注册处理函数"的复杂 API(演示查表失败的容错情况)。
运行这份代码,能清楚看到每一步的执行顺序和打印信息,帮助建立起"32位系统调用从发起到返回"这条完整链路的直观印象。
十、一句话总结整个链路
WoW64 系统调用机制的核心思路是:32位程序发出的调用,先被桩函数拦下来(不真正陷入内核),转交给分发器切换到64位执行环境,参数经过一层"翻译"(简单的走Turbo thunks这条快车道,复杂的交给Wow64SystemServiceEx+wh-系列函数处理),转换完成后才真正发起原生64位系统调用,返回时再把结果反向翻译回32位格式,最后悄无声息地把控制权还给32位代码继续执行——整个过程对 32 位程序而言完全透明,它从始至终都以为自己是在直接和一个"32位版本的操作系统"对话。
WoW64 异常分发(Exception Dispatching)与 APC 投递详解
1. 为什么异常也会打断 CPU 模拟
前面讲过,一个 32 位线程一旦通过调用门切换进 32-bit compatibility mode,就会一直保持在这个模式下运行,只有两种情况会让它退出:系统调用被分发,或者中断/异常发生。系统调用那条路径前面已经讲过了;这一节讲的是另一条路径——异常(Exception)。
异常和系统调用有一个共同点:它们都需要内核介入处理,而内核本身是运行在 64 位原生模式下的(WoW64 是纯用户态的模拟机制,不会让内核也变成"32 位内核")。所以无论是异常还是 APC(Asynchronous Procedure Call,异步过程调用),只要涉及到需要内核参与分发的场景,CPU 就必须先从 32 位模式切回 64 位模式,让 64 位内核和 64 位 Ntdll 先接手,处理完必要的转换工作后,再决定要不要切回 32 位继续执行。
2. 异常发生时,内核先做了什么
当异常发生时,NT 内核要做的第一件事是判断这个异常是不是由一个正在执行用户态代码的线程触发的。如果是,内核就会:
- 在当前活动的栈上构建一个"扩展异常帧"(extended exception frame);
- 把执行流程分发(dispatch)回用户态,具体目标是 64 位 Ntdll 里的
KiUserExceptionDispatcher函数。
这里有个非常关键、也是本节最容易搞混的地方:“当前活动的栈”,对于一个正在运行 32 位代码的 WoW64 线程来说,指的其实是它的 32 位栈(因为异常发生的那一刻,线程正处在 32 位模式下执行 32 位代码)。
但是,KiUserExceptionDispatcher是 64 位 Ntdll 里的函数,它期望看到的是一个64 位格式的异常帧(里面包含了完整的 64 位 CPU 上下文快照)。矛盾就出现在这里:64 位格式的异常帧,被内核直接放到了 32 位栈上——这是一种"内容和容器不匹配"的状态,必须先经过转换才能真正交给 64 位的异常分发逻辑去处理。
用一张图先把这个矛盾点直观地表示出来:
3. Wow64PrepareForException:负责把这个矛盾解开
解决上面这个"64 位数据放在 32 位栈里"的矛盾,正是 Wow64PrepareForException 函数的职责所在。这个函数由 WoW64 核心库导出,专门用来处理异常分发前的格式转换工作。它做的事情,可以拆成清晰的几步:
步骤一:在原生 64 位栈上开辟空间
Wow64PrepareForException 首先会在线程的原生 64 位栈上分配出一块空间。
步骤二:把 32 位栈上的原生异常帧拷贝过去
接着,它把刚才内核放在 32 位栈上的那份(64 位格式的)“原生异常帧”,拷贝到刚刚在 64 位栈上开辟出来的空间里。
这一步只是"搬家",还没有做真正的格式转换——只是先把数据从"错误的容器(32 位栈)"挪到"正确的容器(64 位栈)"里,让后续处理可以在正确的栈上下文里进行。
步骤三:切换到 64 位栈
数据搬完之后,函数会把当前使用的栈从 32 位栈切换到 64 位栈。这样后续的处理逻辑,就是在一个"正常"的 64 位栈环境下进行的了,不会再有格式和容器不匹配的问题。
步骤四:做真正的格式转换——64位转32位
接下来才是核心的转换工作:把异常记录(Exception Record)和上下文记录(Context Record)这两样东西,从 64 位格式转换成对应的 32 位版本。
这里可以类比理解一下这两个记录分别是什么:
- 异常记录(Exception Record):描述"发生了什么",比如异常的类型(除零、访问违例等)、发生异常的地址、附加参数等;
- 上下文记录(Context Record):描述"发生异常那一刻,CPU 各个寄存器的完整快照",比如 EAX、EBX、EIP、EFLAGS 这些 32 位寄存器的值(因为异常发生时线程正跑在 32 位模式下,所以这里要还原出的是 32 位寄存器视角下的状态,而不是 64 位寄存器视角)。
转换完成后,这两份32 位格式的记录,会被存回32 位栈——直接替换掉之前内核放在那里的那份 64 位异常帧。
步骤五:重新从 32 位版本的分发函数开始
一切准备就绪之后,WoW64 核心就可以让 CPU 重新切回 32 位模式,让执行流程从 32 位版本的 KiUserExceptionDispatcher 开始跑。这个 32 位版本函数拿到的,就是一份格式完全正确、内容完整的 32 位异常帧了,它接下来分发异常的方式,和一个真正运行在原生 32 位系统上的程序完全一样——32 位应用程序完全感知不到自己其实是在被模拟。
把整个流程串成一张图:
4. APC 投递:思路相似,但方向相反
讲完异常之后,再看 32 位用户态 APC(Asynchronous Procedure Call,异步过程调用)的投递过程。APC 是 Windows 里用来"往指定线程里插入一段待执行代码"的机制,常用于异步 I/O 完成通知等场景。这里的实现思路和上面讲的异常分发高度相似,但转换的方向正好相反。
先回忆一下正常(原生)情况下 APC 是怎么投递的:一个常规的用户态 APC,是通过原生 Ntdll 的 KiUserApcDispatcher 函数来投递的。
那么对于 WoW64 进程,当 64 位内核准备要向它投递一个用户态 APC 时,情况会复杂一些,因为:
- 这个 APC 例程的地址,本质上是一个32 位地址(因为它是 32 位程序注册的回调);
- 但内核本身是 64 位的,处理逻辑、地址空间都是按 64 位来运作的。
4.1 第一步:把 32 位 APC 地址映射到 64 位地址空间的高位区域
内核会把这个 32 位的 APC 地址,映射到 64 位地址空间中更高的一个范围里。这一步可以理解成:给这个 32 位函数地址"套上"一层能在 64 位地址空间里合法引用的外壳,让 64 位那一侧的代码能够正确地找到并跳转过去。
4.2 第二步:64 位 Ntdll 调用 Wow64ApcRoutine
接着,64 位 Ntdll 会调用 WoW64 核心库导出的 Wow64ApcRoutine 函数。这个函数做的事情,正好和异常处理那边的 Wow64PrepareForException 是"镜像对称"的:
- 在用户态捕获原生(64 位格式)的 APC 记录和上下文记录——这里"捕获"的意思是把内核准备好的这份数据读取/保存下来;
- 把它映射回 32 位栈——也就是把数据从 64 位那一侧,转移回 32 位栈里,为接下来的格式转换做准备;
- 准备好一份 32 位格式的用户态 APC 和上下文记录——同样是做 64 位到 32 位的格式转换,逻辑上和前面讲的异常上下文转换是同一类工作;
- 重启 CPU 模拟,从 32 位版本的
KiUserApcDispatcher函数开始执行。
这个 32 位版本的KiUserApcDispatcher拿到转换好的数据后,投递 APC 的方式和一个真正运行在原生 32 位系统上的 Ntdll 完全一致——同样是"32 位程序完全无感知"。
4.3 异常分发 与 APC 投递 的对比
把两条路径放在一起对比一下,会发现它们其实是同一套思路的两种应用场景:
| 对比项 | 异常分发 | APC 投递 |
|---|---|---|
| 触发来源 | CPU 检测到异常(如访问违例、除零等) | 内核主动想给线程投递一段待执行代码 |
| 数据起点 | 内核在32位栈上构建64位格式的异常帧 | 内核处理32位地址的APC,映射到64位地址空间高位 |
| 转换函数 | Wow64PrepareForException(WoW64核心库导出) |
Wow64ApcRoutine(WoW64核心库导出) |
| 转换内容 | 异常记录 + 上下文记录:64位 → 32位 | APC记录 + 上下文记录:64位 → 32位 |
| 转换结果存放位置 | 32位栈(替换原来的64位异常帧) | 32位栈 |
| 最终重启入口 | 32位版 KiUserExceptionDispatcher |
32位版 KiUserApcDispatcher |
可以看出,两条路径本质上都是在解决同一个问题:内核和64位Ntdll产出的是64位格式的数据,而32位程序只能理解32位格式的数据,所以需要WoW64核心在两者中间做一次"翻译",翻译完成后再把控制权交还给对应的32位版本分发函数,让32位程序全程感觉不到自己是被模拟出来的。
5. 小结
无论是异常分发还是 APC 投递,本质上都属于"需要内核介入、因此必须先退出 32 位模拟模式"的场景。它们共享同一个核心矛盾:内核和64位 Ntdll 天然只产出64位格式的数据结构,而32位应用程序期望看到的是32位格式的数据结构。
WoW64 核心通过 Wow64PrepareForException 和 Wow64ApcRoutine 这两个功能相似的函数,各自完成"数据搬运 + 格式转换"的工作:先把64位格式的数据从32位栈搬到64位栈(或反过来,从内核映射的高位地址搬回32位栈),转换成32位格式后,再存回32位栈,最后重新把 CPU 切回32位模式,从对应的32位版本分发函数(KiUserExceptionDispatcher 或 KiUserApcDispatcher)继续执行。
这样一来,32 位应用程序无论是遇到异常,还是接收到 APC,处理流程和体验都和运行在一个真正的原生32位系统上完全一致,WoW64 这层"模拟"对它来说始终是透明、无感的。
ARM 架构详解:从零理解
一、ARM 是什么
ARM 是一族"精简指令集计算"(Reduced Instruction Set Computing,简称 RISC)架构的统称,最初由 ARM Holding 公司设计。要理解 ARM,首先要理解它和 Intel、AMD 完全不同的商业模式。
Intel 和 AMD 是"设计并制造"CPU 的公司:它们既定义指令集架构,又自己把芯片生产出来卖给整机厂商。而 ARM Holding 只做一件事——设计架构,然后把这套架构的使用权(授权,License)卖给其他公司,比如高通(Qualcomm)、三星(Samsung)。这些拿到授权的公司再根据 ARM 的架构规范,自己去实现具体的芯片,最终生产出成品 CPU。
这种"只设计、不生产"的模式带来一个直接后果:因为许多不同的公司都在按照同一套架构规范做自己的实现,所以 ARM 架构在历史上演化出了非常多的版本,而且演化速度很快。
下面这张图直观地表示了这个商业模式:
| 对比项 | Intel / AMD 模式 | ARM 模式 |
|---|---|---|
| 谁设计架构 | 自己 | ARM Holding |
| 谁生产芯片 | 自己 | 获得授权的第三方厂商 |
| 架构版本数量 | 相对较少 | 非常多,因为授权方众多 |
| 典型代表 | x86、x86-64 | ARMv3 ~ ARMv8 及其众多分支 |
二、ARM 架构版本的演进
ARM 架构从最初非常简单的 32 位 CPU 开始,一路演化到今天最新的 64 位架构。这里给出一条时间线:
- ARMv3(1993 年):最早的一代,只支持 32 位指令和 32 位数据。
- 中间经历了 ARMv4、ARMv5、ARMv6、ARMv7 等多个世代,指令集和功能不断扩充。
- ARMv8(最新一代):引入了真正的 64 位执行模式,是目前手机、平板乃至部分笔记本、服务器上使用的主流架构。
用 ASCII 图表示这条演进主线会更直观:
ARM 架构版本演进
│
├── ARMv3 (1993 年,最早期,纯 32 位)
│
├── ARMv4 / ARMv5 / ARMv6 / ARMv7
│ (中间世代,功能逐步增强,仍以 32 位为主)
│
└── ARMv8 (最新一代)
│
├── AArch32 (兼容传统 32 位执行模式)
├── Thumb-2 (AArch32 的精简子集,16/32 位混合编码)
└── AArch64 (全新的 64 位执行模式)
需要特别说明的是,本文重点关注的是 ARMv8.2 这一代 CPU,它原生支持三种执行模式(也叫"执行状态",英文 execution state):AArch32、Thumb-2、AArch64。下面逐一详细讲解。
三、三种执行模式详解
3.1 AArch32:最经典的 32 位模式
AArch32 是最传统、最经典的执行模式。在这个模式下:
- CPU 只执行 32 位的指令;也就是说,每一条指令在内存中占用的长度固定为 323232 位(即 444 字节)。
- CPU 通过一条 32 位宽度的数据总线(bus)与主内存交换数据。
- CPU 内部使用的通用寄存器也是 32 位宽度的。
可以简单理解为:指令宽度、寄存器宽度、数据总线宽度,这三者在 AArch32 模式下是统一的,都是 323232 位。这是最"传统"的设计思路,也是早期所有 32 位 ARM CPU 天然采用的模式。
3.2 Thumb-2:为省电和省空间而生的精简模式
Thumb-2 是 AArch32 模式下的一个子集(subset)。它的设计目标非常明确:提高代码密度,也就是让同样的程序逻辑占用更少的内存空间,这对于低功耗的嵌入式系统尤其重要(比如早期的智能手表、传感器设备等,内存和存储都非常有限)。
Thumb-2 的核心特点是指令长度可变:
- 在 Thumb-2 模式下,CPU 执行的指令是 16 位和 32 位混合的——有些指令编码为 161616 位(也就是只占 222 字节),有些指令仍然是 323232 位(444 字节)。
- 虽然指令本身的编码长度可以缩短到 161616 位,但 Thumb-2 模式下 CPU 依然访问的是完整的 32 位寄存器和 32 位内存。也就是说,“变短"的只是指令本身在内存中的编码,而不是寄存器或者数据总线的宽度。
用一个简单的类比来理解:AArch32 就像永远用整张 A4 纸写一句话,不管这句话长短都占一整张纸;而 Thumb-2 则像是根据句子的长短,灵活选择用半张纸还是一整张纸,从而节省纸张(也就是节省内存空间),但读写的"内容格子”(寄存器、内存单元)大小依然不变,还是按 32 位的格子来对齐。
3.3 AArch64:现代的 64 位模式
AArch64 是目前最新、最现代的执行模式,也是当前主流移动设备和部分服务器 CPU 实际运行时最常用的模式:
- CPU 在这个模式下可以访问 64 位的通用寄存器。
- 数据通过一条 64 位宽度的数据总线在 CPU 与主内存之间传输。
64 位相比 32 位最直接的好处是可寻址的内存空间大大增加。32 位寄存器最多能表示的地址空间是 2322^{32}232 字节(约 444 GB),而 64 位寄存器理论上能表示的地址空间是 2642^{64}264 字节,这个数字远远超过了目前任何实际设备所配备的物理内存容量,因此在可预见的未来都不会成为瓶颈。
下面用一张表格总结三种执行模式的核心区别:
| 执行模式 | 指令编码长度 | 寄存器宽度 | 数据总线宽度 | 典型定位 |
|---|---|---|---|---|
| AArch32 | 固定 32 位 | 32 位 | 32 位 | 最传统的 32 位执行模式 |
| Thumb-2 | 16 位和 32 位混合 | 32 位 | 32 位 | AArch32 的子集,用于节省代码空间(低功耗嵌入式场景) |
| AArch64 | 64 位左右(固定长度指令,寄存器为 64 位) | 64 位 | 64 位 | 现代主流的 64 位执行模式 |
用 mermaid 图表示三者之间的包含和并列关系:
四、Windows 10 for ARM64 实际使用的模式
理论上 ARMv8.2 支持三种执行模式,但实际操作系统并不一定会用到全部三种。以 Windows 10 for ARM64 系统为例:
- 它实际运行时使用的是 AArch64 或 Thumb-2 这两种执行模式。
- AArch32 模式在这种系统中通常不会被使用到。
- Thumb-2 模式主要用在早期的 Windows RT 系统上(Windows RT 是微软早期针对 ARM 设备推出的一个 Windows 版本)。
也就是说,虽然 CPU 硬件层面支持三种模式,但具体某个操作系统在实际运行时会根据自己的需要,只选用其中的一部分模式。
五、异常级别(Exception Level, EL)
除了执行模式之外,ARM64 处理器当前所处的状态还由另一个维度来共同决定,那就是异常级别(Exception Level,简称 EL)。
异常级别定义的是 CPU 当前所拥有的特权等级(privilege level)。目前 ARM 架构一共定义了:
- 三个异常级别(EL0、EL1、EL2、EL3,实际上是四个编号但常合称"多级异常级别体系")
- 两种安全状态(Secure 安全世界 与 Non-secure 非安全世界,也就是常说的 TrustZone 技术所依赖的机制)
数字越大,特权越高。可以用一个金字塔结构来直观表示这种从低特权到高特权的层级关系:
对应的 ASCII 层级图(从下到上特权递增,类似一座金字塔):
┌───────────────┐
│ EL3 │ 最高特权
│ Secure Monitor│ 安全监控器
└───────────────┘
│
┌───────────────┐
│ EL2 │
│ Hypervisor │ 虚拟机监控层
└───────────────┘
│
┌───────────────┐
│ EL1 │
│ 操作系统内核 │ Kernel
└───────────────┘
│
┌───────────────┐
│ EL0 │ 最低特权
│ 用户态应用程序│
└───────────────┘
简单理解这几个级别各自的作用:
- EL0:日常使用的普通应用程序运行在这一级,权限最低,不能直接访问硬件资源。
- EL1:操作系统内核运行在这一级,比操作系统之上的应用程序拥有更高的权限,可以管理内存、调度进程等。
- EL2:如果系统上运行了虚拟化技术(比如一台物理机上同时跑多个虚拟机),负责管理这些虚拟机的 Hypervisor 就运行在这一级,它的权限比普通操作系统内核还要高。
- EL3:权限最高的一级,通常运行"安全监控器"(Secure Monitor)代码,负责在"安全世界"和"非安全世界"这两种安全状态之间进行切换。这与 ARM 的 TrustZone 安全机制紧密相关。
需要说明的是,异常级别和前面讲的执行模式(AArch32/Thumb-2/AArch64)是两个不同的维度:执行模式决定了 CPU 当前用什么样的指令编码方式和寄存器宽度来工作,而异常级别决定了 CPU 当前拥有多大的权限。两者共同组合,才完整地描述了 ARM64 处理器某一时刻的"状态"。
六、小结
- ARM 与 Intel/AMD 最大的不同在于商业模式:ARM 只设计架构、授权给第三方厂商去生产芯片,这直接导致了 ARM 架构版本众多、演化迅速。
- ARM 架构从 1993 年的 ARMv3(纯 32 位)一路演化到目前最新的 ARMv8。
- 最新的 ARMv8.2 CPU 原生支持三种执行模式:AArch32(经典 32 位)、Thumb-2(AArch32 的精简子集,16/32 位混合编码,主打省空间)、AArch64(现代 64 位模式)。
- Windows 10 for ARM64 系统实际只使用 AArch64 和 Thumb-2 两种模式,AArch32 一般不使用;Thumb-2 主要出现在早期的 Windows RT 系统中。
- 除了执行模式,ARM64 处理器当前的状态还由异常级别(EL0 ~ EL3)和安全状态(Secure / Non-secure)共同决定,异常级别数字越大,特权越高。
内存模型(Memory Models)详解
1. 为什么需要内存模型
在多处理器系统中,除了要解决"同一块内存在不同核心的缓存里数据是否一致"的问题(这个由缓存一致性协议,比如 MESI 协议来保证),还存在另一个更隐蔽的问题:内存访问的顺序。
也就是说,即使缓存一致性协议保证了"最终大家看到的数据是一样的",但是多个内存访问指令被处理器实际执行的先后顺序,可能和程序员在代码里写的顺序不一样。这种现象叫做内存重排序(Memory Reordering)。
现代 CPU 为了追求性能,会在不影响单线程程序正确性的前提下,把指令重新排序执行、或者并行化多条内存访问指令,从而更高效地利用较慢的内存总线。但是这种重排序一旦发生在多线程共享数据的场景下,就可能导致线程 A 看到的内存操作顺序,和线程 B 看到的不一致,从而引发同步问题(也就是常说的"竞态条件"的一种隐藏来源)。
内存模型(也叫内存一致性模型,Memory Consistency Model)就是 CPU 架构对外做出的一个"承诺":它规定了多线程环境下,内存读写操作的可见顺序规则。开发者只有理解了这个模型,才能正确地写出多线程同步代码。
2. 两种内存模型:强模型 vs 弱模型
| 架构 | 内存模型类型 | 特点 |
|---|---|---|
| AMD64 / x86 | 强内存模型(Strong Memory Model) | 内存访问指令基本按程序顺序(Program Order)执行 |
| ARM64 | 弱内存模型(Weak Memory Model) | 处理器可以自由地重排序内存访问指令以提高效率 |
强内存模型(Strong Memory Model),以 AMD64(x86-64)为代表:
- 内存读写指令的执行顺序,基本上和你在源代码里写的顺序一致。
- 这种模型更符合人的直觉,写多线程代码更"安全",不容易出现意料之外的重排序问题。
- 缺点:为了维持这种顺序保证,处理器在硬件层面要做更多约束,牺牲了一部分并行处理内存请求的效率,所以相对更慢。
弱内存模型(Weak Memory Model),以 ARM64 为代表: - 处理器可以按照自己的判断,重新排列内存访问指令的实际执行顺序(只要不违反单线程内部的数据依赖关系)。
- 这样做的好处是可以更好地利用内存总线的并行能力,让访存效率更高,性能更好。
- 缺点:在多线程共享数据的场景下,如果不做任何额外处理,非常容易出现"这个线程看到的操作顺序,跟另一个线程看到的不一样"的问题,给多处理器软件开发带来大量的同步难题。
可以用一句话总结这两者的取舍关系:
强模型⇒更符合直觉、更稳定,但更慢弱模型⇒更快,但需要开发者手动处理同步问题 \text{强模型} \Rightarrow \text{更符合直觉、更稳定,但更慢} \qquad \text{弱模型} \Rightarrow \text{更快,但需要开发者手动处理同步问题} 强模型⇒更符合直觉、更稳定,但更慢弱模型⇒更快,但需要开发者手动处理同步问题
3. 内存屏障(Memory Barrier)
既然弱内存模型的处理器可以自由重排序内存访问,那么当代码里确实需要"某些内存操作必须按顺序发生"的时候(比如经典的多线程同步场景),就需要一种机制来强制阻止重排序——这就是内存屏障(Memory Barrier)。
内存屏障是 CPU 提供的一类特殊机器指令,它的作用是:阻止处理器把屏障指令之前和之后的内存访问操作进行重排序。可以把它想象成在指令流中间放了一道"关卡",关卡前面的内存操作必须先全部完成、对其他处理器可见,然后关卡后面的内存操作才能开始执行(具体保证的强弱程度因屏障类型而异,比如 Acquire 屏障、Release 屏障、Full Fence 等,但核心思想都是"限制重排序范围")。
内存屏障是有性能代价的(它们本质上是在牺牲部分并行性来换取顺序保证),因此只有在真正需要的地方才会使用——比如 Windows 内核里的同步原语,包括:
- 自旋锁(Spinlock)
- 互斥锁(Mutex)
- 推锁(Pushlock)
- 等等
这些同步原语的实现内部,必然会用到内存屏障指令,因为它们的正确性完全依赖于"多个处理器核心之间,对共享标志位/锁状态的读写顺序必须是可预期的"。
4. 与本章"硬件旁路漏洞"和 ARM64 JIT 的联系
结合本章前面讨论的背景,这里补充两个实际应用场景:
(1)ARM64 JIT(即时编译器)为什么总是插入内存屏障?
书中提到的场景是:当 ARM64 的 JIT 引擎(jitter)把 x86 代码翻译成 ARM64 代码执行时,它无法在编译期确定这段代码将来会不会被多个线程并行执行(也就是说它不知道会不会有潜在的同步问题)。
由于 x86 本身遵循强内存模型,原始的 x86 代码天然就不存在重排序问题;但翻译成 ARM64 代码后,如果不加任何限制,ARM64 处理器的弱内存模型特性就可能引入原本 x86 代码中不存在的重排序行为,破坏程序的原有语义。因此,为了保证"在 ARM64 上运行的、翻译自 x86 的代码"依然表现得像强内存模型一样,ARM64 JIT 在多处理器环境下会保守地、始终插入内存屏障指令,以模拟出 x86 那种顺序执行的效果。这是一种"以性能换正确性"的保守策略。
(2)为什么这属于"多处理器同步问题",而不是"乱序执行(Out-of-Order Execution)问题"?
本章前面讲到的乱序执行(Out-of-Order Execution)是单个处理器核心内部为了提高指令级并行度而做的指令调度优化,它对单线程程序的可见结果是透明的(处理器会保证乱序执行的最终效果等价于顺序执行)。
而内存重排序讨论的是多个处理器核心之间,某个核心的内存操作顺序对其他核心的可见性问题——这是一个多处理器/多线程层面的问题,两者的作用范围和产生根源不同,不能混为一谈。x86 由于遵循强内存模型,天然不存在这类多核间的重排序可见性问题。
5. 编译器重排序(Compiler Reordering)
除了处理器会做内存重排序之外,编译器在编译期也会做类似的事情。编译器为了生成更高效的目标代码,可能会在保证单线程语义不变的前提下:
- 调整源代码中内存读写语句的实际顺序;
- 甚至直接消除掉一些它认为"多余"的内存访问(比如把一个变量的读取结果缓存在寄存器里,反复使用,而不是每次都重新从内存读取)。
这种发生在编译阶段的重排序叫做编译器重排序(Compiler Reordering),用来和前面讨论的**处理器重排序(Processor Reordering)**做区分:
| 重排序类型 | 发生阶段 | 发生主体 | 典型应对手段 |
|---|---|---|---|
| 处理器重排序(Processor Reordering) | 运行时 | CPU 硬件 | 内存屏障指令(Memory Barrier) |
| 编译器重排序(Compiler Reordering) | 编译时 | 编译器 | 编译器屏障 / volatile / 原子操作等 |
值得注意的是:内存屏障指令只能阻止处理器重排序,不能阻止编译器重排序。要防止编译器重排序,通常需要使用编译器特有的"编译器屏障(Compiler Barrier)"(比如 GCC/Clang 里的 asm volatile("" ::: "memory"),或 MSVC 的 _ReadWriteBarrier()),或者使用语言层面提供的原子操作(如 C++11 的 std::atomic),因为标准原子操作的实现里,通常同时包含了编译器屏障和必要的处理器内存屏障两部分语义。
6. 用图示理解重排序的发生位置
从这张图可以看出,重排序其实有两道"关卡":第一道在编译阶段(编译器重排序),第二道在处理器实际执行阶段(处理器重排序,只在弱内存模型架构上才明显存在)。要写出正确的多线程代码,这两道关卡都需要被适当地"锁住"。
7. C++ 代码示例:用内存屏障解决弱内存模型下的同步问题
下面用一个经典的例子来演示"弱内存模型下不加屏障可能出错、加了屏障之后语义才正确"这件事。这是多线程编程里非常经典的**“发布-订阅”(Publish-Subscribe)模式**,也常被称为 Double-Checked Locking / Flag-Data 模式的简化版本。
场景设定:
- 线程 A(生产者):先把数据
data写好,然后再把标志位ready设为true,表示"数据已经准备好了"。 - 线程 B(消费者):不断检查
ready是否为true,一旦发现为true,就去读取data。
在强内存模型(如 x86)上,这段代码几乎总是"恰好能正确工作",因为处理器不会把data的写入重排到ready的写入之后。
但在弱内存模型(如 ARM64)上,如果不加任何屏障,处理器可能会把ready=true这个写操作,重排到data写入完成之前对外可见!这会导致线程 B 看到ready已经是true,但读到的data却还是旧值(未初始化的值),造成难以复现的 Bug。
下面是完整、可编译运行的 C++ 代码(使用标准库<atomic>和<thread>,不依赖任何第三方库,也不使用<bits/stdc++.h>):
#include <atomic> // 提供 std::atomic 原子类型和内存序枚举(memory_order)
#include <thread> // 提供 std::thread 用于创建线程
#include <iostream> // 用于打印输出结果
#include <cassert> // 用于断言校验最终结果的正确性
// ---------------------------------------------------------
// 全局共享数据:
// data —— 生产者线程要写入、消费者线程要读取的"真实数据"
// ready —— 一个原子标志位,用来表示 data 是否已经准备好
// ---------------------------------------------------------
int data = 0; // 普通的 int,不是原子类型,代表"待发布的数据"
std::atomic<bool> ready{false}; // 原子布尔标志,false 表示数据还没准备好
// -----------------------------------------------------------------
// 生产者线程要执行的函数
// 作用:先写好 data,再"发布"这个数据(把 ready 置为 true)
// -----------------------------------------------------------------
void producer()
{
data = 42;
// 上面这一行是普通的、非原子的内存写操作。
// 在弱内存模型下,如果没有后面 release 语义的保护,
// 这次写入可能会被处理器重排到下面 store 之后才对其他核心可见。
// 使用 std::memory_order_release 语义存储 ready。
// release 语义的含义是:
// "在这条 store 指令之前的所有内存写操作(包括上面的 data = 42),
// 都必须先对其他线程可见,然后这个 store(true) 才能被其他线程观察到。"
// 这本质上就是在这里插入了一个"写内存屏障"(Store Barrier / Release Fence),
// 阻止了 data 的写入被重排到 ready 写入之后。
ready.store(true, std::memory_order_release);
}
// -----------------------------------------------------------------
// 消费者线程要执行的函数
// 作用:不断轮询 ready,直到发现数据已就绪,然后安全地读取 data
// -----------------------------------------------------------------
void consumer()
{
// 使用 std::memory_order_acquire 语义循环读取 ready,
// 直到读到 true 为止(这是一个简单的"自旋等待",spin-wait)。
//
// acquire 语义的含义是:
// "如果这条 load 指令读到了某个值(比如 true),
// 那么在这之后的所有内存读操作,都不能被重排到这次 load 之前,
// 并且能保证看到对方线程在 release 之前所做的所有写入。"
// 这本质上就是在这里插入了一个"读内存屏障"(Load Barrier / Acquire Fence)。
while (!ready.load(std::memory_order_acquire))
{
// 什么都不做,持续轮询(真实生产代码中这里通常会调用
// std::this_thread::yield() 让出 CPU,避免忙等浪费资源)
std::this_thread::yield();
}
// 因为上面用了 acquire 语义,这里读 data 的时候,
// 一定能看到生产者线程在 release 之前写入的 data = 42,
// 不会读到未初始化的旧值。
assert(data == 42 && "数据应该已经被正确同步过来了!");
std::cout << "消费者线程读取到 data = " << data << std::endl;
}
// -----------------------------------------------------------------
// 主函数:创建生产者和消费者两个线程,并等待它们执行完毕
// -----------------------------------------------------------------
int main()
{
// 创建消费者线程和生产者线程。
// 注意:谁先启动、谁先运行到具体哪一行代码,是不确定的,
// 这正是我们需要 acquire/release 语义来保证正确同步的原因,
// 而不是依赖"猜测"两个线程谁先执行完。
std::thread t_consumer(consumer);
std::thread t_producer(producer);
// 等待两个线程都执行完毕,避免主线程提前退出导致子线程被强制终止
t_producer.join();
t_consumer.join();
std::cout << "程序正常结束,没有出现数据不同步的问题。" << std::endl;
return 0;
}
代码关键点解析:
std::atomic<bool> ready:这里ready必须是原子类型,而不是普通的bool。普通变量在多线程下读写本身就是未定义行为(Data Race),而std::atomic保证了读写操作本身的原子性,同时还能携带"内存序(memory order)"语义。memory_order_release(用在生产者的store上):告诉编译器和处理器,“在这次写入ready=true之前的所有内存写操作(这里指data = 42),都必须先完成并对其他线程可见,才允许这次写入本身被其他线程看到”。这本质上对应文中所说的"内存屏障"——它同时抑制了编译器重排序和处理器重排序。memory_order_acquire(用在消费者的load上):和release是配对使用的。它保证"一旦读到了release写入的值(true),那么在这次读取之后的所有内存读操作,都能看到对方线程在release之前的所有写入结果"。这就是消费者侧的"读屏障"。- release-acquire 配对:这一对内存序合起来,就构成了一次跨线程的"同步点(Synchronizes-with)"关系——保证了即使底层硬件是弱内存模型(比如 ARM64),
data的写入也一定会在ready变为可见的true之前,被消费者线程正确观察到。如果去掉这两个memory_order参数(换成默认的memory_order_seq_cst也可以,但如果错误地换成了根本不提供顺序保证的方式,比如直接用非原子的bool变量),在弱内存模型的机器上就有可能出现前面说的"看到ready=true,却读到data未初始化值"的问题。 - 这段代码可以直接用标准 C++11 及以上的编译器编译运行,例如:
g++ -std=c++11 -O2 -pthread memory_barrier_demo.cpp -o demo ./demo
8. 小结
| 概念 | 一句话理解 |
|---|---|
| 缓存一致性协议(如 MESI) | 保证同一份数据在不同核心缓存里"最终一致" |
| 内存一致性模型(Memory Model) | 规定多核之间"内存操作可见顺序"的规则 |
| 强内存模型(如 x86/AMD64) | 基本按程序顺序执行内存操作,更直观但更慢 |
| 弱内存模型(如 ARM64) | 允许处理器自由重排内存操作以提升性能,但需手动同步 |
| 内存屏障(Memory Barrier) | 阻止处理器在屏障两侧重排内存访问的特殊指令 |
| 处理器重排序 | 运行时由 CPU 硬件引起 |
| 编译器重排序 | 编译期由编译器优化引起 |
理解这套内存模型的核心目的,就是明白:“程序看起来是顺序执行的"这件事,在多线程、多核环境下并不是天然成立的,它是硬件和软件(编译器)共同"努力”(有时是靠开发者显式插入屏障/原子操作)才营造出来的假象。写跨平台的多线程/内核代码时,尤其要小心不能想当然地假设所有架构都像 x86 一样"老实"。
ARM64 平台上运行 ARM32 应用程序的模拟机制
一、整体思路:和 x86 on AMD64 是同一套框架
在 ARM64 系统上运行 32 位 ARM(也就是 ARM32)应用程序,采用的思路和之前讲过的"AMD64 系统上运行 32 位 x86 程序"几乎是一模一样的,都是靠 WoW64(Windows-on-Windows 64,即 64 位系统上运行 32 位程序的兼容层)这套机制来实现的。
不过,虽然大的框架相同,ARM 平台在硬件层面切换执行状态的方式和 x86/AMD64 平台有一个关键区别,这也是本节要重点讲清楚的地方。
二、硬件基础:ARM64v8 CPU 可以在两种执行状态之间切换
前面已经讲过,ARM64v8 CPU 原生支持多种执行状态。这里涉及到的是其中两种:
- AArch64:现代 64 位执行状态。
- Thumb-2:AArch32 的一个子集,支持 16 位和 32 位混合编码的指令。
关键的一点是:ARM64v8 CPU 具备动态切换这两种执行状态的能力。换句话说,CPU 可以直接在硬件层面执行 32 位指令,而不需要额外的软件翻译或者模拟——这是 ARM32 模拟能够高效运行的硬件基础。
三、和 AMD64 平台最大的不同:用户态不能直接切换执行模式
在 AMD64(也就是 x86-64)平台上,CPU 处于用户态时,可以通过一条专门的指令直接完成 32 位和 64 位执行模式之间的切换,整个过程不需要惊动操作系统内核。
但是 ARM64 平台不一样:用户态代码没有办法通过某条指令自行切换执行模式。也就是说,一个运行在用户态(也就是异常级别 EL0)的程序,无法凭自己的力量把 CPU 从 AArch64 状态切换到 Thumb-2 状态,或者反过来。
正因为用户态没有这个权限,所以 WoW64 这一层软件必须换一种思路:向 NT 内核请求,由内核出面完成这次执行模式的切换。这就是本节要详细讲解的整个流程。
下面用一张表格总结两个平台在切换执行模式这件事上的差异:
| 平台 | 谁来完成模式切换 | 切换方式 |
|---|---|---|
| AMD64(x86-64) | 用户态自己完成 | 执行一条专门的模式切换指令,不需要内核介入 |
| ARM64 | 必须由 NT 内核完成 | WoW64 层向内核发起请求,内核完成切换后再返回用户态 |
四、切换流程详细拆解
整个从"64 位模拟器代码"切换到"32 位 ARM 代码"的过程,可以拆分成几个清晰的步骤。为了方便理解,先给出整体流程的 mermaid 时序图,再逐步拆解每一步具体在做什么。
接下来把每一步拆开,逐条讲清楚具体含义。
步骤 1:BtCpuSimulate 函数是整个模拟流程的入口
BtCpuSimulate 是由 Wowarmhw.dll 这个动态链接库导出的一个函数。Wowarmhw.dll 就是专门负责"在 ARM64 上模拟 ARM32"这件事的 CPU 模拟器组件(这里的命名逻辑和之前讲的 x86 模拟器思路是一致的:一个专门的 DLL 负责具体某种架构的模拟逻辑)。
当需要从 64 位模拟器代码切换到 32 位 ARM 代码去执行时,就会调用这个函数,由它来完成后续一系列的准备工作。
步骤 2:保存 64 位非易失性寄存器
在正式切换之前,BtCpuSimulate 会先把当前 非易失性(nonvolatile,也叫 callee-saved,即"被调用者需要负责保存和恢复"的寄存器)的 AArch64 寄存器,保存到 64 位栈(stack)中。
这一步的目的很直接:因为接下来 CPU 要切换到 32 位模式去执行别的代码,等切换回来之后,还需要能够无缝地恢复到刚才 64 位代码执行的现场,所以必须先把这些寄存器的值存起来,防止被 32 位代码执行期间覆盖掉。
步骤 3:恢复 32 位上下文
保存完 64 位现场之后,紧接着会从 WoW64 CPU Area(这是 WoW64 层专门用来保存 32 位程序上下文的一块内存区域)中,把之前保存的 32 位寄存器上下文重新加载出来。
这一步相当于把"32 位程序上次执行到哪里、各个寄存器的值是什么"这些信息准备好,等真正切换到 32 位模式之后,CPU 就能从正确的地方继续执行。
步骤 4:发起一次特殊的系统调用(系统调用号 -1)
准备工作做完之后,代码会主动触发一次系统调用,但这次系统调用比较特殊——它使用的系统调用号是一个无效值:−1-1−1。
这里要理解一个关键点:这次系统调用的目的不是为了让内核执行某个具体的系统服务功能,而是故意用一个不存在的系统调用号,把控制权"人为地"转交给内核,本质上是在借用系统调用这个机制,向内核传递一个"我需要切换执行模式"的信号。
步骤 5:NT 内核识别出这是一次模式切换请求
在 ARM64 架构下有一个和 x86/AMD64 不太一样的设计细节:NT 内核的异常处理程序和系统调用处理程序是同一套代码。也就是说,不管是发生了 CPU 异常,还是应用程序主动发起系统调用,最终都会走到同一个入口去处理。
当内核的这个处理程序被触发之后,它会先判断:这次触发是不是因为一次系统调用引起的。如果是,它就会进一步去检查这次系统调用具体使用的系统调用号是多少。
一旦检测到这个系统调用号正好是特殊的 −1-1−1,内核就明白了:这并不是一次普通的系统服务请求,而是 WoW64 层发来的"请求切换执行模式"的信号。
步骤 6:调用 KiEnter32BitMode,正式完成切换
确认是模式切换请求之后,内核会调用一个专门的例程——KiEnter32BitMode。这个例程做的事情是:
- 把"下层异常级别"(也就是用户态所在的那个更低的异常级别,通常是 EL0)当前使用的执行状态,设置为 AArch32。
- 完成设置之后,内核会结束(dismiss)当前这次异常处理,也就是把控制权交还回去。
- 最终,CPU 带着新设置好的执行状态返回用户态。
由于此时内核已经把下层异常级别的执行状态设为了 AArch32,所以当控制权真正回到用户态时,CPU 就已经切换成了 32 位(AArch32/Thumb-2)状态,代码从这一刻开始,就以 32 位模式执行 ARM32 程序原本的指令了。
五、控制权何时会再交还给模拟器
代码进入 AArch32 状态之后,会一直以 32 位模式正常执行,直到遇到下面两种情况之一,控制权才会重新交还给模拟器:
- 发生了一次异常(exception)。
- 32 位代码主动发起了一次系统调用(system call)。
这一整套"异常和系统调用如何被派发(dispatch)回模拟器处理"的机制,和之前讲过的、AMD64 平台上 x86 模拟器所使用的机制,在设计思路上是完全一致的——都是通过异常/系统调用这个统一的入口,把控制权从"被模拟的低位代码"重新交还给"负责模拟的高位代码"。
六、完整流程 ASCII 总览图
把上面几个步骤串成一张不太拥挤的 ASCII 流程图,帮助从整体上把握这个过程:
[64 位模拟器 Wowarmhw.dll,运行在 AArch64 状态]
│
│ 调用 BtCpuSimulate
▼
[保存非易失性 AArch64 寄存器到 64 位栈]
│
▼
[从 WoW64 CPU Area 恢复 32 位上下文]
│
▼
[发起系统调用,系统调用号 = -1]
│
▼
[NT 内核异常/系统调用处理程序被触发]
│
▼
[检查系统调用号是否为 -1]
│
├── 否 ──> 按普通系统调用正常处理
│
└── 是
▼
[调用 KiEnter32BitMode]
│
▼
[将下层异常级别的执行状态
设置为 AArch32]
│
▼
[结束异常处理,返回用户态]
│
▼
[CPU 以 AArch32/Thumb-2 状态<br>开始执行 32 位 ARM 代码]
│
│ 遇到异常 或 系统调用
▼
[控制权交还给模拟器,继续下一轮]
七、和 x86 on AMD64 模拟机制的对比小结
为了加深理解,最后用一张表格对比一下 ARM32 on ARM64 与 x86 on AMD64 这两套模拟机制的异同:
| 对比维度 | x86 on AMD64 | ARM32 on ARM64 |
|---|---|---|
| 执行模式切换发起方 | 用户态自己 | 用户态无法自己完成,必须请求内核 |
| 切换所用手段 | 专门的模式切换指令 | 构造一次特殊系统调用(调用号 -1),触发内核处理 |
| 内核参与程度 | 不需要内核参与 | 必须由内核的 KiEnter32BitMode 例程完成实际切换 |
| 模拟器 DLL 命名思路 | 针对 x86 的专用模拟器 | Wowarmhw.dll,针对 ARM32 的专用模拟器 |
| 控制权交还模拟器的时机 | 发生异常或系统调用 | 发生异常或系统调用(机制相同) |
可以看出,两套机制在"如何把控制权交还给模拟器"这一点上是完全一致的,真正的差异只体现在"如何从 64 位模式切换到 32 位模式"这一步——AMD64 平台上用户态自己就能搞定,而 ARM64 平台上必须借助内核之手才能完成,这也是本节内容需要重点掌握的核心区别。
ARM64 平台上的 x86 模拟(X86 Simulation on ARM64)详解
1. 为什么需要"模拟"而不是"翻译执行"
在 ARM64 平台上运行 x86 程序,靠的是一个叫 Xtajit.dll 的组件,它扮演的角色是"x86 到 ARM64 的 CPU 模拟器"。
要理解它的特殊之处,得先明白一个前提:ARM64 处理器在硬件层面完全不认识 x86 指令。这跟其他一些"二进制翻译器"不一样——有些翻译场景下,源指令集和目标硬件之间可能存在某种硬件层面的兼容或辅助执行通道,可以让部分指令直接跑在硬件上;但 x86 和 ARM64 是两套完全不同的指令集架构(ISA),ARM64 的译码器、执行单元根本没有能力识别一条 x86 指令长什么样。
所以 Xtajit.dll 没有"直接执行"这条路可走,它必须做两件事:
- 实现一个完整的 x86 模拟器(Emulator)——用软件的方式去理解 x86 指令的语义。
- 实现一个即时编译器(JIT,代码里叫 jitter)——把一段一段的 x86 机器码,翻译(编译)成等价的 AArch64(ARM64)机器码,然后直接把翻译出来的这段 ARM64 代码放到 CPU 上去跑。
也就是说,x86 代码本身从来没有被 ARM64 CPU 执行过;被执行的永远是翻译后的 ARM64 代码。这个"翻译"的过程,就是这一整套机制的核心。
2. 模拟器进程初始化:BtCpuProcessInit
当系统要为一个新的 WoW64 进程(也就是要在 ARM64 上跑的 32 位 x86 进程)启动模拟环境时,会调用模拟器的进程初始化函数——BtCpuProcessInit。这个函数做的事情可以拆成几个步骤来理解。
2.1 构建 Jitter 的注册表主键
初始化函数会拼接出这样一个注册表路径:
HKLM\SOFTWARE\Microsoft\Wow64\x86\xtajit\⟨主进程映像名⟩ \text{HKLM}\backslash\text{SOFTWARE}\backslash\text{Microsoft}\backslash\text{Wow64}\backslash\text{x86}\backslash\text{xtajit}\backslash\langle\text{主进程映像名}\rangle HKLM\SOFTWARE\Microsoft\Wow64\x86\xtajit\⟨主进程映像名⟩
也就是把固定前缀 HKLM\SOFTWARE\Microsoft\Wow64\x86\xtajit 和当前进程的主可执行文件名拼在一起,形成一个"针对这个具体程序"的专属配置项。
为什么要针对具体程序设置专属配置? 因为不同的 x86 程序,可能对模拟器的行为有不同的需求(比如有的程序对多处理器内存顺序特别敏感,有的程序希望编译出的代码块尽量大以减少翻译开销等),所以系统给每个程序留了单独定制的空间。
如果这个键真的存在,模拟器就会从里面读取一系列配置信息,其中比较重要的两项是:
- 多处理器兼容性(multiprocessor compatibility)配置——控制前面讲到的内存屏障相关行为,决定翻译出来的代码要不要按照"更保守、更像强内存模型"的方式插入同步指令。
- JIT 代码块阈值大小(JIT block threshold size)——控制一次翻译"多大一块"x86 代码,这直接影响翻译效率和运行性能之间的平衡。
除此之外,模拟器还会去查询应用兼容性数据库(Application Compatibility Database),获取额外的兼容性配置项——这是 Windows 里专门用来"记录某些特定程序需要哪些特殊兼容性处理"的数据库,跟前面提到的这个专属注册表项是两个不同的信息来源,两者互相补充。
2.2 分配并编译 Syscall 页
接下来,初始化函数会分配并编译一个叫 Syscall 页(Syscall Page) 的内存区域。
顾名思义,这一页内存的作用是用来发出 x86 系统调用。当被翻译执行的 x86 代码需要进行系统调用(比如打开文件、分配内存等,本质上是要陷入内核)的时候,就会走到这一页预先编译好的代码里去,由它负责把请求正确地转发给底层的系统调用处理流程。
这一页会通过一个叫 Wow64Transition 的变量,跟 Ntdll(Windows 的原生系统调用接口库)关联起来——Ntdll 内部原本是给原生 ARM64 程序准备系统调用桩代码的,但对于运行在模拟器下的 x86 程序,这些桩代码需要改道,先经过 Syscall 页做一次"翻译过渡",再进入真正的系统调用路径。可以把 Wow64Transition 理解成一个"路标"变量,它告诉 Ntdll:“如果你是在模拟环境下,系统调用请先跳到这个地址(Syscall 页)去处理”。
2.3 判断是否可以使用 XTA 缓存
到这一步,初始化流程还会去判断当前进程是否可以使用 XTA 缓存(下一节详细讲)。这个判断结果会影响后续代码块是从头翻译,还是可以直接复用之前翻译好的结果。
3. 两种代码缓存:内部缓存 vs 外部 XTA 缓存
模拟器为了避免"同一段 x86 代码被反复翻译"造成的性能浪费,用了两级缓存机制来存放已经翻译好的代码块(这些翻译好的代码块,统称为 jitted blocks,即"被 JIT 编译过的代码块")。
| 缓存类型 | 归属范围 | 内容来源 | 存储位置 |
|---|---|---|---|
| 内部缓存(Internal Cache) | 按线程分配(per-thread) | 模拟器在翻译当前线程正在执行的 x86 代码时动态生成 | 进程内存(运行时) |
| 外部 XTA 缓存(External XTA Cache) | 按 x86 镜像文件分配(per-image) | 由 XtaCache 服务在后台懒惰式(lazily)生成 | 磁盘上的外部缓存文件 |
内部缓存是"当场翻译、当场用"的产物:每个线程都有自己独立的一份,模拟器在执行 x86 代码的过程中,遇到还没翻译过的代码块,就现场翻译,翻译结果存进这个线程私有的缓存里,下次这个线程再执行到同一段代码时就能直接复用,不用重新翻译。因为是按线程分配的,所以不同线程之间不共享这份缓存。
外部 XTA 缓存则是"更长久、更大范围"的优化:它不是针对某一次运行、某一个线程的,而是针对某一个具体的 x86 可执行镜像文件(比如某个 exe 或 dll)。它由一个专门的系统服务——XtaCache 服务——在后台以"懒惰"的方式生成,也就是说不需要程序主动请求,服务会在合适的时机(比如程序运行过程中)把翻译好的代码块持久化保存到磁盘上的一个外部缓存文件里。这样一来,哪怕程序这次退出了、下次重新启动,甚至是另一个进程运行同一个 x86 镜像,都可以直接复用这份磁盘上已经翻译好的代码,而不用把整个翻译过程重新做一遍。
用一张图来说明这两级缓存的关系和数据流向:
简单理解就是:内部缓存解决的是"同一个线程反复执行同一段代码"的重复翻译问题,外部 XTA 缓存解决的是"跨进程、跨启动次数"的重复翻译问题,两者配合,让翻译这件"昂贵的事情"尽量只做一次。
4. CHPE 位图(CHPE Bitmap)的分配
进程初始化流程中,还会分配一个叫 CHPE 位图(Compiled Hybrid Executable Bitmap) 的数据结构。这里先简单理解它是做什么用的,具体的 CHPE 技术细节会在后面章节详细展开。
CHPE 位图覆盖的范围,是一个 32 位进程理论上能用到的整个 4 GB 地址空间:
232 字节=4 GB 2^{32}\ \text{字节} = 4\ \text{GB} 232 字节=4 GB
位图用一个比特(bit) 来标记"某一页内存里是否包含 CHPE 代码"(CHPE 是一种可以直接在 ARM64 硬件上运行、同时又兼容 x86 调用约定的混合代码格式)。也就是说,位图上的每一位,对应地址空间里的一页内存,如果这一位是 1,说明这一页里放的是 CHPE 代码,模拟器在遇到这段地址的时候,处理方式就会跟普通 x86 代码不一样(不需要走完整的模拟翻译流程,因为 CHPE 代码本身已经能被 ARM64 硬件直接理解)。
可以把这个位图想象成一张"标记地图":
4 GB 地址空间(按页划分)
+------+------+------+------+------+------+-----+
| 页0 | 页1 | 页2 | 页3 | 页4 | 页5 | ... |
+------+------+------+------+------+------+-----+
| | | | | |
v v v v v
位图: [ 0 ][ 0 ][ 1 ][ 1 ][ 0 ][ 0 ] ...
^ ^
这两页被标记为 CHPE 代码页
5. 线程初始化:BtCpuThreadInit
进程级别的初始化完成之后,每当有一个新线程要在模拟环境下运行 x86 代码,还需要经过线程级别的初始化——对应的函数是 BtCpuThreadInit。
这个函数主要做两件事:
- 初始化编译器(Compiler)本身——为这个线程准备好 JIT 翻译所需要的运行时状态。
- 在原生栈(Native Stack)上分配"per-thread CPU 状态"结构——这是一个非常重要的数据结构,本质上是给这个线程模拟出一整套"虚拟的 x86 CPU",里面至少包含:
| per-thread CPU 状态包含的内容 | 作用说明 |
|---|---|
| per-thread 编译器状态 | 保存这个线程在做 JIT 翻译时用到的内部状态 |
| x86 线程上下文(x86 thread context) | 模拟 x86 通用寄存器等上下文信息,相当于"这个线程的 x86 视角当前寄存器值是什么" |
| x86 代码发射器状态(code emitter state) | JIT 翻译过程中,用于生成目标 ARM64 指令流的中间状态 |
| 内部代码缓存(internal code cache) | 前面讲到的、按线程私有的已翻译代码块缓存 |
| 被模拟的 x86 CPU 配置 | 包括段寄存器(segment registers)、浮点单元状态(FPU state)、模拟出来的 CPUID 信息等 |
之所以要在原生栈上分配这个结构,是因为这个数据结构需要跟当前线程的执行紧密绑定、访问速度要快,放在栈上便于快速定位和读写,同时也符合"线程私有、线程结束自动释放"的生命周期特点。
可以这样理解这个 per-thread CPU 状态结构:它就是模拟器为每个线程凭空造出来的一颗"虚拟 x86 CPU"——因为真实硬件上没有 x86 CPU,所有 x86 程序原本期望能直接从硬件寄存器读到的东西(通用寄存器值、段寄存器、FPU 状态、CPUID 返回值等),现在都必须由模拟器自己在软件层面维护一份,并且要在每次翻译执行代码块的过程中,正确地读写这份"软件模拟出来的寄存器堆",这样才能让上层的 x86 程序感觉不到自己其实并没有跑在真正的 x86 硬件上。
6. 用 C++ 模拟理解 per-thread CPU 状态和两级缓存的关系
下面用一段简化的 C++ 代码,演示"per-thread CPU 状态"和"内部缓存/外部缓存查找逻辑"背后的基本思想(这不是 Xtajit.dll 的真实源码,而是为了帮助理解其核心机制而写的教学示例,逻辑上尽量贴近前面描述的行为)。
#include <iostream> // 用于打印调试信息
#include <unordered_map> // 用于模拟"代码块地址 -> 已翻译代码"的缓存表
#include <string> // 用于表示翻译后的代码内容(这里简化为字符串代表一段"已编译好的ARM64代码")
#include <optional> // 用于表示"查找结果可能不存在"
// ---------------------------------------------------------------
// 用一个结构体模拟"per-thread CPU 状态",对应文中说到的:
// x86 线程上下文、代码发射器状态、内部代码缓存、模拟 CPU 配置等
// ---------------------------------------------------------------
struct X86Registers
{
// 模拟部分 x86 通用寄存器(这里只列出常见的几个,真实情况远比这复杂)
unsigned int eax = 0;
unsigned int ebx = 0;
unsigned int ecx = 0;
unsigned int edx = 0;
unsigned int eip = 0; // 指令指针,指向当前执行到的 x86 代码地址
};
struct EmulatedCpuConfig
{
// 模拟段寄存器(segment registers),x86 里用于内存分段寻址
unsigned short cs = 0, ds = 0, es = 0, fs = 0, gs = 0, ss = 0;
// 模拟 FPU(浮点运算单元)状态,这里简化成一个标志位表示"是否已初始化"
bool fpu_initialized = false;
// 模拟 CPUID 返回信息(真实 CPU 上,CPUID 指令返回处理器厂商、特性位等信息,
// 这里用一个简单字符串代表模拟器伪造出来的 CPUID 厂商字符串)
std::string emulated_cpuid_vendor = "GenuineIntel";
};
// ---------------------------------------------------------------
// per-thread CPU 状态:对应文中说的"分配在原生栈上的重要数据结构"
// ---------------------------------------------------------------
class PerThreadCpuState
{
public:
X86Registers x86Context; // x86 线程上下文
EmulatedCpuConfig cpuConfig; // 被模拟的 x86 CPU 配置(段寄存器、FPU、CPUID)
std::unordered_map<unsigned int, std::string> internalCache; // 内部代码缓存:key 是 x86 代码块起始地址,value 是翻译后的 ARM64 代码(这里简化为字符串)
// 在内部缓存里查找某个 x86 地址对应的已翻译代码块
// 如果找到了,返回翻译好的代码;找不到就返回空
std::optional<std::string> lookupInternalCache(unsigned int x86Address) const
{
auto it = internalCache.find(x86Address);
if (it != internalCache.end())
{
return it->second; // 命中,返回已翻译的代码
}
return std::nullopt; // 未命中
}
// 把新翻译好的代码块写入内部缓存,供本线程后续复用
void storeToInternalCache(unsigned int x86Address, const std::string& translatedCode)
{
internalCache[x86Address] = translatedCode;
}
};
// ---------------------------------------------------------------
// 用一个全局的 unordered_map 模拟"外部 XTA 缓存"(真实情况下是磁盘上的
// per-image 缓存文件,由 XtaCache 服务懒惰生成;这里简化为进程内的一个
// 静态表,代表"这个 x86 镜像已经被翻译过、且已经持久化保存的代码块")
// ---------------------------------------------------------------
std::unordered_map<unsigned int, std::string> g_externalXtaCache;
// 模拟"现场用 Jitter 翻译一段 x86 代码"的过程。
// 真实的 Jitter 会解析 x86 opcode 字节流,生成等价语义的 AArch64 指令;
// 这里为了聚焦讲解缓存和状态管理逻辑,直接返回一个占位字符串代表翻译结果。
std::string jitTranslateX86Block(unsigned int x86Address)
{
std::cout << "[Jitter] 正在翻译地址 0x" << std::hex << x86Address
<< std::dec << " 处的 x86 代码块...\n";
return "ARM64_CODE_FOR_0x" + std::to_string(x86Address);
}
// ---------------------------------------------------------------
// 核心函数:模拟"执行一个 x86 代码块"时,查找/生成翻译结果的完整流程,
// 对应前文 mermaid 流程图里描述的三级查找顺序:
// 内部缓存 -> 外部 XTA 缓存 -> 现场翻译
// ---------------------------------------------------------------
std::string executeX86Block(PerThreadCpuState& cpuState, unsigned int x86Address)
{
// 第一步:先查本线程私有的内部缓存
if (auto cached = cpuState.lookupInternalCache(x86Address))
{
std::cout << "[内部缓存命中] 地址 0x" << std::hex << x86Address << std::dec << "\n";
return *cached;
}
// 第二步:内部缓存没有,再查外部 XTA 缓存(模拟磁盘上 per-image 的持久化缓存)
auto extIt = g_externalXtaCache.find(x86Address);
if (extIt != g_externalXtaCache.end())
{
std::cout << "[外部 XTA 缓存命中] 地址 0x" << std::hex << x86Address << std::dec << "\n";
// 从外部缓存加载后,顺便写入本线程的内部缓存,方便下次更快命中
cpuState.storeToInternalCache(x86Address, extIt->second);
return extIt->second;
}
// 第三步:两级缓存都没有,只能现场用 Jitter 翻译
std::string translated = jitTranslateX86Block(x86Address);
// 翻译结果写入本线程内部缓存
cpuState.storeToInternalCache(x86Address, translated);
// 同时也模拟 XtaCache 服务"懒惰持久化"的行为:把结果写入外部缓存,
// 这样以后同一个镜像里的其他线程/进程也能直接复用
g_externalXtaCache[x86Address] = translated;
return translated;
}
int main()
{
// 模拟一个线程的 CPU 状态(对应 BtCpuThreadInit 分配的 per-thread CPU 状态)
PerThreadCpuState threadCpuState;
// 初始化一些模拟寄存器和 CPU 配置的初始值,模拟 BtCpuThreadInit 的部分工作
threadCpuState.x86Context.eip = 0x1000; // 假设线程从地址 0x1000 开始执行
threadCpuState.cpuConfig.fpu_initialized = true;
std::cout << "=== 第一次执行地址 0x1000 处的代码块 ===\n";
std::string result1 = executeX86Block(threadCpuState, 0x1000);
std::cout << "得到翻译结果: " << result1 << "\n\n";
std::cout << "=== 第二次执行同一地址 0x1000(应命中内部缓存) ===\n";
std::string result2 = executeX86Block(threadCpuState, 0x1000);
std::cout << "得到翻译结果: " << result2 << "\n\n";
// 模拟"另一个线程"访问同一个 x86 镜像里已经被翻译并持久化到外部缓存的代码块
std::cout << "=== 模拟另一个线程首次访问 0x1000(内部缓存为空,但外部 XTA 缓存已有) ===\n";
PerThreadCpuState anotherThreadCpuState; // 全新的、内部缓存为空的线程状态
std::string result3 = executeX86Block(anotherThreadCpuState, 0x1000);
std::cout << "得到翻译结果: " << result3 << "\n";
return 0;
}
代码关键点解析:
X86Registers和EmulatedCpuConfig:分别对应文中提到的"x86 线程上下文"和"被模拟的 x86 CPU 配置(段寄存器、FPU 状态、模拟 CPUID)"。因为 ARM64 硬件根本没有 x86 的寄存器堆,所以这些状态必须完全由软件维护,用普通的成员变量来"扮演"原本应该存在于硬件里的寄存器。PerThreadCpuState类:对应文中说的"分配在原生栈上的 per-thread CPU 状态",这是整个模拟器里最核心的每线程数据结构,把 x86 上下文、CPU 配置、内部代码缓存都聚合在一起,天然就是"线程私有"的(因为它是这个线程自己的局部对象)。internalCache(std::unordered_map<unsigned int, std::string>):模拟"内部缓存",key 用 x86 代码块的起始地址表示,value 用翻译后的代码表示。用哈希表是为了体现"根据地址快速查找是否已经翻译过"这个核心诉求,对应真实场景里模拟器需要极快地判断"这段代码是不是已经翻译过了"。g_externalXtaCache(全局的unordered_map):用来模拟"外部 XTA 缓存",注意它是全局的、不属于任何一个线程,这对应文中说的"外部 XTA 缓存是按镜像文件(per-image)分配的,而不是按线程分配的",所以不同线程(threadCpuState和anotherThreadCpuState)都能共享同一份g_externalXtaCache。executeX86Block函数:这是整个示例的核心,完整还原了前面 mermaid 图里画的三级查找顺序——先查内部缓存,再查外部 XTA 缓存,最后才现场调用 Jitter 翻译,并且翻译完成后会同时写回内部缓存和外部缓存,模拟真实场景中"翻译一次、多处复用"的设计目的。- 运行效果说明:主函数里演示了三种情况——第一次执行某地址触发真实翻译;第二次执行同一地址命中本线程内部缓存;模拟另一个线程访问同一地址时,虽然它自己的内部缓存是空的,但因为外部 XTA 缓存里已经有上次翻译的结果,所以依然可以直接复用,不需要重新翻译。这正好对应了前面表格里"内部缓存 vs 外部 XTA 缓存"在归属范围上的核心差异。
该代码可以直接用标准 C++17 编译运行(用到了std::optional):
g++ -std=c++17 -O2 arm64_x86_sim_demo.cpp -o demo
./demo
7. 整体流程小结
把这一节讲到的内容串起来,可以用下面这张图总结从进程启动到执行一段 x86 代码的整体过程:
| 阶段 | 关键函数/结构 | 核心作用 |
|---|---|---|
| 进程初始化 | BtCpuProcessInit | 读取配置、准备 Syscall 页、判断 XTA 缓存可用性、分配 CHPE 位图 |
| 线程初始化 | BtCpuThreadInit | 初始化编译器、分配 per-thread CPU 状态 |
| 代码执行 | 内部缓存 / XTA 缓存 / Jitter | 按"缓存优先、翻译兜底"的顺序获得可执行的 ARM64 代码 |
整体来看,这套机制的设计思路非常清晰:能不翻译就不翻译(优先查两级缓存),必须翻译时才现场调用 Jitter,并且把每次翻译的成果尽量长久地保留下来(内部缓存服务当前线程,外部 XTA 缓存服务未来所有运行同一镜像的场景),从而在"完全没有硬件 x86 支持"这个前提下,尽量把模拟执行的性能损耗降到最低。
模拟器的镜像加载通知机制(Image Load Notification)
一、为什么模拟器需要知道"有新镜像被加载"这件事
这里说的"镜像"(image),指的就是可执行文件或者动态链接库被加载进程地址空间之后,在内存里对应的那一份映射(比如一个 .exe 或者 .dll 文件被加载运行时,会被映射到进程的虚拟地址空间中)。
x86-on-ARM64 这个 CPU 模拟器,有一个和其他普通二进制翻译器(binary translator)非常不一样的地方:只要进程地址空间里有新的镜像被映射进来,模拟器就必须被第一时间告知,哪怕这个新加载的镜像是 CHPE 版本的 Ntdll,也不例外。
之所以要强调"哪怕是 CHPE Ntdll 也不例外",是因为 CHPE Ntdll 属于一种比较特殊的镜像(它是一份既包含普通 x86 代码、又包含 CHPE 编译代码的混合模块),很容易被误以为不需要通知,但实际上这类镜像同样必须走通知流程,否则模拟器内部维护的一些数据就会和实际情况不一致。
二、通知是怎么触发的:WoW64 核心拦截 NtMapViewOfSection
要理解这个通知机制是怎么工作的,需要先知道一个背景知识:32 位程序在向操作系统申请"把某个文件映射到自己的地址空间"时,走的是一个叫 NtMapViewOfSection 的原生 API(Native API)。可以简单理解成,这个 API 就是 Windows 内部真正负责"把镜像文件映射进内存"这件事的底层函数。
而这个通知机制的实现方式是:WoW64 核心(也就是负责整个 32 位在 64 位系统上运行的那一层核心逻辑)会在 32 位代码调用 NtMapViewOfSection 的时候,把这次调用拦截下来。
拦截之后,WoW64 核心并不会阻止这次映射操作本身正常完成,而是会额外通知模拟器:现在有一个新的镜像被映射进来了。具体的通知方式,是调用 Xtajit 模拟器对外导出的一个函数——BTCpuNotifyMapViewOfSection。
用一句话总结这个触发链条:32 位代码申请映射镜像 → WoW64 核心拦截这次系统调用 → WoW64 核心调用模拟器导出的 BTCpuNotifyMapViewOfSection 函数,把这个消息转告给模拟器。
下面用 mermaid 时序图把这个触发过程画出来:
三、为什么这个通知一定要发生:模拟器需要更新哪些内部数据
这个通知机制之所以重要,是因为模拟器内部维护着好几份和"当前进程里加载了哪些镜像"密切相关的数据,一旦有新镜像加载却没有通知到模拟器,这些数据就会过时、失效,进而导致模拟器工作出错或者效率下降。具体需要更新的数据有下面三类:
- CHPE 位图(CHPE bitmap)
- 内部模拟的 CFG(Control Flow Guard,控制流防护)状态
- 该镜像对应的 XTA 缓存状态
下面逐一详细讲解这三类数据分别是干什么用的、为什么必须更新。
3.1 CHPE 位图(CHPE bitmap)
CHPE 位图,本质上是一张"标记表",用来记录当前进程地址空间里,哪些内存页(page)属于 CHPE 代码页。
它的更新规则很简单:当新加载的目标镜像里包含 CHPE 代码页时,位图里对应的那些位(bit)需要被设置为 111。
可以把这张位图想象成一张巨大的表格,表格里的每一位对应内存里的一页,如果某一页存放的是 CHPE 代码,对应的位就标记为 111;反之则保持为 000。这样模拟器在后续执行过程中,只要查一下这张位图,就能立刻判断出"当前正在执行的这段代码,是不是 CHPE 代码",从而决定用什么方式去处理这段代码,而不需要每次都重新分析整个镜像。
3.2 内部模拟的 CFG(控制流防护)状态
CFG,全称 Control Flow Guard(控制流防护),是 Windows 系统里的一种安全机制,用来防止程序的执行流程被恶意篡改(比如防止攻击者利用漏洞,让程序跳转到不应该跳转的地方去执行代码)。
由于当前运行的是 32 位代码,而 CFG 这类安全机制原本是针对真实硬件设计的,模拟器需要在软件层面模拟出一套等效的 CFG 状态,来保证被模拟的 32 位程序依然享有和原生运行时相同的安全保护。
当有新镜像加载进来时,这个新镜像本身也可能带有自己的 CFG 相关信息(比如哪些地址是合法的跳转目标),所以模拟器必须及时更新这份内部维护的 CFG 状态,把新镜像的信息也纳入进来,否则新加载模块里的跳转行为就可能无法被正确地校验。
3.3 XTA 缓存状态(XTA cache state)
XTA 缓存,是模拟器用来加速执行的一种缓存机制:它把 x86 代码提前编译好的 ARM64 机器码保存起来,这样下次再执行同一段代码时,就不需要再重新翻译一遍,直接用缓存好的 ARM64 代码块执行即可,从而大幅提升性能。
每当有新的 x86 或者 CHPE 镜像被加载时,模拟器都需要针对这个具体的镜像,重新判断和更新它的 XTA 缓存状态。这部分内容会在下一小节详细展开。
用一张表格总结这三类需要更新的数据:
| 需要更新的数据 | 作用 | 更新时机的判断依据 |
|---|---|---|
| CHPE 位图 | 标记哪些内存页是 CHPE 代码页 | 镜像中包含 CHPE 代码页时,对应位设置为 111 |
| 模拟的 CFG 状态 | 在软件层面提供控制流保护 | 新镜像加载后,需要纳入其控制流合法目标信息 |
| XTA 缓存状态 | 加速执行,避免重复翻译代码 | 针对每个新加载的 x86/CHPE 镜像单独判断和更新 |
四、XTA 缓存状态具体是怎么更新的
这一部分单独详细讲一下"新镜像加载后,XTA 缓存状态具体是怎么被更新"的完整流程,因为这里涉及到模拟器和一个独立的系统服务之间的配合。
整个流程大致分为两步:
第一步:判断这个模块是否应该使用 XTA 缓存。
每当一个新的 x86 镜像或者 CHPE 镜像被加载时,模拟器首先要做的是一个"资格判断":这个模块到底要不要使用 XTA 缓存。这个判断依据主要来自两个方面:
- 注册表(registry)中的相关配置项。
- 应用程序兼容性垫片(application compatibility shim,简称 shim,是 Windows 里专门用来处理各种应用兼容性特殊情况的一套机制)。
第二步:如果判断结果是"应该使用",就向 XtaCache 服务请求这个镜像对应的最新缓存。
一旦上面这个资格检查通过了,模拟器就会更新整个进程范围内、全局共享的(per-process)XTA 缓存状态,具体做法是:向一个独立运行的系统服务——XtaCache 服务——发起请求,问它"能不能提供这个镜像对应的最新缓存"。
XtaCache 服务收到请求之后,会去尝试寻找并打开这个镜像对应的缓存文件。这里会出现两种结果: - 如果 XtaCache 服务能够找到并成功打开这个镜像的最新缓存文件,它就会把这份缓存包装成一个节对象(section object)返回给模拟器。这个节对象里面存放的是预编译好的 ARM64 代码块,模拟器后续执行这个镜像时,就可以直接复用这些现成的 ARM64 代码,从而加快执行速度,不必再重新做一次翻译。
- 如果 XtaCache 服务没有找到合适的缓存文件,那么模拟器就无法获得这份加速用的缓存,只能退回到常规的翻译流程,逐步翻译并执行这个镜像里的代码。
下面用 mermaid 时序图把这个完整的 XTA 缓存更新流程画出来:
五、完整流程 ASCII 总览图
把从"32 位代码申请映射镜像"到"XTA 缓存更新完毕"的整个链条,用一张不算拥挤的 ASCII 图串联起来:
[32 位应用代码调用 NtMapViewOfSection]
│
▼
[WoW64 核心拦截该调用]
│
▼
[正常完成镜像映射操作]
│
▼
[WoW64 核心调用 BTCpuNotifyMapViewOfSection]
│
▼
[Xtajit 模拟器收到通知]
│
├──────────────┬──────────────────┐
▼ ▼ ▼
[更新 CHPE 位图] [更新模拟的 CFG 状态] [更新 XTA 缓存状态]
│
▼
[依据注册表与兼容性垫片
判断是否应使用 XTA 缓存]
│
┌─────────────┴─────────────┐
▼ ▼
[应该使用] [不应该使用]
│ │
▼ ▼
[向 XtaCache 服务请求缓存] [常规翻译执行]
│
┌─────────┴─────────┐
▼ ▼
[找到缓存文件] [未找到缓存文件]
│ │
▼ ▼
[返回含预编译 ARM64 [无法提供缓存,
代码块的节对象] 回退常规翻译]
│
▼
[模拟器使用该节对象
加速后续执行]
(注:上面 ASCII 图中若出现折行显示的中文说明,仅是因为一行文字较长而做了换行排版,实际含义是同一个方框内的完整文字。)
六、小结
- x86-on-ARM64 模拟器和普通二进制翻译器不同,必须在每一次有新镜像被映射进进程地址空间时得到通知,即使这个镜像是 CHPE 版本的 Ntdll 也不例外。
- 这个通知的实现方式是:WoW64 核心拦截 32 位代码对
NtMapViewOfSection的调用,然后调用模拟器导出的BTCpuNotifyMapViewOfSection函数把消息传递过去。 - 收到通知后,模拟器需要更新三类内部数据:CHPE 位图(标记哪些内存页属于 CHPE 代码)、模拟的 CFG 状态(软件层面的控制流保护)、以及该镜像对应的 XTA 缓存状态(用于加速执行)。
- XTA 缓存状态的更新分两步:先依据注册表和应用兼容性垫片判断这个镜像是否应该使用缓存;如果应该使用,就向独立的 XtaCache 服务请求缓存,服务方如果能找到有效的缓存文件,就会返回一个包含预编译好的 ARM64 代码块的节对象,供模拟器直接使用来加速执行。
CHPE(Compiled Hybrid Portable Executables)详解
1. 问题的起点:纯 JIT 翻译的性能困境
前面已经讲过,在 ARM64 上跑 x86 程序,靠的是模拟器把 x86 代码逐块翻译成 ARM64 代码来执行。但这条路有一个绕不开的性能瓶颈,跟内存模型直接相关。
回忆一下前面讲过的内容:x86 遵循强内存模型,ARM64 遵循弱内存模型。模拟器在翻译 x86 代码的时候,面对的是一段它完全不了解设计意图的机器码——它不知道这段代码里,哪些内存访问之间存在真正的顺序依赖关系(也就是"必须按顺序执行不能被重排"的地方),哪些内存访问之间其实没有依赖、重排了也无所谓。
因为模拟器无法区分这两种情况,为了保证翻译后的代码在语义上跟原始 x86 代码完全等价(不会因为 ARM64 的弱内存模型重排序而引入 bug),它只能采取一种非常保守的策略:在 x86 镜像发出的每一次内存访问之间,都插入内存屏障。
但内存屏障是"慢操作"(前面讲过,它是靠牺牲并行性换取顺序保证的),如果不分青红皂白地在所有内存访问之间都插入屏障,性能损耗会非常可观。而更麻烦的是,很多应用程序运行过程中,有相当大一部分时间实际上花在了操作系统自带的库代码上(大约 40% 左右),这些系统库大多是微软自己编译发布的、行为已知、可以被信任的代码。
这就带来一个关键的优化思路:
如果操作系统库根本不需要被"模拟翻译",而是可以直接以 ARM64 原生代码的方式运行, \text{如果操作系统库根本不需要被"模拟翻译",而是可以直接以 ARM64 原生代码的方式运行,} 如果操作系统库根本不需要被"模拟翻译",而是可以直接以 ARM64 原生代码的方式运行,
那么应用程序整体的性能就能获得显著提升。 \text{那么应用程序整体的性能就能获得显著提升。} 那么应用程序整体的性能就能获得显著提升。
这正是 CHPE(Compiled Hybrid Portable Executables,编译混合可移植可执行文件) 这项技术被设计出来的根本动机。
2. CHPE 是什么:一份"心知肚明"的双重代码
一个 CHPE 二进制文件,本质上是一个混合体:它同时包含 x86 代码和与 ARM64 兼容的代码,而且这份 ARM64 兼容代码,是编译器在完全了解原始源代码逻辑的前提下生成的。
这一点是理解 CHPE 的关键:普通的模拟翻译,是"黑盒翻译"——模拟器面对的是已经编译好的 x86 机器码,不知道背后的源代码长什么样,只能猜测式地保守处理内存顺序问题。而 CHPE 代码,是"白盒生成"——编译器在编译源代码(比如 C/C++ 源码)生成这份代码的时候,是完全清楚哪里真正需要内存屏障、哪里不需要的,因为它看得到源代码里变量之间真实的依赖关系。
这份专门为 ARM64 生成、同时又保留了跟 x86 良好互操作性的机器码,被称为 混合代码(hybrid code),也就是 CHPE 代码。它有一个很重要的特点:
- 它依然运行在 AArch64 执行模式下(也就是说,它是真正的 ARM64 指令,能被 ARM64 硬件直接执行,不需要任何翻译);
- 但它是遵循 32 位 ABI(应用二进制接口)规则生成的,目的是为了方便跟 x86 代码之间互相调用、传参、返回值兼容。
可以把这个关系理解成:“外表遵守 x86 世界的规矩(32位调用约定),但内在跑的是 ARM64 硬件原生指令”。
3. CHPE 二进制文件的结构特征
CHPE 二进制文件在表面上,依然被当作一个标准的 x86 可执行文件来对待——它的机器类型标识(Machine ID)仍然是 014C,这正是标准 x86(i386)可执行文件所使用的标识值。也就是说,从文件格式层面上看,操作系统一开始并不会把它当成什么"特殊文件",它戴着一张 x86 的"面具"。
它跟普通 x86 文件真正的区别在于:文件内部额外包含了混合代码(hybrid code),并且这部分混合代码所在的位置,是通过一张表格描述的——这张表被称为 混合镜像元数据(Hybrid Image metadata),存储在 PE 文件的镜像加载配置目录(Image load configuration directory) 当中,是标准 PE 文件格式里本来就存在的一个扩展区域。
| 特征项 | 内容 |
|---|---|
| 文件表面类型 | 标准 x86 可执行文件(Machine ID = 014C) |
| 额外携带内容 | 与 ARM64 兼容的混合(CHPE)代码 |
| 混合代码位置描述 | 存储在 Image Load Configuration Directory 中的 Hybrid Image metadata |
| 混合代码运行模式 | AArch64 执行模式,但遵循 32 位 ABI |
4. 加载 CHPE 二进制文件时,CHPE 位图是如何被更新的
前面讲 x86 模拟器初始化的时候提到过:进程初始化阶段会分配一个覆盖整个 4 GB 地址空间的 CHPE 位图,位图里的每一位对应一页内存,用来标记"这一页是否包含 CHPE 代码"。
现在把这一块串起来:当一个 CHPE 二进制文件被加载进 WoW64 进程的地址空间时,模拟器会根据 Hybrid metadata 里描述的地址范围,把对应的每一页内存在 CHPE 位图中的那一位设置为 1。
这个过程可以理解成一种"地图标注"的工作:镜像加载完成后,Hybrid metadata 告诉模拟器"哪些地址范围是混合代码",模拟器就据此在全局位图上把这些范围标记出来,方便后续执行的时候快速判断"接下来要跳转到的这个地址,是不是已经有现成的 ARM64 原生代码可以直接用,不用再走翻译流程"。
有了这张位图之后,事情就变得高效起来:当 Jitter 在编译 x86 代码块的过程中,一旦检测到当前代码正试图调用某个函数,而这个函数恰好落在被标记为 CHPE 的地址范围内,Jitter 就不会再浪费时间去做任何编译/翻译工作,而是直接把执行权转交给这段已经是原生 ARM64 指令的混合代码去执行(并且使用的是 32 位栈,因为混合代码遵循的是 32 位 ABI)。
这正是 CHPE 带来的核心性能收益:原本需要被逐条模拟、逐块翻译的系统库函数调用,现在可以直接、零开销地跳转执行。
5. ABI 不兼容问题:为什么不能"直接调用"
这里出现一个新的技术难题。Jitter 翻译出来的 x86 代码块,遵循的是一套自定义的 ABI(Custom ABI)——也就是说,模拟器给 ARM64 寄存器怎么用、函数之间参数怎么传递,定义了一套非标准的约定,这套约定专门是为了配合"翻译 x86 代码"这件事而设计的。
而 CHPE 代码虽然也遵循 32 位 ABI(前面提到过,这是为了跟 x86 代码互操作性好),但它遵循的寄存器使用约定,跟 Jitter 翻译出来的代码所遵循的约定并不相同。
Jitted 代码的 ABI≠CHPE 代码的 ABI(尽管两者都自称"兼容32位") \text{Jitted 代码的 ABI} \neq \text{CHPE 代码的 ABI(尽管两者都自称"兼容32位")} Jitted 代码的 ABI=CHPE 代码的 ABI(尽管两者都自称"兼容32位")
这就意味着:Jitter 翻译出来的代码块,不能直接、简单粗暴地跳转过去调用 CHPE 代码——因为参数放在哪个寄存器里、返回值该从哪里取,两边的"约定俗成"是不一样的,直接跳过去只会导致参数错乱、程序崩溃。
反过来,CHPE 代码如果想调用一个还没被翻译、纯粹的 x86 函数,同样存在对称的问题。
6. 解决方案:三种 Thunk 函数
为了打通这两套不同 ABI 之间的桥梁,CHPE 二进制文件里额外包含了三种"转换桥"函数,专业术语叫 thunk(转接代码/桩函数)。可以把 thunk 理解成一个"翻译官":它站在两种不同"方言"(ABI 约定)的中间,负责把参数从一种约定"倒手"转换成另一种约定,然后再把控制权转交出去。
| Thunk 类型 | 调用方向 | 作用 |
|---|---|---|
| Pop Thunk | x86 代码 调用 → CHPE 混合函数 | 把参数从 x86(Guest)约定转换为 CHPE 约定,再把执行权直接转交给混合代码 |
| Push Thunk | CHPE 代码 调用 → x86 例程 | 把参数从 CHPE 约定转换为 x86(Guest)约定,再调用模拟器恢复对 x86 代码的执行 |
| Export Thunk | 外部程序 Detour → CHPE 导出函数 | 为需要挂钩(detour)系统导出函数的外部程序提供兼容性支持 |
下面分别展开讲讲每一种 thunk 具体做了什么。
6.1 Pop Thunk(弹出型转接)
当 x86 代码想要调用一个 CHPE(混合)函数时,会先走到这个函数对应的 pop thunk。Pop thunk 的职责是:
- 把传入的参数(从 x86 调用方的角度看待的参数)转换成 CHPE 函数期望的寄存器/栈约定;
- 转换完成之后,直接把控制权交给混合代码去执行——注意这里不需要经过模拟器逐条解释执行,因为目标本来就是真正的 ARM64 硬件指令。
- 函数返回时,同样需要负责把返回值按照 x86 调用方期望的约定转换回去。
之所以叫"Pop",可以理解为一种形象化的说法:参数原本是按 x86(Guest)那一套方式摆放的,thunk 把它们"弹出"原来的位置,重新按 CHPE 那一套方式摆放好,然后再"跳进去"执行。
6.2 Push Thunk(推入型转接)
方向正好相反:当 CHPE 代码想要调用一个还没被翻译的 x86 例程时,会走到对应的 push thunk。它的职责是:
- 把 CHPE 代码准备好的参数,转换成 x86(Guest)调用约定期望的格式;
- 调用模拟器(Emulator),让执行流程重新回到 x86 代码的模拟执行状态——因为目标函数本身还是纯 x86 代码,没有对应的 CHPE 版本,只能老老实实交回给模拟器去逐条解释/JIT 翻译执行。
可以理解为:这次是把参数"推入"到 x86 世界的规则里,然后把控制权交还给负责处理 x86 世界的模拟器。
6.3 Export Thunk(导出兼容转接)
第三种 thunk 用途比较特殊,它是为了兼容一类特定的应用场景:有些第三方程序会用一种叫 detour(挂钩、劫持) 的技术,直接修改操作系统模块导出的 x86 函数入口代码,来改变这些函数原本的行为(比如常见的 API Hook 技术)。
问题在于:如果一个系统模块的导出函数已经被替换成了纯粹的 CHPE 代码(也就是真正的 ARM64 指令),那么原本那些期望在函数入口处看到"一小段 x86 字节码可以打补丁挂钩"的第三方 detour 工具,就会完全找不到自己熟悉的 x86 字节,导致挂钩失败。
为了让这类依赖 x86 字节码挂钩的工具依然能正常工作,从 CHPE 模块导出的这些函数,会在入口处保留一小段(通常 8 字节左右)的 x86 代码。这段 x86 代码本身在语义上并不承担任何实际功能(它不是真正要执行的逻辑),存在的唯一目的,就是给外部的 detour 工具留一个可以识别、可以打补丁的"锚点",从而保持对这类挂钩工具的向后兼容性。
用一张图总结三种 thunk 的调用关系:
7. 模拟器加载策略:优先 CHPE,退而求其次用标准 x86
x86 模拟器在加载系统库的时候,总是会尽最大努力优先加载 CHPE 版本的系统库,而不是标准的纯 x86 版本,因为前者能带来上面讲到的这些性能优势。
但这个优先策略并非总能实现——如果某个系统组件根本不存在对应的 CHPE 版本,模拟器就只能退而求其次,从传统的 SysWow64 目录(这是 Windows 上专门存放 32 位 x86 系统组件的目录)去加载标准的 x86 版本。
在这种"没有 CHPE 版本可用"的情况下,这个系统模块就会完全按照普通 x86 代码的方式,被 Jitter 逐块翻译执行——也就是说,它享受不到 CHPE 带来的"零翻译开销"优势,退回到了本节开头描述的、性能开销更大的常规模拟翻译流程。
| 系统库是否有 CHPE 版本 | 加载来源 | 执行方式 | 性能特点 |
|---|---|---|---|
| 有 | 系统 CHPE 目录 | 部分/全部代码直接以原生 ARM64 方式执行 | 开销小,接近原生性能 |
| 没有 | SysWow64 目录(标准x86) | 整个模块完全被 Jitter 逐块翻译 | 开销较大,走完整模拟流程 |
8. 用 C++ 模拟理解 CHPE 位图查询与 Thunk 调度逻辑
下面用一段简化的 C++ 教学代码,演示"Jitter 遇到函数调用时,如何借助 CHPE 位图判断走哪条路径(直接执行混合代码 vs 走 Pop Thunk 转换 vs 继续走普通翻译)"的核心逻辑。这不是真实的 Xtajit.dll 源码,而是为了帮助建立直观理解而写的简化模型。
#include <iostream> // 打印调试信息
#include <vector> // 存储地址范围区间
#include <bitset> // 用位集合模拟 CHPE 位图(简化版,真实位图覆盖整个4GB空间)
#include <string> // 表示函数名 / 执行结果
// -----------------------------------------------------------------
// 用一个简化的位图类,模拟"CHPE 位图"的核心行为:
// 每一位代表一页内存是否包含 CHPE(混合)代码。
// 真实系统中位图覆盖 2^32 字节地址空间,这里为了演示简化成较小规模。
// -----------------------------------------------------------------
class ChpeBitmap
{
public:
// 构造函数:pageCount 表示要模拟管理多少个"页",这里用简化的小规模数字演示
explicit ChpeBitmap(size_t pageCount) : bits(pageCount, false) {}
// 把某一页标记为"包含 CHPE 代码",对应文中说的
// "加载 CHPE 镜像时,根据 Hybrid metadata 把对应页在位图中设为1"
void markPageAsChpe(size_t pageIndex)
{
if (pageIndex < bits.size())
{
bits[pageIndex] = true;
}
}
// 查询某一页是否是 CHPE 代码页,
// 对应文中说的"Jitter 编译时检测目标地址是否是混合函数"
bool isChpePage(size_t pageIndex) const
{
if (pageIndex >= bits.size())
{
return false;
}
return bits[pageIndex];
}
private:
std::vector<bool> bits; // 用 vector<bool> 模拟位图(标准库对 bool 向量做了按位压缩优化)
};
// -----------------------------------------------------------------
// 用枚举表示 Jitter 在遇到一次函数调用时,最终会走哪一条执行路径,
// 对应文中讲到的三种情形:
// 1. 直接执行 CHPE 混合代码(通过 Pop Thunk 转换参数后跳转)
// 2. 继续走普通的 x86 JIT 翻译流程(没有 CHPE 版本可用)
// -----------------------------------------------------------------
enum class ExecutionPath
{
DirectChpeViaPopThunk, // 命中 CHPE 位图,走 Pop Thunk 直接执行混合代码
RegularJitTranslation // 未命中,走常规的 x86 逐块翻译
};
// -----------------------------------------------------------------
// 模拟"Pop Thunk"的行为:把 x86 调用约定下的参数,
// 转换成 CHPE 约定下的参数,然后打印出转换结果(这里用简化字符串代替真实的
// 寄存器/栈操作)
// -----------------------------------------------------------------
std::string popThunkConvertAndInvoke(const std::string& functionName,
const std::vector<int>& x86StyleArgs)
{
std::cout << "[Pop Thunk] 将参数从 x86 约定转换为 CHPE 约定: (";
for (size_t i = 0; i < x86StyleArgs.size(); ++i)
{
std::cout << x86StyleArgs[i];
if (i + 1 < x86StyleArgs.size())
{
std::cout << ", ";
}
}
std::cout << ")\n";
std::cout << "[Pop Thunk] 直接跳转执行 CHPE 混合函数: " << functionName << "\n";
return "CHPE_RESULT_OF_" + functionName; // 模拟混合代码执行后的返回值
}
// -----------------------------------------------------------------
// 模拟"常规 JIT 翻译"的行为:如果没有命中 CHPE 位图,
// Jitter 只能走老路——逐条把 x86 指令翻译成 ARM64 指令再执行
// -----------------------------------------------------------------
std::string regularJitTranslateAndExecute(const std::string& functionName)
{
std::cout << "[常规JIT] 未命中 CHPE 位图,开始逐块翻译 x86 函数: "
<< functionName << " ...\n";
return "JITTED_RESULT_OF_" + functionName;
}
// -----------------------------------------------------------------
// 核心调度函数:模拟 Jitter 在遇到一次函数调用时的决策逻辑。
// 参数说明:
// bitmap —— 全局 CHPE 位图
// targetPage —— 被调用函数所在的内存页索引
// functionName —— 被调用的函数名(用于打印演示)
// x86StyleArgs —— 调用方(x86 代码)按 x86 约定准备好的参数
// -----------------------------------------------------------------
std::string dispatchFunctionCall(const ChpeBitmap& bitmap,
size_t targetPage,
const std::string& functionName,
const std::vector<int>& x86StyleArgs)
{
// 第一步:查询 CHPE 位图,判断目标地址所在页是否包含混合代码
if (bitmap.isChpePage(targetPage))
{
std::cout << "[调度] 页 " << targetPage
<< " 命中 CHPE 位图,选择路径: DirectChpeViaPopThunk\n";
// 命中的话,不需要走任何编译流程,直接通过 Pop Thunk 转换参数后跳转执行
return popThunkConvertAndInvoke(functionName, x86StyleArgs);
}
else
{
std::cout << "[调度] 页 " << targetPage
<< " 未命中 CHPE 位图,选择路径: RegularJitTranslation\n";
// 未命中的话,只能走常规的 x86 JIT 翻译流程
return regularJitTranslateAndExecute(functionName);
}
}
int main()
{
// 创建一个模拟的 CHPE 位图,假设一共管理 16 个页(真实场景覆盖整个4GB地址空间)
ChpeBitmap bitmap(16);
// 模拟加载 kernelbase.dll 这个 CHPE 系统库时,
// 根据 Hybrid metadata 把它的混合代码地址范围对应的页标记为 CHPE 页
// 这里假设第 3、4、5 页是混合代码(对应真实场景里 Hybrid Code Address Range Table 描述的范围)
bitmap.markPageAsChpe(3);
bitmap.markPageAsChpe(4);
bitmap.markPageAsChpe(5);
std::cout << "=== 场景一:调用一个位于 CHPE 页(第4页)的系统函数 ===\n";
std::string result1 = dispatchFunctionCall(bitmap, 4, "RtlAllocateHeap",
{1024, 0, 0});
std::cout << "调用结果: " << result1 << "\n\n";
std::cout << "=== 场景二:调用一个位于非 CHPE 页(第10页)的普通 x86 函数 ===\n";
std::string result2 = dispatchFunctionCall(bitmap, 10, "SomeLegacyX86Function",
{42});
std::cout << "调用结果: " << result2 << "\n";
return 0;
}
代码关键点解析:
ChpeBitmap类:用std::vector<bool>模拟真实场景中覆盖整个 4GB 地址空间的 CHPE 位图。markPageAsChpe对应"加载 CHPE 镜像时根据 Hybrid metadata 更新位图"这一步,isChpePage对应"Jitter 在编译时快速查询某个地址是否是混合代码"这一步。真实系统里位图规模远大于这里的演示规模,但核心的"按页、按位标记与查询"逻辑是一致的。ExecutionPath枚举:清晰列出了 Jitter 面对一次函数调用时,实际只有两条路可走——要么命中 CHPE 位图走 Pop Thunk 直接执行原生代码,要么走老老实实的常规 JIT 翻译流程。这对应文中反复强调的"能不翻译就不翻译,命中就直接跳转"的设计核心。popThunkConvertAndInvoke函数:模拟 Pop Thunk 的核心职责——把参数从一种约定"倒手"转换成另一种约定,然后跳转执行。真实场景中这一步涉及具体的 ARM64 寄存器操作(比如把参数从 x86 调用约定期望的寄存器位置,搬运到 CHPE 约定期望的寄存器位置),这里简化成打印字符串来体现"转换"这个动作本身。regularJitTranslateAndExecute函数:模拟未命中 CHPE 位图时的兜底路径,对应文中"如果没有 CHPE 版本,模块会被完整地 Jitted(逐块翻译)"这句话描述的情形。dispatchFunctionCall函数:这是整个示例的调度核心,完整还原了 Jitter 在编译 x86 代码、遇到函数调用指令时的判断逻辑——先查位图,再决定走哪条路径。这个函数的结构直接对应前面 mermaid 图里"加载 CHPE 二进制文件 → 更新位图 → 后续执行时查询位图"这条主线的最后一环:“查询位图并据此调度执行路径”。main函数里的两个场景:分别演示了"命中 CHPE 位图,直接走 Pop Thunk 高效执行"和"未命中,退回常规翻译流程"这两种典型情况,对应前面表格里"系统库是否有 CHPE 版本"所导致的两种截然不同的性能路径。
该代码可以直接用标准 C++17 编译运行:
g++ -std=c++17 -O2 chpe_bitmap_demo.cpp -o demo
./demo
9. 实践演示:用 link.exe 查看 CHPE 元数据(原理讲解)
书里提到可以用 Windows SDK/WDK 自带的 link.exe(增量链接器工具)来查看一个 CHPE 文件(比如 kernelbase.dll)里存储的混合元数据信息。虽然这是一个需要在真实 ARM64 机器上操作的实验,这里重点讲清楚输出内容对应的含义,帮助理解前面讲的理论在实际文件里是怎么体现的。
基本操作思路是:进入系统的 CHPE 专用目录(C:\Windows\SyChpe32),对 kernelbase.dll 执行加载配置目录的转储命令,把结果输出重定向到文本文件里查看(因为内容通常比较多,直接显示在控制台不方便阅读)。
转储出来的内容里,最关键的部分是 混合代码地址范围表(Hybrid Code Address Range Table)。这张表用一组"起始地址-结束地址"的区间,明确列出了这个文件里,哪些地址范围是 x86 代码、哪些地址范围是 CHPE(混合/ARM64兼容)代码。
拿书中展示的例子来说,kernelbase.dll 里一共有四段地址范围,分成两类:
| 类型 | 说明 |
|---|---|
| x86 段(两段) | 标记为 x86 的地址区间,实际上模拟器并不会使用这部分代码 |
| CHPE 段(两段,工具里误标为"arm64") | 真正会被模拟器识别、直接执行的混合代码区间 |
可以用一个简化的 ASCII 图来表示这张地址范围表描述的文件内部布局关系(数值仅为示意,不代表精确的地址计算):
kernelbase.dll 文件内部地址空间布局(示意)
低地址
|
v
+-------------------+ <- x86 段 1(不被模拟器使用)
| x86 code |
+-------------------+
| CHPE code | <- CHPE 段 1(会被模拟器直接执行)
+-------------------+
| x86 code | <- x86 段 2(不被模拟器使用)
+-------------------+
| CHPE code | <- CHPE 段 2(会被模拟器直接执行)
+-------------------+
|
v
高地址
这里有一个值得强调的细节:书中提到,转储工具在输出里把 CHPE 段错误地标注成了字面上的 “arm64” 这个词,但从技术含义上讲,这两段其实就是前面反复讲到的混合(CHPE)代码,只是工具在展示文字上用词不够精确,本质上并不影响这些地址范围真正代表的含义——它们就是能被 ARM64 硬件直接执行、同时又遵循 32 位 ABI 的那部分代码。
这个实验最大的意义在于:它让"混合代码地址范围表"这个抽象概念,变成了一份可以在真实系统文件里直接看到、验证的具体数据——证明了 CHPE 并不是一个纯理论设计,而是确确实实、以结构化元数据的形式,编码在每一个 CHPE 系统库文件内部的。
10. 小结
| 概念 | 一句话理解 |
|---|---|
| CHPE 的设计动机 | 避免对 OS 库代码做保守的、逐条插入内存屏障的模拟翻译,直接用感知源码语义生成的原生ARM64代码 |
| CHPE 二进制文件 | 表面是标准x86文件(Machine ID = 014C),内部额外携带遵循32位ABI的原生ARM64混合代码 |
| Hybrid Image metadata | 存储在Image Load Configuration Directory里,描述混合代码地址范围的元数据表 |
| CHPE 位图 | 加载CHPE镜像时被更新,标记哪些内存页含有混合代码,供Jitter快速查询 |
| Pop Thunk | x86 调用 CHPE 函数时做参数转换 + 直接跳转 |
| Push Thunk | CHPE 调用 x86 函数时做参数转换 + 交还模拟器执行 |
| Export Thunk | 为兼容外部detour挂钩工具,在CHPE导出函数入口保留少量无实际功能的x86字节 |
| 加载优先级 | 优先加载CHPE版本系统库,没有则退回SysWow64标准x86版本并完整JIT翻译 |
CHPE 这套设计的核心思路,其实是一种非常典型的"分而治之":把整个应用程序运行时会用到的代码,划分成"我完全了解、可以直接信任并原生执行"的部分(操作系统自带的 CHPE 库),和"我完全不了解、必须谨慎保守地逐块翻译"的部分(真正的第三方 x86 应用代码本身)。通过让占比很大的系统库调用绕开保守的内存屏障翻译策略,整体应用程序的响应性和运行效率都得到了明显改善,而三种 thunk 机制则保证了这两个世界之间的调用依然能够无缝衔接、正确传参。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)