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
- Przebuduj z
-g -Wall -Wextra -fsanitize=address,undefinedi uruchom jeszcze raz. Najczęściej raport wskazuje linię i sprawa jest załatwiona. - Jeśli sanitizer jest niedostępny, uruchom program w gdb i zrób
backtrace. Patrz na ramkę 1, nie tylko na ramkę 0. - Sprawdź każdy wskaźnik w linii z awarią. Wypisz każdy z nich:
0x0oznacza null, a dziwna wartość w rodzaju0x7fff5fc01000zwykle oznacza wskaźnik niezainicjalizowany lub zwolniony. - Zapytaj, skąd ten wskaźnik się wziął. Niesprawdzone
malloclubfopen? Adres zmiennej lokalnej z funkcji, która już wróciła? Wskaźnik użyty pofree? - Sprawdź każdą granicę pętli w pobliżu awarii, czy nie ma
<=tam, gdzie miało być<. - 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 -Wextrai traktuj ostrzeżenia jak błędy. - Inicjalizuj każdy wskaźnik, wartością
NULL, jeśli nie ma lepszej. - Sprawdzaj wynik
malloc,calloc,reallocifopen. - Ustawiaj wskaźniki na
NULLod razu po zwolnieniu. - Używaj
snprintfifgetszamiastsprintfigets. - 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.