Segmentation fault C++: обработка stack overflow, управление памятью, низкоуровневая оптимизация | AiManual
AiManual Logo Ai / Manual.
07 Янв 2026 Гайд

Segmentation fault в C++: как предотвратить и обработать переполнение стека на практике

Полный гайд по предотвращению segmentation fault в C++. Управление памятью, обработка stack overflow, инструменты отладки и практические примеры для продакшен-к

Когда стек ломает программу: почему segmentation fault - это не просто ошибка

Segfault. SIGSEGV. Аварийное завершение. Знакомо? Если работаешь с C++, это не вопрос "если", а вопрос "когда". Особенно когда кодишь что-то ресурсоемкое - например, пытаешься запустить локальную Stable Diffusion на старом компьютере или разбираешься с загрузкой больших LLM.

Segmentation fault - не ошибка программы. Это операционная система убивает твою программу за попытку сделать что-то запрещенное. Это как полиция выключает двигатель у машины, которая едет по встречке.

Большинство разработчиков думают: "У меня же нет указателей, откуда segfault?" А вот откуда - из стека. Тот самый стек, который все игнорируют, пока он не взорвется.

Стек: тихий убийца стабильности

Представь: ты пишешь рекурсивную функцию для обхода дерева. В теории все работает. На практике - после 10,000 уровней рекурсии программа падает. Почему? Потому что стек на Linux по умолчанию - 8 МБ. На Windows - 1 МБ. Каждый вызов функции жрет место под локальные переменные, адрес возврата, сохраненные регистры.

// Классический пример - рекурсия без контроля глубины
void dangerous_recursion(int depth) {
    char buffer[1024]; // 1 КБ на каждый вызов!
    // ... какой-то код ...
    dangerous_recursion(depth + 1); // И так до segfault
}

10,000 вызовов × 1 КБ = 10 МБ. Бац - стек переполнен. Операционная система отправляет SIGSEGV. Программа мертва.

💡
Особенно актуально при работе с AI-моделями. Помнишь статью про проблему с загрузкой больших LLM на AMD Strix Halo? Там тоже могли быть скрытые переполнения стека в низкоуровневых оптимизациях.

1 Диагностика: как понять, что segfault именно из-за стека

Первое правило борьбы с segfault - понять, откуда он пришел. Не все segmentation faults равны.

Симптом Вероятная причина Как проверить
Падает на глубокой рекурсии Stack overflow GDB + backtrace, ulimit -s
Падает при работе с большими локальными массивами Слишком большие stack-allocated переменные sizeof() локальных переменных
Падает случайно, адрес ошибки около 0x7fff... Переполнение стека Адрес segfault в верхних адресах памяти

Самый простой способ проверить - запустить под gdb и посмотреть backtrace:

# Компилируем с отладочной информацией
g++ -g -o program program.cpp

# Запускаем под gdb
gdb ./program

# В gdb:
(gdb) run
# ... программа падает с SIGSEGV ...
(gdb) backtrace
# Смотрим глубину стека вызовов
(gdb) info frame
# Смотрим адрес стека

2 Предотвращение: не дай стеку взорваться

Лучший segfault - тот, который никогда не случился. Вот как сделать так, чтобы стек не переполнялся.

1. Избегай больших объектов на стеке

// ПЛОХО: 4 МБ на стеке - гарантированный segfault на многих системах
void bad_function() {
    char huge_buffer[4 * 1024 * 1024]; // 4 МБ на стеке!
    // ... работа с буфером ...
}

// ХОРОШО: используем кучу
void good_function() {
    std::vector huge_buffer(4 * 1024 * 1024); // 4 МБ в куче
    // ... работа с буфером ...
}

// ЕЩЕ ЛУЧШЕ: умный указатель для исключений
void better_function() {
    auto buffer = std::make_unique(4 * 1024 * 1024);
    // ... работа с буфером ...
}

2. Контролируй глубину рекурсии

// ПЛОХО: бесконтрольная рекурсия
void process_tree(TreeNode* node) {
    if (!node) return;
    
    char local_data[1024]; // Память на каждый вызов
    process_tree(node->left);
    process_tree(node->right);
}

// ХОРОШО: итеративный обход или ограничение глубины
void process_tree_safe(TreeNode* node, int max_depth = 1000) {
    if (!node || max_depth <= 0) return;
    
    // Используем кучу для больших данных
    auto data = std::make_unique(1024);
    
    process_tree_safe(node->left, max_depth - 1);
    process_tree_safe(node->right, max_depth - 1);
}

