시스템 콜 (System Call)

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

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

핵심 요약

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

단계별 이해

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

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

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

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

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

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

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

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

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

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

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

    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 사용자 공간 메모리만 (가상 주소(Virtual Address) 하위 영역)
CPL (Current Privilege Level): CS 레지스터의 하위 2비트에 저장되는 현재 특권 수준입니다. SYSCALL 명령어 실행 시 하드웨어가 자동으로 CPL을 0으로 전환합니다.

int 0x80에서 SYSCALL까지의 변천

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

메커니즘 아키텍처 도입 시기 특징
int 0x80 i386 초기 소프트웨어 인터럽트(Interrupt), 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에 미리 저장된 진입점(Entry Point)으로 직접 점프하므로 훨씬 빠릅니다.

/* 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                       /* 빠른 시스템 콜 진입 */

glibc 시스템 콜 래퍼 분석

사용자 프로그램에서 read(), write() 같은 함수를 호출하면, 이 함수들은 glibc(GNU C Library)가 제공하는 래퍼(Wrapper) 함수입니다. glibc 래퍼는 시스템 콜 번호를 레지스터에 설정하고, syscall 명령어를 실행한 뒤, 커널이 반환한 값을 검사하여 에러 시 errno를 설정하는 역할을 합니다.

INTERNAL_SYSCALL / INLINE_SYSCALL 매크로

glibc는 인라인 어셈블리를 사용하여 시스템 콜을 실행합니다. 핵심 매크로는 sysdeps/unix/sysv/linux/x86_64/sysdep.h에 정의되어 있습니다.

/* glibc sysdeps/unix/sysv/linux/x86_64/sysdep.h (단순화) */

/* INTERNAL_SYSCALL: 원시 반환값(음수 에러 코드 포함)을 그대로 반환 */
#define INTERNAL_SYSCALL(name, nr, args...) \
  ({ unsigned long _resultvar; \
     LOAD_ARGS_##nr(args) \
     LOAD_REGS_##nr \
     asm volatile ( \
       "syscall\n\t" \
       : "=a" (_resultvar) \
       : "0" (__NR_##name) ASM_ARGS_##nr \
       : "memory", "cc", "r11", "cx" \
     ); \
     (long) _resultvar; })

/* INLINE_SYSCALL: 에러 시 errno 설정 + -1 반환 */
#define INLINE_SYSCALL(name, nr, args...) \
  ({ long _ret = INTERNAL_SYSCALL(name, nr, args); \
     if (INTERNAL_SYSCALL_ERROR_P(_ret)) { \
       __set_errno(INTERNAL_SYSCALL_ERRNO(_ret)); \
       _ret = -1; \
     } \
     _ret; })

/* 에러 판별: 반환값이 -4095 ~ -1 범위이면 에러 */
#define INTERNAL_SYSCALL_ERROR_P(val) \
  ((unsigned long) (val) >= (unsigned long) -4095)
clobber 리스트: syscall 명령어는 rcx(반환 주소)와 r11(RFLAGS 저장)을 덮어씁니다. 따라서 인라인 어셈블리에서 이 두 레지스터를 clobber로 선언합니다. 이것이 시스템 콜이 C 호출 규약의 rcx 대신 r10을 4번째 인자에 사용하는 이유입니다.

read() 래퍼의 전체 경로

사용자가 read(fd, buf, count)를 호출했을 때 glibc 내부에서 일어나는 과정입니다.

/* glibc io/read.c (단순화) */
ssize_t
__libc_read (int fd, void *buf, size_t nbytes)
{
  return SYSCALL_CANCEL (read, fd, buf, nbytes);
}
libc_hidden_def (__libc_read)
weak_alias (__libc_read, read)

/* SYSCALL_CANCEL 매크로 확장 (단순화):
 * 1. pthread 취소 지점(Cancellation Point) 검사
 * 2. INLINE_SYSCALL 실행
 * 3. 취소 요청 시 __pthread_unwind() 호출
 */
#define SYSCALL_CANCEL(name, args...) \
  ({ long _ret; \
     int _cancel_oldtype = LIBC_CANCEL_ASYNC(); \
     _ret = INLINE_SYSCALL(name, 3, args); \
     LIBC_CANCEL_RESET(_cancel_oldtype); \
     _ret; })

Cancellation Point와 시스템 콜

POSIX는 특정 함수(read, write, sleep 등)를 취소 지점(Cancellation Point)으로 지정합니다. pthread_cancel()로 스레드 취소 요청이 있을 때, 해당 스레드가 취소 지점에 도달하면 실제로 취소됩니다.

/* 취소 지점의 동작 과정 */
1. pthread_cancel(target_thread)  /* 취소 요청 설정 */
2. target_thread가 read() 호출   /* 취소 지점 진입 */
3. LIBC_CANCEL_ASYNC()           /* 비동기 취소 활성화 */
4. INLINE_SYSCALL(read, ...)     /* 실제 시스템 콜 실행 */
5. LIBC_CANCEL_RESET()           /* 취소 검사 및 unwind */

syscall() 범용 래퍼

syscall() 함수는 glibc에 전용 래퍼가 없는 시스템 콜이나 새로 추가된 시스템 콜을 직접 호출할 때 사용합니다.

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

/* glibc 전용 래퍼가 있는 경우 */
ssize_t n = read(fd, buf, count);         /* 권장 */

/* syscall() 범용 래퍼 사용 */
long n = syscall(SYS_read, fd, buf, count); /* 동일하지만 취소 지점 아님 */

/* glibc 래퍼가 없는 시스템 콜 (예: pidfd_open, Linux 5.3+) */
int pidfd = syscall(SYS_pidfd_open, pid, 0);

/* io_uring_setup (glibc 래퍼 없음, liburing 사용 권장) */
int ring_fd = syscall(__NR_io_uring_setup, entries, &params);
syscall() vs 전용 래퍼의 차이: syscall()은 POSIX 취소 지점이 아닙니다. 또한 전용 래퍼가 제공하는 추가 검증(예: open()에서의 variadic 인자 처리)이 없습니다. glibc 래퍼가 존재하는 시스템 콜에는 반드시 전용 래퍼를 사용하세요.
사용자 공간 (User Space) 커널 공간 (Kernel Space) read(fd, buf, count) 사용자 프로그램 __libc_read() glibc 래퍼 INLINE_SYSCALL(__NR_read) 인라인 어셈블리 SYSCALL 명령어 rax=0, rdi=fd, rsi=buf, rdx=count entry_SYSCALL_64 do_syscall_64() sys_call_table[0] __x64_sys_read() ksys_read() vfs_read() 특권 전환
그림: 사용자 함수 read()에서 커널 vfs_read()까지의 전체 콜 스택

시스템 콜 에러 처리와 errno 변환

커널 시스템 콜 핸들러는 성공 시 0 이상의 값을, 실패 시 음수 에러 코드(예: -EFAULT, -EINVAL)를 반환합니다. 이 음수 값은 rax 레지스터를 통해 사용자 공간으로 전달되며, glibc가 이를 errno로 변환합니다.

커널 내부 에러 코드 규약

커널에서 에러 코드는 -1부터 -MAX_ERRNO(-4095)까지의 음수 값입니다. 포인터 반환 함수에서는 ERR_PTR()/IS_ERR()/PTR_ERR() 매크로를 사용합니다.

/* include/linux/err.h */
#define MAX_ERRNO    4095

#define IS_ERR_VALUE(x) \
  unlikely((unsigned long)(x) >= (unsigned long)-MAX_ERRNO)

static inline void * ERR_PTR(long error)
{
    return (void *) error;
}

static inline long PTR_ERR(const void *ptr)
{
    return (long) ptr;
}

static inline bool IS_ERR(const void *ptr)
{
    return IS_ERR_VALUE((unsigned long) ptr);
}

/* 사용 예: 파일 열기 */
struct file *f = filp_open("/etc/passwd", O_RDONLY, 0);
if (IS_ERR(f)) {
    int err = PTR_ERR(f);  /* err = -EACCES 등 */
    return err;
}
왜 -4095인가: x86_64에서 유효한 사용자 주소 범위의 상한이 0x00007FFFFFFFFFFF이므로, 0xFFFFFFFFFFFFF001(-4095) ~ 0xFFFFFFFFFFFFFFFF(-1) 범위는 유효한 포인터가 될 수 없습니다. 이 범위를 에러 코드로 안전하게 사용할 수 있습니다.

glibc의 errno 변환 과정

커널이 SYSRET로 반환한 후, glibc는 rax 값을 검사하여 에러 여부를 판단합니다.

/* glibc의 에러 변환 과정 (의사 코드) */

/* 1. 커널이 rax에 반환값 저장 (예: -14 = -EFAULT) */
long result = /* syscall 명령어의 rax 값 */;

/* 2. INTERNAL_SYSCALL_ERROR_P: 에러 판별 */
if ((unsigned long) result >= (unsigned long) -4095) {
    /* 3. errno 설정: 양수로 변환 */
    errno = -result;   /* errno = 14 (EFAULT) */
    /* 4. 사용자에게 -1 반환 */
    return -1;
}
/* 5. 성공 시 결과를 그대로 반환 */
return result;

/* 따라서 사용자 코드에서: */
ssize_t n = read(fd, buf, count);
if (n == -1) {
    perror("read");  /* errno 기반 메시지 출력 */
    /* errno == EFAULT, EINVAL, EIO 등 */
}

주요 errno 코드 분류

errno의미발생 시나리오
EPERM1권한 없음비특권 프로세스가 특권 작업 시도 (예: kill(1, SIGKILL))
ENOENT2파일 없음open()에 존재하지 않는 경로 전달
ESRCH3프로세스 없음kill()에 존재하지 않는 PID 전달
EINTR4시그널에 의한 중단시스템 콜 실행 중 시그널 수신 (SA_RESTART 미설정)
EIO5I/O 오류디스크 읽기/쓰기 하드웨어 오류
EBADF9잘못된 파일 디스크립터닫힌 fd로 read()/write() 호출
EAGAIN11재시도 필요논블로킹 I/O에서 데이터 미준비
ENOMEM12메모리 부족mmap() 시 가용 메모리 소진
EACCES13접근 거부읽기 권한 없는 파일에 open(O_RDONLY)
EFAULT14잘못된 주소read(fd, NULL, n) — NULL 포인터 전달
EBUSY16장치 사용 중마운트된 파일 시스템에 umount()
EEXIST17이미 존재mkdir()에 기존 디렉터리 경로
EINVAL22잘못된 인자lseek(fd, 0, 99) — 잘못된 whence 값
ENOSPC28공간 부족디스크 가용 공간 소진 시 write()
EPIPE32파이프 끊김읽는 쪽이 닫힌 파이프에 write()
ENOSYS38미구현 시스템 콜존재하지 않는 시스템 콜 번호 호출
ENOTSUP95미지원 연산파일 시스템이 지원하지 않는 연산
ETIMEDOUT110시간 초과소켓 연결 시간 초과
EAGAIN vs EWOULDBLOCK: Linux에서 EAGAINEWOULDBLOCK은 같은 값(11)입니다. POSIX는 이 두 코드가 다를 수 있다고 명시하지만, Linux에서는 동일합니다. 이식성을 위해 if (errno == EAGAIN || errno == EWOULDBLOCK)으로 검사하는 것이 권장됩니다.
커널 공간 핸들러: return -EFAULT do_syscall_64() regs->ax = -14 SYSRET / IRET rax = -14 전달 CR3 전환 KPTI (선택) Ring 0 → Ring 3 특권 전환 사용자 공간 (glibc) rax 검사 (unsigned)-14 >= (unsigned)-4095? YES → 에러 errno = -(-14) = 14 return -1 사용자: errno=EFAULT, ret=-1 perror("read") → "Bad address"
그림: 커널 에러 코드(-EFAULT)가 사용자 공간 errno(14)로 변환되는 과정

시스템 콜 아키텍처

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을 사용합니다. 어셈블리(Assembly) 종합 페이지(Page)에서 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으로 선언되어 부팅 후에는 읽기 전용(Read-Only)이 됩니다. 이는 루트킷이 시스템 콜 테이블을 변조하는 것을 방지하는 보안 장치입니다.

시스템 콜 번호 할당

시스템 콜 번호는 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를 반환합니다.
sys_call_table[] — 시스템 콜 번호 → 핸들러 매핑 const sys_call_table[NR_syscalls] __ro_after_init [0] __x64_sys_read [1] __x64_sys_write [2] __x64_sys_open [134] sys_ni_syscall (-ENOSYS) 활성 핸들러 __x64_sys_* → __se_sys_* → __do_sys_* pt_regs 추출 → long 캐스팅 → 실제 구현 제거된/미구현 슬롯 sys_ni_syscall → return -ENOSYS __ro_after_init 보호 부팅 후 읽기 전용 — 런타임 테이블 수정 불가 rax=0 → sys_call_table[0] → __x64_sys_read() 호출 총 ~450개 슬롯 (Linux 6.x 기준), 빈 슬롯은 sys_ni_syscall
그림: sys_call_table 배열 구조 — 시스템 콜 번호가 인덱스, 함수 포인터가 값

SYSCALL_DEFINE 매크로

DEFINE0~6 패밀리

커널 시스템 콜 핸들러는 SYSCALL_DEFINE<em>N</em> 매크로로 정의됩니다. 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);
}
코드 설명
  • SYSCALL_DEFINE3(read, ...)인자가 3개인 read 시스템 콜을 정의합니다. 이 매크로는 내부적으로 __x64_sys_read, __se_sys_read, __do_sys_read 세 함수와 ftrace tracepoint 메타데이터를 모두 생성합니다.
  • char __user *, buf__user 어노테이션(annotation)은 포인터가 사용자 공간 주소임을 표시합니다. 이 표시가 없으면 Sparse 정적 분석 도구가 경고를 발생시킵니다. 이 포인터에 직접 접근하면 안 되며, copy_to_user()/copy_from_user()를 사용해야 합니다.
  • ksys_read(fd, buf, count)ksys_ 접두사 함수는 시스템 콜 래퍼 없이 커널 내부에서 직접 호출할 수 있는 "커널 전용" 인터페이스입니다. 커널 초기화 코드나 다른 서브시스템에서 시스템 콜 기능을 재사용할 때 활용합니다.
  • SYSCALL_DEFINE0(getpid)인자가 없는 시스템 콜입니다. task_tgid_vnr(current)는 현재 태스크(task)의 스레드 그룹 ID(Thread Group ID, PID)를 네임스페이스(Namespace) 상대적으로 반환합니다.
  • SYSCALL_DEFINE6(mmap, ...)인자 6개의 최대치 예시입니다. x86_64 ABI는 rdi·rsi·rdx·r10·r8·r9 레지스터로 인자를 전달하므로 6개가 상한입니다. 참고로 int 0x80(32비트 ABI)에서는 ebx·ecx·edx·esi·edi·ebp 6개를 사용합니다.
  • off >> PAGE_SHIFT파일 오프셋을 바이트 단위에서 페이지 단위로 변환합니다. mmap의 실제 구현인 ksys_mmap_pgoff()는 페이지 단위 오프셋을 받으므로, 시스템 콜 경계에서 변환을 수행합니다.
