컴퓨터 구조 운영체제 네트워크 자료구조 알고리즘 데이터베이스 내 학습
arch · 10

입출력 방식

버스OS입출력

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 점유율 — Programmed I/O: 전송 내내 100% / Interrupt-Driven: 명령+ISR 처리만 / DMA: 설정+ISR 처리만 (~0%)
ISR (Interrupt Service Routine) — 인터럽트 발생 시 실행되는 OS 핸들러입니다. 현재 실행 중인 프로세스를 잠시 중단하고 빠르게 처리 후 복귀합니다.
메모리 맵드 I/O — 장치 레지스터를 메모리 주소 공간에 매핑해 일반 메모리 명령(MOV)으로 I/O를 수행합니다. 포트 맵드 I/O(IN/OUT 명령)와 대비됩니다.
적용 사례 — 저속 소형 장치(GPIO, 센서): Programmed I/O 또는 IRQ / 고속 대용량 전송(NVMe, 네트워크 NIC): DMA / 고속 장치는 DMA를 쓰는 것이 일반적입니다.
버스 중재 (Bus Arbitration) — CPU와 DMA가 동시에 버스를 요청할 때 DMA 컨트롤러가 버스 사용 권한을 중재합니다. DMA 완료 후 CPU에 버스가 반환됩니다.

세 방식이 나뉘는 이유

입출력 장치는 CPU보다 훨씬 느리고, 언제 끝날지도 장치마다 다릅니다. 완료를 CPU가 직접 기다리면(프로그램 I/O) 그 시간이 낭비되고, 장치가 알려 주게 하면(인터럽트 구동 I/O) 낭비는 줄지만 데이터를 옮기는 일은 여전히 CPU가 합니다. 데이터 이동까지 컨트롤러에 맡긴 것이 DMA입니다.

숫자로 확인하기

4KB 블록 하나를 옮길 때(설명용 계산) — 4바이트마다 인터럽트가 걸리는 장치라면 4,096 ÷ 4 = 1,024번의 인터럽트가 필요합니다. DMA는 CPU가 전송을 설정하고 끝났을 때 인터럽트를 한 번 받으므로, 인터럽트 횟수가 블록당 1번으로 줄어듭니다.
폴링은 대기 시간만큼 CPU를 씁니다 — 장치가 1ms 걸린다면 프로그램 I/O는 그 1ms 동안 CPU가 상태 레지스터를 계속 읽습니다. 3GHz CPU라면 사이클로 약 300만 번입니다.

자주 하는 오해

DMA를 쓰면 CPU가 전혀 관여하지 않는다 — CPU가 DMA 컨트롤러에 출발지, 목적지, 전송 크기를 설정해야 하고, 끝나면 인터럽트로 알려 줍니다. 빠지는 것은 데이터를 한 단위씩 옮기는 일입니다.
DMA 중에는 CPU가 완전히 멈춘다 — CPU는 다른 작업을 계속합니다. 다만 DMA가 버스를 쓰는 순간에는 CPU의 메모리 접근이 늦어질 수 있습니다(사이클 스틸링).
메모리 맵드 I/O는 장치가 메모리라는 뜻이다 — 장치의 레지스터에 메모리 주소를 배정해 일반 읽기·쓰기 명령으로 접근하게 한 것이고, 그 주소 뒤에 있는 것은 RAM이 아니라 장치입니다.
DMA 버퍼는 CPU 캐시와 상관없다 — DMA가 메모리를 바꾸면 CPU 캐시의 사본이 오래된 값이 될 수 있어서, 운영체제 드라이버가 이를 맞추는 처리를 합니다. 리눅스는 DMA API 문서에서 캐시 일관성을 다룹니다.

면접에서 이렇게 물어요

면접 질문

프로그램 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를 씁니다.

실제 시스템에서는

리눅스의 I/O 영역 확인 — cat /proc/iomem은 I/O 메모리 맵(물리 주소 영역)을, cat /proc/ioports는 등록된 I/O 포트 영역을 보여 줍니다.
DMA API — 리눅스 커널 문서의 DMA API는 드라이버가 DMA용 메모리를 확보하고 CPU 캐시와의 일관성을 처리하는 방법을 설명합니다.
인터럽트 통계 — /proc/interrupts에서 장치별 인터럽트 횟수를 볼 수 있어, 인터럽트 구동 I/O가 얼마나 일어나는지 확인할 수 있습니다.

함께 보면 좋은 토픽

인터럽트 — I/O 완료를 알리는 인터럽트의 처리 과정을 봅니다.
버스 구조 — DMA와 CPU가 버스를 두고 경쟁하는 중재를 봅니다.
파일 시스템 — 디스크 I/O가 운영체제에서 어떻게 쓰이는지 봅니다.
디스크 스케줄링 — 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 방식의 특징은?

CPU가 계속 상태를 '물어보는' 방식입니다.
Programmed I/O는 CPU가 busy-waiting(polling)하며 입출력 장치의 상태를 반복 확인하기 때문에, 그동안 CPU가 다른 작업을 못 해 비효율적입니다.

Q2

인터럽트 기반 I/O가 Programmed I/O보다 나은 점은?

폴링 대신 '완료되면 알려줘' 방식입니다.
인터럽트 기반 I/O는 CPU가 입출력이 끝날 때까지 기다리지 않고 다른 작업을 하다가, 장치가 인터럽트로 완료를 알리면 처리합니다.

Q3

DMA 방식이 인터럽트 기반 I/O와 다른 점은?

'Direct Memory Access'라는 이름 그대로입니다.
인터럽트 기반 I/O는 완료 시점만 CPU에 알리지만 전송은 CPU가 하는 경우가 많고, DMA는 데이터 전송 자체를 컨트롤러가 대신 수행해 CPU 부담을 더 줄입니다(완료 시 인터럽트는 여전히 사용).