본문 바로가기

I/O와 DMA

장치 컨트롤러의 데이터·상태·제어 레지스터와 메모리 사상 I/O를 설명하고, CPU가 장치를 기다리는 방식인 폴링·인터럽트와, 장치와 메모리 사이의 복사를 컨트롤러에 맡기는 DMA를 비교합니다. 전송 크기에 따라 CPU가 쓰는 비용이 어떻게 달라지는지 계산합니다.

키보드는 사람이 누를 때마다 몇 바이트씩 불규칙하게 데이터를 보내고, SSD는 한 번에 수천 바이트의 블록을 빠르게 보냅니다. 이렇게 성격이 전혀 다른 장치들을 CPU가 모두 같은 방식으로 다룬다면, 어떤 장치에서는 쓸데없이 기다리고 어떤 장치에서는 따라가지 못하게 됩니다. CPU, 메모리와 바깥 장치 사이에서 데이터를 주고받는 일을 입출력(input/output, I/O)이라고 합니다. 이 글에서는 CPU가 장치와 대화하는 방법과, CPU의 일을 덜어 주는 DMA를 다룹니다.

장치 컨트롤러의 레지스터

장치는 보통 장치 컨트롤러(device controller)라는 회로를 통해 CPU와 연결됩니다. 컨트롤러에는 주고받을 데이터를 담는 데이터 레지스터, 장치가 준비되었는지나 오류가 났는지를 알려 주는 상태 레지스터, 장치에 할 일을 지시하는 제어 레지스터가 있습니다. 예를 들어 CPU는 상태 레지스터의 준비(ready) 비트를 보고 읽을 데이터가 있는지 판단한 뒤, 데이터 레지스터에서 값을 읽습니다.

CPU가 이 레지스터에 접근하는 방법은 두 가지입니다. 메모리 사상 I/O(memory-mapped I/O)는 장치의 레지스터를 주소 공간의 일부에 배치해, 일반 LOAD와 STORE로 읽고 씁니다. 포트 사상 I/O(port-mapped I/O)는 x86의 IN, OUT처럼 장치 전용 명령어와 별도의 주소 공간을 씁니다. 어느 쪽이든 장치의 레지스터는 일반 메모리와 다릅니다. 데이터 레지스터를 읽으면 준비 비트가 저절로 꺼지는 것처럼, 읽고 쓰는 일 자체가 장치의 상태를 바꿀 수 있기 때문입니다. 그래서 장치의 주소는 캐시에 담지 않도록 표시해 두고, 이 교재에서도 실제 하드웨어의 레지스터를 직접 다루는 대신 동작을 모델로 설명합니다.

CPU가 장치를 기다리는 두 가지 방법

폴링(polling)은 CPU가 상태 레지스터를 되풀이해 읽으며 장치가 준비될 때까지 확인하는 방법입니다. 만들기는 단순하지만, 데이터가 아직 없을 때도 확인하는 데 시간을 씁니다. 키보드처럼 데이터가 드물게 오는 장치라면 대부분의 확인이 헛수고가 되고, 확인 간격을 늘리면 그만큼 반응이 늦어집니다.

인터럽트(interrupt) 방식은 장치가 준비되었을 때 CPU에 신호를 보내 알리는 방법입니다. CPU는 기다리는 동안 다른 일을 하다가, 신호가 오면 하던 일을 잠시 멈추고 그 장치를 처리하는 코드로 갑니다. 기다리는 시간을 낭비하지 않는 대신, 신호를 받을 때마다 하던 일의 상태를 저장하고 되살리는 비용이 듭니다. 인터럽트가 처리되는 과정은 인터럽트와 예외에서 자세히 다룹니다.

DMA는 복사를 컨트롤러에 맡긴다

폴링이든 인터럽트든 CPU가 데이터를 한 바이트씩 데이터 레지스터에서 읽어 메모리에 쓴다면, 큰 블록을 옮길 때 CPU가 그 복사에 오래 묶여 있게 됩니다. DMA(direct memory access)는 CPU가 전송을 시작할 메모리 주소, 길이, 방향만 정해 주면 DMA 컨트롤러가 장치와 메모리 사이에서 데이터를 직접 옮기는 방법입니다. 전송이 끝나면 컨트롤러가 인터럽트로 완료를 알립니다.

CPU는 요청을 설정하고 컨트롤러는 데이터를 옮깁니다

