what is the vDSO?

The vDSO is the virtual dynamic shared object. Linux maps this small shared library into every new userspace process, and the C library uses functions from it when that is faster than entering the kernel with a normal system call.

The name is used for two closely related things:

  1. an ELF shared object built as part of the kernel
  2. the [vdso] memory mapping containing that object in a process.

The code in that mapping still executes in userspace, at ring 3. It does not gain kernel privileges just because the kernel supplied it. Its advantage is that it can combine ordinary userspace instructions with data the kernel has mapped read-only next to it.

That second mapping is normally called [vvar]:

7ffff7fc4000-7ffff7fc6000 r--p 00000000 00:00 0  [vvar]
7ffff7fc6000-7ffff7fc8000 r-xp 00000000 00:00 0  [vdso]

[vdso] contains executable code. [vvar] contains kernel-maintained data used by that code. Recent x86 kernels may also map a [vvar_vclock] region for paravirtual clock pages. The exact addresses, sizes, and available symbols depend on the architecture, kernel version, and configuration.

Both mappings are normally randomized on each exec. A hard-coded vDSO address would therefore be useless. The kernel instead gives the new process the address of the ELF header in its auxiliary vector as AT_SYSINFO_EHDR.

The older x86 [vsyscall] page is a different mechanism. It lived at a fixed address and had a much more rigid ABI. Some current glibc identifiers still contain VSYSCALL, but in the paths below they mean a call into the vDSO, not the old fixed page.

finding the image

I started by checking the process mappings:

$ rg '\[(vdso|vvar|vvar_vclock)\]' /proc/self/maps
7ffff7fc4000-7ffff7fc6000 r--p 00000000 00:00 0  [vvar]
7ffff7fc6000-7ffff7fc8000 r-xp 00000000 00:00 0  [vdso]

There is a small trap in that command: /proc/self refers to the process which opens it, so a short-lived tool may actually show its own mapping. For examining a particular program, GDB is less ambiguous:

(gdb) set disassembly-flavor intel
(gdb) start
(gdb) info proc mappings
(gdb) info auxv

info auxv includes the entry used to find the image:

AT_SYSINFO_EHDR      System-supplied DSO's ELF header  0x7ffff7fc6000

It is also visible before main through the dynamic loader:

$ LD_SHOW_AUXV=1 /bin/true | rg SYSINFO_EHDR
AT_SYSINFO_EHDR:      0x7ffce917e000

The address can be obtained programmatically with getauxval(AT_SYSINFO_EHDR). This gives the ELF base, not the address of a particular function.

Since the mapping is a real ELF image, it can be dumped and inspected like one. After taking the start and end addresses from info proc mappings:

(gdb) dump binary memory /tmp/vdso.bin 0x7ffff7fc6000 0x7ffff7fc8000
(gdb) shell file /tmp/vdso.bin
(gdb) shell readelf -Ws /tmp/vdso.bin
(gdb) shell objdump -d -M intel /tmp/vdso.bin

The first command produces something along these lines:

/tmp/vdso.bin: ELF 64-bit LSB shared object, x86-64, dynamically linked, stripped

My original dump, from Linux 6.4.4, contained the following interesting dynamic symbols:

clock_gettime@@LINUX_2.6
__vdso_clock_gettime@@LINUX_2.6
gettimeofday@@LINUX_2.6
__vdso_gettimeofday@@LINUX_2.6
time@@LINUX_2.6
__vdso_time@@LINUX_2.6
clock_getres@@LINUX_2.6
__vdso_clock_getres@@LINUX_2.6
getcpu@@LINUX_2.6
__vdso_getcpu@@LINUX_2.6
__vdso_sgx_enter_enclave@@LINUX_2.6

The exact table is not permanent. The current x86-64 linker script also exports getrandom and __vdso_getrandom, for example. New functions can be added because userspace performs an ordinary versioned ELF symbol lookup at runtime. Programs must not assume that every kernel or every architecture exports the same names.

who put it there?

The mappings arrive during exec, before the program gets control.

For an x86-64 ELF executable the current route through the kernel is roughly:

load_elf_binary
  -> ARCH_SETUP_ADDITIONAL_PAGES
  -> arch_setup_additional_pages
  -> map_vdso(&vdso_image_64, 0)
  -> vdso_install_vvar_mapping
  -> create_elf_tables
  -> ARCH_DLINFO
  -> AT_SYSINFO_EHDR = current->mm->context.vdso

map_vdso first finds an unused randomized area. It installs the image as a special readable and executable mapping, then installs the vvar pages next to it. The resulting vDSO base is saved in the process's mm context.

With the error handling removed, the relevant part of the current x86 kernel code is small:

text_start = addr - image->sym_vvar_start;

