시스템 콜 (System Call)

사용자 공간과 커널 공간을 잇는 핵심 인터페이스인 시스템 콜을 아키텍처별 진입 경로부터 반환까지 추적합니다. x86_64 SYSCALL/SYSRET, ARM64 SVC, entry_SYSCALL_64 디스패치, SYSCALL_DEFINE 매크로 전개, 사용자 메모리 접근 검증, vDSO 경량 경로, seccomp/ptrace/audit 정책 훅, compat ABI 및 restart 경로까지 실무 중심으로 상세히 다룹니다.

관련 표준: POSIX.1-2017 (시스템 콜 시맨틱), System V AMD64 ABI (x86-64 호출 규약), Intel SDM (SYSCALL/SYSRET 명령어) — 시스템 콜 인터페이스의 핵심 규격입니다. 종합 목록은 참고자료 — 표준 & 규격 섹션을 참고하세요.
전제 조건: 프로세스 스케줄러프로세스 문서를 먼저 읽으세요. 실행 단위 관리 주제는 태스크 상태 전이와 큐 정책이 핵심이므로, 스케줄링 기준과 wakeup 경로를 먼저 이해해야 합니다.

핵심 요약

  • 시스템 콜 — 사용자 프로그램이 커널 서비스를 요청하는 유일한 공식 인터페이스입니다.
  • SYSCALL/SYSRET — x86_64에서 사용자↔커널 전환을 수행하는 CPU 명령어입니다.
  • sys_call_table — 시스템 콜 번호를 실제 커널 함수에 매핑하는 함수 포인터 테이블입니다.
  • vDSO — 커널 진입 없이 사용자 공간에서 실행되는 가상 동적 공유 객체 (gettimeofday 등).
  • seccomp-BPF — 프로세스가 사용할 수 있는 시스템 콜을 BPF 필터로 제한하는 보안 기능입니다.
  • ARM64 SVC #0 — ARM64 아키텍처에서 SVC #0 명령어와 x8 레지스터로 시스템 콜을 호출합니다 (x86_64의 SYSCALL/rax에 해당).
  • KPTI — Meltdown(CVE-2017-5754) 완화를 위해 사용자/커널 페이지 테이블을 분리하는 기법으로, 1~30%의 성능 오버헤드가 발생합니다.
  • copy_to_user / access_ok — 커널에서 사용자 메모리에 안전하게 접근하기 위한 필수 함수로, SMAP 하드웨어 보호와 협력합니다.

단계별 이해

  1. 호출 흐름 — 사용자 프로그램 → glibc 래퍼 → SYSCALL 명령어 → 커널 진입 → 핸들러 실행 → 결과 반환.

    이 전체 과정이 수백 나노초 안에 완료됩니다.

  2. strace로 관찰strace ls를 실행하면 ls가 호출하는 모든 시스템 콜을 볼 수 있습니다.

    open(), read(), write() 등 실제 시스템 콜과 그 인자/반환값을 확인합니다.

  3. 커널 내부SYSCALL_DEFINE3(read, ...) 매크로가 sys_read() 함수를 정의합니다.

    시스템 콜 번호는 arch/x86/entry/syscalls/syscall_64.tbl에 정의되어 있습니다.

  4. 보안 — seccomp-BPF로 컨테이너나 샌드박스에서 허용할 시스템 콜을 화이트리스트로 제한합니다.

    Docker, Chrome 등이 seccomp을 사용하여 공격 표면을 줄입니다.

  5. 성능 측정perf stat -e syscalls:sys_enter_read로 시스템 콜 횟수를 측정하고, bpftrace로 레이턴시 분포를 확인합니다.

    vDSO는 커널 진입 없이 실행되어 clock_gettime을 3~10배 빠르게 만듭니다. io_uring은 배치 처리로 syscall 오버헤드를 줄입니다.

  6. 보안 취약점 분석 — Meltdown(CVE-2017-5754)은 시스템 콜 경계를 통해 커널 메모리를 읽었고, KPTI 패치가 이를 해결했습니다.

    Spectre v2 완화(RETPOLINE)는 시스템 콜 디스패치 경로의 간접 점프를 보호합니다. array_index_nospec()는 Spectre v1을 방어합니다.

시스템 콜 개요

시스템 콜(system call)은 사용자 공간 프로그램이 커널의 서비스를 요청하기 위한 프로그래밍 인터페이스입니다. 프로세스 생성, 파일 읽기/쓰기, 네트워크 통신, 메모리 할당 등 하드웨어 자원에 접근하는 모든 작업은 시스템 콜을 통해야 합니다. Linux 커널은 약 450개 이상의 시스템 콜을 제공합니다.

보호 링과 특권 수준

x86 아키텍처는 4개의 보호 링(Ring 0~3)을 제공하지만, Linux는 Ring 0(커널 모드)과 Ring 3(사용자 모드) 두 가지만 사용합니다. 시스템 콜은 사용자 모드에서 커널 모드로의 제어된 전환을 수행하는 유일한 합법적 경로입니다.

특권 수준 Ring CPL 접근 가능 영역
커널 모드 Ring 0 0 모든 메모리, I/O 포트, 특권 명령어
사용자 모드 Ring 3 3 사용자 공간 메모리만 (가상 주소 하위 영역)
CPL (Current Privilege Level): CS 레지스터의 하위 2비트에 저장되는 현재 특권 수준입니다. SYSCALL 명령어 실행 시 하드웨어가 자동으로 CPL을 0으로 전환합니다.

int 0x80에서 SYSCALL까지의 변천

x86 시스템 콜 호출 메커니즘은 성능 향상을 위해 발전해 왔습니다.

메커니즘 아키텍처 도입 시기 특징
int 0x80 i386 초기 소프트웨어 인터럽트, IDT 조회 필요 → 느림
sysenter/sysexit i386 (Pentium II+) 2000년대 Intel 전용 빠른 진입, MSR 기반
SYSCALL/SYSRET x86_64 AMD64 64비트 표준, MSR_LSTAR 기반, 가장 빠름
SVC #0 ARM64 (AArch64) ARMv8+ Supervisor Call, x8 레지스터로 번호 전달, EL0→EL1 전환
ecall RISC-V RISC-V ISA Environment Call, a7 레지스터로 번호 전달

int 0x80은 IDT(Interrupt Descriptor Table)를 조회하고 스택 전환을 수행하므로 수백 사이클이 소요됩니다. SYSCALL은 IDT를 우회하고 MSR에 미리 저장된 진입점으로 직접 점프하므로 훨씬 빠릅니다.

/* int 0x80 (레거시 32비트) - 느린 경로 */
mov    $__NR_write, %eax     /* 시스템 콜 번호 */
mov    $1, %ebx              /* fd = stdout */
mov    $msg, %ecx            /* buffer */
mov    $len, %edx            /* count */
int    $0x80                 /* 소프트웨어 인터럽트 */

/* SYSCALL (x86_64) - 빠른 경로 */
mov    $1, %rax              /* __NR_write */
mov    $1, %rdi              /* fd = stdout */
mov    $msg, %rsi            /* buffer */
mov    $len, %rdx            /* count */
syscall                       /* 빠른 시스템 콜 진입 */

시스템 콜 아키텍처

x86_64 레지스터 규약

x86_64에서 SYSCALL 명령어를 사용할 때의 레지스터 규약은 다음과 같습니다. 이는 C 함수 호출 규약(System V AMD64 ABI)과 유사하지만 rcx 대신 r10을 사용하는 점이 다릅니다.

레지스터 용도 비고
rax 시스템 콜 번호 / 반환값 호출 전: 번호, 반환 후: 결과 또는 -errno
rdi 1번째 인자
rsi 2번째 인자
rdx 3번째 인자
r10 4번째 인자 C ABI에서는 rcx이지만, SYSCALL이 rcx를 덮어쓰므로 r10 사용
r8 5번째 인자
r9 6번째 인자
rcx 복귀 주소 (RIP) SYSCALL이 자동으로 RIP → RCX 저장
r11 RFLAGS 백업 SYSCALL이 자동으로 RFLAGS → R11 저장
rcx와 r11 파괴: SYSCALL 명령어는 하드웨어적으로 rcx에 복귀 주소(RIP)를, r11에 RFLAGS를 저장합니다. 따라서 이 두 레지스터의 원래 값은 시스템 콜 수행 후 복원되지 않습니다. glibc의 syscall wrapper는 이를 고려하여 r10으로 4번째 인자를 전달합니다.

시스템 콜 흐름 다이어그램

User Space (Ring 3) Kernel Space (Ring 0) Application glibc wrapper mov $NR, %rax SYSCALL instruction RCX=RIP, R11=RFLAGS vDSO (optional) gettimeofday 등 call syscall fast path entry_SYSCALL_64 swapgs, save pt_regs do_syscall_64() sys_call_table[nr](args) 실제 시스템 콜 핸들러 MSR_LSTAR entry_SYSCALL_64 주소 MSR_STAR CS/SS 셀렉터 MSR_LSTAR SYSRET

ARM64 시스템 콜 구현

ARM64(AArch64) 아키텍처는 x86_64와 다른 시스템 콜 메커니즘을 사용합니다. SVC #0(Supervisor Call) 명령어로 커널에 진입하며, 시스템 콜 번호는 x8 레지스터에 전달합니다. x86_64에서 rax가 번호와 반환값을 겸용하는 것과 달리, ARM64는 번호에 x8, 반환값에 x0을 사용합니다. 어셈블리 종합 페이지에서 ARM64 호출 규약 전반을 확인할 수 있습니다.

ARM64 vs x86_64 레지스터 규약 비교

용도 x86_64 레지스터 ARM64 레지스터
시스템 콜 번호 rax x8
반환값 rax x0
1~6번째 인자 rdi, rsi, rdx, r10, r8, r9 x0, x1, x2, x3, x4, x5
호출 명령어 SYSCALL SVC #0
모드 전환 Ring 3 → Ring 0 (CPL 변경) EL0 → EL1 (Exception Level 변경)
진입점 결정 MSR_LSTAR 레지스터 VBAR_EL1 예외 벡터 테이블
커널 진입 함수 entry_SYSCALL_64 el0t_64_sync_handler
C 디스패처 do_syscall_64() do_el0_svc()
반환 명령어 SYSRET ERET
/* ARM64 시스템 콜 예제 — write(1, buf, 13) */
mov    x8, #64         /* __NR_write = 64 (ARM64 UAPI 번호) */
mov    x0, #1          /* 1번째 인자: fd = stdout */
ldr    x1, =buf        /* 2번째 인자: buffer 주소 */
mov    x2, #13         /* 3번째 인자: 바이트 수 */
svc    #0              /* Supervisor Call → el0t_64_sync_handler */
/* 반환값은 x0에 저장됨 (bytes written 또는 -errno) */

/* x86_64 비교 — write(1, buf, 13) */
mov    $1, %rax        /* __NR_write = 1 (x86_64) */
mov    $1, %rdi        /* 1번째 인자: fd */
mov    $buf, %rsi      /* 2번째 인자: buffer */
mov    $13, %rdx       /* 3번째 인자: count */
syscall                /* → entry_SYSCALL_64 */
/* 반환값은 rax에 저장됨 */

el0_svc 진입점 분석

ARM64에서 SVC #0 명령어는 EL0(사용자)에서 EL1(커널)로 동기 예외(Synchronous Exception)를 발생시킵니다. 커널은 VBAR_EL1 레지스터가 가리키는 예외 벡터 테이블에서 핸들러를 찾아 실행합니다.

