Menu

Ошибка сегментации в C: из-за чего она возникает и как её исправить

Segfault означает, что программа обратилась к памяти, которая ей не принадлежит. Здесь пять причин, на которые приходится почти все такие падения, каждая с минимальным примером и исправлением, а также способы найти точную строку с помощью gdb и AddressSanitizer.

На этой странице есть исполняемые редакторы: меняйте, запускайте и сразу видите результат.

Segmentation fault (core dumped)

Эта одна строка — самая часто разыскиваемая ошибка в C, и она куда менее загадочна, чем кажется. Программа запросила у процессора адрес в памяти, операционная система проверила, разрешено ли процессу трогать этот адрес, и ответ был отрицательным. После этого ядро убило процесс сигналом SIGSEGV.

Ключевая мысль: падение — это симптом, и место падения часто не совпадает с местом ошибки. Плохой указатель обычно был создан где-то раньше, а здесь он просто впервые использован. На этой странице разобраны пять причин, на которые приходятся почти все segfault, и два инструмента, находящие настоящую строку за секунды.

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

Что значит «память, которая вам не принадлежит»

Когда программа стартует, операционная система отображает в её адресное пространство несколько областей: код, глобальные данные, стек и то, до чего дорос heap. Всё остальное в адресном пространстве — включая адрес 0 — не отображено. Обратитесь к неотображённому адресу или запишите в доступный только для чтения — и оборудование это перехватит.

Так что segfault — это не компилятор вас поймал. Это ограждение времени выполнения, и срабатывает оно только тогда, когда некорректный адрес случайно оказался за пределами отображённых страниц. Именно поэтому одна и та же ошибка может падать на одной машине и как будто работать на другой: раскладка памяти разная.

Причина 1: разыменование NULL-указателя

Самая частая причина и самая простая в исправлении. Адрес 0 никогда не отображён, поэтому чтение или запись через нулевой указатель всегда даёт ошибку.

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int *p = NULL;
    *p = 42;              /* ПАДЕНИЕ: запись по адресу 0 */
    printf("%d\n", *p);
    return 0;
}

Реалистичный вариант — непроверенное выделение памяти:

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int *data = malloc(1000000000000UL * sizeof(int));  /* не удаётся, возвращает NULL */
    data[0] = 1;                                        /* ПАДЕНИЕ */
    free(data);
    return 0;
}

malloc возвращает NULL, когда не может выполнить запрос; так же ведёт себя fopen, когда файла нет, и strchr, когда символа нет в строке. Проверяйте результат каждой функции, которая может вернуть NULL, прежде чем им пользоваться.

Инициализируйте указатели значением NULL, а не оставляйте их неинициализированными. Нулевой указатель падает сразу и очевидно; мусорный указатель может что-нибудь испортить и упасть гораздо позже. Подробнее об этом приёме — в нулевых указателях.

Причина 2: запись за конец массива

C не проверяет границы массивов. Индекс 10 у массива из 10 элементов — это просто память сразу за массивом, и компилятор без единого возражения вычислит для вас этот адрес.

#include <stdio.h>

int main(void) {
    int arr[10];

    for (int i = 0; i <= 10; i++) {   /* <= вместо < : на одну итерацию больше */
        arr[i] = i;
    }

    printf("done\n");
    return 0;
}

Упадёт ли это — дело случая. Запись четырёх байт за локальным массивом обычно попадает в другие данные на стеке — сохранённый регистр, другую переменную, адрес возврата, — так что программа портит сама себя и падает позже в совершенно другом месте. Большой выход за границы покидает отображённую страницу и даёт segfault сразу же.

Более дикий вариант падает всегда:

#include <stdio.h>

int main(void) {
    int arr[10];
    arr[1000000] = 42;      /* далеко за пределами всего отображённого: ПАДЕНИЕ */
    return 0;
}

Лечится привычкой писать i < n и вычислять n, а не набирать его дважды:

У строк есть своя версия этой беды: буфер, в котором не осталось места для завершающего '\0'.

#include <string.h>

int main(void) {
    char name[5];
    strcpy(name, "Alexander");   /* 9 символов + терминатор в 5 байт */
    return 0;
}

strcpy понятия не имеет, какого размера name. Используйте snprintf, который знает размер, потому что вы ему его сообщаете:

Причина 3: использование указателя после free (висячие указатели)

После free(p) память возвращается аллокатору. Указатель по-прежнему хранит старый адрес, но этот адрес больше не ваш.

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int *p = malloc(sizeof *p);
    *p = 42;
    free(p);

    printf("%d\n", *p);   /* использование после free: может напечатать мусор, может упасть */
    free(p);              /* двойное освобождение: обычно abort или порча кучи */
    return 0;
}

Родственная ловушка — возврат адреса локальной переменной. Её кадр стека исчезает в тот момент, когда функция возвращает управление:

#include <stdio.h>

int *make_number(void) {
    int value = 42;
    return &value;      /* кадр умирает здесь; указатель повисает */
}

int main(void) {
    int *p = make_number();
    printf("%d\n", *p);   /* неопределённое поведение: мусор или падение */
    return 0;
}

Есть два исправления — в зависимости от того, что вы имели в виду. Вернуть значение вместо указателя или выделить память в куче и позволить вызывающему её освободить:

Обнуление указателя сразу после free — дешёвая защитная привычка: она превращает тихое использование освобождённой памяти в немедленное и очевидное падение по нулевому указателю, а второй free(p) делает безвредным, поскольку free(NULL) по стандарту не делает ничего. Сторона выделения памяти в этой истории описана в динамической памяти и утечках памяти.

Причина 4: переполнение стека из-за неуправляемой рекурсии

Каждый вызов функции кладёт кадр на стек, а стек — это область фиксированного размера (обычно 8 МБ). Рекурсия без базового случая — или с недостижимым базовым случаем — выходит за его пределы.

#include <stdio.h>

int countdown(int n) {
    printf("%d\n", n);
    return countdown(n - 1);    /* нет базового случая: никогда не остановится */
}

int main(void) {
    return countdown(5);
}

То же самое происходит с базовым случаем, который рекурсия перешагивает:

int f(int n) {
    if (n == 0) return 1;
    return n * f(n - 2);    /* при нечётном n никогда не равно 0 */
}

Каждой рекурсивной функции нужен базовый случай, достижимый из любого входного значения:

Огромный локальный массив делает то же самое: int buffer[10000000]; внутри функции просит 40 МБ стека и падает на первой же записи. Большие буферы размещайте в куче с помощью malloc. Размеры разобраны в стеке и куче, а проектирование базового случая — в рекурсии.

Причина 5: запись в строковый литерал

Эта причина удивляет людей, потому что код выглядит совершенно безобидно.

#include <stdio.h>

int main(void) {
    char *s = "hello";
    s[0] = 'H';          /* ПАДЕНИЕ: строковые литералы доступны только для чтения */
    printf("%s\n", s);
    return 0;
}

Строковый литерал лежит в секции исполняемого файла, доступной только для чтения. char *s = "hello" указывает в неё; запись через такой указатель — это нарушение защиты, о котором ОС сообщает как о segfault, точно так же, как о доступе к неотображённой памяти.

Лечится созданием массива, который получает собственную изменяемую копию:

Объявление указателей на литералы как const char * превращает это падение во время выполнения в ошибку компиляции, что заведомо лучше. Сделайте это привычкой.

Как найти настоящую строку: gdb

Соберите с -g, чтобы исполняемый файл нёс отладочные символы, а затем запустите под отладчиком:

gcc -g program.c -o program
gdb ./program

Внутри gdb:

(gdb) run
Program received signal SIGSEGV, Segmentation fault.
0x0000555555555151 in process_item (item=0x0) at program.c:14
14          return item->count * 2;

(gdb) backtrace
#0  process_item (item=0x0) at program.c:14
#1  0x000055555555518a in main () at program.c:23

(gdb) print item
$1 = (struct Item *) 0x0

Три команды делают почти всю работу. run запускает программу и останавливает её там, где происходит сбой. backtrace (или bt) показывает цепочку вызовов, приведшую сюда: кадр #1 обычно и есть место, где на самом деле был создан плохой указатель. print смотрит на переменную, и item = 0x0 прямо называет проблему.

На macOS эквивалент — lldb ./program, затем run и bt.

Как найти быстрее: AddressSanitizer

Ещё лучше — пусть компилятор сам инструментирует программу. AddressSanitizer ловит некорректный доступ в момент, когда тот происходит, включая те случаи, которые не привели бы к падению:

gcc -g -fsanitize=address program.c -o program
./program
==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010
WRITE of size 4 at 0x602000000010 thread T0
    #0 0x4011f6 in main program.c:11

0x602000000010 is located 0 bytes inside of 4-byte region
freed by thread T0 here:
    #1 0x4011c9 in main program.c:10
previously allocated by thread T0 here:
    #2 0x4011a6 in main program.c:8

Этот отчёт называет класс ошибки, строку, где она произошла, строку, где память была освобождена, и строку, где она была выделена. Это самый эффективный инструмент отладки ошибок работы с памятью в C, он работает с GCC и clang на Linux и macOS и стоит примерно двукратного замедления — что во время разработки совершенно неважно.

Добавьте к нему -fsanitize=undefined, чтобы заодно ловить знаковое переполнение и другое неопределённое поведение:

gcc -g -Wall -Wextra -fsanitize=address,undefined program.c -o program

valgrind ./program — альтернатива, не требующая пересборки; она сообщает о тех же классах ошибок плюс об утечках.

Чек-лист, когда вы на это наткнулись

  1. Пересоберите с -g -Wall -Wextra -fsanitize=address,undefined и запустите снова. В большинстве случаев отчёт назовёт строку, и на этом всё.
  2. Если санитайзер недоступен, запустите под gdb и снимите backtrace. Смотрите на кадр 1, а не только на кадр 0.
  3. Проверьте каждый указатель в падающей строке. Напечатайте их все; 0x0 означает нулевой указатель, а дикое значение вроде 0x7fff5fc01000 обычно говорит о неинициализированном или освобождённом.
  4. Спросите себя, откуда взялся этот указатель. Непроверенный malloc или fopen? Адрес локальной переменной из уже завершившейся функции? Указатель, использованный после free?
  5. Проверьте все границы циклов рядом с падением на предмет <= там, где имелось в виду <.
  6. Если стек вызовов глубиной в тысячи кадров — это неуправляемая рекурсия, а вовсе не ошибка с указателем.

Как их предотвращать

Привычки, благодаря которым segfault становятся редкостью:

  • Всегда компилируйте с -Wall -Wextra и относитесь к предупреждениям как к ошибкам.
  • Инициализируйте каждый указатель — значением NULL, если ничего лучше нет.
  • Проверяйте возвращаемое значение malloc, calloc, realloc и fopen.
  • Обнуляйте указатели сразу после освобождения.
  • Используйте snprintf и fgets вместо sprintf и gets.
  • Объявляйте указатели на строковые литералы как const char *.
  • Предпочитайте sizeof arr / sizeof arr[0] жёстко зашитой длине.
  • Гоняйте набор тестов под AddressSanitizer в CI.

Segfault — это дружелюбный вид отказа: он сообщает вам, что что-то не так. Тот же класс ошибок, который тихо портит соседнюю переменную и выдаёт неверные ответы тремя функциями позже, гораздо хуже, а инструменты выше ловят и то, и другое.

Часто задаваемые вопросы

Что такое ошибка сегментации в C?

Это падение, которое вызывает операционная система, когда программа обращается к памяти, трогать которую ей не разрешено: читает или пишет через некорректный указатель, выходит за конец массива в неотображённую страницу или переполняет стек. Ядро посылает процессу сигнал SIGSEGV, который завершает его и печатает "Segmentation fault (core dumped)".

Как найти место, где происходит ошибка сегментации?

Соберите программу с отладочной информацией и запустите под отладчиком: gcc -g program.c -o program, затем gdb ./program, run, а после падения — backtrace. Он покажет точный файл и строку. Ещё быстрее для ошибок работы с памятью: gcc -g -fsanitize=address program.c -o program — достаточно просто запустить программу, и она напечатает полный отчёт о том, что и где пошло не так.

Почему моя программа на C падает с segfault только иногда?

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

Означает ли ошибка сегментации, что у меня утечка памяти?

Нет — это противоположные проблемы. Утечка — это память, которую вы выделили и не освободили: программа продолжает работать и медленно растёт. Segfault — это обращение к памяти, которая вам не принадлежит. Двойное освобождение или использование указателя после free вызывают segfault; забытый free вызывает утечку.

Coddy programming languages illustration

Учитесь программировать с Coddy

НАЧАТЬ