이미지 로딩 중...

Rust로 만드는 나만의 OS - 타이머 인터럽트 완벽 가이드 - 슬라이드 1/9
A

AI Generated

2025. 11. 13. · 5 Views

Rust로 만드는 나만의 OS - 타이머 인터럽트 완벽 가이드

OS 개발의 핵심 메커니즘인 타이머 인터럽트를 Rust로 구현하는 방법을 알아봅니다. PIC/APIC 설정부터 멀티태스킹 구현까지, 실전 코드와 함께 깊이 있게 다룹니다.


목차

  1. 타이머 인터럽트의 기본 개념과 필요성
  2. 인터럽트 디스크립터 테이블(IDT) 설정
  3. 인터럽트 핸들러에서 컨텍스트 저장과 복원
  4. APIC와 로컬 타이머 사용하기
  5. 타이머 기반 스케줄러 구현하기
  6. 시간 측정과 시스템 시계 구현
  7. 인터럽트 우선순위와 네스팅 처리
  8. Bottom Half와 Deferred Work

1. 타이머 인터럽트의 기본 개념과 필요성

시작하며

여러분이 OS를 직접 만들면서 "어떻게 여러 프로그램을 동시에 실행시키지?"라는 의문을 가져본 적 있나요? 단순히 while 루프로 프로그램을 돌리면 한 프로그램이 CPU를 독점해버리고, 다른 프로그램은 영원히 실행되지 못하는 상황이 발생합니다.

이런 문제는 실제 OS 개발에서 가장 먼저 마주치는 근본적인 도전과제입니다. CPU를 강제로 빼앗아올 메커니즘이 없다면, 협조적 멀티태스킹(cooperative multitasking)밖에 구현할 수 없고, 이는 하나의 악의적인 프로그램이 전체 시스템을 먹통으로 만들 수 있다는 의미입니다.

바로 이럴 때 필요한 것이 타이머 인터럽트입니다. 하드웨어 타이머가 주기적으로 CPU에게 신호를 보내면, 현재 실행 중인 코드를 중단하고 OS 커널로 제어권이 넘어오게 됩니다.

이를 통해 선점형 멀티태스킹(preemptive multitasking)을 구현할 수 있죠.

개요

간단히 말해서, 타이머 인터럽트는 하드웨어 타이머가 일정 주기마다 CPU에게 보내는 신호입니다. 이 신호를 받으면 CPU는 현재 작업을 멈추고 미리 정의된 인터럽트 핸들러를 실행합니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, 모든 현대 OS의 스케줄러는 타이머 인터럽트 없이는 작동할 수 없습니다. Linux의 경우 보통 1ms(1000Hz)마다, Windows는 약 15.6ms(64Hz)마다 타이머 인터럽트가 발생하여 스케줄러가 다음에 실행할 프로세스를 결정합니다.

예를 들어, 여러분의 웹 브라우저, 음악 플레이어, IDE가 동시에 부드럽게 실행되는 것도 모두 타이머 인터럽트 덕분입니다. 전통적인 방법과의 비교를 하자면, 기존 협조적 멀티태스킹에서는 각 프로그램이 자발적으로 yield()를 호출해야 했습니다.

하지만 타이머 인터럽트를 사용하면, 프로그램이 협조하지 않더라도 OS가 강제로 CPU를 회수할 수 있습니다. 타이머 인터럽트의 핵심 특징은 첫째, 주기성(일정한 간격으로 발생), 둘째, 비동기성(언제든 현재 코드를 중단), 셋째, 특권 수준 전환(유저 모드에서 커널 모드로 전환)입니다.

이러한 특징들이 OS가 시스템 전체를 통제할 수 있는 근간이 되며, 없다면 진정한 의미의 다중 작업 OS를 만들 수 없습니다.

코드 예제

// PIC(Programmable Interrupt Controller) 초기화 및 타이머 설정
use x86_64::instructions::port::Port;

pub fn init_timer(frequency_hz: u32) {
    // PIC 초기화 - 마스터/슬레이브 PIC 설정
    unsafe {
        // IRQ 0(타이머)를 인터럽트 벡터 32번으로 매핑
        let mut cmd_master = Port::<u8>::new(0x20);
        let mut data_master = Port::<u8>::new(0x21);

        cmd_master.write(0x11);  // 초기화 시작
        data_master.write(0x20); // 벡터 오프셋 32
        data_master.write(0x04); // 슬레이브가 IRQ2에 연결됨
        data_master.write(0x01); // 8086 모드

        // PIT(Programmable Interval Timer) 설정
        // 기본 주파수 1193182Hz를 원하는 주파수로 분할
        let divisor = 1193182 / frequency_hz;
        let mut pit_cmd = Port::<u8>::new(0x43);
        let mut pit_data = Port::<u8>::new(0x40);

        pit_cmd.write(0x36);  // 채널 0, 모드 3(사각파)
        pit_data.write((divisor & 0xFF) as u8);
        pit_data.write((divisor >> 8) as u8);
    }
}

설명

이것이 하는 일: 위 코드는 x86 아키텍처의 PIC(Programmable Interrupt Controller)와 PIT(Programmable Interval Timer)를 초기화하여, 원하는 주파수로 타이머 인터럽트가 발생하도록 설정합니다. 첫 번째로, PIC 초기화 부분은 하드웨어 인터럽트를 소프트웨어 인터럽트 벡터로 매핑하는 작업을 담당합니다.

x86에서는 기본적으로 IRQ 015번이 있는데, 이를 CPU의 인터럽트 벡터 테이블의 3247번으로 재매핑합니다. 왜 32번부터 시작하냐면, 0~31번은 CPU 예외(division by zero, page fault 등)를 위해 예약되어 있기 때문입니다.

0x20 포트에 명령을 쓰고 0x21 포트에 데이터를 쓰는 방식으로 PIC와 통신합니다. 그 다음으로, PIT 설정 부분이 실행되면서 실제 타이머의 주파수를 결정합니다.

PIT의 기본 주파수는 1,193,182Hz인데, 이를 원하는 주파수로 나눈 값(divisor)을 설정하면 됩니다. 예를 들어 100Hz(10ms마다 인터럽트)를 원한다면 divisor는 약 11,932가 됩니다.

이 값을 하위 바이트와 상위 바이트로 나누어 0x40 포트에 순차적으로 기록합니다. 마지막으로, 0x36 명령어가 PIT에게 "채널 0을 사용하고, 모드 3(사각파 생성기)로 동작하라"고 지시하여 최종적으로 규칙적인 인터럽트 신호를 생성하게 됩니다.

모드 3은 출력 신호가 HIGH와 LOW를 반복하는 사각파 형태로, 타이머 인터럽트에 가장 적합한 모드입니다. 여러분이 이 코드를 사용하면 OS의 심장 박동과도 같은 규칙적인 틱(tick)을 만들 수 있습니다.

이를 통해 프로세스 스케줄링, 시간 측정, 타임아웃 처리 등 시간 기반의 모든 OS 기능을 구현할 수 있게 됩니다. 또한 주파수를 조절하여 시스템의 응답성과 오버헤드 사이의 균형을 맞출 수 있죠.

실전 팁

💡 타이머 주파수는 너무 높으면 인터럽트 처리 오버헤드로 CPU를 낭비하고, 너무 낮으면 응답성이 떨어집니다. 데스크톱 OS는 1001000Hz, 임베디드 시스템은 10100Hz가 적절합니다.

💡 APIC(Advanced PIC)를 사용할 수 있다면 반드시 PIC 대신 APIC를 사용하세요. 멀티코어 시스템에서 PIC는 제대로 작동하지 않으며, APIC는 코어별 로컬 타이머를 제공합니다.

💡 타이머 인터럽트 핸들러는 가능한 한 빨리 실행되어야 합니다. 무거운 작업은 핸들러에서 직접 하지 말고, 플래그만 설정한 후 나중에 처리하세요(bottom half 패턴).

💡 PIT는 레거시 하드웨어이므로, 실제 프로덕션 OS에서는 HPET(High Precision Event Timer)나 TSC(Time Stamp Counter)를 사용하는 것이 더 정확하고 효율적입니다.

💡 인터럽트 핸들러 내부에서는 절대 sleep이나 blocking 연산을 하지 마세요. 인터럽트 컨텍스트는 프로세스 컨텍스트가 아니므로 스케줄링이 불가능합니다.


2. 인터럽트 디스크립터 테이블(IDT) 설정

시작하며

여러분이 타이머 인터럽트를 설정했는데, 실제로 인터럽트가 발생했을 때 "어떤 코드를 실행해야 하는지" CPU가 모른다면 어떻게 될까요? 시스템은 즉시 트리플 폴트(triple fault)로 리부트되거나, 최악의 경우 완전히 정지해버립니다.

이런 문제는 모든 OS 개발자가 반드시 해결해야 하는 기본적인 과제입니다. CPU는 인터럽트가 발생하면 IDT(Interrupt Descriptor Table)를 참조하여 어떤 핸들러를 호출할지 결정하는데, 이 테이블이 제대로 설정되지 않으면 CPU는 혼란에 빠집니다.

