인터럽트가 없다면 어떻게 되는가?

CPU가 키보드 입력을 받으려면 어떻게 해야 하는가? 가장 단순한 방법은 CPU가 주기적으로 키보드 컨트롤러의 상태 레지스터를 확인한다. 이를 폴링(polling)이라 한다. CPU는 루프를 돌며 "입력이 있는가?"를 반복해서 묻는다.

과연 이 방식이 효율적인가? 전혀 그렇지 않다. 사용자가 키를 누르는 간격은 수십 밀리초에서 수 초에 이르지만, CPU는 나노초 단위로 동작한다. 키 입력 하나를 기다리는 동안 CPU가 수백만 번의 무의미한 확인을 반복하게 되는 셈이다. 키보드뿐만 아니라 디스크, 네트워크, 타이머 등 모든 장치를 폴링한다면 CPU의 대부분의 시간이 상태 확인에 소비되어 실제 연산에는 거의 쓰이지 못하게 된다.

인터럽트는 이 문제를 근본적으로 해결한다. 장치가 CPU에게 능동적으로 신호를 보내는 것이다. CPU는 장치를 확인하러 갈 필요 없이 자신의 작업을 계속 수행하다가, 장치가 "처리할 일이 있다"는 신호를 보내면 그때 현재 작업을 잠시 중단하고 해당 이벤트를 처리한다. 전화기에 비유하자면, 폴링은 수화기를 들어 "여보세요?" 하고 반복하는 것이고, 인터럽트는 전화벨이 울릴 때만 받는 것이다.

인터럽트의 세 가지 유형

CPU의 정상 실행 흐름을 중단시키는 이벤트는 크게 세 가지로 분류된다.

하드웨어 인터럽트는 외부 장치가 물리적 신호선을 통해 CPU에 알림을 보내는 것이다. 키보드 입력, 타이머 만료, 디스크 I/O 완료, 네트워크 패킷 도착 등이 여기에 해당한다. CPU 외부에서 비동기적으로 발생하므로, 명령어 실행 중 어느 시점에서든 도착할 수 있다.

소프트웨어 인터럽트는 프로그램이 의도적으로 발생시키는 인터럽트이다. x86에서는 INT 명령어를 통해 트리거하며, 이전 포스트에서 살펴본 시스템 콜(INT 0x80)이 대표적인 예이다. 하드웨어 인터럽트와 달리 동기적으로, 즉 프로그램의 실행 흐름 안에서 예측 가능한 시점에 발생한다.

예외(exception)는 명령어 실행 도중 CPU 내부에서 발생하는 이벤트이다. 0으로 나누기, 존재하지 않는 메모리 주소 접근, 특권 명령어의 무단 실행 등이 예외를 발생시킨다. 예외는 다시 세 가지로 세분화된다. 폴트(fault)는 문제가 된 명령어를 재실행할 수 있는 예외이다. 페이지 폴트가 대표적으로, 운영체제가 페이지를 로드한 뒤 해당 명령어를 다시 실행한다. 트랩(trap)은 예외를 발생시킨 명령어의 다음 명령어부터 재개하는 예외이다. 어보트(abort)는 복구 불가능한 심각한 오류로, 보통 해당 프로세스나 시스템 전체를 중단시킨다.

인터럽트 디스크립터 테이블

CPU가 인터럽트를 받으면 어떤 코드를 실행해야 하는지 어떻게 알 수 있는가? x86에서는 인터럽트 디스크립터 테이블(IDT, Interrupt Descriptor Table)이 이 매핑을 제공한다.

IDT는 최대 256개의 엔트리를 가지는 테이블이며, 각 엔트리는 하나의 인터럽트 벡터(번호)에 대응하는 게이트 디스크립터를 포함한다. CPU의 IDTR 레지스터가 이 테이블의 시작 주소와 크기를 가리킨다.

IDTR ──▶ ┌───────────────────────────┐
         │ 벡터 0: #DE (Divide Error) │ ──▶ 예외 핸들러
         │ 벡터 1: #DB (Debug)        │ ──▶ 디버그 핸들러
         │ 벡터 2: NMI               │ ──▶ NMI 핸들러
         │ ...                        │
         │ 벡터 13: #GP (General      │ ──▶ 일반 보호 핸들러
         │          Protection Fault)  │
         │ 벡터 14: #PF (Page Fault)  │ ──▶ 페이지 폴트 핸들러
         │ ...                        │
         │ 벡터 32: 타이머 인터럽트     │ ──▶ 타이머 ISR
         │ 벡터 33: 키보드 인터럽트     │ ──▶ 키보드 ISR
         │ ...                        │
         │ 벡터 255                    │
         └───────────────────────────┘

