Как читать: дизассемблер и Plan 9-синтаксис

Как читать чужой Go-ассемблер: go tool objdump и -gcflags=-S, Plan 9-синтаксис (порядок операндов, суффиксы размера, псевдорегистры FP/SB/SP/PC) и разбор дизасма наивного dot product — доказательство того, что компилятор не векторизовал цикл

Дизассемблер — это инструмент чтения, а не письма: прежде чем писать собственный ассемблер, стоит научиться читать чужой — как компилятор превращает Go в машинный код и как понять, векторизовал ли он цикл сам.

Вторая статья серии «Go assembly» — только про чтение: инструменты (go build -gcflags=-S, go tool objdump, go tool compile -S), Plan 9-синтаксис с нуля и разбор дизасма сквозного примера серии — наивного dot product, где видно, что AVX-регистры не задействованы.

gopher-детектив в шляпе Шерлока с лупой читает свиток дизассемблера (MOVQ, MULSD, подсвечена FMA-инструкция); на стене чарт псевдорегистров Plan 9 — FP (frame pointer), SB (static base), SP (stack pointer, стек растёт вниз); кружка COFFEE.GO, табличка «trust, but disassemble — Rob Pike (probably)»

В статье

Ментальная модель: читать чужой 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=-Sgo 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.go

2. 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 9src → dst (как AT&T, не Intel)MOVQa+0(FP),AXмнемоникасуффикс Q = 8 байтисточник (src)аргумент через FPприёмник (dst)регистрFPframe pointerаргументы и результатыa+0(FP)ret+24(FP)SBstatic baseсимволы и имена функцийTEXT ·dotAVX2(SB)runtime.panicBounds(SB)SPstack pointerлокали текущего кадраx-8(SP)«голый» SP — аппаратный

Суффиксы размера: буква в конце мнемоники — это ширина операнда. В 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, X0

MULSD (DI)(CX*8), X1 — умножить float64 по адресу DI + CX*8 (то есть b[i]) на X1 (где уже лежит a[i], загруженный строкой ниже через MOVSD), результат в X1. Затем ADDSD X1, X0 — прибавить произведение к аккумулятору X0. Обратите внимание на порядок операндов: src, dst, X0 = X0 + X1.

Суффикс SDScalar 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, X0

MOVD 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 $0xe675

ADCB, MOVL $-0x3f7cb73e, LRET — ничего этого в функции нет. Настоящая инструкция по адресу 0x140136f04VFMADD231PD (байты 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.

Обсуждение в Telegram

Присоединиться →

Комментарии