이미지 로딩 중...

Rust OS 개발 완벽 가이드 Double Fault 처리 마스터하기 - 슬라이드 1/9
A

AI Generated

2025. 11. 13. · 3 Views

Rust OS 개발 완벽 가이드 Double Fault 처리 마스터하기

Rust로 운영체제를 만들면서 가장 까다로운 Double Fault 예외를 완벽하게 처리하는 방법을 배워봅니다. IST(Interrupt Stack Table)를 활용한 안전한 스택 전환부터 실전 디버깅 팁까지, OS 개발의 핵심을 실무 수준으로 다룹니다.


목차

  1. Double Fault란 무엇인가 - 예외 처리의 최후 방어선 이해하기
  2. IST(Interrupt Stack Table) 구성하기 - 안전한 스택 전환의 핵심
  3. GDT에 TSS 로드하기 - CPU가 TSS를 인식하게 만들기
  4. 스택 오버플로우 테스트 작성하기 - Double Fault 핸들러 동작 검증
  5. 예외 에러 코드 이해하기 - Double Fault의 원인 파악
  6. 페이지 가드를 활용한 스택 보호 - 오버플로우 조기 감지
  7. x86-interrupt 호출 규약 이해하기 - 안전한 예외 핸들러 작성
  8. IDT 초기화 시점 관리 - 부팅 과정의 핵심 순서

1. Double Fault란 무엇인가 - 예외 처리의 최후 방어선 이해하기

시작하며

여러분이 Rust로 OS를 개발하다가 갑자기 시스템이 Triple Fault로 리부팅되는 상황을 겪어본 적 있나요? 디버깅도 불가능하고, 어디서 문제가 발생했는지조차 알 수 없는 그 답답한 순간 말입니다.

이런 문제는 예외 핸들러가 제대로 설정되지 않았거나, 스택 오버플로우로 인해 예외 처리 중에 또 다른 예외가 발생할 때 나타납니다. CPU는 첫 번째 예외를 처리하려다 실패하면 Double Fault를 발생시키고, 이마저 처리하지 못하면 시스템을 강제로 리셋시킵니다.

바로 이럴 때 필요한 것이 Double Fault 핸들러입니다. 이것은 예외 처리의 최후 방어선으로, 시스템이 완전히 무너지기 전에 마지막으로 제어권을 가질 수 있는 기회를 제공합니다.

개요

간단히 말해서, Double Fault는 CPU가 예외를 처리하는 도중에 또 다른 예외가 발생했을 때 발생하는 특수한 예외입니다. 실제 OS 개발에서는 스택 오버플로우가 가장 흔한 원인입니다.

예를 들어, Page Fault 핸들러가 실행되려는데 스택 공간이 부족하면 또 다른 Page Fault가 발생하고, 이것이 연쇄적으로 Double Fault를 트리거합니다. 이런 상황은 재귀 호출이 깊어지거나 스택 가드 페이지가 없을 때 매우 쉽게 발생합니다.

전통적인 방법으로는 예외 핸들러를 등록하기만 하면 됐지만, Double Fault의 경우 스택 자체가 손상되었을 가능성이 높아 일반적인 방법으로는 처리할 수 없습니다. 그래서 x86_64 아키텍처는 IST(Interrupt Stack Table)라는 특별한 메커니즘을 제공합니다.

Double Fault 핸들러의 핵심 특징은 세 가지입니다. 첫째, 독립적인 스택을 사용하여 스택 오버플로우 상황에서도 안전하게 실행됩니다.

둘째, IDT(Interrupt Descriptor Table)에 특별히 등록되어 CPU가 자동으로 호출합니다. 셋째, 이 핸들러가 실패하면 시스템은 Triple Fault로 리부팅됩니다.

이러한 특징들이 Double Fault를 "마지막 기회"로 만들어주는 이유입니다.

코드 예제

use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame};

lazy_static! {
    static ref IDT: InterruptDescriptorTable = {
        let mut idt = InterruptDescriptorTable::new();
        // Double Fault 핸들러를 등록하고 IST 인덱스 0번 스택 사용
        unsafe {
            idt.double_fault
                .set_handler_fn(double_fault_handler)
                .set_stack_index(0); // IST의 첫 번째 스택으로 전환
        }
        idt
    };
}

extern "x86-interrupt" fn double_fault_handler(
    stack_frame: InterruptStackFrame,
    _error_code: u64
) -> ! {
    panic!("EXCEPTION: DOUBLE FAULT\n{:#?}", stack_frame);
}

설명

이것이 하는 일은 CPU가 예외 처리 과정에서 실패했을 때 시스템을 완전히 포기하기 전에 마지막으로 실행할 수 있는 핸들러를 등록하는 것입니다. 이는 디버깅 정보를 출력하거나 시스템 상태를 저장할 수 있는 마지막 기회를 제공합니다.

첫 번째로, lazy_static을 사용해 IDT를 정적으로 초기화합니다. 이는 OS 부팅 시 한 번만 실행되며, 메모리에 영구적으로 상주합니다.

InterruptDescriptorTable::new()는 모든 엔트리를 기본값으로 초기화한 IDT를 생성합니다. 여기서 중요한 것은 double_fault 필드에 접근하여 핸들러를 설정하는 부분입니다.

그 다음으로, set_handler_fn()으로 실제 핸들러 함수를 등록하고, set_stack_index(0)으로 IST의 0번 인덱스 스택을 지정합니다. 이 과정은 unsafe 블록 안에 있는데, 잘못된 스택 인덱스를 지정하면 정의되지 않은 동작이 발생할 수 있기 때문입니다.

CPU는 Double Fault가 발생하면 자동으로 IST[0]에 지정된 스택으로 전환한 뒤 핸들러를 실행합니다. 마지막으로, 핸들러 함수는 InterruptStackFrame과 error_code를 파라미터로 받습니다.

InterruptStackFrame에는 예외 발생 시점의 명령 포인터, 코드 세그먼트, 플래그 레지스터, 스택 포인터 등 중요한 CPU 상태가 담겨 있습니다. 반환 타입이 !인 것에 주목하세요.

이는 이 함수가 절대 반환하지 않는다는 의미로, Double Fault 이후에는 시스템이 정상적으로 계속 실행될 수 없음을 타입 시스템으로 표현한 것입니다. 여러분이 이 코드를 사용하면 스택 오버플로우나 연쇄 예외 상황에서도 panic!

메시지와 스택 프레임 정보를 확인할 수 있습니다. 실무에서는 시스템이 무작정 재부팅되는 대신 QEMU 콘솔이나 시리얼 포트로 디버깅 정보를 출력하여 문제의 원인을 파악할 수 있고, 심지어 메모리 덤프를 저장하거나 네트워크를 통해 크래시 리포트를 전송하는 등의 복구 작업도 가능해집니다.

실전 팁

💡 IST 스택 크기는 최소 4096바이트(1페이지) 이상으로 설정하세요. 너무 작으면 핸들러 실행 중에 또 다른 스택 오버플로우가 발생할 수 있습니다.

💡 Double Fault 핸들러에서는 절대 힙 할당을 하지 마세요. 힙 상태가 손상되었을 수 있고, 할당 실패 시 또 다른 예외가 발생할 수 있습니다.

💡 디버깅을 위해 핸들러에서 스택 프레임뿐만 아니라 CR2 레지스터(Page Fault 주소)와 CR3(페이지 테이블 주소)도 함께 출력하면 원인 파악이 훨씬 쉬워집니다.