_install_special_mapping(mm, text_start, image->size,
    VM_READ | VM_EXEC | VM_MAYREAD | VM_MAYWRITE | VM_MAYEXEC,
    &vdso_mapping);

vdso_install_vvar_mapping(mm, addr);
mm->context.vdso = (void *)text_start;

text_start is the beginning of the ELF image. addr is positioned so the vvar pages described by the image land at their expected relative offset. The last assignment is the value which will later become AT_SYSINFO_EHDR.

The mapping has VM_MAYWRITE so a debugger can create private copy-on-write pages when it inserts a breakpoint. That does not make the normal mapping writable. Its effective permissions are still read and execute.

Later, create_elf_tables constructs the initial stack, including the auxiliary vector. On x86, ARCH_DLINFO adds AT_SYSINFO_EHDR with the base saved by map_vdso.

The dynamic loader then takes over. glibc's _dl_parse_auxv stores AT_SYSINFO_EHDR in dl_sysinfo_dso. setup-vdso.h treats the already mapped image as an abridged link_map, reads its dynamic section and hash tables, and adds it to the loader namespace. This is why tools such as ldd can display it as if it were another shared library:

linux-vdso.so.1 (0x00007ffd5c3f8000)

Finally, dl-vdso.h performs versioned weak symbol lookups. On x86 the expected version is LINUX_2.6. The resolved addresses are stored in glibc's read-only loader state as pointers such as dl_vdso_clock_gettime.

No vDSO file was opened from the filesystem in this process. The kernel had already mapped the object and told the loader where it was.

where did the ELF come from?

The x86 vDSO is built with the kernel, but it is not linked into the ordinary kernel text and executed there.

The vDSO sources are compiled as position-independent userspace code and linked with their own linker script. The exported symbol set and symbol version are defined in vdso.lds.S. The result is stripped and checked because the runtime image cannot contain relocations that would need a normal dynamic linker.

The x86 build then runs vdso2c. That tool reads the finished ELF image and emits the data and metadata used to construct a vdso_image in the kernel. The 64-bit image eventually becomes vdso_image_64, which is the object passed to map_vdso above.

So the lifecycle looks like this:

kernel source tree
  -> compile and link a small position-independent ELF
  -> turn that ELF into an image embedded in the kernel
  -> exec maps the image into a process
  -> AT_SYSINFO_EHDR publishes its address
  -> libc resolves versioned functions from it

This also explains why the vDSO matches the running kernel rather than the distribution's libc package.

code in vdso, data in vvar

clock_gettime needs data that changes as the kernel maintains time, but letting userspace write that data would obviously be a problem. Linux splits the mechanism in two:

  • [vdso] is executable code supplied by the kernel.
  • [vvar] is kernel-owned data mapped read-only to userspace.

The kernel writes the data through its own mapping. The process can read it but cannot change the shared kernel state.

My old notes described this as one struct vdso_data. That is no longer the current layout. In Linux 7.1, include/vdso/datapage.h defines separate pieces including:

  • struct vdso_clock, with the sequence counter, clock mode, cycle base, mask, multiplier, shift, and base times.
  • struct vdso_time_data, containing the clock data, auxiliary clocks, timezone values, and timer resolution.
  • struct vdso_rng_data, used by the newer vDSO getrandom path.
  • pages for time namespaces and architecture-specific data.

lib/vdso/datastore.c maps these pages and services their faults. The generic order currently includes time data, time-namespace data, and RNG data, followed by architecture-specific pages where required.

That matters if new vDSO data is added. DECLARE_VVAR and DEFINE_VVAR from older architecture code are not a generic extension interface. Shared time data belongs in the generic data structures. Architecture-specific data belongs in the corresponding asm/vdso structures and pages. Every vDSO variant which consumes the layout has to stay in agreement, and the exposed layout has to remain compatible with older userspace code.

following clock_gettime from c

Starting with a normal C program:

#include <stdio.h>
#include <time.h>

int main(void)
{
    struct timespec ts;

    if (clock_gettime(CLOCK_REALTIME, &ts) == -1) {
        perror("clock_gettime");
        return 1;
    }

    printf("time: %lld.%09ld\n", (long long) ts.tv_sec, ts.tv_nsec);
    return 0;
}

On x86-64, clock_gettime is also system call 228. However, tracing this program usually does not show that system call:

$ cc -O2 clock.c -o clock
$ strace -e clock_gettime ./clock
time: 1786430552.887265796
+++ exited with 0 +++

The public function came from glibc. In glibc 2.44 its Linux implementation is __clock_gettime64. Depending on the architecture and its time_t size, the compiled path uses either the time64 or native-time vDSO pointer. Reduced to the relevant decisions, it does this:

if (vdso_clock_gettime != NULL) {
    r = INTERNAL_VSYSCALL_CALL(vdso_clock_gettime, 2, clock_id, tp);
    if (r == 0)
        return 0;
    return an_error;
}

return INTERNAL_SYSCALL_CALL(clock_gettime, clock_id, tp);

INTERNAL_VSYSCALL_CALL is an indirect userspace function call through the address resolved by the dynamic loader. The historic macro name does not mean it performs a system call.

With an optimized build, GDB therefore reaches an instruction resembling:

call rax

The value in rax is inside [vdso] and resolves to __vdso_clock_gettime@@LINUX_2.6. Since execution never crossed into the kernel, strace had nothing to report.

resolving it directly

It is possible to reproduce part of glibc's lookup by hand. This is useful for investigating the object, although ordinary programs should continue to call clock_gettime:

#define _GNU_SOURCE
#include <dlfcn.h>
#include <elf.h>
#include <stdio.h>
#include <string.h>
#include <sys/auxv.h>
#include <time.h>

typedef int (*vdso_clock_gettime_fn)(clockid_t, struct timespec *);

int main(void)
{
    void *handle = dlopen("linux-vdso.so.1", RTLD_LAZY | RTLD_LOCAL);
    if (handle == NULL) {
        fprintf(stderr, "dlopen: %s\n", dlerror());
        return 1;
    }

    dlerror();
    void *symbol = dlvsym(
        handle, "__vdso_clock_gettime", "LINUX_2.6");
    const char *error = dlerror();
    if (error != NULL) {
        fprintf(stderr, "dlvsym: %s\n", error);
        return 1;
    }

    vdso_clock_gettime_fn fn;
    _Static_assert(sizeof(fn) == sizeof(symbol), "incompatible pointer sizes");
    memcpy(&fn, &symbol, sizeof(fn));

    struct timespec ts;
    if (fn(CLOCK_REALTIME, &ts) != 0)
        return 1;

    printf("AT_SYSINFO_EHDR:     %#lx\n", getauxval(AT_SYSINFO_EHDR));
    printf("__vdso_clock_gettime: %p\n", symbol);
    printf("time:                 %lld.%09ld\n",
           (long long) ts.tv_sec, ts.tv_nsec);
}

Compiling and running it shows that the symbol address is a small offset from the auxiliary-vector address:

$ cc vdso-direct.c -ldl -o vdso-direct
$ ./vdso-direct
AT_SYSINFO_EHDR:      0x7ffcdecd6000
__vdso_clock_gettime: 0x7ffcdecd6ff0
time:                 1786430552.887265796

Linux's own vDSO correctness selftest uses the same general dlopen and versioned lookup technique.

inside __vdso_clock_gettime

On x86, __vdso_clock_gettime is a small wrapper around the generic vDSO time implementation. For CLOCK_REALTIME, the current call path is approximately:

__vdso_clock_gettime
  -> __cvdso_clock_gettime
  -> __cvdso_clock_gettime_data
  -> __cvdso_clock_gettime_common
  -> do_hres
  -> vdso_get_timestamp
  -> __arch_get_hw_counter

The important part is that [vvar] does not contain a timestamp which the kernel updates on every nanosecond. It contains a recent base time and the values needed to convert a hardware counter into elapsed nanoseconds.

The kernel source compiled into the vDSO makes that read loop visible. With the capability check and declarations left out, current do_hres contains:

do {
    if (vdso_read_begin_timens(vc, &seq))
        return do_hres_timens(vd, vc, clk, ts);

    if (!vdso_get_timestamp(vd, vc, clk, &sec, &ns))
        return false;
} while (vdso_read_retry(vc, seq));

vdso_set_timespec(ts, sec, ns);
return true;

This is an unusual-looking piece of kernel source: after being built into the vDSO image, these instructions execute in the calling process. A false result tells the outer clock path that this case cannot be answered safely here, so it should use the syscall fallback. A successful read produces sec and ns without leaving userspace.

For a TSC clocksource, the simplified calculation is:

$$ \text{now} = \text{base} + \frac{(\text{cycles} - \text{cycle_last}) \times \text{mult}}{2^{\text{shift}}} $$

The actual vdso_calc_ns code also deals with masking, overflow, and the possibility of a slightly backward TSC observation. The base seconds and nanoseconds, cycle_last, mult, and shift come from [vvar]. The current cycle count comes directly from an x86 instruction.

__arch_get_hw_counter selects that instruction from the clock mode. For VDSO_CLOCKMODE_TSC it calls rdtsc_ordered(). The kernel's alternatives mechanism patches this site for the CPU's available ordering instruction. In Intel syntax the possible sequences are equivalent to:

rdtsc
lfence
rdtsc

or:

rdtscp

