인터럽트: CPU 실행을 멈추고 비동기 이벤트를 처리하는 방식

인터럽트의 처리 과정과 하드웨어·소프트웨어·예외 분류, 벡터 테이블, 우선순위, 운영체제별 관리 방식을 정리한다.

2026-08-14 · 최초 발행 2026-01-06

CPU가 실행 경로를 바꾸는 순간

키보드 입력, 디스크 I/O 완료, 네트워크 패킷 도착, 타이머 만료처럼 실행 시점을 미리 알 수 없는 이벤트는 CPU의 정상적인 명령 실행과 별도로 처리해야 한다. 인터럽트는 이런 신호가 들어왔을 때 현재 프로그램을 잠시 멈추고 필요한 처리 루틴을 실행하게 하는 메커니즘이다.

CPU는 처리 중이던 상태를 보존하고 인터럽트 서비스 루틴(ISR)을 수행한 뒤, 보존한 상태를 복원해 원래 작업을 계속한다. 폴링처럼 장치 상태를 반복 확인하지 않아도 되므로 I/O 대기 중 다른 작업을 수행할 수 있다. 긴급 이벤트에 반응하고, 예외 상황을 다루며, 멀티태스킹을 구현하는 기반이기도 하다.

인터럽트 신호는 하드웨어와 소프트웨어 양쪽에서 발생한다. 외부 장치에서는 키보드 입력, 마우스 클릭, 네트워크 패킷, 디스크 I/O 완료, 타이머 만료가 대표적이다. 소프트웨어는 int 0x80, syscall 같은 시스템 호출이나 명시적 인터럽트 명령으로 진입을 요청할 수 있다. Division by Zero, Page Fault, Invalid Opcode, Segmentation Fault는 CPU 내부에서 감지되는 예외다.

신호신호신호프로그램실행인터럽트발생현재 상태저장ISR실행상태복원프로그램재개I/O 장치타이머예외

신호를 받은 뒤 복귀하기까지

하드웨어 인터럽트에서는 I/O 장치가 인터럽트 라인에 신호를 보내고, PIC나 APIC 같은 Interrupt Controller가 이를 수신한다. 신호는 CPU의 INTR 핀으로 전달되며, CPU는 현재 명령어를 마친 뒤 이를 확인한다.

소프트웨어는 명령어를 통해 인터럽트를 발생시킨다.

; x86
int 0x80      ; Linux system call
int 3         ; Breakpoint

; ARM
svc #0        ; Supervisor Call

인터럽트 처리에서는 발생한 신호를 확인하고, PC·플래그·레지스터를 스택에 저장한다. 이어서 인터럽트 번호로 벡터를 조회해 핸들러 주소를 찾고 ISR을 호출한다. ISR 실행이 끝나면 저장했던 레지스터를 복원하고 원래 프로그램 위치로 되돌아간다.

// 의사 코드
void interrupt_entry() {
    // 하드웨어가 자동으로 수행
    push(EFLAGS);
    push(CS);
    push(EIP);

    // 소프트웨어가 추가 저장
    push(EAX);
    push(EBX);
    // ... 기타 레지스터

    call(interrupt_handler);

    // 복원
    pop(EBX);
    pop(EAX);
    // ...

    iret();  // Interrupt Return
}

x86에서는 IRET 명령어가 스택에서 EIP, CS, EFLAGS를 복원해 원래 프로그램으로 복귀시킨다. 특권 레벨이 바뀐 경우에는 사용자 모드 전환도 이 과정에 포함된다.

iret    ; Interrupt Return
; 스택에서 EIP, CS, EFLAGS 복원
; 원래 프로그램으로 복귀

발생 원인에 따라 나뉘는 인터럽트

하드웨어 인터럽트는 타이머, 디스크와 네트워크 I/O, 키보드와 마우스처럼 외부 장치에서 비동기적으로 발생한다. 마스킹할 수 있고 우선순위를 둘 수 있다.

// Linux 인터럽트 핸들러
irqreturn_t keyboard_interrupt(int irq, void *dev_id) {
    unsigned char scancode = inb(0x60);  // 키보드 포트
    process_keycode(scancode);
    return IRQ_HANDLED;
}

