이미지 로딩 중...
AI Generated
2025. 11. 13. · 6 Views
Rust로 만드는 나만의 OS - PIC 설정 완벽 가이드
x86 시스템의 핵심 하드웨어 컴포넌트인 PIC(Programmable Interrupt Controller)를 Rust로 설정하는 방법을 다룹니다. 인터럽트 처리의 기초부터 실제 구현까지 OS 개발의 필수 개념을 실무 중심으로 설명합니다.
목차
- PIC 초기화 - 하드웨어와의 첫 만남
- 인터럽트 마스킹 - 원하는 신호만 받기
- EOI 신호 전송 - 인터럽트 처리 완료 알리기
- PIC 비활성화 - APIC로의 전환 준비
- Spurious Interrupt 처리 - 가짜 인터럽트 대응
- 인터럽트 우선순위 관리 - 중요한 것부터 처리하기
- PIC 상태 읽기 - 디버깅의 핵심
- Edge vs Level Triggered - 트리거 모드 이해하기
- I/O 대기 시간 처리 - 타이밍 이슈 방지
- PIC 재초기화 및 리셋 - 안정적인 상태 복구
1. PIC 초기화 - 하드웨어와의 첫 만남
시작하며
여러분이 Rust로 OS를 만들다가 키보드 입력을 처리하려고 할 때, 갑자기 시스템이 멈추거나 예상치 못한 인터럽트가 발생한 적 있나요? 이는 PIC가 제대로 초기화되지 않았기 때문입니다.
PIC(Programmable Interrupt Controller)는 x86 시스템에서 하드웨어 인터럽트를 CPU로 전달하는 핵심 컴포넌트입니다. 1981년 IBM PC부터 사용된 8259 PIC는 오늘날까지도 레거시 호환성을 위해 모든 x86 시스템에 존재합니다.
제대로 설정하지 않으면 타이머, 키보드, 마우스 등 모든 하드웨어 입력이 제대로 작동하지 않습니다. 더 심각한 것은 잘못된 인터럽트 벡터로 인해 예외 핸들러와 충돌할 수 있다는 점입니다.
바로 이럴 때 필요한 것이 올바른 PIC 초기화입니다. 이 과정을 통해 하드웨어 인터럽트를 안전하게 처리할 수 있는 기반을 마련할 수 있습니다.
개요
간단히 말해서, PIC 초기화는 하드웨어 인터럽트 번호를 재매핑하고 마스크를 설정하는 과정입니다. 왜 이것이 필요할까요?
x86 시스템에서 PIC는 기본적으로 인터럽트를 0-15번으로 보내는데, 이는 CPU 예외(Divide by Zero, Page Fault 등)와 겹칩니다. 예를 들어, 타이머 인터럽트(IRQ 0)가 0번으로 오면 Divide by Zero 예외 핸들러가 실행되어 시스템이 혼란에 빠집니다.
기존에는 BIOS가 이를 처리했다면, 이제는 우리가 직접 PIC를 프로그래밍해야 합니다. OS 개발에서는 보통 하드웨어 인터럽트를 32-47번으로 재매핑합니다.
이 개념의 핵심 특징은 첫째, I/O 포트를 통한 직접 하드웨어 제어, 둘째, 마스터/슬레이브 듀얼 PIC 구조 관리, 셋째, 인터럽트 우선순위 설정입니다. 이러한 특징들이 안정적인 하드웨어 인터럽트 처리를 가능하게 합니다.
코드 예제
// PIC 제어 포트 정의
const PIC1_CMD: u16 = 0x20;
const PIC1_DATA: u16 = 0x21;
const PIC2_CMD: u16 = 0xA0;
const PIC2_DATA: u16 = 0xA1;
// PIC 초기화 함수
pub unsafe fn initialize_pic() {
// ICW1: 초기화 시작 (0x11 = ICW4 필요 + 연쇄 모드)
outb(PIC1_CMD, 0x11);
outb(PIC2_CMD, 0x11);
// ICW2: 인터럽트 벡터 오프셋 설정 (32부터 시작)
outb(PIC1_DATA, 32); // 마스터 PIC: IRQ 0-7 -> INT 32-39
outb(PIC2_DATA, 40); // 슬레이브 PIC: IRQ 8-15 -> INT 40-47
// ICW3: 마스터-슬레이브 연결 설정
outb(PIC1_DATA, 0x04); // 슬레이브가 IRQ2에 연결
outb(PIC2_DATA, 0x02); // 슬레이브 ID = 2
// ICW4: 8086 모드 설정
outb(PIC1_DATA, 0x01);
outb(PIC2_DATA, 0x01);
// 모든 인터럽트 마스크 해제 (0x00 = 모두 활성화)
outb(PIC1_DATA, 0x00);
outb(PIC2_DATA, 0x00);
}
설명
이것이 하는 일: PIC의 두 개 칩(마스터/슬레이브)을 프로그래밍하여 하드웨어 인터럽트 번호를 재설정하고, 어떤 인터럽트를 받을지 마스크를 설정합니다. 첫 번째 단계로, ICW1(Initialization Command Word 1)을 보내 초기화 시퀀스를 시작합니다.
0x11 값은 "ICW4가 필요하고, 연쇄 모드로 작동한다"는 의미입니다. 마스터와 슬레이브 PIC 모두에게 명령 포트(0x20, 0xA0)를 통해 이 값을 전송합니다.
왜 이렇게 하는지 궁금하실 텐데, 8259 PIC는 특정 초기화 시퀀스를 따라야만 올바르게 설정되기 때문입니다. 두 번째 단계에서, ICW2를 통해 인터럽트 벡터 오프셋을 설정합니다.
마스터 PIC에 32를 보내면 IRQ 0-7이 인터럽트 32-39번으로 매핑됩니다. 슬레이브 PIC에 40을 보내면 IRQ 8-15가 40-47번으로 매핑됩니다.
내부적으로 PIC는 이 오프셋 값에 IRQ 번호를 더해 최종 인터럽트 벡터를 계산합니다. 세 번째 단계는 마스터-슬레이브 연결 구성입니다.
ICW3를 통해 마스터 PIC에게 "IRQ2 핀에 슬레이브가 연결되어 있다"(비트마스크 0x04)고 알려주고, 슬레이브에게는 "너의 ID는 2번이다"라고 설정합니다. 네 번째 단계에서 ICW4로 8086/8088 모드를 활성화하고(0x01), 마지막으로 데이터 포트에 0x00을 써서 모든 인터럽트 라인의 마스크를 해제합니다.
여러분이 이 코드를 사용하면 키보드, 타이머, 네트워크 카드 등 모든 하드웨어 장치로부터 인터럽트를 받을 수 있게 됩니다. 실무에서의 이점은 첫째, CPU 예외와 충돌하지 않는 깔끔한 인터럽트 테이블, 둘째, 15개의 하드웨어 인터럽트 라인을 모두 활용 가능, 셋째, 추후 APIC로 전환할 때도 레거시 호환성 유지가 가능하다는 점입니다.
실전 팁
💡 초기화 시퀀스 중간에 io_wait()를 추가하세요. 일부 오래된 하드웨어는 명령 사이에 짧은 대기 시간이 필요합니다. outb 후 즉시 다음 명령을 보내면 무시될 수 있습니다.
💡 멀티코어 시스템에서는 PIC 대신 APIC(Advanced PIC)를 사용하는 것이 권장되지만, PIC를 먼저 비활성화해야 합니다. APIC 초기화 전 PIC를 마스킹하지 않으면 spurious interrupt가 발생할 수 있습니다.
💡 디버깅 시 특정 IRQ만 활성화하려면 마스크를 선택적으로 설정하세요. 예를 들어 타이머(IRQ 0)만 활성화하려면 outb(PIC1_DATA, 0xFE)를 사용합니다.
💡 QEMU에서 테스트할 때 -no-acpi 옵션을 추가하면 PIC 동작을 더 명확히 관찰할 수 있습니다. 최신 시스템은 기본적으로 APIC를 사용하기 때문입니다.
💡 Rust의 unsafe를 사용하는 이유를 명확히 문서화하세요. I/O 포트 접근은 메모리 안전성을 보장할 수 없으므로 unsafe 블록으로 감싸야 합니다.
2. 인터럽트 마스킹 - 원하는 신호만 받기
시작하며
여러분이 OS를 개발하면서 모든 하드웨어 인터럽트가 동시에 쏟아져 들어와 시스템이 느려지거나 중요한 작업이 방해받은 경험이 있나요? 예를 들어, 디스크 I/O 중에 타이머 인터럽트가 계속 발생해 성능이 저하되는 경우입니다.
이런 문제는 인터럽트 우선순위를 관리하지 않을 때 발생합니다. PIC는 모든 IRQ를 동등하게 처리하지만, 실제로는 어떤 인터럽트는 일시적으로 차단하고 싶을 때가 있습니다.
특히 크리티컬 섹션에서는 특정 인터럽트를 막아야 데이터 일관성을 보장할 수 있습니다. 바로 이럴 때 필요한 것이 인터럽트 마스킹입니다.
필요한 인터럽트만 선택적으로 활성화하거나 비활성화하여 시스템을 효율적으로 제어할 수 있습니다.
개요
간단히 말해서, 인터럽트 마스킹은 PIC의 IMR(Interrupt Mask Register)을 조작하여 특정 IRQ를 활성화하거나 차단하는 기술입니다. 왜 이것이 필요한지 실무 관점에서 보면, 모든 하드웨어가 항상 인터럽트를 보낼 필요는 없습니다.
예를 들어, 사운드 카드를 사용하지 않는다면 해당 IRQ를 마스킹하여 불필요한 CPU 사이클을 절약할 수 있습니다. 또한 스핀락을 구현할 때 특정 IRQ를 일시적으로 차단해야 데드락을 방지할 수 있습니다.
기존에는 모든 인터럽트를 받거나 아예 CLI 명령으로 전체를 차단했다면, 이제는 개별 IRQ를 세밀하게 제어할 수 있습니다. 이 개념의 핵심 특징은 첫째, 각 IRQ별로 독립적인 마스킹이 가능하다는 점, 둘째, 런타임에 동적으로 변경 가능하다는 점, 셋째, 마스터/슬레이브 PIC를 별도로 제어할 수 있다는 점입니다.
이러한 특징들이 유연한 인터럽트 관리를 가능하게 합니다.
코드 예제
// 현재 마스크 읽기
pub unsafe fn read_pic_mask(pic: u16) -> u8 {
inb(pic)
}
// 특정 IRQ 마스킹 (차단)
pub unsafe fn mask_irq(irq: u8) {
let port = if irq < 8 { PIC1_DATA } else { PIC2_DATA };
let irq_bit = if irq < 8 { irq } else { irq - 8 };
let current_mask = inb(port);
let new_mask = current_mask | (1 << irq_bit); // 해당 비트를 1로 설정
outb(port, new_mask);
}
// 특정 IRQ 언마스킹 (활성화)
pub unsafe fn unmask_irq(irq: u8) {
let port = if irq < 8 { PIC1_DATA } else { PIC2_DATA };
let irq_bit = if irq < 8 { irq } else { irq - 8 };
let current_mask = inb(port);
let new_mask = current_mask & !(1 << irq_bit); // 해당 비트를 0으로 설정
outb(port, new_mask);
}
// 사용 예시: 타이머(IRQ 0)와 키보드(IRQ 1)만 활성화
pub unsafe fn enable_essential_irqs() {
outb(PIC1_DATA, 0xFC); // 비트마스크 11111100 = IRQ 0, 1만 활성화
outb(PIC2_DATA, 0xFF); // 슬레이브 PIC 모두 비활성화
}
설명
이것이 하는 일: PIC의 데이터 포트를 통해 8비트 마스크 값을 읽고 쓰면서, 각 비트로 개별 IRQ를 제어합니다. 첫 번째로, IRQ 번호를 받아 어느 PIC에 속하는지 판단합니다.
IRQ 0-7은 마스터 PIC(0x21), IRQ 8-15는 슬레이브 PIC(0xA1)에 매핑됩니다. 그리고 슬레이브 PIC의 경우 실제 비트 위치를 계산하기 위해 8을 빼줍니다.
왜 이렇게 하는지 보면, 각 PIC는 8개의 IRQ 라인만 관리하므로 물리적 비트 위치는 0-7 범위여야 하기 때문입니다. 그 다음으로, 현재 마스크 값을 inb로 읽어옵니다.
이 8비트 값의 각 비트는 하나의 IRQ를 나타내며, 1이면 차단(masked), 0이면 활성화(unmasked) 상태입니다. 마스킹할 때는 OR 연산(|)으로 해당 비트를 1로 만들고, 언마스킹할 때는 AND NOT 연산(&!)으로 해당 비트를 0으로 만듭니다.
마지막으로, 새로운 마스크 값을 outb로 다시 써줍니다. 예를 들어 enable_essential_irqs()에서 0xFC(11111100)를 쓰면 비트 0과 1만 0이 되어 IRQ 0(타이머)와 IRQ 1(키보드)만 활성화됩니다.
이 작업은 즉시 하드웨어에 반영되어 다음 인터럽트부터 적용됩니다. 여러분이 이 코드를 사용하면 불필요한 인터럽트 처리 오버헤드를 줄이고, 크리티컬 섹션에서 특정 IRQ를 임시 차단하여 동시성 문제를 해결할 수 있습니다.
실무에서의 이점은 첫째, CPU 사용률 최적화(사용하지 않는 장치의 IRQ 차단), 둘째, 데이터 레이스 방지(공유 자원 접근 중 관련 IRQ 차단), 셋째, 전력 관리(불필요한 웨이크업 방지)입니다.
실전 팁
💡 마스킹 상태를 변경한 후에는 반드시 현재 상태를 로그로 남기세요. 디버깅 시 "왜 인터럽트가 안 오지?"라는 질문의 90%는 잘못된 마스킹 때문입니다.
💡 슬레이브 PIC의 IRQ를 사용한다면 마스터 PIC의 IRQ 2를 절대 마스킹하지 마세요. IRQ 2는 슬레이브 연결 라인이므로 막으면 IRQ 8-15가 모두 차단됩니다.
💡 크리티컬 섹션에서 IRQ를 마스킹할 때는 RAII 패턴을 사용하세요. Rust의 Drop trait로 자동 복원을 구현하면 패닉 상황에서도 마스크가 원복됩니다.
💡 성능 측정 시 inb/outb는 매우 느린 작업(수백 CPU 사이클)이므로 마스킹을 자주 변경하지 마세요. 필요시 마스크 값을 캐싱하여 불필요한 I/O를 줄이세요.
💡 APIC로 전환하기 전에 모든 PIC IRQ를 마스킹(0xFF)하는 것을 잊지 마세요. 두 컨트롤러가 동시에 활성화되면 spurious interrupt가 발생할 수 있습니다.
3. EOI 신호 전송 - 인터럽트 처리 완료 알리기
시작하며
여러분이 타이머 인터럽트 핸들러를 작성했는데, 한 번만 실행되고 더 이상 인터럽트가 발생하지 않는 상황을 경험한 적 있나요? 이것은 OS 개발 초보자들이 가장 자주 겪는 문제 중 하나입니다.
이런 문제는 PIC에게 "인터럽트 처리가 끝났다"고 알려주지 않았기 때문에 발생합니다. PIC는 현재 인터럽트가 처리 중이라고 판단하여 같은 우선순위 이하의 새로운 인터럽트를 차단합니다.
키보드를 아무리 눌러도 반응이 없고, 타이머도 멈춘 것처럼 보입니다. 바로 이럴 때 필요한 것이 EOI(End Of Interrupt) 신호입니다.
인터럽트 핸들러의 마지막에 이 신호를 보내야만 PIC가 다음 인터럽트를 처리할 준비를 합니다.
개요
간단히 말해서, EOI는 PIC의 명령 포트에 특정 값(0x20)을 써서 "현재 인터럽트 처리가 완료되었다"고 알리는 프로토콜입니다. 왜 이것이 필요한지 실무 관점에서 보면, PIC는 ISR(In-Service Register)이라는 내부 레지스터로 현재 처리 중인 인터럽트를 추적합니다.
예를 들어, IRQ 3 인터럽트가 발생하면 ISR의 비트 3이 1로 설정되고, EOI를 받기 전까지는 같은 우선순위의 다른 인터럽트가 대기 상태로 남습니다. 키보드(IRQ 1)를 누르면 인터럽트가 발생하지만 PIC가 CPU로 전달하지 않는 것이죠.
기존에는 BIOS나 DOS 인터럽트 핸들러에서 자동으로 처리했다면, 이제는 우리가 직접 모든 하드웨어 인터럽트 핸들러에 EOI 전송 코드를 추가해야 합니다. 이 개념의 핵심 특징은 첫째, 슬레이브 PIC의 IRQ는 마스터에게도 EOI를 보내야 한다는 점(체인 구조), 둘째, EOI 전송 순서가 중요하다는 점(슬레이브 먼저, 마스터 나중), 셋째, Specific EOI와 Non-specific EOI의 차이를 이해해야 한다는 점입니다.
이러한 특징들이 안정적인 인터럽트 처리 루프를 가능하게 합니다.
코드 예제
// EOI 명령 코드
const PIC_EOI: u8 = 0x20;
// Non-specific EOI 전송 (가장 높은 우선순위 ISR 비트 클리어)
pub unsafe fn send_eoi(irq: u8) {
// IRQ 8-15는 슬레이브 PIC에 속함
if irq >= 8 {
// 슬레이브 PIC에 EOI 전송
outb(PIC2_CMD, PIC_EOI);
}
// 마스터 PIC에 항상 EOI 전송
// (슬레이브 IRQ도 마스터의 IRQ 2를 거쳐 오므로)
outb(PIC1_CMD, PIC_EOI);
}
// Specific EOI 전송 (특정 ISR 비트 클리어)
pub unsafe fn send_specific_eoi(irq: u8) {
if irq >= 8 {
// 슬레이브: 0x60 + (IRQ - 8)
outb(PIC2_CMD, 0x60 + (irq - 8));
}
// 마스터: 0x60 + IRQ (또는 슬레이브 사용 시 IRQ 2)
let master_irq = if irq >= 8 { 2 } else { irq };
outb(PIC1_CMD, 0x60 + master_irq);
}
// 인터럽트 핸들러 예시
extern "x86-interrupt" fn timer_interrupt_handler(_stack_frame: InterruptStackFrame) {
// 타이머 인터럽트 처리 로직
print!(".");
// 반드시 EOI 전송 (IRQ 0)
unsafe { send_eoi(0); }
}
설명
이것이 하는 일: 인터럽트 핸들러가 종료되기 직전에 PIC의 ISR 레지스터 비트를 클리어하여, 다음 인터럽트를 받을 수 있도록 PIC를 준비 상태로 만듭니다. 첫 번째로, IRQ 번호를 확인하여 마스터와 슬레이브 중 어디에서 온 인터럽트인지 판단합니다.
IRQ 0-7은 마스터 PIC만 관여하지만, IRQ 8-15는 슬레이브 PIC가 먼저 받아 마스터 PIC의 IRQ 2를 통해 CPU로 전달되는 구조입니다. 왜 이렇게 복잡한지 보면, 1981년 IBM PC 설계 당시 8개 이상의 IRQ가 필요해서 두 개의 8259 칩을 캐스케이드(연쇄) 연결했기 때문입니다.
그 다음으로, Non-specific EOI(0x20)를 보내면 PIC는 ISR 레지스터에서 가장 높은 우선순위 비트를 자동으로 찾아 클리어합니다. 이는 대부분의 경우에 충분히 작동하지만, 중첩된 인터럽트를 사용하는 경우(인터럽트 핸들러 내에서 다른 인터럽트 허용)에는 Specific EOI를 사용해야 합니다.
Specific EOI는 0x60 + IRQ 번호 형태로 정확히 어떤 ISR 비트를 클리어할지 지정합니다. 마지막으로, 슬레이브 PIC 인터럽트의 경우 반드시 슬레이브에 먼저 EOI를 보내고, 그 다음 마스터에 보내야 합니다.
순서를 바꾸면 마스터의 IRQ 2(슬레이브 연결 라인) ISR 비트는 클리어되지만 슬레이브의 ISR 비트는 여전히 설정되어 있어, 슬레이브 인터럽트가 더 이상 전달되지 않습니다. 실제 timer_interrupt_handler 예시에서 send_eoi(0)를 호출하지 않으면 첫 번째 틱 이후 타이머가 멈춥니다.
여러분이 이 코드를 사용하면 모든 하드웨어 인터럽트가 연속적으로 정상 처리되는 안정적인 시스템을 만들 수 있습니다. 실무에서의 이점은 첫째, 인터럽트 손실 방지(모든 키 입력, 네트워크 패킷 수신), 둘째, 정확한 타이밍(타이머 인터럽트가 규칙적으로 발생), 셋째, 중첩 인터럽트 지원(Specific EOI 사용 시)입니다.
실전 팁
💡 EOI는 인터럽트 핸들러의 가장 마지막에 보내야 하지만, 패닉이 발생할 수 있는 코드 이전에 보내는 것도 고려하세요. 핸들러가 패닉하면 EOI가 전송되지 않아 시스템이 멈출 수 있습니다.
💡 디버깅 시 ISR과 IRR(Interrupt Request Register)을 읽어보세요. OCW3 명령(0x0B)을 보낸 후 읽으면 현재 ISR 상태를 확인할 수 있어, EOI가 제대로 전송되었는지 검증 가능합니다.
💡 중첩 인터럽트를 사용한다면 반드시 Specific EOI를 사용하세요. Non-specific EOI는 가장 높은 우선순위 ISR 비트만 클리어하므로, 낮은 우선순위 인터럽트 처리 중 높은 우선순위가 끼어들면 잘못된 비트가 클리어됩니다.
💡 일부 에뮬레이터(QEMU 등)는 EOI를 보내지 않아도 동작하는 것처럼 보일 수 있습니다. 반드시 실제 하드웨어나 엄격한 모드에서 테스트하세요.
💡 APIC로 전환 후에도 PIC 모드로 폴백할 수 있도록 EOI 함수를 추상화하세요. 런타임에 현재 인터럽트 컨트롤러를 확인하여 적절한 EOI를 보내는 wrapper를 만드는 것이 좋습니다.
4. PIC 비활성화 - APIC로의 전환 준비
시작하며
여러분이 멀티코어 시스템에서 OS를 개발할 때, PIC로는 모든 코어에 인터럽트를 분산할 수 없어 성능 병목이 발생한 경험이 있나요? 또는 PCI MSI(Message Signaled Interrupts)를 사용하려고 하는데 PIC와 충돌하는 문제를 겪었나요?
이런 문제는 레거시 PIC가 현대적인 멀티코어 환경에 적합하지 않기 때문입니다. PIC는 단일 CPU만 지원하고, 인터럽트 라인이 15개로 제한되며, 우선순위 관리도 정적입니다.
특히 SMP(Symmetric Multi-Processing) 시스템에서는 모든 인터럽트가 하나의 코어로만 가서 부하 분산이 불가능합니다. 바로 이럴 때 필요한 것이 PIC 비활성화입니다.
APIC(Advanced PIC) 또는 x2APIC로 전환하기 전에 반드시 PIC를 완전히 끄는 과정이 필요합니다.
개요
간단히 말해서, PIC 비활성화는 모든 IRQ를 마스킹하고 ACPI/APIC 모드로 전환하는 과정입니다. 왜 이것이 필요한지 실무 관점에서 보면, PIC와 APIC가 동시에 활성화되면 같은 인터럽트가 두 번 전달되거나(double interrupt), spurious interrupt가 발생할 수 있습니다.
예를 들어, 네트워크 카드가 MSI를 통해 APIC로 인터럽트를 보내는 동시에 레거시 INTx 라인으로 PIC에도 신호를 보낼 수 있습니다. 이렇게 되면 같은 패킷을 두 번 처리하거나 시스템이 혼란에 빠집니다.
기존에는 BIOS가 PIC 모드로 부팅했다면, 이제는 우리가 직접 APIC 모드로 전환하고 PIC를 명시적으로 비활성화해야 합니다. 이 개념의 핵심 특징은 첫째, 모든 IRQ 마스킹으로 하드웨어 인터럽트 차단, 둘째, ACPI의 _PIC 메서드 호출로 펌웨어에 모드 전환 알림, 셋째, IMCR(Interrupt Mode Configuration Register) 설정으로 물리적 경로 변경입니다.
이러한 특징들이 안전한 APIC 전환을 보장합니다.
코드 예제
// PIC 완전 비활성화 함수
pub unsafe fn disable_pic() {
// 1. 모든 PIC 인터럽트 마스킹
outb(PIC1_DATA, 0xFF); // 마스터 PIC 모든 IRQ 차단
outb(PIC2_DATA, 0xFF); // 슬레이브 PIC 모든 IRQ 차단
// 2. IMCR 존재 여부 확인 (MP 테이블 또는 ACPI에서 확인)
// IMCR이 있다면 APIC 모드로 전환
if has_imcr() {
outb(0x22, 0x70); // IMCR 선택
outb(0x23, 0x01); // 비트 0을 1로 설정 = APIC 모드
}
// 3. spurious interrupt 방지를 위한 추가 초기화
// PIC를 완전히 리셋하지는 않지만, 모든 ISR 클리어
for irq in 0..16 {
send_specific_eoi(irq);
}
}
// IMCR 존재 여부 확인 (MP Configuration Table 파싱 필요)
fn has_imcr() -> bool {
// 실제 구현에서는 MP 테이블을 파싱하거나
// ACPI MADT를 확인하여 IMCR 존재 여부를 판단
// 여기서는 간단히 true 반환
true
}
// APIC 초기화 전에 호출
pub fn transition_to_apic() {
unsafe {
disable_pic();
// 이후 APIC 초기화 진행
// init_local_apic();
// init_io_apic();
}
}
설명
이것이 하는 일: PIC의 모든 인터럽트 라인을 비활성화하고, 시스템의 인터럽트 라우팅을 레거시 PIC 모드에서 APIC 모드로 변경합니다. 첫 번째로, 마스터와 슬레이브 PIC의 데이터 포트에 0xFF를 써서 모든 8비트를 1로 설정합니다.
이렇게 하면 15개의 모든 IRQ 라인이 마스킹되어, 하드웨어가 PIC에 신호를 보내도 CPU로 전달되지 않습니다. 왜 이것만으로는 부족한지 보면, PIC가 여전히 하드웨어 신호를 받아 IRR(Interrupt Request Register)에 기록하고 있기 때문입니다.
실제 인터럽트는 발생하지 않지만 PIC는 여전히 "활성" 상태입니다. 그 다음으로, IMCR(Interrupt Mode Configuration Register)를 조작합니다.
이는 Intel MP 사양에 정의된 레지스터로, 인터럽트가 PIC를 거쳐 CPU로 갈지(비트 0 = 0), 아니면 APIC로 직접 갈지(비트 0 = 1)를 결정합니다. 0x22 포트에 0x70을 써서 IMCR을 선택하고, 0x23 포트에 0x01을 써서 APIC 모드로 전환합니다.
내부적으로 이것은 마더보드의 칩셋 수준에서 물리적 신호 경로를 변경하는 것입니다. 마지막으로, 모든 ISR 비트를 클리어하기 위해 Specific EOI를 전송합니다.
만약 PIC 사용 중 인터럽트 처리가 완료되지 않은 상태로 비활성화하면, ISR 비트가 여전히 설정되어 있어 나중에 PIC를 다시 활성화할 때 문제가 될 수 있습니다. 실제로 transition_to_apic()를 호출하면 이후부터는 모든 인터럽트가 APIC를 통해 전달되며, PIC는 완전히 투명한(transparent) 상태가 됩니다.
여러분이 이 코드를 사용하면 멀티코어 시스템에서 각 CPU 코어가 독립적으로 인터럽트를 받을 수 있고, 224개 이상의 인터럽트 벡터를 사용할 수 있으며, MSI/MSI-X 같은 현대적인 인터럽트 메커니즘을 활용할 수 있습니다. 실무에서의 이점은 첫째, 더 나은 부하 분산(인터럽트를 여러 코어에 분산), 둘째, 낮은 레이턴시(APIC는 PIC보다 빠름), 셋째, PCIe 장치와의 호환성(대부분 MSI 사용)입니다.
실전 팁
💡 APIC 초기화가 실패할 경우를 대비해 PIC로 폴백하는 코드를 작성하세요. 일부 오래된 시스템이나 가상 머신은 APIC를 지원하지 않을 수 있습니다.
💡 disable_pic()를 호출하기 전에 APIC가 실제로 존재하는지 CPUID로 확인하세요. CPUID.01h:EDX 비트 9가 1이면 APIC가 있습니다.
💡 QEMU에서 테스트할 때 -machine q35 옵션을 사용하면 APIC를 명시적으로 활성화할 수 있습니다. 기본 isapc 머신은 PIC만 지원합니다.
💡 ACPI의 _PIC 메서드도 호출하여 펌웨어에게 APIC 모드 사용을 알려야 합니다. 일부 시스템은 이것이 없으면 PCI 인터럽트 라우팅이 제대로 설정되지 않습니다.
💡 디버깅 시 PIC와 APIC를 동시에 활성화하여 비교하지 마세요. 반드시 하나씩 순차적으로 테스트해야 어느 쪽에서 문제가 발생하는지 명확히 파악할 수 있습니다.
5. Spurious Interrupt 처리 - 가짜 인터럽트 대응
시작하며
여러분이 인터럽트 핸들러를 디버깅하는데, 등록하지 않은 인터럽트 번호로 핸들러가 호출되는 이상한 경험을 한 적 있나요? 특히 IRQ 7과 IRQ 15에서 이런 현상이 자주 발생합니다.
이런 문제는 PIC의 spurious interrupt 때문입니다. 하드웨어 타이밍 문제, 전기적 노이즈, 또는 EOI를 보내는 시점에 새로운 인터럽트가 들어오는 등의 이유로 "가짜" 인터럽트가 발생할 수 있습니다.
특히 마스터 PIC의 IRQ 7과 슬레이브 PIC의 IRQ 15가 가장 낮은 우선순위이기 때문에 spurious interrupt로 자주 사용됩니다. 바로 이럴 때 필요한 것이 spurious interrupt 감지 및 처리입니다.
이것을 제대로 처리하지 않으면 존재하지 않는 장치를 위해 불필요한 작업을 수행하거나, 잘못된 EOI로 인해 실제 인터럽트를 놓칠 수 있습니다.
개요
간단히 말해서, spurious interrupt는 실제 하드웨어 요청 없이 PIC가 생성하는 가짜 인터럽트이며, ISR 레지스터를 확인하여 진짜인지 구분합니다. 왜 이것이 필요한지 실무 관점에서 보면, spurious interrupt에 대해 EOI를 보내면 실제 인터럽트의 EOI가 먹혀버려 그 장치가 멈출 수 있습니다.
예를 들어, 진짜 IRQ 7(병렬 포트)이 처리 중인데 spurious IRQ 7이 발생하여 EOI를 먼저 보내면, 진짜 IRQ 7의 ISR 비트가 클리어되어 병렬 포트가 더 이상 인터럽트를 보낼 수 없게 됩니다. 기존에는 모든 인터럽트를 똑같이 처리했다면, 이제는 spurious인지 먼저 검사하고 다르게 처리해야 합니다.
이 개념의 핵심 특징은 첫째, ISR 레지스터 읽기로 진위 판별, 둘째, spurious인 경우 EOI를 보내지 않음(조건부 EOI), 셋째, 슬레이브 spurious는 마스터에만 EOI 전송입니다. 이러한 특징들이 인터럽트 처리의 정확성을 보장합니다.
코드 예제
// ISR(In-Service Register) 읽기
unsafe fn read_isr(pic_cmd: u16) -> u8 {
outb(pic_cmd, 0x0B); // OCW3: ISR 읽기 모드로 설정
inb(pic_cmd) // ISR 값 반환
}
// Spurious interrupt 확인 함수
pub unsafe fn is_spurious_irq(irq: u8) -> bool {
if irq == 7 {
// 마스터 PIC의 IRQ 7 체크
let isr = read_isr(PIC1_CMD);
// ISR 비트 7이 0이면 spurious
(isr & (1 << 7)) == 0
} else if irq == 15 {
// 슬레이브 PIC의 IRQ 15 체크
let isr = read_isr(PIC2_CMD);
// ISR 비트 7이 0이면 spurious (슬레이브에서 IRQ 15는 비트 7)
(isr & (1 << 7)) == 0
} else {
// 다른 IRQ는 spurious가 발생하지 않음
false
}
}
// 개선된 EOI 전송 함수 (spurious 처리 포함)
pub unsafe fn send_eoi_safe(irq: u8) {
// Spurious interrupt 확인
if is_spurious_irq(irq) {
if irq == 15 {
// 슬레이브 spurious는 마스터에만 EOI 전송
outb(PIC1_CMD, PIC_EOI);
}
// 마스터 spurious(IRQ 7)는 EOI 전송하지 않음
return;
}
// 일반 EOI 전송
if irq >= 8 {
outb(PIC2_CMD, PIC_EOI);
}
outb(PIC1_CMD, PIC_EOI);
}
// 인터럽트 핸들러 예시
extern "x86-interrupt" fn irq7_handler(_stack_frame: InterruptStackFrame) {
unsafe {
if is_spurious_irq(7) {
// Spurious interrupt이므로 처리하지 않고 바로 리턴
return;
}
// 실제 병렬 포트 인터럽트 처리
handle_parallel_port();
send_eoi(7);
}
}
설명
이것이 하는 일: PIC의 ISR 레지스터를 읽어 해당 IRQ가 실제로 처리 중인지 확인하고, spurious interrupt인 경우 특별한 처리를 합니다. 첫 번째로, OCW3(Operation Command Word 3) 명령 0x0B를 PIC 명령 포트에 써서 "다음 읽기는 ISR을 반환하라"고 설정합니다.
기본적으로 명령 포트를 읽으면 IRR(Interrupt Request Register)이 반환되지만, OCW3로 모드를 전환할 수 있습니다. 그 다음 inb로 읽으면 8비트 ISR 값을 얻을 수 있는데, 각 비트는 해당 IRQ가 현재 처리 중인지(1) 아닌지(0)를 나타냅니다.
왜 이렇게 하는지 보면, spurious interrupt는 IRR에는 기록되지만 ISR에는 기록되지 않기 때문입니다. 그 다음으로, IRQ 7 또는 15인 경우 해당 ISR 비트를 체크합니다.
예를 들어 IRQ 7이 spurious라면 ISR의 비트 7이 0입니다. 이것은 "PIC가 CPU에 IRQ 7 인터럽트를 보냈지만, 실제로는 어떤 장치도 요청하지 않았다"는 의미입니다.
이런 일이 발생하는 이유는 PIC가 IRQ를 샘플링하는 순간과 실제로 CPU에 전달하는 순간 사이에 하드웨어 신호가 사라졌기 때문입니다. 마지막으로, spurious interrupt에 대한 EOI 처리를 결정합니다.
IRQ 7(마스터 spurious)은 EOI를 전혀 보내지 않습니다. 왜냐하면 ISR 비트가 설정되지 않았으므로 클리어할 것이 없기 때문입니다.
하지만 IRQ 15(슬레이브 spurious)는 특별합니다. 슬레이브에는 EOI를 보내지 않지만, 마스터의 IRQ 2 ISR 비트는 설정되어 있으므로 마스터에만 EOI를 보내야 합니다.
이것을 놓치면 마스터가 "IRQ 2(슬레이브 연결)가 아직 처리 중"이라고 판단하여 슬레이브의 모든 인터럽트를 차단합니다. 여러분이 이 코드를 사용하면 안정적인 인터럽트 처리 시스템을 구축할 수 있고, 디버깅 시 "왜 갑자기 이상한 인터럽트가 오지?"라는 의문을 해결할 수 있습니다.
실무에서의 이점은 첫째, 잘못된 EOI로 인한 인터럽트 손실 방지, 둘째, 불필요한 핸들러 실행 최소화로 성능 향상, 셋째, 로깅을 통한 하드웨어 문제 감지(spurious가 너무 자주 발생하면 하드웨어 결함)입니다.
실전 팁
💡 Spurious interrupt 발생을 로그로 남기세요. 정상적인 시스템에서도 가끔 발생하지만, 너무 자주 발생한다면 하드웨어 결함, 전원 공급 문제, 또는 타이밍 이슈를 의심해야 합니다.
💡 병렬 포트(IRQ 7)나 세컨더리 ATA(IRQ 15)를 실제로 사용하지 않는다면 해당 IRQ를 마스킹하세요. Spurious interrupt는 활성화된 IRQ에서만 발생합니다.
💡 ISR 읽기는 PIC 상태를 변경하지 않는 안전한 작업이지만, I/O 포트 접근은 느리므로 모든 인터럽트에서 하지 말고 IRQ 7, 15에만 적용하세요.
💡 APIC로 전환 후에는 spurious interrupt 처리 방식이 다릅니다. APIC는 SVR(Spurious Interrupt Vector Register)에 별도의 벡터를 설정하여 자동으로 분리합니다.
💡 일부 에뮬레이터는 spurious interrupt를 생성하지 않을 수 있습니다. 실제 하드웨어 또는 QEMU의 -machine pc 옵션으로 정확한 PIC 에뮬레이션을 활성화하여 테스트하세요.
6. 인터럽트 우선순위 관리 - 중요한 것부터 처리하기
시작하며
여러분이 네트워크 패킷을 처리하는 동안 키보드 입력이 씹히거나, 디스크 I/O 중에 시스템 타이머가 지연되는 문제를 겪은 적 있나요? 이것은 인터럽트 우선순위를 제대로 관리하지 않아서 발생합니다.
이런 문제는 모든 인터럽트를 동등하게 처리하기 때문입니다. 실제로는 시스템 타이머처럼 즉시 처리해야 하는 인터럽트와 병렬 포트처럼 조금 늦어도 되는 인터럽트가 있습니다.
PIC는 하드웨어 수준에서 우선순위를 지원하지만, 이를 제대로 이해하고 활용해야 합니다. 바로 이럴 때 필요한 것이 인터럽트 우선순위 관리입니다.
PIC의 우선순위 메커니즘을 이해하고 필요에 따라 회전(rotation)하거나 고정하여 시스템의 반응성을 최적화할 수 있습니다.
개요
간단히 말해서, 인터럽트 우선순위 관리는 PIC의 우선순위 레벨을 조정하여 어떤 IRQ가 다른 IRQ를 선점할 수 있는지 결정하는 것입니다. 왜 이것이 필요한지 실무 관점에서 보면, 기본적으로 PIC는 IRQ 0이 가장 높은 우선순위, IRQ 7(또는 15)이 가장 낮은 우선순위를 갖습니다.
예를 들어, IRQ 3(COM2)이 처리 중일 때 IRQ 0(타이머)이 들어오면 선점(preempt)할 수 있지만, IRQ 5(사운드 카드)는 대기해야 합니다. 이것을 이해하지 못하면 실시간성이 필요한 인터럽트가 지연될 수 있습니다.
기존에는 고정된 우선순위를 그대로 사용했다면, 이제는 OCW2 명령으로 우선순위를 회전시키거나 특정 수준으로 설정할 수 있습니다. 이 개념의 핵심 특징은 첫째, 완전 중첩 모드(Fully Nested Mode)에서 우선순위 자동 관리, 둘째, 자동/특정 회전 모드로 공정성 향상, 셋째, 특정 우선순위 설정으로 중요 IRQ 보호입니다.
이러한 특징들이 유연한 인터럽트 스케줄링을 가능하게 합니다.
코드 예제
// OCW2 명령어 정의
const OCW2_EOI: u8 = 0x20; // Non-specific EOI
const OCW2_SPECIFIC_EOI: u8 = 0x60; // Specific EOI (+ IRQ 번호)
const OCW2_ROTATE_AUTO: u8 = 0xA0; // Rotate on auto-EOI mode (set)
const OCW2_ROTATE_AUTO_CLEAR: u8 = 0x80; // Rotate on auto-EOI mode (clear)
const OCW2_ROTATE_SPECIFIC: u8 = 0xE0; // Rotate on specific EOI (+ IRQ)
const OCW2_SET_PRIORITY: u8 = 0xC0; // Set priority command (+ IRQ)
// 특정 IRQ를 최저 우선순위로 설정 (그 다음 IRQ가 최고 우선순위가 됨)
pub unsafe fn set_lowest_priority(pic_cmd: u16, irq: u8) {
// 예: IRQ 0을 최저로 설정하면 IRQ 1이 최고 우선순위가 됨
outb(pic_cmd, OCW2_SET_PRIORITY | (irq & 0x07));
}
// 자동 회전 모드 활성화 (공정한 우선순위 분배)
pub unsafe fn enable_auto_rotate(pic_cmd: u16) {
outb(pic_cmd, OCW2_ROTATE_AUTO);
}
// 자동 회전 모드 비활성화
pub unsafe fn disable_auto_rotate(pic_cmd: u16) {
outb(pic_cmd, OCW2_ROTATE_AUTO_CLEAR);
}
// 특정 IRQ 처리 후 우선순위 회전
pub unsafe fn send_eoi_with_rotate(irq: u8) {
if irq >= 8 {
let slave_irq = irq - 8;
outb(PIC2_CMD, OCW2_ROTATE_SPECIFIC | slave_irq);
outb(PIC1_CMD, OCW2_EOI); // 마스터는 일반 EOI
} else {
outb(PIC1_CMD, OCW2_ROTATE_SPECIFIC | irq);
}
}
// 실시간 시스템용 우선순위 설정 예시
pub unsafe fn setup_realtime_priorities() {
// 타이머(IRQ 0)를 항상 최고 우선순위로 유지
set_lowest_priority(PIC1_CMD, 7); // IRQ 7을 최저로 설정
// 이제 우선순위: 0 > 1 > 2 > 3 > 4 > 5 > 6 > 7
// 네트워크(IRQ 11)를 슬레이브에서 높은 우선순위로
set_lowest_priority(PIC2_CMD, 7); // IRQ 15를 최저로 설정
// 슬레이브 우선순위: 8 > 9 > 10 > 11 > 12 > 13 > 14 > 15
}
설명
이것이 하는 일: PIC의 내부 우선순위 로직을 조작하여 동시에 여러 인터럽트가 발생했을 때 어느 것을 먼저 처리할지, 그리고 어떤 인터럽트가 다른 것을 선점할 수 있는지 결정합니다. 첫 번째로, PIC의 완전 중첩 모드(Fully Nested Mode)를 이해해야 합니다.
이 모드에서 PIC는 현재 처리 중인 인터럽트보다 높은 우선순위의 인터럽트만 CPU로 전달합니다. 예를 들어, IRQ 3이 처리 중일 때(ISR 비트 3 설정) IRQ 0, 1, 2는 전달되지만 IRQ 4-7은 대기합니다.
왜 이렇게 하는지 보면, 낮은 우선순위 작업이 높은 우선순위 작업을 방해하지 않기 위함입니다. 기본 우선순위는 IRQ 번호가 낮을수록 높습니다(0 > 1 > 2 ...
7). 그 다음으로, 우선순위 회전 메커니즘을 살펴봅니다.
set_lowest_priority(pic_cmd, irq)를 호출하면 지정된 IRQ가 최저 우선순위가 되고, 그 다음 IRQ(순환)가 최고 우선순위가 됩니다. 예를 들어 set_lowest_priority(PIC1_CMD, 3)을 실행하면 새로운 우선순위는 4 > 5 > 6 > 7 > 0 > 1 > 2 > 3이 됩니다.
내부적으로 PIC는 "기준점" 레지스터를 유지하며, 이 값보다 높은 번호가 실제로는 더 높은 우선순위를 갖도록 순환 비교를 수행합니다. 자동 회전 모드(Auto-Rotate)는 더 흥미롭습니다.
이 모드를 활성화하면 EOI를 받을 때마다 방금 처리된 IRQ가 자동으로 최저 우선순위가 됩니다. 예를 들어, IRQ 0 처리 후 EOI를 보내면 우선순위가 1 > 2 > ...
7 > 0으로 바뀝니다. 다음에 IRQ 2를 처리하면 3 > 4 > ...
0 > 1 > 2가 됩니다. 이것은 모든 IRQ에 공정한 기회를 주는 "라운드 로빈" 스케줄링과 유사합니다.
하지만 실시간 시스템에서는 권장되지 않습니다. 타이머 같은 중요한 인터럽트가 최저 우선순위로 밀려날 수 있기 때문입니다.
여러분이 이 코드를 사용하면 시스템의 인터럽트 응답 시간을 최적화하고, 특정 작업의 실시간성을 보장할 수 있습니다. 실무에서의 이점은 첫째, 타이머 정확도 향상(항상 최고 우선순위 유지), 둘째, 네트워크 처리량 개선(패킷 손실 방지), 셋째, 사용자 입력 반응성 향상(키보드/마우스 우선순위 조정)입니다.
실전 팁
💡 실시간 OS를 개발한다면 타이머(IRQ 0)를 절대 최저 우선순위로 만들지 마세요. 스케줄러가 타이머 인터럽트에 의존하므로 지연되면 전체 시스템 타이밍이 망가집니다.
💡 자동 회전 모드는 일반적인 데스크톱 OS에 적합하지만, 임베디드나 실시간 시스템에서는 고정 우선순위를 사용하세요. 예측 가능성이 더 중요합니다.
💡 우선순위를 변경한 후 현재 설정을 로그로 남기세요. PIC는 우선순위 상태를 읽는 직접적인 방법이 없으므로, 소프트웨어에서 추적해야 합니다.
💡 마스터와 슬레이브 PIC의 우선순위는 독립적입니다. 하지만 슬레이브 전체는 마스터의 IRQ 2를 통하므로, 마스터 우선순위 설정 시 이를 고려하세요.
💡 APIC로 전환하면 훨씬 유연한 우선순위 관리가 가능합니다. APIC는 256 레벨의 우선순위를 지원하며, 각 인터럽트 벡터별로 개별 설정 가능합니다.
7. PIC 상태 읽기 - 디버깅의 핵심
시작하며
여러분이 인터럽트가 오지 않는 문제를 디버깅할 때, "하드웨어가 신호를 보냈는가?", "PIC가 받았는가?", "CPU로 전달했는가?"를 구분하지 못해 몇 시간을 허비한 경험이 있나요? 이것은 OS 개발에서 가장 흔한 디버깅 시나리오입니다.
이런 문제는 PIC의 내부 상태를 관찰할 방법이 없기 때문에 발생합니다. "인터럽트가 안 온다"는 증상만으로는 마스킹 문제인지, EOI를 안 보낸 것인지, 아니면 하드웨어 결함인지 알 수 없습니다.
특히 IRR과 ISR의 차이를 이해하지 못하면 근본 원인을 찾기 어렵습니다. 바로 이럴 때 필요한 것이 PIC 상태 읽기입니다.
IRR, ISR, IMR 레지스터를 읽어 정확히 어느 단계에서 문제가 발생했는지 파악할 수 있습니다.
개요
간단히 말해서, PIC 상태 읽기는 OCW3 명령을 사용하여 IRR, ISR 레지스터를 읽고, 데이터 포트에서 IMR을 읽는 기술입니다. 왜 이것이 필요한지 실무 관점에서 보면, 각 레지스터는 다른 정보를 제공합니다.
IRR(Interrupt Request Register)은 "하드웨어가 요청했지만 아직 CPU로 전달되지 않은" 인터럽트를 보여주고, ISR(In-Service Register)은 "현재 CPU가 처리 중인" 인터럽트를 보여주며, IMR(Interrupt Mask Register)은 "차단된" 인터럽트를 보여줍니다. 예를 들어, IRR 비트 1이 1이고 ISR 비트 1이 0이라면 "키보드가 인터럽트를 요청했지만 PIC가 CPU로 전달하지 못했다"는 의미입니다(아마도 높은 우선순위 인터럽트가 처리 중).
기존에는 printk 디버깅으로 추측했다면, 이제는 정확한 하드웨어 상태를 읽어 과학적으로 디버깅할 수 있습니다. 이 개념의 핵심 특징은 첫째, OCW3로 읽기 모드 전환(IRR/ISR 선택), 둘째, 각 비트가 특정 IRQ 상태를 나타냄, 셋째, 마스터와 슬레이브를 별도로 읽어야 한다는 점입니다.
이러한 특징들이 정밀한 인터럽트 디버깅을 가능하게 합니다.
코드 예제
// PIC 레지스터 읽기 함수들
pub unsafe fn read_irr(pic_cmd: u16) -> u8 {
outb(pic_cmd, 0x0A); // OCW3: IRR 읽기 모드
inb(pic_cmd)
}
pub unsafe fn read_isr(pic_cmd: u16) -> u8 {
outb(pic_cmd, 0x0B); // OCW3: ISR 읽기 모드
inb(pic_cmd)
}
pub unsafe fn read_imr(pic_data: u16) -> u8 {
inb(pic_data) // 데이터 포트에서 직접 읽기
}
// 모든 PIC 상태 출력 (디버깅용)
pub unsafe fn dump_pic_state() {
let master_irr = read_irr(PIC1_CMD);
let master_isr = read_isr(PIC1_CMD);
let master_imr = read_imr(PIC1_DATA);
let slave_irr = read_irr(PIC2_CMD);
let slave_isr = read_isr(PIC2_CMD);
let slave_imr = read_imr(PIC2_DATA);
println!("=== Master PIC (IRQ 0-7) ===");
println!("IRR: {:08b} (Pending)", master_irr);
println!("ISR: {:08b} (In-Service)", master_isr);
println!("IMR: {:08b} (Masked)", master_imr);
println!("\n=== Slave PIC (IRQ 8-15) ===");
println!("IRR: {:08b} (Pending)", slave_irr);
println!("ISR: {:08b} (In-Service)", slave_isr);
println!("IMR: {:08b} (Masked)", slave_imr);
}
// 특정 IRQ 상태 분석
pub unsafe fn analyze_irq(irq: u8) {
let (pic_cmd, pic_data, bit) = if irq < 8 {
(PIC1_CMD, PIC1_DATA, irq)
} else {
(PIC2_CMD, PIC2_DATA, irq - 8)
};
let irr = read_irr(pic_cmd);
let isr = read_isr(pic_cmd);
let imr = read_imr(pic_data);
let pending = (irr & (1 << bit)) != 0;
let in_service = (isr & (1 << bit)) != 0;
let masked = (imr & (1 << bit)) != 0;
println!("IRQ {} status:", irq);
println!(" Pending: {} (하드웨어 요청 있음)", pending);
println!(" In-Service: {} (CPU 처리 중)", in_service);
println!(" Masked: {} (차단됨)", masked);
// 상태 해석
if masked {
println!(" → 문제: IRQ가 마스킹되어 있습니다!");
} else if in_service && pending {
println!(" → 문제: EOI를 보내지 않아 새 인터럽트가 대기 중입니다!");
} else if pending && !in_service {
println!(" → 정상: 하드웨어 요청 대기 중 (높은 우선순위 처리 중일 수 있음)");
} else if in_service && !pending {
println!(" → 정상: 현재 인터럽트 처리 중");
} else {
println!(" → 정상: 유휴 상태");
}
}
설명
이것이 하는 일: PIC의 내부 레지스터를 읽어 인터럽트 처리 파이프라인의 각 단계(요청 → 승인 → 처리)를 가시화합니다. 첫 번째로, IRR(Interrupt Request Register)을 읽는 방법을 봅시다.
OCW3 명령 0x0A를 명령 포트에 쓰면 PIC가 "다음 읽기는 IRR을 반환하라" 모드로 전환됩니다. 그 다음 같은 포트를 읽으면 8비트 값이 나오는데, 각 비트는 해당 IRQ의 요청 상태를 나타냅니다.
예를 들어 비트 1이 1이면 "키보드(IRQ 1)가 인터럽트를 요청했다"는 의미입니다. 왜 이것이 유용한지 보면, IRR 비트가 1인데 인터럽트가 안 온다면 우선순위나 마스킹 문제임을 알 수 있기 때문입니다.
그 다음으로, ISR(In-Service Register)을 읽습니다. OCW3 명령 0x0B로 모드를 전환하고 읽으면, 현재 CPU가 처리 중인(아직 EOI를 받지 않은) 인터럽트를 확인할 수 있습니다.
내부적으로 PIC는 인터럽트를 CPU로 전달하면 해당 ISR 비트를 1로 설정하고, EOI를 받으면 0으로 클리어합니다. 만약 ISR 비트가 계속 1로 남아있다면 핸들러가 EOI를 보내지 않았거나, 핸들러가 크래시했음을 의미합니다.
IMR(Interrupt Mask Register)은 더 간단합니다. 데이터 포트를 직접 읽으면 현재 마스크 상태를 얻을 수 있습니다.
비트 1은 차단, 비트 0은 활성화입니다. analyze_irq() 함수는 이 세 레지스터를 조합하여 자동으로 문제를 진단합니다.
예를 들어, IRR=1, ISR=0, IMR=1이라면 "하드웨어가 요청했지만 마스킹되어 CPU로 전달되지 않았다"고 정확히 알려줍니다. IRR=1, ISR=1이라면 "현재 처리 중이고 새 요청도 대기 중인데, 아마 EOI를 안 보냈을 것"이라고 추론할 수 있습니다.
여러분이 이 코드를 사용하면 디버깅 시간을 몇 시간에서 몇 분으로 단축할 수 있습니다. 실무에서의 이점은 첫째, 근본 원인 즉시 파악(추측 대신 증거 기반 디버깅), 둘째, 하드웨어 문제와 소프트웨어 문제 구분 가능, 셋째, 런타임 모니터링으로 간헐적 버그 포착입니다.
실전 팁
💡 부팅 직후와 주요 초기화 단계마다 dump_pic_state()를 호출하여 로그를 남기세요. 나중에 문제가 생겼을 때 "정상 상태"와 비교할 수 있습니다.
💡 특정 IRQ가 너무 자주 발생한다면(IRR이 항상 1) 하드웨어가 고장났거나, 핸들러가 하드웨어를 제대로 리셋하지 않아 계속 신호를 보내는 것일 수 있습니다.
💡 ISR의 여러 비트가 동시에 1이라면 중첩 인터럽트가 발생 중입니다. 이것이 의도된 것인지 확인하고, 아니라면 인터럽트 핸들러에서 CLI/STI를 잘못 사용했을 수 있습니다.
💡 성능에 민감한 경로에서는 레지스터 읽기를 피하세요. I/O 포트 접근은 매우 느리므로(수백 사이클) 디버깅 빌드에만 사용하거나, 조건부 컴파일로 릴리스 빌드에서는 제거하세요.
💡 QEMU에서 -d int 옵션을 사용하면 모든 인터럽트를 로그로 볼 수 있습니다. PIC 상태 읽기와 함께 사용하면 강력한 디버깅 조합이 됩니다.
8. Edge vs Level Triggered - 트리거 모드 이해하기
시작하며
여러분이 PCI 장치를 추가했는데 인터럽트가 계속 반복되거나, 한 번만 발생하고 멈추는 문제를 겪은 적 있나요? 이것은 인터럽트 트리거 모드를 제대로 설정하지 않았기 때문입니다.
이런 문제는 하드웨어마다 다른 신호 방식을 사용하기 때문에 발생합니다. ISA 장치는 Edge-triggered 방식을 사용하고, PCI 장치는 Level-triggered 방식을 사용합니다.
PIC는 기본적으로 Edge-triggered 모드로 동작하는데, 이것을 이해하지 못하면 PCI 인터럽트를 놓치거나 중복 처리하게 됩니다. 바로 이럴 때 필요한 것이 트리거 모드 설정입니다.
ELCR(Edge/Level Control Register)을 사용하여 각 IRQ의 트리거 방식을 설정할 수 있습니다.
개요
간단히 말해서, Edge-triggered는 신호가 Low→High로 변할 때 한 번 인터럽트를 생성하고, Level-triggered는 신호가 High인 동안 계속 인터럽트를 요청하는 방식입니다. 왜 이것이 필요한지 실무 관점에서 보면, Edge-triggered는 빠른 이벤트(키보드 누름)에 적합하지만 인터럽트를 놓칠 수 있고, Level-triggered는 안정적이지만(하드웨어가 신호를 유지하므로) 반드시 소프트웨어가 인터럽트 원인을 클리어해야 합니다.
예를 들어, PCI 네트워크 카드는 Level-triggered로 설정해야 패킷이 남아있는 동안 계속 인터럽트를 받을 수 있습니다. Edge로 설정하면 첫 번째 패킷만 처리되고 나머지는 무시됩니다.
기존에는 모든 IRQ를 Edge-triggered로 사용했다면(ISA 시대), 이제는 PCI 장치를 위해 Level-triggered를 지원해야 합니다. 이 개념의 핵심 특징은 첫째, ELCR 레지스터로 IRQ별 모드 설정, 둘째, Level-triggered는 반드시 하드웨어 클리어 필요, 셋째, IRQ 공유(shared IRQ)는 Level-triggered만 가능하다는 점입니다.
이러한 특징들이 현대적인 PCI 장치 지원을 가능하게 합니다.
코드 예제
// ELCR(Edge/Level Control Register) 포트
const ELCR1: u16 = 0x4D0; // IRQ 0-7
const ELCR2: u16 = 0x4D1; // IRQ 8-15
// ELCR 읽기
pub unsafe fn read_elcr() -> u16 {
let low = inb(ELCR1) as u16;
let high = inb(ELCR2) as u16;
(high << 8) | low
}
// ELCR 쓰기
pub unsafe fn write_elcr(value: u16) {
outb(ELCR1, (value & 0xFF) as u8);
outb(ELCR2, ((value >> 8) & 0xFF) as u8);
}
// 특정 IRQ를 Level-triggered로 설정
pub unsafe fn set_level_triggered(irq: u8) {
let mut elcr = read_elcr();
elcr |= 1 << irq; // 비트를 1로 설정 = Level-triggered
write_elcr(elcr);
}
// 특정 IRQ를 Edge-triggered로 설정
pub unsafe fn set_edge_triggered(irq: u8) {
let mut elcr = read_elcr();
elcr &= !(1 << irq); // 비트를 0으로 설정 = Edge-triggered
write_elcr(elcr);
}
// PCI 장치 초기화 시 호출
pub unsafe fn configure_pci_irq(pci_irq: u8) {
// PCI는 반드시 Level-triggered
set_level_triggered(pci_irq);
unmask_irq(pci_irq);
}
// Level-triggered 인터럽트 핸들러 예시
extern "x86-interrupt" fn pci_network_handler(_stack_frame: InterruptStackFrame) {
unsafe {
// 1. 네트워크 카드에서 데이터 처리
while let Some(packet) = network_card_read_packet() {
process_packet(packet);
}
// 2. 하드웨어 인터럽트 상태 클리어
// (장치별 레지스터에 써서 IRQ 라인을 Low로 만듦)
network_card_clear_interrupt();
// 3. PIC에 EOI 전송
send_eoi(11); // 예: IRQ 11
// 주의: 순서가 중요! 하드웨어 클리어를 먼저 해야 함
}
}
설명
이것이 하는 일: ELCR 레지스터를 조작하여 PIC가 각 IRQ 라인을 어떻게 해석할지(엣지 감지 vs 레벨 감지) 결정합니다. 첫 번째로, Edge-triggered 동작을 이해해봅시다.
하드웨어가 IRQ 라인을 Low에서 High로 변경하는 순간, PIC는 IRR의 해당 비트를 1로 설정합니다. 그 후 라인이 High로 유지되어도 추가 인터럽트는 생성되지 않습니다.
다시 인터럽트를 받으려면 라인이 Low→High 전환을 반복해야 합니다. 왜 이렇게 하는지 보면, 키보드처럼 "이벤트"가 중요한 장치에 적합하기 때문입니다.
키를 누르는 순간(전환)만 감지하면 되고, 계속 눌러도 한 번만 처리됩니다. 그 다음으로, Level-triggered 동작을 봅시다.
하드웨어가 IRQ 라인을 High로 유지하는 동안, PIC는 계속해서 IRR 비트를 1로 유지하려고 합니다. EOI를 보내 ISR을 클리어해도, 라인이 여전히 High라면 즉시 다시 인터럽트가 발생합니다.
내부적으로 이것은 PIC가 매 클록마다 라인 상태를 샘플링하여 High이면 IRR을 설정하는 방식입니다. 이것이 왜 중요한지 보면, 네트워크 카드처럼 "여러 개의 패킷이 대기 중"인 상황에서 모든 패킷을 처리할 때까지 계속 인터럽트를 받을 수 있기 때문입니다.
중요한 것은 Level-triggered 핸들러의 처리 순서입니다. 반드시 1) 모든 데이터 처리 → 2) 장치의 인터럽트 상태 레지스터 클리어 → 3) PIC에 EOI 전송 순서를 지켜야 합니다.
만약 하드웨어를 클리어하지 않고 EOI를 보내면, 라인이 여전히 High이므로 즉시 같은 인터럽트가 다시 발생하여 무한 루프에 빠집니다. 반대로 EOI를 먼저 보내면 새 인터럽트를 받을 수 있게 되지만, 현재 데이터를 다 처리하지 못했을 수 있습니다.
ELCR 비트 설정은 간단합니다. 비트 0은 Edge, 비트 1은 Level을 의미합니다.
하지만 주의할 점은 IRQ 0, 1, 2, 8, 13은 시스템 예약이므로 변경하면 안 됩니다(일부 시스템에서는 무시되거나 시스템이 불안정해집니다). 여러분이 이 코드를 사용하면 PCI 장치를 안정적으로 지원하고, 여러 장치가 같은 IRQ를 공유(shared interrupt)하는 경우도 처리할 수 있습니다.
실무에서의 이점은 첫째, PCI 네트워크 카드의 패킷 손실 방지, 둘째, IRQ 공유로 제한된 15개 IRQ 라인 효율적 활용, 셋째, 더 안정적인 인터럽트 처리(Level은 인터럽트를 놓치지 않음)입니다.
실전 팁
💡 PCI Configuration Space를 읽어 장치가 어떤 IRQ를 사용하는지 확인하세요. PCI Interrupt Line 레지스터(오프셋 0x3C)에 IRQ 번호가 저장되어 있습니다.
💡 IRQ 공유를 구현할 때는 반드시 Level-triggered를 사용하세요. Edge-triggered에서는 동시에 발생한 여러 장치의 인터럽트 중 하나만 감지됩니다.
💡 인터럽트 핸들러에서 "이 인터럽트가 내 장치에서 온 것인가?"를 확인하세요. 공유 IRQ에서는 다른 장치의 인터럽트일 수 있으므로, 장치 상태 레지스터를 확인해야 합니다.
💡 ELCR은 모든 시스템에 존재하는 것은 아닙니다. 읽어서 0xFFFF가 나오면 ELCR이 없는 것이므로 PCI 장치를 MSI로 전환하는 것을 고려하세요.
💡 APIC/MSI로 전환하면 트리거 모드를 더 유연하게 설정할 수 있습니다. MSI는 항상 Edge-triggered이지만 소프트웨어 레벨에서 처리되어 더 안정적입니다.
9. I/O 대기 시간 처리 - 타이밍 이슈 방지
시작하며
여러분이 PIC 초기화 코드를 작성했는데, 최신 CPU에서는 잘 작동하지만 오래된 하드웨어나 특정 가상 머신에서 인터럽트가 제대로 작동하지 않는 경험을 한 적 있나요? 이것은 I/O 포트 타이밍 문제 때문입니다.
이런 문제는 CPU 속도와 I/O 장치 속도의 차이 때문에 발생합니다. 현대 CPU는 수 GHz로 작동하지만, PIC 같은 레거시 I/O 장치는 수 MHz로 작동합니다.
연속된 outb 명령을 너무 빠르게 보내면 PIC가 첫 번째 명령을 처리하기 전에 두 번째 명령이 도착하여 무시되거나 잘못 해석될 수 있습니다. 바로 이럴 때 필요한 것이 I/O 대기 시간(I/O wait) 삽입입니다.
중요한 I/O 명령 사이에 짧은 딜레이를 추가하여 하드웨어가 안정적으로 처리할 시간을 줍니다.
개요
간단히 말해서, I/O 대기는 의미 없는 포트(0x80)에 쓰기를 수행하여 CPU를 일시적으로 멈추는 기법입니다. 왜 이것이 필요한지 실무 관점에서 보면, outb/inb 명령은 CPU에서는 수십 사이클이 걸리지만, 실제 I/O 버스를 통한 전송과 장치의 응답은 훨씬 느립니다.
예를 들어, PIC 초기화 시 ICW1을 보낸 직후 ICW2를 보내면, PIC가 아직 ICW1을 처리 중이어서 ICW2를 잘못된 레지스터에 쓸 수 있습니다. 특히 486, Pentium 같은 오래된 CPU에서 자주 발생합니다.
기존에는 for 루프로 딜레이를 만들었다면(CPU 속도에 의존), 이제는 I/O 포트 쓰기로 일정한 딜레이를 보장할 수 있습니다. 이 개념의 핵심 특징은 첫째, 포트 0x80 쓰기가 표준 딜레이 방법, 둘째, 약 1μs 정도의 짧은 지연 제공, 셋째, CPU 속도와 무관하게 일정한 시간 보장입니다.
이러한 특징들이 안정적인 레거시 하드웨어 제어를 가능하게 합니다.
코드 예제
// I/O 대기 포트 (POST code port, 대부분 시스템에서 안전)
const IO_WAIT_PORT: u16 = 0x80;
// I/O 대기 함수 (약 1μs 지연)
#[inline]
pub unsafe fn io_wait() {
outb(IO_WAIT_PORT, 0);
}
// 개선된 PIC 초기화 (I/O 대기 포함)
pub unsafe fn initialize_pic_safe() {
// ICW1: 초기화 시작
outb(PIC1_CMD, 0x11);
io_wait();
outb(PIC2_CMD, 0x11);
io_wait();
// ICW2: 인터럽트 벡터 오프셋
outb(PIC1_DATA, 32);
io_wait();
outb(PIC2_DATA, 40);
io_wait();
// ICW3: 마스터-슬레이브 연결
outb(PIC1_DATA, 0x04);
io_wait();
outb(PIC2_DATA, 0x02);
io_wait();
// ICW4: 8086 모드
outb(PIC1_DATA, 0x01);
io_wait();
outb(PIC2_DATA, 0x01);
io_wait();
// 마스크 설정
outb(PIC1_DATA, 0x00);
io_wait();
outb(PIC2_DATA, 0x00);
io_wait();
}
// 대체 방법: PIT를 사용한 정확한 딜레이
pub unsafe fn delay_microseconds(us: u32) {
// PIT(Programmable Interval Timer)를 사용한 정밀 딜레이
// 1.193182 MHz 클록 = 약 0.838 μs per tick
let ticks = (us * 1193182) / 1_000_000;
// PIT 채널 2를 원샷 모드로 설정
outb(0x43, 0xB0);
outb(0x42, (ticks & 0xFF) as u8);
outb(0x42, ((ticks >> 8) & 0xFF) as u8);
// 카운터가 0이 될 때까지 대기
while (inb(0x61) & 0x20) == 0 {}
}
// 현대적 접근: RDTSC 기반 딜레이 (Pentium 이상)
pub unsafe fn delay_tsc(cycles: u64) {
let start = core::arch::x86_64::_rdtsc();
while core::arch::x86_64::_rdtsc() - start < cycles {}
}
설명
이것이 하는 일: CPU가 I/O 버스를 통해 데이터를 전송하고 응답을 기다리는 동안 시간을 소비하여, 느린 하드웨어가 명령을 처리할 시간을 확보합니다. 첫 번째로, 포트 0x80이 선택된 이유를 이해해봅시다.
이 포트는 원래 POST(Power-On Self Test) 코드를 출력하기 위한 것으로, BIOS가 부팅 단계를 표시하는 데 사용했습니다. 대부분의 마더보드에서 이 포트에 쓰기를 해도 아무런 부작용이 없으며, I/O 버스 사이클은 완료됩니다.
왜 이것이 딜레이로 작동하는지 보면, outb 명령 자체는 빠르지만 I/O 버스를 통한 실제 전송(ISA 버스는 8 MHz)과 대상 장치의 응답을 기다리는 시간이 CPU를 정지시키기 때문입니다. 이것은 약 1μs 정도의 지연을 만들며, PIC 같은 느린 장치에는 충분합니다.
그 다음으로, PIC 초기화에서 왜 매 명령마다 io_wait()가 필요한지 봅시다. 8259 PIC의 데이터시트를 보면, 각 ICW(Initialization Command Word) 사이에 최소 몇백 나노초의 간격이 필요하다고 명시되어 있습니다.
현대 CPU(예: 3 GHz)는 1 나노초에 3개 명령을 실행할 수 있으므로, io_wait() 없이는 ICW1과 ICW2가 거의 동시에 도착합니다. 내부적으로 PIC는 상태 머신으로 동작하는데, ICW1을 받으면 "다음은 ICW2" 상태로 전환합니다.
하지만 전환이 완료되기 전에 데이터가 오면 잘못된 레지스터에 쓰여집니다. 대체 방법들도 살펴봅시다.
PIT(Programmable Interval Timer)를 사용하면 더 정확한 마이크로초 단위 딜레이를 만들 수 있지만, PIT 자체를 초기화해야 하므로 초기 부팅 단계에서는 사용하기 어렵습니다. RDTSC(Read Time-Stamp Counter)는 Pentium 이상에서 사용 가능하며 CPU 사이클 기반 딜레이를 제공하지만, CPU 주파수를 알아야 실제 시간으로 변환할 수 있고, 멀티코어 환경에서는 코어 간 TSC 동기화 문제가 있습니다.
따라서 포트 0x80 방법이 가장 간단하고 이식성이 높습니다. 여러분이 이 코드를 사용하면 다양한 하드웨어와 가상 머신에서 일관된 PIC 동작을 보장할 수 있습니다.
실무에서의 이점은 첫째, 오래된 하드웨어 호환성(486, Pentium 시스템), 둘째, 가상 머신 안정성(일부 VM은 I/O 타이밍이 실제 하드웨어와 다름), 셋째, 간헐적 버그 제거(타이밍에 민감한 경쟁 조건)입니다.
실전 팁
💡 최신 시스템에서는 io_wait()가 불필요할 수 있지만, 이식성을 위해 추가하는 것이 좋습니다. 제거하여 얻는 성능 향상(수 마이크로초)은 무시할 수 있지만, 버그 발생 시 디버깅 비용은 매우 큽니다.
💡 일부 최신 시스템은 포트 0x80을 더 이상 지원하지 않을 수 있습니다. 대신 0xED(APM 포트)를 사용하는 것도 고려하세요. 또는 inb(0x80)을 두 번 호출하는 방법도 있습니다.
💡 QEMU 같은 에뮬레이터에서는 -no-reboot 옵션과 함께 io_wait() 호출 횟수를 로깅하여 초기화 흐름이 올바른지 확인할 수 있습니다.
💡 성능이 중요한 EOI 전송에는 io_wait()를 추가하지 마세요. EOI는 자주 호출되므로 매번 1μs 지연이 누적되면 타이머 정확도에 영향을 줄 수 있습니다. 초기화 단계에만 사용하세요.
💡 UEFI 부팅 시스템에서는 레거시 I/O 포트가 비활성화될 수 있습니다. UEFI 서비스를 사용하거나, APIC를 직접 사용하여 PIC를 우회하는 것을 고려하세요.
10. PIC 재초기화 및 리셋 - 안정적인 상태 복구
시작하며
여러분이 OS 개발 중 시스템이 이상한 상태에 빠져 인터럽트가 전혀 작동하지 않거나, 부팅 시 BIOS가 설정한 PIC 상태가 예상과 다른 경험을 한 적 있나요? 특히 소프트 리부트나 exception 발생 후 복구할 때 이런 문제가 자주 발생합니다.
이런 문제는 PIC가 이전 상태(ISR 비트 설정, 우선순위 회전 등)를 유지하고 있기 때문입니다. 하드 리셋과 달리 소프트 리부트는 PIC를 초기화하지 않으므로, 이전 OS나 부트로더가 남긴 설정이 남아있을 수 있습니다.
예를 들어, ISR 비트가 여전히 1로 설정되어 있으면 해당 우선순위 이하의 인터럽트가 모두 차단됩니다. 바로 이럴 때 필요한 것이 PIC 재초기화 및 리셋입니다.
알려진 깨끗한 상태로 PIC를 강제로 되돌려 안정적인 출발점을 만듭니다.
개요
간단히 말해서, PIC 재초기화는 모든 레지스터를 클리어하고 기본 설정으로 되돌리는 과정입니다. 왜 이것이 필요한지 실무 관점에서 보면, PIC는 전원이 켜진 상태에서는 상태를 계속 유지합니다.
예를 들어, 커널 패닉 후 재부팅 루틴에서 PIC 상태가 꼬여있으면 타이머조차 작동하지 않아 재부팅도 실패할 수 있습니다. 또한 체인 로딩(bootloader → OS)할 때 부트로더가 PIC를 잘못 설정했다면, OS 초기화에서 이를 정리해야 합니다.
기존에는 단순히 초기화 시퀀스만 보냈다면, 이제는 모든 ISR을 명시적으로 클리어하고 우선순위를 리셋하는 완전한 재초기화를 수행해야 합니다. 이 개념의 핵심 특징은 첫째, 모든 ISR 비트를 Specific EOI로 클리어, 둘째, 우선순위를 기본값으로 리셋, 셋째, 초기화 시퀀스 재실행으로 알려진 상태 보장입니다.
이러한 특징들이 안정적인 시스템 시작과 복구를 가능하게 합니다.
코드 예제
// 완전한 PIC 재초기화 함수
pub unsafe fn reset_and_reinitialize_pic() {
// 1단계: 모든 인터럽트 마스킹 (새 인터럽트 차단)
outb(PIC1_DATA, 0xFF);
outb(PIC2_DATA, 0xFF);
io_wait();
// 2단계: 모든 ISR 비트 클리어 (Specific EOI로 모든 IRQ에 대해)
for irq in 0..8 {
outb(PIC1_CMD, 0x60 | irq); // Specific EOI for IRQ 0-7
}
for irq in 0..8 {
outb(PIC2_CMD, 0x60 | irq); // Specific EOI for IRQ 8-15
}
io_wait();
// 3단계: 우선순위 리셋 (기본 완전 중첩 모드)
// OCW2: 0x00 = 자동 회전 모드 해제, 기본 우선순위 복원
outb(PIC1_CMD, 0x00);
outb(PIC2_CMD, 0x00);
io_wait();
// 4단계: 완전한 초기화 시퀀스 재실행
// ICW1: 초기화 시작 + ICW4 필요
outb(PIC1_CMD, 0x11);
io_wait();
outb(PIC2_CMD, 0x11);
io_wait();
// ICW2: 인터럽트 벡터 오프셋
outb(PIC1_DATA, 32);
io_wait();
outb(PIC2_DATA, 40);
io_wait();
// ICW3: 마스터-슬레이브 연결
outb(PIC1_DATA, 0x04); // 슬레이브가 IRQ 2에 연결
io_wait();
outb(PIC2_DATA, 0x02); // 슬레이브 ID
io_wait();
// ICW4: 8086 모드, 일반 EOI, 비버퍼 모드
outb(PIC1_DATA, 0x01);
io_wait();
outb(PIC2_DATA, 0x01);
io_wait();
// 5단계: 마스크를 원하는 상태로 설정 (모두 활성화)
outb(PIC1_DATA, 0x00);
io_wait();
outb(PIC2_DATA, 0x00);
io_wait();
}
// 소프트 리부트 시 안전한 PIC 종료
pub unsafe fn shutdown_pic_safely() {
// 모든 인터럽트 마스킹
outb(PIC1_DATA, 0xFF);
outb(PIC2_DATA, 0xFF);
// 모든 ISR 클리어
for irq in 0..16 {
send_specific_eoi(irq);
}
// APIC로 전환 중이라면 추가로 IMCR 설정
// (이미 disable_pic()에서 수행했을 수 있음)
}
// 커널 패닉 핸들러에서 호출할 안전한 재초기화
pub unsafe fn panic_recover_pic() {
// 최소한의 복구: ISR 클리어 + 타이머만 활성화
for irq in 0..16 {
send_specific_eoi(irq);
}
// 타이머(IRQ 0)만 언마스킹하여 재부팅 타이머 작동 보장
outb(PIC1_DATA, 0xFE); // 11111110 = IRQ 0만 활성
outb(PIC2_DATA, 0xFF); // 슬레이브 모두 비활성
}
설명
이것이 하는 일: PIC의 모든 내부 상태(ISR, IRR, 우선순위, 마스크)를 의도적으로 클리어하고 재설정하여, 이전 설정이나 오류 상태를 제거합니다. 첫 번째로, 모든 인터럽트를 마스킹하는 이유를 봅시다.
재초기화 중에 새로운 인터럽트가 발생하면 PIC가 일관되지 않은 상태가 될 수 있습니다. 예를 들어, ICW2를 쓰는 중간에 타이머 인터럽트가 들어오면 PIC는 "이것이 ICW3인가, 아니면 실제 인터럽트인가?" 혼란에 빠질 수 있습니다.
왜 이것이 중요한지 보면, 8259 PIC는 초기화 모드와 동작 모드 사이에 명확한 전환이 있어서, 모드 전환 중 외부 신호는 예측 불가능한 결과를 낳기 때문입니다. 그 다음으로, 모든 ISR 비트를 클리어하는 과정을 이해해봅시다.
각 IRQ(0-15)에 대해 Specific EOI(0x60 + IRQ 번호)를 보내면, 해당 ISR 비트가 명시적으로 0으로 설정됩니다. 왜 Non-specific EOI(0x20)를 사용하지 않는지 보면, Non-specific은 가장 높은 우선순위 ISR 비트만 클리어하므로, 여러 비트가 설정된 경우 한 번에 하나씩만 처리되기 때문입니다.
내부적으로 PIC는 ISR을 우선순위 인코더로 스캔하여 가장 높은 비트를 찾지만, Specific EOI는 직접 비트를 지정하므로 더 확실합니다. 세 번째 단계는 우선순위 리셋입니다.
이전 코드에서 우선순위 회전이나 설정을 사용했다면, 0x00 명령으로 기본 완전 중첩 모드로 되돌립니다. 이렇게 하면 IRQ 0이 최고 우선순위, IRQ 7(또는 15)이 최저 우선순위인 표준 상태가 됩니다.
네 번째 단계는 완전한 초기화 시퀀스 재실행입니다. ICW1을 보내면 PIC는 "초기화 모드"로 진입하고 모든 내부 상태 머신이 리셋됩니다.
이것은 단순히 레지스터를 쓰는 것보다 더 근본적인 리셋으로, PIC 내부의 숨겨진 상태(래치, 플립플롭 등)도 초기화합니다. 그 후 ICW2-4를 순서대로 보내 원하는 설정을 적용합니다.
여러분이 이 코드를 사용하면 부팅, 재부팅, 오류 복구 등 모든 상황에서 일관된 PIC 동작을 보장할 수 있습니다. 실무에서의 이점은 첫째, 소프트 리부트 안정성(이전 상태 제거), 둘째, 멀티부팅 환경 호환성(다른 OS가 남긴 설정 클리어), 셋째, 커널 패닉 후 복구 가능성(최소한 타이머는 작동하도록)입니다.
실전 팁
💡 OS 진입점(kernel_main)의 첫 부분에서 항상 reset_and_reinitialize_pic()을 호출하세요. 부트로더가 어떤 상태로 남겼는지 신뢰할 수 없습니다.
💡 재초기화 전후로 dump_pic_state()를 호출하여 로그를 비교하세요. "재초기화 전: ISR=0x0F(여러 비트 설정), 재초기화 후: ISR=0x00(클린)"같은 로그는 디버깅에 매우 유용합니다.
💡 QEMU에서 -monitor stdio 옵션을 사용하면 info pic 명령으로 PIC 상태를 확인할 수 있습니다. 여러분의 재초기화 코드가 제대로 작동하는지 검증할 수 있습니다.
💡 Triple Fault 복구 시에는 재초기화조차 실패할 수 있습니다. 이런 경우 하드웨어 리셋(키보드 컨트롤러 0x64 포트 사용)이 필요합니다.
💡 APIC로 전환한 후에는 PIC를 재초기화하지 마세요. 대신 완전히 비활성화 상태로 유지하는 것이 안전합니다. PIC와 APIC의 혼재는 예측 불가능한 인터럽트 동작을 유발합니다.