0%

Linux 字符设备驱动

本文将介绍一个 linux 下字符设备驱动的简单实现,代码基于 Linux Device Driverscull 驱动,介绍该字符设备的注册、打开、写入数据、读出数据等操作,所有示例代码可以在我的 Github 中找到。

字符设备的定义

Linux对于设备是通过面向对象思想实现的,对于一个字符设备,通过数据结构 struct cdev 来描述。cdev 定义于 <linux/cdev.h>中,其中最关键的是file_operations 结构,它是实现字符设备的操作集。

1
2
3
4
5
6
7
8
struct cdev {
struct kobject kobj; // 内嵌内核对象
struct module *owner; // 该字符设备所在的内核模块
const struct file_operations *ops; // 文件操作结构体
struct list_head list; // 已注册字符设备链表
dev_t dev; // 由主、次设备号构成的设备号
unsigned int count; // 同一主设备号的次设备号的个数
};

我们实现的scull通过数据结构 struct scull_dev 表示,scull_dev类是继承 cdev类。因C语言没有定义面向对象的语法,在这里使用嵌套实现。这样就得到我们的设备模型struct scull_devdata 表示我们的硬件设备的起始地址,而 size 则表示这个硬件设备最大的存储空间。

1
2
3
4
5
struct scull_dev {
int size;
void *data;
struct cdev cdev;
};

下面列出了 cdev 的一些操作方法:

1
2
3
4
5
6
7
8
9
10
// 静态内存定义初始化 cdev
void cdev_init (struct cdev * cdev, const struct file_operations * fops);
// 动态内存定义初始化 cdev
struct cdev *cdev_alloc(void);

// 初始化 cdev 后,需要把它添加到系统中去。为此可以调用 cdev_add() 函数。传入 cdev 结构的指针,起始设备编号,以及设备编号范围
int cdev_add (struct cdev * p, dev_t dev, unsigned count);

// 当一个字符设备驱动不再需要的时候(比如模块卸载),就可以用 cdev_del() 函数来释放 cdev 占用的内存
void cdev_del ( struct cdev * p);

一个 cdev 一般它有两种定义初始化方式:静态的和动态的。

静态内存定义初始化:

1
2
3
struct cdev my_cdev;
cdev_init(&my_cdev, &fops);
my_cdev.owner = THIS_MODULE;

动态内存定义初始化:

1
2
3
struct cdev *my_cdev = cdev_alloc();
my_cdev->ops = &fops;
my_cdev->owner = THIS_MODULE;

两种使用方式的功能是一样的,只是使用的内存区不一样,一般视实际的需求而定。

字符设备的操作

scull_dev 的属性已经定义好了,接下来要定义其方法,即如何操作该设备。对于字符设备Linux,Linux 使用 file_operations 结构访问驱动程序的函数,这个结构的每一个成员的名字都对应着一个系统调用。

linux/fs.h
1
2
3
4
5
6
7
8
9
struct file_operations {
struct module *owner;
loff_t (*llseek) (struct file *, loff_t, int);
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
int (*open) (struct inode *, struct file *);
int (*release) (struct inode *, struct file *);
...
};

对于我们要实现的scull,需要提供open、write、read、lseek等操作。

1
2
3
4
5
6
7
8
static struct file_operations fops = {
.owner = THIS_MODULE,
.open = scull_open,
.write = scull_write,
.read = scull_read,
.release = scull_release,
.llseek = scull_llseek,
};

open

open函数有两个参数,一个指向 inode 的指针,一个指向 file 的指针:

1
int (*open) (struct inode *, struct file *);
  • inode表示期望打开的设备节点,在其成员中有个一个指向字符设备 cdev的指针 i_cdev,我们可以通过该指针得到指向scull设备的指针(借助 container_of 宏实现)
  • file 用于表示将要打开的设备描述,包含期望打开文件的标志 f_flags(读写标志)。我们需要使用指向设备的指针,初始化file成员 private_data,该成员指向的是将要打开的设备。
