안동민 개발노트

본문 시작

인터럽트와 I/O

하드웨어·소프트웨어 인터럽트와 예외의 처리 흐름을 살펴보고 폴링·인터럽트·DMA 방식의 비용을 비교합니다.

CPU가 프로그램을 실행하는 중에 키보드를 누르면 어떻게 될까요?

CPU가 매 사이클마다 키보드에 입력이 있나?, 디스크 읽기가 끝났나?, 네트워크 패킷이 왔나? 하고 모든 장치를 확인한다면 엄청난 낭비입니다.

장치가 수십 개인데, 대부분은 아무 이벤트도 없는 상태이니까요.

이 문제를 해결하는 메커니즘이 인터럽트(Interrupt)입니다.

인터럽트는 OS의 근간입니다.

OS가 CPU를 관리한다는 말을 실제로 가능하게 하는 것이 바로 인터럽트입니다.

OS가 프로세스를 전환하는 것, 타이머가 만료되어 스케줄러가 개입하는 것, 키보드 입력이 프로그램에 전달되는 것 — 이 과정에서 인터럽트가 중요한 역할을 합니다. 시스템 콜이나 실행 중인 커널 코드도 스케줄링을 요청할 수 있으므로 전환이 모두 외부 인터럽트에서 시작하는 것은 아닙니다.


인터럽트의 개념

인터럽트는 CPU에게 지금 하던 일을 잠시 멈추고 이것을 처리하라고 알리는 신호입니다.

레스토랑에서 요리사(CPU)가 스테이크를 굽고 있습니다.

주방 벨이 울립니다(인터럽트 발생).

요리사는 스테이크의 현재 상태를 기억해두고(레지스터 저장), 벨에 대응합니다(인터럽트 핸들러 실행).

대응이 끝나면 스테이크 굽기를 재개합니다(레지스터 복원).

핵심은 비동기성입니다.

인터럽트는 현재 실행 중인 프로그램과 무관하게, 외부 이벤트로 요청되며 실제 처리 시점은 마스크·우선순위·CPU 상태 등에 따라 지연될 수 있습니다.

프로그램은 인터럽트가 발생했다는 사실을 알지 못합니다.

자기 코드가 연속적으로 실행된 것처럼 느낍니다.

이 투명성(transparency)이 인터럽트 처리의 핵심 설계 원칙입니다.


인터럽트의 종류

인터럽트는 발생 원인에 따라 세 가지로 구분됩니다.

하드웨어 인터럽트 (외부 인터럽트)

외부 장치가 CPU에 보내는 전기적 신호입니다.

핀을 통한 신호뿐 아니라 MSI 같은 메시지 기반 통지도 사용됩니다.

  • 키보드 인터럽트: 키를 누르면 키보드 컨트롤러가 인터럽트를 발생시킵니다. 커널의 키보드 드라이버가 키 코드를 읽고, 현재 포커스된 프로세스에 전달합니다.
  • 디스크 인터럽트: 디스크 읽기/쓰기가 완료되면 디스크 컨트롤러가 인터럽트를 보냅니다. 이 인터럽트를 받으면 OS는 I/O를 기다리던 프로세스를 깨웁니다.
  • 타이머 인터럽트: 하드웨어 타이머가 설정된 주기마다 인터럽트를 발생시킵니다. OS가 시분할 멀티태스킹을 구현하는 핵심 메커니즘입니다.
  • 네트워크 인터럽트: 네트워크 카드에 패킷이 도착하면 인터럽트를 보냅니다. 커널의 네트워크 스택이 패킷을 처리합니다.

하드웨어 인터럽트는 마스크 가능(Maskable)한 것과 마스크 불가능(NMI, Non-Maskable Interrupt)한 것으로 나뉩니다.

마스크 가능 인터럽트는 소프트웨어적으로 비활성화(disable)할 수 있습니다.

로컬 인터럽트 비활성화는 해당 CPU의 IRQ 개입을 막습니다. 다른 CPU의 동시 접근까지 막으려면 필요한 락과 동기화를 함께 사용해야 합니다. Linux 잠금 문서

NMI는 비활성화할 수 없으며, 메모리 패리티 오류나 하드웨어 장애 같은 긴급 상황에서 발생합니다.

소프트웨어 인터럽트 (트랩)

프로그램이 의도적으로 발생시키는 인터럽트입니다.

시스템 콜이 대표적입니다.