CPU가 주소·길이·방향을 설정한다. 전송 완료를 CPU에 알린다. 각 단계의 값과 전환 이유를 비교합니다.

컴퓨터구조 · 읽는 순서

CPU는 요청을 설정하고 컨트롤러는 데이터를 옮깁니다

입력 방향의 DMA 모델입니다. 실선은 설정 또는 데이터 이동, 점선은 완료 알림입니다.

DMA 입력 전송CPU가 DMA를 설정하면 입력 장치의 데이터가 컨트롤러를 통해 메모리 버퍼로 이동하고 CPU에 완료를 알립니다.CPU설정·완료 처리DMA 컨트롤러전송 제어메모리전송 버퍼입력 장치데이터 제공점선: 완료 알림실선: CPU 설정과 장치→컨트롤러→메모리 이동
DMA 입력 전송CPU가 DMA를 설정하면 입력 장치의 데이터가 컨트롤러를 통해 메모리 버퍼로 이동하고 CPU에 완료를 알립니다.CPU설정·완료 처리DMA 컨트롤러전송 제어입력 장치데이터 제공메모리전송 버퍼점선: 완료 알림

CPU 설정 → 장치 데이터 전송 → 완료 확인 순서입니다. CPU가 바이트마다 직접 복사하지 않아도 준비·보호·일관성·오류 처리는 필요합니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 전송 설정부터 완료 알림까지 진행합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
전송 설정

CPU가 주소·길이·방향을 설정한다.

  • 장치·메모리 1: CPU→DMA 설정
  • DMA 준비
데이터 전송

DMA가 입력 장치와 메모리 사이의 전송을 수행한다.

  • 장치·메모리 1: 장치→DMA→메모리
  • 설정한 길이만큼 데이터 이동
완료 알림

전송 완료를 CPU에 알린다.

  • 장치·메모리 1: DMA→CPU 인터럽트
  • CPU가 완료 상태 확인

CPU는 요청을 설정하고 컨트롤러는 데이터를 옮깁니다

CPU가 주소·길이·방향을 설정한다. 전송 완료를 CPU에 알린다. 각 단계의 값과 전환 이유를 비교합니다.

컴퓨터구조 · 읽는 순서

CPU는 요청을 설정하고 컨트롤러는 데이터를 옮깁니다

입력 방향의 DMA 모델입니다. 실선은 설정 또는 데이터 이동, 점선은 완료 알림입니다.

DMA 입력 전송CPU가 DMA를 설정하면 입력 장치의 데이터가 컨트롤러를 통해 메모리 버퍼로 이동하고 CPU에 완료를 알립니다.CPU설정·완료 처리DMA 컨트롤러전송 제어메모리전송 버퍼입력 장치데이터 제공점선: 완료 알림실선: CPU 설정과 장치→컨트롤러→메모리 이동
DMA 입력 전송CPU가 DMA를 설정하면 입력 장치의 데이터가 컨트롤러를 통해 메모리 버퍼로 이동하고 CPU에 완료를 알립니다.CPU설정·완료 처리DMA 컨트롤러전송 제어입력 장치데이터 제공메모리전송 버퍼점선: 완료 알림

CPU 설정 → 장치 데이터 전송 → 완료 확인 순서입니다. CPU가 바이트마다 직접 복사하지 않아도 준비·보호·일관성·오류 처리는 필요합니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 전송 설정부터 완료 알림까지 진행합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
전송 설정

CPU가 주소·길이·방향을 설정한다.

  • 장치·메모리 1: CPU→DMA 설정
  • DMA 준비
데이터 전송

DMA가 입력 장치와 메모리 사이의 전송을 수행한다.

  • 장치·메모리 1: 장치→DMA→메모리
  • 설정한 길이만큼 데이터 이동
완료 알림

전송 완료를 CPU에 알린다.

  • 장치·메모리 1: DMA→CPU 인터럽트
  • CPU가 완료 상태 확인

그림처럼 CPU는 전송을 설정하고 완료 알림을 받는 일은 여전히 합니다. 그래서 "DMA를 쓰면 CPU가 전혀 관여하지 않는다"는 말은 정확하지 않습니다. 오류를 처리하고, 전송할 메모리 영역을 보호하고, 캐시에 남은 데이터와 메모리의 데이터가 어긋나지 않게 하는 일도 필요합니다. 예를 들어 CPU가 write-back 캐시에만 써 둔 데이터를 DMA가 메모리에서 읽어 가면 옛 값을 보내게 되는데, 실제 운영체제가 이런 문제를 어떻게 다루는지는 Linux 커널의 DMA API 문서에 있습니다. 오래된 대형 컴퓨터에서는 여러 전송을 프로그램처럼 이어서 처리하는 I/O 채널(I/O channel)이라는 전용 장치를 쓰기도 했습니다.

