← Blog
Computer Science / 운영체제 / 커널과 시스템 콜

커널 아키텍처 — 경계선을 어디에 그을 것인가

앞 글 운영체제와 커널에서 커널을 이렇게 정의했다 — 특권 모드에서 실행되며 하드웨어 접근을 중재하는 코드. 그리고 그 코드가 모든 프로세스의 주소 공간 상단에 함께 매핑되는 이유까지 봤다.

그런데 그 정의는 질문 하나를 남긴다. 어디까지를 커널 안에 넣을 것인가?

스케줄러는 당연히 안이다. 물리 메모리를 나눠주는 코드도 안일 수밖에 없다. 그런데 파일 시스템은? USB 드라이버는? TCP/IP 스택은? 이것들은 하드웨어를 만지긴 하지만, 스케줄러처럼 "없으면 시스템이 성립하지 않는" 종류는 아니다.

이 글은 그 질문 하나에 대한 서로 다른 답들을 다룬다. 앞 글이 커널이 어디에 있는가였다면, 이 글은 커널이 어디까지인가다.

CR3·TLB·주소 공간·특권 모드는 앞 글에서 세운 개념이다. 여기서는 전제로 쓰고 재설명하지 않는다. 필요한 지점마다 링크를 건다.


1. 정의 — 분류를 만든 질문 하나

1-1. 쟁점은 기능 목록이 아니라 배치다

커널 아키텍처를 비교할 때 가장 먼저 정리해야 할 게 있다.

모든 커널은 같은 기능을 갖는다. 다른 것은 그 기능을 경계선 안에 두느냐 밖에 두느냐다.

마이크로커널이라고 해서 파일 시스템이 없는 게 아니다. 네트워크가 안 되는 것도 아니다. ext4든 TCP/IP든 누군가는 그 코드를 짜야 하고, 실제로 짜여 있다. 차이는 그 코드가 어디에서 어떤 권한으로 실행되는가다. 이 문장이 이 글 전체의 축이다.

그래서 "마이크로커널은 기능이 적다"는 표현은 오해를 부른다. 정확히는 커널 안에 든 기능이 적다.

경계선 안에 둔다는 것의 구체적 의미는 이렇다.

  • 같은 주소 공간에 있으므로 서로를 함수 호출로 부른다. VFS가 ext4를 부르는 건 그냥 함수 호출이다.
  • 특권 모드로 실행되므로 하드웨어를 직접 만질 수 있다. 포트 I/O든 MMIO든 제약이 없다.
  • 격리가 없다. 드라이버 하나가 널 포인터를 참조하면 커널 패닉이고, 시스템 전체가 죽는다.

경계선 밖에 두면 정확히 반대가 된다.

  • 별도의 프로세스가 되므로, 서로를 부르려면 IPC를 거쳐야 한다.
  • 유저 모드로 실행되므로 하드웨어를 직접 만질 수 없다. 커널이 허락한 경로로만 접근한다.
  • 격리된다. 파일 시스템 서버가 죽어도 커널은 살아 있고, 원칙적으로 재시작할 수 있다.

이 하나의 선택이 성능·안정성·검증 가능성을 전부 결정한다. 그리고 그 선택의 스펙트럼 위에 아래 세 이름이 놓인다.

1-2. 세 가지 답

모놀리식(Monolithic) — 다 넣는다.

스케줄러, 메모리 관리, VFS와 파일 시스템 구현, TCP/IP 스택, 그리고 모든 장치 드라이버가 하나의 주소 공간 안에 있다. 리눅스와 BSD 계열이 여기 속한다. 서비스 간 호출이 전부 함수 호출이므로 빠르다. 대신 GPU 드라이버의 버그가 커널 전체를 내린다.

마이크로커널(Microkernel) — 최소한만 넣는다.

커널 안에는 세 가지 정도만 남긴다 — 주소 공간 관리, 스케줄링, IPC. 이 셋은 다른 것으로 대체할 수 없어서 남는다. 커널 밖에서 서비스를 제공하려면 주소 공간이 있어야 하고, 실행 순서를 정하는 주체가 있어야 하고, 서비스끼리 통신할 수단이 있어야 하기 때문이다.