/* arch/arm64/kernel/entry.S (간략화) */
SYM_CODE_START_LOCAL(el0t_64_sync_handler)
    /* ESR_EL1: Exception Syndrome Register에서 예외 종류 판단 */
    ldr    x24, [sp, #S_ESR]
    ubfx   x24, x24, #ESR_ELx_EC_SHIFT, #ESR_ELx_EC_WIDTH
    cmp    x24, #ESR_ELx_EC_SVC64   /* SVC 64비트인지 확인 */
    b.eq   el0_svc

el0_svc:
    mov    x0, sp                /* 1번째 인자: pt_regs 포인터 */
    bl     do_el0_svc

/* arch/arm64/kernel/syscall.c */
void do_el0_svc(struct pt_regs *regs)
{
    invoke_syscall(regs,
                   regs->regs[8],  /* x8 = 시스템 콜 번호 */
                   __NR_syscalls,
                   sys_call_table);
}

static void invoke_syscall(struct pt_regs *regs,
                            unsigned int scno,
                            unsigned int sc_nr,
                            const syscall_fn_t syscall_table[])
{
    long ret;
    scno = array_index_nospec(scno, sc_nr);  /* Spectre v1 방어 */
    ret = syscall_table[scno](regs);
    regs->regs[0] = ret;   /* 반환값 → x0 */
}
시스템 콜 번호 차이: ARM64와 x86_64의 시스템 콜 번호는 완전히 다릅니다. 예를 들어 write는 x86_64에서 1번이지만 ARM64에서는 64번입니다. ARM64는 AArch64/AArch32 통합을 위해 번호 체계를 새로 설계했습니다. arch/arm64/tools/syscall.tbl에서 확인할 수 있습니다.

AArch32 호환성 계층

ARM64 커널은 CONFIG_COMPAT 옵션으로 32비트 ARM(AArch32) 프로그램을 지원합니다. AArch32에서의 시스템 콜은 swi #0(Software Interrupt) 명령어를 사용하며, 번호는 r7에 전달합니다. ARM64 커널의 compat 진입점은 el0_svc_compat이며, 별도의 compat_sys_call_table을 사용합니다. x86_64의 ia32_sys_call_table과 동일한 역할입니다.

ARM64 vs x86_64 시스템 콜 경로 비교 다이어그램

x86_64 ARM64 rax = 번호, rdi/rsi/rdx/r10/r8/r9 = 인자 레지스터 설정 (User Space) SYSCALL RCX←RIP, R11←RFLAGS, CPL→0 MSR_LSTAR → entry_SYSCALL_64 swapgs, 커널 스택 전환, pt_regs 저장 do_syscall_64() → sys_call_table[rax] 반환: rax, SYSRET x8 = 번호, x0~x5 = 인자 레지스터 설정 (EL0 User Space) SVC #0 동기 예외 발생, EL0 → EL1 VBAR_EL1 → el0t_64_sync_handler 커널 스택 전환, pt_regs 저장 do_el0_svc() → sys_call_table[x8] 반환: x0, ERET

시스템 콜 테이블

sys_call_table은 시스템 콜 번호를 해당 핸들러 함수 포인터에 매핑하는 배열입니다. x86_64에서는 arch/x86/entry/syscall_64.c에 정의됩니다.

/* arch/x86/entry/syscall_64.c */
#include <asm/syscalls_64.h>

#define __SYSCALL(nr, sym) [nr] = __x64_##sym,

const sys_call_ptr_t sys_call_table[] __ro_after_init = {
    [0 ... __NR_syscall_max] = __x64_sys_ni_syscall,
    #include <asm/syscalls_64.h>
};

이 테이블은 __ro_after_init으로 선언되어 부팅 후에는 읽기 전용이 됩니다. 이는 루트킷이 시스템 콜 테이블을 변조하는 것을 방지하는 보안 장치입니다.

시스템 콜 번호 할당

시스템 콜 번호는 arch/x86/entry/syscalls/syscall_64.tbl 테이블 파일에서 관리됩니다. 빌드 시 이 파일로부터 헤더가 자동 생성됩니다.

# arch/x86/entry/syscalls/syscall_64.tbl
# <번호>  <ABI>   <이름>          <진입점>
0       common  read            sys_read
1       common  write           sys_write
2       common  open            sys_open
3       common  close           sys_close
...
56      common  clone           sys_clone
57      common  fork            sys_fork
59      common  execve          sys_execve
...
435     common  clone3          sys_clone3

주요 시스템 콜 번호를 확인하려면:

# 사용자 공간에서 시스템 콜 번호 헤더 확인
$ grep -E '__NR_(read|write|open|close|fork|clone|execve) ' \
    /usr/include/asm/unistd_64.h
#define __NR_read    0
#define __NR_write   1
#define __NR_open    2
#define __NR_close   3
새로운 시스템 콜 번호: 번호는 절대 재사용되지 않으며, 항상 테이블 끝에 새 번호가 추가됩니다. 삭제된 시스템 콜은 sys_ni_syscall(not implemented)로 매핑되어 -ENOSYS를 반환합니다.

SYSCALL_DEFINE 매크로

DEFINE0~6 패밀리

커널 시스템 콜 핸들러는 SYSCALL_DEFINEN 매크로로 정의됩니다. N은 인자 개수(0~6)를 나타냅니다. 이 매크로는 함수 프로토타입 생성, 인자 타입 검증, 추적(tracing) 지원, CVE 방지용 타입 캐스팅을 자동으로 처리합니다.

/* include/linux/syscalls.h */
#define SYSCALL_DEFINE0(name)  ...
#define SYSCALL_DEFINE1(name, type1, arg1)  ...
#define SYSCALL_DEFINE2(name, type1, arg1, type2, arg2)  ...
/* ... SYSCALL_DEFINE6까지 */

/* 내부적으로 확장되는 구조 */
#define SYSCALL_DEFINE3(name, ...) \
    SYSCALL_METADATA(name, 3, __VA_ARGS__)  \
    __SYSCALL_DEFINEx(3, _##name, __VA_ARGS__)

SYSCALL_METADATA는 ftrace의 syscall tracepoint에 사용되며, __SYSCALL_DEFINEx는 실제 함수 본체를 정의합니다. 인자를 long으로 받아 올바른 타입으로 캐스팅하여 레지스터 상위 비트 오염 공격(CVE-2009-0029)을 방지합니다.

실제 커널 코드 예제

/* fs/read_write.c - read 시스템 콜 */
SYSCALL_DEFINE3(read,
    unsigned int, fd,
    char __user *, buf,
    size_t, count)
{
    return ksys_read(fd, buf, count);
}

/* kernel/fork.c - getpid (인자 없음) */
SYSCALL_DEFINE0(getpid)
{
    return task_tgid_vnr(current);
}

/* mm/mmap.c - mmap (인자 6개, 최대) */
SYSCALL_DEFINE6(mmap,
    unsigned long, addr,
    unsigned long, len,
    unsigned long, prot,
    unsigned long, flags,
    unsigned long, fd,
    unsigned long, off)
{
    return ksys_mmap_pgoff(addr, len, prot, flags, fd,
                            off >> PAGE_SHIFT);
}
인자 6개 제한: x86_64 SYSCALL 규약에서 사용 가능한 인자 전달 레지스터가 rdi, rsi, rdx, r10, r8, r9의 6개이므로, 시스템 콜 인자는 최대 6개로 제한됩니다. 더 많은 데이터가 필요한 경우 구조체 포인터를 전달합니다.

시스템 콜 진입 경로 상세

entry_SYSCALL_64 분석

entry_SYSCALL_64는 x86_64에서 모든 64비트 시스템 콜의 커널 진입점입니다. MSR_LSTAR에 이 함수의 주소가 저장되어 있어, SYSCALL 명령어 실행 시 하드웨어가 이 주소로 점프합니다. 어셈블리 종합 페이지와 MSR 레지스터 페이지에서 관련 세부 사항을 확인할 수 있습니다.

/* arch/x86/entry/entry_64.S (간략화) */
SYM_CODE_START(entry_SYSCALL_64)
    /* SYSCALL 직후: RCX=사용자RIP, R11=사용자RFLAGS
     * RSP는 아직 사용자 스택! */

    swapgs                        /* GS base를 커널용으로 전환 */

    /* 사용자 RSP를 per-CPU 영역에 임시 저장 */
    movq    %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2)

    /* 커널 스택으로 전환 (per-CPU에서 로드) */
    movq    PER_CPU_VAR(pcpu_hot + X86_top_of_stack), %rsp

    /* pt_regs 구조체 형태로 레지스터 저장 */
    pushq   $__USER_DS              /* pt_regs->ss */
    pushq   PER_CPU_VAR(cpu_tss_rw + TSS_sp2)  /* pt_regs->sp */
    pushq   %r11                    /* pt_regs->flags */
    pushq   $__USER_CS              /* pt_regs->cs */
    pushq   %rcx                    /* pt_regs->ip */
    pushq   %rax                    /* pt_regs->orig_ax (syscall nr) */

    /* 나머지 범용 레지스터 저장 */
    PUSH_AND_CLEAR_REGS rax=$-ENOSYS

    /* C 핸들러 호출 */
    movq    %rsp, %rdi             /* 1번째 인자: pt_regs 포인터 */
    movslq  %eax, %rsi             /* 2번째 인자: syscall 번호 */
    call    do_syscall_64

    /* 반환 경로: 레지스터 복원 후 SYSRET */
    ...
    swapgs
    sysretq
SYM_CODE_END(entry_SYSCALL_64)
swapgs 보안: swapgs는 사용자 GS base와 커널 GS base를 교환합니다. 이 명령어가 빠지면 커널이 사용자 제어 GS base를 사용하게 되어 보안 취약점이 발생합니다. Spectre v1 변종 공격에서 swapgs 경계가 표적이 된 사례(CVE-2019-1125)가 있습니다.

pt_regs 구조체

struct pt_regs는 시스템 콜(또는 인터럽트/예외) 진입 시 저장되는 사용자 레지스터 상태를 담는 구조체입니다. 커널 스택에 이 구조체 형태로 레지스터를 push합니다.

/* arch/x86/include/asm/ptrace.h */
struct pt_regs {
    unsigned long r15, r14, r13, r12;
    unsigned long bp, bx;
    unsigned long r11, r10, r9, r8;
    unsigned long ax, cx, dx, si, di;
    unsigned long orig_ax;      /* 시스템 콜 번호 */
    unsigned long ip;           /* 사용자 RIP (RCX에서) */
    unsigned long cs;
    unsigned long flags;        /* 사용자 RFLAGS (R11에서) */
    unsigned long sp;           /* 사용자 RSP */
    unsigned long ss;
};

do_syscall_64()

do_syscall_64()는 어셈블리 진입점에서 호출되는 C 함수로, 시스템 콜 번호 검증과 디스패치를 수행합니다.

/* arch/x86/entry/common.c */
__visible noinstr void do_syscall_64(
    struct pt_regs *regs, int nr)
{
    add_random_kstack_offset();
    nr = syscall_enter_from_user_mode(regs, nr);

    instrumentation_begin();

    if (!do_syscall_x64(regs, nr))
        do_syscall_x32(regs, nr);  /* x32 ABI 폴백 */

    instrumentation_end();
    syscall_exit_to_user_mode(regs);
}

static __always_inline bool do_syscall_x64(
    struct pt_regs *regs, int nr)
{
    if (likely(nr < NR_syscalls)) {
        nr = array_index_nospec(nr, NR_syscalls);
        regs->ax = sys_call_table[nr](regs);
        return true;
    }
    return false;
}
add_random_kstack_offset(): 시스템 콜마다 커널 스택 오프셋을 랜덤화하여, 스택 기반 공격(스택 스프레이 등)을 어렵게 만드는 보안 강화 기법입니다. array_index_nospec()는 Spectre v1 방어를 위한 투기적 실행 차단 함수입니다.

진입 경로 상세 다이어그램

SYSCALL (하드웨어) swapgs + 커널 스택 전환 pt_regs 저장 (push regs) syscall_enter_from_user_mode() seccomp 검사 audit, ptrace, sys_call_table[nr](regs) 실제 핸들러 실행 syscall_exit_to_user_mode() 시그널 처리, 스케줄링 체크 pt_regs 복원 (pop regs) swapgs + SYSRET (하드웨어)

사용자 메모리 접근 보안

커널 코드에서 시스템 콜 인자로 전달받은 사용자 포인터에 접근할 때는 반드시 전용 함수를 사용해야 합니다. 잘못된 포인터, 커널 공간 주소 위장, TOCTOU 공격 등을 방지하기 위한 다층 보안 메커니즘이 적용됩니다.

access_ok() — 주소 범위 검증

access_ok(addr, size)는 사용자 포인터가 사용자 공간 범위 내에 있는지 검증합니다. 커널 주소를 사용자인 척 전달하는 공격을 차단하는 첫 번째 방어선입니다.

/* include/linux/uaccess.h */
/* access_ok()는 아키텍처마다 구현이 다르지만 목적은 동일:
 * 주소가 사용자 공간 범위(< TASK_SIZE_MAX)인지 확인 */

/* x86_64 구현 */
static inline bool access_ok(const void __user *addr, unsigned long size)
{
    return likely((unsigned long)addr + size <= TASK_SIZE_MAX);
}

/* __user 어노테이션: Sparse 정적 분석 도구가 포인터 혼용 검출 */
SYSCALL_DEFINE3(read,
    unsigned int, fd,
    char __user *, buf,   /* 사용자 공간 포인터 표시 */
    size_t, count)
{
    /* copy_to_user 내부에서 access_ok 자동 호출 */
    return ksys_read(fd, buf, count);
}

copy_to_user / copy_from_user

가장 일반적인 사용자 메모리 접근 함수입니다. 내부적으로 access_ok()를 호출하고, SMAP/PAN 하드웨어 보호를 우회하는 특수 어셈블리를 포함합니다.

/* include/linux/uaccess.h */

/* 커널 → 사용자 복사 (sys_read 등의 반환 경로) */
static inline unsigned long
copy_to_user(void __user *to, const void *from, unsigned long n)
{
    if (!access_ok(to, n))
        return n;          /* 복사 실패: 미복사 바이트 수 반환 */
    return raw_copy_to_user(to, from, n);
}

/* 사용자 → 커널 복사 (sys_write 등의 입력 경로) */
static inline unsigned long
copy_from_user(void *to, const void __user *from, unsigned long n)
{
    if (!access_ok(from, n))
        return n;
    return raw_copy_from_user(to, from, n);
}

/* 소량 데이터용 매크로: sizeof(val)을 자동으로 처리 */
int val;
get_user(val, user_ptr);     /* 사용자 → 커널, 에러 시 -EFAULT */
put_user(val, user_ptr);     /* 커널 → 사용자, 에러 시 -EFAULT */

/* 실제 커널 코드 예시: sys_hello 핸들러 */
SYSCALL_DEFINE1(hello, char __user *, buf)
{
    const char msg[] = "Hello from kernel!\n";
    if (copy_to_user(buf, msg, sizeof(msg)))
        return -EFAULT;    /* 복사 실패 시 EFAULT 반환 */
    return sizeof(msg);
}
반환값 확인 필수: copy_to_user()copy_from_user()는 복사에 실패한 바이트 수를 반환합니다 (성공 시 0). 0이 아닌 값이 반환되면 반드시 -EFAULT를 반환해야 합니다. 이를 무시하면 데이터 손상이나 정보 누출이 발생할 수 있습니다.

SMAP과 SMEP — 하드웨어 보호

현대 CPU는 커널 코드가 사용자 공간 메모리에 직접 접근하거나 사용자 코드를 실행하는 것을 하드웨어 수준에서 차단합니다.

기능 전체 이름 보호 내용 활성화
SMEP Supervisor Mode Execution Prevention 커널 모드에서 사용자 공간 코드 실행 방지 CR4.SMEP 비트
SMAP Supervisor Mode Access Prevention 커널 모드에서 사용자 공간 메모리 읽기/쓰기 차단 CR4.SMAP 비트
PAN Privileged Access Never (ARM64) ARM64의 SMAP 상당 기능 PSTATE.PAN 비트
PXN Privileged Execute Never (ARM64) ARM64의 SMEP 상당 기능 페이지 테이블 PXN 비트
/* SMAP이 활성화된 경우 커널이 사용자 메모리에 접근하려면
 * STAC(Set AC) 명령어로 일시 비활성화 후 CLAC(Clear AC)로 복원 */

/* arch/x86/include/asm/uaccess.h */
static inline void user_access_begin(const void __user *ptr, size_t len)
{
    access_ok(ptr, len);
    __uaccess_begin_nospec();  /* STAC + LFENCE (Spectre v1 방어) */
}

static inline void user_access_end(void)
{
    __uaccess_end();           /* CLAC 명령어 실행 */
}

/* raw_copy_to_user 내부에서 사용 */
user_access_begin(to, n);
/* ... rep movsb 등 실제 복사 어셈블리 ... */
user_access_end();

/* SMAP 활성화 여부 확인 */
$ grep SMAP /proc/cpuinfo | head -1
flags : ... smap smep ...

__user 어노테이션과 Sparse 정적 분석

__user는 GCC/Clang에서는 무시되지만, Sparse 정적 분석 도구가 사용자 포인터와 커널 포인터의 혼용을 검출하는 데 사용합니다. 커널 빌드 시 make C=1로 Sparse를 활성화할 수 있습니다.

# Sparse로 __user 어노테이션 위반 검사
$ make C=1 drivers/char/mem.o
drivers/char/mem.c:300:26: warning: incorrect type in argument 1
    expected void [noderef] __user *to, got char *

# 커널 전체 소스 검사 (시간이 오래 걸림)
$ make C=2 2>&1 | grep "address space"

SMAP/SMEP 보안 레이어 다이어그램

User Space (Ring 3 / EL0) 사용자 코드 / 텍스트 영역 SMEP: 커널 모드에서 실행 불가 사용자 버퍼 (sys_write 인자) SMAP: 커널이 직접 접근 불가 copy_from_user() 호출 후 STAC(Set AC) → 접근 허용 Kernel Space (Ring 0 / EL1) 시스템 콜 핸들러 SYSCALL_DEFINE 함수 copy_from_user() access_ok() + STAC/CLAC 커널 내부 버퍼 복사 완료 후 CLAC(Clear AC) 보안 경계 copy_from SMEP 차단

새로운 시스템 콜 추가

단계별 가이드

리눅스 커널에 새로운 시스템 콜을 추가하는 과정을 단계별로 설명합니다. 아래는 sys_hello라는 예시 시스템 콜을 추가하는 과정입니다.

1단계: 시스템 콜 테이블에 번호 등록

# arch/x86/entry/syscalls/syscall_64.tbl 끝에 추가
# 번호는 마지막 항목 + 1
451     common  hello           sys_hello

2단계: 시스템 콜 핸들러 구현

/* kernel/sys_hello.c */
#include <linux/syscalls.h>
#include <linux/uaccess.h>

SYSCALL_DEFINE1(hello, char __user *, buf)
{
    const char msg[] = "Hello from kernel!\\n";

    if (copy_to_user(buf, msg, sizeof(msg)))
        return -EFAULT;

    return sizeof(msg);
}

3단계: 헤더에 프로토타입 선언

/* include/linux/syscalls.h 에 추가 */
asmlinkage long sys_hello(char __user *buf);

4단계: Makefile에 오브젝트 추가

# kernel/Makefile
obj-y += sys_hello.o

5단계: 사용자 공간에서 호출

/* userspace test program */
#include <unistd.h>
#include <sys/syscall.h>
#include <stdio.h>

#define __NR_hello 451

int main(void)
{
    char buf[64];
    long ret = syscall(__NR_hello, buf);
    if (ret > 0)
        printf("%s", buf);
    return 0;
}
시스템 콜 ABI 안정성: 리눅스 커널은 시스템 콜 ABI의 하위 호환성을 절대적으로 보장합니다. 한 번 번호가 할당된 시스템 콜은 영구히 유지되며, 인터페이스 변경은 새로운 시스템 콜을 추가하는 방식으로만 이루어집니다 (예: cloneclone3, epoll_createepoll_create1).

호환성 계층

compat_sys_* 래퍼

64비트 커널에서 32비트 사용자 프로그램을 실행할 때, 시스템 콜 인자의 크기가 다릅니다 (예: long이 4바이트 vs 8바이트). compat_sys_* 래퍼는 32비트 인자를 64비트로 안전하게 변환합니다.

/* fs/read_write.c */
COMPAT_SYSCALL_DEFINE3(read,
    unsigned int, fd,
    char __user *, buf,
    compat_size_t, count)
{
    return ksys_read(fd, buf, (size_t)count);
}

/* 타입 변환이 필요한 구조체 예시 */
struct compat_stat {
    compat_dev_t   st_dev;     /* 32비트 dev_t */
    compat_ino_t   st_ino;     /* 32비트 ino_t */
    compat_off_t   st_size;    /* 32비트 off_t */
    /* ... */
};

32비트 호환 모드 (IA-32 Emulation)

x86_64 Linux 커널은 CONFIG_IA32_EMULATION 옵션으로 32비트 프로그램의 int 0x80sysenter 호출을 지원합니다. 32비트 시스템 콜은 별도의 테이블(ia32_sys_call_table)을 사용하며, 번호 체계도 다릅니다.

/* arch/x86/entry/entry_64_compat.S */
SYM_CODE_START(entry_SYSENTER_compat)
    /* 32비트 sysenter 진입점 */
    swapgs
    /* 32비트 레지스터에서 인자 추출 */
    movl    %ebp, %r10d    /* ebp → r10 (4번째 인자) */
    ...
    call    do_SYSENTER_32
SYM_CODE_END(entry_SYSENTER_compat)
항목 64비트 (native) 32비트 (compat)
호출 명령어 SYSCALL int 0x80 / sysenter
시스템 콜 테이블 sys_call_table ia32_sys_call_table
번호 헤더 unistd_64.h unistd_32.h
write 번호 1 4
인자 전달 rdi, rsi, rdx, r10, r8, r9 ebx, ecx, edx, esi, edi, ebp

vDSO (virtual Dynamic Shared Object)

개념과 목적

vDSO는 커널이 사용자 공간 프로세스의 주소 공간에 매핑하는 작은 공유 라이브러리입니다. 커널 모드 전환 없이 특정 시스템 콜을 사용자 공간에서 직접 실행할 수 있게 합니다. 이를 통해 gettimeofday(), clock_gettime(), getcpu() 등 자주 호출되는 시스템 콜의 오버헤드를 제거합니다.

# vDSO 매핑 확인
$ cat /proc/self/maps | grep vdso
7ffd3c5fe000-7ffd3c600000 r-xp 00000000 00:00 0  [vdso]

# vDSO에서 제공하는 함수 목록
$ objdump -T /proc/self/root/$(readlink /proc/self/exe) 2>/dev/null || \
  LD_SHOW_AUXV=1 /bin/true | grep VDSO
$ vdso=$(dd if=/proc/self/mem bs=1 skip=$((0x7ffd3c5fe000)) count=8192 2>/dev/null | file -)

vDSO가 가속하는 함수들:

작동 원리: 커널은 시간 데이터를 담고 있는 vvar 페이지(읽기 전용)를 사용자 공간에 매핑합니다. 타이머 인터럽트마다 커널이 이 페이지를 갱신하고, vDSO 함수는 이 데이터를 직접 읽어 반환합니다. 자세한 내용은 ktime / Clock 심화 페이지를 참조하세요.

구현

/* arch/x86/entry/vdso/vclock_gettime.c (간략화) */
notrace int __vdso_clock_gettime(clockid_t clock,
                                  struct timespec *ts)
{
    /* 커널 진입 없이 vvar 페이지에서 시간 데이터 읽기 */
    const struct vdso_data *vd = __arch_get_vdso_data();

    if (vd->clock_mode != VDSO_CLOCKMODE_NONE) {
        /* TSC 기반 고속 경로 */
        u64 cycles = __arch_get_hw_counter(vd->clock_mode, vd);
        ns = (cycles - vd->cycle_last) * vd->mult;
        ns >>= vd->shift;
        ts->tv_sec  = vd->basetime[clock].sec + ns / NSEC_PER_SEC;
        ts->tv_nsec = ns % NSEC_PER_SEC;
        return 0;
    }

    /* TSC 불안정 시 실제 시스템 콜로 폴백 */
    return clock_gettime_fallback(clock, ts);
}

vDSO vs vsyscall

항목 vsyscall (레거시) vDSO (현재)
주소 고정 (0xFFFFFFFFFF600000) ASLR 적용 (매 실행마다 변경)
크기 1 페이지 2~4 페이지
보안 ROP gadget 표적 (고정 주소) ASLR로 보호
에뮬레이션 모드 vsyscall=emulate (기본) 해당 없음
함수 수 3개 (time, gettimeofday, getcpu) 5개+
상태 레거시 (호환용만 유지) 표준
vsyscall=emulate: 현재 커널은 기본적으로 vsyscall 페이지를 에뮬레이트 모드로 설정합니다. 접근 시 실제로는 page fault를 발생시킨 후 커널이 결과를 반환합니다. 이는 레거시 바이너리 호환성은 유지하면서 고정 주소 ROP 공격을 방지합니다. 부트 파라미터 vsyscall=none으로 완전히 비활성화할 수도 있습니다.

Seccomp (Secure Computing Mode)

seccomp-bpf 필터링

Seccomp은 프로세스가 호출할 수 있는 시스템 콜을 제한하는 리눅스 커널 보안 기능입니다. 원래 모드(strict)는 read, write, exit, sigreturn 4개만 허용했으나, seccomp-bpf(filter 모드)에서는 BPF 프로그램으로 세밀한 필터링이 가능합니다.

/* seccomp-bpf 필터 예제: write만 허용하는 필터 */
#include <linux/seccomp.h>
#include <linux/filter.h>
#include <sys/prctl.h>

struct sock_filter filter[] = {
    /* 아키텍처 검증 */
    BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
            offsetof(struct seccomp_data, arch)),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64,
            1, 0),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS),

    /* 시스템 콜 번호 로드 */
    BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
            offsetof(struct seccomp_data, nr)),

    /* write(1) 허용 */
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_write, 0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),

    /* exit_group(231) 허용 */
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_exit_group, 0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),

    /* 나머지 모두 거부 */
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS),
};

