이미지 로딩 중...

Rust로 만드는 나만의 OS 스택 오버플로우 방지 - 슬라이드 1/11
A

AI Generated

2025. 11. 14. · 3 Views

Rust로 만드는 나만의 OS 스택 오버플로우 방지

OS 개발에서 가장 치명적인 버그 중 하나인 스택 오버플로우를 어떻게 감지하고 방지할까요? Rust로 커널을 만들면서 가드 페이지, 더블 폴트, IST를 활용한 안전한 스택 관리 기법을 실무 수준으로 깊이 있게 다룹니다.


목차

  1. 스택 오버플로우의 위험성 - OS 커널에서 가장 치명적인 버그
  2. 더블 폴트 예외 처리 - 스택 오버플로우의 특수한 문제
  3. IST를 통한 안전한 스택 전환 - 더블 폴트를 위한 독립 스택
  4. GDT와 TSS 로드하기 - IST를 활성화하는 필수 단계
  5. IDT에서 IST 인덱스 설정하기 - 더블 폴트와 IST 연결
  6. 통합 테스트 - 스택 오버플로우 방어 검증
  7. 가드 페이지 건너뛰기 문제 - 대용량 스택 할당의 위험
  8. 커널 스택 vs 유저 스택 - 권한 수준에 따른 스택 관리
  9. 스택 오버플로우 복구 전략 - 더블 폴트 이후의 선택
  10. 성능 영향 분석 - 스택 오버플로우 방어의 오버헤드

1. 스택 오버플로우의 위험성 - OS 커널에서 가장 치명적인 버그

시작하며

여러분이 OS 커널을 개발하면서 갑자기 시스템이 완전히 멈춰버리거나, 디버깅조차 불가능한 상황을 겪어본 적 있나요? 재귀 함수를 실수로 잘못 작성했거나, 스택에 너무 큰 배열을 할당했을 때 이런 현상이 발생합니다.

이런 문제는 실제 OS 개발 현장에서 가장 치명적인 버그로 손꼽힙니다. 스택 오버플로우가 발생하면 스택이 다른 메모리 영역을 덮어쓰게 되고, 이는 예측 불가능한 동작과 시스템 크래시를 초래합니다.

특히 커널 모드에서는 복구가 불가능합니다. 바로 이럴 때 필요한 것이 스택 오버플로우 감지와 방어 메커니즘입니다.

Rust와 x86-64 아키텍처의 하드웨어 기능을 활용하면 스택 오버플로우를 조기에 감지하고 안전하게 처리할 수 있습니다.

개요

간단히 말해서, 스택 오버플로우는 프로그램이 할당된 스택 공간을 초과하여 사용할 때 발생하는 치명적인 오류입니다. OS 개발에서 이 문제가 특히 위험한 이유는 커널 스택이 매우 제한적이고(보통 4KB-16KB), 오버플로우 시 다른 커널 데이터 구조를 파괴하기 때문입니다.

예를 들어, 인터럽트 핸들러에서 깊은 재귀 호출이나 큰 지역 변수를 사용하면 즉시 시스템이 다운될 수 있습니다. 기존에는 스택 오버플로우를 감지하기 어려웠고, 발생 후에야 문제를 알 수 있었다면, 이제는 가드 페이지(Guard Page)를 통해 사전에 감지하고 예외를 발생시킬 수 있습니다.

핵심 특징은 다음과 같습니다: 첫째, 가드 페이지를 통한 하드웨어 기반 감지, 둘째, 더블 폴트(Double Fault)를 활용한 안전한 오류 처리, 셋째, IST(Interrupt Stack Table)를 통한 독립적인 예외 처리 스택. 이러한 특징들이 시스템의 안정성과 디버깅 가능성을 크게 향상시킵니다.

코드 예제

// bootloader를 통해 자동으로 가드 페이지가 설정됩니다
// Cargo.toml에 bootloader 설정 추가
// [dependencies]
// bootloader = { version = "0.9", features = ["map-physical-memory"] }

// 스택 오버플로우를 테스트하는 함수
#[allow(unconditional_recursion)]
fn stack_overflow() {
    stack_overflow(); // 무한 재귀로 스택 오버플로우 유발
    // 이 줄은 tail call optimization을 방지합니다
    volatile::Volatile::new(0).read();
}

// 테스트 함수
pub extern "C" fn kernel_main(boot_info: &'static BootInfo) -> ! {
    println!("스택 오버플로우 테스트 시작...");
    stack_overflow(); // 가드 페이지에 접근하여 Page Fault 발생
    loop {}
}

설명

이것이 하는 일: 위 코드는 의도적으로 스택 오버플로우를 발생시켜 가드 페이지가 제대로 작동하는지 테스트합니다. 첫 번째로, bootloader 크레이트의 기능을 활용하여 커널 스택 아래에 자동으로 가드 페이지를 설정합니다.

가드 페이지는 페이지 테이블에서 "present" 비트가 0으로 설정된 특수한 페이지로, 접근 시 즉시 Page Fault가 발생합니다. 이는 x86-64의 페이징 하드웨어가 자동으로 처리하므로 소프트웨어 오버헤드가 전혀 없습니다.

그 다음으로, stack_overflow() 함수가 실행되면서 무한 재귀가 발생합니다. 각 함수 호출마다 반환 주소와 스택 프레임이 스택에 쌓이게 되고, 결국 스택이 가드 페이지 영역에 도달하게 됩니다.

volatile::Volatile::new(0).read() 코드는 컴파일러의 tail call optimization을 방지하여 실제로 스택이 증가하도록 보장합니다. 마지막으로, 스택 포인터가 가드 페이지에 접근하는 순간 CPU는 자동으로 Page Fault 예외를 발생시킵니다.

이 예외가 발생하면 IDT(Interrupt Descriptor Table)에 등록된 Page Fault 핸들러가 호출되어 오류 정보를 출력하고 시스템을 안전하게 중단시킬 수 있습니다. 여러분이 이 코드를 사용하면 스택 오버플로우를 조기에 감지하여 시스템 크래시 대신 명확한 오류 메시지를 받을 수 있습니다.

또한 디버깅이 훨씬 쉬워지며, 잘못된 메모리 영역 파괴를 방지할 수 있습니다.

실전 팁

💡 가드 페이지는 기본적으로 하나의 페이지(4KB)만 보호하므로, 스택에 4KB 이상의 큰 배열을 한 번에 할당하면 가드 페이지를 건너뛸 수 있습니다. 큰 데이터는 항상 힙에 할당하세요.

💡 디버그 빌드에서는 스택 사용량이 릴리스 빌드보다 훨씬 많으므로, 두 가지 빌드 모드에서 모두 테스트해야 합니다.

💡 LLVM의 probe-stack 기능을 활성화하면 큰 스택 할당 시 자동으로 스택을 프로브하여 가드 페이지를 건너뛰는 것을 방지할 수 있습니다.

💡 커널 개발 시에는 각 스레드/태스크마다 독립적인 스택과 가드 페이지를 할당해야 하며, 스택 크기를 너무 작게 설정하지 않도록 주의하세요.


2. 더블 폴트 예외 처리 - 스택 오버플로우의 특수한 문제

시작하며

여러분이 Page Fault 핸들러를 완벽하게 구현했다고 생각했는데, 스택 오버플로우가 발생하자 핸들러조차 실행되지 않고 시스템이 트리플 폴트로 리셋되는 상황을 경험한 적 있나요? 이는 스택 오버플로우의 가장 까다로운 문제입니다.

이런 문제는 예외 핸들러 자체도 스택을 사용하기 때문에 발생합니다. 스택이 이미 오버플로우된 상태에서 Page Fault 핸들러를 호출하려고 하면, CPU가 또 다시 Page Fault를 발생시키고, 이는 더블 폴트로 이어집니다.

