이미지 로딩 중...

Rust로 만드는 나만의 OS 2 CPU 예외 종류 이해 - 슬라이드 1/11
A

AI Generated

2025. 11. 13. · 6 Views

Rust로 만드는 나만의 OS 2 CPU 예외 종류 이해

CPU 예외 처리는 운영체제 개발의 핵심입니다. 이 가이드에서는 Rust로 OS를 만들 때 반드시 알아야 할 CPU 예외의 종류와 각각의 특징, 그리고 실제 처리 방법을 다룹니다. x86-64 아키텍처의 다양한 예외 상황을 깊이 있게 이해하고 안전하게 처리하는 방법을 배워보세요.


목차

  1. Division Error - 0으로 나누기 예외 처리
  2. Debug Exception - 디버깅과 하드웨어 브레이크포인트
  3. Breakpoint Exception - 소프트웨어 브레이크포인트 구현
  4. Page Fault - 메모리 접근 예외 처리
  5. General Protection Fault - 권한 및 세그먼트 위반
  6. Double Fault - 예외 처리 중 예외 발생
  7. Invalid TSS - 잘못된 태스크 상태 세그먼트
  8. Stack Segment Fault - 스택 관련 세그먼트 오류
  9. Invalid Opcode - 유효하지 않은 명령어
  10. Alignment Check - 메모리 정렬 검사

1. Division Error - 0으로 나누기 예외 처리

시작하며

여러분이 OS 커널을 개발하면서 산술 연산을 수행할 때, 갑자기 시스템이 멈춰버린 경험이 있나요? 특히 사용자 프로그램이나 드라이버에서 0으로 나누는 연산을 시도했을 때 커널이 패닉에 빠지는 상황 말이죠.

