운영체제와 커널 — 커널은 프로세스가 아니다
커널을 설명할 때 자주 등장하는 그림이 있다. 시스템 어딘가에서 항상 돌아가면서 프로세스들을 내려다보고, 누가 선을 넘으면 개입하는 관리자. 프로그램이 파일을 읽고 싶으면 커널에게 부탁하고, 커널이 대신 처리해서 결과를 돌려준다는 그림이다.
절반은 맞고 절반은 틀렸다. 커널이 자원을 중재한다는 건 맞지만, 커널은 "돌고" 있지 않다. 감시자도 아니고, 부탁을 받는 별도의 주체도 아니다. 이 글은 그 그림을 뒤집는 데 대부분을 쓴다.
전체를 관통하는 질문은 하나다.
커널 코드는 대체 언제, 누구의 시간으로 실행되는가?
이 질문에 제대로 답하려면 커널이 메모리의 어디에 놓여 있는지부터 봐야 한다. 그래서 순서는 이렇게 간다 — 커널의 경계가 어디인가(정의) → 왜 커널은 모든 프로세스의 주소 공간에 함께 매핑되는가(이유) → 그래서 커널은 언제 실행되는가(판단).
커널의 경계선을 어디에 그을 것인가(모놀리식이냐 마이크로커널이냐)는 이 글의 주제가 아니다. 후속 글 커널 아키텍처에서 다룬다. 여기서는 경계가 이미 그어져 있다고 보고, 그 경계가 무엇을 의미하는지만 파고든다.
1. 정의 — 커널의 경계
1-1. 운영체제 ⊃ 커널
한 문장으로 먼저 정리한다.
운영체제는 하드웨어를 추상화해 여러 프로그램이 안전하게 공유하도록 중재하는 소프트웨어 묶음이고, 커널은 그중 특권 모드에서 실행되는 핵심부다.
핵심은 포함 관계다. 운영체제는 커널보다 크다. 우리가 "운영체제"라고 부르며 쓰는 것들 중 상당수는 커널이 아니다.
경계를 가르는 기준은 딱 하나, 특권 모드(CPL=0)에서 실행되는가이다. 이 기준으로 나누면 이렇게 된다.
| 커널인 것 | 커널이 아닌 것 |
|---|---|
| 프로세스 스케줄러 | 셸 (bash, zsh) |
| 메모리 관리자 (페이지 테이블, 페이지 폴트 처리) | ls, cp, grep 같은 유틸리티 |
| VFS와 파일 시스템 구현 (ext4, xfs …) | libc (glibc, musl) |
| 장치 드라이버 | systemd, sshd 같은 데몬 |
| TCP/IP 스택 | GUI (X11, Wayland 컴포지터) |
오른쪽 열은 전부 유저 모드에서 도는 평범한 프로세스다. 특권을 갖지 않고, 커널이 정해준 문(시스템 콜)으로만 커널에 들어간다. 그 문의 존재 자체는 이 글의 결론이 되니 일단 접어두자.
이름 문제도 여기서 정리해두면 편하다. 리눅스는 커널의 이름이다. 우분투·데비안·페도라는 그 커널 위에 셸·유틸리티·패키지 매니저·데스크톱 환경을 얹어 묶은 배포판이다. "리눅스를 설치했다"고 말할 때 실제로 설치한 것의 대부분은 커널이 아니라 그 위에 얹힌 유저 공간 프로그램들이다.
1-2. 오해 정정 ① — 셸과 libc는 커널이 아니다
이 오해는 대체로 셸에 대한 설명에서 시작된다. "셸은 사용자의 명령어를 해석해서 커널에 전달하는 계층"이라는 문장은 어디에나 있는데, 이 문장이 셸을 커널로 가는 특별한 통로처럼 들리게 만든다.
그런 통로는 없다. 셸은 그냥 유저 프로세스다. 터미널에 ls를 치면 셸이 하는 일은 이게 전부다.
pid_t pid = fork(); // 자기 자신을 복제한다
if (pid == 0) {
execve("/bin/ls", argv, envp); // 복제본을 ls로 갈아끼운다
}
waitpid(pid, &status, 0); // 끝날 때까지 기다린다
fork(), execve(), waitpid(). 셸이 쓰는 시스템 콜은 우리가 직접 짠 C 프로그램이 쓸 수 있는 것과 완전히 동일하다. 셸에게만 허락된 특권 명령도, 셸 전용 커널 인터페이스도 없다. 셸이 특별해 보이는 건 그것이 로그인 직후 우리 앞에 놓이는 프로그램이기 때문이지, 계층 구조상 커널에 더 가깝기 때문이 아니다.
같은 오해가 libc에도 붙는다. printf()는 커널 함수가 아니다. 호출하면 이런 순서로 내려간다.
printf()— libc 함수. 포맷 문자열(%d,%s)을 파싱하고, 인자를 문자열로 변환해서, 내부 버퍼에 쌓는다. 여기까지 커널은 전혀 관여하지 않는다.- 버퍼가 차거나 개행을 만나면 libc가
write(1, buf, n)를 호출한다. 그런데 이write()도 커널 함수가 아니라 libc가 제공하는 래퍼 함수다. - 래퍼가 시스템 콜 번호와 인자를 정해진 레지스터에 세팅하고,
syscall명령어를 실행한다. - 여기서 비로소 모드가 바뀐다. CPU가 특권 모드로 올라가고
sys_write()가 실행된다.
printf("hi") 한 줄에서 실제로 커널에 닿는 부분은 4번뿐이고, 1~3은 전부 유저 공간이다. printf가 느리다고 느낄 때 그 비용의 상당 부분도 커널이 아니라 포맷 파싱과 버퍼 처리에서 나온다.
정리하면 이렇다. 커널이냐 아니냐를 가르는 것은 "OS와 함께 설치되었는가"도, "시스템스러운 이름을 가졌는가"도 아니다. 특권 모드에서 실행되는가뿐이다.
1-3. 경계를 눈으로 확인하기
말로만 하면 추상적이니 실제로 경계를 관찰해보자. 리눅스에서 strace는 프로세스가 호출한 시스템 콜을 가로채 출력해준다. 즉 strace에 찍히는 줄이 곧 "커널에 들어간 순간"의 목록이다.
Node.js로 파일 하나를 읽는 프로그램을 준비한다.
// app.js
const fs = require('fs');
const text = fs.readFileSync('/app/a.txt', 'utf8');
console.log(text.length);
JS에서는 한 줄이지만, 이 한 줄이 커널 경계를 몇 번 넘는지 보자.
$ strace -f -e trace=openat,fstat,read,close node app.js
a.txt와 관련된 부분만 추리면 대표적으로 이런 형태가 나온다.
openat(AT_FDCWD, "/app/a.txt", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=12, ...}) = 0
read(3, "hello world\n", 12) = 12
read(3, "", 4096) = 0
close(3) = 0
읽어보면 이렇다.
openat— 파일을 열고 파일 디스크립터3을 받는다.0,1,2는 표준 입출력이 이미 쓰고 있다.fstat— 파일 크기(st_size=12)를 먼저 묻는다.readFileSync는 전체를 한 번에 읽어야 하므로 버퍼를 얼마나 잡을지 알아야 한다.read— 실제로 12바이트를 읽는다. 두 번째read가0을 반환하는 건 파일 끝(EOF) 확인이다.close— 디스크립터를 반납한다.
여기서 경계가 선명해진다. fs.readFileSync라는 함수 자체, 인자 검증, 버퍼 할당, utf8 디코딩은 전부 유저 공간에서 일어난다. 커널에 들어간 건 위 다섯 줄뿐이고, 나머지 시간은 전부 Node.js와 libc의 시간이다. console.log도 마찬가지로 결국 write(1, ...) 한 줄로 귀결된다.
호출 횟수만 세려면 -c를 쓴다.
$ strace -f -c node app.js
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
31.24 0.008112 19 412 18 futex
18.07 0.004693 7 611 mmap
11.35 0.002948 9 311 mprotect
9.62 0.002498 6 385 92 openat
7.71 0.002002 4 441 read
...
0.11 0.000029 5 5 close
------ ----------- ----------- --------- --------- ----------------
100.00 0.025974 3184 137 total
우리가 짠 건 세 줄인데 시스템 콜은 3천 번 넘게 나간다. 대부분은 런타임 초기화(모듈 탐색, 메모리 매핑)에서 나오는 것이고, 여기서 봐야 할 건 정확한 숫자가 아니라 규모다. 시스템 콜은 프로그램 하나가 켜지는 동안에도 수천 번 일어나는 아주 흔한 일이다. 이 사실이 2절의 설계 결정을 그대로 결정한다.
2. 이유 — 왜 커널은 모든 프로세스의 주소 공간에 매핑되는가
2-1. 커널은 별도 공간이 아니라 모든 지도의 윗부분이다
64bit 리눅스에서 각 프로세스는 자기만의 가상 주소 공간을 갖는다. 그 공간은 상하로 나뉘어 있다.
- 아래쪽 (
0x0000_0000_0000_0000~0x0000_7FFF_FFFF_FFFF) — 유저 공간. Code / Data / Heap / Stack이 여기 놓인다. - 위쪽 (
0xFFFF_8000_0000_0000~0xFFFF_FFFF_FFFF_FFFF) — 커널 공간. 커널 코드, 커널 자료구조, 드라이버가 여기 놓인다. - 가운데는 거대한 구멍이다. 현재 하드웨어가 48비트만 실제로 쓰기 때문에 생긴 사용 불가 영역이다.
여기서 중요한 건 배치가 아니라 관계다. 프로세스 A와 B의 아래쪽 절반은 서로 완전히 다른 물리 메모리를 가리킨다. 그것이 격리다. 그런데 위쪽 절반은 A와 B가 완전히 동일하다. 같은 물리 커널을 가리키는 같은 매핑이 모든 프로세스의 지도 윗부분에 똑같이 복사되어 있다.
가상 주소 공간과 페이지 테이블 자체의 구조, 페이지 폴트 처리 과정은 가상 메모리에서 다룬다. 이 글은 "왜 커널이 이렇게 매핑되는가"를 설명하는 데 필요한 만큼만 CR3·TLB·U/S 비트를 꺼내 쓴다.
2-2. 설계 A vs 설계 B — 왜 이렇게 되었나
이 배치는 당연한 게 아니다. 다른 선택지가 있었고, 그게 왜 탈락했는지를 봐야 왜 이 구조인지가 보인다.
설계 A — 커널이 자기만의 주소 공간을 갖는다.
직관적이다. 커널은 커널대로 독립된 지도를 갖고, 프로세스는 프로세스대로 갖는다. 격리도 더 확실해 보인다. 문제는 비용이다. read() 한 번을 처리하려면,
- 커널로 들어가면서 CR3(현재 페이지 테이블을 가리키는 레지스터)를 커널 것으로 교체
- 처리
- 돌아오면서 CR3를 원래 프로세스 것으로 되돌림
read() 하나에 주소 공간 교체가 두 번이다. 프로세스를 바꾼 것도 아닌데 컨텍스트 스위칭에 준하는 비용이 든다.
설계 B — 커널을 모든 프로세스의 주소 공간에 함께 매핑한다.
커널로 들어갈 때 지도를 바꾸지 않는다. 이미 지도 윗부분에 커널이 그려져 있으므로, 바꿀 것은 권한 비트뿐이다. CPL을 3에서 0으로 올리면 그 순간부터 윗부분에 접근할 수 있게 된다.
| 설계 A (별도 주소 공간) | 설계 B (공유 매핑) | |
|---|---|---|
| 시스템 콜 1회당 CR3 교체 | 2회 | 0회 |
| TLB 상태 | 두 번 무효화 | 그대로 유지 |
| 격리 | 주소 공간 자체가 분리 | 페이지 권한 비트로 분리 |
| 진입 비용 | 컨텍스트 스위칭급 | 함수 호출에 가까움 |
1-3절에서 본 숫자가 여기서 의미를 갖는다. 시스템 콜은 프로그램 하나가 켜지는 동안에도 수천 번, 부하가 걸린 서버에서는 초당 수만~수십만 번 일어난다. 이 빈도에서 매번 컨텍스트 스위칭급 비용을 내는 건 감당이 안 된다. 그래서 거의 모든 현대 OS가 설계 B를 택했다.
2-3. 오해 정정 ② — CR3 교체가 비싼 진짜 이유
여기서 흔히 미끄러지는 지점이 있다. "CR3 교체가 비싸다"는 말을 CR3에 값을 쓰는 동작 자체가 비싸다는 뜻으로 읽는 경우다.
그렇지 않다. CR3에 값을 쓰는 것 자체는 싸다. 레지스터 하나에 값을 넣는 명령이다.
비싼 건 그 뒤에 따라오는 일이다. CR3가 바뀌면 지금까지 TLB에 캐시해둔 "가상 주소 → 물리 주소" 번역 결과들이 이전 프로세스 기준의 답이 된다. 그걸 그대로 두면 A의 번역 결과로 B의 메모리를 읽게 되고, 격리가 근본부터 깨진다. 그래서 CPU는 CR3가 바뀌는 순간 TLB를 강제로 비운다.
문제는 그 다음이다. TLB가 비었으니 이후의 메모리 접근은 전부 TLB 미스로 시작한다. 미스가 나면 CPU가 페이지 테이블을 직접 걸어 내려가며 번역을 다시 계산해야 하고, 그 과정에서 메모리를 여러 번 읽는다. 그리고 이게 한두 번이 아니라 이후 수백~수천 번의 메모리 접근에 걸쳐 일어난다.
그래서 이 비용은 이런 성질을 갖는다.
청구서가 한 번에 오지 않는다. 나중에 잘게 쪼개져서, 이후 실행되는 모든 코드에 조금씩 나눠 붙는다.
프로파일러를 켜도 "CR3 교체" 같은 항목으로 잡히지 않는 이유가 이것이다. 비용이 특정 함수 한 곳에 뭉쳐 있지 않고, 스위칭 이후 실행된 코드 전반에 얇게 퍼져 있다.
2-4. 오해 정정 ③ — TLB 미스와 데이터 캐시 미스는 다르다
"컨텍스트 스위칭하면 캐시가 날아간다"는 설명도 자주 보이는데, 이 문장은 두 개의 서로 다른 것을 뭉뚱그린다.
| TLB | L1 / L2 / L3 데이터 캐시 | |
|---|---|---|
| 캐시하는 것 | 가상 → 물리 주소 번역 결과 | 실제 데이터 |
| 태그 기준 | 가상 주소 (프로세스마다 의미가 다름) | 물리 주소 (프로세스와 무관) |
| 컨텍스트 스위칭 시 | 강제로 비워진다 | 비워지지 않는다 |
| 느려지는 원인 | flush 후 재번역 | 오염(pollution)에 의한 자연스러운 eviction |
핵심은 태그 기준의 차이다. TLB는 가상 주소를 키로 쓰는데, 같은 가상 주소가 프로세스마다 다른 물리 주소를 뜻하므로 프로세스가 바뀌면 기존 내용이 틀린 답이 된다. 그래서 버려야 한다.
반면 데이터 캐시는 물리 주소로 태그를 단다. 물리 주소 0x1234_5000의 내용은 어떤 프로세스가 실행 중이든 똑같은 내용이다. 의미가 변하지 않으니 버릴 이유가 없고, 실제로 버리지 않는다.
그런데도 스위칭 직후 코드가 느린 건 사실이다. 이유는 flush가 아니라 오염이다. 새로 올라온 프로세스 B가 자기 데이터를 캐시에 채우기 시작하면, 자리가 부족해지면서 A가 쌓아둔 데이터가 밀려난다. 나중에 A가 다시 실행될 때는 캐시가 이미 B의 데이터로 상당 부분 덮여 있다.
강제로 비워진 것(TLB)과 밀려나서 사라진 것(데이터 캐시)은 구분해서 말해야 한다. 결과는 둘 다 "느려짐"이지만 원인도 다르고, 뒤에서 볼 대응책도 다르다.
캐시 계층과 일관성 자체는 캐시 히트와 일관성, TLB의 동작은 TLB에서 이어진다.
2-5. Global 비트 — 무엇을 위한 장치인가
커널 매핑에는 특별한 표시가 하나 붙는다. 페이지 테이블 항목의 Global 비트다.
커널 영역은 어차피 모든 프로세스에서 동일한 매핑이다. 그러니 CR3가 바뀌었다고 해서 커널 매핑의 번역 결과까지 버릴 이유가 없다. Global 비트가 켜진 항목은 CR3 교체 시의 TLB 무효화 대상에서 제외된다. 덕분에 컨텍스트 스위칭 직후에도 커널 코드 실행만큼은 여전히 빠르다.
여기서 반드시 구분해야 할 게 있다.
Global 비트는 컨텍스트 스위칭을 위한 장치이지, 시스템 콜이 싼 이유가 아니다.
시스템 콜이 싼 이유는 Global 비트가 커널 매핑을 지켜줘서가 아니다. 애초에 CR3를 바꾸지 않아서 무효화가 일어날 일 자체가 없기 때문이다. 무효화가 없는 것과, 무효화는 일어나되 커널 항목만 면제받는 것은 전혀 다른 이야기다. 두 상황을 정리하면 이렇다.
- 시스템 콜 — CR3 교체 없음 → TLB 무효화 자체가 발생하지 않음 → 유저 매핑도 커널 매핑도 전부 생존
- 컨텍스트 스위칭 — CR3 교체 있음 → TLB 무효화 발생 → Global이 아닌 항목(= 유저 매핑) 전멸, 커널 매핑만 생존
2-6. 그런데 왜 유저는 커널 주소를 못 읽는가
같은 지도에 그려져 있다면 유저 코드가 커널 주소를 그냥 읽어버릴 수도 있는 것 아닌가? 실제로 시도해보면 즉시 죽는다.
막는 것은 페이지 테이블 항목의 U/S 비트(User/Supervisor) 다. 커널 영역 페이지들은 이 비트가 S로 표시되어 있고, CPL=3(유저 모드)에서 그 주소에 접근하면 MMU가 주소 번역 단계에서 하드웨어적으로 거부한다. 결과는 페이지 폴트이고, 커널은 이걸 받아 해당 프로세스에 SIGSEGV를 보낸다.
여기서 중요한 건 소프트웨어 검사가 아니라는 점이다. 커널이 "이 프로세스가 지금 접근하려는 주소가 커널 영역인가?"를 코드로 확인하는 게 아니다. 그런 검사를 매 메모리 접근마다 돌리는 건 불가능하다. 대신 MMU가 번역할 때마다 U/S 비트를 함께 보고, 권한이 안 맞으면 번역을 실패시킨다. 공짜로, 그리고 예외 없이 적용된다.
그래서 이 절의 요약은 이 한 문장이다.
커널 매핑은 보이지만 만질 수는 없는 영역을 지도에 그려둔 것이다.
주소는 존재하고 번역도 준비되어 있지만, 권한이 오르기 전까지는 하드웨어가 접근을 거부한다. 권한이 오르는 순간(CPL 3 → 0) 같은 주소가 갑자기 유효해진다. 지도는 그대로다.
2-7. 대비 정리 — 지도를 바꾸는 일과 권한만 올리는 일
2절 전체를 두 문장으로 압축하면 이렇다.
컨텍스트 스위칭 = 지도를 갈아끼운다. 시스템 콜 = 같은 지도에서 출입 권한만 올린다.
지도를 갈아끼우는 쪽의 비용은 한 덩어리가 아니라 세 겹으로 쌓여 있다.
| 겹 | 내용 | 눈에 보이는가 |
|---|---|---|
| ① 직접 비용 | 레지스터 저장·복원, 스케줄러 실행, CR3 쓰기 | 잘 보인다. 측정도 쉽다 |
| ② TLB 강제 무효화 | 번역 결과가 날아가 이후 접근이 미스로 시작 | 잘 안 보인다 |
| ③ 캐시 오염 | 새 프로세스가 캐시를 채우며 이전 데이터를 밀어냄 | 거의 안 보인다 |
직관과 어긋나는 부분은 가장 큰 비용이 가장 아래, 가장 안 보이는 곳에 있다는 점이다. ①은 수백 나노초 단위로 측정할 수 있지만, ③은 스위칭이 끝난 뒤 한참 동안 실행되는 코드 전반에 흩어져 붙는다. "컨텍스트 스위칭 비용"을 ①만으로 계산하면 실제보다 한참 낮게 잡히는 이유다.
컨텍스트 스위칭이 정확히 무슨 일을 하는지, 스레드 전환은 왜 더 싼지는 컨텍스트 스위칭에서 다룬다. 이 글에서는 시스템 콜과의 대비로만 쓴다. 주소 공간이 유지되느냐 교체되느냐, 그 한 가지 차이가 곧 비용의 차이다.
3. 판단 — 커널은 언제 실행되는가
3-1. 커널은 프로세스가 아니라 코드다
이제 처음의 질문으로 돌아온다. 커널 코드는 언제, 누구의 시간으로 실행되는가.
먼저 사실 확인부터. ps를 아무리 뒤져도 "kernel"이라는 이름의 프로세스는 없다. PID 1은 systemd이고, 그것도 유저 프로세스다. 커널은 프로세스 목록에 없다. 목록에 없는 이유는 숨겨져 있어서가 아니라 애초에 프로세스가 아니기 때문이다.
커널은 자기만의 실행 흐름을 갖지 않는다. 스케줄러가 커널에게 CPU를 배분하는 일도 없다. 그러면 sys_read()는 누구의 시간으로 실행되는가?
read()를 호출한 게 프로세스 A라면,sys_read()를 실행하는 것도 여전히 A다.
프로세스가 A에서 "커널"로 바뀌는 게 아니다. 같은 프로세스가 모드만 바꾼 것이다. A의 시간표에서 CPU 시간이 소비되고, 그 시간이 top에서 A의 sy(system) 시간으로 잡힌다. 커널은 A가 잠시 특권 모드로 올라가 실행하는 코드 덩어리다.
이걸 제대로 보려면 두 가지를 서로 다른 축으로 놓아야 한다.
- 실행 주체 (누가) — 프로세스 A인가 B인가
- 권한 레벨 (어떤 권한으로) — 유저 모드인가 커널 모드인가
이 둘은 서로 독립적이다. 한쪽이 바뀐다고 다른 쪽이 따라 바뀌지 않는다.
- 가로 이동 (A 유저 → A 커널) = 시스템 콜·인터럽트·예외. 주체는 그대로, 권한만 올라간다. CR3도 그대로.
- 세로 이동 (A → B) = 컨텍스트 스위칭. 주체가 바뀐다. 지도 자체가 교체된다.
여기서 하나가 자연스럽게 따라 나온다. 프로세스는 스택을 두 개 갖는다. 유저 스택과 커널 스택이다. 시스템 콜로 커널 모드에 올라가면 실행 주체는 그대로지만 스택은 커널 스택으로 갈아탄다. 이유는 명확하다 — 유저 코드가 자기 스택을 마음대로 조작할 수 있는데, 커널이 같은 스택을 쓴다면 유저가 커널의 반환 주소나 지역 변수를 건드릴 수 있게 된다. 격리를 페이지 권한으로 세워놓고 스택으로 무너뜨릴 수는 없다.
3-2. 커널이 깨어나는 계기는 셋뿐이다
커널이 프로세스가 아니라면, 커널 코드는 언제 실행되는가? 계기는 정확히 세 가지다.
| 계기 | 성격 | 예시 |
|---|---|---|
| 시스템 콜 | 자발적 요청 | read(), write(), fork() |
| 인터럽트 | 하드웨어가 비자발적으로 유발 | 타이머 만료, 네트워크 패킷 도착, 디스크 완료 |
| 예외 | 실행 중 사고 | 페이지 폴트, 0으로 나누기, 잘못된 명령어 |
셋의 공통점은 전부 이벤트라는 것이다. 커널이 "지금 뭔가 확인해봐야겠다"고 스스로 판단해서 깨어나는 경로는 없다. 무언가가 일어났을 때, 그 일을 계기로 실행된다.
커널은 폴링하며 지켜보는 감시자가 아니라, 이벤트에 반응하는 라이브러리에 가깝다.
다만 보통의 라이브러리와 두 가지가 다르다. 특권 모드에서만 실행되고, 아무 함수나 부를 수 있는 게 아니라 정해진 진입점으로만 들어갈 수 있다. 문이 몇 개인지, 그 문을 어떻게 통과하는지는 시스템 콜과 Dual Mode에서 이어받는다. 이 글의 몫은 왜 문이 필요한가까지다.
3-3. 그래서 무한 루프는 어떻게 멈추는가
이 모델을 받아들이면 곧바로 곤란한 질문이 생긴다.
while (true) {}
이 코드를 실행하면 커널은 CPU를 어떻게 되찾는가? 커널이 스스로 깨어날 수 없다면, 이 프로세스가 시스템 콜을 한 번도 호출하지 않는 이상 커널 코드는 영원히 실행되지 않는 것 아닌가?
맞다. 소프트웨어만으로는 방법이 없다. 커널이 지켜보다가 뺏는 게 아니다. 실제 해법은 이렇다.
프로세스에게 CPU를 넘겨주기 전에, 커널이 미리 타이머 인터럽트를 예약해두고 잠든다.
타이머는 커널이 아니라 하드웨어다. 정해진 시간이 지나면 하드웨어가 인터럽트를 걸고, CPU는 실행 중이던 코드가 무엇이든 강제로 중단시킨 뒤 커널의 인터럽트 핸들러로 점프한다. 그때 커널이 다시 깨어나고, 그 자리에서 스케줄러를 돌려 다른 프로세스에게 CPU를 넘긴다.
여기서 나오는 결론이 이 글에서 가장 마음에 드는 부분이다.
하드웨어의 힘을 빌리지 않으면 커널조차 무력하다.
선점형 스케줄링은 커널이 강해서 가능한 게 아니라, 커널이 타이머라는 알람을 미리 걸어둘 만큼 신중해서 가능한 것이다. 커널의 권력은 상시적인 감시가 아니라 미리 예약해둔 복귀 지점에서 나온다.1
3-4. 설계가 뒤집힌 사례 — KPTI
2절의 설계 B는 20년 넘게 당연한 선택이었다. 그런데 2018년에 그 전제가 깨졌다.
Meltdown은 정확히 이 지점을 노렸다. 논리는 이랬다.
- 커널 주소는 유저 프로세스의 지도에 매핑되어 있다.
- 다만 U/S 비트 때문에 접근하면 거부된다.
- 그런데 현대 CPU는 성능을 위해 투기적 실행(speculative execution) 을 한다. 권한 검사가 최종 확정되기 전에 일단 메모리를 읽어 뒷 명령어들을 미리 굴려본다.
- 권한 위반이 확정되면 결과는 폐기된다. 그러나 그 사이에 남긴 캐시 흔적은 폐기되지 않는다.
- 그 흔적을 시간 측정으로 되짚으면 읽지 못했어야 할 커널 메모리의 내용을 복원할 수 있다.
즉 "어차피 못 읽는데 매핑은 되어 있다"는 틈을 파고든 것이다. 2절에서 "보이지만 만질 수 없다"고 정리했던 바로 그 성질이 공격 표면이 됐다.
대응책인 KPTI(Kernel Page Table Isolation)는 무식할 만큼 단순하다.
페이지 테이블을 두 벌 만든다. 유저 모드로 돌아갈 때는 커널을 지도에서 지운다.
유저 모드에서 쓰는 페이지 테이블에는 커널 매핑이 거의 없다. 시스템 콜 진입에 필요한 최소한의 조각(트램펄린)만 남긴다. 권한으로 막는 대신 아예 매핑을 없애는 방식이다. 투기적 실행이 아무리 앞서 나가도, 존재하지 않는 매핑은 읽을 수 없다.
대가는 명확하다. 설계 A로의 강제 회귀다. 시스템 콜마다 CR3를 두 번 교체하게 되고, 2-3절에서 본 청구서가 그대로 돌아온다.
영향은 워크로드에 따라 극단적으로 갈린다.
| 워크로드 | 시스템 콜 빈도 | 영향 |
|---|---|---|
| DB, 웹 서버, 작은 파일 대량 처리 | 매우 높음 | 크게 느려짐 (I/O 집약 케이스에서 30% 이상 보고됨) |
| 일반 애플리케이션 | 보통 | 수 % 수준 |
| 영상 인코딩, 행렬 연산 | 거의 없음 | 1~2% 수준 |
같은 패치인데 워크로드에 따라 30배 가까이 영향 차이가 난다. 이 표는 결국 2절을 거꾸로 읽은 것에 불과하다.
시스템 콜이 쌌던 이유가 "주소 공간을 안 바꿔서"였으니, 주소 공간을 바꾸게 만들면 시스템 콜이 비싸진다.
성능 특성이 어디서 왔는지를 알면, 그 전제가 깨졌을 때 무엇이 무너질지도 예측할 수 있다. KPTI의 영향이 워크로드별로 갈린다는 사실은 벤치마크를 돌려봐야 알 수 있는 게 아니라, 설계에서 이미 도출되는 결과다.
3-5. 정리 — 하나의 결정이 만든 연쇄
마지막으로 이 글의 다섯 문장을 한 줄로 꿴다.
- 커널이냐 아니냐의 기준은 특권 모드다. 셸도 libc도 systemd도 커널이 아니다.
- 그래서 커널은 프로세스가 아니라 코드다. 자기 실행 흐름이 없고, 요청한 프로세스의 시간으로 실행된다.
- 그래서 커널은 모든 주소 공간에 공유 매핑된다. 별도 주체가 아니라 코드 덩어리이므로, 각 프로세스의 지도 안에 그려두는 편이 자연스럽다.
- 그래서 시스템 콜이 싸다. 지도를 바꾸지 않으니 CR3도 TLB도 건드릴 필요가 없다.
- 그래서 U/S 비트로 하드웨어가 막아야 한다. 같은 지도에 있는 이상 소프트웨어 검사로는 감당할 수 없다.
이 다섯은 따로 외울 별개의 사실이 아니다. "커널을 별도 주체로 두지 않는다"는 하나의 설계 결정에서 순서대로 따라 나온 연쇄다. 그리고 KPTI는 그 연쇄의 3번을 강제로 끊었을 때 4번이 무너진다는 걸 실증한 사건이다.
처음의 질문에 답하면 이렇게 된다.
커널 코드는 이벤트가 발생했을 때, 그 이벤트를 유발한 프로세스의 시간으로 실행된다.
관련 글
- 커널 아키텍처 — 이 글의 후속. "커널이 어디에 있는가" 다음은 "커널이 어디까지인가"다.
- 시스템 콜 — 이 글이 "왜 문이 필요한가"라면, 그 글은 "문이 어떻게 생겼는가"다.
- Dual Mode — CPL과 특권 명령의 상세.
- 인터럽트 — 커널을 깨우는 세 계기 중 두 번째.
- 가상 메모리 · TLB — 이 글이 최소한만 다룬 주소 번역의 본편.
- 컨텍스트 스위칭 — "지도를 갈아끼우는 쪽"의 상세.
- 프로세스 주소 공간 — 이 글이 다룬 지도의 아래쪽 절반.
- 가상화와 컨테이너 — "커널을 공유한다"는 말이 이 글의 내용 위에 얹힌다.
Footnotes
-
예외가 하나 있다.
kthreadd가 만들어내는 커널 스레드(kswapd,ksoftirqd,kworker등)는 유저 공간 없이 커널 공간에서만 도는 진짜 실행 흐름이고, 스케줄러의 대상이 된다.ps aux에서[kswapd0]처럼 대괄호로 표시되는 것들이다. 다만 이건 메모리 회수나 지연된 인터럽트 처리처럼 "누군가의 요청"에 붙이기 애매한 백그라운드 작업을 위한 특수 케이스이고, 커널 코드의 대부분은 여전히 위에서 설명한 모델대로 요청한 프로세스의 시간으로 실행된다. ↩