golang

Ассемблер для гоферов. Часть 1

  • среда, 10 декабря 2025 г. в 00:00:12
https://habr.com/ru/companies/ruvds/articles/973808/
Ассемблер не так страшен, как его малюют
Ассемблер не так страшен, как его малюют

В этой статье я постараюсь дать максимально простое введение в Го-ассемблер — зачем и когда он может понадобиться, а также мы начнём делать функцию умножения для 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

  1. Начальное состояние флагов неизвестно — Вы не можете полагаться в своей функции, что все флаги обнулены. Вам придётся сделать это самому.

  2. Сохранение флагов — При вызове других функций ваши флаги могут быть изменены. Если ваша функция зависит от конкретных значений флагов, сохраняйте их.

  3. Неявное изменение флагов — Многие инструкции изменяют флаги неявно:

    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 — переход, если одно значение больше другого.

  • — переход по наличию флага переноса 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 ООО «МТ ФИНАНС»