6. ABIInternal и ABI0. Как Go вызывает функции
В завершении предыдущей лекции 5. Go assembler и внутренний ассемблерный синтаксис мы обратили внимание на характерную деталь: сгенерированный ассемблерный код считывал входящие аргументы функции прямо из оперативной памяти, используя относительные смещения стекового указателя SP.
Для системного программиста на C или Rust это выглядело архитектурным анахронизмом: зачем тратить машинные циклы на обращение к стековой памяти, когда кристалл современного процессора оснащен набором сверхбыстрых аппаратных регистров?
Долгие годы именно это являлось главным скрытым узким местом в производительности рантайма Go. Однако с выходом релиза Go 1.17 произошла тектоническая эволюция, которая мгновенно и абсолютно бесплатно ускорила все существующие Go-приложения в мире в среднем на 5–12% без изменения единой строчки прикладного кода. Это фундаментальное улучшение заключалось в отказе от устаревшей стековой конвенции и переходе на регистровый бинарный интерфейс — ABIInternal.
Что такое ABI?
Если высокоуровневый API (Application Programming Interface) определяет, какие функции, типы и методы доступны программисту на уровне исходного кода, то ABI (Application Binary Interface) задает строгий контракт взаимодействия на уровне машинных инструкций и кремния.
Центральным ядром ABI является Конвенция вызовов (Calling Convention). Она дает однозначные ответы на аппаратные вопросы:
- Куда вызывающая функция (
caller) обязана поместить аргументы — во фрейм стека в памяти или в конкретные физические регистры процессора? - Где вызываемая функция (
callee) должна ожидать эти аргументы? - В каких регистрах или областях памяти размещаются множественные возвращаемые значения?
- Кто отвечает за очистку стекового кадра и сохранение регистров общего назначения (caller-saved vs callee-saved)?
ABI0. Наследие Plan 9 (До Go 1.17)
С момента своего рождения в 2009 году и вплоть до версии Go 1.16 включительно язык использовал архитектурную конвенцию, которая сегодня официально именуется ABI0.
Главный принцип ABI0 был предельно консервативен: все аргументы и все возвращаемые значения передаются исключительно через оперативную память (стек горутины).
Когда функция A вызывала функцию B, разворачивался следующий сценарий:
- Функция
Aвыделяла дополнительное место в своем стековом кадре; - Инструкциями
MOVQфункцияAкопировала значения всех аргументов из регистров в память стека; - Выполнялась процессорная инструкция
CALL B; - Функция
Bсчитывала аргументы обратно из памяти стека в свои рабочие регистры; - Выполнив вычисления, функция
Bзаписывала возвращаемый результат обратно в стековую память функцииA; - По инструкции
RETуправление возвращалось вA, иAчитала результат из своего стека.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph ABI_ZERO ["ABI0 — Стековая конвенция (Legacy)"]
CALLER0["Функция A"]:::system -->|"Пишет аргументы<br>в память (MOVQ)"| STACK["Стек горутины (L1 / RAM)"]
STACK -->|"Читает аргументы<br>из памяти (MOVQ)"| CALLEE0["Функция B"]
CALLEE0 -->|"Пишет результат<br>в память (MOVQ)"| STACK
end
Mechanical Sympathy. Почему ABI0 — это медленно?
Вспомним физику кремния и иерархию задержек доступа к памяти:
- Чтение значения из регистра CPU занимает 0–1 такт;
- Чтение из кэша L1 занимает ~3–4 такта;
- Доступ к оперативной памяти (DRAM) требует 100–300 тактов.
Даже с учетом того, что вершины стеков активных горутин почти всегда прогреты и удерживаются в процессорном кэше L1, стековая конвенция ABI0 заставляла CPU совершать колоссальный объем бессмысленной пересылки данных: Регистр -> L1-кэш -> Регистр. Это порождало лишний трафик шины памяти, раздувало размер бинарного кода инструкциями MOV и сковывало параллелизм выполнения инструкций на конвейере CPU (Instruction-Level Parallelism).
ABIInternal. Революция регистров (Go 1.17+)
Начиная с Go 1.17, компилятор перешел на регистровую конвенцию вызовов — ABIInternal.
Ее философия проста и бескомпромиссна: при вызове функции компилятор передает аргументы и забирает возвращаемые результаты напрямую через физические аппаратные регистры процессора, минуя оперативную память.
Для архитектуры amd64 (x86-64) спецификация ABIInternal зарезервировала жестко определенный пул регистров:
- 9 целочисленных регистров:
RAX,RBX,RCX,RDI,RSI,R8,R9,R10,R11; - 15 регистров для чисел с плавающей точкой:
X0–X14.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph ABI_INT ["ABIInternal — Регистровая конвенция"]
CALLER1["Функция A"]:::system -->|"Оставляет данные<br>в регистрах"| REGS["Регистры CPU<br>RAX, RBX, RCX, RDI..."]
REGS -->|"Берет данные<br>из регистров"| CALLEE1["Функция B"]
CALLEE1 -->|"Результат в<br>регистрах"| REGS
end
В рамках ABIInternal тривиальная функция Add(a, b int) больше ни на такт не прикасается к стековой памяти. Аргумент a передается в регистре RAX, аргумент b — в RBX. Функция выполняет единственную инструкцию ADDQ RBX, RAX и оставляет сумму в RAX. Вызов функции превращается в мгновенную переброску данных внутри вычислительного ядра.
Структуры, интерфейсы и слайсы в ABIInternal
Как регистровая конвенция обходится со сложными типами данных? В Go любая составная структура при передаче по значению «уплощается» (flattening / unpack). Компилятор декомпозирует структуру на примитивные поля и распределяет каждое поле по свободным регистрам:
- String (см. 34. Внутреннее устройство string) состоит из двух 8-байтных полей (указатель
Dataи длинаLen). Строка занимает 2 регистра (например,RAXиRBX). - Slice (см. 29. Внутреннее устройство slice) состоит из трех полей (указатель
Data, длинаLenи вместимостьCap). Срез занимает 3 регистра (RAX,RBX,RCX). - Interface (см. 35. Интерфейсы под капотом. eface и iface) состоит из двух указателей (метаданные типа
_typeилиitabи указатель на данныеdata). Интерфейс занимает 2 регистра.
Почему оставили два ABI?
Возникает логичный вопрос: почему инженеры Go назвали конвенцию ABIInternal, сохранили старый ABI0 и не удалили стековый механизм навсегда?
Ответ кроется в строгой обратной совместимости с рукописным ассемблерным кодом.
В экосистеме Go существуют тысячи сторонних пакетов и фундаментальных библиотек (криптография, математические вычисления, сжатие данных), содержащих файлы .s на ассемблере. Весь этот код создавался десятилетиями в жестком расчете на конвенцию ABI0 (ожидая аргументы строго на стеке). Прямое изменение конвенции разрушило бы всю мировую инфраструктуру Go.
Для решения этой дилеммы команда Go внедрила прозрачный механизм ABI-wrapper'ов (адаптеров):
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
GOKOD["Go-код (ABIInternal)<br>Аргументы в регистрах"]:::process --> WRAP["ABI-Wrapper (компилятор)<br>Сохраняет регистры на стек"]
WRAP --> ASM["Ручной ассемблер (ABI0)<br>Читает стек"]
ASM --> WRAP
WRAP -->|"Загружает результат со стека в регистры"| GOKOD
- Весь скомпилированный код на Go по умолчанию использует сверхбыстрый
ABIInternal. - Если Go-код вызывает рукописную функцию на ассемблере, компилятор на лету подставляет невидимый переходник (ABI wrapper).
- Этот адаптер выгружает аргументы из аппаратных регистров на стек горутины, имитируя окружение ABI0, передает управление ассемблерному коду, а при выходе забирает результат со стека и возвращает его в регистры.
Таким образом, наследие старого кода сохраняет работоспособность, а весь чистый код на Go исполняется на предельной скорости кремния.
Итог
- Конвенция вызовов (Calling Convention) регламентирует правила передачи аргументов и возврата значений на физическом уровне процессора.
- ABI0 — историческая конвенция Go, основанная на наследии Plan 9, передававшая данные исключительно через оперативную память стека, что вызывало избыточный трафик кэша L1.
- ABIInternal (начиная с Go 1.17) — современный регистровый стандарт, задействующий до 9 целочисленных регистров (
RAX–R11) и 15 регистров плавающей точки (X0–X14) на x86-64, устраняющий лишние операции с памятью. - Передача слишком длинного списка аргументов или тяжелых структур по значению приводит к вытеснению параметров на стек (Spill to Stack).
- Механизм ABI-wrapper'ов обеспечивает идеальную совместимость между быстрым современным Go-кодом и легаси-библиотеками с рукописным ассемблером ABI0.
Теперь мы досконально понимаем, как компилятор транслирует абстрактные алгоритмы в машинный код с аппаратной конвенцией вызовов. Этот код вместе со средой рантайма линковщик упаковывает в единый монолитный файл.
Что именно скрывается внутри скомпилированного исполняемого файла Go и почему даже простейшая программа «Hello World» весит несколько мегабайт?
Ответ ждет нас в следующей лекции: 7. Устройство бинарника Go.