사용자 공간
고친 사람 github-actions[bot]
사용자 공간은 응용 프로그램에서 하드웨어에 직접 손대는 권한을 걷어 둡니다. 프로그램은 장치를 만지거나 남의 메모리를 읽는 대신 커널에 부탁을 넣습니다. 그래서 프로그램 하나가 잘못 돌아도 나머지 프로그램과 운영체제는 하던 일을 계속합니다.
쉽고 빠른 이해
응용 프로그램이 도는 구역입니다. 편집기도 웹 서버도 명령을 받아 주는 셸도 여기서 돕니다.
이렇게 가른 까닭은 하나가 망가질 때 나머지까지 같이 망가지는 것을 막기 위해서입니다.
- 프로그램은 자기 몫으로 받은 주소 범위 안에서만 돕니다
- 장치를 쓰거나 파일을 열어야 하면 커널에 부탁을 넣습니다
- 커널이 대신 처리하고 결과만 돌려줍니다
대가는 부탁을 넣고 답을 받는 데 드는 값입니다. 짧은 부탁을 자주 넣는 프로그램은 그 값이 전체 성능을 지배할 수 있습니다.
반대로 그 값이 아까워 코드를 커널 안에 넣으면 부탁을 넣을 일이 없어집니다. 대신 거기서 잘못되면 운영체제 전체가 같이 죽습니다.
상세
은행 금고를 떠올려 봅니다. 손님은 금고 문을 직접 열지 않고 창구에 용건을 적어 냅니다. 직원이 대신 금고에 들어갔다 나옵니다. 은행에서는 창구를 건너뛰고 금고로 걸어가는 손님을 사람이 막지만, 기계에서는 프로그램을 실행하는 부품 자신이 막습니다.
사용자 공간은 특권이 가장 낮은 상태로 프로그램이 실행되는 구역입니다. 편집기 하나를 띄우면 그 편집기가 여기서 돕니다. 편집기가 파일을 저장할 때도 디스크에 직접 쓰지 못하고, 커널에 「이 파일에 이 내용을 써 달라」고 부탁합니다. 반대편이 커널 공간입니다.
특권 수준은 CPU 가 들고 있다
구역을 가르는 것은 폴더도 경로도 아니라 CPU(Central Processing Unit, 중앙 처리 장치)가 들고 있는 값입니다. 프로세서는 지금 도는 코드의 특권 수준을 늘 들고 있습니다. 그 수준으로 명령 하나하나와 메모리 접근 하나하나를 검사합니다.
x86-64 부터 봅니다. 세그먼트 보호 기구는 0 부터 3 까지 네 특권 수준을 인식하며 숫자가 클수록 권한이 낮습니다. 가장 안쪽인 0 은 보통 운영체제 커널처럼 중요한 소프트웨어가 씁니다. 바깥 고리는 덜 중요한 소프트웨어가 씁니다. 넷 중 둘만 쓰는 체계는 0 과 3 을 써야 한다고 Intel 문서가 적습니다.
지금 도는 코드의 수준을 CPL(Current Privilege Level, 현재 특권 수준)이라고 부릅니다.
이 값은 CS(코드 세그먼트)와 SS(스택 세그먼트) 레지스터의 아래 두 비트에 담깁니다.
레지스터는 CPU 안에 있는 작은 저장 칸입니다.
무엇이 사용자 쪽 접근인지도 이 값이 정합니다. 명령 인출 전부와 데이터 접근 대부분에서는,
CPL 이 3 인 채로 일어난 선형 주소 접근이 사용자 모드 접근이고 3 보다 작을 때 일어난 접근이
감독자 모드 접근입니다. 선형 주소는 페이지 구조를 거쳐 물리 주소로 옮겨지기 전의 주소이고,
감독자 모드는 Intel 이 사용자 모드의 반대쪽에 붙인 이름입니다.
AArch64 는 같은 것을 다른 이름으로 부릅니다. 특권 수준이 예외를 받거나 예외에서 돌아올
때만 바뀌기 때문에 특권을 예외 수준이라고 부릅니다. EL0(Exception level 0)부터 EL3
까지 넷이고 x86-64 와 반대로 숫자가 클수록 권한이 높습니다. 흔한 쓰임은 응용 코드를
EL0 에, 리눅스 같은 운영체제를 EL1 에 올리는 것입니다. 다만 어느 수준에 무엇을 올릴지를
아키텍처가 정해 주지는 않습니다.
번호가 두 아키텍처에서 반대로 흐릅니다. 사다리를 나란히 놓으면 이렇습니다.
flowchart TD
subgraph x86["x86-64 · 숫자가 클수록 권한이 낮다"]
L0["Level 0 · 운영체제 커널"]
L1["Level 1 · 운영체제 서비스"]
L2["Level 2"]
L3["Level 3 · 응용"]
L0 --> L1 --> L2 --> L3
end
subgraph arm["AArch64 · 숫자가 클수록 권한이 높다"]
E3["EL3 · 펌웨어"]
E2["EL2 · 하이퍼바이저"]
E1["EL1 · Rich OS"]
E0["EL0 · 응용"]
E3 --> E2 --> E1 --> E0
end
응용과 커널이 각각 어느 수준에 서는지만 뽑으면 이렇습니다.
| 응용 프로그램이 도는 수준 | 운영체제 커널이 도는 수준 | |
|---|---|---|
| x86-64 | CPL 3 |
CPL 0 |
| AArch64 | EL0 |
EL1 |
| 윈도우가 부르는 이름 | 사용자 모드 | 커널 모드 |
수준이 바뀌는 때는 정해져 있습니다. AArch64 문서는 예외를 받을 때, 예외에서 돌아올 때, 프로세서가 재시작될 때, 디버그 상태에 있는 동안, 디버그 상태에서 나올 때 다섯을 듭니다. 예외를 받아서 지금보다 낮은 권한으로 내려가는 일은 없습니다.
커널 모드로 넘어가려고 프로그램이 스스로 예외를 일으키는 것을 트랩이라고 부릅니다. 리눅스 매뉴얼이 시스템 콜 래퍼가 밟는 단계를 그렇게 적습니다.
stateDiagram-v2
[*] --> 사용자모드
사용자모드 --> 커널모드: 시스템 콜로 트랩한다
사용자모드 --> 커널모드: 막힌 명령을 실행해 예외가 난다
커널모드 --> 사용자모드: 예외에서 돌아온다
낮은 수준에서 막히는 것
막히는 것은 명령과 메모리 둘입니다.
명령부터 봅니다. 시스템 명령 중 일부는 특권 명령입니다. CPL 이 0 일 때만 실행되고,
0 이 아닌데 실행하면 일반 보호 예외(#GP)가 납니다. 목록에는 기술자 표를 싣는
LGDT·LIDT, 제어 레지스터를 읽고 쓰는 MOV, 프로세서를 멈추는 HLT, 변환 항목을 버리는
INVLPG, 모델 전용 레지스터를 다루는 RDMSR·WRMSR 이 들어 있습니다.
AArch64 는 레지스터 이름이 그 경계를 드러냅니다. 시스템 레지스터 이름의 접미사가 그 레지스터에
접근할 수 있는 가장 낮은 예외 수준을 가리킵니다. 그래서 SCTLR_EL1 은 EL0 에서 접근할 수
없고, 시도하면 예외가 납니다.
메모리는 접근마다 검사됩니다. EL0 에서 시작한 메모리 접근은 비특권 접근 권한으로 검사됩니다.
EL1 이상에서 시작한 접근은 특권 접근 권한으로 검사됩니다. x86-64 는 선형 주소 하나를 옮기는
데 페이지 구조 항목을 여럿 거치는데, 그중 하나라도 U/S 비트가 0 이면 그 주소를 감독자 모드
주소로 칩니다.
운영체제가 그 위에 얹는 것도 같은 방향입니다. 윈도우는 사용자 모드 응용을 띄울 때 프로세스를 하나 만들어 사설 가상 주소 공간과 사설 핸들 표를 줍니다. 주소 공간이 사설이라 한 응용이 다른 응용의 데이터를 고칠 수 없습니다. 응용 하나가 죽어도 다른 응용이나 운영체제에는 영향이 가지 않습니다. 사용자 모드 응용의 가상 주소 공간은 크기도 제한되어 운영체제용으로 예약된 가상 주소에는 닿지 못합니다.
커널로 들어가는 진입점
막힌 일을 하려면 정해진 문으로 들어가야 합니다. 리눅스 매뉴얼은 시스템 콜을 커널로 들어가는 진입점이라고 적습니다. 프로그램이 커널 코드를 아무 데나 부르는 것이 아니라, 고유한 번호가 붙은 진입점을 골라 부탁을 넣습니다.
프로그램이 이 진입점을 직접 두드리는 경우는 드뭅니다. 대개는 C 표준 라이브러리가 딸려 주는 래퍼 함수를 부르고, 그 함수가 커널 모드로 트랩하는 단계를 대신 밟습니다. 그래서 시스템 콜을 하는 것이 보통 라이브러리 함수를 부르는 것과 똑같아 보입니다.
배경
무엇이 불편했는지부터 봅니다. 요즘 소프트웨어는 여러 모듈로 나뉘어 있고 모듈마다 시스템 자원과 프로세서 자원에 닿는 정도가 다릅니다. 운영체제 커널과 사용자 응용을 가르는 것이 그 예입니다. 운영체제는 사용자 응용이 할 수 있기를 바라지 않는 일을 해야 하고, 커널은 시스템 자원에 높은 수준으로 닿아야 하는 반면 사용자 응용이 시스템을 설정하는 능력은 제한되어야 합니다. 어떤 자원을 보고 제어할 수 있는지를 정하는 것이 특권입니다.
그래서 필요한 것은 프로세서가 접근마다 검사해 주는 기구였습니다. 보호 기구는 세그먼트 수준과 페이지 수준 양쪽에서 돌며 특권 수준에 따라 접근을 제한합니다. 중요한 운영체제 코드와 데이터는 응용 코드보다 특권 높은 세그먼트에 둡니다. 그러면 프로세서 보호 기구가 응용 코드에서 그리로 가는 접근을 정해진 길 밖에서는 막습니다.
값은 거의 안 듭니다. 메모리 참조는 하나하나 검사됩니다. 검사는 메모리 사이클이 시작되기 전에 끝납니다. 주소 변환과 나란히 이루어져 성능 손해가 없습니다.
막기만 해서는 프로그램이 아무 일도 못 합니다. 그래서 운영체제는 부탁을 받는 진입점을 따로 냈습니다. 그것이 시스템 콜입니다.
이름은 아키텍처마다 갈립니다. x86-64 는 특권 수준이라 부릅니다. AArch64 는 수준이 예외를 받거나 예외에서 돌아올 때만 바뀐다는 성질을 따서 예외 수준이라 부릅니다. 윈도우는 사용자 모드와 커널 모드라고 부릅니다. 가리키는 구분은 같습니다.
예시
x86-64 에서 커널로 넘어가는 한 줄
아키텍처마다 커널 모드로 넘어가는 방법이 따로 있습니다. syscall(2) 매뉴얼이 그 표를 싣습니다.
| 아키텍처 | 넘어갈 때 쓰는 명령 | 번호를 싣는 레지스터 | 결과가 담기는 레지스터 |
|---|---|---|---|
| i386 | int $0x80 |
eax |
eax · edx |
| x86-64 | syscall |
rax |
rax · rdx |
| arm64 | svc #0 |
w8 |
x0 · x1 |
| riscv | ecall |
a7 |
a0 · a1 |
x86-64 한 줄을 따라가 보면 이렇습니다. C 표준 라이브러리의 래퍼 함수가 인자와 고유한 시스템 콜
번호를 커널이 기대하는 레지스터에 복사하고, 커널 모드로 트랩합니다. 트랩한 뒤부터 진짜 일을
하는 것은 커널입니다. 커널이 오류 번호를 돌려주며 CPU 를 사용자 모드로 되돌리면 래퍼가 errno
를 설정합니다.
오류가 돌아오는 꼴도 정해져 있습니다. 대부분의 시스템 콜은 실패할 때 음수 오류 번호를
반환합니다. 래퍼는 그 절댓값을 errno 변수에 넣은 뒤 자기 반환값으로는 -1 을 줍니다. 부르는
쪽에서는 음수 번호가 보이지 않습니다.
sequenceDiagram
participant 프로그램
participant 래퍼
participant 커널
프로그램->>래퍼: 라이브러리 함수를 부른다
래퍼->>커널: 번호를 레지스터에 싣고 트랩한다
Note over 커널: 여기서 진짜 일이 벌어진다
커널-->>래퍼: 결과 또는 음수 오류 번호
래퍼-->>프로그램: -1 과 errno
장치를 커널 드라이버에서 떼어 내는 DPDK
DPDK(Data Plane Development Kit)는 장치를 다루는 코드를 사용자 공간 프로그램 안에 넣습니다.
공식 문서는 대부분의 장치가 DPDK 에 쓰이려면 원래 쓰던 커널 드라이버에서 떼어져 vfio-pci
커널 모듈에 붙어야 하고, 그 뒤라야 애플리케이션을 돌릴 수 있다고 적습니다.
떼어 낸 값은 바로 드러납니다. 그렇게 쓰는 드라이버에서는 리눅스 제어 아래 있는 네트워크 포트나 다른 하드웨어가 무시되어 애플리케이션이 쓸 수 없습니다. 장치 하나가 커널 쪽이든 사용자 공간 쪽이든 한쪽에만 속합니다.
flowchart TD
subgraph before["떼기 전 · 리눅스 제어 아래"]
DEV1["장치"] --> KD["커널 드라이버"] --> LX["리눅스"]
end
subgraph after["vfio-pci 에 붙인 뒤"]
DEV2["장치"] --> VF["vfio-pci 커널 모듈"] --> APP["사용자 공간 애플리케이션"]
end
대가
경계를 두면 넘는 값이 듭니다. vdso(7) 매뉴얼은 커널이 내주는 시스템 콜 중에 사용자 공간
코드가 하도 자주 쓰게 되는 것이 있어서 그런 호출이 전체 성능을 지배할 수 있다고 적습니다.
까닭은 둘입니다. 호출이 잦다는 것, 그리고 사용자 공간을 나가 커널로 들어가면서 생기는 문맥
교환 부담입니다. 문맥 교환은 CPU 가 돌던 코드를 멈추고 다른 코드로 갈아타면서 레지스터 같은
상태를 갈아 끼우는 일입니다. 시스템 콜을 하는 것은 느릴 수 있고, x86 32비트에서 int $0x80
으로 소프트웨어 인터럽트를 거는 것은 비쌉니다. 프로세서 마이크로코드와 커널 양쪽의 인터럽트
처리 경로를 전부 지나기 때문입니다. 한 번 넘는 데 드는 사이클이나 나노초 수치는 자료에
없습니다(미확인).
그 값을 피해 가는 길이 따로 만들어졌다는 것 자체가 값의 크기를 보여 줍니다.
vDSO(virtual dynamic shared object, 가상 동적 공유 객체)는 커널이 모든 사용자 공간 응용의
주소 공간에 자동으로 매핑해 주는 작은 공유 라이브러리입니다. gettimeofday(2) 가 돌려주는
값은 비밀이 아니라 어느 권한으로 도는 응용이든 같은 답을 받습니다. 그래서 커널이 그 답에
필요한 정보를 프로세스가 접근할 수 있는 메모리에 놓아 둡니다. 그러면 gettimeofday(2) 호출이
시스템 콜에서 보통 함수 호출과 메모리 접근 몇 번으로 바뀝니다.
반대로 경계를 지우는 쪽을 고르면 격리를 내줍니다. 커널 모드에서 도는 코드는 모두 하나의 가상 주소 공간을 공유합니다. 그래서 커널 모드 드라이버는 다른 드라이버나 운영체제로부터 격리되지 않습니다. 커널 모드 드라이버가 잘못된 가상 주소에 쓰면 운영체제나 다른 드라이버에 속한 데이터를 망가뜨릴 수 있습니다. 그 드라이버가 죽으면 운영체제 전체가 죽습니다.
위아래 두 구역의 주소 공간 배치가 이렇게 어긋나 있습니다.
flowchart TD
subgraph 사용자모드["사용자 모드 · 응용마다 사설 주소 공간"]
A1["응용 A · 사설 주소 공간"]
A2["응용 B · 사설 주소 공간"]
A3["나머지 응용 · 각자 사설"]
end
subgraph 커널모드["커널 모드"]
subgraph 한칸["하나의 가상 주소 공간을 함께 쓴다"]
K1["커널"]
K2["드라이버 A"]
K3["드라이버 B"]
end
end
장치를 사용자 공간으로 내리는 쪽도 공짜가 아닙니다. vfio-pci 는 IOMMU
(Input-Output Memory Management Unit, 입출력 메모리 관리 장치) 보호에 기대는 드라이버입니다.
DPDK 문서는 그 보호를 포기하는 no-iommu 모드가 본질적으로 안전하지 않다고 경고합니다.
예전부터 있던 uio(Userspace I/O, 사용자 공간 입출력) 방식도 같은 보호가 없어 본질적으로
안전하지 않습니다. root 사용자만 쓸 수 있습니다.
경계
파일 시스템을 구현한 FUSE(Filesystem in Userspace) 데몬도 사용자 공간인가. 맞습니다.
FUSE 는 커널 모듈 fuse.ko 와 라이브러리 libfuse, 마운트 도구 fusermount 로 이루어진
얼개입니다. 사용자 공간 파일 시스템은 데이터와 메타데이터를 보통의 사용자 공간 프로세스가
내주는 파일 시스템입니다. 그 프로세스를 파일 시스템 데몬이라고 부릅니다. sshfs 가 그런
파일 시스템입니다.
세 조각이 경계를 어떻게 가로지르는지 보면 이렇습니다.
flowchart TD
subgraph us["사용자 공간"]
D["파일 시스템 데몬 · sshfs"]
L["libfuse"]
F["fusermount"]
end
subgraph ks["커널"]
M["fuse.ko"]
end
D --> L
L -->|"/dev/fuse 를 열어 얻은 파일 서술자"| M
F -->|"mount() 로 붙인다"| M
판정 근거는 셋입니다.
첫째, 비특권 마운트에서 파일 시스템 데몬은 마운트한 사용자의 권한으로 돕니다. 권한을 더 받는 것이 아니라 부른 사람 몫 그대로입니다.
둘째, 커널과의 연결은 /dev/fuse 를 열어 얻은 파일 서술자입니다. 파일 서술자는 열어 둔 것을
가리키는 작은 정수입니다. 그 번호를 fd=n 마운트 옵션으로 넘깁니다. 정해진 진입점을 거친다는
뜻입니다.
셋째, mount() 시스템 콜 자체가 특권 작업이라 fusermount 헬퍼가 따로 필요합니다. 이 헬퍼는
setuid root 로 설치됩니다. 실행한 사람이 아니라 root 권한으로 도는 프로그램이라는 뜻입니다.
그래서 마운트한 사람이 그 권한을 훔쳐 쓰지 못하게 막아야 합니다. 커널 문서가 드는 길은 둘입니다.
장치 파일을 담은 파일 시스템을 만들어 그 장치를 여는 것, 그리고 setuid·setgid 가 붙은 프로그램을
담아 두고 실행하는 것입니다. 막는 법은 장치 파일을 못 열게 하고 실행할 때 그 비트들을 무시하는
것이라, fusermount 는 비특권 마운트에 언제나 nosuid 와 nodev 옵션을 붙입니다.
가르는 잣대는 무슨 일을 하느냐가 아니라 어느 수준으로 도느냐입니다. 파일 시스템이라는 커널의 일을 하고 있어도, 낮은 수준으로 돌며 진입점을 거쳐 부탁을 넣는다면 사용자 공간입니다.
관련 항목
사용자 공간과 맞세워지는 반대편
커널 · 커널 공간 · 커널 모드 · 특권 모드 · 운영체제
두 구역을 가르는 실행 모드
사용자 모드 · 모드 전환 · 보호 링 · 권한 · 최소 권한
두 구역 사이를 오가는 창구
시스템 콜 · 트랩 · 인터럽트 · 표준 라이브러리 · 시스템 콜 래퍼
사용자 공간을 나눠 주는 메모리 구조
가상 메모리 · 주소 공간 · 페이지 테이블 · 메모리 관리 장치 · 페이지 폴트
사용자 공간에서 실행되는 단위
프로세스 · 스레드 · 셸 · 데몬 · 애플리케이션 서버
사용자 공간에서 도는 프로그램 갈래
웹 서버 · 브라우저 · 데이터베이스 · 디스플레이 서버 · 편집기
부탁을 넣어야 손댈 수 있는 자원
메모리 · 디스크 · 파일 시스템 · 파일 서술자 · 네트워크 장치
프로그램끼리 값을 주고받는 수단
프로세스 간 통신 · 파이프 · 소켓 · 공유 메모리 · 시그널
경계를 넘다 터지는 오류
세그멘테이션 폴트 · 커널 패닉 · 권한 상승 · 버퍼 오버플로
경계 비용을 줄이는 기법
버퍼 · 제로 카피 · 메모리 매핑 · 배치 처리 · 사용자 공간 드라이버
이 경계를 어떻게 그을지로 갈리는 커널 설계
모놀리식 커널 · 마이크로커널 · 하이브리드 커널 · 유니커널
사용자 공간을 통째로 가두는 격리 수단
컨테이너 · 가상 머신 · 네임스페이스 · 샌드박스 · chroot
사용자 공간 프로그램을 짜고 실행하는 바탕
다른 이름: user space · userspace · user land · userland · 유저 스페이스