Модуль
1От исходника до регистров← вы здесь2Жизнь Go-программы: от запуска до завершения
Урок 1~15 минут

От исходника до регистров

Большинство программистов пишут код, не задумываясь что с ним происходит дальше. Здесь мы пройдём весь путь: от текстового файла с Go-кодом до инструкций в регистрах процессора.

Это не абстрактная теория — каждый этап можно увидеть своими глазами с помощью стандартного тулчейна Go.


Обзор пайплайна

main.go
  │
  ▼
[Лексер] → токены
  │
  ▼
[Парсер] → AST (Abstract Syntax Tree)
  │
  ▼
[Тайп-чекер] → типизированный AST
  │
  ▼
[Escape Analysis] → что на heap, что на stack
  │
  ▼
[SSA / IR] → промежуточное представление
  │
  ▼
[Оптимизации] → удаление мёртвого кода, инлайнинг, ...
  │
  ▼
[Генератор кода] → Plan 9 ассемблер
  │
  ▼
[Линкер] → ELF / Mach-O / PE бинарник
  │
  ▼
./hello (~1.8 МБ)

Симулятор выше показывает каждый этап интерактивно. Ниже — детали с реальными командами.


Этап 1: Лексер — исходник → токены

Первое что делает компилятор — разбивает текст на токены (лексемы). Токен — минимальная смысловая единица языка.

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

Превращается в поток токенов:

ТокенТип
funcKEYWORD
addIDENT
(LPAREN
aIDENT
,COMMA
bIDENT
intIDENT
)RPAREN
intIDENT
{LBRACE
returnKEYWORD
aIDENT
+ADD
bIDENT
}RBRACE

Лексер не понимает структуру — он просто разрезает строку. Это как разрезать предложение на отдельные слова.

В Go лексер живёт в пакете go/scanner:

go
import (
    "go/scanner"
    "go/token"
    "fmt"
)
 
src := []byte(`func add(a, b int) int { return a + b }`)
 
fset := token.NewFileSet()
file := fset.AddFile("", fset.Base(), len(src))
 
var s scanner.Scanner
s.Init(file, src, nil, 0)
 
for {
    pos, tok, lit := s.Scan()
    if tok == token.EOF { break }
    fmt.Printf("%s\t%s\t%q\n", fset.Position(pos), tok, lit)
}
Лексер — попробуй сам
Ctrl+Enter
1

Этап 2: Парсер — токены → AST

Парсер строит дерево из токенов, отражающее структуру программы. AST (Abstract Syntax Tree) — это иерархическое представление кода.

Для func add(a, b int) int { return a + b }:

FuncDecl
├── Name:    Ident "add"
├── Type:    FuncType
│   ├── Params:  FieldList
│   │   ├── Field [a, b] int
│   └── Results: FieldList
│       └── Field int
└── Body:    BlockStmt
    └── ReturnStmt
        └── BinaryExpr (Op: +)
            ├── Ident "a"
            └── Ident "b"

В Go парсер доступен как библиотека — go/parser:

Парсер — AST в действии
Ctrl+Enter
1

Этап 3: Тайп-чекер

Тайп-чекер проходит по AST и проверяет корректность типов. Именно здесь компилятор выдаёт ошибки вроде cannot use string as int.

Что проверяется:

  • Все переменные объявлены перед использованием
  • Типы в операциях совместимы (int + int → ok, int + string → ошибка)
  • Возвращаемые типы совпадают с объявлением
  • Интерфейсы реализованы корректно
go
import "go/types"
 
// types.Config содержит настройки тайп-чекера
// Результат — types.Info с информацией о каждом выражении

Этап 4: Escape Analysis

Перед генерацией кода компилятор решает: где хранить каждую переменную — на стеке или в куче.

go
func stackAlloc() int {
    x := 42        // x на стеке — не выходит за пределы функции
    return x       // стек освобождается при возврате
}
 
func heapAlloc() *int {
    x := 42        // x должен жить после возврата функции
    return &x      // → компилятор переносит x на кучу
}

Стек быстрый (просто сдвиг указателя), куча медленнее (нужен GC). Компилятор стремится держать всё на стеке.

Посмотреть решения компилятора:

bash
go build -gcflags='-m' main.go
# main.go:5:2: moved to heap: x
# main.go:10:6: can inline stackAlloc
Escape analysis
Ctrl+Enter
1

Этап 5: SSA — промежуточное представление

После тайп-чекинга код преобразуется в SSA (Static Single Assignment) форму. В SSA каждая переменная присваивается ровно один раз:

go
// Исходный код:
func add(a, b int) int {
    return a + b
}
// SSA форма:
b1:
  v1 = Arg <int> {a}     ← параметр a
  v2 = Arg <int> {b}     ← параметр b
  v3 = Add64 <int> v1 v2 ← a + b, результат в новой переменной v3
  Ret v3                 ← возврат

SSA упрощает оптимизации. Например, константное распространение:

go
// Исходный код:
x := 2
y := x * 3
z := y + 1    // компилятор видит: z = 2*3+1 = 7
 
// После оптимизации SSA:
z := 7        // весь расчёт заменён константой

Посмотреть SSA своей программы:

bash
GOSSAFUNC=add go build main.go
# Откроет ssa.html в браузере с полным SSA-графом

