본문 바로가기

커널과 시스템 콜

CPU가 사용자 모드와 커널 모드를 나누는 이유, 커널을 설계하는 세 가지 방식, read 한 번이 커널에 들어갔다가 돌아오는 과정, 셸이 시스템 콜을 조합해 명령을 실행하는 방식을 살펴봅니다.

C 프로그램에서 printf("Hello\n")를 부르면 터미널에 글자가 나타납니다. 그런데 이 프로그램은 화면을 그리는 장치도, 터미널 프로그램의 메모리도 직접 건드리지 않았습니다. 디스크에 파일을 쓸 때도, 네트워크로 데이터를 보낼 때도 마찬가지입니다. 일반 프로그램은 장치를 직접 다룰 권한이 없고, 그 일을 대신해 주는 운영체제의 핵심 부분에 부탁할 뿐입니다. 이 부분을 커널(kernel)이라고 하고, 부탁하는 정해진 방법을 시스템 콜(system call)이라고 합니다.

커널은 메모리에 늘 머물면서 프로세스, 메모리, 파일 시스템, 장치를 관리합니다. 이 글에서는 CPU가 일반 프로그램과 커널의 권한을 어떻게 나누는지, 커널을 어떤 구조로 지을 수 있는지, 그리고 read() 호출 하나가 커널에 들어갔다가 돌아오기까지 무슨 일이 일어나는지를 차례로 따라갑니다.

프로그램이 장치를 직접 만지지 못하는 이유

CPU에는 실행 모드가 있습니다. 일반 프로그램은 사용자 모드(user mode)에서 실행되고, 커널은 커널 모드(kernel mode)에서 실행됩니다. 사용자 모드에서는 장치를 직접 제어하는 명령이나 인터럽트를 끄는 명령 같은 특권 명령을 실행할 수 없고, 커널이 허락하지 않은 메모리에도 접근할 수 없습니다. 이런 일을 시도하면 CPU가 예외(exception)를 일으켜 실행을 커널에 넘깁니다.

예외를 받은 커널은 원인을 보고 처리 방법을 정합니다. 허락되지 않은 주소에 접근한 경우라면 Linux는 보통 그 프로세스에 SIGSEGV 신호를 보내고, 프로그램이 이 신호를 따로 처리하지 않으면 종료됩니다. 우리가 보는 "Segmentation fault" 메시지가 이 과정의 결과입니다. 다만 모든 예외가 곧 종료를 뜻하지는 않습니다. 아직 메모리에 올라오지 않은 페이지에 접근해 생긴 예외라면, 커널은 페이지를 채워 넣은 뒤 프로그램을 아무 일 없었던 것처럼 이어서 실행합니다. 이 과정은 9장에서 다룹니다.

커널 모드에서는 특권 명령을 실행할 수 있고 모든 물리 메모리와 장치에 접근할 수 있습니다. 그렇다고 커널이 아무 제약 없이 실행되는 것은 아닙니다. 커널 코드도 페이지 테이블이 정한 매핑을 거쳐 메모리에 접근하고, 하드웨어의 보호 기능이나 가상화 환경의 제약을 받습니다.

모드를 나누지 않으면 어떻게 될까요? 웹 브라우저의 버그가 그래픽 카드의 레지스터를 덮어써 화면이 깨지거나, 메일 프로그램이 디스크 컨트롤러를 잘못 조작해 파일 시스템 전체를 망가뜨릴 수 있습니다. 실제로 이런 보호가 없던 MS-DOS 시절에는 프로그램 하나의 버그가 컴퓨터 전체를 멈추게 하는 일이 흔했습니다. 모드를 나누면 사용자 프로그램의 실수는 그 프로그램 안에서 끝나고, 장치와 다른 프로그램에 닿으려면 반드시 커널을 거쳐야 합니다.

더 깊이: x86의 링과 ARM의 예외 수준

x86 CPU는 특권 수준을 링(ring)이라는 네 단계로 나눕니다. 링 0이 가장 높은 특권으로 커널이 쓰고, 링 3이 가장 낮은 특권으로 사용자 프로그램이 씁니다. 대부분의 운영체제는 링 0과 링 3만 쓰고 링 1과 링 2는 쓰지 않습니다. 가상화를 설명할 때 하이퍼바이저를 "링 -1"이라고 부르기도 하는데, 이는 관용적인 표현일 뿐 x86에 음수 링이 새로 생긴 것은 아닙니다. 실제로는 하드웨어 가상화 기능이 VMX root 모드와 non-root 모드를 따로 두고, 각 모드 안에서 기존의 링을 그대로 씁니다. 이 내용은 13장에서 다시 만납니다.

ARM 프로세서는 예외 수준(exception level)이라는 이름으로 EL0(사용자 프로그램), EL1(커널), EL2(하이퍼바이저), EL3(보안 모니터)을 둡니다. 이름은 달라도 원리는 같습니다. 낮은 수준의 코드는 높은 수준의 기능을 직접 부를 수 없고, 정해진 진입 명령을 통해서만 높은 수준으로 들어갈 수 있습니다.

커널을 어떻게 나누어 지을까

커널이 맡는 일은 많습니다. 프로세스 관리, 메모리 관리, 파일 시스템, 네트워크 스택, 장치 드라이버를 모두 커널 모드에서 실행할지, 일부를 사용자 모드로 내보낼지에 따라 성능과 안정성이 달라집니다. 이 선택에 따라 커널 구조를 크게 세 가지로 나눕니다.

모든 서비스를 한 덩어리로: 모놀리식 커널