나머지는 전부 유저 공간 프로세스가 된다. 파일 시스템도 프로세스, 네트워크 스택도 프로세스, 디스크 드라이버도 프로세스다. L4 계열, seL4, QNX, MINIX 3가 여기 속한다.

혼합형(Hybrid) — 사상은 마이크로커널, 배치는 모놀리식.

마이크로커널의 설계를 상당 부분 받아들였지만, 성능 때문에 상당수를 커널 주소 공간 안에 유지한다. Windows NT 계열과 macOS의 XNU가 여기로 분류된다. 이 분류가 실체가 있는지는 2절에서 따로 따진다.

커널 아키텍처 세 종류

1-3. Windows NT — 경계선을 안쪽으로 옮긴 사례

혼합형이 어떻게 만들어지는지는 Windows NT의 역사가 잘 보여준다.

데이브 커틀러가 설계한 초기 NT는 마이크로커널 사상을 크게 반영했다. 서브시스템(Win32, OS/2, POSIX)을 유저 공간 프로세스로 두어, 하나의 커널 위에 여러 API 환경을 얹을 수 있게 했다. 설계로서는 깔끔했다.

그런데 NT 3.51까지 실제로 써보니 그래픽이 견딜 수 없이 느렸다. 이유는 구조에서 바로 나온다. 창 하나를 그릴 때마다 유저 공간과 커널을 왕복해야 했다. GUI는 작은 그리기 요청이 끊임없이 발생하는 워크로드라, 요청당 고정 비용이 그대로 누적됐다.

그래서 NT 4.0에서 그래픽 서브시스템을 커널 안으로 옮겼다(win32k.sys). 결과는 정확히 교환이었다.

성능을 얻었고, 그래픽 드라이버 버그가 블루스크린을 일으키는 구조를 얻었다.

디스플레이 드라이버 결함이 시스템 전체를 내리는 문제는 그 뒤로 오랫동안 윈도우의 대표적 안정성 이슈였다. 이후 WDDM에서 그래픽 스택 상당 부분을 다시 유저 모드로 빼고, 드라이버가 멈추면 시스템을 죽이는 대신 그래픽 스택만 재시작하는 복구 메커니즘(TDR)을 넣은 것은 이 결정에 대한 되돌림에 가깝다. 경계선은 한 번 긋고 끝나는 게 아니라 계속 옮겨진다.

1-4. macOS XNU — 메커니즘은 빌리고 배치는 빌리지 않았다

XNU는 Mach 마이크로커널 위에 BSD 커널을 얹은 잡종이다. 이름부터 "X is Not Unix"의 약자다.

여기서 오해하기 쉬운 지점이 하나 있다. BSD 레이어는 유저 공간 서버가 아니다. Mach와 같은 주소 공간에서, 같은 특권으로 실행된다. Mach 포트를 통한 메시지 전달 메커니즘을 쓰지만, 그 메시지가 주소 공간 경계를 넘지 않으므로 진짜 IPC 비용은 내지 않는다.

즉 Mach에서 가져온 것은 인터페이스의 형태이지 격리라는 실질이 아니다. 한 문장으로 정리하면 이렇게 된다.

구조는 마이크로커널에서 빌려오고, 배치는 모놀리식으로 한 것.


2. 이유 — 마이크로커널은 왜 느린가

2-1. 경계를 넘는 비용

같은 read() 하나를 두 구조가 어떻게 처리하는지 비교하면 차이가 그대로 드러난다.

모놀리식에서는 모드 전환 1회로 커널에 들어간 뒤, 그 안에서 전부 함수 호출이다.

앱 → [모드 전환] → sys_read() → VFS → ext4 → 블록 계층 → NVMe 드라이버

sys_read()가 VFS를 부르고, VFS가 ext4를 부르고, ext4가 블록 계층을 부르는 것 모두 같은 주소 공간 안의 함수 호출이다. 데이터는 포인터로 넘긴다. 복사가 없다.

마이크로커널에서는 각 단계가 별도 프로세스이므로 전부 IPC다.

앱 →(IPC ①)→ 파일시스템 서버 →(IPC ②)→ 디스크 드라이버 서버
    →(IPC ③)→ 파일시스템 서버 →(IPC ④)→ 앱

함수 호출 3회이던 것이 프로세스 왕복 4회가 됐다. 그리고 프로세스 간 전환이 무엇이었는지는 앞 글에서 이미 봤다.