struct sock_fprog prog = {
    .len    = (unsigned short)(sizeof(filter) / sizeof(filter[0])),
    .filter = filter,
};

/* 필터 설치 */
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog);

seccomp 필터의 반환값(action):

반환값 동작
SECCOMP_RET_ALLOW 시스템 콜 허용
SECCOMP_RET_KILL_PROCESS 프로세스 종료 (SIGSYS)
SECCOMP_RET_KILL_THREAD 해당 스레드만 종료
SECCOMP_RET_TRAP SIGSYS 시그널 전송 (핸들링 가능)
SECCOMP_RET_ERRNO 지정한 errno 반환
SECCOMP_RET_TRACE ptrace tracer에 통지
SECCOMP_RET_LOG 허용하되 로그 기록
SECCOMP_RET_USER_NOTIF 사용자 공간 알림 fd로 전달

컨테이너 보안과 seccomp

컨테이너 런타임(Docker, containerd 등)은 seccomp 프로파일을 사용하여 컨테이너 내부에서 위험한 시스템 콜을 차단합니다. Docker의 기본 프로파일은 약 44개의 시스템 콜을 차단합니다. 자세한 컨테이너 보안 설정은 Linux Containers 심화 페이지를 참조하세요.

# Docker 기본 seccomp 프로파일에서 차단되는 주요 시스템 콜
# (보안 위험이 높은 시스템 콜)
$ docker run --rm alpine cat /proc/1/status | grep Seccomp
Seccomp:	2      # 2 = SECCOMP_MODE_FILTER

# 차단되는 예시:
# - mount, umount2     : 파일시스템 조작
# - reboot             : 호스트 재부팅
# - kexec_load         : 커널 교체
# - init_module        : 커널 모듈 로드
# - bpf                : BPF 프로그램 로드
# - userfaultfd        : 사용자 페이지 폴트 핸들링 (exploit primitive)

# 커스텀 seccomp 프로파일로 컨테이너 실행
$ docker run --security-opt seccomp=my-profile.json alpine sh
seccomp 필터 체이닝: seccomp 필터는 누적됩니다. fork()/clone()으로 생성된 자식 프로세스는 부모의 필터를 상속하며, 추가 필터를 설치하면 기존 필터에 AND 조건으로 결합됩니다. 따라서 필터는 점점 더 제한적으로만 변경 가능합니다.