바로 이럴 때 필요한 것이 IDT 초기화입니다. IDT는 각 인터럽트 번호(벡터)에 대응하는 핸들러 함수의 주소를 담은 테이블로, CPU가 인터럽트 발생 시 참조하는 일종의 점프 테이블입니다.

개요

간단히 말해서, IDT는 256개의 엔트리를 가진 테이블로, 각 엔트리가 하나의 인터럽트 벡터에 대한 핸들러 정보를 담고 있습니다. 031번은 CPU 예외용, 3247번은 하드웨어 인터럽트용, 나머지는 소프트웨어 인터럽트나 시스템 콜용으로 사용됩니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, IDT 없이는 어떤 종류의 인터럽트도 처리할 수 없습니다. 페이지 폴트, divide by zero 같은 예외부터, 키보드 입력, 네트워크 패킷 수신 같은 하드웨어 이벤트까지 모든 것이 IDT를 통해 처리됩니다.

Linux 커널의 arch/x86/kernel/idt.c를 보면 부팅 초기에 IDT를 설정하는 코드가 가장 먼저 실행되는 것을 확인할 수 있습니다. 전통적인 방법과의 비교를 하자면, 16비트 리얼 모드에서는 IVT(Interrupt Vector Table)라는 더 단순한 구조를 사용했습니다.

하지만 보호 모드와 롱 모드에서는 IDT를 사용해야 하며, 이는 권한 레벨 검사, 세그먼트 전환, 스택 전환 등 훨씬 정교한 보안 메커니즘을 제공합니다. IDT의 핵심 특징은 첫째, 각 엔트리가 16바이트(64비트 모드)의 복잡한 구조체라는 점, 둘째, 권한 레벨(DPL)을 지정하여 유저 모드에서 호출 가능한 인터럽트를 제한할 수 있다는 점, 셋째, IST(Interrupt Stack Table)를 통해 각 인터럽트마다 다른 스택을 사용할 수 있다는 점입니다.

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

코드 예제

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

lazy_static! {
    // IDT를 static으로 선언하여 프로그램 전체에서 유지
    static ref IDT: InterruptDescriptorTable = {
        let mut idt = InterruptDescriptorTable::new();

        // 타이머 인터럽트 핸들러 등록 (IRQ 0 -> 벡터 32)
        idt[32].set_handler_fn(timer_interrupt_handler);

        // 페이지 폴트 핸들러 등록 (예외 14)
        idt.page_fault.set_handler_fn(page_fault_handler);

        // 더블 폴트는 별도 스택 사용 (IST 인덱스 0)
        unsafe {
            idt.double_fault
                .set_handler_fn(double_fault_handler)
                .set_stack_index(0);
        }

        idt
    };
}

pub fn init_idt() {
    IDT.load();  // IDTR 레지스터에 IDT 주소 로드
}

// 타이머 인터럽트 핸들러
extern "x86-interrupt" fn timer_interrupt_handler(
    _stack_frame: InterruptStackFrame
) {
    // 타이머 틱 처리
    unsafe { TICKS += 1; }

    // EOI(End Of Interrupt) 신호 전송
    unsafe {
        Port::<u8>::new(0x20).write(0x20);
    }
}

설명

이것이 하는 일: 위 코드는 Rust의 x86_64 크레이트를 사용하여 IDT를 생성하고, 타이머 인터럽트를 포함한 여러 인터럽트 핸들러를 등록한 후, CPU에게 이 테이블을 사용하도록 지시합니다. 첫 번째로, lazy_static 매크로를 사용하여 IDT를 전역 정적 변수로 선언하는 부분이 핵심입니다.

IDT는 프로그램이 실행되는 내내 메모리에 유지되어야 하고, CPU의 IDTR 레지스터가 이 주소를 가리켜야 하므로 static 변수로 만들어야 합니다. lazy_static은 첫 접근 시 한 번만 초기화되도록 보장하며, 복잡한 초기화 로직을 클로저 내부에 작성할 수 있게 해줍니다.

그 다음으로, 각 인터럽트 벡터에 핸들러를 등록하는 과정이 진행됩니다. idt[32]는 타이머 인터럽트(IRQ 0)를 처리하고, idt.page_fault는 페이지 폴트 예외를 처리합니다.

특히 double_fault는 set_stack_index(0)를 통해 IST의 0번 엔트리에 등록된 별도 스택을 사용하도록 설정했습니다. 이는 더블 폴트가 스택 오버플로우로 인해 발생할 수 있기 때문에, 손상된 스택을 사용하지 않기 위한 안전장치입니다.

세 번째 단계로, IDT.load()가 실행되면서 CPU의 IDTR(IDT Register) 레지스터가 우리가 만든 IDT의 주소와 크기를 가리키게 됩니다. 이후 인터럽트가 발생하면 CPU는 자동으로 이 테이블을 참조합니다.

마지막으로, timer_interrupt_handler 함수가 실제 인터럽트가 발생했을 때 실행되는 코드입니다. 여기서는 전역 틱 카운터를 증가시키고, PIC에게 EOI(End Of Interrupt) 신호를 보내 "인터럽트 처리 완료"를 알립니다.

EOI를 보내지 않으면 PIC가 더 이상 인터럽트를 보내지 않으므로 반드시 필요한 과정입니다. 여러분이 이 코드를 사용하면 타이머 인터럽트가 발생할 때마다 자동으로 여러분의 핸들러가 호출됩니다.

이를 통해 시간 추적, 스케줄링, 타임아웃 등을 구현할 수 있으며, 다른 인터럽트(키보드, 디스크 등)도 같은 방식으로 처리할 수 있습니다. extern "x86-interrupt" 호출 규약은 Rust가 인터럽트 핸들러에 필요한 특수한 프롤로그/에필로그 코드를 자동으로 생성하게 해줍니다.

실전 팁

💡 더블 폴트 핸들러는 반드시 별도 스택(IST)을 사용해야 합니다. 스택 오버플로우가 더블 폴트를 일으킬 수 있으므로, 같은 스택을 사용하면 트리플 폴트로 이어집니다.

💡 인터럽트 핸들러에서는 절대 panic!을 호출하지 마세요. 대신 로그를 남기고 시스템을 안전한 상태로 만든 후 halt하는 것이 좋습니다. panic in interrupt context는 디버깅하기 극도로 어렵습니다.

💡 x86_64 크레이트의 InterruptDescriptorTable은 타입 안전성을 제공하지만, 직접 구조체를 만들어 IDT를 설정할 수도 있습니다. 각 엔트리는 offset, selector, ist, type_attr, reserved 필드로 구성됩니다.

💡 모든 인터럽트 핸들러가 등록된 후에 sti(Set Interrupt Flag) 명령으로 인터럽트를 활성화하세요. IDT를 로드하기만 해서는 인터럽트가 발생하지 않습니다.

💡 QEMU에서 테스트할 때는 -d int 옵션을 사용하면 모든 인터럽트를 로그로 볼 수 있어 디버깅에 유용합니다. 예상하지 못한 인터럽트가 발생하는지 확인하세요.


3. 인터럽트 핸들러에서 컨텍스트 저장과 복원

시작하며

여러분이 타이머 인터럽트가 발생하여 프로세스 A를 중단하고 프로세스 B로 전환했는데, 나중에 프로세스 A로 돌아왔을 때 레지스터 값이 모두 엉망이 되어 있다면 어떻게 될까요? 프로그램은 완전히 잘못된 상태에서 실행을 재개하고, 데이터 손상이나 크래시가 발생합니다.

이런 문제는 멀티태스킹 OS에서 가장 치명적인 버그 중 하나입니다. CPU의 레지스터는 프로그램의 실행 상태를 담고 있는 휘발성 저장소인데, 인터럽트가 발생하면 이 값들이 덮어씌워집니다.

제대로 된 저장/복원 메커니즘 없이는 절대 안정적인 멀티태스킹을 구현할 수 없습니다. 바로 이럴 때 필요한 것이 컨텍스트 스위칭(context switching)입니다.

인터럽트가 발생하면 현재 프로세스의 모든 레지스터 값을 저장하고, 새 프로세스의 레지스터 값을 복원하여, 마치 각 프로세스가 계속 실행되고 있는 것처럼 보이게 만듭니다.

개요

간단히 말해서, 컨텍스트 스위칭은 한 프로세스의 실행 상태(레지스터, 스택 포인터, 프로그램 카운터 등)를 저장하고 다른 프로세스의 상태를 복원하는 과정입니다. CPU는 이를 자동으로 해주지 않으므로 OS가 직접 구현해야 합니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, 모든 멀티태스킹 OS의 핵심이 바로 컨텍스트 스위칭입니다. Linux에서 task_struct 구조체가, Windows에서 KTHREAD 구조체가 각 프로세스/스레드의 컨텍스트를 저장하고 있습니다.