IPC = 컨텍스트 스위칭 = CR3 교체 = TLB 무효화

앞 글의 대비 정리를 그대로 가져오면, 지도를 갈아끼우는 쪽의 비용은 세 겹이었다 — 직접 비용, TLB 강제 무효화, 캐시 오염. 마이크로커널은 read() 한 번에 그 청구서를 네 번 받는다.

여기에 하나가 더 붙는다. 데이터 복사다. 모놀리식은 같은 주소 공간이라 버퍼 포인터만 넘기면 그만이었지만, 주소 공간이 다르면 실제로 복사하거나 공유 메모리 매핑을 미리 설정해야 한다. 어느 쪽이든 공짜가 아니다.

read 처리 경로 비교

비교 항목모놀리식마이크로커널
서비스 간 호출함수 호출IPC
CR3 · TLB건드리지 않음매번 교체 · 무효화
데이터 전달포인터복사 또는 공유 메모리 설정
캐시유지오염

그래서 성능 차이의 정체는 이렇게 정리된다.

마이크로커널이 느린 이유는 코드가 느려서가 아니다. 경계를 넘을 때마다 앞 글에서 본 그 비용이 청구되기 때문이다. 경계를 많이 그을수록 넘는 횟수가 늘어난다.

ext4 구현이 느린 게 아니다. 똑같은 알고리즘을 프로세스 경계 너머에 두었을 뿐이다.

IPC의 각 방식(파이프·공유 메모리·메시지 큐·소켓·시그널)과 컨텍스트 스위칭의 상세 비용 분석은 IPC컨텍스트 스위칭에서 다룬다.

2-2. 오해 정정 — "마이크로커널은 원래 느리다"는 반은 틀렸다

위 설명을 읽으면 "그러니까 마이크로커널은 원리적으로 느리구나"로 정리하기 쉽다. 절반만 맞다.

이 인식이 굳은 데는 역사적 사정이 있다. 1980년대 후반 마이크로커널의 대표주자는 Mach였는데, 모놀리식 대비 성능이 심하게 나빴다. 그리고 이 시기가 하필 마이크로커널 논의가 가장 활발하던 때였다. 1992년의 타넨바움-토발즈 논쟁도 이 무렵이다. 당시의 실측 데이터가 곧 "마이크로커널 = 느림"이라는 결론으로 굳어졌다.

그런데 1990년대 중반, 요헨 리트케의 L4가 다른 답을 내놓았다. IPC를 극단적으로 최적화하면 — 메시지를 메모리가 아니라 레지스터로 전달하고, 커널 자체를 캐시에 통째로 들어갈 만큼 작게 유지하면 — Mach 대비 수십 배 빠른 IPC가 가능했다.

같은 아키텍처인데 결과가 이렇게 갈렸다는 건 한 가지를 뜻한다.

느렸던 것은 개념이 아니라 Mach라는 구현이었다.

그렇다고 "마이크로커널도 모놀리식만큼 빠르다"로 뒤집으면 그것도 틀린다. IPC를 아무리 최적화해도 주소 공간을 넘는 일은 같은 주소 공간 안의 함수 호출보다 비싸다. 이 격차는 구현 문제가 아니라 하드웨어의 동작 방식에서 나오므로 원리상 없앨 수 없다. 정확한 표현은 이쯤이다.

오버헤드는 알려진 것보다 훨씬 작게 만들 수 있지만, 0이 될 수는 없다.

2-3. 혼합형은 실체 있는 분류인가

리누스 토발즈는 하이브리드 커널을 두고 마케팅 용어라고 비판한 것으로 잘 알려져 있다. 나는 이 비판에 상당한 타당성이 있다고 본다.

논지는 간단하다. 중요한 것들이 전부 커널 주소 공간에 있다면 그건 그냥 모놀리식이다. 내부 구조가 잘 모듈화되어 있다는 건 코드가 잘 짜였다는 뜻이지 새로운 아키텍처라는 뜻이 아니다. 모듈화는 모든 대규모 소프트웨어가 하는 일이고, 그걸로 분류를 하나 더 만들 이유는 없다.