Этап 6: Ассемблер — Plan 9

Go использует синтаксис ассемблера Plan 9 (из ОС Bell Labs). Он отличается от Intel и AT&T синтаксиса.

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

Ассемблер (Go 1.17+, amd64, register-based ABI):

asm
TEXT main.add(SB), NOSPLIT|ABIInternal, $0-0
    // Соглашение Go 1.17+: аргументы в регистрах
    // a → AX (первый целочисленный аргумент)
    // b → BX (второй целочисленный аргумент)
    // возврат → AX
 
    ADDQ    BX, AX      // AX = AX + BX  (a + b)
    RET                 // результат уже в AX

Посмотреть ассемблер своей программы:

bash
go build -gcflags='-S' main.go 2>&1 | grep -A 10 "main.add"

Регистры CPU (amd64)

РегистрРоль в Go ABI
AXаргумент 1 / возврат 1
BXаргумент 2 / возврат 2
CXаргумент 3
DIаргумент 4
SIаргумент 5
R8R11аргументы 6–9
SPstack pointer
BPbase pointer (frame pointer)

До Go 1.17 все аргументы передавались через стек. После — через регистры. Производительность выросла на ~5-15%.

Посмотреть ассемблер — runtime/debug
Ctrl+Enter
1

Этап 7: Линкер

Линкер собирает .o объектные файлы и создаёт финальный бинарник.

В финальный бинарник Go всегда включает рантайм — сборщик мусора, планировщик горутин, рефлексию. Поэтому минимальный hello world весит ~1.8 МБ.

bash
$ ls -lh hello
-rwxr-xr-x  1.8M hello
 
$ go tool nm hello | head -20
# Показывает все символы в бинарнике

Структура ELF бинарника:

Секции бинарника:
.text      — машинный код функций
.rodata    — строковые константы ("Hello, World!")
.data      — инициализированные глобальные переменные
.bss       — неинициализированные глобальные переменные (нули)
.noptrdata — глобальные без указателей (не сканируются GC)
.typelink  — таблица типов для рефлексии
.itablink  — таблица интерфейсов

Этап 8: Память во время выполнения

Когда программа запущена, виртуальная память процесса выглядит так:

Высокий адрес
┌──────────────────────────────┐
│  goroutine stack             │
│  (2 KB начальный, ↓ вниз)   │ ← SP (stack pointer)
│                              │
│  ─────── (стек растёт) ──── │
│                              │
│  Heap                        │
│  (управляется GC, ↑ вверх)  │ ← mheap
│                              │
├──────────────────────────────┤
│  .bss  (нулевые глобальные)  │
│  .data (инициализированные)  │
│  .rodata ("Hello, World!")   │
│  .text  (машинный код)       │ ← PC (program counter)
└──────────────────────────────┘
Низкий адрес

Стек горутины

В отличие от OS-потоков (1–8 МБ стек), горутина начинает с 2 КБ:

go
// Горутины дёшевы именно из-за малого стека
for i := range 100_000 {
    go func() { /* ... */ }()
}
// 100к горутин × 2 KB = ~200 MB, а не 100к × 1MB = 100 GB

Когда стека не хватает — Go создаёт новый, больший сегмент и копирует содержимое. Это stack growth — прозрачно для программиста.

Стековый фрейм функции

Стек во время вызова add(5, 3):

  ┌─────────────────┐
  │  args/results   │  ← в Go 1.17+: в регистрах, не на стеке
  ├─────────────────┤
  │  return address │  ← куда вернуться после RET
  ├─────────────────┤
  │  saved BP       │  ← base pointer предыдущего фрейма
  ├─────────────────┤  ← BP (base pointer)
  │  local vars     │  ← локальные переменные add()
  └─────────────────┘  ← SP (stack pointer)

Инструменты для исследования

bash
# Ассемблер
go build -gcflags='-S' main.go
 
# Escape analysis
go build -gcflags='-m' main.go
go build -gcflags='-m=2' main.go  # подробнее
 
# SSA граф (откроет HTML в браузере)
GOSSAFUNC=main go build main.go
 
# Все символы бинарника
go tool nm ./hello
 
# Дизассемблер
go tool objdump -s 'main\.add' ./hello
 
# Информация о бинарнике
go version -m ./hello
 
# Профилировщик — горячие места в коде
go test -cpuprofile cpu.prof ./...
go tool pprof cpu.prof

Это инструменты, которые используют разработчики Go-рантайма и core-библиотек. Они встроены в стандартный тулчейн — устанавливать ничего не нужно.

go build -gcflags='-S' выводит ассемблер прямо в терминал. GOSSAFUNC=main go build откроет HTML с SSA-графом в браузере. Эти инструменты встроены в стандартный тулчейн.
Этап 1Исходный код

Go-программа — обычный UTF-8 текст. Спецификация Go описывает грамматику через расширенный BNF. Компилятор читает последовательность Unicode code points и начинает разбор.

main.go
func add(a, b int) int {
    return a + b
}
КодировкаUTF-8 (обязательно)
Расширение.go
Строк кода3
Байт47 байт
Форматgofmt нормализует отступы
Этап 1 / 8
🎯
Миссия 1 из 5
Как называется промежуточное представление в компиляторе Go, где каждая переменная присваивается ровно один раз?