Character Device 심화
리눅스 커널에서 Character Device는 바이트 스트림 기반으로 사용자 공간과 커널 공간 사이의
데이터를 교환하는 가장 기본적이고 널리 사용되는 디바이스 유형입니다.
struct cdev와 file_operations의 내부 구조부터
VFS 디스패치 경로, Major/Minor 번호 체계, ioctl/poll/mmap 구현,
동시성 제어, miscdevice, 다중 인스턴스 패턴까지 전 영역을 상세히 다룹니다.
struct file과 struct inode를 매개로 사용자 공간과 통신하므로,
VFS 객체 모델과 드라이버 등록 생명주기를 먼저 이해해야 합니다.
핵심 요약
- struct cdev — 커널 내부에서 Character Device 하나를 표현하는 객체입니다.
kobject를 내장하여 참조 카운팅과 sysfs 연동을 제공합니다. - file_operations —
open,read,write,ioctl,poll,mmap등 사용자 공간 시스템 콜에 대응하는 콜백 함수 테이블입니다. - dev_t (Major/Minor) — 32비트 디바이스 번호로, 상위 12비트가 Major(드라이버 식별), 하위 20비트가 Minor(인스턴스 식별)입니다.
- chrdev_open — VFS가 Character Device 노드를
open()할 때 호출하는 내부 함수로,cdev_map에서 cdev를 검색하여file->f_op를 교체합니다. - miscdevice — Major 번호 10번을 공유하는 간편 등록 프레임워크로, Minor 번호 하나만 할당하면
/dev노드까지 자동 생성됩니다. - container_of —
private_data나inode->i_cdev포인터로부터 드라이버 고유 구조체를 역참조하는 핵심 매크로입니다.
단계별 이해
- 번호 할당 이해
alloc_chrdev_region()으로 Major/Minor 번호를 동적 할당받는 과정을 먼저 파악합니다./proc/devices에서 등록된 Major 번호를 확인할 수 있습니다. - cdev 등록 흐름
cdev_init()→cdev_add()→class_create()→device_create()순서로 디바이스를 커널에 등록하고, devtmpfs/udev가/dev노드를 자동 생성하는 과정을 추적합니다. - VFS 디스패치 추적
사용자가open("/dev/mydev", ...)를 호출하면 VFS가 어떻게 inode의i_rdev를 통해cdev_map에서 올바른file_operations를 찾아 연결하는지 코드 경로를 따라갑니다. - 콜백 구현 실습
read,write,ioctl,poll콜백을 직접 구현하고,copy_to_user()/copy_from_user()의 안전한 사용법과 에러 처리 패턴을 익힙니다.
Character Device 개요와 역사
Unix에서 Linux까지: Character Device의 진화
Character Device의 역사는 Unix 자체의 역사와 함께합니다. 1975년 V6 Unix에서
Ken Thompson과 Dennis Ritchie가 설계한 "모든 것은 파일이다(everything is a file)" 철학의 핵심 구현체가 바로 디바이스 파일입니다.
V6 Unix의 cdevsw[](character device switch table) 배열은 Major 번호를 인덱스로 사용하여
각 디바이스의 open, close, read, write 함수 포인터를 관리했습니다.
Linux 커널은 이 전통을 계승하면서도 대폭 확장했습니다. 초기 Linux(0.01~1.x)에서는 정적 Major 번호 테이블을 사용했으나,
디바이스 수가 폭증하면서 2.6 커널에서 struct cdev 기반의 동적 등록 체계로 전환했습니다.
Major 번호도 8비트(256개)에서 12비트(4096개)로 확장되었고, Minor 번호는 8비트(256개)에서 20비트(약 100만 개)로 대폭 늘어났습니다.
디바이스 유형 비교: Character vs Block vs Network
리눅스 커널은 디바이스를 세 가지 주요 유형으로 분류합니다. 각 유형은 데이터 접근 패턴과 VFS 연동 방식이 근본적으로 다릅니다.
| 항목 | Character Device | Block Device | Network Device |
|---|---|---|---|
| 데이터 접근 패턴 | 바이트 스트림 (순차/임의) | 고정 크기 블록 단위 | 패킷 단위 |
/dev 노드 |
있음 (c 타입) |
있음 (b 타입) |
없음 (소켓 인터페이스) |
| VFS 연동 | file_operations 직접 연결 |
block_device_operations + I/O 스케줄러 |
net_device_ops (소켓 계층) |
| 버퍼링/캐시 | 드라이버 자체 관리 | Page Cache + BIO 계층 | sk_buff 기반 |
| 랜덤 접근(seek) | 드라이버 선택 (llseek 구현 여부) |
필수 지원 | 해당 없음 |
| 대표 하드웨어 | 시리얼, GPIO, 센서, 프레임버퍼, 사운드 | HDD, SSD, NVMe, RAM 디스크 | 이더넷, Wi-Fi, 가상 NIC |
| 등록 API | cdev_add() / misc_register() |
add_disk() |
register_netdev() |
| 사용자 공간 접근 | open()/read()/write()/ioctl() |
파일시스템 마운트 또는 raw 접근 | socket()/send()/recv() |
주요 Character Device Major 번호 목록
리눅스 커널 소스의 Documentation/admin-guide/devices.txt에 정의된 주요 Character Device Major 번호입니다.
현대 시스템에서는 alloc_chrdev_region()으로 동적 할당하는 것이 권장되지만,
역사적으로 할당된 고정 번호들은 여전히 사용됩니다.
| Major | 디바이스 | 설명 |
|---|---|---|
| 1 | /dev/mem, /dev/null, /dev/zero | 메모리/널/제로 디바이스 |
| 4 | /dev/ttyN | 가상 콘솔 (TTY) |
| 5 | /dev/tty, /dev/console, /dev/ptmx | 대체 TTY / 콘솔 / PTY 마스터 |
| 10 | /dev/watchdog, /dev/rtc 등 | Miscellaneous 디바이스 (misc) |
| 13 | /dev/input/eventN | Input 서브시스템 |
| 29 | /dev/fb0 | 프레임버퍼 |
| 81 | /dev/video0 | V4L2 비디오 캡처 |
| 116 | /dev/snd/* | ALSA 사운드 |
| 136–143 | /dev/pts/N | Unix98 PTY 슬레이브 |
| 180 | /dev/usb/* | USB 디바이스 |
| 188 | /dev/ttyUSBN | USB 시리얼 |
| 226 | /dev/dri/cardN | DRM (GPU) |
| 동적 | alloc_chrdev_region() | 드라이버가 동적으로 할당받는 번호 |
Character Device의 전체 아키텍처
사용자 공간 애플리케이션에서 하드웨어까지의 전체 경로를 4계층으로 나누어 볼 수 있습니다. 각 계층은 명확한 인터페이스로 분리되어 있으며, Character Device 드라이버 개발자는 주로 VFS와 하드웨어 사이의 "Driver Layer"를 구현합니다.
"모든 것은 파일이다" 원칙과 Character Device
Unix/Linux의 핵심 설계 철학인 "모든 것은 파일이다"는 Character Device에 가장 직접적으로 적용됩니다.
하드웨어 장치를 /dev 디렉토리의 특수 파일(device node)로 표현함으로써,
기존의 파일 I/O API(open/read/write/close)를
그대로 사용하여 하드웨어와 통신할 수 있습니다. 이 접근법의 주요 장점은 다음과 같습니다:
- 통일된 인터페이스: 셸 스크립트에서
echo "data" > /dev/mydev만으로 디바이스에 데이터를 쓸 수 있습니다. - 권한 관리: 파일 시스템의 표준 퍼미션(owner/group/other)과 SELinux/AppArmor 정책이 그대로 적용됩니다.
- 파이프라인 활용:
cat /dev/sensor | grep "temp"처럼 Unix 파이프라인에 자연스럽게 통합됩니다. - 도구 재사용:
strace,ltrace,dd,hexdump등 범용 도구를 디바이스 디버깅에 활용할 수 있습니다.
Major/Minor 번호 체계
dev_t의 내부 구조
리눅스 커널에서 디바이스 번호는 dev_t 타입으로 표현됩니다. 이 타입은 32비트 정수로,
상위 12비트가 Major 번호, 하위 20비트가 Minor 번호를 담습니다.
Major 번호는 해당 디바이스를 관리하는 드라이버를 식별하고,
Minor 번호는 동일 드라이버가 관리하는 개별 디바이스 인스턴스를 구분합니다.
/* include/linux/kdev_t.h */
#define MINORBITS 20
#define MINORMASK ((1U << MINORBITS) - 1)
#define MAJOR(dev) ((unsigned int) ((dev) >> MINORBITS))
#define MINOR(dev) ((unsigned int) ((dev) & MINORMASK))
#define MKDEV(ma,mi) (((dev_t)(ma) << MINORBITS) | (mi))
예를 들어, MKDEV(10, 63)을 호출하면 Major=10(misc), Minor=63(watchdog)인
dev_t 값 0x00A0003F이 생성됩니다.
이 값을 ls -l /dev/watchdog으로 확인하면 c 10, 63으로 표시됩니다.
dev_t 비트 레이아웃
정적 할당 vs 동적 할당
Character Device 번호를 커널에 등록하는 방법은 두 가지입니다:
정적 할당(register_chrdev_region())과 동적 할당(alloc_chrdev_region())입니다.
/* 정적 할당: Major 번호를 직접 지정 */
dev_t dev = MKDEV(240, 0); /* Major=240, Minor=0 시작 */
ret = register_chrdev_region(dev, 4, "mydriver");
/* Minor 0~3, 총 4개 디바이스 번호를 예약 */
if (ret < 0) {
pr_err("Failed to register chrdev region\n");
return ret;
}
/* 동적 할당: 커널이 사용 가능한 Major 번호를 자동으로 부여 */
dev_t dev;
ret = alloc_chrdev_region(&dev, 0, 4, "mydriver");
/* 첫 번째 Minor=0부터 4개, Major는 커널이 결정 */
if (ret < 0) {
pr_err("Failed to alloc chrdev region\n");
return ret;
}
pr_info("Allocated Major=%d, Minor=%d\n", MAJOR(dev), MINOR(dev));
alloc_chrdev_region()을 사용하세요.
정적 Major 번호는 다른 드라이버와 충돌할 위험이 있으며,
Documentation/admin-guide/devices.txt에 등록된 번호만 사용해야 합니다.
동적 할당을 사용하면 udev/devtmpfs가 /proc/devices를 참조하여
올바른 Major 번호로 /dev 노드를 자동 생성합니다.
/proc/devices로 확인
등록된 Character Device 목록은 /proc/devices에서 확인할 수 있습니다.
Major 번호와 드라이버 이름이 나열됩니다.
$ cat /proc/devices
Character devices:
1 mem
4 /dev/vc/0
4 tty
5 /dev/tty
5 /dev/console
5 /dev/ptmx
7 vcs
10 misc
13 input
21 sg
29 fb
81 video4linux
116 alsa
128 ptm
136 pts
180 usb
188 ttyUSB
226 drm
240 mydriver # 우리가 등록한 드라이버
unregister_chrdev_region 해제
모듈 언로드 시 반드시 unregister_chrdev_region()으로 할당받은 번호를 반환해야 합니다.
이를 누락하면 해당 Major/Minor 범위가 계속 점유되어 다른 드라이버가 사용할 수 없습니다.
/* 모듈 exit 함수에서 호출 */
unregister_chrdev_region(dev, 4); /* 4개 Minor 번호 해제 */
핵심 자료구조
struct cdev
struct cdev는 커널 내부에서 하나의 Character Device를 표현하는 핵심 객체입니다.
fs/char_dev.c에 정의되어 있으며, VFS와 드라이버를 연결하는 중간 매개체 역할을 합니다.
/* include/linux/cdev.h */
struct cdev {
struct kobject kobj; /* 참조 카운팅 + sysfs 연동 */
struct module *owner; /* 이 cdev를 소유한 모듈 (THIS_MODULE) */
const struct file_operations *ops; /* 사용자 공간 콜백 함수 테이블 */
struct list_head list; /* inode->i_devices 리스트 연결 */
dev_t dev; /* 이 cdev의 시작 디바이스 번호 */
unsigned int count; /* 연속 Minor 번호 개수 */
};
| 필드 | 타입 | 설명 |
|---|---|---|
kobj |
struct kobject |
참조 카운팅을 통한 수명 관리. cdev_put()에서 참조가 0이 되면 cdev_default_release() 또는 사용자 정의 kobj_type.release가 호출됩니다. |
owner |
struct module * |
THIS_MODULE로 설정하여, 디바이스 파일이 열려 있는 동안 모듈이 언로드되지 않도록 보호합니다. chrdev_open()에서 try_module_get(owner)를 호출합니다. |
ops |
const struct file_operations * |
open/read/write/ioctl 등의 콜백 테이블. chrdev_open()에서 file->f_op에 복사됩니다. |
list |
struct list_head |
같은 디바이스 번호에 매핑된 inode 목록. 하나의 cdev가 여러 inode에 연결될 수 있습니다. |
dev |
dev_t |
이 cdev가 담당하는 첫 번째 디바이스 번호(Major + 시작 Minor). |
count |
unsigned int |
연속으로 담당하는 Minor 번호 개수. cdev_add(cdev, dev, count)에서 설정됩니다. |
struct file_operations
struct file_operations는 사용자 공간 시스템 콜을 커널 드라이버 콜백으로 연결하는 함수 포인터 테이블입니다.
Character Device 드라이버의 핵심이며, 사실상 드라이버의 "공개 API"에 해당합니다.
include/linux/fs.h에 정의되어 있으며, 주요 콜백은 다음과 같습니다.
| 콜백 | 대응 시스템 콜 | 설명 |
|---|---|---|
owner | — | THIS_MODULE. 모듈 참조 카운팅 용도 |
open | open(2) | 디바이스 파일 열기. 리소스 할당, private_data 설정 |
release | close(2) | 마지막 fd 닫힘 시 호출. 리소스 해제 |
read | read(2) | 디바이스에서 사용자 버퍼로 데이터 복사 |
write | write(2) | 사용자 버퍼에서 디바이스로 데이터 복사 |
unlocked_ioctl | ioctl(2) | 디바이스 제어 명령 처리 (BKL 없는 현대 버전) |
compat_ioctl | ioctl(2) (32-bit) | 64비트 커널에서 32비트 프로세스의 ioctl 호환 |
poll | poll(2)/select(2)/epoll(7) | I/O 이벤트 대기. wait_queue와 연동 |
mmap | mmap(2) | 디바이스 메모리를 사용자 공간에 매핑 |
llseek | lseek(2) | 파일 오프셋 변경. 스트림 디바이스는 no_llseek 또는 noop_llseek 사용 |
fasync | fcntl(2) (F_SETFL/FASYNC) | 비동기 알림 설정. SIGIO 시그널 전달 |
flush | close(2) | fd 닫힘 시마다 호출 (release와 달리 매번) |
read_iter | readv(2)/preadv(2) | iov_iter 기반 벡터 읽기 (현대 커널 권장) |
write_iter | writev(2)/pwritev(2) | iov_iter 기반 벡터 쓰기 |
splice_read | splice(2) | 제로카피 파이프 읽기 |
전형적인 Character Device 드라이버의 file_operations 정의 예시:
static const struct file_operations mydev_fops = {
.owner = THIS_MODULE,
.open = mydev_open,
.release = mydev_release,
.read = mydev_read,
.write = mydev_write,
.unlocked_ioctl = mydev_ioctl,
.poll = mydev_poll,
.mmap = mydev_mmap,
.llseek = no_llseek, /* 스트림 디바이스: seek 비허용 */
};
.owner = THIS_MODULE을 누락하면,
사용자가 디바이스 파일을 열어둔 상태에서 rmmod로 모듈을 제거할 수 있어
use-after-free 커널 패닉이 발생합니다. chrdev_open()이
try_module_get(fops->owner)를 호출하므로, owner가 NULL이면 보호가 동작하지 않습니다.
struct file
struct file은 "열린 파일 인스턴스"를 나타내는 VFS 객체입니다.
open(2)이 호출될 때마다 생성되며, 같은 디바이스 노드를 여러 프로세스가 열면
각각 별도의 struct file이 생성됩니다. Character Device 드라이버에서 중요한 필드는 다음과 같습니다.
/* include/linux/fs.h (주요 필드만 발췌) */
struct file {
const struct file_operations *f_op; /* 현재 연결된 fops */
loff_t f_pos; /* 현재 파일 오프셋 */
unsigned int f_flags; /* O_RDONLY, O_NONBLOCK 등 */
fmode_t f_mode; /* FMODE_READ, FMODE_WRITE */
void *private_data; /* 드라이버 전용 데이터 */
struct address_space *f_mapping; /* 페이지 캐시 매핑 */
/* ... 기타 필드 생략 ... */
};
| 필드 | 드라이버에서의 활용 |
|---|---|
f_op |
VFS가 chrdev_open()에서 cdev->ops로 교체합니다. 드라이버의 open 콜백에서 필요 시 다시 교체할 수 있습니다 (예: 모드별로 다른 fops 사용). |
f_flags |
O_NONBLOCK 여부를 확인하여 read/write의 블로킹/논블로킹 동작을 결정합니다. |
private_data |
드라이버 고유의 per-open 상태를 저장하는 핵심 필드. open 콜백에서 설정하고, 이후 모든 콜백에서 참조합니다. |
f_pos |
llseek 콜백과 read/write의 오프셋 매개변수가 이 필드를 참조/갱신합니다. |
struct inode
struct inode는 파일 시스템 내의 파일/디바이스 노드 메타데이터를 나타냅니다.
디바이스 노드의 경우, Character Device와의 연결에 관련된 핵심 필드가 있습니다.
/* include/linux/fs.h (디바이스 관련 필드 발췌) */
struct inode {
dev_t i_rdev; /* 디바이스 번호 (Major:Minor) */
struct cdev *i_cdev; /* chrdev_open()에서 연결된 cdev 포인터 */
const struct file_operations *i_fop; /* 디바이스 타입별 기본 fops */
union {
struct cdev *i_cdev;
struct block_device *i_bdev;
struct pipe_inode_info *i_pipe;
};
/* ... */
};
i_rdev는 mknod나 devtmpfs에 의해 노드가 생성될 때 설정되는 디바이스 번호입니다.
chrdev_open()은 이 값을 kobj_lookup()의 검색 키로 사용하여
cdev_map에서 해당 cdev를 찾습니다.
찾은 cdev는 inode->i_cdev에 캐싱되어, 이후 동일 inode에 대한
open()에서는 재검색 없이 바로 사용됩니다.
container_of 매크로를 이용한 역참조 패턴
Character Device 드라이버에서 가장 빈번하게 사용되는 패턴 중 하나는 container_of를 이용한
드라이버 고유 구조체의 역참조입니다. inode->i_cdev가 가리키는 struct cdev는
보통 드라이버 고유 구조체의 멤버로 내장(embed)되어 있으므로, container_of로 전체 구조체를
복원할 수 있습니다.
/* 드라이버 고유 구조체 */
struct mydev_data {
struct cdev cdev; /* cdev를 내장(embed) */
struct mutex lock;
char buffer[4096];
size_t data_len;
wait_queue_head_t read_wq;
};
/* open 콜백에서 container_of 사용 */
static int mydev_open(struct inode *inode, struct file *filp)
{
struct mydev_data *dev;
/* inode->i_cdev는 struct mydev_data의 cdev 멤버를 가리킴.
container_of로 전체 mydev_data 포인터를 복원 */
dev = container_of(inode->i_cdev, struct mydev_data, cdev);
/* private_data에 저장하여 이후 read/write/ioctl에서 사용 */
filp->private_data = dev;
return 0;
}
/* read 콜백에서 private_data 사용 */
static ssize_t mydev_read(struct file *filp, char __user *buf,
size_t count, loff_t *ppos)
{
struct mydev_data *dev = filp->private_data;
/* dev->buffer, dev->lock 등에 직접 접근 가능 */
...
}
디바이스 등록 흐름
5단계 등록 절차
Character Device를 커널에 완전히 등록하려면 다음 5단계를 순서대로 수행해야 합니다. 각 단계는 이전 단계의 결과에 의존하므로, 에러 발생 시 이미 완료된 단계를 역순으로 해제해야 합니다.
alloc_chrdev_region()— Major/Minor 번호 동적 할당cdev_init()—struct cdev초기화 및file_operations연결cdev_add()—cdev_map에 cdev 등록 (이 시점부터 VFS가 디바이스를 인식)class_create()— sysfs에 디바이스 클래스 생성 (/sys/class/myclass/)device_create()— sysfs에 디바이스 노드 생성 + devtmpfs/udev가/dev/mydev자동 생성
완전한 등록/해제 코드 예시 (goto 기반 에러 처리)
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
#define DEVICE_NAME "mychardev"
#define CLASS_NAME "myclass"
#define NUM_DEVICES 4
struct mydev_data {
struct cdev cdev;
struct device *device;
struct mutex lock;
char buffer[4096];
size_t data_len;
int id;
};
static dev_t dev_base;
static struct class *mydev_class;
static struct mydev_data devices[NUM_DEVICES];
static const struct file_operations mydev_fops = {
.owner = THIS_MODULE,
.open = mydev_open,
.release = mydev_release,
.read = mydev_read,
.write = mydev_write,
.unlocked_ioctl = mydev_ioctl,
};
static int __init mydev_init(void)
{
int ret, i;
/* 1단계: Major/Minor 번호 동적 할당 */
ret = alloc_chrdev_region(&dev_base, 0, NUM_DEVICES, DEVICE_NAME);
if (ret < 0) {
pr_err("alloc_chrdev_region failed: %d\n", ret);
return ret;
}
/* 4단계: sysfs 클래스 생성 */
mydev_class = class_create(CLASS_NAME);
if (IS_ERR(mydev_class)) {
ret = PTR_ERR(mydev_class);
pr_err("class_create failed: %d\n", ret);
goto err_unreg_region;
}
/* 각 디바이스 인스턴스 초기화 및 등록 */
for (i = 0; i < NUM_DEVICES; i++) {
struct mydev_data *d = &devices[i];
dev_t devno = MKDEV(MAJOR(dev_base), MINOR(dev_base) + i);
d->id = i;
mutex_init(&d->lock);
/* 2단계: cdev 초기화 */
cdev_init(&d->cdev, &mydev_fops);
d->cdev.owner = THIS_MODULE;
/* 3단계: cdev_map에 등록 */
ret = cdev_add(&d->cdev, devno, 1);
if (ret) {
pr_err("cdev_add failed for device %d: %d\n", i, ret);
goto err_destroy_devices;
}
/* 5단계: sysfs + devtmpfs 노드 생성 */
d->device = device_create(mydev_class, NULL, devno,
d, "mychardev%d", i);
if (IS_ERR(d->device)) {
ret = PTR_ERR(d->device);
pr_err("device_create failed for device %d: %d\n", i, ret);
cdev_del(&d->cdev);
goto err_destroy_devices;
}
}
pr_info("mychardev: registered %d devices (Major=%d)\n",
NUM_DEVICES, MAJOR(dev_base));
return 0;
err_destroy_devices:
/* 이미 생성된 디바이스를 역순으로 해제 */
while (--i >= 0) {
dev_t devno = MKDEV(MAJOR(dev_base), MINOR(dev_base) + i);
device_destroy(mydev_class, devno);
cdev_del(&devices[i].cdev);
}
class_destroy(mydev_class);
err_unreg_region:
unregister_chrdev_region(dev_base, NUM_DEVICES);
return ret;
}
static void __exit mydev_exit(void)
{
int i;
for (i = 0; i < NUM_DEVICES; i++) {
dev_t devno = MKDEV(MAJOR(dev_base), MINOR(dev_base) + i);
device_destroy(mydev_class, devno);
cdev_del(&devices[i].cdev);
}
class_destroy(mydev_class);
unregister_chrdev_region(dev_base, NUM_DEVICES);
pr_info("mychardev: unregistered\n");
}
module_init(mydev_init);
module_exit(mydev_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Example Author");
MODULE_DESCRIPTION("Character Device Example");
cdev_add()를 호출하는 순간부터
사용자 공간에서 해당 디바이스에 접근할 수 있습니다. 따라서 cdev_add() 이전에
모든 드라이버 내부 초기화(뮤텍스, 버퍼, 하드웨어 설정 등)를 완료해야 합니다.
초기화가 불완전한 상태에서 open()이 들어오면 정의되지 않은 동작이 발생합니다.
등록 흐름 다이어그램
등록/해제 순서 요약
에러 처리의 핵심 원칙은 "LIFO(Last-In, First-Out) 해제"입니다. 마지막으로 할당한 자원을 가장 먼저 해제해야 합니다.
| 순서 | 등록 (init) | 해제 (exit) |
|---|---|---|
| 1 | alloc_chrdev_region() | unregister_chrdev_region() |
| 2 | cdev_init() | — (별도 해제 불필요) |
| 3 | cdev_add() | cdev_del() |
| 4 | class_create() | class_destroy() |
| 5 | device_create() | device_destroy() |
register_chrdev(major, name, fops)를 볼 수 있습니다.
이 함수는 Major 번호 전체 256개의 Minor를 한 번에 등록하며, cdev_init/cdev_add를
내부적으로 수행합니다. 현대 커널에서는 세밀한 제어와 리소스 절약을 위해
alloc_chrdev_region + cdev_add 조합이 권장됩니다.
VFS → Character Device 디스패치 경로
open(2)의 전체 커널 경로
사용자 공간에서 open("/dev/mydev", O_RDWR)를 호출하면,
시스템 콜 진입점부터 Character Device 드라이버의 open 콜백까지
다음과 같은 경로를 거칩니다. 이 과정의 핵심은 chrdev_open()이
file->f_op를 디바이스별 file_operations로 교체하는 것입니다.
sys_open()/do_sys_openat2()— 시스템 콜 진입점. 사용자 공간의 파일 경로와 플래그를 커널 공간으로 복사합니다.do_filp_open()—struct file할당 후 경로 탐색을 시작합니다.path_openat()— namei(name-to-inode) 경로 탐색의 메인 루프. dentry 캐시와 실제 파일시스템을 조회하여/dev/mydev에 해당하는 inode를 찾습니다.do_open()— inode의 타입을 확인합니다. Character special file이면inode->i_fop가def_chr_fops를 가리키고 있으므로,f->f_op = fops_get(inode->i_fop)으로 초기 fops를 설정합니다.chrdev_open()—def_chr_fops.open이 이 함수를 가리킵니다. 여기서 실제 디바이스별 cdev를 찾아f_op를 교체하는 핵심 동작이 수행됩니다.
chrdev_open()의 내부 동작
chrdev_open()(fs/char_dev.c)은 Character Device의 VFS 디스패치에서
가장 중요한 함수입니다. 핵심 로직은 다음과 같습니다:
/* fs/char_dev.c (핵심 로직 단순화) */
static int chrdev_open(struct inode *inode, struct file *filp)
{
struct cdev *p;
struct cdev *new = NULL;
int ret = 0;
/* 1. inode에 이미 cdev가 캐싱되어 있는지 확인 */
p = inode->i_cdev;
if (!p) {
struct kobject *kobj;
int idx;
/* 2. cdev_map에서 Major:Minor로 cdev 검색 */
kobj = kobj_lookup(cdev_map, inode->i_rdev, &idx);
if (!kobj)
return -ENXIO; /* 등록되지 않은 디바이스 */
new = container_of(kobj, struct cdev, kobj);
/* 3. inode에 cdev 캐싱 */
inode->i_cdev = p = new;
list_add(&inode->i_devices, &p->list);
}
/* 4. 모듈 참조 카운트 증가 */
if (!try_module_get(p->owner)) {
ret = -ENXIO;
goto out;
}
/* 5. 핵심: file->f_op를 cdev의 ops로 교체! */
replace_fops(filp, fops_get(p->ops));
/* 6. 드라이버의 실제 open 콜백 호출 */
if (filp->f_op->open)
ret = filp->f_op->open(inode, filp);
out:
return ret;
}
cdev_map은 struct kobj_map 타입의 해시 테이블로,
255개의 버킷을 가집니다. 각 버킷은 Major 번호를 255로 나눈 나머지로 인덱싱됩니다.
cdev_add()가 이 맵에 엔트리를 삽입하고, cdev_del()이 제거합니다.
kobj_lookup()은 Major:Minor 번호로 해시 검색하여 해당 cdev의 kobject를 반환합니다.
f_op 교체의 의미
chrdev_open()에서 수행되는 replace_fops(filp, cdev->ops)는
Character Device의 VFS 연동에서 가장 핵심적인 동작입니다. 이 교체 이전과 이후의 상태를 비교하면:
| 시점 | file->f_op | open 콜백 |
|---|---|---|
| 교체 전 | def_chr_fops (Character Device 공통) |
chrdev_open() — VFS 디스패처 |
| 교체 후 | cdev->ops (드라이버별 mydev_fops) |
mydev_open() — 드라이버 고유 |
따라서 최초 open() 이후의 read(), write(), ioctl() 등은
모두 드라이버의 file_operations에 정의된 콜백으로 직접 디스패치됩니다.
VFS는 중간 단계 없이 filp->f_op->read(filp, buf, count, &filp->f_pos)를
호출하므로, 오버헤드가 최소화됩니다.
open(2) 디스패치 경로 다이어그램
read/write/ioctl 디스패치 (open 이후)
open(2)으로 fd를 얻은 후의 read(2), write(2), ioctl(2) 호출은
훨씬 단순한 경로를 거칩니다. VFS는 이미 교체된 filp->f_op를 직접 사용하므로,
chrdev_open()이나 cdev_map 검색 없이 바로 드라이버 콜백에 도달합니다.
/* read(2) 경로 (fs/read_write.c, 단순화) */
SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)
{
struct fd f = fdget_pos(fd);
/* ... 검증 ... */
ret = vfs_read(f.file, buf, count, &pos);
/* ... */
}
ssize_t vfs_read(struct file *file, char __user *buf,
size_t count, loff_t *pos)
{
/* ... 권한 검사 ... */
if (file->f_op->read)
ret = file->f_op->read(file, buf, count, pos);
else if (file->f_op->read_iter)
ret = new_sync_read(file, buf, count, pos);
/* ... */
}
/* ioctl(2) 경로 (fs/ioctl.c, 단순화) */
long do_vfs_ioctl(struct file *filp, unsigned int fd,
unsigned int cmd, unsigned long arg)
{
/* 일부 cmd (FIOCLEX 등)는 VFS가 직접 처리 */
switch (cmd) {
case FIOCLEX: ...
default:
/* 나머지는 드라이버의 unlocked_ioctl로 전달 */
if (filp->f_op->unlocked_ioctl)
return filp->f_op->unlocked_ioctl(filp, cmd, arg);
return -ENOTTY;
}
}
copy_to_user/copy_from_user)
오버헤드는 여전히 존재하며, 이를 피하려면 mmap을 사용합니다.
file_operations 완전 해설
file_operations 콜백 전체 목록
struct file_operations는 include/linux/fs.h에 정의된 함수 포인터 테이블로,
Character Device 드라이버가 사용자 공간에 노출하는 "공개 인터페이스"의 전부입니다.
사용자 공간에서 시스템 콜이 호출되면 VFS 계층이 해당 struct file의 f_op 포인터를 통해
드라이버의 콜백 함수를 호출합니다. 이 테이블의 각 필드를 NULL로 두면 VFS는 기본 동작(대부분 -EINVAL 또는
-ENOTTY 반환)을 수행합니다.
커널 6.x 기준으로 struct file_operations에는 30개 이상의 콜백이 정의되어 있지만,
일반적인 Character Device 드라이버에서 구현하는 것은 10~15개 정도입니다.
아래 표에서 기능별로 그룹화하여 주요 콜백을 정리합니다.
데이터 전송 그룹
| 콜백 | 시그니처 | 대응 시스템 콜 | 설명 |
|---|---|---|---|
.read |
ssize_t (*read)(struct file *, char __user *, size_t, loff_t *) |
read(2) |
디바이스에서 사용자 버퍼로 데이터를 복사합니다. copy_to_user()를 사용하여 커널 버퍼의 데이터를 사용자 공간으로 전달합니다. 반환값은 실제로 읽은 바이트 수이며, 0은 EOF를 의미합니다. |
.write |
ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *) |
write(2) |
사용자 버퍼에서 디바이스로 데이터를 복사합니다. copy_from_user()를 사용합니다. 반환값은 실제로 쓴 바이트 수입니다. |
.read_iter |
ssize_t (*read_iter)(struct kiocb *, struct iov_iter *) |
readv(2), preadv(2), AIO |
scatter-gather 읽기 및 비동기 I/O를 지원하는 현대적 인터페이스입니다. .read보다 우선합니다. iov_iter는 여러 버퍼 세그먼트를 추상화합니다. |
.write_iter |
ssize_t (*write_iter)(struct kiocb *, struct iov_iter *) |
writev(2), pwritev(2), AIO |
scatter-gather 쓰기 및 비동기 I/O를 지원합니다. .write보다 우선합니다. |
.splice_read |
ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int) |
splice(2) |
파이프를 통한 zero-copy 전송을 지원합니다. 구현하지 않으면 generic_file_splice_read()가 사용됩니다. |
제어 그룹
| 콜백 | 시그니처 | 대응 시스템 콜 | 설명 |
|---|---|---|---|
.unlocked_ioctl |
long (*unlocked_ioctl)(struct file *, unsigned int, unsigned long) |
ioctl(2) |
디바이스 제어 명령을 처리합니다. BKL(Big Kernel Lock) 없이 호출되는 현대적 버전입니다. 과거의 .ioctl(BKL 보호)은 2.6.36에서 완전히 제거되었습니다. |
.compat_ioctl |
long (*compat_ioctl)(struct file *, unsigned int, unsigned long) |
ioctl(2) (32-bit 호환) |
64비트 커널에서 32비트 사용자 프로세스의 ioctl 호출을 처리합니다. 포인터 크기 차이와 구조체 패딩 차이를 변환합니다. |
비동기/이벤트 그룹
| 콜백 | 시그니처 | 대응 시스템 콜 | 설명 |
|---|---|---|---|
.poll |
__poll_t (*poll)(struct file *, struct poll_table_struct *) |
poll(2), select(2), epoll_wait(2) |
I/O 이벤트 상태를 보고합니다. poll_wait()로 대기 큐를 등록하고, 현재 상태를 EPOLLIN|EPOLLRDNORM 등의 마스크로 반환합니다. |
.fasync |
int (*fasync)(int, struct file *, int) |
fcntl(2) (F_SETFL/FASYNC) |
비동기 알림(SIGIO) 설정/해제를 처리합니다. fasync_helper()로 fasync_struct 리스트를 관리하고, 이벤트 발생 시 kill_fasync()로 시그널을 전달합니다. |
메모리 매핑 그룹
| 콜백 | 시그니처 | 대응 시스템 콜 | 설명 |
|---|---|---|---|
.mmap |
int (*mmap)(struct file *, struct vm_area_struct *) |
mmap(2) |
디바이스 메모리(DMA 버퍼, 프레임 버퍼 등)를 사용자 가상 주소 공간에 매핑합니다. remap_pfn_range() 또는 vm_insert_page()를 사용합니다. |
탐색 그룹
| 콜백 | 시그니처 | 대응 시스템 콜 | 설명 |
|---|---|---|---|
.llseek |
loff_t (*llseek)(struct file *, loff_t, int) |
lseek(2) |
파일 오프셋을 변경합니다. SEEK_SET/SEEK_CUR/SEEK_END를 처리합니다. 순차 스트림 디바이스는 no_llseek 또는 noop_llseek을 지정합니다. 지정하지 않으면 default_llseek()이 사용되는데, 이는 inode->i_mutex를 잡으므로 성능에 불리합니다. |
.fsync |
int (*fsync)(struct file *, loff_t, loff_t, int datasync) |
fsync(2), fdatasync(2) |
보류 중인 데이터를 디바이스에 플러시합니다. Character Device에서는 하드웨어 FIFO를 비우거나 DMA 전송 완료를 대기하는 용도로 사용됩니다. |
열기/닫기 그룹
| 콜백 | 시그니처 | 대응 시스템 콜 | 설명 |
|---|---|---|---|
.open |
int (*open)(struct inode *, struct file *) |
open(2) |
디바이스 파일이 열릴 때 호출됩니다. 하드웨어 초기화, 리소스 할당, file->private_data 설정이 이루어집니다. container_of()로 inode에서 cdev를 추출하고, 이를 통해 드라이버 구조체를 얻습니다. |
.release |
int (*release)(struct inode *, struct file *) |
close(2) |
마지막 fd가 닫힐 때(참조 카운트가 0이 될 때) 호출됩니다. dup()이나 fork()로 fd가 복제된 경우, 모든 복제본이 닫혀야 호출됩니다. 리소스 해제를 수행합니다. |
.flush |
int (*flush)(struct file *, fl_owner_t id) |
close(2) |
close(2)가 호출될 때마다 호출됩니다(마지막이 아니어도). NFS 등에서 서버에 데이터를 플러시하는 용도이며, 대부분의 Character Device에서는 NULL로 둡니다. |
완전한 file_operations 구조체 초기화 예시
아래 코드는 일반적인 Character Device 드라이버에서 사용하는 file_operations 구조체의 전형적인 초기화 패턴입니다.
C99의 designated initializer 문법을 사용하며, 명시적으로 지정하지 않은 필드는 자동으로 NULL이 됩니다.
static const struct file_operations mydev_fops = {
.owner = THIS_MODULE,
/* 열기/닫기 */
.open = mydev_open,
.release = mydev_release,
/* 데이터 전송 */
.read = mydev_read,
.write = mydev_write,
.read_iter = mydev_read_iter, /* readv/preadv/AIO 지원 시 */
.write_iter = mydev_write_iter, /* writev/pwritev/AIO 지원 시 */
/* 제어 */
.unlocked_ioctl = mydev_ioctl,
.compat_ioctl = mydev_compat_ioctl, /* 32/64비트 호환 필요 시 */
/* 비동기/이벤트 */
.poll = mydev_poll,
.fasync = mydev_fasync,
/* 메모리 매핑 */
.mmap = mydev_mmap,
/* 탐색 */
.llseek = mydev_llseek, /* 스트림이면 no_llseek 사용 */
.fsync = mydev_fsync,
/* zero-copy */
.splice_read = mydev_splice_read,
};
.owner 필드에 THIS_MODULE을 설정하는 것은 모듈 안전성의 핵심입니다.
chrdev_open()은 디바이스 파일을 열 때 try_module_get(fops->owner)를 호출하여
모듈의 참조 카운트를 증가시킵니다. 이렇게 하면 디바이스 파일이 열려 있는 동안에는
rmmod로 모듈을 언로드할 수 없습니다. release 콜백이 호출된 후
fops_put()에서 module_put()이 호출되어 참조 카운트가 감소합니다.
.owner를 NULL로 두면 모듈이 사용 중일 때도 언로드가 가능해져,
커널 패닉이나 메모리 손상이 발생할 수 있습니다.
new_sync_read()/new_sync_write()는 .read_iter/.write_iter가
존재하면 이를 우선 사용하고, 없으면 .read/.write로 폴백합니다.
새로운 드라이버에서는 .read_iter/.write_iter를 구현하는 것이 권장됩니다.
이 인터페이스는 scatter-gather I/O, 비동기 I/O(io_uring), splice(2)를 자연스럽게 지원합니다.
ioctl 구현 패턴
ioctl 명령 번호 인코딩
리눅스 커널에서 ioctl 명령 번호는 단순한 정수가 아니라, 4개의 필드로 구조화된 32비트 값입니다.
include/uapi/asm-generic/ioctl.h에 정의된 매크로를 사용하여 명령 번호를 생성합니다.
이 인코딩 체계는 서로 다른 드라이버 간의 명령 번호 충돌을 방지하고,
커널이 데이터 전송 방향과 크기를 자동으로 파악할 수 있게 합니다.
| 비트 범위 | 필드 | 설명 |
|---|---|---|
| 31~30 (2비트) | direction | 데이터 전송 방향: _IOC_NONE(0), _IOC_WRITE(1), _IOC_READ(2), _IOC_READ|_IOC_WRITE(3) |
| 29~16 (14비트) | size | 데이터의 sizeof. 아키텍처에 따라 13~14비트 |
| 15~8 (8비트) | type | 매직 넘버. 드라이버별 고유 식별자 (보통 ASCII 문자) |
| 7~0 (8비트) | nr | 명령 순서 번호 (0~255) |
명령 정의 매크로
커널은 4가지 편의 매크로를 제공합니다. 이 매크로들은 위 인코딩 체계에 따라 방향, 타입, 번호, 크기를 조합하여 고유한 32비트 명령 번호를 생성합니다.
| 매크로 | 방향 | 의미 | 예시 |
|---|---|---|---|
_IO(type, nr) |
없음 | 데이터 전송 없는 명령 (단순 제어) | _IO('M', 0) — 디바이스 리셋 |
_IOR(type, nr, datatype) |
Read (커널→사용자) | 커널에서 사용자 공간으로 데이터 전달 | _IOR('M', 1, struct mydev_status) — 상태 읽기 |
_IOW(type, nr, datatype) |
Write (사용자→커널) | 사용자 공간에서 커널로 데이터 전달 | _IOW('M', 2, struct mydev_config) — 설정 쓰기 |
_IOWR(type, nr, datatype) |
Read+Write (양방향) | 양방향 데이터 교환 | _IOWR('M', 3, struct mydev_xfer) — 데이터 교환 |
ioctl 명령 정의와 핸들러 코드 예시
아래 코드는 전형적인 Character Device의 ioctl 명령 정의와 핸들러 구현 패턴을 보여줍니다. 명령 정의는 드라이버 헤더 파일에, 핸들러는 드라이버 소스에 위치합니다.
/* mydev_ioctl.h — 사용자/커널 공유 헤더 */
#include <linux/ioctl.h>
#define MYDEV_IOC_MAGIC 'M'
struct mydev_status {
__u32 flags;
__u32 error_count;
__u64 bytes_transferred;
};
struct mydev_config {
__u32 baud_rate;
__u32 data_bits;
__u32 stop_bits;
__u32 parity;
};
/* 명령 번호 정의 */
#define MYDEV_IOC_RESET _IO(MYDEV_IOC_MAGIC, 0)
#define MYDEV_IOC_GET_STATUS _IOR(MYDEV_IOC_MAGIC, 1, struct mydev_status)
#define MYDEV_IOC_SET_CONFIG _IOW(MYDEV_IOC_MAGIC, 2, struct mydev_config)
#define MYDEV_IOC_MAXNR 2
/* mydev_fops.c — ioctl 핸들러 */
static long mydev_ioctl(struct file *filp,
unsigned int cmd, unsigned long arg)
{
struct mydev_data *dev = filp->private_data;
struct mydev_status status;
struct mydev_config config;
int ret = 0;
/* 매직 넘버 검증 */
if (_IOC_TYPE(cmd) != MYDEV_IOC_MAGIC)
return -ENOTTY;
if (_IOC_NR(cmd) > MYDEV_IOC_MAXNR)
return -ENOTTY;
/* 사용자 버퍼 접근 가능성 검증 */
if (_IOC_DIR(cmd) & _IOC_READ)
if (!access_ok((void __user *)arg, _IOC_SIZE(cmd)))
return -EFAULT;
if (_IOC_DIR(cmd) & _IOC_WRITE)
if (!access_ok((void __user *)arg, _IOC_SIZE(cmd)))
return -EFAULT;
mutex_lock(&dev->lock);
switch (cmd) {
case MYDEV_IOC_RESET:
/* 하드웨어 리셋 수행 */
dev->data_len = 0;
dev->error_count = 0;
pr_info("mydev%d: device reset\n", dev->id);
break;
case MYDEV_IOC_GET_STATUS:
/* 커널 → 사용자: 상태 정보 전달 */
status.flags = dev->flags;
status.error_count = dev->error_count;
status.bytes_transferred = dev->bytes_transferred;
if (copy_to_user((void __user *)arg, &status,
sizeof(status))) {
ret = -EFAULT;
break;
}
break;
case MYDEV_IOC_SET_CONFIG:
/* 사용자 → 커널: 설정 수신 */
if (copy_from_user(&config, (void __user *)arg,
sizeof(config))) {
ret = -EFAULT;
break;
}
/* 유효성 검증 */
if (config.baud_rate == 0 || config.baud_rate > 4000000) {
ret = -EINVAL;
break;
}
dev->config = config;
/* 하드웨어에 새 설정 적용 */
mydev_apply_config(dev);
break;
default:
ret = -ENOTTY;
break;
}
mutex_unlock(&dev->lock);
return ret;
}
copy_from_user / copy_to_user 패턴
ioctl에서 사용자 공간과 데이터를 교환할 때는 반드시 copy_from_user()/copy_to_user()를
사용해야 합니다. 이 함수들은 사용자 포인터의 유효성을 검증하고, 페이지 폴트가 발생할 수 있는 상황을 안전하게 처리합니다.
직접 포인터를 역참조하면 커널 OOPS가 발생하거나, 보안 취약점(KASLR 우회 등)이 될 수 있습니다.
copy_from_user()는 복사에 실패한 바이트 수를 반환합니다(성공 시 0).
copy_to_user()도 동일합니다. 반환값이 0이 아니면 -EFAULT를 반환해야 합니다.
간단한 단일 값의 경우 get_user()/put_user() 매크로를 사용할 수 있습니다.
/* 단일 정수값 전달: put_user / get_user */
case MYDEV_IOC_GET_COUNT:
return put_user(dev->count, (int __user *)arg);
case MYDEV_IOC_SET_THRESHOLD: {
int val;
if (get_user(val, (int __user *)arg))
return -EFAULT;
dev->threshold = val;
return 0;
}
compat_ioctl: 32/64비트 호환성
64비트 커널에서 32비트 사용자 프로세스가 ioctl을 호출하면, 포인터 크기(4바이트 vs 8바이트)와
구조체 패딩 규칙의 차이로 인해 데이터 레이아웃이 달라질 수 있습니다.
.compat_ioctl 콜백은 이 차이를 변환하는 역할을 합니다.
구조체에 포인터 필드가 없고 패딩이 동일한 경우, .compat_ioctl에
.unlocked_ioctl과 같은 함수를 지정하면 됩니다. 포인터를 포함하는 구조체라면
compat_ptr()을 사용하여 32비트 포인터를 64비트로 변환하고,
compat_ 접두사의 호환 구조체를 정의하여 변환 후 원래 핸들러를 호출하는 래퍼를 작성합니다.
/* 포인터 없는 단순 구조체: 동일 핸들러 사용 가능 */
static const struct file_operations mydev_fops = {
.owner = THIS_MODULE,
.unlocked_ioctl = mydev_ioctl,
.compat_ioctl = mydev_ioctl, /* 구조체에 포인터 없으면 그대로 사용 */
};
poll/select/epoll 지원
비동기 I/O 대기의 필요성
사용자 공간 프로그램이 디바이스에서 데이터를 읽으려 할 때, 데이터가 아직 준비되지 않았다면
두 가지 선택지가 있습니다. 첫째는 read()에서 블로킹하여 데이터가 올 때까지 기다리는 것이고,
둘째는 O_NONBLOCK 플래그를 설정하고 -EAGAIN을 반환받는 것입니다.
하지만 여러 디바이스를 동시에 모니터링해야 하는 경우(서버, 이벤트 루프 등)에는
poll(2), select(2), 또는 epoll(7)을 사용하여
여러 파일 디스크립터의 상태를 한번에 확인해야 합니다.
이 세 가지 시스템 콜은 모두 VFS를 통해 궁극적으로 드라이버의 .poll 콜백을 호출합니다.
드라이버는 poll_wait()로 대기 큐를 등록하고, 현재 가능한 I/O 방향을 비트 마스크로 반환합니다.
wait_queue_head_t 초기화
poll/select 메커니즘의 핵심은 wait_queue_head_t입니다.
이는 프로세스가 잠들어 있다가 이벤트 발생 시 깨어나는 대기 큐의 헤드입니다.
일반적으로 디바이스 구조체에 읽기용과 쓰기용 대기 큐를 각각 선언합니다.
struct mydev_data {
struct cdev cdev;
struct mutex lock;
wait_queue_head_t read_wq; /* 읽기 가능 대기 큐 */
wait_queue_head_t write_wq; /* 쓰기 가능 대기 큐 */
char buffer[4096];
size_t data_len;
bool readable; /* 데이터 있음 플래그 */
};
/* 초기화: probe 또는 module_init에서 */
init_waitqueue_head(&dev->read_wq);
init_waitqueue_head(&dev->write_wq);
poll 콜백 구현
.poll 콜백은 두 가지 작업을 수행합니다.
첫째, poll_wait()를 호출하여 관련 대기 큐를 poll 테이블에 등록합니다.
이 호출 자체는 프로세스를 잠들게 하지 않습니다 — VFS의 poll 프레임워크가 모든 .poll 콜백을 호출한 후,
필요하면 등록된 대기 큐들에서 잠들게 합니다.
둘째, 현재 디바이스의 I/O 상태를 검사하여 비트 마스크를 반환합니다.
static __poll_t mydev_poll(struct file *filp,
struct poll_table_struct *wait)
{
struct mydev_data *dev = filp->private_data;
__poll_t mask = 0;
mutex_lock(&dev->lock);
/* 1단계: 대기 큐를 poll 테이블에 등록 (잠들지는 않음) */
poll_wait(filp, &dev->read_wq, wait);
poll_wait(filp, &dev->write_wq, wait);
/* 2단계: 현재 I/O 상태 검사 */
if (dev->data_len > 0)
mask |= EPOLLIN | EPOLLRDNORM; /* 읽기 가능 */
if (dev->data_len < sizeof(dev->buffer))
mask |= EPOLLOUT | EPOLLWRNORM; /* 쓰기 가능 */
if (dev->error_flag)
mask |= EPOLLERR; /* 에러 발생 */
if (dev->hangup)
mask |= EPOLLHUP; /* 연결 끊김 */
mutex_unlock(&dev->lock);
return mask;
}
poll 반환 마스크 상수
| 상수 | 의미 | 사용 상황 |
|---|---|---|
EPOLLIN | 읽기 가능 (블로킹 안 됨) | 버퍼에 데이터가 있을 때 |
EPOLLRDNORM | 일반 데이터 읽기 가능 | EPOLLIN과 함께 사용 |
EPOLLOUT | 쓰기 가능 (블로킹 안 됨) | 버퍼에 여유 공간이 있을 때 |
EPOLLWRNORM | 일반 데이터 쓰기 가능 | EPOLLOUT과 함께 사용 |
EPOLLERR | 에러 발생 | 하드웨어 오류, 프로토콜 에러 |
EPOLLHUP | 연결 끊김(hang up) | 피어 종료, 디바이스 분리 |
EPOLLRDBAND | 우선순위 데이터 읽기 가능 | 대역외(OOB) 데이터 |
EPOLLPRI | 긴급 데이터 | 예외적 상황 알림 |
wake_up으로 이벤트 알림
데이터가 사용 가능해지면(예: write 콜백에서 버퍼에 데이터를 썼을 때, 또는 인터럽트 핸들러에서 데이터를 수신했을 때),
wake_up_interruptible()을 호출하여 대기 큐에서 잠들어 있는 프로세스를 깨웁니다.
깨어난 프로세스의 poll 프레임워크는 다시 .poll 콜백을 호출하여 현재 상태를 확인합니다.
static ssize_t mydev_write(struct file *filp,
const char __user *buf,
size_t count, loff_t *ppos)
{
struct mydev_data *dev = filp->private_data;
size_t space, to_write;
ssize_t ret;
mutex_lock(&dev->lock);
space = sizeof(dev->buffer) - dev->data_len;
if (space == 0) {
if (filp->f_flags & O_NONBLOCK) {
ret = -EAGAIN;
goto out;
}
/* 블로킹 모드: 공간이 생길 때까지 대기 */
mutex_unlock(&dev->lock);
if (wait_event_interruptible(dev->write_wq,
dev->data_len < sizeof(dev->buffer)))
return -ERESTARTSYS;
mutex_lock(&dev->lock);
space = sizeof(dev->buffer) - dev->data_len;
}
to_write = min(count, space);
if (copy_from_user(dev->buffer + dev->data_len, buf, to_write)) {
ret = -EFAULT;
goto out;
}
dev->data_len += to_write;
ret = to_write;
/* 읽기 대기 큐를 깨움: 데이터가 생겼으므로 */
wake_up_interruptible(&dev->read_wq);
out:
mutex_unlock(&dev->lock);
return ret;
}
O_NONBLOCK 처리 패턴
Character Device의 read/write 콜백에서는 반드시 O_NONBLOCK 플래그를 검사해야 합니다.
이 플래그가 설정된 경우, 데이터가 없거나 공간이 부족하면 즉시 -EAGAIN을 반환해야 합니다.
플래그가 설정되지 않은 경우(블로킹 모드), wait_event_interruptible()로 조건이 만족될 때까지 대기합니다.
wait_event_interruptible()가 시그널로 중단되면 -ERESTARTSYS를 반환하여
VFS가 시스템 콜을 자동으로 재시작하거나 -EINTR을 사용자에게 전달하게 합니다.
static ssize_t mydev_read(struct file *filp,
char __user *buf,
size_t count, loff_t *ppos)
{
struct mydev_data *dev = filp->private_data;
size_t to_read;
ssize_t ret;
mutex_lock(&dev->lock);
/* 데이터가 없는 경우 */
while (dev->data_len == 0) {
mutex_unlock(&dev->lock);
/* O_NONBLOCK: 즉시 반환 */
if (filp->f_flags & O_NONBLOCK)
return -EAGAIN;
/* 블로킹 모드: 데이터가 올 때까지 대기 */
if (wait_event_interruptible(dev->read_wq,
dev->data_len > 0))
return -ERESTARTSYS;
mutex_lock(&dev->lock);
}
to_read = min(count, dev->data_len);
if (copy_to_user(buf, dev->buffer, to_read)) {
ret = -EFAULT;
goto out;
}
/* 읽은 데이터를 버퍼에서 제거 (앞으로 이동) */
memmove(dev->buffer, dev->buffer + to_read,
dev->data_len - to_read);
dev->data_len -= to_read;
ret = to_read;
/* 쓰기 대기 큐를 깨움: 공간이 생겼으므로 */
wake_up_interruptible(&dev->write_wq);
out:
mutex_unlock(&dev->lock);
return ret;
}
epoll과 poll/select는 동일한 .poll 콜백을 호출합니다.
차이는 VFS/커널 프레임워크 레벨에 있습니다. poll/select는 매 호출마다 모든 fd의 .poll을
다시 호출하지만, epoll은 epoll_ctl()로 등록 시 한 번만 대기 큐를 설정하고,
이후에는 wake_up에 의한 콜백으로만 깨어납니다. 따라서 fd 수가 많을수록 epoll이 유리합니다.
mmap 구현
mmap이 유용한 경우
일반적인 read()/write() 시스템 콜은 커널 버퍼와 사용자 버퍼 사이에
copy_to_user()/copy_from_user()로 데이터를 복사합니다.
이 복사 오버헤드는 소량의 데이터에는 무시할 수 있지만, 대용량 데이터를 고속으로 전송해야 하는
경우에는 심각한 병목이 됩니다. mmap()은 커널 메모리를 사용자 가상 주소 공간에 직접 매핑하여
데이터 복사를 완전히 제거합니다.
mmap이 특히 유용한 사용 사례는 다음과 같습니다:
- DMA 버퍼: DMA 컨트롤러가 직접 쓰는 버퍼를 사용자 공간에서 바로 읽기 (네트워크 카드, 사운드 카드)
- 프레임 버퍼: GPU나 디스플레이 컨트롤러의 비디오 메모리를 매핑 (V4L2, DRM/KMS)
- 디바이스 레지스터: MMIO(Memory-Mapped I/O) 영역을 사용자 공간에서 직접 접근 (FPGA, 고속 ADC)
- 공유 메모리: 커널-사용자 간 또는 프로세스 간 고속 데이터 교환
- 대용량 버퍼: 수 MB~GB 크기의 캡처/전송 버퍼를 zero-copy로 접근
remap_pfn_range() API
remap_pfn_range()는 .mmap 콜백에서 가장 흔히 사용되는 함수입니다.
물리 페이지 프레임 번호(PFN)에서 시작하는 연속 물리 메모리 영역을 사용자 가상 주소 공간에 매핑합니다.
int remap_pfn_range(
struct vm_area_struct *vma, /* 사용자의 VMA */
unsigned long addr, /* 매핑 시작 가상 주소 (보통 vma->vm_start) */
unsigned long pfn, /* 물리 페이지 프레임 번호 */
unsigned long size, /* 매핑 크기 (바이트) */
pgprot_t prot /* 페이지 보호 속성 */
);
| 매개변수 | 설명 |
|---|---|
vma |
커널이 .mmap 콜백에 전달하는 vm_area_struct. 매핑의 시작 주소(vm_start), 끝 주소(vm_end), 플래그(vm_flags) 등을 포함합니다. |
addr |
매핑할 사용자 공간 가상 주소. 일반적으로 vma->vm_start를 사용합니다. |
pfn |
물리 주소를 PAGE_SHIFT만큼 오른쪽 시프트한 값. virt_to_phys(addr) >> PAGE_SHIFT 또는 page_to_pfn(page)로 구합니다. |
size |
매핑 크기. vma->vm_end - vma->vm_start로 구합니다. 페이지 단위로 정렬되어야 합니다. |
prot |
페이지 보호 속성. vma->vm_page_prot를 그대로 사용하거나, 디바이스 메모리의 경우 pgprot_noncached()로 캐시를 비활성화합니다. |
vm_operations_struct
vm_operations_struct는 VMA(Virtual Memory Area)에 대한 콜백을 정의합니다.
mmap된 영역에서 페이지 폴트가 발생하거나, VMA가 복사(fork)되거나, 닫힐 때 호출됩니다.
모든 콜백은 선택적이며, 필요한 것만 구현합니다.
| 콜백 | 호출 시점 | 용도 |
|---|---|---|
.open |
VMA 생성 또는 복제(fork) 시 | 참조 카운트 증가, 리소스 추가 할당 |
.close |
VMA 해제 시 | 참조 카운트 감소, 리소스 해제 |
.fault |
매핑된 페이지에 최초 접근 시 (page fault) | 요청 시(on-demand) 페이지 할당 및 매핑. 대용량 버퍼에서 메모리를 절약하는 데 유용합니다. |
완전한 mmap 구현 예시
아래 코드는 커널에서 할당한 페이지를 사용자 공간에 매핑하는 완전한 예시입니다.
module_init에서 __get_free_pages()로 물리적으로 연속된 페이지를 할당하고,
.mmap 콜백에서 remap_pfn_range()로 사용자 공간에 매핑합니다.
#include <linux/mm.h>
#define MMAP_BUF_ORDER 4 /* 2^4 = 16 페이지 = 64KB */
#define MMAP_BUF_SIZE (PAGE_SIZE << MMAP_BUF_ORDER)
struct mydev_data {
struct cdev cdev;
void *mmap_buf; /* 커널 가상 주소 */
unsigned long mmap_phys; /* 물리 주소 */
struct mutex lock;
atomic_t mmap_count; /* mmap 참조 카운트 */
};
/* vm_operations_struct: VMA 이벤트 콜백 */
static void mydev_vm_open(struct vm_area_struct *vma)
{
struct mydev_data *dev = vma->vm_private_data;
atomic_inc(&dev->mmap_count);
pr_debug("mydev: vm_open, mmap_count=%d\n",
atomic_read(&dev->mmap_count));
}
static void mydev_vm_close(struct vm_area_struct *vma)
{
struct mydev_data *dev = vma->vm_private_data;
atomic_dec(&dev->mmap_count);
pr_debug("mydev: vm_close, mmap_count=%d\n",
atomic_read(&dev->mmap_count));
}
static const struct vm_operations_struct mydev_vm_ops = {
.open = mydev_vm_open,
.close = mydev_vm_close,
};
/* .mmap 콜백 */
static int mydev_mmap(struct file *filp,
struct vm_area_struct *vma)
{
struct mydev_data *dev = filp->private_data;
unsigned long size = vma->vm_end - vma->vm_start;
unsigned long pfn;
int ret;
/* 매핑 크기 검증 */
if (size > MMAP_BUF_SIZE) {
pr_err("mydev: mmap size %lu exceeds buffer %lu\n",
size, (unsigned long)MMAP_BUF_SIZE);
return -EINVAL;
}
/* 오프셋 검증 */
if (vma->vm_pgoff != 0) {
pr_err("mydev: mmap offset must be 0\n");
return -EINVAL;
}
/* 물리 페이지 프레임 번호 계산 */
pfn = dev->mmap_phys >> PAGE_SHIFT;
/* 페이지 테이블 엔트리 생성 */
ret = remap_pfn_range(vma, vma->vm_start, pfn,
size, vma->vm_page_prot);
if (ret) {
pr_err("mydev: remap_pfn_range failed: %d\n", ret);
return ret;
}
/* VMA 콜백 및 프라이빗 데이터 설정 */
vma->vm_ops = &mydev_vm_ops;
vma->vm_private_data = dev;
mydev_vm_open(vma); /* 최초 open은 자동으로 호출되지 않으므로 수동 호출 */
return 0;
}
/* 버퍼 할당 (module_init에서 호출) */
static int mydev_alloc_mmap_buf(struct mydev_data *dev)
{
dev->mmap_buf = (void *)__get_free_pages(GFP_KERNEL | __GFP_ZERO,
MMAP_BUF_ORDER);
if (!dev->mmap_buf)
return -ENOMEM;
dev->mmap_phys = virt_to_phys(dev->mmap_buf);
atomic_set(&dev->mmap_count, 0);
/* SetPageReserved: swap-out 방지 */
{
int i;
struct page *page = virt_to_page(dev->mmap_buf);
for (i = 0; i < (1 << MMAP_BUF_ORDER); i++)
SetPageReserved(page + i);
}
return 0;
}
/* 버퍼 해제 (module_exit에서 호출) */
static void mydev_free_mmap_buf(struct mydev_data *dev)
{
if (dev->mmap_buf) {
int i;
struct page *page = virt_to_page(dev->mmap_buf);
for (i = 0; i < (1 << MMAP_BUF_ORDER); i++)
ClearPageReserved(page + i);
free_pages((unsigned long)dev->mmap_buf, MMAP_BUF_ORDER);
}
}
디바이스 메모리의 캐시 비활성화
PCI BAR(Base Address Register)나 MMIO 영역처럼 실제 하드웨어 레지스터를 매핑할 때는 CPU 캐시를 비활성화해야 합니다. 캐시가 활성화되면 CPU가 레지스터 값을 캐시에서 읽어 하드웨어의 실제 상태와 불일치가 발생할 수 있습니다.
/* 디바이스 MMIO 레지스터 매핑 시: 캐시 비활성화 */
static int mydev_mmap_regs(struct file *filp,
struct vm_area_struct *vma)
{
struct mydev_data *dev = filp->private_data;
/* 캐시 비활성화: 디바이스 메모리에 필수 */
vma->vm_page_prot = pgprot_noncached(vma->vm_page_prot);
/* VM_IO: 이 VMA가 I/O 메모리임을 표시 (core dump 시 제외) */
vm_flags_set(vma, VM_IO | VM_DONTEXPAND | VM_DONTDUMP);
return remap_pfn_range(vma, vma->vm_start,
dev->mmio_phys >> PAGE_SHIFT,
vma->vm_end - vma->vm_start,
vma->vm_page_prot);
}
mmap은 사용자 공간에 커널 메모리의 직접 접근을 허용하므로 보안에 각별히 주의해야 합니다.
매핑 크기와 오프셋을 반드시 검증하고, 커널의 다른 영역이 노출되지 않도록 해야 합니다.
vm_pgoff 필드를 통한 오프셋 공격을 방지하기 위해 허용된 범위만 매핑해야 합니다.
특히 remap_pfn_range()에 전달하는 PFN이 드라이버가 소유한 메모리 범위 내에 있는지
반드시 확인해야 합니다.
동시성과 잠금
Character Device에서의 경쟁 조건
Character Device 드라이버는 여러 프로세스가 동시에 디바이스 파일을 열고, 읽고, 쓰고, ioctl을 호출할 수 있으므로 동시성 문제를 반드시 고려해야 합니다. 특히 다음과 같은 시나리오에서 경쟁 조건(race condition)이 발생할 수 있습니다:
- 동시 read/write: 두 프로세스가 같은 내부 버퍼를 읽고 쓰면 데이터 손상 발생
- 동시 open: 여러 프로세스가 동시에 디바이스를 열면서 초기화 코드가 중복 실행
- 프로세스 컨텍스트 + 인터럽트: read 콜백 실행 중에 인터럽트 핸들러가 같은 버퍼를 수정
- ioctl과 read/write 경합: 설정 변경(ioctl)과 데이터 전송(read/write)이 동시에 일어남
- SMP 환경: 멀티코어 시스템에서 동일한 코드가 여러 CPU에서 동시에 실행
mutex (권장 기본 잠금)
대부분의 Character Device 드라이버에서는 struct mutex가 가장 적합한 잠금 메커니즘입니다.
mutex는 프로세스 컨텍스트에서만 사용할 수 있으며, 잠금을 획득하지 못하면 프로세스를 잠들게 합니다.
이는 긴 임계 영역(I/O 대기, copy_to_user 등)에 적합합니다.
struct mydev_data {
struct cdev cdev;
struct mutex lock; /* 디바이스별 mutex */
char buffer[4096];
size_t data_len;
};
/* 초기화 */
mutex_init(&dev->lock);
/* read 콜백에서의 사용 */
static ssize_t mydev_read(struct file *filp,
char __user *buf,
size_t count, loff_t *ppos)
{
struct mydev_data *dev = filp->private_data;
ssize_t ret;
/* 시그널에 의해 중단 가능한 잠금 획득 */
if (mutex_lock_interruptible(&dev->lock))
return -ERESTARTSYS;
if (dev->data_len == 0) {
ret = 0; /* EOF */
goto out;
}
size_t to_read = min(count, dev->data_len);
if (copy_to_user(buf, dev->buffer, to_read)) {
ret = -EFAULT;
goto out;
}
dev->data_len -= to_read;
memmove(dev->buffer, dev->buffer + to_read, dev->data_len);
ret = to_read;
out:
mutex_unlock(&dev->lock);
return ret;
}
mutex_lock()은 잠금을 얻을 때까지 무한정 대기하며 시그널로 중단할 수 없습니다.
사용자 공간에서 호출된 시스템 콜에서는 mutex_lock_interruptible()을 사용하여
SIGTERM/SIGKILL 등의 시그널로 중단할 수 있게 해야 합니다.
그렇지 않으면 프로세스가 TASK_UNINTERRUPTIBLE 상태에 빠져
kill -9으로도 종료할 수 없는 좀비가 될 수 있습니다.
spinlock (인터럽트 컨텍스트용)
인터럽트 핸들러에서 공유 자원에 접근해야 하는 경우, mutex는 사용할 수 없습니다
(인터럽트 컨텍스트에서는 잠들 수 없기 때문). 이때는 spinlock을 사용합니다.
spinlock은 바쁜 대기(busy-wait)를 수행하므로, 임계 영역이 매우 짧아야 합니다.
struct mydev_data {
spinlock_t irq_lock; /* 인터럽트-안전 spinlock */
char hw_buffer[256];
size_t hw_len;
bool data_ready;
};
/* 인터럽트 핸들러: 하드웨어에서 데이터 수신 */
static irqreturn_t mydev_irq_handler(int irq, void *dev_id)
{
struct mydev_data *dev = dev_id;
unsigned long flags;
spin_lock_irqsave(&dev->irq_lock, flags);
/* 하드웨어 FIFO에서 데이터 읽기 (짧은 작업만!) */
dev->hw_buffer[dev->hw_len++] = ioread8(dev->reg_data);
dev->data_ready = true;
spin_unlock_irqrestore(&dev->irq_lock, flags);
/* 대기 큐 깨움 (spinlock 해제 후!) */
wake_up_interruptible(&dev->read_wq);
return IRQ_HANDLED;
}
/* read 콜백: 프로세스 컨텍스트에서 인터럽트와 동기화 */
static ssize_t mydev_read(struct file *filp,
char __user *buf,
size_t count, loff_t *ppos)
{
struct mydev_data *dev = filp->private_data;
unsigned long flags;
char tmp[256];
size_t len;
/* spinlock으로 인터럽트 핸들러와 동기화 */
spin_lock_irqsave(&dev->irq_lock, flags);
len = min(count, dev->hw_len);
memcpy(tmp, dev->hw_buffer, len);
dev->hw_len -= len;
memmove(dev->hw_buffer, dev->hw_buffer + len, dev->hw_len);
spin_unlock_irqrestore(&dev->irq_lock, flags);
/* copy_to_user는 잠들 수 있으므로 spinlock 밖에서 호출 */
if (copy_to_user(buf, tmp, len))
return -EFAULT;
return len;
}
copy_to_user(), copy_from_user(), kmalloc(GFP_KERNEL),
mutex_lock() 등은 모두 잠들 수 있으므로 spinlock 안에서 호출하면 안 됩니다.
위 코드처럼 spinlock 영역 안에서는 커널 버퍼(tmp)에만 복사하고,
spinlock 해제 후에 copy_to_user()를 호출하는 패턴을 사용합니다.
atomic 연산 (단순 카운터)
열린 파일 수 제한, 통계 카운터 등 단순한 정수 값의 원자적 조작에는
atomic_t/atomic_long_t를 사용합니다.
잠금보다 오버헤드가 적고, 인터럽트/프로세스 컨텍스트 양쪽에서 안전합니다.
struct mydev_data {
atomic_t open_count; /* 현재 열린 fd 수 */
atomic_long_t bytes_read; /* 총 읽은 바이트 통계 */
};
/* open: 최대 동시 접근 수 제한 */
static int mydev_open(struct inode *inode, struct file *filp)
{
struct mydev_data *dev = container_of(
inode->i_cdev, struct mydev_data, cdev);
/* 최대 4개 동시 접근 허용 */
if (atomic_read(&dev->open_count) >= 4)
return -EBUSY;
atomic_inc(&dev->open_count);
filp->private_data = dev;
return 0;
}
/* release: 카운터 감소 */
static int mydev_release(struct inode *inode, struct file *filp)
{
struct mydev_data *dev = filp->private_data;
atomic_dec(&dev->open_count);
return 0;
}
/* 참고: 위 코드에는 TOCTOU 경쟁이 있음.
완벽하려면 atomic_inc_unless_negative나 별도 잠금 필요 */
per-device 잠금 패턴
여러 디바이스 인스턴스(Minor 번호별)가 존재하는 경우, 전역 잠금 대신 디바이스별 잠금을 사용하면
서로 다른 인스턴스 간의 불필요한 직렬화를 피할 수 있습니다.
file->private_data에 디바이스 구조체 포인터를 저장하고,
각 콜백에서 이 구조체의 잠금을 사용하는 패턴이 표준입니다.
/* 디바이스별 구조체에 잠금 포함 */
struct mydev_data {
struct cdev cdev;
struct mutex lock; /* 이 인스턴스 전용 잠금 */
spinlock_t irq_lock; /* 이 인스턴스 전용 IRQ 잠금 */
int id;
/* ... 인스턴스별 데이터 ... */
};
/* open에서 private_data 설정 → 이후 모든 콜백에서 사용 */
static int mydev_open(struct inode *inode, struct file *filp)
{
struct mydev_data *dev = container_of(
inode->i_cdev, struct mydev_data, cdev);
filp->private_data = dev;
return 0;
}
/* read: 디바이스별 잠금만 사용 → 다른 인스턴스와 독립적 */
static ssize_t mydev_read(struct file *filp,
char __user *buf,
size_t count, loff_t *ppos)
{
struct mydev_data *dev = filp->private_data;
if (mutex_lock_interruptible(&dev->lock))
return -ERESTARTSYS;
/* ... 이 디바이스 인스턴스의 데이터만 접근 ... */
mutex_unlock(&dev->lock);
return ret;
}
잠금 메커니즘 비교 및 선택 가이드
| 메커니즘 | 잠들 수 있는가 | 인터럽트 사용 | 임계 영역 길이 | 적합한 상황 |
|---|---|---|---|---|
mutex |
예 | 불가 | 긴 임계 영역 가능 | 대부분의 경우 (read/write/ioctl 콜백) |
spinlock |
아니오 | 가능 (_irqsave) |
매우 짧아야 함 | 인터럽트 핸들러, 짧은 커널 버퍼 조작 |
rwlock |
아니오 | 가능 | 짧아야 함 | 읽기 많고 쓰기 드문 경우 (설정 테이블) |
rw_semaphore |
예 | 불가 | 긴 임계 영역 가능 | 읽기 많은 프로세스 컨텍스트 |
atomic_t |
아니오 | 가능 | 단일 연산 | 카운터, 플래그, 단순 상태 |
RCU |
읽기: 아니오 / 쓰기: 예 | 읽기: 가능 | 읽기: 자유 / 쓰기: 대기 | 읽기 매우 빈번하고 쓰기 극히 드문 경우 |
RCU (Read-Copy-Update) 간략 소개
RCU는 읽기 경로에서 잠금을 전혀 사용하지 않는 동시성 메커니즘입니다.
읽기 측은 rcu_read_lock()/rcu_read_unlock()으로 보호하고,
쓰기 측은 데이터의 새 복사본을 만들고, rcu_assign_pointer()로 포인터를 교체한 후,
synchronize_rcu() 또는 call_rcu()로 기존 복사본의 안전한 해제를 대기합니다.
Character Device에서 RCU가 유용한 경우는 설정 구조체나 라우팅 테이블 등 읽기가 매우 빈번하고 변경이 드문 데이터에 적합합니다. 대부분의 Character Device 드라이버에서는 mutex나 spinlock으로 충분하므로, RCU는 성능이 극도로 중요한 경우에만 고려합니다.
lockdep 기능(CONFIG_PROVE_LOCKING)은 런타임에 잠금 순서 위반을 감지합니다.
misc device (miscdevice)
misc device란?
Misc device(miscellaneous device)는 Character Device의 등록 절차를 극도로 단순화한 프레임워크입니다.
모든 misc device는 Major 번호 10을 공유하며, Minor 번호로 개별 디바이스를 구분합니다.
커널 소스에서 drivers/char/misc.c에 구현되어 있으며,
misc_register() 한 번의 호출로 디바이스 번호 할당, cdev 등록, sysfs 노드 생성,
/dev 노드 생성까지 모두 처리합니다.
일반적인 cdev 등록이 5단계(alloc_chrdev_region → cdev_init → cdev_add → class_create → device_create)를 거치는 반면, misc device는 1단계(misc_register)로 모든 것을 완료합니다. 단, Major 번호를 드라이버 고유로 가질 수 없고, Minor 번호 범위가 0~255로 제한되며, 다중 인스턴스 관리가 번거롭다는 한계가 있습니다.
struct miscdevice
struct miscdevice는 misc device를 정의하는 구조체입니다.
include/linux/miscdevice.h에 정의되어 있으며, 주요 필드는 다음과 같습니다.
| 필드 | 타입 | 설명 |
|---|---|---|
minor |
int |
Minor 번호. MISC_DYNAMIC_MINOR(255)를 지정하면 커널이 자동 할당합니다. 고정 번호를 사용하려면 include/linux/miscdevice.h에 정의된 값을 사용합니다. |
name |
const char * |
디바이스 이름. /dev/ 아래에 이 이름으로 노드가 생성됩니다. |
fops |
const struct file_operations * |
file_operations 테이블. 드라이버의 read/write/ioctl 등의 콜백을 지정합니다. |
parent |
struct device * |
부모 디바이스. 플랫폼 디바이스 등에서 계층 구조를 표현할 때 사용합니다. NULL이면 /sys/devices/virtual/misc/ 아래에 위치합니다. |
mode |
umode_t |
디바이스 파일의 퍼미션. 0이면 기본값(0600)이 사용됩니다. 예: 0666은 모든 사용자 읽기/쓰기 가능. |
nodename |
const char * |
/dev/ 아래의 경로. NULL이면 name 필드가 사용됩니다. 서브디렉토리를 포함할 수 있습니다 (예: "mydir/mydev"). |
this_device |
struct device * |
misc_register() 후 생성된 struct device 포인터. sysfs 속성 등록에 사용합니다. (출력 전용) |
misc_register / misc_deregister API
misc_register()는 내부적으로 다음을 순서대로 수행합니다:
MISC_DYNAMIC_MINOR인 경우 사용 가능한 Minor 번호 자동 할당device_create()로/sys/class/misc/아래에 sysfs 노드 생성- devtmpfs/udev가
/dev/노드를 자동 생성 - 내부적으로 cdev를 생성하고 Major 10 + 할당된 Minor로 등록
misc_deregister()는 이 모든 것을 역순으로 해제합니다.
에러 처리 코드를 드라이버가 직접 작성할 필요가 없으므로 코드가 매우 간결해집니다.
/* API 프로토타입 */
int misc_register(struct miscdevice *misc);
void misc_deregister(struct miscdevice *misc);
완전한 misc device 드라이버 예시
아래 코드는 가장 간결한 형태의 misc device 드라이버입니다. 내부 버퍼에 데이터를 쓰고 읽을 수 있는 기본적인 Character Device를 약 80줄의 코드로 구현합니다.
#include <linux/module.h>
#include <linux/miscdevice.h>
#include <linux/fs.h>
#include <linux/uaccess.h>
#include <linux/mutex.h>
#define BUF_SIZE 4096
static char buffer[BUF_SIZE];
static size_t data_len;
static DEFINE_MUTEX(mymisc_lock);
static int mymisc_open(struct inode *inode, struct file *filp)
{
pr_info("mymisc: opened\n");
return 0;
}
static int mymisc_release(struct inode *inode, struct file *filp)
{
pr_info("mymisc: closed\n");
return 0;
}
static ssize_t mymisc_read(struct file *filp,
char __user *buf,
size_t count, loff_t *ppos)
{
size_t to_read;
ssize_t ret;
if (mutex_lock_interruptible(&mymisc_lock))
return -ERESTARTSYS;
to_read = min(count, data_len);
if (to_read == 0) {
ret = 0;
goto out;
}
if (copy_to_user(buf, buffer, to_read)) {
ret = -EFAULT;
goto out;
}
memmove(buffer, buffer + to_read, data_len - to_read);
data_len -= to_read;
ret = to_read;
out:
mutex_unlock(&mymisc_lock);
return ret;
}
static ssize_t mymisc_write(struct file *filp,
const char __user *buf,
size_t count, loff_t *ppos)
{
size_t space, to_write;
ssize_t ret;
if (mutex_lock_interruptible(&mymisc_lock))
return -ERESTARTSYS;
space = BUF_SIZE - data_len;
to_write = min(count, space);
if (to_write == 0) {
ret = -ENOSPC;
goto out;
}
if (copy_from_user(buffer + data_len, buf, to_write)) {
ret = -EFAULT;
goto out;
}
data_len += to_write;
ret = to_write;
out:
mutex_unlock(&mymisc_lock);
return ret;
}
static const struct file_operations mymisc_fops = {
.owner = THIS_MODULE,
.open = mymisc_open,
.release = mymisc_release,
.read = mymisc_read,
.write = mymisc_write,
};
static struct miscdevice mymisc_dev = {
.minor = MISC_DYNAMIC_MINOR, /* 커널이 Minor 번호 자동 할당 */
.name = "mymisc", /* /dev/mymisc 생성 */
.fops = &mymisc_fops,
.mode = 0666, /* 모든 사용자 읽기/쓰기 가능 */
};
static int __init mymisc_init(void)
{
int ret = misc_register(&mymisc_dev);
if (ret) {
pr_err("mymisc: misc_register failed: %d\n", ret);
return ret;
}
pr_info("mymisc: registered (minor=%d)\n",
mymisc_dev.minor);
return 0;
}
static void __exit mymisc_exit(void)
{
misc_deregister(&mymisc_dev);
pr_info("mymisc: deregistered\n");
}
module_init(mymisc_init);
module_exit(mymisc_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Example");
MODULE_DESCRIPTION("Minimal misc device driver");
misc device vs full cdev: 선택 기준
어떤 방식을 사용할지는 드라이버의 요구사항에 따라 결정합니다. 아래 표는 두 방식의 장단점을 비교합니다.
| 기준 | misc device | full cdev |
|---|---|---|
| 등록 코드 복잡도 | 매우 간단 (1 함수 호출) | 5단계 절차 + 에러 처리 |
| Major 번호 | 10 (공유) | 드라이버 고유 Major 할당 가능 |
| Minor 번호 범위 | 0~255 (8비트) | 0~1,048,575 (20비트) |
| 다중 인스턴스 | 각각 misc_register (번거로움) | cdev_add(dev, base, count)로 범위 등록 |
| sysfs 클래스 | /sys/class/misc/ (공유) |
드라이버 고유 클래스 가능 |
| /dev 이름 제어 | name 필드로 단순 설정 |
device_create()에서 자유롭게 설정 |
| 적합한 경우 | 단일 인스턴스, 간단한 디바이스, 프로토타이핑 | 다중 인스턴스, 복잡한 디바이스 계층, 프로덕션 드라이버 |
| 대표 사용례 | /dev/watchdog, /dev/fuse, /dev/hw_random, /dev/vhost-net |
/dev/ttyS*, /dev/video*, /dev/snd/*, /dev/nvme* |
/dev/watchdog(소프트웨어 워치독), /dev/fuse(FUSE 파일시스템),
/dev/vhost-net(virtio 네트워크 가속) 등이 misc device입니다.
다중 디바이스 인스턴스
문제: 하나의 드라이버, 여러 디바이스 노드
실제 시스템에서는 동일한 드라이버가 여러 디바이스 인스턴스를 동시에 관리해야 하는 경우가 매우 흔합니다.
예를 들어 시리얼 포트 드라이버는 /dev/ttyS0, /dev/ttyS1, /dev/ttyS2 등
복수의 포트를 제어하고, UART 드라이버는 보드에 따라 2~8개의 채널을 동시에 서비스합니다.
이때 모든 디바이스 노드가 동일한 file_operations를 공유하지만,
각 노드는 독립적인 하드웨어 상태(레지스터 베이스 주소, IRQ, 버퍼 등)를 가져야 합니다.
단순히 전역 변수로 디바이스 상태를 관리하면 인스턴스 간 데이터가 충돌합니다.
이 문제를 해결하는 핵심 패턴이 바로 per-device 구조체 + container_of 매크로입니다.
리눅스 커널의 거의 모든 드라이버 프레임워크가 이 패턴을 사용하며,
Character Device에서는 struct cdev를 per-device 구조체에 임베딩하는 방식으로 구현합니다.
container_of 매크로
container_of는 리눅스 커널에서 가장 중요한 매크로 중 하나입니다.
구조체 멤버의 포인터로부터 해당 멤버를 포함하는 부모 구조체의 포인터를 역산합니다.
이 매크로는 include/linux/container_of.h에 정의되어 있으며, 원리는 단순한 포인터 산술입니다.
/* include/linux/container_of.h */
#define container_of(ptr, type, member) ({ \
void *__mptr = (void *)(ptr); \
static_assert(__same_type(*(ptr), ((type *)0)->member) || \
__same_type(*(ptr), void), \
"pointer type mismatch in container_of()"); \
((type *)(__mptr - offsetof(type, member))); })
동작 원리: ptr이 가리키는 멤버의 주소에서 해당 멤버의 구조체 내 오프셋(offsetof)을
빼면, 부모 구조체의 시작 주소를 얻을 수 있습니다.
static_assert로 타입 안전성까지 보장합니다.
Per-Device 구조체 패턴
다중 인스턴스 드라이버의 핵심 설계 패턴은 다음과 같습니다.
struct cdev를 디바이스별 데이터 구조체에 직접 임베딩하고,
open 콜백에서 container_of를 사용하여 per-device 구조체를 복원한 뒤
filp->private_data에 저장합니다.
/* per-device 데이터 구조체 정의 */
struct my_dev {
struct cdev cdev; /* cdev를 임베딩 (포인터가 아님!) */
struct mutex lock; /* 디바이스별 잠금 */
unsigned char *buffer; /* 디바이스별 데이터 버퍼 */
size_t buf_size; /* 버퍼 크기 */
size_t data_len; /* 현재 데이터 길이 */
int irq; /* 디바이스별 IRQ */
void __iomem *regs; /* 디바이스별 레지스터 매핑 */
int index; /* 인스턴스 번호 */
};
/* 드라이버 전역: N개 인스턴스 배열 */
#define MY_DEV_COUNT 4
static struct my_dev *my_devices;
static dev_t my_dev_base; /* 첫 번째 디바이스 번호 */
static struct class *my_class;
open 콜백에서 per-device 구조체 복원
open이 호출되면 inode->i_cdev는 해당 디바이스 노드에 연결된
struct cdev를 가리킵니다. 이 포인터에 container_of를 적용하면
per-device 구조체를 즉시 복원할 수 있습니다.
static int my_open(struct inode *inode, struct file *filp)
{
struct my_dev *dev;
/* inode->i_cdev → 임베딩된 cdev → 부모 struct my_dev 복원 */
dev = container_of(inode->i_cdev, struct my_dev, cdev);
/* per-device 구조체를 filp->private_data에 저장 */
filp->private_data = dev;
pr_info("my_open: device #%d opened\n", dev->index);
return 0;
}
static ssize_t my_read(struct file *filp, char __user *buf,
size_t count, loff_t *f_pos)
{
/* private_data에서 per-device 구조체 복원 */
struct my_dev *dev = filp->private_data;
mutex_lock(&dev->lock);
if (*f_pos >= dev->data_len) {
mutex_unlock(&dev->lock);
return 0; /* EOF */
}
if (*f_pos + count > dev->data_len)
count = dev->data_len - *f_pos;
if (copy_to_user(buf, dev->buffer + *f_pos, count)) {
mutex_unlock(&dev->lock);
return -EFAULT;
}
*f_pos += count;
mutex_unlock(&dev->lock);
return count;
}
static ssize_t my_write(struct file *filp, const char __user *buf,
size_t count, loff_t *f_pos)
{
struct my_dev *dev = filp->private_data;
mutex_lock(&dev->lock);
if (*f_pos + count > dev->buf_size)
count = dev->buf_size - *f_pos;
if (count == 0) {
mutex_unlock(&dev->lock);
return -ENOSPC;
}
if (copy_from_user(dev->buffer + *f_pos, buf, count)) {
mutex_unlock(&dev->lock);
return -EFAULT;
}
*f_pos += count;
if (*f_pos > dev->data_len)
dev->data_len = *f_pos;
mutex_unlock(&dev->lock);
return count;
}
완전한 다중 인스턴스 드라이버 예제
다음은 N개의 인스턴스를 kmalloc 배열로 관리하는 완전한 init/exit 예제입니다.
에러 처리 시 이미 등록된 디바이스를 역순으로 해제하는 패턴에 주목하세요.
#define MY_DEV_COUNT 4
#define MY_BUF_SIZE 4096
#define DEVICE_NAME "mydev"
static const struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
};
static int __init my_init(void)
{
int i, err;
/* 1) 디바이스 번호 범위 할당 */
err = alloc_chrdev_region(&my_dev_base, 0, MY_DEV_COUNT, DEVICE_NAME);
if (err < 0)
return err;
/* 2) 클래스 생성 */
my_class = class_create(DEVICE_NAME);
if (IS_ERR(my_class)) {
err = PTR_ERR(my_class);
goto fail_region;
}
/* 3) per-device 구조체 배열 할당 */
my_devices = kcalloc(MY_DEV_COUNT, sizeof(struct my_dev), GFP_KERNEL);
if (!my_devices) {
err = -ENOMEM;
goto fail_class;
}
/* 4) 각 인스턴스 초기화 및 등록 */
for (i = 0; i < MY_DEV_COUNT; i++) {
struct my_dev *dev = &my_devices[i];
dev_t devno = MKDEV(MAJOR(my_dev_base), MINOR(my_dev_base) + i);
dev->index = i;
dev->buf_size = MY_BUF_SIZE;
dev->buffer = kzalloc(MY_BUF_SIZE, GFP_KERNEL);
if (!dev->buffer) {
err = -ENOMEM;
goto fail_devs;
}
mutex_init(&dev->lock);
cdev_init(&dev->cdev, &my_fops);
dev->cdev.owner = THIS_MODULE;
err = cdev_add(&dev->cdev, devno, 1);
if (err)
goto fail_devs;
if (IS_ERR(device_create(my_class, NULL, devno, NULL,
DEVICE_NAME "%d", i))) {
err = -ENOMEM;
cdev_del(&dev->cdev);
goto fail_devs;
}
}
pr_info("my_init: registered %d devices (major=%d)\n",
MY_DEV_COUNT, MAJOR(my_dev_base));
return 0;
fail_devs:
/* 역순으로 이미 등록된 디바이스 해제 */
while (--i >= 0) {
dev_t devno = MKDEV(MAJOR(my_dev_base), MINOR(my_dev_base) + i);
device_destroy(my_class, devno);
cdev_del(&my_devices[i].cdev);
mutex_destroy(&my_devices[i].lock);
kfree(my_devices[i].buffer);
}
kfree(my_devices);
fail_class:
class_destroy(my_class);
fail_region:
unregister_chrdev_region(my_dev_base, MY_DEV_COUNT);
return err;
}
static void __exit my_exit(void)
{
int i;
for (i = 0; i < MY_DEV_COUNT; i++) {
dev_t devno = MKDEV(MAJOR(my_dev_base), MINOR(my_dev_base) + i);
device_destroy(my_class, devno);
cdev_del(&my_devices[i].cdev);
mutex_destroy(&my_devices[i].lock);
kfree(my_devices[i].buffer);
}
kfree(my_devices);
class_destroy(my_class);
unregister_chrdev_region(my_dev_base, MY_DEV_COUNT);
pr_info("my_exit: all devices unregistered\n");
}
module_init(my_init);
module_exit(my_exit);
IDA를 이용한 동적 Minor 번호 관리
인스턴스 수가 고정되지 않은 경우(핫플러그 디바이스, 가상 디바이스 등), IDA(ID Allocator)를 사용하여 Minor 번호를 동적으로 할당/회수할 수 있습니다. IDA는 IDR(ID Radix tree)의 경량 래퍼로, 정수 ID를 효율적으로 관리합니다.
#include <linux/idr.h>
static DEFINE_IDA(my_ida);
/* 새 인스턴스 생성 시 */
static int my_probe(struct platform_device *pdev)
{
struct my_dev *dev;
int minor, err;
/* IDA에서 Minor 번호 할당 (0 ~ MY_DEV_COUNT-1 범위) */
minor = ida_alloc_max(&my_ida, MY_DEV_COUNT - 1, GFP_KERNEL);
if (minor < 0)
return minor;
dev = kzalloc(sizeof(*dev), GFP_KERNEL);
if (!dev) {
ida_free(&my_ida, minor);
return -ENOMEM;
}
dev->index = minor;
/* ... cdev_init, cdev_add, device_create ... */
platform_set_drvdata(pdev, dev);
return 0;
}
/* 인스턴스 제거 시 */
static int my_remove(struct platform_device *pdev)
{
struct my_dev *dev = platform_get_drvdata(pdev);
/* ... device_destroy, cdev_del ... */
ida_free(&my_ida, dev->index);
kfree(dev->buffer);
kfree(dev);
return 0;
}
/* 모듈 해제 시 전체 정리 */
static void __exit my_exit(void)
{
ida_destroy(&my_ida);
/* ... */
}
private_data 활용 패턴
filp->private_data는 드라이버가 파일 디스크립터별로 자유롭게 사용할 수 있는 void * 포인터입니다.
활용 패턴은 크게 세 가지로 나뉩니다.
| 패턴 | 구현 방식 | 사용 시기 | 예시 |
|---|---|---|---|
| Per-device state | filp->private_data = dev; (per-device 구조체 포인터) |
디바이스별 상태만 관리. 같은 디바이스를 여러 번 open해도 동일한 상태를 공유 | GPIO 드라이버, 센서 드라이버 |
| Per-open state | open마다 새 구조체를 kmalloc하여 저장, release에서 kfree |
각 open 세션이 독립적인 상태를 가져야 할 때 (예: 독립적인 읽기 위치, 세션별 필터) |
/dev/random, 가상 파일 디바이스 |
| Combined | per-open 구조체 안에 per-device 포인터를 포함. struct my_session { struct my_dev *dev; loff_t pos; ... }; |
디바이스별 공유 자원 + open별 독립 상태 모두 필요 | 시리얼 포트(디바이스별 UART 레지스터 + 세션별 라인 설정) |
container_of는 open 콜백에서 한 번만 호출하고,
이후 모든 콜백에서는 filp->private_data를 통해 per-device 구조체에 접근하는 것이 관례입니다.
이렇게 하면 inode 매개변수가 없는 read, write, ioctl 등에서도
디바이스별 데이터에 접근할 수 있습니다.
sysfs 속성과 uevent
sysfs와 Character Device의 통합
sysfs(/sys 파일시스템)는 커널 객체의 속성을 사용자 공간에 노출하는 가상 파일시스템입니다.
device_create()로 Character Device를 등록하면 자동으로 /sys/class/<class_name>/<dev_name>/
디렉터리가 생성됩니다. 이 디렉터리에 커스텀 속성 파일을 추가하면
사용자 공간 프로그램이나 udev 규칙이 디바이스의 상태를 조회하거나 설정을 변경할 수 있습니다.
sysfs 속성은 디바이스 파일(/dev)과는 별도의 인터페이스입니다.
디바이스 파일이 데이터 스트림(read/write)이나 제어 명령(ioctl)을 처리하는 반면,
sysfs 속성은 개별 설정 값이나 상태 정보를 간단한 텍스트 형태로 노출합니다.
이는 "하나의 속성, 하나의 파일" 원칙을 따르는 것으로, /proc의 복잡한 다중값 파일과 대비됩니다.
DEVICE_ATTR 매크로 패밀리
커널은 sysfs 속성을 쉽게 정의할 수 있도록 세 가지 매크로를 제공합니다.
모두 include/linux/device.h에 정의되어 있습니다.
/* 읽기+쓰기 속성 */
DEVICE_ATTR(name, mode, show_func, store_func);
/* → struct device_attribute dev_attr_name 생성 */
/* 읽기 전용 속성 (mode = 0444) */
DEVICE_ATTR_RO(name);
/* → show 콜백: name_show()를 직접 정의해야 함 */
/* 쓰기 전용 속성 (mode = 0200) */
DEVICE_ATTR_WO(name);
/* → store 콜백: name_store()를 직접 정의해야 함 */
/* 읽기+쓰기 속성 (mode = 0644) */
DEVICE_ATTR_RW(name);
/* → name_show()와 name_store() 모두 정의해야 함 */
show/store 콜백 구현
show 콜백은 sysfs 파일을 읽을 때 호출되며, buf에 문자열을 채워 반환합니다.
store 콜백은 sysfs 파일에 쓸 때 호출되며, 사용자가 전달한 문자열을 파싱하여 드라이버 상태를 변경합니다.
두 콜백 모두 struct device *dev 매개변수로 디바이스 정보에 접근할 수 있습니다.
/* 읽기 전용 "status" 속성 */
static ssize_t status_show(struct device *dev,
struct device_attribute *attr, char *buf)
{
struct my_dev *mydev = dev_get_drvdata(dev);
/*
* sysfs_emit(): 커널 5.10+에서 권장하는 출력 함수.
* PAGE_SIZE 바운더리를 자동으로 관리합니다.
* 이전 커널에서는 snprintf(buf, PAGE_SIZE, ...) 사용.
*/
return sysfs_emit(buf, "buffer_used=%zu/%zu\nopen_count=%d\n",
mydev->data_len, mydev->buf_size,
atomic_read(&mydev->open_count));
}
static DEVICE_ATTR_RO(status);
/* 읽기+쓰기 "config" 속성 */
static ssize_t config_show(struct device *dev,
struct device_attribute *attr, char *buf)
{
struct my_dev *mydev = dev_get_drvdata(dev);
return sysfs_emit(buf, "%u\n", mydev->config_value);
}
static ssize_t config_store(struct device *dev,
struct device_attribute *attr,
const char *buf, size_t count)
{
struct my_dev *mydev = dev_get_drvdata(dev);
unsigned int val;
int err;
err = kstrtouint(buf, 0, &val);
if (err)
return err;
if (val > 1000)
return -EINVAL;
mutex_lock(&mydev->lock);
mydev->config_value = val;
mutex_unlock(&mydev->lock);
return count; /* 반드시 처리한 바이트 수를 반환 */
}
static DEVICE_ATTR_RW(config);
속성 등록: 개별 파일 vs 속성 그룹
속성을 등록하는 방법은 두 가지입니다. 개별적으로 device_create_file()을 호출하거나,
속성 그룹(attribute group)을 사용할 수 있습니다. 속성 그룹이 권장되는 방법입니다.
/* 방법 1: 개별 등록 (비권장) */
device_create_file(dev, &dev_attr_status);
device_create_file(dev, &dev_attr_config);
/* 해제 시: */
device_remove_file(dev, &dev_attr_config);
device_remove_file(dev, &dev_attr_status);
/* 방법 2: 속성 그룹 (권장) */
static struct attribute *my_dev_attrs[] = {
&dev_attr_status.attr,
&dev_attr_config.attr,
NULL, /* 반드시 NULL 종료 */
};
ATTRIBUTE_GROUPS(my_dev);
/* → my_dev_groups[] 배열이 자동 생성됨 */
/* 클래스에 속성 그룹 연결 */
static struct class my_class = {
.name = "mydev",
.dev_groups = my_dev_groups, /* 디바이스 생성 시 자동 등록 */
};
/* 또는 device_create 이후 수동으로: */
/* devm_device_add_groups(dev, my_dev_groups); */
속성 그룹을 사용하면 device_create() 시점에 모든 속성이 원자적으로 생성되고,
device_destroy() 시점에 원자적으로 제거됩니다. 개별 등록 방식의 에러 처리 복잡성을 피할 수 있습니다.
uevent: 커널 → 사용자 공간 이벤트 알림
uevent는 커널이 사용자 공간에 디바이스 변경 사항을 알리는 메커니즘입니다.
device_create()는 내부적으로 kobject_uevent(&dev->kobj, KOBJ_ADD)를 호출하여
udev에 디바이스 추가를 알립니다. 드라이버가 직접 uevent를 발생시킬 수도 있습니다.
/* 주요 uevent 액션 */
KOBJ_ADD /* 디바이스 추가 */
KOBJ_REMOVE /* 디바이스 제거 */
KOBJ_CHANGE /* 디바이스 상태 변경 */
KOBJ_MOVE /* 디바이스 경로 이동 */
KOBJ_ONLINE /* 디바이스 온라인 */
KOBJ_OFFLINE /* 디바이스 오프라인 */
/* 드라이버에서 직접 uevent 발생 */
kobject_uevent(&dev->kobj, KOBJ_CHANGE);
/* 환경 변수를 포함한 uevent */
kobject_uevent_env(&dev->kobj, KOBJ_CHANGE, envp);
/* envp 예: char *envp[] = { "STATUS=error", "CODE=42", NULL }; */
udev 규칙에서 sysfs 속성 매칭
udev 규칙은 sysfs 속성을 기반으로 디바이스 노드의 권한, 소유자, 심볼릭 링크를 설정할 수 있습니다.
ATTR{} 키를 사용하여 sysfs 속성 값을 매칭합니다.
# /etc/udev/rules.d/99-mydev.rules
# 기본 규칙: 디바이스 노드 권한 설정
KERNEL=="mydev[0-9]*", SUBSYSTEM=="mydev", MODE="0666", GROUP="users"
# sysfs 속성 기반 매칭
KERNEL=="mydev*", ATTR{status}=="active", SYMLINK+="mydev_active_%n"
# uevent 환경 변수 기반 매칭
ACTION=="change", KERNEL=="mydev*", ENV{STATUS}=="error", \
RUN+="/usr/bin/notify-error.sh %k"
# udevadm으로 디바이스 속성 확인
# $ udevadm info -a -p /sys/class/mydev/mydev0
cat/echo로 간편하게 접근할 수 있고,
udev 규칙과 연동할 수 있는 장점이 있습니다.
devtmpfs와 udev
/dev 노드 생성의 역사
Linux에서 /dev 디렉터리의 디바이스 노드를 관리하는 방식은 수십 년에 걸쳐 진화해 왔습니다.
각 시대의 접근법은 이전 방식의 한계를 해결하면서 새로운 기능을 추가했습니다.
| 시대 | 방식 | 커널 버전 | 특징 | 한계 |
|---|---|---|---|---|
| 1기 | 정적 /dev | ~2.4 | mknod로 수동 생성. MAKEDEV 스크립트로 일괄 생성 |
수천 개의 사용하지 않는 노드. Major/Minor 충돌 위험 |
| 2기 | devfs | 2.4~2.6 초기 | 커널 내부 가상 FS. 드라이버가 직접 /dev 노드 생성 | 커널 공간에서 정책 결정. 네이밍 규칙 불일치. 경쟁 조건 |
| 3기 | udev (순수) | 2.6.15~ | 사용자 공간 데몬. netlink로 uevent 수신 후 /dev 노드 생성 | 부팅 초기 /dev가 비어 있음. 콜드플러그 처리 지연 |
| 4기 | devtmpfs + udev | 2.6.32~ | 커널이 기본 노드 생성 → udev가 권한/심링크 보완 | 현재 표준 방식. 실질적 한계 없음 |
devtmpfs: 커널이 직접 노드를 생성
devtmpfs는 커널 2.6.32에서 Kay Sievers가 도입한 메커니즘으로,
CONFIG_DEVTMPFS를 활성화하면 커널이 부팅 시 tmpfs 기반의 /dev를 마운트하고
디바이스 등록 시 자동으로 노드를 생성합니다. 이로써 udev가 시작되기 전에도
콘솔(/dev/console), 루트 디스크(/dev/sda) 등의 핵심 디바이스에 접근할 수 있습니다.
Character Device 드라이버에서 device_create()를 호출하면 내부적으로 다음 과정이 진행됩니다.
/* device_create() 호출 시 커널 내부 흐름 */
device_create()
→ device_add()
→ devtmpfs_create_node() /* /dev 노드 생성 (devtmpfs 활성화 시) */
→ vfs_mknod() /* 실제 디바이스 파일 생성 */
→ kobject_uevent() /* netlink로 KOBJ_ADD uevent 전송 */
→ udevd /* udev 데몬이 uevent 수신 */
→ 규칙 적용 /* 권한, 심볼릭 링크, RUN 스크립트 */
CONFIG_DEVTMPFS_MOUNT 옵션을 추가로 활성화하면 커널이 rootfs 마운트 직후
자동으로 devtmpfs를 /dev에 마운트합니다. 대부분의 배포판에서 이 옵션이 활성화되어 있습니다.
devtmpfs 노드의 기본 속성
devtmpfs가 생성하는 노드의 기본 속성은 다음과 같습니다. udev는 이 기본값을 규칙에 따라 재설정합니다.
| 속성 | 기본값 | udev 재설정 예 |
|---|---|---|
| 소유자 | root:root |
OWNER="myuser", GROUP="dialout" |
| 권한 | 0600 |
MODE="0666" |
| 이름 | device_create()에서 지정한 이름 |
NAME="custom_name" (비권장) |
| 심볼릭 링크 | 없음 | SYMLINK+="mydev_link" |
udev 데몬의 동작 원리
udev(userspace device manager)는 커널의 uevent를 netlink 소켓으로 수신하여
/etc/udev/rules.d/ 및 /lib/udev/rules.d/의 규칙을 적용합니다.
현대 Linux 시스템에서는 systemd-udevd가 udev의 역할을 수행합니다.
# udev 규칙 파일 예제: /etc/udev/rules.d/99-mydev.rules
# 기본: 디바이스 노드 권한 및 그룹 설정
KERNEL=="mydev[0-9]*", MODE="0666", GROUP="users"
# 심볼릭 링크 생성 (여러 개 가능)
KERNEL=="mydev0", SYMLINK+="my_primary_dev"
# 디바이스 추가 시 스크립트 실행
ACTION=="add", KERNEL=="mydev*", RUN+="/usr/local/bin/mydev-init.sh %k"
# 환경 변수 설정 (다른 규칙에서 참조 가능)
KERNEL=="mydev*", ENV{MY_TYPE}="chardev"
# 특정 서브시스템의 디바이스만 매칭
SUBSYSTEM=="mydev", KERNEL=="mydev*", TAG+="systemd"
systemd-udevd 통합
현대 리눅스 배포판에서 udev는 systemd의 구성 요소로 통합되었습니다.
systemd-udevd.service가 udev 데몬을 관리하며,
부팅 시 systemd-udev-trigger.service가 콜드플러그 이벤트를 재발생시킵니다.
# systemd-udevd 서비스 상태 확인
systemctl status systemd-udevd.service
# udev 규칙 재로드 (데몬 재시작 없이)
sudo udevadm control --reload-rules
# 특정 디바이스의 uevent 재발생 (콜드플러그 시뮬레이션)
sudo udevadm trigger --action=add /sys/class/mydev/mydev0
디버깅 도구
/dev 노드가 예상대로 생성되지 않을 때 사용할 수 있는 디버깅 도구들입니다.
# 1) udevadm info: 디바이스의 sysfs 경로, 속성, udev 속성 확인
udevadm info -a -p /sys/class/mydev/mydev0
# 출력 예:
# looking at device '/devices/virtual/mydev/mydev0':
# KERNEL=="mydev0"
# SUBSYSTEM=="mydev"
# ATTR{status}=="idle"
# 2) udevadm monitor: 실시간 uevent 모니터링
# 두 번째 터미널에서 실행하고, 첫 번째 터미널에서 모듈 로드
udevadm monitor --property --kernel --udev
# 출력 예:
# KERNEL[1234.567890] add /devices/virtual/mydev/mydev0 (mydev)
# UDEV [1234.568123] add /devices/virtual/mydev/mydev0 (mydev)
# 3) udevadm test: 규칙 적용 결과를 시뮬레이션 (실제 적용하지 않음)
sudo udevadm test /sys/class/mydev/mydev0
# 어떤 규칙이 매칭되고 어떤 액션이 수행될지 상세히 출력
# 4) /dev 노드 확인
ls -la /dev/mydev*
# crw-rw-rw- 1 root users 234, 0 Feb 27 10:00 /dev/mydev0
# 5) sysfs 속성 직접 확인
cat /sys/class/mydev/mydev0/dev
# 234:0 (major:minor)
cat /sys/class/mydev/mydev0/uevent
# MAJOR=234
# MINOR=0
# DEVNAME=mydev0
device_create()에서 지정한 이름으로만 노드를 생성합니다.
이름에 슬래시(/)를 포함하면 하위 디렉터리가 자동 생성됩니다.
예: device_create(cls, NULL, devno, NULL, "mydrv/%d", idx)는
/dev/mydrv/0 노드를 생성합니다. 단, 해당 디렉터리 정리는 드라이버 책임입니다.
비동기 I/O와 fasync
fasync 메커니즘: 시그널 기반 비동기 I/O
fasync(File Async Notification)는 디바이스에서 데이터가 준비되었을 때
사용자 공간 프로세스에 SIGIO 시그널을 전송하는 메커니즘입니다.
poll이나 select가 프로세스를 블록시키는 반면,
fasync는 프로세스가 다른 작업을 수행하다가 시그널을 받아 데이터를 처리할 수 있게 합니다.
이 메커니즘은 POSIX의 비동기 I/O 알림(O_ASYNC 플래그)을 커널 측에서 구현합니다.
사용자 프로세스가 파일 디스크립터에 O_ASYNC 플래그를 설정하면,
커널은 해당 fd에 대해 드라이버의 fasync 콜백을 호출합니다.
이후 드라이버가 kill_fasync()를 호출하면 등록된 프로세스에 시그널이 전달됩니다.
fasync 관련 API
드라이버에서 fasync를 구현하려면 두 가지 핵심 함수를 사용합니다.
/* include/linux/fs.h */
/*
* fasync_helper(): fasync 리스트에 프로세스를 추가/제거합니다.
* - fd: 파일 디스크립터 번호
* - filp: 대상 file 구조체
* - on: 0이면 제거, 그 외면 추가
* - fapp: fasync_struct 이중 포인터
* 반환: 0(성공) 또는 음수(에러)
*/
int fasync_helper(int fd, struct file *filp, int on,
struct fasync_struct **fapp);
/*
* kill_fasync(): 등록된 프로세스들에게 시그널을 보냅니다.
* - fp: fasync_struct 이중 포인터
* - sig: 전송할 시그널 (보통 SIGIO)
* - band: 이벤트 유형 (POLL_IN=읽기 가능, POLL_OUT=쓰기 가능)
*/
void kill_fasync(struct fasync_struct **fp, int sig, int band);
드라이버 측 fasync 구현
다음은 fasync를 완전하게 구현하는 드라이버 예제입니다.
fasync_struct를 per-device 구조체에 포함시키고,
데이터가 준비되었을 때(write 또는 인터럽트 핸들러에서) kill_fasync()를 호출합니다.
struct my_dev {
struct cdev cdev;
struct mutex lock;
unsigned char *buffer;
size_t data_len;
size_t buf_size;
wait_queue_head_t read_queue;
struct fasync_struct *async_queue; /* fasync 리스트 */
};
/* fasync 콜백: 사용자가 O_ASYNC를 설정/해제할 때 호출 */
static int my_fasync(int fd, struct file *filp, int mode)
{
struct my_dev *dev = filp->private_data;
return fasync_helper(fd, filp, mode, &dev->async_queue);
}
/* release 콜백: 파일 닫힐 때 fasync 리스트에서 제거 */
static int my_release(struct inode *inode, struct file *filp)
{
/* fasync 리스트에서 이 fd를 제거 (중요!) */
my_fasync(-1, filp, 0);
return 0;
}
/* write 콜백: 데이터를 쓴 후 대기 중인 reader에게 알림 */
static ssize_t my_write(struct file *filp, const char __user *buf,
size_t count, loff_t *f_pos)
{
struct my_dev *dev = filp->private_data;
ssize_t ret;
mutex_lock(&dev->lock);
if (count > dev->buf_size - dev->data_len)
count = dev->buf_size - dev->data_len;
if (count == 0) {
ret = -ENOSPC;
goto out;
}
if (copy_from_user(dev->buffer + dev->data_len, buf, count)) {
ret = -EFAULT;
goto out;
}
dev->data_len += count;
ret = count;
/* 블로킹 중인 reader 깨우기 */
wake_up_interruptible(&dev->read_queue);
/* fasync로 등록된 프로세스에 SIGIO 전송 */
if (dev->async_queue)
kill_fasync(&dev->async_queue, SIGIO, POLL_IN);
out:
mutex_unlock(&dev->lock);
return ret;
}
/* poll 콜백: fasync와 함께 구현하는 것이 일반적 */
static __poll_t my_poll(struct file *filp,
struct poll_table_struct *wait)
{
struct my_dev *dev = filp->private_data;
__poll_t mask = 0;
mutex_lock(&dev->lock);
poll_wait(filp, &dev->read_queue, wait);
if (dev->data_len > 0)
mask |= EPOLLIN | EPOLLRDNORM;
if (dev->data_len < dev->buf_size)
mask |= EPOLLOUT | EPOLLWRNORM;
mutex_unlock(&dev->lock);
return mask;
}
static const struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
.poll = my_poll,
.fasync = my_fasync,
};
사용자 공간에서의 fasync 사용
사용자 공간 프로세스는 fcntl을 사용하여 파일 디스크립터에 O_ASYNC 플래그를 설정합니다.
SIGIO가 전달되면 시그널 핸들러에서 데이터를 읽습니다.
/* 사용자 공간 프로그램 */
#include <stdio.h>
#include <fcntl.h>
#include <signal.h>
#include <unistd.h>
#include <string.h>
static int dev_fd;
static volatile sig_atomic_t got_data = 0;
/* SIGIO 시그널 핸들러 */
static void sigio_handler(int signo)
{
got_data = 1;
}
int main(void)
{
struct sigaction sa;
char buf[256];
int flags;
dev_fd = open("/dev/mydev0", O_RDONLY);
if (dev_fd < 0) {
perror("open");
return 1;
}
/* 1) SIGIO 시그널 핸들러 등록 */
memset(&sa, 0, sizeof(sa));
sa.sa_handler = sigio_handler;
sa.sa_flags = 0;
sigemptyset(&sa.sa_mask);
if (sigaction(SIGIO, &sa, NULL) < 0) {
perror("sigaction");
return 1;
}
/* 2) 이 프로세스를 SIGIO 수신자로 설정 */
if (fcntl(dev_fd, F_SETOWN, getpid()) < 0) {
perror("F_SETOWN");
return 1;
}
/* 3) O_ASYNC 플래그 설정 (이 시점에서 드라이버의 fasync 콜백 호출) */
flags = fcntl(dev_fd, F_GETFL);
if (fcntl(dev_fd, F_SETFL, flags | O_ASYNC) < 0) {
perror("F_SETFL O_ASYNC");
return 1;
}
printf("Waiting for data (SIGIO-driven)...\n");
/* 4) 메인 루프: 다른 작업 수행하다가 시그널 처리 */
for (;;) {
if (got_data) {
ssize_t n = read(dev_fd, buf, sizeof(buf) - 1);
if (n > 0) {
buf[n] = '\0';
printf("Received %zd bytes: %s\n", n, buf);
}
got_data = 0;
}
/* 다른 작업 수행 가능 */
pause(); /* 시그널 대기 */
}
close(dev_fd);
return 0;
}
비동기 I/O 메커니즘 비교
Linux에서 사용할 수 있는 비동기 I/O 메커니즘들의 특성을 비교합니다.
| 메커니즘 | 동작 방식 | 장점 | 단점 | 주요 사용처 |
|---|---|---|---|---|
| fasync (SIGIO) | 시그널 기반. 데이터 준비 시 SIGIO 전달 | 간단한 구현. CPU 낭비 없음 | 시그널 핸들러의 제약. 다중 fd 관리 어려움. 경쟁 조건 위험 | 단일 fd 이벤트 알림, 레거시 드라이버 |
| poll/select | 블로킹 대기. 여러 fd 동시 감시 | 이식성 최고 (POSIX). 다중 fd 지원 | fd 수 증가 시 성능 저하 (O(n) 스캔). select는 FD_SETSIZE 제한 | 범용 I/O 다중화 |
| epoll | 이벤트 기반 대기. 커널이 준비된 fd만 반환 | O(1) 이벤트 전달. 대규모 fd 지원 | Linux 전용. API가 poll보다 복잡 | 고성능 서버, 대규모 동시 연결 |
| io_uring | 링 버퍼 기반 비동기 I/O. 시스템 콜 최소화 | 최고 성능. 배치 처리. 폴링 모드 지원 | 커널 5.1+ 필요. 복잡한 API. 보안 취약점 이력 | 고성능 스토리지, 네트워킹 |
poll/epoll을 기본으로 구현하고,
fasync는 레거시 호환성이 필요한 경우에만 추가하는 것을 권장합니다.
fasync는 release에서 반드시 fasync_helper(fd, filp, 0, &async_queue)로
정리해야 하며, 이를 잊으면 댕글링 포인터로 인한 커널 패닉이 발생할 수 있습니다.
llseek 구현
llseek의 역할
llseek은 파일의 현재 읽기/쓰기 위치(f_pos)를 변경하는 file_operations 콜백입니다.
사용자 공간의 lseek(2) 시스템 콜이 VFS를 거쳐 드라이버의 llseek을 호출합니다.
Character Device에서 llseek은 선택적입니다. 시리얼 포트처럼 순차적 스트림 디바이스는 seek을 지원할 필요가 없고,
프레임버퍼나 NVRAM처럼 랜덤 접근이 의미 있는 디바이스에서만 구현합니다.
lseek(2)는 세 가지 기준점(whence)을 지원합니다.
| whence | 상수 | 의미 |
|---|---|---|
SEEK_SET |
0 | 파일 시작 기준. f_pos = offset |
SEEK_CUR |
1 | 현재 위치 기준. f_pos += offset |
SEEK_END |
2 | 파일 끝 기준. f_pos = size + offset |
커널 제공 기본 llseek 구현
커널은 여러 가지 미리 만들어진 llseek 구현을 제공합니다. 대부분의 경우 직접 llseek을 구현할 필요 없이 이들 중 적절한 것을 선택하면 됩니다.
| 함수 | 동작 | 사용 시기 |
|---|---|---|
no_llseek |
항상 -ESPIPE 반환. seek 불가능 |
순차 스트림 디바이스 (시리얼, 키보드). nonseekable_open()과 함께 사용 |
noop_llseek |
f_pos를 변경하지 않고 현재 값 반환 |
f_pos를 전혀 사용하지 않는 디바이스. pread/pwrite 전용 |
default_llseek |
BKL(Big Kernel Lock) 없이 기본 seek. 바운더리 검사 미흡 | 직접 사용 비권장. llseek을 지정하지 않으면 기본값으로 사용됨 |
generic_file_llseek |
inode->i_size 기반 바운더리 검사 |
일반 파일시스템용. Character Device에서는 거의 사용하지 않음 |
fixed_size_llseek |
고정 크기 기반 바운더리 검사. 크기를 인자로 전달 | 크기가 고정된 디바이스 (NVRAM, EEPROM 등) |
커스텀 llseek 구현
고정 크기 버퍼를 가진 Character Device의 llseek 구현 예제입니다.
SEEK_SET, SEEK_CUR, SEEK_END를 모두 처리하고
바운더리 검사를 수행합니다. mutex로 동시성을 보호합니다.
static loff_t my_llseek(struct file *filp, loff_t offset, int whence)
{
struct my_dev *dev = filp->private_data;
loff_t new_pos;
mutex_lock(&dev->lock);
switch (whence) {
case SEEK_SET:
new_pos = offset;
break;
case SEEK_CUR:
new_pos = filp->f_pos + offset;
break;
case SEEK_END:
new_pos = dev->buf_size + offset;
break;
default:
mutex_unlock(&dev->lock);
return -EINVAL;
}
/* 바운더리 검사: 음수 위치 방지 */
if (new_pos < 0) {
mutex_unlock(&dev->lock);
return -EINVAL;
}
/* 바운더리 검사: 버퍼 크기 초과 방지 */
if (new_pos > dev->buf_size) {
mutex_unlock(&dev->lock);
return -EINVAL;
}
filp->f_pos = new_pos;
mutex_unlock(&dev->lock);
return new_pos;
}
static const struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.read = my_read,
.write = my_write,
.llseek = my_llseek, /* 커스텀 llseek 등록 */
};
nonseekable_open()과 no_llseek
시리얼 포트나 키보드처럼 seek이 무의미한 디바이스에서는
nonseekable_open()과 no_llseek을 조합하여 seek을 명시적으로 비활성화합니다.
static int my_open(struct inode *inode, struct file *filp)
{
struct my_dev *dev = container_of(inode->i_cdev,
struct my_dev, cdev);
filp->private_data = dev;
/*
* nonseekable_open(): filp->f_mode에서 FMODE_LSEEK 플래그를 제거.
* 이후 lseek 호출 시 VFS 레벨에서 즉시 -ESPIPE를 반환.
* stream_open()은 추가로 FMODE_ATOMIC_POS도 해제.
*/
return nonseekable_open(inode, filp);
}
static const struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.read = my_read,
.write = my_write,
.llseek = no_llseek, /* lseek → -ESPIPE */
};
stream_open()은 nonseekable_open()의 확장으로,
FMODE_ATOMIC_POS 플래그도 함께 해제합니다. 이는 f_pos를 전혀 사용하지 않는
스트림 디바이스에서 불필요한 f_pos 잠금 오버헤드를 제거합니다.
파이프와 같은 동작을 하는 디바이스에 적합합니다.
llseek과 pread/pwrite의 관계
pread(2)과 pwrite(2)는 파일 위치를 지정하여 읽기/쓰기를 수행하되,
f_pos를 변경하지 않는 시스템 콜입니다. 이들은 llseek을 호출하지 않고
직접 드라이버의 read/write 콜백에 위치를 전달합니다.
/* pread/pwrite는 lseek이 아닌 read/write를 직접 호출합니다.
* 커널 내부 흐름:
*
* sys_pread64(fd, buf, count, pos)
* → file->f_op->read(file, buf, count, &pos) ← llseek 미호출
*
* sys_read(fd, buf, count)
* → file->f_op->read(file, buf, count, &file->f_pos)
*
* 단, FMODE_LSEEK이 없는 파일(nonseekable_open 사용)에서는
* pread/pwrite가 -ESPIPE를 반환합니다.
*/
llseek을 file_operations에 지정하지 않으면
default_llseek이 사용되어 바운더리 검사 없이 f_pos가 변경됩니다.
seek을 지원하지 않으려면 반드시 .llseek = no_llseek을 명시하고,
open에서 nonseekable_open()을 호출하세요.
디버깅과 추적
/proc/devices: 등록된 Character Device 확인
/proc/devices는 현재 커널에 등록된 모든 Character Device(및 Block Device)의 Major 번호와
드라이버 이름을 보여줍니다. 드라이버 로드 후 등록이 올바르게 이루어졌는지 가장 먼저 확인하는 파일입니다.
# Character Device 목록 확인
cat /proc/devices
# Character devices:
# 1 mem
# 4 tty
# 5 /dev/tty
# 10 misc
# 234 mydev ← 우리 드라이버
# ...
# 특정 드라이버만 필터링
grep mydev /proc/devices
# 234 mydev
strace: 시스템 콜 추적
strace는 사용자 공간 프로그램이 Character Device에 대해 수행하는 시스템 콜을
실시간으로 추적합니다. 디바이스 파일 접근 패턴, 에러 코드, 호출 순서를 확인하는 데 필수적입니다.
# 기본 추적: open, read, write, ioctl 등 모든 시스템 콜
strace -e trace=open,openat,read,write,ioctl,close,mmap \
./my_userspace_app /dev/mydev0
# 출력 예:
# openat(AT_FDCWD, "/dev/mydev0", O_RDWR) = 3
# write(3, "Hello\n", 6) = 6
# read(3, "Hello\n", 1024) = 6
# ioctl(3, _IOC(_IOC_READ, 0x6d, 0x01, 4), 0x7ffd3e8a4b10) = 0
# close(3) = 0
# 타임스탬프 포함 (성능 분석용)
strace -T -tt -e trace=file,desc ./my_app /dev/mydev0
# 에러만 필터링 (-z: 성공한 호출 숨김, -Z: 실패한 호출만 표시)
strace -Z -e trace=file ./my_app /dev/mydev0
# 특정 fd에 대한 호출만 추적 (fd 3번)
strace -e read=3 -e write=3 ./my_app /dev/mydev0
ftrace: 커널 내부 함수 추적
ftrace는 커널의 내장 추적 프레임워크로, Character Device 관련 커널 함수의 호출 흐름을
상세히 추적할 수 있습니다. function_graph 트레이서로 함수 호출 트리를 시각화합니다.
# ftrace 설정 (debugfs가 마운트되어 있어야 함)
cd /sys/kernel/debug/tracing
# 1) 함수 그래프 트레이서 설정
echo function_graph > current_tracer
# 2) 추적할 함수 필터링 (Character Device 관련)
echo 'chrdev_open cdev_get cdev_put' > set_ftrace_filter
echo 'vfs_read vfs_write vfs_ioctl' >> set_ftrace_filter
# 3) 추적 시작
echo 1 > tracing_on
# 4) 테스트 프로그램 실행 (다른 터미널에서)
# ./my_test_app /dev/mydev0
# 5) 추적 결과 확인
echo 0 > tracing_on
cat trace
# 출력 예:
# 2) | chrdev_open() {
# 2) 0.234 us | cdev_get();
# 2) | my_open() {
# 2) 0.123 us | mutex_lock();
# 2) 0.089 us | mutex_unlock();
# 2) 0.567 us | }
# 2) 1.234 us | }
# 추적 종료 후 정리
echo nop > current_tracer
echo > set_ftrace_filter
동적 디버그 (pr_debug / dev_dbg)
pr_debug()와 dev_dbg()는 기본적으로 비활성화된 디버그 메시지입니다.
CONFIG_DYNAMIC_DEBUG가 활성화된 커널에서는 런타임에 특정 파일이나 함수의
디버그 메시지를 선택적으로 켜고 끌 수 있습니다.
/* 드라이버 코드에서 디버그 메시지 삽입 */
static int my_open(struct inode *inode, struct file *filp)
{
struct my_dev *dev = container_of(inode->i_cdev,
struct my_dev, cdev);
/* pr_debug: 모듈 이름 접두사 자동 포함 */
pr_debug("my_open: device #%d, pid=%d\n",
dev->index, current->pid);
/* dev_dbg: 디바이스 정보 접두사 자동 포함 (권장) */
dev_dbg(&dev->device, "opened by pid %d\n", current->pid);
filp->private_data = dev;
return 0;
}
# 런타임에 디버그 메시지 활성화
# 특정 파일의 모든 pr_debug 활성화
echo 'file my_driver.c +p' > /sys/kernel/debug/dynamic_debug/control
# 특정 함수만 활성화
echo 'func my_open +p' > /sys/kernel/debug/dynamic_debug/control
# 특정 모듈의 모든 디버그 메시지 활성화
echo 'module my_module +p' > /sys/kernel/debug/dynamic_debug/control
# 행 번호와 함수명 포함 출력 (+flt)
echo 'file my_driver.c +pflt' > /sys/kernel/debug/dynamic_debug/control
# 비활성화
echo 'file my_driver.c -p' > /sys/kernel/debug/dynamic_debug/control
# 현재 활성화된 디버그 포인트 확인
grep "=p" /sys/kernel/debug/dynamic_debug/control
Character Device 드라이버 흔한 버그
Character Device 드라이버 개발에서 자주 발생하는 버그와 그 증상, 해결 방법을 정리합니다.
| 버그 | 증상 | 원인 | 해결 |
|---|---|---|---|
cdev_del() 누락 |
모듈 언로드 후에도 /proc/devices에 항목 잔존. 재로드 시 -EBUSY |
에러 경로에서 cdev_del()을 호출하지 않음 |
goto 기반 에러 처리에서 역순 해제 보장 |
class_destroy() 누락 |
/sys/class/에 디렉터리 잔존. sysfs 고아 노드 |
exit 함수나 에러 경로에서 class 해제 빠뜨림 | device_destroy() → class_destroy() 순서 보장 |
| 에러 경로 이중 해제 | 커널 패닉 (double free), slab corruption | goto 라벨 순서 오류로 이미 해제된 리소스를 다시 해제 | goto 라벨을 할당 역순으로 배치. 해제 후 포인터를 NULL로 설정 |
원자 컨텍스트에서 GFP_KERNEL |
BUG: scheduling while atomic 커널 경고 |
spinlock 보유 중이나 인터럽트 핸들러에서 kmalloc(GFP_KERNEL) 호출 |
원자 컨텍스트에서는 GFP_ATOMIC 사용. 가급적 spinlock 밖에서 할당 |
private_data 미초기화 |
read/write에서 NULL 역참조, 커널 OOPS |
open에서 filp->private_data를 설정하지 않음 |
open에서 반드시 container_of → private_data 설정 |
mutex_destroy() 누락 |
CONFIG_DEBUG_MUTEXES 활성화 시 경고 메시지 |
per-device 구조체 해제 시 mutex를 destroy하지 않음 | kfree(dev) 직전에 mutex_destroy(&dev->lock) 호출 |
copy_to/from_user 반환값 무시 |
정보 누출(커널 메모리 → 사용자 공간) 또는 데이터 손상 | 복사 실패 시 에러 처리 없이 진행 | 반환값이 0이 아니면 -EFAULT 반환 |
debugfs로 드라이버 내부 상태 노출
debugfs(/sys/kernel/debug)는 개발/디버깅 목적으로 드라이버의 내부 상태를 노출하는
가상 파일시스템입니다. sysfs와 달리 ABI 안정성 보장이 필요 없어 자유롭게 사용할 수 있습니다.
#include <linux/debugfs.h>
static struct dentry *debug_dir;
static int my_debug_show(struct seq_file *s, void *unused)
{
int i;
seq_printf(s, "=== My Driver Debug Info ===\n");
seq_printf(s, "Device count: %d\n", MY_DEV_COUNT);
for (i = 0; i < MY_DEV_COUNT; i++) {
struct my_dev *dev = &my_devices[i];
mutex_lock(&dev->lock);
seq_printf(s, "\nDevice #%d:\n", i);
seq_printf(s, " buffer_size: %zu\n", dev->buf_size);
seq_printf(s, " data_len: %zu\n", dev->data_len);
seq_printf(s, " open_count: %d\n",
atomic_read(&dev->open_count));
mutex_unlock(&dev->lock);
}
return 0;
}
DEFINE_SHOW_ATTRIBUTE(my_debug);
static int __init my_debug_init(void)
{
debug_dir = debugfs_create_dir("my_driver", NULL);
if (IS_ERR(debug_dir))
return PTR_ERR(debug_dir);
debugfs_create_file("info", 0444, debug_dir,
NULL, &my_debug_fops);
/* 간단한 값은 헬퍼 함수로 바로 노출 */
debugfs_create_u32("device_count", 0444, debug_dir,
&device_count_var);
return 0;
}
static void __exit my_debug_exit(void)
{
debugfs_remove_recursive(debug_dir);
}
# debugfs 마운트 확인
mount | grep debugfs
# debugfs on /sys/kernel/debug type debugfs (rw,nosuid,nodev,noexec,relatime)
# 드라이버 디버그 정보 확인
cat /sys/kernel/debug/my_driver/info
# === My Driver Debug Info ===
# Device count: 4
#
# Device #0:
# buffer_size: 4096
# data_len: 12
# open_count: 1
KASAN/UBSAN: 메모리 버그 탐지
커널 주소 정리기(KASAN)와 미정의 동작 정리기(UBSAN)는 컴파일 타임 계측을 통해 런타임에 메모리 버그를 탐지합니다. Character Device 드라이버 개발 시 이 도구들을 활성화하면 버퍼 오버플로, use-after-free, 정수 오버플로 등을 즉시 발견할 수 있습니다.
# 커널 설정에서 KASAN/UBSAN 활성화
# make menuconfig → Kernel hacking → Memory Debugging
# KASAN 활성화
CONFIG_KASAN=y
CONFIG_KASAN_GENERIC=y # 범용 모드 (느리지만 모든 아키텍처 지원)
# 또는
CONFIG_KASAN_SW_TAGS=y # ARM64 전용 태그 기반 모드
# UBSAN 활성화
CONFIG_UBSAN=y
CONFIG_UBSAN_BOUNDS=y # 배열 경계 검사
CONFIG_UBSAN_SHIFT=y # 시프트 연산 검사
# KASAN 감지 예 (use-after-free)
# BUG: KASAN: use-after-free in my_read+0x64/0x120
# Read of size 1 at addr ffff88810a3b0042 by task cat/1234
# ...
# Allocated by task insmod/1200:
# kzalloc+0x12/0x20
# my_init+0x48/0x200 [my_module]
# Freed by task rmmod/1230:
# kfree+0x78/0x90
# my_exit+0x34/0x100 [my_module]
CONFIG_DEBUG_MUTEXES,
CONFIG_PROVE_LOCKING, CONFIG_KASAN을 활성화한 디버그 커널에서 테스트하세요.
운영 환경 배포 전에 이 옵션들을 비활성화하여 성능 오버헤드를 제거합니다.
관련 문서
사이트 내부 문서
Character Device는 리눅스 커널의 여러 서브시스템과 밀접하게 연관되어 있습니다. 아래 문서들은 이 페이지에서 다룬 개념들을 더 깊이 이해하는 데 도움이 됩니다.
| 문서 | 관련 주제 | 이 페이지와의 연관성 |
|---|---|---|
| 디바이스 드라이버 | Device Model, bus/driver/device 계층, 플랫폼 디바이스 | Character Device가 Device Model에서 어떤 위치를 차지하는지, struct device와 struct class의 전체적인 관계를 설명합니다. |
| ioctl 심화 | ioctl 번호 체계 (_IO/_IOR/_IOW/_IOWR), 사용자 정의 명령 |
이 페이지의 ioctl 구현 패턴 섹션의 심화 내용입니다. 번호 충돌 방지, compat_ioctl, 보안 고려사항을 상세히 다룹니다. |
| VFS | 가상 파일시스템 계층, inode, dentry, file 구조체 | VFS 디스패치 경로 섹션에서 다룬 chrdev_open() → file_operations 연결의 전체 맥락을 이해할 수 있습니다. |
| 커널 모듈 | 모듈 빌드 (Kbuild/Makefile), 로드/언로드, 심볼 익스포트 |
Character Device 드라이버는 대부분 커널 모듈로 구현됩니다. 모듈 파라미터, 라이선스 선언, 의존성 관리를 다룹니다. |
| 인터럽트 | IRQ 핸들러, top/bottom half, tasklet, workqueue | 하드웨어 디바이스 드라이버에서 인터럽트를 통해 데이터 준비를 감지하고, wake_up_interruptible()이나 kill_fasync()로 알리는 패턴에 필요합니다. |
| 메모리 관리 | kmalloc, vmalloc, 페이지 할당, slab, DMA 매핑 | per-device 버퍼 할당(kzalloc), mmap 구현(remap_pfn_range), DMA 버퍼 관리에 필요한 메모리 서브시스템의 기초를 다룹니다. |
| 동기화 | mutex, spinlock, RCU, atomic, completion, 잠금 순서 | 동시성과 잠금 섹션에서 다룬 동기화 기법들의 심화 내용입니다. lockdep, 잠금 클래스, 성능 비교를 상세히 다룹니다. |
커널 공식 문서
리눅스 커널 소스 트리의 공식 문서에서 Character Device 관련 내용을 참조할 수 있습니다.
| 문서 | 경로 / URL | 내용 |
|---|---|---|
| Driver API: 기본 가이드 | Documentation/driver-api/basics.rst |
디바이스 모델, 드라이버 등록, devm 리소스 관리의 전반적 개요 |
| Char Device 등록 | Documentation/core-api/kernel-api.rst |
register_chrdev_region, cdev_init, cdev_add API 레퍼런스 |
| sysfs 규칙 | Documentation/filesystems/sysfs.rst |
sysfs 속성 작성 규칙, "하나의 값 하나의 파일" 원칙 |
| debugfs | Documentation/filesystems/debugfs.rst |
debugfs API 사용법, 디버그 파일 생성/제거 |
| udev/devtmpfs | Documentation/driver-api/infrastructure.rst |
디바이스 노드 자동 생성, uevent 메커니즘 |
| Dynamic Debug | Documentation/admin-guide/dynamic-debug-howto.rst |
pr_debug/dev_dbg 런타임 활성화 방법 |
핵심 헤더 파일 참조
Character Device 드라이버 개발에서 반복적으로 참조하게 되는 커널 헤더 파일들입니다. 소스 코드를 직접 읽는 것이 가장 정확한 API 문서입니다.
| 헤더 | 주요 정의 |
|---|---|
include/linux/cdev.h |
struct cdev, cdev_init(), cdev_add(), cdev_del() |
include/linux/fs.h |
struct file_operations, struct inode, struct file, register_chrdev_region() |
include/linux/device.h |
struct class, device_create(), DEVICE_ATTR 매크로 |
include/linux/miscdevice.h |
struct miscdevice, misc_register(), MISC_DYNAMIC_MINOR |
include/linux/uaccess.h |
copy_to_user(), copy_from_user(), get_user(), put_user() |
include/linux/kdev_t.h |
MAJOR(), MINOR(), MKDEV() 매크로 |
include/linux/idr.h |
DEFINE_IDA(), ida_alloc(), ida_free(), ida_destroy() |
include/linux/poll.h |
poll_wait(), __poll_t, EPOLLIN/EPOLLOUT |