소프트웨어 인터럽트는 사용자 프로그램이 커널 서비스를 요청하거나, 디버깅과 오버플로우 검사처럼 의도적으로 인터럽트를 일으키는 데 사용된다. x86 Linux에서는 int 0x80, x86-64에서는 syscall, ARM에서는 svc가 해당한다.

// Linux system call
int main() {
    write(1, "Hello\n", 6);  // write() system call
    // 내부적으로 syscall 명령어 실행
}

내부 인터럽트인 예외는 Fault, Trap, Abort로 구분할 수 있다. Fault는 Page Fault처럼 페이지를 로드한 뒤 명령어를 재시도할 수 있는 복구 가능한 예외다. Trap은 명령어 실행 뒤 보고되며 디버거의 Breakpoint나 Single-step 모드가 여기에 속한다. Double Fault와 Machine Check는 복구할 수 없는 Abort다.

// Page Fault 핸들러 (Linux)
void do_page_fault(struct pt_regs *regs, unsigned long error_code) {
    unsigned long address = read_cr2();  // Fault 주소

    if (handle_mm_fault(...) == VM_FAULT_MAJOR) {
        // 디스크에서 페이지 로드
    }

    // 명령어 재실행
}
인터럽트분류하드웨어인터럽트소프트웨어인터럽트예외타이머I/O키보드SystemCallTrapFaultAbort

벡터 테이블이 ISR을 찾는 방법

인터럽트 벡터 테이블은 인터럽트 번호를 핸들러 주소에 연결한다. x86에서는 IDT(Interrupt Descriptor Table), ARM에서는 Vector Table이 이 역할을 맡는다.

struct idt_entry {
    uint16_t offset_low;   // 핸들러 주소 하위 16비트
    uint16_t selector;     // 코드 세그먼트
    uint8_t  zero;
    uint8_t  type_attr;    // 타입 및 속성
    uint16_t offset_high;  // 핸들러 주소 상위 16비트
} __attribute__((packed));

struct idt_entry idt[256];  // 256개 인터럽트

IDT 엔트리를 구성한 뒤에는 IDTR에 테이블 위치를 적재해야 한다.

void set_idt_gate(int n, uint32_t handler) {
    idt[n].offset_low = handler & 0xFFFF;
    idt[n].selector = 0x08;  // Kernel code segment
    idt[n].zero = 0;
    idt[n].type_attr = 0x8E;  // Present, DPL=0, 32-bit interrupt
    idt[n].offset_high = (handler >> 16) & 0xFFFF;
}

void load_idt() {
    struct {
        uint16_t limit;
        uint32_t base;
    } __attribute__((packed)) idtp;

    idtp.limit = sizeof(idt) - 1;
    idtp.base = (uint32_t)&idt;

    asm volatile("lidt %0" : : "m"(idtp));
}

ISR은 인터럽트가 발생했을 때 실행되는 함수다. 컨텍스트 저장과 복원 부담이 있으므로 짧고 빠르게 끝내는 것이 전제다. 핸들러는 인터럽트 소스를 확인하고, 필요한 최소 처리만 한 뒤 나머지 작업을 지연 처리로 넘길 수 있다.

// Linux 인터럽트 핸들러
irqreturn_t my_interrupt_handler(int irq, void *dev_id) {
    // 1. 인터럽트 소스 확인
    if (!is_my_interrupt(dev_id)) {
        return IRQ_NONE;
    }

    // 2. 최소한의 처리
    ack_interrupt(dev_id);
    schedule_bottom_half();

    // 3. 반환
    return IRQ_HANDLED;
}

Linux 커널에서는 request_irq로 IRQ 라인에 핸들러를 등록하고, 더 이상 사용하지 않을 때 free_irq로 해제한다.

int request_irq(unsigned int irq,
                irq_handler_t handler,
                unsigned long flags,
                const char *name,
                void *dev_id) {
    // IRQ 라인에 핸들러 등록
}

