
В этой статье я постараюсь дать максимально простое введение в Го-ассемблер — зачем и когда он может понадобиться, а также мы начнём делать функцию умножения для 256-битных чисел, а в следующей части её закончим.
Когда нужен Го-ассемблер
В 99% случаев Го-ассемблер вам не нужен. Компилятор Го, если избежать ненужных аллокаций и применить некоторые оптимизационные техники, даёт очень достойные результаты. Подробности по оптимизации быстродействия Го-кода в предыдущей статье «Выжимаем из Go скорость до последних наносекунд».
Но если у вас проект связан с вычислениями, криптографией и по каким-то причинам вы его всё-таки делаете на Го, то тут уже шансы увеличиваются.
Есть очевидное узкое место
В своём проекте, когда я в профайлере обнаружил, что после проведения оптимизаций одна из функций проекта выполняется ~50% времени, я понял нужно рассмотреть возможность ассемблерной реализации.
Сама оптимизируемая функция достаточно мала
Специфика ассемблера на AMD64-архитектуре заключается в том, что регистров очень мало — всего 16 регистров общего назначения. Это связано с историей: архитектура разрабатывалась AMD в спешке, им нужно было опередить Intel, чтобы именно их решение было принято Microsoft как стандартное. В итоге первая 64-битная Windows была скомпилирована именно под AMD64, что и задало индустриальный стандарт.
Из-за малого числа регистров мы не сможем написать сколько-нибудь серьёзную функцию так, чтобы она выполнялась исключительно в регистрах, без обращения к памяти. Если же придётся писать ассемблерную функцию с кучей обращений к памяти, мы растеряем преимущество ассемблера, потому что каждое обращение может занимать вплоть до 100 наносекунд — в зависимости от того, попали ли данные в L1-кэш, L2, L3 или вообще не попали.
Оптимальные ассемблерные функции — те, которые оперируют только регистрами. Работа с памятью нужна только в двух случаях: загрузка входных данных и выгрузка результата. Только при таком подходе можно рассчитывать на существенный выигрыш, поскольку современные компиляторы крайне умны и выполняют свою работу в миллиарды раз быстрее человеческого мозга. Конкурировать с компилятором при необходимости многочисленных обращений к памяти из ассемблера — бессмысленно.
Ассемблер как игра в пятнашки