인자 6개 제한: x86_64 SYSCALL 규약에서 사용 가능한 인자 전달 레지스터가 rdi, rsi, rdx, r10, r8, r9의 6개이므로, 시스템 콜 인자는 최대 6개로 제한됩니다. 더 많은 데이터가 필요한 경우 구조체(Struct) 포인터를 전달합니다.
SYSCALL_DEFINE3(read, ...) 매크로 확장 과정 SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count) 매크로 확장 ① __x64_sys_read(const struct pt_regs *regs) 역할: pt_regs에서 레지스터 값 추출 → long 타입으로 전달 코드: return __se_sys_read(regs->di, regs->si, regs->dx) Entry Point ② __se_sys_read(long fd, long buf, long count) 역할: long → 올바른 타입으로 안전 캐스팅 (CVE-2009-0029 방지) 코드: return __do_sys_read((unsigned int)fd, (char __user *)buf, (size_t)count) Type Safety ③ __do_sys_read(unsigned int fd, char __user *buf, size_t count) 역할: 개발자가 작성한 실제 핸들러 본체 코드: { return ksys_read(fd, buf, count); } Implementation
그림: SYSCALL_DEFINE3(read, ...) 매크로가 생성하는 3계층 래퍼 — pt_regs 추출 → 타입 캐스팅 → 실제 구현

시스템 콜 진입 경로 상세

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)
코드 설명
  • 1행SYM_CODE_START(entry_SYSCALL_64) — 어셈블리 심볼 선언 매크로. MSR_LSTAR에 등록된 주소로, SYSCALL 명령어 실행 시 CPU가 직접 점프합니다.
  • 3행swapgs — IA32_KERNEL_GS_BASE MSR과 현재 GS base를 교환합니다. 커널 per-CPU 데이터(task_struct 포인터 등)에 접근하기 위해 필수입니다.
  • 6행사용자 RSP를 cpu_tss_rw.sp2 필드에 임시 보관합니다. 아직 커널 스택이 아니므로 메모리 쓰기는 불가하고 per-CPU 슬롯을 활용합니다.
  • 9행pcpu_hot.X86_top_of_stack에서 커널 스택 최상위 주소를 읽어 RSP를 교체합니다. 이후 모든 push 연산은 커널 스택에 기록됩니다.
  • 12~17행__USER_DS·__USER_CS·%r11·%rcx·%rax를 순서대로 push하여 커널 스택에 pt_regs 구조체를 형성합니다. %rax에 시스템 콜 번호가, %rcx에 복귀 RIP가, %r11에 사용자 RFLAGS가 들어 있습니다.
  • 20행PUSH_AND_CLEAR_REGS 매크로로 나머지 범용 레지스터를 저장하고, rax-ENOSYS를 설정해 시스템 콜 번호를 덮어씁니다(핸들러가 교체 전까지 유효한 초기값).
  • 23~24행rdirsp(pt_regs 포인터), rsi ← 시스템 콜 번호로 설정한 뒤 do_syscall_64()를 호출합니다. System V AMD64 ABI에 따라 첫 두 인자를 rdi·rsi로 전달합니다.
  • 28~29행반환 경로에서 다시 swapgs로 GS base를 사용자 값으로 복원한 뒤 sysretq로 RCX(사용자 RIP)·R11(RFLAGS)을 복원하고 CPL을 3으로 전환합니다.
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;
};
코드 설명
  • r15~r12, bp, bxcallee-saved 범용 레지스터입니다. 시스템 콜 핸들러가 이 레지스터를 수정하더라도 복귀 전에 복원되어야 합니다.
  • r11, r10, r9, r8caller-saved 레지스터입니다. r11SYSCALL 명령어가 사용자 RFLAGS를 보관하는 특별한 용도로 사용됩니다.
  • ax, cx, dx, si, di시스템 콜 인자 전달용 레지스터입니다. AMD64 ABI에서 인자 순서는 rdi→rsi→rdx→r10→r8→r9이며, cx는 반환 주소(RIP)를 보관합니다.
  • orig_ax원래 시스템 콜 번호를 보존합니다. ax는 핸들러 반환값으로 덮어씌워지므로 시스템 콜 번호가 필요한 재시작(restart) 처리나 ptrace/seccomp 검사를 위해 별도로 저장됩니다.
  • ipSYSCALL 명령어가 RCX ← 다음 명령어 RIP를 저장한 값입니다. SYSRET 시 이 값으로 사용자 공간으로 복귀합니다.
  • flagsSYSCALL 명령어가 R11 ← RFLAGS를 저장한 값입니다. SYSRET은 이 값을 RFLAGS로 복원합니다.
  • sp, 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;
}
코드 설명
  • 1행 — do_syscall_64 선언__visible은 LTO(Link-Time Optimization) 환경에서 심볼을 제거하지 않도록 지시하고, noinstr은 이 함수 내부에서 ftrace·kprobes 계측(instrumentation)을 비활성화합니다. 보안 검사 전에 추적 훅이 실행되지 않아야 하기 때문입니다.
  • 3행 — add_random_kstack_offset()커널 스택의 시작 오프셋을 0~255바이트 범위에서 무작위로 조정합니다. 호출마다 스택 레이아웃이 달라지므로 스택 주소 기반 공격(스택 스프레이, info-leak)을 어렵게 만드는 커널 자기 보호 기법입니다.
  • 4행 — syscall_enter_from_user_mode()사용자 모드에서 커널 모드로 진입할 때 필요한 상태 전환을 처리합니다. 내부에서 ptrace, seccomp BPF 필터, audit 검사를 순서대로 실행하며, 반환값은 실제 사용할 시스템 콜 번호(ptrace가 변경할 수 있음)입니다.
  • 6행 — instrumentation_begin()이 지점부터 kprobes, ftrace, KASAN 등의 동적 계측을 허용합니다. noinstr 구간을 벗어나는 경계를 명시하는 마커입니다.
  • 8~9행 — do_syscall_x64 / do_syscall_x32do_syscall_x64()false를 반환하면(유효 번호 범위 초과) x32 ABI 경로를 시도합니다. x32는 64비트 모드에서 32비트 포인터를 쓰는 ABI로, 번호에 __X32_SYSCALL_BIT(0x40000000)가 설정되어 있습니다.
  • do_syscall_x64 — array_index_nospec()Spectre v1 완화를 위한 인덱스 마스킹 함수입니다. 경계 검사 후에도 투기적 실행이 범위를 벗어날 수 있으므로, nrNR_syscalls를 넘지 못하도록 AND 마스크를 적용합니다.
  • do_syscall_x64 — sys_call_table[nr](regs)함수 포인터 테이블을 통한 핸들러 호출입니다. 반환값은 regs->ax에 저장되어 사용자 공간에 시스템 콜 반환값으로 전달됩니다.
  • 11행 — syscall_exit_to_user_mode()커널 → 사용자 복귀 전 체크리스트를 수행합니다. 시그널 전달, TIF_NOTIFY_RESUME, 스케줄링, seccomp exit 검사가 포함됩니다.
add_random_kstack_offset(): 시스템 콜마다 커널 스택 오프셋(Offset)을 랜덤화하여, 스택 기반 공격(스택 스프레이 등)을 어렵게 만드는 보안 강화 기법입니다. array_index_nospec()는 Spectre v1 방어를 위한 투기적 실행(Speculative Execution) 차단 함수입니다.

진입 경로 상세 다이어그램

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 (하드웨어)

콜 체인 소스 분석: entry_SYSCALL_64 → do_syscall_64() → sys_call_table[nr]

x86_64 시스템 콜의 세 핵심 함수가 어떻게 연결되는지 소스 수준에서 추적합니다. 하드웨어가 MSR_LSTAR를 통해 어셈블리 진입점으로 점프하고, C 핸들러가 시스템 콜 번호로 함수 포인터 테이블을 인덱싱하는 전체 경로를 보여줍니다.

