golang

Ассемблер для гоферов. Стек. Особенности amd64, arm64 и arm. Часть 3

  • четверг, 25 декабря 2025 г. в 00:00:10
https://habr.com/ru/companies/ruvds/articles/979326/
Go-ассемблер поддерживает разные платформы
Go-ассемблер поддерживает разные платформы

Ранее в сериале:

Стек

Как я уже говорил, стек — это небольшая предвыделенная область памяти, которую программа получает при запуске. Поскольку, если мы размещаем переменную в стеке, нам не нужно обращаться к операционной системе для получения блока памяти, то это очень быстрая операция.

В классическом ассемблере для того, чтобы сохранить и извлечь регистры из стека, используются команды PUSH и POP, при этом к указателю на первый свободный байт стека добавляется число, равное размеру данных, сохранённых в стеке.

Таким образом адрес памяти начала стека всегда больше адресов ячеек, уже сохранённых в стеке.

Но мы можем получить доступ к переменным, уже сохранённым в стеке, или, как говорят, «на стеке», вычитая некоторое число или используя отрицательное смещение (напоминает «отрицательный рост», как иногда говорят чиновники).

Стек в Go

Поскольку при создании ассемблерной функции мы заранее знаем, какого размера стек нам нужен, то нам не нужно использовать команды PUSH и POP.

В Go самописная ассемблерная функция обычно получает при компиляции чётко выделенную область на стеке.

Вот заголовок функции, которую мы написали в прошлой части. Она использовала стек нулевого размера (число в заголовке после знака доллара).

TEXT ·Mul(SB), NOSPLIT|NOFRAME, $0-24

NOSPLIT означает, что стек не будет увеличиваться, и компилятор Go не должен внутри функции никак отслеживать размер стека и увеличивать его при необходимости. Если нужно, Go увеличивает размер стека через так называемый сплиттинг, когда создаётся стек в 2 раза большего размера и старый стек копируется в новый.

Директива NOSPLIT улучшает производительность, если вам заранее известен размер вашего стека. Она заставляет компилятор Go не вставлять в ваши ассемблерные функции специальный код, который проверяет достаточность размера стека.

NOFRAME — значит, что стек нам вообще не нужен. NOFRAME и NOSPLIT — это битовые флаги, поэтому они соединяются через логическое «ИЛИ». В реальности это тоже значения, которые подставляются из служебных макросов. Они являются степенями двойки типа 1, 2, 4, 8 и так далее, поэтому не пересекаются в позициях единичных битов.

Предположим, нам потребовалось хранить в качестве локальных переменных функции 2 массива:

var x [16]uint32 
var s [5]uint32

Первая переменная занимает 64 байта, вторая — 20. Но поскольку рекомендуется выравнивать стек кратно 16 байтам (это улучшает производительность), то нам понадобится стек величиной 96 байт.

В этом случае объявление нашей функции (назовём её FastHash) будет выглядеть так:

TEXT ·FastHash(SB), NOSPLIT, $96-16

16, напомню, означает общий размер входящих параметров функции. А SB — это виртуальный регистр, с помощью которого мы можем адресовать глобальные имена. FastHash(SB) означает, что относительно указателя на SB по смещению FastHash мы располагаем функцию. Потом линкер присвоит точное значение смещению FastHash, и при вызове FastHash начнут исполняться команды по смещению FastHash.

Эта функция будет получать указатели на input и output-области памяти. Каждый по 8 байт (на 64-битных архитектурах). Итого 16 байт.

Обращение к переменным на стеке

Несмотря на то, что в архитектурах процессоров предусмотрены аппаратные регистры для хранения указателя на стек, мы не будем их использовать. Мы всегда и на всех архитектурах будем использовать виртуальный регистр SP для максимальной портируемости нашего ассемблерного кода. ВСЕГДА в Go-ассемблере предпочтительнее использовать виртуальный регистр.

Для amd64 возможна путаница виртуального регистра SP с реальным регистром SP, хранящим указатель на стек.

Поэтому мы будем использовать название переменной при адресации к стеку (оно не играет никакой роли — только для того, чтобы виртуальный SP отличать от реального).

x-8(SP) — обращение к ячейке через виртуальный регистр. x не играет никакой роли.
-8(SP) — обращение к ячейке через реальный регистр SP.

