← Назад к вопросам

Что произойдет при вызове функции?

2.0 Middle🔥 111 комментариев
#Язык C++

Комментарии (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);
    // ...
}

Что происходит в процессе подготовки:

  1. Сохранение аргументов в соответствии с calling convention
  2. Выделение памяти на стеке для локальных переменных
  3. Резервирование регистров для сохранения состояния

Этап 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.

Что произойдет при вызове функции? | PrepBro