User Space SYSCALL 명령어 실행 RAX=시스템 콜 번호, RCX←RIP, R11←RFLAGS MSR_LSTAR 점프 entry_SYSCALL_64 arch/x86/entry/entry_64.S ① swapgs (GS base → 커널 per-CPU) ② 커널 스택 전환 (pcpu_hot.X86_top_of_stack) ③ PUSH_AND_CLEAR_REGS → pt_regs 구성 call do_syscall_64(rsp, eax) do_syscall_64(regs, nr) arch/x86/entry/common.c ① add_random_kstack_offset() — 스택 오프셋 랜덤화 ② syscall_enter_from_user_mode() — ptrace/seccomp/audit ③ do_syscall_x64() — 번호 범위 검증 + array_index_nospec ④ syscall_exit_to_user_mode() — 시그널·스케줄 체크 sys_call_table[nr](regs) sys_call_table[nr] → __x64_sys_*(regs) arch/x86/entry/syscall_64.c | include/linux/syscalls.h ① __x64_sys_* — pt_regs에서 인자 추출 래퍼 ② __se_sys_* — long → 실제 타입 안전 캐스팅 ③ __do_sys_* — 개발자가 작성한 실제 핸들러 본체 regs→ax = 반환값 저장 swapgs + SYSRET → 사용자 공간 복귀 RCX→RIP, R11→RFLAGS, CPL=3
그림: entry_SYSCALL_64 → do_syscall_64() → sys_call_table[nr] 콜 체인 전체 흐름

사용자 메모리 접근 보안

커널 코드에서 시스템 콜 인자로 전달받은 사용자 포인터에 접근할 때는 반드시 전용 함수를 사용해야 합니다. 잘못된 포인터, 커널 공간 주소 위장, 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를 반환해야 합니다. 이를 무시하면 데이터 손상이나 정보 누출이 발생할 수 있습니다.

copy_from_user() 내부 콜 체인

앞서 본 copy_from_user()는 얇은 래퍼(Wrapper)에 불과합니다. 실제 실행 경로는 여러 보안 계층을 거칩니다. 아래는 x86_64 기준 전체 콜 체인입니다.

copy_from_user(to, from, n)
 ├─ access_ok(from, n)                  /* 사용자 주소 범위 검증 */
 ├─ __check_object_size(to, n, false)   /* HARDENED_USERCOPY: 커널 객체 경계 검증 */
 ├─ instrument_copy_from_user()         /* KASAN/KCSAN 메모리 계측 */
 └─ raw_copy_from_user(to, from, n)
      ├─ stac()                         /* SMAP 일시 해제 (Set AC flag) */
      ├─ barrier_nospec()               /* LFENCE: Spectre v1 방어 */
      ├─ rep movsb                      /* 실제 바이트 복사 (어셈블리) */
      │   └─ [페이지 폴트] → extable fixup → 미복사 바이트 수 반환
      └─ clac()                         /* SMAP 복원 (Clear AC flag) */
 ├─ (ret > 0) → memset(to + n - ret, 0, ret)  /* 잔여 바이트 제로화 */
 └─ return ret                          /* 0=성공, >0=미복사 바이트 수 */

실제 구현은 lib/usercopy.c_copy_from_user()에 있습니다. 인라인 copy_from_user()는 컴파일 타임 상수 크기일 때 최적화 경로를 선택하고, 그 외에는 이 함수를 호출합니다.

/* lib/usercopy.c — _copy_from_user() 실제 구현 */
unsigned long _copy_from_user(void *to,
        const void __user *from, unsigned long n)
{
    unsigned long res = n;

    might_fault();  /* 프로세스 컨텍스트 확인 (디버그) */

    if (!should_fail_usercopy() &&
        likely(access_ok(from, n))) {
        __check_object_size(to, n, false);
        res = raw_copy_from_user(to, from, n);
    }
    if (unlikely(res))
        memset(to + (n - res), 0, res);  /* 정보 누출 방지 */
    return res;
}
EXPORT_SYMBOL(_copy_from_user);
코드 설명
  • might_fault()디버그 빌드에서 현재 컨텍스트가 프로세스 컨텍스트인지 확인합니다. 인터럽트나 atomic 컨텍스트에서 copy_from_user()를 호출하면 경고를 발생시킵니다. 사용자 메모리 접근은 페이지 폴트를 유발할 수 있어 슬립(sleep)이 가능한 컨텍스트에서만 안전합니다.
  • should_fail_usercopy()fault injection 프레임워크와 연동하여, 테스트 목적으로 의도적으로 복사를 실패시킬 수 있습니다. CONFIG_FAULT_INJECTION_USERCOPY가 활성화된 경우에만 동작합니다.
  • __check_object_size(to, n, false)HARDENED_USERCOPY 검증입니다. 커널 포인터 to가 가리키는 객체의 SLAB 경계, 스택 범위, 텍스트/rodata 영역을 검사합니다. 상세 내용은 커널 하드닝 — HARDENED_USERCOPY 참조.
  • memset(to + (n - res), 0, res)복사가 부분 실패하면, 복사되지 않은 나머지 영역을 0으로 채웁니다. 이는 커널 스택/힙에 남아있던 이전 데이터가 사용자 공간으로 누출되는 것을 방지하는 핵심 보안 조치입니다.

예외 테이블(Exception Table) 메커니즘

copy_from_user()가 단순한 memcpy()와 근본적으로 다른 이유는 예외 테이블(Exception Table) 메커니즘입니다. access_ok()를 통과한 사용자 주소라도 실제 접근 시 페이지 폴트(Page Fault)가 발생할 수 있습니다 — 페이지가 스왑 아웃되었거나, 매핑이 해제되었거나, 권한이 변경된 경우입니다. 이때 커널은 패닉(panic) 대신 우아하게 복구합니다.

컴파일 타임: 사용자 메모리에 접근하는 어셈블리 명령어마다 _ASM_EXTABLE 매크로가 __ex_table 섹션에 fixup 엔트리를 생성합니다.

/* arch/x86/include/asm/asm.h — 예외 테이블 엔트리 생성 매크로 */
#define _ASM_EXTABLE_HANDLE(from, to, handler)   \
    .pushsection "__ex_table", "a" ;              \
    .balign 4 ;                                    \
    .long (from) - . ;  /* 폴트 발생 가능 명령어 주소 (상대) */ \
    .long (to) - . ;    /* fixup 코드 주소 (상대) */          \
    .long (handler) - . ; /* 핸들러 함수 (상대) */            \
    .popsection

/* 사용 예시: raw_copy_from_user 내부 어셈블리 */
1:  rep movsb             /* 사용자 메모리에서 커널로 복사 */
2:  ...                   /* 정상 종료 */
.section .fixup, "ax"
3:  mov %ecx, %eax        /* 미복사 바이트 수 = ECX(남은 카운트) */
    jmp 2b
.previous
_ASM_EXTABLE(1b, 3b)     /* 1번 명령에서 폴트 → 3번(fixup)으로 점프 */

런타임: 페이지 폴트가 커널 모드에서 발생하면, do_page_fault()는 예외 테이블을 검색하여 fixup 코드로 분기합니다.

/* arch/x86/mm/fault.c — 커널 모드 페이지 폴트 처리 (간략화) */
static noinline void
do_kern_addr_fault(struct pt_regs *regs,
                   unsigned long error_code,
                   unsigned long address)
{
    /* 커널 모드에서 폴트 → 예외 테이블 검색 */
    if (fixup_exception(regs, X86_TRAP_PF,
                        error_code, address))
        return;  /* fixup 성공: IP를 fixup 코드로 변경 */

    /* 예외 테이블에 없으면 → 커널 Oops/패닉 */
    die("Oops", regs, error_code);
}

/* kernel/extable.c — 예외 테이블 검색 및 fixup */
bool fixup_exception(struct pt_regs *regs, int trapnr,
                     unsigned long error_code,
                     unsigned long fault_addr)
{
    const struct exception_table_entry *e;
    /* 폴트 발생 주소(IP)로 __ex_table 이진 검색 */
    e = search_exception_tables(regs->ip);
    if (!e)
        return false;  /* fixup 없음 → Oops */
    /* IP를 fixup 주소로 변경, AX에 미복사 바이트 수 설정 */
    return ex_handler_uaccess(e, regs, trapnr,
                              error_code, fault_addr);
}
코드 설명
  • .pushsection "__ex_table", "a"컴파일러에게 이후 데이터를 __ex_table 섹션에 배치하도록 지시합니다. "a"는 allocatable 속성으로, 이 섹션이 메모리에 로드됩니다. 링커가 모든 오브젝트 파일의 __ex_table 엔트리를 하나로 합칩니다.
  • .long (from) - .폴트가 발생할 수 있는 명령어의 상대 주소입니다. 절대 주소 대신 상대 주소를 사용하여 KASLR(Kernel Address Space Layout Randomization) 환경에서도 테이블이 정상 동작합니다.
  • search_exception_tables(regs->ip)폴트가 발생한 명령어 포인터(IP)를 키로 __ex_table을 이진 검색합니다. 테이블은 링크 타임에 주소 순으로 정렬됩니다.
  • ex_handler_uaccess()uaccess 전용 fixup 핸들러입니다. regs->ip를 fixup 코드 주소로, regs->ax를 미복사 바이트 수로 설정합니다. 이후 do_page_fault()가 반환되면 fixup 코드부터 실행이 재개됩니다.
memcpy vs copy_from_user: 커널 내부에서 memcpy()를 사용하여 사용자 주소에 접근하면, 페이지 폴트 시 예외 테이블 엔트리가 없으므로 커널 Oops가 발생합니다. copy_from_user()는 모든 사용자 메모리 접근 명령어에 fixup 엔트리를 미리 등록하므로, 어떤 바이트 위치에서 폴트가 발생하더라도 안전하게 복구할 수 있습니다.

잔여 바이트 제로화(Zero-Remaining)

copy_from_user()가 부분적으로 실패하면, 커널 대상 버퍼에는 일부만 복사되고 나머지는 이전 스택/힙 데이터가 남아있습니다. 이 상태에서 해당 버퍼를 copy_to_user()로 사용자 공간에 전달하면, 커널 메모리가 누출됩니다. 이를 방지하기 위해 copy_from_user()는 미복사 영역을 항상 0으로 채웁니다.

/* lib/usercopy.c — _copy_from_user() 후반부 */
if (unlikely(res))
    memset(to + (n - res), 0, res);
/*
 * 예시: n=100, res=30 (70바이트만 성공, 30바이트 실패)
 * to[0..69]  = 사용자 데이터 (정상 복사)
 * to[70..99] = 0x00 (제로화 — 이전 커널 데이터 덮어쓰기)
 */
함수 실패 시 잔여 바이트 처리 이유
copy_from_user() memset(0) 제로화 커널 버퍼에 이전 데이터 누출 방지
copy_to_user() 제로화 없음 사용자 버퍼는 이미 사용자 소유 — 커널 정보 누출 위험 없음
get_user() 변수를 0으로 설정 단일 값에도 동일 원칙 적용
strncpy_from_user() 복사된 부분까지만 유효 반환값이 길이이므로 호출자가 범위를 알 수 있음
copy_to_user()는 제로화하지 않습니다: 커널 → 사용자 방향은 사용자 버퍼에 쓰는 것이므로, 부분 실패 시에도 커널 메모리 누출 위험이 없습니다. 사용자 버퍼의 미기록 영역은 이미 사용자 프로세스가 소유한 데이터입니다. copy_from_user()만 이 보호를 수행합니다.

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 ...

unsafe_get_user / unsafe_put_user 패턴

