vDSO (Virtual Dynamic Shared Object)

vDSO는 리눅스 커널이 유저 공간 프로세스(Process)의 주소 공간(Address Space)에 자동으로 매핑(Mapping)하는 작은 공유 라이브러리(Shared Library)입니다. clock_gettime(), gettimeofday(), time(), getcpu() 같은 빈번히 호출되는 시스템 콜(System Call)을 커널 모드 전환 없이 유저 공간에서 직접 실행할 수 있게 하여 수십 나노초의 오버헤드(Overhead)를 제거합니다. 이 문서는 vDSO의 내부 아키텍처, 커널 구현, 아키텍처별 차이, 성능 특성, 디버깅(Debugging) 기법까지 종합적으로 다룹니다.

전제 조건: 시스템 콜 (System Call)ELF (Executable and Linkable Format) 문서를 먼저 읽으세요. vDSO를 이해하려면 시스템 콜의 커널 모드 전환 비용과 ELF 공유 라이브러리의 동적 링킹(Dynamic Linking) 메커니즘에 대한 기본 지식이 필요합니다.
일상 비유: vDSO는 은행 ATM과 비슷합니다. 매번 은행 창구(커널)에 가서 줄을 서는 대신, 로비에 설치된 ATM(vDSO)에서 잔액 조회(시간 읽기) 같은 간단한 작업을 즉시 처리할 수 있습니다. ATM에 표시되는 잔액 정보는 은행 시스템(커널)이 주기적으로 업데이트하므로 항상 최신 상태를 유지합니다.

핵심 요약

  • vDSO -- 커널이 유저 공간에 매핑하는 가상 공유 라이브러리. 시스템 콜 없이 커널 데이터에 접근 가능
  • vvar -- 커널이 업데이트하고 유저 공간이 읽기 전용(Read-Only)으로 접근하는 공유 데이터 페이지(Page)
  • AT_SYSINFO_EHDR -- ELF auxiliary vector를 통해 전달되는 vDSO의 기저 주소
  • vsyscall -- vDSO의 전신. 고정 주소 사용으로 ASLR 불가, 보안 취약점(Vulnerability) 존재
  • seqcount -- vvar 데이터의 일관성을 보장하는 순서 잠금(Lock) 메커니즘

단계별 이해

  1. 프로세스가 시작됨
    커널의 execve() 경로에서 ELF 바이너리를 로드한 후, arch_setup_additional_pages()가 vDSO 이미지를 프로세스 주소 공간에 매핑합니다.
  2. vDSO 주소가 전달됨
    커널은 ELF auxiliary vector의 AT_SYSINFO_EHDR 엔트리에 vDSO 기저 주소를 기록합니다. 동적 링커(ld.so)가 이를 읽고 vDSO 심볼을 연결합니다.
  3. glibc가 vDSO 함수를 사용
    clock_gettime()을 호출하면 glibc는 vDSO의 __vdso_clock_gettime()을 직접 호출합니다. 시스템 콜 진입 없이 vvar 페이지에서 시간 데이터를 읽습니다.
  4. 커널이 vvar를 갱신
    타이머(Timer) 인터럽트(Interrupt)나 timekeeping 업데이트 시 커널은 vvar 페이지의 vdso_data 구조체(Struct)를 갱신합니다. seqcount로 원자적(Atomic) 일관성을 보장합니다.

vDSO 개요와 탄생 배경

시스템 콜 오버헤드 문제

리눅스에서 유저 공간 프로세스가 커널 서비스를 요청하려면 시스템 콜을 통해 커널 모드로 전환해야 합니다. x86_64에서 SYSCALL 명령어를 통한 모드 전환은 다음과 같은 비용을 수반합니다:

이러한 오버헤드는 단일 시스템 콜당 약 100~300 나노초(KPTI 활성 시)에 달합니다. gettimeofday()clock_gettime()처럼 초당 수백만 번 호출되는 함수에서는 이 비용이 전체 성능에 심각한 영향을 미칩니다.

vsyscall의 등장과 한계

리눅스 커널 2.5 시대에 x86 아키텍처에서 vsyscall 메커니즘이 도입되었습니다. 고정된 가상 주소(Virtual Address) 0xffffffffff600000에 특수 페이지를 매핑하여 gettimeofday(), time(), getcpu() 3개 함수를 시스템 콜 없이 호출할 수 있게 했습니다.

그러나 vsyscall에는 근본적인 보안 문제가 있었습니다:

vDSO의 탄생

커널 2.6.x에서 vDSO(Virtual Dynamic Shared Object)가 vsyscall의 후속으로 도입되었습니다. vDSO는 표준 ELF 공유 라이브러리 형식을 사용하므로 동적 링커가 자연스럽게 처리할 수 있고, ASLR을 완전히 지원합니다. 커널이 프로세스 생성 시 무작위 주소에 vDSO를 매핑하므로 ROP 공격에 대한 방어가 가능합니다.

특성vsyscall (레거시)vDSO
매핑 주소고정 (0xffffffffff600000)ASLR로 무작위
형식특수 페이지표준 ELF shared object
함수 수3개 고정아키텍처별 확장 가능
보안ROP 가젯 위험ASLR 완전 지원
아키텍처x86_64 전용x86, ARM64, ARM, RISC-V, MIPS, PowerPC 등
glibc 통합하드코딩 주소AT_SYSINFO_EHDR + ELF 심볼 조회
커널 버전2.5+2.6+
참고: 최신 커널에서 vsyscall 페이지는 기본적으로 에뮬레이션 모드(vsyscall=emulate)로 동작합니다. 레거시 호환성을 위해 vsyscall 주소에 접근하면 실제 시스템 콜로 대체 실행되며, vsyscall=none으로 완전히 비활성화할 수도 있습니다.

vDSO 아키텍처

전체 구조

vDSO 메커니즘은 커널 측과 유저 측의 긴밀한 협력으로 작동합니다. 커널은 두 종류의 특수 페이지를 프로세스 주소 공간에 매핑합니다:

  1. vDSO 코드 페이지 -- 실행 가능한 ELF 공유 라이브러리 이미지. __vdso_clock_gettime() 등의 함수 코드를 포함
  2. vvar 데이터 페이지 -- 읽기 전용 데이터 페이지. 커널이 갱신하는 시간 데이터(vdso_data), 아키텍처별 데이터 등을 포함

유저 공간에서 vDSO 함수를 호출하면, 함수는 vvar 페이지의 데이터를 읽어 결과를 계산합니다. 커널 모드 전환이 전혀 발생하지 않으므로 시스템 콜 대비 10~50배 빠릅니다.

프로세스 가상 주소 공간 .text / .data / .bss (프로그램 코드 및 데이터) Heap (brk/mmap) libc.so, libpthread.so, ... vvar 페이지 (읽기 전용) vdso_data: 시간, 클럭 데이터 vDSO 코드 페이지 (실행 가능) __vdso_clock_gettime, __vdso_gettimeofday, ... Stack [vsyscall] 0xffffffffff600000 (레거시, 에뮬레이션 모드) 커널 공간 vdso_image ELF 바이너리 (빌드 시 생성, .rodata) vdso_data (커널 변수) seq, clock_mode, cycle_last, mult, shift, wall_time_*, ... timekeeping 서브시스템 update_vsyscall() 호출 하드웨어 클럭 TSC, HPET, ACPI PM Timer arch_setup_additional_pages() execve() 시 vDSO/vvar 매핑 mmap (읽기/실행) mmap (읽기전용)

vDSO 함수 호출 흐름

유저 공간에서 clock_gettime(CLOCK_MONOTONIC, &ts)를 호출하면 다음 경로를 따릅니다:

  1. glibc의 clock_gettime() 래퍼가 vDSO의 __vdso_clock_gettime() 함수 포인터를 호출
  2. vDSO 함수가 vvar 페이지의 vdso_data에서 seq(seqcount)를 읽음
  3. 현재 TSC 값을 RDTSC 명령어로 읽음
  4. vdso_datacycle_last, mult, shift를 사용하여 경과 시간 계산
  5. wall_time_sec, wall_time_snsec에 경과 시간을 더하여 최종 시간 산출
  6. seq 값이 변경되지 않았는지 확인 (변경되었으면 1단계부터 재시도)
  7. 결과를 struct timespec에 저장하고 반환
핵심: 전체 과정에서 SYSCALL/SYSRET 명령어가 실행되지 않습니다. 유저 모드(Ring 3)에서 완전히 실행되므로 커널 모드 전환 비용이 0입니다. vDSO가 지원하지 않는 클럭 ID(예: CLOCK_PROCESS_CPUTIME_ID)의 경우에만 실제 시스템 콜로 폴백합니다.

vsyscall과 vDSO 비교

vsyscall 페이지의 내부 구조

vsyscall은 x86_64에서만 존재하는 레거시 메커니즘입니다. 커널은 고정 가상 주소 0xffffffffff600000에 1페이지(4KB)를 매핑하고, 정확히 3개의 함수 슬롯을 배치했습니다:

가상 주소함수오프셋(Offset)
0xffffffffff600000gettimeofday()+0x000
0xffffffffff600400time()+0x400
0xffffffffff600800getcpu()+0x800

이 주소는 모든 프로세스에서 동일합니다. 공격자가 메모리 취약점을 이용하여 실행 흐름을 조작할 때, vsyscall 페이지의 RET 명령어 등을 ROP 가젯으로 활용할 수 있었습니다.

vsyscall 에뮬레이션 모드

현대 커널은 vsyscall 페이지를 3가지 모드로 제어합니다:

커널 파라미터CONFIG 옵션동작보안 수준
vsyscall=emulate CONFIG_LEGACY_VSYSCALL_EMULATE vsyscall 주소 접근 시 페이지 폴트(Page Fault) 발생 -> 커널이 실제 시스템 콜로 에뮬레이션 중간 (기본값)
vsyscall=xonly CONFIG_LEGACY_VSYSCALL_XONLY 실행만 가능, 읽기 불가 (데이터 누출 방지) 높음
vsyscall=none CONFIG_LEGACY_VSYSCALL_NONE vsyscall 페이지 완전 제거. 접근 시 SIGSEGV 최고
주의: vsyscall=none을 사용하면 매우 오래된 정적 링크 바이너리(glibc 2.13 이전)가 작동하지 않을 수 있습니다. 현대 시스템에서는 대부분 vDSO를 사용하므로 문제가 없지만, 레거시 환경에서는 vsyscall=emulate(기본값)을 유지해야 합니다.

보안 관점에서의 비교

보안 속성vsyscallvDSO
ASLR불가 (고정 주소)완전 지원 (mmap 무작위화)
NX (No Execute)전체 페이지 실행 가능코드/데이터 분리된 ELF 세그먼트
ROP 가젯 활용쉬움 (주소 예측 가능)어려움 (주소 무작위)
정보 누출가능 (emulate 모드 제외)ELF 메타데이터만 노출
seccomp 필터링에뮬레이션 모드에서만 가능폴백 시스템 콜에 대해 가능

vsyscall 에뮬레이션 내부 흐름

vsyscall=emulate 모드에서 프로그램이 vsyscall 고정 주소에 접근하면, CPU는 페이지 폴트(Page Fault)를 발생시킵니다. 커널의 페이지 폴트 핸들러가 이를 감지하고 해당 시스템 콜을 에뮬레이션합니다:

vsyscall 에뮬레이션 내부 흐름 (vsyscall=emulate) 레거시 앱 call 0xffffffffff600000 #PF (Page Fault) 실행 권한 없음 (--xp) emulate_vsyscall() arch/x86/entry/vsyscall/vsyscall_64.c faulting RIP → 함수 슬롯 결정 주소 → 함수 슬롯 매핑 RIP == 0xffffffffff600000 → __NR_gettimeofday RIP == 0xffffffffff600400 → __NR_time RIP == 0xffffffffff600800 → __NR_getcpu seccomp / audit 검사 에뮬레이션이므로 seccomp 필터 적용됨 실제 시스템 콜 실행 → 결과 반환 레지스터에 결과 기록 후 유저 복귀 비교: 현대 앱 (vDSO 경로) glibc → __vdso_clock_gettime() → 페이지 폴트 없음, 커널 진입 없음 → ~15-25ns (에뮬레이션: ~300-500ns) 에뮬레이션 비용 분석 1. #PF 예외 처리: ~50-100ns 2. 슬롯 판별 + seccomp: ~20-50ns 3. 실제 시스템 콜 실행: ~60-200ns 총합: ~300-500ns (vDSO 대비 20~30배 느림)
에뮬레이션의 함정: vsyscall 에뮬레이션은 매 호출마다 페이지 폴트를 유발하므로, 원래 vsyscall의 성능 이점이 완전히 사라집니다. 사실상 일반 시스템 콜보다 더 느립니다. 레거시 바이너리의 호환성만을 위한 메커니즘이며, 성능 최적화 목적으로는 반드시 vDSO를 사용해야 합니다.

ELF Auxiliary Vector와 vDSO

Auxiliary Vector 개요

ELF auxiliary vector는 커널이 execve() 시 유저 공간 스택에 배치하는 키-값 쌍 배열입니다. 동적 링커(ld-linux-x86-64.so.2)와 C 라이브러리가 런타임 환경 정보를 얻기 위해 사용합니다. vDSO와 관련된 주요 항목은 다음과 같습니다:

키 (AT_*)정의 위치설명
AT_SYSINFO_EHDR (33) vDSO ELF 헤더 주소 include/uapi/linux/auxvec.h vDSO의 ELF 헤더 (Ehdr) 시작 주소. glibc/musl이 이 값으로 vDSO를 파싱
AT_SYSINFO (32) vDSO 진입점 (x86_32) include/uapi/linux/auxvec.h i386에서 __kernel_vsyscall 주소. x86_64에서는 미사용
AT_HWCAP (16) 하드웨어 기능 비트맵(Bitmap) include/uapi/linux/auxvec.h CPU 기능 플래그. vDSO가 TSC 사용 여부를 판단하는 데 간접 활용
AT_CLKTCK (17) 클럭 틱 빈도 include/uapi/linux/auxvec.h USER_HZ 값 (보통 100). times() 시스템 콜의 해상도

커널의 auxiliary vector 설정

execve() 경로에서 create_elf_tables() 함수가 auxiliary vector를 구성합니다. vDSO 관련 엔트리는 다음과 같이 설정됩니다:

/* fs/binfmt_elf.c - create_elf_tables() */
static int create_elf_tables(struct linux_binprm *bprm,
                              const struct elfhdr *exec,
                              unsigned long interp_load_addr,
                              unsigned long e_entry,
                              struct elfhdr *interp_elf_ex)
{
    unsigned long vdso_base = current->mm->context.vdso;

    /* ... 다른 auxiliary 항목 설정 ... */

    /* vDSO 기저 주소를 AT_SYSINFO_EHDR로 전달 */
    if (vdso_base) {
        NEW_AUX_ENT(AT_SYSINFO_EHDR, vdso_base);
    }

#ifdef CONFIG_X86_32
    /* i386: __kernel_vsyscall 진입점 주소 */
    NEW_AUX_ENT(AT_SYSINFO,
                (unsigned long)VDSO32_SYMBOL(vdso_base, vsyscall));
#endif

    NEW_AUX_ENT(AT_HWCAP, ELF_HWCAP);
    NEW_AUX_ENT(AT_CLKTCK, CLOCKS_PER_SEC);
    /* ... */
}
코드 설명
  • 7행 current->mm->context.vdso에서 현재 프로세스의 vDSO 기저 주소를 가져옵니다. 이 값은 arch_setup_additional_pages()에서 설정됩니다.
  • 12-14행 AT_SYSINFO_EHDR 엔트리에 vDSO ELF 헤더 주소를 기록합니다. 동적 링커(ld.so)가 이 주소를 사용하여 vDSO 심볼을 조회합니다.
  • 17-19행 i386(x86 32비트)에서는 추가로 AT_SYSINFO__kernel_vsyscall 진입점 주소를 제공합니다. 이는 SYSENTER 기반 시스템 콜 진입에 사용됩니다.

vDSO ELF 내부 구조 분석

vDSO는 완전한 ELF 공유 라이브러리입니다. 매우 작은 크기(보통 8KB, 2페이지)에 표준 ELF 헤더, 프로그램 헤더, 동적 심볼 테이블, 코드가 모두 포함되어 있습니다. readelf로 추출한 vDSO의 구조를 분석합니다:

# vDSO 프로그램 헤더 (세그먼트)
$ readelf -l vdso.so

Program Headers:
  Type           Offset   VirtAddr           FileSiz  MemSiz   Flg Align
  LOAD           0x000000 0x0000000000000000 0x000724 0x000724 R   0x1000
  LOAD           0x000b70 0x0000000000000b70 0x000491 0x000491 R E 0x1000
  DYNAMIC        0x000350 0x0000000000000350 0x000110 0x000110 R   0x8
  NOTE           0x000270 0x0000000000000270 0x000060 0x000060 R   0x4

# 세그먼트 의미:
# LOAD (R):     읽기 전용 데이터 (ELF 헤더, 심볼, 해시, 버전 정보)
# LOAD (R E):   읽기+실행 가능 코드 (__vdso_clock_gettime 등)
# DYNAMIC:      동적 링킹 정보 (심볼/문자열 테이블 참조)
# NOTE:         빌드 ID, 리눅스 버전 정보
# vDSO 동적 심볼과 버전 정보
$ readelf -Ws --dyn-syms vdso.so

Symbol table '.dynsym' contains 8 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     1: 0000000000000b70  0x95  FUNC    WEAK   DEFAULT   11 clock_gettime@@LINUX_2.6
     2: 0000000000000c10  0x95  FUNC    GLOBAL DEFAULT   11 __vdso_clock_gettime@@LINUX_2.6
     3: 0000000000000cb0  0x60  FUNC    WEAK   DEFAULT   11 gettimeofday@@LINUX_2.6
     4: 0000000000000d10  0x60  FUNC    GLOBAL DEFAULT   11 __vdso_gettimeofday@@LINUX_2.6
     5: 0000000000000d70  0x20  FUNC    WEAK   DEFAULT   11 time@@LINUX_2.6
     6: 0000000000000d90  0x20  FUNC    GLOBAL DEFAULT   11 __vdso_time@@LINUX_2.6
     7: 0000000000000db0  0x30  FUNC    GLOBAL DEFAULT   11 __vdso_getcpu@@LINUX_2.6

# 심볼 특성 분석:
# - WEAK + GLOBAL 쌍: clock_gettime(WEAK)과 __vdso_clock_gettime(GLOBAL)
#   glibc는 __vdso_ 접두사 버전을 우선 조회
# - 버전 태그: @@LINUX_2.6 — 심볼 버저닝으로 ABI 호환성 관리
# - 총 코드 크기: ~0x491 = 1169바이트 (4개 함수)
vDSO ELF 이미지 내부 구조 (~8KB) ELF Header (64B) — ET_DYN, EM_X86_64 Program Headers (4~5 entries) .dynsym + .dynstr + .hash 동적 심볼 (8개) + 문자열 + 해시 .gnu.version + .gnu.version_d (LINUX_2.6) .note (빌드 ID) + .eh_frame (DWARF unwind) .text (실행 가능 코드 ~1.2KB) __vdso_clock_gettime (~0x95 B) __vdso_gettimeofday, __vdso_time, __vdso_getcpu .dynamic (동적 링킹 테이블) .altinstructions (CPU별 패칭 테이블) 심볼 버저닝 (LINUX_2.6) glibc: __vdso_ 접두사 심볼 우선 조회 WEAK 심볼: 사용자 오버라이드 가능 버전 불일치 시 vDSO 미사용 (syscall 폴백) GNU hash + SysV hash 이중 지원 vDSO 코드 → vvar 접근 음수 오프셋: vvar은 vDSO 앞에 배치 vvar_start = -0x3000 (PC-relative 접근) 위치 독립 코드(PIC) 유지 총 크기: ~8KB (2페이지) | 코드: ~1.2KB | 메타데이터: ~6.8KB

유저 공간에서 auxiliary vector 읽기

#include <sys/auxv.h>
#include <stdio.h>

int main(void)
{
    unsigned long vdso_ehdr = getauxval(AT_SYSINFO_EHDR);
    if (vdso_ehdr)
        printf("vDSO base: %#lx\n", vdso_ehdr);
    else
        printf("vDSO not present\n");

    /* AT_HWCAP: CPU 기능 플래그 */
    unsigned long hwcap = getauxval(AT_HWCAP);
    printf("HWCAP: %#lx\n", hwcap);

    return 0;
}
execve() load_elf_binary() ELF 파싱, 세그먼트 매핑 arch_setup_additional_pages() vDSO + vvar 매핑 mm->context.vdso 설정 create_elf_tables() auxiliary vector 구성 유저 스택 (execve 후) argc, argv[], envp[] AT_SYSINFO_EHDR = 0x7ffXXXXXX000 (vDSO 기저 주소) AT_HWCAP, AT_CLKTCK, AT_PAGESZ, AT_PHDR, ... AT_NULL (종료 마커) ld.so vDSO 심볼 조회

vvar 페이지 구조

vdso_data 구조체

vvar 페이지의 핵심은 struct vdso_data 구조체입니다. 커널의 timekeeping 서브시스템이 이 구조체를 갱신하면, 유저 공간의 vDSO 함수가 동일한 물리 페이지를 읽기 전용으로 접근합니다. 아키텍처별로 약간의 차이가 있지만, 공통 구조는 다음과 같습니다:

/* include/vdso/datapage.h */
struct vdso_timestamp {
    u64    sec;            /* 초 (seconds) */
    u64    nsec;           /* 시프트된 나노초 (shifted nanoseconds) */
};

struct vdso_data {
    u32                  seq;             /* seqcount: 홀수면 업데이트 중 */
    s32                  clock_mode;      /* 클럭 모드: VDSO_CLOCKMODE_* */
    u64                  cycle_last;      /* 마지막 읽은 카운터 값 */
    u64                  mask;            /* 카운터 마스크 */
    u32                  mult;            /* 주기 -> 나노초 곱셈 계수 */
    u32                  shift;           /* 정밀도 시프트 */

    union {
        struct vdso_timestamp  basetime[VDSO_BASES];
        struct timens_offset   offset[VDSO_BASES];
    };

    s32                  tz_minuteswest;  /* 시간대 (서쪽 분 단위) */
    s32                  tz_dsttime;      /* DST 타입 */
    u32                  hrtimer_res;     /* hrtimer 해상도 (ns) */
    u32                  __unused;
};
코드 설명
  • 8행 seq 필드는 seqcount 잠금입니다. 커널이 데이터를 갱신할 때 홀수로 변경하고, 완료 후 짝수로 되돌립니다. 유저 공간은 읽기 전후로 이 값을 비교하여 일관성을 검증합니다.
  • 9행 clock_mode는 vDSO가 사용해야 할 클럭소스를 지정합니다. VDSO_CLOCKMODE_TSC(TSC 사용), VDSO_CLOCKMODE_NONE(vDSO 비활성 -> 시스템 콜 폴백) 등이 있습니다.
  • 10행 cycle_last는 커널이 마지막으로 읽은 TSC 카운터 값입니다. vDSO는 현재 TSC 값에서 이를 빼서 경과 사이클을 계산합니다.
  • 12-13행 multshift는 사이클을 나노초로 변환하는 공식 ns = (cycles * mult) >> shift에 사용됩니다.
  • 16행 basetime[] 배열은 각 클럭 유형(CLOCK_REALTIME, CLOCK_MONOTONIC, CLOCK_BOOTTIME 등)의 기저 시간을 저장합니다.

클럭 모드 (VDSO_CLOCKMODE)

모드의미사용 조건
VDSO_CLOCKMODE_NONE 0 vDSO 비활성, 시스템 콜로 폴백 clocksource가 vDSO 미지원 시
VDSO_CLOCKMODE_TSC 1 TSC 기반 고속 경로 x86에서 TSC가 안정적인 경우
VDSO_CLOCKMODE_HRES 2 고해상도 클럭 (ARM64 등) 아키텍처별 카운터 사용
VDSO_CLOCKMODE_TIMENS 0x7fffffff Time Namespace 오프셋 적용 컨테이너(Container)의 시간 격리(Isolation) 사용 시
vvar 페이지 레이아웃 vdso_data[0] (CS_HRES_COARSE) seq: u32 (seqcount) clock_mode: s32 (VDSO_CLOCKMODE_TSC) cycle_last: u64, mask: u64, mult: u32, shift: u32 basetime[CLOCK_REALTIME]: {sec, nsec} basetime[CLOCK_MONOTONIC]: {sec, nsec} basetime[CLOCK_BOOTTIME]: {sec, nsec} ... vdso_data[1] (CS_RAW) CLOCK_MONOTONIC_RAW 전용 데이터 tz_minuteswest, tz_dsttime hrtimer_res (나노초) timens_offset[] (Time Namespace, 선택) update_vsyscall() timekeeping -> vdso_data seqcount 프로토콜 1. seq++ (홀수 = 쓰기 중) 2. 데이터 갱신 3. seq++ (짝수 = 완료) 유저 공간 읽기 1. s = READ_ONCE(seq) 2. 데이터 읽기 + 시간 계산 3. s != READ_ONCE(seq)? 재시도

vvar 페이지의 물리 매핑

커널은 vvar 데이터를 위해 물리 페이지를 할당하고, 이를 커널 주소 공간과 유저 주소 공간 양쪽에 매핑합니다. 커널 측에서는 쓰기가 가능하고, 유저 측에서는 읽기 전용입니다. 이는 vm_fault 핸들러(Handler)를 통해 구현됩니다:

/* arch/x86/entry/vdso/vma.c */
static vm_fault_t vvar_fault(const struct vm_special_mapping *sm,
                            struct vm_area_struct *vma,
                            struct vm_fault *vmf)
{
    if (vmf->pgoff == 0) {
        /* 페이지 0: vdso_data (timekeeping 데이터) */
        return vmf_insert_pfn(vma, vmf->address,
                __pa_symbol(&vdso_data) >> PAGE_SHIFT);
    }
#ifdef CONFIG_TIME_NS
    if (vmf->pgoff == VVAR_TIMENS_PAGE_OFFSET) {
        /* Time Namespace 오프셋 페이지 */
        struct timens_offset *timens;
        timens = get_timens_vvar_page(vma->vm_mm);
        if (!timens)
            return VM_FAULT_SIGBUS;
        return vmf_insert_pfn(vma, vmf->address,
                page_to_pfn(virt_to_page(timens)));
    }
#endif
    return VM_FAULT_SIGBUS;
}

clock_gettime 고속 경로

__vdso_clock_gettime 구현

clock_gettime()의 vDSO 구현은 커널 소스의 lib/vdso/gettimeofday.c에 있습니다. 이 파일은 아키텍처 독립적인 공통 구현으로, 빌드 시 vDSO 이미지에 포함됩니다. 핵심 로직은 다음과 같습니다:

/* lib/vdso/gettimeofday.c */
static int do_hres(const struct vdso_data *vd,
                    clockid_t clk,
                    struct __kernel_timespec *ts)
{
    const struct vdso_timestamp *vdso_ts = &vd->basetime[clk];
    u64 cycles, ns;
    u32 seq;

    do {
        /* 1. seqcount 읽기 시작: 짝수가 될 때까지 스핀 */
        seq = vdso_read_begin(vd);

        /* 2. 클럭 모드 확인 */
        if (unlikely(vd->clock_mode == VDSO_CLOCKMODE_NONE))
            return -1;  /* 시스템 콜로 폴백 */

        /* 3. 현재 하드웨어 카운터 읽기 (x86: RDTSC) */
        cycles = __arch_get_hw_counter(vd->clock_mode, vd);

        /* 4. 경과 나노초 계산: (cycles - cycle_last) * mult >> shift */
        ns = vdso_ts->nsec;
        ns += vdso_calc_delta(cycles, vd->cycle_last, vd->mask, vd->mult);
        ns >>= vd->shift;

        /* 5. 초 단위 오버플로 처리 */
        ts->tv_sec = vdso_ts->sec;
        ts->tv_nsec = ns;
        if (ns >= NSEC_PER_SEC) {
            ts->tv_sec += ns / NSEC_PER_SEC;
            ts->tv_nsec = ns % NSEC_PER_SEC;
        }

    /* 6. seqcount 검증: 변경되었으면 재시도 */
    } while (vdso_read_retry(vd, seq));

    return 0;
}
코드 설명
  • 11행 vdso_read_begin()seq 값을 읽고, 홀수(쓰기 중)이면 짝수가 될 때까지 스핀합니다. 이는 커널의 read_seqcount_begin()과 동등합니다.
  • 14-15행 clock_modeVDSO_CLOCKMODE_NONE이면 vDSO가 비활성 상태입니다. 이 경우 -1을 반환하여 호출자가 실제 시스템 콜로 폴백하게 합니다. clocksource가 TSC에서 HPET로 전환된 경우 등에 발생합니다.
  • 18행 __arch_get_hw_counter()는 아키텍처별 하드웨어 카운터를 읽습니다. x86에서는 RDTSC 또는 RDTSCP 명령어를 실행합니다.
  • 21-22행 vdso_calc_delta()(cycles - cycle_last) & mask로 경과 사이클을 구하고, * mult를 곱합니다. nsec는 사전 시프트된 나노초 값이므로 전체에 >> shift를 적용합니다.
  • 33행 vdso_read_retry()는 현재 seq 값과 시작 시 읽은 값을 비교합니다. 다르면 커널이 중간에 데이터를 갱신한 것이므로 전체 루프를 재시도합니다.
clock_gettime(CLOCK_MONOTONIC, &ts) glibc: __vdso_clock_gettime seq = vdso_read_begin() clock_mode? NONE: syscall 폴백 TSC cycles = RDTSC/RDTSCP ns = basetime.nsec + (delta * mult) >> shift seq 변경? 재시도 결과 반환 (0)

seqcount 동작 원리

vDSO에서 seqcount는 락 없이 읽기 일관성을 보장하는 핵심 메커니즘입니다. 커널(쓰기자)과 유저 공간(읽기자)의 프로토콜은 다음과 같습니다:

/* 커널 측 (쓰기) - kernel/time/vsyscall.c */
void update_vsyscall(struct timekeeper *tk)
{
    struct vdso_data *vdata = __arch_get_k_vdso_data();

    vdso_write_begin(vdata);          /* seq++ (홀수: 쓰기 시작) */
    smp_wmb();                         /* 메모리 배리어 */

    vdata->clock_mode    = cycl->vdso_clock_mode;
    vdata->cycle_last    = tk->tkr_mono.cycle_last;
    vdata->mult          = tk->tkr_mono.mult;
    vdata->shift         = tk->tkr_mono.shift;
    vdata->mask          = tk->tkr_mono.mask;
    /* ... basetime 갱신 ... */

    smp_wmb();                         /* 메모리 배리어 */
    vdso_write_end(vdata);             /* seq++ (짝수: 쓰기 완료) */
}

/* 유저 측 (읽기) - vDSO 코드 */
static inline u32 vdso_read_begin(const struct vdso_data *vd)
{
    u32 seq;
    while ((seq = READ_ONCE(vd->seq)) & 1)
        cpu_relax();    /* 홀수면 쓰기 중 - 스핀 대기 */
    smp_rmb();              /* 읽기 배리어 */
    return seq;
}

static inline bool vdso_read_retry(const struct vdso_data *vd, u32 start)
{
    smp_rmb();              /* 읽기 배리어 */
    return READ_ONCE(vd->seq) != start;
}
성능 특성: seqcount는 읽기 측에서 락을 전혀 취득하지 않습니다. 커널의 쓰기 갱신은 타이머 인터럽트(보통 초당 100~1000회)에서만 발생하므로, 읽기 측의 재시도는 극히 드뭅니다. 대부분의 경우 단일 반복으로 완료됩니다.

vDSO가 지원하는 클럭 ID

클럭 IDvDSO 고속 경로설명
CLOCK_REALTIME지원 (고해상도)UTC 벽시계 시간
CLOCK_MONOTONIC지원 (고해상도)단조 증가 시간 (절전 제외)
CLOCK_BOOTTIME지원 (고해상도)부팅 이후 시간 (절전 포함)
CLOCK_REALTIME_COARSE지원 (저해상도)저해상도 UTC (tick 기반)
CLOCK_MONOTONIC_COARSE지원 (저해상도)저해상도 단조 증가 (tick 기반)
CLOCK_MONOTONIC_RAW지원 (CS_RAW)NTP 보정 없는 원시 클럭
CLOCK_TAI지원 (고해상도)국제 원자 시간 (윤초 미적용)
CLOCK_PROCESS_CPUTIME_ID미지원 (syscall 폴백)프로세스 CPU 시간
CLOCK_THREAD_CPUTIME_ID미지원 (syscall 폴백)스레드(Thread) CPU 시간