모놀리식 커널(monolithic kernel)은 운영체제의 서비스를 모두 하나의 커널 이미지 안에 넣어 커널 모드에서 실행합니다. Linux와 전통적인 UNIX가 이 구조입니다. 모든 서비스가 같은 주소 공간에 있으므로, 파일 시스템 코드가 디스크 드라이버를 부를 때 일반 함수 호출과 같은 방식으로 부를 수 있습니다. 메시지를 복사하거나 주소 공간을 바꿀 필요가 없으니 빠릅니다.

대신 안정성에서 약점이 있습니다. 장치 드라이버도 커널과 같은 특권과 같은 주소 공간에서 실행되므로, 드라이버가 잘못된 포인터로 메모리를 덮어쓰면 커널의 다른 자료구조까지 망가질 수 있습니다. 그 결과 시스템 전체가 멈추는 커널 패닉(kernel panic)이 일어나기도 합니다.

Linux는 드라이버를 모두 커널 이미지에 미리 넣어 두지 않아도 되도록 적재 가능한 커널 모듈(loadable kernel module, LKM)을 지원합니다. 필요한 드라이버나 파일 시스템을 실행 중에 커널에 넣고 뺄 수 있습니다. 모듈도 커널 모드에서 실행되므로 안정성의 약점이 사라지지는 않지만, 배포와 확장은 훨씬 유연해집니다. 가장 작은 모듈은 다음과 같습니다.

hello_mod.c
#include <linux/init.h>
#include <linux/module.h>
#include <linux/printk.h>

static int __init hello_init(void)
{
    pr_info("Hello, Kernel!\n");
    return 0; /* 0이 아니면 적재가 실패한 것으로 처리됩니다. */
}

static void __exit hello_exit(void)
{
    pr_info("Goodbye, Kernel!\n");
}

module_init(hello_init);
module_exit(hello_exit);

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

insmod로 이 모듈을 커널에 넣으면 module_init에 등록한 hello_init이 실행되고, rmmod로 빼면 hello_exit이 실행됩니다. pr_info는 화면이 아니라 커널 로그에 기록하므로, 메시지는 dmesg나 journalctl -k로 확인합니다. MODULE_LICENSE는 모듈의 라이선스를 밝히는 선언인데, 현재 커널의 빌드 도구는 이 선언이 없는 모듈을 오류로 처리합니다.

이 코드는 일반 프로그램처럼 gcc로 바로 컴파일하지 않습니다. 실행 중인 커널과 버전이 맞는 커널 헤더가 있어야 하고, 커널의 빌드 시스템(kbuild)을 거쳐야 합니다. 적재에는 root 권한이 필요하며, UEFI Secure Boot가 켜진 시스템에서는 서명하지 않은 모듈의 적재가 거부될 수 있습니다. 이 글에서는 모듈을 실제로 넣어 보지 않아도 되고, 커널 기능이 이런 단위로 붙었다 떨어질 수 있다는 점만 기억하면 됩니다. 직접 빌드해 보려면 Linux 커널 모듈 프로그래밍 안내서의 절차를 따르세요.

최소한만 커널에: 마이크로커널

마이크로커널(microkernel)은 반대 방향을 택합니다. 커널에는 프로세스 간 통신(inter-process communication, IPC), 기본 스케줄링, 주소 공간 관리 같은 최소한의 기능만 남기고, 파일 시스템, 네트워크 스택, 장치 드라이버는 사용자 모드의 별도 프로세스로 실행합니다. 이렇게 서비스를 맡는 프로세스를 서버라고 부릅니다. MINIX 3, QNX, L4 계열이 이 구조입니다.

장점은 고장이 퍼지는 범위가 좁다는 것입니다. 파일 시스템 서버가 잘못된 메모리를 건드려도 그 서버의 주소 공간만 망가지고 커널은 무사합니다. 그래서 QNX는 자동차, 의료 기기, 산업 제어 장비처럼 한 기능의 결함이 다른 기능을 멈추게 해서는 안 되는 곳에서 쓰입니다. 다만 커널이 살아 있다고 해서 서비스가 저절로 이어지지는 않습니다. 파일 시스템 서버가 죽으면 그 서버를 쓰던 프로그램은 영향을 받으며, 서버를 다시 띄우고 상태를 복구하는 방법은 따로 설계해야 합니다.

단점은 비용입니다. 응용 프로그램이 파일을 읽으려면 커널을 통해 파일 시스템 서버에 메시지를 보내고, 파일 시스템 서버는 다시 커널을 통해 디스크 드라이버 서버에 메시지를 보내며, 결과도 같은 길을 거꾸로 돌아옵니다. 모놀리식 커널이라면 함수 호출로 끝날 일에 메시지 전달과 주소 공간 전환이 여러 번 더해집니다. 실제 성능 차이는 IPC 구현, 복사하는 데이터의 양, 캐시, 장치를 기다리는 시간에 따라 달라지므로, 구조의 이름만으로 어느 쪽이 얼마나 느리다고 단정할 수는 없습니다.

1992년 MINIX를 만든 앤드루 타넨바움(Andrew Tanenbaum)과 Linux를 만든 리누스 토르발스(Linus Torvalds)는 이 주제로 유명한 논쟁을 벌였습니다. 타넨바움은 마이크로커널이 설계상 앞선 방식이라고 주장했고, 토르발스는 모놀리식 구조가 현실적으로 빠르고 실용적이라고 반박했습니다. 오늘날 범용 운영체제에서는 Linux가 널리 쓰이지만, 안전이 가장 중요한 임베디드 분야에서는 마이크로커널이 여전히 중요한 자리를 차지합니다.

둘을 절충하기: 하이브리드 커널

