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.c plus include/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() and syscall_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 of arch_* 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-11 kernel.org/releases.json lists 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:

Architectureselect GENERIC_ENTRY at v6.12?Notes
x86yes (arch/x86/Kconfig)first user, since 5.9
s390yes
riscvyes
loongarchyes
arm64nokeeps its own arch/arm64/kernel/entry-common.c (HTTP 200 at v6.12)
powerpcno at v6.12gains 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, umnobespoke 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:

  1. enter_from_user_mode(regs) establishes correct kernel state while still non-instrumentable. Reading its body (in entry-common.h): it calls the arch hook arch_enter_from_user_mode(), tells lockdep interrupts are off (lockdep_hardirqs_off), checks and reactivates RCU via context tracking (user_exit_irqoff() — crucial under NO_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 marked noinstr/__always_inline so the compiler cannot move it into an instrumentable section.
  2. local_irq_enable() — the trap entered with interrupts disabled; now that state is consistent, re-enable them so the syscall runs with IRQs on.
  3. syscall_enter_from_user_mode_work(regs, syscall) reads the per-task syscall_work flags and, only if any are set, calls the heavyweight syscall_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 -1L means “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_EMU is the PTRACE_SYSEMU case where the syscall is reported but never executed (returns -1L).
  • seccomp runs __secure_computing(); a SECCOMP_RET_ERRNO/KILL/TRAP decision 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_enter tracepoint (feeding perf and 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:

#StageGate (SYSCALL_WORK_* bit)What it may doCan it abort the syscall?
1Syscall User DispatchSYSCALL_USER_DISPATCHredirect the call back to userspace via SIGSYSyes — syscall_user_dispatch() true ⇒ return -1L
2ptrace entry stopSYSCALL_TRACE or SYSCALL_EMUtracer may rewrite nr and args in pt_regsyes — nonzero return, or SYSCALL_EMU set ⇒ return -1L
3seccompSECCOMPrun the filter; RET_ERRNO/RET_KILL/RET_TRAP/RET_TRACEyes — __secure_computing() returning -1L
4re-read nr(unconditional)syscall = syscall_get_nr(current, regs)no
5sys_enter tracepointSYSCALL_TRACEPOINTperf/BPF programs observe — and may rewrite nrno
6re-read nr again(inside the tracepoint branch)syscall = syscall_get_nr(current, regs)no
7audit entrySYSCALL_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:

BitSet byIn SYSCALL_WORK_ENTER?In SYSCALL_WORK_EXIT?What it triggers
SECCOMPseccomp_assign_mode() (kernel/seccomp.c:464) and inherited in copy_process() (kernel/fork.c:1916)yesno__secure_computing(NULL) on entry only — a filter verdict is an entry-time decision
SYSCALL_TRACEPOINTsyscall_regfunc() (kernel/tracepoint.c:800), set on every thread when the first sys_enter/sys_exit probe registersyesyestrace_sys_enter(regs, nr) / trace_sys_exit(regs, retval)
SYSCALL_TRACEptrace_request() for PTRACE_SYSCALL (kernel/ptrace.c:830)yesyesptrace_report_syscall_entry() / _exit() — the two stops strace sees
SYSCALL_EMUptrace_request() for PTRACE_SYSEMU (kernel/ptrace.c:836)yesnoreport the call, then return -1L so it never executes
SYSCALL_AUDITaudit_alloc() / __audit_syscall_entry() path (kernel/auditsc.c:1079)yesyesaudit_syscall_entry() / audit_syscall_exit()
SYSCALL_USER_DISPATCHprctl(PR_SET_SYSCALL_USER_DISPATCH) (kernel/entry/syscall_user_dispatch.c:114)yesyesentry: possible redirect to userspace; exit: suppress the other exit reporters when a call was dispatched
SYSCALL_EXIT_TRAPenable_single_step() (arch/x86/kernel/step.c:141)noyesdeliver 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 &amp;<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_* flagBit (x86, v6.12)Typically set byHandler in the loopWhy it must run before returning to userspace
_TIF_NEED_RESCHED3scheduler 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_UPROBE12uprobe hit on a userspace instructionuprobe_notify_resume()the single-step-out-of-line buffer must be set up before the user instruction re-executes
_TIF_PATCH_PENDING13livepatch transitionklp_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_SIGPENDING2signal_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_SIGNAL17in-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_RESUME1task_work_add(), membarrier, rseq, keyringsresume_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_64 on x86-64), register save into pt_regs, stack switch, and the final sysret/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), and arch_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 to 0.
  • 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:

HookDefaultCalled fromInstrumentable?What an arch uses it for
arch_enter_from_user_mode(regs)emptyenter_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()yesarches whose IRQ enable is conditional on the pending work
local_irq_disable_exit_to_user()local_irq_disable()bottom of the loopyesthe matching disable
arch_exit_to_user_mode_work(regs, ti_work)emptyinside the loop, IRQs onyesextra ARCH_EXIT_TO_USER_MODE_WORK bits that may sleep
arch_exit_to_user_mode_prepare(regs, ti_work)emptyafter the loop, IRQs offyeslast chance for work that must not sleep
arch_exit_to_user_mode()emptyexit_to_user_mode(), after RCU stops watchingno (__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.cinside the loopyesthe real signal-frame builder; see Signal Delivery and the Return to Userspace
ARCH_SYSCALL_WORK_ENTER / _EXIT0OR-ed into the two masks—extra syscall_work bits
ARCH_EXIT_TO_USER_MODE_WORK0OR-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 ran

Walk 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) and fb13b11d5387 (2024). The symptom is that ptrace, seccomp, or a sys_enter BPF probe writes a new number into pt_regs and the old syscall executes anyway. The cause is always a missing syscall_get_nr() reload; the diagnostic is to compare the number the tracer set against the one the sys_exit tracepoint reports.
  • RCU/context-tracking warnings. If an architecture calls instrumentable code before enter_from_user_mode() (or nests syscalls), CT_WARN_ON/rcu_is_watching warnings 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_prepare warns 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.
  • noinstr violations. The entry/exit state-transition code is noinstr so that tracers and KASAN/KMSAN cannot run while RCU is not watching. A function wrongly placed in the noinstr section 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