벡터 0~31은 CPU가 정의한 예외용으로 예약되어 있다. 벡터 32~255는 운영체제와 장치가 사용할 수 있는 영역이다. 인터럽트가 발생하면 CPU는 해당 벡터 번호로 IDT를 인덱싱하고, 게이트 디스크립터에 기록된 세그먼트와 오프셋으로 실행을 전환한다. 이전 포스트에서 설명한 것처럼, 이 과정에서 권한 수준 전환과 스택 전환이 함께 일어나는 것이다.

인터럽트 서비스 루틴의 동작

인터럽트 서비스 루틴(ISR, Interrupt Service Routine)은 특정 인터럽트를 처리하는 코드이다. ISR이 실행될 때 CPU는 이미 중요한 작업들을 자동으로 수행한 상태이다. 현재 명령어 포인터(RIP), 코드 세그먼트(CS), 플래그 레지스터(RFLAGS), 스택 포인터(RSP), 스택 세그먼트(SS)가 커널 스택에 자동으로 저장된다.

ISR은 추가로 범용 레지스터를 보존해야 한다. 인터럽트는 어느 시점에서든 발생할 수 있으므로, ISR이 레지스터를 수정하면 중단되었던 코드가 재개될 때 예상치 못한 값을 만나게 되기 때문이다. 이 때문에 ISR의 시작 부분에서 레지스터를 스택에 푸시하고, 종료 전에 복원하는 프롤로그/에필로그 코드가 필수적이다.

ISR은 가능한 한 빠르게 실행되어야 한다. 인터럽트 게이트를 통해 진입하면 해당 인터럽트(혹은 모든 인터럽트)가 비활성화된 상태이므로, ISR이 오래 실행되면 다른 인터럽트의 처리가 지연된다. 리눅스 커널에서는 이 문제를 해결하기 위해 인터럽트 처리를 두 단계로 나눈다. 상반부(top half)에서 하드웨어 레지스터 읽기, 인터럽트 확인 응답 등 즉각적인 처리만 수행하고, 시간이 걸리는 작업은 하반부(bottom half)로 지연시키는 것이다.

인터럽트 우선순위와 중첩

모든 인터럽트가 동일한 긴급도를 갖는 것은 아니다. 타이머 인터럽트는 시스템 시간의 정확성을 위해 빠르게 처리해야 하고, 키보드 인터럽트는 약간의 지연이 허용된다. 이러한 차이를 반영하기 위해 인터럽트 컨트롤러는 우선순위 체계를 제공한다.

높은 우선순위의 인터럽트가 낮은 우선순위의 ISR 실행 중에 발생하면, 현재 ISR을 중단하고 높은 우선순위 인터럽트를 먼저 처리할 수 있다. 이를 인터럽트 중첩(nested interrupt)이라고 한다. 반대로, 같거나 낮은 우선순위의 인터럽트는 현재 ISR이 완료될 때까지 대기한다.

NMI(Non-Maskable Interrupt)는 가장 높은 우선순위를 가지는 특수한 인터럽트이다. 이름 그대로 마스킹, 즉 소프트웨어적으로 비활성화할 수 없다. 하드웨어 오류, 메모리 패리티 에러 같은 치명적 상황에서 발생하며, 시스템이 어떤 상태에 있든 반드시 처리되어야 하는 이벤트이다. 반면 일반적인 하드웨어 인터럽트는 마스킹 가능(maskable)하여, CLI 명령어로 일시적으로 비활성화하고 STI 명령어로 다시 활성화할 수 있다. 커널이 임계 구역(critical section)에서 인터럽트를 비활성화하는 것은 이 메커니즘을 이용한다.

PIC에서 APIC으로

초기 IBM PC에서는 8259A PIC(Programmable Interrupt Controller)가 인터럽트를 관리했다. PIC 하나가 8개의 IRQ(Interrupt Request) 라인을 제공하고, 두 개를 캐스케이드하여 총 15개의 외부 인터럽트를 처리할 수 있었다.

              ┌──────────┐
IRQ 0 (타이머) ─▶│          │
IRQ 1 (키보드) ─▶│  마스터   │
IRQ 2 (슬레이브)─▶│  PIC     │──▶ CPU INTR 핀
IRQ 3         ─▶│          │
IRQ 4         ─▶│          │
...            │          │
              └──────────┘
                   ▲
              ┌────┴─────┐
IRQ 8         ─▶│          │
IRQ 9         ─▶│  슬레이브 │
...            │  PIC     │
IRQ 15        ─▶│          │
              └──────────┘