하이브리드 커널(hybrid kernel)은 두 방식을 절충합니다. Windows NT 계열 커널과 macOS의 XNU 커널이 여기에 속합니다. 구성 요소를 나누고 인터페이스를 분명히 정하는 마이크로커널의 설계 방식을 따르면서도, 그래픽이나 네트워크처럼 성능이 중요한 부분은 커널 모드에서 실행합니다.

XNU는 "X is Not Unix"의 약자로, 카네기멜런 대학교의 Mach 마이크로커널과 BSD UNIX 코드를 합친 커널입니다. Mach 부분이 스레드와 IPC, 가상 메모리를 맡고, BSD 부분이 프로세스 모델과 파일 시스템, 네트워크를 맡습니다. 그런데 BSD 부분도 커널 모드에서 Mach와 같은 주소 공간에 있으므로, 순수한 마이크로커널이라고 보기는 어렵습니다.

구조예서비스를 두는 곳주로 따지는 비용
모놀리식Linux, 전통적인 UNIX거의 모든 서비스를 커널 주소 공간에 둔다드라이버의 잘못된 메모리 접근이 커널 전체에 퍼질 수 있다
마이크로커널QNX, MINIX 3, L4 계열최소 기능만 커널에, 나머지는 사용자 모드 서버로메시지 전달 비용과 서버 장애 뒤의 복구 설계
하이브리드Windows NT, XNU기능마다 커널과 사용자 모드 가운데 골라 둔다성능과 안정성이 구체적인 구현에 따라 달라진다

결국 커널 구조의 차이는 운영체제의 서비스가 어느 모드에서 실행되고, 한 부분의 결함이 어디까지 퍼질 수 있는지의 차이입니다.

read 한 번이 커널에 다녀오는 길

사용자 프로그램이 커널의 서비스를 요청하는 공식 통로가 시스템 콜입니다. 사용자 모드의 코드는 커널의 함수를 일반 함수처럼 부를 수 없습니다. 그래서 CPU는 사용자 모드에서 커널 모드로 들어가는 전용 명령을 두고, 이 명령이 실행되면 커널이 미리 정해 둔 진입점에서만 실행이 시작되게 합니다. 아무 주소로나 뛰어들 수 없으니, 커널은 들어온 요청을 모두 같은 입구에서 검사할 수 있습니다.

x86-64 Linux에서 프로그램이 read(fd, buf, count)를 부를 때 일어나는 일을 단계별로 따라가 보겠습니다.

  1. 프로그램이 C 라이브러리(glibc)의 read() 함수를 부릅니다. 이 함수는 시스템 콜을 감싼 래퍼(wrapper)입니다.
  2. 래퍼는 시스템 콜 번호를 rax 레지스터에 넣습니다. x86-64에서 read의 번호는 0입니다. 세 인자인 파일 디스크립터, 버퍼 주소, 읽을 크기는 차례로 rdi, rsi, rdx에 넣습니다.
  3. 래퍼가 syscall 명령을 실행합니다. CPU는 커널 모드로 바뀌고, 커널이 미리 등록해 둔 진입점으로 이동합니다.
  4. syscall 명령은 스택 포인터를 바꾸지 않습니다. 그래서 커널의 진입 코드가 사용자 스택 포인터를 보관하고 커널 스택으로 바꾼 뒤, 사용자 레지스터 값을 저장합니다.
  5. 커널은 시스템 콜 번호로 처리 함수의 표를 찾아 read를 처리하는 경로로 보냅니다. 내부 함수의 이름과 구조는 커널 버전에 따라 다를 수 있습니다.
  6. 커널은 파일 디스크립터가 유효한지, 읽기용으로 열렸는지, 버퍼 주소가 사용자 공간에 있는지 확인한 뒤 파일 종류에 맞는 읽기를 실행합니다. 데이터가 이미 페이지 캐시(page cache), 곧 커널이 최근에 읽고 쓴 파일 데이터를 메모리에 담아 두는 곳에 있으면 디스크에 가지 않고 복사만 하며, 파이프나 소켓이라면 전혀 다른 경로를 탑니다.
  7. 커널은 결과를 rax에 넣고 사용자 모드로 돌아갑니다. 성공이면 실제로 읽은 바이트 수를, 실패면 오류 번호에 음수 부호를 붙인 값을 돌려줍니다.
  8. glibc 래퍼는 돌려받은 값이 오류를 뜻하는 음수 범위에 있으면, 그 절댓값을 errno에 넣고 -1을 반환합니다. 그래서 C 프로그래머는 "-1이면 errno를 본다"는 규칙만 알면 됩니다.

아래 단계 개념도는 이 과정을 사용자 요청, 커널 처리, 사용자 반환의 세 단계로 묶어 보여 줍니다.

Linux read의 사용자·커널 경계

glibc가 번호와 인자를 준비한다. glibc가 반환 형식을 맞춘다. 각 단계의 값과 전환 이유를 비교합니다.

시스템 콜 · Linux read

커널 모드 진입이 다른 프로세스로의 전환을 뜻하지는 않습니다

glibc의 read 요청을 커널이 검사하고 파일 종류에 맞게 처리합니다. 결과는 읽은 바이트 수이며, 오류일 때 glibc가 -1과 errno로 전달합니다.

