다음을 통해 공유


Arm64EC ABI 및 어셈블리 코드 해석

Arm64EC("에뮬레이션 호환")는 Arm에서 Windows 11용 앱을 빌드하기 위한 새로운 ABI(애플리케이션 이진 인터페이스)입니다. Arm64EC 개요 및 Win32 앱을 Arm64EC로 빌드하는 방법에 대한 자세한 내용은 Arm64EC를 사용하여 Arm 장치에서 Windows 11용 앱을 빌드하는 방법을 참조하세요.

이 문서의 목적은 Arm64EC ABI를 대상으로 하는 하위 수준/어셈블러 디버깅 및 어셈블리 코드 작성을 포함하여 애플리케이션 개발자가 Arm64EC용으로 컴파일된 코드를 작성하고 디버그할 수 있는 충분한 정보가 포함된 Arm64EC ABI에 대한 자세한 보기를 제공하기 위함입니다.

Arm64EC 설계

Arm64EC는 기본 수준의 기능과 성능을 제공하는 동시에 에뮬레이션에서 실행되는 x64 코드와 투명하고 직접적인 상호 운용성을 제공하도록 설계되었습니다.

Arm64EC는 주로 Classic Arm64 ABI에 추가됩니다. Classic ABI는 거의 변경되지 않았지만 x64 상호 운용성을 위해 부분적으로 추가되었습니다.

이 문서에서는 원래 표준 Arm64 ABI를 "클래식 ABI"라고 합니다. 이렇게 하면 "네이티브"와 같이 오버로드된 용어에 내재된 모호성이 방지됩니다. 명확히 말하자면, Arm64EC는 원래 ABI만큼 네이티브입니다.

Arm64EC와 Arm64 Classic ABI 비교

다음 목록은 Arm64EC가 Arm64 Classic ABI와 나뉘는 위치를 가리킵니다.

이는 전체 ABI가 정의하는 정도의 관점에서 볼 때 작은 변화입니다.

매핑 및 차단된 레지스터 등록

x64 코드와의 형식 수준의 상호 운용성을 위해 Arm64EC 코드는 x64 코드와 동일한 사전 프로세서 아키텍처 정의로 컴파일됩니다.

즉, _M_AMD64과(와) _AMD64_(으)로 정의됩니다. 이 규칙의 영향을 받는 형식 중 하나는 CONTEXT 구조입니다. CONTEXT 구조는 지정된 포인트에서 CPU의 상태를 정의합니다. Exception HandlingGetThreadContext API 항목 같은 것에 사용됩니다. 기존 x64 코드는 CPU 컨텍스트가 x64 CONTEXT구조로 표현되거나, 즉 x64 컴파일 중에 정의된 CONTEXT 구조로 표현되어야 합니다.

해당 구조는 Arm64EC 코드뿐 아니라 x64 코드를 실행하는 동안 CPU 컨텍스트를 나타내는 데 사용해야 합니다. 기존 코드는 함수에서 함수로 변경되는 CPU 레지스터 집합과 같은 새로운 개념을 이해하지 못합니다. x64 CONTEXT 구조를 사용하여 Arm64 실행 상태를 나타내는 경우, 이는 Arm64 레지스터가 x64 레지스터에 효과적으로 매핑된다는 것을 의미합니다.

또한 x64 CONTEXT에 장착할 수 없는 Arm64 레지스터는 사용 CONTEXT 작업이 발생할 때마다 값이 손실될 수 있으므로 사용하지 않아야 하며, 일부는 관리 언어 런타임의 가비지 수집 작업 또는 APC와 같이 비동기적이고 예기치 않은 것일 수 있습니다.

Arm64EC와 x64 레지스터 간의 매핑 규칙은 SDK에 있는 Windows 헤더의 ARM64EC_NT_CONTEXT 구조로 표시됩니다. 해당 구조는 기본적으로 x64에 대해 정의된 것과 정확히 일치하며, Arm64 레지스터 오버레이가 추가된 CONTEXT 구조의 결합입니다.

