이미지 로딩 중...

Rust로 만드는 나만의 OS 9 IDT 구축 완벽 가이드 - 슬라이드 1/11
A

AI Generated

2025. 11. 13. · 2 Views

Rust로 만드는 나만의 OS 9 IDT 구축 완벽 가이드

운영체제 개발의 핵심인 IDT(Interrupt Descriptor Table)를 Rust로 직접 구현하는 방법을 배워봅니다. CPU 예외 처리부터 인터럽트 핸들러 등록까지, 실전 OS 개발에 필요한 모든 것을 다룹니다.


목차

  1. IDT 기본 구조 이해하기 - 인터럽트 처리의 핵심 테이블
  2. 더블 폴트 핸들러 구현하기 - 시스템 붕괴를 막는 최후의 방어선
  3. 페이지 폴트 핸들러 구현하기 - 메모리 접근 위반 감지하기
  4. 브레이크포인트 예외 활용하기 - 디버깅의 핵심 도구
  5. 타이머 인터럽트 설정하기 - 멀티태스킹의 심장박동
  6. 키보드 인터럽트 처리하기 - 사용자 입력 받기
  7. 사용자 정의 인터럽트 만들기 - 소프트웨어 인터럽트 활용하기
  8. APIC 설정하기 - 멀티코어 인터럽트 관리
  9. 인터럽트 마스킹 제어하기 - 크리티컬 섹션 보호
  10. 예외 스택 프레임 분석하기 - 크래시 디버깅의 핵심

1. IDT 기본 구조 이해하기 - 인터럽트 처리의 핵심 테이블

시작하며

여러분이 OS 개발을 시작하면서 가장 먼저 마주치는 난관 중 하나가 바로 "CPU 예외를 어떻게 처리하지?"입니다. 프로그램이 0으로 나누거나, 잘못된 메모리에 접근하면 시스템이 그냥 멈춰버리는 상황을 겪어본 적 있나요?

이런 문제는 실제 OS 개발 현장에서 가장 먼저 해결해야 하는 과제입니다. CPU는 예외 상황이 발생하면 특정 주소로 점프하려고 하는데, 우리가 그 주소를 제대로 설정해주지 않으면 트리플 폴트(Triple Fault)가 발생하며 시스템이 리셋됩니다.

바로 이럴 때 필요한 것이 IDT(Interrupt Descriptor Table)입니다. IDT는 CPU에게 "이런 예외가 발생하면 이 함수를 실행해!"라고 알려주는 전화번호부 같은 역할을 합니다.

개요

간단히 말해서, IDT는 256개의 엔트리를 가진 테이블로, 각 엔트리는 특정 인터럽트나 예외가 발생했을 때 실행할 핸들러 함수의 주소를 담고 있습니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, CPU는 하드웨어 수준에서 예외 처리를 위해 IDT를 참조합니다.

예를 들어, 페이지 폴트(인덱스 14)나 더블 폴트(인덱스 8) 같은 경우에 적절한 핸들러가 없으면 시스템 전체가 다운됩니다. 기존 베어메탈 개발에서는 어셈블리로 복잡한 매크로를 작성해야 했다면, 이제는 Rust의 타입 시스템을 활용해 안전하고 명확하게 IDT를 정의할 수 있습니다.

IDT의 핵심 특징은 첫째, 각 엔트리가 16바이트 크기의 정확한 구조를 가진다는 것, 둘째, 특권 레벨(DPL)을 통해 보안을 제어할 수 있다는 것, 셋째, 인터럽트 게이트와 트랩 게이트 등 다양한 타입을 지원한다는 것입니다. 이러한 특징들이 안정적인 예외 처리 메커니즘을 구축하는 데 필수적입니다.

코드 예제

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

// IDT를 정적으로 선언합니다
static mut IDT: InterruptDescriptorTable = InterruptDescriptorTable::new();

pub fn init_idt() {
    unsafe {
        // 각 예외에 대한 핸들러를 등록합니다
        IDT.breakpoint.set_handler_fn(breakpoint_handler);
        IDT.double_fault.set_handler_fn(double_fault_handler);

        // IDT를 CPU에 로드합니다
        IDT.load();
    }
}

// 브레이크포인트 예외 핸들러
extern "x86-interrupt" fn breakpoint_handler(stack_frame: InterruptStackFrame) {
    println!("EXCEPTION: BREAKPOINT\n{:#?}", stack_frame);
}

설명

이것이 하는 일: 이 코드는 Rust의 x86_64 크레이트를 활용하여 IDT를 생성하고, 특정 예외에 대한 핸들러를 등록한 후, CPU에 로드하는 전체 과정을 보여줍니다. 첫 번째로, static mut IDT를 통해 전역 정적 IDT를 선언합니다.

InterruptDescriptorTable::new()는 256개의 빈 엔트리를 가진 IDT를 초기화하는데, 이때 각 엔트리는 기본값으로 설정됩니다. 왜 static mut를 사용하냐면, IDT는 프로그램이 실행되는 동안 계속 메모리에 존재해야 하고, CPU가 직접 접근하기 때문입니다.

두 번째로, set_handler_fn 메서드를 통해 각 예외에 대한 핸들러 함수를 등록합니다. IDT.breakpoint는 인덱스 3번 엔트리에 해당하며, 브레이크포인트 예외가 발생하면 breakpoint_handler 함수가 호출됩니다.

내부적으로는 함수 포인터의 주소와 코드 세그먼트 셀렉터 등이 엔트리에 저장됩니다. 세 번째로, IDT.load() 메서드가 lidt 명령어를 실행하여 IDT의 주소와 크기를 CPU의 IDTR(IDT Register)에 저장합니다.

이후부터 CPU는 예외가 발생하면 IDTR이 가리키는 테이블을 참조하여 적절한 핸들러로 점프하게 됩니다. 마지막으로, 핸들러 함수는 extern "x86-interrupt" 호출 규약을 사용합니다.

이는 CPU가 자동으로 스택에 푸시한 정보(명령어 포인터, 코드 세그먼트, 플래그 등)를 InterruptStackFrame으로 받아올 수 있게 합니다. 핸들러가 종료되면 CPU는 iretq 명령어로 인터럽트 이전 상태로 복귀합니다.

여러분이 이 코드를 사용하면 예외가 발생해도 시스템이 패닉하지 않고 제어된 방식으로 처리할 수 있습니다. 디버깅이 훨씬 쉬워지고, 예외 정보를 로깅하거나 복구 작업을 수행할 수 있습니다.

실전 팁

💡 IDT는 16바이트 정렬이 필수입니다. #[repr(align(16))]를 사용하거나 x86_64 크레이트가 자동으로 처리하도록 하세요.

💡 핸들러 함수는 절대 반환하지 않는 경우 -> ! 타입을 사용하세요. 더블 폴트 핸들러가 대표적인 예입니다.

💡 static mut의 안전성이 걱정된다면 lazy_static! 매크로와 Mutex를 조합하여 안전한 초기화를 구현할 수 있습니다.

💡 QEMU에서 -d int,cpu_reset 옵션을 사용하면 인터럽트 발생 시점과 CPU 상태를 상세히 로깅할 수 있어 디버깅에 큰 도움이 됩니다.

💡 x86_64 크레이트의 idt 모듈은 컴파일 타임에 타입 체크를 제공하므로, 잘못된 핸들러 시그니처를 사용하면 컴파일 에러가 발생합니다.


2. 더블 폴트 핸들러 구현하기 - 시스템 붕괴를 막는 최후의 방어선

시작하며

여러분이 OS 개발 중 예외 핸들러를 구현했는데, 그 핸들러 안에서 또 다른 예외가 발생하는 상황을 상상해보세요. 예를 들어 페이지 폴트 핸들러가 스택 오버플로우를 일으키는 경우입니다.

이런 문제는 실제로 매우 위험합니다. CPU는 첫 번째 예외 처리 중 두 번째 예외가 발생하면 "더블 폴트"를 발생시키는데, 이마저 처리하지 못하면 트리플 폴트로 이어져 시스템이 즉시 리셋됩니다.

바로 이럴 때 필요한 것이 더블 폴트 핸들러입니다. 이는 시스템 붕괴를 막는 최후의 방어선으로, 제대로 구현하면 치명적인 에러 상황에서도 디버깅 정보를 얻을 수 있습니다.

개요

