Arm64EC ABI とアセンブリ コードについて
Arm64EC ("エミュレーション互換") は、Arm 上の Windows 11 用アプリを構築するための新しいアプリケーション バイナリ インターフェイス (ABI) です。 Arm64EC の概要と、Arm64EC として Win32 アプリの構築を開始する方法については、「 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_
が定義されます。 この規則の影響を受ける型の 1 つは、 CONTEXT
構造体です。 CONTEXT
構造体は、特定の時点での CPU の状態を定義します。 これは、 Exception Handling
API や GetThreadContext
API などに使用されます。 既存の x64 コードでは、CPU コンテキストが x64 CONTEXT
構造体として表されるか、つまり、x64 コンパイル中に定義されている CONTEXT
構造体として表されます。
この構造体は、x64 コードと Arm64EC コードの実行中に CPU コンテキストを表すために使用する必要があります。 既存のコードでは、CPU レジスタ セットが関数から関数に変わるなど、新しい概念を理解できません。 arm64 の実行状態を表すために x64 CONTEXT
構造体を使用する場合、これは Arm64 レジスタが実質的に x64 レジスタにマップされていることを意味します。
また、x64 CONTEXT
に取り付けることができない Arm64 レジスタは、 CONTEXT
を使用する操作が発生するたびに値が失われる可能性があるため、使用しないでください (マネージド言語ランタイムのガベージ コレクション操作や APC など、非同期で予期しないものもあります)。
Arm64EC と x64 レジスタ間のマッピング規則は、SDK に存在する Windows ヘッダーの ARM64EC_NT_CONTEXT
構造によって表されます。 この構造体は基本的に、x64 用に定義されているのとまったく同じように、 CONTEXT
構造体の和集合ですが、追加の Arm64 レジスタ オーバーレイを使用します。
たとえば、RCX
は、X0
、X1
へのRDX
、SP
へのRSP
、PC
へのRIP
などです。また、レジスタx13
、x14
、x23
、x24
、x28
、v16
-v31
に表現がないため、Arm64EC では使用できないことがわかります。
このレジスタの使用制限は、Arm64 クラシック ABIs と EC ABI の最初の違いです。
呼び出しチェッカーは、Windows 8.1 で Control Flow Guard (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 AVI では省略可能ですが、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 とのわずかな違いは次のとおりです。
- 呼び出しチェッカーのシンボル名が異なります。
- ターゲット アドレスは、
x15
ではなく、x11
で指定されます。 - ターゲット アドレス (
x11
) は、[in]
ではなく[in, out]
されます。 - "Exit Thunk" と呼ばれる、
x10
を介して提供される追加のパラメーターがあります。
Exit Thunk は、関数パラメーターを Arm64EC 呼び出し規則から x64 呼び出し規則に変換する機能です。
Arm64EC 呼び出しチェッカーは、Windows の他の ABI に使用されるシンボルとは異なるシンボルを介して配置されます。 クラシック Arm64 ABI では、呼び出しチェッカーのシンボルは __guard_check_icall_fptr
。 このシンボルは Arm64EC に存在しますが、Arm64EC コード自体ではなく、x64 静的にリンクされたコードが使用されます。 Arm64EC コードでは、 __os_arm64x_check_icall
または __os_arm64x_check_icall_cfg
が使用されます。
Arm64EC では、呼び出しチェッカーは省略可能ではありません。 ただし、他の ABI の場合と同様に、CFG は引き続き省略可能です。 CFG はコンパイル時に無効にされるか、CFG が有効になっている場合でも CFG チェックを実行しない正当な理由がある場合があります (たとえば、関数ポインターが RW メモリに存在しない場合など)。 CFG チェックを使用した間接呼び出しの場合は、 __os_arm64x_check_icall_cfg
チェッカーを使用する必要があります。 CFG が無効になっているか不要な場合は、代わりに __os_arm64x_check_icall
を使用する必要があります。
以下は、従来の Arm64、x64、Arm64EC での呼び出しチェッカーの使用方法の概要表です。Arm64EC バイナリには、コードのアーキテクチャに応じて 2 つのオプションがあります。
バイナリ | コード | 保護されていない間接呼び出し | 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 をサポートしていないシステムでダウンレベルで実行できます。呼び出しチェッカーはコンパイル時に no-op ヘルパーで初期化されます。 プロセスでは、構成によって CFG が無効になっている場合もあります。 以前の AVI で CFG が無効になっている (または OS のサポートが存在しない) 場合、OS はバイナリの読み込み時に呼び出しチェッカーを更新しません。 Arm64EC では、CFG 保護が無効になっている場合、OS は __os_arm64x_check_icall
と同じ__os_arm64x_check_icall_cfg
設定されます。これは、CFG 保護ではなく、すべてのケースで必要なターゲット アーキテクチャ チェックを引き続き提供します。
クラシック Arm64 の CFG と同様に、ターゲット関数 (x11
) の呼び出しは、呼び出しチェッカーの呼び出しの直後に行う必要があります。 呼び出しチェッカーのアドレスは揮発性レジスタに配置する必要があり、別のレジスタにコピーしたり、メモリに書き込んだりすることはできません。
__chkstk
は、関数がページより大きいスタック上の領域を割り当てるたびに、コンパイラによって自動的に使用されます。 スタックの末尾を保護するスタック ガード ページをスキップしないようにするために、 __chkstk
を呼び出して、割り当てられた領域内のすべてのページがプローブされるようにします。
__chkstk
は通常、関数のプロローグから呼び出されます。 そのため、最適なコード生成のために、カスタム呼び出し規則が使用されます。
これは、x64 コードと Arm64EC コードには、Entry と Exit サンクが標準の呼び出し規則を前提としており、独自の個別の __chkstk
関数が必要であることを意味します。
x64 と Arm64EC は同じシンボル名前空間を共有するため、 __chkstk
という名前の 2 つの関数を指定することはできません。 既存の x64 コードとの互換性に対応するために、 __chkstk
名は x64 スタック チェッカーに関連付けられます。 Arm64EC コードでは、代わりに __chkstk_arm64ec
が使用されます。
__chkstk_arm64ec
のカスタム呼び出し規則は、クラシック Arm64 __chkstk
の場合と同じです。x15
は、割り当てのサイズをバイト単位で 16 で割って提供します。 すべての非揮発性レジスタと、標準呼び出し規則に関係するすべての揮発性レジスタは保持されます。
上記の __chkstk
に関するすべての内容は、 __security_check_cookie
とそれに対応する Arm64EC ( __security_check_cookie_arm64ec
) にも同様に適用されます。
Arm64EC は、Variadic 関数 (別名 varargs、省略記号 (..) パラメーター キーワードを持つ関数) を除き、従来の Arm64 ABI 呼び出し規則に従います。
可変数固有のケースでは、Arm64EC は x64 可変数に非常によく似た呼び出し規則に従い、わずかな違いしかありません。 Arm64EC 可変性の主な規則を次に示します。
- パラメーターの受け渡しには、最初の 4 つのレジスタ (
x0
、x1
、x2
、x3
) のみが使用されます。 残りのパラメーターはスタックにスピルされます。 これは x64 可変呼び出し規則に厳密に従っており、レジスタx0
->x7
が使用される Arm64 クラシックとは異なります。 - レジスタによって渡される浮動小数点/SIMD パラメーターは、SIMD ではなく汎用レジスタを使用します。 これは Arm64 クラシックに似ていますが、汎用レジスタと SIMD レジスタの両方で FP/SIMD パラメーターが渡される x64 とは異なります。 たとえば、
f1(int, double)
として呼び出f1(int, …)
関数の場合、x64 では、2 番目のパラメーターがRDX
とXMM1
の両方に割り当てられます。 Arm64EC では、2 番目のパラメーターはx1
のみに割り当てられます。 - レジスタを介して値によって構造体を渡す場合、x64 サイズルールが適用されます。サイズが正確に 1、2、4、8 の構造体が汎用レジスタに直接読み込まれます。 他のサイズの構造体がスタックにスピルされ、スピルされた場所へのポインターがレジスタに割り当てられます。 これは基本的に、低レベルで値を基準に降格します。 クラシック Arm64 ABI では、最大 16 バイトの任意のサイズの構造体が汎用レジスタに直接割り当てられます。
- X4 レジスタは、スタック経由で渡された最初のパラメーター (5 番目のパラメーター) へのポインターを使用して読み込まれます。 これには、上記のサイズ制限のために書き込まれた構造体は含まれません。
- 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
は、クラシック Arm64 呼び出し規則規則に従って割り当てられる 5 つのパラメーターを受け取ります。
- 'f' は double です。 d0 に割り当てられます。
- 'tc' は構造体で、サイズは 3 バイトです。 x0 に割り当てられます。
- ull1 は 8 バイト整数です。 x1 に割り当てられます。
- ull2 は 8 バイトの整数です。 x2 に割り当てられます。
- ull3 は 8 バイト整数です。 x3 に割り当てられます。
pt_va_function
は可変関数であるため、上記の Arm64EC 可変規則に従います。
- 'f' は double です。 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 の呼び出し規則の違いを処理します。
次の一覧には、これらの追加が含まれています。
入退室サンクは、Arm64EC 呼び出し規則 (ほぼ従来の Arm64 と同じ) を x64 呼び出し規則に変換し、その逆の処理を行います。
一般的な誤解は、呼び出し規則は、すべての関数シグネチャに適用される 1 つの規則に従うことで変換できることです。 実際には、呼び出し規則にはパラメーター割り当て規則があります。 これらのルールはパラメーターの種類によって異なり、ABI と ABI は異なります。 その結果、AVI 間の変換は各関数シグネチャに固有になり、各パラメーターの型によって異なります。
次のような関数があるとします。
int fJ(int a, int b, int c, int d);
パラメーターの割り当ては次のように行われます。
- Arm64: a -> x0, b -> x1, c -> x2, d -> x3
- x64: -> 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: -> RCX、b -> XMM1、c -> R8、d -> XMM3
- Arm64 -> x64 変換: x0 -> RCX、d0 -> XMM1、x1 -> R8、d1 -> XMM3
これらの例では、パラメーターの割り当てと変換は型によって異なりますが、一覧の前のパラメーターの型も依存していることを示しています。 この詳細は、3 番目のパラメーターで示されています。 どちらの関数でも、パラメーターの型は "int" ですが、結果の変換は異なります。
この理由から、エントリサンクと Exit サンクは存在し、個々の関数シグネチャに合わせて特別に調整されています。
どちらの種類のサンクも、それ自体が関数です。 x64 関数が Arm64EC 関数 (実行 Enters Arm64EC) を呼び出すと、エミュレーターによってエントリ サンクが自動的に呼び出されます。 Arm64EC 関数が x64 関数 (実行 Exits Arm64EC) を呼び出すと、Exit 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' のエントリ サンク
- 'fB' のサンクを終了する
- 'fC' のサンクを終了する
fA
エントリ サンクは、fA
場合に生成され、x64 コードから呼び出されます。 fB
およびfC
の終了サンクは、fB
やfC
の場合に生成され、x64 コードであることが判明します。
コンパイラが関数自体ではなく呼び出しサイトで生成することを考えると、同じ Exit Thunk が複数回生成される可能性があります。 これにより、かなりの量の冗長サンクが発生する可能性があるため、実際には、コンパイラは単純な最適化ルールを適用して、必要なサンクのみが最終的なバイナリになるようにします。
たとえば、Arm64EC 関数 A
が Arm64EC 関数 B
を呼び出すバイナリでは、 B
はエクスポートされず、そのアドレスは A
の外部では認識されません。 B
のエントリサンクと一緒に、A
からB
に出口サンクを排除することは安全です。 また、異なる関数に対して生成された場合でも、同じコードを含むすべての Exit サンクと Entry サンクをエイリアス化しても安全です。
上記の関数 fA
、 fB
、 fC
の例を使用すると、コンパイラは fB
と fC
Exit Thunks の両方を生成します。
サンクを終了して 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
サンクを終了して 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
の場合、2 番目のパラメーターは 3 バイト長の構造体です。 Arm64 では、任意のサイズ構造をレジスタに直接割り当てることができます。 x64 では、サイズ 1、2、4、8 のみが許可されます。 この Exit Thunk は、この struct
をレジスタからスタックに転送し、代わりにレジスタへのポインターを割り当てる必要があります。 これにより、(ポインターを運ぶために) 1 つのレジスタが消費されるため、残りのレジスタの割り当ては変更されません。3 番目と 4 番目のパラメーターに対してレジスタの再シャッフは行われません。 fB
の場合と同様に、5 番目のパラメーターをスタックにスピルする必要があります。
終了サンクに関するその他の考慮事項:
- コンパイラは、>から変換する関数名ではなく、アドレス指定するシグネチャで名前を付けます。 これにより、冗長性を簡単に見つけることができます。
- Exit Thunk は、ターゲット (x64) 関数のアドレスを持つレジスタ
x9
で呼び出されます。 これは呼び出しチェッカーによって設定され、制御されずに 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 関数を返します。 これは、x11
経由で提供されたものと同じ値である場合とそうでない場合があります。
呼び出しチェッカーは常に呼び出し規約パラメーター レジスタを妨がないようにするため、呼び出し元のコードは、 blr x11
を使用してすぐに呼び出しチェッカーの呼び出しに従う必要があります (または、末尾呼び出しの場合は br x11
)。 これらはレジスタ呼び出しチェッカーです。 これらは常に、 x0
-x8
、 x15
(chkstk
)、 q0
-q7
など、標準の不揮発性レジスタを超えて保持されます。
エントリ サンクは、x64 から Arm64 呼び出し規則に必要な変換を行います。 これは基本的に出口サンクの逆ですが、考慮すべき側面がいくつかあります。
fA
をコンパイルする前の例を考えてみましょう。x64 コードでfA
を呼び出すことができるように Entry 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
のエミュレーターによって提供されます。
エントリ サンクを呼び出す前に、x64 エミュレーターはスタックから LR
レジスタにリターン アドレスをポップします。 その後、コントロールがエントリ サンクに転送されるときに、 LR
が x64 コードを指し示されることが予想されます。
エミュレーターは、次に応じて、スタックに対して別の調整を実行することもできます。Arm64 と x64 の両方の ABIs は、関数が呼び出された時点でスタックを 16 バイトにアラインする必要があるスタック配置要件を定義します。 Arm64 コードを実行する場合、ハードウェアはこの規則を適用しますが、x64 に対するハードウェアの適用はありません。 x64 コードの実行中、アラインされていないスタックを使用して関数を誤って呼び出すと、16 バイトのアラインメント命令が使用されるか (一部の SSE 命令が行う)、または Arm64EC コードが呼び出されるまで、無期限に気付かない可能性があります。
この潜在的な互換性の問題に対処するために、Entry Thunk を呼び出す前に、エミュレーターは常にスタック ポインターを 16 バイトにアラインダウンし、元の値を x4
レジスタに格納します。 このように、Entry Thunks は常にアラインされたスタックで実行を開始しますが、 x4
を介してスタックで渡されたパラメーターを正しく参照できます。
不揮発性 SIMD レジスタに関しては、Arm64 と x64 の呼び出し規則に大きな違いがあります。 Arm64 では、レジスタの下位 8 バイト (64 ビット) は不揮発性と見なされます。 つまり、Qn
レジスタのDn
部分のみが非揮発性です。 x64 では、 XMMn
レジスタの 16 バイト全体が不揮発性と見なされます。 さらに、x64 では、 XMM6
および XMM7
は不揮発性レジスタであるのに対し、D6 および D7 (対応する Arm64 レジスタ) は揮発性です。
これらの SIMD レジスタ操作の非対称性に対処するには、エントリ サンクは、x64 で非揮発性と見なされるすべての SIMD レジスタを明示的に保存する必要があります。 x64 は Arm64 よりも厳密であるため、これはエントリ サンクでのみ必要です (Exit Thunks ではありません)。 言い換えると、x64 の保存/保持ルールの登録は、すべてのケースで Arm64 要件を超えています。
スタックをアンワインドするときにこれらのレジスタ値の正しい回復に対処するために (例: setjmp + longjmp、throw + catch)、新しいアンワインド オペコードが導入されました: save_any_reg (0xE7)
。 この新しい 3 バイト アンワインド オペコードを使用すると、汎用または SIMD レジスタ (揮発性と見なされるものを含む) を保存し、フルサイズの Qn
レジスタを含めます。 この新しいオペコードは、上記の Qn
レジスタの書き込み/フィル操作に使用されます。 save_any_reg
は、 save_next_pair (0xE6)
と互換性があります。
参考までに、上記のエントリ サンクに属する対応するアンワインド情報を次に示します。
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 バイトがあります。 関数の Entry Thunk の相対アドレスが見つかるのは、この 4 バイトです。 x64 関数から Arm64EC 関数への呼び出しを実行すると、エミュレーターは関数の開始前に 4 バイトを読み取り、下位 2 ビットをマスクアウトし、その量を関数のアドレスに追加します。 これにより、呼び出すエントリ サンクのアドレスが生成されます。
Adjustor Thunks はシグネチャのない関数であり、いずれかのパラメーターに変換を実行した後、単に制御を別の関数に転送 (末尾呼び出し) します。 変換されるパラメーターの型は既知ですが、残りのパラメーターはすべて何でもかまいません。また、任意の数の場合、Adjustor Thunks はパラメーターを保持している可能性のあるレジスタに触れず、スタックに触れなくなります。 これが、Adjustor Thunks のシグネチャのない関数になります。
Adjustor Thunks はコンパイラによって自動的に生成できます。 これは一般的です。たとえば、C++ の多重継承では、 this
ポインターの調整とは別に、仮想メソッドを変更せず、親クラスに委任できます。
実際の例を次に示します。
[thunk]:CObjectContext::Release`adjustor{8}':
sub x0,x0,#8
b CObjectContext::Release
サンクは、 this
ポインターに 8 バイトを減算し、呼び出しを親クラスに転送します。
要約すると、x64 関数から呼び出せる Arm64EC 関数には、Entry Thunk が関連付けられている必要があります。 エントリ サンクはシグネチャ固有です。 Adjustor Thunks などの Arm64 シグネチャのない関数には、シグネチャのない関数を処理できる別のメカニズムが必要です。
Adjustor Thunk の Entry Thunk は、 __os_arm64x_x64_jump
ヘルパーを使用して、実際のエントリ サンク作業の実行を延期します (1 つの規則から他方の規則にパラメーターを調整します)。 署名が明らかになるのはこの時点です。 これには、Adjustor Thunk のターゲットが x64 関数であることが判明した場合に、呼び出し規則の調整をまったく行わないオプションが含まれます。 Entry Thunk の実行が開始されるまでに、パラメーターは x64 形式になります。
上記の例では、Arm64EC でのコードの外観を考えてみましょう。
Arm64EC のアジャスタ サンク
[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]: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に置き換えることをお勧めします。
- 最初の命令の型: 最初の命令に PC の相対的な性質がある場合は、再配置するには、変位フィールドなどを変更する必要があります。 命令が離れた場所に移動するとオーバーフローする可能性があるため、異なる命令を含む同等のロジックを提供する必要がある場合があります。
このような複雑さのため、堅牢で汎用的なフック ロジックを見つけることはまれです。 多くの場合、アプリケーションに存在するロジックは、アプリケーションが関心のある特定の API で発生すると予想される限られた一連のケースにのみ対処できます。 アプリケーションの互換性の問題の量を想像するのは難しいことではありません。 コードやコンパイラの最適化を簡単に変更しても、コードが想定どおりに表示されなくなった場合は、アプリケーションが使用できなくなる可能性があります。
フックを設定するときに Arm64 コードが発生した場合、これらのアプリケーションはどうなるでしょうか。 彼らは最も確かに失敗します。
Fast-Forward Sequence (FFS) 関数は、Arm64EC のこの互換性要件に対処します。
FFS は非常に小さな x64 関数であり、実際のロジックと実際の Arm64EC 関数の末尾呼び出しは含んでいません。 これらは省略可能ですが、すべての DLL エクスポートと、 __declspec(hybrid_patchable)
で修飾された関数に対して既定で有効になります。
このような場合、エクスポート ケースでGetProcAddress
するか、__declspec(hybrid_patchable)
のケースで&function
することによって、コードが特定の関数へのポインターを取得すると、結果のアドレスには 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
変数の関数ポインター値には、GetMachineTypeAttributes
の FFS のアドレスが含まれます。
高速順方向シーケンスの例を次に示します。
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 関数には正規のプロローグとエピローグがあり、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
[...]
2 つの 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 と同じプロシージャ宣言とプロローグ/エピローグ マクロが使用されます。
- 関数名は、
A64NAME
マクロでラップする必要があります。 C/C++ コードを Arm64EC としてコンパイルする場合、コンパイラは、OBJ
を Arm64EC コードを含むARM64EC
としてマークします。 これは、ARMASM
では発生しません。 ASM コードをコンパイルするときに、生成されたコードが Arm64EC であることをリンカーに通知する別の方法があります。 これは、関数名の前に#
を付けることで行われます。A64NAME
マクロは、_ARM64EC_
が定義されているときにこの操作を実行し、_ARM64EC_
が定義されていない場合は名前を変更しません。 これにより、Arm64 と Arm64EC の間でソース コードを共有できます。 - ターゲット関数が x64 の場合、
pfE
関数ポインターは、まず EC 呼び出しチェッカーと適切な Exit Thunk を介して実行する必要があります。
次の手順では、 fD
のエントリ サンクと、 pfE
の Exit Thunk を生成します。 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 の場合) の生成を指示します。
サンク生成を独自の C ファイルに配置することをお勧めします。 分離ファイルを使用すると、対応する OBJ
シンボルをダンプしたり、逆アセンブルしたりすることで、シンボル名を簡単に確認できます。
カスタムの手動でコード化された Entry Thunks の作成に役立つマクロが SDK に追加されました。 これを使用できる 1 つのケースは、カスタム Adjustor Thunks を作成する場合です。
ほとんどの Adjustor Thunk は C++ コンパイラによって生成されますが、手動で生成することもできます。 これは、ジェネリック コールバックが、パラメーターの 1 つによって識別される実際のコールバックに制御を転送する場合に見つかります。
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 に移植されたときに同等の Adjustor 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 に設定した呼び出し元を利用しています。 呼び出し元は、明示的な署名を対象とする呼び出しを行った可能性があります。
上記のコードでは、呼び出し元が x64 コードの場合に対処するために 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 に埋め込まれており、 __os_arm64x_x64_jump
ヘルパーを介してコントロールがエンド ターゲットに直接転送されます。
Arm64EC プロセスには、Arm64EC コードと x64 コードの 2 種類の実行可能メモリがあります。
オペレーティング システムは、読み込まれたバイナリからこの情報を抽出します。 x64 バイナリはすべて x64 であり、Arm64EC には Arm64EC と x64 コード ページの範囲テーブルが含まれています。
動的に生成されたコードについて Just-In-Time (JIT) コンパイラは実行時にコードを生成しますが、これはバイナリ ファイルではサポートされません。
通常、これは次のことを意味します。
- 書き込み可能なメモリの割り当て (
VirtualAlloc
)。 - 割り当てられたメモリにコードを生成する。
- メモリを読み取り/書き込みから読み取り/実行 (
VirtualProtect
) に再保護します。 - 非自明 (非リーフ) で生成されたすべての関数 (
RtlAddFunctionTable
またはRtlAddGrowableFunctionTable
) にアンワインド関数エントリを追加します。
互換性上の簡単な理由から、Arm64EC プロセスでこれらの手順を実行するアプリケーションでは、コードが x64 コードと見なされます。 これは、変更されていない x64 Java ランタイム、.NET ランタイム、JavaScript エンジンなどを使用するすべてのプロセスで発生します。
Arm64EC 動的コードを生成する場合、プロセスはほぼ同じですが、違いは 2 つだけです。
- メモリを割り当てるときは、(
VirtualAlloc
やVirtualAllocEx
ではなく) 新しいVirtualAlloc2
を使用し、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);
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)
);
Windows on Arm に関するフィードバック
Windows on Arm はオープンソース プロジェクトです。 フィードバックを提供するにはリンクを選択します。