Menu

Segmentation fault w C: co go powoduje i jak go naprawić

Segfault oznacza, że program dotknął pamięci, która do niego nie należy. Oto pięć przyczyn odpowiadających za niemal wszystkie przypadki, każda z minimalnym przykładem i poprawką, oraz sposób na znalezienie dokładnej linii przez gdb i AddressSanitizer.

Na tej stronie są działające edytory: edytuj, uruchamiaj i od razu zobacz wynik.

Segmentation fault (core dumped)

Ta jedna linia to najczęściej wyszukiwany błąd w C i jest mniej tajemnicza, niż się wydaje. Program poprosił procesor o adres w pamięci, system operacyjny sprawdził, czy proces ma prawo go dotknąć, i odpowiedź brzmiała: nie. Jądro zabiło wtedy proces sygnałem SIGSEGV.

Najważniejsza obserwacja: awaria to objaw, a jej miejsce często nie jest miejscem błędu. Zły wskaźnik zwykle powstał gdzie indziej, wcześniej, a to jest tylko pierwsze miejsce, w którym go użyto. Ta strona omawia pięć przyczyn odpowiadających za prawie każdy segfault, a potem dwa narzędzia, które w kilka sekund znajdują prawdziwą linię.

Przykłady z awarią poniżej celowo nie są blokami do uruchomienia, bo z założenia się wysypują. Przeczytaj je, a potem poprawioną wersję, która po nich następuje.

Co znaczy „pamięć, która do ciebie nie należy”

Gdy program startuje, system operacyjny mapuje w jego przestrzeni adresowej kilka obszarów: kod, zmienne globalne, stos i to, do czego urosła sterta. Wszystko inne w przestrzeni adresowej, łącznie z adresem 0, jest niezmapowane. Dotknij niezmapowanego adresu albo zapisz do adresu tylko do odczytu, a sprzęt to przechwyci.

Segfault nie oznacza więc, że złapał cię kompilator. To bariera ochronna działająca w czasie wykonania, która zadziała tylko wtedy, gdy nieprawidłowy adres wypadnie poza zmapowane strony. Dlatego ten sam błąd może wysypać program na jednej maszynie, a na innej pozornie działać: układ pamięci jest inny.

Przyczyna 1: dereferencja wskaźnika NULL

Najczęstsza przyczyna i najłatwiejsza do naprawienia. Adres 0 nigdy nie jest zmapowany, więc odczyt lub zapis przez wskaźnik null zawsze kończy się błędem.

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

int main(void) {
    int *p = NULL;
    *p = 42;              /* AWARIA: zapis pod adres 0 */
    printf("%d\n", *p);
    return 0;
}

Realistyczna wersja to niesprawdzona alokacja:

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

int main(void) {
    int *data = malloc(1000000000000UL * sizeof(int));  /* nie udaje sie, zwraca NULL */
    data[0] = 1;                                        /* AWARIA */
    free(data);
    return 0;
}

malloc zwraca NULL, gdy nie może spełnić żądania. Tak samo robi fopen, gdy plik nie istnieje, i strchr, gdy znaku nie ma. Sprawdzaj każdą funkcję, która może zwrócić NULL, zanim użyjesz jej wyniku.

Inicjalizuj wskaźniki wartością NULL, zamiast zostawiać je niezainicjalizowane. Wskaźnik null wysypuje program od razu i w oczywisty sposób, a śmieciowy wskaźnik może coś uszkodzić i wysypać program dużo później. Więcej o tym wzorcu na stronie o wskaźnikach null.

Przyczyna 2: zapis za końcem tablicy

C nie sprawdza granic tablic. Indeks 10 w tablicy dziesięcioelementowej to po prostu pamięć za tablicą, a kompilator bez słowa skargi wyliczy ten adres.

#include <stdio.h>

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

    for (int i = 0; i <= 10; i++) {   /* <= zamiast < : o jeden za duzo */
        arr[i] = i;
    }

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

To, czy program się wysypie, to kwestia szczęścia. Zapis czterech bajtów za lokalną tablicą zwykle trafia w inne dane na stosie: zapisany rejestr, inną zmienną, adres powrotu. Program sam się psuje i wysypuje później, w jakimś niezwiązanym miejscu. Duże wyjście poza zakres opuszcza zmapowaną stronę i od razu daje segfault.

Bardziej szalona wersja wysypuje się zawsze:

#include <stdio.h>

int main(void) {
    int arr[10];
    arr[1000000] = 42;      /* daleko poza czymkolwiek zmapowanym: AWARIA */
    return 0;
}

Poprawka to nawyk i < n oraz wyliczanie n zamiast wpisywania go dwa razy:

Napisy mają własną wersję tego problemu: bufor bez miejsca na kończące '\0'.

#include <string.h>

