Linux字符设备驱动框架
- 字符设备驱动:以字节流方式访问的设备------鼠标/键盘,在对应
/dev目录下有设备文件 - 网络设备驱动:用于网络通讯的设备------网卡,使用
ifconfig进行配置 - 块设备驱动:以块为单位读写的设备------eMMC、SD卡
最基本框架main
cpp
#include <linux/init.h>
#include <linux/module.h>
MODULE_LICENSE("GPL v2");
// 驱动入口函数
static int hello_init(void)
{
return 0;
}
// 驱动出口函数
static void hello_exit(void)
{
return;
}
module_init(hello_init); // 告诉Linux,这个驱动入口函数是那个装载入口函数
module_exit(hello_exit); // 告诉Linux,这个驱动出口函数是那个装载出口函数
linux驱动本质就是向应用层的程序提供设备的操作函数接口
如何让应用层的程序找到底层驱动提供的接口
字符设备会生成设备文件,对于应用层来说,只需要操作设备文件从而操作硬件设备,操作文件需要调open、read、write、close这些函数,应用层操作设备文件调用了这些函数,就需要调用我们写的硬件设备的接口,那怎么找到我们的写的接口,就通过linux提供的框架,它规定好了函数接口,定义函数指针,返回值和参数就写死了,我们就可以把函数指针指向接口,实现回调函数。通过**结构体file_operations规定函数接口,**我们就需要把我们的接口填充到这个结构体中。硬件设备有很多,抽象出一个字符设备通用结构体cdev,保存的是字符设备的通用信息,file_operations也会记录在cdev结构体中。

应用程序访问底层驱动过程
当驱动被加载时,它会向内核注册自己的主设备号和一组操作函数(file_operations)。当用户程序调用 open 打开 /dev 下的对应设备文件时,内核通过文件中的主设备号找到并绑定该驱动,之后用户程序对该文件描述符的读写操作,就会自动被内核转发到驱动注册的对应函数,从而实现对硬件的控制。
mknod创建设备节点然后文件系统会创建对应的struct inode,当打开设备文件时,chrdev_open根据inode中的i_rdev查找字符设备映射表cdev_map,找不到就报错,找到cdev后就用i_cdev记录找到的struct cdev,通过cdev可以找到file_opeartions,再紧接着创建file结构体,同时让file指针的file_ops指向cdev中的file_ops地址,file结构体记录file_operations为了提高效率。
inode记录了cdev,下次打开就不需要去查找了