KPTI와 보안 완화

2018년 공개된 Meltdown과 Spectre 취약점은 시스템 콜 메커니즘의 보안 가정에 근본적인 의문을 제기했습니다. 이를 완화하기 위한 기법들이 커널에 통합되었으며, 시스템 콜 성능에 상당한 영향을 미쳤습니다. 커널 보안커널 취약점 분석 페이지도 참고하세요.

Meltdown (CVE-2017-5754)과 KPTI

Meltdown은 CPU의 투기적 실행(Speculative Execution)을 이용하여 사용자 프로세스가 시스템 콜 수행 중 커널 메모리를 사이드채널로 읽을 수 있는 취약점입니다. CPU가 권한 검사 결과를 기다리지 않고 투기적으로 커널 데이터를 캐시에 올리기 때문에 발생합니다.

KPTI (Kernel Page-Table Isolation)는 사용자 모드와 커널 모드에서 서로 다른 CR3 레지스터값(페이지 테이블)을 사용하여 Meltdown을 차단합니다. 사용자 모드 페이지 테이블에는 커널 진입에 필요한 최소한의 코드(entry_SYSCALL_64)만 매핑됩니다.

# KPTI 활성화 여부 확인
$ cat /sys/devices/system/cpu/vulnerabilities/meltdown
Mitigation: PTI

# CPU 취약점 전체 상태 확인
$ grep . /sys/devices/system/cpu/vulnerabilities/*
/sys/devices/system/cpu/vulnerabilities/meltdown:Mitigation: PTI
/sys/devices/system/cpu/vulnerabilities/spectre_v1:Mitigation: usercopy/swapgs barriers and __user pointer sanitization
/sys/devices/system/cpu/vulnerabilities/spectre_v2:Mitigation: Retpolines; IBPB: conditional; IBRS_FW; RSB filling

# KPTI 비활성화 부팅 옵션 (가상 환경에서 성능 우선 시)
# GRUB_CMDLINE_LINUX="nopti"

# 커널 설정에서 KPTI 및 Spectre 완화 항목 확인
$ grep -E 'CONFIG_RETPOLINE|CONFIG_PAGE_TABLE_ISOLATION' /boot/config-$(uname -r)
CONFIG_RETPOLINE=y
CONFIG_PAGE_TABLE_ISOLATION=y
KPTI 작동 원리: 시스템 콜 진입/반환 시 CR3 레지스터를 교체하여 페이지 테이블을 전환합니다. 이 CR3 교체는 TLB flush를 유발하여 성능 저하가 발생합니다. Intel Haswell+ CPU의 PCID(Process Context IDentifier) 기능을 사용하면 TLB를 완전히 flush하지 않아 KPTI 오버헤드를 크게 줄일 수 있습니다.

Spectre v1 — 경계 검사 우회 (CVE-2017-5753)

CPU가 분기 예측 실패 시에도 투기적으로 실행하는 코드에서 비밀 데이터를 캐시 사이드채널로 유출하는 취약점입니다. do_syscall_x64()의 배열 인덱스 검증이 우회 대상이므로, array_index_nospec()으로 완화합니다.

/* Spectre v1 취약점 패턴 */
if (user_nr < NR_syscalls) {
    /* CPU가 투기적으로 실행: 범위 밖 sys_call_table[user_nr] 접근 가능 */
    regs->ax = sys_call_table[user_nr](regs);  /* 취약 */
}

/* 완화: array_index_nospec() — 투기적 실행 시 안전한 인덱스 보장 */
if (user_nr < NR_syscalls) {
    user_nr = array_index_nospec(user_nr, NR_syscalls);
    regs->ax = sys_call_table[user_nr](regs);  /* 안전 */
}

/* arch/x86/entry/common.c 실제 코드 */
static __always_inline bool do_syscall_x64(struct pt_regs *regs, int nr)
{
    if (likely(nr < NR_syscalls)) {
        nr = array_index_nospec(nr, NR_syscalls);
        regs->ax = sys_call_table[nr](regs);
        return true;
    }
    return false;
}

Spectre v2 — 간접 분기 예측 주입 (CVE-2017-5715)

공격자가 CPU의 간접 분기 예측기(BTB: Branch Target Buffer)를 조작하여 피해자 프로세스의 코드에서 원하는 가젯을 투기적으로 실행시키는 취약점입니다. sys_call_table[nr](regs)와 같은 간접 함수 호출이 표적이 됩니다.

완화 기법 방식 성능 영향
RETPOLINE 간접 점프를 ret 명령어로 변환하여 BTB 예측 우회 낮음 (컴파일러 변환)
IBRS Indirect Branch Restricted Speculation: 커널 모드에서 BTB 격리 높음 (매 syscall마다)
IBPB Indirect Branch Predictor Barrier: 컨텍스트 전환 시 BTB 플러시 중간 (컨텍스트 전환마다)
eIBRS Enhanced IBRS: 하드웨어 지원 IBRS (Ice Lake+) 매우 낮음

swapgs 취약점 (CVE-2019-1125)

swapgsentry_SYSCALL_64의 첫 번째 명령어입니다. Spectre v1 변종이 이 경계를 이용하여 swapgs 실행 전에 투기적으로 커널 GS 기반에 접근하는 취약점이 발견되었습니다. 완화책으로 swapgs 전후에 LFENCE 명령어가 삽입되었습니다.

KPTI 성능 영향

워크로드 KPTI 오버헤드 비고
데이터베이스 (PostgreSQL) 5~17% syscall 빈도 높음
웹 서버 (Nginx) 3~10% accept/send 빈도 높음
컴파일 (gcc) 1~3% 파일 I/O 위주
인메모리 캐시 (Redis) 20~30% 초고속 syscall 루프
가상 머신 내부 거의 없음 PCID + 하이퍼바이저 최적화
PCID (Process Context IDentifier): Intel Haswell+부터 지원하는 PCID 기능을 사용하면 CR3 전환 시 TLB를 완전히 플러시하지 않아도 됩니다. 리눅스 커널은 PCID를 자동으로 활용하여 KPTI 오버헤드를 크게 줄입니다 (CONFIG_X86_64 기본 활성화). ARM64에서는 ASID(Address Space ID)가 동일한 역할을 합니다.

시스템 콜 추적과 디버깅

strace

strace는 프로세스의 시스템 콜 호출을 추적하는 사용자 공간 도구입니다. 내부적으로 ptrace() 시스템 콜을 사용하여 대상 프로세스의 syscall 진입/반환을 가로챕니다.

# 기본 추적: 시스템 콜과 반환값 출력
$ strace ls /tmp
execve("/usr/bin/ls", ["ls", "/tmp"], ...) = 0
openat(AT_FDCWD, "/tmp", O_RDONLY|O_DIRECTORY) = 3
getdents64(3, ..., 32768) = 240
write(1, "file1.txt\\nfile2.log\\n", 20) = 20
close(3)                                = 0
exit_group(0)                           = ?

# 시간 측정: 각 시스템 콜의 소요 시간
$ strace -T -e trace=read,write cat /dev/null
read(3, "", 131072)                     = 0 <0.000008>

# 통계 요약: 시스템 콜별 호출 횟수와 시간
$ strace -c ls /tmp
% time     calls  syscall
------  --------  --------
 25.00        10  mmap
 18.75         5  openat
 12.50         5  close
  6.25         3  read
  6.25         1  write

# 특정 시스템 콜만 추적
$ strace -e trace=network nginx          # 네트워크 관련만
$ strace -e trace=%file ls               # 파일 관련만
$ strace -e trace=%process bash           # 프로세스 관련만

ftrace 시스템 콜 tracepoint

ftrace의 시스템 콜 tracepoint는 ptrace보다 훨씬 가벼운 커널 내부 추적 메커니즘입니다. SYSCALL_METADATA 매크로가 생성한 tracepoint를 활용합니다.

# 시스템 콜 tracepoint 활성화
$ cd /sys/kernel/tracing

# 사용 가능한 시스템 콜 이벤트 확인
$ ls events/syscalls/ | head
sys_enter_read
sys_exit_read
sys_enter_write
sys_exit_write

# read 시스템 콜 진입/반환 추적
$ echo 1 > events/syscalls/sys_enter_read/enable
$ echo 1 > events/syscalls/sys_exit_read/enable
$ cat trace
           <...>-1234  [002] .... 12345.678: sys_read(fd: 3, buf: 7ffd..., count: 4096)
           <...>-1234  [002] .... 12345.679: sys_read -> 0x100

# 필터 적용: 특정 PID의 시스템 콜만
$ echo 'common_pid == 1234' > events/syscalls/sys_enter_write/filter

audit 서브시스템

Linux audit 프레임워크는 보안 감사를 위해 시스템 콜을 기록합니다. 시스템 콜 진입 경로의 syscall_enter_from_user_mode()에서 audit 검사가 수행됩니다.

# 파일 삭제 관련 시스템 콜 감사 규칙 추가
$ auditctl -a always,exit -F arch=b64 -S unlink,unlinkat,rename,renameat \
    -F auid>=1000 -k file_delete

# 감사 로그 검색
$ ausearch -k file_delete -ts recent
type=SYSCALL msg=audit(1707000000.123:456): arch=c000003e syscall=263 \
  success=yes exit=0 a0=ffffff9c a1=7ffd... auid=1000 uid=1000 \
  comm="rm" exe="/usr/bin/rm"

# 실행 파일 실행 감사
$ auditctl -a always,exit -F arch=b64 -S execve -k exec_log

동적 추적 심화

시스템 콜 추적에는 여러 도구가 있으며 각자 오버헤드와 기능이 다릅니다. strace는 사용이 간편하지만 ptrace 기반으로 오버헤드가 크고, perf tracebpftrace는 훨씬 가벼운 커널 tracepoint를 사용합니다. BPF/XDPftrace 페이지도 참고하세요.

perf trace — 고성능 시스템 콜 추적

perf tracestrace와 유사한 출력을 제공하지만, ptrace 대신 커널 tracepoint를 사용하여 10~100배 낮은 오버헤드로 동작합니다. 프로덕션 환경에서도 사용 가능합니다.

# perf trace: strace와 유사하지만 낮은 오버헤드
$ sudo perf trace ls /tmp
     0.000 ( 0.020 ms): ls/1234 execve("/usr/bin/ls", ...) = 0
     0.234 ( 0.005 ms): ls/1234 brk(NULL)                 = 0x...
     2.100 ( 0.008 ms): ls/1234 openat(AT_FDCWD, "/tmp", O_RDONLY) = 3
     2.200 ( 0.003 ms): ls/1234 getdents64(3, ..., 32768) = 240

# 특정 시스템 콜만 추적 (실행 중인 프로세스)
$ sudo perf trace -e 'syscalls:sys_enter_read,syscalls:sys_exit_read' \
    -p $(pgrep nginx)

# 전체 시스템의 시스템 콜 통계 집계 (5초)
$ sudo perf trace -s --duration 5000 2>/dev/null
   syscall            calls  total       min       avg       max
   --------------- -------- --------- --------- --------- ---------
   futex              12542  1523.232     0.001   121.440  9999.000
   epoll_wait          3201  4521.100     0.002  1412.400 10000.000
   read                8901    45.231     0.001     5.082   102.400

# 시스템 콜별 시간 측정 (perf stat)
$ perf stat -e 'syscalls:sys_enter_read,syscalls:sys_enter_write' \
    dd if=/dev/zero of=/dev/null bs=4096 count=100000
   100,000  syscalls:sys_enter_read
   100,000  syscalls:sys_enter_write

bpftrace — eBPF 기반 추적

bpftrace는 커널 tracepoint와 kprobes에 eBPF 프로그램을 부착하여 고성능 추적과 통계 수집을 수행합니다. one-liner 문법으로 강력한 분석이 가능합니다.

# read() 레이턴시 분포 히스토그램
$ sudo bpftrace -e '
    tracepoint:syscalls:sys_enter_read { @ts[tid] = nsecs; }
    tracepoint:syscalls:sys_exit_read  {
        @usecs = hist((nsecs - @ts[tid]) / 1000);
        delete(@ts[tid]);
    }
    END { print(@usecs); }'

# 프로세스별 시스템 콜 횟수 집계 (top 20)
$ sudo bpftrace -e '
    tracepoint:raw_syscalls:sys_enter {
        @[comm, args->id] = count();
    }
    END { print(@, 20); }'

# 특정 UID의 execve 추적
$ sudo bpftrace -e '
    tracepoint:syscalls:sys_enter_execve
    /uid == 1000/ {
        printf("PID %d (%s) exec: %s\n", pid, comm, str(args->filename));
    }'

# write() fd별 바이트 수 집계
$ sudo bpftrace -e '
    tracepoint:syscalls:sys_enter_write {
        @bytes[args->fd] = sum(args->count);
    }'

# 느린 시스템 콜 식별 (100μs 이상)
$ sudo bpftrace -e '
    tracepoint:raw_syscalls:sys_enter { @start[tid] = nsecs; }
    tracepoint:raw_syscalls:sys_exit {
        $lat = (nsecs - @start[tid]) / 1000;
        if ($lat > 100) {
            printf("slow syscall: pid=%d comm=%s lat=%dμs\n",
                   pid, comm, $lat);
        }
        delete(@start[tid]);
    }'

