이미지 로딩 중...
AI Generated
2025. 11. 14. · 3 Views
Rust로 만드는 나만의 OS - 유저 모드 vs 커널 모드
운영체제의 핵심인 유저 모드와 커널 모드의 차이를 Rust로 직접 구현하며 이해합니다. CPU 권한 레벨, 시스템 콜, 컨텍스트 스위칭의 동작 원리를 실제 코드와 함께 깊이 있게 다룹니다.
목차
- CPU 권한 레벨과 링(Ring) 아키텍처 - 보안의 시작점
- 유저 모드에서 커널 모드로 - 시스템 콜의 비밀
- 커널 스택과 유저 스택 - 이중 스택 구조의 필연성
- 메모리 페이지 테이블 격리 - KPTI와 Meltdown 방어
- 컨텍스트 스위칭 - 프로세스 상태의 완벽한 보존
- 인터럽트와 예외 처리 - 비동기 이벤트의 제어권 탈취
- I/O 권한과 포트 접근 제어 - 하드웨어 격리의 마지막 퍼즐
1. CPU 권한 레벨과 링(Ring) 아키텍처 - 보안의 시작점
시작하며
여러분이 운영체제를 개발하거나 시스템 프로그래밍을 할 때, "왜 일반 애플리케이션은 하드웨어에 직접 접근할 수 없을까?"라는 질문을 해본 적 있나요? 예를 들어, 웹 브라우저가 디스크에 직접 쓰기를 시도하면 운영체제가 이를 차단하는 이유가 무엇일까요?
이런 제약은 우연이 아닙니다. CPU는 태생부터 여러 개의 권한 레벨(Privilege Level)을 가지도록 설계되었습니다.
x86/x64 아키텍처에서는 Ring 0부터 Ring 3까지 4개의 링이 존재하며, 각 링은 서로 다른 권한을 갖습니다. 만약 모든 프로그램이 동일한 권한으로 실행된다면, 악의적인 코드가 시스템 전체를 장악하거나 다른 프로그램의 메모리를 함부로 변경할 수 있습니다.
바로 이럴 때 필요한 것이 CPU 권한 레벨입니다. 이를 통해 운영체제 커널은 Ring 0(가장 높은 권한)에서 실행되고, 사용자 애플리케이션은 Ring 3(가장 낮은 권한)에서 실행되어 시스템의 안정성과 보안을 보장합니다.
개요
간단히 말해서, CPU 권한 레벨은 프로세서가 제공하는 하드웨어 수준의 보안 메커니즘입니다. 이 개념이 필요한 이유는 명확합니다.
운영체제는 여러 프로그램이 동시에 실행되는 환경을 관리해야 하는데, 모든 프로그램에 동일한 권한을 주면 시스템이 무정부 상태가 됩니다. 예를 들어, 게임 애플리케이션이 실수로 커널 메모리를 덮어쓴다면 시스템 전체가 크래시될 것입니다.
또한 악성 코드가 인터럽트 디스크립터 테이블(IDT)을 조작하여 시스템 제어권을 빼앗을 수도 있습니다. 기존의 단순한 시스템에서는 모든 코드가 같은 레벨에서 실행되었습니다.
하지만 현대 운영체제에서는 링 아키텍처를 활용하여 커널 코드는 Ring 0에서, 디바이스 드라이버는 Ring 1-2에서(실제로는 거의 사용되지 않음), 사용자 애플리케이션은 Ring 3에서 실행됩니다. 이 메커니즘의 핵심 특징은 세 가지입니다.
첫째, 낮은 링(높은 권한)의 코드는 높은 링의 리소스에 접근할 수 있지만 그 반대는 불가능합니다. 둘째, 특정 CPU 명령어(HLT, LGDT, LIDT 등)는 Ring 0에서만 실행 가능합니다.
셋째, 링 간 전환은 반드시 정해진 게이트(Gate)를 통해서만 가능합니다. 이러한 특징들이 시스템의 격리(Isolation)와 보호(Protection)를 가능하게 합니다.
코드 예제
// GDT(Global Descriptor Table)에서 코드 세그먼트의 DPL(Descriptor Privilege Level) 설정
// Ring 0 커널 코드 세그먼트 디스크립터
const KERNEL_CODE_SEGMENT: u64 =
(1 << 43) | // executable
(1 << 44) | // code/data segment
(0 << 45) | // DPL = 0 (Ring 0)
(0 << 46) | // DPL = 0 (Ring 0)
(1 << 47); // present
// Ring 3 유저 코드 세그먼트 디스크립터
const USER_CODE_SEGMENT: u64 =
(1 << 43) | // executable
(1 << 44) | // code/data segment
(1 << 45) | // DPL = 3 (Ring 3)
(1 << 46) | // DPL = 3 (Ring 3)
(1 << 47); // present
// 현재 권한 레벨 확인 (CPL: Current Privilege Level)
fn get_current_privilege_level() -> u8 {
let cs: u16;
unsafe { asm!("mov {0:x}, cs", out(reg) cs) };
(cs & 0b11) as u8 // CS 레지스터의 하위 2비트가 CPL
}
설명
이것이 하는 일: 위 코드는 x86/x64 아키텍처에서 GDT(Global Descriptor Table)를 통해 서로 다른 권한 레벨의 코드 세그먼트를 정의하고, 현재 실행 중인 코드의 권한 레벨을 확인하는 방법을 보여줍니다. 첫 번째로, KERNEL_CODE_SEGMENT와 USER_CODE_SEGMENT 상수는 세그먼트 디스크립터를 정의합니다.
각 디스크립터는 64비트 값으로, 비트 45-46이 DPL(Descriptor Privilege Level)을 나타냅니다. 커널 코드는 DPL이 0(이진수 00)이므로 Ring 0 권한을 갖고, 유저 코드는 DPL이 3(이진수 11)이므로 Ring 3 권한을 갖습니다.
43번 비트는 실행 가능(executable) 여부를, 44번 비트는 코드/데이터 세그먼트 여부를, 47번 비트는 세그먼트가 메모리에 존재하는지(present)를 나타냅니다. 두 번째로, get_current_privilege_level 함수는 인라인 어셈블리를 사용하여 CS(Code Segment) 레지스터의 값을 읽습니다.
CS 레지스터의 하위 2비트(비트 0-1)는 CPL(Current Privilege Level)을 나타내며, 이 값이 0이면 현재 Ring 0에서 실행 중이고, 3이면 Ring 3에서 실행 중임을 의미합니다. 이 정보는 디버깅이나 보안 검사 시 매우 유용합니다.
세 번째로, 실제 운영체제에서는 부팅 시 GDT를 초기화하고 LGDT 명령어로 CPU에 로드합니다. 그런 다음 각 태스크(프로세스/스레드)가 생성될 때 해당 태스크가 커널 모드로 실행될지 유저 모드로 실행될지에 따라 적절한 세그먼트 셀렉터를 CS 레지스터에 로드합니다.
세그먼트 셀렉터는 GDT의 인덱스와 요청된 권한 레벨(RPL)을 포함하며, CPU는 RPL과 DPL을 비교하여 권한 위반 여부를 검사합니다. 여러분이 이 코드를 사용하면 운영체제 커널에서 권한 레벨을 명확하게 분리할 수 있습니다.
이를 통해 사용자 프로그램이 특권 명령어를 실행하려 할 때 자동으로 General Protection Fault(GPF, 예외 번호 13)가 발생하여 시스템을 보호할 수 있습니다. 또한 메모리 보호, I/O 포트 접근 제어, 인터럽트 처리 등 모든 시스템 보안의 기초가 됩니다.
실전 팁
💡 실제 운영체제 개발 시 Ring 1과 Ring 2는 거의 사용하지 않습니다. 대부분의 현대 OS는 Ring 0(커널)과 Ring 3(사용자)만 사용하는 2-레벨 모델을 채택합니다. 가상화 환경에서는 하이퍼바이저가 Ring -1을 사용하기도 합니다.
💡 권한 레벨 전환 시 스택도 함께 전환됩니다. TSS(Task State Segment)에 각 권한 레벨별 스택 포인터(RSP0, RSP1, RSP2, RSP3)를 저장해두어야 하며, 그렇지 않으면 Double Fault가 발생할 수 있습니다.
💡 SYSCALL/SYSRET 명령어는 STAR, LSTAR, CSTAR MSR 레지스터를 통해 빠른 링 전환을 제공합니다. 전통적인 INT 0x80 방식보다 10배 이상 빠르므로 현대 운영체제에서는 이를 선호합니다.
💡 디버깅 시 QEMU의 -d int,cpu_reset 옵션을 사용하면 모든 인터럽트와 권한 레벨 변화를 로그로 확인할 수 있어 링 전환 문제를 추적하기 쉽습니다.
💡 ARM 아키텍처는 링 대신 EL0(User), EL1(Kernel), EL2(Hypervisor), EL3(Secure Monitor)라는 Exception Level을 사용합니다. 개념은 유사하지만 구현 방식이 다르므로 크로스 플랫폼 OS 개발 시 주의해야 합니다.
2. 유저 모드에서 커널 모드로 - 시스템 콜의 비밀
시작하며
여러분이 파일을 읽거나 네트워크 패킷을 전송하는 코드를 작성할 때, 내부적으로 어떤 일이 일어나는지 궁금해본 적 있나요? 예를 들어, Rust의 std::fs::File::open()은 단순한 함수 호출처럼 보이지만, 그 뒤에는 복잡한 권한 전환 메커니즘이 숨어 있습니다.
이 과정에서 가장 중요한 것이 바로 시스템 콜(System Call)입니다. 사용자 애플리케이션은 Ring 3에서 실행되므로 하드웨어에 직접 접근할 수 없습니다.
디스크 읽기, 메모리 할당, 네트워크 통신 등 모든 하드웨어 작업은 Ring 0 권한이 필요합니다. 만약 권한 전환 메커니즘이 없다면 사용자 프로그램은 아무것도 할 수 없을 것입니다.
바로 이럴 때 필요한 것이 시스템 콜입니다. 시스템 콜은 유저 모드에서 커널 모드로 안전하게 전환하여 권한이 필요한 작업을 수행하고, 다시 유저 모드로 돌아오는 표준화된 인터페이스입니다.
개요
간단히 말해서, 시스템 콜은 사용자 프로그램이 운영체제 커널의 서비스를 요청하는 공식적인 방법입니다. 이 메커니즘이 필요한 이유는 보안과 추상화 때문입니다.
모든 프로그램에 하드웨어 직접 접근을 허용하면 한 프로그램의 버그가 전체 시스템을 마비시킬 수 있습니다. 예를 들어, 두 개의 프로그램이 동시에 같은 디스크 섹터에 쓰기를 시도하면 데이터 손실이 발생합니다.
시스템 콜을 통해 커널이 중재자 역할을 하면 이런 충돌을 방지할 수 있습니다. 또한 하드웨어별 차이를 커널이 흡수하여 애플리케이션은 통일된 인터페이스만 사용하면 됩니다.
기존에는 소프트웨어 인터럽트(INT 0x80, INT 0x2E 등)를 사용했다면, 현대 시스템에서는 전용 명령어(x86-64의 SYSCALL, ARM의 SVC)를 사용하여 더 빠르게 모드를 전환할 수 있습니다. 시스템 콜의 핵심 특징은 다음과 같습니다.
첫째, 원자적(atomic) 권한 전환이 보장됩니다 - 중간에 끼어들 수 없습니다. 둘째, 레지스터를 통한 인자 전달로 오버헤드를 최소화합니다.
셋째, 커널이 유저 공간 포인터를 검증하여 메모리 침범을 방지합니다. 이러한 특징들이 안전하고 효율적인 커널-유저 통신을 가능하게 합니다.
코드 예제
// Rust로 구현한 간단한 시스템 콜 핸들러
#[naked]
unsafe extern "C" fn syscall_handler() {
// 유저 모드 레지스터 저장
asm!(
"push rcx", // 유저 RIP 저장 (SYSCALL이 자동으로 RCX에 저장)
"push r11", // 유저 RFLAGS 저장
"push rax", // 시스템 콜 번호
"push rdi", // 인자 1
"push rsi", // 인자 2
"push rdx", // 인자 3
"call {handler}", // Rust 핸들러 호출
"pop rdx",
"pop rsi",
"pop rdi",
"pop rax",
"pop r11",
"pop rcx",
"sysretq", // Ring 3로 복귀
handler = sym syscall_dispatcher,
options(noreturn)
);
}
// 시스템 콜 디스패처
extern "C" fn syscall_dispatcher(
syscall_number: u64,
arg1: u64,
arg2: u64,
arg3: u64,
) -> u64 {
match syscall_number {
0 => sys_read(arg1 as i32, arg2 as *mut u8, arg3 as usize),
1 => sys_write(arg1 as i32, arg2 as *const u8, arg3 as usize),
60 => sys_exit(arg1 as i32),
_ => return u64::MAX, // 에러: 잘못된 시스템 콜 번호
}
}
// 실제 시스템 콜 구현 예시
fn sys_write(fd: i32, buf: *const u8, count: usize) -> u64 {
// 유저 공간 포인터 검증 (매우 중요!)
if !is_user_pointer_valid(buf, count) {
return u64::MAX; // EFAULT
}
// 파일 디스크립터 유효성 검사
// 실제 쓰기 작업 수행
// ...
count as u64 // 성공: 쓴 바이트 수 반환
}
설명
이것이 하는 일: 위 코드는 x86-64 아키텍처에서 SYSCALL 명령어를 처리하는 커널 측 핸들러를 구현합니다. 사용자 프로그램이 SYSCALL을 실행하면 CPU가 자동으로 이 핸들러로 점프하고, 핸들러는 시스템 콜 번호에 따라 적절한 커널 함수를 호출합니다.
첫 번째로, syscall_handler 함수는 #[naked] 속성으로 선언되어 Rust 컴파일러가 프롤로그/에필로그 코드를 생성하지 않도록 합니다. SYSCALL 명령어가 실행되면 CPU는 자동으로 RIP(명령어 포인터)를 RCX에, RFLAGS를 R11에 저장하고, LSTAR MSR에 설정된 주소(이 핸들러)로 점프합니다.
동시에 CS 레지스터가 커널 코드 세그먼트로 변경되어 Ring 0로 전환됩니다. 핸들러는 나머지 레지스터들(RAX에는 시스템 콜 번호, RDI/RSI/RDX에는 인자들)을 스택에 저장합니다.
두 번째로, syscall_dispatcher가 호출되어 실제 시스템 콜을 처리합니다. 시스템 콜 번호에 따라 매칭을 수행하는데, 이는 Linux의 시스템 콜 테이블과 유사한 방식입니다.
예를 들어, syscall_number가 1이면 write 시스템 콜을 의미하므로 sys_write가 호출됩니다. 이때 타입 변환을 통해 범용 u64 인자들을 각 시스템 콜이 기대하는 타입으로 변환합니다.
세 번째로, sys_write 같은 실제 시스템 콜 구현은 반드시 유저 공간 포인터를 검증해야 합니다. is_user_pointer_valid 함수는 포인터가 유저 공간 범위(보통 0x0000_0000_0000_0000 ~ 0x0000_7FFF_FFFF_FFFF) 내에 있고, 해당 메모리 영역이 실제로 매핑되어 있으며, 읽기/쓰기 권한이 있는지 확인합니다.
이 검증을 건너뛰면 악의적인 프로그램이 커널 메모리 주소를 전달하여 커널 데이터를 읽거나 수정할 수 있습니다. 검증이 완료되면 실제 I/O 작업을 수행하고 결과를 RAX 레지스터를 통해 반환합니다.
마지막으로, SYSRET 명령어가 실행되어 Ring 3로 복귀합니다. SYSRET은 RCX의 값을 RIP로, R11의 값을 RFLAGS로 복원하고, CS를 유저 코드 세그먼트로 변경합니다.
이로써 사용자 프로그램의 SYSCALL 다음 명령어부터 실행이 재개됩니다. 여러분이 이 패턴을 사용하면 수백 개의 시스템 콜을 체계적으로 관리할 수 있습니다.
또한 시스템 콜 인터페이스가 안정적이므로 커널 내부 구현을 자유롭게 변경할 수 있고, seccomp 같은 보안 메커니즘을 통해 특정 시스템 콜만 허용하는 샌드박스를 구현할 수도 있습니다.
실전 팁
💡 시스템 콜 핸들러에서는 절대로 페이지 폴트가 발생하지 않도록 해야 합니다. 유저 공간 메모리 접근 시 copy_from_user, copy_to_user 같은 안전한 함수를 사용하여 페이지 폴트를 처리할 수 있어야 합니다.
💡 SYSCALL/SYSRET은 SWAPGS 명령어와 함께 사용하여 커널 GS 베이스를 활성화해야 합니다. 그렇지 않으면 per-CPU 데이터에 접근할 때 유저 공간 데이터를 참조하는 심각한 보안 취약점이 발생합니다.
💡 시스템 콜 번호는 한 번 할당되면 절대 변경하면 안 됩니다. 기존 바이너리와의 호환성을 위해 deprecated된 시스템 콜도 유지하거나 에러를 반환하도록 구현해야 합니다.
💡 성능 측정 시 시스템 콜 오버헤드는 보통 100-200 사이클 정도입니다. 빈번한 시스템 콜이 필요한 경우 vDSO(virtual dynamic shared object)를 통해 특정 시스템 콜을 유저 공간에서 직접 실행할 수 있습니다.
💡 ARM64에서는 SYSCALL 대신 SVC(Supervisor Call) 명령어를 사용하며, 시스템 콜 번호는 X8 레지스터에, 인자는 X0-X5 레지스터에 전달됩니다. 아키텍처별 차이를 추상화하는 매크로를 만들어두면 유용합니다.
3. 커널 스택과 유저 스택 - 이중 스택 구조의 필연성
시작하며
여러분이 멀티스레딩 애플리케이션을 디버깅할 때, 스택 오버플로우나 스택 손상 문제를 겪어본 적 있나요? 특히 시스템 콜 도중에 크래시가 발생하면 디버깅이 매우 어렵습니다.
스택 트레이스를 보면 유저 함수와 커널 함수가 뒤섞여 있어 혼란스러울 수 있습니다. 이런 복잡성의 원인 중 하나는 운영체제가 각 태스크마다 두 개의 스택을 관리하기 때문입니다.
유저 모드에서 실행될 때는 유저 스택을, 커널 모드에서 실행될 때는 커널 스택을 사용합니다. 만약 하나의 스택만 사용한다면, 악의적인 사용자 프로그램이 스택 오버플로우를 일으켜 커널 데이터를 덮어쓸 수 있습니다.
바로 이럴 때 필요한 것이 이중 스택 구조입니다. 권한 레벨이 변경될 때마다 스택도 함께 전환되어 커널의 실행 컨텍스트가 유저 공간으로부터 완전히 격리됩니다.
개요
간단히 말해서, 이중 스택 구조는 각 권한 레벨마다 독립적인 스택을 유지하여 보안과 격리를 보장하는 메커니즘입니다. 이것이 필요한 이유는 보안과 안정성 때문입니다.
커널 함수는 실행 중에 중요한 포인터, 반환 주소, 로컬 변수를 스택에 저장합니다. 만약 유저 프로그램이 이 스택 영역에 접근할 수 있다면, 커널의 반환 주소를 조작하여 임의의 커널 코드를 실행하는 ROP(Return-Oriented Programming) 공격이 가능합니다.
예를 들어, 2018년 발견된 Spectre 공격의 변종들 중 일부는 스택 격리가 불완전할 때 커널 메모리를 읽어낼 수 있었습니다. 또한 유저 스택이 가득 찬 상태에서 시스템 콜이 발생하면 커널 함수가 실행될 공간이 없어 시스템이 크래시됩니다.
기존의 단순한 시스템에서는 하나의 스택만 사용했지만, 현대 운영체제에서는 권한 레벨마다 별도의 스택 포인터를 TSS(Task State Segment)에 저장하고, 권한 전환 시 CPU가 자동으로 스택을 전환합니다. 이 메커니즘의 핵심 특징은 다음과 같습니다.
첫째, CPU가 하드웨어 수준에서 스택 전환을 지원하므로 소프트웨어 오버헤드가 없습니다. 둘째, 커널 스택은 항상 커널 주소 공간에 위치하여 유저 모드에서 접근 불가능합니다.
셋째, 각 스레드마다 독립적인 커널 스택을 가지므로 멀티스레딩 환경에서도 안전합니다. 이러한 특징들이 커널의 무결성을 보호합니다.
코드 예제
// TSS(Task State Segment) 구조체 정의
#[repr(C, packed(4))]
pub struct TaskStateSegment {
reserved_1: u32,
pub rsp0: u64, // Ring 0 스택 포인터 (커널 스택)
pub rsp1: u64, // Ring 1 스택 포인터 (거의 사용 안 함)
pub rsp2: u64, // Ring 2 스택 포인터 (거의 사용 안 함)
reserved_2: u64,
pub ist: [u64; 7], // Interrupt Stack Table
reserved_3: u64,
reserved_4: u16,
pub iomap_base: u16,
}
// 스레드별 커널 스택 할당
const KERNEL_STACK_SIZE: usize = 16384; // 16KB
pub struct Thread {
user_stack: VirtualAddress,
kernel_stack: VirtualAddress,
tss: &'static mut TaskStateSegment,
}
impl Thread {
pub fn new() -> Self {
// 커널 스택 할당 (커널 힙에서)
let kernel_stack = allocate_kernel_stack(KERNEL_STACK_SIZE);
// TSS에 커널 스택 포인터 설정
let tss = get_cpu_tss();
tss.rsp0 = (kernel_stack.as_u64() + KERNEL_STACK_SIZE as u64);
// 유저 스택 할당 (유저 주소 공간에서)
let user_stack = allocate_user_stack(KERNEL_STACK_SIZE);
Thread {
user_stack,
kernel_stack,
tss,
}
}
}
// 컨텍스트 스위칭 시 TSS 업데이트
pub fn switch_to_thread(thread: &Thread) {
// 새 스레드의 커널 스택을 TSS에 설정
unsafe {
let tss = get_cpu_tss();
tss.rsp0 = thread.kernel_stack.as_u64() + KERNEL_STACK_SIZE as u64;
}
}
설명
이것이 하는 일: 위 코드는 각 스레드마다 독립적인 커널 스택을 할당하고, TSS를 통해 CPU가 권한 전환 시 자동으로 올바른 커널 스택을 사용하도록 설정하는 방법을 보여줍니다. 첫 번째로, TaskStateSegment 구조체는 x86-64 아키텍처의 TSS 포맷을 정확히 따릅니다.
TSS는 원래 하드웨어 태스크 스위칭을 위한 구조체였지만, 현대 운영체제에서는 주로 스택 포인터 저장 용도로만 사용합니다. rsp0 필드가 가장 중요한데, CPU가 Ring 3에서 Ring 0로 전환할 때 이 값을 RSP 레지스터에 로드합니다.
ist 배열은 Interrupt Stack Table로, 특정 인터럽트(예: Double Fault, NMI)를 처리할 때 사용할 별도의 스택을 지정할 수 있습니다. 이는 스택 오버플로우로 인한 Double Fault를 처리할 때 필수적입니다.
두 번째로, Thread::new 함수는 새 스레드를 생성할 때 두 개의 스택을 할당합니다. 커널 스택은 allocate_kernel_stack을 통해 커널 힙(보통 0xFFFF_8000_0000_0000 이상의 주소)에서 할당되며, 유저 모드에서는 절대 접근할 수 없는 영역입니다.
유저 스택은 allocate_user_stack을 통해 유저 주소 공간(보통 0x0000_7FFF_FFFF_F000 근처)에 할당됩니다. 커널 스택의 크기는 보통 16KB로 설정하는데, 이는 재귀가 깊지 않은 커널 함수들을 실행하기에 충분한 크기입니다.
TSS의 rsp0에는 스택의 최상단 주소를 설정합니다(스택은 아래로 자라므로). 세 번째로, switch_to_thread 함수는 컨텍스트 스위칭 시 호출됩니다.
새 스레드로 전환할 때는 그 스레드의 커널 스택 포인터를 TSS에 업데이트해야 합니다. 그렇지 않으면 시스템 콜이 발생했을 때 이전 스레드의 커널 스택을 사용하게 되어 스택 손상이 발생합니다.
실제로는 유저 스택 포인터(RSP)도 복원해야 하는데, 이는 보통 컨텍스트 구조체에 저장된 레지스터 값을 복원하는 과정에서 함께 처리됩니다. 네 번째로, 실제 권한 전환이 발생하면 CPU는 다음 순서로 동작합니다.
(1) 현재 유저 스택의 SS와 RSP를 내부 버퍼에 저장 (2) TSS.rsp0 값을 RSP에 로드 (3) 커널 스택에 유저 모드의 SS, RSP, RFLAGS, CS, RIP를 PUSH (4) 인터럽트/시스템 콜 핸들러로 점프. 이 과정이 모두 원자적으로 수행되므로 중간에 인터럽트가 발생해도 안전합니다.
여러분이 이 구조를 사용하면 커널 코드가 안전하게 실행될 공간을 확보할 수 있습니다. 또한 스택 가드 페이지(unmapped page)를 커널 스택 아래에 배치하여 스택 오버플로우를 즉시 감지할 수 있고, 각 CPU 코어마다 별도의 TSS를 유지하여 멀티코어 환경에서도 동시성 문제 없이 동작합니다.
실전 팁
💡 커널 스택 크기는 신중하게 결정해야 합니다. 너무 작으면 스택 오버플로우 위험이 있고, 너무 크면 메모리 낭비입니다. Linux는 과거 8KB를 사용했으나 현재는 16KB를 기본으로 사용하며, 일부 아키텍처에서는 32KB를 사용합니다.
💡 스택 오버플로우를 방지하기 위해 커널 스택 하단에 가드 페이지(unmapped page)를 배치하세요. 스택이 넘치면 페이지 폴트가 발생하여 패닉을 일으키므로 조용히 메모리가 손상되는 것보다 훨씬 낫습니다.
💡 IST(Interrupt Stack Table)는 NMI, Double Fault, Machine Check 같은 치명적인 예외를 처리할 때 필수입니다. 특히 Double Fault는 스택 오버플로우로 인해 발생할 수 있으므로 반드시 별도의 스택을 사용해야 합니다.
💡 멀티코어 시스템에서는 각 CPU 코어마다 독립적인 TSS를 가져야 합니다. GDT에 각 코어별 TSS 디스크립터를 추가하고, 부팅 시 각 코어가 LTR 명령어로 자신의 TSS를 로드하도록 해야 합니다.
💡 디버깅 시 커널 스택 내용을 덤프하면 함수 호출 체인을 추적할 수 있습니다. 스택 프레임의 RBP(베이스 포인터) 체인을 따라가면 백트레이스를 생성할 수 있으며, DWARF 디버그 정보와 결합하면 심볼 이름까지 표시할 수 있습니다.
4. 메모리 페이지 테이블 격리 - KPTI와 Meltdown 방어
시작하며
여러분이 보안 뉴스를 팔로우한다면 2018년의 Meltdown과 Spectre 취약점을 기억하실 것입니다. 이 취약점들은 CPU의 추측 실행(Speculative Execution) 기능을 악용하여 커널 메모리를 읽어내는 심각한 보안 문제였습니다.
특히 Meltdown은 유저 모드 프로그램이 커널 메모리를 직접 읽을 수 있었습니다. 이 문제의 근본 원인은 전통적인 운영체제 설계에서 유저 프로세스의 페이지 테이블에 커널 메모리 매핑도 포함되어 있었다는 점입니다.
비록 권한 비트로 접근이 차단되어 있었지만, CPU의 추측 실행은 권한 검사 전에 데이터를 캐시에 로드하여 타이밍 공격으로 정보를 유출할 수 있었습니다. 바로 이럴 때 필요한 것이 KPTI(Kernel Page Table Isolation)입니다.
KPTI는 유저 모드와 커널 모드에서 완전히 다른 페이지 테이블을 사용하여 유저 모드에서는 커널 메모리 매핑 자체가 존재하지 않도록 합니다.
개요
간단히 말해서, KPTI는 유저 공간과 커널 공간의 페이지 테이블을 완전히 분리하여 하드웨어 취약점으로부터 커널 메모리를 보호하는 방어 기법입니다. 이 기술이 필요한 이유는 하드웨어 수준의 보안 취약점 때문입니다.
CPU는 성능을 위해 추측 실행, 비순차 실행(Out-of-Order Execution), 분기 예측(Branch Prediction) 등의 최적화를 수행합니다. 이 과정에서 권한 검사가 완료되기 전에 메모리를 읽어 캐시에 로드할 수 있습니다.
예를 들어, 유저 프로그램이 커널 주소를 읽으려 하면 결국 예외가 발생하지만, 그 사이에 캐시 상태가 변경되어 타이밍 공격으로 데이터를 추론할 수 있습니다. KPTI를 사용하면 유저 모드의 페이지 테이블에는 최소한의 커널 코드(시스템 콜 진입점 등)만 매핑되므로 이런 공격을 원천 차단합니다.
기존에는 하나의 페이지 테이블(CR3 레지스터에 저장된 PML4)을 사용하여 유저와 커널 매핑을 모두 포함했다면, KPTI에서는 두 개의 페이지 테이블을 유지하고 커널 모드 진입/복귀 시 CR3를 전환합니다. KPTI의 핵심 특징은 다음과 같습니다.
첫째, 유저 페이지 테이블에는 유저 메모리와 최소한의 트램폴린 코드만 매핑됩니다. 둘째, 커널 페이지 테이블에는 모든 메모리(유저+커널)가 매핑됩니다.
셋째, 권한 전환마다 CR3 레지스터를 변경하여 페이지 테이블을 전환합니다. 이러한 구조가 하드웨어 취약점에도 안전한 격리를 제공합니다.
코드 예제
// 유저 페이지 테이블과 커널 페이지 테이블 쌍
pub struct KPTIPageTables {
user_cr3: PhysicalAddress, // 유저 모드에서 사용할 PML4
kernel_cr3: PhysicalAddress, // 커널 모드에서 사용할 PML4
}
// 유저 페이지 테이블 초기화 (최소한의 매핑만)
fn create_user_page_table(kernel_tables: &PageTables) -> PageTables {
let mut user_tables = PageTables::new();
// 1. 유저 공간 메모리 매핑 (0x0000_0000_0000_0000 ~ 0x0000_7FFF_FFFF_FFFF)
user_tables.clone_user_mappings_from(kernel_tables);
// 2. 시스템 콜 진입 트램폴린만 매핑 (읽기 전용, 실행 가능)
user_tables.map_page(
VirtualAddress::new(SYSCALL_TRAMPOLINE_ADDR),
PhysicalAddress::new(get_trampoline_phys()),
PageFlags::PRESENT | PageFlags::USER_ACCESSIBLE,
);
// 3. 커널 메모리는 전혀 매핑하지 않음!
// 유저 모드에서 커널 주소를 참조하면 페이지 폴트 발생
user_tables
}
// 시스템 콜 진입 시 페이지 테이블 전환
#[naked]
unsafe extern "C" fn syscall_entry_trampoline() {
asm!(
// 현재 CR3는 유저 페이지 테이블을 가리킴
"swapgs", // 커널 GS 활성화
"mov gs:0x00, rsp", // 유저 RSP 저장
"mov rsp, gs:0x08", // 커널 RSP 로드
// 커널 페이지 테이블로 전환
"mov rax, gs:0x10", // 커널 CR3 로드
"mov cr3, rax", // CR3 전환 (TLB 플러시 발생!)
// 이제 커널 메모리 접근 가능
"call {handler}",
handler = sym syscall_handler,
options(noreturn)
);
}
// 유저 모드 복귀 시 페이지 테이블 전환
unsafe fn return_to_userspace(user_cr3: u64, user_rsp: u64, user_rip: u64) {
asm!(
"mov cr3, {user_cr3}", // 유저 페이지 테이블로 전환
"mov rsp, {user_rsp}", // 유저 스택 복원
"swapgs", // 유저 GS 복원
"sysretq", // 유저 모드로 복귀
user_cr3 = in(reg) user_cr3,
user_rsp = in(reg) user_rsp,
in("rcx") user_rip, // RCX = 복귀 주소
options(noreturn)
);
}
설명
이것이 하는 일: 위 코드는 KPTI를 구현하여 유저 모드에서는 커널 메모리가 전혀 보이지 않도록 하고, 시스템 콜 진입/복귀 시 안전하게 페이지 테이블을 전환하는 방법을 보여줍니다. 첫 번째로, KPTIPageTables 구조체는 각 프로세스마다 두 개의 페이지 테이블 루트 주소를 유지합니다.
user_cr3는 유저 모드에서 사용할 PML4(Page Map Level 4) 테이블의 물리 주소이고, kernel_cr3는 커널 모드에서 사용할 PML4의 물리 주소입니다. 프로세스 컨텍스트 스위칭 시 현재 모드에 맞는 CR3 값을 로드해야 합니다.
두 번째로, create_user_page_table 함수는 유저 페이지 테이블을 생성하되 최소한의 매핑만 포함합니다. 유저 공간 메모리(0x0000_...
주소 범위)는 그대로 매핑하고, 시스템 콜 진입을 위한 작은 트램폴린 코드 페이지만 매핑합니다. 중요한 점은 커널 메모리(보통 0xFFFF_8000_0000_0000 이상)는 전혀 매핑하지 않는다는 것입니다.
따라서 유저 모드에서 커널 주소를 참조하려 하면 즉시 페이지 폴트가 발생하며, 추측 실행도 매핑되지 않은 주소에서는 데이터를 읽을 수 없습니다. 세 번째로, syscall_entry_trampoline은 SYSCALL 명령어가 실행되면 가장 먼저 실행되는 코드입니다.
이 시점에서는 아직 유저 페이지 테이블이 활성화되어 있으므로 커널 메모리에 접근할 수 없습니다. 따라서 트램폴린은 유저 페이지 테이블에도 매핑되어 있어야 합니다.
SWAPGS로 커널 GS 베이스를 활성화하면 per-CPU 데이터에 접근할 수 있게 되고, 여기서 커널 스택 포인터와 커널 CR3를 가져옵니다. 그런 다음 MOV CR3, RAX로 커널 페이지 테이블로 전환하는데, 이 순간 TLB(Translation Lookaside Buffer)가 플러시되어 성능에 영향을 줍니다.
하지만 보안을 위해 감수해야 할 비용입니다. 네 번째로, return_to_userspace 함수는 시스템 콜 처리를 마치고 유저 모드로 복귀할 때 호출됩니다.
커널 페이지 테이블에서 유저 페이지 테이블로 다시 전환하고, 유저 스택 포인터를 복원한 후 SYSRET으로 복귀합니다. 이 전환 과정에서도 TLB가 플러시되므로 KPTI를 사용하면 시스템 콜 성능이 약 5-30% 저하됩니다.
Intel CPU의 경우 PCID(Process Context Identifier)를 사용하면 TLB 플러시를 일부 회피할 수 있어 성능 저하를 줄일 수 있습니다. 다섯 번째로, 트램폴린 코드의 크기는 최소화해야 합니다.
트램폴린이 유저 페이지 테이블에 매핑되어 있으므로 여기서 취약점이 발생하면 공격 표면이 됩니다. 따라서 CR3 전환만 수행하고 즉시 실제 커널 핸들러로 점프해야 합니다.
Linux는 이를 위해 별도의 "entry trampoline" 섹션을 두고 여기에 최소한의 코드만 배치합니다. 여러분이 KPTI를 구현하면 Meltdown 공격을 완전히 차단할 수 있습니다.
또한 커널 메모리 레이아웃이 유출되는 것을 방지하여 KASLR(Kernel Address Space Layout Randomization) 우회 공격도 어렵게 만듭니다. 단점은 성능 저하이지만, 최신 CPU들은 하드웨어 수준에서 이 문제를 해결했습니다(Intel의 경우 "Meltdown 패치"가 적용된 CPU부터).
실전 팁
💡 PCID(Process Context Identifier)를 사용하면 CR3 전환 시 TLB를 완전히 플러시하지 않아도 됩니다. CR3의 상위 12비트에 PCID를 설정하고, CR4.PCIDE를 활성화하면 각 페이지 테이블에 ID를 부여하여 TLB 엔트리를 구분할 수 있습니다.
💡 모든 CPU가 Meltdown에 취약한 것은 아닙니다. AMD CPU는 대부분 취약하지 않으며, 최신 Intel CPU(10세대 이후)도 하드웨어 수정이 적용되었습니다. CPUID를 검사하여 KPTI가 필요한지 동적으로 결정할 수 있습니다.
💡 트램폴린 코드는 절대로 조건 분기를 포함하면 안 됩니다. 분기 예측 실패 시 추측 실행이 발생하여 새로운 공격 표면이 될 수 있습니다. 가능한 한 직선형 코드(straight-line code)로 작성하세요.
💡 성능 측정 시 시스템 콜 집약적인 워크로드(예: read/write 루프)에서는 KPTI의 영향이 크지만, CPU 집약적인 워크로드에서는 거의 영향이 없습니다. 벤치마크로 실제 워크로드에서의 영향을 측정하세요.
💡 ARM64는 KPTI 대신 KPTI와 유사한 "Kernel Page Table Isolation"을 사용하지만, TTBR0(유저 페이지 테이블)와 TTBR1(커널 페이지 테이블) 레지스터를 통해 더 효율적으로 구현할 수 있습니다.
5. 컨텍스트 스위칭 - 프로세스 상태의 완벽한 보존
시작하며
여러분이 멀티태스킹 환경에서 작업할 때, 여러 프로그램이 동시에 실행되는 것처럼 느껴지지만 실제로는 CPU가 매우 빠르게 프로세스를 전환하고 있다는 것을 알고 계시나요? 예를 들어, 음악을 들으면서 코드를 작성하고 웹 브라우징을 할 때, CPU는 밀리초 단위로 각 프로세스에 시간을 할당합니다.
이런 전환이 가능한 이유는 운영체제가 각 프로세스의 상태를 완벽하게 저장하고 복원할 수 있기 때문입니다. 프로세스 A가 실행 중일 때 모든 레지스터, 스택 포인터, 프로그램 카운터가 특정 값을 가지는데, 프로세스 B로 전환할 때 이 값들을 모두 저장해두었다가 다시 A로 돌아올 때 정확히 복원해야 합니다.
하나라도 잘못 저장되면 프로세스가 크래시됩니다. 바로 이럴 때 필요한 것이 컨텍스트 스위칭입니다.
컨텍스트 스위칭은 현재 프로세스의 모든 실행 컨텍스트를 저장하고, 다음 프로세스의 컨텍스트를 복원하는 운영체제의 핵심 메커니즘입니다.
개요
간단히 말해서, 컨텍스트 스위칭은 CPU가 한 프로세스의 실행을 중단하고 다른 프로세스를 실행하기 위해 모든 실행 상태를 교체하는 과정입니다. 이 메커니즘이 필요한 이유는 멀티태스킹과 공정한 자원 분배 때문입니다.
단일 CPU 코어는 한 번에 하나의 명령어 스트림만 실행할 수 있지만, 사용자는 여러 프로그램을 동시에 실행하길 원합니다. 예를 들어, 컴파일러가 코드를 컴파일하는 동안에도 텍스트 에디터는 키 입력에 즉시 반응해야 합니다.
만약 컨텍스트 스위칭 없이 컴파일러가 끝날 때까지 기다려야 한다면 사용자 경험이 매우 나빠질 것입니다. 또한 우선순위가 높은 태스크(예: 인터럽트 핸들러)가 즉시 실행되려면 현재 태스크를 중단하고 전환할 수 있어야 합니다.
기존의 협력적 멀티태스킹(Cooperative Multitasking)에서는 프로세스가 자발적으로 CPU를 양보했지만, 현대의 선점형 멀티태스킹(Preemptive Multitasking)에서는 타이머 인터럽트를 통해 운영체제가 강제로 프로세스를 전환할 수 있습니다. 컨텍스트 스위칭의 핵심 특징은 다음과 같습니다.
첫째, 모든 레지스터(범용, 세그먼트, 제어 레지스터)를 정확히 보존해야 합니다. 둘째, 페이지 테이블(CR3), 스택 포인터(RSP), 프로그램 카운터(RIP)를 전환해야 합니다.
셋째, FPU/SSE/AVX 같은 확장 레지스터 상태도 저장해야 합니다. 넷째, 가능한 한 빠르게 수행되어야 합니다(보통 1-10 마이크로초).
이러한 특징들이 원활한 멀티태스킹을 가능하게 합니다.
코드 예제
// CPU 컨텍스트 구조체 (저장할 레지스터들)
#[repr(C)]
pub struct CpuContext {
// 범용 레지스터 (callee-saved)
r15: u64, r14: u64, r13: u64, r12: u64,
rbx: u64, rbp: u64,
// 복귀 주소 (switch_context가 반환될 위치)
rip: u64,
// 스택 포인터
rsp: u64,
// 세그먼트 레지스터
cs: u64, ss: u64,
// RFLAGS
rflags: u64,
// 페이지 테이블
cr3: u64,
}
// 프로세스 제어 블록 (PCB)
pub struct Process {
pid: ProcessId,
context: CpuContext,
kernel_stack: VirtualAddress,
state: ProcessState,
}
// 컨텍스트 스위칭 수행 (어셈블리 구현)
#[naked]
unsafe extern "C" fn switch_context(
old_ctx: *mut CpuContext,
new_ctx: *const CpuContext,
) {
asm!(
// 현재 프로세스의 레지스터 저장
"mov [rdi + 0x00], r15",
"mov [rdi + 0x08], r14",
"mov [rdi + 0x10], r13",
"mov [rdi + 0x18], r12",
"mov [rdi + 0x20], rbx",
"mov [rdi + 0x28], rbp",
// 복귀 주소와 스택 저장
"lea rax, [rip + 1f]", // 1: 레이블의 주소
"mov [rdi + 0x30], rax", // RIP 저장
"mov [rdi + 0x38], rsp", // RSP 저장
// 새 프로세스의 레지스터 복원
"mov r15, [rsi + 0x00]",
"mov r14, [rsi + 0x08]",
"mov r13, [rsi + 0x10]",
"mov r12, [rsi + 0x18]",
"mov rbx, [rsi + 0x20]",
"mov rbp, [rsi + 0x28]",
"mov rsp, [rsi + 0x38]", // 스택 전환
// 페이지 테이블 전환
"mov rax, [rsi + 0x50]",
"mov cr3, rax", // TLB 플러시
// 새 프로세스로 점프 (저장된 RIP로)
"jmp [rsi + 0x30]",
"1:", // 복귀 지점 레이블
"ret",
options(noreturn)
);
}
// 스케줄러에서 호출
pub fn schedule() {
let current = get_current_process();
let next = pick_next_process();
if current.pid != next.pid {
unsafe {
switch_context(
&mut current.context as *mut CpuContext,
&next.context as *const CpuContext,
);
}
// 여기서 복귀하면 이미 새 프로세스로 전환된 상태!
}
}
설명
이것이 하는 일: 위 코드는 운영체제 스케줄러가 한 프로세스의 실행을 중단하고 다른 프로세스로 전환하는 저수준 메커니즘을 구현합니다. 모든 CPU 상태를 메모리에 저장하고, 새 프로세스의 상태를 로드하여 마치 처음부터 실행되고 있던 것처럼 만듭니다.
첫 번째로, CpuContext 구조체는 프로세스의 실행 상태를 표현합니다. x86-64 호출 규약에서 callee-saved 레지스터(R12-R15, RBX, RBP)는 함수 호출 간에 보존되어야 하므로 이들을 저장합니다.
반면 caller-saved 레지스터(RAX, RCX, RDX 등)는 함수 호출 시 손실될 수 있으므로 컨텍스트 스위칭 전에 이미 스택에 저장되어 있습니다. RIP(복귀 주소)와 RSP(스택 포인터)는 가장 중요한데, 이들이 잘못되면 프로세스가 엉뚱한 코드를 실행하거나 크래시됩니다.
CR3는 페이지 테이블 루트 주소로, 각 프로세스는 독립적인 가상 주소 공간을 가지므로 반드시 전환해야 합니다. 두 번째로, switch_context 함수는 실제 전환을 수행하는 핵심 코드입니다.
첫 번째 인자 old_ctx는 현재 프로세스의 컨텍스트를 저장할 위치이고, new_ctx는 전환할 프로세스의 컨텍스트입니다. "LEA RAX, [RIP + 1f]" 명령어가 핵심인데, 이는 현재 명령어 다음 위치(레이블 1:)의 주소를 계산하여 RAX에 저장합니다.
이 주소가 나중에 새 프로세스에서 돌아왔을 때 실행을 재개할 위치입니다. 스택 전환("MOV RSP, [RSI + 0x38]") 이후에는 완전히 다른 스택에서 실행되므로 로컬 변수들은 더 이상 유효하지 않습니다.
세 번째로, 페이지 테이블 전환("MOV CR3, RAX")은 가상 주소 공간을 완전히 바꿉니다. 예를 들어, 프로세스 A에서 주소 0x1000은 물리 메모리 0x12000을 가리키지만, 프로세스 B에서 같은 주소 0x1000은 0x45000을 가리킬 수 있습니다.
CR3를 변경하는 순간 TLB가 플러시되어 주소 변환 캐시가 무효화됩니다. 이후 메모리 접근은 새 페이지 테이블을 기반으로 수행됩니다.
PCID를 사용하면 TLB 플러시를 회피할 수 있어 성능이 향상됩니다. 네 번째로, "JMP [RSI + 0x30]" 명령어가 실행되면 제어 흐름이 새 프로세스의 저장된 RIP로 이동합니다.
만약 새 프로세스가 처음 생성된 것이라면 이 RIP는 프로세스 초기화 함수를 가리킬 것이고, 이미 실행 중이었던 프로세스라면 이전에 컨텍스트 스위칭 당했던 지점(레이블 1:)을 가리킬 것입니다. 어느 쪽이든 새 프로세스는 자신이 중단된 적이 없는 것처럼 계속 실행됩니다.
다섯 번째로, schedule 함수는 실제 스케줄링 로직을 포함합니다. 타이머 인터럽트나 시스템 콜에서 이 함수를 호출하면, 스케줄러는 실행 큐에서 다음 프로세스를 선택합니다(라운드 로빈, 우선순위 기반 등의 알고리즘 사용).
switch_context 호출 후에는 현재 코드가 즉시 계속 실행되지 않고, 나중에 이 프로세스가 다시 스케줄링될 때 switch_context 다음 줄부터 실행이 재개됩니다. 이것이 컨텍스트 스위칭의 "마법"입니다 - 함수 호출이지만 복귀 시점이 훨씬 나중입니다.
여섯 번째로, 실제 운영체제에서는 FPU/SSE/AVX 레지스터도 저장해야 합니다. 이들은 크기가 크므로(AVX-512는 512바이트 이상) 지연 저장(lazy save) 기법을 사용합니다.
CR0.TS 비트를 설정하여 FPU 사용 시 예외를 발생시키고, 그때만 이전 프로세스의 FPU 상태를 저장하고 새 프로세스의 상태를 복원합니다. 이렇게 하면 FPU를 사용하지 않는 프로세스는 오버헤드를 피할 수 있습니다.
여러분이 이 메커니즘을 구현하면 진정한 멀티태스킹 운영체제를 만들 수 있습니다. 각 프로세스는 자신만의 CPU를 독점하는 것처럼 느끼지만, 실제로는 밀리초 단위로 공유되고 있습니다.
또한 컨텍스트 스위칭 시간을 최소화하면 시스템 전체 성능이 향상되므로, 불필요한 레지스터 저장을 피하고, PCID를 활용하고, 캐시 지역성을 고려하는 등의 최적화가 중요합니다.
실전 팁
💡 FPU/SIMD 레지스터는 지연 저장(lazy save)을 사용하세요. XSAVE/XRSTOR 명령어를 사용하면 실제로 사용된 레지스터만 저장하여 시간을 절약할 수 있습니다. XSAVEOPT는 이전 값과 비교하여 변경된 부분만 저장하는 최적화도 제공합니다.
💡 컨텍스트 스위칭 빈도를 측정하고 조정하세요. 타이머 인터럽트 주기가 너무 짧으면 오버헤드가 크고, 너무 길면 응답성이 떨어집니다. Linux는 보통 1-10ms를 사용하며, 실시간 시스템은 100us 이하를 사용하기도 합니다.
💡 스케줄러에서 같은 프로세스를 연속으로 선택한 경우 컨텍스트 스위칭을 생략하는 최적화가 가능합니다. 위 코드의 if current.pid != next.pid 검사가 이를 수행합니다.
💡 멀티코어 시스템에서는 각 코어가 독립적으로 스케줄링을 수행하므로 per-CPU 실행 큐를 사용합니다. 프로세스를 다른 코어로 이동(migration)할 때는 캐시 무효화를 고려해야 하므로 가능한 한 같은 코어에서 실행하는 것이 좋습니다(CPU affinity).
💡 디버깅 시 각 프로세스의 컨텍스트를 덤프하면 모든 프로세스의 상태를 볼 수 있습니다. GDB의 "info threads"와 유사한 기능을 구현하면 데드락이나 무한 루프 디버깅에 유용합니다.
6. 인터럽트와 예외 처리 - 비동기 이벤트의 제어권 탈취
시작하며
여러분이 키보드를 누르거나 네트워크 패킷이 도착할 때, CPU는 어떻게 이를 즉시 인지하고 처리할 수 있을까요? 만약 운영체제가 주기적으로 "키보드가 눌렸나?" 확인하는 폴링(polling) 방식을 사용한다면 응답 시간이 느리고 CPU가 낭비될 것입니다.
이런 비동기 이벤트를 효율적으로 처리하기 위해 하드웨어는 인터럽트(Interrupt) 메커니즘을 제공합니다. 인터럽트는 CPU가 현재 실행 중인 코드를 즉시 중단하고, 미리 정의된 핸들러로 점프하여 이벤트를 처리한 후 다시 원래 코드로 돌아오는 메커니즘입니다.
또한 예외(Exception)는 CPU 내부에서 발생하는 동기적 이벤트로, 0으로 나누기, 페이지 폴트, 잘못된 명령어 등을 처리합니다. 바로 이럴 때 필요한 것이 인터럽트 디스크립터 테이블(IDT)과 예외 핸들러입니다.
이들은 유저 모드와 커널 모드 전환의 또 다른 중요한 경로를 제공하며, 시스템의 안정성과 보안에 필수적입니다.
개요
간단히 말해서, 인터럽트는 하드웨어가 CPU에게 즉시 처리가 필요한 이벤트를 알리는 메커니즘이고, 예외는 CPU가 프로그램 실행 중 문제를 감지했을 때 발생하는 이벤트입니다. 이 메커니즘이 필요한 이유는 효율성과 안정성 때문입니다.
인터럽트 없이 I/O를 처리하려면 CPU가 계속 디바이스 상태를 확인해야 하는데, 이는 엄청난 낭비입니다. 예를 들어, 디스크 읽기는 수 밀리초가 걸리는데, 그동안 CPU가 다른 작업을 하지 못하고 대기한다면 시스템 성능이 크게 떨어집니다.
인터럽트를 사용하면 디스크에 읽기를 요청한 후 다른 프로세스를 실행하다가, 읽기가 완료되면 인터럽트를 받아 처리할 수 있습니다. 예외 처리는 더욱 중요한데, 페이지 폴트를 적절히 처리하지 않으면 프로그램이 크래시되고, Double Fault를 처리하지 않으면 시스템 전체가 재부팅됩니다.
기존의 단순한 시스템에서는 폴링 방식을 사용했지만, 현대 시스템에서는 인터럽트 기반 I/O를 사용하며, MSI(Message Signaled Interrupts)로 더욱 발전했습니다. 인터럽트와 예외의 핵심 특징은 다음과 같습니다.
첫째, 비동기성 - 인터럽트는 언제든지 발생할 수 있습니다. 둘째, 원자성 - 인터럽트 처리는 중단되지 않도록 보호됩니다(중첩 인터럽트 제외).
셋째, 컨텍스트 저장 - 현재 실행 상태를 스택에 자동 저장합니다. 넷째, 권한 전환 - 인터럽트 핸들러는 항상 Ring 0에서 실행됩니다.
이러한 특징들이 안전하고 효율적인 이벤트 처리를 가능하게 합니다.
코드 예제
// IDT 엔트리 구조체
#[repr(C, packed)]
pub struct IdtEntry {
offset_low: u16, // 핸들러 주소 하위 16비트
selector: u16, // 코드 세그먼트 셀렉터 (커널 코드)
ist: u8, // Interrupt Stack Table 인덱스
flags: u8, // 타입, DPL, Present 비트
offset_mid: u16, // 핸들러 주소 중간 16비트
offset_high: u32, // 핸들러 주소 상위 32비트
reserved: u32,
}
impl IdtEntry {
pub fn new(handler: unsafe extern "C" fn(), ist: u8, dpl: u8) -> Self {
let addr = handler as u64;
IdtEntry {
offset_low: (addr & 0xFFFF) as u16,
selector: 0x08, // 커널 코드 세그먼트
ist,
flags: 0x8E | (dpl << 5), // Present | Type=InterruptGate | DPL
offset_mid: ((addr >> 16) & 0xFFFF) as u16,
offset_high: ((addr >> 32) & 0xFFFFFFFF) as u32,
reserved: 0,
}
}
}
// IDT 초기화
pub fn init_idt() {
let mut idt = [IdtEntry::default(); 256];
// CPU 예외 핸들러 등록
idt[0] = IdtEntry::new(divide_error_handler, 0, 0);
idt[13] = IdtEntry::new(general_protection_fault_handler, 0, 0);
idt[14] = IdtEntry::new(page_fault_handler, 1, 0); // IST 1 사용
// 하드웨어 인터럽트 핸들러 등록
idt[32] = IdtEntry::new(timer_interrupt_handler, 0, 0);
idt[33] = IdtEntry::new(keyboard_interrupt_handler, 0, 0);
// 시스템 콜 (INT 0x80) - DPL=3으로 유저 모드 허용
idt[0x80] = IdtEntry::new(syscall_int_handler, 0, 3);
// LIDT 명령어로 IDT 로드
let idtr = IdtRegister {
limit: (core::mem::size_of_val(&idt) - 1) as u16,
base: &idt as *const _ as u64,
};
unsafe { asm!("lidt [{}]", in(reg) &idtr) };
}
// 페이지 폴트 핸들러 예시
#[naked]
unsafe extern "C" fn page_fault_handler() {
asm!(
"push rax",
"push rcx",
"push rdx",
"mov rdi, cr2", // CR2 = 폴트 주소
"mov rsi, [rsp + 24]", // 에러 코드 (CPU가 자동으로 푸시)
"call {handler}",
"pop rdx",
"pop rcx",
"pop rax",
"add rsp, 8", // 에러 코드 제거
"iretq", // 인터럽트 복귀
handler = sym page_fault_handler_impl,
options(noreturn)
);
}
// 실제 페이지 폴트 처리 로직
extern "C" fn page_fault_handler_impl(fault_addr: u64, error_code: u64) {
if error_code & 0x1 == 0 {
// 페이지가 없음 (Not Present) - 스왑에서 로드하거나 할당
allocate_page(fault_addr);
} else if error_code & 0x2 != 0 {
// 쓰기 권한 위반 - Copy-on-Write 처리
handle_copy_on_write(fault_addr);
} else {
// 진짜 오류 - 프로세스 종료
panic!("Page fault at {:#x}, error code: {:#x}", fault_addr, error_code);
}
}
설명
이것이 하는 일: 위 코드는 인터럽트 디스크립터 테이블(IDT)을 설정하여 CPU가 인터럽트나 예외 발생 시 적절한 커널 핸들러를 호출할 수 있도록 합니다. 각 핸들러는 이벤트를 처리하고 원래 실행 흐름으로 안전하게 복귀합니다.
첫 번째로, IdtEntry 구조체는 x86-64 IDT 엔트리의 16바이트 포맷을 정확히 구현합니다. 핸들러 주소는 64비트인데 세 부분(low, mid, high)으로 나뉘어 저장됩니다.
selector 필드는 핸들러가 실행될 코드 세그먼트를 지정하는데, 보통 커널 코드 세그먼트(GDT 인덱스 1, 셀렉터 0x08)를 사용합니다. ist 필드는 IST(Interrupt Stack Table)를 지정하여 특정 인터럽트에 대해 별도의 스택을 사용할 수 있습니다.
flags는 DPL(Descriptor Privilege Level)을 포함하는데, 대부분 0(Ring 0만 호출 가능)이지만 INT 0x80 같은 시스템 콜은 3(Ring 3도 호출 가능)으로 설정합니다. 두 번째로, init_idt 함수는 256개의 IDT 엔트리를 초기화합니다.
x86-64는 0-31번을 CPU 예외용으로 예약하고(나누기 오류, 페이지 폴트 등), 32-255번을 외부 인터럽트와 소프트웨어 인터럽트용으로 사용합니다. 페이지 폴트 핸들러는 ist=1로 설정되어 있는데, 이는 커널 스택 오버플로우로 인한 페이지 폴트를 처리할 때 별도의 스택이 필요하기 때문입니다.
TSS의 IST[1]에 미리 할당된 스택 주소를 설정해두면 페이지 폴트 발생 시 CPU가 자동으로 그 스택으로 전환합니다. 세 번째로, LIDT 명령어로 IDT의 주소와 크기를 CPU에 알립니다.
이후 인터럽트나 예외가 발생하면 CPU는 벡터 번호(0-255)를 인덱스로 사용하여 IDT에서 해당 엔트리를 찾고, 거기 저장된 핸들러 주소로 점프합니다. 이때 CPU는 자동으로 다음 작업을 수행합니다: (1) 현재 SS, RSP, RFLAGS, CS, RIP를 스택에 푸시 (2) 일부 예외는 에러 코드도 푸시 (3) CS를 엔트리의 selector로 변경 (4) IF 플래그를 클리어하여 인터럽트 비활성화 (5) 핸들러로 점프.
네 번째로, page_fault_handler 어셈블리 코드는 최소한의 레지스터만 저장하고 Rust 함수 page_fault_handler_impl을 호출합니다. CR2 레지스터에는 페이지 폴트를 일으킨 가상 주소가 저장되어 있고, 에러 코드는 CPU가 이미 스택에 푸시해두었습니다.
에러 코드의 비트들은 폴트 유형을 나타냅니다: 비트 0은 페이지 존재 여부, 비트 1은 읽기/쓰기, 비트 2는 유저/커널 모드, 비트 3은 예약 비트 위반, 비트 4는 명령어 페치 여부를 나타냅니다. 이 정보로 폴트 원인을 정확히 파악할 수 있습니다.
다섯 번째로, page_fault_handler_impl은 에러 코드를 분석하여 적절한 처리를 수행합니다. Present 비트가 0이면 페이지가 메모리에 없는 것이므로, demand paging이나 스왑에서 로드해야 합니다.
Write 비트가 1이고 페이지가 read-only라면 Copy-on-Write를 처리합니다. 만약 진짜 잘못된 접근이라면 프로세스를 종료합니다(유저 모드) 또는 커널 패닉을 일으킵니다(커널 모드).
여섯 번째로, IRETQ 명령어로 인터럽트에서 복귀합니다. IRETQ는 스택에서 RIP, CS, RFLAGS, RSP, SS를 팝하여 복원하는데, 이때 CS가 유저 코드 세그먼트라면 자동으로 Ring 3로 전환되고 유저 스택도 복원됩니다.
이 모든 과정이 원자적으로 수행되어 권한 전환 중 공격받을 여지가 없습니다. 여러분이 이 시스템을 구현하면 모든 비동기 이벤트를 일관되게 처리할 수 있습니다.
타이머 인터럽트로 선점형 스케줄링을 구현하고, I/O 완료 인터럽트로 효율적인 디바이스 드라이버를 작성하며, 페이지 폴트로 가상 메모리를 구현할 수 있습니다. 또한 예외 처리를 통해 시스템을 안정적으로 유지하고 디버깅 정보를 제공할 수 있습니다.
실전 팁
💡 인터럽트 핸들러는 최대한 짧게 유지하세요. 실제 작업은 bottom half(work queue, tasklet)로 연기하여 인터럽트 비활성화 시간을 최소화해야 합니다. 그렇지 않으면 다른 인터럽트를 놓칠 수 있습니다.
💡 중첩 인터럽트를 허용하려면 핸들러 초반에 STI 명령어로 인터럽트를 재활성화하세요. 단, 재진입 가능한(reentrant) 코드만 사용해야 하며, 스택 오버플로우를 방지하기 위해 중첩 깊이를 제한해야 합니다.
💡 APIC(Advanced Programmable Interrupt Controller)를 올바르게 설정하세요. 레거시 PIC는 단일 코어 시스템용이므로, 멀티코어에서는 각 코어의 Local APIC와 I/O APIC를 설정하여 인터럽트 라우팅을 관리해야 합니다.
💡 페이지 폴트 핸들러는 성능에 큰 영향을 줍니다. TLB miss와 페이지 폴트를 구분하고(perf 도구 사용), 불필요한 폴트를 줄이기 위해 huge page, THP(Transparent Huge Pages)를 활용하세요.
💡 디버깅 시 인터럽트 벡터와 발생 빈도를 모니터링하세요. /proc/interrupts 같은 인터페이스를 구현하면 어떤 인터럽트가 얼마나 자주 발생하는지 추적할 수 있어, 인터럽트 스톰이나 디바이스 오작동을 빠르게 감지할 수 있습니다.
7. I/O 권한과 포트 접근 제어 - 하드웨어 격리의 마지막 퍼즐
시작하며
여러분이 하드웨어를 직접 제어하는 디바이스 드라이버를 작성할 때, IN/OUT 명령어로 I/O 포트에 접근해본 적 있나요? 예를 들어, 시리얼 포트(0x3F8), VGA 컨트롤러(0x3C0-0x3DF), PCI 설정 공간(0xCF8-0xCFF) 등은 모두 I/O 포트를 통해 제어됩니다.
그런데 만약 일반 사용자 프로그램이 이런 포트에 마음대로 접근할 수 있다면 어떻게 될까요? 악의적인 프로그램이 디스크 컨트롤러 레지스터를 조작하여 임의의 섹터를 덮어쓰거나, 네트워크 카드 설정을 변경하여 패킷을 가로챌 수 있습니다.
또한 실수로 잘못된 값을 쓰면 하드웨어가 오작동하여 시스템이 멈출 수 있습니다. 바로 이럴 때 필요한 것이 I/O 권한 레벨과 I/O 비트맵입니다.
이들은 어떤 프로그램이 어떤 I/O 포트에 접근할 수 있는지 세밀하게 제어하여 하드웨어를 보호합니다.
개요
간단히 말해서, I/O 권한 메커니즘은 IN/OUT 명령어를 통한 하드웨어 포트 접근을 권한 레벨과 비트맵을 통해 제어하는 보안 기능입니다. 이 메커니즘이 필요한 이유는 하드웨어 보호와 격리 때문입니다.
메모리 보호는 페이지 테이블로 구현되지만, I/O 포트는 별도의 주소 공간을 사용하므로 다른 보호 메커니즘이 필요합니다. 예를 들어, 여러 프로그램이 동시에 같은 I/O 포트에 쓰기를 하면 하드웨어 상태가 일관성을 잃습니다.
또한 특정 I/O 포트는 보안에 매우 민감한데, 키보드 컨트롤러를 조작하면 비밀번호를 가로챌 수 있고, CMOS RAM을 조작하면 부팅 설정을 변경할 수 있습니다. 커널만 이런 포트에 접근하도록 제한하면 시스템 무결성을 유지할 수 있습니다.
기존에는 모든 I/O 명령어가 Ring 0에서만 실행 가능했지만, 현대 시스템에서는 TSS의 I/O 비트맵을 통해 포트별로 세밀하게 권한을 부여할 수 있습니다. I/O 권한 메커니즘의 핵심 특징은 다음과 같습니다.
첫째, IOPL(I/O Privilege Level) 필드가 RFLAGS에 있어 현재 프로세스의 I/O 권한을 나타냅니다. 둘째, TSS의 I/O 비트맵이 65536개 포트 각각에 대한 접근 권한을 비트로 저장합니다.
셋째, CPL ≤ IOPL이거나 비트맵에서 허용되면 I/O 명령어를 실행할 수 있습니다. 넷째, 위반 시 General Protection Fault(#GP)가 발생합니다.
이러한 특징들이 하드웨어 접근을 안전하게 제어합니다.
코드 예제
// TSS에 I/O 비트맵 추가
#[repr(C, packed(4))]
pub struct TaskStateSegment {
reserved_1: u32,
pub rsp0: u64,
pub rsp1: u64,
pub rsp2: u64,
reserved_2: u64,
pub ist: [u64; 7],
reserved_3: u64,
reserved_4: u16,
pub iomap_base: u16, // I/O 비트맵 오프셋
}
// I/O 비트맵 (8192바이트 = 65536포트)
pub struct IoBitmap {
bitmap: [u8; 8192],
}
impl IoBitmap {
pub fn new_deny_all() -> Self {
IoBitmap { bitmap: [0xFF; 8192] } // 모든 비트 1 = 금지
}
// 특정 포트 허용
pub fn allow_port(&mut self, port: u16) {
let byte_index = port as usize / 8;
let bit_index = port as usize % 8;
self.bitmap[byte_index] &= !(1 << bit_index); // 비트 0 = 허용
}
// 포트 범위 허용
pub fn allow_port_range(&mut self, start: u16, end: u16) {
for port in start..=end {
self.allow_port(port);
}
}
}
// 디바이스 드라이버용 I/O 권한 설정
pub fn grant_io_permissions(process: &mut Process, ports: &[u16]) {
// 프로세스별 I/O 비트맵 생성
let mut iomap = IoBitmap::new_deny_all();
for &port in ports {
iomap.allow_port(port);
}
// TSS에 I/O 비트맵 설정
let tss = get_current_tss();
tss.iomap_base = offset_of!(TaskStateSegment, iomap_base) as u16 +
size_of::<TaskStateSegment>() as u16;
// TSS 뒤에 I/O 비트맵 복사
unsafe {
let iomap_ptr = (tss as *mut TaskStateSegment as usize +
tss.iomap_base as usize) as *mut IoBitmap;
*iomap_ptr = iomap;
}
}
// 안전한 I/O 포트 래퍼
pub struct IoPort {
port: u16,
}
impl IoPort {
pub const unsafe fn new(port: u16) -> Self {
IoPort { port }
}
pub fn read_u8(&self) -> u8 {
unsafe {
let value: u8;
asm!("in al, dx", out("al") value, in("dx") self.port);
value
}
}
pub fn write_u8(&self, value: u8) {
unsafe {
asm!("out dx, al", in("dx") self.port, in("al") value);
}
}
}
// 예시: 시리얼 포트 드라이버에 권한 부여
pub fn init_serial_driver() {
let serial_ports = [
0x3F8, 0x3F9, 0x3FA, 0x3FB, 0x3FC, 0x3FD, 0x3FE, 0x3FF, // COM1
];
let current_process = get_current_process();
grant_io_permissions(current_process, &serial_ports);
// 이제 이 프로세스는 시리얼 포트에 접근 가능
let serial_data = IoPort::new(0x3F8);
serial_data.write_u8(b'H');
}
설명
이것이 하는 일: 위 코드는 I/O 비트맵을 통해 특정 프로세스에게 특정 I/O 포트에만 접근 권한을 부여하는 방법을 구현합니다. 이를 통해 유저 공간 디바이스 드라이버도 안전하게 하드웨어를 제어할 수 있습니다.
첫 번째로, TaskStateSegment의 iomap_base 필드는 I/O 비트맵이 TSS 내에서 어디에 위치하는지 나타냅니다. 이 값은 TSS 시작점으로부터의 바이트 오프셋인데, 보통 TSS 구조체 바로 뒤에 I/O 비트맵을 배치합니다.
CPU는 I/O 명령어를 실행할 때 이 오프셋을 읽어 비트맵을 찾습니다. 만약 iomap_base가 TSS limit를 넘어선다면 I/O 비트맵이 없는 것으로 간주하여 모든 I/O를 금지합니다.
두 번째로, IoBitmap 구조체는 8192바이트(65536비트) 배열로 각 포트에 대한 권한을 저장합니다. 포트 번호 N에 대한 비트는 bitmap[N/8]의 N%8번째 비트입니다.
비트가 0이면 해당 포트 접근이 허용되고, 1이면 금지됩니다. new_deny_all은 모든 비트를 1로 초기화하여 기본적으로 모든 I/O를 차단하는 안전한 정책을 구현합니다.
그런 다음 allow_port로 필요한 포트만 선별적으로 허용합니다. 세 번째로, grant_io_permissions 함수는 프로세스별 I/O 권한을 설정합니다.
실제로는 각 프로세스마다 독립적인 TSS를 유지하거나, 컨텍스트 스위칭 시 I/O 비트맵을 교체해야 합니다. 이 코드는 현재 TSS에 비트맵을 설정하는 방법을 보여줍니다.
TSS의 iomap_base를 올바르게 설정하고, 그 위치에 비트맵 데이터를 복사합니다. 중요한 점은 TSS 디스크립터의 limit도 비트맵을 포함할 만큼 충분히 커야 한다는 것입니다(보통 0x68 + 8192 = 8296바이트).
네 번째로, IoPort 구조체는 타입 안전한 I/O 포트 추상화를 제공합니다. Rust의 타입 시스템을 활용하여 잘못된 포트 번호나 잘못된 데이터 크기를 컴파일 타임에 방지할 수 있습니다.
read_u8과 write_u8은 인라인 어셈블리로 IN/OUT 명령어를 실행하는데, x86-64에서 DX 레지스터에 포트 번호를, AL/AX/EAX 레지스터에 데이터를 담습니다. CPU는 이 명령어를 실행할 때 자동으로 I/O 비트맵을 검사하여 권한이 없으면 #GP 예외를 발생시킵니다.
다섯 번째로, init_serial_driver 예시는 시리얼 포트 드라이버가 COM1 포트(0x3F8-0x3FF)에 접근할 수 있도록 권한을 부여합니다. 시리얼 포트는 8개의 레지스터를 가지는데, 각각 데이터, 인터럽트 활성화, FIFO 제어, 라인 제어, 모뎀 제어, 라인 상태, 모뎀 상태, 스크래치 레지스터입니다.
이 모든 포트에 접근 권한을 주면 드라이버가 온전히 작동할 수 있습니다. 여섯 번째로, 실제 운영체제에서는 I/O 비트맵 방식 외에도 다른 메커니즘을 사용할 수 있습니다.
예를 들어, IOPL(I/O Privilege Level)을 RFLAGS에 설정하면 해당 레벨 이하의 모든 I/O가 허용됩니다. IOPL은 2비트 필드로 0-3의 값을 가질 수 있는데, 보통 커널은 0, 사용자 프로그램은 3으로 설정합니다.
IOPL 방식은 간단하지만 세밀한 제어가 불가능하므로, 현대 운영체제는 I/O 비트맵을 선호합니다. 일곱 번째로, PCI Express 시대에는 MMIO(Memory-Mapped I/O)가 더 일반적이므로 I/O 포트의 중요성이 줄어들었습니다.
MMIO는 메모리 주소를 통해 하드웨어를 제어하므로 페이지 테이블로 권한을 관리할 수 있어 더 유연합니다. 그러나 레거시 디바이스(PS/2 키보드, PIT 타이머, PIC 등)와 일부 시스템 제어 기능(CMOS, PCI 설정 공간)은 여전히 I/O 포트를 사용하므로 I/O 비트맵은 여전히 필요합니다.
여러분이 이 시스템을 구현하면 유저 공간에서도 안전하게 디바이스 드라이버를 작성할 수 있습니다. 예를 들어, DPDK(Data Plane Development Kit)는 유저 공간에서 네트워크 카드를 직접 제어하여 커널 오버헤드를 제거하는데, I/O 비트맵이나 VFIO(Virtual Function I/O)를 통해 안전하게 구현됩니다.
또한 하드웨어 에뮬레이터(QEMU)나 하이퍼바이저(KVM)도 게스트 OS의 I/O 명령어를 트랩하여 가상 하드웨어를 제공합니다.
실전 팁
💡 I/O 비트맵은 8KB로 꽤 크므로 메모리 낭비를 줄이려면 대부분의 프로세스는 기본 TSS를 공유하고, I/O 권한이 필요한 프로세스만 독립적인 TSS와 비트맵을 할당하세요.
💡 I/O 포트 접근 시 권한 검사 실패는 #GP(13) 예외를 발생시킵니다. 이 예외를 적절히 처리하여 프로세스를 종료하고 보안 로그를 남겨 공격 시도를 추적할 수 있습니다.
💡 최신 시스템에서는 MMIO와 IOMMU를 조합하여 더 안전한 디바이스 격리를 구현합니다. IOMMU는 디바이스가 접근할 수 있는 메모리 영역을 제한하여 DMA 공격을 방지합니다.
💡 성능이 중요한 경우 I/O 포트를 메모리 매핑된 I/O로 전환하세요. 예를 들어, VGA 프레임버퍼를 I/O 포트가 아닌 메모리 매핑(0xA0000-0xBFFFF)으로 접근하면 훨씬 빠릅니다.
💡 가상화 환경에서는 I/O 명령어가 VM exit를 일으켜 성능이 저하됩니다. SR-IOV나 virtio-pci 같은 반가상화 기술을 사용하면 직접 하드웨어 접근이 가능하여 성능이 향상됩니다.