Systems Programming · Systems Debugging

From Page Fault to Signal: How SIGSEGV and SIGBUS Differ

The kernel path that turns a bad memory access into a signal, why a missing mapping and an unbacked page produce different signals, and what siginfo tells a handler.

A program that dereferences a null pointer and a program that reads past the end of a memory-mapped file both die by signal, and the signals are different. The difference is not cosmetic. SIGSEGV means the virtual address has no valid translation for this access; SIGBUS means the translation exists but the backing object could not supply the bytes. Collapsing the two into “bad memory access” throws away the one piece of information that usually identifies the bug.

Getting from a machine-level fault to a signal involves three distinct stages: the processor reporting a fault, the kernel deciding whether the fault is serviceable, and the kernel synthesising a signal with a code that records why.

What the processor actually reports

On x86-64, an access that cannot be completed raises exception vector 14, the page-fault exception. The processor pushes an error code onto the stack alongside the usual frame. The error code is a bit field, not an errno: bit 0 set means the page was present (a protection violation rather than a missing translation), bit 1 means the access was a write, bit 2 means it originated in user mode, bit 3 means a reserved bit was set in the paging structures, bit 4 means the fault happened during instruction fetch. The faulting linear address goes into CR2.

The kernel’s page-fault handler reads those bits and does not immediately signal anyone. Most faults are ordinary and get resolved. A page that was never touched has no physical frame yet, so the handler allocates one, fills it from the file or from swap or with zeros, installs the translation, and returns to the faulting instruction, which now succeeds. A write to a page that is mapped copy-on-write triggers the same kind of repair: allocate a private copy, adjust the page tables, resume. A stack access below the current stack pointer may legitimately grow the stack, so the handler extends the mapping and returns.

Only when none of those repairs applies does the handler call the signal path. That call passes an address and a code, and the code is what distinguishes the two signals.

The two failure shapes

SIGSEGV is generated when the kernel cannot find a mapping that covers the faulting address at all, or when the mapping exists but forbids this access. The two cases are separated by si_code: SEGV_MAPERR when no mapping covers the address, SEGV_ACCERR when the mapping exists but the permission bits reject the access — writing to a read-only text page, for instance, or executing from a non-executable mapping. A null dereference is SEGV_MAPERR, because address zero is simply not mapped; systems enforce this with mmap_min_addr, which prevents unprivileged processes from mapping the low pages that would make null dereferences harmless and hide the bug.

SIGBUS is generated when the mapping is valid and the permissions are correct but the backing store cannot provide the page. The canonical case is a file mapped with MAP_SHARED that another process truncates. The mapping still covers the address, the page tables may still have a translation for the pages that were faulted in earlier, but a page beyond the new end of file has nothing to read. Touching it yields BUS_ADRERR. A mapping of a device region where the hardware returns an error on access yields BUS_OBJERR. On architectures that require alignment for certain accesses, a misaligned access can produce BUS_ADRALN.

The truncation case has a subtle variant worth knowing: whether the process gets SIGBUS or silently reads zeros depends on the page size relative to the file length. If a file is 5000 bytes long on a 4096-byte page system, the second page is only partially backed. Bytes 4096 through 4999 are valid; bytes 5000 through 8191 lie in the same page and are zero-filled by the kernel. Truncating the file to 4500 bytes invalidates that whole second page from the mapping’s perspective, so an access at offset 4600, which was in bounds a moment earlier, now faults. The signal arrives at page granularity, not byte granularity, which is why the reported si_addr may look like it is inside the file.

Delivering the signal and returning from it

Once the kernel decides a signal is due, it builds a siginfo_t. For a fault-generated signal, si_signo holds the signal number, si_code the reason, and si_addr the faulting address. That last field is the single most useful piece of information in a crash report, and it is absent from the older signal() interface: only handlers installed with SA_SIGINFO receive the full structure.

Delivery follows the ordinary signal rules. The default action for both signals is to terminate the process and, if resource limits and the dump filter allow, write a core file — the contents of which are described in what an ELF core file preserves. A handler runs on the thread that faulted, with the faulting instruction address saved as the return address in the signal frame.

