Linux驱动 - 一个虚拟字符设备(缓冲区)
书接上文,不再对模块构建的环境作赘述。传送门:编译一个模块
该模块的效果是,用户态通过read()/write()调用可以读写我们的设备文件(/dev/myCharDev),在内核中实现一个ringbuffer,用户可以多次写入数据,并将先前写入的数据读出来。
基本虚拟字符设备驱动的实现
顶层流程:
- 模块加载时,获取设备号(注册或分配)
- 初始化字符设备结构(
cdev_init()),需要指定file_operation回调集 - 注册
cdev(cdev_add()) - 创建设备类(
class_create())+设备文件(device_create()) - 卸载时释放设备类、设备文件、设备号
file_operation结构的定义
我们需要创建一个struct file_operations类型的结构,它是字符设备可被操作的动作合集。在这里,我们为它创建:
open- 打开设备文件release- 释放设备文件read- 读取设备文件write- 写入设备文件
这四个动作。它们的原型分别为:
open
当用户open()对应设备文件时执行。
struct inode: 设备文件在内核中的inode对象,包含设备号、权限、文件类型
最常用操作是用来获取设备号:int minor = iminor(inode), major = imajor(inode);用来判断哪个设备被打开,支持多设备。例如,/dev/led0和/dev/led1用minor做区分struct file:当前打开的文件实例,fd对应的内核对象
每次open都会创建一个struct file,里面有一个重要字段file->private_data,常用它来保存设备上下文,这样read/write时可以召回
int xx_open(struct inode *inode, struct file *file);
release
当用户close()对应设备文件时执行。参数等同于open,用于关闭设备,释放资源。
内核使用kfree释放堆内存。
int xx_release(struct inode *inode, struct file *file);
read
用户用read()读文件或者用cat读文件时调用。
struct file:当前打开的文件实例,主要用file->private_data访问设备上下文。char __user buf:用户空间缓冲区地址,用户调用read时传入的接收地址
注意__user标识这是用户空间指针,必须用copy_to_user访问size_t count:用户请求读取的字节数。驱动未必返回此值,可以返回较小实际值或0(EOF)loff_t f_pos:文件偏移量。用户执行过lseek后此选项生效。- 返回值:实际读到的字节数。可以小于count或0(EOF)
ssize_t xx_read(struct file *file, char __user *buf, size_t count, loff_t *f_pos);
write
用户用write()写或echo到设备文件时执行。
ssize_t xx_write(struct file *file, const char __user *buf, size_t count, loff_t *f_pos);
驱动代码实现
#include <linux/module.h> // 模块基础
#include <linux/init.h> // 初始化宏
#include <linux/fs.h> // 文件操作
#include <linux/device.h> // 设备模型
#include <linux/uaccess.h> // 用户空间访问
#include <linux/slab.h> // 内存分配
#include <linux/cdev.h> // 字符设备(进阶用)
#include <linux/kernel.h>
#include <linux/string.h>
#define DEFAULT_RB_SIZE (512)
static struct myChardev_t
{
uint8_t *buff;
uint32_t front;
uint32_t back;
uint32_t rb_size;
} myCharDev;
static uint8_t buff[DEFAULT_RB_SIZE];
static int myChardev_open(struct inode *inode, struct file *file)
{
printk(KERN_INFO "chardev rb opened.\n");
return 0;
}
static int myChardev_release(struct inode *inode, struct file *file)
{
printk(KERN_INFO "chardev rb released.\n");
return 0;
}
static ssize_t myChardev_read(struct file *file, char __user *buf, size_t count, loff_t *f_pos)
{
size_t i = 0;
while (i < count && myCharDev.front != myCharDev.back)
{
if (copy_to_user(&buf[i], &myCharDev.buff[myCharDev.back], 1))
{
return -EFAULT;
}
myCharDev.back = (myCharDev.back + 1) & (DEFAULT_RB_SIZE - 1);
++i;
}
return i;
}
static ssize_t myChardev_write(struct file *file, const char __user *buf, size_t count, loff_t *f_pos)
{
size_t i = 0;
while (i < count)
{
size_t next = (myCharDev.front + 1) & (DEFAULT_RB_SIZE - 1);
if (next == myCharDev.back)
break;
if (copy_from_user(&myCharDev.buff[myCharDev.front], &buf[i], 1))
return -EFAULT;
myCharDev.front = next;
++i;
}
return i;
}
static dev_t dev_num;
static struct cdev rb_cdev;
static struct class *cls;
static struct file_operations ring_fops = {
.owner = THIS_MODULE,
.open = myChardev_open,
.read = myChardev_read,
.write = myChardev_write,
.release = myChardev_release,
};
static char *my_class_devnode(const struct device *dev, umode_t *mode)
{
if (mode)
*mode = 0666; // 读写权限都给用户
return NULL;
}
static int __init virdev_init(void)
{
memset(buff, 0, sizeof(buff));
myCharDev.buff = buff;
myCharDev.front = 0;
myCharDev.back = 0;
myCharDev.rb_size = DEFAULT_RB_SIZE;
int ret = alloc_chrdev_region(&dev_num, 0, 1, "myrb");
if (ret < 0)
{
printk(KERN_INFO "virChardev: 设备号分配失败\n");
return ret;
}
cdev_init(&rb_cdev, &ring_fops);
ret = cdev_add(&rb_cdev, dev_num, 1);
if (ret)
{
printk(KERN_INFO "virChardev: 新增字符设备失败\n");
unregister_chrdev_region(dev_num, 1);
return ret;
}
//deprecated in Kernel 6.12
// cls = class_create(THIS_MODULE, "myrb");
cls = class_create("myrb");
cls->devnode = my_class_devnode;
device_create(cls, NULL, dev_num, NULL, "myrb");
printk(KERN_INFO "virChardev: 驱动已加载,major=%d\n", MAJOR(dev_num));
return 0;
}
static void __exit virdev_exit(void)
{
device_destroy(cls, dev_num);
class_destroy(cls);
cdev_del(&rb_cdev);
unregister_chrdev_region(dev_num, 1);
printk(KERN_INFO "virChardev: 驱动已卸载\n");
}
module_init(virdev_init);
module_exit(virdev_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("KurehaTian");
MODULE_DESCRIPTION("Description");
MODULE_VERSION("1.0");
对于之前介绍过的部分,不再赘述。
头文件引用+宏定义:
#include <linux/fs.h>
#include <linux/device.h>
#include <linux/uaccess.h>
#include <linux/slab.h>
#include <linux/cdev.h>
#include <linux/string.h> // 内核的memset等实现
#define DEFAULT_RB_SIZE (512)
linux/fs.h: 文件操作linux/device.h: 设备模型linux/uaccess.h: 用户空间访问linux/slab.h: 内存分配linux/cdev.h: 字符设备
环形缓冲定义:
buff指向一段空间frontback是头尾位置- 初始化时将
myCharDev的buff指针指向buff数组
static struct myChardev_t
{
uint8_t *buff;
uint32_t front;
uint32_t back;
uint32_t rb_size;
} myCharDev;
static uint8_t buff[DEFAULT_RB_SIZE];
设备的 file_operation:
- 打开设备文件时执行
static int myChardev_open(struct inode *inode, struct file *file)
{
printk(KERN_INFO "chardev rb opened.\n");
return 0;
}
- 关闭设备文件时执行
static int myChardev_release(struct inode *inode, struct file *file)
{
printk(KERN_INFO "chardev rb released.\n");
return 0;
}
open close是每打开/关闭都会执行,所以慎重考虑在这个地方初始化/释放资源!举个例子,如果在这里归零/初始化,那么每次
echo/cat的状态都带不到下条指令!- 从设备(内核)读取数据到用户空间
static ssize_t myChardev_read(struct file *file, char __user *buf, size_t count, loff_t *f_pos)
{
size_t i = 0;
while (i < count && myCharDev.front != myCharDev.back)
{
if (copy_to_user(&buf[i], &myCharDev.buff[myCharDev.back], 1))
{
return -EFAULT;
}
myCharDev.back = (myCharDev.back + 1) & (DEFAULT_RB_SIZE - 1);
++i;
}
return i;
}
- 从用户空间写入到内核空间
static ssize_t myChardev_write(struct file *file, const char __user *buf, size_t count, loff_t *f_pos)
{
size_t i = 0;
while (i < count)
{
size_t next = (myCharDev.front + 1) & (DEFAULT_RB_SIZE - 1);
if (next == myCharDev.back)
break;
if (copy_from_user(&myCharDev.buff[myCharDev.front], &buf[i], 1))
return -EFAULT;
myCharDev.front = next;
++i;
}
return i;
}
file_operation定义
static struct file_operations ring_fops = {
.owner = THIS_MODULE,
.open = myChardev_open,
.read = myChardev_read,
.write = myChardev_write,
.release = myChardev_release,
};
模块加载动作:
- 设计设备文件权限回调
static char *my_class_devnode(const struct device *dev, umode_t *mode)
{
if (mode)
*mode = 0666; // 读写权限都给用户
return NULL;
}
- 清空环形缓冲区
static dev_t dev_num;
static struct cdev rb_cdev;
static int __init virdev_init(void)
{
memset(buff, 0, sizeof(buff));
myCharDev.buff = buff;
myCharDev.front = 0;
myCharDev.back = 0;
myCharDev.rb_size = DEFAULT_RB_SIZE;
//... ...
- 分配设备号
//... ...
int ret = alloc_chrdev_region(&dev_num, 0, 1, "myrb");
if (ret < 0)
{
printk(KERN_INFO "virChardev: 设备号分配失败\n");
return ret;
}
cdev_init(&rb_cdev, &ring_fops);
ret = cdev_add(&rb_cdev, dev_num, 1);
// 也可用register_chrdev()静态指定设备号
if (ret)
{
printk(KERN_INFO "virChardev: 新增字符设备失败\n");
unregister_chrdev_region(dev_num, 1);
return ret;
}
- 创建设备类和设备文件
//deprecated in Kernel 6.12
// cls = class_create(THIS_MODULE, "myrb");
// 创建设备类
cls = class_create("myrb");
//设备节点回调
cls->devnode = my_class_devnode;
//创建设备
device_create(cls, NULL, dev_num, NULL, "myrb");
printk(KERN_INFO "virChardev: 驱动已加载,major=%d\n", MAJOR(dev_num));
return 0;
}
模块卸载动作:
原则:先销毁最上层,再销毁底层资源
static void __exit virdev_exit(void)
{
device_destroy(cls, dev_num);
class_destroy(cls);
cdev_del(&rb_cdev);
unregister_chrdev_region(dev_num, 1);
printk(KERN_INFO "virChardev: 驱动已卸载\n");
}
实验
执行以下指令:
$ echo "helloWorld" > /dev/myrb
$ cat /dev/myrb
helloWorld
深入话题
模块中内存的生命周期
- “全局变量”: 生命周期是从模块加载直至卸载。
static修饰的全局变量仅在当前模块中生效,否则可以被其他模块访问到。但是,由于各个模块的生命周期不尽相同,所以可能会存在风险。对于需要与其他模块共享的变量或函数,应当使用EXPORT_SYMBOL或EXPORT_SYMBOL_GPL导出到内核符号表(GPL模块只能被GPL模块引用),在其他模块中extern获取到它。编译时extern告诉编译器此符号在别处,会尝试在内核符号表中查找这个符号。找得到就绑定导出的地址,找不到就加载失败,内核崩溃。因此引入了模块引用计数机制,如果引用模块A的其他模块没有全部卸载,则模块A不能被卸载。 - “局部变量” : 在内核栈中。在哪个task中陷入内核态并调用内核函数,这些变量就放在哪个task的内核栈中。每个task的内核栈大约为8KB/16KB。每个task都会有自己独立的用户栈和内核栈,具体见文《Linux深入探索之进程与线程的关系》(如果没加链接那就是我还没写)。
为什么用copy_to_user/copy_from_user
在内核中操作用户空间内存必须使用这两个函数。原型:
static inline int copy_from_user(void *to, const void __user volatile *from, unsigned long n);
static inline int copy_to_user(void __user volatile *to, const void *from, unsigned long n);
为什么要用这个方式访问?
在用户态中不许访问内核地址,内核态中也不准直接用memcpy等方式访问。
因为这个指针可能不合法,也可能无权限,也可能这个用户页不在物理内存中,需要先触发page fault机制来swap-in。
copy_to/from_user执行了严格的检查:
- 检查用户地址有效性
access_ok(to,n) - 保护内核页表,用内核机制安全访问用户页,如果有page fault捕获并返回,不会直接panic
- 处理跨页访问/分段,逐页处理
为什么内核代码能访问到调用它的进程的用户空间内存?
因为在哪里陷入内核调用,肯定就是在哪个进程地址空间中,此时上下文中使用该进程的页表,寻址使用该进程的地址空间。
关于页表,后面另文再表。
设备类、设备与字符设备
Linux内核中,设备模型分为三个层次:
- 设备类(
class):代表一类设备,用class_create()创建,会出现在/sys/class/下,把同类设备归组便于 sysfs 管理。 - 字符/块设备(
cdev/bdev):代表具体的驱动实例,必须和主/次设备号绑定,把驱动对应到设备号上。(struct cdev类型, cdev_init, cdev_add) - 设备(
device):具体的硬件或驱动实例,用于用户空间创建设备节点。用device_create创建。
device_create本质上做了两件事:
- 在 sysfs 中创建了 device 条目:
/sys/class/<classname>/<devname> - 在
/dev/下创建设备节点,对应传入的dev_t(主/次设备号)。
而设备文件本身是一个入口点,用户空间通过open("/dev/devname")访问内核,内核根据cdev找到对应file_operation执行,同一类里面调用的是同一个逻辑。
若同一个class创建了多个device:
struct class *cls = class_create("myclass");
device_create(cls, NULL, MKDEV(240, 0), NULL, "dev0");
device_create(cls, NULL, MKDEV(240, 1), NULL, "dev1");
device_create(cls, NULL, MKDEV(240, 2), NULL, "dev2");
/dev/dev0、/dev/dev1、/dev/dev2都指向同一个 cdev / 驱动实例- 内核上下文(驱动代码、全局变量、fops)是 共享的;
- 不同
device节点只是 不同的访问入口,你可以在open()或ioctl()中根据inode->i_rdev(设备号)区分处理逻辑
要实现同一种设备不同节点的特异化:
- 可以通过
minor作区分,不同节点打开的minor是不同的, 驱动共用一份fops,通过minor分流 - 通过面向对象设计,将设备对象化,用
devdata存储上下文(在inode->private_data里),即可在同一个驱动逻辑里做不同处理
引申概念:
container_of可以用于通过某结构体的成员地址和名称,捕获到这个结构体的地址。