이미지 로딩 중...

Rust로 만드는 나만의 OS 키보드 인터럽트 처리 - 슬라이드 1/11
A

AI Generated

2025. 11. 14. · 5 Views

Rust로 만드는 나만의 OS 키보드 인터럽트 처리

OS 개발에서 키보드 입력을 처리하는 인터럽트 핸들러 구현 방법을 다룹니다. x86 아키텍처의 PIC, IDT 설정부터 실제 키 입력 처리까지 실무에 필요한 모든 과정을 상세히 설명합니다.


목차

  1. 인터럽트 디스크립터 테이블(IDT) 설정 - CPU에게 인터럽트 핸들러 위치 알려주기
  2. PIC(Programmable Interrupt Controller) 초기화 - 하드웨어 인터럽트를 CPU로 전달하기
  3. 키보드 인터럽트 핸들러 구현 - 실제 키 입력 받기
  4. 스캔코드를 문자로 변환하기 - 하드웨어 신호를 사람이 읽을 수 있는 텍스트로
  5. 비동기 키보드 입력 큐 - 인터럽트와 메인 로직 분리하기
  6. PS/2 키보드 컨트롤러 명령어 - 하드웨어 설정 및 제어
  7. 인터럽트 스택 프레임 분석 - CPU 상태 디버깅하기
  8. 더블 폴트 방지 - 인터럽트 중 예외 처리하기
  9. 키보드 입력 버퍼링 및 라인 편집 - 실용적인 쉘 입력 구현하기
  10. NMI와 다른 인터럽트 우선순위 - 중요한 인터럽트 놓치지 않기

1. 인터럽트 디스크립터 테이블(IDT) 설정 - CPU에게 인터럽트 핸들러 위치 알려주기

시작하며

여러분이 운영체제를 만들면서 키보드 입력을 받으려고 할 때 이런 상황을 겪어본 적 있나요? 키를 눌러도 아무런 반응이 없고, CPU는 그저 무한 루프를 돌면서 할 일이 없다고 대기만 하는 상황 말이죠.

이런 문제는 실제 베어메탈 프로그래밍에서 자주 발생합니다. CPU는 외부 하드웨어(키보드, 마우스 등)로부터 신호를 받을 준비가 되어있지 않기 때문입니다.

인터럽트가 발생해도 어디로 점프해야 할지 모르는 CPU는 그냥 패닉에 빠지거나 무시해버립니다. 바로 이럴 때 필요한 것이 인터럽트 디스크립터 테이블(IDT)입니다.

IDT는 CPU에게 "이런 인터럽트가 발생하면 저기 있는 함수를 실행해"라고 알려주는 일종의 전화번호부 역할을 합니다.

개요

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

예를 들어, 키보드 컨트롤러가 IRQ 1번을 발생시켜도 CPU는 이것을 어떻게 처리해야 할지 전혀 모릅니다. IDT를 통해 이 IRQ를 특정 핸들러 함수와 연결해야만 합니다.

전통적인 방법으로는 어셈블리로 직접 IDT를 구성하고 LIDT 명령어로 로드했다면, Rust에서는 x86_64 크레이트를 사용하여 타입 안전하게 IDT를 구성할 수 있습니다. IDT의 핵심 특징은 첫째, 각 엔트리가 핸들러 주소와 함께 권한 레벨, 세그먼트 셀렉터를 포함한다는 점입니다.

둘째, CPU는 IDTR 레지스터를 통해 IDT의 위치를 알아냅니다. 셋째, 인터럽트 발생 시 자동으로 현재 상태를 스택에 저장하고 핸들러로 점프합니다.

이러한 특징들이 안전하고 예측 가능한 인터럽트 처리를 가능하게 합니다.

코드 예제

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

// IDT를 정적으로 선언 (커널 생명주기 동안 유지)
lazy_static! {
    static ref IDT: InterruptDescriptorTable = {
        let mut idt = InterruptDescriptorTable::new();
        // 키보드 인터럽트(IRQ 1 = 인터럽트 33번)를 핸들러와 연결
        idt[33].set_handler_fn(keyboard_interrupt_handler);
        idt
    };
}

pub fn init_idt() {
    // CPU에게 IDT 위치를 알려줌
    IDT.load();
}

설명

이것이 하는 일: 이 코드는 Rust로 인터럽트 디스크립터 테이블을 생성하고 CPU에 등록하는 전체 과정을 보여줍니다. lazy_static을 사용하여 컴파일 타임에 정적 IDT를 만들고, 런타임에 CPU에 로드합니다.

첫 번째로, lazy_static 매크로 부분은 전역 IDT를 생성합니다. IDT는 커널이 부팅될 때 한 번만 초기화되고 영원히 유지되어야 하기 때문에 'static 라이프타임이 필요합니다.

InterruptDescriptorTable::new()로 기본 IDT를 만든 후, 33번 엔트리(IRQ 1에 해당)에 우리의 키보드 핸들러 함수를 등록합니다. 왜 33번이냐면, Intel 8259 PIC는 IRQ 0-15를 인터럽트 32-47번으로 매핑하기 때문입니다.

그 다음으로, set_handler_fn 메서드가 실행되면서 핸들러 함수의 주소를 IDT 엔트리에 저장합니다. 내부적으로는 함수 포인터를 적절한 형식으로 변환하고, 필요한 플래그(present bit, privilege level 등)를 설정합니다.

x86_64 크레이트가 이 복잡한 과정을 안전하게 추상화해줍니다. 마지막으로, init_idt() 함수에서 IDT.load()를 호출하면 LIDT 어셈블리 명령어가 실행됩니다.

이 명령어는 IDT의 메모리 주소와 크기를 CPU의 IDTR 레지스터에 기록합니다. 이제 CPU는 인터럽트가 발생하면 이 레지스터를 참조하여 적절한 핸들러를 찾아 실행할 수 있습니다.

여러분이 이 코드를 사용하면 타입 안전성을 보장받으면서도 저수준 하드웨어를 제어할 수 있습니다. Rust의 타입 시스템이 잘못된 핸들러 시그니처를 컴파일 타임에 잡아주고, lazy_static이 초기화 순서 문제를 해결해주며, x86_64 크레이트가 복잡한 비트 조작을 추상화해줍니다.

실전 팁

💡 IDT는 반드시 페이징이 활성화되기 전에 로드하세요. 페이징 폴트 핸들러조차 IDT에 등록되어야 하기 때문입니다.

💡 핸들러 함수는 절대 반환하지 않는 diverging function이거나 InterruptStackFrame을 매개변수로 받아야 합니다. 잘못된 시그니처는 컴파일 에러를 발생시킵니다.

💡 lazy_static 대신 spin::Once를 사용하면 초기화 오버헤드를 줄일 수 있지만, lazy_static이 더 안전하고 직관적입니다.

💡 디버깅 시 qemu의 -d int 옵션을 사용하면 모든 인터럽트를 로그로 볼 수 있어 IDT가 제대로 작동하는지 확인할 수 있습니다.

💡 각 인터럽트 핸들러에 별도의 스택을 할당하려면 IST(Interrupt Stack Table)를 사용하세요. 더블 폴트 방지에 필수적입니다.


2. PIC(Programmable Interrupt Controller) 초기화 - 하드웨어 인터럽트를 CPU로 전달하기

시작하며

여러분이 IDT를 완벽하게 설정했는데도 키보드를 눌러도 아무런 반응이 없는 상황을 경험해본 적 있나요? CPU는 준비되었지만, 키보드 컨트롤러와 CPU 사이의 중개자가 제대로 설정되지 않은 경우입니다.

이런 문제는 레거시 x86 시스템에서 필연적으로 발생합니다. 하드웨어 장치들은 직접 CPU와 통신할 수 없고, PIC(Programmable Interrupt Controller)라는 칩을 통해야 합니다.

이 PIC가 제대로 초기화되지 않으면 모든 하드웨어 인터럽트가 CPU에 도달하지 못합니다. 바로 이럴 때 필요한 것이 PIC 초기화입니다.

PIC는 여러 하드웨어 장치의 인터럽트를 받아서 우선순위를 정하고 CPU에 하나씩 전달하는 교통정리 역할을 합니다.

개요

간단히 말해서, PIC는 외부 하드웨어 장치와 CPU 사이의 인터럽트 라우터로, 최대 15개의 IRQ 라인을 관리합니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, x86 시스템은 마스터와 슬레이브 두 개의 8259 PIC 칩을 캐스케이드 방식으로 연결하여 총 15개의 IRQ를 처리합니다.

예를 들어, 키보드는 IRQ 1번, 마우스는 IRQ 12번을 사용하는데, PIC가 없으면 이들의 신호가 CPU에 전달될 방법이 없습니다. 또한 PIC는 인터럽트를 어떤 벡터 번호로 매핑할지 설정할 수 있어, 소프트웨어 예외(0-31번)와 충돌을 피할 수 있습니다.

전통적인 BIOS는 PIC를 인터럽트 8-15번으로 매핑했지만, 이는 CPU 예외와 충돌합니다. 따라서 우리는 PIC를 재초기화하여 IRQ를 32-47번으로 리매핑해야 합니다.

PIC의 핵심 특징은 첫째, 각 IRQ 라인을 개별적으로 마스크(비활성화)할 수 있다는 점입니다. 둘째, EOI(End Of Interrupt) 신호를 보내야 다음 인터럽트를 받을 수 있습니다.