// 사용 예시
request_irq(IRQ_KEYBOARD, keyboard_interrupt,
            IRQF_SHARED, "keyboard", &keyboard_dev);
void free_irq(unsigned int irq, void *dev_id);

우선순위와 마스킹으로 제어하는 범위

일반적인 우선순위는 Machine Check, Hardware Failure, Page Fault, Timer Interrupt, Disk I/O, Network I/O, Keyboard/Mouse 순으로 볼 수 있다. 용도에 따라서는 NMI(Non-Maskable Interrupt)를 절대 우선순위로 두고, 시스템 안정성 관련 이벤트는 Critical, 실시간 요구사항은 High, 일반 I/O는 Normal, 백그라운드 작업은 Low로 분류한다.

더 높은 우선순위 인터럽트가 ISR 실행 중 들어오면 현재 ISR을 중단하고 새 ISR을 실행할 수 있다. 이를 네스티드 인터럽트라고 하며, 높은 우선순위 처리가 끝나면 이전 ISR로 복귀한다.

void high_priority_isr() {
    save_context();

    // 낮은 우선순위 인터럽트 재활성화
    enable_lower_interrupts();

    // 처리
    handle_interrupt();

    disable_interrupts();
    restore_context();
}

중첩이 일어나면 스택에는 원래 프로그램과 각 ISR의 실행 상태가 차례로 쌓인다.

+-------------------+
| Original Program  |
+-------------------+
| Low Priority ISR  |
+-------------------+
| High Priority ISR |  <- Current SP
+-------------------+

Maskable Interrupt는 x86의 IF 플래그로 제어한다. CLI(Clear Interrupt Flag)는 비활성화, STI(Set Interrupt Flag)는 활성화에 사용된다. PIC에서는 IRQ별 마스크 비트를 조절할 수도 있다.

// PIC (8259) 마스킹
void mask_irq(int irq) {
    uint8_t mask = inb(PIC1_DATA);
    mask |= (1 << irq);
    outb(PIC1_DATA, mask);
}

void unmask_irq(int irq) {
    uint8_t mask = inb(PIC1_DATA);
    mask &= ~(1 << irq);
    outb(PIC1_DATA, mask);
}

NMI는 마스킹할 수 없다. 메모리 패리티와 버스 에러 같은 치명적 오류, 워치독 타이머에 사용된다.

폴링과 인터럽트의 선택

폴링은 CPU가 장치 상태를 주기적으로 확인하는 방식이다. 구현은 단순하지만 Busy-Waiting으로 CPU를 계속 사용한다.

while (1) {
    if (device_ready()) {
        process_data();
    }
    // CPU 계속 확인
}

인터럽트 방식에서는 장치가 준비됐을 때 신호를 보내고, CPU는 메인 작업을 수행하다 ISR로 진입한다. CPU 활용은 효율적이지만 구현은 더 복잡하다.

// 메인 프로그램
while (1) {
    do_useful_work();
    // 인터럽트 발생 시 자동으로 ISR 실행
}

// 인터럽트 핸들러
void device_isr() {
    process_data();
}
항목 폴링 인터럽트
CPU 효율 낮음 (Busy-Wait) 높음
응답 시간 폴링 주기에 의존 즉시 (마이크로초)
구현 복잡도 간단 복잡
이벤트 빈도 고빈도 (ms 단위) 저빈도 (초 단위)
예측 가능성 높음 낮음 (비동기)
전력 소비 높음 낮음
적합 케이스 실시간, 고빈도 일반, 저빈도

x86·ARM·RISC-V의 인터럽트 구조

x86의 8259 PIC는 최대 15개 IRQ 라인을 제공하고 마스터/슬레이브 구조를 사용한다. APIC는 멀티 프로세서를 지원하며, 각 CPU의 Local APIC와 외부 장치용 I/O APIC로 나뉜다. x86에서 0-31은 예외 및 NMI, 32-255는 외부 인터럽트에 배정된다.

ARM에서는 IRQ가 일반 인터럽트, FIQ가 빠른 인터럽트, SWI가 System Call을 담당한다. 벡터 테이블은 각 예외와 인터럽트 핸들러로 분기한다.