|-----------------------|------------------------------------|
| cdev_init() | 初始化cdev结构体,绑定file_operations |
| alloc_chrdev_region() | 自动注册设备号 |
| cdev_add() | 将cdev和设备号绑定在cdev_map上 |
| class_create() | 创建设备类/sys/class |
| device_create() | 通过struct class发送uevent事件通知mdev创建设备 |
如果我打开/dev/dev_fifo0进行操作的时候,我怎么知道dev_fifo0所在的内存空间的首地址呢?
我们通过open打开/dev/dev_fifo0这个设备文件时,linux内核最终找到了/dev/dev_fifo0对应在内核空间的dev_fifo0这个设备,我们通过container_of获取了内核空间为dev_fifo设备分配的内存首地址,在xxx_open函数中我们把内核为这个设备分配的空间首地址保存在了file->private_data这个变量中,下一次当我们通过read或write调用到驱动层的xxx_read或xxx_write时,VFS层都会把struct file *file传递下来
Linux Platform子系统框架
总线、设备、驱动 => 增加驱动的可扩展性和可移植性
将设备的信息从驱动中分离出来,我们需要在操作系统中,添加设备和驱动两部分。
设备中包含是设备的信息(资源),驱动中包含的是操作设备函数接口。
为了能让驱动最终能操作我们的硬件设备,我们在驱动中必须获取设备的信息(资源)。
总线在操作系统中本质就是两个链表:挂载设备的链表和挂载驱动的链表
设备和驱动都会注册到总线上,当注册设备的时候,会去寻找同名的驱动,当注册驱动的时候,也会去找同名的设备,相互查找。一旦匹配成功,操作系统就会自动调用驱动提供的probe函数。我们只需要在probe函数中,使用操作系统提供的通用API获取硬件的信息即可。
基于总线写驱动的流程:
(1)根据自己的设备,来确定总线的类型
platform bus / i2c bus /usb bus/....
(2)根据总线的类型, 确定设备在总线上如何描述(结构体)
struct platform_device{
设备名;
设备的资源
通用的设备描述(struct device : platform_data->记录设备私有的信息)
id_entry:当设备和驱动的id_table中某一个成员匹配上的时候,这个成员就会记录id_table中匹配上的成员地址
};
struct resource{
int start;资源的开始
int end;资源的结束
资源的类型:IO资源(寄存器地址) 中断资源(中断号) DMA资源(通道)
};
(3)根据总线的类型, 确定驱动在总线上如何描述(结构体)
struct platform_driver{
probe函数 :设备和驱动匹配的时候,操作系统自动调用
remove函数:设备和驱动分离的时候,操作系统自动调用
通用的驱动描述(struct device_driver : 这里面可以记录驱动的名字)
id_table : 当前驱动支持平台设备(记录支持的平台设备名字)
};
(4)根据总线的类型,确定在总线上如何注册设备
int platform_device_register(struct platform_device *pdev);
(5)根据总线的类型,确定在总线上如何注册驱动
int platform_driver_register(struct platform_driver *pdriver);
(6)根据总线的类型, 确定设备和驱动匹配原则
如果驱动提供了id_table,那就拿设备的名字和id_table中记录的名字匹配
如果驱动没有提供id_table,那就拿设备的名字和驱动的名字进行匹配
(7)一旦设备和驱动匹配后,操作系统就会调用驱动提供的probe函数。
在这个函数中,一般需要做两件事情:
<1>获取匹配的硬件资源
struct resource *platform_get_resource(struct platform_device *dev,unsigned int type,int num);
@dev 平台设备的结构体 @type 资源的类型 @num 同类型资源的编号
<2>注册字符设备(可选)
基于总线写驱动思路:
1.模块化编程
<1>头文件
<2>许可权限
<3>模块的入口和出口
2.在总线上注册驱动
<1>填充结构体
struct platform_driver xxx_driver = {
.probe = xxx_probe,
.remove = xxx_remove,
.id_table = xxx_table,
.driver = {
.owner = THIS_MODULE,
.name = "xxxx",
},
};
<2>注册结构体
在模块的入口函数注册结构体
platform_driver_register
在模块的出口函数注销结构体
platform_driver_unregister
3.总线上的设备和驱动相互匹配后,操作系统自动调用驱动提供的probe函数
实现probe函数
int xxx_probe(struct platform_device *pdev)
{
1.获取资源
platform_get_resource
2.注册字符设备
1初始化cdev结构体 (让cdev结构体记录设备的操作函数接口)
cdev_init
2申请设备号
第一种方式 (静态):
register_chrdev_region
第二种方式 (动态):
alloc_chrdev_region
3添加字符设备
cdev_add
----------------自动创建设备节点------------------------------------------
4创建类
class_create
5创建设备
device_create
}
4.总线上的设备和驱动分离后,操作系统自动调用驱动提供的remove函数
实现remove函数
int xxx_remove(struct platform_device *pdev)
{
核心思想:将probe函数申请的资源,释放掉
<1>删除设备
device_destory
<2>删除类
class_destory
<3>删除字符设备
cdev_del
<4>释放设备号
unregister_chrdev_region
}
设备树
设备的信息是针对于特定平台的,如果我们在Linux内核中包含太多设备信息,则Linux内核移植性就会变差。引入设备树之后,设备的信息的描述不再是以代码的形式存在于 Linux内核源代码中,这种做法实际上是将设备的信息,从Linux 内核中独立出来,单独描述(用设备树语法规则来描述设备的信息)。

- compatible:驱动通过of_match_table中的compatible与设备树匹配
- reg = <寄存器的起始地址 地址的长度>
- status:disable不注册,okay注册
- 设备名-gpios = <&gpio控制器的标签名 管脚编号 标志>
- interrupt-parent = <&中断控制器的标签名>
- interrupts = < 1个或多个uint32的数字描述中断的信息 >,<...>;// #interrupt-cells
|---------------------------|---------------------|
| of_get_named_gpio_flags() | 从设备树获取gpio信息 |
| devm_gpio_request() | 申请占用gpio资源 |
| gpio_direction_output() | 设置gpio输出 |
| devm_request_irq() | 注册中断号、触发方式和注册中断回调函数 |
Linux 中断子系统框架
**中断控制器(GIC )**负责对每个中断源进行编号、使能、屏蔽和优先级仲裁,并根据配置将中断请求分类为普通中断(IRQ)或快速中断(FIQ),最终分发给ARM 核心(ARM Core)。ARM 核接收到中断信号后,会立即保存当前程序的执行上下文(现场),然后跳转到异常处理向量表,执行对应的中断服务例程(ISR),处理完后再恢复现场并返回被打断的程序继续执行。
外部中断由外接的设备通过管脚产生,管脚由gpio控制器控制,gpio控制器向GIC提出中断请求

