Anatomy of glibc Syscall Wrappers

On this page

Seeing openat in strace does not necessarily mean the source code called openat; calling clock_gettime does not guarantee that a system call with the same name appears in the trace. The libc provides the semantics of the public interface, but internally it may choose other system calls, user-space fast paths, or combine multiple steps.

Understanding this boundary serves two purposes: when reading disassembly, you know where to look for argument and error translation; when optimizing, you can confirm whether you have preserved the semantics of the original API.

A library function call does not necessarily correspond to a single kernel call Application calls public API e.g., read, open, clock_gettime 1 libc implements interface semantics Cancellation, compatibility, argument and layout adaptation 2 Select implementation path Available vDSO path, or actually enter the kernel 3 Convert result and return to application Return results and errors according to the public interface convention 4 The public syscall() also translates errno; only the raw assembly layer sees the kernel's -errno directly.

Three Interface Layers: The Same Name May Refer to Different Things

LayerExampleWho Handles ABI and Errors
Public Library Functionread(fd, buf, n)libc implements the full contract of the function
Generic Syscall Functionsyscall(SYS_read, fd, buf, n)libc arranges registers and translates errors; not equivalent to all semantics of read
Raw Instruction WrapperInline assembly syscall / svcCaller passes arguments according to architecture ABI and interprets raw results

In the raw ABIs of x86-64 and AArch64 Linux, error codes are -errno. The public syscall() has already converted errors to -1 and written to errno. Therefore, after seeing syscall(...) == -1, you should read errno; do not negate -1 again to treat it as an error number. syscall(2)

errno is thread-local state; successful returns do not require it to be cleared. Only interpret it when the interface return value indicates failure, and save it before subsequent calls that might overwrite errno. Not all C interfaces report errors via errno; for example, many pthread functions return error codes directly.

Seeing Translation via a Failing Call

#define _GNU_SOURCE
#include <errno.h>
#include <stdio.h>
#include <sys/syscall.h>
#include <unistd.h>

int main(void)
{
    long result = syscall(SYS_write, -1, "x", (size_t)1);
    int saved = errno;
    printf("result=%ld, errno=%d, EBADF=%d\n", result, saved, EBADF);
    return !(result == -1 && saved == EBADF);
}
cc -std=c11 -Wall -Wextra -Werror wrapper.c -o wrapper
./wrapper
strace -e trace=write ./wrapper

An invalid file descriptor causes the kernel to return EBADF. The application observes -1 and errno=EBADF; strace decodes the raw error into a human-readable form. printf itself may also generate a write; when analyzing the trace, distinguish this from the target call with fd=-1.

If you use raw_write from the x86-64 article, the return value will be directly -EBADF. Do not mix these two error conventions in a single algorithm.

Save Error Codes Together with the Return Value That Generated Them

Interface ConventionHow to JudgeSituations Prone to Misinterpretation
read / write / syscallRead errno after return value is -1errno retains old errors on success
pthread_create, etc.Check the error code returned directly by the functionNon-zero return but printing unrelated errno
Interfaces with ambiguous zero values (e.g., getauxval)Clear errno first per documentation, then combine with resultMisjudging non-existence based solely on zero value
Raw syscall assemblyJudge based on architecture error rangeTreating unconverted -errno as ordinary -1

For example, if the first write fails, and then you open a log file to record context, this open might also change errno. You should save saved_errno immediately after the failure, and then use the saved value to format the report. Setting errno=0 is not a fixed ritual before all calls; only do this when the interface convention requires eliminating ambiguity from old values.

Where does old errno go after success?

Fail once, then succeed: the result becomes 3 while errno can remain 9. errno is not a global error lamp reset by the kernel. Save it immediately when the corresponding return indicates failure.

Preparing the visual
Where does old errno go after success?

glibc syscall() converts raw -errno to -1 and sets errno; success need not clear errno. Interpret errno with the matching return value.

What Macros in Source Code Solve

The architecture layer of glibc arranges call numbers, input/output registers, and clobbers; the internal call layer preserves raw results, and the public wrapper layer performs errno translation. Specific macro names and expansion methods vary by version, architecture, and number of arguments; you cannot treat the expansion of an old version's macro as a permanent interface for glibc.

