시스템 호출과 API·ABI·POSIX: 사용자 공간에서 커널로 들어가는 경로

시스템 호출의 역할과 API·ABI의 차이, POSIX 이식성, 커널 진입·반환 과정 및 Seccomp 보안 메커니즘을 정리한다.

2026-08-14 · 최초 발행 2026-01-16

사용자 프로그램이 커널 서비스를 요청하는 방식

사용자 프로세스는 커널 기능을 직접 실행하지 않는다. 하드웨어 제어와 권한이 필요한 작업은 시스템 호출(System Call)을 통해 요청하며, 이 경계가 사용자 공간과 커널 공간의 보안 격리를 유지한다.

시스템 호출은 프로세스 제어, 파일 관리, 디바이스 관리, 통신, 정보 유지보수에 걸쳐 사용된다. 사용자 프로세스가 하드웨어를 직접 제어하면 시스템 안정성이 흔들릴 수 있으므로, 하드웨어 접근 권한은 Ring 0의 커널에 두고 사용자 프로그램은 Ring 3에서 시스템 호출을 이용한다.

파일 I/O는 권한 검증을 거친 뒤 수행되고, 프로세스 간 메모리 접근은 차단된다. 소켓 생성과 통신도 커널이 중재한다.

YesNo사용자 프로세스(Ring 3)하드웨어접근 필요?시스템 호출(Trap)사용자 모드계속 실행모드 전환Ring 3 Ring 0커널 함수실행결과 반환Ring 0 Ring 3

소스 코드의 약속과 바이너리의 약속

API(Application Programming Interface)는 개발자가 소스 코드에서 마주하는 규약이다. 함수 이름, 매개변수, 반환값, 데이터 타입과 에러 코드가 여기에 속한다. C API, Python API, Java API처럼 언어에 따라 형태도 달라진다.

POSIX의 open()은 API 수준에서 다음과 같이 사용된다.

#include <fcntl.h>
int fd = open("/tmp/file.txt", O_RDONLY);

이 호출에서 함수명은 open이고, 매개변수는 const char *pathname, int flags다. 성공하면 파일 디스크립터를, 실패하면 -1을 반환한다.

ABI(Application Binary Interface)는 컴파일이 끝난 바이너리가 호출을 수행하는 규약이다. 시스템 호출 번호, 레지스터 할당, 스택 프레임 구조, 데이터 정렬처럼 기계 수준의 약속을 정의한다. 따라서 x86-64, ARM64, RISC-V 등 아키텍처에 따라 ABI는 달라진다.

Linux x86-64 ABI에서는 시스템 호출 번호를 rax에 넣고, 매개변수는 rdi, rsi, rdx, r10, r8, r9로 전달한다. 반환값은 rax에 실리며, 커널 진입에는 syscall 명령을 사용한다. open의 시스템 호출 번호는 2다.

mov rax, 2           ; open 시스템 호출 번호
mov rdi, pathname    ; 첫 번째 매개변수
mov rsi, O_RDONLY    ; 두 번째 매개변수
syscall              ; 커널 진입
항목 API ABI
레벨 소스 코드 바이너리
가독성 높음 (함수명 사용) 낮음 (레지스터/번호)
호환성 재컴파일 필요 바이너리 호환
이식성 언어 간 이식 어려움 아키텍처 간 이식 불가
예시 write(fd, buf, size) rax=1, rdi=fd, syscall
API 규약ABI 규약소스 코드write(fd, buf, size)컴파일러바이너리mov rax, 1syscallPOSIX 표준x86-64 CallingConvention

POSIX가 제공하는 이식성의 기준

POSIX는 Portable Operating System Interface의 약자로, IEEE에서 제정한 운영체제 API 표준이다. Unix 계열 운영체제 사이에서 소스 코드를 옮길 수 있도록 시스템 호출, 셸, 유틸리티, 스레드, 실시간 확장의 기준을 제공한다.

파일 관리에는 open(), read(), write(), close()가 쓰이고, stat(), chmod(), chown()은 파일 메타데이터를 다룬다. mkdir(), rmdir(), unlink()는 디렉토리와 파일의 생성·삭제를 맡는다.

프로세스 제어에서는 fork()로 자식 프로세스를 만들고, exec()로 프로그램을 실행한다. wait(), waitpid()는 자식 프로세스의 종료를 기다리며, kill()은 시그널을 전송한다.

메모리 관리 API에는 파일을 메모리에 매핑하는 mmap(), 매핑을 해제하는 munmap(), 힙 크기를 조정하는 brk(), sbrk()가 있다. brk(), sbrk()는 구형 API이며 현대에는 malloc을 사용한다. POSIX Threads에서는 pthread_create(), pthread_join(), pthread_mutex_lock()가 각각 스레드 생성, 종료 대기, 뮤텍스 잠금을 제공한다.

Linux(glibc 기반), macOS(Darwin 커널), FreeBSD, OpenBSD는 완전 준수 범주에 속한다. Windows는 POSIX 서브시스템(WSL 이전)과 Cygwin 라이브러리를 통해 부분 준수를 제공하며, Android의 Bionic libc는 일부 POSIX API를 지원하지 않는다.

POSIX 표준LinuxmacOSFreeBSDWindows(부분)애플리케이션소스 코드재컴파일만으로이식 가능

