4. 字符设备驱动框架
字符设备是Linux驱动开发中最基础、最常见的一类设备。它按字节流方式访问,典型代表有串口、按键、LED、I2C从设备等。要写出一个可加载、可被用户空间访问的字符设备驱动,必须理解四个核心概念:主次设备号、cdev、file_operations以及设备节点。本节将围绕这四者,梳理字符设备驱动的完整框架与注册流程。
一、主次设备号:内核识别设备的“身份证”
在Linux中,每个字符设备都由一个设备号(dev_t)唯一标识。dev_t是一个32位整数,其中高12位为主设备号(major),低20位为次设备号(minor)。主设备号用于标识设备对应的驱动程序,次设备号用于区分同一驱动下的不同设备实例。
/* 内核中 dev_t 的构成 */
typedef __u32 dev_t; // 高12位 major,低20位 minor
/* 常用操作宏 */
MAJOR(dev_t dev); // 提取主设备号
MINOR(dev_t dev); // 提取次设备号
MKDEV(int major, int minor); // 合成设备号
设备号有两种分配方式:静态指定和动态分配。静态指定使用register_chrdev_region(),需要开发者提前确定一个未被占用的主设备号;动态分配使用alloc_chrdev_region(),由内核自动分配可用主设备号,推荐在驱动中使用,可避免冲突。
cat /proc/devices,其中“Character devices”段列出了所有已注册的字符设备主设备号及名称。二、cdev:内核中的字符设备对象
struct cdev是内核用来描述一个字符设备的结构体,它把设备号与file_operations绑定在一起。驱动中通常将cdev嵌入到自定义的设备结构体中,便于管理私有数据。
struct cdev {
struct kobject kobj;
struct module *owner;
const struct file_operations *ops;
struct list_head list;
dev_t dev;
unsigned int count;
};
/* 常用API */
void cdev_init(struct cdev *cdev, const struct file_operations *fops);
int cdev_add(struct cdev *cdev, dev_t dev, unsigned int count);
void cdev_del(struct cdev *cdev);
cdev的生命周期通常为:cdev_init()初始化并绑定fops → cdev_add()添加到内核 → 模块卸载时cdev_del()删除。注意cdev_add之后设备即可被访问,因此必须确保所有资源已就绪。
三、file_operations:用户空间与驱动的桥梁
struct file_operations是字符设备驱动的核心,它定义了一组函数指针,对应用户空间对设备文件的操作。当应用程序调用open/read/write/ioctl时,VFS会通过设备号找到对应的cdev,再调用fops中注册的函数。
| 成员函数 | 对应系统调用 | 典型用途 |
|---|---|---|
| open | open() | 初始化设备、申请资源 |
| release | close() | 释放资源、关闭设备 |
| read | read() | 从设备读取数据到用户空间 |
| write | write() | 将用户数据写入设备 |
| unlocked_ioctl | ioctl() | 执行设备特定控制命令 |
| llseek | lseek() | 定位读写位置 |
| mmap | mmap() | 将设备内存映射到用户空间 |
编写fops时需注意:函数原型必须与内核定义严格一致;__user标记的指针必须使用copy_to_user()或copy_from_user()访问,不能直接解引用。
四、register_chrdev:传统注册方式与设备节点创建
早期内核提供register_chrdev()一次性完成设备号申请和cdev注册,使用简单但不够灵活:它会占用一个主设备号下的所有次设备号。
static int major;
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.read = my_read,
.write = my_write,
.release = my_release,
};
static int __init my_init(void)
{
major = register_chrdev(0, "mydev", &my_fops);
if (major < 0)
return major;
printk("mydev major = %d\n", major);
return 0;
}
static void __exit my_exit(void)
{
unregister_chrdev(major, "mydev");
}
模块加载后,需要创建设备节点才能被用户空间访问。设备节点是/dev目录下的特殊文件,记录了设备号。创建方式有两种:
- 手动创建:
mknod /dev/mydev c 240 0,其中c表示字符设备,240为主设备号,0为次设备号。 - 自动创建:使用
class_create()和device_create(),由udev/mdev在/dev下自动生成节点,这是现代驱动的标准做法。
/* 自动创建设备节点 */
static struct class *my_class;
static struct device *my_device;
my_class = class_create(THIS_MODULE, "mydev");
my_device = device_create(my_class, NULL, MKDEV(major, 0), NULL, "mydev");
五、完整框架与数据流
下图展示了从用户空间系统调用到驱动函数的完整路径,以及主次设备号、cdev、file_operations、设备节点之间的对应关系。
六、常见误区与复盘
- 设备号冲突:静态指定主设备号前未检查
/proc/devices,导致注册失败。建议优先使用动态分配。 - 忘记cdev_del:模块卸载时只调用
unregister_chrdev_region而未删除cdev,会造成内核对象残留。 - fops函数原型错误:如read返回值类型写成void,编译可通过但运行异常。务必对照内核头文件。
- 直接访问用户指针:未使用copy_to_user/copy_from_user,可能导致内核崩溃或安全漏洞。
- 设备节点未创建:驱动加载成功但
/dev下无节点,应用无法打开。检查class/device_create是否成功,以及udev是否运行。