💡 QEMU에서 테스트할 때는 -d int,cpu_reset 옵션을 사용하면 Triple Fault 발생 시점의 CPU 상태를 확인할 수 있어 Double Fault 핸들러가 제대로 동작하지 않을 때 유용합니다.

💡 프로덕션 환경에서는 Double Fault 핸들러에서 시리얼 포트나 비디오 버퍼로 직접 출력하세요. println! 같은 고수준 함수는 이 시점에서 안전하지 않을 수 있습니다.


2. IST(Interrupt Stack Table) 구성하기 - 안전한 스택 전환의 핵심

시작하며

여러분이 Double Fault 핸들러를 등록했는데도 여전히 Triple Fault로 리부팅되는 상황을 겪어본 적 있나요? 핸들러 코드는 완벽한데 실제로는 실행조차 되지 않는 그 황당한 순간 말입니다.

이런 문제는 IST를 제대로 설정하지 않았기 때문에 발생합니다. 핸들러를 등록하는 것만으로는 충분하지 않습니다.

CPU가 실제로 사용할 독립적인 스택 메모리를 TSS(Task State Segment)에 정확히 설정해야 하며, 이 스택은 현재 손상된 스택과 완전히 분리되어 있어야 합니다. 바로 이럴 때 필요한 것이 올바른 IST 구성입니다.

IST는 x86_64 아키텍처가 제공하는 특별한 메커니즘으로, 특정 예외 발생 시 자동으로 미리 지정된 안전한 스택으로 전환해줍니다.

개요

간단히 말해서, IST는 TSS 안에 있는 7개의 스택 포인터 배열로, 특정 인터럽트나 예외가 발생했을 때 CPU가 자동으로 전환할 스택 주소를 담고 있습니다. 실무에서 IST가 필요한 이유는 명확합니다.

스택 오버플로우로 인한 Double Fault의 경우 현재 스택을 사용할 수 없기 때문에, CPU가 예외 핸들러의 리턴 주소나 파라미터를 푸시할 방법이 없습니다. 예를 들어, 커널 스택이 가드 페이지에 도달해서 Page Fault가 발생했고, 이 Page Fault를 처리하려고 했지만 여전히 스택 공간이 없어서 또 Page Fault가 발생하는 상황에서는 새로운 스택이 절대적으로 필요합니다.

기존에는 하드웨어 태스크 전환을 사용해서 완전히 다른 태스크의 스택으로 전환했다면, 현대의 x86_64는 이 무거운 메커니즘 대신 IST라는 가벼운 스택 전환만 제공합니다. 이는 성능상 이점이 크고 구현도 훨씬 간단합니다.

IST의 핵심 특징은 다음과 같습니다. 첫째, 각 IST 엔트리는 완전히 독립적인 스택 영역을 가리킵니다.

둘째, IDT의 각 엔트리는 0-7 범위의 IST 인덱스를 지정할 수 있으며, 0은 IST를 사용하지 않음을 의미합니다. 셋째, CPU는 예외 발생 시 자동으로 RSP 레지스터를 IST에 지정된 주소로 변경합니다.

이러한 자동 전환 메커니즘이 스택 손상 상황에서도 안전한 실행을 보장합니다.

코드 예제

use x86_64::VirtAddr;
use x86_64::structures::tss::TaskStateSegment;

pub const DOUBLE_FAULT_IST_INDEX: u16 = 0;