간단히 말해서, 더블 폴트는 예외 처리 중 또 다른 예외가 발생했을 때 CPU가 발생시키는 특수한 예외(인덱스 8)입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 더블 폴트 핸들러가 없으면 예외 처리 코드의 버그를 찾기가 거의 불가능합니다.

시스템이 그냥 리셋되어버리니까요. 예를 들어, 스택 오버플로우가 발생하면 페이지 폴트 핸들러가 스택을 사용하려다 또 페이지 폴트가 발생하는 무한 루프에 빠질 수 있습니다.

기존에는 더블 폴트 발생 시 어떤 정보도 얻지 못하고 시스템이 다운됐다면, 이제는 전용 스택을 사용하여 안전하게 에러 정보를 출력하고 시스템을 중단시킬 수 있습니다. 더블 폴트 핸들러의 핵심 특징은 첫째, 반환하지 않는 함수(-> !)여야 한다는 것, 둘째, IST(Interrupt Stack Table)를 통해 별도의 스택을 사용해야 안전하다는 것, 셋째, 에러 코드를 받아 문제의 원인을 파악할 수 있다는 것입니다.

코드 예제

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

pub fn init_idt() {
    unsafe {
        IDT.double_fault
            .set_handler_fn(double_fault_handler)
            // IST 인덱스 0번을 더블 폴트 전용 스택으로 지정
            .set_stack_index(0);

        IDT.load();
    }
}

// 더블 폴트 핸들러는 절대 반환하지 않습니다
extern "x86-interrupt" fn double_fault_handler(
    stack_frame: InterruptStackFrame,
    error_code: u64  // 더블 폴트는 항상 에러 코드 0을 전달합니다
) -> ! {
    panic!("EXCEPTION: DOUBLE FAULT\n{:#?}\nError Code: {}", stack_frame, error_code);
}

설명

이것이 하는 일: 이 코드는 더블 폴트 핸들러를 등록하고, IST를 통해 별도의 스택을 할당하여 스택 오버플로우 상황에서도 안전하게 처리할 수 있도록 구성합니다. 첫 번째로, set_handler_fn으로 더블 폴트 핸들러를 등록합니다.

이때 핸들러 함수는 -> ! 반환 타입을 가지는데, 이는 Rust에게 "이 함수는 절대 정상적으로 반환하지 않는다"고 알려줍니다. 왜냐하면 더블 폴트는 시스템이 복구 불가능한 상태이므로, 핸들러는 패닉하거나 무한 루프에 들어가야 하기 때문입니다.

두 번째로, set_stack_index(0)를 호출하여 IST의 0번 인덱스를 더블 폴트 전용 스택으로 지정합니다. CPU는 더블 폴트가 발생하면 현재 스택(손상되었을 수 있음) 대신 IST가 가리키는 깨끗한 스택으로 전환합니다.

이는 스택 오버플로우로 인한 더블 폴트를 안전하게 처리하는 핵심 메커니즘입니다. 세 번째로, 핸들러 함수는 error_code 파라미터를 받습니다.

비록 더블 폴트는 항상 0을 전달하지만, 이 시그니처는 CPU가 자동으로 스택에 푸시하는 에러 코드를 처리하기 위해 필요합니다. InterruptStackFrame에는 더블 폴트 발생 시점의 명령어 포인터, 스택 포인터, 플래그 레지스터 등이 포함되어 있어 디버깅에 매우 유용합니다.

마지막으로, panic! 매크로를 통해 시스템을 중단시킵니다. 실무에서는 여기에 스택 트레이스 출력, 메모리 덤프, 시리얼 포트를 통한 로깅 등을 추가하여 사후 분석을 위한 정보를 최대한 수집합니다.

일부 시스템에서는 hlt 명령어로 CPU를 무한 대기 상태로 만들기도 합니다. 여러분이 이 코드를 사용하면 예외 핸들러의 버그로 인한 더블 폴트 발생 시에도 시스템이 완전히 다운되지 않고, 최소한 에러 메시지를 확인할 수 있습니다.

스택 오버플로우 같은 치명적인 상황에서도 안전합니다.

실전 팁

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

💡 GDT(Global Descriptor Table)에 TSS(Task State Segment)를 등록해야 IST가 작동합니다. x86_64 크레이트의 GlobalDescriptorTableTaskStateSegment를 활용하세요.

💡 더블 폴트 핸들러 내에서는 동적 메모리 할당을 피하세요. 힙이 손상되었을 가능성이 있으므로 스택 변수만 사용하는 것이 안전합니다.

💡 QEMU의 -d int 옵션으로 더블 폴트 발생 시점의 레지스터 상태를 확인할 수 있습니다. 특히 RSP(스택 포인터) 값을 보면 스택 문제를 진단할 수 있습니다.

💡 시리얼 포트를 통한 로깅을 구현해두면, 화면 출력이 불가능한 상황에서도 호스트 시스템에서 에러 정보를 확인할 수 있습니다.


3. 페이지 폴트 핸들러 구현하기 - 메모리 접근 위반 감지하기

시작하며

여러분이 OS에서 가상 메모리를 구현하고 있는데, 프로그램이 매핑되지 않은 주소를 읽거나 쓰려고 하는 상황을 마주쳤다고 가정해봅시다. 또는 읽기 전용 페이지에 쓰기를 시도하는 경우도 있겠죠.

이런 문제는 실제 OS 개발에서 가장 빈번하게 발생하는 예외입니다. CPU는 MMU(Memory Management Unit)를 통해 모든 메모리 접근을 검증하고, 문제가 있으면 페이지 폴트(인덱스 14)를 발생시킵니다.

이를 제대로 처리하지 않으면 프로그램이 임의의 메모리를 손상시킬 수 있습니다. 바로 이럴 때 필요한 것이 페이지 폴트 핸들러입니다.

이는 메모리 접근 위반의 원인을 파악하고, 필요한 경우 페이지를 동적으로 할당하거나 프로세스를 종료할 수 있게 해줍니다.

개요

간단히 말해서, 페이지 폴트는 가상 메모리 접근이 실패했을 때 발생하는 예외로, CR2 레지스터에 문제가 된 주소가, 에러 코드에 위반의 종류가 저장됩니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 페이지 폴트 핸들러는 단순한 에러 처리를 넘어 demand paging, copy-on-write, memory-mapped files 등 고급 메모리 관리 기법의 기반이 됩니다.

예를 들어, 프로그램이 큰 배열을 선언했지만 실제로는 일부만 사용하는 경우, 접근하는 페이지만 실제로 할당하여 메모리를 절약할 수 있습니다. 기존에는 페이지 폴트가 발생하면 어떤 주소가 문제인지 수동으로 디버깅해야 했다면, 이제는 CR2 레지스터와 에러 코드를 통해 즉시 원인을 파악할 수 있습니다.

페이지 폴트 핸들러의 핵심 특징은 첫째, CR2 레지스터로 폴트 주소를 읽을 수 있다는 것, 둘째, 에러 코드의 비트 플래그로 원인(present, write, user 등)을 구분할 수 있다는 것, 셋째, 핸들러 내에서 페이지를 할당하고 매핑하여 계속 실행할 수 있다는 것입니다.

코드 예제

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

pub fn init_idt() {
    unsafe {
        IDT.page_fault.set_handler_fn(page_fault_handler);
        IDT.load();
    }
}

extern "x86-interrupt" fn page_fault_handler(
    stack_frame: InterruptStackFrame,
    error_code: PageFaultErrorCode,  // 비트 플래그로 폴트 원인 제공
) {
    // CR2 레지스터에서 폴트가 발생한 가상 주소를 읽습니다
    let fault_address = Cr2::read();

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

    // 실무에서는 여기서 페이지 할당 또는 프로세스 종료 결정
    panic!("Unhandled page fault");
}

설명

이것이 하는 일: 이 코드는 페이지 폴트 예외를 감지하고, 문제가 된 메모리 주소와 접근 유형을 파악하여 적절한 조치를 취할 수 있도록 정보를 제공합니다. 첫 번째로, IDT에 page_fault_handler를 등록합니다.

페이지 폴트는 IDT의 14번 인덱스에 해당하며, CPU가 메모리 접근을 검증할 때마다 이 핸들러가 호출될 수 있습니다. 왜 이것이 중요하냐면, 가상 메모리 시스템의 핵심 메커니즘이기 때문입니다.

