[Device Driver] 2편. 캐릭터 디바이스 드라이버

나우히즈·2025년 12월 15일

안녕하세요. 오늘은 캐릭터 디바이스 드라이버를 개발하는 실습을 진행해보도록 하겠습니다.

개발환경은 라즈베리파이4 보드(linux) 에서 진행하였습니다.

우선 저번에 진행한 사항과 크게 다르지 않습니다만, 캐릭터 디바이스 드라이버는 해당 장치에 대한 동작을 정의해야한다는 점을 기억해야합니다.

이번 실습에서는 "/dev 파일에 read/write를 하면, 커널의 내가 만든 함수가 호출되는 구조를 직접 확인" 해보도록 하겠습니다.

그럼 시작하겠습니다.


Character Device란?

-> 파일처럼 접근하지만, 실제로는 드라이버 코드가 실행되는 인터페이스입니다.
예: /dev/tty, /dev/video0, /dev/i2c-1

해당 장치파일에 cat, echo, read(), write() 를 진행하는 것이 파일 i/o처럼 보이나, 실제 동작은 드라이버 함수를 호출한다는 점에 유의해야합니다.

동작 구조

User Space
 └─ echo "hello" > /dev/mychardev
        ↓
VFS (Virtual File System)
        ↓
file_operations.write
        ↓
my_write()  ← 우리가 작성

우리가 유저공간에서 장치파일에 값을 read/write 하게 되면, 가상파일시스템은 해당 장치파일이 하는 동작에 대한 함수를 찾습니다. 동작에 대한 정보는 file_operations 라는 구조체에 저장되고, 해당 구조체에 우리가 요청한 동작을 수행할 함수가 함수포인터로 연동되어 동작합니다.

실습

1. 작업 디렉토리

우선 작업을 진행할 디렉토리를 하나 생성합니다.

mkdir -p ~/driver_chardev
cd ~/driver_chardev

2. 디바이스 드라이버 소스코드

이후 해당 디렉토리에 디바이스 드라이버 소스코드를 만듭니다.

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
#include <linux/uaccess.h>

#define DEV_NAME "mychardev"
#define BUF_SIZE 128

static dev_t devno;
static struct cdev my_cdev;
static struct class *my_class;

static char kbuf[BUF_SIZE];
static size_t kbuf_len;

/* open */
static int my_open(struct inode *inode, struct file *file)
{
    printk(KERN_INFO DEV_NAME ": open\n");
    return 0;
}

/* release */
static int my_release(struct inode *inode, struct file *file)
{
    printk(KERN_INFO DEV_NAME ": release\n");
    return 0;
}

/* write */
static ssize_t my_write(struct file *file,
                        const char __user *buf,
                        size_t len,
                        loff_t *off)
{
    size_t copy_len = (len >= BUF_SIZE) ? BUF_SIZE - 1 : len;

    if (copy_from_user(kbuf, buf, copy_len))
        return -EFAULT;

    kbuf[copy_len] = '\0';
    kbuf_len = copy_len;

    printk(KERN_INFO DEV_NAME ": write \"%s\"\n", kbuf);
    return len;
}

/* read */
static ssize_t my_read(struct file *file,
                       char __user *buf,
                       size_t len,
                       loff_t *off)
{
    size_t remain;

    if (*off >= kbuf_len)
        return 0;

    remain = kbuf_len - *off;
    if (len > remain)
        len = remain;

    if (copy_to_user(buf, kbuf + *off, len))
        return -EFAULT;

    *off += len;
    printk(KERN_INFO DEV_NAME ": read %zu bytes\n", len);
    return len;
}

/* file_operations */
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 ret;

    /* major/minor 할당 */
    ret = alloc_chrdev_region(&devno, 0, 1, DEV_NAME);
    if (ret < 0)
        return ret;

    /* cdev 초기화 */
    cdev_init(&my_cdev, &my_fops);
    ret = cdev_add(&my_cdev, devno, 1);
    if (ret < 0)
        goto err_cdev;

    /* /sys/class 생성 */
    my_class = class_create(DEV_NAME);
    if (IS_ERR(my_class)) {
        ret = PTR_ERR(my_class);
        goto err_class;
    }

    /* /dev/mychardev 생성 */
    device_create(my_class, NULL, devno, NULL, DEV_NAME);

    printk(KERN_INFO DEV_NAME ": loaded (major=%d minor=%d)\n",
           MAJOR(devno), MINOR(devno));
    return 0;

err_class:
    cdev_del(&my_cdev);
err_cdev:
    unregister_chrdev_region(devno, 1);
    return ret;
}

static void __exit my_exit(void)
{
    device_destroy(my_class, devno);
    class_destroy(my_class);
    cdev_del(&my_cdev);
    unregister_chrdev_region(devno, 1);
    printk(KERN_INFO DEV_NAME ": unloaded\n");
}