더블 폴트마저 제대로 처리하지 못하면 시스템은 복구 불가능한 트리플 폴트 상태가 됩니다. 바로 이럴 때 필요한 것이 더블 폴트 예외 핸들러입니다.

더블 폴트를 잡아서 처리하면 최소한 시스템 리셋 전에 오류 정보를 출력하거나 로깅할 수 있습니다.

개요

간단히 말해서, 더블 폴트는 예외 처리 중에 또 다른 예외가 발생했을 때 CPU가 발생시키는 특수한 예외입니다. 실무에서 이 예외가 중요한 이유는 더블 폴트가 마지막 방어선이기 때문입니다.

더블 폴트를 처리하지 못하면 시스템은 자동으로 트리플 폴트가 되어 리셋되며, 어떤 디버깅 정보도 남지 않습니다. 예를 들어, 프로덕션 서버에서 원인 불명의 재시작이 발생할 때 더블 폴트 핸들러가 있다면 로그를 남길 수 있습니다.

기존에는 더블 폴트를 처리하더라도 같은 오버플로우된 스택을 사용해야 했다면, 이제는 IST(Interrupt Stack Table)를 통해 완전히 독립적인 스택에서 예외를 처리할 수 있습니다. 핵심 특징은 다음과 같습니다: 첫째, 예외 처리 중 발생한 예외를 포착, 둘째, 시스템 리셋 전 마지막 정보 수집 기회, 셋째, IST를 통한 안전한 스택 전환.

이러한 특징들이 디버깅 가능성과 시스템 신뢰성을 크게 높입니다.

코드 예제

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

lazy_static! {
    static ref IDT: InterruptDescriptorTable = {
        let mut idt = InterruptDescriptorTable::new();
        // Page Fault 핸들러 등록
        idt.page_fault.set_handler_fn(page_fault_handler);
        // Double Fault 핸들러 등록 (아직 IST 없음)
        idt.double_fault.set_handler_fn(double_fault_handler);
        idt
    };
}

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

설명

이것이 하는 일: 위 코드는 더블 폴트 예외를 감지하고 처리하는 핸들러를 IDT에 등록합니다. 첫 번째로, lazy_static! 매크로를 사용하여 전역 IDT(Interrupt Descriptor Table)를 생성합니다.

IDT는 CPU가 예외나 인터럽트 발생 시 어떤 핸들러를 호출할지 결정하는 테이블입니다. x86_64 크레이트가 제공하는 타입 안전한 IDT 구조체를 사용하여 컴파일 타임에 오류를 방지할 수 있습니다.

그 다음으로, idt.double_fault.set_handler_fn()을 통해 더블 폴트 핸들러를 등록합니다. 더블 폴트는 벡터 번호 8번에 해당하며, 특별히 에러 코드를 제공합니다(항상 0).

extern "x86-interrupt" 호출 규약은 Rust 컴파일러에게 이것이 인터럽트 핸들러임을 알려주고, 자동으로 적절한 프롤로그/에필로그 코드를 생성합니다. 마지막으로, 핸들러가 호출되면 현재 스택 프레임 정보를 출력하고 패닉을 발생시킵니다.

InterruptStackFrame에는 예외 발생 시점의 명령 포인터, 스택 포인터, RFLAGS 레지스터 값 등이 포함되어 있어 디버깅에 매우 유용합니다. 더블 폴트 핸들러는 절대 반환할 수 없으므로 반환 타입이 ! (never type)입니다.

여러분이 이 코드를 사용하면 스택 오버플로우로 인한 더블 폴트를 최소한 감지하고 정보를 출력할 수 있습니다. 하지만 아직 IST를 설정하지 않았다면, 핸들러 자체도 스택 오버플로우로 실패할 수 있습니다.

다음 섹션에서 이를 해결합니다. 이것이 하는 일: 더블 폴트는 스택 오버플로우 상황에서 특별히 중요한 예외로, 시스템 리셋 전 마지막 기회를 제공합니다.

첫 번째로, CPU가 예외를 발생시킬 때 IDT에서 해당 핸들러의 주소를 찾고, 현재 스택에 스택 프레임을 푸시하려고 시도합니다. 스택이 이미 오버플로우된 상태라면 스택 포인터가 가드 페이지를 가리키고 있으므로, 스택 프레임 푸시 시도 자체가 또 다른 Page Fault를 발생시킵니다.

그 다음으로, CPU는 특정 예외 조합이 발생했을 때 더블 폴트를 발생시킵니다. Page Fault 중 또 다른 Page Fault가 발생하는 것이 대표적인 경우입니다.

CPU는 이제 더블 폴트 핸들러를 호출하려고 하는데, 여전히 같은 오버플로우된 스택을 사용한다면 또 실패하게 됩니다. 마지막으로, 더블 폴트 핸들러마저 실행할 수 없으면 CPU는 트리플 폴트 상태로 들어가고, 대부분의 시스템에서 이는 자동 리셋으로 이어집니다.

이 시점에서는 어떤 소프트웨어 코드도 실행할 수 없으며, 완전히 하드웨어 리셋만이 시스템을 복구할 수 있습니다. 여러분이 더블 폴트를 제대로 처리하면 시스템 크래시의 원인을 파악할 수 있는 귀중한 정보를 얻게 됩니다.

하지만 핸들러가 안전하게 실행되려면 독립적인 스택이 반드시 필요하며, 이것이 바로 IST의 역할입니다.

실전 팁

💡 더블 폴트 핸들러는 절대 반환할 수 없으므로, 핸들러 마지막에 반드시 loop {}hlt_loop()를 호출하여 무한 루프를 만들어야 합니다.

💡 더블 폴트가 발생하면 시스템을 계속 실행하는 것은 위험하므로, 가능한 한 많은 디버깅 정보를 출력한 후 즉시 중단하는 것이 안전합니다.

💡 프로덕션 환경에서는 더블 폴트 정보를 시리얼 포트나 네트워크를 통해 외부 로깅 시스템으로 전송하는 것을 고려하세요.

💡 QEMU에서 테스트할 때는 -d int -no-reboot 옵션을 사용하면 인터럽트 정보를 확인하고 자동 리부트를 방지할 수 있습니다.


3. IST를 통한 안전한 스택 전환 - 더블 폴트를 위한 독립 스택

시작하며

여러분이 더블 폴트 핸들러를 작성했지만, 실제로 스택 오버플로우가 발생하면 핸들러조차 실행되지 않는 문제를 해결해야 할 때가 있습니다. 이는 핸들러가 오버플로우된 스택을 그대로 사용하기 때문입니다.

이런 문제는 x86-64 아키텍처의 근본적인 특성에서 비롯됩니다. 예외 핸들러는 호출된 시점의 스택을 그대로 상속받으므로, 스택이 이미 손상되었다면 핸들러도 실행할 수 없습니다.

이는 디버깅을 거의 불가능하게 만들고, 시스템의 신뢰성을 크게 떨어뜨립니다. 바로 이럴 때 필요한 것이 IST(Interrupt Stack Table)입니다.

IST를 사용하면 특정 예외 핸들러가 완전히 독립적인 스택으로 자동 전환되어, 현재 스택 상태와 무관하게 안전하게 실행될 수 있습니다.

개요

간단히 말해서, IST는 TSS(Task State Segment)의 일부로, 특정 인터럽트나 예외 발생 시 사용할 독립적인 스택 포인터를 7개까지 저장할 수 있는 테이블입니다. 실무에서 이 기능이 필수적인 이유는 치명적인 예외를 안전하게 처리하기 위함입니다.