예를 들어, 여러분이 VS Code로 코딩하다가 브라우저로 전환할 때, 수십 번의 컨텍스트 스위칭이 밀리초 단위로 발생하여 부드러운 멀티태스킹 경험을 만들어냅니다. 전통적인 방법과의 비교를 하자면, 협조적 멀티태스킹에서는 프로세스가 자발적으로 yield()를 호출할 때만 컨텍스트 스위칭이 발생했습니다.

하지만 선점형 멀티태스킹에서는 타이머 인터럽트가 강제로 컨텍스트 스위칭을 일으켜, 프로세스가 협조하지 않아도 공정한 CPU 시간 분배가 가능합니다. 컨텍스트 스위칭의 핵심 특징은 첫째, 저장해야 할 레지스터가 많다는 점(x86_64에서는 범용 레지스터 16개, 플래그, RIP, RSP 등), 둘째, 커널 스택과 유저 스택을 별도로 관리해야 한다는 점, 셋째, FPU/SSE/AVX 레지스터는 lazy 저장(실제 사용 시에만 저장)으로 오버헤드를 줄일 수 있다는 점입니다.

이러한 특징들이 컨텍스트 스위칭의 성능을 좌우하며, 최적화가 필수적입니다.

코드 예제

// 프로세스 컨텍스트 구조체
#[repr(C)]
pub struct ProcessContext {
    // 범용 레지스터
    r15: u64, r14: u64, r13: u64, r12: u64,
    r11: u64, r10: u64, r9: u64, r8: u64,
    rdi: u64, rsi: u64, rbp: u64, rdx: u64,
    rcx: u64, rbx: u64, rax: u64,

    // 인터럽트 프레임 (CPU가 자동 저장)
    rip: u64,      // 명령 포인터
    cs: u64,       // 코드 세그먼트
    rflags: u64,   // 플래그 레지스터
    rsp: u64,      // 스택 포인터
    ss: u64,       // 스택 세그먼트
}

// 컨텍스트 스위칭 핸들러 (어셈블리와 혼합)
extern "x86-interrupt" fn timer_with_context_switch(
    stack_frame: InterruptStackFrame
) {
    // 1. 현재 프로세스 컨텍스트 저장
    let current_ctx = unsafe {
        let mut ctx = ProcessContext::zeroed();
        // 범용 레지스터 저장 (어셈블리 필요)
        asm!(
            "mov [{}], rax", "mov [{}+8], rbx",  // 예시
            in(reg) &mut ctx as *mut _,
        );
        ctx
    };

    SCHEDULER.lock().save_context(current_ctx);

    // 2. 스케줄러가 다음 프로세스 선택
    let next_ctx = SCHEDULER.lock().schedule_next();

    // 3. 다음 프로세스 컨텍스트 복원
    unsafe {
        asm!(
            "mov rax, [{}]", "mov rbx, [{}+8]",  // 예시
            in(reg) &next_ctx as *const _,
        );
    }

    // 4. EOI 전송
    unsafe { Port::<u8>::new(0x20).write(0x20); }
}

설명

이것이 하는 일: 위 코드는 타이머 인터럽트가 발생했을 때, 현재 실행 중인 프로세스의 CPU 상태를 ProcessContext 구조체에 저장하고, 스케줄러가 선택한 다음 프로세스의 상태를 CPU에 복원하여 실행을 이어갑니다. 첫 번째로, ProcessContext 구조체는 CPU의 모든 중요한 레지스터를 담는 컨테이너입니다.

#[repr(C)]는 Rust에게 C 언어와 같은 메모리 레이아웃을 사용하라고 지시하는데, 이는 어셈블리 코드와의 상호작용에 필수적입니다. r15부터 rax까지는 범용 레지스터이고, rip부터 ss까지는 인터럽트 발생 시 CPU가 자동으로 스택에 푸시하는 값들입니다.

이 구조체 하나가 프로세스의 "스냅샷"이 됩니다. 그 다음으로, 인터럽트 핸들러 내부에서 인라인 어셈블리를 사용하여 레지스터 값을 메모리에 저장합니다.

Rust의 asm! 매크로는 안전하지 않은(unsafe) 작업이지만, 레지스터를 직접 조작하는 유일한 방법입니다.

mov [{}], rax 같은 명령어는 rax 레지스터의 값을 ctx 구조체의 특정 오프셋에 저장합니다. 실제 구현에서는 16개의 mov 명령어를 모두 나열해야 하며, 순서가 매우 중요합니다.

세 번째 단계에서, 스케줄러가 다음에 실행할 프로세스를 결정합니다. 이는 라운드 로빈, 우선순위 기반, CFS(Completely Fair Scheduler) 등 다양한 알고리즘으로 구현할 수 있습니다.

schedule_next()는 준비 큐(ready queue)에서 다음 프로세스를 선택하고, 해당 프로세스의 저장된 ProcessContext를 반환합니다. 마지막으로, 반환된 컨텍스트의 레지스터 값을 CPU에 복원하는 역방향 과정이 진행됩니다.

mov rax, [{}] 같은 명령어로 메모리에서 값을 읽어 레지스터에 로드합니다. 이 작업이 완료되고 핸들러가 return하면, CPU는 자동으로 스택에서 rip, rflags, rsp를 복원하여(iretq 명령어), 다음 프로세스가 마치 멈춘 적 없는 것처럼 실행을 재개합니다.

여러분이 이 코드를 사용하면 진짜 멀티태스킹 OS를 만들 수 있습니다. 여러 프로세스가 동시에 실행되는 것처럼 보이지만, 실제로는 밀리초 단위로 빠르게 전환되는 것입니다.

Linux에서 context switch 시간은 보통 1~5 마이크로초 수준이며, 이는 캐시 효과와 레지스터 저장/복원 최적화로 달성됩니다.

실전 팁

💡 FPU/SSE/AVX 레지스터(512바이트 이상)는 매번 저장하면 오버헤드가 크므로, CR0.TS 비트를 설정하여 lazy context switch를 구현하세요. 프로세스가 실제로 FPU를 사용할 때만 저장합니다.

💡 커널 스택과 유저 스택은 반드시 분리하세요. x86_64의 TSS(Task State Segment)를 사용하면 인터럽트 발생 시 자동으로 커널 스택으로 전환됩니다. 유저 스택을 신뢰하면 보안 취약점이 됩니다.

💡 컨텍스트 스위칭 코드는 절대 인터럽트를 다시 활성화하면 안 됩니다. 스위칭 중간에 또 다른 인터럽트가 발생하면 레지스터 값이 손상될 수 있습니다. cli로 인터럽트를 비활성화한 상태를 유지하세요.

💡 디버깅 시에는 각 프로세스의 컨텍스트를 로그로 남기세요. rip 값을 확인하면 어떤 코드에서 스위칭이 발생했는지 알 수 있고, 버그 추적에 매우 유용합니다.

💡 실제로는 naked function과 완전한 어셈블리 구현이 필요할 수 있습니다. Rust의 extern "x86-interrupt"는 일부 레지스터를 자동으로 저장하므로, 정밀한 제어가 필요하다면 전체를 .S 파일로 작성하는 것이 좋습니다.


4. APIC와 로컬 타이머 사용하기

시작하며

여러분이 멀티코어 CPU에서 PIC를 사용하여 타이머 인터럽트를 구현했는데, 한 코어에서만 인터럽트가 발생하고 다른 코어들은 놀고 있다면 어떻게 될까요? 멀티코어의 이점을 전혀 활용하지 못하고, 스케줄링이 불균형하게 되어 한 코어만 과부하되는 상황이 발생합니다.

이런 문제는 레거시 PIC의 근본적인 한계입니다. PIC는 1990년대 이전 단일 CPU 시스템을 위해 설계되었기 때문에, 멀티코어 환경에서는 제대로 작동하지 않습니다.

현대의 모든 멀티코어 시스템은 더 발전된 인터럽트 컨트롤러를 필요로 합니다. 바로 이럴 때 필요한 것이 APIC(Advanced Programmable Interrupt Controller)입니다.

APIC는 각 CPU 코어마다 로컬 APIC를 가지고 있어, 코어별로 독립적인 타이머 인터럽트를 생성할 수 있습니다. 이를 통해 진정한 멀티코어 스케줄링이 가능해집니다.

개요

간단히 말해서, APIC는 PIC의 후속 기술로, 멀티코어 CPU를 위해 설계된 인터럽트 컨트롤러입니다. 각 코어는 자신만의 로컬 APIC를 가지며, 로컬 타이머를 통해 독립적으로 인터럽트를 받을 수 있습니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, 현대 OS는 모두 APIC를 사용합니다. Intel과 AMD의 모든 현대 CPU(Pentium 4 이후)는 APIC를 내장하고 있으며, PIC는 호환성 모드로만 존재합니다.

Linux 커널은 부팅 시 APIC를 감지하면 자동으로 PIC를 비활성화하고 APIC를 사용합니다. 예를 들어, 32코어 서버에서 각 코어가 독립적으로 프로세스를 스케줄링하려면 반드시 코어별 타이머가 필요한데, APIC만이 이를 제공합니다.

