The vDSO Virtual Dynamic Shared Object
The vDSO is a shared library the kernel maps into every process so that some “system calls” are just function calls. The virtual Dynamic Shared Object (vDSO) is a genuine, fully formed ELF shared object — symbol table,
PT_DYNAMIC, GNU symbol versioning and all — that the kernel links at kernel build time, embeds in its own image, and hands to every process atexecve(2), advertising its base address through the auxiliary-vector entryAT_SYSINFO_EHDR(vdso(7)). Alongside it the kernel maps read-only data pages that it keeps fresh — the current clock state, the per-CPU id, the random-number generation counter — so that a handful of calls can be answered entirely in user mode, with no privilege transition at all. On the machine this note was written on, that turnsclock_gettime(CLOCK_MONOTONIC)from 60.5 ns into 17.3 ns, a measured 3.5× — andCLOCK_REALTIME_COARSEinto 2.5 ns. But the saving is conditional, and that is the part most descriptions omit: the vDSO contains realsyscallinstructions and falls back to them whenever the active clocksource is not one userspace can read (TSC, or a paravirtualised clock). The fast path is a property of the machine’s timekeeping configuration, decided inside the vDSO, after the call, invisibly to libc, tostrace, and to the symbol table.
This note covers what the object is, how the kernel produces and maps it, what it exports, how glibc binds it, and — the part that makes the rest matter — whether calling it actually avoids the kernel. Its companion vDSO Symbols and How libc Uses Them covers the userspace lookup mechanics in full, and How the vDSO Reads Kernel Time Without Trapping covers the lock-free read that makes the time answers correct once the fast path is taken.
Split authority, and the boundary with its near-title sibling
This topic has two authorities, and every claim below says which one governs it.
- The kernel builds and maps the vDSO. Anything about the object’s layout, its exported symbol set, the linker script, the special mappings,
AT_SYSINFO_EHDR, and the internal fast-path/fallback decision is Linux kernel territory. Source claims are pinned to the v6.12 LTS tree (the vault’s kernel pin) and cross-checked against v7.1, which is what this host actually runs — the two differ in ways this note records rather than hides. - glibc consumes it. Anything about how a program finds the symbols, which of them libc actually binds, and what happens on the libc side when the vDSO is absent is glibc 2.43 territory (read at
refs/tags/glibc-2.43onsourceware.org; thebminor/glibcGitHub mirror has no 2.43 tag). - Measured claims come from this host: Fedora 44, Linux 7.1.13-200.fc44.x86_64, glibc 2.43, GCC 16.2.1, AMD Ryzen AI MAX+ 395,
current_clocksource = tsc.
Boundary with How the vDSO Reads Kernel Time Without Trapping
These two notes have near-identical titles and are deliberately distinct. This note answers “what is the vDSO, how does it get into my process, what does it export, and does calling it actually avoid a trap?” — the object, the mapping, the symbol set, the dispatch decision, and the measured cost. That note answers “once inside
__vdso_clock_gettime, how is the answer computed correctly?” — thevdso_time_datapage’s field layout, the seqlock protocol (retry on an odd sequence), themult/shiftcycles-to-nanoseconds arithmetic, and the writer side inupdate_vsyscall(). The seam is thecmp $0x1, %eaxinstruction shown below: this note explains why that compare exists and what happens on each branch; that note explains the arithmetic that follows when it succeeds. The seqlock is deliberately not re-derived here — only referenced. For the cost model of the trap the vDSO avoids, see Why System Calls Are Expensive, which owns it; for the head-to-head of all three mechanisms see Raw Syscalls vs vsyscall vs vDSO Performance.
Mental Model
The right way to think about the vDSO is as a kernel-authored .so file that never touches the disk. When you build the kernel, the vDSO source (arch/x86/entry/vdso/) is compiled and linked into a tiny standalone ELF shared object, which is then embedded as a blob inside the kernel image. At process startup the kernel maps that blob, read-only and executable, into the new process — exactly as the dynamic linker would map libc.so.6, except the kernel does it and the file has no on-disk existence. Userspace sees it in /proc/self/maps as the pseudo-region [vdso]. Because it is a real ELF DSO with a symbol table and GNU symbol versioning, userspace can do ordinary symbol lookups on it; because its code only reads data the kernel publishes into an adjacent shared page (the [vvar] region), those lookups resolve to functions that run with no privilege transition.
flowchart LR subgraph KERN["Kernel image (built once, at kernel build time)"] BLOB["struct vdso_image vdso_image_64<br/>= the linked ELF blob, embedded<br/>in the kernel .data"] LIVE["live kernel timekeeping state:<br/>vdso_time_data / vdso_rng_data"] end subgraph PROC["Process address space — placed per execve, ASLR-randomized"] VVAR["[vvar] VMA<br/>VM_READ VM_IO VM_PFNMAP<br/>VM_DONTDUMP"] VCLK["[vvar_vclock] VMA<br/>pvclock / hvclock pages<br/>(SPLIT OUT IN v6.13)"] VDSO["[vdso] VMA<br/>single PT_LOAD, VM_READ VM_EXEC<br/>(+VM_MAYWRITE so gdb can COW)"] end AUXV["auxv entry<br/>AT_SYSINFO_EHDR = base of [vdso]"] BLOB -->|"vdso_fault(): return image->data page"| VDSO LIVE -->|"vvar_fault(): insert the live PFN"| VVAR LIVE -->|"vvar_vclock_fault()"| VCLK VVAR -.->|"vDSO code reads directly,<br/>no trap"| VDSO VDSO -.->|"ARCH_DLINFO emits<br/>mm->context.vdso"| AUXV AUXV -.->|"getauxval(AT_SYSINFO_EHDR)"| LIBC["glibc setup_vdso_pointers()<br/>binds 6 function pointers"] LIBC -.->|"calls through the pointer"| VDSO
How the kernel turns a build-time ELF blob into a per-process mapping, and how glibc reaches it. What it shows: one compiled vdso_image_64 blob backs the executable [vdso] region in every process, while the live kernel timekeeping state is faulted in behind one or two adjacent read-only data regions; the [vdso] base is advertised through AT_SYSINFO_EHDR, which glibc reads once at startup to bind six function pointers. The insight to take: the vDSO is code the kernel publishes alongside data it keeps fresh. The process never asks for either — execve pre-positions everything the call will need, so the call itself is an ordinary call through a function pointer.
Version drift: two mappings or three?
Descriptions of the vDSO — including this note’s previous revision, and the v6.12 source it was based on — say the kernel installs two special mappings,
[vdso]and[vvar]. That is correct for v6.12 and earlier. On this host (Linux 7.1.13) there are three:$ grep -E 'vdso|vvar' /proc/self/maps 7fc9c2a68000-7fc9c2a6c000 r--p 00000000 00:00 0 [vvar] 7fc9c2a6c000-7fc9c2a6e000 r--p 00000000 00:00 0 [vvar_vclock] 7fc9c2a6e000-7fc9c2a70000 r-xp 00000000 00:00 0 [vdso]The paravirtualised-clock pages (KVM
pvclock, Hyper-Vhvclock) were split out of[vvar]into their own[vvar_vclock]mapping with its ownvvar_vclock_fault()handler. Existence-checked by greppingarch/x86/entry/vdso/vma.cat successive tags: 0 occurrences ofvvar_vclockat v6.12, 6 occurrences at v6.13 and at every tag since through v7.1. So the boundary is exactly v6.13. Code that hard-codes “vvar is the page right before vdso” still works —[vvar_vclock]sits between them, so the adjacency assumption breaks even though the ordering does not.
Why the vDSO Exists: the Cost of a Trap
A system call is a controlled trap across the user/kernel privilege boundary (see The Syscall Instruction and Trap Mechanism and Why System Calls Are Expensive). Even the cheapest syscall pays for the syscall instruction’s pipeline serialization, the swap to the kernel stack, saving the user register set into a [[The pt_regs Register Frame|pt_regs]] frame, the generic-entry bookkeeping (The Generic Syscall Entry and Exit Layer), and — since Meltdown/Spectre — page-table isolation switches and speculation barriers on the way in and out. For a syscall that does real work (open a file, send a packet) this overhead is amortized. But for a hot, side-effect-free, read-only call like gettimeofday(2) or clock_gettime(2) — which a latency-tracing or benchmarking program may invoke millions of times per second — the trap is essentially the entire cost.
The vDSO eliminates that cost for exactly the calls where it dominates. The man page states the motivation plainly: such calls “can dominate overall performance … due both to the frequency of the call as well as the context-switch overhead” (vdso(7)). The kernel arranges for the information these calls need — chiefly the current clock state — to live in a page the process can read directly, and provides userspace functions that read it, so the “syscall” becomes an ordinary function call plus a few memory loads.
The most-cited number for this is wrong in its units, and is not "an order of magnitude"
The figure quoted throughout the secondary literature comes from a 2014 guest article on LWN (Implementing virtual system calls, fetched 2026-09-12, HTTP 200, 32,141 B, real article body). Its exact wording is: “On a P6T SE ASUS motherboard with an Intel 2.8GHz Core i7 CPU, the average time required to execute a standard
gettimeofday()system call is 90.5 microseconds, the average time for the corresponding virtual system call is 22.3 microseconds.” Two problems. The units are off by a thousand — a commenter on the article says so directly (“Microseconds forgettimeofdayis way off. Nanoseconds is correct timescale… on my box I recall seeing thatgettimeofdaytakes around 6 nanoseconds”), and 90 µs for a null syscall is absurd on 2014 hardware by two orders of magnitude. And the ratio is not an order of magnitude: 90.5 / 22.3 = 4.06×. The “roughly 10×” claim is a distortion introduced downstream, including in this note’s own previous revision. The interesting part is that the corrected ratio holds up. The measurement below on 2026 hardware gives 3.5× — the same shape, twelve years and one Meltdown mitigation later. Cite the ratio, not the absolute numbers, and never the units.
Measuring it: what the trap actually costs here
With the 2014 figure corrected to a ratio of about 4×, here is a number from the machine in front of you. The benchmark calls each variant three million times in a tight loop pinned to CPU 2, brackets the loop with clock_gettime(CLOCK_MONOTONIC) for wall time and rdtscp for TSC ticks, and additionally reads a perf_event_open hardware cycle counter. Four ways of reaching the same clock:
clock_gettime(CLOCK_MONOTONIC, &ts)through glibc — glibc routes this through the vDSO pointer.__vdso_clock_gettimecalled directly, by parsingAT_SYSINFO_EHDRand resolving the symbol from the vDSO’sDT_HASHtable, bypassing glibc entirely.syscall(__NR_clock_gettime, CLOCK_MONOTONIC, &ts)— forces a real trap, becausesyscall(2)emits thesyscallinstruction itself and never consults the vDSO.syscall(__NR_getpid)— a null syscall, the floor for “what does a trap cost with no work attached.”
Median of eight runs:
| What is being called | ns/op | TSC ticks/op | true core cycles/op | vs. vDSO |
|---|---|---|---|---|
| empty loop (measurement floor) | 0.80 | 2.4 | 4.0 | — |
clock_gettime via glibc → vDSO | 17.3 | 51.9 | ≈ 86 | 1.0× |
__vdso_clock_gettime called directly | 17.1 | 51.4 | ≈ 85 | 0.99× |
clock_gettime forced through syscall(2) | 60.5 | 181.2 | ≈ 299 | 3.5× |
getpid() through syscall(2) (null syscall) | 36.9 | 110.4 | ≈ 183 | 2.1× |
clock_gettime(CLOCK_REALTIME_COARSE) via vDSO | 2.5 | 7.5 | ≈ 12 | 0.15× |
Four things fall out of that table.
The vDSO saves about 43 ns per call — roughly 3.5×, not 10×. The often-quoted “order of magnitude” was measured on pre-Meltdown hardware against slower clocks; on this machine the honest ratio is between three and four. That is still enormous for code that reads the clock in a hot loop: a tracer taking two timestamps per event at ten million events per second pays 0.35 s/s of CPU through the vDSO and 1.2 s/s through the syscall — i.e. the syscall version cannot keep up on one core at all.
Going through glibc costs essentially nothing. 17.3 ns via clock_gettime() versus 17.1 ns calling __vdso_clock_gettime directly — about 0.2 ns, well inside run-to-run noise. glibc’s wrapper is a null check and an indirect call (clock_gettime.c, glibc 2.43). There is no reason to hand-resolve vDSO symbols for speed; do it only when you have no libc.
A null syscall costs 36.9 ns, so clock_gettime’s syscall path costs 24 ns above the trap. The trap is the larger half but not the whole story — the kernel-side clock_gettime still has to take the seqlock, read the TSC and do the arithmetic, which is the same work the vDSO does in userspace. The vDSO removes the trap; it does not make the arithmetic free. The trap cost itself is decomposed in Why System Calls Are Expensive, which owns that model and has the measured per-syscall table — it is not re-derived here.
The coarse clocks are another 7× cheaper again, at 2.5 ns. CLOCK_REALTIME_COARSE does not read the hardware counter at all; it copies basetime[clock] straight off the shared page, giving tick-granularity time — measured with clock_getres as exactly 1,000,000 ns, this kernel’s CONFIG_HZ=1000 tick — for about twelve cycles. If millisecond resolution is enough, this is the cheapest clock Linux offers and it is a one-word change.
Uncertain — calling TSC ticks "cycles" is wrong on this machine, and probably on yours
The three columns above are not interchangeable, and conflating them is the most common way vDSO benchmarks get misreported.
- TSC ticks come from
rdtscp. On any CPU withconstant_tsc/nonstop_tsc(both present in/proc/cpuinfohere) the TSC is an invariant, fixed-frequency time base, not a core-clock counter. Calibrated againstCLOCK_MONOTONICover 0.5 s it came out at 2.9943 GHz on every one of eight runs.- Core cycles come from
perf_event_open(PERF_COUNT_HW_CPU_CYCLES). During the vDSO loop the effective core frequency measured 4.92–5.10 GHz — the core boosts, the TSC does not.- Therefore reporting TSC ticks as “cycles” under-reports by about 39.5 % (
1 − 2.9943 / 4.95). The vDSO call is 52 TSC ticks but ≈86 real cycles.A second trap, specific to an unprivileged benchmark:
/proc/sys/kernel/perf_event_paranoidis 2 on this host, which makesperf_event_openwithexclude_kernel = 0fail withEACCES, so the counter silently measures user-mode cycles only. That is why the raw-syscall rows’ directly-measured counter reads an absurd ≈37–52 cycles for a 60 ns call: the ~250 cycles spent in ring 0 are invisible to it. The “true core cycles” column above is therefore computed asns × 4.95 GHz, validated by the fact that the method reproduces the directly measured counter on the vDSO rows (17.3 ns × 4.95 = 85.6 vs. 86 measured), where all cycles are user-mode. To resolve properly: setperf_event_paranoidto-1(or run withCAP_PERFMON) and re-measure withexclude_kernel = 0. uncertain
The fact that makes this note matter: the vDSO can trap
The single most consequential misunderstanding about the vDSO is that resolving a __vdso_* symbol guarantees you will not enter the kernel. It does not. The vDSO contains real syscall instructions, and whether it uses them depends on the system’s clocksource — a runtime property of the machine, not of the binary, not of the kernel config, and invisible from the symbol table.
The gate is a single field, clock_mode, published by the kernel on the shared data page. Its values on x86-64 come from two enums composed together:
/* include/vdso/clocksource.h, v6.12 */
enum vdso_clock_mode {
VDSO_CLOCKMODE_NONE, /* = 0 */
VDSO_ARCH_CLOCKMODES, /* x86: TSC = 1, PVCLOCK = 2, HVCLOCK = 3 */
VDSO_CLOCKMODE_MAX,
VDSO_CLOCKMODE_TIMENS = INT_MAX /* = 0x7fffffff */
};
/* arch/x86/include/asm/vdso/clocksource.h, v6.12 */
#define VDSO_ARCH_CLOCKMODES \
VDSO_CLOCKMODE_TSC, \
VDSO_CLOCKMODE_PVCLOCK, \
VDSO_CLOCKMODE_HVCLOCKand the generic reader checks it before touching the hardware (lib/vdso/gettimeofday.c, v6.12):
static inline bool vdso_clocksource_ok(const struct vdso_data *vd)
{
return vd->clock_mode != VDSO_CLOCKMODE_NONE;
}
/* … inside do_hres(), after the seqlock read: */
if (unlikely(!vdso_clocksource_ok(vd)))
return -1; /* → caller issues a real syscall */and the caller turns that -1 into a trap:
static __maybe_unused int
__cvdso_clock_gettime_data(const struct vdso_data *vd, clockid_t clock,
struct __kernel_timespec *ts)
{
int ret = __cvdso_clock_gettime_common(vd, clock, ts);
if (unlikely(ret))
return clock_gettime_fallback(clock, ts); /* the `syscall` instruction */
return 0;
}A clocksource sets clock_mode = VDSO_CLOCKMODE_NONE unless it is explicitly vDSO-capable. On x86-64 that means the Time Stamp Counter (and, in guests, the KVM or Hyper-V paravirtualised clocks). The two other clocksources this machine offers — HPET (High Precision Event Timer) and acpi_pm (the ACPI Power Management timer) — are memory-mapped or port-I/O devices that userspace cannot read, so there is nothing to publish and the fast path is off. Every clock_gettime then costs a full trap, and nothing in the process’s view changes: the symbol is still there, the call still returns the right answer, it is simply 3.5× slower.
This is not theory. It is visible as machine code. Dumping the live vDSO out of this process’s address space and disassembling __vdso_clock_gettime shows the gate as a literal compare, and the fallback as a literal syscall:
98f: cmp $0x17,%edi # clock id >= MAX_CLOCKS(24)? → a54
992: ja a54
998: mov $0x1,%eax
99d: mov %edi,%ecx
9a6: shl %cl,%eax # msk = 1U << clock
9a8: test $0x883,%eax # msk & VDSO_HRES ?
9ad: je a7d # no → try COARSE / RAW / AUX
9b3: mov (%r8),%r10d # seq ─┐ seqlock read — see the sibling note
9b6: test $0x1,%r10b # │
9ba: jne a68 # odd → ─┘ retry / timens path
9c0: mov 0x4(%r8),%eax # clock_mode
9c4: cmp $0x1,%eax # == VDSO_CLOCKMODE_TSC ?
9c7: jne a42 # no → a42
9c9: rdtscp # YES: read the counter in ring 3, no trap
...
a42: cmp $0x2,%eax # PVCLOCK?
a45: je be2
a4b: cmp $0x3,%eax # HVCLOCK?
a4e: je b7b
a54: movslq %edi,%rdi # ── THE FALLBACK ──
a57: mov $0xe4,%eax # __NR_clock_gettime = 228
a5c: syscall # a genuine ring transition, inside the vDSO
and on the coarse/raw branch, the VDSO_CLOCKMODE_NONE check is even more explicit:
abf: mov 0x4(%r11),%eax # clock_mode
ac3: test %eax,%eax # == VDSO_CLOCKMODE_NONE (0) ?
ac5: je a54 # → jump straight to the syscall
Two of those immediates are worth pausing on, because they are the kernel’s own constants surviving all the way into shipped machine code. test $0x883 is msk & VDSO_HRES, and VDSO_HRES is defined as BIT(CLOCK_REALTIME) | BIT(CLOCK_MONOTONIC) | BIT(CLOCK_BOOTTIME) | BIT(CLOCK_TAI) = 1 | 2 | 128 | 2048 = 0x883. A few instructions later and $0x60 is msk & VDSO_COARSE = BIT(CLOCK_REALTIME_COARSE) | BIT(CLOCK_MONOTONIC_COARSE) = 32 | 64 = 0x60. Reading the source and reading the disassembly give the same numbers.
flowchart TD CALL["your code calls<br/>clock_gettime(clk, &ts)"] --> GLIBC{"glibc: is<br/>GLRO(dl_vdso_clock_gettime64)<br/>non-NULL?"} GLIBC -->|"NULL — booted vdso=0,<br/>or symbol absent"| SYS1["INTERNAL_SYSCALL_CALL<br/>real trap"] GLIBC -->|"bound at startup"| VD["call __vdso_clock_gettime"] VD --> IDCHK{"clk < MAX_CLOCKS?<br/>(cmp $0x17)"} IDCHK -->|"no — e.g. a PROCESS_CPUTIME<br/>or pidfd clock"| SYSC["syscall 228<br/>inside the vDSO"] IDCHK -->|"yes"| MASK{"which mask does<br/>1<<clk fall in?"} MASK -->|"none of them —<br/>e.g. CPUTIME clocks"| SYSC MASK -->|"VDSO_COARSE 0x60"| COARSE["do_coarse: copy basetime<br/>off the page, no rdtsc<br/>≈2.5 ns"] MASK -->|"VDSO_HRES 0x883<br/>or VDSO_RAW 0x10"| SEQ["seqlock read of the<br/>shared data page<br/>(see the sibling note)"] SEQ --> MODE{"clock_mode?"} MODE -->|"NONE (0) —<br/>clocksource is hpet<br/>or acpi_pm"| SYSC MODE -->|"TSC (1)"| RDTSC["rdtscp, then<br/>(delta*mult)>>shift + base<br/>≈17 ns"] MODE -->|"PVCLOCK (2) /<br/>HVCLOCK (3)"| PV["read the guest<br/>pvclock/hvclock page<br/>in [vvar_vclock]"] MODE -->|"TIMENS (0x7fffffff)"| TNS["re-read from the<br/>time-namespace page,<br/>apply the offset"] SYSC --> RET["return to caller —<br/>SAME result, 3.5x the cost"] COARSE --> RET RDTSC --> RET PV --> RET TNS --> RET style SYSC fill:#ffd9d9,stroke:#c33 style SYS1 fill:#ffd9d9,stroke:#c33 style RDTSC fill:#d9f0d9,stroke:#3a3 style COARSE fill:#d9f0d9,stroke:#3a3
Every path from clock_gettime() to either a userspace return or a real syscall instruction, per lib/vdso/gettimeofday.c v6.12 and confirmed against the disassembly of this host’s live vDSO. What it shows: there are four independent reasons a “vDSO call” ends in a trap (red) — the vDSO was never mapped, the clock id is out of range, the clock id is in none of the three masks, or the active clocksource is not vDSO-capable — and only the last of those depends on machine configuration rather than on what you asked for. The insight to take: the decision is made inside the vDSO, after the call, invisibly. Neither the symbol table nor glibc nor strace can tell you which branch you took; the only way to know is to time it.
Which clocks actually get the fast path
clock_gettime accepts more clock ids than the vDSO serves. The three masks in include/vdso/datapage.h are the complete list, and everything outside them traps. Timings are from this host, ./clocks, one million iterations per row, glibc path versus the same id forced through syscall(2):
| Clock id | Value | Mask it falls in | glibc ns/op | forced syscall ns/op | verdict |
|---|---|---|---|---|---|
CLOCK_REALTIME | 0 | VDSO_HRES | 18.1 | 65.0 | vDSO fast path, rdtscp |
CLOCK_MONOTONIC | 1 | VDSO_HRES | 17.4 | 59.5 | vDSO fast path, rdtscp |
CLOCK_PROCESS_CPUTIME_ID | 2 | none | 101.6 | 90.2 | always traps |
CLOCK_THREAD_CPUTIME_ID | 3 | none | 85.0 | 90.8 | always traps |
CLOCK_MONOTONIC_RAW | 4 | VDSO_RAW | 17.7 | 59.1 | vDSO fast path, separate CS_RAW base |
CLOCK_REALTIME_COARSE | 5 | VDSO_COARSE | 2.5 | 46.1 | vDSO, no rdtsc at all |
CLOCK_MONOTONIC_COARSE | 6 | VDSO_COARSE | 4.0 | 49.5 | vDSO, no rdtsc |
CLOCK_BOOTTIME | 7 | VDSO_HRES | 17.9 | 73.4 | vDSO fast path |
CLOCK_TAI | 11 | VDSO_HRES | 17.7 | 65.9 | vDSO fast path |
CLOCK_AUX … CLOCK_AUX_LAST | 16–23 | VDSO_AUX | not tested | — | v6.17+ only, see below |
any id ≥ 24 (MAX_CLOCKS) | — | rejected by cmp $0x17 | — | — | always traps |
Measured vDSO coverage of the CLOCK_* ids. What it shows: the seven clocks in VDSO_HRES | VDSO_RAW | VDSO_COARSE are served in userspace; the two CPU-time clocks are not, and are the only ids here where going through glibc is slower than the raw syscall. The insight to take: CLOCK_PROCESS_CPUTIME_ID at 101.6 ns is not an anomaly — per-process CPU time cannot be published to a shared page because it must be summed across every thread under a lock, so there is real kernel work that no amount of shared memory removes. The vDSO only ever helps where the answer is a pure function of published state plus a user-readable counter. (Its glibc path being slower than the direct syscall is the vDSO call being attempted, returning -1, and then trapping — you pay the failed attempt.)
One drift note, established by existence-checking rather than assumed. VDSO_AUX does not exist at v6.12. Grepping include/vdso/datapage.h at successive tags gives 0 hits for VDSO_AUX/CLOCK_AUX at v6.12 and v6.16, and 1 hit from v6.17 onward, where it is defined as __GENMASK(CLOCK_AUX_LAST, CLOCK_AUX) = ids 16–23 = mask 0xff0000. That immediate appears in this host’s disassembly (test $0xff0000,%eax) precisely because it runs 7.1.13, and it would be absent from a 6.12 vDSO. This is the clearest single example of why the note pins its source claims: the same function, compiled from the same file name, has a different branch structure two LTS cycles apart.
What the Object Actually Is: a Prelinked ELF DSO
The vDSO is a real ELF shared object, prelinked to a fixed virtual address with a single read-only PT_LOAD segment. Its layout is dictated by the linker script arch/x86/entry/vdso/vdso-layout.lds.S, which lays out the standard DSO machinery — .hash, .gnu.hash, .dynsym (dynamic symbol table), .dynstr (symbol strings), .gnu.version/.gnu.version_d (GNU symbol versioning), .dynamic (the PT_DYNAMIC table the loader walks), .rodata, .text, and an .eh_frame for unwinding — and explicitly forces a single PT_LOAD with read-execute flags:
PHDRS
{
text PT_LOAD FLAGS(5) FILEHDR PHDRS; /* PF_R|PF_X */
dynamic PT_DYNAMIC FLAGS(4); /* PF_R */
note PT_NOTE FLAGS(4); /* PF_R */
eh_frame_hdr PT_GNU_EH_FRAME;
}
FLAGS(5) is PF_R | PF_X (4 | 1) — readable and executable, not writable. Because it is a normal DSO, the same parsing logic that works for any shared library works for the vDSO; the kernel’s own reference parser, tools/testing/selftests/vDSO/parse_vdso.c, opens with the comment “The vDSO is a regular ELF DSO that the kernel maps into user space when it starts a program. It works equally well in statically and dynamically linked binaries.” The general ELF structure it relies on — program headers, the dynamic table, the symbol/hash tables — is covered in ELF Format; the vDSO is just an ELF DSO that happens to be authored by the kernel rather than a userspace toolchain (contrast a normal program image, e.g. Go Executable Layout).
Crucially, the layout script also reserves the [vvar] data region just before the vDSO text. The comment is explicit: “User/kernel shared data is before the vDSO.” The reservation is vvar_start = . - 4 * PAGE_SIZE, and that arithmetic is directly checkable against the live mapping above: 7fc9c2a68000-7fc9c2a6c000 is 0x4000 = 16 KB = exactly four 4 KB pages. Those pages hold the struct vdso_data (timekeeping state) and, at 6.12, a struct vdso_rng_data for the random-number vDSO; the vDSO code reads these structures to answer the time and getrandom calls.
This note treats the data region only as “what the code reads” — its field layout, the seqlock that guards it, and its 6.18 rename from vdso_data to vdso_time_data are How the vDSO Reads Kernel Time Without Trapping’s subject, not this one’s.
Symbols the x86-64 vDSO Exports
Which functions the vDSO offers is decided by a version script — arch/x86/entry/vdso/vdso.lds.S at v6.12, moved to arch/x86/entry/vdso/vdso64/vdso64.lds.S at v7.0 (see the reorganisation table below). Fetching both tags returns byte-identical VERSION blocks; only the #include path differs. The exported set, inside a single LINUX_2.6 version node, is:
VERSION {
LINUX_2.6 {
global:
clock_gettime;
__vdso_clock_gettime;
gettimeofday;
__vdso_gettimeofday;
getcpu;
__vdso_getcpu;
time;
__vdso_time;
clock_getres;
__vdso_clock_getres;
#ifdef CONFIG_X86_SGX
__vdso_sgx_enter_enclave;
#endif
getrandom;
__vdso_getrandom;
local: *;
};
}
Read this carefully, because it corrects two widely-repeated claims:
-
The often-cited “four functions” list is incomplete. Many references — including the vdso(7) man page and the 2014 LWN article, which names exactly these four — list only
__vdso_clock_gettime,__vdso_gettimeofday,__vdso_getcpu, and__vdso_timefor x86-64. The kernel source is ground truth, and at 6.12 it also exports__vdso_clock_getres(the resolution of a clock) and, newest,__vdso_getrandom. The__vdso_getrandomentry is recent: the userspacegetrandom()vDSO acceleration landed in Linux 6.11 (work by Jason A. Donenfeld; the implementation filesvgetrandom.c/vgetrandom-chacha.Scarry 2022–2024 copyright). I verified the release by diffing the version script across tags —getrandom/__vdso_getrandomare absent fromvdso.lds.Sat the v6.10 tag and present from v6.11 onward — so the symbol is present in the 6.12 LTS tree but absent from kernels older than 6.11 and from the vdso(7) man-page snapshot.__vdso_sgx_enter_enclaveis conditional onCONFIG_X86_SGX(Software Guard Extensions) and is a transition primitive, not a time call. -
Each function is exported twice. There is both a
__vdso_-prefixed name (e.g.__vdso_clock_gettime) and a bare alias (clock_gettime). The implementation sourcevclock_gettime.cshows the bare names are weak aliases of the__vdso_symbols:
int __vdso_gettimeofday(struct __kernel_old_timeval *tv, struct timezone *tz)
{
return __cvdso_gettimeofday(tv, tz);
}
int gettimeofday(struct __kernel_old_timeval *, struct timezone *)
__attribute__((weak, alias("__vdso_gettimeofday")));The convention (per the man page) is that the prefixed __vdso_* (or __kernel_* on some architectures) names are the canonical ones userspace should resolve; the unprefixed weak aliases exist for convenience. The actual implementations are thin: __vdso_gettimeofday just calls the generic __cvdso_gettimeofday from lib/vdso/gettimeofday.c (shared across architectures), and __vdso_getcpu (in vgetcpu.c) is a one-liner that calls vdso_read_cpunode(cpu, node) to read the CPU/NUMA-node pair the kernel caches per-CPU.
The complete x86-64 vDSO symbol table, measured
That uncertainty is now resolved by measurement. Dumping the live vDSO out of this process — getauxval(AT_SYSINFO_EHDR), copy the whole [vdso] VMA to a file, run objdump -T on it as an ordinary .so — gives the definitive runtime answer on Linux 7.1.13:
$ objdump -T vdso.so
DYNAMIC SYMBOL TABLE:
00000000000011e0 w DF .text 0000000000000005 LINUX_2.6 clock_gettime
00000000000011e0 g DF .text 0000000000000005 LINUX_2.6 __vdso_clock_gettime
00000000000011a0 w DF .text 0000000000000005 LINUX_2.6 gettimeofday
00000000000011a0 g DF .text 0000000000000005 LINUX_2.6 __vdso_gettimeofday
00000000000011b0 w DF .text 0000000000000027 LINUX_2.6 time
00000000000011b0 g DF .text 0000000000000027 LINUX_2.6 __vdso_time
00000000000011f0 w DF .text 000000000000006e LINUX_2.6 clock_getres
00000000000011f0 g DF .text 000000000000006e LINUX_2.6 __vdso_clock_getres
0000000000001260 w DF .text 0000000000000027 LINUX_2.6 getcpu
0000000000001260 g DF .text 0000000000000027 LINUX_2.6 __vdso_getcpu
00000000000012a0 w DF .text 0000000000000385 LINUX_2.6 getrandom
00000000000012a0 g DF .text 0000000000000385 LINUX_2.6 __vdso_getrandom
00000000000017b0 g DF .text 000000000000009d LINUX_2.6 __vdso_sgx_enter_enclave
0000000000000000 g DO *ABS* 0000000000000000 LINUX_2.6 LINUX_2.6
Six function pairs plus one unpaired symbol. Note that __vdso_sgx_enter_enclave is present on this AMD machine: it is gated on CONFIG_X86_SGX at kernel build time, and Fedora builds it in, so the symbol exists on hardware that has no SGX at all. Its presence tells you nothing about the CPU.
| Symbol | Body size | What it does | Can it always avoid the syscall? |
|---|---|---|---|
__vdso_clock_gettime | 5 B thunk → 0x980 | Reads the requested clock. Dispatches on 1<<clk into VDSO_HRES / VDSO_COARSE / VDSO_RAW (VDSO_AUX on v6.17+). | No. Traps if clock_mode == NONE, if the id is out of range, or if the id is in no mask — see the previous section |
__vdso_gettimeofday | 5 B thunk → 0xed0 | CLOCK_REALTIME as a struct timeval plus a (long-obsolete, always-zeroed) struct timezone | No. Same clock_mode gate — it is do_hres(CS_HRES_COARSE, CLOCK_REALTIME) with a fallback |
__vdso_time | 39 B | time_t seconds only. Reads basetime[CLOCK_REALTIME].sec straight off the page — no rdtsc, no seqlock loop, no arithmetic | Yes. A single load (plus a time-namespace compare); there is no failure path in the disassembly at all |
__vdso_clock_getres | 110 B | The resolution of a clock, not its value. For VDSO_HRES/VDSO_RAW it loads hrtimer_res off the page; for VDSO_COARSE it returns the literal mov $0xf4240 = 1,000,000 ns. Measured: 1 ns for the high-res clocks, 1,000,000 ns for the coarse ones, matching this kernel’s CONFIG_HZ=1000 | No, but only for out-of-range or unsupported clock ids — cmp $0x17 then test $0x893 / test $0x60, else syscall $0xe5 |
__vdso_getcpu | 39 B | Current CPU number and NUMA node, packed one-per-word. cpu = value & 0xfff, node = value >> 12 | Yes. On this host it is rdpid %rax and two shifts — no memory reference at all |
__vdso_getrandom | 901 B | Userspace CSPRNG: per-thread ChaCha20 state seeded from the kernel’s generation counter, re-seeded only when that counter moves | No. Falls back to the getrandom(2) syscall on the first call and on every re-seed |
__vdso_sgx_enter_enclave | 157 B | EENTER/ERESUME transition into an Intel SGX enclave. Not a syscall replacement at all — a privilege-domain transition helper | n/a — it is not a syscall substitute |
Every symbol the x86-64 vDSO exports, with its measured size and its honest answer to “does calling this avoid the kernel?”. What it shows: only two of the seven — __vdso_time and __vdso_getcpu — are unconditionally trap-free, and both are trivial (one load, or one instruction). The functions people actually care about for latency, clock_gettime and gettimeofday, are the ones whose fast path is conditional. The insight to take: the size column is a good proxy for the answer. A 39-byte function has no room for a fallback; a 5-byte thunk into a 200-byte body has room for four.
Two claims the man page makes that the source contradicts, both worth carrying:
- The “four functions” list is stale. vdso(7) lists only
__vdso_clock_gettime,__vdso_gettimeofday,__vdso_getcpuand__vdso_timefor x86-64. The kernel’s version script also exports__vdso_clock_getresand, since Linux 6.11,__vdso_getrandom— verified by diffingvdso.lds.Sacross tags:getrandom/__vdso_getrandomare absent at v6.10 and present from v6.11 onward. Both are visible in the live dump above. - The exported set has not changed between v6.12 and v7.1. Fetching the version script at both tags returns byte-identical
VERSION { LINUX_2.6 { … } }blocks; the only difference is the#includepath ("vdso-layout.lds.S"→"common/vdso-layout.lds.S", see the reorganisation note below). So the symbol list in this note is simultaneously correct for the vault’s 6.12 pin and for the running 7.1 kernel.
What glibc actually binds — and what it deliberately does not
Exported is not the same as used. glibc resolves vDSO symbols exactly once, at startup, into function pointers in the read-only rtld_global_ro structure. The mechanism is dl_vdso_vsym(), which does a weak symbol lookup against the vDSO’s own link_map so a missing symbol is a NULL rather than a fatal error (dl-vdso.h, glibc 2.43):
static inline void *
dl_vdso_vsym (const char *name)
{
struct link_map *map = GLRO (dl_sysinfo_map);
if (map == NULL)
return NULL;
/* Use a WEAK REF so we don't error out if the symbol is not found. */
ElfW (Sym) wsym = { 0 };
wsym.st_info = (unsigned char) ELFW (ST_INFO (STB_WEAK, STT_NOTYPE));
const struct r_found_version rfv = { VDSO_NAME, VDSO_HASH, 1, NULL };
...
}VDSO_NAME and VDSO_HASH are hard-coded per architecture. On x86-64 (sysdeps/unix/sysv/linux/x86_64/sysdep.h) they are "LINUX_2.6" and 61765110 — and that number is exactly the SysV ELF hash of the string "LINUX_2.6", computed here to confirm it rather than taking it on faith. glibc is matching the version node, not just the symbol name: a vDSO that exported clock_gettime under a different version node would not be bound. The same header lists which symbols x86-64 looks for:
# define VDSO_NAME "LINUX_2.6"
# define VDSO_HASH 61765110
# define HAVE_CLOCK_GETTIME64_VSYSCALL "__vdso_clock_gettime"
# define HAVE_GETTIMEOFDAY_VSYSCALL "__vdso_gettimeofday"
# define HAVE_TIME_VSYSCALL "__vdso_time"
# define HAVE_GETCPU_VSYSCALL "__vdso_getcpu"
# define HAVE_CLOCK_GETRES64_VSYSCALL "__vdso_clock_getres"
# define HAVE_GETRANDOM_VSYSCALL "__vdso_getrandom"Six pointers, matching the six function pairs the kernel exports. __vdso_sgx_enter_enclave is not bound — it is not a libc concern; the Intel SGX SDK resolves it itself. Note also that x86-64 defines only the …64_VSYSCALL variants of clock_gettime and clock_getres: on a 64-bit architecture time_t is already 64 bits, so __vdso_clock_gettime is the 64-bit entry point and the 32-bit compatibility paths in clock_gettime.c are compiled out.
The consumption side is a null check and an indirect call (clock_gettime.c, glibc 2.43):
int (*vdso_time64) (clockid_t clock_id, struct __timespec64 *tp)
= GLRO(dl_vdso_clock_gettime64);
if (vdso_time64 != NULL)
{
r = INTERNAL_VSYSCALL_CALL (vdso_time64, 2, clock_id, tp);
if (r == 0)
return 0;
return INLINE_SYSCALL_ERROR_RETURN_VALUE (-r);
}
/* … otherwise INTERNAL_SYSCALL_CALL (clock_gettime64, …) … */The crucial asymmetry: glibc cannot tell whether the vDSO trapped. The vDSO returns 0 on success whether it answered from the shared page or issued syscall internally. There is no status bit, no out-parameter, no errno distinction. So a program that “uses the vDSO” has no supported way to learn whether it is getting the fast path — which is why the only honest diagnostic is the timing measurement above, and why /sys/devices/system/clocksource/clocksource0/current_clocksource is the first thing to check when clock reads are unexpectedly slow. The userspace-side mechanics in full — parse_vdso.c, static binaries, non-glibc consumers — are vDSO Symbols and How libc Uses Them’s subject.
How the Kernel Maps It: map_vdso and the Special Mappings
At execve(2), after the ELF loader has laid out the program and its interpreter, the architecture’s arch_setup_additional_pages() is called. On x86-64 (in arch/x86/entry/vdso/vma.c) this is a one-liner guarded by the vdso64_enabled knob:
int arch_setup_additional_pages(struct linux_binprm *bprm, int uses_interp)
{
if (!vdso64_enabled)
return 0;
return map_vdso(&vdso_image_64, 0);
}map_vdso(image, addr) does the real work. With addr == 0 it asks the memory manager for a free virtual address via get_unmapped_area() — and this is where Address Space Layout Randomization (ASLR) enters: the vDSO base is randomized per execve, so you must never hard-code its address. The man page is emphatic: “You must not assume the vDSO is mapped at any particular location … The base address will usually be randomized at run time every time a new process image is created.” It then installs two special mappings:
addr = get_unmapped_area(NULL, addr,
image->size - image->sym_vvar_start, 0, 0);
...
text_start = addr - image->sym_vvar_start;
/* MAYWRITE to allow gdb to COW and set breakpoints */
vma = _install_special_mapping(mm, text_start, image->size,
VM_READ|VM_EXEC|
VM_MAYREAD|VM_MAYWRITE|VM_MAYEXEC,
&vdso_mapping);
...
vma = _install_special_mapping(mm, addr, -image->sym_vvar_start,
VM_READ|VM_MAYREAD|VM_IO|VM_DONTDUMP|
VM_PFNMAP,
&vvar_mapping);
...
current->mm->context.vdso = (void __user *)text_start;
current->mm->context.vdso_image = image;Two vm_special_mappings are created: the [vdso] mapping (VM_READ | VM_EXEC, the code) and, immediately preceding it, the [vvar] mapping (VM_READ | VM_IO | VM_DONTDUMP | VM_PFNMAP, the kernel-published data). A “special mapping” is a VMA backed not by a file or anonymous memory but by a kernel-supplied fault handler: when the process first touches a page, vdso_fault() returns the corresponding page of the embedded image->data, and vvar_fault() inserts the physical frame of the live kernel data (timekeeping state, and for paravirtualized/Hyper-V guests, the pvclock/hvclock pages). The VM_MAYWRITE on [vdso] is not so the program can patch the vDSO — the comment says it exists “to allow gdb to COW and set breakpoints,” i.e. a debugger can copy-on-write a private page to plant an int3. Finally the kernel records the chosen text base in current->mm->context.vdso, which is the value it will advertise to userspace.
There is also map_vdso_once(), which scans all VMAs and returns -EEXIST if a vDSO/vvar mapping is already present — this backs the arch_prctl(ARCH_MAP_VDSO_*) interface used by tools like CRIU (Checkpoint/Restore In Userspace) to re-map the vDSO at a controlled address after restore.
How this changed by v7.1 — verified by fetching both tags
The code above is v6.12. The running kernel’s version differs in two ways, both checkable:
/* arch/x86/entry/vdso/vma.c, v7.1 */
vma = _install_special_mapping(mm, …,
VM_READ|VM_EXEC|
VM_MAYREAD|VM_MAYWRITE|VM_MAYEXEC|
VM_SEALED_SYSMAP,
&vdso_mapping);
…
vma = _install_special_mapping(mm, …,
VM_READ|VM_MAYREAD|VM_IO|VM_DONTDUMP|
VM_PFNMAP|VM_SEALED_SYSMAP,
&vvar_vclock_mapping);First, the third mapping (vvar_vclock_mapping, with its own vvar_vclock_fault()), which arrived in v6.13 as described above. Second, VM_SEALED_SYSMAP on every one of them — memory sealing, which makes the VMA immune to mmap/munmap/mprotect/mremap from userspace, so a process can no longer unmap or re-protect its own vDSO. That closes a class of exploit primitive (unmap the vDSO, map attacker-controlled code at the address AT_SYSINFO_EHDR still advertises) at the cost of breaking any tool that deliberately unmapped it.
There is a third change, and it is the one that will silently invalidate file paths copied from older write-ups. Between v6.18 and v7.0 the x86 vDSO source tree was reorganised. Existence-checking arch/x86/entry/vdso/vdso.lds.S by HTTP status across tags:
| Tag | arch/x86/entry/vdso/vdso.lds.S | …/vdso-layout.lds.S | …/vma.c |
|---|---|---|---|
| v6.12 – v6.18 | 200 (657 B) | 200 | 200 |
| v7.0 | 404 | 404 | 200 |
| v7.1 | 404 | 404 | 200 (6,832 B) |
| v7.2 | 404 | 404 | 200 |
The files did not disappear; they moved into subdirectories. A git.kernel.org cgit listing of arch/x86/entry/vdso at v7.1 shows Makefile, common/, extable.c, extable.h, vdso32/, vdso32-setup.c, vdso64/, vma.c — with the linker scripts now at common/vdso-layout.lds.S and vdso64/vdso64.lds.S, and vclock_gettime.c/vgetcpu.c duplicated into both common/ and vdso64/. Only vma.c stayed put, which is why a naive “does this path still exist?” check on vma.c alone would have reported no change at all. Note also that a 404 from raw.githubusercontent.com arrives as a 14-byte body, and a transient rate-limit arrives as HTTP 429 with a ~313-byte body — checking the status code alone conflates them. One tag in the sweep above (v6.14’s vdso-layout.lds.S) returned 429/313 B on the first pass and 200 on retry.
The Handshake: AT_SYSINFO_EHDR in the Auxiliary Vector
A process cannot find the vDSO by scanning memory — ASLR forbids that. Instead, the kernel tells it where the vDSO is, through the auxiliary vector (auxv): a list of (type, value) pairs the kernel pushes onto the new process’s stack at execve, just past argv/envp. Among these pairs is AT_SYSINFO_EHDR, whose value is the base address of the vDSO’s ELF header — i.e. exactly the context.vdso recorded by map_vdso. The man page defines it as “The address of a page containing the virtual Dynamic Shared Object (vDSO)” (getauxval(3)).
The auxv entry is emitted by the architecture’s ARCH_DLINFO macro, expanded inside the ELF loader’s auxv-construction. On x86-64 (arch/x86/include/asm/elf.h, v6.12):
#define ARCH_DLINFO \
do { \
if (vdso64_enabled) \
NEW_AUX_ENT(AT_SYSINFO_EHDR, \
(unsigned long __force)current->mm->context.vdso); \
NEW_AUX_ENT(AT_MINSIGSTKSZ, get_sigframe_size()); \
} while (0)Note what x86-64 emits and what it does not: it emits AT_SYSINFO_EHDR (the vDSO base) but not AT_SYSINFO. AT_SYSINFO — “the entry point to the system call function in the vDSO” — is an IA-32 (32-bit/compat) concept, set in ARCH_DLINFO_IA32 so 32-bit code can call __kernel_vsyscall to issue a syscall via the kernel’s preferred instruction. On native 64-bit, userspace issues the syscall instruction directly, so there is no need for a AT_SYSINFO entry-point pointer; the only thing the kernel advertises is the address of the whole DSO via AT_SYSINFO_EHDR. Confusing the two is a common error. How userspace then reads this entry (getauxval(AT_SYSINFO_EHDR)) and parses the DSO is the subject of vDSO Symbols and How libc Uses Them.
Inspecting the vDSO on a Live System
Because the vDSO is a real, mapped ELF object, you can extract it and treat it like any other shared library. Everything below is real output from this host.
1. See the regions. Three of them on 7.1.13 (two on ≤ 6.12):
$ grep -E 'vdso|vvar' /proc/self/maps
7fc9c2a68000-7fc9c2a6c000 r--p 00000000 00:00 0 [vvar] # 16 KB, read-only data
7fc9c2a6c000-7fc9c2a6e000 r--p 00000000 00:00 0 [vvar_vclock] # 8 KB, pv/hv clock pages
7fc9c2a6e000-7fc9c2a70000 r-xp 00000000 00:00 0 [vdso] # 8 KB, read+execute codeThe permission bits match the VMA flags from map_vdso exactly: r--p for the data, r-xp for the code, and nothing is writable. Every address changes on each run because of ASLR.
2. Dump it and inspect it as a .so. Read AT_SYSINFO_EHDR, copy the mapping’s bytes out, and point binutils at the result:
$ ./dumpvdso # getauxval(AT_SYSINFO_EHDR), fwrite the [vdso] VMA
vdso base=0x7f3e69c40000 size=8192 bytes e_type=3 phnum=6
$ readelf -lW vdso.so
Elf file type is DYN (Shared object file)
Entry point 0x0
LOAD 0x000000 0x0…0 0x0…0 0x00195b 0x00195b R E 0x1000
DYNAMIC 0x0004a8 … R
NOTE 0x0005f8 … R
GNU_EH_FRAME 0x000690 … R
GNU_STACK 0x000000 … RW
GNU_PROPERTY 0x0005f8 … R
00 .hash .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_d .dynamic .rodata
.note.gnu.property .note .eh_frame_hdr .eh_frame .text .altinstructions
.altinstr_replacemente_type = 3 is ET_DYN — it really is a shared object, not an executable. The whole DSO is 6,491 bytes (0x195b) of the 8 KB mapping, in a single read-execute PT_LOAD.
3. The two sections nobody mentions. .altinstructions (210 bytes, entry size 14) and .altinstr_replacement (60 bytes) are the kernel’s alternatives mechanism reaching into userspace code. At boot the kernel patches the vDSO’s text in place based on what the CPU supports, before any process maps it. The effect is directly visible in __vdso_getcpu:
0000000000001260 <__vdso_getcpu@@LINUX_2.6>:
1260: b8 7b 00 00 00 mov $0x7b,%eax # dead: the GDT selector for the
1265: f3 0f c7 f8 rdpid %rax # per-CPU segment, patched over
1269: 48 85 ff test %rdi,%rdi
126e: 89 c2 mov %eax,%edx
1270: 81 e2 ff 0f 00 00 and $0xfff,%edx # cpu = value & 0xfff
1276: 89 17 mov %edx,(%rdi)
127d: 48 c1 e8 0c shr $0xc,%rax # node = value >> 12
1281: 89 06 mov %eax,(%rsi)
1283: 31 c0 xor %eax,%eax
1285: c3 ret
The kernel’s shipped instruction sequence is mov $0x7b,%eax; lsl %eax,%eax — load the per-CPU segment selector and read its limit, into which the kernel has stuffed the packed CPU/node pair. On a CPU with the RDPID instruction the alternatives patcher replaces the lsl with rdpid %rax, which reads the same value from IA32_TSC_AUX in one instruction and leaves the now-pointless mov $0x7b behind as dead code. So __vdso_getcpu is not the same machine code on every machine, and the copy you dump from your own process is the patched one.
4. The vDSO records the kernel that built it. Its .note section carries the kernel version twice:
$ readelf -n vdso.so
Displaying notes found in: .note.gnu.property
GNU 0x20 NT_GNU_PROPERTY_TYPE_0
Properties: x86 feature used: x86, XMM
x86 ISA used: x86-64-baseline
Displaying notes found in: .note
Linux 0x04 Unknown note type: (0x00000000)
description data: 0d 01 07 00 # LINUX_VERSION_CODE = 0x0007010d
Linux 0x17 OPEN
description data: 37 2e 31 2e 31 33 2d 32 30 30 2e 66 63 34 34 2e 78 38 36 5f 36 34 00
GNU 0x14 NT_GNU_BUILD_ID
Build ID: d823044909ba4b86c24a68146fc88dfc3ca269a60x0007010d decodes as (7 << 16) | (1 << 8) | 13 = 7.1.13, and the string note is "7.1.13-200.fc44.x86_64". This is genuinely useful: a core dump or a memory image that contains the vDSO contains a self-identifying record of the kernel it ran on, complete with a build ID, even when /proc is long gone. The x86-64-baseline ISA property also tells you the vDSO makes no assumption about AVX — which is why __vdso_getrandom’s ChaCha20 is hand-written rather than vectorised.
5. Check the clocksource before you conclude anything about speed. This is the one-line diagnostic that explains a slow clock_gettime:
$ cat /sys/devices/system/clocksource/clocksource0/current_clocksource
tsc
$ cat /sys/devices/system/clocksource/clocksource0/available_clocksource
tsc hpet acpi_pmtsc means clock_mode == VDSO_CLOCKMODE_TSC and the fast path is live — consistent with the 17.3 ns measured above. Had this read hpet or acpi_pm, clock_mode would be VDSO_CLOCKMODE_NONE and every one of those calls would have trapped.
Uncertain
Verify: the HPET/acpi_pm fallback by observation, not only by reading the gate. Reason: switching the clocksource requires writing
/sys/devices/system/clocksource/clocksource0/current_clocksource, which is root-owned (-rw-r--r--. 1 root root), and no privilege escalation was available in this session. The claim that a non-TSC clocksource forces the syscall path therefore rests on (a)vdso_clocksource_ok()inlib/vdso/gettimeofday.c, (b) thecmp $0x1,%eax/test %eax,%eax → je <syscall>pair in this host’s own disassembly, and (c) the fact that HPET and acpi_pm expose no ring-3-readable counter. To resolve: as root,echo hpet > …/current_clocksourceand re-run the benchmark —clock_gettime(CLOCK_MONOTONIC)should rise from ≈17 ns to ≈60 ns, i.e. to within noise of the forced-syscall row. uncertain
Version drift at a glance
Enough version-specific facts have accumulated that they are worth collecting. Every row was established by fetching the file at each tag and recording the result, not from memory:
timeline title x86-64 vDSO changes relevant to this note (verified by tag-by-tag fetch) Linux 6.10 : no getrandom in vdso.lds.S Linux 6.11 : getrandom / __vdso_getrandom added to the version script Linux 6.12 : the vault's pin — 2 special mappings, [vdso] and [vvar] : struct vdso_data, no VDSO_AUX Linux 6.13 : pvclock/hvclock split into a 3rd mapping, [vvar_vclock] : 0 to 6 occurrences of vvar_vclock in vma.c Linux 6.17 : VDSO_AUX added — auxiliary clocks, ids 16 to 23, mask 0xff0000 Linux 6.18 : last tag where arch/x86/entry/vdso/vdso.lds.S exists (HTTP 200) Linux 7.0 : tree reorganised — vdso.lds.S and vdso-layout.lds.S now 404 : moved to vdso64/ and common/ Linux 7.1 : VM_SEALED_SYSMAP on all three mappings : this host runs 7.1.13
Verified version drift in the x86-64 vDSO. What it shows: in the five releases between the vault’s 6.12 pin and this host’s 7.1.13, the exported symbol set did not change at all, while the mapping count, the data-page structure, the clock-id coverage and the source-tree layout all did. The insight to take: the vDSO’s interface is extraordinarily stable — LINUX_2.6 is still the version node in 2026 — and its implementation is not. Claims about symbols age well; claims about file paths, VMA counts and struct names do not, which is why each is pinned to a tag above.
Failure Modes and Common Misunderstandings
- “The vDSO is at a fixed address.” It is not — its base is randomized by ASLR on every
execve, and has been for many years. Hard-coding0xffffffffff600000is the vsyscall behaviour, the obsolete fixed-address predecessor described in The vsyscall Legacy Mechanism. Those addresses appear in pre-2014 documentation (including the 2014 LWN article’s description of the vvar page) and are stale for the vDSO. Always readAT_SYSINFO_EHDR. - “Calling a vDSO function always avoids a syscall.” The central misconception, and the reason this note devotes a whole section to it. There are four independent ways a vDSO call ends in a trap: the vDSO was never mapped (
vdso=0), the clock id is>= MAX_CLOCKS, the clock id is in none ofVDSO_HRES/VDSO_COARSE/VDSO_RAW/VDSO_AUX, orclock_mode == VDSO_CLOCKMODE_NONEbecause the active clocksource is not vDSO-capable. Only the last is a property of the machine rather than of the request, and it is the one nobody checks. - “
__vdso_getcpuis the same code everywhere.” It is not. The.altinstructionsmachinery patcheslsl→rdpidat boot on capable CPUs, so the bytes you dump from your own process differ from the bytes in the kernel image. Any tool that hashes the vDSO to fingerprint a kernel build is fingerprinting the kernel and the CPU. - “vvar is the page immediately before vdso.” True on Linux ≤ 6.12, false from 6.13, where
[vvar_vclock]was inserted between them. The ordering survives; the adjacency does not. - “The exported symbols are the four in the man page.” Six pairs plus
__vdso_sgx_enter_enclave, verified against the live object above. And the presence of__vdso_sgx_enter_enclavesays nothing about whether the CPU has SGX — it is a kernelCONFIG_X86_SGXbuild option, and it is present on this AMD machine. - Disabling it. Booting with
vdso=0(handled byvdso_setup/__setup("vdso=", …)invma.c) clearsvdso64_enabled, soarch_setup_additional_pagesmaps nothing andAT_SYSINFO_EHDRis not emitted.dl_vdso_vsym()then returnsNULLfor every symbol — because it does a deliberately weak lookup — and glibc’s null checks route everything to the syscall path with no error. Useful for making every clock read visible tostrace, at the measured cost of ~43 ns per call. - Static binaries do get the vDSO. There is no dynamic linker involved, but the kernel still maps it and still emits
AT_SYSINFO_EHDR; a static binary usesgetauxval()plus the parsing logic intools/testing/selftests/vDSO/parse_vdso.c. Thebarefreestanding program in _start and __libc_start_main prints a non-zeroAT_SYSINFO_EHDRwith no libc linked in at all. - The vvar/vDSO split confuses
pmapand core dumps.[vvar]and[vvar_vclock]areVM_DONTDUMP, so they are excluded from core dumps (dumping live kernel timekeeping state would be meaningless and a small information leak);[vdso]is included, which is what lets a debugger symbolise backtraces through it — and, per the notes section above, what makes a core dump self-identify the kernel it was taken on.
Alternatives and When the vDSO Does Not Apply
The vDSO only helps calls that are (a) extremely frequent and (b) safely answerable from read-only published data. It is therefore the right tool for clock reads, CPU-id queries, and userspace random-number generation — and the wrong tool for anything with side effects. You cannot write() from the vDSO because there is genuine kernel work to do; you cannot read CLOCK_PROCESS_CPUTIME_ID from it because the answer requires summing every thread’s runtime under a lock, which is exactly why that clock measured 101.6 ns in the table above. The dividing line is not “is this call cheap?” but “can the kernel publish, in advance, everything the answer depends on?”. Three neighbouring mechanisms answer the same question differently:
- vsyscall (The vsyscall Legacy Mechanism) — the obsolete predecessor: a single page at a fixed address with a fixed layout. The fixed address was a usability convenience (no auxv parsing, works with no loader cooperation) and a security liability (a reliable, always-executable ROP target at a known address), which is precisely why it was replaced by the randomized, ELF-structured vDSO. Modern kernels keep it only in an emulated
xonlyform for very old binaries. - io_uring (io_uring as a Syscall Batching Mechanism) — attacks the same cost from the opposite direction. Instead of removing the trap for one hot call, it amortizes one trap across thousands of batched operations through shared-memory submission and completion rings, and with
IORING_SETUP_SQPOLLremoves it entirely for the steady state. The vDSO removes the trap for calls that need no kernel work; io_uring amortizes it for calls that do. - Just using a coarser clock. The cheapest optimisation in the table is not architectural at all:
CLOCK_REALTIME_COARSEat 2.5 ns versusCLOCK_REALTIMEat 18.1 ns, a 7× saving for a one-token source change, at the price of tick-granularity resolution (measured here: exactly 1 ms, i.e.1/CONFIG_HZ). For rate limiting, log timestamps, cache expiry and timeout bookkeeping that is almost always enough. Reach for it before reaching for anything clever.
Production Notes
- Diagnosing a slow
clock_gettimein production. The checklist, in order:cat /sys/devices/system/clocksource/clocksource0/current_clocksource(anything buttsc,kvm-clockorhyperv_clocksource_tsc_pagemeans every call traps);grep vdso /proc/self/maps(absent meansvdso=0or a seccomp/hardening policy); then time it — 17 ns versus 60 ns is unambiguous. The most common real cause is the kernel having demoted the TSC at runtime: the clocksource watchdog marks the TSC unstable if it disagrees with HPET, logsclocksource: Marking TSC unstable, switches tohpet, and every clock read in every process on the box silently gets 3.5× slower. This is a classic “the whole fleet got slower after a firmware update” incident shape. - Virtualisation changes the answer. In a KVM guest the clocksource is usually
kvm-clock(VDSO_CLOCKMODE_PVCLOCK, mode 2) and on Hyper-Vhyperv_clocksource_tsc_page(VDSO_CLOCKMODE_HVCLOCK, mode 3); both keep the fast path, reading from the[vvar_vclock]pages. But a guest whose host has migrated it between hosts with unsynchronised TSCs can find the TSC marked unstable and fall back to a trapping clocksource — a real and hard-to-attribute source of latency regressions after live migration. - CRIU and checkpoint/restore. Because the vDSO base is randomized and baked into saved register and stack state, restoring a checkpointed process requires re-establishing a compatible mapping at a controlled address; the
arch_prctl(ARCH_MAP_VDSO_*)interface backed bymap_vdso_once()exists largely for this. A vDSO whose size changed between kernel versions — and it does, as the__vdso_getrandomaddition in 6.11 shows — can break naive restore across a kernel upgrade. From 7.1,VM_SEALED_SYSMAPadds a further constraint: the mappings can no longer be unmapped by the restoring process itself. - Containers and time namespaces. In a non-root time namespace (
CONFIG_TIME_NS) the kernel faults in a timens variant of the data page whoseclock_modeis the sentinelVDSO_CLOCKMODE_TIMENS(INT_MAX=0x7fffffff). That value is deliberately neither 0 nor a valid mode, so it fails thecmp $0x1fast-path test and lands in a slow path that re-reads from the page one further back and applies the namespace offset — still with no trap. The compare is visible in this host’s disassembly ascmpl $0x7fffffff,0x4(%r8).vdso_join_timens()invma.cclears and re-faults the VMAs when a task changes namespace. This is how a container runtime virtualisesCLOCK_MONOTONICcheaply. - Seccomp, strace and observability. A vDSO read never traps, so there is no
sys_enter/sys_exittracepoint and no seccomp evaluation. A sandbox that allow-lists syscalls neither needs to nor can gate vDSO clock reads; conversely a tracer trying to account for every time read will miss them unless it boots withvdso=0. This is the standard answer to “why doesn’tstraceshow myclock_gettime?” — and note that it cuts both ways: ifstracedoes showclock_gettime, your clocksource is not vDSO-capable, and you have just found a performance bug for free. - Do not micro-optimise by resolving the symbols yourself. Measured above: 17.3 ns through glibc versus 17.1 ns calling
__vdso_clock_gettimedirectly. glibc’s wrapper is one null check and one indirect call. Hand-rolled vDSO parsing is worth it only where there is no libc — freestanding programs, early-boot code, and language runtimes that do not link one.
See Also
- How the vDSO Reads Kernel Time Without Trapping — the near-title sibling. It owns the
vdso_time_datafield layout, the seqlock read protocol, themult/shiftarithmetic and theupdate_vsyscall()writer. This note stops at thecmp $0x1, %eaxthat decides whether that arithmetic runs at all - vDSO Symbols and How libc Uses Them — the userspace side in full:
parse_vdso.c, static binaries, non-glibc consumers - Raw Syscalls vs vsyscall vs vDSO Performance — the three-way head-to-head; this note measures only the two cases needed to establish the clocksource dependency
- Why System Calls Are Expensive — owns the syscall cost model and the measured per-syscall table. The 36.9 ns null-syscall figure here is a cross-check against it, not a re-derivation
- The x86-64 Syscall Calling Convention — the register contract of the
syscall $0xe4instruction sitting inside the vDSO’s fallback path - The vsyscall Legacy Mechanism — the obsolete fixed-address predecessor and why it was replaced
- The Auxiliary Vector — owns
AT_SYSINFO_EHDRand the rest of the auxv; this note uses only that one entry - _start and __libc_start_main — where
_dl_aux_initcaches the auxv thatsetup_vdso_pointers()later reads - The Dynamic Linker · Versioned Symbols in glibc —
dl_vdso_vsym()is an ordinary versioned symbol lookup against theLINUX_2.6version node (hash61765110) - ELF Format · The Dynamic Section — the shared-object format and the
PT_DYNAMICtable the vDSO is an instance of - The Syscall Instruction and Trap Mechanism · The pt_regs Register Frame · The Generic Syscall Entry and Exit Layer · libc Syscall Wrappers and errno Translation — the machinery a real syscall pays for
- seccomp and Syscall Filtering — why a vDSO call is invisible to a syscall filter
- io_uring as a Syscall Batching Mechanism — the other way to make the trap cost go away
- Linux Time and Timers MOC — maintains the clock data the vDSO reads
- Linux System Call Interface MOC — parent map (§5, “Syscalls Without a Trap”)