Anatomia segfaulta — od MMU przez kernel po core dump w gdb

Co dokładnie dzieje się, gdy proces dotyka złego adresu

Segfault (segmentation fault) to nie „program się wywalił”. To precyzyjny, deterministyczny mechanizm ochrony pamięci, w którym MMU (Memory Management Unit) wykrywa dostęp do strony pamięci niezgodny z jej uprawnieniami, generuje wyjątek sprzętowy, kernel obsługuje go w page fault handler i — gdy nie da się go obsłużyć legalnie — wysyła sygnał SIGSEGV do procesu winowajcy.

Większość deweloperów na widok Segmentation fault (core dumped) wzrusza ramionami i dodaje printf. Inżynier rozumiejący segfault czyta core dump w gdb, lokalizuje dokładną instrukcję, identyfikuje czy to null deref, stack overflow, czy use-after-free — i naprawia przyczynę, nie objaw.

Ten artykuł rozkłada anatomię segfaulta od poziomu sprzętu (MMU, page table) przez kernel (do_page_fault, dostarczenie sygnału) po praktyczną analizę post-mortem w gdb. Bez tej wiedzy debugging błędów pamięci to zgadywanie.

Warstwa sprzętowa: MMU i translacja adresów

Każdy adres, którego dotyka twój program, to adres wirtualny. Procesor nigdy nie adresuje pamięci fizycznej bezpośrednio — między instrukcją a RAM stoi MMU, która tłumaczy adres wirtualny na fizyczny przez wielopoziomową strukturę page table.

Na x86-64 translacja używa czteropoziomowej hierarchii (PML4 → PDPT → PD → PT), gdzie każdy wpis (PTE — Page Table Entry) zawiera nie tylko adres fizyczny ramki, ale też bity uprawnień:

  • Present (P) — czy strona jest w pamięci fizycznej
  • Read/Write (R/W) — czy zapis jest dozwolony
  • User/Supervisor (U/S) — czy userspace może dotknąć tej strony
  • Execute-Disable (NX) — czy strona zawiera kod wykonywalny

Gdy instrukcja CPU próbuje dostępu naruszającego te bity — adres nie jest zmapowany (P=0), zapis do read-only (R/W=0), wykonanie strony z NX=1 — MMU generuje page fault: wyjątek sprzętowy #PF (vector 14), który przekazuje sterowanie do kernela wraz z kodem błędu i adresem winowajcy w rejestrze CR2.

Kluczowe rozróżnienie: page fault ≠ segfault

To rozróżnienie kompromituje większość kandydatów na rozmowach. Nie każdy page fault to segfault. Page fault to normalny, częsty mechanizm — kernel obsługuje miliony page faultów na sekundę bez żadnego problemu:

Typ page faultCo się dziejeCzy segfault?
Minor faultStrona w pamięci, brak mapowania w PT (np. współdzielona biblioteka)❌ Kernel mapuje, wznawia
Major faultStrona wyswapowana na dysk — trzeba wczytać❌ Kernel ładuje z dysku
Demand pagingPierwszy dostęp do zaalokowanej, ale nietkniętej strony❌ Kernel alokuje fizyczną ramkę
Copy-on-WriteZapis do strony współdzielonej po fork()❌ Kernel kopiuje stronę
Invalid accessAdres poza legalnym vma procesuSIGSEGV

Dopiero gdy kernel ustali, że adresu nie da się zalegalizować — nie należy do żadnego Virtual Memory Area (VMA) procesu lub narusza uprawnienia VMA — eskaluje do SIGSEGV.

Warstwa kernela: ścieżka od #PF do SIGSEGV

Na Linuksie x86-64 obsługa zaczyna się w do_page_fault() (dziś handle_page_fault() w arch/x86/mm/fault.c). Uproszczona ścieżka decyzyjna:

/* Pseudokod ścieżki page fault handlera w kernelu */
void handle_page_fault(struct pt_regs *regs, unsigned long error_code) {
    unsigned long address = read_cr2();  /* adres winowajcy */
    struct vm_area_struct *vma;

    /* Znajdź VMA zawierający ten adres */
    vma = find_vma(current->mm, address);

    if (!vma) {
        /* Adres poza jakimkolwiek legalnym mapowaniem */
        goto bad_area;  /* → SIGSEGV */
    }

    if (address < vma->vm_start) {
        /* Może to legalny wzrost stosu? */
        if (!(vma->vm_flags & VM_GROWSDOWN))
            goto bad_area;  /* → SIGSEGV (np. stack overflow) */
        expand_stack(vma, address);
    }

    /* Sprawdź uprawnienia: zapis do read-only? wykonanie NX? */
    if ((error_code & PF_WRITE) && !(vma->vm_flags & VM_WRITE))
        goto bad_area;  /* → SIGSEGV (zapis do .rodata) */

    /* Legalny fault — zmapuj stronę, wznów wykonanie */
    handle_mm_fault(vma, address, flags);
    return;

bad_area:
    /* Dostarcz SIGSEGV do procesu */
    force_sig_fault(SIGSEGV, si_code, (void __user *)address);
}

