시스템 콜이란 무엇인가?

유저 공간의 프로그램은 하드웨어에 직접 접근할 수 없다. 파일을 읽거나, 프로세스를 생성하거나, 네트워크 패킷을 전송하려면 반드시 커널에 요청해야 한다. 이 요청의 통로가 바로 시스템 콜이다. 시스템 콜은 유저 공간과 커널 공간 사이의 유일한 공식 인터페이스이며, 커널이 제공하는 서비스를 사용하기 위한 계약과 같은 것이다.

리눅스 커널은 약 450개의 시스템 콜을 제공한다. open, read, write, close 같은 파일 관련 콜부터 fork, exec, wait 같은 프로세스 관련 콜, socket, bind, listen 같은 네트워크 관련 콜까지 운영체제의 모든 기능이 시스템 콜을 통해 노출된다. 프로그래머가 작성하는 대부분의 코드는 결국 이 시스템 콜들의 조합으로 귀결되는 셈이다.

시스템 콜의 실행 과정

시스템 콜이 호출되면 CPU의 실행 모드가 유저 모드에서 커널 모드로 전환된다. 이 전환은 단순한 함수 호출이 아니라, CPU의 권한 수준이 바뀌는 근본적인 모드 변경이다. 그 과정을 단계별로 살펴보면 다음과 같다.

먼저, 프로그램이 시스템 콜을 호출하면 시스템 콜 번호가 레지스터(x86-64에서는 rax)에 저장되고, 인자들이 정해진 레지스터(rdi, rsi, rdx 등)에 배치된다. 그 다음 x86-64에서는 syscall 명령어가 실행되어 CPU가 커널 모드로 전환된다. 커널은 시스템 콜 테이블에서 rax에 저장된 번호에 해당하는 핸들러 함수를 찾아 실행한다. 핸들러가 작업을 완료하면 결과값이 rax에 저장되고, sysret 명령어를 통해 유저 모드로 복귀한다.

유저 공간                          커널 공간
┌────────────┐                   ┌────────────────┐
│ 프로그램    │                   │                │
│            │  ① rax = 콜 번호   │                │
│ write(fd,  │  ② syscall 명령   │  시스템 콜 테이블 │
│   buf, n)  │ ──────────────►   │  ┌───┬────────┐│
│            │                   │  │ 0 │sys_read ││
│            │  ⑤ sysret        │  │ 1 │sys_write││
│            │ ◄──────────────   │  │ 2 │sys_open ││
│ 반환값 확인 │                   │  │...│  ...    ││
└────────────┘                   │  └───┴────────┘│
                                 │  ③ 핸들러 실행   │
                                 │  ④ 결과 → rax   │
                                 └────────────────┘

이 과정에서 중요한 것은 유저 공간의 스택과 커널 공간의 스택이 별도로 존재한다는 점이다. 커널 모드로 전환될 때 CPU는 커널 스택으로 전환하며, 유저 공간의 레지스터 상태를 저장한다. 이 분리가 없다면 악의적인 프로그램이 커널의 스택을 조작하여 시스템 전체를 장악할 수 있을 것이다.

트랩과 인터럽트

시스템 콜은 소프트웨어 트랩의 한 종류이다. 트랩은 프로그램이 의도적으로 발생시키는 CPU 예외로, 시스템 콜 외에도 디버그 브레이크포인트나 0으로 나누기 같은 상황에서 발생한다. 트랩은 동기적이다. 즉, 프로그램이 특정 명령을 실행하는 시점에 정확히 발생하는 것이다.

이에 반해 인터럽트는 비동기적이다. 키보드 입력, 디스크 I/O 완료, 네트워크 패킷 수신 같은 외부 이벤트가 발생할 때 하드웨어가 CPU에 신호를 보내는 것이다. CPU는 현재 실행 중인 명령을 완료한 후 인터럽트 핸들러로 분기하여 이벤트를 처리한다.