PIC 구조는 단일 프로세서 시스템에서는 충분했지만, 멀티프로세서 환경에서는 한계가 명확했다. 인터럽트를 하나의 CPU에만 전달할 수 있었고, IRQ 라인 수도 부족했기 때문이다.

이를 대체하기 위해 APIC(Advanced Programmable Interrupt Controller)이 도입되었다. APIC은 각 CPU 코어에 존재하는 로컬 APIC과 시스템 전체에서 외부 인터럽트를 수집하는 I/O APIC으로 구성된다. I/O APIC은 외부 인터럽트를 받아서 특정 CPU, 혹은 우선순위에 따라 가장 적합한 CPU에 라우팅한다. 이 구조 덕분에 멀티코어 환경에서 인터럽트를 여러 코어에 분산시켜 처리할 수 있게 된 것이다.

현대 시스템에서는 MSI(Message Signaled Interrupt)도 널리 사용된다. 물리적 인터럽트 라인 대신 메모리 쓰기 트랜잭션으로 인터럽트를 전달하는 방식으로, 핀 수의 제약 없이 수백 개의 인터럽트 벡터를 사용할 수 있다.

인터럽트 지연 시간

인터럽트 지연 시간(interrupt latency)은 인터럽트 신호가 발생한 시점부터 ISR의 첫 명령어가 실행되기까지의 시간이다. 이 지연은 현재 명령어 완료, CPU 상태 저장, IDT 조회, 스택 전환, 캐시 미스 등 여러 요인의 합으로 결정된다.

범용 운영체제에서 인터럽트 지연은 수 마이크로초 수준이며, 일반적인 사용에서는 문제가 되지 않는다. 그러나 산업 제어, 오디오 처리, 자율주행 같은 실시간 시스템에서는 보장된 최대 지연 시간이 중요하다. 이 때문에 PREEMPT_RT 패치 같은 실시간 확장이 리눅스 커널에 도입되어, 인터럽트 지연의 상한을 줄이는 최적화를 제공한다.

인터럽트와 멀티태스킹

인터럽트는 멀티태스킹의 핵심 기반이기도 하다. 선점형(preemptive) 멀티태스킹이 가능한 이유는 타이머 인터럽트 덕분이다. 운영체제는 하드웨어 타이머를 설정하여 일정 간격(보통 1~10밀리초)마다 인터럽트를 발생시킨다. 타이머 ISR에서 스케줄러가 호출되고, 스케줄러가 다음에 실행할 프로세스를 결정하여 컨텍스트 스위칭을 수행한다.

이 메커니즘이 없다면 각 프로그램이 자발적으로 CPU를 양보해야만 다른 프로그램이 실행될 수 있을 것이다. 실제로 초기 윈도우(3.1)와 클래식 맥OS는 이러한 협력적(cooperative) 멀티태스킹을 사용했는데, 하나의 프로그램이 CPU를 양보하지 않으면 시스템 전체가 멈추는 문제가 있었다. 타이머 인터럽트에 기반한 선점형 멀티태스킹은 이 문제를 하드웨어 수준에서 해결한 것이다.

DMA와 인터럽트의 협력

대량의 데이터를 디스크나 네트워크에서 메모리로 전송할 때, CPU가 바이트 단위로 직접 복사하는 것은 극히 비효율적이다. DMA(Direct Memory Access) 컨트롤러는 CPU의 개입 없이 장치와 메모리 사이에서 직접 데이터를 전송한다.

DMA와 인터럽트는 함께 동작한다. CPU가 DMA 컨트롤러에 전송 명령(소스 주소, 대상 주소, 크기)을 설정하면, DMA 컨트롤러가 독립적으로 데이터를 전송한다. CPU는 그동안 다른 작업을 수행할 수 있다. 전송이 완료되면 DMA 컨트롤러가 인터럽트를 발생시켜 CPU에 완료를 알린다. CPU는 인터럽트를 받고서야 전송 결과를 확인하고 후속 처리를 수행한다.

이 패턴은 현대 시스템의 I/O 전반에 걸쳐 사용된다. 디스크 읽기, 네트워크 패킷 수신, GPU와의 데이터 교환 모두 DMA 전송과 완료 인터럽트의 조합으로 이루어진다. 인터럽트가 없었다면 CPU는 전송이 끝나기를 폴링으로 기다려야 했을 것이고, 그 시간 동안 다른 어떤 유용한 작업도 수행할 수 없었을 것이다.

다음 글에서는 메모리 계층 구조를 다룬다.