셋째, 마스터-슬레이브 구조로 IRQ 2번이 슬레이브 PIC에 연결되어 있습니다. 이러한 특징들이 복잡한 하드웨어 환경에서도 안정적인 인터럽트 처리를 가능하게 합니다.

코드 예제

use pic8259::ChainedPics;
use spin::Mutex;

// PIC 오프셋: IRQ 0-15를 인터럽트 32-47로 매핑
pub const PIC_1_OFFSET: u8 = 32;
pub const PIC_2_OFFSET: u8 = 40;

// 전역 PIC 인스턴스 (Mutex로 동기화)
pub static PICS: Mutex<ChainedPics> =
    Mutex::new(unsafe { ChainedPics::new(PIC_1_OFFSET, PIC_2_OFFSET) });

pub fn init_pics() {
    unsafe {
        // PIC 재초기화 및 모든 인터럽트 활성화
        PICS.lock().initialize();
    }
    // 키보드 인터럽트만 활성화하고 나머지는 마스크
    // (선택적: 특정 IRQ만 활성화)
}

설명

이것이 하는 일: 이 코드는 두 개의 8259 PIC 칩을 체인 형태로 초기화하고, 하드웨어 인터럽트를 안전한 벡터 범위로 리매핑하는 과정을 보여줍니다. 첫 번째로, PIC_1_OFFSET과 PIC_2_OFFSET 상수는 IRQ를 어떤 인터럽트 번호로 매핑할지 결정합니다.

32부터 시작하는 이유는 0-31번이 CPU 예외(division error, page fault 등)로 예약되어 있기 때문입니다. 마스터 PIC는 IRQ 0-7을 32-39번으로, 슬레이브 PIC는 IRQ 8-15를 40-47번으로 매핑합니다.

이렇게 하면 타이머(IRQ 0)는 인터럽트 32번, 키보드(IRQ 1)는 33번이 됩니다. 그 다음으로, PICS 전역 변수는 Mutex로 보호된 ChainedPics 구조체입니다.

Mutex가 필요한 이유는 인터럽트 핸들러에서 EOI를 보낼 때 PIC에 접근해야 하는데, 여러 핸들러가 동시에 실행될 수 있기 때문입니다. unsafe 블록이 필요한 이유는 PIC 초기화가 I/O 포트를 직접 조작하는 저수준 작업이기 때문입니다.

마지막으로, initialize() 메서드는 ICW(Initialization Command Word) 시퀀스를 PIC에 전송합니다. 이 과정에서 PIC는 리셋되고, 벡터 오프셋이 설정되며, 마스터-슬레이브 연결이 구성됩니다.

pic8259 크레이트가 복잡한 I/O 포트 시퀀스를 추상화해주지만, 내부적으로는 0x20, 0x21, 0xA0, 0xA1 포트에 정확한 순서로 바이트를 쓰는 작업이 일어납니다. 여러분이 이 코드를 사용하면 하드웨어 인터럽트를 안전하게 받을 수 있는 환경이 갖춰집니다.

PIC 없이는 타이머, 키보드, 마우스, 네트워크 카드 등 모든 I/O 장치를 폴링 방식으로만 처리해야 하는데, 이는 CPU 시간을 엄청나게 낭비합니다. 인터럽트 기반 I/O는 장치가 준비되었을 때만 CPU가 개입하므로 훨씬 효율적입니다.

실전 팁

💡 PIC 초기화는 IDT 로드 직후, 인터럽트 활성화(sti 명령어) 직전에 해야 합니다. 순서가 바뀌면 처리되지 않은 인터럽트로 인해 패닉이 발생할 수 있습니다.

💡 실제 하드웨어에서는 APIC/x2APIC가 PIC를 대체했지만, 호환성을 위해 여전히 PIC를 비활성화하는 코드가 필요합니다. 단순히 모든 IRQ를 마스크하면 됩니다.

💡 EOI를 보내는 것을 잊으면 해당 IRQ의 다음 인터럽트가 절대 발생하지 않습니다. 이는 매우 흔한 버그이므로 핸들러 끝에 반드시 PICS.lock().notify_end_of_interrupt(irq_number)를 호출하세요.

💡 QEMU에서 테스트할 때 -d int,cpu_reset 옵션을 사용하면 PIC 설정 문제를 빠르게 발견할 수 있습니다.

💡 멀티코어 시스템에서는 PIC 대신 APIC를 사용해야 하지만, 부팅 초기에는 BSP(Bootstrap Processor)만 활성화되므로 PIC로 시작해도 안전합니다.


3. 키보드 인터럽트 핸들러 구현 - 실제 키 입력 받기

시작하며

여러분이 IDT와 PIC를 모두 설정했는데, 이제 정말로 키를 눌렀을 때 어떤 일이 일어나야 할지 고민해본 적 있나요? 인터럽트는 발생하지만 정작 키 코드를 읽고 처리하는 코드가 없다면 아무 의미가 없습니다.

이런 문제는 시스템 프로그래밍의 핵심 과제입니다. 하드웨어가 신호를 보내면 소프트웨어가 즉시 반응해야 하고, 데이터를 읽어서 적절히 처리한 후 하드웨어에게 "받았다"는 응답을 보내야 합니다.

이 과정 중 하나라도 빠뜨리면 시스템이 멈추거나 데이터를 잃게 됩니다. 바로 이럴 때 필요한 것이 인터럽트 핸들러 함수입니다.

이 함수는 CPU가 자동으로 호출하며, 키보드 컨트롤러에서 스캔코드를 읽고 처리하는 모든 책임을 집니다.

개요

간단히 말해서, 키보드 인터럽트 핸들러는 IRQ 1이 발생했을 때 CPU가 자동으로 호출하는 함수로, 키보드 데이터 포트(0x60)에서 스캔코드를 읽어 처리합니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 키보드 컨트롤러는 내부에 작은 버퍼만 가지고 있어서 빠르게 데이터를 읽어주지 않으면 오버런이 발생합니다.

예를 들어, 사용자가 빠르게 타이핑하면 각 키 입력마다 인터럽트가 발생하는데, 핸들러가 느리거나 없으면 일부 키 입력이 손실됩니다. 또한 EOI를 보내지 않으면 다음 키보드 인터럽트를 받을 수 없어 시스템이 먹통이 됩니다.

전통적인 방법으로는 어셈블리로 스택 프레임을 수동으로 관리하고 I/O 포트를 직접 읽었다면, Rust에서는 x86_64 크레이트의 InterruptStackFrame과 타입 안전한 포트 I/O를 사용할 수 있습니다. 핸들러의 핵심 특징은 첫째, 절대 패닉을 일으켜서는 안 된다는 점입니다(인터럽트 중 패닉은 더블 폴트로 이어짐).

둘째, 가능한 빠르게 실행되어야 합니다(긴 작업은 큐에 넣고 나중에 처리). 셋째, 리엔트런트해야 합니다(다른 인터럽트가 끼어들 수 있음).

이러한 특징들이 안정적인 실시간 입력 처리를 가능하게 합니다.

코드 예제

use x86_64::structures::idt::InterruptStackFrame;
use x86_64::instructions::port::Port;

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

    // 스캔코드를 큐에 추가 (나중에 처리)
    crate::task::keyboard::add_scancode(scancode);

    unsafe {
        // PIC에 EOI(End Of Interrupt) 신호 전송
        PICS.lock()
            .notify_end_of_interrupt(33);  // IRQ 1 = 인터럽트 33
    }
}

설명

이것이 하는 일: 이 코드는 키보드 인터럽트가 발생했을 때 CPU가 자동으로 호출하는 핸들러 함수를 정의하고, 스캔코드를 읽어서 안전하게 처리하는 전체 흐름을 보여줍니다. 첫 번째로, extern "x86-interrupt" 호출 규약은 일반 함수와 다른 특별한 규칙을 적용합니다.

CPU가 인터럽트를 호출할 때는 에러 코드와 리턴 주소를 스택에 푸시하고, 핸들러는 iretq 명령어로 반환해야 합니다. Rust는 이 복잡한 과정을 자동으로 처리해주지만, 우리는 반드시 이 호출 규약을 지정해야 합니다.

InterruptStackFrame 매개변수는 인터럽트 발생 시점의 CPU 상태(명령어 포인터, 코드 세그먼트, 플래그 등)를 담고 있습니다. 그 다음으로, Port::new(0x60)은 키보드 데이터 포트에 대한 추상화를 생성합니다.

0x60은 PS/2 키보드 컨트롤러의 표준 데이터 포트 주소입니다. port.read()는 in 어셈블리 명령어를 실행하여 8비트 스캔코드를 읽습니다.

이 스캔코드는 어떤 키가 눌렸는지(make code) 또는 떼어졌는지(break code)를 나타내는 하드웨어 의존적인 값입니다. unsafe가 필요한 이유는 I/O 포트 접근이 undefined behavior를 일으킬 수 있기 때문입니다.

세 번째로, add_scancode(scancode) 호출은 실제 키 처리 로직을 인터럽트 핸들러 밖으로 미룹니다. 인터럽트 핸들러는 최대한 빠르게 실행되어야 하므로, 복잡한 처리(스캔코드를 ASCII로 변환, 버퍼에 추가, 화면에 출력 등)는 별도의 태스크에서 수행합니다.

이것이 "top half / bottom half" 패턴입니다. 마지막으로, notify_end_of_interrupt(33)은 PIC에게 "이 인터럽트 처리 끝났음"을 알립니다.

이 EOI 신호를 보내지 않으면 PIC는 같은 IRQ의 다음 인터럽트를 절대 보내지 않습니다. 33은 IRQ 1에 해당하는 인터럽트 벡터 번호입니다.

