이미지 로딩 중...

Rust로 만드는 나만의 OS 인터럽트 핸들러 작성 - 슬라이드 1/11
A

AI Generated

2025. 11. 14. · 3 Views

Rust로 만드는 나만의 OS 인터럽트 핸들러 작성

OS 개발의 핵심인 인터럽트 핸들러를 Rust로 구현하는 방법을 배웁니다. CPU 예외 처리부터 IDT 설정, 안전한 핸들러 작성까지 실전 OS 개발에 필요한 모든 것을 다룹니다.


목차

  1. IDT 구조체 정의 - 인터럽트 디스크립터 테이블의 핵심
  2. Breakpoint 예외 핸들러 구현 - 디버깅의 시작점
  3. Double Fault 핸들러 - 시스템 안정성의 마지막 보루
  4. Page Fault 핸들러 - 메모리 관리의 핵심
  5. 타이머 인터럽트 핸들러 - 선점형 멀티태스킹의 심장
  6. 키보드 인터럽트 핸들러 - 사용자 입력의 시작
  7. 시스템콜 인터럽트 핸들러 - 사용자와 커널의 게이트
  8. IST를 활용한 스택 전환 - 예외 처리의 안전망
  9. 인터럽트 비활성화와 크리티컬 섹션 - 동시성 제어의 기본
  10. 예외 에러 코드 처리 - 정확한 진단 정보

1. IDT 구조체 정의 - 인터럽트 디스크립터 테이블의 핵심

시작하며

여러분이 OS를 개발하면서 CPU 예외나 하드웨어 인터럽트를 처리하려고 할 때, 갑자기 시스템이 트리플 폴트로 리부팅되는 상황을 겪어본 적 있나요? CPU가 예외를 발생시켰는데 어디로 점프해야 할지 모르는 상황입니다.

이런 문제는 실제 OS 개발 현장에서 가장 먼저 마주치는 난관입니다. CPU는 예외나 인터럽트가 발생하면 IDT(Interrupt Descriptor Table)를 참조하는데, 이것이 제대로 설정되지 않으면 시스템이 즉시 패닉에 빠집니다.

바로 이럴 때 필요한 것이 IDT 구조체입니다. Rust의 타입 안정성을 활용하여 컴파일 타임에 안전한 IDT를 구성할 수 있습니다.

개요

간단히 말해서, IDT는 CPU가 인터럽트나 예외 발생 시 어느 핸들러 함수를 실행할지 알려주는 테이블입니다. x86_64 아키텍처에서는 256개의 인터럽트 벡터를 지원하며, 각 벡터마다 16바이트 크기의 엔트리가 필요합니다.

예를 들어, 0번 벡터는 Division Error, 14번은 Page Fault 같은 CPU 예외들이 할당되어 있습니다. 기존 C 언어로 OS를 개발할 때는 수동으로 비트 필드를 조작해야 했다면, Rust에서는 타입 안전한 래퍼를 통해 훨씬 안전하게 관리할 수 있습니다.

IDT의 핵심 특징은 첫째, 각 엔트리가 핸들러 함수의 주소를 가리킨다는 점, 둘째, 권한 레벨(DPL)과 세그먼트 셀렉터를 포함한다는 점, 셋째, 인터럽트 게이트와 트랩 게이트 등 다양한 타입을 지원한다는 점입니다. 이러한 특징들이 OS의 안정성과 보안을 결정짓는 중요한 요소입니다.

코드 예제

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

// IDT를 정적 변수로 선언 (lazy_static 사용)
lazy_static! {
    static ref IDT: InterruptDescriptorTable = {
        let mut idt = InterruptDescriptorTable::new();

        // Breakpoint 예외 핸들러 등록 (벡터 3)
        idt.breakpoint.set_handler_fn(breakpoint_handler);

        // Double Fault 핸들러 등록 (벡터 8)
        unsafe {
            idt.double_fault
                .set_handler_fn(double_fault_handler)
                .set_stack_index(DOUBLE_FAULT_IST_INDEX);
        }

        idt
    };
}

설명

이것이 하는 일: CPU 예외와 인터럽트를 처리할 핸들러 함수들을 등록하고, CPU가 참조할 수 있도록 메모리에 테이블을 구성합니다. 첫 번째로, InterruptDescriptorTable::new()로 256개의 엔트리를 가진 빈 IDT를 생성합니다.

각 엔트리는 초기에는 아무것도 가리키지 않는 상태이며, 이후 set_handler_fn 메서드로 실제 핸들러 함수를 연결합니다. 이렇게 하는 이유는 필요한 예외만 선택적으로 처리할 수 있기 때문입니다.

두 번째로, breakpoint 필드에 핸들러를 설정합니다. idt.breakpoint는 CPU의 int3 명령어에 대응하는 벡터 3번 예외입니다.

set_handler_fn은 함수 포인터의 타입을 검증하여 잘못된 시그니처의 함수가 등록되는 것을 컴파일 타임에 방지합니다. 내부적으로는 함수 주소를 16바이트 엔트리의 여러 필드로 분할하여 저장합니다.

세 번째로, Double Fault 핸들러는 unsafe 블록 안에서 설정합니다. 왜냐하면 IST(Interrupt Stack Table) 인덱스를 설정하는 것은 잘못된 스택 주소를 가리킬 수 있어 위험하기 때문입니다.

Double Fault는 예외 처리 중 또 다른 예외가 발생할 때 트리거되므로, 별도의 안전한 스택을 사용해야 스택 오버플로우를 방지할 수 있습니다. 마지막으로, lazy_static! 매크로로 IDT를 정적 변수로 선언하여 프로그램 전체 수명 동안 유지되도록 합니다.

IDT의 주소는 lidt 명령어로 CPU에 전달되므로, 메모리 위치가 변하면 안 되기 때문입니다. 여러분이 이 코드를 사용하면 타입 안전하게 인터럽트 핸들러를 등록할 수 있으며, 잘못된 핸들러 시그니처나 유효하지 않은 벡터 번호 사용을 컴파일 타임에 방지할 수 있습니다.

또한 x86_64 크레이트가 제공하는 추상화 덕분에 복잡한 비트 조작 없이도 IDT를 구성할 수 있습니다.

실전 팁

💡 IDT는 반드시 정적 수명을 가져야 합니다. lazy_static!이나 static mut 대신 once_cell 크레이트를 사용하면 더 안전합니다.

💡 모든 예외를 처리할 필요는 없지만, 최소한 Double Fault는 꼭 등록하세요. 그렇지 않으면 트리플 폴트로 시스템이 리부팅됩니다.

💡 핸들러 함수의 시그니처가 정확히 extern "x86-interrupt" fn(InterruptStackFrame)이어야 합니다. 일반 Rust 함수와 호출 규약이 다릅니다.

💡 IDT를 수정한 후에는 반드시 lidt 명령어로 CPU에 다시 로드해야 합니다. 그렇지 않으면 변경사항이 적용되지 않습니다.

💡 디버깅 시에는 idt.debug_assertions()를 활성화하여 설정 오류를 빠르게 찾을 수 있습니다.


2. Breakpoint 예외 핸들러 구현 - 디버깅의 시작점

시작하며

여러분이 OS 커널을 디버깅하면서 특정 코드 지점에서 실행을 멈추고 상태를 확인하고 싶을 때 어떻게 하시나요? 일반 애플리케이션에서는 디버거의 브레이크포인트를 사용하지만, OS 개발에서는 직접 인터럽트 핸들러를 구현해야 합니다.

이런 문제는 베어메탈 환경에서 특히 심각합니다. printf 디버깅만으로는 복잡한 타이밍 이슈나 레이스 컨디션을 잡기 어렵고, 외부 디버거 연결도 항상 가능한 것은 아닙니다.

바로 이럴 때 필요한 것이 Breakpoint 예외 핸들러입니다. int3 명령어로 언제든 실행을 중단하고 시스템 상태를 출력하거나 검증할 수 있습니다.

개요

