由于Linux中一切皆文件
的思想,字符设备(键盘/鼠标/串口)、块设备(存储器)、网络设备(套接字)作为三种主要设备类型,我们在用户层基本可以通过open close read write
来完成对设备的绝大部分操作。其中字符设备是涵盖面最广的一种,而且其概念相对容易理解,因此以字符设备为切入口,掌握内核与用户层之间的调用机制是再合适不过了。
本章主要学习:
- 字符设备驱动的基本概念
- 如何向内核注册/注销一个字符设备
- 如何通过设备节点、文件API调用驱动的函数
什么是字符设备?
所谓字符设备,泛指那些输入输出以串行方式、按顺序、一个字符一个字符的收发数据的设备。比如键盘,按键数据是一个一个发给系统的,哪怕是组合键;鼠标的坐标;LED灯;串口终端等。而这些数据如果不能及时被系统处理,要么被缓存,要么直接丢弃,但不论如何,当系统去处理这些数据的时候,也是按先来后到的顺序。
你可以设想以下,你的电脑突然卡顿了5秒钟,而这期间你疯狂打了10个字,毫无疑问这10个字的数据被缓存起来了,等系统空闲的时候开始处理它们,现在你是希望这10个字是按照你打的顺序出现在屏幕呢,还是希望它们毫无章法地出现在屏幕。
所以,字符设备,就是数据的IO总是以连续的形式处理的设备类型。
顺带提一句块设备,与字符设备概念做个对比。比如硬盘:你和你的右手出去约会并拍了很多照片回来,然后你把照片导入电脑,而硬盘的存储空间并不是像水杯一样从下往上按顺序填满,而是根据某种算法“随机”存,所以在真实的硬盘中,存放你1号照片的存储区域旁边,并不是2号照片,可能是某份日语学习资料;同理,3号照片可能与去年下载的种子呆在一起。
第一个字符设备 meme
在操作系统中,设备不一定要真实存在,虚拟设备也是设备,可以通过文件函数进行操作。因此为了掌握字符设备的知识,最简单的方法就是自己动手写一个虚拟字符设备。
meme的中文意思是米姆、莫因,表示文化DNA的意思,而作为我学习驱动的实例,它就是“记事本”,用户可以用多种方式读写它,而我给这个虚拟设备取这么个名字没别的意思——just for fun!
先来看看我打算要meme实现哪些内容:
- 向内核注册/注销自己
- 支持open/close/read/write等文件操作函数
字符设备的注册与注销
要像内核注册一个设备,必须提供它的设备号作为唯一标示,字符设备的设备号由主设备号和次设备号共同组成
- 主设备号(major):表示设备的分类编号,比如1代表u盘、2代表显示器、3代表打印机
- 次设备号(minor):表示同一分类的实例编号,比如1号u盘、2号u盘、3号u盘
Linux内核可以静态注册和动态分配的方式注册一个设备号:
1 | // 动态申请,由内核自动扫描空闲设备号给它 |
⚠️注意⚠️:在实际开发过程中,强烈建议仅通过动态分配的方式注册设备,因为说不准你自己规划好的设备编号会在未来被Linux内核给占用了。
字符设备的注册大体分两步:
- 初始化一个
cdev
字符设备的数据结构,向内核说明自己是字符设备 - 分配一个设备号与cdev绑定起来
下面就是一个最纯粹的字符设备注册和注销的实现:
1 |
|
可以看到,字符设备的注册/注销一般是在模块的加载和卸载函数中实现的,整个过程非常简单:
申请设备号 –> 初始化字符设备 –> 内核添加字符设备 –> 内核删除字符设备 –> 释放设备号
关于cdev_init / cdev_add / cdev_del
等函数没有展开说明是因为后续有更好的方法,不要拘泥于此。不过注意cdev_init(&cdev, &fops)
这行,fops是接下来要学习的一个非常重要的数据结构——文件操作。
字符设备的文件操作
因为Linux的一切皆文件,理论上在用户层任何设备都可以通过文件相关的API函数调用设备,而作为设备驱动本身,很重要的一部分工作就是把用户层的各种文件操作关联到各种驱动函数当中,而这离不开一个结构体struct file_operations
,先来看看内核对其定义:
1 | struct file_operations { |
乍一看,很复杂!其实用面向对象的思维可以简单理解为——file_operations就是一个需要继承实现的interface。因此不要去纠结里面的内容有多少,大部分时候我们都是根据需要去实现其中的部分函数即可,等具体实现的时候再来参考对应函数在其中的声明形式。(通过该结构体打通了内核与应用间的任督二脉)
为用户层实现open/close和read/write
- open和close一般都是处理一些设备复位的工作(或者干脆不做任何处理),但如果有逻辑要放在open/close里执行,务必留意应用程序可能会重复多次打开/关闭同一个文件的情况。
- read/write在内核中主要围绕
copy_to_user
和copy_from_user
两个函数来处理,它们负责把“交换”用户空间与内核模块的数据,但要特别小心文件偏移,不是所有设备在读写的时候都需要偏移的!比如读取LED灯的状态,每次都应该从头开始读。
1 | // 对应用户层read读取函数,通常用户接收设备内容,如串口收数据 |
代码很简单,该说的注释都已经说了,除了file_operations里的这句:.owner = THIS_MODULE
。首先要直到THIS_MODULE是一个结构体定义,之所以把它指向owner(模块自身)是为了防止模块正在进行其他操作(如读写)时,被rmmod卸载。每个以模块形式存在的驱动,最好都把这句加上!
设备节点与操作
设备节点是是用户层与内核模块沟通的桥梁!
作为初学者的我曾一度认为/dev目录下的节点就是设备本身,显然这种看法很幼稚,内核真正把设备和驱动暴露在/proc和/sys目录(再说一遍,其实是文件系统!!)而设备节点是在用户层创建的一个特殊文件,它的创建和删除几乎不影响内核对设备本身的管理。
设备节点的创建命令为:mknod <node_name> <type> <major> <minor>
把meme编译好之后实际测试一下:
1 | # 将驱动模块拷贝到镜像 |
从上面的操作中可以看到,meme驱动的基本文件操作功能已经具备,之所以最后读出来的内容有乱码,是因为这是一个设备,不是普通文件,该设备强制有0x100
的存储空间,每次都会被全部读出来。
小结一下
- 字符设备是一种通过顺序读写来操作的设备
- 主设备号表示设备类型,次设备号表示设备实例号
- file_operations定义了文件操作函数的接口
- 设备节点是在用户层创建的特殊文件