// ЕЩЕ ЛУЧШЕ: итеративный обход со стеком в куче
void process_tree_iterative(TreeNode* root) {
    std::stack node_stack;
    node_stack.push(root);
    
    while (!node_stack.empty()) {
        TreeNode* node = node_stack.top();
        node_stack.pop();
        
        if (!node) continue;
        
        // Обработка узла
        auto data = std::make_unique(1024);
        
        // Добавляем детей
        if (node->right) node_stack.push(node->right);
        if (node->left) node_stack.push(node->left);
    }
}
💡
Этот подход особенно важен при работе с рекурсивными структурами в AI-контексте. Помнишь сборку llama.cpp? Там рекурсивный разбор графов вычислений может легко сожрать весь стек на глубоких моделях.

3. Настраивай размер стека (осторожно!)

Иногда увеличить стек - правильное решение. Но делай это осознанно.

# Linux: увеличиваем стек для программы
ulimit -s 32768  # 32 МБ вместо 8 МБ
./my_program

# Или в коде:
#include 

void increase_stack_size() {
    rlimit limit;
    getrlimit(RLIMIT_STACK, &limit);
    limit.rlim_cur = 32 * 1024 * 1024; // 32 МБ
    setrlimit(RLIMIT_STACK, &limit);
}
// Windows: атрибут стека в Visual Studio
#pragma comment(linker, "/STACK:33554432") // 32 МБ

// Или в коде Windows API:
#include 

void win32_stack() {
    // Устанавливаем размер стека для текущего потока
    SetThreadStackGuarantee(&guarantee);
}

Увеличение стека - костыль, а не решение. Если тебе постоянно не хватает стека, проблема в архитектуре, а не в лимитах. Особенно в продакшене, где программы живут неделями.

3 Обработка: когда segfault все-таки случился

Иногда предотвратить нельзя. Особенно в чужом коде или библиотеках. Тогда лови и обрабатывай.

1. Signal handlers: ловим SIGSEGV

#include 
#include 
#include 
#include 
#include 

void segfault_handler(int signal, siginfo_t* info, void* context) {
    std::cerr << "\n=== SEGFAULT DETECTED ===\n";
    std::cerr << "Signal: " << signal << " (SIGSEGV)\n";
    std::cerr << "Fault address: " << info->si_addr << "\n";
    
    // Получаем backtrace
    void* callstack[128];
    int frames = backtrace(callstack, 128);
    char** symbols = backtrace_symbols(callstack, frames);
    
    if (symbols) {
        std::cerr << "\nBacktrace:\n";
        for (int i = 0; i < frames; i++) {
            std::cerr << "  " << symbols[i] << "\n";
        }
        free(symbols);
    }
    
    // Проверяем, не переполнение ли стека
    // Адреса около 0x7fff... обычно указывают на stack overflow
    uintptr_t fault_addr = reinterpret_cast(info->si_addr);
    if (fault_addr > 0x7fff00000000) { // Примерно верхние адреса
        std::cerr << "\nSUSPECTED STACK OVERFLOW!\n";
        std::cerr << "Fault near stack region: " << std::hex << fault_addr << std::dec << "\n";
    }
    
    std::cerr << "\nAttempting safe shutdown...\n";
    
    // Выходим с кодом ошибки
    _exit(EXIT_FAILURE);
}

void setup_signal_handlers() {
    struct sigaction sa;
    sa.sa_sigaction = segfault_handler;
    sa.sa_flags = SA_SIGINFO | SA_RESETHAND; // RESETHAND - сброс после первого вызова
    sigemptyset(&sa.sa_mask);
    
    sigaction(SIGSEGV, &sa, nullptr);
    sigaction(SIGBUS, &sa, nullptr);  // BUS error тоже ловим
}

// В начале main()
int main() {
    setup_signal_handlers();
    
    // ... твой код ...
    
    return 0;
}
💡
Сигнальные обработчики - последняя линия обороны. Они не "чинят" программу, а лишь дают шанс корректно залогировать ошибку и выйти. В продакшене это лучше, чем просто "Segmentation fault (core dumped)".

2. Stack canaries: детектим переполнение до segfault

Компиляторы умеют добавлять канарейки в стек - специальные значения, которые проверяются при выходе из функции.