When reading inline asm, focus on checking: whether the output registers are correctly associated with the input call number, whether the fourth argument is placed in the register required by the target ABI, whether hardware-clobbered registers are listed in the clobber list, and whether memory visibility is accurately described.

asm volatile indicates that the assembly has side effects that cannot be arbitrarily removed; it is not itself a memory barrier. The "memory" clobber tells the compiler that the assembly may access memory not listed, limiting corresponding compiler reordering, and does not automatically generate CPU memory barriers. These constraints are a different layer of concern from C/C++ atomic memory orders. GCC Extended Asm

Thread Cancellation is Not "Check Before a Slow Call"

Interfaces like read and write have cancellation point semantics. The glibc read implementation goes through SYSCALL_CANCEL; the implementation needs to handle cancellation requests and the race windows between entering, blocking, and leaving the system call. Checking the flag only once before the call might result in receiving a cancellation request after the check and blocking permanently. glibc read implementation

Directly changing to syscall(SYS_read, ...) or raw assembly cannot be considered an equivalent replacement. It may bypass libc's cancellation mechanism, thereby changing thread stop and resource cleanup behavior. Conversely, using the public read does not mean the thread can be safely asynchronously cancelled at any arbitrary point; design resource lifecycles according to the conventions of cancellation types and cleanup handlers.

Cancellation requests can be suspended first, then processed at allowed cancellation points Worker: cancel disabled Register cleanup, ready Main: request cancel Pending does not mean exited Worker: enable cancel Public read: cancellation point Cleanup closes read end; join observes PTHREAD_CANCELED Main thread then closes its own write end and destroys synchronization objects This example uses deferred cancellation (default); two synchronization barriers are used to determine event order, not relying on sleep to guess.

Observe Cancellation and Cleanup with Deterministic Ordering

Below, on glibc/Linux, we set up a pipe; the worker thread eventually prepares to call read; the main thread holds the write end but does not write, so there is no data, and EOF does not appear prematurely. Two barriers ensure that cleanup is registered first, then the cancellation request is issued, and finally cancellation is re-enabled. With deferred cancellation (default), the suspended request is processed at the cancellation point of the public read. Cancellation State

Save as cancel-read.c:

#define _GNU_SOURCE
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

static pthread_barrier_t ready, requested;
static int ends[2];
static int cleaned;
static void require(int code) { if (code != 0) abort(); }
static void meet(pthread_barrier_t *barrier) {
    int code = pthread_barrier_wait(barrier);
    if (code != 0 && code != PTHREAD_BARRIER_SERIAL_THREAD) abort();
}
static void cleanup(void *unused) {
    (void)unused;
    if (close(ends[0]) != 0) abort();
    cleaned = 1;
}
static void *worker(void *unused) {
    (void)unused;
    require(pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL));
    pthread_cleanup_push(cleanup, NULL);
    meet(&ready);
    meet(&requested);
    require(pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL));
    char byte;
    (void)read(ends[0], &byte, 1);
    pthread_cleanup_pop(1);
    return NULL;
}
int main(void) {
    if (pipe(ends) != 0) return 1;
    require(pthread_barrier_init(&ready, NULL, 2));
    require(pthread_barrier_init(&requested, NULL, 2));
    pthread_t thread;
    require(pthread_create(&thread, NULL, worker, NULL));
    meet(&ready);
    require(pthread_cancel(thread));
    meet(&requested);
    void *result;
    require(pthread_join(thread, &result));
    if (close(ends[1]) != 0) return 1;
    require(pthread_barrier_destroy(&ready));
    require(pthread_barrier_destroy(&requested));
    printf("canceled=%d cleaned=%d\n", result == PTHREAD_CANCELED, cleaned);
    return !(result == PTHREAD_CANCELED && cleaned == 1);
}
cc -std=c11 -Wall -Wextra -pthread cancel-read.c -o cancel-read
./cancel-read

Expected output: canceled=1 cleaned=1. cleaned is written by the worker thread and read by the main thread after a successful join; this read point guarantees that the thread has ended; you cannot remove the join and have the main thread poll a plain int without synchronization.