gettimeofday 가속

__vdso_gettimeofday 구현

gettimeofday()의 vDSO 구현은 clock_gettime()의 결과를 struct timeval 형식으로 변환합니다. 내부적으로 do_hres()를 재사용하되, 나노초를 마이크로초로 변환하는 단계가 추가됩니다.

/* lib/vdso/gettimeofday.c */
static __always_inline int
__cvdso_gettimeofday_data(const struct vdso_data *vd,
                         struct __kernel_old_timeval *tv,
                         struct timezone *tz)
{
    if (likely(tv != NULL)) {
        struct __kernel_timespec ts;

        /* CLOCK_REALTIME의 고속 경로 시도 */
        if (do_hres(vd, CLOCK_REALTIME, &ts))
            return gettimeofday_fallback(tv, tz);

        /* 나노초 -> 마이크로초 변환 */
        tv->tv_sec  = ts.tv_sec;
        tv->tv_usec = (u32)ts.tv_nsec / NSEC_PER_USEC;
    }

    if (unlikely(tz != NULL)) {
        /* 시간대 정보는 vvar에서 직접 읽기 */
        tz->tz_minuteswest = vd->tz_minuteswest;
        tz->tz_dsttime     = vd->tz_dsttime;
    }

    return 0;
}
코드 설명
  • 11-12행 do_hres()가 -1을 반환하면(vDSO 비활성) gettimeofday_fallback()이 실제 __NR_gettimeofday 시스템 콜을 수행합니다.
  • 15-16행 timespec(나노초)에서 timeval(마이크로초)로 변환합니다. NSEC_PER_USEC은 1000입니다.
  • 19-22행 시간대(timezone) 정보는 seqcount 보호 없이 직접 읽습니다. 이 값은 거의 변경되지 않으며, 약간의 불일치가 허용됩니다.

gettimeofday vs clock_gettime 선택 가이드

속성gettimeofday()clock_gettime()
해상도마이크로초 (usec)나노초 (nsec)
클럭 선택CLOCK_REALTIME 고정임의 클럭 ID 지정 가능
POSIXPOSIX.1-2001 (deprecated 권고)POSIX.1-2001 (권장)
vDSO 성능동일 (내부적으로 동일 경로)동일
시간대tz 매개변수로 제공미제공 (별도 localtime 필요)
권장: 새로운 코드에서는 clock_gettime()을 사용하세요. 나노초 해상도를 제공하고, CLOCK_MONOTONIC 등 용도에 맞는 클럭을 선택할 수 있습니다. gettimeofday()는 레거시 호환성을 위해서만 유지되며, POSIX에서 향후 제거가 논의되고 있습니다.

time과 getcpu

__vdso_time 구현

time()은 현재 시간을 초 단위로 반환하는 가장 간단한 시간 함수입니다. vDSO 구현은 CLOCK_REALTIME의 coarse(저해상도) 경로를 사용합니다:

/* lib/vdso/gettimeofday.c */
static __always_inline __kernel_old_time_t
__cvdso_time_data(const struct vdso_data *vd, __kernel_old_time_t *t)
{
    __kernel_old_time_t sec;
    const struct vdso_timestamp *vdso_ts;

    /* coarse 데이터에서 초만 읽기 - TSC 불필요 */
    vdso_ts = &vd->basetime[CLOCK_REALTIME];
    sec = (__kernel_old_time_t)READ_ONCE(vdso_ts->sec);

    if (t)
        *t = sec;
    return sec;
}

time()은 초 단위만 필요하므로 seqcount 루프조차 필요하지 않습니다. READ_ONCE()로 단일 원자적 읽기만 수행하며, 이는 약 1~5 나노초 수준의 극도로 빠른 성능을 보입니다.

__vdso_getcpu 구현

getcpu()는 현재 스레드가 실행 중인 CPU 번호와 NUMA 노드를 반환합니다. x86에서는 RDTSCP 또는 LSL(Load Segment Limit) 명령어를 사용하여 GDT 엔트리에 인코딩된 CPU/노드 정보를 읽습니다:

/* arch/x86/include/asm/vdso/getcpu.h */
static inline unsigned long __getcpu(void)
{
    unsigned long cpu;

    /*
     * RDTSCP는 TSC 값과 함께 IA32_TSC_AUX MSR 값을
     * ECX에 반환합니다. 커널은 이 MSR에 CPU 번호와
     * NUMA 노드를 인코딩해 둡니다.
     *
     * IA32_TSC_AUX = (node_id << 12) | cpu_id
     */
    asm volatile (
        "rdtscp"
        : "=c" (cpu)
        :
        : "eax", "edx"
    );

    return cpu;
}

/* vDSO의 getcpu 래퍼 */
static __always_inline int
__cvdso_getcpu(unsigned *cpu, unsigned *node)
{
    unsigned long p = __getcpu();

    if (cpu)
        *cpu  = p & 0xfff;         /* 하위 12비트: CPU ID */
    if (node)
        *node = p >> 12;            /* 상위 비트: NUMA 노드 */

    return 0;
}
코드 설명
  • 13-18행 RDTSCP 명령어는 TSC 값(EAX:EDX)과 함께 IA32_TSC_AUX MSR 값(ECX)을 반환합니다. 커널의 __switch_to()에서 이 MSR에 현재 CPU/노드를 기록합니다.
  • 30-32행 하위 12비트에 CPU ID(최대 4096개 CPU 지원), 상위 비트에 NUMA 노드 번호가 인코딩되어 있습니다.
ARM64에서의 getcpu: ARM64는 RDTSCP가 없으므로 MRS 명령어로 TPIDRRO_EL0 레지스터를 읽어 CPU 번호를 얻습니다. 이 레지스터도 컨텍스트 스위치 시 커널이 갱신합니다.

커널 측 구현

vdso_image 구조체

커널은 빌드 시 생성된 vDSO ELF 바이너리를 struct vdso_image 형태로 보관합니다. x86에서는 64비트와 32비트 각각 별도의 이미지가 존재합니다:

/* arch/x86/include/asm/vdso.h */
struct vdso_image {
    void          *data;          /* vDSO ELF 이미지 데이터 */
    unsigned long  size;          /* 이미지 크기 (바이트) */

    unsigned long  alt;           /* 대체 명령어 패치 테이블 오프셋 */
    unsigned long  alt_len;       /* 대체 패치 테이블 길이 */

    unsigned long  sym_vvar_start;  /* vvar 시작 주소 오프셋 */
    unsigned long  sym_vvar_page;   /* vvar 페이지 오프셋 */
    unsigned long  sym_pvclock_page; /* pvclock 페이지 (KVM) */
    unsigned long  sym_hvclock_page; /* Hyper-V 클럭 페이지 */
    unsigned long  sym_timens_page;  /* Time Namespace 페이지 */
};

/* 전역 vDSO 이미지 변수 */
extern const struct vdso_image vdso_image_64;   /* x86_64 */
extern const struct vdso_image vdso_image_32;   /* x86 32비트 (compat) */

arch_setup_additional_pages()

execve() 경로에서 ELF 바이너리를 로드한 후, arch_setup_additional_pages()가 호출되어 vDSO와 vvar 페이지를 프로세스 주소 공간에 매핑합니다:

/* arch/x86/entry/vdso/vma.c */
int arch_setup_additional_pages(struct linux_binprm *bprm, int uses_interp)
{
    struct mm_struct *mm = current->mm;
    const struct vdso_image *image;
    unsigned long addr, vdso_addr;
    int ret;

    /* 32비트 compat 프로세스이면 32비트 이미지 사용 */
    if (in_ia32_syscall())
        image = &vdso_image_32;
    else
        image = &vdso_image_64;

    /* ASLR을 적용한 vDSO 주소 계산 */
    vdso_addr = vdso_addr(mm, image->size);

    mmap_write_lock(mm);

    /* 1. vvar 페이지 매핑 (읽기 전용, 실행 불가) */
    addr = _install_special_mapping(mm, vdso_addr - image->sym_vvar_start,
                                     image->sym_vvar_start,
                                     VM_READ | VM_MAYREAD,
                                     &vvar_mapping);
    if (IS_ERR_VALUE(addr)) {
        ret = (int)addr;
        goto err;
    }

    /* 2. vDSO 코드 페이지 매핑 (읽기+실행, 쓰기 불가) */
    vdso_addr = _install_special_mapping(mm, vdso_addr, image->size,
                                          VM_READ | VM_EXEC |
                                          VM_MAYREAD | VM_MAYWRITE |
                                          VM_MAYEXEC,
                                          &vdso_mapping);
    if (IS_ERR_VALUE(vdso_addr)) {
        ret = (int)vdso_addr;
        goto err;
    }

    /* 3. mm->context.vdso에 기저 주소 저장 */
    mm->context.vdso = (void *)vdso_addr;

    mmap_write_unlock(mm);
    return 0;

err:
    mmap_write_unlock(mm);
    return ret;
}
코드 설명
  • 10-13행 32비트 호환(compat) 프로세스이면 vdso_image_32를, 64비트 프로세스이면 vdso_image_64를 사용합니다.
  • 16행 vdso_addr()는 ASLR을 적용하여 vDSO를 매핑할 무작위 주소를 계산합니다. 보통 스택 아래 영역에 배치됩니다.
  • 21-24행 vvar 페이지를 VM_READ 권한으로 매핑합니다. 유저 공간은 읽기만 가능하며, vvar_fault() 핸들러가 실제 물리 페이지를 연결합니다.
  • 31-35행 vDSO 코드 페이지를 VM_READ | VM_EXEC 권한으로 매핑합니다. 쓰기 불가이므로 코드 무결성(Integrity)이 보장됩니다.
  • 42행 vDSO 기저 주소를 mm->context.vdso에 저장합니다. 이 값은 create_elf_tables()에서 AT_SYSINFO_EHDR로 전달됩니다.

update_vsyscall()

커널의 timekeeping 서브시스템은 타이머 인터럽트가 발생할 때마다 update_vsyscall()을 호출하여 vvar 페이지의 vdso_data를 갱신합니다. 이 함수는 kernel/time/vsyscall.c에 정의되어 있습니다:

/* kernel/time/vsyscall.c */
void update_vsyscall(struct timekeeper *tk)
{
    struct vdso_data *vdata = __arch_get_k_vdso_data();
    struct vdso_timestamp *vdso_ts;
    s32 clock_mode;
    u64 nsec;

    /* clocksource의 vDSO 클럭 모드 가져오기 */
    clock_mode = tk->tkr_mono.clock->vdso_clock_mode;

    /* seqcount 쓰기 시작 */
    vdso_write_begin(vdata);

    /* 클럭 매개변수 갱신 */
    vdata[CS_HRES_COARSE].clock_mode   = clock_mode;
    vdata[CS_HRES_COARSE].cycle_last    = tk->tkr_mono.cycle_last;
    vdata[CS_HRES_COARSE].mask          = tk->tkr_mono.mask;
    vdata[CS_HRES_COARSE].mult          = tk->tkr_mono.mult;
    vdata[CS_HRES_COARSE].shift         = tk->tkr_mono.shift;

    /* CLOCK_REALTIME 기저 시간 */
    vdso_ts = &vdata[CS_HRES_COARSE].basetime[CLOCK_REALTIME];
    vdso_ts->sec  = tk->xtime_sec;
    vdso_ts->nsec = tk->tkr_mono.xtime_nsec;

    /* CLOCK_MONOTONIC 기저 시간 */
    vdso_ts = &vdata[CS_HRES_COARSE].basetime[CLOCK_MONOTONIC];
    vdso_ts->sec  = tk->xtime_sec + tk->wall_to_monotonic.tv_sec;
    nsec          = tk->tkr_mono.xtime_nsec;
    nsec += ((u64)tk->wall_to_monotonic.tv_nsec << tk->tkr_mono.shift);
    /* ... BOOTTIME, TAI 등 다른 클럭도 갱신 ... */

    /* 시간대 갱신 */
    vdata[CS_HRES_COARSE].tz_minuteswest = sys_tz.tz_minuteswest;
    vdata[CS_HRES_COARSE].tz_dsttime     = sys_tz.tz_dsttime;

    /* seqcount 쓰기 완료 */
    vdso_write_end(vdata);
}
타이머 인터럽트 (tick / hrtimer) timekeeping_advance() timekeeper 갱신 update_vsyscall() vdso_write_begin() vdso_write_end() vdso_data (vvar 페이지) 갱신 데이터 항목 clock_mode: clocksource의 vdso_clock_mode cycle_last: 마지막 TSC/카운터 값 mult, shift, mask: 사이클->나노초 변환 계수 basetime[REALTIME]: {xtime_sec, xtime_nsec} basetime[MONOTONIC]: wall_to_monotonic 보정 basetime[BOOTTIME]: sleep 시간 포함 유저 공간 (vDSO) vdso_data 읽기 + RDTSC -> 시간 계산 (syscall 없음) 읽기 전용 접근

vDSO와 프로세스 생명주기

fork()와 vDSO 매핑 상속

fork()(clone())으로 자식 프로세스를 생성하면, 부모의 전체 주소 공간이 Copy-on-Write(COW)로 복제됩니다. vDSO와 vvar 매핑도 이 과정에서 상속됩니다:

/* kernel/fork.c - copy_mm() 경로 */
/*
 * fork 시 mm_struct가 dup_mm()으로 복제됩니다.
 * vDSO/vvar VMA(Virtual Memory Area)는 special mapping으로
 * vm_area_struct가 복사되며, 물리 페이지는 공유됩니다.
 *
 * mm->context.vdso는 부모의 값이 그대로 복사됩니다.
 * → 자식 프로세스는 fork 직후부터 vDSO 사용 가능
 */

/* copy_mm() → dup_mm() → dup_mmap() 경로에서
 * VM_SPECIAL 플래그가 설정된 VMA가 그대로 복제됨 */

execve()와 vDSO 재매핑

execve()로 새 프로그램을 로드하면, 기존 주소 공간이 완전히 교체됩니다. 새 vDSO 매핑은 다음 순서로 설정됩니다:

  1. exec_mmap(): 기존 mm_struct를 해제하고 새 mm을 할당
  2. load_elf_binary(): ELF 바이너리의 세그먼트를 매핑
  3. arch_setup_additional_pages(): 새 ASLR 오프셋으로 vDSO/vvar 재매핑
  4. create_elf_tables(): 새 AT_SYSINFO_EHDR 값을 auxiliary vector에 기록
ASLR 재무작위화: fork()만으로는 vDSO 주소가 변경되지 않습니다. execve()를 호출해야 새로운 무작위 주소가 할당됩니다. 이는 공격자가 fork() 후 자식 프로세스에서 vDSO 주소를 추측하는 것을 방지하려면 execve()를 거쳐야 함을 의미합니다.

clone()과 스레드의 vDSO

clone(CLONE_VM)으로 생성된 스레드는 부모와 동일한 mm_struct를 공유합니다. 따라서 vDSO/vvar 매핑도 완전히 동일합니다. 멀티스레드 환경에서의 주요 특성:

프로세스 생명주기와 vDSO 매핑 부모 프로세스 (PID 100) mm_struct (mm_A) vDSO @ 0x7fff1300_0000 vvar @ 0x7fff12ff_e000 context.vdso = 0x7fff1300_0000 ASLR offset: 0x1300_0000 fork() 자식 프로세스 (PID 101) mm_struct (mm_B, COW 복제) vDSO @ 0x7fff1300_0000 (동일) vvar @ 0x7fff12ff_e000 (동일) 물리 페이지 공유 (COW, 변경 불필요) execve() 새 프로그램 (PID 101) mm_struct (mm_C, 완전 교체) vDSO @ 0x7ffd8a00_0000 (새 ASLR) vvar @ 0x7ffd89ff_e000 (새 ASLR) 새 AT_SYSINFO_EHDR 할당 스레드 (TID 102) mm_struct: mm_A (공유!) vDSO @ 동일 주소 vvar @ 동일 주소 CLONE_VM: 완전 동일 매핑 RDTSCP: 코어별 CPU ID seqcount: lock-free 읽기 clone(CLONE_VM) 커널 vdso_data 단일 물리 페이지 모든 프로세스 공유 fork: vDSO 주소 동일 (COW) | execve: 새 ASLR 주소 | clone(CLONE_VM): mm 공유 → vDSO 동일 vvar 물리 페이지는 커널 전체에서 단 하나만 존재 → 모든 프로세스/스레드가 공유