일반적인 get_user()/put_user()는 매 호출마다 STAC/CLAC(또는 PAN enable/disable)를 실행합니다. 이들은 직렬화 명령어(serializing instruction)이므로 파이프라인을 비우고 성능에 영향을 줍니다. 여러 사용자 값을 연속으로 읽고 쓸 때는 user_access_begin()/user_access_end() 블록 안에서 unsafe_* 변형을 사용하여 STAC/CLAC 한 쌍만 실행합니다.

/* arch/x86/kernel/signal.c — 시그널 프레임 설정 예시 */
static int setup_rt_frame(struct ksignal *ksig,
        struct pt_regs *regs)
{
    struct rt_sigframe __user *frame;
    /* ... frame 주소 계산 ... */

    /* STAC 한 번만 실행 + access_ok 검증 */
    if (!user_access_begin(frame, sizeof(*frame)))
        return -EFAULT;

    /* 블록 안에서 반복 접근 — 개별 STAC/CLAC 없음 */
    unsafe_put_user(restorer, &frame->pretcode, Efault);
    unsafe_put_user(sig,      &frame->sig,      Efault);
    unsafe_put_user(pinfo,    &frame->pinfo,    Efault);
    unsafe_put_user(puc,      &frame->puc,      Efault);

    user_access_end();  /* CLAC 한 번만 실행 */
    return 0;

Efault:
    user_access_end();
    return -EFAULT;
}
코드 설명
  • user_access_begin(frame, sizeof(*frame))내부적으로 access_ok()로 주소 범위를 검증한 후 __uaccess_begin_nospec()(STAC + LFENCE)를 실행합니다. 실패 시 false를 반환하여 접근을 차단합니다.
  • unsafe_put_user(val, ptr, label)access_ok()와 STAC/CLAC 없이 직접 사용자 메모리에 씁니다. 폴트 발생 시 extable fixup이 label(여기서는 Efault)로 점프합니다. 반드시 user_access_begin/end 블록 안에서만 사용해야 합니다.
  • Efault: user_access_end();에러 경로에서도 반드시 user_access_end()(CLAC)를 호출하여 SMAP 보호를 복원합니다. goto 레이블을 사용하는 이유는 매번 if-check를 피하면서도 예외 처리를 보장하기 위함입니다.
unsafe_* 함수는 반드시 user_access_begin/end 블록 안에서만 사용: 블록 밖에서 사용하면 access_ok() 검증과 SMAP/PAN 보호가 모두 우회됩니다. 일반적인 단일 접근에는 get_user()/put_user()를 사용하고, 3개 이상 연속 접근 시에만 unsafe_* 패턴을 고려하세요.

strncpy_from_user / strnlen_user

strncpy_from_user()strnlen_user()는 NUL 종단 문자열 전용 사용자 메모리 접근 함수입니다. 파일명 복사(getname_flags()), 모듈명 검증 등에서 사용됩니다. copy_from_user()와 달리 NUL 문자를 만나면 복사를 중단합니다.

/* lib/strncpy_from_user.c — 사용자 문자열을 커널로 복사 */
long strncpy_from_user(char *dst,
        const char __user *src, long count)
/*
 * 반환값:
 *   >= 0 : 복사된 문자열 길이 (NUL 제외)
 *   -EFAULT : 접근 실패
 * NUL 종단 문자까지 복사하되 count 바이트를 초과하지 않음
 * count에 도달해도 NUL을 만나지 못하면 count 반환 (NUL 종단 없음!)
 */

/* 실제 사용 예시: fs/namei.c — 파일명 복사 */
struct filename * getname_flags(
        const char __user *filename, int flags)
{
    struct filename *result;
    char *kname;
    int len;

    result = audit_reusename(filename);
    if (result)
        return result;

    result = __getname();  /* SLAB에서 filename 구조체 할당 */
    kname = (char *)result->iname;

    len = strncpy_from_user(kname, filename, EMBEDDED_NAME_MAX);
    if (unlikely(len < 0)) {
        __putname(result);
        return ERR_PTR(len);  /* -EFAULT */
    }
    /* len == EMBEDDED_NAME_MAX이면 별도 페이지 할당하여 긴 경로 처리 */
}
함수 용도 반환값 NUL 종단
copy_from_user() 고정 크기 바이너리 데이터 미복사 바이트 수 (0=성공) 해당 없음
strncpy_from_user() NUL 종단 문자열 복사 문자열 길이 또는 -EFAULT 자동 추가 (count 미달 시)
strnlen_user() 문자열 길이 측정 (복사 없음) 길이+1 (NUL 포함) 또는 0(실패) 측정만
get_user() 단일 스칼라 값 (1/2/4/8바이트) 0=성공, -EFAULT=실패 해당 없음

copy_struct_from_user() — 확장 가능한 구조체 복사

시스템 콜이 사용자 구조체를 받을 때, 커널과 사용자 프로그램의 구조체 크기가 다를 수 있습니다. 구조체에 새 필드가 추가된 새 커널에서 이전 사용자 프로그램이 작은 구조체를 전달하거나, 반대로 새 사용자 프로그램이 이전 커널에 큰 구조체를 전달할 수 있습니다. copy_struct_from_user()는 양방향 호환성을 보장합니다.

/* lib/strnlen_user.c / include/linux/uaccess.h */
int copy_struct_from_user(void *dst, size_t ksize,
                          const void __user *src,
                          size_t usize)
{
    size_t size = min(ksize, usize);

    /* 1단계: 커널 버퍼 전체를 0으로 초기화
     * → 사용자가 안 보낸 필드는 자동으로 0 (기본값) */
    memset(dst, 0, ksize);

    if (usize > ksize) {
        /* 2단계: 사용자 구조체가 더 큼 → 초과 부분이 0인지 확인
         * 미지의 필드에 값이 있으면 커널이 이해 못하므로 거부 */
        if (check_zeroed_user(src + ksize, usize - ksize))
            return -E2BIG;
    }

    /* 3단계: 공통 부분만 복사 */
    if (copy_from_user(dst, src, size))
        return -EFAULT;

    return 0;
}
/* kernel/fork.c — clone3 시스템 콜 실사용 예시 */
SYSCALL_DEFINE2(clone3,
    struct clone_args __user *, uargs,
    size_t, size)
{
    struct kernel_clone_args kargs;
    struct clone_args args;

    /* size = 사용자가 전달한 구조체 크기
     * sizeof(args) = 커널이 기대하는 구조체 크기 */
    err = copy_struct_from_user(&args, sizeof(args),
                                 uargs, size);
    /*
     * 이전 userspace (size < sizeof) → 부족분 0으로 채움
     * 이후 userspace (size > sizeof) → 초과분 0 확인 후 복사
     * 동일 크기 → 그대로 복사
     */
}
copy_struct_from_user()를 사용하는 시스템 콜: clone3, openat2, sched_setattr, mount_setattr, futex_waitv 등. 새로운 시스템 콜에서 확장 가능한 구조체를 받을 때는 이 패턴을 사용하는 것이 권장됩니다. 핵심 규칙은 "새 필드는 항상 0이 기본값"이어야 한다는 것입니다.

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

copy_from_user() 전체 실행 흐름 다이어그램

copy_from_user(kbuf, ubuf, n) 1. access_ok(ubuf, n) 사용자 주소 범위 검증 fail return n (전체 실패) pass 2. __check_object_size(kbuf, n) HARDENED_USERCOPY 커널 객체 검증 3. STAC + LFENCE SMAP 해제 + Spectre 방어 4. raw_copy_from_user() rep movsb 어셈블리 복사 성공 (ret=0) page fault 5a. CLAC (SMAP 복원) return 0 (성공) 5b. extable fixup do_page_fault → search_exception_tables → IP를 fixup 코드로 변경 5c. CLAC + 미복사 바이트 수 반환 6. memset(kbuf+copied, 0, ret) 잔여 바이트 제로화 (정보 누출 방지) return 미복사 바이트 수
그림: copy_from_user() 전체 실행 흐름 — 정상 경로(왼쪽)와 페이지 폴트 경로(오른쪽)

Spectre v1 완화와 uaccess

Spectre v1(Bounds Check Bypass)은 투기적 실행(Speculative Execution)을 이용하여 access_ok() 검증을 우회하고 커널 메모리를 읽을 수 있는 취약점입니다. 공격자가 조건 분기를 학습시켜, CPU가 검증 실패 경로를 투기적으로 실행하도록 유도합니다. 이때 사용자 메모리 접근 코드가 임의의 커널 주소를 읽고, 캐시 사이드 채널(side channel)을 통해 데이터를 유출할 수 있습니다.

/* arch/x86/include/asm/uaccess.h — Spectre v1 방어 포함 */
static inline void __uaccess_begin_nospec(void)
{
    stac();             /* SMAP 해제 (Set AC flag) */
    barrier_nospec();   /* LFENCE: 투기적 실행 차단 */
}

/* arch/x86/include/asm/barrier.h */
#define barrier_nospec() asm volatile("lfence" ::: "memory")
/*
 * LFENCE는 이전의 모든 로드가 완료될 때까지
 * 이후 명령어의 투기적 실행을 차단합니다.
 * STAC 직후 삽입하여, access_ok() 검증이 완료되기 전에
 * 사용자 메모리 접근이 투기적으로 실행되는 것을 방지합니다.
 */

시스템 콜 테이블 접근에도 동일한 원칙이 적용됩니다. 시스템 콜 번호가 범위를 초과하는 경우, 투기적 실행이 테이블 경계 밖의 함수 포인터를 읽는 것을 array_index_nospec()으로 방지합니다.

/* include/linux/nospec.h — 투기적 OOB 접근 방지 */
#define array_index_nospec(index, size)                \
({                                                      \
    typeof(index) _i = (index);                         \
    typeof(size) _s = (size);                           \
    unsigned long _mask =                                \
        array_index_mask_nospec(_i, _s);                \
    (typeof(_i)) (_i & _mask);                          \
})
/* _mask = (index < size) ? ~0UL : 0
 * index >= size이면 _mask=0 → 결과=0 (투기적 OOB 방지) */

/* arch/x86/entry/common.c — 시스템 콜 디스패치 */
static noinline bool __do_fast_syscall_32(
        struct pt_regs *regs)
{
    unsigned int nr = regs->orig_ax;

    /* 투기적 실행에서도 nr이 범위를 벗어나면 0으로 클램프 */
    nr = array_index_nospec(nr, IA32_NR_syscalls);
    regs->ax = ia32_sys_call_table[nr](regs);
}
성능 비용: LFENCE 명령어는 파이프라인을 직렬화하므로 copy_from_user() 호출당 수 나노초의 오버헤드가 추가됩니다. 그러나 Spectre v1 공격을 근본적으로 차단하므로, 보안 이점이 성능 비용을 크게 상회합니다. user_access_begin()/user_access_end() 패턴을 사용하면 LFENCE 횟수를 줄여 오버헤드를 최소화할 수 있습니다.

시스템 콜 재시작 메커니즘

사용자 프로세스가 시스템 콜(예: read(), nanosleep()) 실행 중 시그널을 수신하면, 시스템 콜이 중단됩니다. 이때 커널은 시스템 콜을 자동으로 재시작할지, 아니면 -EINTR로 반환할지 결정해야 합니다. 이 결정은 커널 반환 에러 코드와 시그널 핸들러의 SA_RESTART 플래그에 의해 이루어집니다.

4가지 재시작 에러 코드

커널 시스템 콜 핸들러가 시그널에 의해 중단될 때 반환하는 내부 에러 코드는 4가지입니다. 이 코드들은 사용자 공간에 직접 노출되지 않으며, do_signal()에서 재시작 판단에 사용됩니다.

