I/O 하드웨어와 소프트웨어
장치 파일·컨트롤러·레지스터·디바이스 드라이버가 다양한 입출력 장치를 공통 인터페이스로 연결하는 구조를 이해합니다.
컴퓨터는 CPU와 메모리만으로는 아무것도 할 수 없습니다.
키보드로 입력을 받고, 화면에 출력하고, 디스크에 저장하고, 네트워크로 통신해야 합니다.
이 모든 것이 I/O(Input/Output)이며, OS가 다양한 장치들을 통일된 방식으로 관리합니다.
I/O 서브시스템은 OS에서 가장 복잡한 부분이기도 합니다.
Linux는 다양한 장치별 드라이버를 포함합니다. 드라이버 소스 비중은 버전과 집계 기준에 따라 달라집니다.
I/O 장치의 분류
I/O 장치는 크게 두 가지로 나뉩니다.
블록 장치(Block Device)는 데이터를 고정 크기의 블록 단위로 읽고 씁니다.
하드 디스크, SSD, USB 메모리가 여기에 속합니다.
블록 단위로 임의 접근(Random Access)이 가능합니다.
문자 장치(Character Device)는 데이터를 바이트 스트림으로 전달합니다.
키보드, 마우스, 시리얼 포트, 프린터가 여기에 속합니다.
터미널·시리얼처럼 순차 스트림을 제공하는 경우가 많지만, 모든 문자 장치에서 seek가 금지되는 것은 아닙니다. 지원 연산은 드라이버에 따릅니다.
이 분류가 완벽한 것은 아닙니다.
네트워크 장치는 블록도 문자도 아닌 독자적인 인터페이스(소켓)를 사용합니다.
타이머, 그래픽 카드, GPU도 단순한 분류에 들어맞지 않습니다.
#include <stdio.h>
#include <sys/stat.h>
#include <sys/sysmacros.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)major(st.st_rdev), (int)minor(st.st_rdev));
}
}
return 0;
}Linux의 dev_t는 단순한 상위·하위 8비트 분할이 아니므로 major()/minor()를 사용합니다. 예제의 장치 경로는 시스템에 없을 수 있으며 stat에 성공한 항목만 표시합니다.
major 번호는 장치 종류에 대한 커널 등록을 식별하고, minor 번호는 같은 드라이버가 관리하는 개별 장치를 식별합니다.
ls -l /dev/sda에서 8, 0이 보이면 major=8(SCSI 디스크), minor=0(첫 번째 디스크)입니다.
장치 파일 이름은 사람이 보는 입구이고, major/minor 번호는 커널이 어떤 드라이버와 장치 인스턴스로 넘길지 정하는 실마리입니다.
이 관계를 먼저 잡아두면 뒤에서 나오는 컨트롤러와 드라이버의 경계가 더 자연스럽게 이어집니다.
문자 장치 요청의 개념 경로입니다. major/minor는 커널 등록을 찾는 단서이고, 지원 연산과 실제 레지스터 접근은 드라이버에 따릅니다. 블록·네트워크 경로는 동일한 문자 장치 등록 경로가 아닙니다.
I/O 컨트롤러
CPU가 I/O 장치와 직접 통신하지는 않습니다.
중간에 I/O 컨트롤러(I/O Controller)가 있습니다.
컨트롤러는 장치의 물리적 동작을 담당하며, CPU는 컨트롤러의 레지스터를 통해 명령을 내리고 상태를 확인합니다.
컨트롤러에는 보통 세 가지 레지스터가 있습니다.
- 데이터 레지스터(Data Register): 전송할 데이터를 담습니다. 폭과 의미는 컨트롤러의 규격에 따릅니다.
- 상태 레지스터(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)와 메모리 주소가 완전히 분리되어 있습니다.
/* Linux x86 포트 I/O 표기 예시: 실제 포트 쓰기는 실행하지 않음 */
#include <sys/io.h>
/* sys/io.h는 사용자 공간 인터페이스이며 실제 접근에는 I/O 권한 설정 필요 */
/* 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): 장치 레지스터를 메모리 주소 공간에 매핑합니다.
CPU의 메모리 접근 명령을 사용하지만 장치에 맞는 매핑 속성·접근 폭·순서를 지켜야 합니다.
/* 메모리 맵 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 */이 GPIO 조각은 해당 물리 주소가 직접 접근 가능하도록 구성된 임베디드 환경의 개념 예시입니다. Linux 사용자 공간에서 물리 주소를 그대로 역참조하는 실행 코드가 아니며 보드별 주소도 다릅니다.
MMIO는 메모리 접근 방식을 쓰지만 volatile만으로 CPU·장치 간 순서나 캐시 일관성을 보장하지 않습니다.
장치 레지스터에는 적절한 비캐시 매핑 등이 필요합니다. 프레임버퍼의 write-combining처럼 영역별 속성이 다를 수 있으며, Linux 드라이버에서는 ioremap과 readl/writel 등 접근 API를 사용합니다.
현대 시스템은 대부분 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 (*unlocked_ioctl)(struct file *, unsigned int, unsigned long);
/* ... */
};
/* 실제 file_operations의 일부만 표현; 필요한 연산만 구현함 */
/* 사용자 프로그램: open("/dev/mydevice", ...) → 커널 → 드라이버의 open() */공통 파일 API가 장치 세부를 추상화하지만 지원 연산과 오류·블로킹 동작은 대상에 따라 다릅니다. 네트워크 연결 설정이나 장치별 ioctl처럼 별도 API도 필요합니다.
새 장치를 지원할 때도 드라이버만 추가하면 됩니다.
드라이버와 커널 모드
일반적인 Linux 장치 드라이버는 커널 공간에서 실행됩니다. 사용자 공간 드라이버도 있으므로 모든 OS·장치에 대한 절대적인 규칙은 아닙니다.
버그가 있는 드라이버는 시스템 전체를 크래시시킬 수 있습니다(커널 패닉/블루스크린).
드라이버 장애는 커널과 자원을 공유하는 범위에 영향을 줄 수 있습니다.
마이크로커널 설계는 드라이버를 사용자 공간에서 실행하여 안정성을 높이려 합니다.
MINIX 3, QNX가 이 방식입니다.
하지만 성능 오버헤드가 있어 범용 OS에서는 모노리식 커널(드라이버를 커널에 포함)이 주류입니다.
I/O 소프트웨어 계층
I/O 소프트웨어는 여러 계층으로 구성됩니다.
| 계층 | 역할 | 예시 |
|---|---|---|
| 사용자 프로그램 | 라이브러리 함수 호출 | printf, fread |
| 장치 독립 소프트웨어 | 일관된 인터페이스 제공, 버퍼링, 에러 리포트 | VFS, 버퍼 캐시 |
| 디바이스 드라이버 | 장치별 명령 변환 | e1000, nvme |
| 인터럽트 핸들러 | 인터럽트 처리 | ISR |
| 하드웨어 | 물리적 I/O 수행 | 디스크 컨트롤러 |
printf는 먼저 사용자 공간의 stdio 버퍼에 기록할 수 있습니다. flush 후 write 대상이 터미널·파일·파이프 중 무엇인지에 따라 경로가 달라집니다. GUI 터미널도 tty에서 디스플레이 컨트롤러로 곧바로 이어지는 단일 경로가 아닙니다.
각 계층이 자기 역할만 담당하므로 유지보수가 용이합니다.
이 절의 핵심은 사용자 프로그램이 장치 세부를 직접 다루지 않고, 커널 계층을 거치며 드라이버가 하드웨어 명령으로 번역한다는 점입니다.
다음 절에서는 완료 확인 방식인 폴링·인터럽트와 데이터 전송 방식인 PIO·DMA를 비교합니다.