한 줄 정의

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 명령어가 자동으로 RIPRFLAGSRCX/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가 수행하는 작업:

  1. 현재 RIP, RSP, RFLAGS를 커널 스택에 저장
  2. CPL 변경 (3→0 또는 0→3)
  3. 스택 포인터를 커널 스택으로 교체 (TSS에서 로드)
  4. 파이프라인 flush (일부 CPU에서)
  5. 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 flushX (KPTI 제외)O (프로세스 간)
비용~100-300ns~1-10μs + 간접 비용
관계컨텍스트 스위칭의 일부모드 전환을 포함

모든 컨텍스트 스위칭은 모드 전환을 포함하지만, 모든 모드 전환이 컨텍스트 스위칭은 아닙니다. read() 시스템 콜은 모드 전환만 발생시키고, 같은 프로세스로 돌아옵니다.

실무에서 만난 사례

  • 버퍼링의 이유: Java의 BufferedInputStream이 기본 8KB 버퍼를 사용하는 것은 read() 시스템 콜 횟수를 줄이기 위해서입니다. 바이트 단위로 1000번 read()를 호출하면 모드 전환만 1000회 발생하지만, 8KB 버퍼로 한 번에 읽으면 1회로 줄어듭니다.
  • epoll의 배치 처리: epollepoll_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)