read 요청과 결과 반환실선은 read의 커널 요청, 점선은 결과 반환입니다. x86-64 syscall 명령 자체가 스택 포인터를 바꾸지는 않으며 커널 진입 코드가 스택을 준비합니다.사용자 공간앱 · glibc커널 공간파일 읽기 처리read(fd, buf, count)진입 코드가 커널 스택 준비fd · 접근 모드 · 사용자 버퍼 확인파일 종류에 맞는 읽기 처리바이트 수 또는 커널 오류 코드glibc: 성공값 반환오류면 -1과 errno
read 요청과 결과 반환실선은 read의 커널 요청, 점선은 결과 반환입니다. x86-64 syscall 명령 자체가 스택 포인터를 바꾸지는 않으며 커널 진입 코드가 스택을 준비합니다.사용자공간앱 · glibc커널공간읽기 처리read(fd, buf,count)진입 코드가커널 스택 준비fd · 접근 모드사용자 버퍼 확인파일 종류별읽기 처리바이트 수 또는커널 오류 코드성공값 반환오류: -1 · errno

일반적인 read 호출의 개념도입니다. 데이터는 파일 종류에 따라 페이지 캐시·장치·파이프·소켓 등에서 오며, 모든 read가 디스크 I/O를 발생시키거나 다른 프로세스로 전환되지는 않습니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 사용자 요청부터 사용자 반환까지 진행합니다.
관계도의 보충 설명
전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목Linux read의 사용자·커널 경계
예의 값과 변화
관찰·상황값·보충 정보변화·이유
사용자 요청

glibc가 번호와 인자를 준비한다.

  • 요청·실행 모드 1: x86-64 read 번호 0
  • 요청·실행 모드 2: fd·buf·count
  • syscall로 커널 진입
커널 처리

진입 코드가 스택을 준비하고 요청을 검사한다.

  • 요청·실행 모드 1: fd·접근 모드·사용자 버퍼 검사
  • 요청·실행 모드 2: 파일 종류별 처리
  • 실제 바이트 수 또는 커널 오류 결정
사용자 반환

glibc가 반환 형식을 맞춘다.

  • 요청·실행 모드 1: 성공: 실제 읽은 바이트 수
  • 요청·실행 모드 2: 오류: -1과 errno
  • 사용자 모드 복귀; 모든 read의 디스크 I/O/태스크 전환 보장 없음

Linux read의 사용자·커널 경계

glibc가 번호와 인자를 준비한다. glibc가 반환 형식을 맞춘다. 각 단계의 값과 전환 이유를 비교합니다.

시스템 콜 · Linux read

커널 모드 진입이 다른 프로세스로의 전환을 뜻하지는 않습니다

glibc의 read 요청을 커널이 검사하고 파일 종류에 맞게 처리합니다. 결과는 읽은 바이트 수이며, 오류일 때 glibc가 -1과 errno로 전달합니다.

read 요청과 결과 반환실선은 read의 커널 요청, 점선은 결과 반환입니다. x86-64 syscall 명령 자체가 스택 포인터를 바꾸지는 않으며 커널 진입 코드가 스택을 준비합니다.사용자 공간앱 · glibc커널 공간파일 읽기 처리read(fd, buf, count)진입 코드가 커널 스택 준비fd · 접근 모드 · 사용자 버퍼 확인파일 종류에 맞는 읽기 처리바이트 수 또는 커널 오류 코드glibc: 성공값 반환오류면 -1과 errno
read 요청과 결과 반환실선은 read의 커널 요청, 점선은 결과 반환입니다. x86-64 syscall 명령 자체가 스택 포인터를 바꾸지는 않으며 커널 진입 코드가 스택을 준비합니다.사용자공간앱 · glibc커널공간읽기 처리read(fd, buf,count)진입 코드가커널 스택 준비fd · 접근 모드사용자 버퍼 확인파일 종류별읽기 처리바이트 수 또는커널 오류 코드성공값 반환오류: -1 · errno

일반적인 read 호출의 개념도입니다. 데이터는 파일 종류에 따라 페이지 캐시·장치·파이프·소켓 등에서 오며, 모든 read가 디스크 I/O를 발생시키거나 다른 프로세스로 전환되지는 않습니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 사용자 요청부터 사용자 반환까지 진행합니다.

관계도의 보충 설명

전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목Linux read의 사용자·커널 경계
예의 값과 변화
관찰·상황값·보충 정보변화·이유
사용자 요청

glibc가 번호와 인자를 준비한다.

  • 요청·실행 모드 1: x86-64 read 번호 0
  • 요청·실행 모드 2: fd·buf·count
  • syscall로 커널 진입
커널 처리

진입 코드가 스택을 준비하고 요청을 검사한다.

  • 요청·실행 모드 1: fd·접근 모드·사용자 버퍼 검사
  • 요청·실행 모드 2: 파일 종류별 처리
  • 실제 바이트 수 또는 커널 오류 결정
사용자 반환

glibc가 반환 형식을 맞춘다.

  • 요청·실행 모드 1: 성공: 실제 읽은 바이트 수
  • 요청·실행 모드 2: 오류: -1과 errno
  • 사용자 모드 복귀; 모든 read의 디스크 I/O/태스크 전환 보장 없음

여기서 두 가지를 구분해 두면 뒤의 장이 쉬워집니다. 첫째, 커널 모드에 들어갔다는 것이 다른 프로세스로 실행이 넘어갔다는 뜻은 아닙니다. 같은 프로세스가 커널 코드를 실행하다가 같은 프로세스로 돌아오는 것이 기본이고, 다른 프로세스로 넘어가는 일은 읽을 데이터를 기다려야 할 때처럼 커널이 스케줄러를 부를 때만 일어납니다. 둘째, 모든 read가 디스크를 읽는 것도 아닙니다. 페이지 캐시에 데이터가 있으면 메모리 복사만으로 끝납니다.

더 깊이: 레지스터 규약과 진입 코드

