sigaction and Signal Handlers

sigaction(2) is the modern, reliable way to examine or change what a process does when it receives a signal: ignore it, take the default action, or run a handler. It supersedes the old signal(2), whose semantics — does the handler stay installed? is the signal blocked while it runs? are syscalls restarted? — varied across Unix flavors and even across Linux versions, making it unsafe for anything beyond setting SIG_DFL/SIG_IGN (signal(2): “Avoid its use: use sigaction(2) instead.”). sigaction nails all of that down explicitly through a struct sigaction carrying the handler, a mask of additional signals to block during the handler, and a flag word; with SA_SIGINFO the handler also receives a rich siginfo_t describing why the signal fired. This note walks the structure field by field, the flags that change delivery semantics, and sigaltstack(2), the companion call that lets you catch SIGSEGV from a blown stack (sigaction(2)).

What this note owns. This note is the reference for the disposition-installation interface: struct sigaction field by field, the complete sa_flags bit table and how the flags interact, the siginfo_t/si_code payload that SA_SIGINFO unlocks, sigaltstack(2), and the precise, measured difference between sigaction() and the legacy signal(). Adjacent notes own the neighbouring layers and are cross-linked rather than duplicated: Standard Signals and Their Default Actions owns the per-signal number/default-action/catchability table; Restartable System Calls and ERESTARTSYS owns the SA_RESTART restart machinery and its measured per-syscall truth table; Signal Delivery and the Return to Userspace owns get_signal() and the signal-frame construction; Signal Sets and Masking owns sigset_t and sigprocmask; Async-Signal-Safety and Reentrant Handlers owns what a handler may legally call; signalfd owns fd-based delivery.

Measurement environment

Every number, program output, and readback in this note was produced on Linux 7.1.13-200.fc44.x86_64 (Fedora 44), glibc 2.43, GCC 16.2.1, x86-64, on 2026-09-12. Kernel source claims are pinned to Linux v6.12 (the vault’s pin); where the running 7.1.13 headers were also checked, the note says so. The two agreed on every fact checked here.

Mental Model — A Per-Signal Disposition Table

Think of every process (more precisely, every thread group sharing a sighand_struct) as owning a table with one slot per signal number. Each slot holds a disposition: SIG_DFL (do the kernel’s built-in default — terminate, dump core, stop, or ignore depending on the signal), SIG_IGN (discard it), or a pointer to a user function (run it). sigaction() is the read/modify/write operation on one slot. The flag word and mask attached to the slot are not afterthoughts — they decide whether the handler stays installed, what is blocked while it runs, whether interrupted syscalls restart, and which stack it runs on. Getting those right is the whole reason sigaction exists.

flowchart LR
  APP["sigaction(signum, act, oldact)"] --> KERN["do_sigaction()<br/>kernel/signal.c"]
  KERN --> TBL["sighand->action[signum-1]<br/>= struct k_sigaction"]
  TBL --> H["sa_handler / sa_sigaction<br/>(the function, or SIG_DFL/SIG_IGN)"]
  TBL --> M["sa_mask<br/>(extra signals blocked in handler)"]
  TBL --> F["sa_flags<br/>(SA_SIGINFO, SA_RESTART, ...)"]
  DELIV["on delivery:<br/>get_signal() reads this slot"] --> TBL

Installing and consuming a disposition. What it shows: sigaction writes one slot of the per-thread-group action table; at delivery time get_signal() reads the same slot to decide handler vs default vs ignore and how to set up the frame. The insight to take: the handler pointer is only one of three things you configure — the mask and flags are equally load-bearing, and the old racy signal() left them implicit and inconsistent.

The slot is only half the story, though. A disposition is consumed at a very specific moment — the next transition from kernel mode back to user mode — and the flags in the slot decide what happens at that moment. The sequence below traces one signal from generation to rt_sigreturn, showing exactly where each SA_* flag takes effect. Everything in it is drawn from kernel/signal.c at Linux v6.12: get_signal() (line 2780 onward) reads the slot and applies SA_ONESHOT; signal_delivered() (line 2941) applies sa_mask and SA_NODEFER.

sequenceDiagram
    autonumber
    participant S as Sender<br/>(kill / fault / timer)
    participant K as Kernel
    participant P as Pending set<br/>(task or thread-group)
    participant U as User thread
    participant H as Handler frame<br/>(on stack or altstack)
    S->>K: generate signal N
    K->>P: sigaddset(pending, N)<br/>queue siginfo if RT
    Note over P: if N is blocked, it stops here<br/>and simply stays pending
    K-->>U: any kernel-to-user transition<br/>(syscall return, IRQ, fault return)
    U->>K: arch_do_signal_or_restart()
    K->>K: get_signal() reads action[N-1]
    alt disposition is SIG_IGN
        K->>K: discard, loop for next signal
    else disposition is SIG_DFL
        K->>K: do_group_exit / coredump / stop
    else disposition is a handler
        K->>K: if SA_RESETHAND, action[N-1].sa_handler = SIG_DFL
        K->>H: setup_rt_frame(): push ucontext + siginfo,<br/>set sp (SA_ONSTACK picks ss_sp), set pc = handler
        K->>K: signal_delivered(): blocked |= sa_mask<br/>and, unless SA_NODEFER, |= N
        K-->>H: iret to userspace, handler runs
        H->>K: rt_sigreturn() via sa_restorer trampoline
        K->>U: restore registers and old blocked mask,<br/>resume interrupted instruction
    end

One signal’s full life cycle, from generation to rt_sigreturn. What it shows: the four places a sigaction setting actually bites — SA_RESETHAND at step 8 (before the handler runs), SA_ONSTACK and SA_SIGINFO at step 9 when the frame is built, and sa_mask/SA_NODEFER at step 10 when the blocked set is widened. The insight to take: a signal is never “delivered” at the instant it is generated. Generation and delivery are separated by an arbitrary amount of time and by the blocked mask, and everything sigaction configures is read at delivery time, not at install time — which is why changing a disposition can affect a signal that was generated seconds earlier.

That separation gives a small state machine, worth holding in mind because most signal bugs are really confusions about which state a signal is in:

stateDiagram-v2
    [*] --> Generated : kill(2) / fault / timer / sigqueue(3)
    Generated --> Pending : sigaddset(pending, N)
    Generated --> Discarded : disposition is SIG_IGN,<br/>or SIG_DFL whose default is Ign
    Pending --> Pending : blocked in the thread mask —<br/>a second standard signal coalesces
    Pending --> Discarded : sigaction() installs SIG_IGN<br/>flush_sigqueue_mask() drops it
    Pending --> Consumed : signalfd / sigwaitinfo / sigtimedwait<br/>reads it out of the queue
    Pending --> Delivered : unblocked and a kernel-to-user<br/>transition happens
    Delivered --> HandlerRunning : disposition is a handler
    Delivered --> DefaultAction : disposition is SIG_DFL
    HandlerRunning --> [*] : rt_sigreturn() restores<br/>registers and blocked mask
    DefaultAction --> [*] : Term / Core / Stop / Cont / Ign
    Discarded --> [*]
    Consumed --> [*]