더블 폴트, NMI(Non-Maskable Interrupt), 머신 체크 예외 같은 치명적 상황에서는 현재 스택을 신뢰할 수 없으므로, 독립적인 스택이 필수입니다. 예를 들어, 커널 개발에서 더블 폴트를 처리하지 못하면 모든 스택 오버플로우가 트리플 폴트로 이어집니다.

기존에는 TSS를 수동으로 설정하고 GDT에 등록하는 복잡한 과정이 필요했다면, 이제는 Rust의 x86_64 크레이트가 타입 안전한 API를 제공하여 실수를 방지할 수 있습니다. 핵심 특징은 다음과 같습니다: 첫째, 하드웨어 수준의 자동 스택 전환으로 소프트웨어 오버헤드 없음, 둘째, 최대 7개의 독립 스택 지원, 셋째, 특정 예외 핸들러에만 선택적으로 적용 가능.

이러한 특징들이 시스템의 강건성과 디버깅 능력을 극대적으로 향상시킵니다.

코드 예제

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();
        // IST 0번 슬롯에 더블 폴트용 스택 설정
        tss.interrupt_stack_table[DOUBLE_FAULT_IST_INDEX as usize] = {
            const STACK_SIZE: usize = 4096 * 5; // 20KB 스택
            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
    };
}

설명

이것이 하는 일: 위 코드는 더블 폴트 핸들러가 사용할 독립적인 스택을 TSS의 IST에 등록합니다. 첫 번째로, TaskStateSegment를 생성하고 IST의 0번 슬롯에 새로운 스택을 할당합니다.

TSS는 x86-64에서 주로 IST와 권한 수준 변경 시 스택 전환에 사용되는 특수한 구조체입니다. lazy_static!을 사용하여 TSS가 프로그램 전체 수명 동안 유효한 정적 메모리에 할당되도록 보장합니다.

그 다음으로, 더블 폴트 전용 스택을 20KB 크기의 정적 배열로 할당합니다. static mut를 사용하여 변경 가능한 전역 배열을 만들고, 이 배열의 끝 주소를 IST에 저장합니다.

x86-64에서 스택은 높은 주소에서 낮은 주소로 자라므로, 스택의 "끝" 주소를 스택 포인터로 사용해야 합니다. 20KB는 더블 폴트 핸들러가 충분한 작업(출력, 로깅 등)을 수행하기에 넉넉한 크기입니다.

마지막으로, CPU가 더블 폴트를 감지하고 IDT에서 핸들러 엔트리를 읽을 때, IST 인덱스가 0이 아니면 자동으로 TSS의 해당 IST 슬롯에서 새로운 스택 포인터를 로드합니다. 이 과정은 완전히 하드웨어에서 처리되므로, 핸들러 코드가 실행되기 전에 이미 안전한 스택으로 전환되어 있습니다.

여러분이 이 코드를 사용하면 스택 오버플로우 상황에서도 더블 폴트 핸들러가 안전하게 실행되어, 시스템 크래시의 원인을 정확히 진단할 수 있습니다. 또한 여러 개의 독립 스택을 설정하여 NMI 등 다른 치명적 예외도 안전하게 처리할 수 있습니다.

실전 팁

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

💡 IST 스택은 반드시 static mut나 Box::leak() 같은 방법으로 프로그램 전체 수명 동안 유효해야 합니다. 지역 변수를 사용하면 핸들러 호출 시 이미 해제된 메모리를 참조하게 됩니다.

💡 디버그 빌드에서는 스택 사용량이 많으므로, IST 스택 크기를 릴리스 빌드보다 크게 설정하는 것이 안전합니다.

💡 멀티코어 시스템에서는 각 CPU 코어마다 독립적인 TSS와 IST 스택이 필요합니다. 공유 스택을 사용하면 레이스 컨디션이 발생할 수 있습니다.

💡 IST를 남용하지 마세요. 모든 인터럽트에 IST를 사용하면 캐시 효율이 떨어지고 메모리 사용량이 증가합니다. 치명적 예외에만 사용하세요.


4. GDT와 TSS 로드하기 - IST를 활성화하는 필수 단계

시작하며

여러분이 TSS를 완벽하게 설정했지만, 실제로 더블 폴트가 발생해도 여전히 트리플 폴트가 발생하는 상황을 겪을 수 있습니다. 이는 TSS를 CPU에 로드하지 않았기 때문입니다.

이런 문제는 x86-64 아키텍처의 복잡한 초기화 과정에서 비롯됩니다. TSS를 생성하는 것만으로는 충분하지 않고, GDT(Global Descriptor Table)에 등록하고, CPU에 GDT를 로드하고, 마지막으로 TSS 세그먼트를 활성화해야 합니다.

이 단계 중 하나라도 빠뜨리면 IST는 작동하지 않습니다. 바로 이럴 때 필요한 것이 GDT 설정과 TSS 로드 과정입니다.

이 단계를 올바르게 완료해야 CPU가 IST를 인식하고 사용할 수 있습니다.

개요

간단히 말해서, GDT는 메모리 세그먼트를 정의하는 테이블이고, TSS는 그 중 하나의 특수한 시스템 세그먼트입니다. 실무에서 이 과정이 중요한 이유는 x86-64가 레거시 호환성을 위해 여전히 세그멘테이션 개념을 유지하고 있기 때문입니다.

TSS는 반드시 GDT의 한 엔트리로 등록되어야 하며, 단순히 메모리에 존재하는 것만으로는 CPU가 인식하지 못합니다. 예를 들어, 커널 초기화 시 GDT 설정을 잘못하면 모든 예외 처리가 실패할 수 있습니다.

기존에는 어셈블리어로 GDT를 수동으로 작성하고 lgdt 명령어를 직접 호출해야 했다면, 이제는 x86_64 크레이트가 안전한 Rust API를 제공하여 실수를 대폭 줄일 수 있습니다. 핵심 특징은 다음과 같습니다: 첫째, GDT는 코드/데이터 세그먼트와 TSS를 포함, 둘째, TSS 디스크립터는 2개의 GDT 엔트리를 차지(16바이트 크기), 셋째, lgdt로 GDT를 로드하고 ltr로 TSS를 활성화.

이러한 단계들이 올바르게 완료되어야 IST가 작동합니다.

코드 예제

use x86_64::structures::gdt::{GlobalDescriptorTable, Descriptor, SegmentSelector};

