字符设备驱动框架

简介

Linux的字符设备驱动框架是以struct cdev为核心的一套API,通过向内核注册cdev,从而向用户空间提供对设备进行IO操作的接口

注意:Linux字符设备驱动框架主要用于封装设备的IO操作,有别于Linux的设备驱动模型,并不负责设备与驱动的匹配之类的,所以2者通常同时出现

而且不是所有字符设备的驱动都需要用到这个框架,比如一个用了input子系统的按键,它不需要向用户提供IO操作的接口,所以就不需要(其实这是驱动分层的结果,input子系统的核心层会用到该框架来提供用户对/dev/input/eventXIO操作的能力,但我们自己写的驱动层不需要用)

核心组件

image-20250316161807846

cdev

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct cdev {
struct kobject kobj;
struct module *owner;
const struct file_operations *ops;
struct list_head list;
dev_t dev;
unsigned int count;
};
//初始化cdev结构体
void cdev_init(struct cdev *cdev, const struct file_operations *fops);
//向Linux系统添加cdev设备
int cdev_add(struct cdev *p, dev_t dev, unsigned count);
//从Linux系统删除字符设备
void cdev_del(struct cdev *p);
//数用于动态申请一个cdev内存
struct cdev *cdev_alloc(void);

核心成员:

  • owner:这个字符设备属于哪个模块
  • ops:封装了的I/O操作
  • dev:设备号

file_operations

1
2
3
4
5
6
7
8
9
10
11
12
struct file_operations {
struct module *owner;
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
int (*mmap) (struct file *, struct vm_area_struct *);
int (*mremap)(struct file *, struct vm_area_struct *);
int (*open) (struct inode *, struct file *);
int (*flush) (struct file *, fl_owner_t id);
int (*release) (struct inode *, struct file *);
long (*unlocked_ioctl) (struct file * filp, unsigned int cmd, unsigned long arg);
...
};

file_operations是Linux的VFS提供的一个对于struct file进行操作的类,VFS用file抽象内存中打开的文件。对于不同类型的文件/设备,I/O操作通常不同,所以会设计这样一个file_operations

内核与用户空间的数据交互

在对设备节点进行read()write()操作时,本质上就是内核与用户空间进行数据的交互,我们知道用户空间和内核其实是隔离的,因此在驱动中(内核中)与用户空间进行数据传输需要特定的API:

1
2
unsigned long copy_from_user(void *to, const void __user *from, unsigned long n);
unsigned long copy_to_user(void __user *to, const void *from, unsigned long n);

实现原理:

1.检查传入的地址是不是有效的用户空间地址

2.处理页错误

3.数据拷贝

疑问:根据Linux内存空间的布局,对于一个进程来说,其内核空间与用户空间共用1个页表,且内核态时,CPU可以访问任意空间的内存,为啥驱动中(内核态)还要用这个copy_from_userAPI,而不直接访问用户空间的内存并进行数据拷贝?

  • 1.考虑了安全性,会做地址有效性的检查
  • 2.不是所有体系结构的CPU都可以在内核态直接访问用户空间的内存的,这个API提供了和平台无关的抽象

ioctrl

ioctrl函数一般接收一个命令cmd和参数arg,根据cmd的不同可以进行多种不同的操作。为了避免命令号污染,Linux提供了一个统一的命令命名准则

image-20250316170014025

dev_t

通过cat /proc/devices可以查看当前所有已经使用了的设备号

1
typedef __u32 dev_t;

设备号是内核用来唯一标识系统中设备的值,每个设备都有一个唯一的设备号。设备号由主设备号(major number)和次设备号(minor number)组成。dev_t被扩展为32位无符号整数,其中高12位为主设备号,低20位为次设备号

  • 主设备号:用于标识设备驱动程序
  • 次设备号:用于标识同一类设备中的具体实例

设备号相关宏定义

1
2
3
#define MAJOR(dev)	((unsigned int) ((dev) >> MINORBITS)) //提取主设备号
#define MINOR(dev) ((unsigned int) ((dev) & MINORMASK)) //提取主设备号
#define MKDEV(ma,mi) (((ma) << MINORBITS) | (mi)) // 合并完整设备号