두 번째로, 핸들러는 PageFaultErrorCode를 받습니다. 이는 5개의 비트 플래그를 포함합니다: PRESENT(페이지가 없음), WRITE(쓰기 시도), USER(유저 모드에서 발생), RESERVED_WRITE(예약된 비트 설정), INSTRUCTION_FETCH(명령어 페치 중).

예를 들어, error_code.contains(PageFaultErrorCode::WRITE)로 읽기 전용 페이지에 쓰기를 시도했는지 확인할 수 있습니다. 세 번째로, Cr2::read()를 통해 폴트가 발생한 가상 주소를 읽습니다.

CR2는 CPU가 자동으로 설정하는 특수 제어 레지스터로, 페이지 폴트 발생 시 항상 문제의 주소를 담고 있습니다. 이 정보를 페이지 테이블과 대조하면 매핑 상태를 확인할 수 있습니다.

네 번째로, 실무에서는 이 정보를 바탕으로 동적 처리를 합니다. 만약 폴트 주소가 힙 영역이고 PRESENT 플래그가 설정되어 있지 않다면, 새 물리 페이지를 할당하고 페이지 테이블에 매핑한 후 반환하여 명령어를 재실행할 수 있습니다.

반대로 커널 영역에 유저 모드가 접근했다면 프로세스를 종료해야 합니다. 여러분이 이 코드를 사용하면 메모리 버그를 즉시 감지할 수 있고, demand paging 같은 최적화 기법을 구현할 수 있습니다.

또한 보안 위반(예: 버퍼 오버플로우)을 감지하여 악성 코드 실행을 차단할 수도 있습니다.

실전 팁

💡 에러 코드의 PROTECTION_VIOLATION 플래그를 체크하세요. 이것이 설정되면 페이지는 존재하지만 권한이 없는 것이므로, 보안 위반일 가능성이 높습니다.

💡 커널 스택 근처에서 페이지 폴트가 발생하면 스택 오버플로우를 의심하세요. 가드 페이지(언매핑된 페이지)를 스택 끝에 배치하면 오버플로우를 조기 감지할 수 있습니다.

💡 페이지 폴트 핸들러 내에서 또 다른 페이지 폴트가 발생하지 않도록 주의하세요. 핸들러가 사용하는 모든 코드와 데이터는 항상 매핑되어 있어야 합니다.

💡 통계를 수집하세요. 페이지 폴트 빈도와 위치를 추적하면 메모리 접근 패턴을 분석하고 성능을 최적화할 수 있습니다.

💡 QEMU의 -d page 옵션으로 모든 페이지 폴트와 페이지 테이블 변경을 로깅할 수 있어, 메모리 관리 코드 디버깅에 매우 유용합니다.


4. 브레이크포인트 예외 활용하기 - 디버깅의 핵심 도구

시작하며

여러분이 OS 코드를 디버깅하면서 "이 지점에서 정확히 무슨 일이 일어나는지 보고 싶다"고 생각한 적 있나요? printf 디버깅은 한계가 있고, 하드웨어 디버거는 설정이 복잡합니다.

이런 문제는 커널 개발에서 특히 심각합니다. 유저 스페이스 디버거를 사용할 수 없고, 잘못된 코드 한 줄이 시스템 전체를 다운시킬 수 있습니다.

실행 흐름을 정확히 추적하지 못하면 버그 찾기가 악몽이 됩니다. 바로 이럴 때 필요한 것이 브레이크포인트 예외입니다.

CPU의 int3 명령어(1바이트 인스트럭션)를 코드에 삽입하면, 실행이 그 지점에서 멈추고 핸들러가 호출되어 시스템 상태를 검사할 수 있습니다.

개요

간단히 말해서, 브레이크포인트 예외는 int3 명령어를 만나면 발생하는 예외(인덱스 3)로, 디버거가 프로그램 실행을 중단하고 상태를 검사하는 핵심 메커니즘입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, GDB 같은 디버거가 "여기에 브레이크포인트 설정"이라고 하면 실제로는 그 위치의 명령어를 int3(0xCC 바이트)로 교체합니다.

예를 들어, 특정 함수의 입구점에 브레이크포인트를 설정하면, 그 함수가 호출될 때마다 실행이 중단되어 인자와 레지스터를 검사할 수 있습니다. 기존에는 시리얼 포트를 통해 수동으로 print 문을 추가해야 했다면, 이제는 브레이크포인트로 원하는 지점에서 정확히 멈추고 대화형으로 디버깅할 수 있습니다.

브레이크포인트 예외의 핵심 특징은 첫째, 1바이트 명령어라서 어떤 명령어도 교체할 수 있다는 것, 둘째, 트랩 타입 예외라서 명령어 실행 후에 핸들러가 호출된다는 것, 셋째, 핸들러에서 반환하면 다음 명령어부터 계속 실행된다는 것입니다.

코드 예제

use x86_64::structures::idt::InterruptStackFrame;

pub fn init_idt() {
    unsafe {
        IDT.breakpoint.set_handler_fn(breakpoint_handler);
        IDT.load();
    }
}

extern "x86-interrupt" fn breakpoint_handler(stack_frame: InterruptStackFrame) {
    println!("BREAKPOINT HIT!");
    println!("Instruction Pointer: {:#x}", stack_frame.instruction_pointer.as_u64());
    println!("Stack Pointer: {:#x}", stack_frame.stack_pointer.as_u64());
    println!("CPU Flags: {:#x}", stack_frame.cpu_flags);

    // 여기서 레지스터 덤프, 백트레이스 등을 수행할 수 있습니다
    // 반환하면 다음 명령어부터 실행이 계속됩니다
}

// 코드의 아무 곳에서나 이렇게 사용합니다
pub fn some_function() {
    x86_64::instructions::interrupts::int3();  // 브레이크포인트 트리거
}

설명

이것이 하는 일: 이 코드는 브레이크포인트 예외 핸들러를 등록하고, int3 명령어가 실행되면 현재 CPU 상태를 출력한 후 계속 실행합니다. 첫 번째로, breakpoint_handler를 IDT의 3번 인덱스에 등록합니다.

브레이크포인트는 트랩(Trap) 타입 게이트로 설정되므로, 인터럽트 플래그(IF)가 클리어되지 않습니다. 즉, 핸들러 실행 중에도 다른 인터럽트가 발생할 수 있습니다.

왜 이것이 중요하냐면, 디버깅 중에도 타이머 인터럽트 같은 시스템 기능이 계속 작동해야 하기 때문입니다. 두 번째로, InterruptStackFrame으로 CPU가 자동으로 저장한 정보를 받습니다.

instruction_pointerint3 명령어의 다음 주소를 가리키므로, 어느 지점에서 브레이크포인트가 발생했는지 정확히 알 수 있습니다. cpu_flags에는 Carry, Zero, Sign 등의 플래그가 들어있어 조건 분기의 결과를 추적할 수 있습니다.

세 번째로, 핸들러 내에서 원하는 정보를 출력합니다. 실무에서는 여기에 레지스터 덤프(RAX, RBX 등), 스택 백트레이스, 주변 메모리 내용 등을 추가합니다.

GDB 스텁을 구현했다면, 이 시점에서 GDB가 연결되기를 기다리고 원격으로 메모리를 검사할 수도 있습니다. 네 번째로, int3() 함수를 통해 코드의 아무 곳에서나 브레이크포인트를 트리거할 수 있습니다.

이는 단순히 int 3 어셈블리 명령어를 인라인으로 실행하는 것으로, 컴파일러가 0xCC 바이트를 생성합니다. 핸들러가 반환하면 int3 다음 명령어부터 정상 실행이 재개됩니다.

여러분이 이 코드를 사용하면 복잡한 버그를 추적할 때 실행 흐름을 정밀하게 제어할 수 있습니다. 조건부 로깅, 성능 프로파일링, 보안 검증 등 다양한 용도로 활용 가능합니다.

실전 팁

💡 릴리스 빌드에서는 int3() 호출을 조건부 컴파일로 제거하세요. #[cfg(debug_assertions)]를 사용하면 디버그 모드에서만 활성화됩니다.

💡 브레이크포인트 핸들러에서 no_mangle 함수의 심볼 정보를 출력하면, 스택 트레이스를 수동으로 구성할 수 있습니다.