lazy_static! {
    static ref GDT: (GlobalDescriptorTable, Selectors) = {
        let mut gdt = GlobalDescriptorTable::new();
        // 코드 세그먼트 등록
        let code_selector = gdt.add_entry(Descriptor::kernel_code_segment());
        // TSS 등록 (2개 엔트리 사용)
        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(); // GDT를 CPU에 로드
    unsafe {
        // 코드 세그먼트 설정
        x86_64::instructions::segmentation::CS::set_reg(GDT.1.code_selector);
        // TSS 로드
        x86_64::instructions::tables::load_tss(GDT.1.tss_selector);
    }
}

설명

이것이 하는 일: 위 코드는 GDT를 생성하고, TSS를 등록한 후, CPU에 로드하여 IST를 활성화합니다. 첫 번째로, GlobalDescriptorTable을 생성하고 필수 세그먼트들을 추가합니다.

x86-64 롱 모드에서는 세그멘테이션이 거의 사용되지 않지만, 코드 세그먼트와 TSS는 여전히 필요합니다. add_entry()는 디스크립터를 GDT에 추가하고, 해당 세그먼트를 참조할 수 있는 셀렉터를 반환합니다.

TSS 디스크립터는 특별히 128비트(16바이트) 크기이므로 GDT에서 2개의 연속된 엔트리를 차지합니다. 그 다음으로, GDT.0.load()를 호출하여 GDT의 주소와 크기를 GDTR 레지스터에 로드합니다.

이는 내부적으로 lgdt 명령어를 실행하며, 이후 모든 세그먼트 참조는 이 새로운 GDT를 사용하게 됩니다. GDT 로드는 즉시 효과가 있지만, 세그먼트 레지스터들은 명시적으로 업데이트해야 새로운 값을 사용합니다.

마지막으로, CS(코드 세그먼트) 레지스터를 새로운 코드 셀렉터로 업데이트하고, load_tss()로 TR(Task Register)에 TSS 셀렉터를 로드합니다. TR이 설정되면 CPU는 해당 TSS의 IST를 인식하고, 더블 폴트 발생 시 자동으로 IST 스택으로 전환합니다.

이 과정은 unsafe로 표시되는데, 잘못된 셀렉터를 로드하면 예외가 발생할 수 있기 때문입니다. 여러분이 이 코드를 커널 초기화 시 호출하면 IST가 완전히 활성화되어, 스택 오버플로우가 발생해도 더블 폴트 핸들러가 안전하게 실행됩니다.

이제 시스템은 치명적인 스택 오버플로우에서도 복구 가능하거나 최소한 디버깅 정보를 남길 수 있습니다.

실전 팁

💡 GDT는 반드시 프로그램 전체 수명 동안 유효해야 하므로 lazy_static!이나 static으로 선언하세요. 스택이나 힙에 GDT를 할당하면 메모리가 해제된 후에도 CPU가 참조하여 크래시가 발생합니다.

💡 init() 함수는 커널 초기화의 매우 초기 단계에서, IDT를 로드하기 전에 호출해야 합니다. 순서를 바꾸면 예외 발생 시 잘못된 TSS를 참조할 수 있습니다.

💡 멀티코어 시스템에서는 각 코어가 부팅될 때마다 GDT와 TSS를 로드해야 합니다. GDTR과 TR은 코어별 레지스터이므로 자동으로 공유되지 않습니다.

💡 QEMU에서 info registersinfo gdt 명령어로 GDT와 세그먼트 레지스터가 올바르게 설정되었는지 확인할 수 있습니다.


5. IDT에서 IST 인덱스 설정하기 - 더블 폴트와 IST 연결

시작하며

여러분이 TSS와 GDT를 완벽하게 설정했는데도, 더블 폴트 핸들러가 여전히 오버플로우된 스택을 사용하는 문제를 겪을 수 있습니다. 이는 IDT 엔트리에 IST 인덱스를 설정하지 않았기 때문입니다.

이런 문제는 CPU가 어떤 예외에 IST를 사용할지 자동으로 알 수 없기 때문에 발생합니다. TSS에 IST 스택을 등록하는 것만으로는 충분하지 않고, IDT의 각 예외 엔트리에 "이 예외는 IST의 X번 스택을 사용하라"고 명시적으로 지정해야 합니다.

바로 이럴 때 필요한 것이 IDT 엔트리의 IST 인덱스 설정입니다. 이 한 줄의 코드가 스택 오버플로우 방어의 마지막 퍼즐 조각입니다.

개요

간단히 말해서, IDT의 각 엔트리에는 IST 인덱스 필드가 있으며, 0이 아닌 값을 설정하면 해당 예외 처리 시 IST 스택으로 자동 전환됩니다. 실무에서 이 설정이 중요한 이유는 IST를 선택적으로 사용할 수 있기 때문입니다.

모든 예외에 IST를 사용하면 메모리 낭비와 성능 저하가 발생하므로, 더블 폴트, NMI, 머신 체크 같은 치명적 예외에만 IST를 사용하는 것이 최선입니다. 예를 들어, 일반 타이머 인터럽트는 현재 스택을 사용해도 안전하지만, 더블 폴트는 반드시 독립 스택이 필요합니다.

기존에는 IDT를 어셈블리어로 수동으로 작성하고 비트 단위로 필드를 설정해야 했다면, 이제는 x86_64 크레이트의 빌더 패턴을 사용하여 타입 안전하게 설정할 수 있습니다. 핵심 특징은 다음과 같습니다: 첫째, IST 인덱스는 1-7 범위의 값(0은 IST 사용 안 함), 둘째, 예외별로 독립적으로 설정 가능, 셋째, 컴파일 타임에 유효성 검사.

이러한 특징들이 안전하고 유연한 예외 처리를 가능하게 합니다.

코드 예제

use x86_64::structures::idt::InterruptDescriptorTable;

lazy_static! {
    static ref IDT: InterruptDescriptorTable = {
        let mut idt = InterruptDescriptorTable::new();
        idt.page_fault.set_handler_fn(page_fault_handler);

        // 더블 폴트 핸들러에 IST 인덱스 설정
        unsafe {
            idt.double_fault
                .set_handler_fn(double_fault_handler)
                .set_stack_index(DOUBLE_FAULT_IST_INDEX); // IST 0번 스택 사용
        }

        idt
    };
}

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

설명

이것이 하는 일: 위 코드는 더블 폴트 핸들러의 IDT 엔트리에 IST 인덱스를 설정하여 스택 전환을 활성화합니다. 첫 번째로, set_handler_fn()으로 핸들러 함수를 등록한 후, 빌더 패턴을 사용하여 set_stack_index()를 체이닝합니다.

DOUBLE_FAULT_IST_INDEX는 앞서 정의한 상수 0으로, TSS의 IST[0] 슬롯을 가리킵니다. 이 메서드는 unsafe로 표시되는데, 잘못된 인덱스를 설정하거나 해당 IST 슬롯이 유효한 스택을 가리키지 않으면 시스템 크래시가 발생할 수 있기 때문입니다.

그 다음으로, CPU가 더블 폴트를 감지하면 IDT에서 더블 폴트 엔트리(벡터 8)를 읽고, IST 인덱스 필드가 0이 아님을 확인합니다. 이 경우 CPU는 현재 스택을 사용하지 않고, TR(Task Register)이 가리키는 TSS의 interrupt_stack_table[0]에서 새로운 스택 포인터를 로드합니다.

이 모든 과정은 완전히 하드웨어에서 자동으로 처리됩니다. 마지막으로, 스택이 전환된 후 CPU는 새로운 스택에 예외 스택 프레임(SS, RSP, RFLAGS, CS, RIP)과 에러 코드를 푸시하고, 핸들러 함수를 호출합니다.

핸들러는 이제 20KB의 깨끗한 독립 스택에서 실행되므로, 출력, 로깅, 디버깅 등 모든 작업을 안전하게 수행할 수 있습니다. 여러분이 이 설정을 완료하면 스택 오버플로우 방어 시스템이 완성됩니다.

이제 무한 재귀나 큰 스택 할당이 발생해도, 시스템은 트리플 폴트 대신 더블 폴트를 잡아 명확한 오류 메시지를 출력할 수 있습니다.

실전 팁

💡 IST 인덱스는 1-7 범위여야 하지만, Rust의 타입 시스템이 0-6 범위의 usize를 받으므로 주의하세요. 상수로 정의하여 실수를 방지하세요.

💡 여러 예외가 같은 IST 인덱스를 공유할 수 있지만, 중첩 예외가 발생하면 스택이 꼬일 수 있으므로 치명적 예외는 각각 독립적인 IST 슬롯을 사용하는 것이 안전합니다.

💡 디버깅 시 info idt (QEMU) 명령어로 IDT 엔트리의 IST 필드가 올바르게 설정되었는지 확인할 수 있습니다.

💡 프로덕션 코드에서는 NMI와 머신 체크 예외에도 별도의 IST 스택을 할당하는 것을 강력히 권장합니다. 이들도 현재 스택을 신뢰할 수 없는 상황에서 발생할 수 있습니다.


6. 통합 테스트 - 스택 오버플로우 방어 검증

시작하며

여러분이 모든 설정을 완료했다면, 이제 실제로 스택 오버플로우를 발생시켜 시스템이 올바르게 반응하는지 테스트해야 합니다. 단순히 코드를 작성하는 것만으로는 충분하지 않으며, 실제 동작을 검증하는 것이 중요합니다.

이런 테스트는 실제 개발 현장에서 매우 중요합니다. 설정 단계 중 하나라도 빠뜨리거나 잘못하면 여전히 트리플 폴트가 발생하지만, 겉으로는 모든 것이 정상으로 보일 수 있습니다.

실제 스택 오버플로우가 발생하기 전까지는 문제를 알 수 없으므로, 프로액티브한 테스트가 필수입니다. 바로 이럴 때 필요한 것이 통합 테스트입니다.

의도적으로 스택 오버플로우를 발생시키고, 더블 폴트 핸들러가 호출되는지 확인하는 테스트를 작성하면 시스템의 안정성을 보장할 수 있습니다.

개요

간단히 말해서, 통합 테스트는 전체 시스템이 예상대로 작동하는지 검증하는 end-to-end 테스트입니다. 실무에서 이런 테스트가 중요한 이유는 예외 처리 코드는 일반적인 실행 경로에서 거의 실행되지 않기 때문입니다.

더블 폴트 핸들러는 치명적인 상황에서만 호출되므로, 명시적으로 테스트하지 않으면 프로덕션 환경에서 처음으로 실행되는 상황이 발생할 수 있습니다. 예를 들어, IST 설정을 잊어버렸는데 우연히 스택 오버플로우가 발생하지 않아 몇 달간 문제를 모를 수 있습니다.

기존에는 수동으로 테스트하고 QEMU 출력을 눈으로 확인해야 했다면, 이제는 Rust의 통합 테스트 프레임워크와 custom test framework를 사용하여 자동화된 테스트를 작성할 수 있습니다. 핵심 특징은 다음과 같습니다: 첫째, 실제 무한 재귀로 스택 오버플로우 유발, 둘째, 더블 폴트 핸들러에서 성공 시그널 전송, 셋째, 트리플 폴트 발생 시 테스트 실패.

이러한 테스트들이 스택 오버플로우 방어가 실제로 작동함을 증명합니다.

코드 예제

// tests/stack_overflow.rs
#![no_std]
#![no_main]
#![feature(custom_test_frameworks)]

use core::panic::PanicInfo;
use blog_os::{serial_print, serial_println, exit_qemu, QemuExitCode};

#[no_mangle]
pub extern "C" fn _start() -> ! {
    serial_print!("stack_overflow::stack_overflow...\t");

    blog_os::gdt::init(); // GDT와 TSS 초기화
    blog_os::interrupts::init_idt(); // IDT 초기화

    stack_overflow(); // 스택 오버플로우 유발

    panic!("실행이 계속되면 안 됩니다!");
}

#[allow(unconditional_recursion)]
fn stack_overflow() {
    stack_overflow(); // 각 재귀마다 반환 주소 푸시
    volatile::Volatile::new(0).read(); // tail call 방지
}

#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
    blog_os::test_panic_handler(info)
}