간단히 말해서, Breakpoint 핸들러는 CPU의 int3 명령어가 실행될 때 호출되는 특별한 함수입니다. x86 아키텍처에서 int3는 1바이트 명령어(0xCC)로, 디버거가 코드의 특정 지점에 삽입하여 실행을 중단시킵니다.

예를 들어, 페이지 테이블 설정 직후에 int3를 삽입하면 설정이 올바른지 확인할 수 있습니다. 기존에는 예외가 발생하면 시스템이 패닉하거나 리부팅되었다면, 이제는 핸들러를 통해 제어권을 유지하고 상태를 출력한 후 정상적으로 복귀할 수 있습니다.

Breakpoint 핸들러의 핵심 특징은 첫째, 실행을 재개할 수 있다는 점(트랩 게이트 사용), 둘째, 시스템 상태를 검사할 수 있다는 점, 셋째, 성능 오버헤드가 거의 없다는 점입니다. 이러한 특징들이 효과적인 커널 디버깅을 가능하게 합니다.

코드 예제

extern "x86-interrupt" fn breakpoint_handler(
    stack_frame: InterruptStackFrame
) {
    // 예외 발생 시점의 정보 출력
    println!("EXCEPTION: BREAKPOINT");
    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());

    // 선택적: 레지스터 덤프나 백트레이스 출력
    // dump_registers();
    // print_backtrace(&stack_frame);
}

설명

이것이 하는 일: CPU가 0xCC 바이트(int3 명령어)를 만나면 현재 실행 컨텍스트를 저장하고 이 핸들러로 점프합니다. 핸들러는 상태를 출력한 후 원래 위치로 돌아갑니다.

첫 번째로, 함수 시그니처에 extern "x86-interrupt"를 명시합니다. 이것은 특별한 호출 규약으로, CPU가 자동으로 레지스터를 스택에 푸시하고 핸들러 종료 시 iretq 명령어로 복귀하도록 컴파일러에 지시합니다.

일반 Rust 함수와 달리 인자 전달 방식과 스택 정리가 완전히 다릅니다. 두 번째로, InterruptStackFrame 파라미터를 통해 예외 발생 시점의 CPU 상태를 받습니다.

이 구조체는 명령어 포인터(RIP), 코드 세그먼트, CPU 플래그, 스택 포인터, 스택 세그먼트를 포함합니다. CPU가 예외를 발생시킬 때 자동으로 이 값들을 스택에 푸시하며, 핸들러는 이를 읽기만 하면 됩니다.

세 번째로, println! 매크로로 상태를 출력합니다. OS 개발에서는 표준 출력이 없으므로, 이것은 여러분이 구현한 VGA 버퍼나 시리얼 포트 드라이버를 사용합니다.

