04-最简单的字符设备驱动
一、设备驱动分类
linux设备驱动一般分为3类,字符设备,块设备,网络设备。前两个在/dev目录下有对应的设备节点,网络设备比较特殊,没有。通过ls -l /dev/xx
可以看出设备类型:
thammer@test:~$ ls -l /dev/nvme0n1
brw-rw---- 1 root disk 259, 0 10月 9 08:59 /dev/nvme0n1
thammer@test:~$ ls -l /dev/tty
crw-rw-rw- 1 root tty 5, 0 10月 10 15:25 /dev/tty
以b
开头的表示块设备(block),以c
开头的表示字符设备(character)。块设备一般是指硬盘(HDD),固态硬盘(SSD),移动存储介质如SD卡,TF卡,U盘等,其他拥有设备节点的驱动基本都属于字符设备。暂时仅关注字符设备驱动。
二、字符设备驱动的核心概念
2.1 设备号
Linux 使用设备号来标识设备,设备号由两部分组成:
设备号 = 主设备号 (12位) + 次设备号 (20位)
- 主设备号:标识驱动程序,内核通过主设备号找到对应的驱动
- 次设备号:标识具体设备,同一驱动可管理多个同类型设备
查看已注册的设备号:
cat /proc/devices
2.2 file_operations 结构体
file_operations
是字符设备的核心,定义了用户空间系统调用与驱动函数的映射关系:
struct file_operations {
struct module *owner;
int (*open) (struct inode *, struct file *);
int (*release) (struct inode *, struct file *);
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
// ... 还有很多其他操作
};
映射关系:
- 用户调用
open()
→ 内核调用fops->open()
- 用户调用
read()
→ 内核调用fops->read()
- 用户调用
write()
→ 内核调用fops->write()
- 用户调用
close()
→ 内核调用fops->release()
这些称为系统调用,系统调用会通过中断,切换到内核空间,从而调用内核对应的函数,也就是上面举例的这些,当然还有很多其他的未一一列举。可以发现在用户空间,它们采用统一接口,在内核空间根据不同的设备驱动,调用相应的操作接口。
2.3 用户空间与内核空间的隔离
Linux 将内存空间分为用户空间和内核空间,两者相互隔离。驱动中不能直接访问用户空间指针,必须使用以下函数:
// 从用户空间复制数据到内核空间(用于 write)
unsigned long copy_from_user(void *to, const void __user *from, unsigned long n);
// 从内核空间复制数据到用户空间(用于 read)
unsigned long copy_to_user(void __user *to, const void *from, unsigned long n);
返回值:0 表示成功,非 0 表示未复制的字节数。
三、极简字符设备驱动示例
#include <linux/module.h>
#include <linux/fs.h>
// 主设备号
static int major;
static char devName[] = "mychardev";
// 对应应用空间的open系统调用
static int chardev_open(struct inode *inode, struct file *file)
{
printk(KERN_INFO "chardev open\n");
return 0;
}
// 对应应用空间的close系统调用
static int chardev_release(struct inode *inode, struct file *file)
{
printk(KERN_INFO "chardev release\n");
return 0;
}
// 字符设备驱动对应的文件操作结构
static struct file_operations fOpts = {
.owner = THIS_MODULE,
.open = chardev_open,
.release = chardev_release
};
//加载内核模块后的入口函数
static int __init chardev_drv_init(void)
{
printk(KERN_INFO "chardev driver init\n");
//向内核注册字符设备驱动
major = register_chrdev(0, devName, &fOpts);
if (major < 0)
{
printk(KERN_ERR "chardev driver regist\n");
return major;
}
printk(KERN_INFO "got major:%d\n", major);
return 0;
}
//卸载内核模块后的清理函数
static void __exit chardev_drv_exit(void)
{
//向内核注销字符设备驱动
unregister_chrdev(major, devName);
printk(KERN_INFO "chardev driver exit\n");
}
module_init(chardev_drv_init);
module_exit(chardev_drv_exit);
MODULE_LICENSE("GPL");
编译该字符设备驱动的Makfile:
KERN_DIR=/usr/src/linux-headers-$(shell uname -r)
all:
make -C ${KERN_DIR} M=$(shell pwd) modules
clean:
make -C ${KERN_DIR} M=$(shell pwd) modules clean
obj-m += chardev_drv.o
四、编译、加载测试
4.1 编译驱动
make
4.2 加载驱动
sudo insmod chardev_drv.ko
通过dmesg可以看到内核日志输出:
[ 5476.132713] chardev driver init
[ 5476.132717] got major:237
4.3 手动创建设备节点
到此应用程序如果要操作这个驱动,还缺乏对应的设备节点。这里需要我们手动创建设备节点:
sudo mknod /dev/mychardev c $(cat /proc/devices | grep mychardev | awk '{print $1}') 0
# 验证
ls -l /dev/mychardev
# 输出: crw-r--r-- 1 root root 237, 0 xxx /dev/mychardev
说明:c
表示字符设备,237
是主设备号,0
是次设备号。
mknode命令用于手动创建设备节点,其命令创建设备节点的格式为:
mknod [选项]... 节点路径 类型 [主设备号 次设备号]
- 选项:一般可以通过
-m
指定创建的设备节点的文件权限。也可以在创建后通过chmod修改。 - 节点路径:习惯位于/dev下面或者其子目录,但是这仅仅是一个习惯而已,实际如果你想要,可以是任意位置。
- 类型:就是
c
,b
等,还可以是比较少见的p
或者u
,p
表示管道,u
和c
等价。 - 主,次设备号:当类型为
p
时不能指定主,次设备号,其他类型时,必须指定。
当我们insmod
后,内核模块被加载,在/proc/devices
这个文件中就会产生一行新的记录,分别是主设备号和驱动名称(例如上面通过register_chrdev
传入的"mychardev"),这也是手动创建设备节点时获得主设备号的一个依据。
4.4 测试驱动
最简单的方法是通过命令方式,比如cat
sudo cat /dev/mychardev
#执行后dmesg -w会输出下面的内容
#[63682.250395] chardev open
#[63682.250485] chardev release
由于此时我这里仅仅提供了open
和close
系统调用对应的内核空间驱动的相应接口,所以cat会报错,因为cat至少还需要read
系统调用。
五、增加read,write对应接口
5.1 在原驱动基础上新增如下内容:
// 定义一个全局的数组,存储应用空间通过write传过来的数据,并在read时又将数据返回。
#define KBUF_SIZE 4096
static char kBuf[KBUF_SIZE];
// 对应read系统调用
static ssize_t chardev_read(struct file *file, char __user *buf, size_t size, loff_t *offset)
{
unsigned long ret;
ssize_t bytesRead = 0;
if (size > KBUF_SIZE)
{
bytesRead = KBUF_SIZE;
}
else
{
bytesRead = size;
}
ret = copy_to_user(buf, kBuf, bytesRead);
if (ret != 0)
{
printk(KERN_INFO "copy_to_user failed\n");
return -EFAULT;
}
printk(KERN_INFO "chardev read %ld bytes\n", bytesRead);
return bytesRead;
}
// 对应write系统调用
static ssize_t chardev_write(struct file *file, const char __user *buf, size_t size, loff_t *offset)
{
unsigned long ret;
ssize_t bytesWrite = 0;
if (size > KBUF_SIZE)
{
bytesWrite = KBUF_SIZE;
}
else
{
bytesWrite = size;
}
memset(kBuf, 0, KBUF_SIZE);
ret = copy_from_user(kBuf, buf, bytesWrite);
if (ret != 0)
{
printk(KERN_INFO "copy_from_user failed\n");
return -EFAULT;
}
printk(KERN_INFO "chardev write %ld bytes\n", bytesWrite);
return bytesWrite;
}
// 字符设备驱动对应的文件操作结构
static struct file_operations fOpts = {
.owner = THIS_MODULE,
.open = chardev_open,
.release = chardev_release,
.read = chardev_read,
.write = chardev_write
};
这只是一个测试验证的例子,并不是一个完善的驱动,存在一些问题,比如未维护kBuf
当前存储位置,暂时可以忽略这些。重新编译,并且卸载原来的驱动,重新加载。如果主设备号变了,那么设备节点也要删除,并重新按照新的主设备号生成,如果没变,就不需要做什么。
make
sudo rmmod chardev_drv
sudo insmod chardev_drv.ko
5.2 测试验证
sudo echo "hello world" > /dev/mychardev
sudo cat /dev/mychardev
由于未维护kBuf
的存储位置信息,未标记有效数据,导致read一直可读,因此cat会一直读到数据。
六、设备号、设备节点、file_operations 结构体之间的关联
为什么通过打开设备节点,然后就可以调用到对应的file_operations.open?因为设备节点这个文件的元数据(inode信息)里面可以得到设备号信息,然后使用这个设备号,就可以在内核空间查询到对应的file_operations了。
用户空间
│
├─ 设备节点 (/dev/mydevice) → 设备号 (Major:237 Minor:0)
↓
内核空间
├─ 设备号 237 → 绑定 file_operations (mydev_read/mydev_write/...)
↓
硬件设备
查看内核源码里面对struct inode的定义:
里面分别有cdev
和file_operations
。再看struct cdev字符设备结构的定义:
这里的的dev_t dev
成员就是设备号。