Стековые фреймы и вызов функций¶
При каждом вызове функции процессор должен сохранить достаточно информации, чтобы после завершения функции вернуться к выполнению вызывающего кода. Эту информацию хранит стековый фрейм — область стека, выделяемая при входе в функцию и освобождаемая при выходе.
Инструкции call и ret¶
Вызов функции в x86-64 выполняется инструкцией call, а возврат — инструкцией ret.
call function # эквивалент: push return_address; jmp function
call *%rax # косвенный вызов по адресу в регистре
Механика проста: 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-аргументы | xmm0–xmm7 |
Возвращаемое значение:
- целое или указатель:
rax; - 128-битное:
rdx:rax(старшие биты вrdx); - float/double:
xmm0.
Пример функции сложения:
Callee-saved и caller-saved регистры¶
Регистров мало (16 целочисленных), а вложенных вызовов много, и каждый хочет ими пользоваться. Чтобы значения не
затирались на каждом call, ABI делит регистры на две группы — по тому, кто отвечает за их сохранность через вызов.
| Группа | Регистры | Кто сохраняет | Контракт |
|---|---|---|---|
| caller-saved (volatile) | rax, rcx, rdx, rsi, rdi, r8–r11, xmm0–xmm15 |
вызывающий | вызванная функция вправе затирать их свободно; нужно значение после call — сохрани его сам до вызова |
| callee-saved (non-volatile) | rbx, rbp, r12–r15, rsp |
вызванная функция | если функция их использует, она обязана восстановить исходные значения до ret; вызывающий вправе считать их неизменными |
Как запомнить, кто за что отвечает:
- caller-saved — регистры, которые
callразрешено разрушить. Они же используются для аргументов (rdi…r9) и возврата (rax) — то есть заведомо «рабочие». Если вызывающему (caller) нужно их значение после вызова, он сам кладёт его на стек доcallи снимает после. - callee-saved — регистры, сохранность которых
callобещает. Если вызванной (callee) функции они нужны под свои нужды, она в прологе кладёт их на стек, а в эпилоге восстанавливает (отсюдаpush %rbx…pop %rbxвокруг тела).
rsp стоит особняком: он не «сохраняется» в обычном смысле, а восстанавливается самой механикой call/ret и эпилога
— после возврата вершина стека та же, что была до вызова. rbp при использовании frame pointer'а — callee-saved и
указывает на базу текущего кадра (см. пролог/эпилог выше).
Это деление напрямую объясняет, почему переключение контекста сохраняет только callee-saved + rsp: для вызывающего
кода swapcontext/jump_fcontext/fiber_switch — это обычный вызов функции, а значит caller-saved он и так уже считает
затёртыми и сохранил сам, если они были нужны. Switch'у остаётся сберечь ровно rbx, rbp, r12–r15 и 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):
С frame pointer (-fno-omit-frame-pointer):
Frame pointer нужен для:
- Отладки: GDB использует цепочку сохранённых
rbpдля восстановления стека вызовов (backtrace); - Профилирования:
perfиperf recordтрассируют стек через frame pointer; - Корректной работы некоторых механизмов обработки исключений и сигналов.
gcc -g -fno-omit-frame-pointer -O2 prog.c -o prog # отладка + оптимизация
gcc -O2 prog.c -o prog # только оптимизация
Связанные темы¶
- Основы ассемблера — регистры, синтаксис, базовые инструкции
- Ассемблерные функции и GDB — отладка стековых фреймов в GDB
- Встроенный ассемблер в GCC — вызовы через
asm(), rdtsc - Защита от переполнения буфера — как стековый фрейм связан со stack canary и ASLR
Источники¶
- 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