x86-64 Linux의 시스템 콜 규약에서 인자는 rdi, rsi, rdx, r10, r8, r9 순서로 최대 여섯 개까지 전달합니다. 일반 함수 호출 규약과 달리 넷째 인자에 rcx 대신 r10을 쓰는 이유는, syscall 명령이 돌아올 주소를 rcx에, 플래그 레지스터를 r11에 저장하면서 두 레지스터의 원래 값을 덮어쓰기 때문입니다. 이 규약은 syscall(2) 매뉴얼의 아키텍처별 표에 정리되어 있습니다.

커널 진입 코드는 entry_64.S의 entry_SYSCALL_64에서 시작합니다. 이 코드는 사용자 스택 포인터를 CPU별 임시 공간에 보관하고 현재 태스크의 커널 스택으로 바꾼 뒤, 레지스터들을 스택에 저장해 C 함수가 다룰 수 있는 구조체로 만듭니다. 돌아갈 때는 조건이 맞으면 빠른 sysret 경로를, 그렇지 않으면 더 일반적인 iret 경로를 씁니다. 커널이 오류를 음수 오류 번호로 돌려준다는 점은 intro(2)에서, glibc가 -4095부터 -1까지의 값을 오류로 본다는 점은 glibc 소스 sysdeps/unix/sysv/linux/x86_64/sysdep.h의 주석에서 확인할 수 있습니다.

커널에 덜 들어가려는 장치들

시스템 콜은 일반 함수 호출보다 비쌉니다. 모드를 바꾸고, 레지스터를 저장하고 되살리며, 보안 완화 기법이 켜져 있다면 그에 따른 작업도 더해집니다. 실제 비용은 CPU와 커널 설정에 따라 크게 다르므로 고정된 숫자로 외울 수는 없지만, 아주 자주 부르는 호출이라면 이 비용이 쌓입니다. 그래서 시스템 콜 자체를 줄이는 장치들이 생겼습니다.

Linux의 vDSO(virtual dynamic shared object)는 커널이 모든 프로세스의 주소 공간에 넣어 주는 작은 공유 라이브러리입니다. clock_gettime이나 gettimeofday처럼 현재 시각을 읽는 호출은 커널이 공유 메모리에 갱신해 두는 값을 읽기만 하면 되므로, vDSO 안의 코드가 사용자 모드에서 바로 처리합니다. 어떤 호출을 vDSO로 처리하는지는 아키텍처마다 다르며 vdso(7)에 정리되어 있습니다.

Linux 5.1에서 추가된 io_uring은 입출력 요청을 커널과 사용자 프로그램이 함께 보는 링 버퍼에 쌓아 두고 한꺼번에 처리하게 합니다. 요청마다 시스템 콜을 부르는 대신 여러 요청을 모아 제출하므로, 입출력이 많은 서버에서 시스템 콜 횟수를 줄일 수 있습니다. 이 방식은 11장에서 다시 다룹니다.

가장 흔한 장치는 라이브러리의 버퍼링입니다. 이 내용은 아래에서 printf와 write를 비교하며 자세히 봅니다.

strace로 시스템 콜 지켜보기

프로그램이 실제로 어떤 시스템 콜을 부르는지는 Linux의 strace로 볼 수 있습니다. strace는 대상 프로그램을 실행하면서, 그 프로그램이 부르는 시스템 콜의 이름과 인자, 반환값을 한 줄씩 표준 에러로 출력합니다. 간단한 프로그램으로 확인해 보겠습니다.

hello.c
#include <stdio.h>

int main(void)
{
    printf("Hello, OS!\n");
    return 0;
}
gcc -o hello hello.c
strace ./hello

출력에는 프로그램을 메모리에 올리는 execve, 공유 라이브러리를 찾고 매핑하는 openat과 mmap 같은 호출이 먼저 여러 줄 나옵니다. 그 뒤쪽에서 write(1, "Hello, OS!\n", 11) = 11 형태의 줄을 찾을 수 있습니다. 첫째 인자 1은 표준 출력의 파일 디스크립터이고, 셋째 인자 11은 쓰려는 바이트 수이며, 등호 뒤의 11은 실제로 쓴 바이트 수입니다. printf가 결국 write 시스템 콜로 커널에 쓰기를 요청한다는 사실을 눈으로 확인하는 셈입니다.

앞쪽에 나오는 호출의 종류와 순서, 횟수는 C 라이브러리와 배포판에 따라 달라지므로 줄 수를 맞추려 하지 않아도 됩니다. 관심 있는 호출만 보려면 strace -e trace=write ./hello처럼 걸러 낼 수 있습니다. 실무에서는 프로그램이 어떤 파일을 열려다 실패하는지, 어떤 소켓에 연결하는지, 어느 호출에서 멈춰 있는지를 확인할 때 strace를 자주 씁니다. 옵션은 strace(1) 매뉴얼에서 확인할 수 있습니다.

기능별 대표 시스템 콜

Linux에는 수백 개의 시스템 콜이 있지만, 이 교재에서 자주 만나는 것은 몇 갈래로 묶입니다. 여기서는 각 호출이 무엇을 하는지만 보고, 자세한 동작은 해당 장에서 다룹니다.

프로세스를 만들고 기다리기

호출하는 일
fork()지금 프로세스를 복제해 자식 프로세스를 만든다. 부모와 자식은 같은 코드에서 이어서 실행되지만 서로 다른 프로세스다
execve()지금 프로세스의 코드와 데이터를 새 프로그램으로 바꾼다. execlp() 같은 exec 계열 함수는 이 호출을 감싼 라이브러리 함수다
waitpid()자식 프로세스가 끝나기를 기다리고 종료 상태를 거둔다. 거두지 않은 자식은 좀비 프로세스로 남는다
_exit()프로세스를 끝낸다. 커널이 열린 파일을 닫고 메모리를 회수한다

