在Linux内核中,字符驱动是最为基础的设备驱动。本文实现了一个最简单的字符设备驱动,支持简单的读写功能。与常规驱动不同,该设备实际上是一块虚拟的内存,并不能做任何真正有用的事情。
准备知识
主要介绍一些与字符设备驱动相关的知识。
设备号
所谓一切皆文件,Linux也是通过文件来管理设备的,这些驱动文件被称为特殊文件,通常位于/dev目录下,我们可以通过ls -l /dev
命令查看/dev下的文件,以下是其中的一部分1
2
3
4
5
6
7
8
9crw------- 1 root root 108, 0 Dec 2 12:44 ppp
crw------- 1 root root 10, 1 Dec 2 12:44 psaux
crw-rw-rw- 1 root tty 5, 2 Dec 2 20:19 ptmx
drwxr-xr-x 2 root root 0 Dec 2 2013 pts
brw-rw---- 1 root disk 1, 0 Dec 2 12:44 ram0
brw-rw---- 1 root disk 1, 1 Dec 2 12:44 ram1
brw-rw---- 1 root disk 1, 10 Dec 2 12:44 ram10
brw-rw---- 1 root disk 1, 11 Dec 2 12:44 ram11
brw-rw---- 1 root disk 1, 12 Dec 2 12:44 ram12
开头的第一个字母表示了设备类型,其中c代表的就是字符文件。而在修改日期前面,可以看到两个逗号隔开的数字,分别就是设备的主设备号和次设备号,主设备号表示管理该设备的驱动程序,而次设备号是驱动用来识别不同设备的,内核并不关心。通过cat /proc/devices
命令,我们可以看到现在已经被用掉的主设备号。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18Character devices:
1 mem
4 /dev/vc/0
4 tty
4 ttyS
5 /dev/tty
5 /dev/console
.........
Block devices:
1 ramdisk
259 blkext
7 loop
8 sd
9 md
11 sr
65 sd
..........
一般来说,主设备号和次设备号唯一的决定了一台设备以及它所使用的驱动程序。在内核中,dev_t
类型用于保存设备编号,其中前12位是主设备号,后20位是次设备号,有三个与之相关的宏,我们可以用这三个宏在设备编号、主设备号、次设备号之间进行转换。1
2
3MAJOR(dev_t dev); //根据设备号获得设备的主设备号
MINOR(dev_t dev); //根据设备号获得设备的次设备号
MKDEV(int major, int minor); //根据主设备号和次设备号来组成设备编号
在加载我们的驱动程序时,首先要做的就是获得一个或多个设备编号以供使用,相关的函数有以下三个1
2
3int register_chrdev_region(dev_t first, unsigned int count, char *name);
int alloc_chrdev_region(dev_t *dev, unsigned int firstminor, unsigned int count, char *name);
void unregister_chrdev_region(dev_t first, unsigned int count);
这三个函数都可以同时对多个设备进行操作,最后一个函数用于释放设备号,会对first开始的一共count个设备号进行释放。需要区分的是前两个分配函数,第一个函数会分配从first开始一共分配count个设备,这些设备的主设备号都是指定的。但往往我们并不知道哪些主设备号已经被使用了,为此内核提供了第二个函数,内核动态分配一个主设备号给驱动并存在dev中返回,用户仅需指定需要的次设备号的开始数字及个数即可。分配函数中的name作为设备标识,分配后可以在/proc/devices
文件中查看到。
字符设备注册
内核使用struct cdev
来表示字符设备,在内核对一个字符设备操作之前,必须分配一个这样的结构体。内核提供了以下几个函数对这个结构体进行分配和释放操作。需要注意的是,初始化后我们要手动分配cdev
的owner
为THIS_MODULE
,以及文件操作函数表。1
2
3
4
5
6
7cdev_alloc(); //返回一个struct cdev指针,用于动态内存初始化
cdev_init(struct cdev *cdev, struct file_operations *fops);
//用于静态内存初始化
cdev_add(struct cdev *dev, dev_t num, unsigned int count);
//告知内核,将设备添加到系统中
void cdev_del(struct cdev *dev);
//将设备从系统中移除
文件操作
对于字符设备,内核是调用file_operations
中的函数指针来进行操作的,我们要将这些函数指针指向我们自己实现的函数,这其中的函数十分之多,而我们只用实现以下几种操作就可以实现简单读写了。1
2
3
4
5int (*open)(struct inode *, struct file *);
int (*release)(struct inode *, struct file *);
size_t (*read)(struct file *, char __user *, size_t, loff_t *);
size_t (*write)(struct file *, const char __user *, size_t, loff_t *);
loff_t (*llseek)(struct file *, loff_t, int);
其中open
和release
是打开和关闭文件时调用的函数,为空时文件将永远打开成功以及关闭成功。read
和write
就是读写操作了,__user
代表这是一个用户空间地址,内核不能直接操作,需要调用copy_to_user
和copy_from_user
在用户空间地址和内核空间地址间进行拷贝,loff_t
代表当前的文件偏移。最后的llseek
是在lseek
函数调用的,函数原型基本一样。
file
结构并不是用户空间的FILE
结构体,两者无任何关联。它由内核管理,指向一个打开的文件,我们需要使用的是它的private_data
域,我们用这个域将file结构体与我们的设备关联起来。
inode
应该都很熟悉,它与文件一一对应,有着大量字段,但对字符设备驱动编写有用的只有以下两个1
2dev_t i_rdev; //该文件的设备编号
struct cdev *i_cdev; //指向一个cdev结构体的指针
代码实现
结构体与函数原型
memcdev.h
的内容如下,该头文件主要包括三部分内容,一是可以在编译时决定是否打印调试信息的DEBUG宏,二是我们的字符设备结构体,三是相关文件操作函数原型。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//DEBUG宏
//字符设备结构体
struct memcdev {
char mem[BUFSIZE];
unsigned int size;
struct cdev cdev;
};
//开关函数原型
int memcdev_open(struct inode *, struct file *);
int memcdev_release(struct inode *, struct file *);
//读写函数原型
ssize_t memcdev_read(struct file *, char __user *, size_t ,loff_t *);
ssize_t memcdev_write(struct file *, const char __user *, size_t, loff_t *);
//修改当前读写指针函数
loff_t memcdev_lseek(struct file *, loff_t, int);
Begin
这里定义了两个可作为参数的变量,分别是主设备号和次设备号,memcdev
是我们使用的字符设备,而memcdev_fops
指定了对应的处理函数。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h>
#include <linux/types.h>
#include <linux/cdev.h>
#include <linux/fcntl.h>
#include <linux/slab.h>
#include <asm/uaccess.h>
#include "memcdev.h"
int memcdev_major = 0; //主设备号,默认0表示由系统分配
int memcdev_minor = 0; //次设备号
module_param(memcdev_major, int, S_IRUGO);
module_param(memcdev_minor, int, S_IRUGO);
struct memcdev *memcdev;
struct file_operations memcdev_fops = {
.owner = THIS_MODULE,
.read = memcdev_read,
.write = memcdev_write,
.open = memcdev_open,
.release = memcdev_release,
.llseek = memcdev_lseek,
};
加载与卸载模块
初始化函数的主要空间就是分配设备号和内存,我们这里分配了一块次设备号为0的设备。这里的清除函数我们并没有加__exit
,因为我们在初始化失败时也会调用此函数来释放已经申请的内存。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59static void memcdev_exit(void) {
dev_t dev = MKDEV(memcdev_major, memcdev_minor);
//移除字符设备
cdev_del(&memcdev->cdev);
//释放字符设备占的内存
kfree(memcdev);
//释放设备编号
unregister_chrdev_region(dev, 1);
DPRINTF("bye memcdev!\n");
}
//__init表示将代码段放到init section中,初始化完就移出内存
static int __init memcdev_init(void) {
int retval = 0;
size_t devssize = sizeof(struct memcdev);
dev_t dev = MKDEV(memcdev_major, memcdev_minor);
//如果用户手动设置了主设备号则使用用户设置的设备号
if (memcdev_major) {
retval = register_chrdev_region(dev, 1, "memcdev");
//否则由系统动态来分配一个设备号
} else {
retval = alloc_chrdev_region(&dev, memcdev_minor, 1, "memcdev");
memcdev_major = MAJOR(dev);
}
//上面两个分配函数如果返回小于0说明分配设备号失败
if (retval < 0) {
DPRINTF("Can't get memcdev major!\n");
goto fail;
}
//给字符设备分配空间
memcdev = kmalloc(devssize, GFP_KERNEL);
if (!memcdev) {
retval = -ENOMEM;
goto fail;
}
memset(memcdev, 0, devssize);
//注册字符设备到内核
cdev_init(&memcdev->cdev, &memcdev_fops);
memcdev->cdev.owner = THIS_MODULE;
memcdev->cdev.ops = &memcdev_fops;
retval = cdev_add(&memcdev->cdev, dev, 1);
//返回非0表示添加设备失败
if (retval) {
DPRINTF("Can't add dev memcdev\n");
goto fail;
}
DPRINTF("hello memcdev!\n");
return 0;
fail:
memcdev_exit();
return retval;
}
module_init(memcdev_init);
module_exit(memcdev_exit);
//模块描述
MODULE_LICENSE("GPL");
MODULE_AUTHOR("swm8023");
打开与关闭文件
打开时关联文件,并注意在只写模式打开时要截断文件。release操作只是为了调试打印了一句话,总能调用成功。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15int memcdev_open(struct inode *node, struct file *filp) {
//将文件与设备关联,将设备结构体放到文件的private_data字段里
struct memcdev *dev = container_of(node->i_cdev, struct memcdev, cdev);
filp->private_data = dev;
//如果文件以只写模式打开,将文件内容清空
if ((filp->f_flags & O_ACCMODE) == O_WRONLY)
dev->size = 0;
DPRINTF("opened\n");
return 0;
}
int memcdev_release(struct inode *inode, struct file *filp) {
//没有操作直接retutn 0即可
DPRINTF("closed\n");
return 0;
}
读写操作
实际上是在对一块4K的内存进行读写,注意处理越界问题,并且要使用copy_to_user
和copy_from_user
在内核空间和用户空间之间传递数据。还有这里的偏移量传的是一个指针,我们要在函数中根据实际读写情况调整偏移量。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45ssize_t memcdev_read(struct file *filp, char __user *buf, size_t count, loff_t *fpos) {
struct memcdev *dev = filp->private_data;
int retval = 0;
//如果指针位置非法(超过文件大小)
if (*fpos >= dev->size)
goto ret;
//保证不会读越界(超过文件大小)
if (*fpos + count > dev->size)
count = dev->size - *fpos;
//拷贝数据到用户空间
if (copy_to_user(buf, dev->mem + *fpos, count)) {
retval = -EFAULT;
goto ret;
}
//修改偏移量
*fpos += count;
retval = count;
ret:
DPRINTF("read size:%d\n", retval);
return retval;
}
ssize_t memcdev_write(struct file *filp, const char __user *buf, size_t count, loff_t *fpos) {
struct memcdev *dev = filp->private_data;
int retval = 0;
//如果指针位置非法
if (*fpos >= BUFSIZE)
goto ret;
//保证不会写越界
if (*fpos + count > BUFSIZE)
count = BUFSIZE - *fpos;
//拷贝数据到内核空间
if (copy_from_user(dev->mem + *fpos, buf, count)) {
retval = -EFAULT;
goto ret;
}
//修改偏移量
*fpos += count;
retval = count;
//修改文件大小
if (dev->size < *fpos)
dev->size = *fpos;
ret:
DPRINTF("write size:%d\n", retval);
return retval;
}
lseek操作
这里就是实现lseek函数的操作,这个偏移指针是可以超过文件大小的,但不能为负。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19loff_t memcdev_lseek(struct file *filp, loff_t offset, int whence) {
struct memcdev *dev = filp->private_data;
loff_t newpos;
switch(whence) {
case SEEK_SET:
newpos = offset;
break;
case SEEK_CUR:
newpos = filp->f_pos + offset;
break;
case SEEK_END:
newpos = dev->size +offset;
default:
return -EINVAL;
}
if (newpos < 0) return -EINVAL;
DPRINTF("lseek %lld->%lld\n", filp->f_pos, newpos);
return filp->f_pos = newpos;
}
Makefile
test.c是我写的测试文件,也放在一起Make了。前面DEBUG = y
时开启调试输出。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25DEBUG = y
ifeq ($(DEBUG),y)
DEBFLAGS = -O -DMEMC_DEBUG
else
DEBFLAGS = -O2
endif
EXTRA_CFLAGS += $(DEBFLAGS)
ifneq ($(KERNELRELEASE),)
obj-m := memcdev.o
else
KERNELDIR ?= /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
all: module test
module:
$(MAKE) -C $(KERNELDIR) M=$(PWD) modules
test: test.c
$(CC) -o test test.c
endif
测试
创建设备
运行以下脚本来加载memcdev
内核模块并在/dev
目录下创建一个特殊设备文件,该设备文件的读写会使用我们自己的字符驱动。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
module="memcdev"
device="memcdev"
#加载内核模块
/sbin/rmmod ./$module.ko
/sbin/insmod ./$module.ko $* || exit 1
#找出设备的主设备号
major=$(awk "\$2==\"$module\" {print \$1}" /proc/devices)
#删除已有的设备
rm -rf /dev/${device}0
#创建特殊设备
mknod /dev/${device}0 c $major 0
测试代码
测试文件内容如下,先尝试正常读写,再尝试写一个超过字符设备容量的串。这里我们并不在测试程序中直接打印结果,而是通过dmesg
和strace
工具来分别追踪内核空间和用户空间的运行情况。
我们使用strace ./test
来运行程序,以获得调试输出。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int main(int argc, char *argv) {
char wword[] = "Hello World!";
char rword[BUFSIZE * 2];
size_t n;
//打开文件
int fd = open("/dev/memcdev0", O_RDWR);
//写13个字节
write(fd, wword, sizeof(wword));
//将偏移移动到文件头
lseek(fd, 0, 0);
read(fd, rword, sizeof(rword));
//过量写
write(fd, rword, sizeof(rword));
//关闭文件
close(fd);
return 0;
}
dmesg
dmesg
输出如下,其中前后两行是insmod
和rmmod
时打印的,中间一段是运行test程序时打印的,可以看到打开文件打开后首先写入了13个字节,然后将文件偏移移到开头,读取了13个字节刚刚写入的内容,下一次过量写时只写入了4096-13=4083
个字节,最后程序close
时调用了release
。1
2
3
4
5
6
7
8[32158.139396] MEMCDEV: hello memcdev!
[32171.598513] MEMCDEV: opened
[32171.598540] MEMCDEV: write size:13
[32171.598559] MEMCDEV: lseek 13->0
[32171.598577] MEMCDEV: read size:13
[32171.598610] MEMCDEV: write size:4083
[32171.598628] MEMCDEV: closed
[32193.212250] MEMCDEV: bye memcdev!
strace
steace ./test
输出内容很多,前面一堆都不用管,后面那几行是运行那几句代码时的用户空间调用及其返回值。可以看到调用返回与dmesg
打印的内容完全一致。
1 | open("/dev/memcdev0", O_RDWR) = 3 |
OVER
到这里一个最简单的字符设备驱动就完成了,但它是有很多问题的,比如并发问题等等。之后我将随着学习的过程逐渐完善这个程序。