kprobes로 시스템 콜 핸들러 내부 계측

kprobes는 커널 함수 심볼에 동적으로 브레이크포인트를 삽입하여 시스템 콜 핸들러 내부를 계측할 수 있습니다. 커널 디버깅 페이지에서 kprobes 전반을 다룹니다.

/* kprobe를 이용한 시스템 콜 핸들러 내부 계측 */
#include <linux/kprobes.h>

static int handler_pre(struct kprobe *p, struct pt_regs *regs)
{
    /* ksys_read() 진입 시 호출: fd와 count 출력 */
    pr_info("ksys_read: fd=%ld, count=%ld\n",
            regs->di, regs->dx);
    return 0;
}

static struct kprobe kp = {
    .symbol_name = "ksys_read",
    .pre_handler = handler_pre,
};

static int __init probe_init(void)
{
    return register_kprobe(&kp);
}

/* bpftrace one-liner로 동일한 효과 */
$ sudo bpftrace -e '
    kprobe:ksys_read {
        printf("pid=%d fd=%d count=%d\n", pid, arg0, arg2);
    }'
perf trace vs strace 오버헤드 비교: straceptrace(PTRACE_SYSCALL)로 매 시스템 콜마다 대상 프로세스를 중단시켜 100배 이상의 오버헤드가 발생할 수 있습니다. perf trace는 ring buffer 기반 tracepoint를 사용하여 오버헤드가 1~5% 이하입니다. bpftrace는 eBPF JIT 컴파일로 더 낮은 오버헤드를 달성합니다. 프로덕션 환경에서는 항상 perf/bpftrace를 사용하세요.

시스템 콜 성능 분석

시스템 콜은 모드 전환, 레지스터 저장, 커널 스택 전환 등의 비용으로 수백 나노초가 소요됩니다. KPTI와 Spectre 완화까지 고려하면 오버헤드는 더 커집니다. vDSO와 io_uring을 활용하면 이 오버헤드를 크게 줄일 수 있습니다.

시스템 콜 레이턴시 수치

방식 레이턴시 (ns) 조건
vDSO clock_gettime 5~15 ns 커널 진입 없음, TSC 직접 읽기
getpid() — KPTI 없음 80~120 ns x86_64 Skylake, PCID 있음
getpid() — KPTI 있음 150~250 ns PCID 없는 경우 TLB flush 추가
read() — 파이프 캐시 히트 300~600 ns 1바이트, 컨텍스트 스위치 없음
io_uring — 배치 처리 50~100 ns/I/O SQ/CQ 링버퍼, 여러 I/O 묶음 처리

vDSO 성능 이점

/* vDSO vs syscall 성능 비교 측정 */
#include <time.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/syscall.h>

#define ITER 10000000

int main(void)
{
    struct timespec ts, t1, t2;

    /* glibc clock_gettime: vDSO를 통해 커널 진입 없이 실행 */
    clock_gettime(CLOCK_MONOTONIC, &t1);
    for (int i = 0; i < ITER; i++)
        clock_gettime(CLOCK_MONOTONIC, &ts);
    clock_gettime(CLOCK_MONOTONIC, &t2);
    printf("vDSO clock_gettime: %.1f ns/call\n",
           (t2.tv_nsec - t1.tv_nsec + (t2.tv_sec - t1.tv_sec) * 1e9) / ITER);

    /* syscall()로 강제 커널 진입 — vDSO 우회 */
    clock_gettime(CLOCK_MONOTONIC, &t1);
    for (int i = 0; i < ITER; i++)
        syscall(__NR_clock_gettime, CLOCK_MONOTONIC, &ts);
    clock_gettime(CLOCK_MONOTONIC, &t2);
    printf("syscall clock_gettime: %.1f ns/call\n",
           (t2.tv_nsec - t1.tv_nsec + (t2.tv_sec - t1.tv_sec) * 1e9) / ITER);

    /* 전형적인 결과: vDSO ~10ns vs syscall ~200ns (10~20x 차이) */
    return 0;
}

io_uring vs 전통적 I/O

io_uring은 공유 링버퍼(Submission Queue / Completion Queue)로 I/O 요청과 완료를 전달하여 다수의 I/O를 단 한 번의 시스템 콜로 처리합니다. SQPOLL 모드에서는 커널 스레드가 폴링하여 시스템 콜 자체를 제거합니다.

방식 I/O당 syscall 수 상대적 IOPS
전통적 read()/write() 1 syscall / I/O 기준선 (1x)
preadv2()/pwritev2() 1 syscall / 여러 I/O ~1.2x
io_uring (기본 모드) 1 syscall / 배치 ~1.5x
io_uring (SQPOLL 모드) 0 syscall (커널 폴링) ~2x (CPU 1코어 점유)

시스템 콜 방식별 성능 비교

레이턴시 비교 (낮을수록 좋음, 대표값 기준) 500ns 400ns 300ns 200ns 100ns vDSO clock_gettime ~10ns getpid() KPTI 없음 ~100ns getpid() KPTI 있음 ~200ns read() 캐시 히트 ~400ns io_uring 배치 처리 ~75ns

주요 시스템 콜 분류

Linux 시스템 콜은 기능에 따라 다음과 같이 분류됩니다. 관련 문서 페이지로의 교차 참조를 포함합니다.

카테고리 대표 시스템 콜 관련 페이지
프로세스 fork, clone, clone3, execve, exit, wait4, getpid 프로세스 관리
파일 I/O open, read, write, close, lseek, pread64, pwrite64 VFS, inode
메모리 mmap, munmap, mprotect, brk, madvise, mlock 메모리 관리
네트워크 socket, bind, listen, accept, connect, sendto, recvfrom 네트워크 스택
IPC pipe, shmget, semget, msgget, eventfd, signalfd IPC 메커니즘
시그널 kill, rt_sigaction, rt_sigprocmask, sigaltstack 시그널 처리
파일시스템 관리 statfs, mount, umount2, pivot_root, chroot VFS, 네임스페이스
시간 clock_gettime, nanosleep, timer_create, timerfd_create ktime / Clock, 타이머
네임스페이스 unshare, setns, clone (CLONE_NEW*) 네임스페이스
보안 prctl, seccomp, setuid, setgid, capset Linux Containers
비동기 I/O io_uring_setup, io_uring_enter, epoll_create1, select, poll 성능 최적화
모듈/드라이버 init_module, finit_module, delete_module, ioctl 커널 모듈, 디바이스 드라이버
BPF bpf, perf_event_open BPF/XDP
시스템 콜 전체 목록: man 2 syscalls 명령어로 현재 시스템에서 지원하는 전체 시스템 콜 목록을 확인할 수 있습니다. 커널 소스의 include/linux/syscalls.h에 모든 시스템 콜의 프로토타입이 선언되어 있습니다.

x86_64 시스템 콜 진입 전체 경로

x86_64 아키텍처에서 사용자 프로그램이 시스템 콜을 호출할 때의 전체 경로를 하드웨어 수준부터 C 핸들러 반환까지 상세히 추적합니다. SYSCALL 명령어와 SYSENTER 명령어의 차이, entry_SYSCALL_64의 내부 동작, 그리고 SYSRETIRET 반환 경로의 선택 기준을 분석합니다.

SYSCALL vs SYSENTER

SYSCALL(AMD 제안, x86_64 표준)과 SYSENTER(Intel 제안, 32비트 전용)는 모두 빠른 시스템 콜 진입을 위한 CPU 명령어이지만, 동작 방식과 사용 맥락이 다릅니다. 64비트 모드에서는 SYSCALL만 사용됩니다.

항목 SYSCALL/SYSRET SYSENTER/SYSEXIT
출처 AMD (K6-2, 1998) Intel (Pentium II, 1997)
64비트 지원 x86_64 표준 (필수) 32비트 전용 (Long Mode 미지원)
진입점 MSR MSR_LSTAR (0xC0000082) MSR_IA32_SYSENTER_EIP (0x176)
RIP 저장 하드웨어가 RCX ← RIP 소프트웨어가 스택에 push
RFLAGS 저장 하드웨어가 R11 ← RFLAGS 저장하지 않음
스택 전환 소프트웨어 (entry_SYSCALL_64) 하드웨어 (MSR_IA32_SYSENTER_ESP)
Linux 사용 64비트 모드 전용 32비트 compat 모드 (entry_SYSENTER_compat)

MSR 초기화: 진입점 등록

커널 부팅 시 syscall_init()이 호출되어 MSR_LSTARentry_SYSCALL_64의 주소를 기록합니다. 이후 모든 SYSCALL 명령어는 이 주소로 점프합니다.

/* arch/x86/kernel/cpu/common.c */
void syscall_init(void)
{
    wrmsr(MSR_STAR,  0, (__USER32_CS << 16) | __KERNEL_CS);
    wrmsrl(MSR_LSTAR, (unsigned long)entry_SYSCALL_64);
    wrmsrl(MSR_CSTAR, (unsigned long)ignore_sysret);

    /* SYSCALL 시 마스크할 RFLAGS 비트: IF, TF, DF, AC, NT */
    wrmsrl(MSR_SYSCALL_MASK,
           X86_EFLAGS_TF | X86_EFLAGS_DF | X86_EFLAGS_IF |
           X86_EFLAGS_AC | X86_EFLAGS_NT);
}
MSR_SYSCALL_MASK: SYSCALL 명령어 실행 시 하드웨어가 이 마스크에 지정된 RFLAGS 비트를 자동으로 클리어합니다. IF(인터럽트 플래그)를 클리어하면 커널 진입 초기에 인터럽트가 비활성화되어 안전하게 스택 전환을 수행할 수 있습니다. AC 비트 클리어는 SMAP 보호를 활성 상태로 유지합니다.

entry_SYSCALL_64 전체 실행 시퀀스

다음 다이어그램은 SYSCALL 명령어 실행부터 SYSRET/IRET 반환까지의 전체 경로를 보여줍니다. 각 단계에서 수행되는 하드웨어/소프트웨어 동작을 구분합니다.

User Space (Ring 3, CPL=3) Kernel Space (Ring 0, CPL=0) User Space (복귀) SYSCALL: RCX←RIP, R11←RFLAGS, CPL→0, RIP←MSR_LSTAR RFLAGS &= ~MSR_SYSCALL_MASK (IF=0, AC=0) swapgs (GS base → 커널 per-CPU 영역) 사용자 RSP 저장 → 커널 스택(TSS.sp0) 전환 PUSH_AND_CLEAR_REGS → pt_regs 구조체 형성 syscall_enter_from_user_mode() 보안 검사 체인: 1. ptrace 검사 2. seccomp BPF 3. audit 기록 4. 시스템 콜 번호 검증 array_index_nospec() → sys_call_table[nr](regs) 실제 핸들러 실행 (SYSCALL_DEFINE) syscall_exit_to_user_mode() 종료 작업: 1. 시그널 처리 2. need_resched 확인 3. audit exit 기록 pt_regs 복원 (POP_REGS) swapgs → SYSRET (빠른 경로) swapgs → IRET (느린 경로) RIP←RCX, RFLAGS←R11, CPL→3 (사용자 모드 복귀) 반환값: RAX (성공 시 값, 실패 시 -errno)

SYSRET vs IRET 반환 경로 선택

SYSRETSYSCALL의 역연산으로 가장 빠른 반환 경로이지만, 모든 상황에서 사용할 수 없습니다. 특정 조건에서는 더 느리지만 안전한 IRET 경로를 선택해야 합니다.

조건 반환 경로 사유
정상적인 64비트 시스템 콜 SYSRET 가장 빠른 경로
사용자 RIP가 비표준(non-canonical) 주소 IRET SYSRET이 GP fault를 Ring 0에서 발생시키는 버그 방지 (CVE-2014-4699)
ptrace가 RIP/RFLAGS를 변경한 경우 IRET 임의의 RFLAGS 복원 필요
시그널 전달 중 IRET 수정된 pt_regs로 복귀
NMI/인터럽트 중첩 IRET 중첩된 스택 프레임 안전 복원
/* arch/x86/entry/entry_64.S — SYSRET 가능 여부 검사 */
SYM_INNER_LABEL(entry_SYSCALL_64_after_hwframe)
    ...
    /* RCX(복귀 RIP)가 canonical 주소인지 확인 */
    movq    RCX(%rsp), %rcx
    movq    RIP(%rsp), %r11
    cmpq    %rcx, %r11
    jne     swapgs_restore_regs_and_return_to_usermode  /* → IRET */

    /* RFLAGS 검사: IOPL이 0이 아니면 IRET 사용 */
    testq   $(X86_EFLAGS_RF|X86_EFLAGS_TF), R11(%rsp)
    jnz     swapgs_restore_regs_and_return_to_usermode  /* → IRET */

    /* 모든 검사 통과 → SYSRET 빠른 경로 */
    swapgs
    sysretq
CVE-2014-4699 (SYSRET 취약점): Intel CPU에서 SYSRET이 비표준(non-canonical) RIP 주소로 복귀할 때 #GP 예외가 Ring 0에서 발생합니다. 이때 RSP는 이미 사용자 스택을 가리키고 있어, 공격자가 제어하는 스택에 커널 데이터가 기록됩니다. Linux 커널은 SYSRET 전에 RIP 주소를 검증하여 이 취약점을 방지합니다.