1절에서 세운 기준으로 직접 검증해보면 더 분명해진다. 아키텍처를 가르는 축은 **"서비스들이 서로 다른 주소 공간·특권 도메인에 있는가"**였다. 이 질문을 던지면,

  • Windows — 드라이버가 커널 주소 공간에 있다. 드라이버 실패는 블루스크린이다.
  • macOS(XNU) — BSD 레이어도 드라이버도 커널 주소 공간에 있다. 실패는 커널 패닉이다.

성능 프로파일도 장애 프로파일도 모놀리식이다. 마이크로커널에서 기대하는 실질적 이득 — 서비스가 죽어도 시스템이 살아남는 것 — 은 대부분 확보되지 않았다.

다만 완전히 무의미한 분류라고 하기도 어렵다. 윈도우는 일부 서브시스템을 유저 공간에 두고, UMDF로 유저 모드 드라이버를 지원한다. 프린터나 USB 주변기기 드라이버 중 상당수가 유저 모드에서 돈다. 순수한 모놀리식보다 경계선이 여러 겹인 건 사실이다.

그래서 가장 정확한 표현은 이쪽이다.

세 종류의 배타적 분류가 아니라, 연속적인 스펙트럼 위의 한 점으로 보는 것.

커널 아키텍처 스펙트럼

축은 성능이냐 격리냐이고, 경계선을 적게 그을수록 왼쪽, 많이 그을수록 오른쪽이다. 혼합형은 "제3의 아키텍처"가 아니라 오른쪽 사상을 받아들이되 왼쪽에 머무른 지점이다.


3. 판단 — 무엇을 기준으로 고르는가

3-1. 가장 많이 틀리는 지점 — 모듈성 ≠ 격리

리눅스는 LKM(Loadable Kernel Module)을 지원한다. 재부팅 없이 드라이버를 넣고 뺄 수 있다.

$ lsmod | head -5
Module                  Size  Used by
nvme                   57344  3
ext4                  962560  1
xfs                  2158592  0
usbcore               348160  7 usbhid,uas,usb_storage
$ sudo insmod ./mydriver.ko   # 로드
$ sudo rmmod mydriver         # 언로드

이걸 보고 "리눅스도 모듈로 분리되어 있으니 혼합형 아닌가?" 하는 오해가 흔하다. lsmod 출력이 마치 독립된 컴포넌트 목록처럼 보이기 때문이다.

insmod가 실제로 하는 일을 보면 답이 나온다. 모듈 코드를 커널 주소 공간에 로드하고, 커널이 export한 심볼과 링크하는 것이다. 동적 링킹에 가깝다. modinfo로 확인해보면 모듈이 커널과 얼마나 단단히 묶여 있는지 드러난다.

$ modinfo nvme | head -8
filename:       /lib/modules/6.8.0-generic/kernel/drivers/nvme/host/nvme.ko
license:        GPL
description:    NVM Express device driver
depends:        nvme-core
retpoline:      Y
vermagic:       6.8.0-generic SMP preempt mod_unload modversions

vermagic에 커널 버전이 박혀 있다. 다른 버전의 커널에는 그대로 로드되지 않는다. 이 필드 하나가 3-3절에서 다룰 문제의 씨앗이다.

로드된 모듈은 커널과 완전히 동일한 특권으로 실행된다. 모듈은 프로세스가 아니라 커널 본체의 일부가 된다. 모듈 안에서 널 포인터를 참조하면 커널 패닉이다. rmmod로 뺄 수 있다는 사실은 실행 중의 격리와 아무 상관이 없다.

두 개념을 분리해야 한다.

모듈성 (Modularity)격리 (Isolation)
질문코드를 어떻게 나누고 배포하는가실행 중 실패가 전파되는가
리눅스있다 (LKM)없다
관찰 시점빌드·배포 시점런타임
위반 시 결과코드가 지저분해짐시스템 전체 정지

판단 기준은 한 줄로 충분하다.

"이 컴포넌트가 죽으면 시스템이 죽는가?" 죽으면 커널 안이다. 어떻게 배포되는지는 상관없다.

3-2. 리눅스도 경계를 넘나든다

경계선의 정답이 고정돼 있다면 이야기가 간단하겠지만, 실제로는 같은 OS 안에서도 계속 움직인다. 리눅스에서 세 방향의 사례를 보자.

FUSE — 격리를 위해 밖으로.

