3、字符设备驱动基础:Linux字符设备驱动框架、file_operations结构体、设备号注册与注销

好,咱们今天聊聊字符设备驱动。这是Linux驱动开发里最基础、也是最核心的一块。说白了,你只要搞嵌入式Linux,就绕不开它。

我记得刚入行那会儿,带我的老工程师跟我说:“字符设备驱动就是Linux驱动的‘Hello World’。” 我当时还不信,觉得太简单了。后来做了几个项目才发现,嗯,这话一点不假。很多复杂的驱动,比如触摸屏、GPIO、串口,底层都是字符设备驱动的变种。

3.1 Linux字符设备驱动框架

先说说框架。Linux把设备分成了三大类:字符设备、块设备和网络设备。字符设备的特点是啥?数据是按字节流一个一个读写的,没有缓冲区。像键盘、鼠标、串口,都是典型的字符设备。

驱动框架其实不复杂。你想想看,用户程序调用 open()read()write() 这些系统调用,内核怎么知道该调用哪个驱动的函数?

答案就是:通过文件系统。Linux里“一切皆文件”。设备在 /dev 目录下也有一个文件节点。用户操作这个文件,内核通过 VFS(虚拟文件系统)找到对应的驱动函数。

整个流程是这样的:

  1. 用户程序调用 open("/dev/mydevice", O_RDWR)
  2. VFS 根据设备文件的主设备号,找到对应的驱动
  3. 驱动里注册的 file_operations 结构体中的 open 函数被调用
  4. 驱动执行硬件操作,返回文件描述符给用户

这个流程,我建议你背下来。面试的时候经常问,项目里也天天用。

核心要点: 字符设备驱动的本质,就是实现 file_operations 结构体里的函数指针,然后告诉内核:“嘿,这个设备号归我管了!”

3.2 file_operations 结构体

这个结构体,是字符设备驱动的灵魂。它定义在 <linux/fs.h> 里。说白了,它就是一张函数跳转表。

我挑几个最常用的成员给你讲讲:

成员 函数原型 说明
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) 设备控制命令。这个很常用,我后面会细讲

看一个最简单的实现例子:

// 一个极简的字符设备驱动框架
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/uaccess.h>

#define DEVICE_NAME "my_char_dev"
#define BUF_SIZE 1024

static char device_buffer[BUF_SIZE];
static int major_num;

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

// release 函数
static int my_release(struct inode *inode, struct file *file) {
    printk(KERN_INFO "Device closed\n");
    return 0;
}

// read 函数
static ssize_t my_read(struct file *file, char __user *user_buf, 
                       size_t count, loff_t *offset) {
    size_t to_read = min(count, (size_t)BUF_SIZE);
    if (copy_to_user(user_buf, device_buffer, to_read)) {
        return -EFAULT;
    }
    return to_read;
}

// write 函数
static ssize_t my_write(struct file *file, const char __user *user_buf,
                        size_t count, loff_t *offset) {
    size_t to_write = min(count, (size_t)BUF_SIZE);
    if (copy_from_user(device_buffer, user_buf, to_write)) {
        return -EFAULT;
    }
    return to_write;
}

// 定义 file_operations 结构体
static struct file_operations my_fops = {
    .owner   = THIS_MODULE,
    .open    = my_open,
    .release = my_release,
    .read    = my_read,
    .write   = my_write,
};

个人经验: 我建议你在 open 函数里加一个计数器。每次 open 加1,release 减1。这样能防止用户程序异常退出时资源没释放。我曾经在一个工控项目里吃过这个亏,设备打开后没关,结果第二次就打开失败了。

3.3 设备号注册与注销

设备号是内核识别设备的“身份证”。它由两部分组成:主设备号和次设备号。主设备号对应驱动,次设备号对应具体设备。

比如,同一个串口驱动,可以管理多个串口。主设备号相同,次设备号不同。

注册设备号有两种方式:

3.3.1 静态分配

你自己指定一个主设备号。比如:

major_num = register_chrdev(0, DEVICE_NAME, &my_fops);

第一个参数传 0,表示让内核自动分配。如果你传一个具体的数字,比如 200,那就是静态分配。

注意: 静态分配容易冲突。我以前在调试一个项目时,两个驱动都用了主设备号 200,结果加载第二个驱动时直接报错。从那以后,我都是让内核自动分配,省心。

3.3.2 动态分配

推荐用这种方式。内核会帮你找一个没被占用的主设备号:

major_num = register_chrdev(0, DEVICE_NAME, &my_fops);
if (major_num < 0) {
    printk(KERN_ERR "Failed to register device\n");
    return major_num;
}
printk(KERN_INFO "Registered with major number %d\n", major_num);

注销设备号也很简单:

unregister_chrdev(major_num, DEVICE_NAME);

这个函数一定要在模块卸载时调用。不然下次加载驱动时,设备号可能已经被别人占用了。

3.4 完整的驱动模板

把上面的代码拼起来,就是一个能用的字符设备驱动了。我再加一个模块初始化和卸载函数:

static int __init my_driver_init(void) {
    major_num = register_chrdev(0, DEVICE_NAME, &my_fops);
    if (major_num < 0) {
        printk(KERN_ERR "Failed to register device\n");
        return major_num;
    }
    printk(KERN_INFO "My char driver loaded. Major number: %d\n", major_num);
    return 0;
}

static void __exit my_driver_exit(void) {
    unregister_chrdev(major_num, DEVICE_NAME);
    printk(KERN_INFO "My char driver unloaded\n");
}

module_init(my_driver_init);
module_exit(my_driver_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple char device driver");

避坑指南: 我曾经犯过一个低级错误——在 __exit 函数里忘了注销设备号。结果模块卸载后,/proc/devices 里还显示着这个设备。下次加载时,内核说设备号已存在,加载失败。排查了半天才发现是卸载没清理干净。

3.5 总结

字符设备驱动的基础就这些。说白了就是三步:

  1. 实现 file_operations 结构体里的函数
  2. 注册设备号
  3. 在模块卸载时注销设备号

你想想看,是不是很简单?但别小看它。很多复杂的驱动,比如 I2C、SPI 设备驱动,底层都是基于这个框架的。只是多了些总线、设备树的处理。

下一章,我会讲怎么创建设备节点,以及用户程序怎么和驱动交互。到时候咱们再深入聊聊 copy_to_usercopy_from_user 的那些坑。

我的建议: 学驱动开发,一定要动手。光看代码没用。你把这个模板编译一下,加载到开发板上,用 echocat 测试一下读写功能。只有亲手跑通了,才算真正理解。