The states a signal passes through. What it shows: Pending is an absorbing state for as long as the signal is blocked, and the self-loop is where standard signals lose information — a second SIGUSR1 arriving while the first is pending sets a bit that is already set (measured below: 10 sent, 1 delivered). The insight to take: sigaction() can move a signal from Pending straight to Discarded by installing SIG_IGN, which is the POSIX 3.3.1.3 rule do_sigaction() implements with flush_sigqueue_mask() — installing SIG_IGN is not just “future signals are ignored”, it retroactively cancels ones already queued.

struct sigaction — Field by Field

The userspace structure (sigaction(2)) is:

struct sigaction {
    void     (*sa_handler)(int);
    void     (*sa_sigaction)(int, siginfo_t *, void *);
    sigset_t   sa_mask;
    int        sa_flags;
    void     (*sa_restorer)(void);
};
  • sa_handler — the simple, one-argument handler void h(int signum), or one of the special constants SIG_DFL (default action) and SIG_IGN (ignore). Used when SA_SIGINFO is not set in sa_flags.
  • sa_sigaction — the three-argument handler void h(int signum, siginfo_t *info, void *ucontext), used when SA_SIGINFO is set. The man page warns: “On some architectures a union is involved: do not assign to both sa_handler and sa_sigaction.” On Linux sa_handler and sa_sigaction overlap in a union, so you fill exactly one and set the flag accordingly. The first argument is the signal number; the second points at the siginfo_t (below); the third is a ucontext_t * describing the interrupted machine state (the same context [[Signal Delivery and the Return to Userspace|rt_sigreturn restores]]), castable to inspect or even modify the register set that resumes after the handler.
  • sa_mask — a set of additional signals to block while this handler executes. The signal being handled is also blocked (unless SA_NODEFER), so sa_mask is for blocking other signals you don’t want interrupting this handler. Restored automatically when the handler returns.
  • sa_flags — the bit field that selects semantics; detailed below.
  • sa_restorer — “not intended for application use.” It holds the address of the return trampoline (__restore_rt in the VDSO/libc); the C library fills it in. On x86-64 a frame is rejected if SA_RESTORER isn’t set, so glibc always supplies it. You never touch this field.
classDiagram
  class struct_sigaction {
    +void (*sa_handler)(int)
    +void (*sa_sigaction)(int, siginfo_t*, void*)
    +sigset_t sa_mask
    +int sa_flags
    +void (*sa_restorer)(void)
  }
  class UNION_one_or_the_other {
    sa_handler and sa_sigaction
    overlap in memory on Linux
    fill exactly ONE
  }
  class chosen_by_SA_SIGINFO {
    flag clear, use sa_handler
    flag set, use sa_sigaction
  }
  class library_owned {
    sa_restorer holds the
    __restore_rt trampoline
    glibc fills it in, always
  }
  struct_sigaction --> UNION_one_or_the_other : sa_handler / sa_sigaction
  UNION_one_or_the_other --> chosen_by_SA_SIGINFO : which one is live
  struct_sigaction --> library_owned : sa_restorer

The five members, and which of them you are actually allowed to touch. What it shows: sa_handler and sa_sigaction are not two independent fields — on Linux they occupy the same storage, and SA_SIGINFO is the tag that says which interpretation is live. sa_restorer is not yours at all. The insight to take: zero the whole struct first (struct sigaction sa = {0};), then set exactly one handler member and the matching flag. Assigning both is not “belt and braces”, it is writing two different values into one word.

The third argument to sigaction() itself, oldact, receives the previous disposition if non-NULL, so you can save and later restore a handler.

The siginfo_t Payload (SA_SIGINFO)

With SA_SIGINFO, the handler’s second argument is a siginfo_t, a large union whose meaningful members depend on the signal and on si_code. The man page lists (sigaction(2)):

siginfo_t {
    int      si_signo;     /* Signal number */
    int      si_errno;     /* An errno value */
    int      si_code;      /* Signal code: WHY it was sent */
    pid_t    si_pid;       /* Sending process ID */
    uid_t    si_uid;       /* Real UID of sending process */
    int      si_status;    /* Exit value or signal (SIGCHLD) */
    union sigval si_value; /* Value supplied with sigqueue() */
    void    *si_addr;      /* Faulting address (SIGSEGV/SIGBUS/...) */
    int      si_fd;        /* File descriptor (SIGIO) */
    int      si_pkey;      /* Protection key (SEGV_PKUERR) */
    ... /* many more, signal-specific */
}

“si_signo, si_errno and si_code are defined for all signals.” si_code is the discriminator that tells you how the signal was generated and which other members are valid:

There are two layers of si_code values, and conflating them is a common source of confusion. The kernel supplies the discriminator itself, as two macros in include/uapi/asm-generic/siginfo.h: #define SI_FROMUSER(siptr) ((siptr)->si_code <= 0) and #define SI_FROMKERNEL(siptr) ((siptr)->si_code > 0).

flowchart TD
  IN["Handler entered with SA_SIGINFO<br/>read info->si_code"] --> Q{"SI_FROMUSER, i.e. si_code <= 0 ?"}
  Q -- "yes" --> ORIG["ORIGIN layer — somebody sent it<br/>SI_USER=0, SI_QUEUE=-1, SI_TIMER=-2, SI_MESGQ=-3,<br/>SI_ASYNCIO=-4, SI_SIGIO=-5, SI_TKILL=-6,<br/>SI_DETHREAD=-7, SI_ASYNCNL=-60"]
  Q -- "no, SI_FROMKERNEL" --> FAULT["KERNEL layer — the kernel generated it.<br/>SI_KERNEL itself is 0x80, also positive"]
  ORIG --> O1["si_pid / si_uid identify the sender<br/>si_value carries a sigqueue payload"]
  FAULT --> SW{"Which signal?"}
  SW -- SIGSEGV --> S1["SEGV_MAPERR / SEGV_ACCERR / SEGV_PKUERR ...<br/>si_addr is meaningful"]
  SW -- SIGBUS --> S2["BUS_ADRALN / BUS_ADRERR / BUS_MCEERR_AR ...<br/>si_addr, si_addr_lsb"]
  SW -- SIGCHLD --> S3["CLD_EXITED / CLD_KILLED / CLD_STOPPED ...<br/>si_status means exit code OR signal number<br/>depending on the code — read the code first"]
  SW -- SIGSYS --> S4["SYS_SECCOMP / SYS_USER_DISPATCH<br/>si_syscall, si_arch, si_call_addr"]
  SW -- "SIGILL / SIGFPE / SIGTRAP / SIGIO" --> S5["ILL_* / FPE_* / TRAP_* / POLL_*"]

How to decode si_code in two steps. What it shows: the sign of si_code is the first discriminator, and only after that does the signal number select which enumeration applies. The insight to take: use the kernel’s own SI_FROMUSER/SI_FROMKERNEL macros rather than comparing against constants, because the obvious guess is wrong in one place — SI_KERNEL is 0x80, a positive value, so it lands on the kernel side despite reading like an origin code. Note also SI_DETHREAD (-7, “sent by execve() killing subsidiary threads”), which is in the kernel header but not in the man page’s table. The first layer is origin codes, which are valid for any signal and answer “who sent this?”. These are all negative or zero constants, which is how you tell them apart from the fault codes: a positive si_code means the kernel generated the signal from a hardware or software event, a non-positive one means somebody sent it.