seccomp BPF 검사 심화

seccomp-BPF 필터는 시스템 콜 진입 경로의 syscall_enter_from_user_mode() 내부에서 실행됩니다. 필터는 cBPF(classic BPF) 바이트코드로 작성되며, 커널이 JIT 컴파일하여 네이티브 코드로 변환합니다. 이 섹션에서는 __seccomp_filter()의 내부 구조와 SECCOMP_RET_* 액션의 실행 경로를 분석합니다.

__seccomp_filter() 내부 흐름

/* kernel/seccomp.c — 핵심 필터 실행 루틴 */
static int __seccomp_filter(int this_syscall,
                            const struct seccomp_data *sd,
                            const bool recheck_after_trace)
{
    u32 filter_ret, action;
    struct seccomp_filter *match = NULL;
    int data;

    /* 필터 체인 실행: 가장 마지막에 설치된 필터부터 역순 */
    filter_ret = seccomp_run_filters(sd, &match);
    action = filter_ret & SECCOMP_RET_ACTION_FULL;
    data   = filter_ret & SECCOMP_RET_DATA;

    switch (action) {
    case SECCOMP_RET_KILL_PROCESS:
        seccomp_log(this_syscall, SIGSYS, action, true);
        do_group_exit(SIGSYS);     /* 전체 프로세스 종료 */
        break;

    case SECCOMP_RET_KILL_THREAD:
        seccomp_log(this_syscall, SIGSYS, action, true);
        do_exit(SIGSYS);            /* 해당 스레드만 종료 */
        break;

    case SECCOMP_RET_TRAP:
        /* SIGSYS 시그널 전달 (siginfo에 syscall 정보 포함) */
        syscall_set_return_value(current, current_pt_regs(),
                                 -ENOSYS, 0);
        force_sig_seccomp(this_syscall, data, false);
        goto skip;

    case SECCOMP_RET_ERRNO:
        /* 지정된 errno 반환 (시스템 콜 실행하지 않음) */
        syscall_set_return_value(current, current_pt_regs(),
                                 -data, 0);
        goto skip;

    case SECCOMP_RET_TRACE:
        /* ptrace tracer에 통지: tracer가 syscall 변경 가능 */
        if (!ptrace_event_enabled(current, PTRACE_EVENT_SECCOMP))
            goto skip;
        ptrace_event(PTRACE_EVENT_SECCOMP, data);
        break;

    case SECCOMP_RET_USER_NOTIF:
        /* 사용자 공간 supervisor에 fd로 전달 */
        return seccomp_do_user_notification(this_syscall,
                                            match, sd);

    case SECCOMP_RET_LOG:
        seccomp_log(this_syscall, 0, action, true);
        /* fall through → ALLOW */

    case SECCOMP_RET_ALLOW:
        /* 시스템 콜 정상 진행 */
        return 0;
    }
    ...
}

seccomp_data 구조체

BPF 필터에 전달되는 데이터 구조체입니다. 필터는 이 구조체의 필드를 BPF_LD/BPF_ABS로 로드하여 검사합니다.

/* include/uapi/linux/seccomp.h */
struct seccomp_data {
    int   nr;                 /* 시스템 콜 번호 */
    __u32 arch;               /* AUDIT_ARCH_* (아키텍처 식별) */
    __u64 instruction_pointer; /* 호출 위치 (syscall 명령어 주소) */
    __u64 args[6];            /* 시스템 콜 인자 (arg0~arg5) */
};
arch 필드 검증 필수: seccomp 필터는 반드시 arch 필드를 검증해야 합니다. 그렇지 않으면 공격자가 32비트 호환 모드(int 0x80)로 다른 번호 체계의 시스템 콜을 호출하여 필터를 우회할 수 있습니다. 예를 들어 x86_64에서 __NR_write는 1이지만, 32비트에서는 4입니다.
syscall_enter_from_user_mode() __seccomp_filter(nr, &sd) seccomp_data 구조체 구성 seccomp_run_filters(sd) cBPF 프로그램 실행 (JIT 컴파일) action = ret & MASK RET_ALLOW RET_LOG RET_ERRNO RET_TRAP RET_KILL_* USER NOTIF 시스템 콜 진행 진행 + 로그 -errno 반환 SIGSYS 전달 프로세스/스레드 종료 fd 전달 audit_seccomp(): 감사 로그 기록 (SECCOMP_RET_LOG/KILL)

SECCOMP_RET_USER_NOTIF: 사용자 공간 정책

SECCOMP_RET_USER_NOTIF는 Linux 5.0에서 도입된 기능으로, seccomp 필터가 시스템 콜을 사용자 공간의 supervisor 프로세스에 위임합니다. 컨테이너 런타임이 mount(), mknod() 등을 에뮬레이트하는 데 사용됩니다.

/* seccomp_unotif를 사용한 사용자 공간 syscall 핸들링 */
#include <linux/seccomp.h>

/* 1. seccomp 필터 설치 (감시 대상 프로세스) */
int listener_fd = syscall(__NR_seccomp,
    SECCOMP_SET_MODE_FILTER,
    SECCOMP_FILTER_FLAG_NEW_LISTENER,
    &prog);

/* 2. supervisor 프로세스에서 알림 수신 */
struct seccomp_notif *req;
struct seccomp_notif_resp *resp;
seccomp_notify_alloc(&req, &resp);

while (1) {
    ioctl(listener_fd, SECCOMP_IOCTL_NOTIF_RECV, req);

    /* 시스템 콜 번호와 인자 확인 후 응답 */
    resp->id    = req->id;
    resp->val   = 0;       /* 반환값 */
    resp->error = 0;       /* errno (0 = 성공) */
    resp->flags = SECCOMP_USER_NOTIF_FLAG_CONTINUE;  /* 또는 에뮬레이트 */

    ioctl(listener_fd, SECCOMP_IOCTL_NOTIF_SEND, resp);
}
libseccomp 라이브러리: 직접 cBPF 바이트코드를 작성하는 대신 libseccomp 라이브러리를 사용하면 고수준 API로 seccomp 필터를 정의할 수 있습니다. seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(write), 0)처럼 시스템 콜 이름과 액션을 직접 지정합니다. 내부적으로 최적화된 cBPF 프로그램을 생성합니다.

vDSO 메커니즘 심화

vDSO(virtual Dynamic Shared Object)는 커널 모드 전환 없이 특정 시스템 콜을 사용자 공간에서 실행하는 최적화 메커니즘입니다. 이 섹션에서는 gettimeofday()clock_gettime()의 vDSO 최적화 내부 구조, vvar 페이지의 업데이트 메커니즘, 그리고 레거시 vsyscall과의 보안/성능 차이를 상세히 비교합니다.

vDSO 매핑 메커니즘

커널은 프로세스 생성 시 ELF 로더가 vDSO를 자동으로 매핑합니다. arch_setup_additional_pages()에서 vDSO ELF 바이너리와 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;
    unsigned long vdso_addr;

    /* ASLR 적용된 랜덤 주소 선택 */
    vdso_addr = get_unmapped_area(NULL, 0,
                                   vdso_image.size + vvar_size,
                                   0, 0);

    /* vvar 페이지 매핑 (읽기 전용, 커널이 갱신) */
    _install_special_mapping(mm, vdso_addr,
                             vvar_size,
                             VM_READ | VM_MAYREAD,
                             &vvar_mapping);

    /* vDSO 코드 매핑 (읽기+실행) */
    _install_special_mapping(mm, vdso_addr + vvar_size,
                             vdso_image.size,
                             VM_READ | VM_EXEC | VM_MAYREAD | VM_MAYEXEC,
                             &vdso_mapping);
    return 0;
}

/* vvar 페이지 내용: 커널이 타이머 인터럽트마다 업데이트 */
struct vdso_data {
    u32 seq;                 /* seqcount: 읽기 측 일관성 보장 */
    s32 clock_mode;           /* VDSO_CLOCKMODE_TSC 등 */
    u64 cycle_last;           /* 마지막 TSC 값 */
    u64 mask;                 /* TSC 마스크 */
    u32 mult;                 /* 사이클 → 나노초 변환 승수 */
    u32 shift;                /* 나노초 변환 시프트 */
    struct vdso_timestamp basetime[VDSO_BASES]; /* 기준 시간 */
    s32 tz_minuteswest;       /* 시간대 오프셋 */
    s32 tz_dsttime;           /* DST 정보 */
};
User Space (Ring 3) Kernel Space (Ring 0) clock_gettime() glibc: vDSO 함수 호출 __vdso_clock_gettime() vDSO 코드 영역 (R-X, ASLR) vvar 페이지 (R--, 읽기 전용) seq, cycle_last, mult, shift, basetime[] 사용자 공간에서 직접 읽기 RDTSC (현재 TSC 읽기) 결과 반환 (~10ns) 타이머 인터럽트 (HZ/tick) update_vsyscall() vdso_data 구조체 갱신 seq++ → 데이터 기록 → seq++ (seqcount) vsyscall 페이지 (레거시) 고정 주소 0xFFFFFFFFFF600000 emulate 모드: page fault → 커널 처리 clock_gettime_fallback() TSC 불안정 시 → 실제 SYSCALL 호출 오버헤드: ~200ns 공유 매핑 폴백

seqcount 기반 일관성 보장

vDSO는 커널과 사용자 공간 사이의 lock-free 데이터 공유를 위해 seqcount 패턴을 사용합니다. 커널(writer)이 데이터를 갱신할 때 시퀀스 번호를 홀수로 만들고, 완료 후 짝수로 만듭니다. 사용자(reader)는 읽기 전후의 시퀀스 번호가 같고 짝수인지 확인합니다.

/* vDSO 시간 읽기: seqcount 기반 lock-free 읽기 */
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 {
        seq = vdso_read_begin(vd);  /* seq 홀수이면 재시도 */

        if (unlikely(vd->clock_mode == VDSO_CLOCKMODE_NONE))
            return -1;  /* → syscall 폴백 */

        cycles = __arch_get_hw_counter(vd->clock_mode, vd);
        ns = (cycles - vd->cycle_last) * vd->mult;
        ns >>= vd->shift;
        ns += vdso_ts->nsec;
        ts->tv_sec  = vdso_ts->sec;
        ts->tv_nsec = ns;

        if (ns >= NSEC_PER_SEC) {
            ts->tv_sec++;
            ts->tv_nsec -= NSEC_PER_SEC;
        }
    } while (vdso_read_retry(vd, seq));  /* seq 변경 시 재시도 */

    return 0;
}

vsyscall 보안 문제와 에뮬레이션

vsyscall은 vDSO의 전신으로, 고정 주소(0xFFFFFFFFFF600000)에 매핑됩니다. 이 고정 주소는 ASLR을 무력화하여 ROP(Return-Oriented Programming) 공격의 가젯 소스로 악용됩니다. 현재 커널은 vsyscall=emulate(기본)로 접근 시 page fault를 발생시킨 후 커널이 결과를 반환합니다.

vsyscall 모드 부트 파라미터 동작 보안
emulate (기본) vsyscall=emulate page fault → 커널 에뮬레이션 ROP 가젯 제거, 호환성 유지
xonly vsyscall=xonly 실행만 허용, 읽기 불가 가젯 읽기 방지, 실행은 에뮬레이트
none vsyscall=none 완전 비활성화 최고 보안, 레거시 바이너리 실행 불가
vsyscall 에뮬레이트 오버헤드: vsyscall=emulate 모드에서 vsyscall 페이지 접근은 page fault → 커널 예외 핸들러 → 결과 반환 순서로 처리되므로, 실제 시스템 콜보다 오히려 느릴 수 있습니다. 모든 현대 배포판의 glibc는 vDSO를 사용하므로 vsyscall은 구형 정적 바이너리 호환용으로만 유지됩니다.

sys_call_table 내부 구조 심화

시스템 콜 테이블의 생성 과정, __x64_sys_* 래퍼 함수의 역할, 그리고 SYSCALL_DEFINE 매크로가 전처리기에 의해 어떻게 확장되는지를 심층 분석합니다. 이 메커니즘을 이해하면 CVE-2009-0029와 같은 레지스터 상위 비트 공격이 왜 발생했고 어떻게 방어되는지 파악할 수 있습니다.

__x64_sys_* 래퍼 함수

Linux 4.17부터 시스템 콜 핸들러는 개별 레지스터 인자 대신 struct pt_regs *를 유일한 인자로 받습니다. __x64_sys_* 래퍼가 pt_regs에서 인자를 추출하여 실제 핸들러에 전달합니다.

/* SYSCALL_DEFINE3(read, ...) 매크로 확장 결과 */

/* 1단계: 실제 구현 함수 (내부) */
static long __se_sys_read(
    long __fd,           /* unsigned int fd → long으로 수신 */
    long __buf,           /* char __user *buf → long */
    long __count)         /* size_t count → long */
{
    /* 2단계: long → 실제 타입으로 안전 캐스팅 */
    return __do_sys_read(
        (unsigned int)__fd,    /* 상위 32비트 절단 → CVE-2009-0029 방어 */
        (char __user *)__buf,
        (size_t)__count);
}

/* 3단계: pt_regs에서 인자 추출하는 래퍼 */
long __x64_sys_read(const struct pt_regs *regs)
{
    return __se_sys_read(
        regs->di,   /* 1번째 인자: rdi */
        regs->si,   /* 2번째 인자: rsi */
        regs->dx);  /* 3번째 인자: rdx */
}

