libc Syscall Wrappers and errno Translation

When a C program calls open("/nope", O_RDONLY), it is not executing a syscall instruction directly — it calls a C-library wrapper function, a thin stub that loads the syscall number and arguments into the architecture’s registers, executes the syscall instruction, and then translates the kernel’s raw result into the POSIX convention every C programmer expects. The translation is mechanical: the kernel returns a small negative number in RAX (see The errno Convention and Negative Return Values); the wrapper tests whether that value falls in the reserved band [-4095, -1], and if so it negates the value into the thread-local errno variable and returns -1 to the caller; otherwise it returns the value unchanged. This note traces that wrapper, the exact range check (cmpq $-4095, %rax in assembly, (unsigned long)val > -4096UL in C), the per-thread storage of errno via thread-local storage (TLS) and __errno_location, and the cases — like the vDSO — where there is no trap and no errno touch at all. Source claims here are pinned to glibc 2.43 (tagged on sourceware.org roughly seven months before 2026-09-11; 2.44 is the current release) because that is the version on the machine every disassembly and measurement below came from, and to musl 1.2.6 where musl is quoted. This note is deliberately the userspace half of the story: the kernel side — how -ENOENT gets into RAX in the first place — is The errno Convention and Negative Return Values and The Generic Syscall Entry and Exit Layer.

Mental Model

There are three distinct layers between your open() call and the kernel, and conflating them is the source of most confusion. At the top is the POSIX API: “on error, return -1 and set the global errno.” In the middle is the libc wrapper, the only code that knows both worlds — it speaks the raw kernel protocol below and presents the POSIX convention above. At the bottom is the kernel ABI: “result or -errno in RAX, that’s it.” The wrapper’s entire job is impedance matching between the kernel’s single-register negative-return scheme and the C library’s two-channel “return value plus errno” scheme. errno is invented and owned entirely by libc; the kernel has never heard of it.

flowchart TB
  APP["Application:<br/>fd = open(path, flags)<br/>if (fd == -1) perror(...)"] --> WRAP
  subgraph WRAP["libc wrapper (open)"]
    LOAD["load nr into RAX,<br/>args into RDI/RSI/RDX/R10/R8/R9"]
    INSN["syscall instruction"]
    CHECK{"RAX in [-4095, -1]?<br/>(cmpq $-4095,%rax; jae)"}
    SETE["errno = -RAX<br/>(write via %fs: TLS)<br/>return -1"]
    PASS["return RAX unchanged"]
    LOAD --> INSN --> CHECK
    CHECK -->|"yes (error)"| SETE
    CHECK -->|"no (success)"| PASS
  end
  INSN -.->|trap| K["kernel handler<br/>returns -ENOENT (= -2)"]
  K -.->|"RAX = -2"| CHECK
  SETE --> APP
  PASS --> APP

The wrapper sits between the POSIX-shaped caller and the register-shaped kernel. What it shows: the kernel returns -2 (-ENOENT) in RAX; the wrapper’s range test catches it, negates it to errno = 2, and returns -1; on success the raw value passes through untouched. The insight to take: errno is set by libc, in userspace, after the trap returns — never by the kernel. The negation (errno = -RAX) and the -1 return are the two halves of the translation that turn the kernel’s one-register protocol into POSIX’s two-channel one.

The same trip drawn as a timeline, because the ordering is the part people get wrong — in particular that errno is written after the kernel has already finished and returned:

sequenceDiagram
  autonumber
  participant APP as your code
  participant W as glibc open()<br/>__libc_open
  participant C as __syscall_cancel<br/>(cancellation shim)
  participant K as kernel
  participant T as this thread's TLS<br/>(%fs-relative)
  APP->>W: open("/definitely/not/here", O_RDONLY)
  W->>C: __syscall_cancel(AT_FDCWD, path, flags, mode, 0, 0, 257)
  C->>C: test this thread's cancel bit
  C->>K: syscall  (RAX=257 openat, RDI=-100, RSI=path, ...)
  Note over K: path walk fails on the<br/>missing component
  K-->>C: RAX = -2  (-ENOENT). errno does not exist here.
  C->>C: cmp $-4096 then ja   (value is in the error band)
  C->>T: neg %eax then mov %eax into %fs:(errno offset)   [errno = 2]
  C-->>W: -1
  W-->>APP: -1
  APP->>T: errno  ==  *__errno_location()  ==  2
  APP->>APP: perror("open") prints "open: No such file or directory"

One failing open() on glibc 2.43/x86-64, end to end. What it shows: four distinct pieces of code touch the result — the wrapper, the cancellation shim, the kernel, and the TLS store — and only the last two lines involve errno at all. The insight to take: steps 6 through 9 all happen after the kernel has returned and are pure userspace bookkeeping. The kernel’s entire contribution to the error is the integer -2; everything that makes it feel like “the error came back from the kernel as errno” was manufactured by three instructions in libc. (Steps 2, 3 and 6 are specific to glibc’s cancellation path and are unpacked under Cancellation Points below; a non-cancellable wrapper such as getpid collapses this diagram to three steps.)

The Range Check — Where libc Decides “Error”

The boundary that defines an error is the same MAX_ERRNO = 4095 reservation the kernel uses (walked symbol-by-symbol in The errno Convention and Negative Return Values); libc just phrases the test for its own side. In glibc’s hand-written x86-64 assembly stubs, sysdeps/unix/sysv/linux/x86_64/sysdep.h defines the PSEUDO macro that builds a syscall entry point:

#  define PSEUDO(name, syscall_name, args) \
  .text;                                                              \
  ENTRY (name)                                                        \
    DO_CALL (syscall_name, args, 0, 0);   /* movl $__NR_xxx,%eax; syscall */ \
    cmpq $-4095, %rax;                     /* is RAX >= -4095 (unsigned)? */ \
    jae SYSCALL_ERROR_LABEL                /* if so, jump to error handler */

DO_CALL expands to loading the syscall number into %eax and executing syscall. The two lines that matter are cmpq $-4095, %rax followed by jae (jump if above-or-equal). jae is an unsigned conditional jump, so the comparison treats %rax as unsigned: it branches to the error handler exactly when (unsigned long)rax >= (unsigned long)-4095, i.e. when rax is one of -4095 .. -1 as a signed value. This is the same band as the kernel’s IS_ERR_VALUE. The glibc source even carries the historical comment explaining the magic number: “Linus said he will make sure the no syscall returns a value in -1 .. -4095 as a valid result so we can safely test with -4095.”

The C-language path (used by the inline INLINE_SYSCALL macro for most syscalls) phrases the identical test arithmetically. From sysdeps/unix/sysv/linux/sysdep.h:

#define INTERNAL_SYSCALL_ERROR_P(val) \
  ((unsigned long int) (val) > -4096UL)
 
#define INLINE_SYSCALL(name, nr, args...)                       \
  ({                                                            \
    long int sc_ret = INTERNAL_SYSCALL (name, nr, args);        \
    __glibc_unlikely (INTERNAL_SYSCALL_ERROR_P (sc_ret))        \
    ? SYSCALL_ERROR_LABEL (INTERNAL_SYSCALL_ERRNO (sc_ret))     \
    : sc_ret;                                                   \
  })
 
#define INTERNAL_SYSCALL_ERRNO(val)  (-(val))

INTERNAL_SYSCALL_ERROR_P(val) is (unsigned long)val > -4096UL. Note the > against -4096 rather than >= against -4095 — these are the same set: “strictly greater than 2⁶⁴ − 4096” is “at least 2⁶⁴ − 4095,” which is the band [-4095, -1]. INLINE_SYSCALL runs the raw INTERNAL_SYSCALL (the inline syscall instruction), tests the result with the __glibc_unlikely branch hint, and on error calls SYSCALL_ERROR_LABEL with INTERNAL_SYSCALL_ERRNO(sc_ret) = -(sc_ret) — the negation that turns -2 into 2. On success it yields sc_ret unchanged. musl is even terser; src/internal/syscall_ret.c:

long __syscall_ret(unsigned long r)
{
	if (r > -4096UL) {        // same band test
		errno = -r;       // negate into errno
		return -1;        // POSIX failure return
	}
	return r;                 // success: pass through
}

Every libc-wrapped syscall in musl runs its raw result through __syscall_ret; the syscall() macro itself is literally #define syscall(...) __syscall_ret(__syscall(__VA_ARGS__)) (src/internal/syscall.h). The two libcs differ in style but implement byte-for-byte the same protocol.

The Same Band, Two Encodings

The assembly form (cmpq $-4095, %rax + jae) and the C form ((unsigned long)val > -4096UL) select the identical set, and both survive to machine code unchanged — which means you can tell, from a disassembly alone, whether a given glibc stub came from the hand-written PSEUDO path or from an INLINE_SYSCALL C wrapper:

FormSourceCompiles toBranch taken whenSet selected
assemblycmpq $-4095, %rax + jaecmp $0xfffffffffffff001,%rax + jae(u64)rax ≥ 2⁶⁴ − 4095[-4095, -1]
C(unsigned long int)(val) > -4096ULcmp $0xfffffffffffff000,%rax + ja(u64)rax > 2⁶⁴ − 4096[-4095, -1]

The two spellings of the error test, with the constants they actually emit. What it shows: ≥ −4095 and > −4096 are the same predicate over integers, so the two encodings differ only in which immediate and which Jcc the assembler picks. The insight: 0xfffffffffffff001 (= −4095) in a disassembly means hand-written stub; 0xfffffffffffff000 (= −4096) means a C wrapper. Both are shown, side by side from the same shared library, in Demonstrating It below.

Four Wrapper Flavours, Not One

“The libc wrapper” is not a single shape. sysdeps/unix/sysv/linux/x86_64/sysdep.h at glibc 2.43 defines three assembly wrapper macros, and the C side adds a fourth and fifth, each with a different error contract:

FlavourMacro / mechanismTail of the stubSets errno?Returns on failureUsed for
errno-setting asmPSEUDO + PSEUDO_ENDcmpq $-4095 / jae → SYSCALL_ERROR_HANDLERyes-1the classic hand-written stub, e.g. syscall itself
never-fails asmPSEUDO_NOERRNO / PSEUDO_END_NOERRNOnothing at all — just retnon/a (cannot fail)getpid, getppid, gettid, getuid …
errval asmPSEUDO_ERRVAL / PSEUDO_END_ERRVALnegq %raxnoreturns the positive error number directlyPOSIX interfaces that return the error code rather than -1
errno-setting CINLINE_SYSCALL_CALL → INLINE_SYSCALLINTERNAL_SYSCALL_ERROR_P then SYSCALL_ERROR_LABEL(…)yes-1most modern wrappers, and all *_nocancel internals
cancellable CSYSCALL_CANCEL → __syscall_cancelrange check inside __syscall_cancelyes-1every POSIX cancellation point — open, read, write, close, …
raw internalINTERNAL_SYSCALL_CALLnonenoreturns -errno unmodifiedlibc-internal callers that want the raw value