Для обращения к переменным на стеке очень удобно использовать макросы.

#define X_BASE x-96
#define S_BASE s-32

Теперь для того, чтобы загрузить s[0] в регистр R8, s[1] в регистр R9 мы можем использовать:

MOVQ S_BASE(SP), R8
MOVQ S_BASE+4(SP), R9 // наши переменные по 32 бита, поэтому мы используем +4 байта для получения адреса следующей ячейки массива.

Мы можем пойти дальше и вместо ручного вычисления смещений для переменных в стеке использовать макросы для конкретных элементов, если их немного.

#define S0 S_BASE+0
#define S1 S_BASE+4
#define S2 S_BASE+8
#define S3 S_BASE+12
#define S4 S_BASE+16

Потом мы можем обращаться к переменным так:

MOVQ S0(SP), R8
MOVQ S1(SP), R9 

Это несравненно удобнее и идиоматичнее для Go-ассемблера.

Особенности amd64, arm64 и arm

Проще всего программировать на ассемблере для arm64. И вот почему:

  • Большое число регистров общего назначения (32 против 16).

  • Команды для загрузки/выгрузки двух регистров сразу.

  • Условные команды без ветвлений (например, прибавить к регистру 1, если другой регистр больше нуля).

Но если мы будем писать код для архитектуры amd64, то его довольно просто портировать на arm64, так как на arm64 больше регистров. И вполне можно переделать для arm, хотя там доступных регистров чуть меньше.

Доступные для ассемблер-программиста регистры на AMD64 (15 штук):

AX, BX, CX, DX, SI, DI, R8, R9, R10, R11, R12, R13, R14, R15, BP

Доступные регистры на arm64 (27 штук):

R0-R17, R19-R27

И ещё есть специальный ZR — Zero register, всегда содержащий ноль. Очень удобно для обнуления памяти и других регистров.

Доступные регистры на arm (12 штук):

R0-R9, R11-R12

Доступных регистров на arm меньше, чем на amd64, поэтому чаще приходится использовать стек. И сами регистры 32-битные. Поэтому необходимость портирования сомнительна, однако некоторые люди ещё используют такие устройства.

Особенности arm64 и arm

Это Load-Store архитектуры. Что означает, что для операций с данными они должны быть загружены в регистры. Нельзя как в amd64 сложить ячейку памяти с регистром:

// Берём ячейку памяти по смещению 8 от адреса памяти из SI и прибавляем к R8
ADDQ 8(SI), R8;

В arm64 можно сложить только регистр с регистром, а потом записать данные в память. Поначалу это менее удобно, но идеологически такая архитектура проще и понятнее. И в том числе и поэтому архитектурам типа arm64 нужно много регистров.

Загрузка/выгрузка данных в amd64

Когда нужно скопировать 16 байт сразу в стек из памяти, мы можем использовать XMM-регистры:

// Загружаем параметры через виртуальный регистр FP
MOVQ input+0(FP), SI  // DI = указатель на input
MOVQ out+8(FP), DI    // SI = указатель на out

// Копируем input[0:32] в x[0:32], используя XMM-регистры
MOVOU (SI), X0
MOVOU 16(SI), X1
MOVOU X0, X_BASE(SP)
MOVOU X1, X_BASE+16(SP)

Загрузка/выгрузка данных в arm64

// Загружаем параметры
MOVD input+0(FP), R0  // R0 = указатель на input
MOVD out+8(FP), R1    // R1 = указатель на out

// Копируем input[0:32] в x[0:32] используя LDP/STP (аналог XMM в AMD64)
LDP (R0), (R2, R3)      // Загружаем первые 16 байт
LDP 16(R0), (R4, R5)    // Загружаем следующие 16 байт
STP (R2, R3), X_BASE(SP)  // Сохраняем первые 16 байт на стек (x[0..3])
STP (R4, R5), X_BASE+16(SP)  // Сохраняем следующие 16 байт (x[4..7])

Или мы можем использовать NEON-регистры. Они в Go-ассемблере обозначаются как V. Окончание .B16 говорит о том, что мы их рассматриваем как 16-байтовые. Скорее всего, будет быстрее, так как меньше обращений к памяти:

// Копируем input[0:32] в x[0:32] используя NEON SIMD (VLD1/VST1)
// Вычисляем адрес X_BASE(SP) в регистре R11
MOVD $X_BASE(SP), R11  // теперь R11 = адрес X_BASE на стеке
VLD1 (R0), [V0.B16, V1.B16]  // Загрузка 32 байт в два полных 128-битных регистра
VST1 [V0.B16, V1.B16], (R11)  // Сохранение 32 байт на стек

Загрузка/выгрузка данных в arm

// Параметры:
MOVW input+0(FP), R0  // R0 = указатель на input
MOVW out+4(FP), R1    // R1 = указатель на out
	
//  Копируем input[0:32] в x[0:32] (8 операций по 4 байта)
MOVW (R0), R2         // байты 0-3
MOVW R2, X_BASE+0(SP)
MOVW 4(R0), R2        // байты 4-7
MOVW R2, X_BASE+4(SP)
MOVW 8(R0), R2        // байты 8-11
MOVW R2, X_BASE+8(SP)
MOVW 12(R0), R2       // байты 12-15
MOVW R2, X_BASE+12(SP)
MOVW 16(R0), R2       // байты 16-19
MOVW R2, X_BASE+16(SP)
MOVW 20(R0), R2       // байты 20-23
MOVW R2, X_BASE+20(SP)
MOVW 24(R0), R2       // байты 24-27
MOVW R2, X_BASE+24(SP)
MOVW 28(R0), R2       // байты 28-31
MOVW R2, X_BASE+28(SP)

Но это долго. Мы можем воспользоваться специальными оптимизированными инструкциями arm для массовой загрузки из памяти:

MOVW $X_BASE(SP), R11  // Загружаем адрес с $ (он позволяет взять именно адрес, а не то, что лежит по адресу)
MOVM.IA (R0), [R2-R9]  // Загружаем 8 слов (32 байта) из input
MOVM.IA [R2-R9], (R11)  // Сохраняем 8 слов (32 байта) в стек

.IA в MOVM.IA означает "Increment After" — режим адресации для инструкций Load/Store Multiple (LDM/STM).

Режимы адресации для MOVM (LDM/STM):

  • .IA (Increment After) — адрес увеличивается после загрузки/сохранения каждого регистра.

  • .IB (Increment Before) — адрес увеличивается перед загрузкой/сохранением каждого регистра.

  • .DA (Decrement After) — адрес уменьшается после загрузки/сохранения каждого регистра.

  • .DB (Decrement Before) — адрес уменьшается перед загрузкой/сохранением каждого регистра.

Если добавить в конец MOVM.IA модификатор .W (например, MOVM.IA.W), базовый регистр автоматически увеличится после операции на число считанных байт:

// Загружает регистры И увеличивает R0 на 16 (4 регистра × 4 байта)
MOVM.IA.W (R0), [R2-R5]

В нашем коде используется MOVM.IA без .W, поэтому базовый регистр (R0 или R11) не изменяется автоматически.

Различия в синтаксисе Go-ассемблера для AMD64 и ARM64

Группа

Функция

AMD64 (x86-64)

ARM64 (AArch64)

Примечание

Пересылка

Загрузка 64 бит

MOVQ (AX), CX

MOVD (R0), R1

Загрузка адреса

LEAQ var(SB), AX

MOVD $var(SB), R0

ARM использует MOVD с $ для адресов

Обнуление

XORQ AX, AX

MOVD ZR, R0

Или EOR R0, R0, R0 (редко)

Арифметика

Сложение

ADDQ BX, AX

ADD R1, R0

В ARM результат пишется в последний регистр (R0 = R0 + R1)

Вычитание

SUBQ BX, AX

SUB R1, R0

Умножение

MULQ x или MULXQ x, low, hi;

MUL x, y, low; UMULH x0 y, hi;

В amd64 есть однооператорная форма умножения и современная из набора BMI2 форма с произвольными выходными регистрами

Логика

И / ИЛИ

ANDQ, ORQ

AND, ORR

В ARM OR пишется как ORR

Исключающее ИЛИ

XORQ

EOR

XOR в x86 = EOR в ARM

Инверсия бит (Not)

NOTQ AX

MVN R0, R0

MVN = Move Not (пересылка с инверсией)

Сдвиги

Влево

SHLQ $2, AX

LSL $2, R0

Logical Shift Left

Вправо (без знака)

SHRQ $2, AX

LSR $2, R0