슬레이브 PIC의 IRQ(8-15번)라면 슬레이브와 마스터 모두에 EOI를 보내야 하는데, pic8259 크레이트가 이를 자동으로 처리해줍니다. 여러분이 이 코드를 사용하면 키보드 입력을 놓치지 않고 안전하게 수집할 수 있습니다.

인터럽트 기반 입력 처리는 폴링에 비해 레이턴시가 낮고 CPU 효율이 높습니다. 또한 Rust의 타입 시스템이 일반 함수를 인터럽트 핸들러로 잘못 등록하는 것을 컴파일 타임에 방지해줍니다.

실전 팁

💡 인터럽트 핸들러 내에서는 절대 힙 할당(Box, Vec 등)을 하지 마세요. 할당자가 락을 잡고 있을 때 인터럽트가 발생하면 데드락이 발생합니다.

💡 print! 매크로도 주의해서 사용하세요. VGA 버퍼에 락이 있다면 같은 데드락 문제가 발생할 수 있습니다. lock-free 큐나 별도 버퍼를 사용하세요.

💡 스캔코드는 키보드 레이아웃에 따라 다르므로, 실제 문자로 변환하려면 스캔코드 세트(보통 Set 1 또는 Set 2)를 알아야 합니다. pc-keyboard 크레이트가 이를 처리해줍니다.

💡 디버깅 시 직렬 포트 출력을 사용하세요. 직렬 포트는 락이 필요 없고 인터럽트 핸들러 내에서도 안전하게 사용할 수 있습니다.

💡 QEMU의 -serial mon:stdio 옵션을 사용하면 게스트 OS의 직렬 포트 출력을 호스트 터미널로 볼 수 있어 디버깅이 편합니다.


4. 스캔코드를 문자로 변환하기 - 하드웨어 신호를 사람이 읽을 수 있는 텍스트로

시작하며

여러분이 키보드 인터럽트를 성공적으로 받았는데, 화면에 출력하려니 0x1C, 0x9C 같은 이상한 숫자만 보이는 상황을 겪어본 적 있나요? 스캔코드는 하드웨어가 내보내는 raw 데이터일 뿐, 사람이 이해할 수 있는 형태가 아닙니다.

이런 문제는 키보드 입력을 실제로 활용하려는 모든 OS에서 발생합니다. 같은 'A' 키라도 키보드 종류에 따라 다른 스캔코드를 보낼 수 있고, Shift나 Caps Lock 상태에 따라 대소문자가 달라지며, 국가별 키보드 레이아웃에 따라 완전히 다른 문자가 입력됩니다.

바로 이럴 때 필요한 것이 스캔코드 디코더입니다. 디코더는 스캔코드 세트, 키보드 레이아웃, 모디파이어 키 상태를 모두 고려하여 최종적으로 ASCII 또는 유니코드 문자를 생성합니다.

개요

간단히 말해서, 스캔코드 디코더는 바이트 스트림을 받아서 키보드 레이아웃과 상태 머신을 적용하여 KeyEvent(키가 눌림/떼어짐)와 최종 문자를 생성하는 상태 기반 파서입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 스캔코드는 단순한 일대일 매핑이 아닙니다.

예를 들어, 확장 키(화살표, Page Up/Down 등)는 0xE0 프리픽스 바이트 다음에 실제 코드가 옵니다. Shift+A는 두 개의 스캔코드(Shift down, A down)를 생성하므로 상태를 추적해야 합니다.

또한 같은 물리적 키보드라도 BIOS 설정에 따라 Scan Code Set 1, 2, 3 중 하나를 사용할 수 있습니다. 전통적인 방법으로는 거대한 switch 문이나 룩업 테이블로 직접 구현했다면, Rust에서는 pc-keyboard 크레이트가 검증된 상태 머신과 여러 레이아웃을 제공합니다.

디코더의 핵심 특징은 첫째, 상태 머신 기반이라 이전 입력을 기억한다는 점입니다(멀티바이트 시퀀스 처리). 둘째, 모디파이어 키(Shift, Ctrl, Alt)의 상태를 추적합니다.

셋째, 키보드 레이아웃을 교체 가능한 모듈로 분리하여 다국어 지원이 쉽습니다. 이러한 특징들이 복잡한 입력 시나리오를 정확하게 처리할 수 있게 합니다.

코드 예제

use pc_keyboard::{layouts, DecodedKey, HandleControl, Keyboard, ScancodeSet1};
use spin::Mutex;

lazy_static! {
    // 전역 키보드 디코더 (상태를 유지해야 하므로 static)
    static ref KEYBOARD: Mutex<Keyboard<layouts::Us104Key, ScancodeSet1>> =
        Mutex::new(Keyboard::new(
            layouts::Us104Key,
            ScancodeSet1,
            HandleControl::Ignore
        ));
}

pub fn process_scancode(scancode: u8) {
    let mut keyboard = KEYBOARD.lock();

    // 스캔코드를 KeyEvent로 변환
    if let Ok(Some(key_event)) = keyboard.add_byte(scancode) {
        // KeyEvent를 문자로 변환 (모디파이어 상태 적용)
        if let Some(key) = keyboard.process_keyevent(key_event) {
            match key {
                DecodedKey::Unicode(character) => print!("{}", character),
                DecodedKey::RawKey(key) => print!("{:?}", key),
            }
        }
    }
}

설명

이것이 하는 일: 이 코드는 raw 스캔코드 바이트를 받아서 키보드 레이아웃과 상태를 적용하여 최종적으로 출력 가능한 문자 또는 특수 키 이벤트로 변환하는 전체 파이프라인을 보여줍니다. 첫 번째로, Keyboard 타입은 두 개의 제네릭 파라미터를 받습니다.

layouts::Us104Key는 미국 표준 104키 레이아웃을 의미하며, 어떤 스캔코드가 어떤 키에 대응하는지 정의합니다. ScancodeSet1은 IBM PC/AT 호환 키보드가 사용하는 스캔코드 세트로, 가장 널리 사용됩니다.

HandleControl::Ignore는 Ctrl+C 같은 제어 문자를 어떻게 처리할지 결정하는데, Ignore는 원본 문자를 그대로 반환합니다. 이 구성은 KEYBOARD 전역 변수에 저장되며, Mutex로 보호됩니다.

그 다음으로, add_byte(scancode) 메서드는 상태 머신의 핵심입니다. 이 메서드는 한 바이트를 받아서 내부 상태를 업데이트하고, 완전한 키 이벤트가 완성되었을 때만 Some(KeyEvent)를 반환합니다.

예를 들어, 0xE0(확장 키 프리픽스)을 받으면 내부 상태만 업데이트하고 None을 반환합니다. 다음 바이트(예: 0x48, Up Arrow)가 오면 그제야 KeyEvent를 반환합니다.

또한 make code(키 눌림)와 break code(키 떼어짐)를 구분하여 KeyState::Down 또는 KeyState::Up을 생성합니다. 세 번째로, process_keyevent(key_event)는 KeyEvent를 실제 문자로 변환합니다.

이 과정에서 현재 활성화된 모디파이어 키(Shift, Caps Lock 등)를 고려합니다. 예를 들어, 'A' 키가 눌렸을 때 Shift가 눌려있으면 'A'를, 아니면 'a'를 반환합니다.

반환값은 DecodedKey 열거형인데, Unicode 변형은 출력 가능한 문자를, RawKey 변형은 특수 키(F1, Escape, PageUp 등)를 담습니다. 마지막으로, match 표현식은 두 경우를 처리합니다.

Unicode 문자는 그대로 화면에 출력하고, RawKey는 디버그 형식으로 출력합니다. 실제 쉘이나 텍스트 에디터에서는 RawKey를 특별하게 처리해야 합니다(예: Backspace는 마지막 문자 삭제, Enter는 명령 실행).

여러분이 이 코드를 사용하면 키보드 입력을 사람이 읽을 수 있는 텍스트로 변환할 수 있습니다. pc-keyboard 크레이트는 수년간의 버그 픽스와 엣지 케이스 처리가 녹아있는 검증된 구현입니다.

직접 구현하면 수백 줄의 코드와 수많은 버그를 만나게 되지만, 이 크레이트를 사용하면 10줄로 해결됩니다.

실전 팁

💡 다른 키보드 레이아웃을 지원하려면 layouts::Uk105Key, layouts::Dvorak104Key 등으로 교체하면 됩니다. 런타임에 전환하려면 Box<dyn KeyboardLayout>를 사용하세요.

💡 Scan Code Set 2를 사용하는 키보드도 있습니다. BIOS/UEFI 설정이나 키보드 컨트롤러 명령(0x60 포트의 0xF0)으로 확인하고 변경할 수 있습니다.

💡 HandleControl::MapLettersToUnicode를 사용하면 Ctrl+C가 '\x03' (ETX)로 변환되어 Unix 스타일의 시그널 처리가 가능합니다.

💡 멀티바이트 UTF-8 문자를 지원하려면 DecodedKey::Unicode(char)를 char::encode_utf8()로 변환하여 바이트 배열로 만들어야 합니다.

💡 키 리피트 기능을 구현하려면 타이머 인터럽트에서 마지막 KeyState::Down 이벤트를 주기적으로 재전송하면 됩니다.


5. 비동기 키보드 입력 큐 - 인터럽트와 메인 로직 분리하기

시작하며

여러분이 인터럽트 핸들러에서 직접 화면에 출력하다가 락 경쟁이나 데드락 문제를 겪어본 적 있나요? 인터럽트 핸들러는 언제든 실행될 수 있어서 메인 코드와 자원을 공유하면 위험합니다.