시스템 콜은 명령 실행에 따라 동기적으로 커널에 진입합니다. x86의 syscall은 전용 진입 명령이며, int 명령이나 CPU가 분류하는 trap 예외와 동일한 하드웨어 경로는 아닙니다.

프로그램이 OS의 서비스를 요청하는 공식 통로입니다.

트랩은 동기적(Synchronous)입니다.

하드웨어 인터럽트가 예측 불가능한 시점에 발생하는 것과 달리, 트랩은 프로그램이 특정 명령어를 실행한 그 시점에 정확히 발생합니다.

예외 (Exception)

프로그램 실행 중 오류 또는 특수 상황에서 발생합니다.

  • 나눗셈 오류: 0으로 나누기를 시도하면 CPU가 예외를 발생시킵니다. 커널이 해당 프로세스에 SIGFPE 신호를 보내고, 기본 동작은 프로세스 종료입니다.
  • 페이지 폴트(Page Fault): 페이지가 없거나 접근 권한 조건을 만족하지 않을 때 발생할 수 있습니다. 요구 할당·Copy-on-Write·이미 메모리에 있는 페이지의 매핑 등으로 복구할 수도 있고 저장장치 I/O가 필요할 수도 있습니다. 잘못된 접근이면 복구되지 않을 수 있습니다. 9장에서 자세히 다룹니다.
  • 보호 위반: 사용자 모드에서 커널 메모리에 접근하거나, 읽기 전용 영역에 쓰기를 시도하면 발생합니다. 리눅스에서는 SIGSEGV(세그먼테이션 폴트)로 나타납니다.
  • 잘못된 명령어: CPU가 해석할 수 없는 바이너리 패턴을 명령어로 실행하려 하면 발생합니다.

인터럽트 처리 과정

인터럽트와 I/O에서는 하드웨어 신호, 커널 진입, 제어권 이동 순서를 추적합니다.

인터럽트가 발생하면 CPU는 다음 과정을 거칩니다.

이 과정을 정확히 이해하면 OS의 다른 모든 메커니즘이 자연스럽게 이해됩니다.

1. 현재 명령어 완료: CPU는 현재 실행 중인 명령어를 마저 완료합니다.

일반적인 외부 인터럽트는 아키텍처가 정한 재개 가능한 경계에서 처리합니다. 복잡한 반복 명령과 fault·trap의 재개 위치까지 모두 같은 규칙은 아닙니다.

2. 상태 저장: 하드웨어가 정해진 복귀 상태를 저장하고 커널 진입 코드가 필요한 레지스터를 추가로 보존합니다. 정확한 저장 항목과 스택 전환은 아키텍처·이전 모드에 따라 다릅니다.

나중에 정확히 이 시점으로 돌아오기 위한 필수 정보입니다.

3. 인터럽트 인식 및 번호 확인: CPU는 인터럽트의 종류(번호)를 확인합니다.

하드웨어 인터럽트의 경우 인터럽트 컨트롤러(APIC: Advanced Programmable Interrupt Controller)가 인터럽트 번호를 CPU에 알려줍니다.

4. 인터럽트 벡터 테이블(IVT) 조회: 인터럽트 번호를 인덱스로 사용하여 인터럽트 벡터 테이블(또는 IDT: Interrupt Descriptor Table)에서 해당 인터럽트 핸들러의 주소를 찾습니다.

이 테이블은 OS가 부팅 시 미리 설정해둡니다.

각 항목이 "인터럽트 N이 발생하면 주소 X의 코드를 실행하라"는 매핑입니다.

5. 핸들러 실행 환경 준비: 사용자 모드에서 들어오면 커널 실행 환경으로 전환합니다. 이미 커널 모드에서 실행 중이었다면 이를 사용자→커널 전환으로 볼 수 없습니다.

6. ISR 실행: 인터럽트 서비스 루틴(ISR, Interrupt Service Routine)이 실행됩니다.

ISR은 인터럽트의 원인을 파악하고, 필요한 작업(데이터 읽기, 프로세스 깨우기, 상태 갱신 등)을 수행합니다.

7. 상태 복원 및 복귀: 필요한 후속 처리와 스케줄링을 거쳐 중단된 문맥을 재개할 수 있습니다. 다른 태스크가 먼저 실행될 수 있으며, 돌아갈 모드도 원래 문맥에 따릅니다.

인터럽트 처리의 핵심 원칙은 빨리 끝내라입니다.

