libc Syscall Wrappers and errno Translation
When a C program calls
open("/nope", O_RDONLY), it is not executing asyscallinstruction directly — it calls a C-library wrapper function, a thin stub that loads the syscall number and arguments into the architecture’s registers, executes thesyscallinstruction, 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 inRAX(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-localerrnovariable and returns-1to the caller; otherwise it returns the value unchanged. This note traces that wrapper, the exact range check (cmpq $-4095, %raxin assembly,(unsigned long)val > -4096ULin C), the per-thread storage oferrnovia thread-local storage (TLS) and__errno_location, and the cases — like the vDSO — where there is no trap and noerrnotouch at all. Source claims here are pinned to glibc 2.43 (tagged onsourceware.orgroughly 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-ENOENTgets intoRAXin 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:
| Form | Source | Compiles to | Branch taken when | Set selected |
|---|---|---|---|---|
| assembly | cmpq $-4095, %rax + jae | cmp $0xfffffffffffff001,%rax + jae | (u64)rax ≥ 2⁶⁴ − 4095 | [-4095, -1] |
| C | (unsigned long int)(val) > -4096UL | cmp $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:
| Flavour | Macro / mechanism | Tail of the stub | Sets errno? | Returns on failure | Used for |
|---|---|---|---|---|---|
| errno-setting asm | PSEUDO + PSEUDO_END | cmpq $-4095 / jae → SYSCALL_ERROR_HANDLER | yes | -1 | the classic hand-written stub, e.g. syscall itself |
| never-fails asm | PSEUDO_NOERRNO / PSEUDO_END_NOERRNO | nothing at all — just ret | no | n/a (cannot fail) | getpid, getppid, gettid, getuid … |
| errval asm | PSEUDO_ERRVAL / PSEUDO_END_ERRVAL | negq %rax | no | returns the positive error number directly | POSIX interfaces that return the error code rather than -1 |
| errno-setting C | INLINE_SYSCALL_CALL → INLINE_SYSCALL | INTERNAL_SYSCALL_ERROR_P then SYSCALL_ERROR_LABEL(…) | yes | -1 | most modern wrappers, and all *_nocancel internals |
| cancellable C | SYSCALL_CANCEL → __syscall_cancel | range check inside __syscall_cancel | yes | -1 | every POSIX cancellation point — open, read, write, close, … |
| raw internal | INTERNAL_SYSCALL_CALL | none | no | returns -errno unmodified | libc-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; \
})
#endifand 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;
#endifSo 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 &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.
| Name | Kind | Visibility | Who uses it |
|---|---|---|---|
errno (the variable) | __thread int, 4 bytes | exported (errno@@GLIBC_2.2.5 and errno@@GLIBC_PRIVATE) | the definition; also what __errno_location() returns the address of |
__libc_errno | alias of the same storage | attribute_hidden, local | code inside libc.so, so the access needs no GOT-mediated symbol lookup |
rtld_errno | plain int, not thread-local | hidden, ld.so only | the 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() | function | public, libc_hidden_proto’d | the 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");.
- The application calls glibc’s
open.__NR_open(2) does exist on x86-64, but glibc does not use it: it issuesopenat(AT_FDCWD, …)instead, so that one implementation serves bothopenandopenat.sysdeps/unix/sysv/linux/open.cparses the optional variadicmodeargument and issuesSYSCALL_CANCEL (openat, AT_FDCWD, file, oflag, mode). Becauseopenis a POSIX cancellation point, that expands to a call to__syscall_cancel, not to an inline trap; the disassembly under Demonstrating It showspush $0x101(257 =__NR_openat) andmov $0xffffffffffffff9c,%rdi(AT_FDCWD= −100) immediately before the call. __syscall_cancel→__internal_syscall_cancel→__syscall_cancel_arch, which tests this thread’s cancel bit, then loads__NR_openatintoRAXand the arguments intoRDI,RSI,RDX,R10per the x86-64 calling convention, and executessyscall. (A non-cancellation-point wrapper such asgetpidskips steps 1–2 entirely and traps directly.)- The CPU traps to ring 0; after the generic entry layer has run its entry work, the kernel’s
openathandler tries to resolve the path, fails at the missing component, and returns-ENOENT(signed-2). Viado_syscall_64,regs->ax = -2(see The errno Convention and Negative Return Values). sysretqreturns to userspace withRAX = -2.__syscall_cancelruns its range check — the C form,cmp $0xfffffffffffff000,%rax/ja— asking “is-2in[-4095, -1]?” It is, so the error branch is taken. (A hand-written stub such assyscall(2)would take the assembly form,cmp $-4095/jae, at this step; same set, different encoding.)- The error branch computes
neg %eax→2, stores2into this thread’serrnoslot withmov %eax,%fs:(%rdx)where%rdxholds the initial-exec TLS offset loaded from the GOT, setsRAX = -1, and returns. - The application sees
fd == -1, enters theif, and callsperror("open").perrorreadserrno(=2), maps it throughstrerrorto the message string, and printsopen: No such file or directory. The mapping from2to that string comes from the same name table seen in the kernel’serrno-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:
- 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.
- 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:
| Window | Where SIGCANCEL arrived | Act on cancellation? | How it is detected |
|---|---|---|---|
| 1 | before the initial cancel test | yes | the explicit test at __syscall_cancel_arch_start |
| 2 | between the test and the syscall instruction | yes | saved PC from the handler’s ucontext_t lies in [__syscall_cancel_arch_start, __syscall_cancel_arch_end) |
| 3 | blocked in the syscall, no side effects yet | yes | kernel rewinds PC to the syscall instruction for restart, so this looks exactly like window 2 |
| 4 | blocked in the syscall, side effects already taken (partial read/write) | no | kernel cannot restart, returns the partial result; PC is past the syscall |
| 5 | after the syscall returned | no | saved 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 internal | Cancellable public counterpart |
|---|---|
__open_nocancel, __open64_nocancel | open, open64 |
__openat_nocancel, __openat64_nocancel | openat, openat64 |
__close_nocancel | close |
__read_nocancel, __pread64_nocancel | read, pread64 |
__write_nocancel | write |
__fcntl64_nocancel | fcntl |
__getrandom_nocancel | getrandom |
__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: retThat 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 errnoThree 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 0x8and 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,%raxSet 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: retThe 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-80Five claims, all confirmed in six lines:
- The kernel returns
-2, noterrno. The hand-rolledsyscallinstruction gets-2inRAXanderrnostays0. Nothing in the kernel wroteerrno, becauseerrnois not a kernel concept. 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-1return anderrno == 38.- Never-failing wrappers leave
errnoalone.syscall(SYS_getpid)returns a large positive PID;errnois still0. - Each thread has its own
errno. Three different addresses —0x7f100f35e670,0x7f101035f670,0x7f10103606f0— roughly 16 MiB apart, one per thread stack/TLS area. - At the same offset in every thread. All three report
tp-80, matching the−128 + 0x30computed from thePT_TLSheader. 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 4242errno 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:
| Symptom | Root cause | Correct idiom |
|---|---|---|
errno is nonzero after a call that succeeded | a successful call is permitted to clobber errno and never clears it | test 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 failure | errno = 0; before the call, test errno after |
| the reported error is from the wrong call | any intervening libc call — including printf in your own error path — may set errno | int 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 invention | do the [-4095, -1] band check yourself |
a signal handler corrupts the main flow’s errno | the handler’s own syscalls write the same per-thread slot | save 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
- The errno Convention and Negative Return Values — the kernel-side half: how
-EINVALreachesRAXand theMAX_ERRNO/IS_ERR_VALUEreservation this check mirrors - The syscall() Generic Wrapper Function — the by-number generic wrapper that runs this same errno translation
- The x86-64 Syscall Calling Convention — which register carries the number, the args, and the result the wrapper loads and reads
- The vDSO Virtual Dynamic Shared Object — the no-trap fast path that bypasses the wrapper and never touches
errno - Raw Syscalls vs vsyscall vs vDSO Performance — cost comparison of the trapping wrapper vs the vDSO shortcut
- EINTR and Interrupted System Calls — the wrapper translates
-EINTRintoerrno == EINTR; restart loops live here - System Calls and the Scheduler — Go bypasses libc, issues the raw instruction, and does its own negative-return check
- The Kernel ABI vs API Distinction — why
errnois an API presentation invented by libc, not part of the kernel ABI - The Generic Syscall Entry and Exit Layer — the kernel-side counterpart: what runs between the
syscallinstruction and the handler, and what puts the negative value inregs->ax - Thread-Local Storage — the four TLS models;
errnouses initial-exec, which is why the store is one%fs-relativemov - The Dynamic Linker — resolves the
R_X86_64_TPOFF64relocation behinderrno, and is itself the reasonrtld_errnoexists - glibc vs musl — the two implementations compared here in the small
- Async-Signal-Safety and Reentrant Handlers — why a signal handler must save and restore
errno - Versioned Symbols in glibc — why the disassembly shows
errno@@GLIBC_2.2.5anderrno@@GLIBC_PRIVATEfor one variable - Linux System Call Interface MOC — parent map (§10 The Userspace Side — Wrappers)
- Linux Userspace Runtime MOC