How the vDSO Reads Kernel Time Without Trapping
Reading the clock with
clock_gettime(CLOCK_MONOTONIC, …)looks like a system call, but on a healthy x86-64 Linux box it never enters the kernel. The trick is a kernel-maintained, read-only shared page — historicallystruct vdso_data, since Linux 6.13/6.18 split intostruct vdso_time_data— that holds the current clock state: the last hardware-counter value, the multiplier and shift that convert counter ticks to nanoseconds, and the base time at that counter value. The timekeeping subsystem updates that page fromupdate_vsyscall()whenever the clock changes; the vDSO code that libc calls reads it under a seqlock, reads the hardware Time Stamp Counter with the user-moderdtscinstruction, and computes the time with a multiply-add-shift — all in user mode, with zero ring transitions (perlib/vdso/gettimeofday.c, v6.12; vdso(7)). This note explains that mechanism — the data page, the seqlock, and the cycles-to-nanoseconds math — and why it works only for read-mostly, hardware-readable clock data and nothing else.
This note assumes you already know what the vDSO is and how it is mapped into a process; that lives in The vDSO Virtual Dynamic Shared Object. Here we trace only the time-read data path. For the cost comparison against a real trap, see Raw Syscalls vs vsyscall vs vDSO Performance.
Mental Model
Think of the kernel and userspace as a writer and a reader sharing a whiteboard. The whiteboard is one physical page that the kernel maps read-write into its own address space and read-only into every process. On it the kernel timekeeper writes a few numbers: the hardware counter value cycle_last at the last update, the wall-clock and monotonic base times at that instant, and the conversion factors mult and shift. The reader (the vDSO function inside the calling process) never asks the kernel for the time. Instead it reads the counter itself with a non-privileged instruction (rdtsc on x86-64), looks at how many ticks have elapsed since cycle_last, multiplies by mult, shifts right by shift to get nanoseconds, adds the base time, and returns. The kernel is not involved in the read at all — it only keeps the whiteboard fresh.
flowchart TB subgraph K["Kernel (ring 0) — the WRITER"] TICK["timekeeper update<br/>(tick, NTP slew, settimeofday)"] UVS["update_vsyscall(tk)<br/>kernel/time/vsyscall.c"] TICK --> UVS end UVS -->|"seq++ (odd),<br/>write cycle_last/mult/shift/basetime,<br/>seq++ (even)"| PAGE subgraph SHARED["Shared VVAR page (read-only in userspace)"] PAGE["vdso_data / vdso_time_data:<br/>seq, clock_mode,<br/>cycle_last, mult, shift,<br/>basetime[clock_id]"] end subgraph U["Userspace (ring 3) — the READER"] APP["clock_gettime() call"] VDSO["__vdso_clock_gettime<br/>(do_hres in vDSO)"] RDTSC["rdtsc instruction<br/>(reads HW counter directly)"] APP --> VDSO VDSO -->|"read seq, fields"| PAGE VDSO --> RDTSC RDTSC -->|"cycles"| CALC["ns = ((cycles - cycle_last) * mult) >> shift<br/>+ basetime; retry if seq changed"] CALC --> APP end
The vDSO time read as a single-writer/many-reader whiteboard. What it shows: the kernel writes clock state to a shared page only on timekeeper updates; the reader extrapolates the current time from that snapshot plus a self-issued rdtsc, never trapping. The insight to take: the boundary cost of a syscall is avoided by publishing the small amount of state a read needs into shared memory and letting the reader do the arithmetic — possible only because the inputs (counter, mult, shift, base) are non-secret and the counter is readable from ring 3.
What Lives on the Page
The shared structure is defined in include/vdso/datapage.h. On Linux 6.12 LTS it is struct vdso_data, and there is an array of them indexed by CS_HRES_COARSE (index 0, for high-resolution and coarse clocks) and CS_RAW (index 1, for CLOCK_MONOTONIC_RAW). The fields that matter for a read are:
u32 seq— the seqlock sequence counter. Even means “stable, safe to read”; odd means “a write is in progress, retry.”s32 clock_mode— which hardware time source backs this page:VDSO_CLOCKMODE_TSC,VDSO_CLOCKMODE_PVCLOCK(KVM paravirt clock),VDSO_CLOCKMODE_HVCLOCK(Hyper-V), orVDSO_CLOCKMODE_NONE(no vDSO-capable clocksource — the read must fall back to a real syscall).u64 cycle_last— the raw hardware-counter value captured at the last timekeeper update; the “zero point” for the elapsed-cycles delta.u32 mult/u32 shift— the multiply-then-shift constants that convert counter ticks to nanoseconds. The kernel chooses these so thatticks * mult >> shiftapproximatesticks / counter_frequency * 1e9with integer-only arithmetic.u64 mask— the clocksource bit-mask, used to wrap a counter that is narrower than 64 bits (the TSC is full 64-bit, so on x86-64 the effective mask is wider; see the failure-mode note on theS64_MAXmask below).struct vdso_timestamp basetime[VDSO_BASES]— for each acceleratedclock_id(CLOCK_REALTIME,CLOCK_MONOTONIC,CLOCK_BOOTTIME,CLOCK_TAI, plus the coarse variants), the{sec, nsec}base time corresponding tocycle_last. For high-resolution clocks thensecfield is stored left-shifted byshiftso the addition can happen before the final shift.
The kernel maps this page read-write for itself and read-only into userspace (the so-called VVAR page that accompanies the vDSO code page). Because userspace can read but never write it, a buggy or hostile process cannot corrupt the clock state — it can only read stale-but-consistent values, which the seqlock handles. The structure is declared with __attribute__((visibility("hidden"))) so the compiler emits a PC-relative reference rather than a Global Offset Table entry, which is what lets a single mapped blob with no relocations find its own data (datapage.h, v6.12).
The Writer: update_vsyscall
The kernel side is generic code in kernel/time/vsyscall.c. update_vsyscall(struct timekeeper *tk) is invoked by the timekeeping core whenever the clock state changes — on the periodic tick that accumulates time, on NTP frequency adjustments (slewing), and on settimeofday/clock_settime. (It is not called on every clock read — only on writes to the kernel’s notion of time, which is exactly why this scheme is worth it: writes are rare, reads are constant.)
The body is a textbook seqlock write:
void update_vsyscall(struct timekeeper *tk)
{
struct vdso_data *vdata = __arch_get_k_vdso_data();
/* ... */
vdso_write_begin(vdata); /* seq -> odd: "update in progress" */
clock_mode = tk->tkr_mono.clock->vdso_clock_mode;
vdata[CS_HRES_COARSE].clock_mode = clock_mode;
vdata[CS_RAW].clock_mode = clock_mode;
/* CLOCK_REALTIME basetime, also used by time() */
vdso_ts = &vdata[CS_HRES_COARSE].basetime[CLOCK_REALTIME];
vdso_ts->sec = tk->xtime_sec;
vdso_ts->nsec = tk->tkr_mono.xtime_nsec;
/* ... coarse + monotonic + boottime + tai filled similarly ... */
if (clock_mode != VDSO_CLOCKMODE_NONE)
update_vdso_data(vdata, tk); /* copies cycle_last, mult, shift, mask */
vdso_write_end(vdata); /* seq -> even: "stable" */
__arch_sync_vdso_data(vdata);
}vdso_write_begin increments seq (making it odd) and issues a write memory barrier; vdso_write_end issues another barrier and increments seq again (back to even). Crucially, if clock_mode == VDSO_CLOCKMODE_NONE — meaning the active clocksource (e.g. HPET, the ACPI power-management timer) is not readable from userspace — the kernel skips copying cycle_last/mult/shift entirely. The page still carries valid coarse base times, but the high-resolution path is disabled, and readers will fall back to a real syscall (see Failure Modes).
The helper update_vdso_data copies the conversion parameters straight out of the timekeeper’s read-base:
vdata[CS_HRES_COARSE].cycle_last = tk->tkr_mono.cycle_last;
vdata[CS_HRES_COARSE].mask = tk->tkr_mono.mask;
vdata[CS_HRES_COARSE].mult = tk->tkr_mono.mult;
vdata[CS_HRES_COARSE].shift = tk->tkr_mono.shift;This is the same mult/shift the kernel itself uses internally, which is why the vDSO and an in-kernel clock_gettime return bit-identical results.
The Reader: the Seqlock Loop in do_hres
The user-mode read is the generic do_hres() in lib/vdso/gettimeofday.c. __vdso_clock_gettime (the symbol libc jumps to) routes a high-resolution clock here:
static __always_inline int do_hres(const struct vdso_data *vd, clockid_t clk,
struct __kernel_timespec *ts)
{
const struct vdso_timestamp *vdso_ts = &vd->basetime[clk];
u64 cycles, sec, ns;
u32 seq;
do {
while (unlikely((seq = READ_ONCE(vd->seq)) & 1)) {
/* odd seq: writer in progress (or time-namespace page) */
if (IS_ENABLED(CONFIG_TIME_NS) &&
vd->clock_mode == VDSO_CLOCKMODE_TIMENS)
return do_hres_timens(vd, clk, ts);
cpu_relax();
}
smp_rmb();
if (unlikely(!vdso_clocksource_ok(vd)))
return -1; /* -> syscall fallback */
cycles = __arch_get_hw_counter(vd->clock_mode, vd); /* rdtsc */
if (unlikely(!vdso_cycles_ok(cycles)))
return -1; /* -> syscall fallback */
ns = vdso_calc_ns(vd, cycles, vdso_ts->nsec);
sec = vdso_ts->sec;
} while (unlikely(vdso_read_retry(vd, seq)));
ts->tv_sec = sec + __iter_div_u64_rem(ns, NSEC_PER_SEC, &ns);
ts->tv_nsec = ns;
return 0;
}Walking it symbol by symbol:
READ_ONCE(vd->seq) & 1— read the sequence counter. If it is odd, a writer holds the “lock,” so spin withcpu_relax()(apauseinstruction) until it goes even. This is the entire seqlock read-side: readers never block a writer and never take a lock; they optimistically read and retry only if the snapshot was torn.smp_rmb()— a read barrier so the loads ofcycle_last/basetimecannot be hoisted above theseqcheck.vdso_clocksource_ok(vd)returns false whenclock_mode == VDSO_CLOCKMODE_NONE; returning-1signals libc to issue the real syscall.__arch_get_hw_counter(vd->clock_mode, vd)is the architecture hook. On x86-64 (arch/x86/include/asm/vdso/gettimeofday.h) the fast path is literallyreturn (u64)rdtsc_ordered() & S64_MAX;— a userspacerdtscinstruction. For paravirtualized guests it instead reads the KVMpvclockor Hyper-V TSC page.vdso_calc_ns(vd, cycles, base)is the cycles-to-nanoseconds math (next section).vdso_read_retry(vd, seq)re-readsseq(with a barrier) and returns true if it changed since the start of the loop. If a writer ran while we were reading, the whole loop repeats with a fresh snapshot — guaranteeing the returned{sec, nsec}came from one consistent version of the page.
The corresponding seqlock helpers are tiny (include/vdso/helpers.h): vdso_read_begin spins while seq is odd then smp_rmb(); vdso_read_retry does smp_rmb() then compares seq.
The Math: cycles → nanoseconds
The core conversion is vdso_calc_ns (generic version):
static __always_inline u64 vdso_calc_ns(const struct vdso_data *vd, u64 cycles, u64 base)
{
u64 delta = (cycles - vd->cycle_last) & VDSO_DELTA_MASK(vd);
if (likely(vdso_delta_ok(vd, delta)))
return vdso_shift_ns((delta * vd->mult) + base, vd->shift);
return mul_u64_u32_add_u64_shr(delta, vd->mult, base, vd->shift);
}Symbol by symbol:
delta = cycles - vd->cycle_last— how many hardware counter ticks have elapsed since the kernel last updated the page. Because both are read close together and the counter is monotonic, this is a small number.delta * vd->mult— scale ticks into a fixed-point nanosecond count.multis chosen so thatmult / 2^shift ≈ nanoseconds-per-tick. Using a multiply plus a right-shift instead of a division is the whole reason this is cheap: integer multiply-and-shift, no divide.+ base— add the basetime’s nanosecond field (which the writer pre-shifted left byshift), keeping everything in the same fixed-point domain before the final shift.>> vd->shift(vdso_shift_ns) — shift right to land back in plain nanoseconds.
So the full expression is ns = ((cycles - cycle_last) * mult + base_nsec_shifted) >> shift, then tv_sec/tv_nsec are split out with __iter_div_u64_rem. The slow branch (mul_u64_u32_add_u64_shr) handles the case where delta * mult would overflow 64 bits — guarded by vdso_delta_ok, which on kernels built with CONFIG_GENERIC_VDSO_OVERFLOW_PROTECT checks delta < max_cycles.
Why This Works Only for Read-Mostly Clock Data
The whole scheme rests on three properties that clock data happens to have and most kernel state does not:
- The data is non-secret and safe to expose read-only. Time, the conversion factors, and the counter base reveal nothing privileged, so mapping them read-only into every process leaks nothing. Contrast a syscall like
open(), whose work (permission checks, allocating a file descriptor, touching the VFS) inherently mutates protected kernel state — there is nothing to “publish” to userspace. The vDSO can only serve calls that are pure reads of shareable state. - Writes are rare; reads are constant. The seqlock is a win precisely because
update_vsyscallfires only on timekeeper updates (ticks, NTP, settime) while reads happen millions of times a second. A seqlock makes readers cheap at the cost of making them retry during a write; if writes were frequent, readers would spin and the advantage would vanish. - The hardware counter is readable from ring 3.
rdtscexecutes in user mode, so the reader can sample the current counter itself and extrapolate. If the only way to read the counter required a privileged instruction or an MMIO access to a device register the kernel guards, there would be no way to do the read without trapping — which is exactly the situation for non-TSC clocksources.
When any of these breaks, the trick is abandoned and a real syscall is used. The cleanest example is property (3): on a machine whose clocksource is HPET or the ACPI PM timer (common when the TSC is deemed unstable), the kernel sets clock_mode = VDSO_CLOCKMODE_NONE, do_hres returns -1, and libc falls through to the clock_gettime syscall. That fallback is the proof of the rule — the vDSO accelerates clock reads only when the clock is hardware-readable, shareable, and read-mostly.
Failure Modes and Subtleties
- Non-vDSO clocksource → silent syscall fallback. If
/sys/devices/system/clocksource/clocksource0/current_clocksourcereadshpetoracpi_pmrather thantsc,clock_gettimequietly becomes a real trap and gets ~30–100× slower. The program still works; it just loses the fast path. Diagnose by checking the current clocksource and bystrace-ing — a vDSO read shows no syscall, a fallback shows aclock_gettimeentry. See Raw Syscalls vs vsyscall vs vDSO Performance. - The x86
S64_MAXmask and unstable-TSC guard. On x86-64,__arch_get_hw_countermasks the TSC withS64_MAXandarch_vdso_cycles_okrejects any value with the sign bit set. This lets paravirt/Hyper-V clocks signal “invalidated” by returningU64_MAX(which becomes negative after the mask), forcing a retry or fallback. The x86vdso_calc_nsalso clampscyclesto be>= cycle_lastbecause the TSC can be slightly skewed across sockets (arch/x86/.../vdso/gettimeofday.h). - Time namespaces. Inside a container time namespace, the kernel installs a different VVAR page with
seq = 1andclock_mode = VDSO_CLOCKMODE_TIMENS, forcingdo_hresdowndo_hres_timens, which fetches the real host page and adds a per-clock offset. This is why an oddseqin the read loop is also the trigger for the time-namespace slow path, not only a writer-in-progress.
Linux 6.18: the vdso_data → vdso_time_data Refactor
The code walk above is anchored at Linux 6.12 LTS, where the page is a single struct vdso_data array. As of Linux 6.18 LTS (released 2025-11-30) the data layout was refactored, though the mechanism is unchanged: struct vdso_data was split into an outer struct vdso_time_data (the page itself — holding tz_*, hrtimer_res, and an array of clocks) and an inner struct vdso_clock that now carries the per-clocksource seq, clock_mode, cycle_last, mult, shift, and basetime[]. update_vsyscall now operates on struct vdso_clock *vc = vdata->clock_data; and the global pointer is vdso_k_time_data rather than the old __arch_get_k_vdso_data() accessor (datapage.h, v6.18; vsyscall.c, v6.18). The seqlock discipline, the rdtsc, and the multiply-add-shift are identical; only the struct names and nesting changed. The vdso_time_data name in this note’s aliases refers to this post-refactor naming.
Uncertain
Verify: the exact kernel release that renamed
struct vdso_datatostruct vdso_time_data/struct vdso_clock. The split is present at the v6.18 tag and absent at v6.12; the intermediate point (likely 6.13/6.14) was not bisected here. Reason: only the two LTS tags were fetched. To resolve:git log --oneline -- include/vdso/datapage.haround 6.12..6.18 and find the rename commit. uncertain
See Also
- The vDSO Virtual Dynamic Shared Object — what the vDSO is, how the ELF blob and VVAR page are mapped into every process, and the full symbol list
- The vsyscall Legacy Mechanism — the obsolete fixed-address predecessor this scheme replaced
- Raw Syscalls vs vsyscall vs vDSO Performance — the measured cost difference between a real trap and this no-trap read
- vDSO Symbols and How libc Uses Them — how glibc/musl find and call
__vdso_clock_gettime - time.Time Comparison and the Monotonic Clock — Go’s monotonic-clock handling, a userspace consumer of
CLOCK_MONOTONIC(contrast: language-runtime semantics, not the kernel read path) - Linux System Call Interface MOC — parent map; this is §5, “Syscalls Without a Trap”
- Linux Time and Timers MOC — where the clocksource,
mult/shift, and timekeeper that feed this page are maintained