# GCC/Clang: включаем защиту стека
g++ -fstack-protector-all -o program program.cpp  # Для всех функций
g++ -fstack-protector -o program program.cpp     # Только для уязвимых

g++ -fstack-protector-strong -o program program.cpp  # Более умная эвристика

# Проверяем, что защита включена
readelf -s program | grep __stack_chk
// Пример того, что генерирует компилятор с -fstack-protector
void protected_function() {
    // Компилятор добавляет:
    // uint64_t canary = __stack_chk_guard;  // Секретное значение
    // ... твой код ...
    
    char buffer[64];
    // ... работа с буфером ...
    
    // При выходе:
    // if (canary != __stack_chk_guard) {
    //     __stack_chk_fail();  // Вызывается при переполнении
    // }
}

3. Ручные канарейки для критических функций

#include 
#include 
#include 

class StackGuard {
private:
    static constexpr uint64_t CANARY = 0xDEADBEEFCAFEBABE;
    uint64_t canary_;
    const char* function_name_;
    
public:
    explicit StackGuard(const char* func_name) 
        : canary_(CANARY), function_name_(func_name) {
        // Можно добавить логирование входа
    }
    
    ~StackGuard() {
        if (canary_ != CANARY) {
            std::cerr << "STACK CORRUPTION DETECTED in " 
                      << (function_name_ ? function_name_ : "unknown") 
                      << "!\n";
            // Здесь можно вызвать аварийное завершение
            std::abort();
        }
    }
    
    // Запрещаем копирование
    StackGuard(const StackGuard&) = delete;
    StackGuard& operator=(const StackGuard&) = delete;
};

void risky_function() {
    StackGuard guard(__func__);
    
    char buffer[64];
    // Опасная операция, которая может переполнить буфер
    // memset(buffer, 0, 128);  // Это сломает канарейку!
    
    // Нормальная работа
    memset(buffer, 0, sizeof(buffer));
    
    // При выходе деструктор guard проверит канарейку
}

Инструменты: что использовать кроме print и молитв

Старый добрый printf уже не катит. Вот инструменты, которые реально помогают.

AddressSanitizer (ASan)

ASan ловит не только переполнения стека, но и кучу других memory errors.

# Компилируем с ASan
g++ -fsanitize=address -fno-omit-frame-pointer -g -o program program.cpp

# Запускаем
ASAN_OPTIONS=detect_stack_use_after_return=1 ./program

# Если ASan находит ошибку, он покажет красивый отчет с
# stack trace, тип ошибки и даже историю аллокаций
// Пример кода, который ASan поймает
void asan_example() {
    char buffer[10];
    // Переполнение буфера - ASan поймает сразу
    for (int i = 0; i <= 10; i++) {  // Ошибка на 10-й итерации!
        buffer[i] = 'A';
    }
}

Valgrind и Massif

Valgrind - классика. Massif - его инструмент для анализа использования памяти, включая стек.

# Запускаем Massif
valgrind --tool=massif --stacks=yes ./program

# Massif создает файл massif.out.XXXXX
# Конвертируем в читаемый формат
ms_print massif.out.12345 > profile.txt

# Смотрим на использование стека
# Massif покажет пиковое использование и график

GDB с расширениями

# Современный GDB умеет многое
gdb ./program

# Устанавливаем точку останова на переполнение
(gdb) catch signal SIGSEGV

# Смотрим карту памяти при падении
(gdb) info proc mappings

# Смотрим текущий размер стека
(gdb) info frame
(gdb) print (void*)$rsp  # Указатель стека на x86_64

# Полезные команды для анализа
(gdb) x/100a $rsp  # Смотрим 100 значений на стеке
(gdb) bt full     # Полный backtrace с локальными переменными
💡
Эти инструменты спасали меня при отладке проблем с памятью в AI-инфраструктуре. Особенно когда сталкивался с переполнением VRAM на новых картах - там часто похожие проблемы, только с видеопамятью вместо оперативной.

Практические паттерны для продакшена

Теория - это хорошо. Но вот что реально работает в боевом коде.

1. Лимитированные аллокации на стеке

template
class SmartBuffer {
private:
    std::array stack_buffer_;
    std::unique_ptr heap_buffer_;
    char* data_;
    size_t size_;
    
public:
    explicit SmartBuffer(size_t size) : size_(size) {
        if (size <= MaxStackSize) {
            data_ = stack_buffer_.data();
            heap_buffer_.reset();
        } else {
            heap_buffer_ = std::make_unique(size);
            data_ = heap_buffer_.get();
        }
    }
    
