인터럽트와 I/O
하드웨어·소프트웨어 인터럽트와 예외의 처리 흐름을 살펴보고 폴링·인터럽트·DMA 방식의 비용을 비교합니다.
CPU가 프로그램을 실행하는 중에 키보드를 누르면 어떻게 될까요?
CPU가 매 사이클마다 키보드에 입력이 있나?, 디스크 읽기가 끝났나?, 네트워크 패킷이 왔나? 하고 모든 장치를 확인한다면 엄청난 낭비입니다.
장치가 수십 개인데, 대부분은 아무 이벤트도 없는 상태이니까요.
이 문제를 해결하는 메커니즘이 인터럽트(Interrupt)입니다.
인터럽트는 OS의 근간입니다.
OS가 CPU를 관리한다는 말을 실제로 가능하게 하는 것이 바로 인터럽트입니다.
OS가 프로세스를 전환하는 것, 타이머가 만료되어 스케줄러가 개입하는 것, 키보드 입력이 프로그램에 전달되는 것 — 이 모든 것의 시작점이 인터럽트입니다.
장치와 예외는 인터럽트로 실행 흐름을 잠시 바꾼다. CPU는 현재 문맥을 보존하고 원인별 handler를 실행한 뒤 중단 지점으로 돌아간다.
- 1이벤트 발생
장치 완료, timer, system call, fault가 CPU에 처리 원인을 알린다. device · timer · exception
- 2커널 진입
PC와 상태를 저장하고 vector가 가리키는 handler로 제어를 옮긴다. save → vector → handler
- 3완료와 복귀
원인을 승인하고 대기 task를 깨운 뒤 저장한 문맥으로 복귀한다. ack → wake → return
- 4인터럽트와 I/O 기준
Polling CPU가 상태를 반복 조회한다. 대기가 매우 짧을 때만 단순성이 비용보다 유리하다. Interrupt 장치가 완료 시점에만 신호를 보내므로 CPU가 다른 일을 할 수 있다. DMA payload는 controller가 메모리로 옮기고 interrupt는 완료만 알린다. 우선순위 mask와 priority로 더 급한 이벤트를 먼저 처리하고 폭주를 제어한다.
- 5Polling CPU
상태를 반복 조회한다. 대기가 매우 짧을 때만 단순성이 비용보다 유리하다.
- 6Interrupt 장치
완료 시점에만 신호를 보내므로 CPU가 다른 일을 할 수 있다.
- 7DMA payload
controller가 메모리로 옮기고 interrupt는 완료만 알린다.
- 8우선순위 mask
priority로 더 급한 이벤트를 먼저 처리하고 폭주를 제어한다.
인터럽트의 개념
인터럽트는 CPU에게 지금 하던 일을 잠시 멈추고 이것을 처리하라고 알리는 신호입니다.
레스토랑에서 요리사(CPU)가 스테이크를 굽고 있습니다.
주방 벨이 울립니다(인터럽트 발생).
요리사는 스테이크의 현재 상태를 기억해두고(레지스터 저장), 벨에 대응합니다(인터럽트 핸들러 실행).
대응이 끝나면 스테이크 굽기를 재개합니다(레지스터 복원).
핵심은 비동기성입니다.
인터럽트는 현재 실행 중인 프로그램과 무관하게, 외부 이벤트가 발생한 그 순간에 CPU에 전달됩니다.
프로그램은 인터럽트가 발생했다는 사실을 알지 못합니다.
자기 코드가 연속적으로 실행된 것처럼 느낍니다.
이 투명성(transparency)이 인터럽트 처리의 핵심 설계 원칙입니다.
인터럽트의 종류
인터럽트는 발생 원인에 따라 세 가지로 구분됩니다.
하드웨어 인터럽트 (외부 인터럽트)
외부 장치가 CPU에 보내는 전기적 신호입니다.
CPU의 인터럽트 핀에 신호가 들어오면 인터럽트가 발생합니다.
- 키보드 인터럽트: 키를 누르면 키보드 컨트롤러가 인터럽트를 발생시킵니다. 커널의 키보드 드라이버가 키 코드를 읽고, 현재 포커스된 프로세스에 전달합니다.
- 디스크 인터럽트: 디스크 읽기/쓰기가 완료되면 디스크 컨트롤러가 인터럽트를 보냅니다. 이 인터럽트를 받으면 OS는 I/O를 기다리던 프로세스를 깨웁니다.
- 타이머 인터럽트: 하드웨어 타이머가 설정된 주기마다 인터럽트를 발생시킵니다. OS가 시분할 멀티태스킹을 구현하는 핵심 메커니즘입니다.
- 네트워크 인터럽트: 네트워크 카드에 패킷이 도착하면 인터럽트를 보냅니다. 커널의 네트워크 스택이 패킷을 처리합니다.
하드웨어 인터럽트는 마스크 가능(Maskable)한 것과 마스크 불가능(NMI, Non-Maskable Interrupt)한 것으로 나뉩니다.
마스크 가능 인터럽트는 소프트웨어적으로 비활성화(disable)할 수 있습니다.
커널이 임계 영역을 실행할 때 인터럽트를 잠시 비활성화하여 원자성을 보장합니다.
NMI는 비활성화할 수 없으며, 메모리 패리티 오류나 하드웨어 장애 같은 긴급 상황에서 발생합니다.
소프트웨어 인터럽트 (트랩)
프로그램이 의도적으로 발생시키는 인터럽트입니다.
시스템 콜이 대표적입니다.
1장에서 다뤘듯이, 프로그램이 syscall 명령어를 실행하면 트랩이 발생하여 커널 모드로 전환됩니다.
프로그램이 OS의 서비스를 요청하는 공식 통로입니다.
트랩은 동기적(Synchronous)입니다.
하드웨어 인터럽트가 예측 불가능한 시점에 발생하는 것과 달리, 트랩은 프로그램이 특정 명령어를 실행한 그 시점에 정확히 발생합니다.
예외 (Exception)
프로그램 실행 중 오류 또는 특수 상황에서 발생합니다.
- 나눗셈 오류: 0으로 나누기를 시도하면 CPU가 예외를 발생시킵니다. 커널이 해당 프로세스에 SIGFPE 신호를 보내고, 기본 동작은 프로세스 종료입니다.
- 페이지 폴트(Page Fault): 프로세스가 물리 메모리에 매핑되지 않은 가상 주소에 접근할 때 발생합니다. 이것이 반드시 오류는 아닙니다. 가상 메모리 시스템에서 정상적인 동작입니다. 커널이 디스크에서 해당 페이지를 가져와 메모리에 적재하고, 프로그램은 아무 일도 없었다는 듯이 계속 실행됩니다. 9장에서 상세히 다룹니다.
- 보호 위반: 사용자 모드에서 커널 메모리에 접근하거나, 읽기 전용 영역에 쓰기를 시도하면 발생합니다. 리눅스에서는 SIGSEGV(세그먼테이션 폴트)로 나타납니다.
- 잘못된 명령어: CPU가 해석할 수 없는 바이너리 패턴을 명령어로 실행하려 하면 발생합니다.
인터럽트 처리 과정
인터럽트와 I/O에서는 하드웨어 신호, 커널 진입, 제어권 이동 순서를 추적합니다.
인터럽트 처리는 현재 명령을 마무리하고, 복귀할 상태를 저장한 다음, 번호에 맞는 핸들러를 실행하는 순서입니다.
- 1명령 완료 CPU
현재 실행 중인 명령을 중간에 끊지 않는다.
- 2상태 저장 복귀에 필요한 PC
플래그를 커널이 볼 수 있게 보관한다.
- 3번호 확인 APIC나 CPU 예외 번호
어떤 사건인지 식별한다.
- 4핸들러 실행 커널의 인터럽트 벡터
알맞은 처리 루틴을 호출한다.
- 5복귀 저장한 상태
복원하고 원래 흐름이나 스케줄러로 돌아간다.
| 사건 | 발생 시점 | 대표 처리 |
|---|---|---|
| 하드웨어 인터럽트 | 장치가 CPU에 외부 신호를 보낼 때 | 키보드, 디스크, 네트워크 완료를 커널이 처리한다. |
| 트랩 | 프로그램이 syscall 같은 명령을 실행할 때 | 사용자 모드에서 커널 서비스 요청으로 전환한다. |
| 예외 | 잘못된 연산이나 페이지 폴트가 발생할 때 | 복구하거나 프로세스에 신호를 보낸다. |
인터럽트가 발생하면 CPU는 다음 과정을 거칩니다.
이 과정을 정확히 이해하면 OS의 다른 모든 메커니즘이 자연스럽게 이해됩니다.
1. 현재 명령어 완료: CPU는 현재 실행 중인 명령어를 마저 완료합니다.
명령어 중간에 끊을 수는 없습니다.
2. 상태 저장: 현재 프로그램 카운터(PC), CPU 상태 레지스터(FLAGS), 그리고 일부 범용 레지스터를 스택에 저장합니다.
나중에 정확히 이 시점으로 돌아오기 위한 필수 정보입니다.
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이 완료되면, 저장해 둔 레지스터와 PC를 복원하고, 사용자 모드로 돌아갑니다.
원래 프로그램은 인터럽트가 발생했다는 사실을 모른 채 다음 명령어부터 계속 실행합니다.
인터럽트 처리의 핵심 원칙은 빨리 끝내라입니다.
ISR이 실행되는 동안 같은 종류의 인터럽트가 차단(마스킹)될 수 있고, 다른 프로세스도 실행되지 못합니다.
ISR에서 시간이 오래 걸리는 작업(디스크 읽기 결과 처리, 네트워크 패킷 파싱 등)을 하면 시스템 전체의 응답성이 떨어집니다.
이 문제를 해결하기 위해 Linux는 인터럽트 처리를 상반부(Top Half)와 하반부(Bottom Half)로 나눕니다.
- 상반부: ISR에서 실행됩니다. 최소한의 긴급 작업만 처리합니다. 하드웨어에서 데이터를 읽어 버퍼에 저장하고, 하반부를 스케줄링합니다.
- 하반부: 인터럽트 컨텍스트가 아닌 일반 실행 컨텍스트에서 나중에 처리합니다.
softirq,tasklet,workqueue등의 메커니즘이 사용됩니다.
네트워크 카드에 패킷이 도착했을 때를 예로 들면, 상반부에서는 패킷 데이터를 카드에서 메모리로 복사만 하고, 프로토콜 스택 처리(IP 파싱, TCP 처리, 소켓 전달)는 하반부에서 수행합니다.
I/O 처리 방식
CPU가 I/O 장치와 데이터를 주고받는 방식은 세 가지로 발전해 왔습니다.
각 방식은 이전 방식의 문제를 해결합니다.
아래 다이어그램은 PIO, 인터럽트 기반 I/O, DMA를 어떤 상황에서 선택하는지 비용과 데이터 크기 기준으로 정리한 것입니다.
I/O 방식은 장치가 빠른지, 데이터가 큰지, CPU가 직접 기다려도 되는지로 고른다. 핵심은 CPU 시간을 어디까지 아끼느냐다.
- PIO
CPU가 상태 레지스터를 계속 확인하고 데이터 이동도 직접 맡는다. 짧은 대기 CPU 점유 큼
- Interrupt I/O
요청 후 CPU는 다른 작업을 실행하고, 장치가 완료 시점만 알려준다. 불규칙 이벤트 응답성 좋음
- DMA
CPU는 전송 조건만 설정하고, 컨트롤러가 메모리로 직접 옮긴다. 큰 블록 인터럽트 1회
- PIO·Interrupt·DMA 선택 흐름
대기가 아주 짧으면 PIO도 가능하다. 완료 시점이 불규칙하면 인터럽트가 낫다. 데이터가 커지면 DMA가 기본 선택이 된다.
| CPU 대기 | 계속 확인 | 완료 전까지 자유 | 전송 중에도 자유 |
|---|---|---|---|
| 데이터 이동 | CPU가 직접 복사 | 보통 CPU/드라이버가 관여 | 장치가 메모리에 직접 전송 |
| 전송 크기 | 작고 짧을 때 | 중간 규모, 드문 이벤트 | 큰 블록, 연속 스트림 |
| 오버헤드 | 낭비는 크지만 단순 | 인터럽트마다 진입 비용 | 설정 비용 후 완료 신호 1회 |
프로그래밍 I/O (Programmed I/O, 폴링)
CPU가 직접 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가 완료되면 장치 컨트롤러가 인터럽트를 발생시킵니다.
/* CPU: 디스크에 명령을 보내고, 다른 일을 한다 */
send_command_to_disk(READ, sector, buffer);
schedule_other_process(); /* 다른 프로세스 실행 */
/* ... 나중에 디스크가 인터럽트를 발생시키면 ... */
void disk_interrupt_handler() {
/* ISR: 데이터를 사용자 버퍼로 복사 */
copy_data_to_buffer();
/* I/O를 기다리던 프로세스를 Ready 상태로 전환 */
wake_up_waiting_process();
}폴링보다 훨씬 효율적입니다.
CPU는 I/O 완료를 기다리는 동안 다른 프로세스를 실행할 수 있습니다.
멀티프로그래밍과 시분할의 핵심 동작이 바로 이것입니다.
I/O 대기 중인 프로세스 대신 다른 프로세스를 돌려라.하지만 단점이 있습니다.
대량 데이터를 전송할 때, 바이트 또는 워드 단위로 인터럽트가 발생하면 인터럽트 처리의 오버헤드가 누적됩니다.
1MB의 데이터를 전송하는데 바이트마다 인터럽트가 발생하면, 100만 번의 인터럽트 처리가 필요합니다.
DMA (Direct Memory Access)
DMA는 이 문제를 근본적으로 해결합니다.
I/O 장치가 CPU의 개입 없이 메모리에 직접 데이터를 전송합니다.
/* 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와 독립적으로 데이터를 전송합니다.
전체 전송이 완료되면 인터럽트를 딱 한 번 발생시킵니다.
1MB 전송에 100만 번의 인터럽트가 아니라 1번의 인터럽트로 끝납니다.
현대의 거의 모든 대용량 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의 메모리가 시스템의 메모리 주소 공간에 매핑되어, CPU가 특정 메모리 주소에 쓰면 그것이 화면의 픽셀 데이터가 됩니다.
타이머 인터럽트와 멀티태스킹
타이머 인터럽트는 OS가 멀티태스킹을 구현하는 핵심 메커니즘입니다.
주기적으로 커널 실행 기회를 만들어 선점형 스케줄링의 기준점이 됩니다.
OS는 하드웨어 타이머를 설정하여, 일정 시간(예: 1ms~10ms)이 지나면 타이머 인터럽트가 발생하게 합니다.
이 주기를 틱(Tick)이라 합니다.
Linux의 기본 틱 주기는 설정에 따라 다르지만, 일반적으로 1ms(HZ=1000)입니다.
타이머 인터럽트가 발생하면 커널의 타이머 핸들러가 실행됩니다.
이 핸들러는 여러 가지 중요한 일을 합니다.
- 시간 갱신: 시스템 시계를 업데이트합니다.
time()이나gettimeofday()시스템 콜이 반환하는 시간이 여기서 갱신됩니다. - 프로세스 시간 계산: 현재 프로세스가 사용한 CPU 시간을 기록합니다.
top명령어에서 보는 CPU 사용률이 이 정보를 기반으로 합니다. - 스케줄링 결정: 현재 프로세스의 타임 슬라이스가 만료되었는지 확인합니다. 만료되었으면 스케줄러를 호출하여 다른 프로세스에게 CPU를 넘깁니다.
- 타이머 이벤트 처리:
sleep()으로 잠든 프로세스,setTimeout같은 타이머 이벤트를 확인하여 만료된 것이 있으면 해당 프로세스를 깨웁니다.
만약 타이머 인터럽트가 없다면, 하나의 프로세스가 CPU를 독점할 수 있습니다.
무한 루프에 빠진 프로그램이 있으면 다른 프로그램은 영원히 실행되지 못합니다.
타이머 인터럽트 덕분에 OS는 어떤 프로그램이든, 심지어 무한 루프 중이더라도, 강제로 CPU를 빼앗아 다른 프로세스에게 줄 수 있습니다.
이것이 선점형(Preemptive) 멀티태스킹의 기반입니다.
반대로 비선점형(Cooperative/Non-Preemptive) 멀티태스킹에서는 프로세스가 자발적으로 CPU를 양보(yield)해야만 다른 프로세스가 실행됩니다.
Windows 3.x, 초기 macOS가 이 방식이었는데, 하나의 프로그램이 양보하지 않으면 전체 시스템이 멈추는 문제가 있었습니다.
현대 OS는 모두 선점형 멀티태스킹을 사용합니다.
최근의 Linux 커널은 Tickless 커널(NO_HZ)이라는 최적화도 제공합니다.
CPU가 유휴 상태(실행할 프로세스가 없는 상태)일 때 불필요한 타이머 인터럽트를 발생시키지 않아 전력 소비를 줄입니다.
노트북이나 서버에서 전력 효율이 중요할 때 유용합니다.
아래 다이어그램은 인터럽트 처리와 I/O 전송 방식을 CPU 개입 정도, 데이터 이동 경로, 스케줄링 영향으로 함께 비교한 운영체제 이벤트 흐름표입니다.
장치 이벤트는 CPU를 잠깐 커널로 끌어온 뒤, 긴급 처리와 지연 처리를 나누고, 필요하면 스케줄러를 통해 대기 중인 프로세스를 깨웁니다.
- 1장치 이벤트
event 1 키보드, 디스크, 네트워크 카드가 작업 완료나 입력 발생을 CPU에 알립니다.
- 2상태 저장
trap 2 CPU는 현재 PC와 레지스터를 저장하고 IDT를 통해 핸들러 주소를 찾습니다.
- 3상반부
isr 3 ISR은 장치 상태 확인, 버퍼 기록, ACK 같은 긴급 작업만 짧게 수행합니다.
- 4하반부
defer 4 프로토콜 처리, 파일 시스템 갱신, 소켓 전달은 나중에 일반 컨텍스트에서 실행됩니다.
- 5대기 해제
wake 5 I/O를 기다리던 프로세스가 준비 큐로 이동하고, 타임 슬라이스 정책을 적용받습니다.
- 6선점 판단
tick 6 타이머 인터럽트가 실행 시간을 갱신하고 필요하면 다른 프로세스로 전환합니다.
I/O 완료 인터럽트가 프로세스 큐를 어떻게 바꾸는지 보면, Waiting 상태가 Ready 상태로 돌아오는 순간이 더 명확해집니다.
I/O 완료 인터럽트는 잠든 프로세스를 깨우고, 스케줄러가 다시 CPU를 줄 수 있는 상태로 옮깁니다.
- Running 프로세스
read 같은 I/O 요청을 보낸다.
- Waiting CPU
반납하고 I/O 완료 이벤트를 기다린다.
- Ready 인터럽트 핸들러
프로세스를 실행 가능한 큐로 옮긴다.
- Scheduled 스케줄러
선택하면 다시 CPU에서 실행된다.
| 상태 | 큐 위치 | 실행 가능 여부 |
|---|---|---|
| Running | CPU 위 | 지금 명령을 실행 중이다. |
| Waiting | 장치 대기 큐 | CPU가 비어도 I/O 전에는 실행할 수 없다. |
| Ready | Ready queue | CPU만 받으면 즉시 실행할 수 있다. |
다음 절에서는 전원을 켠 순간부터 OS가 메모리에 적재되기까지의 부팅 과정을 살펴보겠습니다.
장치 이벤트, 예외, 타이머는 현재 문맥을 저장하고 커널이 큐와 프로세스 상태를 갱신하게 만든다.
- 폴링
CPU가 계속 묻는 방식이라 단순하지만 낭비가 커질 수 있다.
- DMA
컨트롤러가 데이터를 옮기고 CPU는 완료 인터럽트만 받는다.
- 타이머
무한 루프 중인 프로세스도 주기적으로 CPU를 빼앗길 수 있다.
- 큐
I/O 완료는 Waiting에서 Ready로 이동하는 명확한 전환점이다.
| 구간 | 커널이 하는 일 | 상태 변화 |
|---|---|---|
| event | 장치, 예외, 타이머 이벤트를 감지한다 | 현재 실행 흐름을 잠시 멈춘다 |
| save | PC와 레지스터 같은 문맥을 보존한다 | 나중에 같은 위치로 돌아갈 근거를 남긴다 |
| handler | ISR이 원인별 상태를 갱신한다 | I/O 완료나 예외 결과가 큐에 반영된다 |
| wake | 대기 중이던 프로세스를 Ready로 옮긴다 | 스케줄러가 다음 실행 후보를 다시 고른다 |