vDSO와 unshare(CLONE_NEWTIME)

unshare(CLONE_NEWTIME)으로 Time Namespace를 분리하면, 커널은 해당 프로세스의 vvar 매핑을 수정합니다. 기존 vvar 페이지 0의 clock_modeVDSO_CLOCKMODE_TIMENS로 변경되고, timens 오프셋 페이지가 추가 매핑됩니다. 이 과정에서 vDSO 코드 페이지는 변경되지 않습니다 — 동일한 코드가 clock_mode를 확인하여 timens 경로를 분기합니다.

vDSO 빌드 프로세스

빌드 단계 개요

vDSO는 일반 커널 코드와 다른 특수한 빌드 과정을 거칩니다. 유저 공간에서 실행되는 코드이므로 커널의 링킹과는 별도로 독립적인 ELF 공유 라이브러리로 빌드됩니다:

  1. 소스 컴파일: arch/x86/entry/vdso/의 C/어셈블리(Assembly) 소스를 유저 공간 ABI로 컴파일 (-fPIC, -shared)
  2. 링커 스크립트: vdso.lds를 사용하여 ELF 공유 라이브러리 형식으로 링킹
  3. 바이너리 변환: vdso2c 도구로 ELF 바이너리를 C 배열로 변환
  4. 커널 포함: 변환된 C 배열이 커널 이미지의 .rodata 섹션에 포함

링커 스크립트 (vdso.lds)

/* arch/x86/entry/vdso/vdso.lds.S */
SECTIONS
{
    . = SIZEOF_HEADERS;

    .hash           : { *(.hash) }          /* ELF 해시 */
    .gnu.hash       : { *(.gnu.hash) }      /* GNU 해시 */
    .dynsym         : { *(.dynsym) }        /* 동적 심볼 */
    .dynstr         : { *(.dynstr) }        /* 동적 문자열 */
    .gnu.version    : { *(.gnu.version) }   /* 심볼 버저닝 */
    .gnu.version_d  : { *(.gnu.version_d) } /* 버전 정의 */

    .text           : {
        *(.text*)
    } :text = 0x90909090  /* NOP으로 패딩 */

    .altinstructions : { *(.altinstructions) }
    .altinstr_replacement : { *(.altinstr_replacement) }

    .rodata         : { *(.rodata*) }

    .dynamic        : { *(.dynamic) } :text :dynamic

    .note           : { *(.note.*) } :text :note

    /* vvar 심볼 정의: 커널이 이 오프셋에 데이터 페이지를 매핑 */
    . = ALIGN(PAGE_SIZE);
    vvar_start = . - 3 * PAGE_SIZE;
    vvar_page  = vvar_start;
    pvclock_page = vvar_start + PAGE_SIZE;
    hvclock_page = vvar_start + 2 * PAGE_SIZE;
    timens_page  = vvar_start + 3 * PAGE_SIZE;
}

vdso2c 변환 도구

arch/x86/entry/vdso/vdso2c.c는 빌드 호스트에서 실행되는 도구로, 링킹된 vDSO ELF 바이너리를 분석하여 C 소스 파일(vdso-image-64.c)로 변환합니다:

/* 자동 생성된 파일 (vdso-image-64.c) 예시 */
static unsigned char raw_data[8192]
    __ro_after_init __aligned(PAGE_SIZE) = {
    0x7f, 0x45, 0x4c, 0x46,  /* ELF 매직 넘버 */
    0x02, 0x01, 0x01, 0x00,  /* 64비트, LE, ELF v1 */
    /* ... 전체 vDSO ELF 이미지 바이트 ... */
};

const struct vdso_image vdso_image_64 = {
    .data            = raw_data,
    .size            = sizeof(raw_data),
    .sym_vvar_start  = -0x3000,    /* vvar 시작 오프셋 */
    .sym_vvar_page   = -0x3000,    /* vvar 페이지 오프셋 */
    .sym_pvclock_page = -0x2000,   /* pvclock 오프셋 */
    .sym_hvclock_page = -0x1000,   /* Hyper-V 클럭 오프셋 */
    .sym_timens_page  = -0x4000,   /* timens 오프셋 */
};

Makefile 빌드 규칙

# arch/x86/entry/vdso/Makefile (핵심 부분)

# vDSO 소스 파일들
vobjs-y := vdso-note.o vclock_gettime.o vgetcpu.o

# 유저 공간 컴파일 플래그
CFLAGS_REMOVE_vclock_gettime.o = -pg    # ftrace 제외
CFLAGS_vclock_gettime.o = -fPIC -shared \
    -fno-stack-protector -fno-jump-tables

# vDSO 링킹
$(obj)/vdso64.so.dbg: $(obj)/vdso.lds $(vobjs)
    $(call if_changed,vdso64)

# vdso2c로 C 배열 변환
$(obj)/vdso-image-64.c: $(obj)/vdso64.so.dbg $(obj)/vdso2c
    $(obj)/vdso2c $< $@

아키텍처별 vDSO 구현

아키텍처별 비교

속성 x86_64 x86_32 (i386) ARM64 (AArch64) RISC-V
하드웨어 카운터 RDTSC/RDTSCP RDTSC/RDTSCP CNTVCT_EL0 (Generic Timer) RDTIME/RDCYCLE
getcpu 방식 RDTSCP (ECX) 또는 LSL LSL (GDT 엔트리) MRS TPIDRRO_EL0 tp 레지스터 (SBI 확장)
시스템 콜 진입 SYSCALL SYSENTER/INT 0x80 SVC #0 ECALL
vDSO 함수 수 4 (time, gettimeofday, clock_gettime, getcpu) 5 (+ __kernel_vsyscall, clock_gettime64) 4 (time, gettimeofday, clock_gettime, clock_getres) 4 (time, gettimeofday, clock_gettime, clock_getres)
ELF 형식 ET_DYN (x86-64) ET_DYN (i386) ET_DYN (AArch64) ET_DYN (RISC-V 64/32)
소스 위치 arch/x86/entry/vdso/ arch/x86/entry/vdso/ arch/arm64/kernel/vdso/ arch/riscv/kernel/vdso/
vsyscall 지원 레거시 에뮬레이션 해당 없음 해당 없음 해당 없음

x86_64: RDTSC 기반 고속 경로

x86_64에서 vDSO의 핵심은 RDTSC/RDTSCP 명령어입니다. TSC(Time Stamp Counter)는 CPU 코어의 하드웨어 카운터로, 유저 공간에서 직접 읽을 수 있습니다:

/* arch/x86/include/asm/vdso/gettimeofday.h */
static __always_inline u64
__arch_get_hw_counter(s32 clock_mode, const struct vdso_data *vd)
{
    if (likely(clock_mode == VDSO_CLOCKMODE_TSC))
        return (u64)__native_read_tsc();

    /*
     * TSC 불안정 시 HPET 등을 직접 읽을 수 없으므로
     * VDSO_CLOCKMODE_NONE -> 시스템 콜 폴백
     */
    return U64_MAX;  /* 호출자가 폴백 처리 */
}

/* RDTSC 인라인 어셈블리 */
static __always_inline unsigned long long
__native_read_tsc(void)
{
    unsigned hi, lo;
    asm volatile (
        "rdtsc"
        : "=a" (lo), "=d" (hi)
    );
    return ((unsigned long long)hi << 32) | lo;
}

ARM64: Generic Timer 기반

ARM64에서는 CPU의 Generic Timer 카운터(CNTVCT_EL0)를 사용합니다. 이 레지스터는 유저 공간에서 MRS 명령어로 직접 읽을 수 있습니다 (CNTKCTL_EL1.EL0VCTEN 비트가 설정된 경우):

/* arch/arm64/include/asm/vdso/gettimeofday.h */
static __always_inline u64
__arch_get_hw_counter(s32 clock_mode, const struct vdso_data *vd)
{
    u64 res;

    /*
     * CNTVCT_EL0: 가상 카운터 레지스터
     * ISB: 명령어 직렬화 배리어 (순서 보장)
     */
    asm volatile(
        "isb\n"
        "mrs %0, cntvct_el0"
        : "=r" (res)
        :
        : "memory"
    );

    return res;
}
x86_64 RDTSC / RDTSCP SYSCALL / SYSRET IA32_TSC_AUX (getcpu) vsyscall 레거시 vDSO 함수 __vdso_clock_gettime __vdso_gettimeofday __vdso_time, __vdso_getcpu ARM64 CNTVCT_EL0 (Generic Timer) SVC #0 MRS TPIDRRO_EL0 (getcpu) (vsyscall 없음) vDSO 함수 __vdso_clock_gettime __vdso_gettimeofday __vdso_clock_getres RISC-V RDTIME (CSR time) ECALL tp 레지스터 (SBI) (vsyscall 없음) vDSO 함수 __vdso_clock_gettime __vdso_gettimeofday __vdso_clock_getres, __vdso_flush_icache

KVM/가상화(Virtualization) 환경에서의 vDSO

가상화 환경에서는 vDSO의 동작에 특수한 고려사항이 있습니다:

유저 공간 vDSO 파싱

glibc의 vDSO 통합

glibc는 프로세스 시작 시 AT_SYSINFO_EHDR에서 vDSO 기저 주소를 읽고, ELF 동적 심볼 테이블(Symbol Table)을 파싱하여 vDSO 함수 포인터를 설정합니다. 이후 clock_gettime() 등의 호출은 시스템 콜 대신 vDSO 함수를 직접 호출합니다.

수동 vDSO 파싱 예제

glibc에 의존하지 않는 환경(정적 링크 바이너리, musl, 커스텀 런타임)에서 vDSO를 직접 파싱하여 사용하는 방법입니다:

#include <elf.h>
#include <sys/auxv.h>
#include <string.h>
#include <time.h>

/* vDSO 함수 타입 정의 */
typedef int (*vdso_clock_gettime_t)(clockid_t, struct timespec *);

/* ELF 동적 심볼에서 특정 이름의 함수를 찾기 */
static void *vdso_find_sym(uintptr_t base, const char *name)
{
    const Elf64_Ehdr *ehdr = (const Elf64_Ehdr *)base;
    const Elf64_Phdr *phdr = (const Elf64_Phdr *)(base + ehdr->e_phoff);
    const Elf64_Dyn  *dyn  = NULL;
    const Elf64_Sym  *symtab = NULL;
    const char       *strtab = NULL;
    const Elf64_Word *hashtab = NULL;

    /* PT_DYNAMIC 세그먼트 찾기 */
    for (int i = 0; i < ehdr->e_phnum; i++) {
        if (phdr[i].p_type == PT_DYNAMIC) {
            dyn = (const Elf64_Dyn *)(base + phdr[i].p_offset);
            break;
        }
    }
    if (!dyn) return NULL;

    /* 동적 섹션에서 심볼/문자열/해시 테이블 추출 */
    for (; dyn->d_tag != DT_NULL; dyn++) {
        switch (dyn->d_tag) {
        case DT_SYMTAB:
            symtab = (const Elf64_Sym *)(base + dyn->d_un.d_ptr);
            break;
        case DT_STRTAB:
            strtab = (const char *)(base + dyn->d_un.d_ptr);
            break;
        case DT_HASH:
            hashtab = (const Elf64_Word *)(base + dyn->d_un.d_ptr);
            break;
        }
    }
    if (!symtab || !strtab || !hashtab) return NULL;

    /* 해시 테이블을 순회하며 이름 매칭 */
    Elf64_Word nbucket = hashtab[0];
    Elf64_Word nchain  = hashtab[1];
    const Elf64_Word *buckets = &hashtab[2];
    const Elf64_Word *chains  = &hashtab[2 + nbucket];

    for (Elf64_Word i = 0; i < nchain; i++) {
        if (strcmp(strtab + symtab[i].st_name, name) == 0) {
            return (void *)(base + symtab[i].st_value);
        }
    }
    return NULL;
}

/* 사용 예 */
int main(void)
{
    uintptr_t vdso_base = getauxval(AT_SYSINFO_EHDR);
    if (!vdso_base) return 1;

    vdso_clock_gettime_t vdso_cgt =
        (vdso_clock_gettime_t)vdso_find_sym(vdso_base,
                                             "__vdso_clock_gettime");
    if (vdso_cgt) {
        struct timespec ts;
        vdso_cgt(CLOCK_MONOTONIC, &ts);
        /* ts에 시간이 기록됨 - 시스템 콜 없이! */
    }
    return 0;
}
코드 설명
  • 11-12행 vDSO 기저 주소를 ELF 헤더로 캐스팅합니다. vDSO는 표준 ELF 형식이므로 일반 ELF 파싱 코드로 처리 가능합니다.
  • 20-26행 ELF Program Header Table에서 PT_DYNAMIC 타입 세그먼트를 찾습니다. 이 세그먼트에 동적 링킹 정보가 포함되어 있습니다.
  • 29-42행 동적 섹션에서 심볼 테이블(DT_SYMTAB), 문자열 테이블(DT_STRTAB), 해시 테이블(DT_HASH)의 주소를 추출합니다.
  • 49-53행 심볼 체인을 순회하며 요청된 함수 이름과 일치하는 심볼을 찾고, 기저 주소에 심볼 값을 더하여 함수 포인터를 반환합니다.
  • 58행 getauxval(AT_SYSINFO_EHDR)로 vDSO 기저 주소를 얻습니다. vDSO가 없는 환경에서는 0을 반환합니다.

vDSO 심볼 이름 규칙

심볼 이름대응 시스템 콜아키텍처
__vdso_clock_gettimeclock_gettime()모든 아키텍처
__vdso_gettimeofdaygettimeofday()모든 아키텍처
__vdso_timetime()x86, ARM64, RISC-V
__vdso_getcpugetcpu()x86
__vdso_clock_getresclock_getres()ARM64, RISC-V
__vdso_clock_gettime64clock_gettime64()x86_32 (Y2038 대응)
__kernel_vsyscall(시스템 콜 진입)x86_32 (SYSENTER)
__vdso_flush_icache(캐시(Cache) 플러시)RISC-V

성능 분석과 벤치마크

시스템 콜 vs vDSO 레이턴시 비교

다음은 x86_64 환경(Intel Core i9, 5.15 커널)에서 측정한 대표적인 레이턴시입니다. 각 함수를 1,000만 회 반복 호출하여 평균값을 산출했습니다:

함수시스템 콜 (KPTI 비활성)시스템 콜 (KPTI 활성)vDSO가속 비율
clock_gettime(CLOCK_MONOTONIC) 약 60~80 ns 약 150~250 ns 약 15~25 ns 4~10배
clock_gettime(CLOCK_REALTIME) 약 60~80 ns 약 150~250 ns 약 15~25 ns 4~10배
gettimeofday() 약 60~80 ns 약 150~250 ns 약 15~25 ns 4~10배
clock_gettime(CLOCK_REALTIME_COARSE) 약 50~70 ns 약 140~230 ns 약 5~10 ns 10~30배
time() 약 50~70 ns 약 140~230 ns 약 2~5 ns 25~50배
getcpu() 약 50~70 ns 약 140~230 ns 약 5~10 ns 10~25배
KPTI 영향: Meltdown 취약점 완화를 위한 KPTI(Kernel Page Table Isolation)는 시스템 콜 오버헤드를 약 2~3배 증가시킵니다. vDSO는 커널 모드로 전환하지 않으므로 KPTI의 영향을 받지 않습니다. 이는 Spectre/Meltdown 시대에 vDSO의 가치가 더욱 커진 이유입니다.

벤치마크 방법

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

#define ITERATIONS 10000000

static inline uint64_t rdtsc_fenced(void)
{
    uint32_t lo, hi;
    asm volatile (
        "lfence\n\trdtsc"
        : "=a"(lo), "=d"(hi)
    );
    return ((uint64_t)hi << 32) | lo;
}

