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). Она дает однозначные ответы на аппаратные вопросы:

  1. Куда вызывающая функция (caller) обязана поместить аргументы — во фрейм стека в памяти или в конкретные физические регистры процессора?
  2. Где вызываемая функция (callee) должна ожидать эти аргументы?
  3. В каких регистрах или областях памяти размещаются множественные возвращаемые значения?
  4. Кто отвечает за очистку стекового кадра и сохранение регистров общего назначения (caller-saved vs callee-saved)?

ABI0. Наследие Plan 9 (До Go 1.17)

С момента своего рождения в 2009 году и вплоть до версии Go 1.16 включительно язык использовал архитектурную конвенцию, которая сегодня официально именуется ABI0.

Главный принцип ABI0 был предельно консервативен: все аргументы и все возвращаемые значения передаются исключительно через оперативную память (стек горутины).

Когда функция A вызывала функцию B, разворачивался следующий сценарий:

  1. Функция A выделяла дополнительное место в своем стековом кадре;
  2. Инструкциями MOVQ функция A копировала значения всех аргументов из регистров в память стека;
  3. Выполнялась процессорная инструкция CALL B;
  4. Функция B считывала аргументы обратно из памяти стека в свои рабочие регистры;
  5. Выполнив вычисления, функция B записывала возвращаемый результат обратно в стековую память функции A;
  6. По инструкции 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). Компилятор декомпозирует структуру на примитивные поля и распределяет каждое поле по свободным регистрам:


Почему оставили два 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
  
  1. Весь скомпилированный код на Go по умолчанию использует сверхбыстрый ABIInternal.
  2. Если Go-код вызывает рукописную функцию на ассемблере, компилятор на лету подставляет невидимый переходник (ABI wrapper).
  3. Этот адаптер выгружает аргументы из аппаратных регистров на стек горутины, имитируя окружение ABI0, передает управление ассемблерному коду, а при выходе забирает результат со стека и возвращает его в регистры.

Таким образом, наследие старого кода сохраняет работоспособность, а весь чистый код на Go исполняется на предельной скорости кремния.


Итог

  1. Конвенция вызовов (Calling Convention) регламентирует правила передачи аргументов и возврата значений на физическом уровне процессора.
  2. ABI0 — историческая конвенция Go, основанная на наследии Plan 9, передававшая данные исключительно через оперативную память стека, что вызывало избыточный трафик кэша L1.
  3. ABIInternal (начиная с Go 1.17) — современный регистровый стандарт, задействующий до 9 целочисленных регистров (RAX–R11) и 15 регистров плавающей точки (X0–X14) на x86-64, устраняющий лишние операции с памятью.
  4. Передача слишком длинного списка аргументов или тяжелых структур по значению приводит к вытеснению параметров на стек (Spill to Stack).
  5. Механизм ABI-wrapper'ов обеспечивает идеальную совместимость между быстрым современным Go-кодом и легаси-библиотеками с рукописным ассемблером ABI0.

Теперь мы досконально понимаем, как компилятор транслирует абстрактные алгоритмы в машинный код с аппаратной конвенцией вызовов. Этот код вместе со средой рантайма линковщик упаковывает в единый монолитный файл.

Что именно скрывается внутри скомпилированного исполняемого файла Go и почему даже простейшая программа «Hello World» весит несколько мегабайт?
Ответ ждет нас в следующей лекции: 7. Устройство бинарника Go.