예를 들어 ,RCX은(는) X0(으)로, RDX은(는) X1(으)로, RSP은(는) SP(으)로, RIP은(는) PC(으)로 매핑됩니다. 또한 레지스터x13, x14, x23, x24, x28, v16-v31 표현이 없으므로 Arm64EC에서 사용할 수 없습니다.

해당 레지스터 사용 제한은 Arm64 Classic 및 EC API 간의 첫 번째 차이가 됩니다.

호출 검사기

호출 검사기는 Windows 8.1에서 CFG(Control Flow Guard)가 도입된 이래로 Windows의 한 부분이었습니다. 호출 검사기는 함수 포인터에 대해 주소를 살균합니다(이러한 항목을 주소 살균이라고 부르기 전). 코드가 옵션 /guard:cf 으로 컴파일될 때마다 컴파일러는 간접 호출/점프 직전에 검사기 함수에 대한 추가 호출을 생성합니다. 검사기 함수 자체는 Windows에서 제공하며 CFG의 경우 알려진 호출 대상에 대해 유효성 검사를 수행합니다. 이 정보는 /guard:cf로 컴파일된 이진 파일에도 포함됩니다.

다음은 Classic 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("Emulation Compatible") 함수인지 또는 에뮬레이션에서 실행되어야 하는 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

Classic Arm64와 약간의 차이점은 다음과 같습니다.

  • 호출 검사기의 기호 이름이 다릅니다.
  • 대상 주소가 x15대신 x11에 제공됩니다.
  • 대상 주소(x11)는 [in] 대신 [in, out]입니다.
  • "Exit Thunk"라는 추가 매개 변수가 제공됩니다 x10.

Exit Thunk는 함수 매개 변수를 Arm64EC 호출 규칙에서 x64 호출 규칙으로 변환하는 funclet입니다.

Arm64EC 호출 검사기는 Windows의 다른 API에 사용되는 것과 다른 기호를 통해 배치됩니다. Classic Arm64 ABI에서 호출 검사기의 기호는 __guard_check_icall_fptr입니다. 이 기호는 Arm64EC에 있지만, Arm64EC 코드 자체가 아니라 x64 고정적으로 연결된 코드를 사용할 수 있습니다. Arm64EC 코드는 둘 중 하나 __os_arm64x_check_icall 또는 __os_arm64x_check_icall_cfg을(를) 사용합니다.

Arm64EC에서 호출 검사기는 선택 사항이 아닙니다. 그러나 CFG는 다른 API의 사례와 마찬가지로 여전히 선택 사항입니다. CFG를 컴파일 타임에 사용하지 않도록 설정하거나 CFG를 사용하는 경우에도 CFG 검사 수행하지 않는 정당한 이유가 있을 수 있습니다(예: 함수 포인터가 RW 메모리에 상주하지 않음). CFG 검사 간접 호출의 경우 __os_arm64x_check_icall_cfg 검사기를 사용해야 합니다. CFG를 사용하지 않도록 설정하거나 불필요한 경우 __os_arm64x_check_icall을(를) 대신 사용해야 합니다.

다음은 Classic Arm64, x64 및 Arm64EC에서의 호출 검사기 사용량에 대한 요약 테이블로, Arm64EC 이진 파일에는 코드의 아키텍처에 따라 두 가지 옵션이 있을 수 있습니다.

이진 코드 보호되지 않는 간접 호출 CFG 보호 간접 호출
X64 X64 호출 검사기 없음 __guard_check_icall_fptr 또는 __guard_dispatch_icall_fptr
Arm64 Classic 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를 사용하지 않도록 설정할 수도 있습니다. 이전 API에서 CFG를 사용하지 않도록 설정하거나 OS 지원이 없는 경우, OS는 이진 파일이 로드될 때 호출 검사기를 업데이트하지 않습니다. Arm64EC에서 CFG 보호를 사용하지 않도록 설정하면 OS는 __os_arm64x_check_icall_cfg을(를) __os_arm64x_check_icall와(과) 동일하게 설정하며, 이는 CFG 보호가 아닌 모든 사례에 필요한 대상 아키텍처 검사를 제공합니다.