이런 문제는 모든 실시간 시스템에서 발생하는 고전적인 동기화 이슈입니다. 인터럽트 핸들러는 빠르게 실행되어야 하지만, 복잡한 처리(예: 문자열 파싱, 네트워크 전송)는 시간이 오래 걸립니다.

또한 인터럽트 핸들러에서 락을 잡으면 우선순위 역전이나 데드락이 발생할 수 있습니다. 바로 이럴 때 필요한 것이 lock-free 큐입니다.

큐는 인터럽트 핸들러(producer)와 메인 로직(consumer) 사이의 안전한 통신 채널 역할을 하며, 락 없이도 동기화를 보장합니다.

개요

간단히 말해서, lock-free 큐는 생산자와 소비자가 락 없이 동시에 접근할 수 있는 데이터 구조로, atomic 연산을 사용하여 동기화를 달성합니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 인터럽트 핸들러는 절대 블로킹되어서는 안 됩니다.

예를 들어, 화면 출력 함수가 락을 잡고 있을 때 인터럽트가 발생하여 또 출력하려 하면 데드락입니다. 큐를 사용하면 인터럽트 핸들러는 단순히 데이터를 큐에 넣기만 하고(보통 수 나노초), 메인 루프는 여유 있을 때 큐에서 꺼내 처리합니다.

이것이 "deferred work" 패턴입니다. 전통적인 방법으로는 링 버퍼에 인덱스를 atomic하게 관리했다면, Rust에서는 crossbeam-queue 같은 검증된 구현을 사용할 수 있습니다.

no_std 환경에서는 conquer-once의 ArrayQueue가 적합합니다. lock-free 큐의 핵심 특징은 첫째, CAS(Compare-And-Swap) 같은 atomic 연산만 사용하여 대기가 없다는 점입니다.

둘째, 고정 크기 배열 기반이라 힙 할당이 필요 없습니다(커널에서 중요). 셋째, SPSC(Single Producer Single Consumer) 큐는 멀티프로듀서보다 빠릅니다.

이러한 특징들이 인터럽트 컨텍스트에서 안전하고 효율적인 통신을 가능하게 합니다.

코드 예제

use conquer_once::spin::OnceCell;
use crossbeam_queue::ArrayQueue;

// 전역 스캔코드 큐 (최대 100개 저장)
static SCANCODE_QUEUE: OnceCell<ArrayQueue<u8>> = OnceCell::uninit();

// 초기화 함수 (커널 시작 시 한 번 호출)
pub fn init() {
    SCANCODE_QUEUE.init_once(|| ArrayQueue::new(100));
}

// 인터럽트 핸들러에서 호출 (producer)
pub fn add_scancode(scancode: u8) {
    if let Ok(queue) = SCANCODE_QUEUE.try_get() {
        // 큐가 가득 차면 오래된 데이터 버림 (또는 에러 로그)
        if queue.push(scancode).is_err() {
            // 큐 오버플로우 - 데이터 손실
        }
    }
}

// 메인 루프에서 호출 (consumer)
pub async fn print_keypresses() {
    let queue = SCANCODE_QUEUE.try_get().expect("queue not initialized");
    loop {
        if let Some(scancode) = queue.pop() {
            process_scancode(scancode);
        } else {
            // 큐가 비었으면 다른 작업에 양보
            yield_now().await;
        }
    }
}

설명

이것이 하는 일: 이 코드는 인터럽트 핸들러가 스캔코드를 빠르게 저장하고, 메인 루프가 여유 있을 때 처리할 수 있도록 하는 비동기 큐 시스템을 구현합니다. 첫 번째로, OnceCell<ArrayQueue<u8>>은 한 번만 초기화되는 전역 큐입니다.

OnceCell이 필요한 이유는 const 컨텍스트에서 ArrayQueue::new()를 호출할 수 없기 때문입니다(동적 초기화가 필요). static mut은 unsafe하고 실수하기 쉬우므로 OnceCell이 더 안전한 대안입니다.

ArrayQueue<u8>은 최대 100개의 u8을 저장할 수 있는 고정 크기 링 버퍼입니다. 힙 할당이 없으므로 커널 초기화 단계에서도 안전하게 사용할 수 있습니다.

그 다음으로, add_scancode() 함수는 인터럽트 핸들러에서 호출됩니다. try_get()은 큐가 초기화되었는지 확인하고, push(scancode)는 atomic 연산으로 데이터를 큐에 추가합니다.

내부적으로는 head 인덱스를 atomic하게 증가시키고 해당 위치에 데이터를 씁니다. push()가 실패하는 경우는 큐가 가득 찬 경우인데, 이때는 오래된 데이터를 버리거나(ring buffer overwrite) 에러를 로깅할 수 있습니다.

중요한 점은 이 모든 과정이 락 없이 진행되어 절대 블로킹되지 않는다는 것입니다. 세 번째로, print_keypresses() 함수는 비동기 태스크로 실행됩니다.

pop()은 큐에서 데이터를 제거하고 반환하는데, 역시 atomic 연산으로 tail 인덱스를 증가시킵니다. 큐가 비어있으면 None을 반환하므로, yield_now().await으로 다른 태스크에 CPU를 양보합니다.

이것이 협력적 멀티태스킹(cooperative multitasking)입니다. 비동기를 사용하면 여러 I/O 작업(키보드, 디스크, 네트워크)을 동시에 처리할 수 있습니다.

마지막으로, 전체 아키텍처를 보면 데이터 흐름이 명확합니다: 키보드 → 인터럽트 → add_scancode() → 큐 → print_keypresses() → process_scancode() → 화면. 인터럽트 핸들러는 최소한의 작업(큐에 넣기)만 하고 즉시 반환하므로, 다른 인터럽트를 빠르게 처리할 수 있습니다.

실제 처리는 메인 루프에서 우선순위에 따라 스케줄링됩니다. 여러분이 이 코드를 사용하면 인터럽트 핸들러를 단순하고 빠르게 유지하면서도 복잡한 처리를 안전하게 수행할 수 있습니다.

락 기반 동기화는 우선순위 역전, 데드락, 컨보이 효과 같은 문제를 일으키지만, lock-free 큐는 이런 문제가 없습니다. 또한 비동기 프로그래밍은 수천 개의 I/O 바운드 태스크를 효율적으로 관리할 수 있게 해줍니다.

실전 팁

💡 큐 크기는 시스템의 최악의 경우를 고려해서 설정하세요. 사용자가 초당 10타를 치고 처리가 1ms 걸린다면 최소 10개, 여유를 두어 100개가 적당합니다.

💡 MPSC(Multi Producer Single Consumer) 큐가 필요하면 crossbeam-queue::SegQueue를 사용하세요. 여러 CPU 코어에서 동시에 인터럽트가 발생할 수 있는 경우입니다.

💡 큐 오버플로우를 모니터링하세요. 오버플로우가 자주 발생하면 큐가 너무 작거나 consumer가 너무 느린 것입니다. metrics를 추가하여 추적하세요.

💡 비동기 런타임 없이 폴링 방식으로도 구현 가능합니다. loop { if let Some(sc) = queue.pop() { process(sc); } }처럼 idle 루프에서 확인하면 됩니다.

💡 실제 제품에서는 우선순위 큐를 사용하여 중요한 입력(Ctrl+Alt+Del)을 먼저 처리할 수 있습니다. heapless::BinaryHeap를 참고하세요.


6. PS/2 키보드 컨트롤러 명령어 - 하드웨어 설정 및 제어

시작하며

여러분이 키보드 입력을 받다가 갑자기 입력이 멈추거나, 키보드 LED를 제어하고 싶거나, 키보드 버퍼를 비우고 싶을 때 어떻게 해야 할지 막막했던 경험이 있나요? 단순히 스캔코드를 읽는 것만으로는 키보드를 완전히 제어할 수 없습니다.

이런 문제는 PS/2 키보드가 단순한 입력 장치가 아니라 자체 프로세서와 메모리를 가진 지능형 장치이기 때문에 발생합니다. 키보드 컨트롤러는 다양한 명령어를 받을 수 있고, 각 명령어는 특정 기능을 수행하거나 설정을 변경합니다.

이를 모르면 키보드의 고급 기능을 전혀 활용할 수 없습니다. 바로 이럴 때 필요한 것이 키보드 컨트롤러 명령어 인터페이스입니다.

0x60과 0x64 포트를 통해 명령어를 보내고 응답을 받아 키보드를 완전히 제어할 수 있습니다.

개요

간단히 말해서, PS/2 키보드 컨트롤러는 0x60(데이터 포트)과 0x64(명령/상태 포트) 두 개의 I/O 포트를 통해 제어되며, 다양한 명령어로 LED, 타이핑 속도, 스캔코드 세트 등을 설정할 수 있습니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 키보드는 부팅 시 기본 설정으로 되어있지만 이것이 항상 최적은 아닙니다.

예를 들어, Caps Lock LED를 켜려면 0xED 명령어를 보내야 하고, 키보드를 리셋하려면 0xFF를 보내야 합니다. 또한 키보드 버퍼가 가득 차서 명령어를 받을 수 없을 때는 상태 레지스터(0x64)를 확인해야 합니다.

이런 저수준 제어 없이는 완전한 키보드 드라이버를 만들 수 없습니다. 전통적인 방법으로는 어셈블리로 in/out 명령어를 직접 사용했다면, Rust에서는 x86_64::instructions::port::Port로 타입 안전하게 접근할 수 있습니다.

