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

Ранее в сериале:
Как я уже говорил, стек — это небольшая предвыделенная область памяти, которую программа получает при запуске. Поскольку, если мы размещаем переменную в стеке, нам не нужно обращаться к операционной системе для получения блока памяти, то это очень быстрая операция.
В классическом ассемблере для того, чтобы сохранить и извлечь регистры из стека, используются команды PUSH и POP, при этом к указателю на первый свободный байт стека добавляется число, равное размеру данных, сохранённых в стеке.
Таким образом адрес памяти начала стека всегда больше адресов ячеек, уже сохранённых в стеке.
Но мы можем получить доступ к переменным, уже сохранённым в стеке, или, как говорят, «на стеке», вычитая некоторое число или используя отрицательное смещение (напоминает «отрицательный рост», как иногда говорят чиновники).
Поскольку при создании ассемблерной функции мы заранее знаем, какого размера стек нам нужен, то нам не нужно использовать команды 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-ассемблера.
Проще всего программировать на ассемблере для 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-битные. Поэтому необходимость портирования сомнительна, однако некоторые люди ещё используют такие устройства.
Это Load-Store архитектуры. Что означает, что для операций с данными они должны быть загружены в регистры. Нельзя как в amd64 сложить ячейку памяти с регистром:
// Берём ячейку памяти по смещению 8 от адреса памяти из SI и прибавляем к R8
ADDQ 8(SI), R8;
В arm64 можно сложить только регистр с регистром, а потом записать данные в память. Поначалу это менее удобно, но идеологически такая архитектура проще и понятнее. И в том числе и поэтому архитектурам типа arm64 нужно много регистров.
Когда нужно скопировать 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)
// Загружаем параметры
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 байт на стек
// Параметры:
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) не изменяется автоматически.
Группа | Функция | 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 |
В Go-ассемблере для ARM64 мы не используем суффикс Q (как в x86) для обозначения 64-битных операций и не используем суффикс L для 32-битных.
В архитектуре ARM64 размер операндов определяется именами регистров, а не суффиксом инструкции.
Регистры R0...R30 (или R) — это 64-битные регистры. Если вы используете их в команде, операция автоматически считается 64-битной.
Для обращения к младшей, 32-битной части регистров, надлежит использовать команды с суффиксом W.
В Go-ассемблере, однако, есть особенность: он использует имена R0-R30 для всех случаев, но компилятор решает, какую инструкцию сгенерировать, основываясь на суффиксе команды.
Обычные инструкции (ADD, SUB) не меняют флаги (Zero, Negative, Carry, Overflow). Если вы хотите использовать результат операции для условного перехода или арифметики с переносом, вы должны добавить суффикс S.
Архитектура | Пример инструкции | Особенности |
|---|---|---|
ARM (32-bit) |
| Без суффиксов, результат — последний аргумент |
ARM64 |
| Суффикс |
В ARM64 также доступны 64-битные версии инструкций без суффикса W (например, EOR вместо EORW). Но лучше всегда явно указывать суффикс Q для 64-битных регистров.
// ARM (32-bit)
MOVW reg@>(32-shift), reg // Использует оператор @>
// ARM64
RORW $(32-shift), reg, reg // Использует инструкцию RORW
Смотри раздел выше о загрузке/выгрузке.
R10 — зарезервирован для goroutine (g) ❌ нельзя использовать
R11 — часто используется линкером ⚠️ лучше избегать
R13 = SP, R14 = LR, R15 = PC ❌ нельзя использовать
Больше доступных регистров (R0-R30)
R28 (g) — указатель на goroutine ❌ нельзя использовать
R29 (FP) — frame pointer
R30 (LR) — link register
R31 (SP/ZR) — stack pointer
Меньше строгих ограничений на остальные регистры
ARM и ARM64 используют одинаковый синтаксис, но в ARM64 указатели 64-битные
Нам нужно будет поставить пакет 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 ООО «МТ ФИНАНС»