에러 코드SA_RESTART 설정 시SA_RESTART 미설정 시사용 예
-ERESTARTSYS 512 자동 재시작 -EINTR 반환 read(), write(), wait4()
-ERESTARTNOHAND 514 자동 재시작 자동 재시작 pause(), sigsuspend()
-ERESTARTNOINTR 513 항상 재시작 항상 재시작 시그널 자체 처리 코드
-ERESTART_RESTARTBLOCK 516 restart_block 사용 -EINTR 반환 nanosleep(), futex()
사용자에게 보이지 않는 코드: -ERESTARTSYS(512) 등의 값은 일반적인 errno 범위(1~4095)를 넘지 않지만, MAX_ERRNO(4095) 이내이므로 사용자 공간에 절대 노출되어서는 안 됩니다. 커널은 do_signal()에서 이 코드를 가로채어 재시작 또는 -EINTR(-4)로 변환합니다.

do_signal()의 재시작 판단 로직

시그널 처리 후 시스템 콜 반환값을 검사하여 재시작 여부를 결정하는 핵심 코드입니다.

/* arch/x86/kernel/signal.c (Linux 6.x, 단순화) */
static void handle_signal(struct ksignal *ksig,
                           struct pt_regs *regs)
{
    int ret = regs->ax;

    switch (ret) {
    case -ERESTARTSYS:
        if (!(ksig->ka.sa.sa_flags & SA_RESTART)) {
            regs->ax = -EINTR;  /* SA_RESTART 없으면 EINTR */
            break;
        }
        /* fallthrough: SA_RESTART 있으면 재시작 */
    case -ERESTARTNOINTR:
        regs->ax = regs->orig_ax;  /* 원래 시스템 콜 번호 복원 */
        regs->ip -= 2;             /* syscall 명령어 크기(2바이트) 되감기 */
        break;

    case -ERESTARTNOHAND:
        regs->ax = -EINTR;       /* 핸들러가 있으면 EINTR */
        break;

    case -ERESTART_RESTARTBLOCK:
        regs->ax = -EINTR;       /* 핸들러 처리 후 EINTR */
        break;
    }
}
IP 되감기(rewind): 시스템 콜을 재시작할 때 regs->ip -= 2는 x86_64 syscall 명령어의 크기(2바이트)만큼 명령어 포인터를 되감습니다. 이렇게 하면 커널에서 사용자 공간으로 복귀할 때 syscall 명령어가 다시 실행되어 시스템 콜이 재시작됩니다. ARM64에서는 SVC #0이 4바이트이므로 regs->pc -= 4입니다.

restart_block과 나머지 시간 계산

-ERESTART_RESTARTBLOCK은 단순한 재시작이 불가능한 경우에 사용됩니다. 예를 들어 nanosleep(2초) 호출 중 1초 후에 시그널이 도착하면, 남은 1초만 잠들어야 합니다. 이때 restart_block 구조체에 재시작 함수와 남은 시간을 저장합니다.

/* include/linux/restart_block.h */
struct restart_block {
    unsigned long arch_data;
    long (*fn)(struct restart_block *);
    union {
        struct {        /* nanosleep용 */
            struct timespec64 expires;
            clockid_t clockid;
            u32 type;
        } nanosleep;
        struct {        /* futex용 */
            u32 __user *uaddr;
            u32 val;
            u32 flags;
            u64 time;
        } futex;
        struct {        /* poll용 */
            struct pollfd __user *ufds;
            int nfds;
            int has_timeout;
            unsigned long tv_sec;
            unsigned long tv_nsec;
        } poll;
    };
};

/* kernel/time/hrtimer.c — nanosleep 재시작 예제 */
static int __sched do_nanosleep(struct hrtimer_sleeper *t,
                                enum hrtimer_mode mode)
{
    struct restart_block *restart;

    /* ... hrtimer 대기 ... */

    if (signal_pending(current)) {
        /* 시그널 도착: 남은 시간으로 restart_block 설정 */
        restart = &current->restart_block;
        restart->fn = hrtimer_nanosleep_restart;
        restart->nanosleep.clockid = t->timer.base->clockid;
        restart->nanosleep.expires = hrtimer_get_softexpires_tv64(&t->timer);

        return -ERESTART_RESTARTBLOCK;
    }
    return 0;
}

SA_RESTART 플래그와 sigaction()

사용자 공간에서 시그널 핸들러를 등록할 때 SA_RESTART 플래그를 설정하면, -ERESTARTSYS로 중단된 시스템 콜이 자동으로 재시작됩니다.

/* SA_RESTART 사용 예제 */
struct sigaction sa;
sa.sa_handler = my_handler;
sa.sa_flags = SA_RESTART;  /* 이 플래그가 핵심! */
sigemptyset(&sa.sa_mask);
sigaction(SIGUSR1, &sa, NULL);

/* SA_RESTART 있으면: read() 중 SIGUSR1 → 핸들러 실행 → read() 자동 재시작 */
/* SA_RESTART 없으면: read() 중 SIGUSR1 → 핸들러 실행 → read()=-1, errno=EINTR */

/* 주의: signal() 함수는 SA_RESTART를 기본 설정합니다 (glibc) */
signal(SIGUSR1, my_handler);  /* glibc: SA_RESTART 포함 */

/* BSD 호환이 아닌 경우 (예: musl libc) signal()은 SA_RESTART 미설정 */
방어적 코딩: SA_RESTART에 의존하지 말고, 시스템 콜이 -EINTR을 반환할 수 있다고 항상 가정하세요. 특히 select(), poll(), epoll_wait()SA_RESTART가 설정되어도 재시작되지 않습니다. 안전한 패턴: while ((ret = read(fd, buf, n)) == -1 && errno == EINTR) continue;
시스템 콜 실행 중 (예: read()) 시그널 수신 → 핸들러 실행 반환값 검사 regs->ax = ? -ERESTARTSYS SA_RESTART? 재시작 IP -= 2 YES -EINTR NO -ERESTARTNOINTR 항상 재시작 -ERESTART_RESTARTBLOCK restart_block.fn() 남은 시간으로 재시작 -ERESTARTNOHAND 핸들러 없으면 재시작 핸들러 있으면 EINTR
그림: 시그널 수신 시 시스템 콜 재시작 판단 흐름 -- 에러 코드와 SA_RESTART에 따른 분기

새로운 시스템 콜 추가

단계별 가이드

리눅스 커널에 새로운 시스템 콜을 추가하는 과정을 단계별로 설명합니다. 아래는 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).
개념 연결: 시스템 콜 ABI는 ABI 전체의 일부입니다. 호출 규약, 시스템 콜 번호, 공개 구조체, libc 경계, compat ABI를 하나의 계약으로 묶어 보고 싶다면 ABI 문서를 함께 읽으세요.

ABI 안정성 정책

리눅스 커널의 가장 중요한 원칙 중 하나는 "사용자 공간을 절대 깨뜨리지 않는다"(Linus Torvalds: "We do NOT break userspace")입니다. 한 번 할당된 시스템 콜 번호는 영원히 유지되며, 사용되지 않게 된 시스템 콜도 번호를 반환하지 않고 sys_ni_syscall 스텁(Stub)으로 교체합니다.

/* kernel/sys_ni.c — 미구현 시스템 콜 스텁 */
COND_SYSCALL(io_setup);
COND_SYSCALL(io_destroy);

/*
 * sys_ni_syscall — "not implemented" 스텁
 * 제거된 시스템 콜 번호에 매핑되어 -ENOSYS 반환
 * 예: sys_set_thread_area (번호 205) — x86_64에서 미구현
 */
SYSCALL_DEFINE0(ni_syscall)
{
    return -ENOSYS;
}

시스템 콜 진화 사례

기존 시스템 콜의 한계를 극복하기 위해 새로운 시스템 콜이 추가되는 패턴은 리눅스 커널 역사에서 반복됩니다. 기존 시스템 콜은 제거되지 않고 영구히 유지됩니다.

원래 시스템 콜개선된 시스템 콜변경 이유커널 버전
cloneclone364비트 flags 확장, 구조체 기반 인자로 미래 호환성 확보5.3
openopenat상대 경로의 TOCTOU 취약점 방지 (dirfd 기반)2.6.16
openatopenat2RESOLVE_* 플래그로 심볼릭 링크 추적 정책 제어5.6
epoll_createepoll_create1EPOLL_CLOEXEC 지원 (close-on-exec 원자적 설정)2.6.27
stat/lstat/fstatstatx생성 시각, 마운트 ID 등 확장 정보 + 선택적 필드 쿼리4.11
selectpselect6나노초 타임아웃 + 시그널 마스크 원자적 설정2.6.16
pipepipe2O_CLOEXEC, O_NONBLOCK 원자적 설정2.6.27
dup2dup3O_CLOEXEC 지원2.6.27
signalfdsignalfd4SFD_CLOEXEC, SFD_NONBLOCK 플래그2.6.27
renameatrenameat2RENAME_NOREPLACE, RENAME_EXCHANGE 원자적 교환3.15
설계 교훈: 리눅스 2.6.27에서 대거 추가된 *2/*4 시스템 콜은 O_CLOEXEC 플래그를 원자적으로 설정하기 위함입니다. fork()+exec() 사이 경쟁 조건에서 파일 디스크립터가 누출되는 보안 문제를 해결합니다. 이 교훈 이후 새로운 시스템 콜에는 항상 플래그 인자를 포함하도록 권장됩니다.

새 시스템 콜 추가의 커뮤니티 프로세스

리눅스 커널에 새 시스템 콜을 추가하는 것은 커널 변경 중 가장 신중한 검토가 필요한 작업입니다. 한 번 추가되면 영원히 유지해야 하기 때문입니다.

  1. 사전 논의 — LKML(Linux Kernel Mailing List)에 RFC 패치와 함께 제안서를 게시합니다. 기존 시스템 콜로 해결할 수 없는 이유, ioctl/netlink/sysfs 대안이 부적합한 이유를 명확히 설명해야 합니다.
  2. 인터페이스 설계 검토 — 인자 개수(최대 6개), 구조체 사용 시 확장 가능한 flags/size 필드 포함, 32비트/64비트 호환성, compat 래퍼 필요 여부를 검토합니다.
  3. 패치 세트 작성 — 다음을 포함합니다:
    • 시스템 콜 테이블 등록 (모든 아키텍처: x86, ARM64, RISC-V 등)
    • SYSCALL_DEFINE 핸들러 구현
    • kselftest 테스트 (tools/testing/selftests/)
    • man-pages 업데이트 패치 (별도 저장소)
    • UAPI 헤더 (include/uapi/linux/)
  4. 리뷰 및 머지 — 서브시스템 메인테이너 리뷰 → linux-next 트리 통합 → 다음 릴리스 머지 윈도우에서 메인라인 통합.
  5. glibc 래퍼 추가 — 커널 머지 후 glibc 프로젝트에 래퍼 함수를 추가하는 별도 패치가 필요합니다. glibc 래퍼가 추가되기 전에는 syscall(__NR_xxx, ...)으로만 호출 가능합니다.

커널 내부 시스템 콜 사용

커널 스레드(Kernel Thread)나 초기 부팅 코드에서도 시스템 콜의 기능이 필요한 경우가 있습니다. 그러나 커널 내부에서 시스템 콜을 직접 호출하는 것은 권장되지 않으며, 대신 ksys_* 접두사의 내부 함수를 사용합니다.

ksys_* 함수 패턴

ksys_* 함수는 시스템 콜 핸들러의 핵심 로직을 추출한 것으로, pt_regs 구조체 없이 직접 인자를 받습니다.

/* fs/read_write.c */