ISR이 실행되는 동안 같은 종류의 인터럽트가 차단(마스킹)될 수 있고, 다른 프로세스도 실행되지 못합니다.

ISR에서 시간이 오래 걸리는 작업(디스크 읽기 결과 처리, 네트워크 패킷 파싱 등)을 하면 시스템 전체의 응답성이 떨어집니다.

이 문제를 해결하기 위해 Linux는 인터럽트 처리를 상반부(Top Half)와 하반부(Bottom Half)로 나눕니다.

  • 상반부: ISR에서 실행됩니다. 최소한의 긴급 작업만 처리합니다. 하드웨어에서 데이터를 읽어 버퍼에 저장하고, 하반부를 스케줄링합니다.
  • 하반부: 후속 처리를 미루는 방법입니다. softirq·tasklet은 임의로 잠들 수 없는 실행 문맥이고, 일반적인 threaded workqueue는 작업 스레드의 프로세스 문맥을 사용합니다. WQ_BH 작업은 softirq 문맥이므로 workqueue라는 이름만으로 수면 가능 여부를 판단하지 않습니다. Workqueue 문서

DMA를 쓰는 NIC는 패킷을 이미 메모리 버퍼에 옮겨 놓을 수 있습니다. IRQ 핸들러가 NAPI 처리를 예약하고 후속 폴링에서 패킷을 회수하는 등 드라이버에 따라 경로가 달라집니다. Linux NAPI


I/O 처리 방식

I/O는 완료를 알아내는 방식과 데이터를 옮기는 방식을 나누어 봅니다. 아래 예시들은 비용 차이를 설명하지만 서로 배타적인 세 세대가 아닙니다.

완료 확인과 데이터 전송은 다른 선택이다
완료 확인과 데이터 전송은 다른 선택이다
선택할 질문방식과 비용
완료 확인폴링: CPU가 상태를 조회합니다. 인터럽트: 장치의 알림을 처리합니다. 대기 시간·부하에 따라 비용이 달라집니다.
데이터 이동PIO: CPU 명령으로 옮깁니다. DMA: 설정된 버퍼와 장치 사이를 하드웨어가 옮기며 CPU는 준비·동기화·완료 처리를 맡습니다.
함께 사용DMA 전송을 인터럽트로 통지하거나 폴링으로 회수할 수 있습니다. DMA가 항상 인터럽트 한 번을 뜻하지는 않습니다.
완료 확인
방식과 비용: 폴링: CPU가 상태를 조회합니다. 인터럽트: 장치의 알림을 처리합니다. 대기 시간·부하에 따라 비용이 달라집니다.
데이터 이동
방식과 비용: PIO: CPU 명령으로 옮깁니다. DMA: 설정된 버퍼와 장치 사이를 하드웨어가 옮기며 CPU는 준비·동기화·완료 처리를 맡습니다.
함께 사용
방식과 비용: DMA 전송을 인터럽트로 통지하거나 폴링으로 회수할 수 있습니다. DMA가 항상 인터럽트 한 번을 뜻하지는 않습니다.

폴링·인터럽트는 완료를 알아내는 방식이고 PIO·DMA는 데이터를 옮기는 방식입니다. DMA와 폴링도 함께 사용할 수 있습니다.

폴링으로 상태 확인하기

CPU가 직접 I/O 장치의 상태 레지스터를 반복적으로 확인합니다.

폴링 방식의 I/O (의사 코드)
/* CPU가 직접 상태를 반복 확인 */
send_command_to_disk(READ, sector, buffer);

while (disk_status_register != READY) {
    /* CPU가 아무 의미 없이 반복: busy waiting */
}

/* 데이터 전송 완료 */
copy_from_controller(buffer, data, size);

이 방식의 문제는 명백합니다.

디스크 읽기가 5ms 걸린다면, 3GHz CPU가 약 1500만 사이클을 아무 의미 없이 루프를 돌며 소비합니다.

이 시간에 다른 프로그램의 명령어를 수백만 개 처리할 수 있었을 것입니다.

폴링이 완전히 쓸모없는 것은 아닙니다.

아주 빠른 장치(일부 NVMe SSD)에서, I/O가 수 마이크로초 내에 완료될 것이 확실하다면, 컨텍스트 스위칭의 오버헤드보다 폴링이 더 효율적일 수 있습니다.

리눅스 커널의 blk-mq에서 고성능 SSD를 위한 폴링 모드를 지원하는 이유입니다.