Sygnał SIGSEGV niesie ze sobą strukturę siginfo_t z polem si_code, które rozróżnia dlaczego nastąpił segfault:

  • SEGV_MAPERR — adres w ogóle nie jest zmapowany (klasyczny null deref, dziki wskaźnik)
  • SEGV_ACCERR — adres zmapowany, ale brak uprawnień (zapis do read-only, wykonanie NX)
  • SEGV_BNDERR — naruszenie granicy (Intel MPX)
  • SEGV_PKUERR — naruszenie protection key (PKU)

Taksonomia: trzy klasy segfaultów

Praktycznie wszystkie segfaulty sprowadzają się do trzech kategorii o fundamentalnie różnych przyczynach źródłowych.

1. Null pointer dereference

Adres 0x0 (i niska strona poniżej mmap_min_addr, domyślnie 64 KB) jest celowo niezmapowany przez kernel. To projektowa decyzja: dereferencja NULL ma natychmiast crashować, nie cicho czytać śmieci.

#include <stdio.h>

int main(void) {
    int *ptr = NULL;
    return *ptr;  /* SIGSEGV, si_code = SEGV_MAPERR, CR2 = 0x0 */
}
$ gcc -g segfault_null.c -o segfault_null
$ ./segfault_null
Segmentation fault (core dumped)

$ gdb -q ./segfault_null core
(gdb) bt
#0  0x0000555555555129 in main () at segfault_null.c:5
(gdb) print ptr
$1 = (int *) 0x0
(gdb) p/x $_siginfo._sifields._sigfault.si_addr
$2 = 0x0    # CR2 — dostęp do adresu zerowego

2. Stack overflow

Nieskończona (lub zbyt głęboka) rekurencja rozrasta stos poza jego limit (RLIMIT_STACK, domyślnie 8 MB). Gdy stos próbuje wejść na stronę guard page tuż poniżej dolnej granicy VMA — kernel widzi dostęp poza legalnym obszarem i nie może go obsłużyć jako wzrostu stosu.

#include <stdio.h>

/* Rekurencja bez warunku stopu — wykładniczy wzrost stosu */
long recurse(long depth) {
    char frame_buffer[4096];  /* 4 KB per ramka — szybciej wyczerpie stos */
    frame_buffer[0] = (char)depth;
    return recurse(depth + 1) + frame_buffer[0];
}

int main(void) {
    return (int)recurse(0);  /* SIGSEGV po ~2000 ramek */
}
$ gdb -q ./stack_overflow core
(gdb) bt
#0  recurse (depth=2046) at stack_overflow.c:6
#1  recurse (depth=2045) at stack_overflow.c:7
#2  recurse (depth=2044) at stack_overflow.c:7
... (tysiące identycznych ramek — sygnatura stack overflow)

(gdb) p $sp
$1 = (void *) 0x7ffffffde000   # wskaźnik stosu na granicy guard page
(gdb) info proc mappings
# Adres $sp pokrywa się z dolną granicą [stack] VMA

Sygnatura diagnostyczna: tysiące identycznych ramek w backtrace + $sp dokładnie na dolnej granicy mapowania [stack].

3. Use-after-free / heap corruption

Najpodstępniejsza klasa, bo nie zawsze crashuje deterministycznie. Dostęp do zwolnionej pamięci może działać poprawnie dopóki allocator nie odda strony do kernela (przez munmap) — wtedy adres staje się niezmapowany i dopiero wtedy następuje segfault, daleko od miejsca błędu.

#include <stdlib.h>
#include <string.h>

typedef struct {
    char name[64];
    void (*callback)(void);  /* wskaźnik na funkcję — wektor ataku */
} Session;

int main(void) {
    Session *s = malloc(sizeof(Session));
    strcpy(s->name, "session-1");
    free(s);                  /* pamięć zwolniona */

    /* Use-after-free: callback wskazuje na śmieci po realokacji areny */
    s->callback();            /* SIGSEGV — wykonanie spod losowego adresu */
    return 0;
}

Tej klasy nie debuguje się gołym gdb — potrzeba instrumentacji:

# AddressSanitizer — wykrywa use-after-free w momencie dostępu, nie crasha
$ gcc -g -fsanitize=address use_after_free.c -o uaf
$ ./uaf
==12847==ERROR: AddressSanitizer: heap-use-after-free on address 0x...
    #0 0x... in main use_after_free.c:16
