Перейти к содержанию

Стековые фреймы и вызов функций

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

Инструкции call и ret

Вызов функции в x86-64 выполняется инструкцией call, а возврат — инструкцией ret.

call function       # эквивалент: push return_address; jmp function
call *%rax          # косвенный вызов по адресу в регистре
ret                 # эквивалент: pop %rip (прыгнуть на адрес, снятый со стека)

Механика проста: call помещает на стек адрес следующей после себя инструкции (адрес возврата), затем передаёт управление целевой функции. ret снимает этот адрес со стека и прыгает туда.

До call                         После call (до пролога)         После пролога (push rbp; mov rbp,rsp)

   высокие адреса                  высокие адреса                  высокие адреса
   ┌──────────────────┐            ┌──────────────────┐            ┌──────────────────┐
   │  ...caller...    │            │  ...caller...    │            │  ...caller...    │
   ├──────────────────┤            ├──────────────────┤            ├──────────────────┤
   │  arg8            │            │  arg8            │            │  arg8            │ rbp+24
   ├──────────────────┤            ├──────────────────┤            ├──────────────────┤
   │  arg7            │            │  arg7            │            │  arg7            │ rbp+16
   ├──────────────────┤            ├──────────────────┤            ├──────────────────┤
rsp│                  │   call ──▶ │  return address  │ ◀── rsp    │  return address  │ rbp+8
   └──────────────────┘            └──────────────────┘            ├──────────────────┤
                                                                   │  saved rbp       │ ◀── rbp
                                                                   ├──────────────────┤
                                                                   │  local_var1      │ rbp-8
                                                                   ├──────────────────┤
                                                                   │  local_var2      │ rbp-16
                                                                   ├──────────────────┤
                                                              rsp ▶│  ...             │
                                                                   └──────────────────┘
                                                                      низкие адреса

Стековый фрейм