인터럽트 기반 I/O

CPU가 I/O 요청을 보낸 후 다른 작업을 수행합니다.

I/O가 완료되면 장치 컨트롤러가 인터럽트를 발생시킵니다.

인터럽트 기반 I/O (의사 코드)
/* CPU: 디스크에 명령을 보내고, 다른 일을 한다 */
send_command_to_disk(READ, sector, buffer);
schedule_other_process();  /* 다른 프로세스 실행 */

/* ... 나중에 디스크가 인터럽트를 발생시키면 ... */

void disk_interrupt_handler() {
    /* 단순화한 완료 처리: 준비된 커널 버퍼로 복사 */
    copy_data_to_buffer();
    /* I/O를 기다리던 프로세스를 Ready 상태로 전환 */
    wake_up_waiting_process();
}

대기 시간이 길거나 이벤트가 드물면 바쁜 폴링의 CPU 소비를 줄일 수 있습니다.

CPU는 I/O 완료를 기다리는 동안 다른 프로세스를 실행할 수 있습니다.

멀티프로그래밍과 시분할의 핵심 동작이 바로 이것입니다.

I/O 대기 중인 프로세스 대신 다른 프로세스를 돌려라.

하지만 단점이 있습니다.

대량 데이터를 전송할 때, 바이트 또는 워드 단위로 인터럽트가 발생하면 인터럽트 처리의 오버헤드가 누적됩니다.

1MB의 데이터를 전송하는데 바이트마다 인터럽트가 발생하면, 100만 번의 인터럽트 처리가 필요합니다.

DMA (Direct Memory Access)

DMA는 이 문제를 근본적으로 해결합니다.

I/O 장치가 CPU의 개입 없이 메모리에 직접 데이터를 전송합니다.

DMA 방식 I/O (의사 코드)
/* CPU: DMA 컨트롤러에 전송 명령 */
setup_dma(source_disk_addr, dest_memory_addr, byte_count);
start_dma_transfer();
schedule_other_process();  /* CPU는 자유롭게 다른 일 수행 */

/* DMA 컨트롤러가 데이터를 직접 메모리에 복사 */
/* 이 예시는 전체 전송 완료를 인터럽트로 통지한다고 가정 */

void dma_complete_interrupt_handler() {
    /* 모든 데이터가 이미 메모리에 있음 */
    wake_up_waiting_process();
}

CPU는 이 디스크 위치의 데이터 N바이트를 이 메모리 주소로 복사하라고 DMA 컨트롤러에 한 번만 지시합니다.

DMA 컨트롤러가 CPU와 독립적으로 데이터를 전송합니다.

완료는 인터럽트나 폴링으로 확인할 수 있습니다. 통지 횟수는 큐·버퍼·장치와 인터럽트 병합 설정에 따라 달라지며 DMA 자체가 전송당 한 번을 보장하지 않습니다. Linux DMA API

현대의 거의 모든 대용량 I/O(디스크, 네트워크, 그래픽)는 DMA를 사용합니다.

CPU는 바이트별 복사를 줄일 수 있지만 버퍼 매핑·캐시 동기화·완료 처리 등의 책임은 남습니다.


I/O 접근 방식: 포트 I/O vs MMIO

CPU가 장치 컨트롤러의 레지스터에 접근하는 방식도 두 가지가 있습니다.

포트 매핑 I/O(Port-Mapped I/O): x86에서 전통적으로 사용하는 방식입니다.

I/O 장치 전용 주소 공간이 별도로 있고, in/out 같은 특수 명령어로 접근합니다.

예를 들어 키보드 컨트롤러는 포트 0x60에 매핑됩니다.

메모리 매핑 I/O(Memory-Mapped I/O, MMIO): I/O 장치의 레지스터를 메모리 주소 공간에 매핑합니다.

일반적인 메모리 읽기/쓰기 명령어(mov 등)로 장치를 제어합니다.

특수 명령어가 필요 없으므로 프로그래밍이 간단하고, C 언어의 포인터로 직접 접근할 수 있습니다.

ARM 프로세서와 대부분의 현대 장치가 MMIO를 사용합니다.

GPU의 VRAM(비디오 메모리)이 MMIO의 대표적인 예입니다.

장치의 레지스터나 허용된 프레임버퍼 영역을 주소 공간에 매핑할 수 있습니다. 실제 화면 표시는 드라이버·GPU·디스플레이 파이프라인에 따라 달라지므로 임의의 MMIO 쓰기가 곧 픽셀 출력인 것은 아닙니다.