{:#x} 포맷으로 16진수 출력 시 0x 접두사가 자동으로 붙어 가독성이 높아집니다. 네 번째로, 추가 디버깅 정보를 원하면 주석 처리된 함수들을 활성화할 수 있습니다.

dump_registers()는 모든 범용 레지스터(RAX, RBX 등)를 출력하고, print_backtrace()는 호출 스택을 역추적하여 함수 체인을 보여줍니다. 마지막으로, 핸들러가 정상적으로 리턴하면 CPU는 iretq 명령어를 실행하여 스택 프레임을 복원하고 int3 다음 명령어부터 계속 실행합니다.

이것이 브레이크포인트가 실행 흐름을 중단시키지 않고도 디버깅을 가능하게 하는 핵심입니다. 여러분이 이 코드를 사용하면 커널 코드 어디서든 int3를 삽입하여 즉시 실행 컨텍스트를 확인할 수 있고, 복잡한 버그를 추적할 때 변수 값이나 레지스터 상태를 실시간으로 검증할 수 있습니다.

실전 팁

💡 int3는 싱글 바이트 명령어라서 멀티바이트 명령어를 덮어쓸 걱정이 없습니다. 안전하게 어디든 삽입 가능합니다.

💡 핸들러 내부에서 또 다른 예외를 발생시키지 않도록 주의하세요. 페이지 폴트 등이 발생하면 Double Fault로 이어질 수 있습니다.

💡 성능에 민감한 코드에서는 int3를 제거하거나 cfg! 매크로로 디버그 빌드에만 포함시키세요.

💡 QEMU나 Bochs 같은 에뮬레이터는 브레이크포인트에서 자동으로 멈추는 옵션이 있으니 활용하세요.

💡 핸들러에서 너무 많은 정보를 출력하면 화면이 스크롤되어 중요한 내용을 놓칠 수 있으니, 페이징이나 로그 버퍼 구현을 고려하세요.


3. Double Fault 핸들러 - 시스템 안정성의 마지막 보루

시작하며

여러분이 페이지 폴트 핸들러를 작성하는데, 핸들러 내부에서 스택 오버플로우가 발생한다면 어떻게 될까요? 예외 처리 중에 또 다른 예외가 발생하는 이 악몽 같은 상황이 바로 Double Fault입니다.

이런 문제는 실제 OS 개발에서 가장 위험한 시나리오 중 하나입니다. Double Fault를 처리하지 못하면 CPU는 Triple Fault 상태로 진입하여 즉시 리셋되고, 모든 디버깅 정보가 사라집니다.

바로 이럴 때 필요한 것이 별도의 스택을 사용하는 Double Fault 핸들러입니다. 기존 스택이 손상되었어도 안전하게 예외를 처리할 수 있습니다.

개요

간단히 말해서, Double Fault는 예외 핸들러 실행 중 또 다른 예외가 발생했을 때 CPU가 발생시키는 특별한 예외입니다. CPU는 특정 예외 조합에서 Double Fault를 트리거합니다.

예를 들어, Page Fault 처리 중 또 다른 Page Fault가 발생하거나, 인터럽트 핸들러의 스택이 유효하지 않을 때입니다. 이는 시스템이 회복 불가능한 상태에 빠졌음을 의미합니다.

기존에는 Double Fault 발생 시 즉시 Triple Fault로 리부팅되었다면, 이제는 IST(Interrupt Stack Table)를 활용해 별도의 깨끗한 스택에서 핸들러를 실행하여 최소한 에러 메시지라도 출력할 수 있습니다. Double Fault 핸들러의 핵심 특징은 첫째, 독립적인 스택을 사용한다는 점(IST 메커니즘), 둘째, 복귀가 불가능하다는 점(시스템 halt 필요), 셋째, 스택 프레임에 에러 코드가 포함된다는 점입니다.

이러한 특징들이 치명적 상황에서도 최소한의 정보를 남길 수 있게 합니다.

코드 예제

// Double Fault용 별도 스택 할당 (5개 페이지 = 20KB)
const DOUBLE_FAULT_IST_INDEX: u16 = 0;

static DOUBLE_FAULT_STACK: [u8; 4096 * 5] = [0; 4096 * 5];

extern "x86-interrupt" fn double_fault_handler(
    stack_frame: InterruptStackFrame,
    error_code: u64,
) -> ! {
    // 치명적 오류 정보 출력
    println!("\n!!! EXCEPTION: DOUBLE FAULT !!!");
    println!("Error Code: {:#x}", error_code);
    println!("Stack Frame: {:#?}", stack_frame);

    // 추가 진단 정보
    println!("CR2 (Page Fault Address): {:#x}", read_cr2());

    // 시스템 중지 (복구 불가능)
    loop {
        x86_64::instructions::hlt();
    }
}

설명

이것이 하는 일: 예외 핸들러가 실패했을 때 마지막 방어선으로 작동하여, 독립적인 스택에서 진단 정보를 출력하고 시스템을 안전하게 중지시킵니다. 첫 번째로, DOUBLE_FAULT_STACK 정적 배열로 20KB 크기의 별도 스택을 할당합니다.

이 스택은 TSS(Task State Segment)의 IST 테이블에 등록되어, Double Fault 발생 시 CPU가 자동으로 이 스택으로 전환합니다. 왜 별도 스택이 필요한가?

기존 스택이 오버플로우되었거나 손상되었을 수 있기 때문입니다. 일반 스택을 사용하면 핸들러 자체가 실패할 위험이 큽니다.

두 번째로, 핸들러 시그니처에 두 번째 파라미터 error_code: u64가 추가됩니다. CPU가 Double Fault를 발생시킬 때 스택에 자동으로 푸시하는 에러 코드입니다.

대부분의 경우 0이지만, 특정 상황에서는 원인을 파악하는 힌트를 제공합니다. 그리고 반환 타입이 -> !(never type)인데, 이는 이 함수가 절대 리턴하지 않음을 컴파일러에 알립니다.

세 번째로, stack_frame{:#?} 포맷으로 출력하여 모든 필드를 상세하게 표시합니다. 여기에는 예외 발생 시점의 RIP, RSP, RFLAGS 등이 포함되어 어느 코드에서 문제가 시작되었는지 추적할 수 있습니다.

CR2 레지스터 읽기는 선택사항이지만, Page Fault 관련 Double Fault에서는 잘못된 메모리 주소를 알려줍니다. 네 번째로, 무한 루프에서 hlt 명령어를 반복 실행합니다.

hlt는 CPU를 저전력 모드로 전환하여 다음 인터럽트까지 대기시킵니다. 단순 루프(loop {})보다 에너지 효율적이며, 시스템을 완전히 정지시켜 추가 손상을 방지합니다.

마지막으로, IDT 설정 시 set_stack_index(DOUBLE_FAULT_IST_INDEX)로 이 핸들러가 IST의 0번 엔트리를 사용하도록 지정합니다. 이 작업은 unsafe인데, 잘못된 스택 주소나 크기를 지정하면 또 다른 예외를 발생시킬 수 있기 때문입니다.

여러분이 이 코드를 사용하면 스택 오버플로우나 핸들러 체이닝 오류가 발생해도 시스템이 조용히 리부팅되는 대신, 명확한 에러 메시지와 진단 정보를 남길 수 있습니다. 이것은 디버깅 시간을 몇 시간에서 몇 분으로 단축시켜줍니다.

실전 팁

💡 IST 스택 크기는 최소 4KB 이상 권장됩니다. 핸들러 내부에서 복잡한 작업을 하면 스택 오버플로우가 재발할 수 있습니다.

💡 Double Fault에서는 절대 panic!을 호출하지 마세요. Panic 핸들러가 또 다른 예외를 유발하면 Triple Fault입니다.

💡 TSS와 GDT 설정을 올바르게 해야 IST가 작동합니다. x86_64 크레이트의 예제 코드를 참고하세요.

💡 QEMU에서 -d int,cpu_reset 옵션을 사용하면 Triple Fault 직전 CPU 상태를 덤프할 수 있습니다.

💡 실제 하드웨어에서는 시리얼 포트로 로그를 출력하면 화면이 깨져도 정보를 확인할 수 있습니다.


4. Page Fault 핸들러 - 메모리 관리의 핵심

시작하며

여러분의 OS가 사용자 프로그램을 실행하는데 갑자기 멈춰버린다면? 아마도 Page Fault가 발생했는데 핸들러가 없어서 패닉했을 것입니다.

가상 메모리를 사용하는 모든 OS에서 Page Fault는 매 순간 발생하는 정상적인 이벤트입니다. 이런 문제는 페이징이 활성화된 순간부터 피할 수 없습니다.

프로세스가 아직 로드되지 않은 메모리에 접근하거나, 권한이 없는 영역을 건드리면 CPU가 즉시 Page Fault를 발생시킵니다. 바로 이럴 때 필요한 것이 Page Fault 핸들러입니다.

정상적인 Demand Paging인지, 아니면 실제 버그인지 판단하여 적절히 처리할 수 있습니다.

개요

간단히 말해서, Page Fault는 프로세스가 접근하려는 가상 주소가 물리 메모리에 매핑되지 않았거나 접근 권한이 없을 때 발생하는 예외입니다. CPU는 Page Fault 발생 시 CR2 레지스터에 문제의 가상 주소를 저장하고, 에러 코드를 스택에 푸시합니다.

예를 들어, 사용자 모드 프로그램이 커널 메모리에 쓰기를 시도하면 Protection Fault가 발생합니다. 기존에는 모든 Page Fault를 에러로 처리했다면, 현대 OS에서는 이것을 Copy-on-Write, Demand Paging, Swap 등 고급 메모리 관리 기법의 기반으로 활용합니다.

Page Fault 핸들러의 핵심 특징은 첫째, CR2 레지스터로 잘못된 주소를 알 수 있다는 점, 둘째, 에러 코드로 원인을 분류할 수 있다는 점(Present, Write, User 비트), 셋째, 핸들러가 페이지를 매핑한 후 재시도 가능하다는 점입니다. 이러한 특징들이 효율적인 메모리 관리를 가능하게 합니다.

코드 예제

use x86_64::structures::idt::PageFaultErrorCode;
use x86_64::registers::control::Cr2;

extern "x86-interrupt" fn page_fault_handler(
    stack_frame: InterruptStackFrame,
    error_code: PageFaultErrorCode,
) {
    // 문제의 가상 주소 읽기
    let faulting_address = Cr2::read();

    println!("\nEXCEPTION: PAGE FAULT");
    println!("Accessed Address: {:?}", faulting_address);
    println!("Error Code: {:?}", error_code);

    // 에러 원인 분석
    if error_code.contains(PageFaultErrorCode::PROTECTION_VIOLATION) {
        println!("  -> Protection violation (page present)");
    } else {
        println!("  -> Page not present");
    }

    if error_code.contains(PageFaultErrorCode::CAUSED_BY_WRITE) {
        println!("  -> Occurred during write operation");
    }

    if error_code.contains(PageFaultErrorCode::USER_MODE) {
        println!("  -> User mode access");
    }

    // TODO: 실제로는 페이지 할당 또는 프로세스 종료
    panic!("Unhandled page fault at {:?}", faulting_address);
}

설명

이것이 하는 일: 가상 주소 접근이 실패한 원인을 분석하고, Demand Paging 같은 정상 케이스는 처리하며, 진짜 버그는 프로세스를 종료시킵니다. 첫 번째로, Cr2::read()로 CPU의 CR2 레지스터를 읽습니다.

CPU는 Page Fault 발생 즉시 문제가 된 가상 주소를 이 레지스터에 자동으로 저장합니다. 이것은 핸들러가 어느 주소가 문제인지 정확히 알 수 있게 하는 x86 아키텍처의 핵심 기능입니다.

두 번째로, PageFaultErrorCode 타입의 에러 코드를 분석합니다. 이것은 비트 플래그로, PROTECTION_VIOLATION 비트가 설정되면 페이지는 존재하지만 권한이 없다는 뜻입니다(읽기 전용 페이지에 쓰기 시도 등).

설정되지 않았다면 페이지 테이블에 아예 엔트리가 없다는 의미입니다. 세 번째로, CAUSED_BY_WRITE 비트를 확인하여 읽기와 쓰기 접근을 구분합니다.

이것은 Copy-on-Write 구현에 중요한데, 읽기 전용으로 공유되던 페이지에 쓰기가 발생하면 새 복사본을 할당해야 하기 때문입니다. USER_MODE 비트는 커널 모드 접근인지 사용자 모드 접근인지 알려줍니다.

네 번째로, 실제 프로덕션 OS에서는 panic! 대신 복잡한 로직이 들어갑니다. Demand Paging이라면 디스크에서 페이지를 로드하고, Stack growth라면 새 페이지를 할당하며, 진짜 버그라면 SIGSEGV 시그널을 보내거나 프로세스를 종료합니다.

이 예제는 단순화를 위해 모두 패닉으로 처리합니다. 마지막으로, 핸들러가 페이지를 성공적으로 매핑했다면 정상 리턴하여 CPU가 같은 명령어를 재실행하도록 합니다.

두 번째 시도에서는 페이지가 존재하므로 성공합니다. 이것이 Demand Paging의 핵심 메커니즘입니다.

여러분이 이 코드를 사용하면 가상 메모리 관련 모든 문제를 중앙에서 처리할 수 있고, 에러 코드 분석으로 Copy-on-Write, Swap, Stack 확장 등 고급 기법을 구현할 기반을 마련할 수 있습니다. 또한 사용자 프로그램의 잘못된 메모리 접근을 안전하게 처리하여 커널을 보호할 수 있습니다.

실전 팁

💡 Page Fault는 초당 수천~수만 번 발생하는 핫패스입니다. 핸들러는 최대한 빠르게 작성하세요.

💡 핸들러 내부에서 페이지 할당 시 재귀적 Page Fault가 발생할 수 있습니다. 메모리 할당자를 Page Fault 안전하게 설계하세요.

💡 Copy-on-Write 구현 시 페이지 테이블의 Write 비트를 끄고 참조 카운터를 유지하면 메모리를 크게 절약할 수 있습니다.

💡 커널 모드에서 발생한 Page Fault는 매우 심각합니다. 패닉하거나 최소한 상세 로그를 남기세요.

💡 QEMU의 -d page 옵션으로 모든 페이지 테이블 변경을 추적하면 디버깅에 유용합니다.


5. 타이머 인터럽트 핸들러 - 선점형 멀티태스킹의 심장

시작하며

여러분의 OS에서 프로그램 하나가 무한 루프에 빠졌는데, 다른 프로그램들은 멀쩡히 실행되고 있다면? 그것이 바로 선점형 멀티태스킹의 마법이며, 이를 가능하게 하는 것이 타이머 인터럽트입니다.

이런 문제는 협력형 멀티태스킹(cooperative multitasking)에서 치명적입니다. 한 프로세스가 CPU를 양보하지 않으면 시스템 전체가 멈춥니다.

실시간 시스템에서는 더욱 심각합니다. 바로 이럴 때 필요한 것이 주기적으로 발생하는 타이머 인터럽트 핸들러입니다.

강제로 CPU를 빼앗아 스케줄러를 실행시킬 수 있습니다.

개요

간단히 말해서, 타이머 인터럽트는 하드웨어 타이머가 주기적으로(보통 1~10ms마다) 발생시키는 인터럽트로, OS에게 스케줄링 기회를 제공합니다. x86 시스템에서는 주로 PIT(Programmable Interval Timer), APIC Timer, HPET(High Precision Event Timer) 등을 사용합니다.

예를 들어, 10ms마다 인터럽트를 발생시키도록 PIT를 프로그래밍하면 초당 100번의 컨텍스트 스위칭 기회를 얻습니다. 기존에는 프로그램이 자발적으로 CPU를 양보해야 했다면, 이제는 타이머가 강제로 제어권을 OS에게 돌려줍니다.

이것이 현대 모든 OS의 기본입니다. 타이머 인터럽트 핸들러의 핵심 특징은 첫째, 예측 가능한 주기성을 가진다는 점, 둘째, 하드웨어에서 발생하므로 마스킹 가능하다는 점(APIC 사용), 셋째, 시스템 시간 측정과 스케줄링 두 가지 역할을 한다는 점입니다.

이러한 특징들이 공정한 CPU 시간 분배를 보장합니다.

코드 예제

use pic8259::ChainedPics;

// PIC 인터럽트 오프셋 (32부터 시작)
pub const PIC_1_OFFSET: u8 = 32;
pub const TIMER_INTERRUPT_ID: u8 = PIC_1_OFFSET;

static mut TICKS: u64 = 0;

extern "x86-interrupt" fn timer_interrupt_handler(
    _stack_frame: InterruptStackFrame
) {
    unsafe {
        TICKS += 1;

        // 10ms * 100 = 1초마다 로그
        if TICKS % 100 == 0 {
            println!("Timer tick: {} seconds", TICKS / 100);
        }
    }

    // TODO: 스케줄러 호출
    // scheduler::yield_cpu();

    // PIC에 EOI(End Of Interrupt) 신호 전송 (중요!)
    unsafe {
        PICS.lock()
            .notify_end_of_interrupt(TIMER_INTERRUPT_ID);
    }
}

설명

이것이 하는 일: 하드웨어 타이머가 설정된 주기마다 이 핸들러를 호출하여 시간을 갱신하고, 선점형 멀티태스킹을 위해 스케줄러를 실행시킵니다. 첫 번째로, TICKS 전역 변수를 증가시켜 부팅 이후 경과 시간을 추적합니다.

이것은 static mut이므로 unsafe 블록이 필요한데, 멀티코어 환경에서는 AtomicU64를 사용해야 안전합니다. 틱 카운터는 시스템 업타임, 프로파일링, 타임아웃 구현 등 다양한 곳에 활용됩니다.

두 번째로, 100틱마다(10ms * 100 = 1초) 로그를 출력합니다. 실제 OS에서는 이런 출력을 하지 않지만, 개발 단계에서는 타이머가 올바르게 작동하는지 확인하는 데 유용합니다.

너무 자주 출력하면 성능에 영향을 줍니다. 세 번째로, 주석 처리된 scheduler::yield_cpu() 호출이 핵심입니다.

스케줄러는 현재 프로세스의 타임 슬라이스를 확인하고, 소진되었다면 컨텍스트 스위칭으로 다음 프로세스로 전환합니다. 이것이 무한 루프에 빠진 프로세스로부터 CPU를 빼앗는 메커니즘입니다.

네 번째로, notify_end_of_interrupt()로 PIC(Programmable Interrupt Controller)에 EOI 신호를 보냅니다. 이것을 빼먹으면 PIC가 "아직 인터럽트 처리 중"이라고 판단하여 더 이상 타이머 인터럽트를 전달하지 않습니다.

EOI는 인터럽트 처리 완료 후 반드시 전송해야 하는 하드웨어 프로토콜입니다. 마지막으로, 핸들러가 리턴하면 CPU는 인터럽트되었던 코드로 돌아갑니다.

스케줄러가 컨텍스트 스위칭을 했다면 다른 프로세스의 코드가 실행되고, 그렇지 않다면 원래 프로세스가 계속됩니다. 이 투명성이 선점형 멀티태스킹의 장점입니다.

여러분이 이 코드를 사용하면 프로세스들에게 공정하게 CPU 시간을 분배할 수 있고, 시스템 시간 측정이 가능하며, 슬립이나 타이머 같은 시간 기반 기능을 구현할 기반을 마련할 수 있습니다.

실전 팁

💡 타이머 주기는 트레이드오프입니다. 짧으면 반응성이 좋지만 오버헤드가 크고, 길면 그 반대입니다. 보통 1~10ms를 사용합니다.

💡 핸들러는 매우 자주 호출되므로 절대 무거운 작업을 하지 마세요. 최소한의 로직만 두고 나머지는 별도 태스크로 스케줄링하세요.

💡 EOI를 보내는 타이밍이 중요합니다. 너무 일찍 보내면 핸들러가 재진입될 수 있고, 너무 늦으면 인터럽트가 밀립니다.

💡 멀티코어 시스템에서는 각 CPU마다 로컬 APIC 타이머를 사용하는 것이 확장성이 좋습니다.

💡 고정밀 시간 측정이 필요하면 RDTSC 명령어로 CPU 사이클 카운터를 읽으세요. 나노초 단위 정확도를 제공합니다.


6. 키보드 인터럽트 핸들러 - 사용자 입력의 시작

시작하며

여러분의 OS에서 사용자가 키를 눌렀을 때 어떻게 감지하시나요? 폴링 방식으로 계속 확인한다면 CPU를 낭비하고 있는 것입니다.

인터럽트 방식이라면 키가 눌렸을 때만 반응합니다. 이런 문제는 I/O 디바이스 처리에서 보편적입니다.

폴링은 간단하지만 비효율적이고, 인터럽트는 복잡하지만 CPU를 효율적으로 사용합니다. 바로 이럴 때 필요한 것이 키보드 인터럽트 핸들러입니다.

키보드 컨트롤러가 키 입력을 감지하면 자동으로 인터럽트를 발생시켜 핸들러를 호출합니다.

개요

간단히 말해서, 키보드 인터럽트는 사용자가 키를 누르거나 뗄 때 PS/2 키보드 컨트롤러가 발생시키는 하드웨어 인터럽트입니다. x86 시스템에서 키보드는 IRQ 1번에 연결되어 있으며, PIC를 통해 CPU에 전달됩니다.

예를 들어, 'A' 키를 누르면 스캔코드 0x1E가 포트 0x60에 쓰여지고 인터럽트가 발생합니다. 기존에는 루프를 돌면서 키보드 상태를 주기적으로 확인했다면, 이제는 키 이벤트가 발생할 때만 핸들러가 실행되어 CPU 사이클을 절약합니다.

키보드 인터럽트 핸들러의 핵심 특징은 첫째, 비동기적으로 발생한다는 점(언제 키가 눌릴지 예측 불가), 둘째, 스캔코드를 ASCII로 변환해야 한다는 점, 셋째, Make/Break 코드를 구분해야 한다는 점(눌림/뗌)입니다. 이러한 특징들이 반응적인 사용자 인터페이스를 가능하게 합니다.

코드 예제

use x86_64::instructions::port::Port;
use pc_keyboard::{layouts, DecodedKey, HandleControl, Keyboard, ScancodeSet1};
use spin::Mutex;

lazy_static! {
    static ref KEYBOARD: Mutex<Keyboard<layouts::Us104Key, ScancodeSet1>> = {
        Mutex::new(Keyboard::new(
            ScancodeSet1::new(),
            layouts::Us104Key,
            HandleControl::Ignore
        ))
    };
}

extern "x86-interrupt" fn keyboard_interrupt_handler(
    _stack_frame: InterruptStackFrame
) {
    // 포트 0x60에서 스캔코드 읽기
    let mut port = Port::new(0x60);
    let scancode: u8 = unsafe { port.read() };

    // 스캔코드를 키 이벤트로 변환
    let mut keyboard = KEYBOARD.lock();
    if let Ok(Some(key_event)) = keyboard.add_byte(scancode) {
        if let Some(key) = keyboard.process_keyevent(key_event) {
            match key {
                DecodedKey::Unicode(character) => print!("{}", character),
                DecodedKey::RawKey(key) => print!("{:?}", key),
            }
        }
    }

    // EOI 전송
    unsafe {
        PICS.lock().notify_end_of_interrupt(KEYBOARD_INTERRUPT_ID);
    }
}

설명

이것이 하는 일: 키보드에서 발생한 스캔코드를 읽어서 눌러진 키를 파악하고, 문자 입력이면 화면에 출력하거나 버퍼에 저장합니다. 첫 번째로, Port::new(0x60)으로 I/O 포트 객체를 생성합니다.

x86 아키텍처에서 키보드 데이터 포트는 0x60번이 표준이며, port.read()로 한 바이트 스캔코드를 읽습니다. 이것은 unsafe인데, 잘못된 포트 주소를 읽으면 하드웨어 오동작을 일으킬 수 있기 때문입니다.

두 번째로, 전역 KEYBOARD 객체의 락을 획득합니다. pc_keyboard 크레이트의 상태 머신은 이전 스캔코드를 기억하여 멀티바이트 시퀀스를 처리하므로, 여러 인터럽트 간에 상태를 유지해야 합니다.

Mutex는 멀티코어 환경에서도 안전하게 접근을 직렬화합니다. 세 번째로, add_byte()로 스캔코드를 파서에 전달합니다.

PS/2 키보드는 스캔코드 세트 1을 사용하는데, 일부 키(화살표, F키 등)는 0xE0 접두사가 붙은 2바이트 시퀀스입니다. 파서가 완전한 키 이벤트를 조립하면 Some(key_event)를 반환합니다.

네 번째로, process_keyevent()로 키 이벤트를 실제 키로 변환합니다. 이 단계에서 Shift, Caps Lock 등의 모디파이어가 적용되고, 레이아웃(US, UK 등)에 따라 최종 문자가 결정됩니다.

DecodedKey::Unicode는 출력 가능한 문자이고, RawKey는 Ctrl, Alt 같은 특수키입니다. 마지막으로, EOI를 전송하여 다음 키보드 인터럽트를 활성화합니다.

EOI 전송을 빼먹으면 첫 키 입력만 받고 이후 입력이 무시됩니다. print! 매크로는 여러분의 VGA 드라이버로 화면에 출력하지만, 실제 OS에서는 입력 버퍼에 저장하여 애플리케이션이 read()로 읽을 수 있게 합니다.

여러분이 이 코드를 사용하면 효율적으로 사용자 입력을 처리할 수 있고, 쉘이나 텍스트 에디터 같은 인터랙티브 프로그램을 구현할 기반을 마련할 수 있습니다. 또한 스캔코드 파싱의 복잡함을 크레이트에 위임하여 버그를 줄일 수 있습니다.

실전 팁

💡 키보드 버퍼 오버플로우를 방지하려면 입력을 빠르게 처리하거나 링 버퍼를 사용하세요.

💡 USB 키보드는 완전히 다른 프로토콜입니다. xHCI 드라이버가 필요하며 훨씬 복잡합니다.

💡 포트 0x64는 키보드 컨트롤러의 상태/명령 레지스터입니다. LED 제어 등에 사용됩니다.

💡 인터럽트 핸들러 내부에서 블로킹 작업을 하지 마세요. 입력을 큐에 넣고 별도 태스크에서 처리하세요.

💡 보안에 민감한 경우 키로거 방지를 위해 키 입력을 암호화하거나 보안 입력 모드를 구현하세요.


7. 시스템콜 인터럽트 핸들러 - 사용자와 커널의 게이트

시작하며

여러분의 OS에서 사용자 프로그램이 파일을 열거나 네트워크 패킷을 보내려면 어떻게 해야 할까요? 직접 하드웨어에 접근하면 보안 문제가 발생합니다.

시스템콜 인터럽트를 통해 안전하게 커널 서비스를 요청할 수 있습니다. 이런 문제는 권한 분리가 필수인 현대 OS의 근본적인 과제입니다.

사용자 모드에서는 제한된 명령어만 실행 가능하고, 특권 작업은 커널 모드로 전환해야 합니다. 바로 이럴 때 필요한 것이 시스템콜 인터럽트 핸들러입니다.

syscall 명령어나 소프트웨어 인터럽트로 안전하게 권한 레벨을 전환하고 커널 함수를 호출합니다.

개요

간단히 말해서, 시스템콜 인터럽트는 사용자 프로그램이 커널 서비스를 요청할 때 사용하는 제어된 진입점입니다. 전통적으로 Linux는 int 0x80 인터럽트를 사용했고, 현대 x86_64는 syscall 명령어를 사용합니다.

예를 들어, C 표준 라이브러리의 read() 함수는 내부적으로 syscall 번호 0을 레지스터에 담아 커널을 호출합니다. 기존에는 사용자 프로그램이 하드웨어에 직접 접근했다면(DOS 시대), 이제는 모든 I/O와 리소스 관리를 커널이 중재하여 격리와 보안을 보장합니다.

시스템콜 인터럽트 핸들러의 핵심 특징은 첫째, 링 3(사용자)에서 링 0(커널)로 권한 상승이 일어난다는 점, 둘째, 레지스터로 인자를 전달한다는 점, 셋째, 시스템콜 번호로 디스패칭한다는 점입니다. 이러한 특징들이 안전한 커널-유저 인터페이스를 제공합니다.

코드 예제

// 시스템콜 벡터 (Linux는 0x80 사용)
const SYSCALL_VECTOR: u8 = 0x80;

extern "x86-interrupt" fn syscall_handler(
    mut stack_frame: InterruptStackFrame
) {
    // 레지스터에서 시스템콜 번호와 인자 읽기
    // RAX: syscall 번호
    // RDI, RSI, RDX, R10, R8, R9: 인자 1~6
    let syscall_number: u64;
    let arg1: u64;
    let arg2: u64;

    unsafe {
        asm!(
            "mov {}, rax",
            "mov {}, rdi",
            "mov {}, rsi",
            out(reg) syscall_number,
            out(reg) arg1,
            out(reg) arg2,
        );
    }

    // 시스템콜 디스패치
    let result = match syscall_number {
        0 => sys_read(arg1, arg2),      // read(fd, buf)
        1 => sys_write(arg1, arg2),     // write(fd, buf)
        2 => sys_open(arg1),             // open(path)
        60 => sys_exit(arg1),            // exit(code)
        _ => Err(EINVAL),                // 잘못된 syscall
    };

    // 결과를 RAX에 저장 (리턴값)
    // stack_frame.rax = result;
}

설명

이것이 하는 일: 사용자 모드에서 발생한 소프트웨어 인터럽트를 받아 시스템콜 번호를 확인하고, 대응하는 커널 함수를 실행한 후 결과를 사용자에게 반환합니다. 첫 번째로, 인라인 어셈블리로 CPU 레지스터를 읽습니다.

x86_64 System V ABI에 따르면 RAX에 시스템콜 번호를, RDI/RSI/RDX/R10/R8/R9에 최대 6개의 인자를 담습니다. InterruptStackFrame에는 일부 레지스터만 포함되므로, 나머지는 직접 읽어야 합니다.

두 번째로, match 표현식으로 시스템콜 번호에 따라 적절한 핸들러 함수를 호출합니다. 각 시스템콜 함수(sys_read, sys_write 등)는 실제 커널 로직을 구현하며, 파일 디스크립터 검증, 권한 확인, 하드웨어 조작 등을 수행합니다.

이것을 시스템콜 디스패칭이라고 합니다. 세 번째로, 알 수 없는 시스템콜 번호는 EINVAL 에러를 반환합니다.

이것은 사용자 프로그램이 잘못된 요청을 하더라도 커널이 패닉하지 않고 우아하게 에러를 전달하는 방법입니다. 실제 Linux는 300개 이상의 시스템콜을 지원합니다.

네 번째로, 핸들러 함수의 반환값을 RAX 레지스터에 저장해야 합니다. 성공 시 0 또는 결과 값을, 실패 시 음수 에러 코드를 반환하는 것이 Unix 관례입니다.

InterruptStackFrame을 수정하거나 인라인 어셈블리로 직접 설정할 수 있습니다. 마지막으로, 핸들러가 리턴하면 CPU는 자동으로 사용자 모드로 권한 레벨을 낮추고 인터럽트 지점 다음 명령어부터 실행합니다.

이 투명한 전환이 사용자 프로그램으로 하여금 시스템콜을 일반 함수처럼 느끼게 합니다. 여러분이 이 코드를 사용하면 사용자 프로그램에게 안전한 커널 서비스 인터페이스를 제공할 수 있고, 파일 시스템, 네트워킹, 프로세스 관리 등 모든 OS 기능을 시스템콜로 노출할 수 있습니다.

또한 권한 검사와 인자 검증으로 악의적인 프로그램으로부터 커널을 보호할 수 있습니다.

실전 팁

💡 현대 CPU는 syscall/sysret 명령어가 int/iret보다 훨씬 빠릅니다. MSR 레지스터로 설정하세요.

💡 사용자가 전달한 포인터는 절대 신뢰하지 마세요. 커널 주소 공간을 가리키는지, 권한이 있는지 검증해야 합니다.

💡 시스템콜 테이블을 함수 포인터 배열로 만들면 match 대신 배열 인덱싱으로 더 빠르게 디스패치할 수 있습니다.

💡 너무 많은 기능을 하나의 시스템콜에 넣지 마세요. ioctl 같은 만능 시스템콜은 유지보수 악몽입니다.

💡 시스템콜 성능은 critical합니다. 불필요한 복사를 줄이고, 자주 사용되는 시스템콜은 vDSO로 유저 공간에서 직접 실행하세요.


8. IST를 활용한 스택 전환 - 예외 처리의 안전망

시작하며

여러분의 커널 스택이 오버플로우되어 가드 페이지에 닿았는데, Page Fault 핸들러가 같은 스택을 사용하려고 한다면? 즉시 Double Fault가 발생하고 시스템이 리부팅됩니다.

이런 문제는 스택 오버플로우가 가장 흔한 커널 버그 중 하나이기 때문에 심각합니다. 재귀 함수나 큰 로컬 변수, 무한 인터럽트 루프 등이 원인입니다.

바로 이럴 때 필요한 것이 IST(Interrupt Stack Table) 메커니즘입니다. 특정 예외 핸들러가 별도의 안전한 스택을 사용하도록 강제할 수 있습니다.

개요

간단히 말해서, IST는 TSS(Task State Segment) 안에 있는 7개의 추가 스택 포인터 배열로, 특정 인터럽트 발생 시 자동으로 스택을 전환하는 x86_64 기능입니다. 각 IDT 엔트리에는 IST 인덱스 필드(0~7)가 있으며, 0이 아닌 값으로 설정하면 CPU가 해당 IST 엔트리의 스택으로 전환합니다.

예를 들어, Double Fault 핸들러에 IST 인덱스 1을 설정하면 항상 깨끗한 스택에서 실행됩니다. 기존에는 모든 핸들러가 인터럽트 발생 시점의 스택을 공유했다면, 이제는 중요한 핸들러(Double Fault, NMI, Machine Check)에 독립 스택을 할당하여 안정성을 크게 높일 수 있습니다.

IST 메커니즘의 핵심 특징은 첫째, 하드웨어 레벨 지원이라 오버헤드가 거의 없다는 점, 둘째, 스택 오버플로우에도 안전하다는 점, 셋째, 최대 7개의 독립 스택을 지원한다는 점입니다. 이러한 특징들이 견고한 예외 처리를 보장합니다.

코드 예제

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

pub const DOUBLE_FAULT_IST_INDEX: u16 = 0;
pub const PAGE_FAULT_IST_INDEX: u16 = 1;

// 각 IST 엔트리용 스택 (16KB)
static mut DOUBLE_FAULT_STACK: [u8; 4096 * 4] = [0; 4096 * 4];
static mut PAGE_FAULT_STACK: [u8; 4096 * 4] = [0; 4096 * 4];

lazy_static! {
    static ref TSS: TaskStateSegment = {
        let mut tss = TaskStateSegment::new();

        // IST 엔트리 설정
        tss.interrupt_stack_table[DOUBLE_FAULT_IST_INDEX as usize] = {
            let stack_start = VirtAddr::from_ptr(unsafe { &DOUBLE_FAULT_STACK });
            let stack_end = stack_start + (4096 * 4);
            stack_end  // 스택은 위에서 아래로 자라므로 끝 주소 저장
        };

        tss.interrupt_stack_table[PAGE_FAULT_IST_INDEX as usize] = {
            let stack_start = VirtAddr::from_ptr(unsafe { &PAGE_FAULT_STACK });
            let stack_end = stack_start + (4096 * 4);
            stack_end
        };

        tss
    };
}

// IDT 설정 시 IST 인덱스 지정
idt.double_fault
    .set_handler_fn(double_fault_handler)
    .set_stack_index(DOUBLE_FAULT_IST_INDEX);

설명

이것이 하는 일: 치명적인 예외(Double Fault, NMI 등)에 독립적인 스택을 할당하여, 기존 스택이 손상되어도 핸들러가 안전하게 실행되도록 보장합니다. 첫 번째로, 각 IST 엔트리용 정적 배열을 선언합니다.

16KB 크기는 대부분의 핸들러에 충분하며, 너무 크면 메모리 낭비이고 너무 작으면 핸들러가 스택 오버플로우를 일으킬 수 있습니다. static mut이므로 메모리 위치가 고정되어 CPU가 안전하게 참조할 수 있습니다.

두 번째로, TaskStateSegment를 생성하고 interrupt_stack_table 배열에 스택 주소를 설정합니다. 주의할 점은 스택의 끝 주소(높은 주소)를 저장해야 한다는 것입니다.

x86 스택은 높은 주소에서 낮은 주소로 자라므로(스택 푸시 시 RSP 감소), CPU는 이 주소에서 시작하여 아래로 자랍니다. 세 번째로, VirtAddr::from_ptr로 배열의 주소를 가져옵니다.

Rust의 타입 시스템 덕분에 잘못된 주소 계산을 컴파일 타임에 방지할 수 있습니다. stack_start + (4096 * 4)로 스택의 끝을 계산하는데, 이것은 바이트 단위 덧셈입니다.

네 번째로, IDT 엔트리 설정 시 set_stack_index()로 IST 인덱스를 연결합니다. 이제 Double Fault가 발생하면 CPU는 자동으로 RSP를 TSS.interrupt_stack_table[0]의 값으로 교체하고 핸들러를 호출합니다.

스택 전환은 하드웨어가 자동으로 수행하므로 소프트웨어 오버헤드가 없습니다. 마지막으로, TSS를 GDT(Global Descriptor Table)에 등록하고 ltr 명령어로 CPU에 로드해야 IST가 활성화됩니다.

이 부분은 부팅 초기화 코드에서 한 번만 수행하면 됩니다. 여러분이 이 코드를 사용하면 커널 스택 오버플로우가 발생해도 최소한 에러 메시지를 출력하고 우아하게 종료할 수 있으며, NMI나 Machine Check 같은 비마스킹 인터럽트도 안전하게 처리할 수 있습니다.

이것은 프로덕션 OS의 필수 안정성 기능입니다.

실전 팁

💡 모든 예외에 IST를 사용하지 마세요. 스택 전환 비용은 거의 없지만, 메모리는 한정적입니다. Double Fault, NMI, Machine Check만 권장합니다.

💡 IST 스택도 오버플로우될 수 있습니다. 가드 페이지를 할당하여 감지하거나, 핸들러를 최대한 간결하게 작성하세요.

💡 멀티코어 시스템에서는 각 CPU마다 별도의 TSS와 IST 스택이 필요합니다. Per-CPU 데이터 구조를 활용하세요.

💡 디버깅 시 IST 스택 포인터를 출력하면 올바른 스택을 사용하는지 확인할 수 있습니다.

💡 UEFI 환경에서는 부팅 시 임시 스택이 제공되지만, 페이징 활성화 전에 IST를 설정해야 안전합니다.


9. 인터럽트 비활성화와 크리티컬 섹션 - 동시성 제어의 기본

시작하며

여러분의 커널에서 데이터 구조를 수정하는 중에 타이머 인터럽트가 발생하여 같은 데이터에 접근한다면? 레이스 컨디션으로 데이터가 손상되고 시스템이 불안정해집니다.

이런 문제는 멀티태스킹 OS에서 피할 수 없는 근본적인 동시성 문제입니다. 인터럽트는 언제든 비동기적으로 발생하므로, 공유 데이터 접근을 원자적으로 만들어야 합니다.

바로 이럴 때 필요한 것이 인터럽트 비활성화입니다. 크리티컬 섹션 동안 일시적으로 인터럽트를 끄면 실행이 중단되지 않습니다.

개요

간단히 말해서, 인터럽트 비활성화는 cli(Clear Interrupt Flag) 명령어로 CPU의 IF 플래그를 끄는 것으로, 마스킹 가능한 인터럽트를 무시하게 만듭니다. x86 CPU는 RFLAGS 레지스터의 IF 비트가 1일 때만 INTR 핀의 인터럽트를 받아들입니다.

예를 들어, cli 실행 후에는 타이머나 키보드 인터럽트가 발생해도 CPU가 처리를 지연시킵니다. 기존에는 락 없이 데이터를 수정하여 레이스 컨디션에 취약했다면, 이제는 cli/sti 쌍으로 크리티컬 섹션을 보호하여 원자성을 보장할 수 있습니다.

인터럽트 비활성화의 핵심 특징은 첫째, 매우 빠르다는 점(명령어 하나), 둘째, 단일 CPU에서만 유효하다는 점(멀티코어는 스핀락 필요), 셋째, NMI는 막을 수 없다는 점입니다. 이러한 특징들이 간단하면서도 제한적인 동기화 메커니즘을 제공합니다.

코드 예제

use x86_64::instructions::interrupts;

// 수동 인터럽트 제어
pub fn critical_section<F, R>(f: F) -> R
where
    F: FnOnce() -> R,
{
    // 인터럽트 비활성화
    interrupts::disable();

    // 크리티컬 섹션 실행
    let result = f();

    // 인터럽트 재활성화
    interrupts::enable();

    result
}

// 더 안전한 방법: 이전 상태 복원
pub fn without_interrupts<F, R>(f: F) -> R
where
    F: FnOnce() -> R,
{
    // 현재 IF 플래그 저장하고 인터럽트 끄기
    let was_enabled = interrupts::are_enabled();
    interrupts::disable();

    let result = f();

    // 이전 상태로 복원 (중첩 호출 지원)
    if was_enabled {
        interrupts::enable();
    }

    result
}

// 사용 예시
static mut COUNTER: u64 = 0;

pub fn increment_counter() {
    without_interrupts(|| {
        unsafe {
            COUNTER += 1;  // 원자적 증가 보장
        }
    });
}

설명

이것이 하는 일: 공유 데이터를 수정하는 동안 인터럽트를 비활성화하여, 핸들러가 동일 데이터에 접근하여 발생하는 레이스 컨디션을 방지합니다. 첫 번째로, interrupts::disable()은 내부적으로 cli 명령어를 실행합니다.

이것은 CPU의 RFLAGS.IF 비트를 0으로 설정하여, 이후 발생하는 모든 마스킹 가능 인터럽트(타이머, 키보드, 네트워크 등)를 보류시킵니다. 인터럽트는 사라지는 것이 아니라 IF가 다시 켜질 때까지 대기합니다.

두 번째로, 클로저 f()를 실행하는 동안 인터럽트가 발생할 수 없으므로 실행 흐름이 중단되지 않습니다. 예를 들어, COUNTER += 1은 실제로는 load-add-store 세 단계인데, 중간에 인터럽트가 끼어들면 업데이트가 사라질 수 있습니다.

인터럽트를 끄면 세 단계가 원자적으로 실행됩니다. 세 번째로, without_interrupts 버전은 이전 IF 상태를 저장하고 복원합니다.

왜 중요한가? 중첩된 크리티컬 섹션을 지원하기 위해서입니다.

만약 인터럽트가 이미 꺼진 상태에서 함수가 호출되었다면, 함수 종료 시 다시 켜면 안 됩니다. 이전 상태 복원 방식이 더 안전합니다.

네 번째로, 클로저가 패닉하면 인터럽트가 영구히 꺼질 위험이 있습니다. 프로덕션 코드에서는 defer! 매크로나 RAII 가드를 사용하여 패닉 시에도 sti가 실행되도록 보장해야 합니다.

마지막으로, 멀티코어 시스템에서는 인터럽트 비활성화만으로는 불충분합니다. 다른 CPU가 동시에 같은 데이터에 접근할 수 있기 때문입니다.

이 경우 스핀락이나 뮤텍스와 함께 사용해야 합니다. 여러분이 이 코드를 사용하면 간단한 공유 변수를 안전하게 수정할 수 있고, 드라이버나 인터럽트 핸들러에서 데이터 손상을 방지할 수 있습니다.

단, 크리티컬 섹션을 최소한으로 유지해야 시스템 반응성이 떨어지지 않습니다.

실전 팁

💡 인터럽트를 끄는 시간을 최소화하세요. 오래 끄면 타이머 틱을 놓쳐 시간이 부정확해지고, 키 입력이 손실될 수 있습니다.

💡 멀티코어 환경에서는 without_interrupts와 스핀락을 조합하세요. 순서는 스핀락 획득 → 인터럽트 끄기 → 작업 → 인터럽트 켜기 → 스핀락 해제입니다.

💡 NMI(Non-Maskable Interrupt)는 cli로도 막을 수 없습니다. NMI 핸들러에서 접근하는 데이터는 원자 연산을 사용하세요.

💡 디버깅 시 인터럽트가 너무 오래 꺼져 있으면 워치독 타이머가 시스템을 리셋할 수 있습니다.

💡 Rust의 spin::Mutex는 내부적으로 인터럽트 비활성화를 지원하는 InterruptMutex 타입을 제공합니다.


10. 예외 에러 코드 처리 - 정확한 진단 정보

시작하며

여러분의 OS에서 Page Fault가 발생했는데, 읽기 때문인지 쓰기 때문인지 알 수 없다면? 에러 코드를 제대로 파싱하지 않으면 버그 원인을 찾는 데 몇 시간을 낭비할 수 있습니다.

이런 문제는 CPU가 제공하는 풍부한 진단 정보를 활용하지 못하는 것입니다. 일부 예외는 추가 컨텍스트를 에러 코드로 전달하는데, 이를 무시하면 맹목적으로 디버깅해야 합니다.

바로 이럼 때 필요한 것이 에러 코드 파싱입니다. 각 비트의 의미를 이해하면 예외의 정확한 원인을 즉시 파악할 수 있습니다.

개요

간단히 말해서, 에러 코드는 특정 예외(Page Fault, Double Fault, Invalid TSS 등) 발생 시 CPU가 스택에 푸시하는 추가 정보입니다. x86_64에서는 8개 예외가 에러 코드를 제공합니다: #DF, #TS, #NP, #SS, #GP, #PF, #AC, #CP.

예를 들어, Page Fault 에러 코드의 비트 0은 Present(페이지 존재 여부), 비트 1은 Write(쓰기 접근), 비트 2는 User(사용자 모드) 등을 나타냅니다. 기존에는 에러 코드를 16진수로만 출력하여 수동으로 비트를 해석해야 했다면, Rust의 비트 플래그 타입으로 의미 있는 필드로 자동 파싱할 수 있습니다.

에러 코드의 핵심 특징은 첫째, 예외별로 형식이 다르다는 점, 둘째, 대부분 비트 플래그라는 점, 셋째, 핸들러 시그니처에 두 번째 파라미터로 나타난다는 점입니다. 이러한 특징들이 정확하고 빠른 예외 진단을 가능하게 합니다.

코드 예제

use x86_64::structures::idt::{PageFaultErrorCode, GeneralProtectionFaultErrorCode};

// Page Fault 핸들러 - 에러 코드 상세 분석
extern "x86-interrupt" fn page_fault_handler(
    stack_frame: InterruptStackFrame,
    error_code: PageFaultErrorCode,
) {
    use x86_64::registers::control::Cr2;

    println!("\n!!! PAGE FAULT !!!");
    println!("Faulting Address: {:?}", Cr2::read());

    // 비트 플래그 자동 파싱
    println!("Cause:");
    println!("  Page present: {}", error_code.contains(PageFaultErrorCode::PROTECTION_VIOLATION));
    println!("  Write access: {}", error_code.contains(PageFaultErrorCode::CAUSED_BY_WRITE));
    println!("  User mode: {}", error_code.contains(PageFaultErrorCode::USER_MODE));
    println!("  Reserved bit: {}", error_code.contains(PageFaultErrorCode::MALFORMED_TABLE));
    println!("  Instruction fetch: {}", error_code.contains(PageFaultErrorCode::INSTRUCTION_FETCH));

    // 에러 타입 분류
    if !error_code.contains(PageFaultErrorCode::PROTECTION_VIOLATION) {
        println!("→ Page not mapped (need allocation)");
    } else if error_code.contains(PageFaultErrorCode::CAUSED_BY_WRITE) {
        println!("→ Write to read-only page (possible COW)");
    } else {
        println!("→ Permission violation");
    }

    panic!("Unhandled page fault");
}

// General Protection Fault 핸들러
extern "x86-interrupt" fn general_protection_fault_handler(
    stack_frame: InterruptStackFrame,
    error_code: u64,
) {
    println!("\n!!! GENERAL PROTECTION FAULT !!!");
    println!("Error Code: {:#x}", error_code);

    // 에러 코드 수동 파싱
    let external = error_code & 1 != 0;
    let table = (error_code >> 1) & 0b11;
    let selector_index = (error_code >> 3) as u16;

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

    panic!("Unrecoverable GPF");
}

설명

이것이 하는 일: 예외 발생 시 CPU가 스택에 푸시한 에러 코드를 읽어서 각 비트의 의미를 해석하고, 사람이 읽기 쉬운 형태로 출력하여 디버깅을 돕습니다. 첫 번째로, PageFaultErrorCode 타입은 x86_64 크레이트가 제공하는 비트 플래그 래퍼입니다.

contains() 메서드로 특정 비트가 설정되었는지 확인할 수 있으며, 매직 넘버 대신 명명된 상수를 사용하여 가독성이 높습니다. CPU가 자동으로 푸시하므로 핸들러는 읽기만 하면 됩니다.

두 번째로, 각 비트의 의미를 개별적으로 출력합니다. PROTECTION_VIOLATION이 false면 페이지 테이블에 엔트리가 없다는 뜻이고, true면 페이지는 있지만 권한이 부족하다는 뜻입니다.

CAUSED_BY_WRITE는 읽기(false)와 쓰기(true)를 구분하며, USER_MODE는 커널(false)과 사용자(true) 접근을 구분합니다. 세 번째로, 비트 조합으로 에러 타입을 분류합니다.

"Page not mapped"는 Demand Paging이나 Stack growth로 해결 가능하고, "Write to read-only page"는 Copy-on-Write로 처리 가능하며, 일반 "Permission violation"은 버그일 가능성이 높습니다. 이 분류가 핸들러 로직의 분기점이 됩니다.

네 번째로, General Protection Fault 같은 예외는 타입 래퍼가 없어서 수동으로 비트를 추출합니다. 비트 0은 외부 이벤트, 비트 1-2는 디스크립터 테이블 타입(GDT/IDT/LDT), 비트 3-15는 셀렉터 인덱스입니다.

비트 시프트와 마스킹으로 각 필드를 분리합니다. 마지막으로, 파싱된 정보를 출력하여 개발자가 문제를 즉시 파악할 수 있게 합니다.

"Write to read-only page at 0x12345000"는 "Page Fault 0x3"보다 훨씬 유용합니다. 여러분이 이 코드를 사용하면 예외 디버깅 시간을 크게 단축할 수 있고, Copy-on-Write나 Demand Paging 같은 고급 기법 구현 시 필요한 정보를 정확히 얻을 수 있으며, 버그와 정상 동작을 명확히 구분할 수 있습니다.

실전 팁

💡 모든 예외가 에러 코드를 제공하는 것은 아닙니다. Breakpoint, Divide Error 등은 에러 코드 없이 InterruptStackFrame만 받습니다.

💡 에러 코드의 비트 배치는 예외마다 다릅니다. Intel SDM 3권을 참고하여 정확히 파싱하세요.

💡 프로덕션 환경에서는 에러 코드를 로그에 남기되, 화면 출력은 최소화하여 성능 영향을 줄이세요.

💡 비트 플래그가 예약 비트를 포함하면 하드웨어 버그나 손상을 의심하세요. 매우 드물지만 치명적입니다.

💡 에러 코드 파싱 로직을 단위 테스트로 검증하면 비트 마스킹 실수를 방지할 수 있습니다.


#Rust#OS개발#인터럽트핸들러#IDT#CPU예외처리#시스템프로그래밍

댓글 (0)

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