Стековый фрейм (stack frame) — это участок стека, отведённый для одного конкретного вызова функции. Он содержит:

  • адрес возврата (помещён инструкцией call);
  • сохранённый rbp предыдущего фрейма;
  • локальные переменные функции;
  • сохранённые callee-saved регистры;
  • аргументы сверх шести (если они не поместились в регистры).
   высокие адреса
   ┌─────────────────────────────────────────┐
   │  arg8, ...                              │  rbp+24   (8-й аргумент и далее)
   ├─────────────────────────────────────────┤
   │  arg7                                   │  rbp+16   (7-й аргумент)
   ├─────────────────────────────────────────┤
   │  return address                         │  rbp+8    (помещён инструкцией call)
   ├─────────────────────────────────────────┤
   │  saved rbp  ◀────────────────────── rbp │  rbp+0    (сохранённый frame pointer caller'а)
   ╞═════════════════════════════════════════╡  ← граница фрейма
   │  local_var1                             │  rbp-8
   ├─────────────────────────────────────────┤
   │  local_var2                             │  rbp-16
   ├─────────────────────────────────────────┤
   │  saved rbx  (callee-saved, если нужен)  │  rbp-24
   ├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
   │  red zone (128 байт, только user mode)  │  rsp-1 .. rsp-128
   └─────────────────────────────────────────┘  ◀── rsp
   низкие адреса

Цепочка сохранённых rbp образует linked list фреймов: каждый saved rbp указывает на saved rbp вызывающего. GDB и perf обходят эту цепочку при построении stack trace.

Регистры rsp и rbp

rsp (Stack Pointer) всегда указывает на вершину стека — на байт, по которому будет произведена следующая запись при push или следующее чтение при pop. Стек растёт в сторону убывания адресов: push уменьшает rsp на 8, pop увеличивает на 8.

rbp (Base Pointer, или Frame Pointer) указывает на фиксированную точку внутри текущего фрейма. Относительно него удобно адресовать локальные переменные и аргументы без необходимости отслеживать изменения rsp.

Пролог и эпилог функции

Стандартный пролог функции:

func:
    push %rbp               # сохранить старый rbp на стек
    movq %rsp, %rbp         # rbp = rsp (установить базу фрейма)
    subq $32, %rsp          # выделить 32 байта для локальных переменных

После пролога rbp-8, rbp-16, rbp-24, rbp-32 — слоты для локальных переменных. rbp+8 — адрес возврата. rbp+16, rbp+24 и т.д. — аргументы, переданные на стеке (если их больше шести).

Стандартный эпилог:

    movq $0, %rax           # подготовить возвращаемое значение
    leave                   # эквивалент: movq %rbp, %rsp; popq %rbp
    ret                     # вернуться к вызывающему

Инструкция leave восстанавливает rsp и rbp одним действием.

Передача аргументов и возврат значений

В Linux x86-64 действует соглашение о вызовах System V AMD64 ABI:

Аргументы функции:

Позиция Регистр
1-й целочисленный / указатель rdi
2-й rsi
3-й rdx
4-й rcx
5-й r8
6-й r9
7-й и далее стек (справа налево)
float-аргументы xmm0xmm7
void func(int a, int b, int c, int d, int e, int f, int g);
/*         rdi  rsi  rdx  rcx   r8   r9  [rsp+8] */

Возвращаемое значение:

  • целое или указатель: rax;
  • 128-битное: rdx:rax (старшие биты в rdx);
  • float/double: xmm0.

Пример функции сложения:

long add(long a, long b) { return a + b; }
add:
    movq %rdi, %rax         # rax = a
    addq %rsi, %rax         # rax += b
    ret

Callee-saved и caller-saved регистры

Регистров мало (16 целочисленных), а вложенных вызовов много, и каждый хочет ими пользоваться. Чтобы значения не затирались на каждом call, ABI делит регистры на две группы — по тому, кто отвечает за их сохранность через вызов.

Группа Регистры Кто сохраняет Контракт
caller-saved (volatile) rax, rcx, rdx, rsi, rdi, r8r11, xmm0xmm15 вызывающий вызванная функция вправе затирать их свободно; нужно значение после call — сохрани его сам до вызова
callee-saved (non-volatile) rbx, rbp, r12r15, rsp вызванная функция если функция их использует, она обязана восстановить исходные значения до ret; вызывающий вправе считать их неизменными

Как запомнить, кто за что отвечает:

  • caller-saved — регистры, которые call разрешено разрушить. Они же используются для аргументов (rdir9) и возврата (rax) — то есть заведомо «рабочие». Если вызывающему (caller) нужно их значение после вызова, он сам кладёт его на стек до call и снимает после.
  • callee-saved — регистры, сохранность которых call обещает. Если вызванной (callee) функции они нужны под свои нужды, она в прологе кладёт их на стек, а в эпилоге восстанавливает (отсюда push %rbxpop %rbx вокруг тела).

rsp стоит особняком: он не «сохраняется» в обычном смысле, а восстанавливается самой механикой call/ret и эпилога — после возврата вершина стека та же, что была до вызова. rbp при использовании frame pointer'а — callee-saved и указывает на базу текущего кадра (см. пролог/эпилог выше).

Это деление напрямую объясняет, почему переключение контекста сохраняет только callee-saved + rsp: для вызывающего кода swapcontext/jump_fcontext/fiber_switch — это обычный вызов функции, а значит caller-saved он и так уже считает затёртыми и сохранил сам, если они были нужны. Switch'у остаётся сберечь ровно rbx, rbp, r12r15 и rsp (см. Userspace context switching).

Выравнивание стека и red zone

Согласно System V AMD64 ABI, стек должен быть выровнен по 16 байт в момент выполнения инструкции call. Это значит, что внутри функции (после call, которая кладёт на стек 8-байтовый адрес возврата) rsp выровнен по 8, а не по 16. Компилятор учитывает это и при необходимости добавляет выравнивающий отступ:

func:
    push %rbp            # rsp -= 8 (был выровнен по 16 до call)
    movq %rsp, %rbp      # rsp теперь выровнен по 8
    subq $24, %rsp       # выделяем 24 байта локальных + 8 pad = 32 (кратно 16)

Red zone — 128 байт ниже текущего rsp, которые ABI гарантирует неприкосновенными от прерываний и обработчиков сигналов (только в user mode). Это позволяет листовым функциям (leaf functions) использовать память ниже rsp без явного вычитания из rsp:

leaf_func:
    # Нет push/sub rsp — используем red zone напрямую
    movq %rdi, -8(%rsp)     # временное хранение в red zone
    movq %rsi, -16(%rsp)
    # ... вычисления ...
    ret

Ядро Linux при обработке прерываний не соблюдает red zone, поэтому код ядра компилируется с -mno-red-zone.

Флаг компилятора -fno-omit-frame-pointer

При включённых оптимизациях GCC по умолчанию использует -fomit-frame-pointer: он не сохраняет rbp, освобождая его как регистр общего назначения и убирая несколько инструкций из пролога/эпилога.

Флаг -fno-omit-frame-pointer отключает эту оптимизацию и заставляет компилятор всегда сохранять frame pointer.

Без frame pointer (-fomit-frame-pointer):

func:
    subq $32, %rsp          # только выделяем стек, rbp свободен
    # ... код ...
    addq $32, %rsp
    ret

С frame pointer (-fno-omit-frame-pointer):

func:
    push %rbp
    movq %rsp, %rbp
    subq $32, %rsp
    # ... код ...
    leave
    ret

Frame pointer нужен для:

  1. Отладки: GDB использует цепочку сохранённых rbp для восстановления стека вызовов (backtrace);
  2. Профилирования: perf и perf record трассируют стек через frame pointer;
  3. Корректной работы некоторых механизмов обработки исключений и сигналов.
gcc -g -fno-omit-frame-pointer -O2 prog.c -o prog   # отладка + оптимизация
gcc -O2 prog.c -o prog                               # только оптимизация

Связанные темы

Источники

  • System V Application Binary Interface AMD64 Supplement: https://gitlab.com/x86-psABIs/x86-64-ABI
  • man 1 gcc — флаги -fomit-frame-pointer, -fno-omit-frame-pointer, -mno-red-zone
  • Intel 64 and IA-32 Architectures Software Developer's Manual, Vol. 2 — описание call, ret, push, pop, leave