FUSE(Filesystem in Userspace)는 파일 시스템 구현을 유저 공간 프로세스로 뺀다. sshfs, ntfs-3g 같은 것들이 이 방식이다. 정확히 마이크로커널의 배치이고, 그래서 정확히 마이크로커널의 비용을 낸다. 요청 하나당 추가로 붙는 것들을 단계별로 보면,

  1. 컨텍스트 스위칭 2회 — 커널이 요청을 FUSE 데몬에 넘기고, 데몬이 결과를 돌려준다. CR3 교체 → TLB 무효화가 두 번. 여기가 가장 비싸다.
  2. 모드 전환 증가 — 데몬 자신도 실제 저장소를 읽으려면 시스템 콜을 해야 한다.
  3. 데이터 복사 — 커널 버퍼와 데몬 버퍼 사이를 오간다.
  4. 스케줄러 지연 — 이게 특히 놓치기 쉽다. 데몬을 깨우는 것은 실행 가능 상태로 만드는 것일 뿐이고, 실제로 언제 실행될지는 스케줄러가 정한다. 시스템이 바쁘면 깨어난 뒤에도 기다린다.

대신 얻는 것이 있다. 만들기 쉽고, 죽어도 커널이 안 죽는다. 검증되지 않은 파일 시스템 구현을 커널에 넣는 것과 비교하면 위험이 비교가 안 된다.

비용 구조가 요청당 고정 비용이라는 점도 중요하다. 그래서 영향이 워크로드에 따라 갈린다.

  • 작은 파일을 많이 읽을 때 — 요청 수가 많으니 고정 비용이 그대로 누적된다. 특히 불리하다.
  • 큰 파일을 순차적으로 읽을 때 — 요청 하나가 옮기는 데이터가 크니 고정 비용이 희석된다. 격차가 작다.

DPDK / SPDK — 성능을 위해 밖으로.

방향이 정반대인 사례다. 네트워크·스토리지 처리를 유저 공간으로 빼서 오히려 빠르게 만든다. 커널의 네트워크 스택을 통째로 우회하고 장치를 유저 공간에서 직접 제어해, 시스템 콜 비용 자체를 없앤다. 고성능 패킷 처리나 NVMe 스토리지에서 쓰인다.

같은 "유저 공간으로 뺀다"인데 FUSE는 느려지고 DPDK는 빨라진다. 차이는 경계를 몇 번 넘느냐다. FUSE는 경계를 하나 더 만들었고, DPDK는 있던 경계를 지웠다.

io_uring — 경계는 두되 넘는 횟수를 줄인다.

커널과 유저 공간이 공유 링 버퍼로 통신한다. 요청을 큐에 쌓아두면 커널이 가져가 처리하고 완료를 다른 큐에 넣는다. 요청마다 시스템 콜을 하는 대신 여러 요청을 모아 한 번에 처리하므로, 경계를 넘는 횟수가 줄어든다.

정리하면 이렇다. FUSE는 격리를 위해, DPDK와 io_uring은 성능을 위해 경계를 넘나든다. 방향이 반대인 선택이 같은 커널 안에 공존한다는 사실이 결론을 대신한다.

경계선을 어디에 긋는가에는 고정된 답이 없고, 워크로드마다 다르다.

유저 공간 파일 시스템의 비용 구조는 파일 시스템과 함께 보면 더 선명하다.

3-3. 아키텍처의 대가는 성능만이 아니다 — Fuchsia 사례

여기까지 보면 저울의 양쪽은 성능과 격리 둘뿐인 것 같다. 그런데 세 번째 항목이 있다. 업데이트 유연성이다.

안드로이드 기기의 OS 업데이트가 2~3년 만에 끊기는 일은 흔하다. 이걸 제조사의 태만으로 설명하기 쉽지만, 상당 부분은 구조적이다.

리눅스 커널은 내부 ABI를 의도적으로 안정적으로 유지하지 않는다. 커널 내부 인터페이스는 필요하면 언제든 바꾼다는 것이 명시적인 정책이다. 철학은 이렇다 — "드라이버는 커널 소스에 합쳐라(upstream). 그러면 인터페이스를 바꿀 때 우리가 같이 고쳐주겠다." 트리 안에 있는 드라이버는 실제로 그렇게 관리된다.

