입출력 방식
I/O 방식은 CPU가 입출력 장치와 데이터를 주고받는 방법입니다. Programmed I/O → Interrupt-Driven → DMA 순으로 발전하며 CPU 효율이 점진적으로 개선됩니다.
핵심 개념
Programmed I/O
CPU가 장치 상태 레지스터를 반복 확인(Polling)하며 완료를 기다립니다. 구현이 단순하지만 I/O 내내 CPU가 점유되어 다른 작업을 할 수 없습니다.
핵심 개념
Interrupt-Driven I/O
CPU가 I/O 명령만 내리고 즉시 다른 작업으로 돌아갑니다. 장치가 완료되면 인터럽트 신호를 보내 CPU에 통보합니다. 폴링 낭비가 없어집니다.
핵심 개념
DMA (Direct Memory Access)
DMA 컨트롤러가 버스를 직접 제어해 장치↔메모리 간 데이터를 전송합니다. CPU는 시작과 완료 인터럽트 처리만 담당해 부담이 최소화됩니다.
핵심 개념
Interrupt Controller
여러 장치의 인터럽트 요청(IRQ)을 수집해 우선순위를 결정하고 CPU에 전달하는 하드웨어입니다. x86에서는 PIC가 쓰였고 현재 시스템은 주로 APIC를 사용합니다.
핵심 개념
Device Controller
실제 I/O 장치(디스크, 키보드 등)와 시스템 버스 사이에서 신호를 변환하는 하드웨어입니다. 데이터 레지스터·상태 레지스터·커맨드 레지스터를 가집니다.
핵심 개념
사이클 스틸링
DMA가 버스를 점유하는 순간 CPU의 메모리 접근이 한 사이클 지연됩니다. 대용량 전송에서는 보통 CPU 효율 이득이 지연 비용보다 크므로 실용적입니다.
세 방식이 나뉘는 이유
입출력 장치는 CPU보다 훨씬 느리고, 언제 끝날지도 장치마다 다릅니다. 완료를 CPU가 직접 기다리면(프로그램 I/O) 그 시간이 낭비되고, 장치가 알려 주게 하면(인터럽트 구동 I/O) 낭비는 줄지만 데이터를 옮기는 일은 여전히 CPU가 합니다. 데이터 이동까지 컨트롤러에 맡긴 것이 DMA입니다.
숫자로 확인하기
자주 하는 오해
면접에서 이렇게 물어요
면접 질문
프로그램 I/O, 인터럽트 구동 I/O, DMA를 비교해 보세요.
프로그램 I/O는 CPU가 완료까지 폴링해 구현이 단순하지만 CPU를 낭비합니다. 인터럽트 구동 I/O는 완료를 인터럽트로 받아 그동안 다른 일을 하지만 데이터 이동은 CPU가 합니다. DMA는 데이터 이동까지 DMA 컨트롤러가 해서 대용량 전송에 유리합니다.
면접 질문
메모리 맵드 I/O와 포트 맵드 I/O는 무엇이 다른가요?
메모리 맵드 I/O는 장치 레지스터를 메모리 주소 공간에 두고 일반 메모리 명령으로 접근합니다. 포트 맵드 I/O는 별도의 I/O 주소 공간을 쓰고 x86의 IN, OUT 같은 전용 명령으로 접근합니다.
면접 질문
DMA의 단점이나 주의할 점은 무엇인가요?
DMA가 버스를 쓰는 동안 CPU와 경쟁이 생기고, DMA가 바꾼 메모리와 CPU 캐시가 어긋날 수 있어 운영체제가 처리해야 합니다. 또 전송이 작으면 설정 비용이 이득보다 클 수 있습니다.
면접 질문
폴링이 더 나은 경우도 있나요?
이벤트가 아주 자주 와서 인터럽트 처리 비용이 크거나, 응답 지연을 최소화해야 하는 경우입니다. 리눅스 네트워크 드라이버는 부하가 높을 때 폴링으로 바꾸는 NAPI를 씁니다.
실제 시스템에서는
cat /proc/iomem은 I/O 메모리 맵(물리 주소 영역)을, cat /proc/ioports는 등록된 I/O 포트 영역을 보여 줍니다.
/proc/interrupts에서 장치별 인터럽트 횟수를 볼 수 있어, 인터럽트 구동 I/O가 얼마나 일어나는지 확인할 수 있습니다.
함께 보면 좋은 토픽
참고 자료
C — Programmed I/O (Polling)
// I/O 포트 주소 (메모리 맵드 I/O 가정) #define DISK_STATUS_REG 0xFF00 // 상태 레지스터 (read-only) #define DISK_DATA_REG 0xFF04 // 데이터 레지스터 #define DISK_CMD_REG 0xFF08 // 커맨드 레지스터 (write-only) #define CMD_READ 0x20 // 섹터 읽기 명령 #define CMD_READ_NEXT 0x21 // 다음 워드 전송 요청 #define STATUS_READY 0x02 // 데이터 준비 완료 비트 volatile uint32_t *status_reg = (volatile uint32_t *)DISK_STATUS_REG; volatile uint32_t *data_reg = (volatile uint32_t *)DISK_DATA_REG; volatile uint32_t *cmd_reg = (volatile uint32_t *)DISK_CMD_REG; // Programmed I/O: CPU가 직접 폴링하며 대기 uint32_t pio_read_sector(uint32_t sector) { // 1. 커맨드 레지스터에 읽기 명령 전송 (상태 레지스터는 read-only) *cmd_reg = sector | CMD_READ; // 2. Busy-Wait: 완료 비트(READY)가 설정될 때까지 CPU가 반복 폴링 // '!BUSY'가 아니라 'READY 설정'을 검사 — 장치가 상태를 갱신하기 전에 // 루프를 빠져나가는 경합(race)을 방지하기 위함 while (!(*status_reg & STATUS_READY)) { /* spin — CPU 100% 점유, 다른 일 불가 */ } // 3. 완료 후 데이터 직접 읽기 return *data_reg; } // Interrupt-Driven I/O: 명령만 내리고 CPU는 다른 작업 수행 void irq_read_sector(uint32_t sector) { // 1. 커맨드 레지스터에 읽기 명령 전송 *cmd_reg = sector | CMD_READ; // 2. CPU는 즉시 다른 작업으로 복귀 — 폴링(Busy-Wait)이 없음 // 완료 통보는 인터럽트(ISR)가 담당 do_other_work(); } // ISR: 장치가 데이터 1워드를 준비할 때마다 인터럽트로 호출됨 void __attribute__((interrupt)) disk_isr() { uint32_t data = *data_reg; // 데이터 복사를 CPU(ISR)가 직접 수행 store_to_buffer(data); // 수신 버퍼에 누적 if (transfer_remaining()) { // 받을 데이터가 더 남았으면 *cmd_reg = CMD_READ_NEXT; // 다음 워드 요청 → 또 인터럽트 발생 } else { wake_up_waiting_process(); // 전송 완료 — 대기 프로세스 깨움 } eoi(); // End of Interrupt 신호 // 주의: 섹터(예: 128워드)당 인터럽트가 워드 수만큼 발생한다. // 폴링 낭비는 없앴지만 전송 단위마다 CPU가 개입하므로 // 대용량 전송에서는 인터럽트+복사 오버헤드가 누적된다 → DMA의 동기 }
Programmed I/O에서는 while (!(*status_reg & STATUS_READY)) 폴링 루프가 핵심입니다.
CPU가 상태 레지스터를 반복 읽으며 장치 완료를 기다리므로, 이 시간 동안 CPU 자원이 100% 낭비됩니다.
완료 비트(STATUS_READY)가 설정되는지를 검사하면, 장치가 상태를 갱신하기 전에 루프를 빠져나가는 경합을 피할 수 있습니다.
Interrupt-Driven 방식은 폴링 없이 명령만 내리고 즉시 반환하며, 완료 통보는 ISR(Interrupt Service Routine)이 처리합니다.
다만 순수 Interrupt-Driven I/O에서는 ISR이 전송 단위(워드)마다 데이터를 직접 복사하므로,
대용량 전송에서는 인터럽트 횟수만큼 CPU 개입 비용이 누적됩니다 — 이 비용까지 없애는 것이 DMA입니다.
volatile 키워드는 컴파일러가 레지스터 읽기를 최적화로 제거하지 못하도록 강제합니다.
C — DMA 전송 설정
#define DMA_SRC_REG 0xFE00 // 소스 주소 레지스터 #define DMA_DST_REG 0xFE04 // 목적지 주소 레지스터 #define DMA_LEN_REG 0xFE08 // 전송 길이 레지스터 #define DMA_CTRL_REG 0xFE0C // 제어 레지스터 (START=0x1) volatile uint32_t *dma_src = (volatile uint32_t *)DMA_SRC_REG; volatile uint32_t *dma_dst = (volatile uint32_t *)DMA_DST_REG; volatile uint32_t *dma_len = (volatile uint32_t *)DMA_LEN_REG; volatile uint32_t *dma_ctrl = (volatile uint32_t *)DMA_CTRL_REG; /** * DMA 전송 시작 — CPU는 주소/길이만 설정하고 즉시 반환 * 실제 데이터 이동은 DMA 컨트롤러가 버스를 직접 제어해서 수행 * 완료 시 DMA가 인터럽트를 발생시켜 CPU에 통보 */ void dma_transfer(uint32_t src_phys, uint32_t dst_phys, size_t len) { *dma_src = src_phys; // 소스 물리 주소 설정 *dma_dst = dst_phys; // 목적지 물리 주소 설정 *dma_len = len; // 전송 바이트 수 *dma_ctrl = 0x01; // START 비트 — DMA 전송 시작 // CPU는 여기서 즉시 반환 — DMA가 버스를 점유하며 전송 수행 // CPU는 다른 프로세스 실행 가능 schedule(); // 다른 프로세스에 CPU 양보 } // DMA 완료 ISR void __attribute__((interrupt)) dma_isr() { wake_up_waiting_process(); // 대기 중인 프로세스를 준비 큐로 이동 eoi(); } // 세 방식 CPU 개입 비교 // Programmed I/O : 전송 내내 CPU 100% 점유 (Busy-Wait) // Interrupt-Driven: 폴링 낭비는 없으나 전송 단위(워드)마다 인터럽트+CPU 복사 // → 대용량일수록 인터럽트 오버헤드 누적 // DMA : 설정 + 완료 인터럽트 1회만 CPU 개입 (전송 전체에 ~0%) // 단, 전송 중 버스 경합(Cycle Stealing) 주의
DMA는 CPU 대신 DMA 컨트롤러가 시스템 버스를 직접 제어해서 장치↔메모리 간 데이터를 전송합니다.
CPU는 소스·목적지 주소와 길이를 레지스터에 기록하고 START 비트를 세운 뒤 즉시 다른 작업으로 넘어갑니다.
Interrupt-Driven 방식과 달리 전송 도중에는 CPU가 전혀 개입하지 않고, 완료 시 단 한 번의 인터럽트만 처리합니다.
단, DMA가 버스를 점유하는 동안 CPU의 메모리 접근이 잠시 지연될 수 있는 사이클 스틸링(Cycle Stealing)이 발생합니다.
대용량 블록 전송(디스크 I/O, 네트워크 수신 등)에서 DMA는 CPU 오버헤드를 최소화하는 가장 효율적인 방식입니다.
Q1
Programmed I/O 방식의 특징은?
Q2
인터럽트 기반 I/O가 Programmed I/O보다 나은 점은?
Q3
DMA 방식이 인터럽트 기반 I/O와 다른 점은?