/* 시스템 콜 핸들러: pt_regs에서 인자를 추출 */
SYSCALL_DEFINE3(read, unsigned int, fd,
                char __user *, buf, size_t, count)
{
    return ksys_read(fd, buf, count);
}

/* ksys_read: 커널 내부에서도 호출 가능 */
ssize_t ksys_read(unsigned int fd,
                  char __user *buf, size_t count)
{
    struct fd f = fdget_pos(fd);
    ssize_t ret = -EBADF;

    if (f.file) {
        loff_t pos, *ppos = file_ppos(f.file);
        if (ppos) {
            pos = *ppos;
            ppos = &pos;
        }
        ret = vfs_read(f.file, buf, count, ppos);
        if (ret >= 0 && ppos)
            f.file->f_pos = pos;
        fdput_pos(f);
    }
    return ret;
}

kernel_execve()와 kernel_clone()

커널이 첫 번째 사용자 프로세스(/sbin/init)를 실행하거나, 커널 스레드를 생성할 때 사용하는 내부 함수입니다.

/* init/main.c — 커널 초기화에서 init 프로세스 실행 */
static int __ref kernel_init(void *unused)
{
    /* ... 초기화 작업 ... */

    /* /sbin/init 실행: 커널에서 사용자 프로그램으로 전환 */
    if (!try_to_run_init_process("/sbin/init") ||
        !try_to_run_init_process("/etc/init") ||
        !try_to_run_init_process("/bin/init") ||
        !try_to_run_init_process("/bin/sh"))
        return 0;

    panic("No working init found.");
}

static int try_to_run_init_process(const char *init_filename)
{
    /* kernel_execve: 커널 컨텍스트에서 ELF 바이너리 실행 */
    return kernel_execve(init_filename, argv_init, envp_init);
}

/* kernel/fork.c — 커널 스레드 생성 */
pid_t kernel_clone(struct kernel_clone_args *args)
{
    /* clone 시스템 콜과 동일한 로직이지만
     * 사용자 공간 검증 없이 커널 내부에서 직접 실행 */
    struct task_struct *p;
    p = copy_process(NULL, 0, args);
    /* ... */
}

초기 부팅 코드의 시스템 콜 사용

커널 초기화 과정(init/main.ckernel_init())에서 파일 시스템 마운트, 장치 노드 생성 등을 위해 시스템 콜 기능을 사용합니다.

/* init/do_mounts.c — 초기 루트 파일 시스템 마운트 */
void __init mount_root(void)
{
    /* ksys_mount: mount() 시스템 콜의 커널 내부 버전 */
    init_mount(".", "/", NULL, MS_MOVE, NULL);
    init_chroot(".");
}

/* init/initramfs.c — initramfs 압축 해제 */
static int __init do_copy(void)
{
    /* init_mkdir, init_mknod, init_symlink 등
     * 시스템 콜 대응 함수로 초기 디렉터리 구조 생성 */
    init_mkdir("/dev", 0755);
    init_mknod("/dev/console", S_IFCHR|0600,
              new_encode_dev(MKDEV(5, 1)));
}

set_fs() 제거와 주소 공간 정책 변경

Linux 5.18 이전에는 set_fs(KERNEL_DS)를 사용하여 커널 내부에서 copy_from_user() 등의 함수를 커널 포인터에 대해 호출할 수 있었습니다. 이는 보안 위험이 크므로 Linux 5.18에서 완전히 제거되었습니다.

/* Linux 5.18 이전 (제거됨) — 위험한 패턴 */
mm_segment_t old_fs = get_fs();
set_fs(KERNEL_DS);           /* 사용자 주소 검증 비활성화! */
vfs_read(file, kernel_buf, count, &pos);  /* 커널 버퍼에 직접 읽기 */
set_fs(old_fs);

/* Linux 5.18 이후 — 안전한 대안 */
kernel_read(file, kernel_buf, count, &pos);
kernel_write(file, kernel_buf, count, &pos);

/*
 * kernel_read/kernel_write는 내부적으로
 * __vfs_read/__vfs_write를 직접 호출하여
 * access_ok() 검사를 우회합니다.
 * set_fs()와 달리 전역 상태를 변경하지 않으므로 안전합니다.
 */
set_fs() 제거의 영향: set_fs() 제거로 인해 커널 내부에서 사용자 공간 API(sys_*)를 직접 호출하는 것이 불가능해졌습니다. 모든 커널 내부 파일 접근은 kernel_read()/kernel_write()/filp_open() 등의 전용 API를 사용해야 합니다. 이는 SMAP/PAN과 결합하여 커널의 사용자 메모리 접근 보안을 크게 강화합니다.

호환성 계층

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
64비트 커널의 32비트 호환 시스템 콜 경로 사용자 공간 (32비트 프로세스) int 0x80 (eax=4) sysenter (eax=4) SYSCALL (rax=1) 64비트 프로세스 커널 진입점 entry_INT80_compat entry_SYSENTER_compat entry_SYSCALL_64 디스패처 ia32_sys_call_table[eax] sys_call_table[rax] compat_sys_write() → ksys_write() __x64_sys_write() → ksys_write()
그림: 32비트 프로세스(int 0x80/sysenter)와 64비트 프로세스(SYSCALL)의 별도 디스패치 경로

x32 ABI

x32 ABI는 x86_64 프로세서의 64비트 모드에서 32비트 포인터를 사용하는 특수한 ABI입니다. 64비트 레지스터와 명령어의 성능 이점을 유지하면서 포인터 크기를 줄여 캐시 효율을 높이는 것이 목적입니다.

여기서 중요한 점은 x32가 단순한 컴파일 옵션이 아니라 별도 ABI라는 사실입니다. 즉, 사용자 공간은 같은 명령어셋을 사용해도 포인터 크기, 구조체 레이아웃, 시스템 콜 번호 해석 규칙이 달라질 수 있습니다. ABI 전체 관점은 ABI 문서의 compat ABI 섹션에서 따로 정리합니다.

/* arch/x86/entry/common.c */
#define __X32_SYSCALL_BIT  0x40000000

static bool __do_fast_syscall_32(struct pt_regs *regs)
{
    int nr = regs->ax;

    /* x32 시스템 콜: 번호의 비트 30이 1 */
    if (nr & __X32_SYSCALL_BIT) {
        nr &= ~__X32_SYSCALL_BIT;
        return do_syscall_x32(regs, nr);
    }
    return do_syscall_32(regs, nr);
}

/* x32 시스템 콜 테이블 */
/* arch/x86/entry/syscalls/syscall_64.tbl */
/* 512  x32  rt_sigaction     compat_sys_rt_sigaction */
/* 513  x32  rt_sigreturn     compat_sys_x32_rt_sigreturn */
/* x32 전용 시스템 콜은 512번부터 시작 */
x32 ABI의 현재 상태: x32 ABI(CONFIG_X86_X32_ABI)는 채택률이 낮아 Linux 6.x에서 deprecated 논의가 진행 중입니다. 대부분의 배포판에서 기본 비활성화되어 있으며, 새 프로젝트에서는 사용을 권장하지 않습니다.

Y2038 문제와 시스템 콜 변경

32비트 time_t는 2038년 1월 19일 03:14:07 UTC에 오버플로됩니다. 이 문제를 해결하기 위해 Linux 5.1부터 64비트 시간을 사용하는 새로운 시스템 콜이 추가되었습니다.

기존 시스템 콜 (32비트 time_t)새 시스템 콜 (64비트 time_t)커널 버전
clock_gettimeclock_gettime645.1
clock_settimeclock_settime645.1
clock_nanosleepclock_nanosleep_time645.1
futexfutex_time645.1
ppollppoll_time645.1
pselect6pselect6_time645.1
recvmmsgrecvmmsg_time645.1
utimensatutimensat_time645.1
64비트 시스템 영향: 64비트 아키텍처(x86_64, ARM64)에서는 time_t가 이미 64비트이므로 Y2038 문제가 없습니다. *_time64 시스템 콜은 주로 32비트 ARM, MIPS 등의 아키텍처를 위한 것입니다. glibc 2.32+는 32비트에서도 자동으로 *_time64 시스템 콜을 사용합니다.

compat_ioctl — 32비트 프로그램의 ioctl 호환

ioctl()은 디바이스 드라이버마다 고유한 명령어와 구조체를 사용하므로, 32비트/64비트 호환이 자동으로 이루어지지 않습니다. 구조체 내 포인터나 long 필드의 크기가 달라지기 때문입니다.

/* 문제 상황: 32비트 프로그램이 64비트 커널에 ioctl 호출 */
struct my_ioctl_data {
    unsigned long flags;    /* 32비트: 4바이트, 64비트: 8바이트! */
    void *buffer;           /* 32비트: 4바이트, 64비트: 8바이트! */
    __u32 size;              /* 동일: 4바이트 */
};

/* 해결: compat_ioctl 핸들러에서 32비트 구조체를 변환 */
struct compat_my_ioctl_data {
    compat_ulong_t flags;   /* 항상 4바이트 */
    compat_uptr_t buffer;    /* 항상 4바이트 */
    __u32 size;
};

static long my_compat_ioctl(struct file *file,
                            unsigned int cmd, unsigned long arg)
{
    struct compat_my_ioctl_data compat_data;
    struct my_ioctl_data data;

    if (copy_from_user(&compat_data, (void __user *)arg,
                       sizeof(compat_data)))
        return -EFAULT;

    /* 32비트 → 64비트 변환 */
    data.flags = compat_data.flags;
    data.buffer = compat_ptr(compat_data.buffer);
    data.size = compat_data.size;

    return my_ioctl_internal(file, cmd, &data);
}

/* file_operations 등록 */
static const struct file_operations my_fops = {
    .unlocked_ioctl = my_ioctl,
    .compat_ioctl   = my_compat_ioctl,  /* 32비트 호환 */
};
권장 설계: 새로운 ioctl 인터페이스를 설계할 때는 구조체에 unsigned long, void * 대신 고정 크기 타입(__u32, __u64)만 사용하면 compat 핸들러가 필요 없습니다. 포인터는 __u64로 전달하세요.

vDSO (virtual Dynamic Shared Object)

개념과 목적

vDSO는 커널이 사용자 공간 프로세스의 주소 공간(Address Space)에 매핑하는 작은 공유 라이브러리(Shared Library)입니다. 커널 모드 전환 없이 특정 시스템 콜을 사용자 공간에서 직접 실행할 수 있게 합니다. 이를 통해 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 페이지(읽기 전용)를 사용자 공간에 매핑합니다. 타이머(Timer) 인터럽트마다 커널이 이 페이지를 갱신하고, 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 해당 스레드(Thread)만 종료
SECCOMP_RET_TRAP SIGSYS 시그널(Signal) 전송 (핸들링 가능)
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가 권한 검사 결과를 기다리지 않고 투기적으로 커널 데이터를 캐시(Cache)에 올리기 때문에 발생합니다.

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가 분기 예측(Branch Prediction) 실패 시에도 투기적으로 실행하는 코드에서 비밀 데이터를 캐시 사이드채널로 유출하는 취약점입니다. 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 격리(Isolation) 높음 (매 syscall마다)
IBPB Indirect Branch Predictor Barrier: 컨텍스트 전환 시 BTB 플러시(Flush) 중간 (컨텍스트 전환마다)
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 + 하이퍼바이저(Hypervisor) 최적화
PCID (Process Context IDentifier): Intel Haswell+부터 지원하는 PCID 기능을 사용하면 CR3 전환 시 TLB를 완전히 플러시하지 않아도 됩니다. 리눅스 커널은 PCID를 자동으로 활용하여 KPTI 오버헤드를 크게 줄입니다 (CONFIG_X86_64 기본 활성화). ARM64에서는 ASID(Address Space ID)가 동일한 역할을 합니다.