si_codeOriginWhich extra members become valid
SI_USERkill(2)si_pid, si_uid — the sender’s PID and real UID
SI_KERNEL“Sent by the kernel”none guaranteed beyond the mandatory three
SI_QUEUEsigqueue(3)si_pid, si_uid, and si_value — the caller’s union sigval payload (see Realtime Signals)
SI_TIMER“POSIX, or setitimer(2) or alarm(2) timer expired”si_timerid, si_overrun, si_value
SI_MESGQ (Linux 2.6.6+)“POSIX message queue state changed”; see mq_notify(3)si_value
SI_ASYNCIO“AIO completed”si_value
SI_SIGIO“Queued SIGIO” — only up to Linux 2.2historical; from Linux 2.4 onward SIGIO/SIGPOLL uses the POLL_* codes below
SI_TKILL (Linux 2.4.19+)tkill(2) or tgkill(2)si_pid, si_uid
SI_ASYNCNL“Async name lookup completion”si_value

The second layer is per-signal fault codes, positive values whose meaning depends on which signal arrived. This is the layer that makes SA_SIGINFO worth using at all: a one-argument sa_handler knows that a SIGSEGV happened, while a three-argument handler knows where (si_addr) and why (si_code).

Signalsi_code valuesMeaning, and the extra members that come with them
SIGSEGVSEGV_MAPERR / SEGV_ACCERR“Address not mapped to object” vs “Invalid permissions for mapped object” — the difference between dereferencing NULL and writing a read-only page. si_addr is the faulting address.
SEGV_BNDERR (3.19+)“Failed address bound checks”; adds si_lower and si_upper.
SEGV_PKUERR (4.6+)“Access was denied by memory protection keys”; adds si_pkey, the key that applied. See pkeys(7).
SEGV_MTEAERR / SEGV_MTESERR (5.10+, ARM only)Asynchronous / synchronous Memory Tagging Extension error. This is the case SA_EXPOSE_TAGBITS exists for.
SEGV_CPERR (6.10+)“Control protection fault” — shadow-stack / indirect-branch-tracking violation.
SIGBUSBUS_ADRALN / BUS_ADRERR / BUS_OBJERR“Invalid address alignment” / “Nonexistent physical address” (the classic mmap-past-EOF fault) / “Object-specific hardware error”.
BUS_MCEERR_AR / BUS_MCEERR_AO (2.6.32+)Machine-check memory error, “action required” (consumed) vs “action optional” (detected but not consumed); adds si_addr_lsb, the least-significant bit of the affected address range, which tells you the size of the poisoned region.
SIGILLILL_ILLOPC, ILL_ILLOPN, ILL_ILLADR, ILL_ILLTRP, ILL_PRVOPC, ILL_PRVREG, ILL_COPROC, ILL_BADSTKIllegal opcode / operand / addressing mode / trap, privileged opcode / register, coprocessor error, internal stack error. si_addr points at the offending instruction.
SIGFPEFPE_INTDIV, FPE_INTOVF, FPE_FLTDIV, FPE_FLTOVF, FPE_FLTUND, FPE_FLTRES, FPE_FLTINV, FPE_FLTSUBInteger divide-by-zero and overflow, then the six IEEE-754 conditions and subscript-out-of-range. FPE_FLTUNK (4.17+) is the “undiagnosed” catch-all.
SIGTRAPTRAP_BRKPT / TRAP_TRACE“Process breakpoint” / “Process trace trap” — what a debugger sees.
TRAP_UNK (4.18+), TRAP_PERF (5.13+, glibc 2.43+)Undiagnosed trap; and a perf event configured with sigtrap=1.
SIGCHLDCLD_EXITED, CLD_KILLED, CLD_DUMPED, CLD_TRAPPED, CLD_STOPPED, CLD_CONTINUED (2.6.9+)Exited normally / killed by a signal / dumped core / traced child trapped / stopped / continued. Comes with si_pid, si_uid, si_status — and si_status is an exit code for CLD_EXITED but a signal number for CLD_KILLED, so the code must be read before the status.
SIGIO / SIGPOLLPOLL_IN, POLL_OUT, POLL_MSG, POLL_ERR, POLL_PRI, POLL_HUPWhich readiness condition fired; adds si_band and si_fd.
SIGSYSSYS_SECCOMP (3.5+, glibc 2.43+)“Triggered by a seccomp(2) filter rule”; adds si_call_addr, si_syscall, si_arch — the return address, syscall number, and AUDIT_ARCH_* of the blocked call.
SYS_USER_DISPATCH (5.11+, glibc 2.43+)Syscall User Dispatch triggered.

Two point-in-time notes on that table. TRAP_PERF, SYS_SECCOMP, and SYS_USER_DISPATCH are all marked in sigaction(2) as available “since glibc 2.43” — the kernel has supported them for years, but the constants only became visible through glibc’s headers in the release this note’s host runs, so a program built against an older glibc had to define them itself. And the si_code constants are only exposed if a feature-test macro asks for them: _GNU_SOURCE, or _POSIX_C_SOURCE >= 200809L; the TRAP_* constants need the first two cases specifically, and before glibc 2.20 none of them needed any macro at all.

Uncertain

Verify: the complete member-by-member layout of siginfo_t for the rarely-used unions. Reason: siginfo_t is a union and this note enumerates the members reachable from each si_code above, not every member of every architecture-specific arm (e.g. the SPARC-only SEGV_ACCADI/SEGV_ADIDERR/SEGV_ADIPERR, PARISC’s FPE_CONDTRAP, EMT_TAGOVF for SIGEMT, and the IA-64-only TRAP_BRANCH/TRAP_HWBKPT are listed in the man page but not expanded here because they cannot be exercised on x86-64). To resolve: cross-check the “The siginfo_t argument” section of sigaction(2) and the kernel’s include/uapi/asm-generic/siginfo.h before relying on any member on a non-x86 architecture. uncertain

The Flags — sa_flags Bit by Bit

The flag word is where sigaction earns its keep: every semantic that the legacy signal() left to the vendor’s taste is one explicit bit here. The numeric values are not arbitrary trivia — they are stable UAPI, and knowing them lets you read a raw sa_flags value out of a debugger or a core dump. All values below are from Linux v6.12 include/uapi/asm-generic/signal-defs.h and, for SA_RESTORER, arch/x86/include/uapi/asm/signal.h line 64; the semantics are quoted from sigaction(2).