과연 트랩과 인터럽트의 구분이 실질적으로 중요한가? 그렇다. 트랩은 현재 프로세스의 맥락에서 처리되지만, 인터럽트는 어떤 프로세스가 실행 중이든 상관없이 발생한다. 커널은 인터럽트 처리 중에 가능한 한 적은 작업만 수행하고(top half), 나머지를 나중에 처리(bottom half)하는 전략을 사용한다. 이 분리가 없으면 잦은 인터럽트로 인해 시스템의 응답성이 크게 저하될 수 있는 것이다.

glibc의 래퍼 함수

C 프로그램에서 write()를 호출할 때, 프로그래머가 직접 레지스터를 설정하고 syscall 명령어를 실행하는 것은 아니다. glibc(GNU C Library)가 이 과정을 래퍼 함수로 감싸고 있기 때문이다.

glibc의 래퍼 함수는 단순히 시스템 콜을 대신 호출하는 것 이상의 역할을 한다. 에러 처리를 표준화하여 시스템 콜이 실패하면 errno를 설정하고 -1을 반환한다. 또한 일부 시스템 콜은 glibc 내부에서 버퍼링이나 캐싱을 수행하여 불필요한 커널 진입을 줄인다. printf가 매번 write 시스템 콜을 호출하지 않고 버퍼가 찰 때까지 유저 공간에서 데이터를 모으는 것이 그 예이다.

// 프로그래머가 작성하는 코드
ssize_t n = write(fd, buf, count);
if (n == -1) {
    perror("write failed");
}

// glibc 내부에서 일어나는 일 (개념적)
ssize_t write(int fd, const void *buf, size_t count) {
    long ret;
    asm volatile (
        "syscall"
        : "=a" (ret)
        : "a" (__NR_write), "D" (fd), "S" (buf), "d" (count)
        : "rcx", "r11", "memory"
    );
    if (ret < 0) {
        errno = -ret;
        return -1;
    }
    return ret;
}

strace로 시스템 콜 추적하기

프로그램이 어떤 시스템 콜을 호출하는지 실시간으로 관찰할 수 있는 도구가 strace이다. strace는 ptrace 시스템 콜을 이용하여 대상 프로세스의 모든 시스템 콜 진입과 반환을 가로챈다.

$ strace ls /tmp
execve("/usr/bin/ls", ["ls", "/tmp"], 0x7ffd...) = 0
...
openat(AT_FDCWD, "/tmp", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY) = 3
getdents64(3, /* 5 entries */, 32768)   = 160
getdents64(3, /* 0 entries */, 32768)   = 0
close(3)                                = 0
write(1, "file1.txt  file2.txt\n", 21)  = 21
close(1)                                = 0
close(2)                                = 0
exit_group(0)                           = ?

이 출력에서 단순한 ls /tmp 명령이 실제로는 수십 개의 시스템 콜을 호출한다는 것을 알 수 있다. openat으로 디렉터리를 열고, getdents64로 디렉터리 엔트리를 읽고, write로 결과를 출력하는 과정이 보인다. 프로그램이 기대대로 동작하지 않을 때 strace는 문제의 원인이 어떤 시스템 콜에서 실패했는지를 정확히 짚어주는 강력한 디버깅 도구인 것이다.

strace -c를 사용하면 시스템 콜별 호출 횟수와 소요 시간의 통계를 볼 수 있어 성능 분석에도 유용하다. strace -e trace=file처럼 특정 카테고리의 시스템 콜만 필터링할 수도 있다.

커널 모듈

리눅스는 모놀리식 커널이지만, 모든 기능을 부팅 시점에 메모리에 올려야 하는 것은 아니다. 커널 모듈은 실행 중인 커널에 동적으로 로드하거나 언로드할 수 있는 코드 조각이다. 디바이스 드라이버, 파일 시스템, 네트워크 프로토콜 등이 커널 모듈로 구현되는 경우가 많다.