The wrapper taxonomy in glibc 2.43 on x86-64. What it shows: exactly half of these flavours never touch errno, and two of them (PSEUDO_ERRVAL, INTERNAL_SYSCALL_CALL) hand the error back as a value instead. The insight to take: “libc sets errno” is a statement about a subset of libc’s syscall entry points, chosen per-function at build time. The PSEUDO_NOERRNO flavour exists because a wrapper that cannot fail should not pay for a branch it can never take — and you can see the saving in the disassembly of getpid below, which is four instructions long with no comparison in it. INTERNAL_SYSCALL_CALL exists because libc’s own internals frequently must not clobber the caller’s errno.

The distinction matters to callers too, because the C-visible SYSCALL_ERROR_LABEL has a generic definition in sysdeps/unix/sysv/linux/sysdep.h that architectures may override:

#ifndef SYSCALL_ERROR_LABEL
# define SYSCALL_ERROR_LABEL(sc_err)					\
  ({									\
    __set_errno (sc_err);						\
    -1L;								\
  })
#endif

and sysdeps/unix/sysv/linux/x86_64/sysdep.h does override it — but only in the assembler half of the file (#ifdef __ASSEMBLER__), where it becomes the local numeric label 0f under position-independent code, matching the 0: that PSEUDO_END emits via SYSCALL_ERROR_HANDLER. So on x86-64 the same macro name means “a branch target” in assembly and “set errno and yield -1L” in C. Reading only one half of that file is a reliable way to misunderstand the other.

Setting errno — and Why It Is Per-Thread

Once the wrapper knows it has an error, it writes the negated value into errno. The subtlety is that errno is thread-local: setting it in one thread must not be visible in another (errno(3): “errno is thread-local; setting it in one thread does not affect its value in any other thread”). If errno were a single global, two threads each issuing a failing syscall would race and clobber each other’s error indication. The fix is thread-local storage (TLS): each thread has its own errno slot, reached through the thread pointer.

In glibc’s x86-64 assembly error handler, from the same sysdep.h, the write goes directly through the %fs segment register, which on x86-64 points at the thread control block:

#  define SYSCALL_SET_ERRNO                     \
  movq SYSCALL_ERROR_ERRNO@GOTTPOFF(%rip), %rcx;\  /* TLS offset of errno */
  neg %eax;                                     \  /* errno = -RAX */
  movl %eax, %fs:(%rcx);                           /* store into this thread's slot */
 
#  define SYSCALL_ERROR_HANDLER \
0:                              \
  SYSCALL_SET_ERRNO;            \
  or $-1, %RAX_LP;              \  /* RAX = -1 : POSIX failure return */
  ret;

SYSCALL_ERROR_ERRNO@GOTTPOFF(%rip) resolves to the TLS offset of the errno (actually __libc_errno) variable; neg %eax negates the kernel’s -errno to a positive errno; movl %eax, %fs:(%rcx) stores it at that offset relative to %fs, i.e. in the calling thread’s own TLS block. Then or $-1, %RAX_LP forces the return value to -1 and the stub returns. (There is also an RTLD_PRIVATE_ERRNO variant the dynamic linker uses before TLS is set up, writing to a private rtld_errno.) musl takes a different but equivalent route — it stores errno inside the thread’s pthread structure, reached via the thread pointer. From src/errno/__errno_location.c:

int *__errno_location(void)
{
	return &__pthread_self()->errno_val;
}

__pthread_self() returns a pointer to the current thread’s control structure (via the thread pointer register), and errno_val is a field within it; so each thread’s errno lives in its own thread struct. The C-visible errno is a macro that dereferences this function — from musl’s include/errno.h: #define errno (*__errno_location()). glibc uses the identical __errno_location() indirection (the standard requires it, so errno cannot be a plain global), backed by its TLS variable. This is why portable code must #include <errno.h> and use the errno macro rather than declaring extern int errno — the macro hides a function call that finds the right per-thread slot.

What errno Actually Is in glibc, Precisely

Three files define it, and reading only one of them produces a wrong mental model. csu/errno.c declares the storage:

#if RTLD_PRIVATE_ERRNO
/* Code compiled for rtld refers only to this name.  */
int rtld_errno attribute_hidden;
#else
__thread int errno;
extern __thread int __libc_errno __attribute__ ((alias ("errno")))
  attribute_hidden;
#endif

So the object really is named errno, it really is a __thread variable — and __libc_errno is a hidden alias for the same four bytes, not a separate variable. csu/errno-loc.c is then a two-line function:

int *__errno_location (void) { return &errno; }

and the public header stdlib/errno.h (installed as <errno.h>) turns that into the macro every program sees:

extern int *__errno_location (void) __THROW __attribute_const__;
# define errno (*__errno_location ())

Two details in that one declaration carry real weight. __attribute_const__ tells the compiler the function’s result depends on nothing it can observe and has no side effects, so repeated uses of errno in a function collapse to a single call — which is what makes a “function call per errno access” acceptable. And the third file, glibc’s internal include/errno.h, redirects errno for code compiled into libc itself:

# elif IS_IN_LIB && !IS_IN (rtld) && !defined LIBC_NONSHARED
#  if IS_IN (libc)
#   define errno __libc_errno
#  endif
extern __thread int errno attribute_tls_model_ie;

attribute_tls_model_ie requests the initial-exec TLS model. That is the whole reason a libc wrapper can store errno with one mov ..., %fs:(%rcx) instead of calling __tls_get_addr: initial-exec resolves to a fixed offset from the thread pointer at load time, at the cost of making the library unloadable by dlclose — a trade libc can make because libc is never unloaded. See Thread-Local Storage for the four TLS models and what each costs.

flowchart LR
  subgraph SRC["glibc source"]
    E1["csu/errno.c<br/>__thread int errno;<br/>alias __libc_errno"]
    E2["csu/errno-loc.c<br/>__errno_location()<br/>{ return &amp;errno; }"]
    E3["stdlib/errno.h<br/>#define errno<br/>(*__errno_location())"]
  end
  subgraph LINK["libc.so.6, link time"]
    SYM["TLS symbol errno<br/>value 0x30, size 4<br/>PT_TLS MemSiz 0x80"]
    GOT["GOT slot 0x1eedd8<br/>R_X86_64_TPOFF64<br/>addend 0x30"]
  end
  subgraph RUN["runtime, per thread"]
    TP1["thread 1<br/>%fs:0 = 0x7f101035f6c0"]
    TP2["thread 2<br/>%fs:0 = 0x7f100f35e6c0"]
    S1["errno at tp-80<br/>= 0x7f101035f670"]
    S2["errno at tp-80<br/>= 0x7f100f35e670"]
  end
  E1 --> SYM
  E2 --> E3
  SYM --> GOT
  GOT -- "ld.so resolves TPOFF once<br/>(initial-exec)" --> OFF["fixed offset -80"]
  OFF --> TP1 --> S1
  OFF --> TP2 --> S2
  E3 -. "compiles to a call that<br/>returns %fs:0 + offset" .-> OFF

How one source-level name reaches a different four bytes in every thread, with the real numbers from libc.so.6 in glibc 2.43-8.fc44. What it shows: the per-thread-ness is produced entirely by the %fs base; the offset is a single constant resolved once, at load time, into one GOT word that both the syscall error handler and __errno_location() read. The insight to take: there is no lookup, no table, and no lock on the errno path — an error store is neg %eax followed by one %fs-relative mov. That is only possible because libc pins errno to the initial-exec TLS model; a __thread variable in a dlopen-able shared object would need a __tls_get_addr call instead, and errno would cost a function call per store. The thread-pointer values shown are from the run in Demonstrating It below, where all three threads report the same tp-80 offset.

NameKindVisibilityWho uses it
errno (the variable)__thread int, 4 bytesexported (errno@@GLIBC_2.2.5 and errno@@GLIBC_PRIVATE)the definition; also what __errno_location() returns the address of
__libc_errnoalias of the same storageattribute_hidden, localcode inside libc.so, so the access needs no GOT-mediated symbol lookup
rtld_errnoplain int, not thread-localhidden, ld.so onlythe dynamic linker before TLS exists; safe because “all access to errno inside the dynamic linker is serialized” (per include/errno.h)
errno (the macro)(*__errno_location ())public <errno.h>every program; __attribute_const__ lets the compiler CSE the call
__errno_location()functionpublic, libc_hidden_proto’dthe ABI contract — this, not the variable, is what the standard requires

The four things called “errno” in glibc 2.43 and the one function that ties them together. What it shows: the variable, its internal alias, the loader’s non-TLS stand-in, and the public macro are four different objects with three different linkage rules. The insight to take: __errno_location() is the interface and the __thread variable is the implementation. That is why extern int errno; cannot work on modern glibc and why the dynamic linker — which must set errno before the TLS machinery it is itself responsible for setting up exists — is allowed to substitute a plain global. If errno were simply “a thread-local int”, none of that structure would be needed.

Mechanical Walk-through — open() on a Missing File

Trace the whole pipeline with one concrete call: int fd = open("/does/not/exist", O_RDONLY); followed by if (fd == -1) perror("open");.

  1. The application calls glibc’s open. __NR_open (2) does exist on x86-64, but glibc does not use it: it issues openat(AT_FDCWD, …) instead, so that one implementation serves both open and openat. sysdeps/unix/sysv/linux/open.c parses the optional variadic mode argument and issues SYSCALL_CANCEL (openat, AT_FDCWD, file, oflag, mode). Because open is a POSIX cancellation point, that expands to a call to __syscall_cancel, not to an inline trap; the disassembly under Demonstrating It shows push $0x101 (257 = __NR_openat) and mov $0xffffffffffffff9c,%rdi (AT_FDCWD = −100) immediately before the call.
  2. __syscall_cancel → __internal_syscall_cancel → __syscall_cancel_arch, which tests this thread’s cancel bit, then loads __NR_openat into RAX and the arguments into RDI, RSI, RDX, R10 per the x86-64 calling convention, and executes syscall. (A non-cancellation-point wrapper such as getpid skips steps 1–2 entirely and traps directly.)
  3. The CPU traps to ring 0; after the generic entry layer has run its entry work, the kernel’s openat handler tries to resolve the path, fails at the missing component, and returns -ENOENT (signed -2). Via do_syscall_64, regs->ax = -2 (see The errno Convention and Negative Return Values).
  4. sysretq returns to userspace with RAX = -2. __syscall_cancel runs its range check — the C form, cmp $0xfffffffffffff000,%rax / ja — asking “is -2 in [-4095, -1]?” It is, so the error branch is taken. (A hand-written stub such as syscall(2) would take the assembly form, cmp $-4095 / jae, at this step; same set, different encoding.)
  5. The error branch computes neg %eax → 2, stores 2 into this thread’s errno slot with mov %eax,%fs:(%rdx) where %rdx holds the initial-exec TLS offset loaded from the GOT, sets RAX = -1, and returns.
  6. The application sees fd == -1, enters the if, and calls perror("open"). perror reads errno (= 2), maps it through strerror to the message string, and prints open: No such file or directory. The mapping from 2 to that string comes from the same name table seen in the kernel’s errno-base.h (ENOENT 2).

The whole translation — negate, set TLS errno, return -1 — happened entirely in userspace, after the kernel had already finished. The kernel’s only contribution was the -2 in RAX.

The Raw syscall() Wrapper and the Inline Stubs

Not every syscall has a hand-written stub. glibc and musl provide a generic path, the [[The syscall() Generic Wrapper Function|syscall() function]], for calling any syscall by number — syscall(SYS_openat, AT_FDCWD, path, flags, mode). Per syscall(2), syscall() “saves CPU registers before making the system call, restores the registers upon return from the system call, and stores any error returned by the system call in errno” — it runs the same range-check-and-set-errno logic as a dedicated wrapper. The inline INTERNAL_SYSCALL macros shown earlier are how glibc emits the syscall instruction inline for the common arities; each internal_syscallN pins arguments to the right registers with GCC’s register ... asm("rdi") constructs and lists "memory", "cc", "r11", "cx" as clobbers (the kernel destroys RCX and R11 across syscall). The difference between a dedicated wrapper and syscall() is mostly that the dedicated one knows the exact argument types and any compat fix-ups, whereas syscall() passes raw longs and the caller must handle architecture quirks itself (syscall(2) gives the ARM-EABI readahead example).

Cancellation Points — Why open() Is Not a Plain Stub

The disassembly of glibc’s open does not contain a syscall instruction. It contains a call to __syscall_cancel. That surprises people who expect a wrapper to be a thin stub, and the reason is POSIX thread cancellation: open, read, write, close, poll and roughly sixty other interfaces are required to be cancellation points, meaning a pthread_cancel() on a thread blocked in one of them must take effect. The source is direct about it — sysdeps/unix/sysv/linux/open.c in glibc 2.43 is, in its entirety after the argument handling:

return SYSCALL_CANCEL (openat, AT_FDCWD, file, oflag, mode);

SYSCALL_CANCEL (sysdeps/unix/sysdep.h) expands to __syscall_cancel (__SSC (a1), …, __NR_openat), a real function call into nptl, which in turn calls the architecture-specific __syscall_cancel_arch. Getting this right is genuinely hard, and glibc’s current design is the resolution of a bug that stayed open for over a decade.

The old implementation enabled asynchronous cancellation around the syscall and disabled it afterwards. Adhemerval Zanella’s cover letter for the final fix (libc-alpha, “[PATCH v8] nptl: Fix Race conditions in pthread cancellation [BZ#12683]”, 25 June 2024) names the two defects of that approach precisely:

  1. Cancellation can act after the syscall has returned from the kernel, but before userspace saves the return value. It might result in a resource leak if the syscall allocated a resource or a side effect (partial read/write), and there is no way to program handle it with cancellation handlers.
  2. If a signal is handled while the thread is blocked at a cancellable syscall, the entire signal handler runs with asynchronous cancellation enabled. This can lead to issues if the signal handler call functions which are async-signal-safe but not async-cancel-safe.

The replacement reframes cancellation as a question about where the interrupted program counter was. The same message enumerates the windows:

WindowWhere SIGCANCEL arrivedAct on cancellation?How it is detected
1before the initial cancel testyesthe explicit test at __syscall_cancel_arch_start
2between the test and the syscall instructionyessaved PC from the handler’s ucontext_t lies in [__syscall_cancel_arch_start, __syscall_cancel_arch_end)
3blocked in the syscall, no side effects yetyeskernel rewinds PC to the syscall instruction for restart, so this looks exactly like window 2
4blocked in the syscall, side effects already taken (partial read/write)nokernel cannot restart, returns the partial result; PC is past the syscall
5after the syscall returnednosaved PC is ≥ __syscall_cancel_arch_end

The five arrival windows for a cancellation signal around one syscall, from the BZ#12683 cover letter. What it shows: the correct behaviour is not uniform — cancelling in windows 4 and 5 would silently discard a partial read() or leak an allocated resource. The insight to take: the whole mechanism reduces to a program-counter range check inside the SIGCANCEL handler, which is why the two global labels have to bracket the syscall instruction with nothing else in between — and why glibc’s generic C reference implementation carries a #warning "This implementation should be use just as reference or for bootstrapping": on any architecture where INTERNAL_SYSCALL_NCS emits extra instructions around the trap (powerpc and sparc check a condition register, ARM Thumb uses an out-of-line helper), the C version puts the labels in the wrong place. For cases 4 and 5 the cancellation is not lost, only deferred: it “will eventually happen in the next cancellable entrypoint without any further external event.”

This design can be dated by existence-check: sysdeps/unix/sysv/linux/x86_64/syscall_cancel.S returns HTTP 404 at tags glibc-2.26 through glibc-2.40 and HTTP 200 at glibc-2.41, and glibc-2.41’s NEWS lists [12683] nptl: Race conditions in pthread cancellation among the bugs resolved. So the x86-64 assembly cancellation shim is new in glibc 2.41; the note’s host runs 2.43 and has it.

Two consequences for reading a disassembly. First, a cancellation point’s error check lives in __syscall_cancel, not in the wrapper — which is why open has no cmp against -4095 anywhere in it. Second, glibc needs non-cancellable variants for its own internal I/O, because libc opening a locale file must not be a cancellation point in the caller’s thread. Those are the __*_nocancel symbols, and the set is small and enumerable; on the glibc 2.43 build measured here readelf finds exactly these eleven:

Non-cancellable internalCancellable public counterpart
__open_nocancel, __open64_nocancelopen, open64
__openat_nocancel, __openat64_nocancelopenat, openat64
__close_nocancelclose
__read_nocancel, __pread64_nocancelread, pread64
__write_nocancelwrite
__fcntl64_nocancelfcntl
__getrandom_nocancelgetrandom
__fxprintf_nocancel(internal formatting helper, not a syscall wrapper)

The _nocancel internals present in libc.so.6 from glibc 2.43-8.fc44, listed with readelf -sW. What it shows: the non-cancellable set is tiny — eleven symbols, ten of them syscall wrappers — compared with the several dozen interfaces POSIX requires to be cancellation points. The insight: these are not an alternative API for users (they are not exported for external linking); they exist so that libc’s own use of open/read/close — loading locales, reading /etc/resolv.conf, seeding the stack guard — does not create cancellation points inside functions that POSIX does not list as such.

The vDSO Shortcut — No Trap, No errno

A few “syscalls” never reach this wrapper logic at all. For hot, side-effect-free reads like clock_gettime, libc first tries a function exported by the vDSO — a small shared object the kernel maps into every process. __vdso_clock_gettime reads kernel-maintained timekeeping data straight from a shared page and returns without executing a syscall instruction and without trapping (vdso(7)). On the vDSO fast path there is no kernel transition, so there is no RAX negative-return to check and errno is not touched on success; libc only falls back to the real trapping syscall (and its errno translation) when the vDSO path declines. The mechanism of how the vDSO reads time without trapping is out of scope here — see How the vDSO Reads Kernel Time Without Trapping and Raw Syscalls vs vsyscall vs vDSO Performance. The point for this note is only that the errno-translation wrapper is bypassed entirely on that path.

Demonstrating It

Everything above is checkable on a stock distribution with objdump, readelf and a twenty-line C program. Toolchain: Fedora 44, glibc-2.43-8.fc44.x86_64, GCC 16.2.1, binutils 2.46.1, kernel 7.1.13-200.fc44.x86_64, measured 2026-09-11. All addresses below are from the unmodified distribution /lib64/libc.so.6.

The Hand-Written Stub, Verbatim

syscall(2) itself is built by the PSEUDO-style path, so its disassembly is the assembly macro expanded:

$ objdump -d --no-show-raw-insn /lib64/libc.so.6 | awk '/^[0-9a-f]+ <syscall>:/,/^$/'
00000000000f4360 <syscall>:
   f4360:	endbr64
   f4364:	mov    %rdi,%rax          # syscall number -> RAX
   f4367:	mov    %rsi,%rdi          # shift arg1..arg5 down one register
   f436a:	mov    %rdx,%rsi
   f436d:	mov    %rcx,%rdx
   f4370:	mov    %r8,%r10           # arg4 goes in R10, not RCX
   f4373:	mov    %r9,%r8
   f4376:	mov    0x8(%rsp),%r9      # arg6 came in on the stack
   f437b:	syscall
   f437d:	cmp    $0xfffffffffffff001,%rax   # cmpq $-4095, %rax
   f4383:	jae    f4386 <syscall+0x26>       # jae SYSCALL_ERROR_LABEL (= 0f)
   f4385:	ret                                # success: return RAX unchanged
   f4386:	mov    0xfaa4b(%rip),%rcx         # 1eedd8: GOT slot = TLS offset of errno
   f438d:	neg    %eax                       # errno = -RAX
   f438f:	mov    %eax,%fs:(%rcx)            # store into THIS thread's slot
   f4392:	or     $0xffffffffffffffff,%rax   # or $-1, %RAX_LP
   f4396:	ret

That is sysdeps/unix/sysv/linux/x86_64/syscall.S and the SYSCALL_ERROR_HANDLER macro, instruction for instruction — including the register shuffle that turns the C argument order (rdi, rsi, rdx, rcx, r8, r9) into the kernel’s (rax, rdi, rsi, rdx, r10, r8, r9), which is necessary because the kernel destroys RCX (see The x86-64 Syscall Calling Convention).

The Same GOT Slot, From Three Directions

The error handler loads a GOT word at 0x1eedd8 and treats it as an %fs-relative offset. __errno_location() computes the address of the same variable, and it reads the same slot:

$ objdump -d --no-show-raw-insn /lib64/libc.so.6 | awk '/^[0-9a-f]+ <__errno_location>:/,/^$/'
0000000000003a00 <__errno_location>:
    3a00:	endbr64
    3a04:	mov    0x1eb3cd(%rip),%rax        # 1eedd8  <-- the same slot
    3a0b:	add    %fs:0x0,%rax               # thread pointer + offset
    3a14:	ret
 
$ readelf -rW /lib64/libc.so.6 | grep 1eedd8
00000000001eedd8  0000000000000012 R_X86_64_TPOFF64                          30
 
$ readelf -sW /lib64/libc.so.6 | grep -w 'errno\|__libc_errno'
   914: 0000000000000030     4 TLS  GLOBAL DEFAULT 25 errno@@GLIBC_PRIVATE
  1769: 0000000000000030     4 TLS  LOCAL  DEFAULT 25 __libc_errno
  6005: 0000000000000030     4 TLS  GLOBAL DEFAULT 25 errno

Three facts fall out at once, and each confirms a claim made earlier from source. The relocation type is R_X86_64_TPOFF64 — the initial-exec model requested by attribute_tls_model_ie, resolved once by the dynamic linker rather than by a call to __tls_get_addr. The symbol table shows errno and __libc_errno at the same value 0x30, with the same size 4, in the same section — they are the alias pair from csu/errno.c, not two variables. And __errno_location is literally “thread pointer plus that offset”, which is why *__errno_location() and the stub’s %fs:(%rcx) store necessarily refer to the same four bytes.

The arithmetic closes exactly. libc.so.6’s PT_TLS segment has MemSiz 0x80, and on x86-64 a library’s TLS block for the initial-exec model is placed below the thread pointer, so errno at offset 0x30 within a 128-byte block lands at −128 + 48 = −80 from %fs:0:

$ readelf -lW /lib64/libc.so.6 | grep TLS
  TLS  0x1ebb78 0x1ebb78 0x1ebb78 0x000030 0x000080 R  0x8

and a program that prints (char *)__errno_location() - (char *)__builtin_thread_pointer() reports exactly -80, in every thread, below.

Three Wrapper Flavours in One Library

$ # PSEUDO_NOERRNO: getpid cannot fail, so there is no check and no errno
00000000000c76c0 <__getpid>:
   c76c0:	endbr64
   c76c4:	mov    $0x27,%eax        # __NR_getpid = 39
   c76c9:	syscall
   c76cb:	ret
 
$ # INLINE_SYSCALL_CALL (C form): note -4096/ja, not -4095/jae
00000000000ea1b0 <__open64_nocancel>:
   ...
   ea1e7:	mov    $0xffffff9c,%edi  # AT_FDCWD = -100
   ea1ef:	mov    $0x101,%eax       # __NR_openat = 257
   ea1f4:	syscall
   ea1f6:	cmp    $0xfffffffffffff000,%rax
   ea1fc:	ja     ea230 <__open64_nocancel+0x80>
 
$ # SYSCALL_CANCEL: the public open() has no syscall instruction at all
00000000000e8b80 <__open>:            # this address is open@@GLIBC_2.2.5
   ...
   e8bc3:	push   $0x101             # __NR_openat, last argument
   e8bc8:	mov    %rax,%rsi          # file
   e8bcb:	mov    $0xffffffffffffff9c,%rdi   # AT_FDCWD
   e8bd2:	call   6f560 <__syscall_cancel>
 
000000000006f560 <__syscall_cancel>:
   6f56f:	call   6f4d0 <__internal_syscall_cancel>
   6f576:	cmp    $0xfffffffffffff000,%rax   # the C-form band test
   6f57c:	ja     6f580 <__syscall_cancel+0x20>
   6f57e:	leave
   6f57f:	ret
   6f580:	mov    0x17f851(%rip),%rdx        # 1eedd8  <-- the same GOT slot again
   6f587:	neg    %eax
   6f589:	mov    %eax,%fs:(%rdx)
   6f58c:	mov    $0xffffffffffffffff,%rax

Set against the Four Wrapper Flavours table, this is the whole taxonomy visible in one library: getpid is four instructions with no branch (PSEUDO_NOERRNO); __open64_nocancel inlines the trap and uses the C-form -4096/ja test (INLINE_SYSCALL_CALL); the exported open delegates entirely (SYSCALL_CANCEL). And __syscall_cancel_arch — the innermost frame — does the trap with the cancellation markers bracketing it, exactly as the BZ#12683 design requires:

000000000007b2e0 <__syscall_cancel_arch>:
   7b2e0:	endbr64
 
000000000007b2e4 <__syscall_cancel_arch_start>:   # global marker 1
   7b2e4:	mov    (%rdi),%eax
   7b2e6:	testb  $0x8,(%rdi)                     # CANCELED_BITMASK
   7b2e9:	jne    6f460 <__syscall_do_cancel>
   ...                                             # argument shuffle
   7b310:	syscall
 
000000000007b312 <__syscall_cancel_arch_end>:     # global marker 2, PC+2
   7b312:	ret

The two markers are ordinary global symbols, and there is nothing between the cancel test and the syscall instruction except the argument shuffle — which is precisely the invariant the generic C reference implementation warns it cannot guarantee.

errno Is Per-Thread, Measurably

static void *worker(void *arg) {
	int fd = open("/definitely/not/here", O_RDONLY);
	printf("%-8s open()=%d  errno=%d (%s)  &errno=%p  tp%+td\n",
	       (char *)arg, fd, errno, strerror(errno),
	       (void *)__errno_location(),
	       (char *)__errno_location() - (char *)__builtin_thread_pointer());
	return NULL;
}

with a raw openat issued by inline assembly for contrast:

$ gcc -O2 -Wall -o errno_demo errno_demo.c && ./errno_demo
raw syscall openat()      -> -2   (errno untouched: 0)
syscall(SYS_getpid)       -> 1299146   (errno untouched: 0)
syscall(-1) [no such nr]  -> -1   errno=38 (Function not implemented)
thread2  open()=-1  errno=2 (No such file or directory)  &errno=0x7f100f35e670  tp-80
thread1  open()=-1  errno=2 (No such file or directory)  &errno=0x7f101035f670  tp-80
main     open()=-1  errno=2 (No such file or directory)  &errno=0x7f10103606f0  tp-80

Five claims, all confirmed in six lines:

  1. The kernel returns -2, not errno. The hand-rolled syscall instruction gets -2 in RAX and errno stays 0. Nothing in the kernel wrote errno, because errno is not a kernel concept.
  2. syscall() performs the translation even for arbitrary numbers. syscall(-1) is rejected by the kernel with -ENOSYS (-38) and the wrapper turns that into a -1 return and errno == 38.
  3. Never-failing wrappers leave errno alone. syscall(SYS_getpid) returns a large positive PID; errno is still 0.
  4. Each thread has its own errno. Three different addresses — 0x7f100f35e670, 0x7f101035f670, 0x7f10103606f0 — roughly 16 MiB apart, one per thread stack/TLS area.
  5. At the same offset in every thread. All three report tp-80, matching the −128 + 0x30 computed from the PT_TLS header. One fixed offset, three different thread pointers: that is the initial-exec TLS model, observed.

The vDSO Path, For Contrast

$ ./vd
clock_gettime: 17.1 ns/call, errno still 4242

errno was deliberately set to 4242 before the loop and is unchanged after two million clock_gettime() calls — nothing in the vDSO path touches it — and at 17.1 ns the call costs less than half the ~36–38 ns a trapping getpid() costs on the same machine (measured in The Generic Syscall Entry and Exit Layer). Same C-level call syntax, entirely different machinery underneath.

Failure Modes and Common Misunderstandings

Almost every errno bug is one of six shapes, and they are worth having as a checklist before the prose:

SymptomRoot causeCorrect idiom
errno is nonzero after a call that succeededa successful call is permitted to clobber errno and never clears ittest the return value first; only then read errno
-1 is a legitimate result (getpriority, some ioctls, strtol overflow)the return value cannot distinguish success from failureerrno = 0; before the call, test errno after
the reported error is from the wrong callany intervening libc call — including printf in your own error path — may set errnoint saved = errno; on the line immediately after the failing call
extern int errno; does not compile, or links to the wrong object<errno.h> #defines errno to (*__errno_location ())include <errno.h> and use the macro
a raw syscall instruction “does not set errno”correct — errno is a libc inventiondo the [-4095, -1] band check yourself
a signal handler corrupts the main flow’s errnothe handler’s own syscalls write the same per-thread slotsave and restore errno around the handler body

The six recurring errno defects. What it shows: four of the six come from treating errno as if it were a result rather than a side channel that is only meaningful in a window the return value opens. The insight to take: the last row is the one that bites hardest in practice and is not on most checklists — a signal handler runs on the same thread and therefore writes the same errno slot, so an interrupted read() can have its errno overwritten by a write() inside the handler before the main flow ever looks at it. Per-thread storage solves the thread race; it does nothing for the signal race, and Async-Signal-Safety and Reentrant Handlers is where that belongs.

The classic bug is reading errno when the call succeeded. The C standard and errno(3) are explicit: a successful call may leave errno nonzero (a function is allowed to clobber it internally), and errno is never reset to zero by a successful call. So errno is meaningful only after a call has signalled failure through its return value. Code that does open(...); if (errno) ... is wrong; it must test the return (fd == -1) first.

A second trap is the -1 is a valid result family — getpriority(2), some ioctls — where -1 can be a legitimate success. The portable idiom (errno(3)) is to set errno = 0 before the call and then test errno afterward, since the return value alone cannot disambiguate. A third is errno clobbering across library calls: any intervening libc call (even printf) may set errno, so the value must be saved immediately if it is needed later — int saved = errno; right after the failing call.

A subtler issue is thread-safety assumptions in old code. Pre-threads Unix declared extern int errno; as a global. On modern Linux that declaration is wrong and will not even compile against <errno.h> (which #defines errno to a function call). Code ported from ancient sources sometimes still tries it; the fix is always to use the header’s macro. Finally, on the raw-instruction path (Go, custom runtimes, hand-written assembly that does not go through libc), errno is not set at all — the negative return is the only error signal, and the runtime must do its own band check. The Go runtime does exactly this; see System Calls and the Scheduler.

Alternatives and When They Apply

Most application code should use the dedicated wrappers (open, read, write) — they handle argument types, compat translation, and cancellation points correctly. Reach for the generic [[The syscall() Generic Wrapper Function|syscall()]] only when a syscall has no wrapper — a brand-new syscall whose glibc stub has not shipped yet, or one glibc historically declined to wrap until a later release (gettid and getrandom are commonly cited examples of syscalls that for years had to be called via syscall()) — accepting that you handle architecture-specific argument marshalling yourself. Bypassing libc entirely (issuing the syscall instruction directly) is what Go and some sandboxes/static binaries do; it avoids libc’s TLS setup and cancellation machinery but means you forfeit errno translation, vDSO acceleration unless you replicate it, and any compat fix-ups — you are then implementing the kernel ABI by hand. The trade-off is control and minimal dependencies versus reimplementing correctness that libc already got right.

See Also