int main(void) {
    char name[5];
    strcpy(name, "Alexander");   /* 9 znakow + terminator do 5 bajtow */
    return 0;
}

strcpy nie ma pojęcia, jak duże jest name. Użyj snprintf, które to wie, bo mu to mówisz:

Przyczyna 3: użycie wskaźnika po free (wiszące wskaźniki)

Po free(p) pamięć wraca do alokatora. Wskaźnik nadal trzyma stary adres, ale ten adres już do ciebie nie należy.

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

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

    printf("%d\n", *p);   /* uzycie po zwolnieniu: moze wypisac smieci, moze sie wysypac */
    free(p);              /* podwojne zwolnienie: zwykle przerywa program lub psuje sterte */
    return 0;
}

Pokrewna pułapka to zwracanie adresu zmiennej lokalnej. Jej ramka stosu znika w chwili powrotu z funkcji:

#include <stdio.h>

int *make_number(void) {
    int value = 42;
    return &value;      /* tu ramka umiera, a wskaznik zostaje wiszacy */
}

int main(void) {
    int *p = make_number();
    printf("%d\n", *p);   /* niezdefiniowane: smieci albo awaria */
    return 0;
}

Dwie poprawki, zależnie od tego, o co ci chodziło. Zwróć wartość zamiast wskaźnika albo zaalokuj pamięć na stercie i pozwól wywołującemu ją zwolnić:

Ustawienie wskaźnika na NULL zaraz po free to tani nawyk obronny: zamienia ciche użycie po zwolnieniu w natychmiastową, oczywistą awarię dereferencji null, a drugie free(p) staje się nieszkodliwe, bo free(NULL) z definicji nic nie robi. Stronę alokacji tej historii opisują pamięć dynamiczna i wycieki pamięci.

Przyczyna 4: przepełnienie stosu przez niekontrolowaną rekurencję

Każde wywołanie funkcji odkłada ramkę na stos, a stos to obszar o stałym rozmiarze (zwykle 8 MB). Rekurencja bez przypadku bazowego, albo z takim, który nigdy nie zostaje osiągnięty, wychodzi poza jego koniec.

#include <stdio.h>

int countdown(int n) {
    printf("%d\n", n);
    return countdown(n - 1);    /* brak przypadku bazowego: nigdy sie nie zatrzymuje */
}

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

To samo dzieje się z przypadkiem bazowym, który rekurencja przeskakuje:

int f(int n) {
    if (n == 0) return 1;
    return n * f(n - 2);    /* dla nieparzystego n nigdy nie bedzie rowne 0 */
}

Każda funkcja rekurencyjna potrzebuje przypadku bazowego, który jest osiągalny z każdego wejścia:

Ogromna lokalna tablica robi to samo: int buffer[10000000]; wewnątrz funkcji żąda 40 MB stosu i wywala się przy pierwszym zapisie. Duże bufory alokuj na stercie przez malloc. Rozmiary opisuje strona stos a sterta, a projektowanie przypadków bazowych strona o rekurencji.

Przyczyna 5: zapis do literału napisowego

Ta przyczyna zaskakuje, bo kod wygląda niewinnie.

#include <stdio.h>

int main(void) {
    char *s = "hello";
    s[0] = 'H';          /* AWARIA: literaly napisowe sa tylko do odczytu */
    printf("%s\n", s);
    return 0;
}

Literał napisowy leży w sekcji pliku wykonywalnego przeznaczonej tylko do odczytu. char *s = "hello" wskazuje w jej wnętrze, a zapis przez ten wskaźnik to błąd ochrony, który system zgłasza jako segfault, tak samo jak dostęp do niezmapowanej pamięci.

Poprawka to utworzenie tablicy, która dostaje własną, modyfikowalną kopię:

Deklarowanie wskaźników na literały jako const char * zamienia tę awarię w czasie działania w błąd kompilacji, co jest zdecydowanie lepsze. Zrób z tego nawyk.

Znajdowanie prawdziwej linii: gdb

Skompiluj z -g, aby plik wykonywalny zawierał symbole debugowania, a potem uruchom go w debuggerze:

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

Wewnątrz 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

Większość pracy wykonują trzy polecenia. run uruchamia program i zatrzymuje się w miejscu błędu. backtrace (lub bt) pokazuje łańcuch wywołań, który do niego doprowadził, a ramka #1 to zwykle miejsce, gdzie zły wskaźnik faktycznie powstał. print pokazuje wartość zmiennej, a item = 0x0 wprost nazywa problem.

Na macOS odpowiednikiem jest lldb ./program, a potem run i bt.

Szybsze szukanie: AddressSanitizer

Jeszcze lepiej pozwolić kompilatorowi oprzyrządować program. AddressSanitizer łapie nieprawidłowy dostęp w chwili, gdy następuje, łącznie z tymi, które nie wysypałyby programu:

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

