컴퓨터 구조 운영체제 네트워크 자료구조 알고리즘 데이터베이스 내 학습
os · 10

시스템 콜

OS커널인터페이스

시스템 콜은 사용자 프로그램이 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() 같은 일부 시스템 콜을 모드 전환 없이 처리합니다.

syscall 명령어 (x86-64) — RAX에 시스템 콜 번호, RDI·RSI·RDX 등에 인수를 넣고 syscall을 실행합니다. 커널은 처리 후 RAX에 반환값을 저장하고 유저 모드로 복귀합니다.
strace 도구 — 프로세스의 모든 시스템 콜을 추적합니다. strace ls 실행 시 execve·openat·read·write 등 여러 시스템 콜이 표시됩니다. 성능 분석과 디버깅에 유용합니다.
Spectre/Meltdown 이후 — Meltdown 완화책인 KPTI는 유저와 커널의 페이지 테이블을 분리해 커널 진입·복귀 비용을 늘릴 수 있고, 영향은 워크로드와 CPU에 따라 다릅니다. CPU가 Meltdown 영향권이 아니면 커널이 자동 모드에서 PTI를 켜지 않습니다(커널 소스 arch/x86/mm/pti.c 참고).
seccomp — 프로세스가 사용할 수 있는 시스템 콜을 필터링합니다. Docker 컨테이너는 기본 seccomp 프로필로 호출할 수 있는 시스템 콜이 일부 제한됩니다.

왜 시스템 콜이 있는가

사용자 프로그램이 하드웨어나 다른 프로세스의 메모리를 마음대로 다루면 시스템 전체가 위험해집니다. 그래서 CPU는 권한이 다른 모드를 두고, 권한이 필요한 일은 커널에 요청하도록 합니다. 시스템 콜은 이 요청이 커널로 들어가는 정해진 입구이고, 리눅스는 시스템 콜 목록을 syscalls(2) 매뉴얼에 정리합니다.

숫자로 확인하기

write 한 번의 레지스터 — x86-64 리눅스에서 write(1, buf, 3)은 RAX에 1(write 번호), RDI에 1(표준 출력), RSI에 버퍼 주소, RDX에 3을 넣고 syscall을 실행합니다. 3바이트를 썼다면 RAX에 3이 돌아옵니다.
시스템 콜 번호 — 커널 소스의 x86-64 시스템 콜 표에서 read 0, write 1, open 2, close 3, fork 57, execve 59, exit 60, exit_group 231을 확인할 수 있습니다.
오류는 음수로 돌아온다 — syscall(2)와 intro(2) 매뉴얼은 대부분의 시스템 콜이 실패하면 음수 오류 번호를 돌려주고, C 라이브러리 래퍼가 이를 errno에 넣고 -1을 돌려준다고 설명합니다.

자주 하는 오해

printf는 시스템 콜이다 — printf는 C 라이브러리 함수입니다. 출력을 stdout 버퍼에 모으고, 터미널이면 줄바꿈 때, 파일이나 파이프면 버퍼가 차거나 닫힐 때 내보내며 이때 보통 write를 호출합니다(strace로 확인할 수 있습니다).
모든 함수 호출이 커널로 들어간다 — 대부분의 함수 호출은 유저 모드에서 끝납니다. 시스템 콜은 커널의 서비스가 필요할 때만 일어납니다.
시스템 콜은 컨텍스트 스위치다 — 시스템 콜은 같은 프로세스가 모드만 바꿔 커널에 들어가는 것이고, 다른 프로세스로 CPU가 넘어가는 컨텍스트 스위치와 다릅니다. 다만 시스템 콜 안에서 기다려야 하면 스케줄러가 다른 프로세스를 고를 수 있습니다.
malloc은 시스템 콜이다 — malloc은 라이브러리 함수입니다. 작은 요청은 보통 이미 확보한 힙에서 처리하고, 힙을 늘려야 할 때 brk 계열을, glibc에서 큰 요청(기본 약 128KB 이상)은 mmap을 호출합니다.

면접에서 이렇게 물어요

면접 질문

시스템 콜이 호출되면 어떤 일이 일어나나요?

프로그램이 번호와 인수를 레지스터에 넣고 syscall 같은 특수 명령을 실행하면 CPU가 커널 모드로 전환되어 커널의 진입점으로 갑니다. 커널이 번호로 처리 함수를 찾아 실행하고 결과를 레지스터에 담아 유저 모드로 돌아옵니다.

면접 질문

유저 모드와 커널 모드를 왜 나누나요?

프로그램의 오류나 악의적인 동작이 커널과 다른 프로세스를 망가뜨리지 못하게 하기 위해서입니다. 하드웨어 접근 같은 특권 명령은 커널 모드에서만 실행할 수 있습니다.

면접 질문

라이브러리 함수와 시스템 콜의 차이는 무엇인가요?

라이브러리 함수는 유저 모드의 코드이고 그 안에서 필요하면 시스템 콜을 호출합니다. 예를 들어 fopen은 라이브러리 함수이고 내부에서 open이나 openat 시스템 콜을 부릅니다.

면접 질문

시스템 콜 횟수를 줄이려면 어떻게 하나요?

작은 읽기와 쓰기를 버퍼에 모아 한 번에 처리하고, vDSO가 제공하는 호출은 커널 진입 없이 처리하며, 여러 I/O를 한꺼번에 제출하는 io_uring 같은 인터페이스를 쓸 수 있습니다.

실제 시스템에서는

strace — strace -c 명령은 시스템 콜별 횟수와 시간을 요약하고, strace -e trace=openat 명령은 특정 시스템 콜만 보여 줍니다.
seccomp — seccomp(2) 매뉴얼은 프로세스가 호출할 수 있는 시스템 콜을 제한하는 기능이라고 설명하고, Docker 문서는 컨테이너에 기본 seccomp 프로필이 적용된다고 설명합니다.
vDSO — vdso(7) 매뉴얼은 일부 호출을 커널 진입 없이 사용자 공간에서 처리하는 메커니즘이라고 설명하며, x86-64에서는 clock_gettime, gettimeofday, time, getcpu가 대상입니다.

함께 보면 좋은 토픽

프로세스 — fork, exec, wait 시스템 콜의 흐름을 봅니다.
파일 시스템 — open과 read가 도달하는 파일 시스템을 봅니다.
메모리 관리 — brk와 mmap이 주소 공간을 바꾸는 방식을 봅니다.
스레드 — 스레드 생성이 시스템 콜로 이어지는 방식을 봅니다.

참고 자료

교재 — Silberschatz, Galvin, Gagne, "Operating System Concepts", 10th Edition

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

유저 모드에서 커널 모드로 전환이 일어나는 시점은?

커널의 도움이 필요한 순간입니다.
시스템 콜 호출이나 하드웨어 인터럽트가 발생하면 CPU는 유저 모드에서 커널 모드로 전환해 특권 작업을 수행합니다.

Q3

커널 모드가 유저 모드보다 가지는 특징은?

커널은 시스템 전체를 관리해야 합니다.
커널 모드는 하드웨어와 시스템 자원에 제한 없이 접근할 수 있는 특권 모드로, 유저 모드보다 강력한 권한을 가집니다.