컨트롤러의 핵심 특징은 첫째, 명령어를 보내기 전에 입력 버퍼가 비었는지 확인해야 한다는 점입니다(busy wait). 둘째, 일부 명령어는 ACK(0xFA) 응답을 보내므로 이를 읽어야 합니다.

셋째, 명령어와 데이터를 같은 포트(0x60)로 보내므로 순서가 중요합니다. 이러한 특징들이 키보드와의 안정적인 통신을 위해 반드시 지켜져야 합니다.

코드 예제

use x86_64::instructions::port::Port;

pub struct KeyboardController {
    data_port: Port<u8>,      // 0x60
    command_port: Port<u8>,   // 0x64
}

impl KeyboardController {
    pub fn new() -> Self {
        Self {
            data_port: Port::new(0x60),
            command_port: Port::new(0x64),
        }
    }

    // 입력 버퍼가 비어있는지 확인 (비트 1)
    fn wait_input_buffer(&mut self) {
        while unsafe { self.command_port.read() } & 0x02 != 0 {
            // busy wait: 버퍼가 비워질 때까지 대기
        }
    }

    // 출력 버퍼에 데이터가 있는지 확인 (비트 0)
    fn wait_output_buffer(&mut self) {
        while unsafe { self.command_port.read() } & 0x01 == 0 {
            // busy wait: 데이터가 준비될 때까지 대기
        }
    }

    // LED 설정 (Scroll Lock, Num Lock, Caps Lock)
    pub fn set_leds(&mut self, scroll: bool, num: bool, caps: bool) {
        self.wait_input_buffer();
        unsafe { self.data_port.write(0xED); }  // LED 설정 명령어

        self.wait_input_buffer();
        let led_state = (scroll as u8) << 0 | (num as u8) << 1 | (caps as u8) << 2;
        unsafe { self.data_port.write(led_state); }

        // ACK(0xFA) 응답 읽기
        self.wait_output_buffer();
        unsafe { self.data_port.read(); }
    }
}

설명

이것이 하는 일: 이 코드는 PS/2 키보드 컨트롤러와 안전하게 통신하여 LED를 설정하는 완전한 예제를 보여줍니다. 버퍼 대기, 명령어 전송, 응답 읽기의 전체 프로토콜을 구현합니다.

첫 번째로, KeyboardController 구조체는 두 개의 I/O 포트를 캡슐화합니다. 0x60은 데이터 포트로, 스캔코드를 읽거나 명령어 데이터를 쓸 때 사용합니다.

0x64는 명령/상태 포트로, 읽으면 상태 레지스터, 쓰면 컨트롤러 명령어가 됩니다. Port<u8>는 타입 안전한 I/O 포트 추상화로, 실수로 잘못된 타입을 읽거나 쓰는 것을 방지합니다.

그 다음으로, wait_input_buffer()는 키보드 컨트롤러가 다음 명령어를 받을 준비가 되었는지 확인합니다. 상태 레지스터의 비트 1이 1이면 입력 버퍼가 가득 차서 새 명령어를 받을 수 없다는 의미입니다.

이때는 busy wait으로 대기해야 하는데, 보통 수 마이크로초면 클리어됩니다. 타임아웃을 추가하지 않으면 키보드가 고장났을 때 무한 루프에 빠질 수 있으므로 실제 제품에서는 타임아웃 로직이 필요합니다.

세 번째로, set_leds() 메서드는 LED 상태를 변경하는 전체 프로토콜을 구현합니다. 먼저 0xED 명령어를 보내 "LED 설정 모드"로 들어갑니다.

그 다음 LED 상태 바이트를 보내는데, 비트 0은 Scroll Lock, 비트 1은 Num Lock, 비트 2는 Caps Lock을 제어합니다. 비트 시프트와 OR 연산으로 세 개의 불린 값을 하나의 바이트로 패킹합니다.

각 단계마다 wait_input_buffer()를 호출하여 키보드가 준비되었는지 확인합니다. 마지막으로, ACK 응답을 읽는 부분은 프로토콜 완성에 필수적입니다.

키보드는 명령어를 성공적으로 받으면 0xFA(ACK)를, 에러가 있으면 0xFE(Resend) 또는 다른 에러 코드를 보냅니다. wait_output_buffer()로 데이터가 준비될 때까지 기다린 후 data_port.read()로 읽어야 합니다.

읽지 않으면 출력 버퍼가 가득 차서 다음 스캔코드를 받을 수 없게 됩니다. 여러분이 이 코드를 사용하면 키보드를 완전히 제어할 수 있습니다.

LED 설정뿐만 아니라 타이핑 속도(0xF3), 스캔코드 세트 변경(0xF0), 키보드 리셋(0xFF) 등 다양한 명령어를 같은 패턴으로 구현할 수 있습니다. 이것이 완전한 키보드 드라이버의 기반이 됩니다.

실전 팁

💡 일부 명령어(0xEE Echo Test)는 즉시 응답하지만, 리셋(0xFF)은 수백 밀리초가 걸릴 수 있습니다. 타임아웃을 명령어별로 다르게 설정하세요.

💡 키보드가 0xFE(Resend)를 보내면 마지막 명령어를 재전송해야 합니다. 최대 3번 재시도 후 실패로 처리하세요.

💡 USB 키보드는 PS/2 에뮬레이션 모드에서도 일부 명령어(특히 스캔코드 세트 변경)가 작동하지 않을 수 있습니다. 하드웨어별 테스트가 필수입니다.

💡 QEMU에서는 -device usb-kbd 대신 -device i8042를 사용하여 실제 PS/2 컨트롤러를 에뮬레이트하세요. 더 정확한 동작을 테스트할 수 있습니다.

💡 실제 하드웨어에서는 키보드 컨트롤러가 A20 게이트도 제어하므로, 0x64 포트의 0xD1 명령어로 A20을 활성화할 수 있습니다(1MB 이상 메모리 접근에 필요).


7. 인터럽트 스택 프레임 분석 - CPU 상태 디버깅하기

시작하며

여러분이 키보드 인터럽트 핸들러에서 이상한 버그를 만났을 때, 정확히 어떤 코드에서 인터럽트가 발생했는지, CPU가 어떤 상태였는지 알고 싶었던 적이 있나요? 단순히 "인터럽트 발생함"만 아는 것으로는 복잡한 버그를 추적하기 어렵습니다.

이런 문제는 실시간 시스템 디버깅에서 항상 발생합니다. 인터럽트는 언제 어디서든 발생할 수 있고, 현재 실행 중인 코드를 중단시킵니다.

버그가 특정 코드 경로에서만 발생하거나, 타이밍에 민감한 경우 인터럽트 발생 시점의 정확한 컨텍스트가 필수적입니다. 바로 이럴 때 필요한 것이 인터럽트 스택 프레임 분석입니다.

CPU는 인터럽트 발생 시 자동으로 현재 상태를 스택에 저장하며, 우리는 이 정보를 읽어서 디버깅에 활용할 수 있습니다.

개요

간단히 말해서, InterruptStackFrame은 CPU가 인터럽트 발생 시 스택에 푸시하는 5개 값(명령어 포인터, 코드 세그먼트, CPU 플래그, 스택 포인터, 스택 세그먼트)을 담은 구조체입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 인터럽트는 비동기적이므로 어떤 코드를 실행하던 중에 발생할 수 있습니다.

예를 들어, 페이지 폴트가 키보드 인터럽트 처리 중에 발생하면 어디서 문제가 생긴 건지 알아야 합니다. instruction_pointer는 인터럽트 직전에 실행하려던 명령어 주소를 알려주고, stack_pointer는 스택 오버플로우 검출에 유용하며, cpu_flags는 인터럽트가 활성화되어 있었는지 등의 상태를 보여줍니다.

전통적인 방법으로는 어셈블리로 스택에서 직접 값을 읽었다면, Rust에서는 x86_64 크레이트가 타입 안전한 구조체로 제공합니다. 스택 프레임의 핵심 특징은 첫째, CPU가 자동으로 생성하므로 우리는 읽기만 하면 된다는 점입니다.

둘째, iretq 명령어로 반환할 때 이 값들이 복원되어 정확히 중단된 지점부터 재개됩니다. 셋째, 권한 레벨 변경(유저 → 커널)이 있으면 스택 포인터와 세그먼트도 저장됩니다.

이러한 특징들이 인터럽트의 투명성(transparency)을 보장합니다.

코드 예제

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