CR3 전환 코드 분석

KPTI가 활성화되면 시스템 콜 진입 시 SWITCH_TO_KERNEL_CR3 매크로가 실행되고, 반환 시 SWITCH_TO_USER_CR3가 실행됩니다. CR3 레지스터는 페이지 테이블의 물리 주소를 가리키므로, 이 전환이 사용자/커널 페이지 테이블 분리의 핵심입니다.

/* arch/x86/entry/calling.h (Linux 6.x, 단순화) */

/*
 * 사용자 CR3 → 커널 CR3 전환
 * 비트 12(PAGE_TABLE_ISOLATION 비트)를 클리어하여
 * 커널 페이지 테이블 주소로 전환
 */
.macro SWITCH_TO_KERNEL_CR3 scratch_reg:req
    mov    %cr3, \scratch_reg
    andq   $(~PTI_USER_PGTABLE_AND_PCID_MASK), \scratch_reg
    mov    \scratch_reg, %cr3
.endm

/*
 * 커널 CR3 → 사용자 CR3 전환
 * 비트 12를 세팅하여 사용자 페이지 테이블 주소로 전환
 * PCID가 지원되면 noflush 비트(63)도 세팅하여 TLB 플러시 방지
 */
.macro SWITCH_TO_USER_CR3_NOSTACK scratch_reg:req
    mov    %cr3, \scratch_reg
    orq    $(PTI_USER_PGTABLE_AND_PCID_MASK), \scratch_reg

    /* PCID 지원 시: CR3 비트 63 = 1 → TLB noflush */
    ALTERNATIVE "", "bts $63, \scratch_reg", X86_FEATURE_PCID

    mov    \scratch_reg, %cr3
.endm
PTI_USER_PGTABLE_AND_PCID_MASK: 이 마스크는 페이지 테이블 물리 주소의 비트 12를 토글합니다. 리눅스 커널은 커널 PGD(Page Global Directory)와 사용자 PGD를 연속된 2개 페이지에 배치하여, 비트 12만 바꾸면 커널↔사용자 페이지 테이블을 전환할 수 있도록 설계했습니다.

사용자 페이지 테이블 트램펄린

사용자 페이지 테이블에는 커널 코드가 거의 매핑되지 않지만, SYSCALL 명령어가 최초로 점프하는 진입점(entry stub)은 반드시 사용자 PT에도 매핑되어야 합니다. 이 최소한의 매핑 영역을 트램펄린(Trampoline)이라 합니다.

/* arch/x86/mm/pti.c — 트램펄린 매핑 */
void __init pti_init(void)
{
    /* 사용자 PT에 매핑하는 최소 영역들: */
    /* 1. entry_SYSCALL_64 진입 stub (CR3 전환까지) */
    /* 2. entry_SYSRETQ / entry_IRETQ 반환 stub */
    /* 3. NMI, #DB 등 예외 핸들러 진입 stub */
    /* 4. CPU entry area (per-CPU 데이터) */

    pti_clone_entry_text();     /* .entry.text 섹션 복제 */
    pti_clone_user_shared();    /* per-CPU entry area */

    /* 사용자 PT에서 접근 가능한 커널 메모리:
     * - 약 4KB의 진입/반환 코드
     * - per-CPU TSS(Task State Segment)
     * - GDT(Global Descriptor Table)
     * - IDT(Interrupt Descriptor Table)
     * → 전체 커널 이미지 대비 0.01% 미만
     */
}

PCID/ASID를 활용한 TLB 최적화

CR3 전환 시 기본적으로 전체 TLB가 플러시(Flush)되어 성능 저하가 큽니다. PCID(Process Context IDentifier, Intel Haswell+)를 사용하면 각 CR3 값에 PCID 태그를 부여하여, 다른 PCID의 TLB 엔트리를 유지할 수 있습니다.

/* arch/x86/mm/tlb.c (단순화) */

/*
 * PCID 할당 전략:
 * - 각 프로세스에 커널 PCID와 사용자 PCID 쌍을 할당
 * - 커널 PCID = N, 사용자 PCID = N | 0x800
 * - 총 4096개 PCID 중 사용 가능 개수는 제한적 (TLB 크기 고려)
 */

static void load_new_mm_cr3(pgd_t *pgdir,
                             u16 new_asid, bool need_flush)
{
    unsigned long new_mm_cr3;

    if (need_flush) {
        /* TLB 플러시 필요: CR3 비트 63 = 0 */
        new_mm_cr3 = build_cr3(pgdir, new_asid);
    } else {
        /* TLB 보존: CR3 비트 63 = 1 (noflush) */
        new_mm_cr3 = build_cr3_noflush(pgdir, new_asid);
    }

    write_cr3(new_mm_cr3);
}

/*
 * KPTI 성능 효과:
 * - PCID 미지원: 매 시스템 콜마다 TLB 전체 플러시 → 30~50% 오버헤드
 * - PCID 지원: TLB 보존 가능 → 1~5% 오버헤드 (대부분의 워크로드)
 * - Skylake+: CR3 noflush 비트로 추가 최적화
 */
KPTI: 페이지 테이블 분리 구조 사용자 페이지 테이블 (User PGD) 사용자 코드/데이터 (전체 매핑) vDSO (커널 제공 공유 라이브러리) 트램펄린 (최소 커널 코드) entry stub + per-CPU area (~4KB) 전체 커널의 0.01% 미만 나머지 커널 영역: 매핑 없음 PCID = N|0x800 커널 페이지 테이블 (Kernel PGD) 사용자 코드/데이터 (전체 매핑) 커널 코드 (.text, .rodata) 커널 데이터 (.data, .bss) 커널 모듈, vmalloc 영역 direct map (물리 메모리 전체) PCID = N SYSCALL CR3 비트12 클리어 SYSRET CR3 비트12 세팅 양쪽 모두 매핑 커널 PT에만 매핑 트램펄린 (최소 매핑)
그림: KPTI CR3 전환 — 사용자 PT에는 트램펄린만 매핑, 커널 PT에는 전체 커널 메모리 매핑

시스템 콜 관련 보안 취약점 사례

시스템 콜은 사용자-커널 경계를 넘는 유일한 공식 인터페이스이므로, 공격자에게 가장 중요한 공격 표면(Attack Surface)입니다. 주요 CVE를 시스템 콜 경로별로 분류합니다.

CVE년도공격 벡터영향커널 완화
CVE-2009-0029 2009 레지스터 상위 32비트 미정리 권한 상승 SYSCALL_DEFINE 매크로의 long 캐스팅 도입
CVE-2014-4699 2014 ptrace + SYSRET 비표준 RIP 커널 코드 실행 비정규 RIP 검사 → IRET 폴백
CVE-2016-5195 2016 madvise() + write() 경쟁 조건 (Dirty COW) 읽기 전용 파일 쓰기 get_user_pages() FOLL_COW 플래그 수정
CVE-2017-5754 2018 투기적 실행으로 커널 메모리 읽기 (Meltdown) 커널 메모리 유출 KPTI (페이지 테이블 분리)
CVE-2017-5753 2018 배열 경계 투기적 우회 (Spectre v1) 커널 메모리 유출 array_index_nospec() 배리어
CVE-2017-5715 2018 간접 분기 예측 주입 (Spectre v2) 커널 코드 실행 Retpoline, IBRS, STIBP
CVE-2019-1125 2019 swapgs 투기적 실행 커널 메모리 유출 swapgs 배리어 추가
CVE-2021-4154 2021 cgroup v1 release_agent UAF 컨테이너 탈출 cgroup 참조 카운팅 수정
CVE-2022-0185 2022 fsconfig() 힙 오버플로 컨테이너 탈출, 권한 상승 legacy_parse_param() 길이 검사 수정
CVE-2022-0847 2022 splice() 파이프 플래그 미초기화 (Dirty Pipe) 읽기 전용 파일 쓰기 copy_page_to_iter_pipe() 플래그 초기화
CVE-2009-0029 — SYSCALL_DEFINE의 탄생 배경: x86_64에서 32비트 인자를 전달하면 레지스터의 상위 32비트에 쓰레기 값이 남습니다. asmlinkage long sys_foo(int arg)에서 arg가 64비트 long으로 전달되면 상위 비트가 제어 흐름에 영향을 줄 수 있습니다. SYSCALL_DEFINE은 인자를 long으로 받은 뒤 명시적으로 올바른 타입으로 캐스팅하여 이 문제를 해결합니다.
Dirty COW (CVE-2016-5195) 상세: Copy-On-Write 메커니즘의 경쟁 조건을 악용합니다. 공격자가 madvise(MADV_DONTNEED)/proc/self/mem 쓰기를 동시에 실행하면, COW 페이지의 원본이 수정됩니다. /etc/passwd를 수정하여 root 권한을 획득하는 공격이 야생에서 발견되었습니다. 이 취약점은 2007년부터 존재했으며 9년간 발견되지 않았습니다.

시스템 콜 추적과 디버깅(Debugging)

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 페이지도 참고하세요.

추적 도구 오버헤드 정량 비교

getpid()를 1,000만 회 호출하는 벤치마크로 각 추적 도구의 오버헤드를 비교합니다. getpid()는 가장 단순한 시스템 콜이므로 순수 추적 오버헤드를 측정하기에 적합합니다.

추적 도구메커니즘getpid() 평균 지연오버헤드 배율프로덕션 적합성
추적 없음 (baseline)~180ns1x
ftrace tracepoint커널 tracepoint~190ns1.06x적합
bpftraceeBPF + tracepoint~195ns1.08x적합
perf traceperf_event + tracepoint~200ns1.11x적합
auditaudit 서브시스템~350ns1.94x보안 감사용
straceptrace(PTRACE_SYSCALL)~15,000ns83x부적합 (디버깅 전용)
/* 벤치마크 코드 */
#include <time.h>
#include <unistd.h>
#include <stdio.h>

int main(void) {
    struct timespec start, end;
    const int N = 10000000;

    clock_gettime(CLOCK_MONOTONIC, &start);
    for (int i = 0; i < N; i++)
        getpid();
    clock_gettime(CLOCK_MONOTONIC, &end);

    double ns = (end.tv_sec - start.tv_sec) * 1e9
               + (end.tv_nsec - start.tv_nsec);
    printf("avg: %.1f ns/call\n", ns / N);
}
추적 도구별 시스템 콜 오버헤드 비교 (getpid() 기준) 지연 시간 (ns, 로그 스케일) 180ns 없음 190ns ftrace 195ns bpftrace 200ns perf trace 350ns audit 15,000ns 83x 오버헤드 strace 프로덕션 안전 한계 (~2x)
그림: 추적 도구별 getpid() 시스템 콜 오버헤드 — strace(ptrace)는 83배 오버헤드, eBPF 기반은 1.1배 미만
프로덕션 권장: strace는 디버깅 전용입니다. 프로덕션 환경에서는 bpftrace 또는 perf trace를 사용하세요. 두 도구 모두 커널 tracepoint를 사용하여 대상 프로세스를 중단시키지 않으며, 오버헤드가 10% 미만입니다. 보안 감사가 필요한 경우에만 audit 서브시스템을 활성화하세요.

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 모드에서는 커널 스레드(Kernel Thread)가 폴링(Polling)하여 시스템 콜 자체를 제거합니다.