문제는 트리 밖 드라이버다. 커널 버전이 바뀌면 다시 컴파일해야 하고, 인터페이스가 바뀌었으면 코드도 고쳐야 한다. 앞서 modinfo에서 본 vermagic이 그 결과물이다.

그래서 이런 직렬 의존 사슬이 생긴다.

구글이 새 안드로이드/커널 출시
  → 칩 벤더가 드라이버를 새 커널로 포팅
    → OEM이 자사 하드웨어에 맞춰 수정
      → 통신사 검증
        → 사용자에게 배포

핵심은 이게 직렬이라는 것이다. 벤더가 3년 된 칩의 드라이버를 새 커널로 포팅해줄 상업적 이유는 없고, 안 해주면 그 뒤 단계는 시작조차 못 한다. OEM이 아무리 의지가 있어도 드라이버가 없으면 커널을 올릴 수 없다.

Zircon(Fuchsia의 커널)의 답은 아키텍처적이다. 드라이버를 유저 공간 프로세스로 빼고, 안정적인 드라이버 인터페이스(FIDL)를 제공한다. 드라이버는 커널과 링크되지 않고 정의된 인터페이스로 통신하므로, 드라이버를 건드리지 않고 커널만 갈아끼울 수 있다.

여기서 정확히 짚어야 할 게 있다.

Zircon이 없앤 것은 단계가 아니라 의존이다.

드라이버를 만드는 노동이 사라진 게 아니다. 벤더는 여전히 드라이버를 짜야 한다. 달라진 건 그것이 뒷 단계의 선행 조건이 아니게 되었다는 점이다. 커널 업데이트와 드라이버 유지보수가 분리되면 사슬이 끊기고, 각자의 속도로 갈 수 있다.

못 없애는 것도 정직하게 짚어야 한다. 통신사 검증은 그대로 남는다. 그건 기술적 결합이 아니라 조직적·규제적 결합이기 때문이다.

아키텍처는 기술적 의존만 끊을 수 있다.

대비 — GKI/KMI. 구글은 리눅스 안에서도 같은 목표를 시도했다. Android 11부터의 GKI(Generic Kernel Image)는 공통 커널 코어와 벤더 모듈을 분리하고, 그 사이에 안정적인 인터페이스(KMI)를 정의한다. 목표는 Zircon과 같다.

차이는 보장의 성격에 있다.

Zircon (FIDL)GKI (KMI)
드라이버 위치유저 공간 프로세스커널 주소 공간
보장의 근거프로세스가 분리되어 있다는 구조적 보장"이 LTS 브랜치 동안 바꾸지 않겠다"는 정책적 약속
드라이버 실패 시원칙적으로 재시작 가능커널 패닉
약속이 깨지면구조상 깨질 수 없음다시 포팅

GKI 모듈은 여전히 커널 주소 공간에 로드된다. 3-1절의 기준을 그대로 적용하면 격리는 없다. GKI가 해결하는 것은 배포·유지보수의 문제이지 장애 전파의 문제가 아니다. 좋은 정책적 약속이지만, 구조적 보장과는 종류가 다르다.

마무리는 정직하게 해야 한다. Fuchsia는 결국 폰에 실리지 않았다. Nest Hub 등 일부 기기에 들어갔을 뿐이다. 위 내용은 설계 의도이지 입증된 결과가 아니다. 오히려 생태계 관성이 아키텍처적 우위를 이긴 사례로 보는 것이 정확하다. 아무리 구조가 낫더라도 수십 년치 드라이버·앱·툴체인을 다시 만들 수는 없다.

그래도 이 사례가 남기는 결론은 유효하다.

모놀리식의 대가는 성능이 아니라 업데이트 유연성이었다.

성능 표만 보면 모놀리식이 압도적이다. 그런데 그 표에 없는 비용이 수억 대의 기기가 보안 패치를 못 받는 형태로 나타났다. 아키텍처 선택은 기술적 이유로만 결정되지 않고, 그 대가도 기술 지표로만 나타나지 않는다.

3-4. 선택 기준 — 실패했을 때 잃는 것의 크기

