4. 字符设备驱动(上):设备号注册与注销、file_operations 结构体、cdev 结构体、设备节点的创建

字符设备驱动,是 Linux 驱动里最基础、也最常用的一类。说白了,就是那些按字节流读写数据的设备,比如串口、LED、按键、温度传感器。你想想看,它们的数据访问方式就是“一个一个字节来”,没有复杂的缓冲区管理,也没有随机访问的概念。

我刚开始写驱动时,就是从字符设备入手的。那时候踩了不少坑,尤其是设备号冲突和节点创建失败的问题,折腾了好几个晚上。今天咱们就把这块彻底讲透。

4.1 设备号:驱动的“身份证”

每个字符设备在内核里都有一个唯一的编号,叫设备号。它由两部分组成:主设备号和次设备号。

  • 主设备号:标识设备对应的驱动程序。比如,所有串口设备可能共用同一个主设备号。
  • 次设备号:标识同一个驱动管理的不同设备实例。比如,两个串口分别用次设备号 0 和 1。

在内核源码中,设备号用 dev_t 类型表示,它是一个 32 位的无符号整数。高 12 位是主设备号,低 20 位是次设备号。嗯,这里要注意:主设备号的范围是 0~4095,次设备号的范围是 0~1048575。

设备号分解与组合

内核提供了两个宏来操作设备号:

  • MAJOR(dev_t dev):从设备号中提取主设备号。
  • MINOR(dev_t dev):从设备号中提取次设备号。
  • MKDEV(int major, int minor):将主次设备号组合成一个 dev_t。

4.2 设备号注册与注销

设备号不能随便用,得向内核申请。申请方式有两种:静态申请和动态分配。

4.2.1 静态申请

如果你知道某个主设备号没有被占用,可以直接指定。用 register_chrdev_region() 函数:

int register_chrdev_region(dev_t from, unsigned count, const char *name);
  • from:要申请的起始设备号。
  • count:连续设备号的数量。
  • name:设备名称,会在 /proc/devices 中显示。

返回值:成功返回 0,失败返回负的错误码。

注意:静态申请前,最好查一下 /proc/devices 或 Documentation/devices.txt,确认你想要的设备号没有被占用。我曾经因为没查,直接用了 240 号,结果跟某个内核模块冲突,系统直接 oops 了。

4.2.2 动态分配

更推荐的做法是让内核自动分配一个可用的主设备号。用 alloc_chrdev_region():

int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name);
  • dev:输出参数,返回分配到的设备号。
  • baseminor:起始次设备号。
  • count:连续设备号的数量。
  • name:设备名称。

我个人习惯用动态分配。省心,不用担心冲突。你想想看,如果写一个通用驱动,你根本不知道用户会在什么环境下加载它,静态指定一个号太冒险了。

4.2.3 注销设备号

驱动卸载时,必须把设备号还回去。用 unregister_chrdev_region():

void unregister_chrdev_region(dev_t from, unsigned count);

参数跟注册时一样。不注销的话,下次加载驱动就会失败。嗯,这个坑我也踩过,卸载模块时忘了这步,结果重新 insmod 一直报“Device or resource busy”。

4.3 file_operations 结构体:驱动的“接口表”

字符设备驱动跟用户程序之间的交互,全靠 file_operations 结构体。它定义在 <linux/fs.h> 中,里面全是函数指针。用户程序调用 open()、read()、write() 时,内核会通过这个结构体找到驱动里对应的函数。

常用的成员有这些:

成员 函数原型 说明
owner struct module *owner 指向模块自身,通常设为 THIS_MODULE
open int (*open)(struct inode *, struct file *) 打开设备时调用
release int (*release)(struct inode *, struct file *) 关闭设备时调用
read ssize_t (*read)(struct file *, char __user *, size_t, loff_t *) 从设备读取数据
write ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *) 向设备写入数据
unlocked_ioctl long (*unlocked_ioctl)(struct file *, unsigned int, unsigned long) 设备控制命令(无大内核锁)

举个例子,一个最简单的 file_operations 定义:

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

小技巧:如果某个操作不需要实现,直接设为 NULL。内核会帮你处理默认行为。比如 open 为 NULL 时,内核默认返回 0,表示打开成功。

4.4 cdev 结构体:内核中的“设备对象”

cdev 是内核用来管理字符设备的核心结构体。定义在 <linux/cdev.h> 中。它把设备号和 file_operations 绑定在一起。

