9. Базовый Ассемблер. Как CPU видит код
Для прикладного разработчика исходный код — это лаконичные структуры, замыкания, каналы и архитектурные абстракции. Для кремниевого кристалла процессора этих концепций не существует. На уровне транзисторов есть лишь непрерывный поток машинных битов, переключающий мультиплексоры и триггеры.
Ассемблер — это тончайшая и абсолютно прозрачная граница между человеческим мышлением и физикой машины. Это символическое, человекочитаемое представление машинного кода той самой ISA, которую мы изучили в 8. ISA. Интерфейс между железом и софтом.
Умение читать ассемблер — одна из главных суперсил Senior-инженера. Высокоуровневый код на Go может скрывать детали: компилятор может незаметно вставить аллокацию в кучу, аппаратную проверку границ массива или вызов диспетчера рантайма. Ассемблер не врет никогда. Это окончательная, обнаженная истина о том, какие именно инструкции выполнит ваше процессорное ядро.
В этой лекции мы развеем миф о «непостижимой сложности» ассемблера, освоим диалект Plan 9, на котором говорят компилятор и рантайм Go, и научимся исследовать скрытые механизмы собственного кода.
Диалект Plan 9: Свой путь Go
В мире x86-64 исторически конкурируют два стандарта оформления инструкций:
- Синтаксис Intel: принят в документации Intel/AMD и в мире Windows (формат:
Opcode Destination, Source— приемник слева); - Синтаксис AT&T: исторический стандарт мира UNIX и GNU Assembler (формат:
Opcode Source, Destination, операнды с префиксами%rax,$42).
Однако, когда вы впервые скомпилируете Go-код с флагом вывода ассемблера, вы увидите третий, самобытный диалект — Plan 9 Assembly.
flowchart TD
GoCode["Исходный код Go (*.go)"] --> Front["Компилятор Go (cmd/compile)"]
Front --> SSA["SSA-оптимизатор (IR-граф)"]
SSA --> Plan9["Генерация Plan 9 Assembly<br>(Кросс-платформенный псевдокод)"]
Plan9 --> Asm["Внутренний ассемблер (cmd/internal/obj)"]
Asm --> MachCode["Сырой бинарный код<br>(x86-64 / ARM64 / RISC-V)"]
classDef src fill:#131d33,stroke:#38bdf8,stroke-width:1px,color:#f8fafc;
classDef comp fill:#1e293b,stroke:#6366f1,stroke-width:1px,color:#f8fafc;
classDef bin fill:#0f172a,stroke:#10b981,stroke-width:1px,color:#f8fafc;
class GoCode src;
class Front,SSA,Plan9,Asm comp;
class MachCode bin;
Корни этого решения уходят в исследовательский центр Bell Labs. Двое из трёх создателей языка Go — Кен Томпсон и Роб Пайк — были авторами распределенной операционной системы Plan 9. Для нее Кен Томпсон написал семейство уникальных C-компиляторов (8c, 6c, 5c), где ассемблер служил не отдельным языком макросов, а переносимым промежуточным представлением (IR).
Золотое правило Plan 9: Слева направо
В синтаксисе Plan 9 поток данных всегда читается естественно — слева направо:
$$\mathbf{Инструкция} \quad \mathbf{Источник}, \quad \mathbf{Приемник}$$
Например, команда:
MOVQ AX, BX
читается как: «Скопировать 64-битное значение из регистра AX в регистр BX».
Суффиксы размера операндов
К мнемонике операции всегда прикрепляется буква, указывающая точный размер обрабатываемых данных:
B(Byte): 1 байт (8 бит), напримерMOVB;W(Word): 2 байта (16 бит), машинное слово, напримерMOVW;L(Long): 4 байта (32 бита), двойное слово, напримерMOVL;Q(Quadword): 8 байт (64 бита), учетверенное слово — базовый размер в 64-битных системах, напримерMOVQ.
Константы в Plan 9 всегда предваряются символом доллара: $42 — это число 42, а не адрес ячейки памяти.
Базовый словарь инструкций
Микропроцессор x86-64 поддерживает тысячи инструкций, но чтобы уверенно читать 95% вывода компилятора Go, достаточно запомнить всего шесть основных глаголов:
MOV(Move — Перемещение):
Главный труженик любого чипа. Копирует байты между регистрами (MOVQ AX, BX) или между регистром и оперативной памятью (MOVQ (AX), BX). Инструкции семействаMOV— самые частые в типичном выводе компилятора: данные постоянно перекладываются между регистрами, стеком и памятью.ADD,SUB,IMUL(Арифметика):
Сложение, вычитание и целочисленное умножение.
ADDQ $10, AX— прибавить число 10 к региструAXи сохранить результат вAX.LEA(Load Effective Address — Загрузка эффективного адреса):
Уникальная инструкция архитектуры x86. Исходно она проектировалась для быстрого вычисления адреса ячейки памяти по формуле[база + индекс*масштаб + смещение]. Но компиляторы используют командуLEAQкак супербыстрый математический трюк: она позволяет выполнить сложение и умножение на константу (2, 4, 8) за один-единственный такт CPU, вообще не обращаясь к памяти!CMP(Compare — Сравнение):
Сравнивает два операнда. Под капотомCMPпросто вычитает один операнд из другого, но не сохраняет разность (в Go-ассемблереCMPQ a, bвыставляет флаги по результатуa - b: первый операнд минус второй), а лишь выставляет битовые флаги в регистре состоянияRFLAGS(Zero FlagZF, Sign FlagSFи др.).JMP,JEQ,JNE,JAE(Jump — Переходы и ветвления):
Инструкции ветвления, меняющие значение счетчика командRIP.JMP: безусловный прыжок по указанному адресу;JEQ(Jump if Equal): переход, если предыдущийCMPпоказал равенство (флаг $ZF = 1$);JNE(Jump if Not Equal): переход, если операнды не равны ($ZF = 0$);JAE(Jump if Above or Equal): переход для беззнаковых чисел ($A \ge B$).
CALLиRET:
Переход к телу вызываемой функции (с сохранением адреса возврата на вершине стека) и возврат обратно.
Псевдо-регистры Go
Ключевая инновация диалекта Plan 9 — концепция псевдо-регистров. Это виртуальные регистры, которых физически нет на кристалле процессора. Компилятор Go оперирует ими для построения платформонезависимого кода, а внутренний ассемблер и линкер на финальном этапе подставляют реальные смещения в памяти.
В Go определены четыре главных псевдо-регистра:
SB(Static Base): Указатель на начало глобальной памяти программы. Все функции и глобальные константы адресуются относительноSB. Запись"".main(SB)обозначает глобальный адрес точки входаmainтекущего пакета.FP(Frame Pointer): Виртуальный указатель базы фрейма. Используется для адресации аргументов, переданных вызывающей стороной через память.SP(Stack Pointer): Виртуальный указатель стека, адресующий локальные переменные текущей функции на стеке.PC(Program Counter): Псевдоним аппаратного счетчика инструкцийRIP.
Читаем код: Как CPU видит вашу функцию
Давайте напишем элементарную функцию на Go и посмотрим на нее глазами процессорного ядра:
package main
//go:noinline
func add(a, b int) int {
return a + b
}
(Прагма //go:noinline приказывает компилятору не встраивать тело функции в место вызова, чтобы мы могли увидеть ее в дизассемблере).
Скомпилируем файл с флагом вывода ассемблера:
go tool compile -S main.go
(или go build -gcflags=-S; для go build в пакете main должна быть объявлена функция main).
В терминале появится компактный ассемблерный блок (на архитектуре amd64):
main.add STEXT nosplit size=4 align=0x0 args=0x10 locals=0x0 funcid=0x0
0x0000 00000 (main.go:4) TEXT main.add(SB), NOSPLIT|NOFRAME|ABIInternal, $0-16
0x0000 00000 (main.go:5) ADDQ BX, AX
0x0003 00003 (main.go:5) RET
Разберем каждую строчку ассемблерного вывода:
TEXT main.add(SB), NOSPLIT|NOFRAME|ABIInternal, $0-16- Директива
TEXTобъявляет секцию исполняемого кода и имя функцииmain.add(SB). (В старых версиях Go, примерно до 1.20, имя печаталось как"".add(SB): пустые кавычки означали «текущий пакет».) - Флаг
NOSPLITсообщает, что функции не требуется пролог проверки размера стека, так как она не создает локальных переменных и не вызывает других функций;NOFRAME— что ей не нужен и стековый кадр. - Флаг
ABIInternalозначает использование современной конвенции вызова через регистры (Go 1.17+). - Числовая пара
$0-16:$0— размер локального фрейма на стеке (0 байт, переменные не создаются);$16— суммарный размер аргументов и возвращаемого значения (два операндаintпо 8 байт).
- Директива
ADDQ BX, AX
Обратите внимание: в коде нет ни одной инструкции чтения из памяти!
В силу стандарта ABIInternal аргументaпришел в регистреAX, а аргументb— в регистреBX. ИнструкцияADDQ BX, AXскладывает их в кремнии за 1 такт и сохраняет результат вAX, откуда вызывающая сторона заберет возвращаемое значение.RET
Процессор извлекает адрес возврата из стека и возвращает управление в вызывающую функцию.
Mechanical Sympathy: Скрытая цена слайсов
Ассемблер наглядно показывает, какую скрытую цену приходится платить за безопасность памяти в Go по сравнению с языками без проверок (C / C++).
Посмотрим на невинную функцию чтения элемента слайса:
package main
func readSlice(s []int, i int) int {
return s[i]
}
Скомпилируем ее через go tool compile -S main.go и заглянем внутрь (Go 1.27, amd64; служебные строки пролога/эпилога с PUSHQ BP/POPQ BP для краткости опущены, адреса — условные):
// Слайс s передается тремя регистрами: AX = указатель на массив данных,
// BX = длина (len), CX = ёмкость (cap). Следующий аргумент, индекс i, — в DI.
CMPQ DI, BX // Сравниваем индекс (DI) с длиной (BX)
JCC panic // Если i >= len (беззнаково), прыжок на панику! (JCC == JAE)
MOVQ (AX)(DI*8), AX // Чтение из памяти: адрес = AX + DI*8
RET
panic:
CALL runtime.panicBounds(SB) // Аварийный выброс паники (в старых версиях Go — runtime.panicIndex)
flowchart TD
Index["Индекс i в регистре DI"] --> CMP["CMPQ DI, BX<br>(Сравнение i с len)"]
Len["Длина len в регистре BX"] --> CMP
CMP --> Cond{"i < len ?"}
Cond -->|"Да: Безопасный доступ"| Read["MOVQ (AX)(DI*8), AX<br>Чтение из памяти (0.5-2 ns)"]
Cond -->|"Нет: Выход за границы"| Panic["CALL runtime.panicBounds<br>Паника рантайма!"]
Read --> Ret["RET (Успешный возврат)"]
classDef ok fill:#1e293b,stroke:#10b981,stroke-width:1px,color:#f8fafc;
classDef bad fill:#1e293b,stroke:#f43f5e,stroke-width:1px,color:#f8fafc;
class Read,Ret ok;
class Panic bad;
В языке C чтение по индексу превращается в единственную инструкцию MOVQ.
В Go компилятор гарантирует защиту от уязвимостей переполнения буфера (Buffer Overflow), поэтому автоматически генерирует две защитные инструкции: CMPQ и условный переход (JAE, в выводе Go-ассемблера — JCC) на вызов функции паники (runtime.panicBounds).
В горячих циклах (обработка сетевых пакетов, парсинг логов, сжатие данных) эти проверки могут заметно сказываться на производительности (сама пара CMP + Jcc дёшева и хорошо предсказывается, но мешает другим оптимизациям; масштаб зависит от кода — измеряйте бенчмарком).
Итог
- Ассемблер — это символическая запись машинных инструкций, дающая 100% достоверное представление о работе кода в кремнии.
- Plan 9 Assembly: диалект ассемблера Go с естественным синтаксисом «Слева направо» (
Инструкция Источник, Приемник) и суффиксами размера данных (B,W,L,Q). - Псевдо-регистры Go:
SB(глобальная память),FP(аргументы),SP(локальный стек),PC(счетчик инструкций). Виртуальныйимя-8(SP)нельзя путать с аппаратным-8(SP). - ABIInternal (Go 1.17+): аргументы функций передаются прямо через физические регистры процессора (
AX,BX...), что устраняет лишние обращения к памяти. - Анализ скомпилированного кода: флаг
go build -gcflags="-S"позволяет находить проверки границ слайсов (Bounds Checking), невидимые аллокации и скрытые вызовы рантайма.
Мы увидели, как инструкции оперируют регистрами и стеком при вызове функций. Но какие правила регулируют порядок передачи аргументов, кто очищает стек и как Go умудряется вызывать C-код?
В следующей лекции мы детально изучим конституцию взаимодействия функций: 10. ABI, Calling Convention и стек вызовов.