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 Handling
과 GetThreadContext
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
의 사례에서, fB
및 fC
에 대한 Exit Thunks가 생성되며, x64 코드로 확인됩니다.
컴파일러가 함수 자체가 아닌 호출 사이트에서 생성되므로 동일한 Exit Thunk가 여러 번 생성될 수 있습니다. 이로 인해 상당한 양의 중복 썽크가 발생할 수 있으므로 실제로 컴파일러는 간단한 최적화 규칙을 적용하여 필요한 썽크만 최종 이진 파일로 만들 수 있도록 합니다.
예를 들어 Arm64EC 함수 A
이(가) Arm64EC 함수 B
을(를) 호출하는 이진 파일에서 B
은(는) 내보낼 수 없으며 해당 주소는 A
의 외부에서 알 수 없습니다. B
에 대한 Entry Thunk와 함께, A
에서 B
까지의 Exit Thunk를 제거하는 것이 안전합니다. 또한 고유 함수에 대해 생성된 경우에도, 동일한 코드를 포함하는 모든 Exit 및 Entry Thunks를 함께 별칭으로 지정하는 것이 안전합니다.
Exit Thunks
예제 함수 및 fA
, fB
및 fC
을(를) 사용하여 컴파일러가fB
및 fC
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에서 XMM6
및 XMM7
은(는) 비휘발성 레지스터인 반면 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를 사용하십시오.
- 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)
);
Windows on Arm