设备号的分配

1
2
// 静态分配(已有主设备号)
int register_chrdev_region(dev_t, unsigned, const char *);
  • dev_t dev:: 指向设备号的指针,函数会通过这个指针返回分配的设备号
  • unsigned count:连续设备号的数量
  • const char *name:设备或驱动程序的名称
    • 返回值:成功时返回0,失败时返回负值
1
2
// 动态分配(内核自动决定一个主设备号和次设备号)
int alloc_chrdev_region(dev_t *dev, unsigned int baseminor, unsigned int count, const char *name);
  • dev: 指向设备号的指针,函数会通过这个指针返回分配的设备号
  • baseminor: 起始次设备号,通常为 0
  • count: 要分配的设备号数量
  • name: 设备的名称
1
2
// 释放设备号
void unregister_chrdev_region(unsigned int base, unsigned int count);
  • base:主设备号
  • count:要释放的设备个数

开发步骤

image-20240707103134399

1.根据需求实现设备的具体操作函数

  • file_operations结构体就如同JAVA中的一个接口类,其中定义了设备具体操作的一些接口(函数指针),包括open()release()read()write()等等,在写驱动模块时,需要实现其中定义的API

2.实现模块的入口/出口的具体函数

  • 即实现xxx_init()xxx_exit()函数

3.在入口/出口代码中,加入cdev注册/注销

4.代码中调用module_init(xxx_init)module_exit(xxx_exit),将上述2个函数指定为加载/卸载该模块时所调用的函数

5.添加LICENSE和作者信息:不加LICENSE编译会报错

6.交叉编译驱动模块文件(.ko)

  • 在编译驱动模块文件时,需要使用Linux内核源码,且不需要写main()函数,这和之前的程序有很大的区别

7.写驱动模块的测试代码(需要写main()函数)

8.交叉编译测试代码,得到可执行文件,进行测试

字符设备I/O操作底层原理

我们在注册字符设备的驱动时,通常要指定其.file_operations成员变量,使得我们在用户态对字符设备做I/O操作时,能用我们重写的I/O操作来控制设备,这底层的原理到底是什么呢?

1
2
3
4
5
6
7
8
9
10
11
static struct file_operations fops = {
.owner = THIS_MODULE,
.open = my_device_open,
.read = my_device_read,
.write = my_device_write,
.release = my_device_release,
/* 其他操作 */
};

struct cdev my_cdev;
cdev_init(&my_cdev, &fops);

以下面的代码为例,展示字符设备的file_operations到底是怎么被调用的

1
2
3
4
int fd = open("/dev/my_device", O_RDWR);
write(fd, buffer, size);
read(fd, buffer, size);
close(fd);

1.文件打开过程 (open): 当用户进程尝试打开一个文件(例如字符设备文件 /dev/my_device)时,内核通过文件路径找到对应的 inode 结构体。

2.inodefile 的关联: 在成功找到 inode 后,内核会为该文件创建一个 file 结构体实例。file 结构体代表一个已打开的文件,包含文件指针、访问模式等。

1
2
3
4
5
6
struct file {
const struct file_operations *f_op;
struct inode *f_inode; /* 文件对应的 inode */
struct dentry *f_path.dentry;
/* 其他成员 */
};
  • f_inode 指向与该文件关联的 inode 结构体。
  • f_op 指向文件系统或设备驱动程序定义的 file_operations 结构体。

3.初始化 file_operations: 在打开文件时,内核会通过 inode 结构体找到与设备文件关联的 file_operations 结构体,并将其赋值给 file 结构体中的 f_op 成员。这个 file_operations 结构体通常由设备驱动程序在注册设备时指定。

1
file->f_op = fops; // fops是设备驱动程序中定义的 file_operations 结构体

4.调用 file_operations 函数: 之后,当用户执行 read, write, ioctl 等操作时,内核会使用 file 结构体中的 f_op 指针调用对应的操作函数。

参考链接: