10. ABI, Calling Convention и стек вызовов
В предыдущей лекции 9. Базовый Ассемблер. Как CPU видит код мы заглянули в святая святых машины — дизассемблированный листинг компилятора — и увидели, как процессор перекладывает биты между регистрами. Но как только программа становится сложнее одного файла и одна функция вызывает другую, перед инженерами встает фундаментальная проблема коммуникации:
Откуда вызываемая функция узнает, где лежат переданные ей аргументы? В регистре AX? В регистре BX? Или на вершине стека памяти? Куда положить возвращаемый результат, чтобы вызывающая функция (Caller) смогла его прочитать? И кто обязан восстанавливать состояние регистров и очищать память после выхода из функции?
Сам кремниевый процессор не знает ответов на эти вопросы. Железо абсолютно нейтрально: для него регистры — это просто безымянные 64-битные ячейки памяти, а стек — обычные адреса в оперативной памяти.
Порядок взаимодействия между функциями, модулями и даже разными языками программирования (например, при связке Go и C; подробности вызова C из Go — через cgo с переходом на стек g0 — выходят за рамки этой лекции) устанавливается на уровне компилятора и операционной системы. Этот свод строгих правил называется ABI (Application Binary Interface) и Конвенцией вызова (Calling Convention).
API против ABI
Все разработчики знают, что такое API (Application Programming Interface). В Go сигнатура func Process(id int, data[]byte) error — это API. Это контракт на уровне исходного кода. Если типы аргументов и возвращаемого значения совпадают, компилятор счастлив и успешно собирает проект.
Но когда компилятор превращает Go-код в машинные биты, имена аргументов id и data испаряются, а типы int, slice и error превращаются в раскладку байтов (при этом рантайм сохраняет метаданные типов для интерфейсов, reflect и GC). Процессор оперирует лишь электрическими потенциалами на кремниевых шинах и номерами регистров.
ABI (Application Binary Interface) — это контракт на уровне скомпилированного бинарного кода. Он регламентирует:
- Размер и выравнивание типов данных: сколько байт в памяти занимает тип
int(8 байт на 64-битной машине), какова внутренняя структура слайсаslice(ровно 24 байта: указатель на массив, длинаlen, вместимостьcap); - Конвенцию вызова (Calling Convention): в каких именно регистрах или по каким смещениям стека передаются входные параметры и где забирать возвращаемый результат;
- Разделение ответственности за регистры: какие регистры обязана сохранить вызываемая сторона (Callee-saved), а какие вызывающая сторона должна сохранить сама, если они ей еще нужны (Caller-saved);
- Формат исполняемых файлов и системных вызовов: структура заголовков ELF (в Linux) или Mach-O (в macOS), и номер регистра для передачи номера системного вызова ядра (
RAX).
flowchart TD
subgraph Levels ["Уровни абстракции контрактов"]
Source["Исходный код Go<br>func Process(id int, data []byte) error"]
API["<b>API (Контракт компилятора)</b><br>Типы, имена функций, пакеты, синтаксис"]
Machine["Машинный код<br>MOVQ AX, BX; CALL 0x401080; RET"]
ABI["<b>ABI (Бинарный контракт)</b><br>Регистры (AX, BX...), смещения стека, выравнивание 8B"]
end
Source --> API
API --> Machine
Machine --> ABI
classDef src fill:#131d33,stroke:#38bdf8,stroke-width:1px,color:#f8fafc;
classDef contract fill:#1e293b,stroke:#6366f1,stroke-width:1px,color:#f8fafc;
class Source,Machine src;
class API,ABI contract;
Стек вызовов (Call Stack) и Stack Frame
Прежде чем исследовать конвенции вызовов, разберем анатомию пространства, в котором живут функции во время работы.
Каждая горутина в Go при старте получает собственный непрерывный блок памяти — Стек вызовов (Call Stack).
Исторически на архитектуре x86 стек растет вниз — от высоких адресов оперативной памяти к низким.
Каждый раз, когда функция A вызывает функцию B, на вершине стека формируется изолированный фрагмент памяти — Стековый фрейм (Stack Frame) функции B.
Границы активного фрейма контролируются двумя ключевыми регистрами процессора:
RSP/SP(Stack Pointer — Указатель стека): хранит адрес текущей вершины стека (самый младший занятый адрес в памяти);RBP/BP(Base Pointer / Frame Pointer — Базовый указатель): хранит адрес начала (базы) текущего фрейма.
flowchart TD
HighMem["Высокие адреса памяти (Старт стека)"] --> CallerFrame["Фрейм вызывающей функции (Caller Frame)<br>Локальные переменные функции A"]
CallerFrame --> SpillArgs["Аргументы для функции B, переданные через стек<br>(stack-assigned: не поместились в регистры)"]
SpillArgs --> RetAddr["Адрес возврата (Return Address)<br>Записывается аппаратно инструкцией CALL"]
RetAddr --> SavedBP["Сохраненный RBP предыдущего фрейма<br>(Связывает цепочку вызовов для отладчиков и стек-трейсов)"]
SavedBP --> Locals["Локальные переменные функции B<br>(Выделяются сдвигом: SUBQ $N, SP)"]
Locals --> LowMem["Младшие адреса памяти<br>Текущий физический указатель SP (RSP)"]
classDef mem fill:#131d33,stroke:#38bdf8,stroke-width:1px,color:#f8fafc;
classDef frame fill:#1e293b,stroke:#6366f1,stroke-width:1px,color:#f8fafc;
class HighMem,LowMem mem;
class CallerFrame,SpillArgs,RetAddr,SavedBP,Locals frame;
Как выделяется и освобождается фрейм?
Обратите внимание на поразительную скорость работы стека:
-
Чтобы выделить память под фрейм локальных переменных размером 64 байта, процессору достаточно выполнить одну инструкцию вычитания:
Assembly SUBQ $64, SP -
Чтобы мгновенно уничтожить фрейм при завершении функции, процессор выполняет обратную операцию сложения, а затем возврат:
Assembly ADDQ $64, SP RET(Сам
RETлишь извлекает адрес возврата со стека и передаёт туда управление; кадр освобождает именно сдвигSP.)
Выделение и освобождение кадра на стеке — это сдвиг указателя: порядка одного такта CPU на саму инструкцию. В отличие от кучи (Heap), на стеке нет фрагментации и нет блокировок аллокатора, а освобождение не требует работы сборщика мусора. (Но не стоит путать: GC всё равно сканирует стеки горутин в поисках указателей на объекты в куче, а стек может расти — об этом см. статью про ассемблер.)
Эволюция Calling Convention в Go
В истории развития языка Go произошла грандиозная внутренняя революция, разделившая его историю на «до» и «после».
ABI0: Классический (медленный) стек
Вплоть до релиза Go 1.17 компилятор Go использовал конвенцию вызова ABI0.
В ней действовало железное правило: абсолютно все аргументы функций и возвращаемые значения передавались строго через оперативную память (стек).
Представьте простой вызов функции сложения двух чисел:
func add(a, b int) int {
return a + b
}
// Вызов:
res := add(10, 20)
Что физически происходило внутри процессора при вызове add(10, 20) по правилам ABI0?
- Вызывающая функция брала число
10и записывала его в память по адресу стека:MOVQ $10, 0(SP). - Брала число
20и записывала его в память:MOVQ $20, 8(SP). - Выполняла инструкцию перехода
CALL. - Функция
addсчитывала10из памяти обратно в регистр ALU:MOVQ 0(SP), AX. - Считывала
20из памяти:MOVQ 8(SP), BX. - Складывала их:
ADDQ BX, AX. - Записывала сумму
30обратно в память стека:MOVQ AX, 16(SP). - Выполняла возврат
RET. - Вызывающая функция читала результат
30из памяти:MOVQ 16(SP), DX.
Каждый чих сопровождался записью и чтением памяти! Даже с учетом кэша L1 это создавало паразитный I/O трафик на внутренних шинах кристалла.
Почему авторы Go пошли на это?
Создатели языка сделали это ради простоты и надежности (так это обычно объясняют; прямых заявлений авторов в проектной документации мало):
- Стек-ориентированный ABI сделал сборщик мусора (GC) предельно надежным: GC точно знал, где лежат все указатели — на стеке, с фиксированными смещениями;
- Генератор кода для кросс-компиляции на новые архитектуры писался проще, ведь правила стека везде одинаковы.
ABIInternal: Регистровая революция (Go 1.17+)
К 2021 году стало очевидно, что цена стекового соглашения слишком высока. Инженеры Austin Clements и Cherry Mui спроектировали и внедрили новый стандарт — ABIInternal (Register-based Calling Convention).
Начиная с Go 1.17, компилятор мапит параметры напрямую на физические регистры процессора:
Правила ABIInternal в Go (для архитектуры amd64):
- Выделено 9 целочисленных регистров для передачи аргументов и указателей:
RAX,RBX,RCX,RDI,RSI,R8,R9,R10,R11. - Выделено 15 векторных регистров для чисел с плавающей точкой (
float):
X0–X14. - Аргументы распределяются по этим регистрам строго слева направо.
- Возвращаемые значения передаются в этих же регистрах!
- Callee-saved регистров в Go нет: ABIInternal не требует от вызываемой функции сохранять какие-либо регистры, все регистры считаются caller-saved. Часть регистров зарезервирована:
R14хранит указатель на текущую горутинуg,X15всегда равен нулю,RDXиспользуется как контекст замыкания,R12/R13— временные регистры компилятора (R15— тоже, кроме динамической линковки).
Теперь при вызове add(10, 20):
- Число
10помещается в регистрAX; - Число
20помещается в регистрBX; - Процессор выполняет
CALL; - Функция делает
ADDQ BX, AXза 1 такт CPU; - Процессор делает
RET.
Результат30уже находится в регистреAX! Ни одного лишнего обращения к оперативной памяти.
Этот архитектурный апгрейд принес всем Go-приложениям в мире бесплатное ускорение (по официальным заметкам к релизу Go 1.17 — в среднем около 5%, зависит от программы) просто при обновлении версии Go без изменения единой строчки кода.
Mechanical Sympathy: Ловушки регистров
Аппаратные ресурсы кристалла ограничены законами физики. В архитектуре x86-64 для передачи аргументов компилятор Go выделил ровно 9 целочисленных регистров.
Что произойдет, если ваша функция принимает большую структуру по значению?
type User struct {
ID int
RoleID int
Age int
Score int
Status int
Flags int
DepartID int
CityID int
RegionID int
ZipCode int // 10-е поле типа int!
}
// Передача структуры по значению
func ProcessUser(u User) {
// ...
}
При передаче User по значению компилятор подсчитывает: для 10 полей-int (10 × 8 байт = 80 байт) нужно 10 целочисленных регистров-аргументов. В пуле ABIInternal для amd64 их только 9 (RAX, RBX, RCX, RDI, RSI, R8, R9, R10, R11). Структура не вмещается полностью.
Таким образом, для нашей User со всеми 10 полями компилятор Go полностью передает структуру через стек:
flowchart LR
subgraph CallerStack ["Стек caller'а: все 10 полей (80 байт)"]
F1["Field 1 (ID) -> 0(SP)"]
F2["Field 2 (RoleID) -> 8(SP)"]
F3["..."]
F10["Field 10 (ZipCode) -> 72(SP)"]
end
subgraph Note ["Регистры аргументов"]
R["RAX..R11: свободны,\nне использованы для u"]
end
classDef slow fill:#1e293b,stroke:#f59e0b,stroke-width:1px,color:#f8fafc;
classDef note fill:#1e293b,stroke:#475569,stroke-width:1px,color:#94a3b8;
class CallerStack,F1,F2,F3,F10 slow;
class Note,R note;
Динамическая диспетчеризация (Интерфейсы)
Понимание ABI дает ответ на еще один классический вопрос оптимизации: почему вызовы методов через интерфейсы работают медленнее прямых вызовов функций?
Сравним два вызова:
-
Прямой вызов:
Go user.Save()Адрес функции
User.Saveизвестен компилятору на этапе компиляции. В машинный код зашивается прямая инструкция перехода:CALL 0x402100(Direct Call). Процессор заранее знает целевой адрес инструкции и не простаивает. -
Вызов через интерфейс:
Go repository.Save()Переменная
repository— это интерфейс. На этапе компиляции конкретный тип реализации неизвестен (это может бытьPostgresRepo,MockRepoилиRedisRepo).
На уровне ABI интерфейс Go кодируется 16-байтной структурой iface:
- Указатель
tabна структуруitab(метаданные типа и таблица функций); - Указатель
dataна реальный экземпляр объекта в памяти.
sequenceDiagram
autonumber
participant CPU as Регистры CPU
participant Interface as Интерфейс iface (16 байт)
participant Itab as Таблица itab в памяти
participant Code as Метод PostgresRepo.Save()
CPU->>Interface: 1. Чтение указателя itab
CPU->>Itab: 2. Разыменование указателя itab (Pointer Dereference, чтение RAM/L1)
Itab-->>CPU: 3. Извлечение адреса функции Save из таблицы fun
CPU->>Code: 4. Косвенный вызов: CALL *AX (Indirect Call)
Note over CPU, Code: Накладные расходы: 2 чтения памяти + риск сбоя BTB
Чтобы выполнить repository.Save(), процессору приходится совершить многоступенчатый танец:
- Прочитать адрес
itabиз интерфейса; - Сделать разыменование указателя (Pointer Dereferencing) и залезть в память структуры
itab; - Найти смещение нужного метода
Saveв массиве указателей на функции; - Выполнить косвенный вызов (Indirect Call):
CALL *AX.
Хотя сами аргументы метода по-прежнему передадутся через регистры (ABIInternal; itab и data тоже приходят в регистрах), косвенный вызов создает две проблемы:
- Лишние чтения памяти (потенциальные промахи кэша);
- Удар по предсказателю ветвлений процессора (Branch Target Buffer, BTB): если в цикле через один и тот же интерфейс вызываются разные типы, процессор не может угадать адрес прыжка и конвейер останавливается. Современные предикторы косвенных переходов (в том числе ITTAGE-подобные) справляются с этим лучше, чем кажется, так что «катастрофой» это бывает не всегда. А если компилятор может определить конкретный тип статически — или с помощью PGO (Profile-Guided Optimization) — он девиртуализирует вызов и даже сможет его заинлайнить.
Итог
- ABI (Application Binary Interface): бинарная конституция программы. Определяет соглашения о вызовах, раскладку регистров, выравнивание структур в памяти и формат передачи аргументов.
- Стек вызовов и фрейм: стек растет вниз (к младшим адресам). Границы фрейма определяются регистрами
BP(база фрейма) иSP(вершина стека). Память под локальные переменные выделяется мгновенно — одной инструкцией вычитанияSUBQ $N, SP. - Эпоха ABI0 (до Go 1.17): все аргументы и возвращаемые значения передавались через стек памяти, создавая избыточную нагрузку на кэш L1.
- Эпоха ABIInternal (Go 1.17+): революция регистровых вызовов. Аргументы (до 9 целых чисел и до 15
float) и мульти-возвраты передаются напрямую через регистры CPU, обеспечив ускорение кода примерно на 5% (по оценке авторов Go 1.17). - Передача структур на стеке: если составной аргумент (структура) не умещается целиком в доставшихся ему свободных регистрах, Go ABIInternal передает всю структуру целиком через стек — без частичного размещения полей в регистрах. Это гарантирует корректную работу при взятии адреса
&argвнутри вызываемой функции. Большие структуры выгоднее передавать по указателю*User. - Вызовы через интерфейсы: требуют чтения структуры
itabв памяти и косвенного прыжка (CALL *AX), что, как правило, не позволяет заинлайнить вызов (если компилятор не смог девиртуализировать его) и создает дополнительную нагрузку на Branch Target Buffer.
Мы изучили, как процессор последовательно исполняет инструкции, прыгает по адресам памяти через CALL и возвращается через RET. Но почему же современные процессоры с тактовой частотой 4–5 ГГц работают с такой поразительной скоростью? Неужели они действительно ждут окончания предыдущей команды, прежде чем приступить к следующей?
В следующей лекции мы откроем главную тайну микроархитектуры — превращение процессора в непрерывный промышленный конвейер Генри Форда: 11. Конвейеризация. Почему CPU исполняет несколько инструкций одновременно.