int main(void)
{
    struct timespec ts;
    uint64_t start, end;

    /* vDSO 경로 벤치마크 (clock_gettime 사용) */
    start = rdtsc_fenced();
    for (int i = 0; i < ITERATIONS; i++)
        clock_gettime(CLOCK_MONOTONIC, &ts);
    end = rdtsc_fenced();

    printf("vDSO clock_gettime: %.1f cycles/call\n",
           (double)(end - start) / ITERATIONS);

    /* 시스템 콜 강제 경로 벤치마크 (syscall 직접 호출) */
    start = rdtsc_fenced();
    for (int i = 0; i < ITERATIONS; i++)
        syscall(__NR_clock_gettime, CLOCK_MONOTONIC, &ts);
    end = rdtsc_fenced();

    printf("syscall clock_gettime: %.1f cycles/call\n",
           (double)(end - start) / ITERATIONS);

    return 0;
}

실제 워크로드에서의 영향

vDSO의 성능 이점은 시간 함수를 빈번히 호출하는 워크로드에서 두드러집니다:

vDSO 캐시라인과 마이크로아키텍처 최적화

vvar 데이터의 캐시라인 배치

vdso_data 구조체는 캐시라인(Cache Line) 효율을 극대화하도록 설계되어 있습니다. 현대 x86 CPU의 L1 캐시라인은 64바이트이며, 빈번히 접근되는 필드가 동일 캐시라인에 배치되어 캐시 미스(Cache Miss)를 최소화합니다:

오프셋(Byte)필드크기캐시라인접근 빈도
0seq4B캐시라인 #0 (0~63)매 호출 (2회 이상)
4clock_mode4B매 호출
8cycle_last8B매 호출
16mask8B매 호출
24mult4B매 호출
28shift4B매 호출
32+basetime[0].sec8B캐시라인 #0~#1매 호출 (클럭별)
40+basetime[0].nsec8B캐시라인 #0~#1매 호출 (클럭별)

핵심 필드(seq, clock_mode, cycle_last, mult, shift)가 모두 첫 번째 캐시라인(64바이트)에 포함됩니다. 따라서 clock_gettime() 호출 시 대부분의 경우 단 1~2회의 캐시라인 로드로 완료됩니다.

vdso_data 캐시라인 접근 패턴 캐시라인 #0 (Hot Line - 64바이트) seq clock_mode cycle_last mask mult shift base[0] L1 캐시 히트: ~1-4ns 접근 (대부분의 호출이 이 라인만 접근) 캐시라인 #1 (basetime 나머지) basetime[MONOTONIC], basetime[BOOTTIME], basetime[TAI], ... 캐시라인 #2+: tz_minuteswest, tz_dsttime, hrtimer_res (드문 접근) CPU 사이클 비용 RDTSC 직렬화: ~7 사이클 L1 캐시 히트: ~4 사이클 L2 캐시 히트: ~12 사이클 L3 캐시 히트: ~40 사이클 64비트 곱셈: ~3 사이클 시프트: ~1 사이클 총합: ~15-25 사이클 (L1 히트 시) @ 3GHz = 5-8ns + seqcount = 15-25ns 총 커널 갱신: 초당 100~1000회 캐시라인 무효화 → L2/L3 미스 가능

캐시라인 바운싱과 NUMA

커널이 vdso_data를 갱신할 때마다 해당 캐시라인이 무효화됩니다. 멀티코어/NUMA 환경에서는 이 무효화가 캐시 코히어런스(Cache Coherence) 프로토콜을 통해 모든 코어에 전파되며, 다음 vDSO 읽기 시 L2/L3 캐시 미스가 발생할 수 있습니다. 그러나 이 영향은 대부분 무시할 수 있습니다:

분기 예측(Branch Prediction)과 vDSO

vDSO 함수의 주요 분기는 CPU 분기 예측기(Branch Predictor)에 매우 친화적입니다:

분기예측 방향실패 빈도설명
seq & 1 (쓰기 중?) 거의 항상 0 (쓰기 안 함) ~0.0001% 커널 갱신은 극히 짧은 시간 동안만 seq가 홀수
clock_mode == NONE 거의 항상 false (TSC 활성) ~0% clocksource 전환은 거의 발생하지 않음
ns >= NSEC_PER_SEC 거의 항상 false 매우 낮음 초 단위 오버플로는 1초에 1회만 발생
seq != start (재시도) 거의 항상 false (재시도 불필요) ~0.0001% 커널 갱신과 동시 실행될 확률이 극히 낮음
최적의 예측 가능성: vDSO의 모든 조건 분기는 한 방향으로 압도적으로 치우쳐 있어, CPU 분기 예측기가 거의 100%에 가까운 적중률을 보입니다. 이는 vDSO가 파이프라인 스톨(Pipeline Stall) 없이 매우 효율적으로 실행되는 핵심 이유 중 하나입니다.

vDSO 디버깅

/proc/PID/maps에서 vDSO 확인

# 현재 프로세스의 vDSO/vvar 매핑 확인
$ cat /proc/self/maps | grep -E 'vdso|vvar|vsyscall'
7fff12ffe000-7fff13000000 r--p 00000000 00:00 0  [vvar]
7fff13000000-7fff13002000 r-xp 00000000 00:00 0  [vdso]
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0  [vsyscall]

출력에서 주목할 점:

vDSO ELF 추출 및 분석

# vDSO 이미지를 파일로 추출
$ dd if=/proc/self/mem of=vdso.so bs=1 \
    skip=$(($(cat /proc/self/maps | grep '\[vdso\]' | \
    cut -d'-' -f1 | xargs printf '%d\n' 0x))) \
    count=$((0x2000)) 2>/dev/null

# 또는 GDB를 사용한 추출
$ gdb -batch -ex 'set confirm off' \
    -ex 'dump binary memory vdso.so 0x7fff13000000 0x7fff13002000' \
    -ex 'quit' /bin/true

# ELF 헤더 확인
$ readelf -h vdso.so
ELF Header:
  Class:                             ELF64
  Type:                              DYN (Shared object file)
  Machine:                           Advanced Micro Devices X86-64
  Entry point address:               0x...

# 동적 심볼 확인
$ readelf -Ws vdso.so
Symbol table '.dynsym' contains N entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     1: 0000000000000b70   ...  FUNC    WEAK   DEFAULT   11 clock_gettime@@LINUX_2.6
     2: 0000000000000c80   ...  FUNC    WEAK   DEFAULT   11 __vdso_gettimeofday@@LINUX_2.6
     3: 0000000000000d30   ...  FUNC    WEAK   DEFAULT   11 __vdso_time@@LINUX_2.6
     4: 0000000000000d50   ...  FUNC    WEAK   DEFAULT   11 __vdso_getcpu@@LINUX_2.6

# 디스어셈블리
$ objdump -d vdso.so | head -50

Auxiliary Vector 확인

# LD_SHOW_AUXV로 auxiliary vector 출력
$ LD_SHOW_AUXV=1 /bin/true
AT_SYSINFO_EHDR: 0x7ffd3f9f0000
AT_HWCAP:        0x178bfbff
AT_PAGESZ:       4096
AT_CLKTCK:       100
AT_PHDR:         0x5621e4400040
AT_PHENT:        56
AT_PHNUM:        11
AT_BASE:         0x7f8b2c000000
AT_FLAGS:        0x0
AT_ENTRY:        0x5621e4401090
AT_UID:          1000
AT_EUID:         1000
AT_GID:          1000
AT_EGID:         1000
AT_SECURE:       0
AT_RANDOM:       0x7ffd3f9cf209
AT_EXECFN:       /bin/true
AT_PLATFORM:     x86_64

GDB에서 vDSO 디버깅

# GDB로 vDSO 함수에 브레이크포인트 설정
$ gdb ./my_program
(gdb) break __vdso_clock_gettime
Breakpoint 1 at 0x7ffff7fce0b0

# vDSO 공유 라이브러리 정보 확인
(gdb) info sharedlibrary
From                To                  Shared Object Library
0x00007ffff7fce000  0x00007ffff7fcf1a0  linux-vdso.so.1

# vDSO 함수 디스어셈블
(gdb) disas __vdso_clock_gettime
Dump of assembler code for function __vdso_clock_gettime:
   0x00007ffff7fce0b0:  push   %rbp
   0x00007ffff7fce0b1:  mov    %rsp,%rbp
   ...

strace로 vDSO 동작 확인

# strace는 vDSO 경로를 추적하지 못함 (시스템 콜 아님)
# vDSO를 사용하는 프로그램에서는 clock_gettime이 보이지 않음
$ strace -e trace=clock_gettime ./my_program
# (출력 없음 - vDSO 경로 사용 중)

# 시스템 콜을 강제하면 strace에 나타남
$ strace -e trace=clock_gettime -- \
    python3 -c "import ctypes; ctypes.CDLL(None).syscall(228, 1, 0)"
clock_gettime(CLOCK_MONOTONIC, {tv_sec=..., tv_nsec=...}) = 0
ltrace 사용: ltrace는 라이브러리 함수 호출을 추적하므로 glibc의 clock_gettime() 래퍼 호출은 확인할 수 있습니다. 다만 vDSO 내부의 실제 실행까지는 추적하지 못합니다.

보안 고려사항

ASLR과 vDSO 무작위화

vDSO는 매 execve()마다 무작위 주소에 매핑됩니다. 이는 커널의 ASLR 메커니즘과 통합되어 있습니다:

/* arch/x86/entry/vdso/vma.c */
static unsigned long vdso_addr(struct mm_struct *mm,
                               unsigned long image_size)
{
    unsigned long addr, end;

    /* 스택 영역 위에 배치 */
    end = (mm->stack_start + STACK_TOP_MAX) & PAGE_MASK;
    end -= image_size;

    /* 무작위 오프셋 적용: PMD 크기 범위 내에서 무작위화 */
    if (current->flags & PF_RANDOMIZE) {
        addr = end - get_random_long() % (VDSO_RANDOMIZE_SIZE);
    } else {
        addr = end;
    }

    /* 페이지 정렬 */
    addr = PAGE_ALIGN(addr);

    return addr;
}

vDSO 관련 공격 벡터와 방어

공격 벡터위험도방어 메커니즘
vDSO 주소 정보 누출 중간 ASLR, /proc/[pid]/maps 접근 제한 (hidepid=2)
vsyscall ROP 가젯 높음 (레거시) vsyscall=emulate/none으로 네이티브 코드 제거
vvar 데이터 조작 낮음 vvar은 유저 측에서 읽기 전용. 쓰기 시도 시 SIGSEGV
vDSO 코드 덮어쓰기 낮음 vDSO는 r-x 매핑. mprotect()로 쓰기 가능으로 변경 시도 시 실패
Side-channel (TSC 기반) 낮음 TSC는 코어 로컬, 타 프로세스 시간 측정 불가

seccomp과 vDSO의 관계

vDSO 함수는 시스템 콜을 통하지 않으므로 seccomp 필터의 적용을 받지 않습니다. 이는 의도된 동작입니다:

주의: seccomp으로 clock_gettime() 호출을 감사(audit)하려면 vDSO를 비활성화해야 합니다. 커널에서 clocksource를 vDSO 미지원 소스(예: HPET)로 강제 전환하거나, glibc 환경 변수를 사용하는 방법이 있지만, 일반적으로 권장되지 않습니다.

커널 설정

vDSO 관련 CONFIG 옵션

CONFIG 옵션기본값설명
CONFIG_GENERIC_VDSO y (자동) 아키텍처 독립 vDSO 프레임워크 활성화. lib/vdso/의 공통 코드 사용
CONFIG_HAVE_GENERIC_VDSO y (자동) 아키텍처가 제네릭 vDSO를 지원함을 선언
CONFIG_LEGACY_VSYSCALL_EMULATE y (x86) vsyscall을 에뮬레이션 모드로 동작 (기본 권장)
CONFIG_LEGACY_VSYSCALL_XONLY n vsyscall 페이지 실행만 허용, 읽기 차단
CONFIG_LEGACY_VSYSCALL_NONE n vsyscall 완전 비활성화 (최고 보안)
CONFIG_X86_X32_ABI n x32 ABI 지원 시 별도 vDSO 이미지 빌드
CONFIG_IA32_EMULATION y 32비트 호환 모드에서 32비트 vDSO 사용
CONFIG_TIME_NS y Time Namespace 지원. vvar에 timens 오프셋 페이지 추가
CONFIG_PARAVIRT_CLOCK y (KVM) pvclock 반가상화 클럭. vvar에 pvclock 페이지 추가

부트 파라미터

파라미터설명
vsyscall=emulate vsyscall 주소 접근 시 시스템 콜로 에뮬레이션 (기본값)
vsyscall=xonly vsyscall 페이지 실행만 허용, 데이터 읽기 차단
vsyscall=none vsyscall 완전 비활성화. 접근 시 SIGSEGV
vdso=0 vDSO 매핑 비활성화 (디버깅/테스트 용도)
vdso32=0 32비트 vDSO만 비활성화
clocksource=tsc TSC를 clocksource로 강제. vDSO 고속 경로 보장
clocksource=hpet HPET를 clocksource로 강제. vDSO가 비활성화될 수 있음
tsc=reliable TSC 안정성 검사 우회. 가상화 환경에서 유용할 수 있음

런타임 확인

# 현재 clocksource 확인
$ cat /sys/devices/system/clocksource/clocksource0/current_clocksource
tsc

# 사용 가능한 clocksource 목록
$ cat /sys/devices/system/clocksource/clocksource0/available_clocksource
tsc hpet acpi_pm

# vDSO 크기 확인
$ cat /proc/self/maps | grep vdso
7fff25dfe000-7fff25e00000 r-xp 00000000 00:00 0  [vdso]
# 크기: 0x2000 = 8KB (2페이지)

# vsyscall 모드 확인 (커널 커맨드라인)
$ cat /proc/cmdline | grep -o 'vsyscall=[^ ]*'
vsyscall=emulate

# CONFIG 확인
$ zcat /proc/config.gz | grep -i vdso
CONFIG_GENERIC_VDSO=y
CONFIG_HAVE_GENERIC_VDSO=y

$ zcat /proc/config.gz | grep -i vsyscall
CONFIG_LEGACY_VSYSCALL_EMULATE=y
# CONFIG_LEGACY_VSYSCALL_XONLY is not set
# CONFIG_LEGACY_VSYSCALL_NONE is not set

시간 계산의 수학적 상세

사이클→나노초 변환 공식

vDSO의 핵심 연산은 하드웨어 카운터의 사이클 수를 나노초로 변환하는 것입니다. 커널은 정수 산술만으로 이 변환을 수행하기 위해 고정소수점 곱셈 기법을 사용합니다. 부동소수점 연산은 커널과 vDSO에서 모두 금지되므로 이 접근이 필수적입니다.

기본 변환 공식은 다음과 같습니다:

/* 나노초 변환 공식 */
ns = (cycles × mult) >> shift

/* 전체 시간 계산 */
time_ns = basetime.nsec + ((current_cycle - cycle_last) & mask) × mult
final_ns = time_ns >> shift
final_sec = basetime.sec + final_ns / 1,000,000,000
final_nsec = final_ns % 1,000,000,000

여기서 multshift는 clocksource 등록 시 다음 관계를 만족하도록 계산됩니다:

/* kernel/time/clocksource.c - clocks_calc_mult_shift() */
/*
 * 목표: freq Hz 클럭의 1 사이클 = (10^9 / freq) 나노초
 * mult/shift는 다음 관계를 근사:
 *   mult / 2^shift ≈ 10^9 / freq
 *
 * 즉: mult ≈ (10^9 × 2^shift) / freq
 *
 * shift가 클수록 정밀도가 높지만 오버플로 위험 증가
 * 커널은 32비트 곱셈 결과가 64비트에 맞도록 shift를 조정
 */
