Общие сведения об 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.

Это небольшие изменения при просмотре в перспективе того, сколько всего 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 заботятся о переводе соглашения о вызовах 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 BArm64EC, не экспортируется, 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 будет содержать адрес GetMachineTypeAttributesFFS.

Это пример последовательности быстрого переадресации:

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.

Ниже приведен пример выделения памяти:

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