전통적인 방법과의 비교를 하자면, PIC는 8개의 IRQ 라인만 제공하고 모든 인터럽트가 단일 CPU로 전달됩니다. 반면 APIC는 256개의 벡터를 지원하고, 각 인터럽트를 특정 코어나 코어 그룹으로 라우팅할 수 있으며, 인터럽트 우선순위도 더 세밀하게 제어할 수 있습니다.

APIC의 핵심 특징은 첫째, 각 코어가 독립적인 로컬 타이머를 가진다는 점, 둘째, 메모리 맵 I/O(보통 0xFEE00000 주소)를 통해 제어된다는 점, 셋째, IPI(Inter-Processor Interrupt)를 통해 코어 간 통신이 가능하다는 점입니다. 이러한 특징들이 멀티코어 OS의 기반이 되며, 코어 간 동기화, 원격 함수 호출(RPC), TLB shootdown 등에 필수적입니다.

코드 예제

use core::ptr::{read_volatile, write_volatile};

// APIC 레지스터 오프셋
const APIC_BASE: usize = 0xFEE00000;
const APIC_ID: usize = 0x20;
const APIC_TPR: usize = 0x80;        // Task Priority Register
const APIC_EOI: usize = 0xB0;        // End of Interrupt
const APIC_SPURIOUS: usize = 0xF0;   // Spurious Interrupt Vector
const APIC_TIMER_LVTT: usize = 0x320; // LVT Timer
const APIC_TIMER_INIT: usize = 0x380; // Initial Count
const APIC_TIMER_CURRENT: usize = 0x390; // Current Count
const APIC_TIMER_DIV: usize = 0x3E0; // Divide Configuration

pub struct LocalApic {
    base: usize,
}

impl LocalApic {
    pub unsafe fn new() -> Self {
        // IA32_APIC_BASE MSR(0x1B)에서 APIC 기본 주소 읽기
        let apic_base = x86_64::registers::model_specific::Msr::new(0x1B)
            .read() & 0xFFFF_F000;

        Self { base: apic_base as usize }
    }

    // APIC 활성화
    pub unsafe fn enable(&mut self) {
        // Spurious Interrupt Vector Register 설정
        // 비트 8: APIC 활성화, 비트 0-7: 벡터 번호(255)
        self.write(APIC_SPURIOUS, 0x1FF);
    }

    // 로컬 타이머 초기화 (주기적 모드, 1ms 간격)
    pub unsafe fn init_timer(&mut self) {
        // Divide Configuration: 16으로 분할
        self.write(APIC_TIMER_DIV, 0x3);

        // LVT Timer: 벡터 32, 주기 모드 (비트 17)
        self.write(APIC_TIMER_LVTT, 32 | (1 << 17));

        // Initial Count: 버스 주파수에 따라 조정 필요
        // 예: 1GHz 버스 / 16 = 62.5MHz, 1ms = 62500 틱
        self.write(APIC_TIMER_INIT, 62500);
    }

    // EOI 전송
    pub unsafe fn send_eoi(&mut self) {
        self.write(APIC_EOI, 0);
    }

    // APIC 레지스터 읽기
    unsafe fn read(&self, offset: usize) -> u32 {
        read_volatile((self.base + offset) as *const u32)
    }

    // APIC 레지스터 쓰기
    unsafe fn write(&mut self, offset: usize, value: u32) {
        write_volatile((self.base + offset) as *mut u32, value);
    }
}

설명

이것이 하는 일: 위 코드는 x86_64 아키텍처의 로컬 APIC를 메모리 맵 I/O를 통해 제어하여, 각 CPU 코어가 독립적인 주기적 타이머 인터럽트를 받을 수 있도록 설정합니다. 첫 번째로, LocalApic::new()에서 MSR(Model Specific Register) 0x1B를 읽어 APIC의 기본 주소를 얻습니다.

이 주소는 보통 0xFEE00000이지만, BIOS나 부트로더가 변경할 수 있으므로 MSR에서 읽어야 합니다. 이 주소 공간은 물리 메모리가 아닌 CPU 내부 레지스터에 매핑되어 있으며, 페이지 테이블에서 캐시 비활성화(uncacheable)로 설정해야 합니다.

그 다음으로, enable() 메서드는 APIC_SPURIOUS 레지스터에 0x1FF를 써서 APIC를 활성화합니다. 비트 8은 APIC 활성화 플래그이고, 하위 8비트는 스퓨리어스 인터럽트(잘못 발생한 인터럽트)의 벡터 번호입니다.

255번 벡터를 사용하면 실제 인터럽트와 겹치지 않아 안전합니다. APIC를 활성화하면 자동으로 PIC가 비활성화되므로, PIC와의 충돌을 걱정할 필요가 없습니다.

세 번째 단계에서, init_timer()는 로컬 타이머를 주기 모드로 설정합니다. APIC_TIMER_DIV에 0x3을 쓰면 타이머 입력 주파수를 16으로 나누고, APIC_TIMER_LVTT의 비트 17을 설정하면 주기 모드(periodic mode)가 활성화됩니다.

비트 0-7은 인터럽트 벡터 번호(32)입니다. 마지막으로 APIC_TIMER_INIT에 초기값을 쓰면 타이머가 이 값부터 카운트다운을 시작하며, 0에 도달하면 인터럽트를 발생시키고 다시 초기값으로 리셋됩니다.

마지막으로, send_eoi() 메서드는 인터럽트 처리 완료를 알립니다. APIC의 EOI 레지스터에 아무 값이나 쓰면(보통 0) APIC가 "이 인터럽트 처리 완료"를 인식하고 다음 인터럽트를 보낼 수 있게 됩니다.

PIC의 EOI와 달리 APIC는 어떤 인터럽트를 완료했는지 자동으로 추적하므로, 벡터 번호를 명시할 필요가 없습니다. 여러분이 이 코드를 사용하면 각 CPU 코어가 독립적으로 타이머 인터럽트를 받아 자신의 스케줄러를 실행할 수 있습니다.

4코어 CPU라면 4개의 로컬 APIC가 동시에 작동하여, 각 코어가 자신의 프로세스 큐를 스케줄링합니다. 이는 확장성이 뛰어나며, 128코어 서버에서도 동일한 방식으로 작동합니다.

실전 팁

💡 APIC 메모리 영역(0xFEE00000)을 페이지 테이블에 매핑할 때 반드시 PAT(Page Attribute Table)나 MTRR로 uncacheable 속성을 설정하세요. 캐시되면 레지스터 쓰기가 무시될 수 있습니다.

💡 로컬 타이머의 정확한 주파수를 알려면 부팅 시 캘리브레이션(calibration)이 필요합니다. PIT의 알려진 주파수를 기준으로 APIC 타이머가 몇 틱 카운트하는지 측정하세요.

💡 x2APIC 모드를 지원하는 CPU(Nehalem 이후)에서는 메모리 맵 대신 MSR을 통해 APIC를 제어하면 성능이 더 좋습니다. CPUID로 x2APIC 지원 여부를 확인하세요.

💡 IPI(Inter-Processor Interrupt)를 사용하면 다른 코어에게 신호를 보낼 수 있습니다. ICR(Interrupt Command Register)에 목적지 코어 ID와 벡터를 쓰면 됩니다. 이는 TLB shootdown이나 원격 함수 호출에 사용됩니다.

💡 APIC 타이머는 one-shot 모드와 TSC-deadline 모드도 지원합니다. TSC-deadline 모드는 특정 TSC 값에 도달하면 인터럽트를 발생시켜, 더 정밀한 타이밍 제어가 가능합니다.


5. 타이머 기반 스케줄러 구현하기

시작하며

여러분이 타이머 인터럽트를 설정하고 컨텍스트 스위칭도 구현했는데, "어떤 프로세스를 다음에 실행할지" 결정하는 로직이 없다면 어떻게 될까요? 프로세스가 무작위로 선택되거나, 한 프로세스만 계속 실행되는 불공평한 상황이 발생합니다.

이런 문제는 OS의 공정성과 응답성을 심각하게 해칩니다. 타이머 인터럽트와 컨텍스트 스위칭은 멀티태스킹의 "메커니즘"이지만, 어떻게 스케줄링할지는 "정책"의 문제입니다.

좋은 스케줄러 없이는 아무리 완벽한 인터럽트 시스템도 무용지물입니다. 바로 이럴 때 필요한 것이 타이머 기반 스케줄러입니다.

타이머 인터럽트가 발생할 때마다 스케줄러가 실행되어, 공정성과 우선순위를 고려하여 다음에 실행할 프로세스를 선택합니다.

개요

