Комментарии (1)
🐱
claude-haiku-4.5PrepBro AI29 мар. 2026 г.(ред.)
Ответ сгенерирован нейросетью и может содержать ошибки
Что произойдет при вызове функции
Вопрос звучит общо, но это отличный повод рассмотреть полный процесс того, что происходит на уровне процессора, памяти и runtime-а при вызове функции в C++.
Этап 1: Подготовка (до вызова)
Регистры и стек
void expensive_operation(int a, int b, double c) {
int result = a + b;
// Используется result
}
int main() {
// ДО вызова:
// RSP (Stack Pointer) указывает на вершину стека
// RBP (Base Pointer) указывает на фрейм текущей функции
expensive_operation(10, 20, 3.14);
// ...
}
Что происходит в процессе подготовки:
- Сохранение аргументов в соответствии с calling convention
- Выделение памяти на стеке для локальных переменных
- Резервирование регистров для сохранения состояния
Этап 2: Вызов функции (CALL)
// В ассемблере это выглядит примерно так:
// call expensive_operation
// Микрооперации:
// 1. Push текущий Program Counter (адрес возврата) на стек
// 2. Jump на адрес функции
Визуализация стека:
BEFORE: AFTER call:
┌─────────────────┐ ┌─────────────────┐
│ main's locals │ │ main's locals │
├─────────────────┤ ├─────────────────┤
│ ...other... │ │ ...other... │
├─────────────────┤ ├─────────────────┤
│ (RSP) empty │ ─────→ │ return address │ ← RSP
└─────────────────┘ └─────────────────┘
Этап 3: Пролог функции (Function Prologue)
void expensive_operation(int a, int b, double c) {
int result = a + b;
double temp = c * 2.0;
// ...
}
// Компилятор генерирует пролог примерно такой:
// push rbp // Сохраняем старый base pointer
// mov rbp, rsp // Устанавливаем новый base pointer
// sub rsp, 32 // Выделяем место для локальных переменных
Стек после пролога:
┌─────────────────────────┐
│ main's locals │
├─────────────────────────┤
│ return address │ (pushed by call)
├─────────────────────────┤
│ saved RBP │ (pushed by prologue)
├─────────────────────────┤
│ result (int) │ (allocated by sub rsp)
├─────────────────────────┤
│ temp (double) │
├─────────────────────────┤
│ padding/alignment │
├─────────────────────────┤
│ (RSP) │
└─────────────────────────┘
Этап 4: Тело функции
void expensive_operation(int a, int b, double c) {
// Аргументы находятся в регистрах (x86-64 ABI):
// EDI (a), ESI (b), XMM0 (c)
int result = a + b;
// ADD EDI, ESI
// MOV [RBP-8], EAX (сохраняем результат на стеке)
double temp = c * 2.0;
// MULSD XMM0, [2.0]
// MOVSD [RBP-16], XMM0
// Возможные оптимизации компилятора:
// - Inlining (если небольшая функция)
// - Register allocation (держим в регистрах вместо стека)
// - Dead code elimination (удаляем неиспользуемый код)
}
Этап 5: Эпилог функции (Function Epilogue)
return result; // Prepare return value
// Компилятор генерирует эпилог:
// mov eax, [rbp-8] // Кладем результат в EAX (или RAX для int64)
// mov rsp, rbp // Восстанавливаем стек
// pop rbp // Восстанавливаем старый base pointer
// ret // Pop адреса возврата и jump
Стек при выходе из функции:
┌─────────────────────────┐
│ main's locals │
├─────────────────────────┤
│ return address │ ← RSP (при ret)
└─────────────────────────┘
// ret инструкция:
// 1. Pop адрес из стека в Program Counter
// 2. Отскочить на адрес в main
Полный пример: From Call to Return
#include <iostream>
int add(int a, int b) {
std::cout << "Inside add\n"; // Может быть оптимизирована
return a + b; // Результат в EAX/RAX
}
int main() {
std::cout << "Before call\n";
int result = add(5, 3); // CALL add
std::cout << "After call\n";
std::cout << result << "\n"; // Используем результат
return 0;
}
// Ассемблер (упрощённо):
// main:
// PUSH RBP
// MOV RBP, RSP
// MOV EDI, 5 ; Первый аргумент
// MOV ESI, 3 ; Второй аргумент
// CALL add ; Вызов функции
// MOV [RBP-8], EAX ; Сохраняем результат
// ...
// MOV EAX, 0
// POP RBP
// RET
//
// add:
// PUSH RBP
// MOV RBP, RSP
// ADD EDI, ESI ; a + b
// MOV EAX, EDI ; Результат в EAX
// POP RBP
// RET ; Возврат в main
Вызов функции с исключениями
void risky_operation() {
throw std::runtime_error("Something went wrong");
}
int main() {
try {
risky_operation(); // CALL with unwinding
} catch (const std::exception& e) {
std::cout << e.what() << "\n";
}
}
// При exception:
// 1. Выполнение прерывается
// 2. Стек "разворачивается" (stack unwinding)
// 3. Вызываются деструкторы локальных объектов
// 4. Контроль переходит в catch блок
Вызов виртуальной функции
class Base {
public:
virtual void print() { std::cout << "Base\n"; }
};
class Derived : public Base {
public:
void print() override { std::cout << "Derived\n"; }
};
int main() {
Base* obj = new Derived();
obj->print(); // Виртуальный вызов
}
// Виртуальный вызов требует:
// 1. Получить VTable указатель из объекта
// 2. Найти нужный метод в VTable
// 3. Выполнить косвенный вызов (через указатель)
// Ассемблер:
// MOV RAX, [RDI] ; Получить VTable
// MOV RAX, [RAX + offset]; Получить функцию из VTable
// CALL RAX ; Косвенный вызов
Оптимизации компилятора
1. Inlining
// Исходный код
inline int add(int a, int b) {
return a + b;
}
int main() {
int result = add(5, 3);
}
// После inlining:
// int main() {
// int result = 5 + 3; // Функция встроена, нет вызова
// }
// Это исключает затраты на:
// - CALL и RET инструкции
// - Пролог и эпилог
// - Сохранение/восстановление регистров
2. Tail Call Optimization
// Без оптимизации: лишние стек кадры
int factorial(int n, int acc = 1) {
if (n <= 1) return acc;
return factorial(n - 1, acc * n); // Tail call
}
// С tail call optimization: реиспользуется стек кадр
// Это как goto вместо call!
// Эквивалентно:
// factorial_loop:
// if (n <= 1) goto return_acc;
// acc *= n;
// n -= 1;
// goto factorial_loop;
// return_acc:
// return acc;
Costs and Performance
// Примерные стоимости (относительно):
// 1. Обычный вызов: ~10-20 циклов процессора
// - CALL инструкция: 1-2 цикла
// - Пролог/эпилог: 3-5 циклов
// - Сохранение регистров: 3-5 циклов
// - Branch prediction miss: 10-20 циклов
// 2. Виртуальный вызов: +5-10 циклов
// - Дополнительная загрузка из VTable
// 3. Inline: 0 циклов (функция встроена)
// Пример: быстрая функция может быть медленнее
// из-за стоимости вызова, чем встроенная версия
Итог: При вызове функции происходит сложный процесс: 1) аргументы передаются через регистры/стек, 2) адрес возврата сохраняется на стеке, 3) выполняется пролог функции (сохранение контекста), 4) выполняется тело функции, 5) выполняется эпилог (восстановление контекста), 6) возвращается результат и управление. Компилятор может оптимизировать это через inlining, tail call optimization и регистр-allocation.