모놀리식마이크로커널혼합형
서비스 간 호출함수 호출IPC대부분 함수 호출
장애 격리없음 (전체 정지)있음 (재시작 가능)대부분 없음
성능최상경계마다 비용모놀리식에 근접
커널 크기수백만 줄1만 줄 내외수백만 줄
형식 검증사실상 불가능가능 (seL4)불가능
대표 사례Linux, BSDL4, seL4, QNX, MINIX 3Windows NT, XNU

이 표를 실제 선택으로 옮기면 기준은 하나로 수렴한다.

마이크로커널을 쓰는 곳 — 실패의 대가가 "시스템 정지"보다 훨씬 큰 영역이다.

  • 자동차 — QNX가 차량 인포테인먼트와 제어 계통에서 사실상 표준이다. 주행 중 커널 패닉은 재부팅으로 넘길 수 있는 사건이 아니다.
  • 항공·군사·의료기기 — 인증 기관이 형식 검증을 요구한다. 커널이 1만 줄 규모여야 수학적 증명이 현실적으로 가능하다. seL4는 명세에 대해 구현의 정확성이 기계 검증으로 증명된 유일한 범용 OS 커널이다. 리눅스처럼 수백만 줄인 커널로는 시도조차 할 수 없다.

모놀리식을 쓰는 곳 — 처리량이 지배적이고, "커널 패닉 나면 재부팅한다"가 허용되는 영역이다. 서버, 데스크톱, 안드로이드가 여기 속한다. 재부팅의 비용이 감당 가능하다면 격리에 성능을 지불할 이유가 약하다.

혼합형을 쓰는 곳 — 수십 년치 드라이버 생태계를 짊어져 순수 마이크로커널로 갈아탈 수 없고, 성능도 포기할 수 없는 상용 OS의 실용적 타협이다. 3-3절의 Fuchsia 사례가 보여주듯, 생태계를 버리는 선택지는 대체로 존재하지 않는다.

성능이냐 격리냐의 저울에서, 시스템이 실패했을 때 잃는 것의 크기가 추를 결정한다.

3-5. 소프트웨어 격리는 하드웨어 없이 성립하지 않는다

마지막으로 짚어야 할 것이 남았다. 드라이버를 유저 공간으로 빼면 격리가 완성되는가? 아니다.

드라이버는 소프트웨어만 다루는 코드가 아니다. 하드웨어를 제어한다. 그리고 하드웨어는 소프트웨어의 규칙을 따르지 않는다.

DMA가 문제의 출발점이다. 장치가 CPU를 거치지 않고 메모리 버스에 직접 접근해 읽고 쓰는 방식이다. 네트워크 카드가 수신한 패킷을 메모리에 바로 쓰고, 디스크 컨트롤러가 읽은 블록을 메모리에 바로 올린다. CPU를 거치지 않으니 빠르다.

여기서 결정적인 사실이 하나 있다.

MMU는 CPU 안에 있다.

CPU가 메모리에 접근할 때는 가상 주소 → MMU 검사 → 물리 주소의 경로를 밟는다. 앞 글에서 본 U/S 비트 검사도 여기서 일어난다. 그런데 장치가 버스로 직접 접근할 때는 MMU가 관여하지 않는다. 장치의 DMA는 물리 주소로 곧장 간다.

그래서 이런 일이 가능해진다. 유저 공간 드라이버가 (버그든 악의든) 장치에게 "커널 코드 영역의 물리 주소에 이 데이터를 써라" 고 시키면, 그대로 써진다. 프로세스는 유저 모드였고 CPL도 3이었는데 커널이 파괴된다. 유저 공간에 격리해둔 것이 아무 의미가 없어진다.

이 구조를 confused deputy 문제라고 부른다. 권한 없는 자가, 권한 있는 자를 시켜서 목적을 달성한다. 드라이버에게는 권한이 없지만 장치에게는 있고, 드라이버는 장치에게 명령할 수 있다.

해결책이 IOMMU다(Intel VT-d, AMD-Vi). 발상은 MMU를 그대로 복제한 것이다.

MMU가 CPU의 메모리 접근을 번역·검사하듯, IOMMU는 장치의 메모리 접근을 번역·검사한다.

장치마다 페이지 테이블을 두고, 커널이 명시적으로 허락한 버퍼 외의 물리 주소로는 접근 자체를 실패시킨다. 한마디로 장치에게도 가상 주소 공간을 주는 것이다.

