한 줄 정의
CPU가 제공하는 실행 권한 수준(privilege level)으로, 유저 모드에서는 하드웨어 접근이 제한되고 커널 모드에서는 모든 명령어와 메모리에 접근할 수 있다. 운영체제가 자기 자신과 다른 프로세스를 보호하는 가장 근본적인 메커니즘이다.
쉽게 말하면
은행에 비유하면, 유저 모드는 창구 앞 고객 공간이고 커널 모드는 금고가 있는 직원 전용 구역입니다. 고객(애플리케이션)은 직접 금고(하드웨어)에 손을 댈 수 없고, 반드시 창구 직원(커널)에게 요청서(시스템 콜)를 제출해야 합니다. 직원이 요청을 검증한 뒤 대신 처리해주는 구조이기 때문에, 고객이 악의적이거나 실수를 하더라도 금고는 안전합니다.
이 “요청서 제출 → 직원이 처리 → 결과 전달” 과정이 모드 전환이며, 매번 보안 검증과 컨텍스트 스위칭 비용이 발생합니다.
왜 이걸 알아야 하는가?
- 시스템 콜 비용의 실체:
read(),write(),epoll_wait()같은 시스템 콜이 일반 함수 호출보다 느린 이유가 모드 전환 비용 때문입니다. 이를 이해해야 “왜 io_uring이 시스템 콜 자체를 줄이려 하는가”, “왜 버퍼링이 중요한가”를 설명할 수 있습니다. - 컨텍스트 스위칭 비용의 구성 요소: 컨텍스트 스위칭의 직접 비용 중 상당 부분이 유저↔커널 모드 전환입니다. Virtual Thread나 Coroutine이 “커널 모드 전환 없이 전환한다”는 것의 의미를 정확히 이해할 수 있습니다.
- 보안 취약점의 근본 원리: Meltdown, Spectre 같은 CPU 취약점은 모드 간 권한 경계를 우회하는 공격입니다. KPTI(Kernel Page Table Isolation) 패치가 왜 성능에 영향을 주는지 이해하려면 모드 분리의 원리를 알아야 합니다.
왜 이렇게 설계했는가?
- 해결하는 문제: 사용자 프로그램이 하드웨어를 직접 제어하면 다른 프로그램의 메모리를 읽거나, 디스크를 포맷하거나, 시스템 전체를 멈출 수 있습니다. 운영체제가 하드웨어 접근을 독점하고 프로그램은 반드시 OS를 거치도록 강제하는 것이 핵심입니다.
- 이게 없다면: 모든 프로그램이 특권 명령어를 실행할 수 있으므로, 하나의 버그가 시스템 전체를 크래시시킵니다. MS-DOS가 이런 구조였으며, 하나의 프로그램이 죽으면 시스템 전체가 멈추는 것이 일상이었습니다.
- 하드웨어 수준의 강제:
이 분리는 소프트웨어 약속이 아니라 CPU 하드웨어가 강제합니다.
유저 모드에서 특권 명령어(
HLT,IN/OUT,MOV CR3)를 실행하면 CPU가 즉시 예외(exception)를 발생시켜 커널에 제어를 넘깁니다.
어떻게 동작하는가?
CPU 보호 링 (Protection Rings)
x86 CPU는 4단계 보호 링(Ring 0~3)을 제공하지만, 실제로 대부분의 OS는 2단계만 사용합니다:
| Ring | 이름 | 권한 | 사용자 |
|---|---|---|---|
| Ring 0 | 커널 모드 | 모든 명령어, 모든 메모리, I/O 포트 접근 | OS 커널 |
| Ring 1, 2 | (미사용) | 중간 권한 | 이론적으로 디바이스 드라이버용이나 현대 OS에서 미사용 |
| Ring 3 | 유저 모드 | 제한된 명령어, 자신의 가상 주소 공간만 | 애플리케이션 |
왜 Ring 1, 2를 안 쓰는가? 대부분의 OS가 x86 이전부터 2단계(유저/커널) 모델로 설계되었고, Ring 1/2를 활용하면 이식성이 떨어지기 때문입니다. 다만 하이퍼바이저(VMX root/non-root)가 사실상 “Ring -1”로 동작하면서 가상화에서 링 구조가 다시 활용됩니다.
CPU의 현재 권한 수준은 CS(Code Segment) 레지스터의 하위 2비트인 **CPL(Current Privilege Level)**에 저장됩니다:
- CPL = 0: 커널 모드
- CPL = 3: 유저 모드
유저 모드에서 할 수 없는 것
| 금지 동작 | 이유 | 시도하면? |
|---|---|---|
I/O 포트 직접 접근 (IN/OUT) | 하드웨어 직접 제어 차단 | General Protection Fault (#GP) |
제어 레지스터 변경 (MOV CR3) | 페이지 테이블 조작 차단 | GP |
인터럽트 비활성화 (CLI) | 시스템 멈춤 차단 | GP |
CPU 정지 (HLT) | 시스템 멈춤 차단 | GP |
| 다른 프로세스 메모리 접근 | 격리 보장 | Page Fault → SIGSEGV |
모드 전환이 발생하는 3가지 경로
1. 시스템 콜 (System Call) — 자발적 진입
프로그램이 OS 서비스를 요청할 때:
애플리케이션: read(fd, buf, size)
↓
glibc: 레지스터에 시스템 콜 번호(0), 인자 세팅
↓
SYSCALL 명령어 (x86-64) / SVC 명령어 (ARM)
↓
CPU: CPL 3→0, RIP를 커널 entry point로 변경
↓
커널: sys_read() 실행
↓
SYSRET 명령어
↓
CPU: CPL 0→3, 유저 코드로 복귀
x86-64 시스템 콜 규약:
RAX: 시스템 콜 번호 (예:read= 0,write= 1)RDI, RSI, RDX, R10, R8, R9: 인자 (최대 6개)SYSCALL명령어가 자동으로RIP와RFLAGS를RCX/R11에 백업하고 커널 진입
INT 0x80 vs SYSCALL: 과거에는 소프트웨어 인터럽트(
INT 0x80)를 사용했으나, 현대 x86-64에서는 전용 명령어SYSCALL/SYSRET을 사용합니다. 인터럽트 방식은 IDT 조회, 스택 전환 등 오버헤드가 크기 때문에SYSCALL은 이를 최소화하도록 설계되었습니다.
2. 인터럽트 (Interrupt) — 비자발적 진입
외부 하드웨어가 CPU에 신호를 보낼 때:
- 타이머 인터럽트: 스케줄러가 컨텍스트 스위칭을 수행하는 트리거
- I/O 완료 인터럽트: 디스크/네트워크 작업 완료 통보
- 키보드/마우스 입력: 디바이스 드라이버가 처리
CPU가 인터럽트를 감지하면 현재 실행을 중단하고, IDT(Interrupt Descriptor Table)에서 핸들러 주소를 찾아 커널 모드로 전환합니다.
3. 예외 (Exception) — 오류에 의한 진입
프로그램 실행 중 문제가 발생할 때:
- Page Fault: 매핑되지 않은 가상 주소 접근 → 커널이 페이지를 로드하거나 SIGSEGV 전달
- Division by Zero: 0으로 나누기 → SIGFPE 전달
- General Protection Fault: 유저 모드에서 특권 명령 시도 → 프로세스 종료
모드 전환 비용
모드 전환 시 CPU가 수행하는 작업:
- 현재
RIP,RSP,RFLAGS를 커널 스택에 저장 - CPL 변경 (3→0 또는 0→3)
- 스택 포인터를 커널 스택으로 교체 (TSS에서 로드)
- 파이프라인 flush (일부 CPU에서)
- KPTI 환경에서는 페이지 테이블 전환 추가
| 동작 | 비용 |
|---|---|
| 일반 함수 호출 | ~1-2ns |
| 시스템 콜 (SYSCALL/SYSRET) | ~100-300ns |
| 시스템 콜 + KPTI | ~400-800ns |
| 시스템 콜 + KPTI + PCID 없음 | ~1-2μs |
시스템 콜은 일반 함수 호출보다 100~1000배 느립니다. 이것이
read()를 바이트 단위로 호출하면 안 되는 이유이며, 버퍼링과 배치 처리가 중요한 이유입니다.
vDSO — 시스템 콜 없이 커널 데이터 읽기
모든 커널 요청이 모드 전환을 필요로 하는 것은 아닙니다.
gettimeofday(), clock_gettime() 같은 읽기 전용 요청은 **vDSO(virtual Dynamic Shared Object)**를 통해 유저 모드에서 직접 처리합니다.
- 커널이 시간 정보를 공유 메모리 페이지에 주기적으로 업데이트
- 유저 프로세스는 이 페이지를 읽기 전용으로 매핑하여 시스템 콜 없이 접근
gettimeofday()가 초당 수백만 번 호출되는 환경에서 모드 전환 비용을 제거
시각화
모드 전환 과정 (시스템 콜)
sequenceDiagram participant App as 애플리케이션 (Ring 3) participant CPU as CPU participant Kernel as 커널 (Ring 0) App->>CPU: SYSCALL 명령어 Note over CPU: CPL 3 → 0 CPU->>CPU: RIP/RFLAGS 백업 (→ RCX/R11) CPU->>CPU: RSP → 커널 스택으로 교체 CPU->>Kernel: 커널 entry point로 점프 Kernel->>Kernel: 시스템 콜 테이블에서 핸들러 조회 Kernel->>Kernel: sys_read() 등 실행 Kernel->>CPU: SYSRET 명령어 Note over CPU: CPL 0 → 3 CPU->>CPU: RIP/RFLAGS 복원 CPU->>App: 유저 코드로 복귀
모드 전환의 세 경로
유저 모드 (Ring 3)
│
├── 시스템 콜 ──────→ 커널 모드 (Ring 0)
│ read(), write() 자발적, 예측 가능
│ fork(), exec()
│
├── 인터럽트 ──────→ 커널 모드 (Ring 0)
│ 타이머, I/O 완료 비자발적, 비동기
│ 키보드, 네트워크
│
└── 예외 ─────────→ 커널 모드 (Ring 0)
Page Fault 오류에 의한 진입
Division by Zero
General Protection Fault
정리
| 기준 | 유저 모드 (Ring 3) | 커널 모드 (Ring 0) |
|---|---|---|
| CPU 권한 | 제한된 명령어만 | 모든 명령어 |
| 메모리 접근 | 자신의 가상 주소 공간만 | 모든 물리/가상 메모리 |
| I/O 접근 | 불가 (시스템 콜 필요) | 직접 가능 |
| 실행 주체 | 애플리케이션, 라이브러리 | OS 커널, 디바이스 드라이버 |
| 오류 시 영향 | 해당 프로세스만 종료 | 커널 패닉 (시스템 전체 중단) |
| 전환 비용 | — | ~100-300ns (SYSCALL) |
모드 전환 vs 컨텍스트 스위칭
| 기준 | 모드 전환 | 컨텍스트 스위칭 |
|---|---|---|
| 무엇이 바뀌나 | 권한 수준 (CPL) | 실행 중인 프로세스/스레드 |
| 프로세스 교체 | X (같은 프로세스 내) | O |
| 페이지 테이블 전환 | X (KPTI 제외) | O (프로세스 간) |
| TLB flush | X (KPTI 제외) | O (프로세스 간) |
| 비용 | ~100-300ns | ~1-10μs + 간접 비용 |
| 관계 | 컨텍스트 스위칭의 일부 | 모드 전환을 포함 |
모든 컨텍스트 스위칭은 모드 전환을 포함하지만, 모든 모드 전환이 컨텍스트 스위칭은 아닙니다.
read()시스템 콜은 모드 전환만 발생시키고, 같은 프로세스로 돌아옵니다.
실무에서 만난 사례
- 버퍼링의 이유:
Java의
BufferedInputStream이 기본 8KB 버퍼를 사용하는 것은read()시스템 콜 횟수를 줄이기 위해서입니다. 바이트 단위로 1000번read()를 호출하면 모드 전환만 1000회 발생하지만, 8KB 버퍼로 한 번에 읽으면 1회로 줄어듭니다. - epoll의 배치 처리:
epoll이
epoll_wait()로 여러 이벤트를 한 번에 가져오는 설계도 시스템 콜(= 모드 전환) 횟수를 최소화하기 위한 것입니다. io_uring은 한 단계 더 나아가 submission/completion ring으로 시스템 콜 자체를 줄입니다. - KPTI 패치의 성능 영향: Meltdown 패치 이후 시스템 콜마다 커널/유저 페이지 테이블을 전환하게 되면서, I/O가 많은 워크로드에서 최대 30% 성능 저하가 관측되었습니다. PCID 지원 CPU에서는 이 영향이 크게 완화됩니다.
관련 개념
출처
- Abraham Silberschatz, “Operating System Concepts” Chapter 1.5 - Operations, Dual-Mode
- Robert Love, “Linux Kernel Development” Chapter 5 - System Calls
- Intel Software Developer’s Manual, Vol. 3A - Chapter 5 (Protection)