Classic Arm64의 CFG와 마찬가지로 대상 함수(x11)에 대한 호출은 호출 검사기 호출 바로 뒤에 와야 합니다. 호출 검사기의 주소는 휘발성 레지스터에 배치되어야 하며 대상 함수의 주소도 다른 레지스터에 복사하거나 메모리로 분산해서는 안 됩니다.

스택 검사기

__chkstk은(는) 함수가 페이지보다 큰 스택의 영역을 할당할 때마다 컴파일러에서 자동으로 사용됩니다. 스택의 끝을 보호하는 스택 가드 페이지를 건너뛰지 않도록 __chkstk이 할당된 영역의 모든 페이지가 검색되도록 호출됩니다.

__chkstk은(는) 일반적으로 함수의 프롤로그에서 호출됩니다. 이러한 이유로 최적의 코드 생성을 위해 사용자 지정 호출 규칙을 사용합니다.

즉, Entry 및 Exit 썽크가 표준 호출 규칙을 가정하므로 x64 코드와 Arm64EC 코드에는 고유한 __chkstk 함수가 필요합니다.

x64 및 Arm64EC는 동일한 기호 네임스페이스를 공유하므로 이름이 두 __chkstk개의 함수가 있을 수 없습니다. 기존 x64 코드와의 호환성을 수용하기 위해 __chkstk 이름은 x64 스택 검사기와 연결됩니다. Arm64EC 코드는 대신 __chkstk_arm64ec을(를) 사용합니다.

__chkstk_arm64ec에 대한 사용자 지정 호출 규칙은 Classic Arm64 __chkstk의 경우와 동일합니다. x15의 경우, 할당 크기(바이트)를 16으로 나눕니다. 모든 비휘발성 레지스터와 표준 호출 규칙에 관련된 모든 휘발성 레지스터가 유지됩니다.

__chkstk에 대해 말한 모든 것은 __security_check_cookie 및 Arm64EC 대응 항목인에 동일하게 __security_check_cookie_arm64ec에도 적용됩니다.

Variadic 호출 규칙

Arm64EC는 Variadic 함수(예: varargs, 줄임표(. . .) 매개 변수 키워드(keyword) 함수)를 제외하고 Classic Arm64 ABI 호출 규칙을 따릅니다.

variadic 특정 사례의 경우, Arm64EC는 x64 variadic과 매우 유사한 호출 규칙을 따르며 몇 가지 차이점만을 보입니다. 다음은 Arm64EC variadic에 대한 주요 규칙입니다.

  • 매개 변수 전달 x0, x1, x2, x3에는 처음 4개의 레지스터만 사용됩니다. 나머지 기본 매개 변수가 스택에 분산됩니다. 이는 x64 variadic 호출 규칙을 정확히 따르고 레지스터 x0->x7 가 사용되는 Arm64 Classic과 다릅니다.
  • 레지스터에서 전달되는 부동 소수점/SIMD 매개 변수는 SIMD가 아닌 범용 레지스터를 사용합니다. 이는 Arm64 Classic과 유사하며, 범용 및 SIMD 레지스터 모두에서 FP/SIMD 매개 변수가 전달되는 x64와 다릅니다. 예를 들어 x64에서 f1(int, …)함수가 f1(int, double)(으)로 호출되는 함수의 경우 두 번째 매개 변수는 RDX와(과) XMM1 모두에 할당됩니다. Arm64EC에서 두 번째 매개 변수는 x1에만 할당됩니다.
  • 레지스터를 통해 값으로 구조를 전달할 때 x64 크기 규칙이 적용됩니다. 크기가 정확히 1, 2, 4 및 8인 구조체는 범용 레지스터에 직접 로드됩니다. 다른 크기의 구조가 스택에 분산되고 분산된 위치에 대한 포인터가 레지스터에 할당됩니다. 기본적으로 값을 기준으로 하위 수준에서 참조 단위로 강등합니다. Classic Arm64 ABI에서는 최대 16바이트 크기의 구조가 범용 레지스터에 직접 할당됩니다.
  • X4 레지스터는 스택(5번째 매개 변수)을 통해 전달된 첫 번째 매개 변수에 대한 포인터와 함께 로드됩니다. 위에 설명된 크기 제한으로 인해 유출된 구조는 포함되지 않습니다.
  • X5 레지스터는 스택에서 전달된 모든 매개 변수의 크기(바이트)로 로드됩니다(5번째부터 모든 매개 변수의 크기). 위에 설명된 크기 제한으로 인해, 유출된 값으로 전달된 구조는 포함되지 않습니다.