다음 프로그램은 자식 프로세스를 만들어 ls -l을 실행하고, 부모는 자식이 끝나기를 기다렸다가 종료 상태를 출력합니다.

fork_exec.c
#include <errno.h>
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void)
{
    pid_t pid = fork();
    if (pid < 0) {
        perror("fork");
        return 1;
    }
    if (pid == 0) {
        /* 자식: 성공하면 이 아래로 돌아오지 않습니다. */
        execlp("ls", "ls", "-l", (char *) NULL);
        perror("execlp");
        _exit(127);
    }

    /* 부모: 자식이 끝나기를 기다립니다. */
    int status;
    pid_t waited;
    do {
        waited = waitpid(pid, &status, 0);
    } while (waited < 0 && errno == EINTR);
    if (waited < 0) {
        perror("waitpid");
        return 1;
    }

    if (WIFEXITED(status))
        printf("자식 종료, 상태: %d\n", WEXITSTATUS(status));
    else if (WIFSIGNALED(status))
        printf("자식이 신호 %d로 종료\n", WTERMSIG(status));
    return 0;
}

fork()는 한 번 불렀는데 두 번 돌아옵니다. 부모에게는 자식의 프로세스 ID를, 자식에게는 0을 돌려주므로, 반환값으로 지금 실행 중인 쪽이 어느 쪽인지 구분합니다. 자식은 execlp로 자신을 ls 프로그램으로 바꾸는데, 성공하면 원래 코드로 돌아오지 않으므로 그 아래의 perror는 실패했을 때만 실행됩니다. 실패한 자식이 exit 대신 _exit을 부르는 이유는, 부모에게서 복제된 stdio 버퍼를 자식이 다시 내보내는 일을 막기 위해서입니다.

부모의 waitpid는 신호 때문에 중간에 깨어나면 -1과 함께 errno를 EINTR로 설정하므로, 그 경우에는 다시 기다립니다. ls가 정상적으로 끝나면 디렉터리 목록 뒤에 "자식 종료, 상태: 0"이 출력됩니다. 종료 상태를 해석하는 매크로는 waitpid(2)에 정리되어 있으며, 이 흐름은 3장에서 자세히 다룹니다.

파일을 열고 읽기

UNIX 계열 운영체제는 일반 파일뿐 아니라 장치, 파이프, 소켓도 같은 호출로 다룹니다. "모든 것은 파일"이라는 말은 이 설계를 가리킵니다.

호출하는 일
open()파일을 열고 파일 디스크립터(file descriptor)라는 작은 정수를 돌려준다. 0은 표준 입력, 1은 표준 출력, 2는 표준 에러다
read()파일 디스크립터에서 데이터를 읽어 버퍼에 담고, 실제로 읽은 바이트 수를 돌려준다. 0이면 파일 끝이다
write()버퍼의 데이터를 파일 디스크립터에 쓰고, 실제로 쓴 바이트 수를 돌려준다
close()파일 디스크립터를 닫는다
lseek()파일 안에서 다음에 읽거나 쓸 위치를 옮긴다

파일 디스크립터를 닫지 않고 계속 열기만 하면 프로세스가 열 수 있는 개수의 한도에 걸립니다. 이 한도는 시스템과 설정마다 다르므로 특정 숫자로 외우지 말고, 셸에서 ulimit -n으로 지금 프로세스의 값을 확인합니다.

read_hostname.c
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>

int main(void)
{
    int fd = open("/etc/hostname", O_RDONLY);
    if (fd < 0) {
        perror("open");
        return 1;
    }

    char buf[256];
    ssize_t n = read(fd, buf, sizeof(buf) - 1);
    if (n < 0) {
        perror("read");
        close(fd);
        return 1;
    }
    buf[n] = '\0';
    printf("읽은 바이트 수: %zd\n", n);
    printf("호스트 이름: %s", buf);

    close(fd);
    return 0;
}

이 프로그램에서 눈여겨볼 곳은 read의 반환값을 다루는 방식입니다. read(fd, buf, 255)는 최대 255바이트를 요청하는 것이지 반드시 255바이트를 읽는다는 뜻이 아닙니다. 호스트 이름 파일은 보통 수십 바이트이므로 그만큼만 읽히고, 이렇게 요청보다 적게 읽히는 것은 오류가 아닙니다. 반환값 0은 더 읽을 것이 없다는 파일 끝이고, -1일 때만 오류이므로 그때 errno를 확인합니다. 읽은 데이터 뒤에 '\0'을 붙이는 것은 read가 문자열 끝 표시를 넣어 주지 않기 때문이며, 그래서 버퍼 크기보다 1 작게 요청했습니다. 반환값의 의미는 read(2)에서 확인할 수 있습니다.

메모리를 늘리고 매핑하기

호출하는 일
mmap()파일이나 익명 메모리를 프로세스의 가상 주소 공간에 매핑한다
munmap()mmap으로 만든 매핑을 해제한다
brk()힙 영역의 끝을 옮겨 힙을 늘리거나 줄인다

C의 malloc은 시스템 콜이 아닙니다. malloc은 이미 확보한 힙 공간을 사용자 공간 안에서 쪼개 나누어 주고, 공간이 모자랄 때만 brk나 mmap으로 커널에 메모리를 더 요청합니다. 그래서 malloc을 천 번 불러도 시스템 콜은 몇 번만 일어날 수 있습니다. 이 내용은 8장에서 다룹니다.

printf와 write는 어떻게 다른가