이런 문제는 실제 OS 개발에서 가장 먼저 마주치는 예외 중 하나입니다. Division Error(#DE)는 CPU가 0으로 나누기나 오버플로우를 감지했을 때 발생하는 Fault 타입 예외입니다.

이를 제대로 처리하지 않으면 전체 시스템이 다운될 수 있습니다. 바로 이럴 때 필요한 것이 Division Error 핸들러입니다.

적절한 예외 처리를 통해 문제가 발생한 프로세스만 종료하고 시스템은 안전하게 유지할 수 있습니다.

개요

간단히 말해서, Division Error는 CPU가 벡터 번호 0번으로 발생시키는 예외로, 나눗셈 연산에서 문제가 생겼을 때 트리거됩니다. 왜 이 예외를 처리해야 할까요?

OS 커널은 사용자 공간의 악의적이거나 버그가 있는 코드로부터 자신을 보호해야 합니다. 예를 들어, 사용자가 작성한 프로그램에서 잘못된 계산으로 0으로 나누기가 발생했을 때, 이것이 커널까지 영향을 미쳐서는 안 됩니다.

전통적인 애플리케이션 개발에서는 이런 경우 프로그램이 그냥 크래시되지만, OS 개발에서는 예외를 캐치하고 적절히 처리하여 시스템 안정성을 유지해야 합니다. 이 예외의 핵심 특징은 첫째, Fault 타입이라 에러가 발생한 명령어 이전으로 돌아갈 수 있다는 점, 둘째, 에러 코드가 푸시되지 않는다는 점, 셋째, 가장 낮은 벡터 번호(0)를 가진다는 점입니다.

이러한 특징들을 이해하면 정확한 예외 핸들러를 구현할 수 있습니다.

코드 예제

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

lazy_static! {
    static ref IDT: InterruptDescriptorTable = {
        let mut idt = InterruptDescriptorTable::new();
        // Division Error 핸들러 등록 (벡터 #0)
        idt.divide_error.set_handler_fn(divide_error_handler);
        idt
    };
}

// Division Error 핸들러: Fault 타입이므로 에러 코드 없음
extern "x86-interrupt" fn divide_error_handler(
    stack_frame: InterruptStackFrame
) {
    println!("EXCEPTION: DIVIDE ERROR");
    println!("Instruction Pointer: {:?}", stack_frame.instruction_pointer);
    // 프로세스 종료 또는 복구 로직
    loop {}
}

설명

이것이 하는 일: 이 코드는 x86-64 아키텍처의 Interrupt Descriptor Table(IDT)에 Division Error 핸들러를 등록하고, 예외 발생 시 안전하게 처리하는 메커니즘을 구현합니다. 첫 번째로, lazy_static 매크로를 사용하여 전역 IDT를 생성합니다.

IDT는 256개의 엔트리를 가지며, 각각은 특정 인터럽트나 예외에 대한 핸들러 함수를 가리킵니다. divide_error 필드에 set_handler_fn으로 핸들러를 등록하면, CPU가 Division Error를 감지했을 때 자동으로 이 함수를 호출하게 됩니다.

그 다음으로, divide_error_handler 함수가 실행됩니다. 이 함수는 특별한 x86-interrupt calling convention을 사용하는데, 이는 CPU가 예외 발생 시 자동으로 스택 프레임을 구성하고 인터럽트를 처리한 후 원래 상태로 복귀할 수 있도록 합니다.

InterruptStackFrame 파라미터에는 예외가 발생한 시점의 CPU 상태(명령어 포인터, 코드 세그먼트, RFLAGS 등)가 담겨 있습니다. 마지막으로, 핸들러 내부에서는 예외 정보를 출력하고 적절한 조치를 취합니다.

실제 OS에서는 여기서 프로세스를 종료하거나, 시그널을 보내거나, 에러 로그를 남기는 등의 복구 로직을 구현합니다. Fault 타입 예외이므로 이론적으로는 문제를 수정하고 같은 명령어를 재실행할 수도 있지만, Division Error의 경우 대부분 프로그램 로직 오류이므로 복구가 어렵습니다.

여러분이 이 코드를 사용하면 커널이 예상치 못한 산술 오류로부터 보호받고, 문제가 발생한 프로세스만 격리하여 처리할 수 있습니다. 또한 디버깅을 위한 정확한 에러 위치(instruction_pointer)를 확보할 수 있어, 문제 원인을 빠르게 파악할 수 있습니다.

실전 팁

💡 Division Error는 에러 코드를 푸시하지 않는 예외입니다. 만약 핸들러 시그니처에 error_code 파라미터를 추가하면 스택 정렬이 깨져서 더블 폴트가 발생하니 주의하세요.

💡 실제 운영체제에서는 user space에서 발생한 Division Error와 kernel space에서 발생한 것을 구분해야 합니다. stack_frame.code_segment를 확인하여 권한 레벨(Ring 0 vs Ring 3)을 판단하세요.

💡 Division Error는 정수 나눗셈(DIV, IDIV 명령어)에서만 발생합니다. 부동소수점 나눗셈의 경우 별도의 FPU 예외(#MF)가 발생하므로 혼동하지 마세요.

💡 핸들러 내부에서 무한 루프 대신, 실제로는 프로세스 종료 함수를 호출하거나 패닉 메시지를 남기고 시스템을 안전하게 정지시키는 것이 좋습니다. 개발 중에는 스택 트레이스를 출력하는 것도 유용합니다.


2. Debug Exception - 디버깅과 하드웨어 브레이크포인트

시작하며

여러분이 OS 레벨 디버거를 만들거나 프로세스 추적 기능을 구현할 때, 어떻게 특정 메모리 주소 접근이나 명령어 실행을 감지할 수 있을까요? gdb나 lldb 같은 디버거들이 브레이크포인트를 어떻게 구현하는지 궁금했던 적이 있나요?

이런 기능은 CPU가 제공하는 Debug Exception을 활용하여 구현됩니다. Debug Exception(#DB)은 하드웨어 브레이크포인트, 단일 스텝 실행, 특정 메모리 접근 감시 등 다양한 디버깅 시나리오에서 발생합니다.

이는 OS 개발자가 강력한 디버깅 도구를 만들 수 있게 해주는 핵심 메커니즘입니다. 바로 이럴 때 필요한 것이 Debug Exception 핸들러입니다.

DR0-DR7 디버그 레지스터와 함께 사용하면 코드 변경 없이 임의의 주소에 브레이크포인트를 설정하고 프로그램 실행을 제어할 수 있습니다.

개요

간단히 말해서, Debug Exception은 CPU의 디버깅 기능이 활성화되었을 때 발생하는 벡터 번호 1번 예외로, Fault 또는 Trap 타입으로 동작할 수 있습니다. 왜 이 예외가 중요할까요?

현대 운영체제의 디버거, 프로파일러, 보안 도구들은 모두 이 메커니즘에 의존합니다. 예를 들어, 악성코드 분석 도구에서 특정 API 호출을 감시하거나, 성능 프로파일링에서 함수 진입점을 추적하는 등의 작업에 필수적입니다.

기존에 소프트웨어 브레이크포인트(INT3 명령어로 코드를 수정)를 사용했다면, 하드웨어 브레이크포인트를 사용하면 코드를 전혀 수정하지 않고도 같은 기능을 구현할 수 있습니다. 또한 읽기 전용 메모리나 실행 중인 코드에도 적용할 수 있습니다.

이 예외의 핵심 특징은 첫째, DR6 레지스터를 통해 정확한 발생 원인을 파악할 수 있다는 점, 둘째, 단일 스텝 모드(TF 플래그)를 지원한다는 점, 셋째, 최대 4개의 하드웨어 브레이크포인트를 동시에 설정할 수 있다는 점입니다. 이를 통해 정교한 디버깅 전략을 구사할 수 있습니다.

코드 예제

use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame};
use x86_64::registers::debug::{Dr6, Dr7, DebugAddressRegister};

extern "x86-interrupt" fn debug_exception_handler(
    stack_frame: InterruptStackFrame
) {
    // DR6 레지스터로 예외 원인 파악
    let dr6 = Dr6::read();

    if dr6.breakpoint_condition(0) {
        // 하드웨어 브레이크포인트 0번 히트
        println!("Hardware breakpoint 0 hit at {:?}", stack_frame.instruction_pointer);
    }

    if dr6.single_step() {
        // 단일 스텝 실행 완료
        println!("Single step completed");
    }

    // DR6 레지스터 초기화 (다음 예외를 위해)
    Dr6::clear();
}

설명

이것이 하는 일: 이 코드는 CPU의 디버깅 하드웨어를 활용하여 프로그램 실행을 세밀하게 제어하고 감시하는 메커니즘을 구현합니다. Debug Exception이 발생하면 정확한 원인을 파악하고 적절히 대응합니다.

첫 번째로, debug_exception_handler가 호출되면 DR6(Debug Status Register)를 읽어옵니다. DR6는 어떤 종류의 디버그 이벤트가 발생했는지 알려주는 비트 필드입니다.

B0-B3 비트는 각각 DR0-DR3 브레이크포인트가 히트했는지 나타내고, BS 비트는 단일 스텝 실행이 완료되었는지 알려줍니다. 그 다음으로, breakpoint_condition 메서드로 특정 하드웨어 브레이크포인트가 트리거되었는지 확인합니다.

하드웨어 브레이크포인트는 DR0-DR3 레지스터에 설정된 주소에 접근하거나 실행할 때 발생합니다. DR7 레지스터로 각 브레이크포인트의 조건(실행, 쓰기, 읽기/쓰기)과 크기를 설정할 수 있습니다.

이를 통해 "이 변수가 변경될 때만 멈춤" 같은 조건부 브레이크포인트를 구현할 수 있습니다. 마지막으로, single_step 메서드로 단일 스텝 모드를 확인합니다.

RFLAGS 레지스터의 TF(Trap Flag) 비트가 설정되어 있으면, CPU는 매 명령어 실행 후 Debug Exception을 발생시킵니다. 이는 디버거에서 "Step Into" 기능을 구현하는 핵심 메커니즘입니다.

처리 후에는 반드시 DR6를 clear() 해야 다음 예외를 올바르게 감지할 수 있습니다. 여러분이 이 코드를 사용하면 커널 레벨 디버거를 구현할 수 있고, 시스템 콜 추적, 메모리 접근 감시, 코드 커버리지 분석 등 다양한 고급 기능을 만들 수 있습니다.

또한 루트킷 탐지나 샌드박스 환경 구축에도 활용할 수 있습니다.

실전 팁

💡 Debug Exception은 Trap과 Fault 두 가지 타입으로 발생할 수 있습니다. 명령어 브레이크포인트는 Fault(명령어 실행 전), 단일 스텝은 Trap(명령어 실행 후)으로 동작하므로 처리 시 주의하세요.

💡 하드웨어 브레이크포인트는 물리적으로 4개만 사용 가능합니다. 더 많은 브레이크포인트가 필요하면 소프트웨어 브레이크포인트(INT3)와 혼합하여 사용하세요.

💡 DR7 레지스터 설정이 잘못되면 예외가 발생하지 않거나 잘못된 시점에 발생할 수 있습니다. 각 브레이크포인트의 L(로컬) 또는 G(글로벌) 비트를 올바르게 설정했는지 확인하세요.

💡 가상화 환경에서는 Debug Exception이 VM exit을 유발할 수 있어 성능에 영향을 줍니다. 프로덕션 환경에서는 디버그 기능을 비활성화하는 것이 좋습니다.

💡 DR6 레지스터를 clear하지 않으면 이전 예외의 상태가 남아있어 다음 예외 원인을 잘못 판단할 수 있습니다. 핸들러 마지막에 항상 초기화하는 습관을 들이세요.


3. Breakpoint Exception - 소프트웨어 브레이크포인트 구현

시작하며

여러분이 디버거를 만들 때 가장 기본적인 기능인 브레이크포인트를 어떻게 구현하나요? 특정 코드 라인에서 실행을 멈추고 변수 값을 확인하는 그 기능 말입니다.

이런 기능은 CPU의 특별한 명령어인 INT3를 활용합니다. Breakpoint Exception(#BP)은 명시적으로 INT3 명령어(0xCC 바이트코드)를 실행했을 때 발생하는 Trap 타입 예외입니다.

모든 디버거의 기본이 되는 메커니즘이며, OS 개발 중 디버깅 인프라를 구축할 때 필수적입니다. 바로 이럴 때 필요한 것이 Breakpoint Exception 핸들러입니다.

이를 통해 프로그램 실행을 중단하고, 상태를 검사하고, 사용자 입력을 받아 다시 실행을 재개하는 대화형 디버깅 환경을 만들 수 있습니다.

개요

간단히 말해서, Breakpoint Exception은 벡터 번호 3번 예외로, INT3 명령어를 만났을 때 발생하며, Trap 타입이므로 명령어 실행 후에 핸들러가 호출됩니다. 왜 소프트웨어 브레이크포인트가 필요할까요?

하드웨어 브레이크포인트는 개수 제한(4개)이 있지만, 소프트웨어 브레이크포인트는 무제한으로 설정할 수 있습니다. 예를 들어, 대형 프로젝트에서 수십 개의 함수 진입점을 동시에 모니터링해야 할 때 소프트웨어 브레이크포인트가 유일한 선택입니다.

기존 하드웨어 브레이크포인트가 레지스터 기반이라면, 소프트웨어 브레이크포인트는 코드 패치 기반입니다. 디버거는 원본 명령어를 저장한 후 INT3(1바이트)로 덮어쓰고, 브레이크포인트 히트 시 원본 명령어를 복원합니다.

이 예외의 핵심 특징은 첫째, 단 1바이트 명령어라 어떤 명령어도 대체 가능하다는 점, 둘째, Trap 타입이라 INT3 다음 명령어부터 실행을 재개한다는 점, 셋째, 에러 코드 없이 빠르게 처리된다는 점입니다. 이러한 특성 덕분에 효율적인 디버깅이 가능합니다.

코드 예제

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

lazy_static! {
    static ref IDT: InterruptDescriptorTable = {
        let mut idt = InterruptDescriptorTable::new();
        // Breakpoint Exception 핸들러 등록 (벡터 #3)
        idt.breakpoint.set_handler_fn(breakpoint_handler);
        idt
    };
}

extern "x86-interrupt" fn breakpoint_handler(
    stack_frame: InterruptStackFrame
) {
    println!("EXCEPTION: BREAKPOINT at {:#x}",
             stack_frame.instruction_pointer.as_u64());

    // 스택 정보 출력 (디버깅용)
    println!("Stack frame: {:#?}", stack_frame);

    // 여기서 디버거 프롬프트를 띄우거나 상태 검사
    // 실제 디버거에서는 원본 명령어 복원 후 재실행
}

설명

이것이 하는 일: 이 코드는 소프트웨어 브레이크포인트를 처리하는 인프라를 구축합니다. INT3 명령어가 실행되면 프로그램을 일시 중지하고 현재 상태를 검사할 수 있게 합니다.

첫 번째로, IDT의 breakpoint 엔트리에 핸들러를 등록합니다. 이는 벡터 번호 3번에 해당하며, CPU가 0xCC 바이트코드를 만나면 자동으로 현재 실행을 중단하고 이 핸들러로 점프합니다.

Trap 타입 예외이므로 INT3 명령어는 이미 실행이 완료된 상태이고, instruction_pointer는 다음 명령어를 가리킵니다. 그 다음으로, breakpoint_handler가 실행되면서 현재 실행 위치와 스택 프레임 정보를 출력합니다.

InterruptStackFrame에는 instruction_pointer뿐만 아니라 code_segment, cpu_flags, stack_pointer, stack_segment 등 CPU의 전체 상태가 담겨 있습니다. 이 정보를 분석하면 브레이크포인트 발생 시점의 정확한 컨텍스트를 파악할 수 있습니다.

마지막으로, 실제 디버거 구현에서는 여기서 사용자 입력을 받아 변수 검사, 스택 트레이스 출력, 메모리 덤프 등을 수행합니다. 실행을 재개하려면 INT3로 덮어쓴 원본 명령어를 복원하고, instruction_pointer를 1바이트 뒤로(INT3 명령어 위치로) 조정한 후 iretq 명령어로 복귀하면 됩니다.

이 과정을 자동화하면 "Continue" 기능이 완성됩니다. 여러분이 이 코드를 사용하면 자체 디버거를 만들거나, 테스트 프레임워크에 브레이크포인트 기능을 추가하거나, 런타임 코드 분석 도구를 개발할 수 있습니다.

또한 어설션 매크로를 구현할 때도 활용할 수 있어, 조건이 실패하면 자동으로 디버거가 멈추도록 만들 수 있습니다.

실전 팁

💡 INT3는 1바이트 명령어(0xCC)이지만, 일반적인 소프트웨어 인터럽트 INT n은 2바이트(0xCD 0xnn)입니다. INT3만 특별히 1바이트로 설계되어 브레이크포인트 구현에 최적화되어 있습니다.

💡 멀티스레드 환경에서 브레이크포인트를 설정할 때는 race condition에 주의하세요. 코드를 패치하는 동안 다른 스레드가 해당 코드를 실행 중일 수 있으므로, 적절한 동기화가 필요합니다.

💡 Trap 타입이므로 instruction_pointer는 INT3 다음을 가리킵니다. 원본 명령어를 재실행하려면 RIP를 수동으로 조정해야 합니다. 이를 잊으면 명령어가 스킵됩니다.

💡 실제 디버거 구현 시 원본 명령어를 HashMap 같은 자료구조에 저장해두세요. 브레이크포인트 주소를 키로, 원본 바이트를 값으로 저장하면 여러 브레이크포인트를 효율적으로 관리할 수 있습니다.

💡 커널 디버깅 시 브레이크포인트 핸들러 내부에서 또 다른 예외가 발생하지 않도록 주의하세요. 특히 메모리 할당이나 락 획득 같은 복잡한 작업은 피하는 것이 좋습니다.


4. Page Fault - 메모리 접근 예외 처리

시작하며

여러분이 OS의 가상 메모리 시스템을 구현할 때, 어떻게 존재하지 않는 페이지 접근을 감지하고 처리하나요? 또는 읽기 전용 메모리에 쓰기를 시도하는 것을 어떻게 막나요?

이런 모든 상황은 Page Fault(#PF)라는 예외를 통해 처리됩니다. Page Fault는 가상 메모리 시스템의 핵심이며, 요구 페이징(demand paging), 메모리 보호, 스왑, Copy-on-Write 같은 고급 메모리 관리 기법의 기초가 됩니다.

실제로 여러분이 사용하는 모든 현대 운영체제는 Page Fault를 수천 번씩 처리하며 작동합니다. 바로 이럴 때 필요한 것이 Page Fault 핸들러입니다.

메모리 접근 위반을 감지하고, 필요한 페이지를 로드하거나, 권한 위반 시 프로세스를 종료하는 등 적절한 조치를 취할 수 있습니다.

개요

간단히 말해서, Page Fault는 벡터 번호 14번 예외로, 페이지 테이블에 없는 주소에 접근하거나 권한이 없는 작업을 시도할 때 발생하는 Fault 타입 예외입니다. 왜 이 예외가 OS 개발의 핵심일까요?

현대 OS의 가상 메모리는 모두 Page Fault 기반으로 작동합니다. 예를 들어, 프로그램을 실행할 때 전체 이미지를 메모리에 로드하지 않고, 실제로 접근하는 페이지만 Page Fault를 통해 점진적으로 로드합니다(lazy loading).

이를 통해 메모리 사용량을 획기적으로 줄일 수 있습니다. 기존에 물리 메모리만 사용하던 시절에는 메모리 부족 시 프로그램을 실행할 수 없었지만, 가상 메모리와 Page Fault를 활용하면 물리 메모리보다 큰 프로그램도 실행할 수 있습니다.

디스크와 메모리 사이에서 페이지를 교환하며 마치 무한한 메모리가 있는 것처럼 작동하죠. 이 예외의 핵심 특징은 첫째, 에러 코드를 푸시하여 정확한 원인을 알 수 있다는 점(P, W/R, U/S, RSVD, I/D 비트), 둘째, CR2 레지스터에 접근하려던 주소가 저장된다는 점, 셋째, Fault 타입이라 페이지를 로드한 후 같은 명령어를 재실행할 수 있다는 점입니다.

이러한 정보로 정교한 메모리 관리가 가능합니다.

코드 예제

use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame, PageFaultErrorCode};
use x86_64::registers::control::Cr2;

extern "x86-interrupt" fn page_fault_handler(
    stack_frame: InterruptStackFrame,
    error_code: PageFaultErrorCode,
) {
    // CR2 레지스터에서 접근 시도한 주소 읽기
    let accessed_address = Cr2::read();

    println!("PAGE FAULT while accessing: {:?}", accessed_address);
    println!("Error code: {:?}", error_code);

    // 에러 원인 분석
    if !error_code.contains(PageFaultErrorCode::PRESENT) {
        println!("Page not present - 페이지 로드 필요");
        // 디스크에서 페이지 로드 로직
    }
    if error_code.contains(PageFaultErrorCode::WRITE) {
        println!("Write access - 쓰기 시도");
    }
    if error_code.contains(PageFaultErrorCode::USER) {
        println!("User mode access - 사용자 공간에서 발생");
    }

    panic!("Unhandled page fault");
}

설명

이것이 하는 일: 이 코드는 가상 메모리 시스템의 핵심인 Page Fault를 처리하고, 메모리 접근 위반의 정확한 원인을 파악하여 적절한 조치를 취하는 메커니즘을 구현합니다. 첫 번째로, page_fault_handler는 두 개의 파라미터를 받습니다.

InterruptStackFrame는 일반적인 CPU 상태를, PageFaultErrorCode는 Page Fault의 구체적인 원인을 나타냅니다. 에러 코드의 비트 필드를 분석하면 P(페이지 존재 여부), W/R(쓰기/읽기), U/S(사용자/커널 모드), RSVD(예약 비트 위반), I/D(명령어/데이터 접근) 등을 알 수 있습니다.

이는 디버깅과 적절한 대응에 필수적인 정보입니다. 그 다음으로, Cr2::read()로 접근하려던 가상 주소를 읽어옵니다.

CR2는 Page Fault를 유발한 선형 주소를 저장하는 특수 레지스터입니다. 이 주소와 에러 코드를 결합하면 정확히 무엇이 잘못되었는지 알 수 있습니다.

예를 들어, 주소가 유효한 힙 영역이지만 페이지가 없다면(PRESENT 비트 0) 디스크에서 로드하면 되고, 잘못된 주소라면 Segmentation Fault로 프로세스를 종료해야 합니다. 마지막으로, 에러 원인에 따라 분기 처리를 수행합니다.

PRESENT 비트가 0이면 요구 페이징 상황이므로 디스크에서 페이지를 읽어 페이지 테이블에 매핑하고 복귀하면 됩니다(Fault 타입이므로 같은 명령어 재실행). WRITE 비트를 확인하여 Copy-on-Write 페이지에 쓰기 시도인지 판단하고, USER 비트로 커널 메모리에 대한 사용자 공간 접근을 차단할 수 있습니다.

여러분이 이 코드를 사용하면 메모리 효율적인 프로세스 관리, 빠른 프로세스 포크(COW), 메모리 매핑 파일, 보호된 메모리 영역 등 현대 OS의 핵심 기능들을 구현할 수 있습니다. 또한 메모리 디버깅 도구(예: AddressSanitizer)를 만들거나, 가비지 컬렉터의 write barrier를 구현하는 데도 활용할 수 있습니다.

실전 팁

💡 Page Fault는 매우 빈번하게 발생하는 예외입니다. 핸들러를 최적화하지 않으면 시스템 성능에 큰 영향을 미치므로, 빠른 경로(fast path)와 느린 경로(slow path)를 분리하여 구현하세요.

💡 CR2 레지스터는 가장 최근의 Page Fault 주소만 저장합니다. 핸들러 내부에서 또 다른 Page Fault가 발생하면 CR2가 덮어써지므로, 처음 읽은 값을 로컬 변수에 저장하세요.

💡 에러 코드의 RESERVED 비트가 설정되어 있다면 페이지 테이블 엔트리의 예약된 비트가 1로 설정된 것입니다. 이는 심각한 커널 버그이므로 즉시 패닉해야 합니다.

💡 멀티코어 시스템에서는 TLB shootdown을 고려해야 합니다. 한 코어에서 페이지 테이블을 수정하면 다른 코어의 TLB를 무효화(IPI 전송)하지 않으면 stale 데이터를 참조할 수 있습니다.

💡 I/D 비트(Instruction/Data)를 활용하면 실행 불가능한 페이지(NX bit)에서 코드 실행 시도를 감지할 수 있습니다. 이는 보안 취약점(스택 오버플로우 등)을 방어하는 중요한 메커니즘입니다.


5. General Protection Fault - 권한 및 세그먼트 위반

시작하며

여러분이 커널 모드에서만 실행 가능한 명령어를 사용자 공간에서 실행하려 하거나, 잘못된 세그먼트 셀렉터를 로드하려고 할 때 무슨 일이 일어날까요? 또는 NULL 포인터를 역참조하면 어떻게 될까요?

이런 모든 권한 위반과 보호 위반은 General Protection Fault(#GP)를 발생시킵니다. #GP는 "기타 모든 보호 위반"을 처리하는 포괄적인 예외로, 세그먼트 위반, 권한 레벨 위반, NULL 포인터 접근, 잘못된 명령어 등 다양한 상황에서 발생합니다.

제대로 처리하지 않으면 시스템이 불안정해지거나 보안 취약점이 될 수 있습니다. 바로 이럴 때 필요한 것이 General Protection Fault 핸들러입니다.

커널과 사용자 공간의 경계를 보호하고, 악의적이거나 잘못된 코드로부터 시스템을 지키는 최후의 방어선입니다.

개요

간단히 말해서, General Protection Fault는 벡터 번호 13번 예외로, 메모리 보호, 권한 레벨, 세그먼트 한계 등의 위반이 Page Fault 외의 형태로 발생할 때 트리거되는 Fault 타입 예외입니다. 왜 이 예외를 철저히 처리해야 할까요?

#GP는 보안의 핵심입니다. 사용자 프로그램이 커널 메모리에 접근하거나, I/O 포트를 직접 조작하거나, 권한 있는 명령어를 실행하려는 모든 시도를 차단합니다.

예를 들어, Ring 3(사용자 모드)에서 HLT 명령어를 실행하려 하면 #GP가 발생하여 시스템을 보호합니다. 기존 보호 모드 이전 시대에는 이런 보호 기능이 없어 한 프로그램의 버그가 전체 시스템을 다운시킬 수 있었습니다.

보호 모드와 #GP 덕분에 각 프로세스를 격리하고 안정적인 멀티태스킹이 가능해졌습니다. 이 예외의 핵심 특징은 첫째, 에러 코드가 세그먼트 셀렉터를 포함할 수 있어 정확한 원인 파악이 가능하다는 점, 둘째, NULL 포인터(주소 0) 접근도 감지한다는 점, 셋째, Fault 타입이지만 대부분 복구 불가능하여 프로세스 종료로 이어진다는 점입니다.

이를 통해 시스템 무결성을 유지할 수 있습니다.

코드 예제

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

extern "x86-interrupt" fn general_protection_fault_handler(
    stack_frame: InterruptStackFrame,
    error_code: u64,
) {
    println!("EXCEPTION: GENERAL PROTECTION FAULT");

    if error_code != 0 {
        // 에러 코드 분석: 세그먼트 셀렉터 관련 정보
        let external = error_code & 0x1;  // 외부 이벤트 여부
        let table = (error_code >> 1) & 0x3;  // GDT(0), IDT(1,3), LDT(2)
        let index = (error_code >> 3) & 0x1FFF;  // 셀렉터 인덱스

        println!("Error code: {:#x}", error_code);
        println!("  Segment index: {}, Table: {}", index, table);
    } else {
        println!("Non-segment related GPF (NULL pointer, privilege, etc.)");
    }

    println!("Instruction pointer: {:?}", stack_frame.instruction_pointer);
    println!("Code segment: {:#x}", stack_frame.code_segment);

    // 커널 모드에서 발생했다면 패닉, 사용자 모드면 프로세스 종료
    panic!("Unrecoverable GPF in kernel mode");
}

설명

이것이 하는 일: 이 코드는 다양한 보호 위반을 감지하고 분석하여, 시스템의 안정성과 보안을 지키는 메커니즘을 구현합니다. #GP는 매우 광범위한 예외이므로 에러 코드를 세밀하게 분석해야 합니다.

첫 번째로, general_protection_fault_handler는 에러 코드와 스택 프레임을 받습니다. 에러 코드가 0이 아니면 세그먼트 셀렉터와 관련된 문제입니다.

비트 0(EXT)은 외부 이벤트(인터럽트나 예외)로 인한 것인지, 비트 1-2(TBL)는 어느 디스크립터 테이블(GDT, LDT, IDT)인지, 비트 3-15(IDX)는 테이블 내 인덱스를 나타냅니다. 이 정보로 어떤 세그먼트가 문제인지 정확히 알 수 있습니다.

그 다음으로, 에러 코드가 0인 경우를 처리합니다. 이는 세그먼트와 무관한 위반으로, NULL 포인터 역참조(0x0 접근), 권한 없는 명령어 실행(예: Ring 3에서 LGDT 실행), I/O 권한 위반(IOPL보다 높은 권한 필요한 포트 접근) 등이 해당합니다.

code_segment 필드를 확인하면 커널 모드(Ring 0, 하위 2비트가 0)인지 사용자 모드(Ring 3, 하위 2비트가 3)인지 판단할 수 있습니다. 마지막으로, instruction_pointer로 정확한 오류 발생 위치를 파악하고 적절히 대응합니다.

커널 모드에서 #GP가 발생했다면 심각한 커널 버그이므로 즉시 패닉해야 합니다. 하지만 사용자 모드에서 발생했다면 해당 프로세스만 SIGSEGV 시그널로 종료하고 커널은 계속 실행되어야 합니다.

Fault 타입이므로 이론상 복구 가능하지만, 대부분의 #GP는 프로그래밍 오류라 실제 복구는 드뭅니다. 여러분이 이 코드를 사용하면 프로세스 격리, 권한 분리, 메모리 보호 등 OS의 핵심 보안 기능을 구현할 수 있습니다.

또한 NULL 포인터 버그를 조기에 발견하고, 악의적인 코드의 권한 상승 시도를 차단하며, 잘못된 시스템 콜 파라미터를 검증할 수 있습니다.

실전 팁

💡 NULL 포인터 접근이 항상 #GP를 발생시키는 것은 아닙니다. 페이지 0이 매핑되어 있으면 Page Fault도 발생하지 않습니다. 보안을 위해 페이지 0는 절대 매핑하지 마세요.

💡 사용자 모드에서 IN/OUT 명령어를 실행하면 #GP가 발생합니다. IOPL 비트나 I/O 퍼미션 비트맵으로 특정 포트만 허용할 수 있지만, 보안상 시스템 콜을 통하는 것이 안전합니다.

💡 잘못된 시스템 콜 번호나 파라미터로 #GP가 발생할 수 있습니다. 시스템 콜 핸들러에서 모든 파라미터를 검증하여 #GP를 사전에 방지하세요.

💡 세그먼트 셀렉터의 RPL(Requested Privilege Level)이 DPL(Descriptor Privilege Level)보다 낮으면 #GP가 발생합니다. 이는 권한 상승을 막는 중요한 메커니즘입니다.

💡 Long Mode(64비트)에서는 세그먼트가 거의 사용되지 않지만, CS와 SS는 여전히 중요합니다. 잘못된 GDT 설정은 부팅 초기에 #GP를 유발할 수 있으니 주의하세요.


6. Double Fault - 예외 처리 중 예외 발생

시작하며

여러분이 예외 핸들러를 작성했는데, 그 핸들러 내부에서 또 다른 예외가 발생하면 어떻게 될까요? 예를 들어, Page Fault 핸들러가 존재하지 않는 스택에 접근하려 한다면?

이런 위험한 상황은 Double Fault(#DF)를 발생시킵니다. Double Fault는 예외 처리 중 특정 조합의 예외가 또 발생했을 때 트리거되는 Abort 타입 예외입니다.

제대로 처리하지 않으면 Triple Fault로 이어져 CPU가 리셋되고 시스템이 재부팅됩니다. OS 개발에서 가장 디버깅하기 어려운 상황 중 하나입니다.

바로 이럴 때 필요한 것이 Double Fault 핸들러와 별도의 IST(Interrupt Stack Table)입니다. 안전한 스택에서 예외를 처리하여 시스템 재부팅을 막고 디버깅 정보를 확보할 수 있습니다.

개요

간단히 말해서, Double Fault는 벡터 번호 8번 예외로, 예외 처리 중 또 다른 예외가 발생했을 때(특정 조합만) 트리거되며, Abort 타입이므로 복구가 거의 불가능합니다. 왜 Double Fault를 반드시 처리해야 할까요?

처리하지 않으면 Triple Fault가 발생하여 CPU가 리셋됩니다. 이는 디버깅 정보 없이 시스템이 갑자기 재부팅되는 최악의 시나리오입니다.

예를 들어, 커널 스택 오버플로우 상황에서 Page Fault 핸들러가 스택을 사용하려 하면 또 다른 Page Fault가 발생하고, 이것이 Double Fault로 이어집니다. 기존에는 예외가 연쇄적으로 발생하면 시스템이 멈췄지만, Double Fault 핸들러를 별도 스택(IST)에서 실행하면 스택 문제와 무관하게 안전하게 처리할 수 있습니다.

이 예외의 핵심 특징은 첫째, 항상 에러 코드 0을 푸시한다는 점(의미 없음), 둘째, Abort 타입이라 복구가 아닌 정보 수집에 집중해야 한다는 점, 셋째, IST를 사용하지 않으면 핸들러 자체가 실행 불가능할 수 있다는 점입니다. 이것이 OS 안정성의 마지막 보루입니다.

코드 예제

use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame};
use x86_64::structures::tss::TaskStateSegment;
use lazy_static::lazy_static;

pub const DOUBLE_FAULT_IST_INDEX: u16 = 0;

lazy_static! {
    static ref TSS: TaskStateSegment = {
        let mut tss = TaskStateSegment::new();
        // IST에 별도 스택 할당 (스택 오버플로우 방지)
        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 });
            stack_start + STACK_SIZE  // 스택은 위에서 아래로 자람
        };
        tss
    };
}

extern "x86-interrupt" fn double_fault_handler(
    stack_frame: InterruptStackFrame,
    error_code: u64,
) -> ! {
    // Abort 타입이므로 복귀 불가능
    panic!("EXCEPTION: DOUBLE FAULT\n{:#?}\nError code: {}",
           stack_frame, error_code);
}

설명

이것이 하는 일: 이 코드는 예외 처리 중 발생하는 치명적인 상황을 감지하고, 시스템 재부팅(Triple Fault) 전에 디버깅 정보를 확보하는 최후의 안전장치를 구현합니다. 첫 번째로, TSS(Task State Segment)를 설정하고 interrupt_stack_table에 별도의 스택을 할당합니다.

IST는 특정 예외 핸들러가 현재 스택과 무관한 독립적인 스택에서 실행되도록 합니다. Double Fault는 주로 스택 문제(오버플로우, 가드 페이지 접근)로 발생하므로, 새로운 안전한 스택에서 핸들러를 실행하는 것이 필수입니다.

4096 * 5 바이트(20KB)는 핸들러가 충분히 작동할 수 있는 크기입니다. 그 다음으로, IDT 엔트리에 핸들러를 등록할 때 IST 인덱스를 지정합니다(코드에는 명시적으로 없지만 set_stack_index 메서드 사용).

CPU가 Double Fault를 감지하면 자동으로 RSP를 IST의 해당 스택으로 변경한 후 핸들러를 호출합니다. 이 과정은 완전히 하드웨어가 처리하므로 스택 상태와 무관하게 안전합니다.

마지막으로, double_fault_handler는 Abort 타입이므로 절대 복귀하지 않는 발산 함수(-> !)로 선언됩니다. 여기서는 가능한 많은 디버깅 정보(스택 프레임, 레지스터 상태, 백트레이스)를 시리얼 포트나 화면에 출력한 후 시스템을 안전하게 정지시킵니다.

일부 OS는 여기서 커널 덤프를 생성하여 사후 분석을 가능하게 합니다. 여러분이 이 코드를 사용하면 가장 치명적인 커널 버그(스택 오버플로우, 잘못된 예외 핸들러, 무한 예외 루프)를 진단할 수 있습니다.

Triple Fault로 시스템이 갑자기 재부팅되는 대신, 명확한 에러 메시지와 스택 트레이스를 얻을 수 있어 디버깅 시간을 크게 단축할 수 있습니다.

실전 팁

💡 모든 예외 조합이 Double Fault를 유발하는 것은 아닙니다. Page Fault 중 또 다른 Page Fault가 발생하거나, Division Error 핸들러가 없어 #GP가 발생하는 등 특정 조합만 해당됩니다.

💡 IST 스택 크기는 신중히 결정하세요. 너무 작으면 핸들러 실행 중 스택 오버플로우가 발생할 수 있고, 너무 크면 메모리 낭비입니다. 보통 16-20KB면 충분합니다.

💡 Double Fault 핸들러 내부에서 복잡한 작업(힙 할당, 락 획득 등)을 피하세요. 시스템이 이미 불안정한 상태이므로 최소한의 정보 출력만 하고 멈추는 것이 안전합니다.

💡 가상화 환경에서는 Triple Fault 시 호스트로 제어가 넘어가므로 디버깅이 더 어렵습니다. QEMU의 -d int 옵션으로 예외 로그를 활성화하면 도움이 됩니다.

💡 실제 하드웨어에서는 시리얼 포트를 통해 Double Fault 정보를 외부 시스템으로 전송하는 것이 좋습니다. 화면 출력은 다른 예외로 실패할 수 있지만, 시리얼 I/O는 비교적 안전합니다.


7. Invalid TSS - 잘못된 태스크 상태 세그먼트

시작하며

여러분이 태스크 스위칭을 구현하거나 TSS를 설정할 때, 잘못된 세그먼트 셀렉터나 경계를 넘는 접근을 했다면 어떻게 될까요? 또는 하드웨어 태스크 스위칭을 시도하다 실패하면?

이런 상황은 Invalid TSS(#TS) 예외를 발생시킵니다. TSS(Task State Segment)는 태스크의 상태(레지스터, 스택 포인터, I/O 권한 등)를 저장하는 구조체로, x86-64에서는 주로 권한 레벨 변경 시 스택 전환과 IST에 사용됩니다.

TSS 관련 오류는 시스템 초기화나 컨텍스트 스위칭에서 발생하므로 정확한 설정이 중요합니다. 바로 이럴 때 필요한 것이 Invalid TSS 핸들러입니다.

TSS 구성 오류를 조기에 발견하고 디버깅 정보를 제공하여 시스템 불안정을 예방할 수 있습니다.

개요

간단히 말해서, Invalid TSS는 벡터 번호 10번 예외로, TSS 디스크립터나 TSS 내부의 필드가 유효하지 않을 때 발생하는 Fault 타입 예외입니다. 왜 이 예외를 이해해야 할까요?

x86-64 Long Mode에서는 하드웨어 태스크 스위칭이 지원되지 않지만, TSS는 여전히 중요합니다. 시스템 콜이나 인터럽트로 Ring 3에서 Ring 0으로 전환될 때, CPU는 TSS의 RSP0 필드를 읽어 커널 스택으로 전환합니다.

또한 IST를 통한 별도 스택 사용에도 TSS가 필수적입니다. TSS 설정이 잘못되면 시스템 콜이나 예외 처리가 실패합니다.

기존 32비트 시스템에서는 TSS가 하드웨어 멀티태스킹에도 사용되었지만, 64비트에서는 소프트웨어 태스크 스위칭이 일반적이고 TSS는 스택 관리에 집중합니다. 이 예외의 핵심 특징은 첫째, 에러 코드가 TSS 셀렉터를 포함한다는 점, 둘째, TSS 디스크립터나 내부 필드의 다양한 문제를 감지한다는 점, 셋째, Fault 타입이지만 복구는 설정을 수정해야 하므로 어렵다는 점입니다.

올바른 TSS 구성이 안정적인 시스템의 전제조건입니다.

코드 예제

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

extern "x86-interrupt" fn invalid_tss_handler(
    stack_frame: InterruptStackFrame,
    error_code: u64,
) {
    println!("EXCEPTION: INVALID TSS");
    println!("Error code (TSS selector): {:#x}", error_code);

    // 에러 코드 분석
    let external = error_code & 0x1;
    let table = (error_code >> 1) & 0x3;
    let index = (error_code >> 3) & 0x1FFF;

    println!("  External event: {}", external != 0);
    println!("  Descriptor table: {}", match table {
        0 => "GDT",
        1 | 3 => "IDT",
        2 => "LDT",
        _ => "Unknown"
    });
    println!("  Selector index: {}", index);

    println!("Instruction pointer: {:?}", stack_frame.instruction_pointer);

    // TSS 설정 오류는 보통 초기화 문제
    panic!("Invalid TSS - check GDT and TSS initialization");
}

설명

이것이 하는 일: 이 코드는 TSS 관련 오류를 감지하고 분석하여, 시스템 초기화나 권한 레벨 전환 시 발생하는 구성 문제를 진단하는 메커니즘을 구현합니다. 첫 번째로, invalid_tss_handler는 에러 코드를 받아 분석합니다.

에러 코드는 세그먼트 셀렉터 형식으로, 문제가 된 TSS 또는 관련 세그먼트를 가리킵니다. EXT 비트(0)는 외부 이벤트로 인한 것인지, TBL 비트(1-2)는 GDT, LDT, IDT 중 어느 테이블인지, Index 비트(3-15)는 테이블 내 인덱스를 나타냅니다.

이 정보로 정확히 어떤 디스크립터가 문제인지 알 수 있습니다. 그 다음으로, 발생 가능한 원인을 추론합니다.

Invalid TSS는 여러 이유로 발생할 수 있습니다: TSS 디스크립터의 limit이 최소 크기(104바이트)보다 작음, TSS 베이스 주소가 유효하지 않음, TSS가 busy 상태(B 비트 설정)인데 다시 로드 시도, TSS 내부의 SS, CS 등 세그먼트 셀렉터가 유효하지 않음, LDT 셀렉터가 잘못됨 등입니다. instruction_pointer와 함께 분석하면 어느 단계에서 문제가 발생했는지 파악할 수 있습니다.

마지막으로, 디버깅 정보를 출력하고 패닉합니다. Invalid TSS는 대부분 커널 초기화 코드의 버그입니다.

GDT에서 TSS 디스크립터를 올바르게 설정했는지, TSS 구조체의 모든 필드가 올바른 값으로 초기화되었는지, TR(Task Register)에 올바른 셀렉터를 로드했는지 확인해야 합니다. 이 예외는 보통 부팅 초기에 한 번 발생하므로, 고치면 다시 발생하지 않습니다.

여러분이 이 코드를 사용하면 TSS 초기화 오류를 빠르게 발견하고, 시스템 콜이나 인터럽트 처리가 실패하는 원인을 파악할 수 있습니다. 특히 권한 레벨 전환이 포함된 복잡한 시나리오(예: Ring 3 사용자 프로그램 실행)를 구현할 때 필수적인 디버깅 도구입니다.

실전 팁

💡 x86-64에서는 TSS가 하나만 있어도 충분합니다. 각 CPU 코어당 하나씩만 설정하고, 태스크 스위칭 시 TSS 내부의 RSP0 필드만 업데이트하세요.

💡 TSS 디스크립터는 GDT에서 16바이트를 차지합니다(64비트 모드). 8바이트 디스크립터로 착각하여 다음 엔트리를 덮어쓰지 않도록 주의하세요.

💡 TR(Task Register)에 TSS를 로드할 때 LTR 명령어를 사용합니다. 이 명령어는 Ring 0에서만 실행 가능하며, 셀렉터의 RPL은 0이어야 합니다.

💡 TSS의 I/O Permission Bitmap을 사용하면 사용자 공간에서 특정 I/O 포트 접근을 허용할 수 있습니다. 하지만 보안상 신중하게 사용해야 하며, 대부분은 시스템 콜을 통하는 것이 안전합니다.

💡 멀티코어 시스템에서는 각 코어가 자신의 TSS를 가져야 합니다. GDT를 공유하되, 각 코어의 TR은 서로 다른 TSS 디스크립터를 가리켜야 스택 충돌을 방지할 수 있습니다.


8. Stack Segment Fault - 스택 관련 세그먼트 오류

시작하며

여러분이 스택 세그먼트의 경계를 넘어서 접근하거나, 존재하지 않는 스택 세그먼트를 로드하려 하면 어떻게 될까요? 또는 스택 관련 연산에서 세그먼트 위반이 발생하면?

이런 상황은 Stack Segment Fault(#SS)를 발생시킵니다. 이 예외는 스택 세그먼트와 관련된 특정 보호 위반을 감지합니다.

x86-64 Long Mode에서는 세그먼트가 거의 플랫하게 사용되지만, SS 레지스터와 스택 경계 검사는 여전히 작동합니다. 특히 권한 레벨 변경 시 스택 전환에서 문제가 발생할 수 있습니다.

바로 이럴 때 필요한 것이 Stack Segment Fault 핸들러입니다. 스택 오버플로우나 언더플로우, 잘못된 스택 세그먼트 구성을 감지하여 시스템 안정성을 유지할 수 있습니다.

개요

간단히 말해서, Stack Segment Fault는 벡터 번호 12번 예외로, SS 레지스터 관련 작업이나 스택 작업에서 세그먼트 위반이 발생할 때 트리거되는 Fault 타입 예외입니다. 왜 이 예외를 처리해야 할까요?

스택은 프로그램 실행의 핵심입니다. 스택 손상은 리턴 주소 덮어쓰기, 로컬 변수 파괴 등 치명적인 결과를 초래합니다.

#SS는 스택 관련 문제를 조기에 감지하여 더 큰 피해를 막습니다. 예를 들어, 재귀 함수가 너무 깊어져 스택 가드 페이지에 접근하면 #SS(또는 #PF)가 발생하여 스택 오버플로우를 알립니다.

기존에는 스택 오버플로우를 감지하기 어려웠지만, 가드 페이지와 #SS 핸들러를 조합하면 정확한 시점에 오류를 잡아낼 수 있습니다. 이 예외의 핵심 특징은 첫째, 에러 코드가 SS 셀렉터를 포함할 수 있다는 점, 둘째, 스택 작업(PUSH, POP, CALL, RET, ENTER, LEAVE 등)에서 발생한다는 점, 셋째, Fault 타입이지만 스택이 손상되었으므로 복구가 어렵다는 점입니다.

조기 감지가 핵심입니다.

코드 예제

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

extern "x86-interrupt" fn stack_segment_fault_handler(
    stack_frame: InterruptStackFrame,
    error_code: u64,
) {
    println!("EXCEPTION: STACK SEGMENT FAULT");

    if error_code != 0 {
        // 에러 코드가 있으면 세그먼트 셀렉터 정보 포함
        let index = (error_code >> 3) & 0x1FFF;
        let table = (error_code >> 1) & 0x3;

        println!("SS selector index: {}, table: {}", index,
                 match table {
                     0 => "GDT",
                     2 => "LDT",
                     _ => "Invalid"
                 });
    } else {
        println!("Stack limit violation or NULL SS");
    }

    println!("Stack pointer: {:?}", stack_frame.stack_pointer);
    println!("Instruction: {:?}", stack_frame.instruction_pointer);

    // 스택 손상이므로 복구 어려움
    panic!("Stack segment fault - possible stack overflow or corruption");
}

설명

이것이 하는 일: 이 코드는 스택과 관련된 세그먼트 위반을 감지하고, 스택 오버플로우나 잘못된 스택 구성으로 인한 시스템 불안정을 예방하는 메커니즘을 구현합니다. 첫 번째로, stack_segment_fault_handler는 에러 코드를 분석합니다.

에러 코드가 0이 아니면 특정 세그먼트 셀렉터와 관련된 문제입니다. 예를 들어, MOV SS, AX 같은 명령어로 잘못된 셀렉터를 SS에 로드하려 할 때 발생합니다.

Index와 Table 비트로 어떤 디스크립터가 문제인지 파악할 수 있습니다. 에러 코드가 0이면 스택 리미트 위반이나 NULL SS 참조로, 스택 경계를 넘는 PUSH/POP 연산이 원인일 가능성이 높습니다.

그 다음으로, stack_pointer와 instruction_pointer를 출력하여 디버깅 정보를 제공합니다. 스택 포인터 값을 보면 스택이 얼마나 커졌는지, 가드 페이지에 도달했는지 알 수 있습니다.

커널 스택은 보통 제한된 크기(예: 16KB)이므로, RSP 값이 스택 베이스에서 너무 멀리 떨어졌다면 오버플로우입니다. instruction_pointer로는 어떤 함수가 마지막으로 호출되었는지 파악할 수 있습니다.

마지막으로, 패닉하여 시스템을 안전하게 정지시킵니다. 스택이 손상되었으므로 정상적인 함수 복귀가 불가능합니다.

실제 운영체제에서는 사용자 모드에서 발생한 #SS는 해당 프로세스를 종료하고, 커널 모드에서 발생한 것은 커널 패닉을 유발합니다. 백트레이스를 출력하면 재귀 깊이나 메모리 누수를 파악하는 데 도움이 됩니다.

여러분이 이 코드를 사용하면 스택 오버플로우 버그를 조기에 발견하고, 스택 크기를 적절히 조정하거나 재귀 깊이를 제한할 수 있습니다. 또한 스택 가드 페이지가 제대로 작동하는지 검증하고, 권한 레벨 전환 시 스택 전환 로직의 정확성을 확인할 수 있습니다.

실전 팁

💡 Long Mode에서는 스택 세그먼트의 base와 limit이 무시되지만, SS의 DPL과 RPL은 여전히 검사됩니다. Ring 0에서는 SS의 하위 2비트가 0이어야 합니다.

💡 스택 가드 페이지를 사용하면 스택 오버플로우를 Page Fault로 감지할 수 있습니다. #SS보다 #PF로 잡히는 경우가 더 흔하므로, 두 핸들러 모두 스택 문제를 고려하세요.

💡 시스템 콜이나 인터럽트로 Ring 3에서 Ring 0으로 전환될 때, CPU는 TSS의 RSP0를 자동으로 로드합니다. TSS의 스택 포인터가 유효하지 않으면 #SS가 발생할 수 있습니다.

💡 SYSCALL/SYSRET 명령어는 SS를 자동으로 설정합니다. 하지만 잘못된 GDT 구성은 여전히 #SS를 유발할 수 있으므로, IA32_STAR MSR 설정을 확인하세요.

💡 재귀 함수를 사용할 때는 항상 종료 조건을 명확히 하고, 스택 사용량을 모니터링하세요. 테일 콜 최적화가 가능한 경우 이를 활용하여 스택 사용을 줄일 수 있습니다.


9. Invalid Opcode - 유효하지 않은 명령어

시작하며

여러분이 잘못된 기계어 코드를 실행하거나, CPU가 지원하지 않는 명령어를 만나면 어떻게 될까요? 또는 FPU 명령어를 사용했는데 FPU가 비활성화되어 있다면?

이런 상황은 Invalid Opcode(#UD, Undefined Opcode)를 발생시킵니다. 이 예외는 현재 CPU가 해석할 수 없는 명령어를 만났을 때 트리거됩니다.

이는 컴파일 오류, 손상된 바이너리, 잘못된 함수 포인터, 또는 명령어 에뮬레이션이 필요한 상황을 나타낼 수 있습니다. 바로 이럴 때 필요한 것이 Invalid Opcode 핸들러입니다.

명령어 에뮬레이션 구현, CPU 기능 감지, 바이너리 손상 탐지 등에 활용할 수 있습니다.

개요

간단히 말해서, Invalid Opcode는 벡터 번호 6번 예외로, CPU가 인식할 수 없는 opcode를 만났을 때 발생하는 Fault 타입 예외입니다. 왜 이 예외를 처리해야 할까요?

첫째, 소프트웨어 에뮬레이션이 가능해집니다. 예를 들어, 구형 CPU에서 최신 명령어(AVX-512 등)를 소프트웨어로 에뮬레이션할 수 있습니다.

둘째, CPU 기능 탐지에 사용됩니다. CPUID보다 확실한 방법으로, 명령어를 실행해보고 #UD가 발생하는지 확인할 수 있습니다.

셋째, 보안 측면에서 ROP 공격 같은 악의적인 코드 실행을 감지할 수 있습니다. 기존에 CPUID로만 기능을 확인했다면, #UD 핸들러를 활용하면 실제 명령어 지원 여부를 테스트하고 폴백 로직을 구현할 수 있습니다.

이 예외의 핵심 특징은 첫째, 에러 코드가 푸시되지 않는다는 점, 둘째, 명시적으로 UD2 명령어로도 발생시킬 수 있다는 점(unreachable!() 매크로 구현), 셋째, instruction_pointer가 유효하지 않은 명령어의 시작을 가리킨다는 점입니다. 이를 통해 정확한 디버깅과 에뮬레이션이 가능합니다.

코드 예제

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

extern "x86-interrupt" fn invalid_opcode_handler(
    stack_frame: InterruptStackFrame
) {
    println!("EXCEPTION: INVALID OPCODE");
    println!("Instruction pointer: {:#x}",
             stack_frame.instruction_pointer.as_u64());

    // 명령어 바이트 읽기 (디버깅용)
    unsafe {
        let opcode_ptr = stack_frame.instruction_pointer.as_ptr::<u8>();
        println!("Opcode bytes: {:02x} {:02x} {:02x} {:02x}",
                 *opcode_ptr,
                 *opcode_ptr.offset(1),
                 *opcode_ptr.offset(2),
                 *opcode_ptr.offset(3));
    }

    // 에뮬레이션 가능 여부 확인
    // 예: CPUID로 확인한 unsupported 명령어를 소프트웨어로 에뮬레이션
    // if is_emulatable(opcode) {
    //     emulate_instruction(stack_frame);
    //     return;  // 에뮬레이션 후 복귀
    // }

    panic!("Invalid opcode - possible code corruption or unsupported instruction");
}

설명

이것이 하는 일: 이 코드는 유효하지 않은 명령어를 감지하고, 명령어 바이트를 분석하여 에뮬레이션 가능성을 판단하거나 디버깅 정보를 제공하는 메커니즘을 구현합니다. 첫 번째로, invalid_opcode_handler가 호출되면 instruction_pointer를 출력합니다.

이는 문제의 명령어가 정확히 어디에 있는지 알려줍니다. 역어셈블러와 함께 사용하면 어떤 함수의 어떤 부분에서 문제가 발생했는지 파악할 수 있습니다.

예를 들어, 함수 포인터가 잘못된 주소를 가리켜 데이터 영역을 명령어로 해석하려 했을 수 있습니다. 그 다음으로, instruction_pointer를 역참조하여 실제 opcode 바이트를 읽습니다.

x86-64 명령어는 가변 길이(1-15바이트)이므로 정확한 디코딩이 필요하지만, 첫 몇 바이트만으로도 많은 정보를 얻을 수 있습니다. 예를 들어, 0x0F 0x38이면 SSE4 명령어일 가능성이 높고, 0xC4/0xC5는 VEX 인코딩(AVX)을 나타냅니다.

이 정보로 어떤 명령어 세트가 문제인지 알 수 있습니다. 마지막으로, 에뮬레이션 가능성을 판단합니다.

일부 OS는 구형 하드웨어에서도 최신 바이너리를 실행할 수 있도록 명령어 에뮬레이션을 구현합니다. opcode를 분석하여 에뮬레이션 가능하면 소프트웨어로 같은 동작을 수행하고, instruction_pointer를 명령어 길이만큼 증가시킨 후 복귀하면 됩니다.

에뮬레이션 불가능하면 프로세스를 종료하거나 패닉합니다. 여러분이 이 코드를 사용하면 하위 호환성을 유지하면서 최신 명령어를 활용하거나, 특정 하드웨어 기능이 없는 환경에서도 프로그램을 실행할 수 있습니다.

또한 UD2 명령어를 명시적으로 사용하여 unreachable 코드 경로를 표시하고, 도달 시 즉시 패닉하도록 만들 수 있습니다.

실전 팁

💡 UD2 명령어(0x0F 0x0B)는 항상 #UD를 발생시킵니다. Rust의 unreachable!() 매크로나 panic!()의 일부 최적화에서 사용되므로, #UD가 항상 버그는 아닙니다.

💡 명령어 에뮬레이션을 구현할 때는 명령어 디코더가 필수입니다. x86-64의 복잡한 인코딩(REX, VEX, ModR/M, SIB 등)을 정확히 파싱해야 하므로, 기존 라이브러리(예: xed, capstone)를 활용하세요.

💡 CR0.EM 비트가 설정되어 있으면 x87 FPU 명령어가 #UD를 유발합니다. 이를 이용해 lazy FPU context switching을 구현할 수 있습니다.

💡 LOCK 프리픽스를 잘못된 명령어에 붙이거나, 레지스터 피연산자에 사용하면 #UD가 발생합니다. 멀티스레드 코드에서 동기화 버그일 수 있으니 주의하세요.

💡 가상화 환경에서는 특권 명령어(VMCALL, INVEPT 등)가 #UD를 유발할 수 있습니다. 하이퍼바이저가 설치되지 않은 환경에서 하이퍼바이저 명령어를 사용했을 가능성을 고려하세요.


10. Alignment Check - 메모리 정렬 검사

시작하며

여러분이 성능을 위해 SSE/AVX 명령어를 사용할 때, 16바이트 정렬되지 않은 주소에 접근하면 어떻게 될까요? 또는 정렬 검사를 활성화한 상태에서 정렬되지 않은 메모리 접근을 시도하면?

이런 상황은 Alignment Check(#AC)를 발생시킵니다. 이 예외는 기본적으로 비활성화되어 있지만, RFLAGS의 AC 비트와 CR0의 AM 비트가 모두 설정되면 활성화됩니다.

특정 SIMD 명령어(MOVDQA 등)는 항상 정렬을 요구하며, 정렬되지 않은 주소에 접근하면 #GP 대신 #AC를 발생시킬 수 있습니다. 바로 이럴 때 필요한 것이 Alignment Check 핸들러입니다.

메모리 정렬 문제를 디버깅하고, 성능 저하 원인을 파악하며, 데이터 구조체 레이아웃을 최적화하는 데 활용할 수 있습니다.

개요

간단히 말해서, Alignment Check는 벡터 번호 17번 예외로, 메모리 정렬 검사가 활성화된 상태에서 정렬되지 않은 주소에 접근할 때 발생하는 Fault 타입 예외입니다. 왜 메모리 정렬이 중요할까요?

첫째, 성능입니다. 현대 CPU는 정렬된 메모리 접근을 훨씬 빠르게 처리합니다.

정렬되지 않은 8바이트 읽기는 두 번의 메모리 사이클을 소비할 수 있습니다. 둘째, 정확성입니다.

일부 SIMD 명령어(MOVDQA, MOVAPS 등)는 정렬을 필수로 요구하며, 위반 시 크래시합니다. 셋째, 이식성입니다.

ARM 같은 일부 아키텍처는 정렬되지 않은 접근을 전혀 지원하지 않습니다. 기존에는 정렬 문제를 찾기 어려웠지만(성능 저하로만 나타남), #AC를 활성화하면 모든 정렬 위반을 즉시 감지할 수 있습니다.

이 예외의 핵심 특징은 첫째, 사용자 모드(Ring 3)에서만 검사된다는 점, 둘째, 명시적으로 활성화해야 한다는 점(RFLAGS.AC=1, CR0.AM=1), 셋째, 에러 코드가 항상 0이라는 점입니다. 디버깅 도구로 유용하지만 프로덕션에서는 보통 비활성화합니다.

코드 예제

use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame};
use x86_64::registers::rflags::{self, RFlags};
use x86_64::registers::control::{Cr0, Cr0Flags};

// 정렬 검사 활성화 (디버그 빌드에서)
pub fn enable_alignment_check() {
    unsafe {
        // CR0.AM 비트 설정
        Cr0::update(|flags| flags.insert(Cr0Flags::ALIGNMENT_MASK));
        // RFLAGS.AC 비트 설정
        rflags::write(rflags::read() | RFlags::ALIGNMENT_CHECK);
    }
}

extern "x86-interrupt" fn alignment_check_handler(
    stack_frame: InterruptStackFrame,
    error_code: u64,
) {
    println!("EXCEPTION: ALIGNMENT CHECK");
    println!("Unaligned memory access at: {:?}",
             stack_frame.instruction_pointer);

    // 스택 트레이스 출력하여 어떤 함수에서 발생했는지 확인
    println!("Stack: {:?}", stack_frame.stack_pointer);

    // 디버그 빌드: 패닉
    // 릴리스 빌드: 경고만 출력하고 AC 비트 제거 후 재실행
    #[cfg(debug_assertions)]
    panic!("Alignment violation detected");

    #[cfg(not(debug_assertions))]
    {
        println!("WARNING: Alignment violation, continuing...");
        // AC 플래그를 끄고 명령어 재실행
    }
}

설명

이것이 하는 일: 이 코드는 메모리 정렬 검사를 활성화하고, 정렬 위반을 감지하여 데이터 구조체나 메모리 접근 패턴의 문제를 찾아내는 메커니즘을 구현합니다. 첫 번째로, enable_alignment_check 함수가 정렬 검사를 활성화합니다.

CR0.AM(Alignment Mask) 비트를 설정하면 시스템 전체에서 정렬 검사 기능이 켜지고, RFLAGS.AC(Alignment Check) 비트를 설정하면 현재 태스크에 대해 실제로 검사가 수행됩니다. 두 비트가 모두 1이고 CPL=3(사용자 모드)일 때만 #AC가 발생합니다.

이는 디버그 빌드에서만 활성화하여 개발 중 문제를 찾는 데 유용합니다. 그 다음으로, alignment_check_handler가 호출되면 정렬 위반의 위치를 출력합니다.

instruction_pointer는 정렬되지 않은 접근을 시도한 명령어를 가리킵니다. 이 주소를 역어셈블하면 어떤 변수나 구조체 필드가 잘못 정렬되었는지 파악할 수 있습니다.

예를 들어, #[repr(packed)] 구조체를 사용하면 필드들이 정렬 없이 빽빽하게 배치되어 #AC를 유발할 수 있습니다. 마지막으로, 디버그와 릴리스 빌드에서 다르게 처리합니다.

디버그에서는 즉시 패닉하여 개발자가 문제를 인지하도록 하고, 릴리스에서는 경고만 출력하고 계속 진행할 수 있습니다. 일부 구현에서는 RFLAGS.AC를 끈 후 명령어를 재실행하여, 한 번만 경고하고 이후는 허용하는 방식을 사용합니다.

하지만 근본 원인을 수정하는 것이 최선입니다. 여러분이 이 코드를 사용하면 데이터 구조체 레이아웃 최적화, SIMD 코드의 정렬 요구사항 검증, 성능 저하 원인 파악 등을 효과적으로 수행할 수 있습니다.

특히 네트워크 패킷 파싱이나 바이너리 포맷 처리에서 정렬 문제가 자주 발생하므로, 개발 단계에서 #AC를 활성화하는 것이 좋습니다.

실전 팁

💡 Alignment Check는 Ring 3에서만 작동합니다. 커널 코드의 정렬 문제는 감지하지 못하므로, 커널 개발 시 별도의 정적 분석 도구를 사용하세요.

💡 MOVDQU(Unaligned), MOVDQA(Aligned) 같이 정렬 요구사항이 명시된 SIMD 명령어를 사용하세요. MOVDQA가 더 빠르지만 정렬이 보장될 때만 안전합니다.

💡 Rust의 #[repr(C)]는 C ABI 정렬을 따르고, #[repr(packed)]는 패딩을 제거합니다. #[repr(align(N))]로 명시적 정렬을 지정할 수 있습니다.

💡 일부 CPU는 정렬되지 않은 접근을 하드웨어적으로 처리하여 성능만 저하되지만, Atom 같은 저전력 CPU는 #AC를 발생시킬 수 있습니다. 테스트 환경을 다양화하세요.

💡 캐시 라인(보통 64바이트) 경계를 넘는 접근도 성능 저하를 유발합니다. #AC는 이를 감지하지 못하므로, 성능 크리티컬한 데이터는 #[repr(align(64))]로 정렬하세요.


#Rust#CPU예외#운영체제#인터럽트핸들러#x86-64#시스템프로그래밍

댓글 (0)

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