💡 하드웨어 브레이크포인트(DR0-DR3 레지스터)를 활용하면 특정 메모리 주소에 접근할 때 자동으로 브레이크할 수 있습니다. 코드 수정 없이 watchpoint를 구현할 수 있습니다.

💡 QEMU의 GDB 서버(-s -S 옵션)와 연결하면, 호스트에서 GDB로 원격 디버깅이 가능합니다. target remote :1234로 연결 후 c로 계속 실행하세요.

💡 브레이크포인트 카운터를 구현하여 "이 함수가 100번째 호출될 때만 멈춰라" 같은 조건부 브레이크포인트를 만들 수 있습니다.


5. 타이머 인터럽트 설정하기 - 멀티태스킹의 심장박동

시작하며

여러분이 OS에서 여러 프로그램을 동시에 실행하고 싶은데, 한 프로그램이 CPU를 독점하면 다른 프로그램이 멈춰버리는 상황을 겪어본 적 있나요? 무한 루프에 빠진 프로그램이 시스템 전체를 먹통으로 만드는 경우도 있죠.

이런 문제는 협력적 멀티태스킹(cooperative multitasking)의 고질적인 한계입니다. 프로그램이 자발적으로 CPU를 양보하지 않으면 다른 프로그램은 실행 기회를 얻지 못합니다.

이는 응답성과 공정성 측면에서 치명적입니다. 바로 이럴 때 필요한 것이 타이머 인터럽트입니다.

하드웨어 타이머가 주기적으로(보통 1ms마다) 인터럽트를 발생시키면, OS는 강제로 CPU를 회수하여 다른 프로그램에 할당할 수 있습니다.

개요

간단히 말해서, 타이머 인터럽트는 PIT(Programmable Interval Timer) 또는 APIC 타이머가 주기적으로 발생시키는 하드웨어 인터럽트로, 선점형 멀티태스킹과 시간 측정의 기반입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 현대적인 OS는 모두 선점형 스케줄링을 사용합니다.

타이머 인터럽트가 발생하면 스케줄러가 호출되어 CPU 시간을 공정하게 분배하고, 시간 초과를 감지하고, 주기적인 시스템 유지보수 작업을 수행합니다. 예를 들어, 100Hz 타이머라면 모든 프로세스가 최소 10ms마다 한 번씩 실행 기회를 얻습니다.

기존에는 프로그램이 자발적으로 yield()를 호출해야 했다면, 이제는 타이머 인터럽트가 자동으로 컨텍스트 스위칭을 트리거하여 무한 루프에 빠진 프로그램도 제어할 수 있습니다. 타이머 인터럽트의 핵심 특징은 첫째, 하드웨어가 자동으로 주기적 신호를 생성한다는 것, 둘째, PIC/APIC를 통해 CPU에 전달되어 IDT의 특정 인덱스를 트리거한다는 것, 셋째, 핸들러에서 EOI(End Of Interrupt)를 명시적으로 전송해야 다음 인터럽트가 발생한다는 것입니다.

코드 예제

use x86_64::structures::idt::InterruptStackFrame;
use pic8259::ChainedPics;
use spin::Mutex;

// PIC 설정: 마스터 32, 슬레이브 40에서 시작
pub static PICS: Mutex<ChainedPics> =
    Mutex::new(unsafe { ChainedPics::new(32, 40) });

pub const TIMER_INTERRUPT_ID: u8 = 32;  // PIC 오프셋 + IRQ 0

pub fn init_idt() {
    unsafe {
        IDT[TIMER_INTERRUPT_ID as usize]
            .set_handler_fn(timer_interrupt_handler);
        IDT.load();
    }

    // PIC 초기화 및 타이머 인터럽트 언마스크
    unsafe { PICS.lock().initialize() };
}

extern "x86-interrupt" fn timer_interrupt_handler(_stack_frame: InterruptStackFrame) {
    print!(".");  // 타이머 틱 표시

    // 여기서 스케줄러 호출, 시간 업데이트 등 수행

    // EOI 전송 - 이것이 없으면 다음 인터럽트가 발생하지 않습니다!
    unsafe {
        PICS.lock().notify_end_of_interrupt(TIMER_INTERRUPT_ID);
    }
}

설명

이것이 하는 일: 이 코드는 8259 PIC를 초기화하고, 타이머 인터럽트(IRQ 0)를 IDT에 등록하여 주기적으로 핸들러가 호출되도록 설정합니다. 첫 번째로, ChainedPics::new(32, 40)으로 PIC를 구성합니다.

PIC는 하드웨어 인터럽트(IRQ 0-15)를 CPU의 인터럽트 벡터로 변환하는 컨트롤러입니다. 오프셋 32를 사용하는 이유는 0-31번이 CPU 예외로 예약되어 있기 때문입니다.

따라서 IRQ 0(타이머)는 인터럽트 32가 되고, IRQ 1(키보드)는 33이 됩니다. 두 번째로, IDT의 32번 인덱스에 핸들러를 등록합니다.

PIT는 기본적으로 약 18.2Hz로 IRQ 0을 발생시킵니다(더 빠른 속도로 설정할 수 있음). CPU는 인터럽트를 받으면 현재 실행을 중단하고 timer_interrupt_handler로 점프합니다.

이때 스택에 리턴 주소와 플래그가 자동으로 저장됩니다. 세 번째로, 핸들러 내에서 주기적인 작업을 수행합니다.

실무에서는 여기서 시스템 타임을 증가시키고, 프로세스 스케줄러를 호출하고, 타임아웃을 체크합니다. 예를 들어, 각 프로세스의 실행 시간을 카운터로 추적하다가 타임 슬라이스가 소진되면 schedule() 함수로 다른 프로세스로 전환합니다.

네 번째로, notify_end_of_interrupt()로 EOI 신호를 PIC에 전송합니다. 이는 매우 중요합니다!

PIC는 EOI를 받기 전까지 같은 우선순위 이하의 인터럽트를 차단하므로, EOI를 보내지 않으면 타이머가 한 번만 발생하고 멈춥니다. EOI는 PIC의 명령 포트(0x20 또는 0xA0)에 0x20 값을 쓰는 것으로 구현됩니다.

여러분이 이 코드를 사용하면 멀티태스킹 OS의 기반을 구축할 수 있습니다. 각 프로세스가 공정하게 CPU 시간을 받고, 시스템 시간을 정확히 측정하고, sleep() 같은 시간 기반 함수를 구현할 수 있습니다.

실전 팁

💡 타이머 주파수를 변경하려면 PIT의 채널 0에 디바이더 값을 설정하세요. 1193182Hz를 원하는 주파수로 나눈 값을 포트 0x40에 쓰면 됩니다.

💡 타이머 핸들러는 매우 빈번하게 호출되므로 최대한 빠르게 실행되어야 합니다. 무거운 작업은 다른 스레드로 오프로드하세요.

💡 APIC 타이머는 PIT보다 정밀하고 CPU별로 독립적입니다. 멀티코어 시스템에서는 각 코어가 자체 타이머 인터럽트를 가질 수 있습니다.

💡 타이머 인터럽트 핸들러 내에서 hlt 명령어를 사용하지 마세요. 인터럽트가 비활성화된 상태에서 hlt를 실행하면 시스템이 영원히 멈춥니다.

💡 jiffies(타이머 틱 카운터)를 전역 변수로 유지하면 시스템 업타임을 측정할 수 있습니다. AtomicU64를 사용하여 락 없이 안전하게 증가시키세요.


6. 키보드 인터럽트 처리하기 - 사용자 입력 받기

시작하며

여러분이 OS를 만들었는데 사용자가 키보드를 눌러도 아무 반응이 없다면 어떻게 느끼실까요? 폴링(polling) 방식으로 계속 키보드 상태를 확인하는 것은 CPU 자원을 낭비하는 비효율적인 방법입니다.

이런 문제는 입출력 장치를 다룰 때 항상 발생합니다. 폴링은 CPU를 계속 바쁘게 만들고, 반응 속도도 느립니다.

특히 여러 입력 장치가 있는 경우 각각을 계속 확인하는 것은 현실적이지 않습니다. 바로 이럴 때 필요한 것이 키보드 인터럽트입니다.

사용자가 키를 누르는 순간 키보드 컨트롤러가 IRQ 1을 발생시키고, OS는 즉시 반응하여 키 코드를 읽고 처리할 수 있습니다.

개요