void clocks_calc_mult_shift(u32 *mult, u32 *shift,
                             u32 from, u32 to, u32 maxsec)
{
    u64 tmp;
    u32 sft, sftacc = 32;

    /* from=freq(Hz), to=NSEC_PER_SEC(10^9) */
    /* maxsec: 오버플로 없이 표현 가능한 최대 초 */

    /* 최적 shift 탐색: 오버플로 방지하면서 최대 정밀도 */
    for (sft = 32; sft > 0; sft--) {
        tmp = (u64)to << sft;
        tmp += from / 2;       /* 반올림 */
        do_div(tmp, from);
        if ((tmp >> sftacc) == 0)
            break;
    }

    *mult  = tmp;
    *shift = sft;
}
코드 설명
  • 공식 TSC 주파수가 2.4GHz(2,400,000,000 Hz)이면 mult ≈ (10^9 × 2^shift) / 2.4×10^9. shift=32일 때 mult ≈ 1,789,569,706. 1 사이클 = (1 × 1,789,569,706) >> 32 ≈ 0.4167ns — 정확히 10^9 / 2.4×10^9와 일치합니다.
  • 오차 고정소수점 근사의 최대 오차는 1 / 2^shift 나노초/사이클입니다. shift=32이면 사이클당 최대 0.23 피코초 오차로, 1초 누적 시 약 0.56ns 이내입니다.
사이클 → 나노초 변환 파이프라인 RDTSC current_cycle delta 계산 (current - cycle_last) & mask 곱셈 delta × mult 기저시간 합산 basetime.nsec + result >> shift 나노초 추출 오버플로 처리 ns ≥ 10^9 → sec++, ns %= 10^9 timespec {sec, nsec} 수치 예시 (TSC 2.4GHz, shift=32) TSC freq = 2,400,000,000 Hz mult = 1,789,569,706 (≈ 10⁹ × 2³² / 2.4×10⁹) shift = 32 delta = 240,000 cycles (100µs 경과) raw = 240,000 × 1,789,569,706 = 429,496,729,440,000 ns = 429,496,729,440,000 >> 32 = 99,999 ns 결과: ≈ 100,000 ns = 100 µs ✓ 오차: 1ns (고정소수점 절삭, 0.001% 이내)

NTP 보정과 mult 조정

NTP(Network Time Protocol) 데몬이 시스템 시계를 보정하면 커널의 timekeeping 서브시스템은 mult 값을 미세하게 조정합니다. 이를 frequency adjustment라 합니다. adjtimex() 시스템 콜로 주파수 오프셋(ppb 단위)을 지정하면, 커널은 다음과 같이 mult를 보정합니다:

/* kernel/time/timekeeping.c */
static void timekeeping_apply_adjustment(struct timekeeper *tk,
                                          s64 offset_ppb)
{
    /*
     * mult_adj = (base_mult × offset_ppb) / 10^9
     * 양수 ppb: 시계가 느려서 빨리 가게 함 (mult 증가)
     * 음수 ppb: 시계가 빨라서 느리게 함 (mult 감소)
     *
     * 예: +500 ppb = 초당 0.5ns 빠르게
     *     base_mult=1,789,569,706 → 조정 mult=1,789,570,601
     */
    tk->tkr_mono.mult = tk->tkr_mono.base_mult +
        (s64)tk->tkr_mono.base_mult * offset_ppb / NSEC_PER_SEC;

    /* vDSO 갱신: update_vsyscall()이 새 mult를 vvar에 반영 */
}
slew vs step: NTP는 두 가지 보정 방식을 사용합니다. Slew(미세 조정)은 mult를 조정하여 시간 흐름 속도를 변경합니다 — vDSO에서 완전히 투명하게 처리됩니다. Step(즉시 점프)은 basetime.sec/nsec를 직접 변경합니다 — seqcount가 일관성을 보장합니다. 128ms 이상의 오차는 step, 이하는 slew로 처리되며, 이 임계값은 adjtimex()로 조정 가능합니다.

mask 필드의 역할

vdso_data.mask는 하드웨어 카운터의 유효 비트를 마스킹합니다. 대부분의 경우 ~0ULL(64비트 전체)이지만, 일부 clocksource는 제한된 비트 폭을 가집니다:

Clocksource비트 폭mask최대 표현 시간
TSC (x86)64비트0xFFFFFFFFFFFFFFFF~243년 (2.4GHz)
HPET32~64비트0xFFFFFFFF (32비트)~300초 (14.3MHz)
ACPI PM Timer24비트0x00FFFFFF~4.7초 (3.58MHz)
ARM Generic Timer64비트0xFFFFFFFFFFFFFFFF~수백년

mask 연산 (current_cycle - cycle_last) & mask은 카운터 랩어라운드를 정확히 처리합니다. 예를 들어 32비트 HPET에서 current=0x00000100, cycle_last=0xFFFFFE00이면 (0x100 - 0xFFFFFE00) & 0xFFFFFFFF = 0x300 = 정확히 768 사이클 경과입니다.

Coarse 저해상도 경로

do_coarse 함수

CLOCK_REALTIME_COARSECLOCK_MONOTONIC_COARSE는 하드웨어 카운터를 읽지 않는 초경량 경로를 사용합니다. RDTSC 명령어조차 실행하지 않으므로 약 5~10ns 수준의 극한 성능을 제공합니다. 대신 해상도는 tick 주기(보통 1~4ms)로 제한됩니다.

/* lib/vdso/gettimeofday.c */
static __always_inline int
do_coarse(const struct vdso_data *vd,
          clockid_t clk,
          struct __kernel_timespec *ts)
{
    const struct vdso_timestamp *vdso_ts = &vd->basetime[clk];
    u32 seq;

    do {
        seq = vdso_read_begin(vd);

        /* RDTSC 없음! basetime의 sec/nsec만 직접 복사 */
        ts->tv_sec  = vdso_ts->sec;
        ts->tv_nsec = vdso_ts->nsec >> vd->shift;

    } while (vdso_read_retry(vd, seq));

    return 0;
}

Coarse vs High-Resolution 비교

특성CLOCK_MONOTONIC (do_hres)CLOCK_MONOTONIC_COARSE (do_coarse)
RDTSC/카운터 읽기매 호출마다없음
해상도1ns (하드웨어 의존)1~4ms (tick 주기)
레이턴시~15-25ns~5-10ns
사용 사례정밀 타이밍, 벤치마크로그 타임스탬프, 캐시 만료
CPU 파이프라인 영향RDTSC 직렬화(Serialization) 배리어최소 (메모리 읽기만)
do_hres vs do_coarse 실행 경로 do_hres seq 읽기 RDTSC ~7-10 cycles delta×mult>>shift seq 검증 ~15-25ns vs do_coarse seq 읽기 basetime 복사 (메모리) seq 검증 RDTSC 없음! ~5-10ns 0ms 1ms 2ms 3ms
사용 가이드: 로그 타임스탬프, TTL 캐시 만료, 주기적 폴링(Polling) 등 밀리초 이상의 해상도로 충분한 경우 CLOCK_MONOTONIC_COARSE를 사용하면 CLOCK_MONOTONIC 대비 약 2~3배 빠릅니다. 특히 초당 수백만 회 이상 호출하는 고빈도 워크로드에서 RDTSC 직렬화 오버헤드 절감 효과가 큽니다.

Time Namespace와 vDSO 통합

컨테이너 시간 격리

Linux 5.6에서 도입된 Time Namespace는 컨테이너별로 CLOCK_MONOTONICCLOCK_BOOTTIME에 독립적인 오프셋을 적용할 수 있게 합니다. 이를 통해 컨테이너 마이그레이션 시 내부 시계의 연속성을 유지할 수 있습니다.

vDSO는 Time Namespace를 지원하기 위해 별도의 timens 오프셋 페이지를 vvar 영역에 추가 매핑합니다. 이 페이지에는 각 클럭 ID에 대한 초/나노초 오프셋이 저장됩니다:

/* include/linux/time_namespace.h */
struct timens_offset {
    s64    sec;       /* 초 오프셋 */
    u64    nsec;      /* 나노초 오프셋 (시프트됨) */
};

/* vvar 레이아웃 (Time Namespace 활성 시) */
/*
 * vvar 페이지 0: vdso_data[CS_HRES_COARSE] (공통)
 *                vdso_data[CS_RAW]
 * vvar 페이지 1: pvclock_page (KVM)
 * vvar 페이지 2: hvclock_page (Hyper-V)
 * vvar 페이지 3: timens_page (Time Namespace 오프셋)
 *                - offset[CLOCK_MONOTONIC]
 *                - offset[CLOCK_BOOTTIME]
 */

vDSO의 timens 처리 흐름

Time Namespace가 활성화된 프로세스에서 vDSO 함수를 호출하면, clock_modeVDSO_CLOCKMODE_TIMENS로 설정되어 있어 특별한 경로를 타게 됩니다:

/* lib/vdso/gettimeofday.c - timens 처리 */
static __always_inline const struct vdso_data *
__arch_get_timens_vdso_data(const struct vdso_data *vd)
{
    /* timens 페이지의 vdso_data를 가리킴
     * 이 페이지의 clock_mode는 실제 클럭 모드(TSC 등)로 설정되어 있고
     * basetime에는 오프셋이 적용된 값이 들어 있음 */
    return (const struct vdso_data *)
        ((const char *)vd + VVAR_TIMENS_PAGE_OFFSET * PAGE_SIZE);
}

/* __cvdso_clock_gettime_common에서 */
if (unlikely(vd->clock_mode == VDSO_CLOCKMODE_TIMENS)) {
    /* timens 페이지의 vdso_data로 전환 */
    vd = __arch_get_timens_vdso_data(vd);
    /* 이후 do_hres/do_coarse는 오프셋이 적용된 basetime을 사용 */
}
Time Namespace vDSO 매핑 호스트 프로세스 vvar: 기본 vdso_data timens 페이지: 미사용 clock_mode = TSC 컨테이너 (timens) vvar: clock_mode = TIMENS timens 페이지: 오프셋 적용 MONOTONIC +3600s, BOOTTIME +3600s vDSO → timens vdso_data 전환 커널 vdso_data MONOTONIC: sec=86400, nsec=... timens offset: MONOTONIC: +3600s BOOTTIME: +3600s REALTIME: 변경 없음 clock_gettime(CLOCK_MONOTONIC) 결과 비교 호스트: 86400.123456789s 컨테이너: 90000.123456789s (+3600s) CLOCK_REALTIME은 timens 오프셋이 적용되지 않음 (wall clock은 동일해야 함) CLOCK_MONOTONIC, CLOCK_BOOTTIME만 오프셋 적용 (컨테이너 마이그레이션 시 연속성 보장)

Time Namespace 설정 예

# 새 Time Namespace에서 MONOTONIC +1시간 오프셋 설정
$ unshare --time --monotonic 3600 bash

# 컨테이너 내부에서 확인
$ cat /proc/self/timens_offsets
monotonic     3600         0
boottime      3600         0

# clock_gettime 결과: 호스트 대비 +3600초
$ python3 -c "import time; print(time.clock_gettime(time.CLOCK_MONOTONIC))"
90000.123456789  # 호스트에서는 86400.xxx

pvclock과 Hyper-V 클럭

KVM pvclock 메커니즘

가상 머신 내에서 TSC를 직접 읽으면 VM Exit 없이 동작하지만, 호스트와 게스트 간의 TSC 오프셋과 스케일링이 필요합니다. KVM의 pvclock(paravirtual clock)은 이를 위한 반가상화 프로토콜을 제공합니다.

KVM 게스트의 vvar 영역에는 추가로 pvclock_page가 매핑되며, 이 페이지에는 struct pvclock_vsyscall_time_info가 위치합니다:

/* arch/x86/include/asm/pvclock.h */
struct pvclock_vcpu_time_info {
    u32   version;       /* seqcount (홀수=갱신 중) */
    u32   pad0;
    u64   tsc_timestamp;  /* 마지막 갱신 시점의 게스트 TSC */
    u64   system_time;    /* 대응하는 시스템 시간 (ns) */
    u32   tsc_to_system_mul; /* TSC→ns 곱셈 계수 */
    s8    tsc_shift;      /* TSC 시프트 (양수=좌시프트) */
    u8    flags;          /* PVCLOCK_TSC_STABLE_BIT 등 */
    u8    pad[2];
};

/* 게스트 vDSO에서의 pvclock 시간 계산 */
static __always_inline u64
pvclock_clocksource_read(struct pvclock_vcpu_time_info *src)
{
    u64 delta, ret;
    u32 version;

    do {
        version = src->version;
        smp_rmb();

        delta = __native_read_tsc() - src->tsc_timestamp;

        /* TSC 스케일링: delta = (delta << shift) * mul >> 32 */
        if (src->tsc_shift >= 0)
            delta <<= src->tsc_shift;
        else
            delta >>= -src->tsc_shift;

        ret = src->system_time +
              __pvclock_mul_u64_u32(delta, src->tsc_to_system_mul);

        smp_rmb();
    } while ((src->version & 1) || version != src->version);

    return ret;
}
KVM pvclock → vDSO 시간 읽기 경로 게스트 유저 공간 clock_gettime() vDSO: RDTSC (VM Exit 없음) pvclock_page 읽기 TSC offset + scale 시간 계산 (guest_tsc - tsc_timestamp) × tsc_to_system_mul >> 32 게스트 커널 vdso_data (vvar 매핑) pvclock_page (추가 매핑) KVM 호스트 kvm_guest_time_update() vCPU 스케줄 시 갱신 TSC offset 계산: host_tsc × scale + offset 라이브 마이그레이션: 새 호스트 TSC에 맞춰 재계산 공유 메모리

Hyper-V 클럭 (hvclock)

Hyper-V 게스트에서는 HV_REFERENCE_TSC_PAGE MSR을 통해 설정된 참조 TSC 페이지가 사용됩니다. 구조는 pvclock과 유사하지만, Microsoft의 TLFS(Top-Level Functional Specification)에 따른 형식입니다:

/* arch/x86/include/asm/hyperv-tlfs.h */
struct ms_hyperv_tsc_page {
    volatile u32  tsc_sequence;    /* seqcount */
    u32                 reserved1;
    volatile u64  tsc_scale;       /* TSC 스케일링 팩터 */
    volatile s64  tsc_offset;      /* TSC 오프셋 */
    u64                 reserved2[509]; /* 4KB 페이지 패딩 */
};

/* 시간 계산: ns = (rdtsc() × tsc_scale >> 64) + tsc_offset */
가상화 환경 주의: VM 라이브 마이그레이션 시 호스트 간 TSC 주파수가 다르면 pvclock/hvclock의 스케일링 계수가 변경됩니다. 이 과정에서 vdso_data의 seqcount가 갱신되므로, 마이그레이션 중 시간 읽기를 수행하면 재시도 루프가 발생할 수 있지만, 결과의 정확성은 보장됩니다.

vDSO 메모리 순서 보장(Ordering)

메모리 배리어(Memory Barrier)의 역할

vDSO의 seqcount 프로토콜은 정확한 메모리 순서 보장 없이는 동작하지 않습니다. 현대 CPU의 비순차(out-of-order) 실행과 store buffer 최적화로 인해, 프로그램 순서와 실제 메모리 접근 순서가 다를 수 있습니다.

seqcount 메모리 배리어 프로토콜 커널 (쓰기 측) WRITE_ONCE(seq, seq+1) /* 홀수 */ smp_wmb() /* 쓰기 배리어 */ ↓ 배리어 이후 데이터 쓰기 ↓ vdata->clock_mode = TSC vdata->cycle_last = tsc_val vdata->mult = new_mult vdata->basetime[...] = ... smp_wmb() /* 쓰기 배리어 */ WRITE_ONCE(seq, seq+1) /* 짝수 */ 유저 공간 (읽기 측) s = READ_ONCE(seq) /* 짝수 확인 */ smp_rmb() /* 읽기 배리어 */ ↓ 배리어 이후 데이터 읽기 ↓ mode = vd->clock_mode cycles = RDTSC - vd->cycle_last ns = cycles * vd->mult >> shift ns += vd->basetime[clk].nsec smp_rmb() /* 읽기 배리어 */ READ_ONCE(seq) != s ? 재시도

아키텍처별 배리어 구현

배리어x86_64ARM64RISC-V역할
smp_wmb() barrier() (컴파일러) DMB ISHST fence w,w 쓰기 순서 보장
smp_rmb() barrier() (컴파일러) DMB ISHLD fence r,r 읽기 순서 보장
READ_ONCE() volatile 접근 volatile 접근 volatile 접근 컴파일러 최적화(Compiler Optimization) 방지
WRITE_ONCE() volatile 접근 volatile 접근 volatile 접근 스토어 분할/병합 방지
x86 TSO: x86의 강한 메모리 모델(Total Store Order)에서는 smp_wmb()smp_rmb()가 단순한 컴파일러 배리어(asm volatile("" ::: "memory"))로 구현됩니다. 하드웨어가 이미 스토어-로드 순서를 보장하기 때문입니다. 반면 ARM64와 RISC-V는 약한 메모리 모델이므로 실제 하드웨어 배리어 명령어가 필요합니다.