간단히 말해서, 타이머 기반 스케줄러는 타이머 인터럽트마다 실행되어 준비 상태의 프로세스 중에서 다음에 CPU를 할당할 프로세스를 선택하는 알고리즘입니다. 라운드 로빈, 우선순위 기반, 공정 스케줄링 등 다양한 정책이 있습니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, 스케줄러는 OS의 성능과 사용자 경험을 결정하는 핵심 요소입니다. Linux의 CFS(Completely Fair Scheduler)는 모든 프로세스에게 공정한 CPU 시간을 보장하고, Windows의 멀티레벨 피드백 큐는 대화형 프로그램에 높은 우선순위를 부여합니다.

예를 들어, 여러분이 무거운 컴파일 작업을 백그라운드에서 돌리면서도 마우스 커서가 부드럽게 움직이는 것은, 스케줄러가 포어그라운드 프로세스에 더 많은 CPU 시간을 할당하기 때문입니다. 전통적인 방법과의 비교를 하자면, 초기 Unix는 단순한 라운드 로빈 스케줄러를 사용했습니다.

하지만 현대 OS는 프로세스의 과거 행동(CPU-bound vs I/O-bound)을 분석하고, 동적으로 우선순위를 조정하며, NUMA 아키텍처에서 캐시 지역성을 고려하는 등 매우 정교한 스케줄링을 수행합니다. 타이머 기반 스케줄러의 핵심 특징은 첫째, 시간 할당량(time slice/quantum)을 각 프로세스에 부여한다는 점, 둘째, 준비 큐(ready queue)를 관리하여 실행 가능한 프로세스를 추적한다는 점, 셋째, 스케줄링 결정이 매우 빠르게 이루어져야 한다는 점(보통 마이크로초 이내)입니다.

이러한 특징들이 수백 개의 프로세스를 효율적으로 관리할 수 있게 합니다.

코드 예제

use alloc::collections::VecDeque;
use spin::Mutex;

// 간단한 프로세스 제어 블록
#[derive(Clone)]
pub struct Process {
    pid: usize,
    context: ProcessContext,
    time_slice: u32,        // 남은 시간 할당량 (틱 단위)
    priority: u8,           // 우선순위 (높을수록 중요)
    state: ProcessState,
}

#[derive(Clone, PartialEq)]
enum ProcessState {
    Ready,
    Running,
    Blocked,
}

// 라운드 로빈 스케줄러
pub struct RoundRobinScheduler {
    ready_queue: VecDeque<Process>,
    current: Option<Process>,
    time_quantum: u32,  // 기본 시간 할당량 (틱)
}

impl RoundRobinScheduler {
    pub fn new(time_quantum: u32) -> Self {
        Self {
            ready_queue: VecDeque::new(),
            current: None,
            time_quantum,
        }
    }

    // 타이머 인터럽트마다 호출
    pub fn schedule(&mut self) -> Option<&Process> {
        // 현재 프로세스의 시간 할당량 감소
        if let Some(ref mut proc) = self.current {
            proc.time_slice -= 1;

            // 시간 할당량 소진 시 큐 뒤로 이동
            if proc.time_slice == 0 {
                proc.time_slice = self.time_quantum;
                proc.state = ProcessState::Ready;
                self.ready_queue.push_back(proc.clone());
                self.current = None;
            } else {
                // 아직 시간 남음, 계속 실행
                return self.current.as_ref();
            }
        }

        // 다음 프로세스 선택
        if let Some(mut next) = self.ready_queue.pop_front() {
            next.state = ProcessState::Running;
            self.current = Some(next);
        }

        self.current.as_ref()
    }

    // 새 프로세스 추가
    pub fn add_process(&mut self, mut proc: Process) {
        proc.time_slice = self.time_quantum;
        proc.state = ProcessState::Ready;
        self.ready_queue.push_back(proc);
    }
}

static SCHEDULER: Mutex<RoundRobinScheduler> =
    Mutex::new(RoundRobinScheduler::new(10)); // 10틱 = 10ms

설명

이것이 하는 일: 위 코드는 라운드 로빈 스케줄링 알고리즘을 구현하여, 각 프로세스에게 동일한 시간 할당량(time quantum)을 부여하고, 시간이 소진되면 다음 프로세스로 전환하는 공정한 스케줄링을 제공합니다. 첫 번째로, Process 구조체는 각 프로세스의 메타데이터를 담습니다.

pid는 프로세스 식별자, context는 저장된 CPU 상태, time_slice는 아직 실행할 수 있는 틱 수, priority는 향후 우선순위 기반 스케줄링을 위한 필드입니다. state는 프로세스가 실행 중(Running), 준비(Ready), 또는 대기(Blocked, 예: I/O 대기) 상태인지 추적합니다.

이 정보들이 스케줄러의 결정을 좌우합니다. 그 다음으로, RoundRobinScheduler는 VecDeque를 준비 큐로 사용하여, FIFO(First-In-First-Out) 순서로 프로세스를 관리합니다.

ready_queue는 실행 대기 중인 모든 프로세스를 담고, current는 현재 실행 중인 프로세스를 가리킵니다. time_quantum은 각 프로세스가 한 번에 받을 기본 시간 할당량으로, 보통 10100ms(10100틱) 정도가 적절합니다.

세 번째 단계에서, schedule() 메서드는 타이머 인터럽트 핸들러에서 호출되어 스케줄링 결정을 내립니다. 먼저 현재 프로세스의 time_slice를 감소시키고, 0이 되면 해당 프로세스를 큐의 뒤로 보내고 time_slice를 리셋합니다.

이것이 "라운드 로빈"의 핵심으로, 모든 프로세스가 순환하며 동등한 기회를 얻습니다. 아직 시간이 남았다면 현재 프로세스를 계속 실행합니다.

마지막으로, ready_queue.pop_front()로 큐의 맨 앞에서 다음 프로세스를 꺼내 실행합니다. 큐가 비어있다면 (모든 프로세스가 블록됨) None을 반환하고, 이 경우 idle 프로세스나 halt 상태로 진입합니다.

add_process()는 fork()나 프로그램 로드 시 호출되어 새 프로세스를 스케줄러에 등록합니다. 여러분이 이 코드를 사용하면 여러 프로세스가 공정하게 CPU 시간을 나눠 가지는 진짜 멀티태스킹 OS를 만들 수 있습니다.

라운드 로빈은 단순하지만 공정성이 보장되며, 실시간 시스템이 아닌 이상 충분히 실용적입니다. 더 발전시키려면 우선순위 큐, 다단계 피드백 큐, 또는 CFS 같은 알고리즘을 추가할 수 있습니다.

실전 팁

💡 time_quantum은 너무 짧으면 컨텍스트 스위칭 오버헤드가 크고, 너무 길면 응답성이 떨어집니다. 인터랙티브 시스템은 10ms, 처리량 중심 시스템은 100ms가 적절합니다.

💡 I/O-bound 프로세스는 time_slice를 다 쓰기 전에 블록되는 경우가 많으므로, 동적으로 우선순위를 높여주면 응답성이 개선됩니다. Linux CFS의 vruntime 개념을 참고하세요.

💡 멀티코어 시스템에서는 각 코어가 자신의 준비 큐를 가지는 per-CPU run queue를 사용하면 락 경합이 줄어들어 확장성이 좋아집니다. 주기적으로 큐 간 부하 균형(load balancing)을 수행하세요.

💡 idle 프로세스(PID 0)는 항상 준비 상태로 유지하여, ready_queue가 비었을 때 실행되도록 하세요. idle 프로세스는 hlt 명령어로 CPU를 절전 모드로 만들어 전력을 절약합니다.

💡 스케줄러 자체는 절대 블록되거나 sleep해서는 안 됩니다. 모든 연산이 상수 시간 또는 로그 시간 내에 완료되어야 하며, 락은 spinlock만 사용하세요.


6. 시간 측정과 시스템 시계 구현

시작하며

여러분이 OS를 만들었는데 "지금 몇 시인지" 알 수 없고, 프로그램이 sleep(100ms)를 호출해도 얼마나 기다릴지 알 수 없다면 어떻게 될까요? 타임스탬프 없는 로그, 작동하지 않는 타임아웃, 동기화 불가능한 네트워크 통신 등 수많은 기능이 불가능해집니다.

이런 문제는 OS의 근본적인 역할 중 하나인 "시간 추상화"가 없기 때문입니다. 하드웨어는 단순히 틱을 세지만, 응용 프로그램은 "2025년 1월 15일 14:30:00"이나 "100ms 후" 같은 추상적인 시간 개념을 필요로 합니다.

바로 이럴 때 필요한 것이 시스템 시계(system clock)입니다. 타이머 인터럽트로부터 누적된 틱을 관리하고, 이를 실제 시간으로 변환하며, 응용 프로그램에게 시간 관련 API를 제공하는 서브시스템입니다.

개요

간단히 말해서, 시스템 시계는 부팅 후 경과 시간(uptime)과 실제 벽시계 시간(wall clock time)을 추적하는 OS 컴포넌트입니다. 타이머 인터럽트마다 틱 카운터를 증가시키고, 이를 밀리초나 마이크로초 단위로 변환합니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, 모든 시간 관련 시스템 콜(gettimeofday, clock_gettime, sleep, select 등)은 시스템 시계에 의존합니다. 파일 시스템의 타임스탬프, 네트워크 프로토콜의 타임아웃, 프로파일링 도구의 시간 측정 등 OS의 거의 모든 부분이 정확한 시간 정보를 필요로 합니다.

