Arm64EC("에뮬레이션 호환")는 Arm에서 Windows 11용 앱을 빌드하기 위한 새로운 ABI(애플리케이션 이진 인터페이스)입니다. Arm64EC 개요 및 Win32 앱을 Arm64EC로 빌드하는 방법에 대한 자세한 내용은 Arm64EC를 사용하여 Arm 장치에서 Windows 11용 앱을 빌드하는 방법을 참조하세요.
이 문서에서는 Arm64EC ABI를 대상으로 하는 하위 수준/어셈블러 디버깅 및 어셈블리 코드 작성을 포함하여 애플리케이션 개발자가 Arm64EC용으로 컴파일된 코드를 작성하고 디버그할 수 있는 충분한 정보가 포함된 Arm64EC ABI에 대한 자세한 보기를 제공합니다.
Arm64EC 설계
Arm64EC는 네이티브 수준 기능과 성능을 제공하는 동시에 에뮬레이션에서 실행되는 x64 코드와 투명하고 직접적인 상호 운용성을 제공합니다.
Arm64EC는 주로 Classic Arm64 ABI에 추가됩니다. 클래식 ABI는 거의 변경되지 않았지만 Arm64EC ABI는 x64 상호 운용성을 가능하게 하는 부분을 추가했습니다.
이 문서에서는 원래 표준 Arm64 ABI를 "클래식 ABI"라고 합니다. 이 용어는 "네이티브"와 같이 오버로드된 용어에 내재된 모호성을 방지합니다. Arm64EC는 원래 ABI만큼 네이티브입니다.
Arm64EC 및 Arm64 클래식 ABI
다음 목록에서는 Arm64EC가 Arm64 클래식 ABI와 다른 위치를 가리킵니다.
이러한 차이는 전체 ABI가 정의하는 정도의 관점에서 볼 때 작은 변화입니다.
매핑 및 차단된 레지스터 등록
x64 코드와 형식 수준 상호 운용성을 사용하도록 설정하기 위해 Arm64EC 코드는 x64 코드와 동일한 전처리기 아키텍처 정의를 사용하여 컴파일합니다.
즉, _M_AMD64과(와) _AMD64_(으)로 정의됩니다. 이 규칙의 영향을 받는 형식 중 하나는 CONTEXT 구조입니다.
CONTEXT 구조는 지정된 포인트에서 CPU의 상태를 정의합니다.
Exception Handling 과 GetThreadContext API 항목 같은 것에 사용됩니다. 기존 x64 코드는 CPU 컨텍스트가 x64 CONTEXT구조로 표현되거나, 즉 x64 컴파일 중에 정의된 CONTEXT 구조로 표현되어야 합니다.
x64 코드 및 Arm64EC 코드를 실행하는 동안 CPU 컨텍스트를 나타내려면 이 구조를 사용해야 합니다. 기존 코드는 함수에서 함수로 변경되는 CPU 레지스터 집합과 같은 새로운 개념을 이해하지 못합니다. x64 CONTEXT 구조를 사용하여 Arm64 실행 상태를 나타내는 경우 Arm64 레지스터를 x64 레지스터에 효과적으로 매핑합니다.
또한 이 매핑은 x64에 맞지 않는 Arm64 CONTEXT레지스터를 사용할 수 없다는 것을 의미합니다. 해당 값은 작업이 사용할 CONTEXT 때마다 손실될 수 있습니다(관리되는 언어 런타임 또는 APC의 가비지 수집 작업과 같은 일부 작업은 비동기 및 예기치 않은 작업일 수 있습니다).
SDK의 Windows 헤더는 Arm64EC와 x64 레지스터 간의 매핑 규칙을 ARM64EC_NT_CONTEXT 구조를 통해 나타냅니다. 이 구조체는 기본적으로 x64용으로 정의된 구조체와 정확히 동일하지만 Arm64 레지스터 오버레이가 추가로 포함된 구조체의 CONTEXT 결합입니다.
예를 들어, RCX는 X0에 매핑되고, RDX는 X1에, RSP는 SP에, RIP는 PC에 매핑됩니다. 레지스터x13, x14, , x23, x24x28및 v16 through v31 에는 표현이 없으므로 Arm64EC에서 사용할 수 없습니다.
해당 레지스터 사용 제한은 Arm64 Classic 및 EC API 간의 첫 번째 차이가 됩니다.
호출 검사기
호출 검사기는 Windows 8.1에서 CFG(Control Flow Guard)가 도입된 이래로 Windows의 한 부분이었습니다. 호출 검사기는 함수 포인터에 대해 주소를 살균합니다(이러한 항목을 주소 살균이라고 부르기 전). 옵션을 /guard:cf사용하여 코드를 컴파일할 때마다 컴파일러는 간접 호출 또는 점프 직전에 검사기 함수에 대한 추가 호출을 생성합니다. Windows는 검사기 함수 자체를 제공합니다. CFG의 경우 알려진 양호한 상태의 호출 대상에 대해 유효성 검사를 수행합니다. 컴파일된 /guard:cf 이진 파일도 이 정보를 포함합니다.
이 예제에서는 클래식 Arm64에서 호출 검사기를 사용하는 방법을 보여 있습니다.
mov x15, <target>
adrp x16, __guard_check_icall_fptr
ldr x16, [x16, __guard_check_icall_fptr]
blr x16 ; check target function
blr x15 ; call function
CFG의 경우 호출 검사기는 대상이 유효한 경우 단순히 반환하거나, 그렇지 않은 경우 프로세스를 빠르게 실패합니다. 호출 검사기는 사용자 지정 호출 규칙이 있습니다. 일반 호출 규칙에서 사용되지 않는 레지스터에서 함수 포인터를 가져와서 모든 일반 호출 규칙 레지스터를 유지합니다. 이런 식으로, 그들은 그들 주위에 레지스터 유출을 도입하지 않습니다.
호출 검사기는 다른 모든 Windows API에서 선택 사항이지만 Arm64EC에서는 필수입니다. Arm64EC에서 호출 검사기는 호출되는 함수의 아키텍처를 확인하는 작업을 누적합니다. 호출이 다른 EC("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와 약간의 차이점은 다음과 같습니다.
- 호출 검사기의 기호 이름이 다릅니다.
- 대상 주소가
x11대신x15에 제공됩니다. - 대상 주소(
x11)는[in, out]대신[in]입니다. - "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 클래식 | 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 또는 함수라고도 함)를 제외하고 클래식 Arm64 ABI 호출 규칙을 따릅니다.
variadic 특정 사례의 경우, Arm64EC는 x64 variadic과 매우 유사한 호출 규칙을 따르며 몇 가지 차이점만을 보입니다. 다음 목록에는 Arm64EC variadic에 대한 주요 규칙이 나와 있습니다.
- 매개 변수 전달
x0x1x2x3에는 처음 네 개의 레지스터만 사용됩니다. 나머지 매개 변수는 스택으로 분산됩니다. 이 규칙은 x64 variadic 호출 규칙을 정확하게 따르며, 여기에는 레지스터x0에서x7까지 사용되는 Arm64 Classic 호출 규칙과는 다릅니다. - 레지스터로 전달되는 부동소수점 및 SIMD 매개변수는 SIMD 레지스터가 아닌 일반 레지스터를 사용합니다. 이 규칙은 Arm64 클래식과 유사하며 범용 및 SIMD 레지스터 모두에서 FP/SIMD 매개 변수가 전달되는 x64와 다릅니다. 예를 들어, x64 시스템에서 함수
가 로 호출될 때, 두 번째 매개변수가 와 모두에 할당됩니다. Arm64EC에서 두 번째 매개 변수는 x1에만 할당됩니다. - 레지스터를 통해 값으로 구조를 전달할 때 x64 크기 규칙이 적용됩니다. 크기가 정확히 1, 2, 4 및 8바이트인 구조체는 범용 레지스터에 직접 로드됩니다. 다른 크기의 구조체가 스택으로 분산되고 유출된 위치에 대한 포인터가 레지스터에 할당됩니다. 이 규칙은 기본적으로 하위 수준에서 '값에 의한 전달'을 '참조에 의한 전달'로 강등합니다. 클래식 Arm64 ABI에서는 최대 16바이트 크기의 구조체가 범용 레지스터에 직접 할당됩니다.
- 레지스터는
x4스택을 통해 전달된 첫 번째 매개 변수(다섯 번째 매개 변수)에 대한 포인터를 로드합니다. 이 규칙에는 앞에서 설명한 크기 제한으로 인해 유출된 구조체가 포함되지 않습니다. - 레지스터는
x5스택에서 전달된 모든 매개 변수의 크기(5번째부터 모든 매개 변수의 크기)를 바이트 단위로 로드합니다. 이 규칙에는 앞에서 설명한 크기 제한으로 인해 분산된 값으로 전달된 구조체가 포함되지 않습니다.
다음 예제 pt_nova_function 에서는 variadic이 아닌 형식으로 매개 변수를 사용하므로 클래식 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 는 클래식 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 코드와의 투명한 상호 운용성을 달성하려면 클래식 Arm64 ABI를 많이 추가합니다. 이러한 추가는 Arm64EC와 x64 간의 호출 규칙 차이를 처리합니다.
목록에는 다음과 같은 추가 항목이 포함되어 있습니다.
진입 및 종료 함수
진입 및 종료 썽크는 Arm64EC 호출 규칙(대부분 클래식 Arm64와 동일)을 x64 호출 규칙으로 변환하며, 그 반대로도 변환합니다.
일반적인 오해는 모든 함수 서명에 적용된 단일 규칙에 따라 호출 규칙을 변환할 수 있다는 것입니다. 실제로 호출 규칙에는 매개 변수 할당 규칙이 있습니다. 이러한 규칙은 매개 변수 형식에 따라 다르며 ABI마다 다릅니다. 결과적으로 ABI 간 변환은 각 매개 변수의 형식에 따라 달라지는 각 함수 시그니처에 따라 결정됩니다.
다음 함수를 살펴보세요.
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다르지만 결과 변환은 다릅니다.
이러한 이유로 진입 및 종료 덩크가 존재하며 각 개별 함수 시그니처에 맞게 특별히 설계되었습니다.
두 가지 유형의 thunk는 모두 함수입니다. x64 함수가 Arm64EC 함수를 호출할 때(실행 Enters Arm64EC), 에뮬레이터는 엔트리 펑크를 자동으로 호출합니다. Arm64EC 함수가 x64 함수를 호출할 때 호출 검사기가 자동으로 종료 덩크를 호출합니다(실행이 Arm64EC에서 종료될 때).
Arm64EC 코드를 컴파일할 때 컴파일러는 각 Arm64EC 함수의 시그니처에 맞는 진입 덩크를 생성합니다. 또한 컴파일러는 Arm64EC 함수가 호출하는 모든 함수에 대해 종료 펑크를 생성합니다.
다음 예시를 참조하세요.
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 -
fC에 대한 썽크 종료
컴파일러는 x64 코드에서 fA가 호출될 경우 fA 항목 펑크를 생성합니다. 컴파일러는 fB 및 fC가 x64 코드일 경우 fB과 fC에 대한 종료 처리 코드를 생성합니다.
컴파일러는 함수 자체가 아닌 호출 사이트에서 생성하기 때문에 동일한 종료 펑크를 여러 번 생성할 수 있습니다. 이 중복으로 인해 상당한 양의 중복 펑크가 발생할 수 있습니다. 이 중복을 방지하기 위해 컴파일러는 간단한 최적화 규칙을 적용하여 필요한 thunk만 최종 바이너리에 포함되도록 합니다.
예를 들어, Arm64EC 함수 A가 Arm64EC 함수 B를 호출하는 이진 파일에서는 B가 내보내지지 않으며, 해당 주소는 A 외부에서는 알 수 없습니다.
A에서 B로 출구 펑크를 제거하는 것이 안전하며, B의 진입 펑크도 함께 제거할 수 있습니다. 또한 서로 다른 함수에 대해 생성된 경우에도 동일한 코드를 포함하는 모든 종료 및 진입 청크를 함께 별칭으로 지정해도 안전합니다.
펑크를 종료합니다.
예제 함수 fA, fB, fC를 이전 섹션에서 사용하여, 컴파일러는 fB 및 fC 종료 기능을 다음과 같이 생성합니다.
int fB(int a, double b, int i1, int i2, int i3);로 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
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 있으면 Arm64 및 x64의 다른 할당 규칙의 결과로 나머지 GP 레지스터 할당이 다시 구성됩니다. 또한 x64는 레지스터에 네 개의 매개 변수만 할당하므로 다섯 번째 매개 변수를 스택으로 분산해야 합니다.
fC의 사례를 볼 때, 두 번째 매개 변수는 3바이트 길이의 구조입니다. Arm64를 사용하면 모든 크기 구조를 레지스터에 직접 할당할 수 있습니다. x64는 크기 1, 2, 4 및 8만 허용합니다. 이 Exit Thunk는 레지스터에서 스택으로 이 struct 값을 전송하고 대신 레지스터에 포인터를 할당해야 합니다. 이 방법은 여전히 하나의 레지스터(포인터를 전달하기 위해)를 사용하므로 나머지 레지스터에 대한 할당을 변경하지 않습니다. 세 번째 및 네 번째 매개 변수에는 레지스터 개편이 발생하지 않습니다. 이 경우와 fB 마찬가지로 다섯 번째 매개 변수를 스택으로 분산해야 합니다.
Exit Thunks에 대한 추가 고려 사항:
- 컴파일러는 변환하는 함수 이름이 아니라, 함수의 호출 시그니처로 이름을 지정합니다. 이 명명 규칙을 사용하면 중복성을 더 쉽게 찾을 수 있습니다.
- 호출 검사기는 대상(x64) 함수의 주소를 전달하도록 레지스터
x9를 설정합니다. Exit Thunk는x9을 변경 없이 그대로 에뮬레이터에 전달합니다.
매개 변수를 다시 정렬한 후 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-x8x15chkstkq0-q7
항목 펑크
Entry Thunks는 x64에서 Arm64 호출 규칙으로 필요한 변환을 처리합니다. 이 변환은 기본적으로 Exit Thunks의 반대이지만 고려해야 할 몇 가지 측면이 더 포함됩니다.
컴파일의 이전 예제를 고려합니다 fA. x64 코드가 fA를 호출할 수 있도록 Entry Thunk가 생성됩니다.
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 레지스터로 팝합니다.
LR 는 컨트롤이 Entry Thunk로 전송될 때 x64 코드를 가리킬 것으로 예상됩니다.
에뮬레이터는 다음 사항에 따라 스택에 대한 또 다른 조정을 수행할 수도 있습니다. Arm64 및 x64 API 모두 함수가 호출되는 지점에서 스택을 16바이트로 정렬해야 하는 스택 맞춤 요구 사항을 정의합니다. Arm64 코드를 실행할 때 하드웨어는 이 규칙을 적용하지만 x64에 대한 하드웨어 적용은 없습니다. x64 코드를 실행하는 동안 정렬되지 않은 스택을 사용하여 잘못 호출하는 함수는 일부 16 바이트 맞춤 명령이 사용되거나(일부 SSE 명령이 수행됨) Arm64EC 코드가 호출될 때까지 무기한으로 표시되지 않을 수 있습니다.
이 잠재적인 호환성 문제를 해결하기 위해 Entry Thunk를 호출하기 전에 에뮬레이터는 항상 스택 포인터를 16바이트로 정렬하고 원래 값을 레지스터에 x4 저장합니다. 이렇게 하면 Entry Thunks는 항상 정렬된 스택으로 실행을 시작하며, 스택에 전달된 매개 변수를 x4을 통해 올바르게 참조할 수 있습니다.
비휘발성 SIMD 레지스터의 경우 Arm64와 x64 호출 규칙 간에 상당한 차이가 있습니다. Arm64에서 레지스터의 낮은 8바이트(64비트)는 비휘발성으로 간주됩니다. 다시 말해, Dn 레지스터의 Qn 부분만 비휘발성인 것입니다. x64에서는 XMMn 레지스터의 전체 16바이트가 비휘발성으로 간주됩니다. 또한 x64에서 XMM6 및 XMM7은(는) 비휘발성 레지스터인 반면 D6 및 D7(해당 Arm64 레지스터)은 휘발성입니다.
이러한 SIMD 레지스터 조작 비대칭을 해결하려면 Entry Thunks는 x64에서 비휘발성으로 간주되는 모든 SIMD 레지스터를 명시적으로 저장해야 합니다. x64가 Arm64보다 엄격하기 때문에 저장은 진입 Thunks에서만 필요하며, 종료 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바이트를 예약합니다. 이 4바이트에서 함수에 대한 Entry Thunk의 상대 주소를 찾을 수 있습니다. x64 함수에서 Arm64EC 함수로의 호출을 수행할 때 에뮬레이터는 함수가 시작되기 전에 4바이트를 읽고, 낮은 두 비트를 마스크하고, 해당 값을 함수의 주소에 추가합니다. 이 프로세스는 호출할 Entry Thunk의 주소를 생성합니다.
조정자 펑크
Adjustor Thunks는 다른 함수로 제어를 전송하는 시그니처 없는 함수입니다. 컨트롤을 전송하기 전에 매개 변수 중 하나를 변환합니다. 변환되는 매개 변수의 형식은 알려져 있지만 나머지 매개 변수는 모두 무엇이든 될 수 있으며 임의의 수에 있을 수 있습니다. 조정자 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(빠른 전달 시퀀스) 함수는 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주소가 포함됩니다.
이 예제에서는 Fast-Forward 시퀀스를 보여줍니다.
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 코드에서 컴파일되지 않은 함수에 대한 진입 및 종료 썽크를 생성할 수 있습니다.
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로 컴파일하면 컴파일러는 Arm64EC 코드를 포함하는 것으로OBJ표시ARM64EC합니다. 이 표시는ARMASM와 함께 발생하지 않습니다. ASM 코드를 컴파일할 때 함수 이름을 접두사로#사용하여 생성된 코드가 Arm64EC임을 링커에 알릴 수 있습니다. 매크로는A64NAME정의될 때_ARM64EC_이 작업을 수행하고 정의되지 않은 경우_ARM64EC_이름을 변경하지 않습니다. 이 방법을 사용하면 Arm64와 Arm64EC 간에 소스 코드를 공유할 수 있습니다. - 대상 함수가
pfEx64인 경우 EC 호출 검사기를 통해 함수 포인터를 적절한 종료 펑크와 함께 먼저 실행해야 합니다.
진입 및 종료 덩크 생성
다음 단계는 fD에 대한 항목 스렁크와 pfE에 대한 종료 스렁크를 생성하는 것입니다. C 컴파일러는 컴파일러 키워드를 사용하여 _Arm64XGenerateThunk 최소한의 노력으로 이 작업을 수행할 수 있습니다.
void _Arm64XGenerateThunk(int);
int fD2(int i, double d) {
UNREFERENCED_PARAMETER(i);
UNREFERENCED_PARAMETER(d);
_Arm64XGenerateThunk(2);
return 0;
}
int fE(int i, double d) {
UNREFERENCED_PARAMETER(i);
UNREFERENCED_PARAMETER(d);
_Arm64XGenerateThunk(1);
return 0;
}
이 키워드는 _Arm64XGenerateThunk C 컴파일러에 함수 서명을 사용하고, 본문을 무시하고, 종료 펑크(매개 변수가 1인 경우) 또는 항목 펑크(매개 변수가 2인 경우)를 생성하도록 지시합니다.
별도의 C 파일에 중간 호출 함수 생성을 배치합니다. 격리된 파일에 있으면 해당 OBJ 기호를 덤프하거나 디스어셈블리하여 기호 이름을 더 간단하게 확인할 수 있습니다.
사용자 지정 항목 펑크
SDK에는 사용자 지정 수작업으로 코딩된 진입 지점 함수를 만드는 데 도움이 되는 매크로가 포함되어 있습니다. 사용자 지정 조정자 펑크를 만들 때 이러한 매크로를 사용할 수 있습니다.
대부분의 어드저스터 퉁크스는 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
이 예제에서 첫 번째 매개 변수는 구조체에 대한 참조를 제공합니다. 이 코드는 이 구조의 요소에서 대상 함수 주소를 검색합니다. 구조체는 쓰기 가능하므로 CFG(Control Flow Guard)는 대상 주소의 유효성을 검사해야 합니다.
다음 예제에서는 해당하는 조정자 thunk를 Arm64EC로 포트하는 방법을 보여줍니다.
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
앞의 코드는 종료 펑크를 제공하지 않습니다(레지스터 x10). 코드는 여러 서명에 대해 실행할 수 있으므로 이 방법은 불가능합니다. 이 코드는 호출자가 x10을 종료 함수로 설정한 것을 활용합니다. 호출자는 명시적 서명을 대상으로 하는 호출을 만듭니다.
이전 코드에서는 호출자가 x64 코드일 때의 상황을 처리하기 위해 엔트리 섬이 필요합니다. 다음 예제에서는 사용자 지정 entry thunk에 매크로를 사용하여 해당 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
다른 함수와 달리, 이 항목 thunk는 결국 연결된 함수(조정자 펑크)로 제어를 전송하지 않습니다. 이 경우 엔트리 섕크는 (매개변수 조정 기능을 수행하며) 그 자체의 기능을 포함하여 __os_arm64x_x64_jump 도우미를 통해 제어를 최종 대상에 직접 전송합니다.
Arm64EC 코드를 동적으로 생성(JIT 컴파일)하기
Arm64EC 프로세스에는 Arm64EC 코드와 x64 코드라는 두 가지 유형의 실행 메모리가 있습니다.
운영 체제는 로드된 이진 파일에서 이 정보를 추출합니다. x64 이진 파일은 모두 x64이고 Arm64EC 이진 파일에는 Arm64EC 및 x64 코드 페이지에 대한 범위 테이블이 포함되어 있습니다.
동적으로 생성된 코드는 어떻습니까? JIT(Just-In-Time) 컴파일러는 런타임에 특정 이진 파일에 포함되지 않은 코드를 즉석에서 생성합니다.
일반적으로 이 프로세스에는 다음 단계가 포함됩니다.
- 쓰기 가능한 메모리 할당(
VirtualAlloc). - 할당된 메모리에서 코드를 생성합니다.
- 메모리를 읽기/쓰기에서 읽기-실행(
VirtualProtect)으로 다시 보호합니다. - 비트리비얼(비-리프) 생성 함수(
RtlAddFunctionTable또는RtlAddGrowableFunctionTable)마다 언와인드 함수 항목을 추가합니다.
사소한 호환성을 위해 애플리케이션이 Arm64EC 프로세스에서 이러한 단계를 수행하는 경우 운영 체제는 코드를 x64 코드로 간주합니다. 이 동작은 수정되지 않은 x64 Java 런타임, .NET 런타임, JavaScript 엔진 등을 사용하는 모든 프로세스에 대해 발생합니다.
Arm64EC 동적 코드를 생성하려면 두 가지 차이점이 있는 동일한 프로세스를 따릅니다.
- 메모리를 할당할 때 최신
VirtualAlloc2를 사용하고MEM_EXTENDED_PARAMETER_EC_CODE속성을 제공합니다 (대신VirtualAlloc또는VirtualAllocEx). - 함수 항목을 추가하는 경우:
- Arm64 형식이어야 합니다. Arm64EC 코드를 컴파일할 때 형식은
RUNTIME_FUNCTIONx64 형식과 일치합니다. Arm64EC를 컴파일할 때 Arm64 형식의 경우 대신ARM64_RUNTIME_FUNCTION형식을 사용합니다. - 이전
RtlAddFunctionTableAPI를 사용하지 마세요. 항상 최신 API를RtlAddGrowableFunctionTable사용합니다.
- 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