다음 예제: 아래는 pt_nova_function 비 variadic 형식으로 매개 변수를 사용하므로 Classic Arm64 호출 규칙에 따릅니다. 그런 다음 정확히 동일한 매개 변수를 사용하여 호출하지만 대신 variadic 호출에서 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 은(는) Classic Arm64 호출 규칙 규칙에 따라 할당될 5개 매개 변수를 사용합니다.

  • 'f'는 double입니다. d0에 할당됩니다.
  • 'tc'는 크기가 3바이트인 구조체입니다. x0에 할당됩니다.
  • ull1은 8바이트 정수입니다. x1에 할당됩니다.
  • ull2는 8바이트 정수입니다. x2에 할당됩니다.
  • ull3은 8바이트 정수입니다. x3에 할당됩니다.

pt_va_function은(는) variadic 함수입니다. 위에 설명된 Arm64EC variadic 규칙을 따릅니다.

  • '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

ABI 추가

x64 코드와의 투명한 상호 운용성을 달성하기 위해 Classic Arm64 ABI에 많은 추가 사항이 이루어졌습니다. Arm64EC와 x64 간의 호출 규칙 차이를 처리합니다.

목록에는 다음과 같은 추가 항목이 포함되어 있습니다.

Entry 및 Exit Thunks

Entry 및 Exit Thunks는 Arm64EC 호출 규칙(대부분 Classic Arm64와 동일)을 x64 호출 규칙으로 변환하며, 그 반대의 경우도 마찬가지입니다.

일반적으로 하는 오해에는 모든 함수 서명에 적용된 단일 규칙을 따라 호출 규칙을 변환할 수 있다는 것이 있습니다. 실제로 호출 규칙에는 매개 변수 할당 규칙이 있습니다. 이러한 규칙은 매개 변수 형식에 따라 다르며 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 translation: 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 translation: x0 -> RCX, d0 -> XMM1, x1 -> R8, d1 -> XMM3

이러한 예제에서 매개 변수 할당 및 변환이 형식에 따라 다르지만 목록의 이전 매개 변수 형식도 종속됨을 확인할 수 있습니다. 해당 세부 정보는 세 번째 매개 변수로 설명할 수 있습니다. 두 함수에서 매개 변수의 형식은 "int"이지만 결과 변환은 다릅니다.

이러한 이유로 Entry 및 Exit Thunks가 존재하며 각 개별 함수 시그니처에 맞게 특별히 조정됩니다.

두 가지 유형의 썽크는 모두 함수입니다. x64 함수가 Arm64EC 함수(Enters Arm64EC)를 호출하면 Entry Thunks가 에뮬레이터에 의해 자동으로 호출됩니다. Exit Thunks는 Arm64EC 함수가 x64 함수를 호출할 때(Exits은 Arm64EC 실행) 호출 검사기에 의해 자동으로 호출됩니다.

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'에 대한 Thunk 종료
  • 'fC'에 대한 Thunk 종료

fA Entry Thunk는 fA의 사례에 따라 생성되고 x64 코드에서 호출됩니다. fB 및/또는 fC의 사례에서, fBfC에 대한 Exit Thunks가 생성되며, x64 코드로 확인됩니다.

컴파일러가 함수 자체가 아닌 호출 사이트에서 생성되므로 동일한 Exit Thunk가 여러 번 생성될 수 있습니다. 이로 인해 상당한 양의 중복 썽크가 발생할 수 있으므로 실제로 컴파일러는 간단한 최적화 규칙을 적용하여 필요한 썽크만 최종 이진 파일로 만들 수 있도록 합니다.