설명

이것이 하는 일: 위 테스트는 스택 오버플로우 방어 시스템 전체가 올바르게 작동하는지 end-to-end로 검증합니다. 첫 번째로, 테스트는 GDT, TSS, IDT를 포함한 모든 예외 처리 인프라를 초기화합니다.

이는 실제 커널이 부팅할 때와 동일한 과정이며, 각 단계가 올바르게 완료되어야 IST가 작동합니다. serial_print!는 QEMU의 시리얼 포트로 출력하여 자동화된 테스트 러너가 결과를 확인할 수 있게 합니다.

그 다음으로, stack_overflow() 함수를 호출하여 의도적으로 무한 재귀를 시작합니다. 각 재귀 호출은 반환 주소를 스택에 푸시하므로, 약 500-1000번의 재귀 후 스택이 가드 페이지에 도달합니다.

volatile::Volatile::new(0).read()는 컴파일러의 tail call optimization을 방지하여, 실제로 스택 프레임이 누적되도록 보장합니다. 마지막으로, 스택이 가드 페이지에 도달하면 Page Fault가 발생하고, Page Fault 핸들러가 스택 프레임을 푸시하려다 또 다른 Page Fault를 발생시켜 더블 폴트로 이어집니다.

IST가 올바르게 설정되었다면 더블 폴트 핸들러가 독립 스택에서 실행되어 QemuExitCode::Success를 전송하고, 테스트 러너는 이를 감지하여 테스트 성공으로 간주합니다. 만약 IST 설정이 잘못되었다면 트리플 폴트가 발생하여 QEMU가 리셋되고, 테스트는 타임아웃으로 실패합니다.

여러분이 이 테스트를 CI/CD 파이프라인에 통합하면, 스택 오버플로우 방어 코드가 리팩토링이나 업데이트 후에도 계속 작동함을 보장할 수 있습니다. 또한 새로운 팀원이 합류했을 때 시스템의 안정성을 증명하는 객관적인 증거가 됩니다.

실전 팁

💡 cargo test --test stack_overflow 명령어로 이 특정 테스트만 실행할 수 있으며, 보통 1-2초 안에 완료됩니다.

💡 테스트가 실패하면 QEMU의 -d int,cpu_reset -no-reboot 옵션을 사용하여 상세한 인터럽트 로그를 확인하세요. 이를 통해 어떤 예외가 발생했는지, IST가 사용되었는지 등을 알 수 있습니다.

💡 더블 폴트 핸들러에서 exit_qemu(QemuExitCode::Success)를 호출하도록 수정하여 테스트가 성공했음을 명시적으로 표시하세요.

💡 다양한 스택 오버플로우 시나리오(큰 지역 변수, 깊은 함수 호출 체인 등)를 테스트하여 가드 페이지 건너뛰기 문제가 없는지 확인하세요.


7. 가드 페이지 건너뛰기 문제 - 대용량 스택 할당의 위험

시작하며

여러분이 스택 오버플로우 방어를 완벽하게 구현했다고 생각했는데, 큰 배열을 스택에 할당하자 가드 페이지를 건너뛰고 시스템이 크래시되는 상황을 경험할 수 있습니다. 이는 가드 페이지의 근본적인 한계입니다.

이런 문제는 가드 페이지가 단 하나의 페이지(4KB)만 보호하기 때문에 발생합니다. 만약 함수가 스택에 4KB 이상의 데이터를 한 번에 할당하면, 스택 포인터가 가드 페이지를 완전히 건너뛰어 그 아래의 메모리를 직접 덮어쓸 수 있습니다.

이는 가드 페이지를 무용지물로 만들고, 예측 불가능한 메모리 손상을 초래합니다. 바로 이럴 때 필요한 것이 스택 프로빙(stack probing)입니다.

큰 스택 할당 시 자동으로 각 페이지를 순차적으로 접근하여 가드 페이지 건너뛰기를 방지하는 기법입니다.

개요

간단히 말해서, 가드 페이지 건너뛰기는 4KB 이상의 큰 스택 할당으로 인해 스택 포인터가 가드 페이지를 넘어 유효한 메모리를 덮어쓰는 현상입니다. 실무에서 이 문제가 위험한 이유는 감지되지 않는 메모리 손상이 발생하기 때문입니다.

