20. Стек вызовов и стек кадра функции

Начнем с фундаментального механизма, без которого невозможно понимание ни работы процессора, ни планировщика горутин, ни даже обычного panic. Стек вызовов — это не абстракция языка, а физический регион памяти, управляемый напрямую CPU и ядром ОС.

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

Если оперативную память процесса представить как огромный склад, то куча (Heap) — это беспорядочные стеллажи долгосрочного хранения, а стек вызовов (Call Stack) — это блокнот инженера, где каждая новая страница кладется поверх предыдущей, а при завершении дела немедленно отрывается и выбрасывается. Здесь нет пауз на уборку мусора и нет поиска свободных блоков: память выделяется и освобождается ровно за один такт процессора.


📚 1. Что такое стек вызовов и зачем он нужен

Стек вызовов (Call Stack) — это область оперативной памяти, организованная по принципу LIFO (Last In, First Out — последним пришел, первым ушел). Его главная задача — отслеживать активные функции в программе и передавать управление между ними.

Когда функция вызывает другую, текущее состояние сохраняется в кадре функции (Stack Frame), указатель стека сдвигается, и управление передается новой функции. При возврате кадр удаляется, и управление возвращается туда, где оно было прервано.

В Go это особенно важно, потому что каждая горутина (g) имеет свой собственный стек, который динамически растет и сжимается. В отличие от C или Java, где стек треда фиксирован, Go использует сегментированный стек (исторически split stacks, а сегодня — динамически расширяемый непрерывный стек), что кардинально меняет подход к рекурсии, памяти и профилированию.

graph TD
    subgraph StackGrowth["Направление роста стека: от ВЫСОКИХ адресов к НИЗКИМ ↓"]
        direction TB
        FrameA["Кадр функции main()<br/>Локальные переменные, сохраненные регистры"]
        RetA["Адрес возврата в runtime.main"]
        FrameB["Кадр функции ProcessOrder()<br/>Локальные переменные OrderID, Amount"]
        RetB["Адрес возврата в main()"]
        FrameC["Кадр функции CalculateTax()<br/>Локальные переменные TaxRate, Result"]
        RetC["Адрес возврата в ProcessOrder()"]
    end

    FrameA --> RetA --> FrameB --> RetB --> FrameC --> RetC

    style FrameA fill:#1e293b,stroke:#818cf8,color:#fff
    style FrameB fill:#1e3a5f,stroke:#38bdf8,color:#fff
    style FrameC fill:#064e3b,stroke:#34d399,color:#fff
    style RetA fill:#334155,stroke:#64748b,color:#cbd5e1
    style RetB fill:#334155,stroke:#64748b,color:#cbd5e1
    style RetC fill:#334155,stroke:#64748b,color:#cbd5e1
  

🧱 2. Устройство кадра функции (Stack Frame)

Каждый кадр функции содержит строго упорядоченные данные. Расположение полей зависит от ABI (Application Binary Interface), но для x86_64 Linux (System V ABI) структура выглядит так:

classDiagram
    class StackFrame {
        [Высокие адреса]
        ... Аргументы функции (если не поместились в регистры)
        1. Адрес возврата (Return Address)
        2. Сохраненный указатель фрейма (Saved RBP)
        3. Сохраненные регистры (Callee-saved)
        4. Локальные переменные функции
        5. Выравнивание (Padding - 16 байт)
        [Низкие адреса - Вершина стека RSP]
    }
  
  1. Адрес возврата (Return Address) — куда прыгать после завершения функции. Инструкция CALL неявно заносит этот 8-байтный адрес в стек перед прыжком к коду вызываемой функции.
  2. Аргументы функции — передаются через регистры (RDI, RSI, RDX, RCX, R8, R9), а при их нехватке — через стек. В современном Go (начиная с Go 1.17+ с регистровым ABIInternal) аргументы также передаются в регистрах общего назначения (RAX, RBX, RCX, RDI, RSI, R8, R9 и др.), снижая нагрузку на стек.
  3. Локальные переменные — выделяются в нижней части кадра.
  4. Сохраненные регистры (Callee-saved) — RBX, RBP, R12-R15, RSP (в некоторых случаях). По соглашению вызовов, если функция намерена использовать эти регистры, она обязана сохранить их исходные значения в своем стеке и восстановить перед выходом.
  5. Выравнивание (Padding) — для кэш-линий (обычно 16 байт) и выравнивания стека к границе 16 байт (требуется для SSE/AVX инструкций).

⚙️ 3. Под капотом: x86_64, регистры и инструкция CALL/RET

На уровне процессора стек растет вниз (от высоких адресов к низким). Указатель на вершину стека хранится в регистре RSP. Рамка кадра часто отслеживается через RBP (Frame Pointer), и в Go на amd64 и arm64 frame pointer включён всегда: каждый кадр начинается с PUSHQ BP; MOVQ SP, BP, что позволяет внешним профилировщикам вроде Linux perf и eBPF разворачивать стек по цепочке RBP.

При вызове функции в Go компилятор генерирует ассемблерный код, который можно увидеть через go build -gcflags="-S". Базовый сценарий:

Assembly
; Вызов функции
CALL func_name          ; 1. PUSH адрес возврата в RSP
                        ; 2. JMP на func_name

