vDSO Symbols and How libc Uses Them

The kernel maps the vDSO into every process and tells the process where it is through the auxiliary-vector entry AT_SYSINFO_EHDR — but it is entirely userspace’s job to find, parse, and bind the vDSO’s symbols. This note traces that consumer side end to end: how a program reads AT_SYSINFO_EHDR (via getauxval(3)), how it treats the base address as an ELF header and walks the program headers and dynamic table to locate the symbol and hash tables, and how it resolves a versioned symbol such as __vdso_clock_gettime to a callable function pointer. There are two real consumers in practice — glibc’s production path, where the dynamic linker builds a genuine link_map for the vDSO at startup and caches resolved function pointers that the time wrappers call with an automatic syscall fallback; and the parse_vdso.c reference helper, a self-contained DIY parser the kernel ships for static or non-glibc programs. Both rely on the same handshake the kernel set up in The vDSO Virtual Dynamic Shared Object; this note assumes that mapping already exists and focuses on what userspace does with it.

All glibc code below is from glibc 2.40 (2024-07; the relevant logic is long-stable) and all kernel code from the Linux 6.12 LTS tree, x86-64.

Mental Model

Think of vDSO consumption as late binding against a library nobody linked. A normal shared library is named in the executable’s DT_NEEDED list, found on disk by the dynamic linker, mapped, and relocated. The vDSO is none of that — it is already mapped, already prelinked, and not named anywhere. So userspace must discover it at runtime from a single pointer the kernel left on the stack, then perform the symbol lookup itself. glibc does this elegantly by manufacturing a fake-but-real link_map entry so that its ordinary symbol-resolution machinery (dl_lookup_symbol_x) can search the vDSO exactly as it would any DSO; programs that do not have a dynamic linker do it the hard, explicit way with parse_vdso.c. In both cases the output is the same: a function pointer to (say) __vdso_clock_gettime, which the caller invokes directly — and, critically, checks for NULL so that a kernel which did not provide the vDSO transparently degrades to a real syscall.

flowchart TB
  KERN["Kernel at execve:<br/>maps [vdso], pushes<br/>AT_SYSINFO_EHDR onto stack"]
  KERN --> AUX["auxv on process stack"]
  AUX -->|"getauxval(AT_SYSINFO_EHDR)"| BASE["vDSO base address<br/>(ELF Ehdr*)"]
  BASE --> GLIBC["glibc path:<br/>setup_vdso() builds link_map"]
  BASE --> DIY["DIY path:<br/>vdso_init_from_sysinfo_ehdr()<br/>(parse_vdso.c)"]
  GLIBC -->|"dl_vdso_vsym('__vdso_clock_gettime')"| PTR["function pointer"]
  DIY -->|"vdso_sym('LINUX_2.6', '__vdso_clock_gettime')"| PTR
  PTR -->|"ptr != NULL?"| CALL["call vDSO fn (no trap)"]
  PTR -->|"ptr == NULL"| FALL["fall back to<br/>real syscall"]

The two userspace routes from the kernel’s AT_SYSINFO_EHDR pointer to a callable vDSO function. What it shows: both glibc and the reference parser start from the same auxv pointer, walk the same ELF structures, and end at a function pointer that is always NULL-checked. The insight to take: the kernel only hands over an address; resolution and the syscall fallback are pure userspace policy, which is why the vDSO is optional and why a program built against a vDSO-less kernel still works.

Step 1 — Reading the Pointer: getauxval(AT_SYSINFO_EHDR)

The auxiliary vector is the array of (type, value) pairs the kernel pushes onto the new process’s stack at execve, immediately after the environment strings. glibc squirrels it away during startup and exposes it through getauxval(3):

#include <sys/auxv.h>
unsigned long getauxval(unsigned long type);

getauxval(AT_SYSINFO_EHDR) returns the base address of the page containing the vDSO’s ELF header — the value the kernel set in current->mm->context.vdso and emitted via ARCH_DLINFO (see The vDSO Virtual Dynamic Shared Object). The man page documents the return contract precisely: on success it returns the value; “If type is not found, 0 is returned,” and since glibc 2.19 a missing entry additionally sets errno to ENOENT so a genuine zero value can be distinguished from “absent” (getauxval(3)). getauxval itself was added in glibc 2.16. A zero return for AT_SYSINFO_EHDR means the kernel mapped no vDSO (e.g. booted vdso=0), and the caller must fall back to real syscalls.