1
2
3
4
5
6
7
8
9
10
11
12
13
static int scull_open(struct inode *inode, struct file *fp)
{
struct scull_dev *sdev = container_of(inode->i_cdev, struct scull_dev, cdev);

if ((fp->f_flags & O_ACCMODE) == O_WRONLY) {
clean_up_scull_data(sdev);
}

fp->private_data = sdev;

printk(KERN_ALERT "scull open!\n");
return 0;
}

release

release函数参数与open相同:

1
int (*release) (struct inode *, struct file *);

由于我们要实现的 scull 设备一直存在于内存中,无需任何释放动作,因此只需要返回0即可。

1
2
3
4
5
static int scull_release(struct inode *inode, struct file *fp)
{
printk(KERN_ALERT "scull release!\n");
return 0;
}

write

wirte 函数用于向设备写入数据,其参数含义如下:

  • 第一个参数表示打开的文件,即 open 中的 file
  • 第二个参数是指向用户空间,期望写入数据的初始地址
  • 第三个指针是期望写入的数据长度
  • 第四个指针是当前写的位置,write结束时,需要更新该指针指向的数据
  • write函数的返回值为实际写入数据的长度
1
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);

由于驱动程序处在内核空间,内核空间是不能直接读取用户地址空间的数据,用户空间也不能读取内核地址空间的数据,我们需要借用函数 copy_from_user() ,从用户空间将数据拷贝的内核空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static ssize_t scull_write(struct file *fp, const char __user *buffer, size_t st, loff_t * pos)
{
struct scull_dev *sdev = fp->private_data;
int result = -ENOMEM;

if (!sdev->data) {
sdev->data = kmalloc(SCULL_SIZE, GFP_KERNEL);
if (!sdev->data) {
goto nomem;
}
}

result = (sdev->size - *pos) > st ? st : (sdev->size - *pos);
printk(KERN_ALERT "scull_write : result = %d st = %d pos = %d\n", result, st, *pos);
copy_from_user((sdev->data + *pos), buffer, result);

*pos += result;
nomem:
printk(KERN_ALERT "scull_write char: %d!\n", result);
return result;
}

read

read 函数,用于读取设备中的数据,参数与write相类似,同样需要借助 copy_to_user() 函数,将数据从内核空间拷贝的用户空间。

1
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);

下面是 scull 字符设备实现的 read 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static ssize_t scull_read(struct file *fp, char __user *buffer, size_t st, loff_t *pos)
{
struct scull_dev *sdev = fp->private_data;
int result;

if (!sdev->data) {
result = 0;
goto nodata;
}

result = (sdev->size - *pos) > st ? st : (sdev->size - *pos);
printk(KERN_ALERT "scull_read : result = %d st = %d pos = %d\n", result, st, *pos);
copy_to_user(buffer, (sdev->data + *pos), result);

*pos += result;

nodata:
printk(KERN_ALERT "scull_read char: %d!\n", result);
return result;
}

llseek

llseek函数,用于设置当前读写的位置

1
loff_t (*llseek) (struct file *, loff_t, int);

其函数参数含义如下:

  • 第一个参数为file
  • 第二个参数为偏移量,
  • 第三个参数是描述第二个参数偏移量的参考位置,只有三种有效值

    • SEEK_SET(0,相对于文件起始位置的偏移量)
    • SEEK_CUR(1,相对于当前所处位置的偏移量)
    • SEEK_END(2,相对于文件末的偏移量)
    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
    static loff_t scull_llseek(struct file *fp, loff_t off, int whence)
    {
    loff_t result;
    struct scull_dev *sdev = fp->private_data;

    switch(whence) {
    case 0:
    result = off;
    break;
    case 1:
    result = fp->f_pos + off;
    break;
    case 2:
    result = sdev->size + off;
    break;
    default:
    return -EINVAL;
    }

    if (result > SCULL_SIZE) {
    return -EINVAL;
    }

    fp->f_pos = result;

    printk(KERN_ALERT "scull llseek!\n");
    return result;
    }

