04-最简单的字符设备驱动

一、设备驱动分类

linux设备驱动一般分为3类,字符设备,块设备,网络设备。前两个在/dev目录下有对应的设备节点,网络设备比较特殊,没有。通过ls -l /dev/xx可以看出设备类型:

thammer@test:~$ ls -l /dev/nvme0n1
brw-rw---- 1 root disk 259, 0 10月  9 08:59 /dev/nvme0n1
thammer@test:~$ ls -l /dev/tty
crw-rw-rw- 1 root tty 5, 0 10月 10 15:25 /dev/tty

b开头的表示块设备(block),以c开头的表示字符设备(character)。块设备一般是指硬盘(HDD),固态硬盘(SSD),移动存储介质如SD卡,TF卡,U盘等,其他拥有设备节点的驱动基本都属于字符设备。暂时仅关注字符设备驱动。

二、字符设备驱动的核心概念

2.1 设备号

Linux 使用设备号来标识设备,设备号由两部分组成:

设备号 = 主设备号 (12位) + 次设备号 (20位)
  • 主设备号:标识驱动程序,内核通过主设备号找到对应的驱动
  • 次设备号:标识具体设备,同一驱动可管理多个同类型设备

查看已注册的设备号:

cat /proc/devices

2.2 file_operations 结构体

file_operations 是字符设备的核心,定义了用户空间系统调用与驱动函数的映射关系:

struct file_operations {
    struct module *owner;
    int (*open) (struct inode *, struct file *);
    int (*release) (struct inode *, struct file *);
    ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
    ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
    // ... 还有很多其他操作
};

映射关系

  • 用户调用 open() → 内核调用 fops->open()
  • 用户调用 read() → 内核调用 fops->read()
  • 用户调用 write() → 内核调用 fops->write()
  • 用户调用 close() → 内核调用 fops->release()

这些称为系统调用,系统调用会通过中断,切换到内核空间,从而调用内核对应的函数,也就是上面举例的这些,当然还有很多其他的未一一列举。可以发现在用户空间,它们采用统一接口,在内核空间根据不同的设备驱动,调用相应的操作接口。

2.3 用户空间与内核空间的隔离

Linux 将内存空间分为用户空间和内核空间,两者相互隔离。驱动中不能直接访问用户空间指针,必须使用以下函数:

// 从用户空间复制数据到内核空间(用于 write)
unsigned long copy_from_user(void *to, const void __user *from, unsigned long n);

// 从内核空间复制数据到用户空间(用于 read)
unsigned long copy_to_user(void __user *to, const void *from, unsigned long n);

返回值:0 表示成功,非 0 表示未复制的字节数。

三、极简字符设备驱动示例

#include <linux/module.h>
#include <linux/fs.h>

// 主设备号
static int major;
static char devName[] = "mychardev";

// 对应应用空间的open系统调用
static int chardev_open(struct inode *inode, struct file *file)
{
    printk(KERN_INFO "chardev open\n");
    return 0;
}

// 对应应用空间的close系统调用
static int chardev_release(struct inode *inode, struct file *file)
{
    printk(KERN_INFO "chardev release\n");
    return 0;
}

// 字符设备驱动对应的文件操作结构
static struct file_operations fOpts = {
    .owner = THIS_MODULE,
    .open = chardev_open,
    .release = chardev_release
};

//加载内核模块后的入口函数
static int __init chardev_drv_init(void)
{
    printk(KERN_INFO "chardev driver init\n");
    //向内核注册字符设备驱动
    major = register_chrdev(0, devName, &fOpts);
    if (major < 0)
    {
        printk(KERN_ERR "chardev driver regist\n");
        return major;
    }
    printk(KERN_INFO "got major:%d\n", major);
    return 0;
}

//卸载内核模块后的清理函数
static void __exit chardev_drv_exit(void)
{
    //向内核注销字符设备驱动
    unregister_chrdev(major, devName);
    printk(KERN_INFO "chardev driver exit\n");
}

module_init(chardev_drv_init);
module_exit(chardev_drv_exit);

MODULE_LICENSE("GPL");

编译该字符设备驱动的Makfile:

KERN_DIR=/usr/src/linux-headers-$(shell uname -r)

all:
	make -C ${KERN_DIR} M=$(shell pwd) modules

clean:
	make -C ${KERN_DIR} M=$(shell pwd) modules clean

obj-m += chardev_drv.o

四、编译、加载测试

4.1 编译驱动

make

4.2 加载驱动

sudo insmod chardev_drv.ko

通过dmesg可以看到内核日志输出:

[ 5476.132713] chardev driver init
[ 5476.132717] got major:237

4.3 手动创建设备节点

到此应用程序如果要操作这个驱动,还缺乏对应的设备节点。这里需要我们手动创建设备节点:

sudo mknod /dev/mychardev c $(cat /proc/devices | grep mychardev | awk '{print $1}') 0
# 验证
ls -l /dev/mychardev
# 输出: crw-r--r-- 1 root root 237, 0 xxx /dev/mychardev

说明c 表示字符设备,237 是主设备号,0 是次设备号。

mknode命令用于手动创建设备节点,其命令创建设备节点的格式为:

mknod [选项]... 节点路径 类型 [主设备号 次设备号]
  • 选项:一般可以通过-m指定创建的设备节点的文件权限。也可以在创建后通过chmod修改。
  • 节点路径:习惯位于/dev下面或者其子目录,但是这仅仅是一个习惯而已,实际如果你想要,可以是任意位置。
  • 类型:就是c,b等,还可以是比较少见的p或者up表示管道,uc等价。
  • 主,次设备号:当类型为p时不能指定主,次设备号,其他类型时,必须指定。

当我们insmod后,内核模块被加载,在/proc/devices这个文件中就会产生一行新的记录,分别是主设备号和驱动名称(例如上面通过register_chrdev传入的"mychardev"),这也是手动创建设备节点时获得主设备号的一个依据。

4.4 测试驱动

最简单的方法是通过命令方式,比如cat

sudo cat /dev/mychardev
#执行后dmesg -w会输出下面的内容
#[63682.250395] chardev open
#[63682.250485] chardev release

由于此时我这里仅仅提供了openclose系统调用对应的内核空间驱动的相应接口,所以cat会报错,因为cat至少还需要read系统调用。

五、增加read,write对应接口

5.1 在原驱动基础上新增如下内容:

// 定义一个全局的数组,存储应用空间通过write传过来的数据,并在read时又将数据返回。
#define KBUF_SIZE   4096
static char kBuf[KBUF_SIZE];

// 对应read系统调用
static ssize_t chardev_read(struct file *file, char __user *buf, size_t size, loff_t *offset)
{
    unsigned long ret;
    ssize_t bytesRead = 0;

    if (size > KBUF_SIZE)
    {
        bytesRead = KBUF_SIZE;
    }
    else
    {
        bytesRead = size;
    }

    ret = copy_to_user(buf, kBuf, bytesRead);
    if (ret != 0)
    {
        printk(KERN_INFO "copy_to_user failed\n");
        return -EFAULT;
    }

    printk(KERN_INFO "chardev read %ld bytes\n", bytesRead);
    return bytesRead;
}

// 对应write系统调用
static ssize_t chardev_write(struct file *file, const char __user *buf, size_t size, loff_t *offset)
{
    unsigned long ret;
    ssize_t bytesWrite = 0;

    if (size > KBUF_SIZE)
    {
        bytesWrite = KBUF_SIZE;
    }
    else
    {
        bytesWrite = size;
    }


    memset(kBuf, 0, KBUF_SIZE);
    ret = copy_from_user(kBuf, buf, bytesWrite);
    if (ret != 0)
    {
        printk(KERN_INFO "copy_from_user failed\n");
        return -EFAULT;
    }

    printk(KERN_INFO "chardev write %ld bytes\n", bytesWrite);
    return bytesWrite;
}
// 字符设备驱动对应的文件操作结构
static struct file_operations fOpts = {
    .owner = THIS_MODULE,
    .open = chardev_open,
    .release = chardev_release,
    .read = chardev_read,
    .write = chardev_write
};

这只是一个测试验证的例子,并不是一个完善的驱动,存在一些问题,比如未维护kBuf当前存储位置,暂时可以忽略这些。重新编译,并且卸载原来的驱动,重新加载。如果主设备号变了,那么设备节点也要删除,并重新按照新的主设备号生成,如果没变,就不需要做什么。

make
sudo rmmod chardev_drv
sudo insmod chardev_drv.ko

5.2 测试验证

sudo echo "hello world" > /dev/mychardev
sudo cat /dev/mychardev

由于未维护kBuf的存储位置信息,未标记有效数据,导致read一直可读,因此cat会一直读到数据。

六、设备号、设备节点、file_operations 结构体之间的关联

为什么通过打开设备节点,然后就可以调用到对应的file_operations.open?因为设备节点这个文件的元数据(inode信息)里面可以得到设备号信息,然后使用这个设备号,就可以在内核空间查询到对应的file_operations了。

用户空间
    │
    ├─ 设备节点 (/dev/mydevice) → 设备号 (Major:237 Minor:0)
    ↓
内核空间
    ├─ 设备号 237 → 绑定 file_operations (mydev_read/mydev_write/...)
    ↓
硬件设备

查看内核源码里面对struct inode的定义:
img

里面分别有cdevfile_operations。再看struct cdev字符设备结构的定义:

img

这里的的dev_t dev成员就是设备号。

posted @ 2025-10-10 18:53  thammer  阅读(12)  评论(0)    收藏  举报