가드 페이지를 건너뛰면 Page Fault가 발생하지 않으므로, 시스템은 정상 작동하는 것처럼 보이지만 실제로는 중요한 데이터나 코드가 파괴됩니다. 예를 들어, 인터럽트 핸들러에서 큰 버퍼를 스택에 할당하면 다른 태스크의 스택을 덮어쓸 수 있습니다.

기존에는 이 문제를 방지하기 어려웠고 프로그래머가 수동으로 주의해야 했다면, 이제는 LLVM의 probe-stack 옵션을 활성화하여 컴파일러가 자동으로 스택 프로빙 코드를 삽입할 수 있습니다. 핵심 특징은 다음과 같습니다: 첫째, 큰 스택 할당을 여러 개의 작은 단계로 분할, 둘째, 각 페이지를 순차적으로 접근하여 가드 페이지 보장, 셋째, 약간의 성능 오버헤드 발생하지만 안전성 대폭 향상.

이러한 기법들이 가드 페이지의 효과를 보장합니다.

코드 예제

// .cargo/config.toml
[unstable]
build-std = ["core", "compiler_builtins"]
build-std-features = ["compiler-builtins-mem"]

[build]
target = "x86_64-blog_os.json"

# target JSON 파일에 추가 (x86_64-blog_os.json)
{
    "llvm-target": "x86_64-unknown-none",
    "data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128",
    "arch": "x86_64",
    "target-endian": "little",
    "target-pointer-width": "64",
    "target-c-int-width": "32",
    "os": "none",
    "executables": true,
    "linker-flavor": "ld.lld",
    "linker": "rust-lld",
    "panic-strategy": "abort",
    "disable-redzone": true,
    "features": "-mmx,-sse,+soft-float",
    "stack-probes": { "kind": "inline-or-call", "min-llvm-version-for-inline": [16, 0, 0] }
}

설명

이것이 하는 일: 위 설정은 LLVM에게 큰 스택 할당 시 자동으로 스택 프로빙 코드를 생성하도록 지시합니다. 첫 번째로, target JSON 파일에 stack-probes 옵션을 추가하여 스택 프로빙을 활성화합니다.

"kind": "inline-or-call"은 LLVM에게 프로빙 코드를 인라인으로 생성하거나(작은 할당) 별도의 프로빙 함수를 호출하도록(큰 할당) 지시합니다. 이는 코드 크기와 성능 사이의 균형을 맞춥니다.

그 다음으로, 프로그램이 예를 들어 8KB 배열을 스택에 할당하려고 하면, 컴파일러는 자동으로 다음과 같은 코드를 생성합니다: 먼저 스택 포인터를 4KB 감소시키고 해당 주소를 읽어(프로브) 페이지가 유효한지 확인합니다. 그 다음 다시 4KB 감소시키고 프로브합니다.

이 과정을 반복하여 모든 중간 페이지를 접근하므로, 가드 페이지가 있다면 반드시 감지됩니다. 마지막으로, 프로빙 중 가드 페이지에 접근하면 Page Fault가 발생하고, 이는 다시 더블 폴트로 이어져 안전하게 처리됩니다.

프로빙 오버헤드는 일반적으로 매우 작으며(몇 사이클), 큰 스택 할당이 자주 발생하지 않는 커널에서는 성능에 거의 영향을 주지 않습니다. 여러분이 이 옵션을 활성화하면 어떤 크기의 스택 할당도 안전하게 감지할 수 있습니다.

이는 특히 외부 라이브러리를 사용하거나 복잡한 데이터 구조를 다룰 때 중요하며, 프로그래머가 모든 스택 할당을 수동으로 추적할 필요가 없어집니다.

실전 팁

💡 스택 프로빙은 약간의 성능 오버헤드가 있으므로, 핫 패스(hot path)에서 큰 스택 할당을 피하는 것이 여전히 최선입니다. 가능하면 힙 할당을 사용하세요.

💡 LLVM 버전에 따라 스택 프로빙 지원이 다를 수 있으므로, min-llvm-version-for-inline을 확인하고 적절히 설정하세요.

💡 디스어셈블러로 생성된 코드를 확인하여 프로빙 코드가 실제로 삽입되었는지 검증할 수 있습니다. objdump -d 또는 cargo-asm을 사용하세요.

💡 스택 프로빙은 컴파일 타임 기능이므로, 이미 컴파일된 외부 바이너리나 라이브러리에는 적용되지 않습니다. 모든 코드를 소스에서 빌드하거나 신뢰할 수 있는 라이브러리만 사용하세요.


8. 커널 스택 vs 유저 스택 - 권한 수준에 따른 스택 관리

시작하며

여러분이 유저 모드 프로그램을 실행할 수 있는 OS를 개발하면서, 유저 프로그램이 스택 오버플로우를 일으켰을 때 커널까지 크래시되는 문제를 겪을 수 있습니다. 이는 커널 스택과 유저 스택을 제대로 분리하지 않았기 때문입니다.

이런 문제는 x86-64의 권한 수준(ring) 아키텍처에서 비롯됩니다. CPU가 유저 모드(ring 3)에서 커널 모드(ring 0)로 전환될 때, 스택도 자동으로 전환되어야 합니다.

그렇지 않으면 유저 프로그램이 커널 스택을 조작하여 보안을 우회하거나, 유저 스택의 문제가 커널에 영향을 줄 수 있습니다. 바로 이럴 때 필요한 것이 TSS의 RSP0 필드입니다.

권한 수준 변경 시 CPU가 자동으로 참조하여 커널 스택으로 전환하는 메커니즘입니다.

개요

간단히 말해서, 커널 스택은 커널 모드에서 사용하는 스택이고, 유저 스택은 유저 모드 프로그램이 사용하는 스택으로, 보안과 안정성을 위해 완전히 분리되어야 합니다. 실무에서 이 분리가 중요한 이유는 보안과 격리(isolation) 때문입니다.

유저 프로그램이 커널 스택에 접근하면 커널의 반환 주소나 지역 변수를 조작하여 권한 상승을 시도할 수 있습니다. 또한 유저 스택이 오버플로우되어도 커널은 영향받지 않아야 합니다.

예를 들어, 멀티태스킹 OS에서 각 프로세스는 독립적인 유저 스택을 가지지만, 시스템 콜 발생 시 모두 같은 커널 스택으로 전환됩니다. 기존에는 스택 전환을 수동으로 구현하고 어셈블리 코드로 관리해야 했다면, 이제는 TSS의 권한 수준별 스택 포인터(RSP0, RSP1, RSP2)를 설정하여 CPU가 자동으로 처리하게 할 수 있습니다.

핵심 특징은 다음과 같습니다: 첫째, TSS에 최대 3개의 권한 수준별 스택 포인터 저장, 둘째, 권한 상승 시 CPU가 자동으로 스택 전환, 셋째, 각 CPU 코어와 각 프로세스마다 독립적인 커널 스택. 이러한 특징들이 안전한 멀티태스킹과 시스템 콜 처리를 가능하게 합니다.

코드 예제

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

pub const KERNEL_STACK_SIZE: usize = 4096 * 5; // 20KB

pub struct ProcessContext {
    kernel_stack: [u8; KERNEL_STACK_SIZE],
    // ... 다른 필드들
}

impl ProcessContext {
    pub fn new() -> Self {
        Self {
            kernel_stack: [0; KERNEL_STACK_SIZE],
        }
    }

    pub fn switch_to(&mut self, tss: &mut TaskStateSegment) {
        // 이 프로세스의 커널 스택을 TSS에 설정
        let stack_start = VirtAddr::from_ptr(&self.kernel_stack);
        let stack_end = stack_start + KERNEL_STACK_SIZE;
        tss.privilege_stack_table[0] = stack_end; // RSP0에 커널 스택 설정
    }
}

설명