MMU와 IOMMU의 대칭

누구의 메모리 접근인가무엇을 보호하는가보호 장치
CPU의 접근프로세스 격리MMU
장치의 접근DMA 공격 차단IOMMU

활성화 여부는 확인할 수 있다.

$ dmesg | grep -i -e DMAR -e IOMMU
[    0.030000] DMAR: IOMMU enabled
[    0.412000] DMAR: Intel(R) Virtualization Technology for Directed I/O
$ ls /sys/kernel/iommu_groups/
0  1  10  11  12  13  14  2  3  4  5  6  7  8  9

이 디렉터리가 비어 있으면 IOMMU가 꺼져 있다는 뜻이다. 그룹 번호는 함께 격리되는 장치 묶음의 단위다.

이게 추상적인 위협이 아니라는 근거도 충분하다. Thunderbolt나 PCIe 포트에 악성 장치를 꽂아 잠긴 컴퓨터의 메모리를 읽어내는 DMA 공격이 실제 공격 벡터로 존재한다. 최근 OS들이 IOMMU를 기본 활성화하고, 잠금 화면에서 새 Thunderbolt 장치를 차단하는 이유가 이것이다. 가상화의 GPU passthrough도 IOMMU가 필수다 — 없으면 게스트 VM이 DMA로 호스트 메모리를 그대로 읽는다.

그래서 마이크로커널 이야기는 여기로 돌아온다. Fuchsia·seL4·QNX 같은 마이크로커널은 IOMMU를 전제로 설계된다. IOMMU가 없거나 꺼진 하드웨어에서 유저 공간 드라이버를 돌리면, 아무리 프로세스를 분리해도 격리는 반쪽짜리다.

앞 글의 마지막 결론이 여기서 그대로 반복된다.

하드웨어의 힘을 빌리지 않으면 커널조차 무력하다.

앞 글에서는 무한 루프를 도는 프로세스에게서 CPU를 되찾으려면 타이머 인터럽트가 필요했다. 이 글에서는 유저 공간 드라이버가 커널 메모리를 덮어쓰지 못하게 하려면 IOMMU가 필요하다. 소프트웨어가 그은 경계선은, 하드웨어가 그것을 강제해줄 때만 경계선이다.


4. 정리

  1. 쟁점은 기능 목록이 아니라 배치다. 모든 커널이 같은 기능을 갖고, 다른 건 경계선의 안쪽이냐 바깥쪽이냐다.
  2. 경계를 넘는 데는 비용이 든다. IPC는 컨텍스트 스위칭이고, 경계를 많이 그을수록 넘는 횟수가 늘어난다. 이 비용은 작게 만들 수 있어도 0이 되지는 않는다.
  3. 모듈성은 격리가 아니다. insmod로 뺐다 꽂았다 할 수 있다는 것과, 죽었을 때 시스템이 버티느냐는 완전히 다른 문제다.
  4. 저울추를 결정하는 것은 실패의 대가다. 재부팅으로 끝나면 모놀리식, 사람이 다치면 마이크로커널이다.
  5. 그리고 소프트웨어 격리는 하드웨어 없이 성립하지 않는다. MMU가 CPU를 막고 IOMMU가 장치를 막는다.

이 글의 질문은 "어느 아키텍처가 옳은가"가 아니었다. 경계선을 어디에 그을 것인가였고, 그 답은 시스템이 실패했을 때 무엇을 잃는지에 따라 달라진다. 그래서 리눅스도 FUSE로는 경계선을 밖으로 밀고 DPDK로는 안으로 지운다. 하나의 커널 안에서도 답이 하나가 아니다.


관련 글

  • 운영체제와 커널 — 이 글의 선행 글. 저쪽이 "커널이 어디에 있는가"라면 이 글은 "커널이 어디까지인가"다.
  • IPC · 컨텍스트 스위칭 — 마이크로커널의 IPC 비용이 곧 컨텍스트 스위칭 비용이다.
  • 가상 메모리 — MMU와 페이지 테이블의 본편. IOMMU 대칭의 왼쪽 항이다.
  • 파일 시스템 — FUSE가 무엇을 유저 공간으로 뺀 것인지.
  • 가상화와 컨테이너 — "커널을 공유한다"는 문제가 이 글의 격리 논의 위에 얹힌다.