Дизассемблер — это инструмент чтения, а не письма: прежде чем писать собственный ассемблер, стоит научиться читать чужой — как компилятор превращает Go в машинный код и как понять, векторизовал ли он цикл сам.
Вторая статья серии «Go assembly» — только про чтение: инструменты (go build -gcflags=-S, go tool objdump, go tool compile -S), Plan 9-синтаксис с нуля и разбор дизасма сквозного примера серии — наивного dot product, где видно, что AVX-регистры не задействованы.
В статье
- Ментальная модель: читать чужой asm ≠ писать свой
- Три инструмента чтения
- Plan 9-синтаксис с нуля
- Разбор dot product: компилятор не векторизовал
- Читаем реальный кусок stdlib:
bytes.IndexByte - Инструменты тоже врут: objdump и VEX/FMA3
- Выводы
Ментальная модель: читать чужой asm ≠ писать свой
Первый инстинкт при слове «ассемблер» — «сейчас придётся писать эти строчки самому». Для этой статьи забудьте про него. Чтение и письмо — два разных навыка, и порядок между ними жёсткий: сначала научиться читать то, что произвёл компилятор (или лежит в stdlib), и только потом — если привратник из статьи №1 пропустил — браться за собственный .s-файл.
Читать нужно даже тем, кто писать не собирается, и вот зачем:
- Проверить, не сделал ли компилятор работу за вас. Прежде чем писать SIMD-цикл руками, надо убедиться, что в горячем месте его ещё нет. Ответ виден только в дизассемблере — исходник об этом молчит.
- Понять чужой
.s-файл. Половина ассемблера в stdlib и зависимостях — это.s-файлы, которые придётся прочитать, чтобы понять, что там происходит и безопасно ли это трогать. - Отлаживать на уровне инструкций. Когда профиль (статья про профилирование) показал горячую точку внутри одной функции, следующий вопрос — «а во что именно она компилируется» — тоже про чтение.
Письмо — это следующая статья серии: там будет ABI, FP-смещения аргументов, go vet для asm и вся ответственность за корректность на себе. Здесь — только глаза. Никакой .s-файл мы не пишем, только смотрим на готовый вывод инструментов.
Сквозной пример серии — скалярное произведение []float64 (dot product) — уже знаком по статье №1. Наивная golden-реализация выглядит так:
package dotprod
// dotGeneric — эталонная (golden) реализация. Есть на всех архитектурах,
// служит и fallback-ом, и образцом корректности для asm-версий в тестах.
func dotGeneric(a, b []float64) float64 {
var s float64
for i := range a {
s += a[i] * b[i]
}
return s
}Весь код и скрипты из этой статьи — в cookbook-репозитории, листинги ниже сняты со стенда reading/ тем же скриптом reading/gen_listings.sh. Хост: AMD Ryzen 7 5800X3D, Go 1.26.3, windows/amd64.
Три инструмента чтения
В арсенале Go три способа увидеть ассемблер, и они показывают разные вещи. Путаница между ними — источник половины недоразумений.
1. go build -gcflags=-S (и go tool compile -S) — это вывод компилятора: то, что кодогенератор Go собирается породить, ещё в Plan 9-нотации самого Go, с псевдорегистрами и метаданными (FUNCDATA, PCDATA, релокациями). Это «мысли компилятора», а не финальные байты. Оба флага дают одно и то же представление; разница в том, что compile -S работает с одним файлом, а build -gcflags=-S — со всем пакетом:
# весь пакет, вывод идёт в stderr — перенаправляем в файл
go build -gcflags='-S' ./dotprod/ 2>dotprod_all.txt
# то же для одного файла
go tool compile -S dotprod/dot_generic_impl.go2. go tool objdump — это дизасм готового бинарника: инструмент читает уже слинкованный .exe/.o и декодирует настоящие машинные байты обратно в мнемоники. Здесь видно то, что реально исполнит процессор, с настоящими адресами. Функцию ищут по имени флагом -s (регулярка по символу):
# соберём тестовый бинарник и дизассемблируем в нём символ dotAVX2
go test -c -o dotprod.test.exe ./dotprod/
go tool objdump -s 'dotAVX2' dotprod.test.exeРазница принципиальна. -S — «что компилятор намерен сгенерировать», objdump — «что оказалось в бинарнике». Для чистого Go оба покажут примерно одно, но -S удобнее (сразу привязка к строкам исходника), а objdump незаменим, когда бинарник уже есть, а исходника под рукой нет — или когда нужно проверить именно финальные байты. У objdump есть и подводный камень на экзотических инструкциях — к нему вернёмся в конце.
Как найти свою функцию в выводе. Дамп -S для пакета — это простыня из всех функций подряд. Каждая начинается со строки <полное.имя.Символа> STEXT …, поэтому свою функцию ищут по имени символа. На Go 1.26 пакетный скоуп -gcflags='-S=dotGeneric' не поддерживается, так что на стенде листинг одной функции вырезается из полного дампа обычным awk по границам STEXT:
# полный -S дамп пакета
go build -gcflags='-S' ./dotprod/ 2>reading/listings/dotprod_all.txt || true
# только блок dotGeneric: от строки "...dotGeneric STEXT" до следующего STEXT
awk '/\.dotGeneric STEXT/{p=1} p && /STEXT/ && !/\.dotGeneric STEXT/{p=0} p' \
reading/listings/dotprod_all.txt > reading/listings/dotGeneric.txtВ objdump проще — регулярка -s 'dotAVX2' сама отфильтрует нужный символ. Держите в голове: и там, и там точкой в имени разделяются пакет и функция (dotprod.dotGeneric), а невыделенные методы и замыкания получают суффиксы вроде .func1.
Plan 9-синтаксис с нуля
Go не использует ни Intel-, ни AT&T-синтаксис в чистом виде. Ассемблер Go — потомок ассемблера Plan 9, и у него свои правила. Чтобы читать листинги, достаточно усвоить четыре вещи: порядок операндов, суффиксы размера, псевдорегистры и заголовок функции.
Порядок операндов: источник → приёмник. Это главный источник ошибок для тех, кто привык к Intel-синтаксису, где порядок обратный (dst, src). В Plan 9 — как в AT&T: сначала откуда, потом куда.
MOVQ AX, BX // BX = AX (скопировать AX в BX)
ADDQ CX, DX // DX = DX + CX
MULSD (DI), X1 // X1 = X1 * [память по DI]Прочитайте MOVQ AX, BX как «переместить AX в BX» — стрелка всегда слева направо. Если читать по-интеловски, каждая строка «переворачивается», и весь листинг разъезжается. Это настолько частая ошибка, что стоит проговорить вслух первые несколько строк любого нового листинга.
Суффиксы размера: буква в конце мнемоники — это ширина операнда. В Plan 9 размер зашит не в имя регистра, а в суффикс инструкции:
| Суффикс | Размер | Пример |
|---|---|---|
Q (quad) |
8 байт | MOVQ — 64-битное слово (указатель, int) |
L (long) |
4 байта | MOVL — 32-битное |
W (word) |
2 байта | MOVW |
B (byte) |
1 байт | MOVB — один байт |
Для чисел с плавающей точкой суффикс кодирует и тип, и «скалярность»: MOVSD/ADDSD/MULSD — это Scalar Double, один float64 за инструкцию (SSE2). Приставка V (VMOVUPD, VADDPD, VFMADD231PD) означает VEX-кодировку — векторные AVX-инструкции, несколько float64 разом. Разница между скалярным MULSD и векторным VMULPD/VFMADD… — это ровно разница между «компилятор оставил цикл как есть» и «здесь настоящий SIMD», и именно её мы будем ловить глазами в следующем разделе.
Псевдорегистры: FP, SB, SP, PC. Это не физические регистры процессора, а абстракции ассемблера Go — способ адресовать вещи по имени, а не по числовому смещению. Их четыре:
FP(Frame Pointer) — аргументы и результаты функции. Пишутся каксимвол+смещение(FP):a+0(FP),b+8(FP),n+16(FP). Имя перед+— чисто для читаемости (иgo vetпроверяет его по сигнатуре), смещение — реальное положение аргумента.SB(Static Base) — глобальная база: функции, глобальные переменные, таблицы.TEXT ·dotGeneric(SB)— «символ dotGeneric относительно статической базы». Любое имя функции в листинге адресуется через(SB).SP(Stack Pointer) — локальные переменные текущего кадра. У Go есть тонкость: псевдо-SP(с именем,x-8(SP)) — это локали, а «голый»SPбез имени — аппаратный регистр стека. В дизасмеobjdump(см. ниже) вы увидите именно аппаратныйSP/rsp.PC(Program Counter) — счётчик команд, нужен для переходов; в реальных листингах вместо него обычно уже подставлены числовые адреса-цели прыжков.
Заголовок функции: TEXT ·Name(SB), FLAGS, $framesize-argsize. Каждая функция начинается со строки TEXT. Символ · (middle dot, U+00B7) перед именем — это сокращение полного пути пакета: ·dotGeneric разворачивается в путь/пакет.dotGeneric. Флаги (NOSPLIT, ABIInternal) и пара $framesize-argsize описывают кадр и размер аргументов. Разбирать их подробно — тема статьи про письмо; для чтения достаточно узнавать TEXT …(SB) как «вот здесь начинается функция».
Этих четырёх пунктов хватит, чтобы построчно разобрать реальный листинг — чем и займёмся.
Разбор dot product: компилятор не векторизовал
Теперь — центральный момент статьи. Возьмём наивный dotGeneric и посмотрим, во что его превратил компилятор. Вопрос конкретный: векторизовал ли Go цикл s += a[i] * b[i] сам — то есть обрабатывает ли он несколько float64 за инструкцию через AVX, или оставил скалярный цикл по одному числу за раз.
Вот дамп -S функции dotGeneric, снятый скриптом стенда (это reading/listings/dotGeneric.txt, вырезанный awk-ом из полного дампа пакета; служебный hex-дамп и релокации в конце блока опущены — для чтения важны инструкции):
tech.khorost/go-asm-cookbook/dotprod.dotGeneric STEXT nosplit size=58 args=0x30 locals=0x8 funcid=0x0 align=0x0
0x0000 00000 (.../dotprod/dot_generic_impl.go:5) TEXT tech.khorost/go-asm-cookbook/dotprod.dotGeneric(SB), NOSPLIT|ABIInternal, $8-48
0x0000 00000 (.../dotprod/dot_generic_impl.go:5) PUSHQ BP
0x0001 00001 (.../dotprod/dot_generic_impl.go:5) MOVQ SP, BP
0x0004 00004 (.../dotprod/dot_generic_impl.go:5) MOVQ AX, tech.khorost/go-asm-cookbook/dotprod.a+16(FP)
0x0009 00009 (.../dotprod/dot_generic_impl.go:5) MOVQ DI, tech.khorost/go-asm-cookbook/dotprod.b+40(FP)
0x000e 00014 (.../dotprod/dot_generic_impl.go:5) FUNCDATA $0, gclocals·cC5rNmVCHyCv6uELS7ccGw==(SB)
0x000e 00014 (.../dotprod/dot_generic_impl.go:5) FUNCDATA $1, gclocals·J26BEvPExEQhJvjp9E8Whg==(SB)
0x000e 00014 (.../dotprod/dot_generic_impl.go:5) FUNCDATA $5, tech.khorost/go-asm-cookbook/dotprod.dotGeneric.arginfo1(SB)
0x000e 00014 (.../dotprod/dot_generic_impl.go:5) FUNCDATA $6, tech.khorost/go-asm-cookbook/dotprod.dotGeneric.argliveinfo(SB)
0x000e 00014 (.../dotprod/dot_generic_impl.go:5) PCDATA $3, $1
0x000e 00014 (.../dotprod/dot_generic_impl.go:7) XORL CX, CX
0x0010 00016 (.../dotprod/dot_generic_impl.go:7) XORPS X0, X0
0x0013 00019 (.../dotprod/dot_generic_impl.go:7) JMP 33
0x0015 00021 (.../dotprod/dot_generic_impl.go:8) MULSD (DI)(CX*8), X1
0x001a 00026 (.../dotprod/dot_generic_impl.go:8) ADDSD X1, X0
0x001e 00030 (.../dotprod/dot_generic_impl.go:7) INCQ CX
0x0021 00033 (.../dotprod/dot_generic_impl.go:7) CMPQ BX, CX
0x0024 00036 (.../dotprod/dot_generic_impl.go:7) JLE 50
0x0026 00038 (.../dotprod/dot_generic_impl.go:8) MOVSD (AX)(CX*8), X1
0x002b 00043 (.../dotprod/dot_generic_impl.go:8) CMPQ SI, CX
0x002e 00046 (.../dotprod/dot_generic_impl.go:8) JHI 21
0x0030 00048 (.../dotprod/dot_generic_impl.go:8) JMP 52
0x0032 00050 (.../dotprod/dot_generic_impl.go:10) POPQ BP
0x0033 00051 (.../dotprod/dot_generic_impl.go:10) RET
0x0034 00052 (.../dotprod/dot_generic_impl.go:8) PCDATA $1, $1
0x0034 00052 (.../dotprod/dot_generic_impl.go:8) PCDATA $4, $7047
0x0034 00052 (.../dotprod/dot_generic_impl.go:8) CALL runtime.panicBounds(SB)
0x0039 00057 (.../dotprod/dot_generic_impl.go:8) XCHGL AX, AXРазберём по слоям — теперь, вооружившись Plan 9.
Шапка и пролог. STEXT открывает функцию: size=58 — 58 байт машинного кода, args=0x30 (48 байт аргументов — два слайса по 24), locals=0x8. PUSHQ BP / MOVQ SP, BP — стандартный пролог кадра. Строки FUNCDATA/PCDATA — метаданные для сборщика мусора и стектрейсов, при первом чтении их можно пропускать: это не исполняемые инструкции, а таблицы для рантайма.
Инициализация цикла. XORL CX, CX обнуляет счётчик i (это CX), а XORPS X0, X0 обнуляет аккумулятор s — регистр X0. XORPS тут — идиома «положить ноль во float-регистр». JMP 33 прыгает на проверку условия в конец (классический паттерн for-цикла: сначала проверка).
Тело цикла — вот оно, доказательство. Две ключевые строки:
0x0015 00021 (.../dot_generic_impl.go:8) MULSD (DI)(CX*8), X1
0x001a 00026 (.../dot_generic_impl.go:8) ADDSD X1, X0MULSD (DI)(CX*8), X1 — умножить float64 по адресу DI + CX*8 (то есть b[i]) на X1 (где уже лежит a[i], загруженный строкой ниже через MOVSD), результат в X1. Затем ADDSD X1, X0 — прибавить произведение к аккумулятору X0. Обратите внимание на порядок операндов: src, dst, X0 = X0 + X1.
Суффикс SD — Scalar Double. Это SSE2, один float64 за инструкцию. Никаких V-префиксов, никаких PD (Packed Double), никакого VFMADD…. Компилятор Go не свернул умножение и сложение в FMA и не развернул цикл на несколько float64 за такт. Он честно исполняет ровно то, что написано в Go: по одному элементу за итерацию.
Хвост цикла. INCQ CX (i++), CMPQ BX, CX + JLE 50 — проверка i < len(a) и выход, MOVSD (AX)(CX*8), X1 — загрузка a[i], а CMPQ SI, CX + JHI 21 — это bounds check по второму слайсу (b), который в случае выхода за границы прыгает на CALL runtime.panicBounds(SB) в самом низу. Даже bounds check компилятор оставил внутри горячего цикла.
Вывод — центральный тезис всей серии: на автовекторизацию идиоматичного Go-цикла рассчитывать нельзя. Компилятор Go (в отличие от, скажем, clang с -O3) не превращает for i := range a { s += a[i]*b[i] } в SIMD. Если из этого цикла нужно выжать разы — это придётся сделать руками (AVX2 — статья №3) или генерацией через avo (статья №4). И увидеть, что векторизации нет, можно только так — прочитав дизассемблер. Из исходника этот факт не следует никак.
Для контраста: рукописный dotAVX2 из статьи №3 в том же горячем цикле содержит VFMADD231PD ymm0, ymm1, ymm2 — одну инструкцию, которая делает Y0 += Y1*Y2 сразу над четырьмя float64. Как её увидеть в дизасме (и почему objdump тут может соврать) — в предпоследнем разделе.
Читаем реальный кусок stdlib: bytes.IndexByte
Закрепим навык на настоящем ассемблере из стандартной библиотеки. bytes.IndexByte — поиск байта в срезе — один из самых горячих примитивов Go, поэтому написан руками. На Go 1.26.3 реализация живёт не в bytes/, а в internal/bytealg/indexbyte_amd64.s (пакет bytes лишь вызывает bytealg.IndexByte). Открыть её можно прямо в дереве исходников:
ls "$(go env GOROOT)/src/internal/bytealg/"indexbyte_*.s
# indexbyte_amd64.s indexbyte_arm64.s ...Разбирать весь файл незачем — он длинный, с отдельными путями под SSE, AVX2 и короткие строки. Пройдёмся по нескольким показательным строкам — этого достаточно, чтобы всё, что мы разобрали выше, сложилось на реальном коде.
Точка входа. Верх файла — две публичные функции и общее тело:
TEXT ·IndexByte(SB), NOSPLIT, $0-40
MOVQ b_base+0(FP), SI
MOVQ b_len+8(FP), BX
MOVB c+24(FP), AL
LEAQ ret+32(FP), R8
JMP indexbytebody<>(SB)Всё читается тем словарём, что мы завели. TEXT ·IndexByte(SB) — начало функции IndexByte. Аргументы разбираются через FP: b_base+0(FP) — указатель на данные среза (в SI), b_len+8(FP) — его длина (в BX), c+24(FP) — искомый байт (в AL, младший байт AX, суффикс MOVB — один байт). LEAQ ret+32(FP), R8 кладёт в R8 адрес ячейки результата (не значение — LEA вычисляет адрес). Затем JMP на общее тело indexbytebody<>(SB) — суффикс <> означает файл-локальный символ, невидимый снаружи пакета.
Подготовка искомого байта. В теле сразу — трюк с размножением байта по всему вектору:
MOVD AX, X0
PUNPCKLBW X0, X0
PUNPCKLBW X0, X0
PSHUFL $0, X0, X0MOVD AX, X0 кладёт искомый байт в младшие биты SSE-регистра X0, а пара PUNPCKLBW и PSHUFL $0 «размазывают» его так, чтобы во всех 16 байтах X0 оказался один и тот же искомый байт. Дальше его можно будет за одну инструкцию сравнить сразу с 16 байтами данных.
Горячий SSE-цикл. Собственно сравнение:
sseloop:
MOVOU (DI), X1
PCMPEQB X0, X1
PMOVMSKB X1, DX
BSFL DX, DX
JNZ ssesuccess
ADDQ $16, DIЗдесь и есть SIMD, ради которого функцию писали руками. MOVOU (DI), X1 грузит 16 байт данных в X1. PCMPEQB X0, X1 побайтно сравнивает их с искомым байтом (размноженным в X0) — где совпало, там байт становится 0xFF. PMOVMSKB X1, DX собирает старшие биты всех 16 байт в 16-битную маску в DX. BSFL DX, DX находит номер первого установленного бита (позицию совпадения), а JNZ ssesuccess прыгает на обработку успеха, если совпадение было. Если нет — ADDQ $16, DI двигает указатель на следующие 16 байт. Один проход цикла обрабатывает 16 байт вместо одного — вот та самая разница, что оправдывает ассемблер в stdlib.
Обратите внимание: IndexByte использует SSE (128 бит, X-регистры), а для длинных строк в файле есть отдельная ветка avx2 (256 бит, Y-регистры) — ровно как в нашем dot product разница между скалярным SSE2 и AVX2. Тот же файл, кстати, показывает, что stdlib пишет asm ровно там, где узкое место доказано и универсально, — как обсуждалось в статье №1.
Инструменты тоже врут: objdump и VEX/FMA3
Последний, но важный сюжет: инструмент для чтения дизасма и сам может соврать — если инструкция для него слишком экзотическая. Это не гипотетика, а то, на что читатель наткнётся на реальном железе.
На хосте стенда (Go 1.26.3, windows/amd64) встроенный go tool objdump использует декодер из пакета golang.org/x/arch/x86/x86asm, и этот декодер не распознаёт VEX-кодированные FMA3-инструкции — те самые VFMADD231PD/VFMADD231SD, что стоят в горячем цикле рукописного dotAVX2. Байты читаются корректно, но начиная с первой VFMADD мнемоники «уплывают»: декодер разбивает байтовый поток не по тем границам, и печатает уже мусор. Вот сырой вывод go tool objdump -s 'dotAVX2' — так делать нельзя, это листинг-обманка:
dot_amd64.s:16 0x140136efc c5fd1008 ADCB CL, 0(AX)
dot_amd64.s:17 0x140136f00 c5fd1013 ADCB DL, 0(BX)
dot_amd64.s:18 0x140136f04 c4e2f5b8c24883c0 MOVL $-0x3f7cb73e, AX
dot_amd64.s:19 0x140136f0c 204883 ANDB CL, -0x7d(AX)
dot_amd64.s:20 0x140136f0f c3 RET
dot_amd64.s:21 0x140136f13 ca75e6 LRET $0xe675ADCB, MOVL $-0x3f7cb73e, LRET — ничего этого в функции нет. Настоящая инструкция по адресу 0x140136f04 — VFMADD231PD (байты c4e2f5b8c2), а декодер, споткнувшись о VEX-префикс c4, съел лишние байты и выдал фантомный MOVL. Дальше поток рассинхронизирован, и все последующие строки — артефакты, а не то, что исполняет процессор.
Правильный декод тех же самых байт (стенд передекодирует их через Capstone 5, у которого поддержка VEX/AVX2/FMA3 полная) выглядит так:
dot_amd64.s:16 0x140136efc c5fd1008 vmovupd ymm1, ymmword ptr [rax]
dot_amd64.s:17 0x140136f00 c5fd1013 vmovupd ymm2, ymmword ptr [rbx]
dot_amd64.s:18 0x140136f04 c4e2f5b8c2 vfmadd231pd ymm0, ymm1, ymm2
0x140136f09 4883c020 add rax, 0x20
0x140136f0d 4883c320 add rbx, 0x20
0x140136f11 48ffca dec rdx
0x140136f14 75e6 jne 0x140136efcБайты c4e2f5b8c2 в обоих листингах одни и те же — соврала не машина, а декодер. Реальная инструкция — vfmadd231pd ymm0, ymm1, ymm2: один fused-multiply-add сразу над четырьмя float64 (Y0 += Y1*Y2). Это ровно то, чего не было в скалярном dotGeneric, и то, что руками пишет статья №3.
Практические выводы для читателя дизасма:
- Ограничение узкое — конкретно этот декодер
go tool objdumpна этой версии Go/ОС и конкретно на VEX/FMA3. Обычный код (включая скалярныйdotGenericвыше) декодируется верно. Не стоит раздувать это до «objdump всегда врёт». go build -gcflags=-Sэтой проблемы лишён — он не проходит через VEX-декодерobjdump, а печатает то, что генерирует сам компилятор. Поэтому скалярный листингdotGenericв этой статье снят именно через-S, и ему можно доверять.- Если видите бессмысленные мнемоники рядом с AVX/FMA-кодом (
ADCB,LRET, внезапныеMOVLс огромными константами) — это симптом рассинхронизации декодера. Перепроверьте теми же байтами через другой дизассемблер (objdumpиз binutils, Capstone, IDA/Ghidra) или через-Sкомпилятора.
Мораль в духе серии: дизассемблер — инструмент чтения, но и у инструмента есть слепые зоны. Читать надо не только листинг, но и то, чем вы его сняли.
Выводы
- Читать и писать asm — разные навыки. Эта статья — только про чтение; собственный
.s-файл начинается в статье №3. - Три инструмента, три вопроса.
go build -gcflags=-Sиgo tool compile -Sпоказывают, что намерен сгенерировать компилятор;go tool objdump— что реально в бинарнике. Свою функцию ищут по имени символа (… STEXT,-s 'имя'). - Plan 9 в четырёх пунктах. Операнды
src → dst(обратно Intel), размер — в суффиксе (Q/L/W/B,SD— скаляр,PD/V…— вектор), псевдорегистрыFP/SB/SP/PC, заголовокTEXT ·Name(SB). - Компилятор Go не автовекторизует. В дизасме
dotGeneric— скалярныеMULSD/ADDSD, ни однойVFMADD…/PD-инструкции. Хотите SIMD — пишите руками или генерируйте (avo). - Инструменты тоже врут.
go tool objdumpна Go 1.26.3/windows мис-декодит VEX/FMA3 — сверяйтесь с-Sили другим дизассемблером.
Следующая статья серии — «Как писать: рукописный asm и ABI»: тот же dot product, но уже с AVX2-циклом на VFMADD231PD, разбором FP-смещений, ABI и go vet для ассемблера. Весь код — в cookbook.
Комментарии