Как работает компилятор Go: малоизвестные детали

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

Этапы компиляции в Go

  1. Парсинг — преобразование текста в синтаксическое дерево (AST).
  2. Проверка типов — через пакет go/types.
  3. Преобразование в промежуточное представление (IR).
  4. Оптимизация IR через SSA (Static Single Assignment).
  5. Генерация ассемблера.
  6. Сборка объектного файла.

Что мало кто знает

1. SSA как единая форма представления

Go использует SSA (Static Single Assignment) с 2016 года. Это внутреннее представление, в котором каждая переменная присваивается только один раз. Благодаря этому оптимизации становятся тривиальными.

a := 1
b := a + 2
c := b * 3

В SSA:

v1 = 1
v2 = v1 + 2
v3 = v2 * 3

Компилятор cmd/compile/internal/ssa создает граф потока управления (CFG), где каждая инструкция — это нода.

SSA IR Go не совместим с LLVM — у Go своя модель, заточенная под скорость компиляции, а не многоуровневую оптимизацию.

2. Ассемблерная «машина»: cmd/internal/obj

Go использует собственную виртуальную ассемблерную машину, представленную в виде пакета cmd/internal/obj.

Эта машина:

  • Работает как внутренний DSL для генерации инструкций.
  • Имеет абстрактные инструкции, которые позже мапятся на реальные (MOVQ, ADDQ, и т.д.).
  • Поддерживает rewriting rules — шаблоны преобразования инструкций.

Пример правила (в Go ASM):

(MOVQconst [c] _) => (LoadConstant c)

Это значит: если инструкция — MOVQconst с константой, заменить её на более эффективную LoadConstant. Это позволяет делать паттерн-матчинг на уровне инструкций без необходимости писать вручную под каждую архитектуру.

3. Лейаут блоков и физические регистры

Go компилятор реально моделирует регистры, включая spill/reload инструкции, и делает live-range анализ.

Физические регистры назначаются поздно, и аллокатор регистров использует Linear Scan Register Allocation, что намного быстрее, чем Graph Coloring (используемый в LLVM).

4. Инструкции, которых вы не видите

Если вы скомпилируете Go-файл с флагами:

go build -gcflags="-S" main.go

Вы увидите Go-ассемблер, но не увидите промежуточные инструкции SSA. Чтобы их увидеть:

GO_SSADUMP=all ./main

Это даст IR на стадии SSA — с блоками, инструкциями и виртуальными регистрами.

Пример: SSA и ASM

func sum(a, b int) int {
    return a + b
}

SSA-представление:

b1:
    v1 = Arg <int>
    v2 = Arg <int>
    v3 = Add v1 v2
    Ret v3

Ассемблерная форма (для amd64):

MOVQ a+0(FP), AX
MOVQ b+8(FP), CX
ADDQ CX, AX
MOVQ AX, ret+16(FP)
RET

Интересные файлы в исходниках Go

  • src/cmd/compile/internal/ssa/rewriteAMD64.go — правила трансляции SSA в ассемблер.
  • src/cmd/internal/obj/ — абстрактная ассемблерная машина.
  • src/cmd/compile/internal/base — управление фазами компиляции.

Дополнительное

  • Функции с именами defer компилируются в два прохода: один для тела, другой для обработки «defer stack».
  • Inline-функции разворачиваются на SSA-этапе.
  • Nil-указатели в Go часто реализуются через явные CMP и JEQ, а не через инструкции MMU.

Полезные команды

Показать SSA граф:

GOSSAFUNC=sum go build -gcflags="-S" main.go

Создаст .html граф SSA.

Показать полный ASM:

go tool compile -S main.go

Заключение

Компилятор Go — это не просто парсер и генератор кода. Это высокоорганизованная система с собственной IR, виртуальной ассемблерной машиной и оптимизациями, которые делают его компиляцию молниеносной.

Go не использует LLVM, и в этом его сила — полный контроль над кодогенерацией.