extern "x86-interrupt" fn keyboard_interrupt_handler(
    stack_frame: &mut InterruptStackFrame
) {
    // 인터럽트 발생 시점의 명령어 포인터 (RIP)
    let ip = stack_frame.instruction_pointer;

    // CPU 플래그 레지스터 (인터럽트 활성화 여부 등)
    let flags = stack_frame.cpu_flags;

    // 스택 포인터 (스택 오버플로우 검출용)
    let sp = stack_frame.stack_pointer;

    // 디버깅: 인터럽트 발생 위치 로깅
    serial_println!(
        "Keyboard interrupt at RIP: {:#x}, Flags: {:?}, RSP: {:#x}",
        ip.as_u64(),
        flags,
        sp.as_u64()
    );

    // 실제 키보드 처리...
    let scancode = unsafe { Port::new(0x60).read() };
    add_scancode(scancode);

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

설명

이것이 하는 일: 이 코드는 키보드 인터럽트 핸들러에서 CPU의 인터럽트 스택 프레임을 읽어서 디버깅 정보를 출력하는 실용적인 예제를 보여줍니다. 첫 번째로, stack_frame.instruction_pointer는 VirtAddr 타입의 값으로, 인터럽트가 발생한 순간 다음에 실행될 예정이었던 명령어의 주소입니다.

예를 들어, 메인 루프의 0x200050 주소에서 인터럽트가 발생했다면 RIP는 0x200050을 가리킵니다. 이 정보로 어떤 함수를 실행 중이었는지 알 수 있고, 심볼 테이블과 비교하여 함수 이름을 찾을 수 있습니다.

디버거 없이도 스택 트레이스를 구현할 수 있는 기반이 됩니다. 그 다음으로, stack_frame.cpu_flags는 RFLAGS 레지스터의 스냅샷입니다.

이 레지스터는 40개 이상의 플래그 비트를 포함하는데, 주요한 것은 IF(Interrupt Flag, 비트 9)입니다. IF=1이면 인터럽트가 활성화되어 있었다는 의미이고, IF=0이면 인터럽트가 비활성화된 크리티컬 섹션에서 발생했다는 의미입니다(이건 버그일 가능성이 높음).

또한 DF(Direction Flag), TF(Trap Flag) 등도 디버깅에 유용합니다. 세 번째로, stack_frame.stack_pointer는 인터럽트 발생 시점의 RSP 레지스터 값입니다.

각 태스크나 프로세스는 자신만의 스택을 가지므로, 이 값으로 어떤 컨텍스트에서 인터럽트가 발생했는지 알 수 있습니다. 스택 오버플로우를 검출하려면 스택의 끝 주소와 비교하면 됩니다.

예를 들어, 스택이 0x9000-0xA000 범위인데 RSP가 0x8FFF라면 오버플로우입니다. 마지막으로, serial_println!을 사용하는 이유는 VGA 버퍼보다 안전하기 때문입니다.

VGA 출력은 락을 사용하므로 인터럽트 핸들러에서 데드락을 일으킬 수 있지만, 직렬 포트는 단순한 I/O 포트 쓰기라 안전합니다. 실제 하드웨어에서는 COM1 포트로 연결된 터미널로 로그가 전송되고, QEMU에서는 -serial 옵션으로 호스트의 파일이나 stdout으로 리다이렉트할 수 있습니다.

여러분이 이 코드를 사용하면 히이젠버그 버그(재현하기 어려운 타이밍 의존적 버그)를 추적할 수 있습니다. 예를 들어, 특정 함수에서만 키보드 인터럽트가 문제를 일으킨다면 RIP 로그로 패턴을 발견할 수 있습니다.

또한 스택 오버플로우, 잘못된 권한 레벨 전환, 인터럽트 중첩 문제 등을 조기에 발견할 수 있습니다.

실전 팁

💡 instruction_pointer를 심볼 테이블과 매칭하려면 빌드 시 디버그 심볼을 포함하고(-g 플래그), addr2line 유틸리티를 사용하세요. 자동화된 스택 트레이스 구현에 필수입니다.

💡 cpu_flags의 각 비트 의미는 Intel SDM Vol. 1의 RFLAGS 섹션에 상세히 나와있습니다. 특히 AC(Alignment Check), VM(Virtual-8086 Mode) 플래그는 보안 관련 버그를 찾는 데 유용합니다.

💡 멀티태스킹 시스템에서는 stack_segment와 code_segment로 어떤 프로세스/태스크에서 인터럽트가 발생했는지 식별할 수 있습니다. 세그먼트 셀렉터의 하위 2비트가 RPL(Requested Privilege Level)입니다.

💡 성능 프로파일링에도 활용 가능합니다. 타이머 인터럽트 핸들러에서 RIP를 샘플링하면 어떤 함수가 CPU를 많이 쓰는지 통계적으로 알 수 있습니다(statistical profiling).

💡 에러 코드가 있는 인터럽트(페이지 폴트, 세그먼트 폴트 등)는 InterruptStackFrame 뒤에 에러 코드가 추가로 스택에 푸시됩니다. x86_64 크레이트의 PageFaultErrorCode 같은 타입을 사용하세요.


8. 더블 폴트 방지 - 인터럽트 중 예외 처리하기

시작하며

여러분이 키보드 인터럽트 핸들러를 작성했는데, 핸들러 내에서 페이지 폴트가 발생하여 시스템이 트리플 폴트로 리부팅되는 상황을 경험해본 적 있나요? 인터럽트 중에 발생하는 예외는 일반적인 예외보다 훨씬 위험합니다.

이런 문제는 x86 아키텍처의 고유한 특성 때문에 발생합니다. 예외를 처리하는 중에 또 다른 예외가 발생하면 CPU는 "더블 폴트"를 발생시킵니다.

더블 폴트를 처리하는 중에 또 예외가 발생하면 트리플 폴트가 되어 CPU가 리셋됩니다. 키보드 인터럽트 핸들러는 자주 실행되므로 버그가 있으면 더블 폴트로 이어지기 쉽습니다.

바로 이럴 때 필요한 것이 인터럽트 스택 테이블(IST)입니다. IST는 특정 인터럽트에 별도의 스택을 할당하여, 스택 오버플로우로 인한 더블 폴트를 방지합니다.

개요

간단히 말해서, IST(Interrupt Stack Table)는 TSS(Task State Segment) 내의 7개 엔트리 배열로, 각 인터럽트가 사용할 별도 스택의 주소를 지정할 수 있습니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 스택 오버플로우가 발생하면 가드 페이지에 접근하여 페이지 폴트가 일어납니다.

그런데 페이지 폴트 핸들러도 스택을 사용하려 하는데 스택이 이미 망가져있으므로 또 페이지 폴트가 발생합니다. 이것이 더블 폴트입니다.

예를 들어, 재귀 함수 버그로 스택이 오버플로우되면 시스템이 즉시 리부팅됩니다. IST를 사용하면 더블 폴트 핸들러가 깨끗한 새 스택에서 실행되어 진단 정보를 출력하고 우아하게 패닉할 수 있습니다.

전통적인 방법으로는 각 태스크마다 별도의 TSS를 만들었다면, 64비트 모드에서는 하나의 TSS에 IST를 설정하여 중요한 핸들러에만 별도 스택을 할당합니다. IST의 핵심 특징은 첫째, TSS에 7개의 스택 포인터를 저장할 수 있다는 점입니다.

둘째, IDT 엔트리에서 어떤 IST 엔트리를 사용할지 지정합니다(0=사용 안 함, 1-7=IST 인덱스). 셋째, 인터럽트 발생 시 CPU가 자동으로 스택을 전환합니다.

이러한 특징들이 스택 관련 예외로부터 시스템을 보호합니다.

코드 예제

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

pub const DOUBLE_FAULT_IST_INDEX: u16 = 0;

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

        // 더블 폴트용 별도 스택 할당 (4KiB)
        tss.interrupt_stack_table[DOUBLE_FAULT_IST_INDEX as usize] = {
            const STACK_SIZE: usize = 4096 * 5;
            static mut STACK: [u8; STACK_SIZE] = [0; STACK_SIZE];

            let stack_start = VirtAddr::from_ptr(unsafe { &STACK });
            let stack_end = stack_start + STACK_SIZE;
            stack_end  // 스택은 아래로 자라므로 끝 주소 사용
        };
        tss
    };
}

// IDT에서 더블 폴트 핸들러에 IST 적용
lazy_static! {
    static ref IDT: InterruptDescriptorTable = {
        let mut idt = InterruptDescriptorTable::new();
        unsafe {
            idt.double_fault
                .set_handler_fn(double_fault_handler)
                .set_stack_index(DOUBLE_FAULT_IST_INDEX);  // IST 사용
        }
        idt
    };
}

설명

이것이 하는 일: 이 코드는 더블 폴트 핸들러 전용 스택을 할당하고 TSS와 IDT를 통해 CPU에 등록하여, 스택 오버플로우 상황에서도 안전하게 예외를 처리할 수 있게 만듭니다. 첫 번째로, TaskStateSegment::new()는 비어있는 TSS를 생성합니다.

64비트 모드에서 TSS는 태스크 전환용이 아니라 주로 권한 레벨 전환(유저→커널) 시 스택을 지정하는 용도입니다. interrupt_stack_table 필드는 7개 엔트리의 배열로, 각 엔트리는 64비트 스택 포인터입니다.

DOUBLE_FAULT_IST_INDEX를 0으로 정의했으므로 IST의 첫 번째 슬롯을 사용합니다. 그 다음으로, 별도 스택 할당 부분은 주의가 필요합니다.

STACK_SIZE를 20KiB(4096*5)로 설정한 이유는 더블 폴트 핸들러가 스택 트레이스를 출력하거나 복잡한 진단을 수행할 수 있어야 하기 때문입니다. static mut STACK은 .bss 섹션에 할당되어 컴파일 타임에 공간이 예약됩니다.

힙 할당을 사용할 수 없는 이유는 더블 폴트가 발생하는 시점에 힙 할당자가 정상적으로 작동하지 않을 수 있기 때문입니다. 세 번째로, stack_end를 반환하는 이유는 x86 스택이 높은 주소에서 낮은 주소로 자라기 때문입니다.

스택 포인터(RSP)는 처음에 스택의 끝(높은 주소)을 가리키고, push할 때마다 감소합니다. 따라서 TSS에는 스택의 최상위 주소를 저장해야 합니다.

