컴퓨터공학 300 주제 시리즈의 102번째 글이다. 전체 지도는 여기.

한 줄 요약

시스템 콜은 사용자 모드 프로그램이 특수 명령어로 CPU 를 커널 모드로 전환해, 번호로 지정한 커널 기능을 실행하고 결과를 돌려받는 정해진 인터페이스다.

왜 필요한가

앞 글에서 본 것처럼 사용자 프로그램은 하드웨어를 직접 만질 수 없다. 그런데 파일을 읽고, 소켓을 열고, 메모리를 더 받고, 자식 프로세스를 만드는 일은 모두 하드웨어나 커널 자료구조를 건드린다. 그래서 커널은 허가된 기능 목록을 번호로 공개하고, 그 번호로만 들어오게 한다.

이 문이 하나로 좁혀져 있어서 얻는 것이 많다.

  • 보안: 커널은 들어오는 인자를 모두 검사한다. 잘못된 포인터를 넘기면 커널이 죽는 대신 EFAULT 를 돌려준다.
  • 이식성: 같은 read() 가 ext4 에서도, NFS 에서도, 파이프에서도 동작한다.
  • 관측 가능성: 모든 입출력이 이 문을 지나므로 strace 같은 도구로 프로그램이 무엇을 하는지 거의 다 볼 수 있다.

“느린 서버”의 원인이 시스템 콜 횟수인 경우도 흔하다. 1바이트씩 write 하는 로깅, 매 요청마다 반복되는 stat 이 그렇다.

핵심 개념

시스템 콜 한 번의 흐름

x86-64 리눅스를 예로 든다.

사용자 코드            libc                       커널
-----------           ----                       ----
read(fd, buf, n) -->  rax = 0 (read 번호)
                      rdi, rsi, rdx = 인자
                      syscall 명령  ---------->  모드 전환(링3 -> 링0)
                                                 커널 스택으로 교체
                                                 sys_call_table[rax] 호출
                                                 ksys_read(...) 실행
                      <------------- sysret ----  rax = 결과 또는 -errno
                      음수면 errno 설정, -1 반환
ret = ...  <--------

핵심은 세 가지다.

  1. 번호: 각 시스템 콜은 아키텍처별 번호를 가진다. x86-64 에서 read 는 0, write 는 1, getpid 는 39 다. 같은 getpid 가 aarch64 에서는 172 다. 번호는 아키텍처마다 다르다.
  2. 레지스터 규약: 번호는 rax, 인자는 rdi, rsi, rdx, r10, r8, r9 순서로 넣는다. 반환값은 rax 로 돌아온다. 이 규약은 syscall(2) 매뉴얼에 아키텍처별 표로 정리돼 있다.
  3. 에러 표현: 커널은 실패하면 -4095..-1 범위의 음수(−errno)를 돌려준다. libc 래퍼가 이를 받아 전역 errno 에 넣고 -1 을 반환한다. 그래서 C 에서는 “반환값이 -1 이면 errno 를 보라”는 관례가 생겼다.

래퍼 함수와 진짜 시스템 콜은 다르다

우리가 부르는 open(), fork() 는 대부분 libc(glibc, musl)의 래퍼 함수다. 래퍼는 이름이 같아도 실제로는 다른 시스템 콜을 부르기도 한다. 예를 들어 최신 glibc 의 open() 은 내부적으로 openat 시스템 콜을 쓰고, fork() 는 clone 계열을 쓴다. strace 로 보면 소스 코드와 다른 이름이 찍히는 이유다.

printf 처럼 시스템 콜이 아닌 라이브러리 함수도 많다. printf 는 사용자 공간 버퍼에 글자를 모았다가 버퍼가 차거나 줄바꿈이 오면 그때 write 를 한 번 부른다. 버퍼링이 시스템 콜 횟수를 줄이는 대표 기법이다.

비용은 어디서 오나

시스템 콜은 함수 호출보다 비싸다. 비용의 원천은 다음과 같다.

비용 내용
모드 전환 syscall/sysret 명령 자체, 레지스터 저장·복원
보안 완화 Meltdown 이후의 페이지 테이블 분리(KPTI) 등은 전환 비용을 늘린다
캐시·TLB 오염 커널 코드와 데이터가 CPU 캐시를 차지한다
실제 작업 경로 탐색, 락 획득, 복사 등

vDSO: 커널에 들어가지 않는 시스템 콜

gettimeofday, clock_gettime 처럼 아주 자주 불리고 읽기만 하는 호출은 커널이 프로세스 주소 공간에 작은 공유 라이브러리(vDSO)를 매핑해 두고, 그 안의 코드가 커널이 갱신하는 시간 데이터를 사용자 모드에서 바로 읽게 한다. 모드 전환이 없으니 일반 함수 호출 수준으로 빠르다. 어떤 함수가 vDSO 로 제공되는지는 아키텍처마다 다르며 vdso(7) 에 표로 나와 있다.

분류

분류 예
프로세스 fork/clone, execve, exit, wait4, kill
파일 openat, read, write, close, fstat, fsync
메모리 mmap, munmap, brk, mprotect
네트워크 socket, bind, connect, accept4, sendmsg
기타 ioctl, futex, epoll_wait, io_uring_enter

리눅스에는 수백 개의 시스템 콜이 있고, 전체 목록과 도입 버전은 syscalls(2) 에 있다.