Ten raport nazywa klasę błędu, linię, która go spowodowała, linię, która zwolniła pamięć, i linię, która ją zaalokowała. To najskuteczniejsze narzędzie do debugowania błędów pamięci w C: działa z GCC i clang na Linuksie i macOS, a kosztuje mniej więcej dwukrotnie dłuższy czas działania, co podczas pracy nad kodem nie ma znaczenia.

Połącz je z -fsanitize=undefined, żeby jednocześnie łapać przepełnienie liczb ze znakiem i inne niezdefiniowane zachowania:

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

valgrind ./program to alternatywa, która nie wymaga ponownej kompilacji i zgłasza te same klasy błędów, a do tego wycieki.

Lista kontrolna, gdy trafisz na segfault

  1. Przebuduj z -g -Wall -Wextra -fsanitize=address,undefined i uruchom jeszcze raz. Najczęściej raport wskazuje linię i sprawa jest załatwiona.
  2. Jeśli sanitizer jest niedostępny, uruchom program w gdb i zrób backtrace. Patrz na ramkę 1, nie tylko na ramkę 0.
  3. Sprawdź każdy wskaźnik w linii z awarią. Wypisz każdy z nich: 0x0 oznacza null, a dziwna wartość w rodzaju 0x7fff5fc01000 zwykle oznacza wskaźnik niezainicjalizowany lub zwolniony.
  4. Zapytaj, skąd ten wskaźnik się wziął. Niesprawdzone malloc lub fopen? Adres zmiennej lokalnej z funkcji, która już wróciła? Wskaźnik użyty po free?
  5. Sprawdź każdą granicę pętli w pobliżu awarii, czy nie ma <= tam, gdzie miało być <.
  6. Jeśli ślad stosu ma tysiące ramek, to niekontrolowana rekurencja, a wcale nie błąd wskaźnika.

Zapobieganie

Nawyki, dzięki którym segfaulty stają się rzadkie:

  • Zawsze kompiluj z -Wall -Wextra i traktuj ostrzeżenia jak błędy.
  • Inicjalizuj każdy wskaźnik, wartością NULL, jeśli nie ma lepszej.
  • Sprawdzaj wynik malloc, calloc, realloc i fopen.
  • Ustawiaj wskaźniki na NULL od razu po zwolnieniu.
  • Używaj snprintf i fgets zamiast sprintf i gets.
  • Deklaruj wskaźniki na literały napisowe jako const char *.
  • Wybieraj sizeof arr / sizeof arr[0] zamiast wpisanej na sztywno długości.
  • Uruchamiaj testy pod AddressSanitizerem w CI.

Segfault to przyjazny sposób awarii, bo mówi ci, że coś jest nie tak. Ta sama klasa błędów, która po cichu psuje sąsiednią zmienną i trzy funkcje dalej daje złe wyniki, jest znacznie gorsza, a powyższe narzędzia łapią obie.

Najczęściej zadawane pytania

Czym jest segmentation fault w C?

To awaria wywoływana przez system operacyjny, gdy program sięga do pamięci, której nie wolno mu dotykać: czyta lub zapisuje przez nieprawidłowy wskaźnik, wychodzi za koniec tablicy na niezmapowaną stronę albo przepełnia stos. Jądro wysyła do procesu sygnał SIGSEGV, który go kończy i wypisuje "Segmentation fault (core dumped)".

Jak znaleźć miejsce, w którym występuje segmentation fault?

Skompiluj z symbolami debugowania i uruchom w debuggerze: gcc -g program.c -o program, potem gdb ./program, run, a po awarii backtrace. To wypisze dokładny plik i linię. Przy błędach pamięci jeszcze szybsze jest gcc -g -fsanitize=address program.c -o program: samo uruchomienie programu wypisze wtedy pełny raport, co poszło nie tak i gdzie.

Dlaczego mój program w C tylko czasami dostaje segfault?

Bo nieprawidłowy dostęp to niezdefiniowane zachowanie, a nie gwarantowana awaria. Zapis jednego elementu za tablicą często trafia w pamięć, która należy do procesu, więc nic go nie zatrzymuje, a zamiast tego psuje sąsiednią zmienną. Segfault pojawia się tylko wtedy, gdy zły adres wypada poza zmapowaną stronę, a to zależy od układu pamięci w danej kompilacji i uruchomieniu.

Czy segmentation fault oznacza wyciek pamięci?

Nie, to przeciwne problemy. Wyciek to pamięć zaalokowana i nigdy niezwolniona: program działa dalej i powoli rośnie. Segfault to dotknięcie pamięci, która do ciebie nie należy. Podwójne zwolnienie pamięci albo użycie wskaźnika po zwolnieniu powoduje segfaulty, a zapomnienie o zwolnieniu powoduje wycieki.

Ilustracja języków programowania w Coddy

Ucz się programowania z Coddy

ZACZNIJ