이미지 로딩 중...
AI Generated
2025. 11. 14. · 4 Views
Rust로 만드는 나만의 OS 예외 복구 메커니즘
운영체제 개발에서 가장 중요한 예외 처리와 복구 메커니즘을 Rust로 구현하는 방법을 배웁니다. CPU 예외를 안전하게 처리하고 시스템 안정성을 확보하는 실전 기법을 다룹니다.
목차
- IDT 설정 - 예외 처리의 시작점
- Double Fault 스택 전환 - 스택 오버플로우 방지
- Page Fault 핸들러 - 메모리 접근 예외 처리
- Breakpoint 예외 - 디버깅 지원
- General Protection Fault - 권한 위반 탐지
- Invalid Opcode 예외 - 잘못된 명령어 감지
- Stack Segment Fault - 스택 세그먼트 오류 처리
- Machine Check 예외 - 하드웨어 오류 탐지
1. IDT 설정 - 예외 처리의 시작점
시작하며
여러분이 자체 OS를 개발하면서 갑자기 시스템이 멈추거나 Triple Fault가 발생한 경험이 있나요? 디버깅도 어렵고, 무엇이 잘못되었는지 알 수 없어 막막했던 순간 말이죠.
이런 문제는 CPU가 발생시키는 예외(Exception)를 제대로 처리하지 못했기 때문입니다. CPU는 Page Fault, Division by Zero, Invalid Opcode 등 다양한 예외 상황을 알려주지만, 이를 받아줄 핸들러가 없으면 시스템은 그냥 죽어버립니다.
바로 이럴 때 필요한 것이 IDT(Interrupt Descriptor Table)입니다. IDT는 CPU 예외와 인터럽트를 처리할 핸들러 함수들의 주소를 담고 있는 테이블로, OS의 안전망 역할을 합니다.
개요
간단히 말해서, IDT는 CPU가 예외나 인터럽트가 발생했을 때 "어떤 함수를 호출해야 하는지" 알려주는 테이블입니다. CPU는 32개의 예외 벡터를 정의하고 있으며, 각 벡터는 특정 예외 유형을 나타냅니다.
예를 들어, 벡터 0은 Division Error, 벡터 14는 Page Fault를 의미합니다. IDT를 올바르게 설정하면 이런 예외들을 포착하여 적절히 처리하거나 디버깅 정보를 출력할 수 있습니다.
전통적으로 C나 어셈블리로 IDT를 설정하면 타입 안전성 없이 수동으로 비트를 조작해야 했지만, Rust의 x86_64 크레이트를 사용하면 타입 안전하게 IDT를 구성할 수 있습니다. IDT의 핵심 특징은 첫째, 256개의 엔트리를 가질 수 있고(예외 32개 + 사용자 정의 인터럽트 224개), 둘째, 각 엔트리는 핸들러 함수 포인터와 권한 설정을 포함하며, 셋째, CPU가 예외 발생 시 자동으로 참조한다는 점입니다.
이러한 특징들이 OS의 안정성과 디버깅 가능성을 크게 향상시킵니다.
코드 예제
use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame};
use lazy_static::lazy_static;
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
// Division by zero 예외 핸들러 등록
idt.divide_error.set_handler_fn(divide_error_handler);
// Breakpoint 예외 핸들러 등록
idt.breakpoint.set_handler_fn(breakpoint_handler);
// Double Fault 예외 핸들러 등록 (중요!)
idt.double_fault.set_handler_fn(double_fault_handler);
idt
};
}
// Division by zero 핸들러
extern "x86-interrupt" fn divide_error_handler(stack_frame: InterruptStackFrame) {
panic!("EXCEPTION: DIVIDE ERROR\n{:#?}", stack_frame);
}
// Breakpoint 핸들러
extern "x86-interrupt" fn breakpoint_handler(stack_frame: InterruptStackFrame) {
println!("EXCEPTION: BREAKPOINT\n{:#?}", stack_frame);
}
// Double Fault 핸들러 (별도 스택 사용)
extern "x86-interrupt" fn double_fault_handler(
stack_frame: InterruptStackFrame, _error_code: u64) -> ! {
panic!("EXCEPTION: DOUBLE FAULT\n{:#?}", stack_frame);
}
pub fn init_idt() {
IDT.load();
}
설명
이것이 하는 일: 이 코드는 운영체제가 CPU 예외를 안전하게 처리할 수 있도록 Interrupt Descriptor Table을 초기화하고 각 예외 유형별 핸들러를 등록합니다. 첫 번째로, lazy_static 매크로를 사용하여 전역 IDT 인스턴스를 생성합니다.
lazy_static은 런타임에 한 번만 초기화되는 정적 변수를 만들어주며, IDT처럼 복잡한 초기화가 필요한 구조체에 적합합니다. InterruptDescriptorTable::new()로 모든 엔트리가 비어있는 IDT를 생성한 후, set_handler_fn 메서드로 각 예외 벡터에 핸들러를 연결합니다.
그 다음으로, 각 핸들러 함수는 "x86-interrupt" 호출 규약을 사용합니다. 이는 일반 Rust 함수와 다르게 CPU가 예외 발생 시 스택에 푸시한 정보를 올바르게 읽을 수 있도록 특별한 프롤로그/에필로그를 생성합니다.
InterruptStackFrame 파라미터는 예외 발생 시점의 CPU 상태(명령어 포인터, 스택 포인터, 플래그 레지스터 등)를 담고 있어 디버깅에 매우 유용합니다. 세 번째로, double_fault_handler는 특별한 주의가 필요합니다.
Double Fault는 예외 핸들러 실행 중 또 다른 예외가 발생했을 때 트리거되며, 이를 처리하지 못하면 Triple Fault로 이어져 CPU가 리셋됩니다. 따라서 이 핸들러는 절대 반환하지 않는 발산 함수(-> !)로 선언되며, 별도의 안전한 스택을 사용해야 합니다.
마지막으로, init_idt() 함수가 IDT.load()를 호출하면 CPU의 IDTR 레지스터에 IDT의 주소와 크기가 로드됩니다. 이후 CPU는 예외 발생 시 자동으로 이 테이블을 참조하여 적절한 핸들러를 실행합니다.
여러분이 이 코드를 사용하면 시스템 크래시 대신 명확한 에러 메시지를 얻을 수 있고, 어떤 예외가 어디서 발생했는지 정확히 파악할 수 있습니다. 또한 Rust의 타입 시스템 덕분에 잘못된 핸들러 시그니처나 호출 규약 실수를 컴파일 타임에 잡아낼 수 있어 개발 생산성이 크게 향상됩니다.
실전 팁
💡 lazy_static 대신 spin::Once를 사용하면 no_std 환경에서도 동적 초기화를 할 수 있습니다. Once::new()로 생성하고 call_once로 초기화하세요.
💡 모든 예외를 개별적으로 처리하지 않아도 됩니다. 초기에는 중요한 예외(divide_error, breakpoint, double_fault, page_fault)만 구현하고 나머지는 범용 핸들러로 처리하세요.
💡 핸들러 내부에서 println! 매크로를 사용할 때 주의하세요. 인터럽트가 비활성화된 상태에서 락을 획득하려 하면 데드락이 발생할 수 있습니다. 인터럽트 안전한 출력 함수를 만드세요.
💡 InterruptStackFrame을 출력하면 예외 발생 시점의 명령어 주소(RIP)를 볼 수 있습니다. objdump나 addr2line 도구로 이 주소를 소스 코드 위치로 변환할 수 있습니다.
💡 IDT 로드 전에 반드시 핸들러 함수들이 컴파일러 최적화로 제거되지 않도록 #[used] 속성을 고려하세요. 또한 LLVM의 인라인 최적화를 방지하려면 #[inline(never)]를 사용할 수 있습니다.
2. Double Fault 스택 전환 - 스택 오버플로우 방지
시작하며
여러분의 OS에서 스택 오버플로우가 발생하면 어떻게 될까요? 일반적으로 Page Fault 핸들러가 호출되지만, 이 핸들러 자체도 스택을 사용하므로 또 다른 Page Fault가 발생하고, 이것이 Double Fault로 이어집니다.
그런데 Double Fault 핸들러도 같은 스택을 사용한다면? Triple Fault로 시스템이 리셋됩니다.
이런 문제는 실제 운영체제 개발에서 디버깅하기 가장 어려운 케이스 중 하나입니다. 스택이 손상된 상태에서는 어떤 핸들러도 안전하게 실행될 수 없기 때문입니다.
바로 이럴 때 필요한 것이 IST(Interrupt Stack Table)를 통한 스택 전환입니다. CPU에게 "이 예외는 다른 스택을 사용해라"고 알려주는 메커니즘으로, Double Fault 핸들러의 안전한 실행을 보장합니다.
개요
간단히 말해서, IST는 특정 예외 핸들러가 사용할 별도의 스택 공간을 미리 지정해두는 기능입니다. x86_64 아키텍처는 TSS(Task State Segment) 내에 7개의 IST 엔트리를 제공하며, 각 엔트리는 독립적인 스택 포인터를 저장할 수 있습니다.
IDT 엔트리를 설정할 때 IST 인덱스(1-7)를 지정하면, 해당 예외 발생 시 CPU가 자동으로 스택을 전환합니다. 이는 현재 스택 상태와 무관하게 핸들러가 항상 안전한 스택에서 실행됨을 보장합니다.
기존에는 스택 오버플로우 발생 시 Triple Fault로 시스템이 재시작되었지만, IST를 사용하면 명확한 에러 메시지를 출력하고 문제를 진단할 수 있습니다. IST의 핵심 특징은 첫째, 핸들러 실행 전에 CPU가 자동으로 스택을 전환하고, 둘째, 각 IST 엔트리는 독립적인 스택 공간을 가지며, 셋째, 중첩된 예외 상황에서도 안전성을 제공한다는 점입니다.
이러한 특징들이 커널 스택 오버플로우 같은 치명적인 상황에서도 시스템을 복구할 수 있는 기회를 제공합니다.
코드 예제
use x86_64::structures::gdt::{GlobalDescriptorTable, Descriptor, SegmentSelector};
use x86_64::structures::tss::TaskStateSegment;
use x86_64::VirtAddr;
pub const DOUBLE_FAULT_IST_INDEX: u16 = 0;
lazy_static! {
static ref TSS: TaskStateSegment = {
let mut tss = TaskStateSegment::new();
// Double Fault용 별도 스택 설정 (16KB)
tss.interrupt_stack_table[DOUBLE_FAULT_IST_INDEX as usize] = {
const STACK_SIZE: usize = 4096 * 4;
static mut STACK: [u8; STACK_SIZE] = [0; STACK_SIZE];
let stack_start = VirtAddr::from_ptr(unsafe { &STACK });
let stack_end = stack_start + STACK_SIZE;
stack_end // 스택은 아래로 자라므로 끝 주소 반환
};
tss
};
}
lazy_static! {
static ref GDT: (GlobalDescriptorTable, Selectors) = {
let mut gdt = GlobalDescriptorTable::new();
let code_selector = gdt.add_entry(Descriptor::kernel_code_segment());
let tss_selector = gdt.add_entry(Descriptor::tss_segment(&TSS));
(gdt, Selectors { code_selector, tss_selector })
};
}
struct Selectors {
code_selector: SegmentSelector,
tss_selector: SegmentSelector,
}
pub fn init() {
GDT.0.load();
unsafe {
// 코드 세그먼트와 TSS 로드
x86_64::instructions::segmentation::CS::set_reg(GDT.1.code_selector);
x86_64::instructions::tables::load_tss(GDT.1.tss_selector);
}
}
설명
이것이 하는 일: 이 코드는 Double Fault 예외 발생 시 손상되지 않은 별도의 스택을 사용하도록 TSS와 GDT를 설정하여, 스택 오버플로우 같은 치명적 상황에서도 예외 처리가 가능하게 만듭니다. 첫 번째로, TaskStateSegment를 생성하고 그 안의 interrupt_stack_table 배열에 새로운 스택을 할당합니다.
여기서는 16KB(4096 * 4) 크기의 정적 배열을 선언하고, 이 배열의 끝 주소(높은 주소)를 스택 포인터로 설정합니다. x86_64 아키텍처에서 스택은 높은 주소에서 낮은 주소로 자라기 때문에 스택의 끝 주소를 지정하는 것이 중요합니다.
그 다음으로, GDT(Global Descriptor Table)에 커널 코드 세그먼트와 TSS 디스크립터를 추가합니다. GDT는 보호 모드와 롱 모드에서 세그먼트를 정의하는 테이블이며, TSS도 하나의 세그먼트로 등록되어야 합니다.
add_entry 메서드는 새로운 디스크립터를 추가하고 해당 세그먼트의 셀렉터를 반환합니다. 세 번째로, init() 함수에서 GDT를 CPU에 로드하고 코드 세그먼트 레지스터(CS)와 태스크 레지스터(TR)를 업데이트합니다.
CS::set_reg는 커널 코드 세그먼트를 활성화하고, load_tss는 CPU에게 TSS의 위치를 알려줍니다. 이 작업들은 CPU 레지스터를 직접 조작하므로 unsafe 블록이 필요합니다.
마지막으로, IDT 설정 시 Double Fault 엔트리에 IST 인덱스를 지정해야 합니다. 예를 들어 idt.double_fault.set_handler_fn(handler).set_stack_index(DOUBLE_FAULT_IST_INDEX)처럼 set_stack_index를 호출하면, CPU가 Double Fault 발생 시 자동으로 지정된 IST 스택으로 전환합니다.
여러분이 이 코드를 사용하면 재귀 함수 호출이나 큰 스택 변수로 인한 스택 오버플로우가 발생해도 시스템이 완전히 죽지 않고 에러 정보를 출력할 수 있습니다. 또한 개발 초기 단계에서 스택 크기를 실험하거나 최적화할 때 매우 유용한 디버깅 도구가 됩니다.
실전 팁
💡 IST 스택 크기는 16KB가 적당하지만, 핸들러가 복잡한 작업을 수행한다면 더 크게 설정하세요. 하지만 너무 크면 메모리를 낭비하게 됩니다.
💡 여러 예외에 같은 IST 인덱스를 사용하면 중첩 호출 시 스택을 덮어쓸 수 있습니다. 중요한 예외마다 별도의 IST 엔트리를 사용하세요(예: Double Fault는 인덱스 0, Machine Check는 인덱스 1).
💡 IST 스택 배열을 static mut로 선언했지만, 실제로는 읽기 전용으로만 사용됩니다. 보안을 위해 #[link_section = ".idt_stacks"] 같은 속성으로 별도 섹션에 배치하고 페이지 테이블에서 쓰기 권한을 제거하는 것이 좋습니다.
💡 GDT와 TSS 초기화는 IDT 초기화보다 먼저 이루어져야 합니다. 부트 시퀀스에서 올바른 순서를 유지하세요: GDT 로드 → TSS 로드 → IDT 설정 → IDT 로드.
💡 QEMU에서 테스트할 때 -d int옵션을 사용하면 예외 발생 시 상세한 CPU 상태를 로그로 볼 수 있습니다. 스택 전환이 올바르게 작동하는지 확인하는 데 유용합니다.
3. Page Fault 핸들러 - 메모리 접근 예외 처리
시작하며
여러분이 OS에서 잘못된 메모리 주소에 접근하거나, 페이지 테이블에 매핑되지 않은 가상 주소를 사용하면 어떻게 될까요? CPU는 즉시 Page Fault 예외를 발생시키고, 이를 처리하지 않으면 시스템이 멈춥니다.
이런 문제는 운영체제 개발에서 가장 빈번하게 발생하는 예외입니다. 동적 메모리 할당, 스왑 메커니즘, Copy-on-Write 최적화, Lazy Loading 등 현대 OS의 고급 메모리 관리 기법들은 모두 Page Fault를 의도적으로 발생시키고 이를 처리하는 방식으로 구현됩니다.
바로 이럴 때 필요한 것이 정교한 Page Fault 핸들러입니다. 단순히 에러를 출력하는 것을 넘어서, 에러 코드를 분석하고 접근된 주소를 파악하여 적절한 복구 전략을 실행할 수 있어야 합니다.
개요
간단히 말해서, Page Fault 핸들러는 잘못된 메모리 접근이 발생했을 때 호출되며, CR2 레지스터에서 접근 시도된 가상 주소를 읽고 에러 코드를 분석하여 원인을 파악합니다. Page Fault는 다양한 원인으로 발생할 수 있습니다.
페이지가 존재하지 않거나(Present bit가 0), 권한이 없는 접근(유저 모드에서 커널 페이지 접근), 읽기 전용 페이지에 쓰기 시도, 명령어 실행 금지 페이지에서 코드 실행 등이 있습니다. 에러 코드의 각 비트는 이런 정보들을 담고 있어, 핸들러가 정확한 원인을 파악하고 적절히 대응할 수 있게 합니다.
기존 시스템에서는 Page Fault가 곧 프로그램 종료를 의미했지만, 현대 OS에서는 Page Fault를 활용하여 메모리를 더욱 효율적으로 관리합니다. 예를 들어, 큰 파일을 메모리에 매핑할 때 처음에는 페이지를 할당하지 않고, 실제로 접근될 때 Page Fault를 통해 필요한 부분만 로드합니다.
Page Fault 핸들러의 핵심 특징은 첫째, CR2 레지스터를 통해 정확한 Fault 주소를 알 수 있고, 둘째, 에러 코드가 Fault의 세부 원인을 제공하며, 셋째, 핸들러가 페이지를 할당하거나 스왑 인하는 등의 복구 작업을 수행할 수 있다는 점입니다. 이러한 특징들이 가상 메모리 시스템의 유연성과 효율성을 크게 높입니다.
코드 예제
use x86_64::structures::idt::{InterruptStackFrame, PageFaultErrorCode};
use x86_64::registers::control::Cr2;
// Page Fault 핸들러
extern "x86-interrupt" fn page_fault_handler(
stack_frame: InterruptStackFrame,
error_code: PageFaultErrorCode,
) {
// CR2 레지스터에서 Fault 발생 주소 읽기
let fault_addr = Cr2::read();
println!("EXCEPTION: PAGE FAULT");
println!("Accessed Address: {:?}", fault_addr);
println!("Error Code: {:?}", error_code);
// 에러 코드 분석
if !error_code.contains(PageFaultErrorCode::CAUSED_BY_WRITE) {
println!(" - Read access");
} else {
println!(" - Write access");
}
if !error_code.contains(PageFaultErrorCode::PROTECTION_VIOLATION) {
println!(" - Page not present");
} else {
println!(" - Protection violation");
}
if error_code.contains(PageFaultErrorCode::USER_MODE) {
println!(" - User mode access");
} else {
println!(" - Kernel mode access");
}
println!("{:#?}", stack_frame);
// 여기서 복구 로직 구현 가능:
// - 스왑 페이지 로드
// - 지연 할당(lazy allocation)
// - Copy-on-Write 처리
panic!("Unhandled page fault");
}
// IDT에 핸들러 등록
pub fn init_idt() {
let mut idt = InterruptDescriptorTable::new();
idt.page_fault.set_handler_fn(page_fault_handler);
// ... 다른 핸들러들
idt.load();
}
설명
이것이 하는 일: 이 코드는 잘못된 메모리 접근 시 발생하는 Page Fault 예외를 처리하며, 접근된 주소와 접근 유형을 분석하여 상세한 디버깅 정보를 제공하고 필요시 복구 작업을 수행할 수 있도록 합니다. 첫 번째로, Page Fault 핸들러는 두 개의 파라미터를 받습니다.
InterruptStackFrame은 예외 발생 시점의 CPU 상태를 담고 있으며, PageFaultErrorCode는 Fault의 원인을 비트 플래그로 제공합니다. CR2 레지스터를 읽으면 실제로 접근 시도된 가상 메모리 주소를 얻을 수 있습니다.
이 주소는 디버깅에 매우 중요한 정보입니다. 그 다음으로, 에러 코드의 각 비트를 검사하여 Fault의 세부 원인을 파악합니다.
CAUSED_BY_WRITE 비트는 읽기와 쓰기 접근을 구분하고, PROTECTION_VIOLATION 비트는 페이지가 존재하지 않는 것인지 권한 문제인지 알려줍니다. USER_MODE 비트는 유저 공간에서 발생한 Fault인지 커널 공간인지 구분하며, INSTRUCTION_FETCH 비트(코드에 없지만 사용 가능)는 코드 실행 시도인지 데이터 접근인지 알려줍니다.
세 번째로, 실제 OS에서는 여기서 복구 로직을 구현합니다. 예를 들어, Lazy Allocation 전략을 사용한다면 malloc은 가상 주소만 예약하고 실제 물리 페이지는 할당하지 않습니다.
나중에 해당 주소에 접근하면 Page Fault가 발생하고, 핸들러가 물리 페이지를 할당하고 페이지 테이블을 업데이트한 후 명령어를 재실행합니다. 사용자는 이 과정을 전혀 인지하지 못합니다.
네 번째로, Copy-on-Write 최적화도 Page Fault를 활용합니다. fork() 시스템 콜로 프로세스를 복제할 때 메모리를 실제로 복사하지 않고 부모와 자식이 같은 페이지를 공유하되 읽기 전용으로 표시합니다.
쓰기 시도 시 Page Fault가 발생하면 그때 페이지를 복사하고 쓰기 권한을 부여합니다. 이는 fork의 성능을 획기적으로 개선합니다.
여러분이 이 코드를 사용하면 메모리 관련 버그를 훨씬 쉽게 디버깅할 수 있습니다. 어떤 주소에 어떤 방식으로 접근했는지, 그리고 그것이 왜 실패했는지 명확히 알 수 있기 때문입니다.
또한 고급 메모리 관리 기법을 구현할 수 있는 기반이 됩니다.
실전 팁
💡 Page Fault는 매우 빈번하게 발생할 수 있으므로 핸들러를 가능한 한 빠르게 만드세요. 복잡한 로깅은 디버그 빌드에만 포함시키고 릴리스 빌드에서는 최소화하세요.
💡 CR2 레지스터는 다음 Page Fault 발생 시 덮어쓰여지므로, 핸들러 초반에 즉시 읽어서 로컬 변수에 저장하세요. 특히 핸들러 내부에서 또 다른 Page Fault가 발생할 가능성이 있다면 더욱 중요합니다.
💡 복구 가능한 Page Fault와 치명적인 Fault를 구분하세요. 커널 모드에서 널 포인터 역참조는 버그이므로 패닉해야 하지만, 유저 모드의 같은 동작은 프로세스만 종료하면 됩니다.
💡 스왑이나 페이지 로딩 같은 복구 작업이 성공하면 명령어를 재실행해야 합니다. 핸들러가 정상 반환하면 CPU가 자동으로 재실행하므로 별도 처리는 불필요합니다. 단, 무한 루프에 빠지지 않도록 주의하세요.
💡 Page Fault 통계를 수집하면 메모리 사용 패턴을 분석하는 데 유용합니다. 원자적 카운터로 Fault 횟수, 유형별 분포, 평균 처리 시간 등을 추적하고 프로파일링에 활용하세요.
4. Breakpoint 예외 - 디버깅 지원
시작하며
여러분이 OS 코드를 디버깅할 때 특정 지점에서 실행을 멈추고 상태를 확인하고 싶었던 적이 있나요? 일반 애플리케이션에서는 GDB나 LLDB 같은 디버거를 사용하지만, OS 커널을 디버깅할 때는 상황이 다릅니다.
이런 문제는 커널이 가장 낮은 레벨에서 실행되므로 외부 디버거의 도움을 받기 어렵기 때문입니다. QEMU의 GDB 스텁을 사용할 수도 있지만, 때로는 코드에 직접 브레이크포인트를 삽입하여 특정 조건에서만 실행을 멈추거나 정보를 출력하고 싶을 때가 있습니다.
바로 이럴 때 필요한 것이 Breakpoint 예외 처리입니다. x86의 int3 명령어를 사용하면 언제든지 소프트웨어 브레이크포인트를 발생시킬 수 있고, 이를 적절히 처리하면 강력한 디버깅 도구가 됩니다.
개요
간단히 말해서, Breakpoint 예외는 int3 명령어(기계어 0xCC)를 실행할 때 발생하며, 디버거나 OS가 프로그램 실행을 중단하고 검사할 수 있게 해주는 메커니즘입니다. x86 아키텍처는 1바이트 크기의 int3 명령어를 특별히 제공합니다.
디버거는 브레이크포인트를 설정하려는 위치의 명령어 첫 바이트를 0xCC로 덮어쓰고, 예외 발생 시 원래 명령어를 복원한 후 한 단계씩 실행하는 방식으로 동작합니다. OS 개발 시에는 이를 영구적인 디버그 포인트로 사용할 수 있습니다.
전통적으로 printf 디버깅을 사용하면 코드에 출력문을 추가하고 재컴파일해야 했지만, Breakpoint 예외를 활용하면 특정 조건에서만 상태를 덤프하거나 실행을 멈추는 더 유연한 디버깅이 가능합니다. Breakpoint 예외의 핵심 특징은 첫째, 1바이트 명령어로 최소한의 코드 수정만 필요하고, 둘째, 예외 후 원래 지점으로 정확히 돌아갈 수 있으며, 셋째, 하드웨어 디버거 없이도 소프트웨어만으로 구현 가능하다는 점입니다.
이러한 특징들이 커널 수준의 디버깅을 크게 단순화합니다.
코드 예제
use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame};
// Breakpoint 예외 핸들러
extern "x86-interrupt" fn breakpoint_handler(stack_frame: InterruptStackFrame) {
println!("==== BREAKPOINT HIT ====");
println!("Instruction Pointer: {:#x}", stack_frame.instruction_pointer);
println!("Stack Pointer: {:#x}", stack_frame.stack_pointer);
println!("CPU Flags: {:#x}", stack_frame.cpu_flags);
// 여기서 추가 디버깅 정보 출력 가능
// - 레지스터 값
// - 스택 백트레이스
// - 변수 값
println!("========================");
// 핸들러가 반환하면 실행 계속됨
}
// IDT에 핸들러 등록
pub fn init_idt() {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
// ... 다른 핸들러들
idt.load();
}
// 코드 내에서 사용 예시
pub fn test_function() {
println!("Before breakpoint");
// 소프트웨어 브레이크포인트 발생
x86_64::instructions::interrupts::int3();
println!("After breakpoint");
}
// 조건부 브레이크포인트 헬퍼
#[inline(always)]
pub fn conditional_breakpoint(condition: bool) {
if condition {
x86_64::instructions::interrupts::int3();
}
}
// 사용 예시
pub fn debug_memory_allocation(size: usize) {
conditional_breakpoint(size > 1024 * 1024); // 1MB 이상 할당 시만
// ... 할당 로직
}
설명
이것이 하는 일: 이 코드는 소프트웨어 브레이크포인트를 처리하는 메커니즘을 구현하며, 개발자가 원하는 지점에서 프로그램 실행을 중단하고 CPU 상태를 검사할 수 있게 해줍니다. 첫 번째로, breakpoint_handler는 int3 명령어 실행 시 호출됩니다.
InterruptStackFrame을 통해 브레이크포인트가 발생한 정확한 위치(instruction_pointer), 그 시점의 스택 포인터, 그리고 CPU 플래그 레지스터 값을 얻을 수 있습니다. instruction_pointer는 int3 명령어 다음 명령어를 가리키므로, 실제 브레이크포인트 위치는 이보다 1바이트 앞입니다.
그 다음으로, 핸들러 내부에서 다양한 디버깅 정보를 수집하고 출력할 수 있습니다. 예를 들어, 인라인 어셈블리로 범용 레지스터(RAX, RBX 등)의 값을 읽거나, 스택을 역추적하여 호출 체인을 출력하거나, 전역 변수나 데이터 구조의 상태를 덤프할 수 있습니다.
이는 printf 디버깅보다 훨씬 강력한 인사이트를 제공합니다. 세 번째로, x86_64::instructions::interrupts::int3() 함수는 int3 명령어를 생성하는 안전한 래퍼입니다.
일반 Rust 코드 어디에서든 이를 호출하면 즉시 Breakpoint 예외가 발생하고, 핸들러가 실행된 후 다음 명령어로 계속 진행됩니다. 이는 assert!나 debug_assert!와 비슷하지만, 조건이 참일 때 실행을 계속할 수 있다는 점에서 다릅니다.
네 번째로, conditional_breakpoint 헬퍼 함수는 조건부 디버깅을 가능하게 합니다. 예를 들어, 특정 크기 이상의 메모리 할당이나, 특정 에러 코드를 반환하는 경우, 또는 특정 횟수 이상 호출될 때만 브레이크포인트를 발생시킬 수 있습니다.
이는 간헐적으로 발생하는 버그를 추적하는 데 매우 유용합니다. 여러분이 이 코드를 사용하면 복잡한 커널 동작을 단계별로 추적하고, 특정 조건에서의 시스템 상태를 정밀하게 관찰할 수 있습니다.
또한 QEMU의 GDB 스텁과 함께 사용하면 더욱 강력한 디버깅 환경을 구축할 수 있습니다.
실전 팁
💡 릴리스 빌드에서는 #[cfg(debug_assertions)]를 사용하여 브레이크포인트 코드를 제거하세요. 프로덕션 커널에서 불필요한 예외 처리는 성능에 영향을 줍니다.
💡 QEMU와 함께 사용할 때 -s -S 옵션으로 GDB 서버를 시작하고, 브레이크포인트 발생 시 QEMU 모니터로 진입하도록 설정할 수 있습니다. 이렇게 하면 외부 디버거로 더 자세히 분석할 수 있습니다.
💡 스택 백트레이스를 구현하려면 스택 프레임 포인터(RBP)를 따라가면서 반환 주소들을 수집하세요. 단, -fomit-frame-pointer 최적화가 활성화되어 있지 않아야 합니다.
💡 브레이크포인트 핸들러에서 너무 많은 출력을 하면 시리얼 포트가 병목이 될 수 있습니다. 필요한 정보만 선택적으로 출력하거나, 메모리 버퍼에 로그를 저장했다가 나중에 덤프하세요.
💡 원자적 카운터를 사용하여 각 브레이크포인트의 히트 카운트를 추적하면 코드 실행 흐름을 프로파일링하는 데 도움이 됩니다. 예를 들어, 특정 함수가 몇 번 호출되었는지 정확히 알 수 있습니다.
5. General Protection Fault - 권한 위반 탐지
시작하며
여러분이 OS 코드에서 잘못된 세그먼트 셀렉터를 로드하거나, 권한이 없는 명령어를 실행하려고 하면 어떻게 될까요? CPU는 즉시 General Protection Fault(GPF)를 발생시킵니다.
이는 시스템 보호의 최후 방어선입니다. 이런 문제는 보통 포인터 손상, 잘못된 함수 포인터 호출, 스택 오버플로우로 인한 반환 주소 오염 등이 원인입니다.
GPF는 "뭔가 심각하게 잘못되었다"는 신호이며, 적절히 처리하지 않으면 시스템 전체가 불안정해질 수 있습니다. 바로 이럴 때 필요한 것이 정교한 GPF 핸들러입니다.
에러 코드를 분석하여 정확한 원인을 파악하고, 가능하면 복구하거나 최소한 상세한 진단 정보를 제공해야 합니다.
개요
간단히 말해서, General Protection Fault는 세그먼트 위반, 권한 위반, 널 세그먼트 접근, 정렬되지 않은 메모리 접근 등 다양한 보호 위반 시 발생하는 포괄적인 예외입니다. GPF의 에러 코드는 문제가 발생한 세그먼트 셀렉터를 포함하고 있습니다.
에러 코드가 0이면 세그먼트와 무관한 위반(예: 권한 없는 명령어 실행)이고, 0이 아니면 특정 세그먼트 관련 문제입니다. 에러 코드의 각 비트는 셀렉터가 GDT인지 LDT인지, 그리고 테이블 내 인덱스가 무엇인지 알려줍니다.
전통적으로 GPF는 버그의 증상으로만 여겨졌지만, 현대 시스템에서는 보안 위협을 탐지하는 메커니즘으로도 활용됩니다. 예를 들어, 유저 공간 프로세스가 커널 메모리에 접근하려 하거나, 실행 권한 없는 페이지에서 코드를 실행하려 하면 GPF가 발생합니다.
GPF 핸들러의 핵심 특징은 첫째, 다양한 유형의 보호 위반을 하나의 예외로 처리하고, 둘째, 에러 코드를 통해 어느 정도 원인을 특정할 수 있으며, 셋째, 커널 모드 GPF는 거의 항상 심각한 버그를 의미한다는 점입니다. 이러한 특징들이 시스템 안정성과 보안을 유지하는 데 중요한 역할을 합니다.
코드 예제
use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame};
// General Protection Fault 핸들러
extern "x86-interrupt" fn general_protection_fault_handler(
stack_frame: InterruptStackFrame,
error_code: u64,
) {
println!("EXCEPTION: GENERAL PROTECTION FAULT");
// 에러 코드 분석
if error_code == 0 {
println!("Error Code: 0 (not segment-related)");
println!("Possible causes:");
println!(" - Privileged instruction in user mode");
println!(" - Writing to read-only segment");
println!(" - Loading invalid value into control register");
} else {
// 세그먼트 관련 GPF
let external = (error_code & 0b001) != 0;
let table = (error_code & 0b110) >> 1;
let index = (error_code & !0b111) >> 3;
println!("Error Code: {:#x}", error_code);
println!(" External: {}", external);
println!(" Table: {}", match table {
0 => "GDT",
1 | 3 => "IDT",
2 => "LDT",
_ => "Unknown",
});
println!(" Selector Index: {}", index);
}
println!("Instruction Pointer: {:#x}", stack_frame.instruction_pointer);
println!("Code Segment: {:#x}", stack_frame.code_segment);
println!("CPU Flags: {:#x}", stack_frame.cpu_flags);
println!("Stack Pointer: {:#x}", stack_frame.stack_pointer);
println!("Stack Segment: {:#x}", stack_frame.stack_segment);
// 커널 모드 GPF는 치명적
let cs = stack_frame.code_segment;
if (cs & 0b11) == 0 {
panic!("GPF in kernel mode - this is a bug!");
} else {
println!("GPF in user mode - terminating process");
// 여기서 프로세스 종료 로직 구현
}
}
// IDT에 핸들러 등록
pub fn init_idt() {
let mut idt = InterruptDescriptorTable::new();
idt.general_protection_fault.set_handler_fn(general_protection_fault_handler);
// ... 다른 핸들러들
idt.load();
}
설명
이것이 하는 일: 이 코드는 다양한 보호 위반 상황을 감지하고 처리하는 GPF 핸들러를 구현하며, 에러 코드와 스택 프레임을 상세히 분석하여 문제의 정확한 원인을 진단합니다. 첫 번째로, GPF 핸들러는 InterruptStackFrame과 에러 코드를 파라미터로 받습니다.
에러 코드가 0인 경우는 세그먼트와 무관한 위반입니다. 예를 들어, 유저 모드에서 CLI(인터럽트 비활성화) 같은 권한 명령어를 실행하려 하거나, 읽기 전용 세그먼트에 쓰려고 하거나, 제어 레지스터에 유효하지 않은 값을 로드하려 할 때 에러 코드 0과 함께 GPF가 발생합니다.
그 다음으로, 에러 코드가 0이 아니면 특정 세그먼트 셀렉터가 문제를 일으켰다는 의미입니다. 에러 코드의 최하위 비트는 외부 이벤트(예: 하드웨어 인터럽트) 중에 발생했는지를 나타내고, 다음 2비트는 셀렉터가 GDT, LDT, IDT 중 어느 테이블을 참조하는지 알려줍니다.
나머지 상위 비트들은 테이블 내 인덱스를 제공합니다. 이 정보를 통해 어떤 세그먼트 로드 작업이 실패했는지 정확히 알 수 있습니다.
세 번째로, stack_frame의 code_segment 필드를 검사하여 예외가 커널 모드에서 발생했는지 유저 모드에서 발생했는지 판단합니다. code_segment의 하위 2비트는 CPL(Current Privilege Level)을 나타내며, 0이면 커널 모드, 3이면 유저 모드입니다.
커널 모드에서 발생한 GPF는 거의 항상 커널 버그를 의미하므로 즉시 패닉해야 합니다. 네 번째로, 유저 모드에서 발생한 GPF는 악의적이거나 버그가 있는 유저 프로그램이 원인일 가능성이 높습니다.
이 경우 전체 시스템을 다운시키지 않고 해당 프로세스만 종료하고 에러를 보고하는 것이 적절합니다. 이는 시스템의 격리성(isolation)을 유지하는 중요한 메커니즘입니다.
여러분이 이 코드를 사용하면 포인터 손상, 스택 오버플로우, 함수 포인터 오류 같은 심각한 버그를 조기에 발견할 수 있습니다. 또한 유저 공간 프로그램의 잘못된 동작으로부터 커널을 보호하여 시스템 안정성을 높일 수 있습니다.
실전 팁
💡 GPF와 Page Fault를 혼동하지 마세요. GPF는 세그먼트나 권한 문제이고, Page Fault는 페이지 테이블 문제입니다. 잘못된 메모리 주소 접근은 보통 Page Fault를 먼저 발생시킵니다.
💡 커널 개발 초기에는 GPF가 자주 발생할 수 있습니다. 잘못된 GDT 설정, 스택 정렬 오류, 함수 포인터 타입 불일치 등을 체크하세요. instruction_pointer를 objdump로 디스어셈블하면 정확한 명령어를 확인할 수 있습니다.
💡 QEMU에서 -d int,cpu_reset 옵션을 사용하면 GPF 발생 시 모든 레지스터 값이 로그에 출력됩니다. 이는 포인터 손상이나 스택 오염을 추적하는 데 매우 유용합니다.
💡 보안 관점에서 GPF는 익스플로잇 시도를 탐지하는 신호일 수 있습니다. 유저 모드 GPF의 패턴을 모니터링하고 로그를 남겨 비정상적인 활동을 추적하세요.
💡 일부 GPF는 복구 가능할 수 있습니다. 예를 들어, 유저 공간에서 제공한 잘못된 시스템 콜 파라미터로 인한 GPF는 에러 코드를 반환하고 계속 실행할 수 있습니다. 하지만 커널 버그로 인한 GPF는 절대 복구를 시도하지 마세요.
6. Invalid Opcode 예외 - 잘못된 명령어 감지
시작하며
여러분의 OS가 실행 중에 갑자기 이해할 수 없는 기계어 명령어를 만나면 어떻게 될까요? CPU는 Invalid Opcode 예외를 발생시킵니다.
이는 보통 함수 포인터 손상, 데이터를 코드로 실행, 또는 잘못된 JIT 컴파일이 원인입니다. 이런 문제는 디버깅하기 매우 어려운 케이스입니다.
왜냐하면 실행 흐름이 완전히 엉뚱한 곳으로 튀어버리기 때문입니다. 스택이 오염되어 있을 수도 있고, 반환 주소가 덮어써졌을 수도 있습니다.
바로 이럴 때 필요한 것이 Invalid Opcode 핸들러입니다. 예외가 발생한 지점의 명령어 바이트들을 디스어셈블하고, 스택 상태를 분석하여 어떻게 그곳에 도달했는지 추적해야 합니다.
개요
간단히 말해서, Invalid Opcode 예외(#UD)는 CPU가 인식할 수 없는 명령어를 실행하려 할 때 발생하며, 보통 심각한 메모리 손상이나 제어 흐름 하이재킹을 의미합니다. Invalid Opcode는 여러 상황에서 발생할 수 있습니다.
가장 흔한 경우는 함수 포인터가 손상되어 코드 영역이 아닌 데이터 영역을 가리키거나, 버퍼 오버플로우로 반환 주소가 덮어써진 경우입니다. 또한 SSE나 AVX 같은 확장 명령어를 지원하지 않는 CPU에서 해당 명령어를 실행하려 할 때도 발생합니다.
의도적으로는 UD2 명령어를 사용하여 "도달해서는 안 되는 코드" 지점을 표시하기도 합니다. 전통적으로 Invalid Opcode는 재앙적인 실패로 간주되었지만, 현대 시스템에서는 CPU 기능 감지나 명령어 에뮬레이션에도 활용됩니다.
예를 들어, 가상화 환경에서는 특정 명령어 실행 시 의도적으로 Invalid Opcode를 발생시켜 하이퍼바이저가 개입하도록 만들 수 있습니다. Invalid Opcode 예외의 핵심 특징은 첫째, 에러 코드가 없어 추가 정보를 제공하지 않고, 둘째, instruction_pointer가 정확히 잘못된 명령어를 가리키며, 셋째, 커널 모드에서 발생하면 거의 항상 치명적이라는 점입니다.
이러한 특징들이 예외 진단을 어렵게 만들지만 올바르게 처리하면 중요한 버그를 잡을 수 있습니다.
코드 예제
use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame};
// Invalid Opcode 예외 핸들러
extern "x86-interrupt" fn invalid_opcode_handler(stack_frame: InterruptStackFrame) {
println!("EXCEPTION: INVALID OPCODE");
println!("Instruction Pointer: {:#x}", stack_frame.instruction_pointer);
// 문제의 명령어 바이트 읽기 시도 (주의: 페이지가 매핑되어 있어야 함)
unsafe {
let ip = stack_frame.instruction_pointer.as_u64() as *const u8;
println!("Instruction bytes:");
for i in 0..16 {
print!("{:02x} ", *ip.offset(i));
}
println!();
}
println!("Code Segment: {:#x}", stack_frame.code_segment);
println!("CPU Flags: {:#x}", stack_frame.cpu_flags);
println!("Stack Pointer: {:#x}", stack_frame.stack_pointer);
// 스택 내용 덤프 (스택 오염 확인)
unsafe {
let sp = stack_frame.stack_pointer.as_u64() as *const u64;
println!("Stack dump:");
for i in 0..8 {
println!(" [{:#x}] = {:#x}",
sp.offset(i) as u64, *sp.offset(i));
}
}
// CPU 기능 감지 실패 확인
check_cpu_features();
panic!("Cannot continue after invalid opcode");
}
// CPU 기능 확인 헬퍼
fn check_cpu_features() {
use core::arch::x86_64::__cpuid;
unsafe {
let cpuid = __cpuid(1);
println!("CPU Features:");
println!(" SSE: {}", (cpuid.edx & (1 << 25)) != 0);
println!(" SSE2: {}", (cpuid.edx & (1 << 26)) != 0);
println!(" AVX: {}", (cpuid.ecx & (1 << 28)) != 0);
}
}
// IDT에 핸들러 등록
pub fn init_idt() {
let mut idt = InterruptDescriptorTable::new();
idt.invalid_opcode.set_handler_fn(invalid_opcode_handler);
// ... 다른 핸들러들
idt.load();
}
// 도달하지 않아야 하는 코드 표시에 사용
#[inline(always)]
pub fn unreachable_unchecked() -> ! {
unsafe {
core::arch::asm!("ud2");
core::hint::unreachable_unchecked()
}
}
설명
이것이 하는 일: 이 코드는 잘못된 명령어 실행을 감지하고, 예외 발생 지점의 기계어 코드와 스택 상태를 상세히 분석하여 메모리 손상이나 제어 흐름 하이재킹을 진단합니다. 첫 번째로, invalid_opcode_handler는 instruction_pointer가 가리키는 메모리 위치에서 16바이트를 읽어 출력합니다.
이는 문제의 명령어를 확인하는 데 중요합니다. 예를 들어, 모두 0x00이라면 초기화되지 않은 메모리로 점프한 것이고, 무작위 값들이라면 데이터 영역을 코드로 실행한 것입니다.
0x0F 0x0B 바이트 시퀀스가 보이면 의도적인 UD2 명령어입니다. 단, 이 메모리 읽기는 해당 페이지가 매핑되어 있어야 하므로 자체적으로 Page Fault를 발생시킬 수 있습니다.
그 다음으로, 스택 내용을 덤프하여 오염 여부를 확인합니다. 스택의 상위 몇 워드를 출력하면 반환 주소가 합리적인지, 아니면 무작위 값으로 덮어써졌는지 판단할 수 있습니다.
유효한 커널 코드 주소는 보통 특정 범위(예: 0xFFFF800000000000 이상)에 있으므로, 이 범위를 벗어난 값이 있다면 스택 오버플로우나 버퍼 오버런을 의심해야 합니다. 세 번째로, check_cpu_features 함수는 CPUID 명령어를 사용하여 CPU가 지원하는 확장 명령어 세트를 확인합니다.
만약 SSE 명령어를 실행했는데 CPU가 SSE를 지원하지 않는다면 Invalid Opcode가 발생합니다. 이는 컴파일러 플래그 설정이 잘못되었거나, 타겟 CPU를 잘못 지정했음을 의미합니다.
예를 들어, -C target-feature=+avx로 컴파일했는데 AVX를 지원하지 않는 CPU에서 실행하면 이 예외가 발생합니다. 네 번째로, unreachable_unchecked 헬퍼 함수는 "이론적으로 도달할 수 없는 코드"를 표시하는 데 사용됩니다.
UD2 명령어는 Invalid Opcode를 의도적으로 발생시키는 2바이트 명령어입니다. Rust의 unreachable!() 매크로는 디버그 빌드에서만 체크하지만, 이 함수는 릴리스 빌드에서도 항상 예외를 발생시켜 논리적 버그를 잡습니다.
예를 들어, match 문의 "불가능한" 브랜치에 사용할 수 있습니다. 여러분이 이 코드를 사용하면 함수 포인터 테이블 손상, vtable 오염, JIT 컴파일 버그 같은 치명적인 문제를 조기에 발견할 수 있습니다.
또한 명령어 바이트 덤프를 통해 정확히 어떤 코드가 실행되려 했는지 파악할 수 있어 루트 원인 분석이 가능합니다.
실전 팁
💡 명령어 바이트를 덤프한 후 objdump -D -b binary -m i386:x86-64 -Mintel 명령으로 디스어셈블할 수 있습니다. 바이트를 파일로 저장하고 이 도구를 사용하면 무엇을 실행하려 했는지 정확히 알 수 있습니다.
💡 Invalid Opcode가 항상 버그는 아닙니다. CPUID로 기능을 체크한 후 조건부로 최적화된 경로를 사용하는 것은 정상적인 패턴입니다. 핸들러에서 이를 감지하고 폴백 코드를 실행할 수도 있습니다.
💡 Rust 컴파일러는 특정 조건에서 unreachable_unchecked()를 UD2로 구현합니다. panic=abort 모드나 특정 최적화 수준에서 이를 볼 수 있으며, 이는 코드 크기를 줄이는 데 도움이 됩니다.
💡 함수 포인터를 호출하기 전에 주소의 유효성을 검증하세요. 예를 들어, 주소가 코드 세그먼트 범위 내에 있고 적절히 정렬되어 있는지 확인하세요. 이는 약간의 오버헤드가 있지만 디버깅을 훨씬 쉽게 만듭니다.
💡 스택 카나리(stack canary)를 구현하면 버퍼 오버플로우로 인한 반환 주소 오염을 조기에 탐지할 수 있습니다. 함수 프롤로그에서 스택에 무작위 값을 넣고, 에필로그에서 이 값이 변경되었는지 확인하세요.
7. Stack Segment Fault - 스택 세그먼트 오류 처리
시작하며
여러분이 OS에서 스택 관련 세그먼트를 잘못 설정하거나, 스택 세그먼트의 경계를 넘어서 접근하면 어떻게 될까요? CPU는 Stack Segment Fault 예외를 발생시킵니다.
64비트 모드에서는 드문 예외이지만, 발생하면 치명적입니다. 이런 문제는 주로 레거시 호환성이나 특수한 스택 전환 시나리오에서 발생합니다.
예를 들어, TSS의 스택 포인터가 잘못 설정되었거나, 인터럽트 핸들러 진입 시 스택 세그먼트 레지스터(SS)가 올바르게 로드되지 않은 경우입니다. 바로 이럴 때 필요한 것이 Stack Segment Fault 핸들러입니다.
비록 드물지만, 이 예외를 처리하지 않으면 Triple Fault로 이어질 수 있으므로 적절한 진단 정보를 제공해야 합니다.
개요
간단히 말해서, Stack Segment Fault(#SS)는 스택 세그먼트 관련 문제가 발생했을 때 트리거되며, 주로 스택 포인터가 유효하지 않거나 스택 세그먼트 셀렉터가 잘못되었을 때 나타납니다. 64비트 롱 모드에서는 세그먼테이션이 거의 사용되지 않지만, 스택 세그먼트는 여전히 중요합니다.
특히 인터럽트나 예외 발생 시 권한 레벨이 변경되면 CPU가 TSS에서 새로운 스택 포인터와 스택 세그먼트를 로드합니다. 이때 TSS가 올바르게 설정되지 않았거나, 스택 세그먼트 디스크립터가 유효하지 않으면 Stack Segment Fault가 발생합니다.
전통적으로 16비트와 32비트 모드에서는 스택 세그먼트의 Limit 필드를 사용하여 스택 오버플로우를 감지했지만, 64비트 모드에서는 이 메커니즘이 비활성화되어 있습니다. 따라서 Stack Segment Fault는 주로 세그먼트 셀렉터나 스택 포인터 자체의 문제를 나타냅니다.
Stack Segment Fault의 핵심 특징은 첫째, 에러 코드가 문제의 세그먼트 셀렉터를 포함하고, 둘째, 스택 관련 예외이므로 핸들러 자체가 안전한 스택을 사용해야 하며, 셋째, 64비트 모드에서는 드물지만 발생하면 심각하다는 점입니다. 이러한 특징들이 시스템 초기화와 인터럽트 처리 메커니즘의 올바른 구현을 강제합니다.
코드 예제
use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame};
pub const STACK_SEGMENT_FAULT_IST_INDEX: u16 = 1;
// Stack Segment Fault 핸들러
extern "x86-interrupt" fn stack_segment_fault_handler(
stack_frame: InterruptStackFrame,
error_code: u64,
) {
println!("EXCEPTION: STACK SEGMENT FAULT");
// 에러 코드 분석 (GPF와 동일한 형식)
if error_code != 0 {
let external = (error_code & 0b001) != 0;
let table = (error_code & 0b110) >> 1;
let index = (error_code & !0b111) >> 3;
println!("Error Code: {:#x}", error_code);
println!(" External: {}", external);
println!(" Table: {}", match table {
0 => "GDT",
1 | 3 => "IDT",
2 => "LDT",
_ => "Unknown",
});
println!(" Selector Index: {}", index);
}
println!("Stack Frame:");
println!(" Instruction Pointer: {:#x}", stack_frame.instruction_pointer);
println!(" Code Segment: {:#x}", stack_frame.code_segment);
println!(" CPU Flags: {:#x}", stack_frame.cpu_flags);
println!(" Stack Pointer: {:#x}", stack_frame.stack_pointer);
println!(" Stack Segment: {:#x}", stack_frame.stack_segment);
// TSS 상태 확인
check_tss_configuration();
panic!("Cannot recover from stack segment fault");
}
fn check_tss_configuration() {
println!("Checking TSS configuration...");
// TSS의 스택 포인터들이 올바르게 설정되었는지 확인
// 실제 구현은 TSS 구조체에 접근하여 각 필드를 검증
println!(" Note: Ensure TSS privilege stack pointers are valid");
println!(" Note: Ensure GDT contains valid SS descriptors");
}
// IDT에 핸들러 등록 (별도 IST 스택 사용)
pub fn init_idt() {
let mut idt = InterruptDescriptorTable::new();
unsafe {
idt.stack_segment_fault
.set_handler_fn(stack_segment_fault_handler)
.set_stack_index(STACK_SEGMENT_FAULT_IST_INDEX);
}
// ... 다른 핸들러들
idt.load();
}
설명
이것이 하는 일: 이 코드는 스택 세그먼트 관련 오류를 감지하고 처리하며, TSS와 GDT의 설정을 진단하여 스택 전환 메커니즘의 문제를 파악합니다. 첫 번째로, stack_segment_fault_handler는 에러 코드를 분석하여 어떤 세그먼트 셀렉터가 문제를 일으켰는지 파악합니다.
에러 코드의 형식은 General Protection Fault와 동일합니다. 테이블 필드를 통해 GDT나 LDT 중 어느 것을 참조했는지 알 수 있고, 인덱스를 통해 정확한 디스크립터를 특정할 수 있습니다.
이는 TSS의 특정 권한 레벨 스택 포인터가 잘못된 세그먼트를 가리키고 있음을 의미할 수 있습니다. 그 다음으로, InterruptStackFrame의 stack_segment 필드는 예외 발생 당시의 SS 레지스터 값을 보여줍니다.
64비트 모드에서 유효한 스택 세그먼트는 보통 0x10(커널 데이터 세그먼트) 또는 0x23(유저 데이터 세그먼트, RPL=3 포함)입니다. 만약 이상한 값이 있다면 GDT가 올바르게 설정되지 않았거나, 인터럽트 진입 시 세그먼트 로딩에 문제가 있었음을 의미합니다.
세 번째로, 이 핸들러는 반드시 별도의 IST 스택을 사용해야 합니다. Stack Segment Fault가 발생했다는 것은 현재 스택이 신뢰할 수 없다는 의미이므로, 같은 스택에서 핸들러를 실행하면 또 다른 예외가 발생하여 Double Fault나 Triple Fault로 이어질 수 있습니다.
set_stack_index를 사용하여 IST 엔트리를 지정하면 CPU가 자동으로 안전한 스택으로 전환합니다. 네 번째로, check_tss_configuration 함수는 TSS의 privilege_stack_table 필드들을 검증해야 합니다.
x86_64 아키텍처는 권한 레벨 0, 1, 2별로 별도의 스택 포인터를 TSS에 저장할 수 있으며, 권한 레벨 전환 시 해당 스택으로 자동 전환됩니다. 이 포인터들이 유효한 메모리를 가리키고 적절히 정렬되어 있는지 확인해야 합니다.
여러분이 이 코드를 사용하면 시스템 초기화 과정에서 TSS나 GDT 설정 실수를 빠르게 발견할 수 있습니다. 또한 인터럽트 처리 중 스택 전환 메커니즘이 올바르게 동작하는지 검증할 수 있어, 복잡한 멀티레벨 보호 체계를 안전하게 구현할 수 있습니다.
실전 팁
💡 64비트 롱 모드에서 대부분의 세그먼트 체크는 비활성화되지만, SS와 CS는 여전히 의미가 있습니다. 특히 RPL(Requested Privilege Level) 필드는 권한 체크에 사용되므로 올바르게 설정해야 합니다.
💡 시스템 초기화 시 GDT에 최소한 4개의 디스크립터가 필요합니다: 널 디스크립터, 커널 코드, 커널 데이터(스택 포함), TSS. 유저 공간을 지원한다면 유저 코드와 유저 데이터 디스크립터도 추가하세요.
💡 TSS의 스택 포인터는 스택의 끝(높은 주소)을 가리켜야 합니다. 스택이 아래로 자라므로 이는 직관에 반할 수 있지만 매우 중요합니다. 스택 시작 주소를 설정하면 즉시 Stack Segment Fault가 발생합니다.
💡 QEMU에서 -d int,cpu_reset로 실행하면 Stack Segment Fault 발생 시 모든 세그먼트 레지스터와 디스크립터 캐시 내용이 로그에 출력됩니다. 이는 세그먼트 설정 문제를 디버깅하는 가장 효과적인 방법입니다.
💡 멀티코어 시스템에서는 각 CPU 코어마다 별도의 TSS가 필요합니다. 코어별로 고유한 커널 스택을 사용해야 동시에 인터럽트를 처리할 수 있으므로, TSS를 per-CPU 데이터 구조로 관리하세요.
8. Machine Check 예외 - 하드웨어 오류 탐지
시작하며
여러분의 서버에서 CPU나 메모리에 하드웨어 오류가 발생하면 어떻게 될까요? 현대 CPU는 이런 치명적인 하드웨어 문제를 Machine Check 예외로 보고합니다.
이는 소프트웨어 버그가 아니라 물리적 하드웨어의 문제입니다. 이런 문제는 ECC 메모리 오류, CPU 캐시 패리티 오류, 버스 전송 실패, 과열로 인한 오동작 등 다양한 원인으로 발생할 수 있습니다.
대부분은 복구 불가능하며, 시스템을 즉시 정지시켜 데이터 손상을 방지해야 합니다. 바로 이럴 때 필요한 것이 Machine Check 핸들러입니다.
비록 복구는 어렵지만, Machine Check Bank 레지스터를 읽어 정확한 오류 유형과 위치를 로그로 남기면 하드웨어 교체나 장애 분석에 매우 유용합니다.
개요
간단히 말해서, Machine Check 예외(#MC)는 CPU가 내부적으로 하드웨어 오류를 감지했을 때 발생하며, MSR(Model-Specific Register)을 통해 상세한 오류 정보를 제공합니다. Machine Check Architecture(MCA)는 Intel과 AMD CPU가 하드웨어 오류를 보고하는 표준화된 방법입니다.
CPU는 여러 개의 Machine Check Bank를 가지고 있으며, 각 뱅크는 특정 하드웨어 유닛(캐시, TLB, 메모리 컨트롤러, 버스 인터페이스 등)을 담당합니다. 오류 발생 시 해당 뱅크의 레지스터에 오류 코드, 주소, 기타 진단 정보가 기록됩니다.
일부 Machine Check는 복구 가능(correctable)하고, 일부는 치명적(uncorrectable)입니다. ECC 메모리가 단일 비트 오류를 자동 수정한 경우는 복구 가능한 오류로 보고되며, 시스템은 계속 실행될 수 있습니다.
반면 다중 비트 오류나 CPU 내부 오류는 대부분 복구 불가능하며, 즉시 시스템을 정지해야 합니다. Machine Check 예외의 핵심 특징은 첫째, 소프트웨어 버그가 아닌 하드웨어 문제를 나타내고, 둘째, MSR을 통해 매우 상세한 진단 정보를 제공하며, 셋째, 일부는 복구 가능하지만 대부분은 치명적이라는 점입니다.
이러한 특징들이 미션 크리티컬 시스템에서 하드웨어 신뢰성을 모니터링하는 데 중요합니다.
코드 예제
use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame};
use x86_64::registers::model_specific::Msr;
pub const MACHINE_CHECK_IST_INDEX: u16 = 2;
// Machine Check 관련 MSR 주소
const IA32_MCG_STATUS: u32 = 0x17A;
const IA32_MCG_CAP: u32 = 0x179;
// Machine Check 예외 핸들러
extern "x86-interrupt" fn machine_check_handler(stack_frame: InterruptStackFrame) -> ! {
println!("EXCEPTION: MACHINE CHECK");
println!("This indicates a hardware error!");
unsafe {
// MCG_STATUS 레지스터 읽기
let mcg_status = Msr::new(IA32_MCG_STATUS);
let status = mcg_status.read();
println!("MCG_STATUS: {:#x}", status);
println!(" RIPV (Restart IP Valid): {}", (status & (1 << 0)) != 0);
println!(" EIPV (Error IP Valid): {}", (status & (1 << 1)) != 0);
println!(" MCIP (Machine Check In Progress): {}", (status & (1 << 2)) != 0);
// MCG_CAP 레지스터로 뱅크 개수 확인
let mcg_cap = Msr::new(IA32_MCG_CAP);
let cap = mcg_cap.read();
let bank_count = cap & 0xFF;
println!("Machine Check Banks: {}", bank_count);
// 각 뱅크의 상태 읽기
for bank in 0..bank_count {
let mc_status = Msr::new(0x401 + bank as u32 * 4);
let status = mc_status.read();
// Valid 비트 체크
if (status & (1 << 63)) != 0 {
println!("\nBank {} Error:", bank);
println!(" Status: {:#x}", status);
println!(" Valid: true");
println!(" Overflow: {}", (status & (1 << 62)) != 0);
println!(" Uncorrected: {}", (status & (1 << 61)) != 0);
println!(" Error Enabled: {}", (status & (1 << 60)) != 0);
// 주소 레지스터 읽기 (가능한 경우)
if (status & (1 << 58)) != 0 {
let mc_addr = Msr::new(0x402 + bank as u32 * 4);
println!(" Address: {:#x}", mc_addr.read());
}
}
}
}
println!("\nInstruction Pointer: {:#x}", stack_frame.instruction_pointer);
panic!("Fatal hardware error - system halted");
}
// IDT에 핸들러 등록
pub fn init_idt() {
let mut idt = InterruptDescriptorTable::new();
unsafe {
idt.machine_check
.set_handler_fn(machine_check_handler)
.set_stack_index(MACHINE_CHECK_IST_INDEX);
}
// ... 다른 핸들러들
idt.load();
}
// Machine Check 활성화 (부팅 시 호출)
pub fn enable_machine_check() {
use x86_64::registers::control::Cr4;
use x86_64::registers::control::Cr4Flags;
unsafe {
// CR4.MCE 비트 설정
Cr4::update(|flags| {
*flags |= Cr4Flags::MACHINE_CHECK_EXCEPTION;
});
}
println!("Machine Check Exception enabled");
}
설명
이것이 하는 일: 이 코드는 하드웨어 수준의 치명적 오류를 감지하고, Machine Check 아키텍처 레지스터를 읽어 정확한 오류 정보를 수집한 후 안전하게 시스템을 정지시킵니다. 첫 번째로, machine_check_handler는 MSR(Model-Specific Register)을 사용하여 오류 정보를 읽습니다.
MCG_STATUS 레지스터는 전역 상태를 제공하며, RIPV 비트는 instruction_pointer가 유효한지, EIPV 비트는 오류를 일으킨 명령어 주소가 유효한지, MCIP 비트는 Machine Check가 진행 중인지를 나타냅니다. MCIP가 이미 설정되어 있는데 또 다른 Machine Check가 발생하면 CPU가 리셋됩니다.
그 다음으로, MCG_CAP 레지스터는 CPU가 지원하는 Machine Check Bank의 개수를 알려줍니다. 각 뱅크는 4개의 MSR로 구성됩니다: MCi_CTL(제어), MCi_STATUS(상태), MCi_ADDR(주소), MCi_MISC(기타).
핸들러는 모든 뱅크를 순회하면서 Valid 비트(비트 63)가 설정된 뱅크를 찾습니다. Valid 비트는 해당 뱅크에 오류 정보가 있음을 의미합니다.
세 번째로, 각 뱅크의 STATUS 레지스터를 분석하여 오류의 심각도를 판단합니다. Uncorrected 비트(비트 61)가 설정되어 있으면 복구 불가능한 오류이고, 그렇지 않으면 ECC가 자동 수정한 복구 가능한 오류입니다.
Overflow 비트(비트 62)는 여러 오류가 발생하여 일부 정보가 손실되었음을 나타냅니다. ADDR 레지스터가 유효하면(비트 58), 오류가 발생한 메모리 주소를 제공하므로 어떤 메모리 모듈에 문제가 있는지 파악할 수 있습니다.
네 번째로, enable_machine_check 함수는 시스템 부팅 시 CR4.MCE 비트를 설정하여 Machine Check 예외를 활성화합니다. 이 비트가 설정되지 않으면 하드웨어 오류 발생 시 CPU가 즉시 리셋되어 진단 정보를 수집할 기회가 없습니다.
핸들러는 반드시 별도의 IST 스택을 사용해야 하며, 어떤 경우에도 반환하지 않도록 발산 함수(-> !)로 선언됩니다. 여러분이 이 코드를 사용하면 데이터센터 환경에서 하드웨어 신뢰성을 모니터링하고, 문제가 있는 하드웨어를 조기에 식별하여 교체할 수 있습니다.
또한 복구 가능한 오류의 빈도를 추적하여 하드웨어 노화를 예측하고 예방적 유지보수를 계획할 수 있습니다.
실전 팁
💡 Machine Check는 매우 드물게 발생하지만, 발생하면 치명적입니다. 프로덕션 시스템에서는 오류 정보를 네트워크를 통해 원격 로깅 서버로 전송하거나, 시리얼 포트를 통해 외부 시스템에 알리는 것이 좋습니다.
💡 복구 가능한 오류(correctable error)는 예외가 아니라 인터럽트(CMCI - Corrected Machine Check Interrupt)로 보고될 수 있습니다. 이를 설정하면 시스템 중단 없이 메모리 오류를 모니터링할 수 있습니다.
💡 Intel과 AMD의 Machine Check 레지스터 레이아웃이 약간 다릅니다. CPUID로 벤더를 확인하고 적절한 레지스터 해석 로직을 사용하세요. 특히 MISC와 CTL2 레지스터는 벤더마다 다릅니다.
💡 가상 머신에서는 하이퍼바이저가 Machine Check를 가상화할 수 있습니다. QEMU/KVM에서 테스트하려면 호스트 시스템의 실제 하드웨어 오류를 게스트로 전달하도록 설정하거나, mce-inject 도구로 테스트 오류를 주입할 수 있습니다.
💡 Machine Check 핸들러는 최소한의 작업만 수행해야 합니다. 메모리 할당, 락 획득, 복잡한 로깅 등은 피하세요. 하드웨어가 불안정한 상태이므로 어떤 작업도 실패할 수 있습니다. 가장 중요한 정보를 시리얼 포트로 즉시 출력하고 멈추세요.