커널 모듈을 관리하는 기본 명령어는 세 가지이다. insmod는 모듈을 커널에 로드하고, rmmod는 모듈을 언로드하며, lsmod는 현재 로드된 모듈 목록을 보여준다. 실무에서는 insmod 대신 modprobe를 더 많이 사용하는데, modprobe는 모듈의 의존성을 자동으로 해결하여 필요한 모듈을 순서대로 로드해 주기 때문이다.

$ lsmod | head -5
Module                  Size  Used by
snd_hda_intel         57344  2
snd_intel_dspcfg      28672  1 snd_hda_intel
snd_hda_codec        172032  1 snd_hda_intel
snd_hda_core         106496  2 snd_hda_codec,snd_hda_intel

간단한 커널 모듈 작성

커널 모듈이 어떻게 동작하는지 이해하는 가장 좋은 방법은 직접 작성해 보는 것이다. 아래는 로드될 때 메시지를 출력하고 언로드될 때 또 다른 메시지를 출력하는 최소한의 커널 모듈이다.

#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>

MODULE_LICENSE("GPL");
MODULE_AUTHOR("example");
MODULE_DESCRIPTION("A minimal kernel module");

static int __init hello_init(void) {
    printk(KERN_INFO "hello: module loaded\n");
    return 0;
}

static void __exit hello_exit(void) {
    printk(KERN_INFO "hello: module unloaded\n");
}

module_init(hello_init);
module_exit(hello_exit);

이 코드에서 주목할 점이 몇 가지 있다. 커널 모듈은 일반적인 C 프로그램과 달리 main 함수가 없다. 대신 module_initmodule_exit 매크로로 진입점과 종료점을 지정한다. 출력에는 printf가 아닌 printk를 사용하는데, 커널 공간에서는 표준 C 라이브러리를 사용할 수 없기 때문이다. printk의 출력은 커널 링 버퍼에 기록되며 dmesg 명령으로 확인할 수 있다.

커널 모듈은 커널과 동일한 권한으로 실행되므로, 버그가 있는 모듈은 시스템 전체를 크래시시킬 수 있다. 이것이 커널 개발이 유저 공간 개발보다 훨씬 신중해야 하는 이유인 것이다.

/proc과 /sys 인터페이스

커널은 자신의 내부 상태를 유저 공간에 노출하기 위해 가상 파일 시스템을 사용한다. /proc과 /sys가 대표적이다.

/proc 파일 시스템은 원래 프로세스 정보를 위해 설계되었다. /proc 아래에는 실행 중인 각 프로세스의 PID를 이름으로 하는 디렉터리가 있으며, 그 안에서 프로세스의 메모리 맵, 열린 파일 디스크립터, 명령행 인자 등을 확인할 수 있다. 시간이 지나면서 /proc에는 프로세스와 무관한 시스템 전체 정보도 추가되었다. /proc/meminfo는 메모리 사용량을, /proc/cpuinfo는 CPU 정보를, /proc/interrupts는 인터럽트 통계를 보여준다.

$ cat /proc/self/status | head -8
Name:   cat
Umask:  0022
State:  R (running)
Tgid:   12345
Ngid:   0
Pid:    12345
PPid:   12300
TracerPid:   0

/sys 파일 시스템은 /proc의 혼잡함을 해소하기 위해 도입되었다. /sys는 커널의 장치 모델을 계층적으로 반영하며, 디바이스, 버스, 드라이버 등의 정보를 체계적으로 제공한다. /proc가 역사적 이유로 정리되지 않은 정보가 뒤섞인 반면, /sys는 처음부터 구조화된 설계를 갖추고 있는 것이다.

이 가상 파일 시스템들은 읽기만 가능한 것이 아니다. 특정 파일에 값을 쓰면 커널의 동작을 변경할 수도 있다. 예를 들어 /proc/sys/net/ipv4/ip_forward에 1을 쓰면 IP 포워딩이 활성화된다. 이러한 인터페이스 덕분에 별도의 API나 도구 없이 표준 파일 I/O만으로 커널을 설정할 수 있는 것이다.

다음 포스트에서는 I/O와 디바이스 관리를 살펴본다.