Operating Systems · System Calls
The vDSO and System Calls That Never Trap
How a read-only code page mapped into every process serves clock and CPU queries without a privilege transition, and what that costs in portability.
Reading the current time is one of the most frequent things a program does and one of the least privileged. The answer requires no kernel state that user code cannot be trusted to see: it is a counter plus a correction. Yet for years it was a system call, and a program that logged a timestamp per event paid a privilege transition per timestamp. The vDSO is the mechanism that removed the transition for this and a small set of similar queries by mapping kernel-supplied code and data into every process at load time.
The idea is not to let user code read kernel memory. It is to let the kernel publish a small amount of information in a page that user code may read, together with the code needed to interpret it, so that the interpretation happens entirely at privilege level 3. Nothing about the arrangement weakens protection: the page is read-only to the process, contains no pointers the kernel will later dereference, and cannot be used to make the kernel do anything.
What the mapping contains
Two things, in one or two pages depending on the architecture. The first is executable code: a handful of short routines that implement the fast path of a few calls. The second is data: a structure describing the current clock source, its frequency, the offset to apply, and a sequence counter that lets a reader detect that the data changed mid-read.
The code is built at kernel build time and copied into a page that is mapped into every process’s address space. The address is randomised per process, so a caller cannot jump to a fixed address; it must find the page through the auxiliary vector that the kernel places on the stack at process start. That vector holds entries of type and value pairs, one of which names the vDSO base. The C library reads it during startup and caches a function pointer. A program that does not use the C library must parse the vector itself, and a program that never looks at the vector falls back to the system call without any error being raised.
The routines available are few by design: clock_gettime for the realtime and monotonic clocks, plus
gettimeofday, clock_getres, time, and getcpu. That list is representative of the interface as a
whole rather than an invariant of it; kernels have added entries over time and can add more. The
admission criterion is what has stayed fixed: each call served from the page is a query with no side
effects on kernel state. If answering the question requires the kernel to change something, allocate
something, or check a permission that only the kernel can check, the call cannot live in the vDSO.
Reading a clock without the kernel
The fast path for the monotonic clock is a read of a counter, a multiply, and a shift.
The counter is a free-running timer that the architecture exposes to user mode. On x86 it is the
timestamp counter, read with rdtsc; on AArch64 it is a system register readable at user level. The
vDSO’s job is to convert the counter’s ticks into nanoseconds and add the kernel’s notion of the epoch.
The conversion needs a multiplier and a shift rather than a division, because the parameters are
chosen so that a multiplication and a right shift produce the same result as a divide, and the routine
must stay short enough to beat the trap it replaces. The kernel computes the multiplier and shift once
when the clock source is chosen and publishes them in the data page. The routine then computes
(ticks * mult) >> shift, which is exact for the range of values that matter because the kernel chose
the parameters so that the product does not overflow.
The subtlety is that the parameters change. The kernel may switch clock sources, adjust for a frequency correction, or slew the clock in response to a time service. A reader that loads the parameters, reads the counter, and applies the parameters could apply a stale multiplier to a fresh counter. The data page therefore carries a sequence counter, and the reader brackets its work with it:
/* The retry loop every vDSO clock read depends on. The kernel
increments the sequence before and after modifying the data,
always leaving it odd while a write is in progress. */
struct vdso_data {
unsigned int seq;
unsigned int mult;
unsigned int shift;
uint64_t base; /* epoch offset added to the converted value */
/* ... clock source identifier, per-CPU offsets ... */
};
static inline uint64_t read_tsc(void)
{
unsigned int lo, hi;
__asm__ volatile ("rdtsc" : "=a"(lo), "=d"(hi));
return ((uint64_t)hi << 32) | lo;
}
static uint64_t read_monotonic(const struct vdso_data *d)
{
uint64_t ticks, ns;
unsigned int s1, s2;
do {
s1 = __atomic_load_n(&d->seq, __ATOMIC_RELAXED);
__atomic_thread_fence(__ATOMIC_ACQUIRE); /* parameters before counter */
ticks = read_tsc();
ns = (ticks * d->mult) >> d->shift;
s2 = __atomic_load_n(&d->seq, __ATOMIC_RELAXED);
} while ((s1 & 1u) || s1 != s2);
return ns + d->base;
}
The odd-value test and the equality test are not redundant. An odd value means a write is in progress right now; an unequal pair means a write completed during the read. Either way the result is discarded and retried. The loop assumes the parameters were not mid-update when it started, and the actual reader checks the odd bit before its first use rather than after the fact, so that a read begun during a write is retried as well. The window is short, since the kernel writes these fields rarely, so the loop almost always succeeds on the first attempt.
The ordering matters as much as the sequence. On a weakly ordered architecture, the loads of the parameters could be reordered after the counter read, producing a value computed with parameters that were current after the counter was sampled. The acquire fence between the sequence read and the parameter reads prevents that, and the relaxed load of the second sequence read is sufficient because it only needs to observe that nothing changed.
Why the counter is not enough on its own
Two clocks are involved, and the difference between them is the reason the mechanism needs kernel cooperation at all.
The monotonic clock counts forward and is never adjusted. It is what a program uses to measure durations. The realtime clock is the same counter plus an offset that can be changed by a privileged process, by an NTP-style correction, or by the system administrator. When the offset changes, the kernel must publish the change, and a reader in the middle of a read must not see a torn value. That is what the sequence counter is protecting.
The counter itself is not synchronised across CPUs on all hardware. On some parts it runs at a fixed rate but starts at a different value on each core, or drifts between packages. The kernel measures the offset between cores and applies a correction, and where it cannot establish the offset reliably or where the hardware declares the counter unsuitable, it selects a different clock source instead. When that happens the vDSO routine still runs, but the answer it can produce on its own is no longer the one the clock requires, so it issues a real system call instead of converting. The published parameters include a field indicating which clock source is in use precisely so the routine can make that decision.
That fallback is worth understanding because it is invisible to callers. A program calling
clock_gettime in a loop gets the fast path on one machine and a system call per iteration on another,
with no change in source code and no error to observe. The performance difference between the two is
the difference between a routine that runs in user mode and one that traps, and it appears only in
measurements.
The alternative that was removed
Before the vDSO, x86-64 kernels provided a fixed-address page called the vsyscall page, mapped at a
constant address in every process. It contained three routines, gettimeofday, time and
getcpu. With native vsyscalls enabled, a call into the page executed a syscall instruction whose
target the kernel had placed inside the page itself, so the fault did not arrive at the ordinary entry
point; the kernel distinguished it by inspecting the instruction pointer. With the emulated mode the
page contains only a trapping instruction and the kernel performs the work. The design had two
problems. The fixed
address was a known target for code-reuse attacks, and it defeated address-space randomisation for that
page. And the special handling meant a call into the page was neither a normal function call nor a
normal system call, but a third thing the kernel had to recognise by the address it came from.
Modern kernels still map the page for compatibility but typically emulate it rather than execute from it, so that a caller reaches the same result through a normal system call. The replacement, the vDSO, has neither problem: its address is randomised, and its code runs as ordinary user code with no special recognition by the kernel.
What the design costs
The mechanism’s price is paid in portability and in coupling. The vDSO’s contents are architecture specific and kernel version specific. A program that calls the routines through the C library does not notice, because the library is rebuilt against each kernel’s headers and discovers the routines at runtime. A program that links against the vDSO directly, or that reads the data page itself, is depending on a layout that the kernel does not promise to keep.
The routines are also constrained to be simple. They cannot allocate, cannot block, cannot take a lock, and cannot call anything in the kernel. Every routine must be reentrant and async-signal-safe, because it can be called from a signal handler at any point, including during another call to the same routine on the same thread. That rules out caching in static storage and rules out anything that needs to coordinate with other threads.
There is a third cost, harder to see: the vDSO makes the clock cheap, which changes what programs do. A program that can read the monotonic clock without a trap will read it per request rather than per batch, and the aggregate effect on the instruction cache and on the counter itself is not nothing. The counter read is serialising on some microarchitectures, so a tight loop of clock reads measures the clock rather than the work. The same concern applies to any interface made fast enough to be called casually.
Where the boundary is drawn
The admission rule for the vDSO is narrow and worth restating, because it explains which calls are absent. A call can be served in user space if the answer depends only on data the kernel is willing to publish, if computing it requires no privilege, and if the computation can be done without locks or allocation. That covers clocks, the current CPU identifier, and a few architecture-specific queries. It does not cover anything that touches a file, a socket, or another process.
For those, the transition remains, and the mechanisms that reduce its cost work differently: they batch operations behind a shared ring rather than removing the boundary. That approach, and the point at which it beats a plain call, is the subject of io_uring and Shared-Memory Submission Queues. The general accounting of what a trap costs, which is the baseline the vDSO is measured against, is in What a System Call Actually Costs.
References
- vdso(7) — Linux manual page — Linux man-pages project
- Linux kernel source: arch/x86/entry/vdso — Linux kernel source tree
- POSIX.1-2017, System Interfaces: clock_gettime — IEEE Std 1003.1-2017
- The Linux Programming Interface — Michael Kerrisk, No Starch Press, 2010