Общие сведения об Arm64EC ABI и коде сборки
Arm64EC ("Совместимость с эмуляцией") — это новый двоичный интерфейс приложения (ABI) для создания приложений для Windows 11 в Arm. Общие сведения о Arm64EC и создании приложений Win32 в Arm64EC см. в статье "Использование Arm64EC для создания приложений для Windows 11 на устройствах Arm".
Цель этого документа — предоставить подробное представление ABI Arm64EC с достаточной информацией для разработчика приложений для написания и отладки кода, скомпилированного для Arm64EC, включая отладку и сборку с низкоуровневой отладкой и написанием кода сборки, предназначенный для ABI Arm64EC.
Проектирование Arm64EC
Arm64EC был разработан для обеспечения функциональности и производительности собственного уровня, обеспечивая прозрачное и прямое взаимодействие с кодом x64, выполняющимся под эмуляцией.
Arm64EC в основном является аддитивным к классическому ABI Arm64. Очень мало классического ABI было изменено, но добавлены части для обеспечения взаимодействия x64.
В этом документе исходный стандарт Arm64 ABI должен называться классическим ABI. Это позволяет избежать неоднозначности, присущей перегруженным терминам, таким как Native. Arm64EC, чтобы быть ясным, каждый бит как собственный, как исходный ABI.
Arm64EC против Arm64 Classic ABI
В следующем списке указано, где Arm64EC разошлись от Arm64 Classic ABI.
- Регистрация сопоставлений и заблокированных регистров
- Средства проверки вызовов
- Средства проверки стека
- Соглашение о вызовах variadic
Это небольшие изменения при просмотре в перспективе того, сколько всего ABI определяет.
Регистрация сопоставлений и заблокированных регистров
Чтобы обеспечить взаимодействие на уровне типа с кодом x64, код Arm64EC компилируется с теми же определениями архитектуры предпроцессорной архитектуры, что и код x64.
Другими словами, _M_AMD64
и _AMD64_
определяются. Одним из типов, затронутых этим правилом, является CONTEXT
структура. Структура CONTEXT
определяет состояние ЦП в заданной точке. Он используется для таких вещей, GetThreadContext
как Exception Handling
и API. Существующий код x64 ожидает, что контекст ЦП будет представлен как структура x64 CONTEXT
или, другими словами, CONTEXT
структура, определяемая во время компиляции x64.
Эта структура должна использоваться для представления контекста ЦП при выполнении кода x64, а также кода Arm64EC. Существующий код не будет понимать новую концепцию, например набор регистров ЦП, изменяющийся с функции на функцию. Если структура x64 CONTEXT
используется для представления состояний выполнения Arm64, это означает, что регистры Arm64 эффективно сопоставляются с регистрами x64.
Кроме того, предполагается, что все регистры Arm64, которые не могут быть установлены в x64 CONTEXT
, не должны использоваться, так как их значения могут быть потеряны в любой момент, когда операция используется CONTEXT
(и некоторые из них могут быть асинхронными и непредвиденными, например операция сборки мусора среды выполнения управляемого языка или APC).
Правила сопоставления между регистрами Arm64EC и x64 представлены ARM64EC_NT_CONTEXT
структурой в заголовках Windows, присутствующих в пакете SDK. Эта структура по сути является объединением CONTEXT
структуры, точно так же, как она определена для x64, но с дополнительным наложением регистра Arm64.
Например, RCX
сопоставляется с X0
, RDX
to X1
, RSP
to SP
, RIP
to , и PC
т. д. Мы также видим, как регистры x13
, x14
, x23
, x24
, x28
не v16
-v31
имеют представления и, таким образом, не могут использоваться в Arm64EC.
Это ограничение использования регистрации является первым различием между классическими и EC ABIs Arm64.
Средства проверки вызовов
Средства проверки звонков были частью Windows с момента внедрения Функции управления 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 может иметь два варианта в зависимости от архитектуры кода.
Binary | Код | Незащищенный непрямый вызов | Защищенный косвенный вызов 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, за исключением функций Variadic (ака varargs, ака функций с ключевым словом многоточия (.).
Для конкретного случая вариатических случаях Arm64EC следует соглашению о вызове, очень похожему на x64 variadic, с лишь несколькими различиями. Ниже приведены основные правила для Arm64EC variadic:
- Для передачи параметров используются только первые 4 регистра:
x0
,x1
,x2
.x3
Оставшиеся параметры перетекаются на стек. Это следует соглашению о вызовах x64 variadic, и отличается от Arm64 Classic, где регистрыx0
>x7
используются. - Параметры с плавающей запятой или SIMD, передаваемые регистром общего назначения, а не SIMD. Это похоже на Arm64 Classic и отличается от x64, где параметры FP/SIMD передаются как в регистре общего назначения, так и в SIMD. Например, для вызываемой функции
f1(int, …)
вf1(int, double)
x64 второй параметр будет назначен обоимRDX
иXMM1
. В Arm64EC второй параметр будет назначен толькоx1
. - При передаче структур по значению через регистр применяются правила размера x64: структуры с размерами ровно 1, 2, 4 и 8 будут загружаться непосредственно в регистр общего назначения. Структуры с другими размерами разливаются на стек, а указатель на разливное расположение назначается регистру. Это, по сути, понижает по значению по ссылке на низкоуровневый уровень. В классическом ABI Arm64 структуры любого размера до 16 байт назначаются непосредственно регистрам общего назначения.
- Регистр X4 загружается указателем на первый параметр, передаваемый через стек (5-й параметр). Это не включает структуры, разливаемые из-за ограничений размера, описанных выше.
- Регистр X5 загружается с размером( в байтах) всех параметров, передаваемых стеком (размер всех параметров, начиная с 5-го). Это не включает структуры, передаваемые по значению, разливаемого из-за ограничений размера, описанных выше.
В следующем примере pt_nova_function
ниже приведены параметры в невариационной форме, поэтому после соглашения о вызовах Классического Arm64. Затем он вызывает pt_va_function
с теми же параметрами, но в вариативном вызове.
struct three_char {
char a;
char b;
char c;
};
void
pt_va_function (
double f,
...
);
void
pt_nova_function (
double f,
struct three_char tc,
__int64 ull1,
__int64 ull2,
__int64 ull3
)
{
pt_va_function(f, tc, ull1, ull2, ull3);
}
pt_nova_function
принимает 5 параметров, которые будут назначены в соответствии с правилами соглашения о вызовах классической модели 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
В этих примерах показано, что назначение параметров и перевод зависят от типа, но и от типов предыдущих параметров в списке зависят. Эта информация иллюстрируется 3-м параметром. В обоих функциях тип параметра имеет значение int, но результирующий перевод отличается.
Входные и выходные Thunks существуют по этой причине и специально адаптированы для каждой отдельной подписи функции.
Оба типа thunks являются, сами по себе, функции. Записи Thunks автоматически вызываются эмулятором при вызове функций x64 в функции Arm64EC (выполнение Ввод Arm64EC). Выход из Thunks автоматически вызывается средствами проверки вызовов при вызове функций Arm64EC в функции x64 (выполнение exits Arm64EC).
При компиляции кода Arm64EC компилятор создаст запись Thunk для каждой функции 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.
- Запись Thunk для fA
- Выход из Thunk для fB
- Выход из Thunk для fC
Объект fA
Entry Thunk создается в случае fA
и вызывается из кода x64. Выход из Thunks для fB
и fC
создаются в случае fB
и (или) fC
и оказывается кодом x64.
Один и тот же exit Thunk может создаваться несколько раз, учитывая, что компилятор создаст их на сайте вызова, а не самой функции. Это может привести к значительному количеству избыточных thunks, поэтому в действительности компилятор будет применять тривиальные правила оптимизации, чтобы убедиться, что только необходимые thunks делают его в окончательный двоичный файл.
Например, в двоичном файле, где функция Arm64EC вызывает функцию A
B
Arm64EC, не экспортируется, B
а его адрес никогда не известен за пределами A
. Это безопасно, чтобы исключить выход из Thunk из A
B
, а также запись Thunk для B
. Кроме того, они безопасно псевдонимируют все thunks exit и entry, содержащие один и тот же код, даже если они были созданы для различных функций.
Выход из Thunks
Используя примеры функций fA
и fB
fC
выше, компилятор создаст оба fB
элемента и fC
выход из Thunks:
Выход из 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 назначает только 4 параметра регистрам, поэтому 5-й параметр должен быть разбросан на стек.
fC
В случае второй параметр является структурой 3-байтовой длины. Arm64 позволяет назначать любую структуру размера регистру напрямую. x64 разрешает только размеры 1, 2, 4 и 8. Этот выход из Thunk должен затем передать его struct
из регистра в стек и назначить указатель на регистр. Это по-прежнему использует один регистр (для переноса указателя), поэтому он не изменяет назначения для оставшихся регистров: для 3-го и 4-го параметра не выполняется перетасовка регистра. Как и в fB
случае, 5-й параметр должен быть разлит на стек.
Дополнительные рекомендации по выходу из Thunks:
- Компилятор будет называть их не именем функции, в которую они переводятся,> а сигнатурой, в которую они обращаются. Это упрощает поиск избыточности.
- Объект Exit Thunk вызывается с регистром
x9
, который содержит адрес целевой функции (x64). Это задается методом проверки вызова и передается через выход из Thunk, не беспокоиться, в эмулятор.
После переупорядочения параметров выход из 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
возвращает адрес выходного объекта Thunk, не встревоженный из входных данных.x9
возвращает целевую функцию x64. Это может быть или не совпадать с тем же значением, которое оно было предоставлено черезx11
.
Средство проверки вызовов всегда оставляет параметр соглашения о вызове не беспокоиться, поэтому вызывающий код должен следовать вызову средства проверки вызова немедленно с blr x11
(или br x11
в случае хвостового вызова). Это средства проверки вызовов регистров. Они всегда сохраняются выше и за пределами стандартных ненезависимых регистров: x0
-x8
, x15
(chkstk
) и .q0
-q7
Записи Thunks
Запись Thunks заботится о преобразованиях, необходимых от x64 к соглашениям о вызовах Arm64. Это, по сути, обратный выход Thunks, но есть несколько дополнительных аспектов, которые следует рассмотреть.
Рассмотрим предыдущий пример компиляции fA
, создается объект Entry Thunk, который fA
может вызываться кодом x64.
Запись 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
.
Перед вызовом записи Thunk эмулятор x64 появляется возвращаемый адрес из стека в LR
регистр. Ожидается, что LR
он будет указывать на код x64, когда элемент управления передается в элемент управления Entry Thunk.
Эмулятор также может выполнить другую настройку стека в зависимости от следующего: Интерфейсы ABIs Arm64 и x64 определяют требование выравнивания стека, в котором стек должен быть выровнен до 16 байтов в точке вызова функции. При выполнении кода Arm64 оборудование применяет это правило, но для x64 не применяется аппаратное применение. При выполнении кода x64 ошибочно вызываемые функции с неуправляемым стеком могут оставаться незамеченными на неопределенный срок, пока не будет использоваться около 16-байтовая инструкция выравнивания (некоторые инструкции SSE) или код Arm64EC вызывается.
Чтобы устранить эту потенциальную проблему совместимости, перед вызовом записи Thunk эмулятор всегда будет выравнивать указатель стека на 16 байт и хранить исходное значение в регистре x4
. Таким образом, запись Thunks всегда начинает выполняться с выровненным стеком, но по-прежнему может правильно ссылаться на параметры, переданные в стеке, через x4
.
Когда дело доходит до ненезависимых регистров SIMD, существует значительное различие между соглашениями о вызовах Arm64 и x64. В Arm64 низкие 8 байт (64 бита) регистра считаются нелетучими. Другими словами, только Dn
часть Qn
регистров не является переменной. В x64 все 16 байт XMMn
регистра считаются ненезависимыми. Кроме того, в x64 и XMM7
являются ненезависимыми регистрами, XMM6
в то время как D6 и D7 (соответствующие регистры Arm64) являются переменными.
Чтобы устранить асимметрию операций с регистром SIMD, запись Thunks должна явно сохранить все регистры SIMD, которые считаются ненезависимыми в x64. Это необходимо только для входных thunks (не выход из Thunks), так как x64 является более строгим, чем Arm64. Другими словами, регистрация правил сохранения и сохранения в x64 превышает требования Arm64 во всех случаях.
Чтобы устранить правильное восстановление этих значений регистра при очистке стека (например, setjmp + longjmp или throw +catch), был представлен новый код распаковки: save_any_reg (0xE7)
Этот новый 3-байтовый опкод позволяет сохранять все регистры общего назначения или SIMD (включая те, которые считаются переменными) и включая полноразмерные Qn
регистры. Этот новый код opcode используется для операций Qn
разлива или заливки выше. save_any_reg
совместим с save_next_pair (0xE6)
.
Для справки ниже приведены соответствующие сведения о очистке, относящиеся к записи 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)
После возврата __os_arm64x_dispatch_ret
функции Arm64EC подпрограмма используется для повторного ввода эмулятора обратно в код x64 (на который указывает).LR
Функции Arm64EC имеют 4 байта перед первой инструкцией в функции, зарезервированной для хранения информации, используемой во время выполнения. В этих 4 байтах можно найти относительный адрес записи Thunk для функции. При вызове функции x64 в функцию Arm64EC эмулятор считывает 4 байта перед началом функции, маскирует нижние два бита и добавляет эту сумму в адрес функции. При этом будет получен адрес вызываемого объекта Entry Thunk.
Thunks для параметров
Thunks — это функции без подписей, которые просто передают управление (tail-call) другой функции после выполнения некоторого преобразования в один из параметров. Тип преобразуемых параметров известен, но все остальные параметры могут быть что-либо и, в любом числе: Thunks Adjustor Thunks не будет касаться любого регистра потенциально удерживающего параметр и не будет касаться стека. Это то, что делает функции Adjustor Thunks без подписей.
С помощью компилятора можно автоматически создавать thunks- thunks. Это распространено, например, с несколькими наследованиеми 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
Последовательности быстрого переадресации
Некоторые приложения вносят изменения во время выполнения в функции, находящиеся в двоичных файлах, которые они не имеют, но зависят от двоичных файлов операционной системы , как правило, для отмены выполнения при вызове функции. Это также называется перехватчиком.
На высоком уровне процесс перехвата прост. Однако привязка является архитектурой конкретной и довольно сложной, учитывая потенциальные вариации логики перехватчика, должны решаться.
Как правило, процесс включает в себя следующее:
- Определите адрес функции для перехватчика.
- Замените первую инструкцию функции переходом к подпрограмме перехватчика.
- Когда перехватчик будет выполнен, вернитесь к исходной логике, которая включает выполнение перемещенной исходной инструкции.
Варианты возникают из таких вещей:
- Размер 1-й инструкции: рекомендуется заменить его на JMP, который имеет тот же размер или меньший размер, чтобы избежать замены верхней части функции, а другой поток может выполнять его в полете.
- Тип первой инструкции: Если первая инструкция имеет относительный характер компьютера к нему, перемещение может потребовать изменения таких элементов, как поля смещения. Так как они, вероятно, переполнены при перемещении инструкции в удаленное место, это может потребовать обеспечения эквивалентной логики с разными инструкциями в целом.
Из-за всей сложности, надежной и универсальной логики перехватчика редко найти. Часто логика, представленная в приложениях, может справиться только с ограниченным набором вариантов, которые приложение ожидает столкнуться в конкретных 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
будет содержать адрес GetMachineTypeAttributes
FFS.
Это пример последовательности быстрого переадресации:
kernelbase!EXP+#GetMachineTypeAttributes:
00000001`800034e0 488bc4 mov rax,rsp
00000001`800034e3 48895820 mov qword ptr [rax+20h],rbx
00000001`800034e7 55 push rbp
00000001`800034e8 5d pop rbp
00000001`800034e9 e922032400 jmp 00000001`80243810
Функция FFS x64 имеет канонический пролог и эпилог, заканчивая хвостом вызовом (переходом) к реальной 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
[...]
Это было бы довольно неэффективно, если требуется выполнить 5 эмулированных инструкций 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
содержащий код Arm64EC. Это не происходит сARMASM
. При компиляции кода ASM существует альтернативный способ информирования компоновщика о том, что созданный код является Arm64EC. Это путем префикса имени функции с#
помощью . МакросA64NAME
выполняет эту операцию при_ARM64EC_
определении и оставляет имя без изменений, если_ARM64EC_
оно не определено. Это позволяет совместно использовать исходный код между Arm64 и Arm64EC. pfE
Сначала указатель функции должен выполняться через средство проверки вызова EC вместе с соответствующим выходом из Thunk, если целевая функция — x64.
Создание входных и выходных thunks
Следующий шаг — создать запись Thunk для fD
и exit Thunk для 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) или запись Thunk (если параметр равен 2).
Рекомендуется разместить поколение thunk в собственном файле C. Будучи в изолированных файлах, проще подтвердить имена символов путем дампа соответствующих OBJ
символов или даже дизассемблирования.
Пользовательские блоки записи
Макросы были добавлены в пакет SDK для создания пользовательских, рукописных кодированных элементов, записей. Один из случаев, когда это можно использовать при создании пользовательских Thunks Adjustor.
Большинство thunks-параметров с помощью компилятора 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
В этом примере целевой адрес функции извлекается из элемента структуры, предоставленной ссылкой, через 1-й параметр. Так как структура является записываемой, целевой адрес должен быть проверен с помощью Control Flow Guard (CFG).
В приведенном ниже примере показано, как эквивалентный Thunk 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
Приведенный выше код не предоставляет выход из Thunk (в регистре x10). Это невозможно, так как код можно выполнить для многих разных подписей. Этот код использует преимущества вызывающего объекта, заключив x10 на exit Thunk. Вызывающий объект сделал вызов, предназначенный для явной подписи.
Приведенный выше код требует записи Thunk, чтобы устранить ситуацию, когда вызывающий объект является кодом x64. Вот как создать соответствующую запись Thunk, используя макрос для пользовательских записей Thunks:
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 в конечном итоге не передает управление связанной функции (Thunk Adjustor). В этом случае сама функция (выполнение корректировки параметра) внедряется в элемент управления Entry Thunk и управляется непосредственно в конечный целевой объект через вспомогательный __os_arm64x_x64_jump
объект.
Динамически создаваемый код Arm64EC (JIT-компиляция)
В процессах Arm64EC есть два типа исполняемой памяти: код Arm64EC и код x64.
Операционная система извлекает эти сведения из загруженных двоичных файлов. Двоичные файлы x64 являются x64 и Arm64EC содержат таблицу диапазона для страниц кода Arm64EC и x64.
Что такое динамически созданный код? JIT-компиляторы создают код во время выполнения, который не поддерживается двоичным файлом.
Обычно это означает:
- Выделение записываемой памяти (
VirtualAlloc
). - Создание кода в выделенной памяти.
- Повторное защита памяти от операций чтения и записи на чтение и выполнение (
VirtualProtect
). - Добавьте записи функции очистки для всех нетривиальных (неконечных) созданных функций (
RtlAddFunctionTable
илиRtlAddGrowableFunctionTable
).
По тривиальным причинам совместимости любое приложение, выполняющее эти действия в процессе Arm64EC, приведет к рассмотрению кода x64. Это произойдет для любого процесса с помощью немодифицированной среды выполнения Java, среды выполнения .NET, подсистемы JavaScript и т. д.
Чтобы создать динамический код Arm64EC, процесс в основном совпадает с двумя различиями:
- При выделении памяти используйте более новые
VirtualAlloc2
(вместоVirtualAlloc
илиVirtualAllocEx
) и укажитеMEM_EXTENDED_PARAMETER_EC_CODE
атрибут. - При добавлении записей функций:
- Они должны быть в формате Arm64. При компиляции кода
RUNTIME_FUNCTION
Arm64EC тип будет соответствовать формату x64. Для формата Arm64 при компиляции Arm64EC используйтеARM64_RUNTIME_FUNCTION
вместо него тип. - Не используйте старый
RtlAddFunctionTable
API. Вместо этого всегда используйте более новыйRtlAddGrowableFunctionTable
API.
- Они должны быть в формате 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