glibc/musl vDSO 통합

glibc의 vDSO 초기화

glibc는 프로세스 시작 시 동적 링커(ld.so)의 초기화 과정에서 vDSO를 자동으로 탐색합니다. _dl_vdso_setup() 함수가 AT_SYSINFO_EHDR에서 vDSO 기저 주소를 얻고, ELF 심볼 테이블을 파싱하여 함수 포인터를 설정합니다:

/* glibc/sysdeps/unix/sysv/linux/dl-vdso-setup.c (간략화) */
void _dl_vdso_setup(void)
{
    /* 1. auxiliary vector에서 vDSO 주소 획득 */
    void *vdso_ehdr = (void *)GLRO(dl_sysinfo_ehdr);
    if (!vdso_ehdr) return;

    /* 2. ELF 동적 심볼 해시 테이블 조회 */
    void *p;

    /* 3. 버전 매칭: "LINUX_2.6" 심볼 버전 검사 */
    p = _dl_vdso_vsym("__vdso_clock_gettime",
                       "LINUX_2.6");
    if (p) GLRO(dl_vdso_clock_gettime) = p;

    p = _dl_vdso_vsym("__vdso_gettimeofday",
                       "LINUX_2.6");
    if (p) GLRO(dl_vdso_gettimeofday) = p;

    p = _dl_vdso_vsym("__vdso_getcpu",
                       "LINUX_2.6");
    if (p) GLRO(dl_vdso_getcpu) = p;
}

/* clock_gettime 호출 시 */
int __clock_gettime(clockid_t clk_id, struct timespec *tp)
{
    /* vDSO 함수가 존재하면 직접 호출 */
    int (*vdso_cgt)(clockid_t, struct timespec *) =
        GLRO(dl_vdso_clock_gettime);

    if (vdso_cgt) {
        int ret = vdso_cgt(clk_id, tp);
        if (ret == 0)
            return 0;
        /* ret != 0: vDSO 비활성, syscall 폴백 */
    }

    /* 시스템 콜 경로 */
    return INLINE_SYSCALL_CALL(clock_gettime, clk_id, tp);
}

musl의 vDSO 통합

musl libc는 glibc보다 경량화된 vDSO 통합을 제공합니다. __vdsosym() 함수로 심볼을 조회하되, GNU 해시(Hash) 대신 SysV 해시를 우선 사용합니다:

/* musl/src/internal/vdso.c (간략화) */
static void *__vdsosym(const char *vername, const char *name)
{
    /* getauxval(AT_SYSINFO_EHDR)로 vDSO 기저 주소 획득 */
    size_t base = __hwcap;  /* 또는 getauxval */

    /* ELF 해시 -> 심볼 조회 -> 버전 매칭 */
    /* ... */
    return (void *)(base + sym->st_value);
}

/* musl의 clock_gettime */
int clock_gettime(clockid_t clk, struct timespec *ts)
{
    int (*f)(clockid_t, struct timespec *) =
        (void *)__vdsosym("LINUX_2.6", "__vdso_clock_gettime");
    int r = f ? f(clk, ts) : -1;

    if (r == -1) /* 폴백 */
        r = __syscall(SYS_clock_gettime, clk, ts);

    return __syscall_ret(r);
}

C 라이브러리별 vDSO 지원 비교

C 라이브러리vDSO 탐색심볼 해시버전 매칭폴백 전략
glibc_dl_vdso_setup()GNU hash + SysVLINUX_2.6INLINE_SYSCALL_CALL
musl__vdsosym()SysV hashLINUX_2.6__syscall()
Bionic (Android)__libc_init_vdso()GNU hashLINUX_2.6syscall()
uclibc-ng제한적 지원SysV hash선택적syscall()

32비트 호환 모드 vDSO

x86_32 vDSO (IA-32 호환)

64비트 커널에서 32비트 프로세스를 실행할 때, 커널은 별도의 32비트 vDSO 이미지 (vdso_image_32)를 매핑합니다. 32비트 vDSO에는 64비트에 없는 특수 함수가 포함됩니다:

심볼용도설명
__kernel_vsyscall 시스템 콜 진입 32비트 프로세스의 시스템 콜 진입점. SYSENTER 또는 INT 0x80을 사용하여 커널 모드로 전환. glibc가 AT_SYSINFO에서 이 주소를 얻음
__vdso_clock_gettime64 Y2038 대응 32비트 time_t(signed 32-bit)의 2038년 오버플로 문제를 해결하기 위해 64비트 timespec을 반환. clock_gettime64() 시스템 콜의 vDSO 버전
__vdso_clock_gettime 레거시 32비트 32비트 timespec(32비트 time_t) 반환. 레거시 호환
; arch/x86/entry/vdso/vdso32/system_call.S
; __kernel_vsyscall - 32비트 시스템 콜 진입
__kernel_vsyscall:
    push  %ecx          ; SYSENTER가 ECX, EDX를 덮어쓰므로 저장
    push  %edx
    push  %ebp
    mov   %esp, %ebp   ; SYSENTER가 ESP를 보존하지 않으므로 저장
    sysenter               ; 커널 모드 전환 (SYSCALL보다 느림)

    ; SYSEXIT로 돌아온 후:
    pop   %ebp
    pop   %edx
    pop   %ecx
    ret
x32 ABI: x32는 64비트 명령어 세트를 사용하면서 32비트 포인터를 사용하는 혼합 ABI입니다. CONFIG_X86_X32_ABI=y 시 별도의 x32 vDSO가 빌드됩니다. x32 vDSO는 64비트 RDTSC를 사용하면서 32비트 포인터로 vvar에 접근합니다.

MIPS/PowerPC/S390x vDSO

MIPS vDSO

MIPS 아키텍처의 vDSO는 arch/mips/vdso/에 구현되어 있으며, 하드웨어 카운터로 MIPS CP0의 Count 레지스터(Coprocessor 0, Register 9)를 사용합니다. 이 카운터는 CPU 주파수의 절반 속도로 증가합니다:

/* arch/mips/include/asm/vdso/gettimeofday.h */
static __always_inline u64
__arch_get_hw_counter(s32 clock_mode, const struct vdso_data *vd)
{
    u64 cycle;

    if (clock_mode != VDSO_CLOCKMODE_NONE) {
        /* MIPS CP0 Count 레지스터: 32비트 카운터 */
        asm volatile(
            "rdhwr %0, $2"   /* $2 = CP0_COUNT */
            : "=r" (cycle)
        );
        return cycle;
    }
    return U64_MAX;
}

PowerPC vDSO

PowerPC/POWER 아키텍처는 강력한 vDSO 지원을 제공하며, 하드웨어 카운터로 Timebase Register (TBR)를 사용합니다. PowerPC vDSO는 다른 아키텍처보다 더 많은 함수를 제공합니다:

심볼용도
__kernel_clock_gettimeclock_gettime (나노초)
__kernel_clock_getres클럭 해상도 조회
__kernel_gettimeofdaygettimeofday (마이크로초)
__kernel_timetime (초)
__kernel_get_tbfreqTimebase 주파수 조회
__kernel_get_syscall_map시스템 콜 맵 조회
__kernel_sync_dicacheD-cache/I-cache 동기화
__kernel_sigtramp_rt32/64시그널(Signal) 트램폴린
/* arch/powerpc/include/asm/vdso/gettimeofday.h */
static __always_inline u64
__arch_get_hw_counter(s32 clock_mode, const struct vdso_data *vd)
{
    u64 tb;

    /* mftb: Move From Time Base Register
     * Timebase는 고정 주파수 (보통 512MHz POWER9)
     * 전원 상태와 무관하게 일정 */
    asm volatile("mftb %0" : "=r"(tb));

    return tb;
}

S390x vDSO

IBM Z(s390x)는 TOD (Time-Of-Day) 클럭을 사용합니다. TOD 클럭은 1972년 1월 1일부터의 시간을 마이크로초 단위로 제공하며, STCK(Store Clock) 또는 STCKE(Store Clock Extended) 명령어로 읽습니다:

/* arch/s390/include/asm/vdso/gettimeofday.h */
static __always_inline u64
__arch_get_hw_counter(s32 clock_mode, const struct vdso_data *vd)
{
    u64 tod;

    /* STCKE: 128비트 확장 TOD 클럭 (피코초 해상도)
     * 상위 64비트만 사용 (마이크로초 해상도) */
    asm volatile("stck %0" : "=Q"(tod));

    return tod;
}
/* TOD epoch: 1900-01-01, Linux epoch: 1970-01-01
 * 오프셋: 0x7d91048bca000000 (70년 차이) */
아키텍처별 vDSO 하드웨어 카운터 x86_64 RDTSC/RDTSCP 64비트 카운터 ~GHz 주파수 invariant TSC cpu_relax: PAUSE 직렬화 비용: ~7-10 cycles ~15-25ns ARM64 CNTVCT_EL0 64비트 카운터 고정 주파수 (보통 19.2MHz) 직렬화: ISB 배리어: DMB ISHLD ~20-40ns RISC-V RDTIME (CSR) 64비트 카운터 mtime 미러 CLINT/ACLINT 배리어: fence r,r flush: fence.i (icache) ~25-50ns PowerPC mftb (Timebase) 64비트 카운터 고정 주파수 (~512MHz P9) 캐시 동기화: dcbst/icbi sigtramp: vDSO 내장 ~20-35ns S390x STCK/STCKE 64/128비트 TOD 클럭 epoch: 1900 해상도: 피코초 (STCKE) 직렬화: 암시적 ~15-25ns

LoongArch vDSO

LoongArch 아키텍처는 Linux 6.1에서 vDSO 지원이 추가되었습니다. 하드웨어 카운터로 Stable Counter를 사용하며, RDTIME 계열 명령어로 접근합니다:

/* arch/loongarch/include/asm/vdso/gettimeofday.h */
static __always_inline u64
__arch_get_hw_counter(s32 clock_mode, const struct vdso_data *vd)
{
    u64 count;

    /* rdtime.d: Stable Counter 읽기
     * Stable Counter는 CPU 주파수와 독립적인 고정 주파수 카운터
     * 일반적으로 100MHz */
    asm volatile(
        "rdtime.d %0, $zero"
        : "=r" (count)
    );

    return count;
}
속성LoongArch
하드웨어 카운터Stable Counter (RDTIME.D)
카운터 주파수고정 (보통 100MHz)
카운터 비트 폭64비트
시스템 콜 진입SYSCALL
vDSO 함수clock_gettime, gettimeofday, clock_getres, time
소스 위치arch/loongarch/vdso/
커널 버전Linux 6.1+ (2022)

vDSO 대체 명령어 패칭

alternatives 프레임워크와 vDSO

x86 커널은 부팅 시 CPU 기능에 따라 명령어를 동적으로 패칭하는 alternatives 프레임워크를 사용합니다. vDSO 이미지에도 이 메커니즘이 적용되어, 빌드 시 생성된 범용 코드가 실행 시점에 최적 명령어로 교체됩니다.

/* arch/x86/entry/vdso/vma.c - vDSO alternatives 패칭 */
static void __init vdso_setup(void)
{
    /* vDSO 이미지의 alternatives 테이블을 순회하며
     * CPU 기능에 맞는 명령어로 패칭 */
    apply_alternatives((struct alt_instr *)
        (vdso_image_64.data + vdso_image_64.alt),
        (struct alt_instr *)
        (vdso_image_64.data + vdso_image_64.alt
                             + vdso_image_64.alt_len));
}

패칭 대상 명령어

기본 명령어대체 명령어CPU 기능 플래그이점
RDTSC RDTSCP X86_FEATURE_RDTSCP RDTSCP는 TSC 읽기를 직렬화하여 순서 보장. 추가로 CPU ID를 ECX에 반환 (getcpu에 사용)
LFENCE; RDTSC RDTSCP X86_FEATURE_RDTSCP LFENCE+RDTSC 2명령어 시퀀스를 RDTSCP 1명령어로 대체 (직렬화 + 읽기 동시 수행)
NOP LFENCE Spectre 완화 필요 시 추측 실행 공격 방어를 위한 배리어 삽입
패칭 시점: vDSO alternatives 패칭은 커널 초기화 시(__init) 한 번만 수행됩니다. 패칭 후의 vDSO 이미지가 모든 프로세스에 공유되므로, 런타임 오버헤드는 전혀 없습니다. 패칭은 vDSO가 유저 공간에 매핑되기 전에 완료되어야 하므로 vdso_setup()arch_setup_additional_pages()보다 먼저 호출됩니다.

vDSO 트러블슈팅

vDSO가 비활성화되는 원인

vDSO 고속 경로가 동작하지 않아 시스템 콜로 폴백하는 상황은 성능에 큰 영향을 미칩니다. 다음은 vDSO가 비활성화되는 주요 원인과 진단/해결 방법입니다:

증상원인진단 명령해결책
clock_gettime이 strace에 표시 clock_mode = NONE dmesg | grep -i clocksource clocksource=tsc 부트 파라미터
TSC unstable 메시지 TSC 불안정 판정 dmesg | grep -i tsc tsc=reliable (확인 후) 또는 VM TSC 설정 확인
HPET로 전환됨 clocksource watchdog가 TSC를 불신 cat /sys/.../current_clocksource BIOS에서 TSC 설정 확인, Invariant TSC 지원 CPU 필요
[vdso] 매핑 없음 vdso=0 부트 파라미터 cat /proc/self/maps | grep vdso 부트 파라미터에서 vdso=0 제거
VM에서 느린 clock_gettime pvclock 미설정 dmesg | grep -i pvclock KVM: -cpu host, VMware: TSC offsetting 활성화

vDSO 상태 종합 진단 스크립트

#!/bin/bash
# vdso-diag.sh: vDSO 상태 종합 진단

echo "=== vDSO/vvar 매핑 ==="
cat /proc/self/maps | grep -E 'vdso|vvar|vsyscall'

echo ""
echo "=== Clocksource ==="
echo "현재: $(cat /sys/devices/system/clocksource/clocksource0/current_clocksource)"
echo "가용: $(cat /sys/devices/system/clocksource/clocksource0/available_clocksource)"

echo ""
echo "=== TSC 상태 ==="
dmesg | grep -i 'tsc\|clocksource' | tail -10

echo ""
echo "=== CPU TSC 기능 ==="
grep -o 'tsc[a-z_]*\|rdtscp\|constant_tsc\|nonstop_tsc\|tsc_known_freq' /proc/cpuinfo | sort -u

echo ""
echo "=== 커널 CONFIG ==="
zcat /proc/config.gz 2>/dev/null | grep -i 'vdso\|vsyscall\|pvclock' || \
    grep -i 'vdso\|vsyscall\|pvclock' /boot/config-$(uname -r) 2>/dev/null

echo ""
echo "=== vsyscall 모드 ==="
cat /proc/cmdline | grep -o 'vsyscall=[^ ]*' || echo "(기본값: emulate)"

echo ""
echo "=== vDSO 심볼 ==="
# vDSO ELF를 추출하여 심볼 확인
python3 -c "
import ctypes, struct
base = ctypes.CDLL(None).getauxval(33)  # AT_SYSINFO_EHDR
if base:
    print(f'vDSO base: {hex(base)}')
else:
    print('vDSO not available!')
"

echo ""
echo "=== 간이 벤치마크 ==="
# 100만 회 clock_gettime 호출 시간 측정
python3 -c "
import time
N = 1000000
start = time.perf_counter_ns()
for _ in range(N):
    time.clock_gettime(time.CLOCK_MONOTONIC)
elapsed = time.perf_counter_ns() - start
print(f'clock_gettime: {elapsed/N:.1f} ns/call ({N} iterations)')
print(f'  (< 50ns = vDSO 활성, > 100ns = syscall 폴백 의심)')
"

perf를 활용한 vDSO 분석

# vDSO 심볼로 프로파일링
$ perf record -g -- ./time_intensive_app
$ perf report
# [vdso] 항목에서 __vdso_clock_gettime의 비중 확인

