第四节:字符设备驱动核心概念

说实话,字符设备驱动是Linux驱动开发里最基础、也最经典的一类。我刚开始学驱动时,就是从字符设备入手的。它不像块设备那么复杂,也不像网络设备那么抽象。说白了,字符设备就是像串口、键盘、鼠标这类设备——数据是按字节流一个个传的,没有缓冲区,没有随机访问。

这一节,我们来啃四个核心概念:设备号设备文件file_operations结构体,以及驱动与应用程序的交互流程。搞懂这些,你就能手写第一个字符设备驱动了。

4.1 设备号:主设备号与次设备号

每个字符设备在内核里都有一个唯一的身份标识——设备号。它是个32位的无符号整数,高12位是主设备号,低20位是次设备号。

概念 位数 作用
主设备号 12位(0~4095) 标识设备对应的驱动程序
次设备号 20位(0~1048575) 标识同一个驱动下的不同设备实例

我举个例子你就明白了。你电脑上可能插了两个USB转串口设备,它们用的是同一个驱动(主设备号相同),但一个是ttyUSB0,一个是ttyUSB1(次设备号不同)。内核通过次设备号来区分它们。

核心要点:主设备号找驱动,次设备号找设备。

设备号的分配有两种方式:

  • 静态分配:在驱动代码里写死一个主设备号。比如很多经典驱动用主设备号240。但问题是,万一别的驱动也用了这个号,就冲突了。
  • 动态分配:调用alloc_chrdev_region()让内核自动分配。我个人习惯用动态分配,省心,也避免冲突。

嗯,这里要注意:alloc_chrdev_region()分配的是设备号范围,不是单个设备号。比如你要支持4个设备,就分配4个连续的次设备号。

// 动态分配设备号
dev_t dev_num;
int major, minor;

// 分配一个设备号范围(次设备号从0开始,共1个)
alloc_chrdev_region(&dev_num, 0, 1, "my_char_dev");

major = MAJOR(dev_num);  // 提取主设备号
minor = MINOR(dev_num);  // 提取次设备号

printk("Major: %d, Minor: %d\n", major, minor);

我在项目中遇到过一个问题:用静态分配时,主设备号被另一个内核模块占用了,结果insmod失败。从那以后,我基本只用动态分配。

4.2 设备文件:/dev 目录下的桥梁

设备文件是用户空间和内核空间之间的桥梁。你想想看,应用程序不能直接调用内核函数,它只能通过文件操作来访问设备。设备文件就充当了这个中间人。

设备文件在/dev目录下,用ls -l查看时,你会看到:

crw-rw-rw- 1 root root 240, 0 Jan 15 10:30 my_device

开头的c表示字符设备。后面的240, 0就是主设备号和次设备号。

创建设备文件有两种方式:

  • 手动创建:用mknod /dev/my_device c 240 0。调试时方便,但产品里不推荐。
  • 自动创建:用udevdevtmpfs。驱动加载时自动生成设备文件。我建议你用这种方式,省事又规范。

小技巧:在驱动里用class_create()device_create(),udev会自动在/dev下创建设备文件。你只需要在驱动里注册好设备号,剩下的交给系统。

4.3 file_operations结构体:驱动的函数表

这是字符设备驱动的灵魂。说白了,file_operations就是一个函数指针表,告诉内核:当应用程序对这个设备文件执行openreadwriteclose等操作时,该调用驱动里的哪个函数。

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

常用的回调函数有这些:

回调函数 触发时机 典型实现
open 应用程序调用open() 初始化设备、递增使用计数
release 应用程序调用close() 清理资源、递减使用计数
read 应用程序调用read() 从硬件读取数据到用户空间
write 应用程序调用write() 从用户空间写入数据到硬件
llseek 应用程序调用lseek() 修改文件读写位置

我曾经犯过一个低级错误:忘了给.owner赋值THIS_MODULE。结果驱动卸载时,内核还在用这个结构体,直接导致内核崩溃。嗯,这个坑我踩过,你别再踩了。

4.4 驱动与应用程序的交互流程

理解了上面三个概念,我们来看看整个交互流程。我画了一张图,帮你理清思路。

用户空间 应用程序 系统调用层 VFS层 open/read/write 内核空间 字符设备驱动 file_operations (open/read/write/release) 硬件设备 通过设备号找到驱动 图:字符设备驱动交互流程

整个流程是这样的:

  1. 应用程序调用:比如fd = open("/dev/my_device", O_RDWR)。这是用户空间的第一步。
  2. 系统调用:应用程序陷入内核,触发系统调用。内核根据文件路径找到/dev/my_device这个设备文件。
  3. VFS层:虚拟文件系统从设备文件的inode里提取主设备号和次设备号。然后根据主设备号,找到对应的驱动。
  4. 驱动响应:内核调用驱动注册的file_operations里的open函数。你的my_open()就被执行了。
  5. 硬件操作:驱动里的函数通过ioremapreadl/writel等接口,直接操作硬件寄存器。
  6. 返回结果:数据从内核空间拷贝到用户空间(比如copy_to_user),然后返回给应用程序。

注意:驱动里的readwrite函数不能直接访问用户空间的指针。必须用copy_to_user()copy_from_user()来安全地传输数据。我见过有人直接解引用用户指针,结果内核直接oops了。

4.5 避坑指南

最后,分享几个我踩过的坑:

  • 设备号冲突:用cat /proc/devices查看当前已用的主设备号。动态分配能避免这个问题。
  • 设备文件权限:默认创建的设备文件可能只有root能访问。记得在device_create()里设置合适的权限位。
  • file_operations没注册:调用cdev_add()之前,一定要确保file_operations结构体已经正确初始化。否则内核调用时会崩溃。
  • 忘记释放资源:驱动卸载时,要调用cdev_del()unregister_chrdev_region(),以及销毁类和设备。否则下次加载会出问题。

好了,这一节的内容就到这里。设备号、设备文件、file_operations、交互流程——这四个概念是字符设备驱动的基石。搞懂了它们,下一节我们就可以动手写第一个驱动了。


专注资料整理