9. Базовый Ассемблер. Как CPU видит код

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

Ассемблер — это тончайшая и абсолютно прозрачная граница между человеческим мышлением и физикой машины. Это символическое, человекочитаемое представление машинного кода той самой ISA, которую мы изучили в 8. ISA. Интерфейс между железом и софтом.

Умение читать ассемблер — одна из главных суперсил Senior-инженера. Высокоуровневый код на Go может скрывать детали: компилятор может незаметно вставить аллокацию в кучу, аппаратную проверку границ массива или вызов диспетчера рантайма. Ассемблер не врет никогда. Это окончательная, обнаженная истина о том, какие именно инструкции выполнит ваше процессорное ядро.

В этой лекции мы развеем миф о «непостижимой сложности» ассемблера, освоим диалект Plan 9, на котором говорят компилятор и рантайм Go, и научимся исследовать скрытые механизмы собственного кода.


Диалект Plan 9: Свой путь Go

В мире x86-64 исторически конкурируют два стандарта оформления инструкций:

  1. Синтаксис Intel: принят в документации Intel/AMD и в мире Windows (формат: Opcode Destination, Source — приемник слева);
  2. Синтаксис 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{Приемник}$$

Например, команда:

Assembly
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, достаточно запомнить всего шесть основных глаголов:

  1. MOV (Move — Перемещение):
    Главный труженик любого чипа. Копирует байты между регистрами (MOVQ AX, BX) или между регистром и оперативной памятью (MOVQ (AX), BX). Инструкции семейства MOV — самые частые в типичном выводе компилятора: данные постоянно перекладываются между регистрами, стеком и памятью.
  2. ADD, SUB, IMUL (Арифметика):
    Сложение, вычитание и целочисленное умножение.
    ADDQ $10, AX — прибавить число 10 к регистру AX и сохранить результат в AX.
  3. LEA (Load Effective Address — Загрузка эффективного адреса):
    Уникальная инструкция архитектуры x86. Исходно она проектировалась для быстрого вычисления адреса ячейки памяти по формуле [база + индекс*масштаб + смещение]. Но компиляторы используют команду LEAQ как супербыстрый математический трюк: она позволяет выполнить сложение и умножение на константу (2, 4, 8) за один-единственный такт CPU, вообще не обращаясь к памяти!
  4. CMP (Compare — Сравнение):
    Сравнивает два операнда. Под капотом CMP просто вычитает один операнд из другого, но не сохраняет разность (в Go-ассемблере CMPQ a, b выставляет флаги по результату a - b: первый операнд минус второй), а лишь выставляет битовые флаги в регистре состояния RFLAGS (Zero Flag ZF, Sign Flag SF и др.).
  5. 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$).
  6. 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 и посмотрим на нее глазами процессорного ядра:

Go
package main

//go:noinline
func add(a, b int) int {
    return a + b
}

(Прагма //go:noinline приказывает компилятору не встраивать тело функции в место вызова, чтобы мы могли увидеть ее в дизассемблере).

Скомпилируем файл с флагом вывода ассемблера:

Bash
go tool compile -S main.go

(или go build -gcflags=-S; для go build в пакете main должна быть объявлена функция main).

В терминале появится компактный ассемблерный блок (на архитектуре amd64):

Assembly
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

Разберем каждую строчку ассемблерного вывода:

  1. 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 байт).
  2. ADDQ BX, AX
    Обратите внимание: в коде нет ни одной инструкции чтения из памяти!
    В силу стандарта ABIInternal аргумент a пришел в регистре AX, а аргумент b — в регистре BX. Инструкция ADDQ BX, AX складывает их в кремнии за 1 такт и сохраняет результат в AX, откуда вызывающая сторона заберет возвращаемое значение.
  3. RET
    Процессор извлекает адрес возврата из стека и возвращает управление в вызывающую функцию.

Mechanical Sympathy: Скрытая цена слайсов

Ассемблер наглядно показывает, какую скрытую цену приходится платить за безопасность памяти в Go по сравнению с языками без проверок (C / C++).

Посмотрим на невинную функцию чтения элемента слайса:

Go
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 для краткости опущены, адреса — условные):

Assembly
    // Слайс 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 дёшева и хорошо предсказывается, но мешает другим оптимизациям; масштаб зависит от кода — измеряйте бенчмарком).



Итог

  1. Ассемблер — это символическая запись машинных инструкций, дающая 100% достоверное представление о работе кода в кремнии.
  2. Plan 9 Assembly: диалект ассемблера Go с естественным синтаксисом «Слева направо» (Инструкция Источник, Приемник) и суффиксами размера данных (B, W, L, Q).
  3. Псевдо-регистры Go: SB (глобальная память), FP (аргументы), SP (локальный стек), PC (счетчик инструкций). Виртуальный имя-8(SP) нельзя путать с аппаратным -8(SP).
  4. ABIInternal (Go 1.17+): аргументы функций передаются прямо через физические регистры процессора (AX, BX...), что устраняет лишние обращения к памяти.
  5. Анализ скомпилированного кода: флаг go build -gcflags="-S" позволяет находить проверки границ слайсов (Bounds Checking), невидимые аллокации и скрытые вызовы рантайма.

Мы увидели, как инструкции оперируют регистрами и стеком при вызове функций. Но какие правила регулируют порядок передачи аргументов, кто очищает стек и как Go умудряется вызывать C-код?

В следующей лекции мы детально изучим конституцию взаимодействия функций: 10. ABI, Calling Convention и стек вызовов.