了解 Arm64EC ABI 和汇编代码
Arm64EC(“仿真兼容”)是一个新的应用程序二进制接口(ABI),用于在 Arm 上生成适用于 Windows 11 的应用。 有关 Arm64EC 的概述以及如何开始将 Win32 应用生成为 Arm64EC,请参阅 使用 Arm64EC 在 Arm 设备上生成适用于 Windows 11 的应用。
本文档的目的是提供 Arm64EC ABI 的详细视图,其中包含足够的信息,以便应用程序开发人员编写和调试为 Arm64EC 编译的代码,包括低级别/汇编程序调试和编写面向 Arm64EC ABI 的程序集代码。
Arm64EC 旨在提供本机级别的功能和性能,同时提供与模拟下运行的 x64 代码的透明和直接互操作性。
Arm64EC 主要与经典 Arm64 ABI 相加。 很少更改经典 ABI,但添加了部分以启用 x64 互操作性。
本文档中,原始标准 Arm64 ABI 应称为“经典 ABI”。 这避免了重载术语(如“Native”)固有的歧义性。 要清楚起见,Arm64EC 与原始 ABI 一样本机。
以下列表指出了 Arm64EC 与 Arm64 经典 ABI 的分歧。
从整个 ABI 定义程度的角度来看,这些变化很小。
若要实现与 x64 代码的类型级互操作性,Arm64EC 代码使用与 x64 代码相同的预处理器体系结构定义进行编译。
换句话说, _M_AMD64
定义 _AMD64_
和定义。 受此规则影响的类型之一是 CONTEXT
结构。 该 CONTEXT
结构定义给定点的 CPU 状态。 它用于诸如 Exception Handling
API 之类的内容和 GetThreadContext
API。 现有 x64 代码要求 CPU 上下文表示为 x64 CONTEXT
结构,换句话说, CONTEXT
即在 x64 编译期间定义的结构。
此结构必须用于表示执行 x64 代码时的 CPU 上下文,以及 Arm64EC 代码。 现有代码无法理解新概念,例如 CPU 寄存器集从函数更改为函数。 如果 x64 结构用于表示 Arm64 CONTEXT
执行状态,这意味着 Arm64 寄存器实际上映射到 x64 寄存器。
它还意味着不能使用任何无法安装到 x64 中的 Arm64 CONTEXT
寄存器,因为每当使用 CONTEXT
操作时,其值可能会丢失(有些寄存器可能是异步和意外的,例如托管语言运行时的垃圾回收操作或 APC)。
Arm64EC 和 x64 寄存器之间的映射规则由 ARM64EC_NT_CONTEXT
SDK 中存在的 Windows 标头中的结构表示。 此结构实质上是结构的并集 CONTEXT
,与为 x64 定义完全相同,但具有额外的 Arm64 寄存器覆盖。
例如,RCX
映射到X0
、到X1
、 RDX
RSP
到SP
、RIP
到PC
等。我们还可以查看寄存器x13
、x14
、x23
、x24
、x28
没有v16
-v31
表示形式的方式,因此不能在 Arm64EC 中使用。
此寄存器使用限制是 Arm64 经典版和 EC API 之间的第一个区别。
自 Windows 8.1 中引入控制流防护(CFG)以来,呼叫检查器一直是 Windows 的一部分。 调用检查器是函数指针的地址清理器(在这些内容称为地址清理器之前)。 每次使用选项 /guard:cf
编译代码时,编译器都会在每个间接调用/跳转之前生成对检查器函数的额外调用。 检查器函数本身由 Windows 提供,对于 CFG,它会针对已知良好的调用目标执行有效性检查。 此信息也包含在编译的 /guard:cf
二进制文件中。
这是经典 Arm64 中调用检查器使用的一个示例:
mov x15, <target>
adrp x16, __guard_check_icall_fptr
ldr x16, [x16, __guard_check_icall_fptr]
blr x16 ; check target function
blr x15 ; call function
在 CFG 案例中,如果目标有效,则调用检查器将仅返回;如果目标无效,则快速失败进程。 呼叫检查器具有自定义呼叫约定。 它们采用普通调用约定不使用的寄存器中的函数指针,并保留所有普通调用约定寄存器。 这样,他们就不会在它们周围引入注册溢出。
调用检查器在所有其他 Windows API 上都是可选的,但在 Arm64EC 上是必需的。 在 Arm64EC 上,调用检查器会累积验证所调用函数的体系结构的任务。 它们验证调用是另一个 EC(“仿真兼容”)函数还是必须在仿真下执行的 x64 函数。 在许多情况下,只能在运行时对此进行验证。
Arm64EC 呼叫检查器基于现有的 Arm64 检查器构建,但它们具有略有不同的自定义调用约定。 它们采用额外的参数,可以修改包含目标地址的寄存器。 例如,如果目标为 x64 代码,则必须首先将控件传输到仿真基架逻辑。
在 Arm64EC 中,相同的调用检查器使用将变为:
mov x11, <target>
adrp x9, __os_arm64x_check_icall_cfg
ldr x9, [x9, __os_arm64x_check_icall_cfg]
adrp x10, <name of the exit thunk>
add x10, x10, <name of the exit thunk>
blr x9 ; check target function
blr x11 ; call function
经典 Arm64 的细微差异包括:
- 调用检查器符号名称不同。
- 目标地址提供,
x11
而不是x15
。 - 目标地址 (
x11
)[in, out]
不是[in]
。 - 有一个额外的参数,通过
x10
提供,称为“Exit Thunk”。
Exit Thunk 是一个 funclet,它将函数参数从 Arm64EC 调用约定转换为 x64 调用约定。
Arm64EC 调用检查器通过不同于 Windows 中的其他 API 使用的符号。 在经典 Arm64 ABI 上,调用检查器符号为 __guard_check_icall_fptr
。 此符号将存在于 Arm64EC 中,但 x64 静态链接的代码可以使用,而不是 Arm64EC 代码本身。 Arm64EC 代码将使用或 __os_arm64x_check_icall
__os_arm64x_check_icall_cfg
.
在 Arm64EC 上,呼叫检查器不是可选的。 但是,CFG 仍然是可选的,其他 API 的情况也是可选的。 CFG 可能在编译时被禁用,或者即使启用了 CFG(例如函数指针永远不会驻留在 RW 内存中),也可能存在不执行 CFG 检查的合法理由。 对于使用 CFG 检查的 __os_arm64x_check_icall_cfg
间接调用,应使用检查器。 如果禁用或不需要 CFG, __os_arm64x_check_icall
则应改用。
下面是经典 Arm64、x64 和 Arm64EC 上的调用检查器使用情况的摘要表,指出 Arm64EC 二进制文件可以有两个选项,具体取决于代码的体系结构。
二进制 | 代码 | 未受保护的间接呼叫 | CFG 保护的间接呼叫 |
---|---|---|---|
X64 | X64 | 无呼叫检查器 | __guard_check_icall_fptr 或 __guard_dispatch_icall_fptr |
Arm64 经典版 | ARM64 | 无呼叫检查器 | __guard_check_icall_fptr |
Arm64EC | x64 | 无呼叫检查器 | __guard_check_icall_fptr 或 __guard_dispatch_icall_fptr |
Arm64EC | __os_arm64x_check_icall |
__os_arm64x_check_icall_cfg |
独立于 ABI,启用 CFG 的代码(代码引用 CFG 调用检查器),并不意味着在运行时提供 CFG 保护。 CFG 保护的二进制文件可以在不支持 CFG 的系统上运行低级别:调用检查器在编译时使用无操作帮助程序进行初始化。 进程也可能通过配置禁用 CFG。 在以前的 API 上禁用 CFG(或 OS 支持不存在)时,OS 在加载二进制文件时不会更新调用检查器。 在 Arm64EC 上,如果禁用 CFG 保护,OS 将设置 __os_arm64x_check_icall_cfg
相同的 __os_arm64x_check_icall
设置,这将在所有情况下都提供所需的目标体系结构检查,但不会提供 CFG 保护。
与经典 Arm64 中的 CFG 一样,对目标函数 (x11
) 的调用必须紧跟调用调用检查器。 调用检查器地址必须放置在易失性寄存器中,也不应将目标函数的地址复制到另一个寄存器或溢出到内存中。
__chkstk
每当函数分配大于页面的堆栈上的区域时,编译器都会自动使用。 为了避免跳过保护堆栈末尾的堆栈防护页, __chkstk
请调用以确保探测分配区域中的所有页面。
__chkstk
通常从函数的 prolog 调用。 因此,为了生成最佳代码,它使用自定义调用约定。
这意味着 x64 代码和 Arm64EC 代码需要其自身的不同 __chkstk
函数,因为入口和退出 thunk 采用标准调用约定。
x64 和 Arm64EC 共享相同的符号命名空间,因此无法命名 __chkstk
两个函数。 为了适应与预先存在的 x64 代码的兼容性, __chkstk
名称将与 x64 堆栈检查器相关联。 Arm64EC 代码将改用 __chkstk_arm64ec
。
自定义调用约定与经典 Arm64 __chkstk
相同__chkstk_arm64ec
:x15
提供分配的大小(以字节为单位),除以 16。 将保留所有非易失性寄存器以及标准调用约定中涉及的所有易失寄存器。
上面所说的一__chkstk
切同样适用于__security_check_cookie
其 Arm64EC 对应项: __security_check_cookie_arm64ec
Arm64EC 遵循经典 Arm64 ABI 调用约定,除了 Variadic 函数(aka varargs、aka 函数和省略号 (. .) 参数关键字)。
对于可变特定情况,Arm64EC 遵循的调用约定与 x64 可变性非常相似,只存在一些差异。 下面是 Arm64EC 可变性的主要规则:
- 仅前 4 个寄存器用于参数传递:
x0
、x1
、x2
、x3
。 其余参数将溢出到堆栈上。 这完全遵循 x64 可变调用约定,与使用寄存器x0
>x7
的 Arm64 经典版不同。 - 寄存器传递的浮点/SIMD 参数将使用常规用途寄存器,而不是 SIMD 寄存器。 这类似于 Arm64 经典版,与 x64 不同,其中 FP/SIMD 参数在常规用途和 SIMD 寄存器中传递。 例如,对于在 x64 上调用的
f1(int, double)
函数f1(int, …)
,第二个参数将同时分配给这两RDX
个参数。XMM1
在 Arm64EC 上,第二个参数将只分配给 。x1
- 通过寄存器按值传递结构时,x64 大小规则适用:大小正好为 1、2、4 和 8 的结构将直接加载到常规用途寄存器中。 具有其他大小的结构将溢出到堆栈上,并将指向溢出位置的指针分配给寄存器。 这基本上在低级别将按值降级为按引用。 在经典 Arm64 ABI 上,任何大小高达 16 字节的结构都直接分配给常规用途寄存器。
- 使用指向通过堆栈传递的第一个参数(第 5 个参数)的指针加载 X4 寄存器。 这不包括由于上述大小限制而溢出的结构。
- X5 寄存器加载堆栈传递的所有参数的大小(所有参数的大小,从第 5 个开始)。 这不包括由于上述大小限制而溢出的值传递的结构。
在以下示例中: pt_nova_function
下面的示例采用非可变形式的参数,因此遵循经典 Arm64 调用约定。 然后,它使用完全相同的参数进行调用 pt_va_function
,但在可变调用中。
struct three_char {
char a;
char b;
char c;
};
void
pt_va_function (
double f,
...
);
void
pt_nova_function (
double f,
struct three_char tc,
__int64 ull1,
__int64 ull2,
__int64 ull3
)
{
pt_va_function(f, tc, ull1, ull2, ull3);
}
pt_nova_function
采用 5 个参数,这些参数将按照经典 Arm64 调用约定规则进行分配:
- “f”是一个双精度值。 它将分配给 d0。
- “tc”是一个结构,大小为 3 字节。 它将分配给 x0。
- ull1 是一个 8 字节整数。 它将分配给 x1。
- ull2 是一个 8 字节整数。 它将分配给 x2。
- ull3 是一个 8 字节整数。 它将分配给 x3。
pt_va_function
是一个可变函数,因此它将遵循上面概述的 Arm64EC 可变规则:
- “f”是一个双精度值。 它将分配给 x0。
- “tc”是一个结构,大小为 3 字节。 它将溢出到堆栈及其加载到 x1 中的位置。
- ull1 是一个 8 字节整数。 它将分配给 x2。
- ull2 是一个 8 字节整数。 它将分配给 x3。
- ull3 是一个 8 字节整数。 它将直接分配给堆栈。
- x4 加载堆栈中 ull3 的位置。
- x5 加载大小为 ull3。
下面显示了可能的编译输出 pt_nova_function
,其中说明了上面概述的参数赋值差异。
stp fp,lr,[sp,#-0x30]!
mov fp,sp
sub sp,sp,#0x10
str x3,[sp] ; Spill 5th parameter
mov x3,x2 ; 4th parameter to x3 (from x2)
mov x2,x1 ; 3rd parameter to x2 (from x1)
str w0,[sp,#0x20] ; Spill 2nd parameter
add x1,sp,#0x20 ; Address of 2nd parameter to x1
fmov x0,d0 ; 1st parameter to x0 (from d0)
mov x4,sp ; Address of the 1st in-stack parameter to x4
mov x5,#8 ; Size of the in-stack parameter area
bl pt_va_function
add sp,sp,#0x10
ldp fp,lr,[sp],#0x30
ret
为了实现与 x64 代码的透明互操作性,已对经典 Arm64 ABI 进行了许多添加。 它们处理 Arm64EC 和 x64 之间的调用约定差异。
以下列表包括以下新增内容:
进入和退出 Thunks 负责将 Arm64EC 调用约定(主要与经典 Arm64 相同)转换为 x64 调用约定,反之亦然。
常见的误解是,调用约定可以按照应用于所有函数签名的单个规则进行转换。 现实情况是调用约定具有参数分配规则。 这些规则取决于参数类型,不同于 ABI 到 ABI。 结果是,API 之间的转换将特定于每个函数签名,具体取决于每个参数的类型。
请考虑以下函数:
int fJ(int a, int b, int c, int d);
参数分配将如下所示:
- Arm64:a -> x0、b -> x1、c -> x2、d -> x3
- x64:a -> RCX、b -> RDX、c -> R8、d -> r9
- Arm64 -> x64 翻译:x0 -> RCX、x1 -> RDX、x2 -> R8、x3 -> R9
现在,请考虑其他函数:
int fK(int a, double b, int c, double d);
参数分配将如下所示:
- Arm64:a -> x0、b -> d0、c -> x1、d -> d1
- x64:a -> RCX、b -> XMM1、c -> R8、d -> XMM3
- Arm64 -> x64 翻译:x0 -> RCX、d0 -> XMM1、x1 -> R8、d1 -> XMM3
这些示例演示了参数赋值和转换因类型而异,但列表中的上述参数的类型也取决于这些类型。 此详细信息由第三个参数说明。 在这两个函数中,参数的类型为“int”,但生成的转换不同。
出于此原因,入口和退出 Thunks 存在,专门为每个单独的函数签名量身定做。
这两种类型的 Thunk 本身都是函数。 当 x64 函数调用 Arm64EC 函数(执行 Enters Arm64EC)时,仿真器会自动调用入口 Thunks。 当 Arm64EC 函数调用 x64 函数(执行 Exits Arm64EC)时,调用检查器会自动调用退出 Thunks。
编译 Arm64EC 代码时,编译器将为每个 Arm64EC 函数生成一个 Entry Thunk,使其签名匹配。 编译器还将为 Arm64EC 函数调用的每个函数生成 Exit Thunk。
请考虑以下示例:
struct SC {
char a;
char b;
char c;
};
int fB(int a, double b, int i1, int i2, int i3);
int fC(int a, struct SC c, int i1, int i2, int i3);
int fA(int a, double b, struct SC c, int i1, int i2, int i3) {
return fB(a, b, i1, i2, i3) + fC(a, c, i1, i2, i3);
}
编译上述面向 Arm64EC 的代码时,编译器将生成:
- “fA”的代码。
- “fA”条目 Thunk
- 退出“fB”的 Thunk
- 退出“fC”的 Thunk
fA
输入图恩是在 x64 代码中生成的fA
,从 x64 代码调用。 退出 Thunks,fB
并在出现fB
和fC
/或fC
时生成,并且结果为 x64 代码。
鉴于编译器将在调用站点而不是函数本身生成相同的 Exit Thunk,可能会多次生成它们。 这可能会导致大量的冗余 thunk,因此,实际上,编译器将应用微不足道的优化规则,以确保只有所需的 thunk 才能进入最终二进制文件。
例如,在 Arm64EC 函数A
调用 Arm64EC 函数B
B
的二进制文件中,不导出其地址,并且其地址在外部A
永远不会知道。 可以放心地将出口图克排除A
在B
外,以及进入图克。B
也可以安全地将所有 Exit 和 Entry thunk 组合在一起,这些命令包含相同的代码,即使它们是为不同的函数生成的。
使用示例函数fA
fB
及fC
更高版本,这是编译器如何生成fB
和fC
退出 Thunks:
退出 Thunk 到 int fB(int a, double b, int i1, int i2, int i3);
$iexit_thunk$cdecl$i8$i8di8i8i8:
stp fp,lr,[sp,#-0x10]!
mov fp,sp
sub sp,sp,#0x30
adrp x8,__os_arm64x_dispatch_call_no_redirect
ldr xip0,[x8]
str x3,[sp,#0x20] ; Spill 5th param (i3) into the stack
fmov d1,d0 ; Move 2nd param (b) from d0 to XMM1 (x1)
mov x3,x2 ; Move 4th param (i2) from x2 to R9 (x3)
mov x2,x1 ; Move 3rd param (i1) from x1 to R8 (x2)
blr xip0 ; Call the emulator
mov x0,x8 ; Move return from RAX (x8) to x0
add sp,sp,#0x30
ldp fp,lr,[sp],#0x10
ret
退出 Thunk 到 int fC(int a, struct SC c, int i1, int i2, int i3);
$iexit_thunk$cdecl$i8$i8m3i8i8i8:
stp fp,lr,[sp,#-0x20]!
mov fp,sp
sub sp,sp,#0x30
adrp x8,__os_arm64x_dispatch_call_no_redirect
ldr xip0,[x8]
str w1,[sp,#0x40] ; Spill 2nd param (c) onto the stack
add x1,sp,#0x40 ; Make RDX (x1) point to the spilled 2nd param
str x4,[sp,#0x20] ; Spill 5th param (i3) into the stack
blr xip0 ; Call the emulator
mov x0,x8 ; Move return from RAX (x8) to x0
add sp,sp,#0x30
ldp fp,lr,[sp],#0x20
ret
在这种情况下 fB
,我们可以看到存在“double”参数将如何导致剩余的 GP 寄存器分配重新洗牌,这是 Arm64 和 x64 的不同分配规则的结果。 我们还可以看到 x64 只向寄存器分配 4 个参数,因此必须将第 5 个参数溢出到堆栈中。
在这种情况下 fC
,第二个参数是 3 字节长度的结构。 Arm64 将允许将任何大小结构直接分配给寄存器。 x64 仅允许大小 1、2、4 和 8。 然后,此 Exit Thunk 必须将其从寄存器传输到 struct
堆栈上,并改为向寄存器分配指针。 这仍然使用一个寄存器(用于携带指针),因此它不会更改剩余寄存器的分配:第 3 和第 4 个参数不会进行寄存器重新分配。 与这种情况一样 fB
,第 5 个参数必须溢出到堆栈上。
Exit Thunks 的其他注意事项:
- 编译器将按它们从中>转换到的函数名称来命名它们,而是按它们地址的签名命名。 这样就更容易找到冗余。
- 使用带有目标 (x64) 函数地址的寄存器
x9
调用 Exit Thunk。 这是由调用检查器设置的,通过 Exit Thunk(未干扰)传递到仿真器。
重新排列参数后,Exit Thunk 然后通过 __os_arm64x_dispatch_call_no_redirect
调用仿真器。
此时,它值得查看调用检查器的功能,并详细介绍其自己的自定义 ABI。 这是间接调用 fB
的外观:
mov x11, <target>
adrp x9, __os_arm64x_check_icall_cfg
ldr x9, [x9, __os_arm64x_check_icall_cfg]
adrp x10, $iexit_thunk$cdecl$i8$i8di8i8i8 ; fB function's exit thunk
add x10, x10, $iexit_thunk$cdecl$i8$i8di8i8i8
blr x9 ; check target function
blr x11 ; call function
调用呼叫检查器时:
x11
提供要调用的目标函数的地址(fB
在本例中)。 目前,如果目标函数为 Arm64EC 或 x64,则目前可能未知。x10
提供与所调用函数的签名匹配的 Exit Thunk(fB
在本例中)。
调用检查器返回的数据将取决于作为 Arm64EC 或 x64 的目标函数。
如果目标是 Arm64EC:
x11
将返回要调用的 Arm64EC 代码的地址。 这可能与在 中提供的值可能相同或可能不相同。
如果目标是 x64 代码:
x11
将返回 Exit Thunk 的地址。 这是从提供的x10
输入复制的。x10
将返回从输入中取消干扰的 Exit Thunk 地址。x9
将返回目标 x64 函数。 这可能或可能不是通过 <a0/a0> 提供的相同值。
调用检查器将始终保持调用约定参数寄存器不受干扰,因此调用代码应紧 blr x11
跟调用检查器的调用(或在 br x11
结尾调用的情况下)。 这些是寄存器呼叫检查器。 它们将始终保留超过标准非易失性寄存器:x0
-x8
、x15
(chkstk
) 和 。q0
-q7
Entry Thunks 负责从 x64 到 Arm64 调用约定所需的转换。 这实质上是退出图克斯的反向,但还有一些需要考虑的方面。
请考虑前面的编译 fA
示例,生成一个 Entry Thunk, fA
以便 x64 代码可以调用它。
条目 Thunk int fA(int a, double b, struct SC c, int i1, int i2, int i3)
$ientry_thunk$cdecl$i8$i8dm3i8i8i8:
stp q6,q7,[sp,#-0xA0]! ; Spill full non-volatile XMM registers
stp q8,q9,[sp,#0x20]
stp q10,q11,[sp,#0x40]
stp q12,q13,[sp,#0x60]
stp q14,q15,[sp,#0x80]
stp fp,lr,[sp,#-0x10]!
mov fp,sp
ldrh w1,[x2] ; Load 3rd param (c) bits [15..0] directly into x1
ldrb w8,[x2,#2] ; Load 3rd param (c) bits [16..23] into temp w8
bfi w1,w8,#0x10,#8 ; Merge 3rd param (c) bits [16..23] into x1
mov x2,x3 ; Move the 4th param (i1) from R9 (x3) to x2
fmov d0,d1 ; Move the 2nd param (b) from XMM1 (d1) to d0
ldp x3,x4,[x4,#0x20] ; Load the 5th (i2) and 6th (i3) params
; from the stack into x3 and x4 (using x4)
blr x9 ; Call the function (fA)
mov x8,x0 ; Move the return from x0 to x8 (RAX)
ldp fp,lr,[sp],#0x10
ldp q14,q15,[sp,#0x80] ; Restore full non-volatile XMM registers
ldp q12,q13,[sp,#0x60]
ldp q10,q11,[sp,#0x40]
ldp q8,q9,[sp,#0x20]
ldp q6,q7,[sp],#0xA0
adrp xip0,__os_arm64x_dispatch_ret
ldr xip0,[xip0,__os_arm64x_dispatch_ret]
br xip0
目标函数的地址由模拟器提供 x9
。
在调用 Entry Thunk 之前,x64 仿真器会将堆栈中的返回地址弹出到寄存器中 LR
。 然后,当控件传输到 Entry Thunk 时, LR
它预期将指向 x64 代码。
仿真器还可以对堆栈执行另一个调整,具体取决于以下情况:Arm64 和 x64 ABI 定义堆栈对齐要求,其中调用函数时堆栈必须对齐到 16 字节。 运行 Arm64 代码时,硬件会强制实施此规则,但 x64 没有硬件强制实施。 运行 x64 代码时,错误地调用具有无对齐堆栈的函数可能会无限期地被忽略,直到使用约 16 字节对齐指令(某些 SSE 指令执行)或 Arm64EC 代码。
为了解决这种潜在的兼容性问题,在调用 Entry Thunk 之前,模拟器将始终将堆栈指针对齐到 16 字节,并将其原始值存储在寄存器中 x4
。 这样,Entry Thunks 始终使用对齐的堆栈开始执行,但仍可以正确引用通过 x4
堆栈传递的参数。
对于非易失性 SIMD 寄存器,Arm64 和 x64 调用约定之间存在显著差异。 在 Arm64 上,寄存器的低 8 字节(64 位)被视为非易失性。 换句话说,只有 Dn
寄存器的 Qn
一部分是非易失性的。 在 x64 上,寄存器的 XMMn
整个 16 字节被视为非易失性。 此外,在 x64 上,并且XMM7
是非易失性寄存器,XMM6
而 D6 和 D7(相应的 Arm64 寄存器)是易失性寄存器。
若要解决这些 SIMD 寄存器操作不对称问题,条目 Thunks 必须显式保存在 x64 中被视为非易失性的所有 SIMD 寄存器。 这仅在进入 Thunks(而不是退出 Thunks)上是必需的,因为 x64 比 Arm64 更严格。 换句话说,x64 中的注册保存/保留规则超出了所有情况下的 Arm64 要求。
为了在展开堆栈(例如 setjmp + longjmp 或 throw + catch)时正确恢复这些寄存器值,引入了新的展开操作码: save_any_reg (0xE7)
此新的 3 字节展开操作码允许保存任何常规用途或 SIMD 寄存器(包括被视为易失性寄存器),包括全尺寸 Qn
寄存器。 此新的操作码用于 Qn
上述寄存器溢出/填充操作。 save_any_reg
与 save_next_pair (0xE6)
..
有关参考,下面是属于上面显示的 Entry Thunk 的相应展开信息:
Prolog unwind:
06: E76689.. +0004 stp q6,q7,[sp,#-0xA0]! ; Actual=stp q6,q7,[sp,#-0xA0]!
05: E6...... +0008 stp q8,q9,[sp,#0x20] ; Actual=stp q8,q9,[sp,#0x20]
04: E6...... +000C stp q10,q11,[sp,#0x40] ; Actual=stp q10,q11,[sp,#0x40]
03: E6...... +0010 stp q12,q13,[sp,#0x60] ; Actual=stp q12,q13,[sp,#0x60]
02: E6...... +0014 stp q14,q15,[sp,#0x80] ; Actual=stp q14,q15,[sp,#0x80]
01: 81...... +0018 stp fp,lr,[sp,#-0x10]! ; Actual=stp fp,lr,[sp,#-0x10]!
00: E1...... +001C mov fp,sp ; Actual=mov fp,sp
+0020 (end sequence)
Epilog #1 unwind:
0B: 81...... +0044 ldp fp,lr,[sp],#0x10 ; Actual=ldp fp,lr,[sp],#0x10
0C: E74E88.. +0048 ldp q14,q15,[sp,#0x80] ; Actual=ldp q14,q15,[sp,#0x80]
0F: E74C86.. +004C ldp q12,q13,[sp,#0x60] ; Actual=ldp q12,q13,[sp,#0x60]
12: E74A84.. +0050 ldp q10,q11,[sp,#0x40] ; Actual=ldp q10,q11,[sp,#0x40]
15: E74882.. +0054 ldp q8,q9,[sp,#0x20] ; Actual=ldp q8,q9,[sp,#0x20]
18: E76689.. +0058 ldp q6,q7,[sp],#0xA0 ; Actual=ldp q6,q7,[sp],#0xA0
1C: E3...... +0060 nop ; Actual=90000030
1D: E3...... +0064 nop ; Actual=ldr xip0,[xip0,#8]
1E: E4...... +0068 end ; Actual=br xip0
+0070 (end sequence)
Arm64EC 函数返回后,__os_arm64x_dispatch_ret
例程用于重新输入仿真器,返回到 x64 代码(指向)。LR
Arm64EC 函数在保留的函数中的第一个指令之前具有 4 个字节,用于在运行时存储要使用的信息。 这 4 个字节中可以找到函数的 Entry Thunk 的相对地址。 执行从 x64 函数到 Arm64EC 函数的调用时,模拟器将在函数开始前读取 4 个字节,屏蔽下两位,并将该量添加到函数的地址。 这将生成要调用的 Entry Thunk 的地址。
调整器 Thunks 是无签名函数,在对其中一个参数执行一些转换后,只需将控制权转移到另一个函数(结尾调用)。 要转换的参数的类型是已知的,但所有剩余的参数都可以是任何内容,在任意数字中 – 调整器 Thunks 不会触摸任何可能持有参数的寄存器,也不会触摸堆栈。 这就是调整器 Thunks 无签名功能的功能。
调整器 Thunks 可由编译器自动生成。 例如,这很常见,C++多继承,其中,除了对指针的调整 this
之外,任何虚拟方法都可以委托给父类( 未修改)。
下面是一个实际示例:
[thunk]:CObjectContext::Release`adjustor{8}':
sub x0,x0,#8
b CObjectContext::Release
thunk 减去指针的 this
8 个字节,并将调用转发到父类。
总之,可从 x64 函数调用的 Arm64EC 函数必须具有关联的 Entry Thunk。 Entry Thunk 特定于签名。 Arm64 无签名函数(如 Adjustor Thunks)需要一种不同的机制来处理无签名函数。
调整器 Thunk 的 Entry Thunk 使用 __os_arm64x_x64_jump
帮助程序将实际 Entry Thunk 工作的执行延迟(将参数从一个约定调整到另一个约定)推迟到下一个调用。 此时,签名变得明显。 这包括不执行调用约定调整的选项(如果调整器 Thunk 的目标原来是 x64 函数)。 请记住,在 Entry Thunk 开始运行时,参数采用 x64 格式。
在上面的示例中,请考虑代码在 Arm64EC 中的外观。
Arm64EC 中的调整器 Thunk
[thunk]:CObjectContext::Release`adjustor{8}':
sub x0,x0,#8
adrp x9,CObjectContext::Release
add x11,x9,CObjectContext::Release
stp fp,lr,[sp,#-0x10]!
mov fp,sp
adrp xip0, __os_arm64x_check_icall
ldr xip0,[xip0, __os_arm64x_check_icall]
blr xip0
ldp fp,lr,[sp],#0x10
br x11
调整器 Thunk 的进入中继
[thunk]:CObjectContext::Release$entry_thunk`adjustor{8}':
sub x0,x0,#8
adrp x9,CObjectContext::Release
add x9,x9,CObjectContext::Release
adrp xip0,__os_arm64x_x64_jump
ldr xip0,[xip0,__os_arm64x_x64_jump]
br xip0
某些应用程序对驻留在二进制文件中的函数进行运行时修改,这些函数不拥有它们,但依赖于(通常是操作系统二进制文件),以便在调用函数时绕行执行。 这也称为挂钩。
在高级别,挂钩过程很简单。 但是,详细来说,挂钩是特定于体系结构的,而且非常复杂,因为挂钩逻辑必须解决的潜在变化。
一般情况下,此过程涉及以下内容:
- 确定要挂钩的函数的地址。
- 将函数的第一个指令替换为跳转到挂钩例程。
- 完成挂钩后,返回到原始逻辑,包括运行流离失所的原始指令。
变化源于以下情况:
- 第 1 个指令的大小:最好将其替换为相同大小或更小的 JMP,以避免替换函数的顶部,而其他线程可能正在运行它。
- 第一个指令的类型:如果第一个指令具有一些电脑相对性质,则重新定位可能需要更改排量字段等内容。 由于当指令移动到遥远的位置时,它们可能会溢出,因此这可能需要提供具有不同指令的等效逻辑。
由于所有这些复杂性,可靠和泛型挂钩逻辑很少发现。 应用程序中存在的逻辑通常只能处理应用程序预期在感兴趣的特定 API 中遇到的一组有限的情况。 很难想象,这是多少应用程序兼容性问题。 即使代码或编译器优化中的简单更改也可能使应用程序不再如预期一样不可用。
如果在设置挂钩时遇到 Arm64 代码,这些应用程序会发生什么情况? 他们肯定会失败。
快速转发序列 (FFS) 函数解决了 Arm64EC 中的这种兼容性要求。
FFS 非常小的 x64 函数,它不包含对实际 Arm64EC 函数的实际逻辑和尾部调用。 它们是可选的,但默认为所有 DLL 导出和修饰的任何 __declspec(hybrid_patchable)
函数启用。
对于这些情况,当代码获取指向给定函数的指针时,无论是在GetProcAddress
导出事例&function
__declspec(hybrid_patchable)
中还是在这种情况下,生成的地址将包含 x64 代码。 该 x64 代码将为合法的 x64 函数传递,满足当前可用的大多数挂钩逻辑。
请考虑以下示例(为简洁起见省略的错误处理):
auto module_handle =
GetModuleHandleW(L"api-ms-win-core-processthreads-l1-1-7.dll");
auto pgma =
(decltype(&GetMachineTypeAttributes))
GetProcAddress(module_handle, "GetMachineTypeAttributes");
hr = (*pgma)(IMAGE_FILE_MACHINE_Arm64, &MachineAttributes);
变量中的 pgma
函数指针值将包含 's FFS 的 GetMachineTypeAttributes
地址。
这是快速转发序列的示例:
kernelbase!EXP+#GetMachineTypeAttributes:
00000001`800034e0 488bc4 mov rax,rsp
00000001`800034e3 48895820 mov qword ptr [rax+20h],rbx
00000001`800034e7 55 push rbp
00000001`800034e8 5d pop rbp
00000001`800034e9 e922032400 jmp 00000001`80243810
FFS x64 函数具有规范 prolog 和 epilog,以结尾调用(jump)结尾,以 Arm64EC 代码中的真实 GetMachineTypeAttributes
函数结尾:
kernelbase!GetMachineTypeAttributes:
00000001`80243810 d503237f pacibsp
00000001`80243814 a9bc7bfd stp fp,lr,[sp,#-0x40]!
00000001`80243818 a90153f3 stp x19,x20,[sp,#0x10]
00000001`8024381c a9025bf5 stp x21,x22,[sp,#0x20]
00000001`80243820 f9001bf9 str x25,[sp,#0x30]
00000001`80243824 910003fd mov fp,sp
00000001`80243828 97fbe65e bl kernelbase!#__security_push_cookie
00000001`8024382c d10083ff sub sp,sp,#0x20
[...]
如果需要在两个 Arm64EC 函数之间运行 5 个模拟 x64 指令,则效率会相当低。 FFS 函数很特殊。 如果 FFS 函数保持不变,则不会真正运行。 呼叫检查器帮助程序将有效地检查 FFS 是否已更改。 如果是这种情况,则呼叫将直接转移到实际目标。 如果 FFS 已以任何方式更改,则它将不再是 FFS。 执行将传输到已更改的 FFS,并运行可能存在的任何代码,模拟绕道和任何挂钩逻辑。
当挂钩将执行传回 FFS 的末尾时,它最终将到达对 Arm64EC 代码的结尾调用,后者随后会在挂钩后执行,就像应用程序预期的那样。
Windows SDK 标头和 C 编译器可以简化创作 Arm64EC 程序集的作业。 例如,C 编译器可用于为未从 C 代码编译的函数生成 Entry 和 Exit Thunks。
请考虑与必须在 Assembly(ASM)中创作的以下函数 fD
等效的示例。 此函数可由 Arm64EC 和 x64 代码调用, pfE
函数指针也可以指向 Arm64EC 或 x64 代码。
typedef int (PF_E)(int, double);
extern PF_E * pfE;
int fD(int i, double d) {
return (*pfE)(i, d);
}
以 ASM 编写的 fD
内容如下所示:
#include "ksarm64.h"
IMPORT __os_arm64x_check_icall_cfg
IMPORT |$iexit_thunk$cdecl$i8$i8d|
IMPORT pfE
NESTED_ENTRY_COMDAT A64NAME(fD)
PROLOG_SAVE_REG_PAIR fp, lr, #-16!
adrp x11, pfE ; Get the global function
ldr x11, [x11, pfE] ; pointer pfE
adrp x9, __os_arm64x_check_icall_cfg ; Get the EC call checker
ldr x9, [x9, __os_arm64x_check_icall_cfg] ; with CFG
adrp x10, |$iexit_thunk$cdecl$i8$i8d| ; Get the Exit Thunk for
add x10, x10, |$iexit_thunk$cdecl$i8$i8d| ; int f(int, double);
blr x9 ; Invoke the call checker
blr x11 ; Invoke the function
EPILOG_RESTORE_REG_PAIR fp, lr, #16!
EPILOG_RETURN
NESTED_END
end
在上面的示例中:
- Arm64EC 使用与 Arm64 相同的过程声明和 prolog/epilog 宏。
- 函数名称应由
A64NAME
宏包装。 将 C/C++ 代码编译为 Arm64EC 时,编译器将标记为OBJ
ARM64EC
包含 Arm64EC 代码。 这与ARMASM
.. 编译 ASM 代码时,可以通过另一种方法通知链接器生成的代码为 Arm64EC。 这是通过为函数名称#
加上前缀。 宏A64NAME
在定义时_ARM64EC_
执行此操作,并在未定义时_ARM64EC_
保留名称不变。 这样就可以在 Arm64 和 Arm64EC 之间共享源代码。 pfE
如果目标函数为 x64,则必须首先通过 EC 调用检查器以及相应的 Exit Thunk 运行函数指针。
下一步是生成 Entry Thunk for fD
和 Exit Thunk。pfE
C 编译器可以使用编译器关键字尽可能少地 _Arm64XGenerateThunk
执行此任务。
void _Arm64XGenerateThunk(int);
int fD2(int i, double d) {
UNREFERENCED_PARAMETER(i);
UNREFERENCED_PARAMETER(d);
_Arm64XGenerateThunk(2);
return 0;
}
int fE(int i, double d) {
UNREFERENCED_PARAMETER(i);
UNREFERENCED_PARAMETER(d);
_Arm64XGenerateThunk(1);
return 0;
}
关键字 _Arm64XGenerateThunk
告知 C 编译器使用函数签名、忽略正文并生成 Exit Thunk(参数为 1 时)或 Entry Thunk(参数为 2 时)。
建议将 thunk 生成放在其自己的 C 文件中。 在独立文件中,通过转储相应的 OBJ
符号甚至反汇编来更轻松地确认符号名称。
已将宏添加到 SDK,以帮助创作自定义、手动编码的 Entry Thunks。 一种可以使用这种情况的情况是创作自定义调整器 Thunks。
大多数调整器 Thunk 由C++编译器生成,但它们也可以手动生成。 在泛型回调将控制转移到实际回调(由其中一个参数标识)的情况下,可以找到这种情况。
下面是 Arm64 经典代码中的一个示例:
NESTED_ENTRY MyAdjustorThunk
PROLOG_SAVE_REG_PAIR fp, lr, #-16!
ldr x15, [x0, 0x18]
adrp x16, __guard_check_icall_fptr
ldr x16, [x16, __guard_check_icall_fptr]
blr xip0
EPILOG_RESTORE_REG_PAIR fp, lr, #16
EPILOG_END br x15
NESTED_END
在此示例中,目标函数地址通过第 1 个参数从由引用提供的结构元素中检索。 由于结构是可写的,因此必须通过控制流防护(CFG)验证目标地址。
以下示例演示移植到 Arm64EC 时等效的调整器 Thunk 的外观:
NESTED_ENTRY_COMDAT A64NAME(MyAdjustorThunk)
PROLOG_SAVE_REG_PAIR fp, lr, #-16!
ldr x11, [x0, 0x18]
adrp xip0, __os_arm64x_check_icall_cfg
ldr xip0, [xip0, __os_arm64x_check_icall_cfg]
blr xip0
EPILOG_RESTORE_REG_PAIR fp, lr, #16
EPILOG_END br x11
NESTED_END
上面的代码不提供 Exit Thunk(在寄存器 x10 中)。 这是不可能的,因为代码可以针对许多不同的签名执行。 此代码利用调用方将 x10 设置为 Exit Thunk。 调用方将发出面向显式签名的调用。
上述代码确实需要一个 Entry Thunk 来处理调用方为 x64 代码时的情况。 这是如何使用自定义 Entry Thunks 的宏创作相应的 Entry Thunk:
ARM64EC_CUSTOM_ENTRY_THUNK A64NAME(MyAdjustorThunk)
ldr x9, [x0, 0x18]
adrp xip0, __os_arm64x_x64_jump
ldr xip0, [xip0, __os_arm64x_x64_jump]
br xip0
LEAF_END
与其他函数不同,此 Entry Thunk 最终不会将控制权转移到关联的函数(调整器 Thunk)。 在这种情况下,功能本身(执行参数调整)嵌入到 Entry Thunk 中,并且控件通过 __os_arm64x_x64_jump
帮助程序直接传输到最终目标。
在 Arm64EC 进程中,有两种类型的可执行内存:Arm64EC 代码和 x64 代码。
操作系统从加载的二进制文件中提取此信息。 x64 二进制文件都是 x64,Arm64EC 包含 Arm64EC 与 x64 代码页的范围表。
动态生成的代码是什么? 实时 (JIT) 编译器在运行时生成代码,该代码不受任何二进制文件支持。
通常,这意味着:
- 分配可写内存(
VirtualAlloc
)。 - 将代码生成到分配的内存中。
- 重新保护内存,防止读取写入到读取执行(
VirtualProtect
)。 - 为所有非普通(非叶)生成的函数(
RtlAddFunctionTable
或RtlAddGrowableFunctionTable
)添加展开函数条目。
出于简单兼容性原因,在 Arm64EC 进程中执行这些步骤的任何应用程序都将导致代码被视为 x64 代码。 对于使用未修改的 x64 Java 运行时、.NET 运行时、JavaScript 引擎等的任何进程,都会发生这种情况。
若要生成 Arm64EC 动态代码,该过程大致相同,只有两个差异:
- 分配内存时,请使用较
VirtualAlloc2
新的(而不是VirtualAlloc
或VirtualAllocEx
)并提供MEM_EXTENDED_PARAMETER_EC_CODE
属性。 - 添加函数条目时:
- 它们必须采用 Arm64 格式。 编译 Arm64EC 代码时,该
RUNTIME_FUNCTION
类型将与 x64 格式匹配。 对于编译 Arm64EC 时的 Arm64 格式,请改用类型ARM64_RUNTIME_FUNCTION
。 - 请勿使用较旧的
RtlAddFunctionTable
API。 请改为使用较RtlAddGrowableFunctionTable
新的 API。
- 它们必须采用 Arm64 格式。 编译 Arm64EC 代码时,该
下面是内存分配的示例:
MEM_EXTENDED_PARAMETER Parameter = { 0 };
Parameter.Type = MemExtendedParameterAttributeFlags;
Parameter.ULong64 = MEM_EXTENDED_PARAMETER_EC_CODE;
HANDLE process = GetCurrentProcess();
ULONG allocationType = MEM_RESERVE;
DWORD protection = PAGE_EXECUTE_READ | PAGE_TARGETS_INVALID;
address = VirtualAlloc2 (
process,
NULL,
numBytesToAllocate,
allocationType,
protection,
&Parameter,
1);
添加一个展开函数条目的示例:
ARM64_RUNTIME_FUNCTION FunctionTable[1];
FunctionTable[0].BeginAddress = 0;
FunctionTable[0].Flags = PdataPackedUnwindFunction;
FunctionTable[0].FunctionLength = nSize / 4;
FunctionTable[0].RegF = 0; // no D regs saved
FunctionTable[0].RegI = 0; // no X regs saved beyond fp,lr
FunctionTable[0].H = 0; // no home for x0-x7
FunctionTable[0].CR = PdataCrChained; // stp fp,lr,[sp,#-0x10]!
// mov fp,sp
FunctionTable[0].FrameSize = 1; // 16 / 16 = 1
this->DynamicTable = NULL;
Result == RtlAddGrowableFunctionTable(
&this->DynamicTable,
reinterpret_cast<PRUNTIME_FUNCTION>(FunctionTable),
1,
1,
reinterpret_cast<ULONG_PTR>(pBegin),
reinterpret_cast<ULONG_PTR>(reinterpret_cast<PBYTE>(pBegin) + nSize)
);