이것이 하는 일: 위 코드는 각 프로세스마다 독립적인 커널 스택을 할당하고, 컨텍스트 스위칭 시 TSS를 업데이트합니다. 첫 번째로, ProcessContext 구조체는 각 프로세스의 상태를 나타내며, 독립적인 커널 스택 배열을 포함합니다.

20KB 크기는 대부분의 시스템 콜과 인터럽트 핸들러를 처리하기에 충분하지만, 복잡한 커널 작업을 수행한다면 더 크게 설정할 수 있습니다. 이 스택은 유저 모드에서 커널 모드로 전환될 때만 사용됩니다.

그 다음으로, switch_to() 메서드는 프로세스 스케줄러가 새로운 프로세스로 전환할 때 호출됩니다. 이 메서드는 TSS의 privilege_stack_table[0] (RSP0)을 업데이트하여, 다음 권한 전환 시 CPU가 이 프로세스의 커널 스택을 사용하도록 합니다.

privilege_stack_table은 3개의 엔트리를 가지며, 각각 ring 0, 1, 2로의 전환 시 사용할 스택을 지정합니다(현대 OS는 대부분 ring 0과 3만 사용). 마지막으로, 유저 프로그램이 시스템 콜(syscall 명령어)이나 인터럽트를 통해 커널 모드로 진입하면, CPU는 자동으로 TR이 가리키는 TSS의 RSP0 값을 읽어 RSP 레지스터에 로드합니다.

그 후 기존 유저 스택 포인터와 상태를 새로운 커널 스택에 푸시하고, 커널 코드를 실행합니다. 커널 작업이 완료되면 iretqsysretq 명령어가 유저 스택을 복원합니다.

여러분이 이 패턴을 사용하면 각 프로세스가 완전히 격리된 환경에서 실행되며, 한 프로세스의 스택 오버플로우가 다른 프로세스나 커널에 영향을 주지 않습니다. 또한 보안 취약점을 크게 줄일 수 있습니다.

실전 팁

💡 멀티코어 시스템에서는 각 코어가 동시에 서로 다른 프로세스를 실행할 수 있으므로, 코어별로 독립적인 TSS와 현재 프로세스 추적이 필요합니다.

💡 컨텍스트 스위칭 시 TSS 업데이트는 매우 빈번하게 발생할 수 있으므로, 캐시 친화적인 메모리 레이아웃을 설계하세요.

💡 커널 스택은 페이지 경계에 정렬하고 하단에 가드 페이지를 배치하여 커널 스택 오버플로우도 감지할 수 있습니다.

💡 디버깅 시에는 현재 스택 포인터가 커널 스택 범위 내에 있는지 검증하는 어설션을 추가하면 스택 손상을 조기에 발견할 수 있습니다.


9. 스택 오버플로우 복구 전략 - 더블 폴트 이후의 선택

시작하며

여러분이 더블 폴트를 성공적으로 잡았지만, 이제 무엇을 해야 할지 고민하는 상황이 있을 수 있습니다. 단순히 패닉을 일으키고 시스템을 중단하는 것이 최선일까요, 아니면 복구를 시도해야 할까요?

이런 선택은 OS의 설계 철학과 사용 사례에 따라 달라집니다. 임베디드 시스템이나 안전-중요(safety-critical) 시스템에서는 불확실한 상태로 계속 실행하는 것보다 즉시 중단하는 것이 안전합니다.

반면 서버 OS에서는 영향받은 프로세스만 종료하고 시스템은 계속 실행하는 것이 바람직할 수 있습니다. 바로 이럴 때 필요한 것이 명확한 복구 전략입니다.

더블 폴트의 원인을 분석하고, 안전하게 복구할 수 있는지 판단하고, 적절한 조치를 취하는 체계적인 접근이 필요합니다.

개요

간단히 말해서, 더블 폴트 복구 전략은 치명적 예외 발생 후 시스템을 어떻게 처리할지 결정하는 정책과 메커니즘입니다. 실무에서 이 전략이 중요한 이유는 시스템의 가용성과 안전성 사이의 균형을 맞추기 위함입니다.

단순히 모든 더블 폴트에서 패닉을 일으키면 간단하지만, 일시적인 문제로 전체 시스템이 다운되는 것은 바람직하지 않을 수 있습니다. 예를 들어, 웹 서버에서 하나의 요청 처리 중 스택 오버플로우가 발생했다면, 해당 연결만 종료하고 다른 요청은 계속 처리할 수 있어야 합니다.

기존에는 복구가 거의 불가능했고 시스템 재시작이 유일한 방법이었다면, 이제는 프로세스 격리와 예외 처리 메커니즘을 활용하여 선택적 복구가 가능합니다. 핵심 특징은 다음과 같습니다: 첫째, 더블 폴트의 원인 분석(커널 vs 유저), 둘째, 격리된 프로세스만 종료하고 시스템은 유지, 셋째, 복구 불가능한 경우 안전한 시스템 중단.

이러한 전략들이 시스템의 신뢰성과 가용성을 최대화합니다.

코드 예제

extern "x86-interrupt" fn double_fault_handler(
    stack_frame: InterruptStackFrame,
    _error_code: u64
) -> ! {
    // 예외 발생 시점의 권한 수준 확인
    let privilege_level = stack_frame.code_segment.0 & 0x3;

    if privilege_level == 3 {
        // 유저 모드에서 발생 - 프로세스만 종료
        serial_println!("스택 오버플로우가 유저 프로세스에서 발생했습니다.");
        serial_println!("프로세스를 종료합니다: {:#?}", stack_frame);

        // 현재 프로세스 종료하고 스케줄러 호출
        process::terminate_current(ExitCode::StackOverflow);
        scheduler::schedule(); // 다른 프로세스로 전환
    } else {
        // 커널 모드에서 발생 - 복구 불가능
        panic!("치명적: 커널 스택 오버플로우 발생!\n{:#?}", stack_frame);
    }

    unreachable!()
}

설명

이것이 하는 일: 위 코드는 더블 폴트의 발생 위치를 판단하여 적절한 복구 전략을 적용합니다. 첫 번째로, InterruptStackFramecode_segment 필드를 확인하여 예외가 발생한 권한 수준을 판단합니다.

세그먼트 셀렉터의 하위 2비트(RPL, Requested Privilege Level)는 0(커널)이거나 3(유저)입니다. 이는 예외가 커널 코드 실행 중 발생했는지, 유저 프로그램 실행 중 발생했는지 알려줍니다.

그 다음으로, 유저 모드에서 발생한 경우(privilege_level == 3), 현재 프로세스의 상태를 "종료됨"으로 표시하고, 프로세스가 사용하던 모든 리소스를 정리합니다. 프로세스 테이블에서 해당 엔트리를 제거하고, 할당된 메모리를 해제하고, 열린 파일 디스크립터를 닫습니다.

그 후 스케줄러를 호출하여 다음 실행 가능한 프로세스로 전환합니다. 스케줄러는 절대 반환하지 않으므로, 시스템은 정상적으로 계속 실행됩니다.

마지막으로, 커널 모드에서 발생한 경우(privilege_level == 0), 이는 커널 자체에 치명적인 버그가 있음을 의미하므로 복구를 시도하지 않습니다. 커널 스택이 손상되었거나 커널 코드에 무한 재귀가 있다면, 시스템의 무결성을 보장할 수 없으므로 즉시 패닉을 발생시키고 중단합니다.

패닉 핸들러는 가능한 한 많은 디버깅 정보를 출력한 후 시스템을 안전한 상태로 정지시킵니다. 여러분이 이 전략을 구현하면 시스템의 강건성이 크게 향상됩니다.