FlagValueWhat it changesInteractions and gotchas
SA_NOCLDSTOP0x00000001For SIGCHLD only: “do not receive notification when child processes stop or resume.”Ignored for every other signal. Without it a SIGCHLD handler also fires on SIGSTOP/SIGCONT of a child, which callers that only want exit notification almost never expect. Orthogonal to SA_NOCLDWAIT.
SA_NOCLDWAIT0x00000002For SIGCHLD only: “do not transform children into zombies when they terminate.”Equivalent in effect to setting SIGCHLD to SIG_IGN. Makes wait() block until all children exit and then fail with ECHILD — so it is incompatible with any code that still reaps.
SA_SIGINFO0x00000004“The signal handler takes three arguments, not one” — selects sa_sigaction, delivering siginfo_t * and ucontext_t *.Mutually exclusive with sa_handler: the two members are a union on Linux, so set exactly one member and the matching flag. Required to read si_addr, si_code, si_pid, or a realtime si_value.
SA_UNSUPPORTED0x00000400A probe bit that is guaranteed never to be supported.The kernel enforces this with BUILD_BUG_ON(UAPI_SA_FLAGS & SA_UNSUPPORTED) in do_sigaction(). Set it, read oldact back: if it came back cleared, this kernel clears unknown bits (Linux 5.11+) and the rest of oldact->sa_flags can be trusted as a support report. If it came back set, the kernel is older and readback tells you nothing.
SA_EXPOSE_TAGBITS0x00000800Preserves architecture-defined pointer tag bits in siginfo.si_addr instead of clearing them.Matters on arm64 with the Memory Tagging Extension (MTE) and on SPARC ADI, where the tag is the diagnostically interesting part of the faulting address. Present in the kernel UAPI but not declared by glibc 2.43’s <signal.h> — see the measurement below.
SA_RESTORER0x04000000Says sa_restorer holds the return trampoline’s address.Set by the C library, never by the application. On x86-64 the kernel requires it; glibc always supplies it, which is why it shows up in every readback. New architectures are told not to define it.
SA_ONSTACK0x08000000“Call the signal handler on an alternate signal stack provided by sigaltstack(2).”Silently does nothing if no alternate stack is armed, or if the thread is already running on it. The only way to catch SIGSEGV from stack exhaustion — demonstrated below.
SA_RESTART0x10000000“Provide behavior compatible with BSD signal semantics by making certain system calls restartable across signals.”Per-disposition, not per-syscall: it is the flag on the handler that ran that decides. Covers only the ERESTARTSYS set; poll, select, nanosleep, sem_timedwait, io_getevents and the timeout-bearing socket calls still return EINTR. Full truth table in Restartable System Calls and ERESTARTSYS.
SA_NODEFER (SA_NOMASK)0x40000000“Do not add the signal to the thread’s signal mask while the handler is executing, unless the signal is specified in act.sa_mask.”Turns off the automatic self-block, so the signal can re-enter its own handler recursively. The escape hatch is sa_mask: listing the signal there re-blocks it even with SA_NODEFER set. Recursive SIGSEGV under SA_NODEFER exhausts the stack almost immediately.
SA_RESETHAND (SA_ONESHOT)0x80000000“Restore the signal action to the default upon entry to the signal handler.”The reset happens in get_signal() before the frame is built, so there is no window in which a second instance could take the old handler. Only sa_handler is reset — sa_flags is left alone, which is measurable (below). Combined with SA_NODEFER this reproduces exact System V signal() semantics; that is literally how the kernel’s legacy signal syscall is implemented.

Two further bits exist in the same word but are not part of the user-facing contract. 0x00800000 is SA_IMMUTABLE, defined in include/linux/signal_types.h with the comment “Used to kill the race between sigaction and forced signals”. When the kernel forces a fatal signal on a task — force_sig_info_to_task() with HANDLER_EXIT — it resets the disposition to SIG_DFL and sets SA_IMMUTABLE, and do_sigaction() then refuses with -EINVAL for that signal forever after. This is why a process cannot race a SIGSEGV handler back into place after the kernel has already decided to kill it. The remaining values 0x00000008 through 0x00000200 are recorded in the header as taken by alpha, mips, parisc, and sparc, which is why the generic flags cluster at the top and bottom of the word.

Which bits does the kernel actually keep? (measured)

Since Linux 5.11, do_sigaction() masks both directions of the call with UAPI_SA_FLAGS:

/* kernel/signal.c, Linux v6.12, lines 4192-4203 */
BUILD_BUG_ON(UAPI_SA_FLAGS & SA_UNSUPPORTED);
if (act)
        act->sa.sa_flags &= UAPI_SA_FLAGS;
if (oact)
        oact->sa.sa_flags &= UAPI_SA_FLAGS;

with, from include/linux/signal_types.h:

#define UAPI_SA_FLAGS                                                          \
	(SA_NOCLDSTOP | SA_NOCLDWAIT | SA_SIGINFO | SA_ONSTACK | SA_RESTART |  \
	 SA_NODEFER | SA_RESETHAND | SA_EXPOSE_TAGBITS | __ARCH_UAPI_SA_FLAGS)

and __ARCH_UAPI_SA_FLAGS expanding to SA_RESTORER on any architecture that defines it — x86 does. That predicts exactly nine surviving bits. Setting each of the 32 bits alone on SIGUSR1 and reading it back through oldact confirms it, with no exceptions:

Bit setReadbackResultIdentity
0x000000010x04000001keptSA_NOCLDSTOP
0x000000020x04000002keptSA_NOCLDWAIT
0x000000040x04000004keptSA_SIGINFO
0x000004000x04000000droppedSA_UNSUPPORTED — dropped by design
0x000008000x04000800keptSA_EXPOSE_TAGBITS
0x040000000x04000000keptSA_RESTORER (glibc sets it regardless)
0x080000000x0c000000keptSA_ONSTACK
0x100000000x14000000keptSA_RESTART
0x400000000x44000000keptSA_NODEFER
0x800000000x84000000keptSA_RESETHAND
all 22 others0x04000000droppedunassigned or architecture-private

Two things fall out of the measurement that the documentation does not spell out. First, the readback always carries SA_RESTORER (0x04000000) even when the caller never set it, because glibc’s sigaction wrapper always fills in the trampoline — so oldact->sa_flags is never equal to the value you passed in, and code that compares them for equality will always see a mismatch. Second, SA_EXPOSE_TAGBITS is supported by this kernel, but a program that writes SA_EXPOSE_TAGBITS after including only <signal.h> will not compile: glibc 2.43 does not declare it, and the symbol comes from the kernel’s <asm-generic/signal-defs.h> instead. The SA_UNSUPPORTED probe is the portable way to ask the question at run time rather than at compile time.

The absence is upstream, not a distribution artefact: glibc 2.43’s sysdeps/unix/sysv/linux/bits/sigaction.h declares exactly seven SA_* constants — SA_NOCLDSTOP, SA_NOCLDWAIT, SA_SIGINFO, SA_ONSTACK, SA_RESTART, SA_NODEFER, SA_RESETHAND — plus the aliases SA_NOMASK, SA_ONESHOT, SA_STACK, and one oddity: SA_INTERRUPT 0x20000000 /* Historical no-op. */. Neither SA_UNSUPPORTED, SA_EXPOSE_TAGBITS, nor SA_RESTORER appear. SA_INTERRUPT is the sharpest illustration of why “the header defines it” is not the same as “the kernel honours it”: glibc still exports the name, and bit 0x20000000 is precisely one of the bits the probe above measured as dropped. A program that sets SA_INTERRUPT expecting to defeat SA_RESTART is writing a constant that the kernel throws away.

A Worked Example

#include <signal.h>
#include <string.h>
#include <stdio.h>
#include <unistd.h>
 
static volatile sig_atomic_t got_sigint = 0;
 