# clock_gettime 호출 빈도 추적
$ perf stat -e 'syscalls:sys_enter_clock_gettime' -- ./app
# 0이면 모두 vDSO 경로, 양수면 syscall 폴백 발생

# BPF를 활용한 vDSO 폴백 추적
$ sudo bpftrace -e '
  tracepoint:syscalls:sys_enter_clock_gettime {
    @fallback[comm] = count();
  }
'

흔한 실수와 함정

vDSO 관련 프로그래밍 함정

실수증상원인해결
seccomp으로 시간 함수 감사 시도 clock_gettime 호출이 seccomp 로그에 나타나지 않음 vDSO 경로는 시스템 콜을 거치지 않으므로 seccomp 필터가 적용되지 않음 clocksource를 HPET로 강제 전환하여 vDSO 비활성화 (성능 저하 감수) 또는 eBPF uprobe 사용
fork() 후 vDSO 주소가 동일 자식 프로세스의 vDSO 주소를 부모로부터 추측 가능 fork()는 주소 공간을 COW 복제하므로 ASLR 재무작위화 미수행 보안이 중요하면 fork() 후 execve()를 거쳐 새 ASLR 주소 할당
RDTSC를 wall-clock 시간으로 사용 TSC 값이 시스템 시간과 점점 벗어남 TSC는 NTP 보정이 적용되지 않는 원시 카운터. CPU 주파수 스케일링 영향 가능 wall-clock이 필요하면 반드시 clock_gettime(CLOCK_REALTIME) 사용
CLOCK_MONOTONIC_COARSE의 해상도 오해 밀리초 이하 타이밍이 부정확 COARSE 클럭은 tick 주기(1~4ms) 해상도만 제공 나노초 해상도가 필요하면 CLOCK_MONOTONIC 사용
vDSO 함수 포인터를 캐싱하지 않음 ELF 심볼 해시 조회 반복으로 불필요한 오버헤드 수동 vDSO 파싱 시 매 호출마다 심볼을 다시 찾음 초기화 시 한 번 조회한 함수 포인터를 전역/TLS에 캐싱
getcpu() 결과를 오래 캐싱 CPU 어피니티(Affinity) 설정 무시, NUMA 최적화 실패 스레드가 다른 코어로 마이그레이션되면 이전 getcpu 결과는 무효 getcpu()는 필요할 때마다 호출. 캐싱 시 CPU 바인딩 필수
VM에서 vDSO 성능이 기대 이하 clock_gettime이 strace에 나타남 (syscall 폴백) TSC 불안정 판정으로 clocksource가 HPET로 전환됨 KVM: -cpu host, VMware: TSC offsetting 활성화, tsc=reliable 검토

시그널 핸들러에서의 vDSO 사용

vDSO 함수(clock_gettime(), gettimeofday() 등)는 시그널 핸들러 내에서 안전하게 호출할 수 있습니다. 이는 다음 이유로 보장됩니다:

/* 시그널 핸들러에서 안전한 vDSO 사용 예 */
volatile sig_atomic_t last_signal_time_sec;

void signal_handler(int sig)
{
    struct timespec ts;

    /* 안전: clock_gettime은 async-signal-safe이며
     * vDSO 경로에서 락 없이 실행됨 */
    clock_gettime(CLOCK_MONOTONIC, &ts);
    last_signal_time_sec = ts.tv_sec;

    /* 주의: printf 등 async-signal-unsafe 함수는 사용 금지 */
}

vDSO 시그널 트램폴린

일부 아키텍처(특히 ARM, PowerPC)에서 vDSO는 시간 함수 외에 시그널 리턴 트램폴린도 포함합니다. 시그널 핸들러 실행 후 커널로 복귀하기 위한 sigreturn()/rt_sigreturn() 코드가 vDSO에 내장되어 있습니다:

아키텍처vDSO 시그널 트램폴린메커니즘
ARM (32비트) __kernel_sigreturn, __kernel_rt_sigreturn 시그널 프레임의 리턴 주소가 vDSO 내 트램폴린을 가리킴
PowerPC __kernel_sigtramp_rt32, __kernel_sigtramp_rt64 vDSO에 시그널 트램폴린 코드를 포함하여 스택 실행 불필요
x86_64 (vDSO에 미포함) 커널이 sigreturn 코드를 스택 프레임에 직접 배치 또는 restorer 사용
ARM64 __kernel_rt_sigreturn vDSO 내 트램폴린으로 NX 스택에서도 시그널 리턴 가능
NX 스택과 시그널: 스택에 실행 권한(NX bit 비활성)이 없는 현대 시스템에서, 시그널 리턴 트램폴린을 스택에 직접 배치할 수 없습니다. vDSO에 트램폴린을 포함시키면 실행 가능한 코드 영역에서 sigreturn()을 수행할 수 있어 보안과 호환성을 모두 확보합니다.

vDSO 커널 역사

타임라인

vDSO 진화 타임라인 Linux 2.5 (2002) vsyscall 도입 (x86_64): 고정 주소 0xffffffffff600000 gettimeofday, time, getcpu 3개 함수 Linux 2.6.0 (2003) i386 vDSO 도입: __kernel_vsyscall (SYSENTER 지원) Linux 2.6.22 (2007) x86_64 vDSO 도입: ASLR 지원, ELF 형식 vsyscall의 보안 문제 해결 시작 Linux 3.0 (2011) vsyscall 에뮬레이션 모드 도입 (vsyscall=emulate) 네이티브 vsyscall 실행 → 페이지 폴트 기반 에뮬레이션 전환 Linux 4.x (2015~2018) ARM64, RISC-V, PowerPC 등 다중 아키텍처 vDSO 확대 제네릭 vDSO 프레임워크 (lib/vdso/) 도입 Linux 5.3 (2019) 제네릭 vDSO 완성: 아키텍처 독립 gettimeofday.c 통합 32비트 clock_gettime64 vDSO 추가 (Y2038 대응) Linux 5.6 (2020) Time Namespace 도입: vvar에 timens 오프셋 페이지 추가 컨테이너 CLOCK_MONOTONIC/BOOTTIME 격리 Linux 6.x (2022~현재) vDSO GETRANDOM 논의 (커널 엔트로피 풀의 vDSO 노출) LoongArch vDSO 추가, VDSO_CLOCKMODE 확장

실전 사례 분석

데이터베이스: PostgreSQL/MySQL

PostgreSQL은 트랜잭션 커밋 시각(commit timestamp), WAL(Write-Ahead Log) 삽입 시각, 쿼리 실행 시간 측정을 위해 clock_gettime(CLOCK_MONOTONIC)을 대량 호출합니다. TPC-C 벤치마크에서 vDSO 비활성(HPET fallback) 시 약 3~8%의 전체 처리량(Throughput) 저하가 관측됩니다.

# PostgreSQL의 clock_gettime 호출 빈도 측정
$ perf stat -e 'syscalls:sys_enter_clock_gettime' -- \
    pgbench -c 32 -j 4 -T 60 mydb

# vDSO 활성 시: syscall 0회 (모두 vDSO 경로)
# vDSO 비활성 시: 초당 ~200만 회 syscall

# MySQL (InnoDB)도 유사:
# - 트랜잭션 타임스탬프, redo log LSN 기록
# - innodb_use_native_aio와 결합 시 clock_gettime 호출 급증

금융 트레이딩 (HFT)

고빈도 거래(HFT) 시스템에서 주문 타임스탬프의 지연(Latency)은 직접적인 비용입니다. 매칭 엔진은 주문마다 clock_gettime(CLOCK_REALTIME)을 호출하며, 초당 수백만 주문을 처리합니다:

/* HFT 스타일: 나노초 타임스탬프 + CPU 고정 */
struct order {
    uint64_t timestamp_ns;   /* 주문 타임스탬프 */
    uint32_t symbol_id;
    int64_t  price;
    int32_t  quantity;
    uint8_t  side;            /* BUY/SELL */
};

static inline uint64_t get_timestamp_ns(void)
{
    struct timespec ts;
    clock_gettime(CLOCK_REALTIME, &ts);
    return (uint64_t)ts.tv_sec * 1000000000ULL + ts.tv_nsec;
    /* vDSO: ~15ns, syscall: ~200ns → 13배 차이
     * 초당 500만 주문 × 185ns 절감 = 925ms/초 절감 */
}

DPDK/네트워크 패킷(Packet) 처리

DPDK(Data Plane Development Kit)는 패킷 타임스탬프, 속도 제한, 타임아웃 검사를 위해 TSC를 직접 읽지만, 일부 경로에서는 wall-clock 시간이 필요합니다:

/* DPDK 스타일: TSC 직접 읽기 + 주기적 wall-clock 동기화 */
static inline uint64_t rte_rdtsc(void)
{
    /* DPDK는 vDSO 대신 RDTSC를 직접 사용
     * vDSO의 delta/mult/shift 연산 오버헤드도 회피 */
    uint32_t lo, hi;
    asm volatile("rdtsc" : "=a"(lo), "=d"(hi));
    return ((uint64_t)hi << 32) | lo;
}

/* 주기적으로 TSC↔wall-clock 매핑 갱신 */
void rte_timer_subsystem_init(void)
{
    /* clock_gettime(CLOCK_REALTIME) = vDSO 사용 */
    /* TSC 주파수 = sysfs 또는 /proc/cpuinfo에서 읽기 */
}

Redis 인메모리 캐시

Redis는 키 만료, 이벤트 루프(Event Loop) 타이밍, 슬로우 로그에 시간 함수를 빈번히 호출합니다. Redis 6.0+는 CLOCK_MONOTONIC_COARSE를 적극 활용하여 밀리초 정밀도가 충분한 경우 RDTSC 비용을 완전히 제거합니다:

/* Redis src/server.c (간략화) */
mstime_t mstime(void) {
    struct timespec ts;
    /* COARSE: ~5ns (RDTSC 없음), 밀리초 정밀도 충분 */
    clock_gettime(CLOCK_MONOTONIC_COARSE, &ts);
    return (mstime_t)ts.tv_sec * 1000 +
           ts.tv_nsec / 1000000;
}

/* 반면 슬로우 로그는 정밀 시간 사용 */
ustime_t ustime(void) {
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);  /* RDTSC 경로 */
    return (ustime_t)ts.tv_sec * 1000000 +
           ts.tv_nsec / 1000;
}
실전 교훈: 대부분의 애플리케이션에서 vDSO는 완전히 투명하게 동작합니다. 성능 문제가 발생하는 주요 원인은 (1) TSC 불안정으로 인한 HPET 폴백, (2) 가상 머신에서 pvclock 미설정, (3) 부트 파라미터로 vDSO 비활성화입니다. 앞서 설명한 진단 스크립트로 이러한 문제를 빠르게 식별할 수 있습니다.

vDSO getrandom (Linux 6.11+)

배경: 시스템 콜 없는 난수 생성

Linux 6.11(2024)에서 getrandom()의 vDSO 구현이 도입되었습니다. 암호학적으로 안전한 난수를 시스템 콜 없이 유저 공간에서 직접 생성할 수 있습니다. 이는 TLS 핸드셰이크, UUID 생성, ASLR 시드 등 난수를 빈번히 사용하는 워크로드에서 상당한 성능 향상을 제공합니다.

구현 원리

vDSO getrandom은 커널의 ChaCha20 기반 CSPRNG(Cryptographically Secure Pseudo-Random Number Generator)의 일부 상태를 유저 공간에 노출합니다. 각 스레드는 독립적인 ChaCha20 상태를 가지며, 커널이 주기적으로 시드를 재갱신합니다:

/* include/vdso/getrandom.h */
struct vdso_rng_data {
    u64    generation;     /* 시드 세대 번호 */
    u8     is_ready;       /* CSPRNG 초기화 완료 여부 */
};

/* Per-thread vDSO RNG 상태 (TLS 또는 opaque state) */
struct vgetrandom_state {
    union {
        struct {
            u8     batch[256];  /* 미리 생성된 난수 버퍼 */
            u32    key[8];      /* ChaCha20 키 */
        };
        u8 batch_key[256 + 32];
    };
    u64    generation;     /* 마지막 시드 세대 */
    u8     pos;            /* batch 내 현재 위치 */
    u8     in_use;         /* fork 감지용 */
};

/* vDSO getrandom 동작 흐름:
 * 1. batch에 남은 난수가 있으면 즉시 복사 후 반환
 * 2. batch 소진 시 ChaCha20으로 새 batch + 새 key 생성
 * 3. generation 변경 시 커널에서 새 시드 획득 (syscall)
 */
vDSO getrandom 동작 흐름 getrandom(buf, len, 0) batch에 충분? memcpy batch→buf (~5ns) 아니오 generation 일치? ChaCha20 (유저) 새 batch + 새 key 생성 아니오 syscall: 새 시드 획득 (드물게 발생, reseeding) 성능 비교 vDSO batch hit: ~5ns ChaCha20 refill: ~100ns syscall reseed: ~200ns
요구사항: vDSO getrandom은 Linux 6.11+ 커널과 glibc 2.39+가 필요합니다. CONFIG_VDSO_GETRANDOM=y가 설정되어야 하며, 현재 x86_64에서 먼저 지원됩니다. 이전 커널에서는 자동으로 getrandom() 시스템 콜로 폴백합니다.

성능 최적화 팁

애플리케이션 레벨 최적화

최적화 기법적용 조건예상 이점코드 예
COARSE 클럭 사용 밀리초 정밀도 충분 RDTSC 제거, ~5ns CLOCK_MONOTONIC_COARSE
시간 캐싱 짧은 구간 동일 시간 허용 호출 횟수 95%+ 감소 이벤트 루프 시작 시 1회 호출
배치 타임스탬프 연속 이벤트 처리 N개 이벤트에 1회 호출 로그 배치 내 동일 타임스탬프
TSC 직접 읽기 상대 시간만 필요 vDSO 연산 제거, ~7ns __rdtsc() + 주기적 보정

시간 캐싱 패턴

/* 이벤트 루프 기반 서버의 시간 캐싱 패턴 */
struct event_loop {
    struct timespec cached_time;    /* 루프 반복당 1회 갱신 */
    uint64_t        cached_ms;      /* 밀리초 캐시 */
};

void event_loop_run(struct event_loop *loop)
{
    while (loop->running) {
        /* 루프 시작 시 1회만 시간 읽기 */
        clock_gettime(CLOCK_MONOTONIC_COARSE,
                      &loop->cached_time);
        loop->cached_ms = loop->cached_time.tv_sec * 1000 +
                         loop->cached_time.tv_nsec / 1000000;

        /* 이번 반복의 모든 이벤트는 cached_ms 사용 */
        int n = epoll_wait(loop->epfd, events, MAX_EVENTS, 1);
        for (int i = 0; i < n; i++)
            handle_event(&events[i], loop->cached_ms);
            /* handle_event 내에서 clock_gettime 호출 불필요 */
    }
}

/* Nginx, HAProxy, Redis 등이 유사한 패턴 사용 */

TSC 직접 읽기 vs vDSO 트레이드오프

시간 읽기 방법별 레이턴시 스펙트럼 레이턴시 (ns, 로그 스케일) 1 5 15 50 150 500 time() ~2ns COARSE vDSO ~5-10ns RDTSC ~7ns HRES vDSO ~15-25ns syscall (KPTI off) ~60-80ns syscall (KPTI on) ~150-250ns
결론: 일반 애플리케이션은 vDSO를 통한 clock_gettime()이 최적의 선택입니다. RDTSC 직접 읽기는 상대 시간(elapsed time)만 필요한 극한 성능 워크로드에 적합하지만, TSC 주파수 보정, CPU 마이그레이션 처리, NTP 동기화 등을 직접 관리해야 합니다. 대부분의 경우 vDSO가 이 모든 복잡성을 투명하게 처리해 줍니다.

참고자료

다음 학습:
  • 시스템 콜 -- vDSO의 폴백 경로인 시스템 콜의 전체 진입/복귀 경로
  • ktime / Clock -- vDSO가 읽는 clocksource와 timekeeper 서브시스템
  • 타이머 -- hrtimer와 tick 기반 타이머의 vDSO 갱신 트리거
  • Time Namespaces -- vvar의 timens 오프셋 페이지와 컨테이너 시간 격리

vDSO와 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.