了解 Arm64EC ABI 和汇编代码

Arm64EC(“仿真兼容”)是一个新的应用程序二进制接口(ABI),用于在 Arm 上生成适用于 Windows 11 的应用。 有关 Arm64EC 的概述以及如何开始将 Win32 应用生成为 Arm64EC,请参阅 使用 Arm64EC 在 Arm 设备上生成适用于 Windows 11 的应用。

本文档的目的是提供 Arm64EC ABI 的详细视图,其中包含足够的信息,以便应用程序开发人员编写和调试为 Arm64EC 编译的代码,包括低级别/汇编程序调试和编写面向 Arm64EC ABI 的程序集代码。

Arm64EC 的设计

Arm64EC 旨在提供本机级别的功能和性能,同时提供与模拟下运行的 x64 代码的透明和直接互操作性。

Arm64EC 主要与经典 Arm64 ABI 相加。 很少更改经典 ABI,但添加了部分以启用 x64 互操作性。

本文档中,原始标准 Arm64 ABI 应称为“经典 ABI”。 这避免了重载术语(如“Native”)固有的歧义性。 要清楚起见,Arm64EC 与原始 ABI 一样本机。

Arm64EC 与 Arm64 经典 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、到X1RDXRSPSPRIPPC等。我们还可以查看寄存器x13x14x23x24x28没有v16-v31表示形式的方式,因此不能在 Arm64EC 中使用。

此寄存器使用限制是 Arm64 经典版和 EC API 之间的第一个区别。

呼叫检查器

自 Windows 8.1 中引入控制流防护(CFG)以来,呼叫检查器一直是 Windows 的一部分。 调用检查器是函数指针的地址清理器(在这些内容称为地址消毒器之前)。 每次使用选项/guard:cf编译代码时,编译器都会在每个间接调用/跳转之前生成对 检查er 函数的额外调用。 检查er 函数本身由 Windows 提供,对于 CFG,它针对已知良好的调用目标执行有效性检查。 此信息也包含在编译的 /guard:cf二进制文件中。

这是经典 Arm64 中使用的调用检查er 的示例:

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 案例中,如果目标有效,则调用检查er 将仅返回;如果目标无效,则快速失败进程。 呼叫检查程序具有自定义调用约定。 它们采用普通调用约定不使用的寄存器中的函数指针,并保留所有普通调用约定寄存器。 这样,他们就不会在它们周围引入注册溢出。

调用检查器在所有其他 Windows API 上都是可选的,但在 Arm64EC 上是必需的。 在 Arm64EC 上,调用检查器会累积验证所调用函数的体系结构的任务。 它们验证调用是另一个 EC(“仿真兼容”)函数还是必须在仿真下执行的 x64 函数。 在许多情况下,只能在运行时对此进行验证。

Arm64EC 调用检查器构建在现有 Arm64 检查ers 的基础上,但它们的自定义调用约定略有不同。 它们采用额外的参数,可以修改包含目标地址的寄存器。 例如,如果目标为 x64 代码,则必须首先将控件传输到仿真基架逻辑。

在 Arm64EC 中,同一调用检查er 的使用将变为:

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 的细微差异包括:

  • 调用检查er 的符号名称不同。
  • 目标地址提供, x11 而不是 x15
  • 目标地址 (x11[in, out] 不是 [in]
  • 有一个额外的参数,通过 x10提供,称为“Exit Thunk”。

Exit Thunk 是一个 funclet,它将函数参数从 Arm64EC 调用约定转换为 x64 调用约定。

Arm64EC 调用检查er 通过不同于 Windows 中其他 API 使用的符号。 在经典 Arm64 ABI 上,调用检查er 的符号为 __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应使用 检查er。 如果禁用或不需要 CFG, __os_arm64x_check_icall 则应改用。

下面是经典 Arm64、x64 和 Arm64EC 上的调用检查er 用法的摘要表,指出 Arm64EC 二进制文件可以有两个选项,具体取决于代码的体系结构。

二进制 代码 未受保护的间接呼叫 CFG 保护的间接呼叫
X64 X64 无调用检查er __guard_check_icall_fptr__guard_dispatch_icall_fptr
Arm64 经典版 ARM64 无调用检查er __guard_check_icall_fptr
Arm64EC x64 无调用检查er __guard_check_icall_fptr__guard_dispatch_icall_fptr
Arm64EC __os_arm64x_check_icall __os_arm64x_check_icall_cfg

独立于 ABI,启用 CFG 的代码(引用 CFG 调用检查器的代码)并不意味着在运行时提供 CFG 保护。 CFG 保护的二进制文件可以在不支持 CFG 的系统上运行低级别:在编译时使用无操作帮助程序初始化调用检查er。 进程也可能通过配置禁用 CFG。 在以前的 API 上禁用 CFG(或 OS 支持不存在)时,OS 在加载二进制文件时不会更新调用检查er。 在 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 堆栈检查er 相关联。 Arm64EC 代码将改用 __chkstk_arm64ec

自定义调用约定与经典 Arm64 __chkstk相同__chkstk_arm64ecx15提供分配的大小(以字节为单位),除以 16。 将保留所有非易失性寄存器以及标准调用约定中涉及的所有易失寄存器。

上面所说的一__chkstk切同样适用于__security_check_cookie其 Arm64EC 对应项: __security_check_cookie_arm64ec

Variadic 调用约定

Arm64EC 遵循经典 Arm64 ABI 调用约定,除了 Variadic 函数(aka varargs、aka 函数以及省略号 (. .. .) 参数关键字 (keyword))。

对于可变特定情况,Arm64EC 遵循的调用约定与 x64 可变性非常相似,只存在一些差异。 下面是 Arm64EC 可变性的主要规则:

  • 仅前 4 个寄存器用于参数传递: x0x1x2x3。 其余参数将溢出到堆栈上。 这完全遵循 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

ABI 新增内容

为了实现与 x64 代码的透明互操作性,已对经典 Arm64 ABI 进行了许多添加。 它们处理 Arm64EC 和 x64 之间的调用约定差异。

以下列表包括以下新增内容:

进入和退出 Thunks

进入和退出 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并在出现fBfC/或fC时生成,并且结果为 x64 代码。

鉴于编译器将在调用站点而不是函数本身生成相同的 Exit Thunk,可能会多次生成它们。 这可能会导致大量的冗余 thunk,因此,实际上,编译器将应用微不足道的优化规则,以确保只有所需的 thunk 才能进入最终二进制文件。

例如,在 Arm64EC 函数A调用 Arm64EC 函数BB的二进制文件中,不导出其地址,并且其地址在外部A永远不会知道。 可以放心地将出口图克排除AB外,以及进入图克。B 也可以安全地将所有 Exit 和 Entry thunk 组合在一起,这些命令包含相同的代码,即使它们是为不同的函数生成的。

退出 Thunks

使用示例函数fAfBfC更高版本,这是编译器如何生成fBfC退出 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。 这是由调用检查er 设置的,并通过 Exit Thunk(未干扰)传递到仿真器。

重新排列参数后,Exit Thunk 然后通过 __os_arm64x_dispatch_call_no_redirect调用仿真器。

此时,它值得查看调用检查er 的函数,并详细介绍其自己的自定义 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

调用呼叫检查er 时:

  • 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> 提供的相同值。

调用检查器将始终保留调用约定参数寄存器,因此调用代码应紧跟调用检查er 的blr x11调用(或者在br x11结尾调用的情况下)。 这些是寄存器调用检查器。 它们将始终保留超过标准非易失性寄存器:x0-x8x15chkstk) 和 。q0-q7