개별 프로그램의 버그가 전체 시스템을 다운시키지 않으며, 동시에 커널 버그는 즉시 감지되어 데이터 손상을 방지할 수 있습니다.

실전 팁

💡 프로세스 종료 시 부모 프로세스에게 SIGCHLD 시그널을 전송하여 자식 프로세스의 비정상 종료를 알려주는 것을 잊지 마세요.

💡 커널 패닉 시에는 모든 CPU 코어를 중지시키고(IPI 전송), 디스크 캐시를 플러시하여 로그가 손실되지 않도록 하세요.

💡 프로덕션 환경에서는 커널 크래시 덤프를 생성하여 사후 분석을 가능하게 하는 kdump 같은 메커니즘을 구현하세요.

💡 더블 폴트 핸들러에서 복잡한 작업(메모리 할당, 락 획득 등)을 수행하지 마세요. 시스템이 이미 불안정한 상태이므로 최소한의 작업만 수행해야 합니다.


10. 성능 영향 분석 - 스택 오버플로우 방어의 오버헤드

시작하며

여러분이 완벽한 스택 오버플로우 방어 시스템을 구축했지만, 성능에 미치는 영향이 걱정될 수 있습니다. 가드 페이지, IST, 스택 프로빙은 모두 시스템 리소스를 소비하므로, 트레이드오프를 이해하는 것이 중요합니다.

이런 고민은 실제 프로덕션 시스템에서 매우 중요합니다. 보안과 안정성을 위해 너무 많은 성능을 희생하면 시스템이 실용적이지 않게 되고, 반대로 성능을 위해 보안을 소홀히 하면 심각한 버그를 놓칠 수 있습니다.

특히 고성능 서버나 실시간 시스템에서는 이 균형이 매우 중요합니다. 바로 이럴 때 필요한 것이 성능 영향 분석과 최적화 전략입니다.

각 방어 메커니즘의 오버헤드를 측정하고, 필요한 부분에만 적용하여 최적의 균형점을 찾는 것이 중요합니다.

개요

간단히 말해서, 스택 오버플로우 방어의 성능 영향은 대부분 메모리 사용량 증가에서 비롯되며, 실행 시간 오버헤드는 일반적으로 매우 작습니다. 실무에서 이 분석이 중요한 이유는 리소스가 제한된 환경에서 설계 결정을 내리기 위함입니다.

임베디드 시스템에서는 메모리가 귀중하므로 각 스택의 크기를 신중하게 선택해야 하고, 클라우드 서버에서는 수천 개의 프로세스가 동시에 실행되므로 프로세스당 메모리 사용량이 총 비용에 큰 영향을 줍니다. 예를 들어, 10,000개의 프로세스가 각각 20KB의 IST 스택을 가지면 200MB의 추가 메모리가 필요합니다.

기존에는 성능 영향을 추측하거나 벤치마크를 수동으로 작성해야 했다면, 이제는 perf, valgrind, cargo-bench 같은 도구로 정확한 측정이 가능합니다. 핵심 특징은 다음과 같습니다: 첫째, 가드 페이지는 거의 제로 오버헤드(하드웨어 기반), 둘째, IST는 메모리 사용량 증가(프로세스/코어당 수십 KB), 셋째, 스택 프로빙은 큰 할당 시 약간의 CPU 오버헤드.

이러한 특성들을 이해하면 효과적인 최적화가 가능합니다.

코드 예제

// 메모리 오버헤드 계산 예시
pub fn calculate_memory_overhead() {
    const GUARD_PAGE_SIZE: usize = 4096; // 가드 페이지는 실제 메모리를 소비하지 않음
    const IST_STACK_SIZE: usize = 4096 * 5; // 20KB per IST stack
    const NUM_IST_STACKS: usize = 1; // 더블 폴트용 하나
    const KERNEL_STACK_SIZE: usize = 4096 * 5; // 프로세스당 커널 스택

    let overhead_per_process =
        KERNEL_STACK_SIZE + // 커널 스택
        (IST_STACK_SIZE * NUM_IST_STACKS); // IST 스택들

    println!("프로세스당 메모리 오버헤드: {}KB", overhead_per_process / 1024);

    let num_processes = 1000;
    let total_overhead = overhead_per_process * num_processes;
    println!("1000개 프로세스 총 오버헤드: {}MB", total_overhead / (1024 * 1024));
}

// 성능 벤치마크 예시
#[bench]
fn bench_stack_allocation_with_probing(b: &mut Bencher) {
    b.iter(|| {
        // 큰 스택 할당 (프로빙 트리거)
        let large_array: [u8; 8192] = [0; 8192];
        black_box(&large_array); // 최적화 방지
    });
}

설명

이것이 하는 일: 위 코드는 스택 오버플로우 방어 메커니즘의 메모리 오버헤드를 계산하고 성능을 측정합니다. 첫 번째로, calculate_memory_overhead() 함수는 각 방어 메커니즘이 소비하는 메모리를 계산합니다.

가드 페이지는 실제로 메모리를 소비하지 않고 단지 페이지 테이블에서 "present" 비트를 0으로 설정하는 것뿐이므로, 물리 메모리 오버헤드가 없습니다. 반면 IST 스택과 프로세스별 커널 스택은 실제 메모리를 할당하므로, 프로세스 수가 많으면 상당한 메모리가 필요합니다.

그 다음으로, 1000개의 프로세스를 가정하여 총 메모리 오버헤드를 계산합니다. 각 프로세스가 40KB(커널 스택 20KB + IST 스택 20KB)를 사용한다면, 1000개 프로세스는 약 40MB의 추가 메모리가 필요합니다.

이는 최신 서버에서는 무시할 수 있는 수준이지만, 임베디드 시스템에서는 중요한 고려사항입니다. 마지막으로, 벤치마크 함수는 스택 프로빙의 성능 영향을 측정합니다.

8KB 배열을 스택에 할당하면 컴파일러가 자동으로 프로빙 코드를 삽입하므로, 이 할당이 얼마나 오래 걸리는지 측정할 수 있습니다. 일반적으로 프로빙 오버헤드는 수 나노초에서 수십 나노초 수준으로, 전체 함수 실행 시간에 비해 무시할 수 있습니다.

black_box는 컴파일러가 배열을 최적화로 제거하는 것을 방지합니다. 여러분이 이 분석을 수행하면 시스템의 요구사항에 맞게 스택 크기와 방어 메커니즘을 조정할 수 있습니다.

예를 들어, 메모리가 부족한 시스템에서는 IST 스택을 8KB로 줄이거나, 성능이 중요한 시스템에서는 스택 프로빙을 일부 함수에서만 활성화할 수 있습니다.

실전 팁

💡 perf recordperf report를 사용하여 스택 프로빙 함수가 전체 실행 시간의 몇 퍼센트를 차지하는지 확인하세요. 1% 미만이라면 오버헤드를 걱정할 필요가 없습니다.

💡 Copy-on-Write (COW) 기법을 사용하면 프로세스가 실제로 스택을 사용할 때까지 물리 메모리를 할당하지 않아 메모리 오버헤드를 줄일 수 있습니다.

💡 프로파일 기반 최적화(PGO)를 활용하면 컴파일러가 자주 실행되는 함수에서는 스택 프로빙을 인라인하고, 드물게 실행되는 함수에서는 함수 호출로 구현하여 코드 크기와 성능을 모두 최적화할 수 있습니다.

💡 메모리가 매우 제한적인 환경에서는 더블 폴트 핸들러를 매우 간단하게 만들어(예: 시리얼 포트에 한 줄 출력) IST 스택을 4KB로 줄일 수 있습니다.


#Rust#StackOverflow#GuardPage#DoubleFault#IST#시스템프로그래밍

댓글 (0)

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