I/O 하드웨어와 소프트웨어
컴퓨터는 CPU와 메모리만으로는 아무것도 할 수 없습니다. 키보드로 입력을 받고, 화면에 출력하고, 디스크에 저장하고, 네트워크로 통신해야 합니다. 이 모든 것이 I/O(Input/Output)이며, OS가 다양한 장치들을 통일된 방식으로 관리합니다. I/O 서브시스템은 OS에서 가장 복잡한 부분이기도 합니다. Linux 커널 소스의 절반 이상이 디바이스 드라이버입니다.
I/O 장치의 분류
I/O 장치는 크게 두 가지로 나뉩니다.
블록 장치(Block Device)는 데이터를 고정 크기의 블록 단위로 읽고 씁니다. 하드 디스크, SSD, USB 메모리가 여기에 속합니다. 블록 단위로 임의 접근(Random Access)이 가능합니다.
문자 장치(Character Device)는 데이터를 바이트 스트림으로 전달합니다. 키보드, 마우스, 시리얼 포트, 프린터가 여기에 속합니다. 임의 접근이 불가능하고, 순차적으로 데이터가 흐릅니다.
이 분류가 완벽한 것은 아닙니다. 네트워크 장치는 블록도 문자도 아닌 독자적인 인터페이스(소켓)를 사용합니다. 타이머, 그래픽 카드, GPU도 단순한 분류에 들어맞지 않습니다.
#include <stdio.h>
#include <sys/stat.h>
int main() {
struct stat st;
const char *devices[] = {"/dev/sda", "/dev/tty0", "/dev/null"};
const char *names[] = {"sda(디스크)", "tty0(터미널)", "null(특수)"};
for (int i = 0; i < 3; i++) {
if (stat(devices[i], &st) == 0) {
const char *type = S_ISBLK(st.st_mode) ? "블록" :
S_ISCHR(st.st_mode) ? "문자" : "기타";
printf("%-15s → %s 장치 (major=%d, minor=%d)\n",
names[i], type,
(int)(st.st_rdev >> 8), (int)(st.st_rdev & 0xFF));
}
}
return 0;
}major 번호는 드라이버를 식별하고, minor 번호는 같은 드라이버가 관리하는 개별 장치를 식별합니다. ls -l /dev/sda에서 8, 0이 보이면 major=8(SCSI 디스크), minor=0(첫 번째 디스크)입니다.
I/O 컨트롤러
CPU가 I/O 장치와 직접 통신하지는 않습니다. 중간에 I/O 컨트롤러(I/O Controller)가 있습니다. 컨트롤러는 장치의 물리적 동작을 담당하며, CPU는 컨트롤러의 레지스터를 통해 명령을 내리고 상태를 확인합니다.
컨트롤러에는 보통 세 가지 레지스터가 있습니다.
- 데이터 레지스터(Data Register): 전송할 데이터를 담습니다. 보통 1~4바이트 크기입니다.
- 상태 레지스터(Status Register): 장치가 바쁜지(busy), 준비됐는지(ready), 오류가 있는지(error) 등을 알려줍니다.
- 명령 레지스터(Command Register): CPU가 장치에 내리는 명령(읽기, 쓰기, 리셋 등)입니다.
큰 데이터 전송을 위해 컨트롤러에 데이터 버퍼가 있기도 합니다. 디스크 컨트롤러의 내부 버퍼는 한 트랙 분량의 데이터를 담을 수 있습니다.
메모리 맵 I/O와 포트 맵 I/O
CPU가 컨트롤러의 레지스터에 접근하는 방식은 두 가지입니다.
포트 맵 I/O(Port-mapped I/O, PMIO): I/O 장치 전용 주소 공간을 사용합니다. x86의 in/out 어셈블리 명령어가 이 방식입니다. I/O 주소(0x0000~0xFFFF)와 메모리 주소가 완전히 분리되어 있습니다.
/* x86 포트 맵 I/O 예시 (커널 공간에서만 사용 가능) */
#include <sys/io.h>
/* 커널 모드에서 직접 포트 접근 */
/* outb(value, port): 포트에 1바이트 쓰기 */
/* inb(port): 포트에서 1바이트 읽기 */
/* 예: PIT(Programmable Interval Timer) 포트 */
/* outb(0x36, 0x43); 명령 레지스터에 모드 설정 */
/* outb(low, 0x40); 카운터 하위 바이트 */
/* outb(high, 0x40); 카운터 상위 바이트 */메모리 맵 I/O(Memory-mapped I/O, MMIO): 장치 레지스터를 메모리 주소 공간에 매핑합니다. 일반적인 mov 명령으로 장치를 제어할 수 있습니다.
/* 메모리 맵 I/O 예시 (임베디드/ARM) */
#include <stdint.h>
/* GPIO 레지스터가 물리 주소 0x3F200000에 매핑된 경우 */
#define GPIO_BASE 0x3F200000
#define GPIO_SET (GPIO_BASE + 0x1C)
#define GPIO_CLR (GPIO_BASE + 0x28)
/* volatile: 컴파일러 최적화로 메모리 접근을 생략하지 않게 */
volatile uint32_t *gpio_set = (volatile uint32_t *)GPIO_SET;
/* *gpio_set = (1 << 17); → GPIO 17번 핀 HIGH */MMIO의 장점: C 포인터로 접근 가능, 별도의 명령어 불필요. 단점: 주소 공간을 소비하고, 캐시를 비활성화해야 합니다(장치 레지스터를 캐싱하면 갱신된 상태를 못 읽음). 현대 시스템은 대부분 MMIO를 사용합니다.
디바이스 드라이버
OS와 하드웨어 사이에 디바이스 드라이버(Device Driver)가 위치합니다. 드라이버는 특정 장치의 컨트롤러와 통신하는 방법을 아는 소프트웨어입니다.
OS 커널은 드라이버에게 일관된 인터페이스를 요구합니다.
/* Linux 문자 디바이스 드라이버의 인터페이스 (개념 코드) */
struct file_operations {
int (*open)(struct inode *, struct file *);
ssize_t (*read)(struct file *, char *, size_t, loff_t *);
ssize_t (*write)(struct file *, const char *, size_t, loff_t *);
int (*release)(struct inode *, struct file *);
long (*ioctl)(struct file *, unsigned int, unsigned long);
/* ... */
};
/* 모든 문자 디바이스는 위 함수 포인터를 구현해야 함 */
/* 사용자 프로그램: open("/dev/mydevice", ...) → 커널 → 드라이버의 open() */이 구조 덕분에 사용자 프로그램은 open(), read(), write(), close()만 알면 되고, 장치가 HDD인지 SSD인지 네트워크인지 신경 쓸 필요가 없습니다. 새 장치를 지원할 때도 드라이버만 추가하면 됩니다.
드라이버와 커널 모드
드라이버는 커널 공간에서 실행됩니다. 버그가 있는 드라이버는 시스템 전체를 크래시시킬 수 있습니다(커널 패닉/블루스크린). Windows 블루스크린의 상당수가 서드파티 드라이버 문제입니다.
마이크로커널 설계는 드라이버를 사용자 공간에서 실행하여 안정성을 높이려 합니다. MINIX 3, QNX가 이 방식입니다. 하지만 성능 오버헤드가 있어 범용 OS에서는 모노리식 커널(드라이버를 커널에 포함)이 주류입니다.
I/O 소프트웨어 계층
I/O 소프트웨어는 여러 계층으로 구성됩니다.
| 계층 | 역할 | 예시 |
|---|---|---|
| 사용자 프로그램 | 라이브러리 함수 호출 | printf, fread |
| 장치 독립 소프트웨어 | 일관된 인터페이스 제공, 버퍼링, 에러 리포트 | VFS, 버퍼 캐시 |
| 디바이스 드라이버 | 장치별 명령 변환 | e1000, nvme |
| 인터럽트 핸들러 | 인터럽트 처리 | ISR |
| 하드웨어 | 물리적 I/O 수행 | 디스크 컨트롤러 |
사용자가 printf("hello")를 호출하면: printf → write() 시스템 콜 → 장치 독립 계층(버퍼링) → tty 드라이버 → 시리얼/디스플레이 컨트롤러 → 화면 출력. 각 계층이 자기 역할만 담당하므로 유지보수가 용이합니다.
다음 절에서는 CPU가 I/O를 처리하는 세 가지 방식 — 폴링, 인터럽트, DMA를 비교합니다.