I/O 하드웨어와 소프트웨어
블록 장치와 문자 장치, 장치 파일의 major·minor 번호가 드라이버로 이어지는 과정을 모델로 따라갑니다. 장치 컨트롤러의 레지스터에 접근하는 포트 I/O와 메모리 맵 I/O, 장치를 공통 인터페이스 뒤로 감추는 디바이스 드라이버와 I/O 소프트웨어의 계층을 정리합니다.
CPU와 메모리만으로는 컴퓨터가 할 수 있는 일이 없습니다. 키보드로 입력을 받고, 화면에 출력하고, 디스크에 저장하고, 네트워크로 통신해야 쓸모가 생깁니다. 이렇게 컴퓨터 바깥과 데이터를 주고받는 일이 입출력(input/output, I/O)이며, 운영체제는 속도도 동작 방식도 제각각인 장치들을 일관된 방식으로 다룰 수 있게 해 줍니다.
I/O는 운영체제에서 가장 크고 다양한 부분이기도 합니다. Linux 커널 소스에서 가장 큰 비중을 차지하는 것도 수많은 장치를 지원하는 드라이버 코드입니다. 이 글에서는 장치의 종류와 장치 파일, 장치 컨트롤러에 접근하는 방법, 그리고 그 위에 놓이는 드라이버와 소프트웨어 계층을 살펴봅니다.
블록 장치와 문자 장치
Unix 계열 운영체제는 장치를 크게 두 종류로 나눕니다. 블록 장치(block device)는 정해진 크기의 블록 단위로 데이터를 읽고 쓰며, 원하는 블록을 골라 읽는 임의 접근이 가능합니다. HDD, SSD, USB 메모리가 여기에 속하고, 파일 시스템은 블록 장치 위에 만들어집니다.
문자 장치(character device)는 데이터를 바이트 스트림으로 주고받습니다. 키보드, 마우스, 시리얼 포트, 터미널이 여기에 속하며, 대개 들어오는 순서대로 읽을 뿐 위치를 옮겨 다니지 않습니다. 다만 어떤 연산을 지원할지는 드라이버가 정하므로, 위치 이동을 지원하는 문자 장치도 있습니다.
이 분류에 들어맞지 않는 장치도 있습니다. 네트워크 카드는 장치 파일 대신 소켓(socket)이라는 별도의 인터페이스로 다루고, GPU나 타이머도 단순한 읽기·쓰기만으로는 설명되지 않습니다. 두 분류는 운영체제가 장치를 다루는 기본 틀로 이해하면 됩니다.
장치 파일과 장치 번호
Unix 계열에서는 장치도 /dev 아래의 파일로 나타납니다. 이런 장치 파일(device file)의 inode에는 데이터 블록 대신 장치의 종류와 두 번호가 기록되어 있습니다. major 번호는 그 장치를 맡은 드라이버를, minor 번호는 그 드라이버가 다루는 장치 가운데 어느 것인지를 가리킵니다.
ls -l /dev/null /dev/sda # 첫 글자 c는 문자 장치, b는 블록 장치
stat -c '%n: %F, major %Hr, minor %Lr' /dev/nullls -l로 보면 장치 파일은 크기 자리에 1, 3처럼 major와 minor 번호가 쉼표로 나뉘어 나옵니다. Linux 커널 문서의 장치 번호 목록에 따르면 /dev/null은 문자 장치 1번의 3번, /dev/zero는 1번의 5번, /dev/sda는 블록 장치 8번의 0번입니다. 이름은 사람이 쓰는 입구일 뿐이고, 커널이 어느 드라이버로 요청을 보낼지는 이 번호로 정합니다.
문자 장치 파일에서 드라이버까지
사용자가 문자 장치 파일을 연다. 선택한 연산을 해당 장치에 전달한다. 각 단계의 값과 전환 이유를 비교합니다.
I/O 계층 · 문자 장치
장치 파일의 이름에서 등록된 드라이버 연산으로 연결됩니다
Linux 문자 장치의 개념 경로입니다. 파일 경로의 권한과 장치 inode를 확인하고, major/minor로 표현한 dev_t를 통해 등록된 cdev와 드라이버 연산에 연결합니다.
문자 장치 요청의 개념 경로입니다. major/minor는 커널 등록을 찾는 단서이고, 지원 연산과 실제 레지스터 접근은 드라이버에 따릅니다. 블록·네트워크 경로는 동일한 문자 장치 등록 경로가 아닙니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 이름으로 요청부터 드라이버 처리까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 제목 | 문자 장치 파일에서 드라이버까지 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 이름으로 요청 사용자가 문자 장치 파일을 연다. |
|
|
| 등록 조회 권한과 장치 번호를 확인한다. |
|
|
| 드라이버 처리 선택한 연산을 해당 장치에 전달한다. |
|
|
문자 장치 파일에서 드라이버까지
사용자가 문자 장치 파일을 연다. 선택한 연산을 해당 장치에 전달한다. 각 단계의 값과 전환 이유를 비교합니다.
I/O 계층 · 문자 장치
장치 파일의 이름에서 등록된 드라이버 연산으로 연결됩니다
Linux 문자 장치의 개념 경로입니다. 파일 경로의 권한과 장치 inode를 확인하고, major/minor로 표현한 dev_t를 통해 등록된 cdev와 드라이버 연산에 연결합니다.
문자 장치 요청의 개념 경로입니다. major/minor는 커널 등록을 찾는 단서이고, 지원 연산과 실제 레지스터 접근은 드라이버에 따릅니다. 블록·네트워크 경로는 동일한 문자 장치 등록 경로가 아닙니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 이름으로 요청부터 드라이버 처리까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 제목 | 문자 장치 파일에서 드라이버까지 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 이름으로 요청 사용자가 문자 장치 파일을 연다. |
|
|
| 등록 조회 권한과 장치 번호를 확인한다. |
|
|
| 드라이버 처리 선택한 연산을 해당 장치에 전달한다. |
|
|
그림처럼 프로세스가 문자 장치 파일을 열면, 커널은 경로를 해석해 inode를 찾고, 권한을 확인한 뒤, inode에 적힌 장치 번호로 등록된 드라이버를 찾아 그 드라이버의 연산을 연결합니다. 이후의 read()와 write()는 그 드라이버의 함수로 바로 전달됩니다. 다음 코드는 이 과정을 모델로 흉내 냅니다. 세 장치의 동작은 null(4)와 full(4)에 설명된 대로입니다.
import errno
# 장치 파일의 inode에는 데이터 대신 종류와 (major, minor) 번호가 기록되어 있다
dev_inodes = {
"/dev/null": ("c", 1, 3),
"/dev/zero": ("c", 1, 5),
"/dev/full": ("c", 1, 7),
"/dev/sda": ("b", 8, 0),
}
class Null: # 쓰면 버리고, 읽으면 곧바로 파일 끝
def read(self, n):
return b""
def write(self, data):
return len(data)
class Zero(Null): # 읽으면 0 바이트가 끝없이 나온다
def read(self, n):
return bytes(n)
class Full(Zero): # 쓰면 언제나 "공간 없음"
def write(self, data):
raise OSError(errno.ENOSPC, "No space left on device")
# 문자 장치 드라이버의 등록표: major -> {minor -> 장치}
char_drivers = {1: {3: Null(), 5: Zero(), 7: Full()}}
def open_device(path):
kind, major, minor = dev_inodes[path] # 경로를 해석해 장치 번호를 얻고
if kind != "c":
raise OSError(errno.ENODEV, f"{path}는 문자 장치가 아닙니다")
return char_drivers[major][minor] # 번호로 등록된 드라이버를 찾는다
null = open_device("/dev/null")
print("null에 쓰기:", null.write(b"hello"), "/ 읽기:", null.read(4))
print("zero에서 읽기:", open_device("/dev/zero").read(4))
try:
open_device("/dev/full").write(b"hello")
except OSError as error:
print("full에 쓰기:", error.strerror)null에 쓰기: 5 / 읽기: b''
zero에서 읽기: b'\x00\x00\x00\x00'
full에 쓰기: No space left on device/dev/null에 쓰면 받은 바이트 수를 그대로 돌려주어 성공한 것처럼 보이지만 데이터는 버려지고, 읽으면 곧바로 파일 끝입니다. /dev/zero는 읽는 만큼 0 바이트를 내주며, /dev/full은 쓰기마다 ENOSPC를 돌려주어 디스크가 가득 찬 상황을 시험할 때 씁니다. 세 장치는 같은 드라이버에 속하고 minor 번호로만 구별됩니다.
장치 컨트롤러와 레지스터
CPU가 장치의 기계적, 전기적 동작을 직접 다루지는 않습니다. 장치마다 장치 컨트롤러(device controller)라는 전용 회로가 있어서 실제 동작을 맡고, CPU는 컨트롤러가 내보인 레지스터를 읽고 쓰는 방식으로 장치와 대화합니다. 컨트롤러의 레지스터는 대개 다음 세 가지 역할로 나뉩니다.
| 레지스터 | 역할 |
|---|---|
| 데이터 레지스터 | 장치로 보낼 데이터나 장치에서 받은 데이터를 담는다 |
| 상태 레지스터 | 장치가 바쁜지, 준비되었는지, 오류가 났는지를 알린다 |
| 명령 레지스터 | CPU가 읽기, 쓰기, 초기화 같은 명령을 적는다 |
큰 데이터를 주고받는 컨트롤러는 내부에 데이터 버퍼를 두기도 하고, NVMe SSD나 네트워크 카드처럼 메모리에 놓인 명령 큐를 읽어 가며 일하기도 합니다. 어느 경우든 운영체제가 장치를 움직이는 방법은 정해진 레지스터에 정해진 값을 쓰고, 상태를 읽어 결과를 확인하는 것입니다.
레지스터에 접근하는 두 방법
포트 I/O
포트 I/O(port-mapped I/O)는 메모리와 분리된 I/O 전용 주소 공간을 씁니다. x86에서는 in과 out이라는 전용 명령어로 0번부터 65,535번까지의 I/O 포트를 읽고 씁니다. 예를 들어 PC의 오래된 타이머 칩은 0x40번부터 0x43번까지의 포트를 쓰고, 첫 번째 시리얼 포트는 0x3F8번에서 시작합니다. 전용 명령어는 커널 모드에서만 자유롭게 쓸 수 있으므로, 사용자 프로그램은 장치에 직접 접근하지 못합니다.
메모리 맵 I/O
메모리 맵 I/O(memory-mapped I/O, MMIO)는 장치의 레지스터를 물리 주소 공간의 한 구간에 배치합니다. CPU는 보통의 메모리 읽기·쓰기 명령으로 레지스터에 접근하고, 메모리 대신 장치가 그 접근에 응답합니다. 전용 명령어가 필요 없고 주소 공간이 넓어서, 오늘날 대부분의 장치는 이 방식을 씁니다.
다음은 라즈베리 파이 3처럼 GPIO 레지스터가 물리 주소 0x3F200000부터 놓인 보드에서, 운영체제 없이 핀 하나를 켜고 끄는 코드입니다. 핀을 출력으로 설정하는 단계는 생략했습니다.
#include <stdint.h>
#define GPIO_BASE 0x3F200000u /* GPIO 레지스터의 물리 주소 */
#define GPSET0 ((volatile uint32_t *)(GPIO_BASE + 0x1C)) /* 1을 쓴 비트의 핀을 켠다 */
#define GPCLR0 ((volatile uint32_t *)(GPIO_BASE + 0x28)) /* 1을 쓴 비트의 핀을 끈다 */
void led_on(void) { *GPSET0 = 1u << 17; } /* 17번 핀만 켠다 */
void led_off(void) { *GPCLR0 = 1u << 17; } /* 17번 핀만 끈다 */SET 레지스터의 주소는 0x3F20001C, CLR 레지스터는 0x3F200028이고, 1u << 17은 17번 비트만 1인 값 131072입니다. 같은 레지스터의 각 비트가 서로 다른 핀을 맡으므로, 17번 핀을 끄려고 CLR 레지스터에 쓴 값은 다른 핀에 영향을 주지 않습니다. volatile은 컴파일러가 이 쓰기를 메모리 쓰기로 보고 없애거나 순서를 바꾸지 못하게 합니다.
이 코드는 운영체제가 없거나 물리 주소에 직접 접근할 수 있는 환경을 가정합니다. Linux의 사용자 프로그램은 가상 주소를 쓰므로 이 물리 주소를 그대로 쓸 수 없고, 드라이버는 ioremap()으로 레지스터 구간을 가상 주소에 매핑한 뒤 readl(), writel() 같은 전용 함수로 접근합니다. 장치 레지스터는 캐시하면 안 되고 접근 순서도 지켜야 하는데, volatile만으로는 CPU와 버스 수준의 순서까지 보장되지 않기 때문입니다. 커널의 장치 I/O 문서가 이 함수들을 설명합니다.
Linux에서는 어느 장치가 어떤 포트와 주소 구간을 쓰는지 다음 파일로 볼 수 있습니다.
cat /proc/ioports # 장치별로 쓰는 I/O 포트 구간
sudo cat /proc/iomem # 물리 주소 공간에서 RAM과 장치 레지스터 구간디바이스 드라이버
디바이스 드라이버(device driver)는 특정 장치의 컨트롤러를 다루는 방법을 아는 커널의 코드입니다. 커널은 드라이버마다 정해진 형태의 함수 모음을 요구하고, 드라이버는 그 함수들을 자기 장치에 맞게 구현합니다. Linux의 문자 장치 드라이버라면 struct file_operations에 open, read, write, release, unlocked_ioctl 같은 함수 포인터를 채워 등록합니다. 위의 모델에서 Null, Zero, Full이 각자 read와 write를 구현한 것과 같은 구조입니다.
다음은 읽으면 hello 한 줄을 돌려주는 가장 작은 문자 장치 드라이버입니다. 기타(misc) 장치로 등록하면 커널이 minor 번호를 정하고 /dev/hello 장치 파일을 만들 수 있게 해 줍니다.
#include <linux/fs.h>
#include <linux/miscdevice.h>
#include <linux/module.h>
static ssize_t hello_read(struct file *file, char __user *buf, size_t count, loff_t *ppos)
{
static const char msg[] = "hello\n";
/* 오프셋에 맞춰 사용자 버퍼로 복사하고 오프셋을 옮긴다. 끝에 이르면 0을 돌려준다. */
return simple_read_from_buffer(buf, count, ppos, msg, sizeof msg - 1);
}
static const struct file_operations hello_fops = {
.owner = THIS_MODULE,
.read = hello_read, /* 구현하지 않은 연산은 커널이 기본 동작으로 처리한다. */
};
static struct miscdevice hello_dev = {
.minor = MISC_DYNAMIC_MINOR, /* minor 번호를 커널이 고른다. */
.name = "hello", /* /dev/hello */
.fops = &hello_fops,
};
module_misc_device(hello_dev);
MODULE_LICENSE("GPL");1장 3편에서 안내한 kbuild 절차대로 빌드해 insmod로 넣으면, 성공한 경우 /dev/hello가 생기고 sudo cat /dev/hello가 hello 한 줄을 출력합니다. cat이 두 번째로 read()를 부를 때는 오프셋이 메시지 끝에 있으므로 0이 돌아와 파일 끝으로 처리됩니다. simple_read_from_buffer()가 사용자 버퍼로 복사할 때 쓰는 copy_to_user()는, 사용자 프로그램이 넘긴 주소가 잘못되었어도 커널이 멈추지 않고 오류를 돌려주게 해 줍니다.
드라이버가 커널 안에서 돈다는 것
Linux와 Windows의 드라이버는 대부분 커널 모드에서 실행됩니다. 하드웨어에 빠르게 접근할 수 있는 대신, 드라이버의 버그가 커널 전체를 멈추게 할 수 있습니다. 잘못된 포인터 하나가 Linux에서는 커널 패닉(kernel panic)을, Windows에서는 블루스크린을 일으킵니다. 실제로 운영체제가 멈추는 원인의 상당수가 드라이버에서 나옵니다.
마이크로커널(microkernel) 운영체제인 MINIX 3와 QNX는 드라이버를 사용자 모드의 별도 프로세스로 실행해, 드라이버가 죽어도 다시 시작하면 되게 합니다. 대신 드라이버와 커널이 메시지를 주고받는 비용이 들어서, 범용 운영체제에서는 드라이버를 커널에 넣는 방식이 주류입니다. 범용 운영체제도 일부는 사용자 모드로 옮겼습니다. Linux는 장치를 사용자 프로그램에 넘기는 UIO와 VFIO, 파일 시스템을 사용자 프로그램으로 구현하는 FUSE를 제공하고, Windows는 사용자 모드 드라이버 프레임워크(UMDF)를 둡니다.
더 깊이: 트리 밖에서 빌드한 모듈이 남기는 오염 표시
앞의 예처럼 커널 소스 트리 밖에서 빌드한 모듈을 insmod로 넣으면, 커널은 자신을 오염되었다(tainted)고 표시합니다. 오염된 커널 문서에 따르면 이 표시는 나중에 문제를 조사할 때 관련이 있을 만한 일이 일어났다는 기록이며, 커널이 동작하는 데에는 대개 지장이 없습니다. 이유마다 글자와 비트가 정해져 있어서, 트리 밖에서 빌드한 모듈은 O(값 4096), 서명이 없는 모듈은 E(값 8192), 사유 라이선스 모듈은 P(값 1), 커널이 경고를 낸 경우는 W(값 512)로 남습니다.
지금 상태는 /proc/sys/kernel/tainted로 읽습니다. 0이면 오염되지 않은 것이고, 0이 아니면 여러 이유의 비트를 더한 값입니다. 커널이 oops나 패닉 메시지를 낼 때는 CPU:로 시작하는 줄의 Tainted: 뒤에 이 글자들이 찍히며, 이 표시는 원인을 없앤 뒤에도 남아서 사유 모듈을 내려도 지워지지 않습니다. 문서는 오염된 커널에서 나온 버그 보고는 개발자들이 무시하는 경우가 많으니, 할 수 있다면 오염되지 않은 커널에서 문제를 다시 일으켜 보라고 권합니다.
I/O 소프트웨어의 계층
응용 프로그램의 요청은 여러 계층을 거쳐 장치에 이릅니다.
| 계층 | 하는 일 | 예 |
|---|---|---|
| 사용자 프로그램과 라이브러리 | 요청을 만들고, 사용자 공간에서 버퍼링한다 | printf, fread |
| 장치 독립 소프트웨어 | 장치와 상관없는 공통 인터페이스, 캐시, 권한 확인, 오류 보고 | VFS, 페이지 캐시, 블록 계층 |
| 디바이스 드라이버 | 공통 요청을 장치의 명령으로 바꾼다 | nvme, e1000e |
| 인터럽트 처리기 | 장치가 일을 마쳤다고 알리면 처리를 이어 간다 | 인터럽트 서비스 루틴 |
| 하드웨어 | 실제 입출력을 수행한다 | 디스크 컨트롤러, 네트워크 카드 |
예를 들어 printf("hi\n")는 먼저 C 라이브러리의 버퍼에 쓰이고, 버퍼가 비워질 때 write() 시스템 콜이 됩니다. 커널에서는 fd가 가리키는 대상에 따라 길이 갈려, 터미널이라면 tty 계층과 터미널 드라이버로, 파일이라면 VFS와 파일 시스템, 페이지 캐시로 갑니다. 각 계층은 위아래와 정해진 방식으로만 대화하므로, 새 장치를 지원하려면 드라이버만 추가하면 되고 응용 프로그램은 고칠 필요가 없습니다.
정리
- 블록 장치는 블록 단위로 임의 접근하고, 문자 장치는 바이트 스트림을 주고받습니다. 네트워크 장치는 소켓이라는 별도의 인터페이스로 다룹니다.
- 장치 파일의 inode에는 major와 minor 번호가 있고, 커널은 이름이 아니라 이 번호로 드라이버를 찾아 연산을 연결합니다.
- CPU는 장치 컨트롤러의 데이터, 상태, 명령 레지스터를 읽고 써서 장치를 움직입니다. 레지스터에는 전용 명령어를 쓰는 포트 I/O나, 메모리 주소에 배치하는 메모리 맵 I/O로 접근합니다.
- 드라이버는 커널이 정한 함수 모음을 장치에 맞게 구현합니다. 커널 모드에서 돌아 빠르지만, 드라이버의 버그는 시스템 전체를 멈추게 할 수 있습니다.
- I/O 요청은 라이브러리, 장치 독립 소프트웨어, 드라이버, 인터럽트 처리기, 하드웨어의 계층을 거치며, 이 계층 덕분에 응용 프로그램은 장치의 세부를 몰라도 됩니다.
연습 문제
1. 이름이 다른 같은 장치
관리자가 sudo mknod /tmp/mynull c 1 3으로 장치 파일 /tmp/mynull을 만들었습니다. 이 파일에 데이터를 쓰면 어떻게 되나요? 장치 번호는 그대로 두고 이름만 /tmp/disk로 바꾸면 동작이 달라지나요?
해설 보기
c 1 3은 문자 장치, major 1, minor 3이라는 뜻으로 /dev/null과 같은 번호입니다. 커널은 장치 파일을 열 때 이름이 아니라 inode에 적힌 번호로 드라이버를 찾으므로, /tmp/mynull에 쓴 데이터는 /dev/null처럼 버려지고, 읽으면 곧바로 파일 끝이 나옵니다. 이름을 /tmp/disk로 바꾸어도 번호가 그대로이니 동작도 같습니다. 반대로 /tmp 파일 시스템이 nodev 옵션으로 마운트되어 있다면 장치 파일을 열 수 없으므로, 장치 파일이 동작하려면 번호 말고도 마운트 옵션과 권한이 맞아야 합니다.
2. GPIO 레지스터 계산
본문의 GPIO 예에서 3번 핀을 끄려면 어느 주소에 어떤 값을 써야 하나요? 17번 핀과 3번 핀을 한 번에 끄려면 어떤 값을 쓰면 되나요?
해설 보기
핀을 끄는 CLR 레지스터는 3번 핀이든 17번 핀이든 같은 0x3F200028이고, 3번 핀의 값은 3번 비트만 1인 1u << 3, 곧 8입니다. 두 핀을 한 번에 끄려면 두 비트를 모두 켠 (1u << 17) | (1u << 3), 곧 131080을 쓰면 됩니다. 비트 번호, 그 비트를 켠 값, 레지스터의 주소는 서로 다른 개념이므로 구별해야 합니다. 이 계산이 맞아도, 그 주소에 실제로 접근하려면 운영체제가 그 구간을 매핑하고 권한을 주어야 합니다.
3. printf 한 줄이 가는 길
C 프로그램이 printf("hi\n")를 부릅니다. 출력이 터미널일 때와 ./program > out.txt로 파일에 리다이렉션했을 때, 데이터가 거치는 계층이 어떻게 다른지 설명하세요.
해설 보기
두 경우 모두 데이터는 먼저 C 라이브러리의 stdio 버퍼에 들어갑니다. 출력이 터미널이면 stdio는 줄 단위로 버퍼링하므로 줄바꿈에서 바로 write(1, ...)를 부르고, 커널은 fd 1이 가리키는 터미널 장치의 tty 계층과 드라이버를 거쳐 화면에 글자를 보냅니다. 파일로 리다이렉션하면 stdio는 버퍼가 찰 때까지 모았다가 write()를 부르며, 커널에서는 VFS와 파일 시스템을 거쳐 페이지 캐시에 쓰입니다. 디스크로는 나중에 플러셔가 블록 계층과 디스크 드라이버를 거쳐 내보냅니다. 같은 printf라도 fd 1이 가리키는 대상에 따라 아래 계층이 완전히 달라지는데, 프로그램은 이를 몰라도 됩니다.
다음 글 I/O 처리 방식에서는 CPU가 장치의 작업이 끝났음을 알아내는 폴링과 인터럽트, 그리고 데이터를 옮기는 프로그램 I/O와 DMA를 비교합니다.