Примечание.
Для доступа к этой странице требуется авторизация. Вы можете попробовать войти или изменить каталоги.
Для доступа к этой странице требуется авторизация. Вы можете попробовать изменить каталоги.
Arm64EC ("Совместимость с эмуляцией") — это новый двоичный интерфейс приложения (ABI) для создания приложений для Windows 11 в Arm. Общие сведения о Arm64EC и создании приложений Win32 в Arm64EC см. в статье "Использование Arm64EC для создания приложений для Windows 11 на устройствах Arm".
В этой статье представлен подробный обзор ABI Arm64EC с достаточной информацией для разработчика приложений для написания и отладки кода, скомпилированного для Arm64EC, включая отладку кода на ассемблере и написание ассемблерного кода, предназначенного для ABI Arm64EC.
Проектирование Arm64EC
Arm64EC обеспечивает функциональность и производительность собственного уровня, обеспечивая прозрачное и прямое взаимодействие с кодом x64, выполняющимся под эмуляцией.
Arm64EC в основном является аддитивным к классическому ABI Arm64. Классический ABI изменился очень мало, но ABI Arm64EC внес изменения, чтобы обеспечить совместимость с x64.
В этом документе исходный стандарт ABI Arm64 называется классическим ABI. Этот термин избегает неоднозначности, присущей перегруженным терминам, таким как Native. Arm64EC так же нативен, как и исходный ABI.
Arm64EC vs. Arm64 Classic ABI
В следующем списке указано, где Arm64EC расходится с Arm64 Classic ABI.
- Регистрация сопоставлений и заблокированных регистров
- Средства проверки вызовов
- Средства проверки стека
- Соглашение о вызовах variadic
Эти различия являются небольшими изменениями, когда они рассматриваются в контексте того, сколько определяет полный ABI.
Регистрация сопоставлений и заблокированных регистров
Чтобы включить взаимодействие на уровне типа с кодом x64, код Arm64EC компилируется с теми же определениями архитектуры препроцессора, что и код x64.
Другими словами, _M_AMD64 и _AMD64_ определяются. Одним из типов, затронутых этим правилом, является CONTEXT структура. Структура CONTEXT определяет состояние ЦП в заданной точке. Он используется для таких вещей, Exception Handling как GetThreadContext и API. Существующий код x64 ожидает, что контекст ЦП будет представлен как структура x64 CONTEXT или, другими словами, CONTEXT структура, определяемая во время компиляции x64.
Эту структуру необходимо использовать для представления контекста ЦП при выполнении кода x64 и кода Arm64EC. Существующий код не понимает новую концепцию, например набор регистров ЦП, изменяющийся с функции на функцию. Если для представления состояний выполнения Arm64 используется структура x64 CONTEXT , вы эффективно сопоставляете регистры Arm64 с регистрами x64.
Это сопоставление также означает, что вы не можете использовать регистры Arm64, которые не соответствуют x64 CONTEXT. Их значения могут быть потеряны в любое время, когда операция используется CONTEXT (а некоторые операции могут быть асинхронными и непредвиденными, например, операция сборки мусора среды выполнения управляемого языка или APC).
Заголовки Windows в пакете SDK представляют правила сопоставления между Arm64EC и x64 регистрами со структурой ARM64EC_NT_CONTEXT . Эта структура по сути является объединением CONTEXT структуры, точно так же, как она определена для x64, но с дополнительным регистром Arm64.
Например, RCX сопоставляется с X0, RDX сопоставляется с X1, RSP сопоставляется с SP, RIP сопоставляется с PC, и так далее. Регистры x13, x14, x23, x24, x28, v16 и v31 не имеют представления и, таким образом, не могут использоваться в Arm64EC.
Это ограничение использования регистрации является первым различием между классическими и EC ABIs Arm64.
Средства проверки вызовов
Средства проверки звонков были частью Windows с тех пор, как control Flow Guard (CFG) появилась в Windows 8.1. Средства проверки вызовов — это санитизаторы адресов для указателей функций (до того, как эти вещи были вызваны санитизаторами адресов). Каждый раз при компиляции кода с параметром /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 средство проверки вызовов просто возвращается, если целевой объект действителен, или быстро завершается сбоем процесса, если это не так. Средства проверки звонков имеют пользовательские соглашения о вызовах. Они принимают указатель функции в регистре, не используемом обычным соглашением вызова, и сохраняют все обычные регистры соглашения вызова. Таким образом, они не вводят регистрацию разлива вокруг них.
Средства проверки вызовов являются необязательными для всех остальных API Windows, но обязательны для 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 относятся:
- Имя символа для средства проверки вызовов отличается.
- Целевой адрес предоставляется
x11вместоx15. - Целевой адрес (
x11) вместо[in, out][in]него. - Существует дополнительный параметр, предоставленный через
x10, называемый "Exit Thunk".
Exit Thunk — это funclet, который преобразует параметры функции из соглашения о вызовах Arm64EC в соглашение о вызовах x64.
Средство проверки вызовов Arm64EC расположено с помощью другого символа, отличного от используемого для других API в Windows. В классическом ABI Arm64 символ __guard_check_icall_fptrдля средства проверки вызова . Этот символ будет присутствовать в Arm64EC, но он предназначен для использования статически связанного кода x64, а не самого кода Arm64EC. Код Arm64EC будет использовать либо __os_arm64x_check_icall__os_arm64x_check_icall_cfg.
В Arm64EC средства проверки вызовов не являются необязательными. Однако CFG по-прежнему необязателен, как и для других ABIs. CFG может быть отключен во время компиляции или может быть законной причиной, чтобы не выполнять проверку CFG даже при включении CFG (например, указатель функции никогда не находится в памяти RW). Для непрямого вызова с проверкой __os_arm64x_check_icall_cfg CFG следует использовать средство проверки. Если CFG отключен или не требуется, __os_arm64x_check_icall следует использовать вместо него.
Ниже приведена сводная таблица использования средства проверки вызовов для классических 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 по конфигурации. Если функция CFG отключена (или поддержка ОС отсутствует) в предыдущих ABIs ос, ос просто не обновит средство проверки вызовов при загрузке двоичного файла. В Arm64EC, если защита CFG отключена, ОПЕРАЦИОННая система установит __os_arm64x_check_icall_cfg то же самое, что __os_arm64x_check_icallи для проверки требуемой целевой архитектуры во всех случаях, но не защиты CFG.
Как и в случае с CFG в классической версии Arm64, вызов целевой функции (x11) должен немедленно следовать вызову средства проверки вызовов. Адрес средства проверки вызовов должен быть помещен в неустойчивый регистр, и ни его, ни адрес целевой функции, никогда не должен быть скопирован в другой регистр или разливаться в память.
Средства проверки стека
__chkstk автоматически используется компилятором каждый раз, когда функция выделяет область в стеке больше страницы. Чтобы избежать пропуска страницы стека защиты конца стека, вызывается, чтобы убедиться, __chkstk что все страницы в выделенной области проверены.
__chkstk обычно вызывается из пролога функции. По этой причине и для оптимального создания кода используется настраиваемое соглашение о вызовах.
Это означает, что код x64 и код Arm64EC нуждаются в собственных, уникальных, __chkstk функциях, так как блоки входа и выхода предполагают стандартные соглашения о вызовах.
x64 и Arm64EC используют одно и то же пространство имен символов, поэтому не может быть двух функций с именем __chkstk. Для обеспечения совместимости с существующим кодом __chkstk x64 имя будет связано с средством проверки стека x64. Вместо этого будет использоваться __chkstk_arm64ec код Arm64EC.
Соглашение о пользовательских вызовах __chkstk_arm64ec совпадает с классическим arm64 __chkstk: x15 предоставляет размер выделения в байтах, разделенный на 16. Сохраняются все ненезависимые регистры, а также все переменные регистры, участвующие в стандартном соглашении о вызовах.
Все, что говорилось выше, __chkstk применяется одинаково к __security_check_cookie своему коллеге Arm64EC: __security_check_cookie_arm64ec
Соглашение о вызовах variadic
Arm64EC следует классическому соглашению о вызовах Arm64 ABI, за исключением функций с переменным количеством аргументов (также известных как varargs или функций с ключевым словом многоточие (...)).
Для конкретного случая вариатических случаях Arm64EC следует соглашению о вызове, очень похожему на x64 variadic, с лишь несколькими различиями. В следующем списке показаны основные правила для arm64EC variadic:
- Для передачи параметров используются только первые четыре регистра:
x0,x1,x2.x3Оставшиеся параметры передаются на стек. Это правило точно следует соглашению о вызовах x64 с переменным числом аргументов и отличается от Arm64 Classic, где используются регистрыx0иx7. - Параметры с плавающей точкой и SIMD, передаваемые через регистры, используют регистры общего назначения, а не регистры SIMD. Это правило аналогично Классической версии Arm64 и отличается от x64, где параметры FP/SIMD передаются как в регистре общего назначения, так и в SIMD. Например, для функции
f1(int, …), вызываемой какf1(int, double)x64, второй параметр назначается обоимRDXиXMM1. В Arm64EC второй параметр назначается толькоx1. - При передаче структур по значению через регистр применяются правила размера x64: структуры с размером ровно 1, 2, 4 и 8 байт загружаются непосредственно в регистр общего назначения. Структуры с другими размерами переносятся на стек, а указатель на место переполнения присваивается регистру. Это правило, по сути, преобразует передачу по значению в передачу по ссылке на низком уровне. В классическом ABI Arm64 структуры любого размера до 16 байт назначаются непосредственно регистрам общего назначения.
- Регистр
x4загружает указатель на первый параметр, передаваемый через стек (пятый параметр). Это правило не включает структуры, разливаемые из-за ограничений размера, описанных ранее. - Регистр
x5загружает размер (в байтах) всех параметров, передаваемых стеком (размер всех параметров, начиная с пятого). Это правило не включает структуры, передаваемые по значению, которые переходят из-за ограничений размера, описанных ранее.
В следующем примере pt_nova_function принимает параметры в не-вариативной форме, поэтому 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:
- "f" является двойным. Он назначает элемент
d0. - "tc" — это структуру размером 3 байта. Он назначает элемент
x0. -
ull1— целое число 8-байтов. Назначается наx1. -
ull2— целое число 8-байтов. Он назначаетсяx2. -
ull3— целое число 8-байтов. Он назначаетсяx3.
pt_va_function — это динамическая функция, поэтому она следует правилам variadic Arm64EC, описанным ранее:
- "f" является двойным. Он назначается
x0. - "tc" — это структуру размером 3 байта. Он записывается в стек и его адрес загружается в
x1. -
ull1— целое число 8-байтов. Он назначаетсяx2. -
ull2— целое число 8-байтов. Он назначает элементx3. -
ull3— целое число 8-байтов. Он назначается непосредственно стеку. -
x4загружает расположениеull3в стеке. -
x5загружает размерull3.
В следующем примере показаны потенциальные результаты компиляции pt_nova_function, которые иллюстрируют различия в назначении параметров, описанные ранее.
stp fp,lr,[sp,#-0x30]!
mov fp,sp
sub sp,sp,#0x10
str x3,[sp] ; Spill 5th parameter
mov x3,x2 ; 4th parameter to x3 (from x2)
mov x2,x1 ; 3rd parameter to x2 (from x1)
str w0,[sp,#0x20] ; Spill 2nd parameter
add x1,sp,#0x20 ; Address of 2nd parameter to x1
fmov x0,d0 ; 1st parameter to x0 (from d0)
mov x4,sp ; Address of the 1st in-stack parameter to x4
mov x5,#8 ; Size of the in-stack parameter area
bl pt_va_function
add sp,sp,#0x10
ldp fp,lr,[sp],#0x30
ret
Дополнения ABI
Чтобы обеспечить прозрачное взаимодействие с кодом x64, внесите много дополнений в классический ABI Arm64. Эти дополнения обрабатывают различия между соглашениями о вызовах между Arm64EC и x64.
Следующий список включает следующие дополнения:
- Вход и выход из Thunks
- Выход из Thunks
- Записи Thunks
- Thunks для параметров
- Последовательности быстрого переадресации
Входные и выходные thunks
Вход и выход из thunks переводят соглашение о вызовах Arm64EC (в основном то же самое, что и классический Arm64) в соглашение о вызовах x64 и наоборот.
Распространенное неправильное представление заключается в том, что можно преобразовать соглашения о вызовах, следуя одному правилу, применяемого ко всем подписям функций. Реальность заключается в том, что соглашения о вызовах имеют правила назначения параметров. Эти правила зависят от типа параметра и отличаются от ABI до ABI. Следствием является то, что преобразование между ABIs специфично для каждой сигнатуры функции и варьируется в зависимости от типа каждого параметра.
Рассмотрим следующую функцию:
int fJ(int a, int b, int c, int d);
Назначение параметров происходит следующим образом:
- Arm64: a -> x0, b -> x1, c -> x2, d -> x3
- x64: a -> RCX, b -> RDX, c - R8, d ->> r9
- Arm64 — перевод x64> : x0 —> RCX, x1 —> RDX, x2 —> R8, x3 —> R9
Теперь рассмотрим другую функцию:
int fK(int a, double b, int c, double d);
Назначение параметров происходит следующим образом:
- Arm64: a -> x0, b -> d0, c -> x1, d -> d1
- x64: a -> RCX, b -> XMM1, c -> R8, d -> XMM3
- Arm64 — перевод x64> : x0 —> RCX, d0 —> XMM1, x1 —> R8, d1 —> XMM3
В этих примерах показано, что назначение параметров и перевод зависят от типов предыдущих параметров в списке. Эти сведения иллюстрируются третьим параметром. В обоих функциях тип параметра имеет значение int, но результирующий перевод отличается.
Входные и выходные блоки существуют по этой причине и специально адаптированы для каждой отдельной подписи функции.
Оба типа thunks представляют собой функции. Эмулятор автоматически вызывает входные переходы, когда функции x64 вызывают функции Arm64EC (задача перехода к Arm64EC). Проверяющие средства автоматически вызывают завершающие функции при вызове функций Arm64EC в функции x64 (выполнение завершает Arm64EC).
При компиляции кода Arm64EC компилятор создает запись для каждой функции Arm64EC, соответствующую его подписи. Компилятор также создает выходной thunk для каждой функции, которую вызывает функция 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 - Выход из thunk для
fC
Компилятор генерирует fA переходной код (entry thunk) в случае вызова fA из x64-кода. Компилятор создает выходные транки для fB и fC в случае, если fB и fC являются кодом x64.
Компилятор может создавать один и тот же выходной участок кода несколько раз, так как он создается в месте вызова, а не в самой функции. Это дублирование может привести к значительному количеству избыточных thunks. Чтобы избежать дублирования, компилятор применяет тривиальные правила оптимизации, чтобы убедиться, что в окончательный двоичный файл попадают только необходимые thunks.
Например, в двоичном файле, где функция A вызывает функцию B Arm64EC, B не экспортируется, и его адрес никогда не известен за пределами A. Безопасно устранить выходной thunk от A до B, а также входной thunk для B. Это также безопасно объединять все выходные и входные фрагменты, содержащие один и тот же код, даже в том случае, если они были созданы для отдельных функций.
Выход из thunks
Используя примеры функций fA, fB и fC в предыдущем разделе, компилятор создает выходные thunks fB и fC следующим образом:
Выход из thunk в int fB(int a, double b, int i1, int i2, int i3);
$iexit_thunk$cdecl$i8$i8di8i8i8:
stp fp,lr,[sp,#-0x10]!
mov fp,sp
sub sp,sp,#0x30
adrp x8,__os_arm64x_dispatch_call_no_redirect
ldr xip0,[x8]
str x3,[sp,#0x20] ; Spill 5th param (i3) into the stack
fmov d1,d0 ; Move 2nd param (b) from d0 to XMM1 (x1)
mov x3,x2 ; Move 4th param (i2) from x2 to R9 (x3)
mov x2,x1 ; Move 3rd param (i1) from x1 to R8 (x2)
blr xip0 ; Call the emulator
mov x0,x8 ; Move return from RAX (x8) to x0
add sp,sp,#0x30
ldp fp,lr,[sp],#0x10
ret
Выход из thunk в int fC(int a, struct SC c, int i1, int i2, int i3);
$iexit_thunk$cdecl$i8$i8m3i8i8i8:
stp fp,lr,[sp,#-0x20]!
mov fp,sp
sub sp,sp,#0x30
adrp x8,__os_arm64x_dispatch_call_no_redirect
ldr xip0,[x8]
str w1,[sp,#0x40] ; Spill 2nd param (c) onto the stack
add x1,sp,#0x40 ; Make RDX (x1) point to the spilled 2nd param
str x4,[sp,#0x20] ; Spill 5th param (i3) into the stack
blr xip0 ; Call the emulator
mov x0,x8 ; Move return from RAX (x8) to x0
add sp,sp,#0x30
ldp fp,lr,[sp],#0x20
ret
fB В этом случае наличие параметра double приводит к перетасовке назначений остальных регистров общего назначения, в результате различных правил назначения Arm64 и x64. Кроме того, можно увидеть, что x64 назначает только четыре параметра регистрам, поэтому пятый параметр должен быть перелит на стек.
fC В случае второй параметр является структурой 3-байтовой длины. Arm64 позволяет назначать любую структуру размера непосредственно регистру. x64 разрешает только размеры 1, 2, 4 и 8. Этот выходной thunk должен перенести struct из регистра в стек и записать в регистр указатель. Этот подход по-прежнему использует один регистр (для переноса указателя), поэтому он не изменяет назначения для оставшихся регистров: для третьих и четвертых параметров не выполняется перетасовка регистров. Как и в fB случае, пятый параметр должен быть перелит на стек.
Дополнительные рекомендации по выходу из Thunks:
- Компилятор называет их не по имени функции, от и до которой они переводят, а по сигнатуре, к которой они обращаются. Это соглашение об именовании упрощает поиск избыточности.
- Средство проверки вызова задает регистр
x9для передачи адреса целевой функции (x64). 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-x8, x15(chkstk) и .q0-q7
Записи Thunks
Запись Thunks заботится о преобразованиях, необходимых от x64 к соглашениям о вызовах Arm64. Это преобразование, по сути, является инверсным процессом Exit Thunks и включает в себя несколько дополнительных аспектов, которые следует также учитывать.
Рассмотрим предыдущий пример компиляции fA. Для вызова fA с помощью x64 кода создается конструкция «Entry Thunk».
Запись Thunk для int fA(int a, double b, struct SC c, int i1, int i2, int i3)
$ientry_thunk$cdecl$i8$i8dm3i8i8i8:
stp q6,q7,[sp,#-0xA0]! ; Spill full non-volatile XMM registers
stp q8,q9,[sp,#0x20]
stp q10,q11,[sp,#0x40]
stp q12,q13,[sp,#0x60]
stp q14,q15,[sp,#0x80]
stp fp,lr,[sp,#-0x10]!
mov fp,sp
ldrh w1,[x2] ; Load 3rd param (c) bits [15..0] directly into x1
ldrb w8,[x2,#2] ; Load 3rd param (c) bits [16..23] into temp w8
bfi w1,w8,#0x10,#8 ; Merge 3rd param (c) bits [16..23] into x1
mov x2,x3 ; Move the 4th param (i1) from R9 (x3) to x2
fmov d0,d1 ; Move the 2nd param (b) from XMM1 (d1) to d0
ldp x3,x4,[x4,#0x20] ; Load the 5th (i2) and 6th (i3) params
; from the stack into x3 and x4 (using x4)
blr x9 ; Call the function (fA)
mov x8,x0 ; Move the return from x0 to x8 (RAX)
ldp fp,lr,[sp],#0x10
ldp q14,q15,[sp,#0x80] ; Restore full non-volatile XMM registers
ldp q12,q13,[sp,#0x60]
ldp q10,q11,[sp,#0x40]
ldp q8,q9,[sp,#0x20]
ldp q6,q7,[sp],#0xA0
adrp xip0,__os_arm64x_dispatch_ret
ldr xip0,[xip0,__os_arm64x_dispatch_ret]
br xip0
Эмулятор предоставляет адрес целевой функции в x9.
Перед вызовом Entry Thunk эмулятор x64 выталкивает возвращаемый адрес из стека в регистр LR.
LR Ожидается, что он будет указывать на код x64, когда управление передается в Entry Thunk.
Эмулятор также может выполнить другую настройку стека в зависимости от следующего: интерфейсы ABIs Arm64 и x64 определяют требование выравнивания стека, в котором стек должен быть выровнен до 16 байтов в точке вызова функции. При выполнении кода Arm64 оборудование применяет это правило, но для x64 не применяется аппаратное применение. При выполнении кода x64 ошибочно вызываемые функции с невыравненным стеком могут оставаться незамеченными на неопределенный срок, пока не будет использоваться команда с 16-байтовым выравниванием (как в некоторых инструкциях SSE) или вызовется код Arm64EC.
Чтобы устранить эту потенциальную проблему совместимости, перед вызовом Entry Thunk, эмулятор всегда снижает выравнивание указателя стека до 16 байт и сохраняет его исходное значение в регистре x4. Таким образом, входные Thunks всегда начинают выполнение с выровненным стеком, но по-прежнему могут правильно ссылаться на параметры, переданные в стеке, через x4.
Когда дело доходит до неизменяемых регистров SIMD, существует значительное различие между соглашениями вызова Arm64 и x64. В Arm64 низкие 8 байт (64 бита) регистра считаются нелетучими. Другими словами, только Dn часть Qn регистров не является переменной. В x64 все 16 байт XMMn регистра считаются ненезависимыми. Кроме того, в x64 и XMM6 являются ненезависимыми регистрами, XMM7 в то время как D6 и D7 (соответствующие регистры Arm64) являются переменными.
Чтобы устранить асимметрию операций с регистром SIMD, запись Thunks должна явно сохранить все регистры SIMD, которые считаются не изменяющимися в x64. Это сохранение требуется только для входных Thunks (не выходных Thunks), так как x64 является более строгим, чем Arm64. Другими словами, правила сохранения и восстановления регистров в x64 превосходят требования Arm64 во всех случаях.
Чтобы обеспечить правильное восстановление этих значений регистра при очистке стека (например, setjmp + longjmp или throw + catch), был представлен новый код развёртки: save_any_reg (0xE7). Этот новый 3-байтовый опкод позволяет сохранять все регистры общего назначения или SIMD (включая те, которые считаются переменными) и включая полноразмерные Qn регистры. Этот новый код операции используется для выгрузки и загрузки регистра 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, подпрограмма снова входит в эмулятор, возвращаясь к коду x64, на который указывает LR.
Функции Arm64EC резервируют четыре байта перед первой инструкцией для хранения информации, используемой во время выполнения. В этих четырех байтах можно найти относительный адрес Entry Thunk для функции. При вызове функции x64 в функцию Arm64EC эмулятор считывает четыре байта перед началом функции, маскирует нижние два бита и добавляет этот объем в адрес функции. Этот процесс создает адрес Entry Thunk для вызова.
Thunks для параметров
Функции Adjustor Thunks — это функции без сигнатур, которые неявно передают управление в другую функцию посредством хвостового вызова (tail-call). Перед передачей элемента управления они преобразуют один из параметров. Тип преобразованных параметров известен, но все остальные параметры могут быть любыми и могут находиться в любом количестве. Adjustor Thunks не затрагивают ни регистры, которые потенциально могут содержать параметры, ни стек. Эта характеристика делает функции Adjustor Thunks функциями без сигнатур.
Компилятор может автоматически создавать Thunks Adjustor. Это поколение распространяется, например, на множественное наследование C++, где любой виртуальный метод может делегировать вызов родительскому классу без модификаций, кроме корректировки указателя this.
В следующем примере показан реальный сценарий:
[thunk]:CObjectContext::Release`adjustor{8}':
sub x0,x0,#8
b CObjectContext::Release
Thunk вычитает 8 байт this на указатель и перенаправляет вызов родительского класса.
В итоге функции Arm64EC, вызываемые из функций x64, должны иметь связанную запись Thunk. Запись Thunk относится к сигнатуре. Функции без подписей Arm64, такие как Thunks, требуют другого механизма, который может обрабатывать функции без подписей.
В элементе Entry Thunk элемента Thunk используется вспомогательный элемент для отсрочки выполнения реальной работы Entry Thunk __os_arm64x_x64_jump (настройка параметров из одного соглашения на другое) до следующего вызова. В настоящее время подпись становится очевидной. Это включает в себя возможность не выполнять корректировки соглашения о вызове вообще, если целевой объект Thunk Thunk Adjustor оказывается функцией x64. Помните, что к моменту запуска записи Thunk параметры находятся в их форме x64.
В приведенном выше примере рассмотрим, как выглядит код в Arm64EC.
Адаптатор Thunk в 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 Thunk
[thunk]:CObjectContext::Release$entry_thunk`adjustor{8}':
sub x0,x0,#8
adrp x9,CObjectContext::Release
add x9,x9,CObjectContext::Release
adrp xip0,__os_arm64x_x64_jump
ldr xip0,[xip0,__os_arm64x_x64_jump]
br xip0
Последовательности быстрого перемотки
Некоторые приложения вносят изменения во время выполнения в функции, находящиеся в двоичных файлах, которые они не имеют, но зависят от двоичных файлов операционной системы , как правило, в целях отмены выполнения при вызове функции. Этот процесс также называется хуком.
На высоком уровне процесс хукинга прост. Однако привязка является архитектурой конкретной и довольно сложной, учитывая потенциальные вариации логики перехватчика, должны решаться.
Как правило, процесс состоит из следующих шагов:
- Определите адрес функции для перехватчика.
- Замените первую инструкцию функции переходом к подпрограмме перехватчика.
- Когда перехватчик будет выполнен, вернитесь к исходной логике, которая включает выполнение перемещенной исходной инструкции.
Варианты возникают из таких вещей:
- Размер первой инструкции: рекомендуется заменить её на JMP того же размера или меньшего, чтобы избежать замены верхней части функции, когда другой поток может её выполнять.
- Тип первой инструкции: Если первая инструкция как-то связана с PC, её перенос может потребовать изменения таких элементов, как поля смещения. Так как может произойти переполнение при перемещении инструкции в отдаленное место, это изменение может потребовать предоставления аналогичной логики с использованием других инструкций.
Из-за всей сложности, надежной и универсальной логики перехватчика редко найти. Часто логика, которая имеется в приложениях, может справиться только с ограниченным набором случаев, которые приложение ожидает столкнуться в конкретных API, которыми оно интересуется. Нелегко представить, с какими проблемами совместимости приложений это связано. Даже простое изменение в коде или оптимизации компилятора может сделать приложения неработоспособными, если код уже не соответствует ожидаемому виду.
Что произойдет с этими приложениями, если они столкнулись с кодом Arm64 при настройке перехватчика? Они, безусловно, не смогли бы.
Функции быстрой перемотки последовательности (FFS) решают эту потребность в совместимости в Arm64EC.
FFS — это очень небольшие функции x64, которые не содержат реальной логики и осуществляют возврат вызова к реальной функции Arm64EC. Они необязательные, но включены по умолчанию для всех экспортов DLL и для любой функции, украшенной __declspec(hybrid_patchable).
В таких случаях, когда код получает указатель на определенную функцию, либо GetProcAddress в случае экспорта, либо &function в __declspec(hybrid_patchable) случае, в результирующем адресе содержится код x64. Этот код x64 выглядит как законная функция x64, удовлетворяющая большинству доступной в настоящее время логики перехвата.
Рассмотрим следующий пример (обработка ошибок, опущенная для краткости):
auto module_handle =
GetModuleHandleW(L"api-ms-win-core-processthreads-l1-1-7.dll");
auto pgma =
(decltype(&GetMachineTypeAttributes))
GetProcAddress(module_handle, "GetMachineTypeAttributes");
hr = (*pgma)(IMAGE_FILE_MACHINE_Arm64, &MachineAttributes);
Значение указателя функции в переменной pgma содержит адрес GetMachineTypeAttributesFFS.
В этом примере показана последовательность 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 имеет канонический пролог и эпилог, заканчивая хвостом вызовом (переходом) к реальной GetMachineTypeAttributes функции в коде Arm64EC:
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
[...]
Это было бы довольно неэффективно, если требуется выполнить пять эмулированных инструкций x64 между двумя функциями Arm64EC. Функции FFS являются специальными. Функции FFS не выполняются, если они остаются неуправляемыми. Вспомогательное средство проверки вызовов эффективно проверяет, что FFS не изменился. Если это так, вызов передается непосредственно в реальное место назначения. Если FFS изменяется каким-либо образом, он больше не является FFS. Выполнение передается в измененный FFS и запускает любой код, эмулируя обход и любую логику перехвата.
Когда хук передает выполнение обратно в конец FFS, он в конечном итоге достигает хвостового вызова к коду Arm64EC, который выполняется после хука, так же как ожидает приложение.
Создание Arm64EC на ассемблере
Заголовки пакета SDK для Windows и компилятор C упрощают задание разработки сборки Arm64EC. Например, можно использовать компилятор C для создания точек входа и выхода thunks для функций, которые не компилируются с помощью кода на языке C.
Рассмотрим пример эквивалентной следующей функции fD , которую необходимо создать в сборке (ASM). Код 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);
}
Запись fD в ASM может выглядеть следующим образом:
#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указатель функции с помощью средства проверки вызова EC вместе с соответствующим выходом, если целевая функция — x64.
Создание входных и выходных фрагментов
Следующим шагом является создание вступительного модуля 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 использовать сигнатуру функции, игнорировать тело функции и создать либо exit thunk (если параметр равен 1), либо entry thunk (если параметр равен 2).
Поместите генерацию thunk в отдельный файл на C. Будучи в изолированных файлах, проще подтвердить имена символов путем дампа соответствующих OBJ символов или даже дизассемблирования.
Пользовательские входные трюки
Пакет SDK содержит макросы, которые помогают создавать настраиваемые, вручную кодируемые "entry thunks". Эти макросы можно использовать при создании пользовательских adjustor thunks.
Большинство кнопок-адаптеров создаются компилятором C++, но их также можно создать вручную. Вы можете вручную создать адаптер thunk, когда обобщенный обратный вызов передает управление в реальный обратный вызов, и один из параметров идентифицирует этот реальный обратный вызов.
В следующем примере показана функция адаптации в классическом коде 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
В этом примере первый параметр предоставляет ссылку на структуру. Код извлекает адрес целевой функции из элемента этой структуры. Так как структура является записываемой, функция Control Flow Guard (CFG) должна проверить целевой адрес.
В следующем примере показано, как адаптировать эквивалентный adjustor 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 для выхода из thunk. Вызывающий объект выполняет вызов, предназначенный для явной подписи.
Приведенный выше код требует записи, чтобы устранить ситуацию, когда вызывающий объект является кодом x64. В следующем примере показано, как создать соответствующую входную заглушку с помощью макроса для пользовательских входных заглушек.
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-компиляторы создают код во время выполнения, который не поддерживается двоичным файлом.
Обычно этот процесс включает в себя следующие действия:
- Выделение записываемой памяти (
VirtualAlloc). - Создание кода в выделенной памяти.
- Повторная защита памяти из read-write в read-execute (
VirtualProtect). - Добавление записей функции очистки для всех нетривиальных (неконечных) созданных функций (
RtlAddFunctionTableилиRtlAddGrowableFunctionTable).
По тривиальным причинам совместимости, если приложение выполняет эти действия в процессе Arm64EC, операционная система рассматривает код как код x64. Это происходит для любого процесса, использующего неизмененную среду выполнения Java x64, среду выполнения .NET, подсистему JavaScript и т. д.
Чтобы создать динамический код Arm64EC, выполните один и тот же процесс с двумя различиями:
- При выделении памяти используйте более новый
VirtualAlloc2(вместоVirtualAllocилиVirtualAllocEx) и укажите атрибутMEM_EXTENDED_PARAMETER_EC_CODE. - При добавлении записей функций:
- Они должны быть в формате Arm64. При компиляции кода типа
RUNTIME_FUNCTIONArm64EC он соответствует формату x64. Для формата Arm64 при компиляции Arm64EC используйтеARM64_RUNTIME_FUNCTIONвместо него тип. - Не используйте старый
RtlAddFunctionTableAPI. Всегда используйте более новыйRtlAddGrowableFunctionTableAPI.
- Они должны быть в формате Arm64. При компиляции кода типа
В следующем примере показано выделение памяти:
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