От исходника до регистров
Большинство программистов пишут код, не задумываясь что с ним происходит дальше. Здесь мы пройдём весь путь: от текстового файла с 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: Лексер — исходник → токены
Первое что делает компилятор — разбивает текст на токены (лексемы). Токен — минимальная смысловая единица языка.
func add(a, b int) int { return a + b }Превращается в поток токенов:
| Токен | Тип |
|---|---|
func | KEYWORD |
add | IDENT |
( | LPAREN |
a | IDENT |
, | COMMA |
b | IDENT |
int | IDENT |
) | RPAREN |
int | IDENT |
{ | LBRACE |
return | KEYWORD |
a | IDENT |
+ | ADD |
b | IDENT |
} | RBRACE |
Лексер не понимает структуру — он просто разрезает строку. Это как разрезать предложение на отдельные слова.
В Go лексер живёт в пакете go/scanner:
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)
}Этап 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:
Этап 3: Тайп-чекер
Тайп-чекер проходит по AST и проверяет корректность типов. Именно здесь компилятор выдаёт ошибки вроде cannot use string as int.
Что проверяется:
- Все переменные объявлены перед использованием
- Типы в операциях совместимы (
int + int→ ok,int + string→ ошибка) - Возвращаемые типы совпадают с объявлением
- Интерфейсы реализованы корректно
import "go/types"
// types.Config содержит настройки тайп-чекера
// Результат — types.Info с информацией о каждом выраженииЭтап 4: Escape Analysis
Перед генерацией кода компилятор решает: где хранить каждую переменную — на стеке или в куче.
func stackAlloc() int {
x := 42 // x на стеке — не выходит за пределы функции
return x // стек освобождается при возврате
}
func heapAlloc() *int {
x := 42 // x должен жить после возврата функции
return &x // → компилятор переносит x на кучу
}Стек быстрый (просто сдвиг указателя), куча медленнее (нужен GC). Компилятор стремится держать всё на стеке.
Посмотреть решения компилятора:
go build -gcflags='-m' main.go
# main.go:5:2: moved to heap: x
# main.go:10:6: can inline stackAllocЭтап 5: SSA — промежуточное представление
После тайп-чекинга код преобразуется в SSA (Static Single Assignment) форму. В SSA каждая переменная присваивается ровно один раз:
// Исходный код:
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 упрощает оптимизации. Например, константное распространение:
// Исходный код:
x := 2
y := x * 3
z := y + 1 // компилятор видит: z = 2*3+1 = 7
// После оптимизации SSA:
z := 7 // весь расчёт заменён константойПосмотреть SSA своей программы:
GOSSAFUNC=add go build main.go
# Откроет ssa.html в браузере с полным SSA-графомЭтап 6: Ассемблер — Plan 9
Go использует синтаксис ассемблера Plan 9 (из ОС Bell Labs). Он отличается от Intel и AT&T синтаксиса.
func add(a, b int) int { return a + b }Ассемблер (Go 1.17+, amd64, register-based ABI):
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Посмотреть ассемблер своей программы:
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 |
R8–R11 | аргументы 6–9 |
SP | stack pointer |
BP | base pointer (frame pointer) |
До Go 1.17 все аргументы передавались через стек. После — через регистры. Производительность выросла на ~5-15%.
Этап 7: Линкер
Линкер собирает .o объектные файлы и создаёт финальный бинарник.
В финальный бинарник Go всегда включает рантайм — сборщик мусора, планировщик горутин, рефлексию. Поэтому минимальный hello world весит ~1.8 МБ.
$ 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 КБ:
// Горутины дёшевы именно из-за малого стека
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)
Инструменты для исследования
# Ассемблер
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-библиотек. Они встроены в стандартный тулчейн — устанавливать ничего не нужно.