static void on_segv(int sig, siginfo_t *info, void *uctx) {
    /* async-signal-safe-only: write() is allowed, printf() is NOT */
    char buf[64];
    int n = snprintf(buf, sizeof buf, "SIGSEGV at %p code=%d\n",
                     info->si_addr, info->si_code);
    write(STDERR_FILENO, buf, n);   /* snprintf is not AS-safe; see note */
    _exit(139);
}
 
static void on_int(int sig) { got_sigint = 1; }
 
int main(void) {
    /* 1. Install an alternate stack so we can handle SIGSEGV from a
          blown stack, where the normal stack is unusable.
          NOTE: `static char altstk[SIGSTKSZ];` does NOT compile under
          _GNU_SOURCE on glibc >= 2.34 — SIGSTKSZ is a sysconf() call
          there, not a constant. Allocate it instead. */
    size_t sz = (size_t)sysconf(_SC_SIGSTKSZ);
    char *altstk = malloc(sz);
    stack_t ss = { .ss_sp = altstk, .ss_size = sz, .ss_flags = 0 };
    sigaltstack(&ss, NULL);
 
    /* 2. SIGSEGV: rich handler on the alt stack, one-shot. */
    struct sigaction sa_segv = {0};
    sa_segv.sa_sigaction = on_segv;
    sa_segv.sa_flags     = SA_SIGINFO | SA_ONSTACK | SA_RESETHAND;
    sigemptyset(&sa_segv.sa_mask);
    sigaction(SIGSEGV, &sa_segv, NULL);
 
    /* 3. SIGINT: plain handler, restart slow syscalls, block SIGTERM
          while it runs. */
    struct sigaction sa_int = {0};
    sa_int.sa_handler = on_int;
    sa_int.sa_flags   = SA_RESTART;
    sigemptyset(&sa_int.sa_mask);
    sigaddset(&sa_int.sa_mask, SIGTERM);
    sigaction(SIGINT, &sa_int, NULL);
 
    while (!got_sigint) pause();   /* pause() is NOT restartable: returns on signal */
    return 0;
}

Line by line: sigaltstack() registers altstk as the stack handlers tagged SA_ONSTACK will run on. For SIGSEGV we set sa_sigaction (because SA_SIGINFO is set) so the handler can print info->si_addr; SA_ONSTACK runs it on the alternate stack (mandatory if the original stack overflowed); SA_RESETHAND makes it fire once then revert to the default (so a second fault dumps core normally instead of looping). For SIGINT we use the simple sa_handler, SA_RESTART so a read() interrupted by Ctrl-C resumes, and put SIGTERM in sa_mask so a SIGTERM can’t interrupt the SIGINT handler. Note the comment on snprintf: it is not on the async-signal-safe list, so this example is illustrative — a strictly-correct handler would format with hand-rolled AS-safe code (see Async-Signal-Safety and Reentrant Handlers). sig_atomic_t and volatile on got_sigint are required: it is written by the handler and read by main, and only volatile sig_atomic_t is guaranteed atomically accessible across that boundary.

Inside the Kernel — do_sigaction()

sigaction() reaches the kernel as rt_sigaction, which calls do_sigaction() (v6.12 kernel/signal.c, line 4170):

int do_sigaction(int sig, struct k_sigaction *act, struct k_sigaction *oact)
{
    struct task_struct *p = current, *t;
    struct k_sigaction *k;
    sigset_t mask;
 
    if (!valid_signal(sig) || sig < 1 || (act && sig_kernel_only(sig)))
        return -EINVAL;
 
    k = &p->sighand->action[sig-1];
    spin_lock_irq(&p->sighand->siglock);
    if (k->sa.sa_flags & SA_IMMUTABLE) {     /* kernel forced this one; refuse */
        spin_unlock_irq(&p->sighand->siglock);
        return -EINVAL;
    }
    if (oact)
        *oact = *k;
 
    BUILD_BUG_ON(UAPI_SA_FLAGS & SA_UNSUPPORTED);
    if (act)
        act->sa.sa_flags &= UAPI_SA_FLAGS;   /* strip unknown flag bits */
    if (oact)
        oact->sa.sa_flags &= UAPI_SA_FLAGS;  /* ...in both directions */
 
    sigaction_compat_abi(act, oact);
 
    if (act) {
        sigdelsetmask(&act->sa.sa_mask,
                      sigmask(SIGKILL) | sigmask(SIGSTOP));
        *k = *act;
        /*
         * POSIX 3.3.1.3: setting a pending signal's action to SIG_IGN
         * (or SIG_DFL when the default is ignore) discards it.
         */
        if (sig_handler_ignored(sig_handler(p, sig), sig)) {
            sigemptyset(&mask);
            sigaddset(&mask, sig);
            flush_sigqueue_mask(&mask, &p->signal->shared_pending);
            for_each_thread(p, t)
                flush_sigqueue_mask(&mask, &t->pending);
        }
    }
    spin_unlock_irq(&p->sighand->siglock);
    return 0;
}

Several guarantees fall out of this code:

  • SIGKILL and SIGSTOP cannot be caught or ignored. act && sig_kernel_only(sig) returns -EINVAL for any attempt to change their disposition. The kernel must always be able to kill and stop a process.
  • You cannot block SIGKILL/SIGSTOP via sa_mask either. sigdelsetmask(&act->sa.sa_mask, sigmask(SIGKILL) | sigmask(SIGSTOP)) silently strips them from the mask you supplied, so even a handler can never run with SIGKILL blocked.
  • Unknown flag bits are cleared in both directions (act->sa.sa_flags &= UAPI_SA_FLAGS on the way in, oact->sa.sa_flags &= UAPI_SA_FLAGS on the way out), which is the mechanism behind SA_UNSUPPORTED probing and behind the bit-by-bit readback table above.
  • A forced signal’s disposition is frozen. If the kernel has already decided to kill the task with this signal, force_sig_info_to_task() has set SA_IMMUTABLE on the slot, and do_sigaction() bails out with -EINVAL before touching anything. This is what the header comment calls killing “the race between sigaction and forced signals”: without it, a SIGSEGV handler reinstalled from inside a fault handler could keep a doomed process alive indefinitely.
  • Setting SIG_IGN discards already-pending instances. Per POSIX, changing a pending signal’s disposition to ignore flushes it from both the per-process shared_pending queue and each thread’s pending queue (flush_sigqueue_mask). Without this you could install SIG_IGN and still take one more delivery of an already-queued instance.

The whole thing runs under p->sighand->siglock, the spinlock guarding the shared action table — handlers are a per-thread-group resource (threads created with CLONE_SIGHAND share one sighand_struct), so installing a handler in one thread changes it for all.

sigaltstack — A Separate Stack for the Handler

The companion call sigaltstack(2) registers an alternate stack for handlers flagged SA_ONSTACK. Its structure (sigaltstack(2)):

typedef struct {
    void  *ss_sp;     /* Base address of stack */
    int    ss_flags;  /* Flags */
    size_t ss_size;   /* Number of bytes in stack */
} stack_t;

The motivating use case, in the man page’s words: “The most common usage of an alternate signal stack is to handle the SIGSEGV signal that is generated if the space available for the standard stack is exhausted: in this case, a signal handler for SIGSEGV cannot be invoked on the standard stack; if we wish to handle it, we must use an alternate signal stack.” The logic is unavoidable: if the thread overflowed its stack, the kernel cannot push a signal frame onto that same (full) stack — setup_rt_frame() would fault and the delivery would degrade to a forced, fatal SIGSEGV (see Signal Delivery and the Return to Userspace). With SA_ONSTACK, get_sigframe() switches sp to ss_sp + ss_size instead, giving the handler clean room to run.