    char* data() { return data_; }
    size_t size() const { return size_; }
    
    // Автоматически использует стек для маленьких буферов,
    // кучу - для больших
};

// Использование:
void process_data(size_t data_size) {
    SmartBuffer<8192> buffer(data_size);  // До 8КБ - на стеке, больше - в куче
    // ... работа с buffer.data() ...
}

2. Рекурсия с явным стеком в куче

// Безопасный обход дерева
void safe_tree_traversal(TreeNode* root) {
    // Используем std::stack (который аллоцирует в куче)
    std::stack> traversal_stack;
    traversal_stack.push({root, 0});
    
    // Ограничиваем максимальную глубину явно
    constexpr int MAX_DEPTH = 10000;
    
    while (!traversal_stack.empty()) {
        auto [node, depth] = traversal_stack.top();
        traversal_stack.pop();
        
        if (!node || depth > MAX_DEPTH) {
            continue;
        }
        
        // Обрабатываем узел
        process_node(node);
        
        // Добавляем детей
        if (node->right) {
            traversal_stack.push({node->right, depth + 1});
        }
        if (node->left) {
            traversal_stack.push({node->left, depth + 1});
        }
    }
}

3. Мониторинг использования стека в рантайме

#include 
#include 
#include 
#include 

class StackMonitor {
private:
    pthread_t thread_id_;
    size_t stack_size_;
    void* stack_addr_;
    
public:
    StackMonitor() {
        thread_id_ = pthread_self();
        
        pthread_attr_t attr;
        pthread_getattr_np(thread_id_, &attr);
        pthread_attr_getstack(&attr, &stack_addr_, &stack_size_);
        pthread_attr_destroy(&attr);
    }
    
    size_t get_used_stack() const {
        // Грубая оценка использованного стека
        // На x86_64 стек растет вниз
        char dummy;
        void* current_stack = &dummy;
        
        size_t used = reinterpret_cast(stack_addr_) + stack_size_
                     - reinterpret_cast(current_stack);
        
        return used;
    }
    
    double get_usage_percentage() const {
        return (static_cast(get_used_stack()) / stack_size_) * 100.0;
    }
    
    void check_and_warn(double threshold_percent = 80.0) {
        double usage = get_usage_percentage();
        if (usage > threshold_percent) {
            std::cerr << "WARNING: Stack usage at " << usage 
                      << "% (" << get_used_stack() << " / " 
                      << stack_size_ << " bytes)\n";
            
            if (usage > 95.0) {
                std::cerr << "CRITICAL: Stack near overflow!\n";
                // Здесь можно принять меры: бросить исключение,
                // переключиться на итеративный алгоритм и т.д.
            }
        }
    }
};

// Использование в рекурсивной функции
void monitored_recursion(int depth, StackMonitor& monitor) {
    monitor.check_and_warn();
    
    if (depth <= 0) return;
    
    char buffer[1024]; // Имитируем использование стека
    (void)buffer; // Подавляем warning о неиспользованной переменной
    
    monitored_recursion(depth - 1, monitor);
}

int main() {
    StackMonitor monitor;
    monitored_recursion(100, monitor);
    return 0;
}

Чего НЕ делать никогда

Есть антипаттерны, которые гарантированно приведут к проблемам.

  • Игнорировать warning о больших stack-allocated объектах. Если компилятор говорит "warning: stack frame size of 123456 bytes", он не шутит.
  • Использовать alloca() в продакшене. alloca() аллоцирует на стеке, но не бросает исключений при переполнении - просто segfault.
  • Доверять VLA (Variable Length Arrays) в C++. Это расширение GCC, которое аллоцирует массивы переменной длины на стеке. Ужасная идея.
  • Писать рекурсивные алгоритмы без контроля глубины. Даже если "в моих тестах глубина не превышает 100".
  • Копировать большие структуры по значению в рекурсивных функциях. Каждый вызов - новая копия на стеке.
// АНТИПАТТЕРНЫЙ КОД - НЕ ПОВТОРЯЙТЕ ЭТО

// 1. alloca() - прямой путь в ад
void demonic_function(size_t size) {
    char* buffer = (char*)alloca(size); // Может переполнить стек без предупреждения
    // ...
}