예를 들어, HTTPS 인증서 검증은 현재 시간이 정확해야 하고, TCP 재전송 타이머는 밀리초 정밀도를 요구합니다. 전통적인 방법과의 비교를 하자면, 초기 시스템은 단순히 틱 카운터만 제공했습니다.

하지만 현대 OS는 여러 시간 소스(PIT, RTC, HPET, TSC)를 관리하고, 단조 시계(monotonic clock, 절대 뒤로 가지 않음)와 벽시계(wall clock, NTP로 조정 가능)를 구분하며, 나노초 정밀도를 제공합니다. 시스템 시계의 핵심 특징은 첫째, jiffies(커널 틱 카운터)와 xtime(실제 시간)을 분리 관리한다는 점, 둘째, RTC(Real-Time Clock) 하드웨어에서 초기 시간을 읽어온다는 점, 셋째, NTP(Network Time Protocol)로 시간을 동기화한다는 점입니다.

이러한 특징들이 정확하고 신뢰할 수 있는 시간 서비스를 가능하게 합니다.

코드 예제

use core::sync::atomic::{AtomicU64, Ordering};
use spin::Mutex;

// 부팅 후 경과 틱 (타이머 인터럽트 횟수)
static JIFFIES: AtomicU64 = AtomicU64::new(0);

// 타이머 주파수 (Hz)
const TIMER_FREQ: u64 = 100;  // 100Hz = 10ms per tick

// 실제 시간 (Unix timestamp)
static WALL_CLOCK: Mutex<SystemTime> = Mutex::new(SystemTime {
    seconds: 0,
    nanoseconds: 0,
});

#[derive(Clone, Copy)]
pub struct SystemTime {
    seconds: u64,       // Unix epoch 기준 초
    nanoseconds: u32,   // 나노초 (0~999,999,999)
}

// 타이머 인터럽트 핸들러에서 호출
pub fn tick() {
    // Jiffies 증가
    let jiffies = JIFFIES.fetch_add(1, Ordering::SeqCst) + 1;

    // 매 틱마다 벽시계 시간 업데이트
    let mut wall = WALL_CLOCK.lock();
    let ns_per_tick = 1_000_000_000 / TIMER_FREQ;  // 10ms = 10,000,000ns

    wall.nanoseconds += ns_per_tick as u32;
    if wall.nanoseconds >= 1_000_000_000 {
        wall.nanoseconds -= 1_000_000_000;
        wall.seconds += 1;
    }
}

// 부팅 후 경과 시간 (밀리초)
pub fn uptime_ms() -> u64 {
    let jiffies = JIFFIES.load(Ordering::SeqCst);
    (jiffies * 1000) / TIMER_FREQ
}

// 현재 벽시계 시간
pub fn now() -> SystemTime {
    *WALL_CLOCK.lock()
}

// RTC에서 초기 시간 읽기 (부팅 시 한 번 호출)
pub fn init_from_rtc() {
    unsafe {
        // CMOS RTC 레지스터 읽기 (0x70=주소, 0x71=데이터)
        let mut cmd = Port::<u8>::new(0x70);
        let mut data = Port::<u8>::new(0x71);

        cmd.write(0x00); let sec = data.read();
        cmd.write(0x02); let min = data.read();
        cmd.write(0x04); let hour = data.read();
        // ... 날짜/월/연도도 읽기

        // BCD를 이진수로 변환하고 Unix timestamp 계산
        let seconds = calculate_unix_timestamp(year, month, day, hour, min, sec);

        WALL_CLOCK.lock().seconds = seconds;
    }
}

설명

이것이 하는 일: 위 코드는 타이머 인터럽트마다 호출되는 tick() 함수를 통해 jiffies(틱 카운터)와 wall clock(실제 시간)을 증가시켜, OS 전체에서 일관된 시간 정보를 제공하는 시스템 시계를 구현합니다. 첫 번째로, JIFFIES는 원자적(atomic) 카운터로 부팅 후 발생한 타이머 인터럽트 횟수를 추적합니다.

AtomicU64를 사용하여 락 없이 멀티코어에서 안전하게 증가시킬 수 있습니다. Ordering::SeqCst는 가장 강한 메모리 순서 보장을 제공하여, 모든 코어가 일관된 값을 보도록 합니다.

jiffies는 Linux 커널의 핵심 개념으로, 상대적 시간 측정의 기준이 됩니다. 그 다음으로, WALL_CLOCK은 실제 달력 시간을 초와 나노초로 나누어 저장합니다.

초와 나노초를 분리하는 이유는, 64비트 정수 하나로는 나노초 정밀도로 충분히 긴 시간을 표현할 수 없기 때문입니다(2^64 나노초 ≈ 584년). Unix timestamp는 1970년 1월 1일 00:00:00 UTC부터 경과한 초 수로, 모든 Unix 계열 시스템의 표준입니다.

세 번째 단계에서, tick() 함수가 매 타이머 인터럽트마다 실행되어 시간을 업데이트합니다. ns_per_tick은 타이머 주파수로부터 계산한 한 틱의 나노초 값(100Hz → 10,000,000ns)입니다.

nanoseconds에 이 값을 더하고, 10억을 넘으면 seconds를 증가시켜 올림 처리를 합니다. 이 방식은 정밀도를 유지하면서도 매우 빠릅니다.

마지막으로, uptime_ms()와 now()는 응용 프로그램이 호출할 수 있는 API를 제공합니다. uptime_ms()는 부팅 후 경과 시간을 밀리초로 반환하여, 타임아웃이나 성능 측정에 사용됩니다.

now()는 현재 벽시계 시간을 반환하여, 파일 타임스탬프나 로그에 사용됩니다. init_from_rtc()는 부팅 시 CMOS RTC 칩에서 하드웨어 시계를 읽어 초기 시간을 설정합니다.

여러분이 이 코드를 사용하면 OS가 시간을 추적하고 관리할 수 있게 됩니다. sleep() 함수는 원하는 시간이 uptime_ms()를 초과할 때까지 프로세스를 블록하면 되고, select()는 타임아웃까지의 남은 시간을 계산할 수 있습니다.

NTP 클라이언트를 구현하면 인터넷에서 정확한 시간을 받아와 WALL_CLOCK을 조정할 수도 있습니다.

실전 팁

💡 벽시계는 NTP나 사용자가 수동으로 조정할 수 있으므로 뒤로 가거나 점프할 수 있습니다. 타임아웃이나 측정에는 반드시 단조 시계(jiffies 기반)를 사용하세요.

💡 RTC는 초 단위 정밀도만 제공하므로, 부팅 시 RTC로 초기화한 후에는 타이머 인터럽트로 나노초까지 추적하는 것이 일반적입니다.

💡 고정밀 타이머가 필요하다면 TSC(Time Stamp Counter)를 사용하세요. rdtsc 명령어로 CPU 사이클을 읽을 수 있으며, 캘리브레이션하면 나노초 정밀도를 얻을 수 있습니다.

💡 시간 조정 시 갑작스러운 점프 대신 adjtime() 방식으로 서서히 조정하면 응용 프로그램이 혼란스럽지 않습니다. 매 틱마다 미세하게 더 빠르거나 느리게 증가시키세요.

💡 멀티코어에서는 각 코어의 TSC가 동기화되지 않을 수 있습니다. invariant TSC(CPUID로 확인)를 지원하는 CPU에서만 TSC를 신뢰하고, 그렇지 않으면 HPET를 사용하세요.


7. 인터럽트 우선순위와 네스팅 처리

시작하며

여러분이 타이머 인터럽트를 처리하는 중에 더 긴급한 인터럽트(예: 하드 디스크 오류)가 발생했는데, 이를 처리할 수 없다면 어떻게 될까요? 중요한 하드웨어 이벤트를 놓치거나, 타임아웃으로 인해 하드웨어가 오작동할 수 있습니다.

이런 문제는 모든 인터럽트가 동등하다고 가정할 때 발생합니다. 실제로는 일부 인터럽트(전원 손실, 하드웨어 오류)는 다른 것들(타이머, 키보드)보다 훨씬 더 긴급합니다.

우선순위 없는 인터럽트 시스템은 실시간 시스템이나 안정적인 하드웨어 제어에 부적합합니다. 바로 이럴 때 필요한 것이 인터럽트 우선순위와 네스팅(nesting)입니다.

높은 우선순위의 인터럽트는 낮은 우선순위 인터럽트 핸들러를 중단(preempt)할 수 있어, 긴급한 이벤트를 즉시 처리할 수 있습니다.

개요