The return path is the part that surprises people. A handler for SIGSEGV normally returns to the instruction that faulted. If the handler has not changed the mapping or the pointer, the instruction faults again, the handler runs again, and the process loops until something else intervenes. There is no automatic skip. A handler that intends to recover must either repair the condition — map the page, adjust the pointer in the saved context — or leave the handler by a non-local jump so the faulting instruction is never retried. Both are legitimate; neither is what most handlers do.

A second trap is stack exhaustion. If the fault is caused by the stack pointer running into an unmapped guard page, the handler has no stack to run on. The kernel detects this case and, if the process installed an alternate signal stack with sigaltstack, switches to it before entering the handler. Without that, the handler faults on entry and the kernel kills the process with the original signal, which is why the crash appears to have no handler at all.

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

static void on_fault(int sig, siginfo_t *si, void *ctx)
{
    /* Write with write(2): stdio is not async-signal-safe. */
    char buf[160];
    int n = snprintf(buf, sizeof buf,
                     "signal %d code %d addr %p\n",
                     sig, si->si_code, si->si_addr);
    write(STDERR_FILENO, buf, (size_t)n);
    _exit(128 + sig);
}

int main(void)
{
    stack_t ss = { .ss_size = SIGSTKSZ };
    ss.ss_sp = malloc(ss.ss_size);
    sigaltstack(&ss, NULL);

    struct sigaction sa = {0};
    sa.sa_sigaction = on_fault;
    sa.sa_flags = SA_SIGINFO | SA_ONSTACK;
    sigemptyset(&sa.sa_mask);
    sigaction(SIGSEGV, &sa, NULL);
    sigaction(SIGBUS, &sa, NULL);

    *(volatile int *)0 = 1;   /* SEGV_MAPERR */
    return 0;
}

Two details in that handler matter more than the structure. write(2) is used rather than printf, because the C library’s buffered output is not async-signal-safe and may deadlock if the fault interrupted a stdio call. And _exit is used rather than exit, because exit runs atexit handlers and flushes streams, both of which can touch state the fault left inconsistent.

Distinguishing faults from traps and aborts

Not every fatal signal comes from the page-fault path, and the distinction changes what a handler can do. SIGILL is raised by the processor on an invalid instruction encoding, and also deliberately by runtimes that emit a trap instruction to mark unreachable code. SIGFPE covers integer division by zero and integer overflow of the most negative value divided by minus one, despite the name suggesting floating point; on x86 the floating-point unit does not trap by default and produces infinities and NaNs instead. SIGTRAP is the breakpoint and single-step signal, generated by the debug exception.

The architectural difference is that faults are restartable and traps are not. A page fault leaves the instruction pointer at the faulting instruction so the kernel can fix the page and retry. A trap leaves it after the instruction, because the instruction completed. For a debugger, this is why stepping over a faulting instruction is awkward and why setting a breakpoint by replacing a byte with int3 works at all: the trap is reported after the substituted instruction executes, and the debugger must restore the original byte before continuing.

Reading the evidence in order

For a crash under investigation, the useful sequence is: si_signo to know which family, si_code to know the shape, si_addr to know the address, and then the mapping list to see what that address was. A SEGV_MAPERR at an address that looks like a small offset from a pointer suggests a null-plus-field access. A SEGV_ACCERR at an address inside a known read-only region suggests a write through a stale pointer. A BUS_ADRERR at an address inside a shared file mapping suggests the file shrank underneath the process, which is a race between processes rather than a bug in the faulting code.

None of that reconstruction is possible from the shell’s one-line “Segmentation fault” message, which is why the kernel’s core file and the siginfo_t it records are worth more than the exit status. The signal number is the headline; the code and address are the story.

References

  1. sigaction(2) — Linux man-pages — Linux man-pages project
  2. mmap(2) — Linux man-pages — Linux man-pages project
  3. Intel 64 and IA-32 Architectures Software Developer Manual, Volume 3A — Intel Corporation