lazy_static! {
    static ref TSS: TaskStateSegment = {
        let mut tss = TaskStateSegment::new();
        // 5페이지 크기의 스택 할당 (20KB)
        tss.interrupt_stack_table[DOUBLE_FAULT_IST_INDEX as usize] = {
            const STACK_SIZE: usize = 4096 * 5;
            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
    };
}

설명

이것이 하는 일은 Double Fault 핸들러가 실행될 때 사용할 완전히 새로운 스택 영역을 메모리에 확보하고, 그 주소를 TSS의 IST 배열에 등록하는 것입니다. CPU는 Double Fault가 발생하면 이 주소를 자동으로 RSP 레지스터에 로드합니다.

첫 번째로, TaskStateSegment::new()로 TSS 구조체를 생성합니다. TSS는 원래 하드웨어 태스크 전환을 위한 구조체였지만, x86_64에서는 주로 IST와 권한 레벨 전환 시 사용할 스택 포인터를 저장하는 용도로 사용됩니다.

interrupt_stack_table 필드는 [VirtAddr; 7] 타입의 배열로, 7개의 독립적인 스택을 지정할 수 있습니다. 그 다음으로, static mut STACK 배열로 실제 스택 메모리를 할당합니다.

여기서는 20KB(5페이지)를 할당했는데, 이는 핸들러가 panic!을 호출하고 스택 트레이스를 출력하는 데 충분한 공간입니다. VirtAddr::from_ptr()로 배열의 시작 주소를 가져오고, STACK_SIZE를 더해서 스택의 끝 주소를 계산합니다.

x86 스택은 높은 주소에서 낮은 주소로 자라기 때문에 끝 주소를 스택 포인터로 사용해야 합니다. 마지막으로, 이 스택 끝 주소를 tss.interrupt_stack_table[0]에 할당합니다.

DOUBLE_FAULT_IST_INDEX를 상수로 정의해서 매직 넘버를 피하고, 나중에 IDT 설정과 TSS 설정 간의 일관성을 유지하기 쉽게 만듭니다. 이제 CPU가 Double Fault를 감지하면 자동으로 이 스택으로 전환한 뒤 핸들러를 실행하게 됩니다.

여러분이 이 코드를 사용하면 스택 오버플로우가 발생해도 Double Fault 핸들러가 완전히 독립적인 메모리 공간에서 안전하게 실행됩니다. 실무에서는 현재 손상된 스택의 내용을 검사하고, 어떤 함수 호출이 스택 오버플로우를 일으켰는지 역추적할 수 있으며, 멀티코어 시스템에서는 각 코어마다 별도의 IST 스택을 할당하여 코어 간 간섭 없이 예외를 처리할 수 있습니다.

또한 테스트 코드에서 의도적으로 스택 오버플로우를 발생시켜 Double Fault 핸들러가 제대로 동작하는지 검증할 수 있습니다.

실전 팁

💡 IST 스택은 반드시 static mut로 선언하거나 Box::leak()을 사용해서 프로그램 전체 수명 동안 유효해야 합니다. 지역 변수로 할당하면 스코프를 벗어날 때 해제되어 댕글링 포인터가 됩니다.

💡 멀티코어 시스템에서는 각 CPU 코어마다 독립적인 TSS와 IST 스택이 필요합니다. 공유하면 경쟁 조건이 발생합니다.

💡 IST 스택 크기는 핸들러의 복잡도에 따라 결정하세요. 단순 메시지 출력만 한다면 4KB로 충분하지만, 스택 트레이스나 메모리 덤프를 생성한다면 최소 16KB는 확보해야 합니다.

💡 디버깅 시 IST 스택을 특정 패턴(예: 0xCC)으로 초기화하면 스택 사용량을 쉽게 측정할 수 있습니다. 핸들러 실행 후 얼마나 많은 바이트가 여전히 0xCC인지 확인하면 됩니다.

💡 Page Fault 핸들러도 IST를 사용하는 것을 고려하세요. 특히 커널 스택의 가드 페이지를 구현한 경우, Page Fault 핸들러 자체가 스택 오버플로우의 첫 번째 감지 지점이 되므로 별도 스택이 필요합니다.


3. GDT에 TSS 로드하기 - CPU가 TSS를 인식하게 만들기

시작하며

여러분이 TSS를 완벽하게 구성했는데도 CPU가 여전히 이를 무시하는 상황을 겪어본 적 있나요? IST 스택도 할당했고 주소도 정확한데, 실제로 예외가 발생하면 여전히 이전 스택을 사용하는 그 혼란스러운 순간 말입니다.

이런 문제는 TSS를 생성만 하고 CPU에게 알려주지 않았기 때문에 발생합니다. x86_64 아키텍처에서 CPU는 GDT(Global Descriptor Table)를 통해서만 TSS의 위치를 알 수 있습니다.

TSS 세그먼트 디스크립터를 GDT에 추가하고, ltr 명령으로 Task Register에 해당 세그먼트 셀렉터를 로드해야 비로소 CPU가 TSS를 사용하기 시작합니다. 바로 이럴 때 필요한 것이 GDT 설정과 TSS 로딩입니다.

이는 TSS를 단순한 메모리 구조체에서 CPU가 실제로 인식하고 사용하는 하드웨어 자원으로 승격시키는 과정입니다.

개요

간단히 말해서, GDT에 TSS를 로드한다는 것은 TSS의 메모리 주소와 크기를 담은 특수한 디스크립터를 GDT에 추가하고, CPU의 Task Register가 이를 가리키도록 설정하는 과정입니다. 실무에서 이 과정이 중요한 이유는 CPU가 권한 레벨을 전환하거나 IST를 사용해야 할 때 Task Register가 가리키는 TSS를 자동으로 참조하기 때문입니다.

예를 들어, 유저 모드에서 시스템 콜이 발생하면 CPU는 TSS의 privilege_stack_table[0]을 참조하여 커널 스택으로 전환하고, Double Fault가 발생하면 interrupt_stack_table[0]을 참조합니다. TSS가 로드되지 않으면 이런 자동 전환이 전혀 동작하지 않습니다.

전통적인 x86 32비트 모드에서는 하드웨어 태스크 전환을 위해 여러 개의 TSS를 GDT에 등록했다면, x86_64에서는 CPU당 하나의 TSS만 있으면 충분합니다. 소프트웨어 컨텍스트 스위칭이 훨씬 빠르기 때문입니다.

GDT TSS 로딩의 핵심 특징은 다음과 같습니다. 첫째, TSS 디스크립터는 일반 세그먼트 디스크립터의 두 배 크기(16바이트)를 차지합니다.

둘째, GDT는 부팅 시 한 번만 설정되고 런타임에는 거의 변경되지 않습니다. 셋째, ltr 명령은 특권 명령이므로 커널 모드에서만 실행 가능합니다.

이러한 특징들이 TSS를 시스템 전체에서 안전하게 공유할 수 있게 만듭니다.

코드 예제

use x86_64::structures::gdt::{GlobalDescriptorTable, Descriptor, SegmentSelector};
use x86_64::instructions::tables::load_tss;
use x86_64::instructions::segmentation::{CS, Segment};

lazy_static! {
    static ref GDT: (GlobalDescriptorTable, Selectors) = {
        let mut gdt = GlobalDescriptorTable::new();
        // 커널 코드 세그먼트 추가
        let code_selector = gdt.add_entry(Descriptor::kernel_code_segment());
        // TSS 디스크립터 추가 (16바이트)
        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(); // GDTR 레지스터에 GDT 주소와 크기 로드
    unsafe {
        CS::set_reg(GDT.1.code_selector); // 코드 세그먼트 설정
        load_tss(GDT.1.tss_selector);      // Task Register에 TSS 로드
    }
}

설명

이것이 하는 일은 CPU에게 "이 메모리 주소에 있는 TSS 구조체를 사용해서 스택 전환을 처리하라"고 알려주는 것입니다. 이는 하드웨어 레벨에서 예외 처리 메커니즘을 활성화하는 핵심 단계입니다.

첫 번째로, GlobalDescriptorTable::new()로 빈 GDT를 생성합니다. GDT는 여러 세그먼트 디스크립터를 담는 테이블로, 최소한 null 디스크립터(0번 엔트리)와 코드 세그먼트가 필요합니다.

add_entry()를 호출할 때마다 디스크립터가 순차적으로 추가되고, 해당 엔트리를 가리키는 SegmentSelector가 반환됩니다. 이 셀렉터는 세그먼트 레지스터에 로드할 때 사용하는 인덱스입니다.

그 다음으로, Descriptor::tss_segment(&TSS)로 TSS 디스크립터를 생성합니다. 이 디스크립터는 TSS의 시작 주소, 크기(limit), 그리고 타입 정보를 포함합니다.

x86_64에서 TSS 디스크립터는 16바이트를 차지하는데, 64비트 주소를 모두 담기 위해 두 개의 GDT 엔트리를 사용합니다. 이는 x86_64 아키텍처의 특별한 규칙이며, x86-64 crate가 자동으로 처리해줍니다.

마지막으로, init() 함수에서 실제 하드웨어 설정을 수행합니다. GDT.0.load()는 lgdt 명령을 실행하여 GDTR 레지스터에 GDT의 주소와 크기를 로드합니다.

CS::set_reg()는 코드 세그먼트 레지스터를 새로운 셀렉터로 변경하는데, 이는 far return이나 far jump를 통해 안전하게 이루어집니다. load_tss()는 ltr 명령을 실행하여 Task Register에 TSS 셀렉터를 로드하며, 이 시점부터 CPU는 예외 발생 시 TSS를 자동으로 참조하기 시작합니다.

여러분이 이 코드를 사용하면 OS 초기화 과정에서 한 번만 호출하여 전체 시스템에서 IST가 동작하도록 만들 수 있습니다. 실무에서는 멀티코어 시스템의 각 코어가 부팅될 때 자신만의 GDT와 TSS를 로드하며, SMP(대칭형 다중처리) 초기화 루틴에서 이 init() 함수를 코어별로 호출합니다.

또한 유저 모드를 지원하는 OS라면 GDT에 유저 코드와 데이터 세그먼트도 함께 추가하여 권한 레벨 전환을 가능하게 만듭니다. 이 설정이 완료되면 스택 오버플로우 테스트를 실행해서 Double Fault 핸들러가 정확히 동작하는지 검증할 수 있습니다.

실전 팁

💡 GDT는 반드시 'static 라이프타임을 가져야 합니다. GDTR이 가리키는 메모리가 해제되면 다음 세그먼트 로드 시 시스템이 크래시됩니다.

💡 init() 함수는 인터럽트가 비활성화된 상태에서 호출하세요. GDT와 TSS를 로드하는 중간에 인터럽트가 발생하면 불완전한 상태의 세그먼트를 사용하게 됩니다.

💡 QEMU에서 테스트할 때 -d cpu_reset,int를 사용하면 TSS 로드 실패나 잘못된 셀렉터 사용 시 상세한 에러 메시지를 볼 수 있습니다.

💡 GDT 엔트리는 최대 8192개까지 가능하지만, 대부분의 OS는 10개 미만만 사용합니다. null, 커널 코드/데이터, 유저 코드/데이터, TSS 정도면 충분합니다.

💡 TSS의 iomap_base 필드를 설정하면 유저 모드에서 특정 I/O 포트에 직접 접근하도록 허용할 수 있습니다. 이는 고성능 드라이버를 유저 공간에서 실행할 때 유용합니다.


4. 스택 오버플로우 테스트 작성하기 - Double Fault 핸들러 동작 검증

시작하며

여러분이 Double Fault 핸들러를 모두 구현했는데 실제로 제대로 동작하는지 확신할 수 없는 상황을 겪어본 적 있나요? 코드는 컴파일되고 부팅도 되지만, 정작 스택 오버플로우가 발생했을 때 핸들러가 호출될지 장담할 수 없는 그 불안한 순간 말입니다.

이런 문제는 예외 처리 코드의 특성 때문에 발생합니다. 정상적인 실행 경로에서는 절대 호출되지 않기 때문에 버그가 있어도 발견하기 어렵고, 실제 문제가 발생했을 때 비로소 동작하지 않는다는 것을 알게 됩니다.

게다가 스택 오버플로우는 예측하기 어려운 타이밍에 발생하므로 재현도 어렵습니다. 바로 이럴 때 필요한 것이 의도적인 스택 오버플로우 테스트입니다.

재귀 함수를 무한히 호출하여 스택을 고갈시키고, Double Fault 핸들러가 정확히 호출되는지 확인하는 것이 안전한 OS 개발의 핵심입니다.

개요

간단히 말해서, 스택 오버플로우 테스트는 의도적으로 재귀 호출을 무한 반복하여 스택을 모두 소진시키고, 그 결과로 Double Fault가 발생하는지 확인하는 자동화된 검증 메커니즘입니다. 실무에서 이런 테스트가 필요한 이유는 예외 처리 코드의 정확성이 시스템 안정성에 직결되기 때문입니다.

예를 들어, IST 스택 주소를 잘못 설정했거나, TSS를 로드하지 않았거나, IDT에서 IST 인덱스를 잘못 지정한 경우 모두 스택 오버플로우 시 Triple Fault로 이어집니다. 이런 버그는 통합 테스트에서 자동으로 감지되어야 하며, CI/CD 파이프라인에서 회귀를 방지해야 합니다.

전통적인 방법으로는 디버거로 수동으로 스택을 추적하며 확인했다면, 현대적인 방법은 테스트 프레임워크를 사용해 자동으로 검증합니다. Rust의 #[test_case] 속성과 커스텀 테스트 러너를 활용하면 QEMU에서 OS를 부팅하고 특정 테스트를 실행한 뒤 결과를 자동으로 확인할 수 있습니다.

스택 오버플로우 테스트의 핵심 특징은 다음과 같습니다. 첫째, 컴파일러 최적화를 방지하기 위해 휘발성 변수나 블랙박스 함수를 사용합니다.

둘째, 테스트가 성공하면 특정 종료 코드를 반환하여 외부 러너가 결과를 판단할 수 있게 합니다. 셋째, Double Fault 핸들러 내부에서 테스트 성공 시그널을 보내고 시스템을 종료합니다.

이러한 구조가 예외 처리 코드를 실제 환경과 동일한 조건에서 검증할 수 있게 만듭니다.

코드 예제

#[test_case]
fn stack_overflow() {
    // 컴파일러 최적화를 방지하기 위한 휘발성 변수
    #[allow(unconditional_recursion)]
    fn recurse(counter: &mut u64) {
        *counter += 1;
        // 스택에 데이터 쓰기 (최적화 방지)
        core::hint::black_box(counter);
        recurse(counter); // 무한 재귀
    }

    serial_print!("stack_overflow::stack_overflow...\t");

    let mut counter = 0;
    recurse(&mut counter);

    // 이 지점에 도달하면 테스트 실패
    panic!("Execution continued after stack overflow");
}

extern "x86-interrupt" fn test_double_fault_handler(
    _stack_frame: InterruptStackFrame,
    _error_code: u64,
) -> ! {
    serial_println!("[ok]");
    exit_qemu(QemuExitCode::Success);
    loop {}
}

설명

이것이 하는 일은 테스트 환경에서 실제 스택 오버플로우를 안전하게 재현하고, Double Fault 핸들러가 예상대로 호출되는지 자동으로 확인하는 것입니다. 테스트가 통과하면 핸들러가 정확히 동작하는 것이고, 실패하면 설정 과정 어딘가에 문제가 있는 것입니다.

첫 번째로, recurse() 함수는 자기 자신을 무조건 호출하는 재귀 함수입니다. counter 변수를 증가시키고 black_box()로 감싸서 컴파일러가 이 함수 호출을 최적화로 제거하지 못하게 합니다.

black_box()는 컴파일러에게 "이 값이 외부에서 관찰될 수 있으니 부작용을 유지하라"고 알려주는 함수입니다. #[allow(unconditional_recursion)]은 Rust 컴파일러의 무한 재귀 경고를 의도적으로 억제합니다.

그 다음으로, recurse()를 호출하면 각 재귀 호출마다 스택에 리턴 주소, counter의 주소, 그리고 지역 변수들이 푸시됩니다. 수백 번의 재귀 후에는 스택이 가드 페이지나 다음 메모리 영역에 도달하게 되고, 이때 Page Fault가 발생합니다.

Page Fault 핸들러가 실행되려고 하지만 여전히 스택 공간이 부족하므로 또 다른 Page Fault가 발생하여 최종적으로 Double Fault로 이어집니다. 마지막으로, test_double_fault_handler()가 호출되면 IST 스택으로 전환되어 안전하게 실행됩니다.

serial_println!("[ok]")로 테스트 성공 메시지를 시리얼 포트로 출력하고, exit_qemu()로 QEMU를 특정 종료 코드와 함께 종료시킵니다. 외부 테스트 러너(cargo test)는 이 종료 코드를 확인하여 테스트 통과 여부를 판단합니다.

만약 Double Fault 핸들러가 호출되지 않았다면 시스템은 Triple Fault로 리부팅되고, 테스트 러너는 타임아웃이나 예상치 못한 종료 코드를 감지하여 테스트를 실패로 판정합니다. 여러분이 이 코드를 사용하면 CI/CD 파이프라인에서 모든 커밋마다 자동으로 Double Fault 처리가 정상 동작하는지 검증할 수 있습니다.

실무에서는 cargo test를 실행하면 QEMU가 자동으로 시작되고, 이 테스트를 포함한 모든 커널 테스트가 순차적으로 실행되며, 각 테스트의 성공/실패가 표준 출력으로 보고됩니다. 또한 다양한 스택 크기에서 테스트를 실행하여 경계 조건을 검증하거나, 여러 코어에서 동시에 스택 오버플로우를 발생시켜 코어별 IST 설정이 올바른지 확인할 수도 있습니다.

실전 팁

💡 테스트용 Double Fault 핸들러는 프로덕션 핸들러와 별도로 정의하세요. 테스트에서는 성공 시그널을 보내고 종료해야 하지만, 프로덕션에서는 디버깅 정보를 출력하고 대기해야 합니다.

💡 black_box() 대신 inline(never) 속성을 사용할 수도 있지만, black_box가 더 강력한 최적화 방지 효과를 제공합니다.

💡 스택 오버플로우 테스트는 실행 시간이 길 수 있으므로 타임아웃을 적절히 설정하세요. 일반적으로 1-2초면 충분합니다.

💡 counter 값을 핸들러에서 출력하면 실제로 몇 번의 재귀 호출 후에 스택이 고갈되는지 확인할 수 있어, 스택 크기 조정에 도움이 됩니다.

💡 릴리스 빌드에서는 컴파일러가 꼬리 재귀 최적화를 시도할 수 있습니다. opt-level을 조정하거나 더 복잡한 재귀 패턴을 사용하여 이를 방지하세요.


5. 예외 에러 코드 이해하기 - Double Fault의 원인 파악

시작하며

여러분이 Double Fault 핸들러를 구현하고 나서 실제로 예외가 발생했는데, 정확히 어떤 상황에서 발생했는지 알 수 없는 경험을 해보셨나요? 핸들러는 호출되었지만, 첫 번째 예외가 무엇이었는지, 왜 처리에 실패했는지 전혀 알 수 없는 그 답답한 상황 말입니다.

이런 문제는 Double Fault가 발생한 원인을 분석하는 메커니즘을 구현하지 않았기 때문에 발생합니다. Double Fault는 단순히 "예외 처리 중 또 다른 예외가 발생했다"는 신호일 뿐이고, 구체적으로 어떤 예외들이 조합되어 Double Fault를 일으켰는지는 별도로 조사해야 합니다.

또한 error_code 파라미터에 담긴 정보를 해석하는 방법을 알아야 합니다. 바로 이럴 때 필요한 것이 에러 코드 분석과 예외 조합 이해입니다.

x86_64 아키텍처는 특정 예외 조합이 Double Fault를 유발하는 규칙을 정의하고 있으며, 이를 정확히 이해해야 효과적으로 디버깅할 수 있습니다.

개요

간단히 말해서, Double Fault의 에러 코드는 첫 번째 예외를 처리하던 중 발생한 두 번째 예외에 대한 정보를 담고 있지만, 대부분의 경우 0입니다. 예외 조합을 이해하는 것이 원인 파악의 핵심입니다.

실무에서 에러 코드 해석이 중요한 이유는 동일한 증상(Double Fault)이 다양한 원인으로 발생할 수 있기 때문입니다. 예를 들어, Divide Error 중 Page Fault 발생, Page Fault 중 또 다른 Page Fault 발생, Invalid TSS 중 General Protection Fault 발생 등 다양한 시나리오가 있습니다.

각 상황마다 디버깅 방법이 다르므로 정확한 원인을 파악하는 것이 중요합니다. 전통적인 방법으로는 CPU 매뉴얼의 예외 조합 테이블을 일일이 참조했다면, 현대적인 방법은 CR2 레지스터(Page Fault 주소), IDT 엔트리 상태, 스택 프레임 분석 등을 종합하여 자동으로 원인을 추론하는 디버깅 루틴을 구현합니다.

에러 코드 분석의 핵심 특징은 다음과 같습니다. 첫째, Double Fault의 에러 코드는 대부분 0이며, 이는 "예외 처리에 실패했다"는 일반적인 정보만 제공합니다.

둘째, InterruptStackFrame에는 예외 발생 시점의 RIP, CS, RFLAGS, RSP, SS가 담겨 있어 컨텍스트를 파악할 수 있습니다. 셋째, CR2 레지스터를 읽으면 마지막 Page Fault의 가상 주소를 알 수 있습니다.

이러한 정보들을 조합하면 Double Fault의 근본 원인을 추론할 수 있습니다.

코드 예제

use x86_64::registers::control::Cr2;

extern "x86-interrupt" fn double_fault_handler(
    stack_frame: InterruptStackFrame,
    error_code: u64,
) -> ! {
    // Page Fault 주소 읽기
    let cr2 = Cr2::read();

    println!("EXCEPTION: DOUBLE FAULT");
    println!("Error Code: {:#x}", error_code);
    println!("CR2 (Page Fault Address): {:?}", cr2);
    println!("\nStack Frame:");
    println!("  Instruction Pointer: {:#x}", stack_frame.instruction_pointer.as_u64());
    println!("  Code Segment: {:#x}", stack_frame.code_segment);
    println!("  CPU Flags: {:#x}", stack_frame.cpu_flags);
    println!("  Stack Pointer: {:#x}", stack_frame.stack_pointer.as_u64());
    println!("  Stack Segment: {:#x}", stack_frame.stack_segment);

    // 스택 포인터로 원인 추론
    if stack_frame.stack_pointer.as_u64() < 0x4000 {
        println!("\nLikely cause: Stack overflow (RSP too low)");
    }

    hlt_loop();
}

설명

이것이 하는 일은 Double Fault 발생 시점의 모든 유용한 정보를 수집하여 출력하고, 그 정보를 바탕으로 가능한 원인을 추론하는 것입니다. 이는 디버깅 과정에서 가장 중요한 단서를 제공합니다.

첫 번째로, Cr2::read()로 CR2 컨트롤 레지스터를 읽습니다. CR2는 마지막으로 발생한 Page Fault의 가상 주소를 담고 있는 특수 레지스터입니다.

Double Fault가 Page Fault 관련 문제로 발생했다면 이 값이 결정적인 단서가 됩니다. 예를 들어, CR2가 스택 영역의 주소를 가리키고 있다면 스택 오버플로우를 의심할 수 있고, 코드 영역이라면 잘못된 함수 포인터 호출을 의심할 수 있습니다.

그 다음으로, stack_frame의 각 필드를 출력합니다. instruction_pointer는 예외가 발생한 정확한 코드 위치를 보여주므로, 이를 objdump나 addr2line으로 심볼 정보와 매칭하면 어떤 함수에서 문제가 발생했는지 알 수 있습니다.

cpu_flags는 인터럽트 활성화 여부, 방향 플래그, 오버플로우 플래그 등을 담고 있어 실행 컨텍스트를 이해하는 데 도움이 됩니다. stack_pointer는 현재 스택 위치를 보여주며, 이 값이 비정상적으로 작거나 크다면 스택 손상을 나타냅니다.

마지막으로, 간단한 휴리스틱으로 원인을 추론합니다. stack_pointer가 0x4000(16KB) 미만이라면 스택이 거의 바닥에 도달했다는 의미이므로 스택 오버플로우일 가능성이 높습니다.

더 정교한 분석을 위해서는 스택 메모리 영역의 경계를 알고 있어야 하며, 스택 포인터가 이 경계를 넘어섰는지 확인할 수 있습니다. 또한 instruction_pointer가 유효하지 않은 메모리 주소를 가리킨다면 잘못된 함수 호출이나 버퍼 오버플로우로 인한 리턴 주소 변조를 의심할 수 있습니다.

여러분이 이 코드를 사용하면 Double Fault 발생 시 시리얼 콘솔이나 화면에 상세한 디버깅 정보가 출력되어, 코어 덤프 분석 없이도 대부분의 문제를 진단할 수 있습니다. 실무에서는 이 정보를 로그 파일에 저장하거나 네트워크를 통해 중앙 로깅 서버로 전송하여 프로덕션 환경에서 발생한 크래시를 분석합니다.

또한 스택 메모리를 덤프하여 함수 호출 체인을 역추적하거나, 페이지 테이블을 검사하여 메모리 매핑 상태를 확인하는 등의 고급 디버깅 기법도 적용할 수 있습니다.

실전 팁

💡 스택 트레이스를 구현하려면 RBP 레지스터를 따라가며 각 스택 프레임의 리턴 주소를 추출하세요. 이는 함수 호출 체인을 보여줍니다.

💡 CR3 레지스터를 읽으면 현재 페이지 테이블의 물리 주소를 알 수 있어, 가상-물리 주소 변환 문제를 디버깅할 때 유용합니다.

💡 프로덕션 환경에서는 Double Fault 시 메모리 덤프를 플래시 메모리나 디스크에 저장하고, 다음 부팅 시 이를 분석하여 크래시 리포트를 생성하세요.

💡 QEMU의 -d int 옵션과 함께 사용하면 모든 인터럽트와 예외의 상세한 로그를 파일로 저장할 수 있어, Double Fault 직전의 이벤트를 추적할 수 있습니다.

💡 여러 종류의 예외 핸들러에서 공통 디버깅 정보를 출력하도록 헬퍼 함수를 만들면 코드 중복을 줄이고 일관된 디버깅 경험을 제공할 수 있습니다.


6. 페이지 가드를 활용한 스택 보호 - 오버플로우 조기 감지

시작하며

여러분이 Double Fault 핸들러를 완벽히 구현했지만, 스택 오버플로우가 발생한 후에야 감지되는 상황에 불만을 느껴본 적 있나요? 이미 스택이 손상된 후에 알게 되면 데이터 복구가 불가능하고, 어떤 데이터가 덮어써졌는지조차 알 수 없는 그 문제 말입니다.

이런 문제는 스택 오버플로우를 사후에 감지하기 때문에 발생합니다. 스택이 인접한 메모리 영역을 침범한 후에야 Double Fault가 발생하므로, 그 사이에 중요한 커널 데이터나 다른 태스크의 스택이 손상될 수 있습니다.

멀티태스킹 시스템에서는 한 태스크의 스택 오버플로우가 다른 태스크를 망가뜨릴 수 있어 치명적입니다. 바로 이럴 때 필요한 것이 페이지 가드(Guard Page)입니다.

스택의 끝 바로 다음에 접근 불가능한 페이지를 배치하여, 스택이 경계를 넘으려는 첫 시도에서 즉시 Page Fault를 발생시킵니다.

개요

간단히 말해서, 페이지 가드는 스택 메모리 영역의 양 끝에 배치된 접근 불가능한 페이지로, 스택 오버플로우나 언더플로우가 발생하자마자 감지하는 안전 장치입니다. 실무에서 페이지 가드가 중요한 이유는 조기 감지가 피해를 최소화하기 때문입니다.

예를 들어, 8KB 스택을 사용하는 태스크가 9KB를 사용하려 하면, 가드 페이지가 없다면 인접한 메모리를 조용히 덮어쓰지만, 가드 페이지가 있다면 첫 바이트를 쓰려는 순간 Page Fault가 발생합니다. 이를 통해 메모리 손상을 완전히 방지할 수 있습니다.

전통적인 방법으로는 스택 크기를 매우 크게 할당하여 오버플로우 가능성을 줄였다면, 현대적인 방법은 필요한 만큼만 할당하고 가드 페이지로 경계를 보호합니다. 이는 메모리 효율성과 안전성을 동시에 달성합니다.

페이지 가드의 핵심 특징은 다음과 같습니다. 첫째, 가드 페이지는 페이지 테이블에서 Present 비트가 0으로 설정되어 접근 시 Page Fault를 유발합니다.

둘째, 가상 메모리를 사용하므로 실제 물리 메모리는 소비하지 않습니다. 셋째, Page Fault 핸들러에서 접근 주소가 가드 페이지인지 확인하여 스택 오버플로우를 정확히 식별할 수 있습니다.

이러한 메커니즘이 최소한의 오버헤드로 강력한 보호를 제공합니다.

코드 예제

use x86_64::structures::paging::{Page, PageTableFlags, Mapper, Size4KiB};
use x86_64::VirtAddr;

pub fn allocate_stack_with_guard(
    mapper: &mut impl Mapper<Size4KiB>,
    frame_allocator: &mut impl FrameAllocator<Size4KiB>,
    size_in_pages: usize,
) -> Result<Range<VirtAddr>, MapToError<Size4KiB>> {
    let guard_page_start = /* 스택 할당 위치 */;
    let stack_start = guard_page_start + 4096; // 가드 페이지 다음
    let stack_end = stack_start + (size_in_pages * 4096);

    // 가드 페이지는 매핑하지 않음 (접근 시 Page Fault 발생)
    // 실제 스택 페이지들만 매핑
    for page_idx in 0..size_in_pages {
        let page = Page::containing_address(stack_start + (page_idx * 4096));
        let frame = frame_allocator.allocate_frame()
            .ok_or(MapToError::FrameAllocationFailed)?;
        let flags = PageTableFlags::PRESENT | PageTableFlags::WRITABLE;
        unsafe {
            mapper.map_to(page, frame, flags, frame_allocator)?.flush();
        }
    }

    Ok(stack_start..stack_end)
}

설명

이것이 하는 일은 스택 메모리를 할당할 때 실제 사용 가능한 페이지들 주변에 매핑되지 않은 페이지를 만들어, 스택이 경계를 넘으려는 순간 하드웨어 레벨에서 자동으로 예외를 발생시키는 것입니다. 첫 번째로, guard_page_start 주소를 계산합니다.

이는 스택이 할당될 가상 메모리 영역의 시작점이며, 이 페이지는 의도적으로 매핑하지 않습니다. stack_start는 가드 페이지 바로 다음 페이지로, 실제 스택으로 사용될 첫 번째 페이지입니다.

스택은 높은 주소에서 낮은 주소로 자라므로, 스택이 가득 차서 guard_page_start에 도달하려 하면 Page Fault가 발생합니다. 그 다음으로, for 루프에서 실제 스택 페이지들만 물리 프레임에 매핑합니다.

Page::containing_address()는 가상 주소를 포함하는 페이지 구조체를 생성하고, frame_allocator.allocate_frame()은 물리 메모리에서 하나의 프레임을 할당합니다. PageTableFlags::PRESENT는 페이지가 물리 메모리에 존재함을 나타내고, WRITABLE은 쓰기 가능함을 의미합니다.

가드 페이지는 이 플래그들 없이 남겨두어 접근 불가능하게 만듭니다. 마지막으로, mapper.map_to()로 페이지 테이블 엔트리를 설정하고 flush()로 TLB(Translation Lookaside Buffer)를 갱신합니다.

TLB는 가상-물리 주소 변환을 캐시하는 하드웨어이므로, 페이지 테이블을 변경한 후에는 반드시 무효화해야 새로운 매핑이 적용됩니다. 함수는 스택으로 사용 가능한 가상 주소 범위를 반환하며, 이 범위의 끝 주소를 스택 포인터 초기값으로 사용합니다.

여러분이 이 코드를 사용하면 각 커널 스레드나 유저 프로세스에 스택을 할당할 때 자동으로 가드 페이지가 포함되어, 스택 오버플로우가 절대 인접 메모리를 손상시킬 수 없습니다. 실무에서는 Page Fault 핸들러에서 CR2 레지스터를 확인하여 접근 주소가 가드 페이지 범위에 있는지 검사하고, 맞다면 "Stack Overflow in Task X"와 같은 명확한 에러 메시지를 출력할 수 있습니다.

또한 스택 사용량을 모니터링하는 시스템을 구축하여, 가드 페이지에 도달하기 전에 스택이 거의 찼다는 경고를 미리 받을 수도 있습니다. 멀티코어 시스템에서는 각 코어가 실행하는 모든 태스크의 스택에 개별적인 가드 페이지를 할당하여 완전한 격리를 보장합니다.

실전 팁

💡 양방향 가드 페이지를 구현하세요. 스택의 위와 아래 모두에 가드 페이지를 배치하면 오버플로우와 언더플로우(잘못된 스택 포인터 조작)를 모두 감지할 수 있습니다.

💡 디버그 빌드에서는 스택을 특정 패턴(예: 0xDEADBEEF)으로 초기화하면 사용되지 않은 스택 공간을 쉽게 식별할 수 있어 최대 스택 사용량을 측정할 수 있습니다.

💡 가드 페이지 크기는 최소 하나의 페이지(4KB)면 충분하지만, 대량의 스택 변수를 한 번에 할당하는 코드가 있다면 여러 페이지를 가드로 사용하는 것도 고려하세요.

💡 Page Fault 핸들러도 별도의 IST 스택을 사용하도록 설정하세요. 그렇지 않으면 스택 오버플로우로 인한 Page Fault를 처리하려다 또 다른 Page Fault가 발생할 수 있습니다.

💡 가상 메모리 레이아웃을 설계할 때 스택 영역들 사이에 충분한 간격을 두어, 한 스택이 다른 스택의 가드 페이지를 우회하지 못하도록 하세요. 보통 각 스택 사이에 여러 페이지의 언매핑된 공간을 둡니다.


7. x86-interrupt 호출 규약 이해하기 - 안전한 예외 핸들러 작성

시작하며

여러분이 예외 핸들러 함수를 일반 함수처럼 작성했다가 예측 불가능한 크래시를 경험해본 적 있나요? 함수 내용은 단순한데 호출되자마자 레지스터 값이 망가지거나 스택이 손상되는 그 이상한 버그 말입니다.

이런 문제는 예외 핸들러가 일반 함수 호출과는 완전히 다른 방식으로 호출되기 때문에 발생합니다. CPU가 예외를 발생시킬 때는 특별한 메커니즘으로 스택에 정보를 푸시하고, 핸들러가 반환할 때는 iretq 명령을 사용해야 합니다.

일반 함수 호출 규약을 따르는 코드는 이 과정에서 스택 정렬이 깨지거나 레지스터가 올바르게 보존되지 않아 실패합니다. 바로 이럴 때 필요한 것이 x86-interrupt 호출 규약입니다.

이는 Rust 컴파일러가 예외 핸들러를 위해 특별히 제공하는 ABI(Application Binary Interface)로, 하드웨어의 예외 처리 메커니즘과 정확히 일치하도록 코드를 생성합니다.

개요

간단히 말해서, x86-interrupt는 예외 핸들러 함수가 CPU의 인터럽트/예외 호출 규약을 정확히 따르도록 컴파일러에게 지시하는 특수한 함수 속성입니다. 실무에서 이 호출 규약이 중요한 이유는 예외 처리의 정확성과 안전성이 직접적으로 달려있기 때문입니다.

예를 들어, 일반 함수는 ret 명령으로 반환하지만 예외 핸들러는 iretq를 사용해야 하며, 일반 함수는 일부 레지스터만 보존하지만 예외 핸들러는 모든 레지스터를 보존해야 합니다. 이러한 차이를 수동으로 처리하면 인라인 어셈블리를 대량으로 작성해야 하고 실수하기 쉽습니다.

전통적인 C 기반 OS 개발에서는 어셈블리 래퍼를 작성하여 레지스터를 수동으로 저장하고 복원했다면, Rust의 x86-interrupt는 컴파일러가 이를 자동으로 처리해줍니다. 이는 코드의 안전성과 가독성을 크게 향상시킵니다.

x86-interrupt 호출 규약의 핵심 특징은 다음과 같습니다. 첫째, 모든 범용 레지스터를 스택에 저장하고 복원합니다.

둘째, 스택 정렬을 16바이트 경계에 맞춥니다(x86_64 ABI 요구사항). 셋째, iretq 명령으로 반환하여 CPU가 푸시한 인터럽트 프레임을 올바르게 처리합니다.

이러한 자동화가 저수준 세부사항을 추상화하면서도 정확성을 보장합니다.

코드 예제

// x86-interrupt 호출 규약을 사용하는 예외 핸들러
extern "x86-interrupt" fn page_fault_handler(
    stack_frame: InterruptStackFrame,
    error_code: PageFaultErrorCode,
) {
    use x86_64::registers::control::Cr2;

    println!("EXCEPTION: PAGE FAULT");
    println!("Accessed Address: {:?}", Cr2::read());
    println!("Error Code: {:?}", error_code);
    println!("{:#?}", stack_frame);

    hlt_loop();
}

// 컴파일러가 생성하는 코드 (개념적 표현):
// 1. 모든 레지스터를 스택에 푸시
// 2. 스택 포인터를 16바이트 정렬
// 3. 실제 핸들러 본문 실행
// 4. 레지스터를 스택에서 팝
// 5. iretq 명령으로 반환 (ret 아님!)

설명

이것이 하는 일은 Rust 함수를 CPU의 하드웨어 예외 처리 메커니즘과 완벽히 호환되는 기계어 코드로 변환하는 것입니다. 개발자는 일반 Rust 코드를 작성하지만, 컴파일러는 특별한 프롤로그와 에필로그를 자동으로 삽입합니다.

첫 번째로, extern "x86-interrupt"를 함수 시그니처에 붙이면 컴파일러는 이 함수가 예외 핸들러임을 인식합니다. 파라미터는 반드시 InterruptStackFrame이 첫 번째이고, 선택적으로 u64 타입의 에러 코드가 두 번째입니다.

컴파일러는 이 파라미터들이 스택의 특정 위치에서 전달된다는 것을 알고, 하드웨어가 푸시한 스택 프레임을 올바르게 읽어옵니다. 그 다음으로, 함수 본문이 실행되기 전에 컴파일러가 생성한 프롤로그 코드가 실행됩니다.

이 코드는 RAX, RBX, RCX, RDX, RSI, RDI, R8-R15 등 모든 범용 레지스터를 스택에 푸시합니다. 또한 SSE/AVX 레지스터도 필요시 저장합니다.

그런 다음 스택 포인터를 16바이트 경계로 정렬하는데, 이는 x86_64 System V ABI가 요구하는 사항입니다. 정렬이 맞지 않으면 SIMD 명령이나 일부 라이브러리 함수가 오작동할 수 있습니다.

마지막으로, 함수 본문이 종료되면 에필로그 코드가 실행됩니다. 모든 레지스터를 스택에서 팝하여 원래 값으로 복원하고, iretq 명령을 실행합니다.

iretq는 인터럽트에서 반환하는 특수 명령으로, 스택에서 RIP, CS, RFLAGS, RSP, SS를 차례로 팝하여 CPU 상태를 예외 발생 직전으로 완전히 복원합니다. 일반 ret 명령은 RIP만 팝하므로 스택이 망가지게 됩니다.

여러분이 이 호출 규약을 사용하면 복잡한 어셈블리 코드 없이 순수 Rust로 모든 예외 핸들러를 작성할 수 있습니다. 실무에서는 Page Fault, General Protection Fault, Invalid Opcode 등 다양한 예외마다 별도의 핸들러를 작성하며, 각각 extern "x86-interrupt"를 사용합니다.

또한 인터럽트 핸들러(타이머, 키보드 등)도 동일한 호출 규약을 사용하여 일관된 방식으로 처리할 수 있습니다. 컴파일러가 생성한 어셈블리 코드를 objdump로 확인하면 정확히 어떤 명령들이 삽입되는지 학습할 수 있어, 성능 최적화나 고급 디버깅에 도움이 됩니다.

실전 팁

💡 x86-interrupt 함수는 절대 인라인되지 않습니다. 인라인되면 호출 규약이 의미가 없어지므로 컴파일러가 자동으로 방지합니다.

💡 예외 핸들러에서 panic!을 호출하면 또 다른 예외가 발생할 수 있으므로, 크리티컬한 핸들러에서는 최소한의 코드만 실행하세요.

💡 에러 코드가 없는 예외(Divide Error, Debug 등)의 핸들러는 파라미터를 하나만 받아야 합니다. 에러 코드가 있는지는 Intel 매뉴얼을 참조하세요.

💡 인터럽트 핸들러 내부에서 부동소수점 연산을 사용하면 XMM 레지스터 저장/복원 오버헤드가 추가됩니다. 가능하면 정수 연산만 사용하세요.

💡 extern "x86-interrupt"는 nightly Rust의 abi_x86_interrupt 피처 게이트를 필요로 합니다. Cargo.toml에 명시하세요.


8. IDT 초기화 시점 관리 - 부팅 과정의 핵심 순서

시작하며

여러분이 IDT, TSS, GDT를 모두 완벽하게 구성했는데도 부팅 중에 Triple Fault가 발생하는 상황을 겪어본 적 있나요? 각 컴포넌트는 개별적으로 완벽한데, 조합하면 실패하는 그 이상한 타이밍 문제 말입니다.

이런 문제는 초기화 순서가 잘못되었기 때문에 발생합니다. IDT를 로드하기 전에 TSS가 설정되어야 하고, TSS를 로드하기 전에 GDT가 준비되어야 하며, 모든 것이 준비되기 전에 인터럽트가 활성화되면 안 됩니다.

한 단계라도 순서가 바뀌거나 누락되면 CPU가 잘못된 데이터를 참조하여 시스템이 불안정해집니다. 바로 이럴 때 필요한 것이 체계적인 초기화 순서 관리입니다.

부팅 과정의 각 단계를 명확히 정의하고, 의존성을 고려하여 올바른 순서로 초기화해야 안정적인 OS를 만들 수 있습니다.

개요

간단히 말해서, IDT 초기화 시점 관리는 GDT, TSS, IDT를 올바른 순서로 설정하고, 인터럽트를 활성화하기 전에 모든 준비를 완료하는 체계적인 부팅 절차입니다. 실무에서 초기화 순서가 중요한 이유는 CPU가 각 단계에서 이전 단계의 결과를 참조하기 때문입니다.

예를 들어, IDT를 로드한 후 예외가 발생하면 CPU는 IST를 사용하려 하고, IST는 TSS에 정의되어 있으며, TSS는 GDT를 통해 참조됩니다. 순서가 잘못되면 이 참조 체인의 어딘가가 끊어져서 정의되지 않은 동작이 발생합니다.

전통적인 부팅 과정에서는 어셈블리 코드로 각 단계를 수동으로 제어했다면, 현대적인 Rust OS 개발에서는 타입 시스템과 초기화 함수를 활용하여 컴파일 타임에 일부 오류를 잡을 수 있습니다. lazy_static!을 사용하면 첫 번째 접근 시 자동으로 초기화되지만, 명시적인 초기화 함수를 만들어 순서를 제어하는 것이 더 안전합니다.

초기화 순서 관리의 핵심 특징은 다음과 같습니다. 첫째, 각 단계는 명확한 전제 조건과 사후 조건을 가집니다.

둘째, 초기화 함수는 멱등성을 가져야 하며 중복 호출에 안전해야 합니다. 셋째, 실패 시 명확한 에러 메시지를 제공하여 디버깅을 용이하게 해야 합니다.

이러한 원칙들이 복잡한 부팅 과정을 관리 가능하게 만듭니다.

코드 예제

// 단계별 초기화를 보장하는 구조
pub fn init() {
    // 1. GDT와 TSS 먼저 로드 (IDT가 IST를 참조하기 전에)
    gdt::init();

    // 2. IDT 로드 (예외 핸들러 등록)
    idt::init();

    // 3. PIC(Programmable Interrupt Controller) 초기화
    unsafe { interrupts::PICS.lock().initialize() };

    // 4. 모든 준비가 완료된 후에만 인터럽트 활성화
    x86_64::instructions::interrupts::enable();
}

mod gdt {
    pub fn init() {
        use x86_64::instructions::segmentation::Segment;
        use x86_64::instructions::tables::load_tss;

        GDT.0.load();
        unsafe {
            CS::set_reg(GDT.1.code_selector);
            load_tss(GDT.1.tss_selector);
        }
    }
}

mod idt {
    pub fn init() {
        IDT.load();
    }
}

설명

이것이 하는 일은 OS 부팅 시 CPU의 예외 처리와 인터럽트 메커니즘을 올바른 순서로 설정하여, 초기화 과정 중에 발생할 수 있는 예외도 안전하게 처리할 수 있도록 만드는 것입니다. 첫 번째로, gdt::init()을 가장 먼저 호출합니다.

이는 GDT를 GDTR 레지스터에 로드하고, 코드 세그먼트와 TSS를 설정합니다. 이 시점부터 CPU는 새로운 세그먼트 디스크립터를 사용하기 시작하며, Task Register는 TSS를 가리킵니다.

TSS 안에는 이미 interrupt_stack_table[0]에 Double Fault용 스택 주소가 설정되어 있어야 합니다. 이 단계가 완료되어야 다음 단계에서 IST를 안전하게 사용할 수 있습니다.

그 다음으로, idt::init()을 호출하여 IDT를 IDTR 레지스터에 로드합니다. 이제 CPU는 예외나 인터럽트가 발생하면 IDT를 참조하여 적절한 핸들러를 찾습니다.

Double Fault 핸들러는 IST 인덱스 0을 사용하도록 설정되어 있으므로, 예외 발생 시 CPU가 TSS의 interrupt_stack_table[0]을 자동으로 읽어서 스택을 전환합니다. 이 참조 체인이 GDT → TSS → IST 순서로 연결되어 있으므로 GDT가 먼저 설정되어야 했던 것입니다.

마지막으로, PIC를 초기화하고 인터럽트를 활성화합니다. PIC는 외부 하드웨어 인터럽트(타이머, 키보드 등)를 관리하는 칩이며, 올바르게 초기화하지 않으면 잘못된 인터럽트 벡터로 신호를 보낼 수 있습니다.

PICS.lock().initialize()는 마스터와 슬레이브 PIC를 재매핑하여 IRQ 0-15를 IDT 엔트리 32-47로 연결합니다. 모든 준비가 완료된 후 interrupts::enable()을 호출하면 CPU의 인터럽트 플래그(IF)가 설정되어 외부 인터럽트가 활성화됩니다.

여러분이 이 코드를 사용하면 OS의 main 함수나 _start 엔트리 포인트 초반에 init()을 한 번만 호출하면 모든 예외와 인터럽트 처리가 자동으로 준비됩니다. 실무에서는 각 초기화 함수가 성공했는지 Result를 반환하도록 만들어, 실패 시 구체적인 에러 메시지를 출력하고 부팅을 중단할 수 있습니다.

또한 멀티코어 시스템에서는 부트스트랩 프로세서(BSP)가 공통 자원(PIC 등)을 초기화한 후, 각 애플리케이션 프로세서(AP)가 자신의 GDT, TSS, IDT를 개별적으로 로드합니다. 초기화 완료 후에는 스택 오버플로우 테스트를 실행하여 전체 설정이 올바른지 검증하는 것이 좋은 관행입니다.

실전 팁

💡 초기화 함수에서 serial_println!을 사용해 각 단계의 완료를 로깅하면 부팅 과정을 추적하기 쉽습니다. "GDT loaded", "TSS loaded" 같은 메시지가 유용합니다.

💡 인터럽트를 활성화하기 전에 타이머 핸들러를 등록하지 않으면 PIT(Programmable Interval Timer)가 인터럽트를 보낼 때 핸들러가 없어서 예외가 발생할 수 있습니다.

💡 QEMU의 -no-reboot 옵션을 사용하면 Triple Fault 시 재부팅 대신 종료되어, 초기화 실패를 더 명확히 알 수 있습니다.

💡 각 초기화 함수를 #[inline(never)]로 표시하면 스택 트레이스에서 어느 단계에서 실패했는지 쉽게 파악할 수 있습니다.

💡 프로덕션 환경에서는 초기화 중 크리티컬 섹션에서 watchdog 타이머를 사용하여, 무한 루프나 데드락 발생 시 자동으로 재부팅되도록 만드세요.


#Rust#DoubleFault#IST#x86_64#ExceptionHandling#시스템프로그래밍

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.