It is worth distinguishing the related auxv entries the manual lists:

  • AT_SYSINFO_EHDR — “The address of a page containing the virtual Dynamic Shared Object (vDSO).” This is the one every architecture’s vDSO consumer uses.
  • AT_SYSINFO — “The entry point to the system call function in the vDSO.” This is an IA-32 / 32-bit concept (a pointer to __kernel_vsyscall), used so 32-bit code can issue a syscall via the kernel’s preferred instruction. On native x86-64 the kernel does not emit AT_SYSINFO (verified in ARCH_DLINFO in The vDSO Virtual Dynamic Shared Object); 64-bit userspace issues syscall directly. Conflating the two is a common error.
  • AT_HWCAP — unrelated to the vDSO; a CPU-capability bitmask.

Step 2 — Treating the Base as ELF: Walking the Program Headers and Dynamic Table

Once it has the base address, userspace casts it to an ELF header (Elf64_Ehdr *) and parses the DSO. The kernel’s reference parser, tools/testing/selftests/vDSO/parse_vdso.c (written by Andy Lutomirski), is the canonical worked example and shows exactly what is needed. The general ELF machinery — Ehdr, program headers (Phdr), the PT_LOAD and PT_DYNAMIC segments, the dynamic-table tags — is covered in ELF Format; here is how the vDSO parser uses them.

First it sanity-checks the ELF class and walks the program headers to find two things: the load offset (the difference between where the DSO is actually mapped and the virtual address it was prelinked for) and the dynamic table:

ELF(Phdr) *pt = (ELF(Phdr)*)(vdso_info.load_addr + hdr->e_phoff);
ELF(Dyn) *dyn = 0;
 
for (i = 0; i < hdr->e_phnum; i++) {
	if (pt[i].p_type == PT_LOAD && !found_vaddr) {
		found_vaddr = true;
		vdso_info.load_offset = base
			+ (uintptr_t)pt[i].p_offset
			- (uintptr_t)pt[i].p_vaddr;
	} else if (pt[i].p_type == PT_DYNAMIC) {
		dyn = (ELF(Dyn)*)(base + pt[i].p_offset);
	}
}

The load_offset is the crucial correction: because the vDSO is prelinked to some virtual address but ASLR maps it elsewhere, every recorded virtual address inside the DSO (symbol values, table pointers) must be adjusted by load_offset to become a real pointer. Next it walks the dynamic table to fish out the symbol string table (DT_STRTAB), the symbol table (DT_SYMTAB), the ELF hash table (DT_HASH), and the GNU symbol-versioning tables (DT_VERSYM, DT_VERDEF), applying load_offset to each:

for (i = 0; dyn[i].d_tag != DT_NULL; i++) {
	switch (dyn[i].d_tag) {
	case DT_STRTAB: vdso_info.symstrings = (const char *)
			((uintptr_t)dyn[i].d_un.d_ptr + vdso_info.load_offset); break;
	case DT_SYMTAB: vdso_info.symtab = (ELF(Sym) *)
			((uintptr_t)dyn[i].d_un.d_ptr + vdso_info.load_offset); break;
	case DT_HASH:   hash = (ELF_HASH_ENTRY *)
			((uintptr_t)dyn[i].d_un.d_ptr + vdso_info.load_offset); break;
	case DT_VERSYM: ...; break;
	case DT_VERDEF: ...; break;
	}
}

Finally it reads the hash-table header (nbucket, nchain, and the bucket/chain arrays) so symbol lookups can be done in (near) constant time rather than by linear scan. After this the parser is “valid” and ready to resolve symbols.

Step 3 — Resolving a Versioned Symbol: vdso_sym

vDSO symbols carry GNU symbol versions (all under LINUX_2.6 on x86-64; see the version script in The vDSO Virtual Dynamic Shared Object), so resolution matches both name and version. parse_vdso.c’s vdso_sym(version, name) does the classic ELF hash lookup, then filters by symbol type/binding/definedness and finally by version:

void *vdso_sym(const char *version, const char *name)
{
	unsigned long ver_hash = elf_hash(version);
	ELF(Word) chain = vdso_info.bucket[elf_hash(name) % vdso_info.nbucket];
 
	for (; chain != STN_UNDEF; chain = vdso_info.chain[chain]) {
		ELF(Sym) *sym = &vdso_info.symtab[chain];
 
		/* Defined global or weak function with the right name. */
		if (ELF64_ST_TYPE(sym->st_info) != STT_FUNC &&
		    ELF64_ST_TYPE(sym->st_info) != STT_NOTYPE) continue;
		if (ELF64_ST_BIND(sym->st_info) != STB_GLOBAL &&
		    ELF64_ST_BIND(sym->st_info) != STB_WEAK)   continue;
		if (sym->st_shndx == SHN_UNDEF) continue;
		if (strcmp(name, vdso_info.symstrings + sym->st_name)) continue;
 
		/* Check symbol version. */
		if (vdso_info.versym &&
		    !vdso_match_version(vdso_info.versym[chain], version, ver_hash))
			continue;
 
		return (void *)(vdso_info.load_offset + sym->st_value);
	}
	return 0;
}

The returned pointer is load_offset + sym->st_value — the prelinked symbol value corrected for where the DSO actually landed. A consumer thus binds the function in two lines:

extern char **environ;
/* ... locate auxv from environ end ... */
vdso_init_from_auxv(auxv);                 /* finds AT_SYSINFO_EHDR, parses */
typeof(clock_gettime) *cg =
	(void *)vdso_sym("LINUX_2.6", "__vdso_clock_gettime");
if (cg) cg(CLOCK_MONOTONIC, &ts);          /* no trap */
else    clock_gettime(CLOCK_MONOTONIC, &ts); /* fallback */

vdso_init_from_auxv itself just scans for the entry the kernel left:

void vdso_init_from_auxv(void *auxv) {
	ELF(auxv_t) *elf_auxv = auxv;
	for (int i = 0; elf_auxv[i].a_type != AT_NULL; i++)
		if (elf_auxv[i].a_type == AT_SYSINFO_EHDR) {
			vdso_init_from_sysinfo_ehdr(elf_auxv[i].a_un.a_val);
			return;
		}
	vdso_info.valid = false;
}

This DIY path is what you use in a statically-linked binary, in a non-glibc libc, or in a language runtime that bypasses libc (Go, for instance, issues raw syscalls and historically read the clock via its own vDSO parsing rather than glibc — see System Calls and the Scheduler).

A dynamically-linked glibc program does not run parse_vdso.c; glibc does the resolution during startup, more elegantly, by feeding the vDSO into its existing dynamic-linker data structures. In elf/setup-vdso.h, setup_vdso() is called early in ld.so’s initialization. If the kernel supplied the vDSO header (GLRO(dl_sysinfo_dso), which glibc populated from AT_SYSINFO_EHDR), it constructs a brand-new struct link_map — the same structure that represents any loaded shared object — and fills it in as if glibc had just mapped and relocated the DSO itself:

struct link_map *l = _dl_new_object ((char *) "", "", lt_library, NULL,
				     __RTLD_VDSO, LM_ID_BASE);
/* ... walk PT_DYNAMIC / PT_LOAD program headers, set l_ld, l_addr ... */
elf_get_dynamic_info (l, false, false);
_dl_setup_hash (l);
l->l_relocated = 1;
/* ... add to the namespace object list ... */
GLRO(dl_sysinfo_map) = l;

The comment is explicit about the trick: “Do an abridged version of the work _dl_map_object_from_fd would do to map in the object. It’s already mapped and prelinked … We just want our data structures to describe it as if we had just mapped and relocated it normally.” Because the result is a real link_map (GLRO(dl_sysinfo_map)), glibc can now resolve vDSO symbols with its standard symbol-lookup routine. That is what dl_vdso_vsym() (in sysdeps/unix/sysv/linux/dl-vdso.h) does:

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 };
 
  const ElfW (Sym) *ref = &wsym;
  lookup_t result = GLRO (dl_lookup_symbol_x) (name, map, &ref,
					       map->l_local_scope, &rfv, 0, 0, NULL);
  return ref != NULL ? DL_SYMBOL_ADDRESS (result, ref) : NULL;
}