Entry Thunks

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 字节对齐指令(某些 S标准版 指令执行)或 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_regsave_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 无签名功能的功能。

调整器 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 尚未更改,则调用检查er 帮助程序将有效地检查。 如果是这种情况,则呼叫将直接转移到实际目标。 如果 FFS 已以任何方式更改,则它将不再是 FFS。 执行将传输到已更改的 FFS,并运行可能存在的任何代码,模拟绕道和任何挂钩逻辑。

当挂钩将执行传回 FFS 的末尾时,它最终将到达对 Arm64EC 代码的结尾调用,后者随后会在挂钩后执行,就像应用程序预期的那样。

在程序集中创作 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 时,编译器将标记为OBJARM64EC包含 Arm64EC 代码。 这与 ARMASM.. 编译 ASM 代码时,可以通过另一种方法通知链接器生成的代码为 Arm64EC。 这是通过为函数名称 #加上前缀。 宏 A64NAME 在定义时 _ARM64EC_ 执行此操作,并在未定义时 _ARM64EC_ 保留名称不变。 这样就可以在 Arm64 和 Arm64EC 之间共享源代码。
  • pfE如果目标函数为 x64,则必须首先通过 EC 调用检查er 运行函数指针,以及相应的 Exit Thunk。

生成进入和退出 Thunks

下一步是生成 Entry Thunk for fD 和 Exit Thunk。pfE 使用编译器关键字 (keyword),_Arm64XGenerateThunkC 编译器可以尽可能少地执行此任务。

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 关键字 (keyword)告知 C 编译器使用函数签名、忽略正文并生成 Exit Thunk(参数为 1 时)或 Entry Thunk(参数为 2 时)。

建议将 thunk 生成放在其自己的 C 文件中。 在独立文件中,通过转储相应的 OBJ 符号甚至反汇编来更轻松地确认符号名称。

自定义条目 Thunks

已将宏添加到 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 帮助程序直接传输到最终目标。

动态生成 (JIT 编译) Arm64EC 代码

在 Arm64EC 进程中,有两种类型的可执行内存:Arm64EC 代码和 x64 代码。

操作系统从加载的二进制文件中提取此信息。 x64 二进制文件都是 x64,Arm64EC 包含 Arm64EC 与 x64 代码页的范围表。

动态生成的代码是什么? 实时 (JIT) 编译器在运行时生成代码,该代码不受任何二进制文件支持。

通常,这意味着:

  • 分配可写内存(VirtualAlloc)。
  • 将代码生成到分配的内存中。
  • 重新保护内存,防止读取写入到读取执行(VirtualProtect)。
  • 为所有非普通(非叶)生成的函数(RtlAddFunctionTableRtlAddGrowableFunctionTable)添加展开函数条目。

出于简单兼容性原因,在 Arm64EC 进程中执行这些步骤的任何应用程序都将导致代码被视为 x64 代码。 对于使用未修改的 x64 Java 运行时、.NET 运行时、JavaScript 引擎等的任何进程,都会发生这种情况。

若要生成 Arm64EC 动态代码,该过程大致相同,只有两个差异:

  • 分配内存时,请使用较 VirtualAlloc2 新的(而不是 VirtualAllocVirtualAllocEx)并提供 MEM_EXTENDED_PARAMETER_EC_CODE 属性。
  • 添加函数条目时:
    • 它们必须采用 Arm64 格式。 编译 Arm64EC 代码时,该 RUNTIME_FUNCTION 类型将与 x64 格式匹配。 对于编译 Arm64EC 时的 Arm64 格式,请改用类型 ARM64_RUNTIME_FUNCTION
    • 请勿使用较旧的 RtlAddFunctionTable API。 请改为使用较 RtlAddGrowableFunctionTable 新的 API。

下面是内存分配的示例:

    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)
);