freed by thread T0 here:
    #1 0x... in free
    #2 0x... in main use_after_free.c:13   # dokładny free()
previously allocated by thread T0 here:
    #3 0x... in malloc
    #4 0x... in main use_after_free.c:11    # dokładny malloc()

ASan podaje trzy stack trace’y: gdzie nastąpił błędny dostęp, gdzie pamięć została zwolniona, gdzie była zaalokowana. To zamienia godziny zgadywania w trzy sekundy czytania.

Praktyka: konfiguracja core dumps

Zanim cokolwiek zdebugujesz post-mortem, system musi faktycznie zapisać core dump. Domyślnie na wielu dystrybucjach jest to wyłączone (ulimit -c 0):

# Włącz nieograniczone core dumps dla bieżącej sesji
$ ulimit -c unlimited

# Gdzie systemd zapisuje core dumps (większość nowoczesnych dystrybucji)
$ cat /proc/sys/kernel/core_pattern
|/usr/lib/systemd/systemd-coredump %P %u %g %s %t %c %h

# Lista przechwyconych core dumps
$ coredumpctl list

# Otwórz najnowszy crash bezpośrednio w gdb
$ coredumpctl gdb

# Wzorzec pliku zamiast systemd-coredump (dla kontenerów/CI)
$ echo "/tmp/core.%e.%p" | sudo tee /proc/sys/kernel/core_pattern

Metodyka czytania backtrace

Otwarcie core dumpa to dopiero początek. Systematyczna analiza:

Komenda gdbCo ujawnia
bt fullBacktrace z lokalnymi zmiennymi każdej ramki
frame NPrzeskok do konkretnej ramki stosu
info registersStan rejestrów w momencie crasha (RIP = błędna instrukcja)
x/i $ripDisasembluje instrukcję, która spowodowała segfault
p/x $_siginfosi_code i si_addr — dlaczego i pod jakim adresem
info proc mappingsMapa VMA — czy adres jest w stosie, heapie, czy nigdzie
p variableWartość wskaźnika — najczęściej 0x0 lub oczywisty śmieć

Złota reguła: zacznij od x/i $rip i p/x $_siginfo._sifields._sigfault.si_addr. Instrukcja + adres docelowy w 80% przypadków natychmiast zdradzają klasę błędu.

Narzędzia: kiedy gdb nie wystarcza

NarzędzieWykrywaNarzut
gdb + corePost-mortem: gdzie crashłoZero (po fakcie)
AddressSanitizerUAF, heap/stack overflow, use-after-return~2× CPU, ~3× RAM
Valgrind (memcheck)UAF, leaks, uninitialized reads~20–50× CPU
MemorySanitizerOdczyt niezainicjalizowanej pamięci~3× CPU
Valgrind + vgdbUAF z interaktywnym gdb w momencie błędu~20× CPU

Reguła praktyczna: ASan w CI i development (szybki, wykrywa większość), Valgrind do trudnych przypadków (wolny, ale głębszy), gdb + core do produkcji (jedyne co masz po fakcie).

Podsumowanie: segfault jako sygnał diagnostyczny

Segfault to nie awaria losowa — to deterministyczny mechanizm sprzętowo-kernelowy, który niesie precyzyjną informację: dokładny adres (CR2/si_addr), instrukcję (RIP), powód (si_code) i pełen kontekst wykonania (core dump). Inżynier, który traktuje tę informację jak materiał dowodowy — a nie jak komunikat do zignorowania — debuguje błędy pamięci w minuty zamiast w godziny.

Mechanika jest zawsze ta sama: MMU wykrywa naruszenie → #PF → kernel próbuje zalegalizować przez VMA → niepowodzenie → SIGSEGV z pełnym kontekstem. Zrozumienie tego łańcucha zamienia „program się wywalił” w „null deref w ramce 3, linia 142, wskaźnik niezainicjalizowany po wczesnym return„.

Pamięć w C/C++ nie wybacza. Ale system operacyjny daje ci dokładnie tyle informacji, ile potrzeba — pod warunkiem, że wiesz jak ją czytać.


Mechanika stron pamięci i copy-on-write, którą rozkładamy tu na czynniki pierwsze, wygląda inaczej z perspektywy tworzenia procesu — pokazuje to tekst o tym, co naprawdę robi fork() w Linuksie pod spodem. A jeśli zamiast samego zrozumienia segfaulta interesuje Cię metodyczne dochodzenie do przyczyny każdej awarii, opisałem to w materiale o debugowaniu przez dedukcję zamiast zgadywania.

Piotr Karasiński
Piotr Karasiński — samouk oprogramowania, pasjonat GNU/Linux i architektury systemów. Pisze o warstwie między „działa" a „rozumiem dlaczego działa" na devmindset.dev.

Dodaj komentarz