vDSO (Virtual Dynamic Shared Object) 심화
vDSO는 리눅스 커널이 유저 공간 프로세스의 주소 공간에 자동으로 매핑하는 작은 공유 라이브러리입니다.
clock_gettime(), gettimeofday(), time(), getcpu() 같은 빈번히 호출되는
시스템 콜을 커널 모드 전환 없이 유저 공간에서 직접 실행할 수 있게 하여 수십 나노초의 오버헤드를 제거합니다.
이 문서는 vDSO의 내부 아키텍처, 커널 구현, 아키텍처별 차이, 성능 특성, 디버깅 기법까지 종합적으로 다룹니다.
핵심 요약
- vDSO -- 커널이 유저 공간에 매핑하는 가상 공유 라이브러리. 시스템 콜 없이 커널 데이터에 접근 가능
- vvar -- 커널이 업데이트하고 유저 공간이 읽기 전용으로 접근하는 공유 데이터 페이지
- AT_SYSINFO_EHDR -- ELF auxiliary vector를 통해 전달되는 vDSO의 기저 주소
- vsyscall -- vDSO의 전신. 고정 주소 사용으로 ASLR 불가, 보안 취약점 존재
- seqcount -- vvar 데이터의 일관성을 보장하는 순서 잠금 메커니즘
단계별 이해
- 프로세스가 시작됨
커널의execve()경로에서 ELF 바이너리를 로드한 후,arch_setup_additional_pages()가 vDSO 이미지를 프로세스 주소 공간에 매핑합니다. - vDSO 주소가 전달됨
커널은 ELF auxiliary vector의AT_SYSINFO_EHDR엔트리에 vDSO 기저 주소를 기록합니다. 동적 링커(ld.so)가 이를 읽고 vDSO 심볼을 연결합니다. - glibc가 vDSO 함수를 사용
clock_gettime()을 호출하면 glibc는 vDSO의__vdso_clock_gettime()을 직접 호출합니다. 시스템 콜 진입 없이 vvar 페이지에서 시간 데이터를 읽습니다. - 커널이 vvar를 갱신
타이머 인터럽트나 timekeeping 업데이트 시 커널은 vvar 페이지의vdso_data구조체를 갱신합니다. seqcount로 원자적 일관성을 보장합니다.
vDSO 개요와 탄생 배경
시스템 콜 오버헤드 문제
리눅스에서 유저 공간 프로세스가 커널 서비스를 요청하려면 시스템 콜을 통해 커널 모드로 전환해야 합니다.
x86_64에서 SYSCALL 명령어를 통한 모드 전환은 다음과 같은 비용을 수반합니다:
- CPU 파이프라인 플러시 -- 권한 수준 변경으로 인한 파이프라인 무효화
- 레지스터 저장/복원 -- 유저 공간 레지스터를
pt_regs에 저장하고 커널 스택으로 전환 - KPTI (Kernel Page Table Isolation) -- Meltdown 취약점 완화를 위한 페이지 테이블 전환 (CR3 교체)
- Spectre 완화 -- IBRS/STIBP/retpoline 등 추측 실행 방어
- TLB 플러시 -- PCID 미사용 시 커널/유저 전환마다 TLB 전체 무효화
- 감사(audit)/보안 훅 -- seccomp, ptrace, audit 서브시스템의 검사 오버헤드
이러한 오버헤드는 단일 시스템 콜당 약 100~300 나노초(KPTI 활성 시)에 달합니다.
gettimeofday()나 clock_gettime()처럼 초당 수백만 번 호출되는 함수에서는
이 비용이 전체 성능에 심각한 영향을 미칩니다.
vsyscall의 등장과 한계
리눅스 커널 2.5 시대에 x86 아키텍처에서 vsyscall 메커니즘이 도입되었습니다.
고정된 가상 주소 0xffffffffff600000에 특수 페이지를 매핑하여 gettimeofday(),
time(), getcpu() 3개 함수를 시스템 콜 없이 호출할 수 있게 했습니다.
그러나 vsyscall에는 근본적인 보안 문제가 있었습니다:
- 고정 주소 -- 모든 프로세스에서 동일한 가상 주소를 사용하여 ASLR(Address Space Layout Randomization) 적용 불가
- ROP 가젯 -- 공격자가 고정 주소의 코드를 Return-Oriented Programming 체인에 활용 가능
- 확장 불가 -- 3개 함수 슬롯만 지원하며, 새로운 함수 추가 시 ABI 호환성 파괴
- 다중 아키텍처 미지원 -- x86_64 전용 메커니즘으로 다른 아키텍처에 적용 불가
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=emulate)로 동작합니다.
레거시 호환성을 위해 vsyscall 주소에 접근하면 실제 시스템 콜로 대체 실행되며, vsyscall=none으로 완전히 비활성화할 수도 있습니다.
vDSO 아키텍처
전체 구조
vDSO 메커니즘은 커널 측과 유저 측의 긴밀한 협력으로 작동합니다. 커널은 두 종류의 특수 페이지를 프로세스 주소 공간에 매핑합니다:
- vDSO 코드 페이지 -- 실행 가능한 ELF 공유 라이브러리 이미지.
__vdso_clock_gettime()등의 함수 코드를 포함 - vvar 데이터 페이지 -- 읽기 전용 데이터 페이지. 커널이 갱신하는 시간 데이터(
vdso_data), 아키텍처별 데이터 등을 포함
유저 공간에서 vDSO 함수를 호출하면, 함수는 vvar 페이지의 데이터를 읽어 결과를 계산합니다. 커널 모드 전환이 전혀 발생하지 않으므로 시스템 콜 대비 10~50배 빠릅니다.
vDSO 함수 호출 흐름
유저 공간에서 clock_gettime(CLOCK_MONOTONIC, &ts)를 호출하면 다음 경로를 따릅니다:
- glibc의
clock_gettime()래퍼가 vDSO의__vdso_clock_gettime()함수 포인터를 호출 - vDSO 함수가 vvar 페이지의
vdso_data에서seq(seqcount)를 읽음 - 현재 TSC 값을
RDTSC명령어로 읽음 vdso_data의cycle_last,mult,shift를 사용하여 경과 시간 계산wall_time_sec,wall_time_snsec에 경과 시간을 더하여 최종 시간 산출seq값이 변경되지 않았는지 확인 (변경되었으면 1단계부터 재시도)- 결과를
struct timespec에 저장하고 반환
SYSCALL/SYSRET 명령어가 실행되지 않습니다.
유저 모드(Ring 3)에서 완전히 실행되므로 커널 모드 전환 비용이 0입니다.
vDSO가 지원하지 않는 클럭 ID(예: CLOCK_PROCESS_CPUTIME_ID)의 경우에만 실제 시스템 콜로 폴백합니다.
vsyscall과 vDSO 비교
vsyscall 페이지의 내부 구조
vsyscall은 x86_64에서만 존재하는 레거시 메커니즘입니다. 커널은 고정 가상 주소
0xffffffffff600000에 1페이지(4KB)를 매핑하고, 정확히 3개의 함수 슬롯을 배치했습니다:
| 가상 주소 | 함수 | 오프셋 |
|---|---|---|
0xffffffffff600000 | gettimeofday() | +0x000 |
0xffffffffff600400 | time() | +0x400 |
0xffffffffff600800 | getcpu() | +0x800 |
이 주소는 모든 프로세스에서 동일합니다. 공격자가 메모리 취약점을 이용하여
실행 흐름을 조작할 때, vsyscall 페이지의 RET 명령어 등을 ROP 가젯으로 활용할 수 있었습니다.
vsyscall 에뮬레이션 모드
현대 커널은 vsyscall 페이지를 3가지 모드로 제어합니다:
| 커널 파라미터 | CONFIG 옵션 | 동작 | 보안 수준 |
|---|---|---|---|
vsyscall=emulate |
CONFIG_LEGACY_VSYSCALL_EMULATE |
vsyscall 주소 접근 시 페이지 폴트 발생 -> 커널이 실제 시스템 콜로 에뮬레이션 | 중간 (기본값) |
vsyscall=xonly |
CONFIG_LEGACY_VSYSCALL_XONLY |
실행만 가능, 읽기 불가 (데이터 누출 방지) | 높음 |
vsyscall=none |
CONFIG_LEGACY_VSYSCALL_NONE |
vsyscall 페이지 완전 제거. 접근 시 SIGSEGV | 최고 |
vsyscall=none을 사용하면 매우 오래된 정적 링크 바이너리(glibc 2.13 이전)가
작동하지 않을 수 있습니다. 현대 시스템에서는 대부분 vDSO를 사용하므로 문제가 없지만,
레거시 환경에서는 vsyscall=emulate(기본값)을 유지해야 합니다.
보안 관점에서의 비교
| 보안 속성 | vsyscall | vDSO |
|---|---|---|
| ASLR | 불가 (고정 주소) | 완전 지원 (mmap 무작위화) |
| NX (No Execute) | 전체 페이지 실행 가능 | 코드/데이터 분리된 ELF 세그먼트 |
| ROP 가젯 활용 | 쉬움 (주소 예측 가능) | 어려움 (주소 무작위) |
| 정보 누출 | 가능 (emulate 모드 제외) | ELF 메타데이터만 노출 |
| seccomp 필터링 | 에뮬레이션 모드에서만 가능 | 폴백 시스템 콜에 대해 가능 |
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) |
하드웨어 기능 비트맵 | 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기반 시스템 콜 진입에 사용됩니다.
유저 공간에서 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;
}
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행
mult와shift는 사이클을 나노초로 변환하는 공식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 오프셋 적용 | 컨테이너의 시간 격리 사용 시 |
vvar 페이지의 물리 매핑
커널은 vvar 데이터를 위해 물리 페이지를 할당하고, 이를 커널 주소 공간과 유저 주소 공간 양쪽에 매핑합니다.
커널 측에서는 쓰기가 가능하고, 유저 측에서는 읽기 전용입니다. 이는 vm_fault 핸들러를 통해 구현됩니다:
/* 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_mode가VDSO_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값과 시작 시 읽은 값을 비교합니다. 다르면 커널이 중간에 데이터를 갱신한 것이므로 전체 루프를 재시도합니다.
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;
}
vDSO가 지원하는 클럭 ID
| 클럭 ID | vDSO 고속 경로 | 설명 |
|---|---|---|
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 폴백) | 스레드 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 지정 가능 |
| POSIX | POSIX.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_AUXMSR 값(ECX)을 반환합니다. 커널의__switch_to()에서 이 MSR에 현재 CPU/노드를 기록합니다. - 30-32행 하위 12비트에 CPU ID(최대 4096개 CPU 지원), 상위 비트에 NUMA 노드 번호가 인코딩되어 있습니다.
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권한으로 매핑합니다. 쓰기 불가이므로 코드 무결성이 보장됩니다. -
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);
}
vDSO 빌드 프로세스
빌드 단계 개요
vDSO는 일반 커널 코드와 다른 특수한 빌드 과정을 거칩니다. 유저 공간에서 실행되는 코드이므로 커널의 링킹과는 별도로 독립적인 ELF 공유 라이브러리로 빌드됩니다:
- 소스 컴파일:
arch/x86/entry/vdso/의 C/어셈블리 소스를 유저 공간 ABI로 컴파일 (-fPIC,-shared) - 링커 스크립트:
vdso.lds를 사용하여 ELF 공유 라이브러리 형식으로 링킹 - 바이너리 변환:
vdso2c도구로 ELF 바이너리를 C 배열로 변환 - 커널 포함: 변환된 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;
}
KVM/가상화 환경에서의 vDSO
가상화 환경에서는 vDSO의 동작에 특수한 고려사항이 있습니다:
- pvclock -- KVM 게스트에서 호스트의 시간 정보를 직접 읽기 위한 반가상화 클럭. vvar에 추가 페이지(
pvclock_page)를 매핑하여struct pvclock_vsyscall_time_info를 제공합니다. - Hyper-V 클럭 -- Hyper-V 게스트에서 사용하는
hvclock_page.HV_REFERENCE_TSC_PAGEMSR을 통해 호스트 TSC와의 오프셋/스케일 정보를 제공합니다. - TSC 안정성 -- 가상 머신에서 TSC가 불안정할 수 있어
clock_mode = VDSO_CLOCKMODE_NONE으로 설정되면 vDSO가 비활성화됩니다. - 라이브 마이그레이션 -- VM 마이그레이션 시 TSC 오프셋이 변경되므로 커널이 vdso_data를 즉시 갱신해야 합니다.
유저 공간 vDSO 파싱
glibc의 vDSO 통합
glibc는 프로세스 시작 시 AT_SYSINFO_EHDR에서 vDSO 기저 주소를 읽고,
ELF 동적 심볼 테이블을 파싱하여 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_gettime | clock_gettime() | 모든 아키텍처 |
__vdso_gettimeofday | gettimeofday() | 모든 아키텍처 |
__vdso_time | time() | x86, ARM64, RISC-V |
__vdso_getcpu | getcpu() | x86 |
__vdso_clock_getres | clock_getres() | ARM64, RISC-V |
__vdso_clock_gettime64 | clock_gettime64() | x86_32 (Y2038 대응) |
__kernel_vsyscall | (시스템 콜 진입) | x86_32 (SYSENTER) |
__vdso_flush_icache | (캐시 플러시) | 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배 |
벤치마크 방법
#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의 성능 이점은 시간 함수를 빈번히 호출하는 워크로드에서 두드러집니다:
- 데이터베이스 -- PostgreSQL, MySQL 등은 트랜잭션 타임스탬프, WAL 로그, 쿼리 프로파일링에
clock_gettime()을 초당 수만~수백만 회 호출합니다. - 금융 트레이딩 -- HFT(고빈도 거래) 시스템은 주문 타임스탬프에 나노초 해상도가 필요하며, 시스템 콜 레이턴시가 직접적인 비용입니다.
- 로깅 시스템 -- 구조화 로그(structured logging)의 매 이벤트 타임스탬프에 vDSO가 활용됩니다.
- 게임 엔진 -- 렌더링 루프의 프레임 타이밍, 물리 시뮬레이션의 delta time 계산에 사용됩니다.
- 네트워크 서버 -- Nginx, HAProxy 등의 타임아웃 검사, 연결 시간 기록에 사용됩니다.
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]
출력에서 주목할 점:
- [vvar] -- 읽기 전용(
r--p). 커널이 갱신하는 데이터 페이지 - [vdso] -- 읽기+실행(
r-xp). vDSO 코드 페이지 - [vsyscall] -- 실행 전용(
--xp). 레거시 vsyscall 페이지 (에뮬레이션 모드) - vDSO 주소는 ASLR로 매번 달라지며, vsyscall 주소는 항상
0xffffffffff600000으로 고정
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는 라이브러리 함수 호출을 추적하므로
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 필터의 적용을 받지 않습니다. 이는 의도된 동작입니다:
- vDSO 함수가 성공적으로 실행되면 seccomp이 관여하지 않음
- vDSO가 시스템 콜로 폴백하는 경우(clock_mode == NONE, 지원하지 않는 클럭 ID) seccomp 필터가 적용됨
- seccomp으로
clock_gettime을 차단해도 vDSO 경로는 차단되지 않음에 주의
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
참고자료
- Linux Kernel Documentation: vDSO -- 커널 공식 vDSO 문서
- vdso(7) man page -- vDSO 맨페이지: 아키텍처별 함수 목록, 사용법
- LWN.net: On vsyscalls and the vDSO -- vsyscall에서 vDSO로의 전환 배경
- LWN.net: Removing vsyscall from the kernel -- vsyscall 제거 논의
- lib/vdso/ 소스 (Bootlin Elixir) -- 아키텍처 독립 vDSO 공통 구현
- arch/x86/entry/vdso/ 소스 -- x86 vDSO 구현
- include/vdso/datapage.h -- vdso_data 구조체 정의
- 시스템 콜 (System Call) -- 시스템 콜 진입 경로와 vDSO 최적화
- ktime / Clock 심화 -- clocksource, timekeeper, vDSO와의 연관
- ELF (Executable and Linkable Format) -- ELF 구조, auxiliary vector, 동적 링킹
- 시스템 콜 -- vDSO의 폴백 경로인 시스템 콜의 전체 진입/복귀 경로
- ktime / Clock -- vDSO가 읽는 clocksource와 timekeeper 서브시스템
- 타이머 -- hrtimer와 tick 기반 타이머의 vDSO 갱신 트리거
- Time Namespaces -- vvar의 timens 오프셋 페이지와 컨테이너 시간 격리