더 깊이: 캐시 블록을 소프트웨어가 직접 다루는 명령

하드웨어가 캐시와 장치 사이의 일관성을 맞춰 주지 않는 시스템에서는, 소프트웨어가 캐시에 명령을 내려 그 차이를 메워야 합니다. RISC-V의 캐시 관리 명령 규격의 Zicbom 확장은 캐시 블록 하나에 작용하는 명령 셋을 둡니다. cbo.clean은 블록이 저장 명령으로 고쳐졌다면 그 내용을 다른 캐시나 메모리로 써 보내고, cbo.inval은 블록의 복사본을 캐시에서 없애며, cbo.flush는 clean과 invalidate를 한 동작으로 차례로 합니다.

쓰는 방향에 따라 필요한 명령이 다릅니다. CPU가 write-back 캐시에 써 둔 데이터를 장치가 DMA로 읽어 가기 전에는 clean으로 그 블록을 메모리에 내보내야, 장치가 앞에서 말한 옛 값을 읽지 않습니다. 반대로 장치가 메모리에 쓴 데이터를 CPU가 읽기 전에는 invalidate로 캐시에 남은 옛 복사본을 없애야 메모리에서 새 값을 가져옵니다.

다만 invalidate는 아직 메모리에 쓰이지 않은 수정까지 버리므로 위험할 수 있습니다. 규격은 권한이 낮은 소프트웨어가 invalidate를 하면 메모리에 남은 옛 데이터가 드러나 보안 구멍이 될 수 있다고 경고합니다. 그래서 더 높은 권한의 소프트웨어가 설정 레지스터로 cbo.inval을 아예 막거나, 낮은 권한에서는 flush로 동작하게 바꿀 수 있습니다.

전송 크기에 따라 달라지는 비용

CPU가 바이트마다 2사이클을 들여 직접 복사하고, DMA는 요청 한 번에 설정과 완료 처리로 CPU가 50사이클을 쓴다고 해 봅시다. 같은 800바이트라도 작은 요청 여러 번으로 나누느냐 큰 블록 한 번으로 보내느냐에 따라 결과가 달라집니다.

def cpu_copy_cycles(size, per_byte=2):
    return size * per_byte


def dma_cycles(requests, setup=50):
    return requests * setup


for label, sizes in (("8바이트 100번", [8] * 100), ("800바이트 1번", [800])):
    copy = sum(cpu_copy_cycles(n) for n in sizes)
    dma = dma_cycles(len(sizes))
    print(f"{label}: CPU 복사 {copy}사이클, DMA {dma}사이클")
print(min(n for n in range(1, 100) if cpu_copy_cycles(n) > dma_cycles(1)))
8바이트 100번: CPU 복사 1600사이클, DMA 5000사이클
800바이트 1번: CPU 복사 1600사이클, DMA 50사이클
26

CPU가 직접 복사하면 나누어 보내든 한 번에 보내든 800바이트에 1600사이클이 듭니다. DMA는 요청마다 설정 비용이 들기 때문에, 8바이트씩 100번 보내면 5000사이클로 오히려 직접 복사보다 비싸고, 한 번에 보내면 50사이클로 훨씬 쌉니다. 마지막 줄은 이 가정에서 DMA가 직접 복사보다 CPU를 덜 쓰기 시작하는 가장 작은 크기가 26바이트라는 계산입니다. 그래서 작은 데이터를 드문드문 보내는 장치에는 인터럽트와 직접 복사가, 큰 블록을 보내는 장치에는 DMA가 알맞습니다.

여기서 센 것은 CPU가 전송에 쓰는 사이클일 뿐, 전송이 끝나기까지 걸리는 전체 시간은 아닙니다. DMA가 데이터를 옮기는 동안에도 버스와 장치의 시간이 들고, CPU는 그동안 다른 일을 할 수 있습니다. 전체 시간을 알려면 장치의 속도와 버스를 함께 쓰는 다른 전송까지 따져야 합니다.

정리

  • 장치 컨트롤러는 데이터, 상태, 제어 레지스터로 CPU와 대화하며, 메모리 사상 I/O는 이 레지스터를 주소 공간에, 포트 사상 I/O는 별도의 공간에 둡니다.
  • 장치의 레지스터는 읽고 쓰는 일이 장치의 상태를 바꿀 수 있어 일반 메모리처럼 다루면 안 되며, 캐시에 담지 않습니다.
  • 폴링은 CPU가 상태를 되풀이해 확인하고, 인터럽트는 장치가 준비되면 CPU에 알리며, 각각 헛된 확인과 상태 저장의 비용이 있습니다.
  • DMA는 CPU가 주소, 길이, 방향을 설정하면 컨트롤러가 장치와 메모리 사이의 복사를 맡고, 끝나면 인터럽트로 알립니다.
  • DMA는 요청마다 설정 비용이 있어 큰 전송에 알맞으며, CPU가 쓰는 비용과 전송 전체의 시간은 서로 다른 값입니다.

연습 문제

1. 폴링으로 감당할 수 있을까

1 GHz CPU가 폴링 한 번에 400사이클을 씁니다. 키보드를 1초에 100번 폴링하면 CPU 시간의 몇 %를 쓰나요? 초당 500 MB를 보내는 장치를 4바이트마다 폴링해야 한다면 어떻게 되나요?

해설 보기

키보드는 1초에 100 × 400 = 40000사이클을 쓰고, CPU는 1초에 10910^9사이클을 실행하므로 0.004%입니다. 거의 부담이 없으므로 폴링으로도 충분합니다. 500 MB/s 장치는 1초에 500×106/4=1.25×108500\times10^6/4=1.25\times10^8번 폴링해야 하므로 5×10105\times10^{10}사이클이 필요한데, 이것은 CPU가 1초에 실행할 수 있는 사이클의 50배라서 불가능합니다. 이런 장치에는 DMA로 큰 블록을 한 번에 옮기고 끝났을 때만 인터럽트로 알리게 해야 합니다.

2. 캐시와 DMA

CPU가 보낼 데이터를 write-back 캐시가 있는 메모리 영역에 쓴 뒤 곧바로 DMA로 그 영역을 장치에 보내게 했습니다. 장치에 옛 데이터가 갈 수 있는 이유와 막는 방법을 설명하세요.

해설 보기

write-back 캐시는 고친 값을 캐시에만 두고 더티 비트를 켜 두었다가 나중에 메모리에 쓰므로, CPU가 쓴 직후 메모리에는 아직 옛 값이 있을 수 있습니다. DMA 컨트롤러가 캐시를 거치지 않고 메모리에서 바로 읽으면 그 옛 값을 장치에 보냅니다. 이를 막으려면 DMA를 시작하기 전에 그 영역의 캐시 라인을 메모리에 내보내거나, 그 영역을 캐시에 담지 않도록 하거나, 캐시와 DMA가 서로의 변경을 알아차리는 하드웨어를 씁니다. 운영체제의 DMA 인터페이스가 이런 처리를 맡습니다.

3. 상태 레지스터를 캐시에 담으면

메모리 사상 I/O에서 장치의 상태 레지스터 주소를 일반 메모리처럼 캐시에 담았다면 폴링에 어떤 문제가 생기나요?

해설 보기

처음 읽은 상태 값이 캐시에 남으면, 그 뒤로는 캐시에 적중해 같은 값만 되풀이해 읽게 됩니다. 장치가 준비되어 실제 상태 레지스터의 준비 비트가 1로 바뀌어도 CPU는 캐시의 옛 값 0만 보므로, 폴링이 끝나지 않습니다. 상태 레지스터는 CPU가 쓰지 않아도 장치가 바꾸는 값이라 캐시의 가정과 맞지 않습니다. 그래서 장치의 주소 영역은 캐시하지 않도록 표시하고, 프로그램에서도 그 값을 매번 실제로 읽도록 해야 합니다.

장치와 데이터를 주고받는 방법을 고를 때는 데이터가 얼마나 자주, 얼마나 크게 오는지와 CPU가 그동안 무엇을 할 수 있는지를 함께 따져 보세요. 다음 글 버스·핸드셰이크·중재에서는 CPU, 메모리, 장치를 잇는 통로를 여럿이 함께 쓰는 규칙을 다룹니다.