; Внутри func_name
SUBQ $16, RSP           ; Выделяем 16 байт под локальные переменные
MOVQ $0, 8(RSP)         ; Инициализация локальной переменной
...
ADDQ $16, RSP           ; Освобождаем кадр
RET                     ; POP адрес возврата, JMP на него

Mechanical Sympathy: Каждый SUBQ/ADDQ — это запись в L1/L2 кэш. Если стек "прыгает" по разным кэш-линиям, возникает cache miss. Вот почему компиляторы стараются держать часто используемые переменные в регистрах. Переключение контекста внутри стека (вызов/возврат) обходится в ~5-15 тактов CPU, что значительно дешевле системного вызова, но критично в горячих путях.

Благодаря тому, что текущий стек почти всегда горяч и прогрет в L1d кэше процессора, чтение и запись локальных переменных занимают всего 1–3 такта (доли наносекунды).


🚀 4. Go и его уникальный подход к стеку

В C/C++ или Java размер стека треда фиксирован (обычно 1-8 МБ). Переполнение приводит к Stack Overflow или SIGSEGV.

В Go стек горутин (g.stack) динамический. Он стартует с малого размера (например, 2-4 КБ на 64-битной системе) и автоматически растет при вызове функций. Если места не хватает, планировщик выделяет новый, больший блок памяти и копирует туда данные. Это позволяет Go запускать сотни тысяч горутин без краша.

sequenceDiagram
    autonumber
    participant G as Горутина g (Стек 2 КБ)
    participant Prologue as Пролог функции (Ассемблер)
    participant Runtime as Рантайм Go (morestack)
    participant Alloc as Аллокатор стеков

    G->>Prologue: CALL DeepFunction()
    Prologue->>Prologue: Проверка: RSP <= g.stackguard0
    Note over Prologue: Стек почти полон!
    Prologue->>Runtime: Вызов runtime.morestack
    Runtime->>Alloc: Выделить непрерывный буфер x2 (4 КБ)
    Runtime->>Runtime: Скопировать все фреймы в новый стек
    Runtime->>Runtime: Скорректировать указатели (Stack Pointer Adjustment)
    Runtime->>Alloc: Вернуть старый буфер (2 КБ) в пул
    Runtime->>G: Продолжить выполнение DeepFunction в новом стеке
  

Escape Analysis: кто остается на стеке, а кто идет в кучу?

Компилятор Go проводит строгий статический анализ побега (Escape Analysis), решая, безопасно ли оставить объект в стековом фрейме.

Go
package main

import "fmt"

// Эта функция НЕ вызывает эскейп-анализ, локальные переменные остаются в стеке
func stackLocal() {
    var buffer [1024]byte
    fmt.Println(len(buffer))
}

// Эта функция ВЫЗЫВает эскейп-анализ. Указатель на локальную переменную
// уходит за пределы функции, поэтому компилятор переместит её в кучу.
func heapEscape() []byte {
    var buffer [1024]byte
    return buffer[:]
}

func main() {
    stackLocal()
    heapEscape()
}

Запустите go build -gcflags="-m" main.go и вы увидите:

Text
main.heapEscape: leaking params: buffer to heap

Компилятор видит, что срез buffer[:] возвращается вызывающей стороне. Если бы буфер остался на стеке функции heapEscape, то сразу после инструкции RET его память была бы перезаписана следующим вызовом, что привело бы к повреждению памяти (Use-After-Free). Поэтому компилятор перемещает его в управляемую кучу.


📊 5. Сравнение с другими языками и ловушки

Аспект C / C++ Java / .NET Go
Размер стека Фиксирован (указан при создании треда) Фиксирован (-Xss) Динамический, растет/сжимается автоматически
Переполнение SIGSEGV (краш процесса) StackOverflowError (runtime exception) fatal error: stack overflow (throw + goroutine dump, не panic)
Frame Pointer Часто отключен (-fomit-frame-pointer) Включен по умолчанию для отладки Включён всегда на amd64/arm64 (раньше был экспериментом GOEXPERIMENT=framepointer, теперь он не нужен)
Хвостовая рекурсия Опционально (-O2, -O3) Не поддерживается Не поддерживается

🎯 Итоги для Go-разработчика

  1. Стек — это не магия, а физическая память, регистры CPU и ABI. Понимание RSP/RBP и порядка передачи аргументов критично для отладки SIGSEGV и panic трассировок.
  2. Динамический непрерывный стек: Go использует динамический сегментированный стек для горутин. Это снимает проблему Stack Overflow на микроуровне, но вводит лимиты на рост (обычно ~1 ГБ).
  3. Escape Analysis решает судьбу аллокаций: Escape Analysis решает, что лежит в стеке, а что в куче. Понимание этого критично для производительности: аллокации в стеке бесплатны (просто сдвиг RSP), аллокации в куче требуют работы GC.
  4. Профилирование стеков: Фрейм-поинтер в Go на amd64/arm64 сохраняется всегда, поэтому perf и eBPF умеют разворачивать стеки Go по цепочке RBP. Для анализа стека внутри программы используйте runtime.Stack() и pprof.
  5. Защита памяти: В следующей статье мы разберем 21. Переполнение стека и защита памяти, где поговорим о guard pages, ASLR и том, как ОС физически защищает вас от случайного уничтожения стека.