직접 해 보기

파이썬 ctypes 로 libc 의 syscall() 을 직접 불러 번호로 커널을 호출해 보고, vDSO 와 진짜 시스템 콜의 비용도 비교한다.

import ctypes, os, platform, time

libc = ctypes.CDLL(None, use_errno=True)
SYS_getpid = {"x86_64": 39, "aarch64": 172}[platform.machine()]

print("os.getpid()        :", os.getpid())
print("syscall(SYS_getpid):", libc.syscall(SYS_getpid))

# 존재하지 않는 파일 열기 -> 커널이 -ENOENT 를 돌려주고 libc 가 errno 에 담는다
fd = libc.open(b"/no/such/file", os.O_RDONLY)
err = ctypes.get_errno()
print("open ->", fd, "errno =", err, os.strerror(err))

def bench(label, fn, n=1_000_000):
    t = time.perf_counter()
    for _ in range(n):
        fn()
    dt = (time.perf_counter() - t) / n * 1e9
    print(f"{label:28s} {dt:7.1f} ns/call")

bench("python 빈 함수", lambda: None)
bench("time.time() (vDSO)", time.time)
bench("os.getppid() (진짜 syscall)", os.getppid)
bench("os.stat('/') (syscall+경로탐색)", lambda: os.stat("/"), 300_000)

x86-64 리눅스 노트북에서의 출력이다.

os.getpid()        : 1164106
syscall(SYS_getpid): 1164106
open -> -1 errno = 2 No such file or directory
python 빈 함수                    142.3 ns/call
time.time() (vDSO)             167.9 ns/call
os.getppid() (진짜 syscall)     2222.3 ns/call
os.stat('/') (syscall+경로탐색)   5699.7 ns/call

파이썬 인터프리터 자체의 오버헤드(약 140ns)를 빼면, vDSO 를 쓰는 time.time() 은 거의 공짜이고 진짜 시스템 콜은 그보다 한 자릿수 이상 비싸다. 절대값은 CPU, 커널 설정, 가상화 여부에 따라 크게 달라지므로 비율만 기억하면 된다. 리눅스에서 strace -c python3 -c 'import os; os.stat("/")' 를 돌려 보면 인터프리터가 시작하는 데만 수백 번의 시스템 콜이 필요하다는 것도 확인할 수 있다.

현업에서는

  • strace 는 첫 번째 진단 도구다: 프로세스가 멈춘 것처럼 보이면 strace -p PID 로 어느 시스템 콜에서 막혔는지 본다. futex 에 걸려 있으면 락 대기, read 면 입력 대기, connect 면 네트워크 대기다. 다만 strace 는 ptrace 기반이라 대상 프로세스를 크게 느리게 하므로 운영 트래픽 중에는 조심해서 짧게 쓴다.
  • 컨테이너 보안의 seccomp: 쿠버네티스의 RuntimeDefault seccomp 프로필은 컨테이너가 부를 수 있는 시스템 콜 목록을 제한한다. 컨테이너 안에서 특정 도구가 Operation not permitted 를 내면 seccomp 차단일 수 있다.
  • 배치 처리: 작은 쓰기를 모아 한 번에 보내는 버퍼링, 여러 요청을 한 번에 제출하는 io_uring, 여러 메시지를 한 번에 받는 recvmmsg 는 모두 시스템 콜 횟수를 줄이려는 기법이다.
  • errno 를 읽는 습관: EMFILE(열린 파일 수 한도), ENOSPC(공간 부족), EAGAIN(지금은 불가, 다시 시도)처럼 errno 이름만 알아도 로그의 절반은 해석된다.

확인 문제

  1. 사용자 프로그램이 read() 를 호출했을 때 커널이 “어떤 기능을 원하는지” 아는 방법은?
  2. C 에서 시스템 콜이 실패하면 왜 반환값이 -1 이고 실제 원인은 errno 에 있는가?
  3. printf("a") 를 100번 호출해도 write 시스템 콜은 100번보다 훨씬 적게 일어날 수 있다. 왜인가?
  4. clock_gettime 이 다른 시스템 콜보다 훨씬 빠른 이유는?
  5. 소스 코드에서 open() 을 불렀는데 strace 에는 openat 이 찍힌다. 무엇을 의미하는가?

풀이

  1. libc 래퍼가 시스템 콜 번호를 정해진 레지스터(x86-64 는 rax)에 넣고 syscall 명령을 실행한다. 커널은 그 번호로 시스템 콜 테이블을 찾아 해당 함수를 부른다.
  2. 커널은 음수(−errno)로 실패를 알리고, libc 래퍼가 이를 errno 에 저장한 뒤 -1 을 반환하는 것이 POSIX 관례이기 때문이다.
  3. printf 는 stdio 버퍼에 데이터를 모았다가 버퍼가 차거나 줄바꿈·fflush 시점에 한꺼번에 write 하기 때문이다.
  4. vDSO 덕분이다. 커널이 매핑해 둔 코드와 데이터로 사용자 모드에서 바로 시간을 읽으므로 모드 전환이 없다.
  5. open() 은 libc 래퍼일 뿐이고, 현대 glibc 는 그것을 openat 시스템 콜로 구현한다. 라이브러리 함수와 시스템 콜은 1:1 대응이 아니다.

더 읽을거리 (References)