printf()는 시스템 콜이 아니라 C 라이브러리 함수입니다. printf는 사용자 공간에서 서식 문자열을 해석해 결과를 라이브러리의 버퍼에 쌓아 두고, 버퍼를 비워야 할 때가 되면 그때 write 시스템 콜을 부릅니다. 반면 write()는 부를 때마다 커널에 들어가는 시스템 콜입니다. 둘을 구분하는 기준은 하나입니다. 그 함수가 커널 모드로 들어가는지입니다.

버퍼를 언제 비우는지는 출력 대상에 따라 다릅니다. C 표준 라이브러리에서 표준 출력이 터미널에 연결되어 있으면 보통 줄 단위로 버퍼링하므로 줄바꿈을 만날 때마다 write가 일어납니다. 파일이나 파이프로 리다이렉트하면 버퍼가 찰 때까지 모았다가 한꺼번에 씁니다. 그래서 printf("x\n")를 100번 부르는 프로그램은 터미널에서는 write를 100번 부르지만, 파일로 리다이렉트하면 200바이트가 버퍼 하나에 다 들어가므로 프로그램이 끝날 때 write를 한 번 부르는 것으로 끝날 수 있습니다. 표준 에러는 기본적으로 버퍼링하지 않습니다. 버퍼링 방식과 바꾸는 방법은 setvbuf(3)에 정리되어 있습니다.

Python의 print()도 같은 구조입니다. sys.stdout 문서에 따르면 대화형으로 실행할 때 표준 출력은 줄 단위로 버퍼링되고, 그렇지 않으면 블록 단위로 버퍼링됩니다. 그래서 출력을 파이프로 넘기는 Python 프로그램에서 메시지가 늦게 나타난다면, print(..., flush=True)나 sys.stdout.flush()로 버퍼를 비우거나 python -u로 버퍼링을 끌 수 있습니다.

이 차이는 버그로도 이어집니다. 버퍼에 출력이 남아 있는 상태에서 프로그램이 비정상적으로 종료되면, 아직 write되지 않은 출력은 사라집니다. 로그가 중간에서 끊겨 보인다면 마지막 줄이 실제로 실행되지 않은 것인지, 실행됐지만 버퍼에서 나가지 못한 것인지를 구분해야 합니다.

셸이 명령 하나를 실행하는 과정

셸(shell)은 사용자가 입력한 명령을 해석해 시스템 콜로 바꾸어 실행하는 프로그램입니다. bash나 zsh에서 ls를 입력하면 셸은 대략 다음 순서로 일합니다.

  1. 셸이 fork()로 자식 프로세스를 만듭니다.
  2. 자식 프로세스가 execve()로 /usr/bin/ls 프로그램을 실행합니다.
  3. ls는 opendir, readdir 같은 라이브러리 함수로 디렉터리를 읽습니다. Linux에서는 그 아래에서 openat, getdents64 같은 시스템 콜이 쓰입니다.
  4. ls가 결과를 write()로 표준 출력에 씁니다.
  5. ls가 끝나면 커널이 종료 상태를 보관합니다.
  6. 셸은 wait 계열 호출로 자식의 종료 상태를 거두고, 다시 프롬프트를 보여 줍니다.

파이프가 끼면 셸이 부르는 시스템 콜이 늘어납니다. ls | grep txt를 입력하면 셸은 먼저 pipe()로 한쪽 끝에 쓰면 다른 쪽 끝에서 읽을 수 있는 통로를 만듭니다. 그다음 자식을 두 개 만들어, 첫째 자식은 표준 출력을 파이프의 쓰기 쪽으로 바꾼 뒤 ls를 실행하고, 둘째 자식은 표준 입력을 파이프의 읽기 쪽으로 바꾼 뒤 grep을 실행합니다. 표준 입출력을 바꾸는 데는 dup2()가 쓰입니다. ls는 자기 출력이 파이프로 간다는 사실을 몰라도 되고, 그저 파일 디스크립터 1에 쓸 뿐입니다.

>와 <로 리다이렉트하는 것도, $?로 직전 명령의 종료 상태를 확인하는 것도 모두 이런 시스템 콜의 조합입니다. 셸은 이 조합을 사람이 읽고 쓰기 쉬운 문법으로 감싼 프로그램입니다. 다만 cd처럼 셸 자신의 상태를 바꾸는 내장 명령(builtin)은 자식 프로세스를 만들지 않고 셸 안에서 바로 처리합니다. 자식 프로세스가 디렉터리를 바꿔 봐야 셸에는 아무 영향이 없기 때문입니다.

더 깊이: strace에 fork가 보이지 않는 이유

셸에 strace -f를 붙여 명령 실행을 추적해 보면 fork라는 이름의 호출 대신 clone이나 clone3가 보입니다. glibc의 fork() 래퍼 함수는 커널의 fork 시스템 콜 대신 clone 계열 시스템 콜에 적절한 플래그를 주어 부르기 때문입니다. 이 사실은 fork(2)의 C 라이브러리 관련 설명에 적혀 있습니다. 마찬가지로 execlp 같은 exec 계열 함수는 모두 라이브러리 함수이고, 커널에 들어가는 호출은 execve 하나입니다. 라이브러리 함수 이름과 실제 시스템 콜 이름이 다를 수 있다는 점을 알아 두면 추적 결과를 읽을 때 헤매지 않습니다.