pthread_cleanup_push/pop are macros in glibc that must be used in pairs, and their scopes must match. In this example, the cleanup handler owns the read end, and the main thread owns the write end; after separating the closing responsibilities, neither the cancellation path nor the normal path will close the same descriptor twice. To highlight the protocol, thread infrastructure errors terminate the example with abort; production code also needs to arrange resource cleanup in case of creation failures. Cleanup Handlers

Do not directly replace the read in this example with a raw system call to make a "faster" version: that would change the cancellation point semantics, and suspended requests might not terminate the wait according to this example's protocol. If you choose to manage stopping yourself, provide a clear wake-up channel for the wait and redesign the cleanup protocol, rather than just adding a boolean value outside the loop.

vDSO: Keep API, Replace Implementation Path

The kernel maps the vDSO into the process, and libc can resolve versioned symbols within it. For supported clocks, the clock_gettime path can read kernel-shared timing information and compute results in user space; when encountering unsupported environments or requests, the path may enter the kernel.

This is not a generic whitelist based on the numerical value of clock_id. Different architectures, time64 compatibility requirements, vDSO symbols, and their return errors all affect the implementation. You also cannot unconditionally ignore vDSO errors and call again: specific error propagation and fallback must conform to the interface. glibc clock_gettime, vdso(7)

Below, compare the two paths on native 64-bit Linux:

#define _GNU_SOURCE
#include <stdio.h>
#include <sys/syscall.h>
#include <time.h>
#include <unistd.h>

int main(void)
{
    struct timespec a, b;
    if (clock_gettime(CLOCK_MONOTONIC, &a) == -1) {
        perror("clock_gettime");
        return 1;
    }
    if (syscall(SYS_clock_gettime, CLOCK_MONOTONIC, &b) == -1) {
        perror("syscall clock_gettime");
        return 1;
    }
    printf("libc=%lld.%09ld, syscall=%lld.%09ld\n",
           (long long)a.tv_sec, a.tv_nsec,
           (long long)b.tv_sec, b.tv_nsec);
    return 0;
}

Compile and run strace -e trace=clock_gettime ./clock-paths. The second call explicitly enters the kernel; the first call may not produce this syscall record if it takes the available vDSO path. Both calls read the same monotonic clock; the latter should not be earlier than the former, but clock resolution allows the two results to be equal; scheduling may also increase the interval, so you cannot use a very small fixed difference as a mandatory pass condition. No syscall record does not mean the function did not execute. clock_gettime(3)

Wrappers Also Include Historical Compatibility

glibc can implement open using openat; time interfaces on 32-bit platforms may require time64 compatibility branches. Source code function names, dynamic symbol names, and kernel syscall names do not necessarily correspond one-to-one; seccomp policies and trace filter conditions should be determined based on the actual call paths of the target environment. open(2)

glibc removed the PID cache for getpid in version 2.25; you can no longer use "current getpid always returns directly from the cache" to explain benchmark results. getpid(2)

musl also requires architecture argument conventions, thread-local errno, cancellation points, and applicable vDSO paths. When comparing two libc implementations, select the version, target architecture, and specific functions, and check build artifacts and behavior; deriving performance from "five lines of code per syscall versus fifty lines" has no reliable significance.

How to Measure Wrapper Cost

First, clarify whether the measurement object is raw entry/exit, public API calls, or end-to-end operations including scheduling and device waits. Record kernel, libc, CPU, compilation options, and virtualization environment, and distinguish batch loop overhead from the target call.

strace is suitable for confirming paths and errors; tracing itself significantly perturbs latency and is not suitable for directly representing micro-benchmarks of untraced programs. For clock APIs, confirm whether vDSO is used; for futex, you must specify the wait value, whether it actually sleeps, and who wakes it up; you cannot treat "uncontested FUTEX_WAIT" as a fixed-cost operation.

Only consider bypassing the public interface when measurement shows that the wrapper cost is significant and you can clearly assume the semantics of cancellation and compatibility. Reducing unnecessary system calls and merging I/O is usually more worth checking first than copying architecture-specific assembly.