// 2. VLA в C++ - почему, зачем?
void vla_madness(size_t n) {
    int array[n]; // Нестандартное расширение GCC
    // Размер стека неизвестен на этапе компиляции
    // Легко переполнить
}

// 3. Рекурсия с большими копиями
struct BigData {
    char data[4096]; // 4 КБ
};

void recursive_copy(BigData data, int depth) { // Копия 4 КБ на каждый вызов!
    if (depth <= 0) return;
    recursive_copy(data, depth - 1); // Экспоненциальный рост стека
}
💡
Если видишь такой код в legacy-проекте - беги исправлять. Особенно если проект связан с AI, где ошибки управления памятью особенно болезненны.

Когда стек - не стек, а куча проблем

Есть нюансы, о которых молчат в учебниках.

Потоки и стек

Каждый поток имеет свой стек. Размер по умолчанию зависит от системы и библиотеки потоков.

#include 
#include 

// Создаем поток с увеличенным стеком
void thread_with_big_stack() {
    char large_buffer[2 * 1024 * 1024]; // 2 МБ
    // ...
}

int main() {
    // По умолчанию стек потока - несколько МБ
    std::thread normal_thread([]{ /* обычный код */ });
    
    // POSIX threads позволяют задать размер стека
    pthread_attr_t attr;
    pthread_attr_init(&attr);
    pthread_attr_setstacksize(&attr, 16 * 1024 * 1024); // 16 МБ
    
    pthread_t thread;
    pthread_create(&thread, &attr, thread_func, nullptr);
    
    // C++11 std::thread не позволяет задать размер стека напрямую
    // Приходится использовать platform-specific API
    
    normal_thread.join();
    pthread_join(thread, nullptr);
    
    return 0;
}

Сигналы и альтернативный стек

Для обработчиков сигналов можно выделить отдельный стек, чтобы не переполнять основной при обработке SIGSEGV.

#include 
#include 
#include 
#include 

// Выделяем альтернативный стек для обработчиков сигналов
void setup_alt_stack() {
    stack_t alt_stack;
    
    // Выделяем память для стека обработчика
    // SIGSTKSZ - минимальный размер, рекомендуемый системой
    alt_stack.ss_size = SIGSTKSZ * 4; // В 4 раза больше
    alt_stack.ss_sp = mmap(nullptr, alt_stack.ss_size,
                          PROT_READ | PROT_WRITE,
                          MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK,
                          -1, 0);
    
    if (alt_stack.ss_sp == MAP_FAILED) {
        perror("mmap failed for alt stack");
        return;
    }
    
    alt_stack.ss_flags = 0;
    
    if (sigaltstack(&alt_stack, nullptr) == -1) {
        perror("sigaltstack failed");
        munmap(alt_stack.ss_sp, alt_stack.ss_size);
        return;
    }
    
    std::cout << "Alternative stack setup: " 
              << alt_stack.ss_size << " bytes at " 
              << alt_stack.ss_sp << std::endl;
}

// Обработчик сигнала, использующий альтернативный стек
void segfault_handler_alt(int sig, siginfo_t* info, void* context) {
    // Этот код выполняется на альтернативном стеке
    // Даже если основной стек переполнен
    
    char safe_buffer[4096]; // Безопасно - мы на альтернативном стеке
    
    // Логируем ошибку в safe_buffer
    snprintf(safe_buffer, sizeof(safe_buffer),
             "Segfault at %p, signal %d\n",
             info->si_addr, sig);
    
    // Пишем в stderr (осторожно! syscalls могут быть небезопасны)
    write(STDERR_FILENO, safe_buffer, strlen(safe_buffer));
    
    _exit(EXIT_FAILURE);
}

Финальный совет: стек - не мусорка

Стек - быстрый, автоматический, удобный. Но он не безразмерный. Относись к нему как к ценности, а не как к мусорке для временных данных.

Помни: каждый мегабайт на стеке - это мегабайт, который нельзя использовать для рекурсии, для вложенных вызовов, для обработки глубоких структур.

Особенно в эпоху AI, где генерация кода нейросетями может создавать неочевидные рекурсивные структуры, контроль над стеком - не роскошь, а необходимость.

Следующий раз, когда увидишь segmentation fault, не просто перезапускай программу. Посмотри на стек. Пойми, что его переполнило. И исправь архитектуру, а не просто увеличь лимит.

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