시스템 콜
시스템 콜은 사용자 프로그램이 OS 커널의 서비스를 요청하는 표준 통로입니다. 프로세스는 파일 읽기·네트워크·프로세스 생성 등 하드웨어 접근이 필요한 모든 작업에서 반드시 시스템 콜을 통해 커널에 요청합니다.
핵심 개념
유저 모드 / 커널 모드
CPU는 특권 수준을 나눕니다(여기서는 x86의 두 모드를 기준으로 합니다). 유저 모드(Ring 3)는 제한된 명령만 실행하며 하드웨어에 직접 접근할 수 없습니다. 커널 모드(Ring 0)는 모든 명령과 하드웨어에 접근 가능합니다. 시스템 콜이 두 모드를 전환시킵니다.
핵심 개념
트랩 (Trap)
시스템 콜 호출 시 일어나는 커널 진입(트랩)입니다. x86에서는 int 0x80(32비트의 구식 방식) 또는 syscall 명령어를 사용합니다. 트랩이 발생하면 CPU가 커널 모드로 전환되고 미리 등록된 트랩 핸들러가 실행됩니다.
핵심 개념
시스템 콜 테이블
각 시스템 콜에는 고유 번호가 있습니다. Linux x86-64에서 read=0, write=1, open=2, close=3, fork=57, execve=59, exit=60입니다. 커널은 이 번호로 sys_call_table에서 핸들러 함수를 찾아 실행합니다.
핵심 개념
시스템 콜 종류
교재에 따라 프로세스 제어, 파일 관리, 장치 관리, 정보 유지, 통신, 보호 같은 범주로 나눕니다. 대표 예는 파일(open·read·write·close), 프로세스(fork·exec·exit·wait), 메모리(mmap·brk), 장치(ioctl), 정보(getpid·time), 통신(socket·send·recv)입니다.
핵심 개념
C 라이브러리 래퍼
프로그래머는 보통 시스템 콜을 직접 호출하지 않습니다. glibc의 printf()→write(), fopen()→open() 같이 표준 라이브러리가 시스템 콜을 래핑합니다. 이를 통해 이식성이 높아지고 에러 처리가 표준화됩니다.
핵심 개념
시스템 콜 오버헤드
모드 전환과 커널 진입·복귀에 비용이 들며, 크기는 CPU와 보안 완화 설정에 따라 다릅니다. 레지스터 저장·복원이 필요하고 설정에 따라 페이지 테이블 전환 비용이 더해집니다. vDSO(virtual Dynamic Shared Object)는 gettimeofday() 같은 일부 시스템 콜을 모드 전환 없이 처리합니다.
strace ls 실행 시 execve·openat·read·write 등 여러 시스템 콜이 표시됩니다. 성능 분석과 디버깅에 유용합니다.
왜 시스템 콜이 있는가
사용자 프로그램이 하드웨어나 다른 프로세스의 메모리를 마음대로 다루면 시스템 전체가 위험해집니다. 그래서 CPU는 권한이 다른 모드를 두고, 권한이 필요한 일은 커널에 요청하도록 합니다. 시스템 콜은 이 요청이 커널로 들어가는 정해진 입구이고, 리눅스는 시스템 콜 목록을 syscalls(2) 매뉴얼에 정리합니다.
숫자로 확인하기
write(1, buf, 3)은 RAX에 1(write 번호), RDI에 1(표준 출력), RSI에 버퍼 주소, RDX에 3을 넣고 syscall을 실행합니다. 3바이트를 썼다면 RAX에 3이 돌아옵니다.
자주 하는 오해
면접에서 이렇게 물어요
면접 질문
시스템 콜이 호출되면 어떤 일이 일어나나요?
프로그램이 번호와 인수를 레지스터에 넣고 syscall 같은 특수 명령을 실행하면 CPU가 커널 모드로 전환되어 커널의 진입점으로 갑니다. 커널이 번호로 처리 함수를 찾아 실행하고 결과를 레지스터에 담아 유저 모드로 돌아옵니다.
면접 질문
유저 모드와 커널 모드를 왜 나누나요?
프로그램의 오류나 악의적인 동작이 커널과 다른 프로세스를 망가뜨리지 못하게 하기 위해서입니다. 하드웨어 접근 같은 특권 명령은 커널 모드에서만 실행할 수 있습니다.
면접 질문
라이브러리 함수와 시스템 콜의 차이는 무엇인가요?
라이브러리 함수는 유저 모드의 코드이고 그 안에서 필요하면 시스템 콜을 호출합니다. 예를 들어 fopen은 라이브러리 함수이고 내부에서 open이나 openat 시스템 콜을 부릅니다.
면접 질문
시스템 콜 횟수를 줄이려면 어떻게 하나요?
작은 읽기와 쓰기를 버퍼에 모아 한 번에 처리하고, vDSO가 제공하는 호출은 커널 진입 없이 처리하며, 여러 I/O를 한꺼번에 제출하는 io_uring 같은 인터페이스를 쓸 수 있습니다.
실제 시스템에서는
strace -c 명령은 시스템 콜별 횟수와 시간을 요약하고, strace -e trace=openat 명령은 특정 시스템 콜만 보여 줍니다.
함께 보면 좋은 토픽
참고 자료
C — 시스템 콜 직접 호출 vs 라이브러리 래퍼
#include <unistd.h> #include <fcntl.h> #include <sys/syscall.h> int main() { /* ── 방법 1: glibc 래퍼 (일반적) ──────────────────── */ int fd1 = open("/etc/passwd", O_RDONLY); // glibc → syscall 명령어 → 커널 sys_open → fd 반환 /* ── 방법 2: syscall() 함수로 직접 호출 ───────────── */ int fd2 = syscall(SYS_open, "/etc/passwd", O_RDONLY); // SYS_open = 2 (x86-64 Linux) /* ── 방법 3: 인라인 어셈블리 (x86-64) ─────────────── */ long fd3; __asm__ volatile ( "syscall" : "=a"(fd3) // RAX = 반환값 : "0"(2), // RAX = 2 (open) "D"("/etc/passwd"), // RDI = 경로 "S"((long)O_RDONLY), // RSI = 플래그 "d"((long)0644) // RDX = 모드 : "rcx", "r11", "memory" ); /* ── strace 확인 명령어 ───────────────────────────── */ // $ strace -e trace=open,openat ./a.out // openat(AT_FDCWD, "/etc/passwd", O_RDONLY) = 3 close(fd1); return 0; }
open()을 호출하면 glibc가 RAX=2(x86-64에서 open 번호), RDI=경로, RSI=플래그를 설정하고 syscall 명령어를 실행합니다. CPU는 Ring 3→Ring 0으로 전환하고 커널의 sys_open을 실행합니다.
syscall() 함수는 시스템 콜을 직접 호출할 때 사용합니다. 커널 버전별로 시스템 콜 번호가 다를 수 있어 일반적으로는 glibc 래퍼 사용을 권장합니다.
인라인 어셈블리로도 직접 호출 가능하지만, 이식성이 없어 커널 개발이나 저수준 실험 목적으로만 사용합니다.
대부분의 운영 코드는 glibc 래퍼를 사용합니다.
Java — JNI 없이 시스템 콜 흐름 추적
import java.io.*; import java.nio.file.*; import java.lang.management.*; public class SystemCallTrace { public static void main(String[] args) throws Exception { // JVM 내부에서 발생하는 시스템 콜 확인 // $ strace -p <PID> -e trace=openat,read,write,close // ── FileInputStream: open() 시스템 콜 발생 ──────────── try (var fis = new FileInputStream("/etc/passwd")) { // JVM → JNI → open() → syscall → 커널 sys_openat byte[] buf = fis.readAllBytes(); // read() 시스템 콜 System.out.printf("읽은 바이트: %d%n", buf.length); } // close() 시스템 콜 (try-with-resources) // ── ProcessBuilder: fork() + execve() 시스템 콜 ─────── ProcessBuilder pb = new ProcessBuilder("ls", "-la"); pb.redirectErrorStream(true); Process proc = pb.start(); // fork() + execve() try (var reader = new BufferedReader( new InputStreamReader(proc.getInputStream()))) { reader.lines().forEach(System.out::println); } proc.waitFor(); // wait4() 시스템 콜 // ── JVM이 사용하는 주요 시스템 콜 목록 ──────────────── // mmap : 힙/스택/JIT 코드 메모리 할당 // futex : synchronized 블록 잠금 // clone : 스레드 생성 (pthread_create) // epoll : NIO Selector (비동기 I/O) // brk : 힙 크기 조정 // getpid : Thread.currentThread().getId() // JVM PID 확인 (getpid 시스템 콜) long pid = ProcessHandle.current().pid(); System.out.println("JVM PID: " + pid); System.out.println("strace -p " + pid + " -e trace=all"); } }
Java의 파일 I/O는 JVM → JNI → POSIX API → 시스템 콜 경로로 실행됩니다. FileInputStream.read()는 내부적으로 read() 시스템 콜을 호출합니다.
ProcessBuilder.start()는 fork()와 execve() 두 개의 시스템 콜을 순서대로 발생시킵니다. fork()로 현재 프로세스를 복사하고, execve()로 새 프로그램을 실행합니다.
strace -p PID로 실행 중인 JVM의 시스템 콜을 실시간 모니터링할 수 있습니다.
성능 문제 분석 시 특정 시스템 콜이 병목인지 확인하는 데 유용합니다.
Q1
시스템 콜이 필요한 이유는?
Q2
유저 모드에서 커널 모드로 전환이 일어나는 시점은?
Q3
커널 모드가 유저 모드보다 가지는 특징은?