字符设备驱动基础(上):设备号、cdev与file_operations

大家好,我是你们的老朋友。今天我们来聊聊字符设备驱动的基础部分。说实话,这部分内容在嵌入式Linux开发里,就像盖房子的地基一样重要。我当年刚入行时,就是被这几个概念绕得晕头转向——设备号、cdev结构体、file_operations……嗯,今天咱们一个一个把它掰扯清楚。

设备号的概念

先问大家一个问题:Linux系统里,应用程序怎么找到你的硬件设备?

答案就是——设备号。每个设备在系统里都有一个唯一的身份证,由两部分组成:主设备号次设备号

  • 主设备号:标识设备对应的驱动程序。说白了,就是告诉内核“这个设备该由哪个驱动来管”。
  • 次设备号:标识同一个驱动下的不同设备实例。比如你插了两个USB转串口,主设备号相同,次设备号不同。

在内核源码里,设备号用dev_t类型表示,它是一个32位的无符号整数。其中高12位是主设备号,低20位是次设备号。你可以用两个宏来操作它:

#include <linux/types.h>

dev_t dev = MKDEV(major, minor);  // 合成设备号
int major = MAJOR(dev);           // 提取主设备号
int minor = MINOR(dev);           // 提取次设备号

重要提醒:主设备号范围是0~4095,次设备号范围是0~1048575。别搞反了,我见过有人把次设备号写到主设备号的位置上,结果驱动死活加载不了。

动态与静态分配设备号

设备号怎么来?有两种方式:静态分配和动态分配。

静态分配

静态分配就是你自己指定一个主设备号。比如:

#define MY_MAJOR 200
#define MY_MINOR 0

dev_t dev = MKDEV(MY_MAJOR, MY_MINOR);
register_chrdev_region(dev, 1, "my_device");

这种方式的好处是设备号固定,应用程序不用改。但坏处也很明显——万一别的驱动已经用了200这个号,你就冲突了。我在一个项目里就吃过这个亏,当时调试了两天才发现是设备号冲突。

动态分配

动态分配就聪明多了,让内核帮你选一个空闲的设备号:

dev_t dev;
alloc_chrdev_region(&dev, 0, 1, "my_device");
int major = MAJOR(dev);  // 内核分配的主设备号

我个人习惯用动态分配。为什么呢?因为省心。你想想看,你写一个驱动要发布给别人用,静态分配的话,用户还得检查自己的系统里有没有冲突。动态分配就完全不用操心。

我的建议:产品开发阶段用动态分配,量产时如果为了固定设备节点方便,可以改成静态分配。但一定要在文档里注明你用了哪个主设备号。

cdev结构体

设备号有了,接下来怎么告诉内核“这个设备号对应哪些操作”?这就轮到cdev结构体登场了。

cdev是内核里描述字符设备的核心结构体。它的定义大概长这样:

struct cdev {
    struct kobject kobj;          // 内核对象,用于设备模型
    struct module *owner;         // 所属模块
    const struct file_operations *ops;  // 操作函数集
    struct list_head list;        // 链表节点
    dev_t dev;                    // 设备号
    unsigned int count;           // 设备数量
};

使用cdev的流程很简单,三步走:

  1. 分配cdev:可以用静态定义,也可以用cdev_alloc()动态分配。
  2. 初始化cdev:调用cdev_init(),把file_operations挂上去。
  3. 添加cdev:调用cdev_add(),把它注册到内核里。

看个例子:

struct cdev my_cdev;

// 初始化
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;

// 添加设备
dev_t dev = MKDEV(major, 0);
cdev_add(&my_cdev, dev, 1);

注意cdev_add()之后,设备就立即可用了。所以一定要确保所有初始化都完成了再调用它。我曾经在cdev_add()之后才去初始化硬件,结果用户空间一打开设备就崩溃了……嗯,血的教训。

file_operations结构体

好了,设备号有了,cdev也注册了。但应用程序调用open()read()write()时,内核怎么知道该执行什么代码?

答案就是file_operations结构体。它就像一个函数指针表,把系统调用和你的驱动函数一一对应起来。

常用的成员有这些:

成员 对应的系统调用 说明
open open() 打开设备,做初始化工作
release close() 关闭设备,释放资源
read read() 从设备读取数据
write write() 向设备写入数据
unlocked_ioctl ioctl() 设备控制命令

定义方式是这样的:

struct file_operations my_fops = {
    .owner   = THIS_MODULE,
    .open    = my_open,
    .release = my_close,
    .read    = my_read,
    .write   = my_write,
};

关键点.owner一定要赋值为THIS_MODULE。否则模块被卸载时,如果设备还在被使用,内核会直接崩溃。这个坑我踩过,真的会panic。

实现open/read/write/close

最后,我们来看看这四个函数具体怎么写。我以一个虚拟设备为例,它内部维护一个缓冲区。

open函数

static int my_open(struct inode *inode, struct file *filp)
{
    // 可以在这里分配资源、初始化硬件
    printk(KERN_INFO "my_device: device opened\n");
    return 0;
}

这里要注意,inode参数包含了设备号等信息,filp是文件指针,你可以用filp->private_data来保存设备相关的数据结构。

close函数

static int my_close(struct inode *inode, struct file *filp)
{
    // 释放资源、关闭硬件
    printk(KERN_INFO "my_device: device closed\n");
    return 0;
}

说白了,open和close就是一对儿,open里申请的东西,close里要记得释放。

read函数

static ssize_t my_read(struct file *filp, char __user *buf,
                       size_t count, loff_t *f_pos)
{
    // 从内核空间拷贝数据到用户空间
    if (copy_to_user(buf, kernel_buffer, count)) {
        return -EFAULT;
    }
    return count;
}

千万注意:绝对不能直接用memcpy往用户空间的buf里写数据!必须用copy_to_user()。否则轻则数据错误,重则内核崩溃。我见过有人图省事直接memcpy,结果系统直接死机。

write函数

static ssize_t my_write(struct file *filp, const char __user *buf,
                        size_t count, loff_t *f_pos)
{
    // 从用户空间拷贝数据到内核空间
    if (copy_from_user(kernel_buffer, buf, count)) {
        return -EFAULT;
    }
    return count;
}

同理,copy_from_user()也是必须的。这两个函数会检查用户空间的地址是否合法,防止恶意程序搞破坏。

完整示例框架

把上面这些串起来,一个最简单的字符设备驱动框架就出来了:

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

#define DEVICE_NAME "my_device"
#define BUFFER_SIZE 1024

static dev_t dev_num;
static struct cdev my_cdev;
static char kernel_buffer[BUFFER_SIZE];

static int my_open(struct inode *inode, struct file *filp)
{
    printk(KERN_INFO "Device opened\n");
    return 0;
}

static int my_close(struct inode *inode, struct file *filp)
{
    printk(KERN_INFO "Device closed\n");
    return 0;
}

static ssize_t my_read(struct file *filp, char __user *buf,
                       size_t count, loff_t *f_pos)
{
    if (copy_to_user(buf, kernel_buffer, count))
        return -EFAULT;
    return count;
}

static ssize_t my_write(struct file *filp, const char __user *buf,
                        size_t count, loff_t *f_pos)
{
    if (copy_from_user(kernel_buffer, buf, count))
        return -EFAULT;
    return count;
}

static struct file_operations my_fops = {
    .owner   = THIS_MODULE,
    .open    = my_open,
    .release = my_close,
    .read    = my_read,
    .write   = my_write,
};

static int __init my_init(void)
{
    // 动态分配设备号
    alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
    
    // 初始化并添加cdev
    cdev_init(&my_cdev, &my_fops);
    my_cdev.owner = THIS_MODULE;
    cdev_add(&my_cdev, dev_num, 1);
    
    printk(KERN_INFO "My device loaded, major=%d\n", MAJOR(dev_num));
    return 0;
}

static void __exit my_exit(void)
{
    cdev_del(&my_cdev);
    unregister_chrdev_region(dev_num, 1);
    printk(KERN_INFO "My device unloaded\n");
}

module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");

嗯,这个框架虽然简单,但五脏俱全。你把它编译加载后,用mknod /dev/mydev c 主设备号 0创建节点,就可以用echocat来测试了。

我的经验:刚开始学的时候,别急着写复杂的驱动。先把上面这个框架跑通,用dmesg看内核日志,确认open、read、write都被调用了。这一步走稳了,后面加硬件操作就水到渠成。

好了,今天的内容就到这里。字符设备驱动的基础——设备号、cdev、file_operations,说白了就是这三板斧。下节课我们继续深入,聊聊如何管理多个设备、如何用ioctl实现控制命令。到时候见!