Two details matter. First, the lookup is a weak reference (STB_WEAK), so an unresolved symbol returns NULL rather than aborting the process — the whole point being graceful degradation. Second, the version it matches against, r_found_version, is built from the per-architecture macros VDSO_NAME and VDSO_HASH. On x86-64 (sysdeps/unix/sysv/linux/x86_64/sysdep.h, glibc 2.40):

# define VDSO_NAME  "LINUX_2.6"
# define VDSO_HASH  61765110

VDSO_HASH is the ELF hash of the version string "LINUX_2.6" (precomputed so glibc need not hash it at runtime), matching the LINUX_2.6 version node the kernel’s vdso.lds.S emits. The same header declares which functions glibc will look for, via the HAVE_*_VSYSCALL macros that name the vDSO symbol:

# 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"

For each defined macro, glibc keeps a cached function pointer in a per-arch structure (dl-vdso-setup.c):

# ifdef HAVE_CLOCK_GETTIME64_VSYSCALL
PROCINFO_CLASS int (*_dl_vdso_clock_gettime64) (clockid_t,
						struct __timespec64 *) RELRO;
#endif
# ifdef HAVE_GETTIMEOFDAY_VSYSCALL
PROCINFO_CLASS int (*_dl_vdso_gettimeofday) (struct timeval *, void *) RELRO;
#endif
/* ... time, getcpu, clock_getres ... */

These pointers (RELRO = placed in read-only-after-relocation memory, so they can’t be hijacked post-startup) are filled once via dl_vdso_vsym and thereafter the wrappers just call through them.

Step 5 — Calling It With a Fallback: clock_gettime and INLINE_VSYSCALL

The payoff is in the syscall wrappers (see libc Syscall Wrappers and errno Translation for the general wrapper/errno story). glibc’s __clock_gettime64 (sysdeps/unix/sysv/linux/clock_gettime.c) shows the canonical pattern: try the vDSO pointer if non-NULL; otherwise issue the real syscall:

#ifdef HAVE_CLOCK_GETTIME64_VSYSCALL
  int (*vdso_time64) (clockid_t, struct __timespec64 *)
    = 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);
  }
#endif
  /* ... 32-bit-time vDSO attempt, then: */
  r = INTERNAL_SYSCALL_CALL (clock_gettime64, clock_id, tp);
  if (r == 0) return 0;
  if (r != -ENOSYS) return INLINE_SYSCALL_ERROR_RETURN_VALUE (-r);

If GLRO(dl_vdso_clock_gettime64) was resolved, the call goes through the vDSO with no trap; the result is checked, and a negative return is turned into an errno/-1 exactly as for a real syscall. If the pointer is NULL (no vDSO), control falls straight through to INTERNAL_SYSCALL_CALL, the genuine clock_gettime64 syscall. The reusable form of this “vDSO-then-syscall” dance is the INLINE_VSYSCALL macro in sysdep-vdso.h:

#define INLINE_VSYSCALL(name, nr, args...)				\
  ({									\
    long int sc_ret;							\
    __typeof (GLRO(dl_vdso_##name)) vdsop = GLRO(dl_vdso_##name);	\
    if (vdsop != NULL) {						\
	sc_ret = INTERNAL_VSYSCALL_CALL (vdsop, nr, ##args);		\
	if (!INTERNAL_SYSCALL_ERROR_P (sc_ret)) goto out;		\
	if (INTERNAL_SYSCALL_ERRNO (sc_ret) != ENOSYS) goto iserr;	\
    }									\
    sc_ret = INTERNAL_SYSCALL_CALL (name, ##args);  /* fallback */	\
    /* ... errno handling ... */					\
  out: sc_ret; })

The logic reads: if the vDSO pointer exists, call it; if it succeeded, done; if it failed with anything other than ENOSYS, that’s the real error (set errno, return -1); only if the vDSO is absent or returned ENOSYS do we issue the real syscall. ENOSYS is the signal the vDSO uses to say “I can’t serve this in userspace right now” (e.g. an unsupported clocksource), prompting a clean fallback rather than a wrong answer.

Failure Modes and Common Misunderstandings

  • NULL-check omission. A DIY consumer that calls vdso_sym(...)’s result without checking for NULL will segfault on a kernel that mapped no vDSO (vdso=0) or on an architecture lacking the symbol. The kernel guarantees nothing; the NULL check is the contract. glibc’s use of a weak reference enforces this structurally.
  • Wrong version string. Resolving __vdso_clock_gettime without matching the LINUX_2.6 version can pick up the wrong (or no) symbol on architectures or future kernels that introduce a new version node. parse_vdso.c takes the version explicitly for this reason; glibc bakes it into VDSO_NAME/VDSO_HASH.
  • Forgetting load_offset. Using sym->st_value directly (without adding the load offset) yields a pointer valid only for the prelink address, which ASLR has moved. This is the single most common bug when hand-rolling a vDSO parser.
  • Assuming getauxval is cheap/always present. On very old glibc (< 2.16) getauxval does not exist; portable code reads the auxv by scanning past environ (as parse_vdso.c’s harness does). Even where it exists, a 0 return needs the errno == ENOENT (glibc ≥ 2.19) disambiguation if 0 could be a legitimate value (not an issue for AT_SYSINFO_EHDR, where 0 always means “no vDSO”).
  • strace shows no clock_gettime. Because a successfully-bound vDSO call never traps, it generates no sys_enter/sys_exit and is invisible to strace/ltrace/seccomp. People debugging “where did my syscall go?” are seeing the vDSO at work. Forcing vdso=0 makes the calls visible again.

Alternatives and When to Choose Them

  • glibc’s automatic binding is what you get for free in any normal dynamically-linked C program: include <time.h>, call clock_gettime, and the vDSO is used transparently with a syscall fallback. Choose this always unless you have a specific reason not to.
  • parse_vdso.c (DIY) is for static binaries, alternative libcs (musl does its own vDSO resolution), or runtimes that bypass libc. It is more code and you own the NULL-check/version-match correctness, but it has no glibc dependency.
  • Calling the raw syscall and skipping the vDSO is occasionally deliberate — e.g. a tracer that wants every clock read to trap so it can observe it, or a test forcing the slow path. You lose the performance but gain observability.

Production Notes

  • The pointers are RELRO. glibc stores the resolved vDSO function pointers in relocation-read-only memory and resolves them once at startup, so an attacker who gains a write primitive after startup cannot repoint clock_gettime at arbitrary code through this table — a deliberate hardening choice given that these pointers are called on a hot path.

  • musl vs glibc. musl libc implements its own compact vDSO lookup rather than reusing a dynamic-linker link_map; the mechanism differs but the handshake (AT_SYSINFO_EHDR → parse ELF → version-matched symbol → NULL-checked call) is identical, because it is dictated by the kernel-supplied object, not by any one libc.

    Uncertain __vdsosym) and binds the same LINUX_2.6 symbols. Reason: the bminor/musl mirror path/tag I tried (src/internal/vdso.c at v1.2.5) returned 404, so I could not confirm the exact file/function name in this task. The handshake description is correct on first principles (it is kernel-dictated); the specific musl internal name is unverified. To resolve: read musl's arch/*/syscall_arch.h and the vDSO helper in the current release tree. uncertain

    Verify: that musl’s vDSO resolver lives in a single internal helper (the function is sometimes referred to as

  • Go and other runtimes. Go’s runtime reads AT_SYSINFO_EHDR from the auxv during start and parses the vDSO itself to accelerate clock reads, precisely because it does not link glibc. Verified against runtime/vdso_linux.go and runtime/vdso_linux_amd64.go (Go 1.23): the runtime defines _AT_SYSINFO_EHDR = 33, scans the auxv for it, runs its own vdsoParseSymbols, matches the vdsoLinuxVersion key {"LINUX_2.6", 0x3ae75f6}, and resolves __vdso_gettimeofday and __vdso_clock_gettime into vdsoGettimeofdaySym/vdsoClockgettimeSym — the same parse_vdso.c-style path reimplemented in Go. Note Go binds only the two time symbols, not the full set glibc does. See libc Syscall Wrappers and errno Translation for the contrasting libc path, and System Calls and the Scheduler for how Go’s runtime coordinates blocking syscalls with its scheduler.

  • time namespaces. When the consuming process is in a non-root time namespace, the same resolved function pointer transparently reads the namespace-adjusted vvar data the kernel faulted in — userspace resolution is unchanged; only the data the function reads differs. The read itself is detailed in How the vDSO Reads Kernel Time Without Trapping.

See Also