Ранее в сериале:
Стек
Как я уже говорил, стек — это небольшая предвыделенная область памяти, которую программа получает при запуске. Поскольку, если мы размещаем переменную в стеке, нам не нужно обращаться к операционной системе для получения блока памяти, то это очень быстрая операция.
В классическом ассемблере для того, чтобы сохранить и извлечь регистры из стека, используются команды PUSH и POP, при этом к указателю на первый свободный байт стека добавляется число, равное размеру данных, сохранённых в стеке.
Таким образом адрес памяти, который хранится в регистре-указателе SP (на начало свободной области стека) всегда больше адресов ячеек, уже сохранённых в стеке.
Но мы можем получить доступ к переменным, уже сохранённым в стеке, или, как говорят, «на стеке», вычитая некоторое число или используя отрицательное смещение (напоминает «отрицательный рост», как иногда говорят чиновники).
Стек в 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 бит |
|
|
|
|
|
Загрузка адреса |
|
|
ARM использует |
|
|
Обнуление |
|
|
Или |
|
Арифметика |
Сложение |
|
|
В ARM результат пишется в последний регистр (R0 = R0 + R1) |
|
|
Вычитание |
|
|
|
|
|
Умножение |
|
|
В amd64 есть однооператорная форма умножения и современная из набора BMI2 форма с произвольными выходными регистрами |
|
Логика |
И / ИЛИ |
|
|
В ARM |
|
|
Исключающее ИЛИ |
|
|
|
|
|
Инверсия бит (Not) |
|
|
MVN = Move Not (пересылка с инверсией) |
|
Сдвиги |
Влево |
|
|
Logical Shift Left |
|
|
Вправо (без знака) |
|
|
Logical Shift Right |
|
|
Вправо (со знаком) |
|
|
Arithmetic Shift Right |
|
|
Вращение влево |
|
|
В ARM нет ROL, используется ROR |
Суффиксы в arm64
В Go-ассемблере для ARM64 мы не используем суффикс Q (как в x86) для обозначения 64-битных операций, а для 32-битных используем суффикс W.
Регистры R0...R30 (или R) — это 64-битные регистры. Во всех командах мы используем их. Если хотим выполнить 64-битную операцию, то суффикс не нужен.
Такая в Go-ассемблере особенность: он использует имена R0-R30 для всех случаев, а компилятор решает, какую инструкцию сгенерировать, основываясь на суффиксе команды.
Флаги в arm64
Обычные инструкции (ADD, SUB) не меняют флаги (Zero, Negative, Carry, Overflow). Если вы хотите использовать результат операции для условного перехода или арифметики с переносом, вы должны добавить суффикс S.
Обзор различий Go-ассемблера для ARM и ARM64
1. Синтаксис инструкций
|
Архитектура |
Пример инструкции |
Особенности |
|---|---|---|
|
ARM (32-bit) |
|
Без суффиксов, результат — последний аргумент |
|
ARM64 |
|
Суффикс |
В 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 ООО «МТ ФИНАНС»
Автор: inetstar