使用 cdev 的步骤很简单,就三步:

  1. 分配:用 cdev_alloc() 或静态定义。
  2. 初始化:用 cdev_init() 关联 file_operations。
  3. 添加:用 cdev_add() 将设备注册到内核。

代码示例:

struct cdev my_cdev;
dev_t dev_num;

// 1. 分配设备号
alloc_chrdev_region(&dev_num, 0, 1, "my_device");

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

// 3. 添加 cdev 到内核
cdev_add(&my_cdev, dev_num, 1);

卸载时,顺序反过来:先 cdev_del(),再 unregister_chrdev_region()。

注意顺序:cdev_add() 之后,内核随时可能调用你的 file_operations 函数。所以一定要在 cdev_add() 之前完成所有初始化工作。我曾经在 cdev_add() 之后才分配私有数据,结果并发访问时直接崩溃。

4.5 设备节点的创建

设备号注册好了,cdev 也加进去了,但用户程序怎么访问呢?答案是:通过设备节点。设备节点就是 /dev 目录下的一个文件,比如 /dev/my_device。

创建设备节点有两种方式:

4.5.1 手动创建(调试用)

用 mknod 命令:

mknod /dev/my_device c 240 0
  • c 表示字符设备。
  • 240 是主设备号。
  • 0 是次设备号。

这种方式只适合调试。产品中不可能让用户手动敲 mknod,对吧?

4.5.2 自动创建(推荐)

利用内核的 device_create() 和 class_create() 机制。它会自动在 /dev 下生成节点,还能设置权限。

步骤:

  1. 创建一个 class(设备类)。
  2. 在类下创建 device。

代码示例:

static struct class *my_class;
static struct device *my_device;

// 在模块初始化函数中
my_class = class_create(THIS_MODULE, "my_class");
if (IS_ERR(my_class)) {
    // 错误处理
}

my_device = device_create(my_class, NULL, dev_num, NULL, "my_device");
if (IS_ERR(my_device)) {
    // 错误处理
}

// 在模块退出函数中
device_destroy(my_class, dev_num);
class_destroy(my_class);

这样,加载模块后,/dev/my_device 就自动出现了。用户程序可以直接 open() 它。

权限问题:默认创建的设备节点权限是 0600,只有 root 能访问。如果想允许普通用户访问,可以在 device_create() 之后用 devtmpfs 或 udev 规则修改权限。我个人习惯在驱动里用 device_create() 的 devt 参数指定一个默认的 mode,但更灵活的做法是交给 udev 处理。

4.6 完整流程梳理

说了这么多,咱们把整个流程串起来。下面这张图展示了字符设备驱动从注册到节点创建的全过程:

字符设备驱动注册与节点创建流程 字符设备驱动注册与节点创建流程 1. 分配设备号 alloc_chrdev_region() 2. 初始化 cdev cdev_init() + 设置 owner 3. 添加 cdev 到内核 cdev_add() 4. 创建设备类 class_create() 5. 创建设备节点 device_create() → /dev/my_device ⚠ 注意顺序 cdev_add() 之后 内核可能立即调用 你的回调函数 💡 推荐做法 使用动态分配 避免设备号冲突

嗯,这张图把整个流程串起来了。从分配设备号开始,到 /dev 下出现节点,每一步都环环相扣。你写驱动时,就按这个顺序来,准没错。

4.7 避坑指南

最后,分享几个我实际项目中踩过的坑:

  • 设备号冲突:静态分配时一定要确认设备号未被占用。我曾经在嵌入式板子上用了 200 号,结果跟板载的 RTC 驱动冲突,系统直接挂掉。
  • cdev_add 顺序:一定要在 cdev_add() 之前完成所有初始化。包括私有数据分配、锁初始化、硬件复位等。否则并发访问时,你的驱动可能还没准备好。
  • 设备节点权限:默认 0600 权限,普通用户无法访问。如果设备需要被非 root 用户使用,记得配置 udev 规则,或者在驱动中设置 devnode 回调。
  • 卸载顺序:先 cdev_del(),再 device_destroy(),再 class_destroy(),最后 unregister_chrdev_region()。顺序错了,可能导致内核崩溃。

字符设备驱动的基础就这些。说白了,就是设备号、file_operations、cdev、设备节点这四个东西。把它们搞明白,剩下的就是填充具体的读写逻辑了。