在linux系统中实现中断功能需要调用request_irq函数进行中断注册
1.中断注册(当中断产生的时候,操作系统会自动调用我们中断处理函数)
typedef irqreturn_t (*irq_handler_t)(int, void *);
int request_irq(unsigned int irq, irq_handler_t handler, unsigned long flags,const char *name, void *dev)
参数:
@irq 中断号
@handler 中断处理函数的入口地址
@flags 标志
@name 名字:/proc/interrupts
@dev 传递给中断处理函数的参数
返回值:
成功返回0,失败返回错误码
2.注销中断
void free_irq(unsigned int irq, void *dev)
参数:
@irq 中断号
@dev 注册中断的时候,传递给中断处理函数的参数
注意:
devm_request_irq申请的中断号,在驱动与设备分离的时候,自动释放中断资源
3.共享中断
多个驱动程序申请的是同一个中断号(多个设备请求的中断是同一个中断的时候)
此时中断产生的时候,操作系统是不区分那个设备产生了中断,操作系统做法是将这个中断号关联
所有中断处理函数全部调用一次,所以此时中断处理函数必须能判别是否是自己的设备产生的中断(通过读状态寄存器),如果不是,立即返回,如果是,则做中断处理。
<1>注册中断的时候,需要IRQF_SHARED标志
<2>给中断处理函数传递的参数必须是唯一的,不能是NULL
中断上半部和下半部
将中断处理函数中需要做的事情,分成两部分,在不同的函数中完成。
中断处理函数中完成的事情 ,是上半部(屏蔽外面的中断)。
而另外一个函数中完成的事情,是下半部(不屏蔽外面的中断)。
- 如果一个任务时间十分敏感,将其放在上半部
- 如果一个任务和硬件有关,将其放在上半部
- 如果一个任务要保证不被其他中断打断,将其放在上半部
- 其他所有任务,考虑放在下半部
下半部的实现机制
1.软中断
2.tasklet
tasklet 是基于软中断实现的。
软中断不能直接使用 ,Linux 内核为了方便开发者,在软中断之上封装了 tasklet 接口,使用更简单。所以,除非对性能要求特别高,否则都应使用 tasklet。
一个tasklet被tasklet_schedule调用过,它的state状态就被设置为TASKLET_STATE_SCHED,接下来等待被执行。tasklet被执行完后,它的状态才会被清除。如果重复调用一个tasklet,它如果没有被执行过,是不会重复执行的。
|--------------------|---------------|
| tasklet_init() | 注册tasklet回调函数 |
| tasklet_schedule() | 调用软中断 |
3.workqueue
工作队列可以把工作推后,交由一个内核线程去执行,这个下半部总是会在进程上下文执行,由于是内核线程,其不能访问用户空间,工作队列允许重新调度甚至是睡眠。
- 如果推后执行的任务需要睡眠,那么只能选工作队列
- 如果推后执行的任务需要延时指定的时间再触发,那么使用工作队列,因为其可以利用timer延时
- 如果推后执行的任务需要在一个tick之内处理,则需要软中断或tasklet,因为其可以抢占普通进程和内核线程
- 如果推后执行的任务对延迟的时间没有任何要求,则使用工作队列,此时通常为无关紧要的任务
|------------------|-----------------------------------|
| INIT_WORK | 初始化 work_struct 结构体,并绑定工作队列回调函数 |
| schedule_work | 将工作项放入系统默认工作队列的链表中,并唤醒工作线程执行 |
| cancel_work_sync | 取消已提交但未执行的工作项,并同步等待正在执行的工作项完成 |
如果一个工作没有被执行,后面重复提交的工作将不会被加入工作队列中
下半部机制的使用总结
|---------|-----|-----|------|-----------|
| 下半部机制 | 上下文 | 复杂度 | 执行性能 | 顺序执行保障 |
| 软中断 | 中断 | 高 | 好 | 没有 |
| tasklet | 中断 | 中 | 中 | 同类型不能同时执行 |
| 工作队列 | 进程 | 低 | 差 | 没有 |
Linux Input子系统框架
Input 子系统用于管理各类输入设备(如按键、触摸屏、鼠标等),负责对外部事件的感知与上报。它将硬件层产生的原始输入事件,经过设备驱动层和核心层处理后,通过统一的接口,当 input_register_device() 执行时,内核会自动创建(如 /dev/input/eventX)提供给用户空间应用程序使用。

linux内核注册了多个handler驱动模块,并通过input_handler_list链表维护

Linux内核中维护了一个名为input_handler_list的链表,每个input_handler都会被注册到这个链表 上,而链表是通过类型为list_head的成员node串联起来的。

input_handler和input_dev匹配成功后会创建**input_handle ,会分配新的次设备号、注册字符设备(提供 evdev_fops)并创建设备节点(/dev/input/eventX)。**
|----------------------------|------------------------------------|
| devm_input_allocate_device | 申请input_dev资源并在remove时自动释放资源 |
| input_register_device | 注册input_dev设备,并创建一个input设备节点给应用层访问 |
| input_report_key | 向核心层上报按键值,存入事件缓存 |
| input_sync | 发送同步事件,表示应用层可读取一组输入事件 |
在我们自己设备树下查找


然后看看别人是怎么写的


我们选谁来当我们的中断控制器节点呢,就近原则,谁直接控制我们的中断源就选谁,这里是gpio控制我们的中断源,所以选gpio,即这里的gpx1(通过原理图知道K2的gpio标签名为gpx1)

在芯片的参考文档linux-3.14/Documentation/devicetree/bindings中找


最后写出我们的设备树节点是

实验结果