예를 들어 Arm64EC 함수 A이(가) Arm64EC 함수 B을(를) 호출하는 이진 파일에서 B은(는) 내보낼 수 없으며 해당 주소는 A의 외부에서 알 수 없습니다. B에 대한 Entry Thunk와 함께, A에서 B까지의 Exit Thunk를 제거하는 것이 안전합니다. 또한 고유 함수에 대해 생성된 경우에도, 동일한 코드를 포함하는 모든 Exit 및 Entry Thunks를 함께 별칭으로 지정하는 것이 안전합니다.

Exit Thunks

예제 함수 및 fA, fBfC을(를) 사용하여 컴파일러가fBfC Exit Thunk를 모두 생성하는 방법입니다.

int fB(int a, double b, int i1, int i2, int i3);에 대한 Exit Thunk

$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);에 대한 Exit Thunk

$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' 매개 변수가 있으면 Arm64 및 x64의 다른 할당 규칙의 결과로 나머지 GP 레지스터 할당이 어떻게 다시 구성되는지 확인할 수 있습니다. x64가 레지스터에 4개의 매개 변수만 할당하는 것을 볼 수 있으므로 5번째 매개 변수를 스택으로 분산해야 합니다.

fC의 사례를 볼 때, 두 번째 매개 변수는 3바이트 길이의 구조입니다. Arm64를 사용하면 모든 크기 구조를 레지스터에 직접 할당할 수 있습니다. x64는 크기 1, 2, 4 및 8만 허용합니다. 이 Exit Thunk는 레지스터에서 스택으로 이 struct 값을 전송하고, 대신 레지스터에 포인터를 할당해야 합니다. 이 경우 여전히 하나의 레지스터(포인터를 전달하기 위해)를 사용하므로 다시 기본 레지스터에 대한 할당을 변경하지 않습니다. 3번째 및 4번째 매개 변수에는 레지스터 재편성이 발생하지 않습니다. fB의 사례와 마찬가지로 5번째 매개 변수를 스택으로 분산해야 합니다.

Exit Thunks에 대한 추가 고려 사항:

  • 컴파일러는 from->to로 변환하는 함수 이름이 아니라 주소가 지정되는 시그니처로 이름을 지정합니다. 이렇게 하면 중복성을 더 쉽게 찾을 수 있습니다.
  • 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 이상을 유지합니다.

Entry Thunks

Entry Thunks는 x64에서 Arm64 호출 규칙으로 필요한 변환을 처리합니다. 이는 본질적으로 Exit Thunks의 반대이지만 고려해야 할 몇 가지 더 많은 측면이 있습니다.

x64 코드에서 호출할 수 있도록 Entry Thunk가 fA을(를) 컴파일하는 fA의 이전 예제를 생각해 보십시오.

int fA(int a, double b, struct SC c, int i1, int i2, int i3)에 대한 Entry Thunk

$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 API는 함수가 호출되는 지점에서 스택을 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에서 XMM6XMM7은(는) 비휘발성 레지스터인 반면 D6 및 D7(해당 Arm64 레지스터)은 휘발성입니다.

이러한 SIMD 레지스터 조작 비대칭을 해결하려면 Entry Thunks는 x64에서 비휘발성으로 간주되는 모든 SIMD 레지스터를 명시적으로 저장해야 합니다. x64가 Arm64보다 철저하므로, Entry Thunks(Exit Thunks 아님)에서만 필요합니다. 즉, x64에 저장/보존 규칙을 등록하면 모든 경우에 Arm64 요구 사항이 초과됨을 의미합니다.