간단히 말해서, 키보드 인터럽트는 PS/2 키보드 컨트롤러가 키 이벤트 발생 시 IRQ 1을 통해 알리는 메커니즘으로, 효율적이고 즉각적인 입력 처리를 가능하게 합니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 인터럽트 기반 입력 처리는 이벤트 기반 아키텍처의 기반입니다.

CPU는 키 입력이 있을 때만 깨어나서 처리하고, 나머지 시간에는 다른 작업을 할 수 있습니다. 예를 들어, 사용자가 텍스트 에디터에서 타이핑하는 동안에도 백그라운드에서 파일 다운로드가 계속 진행될 수 있습니다.

기존에는 busy waiting으로 키보드를 계속 폴링해야 했다면, 이제는 인터럽트가 발생할 때만 핸들러가 실행되어 CPU 사이클을 절약합니다. 키보드 인터럽트의 핵심 특징은 첫째, 키를 누르거나 뗄 때 모두 인터럽트가 발생한다는 것(make/break 코드), 둘째, 포트 0x60에서 스캔 코드를 읽어야 한다는 것, 셋째, 읽지 않으면 키보드 버퍼가 가득 차서 더 이상 인터럽트가 발생하지 않는다는 것입니다.

코드 예제

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

pub const KEYBOARD_INTERRUPT_ID: u8 = 33;  // PIC 오프셋 + IRQ 1

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

pub fn init_idt() {
    unsafe {
        IDT[KEYBOARD_INTERRUPT_ID as usize]
            .set_handler_fn(keyboard_interrupt_handler);
        IDT.load();
    }
}

extern "x86-interrupt" fn keyboard_interrupt_handler(_stack_frame: InterruptStackFrame) {
    let mut keyboard = KEYBOARD.lock();
    let mut port = Port::new(0x60);

    // 키보드 컨트롤러에서 스캔 코드 읽기
    let scancode: u8 = unsafe { port.read() };

    // 스캔 코드를 키 이벤트로 변환
    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),
            }
        }
    }

    unsafe {
        PICS.lock().notify_end_of_interrupt(KEYBOARD_INTERRUPT_ID);
    }
}

설명

이것이 하는 일: 이 코드는 키보드 인터럽트를 등록하고, 스캔 코드를 읽어서 ASCII 문자로 변환하여 화면에 출력합니다. 첫 번째로, Keyboard 객체를 lazy_static!으로 초기화합니다.

이는 US 104키 레이아웃과 스캔 코드 세트 1을 사용하는 상태 머신입니다. 왜 상태 머신이 필요하냐면, 멀티바이트 스캔 코드(예: 화살표 키는 0xE0 접두사 필요)와 모디파이어 키(Shift, Ctrl 등)의 상태를 추적해야 하기 때문입니다.

두 번째로, 핸들러는 포트 0x60에서 스캔 코드를 읽습니다. 이는 매우 중요한 단계입니다!

키보드 컨트롤러는 8바이트 버퍼를 가지고 있는데, 버퍼가 가득 차면 더 이상 인터럽트를 발생시키지 않습니다. 따라서 인터럽트가 발생할 때마다 즉시 스캔 코드를 읽어 버퍼를 비워줘야 합니다.

스캔 코드는 키를 누르면 make 코드(0x01-0x7F), 떼면 break 코드(make + 0x80)가 전송됩니다. 세 번째로, add_byte()process_keyevent()로 스캔 코드를 처리합니다.

add_byte()는 멀티바이트 시퀀스를 조합하고, process_keyevent()는 모디파이어를 적용하여 최종 키를 생성합니다. 예를 들어, Shift를 누른 상태에서 'a'를 누르면 'A'가 출력되는데, 이는 Keyboard 객체가 Shift 상태를 추적하고 있기 때문입니다.

네 번째로, 디코딩된 키는 DecodedKey 열거형으로 반환됩니다. Unicode 배리언트는 출력 가능한 문자(a-z, 0-9, 공백 등)를 담고, RawKey 배리언트는 특수 키(F1-F12, Home, End 등)를 담습니다.

실무에서는 이를 입력 버퍼에 저장하거나 쉘/에디터로 전달합니다. 여러분이 이 코드를 사용하면 사용자 입력을 효율적으로 받을 수 있고, 쉘, 텍스트 에디터, 게임 등 모든 대화형 애플리케이션의 기반을 구축할 수 있습니다.

CPU 사용률도 최소화됩니다.

실전 팁

💡 키보드 LED(Caps Lock, Num Lock 등)를 제어하려면 포트 0x60에 명령을 쓰세요. 0xED를 쓴 후 LED 상태 바이트를 쓰면 됩니다.

💡 입력 버퍼를 링 버퍼(circular buffer)로 구현하면 빠른 타이핑에도 키 손실이 없습니다. ArrayQueue를 사용하면 락프리로 구현할 수 있습니다.

💡 USB 키보드는 PS/2 에뮬레이션 모드에서만 이 방식으로 작동합니다. 네이티브 USB 지원을 위해서는 USB HID 드라이버가 필요합니다.

💡 스캔 코드 세트 2를 사용하는 키보드도 있습니다. 포트 0x60에 0xF0, 0x00을 쓰면 현재 세트를 확인할 수 있습니다.

💡 키 반복(key repeat) 기능을 구현하려면 타이머와 결합하세요. 키를 누르고 있으면 일정 시간 후 자동으로 반복 입력되도록 할 수 있습니다.


7. 사용자 정의 인터럽트 만들기 - 소프트웨어 인터럽트 활용하기

시작하며

여러분이 OS에서 시스템 콜을 구현하려는데, 유저 스페이스에서 커널 함수를 어떻게 안전하게 호출할지 고민한 적 있나요? 직접 점프하면 보안 문제가 발생하고, 특권 레벨 전환도 제대로 안 됩니다.

이런 문제는 마이크로커널이나 보호된 실행 환경을 구현할 때 필수적으로 해결해야 합니다. 유저 프로그램이 파일 읽기, 네트워크 전송 같은 특권 작업을 요청할 방법이 필요하지만, 직접 커널 메모리에 접근하게 할 수는 없습니다.

바로 이럴 때 필요한 것이 사용자 정의 소프트웨어 인터럽트입니다. IDT의 빈 엔트리(예: 0x80)에 시스템 콜 핸들러를 등록하고, 유저 프로그램이 int 0x80을 실행하면 안전하게 커널 모드로 전환됩니다.

개요

간단히 말해서, 사용자 정의 인터럽트는 IDT의 사용되지 않는 인덱스(32-255)에 직접 핸들러를 등록하여 소프트웨어적으로 트리거할 수 있는 메커니즘입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 시스템 콜은 현대 OS의 핵심 인터페이스입니다(Linux는 전통적으로 int 0x80 사용).

DPL(Descriptor Privilege Level)을 3으로 설정하면 유저 모드(링 3)에서도 이 인터럽트를 트리거할 수 있지만, 핸들러는 커널 모드(링 0)에서 실행됩니다. 예를 들어, 유저 프로그램이 read() 시스템 콜을 호출하면 내부적으로 int 0x80이 실행되어 커널의 파일 시스템 코드가 실행됩니다.

기존에는 특권 레벨 전환을 수동으로 관리해야 했다면, 이제는 CPU가 자동으로 스택 전환, 세그먼트 레지스터 변경, 플래그 저장 등을 처리합니다. 사용자 정의 인터럽트의 핵심 특징은 첫째, DPL 설정으로 호출 가능한 특권 레벨을 제어할 수 있다는 것, 둘째, 레지스터를 통해 파라미터를 전달할 수 있다는 것, 셋째, syscall/sysret 명령어보다 간단하지만 약간 느리다는 것입니다.

코드 예제

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

pub const SYSCALL_INTERRUPT_ID: u8 = 0x80;

pub fn init_idt() {
    let mut idt = InterruptDescriptorTable::new();

    unsafe {
        // 시스템 콜 인터럽트 설정
        idt[SYSCALL_INTERRUPT_ID as usize]
            .set_handler_fn(syscall_handler)
            // DPL을 3으로 설정하여 유저 모드에서 호출 가능하게 함
            .set_privilege_level(PrivilegeLevel::Ring3);

        idt.load();
    }
}