Flags and constants:

NameDirectionMeaning
SS_ONSTACKqueried only — appears in oldss.ss_flagsThe thread is currently executing on the alternate stack. “It is not possible to change the alternate signal stack if the thread is currently executing on it” — trying returns EPERM. This is also the cheapest way for a handler to prove to itself which stack it is on, used in the reproduction below.
SS_DISABLEset (in ss.ss_flags) or queriedDisable the alternate stack. When setting, “the kernel ignores any other flags in ss.ss_flags and the remaining fields” — so you need not zero ss_sp/ss_size. When querying, it means no alternate stack is currently armed.
SS_AUTODISARMset(Linux 4.7+) “Clear the alternate signal stack settings on entry to the signal handler. When the signal handler returns, the previous alternate signal stack settings are restored.” Makes the alt stack safe under swapcontext(3) and coroutine libraries, which would otherwise leave a still-armed alt stack pointing at a stack that has been switched away. The kernel honours it in signal_delivered(): if (current->sas_ss_flags & SS_AUTODISARM) sas_ss_reset(current);.
MINSIGSTKSZconstant / sysconf(_SC_MINSIGSTKSZ)The bare minimum the kernel needs to build a frame. sigaltstack() fails with ENOMEM if ss_size is smaller.
SIGSTKSZconstant / sysconf(_SC_SIGSTKSZ)The recommended size — minimum plus headroom for the handler’s own frames.

The size constants are a trap worth spelling out, because on this host they are neither what the names suggest nor what a portable-looking program would compute. Architectures with large or variable register files (arm64 with the Scalable Vector Extension, x86 with AVX-512 and AMX) cannot state a signal-frame size at compile time, so glibc made these macros runtime expressions. sysdeps/unix/sysv/linux/bits/sigstksz.h at glibc 2.43 reads, in full:

#if defined __USE_DYNAMIC_STACK_SIZE && __USE_DYNAMIC_STACK_SIZE
# include <unistd.h>
/* Default stack size for a signal handler: sysconf (SC_SIGSTKSZ).  */
# undef SIGSTKSZ
# define SIGSTKSZ sysconf (_SC_SIGSTKSZ)
/* Minimum stack size for a signal handler: SIGSTKSZ.  */
# undef MINSIGSTKSZ
# define MINSIGSTKSZ SIGSTKSZ
#endif

Two consequences, both measured on this host:

ExpressionValue with _GNU_SOURCEValue withoutNote
SIGSTKSZ (macro)135048192Runtime sysconf call vs. the old compile-time constant
MINSIGSTKSZ (macro)135042048Aliased to SIGSTKSZ by the header above
sysconf(_SC_SIGSTKSZ)1350413504The real recommended size on this CPU
sysconf(_SC_MINSIGSTKSZ)33763376The real minimum — 4× smaller than the macro claims

This behaviour arrived in glibc 2.34, pinned by existence-checking the file itself against the sourceware git tags: sysdeps/unix/sysv/linux/bits/sigstksz.h returns HTTP 404 at glibc-2.32 and glibc-2.33 and HTTP 200 at glibc-2.34 and every tag after. The gate is the feature-test macro _DYNAMIC_STACK_SIZE_SOURCE, which <features.h> turns on automatically whenever _GNU_SOURCE is defined. That means the behaviour flips based on a #define at the top of the file, not on the glibc version. First consequence: SIGSTKSZ is no longer an integer constant expression, so static char altstk[SIGSTKSZ]; — the idiom in every older textbook — stops compiling the moment _GNU_SOURCE appears:

t_sz.c:3:13: error: variably modified 'altstk' at file scope
    3 | static char altstk[SIGSTKSZ];

The same file compiles cleanly with the _GNU_SOURCE line removed. Code that must have an alternate stack therefore has to malloc it (or use a fixed literal and accept the risk of being wrong on a future CPU). Second, and more subtly, glibc deliberately #defines MINSIGSTKSZ to equal SIGSTKSZ, so the macro reports 13504 while the kernel’s actual floor via sysconf(_SC_MINSIGSTKSZ) is 3376. The macro is therefore conservative, not wrong — but a program that sizes a per-thread alt stack from MINSIGSTKSZ on the assumption that it is a tight lower bound will allocate four times more than it needs, multiplied by every thread. The rule that follows: size alternate stacks from sysconf(_SC_SIGSTKSZ) and never from the macro.

Why sigaction Superseded signal()

The historical point is worth stating plainly because it is a common interview/debugging trap. The old signal() had two incompatible lineages (signal(2)):

  • System V semantics: on delivery, “the disposition of the signal would be reset to SIG_DFL, and the system did not block delivery of further instances of the signal.” This is racy: between entering the handler and the handler reinstalling itself, a second signal takes the default action (often: terminate). And the signal isn’t blocked during the handler, inviting recursion.
  • BSD semantics: “the signal disposition is not reset, and further instances of the signal are blocked from being delivered while the handler is executing,” and certain syscalls auto-restart.

The man page states the System V equivalence in sigaction terms exactly: original-Unix and System V signal() “is equivalent to calling sigaction(2) with the following flags: sa.sa_flags = SA_RESETHAND | SA_NODEFER;”. That is not a paraphrase — it is literally what the kernel’s legacy signal system call does, in kernel/signal.c at Linux v6.12:

SYSCALL_DEFINE2(signal, int, sig, __sighandler_t, handler)
{
	struct k_sigaction new_sa, old_sa;
	int ret;
 
	new_sa.sa.sa_handler = handler;
	new_sa.sa.sa_flags = SA_ONESHOT | SA_NOMASK;   /* = SA_RESETHAND | SA_NODEFER */
	sigemptyset(&new_sa.sa.sa_mask);
 
	ret = do_sigaction(sig, &new_sa, &old_sa);
 
	return ret ? ret : (unsigned long)old_sa.sa.sa_handler;
}

That syscall is compiled in only where __ARCH_WANT_SYS_SIGNAL is defined, and x86 does define it (arch/x86/include/asm/unistd.h) — but defining the macro only builds the function; it does not give it a syscall number. arch/x86/entry/syscalls/syscall_32.tbl maps it at number 48 for i386, while syscall_64.tbl has no signal row at all, and this host’s /usr/include/asm/unistd_64.h accordingly has no __NR_signal. So on x86-64 the System V signal entry point is reachable only through the 32-bit compat path: a native 64-bit program could not invoke it even deliberately. What glibc does is in sysdeps/posix/signal.c, where the public signal is a weak alias for __bsd_signal:

  act.sa_handler = handler;
  __sigemptyset (&act.sa_mask);
  __sigaddset (&act.sa_mask, sig);                             /* block sig in its own handler */
  act.sa_flags = __sigismember (&_sigintr, sig) ? 0 : SA_RESTART;
  if (__sigaction (sig, &act, &oact) < 0)
    return SIG_ERR;