vectors:
    ldr pc, reset_handler
    ldr pc, undefined_handler
    ldr pc, swi_handler
    ldr pc, prefetch_abort_handler
    ldr pc, data_abort_handler
    nop
    ldr pc, irq_handler
    ldr pc, fiq_handler

FIQ는 전용 레지스터 R8-R14를 사용하며, 빠른 컨텍스트 스위칭과 높은 우선순위를 제공한다.

RISC-V는 예외를 Synchronous, 인터럽트를 Asynchronous로 구분한다. mstatus는 인터럽트 활성화, mie는 인터럽트 enable, mtvec는 핸들러 주소를 다루는 CSR(Control and Status Registers)이다.

void trap_handler(void) {
    uint64_t cause = read_csr(mcause);

    if (cause & (1UL << 63)) {
        // 인터럽트
        handle_interrupt(cause & 0x7FFFFFFFFFFFFFFF);
    } else {
        // 예외
        handle_exception(cause);
    }
}

운영체제가 인터럽트 부담을 나누는 방식

Linux의 인터럽트 컨텍스트는 프로세스 컨텍스트와 분리돼 있으며 스케줄링하거나 Sleep할 수 없다. 그래서 Top Half에서는 하드웨어 확인과 같은 최소 작업만 수행하고, 무거운 작업은 Bottom Half로 미룬다.

// Top Half: 최소 작업
irqreturn_t my_interrupt(int irq, void *dev_id) {
    ack_hardware();
    schedule_work(&my_work);  // Bottom Half 예약
    return IRQ_HANDLED;
}

// Bottom Half: 무거운 작업
void my_work_handler(struct work_struct *work) {
    process_data();  // 여기서는 Sleep 가능
}

Linux에서는 Softirq가 동시 실행될 수 있고, Tasklet은 직렬화된다.

Windows는 IRQL(Interrupt Request Level)로 실행 레벨을 구분한다. 0은 PASSIVE_LEVEL, 1은 APC_LEVEL, 2는 DISPATCH_LEVEL, 3-26은 Device IRQLs, 27-30은 고우선순위, 31은 HIGH_LEVEL이다. DPC(Deferred Procedure Call)는 Bottom Half와 유사하며 DISPATCH_LEVEL에서 실행된다.

실시간 OS는 최대 인터럽트 지연 시간을 보장하고 우선순위를 엄격히 지키며, 선점 가능한 커널을 사용한다. FreeRTOS에서는 ISR에서 태스크 전환을 수행할 수 있다.

void vPortYield(void) {
    // ISR에서 태스크 전환
    portYIELD_FROM_ISR();
}

인터럽트 오버헤드를 줄이는 지점

인터럽트 코얼레싱은 여러 인터럽트를 하나로 합쳐 인터럽트 오버헤드를 줄이는 기법이며, 네트워크 카드에서 주로 사용된다.

// 100개 패킷마다 또는 1ms마다 인터럽트
ethtool -C eth0 rx-usecs 1000 rx-frames 100

인터럽트 어피니티는 특정 IRQ를 CPU에 바인딩하는 방식이다. 캐시 지역성을 높이고 Context Switching을 줄이는 데 사용한다.

# IRQ 5를 CPU 0에 바인딩
echo 1 > /proc/irq/5/smp_affinity

Top Half와 Bottom Half의 분할도 지연을 줄이는 핵심이다. ISR은 짧게 유지하고 긴 작업은 Bottom Half에 넘긴다. Linux에서는 Softirq가 고성능이지만 복잡하고, Tasklet은 간단하며 자동 직렬화된다. Work Queue는 프로세스 컨텍스트에서 실행되어 Sleep할 수 있다.

// Tasklet 사용
DECLARE_TASKLET(my_tasklet, my_tasklet_handler, data);

void my_interrupt(int irq, void *dev_id) {
    // Top Half
    tasklet_schedule(&my_tasklet);
}

void my_tasklet_handler(unsigned long data) {
    // Bottom Half
    heavy_processing();
}
인터럽트운영체제CPUISR시스템