방식 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 메모리 관리(Memory Management)
네트워크 socket, bind, listen, accept, connect, sendto, recvfrom 네트워크 스택(Network Stack)
IPC pipe, shmget, semget, msgget, eventfd, signalfd IPC 메커니즘
시그널 kill, rt_sigaction, rt_sigprocmask, sigaltstack 시그널 처리
파일시스템(Filesystem) 관리 statfs, mount, umount2, pivot_root, chroot VFS, 네임스페이스(Namespace)
시간 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 커널 모듈(Kernel Module), 디바이스 드라이버
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_STARSyscall Target Address Register입니다. 상위 32비트에 세그먼트 셀렉터(Segment Selector)를 인코딩합니다. (__USER32_CS << 16) | __KERNEL_CSSYSCALL 진입 시 CS를 __KERNEL_CS로 설정하고, SYSRET 복귀 시 CS를 __USER_CS로 복원하도록 지시합니다.
  • MSR_LSTARLong-mode SYSCALL Target Address Register(0xC0000082)입니다. SYSCALL 명령어 실행 시 RIP가 이 MSR 값으로 교체됩니다. 여기에 entry_SYSCALL_64의 주소를 등록함으로써 모든 64비트 시스템 콜의 커널 진입점이 확정됩니다.
  • MSR_CSTARCompat-mode SYSCALL Target Address Register입니다. 64비트 커널에서 32비트 compat 프로세스의 SYSCALL을 처리합니다. 최신 커널은 compat SYSCALL을 완전히 비활성화하여 ignore_sysret을 설정합니다.
  • MSR_SYSCALL_MASKSYSCALL 실행 시 하드웨어가 RFLAGS에서 자동으로 클리어하는 비트 마스크입니다. TF(트레이스), DF(방향), IF(인터럽트), AC(정렬 체크), NT(중첩 태스크)를 클리어합니다. IF 클리어로 커널 진입 초기 인터럽트가 비활성화되어 스택 전환이 안전하게 수행됩니다.
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 중첩된 스택 프레임(Stack Frame) 안전 복원
/* 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) */
};
코드 설명
  • nr현재 시스템 콜 번호입니다. BPF 필터에서 offsetof(struct seccomp_data, nr) = 0으로, 가장 첫 번째로 검사하는 필드입니다. AUDIT_ARCH_X86_64와 함께 특정 시스템 콜을 허용/거부하는 핵심 필터 기준입니다.
  • arch아키텍처 식별자입니다. AUDIT_ARCH_X86_64(0xC000003E), AUDIT_ARCH_I386(0x40000003) 등 AUDIT_ARCH_* 상수를 담습니다. 필터가 이 필드를 먼저 검증하지 않으면 x32 ABI나 32비트 호환 경로를 통한 필터 우회가 가능합니다.
  • instruction_pointer시스템 콜을 발생시킨 명령어의 주소입니다. 어느 코드 위치에서 시스템 콜이 호출되었는지 추적할 때 활용합니다. 화이트리스트 기반 샌드박스에서 허용된 코드 영역(예: vDSO, libc)의 IP 범위를 필터링하는 데 사용할 수 있습니다.
  • args[6]시스템 콜 인자 6개(arg0~arg5)를 u64 배열로 담습니다. 인자 기반 세밀한 제어, 예를 들어 open()의 플래그 값이나 mmap()의 보호 비트를 검사할 때 사용합니다. 포인터 인자는 주소값만 알 수 있고 역참조(dereference)는 불가합니다.
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_filter 구조체 심층 분석

struct seccomp_filter는 개별 BPF 필터 프로그램을 나타내며, 프로세스는 이 구조체의 연결 리스트(Linked List)를 통해 필터 체인을 유지합니다. task_structseccomp.filter 포인터가 가장 최근에 설치된 필터를 가리키고, 각 필터는 이전 필터를 prev로 참조합니다.

/* kernel/seccomp.c */
struct seccomp_filter {
    refcount_t        usage;    /* 참조 카운트 (fork 시 공유) */
    bool              is_dead;  /* 프로세스 종료 시 무효화 플래그 */
    struct task_struct __rcu *notif_task; /* USER_NOTIF 수신자 태스크 */
    struct seccomp_filter *prev; /* 이전 필터(부모 체인) */
    struct bpf_prog   *prog;    /* JIT 컴파일된 BPF 프로그램 */
    struct notification *notif; /* USER_NOTIF fd 알림 구조체 */
    struct mutex      notify_lock; /* notif 접근 직렬화 */
    wait_queue_head_t wqh;     /* USER_NOTIF 대기 큐 */
};
코드 설명
  • usage원자적(Atomic) 참조 카운터입니다. fork() 시 자식 프로세스가 부모의 필터 체인을 공유하기 때문에 참조 카운팅이 필요합니다. 카운터가 0이 되면 필터와 BPF 프로그램 메모리가 해제됩니다.
  • is_dead프로세스 그룹이 종료 중일 때 설정됩니다. 이미 종료된 프로세스의 필터가 USER_NOTIF를 통해 신호를 기다리는 supervisor에게 즉시 에러를 반환하도록 합니다.
  • prev필터 체인의 이전 노드를 가리킵니다. seccomp_run_filters()는 현재 필터부터 역방향으로 순회하여 모든 필터를 실행합니다. 가장 엄격한(최근에 설치된) 필터가 먼저 실행되며, 체인 중 하나라도 KILL/ERRNO를 반환하면 즉시 종료됩니다.
  • progcBPF를 커널이 JIT 변환한 struct bpf_prog 포인터입니다. seccomp_run_filters()에서 BPF_PROG_RUN(filter->prog, sd)로 호출됩니다. JIT가 비활성화된 환경에서는 BPF 인터프리터로 실행합니다.
  • notif, notify_lock, wqhSECCOMP_RET_USER_NOTIF 기능을 위한 필드들입니다. notif는 사용자 공간 supervisor와 통신하는 알림 구조체이고, notify_lock은 동시 접근을 직렬화하며, wqh는 supervisor가 응답을 기다리는 대기 큐입니다.

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 로더(Loader)가 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 진입점을 사용합니다.

RISC-V 시스템 콜 구현

RISC-V 아키텍처는 ecall(Environment Call) 명령어로 시스템 콜을 호출합니다. x86_64의 SYSCALL, ARM64의 SVC #0에 대응하는 명령어입니다. RISC-V의 시스템 콜 인터페이스는 상대적으로 단순하며, x86_64와 달리 ecall 전후 레지스터 저장/복원에 대한 하드웨어 지원이 없어 소프트웨어에서 모두 처리합니다.

ecall 레지스터 규약

역할RISC-V 레지스터x86_64 대응ARM64 대응
시스템 콜 번호a7raxx8
인자 1a0rdix0
인자 2a1rsix1
인자 3a2rdxx2
인자 4a3r10x3
인자 5a4r8x4
인자 6a5r9x5
반환값a0raxx0
에러 플래그a0 (음수)rax (음수)x0 (음수)

ecall 진입 경로 분석

RISC-V에서 ecall은 Environment Call exception을 발생시키며, 트랩 핸들러가 stvec 레지스터에서 지정한 주소로 점프합니다.

/* RISC-V 사용자 공간에서 시스템 콜 호출 */
li      a7, 64        /* __NR_write = 64 */
li      a0, 1         /* fd = STDOUT */
la      a1, msg        /* buf = 메시지 주소 */
li      a2, 13        /* count = 13 */
ecall                   /* 트랩 발생 → S-mode 전환 */
/* a0에 반환값 (쓴 바이트 수 또는 음수 에러) */
/* arch/riscv/kernel/entry.S — 트랩 진입점 (단순화) */
ENTRY(handle_exception)
    /* 1. sscratch ↔ tp 스왑 (커널 스레드 포인터 획득) */
    csrrw   tp, sscratch, tp

    /* 2. 커널 스택에 pt_regs 저장 */
    REG_S   sp, PT_SP(tp)
    REG_S   ra, PT_RA(tp)
    REG_S   a0, PT_A0(tp)
    /* ... 나머지 레지스터 저장 ... */

    /* 3. 예외 원인 확인: scause == EXC_SYSCALL */
    csrr    t0, scause
    li      t1, EXC_SYSCALL
    beq     t0, t1, handle_syscall

    /* 기타 예외 처리... */

handle_syscall:
    /* 4. sepc += 4 (ecall 다음 명령어로 복귀) */
    addi    s2, s2, 0x4
    REG_S   s2, PT_EPC(sp)

    /* 5. C 핸들러 호출 */
    jal     do_trap_ecall_u
END(handle_exception)
/* arch/riscv/kernel/traps.c */
void do_trap_ecall_u(struct pt_regs *regs)
{
    syscall_enter_from_user_mode(regs);

    long nr = regs->a7;  /* 시스템 콜 번호 */

    if (likely(nr < NR_syscalls)) {
        regs->a0 = sys_call_table[nr](regs);
    } else {
        regs->a0 = -ENOSYS;
    }

    syscall_exit_to_user_mode(regs);
}

3대 아키텍처 시스템 콜 비교

항목x86_64ARM64RISC-V
진입 명령어SYSCALLSVC #0ecall
명령어 크기2바이트4바이트4바이트
특권 전환Ring 3→0EL0→EL1U-mode→S-mode
번호 레지스터raxx8a7
반환 레지스터raxx0a0
복귀 주소 저장rcx (HW)ELR_EL1 (HW)sepc (SW +4)
플래그 저장r11 (HW)SPSR_EL1 (HW)sstatus (HW)
반환 명령어SYSRET/IRETERETsret
벡터 테이블MSR_LSTARVBAR_EL1stvec
디스패처do_syscall_64()invoke_syscall()do_trap_ecall_u()
HW 레지스터 저장rcx, r11만ELR, SPSR만없음 (전부 SW)
uaccess 보호SMAP/SMEPPAN/PXNSUM (sstatus 비트)
vDSO 지원
RISC-V의 단순성: x86_64의 SYSCALLrcx, r11을 하드웨어가 자동 저장하고 MSR 기반으로 즉시 커널 코드로 점프합니다. ARM64의 SVC #0ELR_EL1, SPSR_EL1을 하드웨어가 저장합니다. 반면 RISC-V의 ecallsepc만 하드웨어가 저장하고 나머지는 모두 소프트웨어가 처리합니다. 이는 RISC-V의 "최소 하드웨어, 최대 소프트웨어" 철학을 반영합니다.
x86_64 ARM64 RISC-V User SYSCALL (rax=nr) SVC #0 (x8=nr) ecall (a7=nr) HW rcx←RIP, r11←RFLAGS MSR_LSTAR→RIP ELR_EL1←PC, SPSR←PSTATE VBAR_EL1+offset→PC sepc←PC (그 외 없음) stvec→PC Entry entry_SYSCALL_64 el0_svc → el0_svc_handler handle_exception Dispatch do_syscall_64() invoke_syscall() do_trap_ecall_u() Return SYSRET / IRET ERET sret SMAP/SMEP PAN/PXN SUM bit
그림: x86_64 vs ARM64 vs RISC-V 시스템 콜 진입부터 반환까지의 전체 경로 비교

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 여러 버퍼(Buffer)의 scatter/gather I/O N개 read/write → 1 syscall 데이터베이스, 로그 서버
splice/tee 커널 내 zero-copy 데이터 이동 read+write → 1 syscall 프록시 서버, 파이프라인(Pipeline)
epoll 이벤트 기반 다중 fd 모니터링 N개 poll → 1 syscall 이벤트 루프(Event Loop), 웹 서버
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 오버헤드를 최소화할 수 있습니다.

참고 자료

커널 공식 문서

LWN.net 기사

man 페이지

커널 소스 (Bootlin Elixir)

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