정리

  • CPU는 사용자 모드와 커널 모드를 나누어, 일반 프로그램이 장치와 다른 프로그램의 메모리를 직접 건드리지 못하게 합니다. 허락되지 않은 일을 시도하면 예외가 일어나 커널이 처리합니다.
  • 모놀리식 커널은 서비스를 모두 커널 모드에 두어 빠르지만 한 부분의 결함이 커널 전체에 퍼질 수 있고, 마이크로커널은 서비스를 사용자 모드 서버로 분리해 결함을 가두는 대신 메시지 전달 비용을 냅니다. 하이브리드 커널은 둘을 절충합니다.
  • 시스템 콜은 사용자 프로그램이 커널의 서비스를 요청하는 공식 통로입니다. x86-64 Linux에서는 레지스터에 번호와 인자를 넣고 syscall 명령으로 커널의 정해진 진입점에 들어갑니다.
  • 시스템 콜이 실패하면 커널은 음수 오류 번호를 돌려주고, glibc 래퍼가 이를 -1과 errno로 바꿉니다. read가 요청보다 적게 읽는 것은 오류가 아닙니다.
  • printf는 버퍼에 모았다가 write를 부르는 라이브러리 함수이고, 버퍼를 비우는 시점은 출력 대상에 따라 달라집니다.
  • 셸은 fork, execve, wait, pipe, dup2 같은 시스템 콜을 조합해 명령과 파이프라인을 실행합니다.

연습 문제

1. read의 세 가지 결과

크기 8을 요청한 read 호출이 세 번의 실행에서 각각 3, 0, -1을 반환했고, 마지막 경우의 errno는 EBADF였습니다. 각 결과를 어떻게 해석하고 다음에 무엇을 해야 할까요? 또 이 호출이 커널 모드에 들어갔다는 사실만으로 다른 프로세스가 실행됐다고 말할 수 있을까요?

해설 보기

3은 실제로 3바이트를 읽었다는 뜻이므로 버퍼의 앞 3바이트만 유효한 데이터로 다룹니다. 요청보다 적게 읽힌 것이므로, 더 필요하다면 이어서 다시 읽습니다. 0은 요청 크기가 0이 아닌 상황에서 파일 끝에 도달했다는 뜻이므로 읽기를 끝냅니다. -1과 EBADF는 파일 디스크립터가 유효하지 않거나 읽기용으로 열리지 않았다는 오류이므로, 디스크립터를 어디서 얻었는지와 열 때의 모드를 확인합니다.

커널 모드에 들어갔다는 사실만으로는 다른 프로세스가 실행됐다고 말할 수 없습니다. 같은 프로세스가 커널 코드를 실행하고 같은 프로세스로 돌아오는 것이 기본입니다. 다른 프로세스로 넘어가는 것은 데이터를 기다려야 해서 커널이 스케줄러를 부를 때처럼 별도의 일이 일어났을 때입니다.

2. printf 100번과 write 횟수

다음 프로그램을 터미널에서 그대로 실행할 때와 ./a.out > out.txt처럼 파일로 리다이렉트할 때, strace로 센 write 호출 횟수는 각각 어떻게 될지 예상하고 이유를 설명하세요. 프로그램은 printf를 100번 부르고, 한 번 부를 때마다 x와 줄바꿈의 두 바이트를 출력합니다.

#include <stdio.h>

int main(void)
{
    for (int i = 0; i < 100; i++)
        printf("x\n");
    return 0;
}
해설 보기

터미널에 연결된 표준 출력은 보통 줄 단위로 버퍼링되므로, 줄바꿈이 나올 때마다 버퍼를 비워 write가 100번 일어납니다. 파일로 리다이렉트하면 표준 출력이 블록 단위로 버퍼링됩니다. 출력 전체가 200바이트로 버퍼 하나에 들어가므로, main이 끝나고 라이브러리가 버퍼를 비울 때 write가 한 번 일어나는 것이 보통입니다. 정확한 버퍼 크기는 C 라이브러리와 파일 시스템에 따라 다르므로, 출력이 더 길다면 버퍼 크기만큼씩 나누어 여러 번 쓰게 됩니다. 이 차이 때문에 같은 프로그램이라도 출력 대상에 따라 시스템 콜 횟수와 속도가 달라집니다.

3. 파일 시스템 서버가 멈춘 마이크로커널

마이크로커널 운영체제에서 파일 시스템 서버가 잘못된 포인터 때문에 종료되었습니다. 커널과 다른 서버, 그리고 이 파일 시스템을 쓰던 응용 프로그램은 각각 어떤 영향을 받을까요? 같은 결함이 모놀리식 커널의 파일 시스템 코드에 있었다면 어떻게 달랐을지도 비교하세요.

해설 보기

파일 시스템 서버는 자기 주소 공간에서 실행되므로, 잘못된 포인터가 망가뜨릴 수 있는 것은 그 서버의 메모리뿐입니다. 커널과 네트워크 서버 같은 다른 서버는 계속 실행됩니다. 하지만 이 파일 시스템에 요청을 보내던 응용 프로그램은 응답을 받지 못하거나 오류를 받습니다. 서버를 다시 띄우더라도 진행 중이던 요청과 열려 있던 파일의 상태를 어떻게 되살릴지는 따로 설계해야 합니다.

모놀리식 커널이라면 파일 시스템 코드가 커널과 같은 주소 공간에서 실행되므로, 잘못된 포인터가 커널의 다른 자료구조를 덮어쓸 수 있습니다. 그러면 파일 시스템과 상관없는 기능까지 오동작하거나 커널 패닉으로 시스템 전체가 멈출 수 있습니다. 대신 평소에는 메시지 전달 없이 함수 호출로 처리하므로 비용이 적습니다.

다음 장의 컴퓨터 하드웨어 구조에서는 운영체제가 관리하는 CPU, 메모리, 입출력 장치가 어떻게 연결되어 있고, 커널이 각각을 어떻게 제어하는지 살펴봅니다.