extern "x86-interrupt" fn syscall_handler(stack_frame: InterruptStackFrame) {
    // 레지스터에서 시스템 콜 번호와 인자를 읽습니다 (실제로는 인라인 어셈블리 필요)
    // RAX: 시스템 콜 번호, RDI, RSI, RDX: 인자들

    // 시스템 콜 번호에 따라 분기
    // match syscall_number {
    //     1 => sys_write(...),
    //     2 => sys_read(...),
    //     _ => return Err(EINVAL),
    // }

    println!("System call invoked from user space!");
}

// 유저 스페이스에서 이렇게 호출합니다
pub unsafe fn trigger_syscall() {
    core::arch::asm!("int 0x80");
}

설명

이것이 하는 일: 이 코드는 0x80 인터럽트를 시스템 콜 인터페이스로 등록하고, 유저 프로그램이 안전하게 커널 기능을 호출할 수 있도록 설정합니다. 첫 번째로, IDT의 0x80 인덱스에 핸들러를 설정합니다.

0x80을 선택한 이유는 Linux와의 호환성을 위해서인데, 사실 32-255 사이의 아무 번호나 사용할 수 있습니다. 중요한 것은 하드웨어 인터럽트(32-47)와 겹치지 않아야 한다는 점입니다.

두 번째로, set_privilege_level(Ring3)을 호출합니다. 이것이 핵심입니다!

DPL을 3으로 설정하면 링 3(유저 모드)에서 실행되는 코드가 이 인터럽트를 트리거할 수 있습니다. 만약 DPL이 0(기본값)이면 유저 모드에서 int 0x80을 실행하면 General Protection Fault(GPF)가 발생합니다.

CPU는 현재 특권 레벨(CPL)과 게이트의 DPL을 비교하여 접근 권한을 검증합니다. 세 번째로, 핸들러는 레지스터에서 파라미터를 읽습니다.

x86-64 시스템 콜 규약에 따르면 RAX는 시스템 콜 번호, RDI/RSI/RDX/R10/R8/R9는 인자 1-6입니다. 예를 들어, write(fd, buf, count)를 호출하면 RAX=1(write), RDI=fd, RSI=buf, RDX=count가 설정됩니다.

인라인 어셈블리로 이 값들을 읽어 Rust 함수로 전달합니다. 네 번째로, CPU는 인터럽트 발생 시 자동으로 특권 레벨을 0으로 전환하고, TSS에 지정된 커널 스택으로 전환합니다.

유저 스택 포인터와 코드 세그먼트는 스택에 저장되고, iretq로 반환하면 자동으로 복원됩니다. 이 메커니즘 덕분에 유저와 커널 메모리가 완전히 분리됩니다.

여러분이 이 코드를 사용하면 완전한 시스템 콜 인터페이스를 구축할 수 있습니다. 유저 프로그램은 안전하게 파일 I/O, 네트워크, 프로세스 관리 등의 커널 기능을 요청할 수 있습니다.

실전 팁

💡 최신 시스템에서는 syscall/sysret 명령어를 사용하세요. int 명령어보다 훨씬 빠르며(약 30% 성능 향상), MSR 레지스터로 핸들러를 설정합니다.

💡 시스템 콜 핸들러에서 유저 포인터를 역참조하기 전에 반드시 검증하세요. 유저가 커널 메모리 주소를 전달하면 보안 위반입니다.

💡 각 시스템 콜마다 별도의 함수를 만들고, 점프 테이블로 디스패치하세요. match 문은 시스템 콜이 많아지면 비효율적입니다.

💡 에러 반환 규약을 명확히 하세요. Linux는 RAX에 음수 에러 코드(-EINVAL 등)를 반환하고, 유저 스페이스 래퍼가 errno를 설정합니다.

💡 strace 같은 도구를 구현하려면 시스템 콜 진입/종료 시점에 훅을 추가하여 인자와 반환값을 로깅할 수 있습니다.


8. APIC 설정하기 - 멀티코어 인터럽트 관리

시작하며

여러분이 멀티코어 시스템에서 OS를 개발하는데, 한 코어에만 모든 인터럽트가 전달되어 병목이 발생하는 상황을 겪어본 적 있나요? 네트워크 패킷이 초당 수백만 개 들어오는데 하나의 CPU만 처리한다면 성능이 형편없겠죠.

이런 문제는 레거시 8259 PIC의 근본적인 한계입니다. PIC는 단일 CPU만 지원하고, 인터럽트 우선순위 관리가 제한적이며, 최대 15개의 인터럽트만 처리할 수 있습니다.

현대의 멀티코어 시스템에는 턱없이 부족합니다. 바로 이럴 때 필요한 것이 APIC(Advanced Programmable Interrupt Controller)입니다.

각 CPU 코어마다 Local APIC이 있고, I/O APIC가 외부 인터럽트를 여러 코어에 분산시켜 진정한 병렬 인터럽트 처리를 가능하게 합니다.

개요

간단히 말해서, APIC는 멀티코어 시스템을 위한 고급 인터럽트 컨트롤러로, Local APIC(각 코어마다)와 I/O APIC(외부 장치용)로 구성되어 인터럽트를 분산 처리합니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 서버나 데스크톱 PC는 모두 APIC를 사용합니다.

Local APIC는 코어 간 인터럽트(IPI), 로컬 타이머, 에러 인터럽트를 관리하고, I/O APIC는 24개 이상의 외부 인터럽트를 지원하며 로드 밸런싱을 제공합니다. 예를 들어, 네트워크 카드의 인터럽트를 여러 코어에 분산시켜 처리량을 극대화할 수 있습니다.

기존 PIC에서는 모든 인터럽트가 하나의 코어로 집중됐다면, 이제는 APIC가 자동으로 인터럽트를 가장 적게 바쁜 코어로 라우팅할 수 있습니다. APIC의 핵심 특징은 첫째, MMIO(Memory-Mapped I/O)를 통해 접근한다는 것, 둘째, 벡터 번호와 우선순위를 세밀하게 제어할 수 있다는 것, 셋째, IPI를 통해 멀티코어 동기화가 가능하다는 것입니다.

코드 예제

use x86_64::structures::paging::PhysAddr;
use x86_64::registers::model_specific::Msr;

const IA32_APIC_BASE_MSR: u32 = 0x1B;
const APIC_SPURIOUS_INTERRUPT_VECTOR: usize = 0xF0;
const APIC_EOI_REGISTER: usize = 0xB0;

pub struct LocalApic {
    base_addr: *mut u32,
}

impl LocalApic {
    pub unsafe fn new() -> Self {
        // MSR에서 APIC 베이스 주소 읽기
        let mut apic_base_msr = Msr::new(IA32_APIC_BASE_MSR);
        let apic_base = apic_base_msr.read() & 0xFFFF_F000;

        // 베이스 주소를 포인터로 변환 (MMIO)
        let base_addr = apic_base as *mut u32;

        let mut apic = Self { base_addr };
        apic.enable();
        apic
    }

    unsafe fn enable(&mut self) {
        // Spurious Interrupt Vector 레지스터 설정 (비트 8: APIC 활성화)
        let spurious_reg = self.base_addr.add(APIC_SPURIOUS_INTERRUPT_VECTOR / 4);
        spurious_reg.write_volatile(0x100 | 0xFF);  // Enable + Spurious Vector 0xFF
    }

    pub unsafe fn end_of_interrupt(&mut self) {
        // EOI 레지스터에 0 쓰기
        let eoi_reg = self.base_addr.add(APIC_EOI_REGISTER / 4);
        eoi_reg.write_volatile(0);
    }
}

설명

이것이 하는 일: 이 코드는 Local APIC를 초기화하고 활성화하여, 현대적인 멀티코어 인터럽트 처리를 가능하게 합니다. 첫 번째로, IA32_APIC_BASE MSR(Model-Specific Register)에서 APIC의 물리 주소를 읽습니다.

대부분의 시스템에서 이 주소는 0xFEE0_0000이지만, BIOS가 변경할 수 있으므로 MSR을 읽어 확인해야 합니다. 하위 12비트는 플래그이므로 마스킹해서 실제 주소만 추출합니다.

두 번째로, APIC는 메모리 매핑 방식으로 접근합니다. 포트 I/O를 사용하는 PIC와 달리, APIC의 레지스터들은 특정 메모리 주소에 매핑되어 있습니다.

예를 들어, EOI 레지스터는 베이스 + 0xB0 위치에 있습니다. 페이지 테이블에서 이 영역을 캐시 불가능(uncacheable)으로 설정해야 합니다.