module_init(my_init);
module_exit(my_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("sihwan");
MODULE_DESCRIPTION("Simple character device driver");

코드 포인트 1. dev_t devno

dev_t 타입에 대해 이해해야합니다.

커널이 디바이스를 구분하기 위해 사용하는 정수형 typedef이며, major, minor 번호 정보를 인코딩하여 담은 커널 타입입니다.

VFS가 파일을 열고 inode에서 major 번호를 확인하게 되는데, 이 때 major 번호가 어느 드라이버인지를 결정합니다. 따라서, 우리가 코드 상에서 장치파일을 open() 했을 때, 해당 장치파일은 어떤 드라이버와 연결해야하는지에 대한 답을 major 번호가 담게 됩니다.

그 과정에서 우리는 우리가 개발하는 모듈이 장치파일과 잘 연결될 수 있도록, dev_t 타입을 활용하게 되는 것입니다.

dev_t = "장치파일과 드라이버를 연결하는 커널 내부의 열쇠"

장치번호(major/minor, dev_t)는 “사용자가 open한 장치파일을 어떤 디바이스 드라이버의 file_operations와 연결할지 결정하기 위한 식별자”다.

코드 포인트 2. alloc_chrdev_region()

코드에서 보면 alloc_chrdev_region() 함수를 통해 장치 번호 할당을 요청한다.

기존 교육을 진행하면서는 범위를 통한 할당이 아닌 단일 번호에 대해 요청을 하는 함수(register_chrdev())를 활용하였다. 하지만 이렇게 하게 되면 충돌이 발생할 수 있어서 장치번호를 동적할당 하여 번호 충돌을 피하는 것이 좋다.

alloc_chrdev_region() vs register_chrdev_region()

int alloc_chrdev_region(dev_t *dev,
                        unsigned int first_minor,
                        unsigned int count,
                        const char *name);

int register_chrdev_region(dev_t from,
                           unsigned int count,
                           const char *name);

alloc 함수의 경우 커널이 사용 가능한 major 번호를 자동할당한다는 점.
register 함수의 경우 사용자가 직접 요청하는 번호를 등록 시도하여 충돌이 발생할 수 있다는 점.

코드 포인트 3. struct file_operations

해당 구조체는 장치 파일에서 발생하는 모든 I/O 동작에 대해 어떤 함수로 대응할 것인지 함수포인터 값을 저장하는 역할을 한다.

user: write(fd, ...)
  ↓
VFS
  ↓
file->f_op->write
  ↓
my_write()

해당 구조체에 작성한 모듈 함수들을 기입해야지만 장치파일에 대한 동작을 연결해줄 수 있다. (안하면 아무것도 안됨)

코드 포인트 4. struct cdev

cdev란?

코드 상에서 struct cdev 자료형이 있었다.

struct cdev 는 "character device 드라이버를 VFS에 등록하기 위한 연결 어댑터" 이다.

/* 등록과정 */
cdev_init(&my_cdev, &my_fops);
cdev_add(&my_cdev, devno, 1);

이 cdev는 "file_operations + dev_t" 를 커널에 묶어주는 객체로
이 장치번호에 해당하는 디바이스는 이 드라이버(file_operations에 등록된 함수)가 처리한다는 것을 명시한다. VFS ↔ 드라이버를 연결하는 커널 내부 객체

만약 유저가,

open("/dev/mychardev", O_RDWR);

를 통해 파일을 오픈하는 명령을 전달했다면,
VFS 입장에서 필요한 정보는 아래와 같다.
1. 이 파일은 일반 파일인가?
2. 아니면 character device인가?
3. 그렇다면 어떤 드라이버의 함수들을 호출해야 하는가?

struct cdev 는 장치파일 번호 + file_operations 로 구성되어 위에 대한 답을 할 수 있다.

이러한 cdev를 시스템 전체의 위치에서 본다면,

유저 공간
  ↓
VFS (파일 시스템 공통 계층)
  ↓
struct cdev  ← ← ← 여기
  ↓
file_operations
  ↓
디바이스 드라이버 동작

VFS와 디바이스 드라이버 동작 간에 위치하여 둘을 연결해주는 연결고리 역할을 수행한다.

cdev가 담고 있는 핵심 정보

struct cdev {
    struct kobject kobj;
    const struct file_operations *ops;
    struct module *owner;
    dev_t dev;
    unsigned int count;
};

🔹 dev
• 이 cdev가 담당하는 major/minor 범위

🔹 ops
• 이 장치를 열었을 때 사용할 file_operations
• → .open, .read, .write, .ioctl 등

그래서 cdev는 언제, 어떻게 쓰이나?

드라이버 초기화 시점에 cdev 관련 함수를 호출하게 된다.

cdev_init(&my_cdev, &my_fops);
cdev_add(&my_cdev, devno, 1);

이 코드가 의미하는 바는:
1. “이 cdev는 이 fops를 사용한다”
2. “이 dev_t 범위를 담당한다”
3. “VFS에 등록해라”

① 유저가 캐릭터 장치파일을 오픈

open("/dev/mychardev");

② VFS 는 파일의 inode를 확인

  • "/" -> "dev" -> "mychardev" : dentry를 따라 mychardev에 대응하는 inode 탐색
  • inode 획득 (메모리에 확보)
  • inode->i_rdev → dev_t 추출

③ VFS 내부
• dev_t로 등록된 cdev 탐색
• (이건 cdev_add() 때 등록되어 있음)

④ 연결

file->f_op = cdev->ops;

struct cdev는 character device 드라이버가 자신이 담당할 장치번호(dev_t 범위)와
그 장치에 대한 file_operations를 묶어 VFS에 등록하기 위한 커널 객체
다.

코드 포인트 5. class

코드 상에서 보면, 다음과 같은 코드가 있다.

my_class = class_create(DEV_NAME);
device_create(my_class, NULL, devno, NULL, DEV_NAME);

이 클래스라는 것을 이용해서, 장치파일이 자동적으로 생성되게 한다.
기존에 교육에서 진행한 실습의 경우, mknod 명령어(장치파일을 수동으로 만드는 명령어)를 통해 장치파일에 대한 생성이 필요했다.

mknod /dev/mychardev c 240 0

class 란 무엇인가.

class는 “이 디바이스가 사용자 공간에서 어떤 종류의 장치인지 설명하기 위한 커널 객체”
→ 장치의 기능적 분류를 사용자 공간(sysfs, udev)에 전달하는 역할을 한다.

자세한 설명 전에, sysfs, udev 의 개념을 이해해보자.

  • sysfs
    → “커널 내부 객체를 사용자 공간에 파일 형태로 노출하는 가상 파일 시스템.”
  • udev
    → “sysfs와 커널 이벤트를 기반으로 /dev 디바이스 노드를 동적으로 관리하는 사용자 공간 데몬”

즉, 사용자 공간과의 연결을 위한 개념이다.
class가 없던 시절에는 커널 드라이버가 /dev/mydev 를 만들려면, mknod 직접 실행 후 major/minor 번호를 수동으로 관리해야했다.

이러한 불편함을 줄이고자, 현대의 리눅스 커널은 이 장치가 어떤 종류인지를 유저공간(udev)에 알리고, 직접 udev에서 /dev 노드 를 생성하도록 한다.

이 때, 커널에서 사용자 공간으로 장치 정보를 전달하는 연결고리가 class 이다.

class의 시스템 상 역할

[드라이버 로드]
   ↓
class_create() 
   ↓
device_create()
   ↓
sysfs에 정보 노출 (/sys/class/xxx/yyy)
   ↓
udev가 이벤트 수신
   ↓
udev가 /dev/yyy 생성

위 코드에서 class_create(), device_create() 는 다음과 같은 차이를 가진다.

// 1. class 생성 (분류 생성)
struct class *my_class;
my_class = class_create(THIS_MODULE, "my_class");

// 2. device 생성 (장치 하나)
device_create(my_class, NULL, dev_num, NULL, "mydev");

struct class 는 device 들의 그룹이며, sysfs에서 디렉토리로 생성되는 역할을 수행함.

위 코드를 수행한다는 것은, "mydev 라는 장치가 있으며, major/minor 번호는 무엇이고, 이 장치는 my_class 라는 종류야" 라고 유저공간에 알리는 것이다.

그러면 데몬 프로세스로 동작중이던 udev는 이를 감지해 dev 파일을 자동적으로 생성하게 되는 것이다.

class는 커널 동작에 필수는 아니지만, udev를 통한 장치 노드 자동 생성과 사용자 공간에서의 관리 편의성을 위해 사실상 표준적으로 사용된다.

코드 포인트 6. loff_t *off

파일 오프셋 관리를 위한 자료형.
파일의 현재 위치 포인터를 나타내며, struct file 내부의 f_pos와 연결된다.

따라서,

read(fd, buf, len) 처리
 → f_pos 전달
 → 읽은 바이트 수 만큼 *off 갱신

loff_t 가 디바이스 드라이버에서 중요한 이유는, 첫번째로 EOF 처리에 있다.
코드 상에도 볼 수 있듯이,

if (*off >= data_len)
    return 0;

의 코드는 cat 종료조건을 나타낸다.

또한 offset을 무시하고 파일을 읽게한다면 매번 read 시 마다 파일의 처음부터 읽히게 된다. 이를 방지하여 offset을 관리해주어야함.

3. Makefile 작성

이전 실습과 동일한 형태로 작성합니다.

obj-m += mychardev.o

all:
	make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules

clean:
	make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean

4. 빌드 후 테스트

make
sudo insmod mychardev.ko

![](https://velog.velcdn.com/images/sihkang/post/de1af82e-43ed-42f9-ba3d-a6e2f11b6686/image.png)

# 확인
ls -l /dev/mychardev
dmesg | tail

# write 테스트
echo "hello kernel" > /dev/mychardev
dmesg | tail

# read 테스트
cat /dev/mychardev
dmesg | tail

정리

캐릭터 디바이스 드라이버를 만들면서 기존 교육에서 진행했던 사항들을 좀 더 보완한 실습을 진행하였다.

1개의 댓글

comment-user-thumbnail
2025년 12월 20일

감사합니다! 글 잘보고 있습니다

답글 달기