VirtAddr는 타입 안전한 가상 주소 래퍼로, 실수로 정수 연산을 하는 것을 방지합니다. 마지막으로, set_stack_index(DOUBLE_FAULT_IST_INDEX)는 IDT의 더블 폴트 엔트리에 IST 인덱스를 설정합니다.

이제 더블 폴트가 발생하면 CPU는 현재 스택 대신 TSS의 IST[0]에 저장된 주소로 RSP를 전환합니다. 이 새 스택은 깨끗하고 충분한 공간이 있으므로 핸들러가 안전하게 실행될 수 있습니다.

unsafe가 필요한 이유는 잘못된 스택 주소를 설정하면 핸들러가 크래시하기 때문입니다. 여러분이 이 코드를 사용하면 스택 오버플로우가 발생해도 시스템이 조용히 리부팅되는 대신 유용한 에러 메시지를 볼 수 있습니다.

더블 폴트 핸들러에서 스택 트레이스, 레지스터 덤프, 메모리 상태 등을 출력하여 버그의 원인을 파악할 수 있습니다. 이것이 견고한 운영체제의 필수 요소입니다.

실전 팁

💡 페이지 폴트 핸들러에도 별도 IST를 할당하는 것을 고려하세요. 페이지 폴트 중 또 페이지 폴트가 발생하는 것도 더블 폴트의 흔한 원인입니다.

💡 IST 스택도 가드 페이지로 보호해야 합니다. IST 스택이 오버플로우하면 여전히 트리플 폴트입니다. 스택 앞에 비매핑 페이지를 두세요.

💡 TSS는 GDT에 등록해야 CPU가 인식합니다. x86_64::instructions::tables::load_tss()로 TR(Task Register)에 로드하세요.

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

💡 QEMU의 -d int,cpu_reset -D qemu.log 옵션을 사용하면 더블 폴트와 트리플 폴트를 로그로 남겨 디버깅에 도움이 됩니다.


9. 키보드 입력 버퍼링 및 라인 편집 - 실용적인 쉘 입력 구현하기

시작하며

여러분이 키보드에서 문자를 하나씩 받아서 화면에 출력하는 데는 성공했지만, Backspace로 글자를 지우거나 Enter를 눌렀을 때 완성된 한 줄을 반환하는 기능을 구현하고 싶었던 적이 있나요? 단순히 문자를 출력하는 것과 실제 사용 가능한 입력 시스템 사이에는 큰 차이가 있습니다.

이런 문제는 모든 대화형 시스템에서 발생합니다. 사용자는 실수로 타이핑한 글자를 지우고 싶어하고, 화살표 키로 커서를 이동하고 싶어하며, Tab으로 자동완성을 기대합니다.

이런 기능 없이는 프로그래밍할 수도 명령어를 입력할 수도 없습니다. 바로 이럴 때 필요한 것이 라인 편집 버퍼입니다.

이것은 사용자가 Enter를 누르기 전까지 입력을 버퍼에 모으고, Backspace, Delete, 화살표 키 등을 처리하는 상태 기반 시스템입니다.

개요

간단히 말해서, 라인 편집 버퍼는 현재 입력 중인 텍스트 라인, 커서 위치, 편집 명령(삽입, 삭제, 이동)을 관리하는 구조체로, 완성된 라인을 반환할 때까지 문자들을 저장합니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, raw 입력 모드(키를 누르는 즉시 전송)는 네트워크나 파일 I/O에 비효율적이고 사용자 경험도 나쁩니다.

예를 들어, SSH로 원격 서버에 명령을 입력할 때 라인 버퍼링 없이 매 키마다 네트워크 패킷을 보내면 레이턴시가 심각합니다. 또한 Backspace 같은 편집 명령을 서버가 처리하게 하면 복잡도가 증가합니다.

클라이언트 측에서 라인을 완성한 후 보내는 것이 훨씬 효율적입니다. 전통적인 방법으로는 C의 readline 라이브러리나 termios의 canonical 모드를 사용했다면, 베어메탈 OS에서는 직접 구현해야 합니다.

라인 버퍼의 핵심 특징은 첫째, 커서 위치를 추적하여 중간 삽입/삭제를 지원한다는 점입니다. 둘째, VT100 이스케이프 시퀀스로 화면의 커서와 동기화합니다.

셋째, 히스토리 버퍼로 이전 명령을 재사용할 수 있습니다. 이러한 특징들이 현대적인 CLI 경험을 제공합니다.

코드 예제

use pc_keyboard::DecodedKey;

pub struct LineEditor {
    buffer: [u8; 256],    // 입력 버퍼 (최대 256바이트)
    cursor: usize,        // 커서 위치 (0부터 시작)
    length: usize,        // 현재 입력된 길이
}

impl LineEditor {
    pub fn new() -> Self {
        Self {
            buffer: [0; 256],
            cursor: 0,
            length: 0,
        }
    }

    // 키 입력 처리, Enter 시 Some(완성된 라인) 반환
    pub fn process_key(&mut self, key: DecodedKey) -> Option<&str> {
        match key {
            DecodedKey::Unicode('\n') | DecodedKey::Unicode('\r') => {
                // Enter: 라인 완성
                print!("\n");
                let result = core::str::from_utf8(&self.buffer[..self.length]).ok();
                self.cursor = 0;
                self.length = 0;
                result
            }
            DecodedKey::Unicode('\x08') => {
                // Backspace: 커서 앞 문자 삭제
                if self.cursor > 0 {
                    self.buffer.copy_within(self.cursor..self.length, self.cursor - 1);
                    self.cursor -= 1;
                    self.length -= 1;
                    self.redraw_line();
                }
                None
            }
            DecodedKey::Unicode(c) => {
                // 일반 문자: 커서 위치에 삽입
                if self.length < self.buffer.len() {
                    self.buffer.copy_within(self.cursor..self.length, self.cursor + 1);
                    self.buffer[self.cursor] = c as u8;
                    self.cursor += 1;
                    self.length += 1;
                    self.redraw_line();
                }
                None
            }
            _ => None,  // 특수 키는 무시 (또는 별도 처리)
        }
    }

    // 현재 라인을 화면에 다시 그리기
    fn redraw_line(&self) {
        print!("\r");  // 커서를 줄 시작으로
        print!("{}", core::str::from_utf8(&self.buffer[..self.length]).unwrap_or(""));
        print!(" ");  // 마지막 문자 지우기용
        // 커서를 원래 위치로 이동 (VT100 이스케이프 시퀀스)
        print!("\r\x1B[{}C", self.cursor);
    }
}

설명

이것이 하는 일: 이 코드는 사용자 입력을 버퍼링하고 기본적인 편집 기능(Backspace, 중간 삽입)을 제공하며, Enter 시 완성된 라인을 반환하는 간단하지만 실용적인 라인 에디터를 구현합니다. 첫 번째로, LineEditor 구조체는 세 가지 상태를 유지합니다.

buffer는 실제 입력된 바이트들을 저장하고, cursor는 사용자가 다음 입력할 위치를 가리키며, length는 현재까지 입력된 총 길이입니다. 고정 크기 배열을 사용하는 이유는 힙 할당을 피하고 스택 오버플로우를 방지하기 위함입니다.

256바이트 제한은 대부분의 명령어 라인에 충분하며, 초과 시 무시하면 됩니다. 그 다음으로, process_key() 메서드는 세 가지 경우를 처리합니다.

Enter(\n 또는 \r)가 입력되면 버퍼의 내용을 UTF-8로 변환하여 반환합니다. from_utf8()는 유효하지 않은 UTF-8이면 에러를 반환하는데, ok()로 Option으로 변환하여 에러는 None이 됩니다.

라인을 반환한 후 버퍼를 리셋하여 다음 입력을 받을 준비를 합니다. 세 번째로, Backspace(\x08)는 커서 앞의 한 문자를 삭제합니다.

copy_within()은 커서부터 끝까지의 데이터를 한 칸 앞으로 복사하는 효율적인 메서드입니다(memmove와 유사). 예를 들어, "hello" 상태에서 커서가 3(l 다음)이고 Backspace를 누르면 "helo"가 됩니다.

cursor와 length를 감소시킨 후 redraw_line()으로 화면을 업데이트합니다. 네 번째로, 일반 문자 입력은 커서 위치에 삽입됩니다.

마찬가지로 copy_within()으로 커서 뒤의 데이터를 한 칸 뒤로 밀고, 커서 위치에 새 문자를 씁니다. 이렇게 하면 "helo" 상태에서 커서 2에 'l'을 입력하면 "hello"가 됩니다.

이것이 "insert mode"입니다(vi의 기본 모드와 유사). 마지막으로, redraw_line()은 VT100 터미널 이스케이프 시퀀스를 사용합니다.