/* 4단계: __do_sys_read = 개발자가 작성한 실제 코드 */
static long __do_sys_read(
    unsigned int fd,
    char __user *buf,
    size_t count)
{
    return ksys_read(fd, buf, count);
}
CVE-2009-0029 방어: 64비트 레지스터에 32비트 값을 전달할 때 상위 32비트가 오염될 수 있습니다. 예를 들어 unsigned int fdrdi(64비트)로 전달할 때, 상위 비트에 의도치 않은 값이 들어올 수 있습니다. __se_sys_* 래퍼가 long으로 받아 명시적 캐스팅하여 이를 방지합니다.

sys_call_table 생성 과정

시스템 콜 테이블은 빌드 시 여러 단계의 코드 생성을 거쳐 완성됩니다.

syscall_64.tbl 0 common read sys_read 1 common write sys_write ... syscalltbl.sh 스크립트 .tbl 파싱 → 헤더 생성 __SYSCALL(nr, sym) 매크로 → asm/syscalls_64.h asm/syscalls_64.h __SYSCALL(0, sys_read) __SYSCALL(1, sys_write) ... syscall_64.c #define __SYSCALL(nr, sym) [nr] = __x64_##sym, #include <asm/syscalls_64.h> sys_call_table[] __ro_after_init [0] = __x64_sys_read, [1] = __x64_sys_write, ... 기본값: __x64_sys_ni_syscall (-ENOSYS 반환) 부팅 후 읽기 전용 (W^X + __ro_after_init) 보안 보호 계층 __ro_after_init: 부팅 후 쓰기 불가 CONFIG_STATIC_CALL: 간접 호출 제거 (Spectre v2 방어)
/* 생성된 asm/syscalls_64.h (빌드 결과) */
__SYSCALL(0, sys_read)
__SYSCALL(1, sys_write)
__SYSCALL(2, sys_open)
__SYSCALL(3, sys_close)
/* ... */
__SYSCALL(435, sys_clone3)

/* arch/x86/entry/syscall_64.c */
#define __SYSCALL(nr, sym) [nr] = __x64_##sym,

const sys_call_ptr_t sys_call_table[__NR_syscall_max + 1] = {
    /* 미구현 번호는 sys_ni_syscall로 초기화 */
    [0 ... __NR_syscall_max] = __x64_sys_ni_syscall,

    /* 이 include가 실제 핸들러로 덮어씀 */
    #include <asm/syscalls_64.h>
};
/* 결과: [0]=__x64_sys_read, [1]=__x64_sys_write, ... */
CONFIG_STATIC_CALL: 최신 커널에서는 static_call()을 사용하여 간접 함수 호출(sys_call_table[nr](regs))을 직접 호출로 변환할 수 있습니다. 이는 Spectre v2의 간접 분기 예측 주입 공격을 원천 차단합니다. objtool이 빌드 시 간접 호출을 검증합니다.

ptrace/seccomp/audit 추적 훅

시스템 콜 진입과 반환 시점에는 여러 보안 및 추적 훅이 실행됩니다. syscall_enter_from_user_mode()syscall_exit_to_user_mode()에서 ptrace, seccomp, audit 서브시스템이 순서대로 호출되며, 각 훅이 시스템 콜 동작을 변경하거나 차단할 수 있습니다.

진입/반환 훅 실행 순서

진입 경로 (syscall_enter_from_user_mode) 1. __enter_from_user_mode() — 컨텍스트 추적 2. ptrace_report_syscall_entry() PTRACE_SYSCALL 중인 tracer에 통지 tracer가 nr/args 변경 가능 3. __seccomp_filter() BPF 필터 실행, KILL/ERRNO/ALLOW 결정 차단 시 시스템 콜 실행 건너뜀 4. audit_syscall_entry() 감사 규칙 매칭, 시스템 콜 기록 시작 arch, nr, args 기록 시스템 콜 핸들러 실행 반환 경로 (syscall_exit_to_user_mode) 1. audit_syscall_exit() 반환값, 성공/실패 기록 2. ptrace_report_syscall_exit() tracer에 반환값 통지 3. 시그널 처리 (do_signal) 대기 중인 시그널 전달 4. need_resched 확인 선점 필요 시 schedule() 호출 5. __exit_to_user_mode() 컨텍스트 추적 업데이트 SYSRET/IRET → 사용자 모드 복귀 TIF 플래그 (thread_info->flags) TIF_SYSCALL_TRACE → ptrace 활성 TIF_SECCOMP → seccomp 필터 설치됨 TIF_SYSCALL_AUDIT → audit 감사 활성 TIF_SIGPENDING → 대기 시그널 있음 TIF_NEED_RESCHED → 스케줄링 필요

syscall_work 플래그 검사

/* kernel/entry/common.c */
static long syscall_enter_from_user_mode_work(
    struct pt_regs *regs, long syscall)
{
    unsigned long work = READ_ONCE(current_thread_info()->syscall_work);

    if (work & SYSCALL_WORK_SECCOMP) {
        /* seccomp 필터 실행 — 차단 시 syscall = -1 */
        syscall = __seccomp_filter(syscall, NULL, false);
        if (syscall == -1)
            return -1;  /* 시스템 콜 실행 건너뜀 */
    }

    if (work & SYSCALL_WORK_SYSCALL_TRACE) {
        /* ptrace tracer에 진입 통지 */
        ptrace_report_syscall_entry(regs);
        /* tracer가 regs->orig_ax를 변경하면 다른 syscall 실행 */
        syscall = regs->orig_ax;
    }

    if (work & SYSCALL_WORK_SYSCALL_AUDIT) {
        /* audit 레코드 생성 */
        audit_syscall_entry(syscall,
            regs->di, regs->si, regs->dx, regs->r10);
    }

    if (work & SYSCALL_WORK_SYSCALL_EMU) {
        /* ptrace SYSEMU: 시스템 콜 에뮬레이트 (UML 등) */
        return -1;
    }

    return syscall;
}

ptrace를 통한 시스템 콜 변경

ptrace tracer는 PTRACE_SYSCALL로 대상 프로세스의 시스템 콜 진입/반환을 가로채고, PTRACE_SET_SYSCALL이나 레지스터 수정으로 시스템 콜 번호와 인자를 변경할 수 있습니다. strace는 이 메커니즘으로 동작합니다.

/* ptrace를 이용한 시스템 콜 모니터링 예시 */
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/user.h>

void trace_child(pid_t child)
{
    int status;
    struct user_regs_struct regs;

    waitpid(child, &status, 0);
    ptrace(PTRACE_SYSCALL, child, 0, 0);  /* 시작 */

    while (1) {
        waitpid(child, &status, 0);
        if (WIFEXITED(status)) break;

        /* 시스템 콜 진입 시점: 레지스터 읽기 */
        ptrace(PTRACE_GETREGS, child, 0, &regs);
        printf("syscall %lld(0x%llx, 0x%llx, 0x%llx)\n",
               regs.orig_rax, regs.rdi, regs.rsi, regs.rdx);

        /* 시스템 콜 번호를 변경하여 차단 */
        if (regs.orig_rax == __NR_unlink) {
            regs.orig_rax = -1;  /* 유효하지 않은 번호 → -ENOSYS */
            ptrace(PTRACE_SETREGS, child, 0, &regs);
        }

        ptrace(PTRACE_SYSCALL, child, 0, 0);
    }
}
ptrace 오버헤드: ptrace 기반 추적은 매 시스템 콜마다 2번의 컨텍스트 스위치(진입+반환)를 발생시킵니다. strace로 find / -name '*.c'를 추적하면 100배 이상 느려질 수 있습니다. 프로덕션 환경에서는 ftrace tracepoint나 eBPF를 사용하세요.

ARM64 시스템 콜 심화

ARM64(AArch64) 아키텍처의 시스템 콜 메커니즘을 x86_64와 비교하면서 심층 분석합니다. SVC #0 명령어의 동작, el0_svc 핸들러의 내부 구조, 예외 벡터 테이블의 레이아웃, 그리고 32비트 AArch32 호환 계층을 다룹니다.

ARM64 예외 벡터 테이블

ARM64에서 SVC #0은 동기 예외(Synchronous Exception)를 발생시킵니다. CPU는 VBAR_EL1 레지스터가 가리키는 예외 벡터 테이블에서 적절한 핸들러를 찾습니다. 벡터 테이블은 4개 소스(같은/다른 EL, AArch64/AArch32) x 4개 예외 유형(동기/IRQ/FIQ/SError)으로 16개 엔트리로 구성됩니다.

/* arch/arm64/kernel/entry.S — 예외 벡터 테이블 */
    .align 11  /* 2048바이트 정렬 */
SYM_CODE_START(vectors)
    /* ===== 같은 EL, SP_EL0 사용 (커널에서 미사용) ===== */
    kernel_ventry  el1_sync_invalid           /* 동기 예외 */
    kernel_ventry  el1_irq_invalid            /* IRQ */
    kernel_ventry  el1_fiq_invalid            /* FIQ */
    kernel_ventry  el1_error_invalid          /* SError */

    /* ===== 같은 EL, SP_ELx 사용 (커널 내부 예외) ===== */
    kernel_ventry  el1h_64_sync               /* 커널 동기 예외 */
    kernel_ventry  el1h_64_irq                /* 커널 IRQ */
    kernel_ventry  el1h_64_fiq
    kernel_ventry  el1h_64_error

    /* ===== 다른 EL (EL0), AArch64 모드 ===== */
    kernel_ventry  el0t_64_sync               /* ★ 64비트 시스템 콜 진입 */
    kernel_ventry  el0t_64_irq
    kernel_ventry  el0t_64_fiq
    kernel_ventry  el0t_64_error

    /* ===== 다른 EL (EL0), AArch32 모드 ===== */
    kernel_ventry  el0t_32_sync               /* 32비트 호환 시스템 콜 */
    kernel_ventry  el0t_32_irq
    kernel_ventry  el0t_32_fiq
    kernel_ventry  el0t_32_error
SYM_CODE_END(vectors)
EL0 (User Space) x8=syscall_nr, x0~x5=args, SVC #0 AArch32: r7=nr, r0~r5=args, SWI #0 EL1 (Kernel Space) VBAR_EL1 + 0x400: el0t_64_sync ESR_EL1 읽기 → EC 필드로 예외 원인 판단 EC == 0x15 (SVC64) → el0_svc 분기 VBAR_EL1 + 0x600: el0t_32_sync EC == 0x11 (SVC32) → el0_svc_compat compat_sys_call_table 사용 el0_svc: 레지스터 저장 → pt_regs 커널 스택 전환, PAN 활성화 do_el0_svc() → invoke_syscall() array_index_nospec(x8, __NR_syscalls) sys_call_table[x8](regs) → 반환값 → x0 ret_to_user: 레지스터 복원 → ERET ARM64 vs x86_64 핵심 차이 진입: SVC #0 (동기 예외) vs SYSCALL (MSR) GS base 전환: 불필요 (TPIDR_EL1 사용) SMAP 대응: PAN (Privileged Access Never) SMEP 대응: PXN (Privileged Execute Never) KPTI 대응: ASID (Address Space ID, 자동) 반환: ERET (Exception Return) Spectre: CSV2 (ARM Cache Speculation v2) 32비트 호환: CONFIG_COMPAT (AArch32)

ARM64 PAN과 사용자 메모리 접근

ARM64의 PAN(Privileged Access Never)은 x86의 SMAP에 해당하는 기능으로, EL1(커널)에서 EL0(사용자) 메모리에 대한 직접 접근을 차단합니다. copy_to_user() 등에서 uaccess_enable()/uaccess_disable()로 일시적으로 해제합니다.

/* arch/arm64/include/asm/uaccess.h */
static inline void __uaccess_enable_hw_pan(void)
{
    /* PSTATE.PAN 비트 클리어 → 사용자 메모리 접근 허용 */
    asm("msr pstate_pan, #0");
}

static inline void __uaccess_disable_hw_pan(void)
{
    /* PSTATE.PAN 비트 설정 → 사용자 메모리 접근 차단 */
    asm("msr pstate_pan, #1");
}

/* ARM64 copy_from_user 내부 흐름 */
unsigned long __arch_copy_from_user(
    void *to, const void __user *from, unsigned long n)
{
    __uaccess_enable_hw_pan();   /* PAN 해제 */
    n = __raw_copy_from_user(to, from, n);
    __uaccess_disable_hw_pan();  /* PAN 복원 */
    return n;
}

AArch32 호환 계층 상세

ARM64 커널의 CONFIG_COMPAT 옵션은 32비트 ARM 바이너리를 실행할 수 있게 합니다. 32비트 시스템 콜은 SWI #0(Software Interrupt) 명령어를 사용하며, 별도의 compat_sys_call_table을 통해 디스패치됩니다.

항목 AArch64 (native) AArch32 (compat)
호출 명령어 SVC #0 SWI #0
번호 레지스터 x8 r7
인자 레지스터 x0~x5 r0~r5
반환 레지스터 x0 r0
예외 벡터 오프셋 VBAR_EL1 + 0x400 VBAR_EL1 + 0x600
시스템 콜 테이블 sys_call_table compat_sys_call_table
write 번호 64 4
Apple Silicon과 ARM64 시스템 콜: Apple M 시리즈 CPU도 ARM64 아키텍처 기반이지만, macOS/iOS는 Linux와 다른 시스템 콜 번호 체계를 사용합니다. 그러나 시스템 콜 메커니즘(SVC, EL0→EL1 전환, 예외 벡터)은 동일합니다. Asahi Linux 프로젝트에서 Apple Silicon에 Linux 커널을 포팅할 때도 동일한 el0t_64_sync 진입점을 사용합니다.