세 번째로, Spurious Interrupt Vector 레지스터를 설정하여 APIC를 활성화합니다. 비트 8을 설정하면 APIC가 인터럽트를 받기 시작하고, 하위 8비트는 spurious 인터럽트(하드웨어 잡음으로 발생)의 벡터 번호입니다.

0xFF를 사용하는 것이 일반적입니다. 네 번째로, end_of_interrupt()로 EOI를 전송합니다.

PIC와 달리 APIC는 EOI 레지스터에 아무 값이나 쓰면 됩니다(값은 무시됨). 이는 인터럽트 처리가 완료되었음을 Local APIC에 알리고, 다음 우선순위의 인터럽트가 전달될 수 있게 합니다.

다섯 번째로, I/O APIC 설정(코드에는 미포함)은 별도로 필요합니다. I/O APIC는 또 다른 MMIO 영역(보통 0xFEC0_0000)에 있으며, 리다이렉션 테이블로 IRQ를 벡터와 대상 코어에 매핑합니다.

예를 들어, IRQ 1(키보드)를 벡터 33으로, 코어 0에 전달하도록 설정할 수 있습니다. 여러분이 이 코드를 사용하면 멀티코어 시스템에서 인터럽트를 효율적으로 분산시킬 수 있고, IPI를 통해 코어 간 통신을 구현할 수 있습니다.

SMP(Symmetric Multiprocessing) OS의 필수 구성 요소입니다.

실전 팁

💡 ACPI 테이블(MADT)을 파싱하여 I/O APIC의 주소와 IRQ 리매핑 정보를 얻으세요. BIOS가 기본값을 변경했을 수 있습니다.

💡 PIC를 비활성화하려면 마스크 레지스터(0x21, 0xA1)에 0xFF를 써서 모든 인터럽트를 차단하세요. APIC와 PIC가 동시에 활성화되면 충돌이 발생합니다.

💡 Local APIC 타이머는 코어별로 독립적이므로, 각 코어가 자체 타임 슬라이스를 관리할 수 있습니다. Divide Configuration Register로 주파수를 설정하세요.

💡 IPI(Inter-Processor Interrupt)는 ICR(Interrupt Command Register)에 쓰기로 전송합니다. TLB shootdown, 작업 큐 알림 등에 활용하세요.

💡 x2APIC 모드를 활성화하면 MSR로 직접 접근할 수 있어 성능이 향상됩니다. IA32_APIC_BASE MSR의 비트 10을 설정하세요.


9. 인터럽트 마스킹 제어하기 - 크리티컬 섹션 보호

시작하며

여러분이 멀티태스킹 OS에서 공유 데이터 구조를 수정하는 중에 타이머 인터럽트가 발생해서 다른 스레드로 전환되고, 그 스레드가 같은 데이터를 수정하려다 데이터가 손상되는 상황을 겪어본 적 있나요? 이런 경합 조건(race condition)은 찾기도 어렵고 재현도 어렵습니다.

이런 문제는 멀티태스킹 환경에서 불가피하게 발생합니다. 스핀락만으로는 충분하지 않을 때가 있는데, 인터럽트 핸들러와 일반 코드가 같은 자원을 공유하는 경우가 대표적입니다.

데드락이나 데이터 손상이 발생할 수 있습니다. 바로 이럴 때 필요한 것이 인터럽트 마스킹입니다.

크리티컬 섹션(critical section) 동안 일시적으로 인터럽트를 비활성화하여, 아토믹한 작업을 보장할 수 있습니다.

개요

간단히 말해서, 인터럽트 마스킹은 RFLAGS 레지스터의 IF(Interrupt Flag) 비트를 조작하거나 PIC/APIC의 마스크 레지스터를 설정하여 인터럽트 처리를 일시 중단하는 기법입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 커널 개발에서 가장 기본적인 동기화 기법입니다.

예를 들어, 스케줄러가 실행 큐를 수정하는 동안 타이머 인터럽트가 발생하면 큐가 손상될 수 있습니다. 또한 스핀락을 구현할 때도 인터럽트를 비활성화해야 데드락을 방지할 수 있습니다(인터럽트 핸들러가 같은 락을 획득하려다 영원히 기다릴 수 있음).

기존에는 복잡한 락 프로토콜로 동기화해야 했다면, 이제는 짧은 크리티컬 섹션에서는 간단히 인터럽트를 끄고 작업할 수 있습니다. 인터럽트 마스킹의 핵심 특징은 첫째, cli(Clear Interrupt Flag)와 sti(Set Interrupt Flag) 명령어로 간단히 제어할 수 있다는 것, 둘째, 현재 CPU에만 영향을 미친다는 것(다른 코어는 계속 인터럽트 받음), 셋째, NMI(Non-Maskable Interrupt)는 막을 수 없다는 것입니다.

코드 예제

use x86_64::instructions::interrupts;

// 인터럽트를 비활성화하고 이전 상태를 반환
pub struct InterruptGuard {
    were_enabled: bool,
}

impl InterruptGuard {
    pub fn new() -> Self {
        let were_enabled = interrupts::are_enabled();
        interrupts::disable();
        Self { were_enabled }
    }
}

impl Drop for InterruptGuard {
    fn drop(&mut self) {
        if self.were_enabled {
            interrupts::enable();
        }
    }
}

// 크리티컬 섹션 예시
pub fn update_shared_data() {
    // RAII 패턴으로 인터럽트 비활성화
    let _guard = InterruptGuard::new();

    // 이 블록 안에서는 인터럽트가 발생하지 않습니다
    unsafe {
        SHARED_COUNTER += 1;
        SHARED_LIST.push(42);
    }

    // _guard가 drop되면 자동으로 인터럽트 재활성화
}

// 또는 클로저 방식
pub fn without_interrupts<F, R>(f: F) -> R
where
    F: FnOnce() -> R,
{
    interrupts::without_interrupts(|| f())
}

설명

이것이 하는 일: 이 코드는 RAII 패턴을 활용하여 스코프 기반으로 인터럽트를 안전하게 비활성화하고, 스코프를 벗어나면 자동으로 복원합니다. 첫 번째로, InterruptGuard::new()에서 interrupts::are_enabled()로 현재 IF 플래그 상태를 저장합니다.

이는 중첩된 크리티컬 섹션에서 중요합니다. 만약 이미 인터럽트가 비활성화된 상태에서 또 비활성화하고, 나중에 무조건 활성화하면 의도치 않게 인터럽트가 켜질 수 있습니다.

이전 상태를 저장하면 이런 문제를 방지합니다. 두 번째로, interrupts::disable()는 내부적으로 cli 명령어를 실행합니다.

이 명령어는 RFLAGS 레지스터의 IF 비트를 0으로 설정하여, CPU가 마스커블 인터럽트를 무시하게 만듭니다. 하지만 NMI(메모리 패리티 에러 같은 심각한 하드웨어 문제)는 여전히 발생하므로, 크리티컬 섹션에서 NMI 핸들러와 공유하는 데이터는 추가 보호가 필요합니다.

세 번째로, 크리티컬 섹션에서 공유 데이터를 안전하게 수정합니다. 이 시점에는 타이머 인터럽트, 키보드 인터럽트 등이 모두 지연되어 큐에 쌓입니다.

인터럽트가 재활성화되면 누적된 인터럽트가 한꺼번에 처리되므로, 크리티컬 섹션을 최소한으로 짧게 유지해야 합니다(권장: 10us 이하). 네 번째로, Drop 트레이트 구현이 자동으로 복원을 처리합니다.

설령 패닉이나 early return이 발생해도 Rust의 언와인딩이 drop()을 호출하여 인터럽트를 복원합니다. 이는 C의 수동 sti 호출보다 훨씬 안전한 패턴입니다.

만약 복원을 깜빡하면 시스템 전체가 인터럽트를 받지 못하게 됩니다. 여러분이 이 코드를 사용하면 커널 데이터 구조를 안전하게 보호하고, 경합 조건을 제거할 수 있습니다.

스핀락과 결합하면 SMP 시스템에서도 완전한 동기화를 달성할 수 있습니다.

실전 팁

💡 인터럽트 비활성화는 최후의 수단입니다. 가능하면 락프리 자료구조나 RCU(Read-Copy-Update) 같은 기법을 우선 고려하세요.

