The Generic Syscall Entry and Exit Layer
Every architecture must do nearly the same bookkeeping every time userspace enters the kernel through a system call: tell lockdep that interrupts are off, wake RCU out of its userspace quiescent state, run seccomp filters, fire syscall tracepoints, report to a ptrace tracer, record an audit entry — and on the way out, deliver pending signals, reschedule if asked, run task work, and restore the tracing/RCU state in reverse. For most of Linux’s history each architecture re-implemented this in hand-written assembly and arch-specific C, and they drifted: a flag handled on one architecture but forgotten on another, a seccomp hook in the wrong order, a subtle RCU-state bug visible only under
NO_HZ_FULL. The generic entry code —kernel/entry/common.cplusinclude/linux/entry-common.h— introduced in Linux 5.9 (2020) by Thomas Gleixner with x86 as the first and only initial user, factors all of this common work into two architecture-independent entry points,syscall_enter_from_user_mode()andsyscall_exit_to_user_mode(), leaving each architecture responsible only for the genuinely machine-specific parts: the register save/restore, the trap instruction, the dispatch table, and a handful ofarch_*hooks. This note traces that layer as it stands in Linux 6.12, which is this vault’s pin and one of several longterm kernels (as of 2026-09-11kernel.org/releases.jsonlists 6.18, 6.12, 6.6, 6.1 and 5.15 as longterm, with 7.2.5 stable and 7.3-rc2 mainline) — not the only LTS, and not the newest. Where the layer has changed since 6.12, the note says so and dates the change.
This note belongs to the Linux System Call Interface MOC. It is the control-flow companion to SYSCALL_DEFINE and Syscall Handler Wrappers (which is about the handler itself) and sits between Per-Architecture Syscall Entry Assembly (the asm that calls into this C) and Thread Info Flags and Syscall Exit Work / Returning to Userspace and exit_to_user_mode (the work this layer dispatches on the way out).
Mental Model: A Common Funnel Between Two Thin Arch Shims
Think of a syscall’s lifetime as a sandwich. The bread is per-architecture assembly: on the way in it saves registers into a pt_regs frame and switches to the kernel stack; on the way out it restores them and executes sysret/iret. The filling — the part every architecture wants to be identical — is the generic entry code.
flowchart TB ASM_IN["ARCH ASM (entry_SYSCALL_64)<br/>save regs to pt_regs, swap to kernel stack"] ENTER["syscall_enter_from_user_mode(regs, nr)<br/>GENERIC: enter_from_user_mode + work"] EWORK["syscall_trace_enter()<br/>SYSCALL_WORK: user-dispatch, ptrace, seccomp,<br/>tracepoint, audit -- may rewrite or skip nr"] DISPATCH["arch dispatch: x64_sys_call(regs, nr)<br/>-> __x64_sys_*(regs) handler"] XWORK["syscall_exit_to_user_mode(regs)<br/>GENERIC: audit exit, tracepoint, ptrace exit"] LOOP["exit_to_user_mode_loop()<br/>TIF flags: resched, signals, uprobe,<br/>notify-resume, livepatch -- runs until clear"] ASM_OUT["ARCH ASM<br/>restore pt_regs, sysret/iret to ring 3"] ASM_IN --> ENTER --> EWORK --> DISPATCH --> XWORK --> LOOP --> ASM_OUT
The syscall path with the generic entry layer as the common filling between two thin arch-asm slices. What it shows: the architecture assembly only saves/restores state and performs the trap; everything between — entry work (seccomp/ptrace/audit/tracepoints), the handler dispatch, and exit work (signals/resched/task-work) — is generic C in kernel/entry/common.c. The insight: before this layer, the “filling” was duplicated in each arch’s assembly and drifted apart; consolidating it means a new feature (e.g. Syscall User Dispatch) is implemented once and every GENERIC_ENTRY architecture gets it for free, in the correct order.
Why It Was Factored Out
The motivation was correctness through consolidation, not abstraction for its own sake. Before 5.9, the work that must happen on entry — context tracking (RCU), lockdep IRQ-state tracking, seccomp, audit, ptrace, tracepoints — was implemented per architecture, much of it in assembly, and the orderings and flag sets diverged. The commit that created the layer says so in three sentences (142781e108b1, “entry: Provide generic syscall entry functionality”, Gleixner, 22 July 2020):
On syscall entry certain work needs to be done: — Establish state (lockdep, context tracking, tracing) — Conditional work (ptrace, seccomp, audit…). This code is needlessly duplicated and different in all architectures. Provide a generic version based on the x86 implementation which has all the RCU and instrumentation bits right.
“Based on the x86 implementation” is the key phrase: the generic layer is not a new design, it is x86’s entry C promoted to kernel/, because x86 was the architecture whose RCU/instrumentation state machine had already been debugged. Three commits on the same day built the whole layer — 142781e108b1 (syscall entry, +88 lines in kernel/entry/common.c), a9f3a74a29af (syscall exit, +169), and a5497bab5f72 (interrupt entry/exit, +117).
The introduction release can be pinned by existence-check rather than memory: kernel/entry/common.c returns HTTP 404 at the v5.8 tag and HTTP 200 at v5.9, and arch/x86/Kconfig first contains select GENERIC_ENTRY at v5.9 (checked 2026-09-11 against raw.githubusercontent.com). So: Linux 5.9, x86 as the only initial user.
Who Actually Uses It (verified, not assumed)
Adoption is much narrower than the layer’s prominence suggests. Grepping select GENERIC_ENTRY in every arch/*/Kconfig at the v6.12 tag gives exactly four architectures:
| Architecture | select GENERIC_ENTRY at v6.12? | Notes |
|---|---|---|
x86 | yes (arch/x86/Kconfig) | first user, since 5.9 |
s390 | yes | |
riscv | yes | |
loongarch | yes | |
arm64 | no | keeps its own arch/arm64/kernel/entry-common.c (HTTP 200 at v6.12) |
powerpc | no at v6.12 | gains select GENERIC_ENTRY between v7.1 (absent) and v7.2 (present) |
arm, mips, csky, alpha, parisc, sh, sparc, m68k, microblaze, nios2, openrisc, hexagon, arc, xtensa, um | no | bespoke entry code |
Adoption of CONFIG_GENERIC_ENTRY, measured by fetching each arch/*/Kconfig at the v6.12, v7.1 and v7.2 tags on 2026-09-11. The insight: “the generic entry layer” is generic in the sense of architecture-independent, not in the sense of universally adopted — at the vault’s v6.12 pin only four architectures use it, and the largest non-x86 Linux architecture, arm64, is not one of them.
arm64’s absence is deliberate rather than neglectful: it has carried its own arch/arm64/kernel/entry-common.c — structurally similar, but with arm64-specific handling it did not want to push into the generic path — and the upstream response was to make the generic layer splittable rather than to force a wholesale conversion. In Linux 6.16, a70e9f647f50, “entry: Split generic entry into generic exception and syscall entry” (Jinjie Ruan, suggested by Mark Rutland) split the single Kconfig symbol into three, on the stated grounds that the two halves “are otherwise loosely coupled” and that separating them “will make it easier for architectures to migrate to generic entry code”:
config GENERIC_IRQ_ENTRY # generic exception/interrupt entry
bool
config GENERIC_SYSCALL # generic syscall entry (this note's subject)
bool
depends on GENERIC_IRQ_ENTRY
config GENERIC_ENTRY # both, the pre-6.16 meaning
bool
select GENERIC_IRQ_ENTRY
select GENERIC_SYSCALL
The migration path worked: at v7.2, arch/arm64/Kconfig selects GENERIC_IRQ_ENTRY — arm64 now shares the generic exception entry while still running its own syscall entry. This split also relocated code: include/linux/irq-entry-common.h and kernel/entry/syscall-common.c were created, and kernel/entry/common.c shrank by 112 lines. Everything below describes v6.12, where all of it still lives in kernel/entry/common.c and include/linux/entry-common.h.
The Entry Path, Step by Step
Before the code, the handover itself. The architecture assembly does not “call the generic layer” as a single opaque step — it calls three things in a fixed order, and the boundary between them is where every ordering constraint lives.
sequenceDiagram autonumber participant U as Userspace participant A as arch asm<br/>entry_SYSCALL_64 participant X as arch C<br/>do_syscall_64() participant G as generic C<br/>entry-common.h / entry/common.c participant H as handler<br/>__x64_sys_openat() U->>A: syscall instruction<br/>(IRQs off, ring 0) A->>A: swapgs, switch to kernel stack,<br/>build pt_regs, PUSH_AND_CLEAR_REGS A->>X: do_syscall_64(regs, nr) Note over X,G: noinstr region -- RCU not watching yet X->>G: syscall_enter_from_user_mode(regs, nr) G->>G: enter_from_user_mode():<br/>lockdep, then RCU, then tracing G->>G: local_irq_enable() G->>G: syscall_enter_from_user_mode_work():<br/>test syscall_work, maybe syscall_trace_enter() G-->>X: possibly rewritten nr, or -1 to skip Note over X,H: instrumentation_begin() -- normal kernel C X->>H: x64_sys_call(regs, nr) H-->>X: return value -> regs->ax X->>G: syscall_exit_to_user_mode(regs) G->>G: syscall exit work, then TIF loop, then exit_to_user_mode() G-->>X: returns with IRQs disabled X-->>A: true = use SYSRET, false = use IRET A->>U: restore pt_regs, sysretq / iretq
The x86-64 syscall handover at v6.12, from arch/x86/entry/entry_64.S and arch/x86/entry/common.c. What it shows: there are two arch layers, not one — hand-written assembly and an arch C function (do_syscall_64) that owns dispatch and the SYSRET-vs-IRET decision — with the generic layer called twice from the middle of the arch C function. The insight: do_syscall_64 returns a bool, not the syscall result. The result was already written into regs->ax before the generic exit function ran; the boolean answers a completely different question — whether the register state is still safe for the fast SYSRET return path (see The x86-64 Syscall Calling Convention for why SYSRET is conditional).
The architecture assembly calls a single function with the saved frame and the raw syscall number. On x86-64 that is do_syscall_64(), which calls syscall_enter_from_user_mode(regs, nr) (per arch/x86/entry/common.c at v6.12). The generic function is defined in include/linux/entry-common.h:
static __always_inline long syscall_enter_from_user_mode(struct pt_regs *regs, long syscall)
{
long ret;
enter_from_user_mode(regs);
instrumentation_begin();
local_irq_enable();
ret = syscall_enter_from_user_mode_work(regs, syscall);
instrumentation_end();
return ret;
}It does three things in a strict order:
enter_from_user_mode(regs)establishes correct kernel state while still non-instrumentable. Reading its body (inentry-common.h): it calls the arch hookarch_enter_from_user_mode(), tells lockdep interrupts are off (lockdep_hardirqs_off), checks and reactivates RCU via context tracking (user_exit_irqoff()— crucial underNO_HZ_FULL, where RCU treats userspace as an extended quiescent state and is genuinely not watching until this call), unpoisons the entry regs for KMSAN, and traces the hardirqs-off state. The ordering — lockdep, then RCU, then tracing — is load-bearing: you cannot run instrumentable code until RCU is watching, which is why this block is markednoinstr/__always_inlineso the compiler cannot move it into an instrumentable section.local_irq_enable()— the trap entered with interrupts disabled; now that state is consistent, re-enable them so the syscall runs with IRQs on.syscall_enter_from_user_mode_work(regs, syscall)reads the per-tasksyscall_workflags and, only if any are set, calls the heavyweightsyscall_trace_enter():
static __always_inline long syscall_enter_from_user_mode_work(struct pt_regs *regs, long syscall)
{
unsigned long work = READ_ONCE(current_thread_info()->syscall_work);
if (work & SYSCALL_WORK_ENTER)
syscall = syscall_trace_enter(regs, syscall, work);
return syscall;
}The split into syscall_enter_from_user_mode (combined) versus the separate _prepare() + _work() subfunctions exists so an architecture with extra machine-specific work to do between “state is consistent” and “run the tracing work” can call the two halves separately (the docs call these “fine grained subfunctions”). x86-64’s do_syscall_64 uses the combined form; the 32-bit int 0x80 emulation path (do_int80_syscall_32) uses the split form because it must read the syscall number differently.
What the Entry Work Actually Does
syscall_trace_enter() (in kernel/entry/common.c) is where seccomp/ptrace/audit/tracepoints fire, in a deliberately chosen order:
long syscall_trace_enter(struct pt_regs *regs, long syscall, unsigned long work)
{
long ret = 0;
/* Handle Syscall User Dispatch. This must comes first ... */
if (work & SYSCALL_WORK_SYSCALL_USER_DISPATCH) {
if (syscall_user_dispatch(regs))
return -1L;
}
/* Handle ptrace */
if (work & (SYSCALL_WORK_SYSCALL_TRACE | SYSCALL_WORK_SYSCALL_EMU)) {
ret = ptrace_report_syscall_entry(regs);
if (ret || (work & SYSCALL_WORK_SYSCALL_EMU))
return -1L;
}
/* Do seccomp after ptrace, to catch any tracer changes. */
if (work & SYSCALL_WORK_SECCOMP) {
ret = __secure_computing(NULL);
if (ret == -1L)
return ret;
}
/* Either of the above might have changed the syscall number */
syscall = syscall_get_nr(current, regs);
if (unlikely(work & SYSCALL_WORK_SYSCALL_TRACEPOINT)) {
trace_sys_enter(regs, syscall);
syscall = syscall_get_nr(current, regs);
}
syscall_enter_audit(regs, syscall);
return ret ? : syscall;
}The ordering rationale, straight from the comments:
- Syscall User Dispatch first. This mechanism (see Syscall User Dispatch) lets a process redirect selected syscalls back to userspace; because the ABI of a dispatched call is unknown, it must be decided before any other feature interprets the arguments. Returning
-1Lmeans “skip the actual syscall.” - ptrace before seccomp, so a tracer that rewrites the syscall number (via
PTRACE_SYSCALL) does so before seccomp filters evaluate it — seccomp then sees the tracer’s chosen number.SYSCALL_WORK_SYSCALL_EMUis thePTRACE_SYSEMUcase where the syscall is reported but never executed (returns-1L). - seccomp runs
__secure_computing(); aSECCOMP_RET_ERRNO/KILL/TRAPdecision can abort here (see seccomp and Syscall Filtering). - Re-read the syscall number after ptrace/seccomp because either may have changed it, then fire the
sys_entertracepoint (feedingperfand eBPF — see Syscall Tracepoints sys_enter and sys_exit) and re-read again, since a BPF program on the tracepoint can also rewrite it. - audit records the entry last, with whatever the final number and arguments are.
Tabulated, with the abort condition for each stage:
| # | Stage | Gate (SYSCALL_WORK_* bit) | What it may do | Can it abort the syscall? |
|---|---|---|---|---|
| 1 | Syscall User Dispatch | SYSCALL_USER_DISPATCH | redirect the call back to userspace via SIGSYS | yes — syscall_user_dispatch() true ⇒ return -1L |
| 2 | ptrace entry stop | SYSCALL_TRACE or SYSCALL_EMU | tracer may rewrite nr and args in pt_regs | yes — nonzero return, or SYSCALL_EMU set ⇒ return -1L |
| 3 | seccomp | SECCOMP | run the filter; RET_ERRNO/RET_KILL/RET_TRAP/RET_TRACE | yes — __secure_computing() returning -1L |
| 4 | re-read nr | (unconditional) | syscall = syscall_get_nr(current, regs) | no |
| 5 | sys_enter tracepoint | SYSCALL_TRACEPOINT | perf/BPF programs observe — and may rewrite nr | no |
| 6 | re-read nr again | (inside the tracepoint branch) | syscall = syscall_get_nr(current, regs) | no |
| 7 | audit entry | SYSCALL_AUDIT (via audit_context()) | audit_syscall_entry(nr, a0..a3) | no |
The entry-work dispatch order in syscall_trace_enter() at v6.12, with each stage’s gating flag and whether it can suppress the call. What it shows: three of the seven stages can cancel the syscall, and two stages exist solely to re-read a number an earlier stage may have changed. The insight: stages 4 and 6 look redundant and are not — each is a bug fix. Stage 4 was added by d88d59b64ca3, “core/entry: Respect syscall number rewrites” after Kyle Huey (of the rr record-and-replay debugger) reported that the x86-to-generic port “failed to reload the syscall number from ptregs after ptrace and seccomp have run, which both can modify the syscall number in ptregs”; stage 6 was added four years later by fb13b11d5387, “entry: Respect changes to system call number by trace_sys_enter()” (André Rösti, March 2024), which restored semantics an intervening cleanup had silently dropped. Rösti’s commit message also answers the obvious cost objection: the extra reload “only takes place when a trace point is active, and compared to the actual trace point overhead the read from a cache hot variable is negligible.”
The -1L return is the abort channel, and its handling on the x86-64 side is subtler than “skip the call”. do_syscall_64 reads:
if (!do_syscall_x64(regs, nr) && !do_syscall_x32(regs, nr) && nr != -1) {
/* Invalid system call, but still a system call. */
regs->ax = __x64_sys_ni_syscall(regs);
}do_syscall_x64() assigns nr to an unsigned int, so -1 becomes 0xffffffff, fails the unr < NR_syscalls bound check, and returns false without dispatching. do_syscall_x32() fails for the same reason. The third conjunct, nr != -1, is what distinguishes aborted from nonexistent: a genuinely out-of-range number falls through to __x64_sys_ni_syscall() and the caller gets -ENOSYS, whereas an entry-work abort leaves regs->ax exactly as the aborting stage left it — the SECCOMP_RET_ERRNO value, or whatever a ptrace tracer poked in. The kerneldoc for syscall_enter_from_user_mode_work() states the contract: “If the returned syscall number is -1 then the syscall should be skipped. In this case the caller may invoke syscall_set_error() or syscall_set_return_value() first. If neither of those are called and -1 is returned, then the syscall will fail with ENOSYS.”
The SYSCALL_WORK Flags
The work bitmask lives in current_thread_info()->syscall_work, distinct from the broader TIF_* thread flags. The enum (per include/linux/thread_info.h at v6.12) is:
enum syscall_work_bit {
SYSCALL_WORK_BIT_SECCOMP,
SYSCALL_WORK_BIT_SYSCALL_TRACEPOINT,
SYSCALL_WORK_BIT_SYSCALL_TRACE,
SYSCALL_WORK_BIT_SYSCALL_EMU,
SYSCALL_WORK_BIT_SYSCALL_AUDIT,
SYSCALL_WORK_BIT_SYSCALL_USER_DISPATCH,
SYSCALL_WORK_BIT_SYSCALL_EXIT_TRAP,
};Each bit is owned by a different subsystem, is set on a per-task basis, and appears in the entry mask, the exit mask, or both. That last column is the one readers usually get wrong — seccomp and Syscall User Dispatch are not symmetric:
| Bit | Set by | In SYSCALL_WORK_ENTER? | In SYSCALL_WORK_EXIT? | What it triggers |
|---|---|---|---|---|
SECCOMP | seccomp_assign_mode() (kernel/seccomp.c:464) and inherited in copy_process() (kernel/fork.c:1916) | yes | no | __secure_computing(NULL) on entry only — a filter verdict is an entry-time decision |
SYSCALL_TRACEPOINT | syscall_regfunc() (kernel/tracepoint.c:800), set on every thread when the first sys_enter/sys_exit probe registers | yes | yes | trace_sys_enter(regs, nr) / trace_sys_exit(regs, retval) |
SYSCALL_TRACE | ptrace_request() for PTRACE_SYSCALL (kernel/ptrace.c:830) | yes | yes | ptrace_report_syscall_entry() / _exit() — the two stops strace sees |
SYSCALL_EMU | ptrace_request() for PTRACE_SYSEMU (kernel/ptrace.c:836) | yes | no | report the call, then return -1L so it never executes |
SYSCALL_AUDIT | audit_alloc() / __audit_syscall_entry() path (kernel/auditsc.c:1079) | yes | yes | audit_syscall_entry() / audit_syscall_exit() |
SYSCALL_USER_DISPATCH | prctl(PR_SET_SYSCALL_USER_DISPATCH) (kernel/entry/syscall_user_dispatch.c:114) | yes | yes | entry: possible redirect to userspace; exit: suppress the other exit reporters when a call was dispatched |
SYSCALL_EXIT_TRAP | enable_single_step() (arch/x86/kernel/step.c:141) | no | yes | deliver the single-step trap after the syscall returns, before any user instruction runs |
The seven syscall_work bits at v6.12, their owners, and which of the two masks they appear in. What it shows: SYSCALL_WORK_ENTER contains six bits and SYSCALL_WORK_EXIT five, overlapping in four. The insight: the asymmetries encode real semantics. SECCOMP is entry-only because a filter cannot change its mind about a call that already ran. SYSCALL_EXIT_TRAP is exit-only because a single-step trap must fire after the call completes, not before. And SYSCALL_USER_DISPATCH is in both masks for an unusual reason: on the exit side it exists purely to silence the other reporters — syscall_exit_work() checks current->syscall_dispatch.on_dispatch and returns early, because, as the comment puts it, “the ABI of these syscalls is unknown,” exactly as on entry.
SYSCALL_WORK_ENTER (in entry-common.h) ORs together the entry-relevant bits — SECCOMP | SYSCALL_TRACEPOINT | SYSCALL_TRACE | SYSCALL_EMU | SYSCALL_AUDIT | SYSCALL_USER_DISPATCH | ARCH_SYSCALL_WORK_ENTER — so the hot common case (no tracing, no seccomp) skips syscall_trace_enter() entirely with one & test. Splitting these out of the general TIF_* set was itself part of the cleanup: syscall-specific work no longer competes for scarce TIF_* bits. Deeper coverage of the flag mechanics is in Thread Info Flags and Syscall Exit Work.
The Exit Path, Step by Step
After the handler returns (its value already stored in regs->ax), do_syscall_64 calls syscall_exit_to_user_mode(regs) (in kernel/entry/common.c):
__visible noinstr void syscall_exit_to_user_mode(struct pt_regs *regs)
{
instrumentation_begin();
__syscall_exit_to_user_mode_work(regs);
instrumentation_end();
exit_to_user_mode();
}__syscall_exit_to_user_mode_work() does three things: syscall_exit_to_user_mode_prepare() (the syscall-specific exit work), then local_irq_disable_exit_to_user(), then exit_to_user_mode_prepare() (the general TIF-flag loop). The syscall-specific prepare:
static void syscall_exit_to_user_mode_prepare(struct pt_regs *regs)
{
unsigned long work = READ_ONCE(current_thread_info()->syscall_work);
unsigned long nr = syscall_get_nr(current, regs);
CT_WARN_ON(ct_state() != CT_STATE_KERNEL);
/* ... PROVE_LOCKING sanity: warn if the syscall left IRQs disabled ... */
rseq_syscall(regs);
if (unlikely(work & SYSCALL_WORK_EXIT))
syscall_exit_work(regs, work);
}rseq_syscall() checks restartable-sequences invariants; the CT_WARN_ON asserts context tracking still believes we are in the kernel. The conditional syscall_exit_work() runs the exit-side audit, the sys_exit tracepoint (with the return value), and the ptrace exit report / single-step trap (SYSCALL_WORK_SYSCALL_EXIT_TRAP) — the mirror image of entry, again gated on SYSCALL_WORK_EXIT so the common case skips it.
The TIF Work Loop
The general exit work — the part shared with interrupt/exception returns — is exit_to_user_mode_prepare(), which calls exit_to_user_mode_loop() only if any EXIT_TO_USER_MODE_WORK bit is set (per kernel/entry/common.c):
__always_inline unsigned long exit_to_user_mode_loop(struct pt_regs *regs,
unsigned long ti_work)
{
while (ti_work & EXIT_TO_USER_MODE_WORK) {
local_irq_enable_exit_to_user(ti_work);
if (ti_work & _TIF_NEED_RESCHED)
schedule();
if (ti_work & _TIF_UPROBE)
uprobe_notify_resume(regs);
if (ti_work & _TIF_PATCH_PENDING)
klp_update_patch_state(current);
if (ti_work & (_TIF_SIGPENDING | _TIF_NOTIFY_SIGNAL))
arch_do_signal_or_restart(regs);
if (ti_work & _TIF_NOTIFY_RESUME)
resume_user_mode_work(regs);
arch_exit_to_user_mode_work(regs, ti_work);
local_irq_disable_exit_to_user();
tick_nohz_user_enter_prepare();
ti_work = read_thread_flags();
}
return ti_work;
}flowchart TD ENTER["exit_to_user_mode_prepare()<br/>IRQs OFF<br/>ti_work = read_thread_flags()"] TEST{"ti_work &<br/>EXIT_TO_USER_MODE_WORK<br/>?"} IRQON["local_irq_enable_exit_to_user(ti_work)<br/>IRQs ON -- work may sleep"] R["_TIF_NEED_RESCHED ?<br/>schedule()"] UP["_TIF_UPROBE ?<br/>uprobe_notify_resume()"] KLP["_TIF_PATCH_PENDING ?<br/>klp_update_patch_state()"] SIG["_TIF_SIGPENDING | _TIF_NOTIFY_SIGNAL ?<br/>arch_do_signal_or_restart()"] NR["_TIF_NOTIFY_RESUME ?<br/>resume_user_mode_work()"] ARCH["arch_exit_to_user_mode_work()"] IRQOFF["local_irq_disable_exit_to_user()<br/>tick_nohz_user_enter_prepare()<br/>ti_work = read_thread_flags()"] OUT["arch_exit_to_user_mode_prepare()<br/>kmap_assert_nomap(), lockdep_sys_exit()<br/>then exit_to_user_mode()"] ENTER --> TEST TEST -- "no bits set (hot path)" --> OUT TEST -- "some bit set" --> IRQON IRQON --> R --> UP --> KLP --> SIG --> NR --> ARCH --> IRQOFF IRQOFF --> TEST
exit_to_user_mode_loop() at v6.12, drawn as the loop it actually is. What it shows: the flags are re-read at the bottom of every iteration, with interrupts disabled, and the loop only terminates when the re-read finds no work bits at all; the hot path (no bits set) skips the body entirely and never enables interrupts. The insight to take: the back edge is the whole design. Each handler runs with interrupts on, so an interrupt can set a new flag while an earlier one is being serviced, and several handlers set flags directly — schedule() can return with a signal pending, arch_do_signal_or_restart() can leave TIF_NEED_RESCHED set because the signal handler’s stack setup took a page fault. Reading this as a straight-line “check each flag once” list is the single most common misreading of the exit path, and it is why local_irq_disable_exit_to_user() sits before the re-read: the flag word must be sampled at a point where nothing can concurrently add to it.
It is a loop, not a straight line, and that is the crucial subtlety: handling one item can create another. Running a signal handler (arch_do_signal_or_restart) can set TIF_NEED_RESCHED; rescheduling can leave a signal pending. So the loop re-reads read_thread_flags() with interrupts disabled at the bottom and only exits when no work flags remain — guaranteeing the kernel never returns to userspace with pending work. The set of flags it drains is EXIT_TO_USER_MODE_WORK = _TIF_SIGPENDING | _TIF_NOTIFY_RESUME | _TIF_UPROBE | _TIF_NEED_RESCHED | _TIF_PATCH_PENDING | _TIF_NOTIFY_SIGNAL | ARCH_EXIT_TO_USER_MODE_WORK. Note this is where signal delivery happens and where a restartable syscall is restarted (arch_do_signal_or_restart), tying this layer to EINTR and Interrupted System Calls and the signal subsystem. The detailed semantics of exit_to_user_mode() itself — the final RCU/lockdep teardown in reverse order — are covered in Returning to Userspace and exit_to_user_mode.
The flags themselves are not generic: EXIT_TO_USER_MODE_WORK is assembled in include/linux/entry-common.h out of _TIF_* macros that each architecture defines in its own asm/thread_info.h. On x86-64 at v6.12 (arch/x86/include/asm/thread_info.h) the relevant bits and their handlers are:
_TIF_* flag | Bit (x86, v6.12) | Typically set by | Handler in the loop | Why it must run before returning to userspace |
|---|---|---|---|---|
_TIF_NEED_RESCHED | 3 | scheduler tick, wakeup, set_tsk_need_resched() | schedule() | the preemption point for a task returning from the kernel; see The need_resched Flag and Preemption Points |
_TIF_UPROBE | 12 | uprobe hit on a userspace instruction | uprobe_notify_resume() | the single-step-out-of-line buffer must be set up before the user instruction re-executes |
_TIF_PATCH_PENDING | 13 | livepatch transition | klp_update_patch_state() | a task is only “converted” to the new patch state at a kernel/user boundary, where its stack is known to be free of patched frames |
_TIF_SIGPENDING | 2 | signal_wake_up() from kill(2), faults, etc. | arch_do_signal_or_restart() | a signal handler runs on the user stack, so the frame can only be built on the way out |
_TIF_NOTIFY_SIGNAL | 17 | in-kernel “signal-like” notifications (io_uring task work) | arch_do_signal_or_restart() | same path as a real signal; absent from arch/x86/include/asm/thread_info.h at v5.10 and present at v5.11, added so task work could piggyback on the signal check |
_TIF_NOTIFY_RESUME | 1 | task_work_add(), membarrier, rseq, keyrings | resume_user_mode_work() | the generic “run deferred task_work in process context” hook |
The six _TIF_* bits in EXIT_TO_USER_MODE_WORK on x86-64 at v6.12, with the x86 bit number, who raises each, and what drains it. What it shows: the bit numbers are small and arbitrary (1, 2, 3, 12, 13, 17) — they are x86’s private numbering, and _TIF_* is a per-arch namespace even though the mask that uses them is generic. The insight: every entry in this table is work that is only correct at the user/kernel boundary — building a signal frame needs the user stack, livepatch conversion needs a stack with no patched frames, uprobe fixups need the user RIP. That, not convenience, is why the exit path is where all deferred work lands. The flags listed in arch/x86/include/asm/thread_info.h but absent from this mask (_TIF_NEED_FPU_LOAD, _TIF_IO_BITMAP, _TIF_SPEC_IB, _TIF_USER_RETURN_NOTIFY, …) are handled elsewhere — context-switch time, or arch_exit_to_user_mode() — precisely because they do not need the sleepable, IRQs-on environment this loop provides.
Two of these have moved since the v6.12 pin, which matters if you read newer source: _TIF_NEED_RESCHED_LAZY (the lazy-preemption companion flag) is absent from arch/x86/include/asm/thread_info.h at v6.12 and present at v6.13, and by v7.2 the mask — now in include/linux/irq-entry-common.h — has grown both _TIF_NEED_RESCHED_LAZY and _TIF_RSEQ, and has split into EXIT_TO_USER_MODE_WORK_SYSCALL and EXIT_TO_USER_MODE_WORK_IRQ variants under CONFIG_HRTIMER_REARM_DEFERRED.
What Each Architecture Still Provides
The generic layer is deliberately not a complete entry implementation. Each GENERIC_ENTRY architecture still supplies:
- The raw entry/exit assembly — the trap handler (
entry_SYSCALL_64on x86-64), register save intopt_regs, stack switch, and the finalsysret/iret. The generic C is called by this asm; it does not replace it. - Optional
arch_*hooks, each defaulting to a no-op and overridable:arch_enter_from_user_mode()(sanity-check user regs),arch_exit_to_user_mode_work()(extra TIF work),arch_exit_to_user_mode_prepare(),local_irq_enable_exit_to_user()/local_irq_disable_exit_to_user()(arches that need custom IRQ-state handling), andarch_do_signal_or_restart()(the actual signal-delivery machinery, which is inherently arch-specific because it builds a signal frame on the user stack). ARCH_SYSCALL_WORK_ENTER/ARCH_SYSCALL_WORK_EXIT/ARCH_EXIT_TO_USER_MODE_WORK— extra flag bits an architecture wants folded into the generic masks, defaulting to0.- The dispatch itself —
x64_sys_call(regs, nr)and the table — because the calling convention and table are arch-specific.
Every one of those hooks is declared in include/linux/entry-common.h with a #ifndef default, so an architecture overrides one simply by #define-ing the name in its asm/entry-common.h. The full v6.12 set, with what each is allowed to do:
| Hook | Default | Called from | Instrumentable? | What an arch uses it for |
|---|---|---|---|---|
arch_enter_from_user_mode(regs) | empty | enter_from_user_mode() | no (__always_inline) | sanity-check that regs really describes user mode |
local_irq_enable_exit_to_user(ti_work) | local_irq_enable() | top of exit_to_user_mode_loop() | yes | arches whose IRQ enable is conditional on the pending work |
local_irq_disable_exit_to_user() | local_irq_disable() | bottom of the loop | yes | the matching disable |
arch_exit_to_user_mode_work(regs, ti_work) | empty | inside the loop, IRQs on | yes | extra ARCH_EXIT_TO_USER_MODE_WORK bits that may sleep |
arch_exit_to_user_mode_prepare(regs, ti_work) | empty | after the loop, IRQs off | yes | last chance for work that must not sleep |
arch_exit_to_user_mode() | empty | exit_to_user_mode(), after RCU stops watching | no (__always_inline) | speculation mitigations only — the header says “must not do anything complex, no locking etc.” |
arch_do_signal_or_restart(regs) | __weak empty stub in kernel/entry/common.c | inside the loop | yes | the real signal-frame builder; see Signal Delivery and the Return to Userspace |
ARCH_SYSCALL_WORK_ENTER / _EXIT | 0 | OR-ed into the two masks | — | extra syscall_work bits |
ARCH_EXIT_TO_USER_MODE_WORK | 0 | OR-ed into EXIT_TO_USER_MODE_WORK | — | extra _TIF_* bits |
The architecture extension points of the generic entry layer at v6.12. What it shows: there are exactly two noinstr/__always_inline hooks, and both sit outside the RCU-watching window. The insight: the “instrumentable?” column is the contract that makes the layer safe. arch_enter_from_user_mode() runs before RCU is reactivated and arch_exit_to_user_mode() runs after RCU has stopped watching, so anything that traces, allocates, locks, or faults in either of them is a latent heisenbug — which is why the kerneldoc for the latter restricts it to speculation mitigations, and why objtool enforces the noinstr section boundary at build time.
This is the “common funnel, thin shims” design: the policy-bearing, drift-prone work is generic; the genuinely machine-dependent mechanics stay in the architecture.
Observing the Layer From Userspace
Two things in this note can be checked on a running machine without a debugger: that the layer is compiled in and still structured the way the source says, and that the SYSCALL_WORK gate really is a gate.
Toolchain used for everything in this section: Fedora 44, kernel 7.1.13-200.fc44.x86_64, glibc 2.43-8.fc44, GCC 16.2.1, binutils 2.46.1, measured 2026-09-11. This host runs a 7.1 kernel, not the v6.12 the note pins, which is deliberate — the differences are themselves informative.
The kernel config shows the post-6.16 three-symbol split described above, with all three set because x86 still selects the umbrella symbol:
$ zgrep -E "GENERIC_(ENTRY|SYSCALL|IRQ_ENTRY)" /boot/config-$(uname -r)
CONFIG_GENERIC_IRQ_ENTRY=y
CONFIG_GENERIC_SYSCALL=y
CONFIG_GENERIC_ENTRY=y/proc/kallsyms shows which of the functions above survived as out-of-line symbols:
$ grep -E "exit_to_user_mode_loop|do_syscall_64|x64_sys_call|__secure_computing" /proc/kallsyms | grep -v __pfx
0000000000000000 T x64_sys_call
0000000000000000 T exit_to_user_mode_loop
0000000000000000 T do_syscall_64
0000000000000000 T __secure_computing(The addresses read as zero because kptr_restrict hides them from an unprivileged reader; the names are the point.) syscall_trace_enter and syscall_exit_to_user_mode, which are out-of-line functions at v6.12, are absent — they were inlined into the caller by later commits (e43b8bb56e53, “entry: Inline syscall_exit_to_user_mode()”, Charlie Jenkins, April 2025, among others). A function’s absence from kallsyms is evidence of inlining, not of removal.
Proving the SYSCALL_WORK Gate Is a Gate
The claim in the source is that syscall_enter_from_user_mode_work() tests one bitmask and, when it is clear, skips syscall_trace_enter() entirely. That predicts a measurable step when a single bit is set. The following program times getpid() — the cheapest real syscall — installs a seccomp filter that allows everything, and times it again. The filter’s verdict is irrelevant; installing it is what sets SYSCALL_WORK_SECCOMP on the task:
/* allow everything, except turn mkdirat into errno 42 */
struct sock_filter allow[] = {
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, SYS_mkdirat, 0, 1),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | 42),
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
};
struct sock_fprog prog = { .len = 4, .filter = allow };
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, 0, &prog);Five consecutive runs, 2,000,000 iterations each:
$ gcc -O2 -o entry_work entry_work.c && for i in 1 2 3 4 5; do ./entry_work | head -2; done
no SYSCALL_WORK bits set : 38.4 ns / getpid()
SYSCALL_WORK_SECCOMP set : 42.6 ns / getpid()
no SYSCALL_WORK bits set : 38.8 ns / getpid()
SYSCALL_WORK_SECCOMP set : 46.1 ns / getpid()
no SYSCALL_WORK bits set : 35.9 ns / getpid()
SYSCALL_WORK_SECCOMP set : 38.8 ns / getpid()
no SYSCALL_WORK bits set : 36.1 ns / getpid()
SYSCALL_WORK_SECCOMP set : 39.9 ns / getpid()
no SYSCALL_WORK bits set : 36.0 ns / getpid()
SYSCALL_WORK_SECCOMP set : 40.0 ns / getpid()Setting one bit costs roughly 3–7 ns per syscall on this machine — the work & SYSCALL_WORK_ENTER test now succeeds, so every entry takes the call into syscall_trace_enter(), walks the seven-stage chain above, and runs a four-instruction cBPF program. That is about 8–18% on the cheapest syscall in the table, and it is why the mask exists at all: without the gate, every process would pay the syscall_trace_enter() call on every syscall. (These are wall-clock deltas on an unquiesced desktop, not a controlled microbenchmark; treat the magnitude, not the digits, as the result. The absolute syscall cost itself is dissected in Why System Calls Are Expensive.)
The same program then demonstrates the entry-work abort path end to end:
mkdirat under SECCOMP_RET_ERRNO|42 -> -1, errno=42 (No message of desired type)
directory exists? no - handler never ranWalk that through the machinery: __secure_computing() at stage 3 returned -1L after writing -42 into regs->ax; syscall_trace_enter() returned -1L; do_syscall_64’s nr != -1 conjunct was false so __x64_sys_ni_syscall() was not called and regs->ax kept the -42; the openat-family handler never ran (the directory does not exist); and glibc’s wrapper saw -42 in RAX, found it inside the [-4095, -1] error band, and reported errno == 42 — which is ENOMSG, an absurd error for mkdirat, exactly as the filter specified. Every layer in this note and its userspace counterpart is visible in those two lines.
Failure Modes and Diagnostics
The bugs this layer produces are almost never “the syscall returned the wrong value”. They are state-machine violations that surface as a WARN from a subsystem that has no obvious connection to entry code — RCU, lockdep, context tracking — or as a silent semantic regression in a tool that depends on the exact ordering, which is how the two historical bugs in the ordering table were found (the rr debugger, and a probe that rewrote a syscall number). That is the diagnostic heuristic: a report of the form “ptrace/seccomp/a BPF probe changed something and the kernel ignored it” is an entry-work ordering bug, and a report of the form “rcu_is_watching() warns from a tracepoint” is an entry-state bug. They live in different halves of syscall_enter_from_user_mode() and have nothing else in common.
- Syscall-number rewrites silently ignored. The canonical regression class, hit twice:
d88d59b64ca3(2020) andfb13b11d5387(2024). The symptom is thatptrace, seccomp, or asys_enterBPF probe writes a new number intopt_regsand the old syscall executes anyway. The cause is always a missingsyscall_get_nr()reload; the diagnostic is to compare the number the tracer set against the one thesys_exittracepoint reports. - RCU/context-tracking warnings. If an architecture calls instrumentable code before
enter_from_user_mode()(or nests syscalls),CT_WARN_ON/rcu_is_watchingwarnings fire. The docs are explicit: “Do not nest syscalls” — a nested syscall confuses context tracking’s “are we in user or kernel” bookkeeping. - “syscall left IRQs disabled” warning. Under
CONFIG_PROVE_LOCKING,syscall_exit_to_user_mode_preparewarns if a handler returns with interrupts disabled, then force-enables them — catching handlers that take a spinlock with IRQs off and forget to re-enable. noinstrviolations. The entry/exit state-transition code isnoinstrso that tracers and KASAN/KMSAN cannot run while RCU is not watching. A function wrongly placed in thenoinstrsection that is instrumented triggers an objtool build-time error — a class of bug this layer’s careful sectioning is designed to surface at compile time rather than as a runtime heisenbug.
See Also
- SYSCALL_DEFINE and Syscall Handler Wrappers — the handler dispatched between this layer’s entry and exit halves
- Per-Architecture Syscall Entry Assembly — the asm that saves
pt_regsand callsdo_syscall_64/syscall_enter_from_user_mode - Thread Info Flags and Syscall Exit Work — the
TIF_*andSYSCALL_WORK_*flag mechanics this layer tests - Returning to Userspace and exit_to_user_mode — the final state teardown after the work loop
- The pt_regs Register Frame — the saved-register struct threaded through every function here
- seccomp and Syscall Filtering — invoked from
syscall_trace_entervia__secure_computing - ptrace and Syscall Tracing —
ptrace_report_syscall_entry/_exithooks live here - Syscall Tracepoints sys_enter and sys_exit —
trace_sys_enter/trace_sys_exitfired by this layer - Syscall User Dispatch — handled first in the entry work, before any other feature
- Restartable System Calls and ERESTARTSYS — restart happens in the exit work loop via
arch_do_signal_or_restart - libc Syscall Wrappers and errno Translation — the userspace counterpart: what happens to
regs->axafter this layer puts it there - Signal Delivery and the Return to Userspace —
arch_do_signal_or_restart(), the one genuinely arch-specific handler in the exit loop - The need_resched Flag and Preemption Points —
_TIF_NEED_RESCHED, the first flag the loop drains - Lazy Preemption and PREEMPT_LAZY —
_TIF_NEED_RESCHED_LAZY, added toEXIT_TO_USER_MODE_WORKafter the v6.12 pin - Full Dynticks and NOHZ_FULL — why
user_exit_irqoff()andtick_nohz_user_enter_prepare()are in the fast path at all - Why System Calls Are Expensive — the cost breakdown this layer contributes to
- The x86-64 Syscall Calling Convention — the
SYSRET-vs-IRETdecisiondo_syscall_64returns - Linux System Call Interface MOC
- Linux Userspace Runtime MOC