간단히 말해서, 인터럽트 우선순위는 각 인터럽트에 중요도를 부여하고, 네스팅은 인터럽트 핸들러 실행 중에 더 높은 우선순위의 인터럽트를 허용하는 메커니즘입니다. 이를 통해 시스템이 긴급한 이벤트에 빠르게 반응할 수 있습니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, 실시간 OS나 임베디드 시스템에서는 필수적입니다. 자동차의 ECU(Engine Control Unit)에서 크랭크샤프트 센서 인터럽트는 밀리초 내에 처리되어야 하지만, CAN 버스 메시지는 몇 밀리초 늦어도 괜찮습니다.

Windows는 IRQL(Interrupt Request Level)이라는 31단계 우선순위 시스템을 사용하고, Linux는 threaded interrupt로 우선순위를 구현합니다. 전통적인 방법과의 비교를 하자면, 초기 시스템은 모든 인터럽트를 비활성화한 채로 핸들러를 실행했습니다(non-nested).

하지만 이는 인터럽트 지연 시간을 증가시켜, 고속 하드웨어(예: 기가비트 이더넷)에서 데이터 손실을 야기할 수 있습니다. 네스팅을 허용하면 평균 지연 시간이 크게 줄어듭니다.

인터럽트 우선순위의 핵심 특징은 첫째, APIC의 TPR(Task Priority Register)로 현재 우선순위를 설정할 수 있다는 점, 둘째, 핸들러 진입 시 우선순위를 높이고 퇴출 시 복원해야 한다는 점, 셋째, 우선순위 역전(priority inversion) 문제를 주의해야 한다는 점입니다. 이러한 특징들이 복잡한 인터럽트 환경을 안정적으로 관리하게 합니다.

코드 예제

// 인터럽트 우선순위 레벨
#[derive(PartialEq, PartialOrd, Clone, Copy)]
#[repr(u8)]
pub enum InterruptPriority {
    Low = 0,       // 타이머, 키보드 등
    Medium = 4,    // 네트워크, 디스크
    High = 8,      // 중요한 하드웨어
    Critical = 12, // NMI, 머신 체크
}

// 현재 인터럽트 우선순위 (per-CPU)
static mut CURRENT_IPL: u8 = 0;

// 우선순위 기반 인터럽트 핸들러
pub fn interrupt_handler_with_priority(
    vector: u8,
    priority: InterruptPriority,
) {
    unsafe {
        let old_ipl = CURRENT_IPL;

        // 이미 더 높은 우선순위 실행 중이면 연기
        if priority as u8 <= old_ipl {
            // 인터럽트 큐에 추가하여 나중에 처리
            queue_deferred_interrupt(vector);
            send_eoi();
            return;
        }

        // 우선순위 상승
        CURRENT_IPL = priority as u8;
        set_apic_tpr(priority as u8);  // APIC에게 알림

        // 더 높은 우선순위 인터럽트 허용
        x86_64::instructions::interrupts::enable();

        // 실제 인터럽트 처리
        match vector {
            32 => handle_timer(),
            33 => handle_keyboard(),
            // ...
            _ => {}
        }

        // 인터럽트 비활성화 후 우선순위 복원
        x86_64::instructions::interrupts::disable();
        CURRENT_IPL = old_ipl;
        set_apic_tpr(old_ipl);

        // 연기된 낮은 우선순위 인터럽트 처리
        process_deferred_interrupts();

        send_eoi();
    }
}

// APIC TPR 설정
unsafe fn set_apic_tpr(priority: u8) {
    let apic_base = 0xFEE00000usize;
    let tpr = (apic_base + 0x80) as *mut u32;
    write_volatile(tpr, (priority as u32) << 4);
}

// 연기된 인터럽트 큐
static DEFERRED_QUEUE: Mutex<Vec<u8>> = Mutex::new(Vec::new());

fn queue_deferred_interrupt(vector: u8) {
    DEFERRED_QUEUE.lock().push(vector);
}

fn process_deferred_interrupts() {
    let queue = DEFERRED_QUEUE.lock();
    for &vector in queue.iter() {
        // 우선순위 확인 후 처리
        dispatch_interrupt(vector);
    }
    queue.clear();
}

설명

이것이 하는 일: 위 코드는 각 인터럽트에 우선순위를 부여하고, 현재 실행 중인 인터럽트보다 높은 우선순위의 인터럽트만 네스팅을 허용하여, 중요한 하드웨어 이벤트가 즉시 처리되도록 보장합니다. 첫 번째로, InterruptPriority enum은 4단계의 우선순위를 정의합니다.

Low는 타이머나 키보드 같은 일반적인 인터럽트, Critical은 NMI(Non-Maskable Interrupt)나 머신 체크 같은 치명적 이벤트입니다. #[repr(u8)]은 이 enum을 숫자로 변환할 수 있게 하며, PartialOrd 트레잇으로 비교가 가능합니다.

실제 시스템에서는 16~32단계를 사용하기도 합니다. 그 다음으로, CURRENT_IPL(Interrupt Priority Level) 변수는 현재 실행 중인 코드의 우선순위를 추적합니다.

per-CPU 변수여야 하므로, 실제로는 CPU 로컬 스토리지에 저장해야 합니다. 핸들러 진입 시 새 우선순위와 비교하여, 현재 우선순위가 더 높거나 같으면 새 인터럽트를 큐에 넣고 나중에 처리합니다.

이는 우선순위 역전을 방지합니다. 세 번째 단계에서, 우선순위가 승인되면 CURRENT_IPL과 APIC의 TPR(Task Priority Register)을 업데이트합니다.

TPR을 설정하면 APIC가 하드웨어 레벨에서 낮은 우선순위 인터럽트를 블록합니다. 이후 interrupts::enable()로 CPU의 IF 플래그를 설정하여 네스팅을 허용합니다.

이 시점부터 더 높은 우선순위 인터럽트는 현재 핸들러를 중단할 수 있습니다. 마지막으로, 실제 인터럽트 처리가 완료되면 인터럽트를 비활성화하고 우선순위를 이전 값으로 복원합니다.

process_deferred_interrupts()는 현재 우선순위가 낮아졌으므로, 큐에 쌓인 연기된 인터럽트를 처리할 수 있는지 확인하고 실행합니다. 이 메커니즘으로 모든 인터럽트가 결국 처리되지만, 긴급한 것이 먼저 처리되는 것이 보장됩니다.

여러분이 이 코드를 사용하면 실시간 시스템이나 고성능 하드웨어를 다루는 OS를 만들 수 있습니다. 예를 들어, 네트워크 패킷이 초당 백만 개씩 들어오는 서버에서, 타이머 인터럽트가 네트워크 인터럽트를 블록하지 않도록 하여 패킷 손실을 방지할 수 있습니다.

또한 전원 오류 같은 치명적 이벤트는 모든 다른 인터럽트를 중단하고 즉시 처리되어, 데이터 손상을 막을 수 있습니다.

실전 팁

💡 인터럽트 핸들러 내부에서 spinlock을 잡을 때는 반드시 우선순위를 고려하세요. 낮은 우선순위가 락을 잡고 있을 때 높은 우선순위가 같은 락을 기다리면 데드락이 발생합니다.

💡 TPR은 4비트 단위(0, 16, 32, ...)로만 작동하므로, 우선순위를 4의 배수로 설정하고 << 4로 시프트해야 합니다. 문서를 잘못 읽고 실수하기 쉬운 부분입니다.

💡 네스팅을 허용하면 스택 사용량이 증가하므로, 인터럽트 스택 크기를 충분히 크게(최소 8KB) 할당하세요. 스택 오버플로우는 디버깅하기 극도로 어렵습니다.

💡 NMI(Non-Maskable Interrupt)는 IF 플래그와 무관하게 발생하므로, NMI 핸들러는 절대 네스팅을 허용하면 안 됩니다. NMI는 재진입 불가능(non-reentrant)합니다.

💡 실시간 시스템에서는 최악의 경우 인터럽트 지연 시간(interrupt latency)을 측정하고 보장해야 합니다. 각 우선순위 레벨의 최대 실행 시간을 프로파일링하세요.


8. Bottom Half와 Deferred Work

시작하며

여러분이 네트워크 카드에서 인터럽트가 발생했을 때, 수백 바이트의 패킷 데이터를 처리하고, 체크섬을 계산하고, 프로토콜 스택을 거쳐 응용 프로그램으로 전달하는 모든 작업을 인터럽트 핸들러에서 직접 한다면 어떻게 될까요? 인터럽트 핸들러가 수 밀리초 동안 실행되면서 다른 모든 인터럽트가 블록되어, 시스템이 반응하지 않게 됩니다.

이런 문제는 인터럽트 핸들러가 "빠르게 실행되어야 한다"는 근본 원칙을 위반하기 때문입니다. 인터럽트 컨텍스트는 다른 인터럽트를 블록하므로, 무거운 작업을 수행하면 전체 시스템의 지연 시간이 증가합니다.

타이머가 제시간에 발생하지 않고, 키보드 입력이 누락될 수 있습니다. 바로 이럴 때 필요한 것이 Bottom Half 패턴입니다.