字符设备注册销毁

scull_dev设备的类已经构造完成,接下来就需要使用该类构建设备驱动模块。首先我们需要定义设备驱动模块的初始化函数scull_init()。通过宏控module_init告诉内核指定模块的初始化函数。初始化函数需要完成如下操作:

  1. 设备号的申请与释放

一个字符设备或块设备都有一个主设备号和一个次设备号:

  • 主设备号用来标识与设备文件相连的驱动程序,用来反映设备类型
  • 次设备号被驱动程序用来辨别操作的是哪个设备,用来区分同类型的设备

内核通过设备号 dev_t来区分不同的设备,下面列出了操作设备号的一些宏:

1
2
3
4
5
6
#define MINORBITS       20
#define MINORMASK ((1U << MINORBITS) - 1)

#define MAJOR(dev) ((unsigned int) ((dev) >> MINORBITS)) // 从设备号中提取主设备号
#define MINOR(dev) ((unsigned int) ((dev) & MINORMASK)) // 从设备号中提取次设备号
#define MKDEV(ma,mi) (((ma) << MINORBITS) | (mi)) // 从主次设备号拼凑为设备号

linux 提供了一系列的函数来申请和释放主设备号和次设备号:

1
2
3
4
5
6
7
8
// 静态申请设备号, 当已经知道一个可用的设备号时, 可以直接通过 register_chrdev_region, 为设备注册该设备号
int register_chrdev_region (dev_t from, unsigned count, const char * name);

// 动态申请设备号, 一般情况下使用 alloc_chrdev_region 为设备动态的分配一个或多个连续的设备号
int alloc_chrdev_region (dev_t * dev, unsigned baseminor, unsigned count, const char * name);

// 释放设备号
void unregister_chrdev_region (dev_t from, unsigned count);

对应代码如下:

1
2
3
4
5
6
// 1. alloc chrdev region
result = alloc_chrdev_region(&dev_num, minor, count, scull_name);
if (result < 0) {
printk(KERN_ALERT "register dev error!\n");
goto ERR_FINAL;
}

字符设备的注册

根据我们自定义的 scull_dev,对其初始化:动态分配一个scull_dev 对象,初始化其成员 data 与 size

1
2
3
4
5
6
7
8
9
10
// 2. create scull_dev instance
scull_device = kmalloc(sizeof(struct scull_dev), GFP_KERNEL);
if (!scull_device) {
printk(KERN_ALERT "no memory!\n");
result = -ENOMEM;
goto ERR_KMALLOC;
}

memset(scull_device, 0, sizeof(struct scull_dev));
scull_device->size = SCULL_SIZE;

对于cdev成员,需要初始化其成员 ops,该成员是指向 struct file_operations,即包含了操作该设备方法的指针,这里采用的是动态初始化 cdev 方法:

1
2
3
4
// 3. init cdev object
cdev_init(&scull_device->cdev, &fops);
scull_device->cdev.owner = THIS_MODULE;
scull_device->cdev.ops = &fops;

接下来需要通过 cdev_add() 函数将 scull_dev 和它对应的设备号添加到设备列表中

1
2
3
4
5
// 4. register cdev object
if (cdev_add(&scull_device->cdev, dev_num, count)) {
printk(KERN_ALERT "ERROR: cdev_add %d\n", count);
goto ERR_REGISTER_CDEV;
}

自动创建设备节点

基于 udev 机制可以实现设备节点自动创建,在驱动用加入对 udev 的支持需要的工作是:

  • 在驱动初始化的代码里调用 class_create 为该设备创建一个 struct class 结构体
  • 为每个设备调用 device_create 创建对应的设备内核中定义的 struct device 结构体,并在 sysfs 中注册