타이머 인터럽트와 멀티태스킹

타이머 인터럽트는 OS가 멀티태스킹을 구현하는 핵심 메커니즘입니다.

주기적으로 커널 실행 기회를 만들어 선점형 스케줄링의 기준점이 됩니다.

OS는 하드웨어 타이머를 설정하여, 일정 시간(예: 1ms~10ms)이 지나면 타이머 인터럽트가 발생하게 합니다.

이 주기를 틱(Tick)이라 합니다.

Linux의 HZ 값은 구성마다 다릅니다. HZ=1000이면 주기적 틱 간격은 1ms이며, NO_HZ·고해상도 타이머 구성에서는 모든 CPU가 항상 같은 주기로 인터럽트를 받는 것도 아닙니다. NO_HZ

타이머 인터럽트가 발생하면 커널의 타이머 핸들러가 실행됩니다.

이 핸들러는 여러 가지 중요한 일을 합니다.

  • 시간 관리: 타이머 이벤트를 처리하고 시간 관리에 참여합니다. 현재 시간은 clocksource 등으로 계산하므로 틱마다만 갱신되는 단순 카운터로 보지는 않습니다. Linux 시간 관리
  • 프로세스 시간 계산: 현재 프로세스가 사용한 CPU 시간을 기록합니다. top 명령어에서 보는 CPU 사용률이 이 정보를 기반으로 합니다.
  • 스케줄링 결정: 현재 프로세스의 타임 슬라이스가 만료되었는지 확인합니다. 만료되었으면 스케줄러를 호출하여 다른 프로세스에게 CPU를 넘깁니다.
  • 타이머 이벤트 처리: 만료된 커널 타이머와 대기 작업을 처리합니다. JavaScript setTimeout 콜백은 런타임의 이벤트 루프가 실행하며 커널이 직접 실행하지 않습니다.

만약 타이머 인터럽트가 없다면, 하나의 프로세스가 CPU를 독점할 수 있습니다.

무한 루프에 빠진 프로그램이 있으면 다른 프로그램은 영원히 실행되지 못합니다.

타이머 인터럽트 덕분에 OS는 어떤 프로그램이든, 심지어 무한 루프 중이더라도, 강제로 CPU를 빼앗아 다른 프로세스에게 줄 수 있습니다.

이것이 선점형(Preemptive) 멀티태스킹의 기반입니다.

반대로 비선점형(Cooperative/Non-Preemptive) 멀티태스킹에서는 프로세스가 자발적으로 CPU를 양보(yield)해야만 다른 프로세스가 실행됩니다.

Windows 3.x, 초기 macOS가 이 방식이었는데, 하나의 프로그램이 양보하지 않으면 전체 시스템이 멈추는 문제가 있었습니다.

현대 범용 OS는 일반적으로 선점형 멀티태스킹을 사용합니다. 임베디드·특수 환경에는 다른 실행 모델도 있습니다.

최근의 Linux 커널은 Tickless 커널(NO_HZ)이라는 최적화도 제공합니다.

CPU가 유휴 상태(실행할 프로세스가 없는 상태)일 때 불필요한 타이머 인터럽트를 발생시키지 않아 전력 소비를 줄입니다.

노트북이나 서버에서 전력 효율이 중요할 때 유용합니다.

블로킹 I/O 후 다시 실행되기까지
I/O 대기와 실행 재개Running에서 요청 대기 시 Waiting, 완료 조건 충족 시 Ready, 스케줄러 선택 후 Running으로 바뀝니다.Running블로킹 요청 시작Waiting완료 조건을 기다림Ready완료 처리로 실행 가능Running스케줄러가 CPU 배정
I/O 대기와 실행 재개Running에서 요청 대기 시 Waiting, 완료 조건 충족 시 Ready, 스케줄러 선택 후 Running으로 바뀝니다.Running블로킹 요청 시작Waiting완료 조건을 기다림Ready완료 처리로 실행 가능Running스케줄러가 CPU 배정

요청을 기다리며 잠드는 경우의 상태 변화입니다. 완료 처리로 Ready가 되어도 즉시 실행되는 것은 아니며, 스케줄러의 CPU 배정을 기다립니다.

다음 절에서는 전원을 켠 순간부터 OS가 메모리에 적재되기까지의 부팅 과정을 살펴보겠습니다.