icon

안동민 개발노트

2장 : 컴퓨터 시스템 구조

인터럽트와 I/O


CPU가 프로그램을 실행하는 중에 키보드를 누르면 어떻게 될까요? CPU가 매 사이클마다 키보드에 입력이 있나?, 디스크 읽기가 끝났나?, 네트워크 패킷이 왔나? 하고 모든 장치를 확인한다면 엄청난 낭비입니다. 장치가 수십 개인데, 대부분은 아무 이벤트도 없는 상태이니까요. 이 문제를 해결하는 메커니즘이 인터럽트(Interrupt)입니다.

인터럽트는 OS의 근간입니다. OS가 CPU를 관리한다는 말을 실제로 가능하게 하는 것이 바로 인터럽트입니다. OS가 프로세스를 전환하는 것, 타이머가 만료되어 스케줄러가 개입하는 것, 키보드 입력이 프로그램에 전달되는 것 — 이 모든 것의 시작점이 인터럽트입니다.


인터럽트의 개념

인터럽트는 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가 해석할 수 없는 바이너리 패턴을 명령어로 실행하려 하면 발생합니다.

인터럽트 처리 과정

인터럽트가 발생하면 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 장치와 데이터를 주고받는 방식은 세 가지로 발전해 왔습니다. 각 방식은 이전 방식의 문제를 해결합니다.

프로그래밍 I/O (Programmed I/O, 폴링)

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() {
    /* 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의 개입 없이 메모리에 직접 데이터를 전송합니다.

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와 독립적으로 데이터를 전송합니다. 전체 전송이 완료되면 인터럽트를 딱 한 번 발생시킵니다. 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의 "심장 박동"이라고도 합니다.

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가 유휴 상태(실행할 프로세스가 없는 상태)일 때 불필요한 타이머 인터럽트를 발생시키지 않아 전력 소비를 줄입니다. 노트북이나 서버에서 전력 효율이 중요할 때 유용합니다.

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

목차