함수 호출이 커널 핸들러에 도달할 때

애플리케이션은 먼저 라이브러리 함수를 호출한다. 예를 들어 printf()는 라이브러리의 write() 호출로 이어지고, 시스템 호출 래퍼는 호출 번호와 매개변수를 레지스터에 준비한다. Trap 또는 Interrupt 명령을 실행하면 CPU는 커널 모드로 전환한다.

커널의 시스템 호출 디스패처는 호출 번호를 바탕으로 핸들러를 선택한다. sys_write() 같은 핸들러가 작업을 수행한 뒤 결과를 레지스터에 저장하면, 프로세스는 다시 사용자 모드에서 실행을 이어간다.

애플리케이션printf()libcwrite()시스템 호출 래퍼레지스터 설정Trap 명령(syscall/int 0x80)모드 전환User KernelIDT/ExceptionVector 참조시스템 호출디스패처sys_write()핸들러 실행파일 시스템VFS 레이어블록 디바이스드라이버결과 반환Kernel User

write(fd, "Hello", 5);를 호출하면 glibc의 write()를 거쳐 시스템 호출 래퍼로 전달된다. Linux x86-64 기준으로 write = 1이며, rax = 1에는 시스템 호출 번호, rdi = fd에는 파일 디스크립터, rsi = "Hello"에는 버퍼 주소, rdx = 5에는 바이트 수가 설정된다.

x86-64에서는 syscall, x86(32비트)에서는 int 0x80, ARM64에서는 svc #0 명령으로 커널에 진입한다. 이 과정에서 CPU는 현재 프로그램 카운터(RIP)와 플래그 레지스터를 저장하고, Ring 3에서 Ring 0으로 전환한다. 이어 커널 스택으로 바꾸고 IDT(Interrupt Descriptor Table)를 참조해 핸들러 주소를 로드한다.

커널은 entry_SYSCALL_64()에 진입한 뒤 sys_call_table[rax]에서 핸들러 주소를 조회한다. sys_call_table[1]sys_write 주소를 가리킨다.

/* 의사 코드 */
asmlinkage long sys_call_table[__NR_syscall_max+1] = {
    [0] = sys_read,
    [1] = sys_write,
    [2] = sys_open,
    // ...
};

sys_write()는 파일 디스크립터의 유효성을 검증하고, access_ok()로 사용자 공간 버퍼 주소를 확인한다. 이후 VFS(Virtual File System) 레이어와 실제 파일 시스템 드라이버를 거쳐 디스크 I/O 또는 소켓 전송을 수행한다.

성공 시 쓴 바이트 수를, 실패 시 음수 에러 코드를 rax에 저장한다. sysretq 명령으로 사용자 모드에 복귀하면서 저장했던 RIP와 플래그를 복원하고 Ring 0에서 Ring 3으로 전환한다.

모드 전환 비용을 줄이는 vDSO

시스템 호출에는 컨텍스트 스위칭으로 50-100 CPU 사이클이 들고, TLB(Translation Lookaside Buffer) 플러시도 추가 오버헤드가 된다. 일부 호출은 vDSO(Virtual Dynamic Shared Object)를 통해 이 비용을 피한다.

vDSO는 커널이 사용자 공간에 매핑한 공유 라이브러리다. gettimeofday(), clock_gettime()처럼 읽기 전용인 시스템 호출이 대상이며, 사용자 공간에서 커널 데이터를 직접 읽어 모드 전환 없이 처리한다. 이 방식은 100배 이상 빠르다.

일반 시스템 호출write()Trap(50-100 사이클)커널 핸들러vDSO 시스템 호출gettimeofday()사용자 공간직접 읽기1-2 사이클

호출 경로를 관찰하고 제어하는 도구

strace는 프로세스가 호출한 시스템 호출을 추적한다. strace ls /tmp처럼 실행하면 파일 열기, 디렉터리 항목 조회, 표준 출력 쓰기, 파일 닫기 같은 호출 흐름을 확인할 수 있다.

openat(AT_FDCWD, "/tmp", O_RDONLY|O_DIRECTORY) = 3
getdents64(3, /* 50 entries */, 32768)     = 1648
write(1, "file1.txt\nfile2.txt\n", 20)    = 20
close(3)                                   = 0

ptrace는 디버거가 다른 프로세스를 제어할 수 있게 하는 시스템 호출이며 GDB의 기반이다. 중단점 설정, 레지스터 읽기·쓰기, 시스템 호출 가로채기에 사용된다.

커널은 매개변수 검증도 수행한다. access_ok()로 포인터가 사용자 공간 주소인지 확인하고, 크기 매개변수를 검증해 버퍼 오버플로를 방지한다. 파일 접근 권한과 프로세스 소유권도 확인 대상이다.

Seccomp는 프로세스가 호출할 수 있는 시스템 호출을 제한한다. Strict 모드에서는 read(), write(), exit(), sigreturn()만 허용하며, Filter 모드에서는 BPF(Berkeley Packet Filter) 규칙으로 세밀하게 제어한다. 컨테이너(Docker)와 브라우저 샌드박스(Chrome)에서 활용된다.

/* 예시: open() 시스템 호출 차단 */
struct sock_filter filter[] = {
    BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_open, 0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
};
시스템 호출운영체제POSIXAPIABISeccomp