So glibc’s signal() installs BSD semantics — no reset, the signal blocked during its handler (belt-and-braces: via sa_mask, on top of the kernel’s default self-block), and SA_RESTART on unless the obsolete siginterrupt(3) turned it off for that signal. Reading the disposition back through sigaction(sig, NULL, &oldact) after installing it each way makes all three families visible at once:

glibc signal()        sig=10 sa_flags=0x14000000  SA_RESTART=1 SA_RESETHAND=0 SA_NODEFER=0  sig in sa_mask=1
glibc sysv_signal()   sig=12 sa_flags=0xc4000000  SA_RESTART=0 SA_RESETHAND=1 SA_NODEFER=1  sig in sa_mask=0
sigaction(flags=0)    sig=1  sa_flags=0x04000000  SA_RESTART=0 SA_RESETHAND=0 SA_NODEFER=0  sig in sa_mask=0

Reduced to the four questions that actually differ:

QuestionSystem V signal() (= kernel sys_signal, and glibc sysv_signal(3))BSD signal() (= glibc signal(), measured above)sigaction() with sa_flags = 0
Does the handler stay installed after one delivery?No — reverts to SIG_DFL (SA_RESETHAND)YesYes
Is the signal blocked while its handler runs?No (SA_NODEFER) — the same signal can re-enter the handlerYes, both by default and explicitly via sa_maskYes, by the kernel’s default self-block
Are interrupted slow syscalls restarted?No — EINTRYes (SA_RESTART), unless siginterrupt() cleared itNo — EINTR
Can the handler see why the signal fired?NoNoOnly if you also set SA_SIGINFO
Raw sa_flags as read back on this host0xc40000000x140000000x04000000

The last row is the punchline of the whole note: three calls that all “install a handler” produce three different dispositions, and the difference is invisible in the source unless you know which library function you called. Note also that plain sigaction() with sa_flags = 0 is not the same as signal() — it does not set SA_RESTART, so migrating code from signal() to sigaction() without passing SA_RESTART silently introduces EINTR returns that were not there before. That is the single most common regression when people follow the man page’s advice, and it is why Restartable System Calls and ERESTARTSYS matters as soon as you touch this API.

Because “the semantics when using signal() to establish a signal handler vary across systems (and POSIX.1 explicitly permits this variation)”, the man page’s rule is absolute: “do not use it for this purpose.” sigaction removes the ambiguity by making every one of those choices an explicit flag.

One more glibc-specific behaviour is worth knowing, because it produces a confusing EINVAL from an apparently valid call. glibc reserves the first two realtime signal numbers for its own threading implementation — SIGCANCEL (32) and SIGSETXID (33) — and __bsd_signal rejects them through is_internal_signal(). On this host:

signal(32) -> SIG_ERR, errno=Invalid argument (glibc reserves 32/33)
SIGRTMIN=34 SIGRTMAX=64

So SIGRTMIN is 34, not 32, and hard-coding 32 for “the first realtime signal” fails. SIGRTMIN and SIGRTMAX are not compile-time constants in glibc — they expand to function calls — which is why they cannot appear in a case label. Details in Realtime Signals.

There is also an ABI footnote: the original sigaction syscall used a 32-bit sigset_t that couldn’t represent realtime signals after Linux 2.2, so the real syscall is rt_sigaction, which takes an extra size_t sigsetsize; “The glibc wrapper transparently call[s] rt_sigaction()” so applications never see this.

Reproductions — Watching the Flags Behave

Signal semantics are unusually cheap to test: none of what follows needs privilege, a special kernel config, or more than a few dozen lines of C. Every transcript below was produced on the host described at the top of this note.

SA_RESETHAND really does fire exactly once

Install a handler for SIGUSR1 with SA_RESETHAND, raise the signal, read the disposition back, then raise it again:

handler ran
before: handler=0x4005e0 SIG_DFL=(nil)
after 1st delivery: disposition=(nil)  is SIG_DFL? YES   sa_flags=0x84000000
raising again -> default action for SIGUSR1 is Term, so we should die now
exit=138

The process exits with status 138 — the shell’s 128 + signal encoding for “killed by signal 10”, which is SIGUSR1. So the first raise() ran the handler and the second took the default action (Term), exactly as the flag promises. The interesting detail is the readback: sa_flags is still 0x84000000, that is SA_RESETHAND | SA_RESTORER. The kernel reset sa_handler to SIG_DFL but left the flag word untouched, which matches the source precisely — get_signal() writes ka->sa.sa_handler = SIG_DFL; and nothing else. Code that reads oldact to decide “is a handler still installed?” must therefore test sa_handler, not the presence of SA_RESETHAND.

SA_NODEFER really does allow recursive delivery

The same handler, printing whether the signal is blocked inside its own handler and re-raising itself four times. Without the flag:

=== WITHOUT SA_NODEFER (default) ===
  entry #1 depth=1  SIGUSR1 blocked inside handler? YES
  entry #2 depth=1  SIGUSR1 blocked inside handler? YES
  entry #3 depth=1  SIGUSR1 blocked inside handler? YES
  entry #4 depth=1  SIGUSR1 blocked inside handler? YES
entries=4  max nesting depth=1

With it:

=== WITH SA_NODEFER ===
  entry #1 depth=1  SIGUSR1 blocked inside handler? NO
  entry #2 depth=2  SIGUSR1 blocked inside handler? NO
  entry #3 depth=3  SIGUSR1 blocked inside handler? NO
  entry #4 depth=4  SIGUSR1 blocked inside handler? NO
entries=4  max nesting depth=4

Both runs entered the handler four times, but only the second nested. That is the whole difference, and it is worth dwelling on because the default behaviour is easy to misread. In the default case the raise() inside the handler does not vanish — the signal is blocked, so it goes pending, and it is delivered the instant rt_sigreturn restores the pre-handler mask. The handler therefore runs four times sequentially, at depth 1 each time, and the stack never grows. With SA_NODEFER the signal is delivered immediately inside the handler and the frames stack up. Scale that to a SIGSEGV handler that itself faults, and the depth is bounded only by the stack.

sigaltstack is the only way to catch a stack-overflow SIGSEGV

This is the demonstration that most directly justifies SA_ONSTACK existing at all, and it is a genuine impossibility result rather than a matter of taste. The decision the kernel makes looks like this:

flowchart TD
  A["Signal N is deliverable<br/>and its disposition is a handler"] --> B{"SA_ONSTACK set<br/>in this signal's sa_flags?"}
  B -- no --> C["Frame goes on the<br/>interrupted thread's own stack<br/>(below the current sp, after the red zone)"]
  B -- yes --> D{"Is an alternate stack armed,<br/>and are we NOT already on it?"}
  D -- no --> C
  D -- yes --> E["Frame goes at ss_sp + ss_size<br/>(sas_ss_flags says SS_ONSTACK)"]
  C --> F{"Is there room?<br/>Can the kernel write the frame?"}
  E --> F
  F -- yes --> G["Handler runs"]
  F -- no --> H["setup_rt_frame() fails<br/>signal_setup_done(failed) calls force_sigsegv()<br/>disposition forced to SIG_DFL + SA_IMMUTABLE<br/>process dies, handler never runs"]
  H -.-> I["This is exactly the stack-overflow case:<br/>no room on the blown stack to build the frame"]

