시스템 콜
시스템 콜은 응용 프로그램이 스스로 할 수 없는 일을 운영체제의 커널에 맡기는 통로입니다. 파일을 읽는 일도, 다른 기계와 데이터를 주고받는 일도 프로그램이 직접 하지 않고 이 통로로 넘깁니다. 커널이 대신 해 주고 결과만 돌려줍니다.
쉽고 빠른 이해
프로그램이 커널에게 일을 맡기는 통로입니다. 파일에서 한 줄을 읽을 때 프로그램은 디스크를 직접 건드리지 않고 「이 파일에서 이만큼 읽어 달라」고 넘깁니다.
이게 없으면 아무 프로그램이나 장치와 남의 메모리를 직접 만질 수 있습니다. 한 군데가 잘못 돌면 운영체제까지 함께 무너집니다. 그래서 장치에 손대는 권한은 커널만 쥐고, 프로그램에는 부탁하는 길만 열어 두었습니다.
어떻게 도는가:
- 무엇을 부탁할지 번호로 정하고 인자를 정해진 곳에 놓습니다
- 전용 명령을 내리면 권한이 센 상태로 바뀌고 커널이 정한 진입점으로 넘어갑니다
- 커널이 인자를 살펴본 뒤 일을 하고, 권한을 되돌리며 값을 돌려줍니다
대가가 있습니다. 이 통로를 한 번 드나드는 비용이 프로그램 안에서 함수 하나를 부르는 것보다 큽니다. 같은 양을 옮기더라도 잘게 여러 번 부탁하면 느려집니다.
상세
관공서에서 서류를 떼는 모습을 떠올려 봅시다. 서류철이 쌓인 안쪽 방에는 민원인이 들어갈 수 없고, 창구에 정해진 신청서를 내면 직원이 대신 꺼내다 줍니다.
시스템 콜은 응용 프로그램이 자기 권한으로는 못 하는 일을 커널에 맡길 때 쓰는, 운영체제가 미리 정해 놓은 진입점입니다. 파일을 열고 읽는 일, 새 프로그램을 띄우는 일, 다른 기계와 데이터를 주고받는 일이 그런 일입니다. 응용 프로그램과 커널이 만나는 바탕 통로가 이것 하나입니다.
정해 놓았다는 말이 요점입니다. 프로그램은 커널의 아무 코드나 부를 수 없고, 커널이 내놓은 목록에 있는 것만 부탁할 수 있습니다. 목록에는 항목마다 고유한 번호가 하나씩 붙어 있고, 부탁할 때 대는 것이 그 번호입니다. 목록에 없는 일은 부탁할 방법 자체가 없습니다.
권한이 갈리는 경계
프로세서는 도는 코드의 권한을 여러 단계로 가릅니다. 가장 센 단계에서 도는 코드는 장치와 모든 메모리에 손댈 수 있고, 가장 약한 단계에서 도는 코드는 그것이 막혀 있습니다. 응용 프로그램의 코드는 언제나 약한 쪽에서 돕니다.
이 두 구역에는 이름이 있습니다. 응용 프로그램이 도는 약한 권한의 구역을 사용자 공간, 커널이 도는 센 권한의 구역을 커널 공간이라고 부릅니다. 이 문서는 뒤에서도 두 이름을 그대로 씁니다.
막힌 일을 하려면 커널 공간 쪽으로 넘어가야 합니다. 시스템 콜은 그 경계를 넘는 정해진 문이고, 넘어간 다음부터는 커널의 코드가 돕니다. 부른 쪽은 그동안 멈춰 서서 값이 돌아오기를 기다립니다.
flowchart TD
subgraph U["사용자 공간 · 약한 권한"]
A["응용 프로그램"]
end
subgraph K["커널 공간 · 센 권한"]
C["커널"]
end
A -->|"시스템 콜"| C
C --> H["장치와 메모리"]
A -.->|"직접 손대는 길은 막혀 있다"| H
라이브러리 함수와 가르는 선
코드에서 파일을 읽을 때 우리가 부르는 것은 대개 시스템 콜 자체가 아니라 표준 라이브러리의 함수입니다. 그 함수가 인자를 정해진 곳에 옮겨 놓고 대신 시스템 콜을 부릅니다. 이렇게 감싸 주는 함수를 래퍼 함수라고 합니다. 부르는 쪽에서 보면 보통 함수를 부르는 것과 똑같이 보입니다.
그래서 층이 둘이 아니라 셋입니다. 응용 프로그램이 래퍼 함수를 부르고, 래퍼 함수가 경계를 넘습니다. 앞 그림에서 응용 프로그램과 커널을 잇던 화살표의 가운데에 이 층이 들어갑니다.
flowchart TD
subgraph U["사용자 공간"]
A["응용 프로그램"]
W["래퍼 함수 · 표준 라이브러리"]
S["문자열을 이어 붙이는 함수"]
end
subgraph K["커널 공간"]
C["커널"]
end
A -->|"파일을 읽어 달라고 부른다"| W
A -->|"문자열을 이어 달라고 부른다"| S
S -.->|"커널에 안 가고 여기서 끝난다"| A
W -->|"번호와 인자를 레지스터로 옮기고 전용 명령"| C
C -->|"음수 오류 번호 · 래퍼가 오류 변수를 세운다"| W
이름이 같은 경우가 많지만 언제나 같지는 않습니다. 감싸는 함수가 아주 얇아서 인자를 옮기고 오류를 받아 적는 것 말고는 하는 일이 거의 없는 경우도 있습니다.
둘을 가르는 시험은 하나입니다. 커널로 넘어가느냐 입니다. 문자열을 이어 붙이거나 숫자를 세는 함수는 프로그램 안에서 끝나고, 파일과 네트워크를 건드리는 함수는 어딘가에서 커널로 넘어갑니다.
배경
권한이 센 쪽에서 도는 코드는 서로 격리되지 않습니다. 그쪽 코드는 하나의 주소 공간을 함께 쓰기 때문에, 한 군데가 엉뚱한 주소에 쓰기만 해도 운영체제나 다른 코드의 데이터가 망가집니다. 그쪽에서 무엇 하나가 죽으면 기계 전체가 함께 죽습니다.
응용 프로그램은 그래서 반대로 다룹니다. 프로그램마다 사적인 주소 공간을 받고, 그 공간은 서로 볼 수 없습니다. 그래서 한 프로그램이 죽어도 다른 프로그램과 운영체제는 멀쩡합니다. 프로세서는 약한 권한으로 도는 코드가 센 권한의 영역에 손대려 하면 예외를 냅니다.
그런데 응용 프로그램도 장치를 써야 합니다. 화면에 글자를 내보내는 일조차 장치를 건드리는 일입니다. 권한을 내주지 않으면서 일은 되게 하려면, 권한 단계를 넘나드는 길이 통제된 형태로 하나 있어야 합니다. 아무 주소로나 뛰어드는 것이 아니라 정해진 진입점으로만 들어가고, 들어가는 순간 권한 단계가 정해진 값으로 바뀌는 길입니다. 그 길로 커널에게 일을 부탁하는 것을 시스템 콜이라고 부릅니다.
예시
이름 있는 호출 하나를 보는 것이 빠릅니다. 파일에서 데이터를 읽는 호출 하나를 세 곳에서 나란히 놓습니다 — 리눅스 매뉴얼, 이식 가능 운영체제 인터페이스(POSIX, Portable Operating System Interface) 표준, 그리고 윈도우입니다. 읽는 것은 시그니처와 결과를 돌려주는 방식입니다.
파일을 읽는 호출
ssize_t read(int fd, void buf[.count], size_t count);
열려 있는 파일을 가리키는 번호와 받을 버퍼, 그리고 최대 몇 바이트를 읽을지를 넘깁니다.
성공하면 읽은 바이트 수가 돌아오고, 0 이면 파일의 끝이라는 뜻입니다. 이 수가 부탁한 수보다 작아도 오류가 아닙니다. 지금 당장 그만큼이 없거나, 파이프나 단말에서 읽고 있거나, 읽는 중에 시그널이 끼어들면 그럴 수 있습니다. 실패하면 -1 이 돌아오고 무엇이 잘못됐는지는 오류 번호로 따로 받습니다.
오류 번호에는 각자 이름과 뜻이 정해져 있습니다. 넘긴 버퍼가 그 프로그램이 손댈 수 있는 주소
공간 밖이면 EFAULT 가 오고, 데이터를 한 바이트도 읽기 전에 시그널이 끼어들었으면 EINTR 이
옵니다.
표준이 정하는 같은 호출
ssize_t read(int fildes, void *buf, size_t nbyte);
POSIX 의 시스템 인터페이스 문서가 적은 같은 함수입니다. 인자 이름만 다르고 모양은 같습니다.
표준도 매뉴얼과 같은 모양을 적습니다. 이 문서에서 적합성을 따지는 대목은 네 절입니다. 시그니처와 설명, 반환값, 그리고 오류입니다.
같은 문서가 못 박지 않은 것도 있습니다. 같은 파이프나 단말을 여럿이 동시에 읽을 때 무슨 일이 벌어지는지는 정해 두지 않았습니다.
계열이 다른 쪽의 같은 일
NTSTATUS NtReadFile(
HANDLE FileHandle, HANDLE Event, PIO_APC_ROUTINE ApcRoutine, PVOID ApcContext,
PIO_STATUS_BLOCK IoStatusBlock, PVOID Buffer, ULONG Length,
PLARGE_INTEGER ByteOffset, PULONG Key );
윈도우의 네이티브 계층에 있는, 열린 파일에서 데이터를 읽는 함수입니다. 앞의 둘과 견주면 이름도 인자 수도 다르고, 읽은 바이트 수를 반환값으로 돌려주지도 않습니다. 반환값은 상태 코드이고 읽은 바이트 수는 따로 넘긴 구조체의 한 칸에 담겨 돌아옵니다.
같은 일에 붙는 이름과 규약이 계열마다 다르다는 것이 이 예시의 요점입니다.
동작
한 번의 호출이 도는 단계를 봅니다. 대상은 응용 프로그램이 파일을 읽어 달라고 부탁하는 한 번의 호출이고, 보는 것은 단계의 순서와 중간에 생기는 갈림입니다.
결과부터 적으면 이렇습니다. 부른 쪽은 숫자 하나를 받고 다음 줄로 넘어갑니다. 그 사이에 권한 단계가 두 번 바뀌고, 커널이 인자를 살펴본 뒤 일을 합니다.
sequenceDiagram
participant 앱 as 응용 프로그램
participant 커널
participant 장치
앱->>커널: 번호와 인자를 놓고 전용 명령
Note over 앱,커널: 권한이 센 단계로 바뀐다
커널->>장치: 대신 읽는다
장치-->>커널: 데이터
커널-->>앱: 반환값 · 실패하면 오류 번호
Note over 앱,커널: 권한이 약한 단계로 되돌아간다
번호와 인자를 놓는 규약
부탁을 넘기는 방법은 보통 함수 호출과 다릅니다. 부를 곳의 주소로 뛰는 것이 아니라, 무엇을 부탁할지를 번호로 정해 약속된 레지스터에 놓고 인자도 약속된 레지스터에 나눠 놓습니다. 레지스터는 프로세서 안에 있는 아주 작은 저장 칸입니다.
어느 칸을 쓰는지와 어떤 명령을 내리는지는 아키텍처마다 다릅니다.
| 아키텍처 | 넘어가는 명령 | 번호를 놓는 칸 | 인자를 놓는 칸 |
|---|---|---|---|
| x86-64 | syscall |
rax |
rdi · rsi · rdx · r10 · r8 · r9 |
| arm64 | svc #0 |
w8 |
x0 · x1 · x2 · x3 · x4 · x5 |
| riscv | ecall |
a7 |
— |
| i386 | int $0x80 |
eax |
ebx · ecx · edx · esi · edi · ebp |
번호는 사람이 외우는 값이 아니라 헤더 파일에 이름으로 정의되어 있습니다. 감싸는 함수가 하는 일을 풀면 대개 셋입니다. 인자와 번호를 커널이 기대하는 레지스터로 옮기고, 커널 쪽으로 넘어가는 명령을 내고, 돌아온 값이 오류이면 오류 변수를 세워 줍니다.
넘어간 뒤에 커널이 하는 일
넘어가는 명령이 프로세서 안에서 무엇을 하는지는 계열마다 다릅니다. 인텔 프로세서를 예로 들면 특권 수준을 0 부터 3 까지 넷으로 가르고, 숫자가 작을수록 권한이 셉니다. 커널 코드가 도는 곳이 0 이고 응용 프로그램이 도는 곳이 3 입니다.
이 계열이 시스템 콜에 쓰라고 둔 SYSENTER · SYSEXIT 한 쌍은 들어갈 때 특권 수준 0, 나올
때 특권 수준 3 이라는 미리 정해진 상태로 프로세서를 강제합니다. 프로세서 상태가 미리
정해져 있고 언제나 같기 때문에, 다른 특권 수준으로 건너뛰는 일반적인 호출에 본래 필요한 권한
검사의 수가 크게 줄어듭니다. 그래서 이 한 쌍을 두고 「빠른」 호출이라고 적습니다.
넘어간 뒤로는 커널이 운전합니다. 커널은 넘겨받은 번호로 어느 일인지 찾고, 인자를 살펴본 다음
그 일을 합니다. 살펴본다는 대목을 지나치기 쉽습니다. 부탁하는 쪽은 믿을 수 없는 코드라서,
넘어온 주소가 그 프로그램의 것이 맞는지부터 커널이 봅니다. 아니면 일을 하지 않고 EFAULT 로
돌려보냅니다. 번호가 목록에 없을 때도 마찬가지로 ENOSYS 가 돌아옵니다.
돌아올 때 커널이 내놓는 것은 숫자 하나입니다. 오류일 때는 음수인 오류 번호를 돌려주고, 감싸는 함수가 그 절댓값을 오류 변수에 옮겨 담은 뒤 부른 쪽에는 -1 을 줍니다. 그래서 응용 프로그램이 보는 모양과 커널이 내놓는 모양이 다릅니다.
커널까지 안 가는 갈림
모든 부탁이 경계를 넘는 것은 아닙니다. 커널은 모든 응용 프로그램의 주소 공간에 작은 공유 라이브러리를 자동으로 얹어 둡니다. 이것을 가상 동적 공유 객체(vDSO, virtual dynamic shared object)라고 부릅니다. 표준 라이브러리가 대신 불러 주기 때문에 응용 프로그램이 이 이름을 의식할 일은 대개 없습니다.
여기에 실리는 것은 자주 불리면서 감출 것이 없는 종류입니다. 지금 몇 시냐고 묻는
gettimeofday 가 그런 종류입니다. 누가 물어도 답이 같고, 권한이 있든 없든 같은 답을 받습니다.
그래서 커널이 그 답에 필요한 정보를 프로세스가 읽을 수 있는 메모리에 미리 놓아 둡니다. 그러면
그 호출은 경계를 넘는 일에서 보통 함수 호출과 메모리 읽기 몇 번으로 바뀝니다.
flowchart TD
A["프로그램이 부탁한다"] --> B{"vDSO 가<br/>답할 수 있나"}
B -->|"그렇다"| C["사용자 공간에서 끝난다"]
B -->|"아니다"| D["전용 명령으로 경계를 넘는다"]
D --> E["커널이 인자를 살펴보고 일을 한다"]
E --> F["권한을 되돌리고 값을 돌려준다"]
대가
경계를 넘는 일은 공짜가 아닙니다. 옛 32비트 x86 이 쓰던 소프트웨어 인터럽트 방식(int $0x80)
은 프로세서 안쪽의 처리 경로와 커널의 인터럽트 처리 경로를 모두 지나기 때문에 비용이 큽니다.
그래서 새 프로세서는 앞 절에서 본 전용 명령처럼 더 빠른 길을 따로 두었습니다. 그래도 빠른
길조차 공짜는 아닙니다.
한 번에 얼마가 드는지는 알려진 숫자가 없습니다(미확인).
비용이 문제가 되는 것은 한 번이 아니라 횟수 때문입니다. 자주 불리는 종류는 호출 빈도와 경계를 드나드는 비용이 겹쳐서 프로그램 전체의 성능을 좌우하는 데까지 갑니다. 어떤 하드웨어에서는 예전만큼 싸지도 않습니다 — 스펙터(Spectre)와 멜트다운(Meltdown)이라는 프로세서 취약점을 막으려고 운영체제가 넣은 우회 수단 가운데 일부가 하필 이 경계에 걸려 있습니다.
횟수를 줄이는 쪽으로 도구가 생긴다
이 비용을 피하려고 만들어진 것이 여럿입니다. 세 갈래로 갈립니다.
| 갈래 | 맡는 것 | 어떻게 줄이나 |
|---|---|---|
| 한 번에 여러 버퍼 | readv · writev |
흩어진 버퍼 여럿을 한 호출로 채우거나 내보냅니다 |
| 여러 부탁을 한 번에 | io_uring | 요청을 여러 개 쌓아 두고 한 번만 부탁합니다 |
| 아예 안 부른다 | io_uring · 커널이 고리를 직접 훑는 설정 | 부탁을 쌓아 두는 칸을 커널이 직접 들여다봅니다 |
둘째 갈래를 맡는 io_uring 은 리눅스에만 있는, 비동기 입출력을 위한 인터페이스입니다. 사용자 공간과 커널 공간이 함께 쓰는 고리 모양 버퍼로 요청을 주고받습니다.
보통은 요청 하나를 넣을 때마다 호출이 한 번씩 나갑니다. io_uring 에서는 요청 여럿을 고리에
쌓아 두고 io_uring_enter 를 한 번만 부르면 됩니다.
셋째 갈래는 그 한 번마저 없앱니다. 요청을 쌓아 두는 고리를 커널이 직접 훑게 켜 두면
(io_uring 문서가 부르는 이름은 submission queue polling 입니다) io_uring_enter 를 부를
필요가 없습니다. 이것도 리눅스의 io_uring 에만 있는 방식입니다.
flowchart TD
subgraph U["사용자 공간"]
R1["요청 1"]
R2["요청 2"]
R3["요청 3"]
R4["요청 4"]
end
subgraph R["두 구역이 함께 쓰는 고리 버퍼"]
Q["쌓인 요청"]
end
subgraph K["커널 공간"]
S["가져다 처리한다"]
end
R1 --> Q
R2 --> Q
R3 --> Q
R4 --> Q
Q -->|"경계를 넘는 것은 한 번 · 커널이 직접 훑으면 그 한 번도 없다"| S
모아 두었다가 넘기는 쪽의 대가
표준 입출력 라이브러리도 같은 일을 합니다. 쓴 내용을 바로 넘기지 않고 메모리에 모아 두었다가 덩어리로 넘깁니다. 모으는 방식은 셋으로 갈립니다. 모으지 않고 하나씩 곧바로 넘기거나, 줄바꿈을 만날 때마다 넘기거나, 적당한 크기의 덩어리가 될 때까지 모읍니다.
새로 연 것은 보통 마지막 방식입니다. 예외가 하나 있어서 단말처럼 사람이 보고 있는 장치에 붙으면 줄 단위로 넘깁니다. 이 때문에 라이브러리 함수를 백 번 불러도 경계를 넘는 일은 몇 번만 일어나는 경우가 흔합니다. 무엇을 세느냐가 달라서 숫자가 어긋나 보이는 것입니다.
flowchart TD
subgraph A["응용 프로그램"]
C1["쓰기 함수 1회"]
C2["쓰기 함수 2회"]
C3["쓰기 함수 100회"]
end
subgraph L["표준 라이브러리"]
B["모아 두는 칸"]
end
subgraph K["커널"]
S["대신 쓴다"]
end
C1 --> B
C2 --> B
C3 --> B
B -->|"경계를 넘는 것은 몇 번"| S
여기에 비용이 붙습니다. 모아 둔다는 것은 아직 안 넘어간 데이터가 프로그램 쪽 메모리에 남아
있다는 뜻입니다. 줄바꿈으로 끝나지 않은 출력은 곧바로 보일 수도 있고 안 보일 수도 있습니다.
바로 보이게 하려면 쌓인 것을 지금 넘기라고 fflush 로 따로 시켜야 합니다. 스트림을 닫을 때도
안 넘긴 것을 먼저 내보내고 끊습니다.
거꾸로 잘게 나누는 쪽에도 비용이 있습니다. 하나씩 곧바로 넘기면 남아 있는 데이터가 없는 대신 경계를 넘는 횟수가 그만큼 늘어납니다. 어느 쪽을 고를지는 잃으면 안 되는 데이터인지, 처리량이 앞서는지에 따라 갈립니다.
관련 항목
시스템 콜이 넘나드는 두 구역과 그 경계
커널 · 사용자 공간 · 커널 공간 · 사용자 모드와 커널 모드 · 특권 모드 · 특권 수준 · 권한 · 프로세서
시스템 콜과 같은 문으로 커널에 들어가는 통로
트랩 · 인터럽트 · 예외 · 소프트웨어 인터럽트 · 문맥 교환 · vDSO
시스템 콜을 부를 때 지켜야 하는 규약
시스템 콜 번호 · 호출 규약 · 레지스터 · 스택 · 시스템 콜 테이블
시스템 콜을 감싸 응용 프로그램에 내주는 계층
표준 라이브러리 · glibc · 래퍼 함수 · API · ABI · 런타임 · ntdll
시스템 콜로 부탁하는 대표 작업
파일 입출력 · 프로세스 생성 · 메모리 할당 · 소켓 · 시그널 · 프로세스 간 통신
시스템 콜 하나하나에 해당하는 호출
read · write · open · close · fork · execve · mmap · ioctl · readv · NtReadFile
시스템 콜이 실패했을 때 돌려주는 값
errno · EAGAIN · EINTR · ENOSPC · EFAULT · ENOSYS · 반환값
시스템 콜 횟수를 줄이려고 쓰는 수단
버퍼링 · 배치 처리 · epoll · io_uring · 벡터 입출력 · 제로 카피
시스템 콜을 들여다보는 도구
strace · ltrace · seccomp · eBPF · 감사 로그
시스템 콜 이름과 동작을 정하는 표준
POSIX · 유닉스 · Linux · Windows · 운영체제
시스템 콜이 넘나들며 다루는 대상
다른 이름: 시스템콜 · system call · syscall