作为一个刚入坑 Linux 驱动的萌新,别人都是面向结果编程,面向对象编程,而我却是面向 Kernel Panic 编程,真的是一不注意就会创造一点小错误出来。
刚接触字符设备这个概念时,也可能是看的书比较老,书上给的创建字符设备的流程是:先申请设备号,再注册,最后还要切到命令行手敲
mknod才能搞出来一个设备文件。当时我就纳闷了:怎么能这么不智能呢?平时插 U 盘,系统可是一秒就反应过来了,怎么轮到自己写驱动还这么原始?直到今天,我才学到了如何让内核在
/dev目录下自动帮我们创建设备节点。今天就借着我刚写的一坨玩具代码,和大家复盘一下这个过程。如果你也在学习 Linux 驱动,这篇文章应该对你有点帮助。
1. 简单了解字符设备
这篇文章是我《Linux驱动开发》专栏的第三篇,也是字符设备驱动相关的第一篇,我觉得还是有必要先了解一下字符设备是个什么东西。
这一章我只是简单介绍一下字符设备,在完成本篇文章主题的叙述之后,后面的章节还会比较详细地讲解字符设备的知识点。
在 Linux 中,硬件可以被分为三种类型:字符设备,块设备,网络设备。其中字符设备就是最常见、最基础、入门时最先接触到的那一类。
字符设备有下面几个核心特点:
- 按字符流顺序读写,也就是说你不能跳着读。
- 绝大多数外设都是字符设备,我简单举几个例子:Led 点灯、按键检测、串口收发、PWM 调亮度、I2C读写温度传感器等等都是字符设备。
- 我们都知道,Linux 哲学有一个核心点就是:一切皆文件。字符设备在文件系统中也表现为一个文件,路径一般长这样:
/dev/my_led、/dev/my_cdev等等。我们可以直接写个用户程序使用open打开这个文件,然后就可以操作这个设备。 - 每个字符设备都有一个设备号,分为主设备号和次设备号。主设备号表示这一类设备在用哪个驱动,次设备号表示当前设备是这个驱动控制的第几个设备。
我们在学习文件操作时知道了可以使用 lseek 改变文件偏移量,但是实际上很多字符设备压根就不支持随机访问,跳到中间去读往往就好发生错误。
下面就是我们今天这篇文章的主题了。
早期我们会使用 mknod 命令手动创建 /dev 目录下的设备节点,但现在没人这么干了,现在最常见的方式是在驱动里用 class_create() + device_create(),udev 就会自动帮你在 /dev 下创建设备节点。
接下来我们看看具体代码长什么样。
2. 玩具代码展示
先上代码,这段代码的核心使命只有一个:向系统申请设备号,并自动生成设备文件。
#include <linux/module.h>
#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/device.h>
#include <linux/fs.h>
struct my_cdev{
dev_t dev_num;//设备号
struct class* class;//设备类
struct device* device;//设备
};
static struct my_cdev my_cdev;
static int __init my_cdev_init(void)
{
int ret;
//动态申请设备号
ret = alloc_chrdev_region(&(my_cdev.dev_num),0,1,"my_cdev");
if(ret < 0)
{
printk(KERN_INFO "alloc failed!\n");
return ret;
}
//创建设备类,/sys/class
my_cdev.class = class_create(THIS_MODULE,"my_cdev_class");
if(IS_ERR(my_cdev.class))
{
ret = PTR_ERR(my_cdev.class);
printk(KERN_INFO "create class failed!\n");
goto my_unregister;
}
//创建设备节点,/dev
my_cdev.device = device_create(my_cdev.class, NULL, my_cdev.dev_num, NULL, "my_cdev_device");
if(IS_ERR(my_cdev.device))
{
ret = PTR_ERR(my_cdev.device);
printk(KERN_INFO "create device failed!\n");
goto my_destroy_class;
}
printk(KERN_INFO "success\n");
return 0;
//错误处理
my_destroy_class:
class_destroy(my_cdev.class);
my_unregister:
unregister_chrdev_region(my_cdev.dev_num,1);
return ret;
}
static void __exit my_cdev_exit(void)
{
//释放顺序要与申请顺序相反
device_destroy(my_cdev.class,my_cdev.dev_num);
class_destroy(my_cdev.class);
unregister_chrdev_region(my_cdev.dev_num,1);
printk(KERN_INFO "resource freed!\n");
}
module_init(my_cdev_init);
module_exit(my_cdev_exit);
MODULE_LICENSE("GPL");
写完这段代码,我最大的感触就是内核驱动代码与我们在用户态写的 C 代码风格是完全不同的。
本章我会先在宏观层面上分析代码的整体逻辑,后面再去讲代码中涉及到的具体函数。
2.1 不需要手动mknod的原因
在初始化函数中,我使用了 class_create 和 device_create ,他们可以算做是一对搭档吧,class_create 会在虚拟文件系统 /sys/class/ 下创建一个名为 my_cdev_class 的目录,而 device_create 则会把我们的设备号填进去。
关键点来了:
当系统里的守护进程( udev 或 mdev)监控到 /sys 目录的变化后,就会跑到 /dev/ 目录下,帮我们自动创建一个名字叫 my_cdev_device 的文件。我想这也许就是插拔 U 盘能自动识别的底层原理吧。
2.2 指针的判错逻辑
如果你仔细看代码,会发现我判断指针是否为空,用的不是 if(my_cdev.class == NULL),而是 IS_ERR()。
这也是我学习是碰到的一个令我感到比较诧异的点,刚开始我也只是模仿这种写法,后来我想它这样写肯定是有着深意的,于是我去查资料了解了一下。
在内核空间中,内存的最顶层那几页是保留的,也就是地址从 0xfffff000 到 0xffffffff 的空间,绝不会用来分配真正的对象。
内核巧妙地利用了这 4K 的空间:如果一个返回指针落在个范围内,说明它不是个指针,而是一个负数的错误码。我们用 IS_ERR 判断出错后,再用 PTR_ERR 把它强制转换回 int 型的错误码作为函数的返回值。
这种设计简直绝了。
2.3 goto的使用
学习过 C 语言的或多或少应该都了解过 goto 所处的尴尬环境,以至于我在之前的学习中虽然知道它,但从来没有使用过。但现在我用了,因为在 Linux 内核里,goto 是处理错误的标配。
大家可以看看我的 my_cdev_init 函数末尾,申请资源是 A -> B -> C,如果 C 失败了,我就 goto 到 B去释放,再往下走到 A 去释放。代码不仅不乱,反而看起来有点整洁和优雅的感觉。
3. 编译与运行
3.1 编译
Makefile 代码如下:
#这个目录需要视个人情况而定
KERNEL_DIR := /home/xlp/workspace/kernel
#上面的C文件名叫cdev.c
obj-m := cdev.o
#交叉编译
all:
make -C $(KERNEL_DIR) M=$(PWD) ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- modules
clean:
make -C $(KERNEL_DIR) M=$(PWD) clean
终端执行make就编译好了,编译好的截图如下:
编译之后,目录下多了许多文件,其中 cdev.ko 才是我们真正需要的,其他的都是编译的中间产物。
3.2 运行
我们远程连接板子的终端,将需要的 cdev.ko 拷贝到板子上。
如下图,这是板子的终端,cdev.ko 已经被我传输过来了,然后我们加载这个模块并查看内核日志最后 10 行:
sudo insmod cdev.ko
sudo dmesg | tail
倒数第二行不用管,这是正常提示。
请看最后一行,从我们上面代码的逻辑来看,当内核日志出现 “success” 时就说明已经完成了申请设备号、创建类,创建设备节点的过程,并且,不出意外的话 udev 已经自动帮我们创建好设备节点了,我们检查一下。
执行下面命令,查看设备节点是否存在:
ls -lh /dev/my_cdev_device
设备节点确实已经被成功创建了,并且节点名就是我们 C 代码中创建设备节点时指定的,此外还可以发现在这个文件的权限位前面的字母是 c ,这就代表它是个字符设备文件,还可以看到该设备的主设备号为 236,次设备号为 0。
然后我们依次执行下面命令:
sudo rmmod cdev.ko
sudo dmesg | tail
ls -lh /dev/my_cdev_device
结果如下图:
可以看到,在模块卸载之后,内核日志中出现了 “resource freed!” 的信息,这说明我们的 my_cdev_exit 函数成功执行,在模块卸载之前向系统归还了资源。
最后,我们再查看设备节点,果不其然,确实已经消失了。
4. 实验总结
前面已经讲了很多内容了,但是我们这次实验本身并不复杂,我梳理了一下并画了一张图,如下:
正如图中描述的那样,我们在内核空间进行申请设备号、创建类、创建设备节点等诸多操作,最终结果是设备节点出现在了用户空间中。也就是说我们成功向用户空间提供了通过文件操作访问一个字符设备的机会。
那么问题来了,现在我能用 echo "hello" > /dev/my_cdev_device 给它发数据吗?
我们不妨试试,先加载设备,然后验证 /dev/my_cdev_device 存在之后,向他写入数据:
可以看到,结果并不令人奇怪,写入失败了。为什么呢?
我耗费了十牛三虎之力才想出了这样一个妙喻:我们现在只是创建了一个字符设备的 皮囊,我们调用 class_create 和 device_create 时,内核确实在 /dev 目录下创建了一个名为 my_cdev_device 的文件,分配了主次设备号,并且将这个文件通过文件系统暴露给用户层。但这仅仅只是皮囊而已,用户层看得见摸不着,因此他需要灵魂。
但是这涉及到我们后面要学的内容了,先简单透露一点,剩下的我们下一次讲。
后续我们还需要定义一个 struct cdev 结构体,并用 cdev_init 把它关联到一系列操作函数上,最后用 cdev_add 通知内核。
有了这些操作,当我们对设备文件执行读或者写操作时,驱动程序才知道自己要做什么。而且正是因为没写 cdev_add,内核查表发现没有这个字符设备,所以才会弹出 No such device or address 这样的信息。
5. 函数用法及功能总结
5.1 申请设备号
该函数原型如下:
int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name)
- 第一个参数我们需要传入设备号变量的地址,这个函数会把申请到的设备号存到这个地址。
- 第二个参数是我们需要申请的次设备号范围的最小值。
- 第三个参数是我们要申请的次设备号的数量,函数会从我们第二个参数指定的次设备号最小值开始分配。
- 第四个参数是字符串,表示设备或驱动的名字。
代码中第一个参数就把 dev_num 的地址传进去,这很简单。第二个参数传 0,表示次设备号从 0 开始找,没被占用就分配,分配的数量就是第三个参数指定的,这里我们第三个参数为 1,表示我们只需要一个次设备号,因为我们也只有一个同类型的设备实例。
第四个参数是字符串,我们这里指定的字符串会出现在下面地方。
执行下面命令:
cat /proc/devices
结果如下图,第一列的数字是主设备号,还记得上面我们看到的主设备号是 236 吗?我们看 236 那一行,后面正是 my_cdev,我们第四个参数传入的字符串。
5.2 创建类
这是一个宏,看起来有点吓人,但是我们只需要会用就可以了。
#define class_create(owner, name) \
({ \
static struct lock_class_key __key; \
__class_create(owner, name, &__key); \
})
- 第一个参数通常固定填
THIS_MODULE。 - 第二个参数填要创建的类的名称,是一个字符串。
我们代码中第二个参数是my_cdev_class,我们看看这个字符串在哪会出现:
如图,在/sys/class目录下出现了一个以该字符串为名称的软链接。
5.3 创建设备节点
函数原型如下:
struct device *device_create(struct class *class, struct device *parent, dev_t devt, void *drvdata, const char *fmt, ...)
- 上面我们讲过,
class_create和device_create通常是配套使用的,这不,第一个参数就要传类的指针。 - 第二个参数是父设备指针,像我们写的入门驱动程序直接传
NULL就可以。 - 第三个参数是设备号,无需多说。
- 第四个参数是私有数据指针,一般也传
NULL。 - 最后就是设备节点名称加上可变参数,类似于
printf的形式。
我们已经见过设备节点在哪了,就不再多说了。
写在最后:
通过这次实验,我们算是打通了从 内核态 到 用户态文件 的路,虽然它现在还只是个外壳,但我们已经熟悉了:动态申请设备号,自动创建设备节点已经资源回收等内容。下一篇,我们将实现真正的 open、read 和 write 函数,让数据真正能在内核与用户空间传输。
如果你也正在学习 Linux 驱动,觉得这篇文章帮你理清了思路,可以的话给个收藏加关注,你们的支持是我继续坚持的动力。
一起加油💪