Where the signal frame is placed, and the one way that placement can fail. What it shows: SA_ONSTACK is not a preference the kernel weighs against others — it is the only branch that leads to a stack other than the faulting one, and the failure branch is not “the handler is skipped” but “the process is killed with SIGSEGV and the disposition is made immutable”. The insight to take: the failure is silent from the program’s point of view. There is no error return anywhere; a correctly-installed handler simply never runs.

A program that recurses with a 4 KiB frame until it blows its 16 MiB stack, with a SIGSEGV handler installed both ways:

--- no SA_ONSTACK
=== SA_ONSTACK NOT SET ===  sysconf(_SC_SIGSTKSZ)=13504 SIGSTKSZ=13504 MINSIGSTKSZ=13504
timeout: the monitored command dumped core
exit=139
--- with SA_ONSTACK
=== SA_ONSTACK SET ===  sysconf(_SC_SIGSTKSZ)=13504 SIGSTKSZ=13504 MINSIGSTKSZ=13504
HANDLER RAN: si_addr=0x7ffc0b798568 si_code=1 (SEGV_MAPERR=1 SEGV_ACCERR=2) on_altstack=1
exit=42

The alternate stack was armed in both runs — the only difference is the SA_ONSTACK bit. Without it the process dumps core at exit status 139 (128 + 11) and the handler never executes; with it the handler runs, reports si_addr (an address just past the low end of the exhausted 16 MiB stack — ulimit -s is 16384 KiB here), reports si_code = SEGV_MAPERR (address not mapped — the region below the stack is a hole, not a permission violation, so the fault is MAPERR and not ACCERR), confirms via sigaltstack(NULL, &cur) that cur.ss_flags & SS_ONSTACK is set, and exits 42 under its own control. One practical note on reproducing this: the recursion must be compiled at -O0 with -fno-optimize-sibling-calls. At -O2 GCC 16 turns the recursion into a loop, the stack never grows, and the test silently becomes a spin instead of a fault.

Standard signals coalesce; realtime signals queue

The last reproduction is the one that most often bites in production, because it is invisible until load arrives. Block both SIGUSR1 (a standard signal) and SIGRTMIN (a realtime signal), send ten of each while they are blocked, then unblock and count how many handler invocations arrive:

sent: 10 x SIGUSR1 (standard), 10 x SIGRTMIN (realtime), all while blocked
sigpending: SIGUSR1 pending=1 SIGRTMIN pending=1  (a *set*, so it can only say yes/no)
delivered: SIGUSR1 handler ran 1 time(s); SIGRTMIN handler ran 10 time(s)

Ten SIGUSR1s collapsed into one delivery; ten SIGRTMINs produced ten. The reason is structural, not a tuning parameter: a standard signal’s pending state is a bit in a sigset_t, and setting a bit that is already set changes nothing, so the ninth kill() is indistinguishable from the first. A realtime signal carries a queued struct sigqueue per instance, bounded by the RLIMIT_SIGPENDING resource limit (511917 on this host, from ulimit -i), so each sigqueue() adds an entry. The middle line makes the asymmetry concrete: sigpending() returns a set, so it can only ever answer “is at least one pending?” — it cannot tell you nine were dropped. Any design that counts events by counting standard-signal deliveries is undercounting whenever two arrive close together; that is what Realtime Signals exists to fix, and what signalfd sidesteps by making delivery a read().

Failure Modes and Common Mistakes

  • Calling unsafe functions in the handler. Using printf, malloc, or anything not on the async-signal-safe list can deadlock or corrupt state, because the handler interrupted the same code mid-operation. Symptom: rare, load-dependent hangs or crashes. Fix: restrict to AS-safe calls — see Async-Signal-Safety and Reentrant Handlers.
  • Non-volatile/non-sig_atomic_t flags. A flag set by a handler and read by the main loop must be volatile sig_atomic_t; otherwise the compiler may cache it in a register and the main loop never sees the change, or the write may not be atomic.
  • Forgetting SA_ONSTACK for SIGSEGV. A SIGSEGV handler that should diagnose stack overflow but isn’t on an alternate stack simply faults again and the process dies — the handler never runs. Symptom: process dies with SIGSEGV despite the handler being installed.
  • Assuming SA_RESTART covers everything. It doesn’t; poll/select/nanosleep/semop and timeout-bearing socket calls still return EINTR. Symptom: spurious EINTR from event loops even though every handler set SA_RESTART.
  • Assigning both sa_handler and sa_sigaction. They share a union on Linux; set exactly one and the matching SA_SIGINFO state. Zero-initialize the whole struct (struct sigaction sa = {0};) first to avoid garbage in sa_flags/sa_mask.
  • Expecting per-thread handlers. Dispositions are process-wide (per sighand_struct); only the signal mask is per-thread. Installing a handler in one thread changes it for the whole group.
  • Migrating signal() to sigaction() without SA_RESTART. Measured above: glibc’s signal() sets SA_RESTART, while sigaction() with a zeroed sa_flags does not. The mechanical translation of signal(SIGCHLD, h) into a zero-initialized struct sigaction therefore changes behaviour — long-running read()/accept() calls that used to resume transparently start returning EINTR. Symptom: an EINTR handling bug that appears immediately after a “no functional change” refactor. See Restartable System Calls and ERESTARTSYS.
  • Counting events by counting standard-signal deliveries. Reproduced above: ten SIGUSR1s sent while blocked produce one handler call. Symptom: a work counter that is correct under light load and silently low under burst load. Fix: use realtime signals, or a signalfd, or treat the signal as a level-triggered “check the real state” nudge rather than an edge-triggered event.
  • Assuming a SIGSEGV handler can be reinstalled after the kernel forces the signal. Once force_sig_info_to_task() has set SA_IMMUTABLE on a slot, every later sigaction() for that signal returns -EINVAL. Symptom: a crash handler that reinstalls itself “just in case” gets a surprising EINVAL on the second fault, at exactly the moment it is least able to cope.
  • Using MINSIGSTKSZ as a tight lower bound. glibc #defines it to SIGSTKSZ under _GNU_SOURCE, so it reports 13504 where the kernel’s real floor is 3376. Harmless per process, expensive at a few thousand threads.
  • Setting SA_INTERRUPT to cancel SA_RESTART. glibc declares the constant 0x20000000; the kernel’s UAPI_SA_FLAGS mask drops it. The flag does nothing. To get EINTR, simply do not set SA_RESTART.

Alternatives

  • signal(2) — only for SIG_DFL/SIG_IGN; never for installing handlers (see above).
  • signalfd — synchronous, fd-based signal reception: no handler, no reentrancy, no siginfo_t union juggling — you read() struct signalfd_siginfo records. Trade-off: the signal must be blocked first so it stays pending for the fd.
  • sigwaitinfo/sigtimedwait — synchronously wait for a (blocked) signal and receive its siginfo_t on a normal control-flow boundary, avoiding async handlers entirely.
  • pidfd + waitid — replaces SIGCHLD + SA_NOCLDSTOP handler logic with a pollable child-exit fd.

For genuinely asynchronous events the kernel can only express as signals — SIGSEGV/SIGFPE/SIGBUS faults, SIGALRM, terminal SIGINT/SIGTSTP — sigaction with the right flags remains the tool.

See Also