스택을 해제할 때(예: setjmp + longjmp 또는 throw + catch) 이러한 레지스터 값의 올바른 복구를 해결하기 위해 새 해제 opcode가 도입되었습니다. save_any_reg (0xE7). 이 새로운 3바이트 해제 opcode를 사용하면 범용 또는 SIMD 레지스터(휘발성으로 간주되는 레지스터 포함)를 저장하고 전체 크기 Qn 레지스터를 포함할 수 있습니다. 이 새 opcode는 위의 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바이트가 있습니다. 함수에 대한 Entry Thunk의 상대 주소를 찾을 수 있는 것은 이 4바이트입니다. x64 함수에서 Arm64EC 함수로의 호출을 수행할 때 에뮬레이터는 함수가 시작되기 전에 4바이트를 읽고, 낮은 두 비트를 마스크 아웃하고, 해당 값을 함수의 주소에 추가합니다. 그러면 호출할 Entry Thunk의 주소가 생성됩니다.

Adjustor Thunks

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가 있어야 합니다. Entry Thunk는 시그니처에 따라 다릅니다. Adjustor Thunks와 같은 Arm64 시그니처 없는 함수에는 서명 없는 함수를 처리할 수 있는 다른 메커니즘이 필요합니다.

조정자 Thunk의 Entry Thunk는 __os_arm64x_x64_jump 도우미를 사용하여 실제 Entry Thunk 작업의 실행을 연기합니다(한 규칙에서 다른 규칙으로 매개 변수 조정). 이때 시그니처가 분명해집니다. 여기에는 Adjustor Thunk의 대상이 x64 함수로 판명될 경우 규칙 조정을 전혀 호출하지 않는 옵션이 포함됩니다. Entry Thunk가 실행되기 시작할 때까지 매개 변수는 x64 형식입니다.

위의 예제에서는 Arm64EC에서 코드의 모양을 고려합니다.

Arm64EC의 Adjustor 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]: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

빨리 감기 시퀀스

일부 애플리케이션은 함수를 호출할 때 실행을 우회할 목적으로 소유하지 않지만 일반적으로 운영 체제 이진 파일에 의존하는 이진 파일에 있는 함수를 런타임으로 수정합니다. 이를 후킹이라고도합니다.

상위 수준에서 후킹 프로세스는 간단합니다. 그러나 후킹은 후킹 로직이 해결해야 하는 잠재적인 변형을 고려할 때 아키텍처에 따라 매우 복잡합니다.

일반적으로 프로세스는 다음을 포함합니다.

  • 후크할 함수의 주소를 결정합니다.
  • 함수의 첫 번째 명령을 후크 루틴으로의 이동으로 바꿉니다.
  • 후크가 완료되면 변위된 원래 명령을 실행하는 것을 포함하는 원래 로직으로 돌아갑니다.

변형은 다음과 같은 항목에서 발생합니다.

  • 첫 번째 명령의 크기: 다른 스레드가 실행 중인 동안 함수의 상단을 교체하지 않도록 크기가 같거나 작은 JMP로 바꾸는 것이 좋습니다.
  • 첫 번째 명령의 유형: 첫 번째 명령의 PC에 상대적 특성이 있는 경우 재배치하려면 변위 필드와 같은 변경이 필요할 수 있습니다. 명령이 먼 곳으로 이동하는 경우 오버플로될 가능성이 높기 때문에 서로 다른 명령으로 동등한 로직을 제공해야 할 수 있습니다.

이러한 모든 복잡성으로 인해 강력하고 일반적인 후킹 로직을 찾기 어렵습니다. 애플리케이션에 있는 로직은 애플리케이션이 관심 있는 특정 API에서 발생할 것으로 예상되는 제한된 사례 집합에만 대처할 수 있는 경우가 많습니다. 애플리케이션 호환성 문제가 얼마나 많은지 상상하기는 어렵지 않습니다. 코드 또는 컴파일러 최적화가 간단하게 변경되더라도 코드가 더 이상 예상대로 보이지 않는 경우 애플리케이션을 사용할 수 없게 될 수 있습니다.

후크를 설정할 때 Arm64 코드가 발생하는 경우 이러한 애플리케이션은 어떻게 될까요? 확실히 실패할 확률이 높을 것입니다.

FFS(Fast-Forward Sequence) 함수는 Arm64EC에서 이 호환성 요구 사항을 해결합니다.