Logical Shift Right

Вправо (со знаком)

SARQ $2, AX

ASR $2, R0

Arithmetic Shift Right

Вращение влево

ROLQ $n, AX

ROR $(64-n), R0

В ARM нет ROL, используется ROR

Суффиксы в arm64

В Go-ассемблере для ARM64 мы не используем суффикс Q (как в x86) для обозначения 64-битных операций и не используем суффикс L для 32-битных.

В архитектуре ARM64 размер операндов определяется именами регистров, а не суффиксом инструкции.

Регистры R0...R30 (или R) — это 64-битные регистры. Если вы используете их в команде, операция автоматически считается 64-битной.

Для обращения к младшей, 32-битной части регистров, надлежит использовать команды с суффиксом W.

В Go-ассемблере, однако, есть особенность: он использует имена R0-R30 для всех случаев, но компилятор решает, какую инструкцию сгенерировать, основываясь на суффиксе команды.

Флаги в arm64

Обычные инструкции (ADD, SUB) не меняют флаги (Zero, Negative, Carry, Overflow). Если вы хотите использовать результат операции для условного перехода или арифметики с переносом, вы должны добавить суффикс S.

Обзор различий Go-ассемблера для ARM и ARM64

1. Синтаксис инструкций

Архитектура

Пример инструкции

Особенности

ARM (32-bit)

EOR c, alpha1, alpha1

Без суффиксов, результат — последний аргумент

ARM64

EORW c, alpha1, alpha1

Суффикс W для 32-битных операций

В ARM64 также доступны 64-битные версии инструкций без суффикса W (например, EOR вместо EORW). Но лучше всегда явно указывать суффикс Q для 64-битных регистров.

2. Операция ROL (rotate left)

// ARM (32-bit)
MOVW reg@>(32-shift), reg  // Использует оператор @>

// ARM64
RORW $(32-shift), reg, reg  // Использует инструкцию RORW

3. Копирование данных

Смотри раздел выше о загрузке/выгрузке.

4. Ограничения регистров

ARM (32-bit):

  • R10 — зарезервирован для goroutine (g) ❌ нельзя использовать

  • R11 — часто используется линкером ⚠️ лучше избегать

  • R13 = SP, R14 = LR, R15 = PC ❌ нельзя использовать

ARM64:

  • Больше доступных регистров (R0-R30)

  • R28 (g) — указатель на goroutine ❌ нельзя использовать

  • R29 (FP) — frame pointer

  • R30 (LR) — link register

  • R31 (SP/ZR) — stack pointer

  • Меньше строгих ограничений на остальные регистры

5. Адресация памяти

ARM и ARM64 используют одинаковый синтаксис, но в ARM64 указатели 64-битные

Тестирование ассемблерного кода arm и arm64

Нам нужно будет поставить пакет qemu с опциями для поддержки arm64 и arm. В нём есть qemu-arm и qemu-aarch64, которые позволят нам запускать под Linux бинарники этих архитектур.

Нам нужно будет скомпилировать тесты в отдельный файл, а потом запускать нужный тест.

CGO_ENABLED=0 GOARCH=arm64 GOOS=linux go test -tags=asm -c -o test_arm64

Теперь наш скомпилированный тест лежит в файле test_arm64. Запустим его:

qemu-aarch64 ./test_arm64 -test.run=TestFastHashCorrectness -test.v

Таким образом можно вести разработку под все архитектуры, поддерживаемые Go, на одной машине. Очень удобно!

Итоги

Ассемблер в Go весьма похож между разными архитектурами. За счёт использования виртуальных регистров для входных аргументов (FP) и стека (SP) мы можем с минимальными усилиями портировать код с amd64 на arm64, а с немного большими — и на arm.

Качественно написанный ассемблерный код способен дать ускорение в десятки процентов (~20%) против хорошо оптимизированного Go-кода. Или ускорить в 2 и более раз наивный Go-код.

Разработчики Go говорят, что они пока не внедряют интринсики, так как, по их мнению, это полумера, и если нужен по-настоящему быстрый код, предлагают писать его на ассемблере.

Я был этим очень недоволен, но когда попробовал писать код на ассемблере, то понял, что это не так уж и сложно.

Так что пробуем! Не боги горшки обжигают!

© 2025 ООО «МТ ФИНАНС»