\r은 캐리지 리턴(줄 처음으로), \x1B[{}C는 커서를 오른쪽으로 n칸 이동하는 명령입니다. 전체 라인을 다시 출력하고 끝에 공백을 추가하여 삭제된 문자를 지운 후, 커서를 원래 위치로 이동합니다.

이 방법은 간단하지만 깜빡임이 있을 수 있으므로, 실제 제품에서는 차분 업데이트를 구현해야 합니다. 여러분이 이 코드를 사용하면 기본적인 쉘이나 REPL을 구현할 수 있습니다.

완성된 라인을 받아서 파싱하고 명령을 실행하는 루프를 만들면 됩니다. rustyline이나 crossterm 같은 크레이트도 참고할 수 있지만, 베어메탈에서는 no_std 제약이 있으므로 직접 구현이 필요한 경우가 많습니다.

실전 팁

💡 화살표 키는 멀티바이트 시퀀스입니다. 예를 들어, Left Arrow는 0x1B, 0x5B, 0x44 세 바이트입니다. 상태 머신으로 이스케이프 시퀀스를 파싱하세요.

💡 히스토리 기능을 추가하려면 이전 라인들을 링 버퍼에 저장하고, Up/Down Arrow로 탐색하게 만드세요. heapless::Vec를 사용하면 no_std에서도 가능합니다.

💡 Tab 자동완성은 현재 버퍼를 파싱하여 가능한 완성 목록을 찾고, 공통 접두사를 자동으로 채우는 방식으로 구현합니다. Trie 자료구조가 효율적입니다.

💡 Ctrl+C, Ctrl+D 같은 제어 문자를 처리하려면 HandleControl::MapLettersToUnicode를 사용하고, \x03(ETX), \x04(EOT)를 특별히 처리하세요.

💡 유니코드 지원을 위해서는 UTF-8 바이트 시퀀스를 정확히 처리해야 합니다. Backspace는 한 바이트가 아니라 한 문자(1-4바이트)를 삭제해야 합니다. unicode-segmentation 크레이트를 참고하세요.


10. NMI와 다른 인터럽트 우선순위 - 중요한 인터럽트 놓치지 않기

시작하며

여러분이 키보드 인터럽트를 처리하는 중에 훨씬 더 중요한 하드웨어 에러(메모리 패리티 에러, 전원 장애 등)가 발생했는데 무시되는 상황을 걱정해본 적 있나요? 모든 인터럽트가 동등한 우선순위를 가진다면 시스템 안정성이 위협받습니다.

이런 문제는 실시간 시스템과 안전이 중요한 시스템에서 치명적입니다. 예를 들어, 하드웨어 고장이 감지되었는데 키보드 처리가 끝날 때까지 기다린다면 데이터가 손실되거나 시스템이 파괴될 수 있습니다.

인터럽트에도 우선순위가 있어야 하고, 중요한 것은 덜 중요한 것을 선점할 수 있어야 합니다. 바로 이럴 때 필요한 것이 NMI(Non-Maskable Interrupt)와 인터럽트 우선순위 시스템입니다.

NMI는 절대 무시할 수 없는 최우선 인터럽트이며, 일반 인터럽트는 우선순위에 따라 처리됩니다.

개요

간단히 말해서, NMI는 CPU 플래그로도 비활성화할 수 없는 특수 인터럽트 라인(인터럽트 2번)으로, 하드웨어 고장 같은 치명적 상황을 알리는 데 사용됩니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, cli 명령어(인터럽트 비활성화)는 일반 인터럽트는 막지만 NMI는 막을 수 없습니다.

예를 들어, 메모리 ECC 에러, 버스 타임아웃, 워치독 타이머 같은 것들은 NMI로 알려져서 크리티컬 섹션 중에도 처리됩니다. 또한 PIC는 IRQ에 우선순위를 부여하는데, IRQ 0(타이머)이 가장 높고 IRQ 7(프린터)이 가장 낮습니다.

두 인터럽트가 동시에 발생하면 우선순위가 높은 것이 먼저 처리됩니다. 전통적인 시스템에서는 NMI로 메모리 패리티 검사를 수행했다면, 현대 시스템에서는 머신 체크 익셉션(MCE)도 사용합니다.

하지만 NMI는 여전히 프로파일링, 워치독, 디버깅에 활용됩니다. NMI의 핵심 특징은 첫째, IF(Interrupt Flag)와 무관하게 항상 처리된다는 점입니다.

둘째, IDT의 2번 엔트리에 핸들러를 등록합니다. 셋째, NMI 중에 또 NMI가 발생하면 첫 번째 NMI가 완료될 때까지 대기합니다(자동 마스킹).

이러한 특징들이 시스템의 최후 방어선 역할을 합니다.

코드 예제

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

extern "x86-interrupt" fn nmi_handler(stack_frame: &mut InterruptStackFrame) {
    // NMI는 매우 심각한 상황이므로 즉시 로그
    serial_println!("NMI EXCEPTION at {:#x}", stack_frame.instruction_pointer.as_u64());

    // NMI 소스 확인 (포트 0x61의 비트 7-6)
    let nmi_status = unsafe { Port::<u8>::new(0x61).read() };

    if nmi_status & 0x80 != 0 {
        // 비트 7: 메모리 패리티 에러 (치명적)
        serial_println!("FATAL: Memory parity error detected!");
        panic!("Unrecoverable hardware error");
    }

    if nmi_status & 0x40 != 0 {
        // 비트 6: I/O 채널 체크
        serial_println!("WARNING: I/O channel check error");
    }

    // NMI 재활성화 (포트 0x70의 비트 7 클리어)
    unsafe {
        let mut port = Port::<u8>::new(0x70);
        let val = port.read();
        port.write(val & 0x7F);
    }
}

// IDT에 NMI 핸들러 등록
lazy_static! {
    static ref IDT: InterruptDescriptorTable = {
        let mut idt = InterruptDescriptorTable::new();
        idt[2].set_handler_fn(nmi_handler);  // 인터럽트 2 = NMI
        idt[33].set_handler_fn(keyboard_interrupt_handler);  // IRQ 1
        // ... 다른 핸들러들
        idt
    };
}

설명

이것이 하는 일: 이 코드는 NMI를 처리하는 핸들러를 구현하고, NMI의 원인을 진단하여 적절한 조치를 취하는 전체 프로세스를 보여줍니다. 첫 번째로, nmi_handler는 IDT의 2번 엔트리에 등록됩니다.

x86 아키텍처에서 인터럽트 2번은 NMI로 예약되어 있습니다. 다른 인터럽트와 달리 NMI는 cli 명령어로 비활성화할 수 없으므로, 크리티컬 섹션 중에도 발생할 수 있습니다.

따라서 NMI 핸들러는 최소한의 락만 사용하고, 가능한 빠르게 실행되어야 합니다. serial_println!을 사용하는 이유는 락이 필요 없는 직렬 포트가 안전하기 때문입니다.

그 다음으로, 포트 0x61을 읽어서 NMI의 원인을 파악합니다. 이 포트는 "Port B" 또는 "NMI Status and Control"로 불리며, 여러 시스템 상태 비트를 포함합니다.

비트 7이 1이면 메모리 패리티 에러가 발생한 것으로, RAM 칩에 물리적 결함이 있거나 우주선(cosmic ray) 때문일 수 있습니다. 이것은 복구 불가능한 에러이므로 즉시 패닉해야 합니다.

비트 6은 ISA 버스나 PCI 버스의 I/O 에러를 나타냅니다. 세 번째로, NMI 재활성화는 중요한 단계입니다.

NMI가 발생하면 CPU는 자동으로 NMI를 마스크하여 핸들러 실행 중 재진입을 방지합니다. 하지만 핸들러가 끝나도 자동으로 언마스크되지 않으므로, 명시적으로 재활성화해야 다음 NMI를 받을 수 있습니다.

포트 0x70은 CMOS/RTC 인덱스 레지스터인데, 비트 7이 NMI 마스크 비트입니다. 이 비트를 0으로 클리어하면 NMI가 재활성화됩니다.

네 번째로, 우선순위 관련해서는 PIC의 우선순위 회전(priority rotation) 기능을 활용할 수 있습니다. 기본적으로 IRQ 0이 가장 높지만, OCW2(Operation Command Word 2)로 동적으로 우선순위를 변경할 수 있습니다.

예를 들어, 네트워크 패킷이 폭주할 때 네트워크 IRQ 우선순위를 낮춰서 키보드 입력이 블로킹되지 않게 할 수 있습니다. 마지막으로, 실제 시스템에서는 NMI 워치독을 구현하여 커널이 멈췄는지 감시할 수 있습니다.

타이머를 설정하여 주기적으로 NMI를 발생시키고, 핸들러에서 메인 루프의 진행 상황을 확인합니다. 일정 시간 동안 진행이 없으면 데드락이나 무한 루프로 판단하고 시스템을 리부팅합니다.

여러분이 이 코드를 사용하면 하드웨어 고장을 조기에 감지하고, 시스템 행(hang)을 탐지하며, 프로덕션 시스템의 안정성을 크게 향상시킬 수 있습니다. NMI는 마지막 방어선이므로 신중하게 구현해야 하지만, 제대로 사용하면 매우 강력한 도구입니다.

실전 팁

💡 NMI 핸들러 내에서는 절대 힙 할당, 복잡한 락, 느린 I/O를 하지 마세요. 진단 정보를 고정 버퍼에 기록하고 메인 루프에서 처리하세요.

💡 현대 시스템에서는 APIC의 LINT1 핀으로 NMI를 받을 수 있습니다. APIC 모드에서는 IA32_APIC_BASE MSR과 LVT(Local Vector Table)을 설정하세요.

💡 NMI 워치독을 구현할 때는 false positive를 주의하세요. 정상적인 긴 작업(디스크 I/O)이 워치독을 트리거하지 않도록 타임아웃을 넉넉히 설정하세요. �� perf 같은 프로파일러는 NMI를 사용하여 인터럽트가 비활성화된 상태에서도 샘플링합니다. 커널 프로파일링에는 NMI 기반 샘플링이 필수적입니다.

💡 QEMU에서 NMI를 테스트하려면 모니터 콘솔에서 nmi 명령을 사용하세요. 실제 NMI가 시뮬레이션되어 핸들러를 테스트할 수 있습니다.


#Rust#Interrupt#Keyboard#PIC#OS#시스템프로그래밍

댓글 (0)

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