FFS는 실제 Arm64EC 함수에 대한 실제 로직 및 비상 호출을 포함하지 않는 매우 작은 x64 함수입니다. 선택 사항이지만 모든 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 함수 포인터 값에는 '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 함수에는 정식 프롤로그와 에필로그가 있으며 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 코드에 대한 비상 호출에 도달하게 됩니다. 그러면 애플리케이션에서 예상하는 것처럼 후크 이후에 실행됩니다.

어셈블리에서 Arm64EC 작성

Windows SDK 헤더 및 C 컴파일러는 Arm64EC 어셈블리 작성 작업을 간소화할 수 있습니다. 예를 들어 C 컴파일러는 C 코드에서 컴파일되지 않은 함수에 대한 Entry 및 Exit Thunks를 생성하는 데 사용할 수 있습니다.

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 간에 소스 코드를 공유할 수 있습니다.
  • pfE 함수 포인터는 먼저 대상 함수가 x64인 경우, 적절한 Exit Thunk와 함께 EC 호출 검사기를 통해 실행되어야 합니다.

Entry 및 Exit Thunks 생성

다음 단계는 fD에 대한 Entry Thunk 및 pfE에 대한 Exit Thunk을(를) 생성하는 것입니다. C 컴파일러는 _Arm64XGenerateThunk 컴파일러 키워드(keyword) 사용하여 최소한의 노력으로 이 작업을 수행할 수 있습니다.

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) 함수 시그니처를 사용하고 본문을 무시하고 Exit Thunk(매개 변수가 1인 경우) 또는 Entry Thunk(매개 변수가 2인 경우)를 생성하도록 C 컴파일러에 지시합니다.

자체 C 파일에 썽크 생성을 배치하는 것이 좋습니다. 격리된 파일에 있으면 해당 OBJ 기호를 덤프하거나 디스어셈블리하여 기호 이름을 더 간단하게 확인할 수 있습니다.

사용자 지정 Entry Thunks

매크로가 SDK에 추가되어 사용자 지정, 수동 코딩된 Entry Thunks 작성에 도움이 됩니다. 이를 사용할 수 있는 한 가지 사례는 사용자 지정 Adjustor Thunks를 작성할 때입니다.

대부분의 Adjustor Thunk는 C++ 컴파일러에서 생성되지만 수동으로 생성할 수도 있습니다. 이는 제네릭 콜백이 매개 변수 중 하나로 식별되는 실제 콜백으로 컨트롤을 전송하는 경우에 찾을 수 있습니다.

다음은 Arm64 Classic 코드의 예입니다.

    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

이 예제에서 대상 함수 주소는 첫 번째 매개 변수를 통해 참조로 제공되는 구조의 요소에서 검색할 수 있습니다. 구조를 쓰기 가능하므로 CFG(Control Flow Guard)를 통해 대상 주소의 유효성을 검사해야 합니다.

아래 예제에서는 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가 필요합니다. 사용자 지정 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는 결국 연결된 함수(Adjustor Thunk)로 제어를 전환하지 않습니다. 이 경우 기능 자체(매개 변수 조정 수행)가 Entry Thunk에 포함되고 컨트롤이 __os_arm64x_x64_jump 도우미를 통해 최종 대상으로 직접 전환합니다.

동적 생성(JIT 컴파일) Arm64EC 코드

Arm64EC 프로세스에는 Arm64EC 코드와 x64 코드의 두 가지 실행 가능 메모리 유형이 있습니다.

운영 체제는 로드된 이진 파일에서 이 정보를 추출합니다. x64 이진 파일은 모두 x64이고 Arm64EC에는 Arm64EC 및 x64 코드 페이지에 대한 범위 테이블이 포함되어 있습니다.

동적으로 생성된 코드는 어떤가요? JIT(Just-In-Time) 컴파일러는 런타임에 코드를 생성하며, 이 코드는 이진 파일에서 지원되지 않습니다.

이는 일반적으로 다음을 의미합니다.

  • 쓰기 가능한 메모리 할당(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를 사용하십시오.

다음은 메모리 할당에 대한 예제입니다.

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