ftrace 시스템 콜 추적 심화

ftrace의 시스템 콜 tracepoint는 커널 내부에서 가장 가벼운 시스템 콜 추적 메커니즘입니다. SYSCALL_METADATA 매크로가 생성한 메타데이터를 기반으로 sys_enter_*/sys_exit_* tracepoint가 자동 생성됩니다. strace(ptrace 기반)와 달리 대상 프로세스를 중단시키지 않으며, eBPF/bpftrace와 결합하면 프로덕션 환경에서도 안전하게 사용할 수 있습니다.

strace 내부 동작 분석

straceptrace(PTRACE_SYSCALL)을 사용하여 대상 프로세스의 시스템 콜을 추적합니다. 매 시스템 콜마다 프로세스가 2번 중단(진입+반환)되므로 오버헤드가 매우 큽니다.

/* strace 내부 동작 순서 (간략화) */

/* 1. 대상 프로세스 attach */
ptrace(PTRACE_ATTACH, target_pid, 0, 0);
waitpid(target_pid, &status, 0);

/* 2. PTRACE_O_TRACESYSGOOD 옵션 설정 */
ptrace(PTRACE_SETOPTIONS, target_pid,
       0, PTRACE_O_TRACESYSGOOD);

/* 3. 추적 루프 */
while (1) {
    /* 다음 시스템 콜 진입/반환까지 실행 */
    ptrace(PTRACE_SYSCALL, target_pid, 0, 0);
    waitpid(target_pid, &status, 0);

    if (WIFSTOPPED(status) &&
        WSTOPSIG(status) == (SIGTRAP | 0x80)) {

        /* 시스템 콜 진입: 번호와 인자 읽기 */
        ptrace(PTRACE_GETREGSET, target_pid,
               NT_PRSTATUS, &iov);
        /* → regs.orig_rax = syscall number */
        /* → regs.rdi, rsi, rdx, r10, r8, r9 = args */

        /* 다시 실행하여 반환 대기 */
        ptrace(PTRACE_SYSCALL, target_pid, 0, 0);
        waitpid(target_pid, &status, 0);

        /* 시스템 콜 반환: 반환값 읽기 */
        ptrace(PTRACE_GETREGSET, target_pid,
               NT_PRSTATUS, &iov);
        /* → regs.rax = return value */
    }
}

ftrace tracepoint 활용 상세

# ===== ftrace 시스템 콜 추적 실전 활용 =====

# 1. 사용 가능한 시스템 콜 tracepoint 목록 확인
$ ls /sys/kernel/tracing/events/syscalls/ | head -20
sys_enter_accept
sys_enter_accept4
sys_enter_access
sys_enter_acct
...
sys_exit_write
sys_exit_writev

# 2. 특정 시스템 콜 추적 활성화
$ cd /sys/kernel/tracing
$ echo 0 > tracing_on
$ echo > trace
$ echo 1 > events/syscalls/sys_enter_openat/enable
$ echo 1 > events/syscalls/sys_exit_openat/enable

# 3. PID 필터 설정 (특정 프로세스만 추적)
$ echo 'common_pid == 1234' > \
    events/syscalls/sys_enter_openat/filter

# 4. 추적 시작 및 결과 확인
$ echo 1 > tracing_on
$ cat trace_pipe
  nginx-1234  [003] .... 12345.678901: sys_openat(
    dfd: 0xffffff9c, filename: 0x7f3a..., flags: 0x80000,
    mode: 0x0)
  nginx-1234  [003] .... 12345.678950: sys_openat -> 0x5

# 5. function_graph와 결합: 시스템 콜 내부 함수 호출 트리
$ echo function_graph > current_tracer
$ echo do_syscall_64 > set_graph_function
$ echo 1 > tracing_on
$ cat trace
 3)               |  do_syscall_64() {
 3)               |    do_syscall_x64() {
 3)               |      __x64_sys_openat() {
 3)               |        do_sys_openat2() {
 3)   0.890 us    |          getname_flags();
 3)               |          do_filp_open() {
 3)  12.345 us    |          }
 3)  14.567 us    |        }
 3)  15.123 us    |      }
 3)  15.890 us    |    }
 3)  16.234 us    |  }

bpftrace 고급 시스템 콜 분석

# ===== bpftrace 시스템 콜 분석 고급 예제 =====

# 1. 시스템 콜별 레이턴시 히스토그램 (프로세스 이름 포함)
$ sudo bpftrace -e '
    tracepoint:raw_syscalls:sys_enter {
        @start[tid] = nsecs;
        @nr[tid] = args->id;
    }
    tracepoint:raw_syscalls:sys_exit /@start[tid]/ {
        $lat = (nsecs - @start[tid]) / 1000;
        @us[@nr[tid]] = hist($lat);
        delete(@start[tid]);
        delete(@nr[tid]);
    }'

# 2. 프로세스별 시스템 콜 빈도 Top-N (5초 동안)
$ sudo bpftrace -e '
    tracepoint:raw_syscalls:sys_enter {
        @[comm, args->id] = count();
    }
    interval:s:5 {
        print(@, 20);
        clear(@);
    }'

# 3. 특정 파일에 대한 read/write 추적
$ sudo bpftrace -e '
    tracepoint:syscalls:sys_enter_openat
    /str(args->filename) == "/etc/passwd"/ {
        printf("PID %d (%s) opening /etc/passwd\n", pid, comm);
        @fd[tid] = 1;
    }
    tracepoint:syscalls:sys_enter_read /@fd[tid]/ {
        printf("  read(%d, buf, %d)\n", args->fd, args->count);
    }'

# 4. 시스템 콜 에러 분석 (음수 반환값)
$ sudo bpftrace -e '
    tracepoint:raw_syscalls:sys_exit
    /args->ret < 0/ {
        @errors[comm, args->id, - args->ret] = count();
    }
    END { print(@errors, 20); }'
추적 도구 선택 가이드: 개발/디버깅 환경에서는 strace -f가 가장 간편합니다. 프로덕션 모니터링에는 perf trace를 사용하세요. 복잡한 필터링과 집계가 필요하면 bpftrace가 최적입니다. 커널 내부 함수 호출 트리를 보려면 ftrace function_graph를 사용하세요.

시스템 콜 오버헤드와 성능 최적화

시스템 콜의 오버헤드는 하드웨어(모드 전환, 캐시/TLB 영향)와 소프트웨어(보안 완화, 추적 훅) 양쪽에서 발생합니다. 이 섹션에서는 Spectre/Meltdown 완화가 시스템 콜 성능에 미치는 영향을 정량적으로 분석하고, vDSO, io_uring, 배치 시스템 콜 등의 최적화 기법을 비교합니다.

시스템 콜 오버헤드 구성 요소

구성 요소 비용 (사이클) 비용 (ns, 3GHz 기준) 설명
SYSCALL/SYSRET 하드웨어 ~50 ~17 CPL 전환, MSR 읽기, RFLAGS 마스킹
swapgs + 스택 전환 ~30 ~10 GS base 교환, per-CPU 영역 접근
pt_regs 저장/복원 ~60 ~20 15개 레지스터 push/pop
KPTI (CR3 전환) ~100~300 ~33~100 페이지 테이블 전환, TLB 영향
PCID 지원 시 KPTI ~30~50 ~10~17 TLB flush 불필요
Spectre v1 (array_index_nospec) ~5 ~2 LFENCE 또는 조건부 마스킹
Spectre v2 (RETPOLINE) ~10~30 ~3~10 간접 점프를 ret로 변환
IBRS/eIBRS ~20~150 ~7~50 MSR 쓰기 (eIBRS는 진입 시 1번)
seccomp BPF (설치 시) ~50~200 ~17~67 BPF 프로그램 실행 (JIT 컴파일됨)
audit (활성 시) ~100~500 ~33~167 감사 레코드 생성, 링버퍼 쓰기
ptrace (활성 시) ~10000+ ~3333+ 2번의 컨텍스트 스위치

Spectre 완화 기법별 성능 영향

# ===== 보안 완화 기법별 성능 측정 =====

# 1. 현재 활성화된 완화 기법 확인
$ grep . /sys/devices/system/cpu/vulnerabilities/*
meltdown:Mitigation: PTI
spectre_v1:Mitigation: usercopy/swapgs barriers
spectre_v2:Mitigation: Enhanced IBRS; IBPB: conditional; RSB filling
spec_store_bypass:Mitigation: Speculative Store Bypass disabled
retbleed:Not affected

# 2. getpid() 마이크로벤치마크 (null syscall)
$ cat bench_syscall.c
#include <unistd.h>
#include <time.h>
#include <stdio.h>
#include <sys/syscall.h>

int main() {
    struct timespec t1, t2;
    int N = 10000000;
    clock_gettime(CLOCK_MONOTONIC, &t1);
    for (int i = 0; i < N; i++)
        syscall(__NR_getpid);
    clock_gettime(CLOCK_MONOTONIC, &t2);
    double ns = ((t2.tv_sec - t1.tv_sec) * 1e9 +
                 (t2.tv_nsec - t1.tv_nsec)) / N;
    printf("getpid(): %.1f ns/call\n", ns);
}

# 3. 완화 기법 비활성화 후 비교 (테스트 환경만!)
# GRUB_CMDLINE_LINUX="mitigations=off"  # 모든 완화 비활성화
# 주의: 프로덕션에서 절대 사용 금지!

# 4. 전형적인 결과 비교
# mitigations=off:  getpid() ~80ns
# mitigations=auto: getpid() ~180ns (KPTI + eIBRS)
# KPTI + IBRS:      getpid() ~250ns
# KPTI + Retpoline: getpid() ~200ns

배치 시스템 콜과 syscall 최소화

시스템 콜 오버헤드를 줄이는 핵심 전략은 호출 횟수를 줄이는 것입니다. Linux 커널은 여러 가지 배치 메커니즘을 제공합니다.

기법 설명 syscall 절감 적용 사례
io_uring 공유 링버퍼로 I/O 요청 배치 N개 I/O → 1 syscall 고성능 저장소, 네트워크 서버
vDSO 커널 진입 없이 사용자 공간 실행 syscall 0개 시간 조회, getcpu
sendmmsg/recvmmsg 여러 메시지를 한 번에 송수신 N개 메시지 → 1 syscall UDP 서버, DNS
preadv2/pwritev2 여러 버퍼의 scatter/gather I/O N개 read/write → 1 syscall 데이터베이스, 로그 서버
splice/tee 커널 내 zero-copy 데이터 이동 read+write → 1 syscall 프록시 서버, 파이프라인
epoll 이벤트 기반 다중 fd 모니터링 N개 poll → 1 syscall 이벤트 루프, 웹 서버
io_uring SQPOLL 커널 폴링 스레드가 SQ 처리 syscall 0개 (커널 폴링) 극한 저지연 I/O

io_uring SQPOLL: 시스템 콜 제거

/* io_uring SQPOLL 모드: 시스템 콜 없이 I/O 처리 */
#include <liburing.h>

struct io_uring ring;
struct io_uring_params params = {
    .flags = IORING_SETUP_SQPOLL,   /* 커널 폴링 스레드 활성화 */
    .sq_thread_idle = 2000,         /* 유휴 2초 후 스레드 정지 */
};

io_uring_queue_init_params(256, &ring, &params);

/* SQE(Submission Queue Entry) 제출: 시스템 콜 불필요! */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, len, offset);
io_uring_submit(&ring);
/* → 커널 폴링 스레드가 SQ를 감시하여 자동 처리 */
/* → 사용자 공간에서 CQ(Completion Queue)를 폴링하여 결과 확인 */

/* CQE(Completion Queue Entry) 수확 */
struct io_uring_cqe *cqe;
io_uring_peek_cqe(&ring, &cqe);
if (cqe) {
    int result = cqe->res;  /* I/O 결과 */
    io_uring_cqe_seen(&ring, cqe);
}

/* 전체 I/O 루프에서 시스템 콜 0개 달성 가능 */

시스템 콜 최적화 기법 비교

I/O당 시스템 콜 오버헤드 비교 (낮을수록 좋음) 1.0 0.8 0.6 0.4 0.2 I/O당 syscall 수 read()/ write() 1.0 sendmmsg (배치) ~0.1 io_uring (기본) ~0.05 io_uring SQPOLL 0 vDSO (시간 조회) 0 splice (zero-copy) 0.5
실전 최적화 체크리스트:
  • 시간 조회(gettimeofday, clock_gettime)는 vDSO가 자동으로 최적화합니다 (추가 작업 불필요).
  • 고빈도 I/O는 io_uring으로 배치 처리하세요. NVMe SSD에서 2배 이상의 IOPS 향상을 기대할 수 있습니다.
  • UDP 서버에서는 sendmmsg()/recvmmsg()를 사용하세요.
  • 프록시/파이프라인에서는 splice()로 사용자 공간 복사를 제거하세요.
  • mitigations=off는 절대 프로덕션에서 사용하지 마세요. PCID 지원 CPU를 사용하면 KPTI 오버헤드를 최소화할 수 있습니다.

시스템 콜과 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.