💡 크리티컬 섹션의 길이를 측정하세요. 너무 길면 인터럽트 레이턴시가 증가하여 실시간성이 떨어집니다. QEMU의 -d int 로그로 인터럽트 지연을 추적할 수 있습니다.

💡 유저 스페이스에서는 cli를 실행할 수 없습니다(특권 명령어). 유저 프로그램은 뮤텍스나 세마포어를 사용해야 합니다.

💡 개별 인터럽트만 마스킹하려면 PIC의 IMR(Interrupt Mask Register)이나 APIC의 LINT 레지스터를 사용하세요. IF 플래그는 모든 인터럽트를 막습니다.

💡 preempt_disable()interrupt_disable()을 구분하세요. 전자는 스케줄링만 막고, 후자는 모든 인터럽트를 막습니다. 목적에 맞게 선택하세요.


10. 예외 스택 프레임 분석하기 - 크래시 디버깅의 핵심

시작하며

여러분의 OS가 갑자기 크래시했을 때, "어디서 왜 문제가 생긴 거지?"라고 막막했던 경험 있으신가요? 스택 트레이스도 없고, 디버거도 붙일 수 없는 상황에서 단서를 찾기는 정말 어렵습니다.

이런 문제는 커널 개발의 가장 큰 고충 중 하나입니다. 유저 스페이스와 달리 커널 크래시는 전체 시스템을 다운시키므로, 사후 분석을 위한 정보를 최대한 수집해야 합니다.

하지만 어떤 정보가 어디에 있는지 모르면 소용이 없습니다. 바로 이럴 때 필요한 것이 예외 스택 프레임 분석입니다.

CPU가 예외 발생 시 자동으로 스택에 저장하는 정보를 제대로 읽고 해석하면, 크래시 시점의 정확한 상태를 재구성할 수 있습니다.

개요

간단히 말해서, InterruptStackFrame은 CPU가 인터럽트/예외 발생 시 스택에 자동으로 푸시하는 정보(RIP, CS, RFLAGS, RSP, SS)를 구조체로 표현한 것입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 이 정보가 크래시 분석의 핵심 단서입니다.

Instruction Pointer(RIP)는 문제가 발생한 정확한 코드 위치를, Stack Pointer(RSP)는 스택 상태를, RFLAGS는 조건 플래그와 인터럽트 상태를 알려줍니다. 예를 들어, 페이지 폴트가 발생했을 때 RIP를 심볼 테이블과 대조하면 어떤 함수에서 문제가 생겼는지 즉시 알 수 있습니다.

기존에는 레지스터 덤프를 수동으로 해석해야 했다면, 이제는 x86_64 크레이트가 제공하는 타입 안전한 API로 쉽게 접근할 수 있습니다. 스택 프레임의 핵심 특징은 첫째, CPU가 자동으로 생성하므로 신뢰할 수 있다는 것, 둘째, 특권 레벨 전환 시 추가 정보(RSP, SS)가 포함된다는 것, 셋째, 에러 코드를 푸시하는 예외도 있다는 것입니다.

코드 예제

use x86_64::structures::idt::InterruptStackFrame;
use x86_64::VirtAddr;

extern "x86-interrupt" fn debug_exception_handler(
    stack_frame: InterruptStackFrame
) {
    println!("=== EXCEPTION DEBUG INFO ===");

    // 예외 발생 시점의 명령어 주소
    let rip = stack_frame.instruction_pointer.as_u64();
    println!("Instruction Pointer (RIP): {:#x}", rip);

    // 예외 발생 시점의 스택 포인터
    let rsp = stack_frame.stack_pointer.as_u64();
    println!("Stack Pointer (RSP): {:#x}", rsp);

    // CPU 플래그 레지스터 (조건 플래그, IF 등)
    let flags = stack_frame.cpu_flags;
    println!("CPU Flags: {:#x}", flags);
    println!("  Carry: {}", flags & 1 != 0);
    println!("  Zero: {}", flags & (1 << 6) != 0);
    println!("  Interrupts Enabled: {}", flags & (1 << 9) != 0);

    // 코드 세그먼트 (커널 vs 유저 구분)
    let cs = stack_frame.code_segment;
    println!("Code Segment: {:#x} (Ring {})", cs, cs & 3);

    // 스택 백트레이스 시작점
    print_stack_trace(VirtAddr::new(rsp));
}

fn print_stack_trace(stack_pointer: VirtAddr) {
    println!("\n=== STACK BACKTRACE ===");
    // 스택을 역추적하여 호출 체인 출력
    // 실제로는 프레임 포인터(RBP)를 따라가며 리턴 주소들을 수집
}

설명

이것이 하는 일: 이 코드는 예외 핸들러에서 InterruptStackFrame을 상세히 분석하여 크래시 디버깅에 필요한 모든 정보를 추출하고 출력합니다. 첫 번째로, instruction_pointer에서 RIP 값을 읽습니다.

이는 예외가 발생한 순간의 명령어 주소입니다. 심볼 테이블(컴파일 시 생성)이 있다면 이 주소를 함수명과 오프셋으로 변환할 수 있습니다.

예를 들어, 0x100234some_function + 0x14로 표시되면, 그 함수의 20바이트 위치에서 문제가 발생했음을 알 수 있습니다. 두 번째로, stack_pointer로 RSP 값을 얻습니다.

스택 오버플로우 검사에 유용합니다. 만약 RSP가 스택 영역의 범위를 벗어났다면(가드 페이지 근처), 스택 고갈이 원인입니다.

또한 RSP를 시작점으로 스택 백트레이스를 구성할 수 있습니다. 각 함수 호출은 리턴 주소를 스택에 남기므로, 호출 체인을 역추적할 수 있습니다.

세 번째로, cpu_flags를 비트 단위로 분석합니다. Carry Flag(비트 0)는 산술 오버플로우를, Zero Flag(비트 6)는 비교 결과를, Interrupt Flag(비트 9)는 예외 발생 시 인터럽트가 활성화되어 있었는지를 알려줍니다.

Direction Flag(비트 10)는 문자열 명령어의 방향을 제어하는데, 잘못 설정되면 버퍼 오버런을 일으킬 수 있습니다. 네 번째로, code_segment로 특권 레벨을 확인합니다.

하위 2비트(RPL)가 3이면 유저 모드에서 예외가 발생한 것이고, 0이면 커널 모드입니다. 커널 모드 크래시는 더 심각하므로, 이 정보로 우선순위를 판단할 수 있습니다.

유저 프로그램의 버그와 커널 버그를 구분하는 데도 중요합니다. 다섯 번째로, 실무에서는 이 정보를 시리얼 포트나 메모리 버퍼에 저장하여 재부팅 후 분석합니다.

QEMU의 -serial file:serial.log 옵션으로 크래시 로그를 파일에 저장하면, GDB의 info symbol <address> 명령으로 주소를 함수명으로 변환할 수 있습니다. 여러분이 이 코드를 사용하면 커널 크래시 원인을 빠르게 파악할 수 있고, 사후 분석을 위한 충분한 정보를 수집할 수 있습니다.

디버깅 시간을 크게 단축시킵니다.

실전 팁

💡 컴파일 시 -C force-frame-pointers=yes 옵션을 사용하면 RBP 기반 스택 트레이스가 가능해집니다. 성능은 약간 희생되지만 디버깅이 훨씬 쉬워집니다.

💡 에러 코드를 푸시하는 예외(페이지 폴트, 더블 폴트 등)는 스택 레이아웃이 다릅니다. 핸들러 시그니처에 error_code 파라미터를 추가해야 스택이 올바르게 정렬됩니다.

💡 gimli 크레이트로 DWARF 디버그 정보를 파싱하면 런타임에 주소를 소스 코드 라인으로 변환할 수 있습니다. Rust의 std::backtrace와 유사한 기능입니다.

💡 크래시 덤프를 네트워크로 전송하는 기능을 구현하면, 원격 서버에서 자동 분석이 가능합니다. kdump 같은 도구가 이 방식을 사용합니다.

💡 RFLAGS의 Trap Flag(비트 8)를 설정하면 단일 스텝 모드가 활성화됩니다. 각 명령어마다 디버그 예외가 발생하여 세밀한 추적이 가능합니다.


#Rust#IDT#InterruptHandler#SystemProgramming#OSDevelpment#시스템프로그래밍

댓글 (0)

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