加载模块的时候,用户空间中的 udev 会自动响应 device_create 函数,去 sysfs下寻找对应的类从而创建设备节点

1
2
3
4
5
6
7
struct class * class_create(struct module * owner, const char * name);
void class_destroy (struct class * cls);

// creates a device and registers it with sysfs
struct device * device_create(struct class * class, struct device * parent, dev_t devt, void * drvdata, const char * fmt, ...);
// removes a device that was created with device_create
void device_destroy ( struct class * class, dev_t devt);

对应的示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 5. create class
cls = class_create(THIS_MODULE, scull_name);
if (IS_ERR(cls)) {
result = PTR_ERR(cls);
goto ERR_CREATE_CLASS;
}

// 6. create devices
for (i = minor; i < (count + minor); i++) {
devp = device_create(cls, NULL, MKDEV(major, i), NULL, "%s%d", scull_name, i);
if (IS_ERR(devp)) {
result = PTR_ERR(devp);
goto ERR_CREATE_DEVICE;
}
}

字符设备的销毁

scull_exit() 通过宏控 module_exit 告诉内核模块清除函数,清除函数需要将设备从设备列表中移除,释放动态分配的内存以及注销使用的设备号。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static void scull_exit(void)
{
int i;

for (i = minor; i < (minor + count); i++) {
device_destroy(cls, MKDEV(major, i));
}
class_destroy(cls);

cdev_del(&scull_device->cdev);
clean_up_scull_data(scull_device);
kfree(scull_device);
scull_device = NULL;
unregister_chrdev_region(dev_num, count);

printk(KERN_INFO "%s exit!\n", scull_name);
}

这里 clean_up_scull_data 释放 scull 字符设备申请的内存资源:

1
2
3
4
5
static inline void clean_up_scull_data(struct scull_dev *sdev)
{
kfree(sdev->data);
sdev->data = NULL;
}

测试验证

使用以下Makefile 文件编译好驱动程序 scull.ko

1
2
3
4
5
6
7
8
ifneq ($(KERNELRELEASE),)
obj-m := scull.o
else
KERNELDIR ?= /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
default:
$(MAKE) -C $(KERNELDIR) M=$(PWD) modules
endif

可以看到,在 /dev 目录下创建了 6 个字符设备,他们 major 相同,minor 不同:

1
2
$ ls /dev/scull*
/dev/scull0 /dev/scull1 /dev/scull2 /dev/scull3 /dev/scull4 /dev/scull5

编写一个测试的用户程序来读写字符设备:

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
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>

int main(void)
{
int fd;
int res;
char buf[] = "Houmin says hello to scull!";
int buflen = sizeof(buf);
char buf2[100];
fd = open("/dev/scull0", O_RDWR);
if (fd < 0) {
printf("Open scull device failed\n");
}

res = write(fd, buf, buflen);
printf("Write to scull device, content: %s\n", buf);

lseek(fd, 0, SEEK_SET);
res = read(fd, buf2, buflen);
buf2[buflen] = '\0';
printf("Read from scull device: %s\n", buf2);

close(fd);
return 0;
}

编译运行,显示如下:

1
2
3
4
$ gcc userapp.c -o userapp
$ ./userapp
Write to scull device, content: Houmin says hello to scull!
Read from scull device: Houmin says hello to scull!

查看系统日志信息:

1
2
3
4
5
6
7
8
9
[ 1301.936246] scull init, major = 242
[ 1306.650233] scull open!
[ 1306.650238] scull_write : result = 28 st = 28 pos = 0
[ 1306.650239] scull_write char: 28!
[ 1306.650314] scull llseek!
[ 1306.650316] scull_read : result = 28 st = 28 pos = 0
[ 1306.650317] scull_read char: 28!
[ 1306.650332] scull release!
[ 1315.617320] scull exit!

参考资料