[OS] I/O Devices 정리 – 인터럽트, DMA, 디바이스 드라이버까지 한 번에
0. 왜 I/O가 중요한가?
- 입력이 없으면 프로그램은 매번 똑같이 동작합니다.
- 출력이 없으면 프로그램이 뭘 했는지 알 수 없습니다.
- 그래서 운영체제 입장에서는 “CPU + 메모리 + I/O 장치”를 함께 묶어서 관리하는 게 핵심입니다.
핵심 질문(CRUX)
- 어떻게 I/O 장치를 전체 시스템에 효율적으로 통합할 것인가?
- 어떻게 CPU 시간을 덜 쓰면서도 느린 장치들을 잘 활용할 수 있을까?
1. 시스템 아키텍처 – 버스 계층 구조
1-1. 고전적인 구조 (Figure 36.1)
책에서는 CPU–메모리–I/O가 계층형 버스 구조로 연결된 그림을 보여줍니다. (메모리 버스 > 일반 I/O 버스(PCI) > 주변기기 버스(SCSI, SATA, USB))
- Memory Bus : CPU와 DRAM이 붙어 있는 가장 빠른 버스
- General I/O Bus (PCI 등) : 그래픽 카드 같은 고성능 I/O 장치
- Peripheral Bus (SCSI, SATA, USB) : 디스크, 키보드, 마우스 등 느린 장치들
왜 계층형인가?
- 버스가 빠를수록 물리적으로 짧아야 하고, 설계·제작 비용이 비쌉니다.
- 그래서 고성능 장치는 CPU 가까이, 저성능 장치는 먼 쪽 버스에 둡니다.
1-2. 현대 시스템 예시 – Intel Z270 (Figure 36.2)
현대 시스템에서는 CPU와 메모리, 그래픽, I/O 칩이 DMI, PCIe 등으로 연결됩니다.
- CPU ↔ 메모리 : 가장 빠른 경로
- CPU ↔ GPU : 고대역폭 연결 (게임, 그래픽 처리)
- CPU ↔ I/O Chip (DMI) : 나머지 디바이스들은 I/O 칩 밑에 매달림
- I/O Chip 아래 :
- eSATA : 디스크
- USB : 키보드/마우스 같은 저속 장치
- PCIe : 네트워크 카드, 고성능 NVMe SSD 등
기억 포인트
- CPU에 가까울수록 빠르고 비싸고 개수가 적다.
- 멀수록 느리고 싸고 많이 붙는다.
2. Canonical Device – 디바이스의 정석 구조
2-1. 디바이스가 제공하는 두 가지
Figure 36.3에서는 “정석적인 장치”를 두 부분으로 나눠 설명합니다.
- 인터페이스 (Interface)
- OS가 만지는 부분 – 레지스터들
- 예:
Status,Command,Data레지스터
- 내부 구조 (Internals)
- 마이크로컨트롤러(CPU), 메모리(DRAM/SRAM), 칩들
- 장치가 약속한 추상화를 실제로 구현하는 부분
- 복잡한 RAID 컨트롤러 같은 경우 내부에 수십만 줄 펌웨어가 돌아갈 수 있음
2-2. Canonical Protocol – OS가 장치랑 이야기하는 패턴
// 1. 장치가 바쁘지 않을 때까지 대기 (polling)
while (STATUS == BUSY)
;
// 2. 데이터 전송
write(DATA_REGISTER, data);
// 3. 커맨드 전송 (작업 시작)
write(COMMAND_REGISTER, command);
// 4. 완료될 때까지 다시 polling
while (STATUS == BUSY)
;
- 이 방식은 Programmed I/O (PIO)라고 부릅니다.
- CPU가 직접 레지스터에 데이터를 계속 써주기 때문에, CPU가 너무 바쁩니다.
- 또한 상태 레지스터를 계속 읽어보는 polling 때문에 CPU 시간이 낭비됩니다.
이슈 정리
- CPU가 장치의 “알바”처럼 데이터 전송 & 상태 확인을 다 떠맡음
- 느린 장치일수록 CPU는 더 오래 기다리게 됨
3. Polling vs Interrupt – CPU 시간을 아끼는 방법
3-1. Polling의 문제점
- OS가 상태 레지스터를 계속 읽어보며 “다 했어?”만 물어봄
- 디스크처럼 느린 장치일수록 CPU는 놀면서 busy-wait
- 다른 프로세스를 돌릴 수 있는 CPU 시간을 낭비
3-2. 인터럽트로 해결하기
문제의 핵심 질문:
어떻게 장치 상태를 자주 polling하지 않고도, I/O 완료를 알 수 있을까?
인터럽트 기반 흐름
- 프로세스가 I/O 요청 → OS가 장치에 요청을 보냄
- 요청한 프로세스를 sleep 시킴
- OS는 다른 프로세스에게 CPU를 넘김 (context switch)
- 장치 작업 완료 시, 하드웨어 인터럽트 발생
- CPU는 OS의 ISR(Interrupt Service Routine)으로 점프
- ISR이 결과/에러코드 처리 후, 잠자던 프로세스를 깨움
장점
- CPU와 디스크가 동시에 일을 할 수 있어 자원 활용도 향상
- 느린 장치일수록 효과가 큼
3-3. 인터럽트가 항상 좋은 건 아니다
책에서 강조하는 중요한 포인트:
- 장치가 너무 빠를 때 : 그냥 한두 번 polling 하면 끝나는데,
굳이 context switch + 인터럽트 처리 오버헤드를 감당할 필요가 없음. - 네트워크 폭주 상황 : 패킷마다 인터럽트가 들어오면 OS가 인터럽트 처리만 하다가 user 프로세스는 못 도는 livelock 위험.
- 해결책
- polling과 interrupt를 섞어서(hybrid) 사용
- interrupt coalescing – 여러 완료를 한 번에 모아서 인터럽트 한 번으로 처리
기억 포인트
- 느린 장치 → 인터럽트 유리
- 빠른 장치나 폭주 상황 → polling 혹은 하이브리드 전략이 더 효율적일 수 있음
4. DMA – 데이터 복사를 CPU 대신해주는 친구
4-1. PIO의 또 다른 문제
- 대량의 데이터를 디스크로 보내려면 CPU가 데이터를 한 워드씩 직접 복사
- 이 동안 CPU는 “복사 머신”이 되어버림
4-2. DMA(Direct Memory Access)의 아이디어
질문 : 데이터 복사 같은 단순 작업을, CPU 말고 다른 애가 대신해 줄 수 없을까?
DMA 엔진의 역할
- OS가 DMA 컨트롤러에게 다음 정보를 설정
- 메모리에서 어디서부터 읽을지 (주소)
- 얼마나 복사할지 (크기)
- 어떤 장치로 보낼지 (목적지)
- 설정 후, OS는 다른 작업 수행 (CPU는 자유)
- DMA 엔진이 메모리 ↔ 장치 사이의 데이터 복사를 직접 수행
- 복사가 끝나면 DMA 컨트롤러가 인터럽트 발생
- OS는 “복사 완료”를 확인하고 다음 단계 진행
효과
- CPU는 복사 작업에서 해방 → 다른 프로세스 실행
- 대량 I/O에서 특히 이득이 큼
5. 장치와 통신하는 두 가지 방법 – I/O 명령 vs 메모리 매핑
5-1. I/O 전용 명령어 (Port I/O)
- x86의
in,out명령처럼, 별도의 I/O 명령어로 장치 레지스터에 접근 out port, value형태로 특정 포트(주소)에 값 쓰기- 대부분 privileged instruction이어서 OS만 직접 사용 가능
5-2. Memory-mapped I/O
- 장치 레지스터가 일부 메모리 주소 공간에 “매핑”됩니다.
- OS 입장에서는 그냥
load/store명령으로 메모리 읽고 쓰듯이 접근 - 하드웨어가 해당 주소를 캐치해서 메모리가 아니라 장치로 라우팅
정리
- 둘 다 여전히 사용 중이며, 절대적인 우열은 없음
- memory-mapped I/O는 새 명령어가 필요 없다는 점이 장점
6. Device Driver – 장치 특성을 OS에 숨기는 레이어
6-1. 문제 정의
파일 시스템 같은 상위 레이어는 “SCSI냐, SATA냐, USB냐”를 신경 쓰고 싶지 않다.
그래서 OS는 추상화를 사용합니다.
- Device Driver : 특정 장치와 실제로 통신하는 코드
- 레지스터 주소, 프로토콜, 에러 처리 등 “지저분한 상세”를 캡슐화
- 위쪽의 파일 시스템 / 앱은
open / read / write / close같은 POSIX API만 사용- 그 아래의 Generic Block Layer는 “블록 읽기/쓰기”만 신경
책의 Figure 36.4는 다음과 같은 스택을 보여줍니다.
- Application
- File System / Raw Interface
- Generic Block Layer
- Device Driver (SCSI, ATA, …)
6-2. Raw Interface
- 파일 시스템을 거치지 않고, 블록을 직접 읽고 쓰는 인터페이스
- fsck, 디스크 조각 모음 같은 툴이 사용
6-3. Device Driver의 그림자
- Linux 기준, 커널 코드의 70% 이상이 드라이버 코드라는 연구 결과
- 많은 드라이버가 “전업 커널 개발자”가 아닌 사람이 작성 → 버그 많음
- 실제로 커널 크래시의 주요 원인 중 하나가 디바이스 드라이버
7. IDE Disk Driver 예시 – 진짜 프로토콜 맛보기
7-1. IDE 디스크 레지스터
책에서는 IDE 디스크의 레지스터 맵을 보여줍니다.
- Control Register (0x3F6) : 리셋, 인터럽트 활성화 등
- Command Block Registers (0x1F0 ~ 0x1F7)
0x1F0: 데이터 포트0x1F1: 에러 레지스터0x1F2: 섹터 수0x1F3 ~ 0x1F5: LBA 주소0x1F6: 드라이브 선택, 상위 LBA0x1F7: 커맨드 / 상태
- Status Register (0x1F7) : BUSY, READY, ERROR 비트 등
- Error Register (0x1F1) : 어떤 에러인지 상세 코드
7-2. 기본 I/O 프로토콜 요약
- 상태 레지스터에서 READY & not BUSY 될 때까지 기다림
- 섹터 수, LBA, 드라이브 번호 등 파라미터를 각 레지스터에 기록
- READ / WRITE 명령어를 상태/커맨드 레지스터(0x1F7)에 기록
- 쓰기의 경우 : READY & DRQ(데이터 요청) 비트를 확인 후 데이터 포트에 데이터 전송
- 인터럽트 처리 :
- 섹터 단위 또는 여러 섹터 완료 후 한 번에 처리
- 에러 처리 :
- STATUS의 ERROR 비트가 켜져 있으면 ERROR 레지스터 읽기
7-3. xv6 IDE 드라이버 코드 구조
Figure 36.6에는 xv6의 매우 단순화된 IDE 드라이버 코드가 나옵니다.
ide_wait_ready(): 상태 레지스터를 polling하여 READY & not BUSY가 될 때까지 대기ide_start_request(b):- 레지스터 세팅 (섹터 수, LBA, 드라이브 등)
- WRITE인 경우 데이터도 함께 전송
ide_rw(b):- 락을 잡고 큐에 요청 추가
- 큐가 비어 있다면 바로
ide_start_request()호출 - 요청이 완료될 때까지 sleep
ide_intr():- 인터럽트가 들어오면, READ의 경우 데이터 읽기
- 버퍼 플래그 갱신 후, 잠자던 프로세스 깨우기
- 다음 요청이 있다면 다시
ide_start_request()로 다음 I/O 시작
요약 : 디바이스 드라이버는 “요청 큐 관리 + 레지스터 프로토콜 구현 + 인터럽트 처리”를 담당합니다.
8. 최종 요약 – 이 장에서 꼭 기억할 것
8-1. 한 장 요약
- 계층형 버스 구조 : CPU에 가까울수록 빠르고, 멀수록 느리고 많이 붙는다.
- Canonical Device :
- 인터페이스(레지스터) + 내부 구조(컨트롤러, 메모리)
- PIO + polling은 단순하지만 비효율적
- 인터럽트 :
- CPU와 I/O를 겹쳐서 쓸 수 있게 해줌
- 하지만 너무 빠른 장치나 폭주 상황에서는 오히려 문제 → hybrid, coalescing
- DMA :
- 대량 데이터 복사를 장치 전용 엔진이 수행
- CPU는 다른 프로세스를 실행할 수 있음
- 장치와 통신하는 방법 :
- I/O 명령어 (in/out)
- Memory-mapped I/O (load/store)
- Device Driver :
- 장치별 상세 프로토콜을 캡슐화
- 위쪽(file system, app)은 “블록 읽기/쓰기” 같은 추상 인터페이스만 사용
- 커널 코드의 상당 부분을 차지하고, 버그의 주요 원인
8-2. 시험/면접용 한 줄 정리
- 인터럽트 : “느린 I/O 동안 CPU를 놀리지 않기 위한 메커니즘”
- DMA : “대량 데이터 복사를 CPU 대신해주는 전용 컨트롤러”
- Device Driver : “장치별 프로토콜을 숨기고, OS 나머지를 장치-독립적으로 만들어주는 모듈”