The two halves of the TSC are then combined into one 64-bit cycle value. A paravirtual guest may instead read a pvclock or Hyper-V clock page from the additional vvar mapping.

reading while the kernel writes

There is still a race to handle. The kernel could update the multiplier or base time while the process is halfway through reading them.

The vDSO data therefore includes a sequence counter. The high-resolution path does roughly this:

read sequence
if sequence is odd, wait and retry
read base and conversion values
read hardware cycles
read sequence again
if it changed, retry everything
calculate the timestamp

An odd sequence value means a writer is in progress. Equal even values before and after the read mean the snapshot was consistent. The current helpers live in include/vdso/helpers.h.

The writer is on the kernel side. update_vsyscall opens the sequence, updates the clock mode, cycle base, multiplier, shift, base times, and related values, then closes the sequence. Userspace gets a coherent snapshot without a lock and without entering the kernel.

Time namespaces add one more step. A process in a time namespace sees namespace offsets in its vvar page while the underlying host clock data comes from the host page. The helper combines the two before returning the result.

Coarse clocks take a shorter route: they return the kernel's cached base time without reading a hardware cycle counter. They are cheaper but intentionally have lower resolution.

when it still becomes a syscall

The vDSO is a fast path, not a promise that every clock_gettime call avoids the kernel. It falls back when, for example:

  • the requested clock ID has no vDSO implementation.
  • the active clocksource cannot be read safely from userspace.
  • the kernel does not export the expected symbol.
  • an architecture-specific time-width path is unavailable.

The generic vDSO code uses its inline syscall fallback for unsupported cases. glibc also has a syscall path when symbol resolution failed.

To compare the two paths deliberately, the libc wrapper can be bypassed:

#include <sys/syscall.h>
#include <time.h>
#include <unistd.h>

struct timespec ts;
syscall(SYS_clock_gettime, CLOCK_REALTIME, &ts);

Now strace -e clock_gettime sees the call. On current Linux the real kernel handler is still defined in kernel/time/posix-timers.c with SYSCALL_DEFINE2(clock_gettime, ...). That path selects the clock's kernel implementation and copies the resulting timespec back to userspace.

rust reaches the same code

The Rust standard library example is just as small as before:

use std::time::SystemTime;

fn main() {
    println!("{:?}", SystemTime::now());
}

On a normal GNU/Linux target, Rust does not parse the vDSO itself. Its current Unix time backend eventually asks for CLOCK_REALTIME, and Timespec::now calls libc::clock_gettime.

The path is therefore:

SystemTime::now
  -> Rust's Unix time backend
  -> Timespec::now(CLOCK_REALTIME)
  -> libc::clock_gettime
  -> glibc's resolved vDSO function
  -> __vdso_clock_gettime

There is extra handling on 32-bit GNU/Linux for the time64 transition, but the decision still ends in a libc clock function. Other targets, especially non-glibc or statically linked ones, can use a different libc implementation while presenting the same Rust API.

This explains why strace normally shows no clock_gettime system call for the Rust program either.

python reaches the same code

Python adds more layers but ends up at the same interface:

import time

print(time.time_ns())

The names in my original notes had become stale. In current CPython the route is:

time.time / time.time_ns
  -> time_time / time_time_ns
  -> PyTime_Time
  -> py_get_system_clock
  -> clock_gettime(CLOCK_REALTIME)
  -> glibc's vDSO path

The module entry points are in Modules/timemodule.c. The platform work happens in Python/pytime.c, where py_get_system_clock uses clock_gettime(CLOCK_REALTIME) on a Unix system which provides it, and keeps gettimeofday as a fallback.

So CPython does not contain a separate vDSO implementation. It calls the normal C library API and inherits glibc's resolved fast path. time.time() converts the result to a Python float, while time.time_ns() keeps integer nanoseconds. The operating-system clock lookup below them is shared.

putting the pieces together

For the common CLOCK_REALTIME case, the complete route is:

C clock_gettime / Rust SystemTime::now / Python time.time
  -> libc clock_gettime
  -> function pointer resolved from the vDSO ELF
  -> __vdso_clock_gettime executes in userspace
  -> read a consistent base and conversion data from [vvar]
  -> read the hardware or paravirtual cycle counter
  -> calculate the current timestamp
  -> return without a system call

The vDSO works because the kernel split the operation into two parts. The kernel maintains the privileged and slowly changing clock state, while a small kernel-supplied userspace function combines that state with a counter the process is allowed to read. The sequence counter makes concurrent updates detectable, and the syscall path remains available whenever the shortcut cannot answer safely.

That is more machinery than the original clock_gettime call suggests, but it moves one of the most frequently requested pieces of kernel information out of the syscall path without giving the process control over it.

source trail