На что похожа работа с ассемблером? Честно говоря, на игру в пятнашки. У нас есть всего 15 свободных регистров общего назначения, которыми мы можем относительно свободно пользоваться. Наименования регистров здесь и далее даются по схеме, принятой в Go-ассемблере.
AX, BX, CX, DX, SI, DI, R8, R9, R10, R11, R12, R13, R14, R15, BP
Вообще-то, их 16, если включить в список регистр SP, но его нельзя менять вручную, так как он хранит указатель на стек. Нужно, не выходя за эти пределы, решить вычислительную задачу — это очень серьёзные ограничения.
Для архитектуры arm64, как я уже говорил, дела обстоят существенно лучше. Нам доступны:
R0-R17 (18 регистров), R19-R26 (8 регистров — общего назначения)
Остальные зарезервированы, и для их использования нужно уже серьёзно вникать в тонкости ОС и компилятора.
Что такое регистр
Регистр — фактически это uint64. Однако в зависимости от инструкции его значение может интерпретироваться совершенно по-разному: как знаковое или беззнаковое целое, как uint32 или int32, как число с плавающей точкой или как адрес памяти.
Caller-saved VS callee-saved
Для того, чтобы не было хаоса и путаницы в разных операционных системах и языках программирования, есть разные соглашения о том, какими регистрами может свободно пользоваться ваша ассемблерная функция.
Итак, Caller-saved регистры — это регистры, которые должна сохранять вызывающая функция перед вызовом другой функции, вызываемая функция считает их свободными.
Caller-saved — сохраняемые вызывающим.
Callee-saved регистры — регистры, которые должна сохранять вызываемая функция для того, чтобы использовать. Так как вызывающая функция может в них держать свои переменные.
СЮРПРИЗ: в Go нет callee-saved регистров.
There are no callee-save registers, so a call may overwrite any register that doesn’t have a fixed meaning, including argument registers.
В Go ABI (Go 1.17+ Register-based ABI) для amd64 нет понятия callee-saved регистров в классическом смысле.
Это здорово упрощает написание ассемблерных программ. Мы можем считать все регистры свободными и не переживать, что что-то испортим.
Но в то же время нужно понимать, что если мы сами вызовем некую функцию из ассемблерного кода, то она ничтоже сумняся может перезаписать все регистры, которые вы использовали для хранения важных переменных.
Флаги
Флаги в процессорах с архитектурой amd64 — это специальные биты в регистре RFLAGS, которые устанавливаются в результате выполнения логических и арифметических операций.
Скорее всего, вам не потребуется обращаться к этому регистру в ассемблерном коде, так как есть специальные команды, которые тестируют или используют отдельно самые важные флаги.
Флаги — это «нервная система» процессора для управления потоком выполнения. Понимание их работы абсолютно необходимо для написания эффективного ассемблерного кода на amd64.
Статусные флаги
Изменяются арифметическими и логическими операциями. Самые важные для программиста:
Флаг |
Биты |
Название |
Описание |
|---|---|---|---|
CF |
0 |
Carry Flag (Флаг переноса) |
Устанавливается в 1, если произошёл перенос из/заём в старший бит при сложении/вычитании. Критичен для работы с беззнаковыми числами. |
ZF |
6 |
Zero Flag (Флаг нуля) |
Самый важный! Устанавливается в 1, если результат операции равен нулю. |
SF |
7 |
Sign Flag (Флаг знака) |
Равен значению старшего бита результата (1 для отрицательных чисел при знаковой интерпретации). |
OF |
11 |
Overflow Flag (Флаг переполнения) |
1, если произошло переполнение знакового числа. |
Условные переходы на основе флагов
Это самое важное применение флагов!
В Go ассемблере используются такие инструкции
Для беззнаковых сравнений (unsigned):
JA/JNBE— Jump if Above (CF=0 and ZF=0)JAE/JNB/JNC— Jump if Above or Equal (CF=0)JB/JNAE/JC— Jump if Below (CF=1)JBE/JNA— Jump if Below or Equal (CF=1 or ZF=1)
Для знаковых сравнений (signed):
JG/JNLE— Jump if Greater (ZF=0 and SF=OF)JGE/JNL— Jump if Greater or Equal (SF=OF)JL/JNGE— Jump if Less (SF≠OF)JLE/JNG— Jump if Less or Equal (ZF=1 or SF≠OF)
Общие переходы:
JE/JZ— Jump if Equal / Zero (ZF=1)JNE/JNZ— Jump if Not Equal / Not Zero (ZF=0)JS— Jump if Sign (SF=1)JNS— Jump if Not Sign (SF=0)JO— Jump if Overflow (OF=1)JNO— Jump if Not Overflow (OF=0)JC/JB— Jump if Carry / Below (CF=1)JNC/JNB— Jump if No Carry / Not Below (CF=0)
Особенности в Go
Начальное состояние флагов неизвестно — Вы не можете полагаться в своей функции, что все флаги обнулены. Вам придётся сделать это самому.
Сохранение флагов — При вызове других функций ваши флаги могут быть изменены. Если ваша функция зависит от конкретных значений флагов, сохраняйте их.
-
Неявное изменение флагов — Многие инструкции изменяют флаги неявно:
ADD, ADC, SUB, SBC, MUL, DIV — изменяют CF, OF, ZF, SF, PF, AF AND, OR, XOR, NOT — изменяют SF, ZF, PF (CF=0, OF=0) SHL, SHR, SAR — сдвиги изменяют CF
Стек
Это предвыделенная небольшая область памяти для ��рограммы, куда можно временно сохранять данные, а потом восстанавливать. В исполнимом файле компилятором указывается, какого размера стек нужен программе.
О стеке и производительности
Поскольку регистров не хватает, программисты на ассемблере часто используют стек. Но я считаю: если вам приходится часто использовать стек — ассемблер вам уже не нужен. Это работа с памятью, и вы начинаете конкурировать с компилятором. А производительность труда ассемблерного программиста — от 1 до 10 отлаженных операторов в день. Именно отлаженных, вылизанных до идеала.
XMM-регистры как запасное хранилище
Как обеспечить минимальное обращение к памяти? В AMD64 практически с первых процессоров поддерживаются 16 XMM-регистров (XMM0–XMM15) — 128-битные, но к ним можно обращаться и как к 64-битным. Значения, которые сейчас не нужны в регистрах общего назначения, можно временно сбрасывать в XMM-регистры, а потом загружать обратно. Как правило, это значительно быстрее, чем работа с памятью и даже с L1-кэшем.
Уровень |
Латентность (наносекунды) |
|---|---|
L1 |
~0.5–1 нс |
L2 |
~3–4 нс |
L3 |
~10–15 нс |
DRAM |
~60–120 нс |
Да, если значение в L1-кэше, то это может давать значительный выигрыш, такой что разница между записью в XMM-регистр и память будет незаметна. Но кэш — вещь непредсказуемая: вашего значения в нём может не оказаться (например, после переключения контекста). А пересылка из регистра в регистр — всегда быстро. Поэтому я предпочитаю временные данные сбрасывать именно в XMM-регистры, если по логике ассемблерного кода они не используются для других целей.
Передача аргументов
Удобнее всего работать с ассемблерными функциями, когда они не возвращают значений напрямую. Иными словами, когда функция получает указатели на аргументы и на результат. Это наиболее просто и наиболее быстро.
Необходимый минимум инструкций
MOV — пересылка данных: регистр ↔ регистр, регистр ↔ память. По смыслу это
COPY, но по историческим причинам называется от слова «move».JMP — переход по адресу.
JNE — переход, если значения не равны (ZF == 0).
JA — переход, если одно значение больше другого.
JС — переход по наличию флага переноса CF.
ADD, ADC, SUB, SBC — сложение и вычитание.
CMP / TEST — сравнение (равен ли регистр нулю, установлен ли флаг переноса и т. д.)
PUSH / POP — запись и чтение из стека.
RET — выход из ассемблерной функции.
У команды MOV есть постфиксы:
Q, если мы копируем 64-битное слово.D, если мы записываем в XMM-регистр.другие, если мы копируем 32 или 8 бит.
Особенности и ограничения Го-ассемблера
Синтаксис на основе ассемблера Plan 9
В Го-ассемблере используется нестандартный синтаксис, основанный на ассемблере Plan 9. Ключевое отличие: результат записывается в последний операнд, тогда как в Intel-синтаксисе — в первый.
MOVQ x+0(FP), AX // первый входной аргумент (переменная x) записываем в регистр AX
Код на Го-ассемблере компилируется компилятором Go. Его нельзя написать на обычном ассемблере (NASM) и слинковать. Технически это возможно через cgo, но накладные расходы на вызовы cgo сведут на нет весь смысл оптимизации.
Нельзя написать метод
Сейчас в Go часто используются структуры с набором методов, которые работают с этими структурами. Однако на Go-ассемблере нельзя написать метод для структуры. Разработчики Go объясняют это тем, что ABI методов (internal representation) может меняться между версиями. Скрывая эту возможность, они обеспечивают совместимость ассемблерного кода с разными версиями Go.
Рабочие решения:
Использовать функцию, принимающую указатели на нужные структуры.
Использовать метод как обёртку для ассемблерной функции.
У второго подхода есть минус: Go не умеет инлайнить ассемблерный код, поэтому придётся потратить лишнюю инструкцию CALL — примерно +1 наносекунда на каждый вызов. С этим можно смириться или переписать все оптимизированные методы на функции — в зависимости от требований к быстродействию.
//go:build asm
package bint
type Bint [4]uint64
type BintMulRes [8]uint64
// MulAsm — экспортируемая ассемблерная функция для умножения
// Следующая директива подсказывает компилятору Go, что внутри функции память не выделяется
//go:noescape
func MulAsm(z *BintMulRes, x *Bint, y *Bint)
// Метод Mul вызывает ассемблерную функцию
// Это стандартный подход для методов в Go-ассемблере.
// Возврат указателя добавляет от 0.1-2.15нс (скорее второе)
func (z *BintMulRes) Mul(x, y *Bint) {
MulAsm(z, x, y)
//return z
}
Ассемблерный код мы размещаем в файле с постфиксом _amd64.s или _arm64.s в зависимости от поддерживаемой архитектуры.
TEXT ·MulAsm(SB), NOSPLIT|NOFRAME, $0-24
// 0 байт: не используем стек вообще
// 24 байта: три указателя, каждый по 8 байт
// NOSPLIT — запрещает разделение стека (stack split), это сильно убыстряет функцию
// NOFRAME — не создавать стековый фрейм
// Не сохраняет/восстанавливает указатель фрейма (BP/FP)
// Убыстряет и экономит инструкции пролога/эпилога
// Загружаем параметры
// Для метода: первый параметр — receiver (z), затем x, y
MOVQ z+0(FP), CX // CX = z (receiver, указатель на результат)
MOVQ x+8(FP), AX // AX = x (указатель на x)
MOVQ y+16(FP), BX // BX = y (указатель на y)
// Супермакрос, который принимает все свободные регистры (подробнее во второй части)
KMUL_MEGA1(AX, BX, CX, DX, SI, DI, R8, R9, R10, R11, R12, R13, R14, R15, BP)
RET
Мультиплатформенность
Го-ассемблер мультиплатформенный: есть версии для AMD64, ARM64, MIPS и других архитектур. Придётся писать ассемблерные функции для каждой платформы отдельно — это совершенно разные миры.
Например, в ARM64 имеется 32 регистра общего назначения, что позволяет существенно эффективнее использовать ассемблер. Код, написанный под AMD64, можно «переделать» под ARM64, но тогда вы не используете полностью возможности ARM-процессоров. Поэтому имеет смысл писать отдельные реализации для архитектур, которые настолько сильно отличаются.
GOAMD64 и поколения инструкций
Несмотря на то, что при компиляции можно указать поколение инструкций через переменную GOAMD64 (значения v1, v2, v3, v4), это не гарантирует использование современных инструкций. Например, при написании функций с целочисленным умножением Go-компилятор не использовал современные инструкции по умолчанию, пока архитектура не была указана явно.
Инструменты автоматизации и упрощения написания кода на GO-ассемблере
Препроцессор и макросы
В Go-ассемблере есть Си-совместимый препроцессор, а это значит, поддерживаются макросы. Для тех, кто имел серьёзный опыт работы с Си или с C++, возможно, других инструментов не понадобится. Но нужно сказать, что в сложных случаях макросы могут дать нагрузку на мозг больше, чем хотелось бы, и могут быть сложны в отладке.
Инструменты AVO и PeachPy
Чтобы снизить головоломочность программирования на ассемблере были разработаны фреймворки AVO (Go-фреймворк) и PeachPy (Python-фреймворк).
AVO позволяет писать ассемблерный код на Го и красиво разворачивать циклы. И он создан именно для Go.
PeachPy более универсален с похожим функционалом, но считается устаревшим. Возможно, мы рассмотрим эти инструменты в отдельной статье.
Использование макросов
Создаём свой инструментарий для работы
Поскольку препроцессор поддерживает include, мы можем сделать свою библиотеку макросов и использовать её в своих проектах.
#include "asm_common_amd64.h"
Для каждой архитектуры нам придётся, скорее всего, иметь свою библиотеку, так как ассемблерные команды отличаются.
Рассмотрим для примера несколько простых макросов.
#define ZERO_REG(reg) \
MOVQ $0, reg
Это макрос для обнуления регистра. Нам совершенно неизвестно, в каком состоянии нам достался регистр от вызывающего, и поэтому может потребоваться его обнулить $0 — это значит константа 0. Мы записываем константу 0` в регистр, который передаётся как параметр макросу.
Почему не XORQ reg, reg? Ведь она занимает на ~8 байт меньше? Дело в том, что операция XOR меняет флаги процессора. В частности, флаг переноса, который нам потребуется при реализации 256-битного умножения (функцию такого умножения мы напишем для примера).
/* ZERO_MEM1 — сброс 1 слова (8 байт) памяти в ноль
Параметры:
addr — адрес памяти (например, (AX), 0(BP), 8(SP) и т. д.)
*/
#define ZERO_MEM1(addr) \
MOVQ $0, addr
/* ZERO_MEM2 — сброс 2 слов (16 байт) памяти в ноль
Параметры:
base_addr — базовый адрес первого слова (память идёт подряд)
Использование: ZERO_MEM2(0(BP)) ��ля сброса 0(BP)..15(BP)
Если определена константа USE_SSE, использует SSE версию (быстрее)
Иначе использует обычную версию
*/
#ifndef USE_SSE
// --- ZERO_MEM2: обычная версия ---
#define ZERO_MEM2(base_addr) \
MOVQ $0, base_addr; \
MOVQ $0, 8+base_addr
#else
// --- ZERO_MEM2: SSE версия ---
#define ZERO_MEM2(base_addr) \
PXOR X0, X0; \
MOVDQU X0, base_addr
#endif
Конец первой части. Продолжение следует.
© 2025 ООО «МТ ФИНАНС»