인터럽트 핸들러(Top Half)는 최소한의 작업만 하고 빠르게 리턴하며, 무거운 작업은 나중에 프로세스 컨텍스트에서 처리하도록 연기(defer)합니다.

개요

간단히 말해서, Bottom Half는 인터럽트 처리를 긴급한 부분(Top Half)과 연기 가능한 부분(Bottom Half)으로 나누는 디자인 패턴입니다. Top Half는 하드웨어를 인식하고 최소한의 데이터를 읽은 후, 나머지 작업을 Bottom Half 큐에 추가하고 리턴합니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, 모든 고성능 OS는 이 패턴을 사용합니다. Linux의 softirq, tasklet, work queue, Windows의 DPC(Deferred Procedure Call), macOS의 thread call 등이 모두 Bottom Half 메커니즘입니다.

예를 들어, 기가비트 이더넷에서 초당 수만 개의 패킷을 처리할 때, 각 인터럽트 핸들러가 1ms씩 걸리면 CPU가 인터럽트 처리만 하다가 시간을 다 쓰게 됩니다. Bottom Half를 사용하면 핸들러는 수 마이크로초만 실행되고, 실제 패킷 처리는 나중에 배치로 수행됩니다.

전통적인 방법과의 비교를 하자면, 초기 Unix는 모든 작업을 인터럽트 핸들러에서 직접 처리했습니다. 하지만 이는 확장성이 없었고, 고속 하드웨어가 등장하면서 Bottom Half가 필수가 되었습니다.

현대 OS는 여러 단계의 Bottom Half(즉시 실행, 짧은 지연, 긴 지연)를 제공합니다. Bottom Half의 핵심 특징은 첫째, 인터럽트 컨텍스트가 아닌 프로세스 컨텍스트에서 실행되어 sleep과 블록킹이 가능하다는 점, 둘째, 여러 Bottom Half가 큐에 쌓여 배치 처리될 수 있다는 점, 셋째, 우선순위나 지연 시간을 조절할 수 있다는 점입니다.

이러한 특징들이 효율적이고 확장 가능한 인터럽트 처리를 가능하게 합니다.

코드 예제

use alloc::collections::VecDeque;
use alloc::boxed::Box;
use spin::Mutex;

// Bottom Half 작업 클로저 타입
type WorkFn = Box<dyn FnOnce() + Send>;

// Bottom Half 큐 (per-CPU 권장)
static BOTTOM_HALF_QUEUE: Mutex<VecDeque<WorkFn>> =
    Mutex::new(VecDeque::new());

// 플래그: Bottom Half 처리 필요
static BH_PENDING: AtomicBool = AtomicBool::new(false);

// Top Half: 인터럽트 핸들러에서 호출
pub fn schedule_bottom_half<F>(work: F)
where
    F: FnOnce() + Send + 'static,
{
    // 작업을 큐에 추가
    BOTTOM_HALF_QUEUE.lock().push_back(Box::new(work));

    // 플래그 설정
    BH_PENDING.store(true, Ordering::Release);

    // 다음 타이머 틱에서 처리되도록 보장
    // 또는 즉시 처리를 원하면 소프트웨어 인터럽트 발생
}

// 네트워크 인터럽트 예시 (Top Half)
extern "x86-interrupt" fn network_interrupt_handler(
    _stack_frame: InterruptStackFrame
) {
    unsafe {
        // 1. 하드웨어에서 최소한의 정보만 읽기 (빠르게!)
        let packet_addr = read_network_register(RX_BUFFER_ADDR);
        let packet_len = read_network_register(RX_PACKET_LEN);

        // 2. 하드웨어에게 "받았음" 신호 (중요!)
        write_network_register(RX_ACK, 1);

        // 3. 무거운 작업은 Bottom Half로 연기
        schedule_bottom_half(move || {
            // 프로세스 컨텍스트에서 실행 (sleep 가능)
            process_network_packet(packet_addr, packet_len);
        });

        // 4. EOI 전송하고 빠르게 리턴
        APIC.lock().send_eoi();
    }
}

// Bottom Half 실행 (타이머 틱이나 idle 시 호출)
pub fn run_bottom_halves() {
    // Bottom Half가 있는지 확인
    if !BH_PENDING.load(Ordering::Acquire) {
        return;
    }

    // 큐에서 작업을 가져와 실행
    loop {
        let work = BOTTOM_HALF_QUEUE.lock().pop_front();
        match work {
            Some(work_fn) => {
                work_fn();  // 클로저 실행
            }
            None => {
                BH_PENDING.store(false, Ordering::Release);
                break;
            }
        }
    }
}

// 무거운 패킷 처리 (Bottom Half)
fn process_network_packet(addr: usize, len: usize) {
    // 체크섬 계산 (시간 소요)
    let checksum = calculate_checksum(addr, len);

    // 프로토콜 파싱 (복잡한 로직)
    let packet = parse_tcp_packet(addr, len);

    // 소켓 버퍼에 복사 (메모리 할당 가능)
    deliver_to_socket(packet);

    // 여기서는 sleep이나 블록킹도 가능!
}

설명

이것이 하는 일: 위 코드는 인터럽트 핸들러(Top Half)가 최소한의 작업만 수행하고, 무거운 작업은 클로저로 큐에 추가하여 나중에 프로세스 컨텍스트에서 안전하게 실행되도록 합니다. 첫 번째로, BOTTOM_HALF_QUEUE는 실행할 작업들을 담는 큐로, Box<dyn FnOnce()> 타입의 클로저를 저장합니다.

FnOnce는 한 번만 호출되는 클로저이고, Send는 스레드 간 전송이 가능함을 보장하며, 'static은 정적 생명주기를 의미합니다. dyn은 동적 디스패치를 통해 다양한 타입의 클로저를 하나의 큐에 저장할 수 있게 합니다.

실제 시스템에서는 CPU마다 별도의 큐를 두어 락 경합을 줄입니다. 그 다음으로, schedule_bottom_half() 함수는 제네릭 함수로, 어떤 클로저든 받을 수 있습니다.

move 클로저를 사용하면 현재 스코프의 변수(packet_addr, packet_len)를 클로저 내부로 이동시켜, 나중에 실행될 때 사용할 수 있습니다. BH_PENDING 플래그는 원자적 불린으로, Bottom Half가 대기 중임을 빠르게 확인할 수 있게 합니다.

세 번째 단계에서, network_interrupt_handler는 Top Half의 모범 사례를 보여줍니다. 하드웨어 레지스터에서 패킷 정보를 읽고(수 사이클), 하드웨어에게 ACK를 보내고(중요!

이것이 없으면 하드웨어가 멈춤), 나머지 작업을 Bottom Half로 연기한 후 즉시 리턴합니다. 전체 실행 시간이 1마이크로초 미만으로, 다른 인터럽트를 최소한으로 블록합니다.

마지막으로, run_bottom_halves()는 타이머 인터럽트의 끝이나 idle 루프에서 호출되어, 큐에 쌓인 작업을 순차적으로 실행합니다. 이 함수는 프로세스 컨텍스트에서 실행되므로, 내부 작업이 sleep을 호출하거나, 메모리를 할당하거나, 복잡한 계산을 해도 괜찮습니다.

process_network_packet()은 체크섬 계산, 프로토콜 파싱, 소켓 전달 등 시간이 걸리는 작업을 수행하지만, 인터럽트를 블록하지 않습니다. 여러분이 이 코드를 사용하면 고성능 디바이스 드라이버를 만들 수 있습니다.

디스크, 네트워크, GPU 등 현대의 모든 고속 하드웨어는 Bottom Half 패턴을 사용합니다. Linux의 NAPI(New API)는 네트워크 카드가 인터럽트 대신 폴링을 사용하도록 전환하여, 초당 수백만 패킷을 처리할 수 있게 합니다.

여러분의 OS도 이런 최적화를 적용할 수 있습니다.

실전 팁

💡 Bottom Half 큐가 너무 커지면 시스템이 느려지므로, 큐 크기를 제한하고 초과 시 패킷을 드롭하세요. 이는 DoS 공격 방어에도 중요합니다.

💡 시간에 민감한 작업(예: 실시간 오디오)은 전용 높은 우선순위 Bottom Half 큐를 만들어 일반 작업보다 먼저 처리하세요.

💡 Bottom Half에서도 너무 오래 실행되면 문제가 되므로, 큰 작업은 여러 단계로 나누고 매 단계마다 스케줄러에게 양보하세요(cond_resched 패턴).

💡 멀티코어에서는 Bottom Half를 여러 CPU에 분산하여 병렬 처리하세요. Linux의 ksoftirqd 커널 스레드가 이 역할을 합니다.

💡 디버깅 시 Bottom Half 처리 지연 시간을 추적하세요. 큐에서 대기한 시간이 너무 길면 실시간 요구사항을 만족하지 못할 수 있습니다.


#Rust#타이머인터럽트#OS개발#시스템프로그래밍#인터럽트핸들러

댓글 (0)

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