Linux驱动
Linux 驱动有两种运行方式,第一种就是将驱动编译进 Linux 内核中,这样当 Linux 内核启动的时候就会自动运行驱动程序。第二种就是将驱动编译成模块(Linux 下模块扩展名为.ko),在Linux 内核启动以后使用命令加载驱动模块。在调试驱动的时候一般都选择将其编译为模块,这样我们修改驱动以后只需要编译一下驱动代码即可,不需要编译整个 Linux 代码。而且在调试的时候只需要加载或者卸载驱动模块即可,不需要重启整个系统。总之,将驱动编译为模块最大的好处就是方便开发,当驱动开发完成,确定没有问题以后就可以将驱动编译进Linux 内核中,当然也可以不编译进 Linux 内核中,具体看自己的需求。
模块有加载和卸载两种操作,我们在编写驱动的时候需要注册这两种操作函数
1 | /* 驱动入口函数 */ |
字符设备驱动
字符设备是 Linux 驱动中最基本的一类设备驱动,字符设备就是一个一个字节,按照字节流进行读写操作的设备,读写数据是分先后顺序的。比如我们最常见的点灯、按键、IIC、SPI,LCD 等等都是字符设备,这些设备的驱动就叫做字符设备驱动。
设备号
为了方便管理,Linux 中每个设备都有一个设备号,设备号由主设备号和次设备号两部分组成,主设备号表示某一个具体的驱动,次设备号表示使用这个驱动的各个设备。Linux 提供了一个名为 dev_t 的数据类型表示设备号
旧字符设备驱动开发
对于字符设备驱动而言,当驱动模块加载成功以后需要注册字符设备,同样,卸载驱动模块的时候也需要注销掉字符设备。在模块init的时候注册,在exit的时候删除
1 | ... |
新字符设备驱动开发
旧api会占用所以次设备号,且无法自动创建节点,还需要确定哪些主设备号没有用,所以新的方法就可以解决上述问题
alloc_chrdev_region/register_chrdev_region + cdev + class_create + device_create就可以解决上述问题,其中前两者是指定主次设备号或者动态分配主设备的方法,后面的cdev则是新方法里面用于表示一个字符设备的结构体,与设备号的注册分离开了,可以解耦设备号与操作集,之后的两个就是用于完成自动创建设备节点的
1 | static int __init xxx_init(void) |
设备树
没有设备树的时候使用机器码来识别设备,非常不方便,于是就有了设备树用来描述设备,也就是开发板上的设备信息,比如CPU 数量、 内存基地址、IIC 接口上接了哪些设备、SPI 接口上接了哪些设备等等。设备树的文件叫做 DTS,这个 DTS 文件采用树形结构描述板级设备,DTB 是将DTS 编译以后得到的二进制文件,将.dts 编译为.dtb需要用到 DTC 工具
设备树也是用#include引用文件,其中这个imx6ull.dtsi文件就类似于头文件,一般.dtsi 文件用于描述 SOC 的内部外设信息,比如 CPU 架构、主频、外设寄存器地址范围,比如 UART、IIC 等等。这样子就可以给多个设备树文件引用,在具体的dts文件里面再编辑各种设备节点,避免重复写的麻烦
1 | #include <dt-bindings/input/input.h> |
每个设备树只有一个”/“根节点,设备节点的命名格式为
1 | label: node-name@unit-address |
引入 label 的目的就是为了方便访问节点,可以直接通过&label 来访问这个节点而不需要输入完整的节点名字。其中node-name是节点名字,为 ASCII 字符串,节点名字应该能够清晰的描述出节点的功能,比如“uart1”就表示这个节点是 UART1 外设。unit-address一般表示设备的地址或寄存器首地址,如果某个节点没有地址或者寄存器的话unit-address可以不要
常用属性节点
1 | / { |
没有设备树之前,uboot 会向 Linux 内核传递一个叫做 machine id 的值,machine id也就是设备 ID,告诉 Linux 内核自己是个什么设备,看看 Linux 内核是否支持,Linux内核都用MACHINE_START和MACHINE_END来定义一个 machine_desc 结构体来描述这个设备
使用设备树之后,所有machine_desc是通过DT_MACHINE_START定义的,在链接过程中通过链接脚本将所有的machine_desc放置在特定的段(.arch.info.init),保证它们的内存地址连续排列。Linux设备树和内核会遍历这些machine_desc内部的dt_compat[](compatible字符串数组),与设备树根节点的compatible属性来匹配,具体流程就是,start_kernel()初始化内核大部分子系统,其中会调用setup_arch()架构初始化,然后setup_arch()会调用setup_machine_fdt()->of_flat_dt_match_machine(),最后里面使用while(get_next_compat)遍历所有desc下面的dt_compat[]数组与设备树根节点的compatible对比来完成匹配过程
设备树常用of函数,因为驱动里面想要获取设备树节点配置的信息,肯定需要各种读取函数
1 | //查找相关 |
pinctrl子系统
使用设备树之后,肯定不会再通过操作寄存器的方式去操作设备,Linux 内核提供了 pinctrl 和 gpio 子系统用于GPIO 驱动
不管是stm32还是linux裸机开发,操作gpio的时候基本上都是先配置gpio的复用功能,电气属性(上下拉,驱动能力)等等,然后在设置gpio是输入还是输出,前者是pinctrl子系统的工作,后者是gpio子系统的工作
1 |
1 | &iomuxc { |
接下来是pinctrl驱动是如何识别到设备树里面的pinctrl信息的,这个驱动一般是soc厂商编写好的
1 | /*Linux内核里面的pinctrl驱动会匹配上设备树里面的拥有这些compatible的节点,probe函数会被调用*/ |
1 | int imx_pinctrl_probe(struct platform_device *pdev,struct imx_pinctrl_soc_info *info) |
gpio子系统
gpio子系统驱动也是一个平台驱动,检测设备树中gpio节点的compatible字符串列表,匹配上之后就会执行probe函数,一般会在这里先定义一个结构体,这个结构体会存储gpio控制器和gpio寄存器组。然后probe获取设备树里面的reg并通过内存映射得到gpio寄存器基地址在Linux内核中的虚拟地址,这样子内核就可以访问gpio的所有寄存器组了。然后是给gpio控制器定义一些gpio操作方法,将这些全部存储在那个结构体中,gpio驱动再向用户空间提供gpio操作函数,这样子就可以实现对gpio的访问和操作了。
1 | gpio1: gpio@0209c000 { /*设备树的gpio节点*/ |
在imx6ull里面体现为
1 | /*gpio_chip里面定义了各种gpio操作方法*/ |
1 |
|
pinctrl和gpio配合使用
一般这两个子系统是搭配使用的,先配置驱动能力再控制gpio,以下是一个led驱动
1 | gpioled { |
1 | /*设备结构体*/ |
Linux并发与竞争
Linux 系统是个多任务操作系统,会存在多个任务同时访问同一片内存区域,这些任务可能会相互覆盖这段内存中的数据,造成内存数据混乱。针对这个问题必须要做处理,严重的话可能会导致系统崩溃。现在的 Linux 系统并发产生的原因很复杂,总结一下有下面几个主要原因:
①、多线程并发访问,Linux 是多任务(线程)的系统,所以多线程访问是最基本的原因。
②、抢占式并发访问,从 2.6 版本内核开始,Linux 内核支持抢占,也就是说调度程序可以在任意时刻抢占正在运行的线程,从而运行其他的线程。
③、中断程序并发访问,这个无需多说,学过 STM32 的同学应该知道,硬件中断的权利可是很大的。
④、SMP(多核)核间并发访问,现在 ARM 架构的多核 SOC 很常见,多核 CPU 存在核间并发访问。
并发访问带来的问题就是竞争,所谓的临界区就是共享数据段,对于临界区必须保证一次只有一个线程访问,也就是要保证临界区是原子访问的。这里的原子访问就表示这一个访问是一个步骤,不能再进行拆分。如果多个线程同时操作临界区就表示存在竞争,我们在编写驱动的时候一定要注意避免并发和防止竞争访问。
原子操作
首先看一下原子操作,原子操作就是指不能再进一步分割的操作,是通过定义一个原子变量对这个变量进行保护的,这些原子操作可以确保对原子变量的操作是不会被分割的,但是只适用于整形变量
原子操作API

自旋锁
当一个线程要访问某个共享资源的时候首先要先获取自旋锁,自旋锁只能被一个线程持有,只要此线程不释放持有的锁,那么其他的线程就不能获取此锁。
注意,获取自旋锁的线程会禁用内核抢占,等待获取自旋锁的线程是忙等待而不是阻塞等待,所以要注意拿到锁的线程在执行临界区代码的时候不能阻塞或者休眠,否则会发生死锁。例如,A线程拿到了自旋锁就会禁用内核抢占,在执行临界区代码的时候进入阻塞或者休眠的话,就相当于自动放弃了CPU,然后线程B就会运行。然后B也要访问临界区的时候,锁被A拿着,B就只能等A释放锁,B的等待不是阻塞休眠,会一直占用CPU,这个时候A休眠结束了但是内核抢占又被禁用了,所以无法打断B,就形成了死锁
自旋锁API

| 方式 | 功能 | 使用场景 |
|---|---|---|
| DEFINE_SPINLOCK() | 定义 + 初始化 | 全局 / 单独一把锁 |
| spin_lock_init() | 只初始化 | 结构体里的锁、动态创建的锁 |
拿到锁的线程只会禁用内核抢占而不会禁用中断,如果在执行临界区期间被中断打断,且中断也要获取锁访问共享资源,就又可能导致死锁。所以常用下面的API

前两个API是直接强制关中断,解锁时直接强制开中断。不安全!因为如果进来之前中断本来就是关的,解锁后会被错误打开。所以一般在线程中使用 spin_lock_irqsave/spin_unlock_irqrestore,在中断中使用 spin_lock/spin_unlock
除了普通的自旋锁,还有读写锁和顺序锁,但是这两个一般是在Linux内核或者应用中使用,驱动开发用的不多
综合前面关于自旋锁的信息,我们需要在使用自旋锁的时候要注意一下几点:
①、因为在等待自旋锁的时候处于“自旋”状态,因此锁的持有时间不能太长,一定要短,否则的话会降低系统性能。如果临界区比较大,运行时间比较长的话要选择其他的并发处理方式。
②、自旋锁保护的临界区内不能调用任何可能导致线程休眠的 API 函数,否则的话可能导致死锁。
③、不能递归申请自旋锁,因为一旦通过递归的方式申请一个你正在持有的锁,那么你就必须“自旋”,等待锁被释放,然而你正处于“自旋”状态,根本没法释放锁。结果就是自己把自己锁死了!
④、在编写驱动程序的时候我们必须考虑到驱动的可移植性,因此不管你用的是单核的还是多核的 SOC,都将其当做多核 SOC 来编写驱动程序。
信号量
信号量有一个信号量值,相当于一个房子有 10 把钥匙,这 10 把钥匙就相当于信号量值为10。因此,可以通过信号量来控制访问共享资源的访问数量,如果要想进房间,那就要先获取一把钥匙,信号量值减 1,直到 10 把钥匙都被拿走,信号量值为 0,这个时候就不允许任何人进入房间了,因为没钥匙了。如果有人从房间出来,那他要归还他所持有的那把钥匙,信号量值加 1,此时有 1 把钥匙了,那么可以允许进去一个人。相当于通过信号量控制访问资源的线程数。
在初始化的时候将信号量值设置的大于 1,那么这个信号量就是计数型信号量,计数信号量不能用于互斥访问,因为它允许多个线程同时访问共享资源。如果要互斥的访问共享资源那么信号量的值就不能大于 1,此时的信号量就是一个二值信号量。
信号量API,前两者和自旋锁一样的含义

信号量会提高处理器的使用效率,因为它不是自旋等待而是休眠等待。但是,信号量的开销要比自旋锁大,因为信号量使线程进入休眠状态以后会切换线程,切换线程就会有开销。总结一下信号量的特点:
①、因为信号量可以使等待资源线程进入休眠状态,因此适用于那些占用资源比较久的场合。
②、因此信号量不能用于中断中,因为信号量会引起休眠,中断不能休眠。
③、如果共享资源的持有时间比较短,那就不适合使用信号量了,因为频繁的休眠、切换线程引起的开销要远大于信号量带来的那点优势。
互斥体
将信号量的值设置为 1 就可以使用信号量进行互斥访问了,虽然可以通过信号量实现互斥,但是 Linux 提供了一个比信号量更专业的机制来进行互斥,它就是互斥体—mutex。互斥访问表示一次只有一个线程可以访问共享资源,不能递归申请互斥体。在我们编写 Linux 驱动的时候遇到需要互斥访问且耗时较长的地方建议使用 mutex。
互斥体API,前两者和自旋锁一样的含义

在使用 mutex 之前要先定义一个 mutex 变量。在使用 mutex 的时候要注意如下几点:
①、mutex 可以导致休眠,而中断上下文是绝对不允许休眠的,因为会导致系统崩溃。因此不能在中断中使用 mutex,中断中只能使用自旋锁。
②、和信号量一样,mutex 保护的临界区可以调用引起阻塞的 API 函数,因为不会让别的线程死等而是休眠,所以拿到mutex的线程醒来就可以继续执行
③、因为一次只有一个线程可以持有 mutex,因此,必须由 mutex 的持有者释放 mutex。并且 mutex 和自旋锁一样不能递归上锁和解锁,否则死锁。
总结
| 对比项 | 原子操作 atomic | 自旋锁 spinlock | 信号量 semaphore | 互斥锁 mutex |
|---|---|---|---|---|
| 保护对象 | 单个变量 | 一段代码临界区 | 一段代码 / 同步事件 | 一段代码临界区 |
| 获取不到锁行为 | 无锁概念,硬件原子执行 | 自旋忙等待,占用 CPU,不休眠 | 休眠阻塞,让出 CPU | 休眠阻塞,让出 CPU |
| 能否在中断使用 | ✅ 可以 | ✅ 可以 | ❌ 不可以 | ❌ 不可以 |
| 临界区能否睡眠阻塞 | 无临界区 | ❌ 严禁睡眠,睡眠必死锁 | ✅ 可以随便睡 | ✅ 可以随便睡 |
| 是否禁止内核抢占 | 不禁止 | 获取锁自动禁止抢占 | 不禁止 | 不禁止 |
| CPU 开销 | 最低,最快 | 高(忙等待浪费 CPU) | 低(休眠让出 CPU) | 低(休眠让出 CPU) |
| 有无线程归属 | 无归属 | 无归属 | 无归属,任意线程可解锁 | 有归属,必须持有者本人解锁 |
| 能否递归上锁 | 无递归概念 | 不建议递归,递归自旋死等 | ✅ 可以递归 | ❌ 严禁递归,递归必死锁 |
| 主要用途 | 计数器、标志位、状态位 | 短临界区、中断、线程↔中断互斥 | 线程同步、生产者消费者、二值互斥 | 线程之间纯互斥保护、长临界区 |
Linux定时器
Linux 内核中有大量的函数需要时间管理,比如周期性的调度程序、延时程序、定时器。硬件定时器提供时钟源,时钟源的频率可以设置, 设置好以后就周期性的产生定时中断,系统使用定时中断来计时。
节拍率HZ
系统节拍率是可以设置的,单位是 Hz,我们在编译 Linux 内核的时候可以通过图形化界面设置系统节拍率,设置好之后可以在内核根目录下面的.config里面看到CONFIG_HZ的值就是我们设置的频率。Linux内核会定义一个宏HZ,使用CONFIG_HZ来设置HZ的值。
高节拍率和低节拍率的优缺点:
①、高节拍率会提高系统时间精度,如果采用 100Hz 的节拍率,时间精度就是 10ms,采用1000Hz 的话时间精度就是 1ms,精度提高了 10 倍。高精度时钟的好处有很多,对于那些对时间要求严格的函数来说,能够以更高的精度运行,时间测量也更加准确。
②、高节拍率会导致中断的产生更加频繁,频繁的中断会加剧系统的负担,1000Hz 和 100Hz的系统节拍率相比,系统要花费 10 倍的“精力”去处理中断。中断服务函数占用处理器的时间增加,但是现在的处理器性能都很强大,所以采用 1000Hz 的系统节拍率并不会增加太大的负载压力,所以要根据自己的实际情况,选择合适的系统节拍率。
滴答计算器jiffies
Linux 内核使用全局变量 jiffies 来记录系统从启动以来的系统节拍数,有jiffies_64和jiffies, 其实是同一个东西,jiffies_64 用于 64 位系统,而 jiffies 用于 32 位系统。当我们访问 jiffies 的时候其实访问的是 jiffies_64 的低 32 位,在 32 位的系统上读取 jiffies 的值,在 64 位的系统上 jiffes 和 jiffies_64表示同一个变量,因此也可以直接读取 jiffies 的值。所以不管是 32 位的系统还是 64 位系统,都可以使用 jiffies。系统启动的时候会将 jiffies 初始化为0。
不管是 32 位还是 64 位的 jiffies,都有溢出的风险,溢出以后会重新从 0 开始计数,相当于绕回来了,因此有些资料也将这个现象也叫做绕回。假如 HZ 为最大值 1000 的时候,32 位的 jiffies 只需要 49.7 天就发生了绕回,对于 64 位的 jiffies 来说大概需要5.8 亿年才能绕回,因此 jiffies_64 的绕回忽略不计。
判断超时API

单位转换API

延时API

软件定时器
Linux软件定时器使用很简单,只需要提供超时时间(相当于定时值)和定时处理函数即可,当超时时间到了以后设置的定时处理函数就会执行,不需要做一大堆的寄存器初始化工作。在使用的时候要注意一点,软件定时器并不是周期性运行的,超时以后就会自动关闭,因此如果想要实现周期性定时,那么就需要在定时处理函数中重新开启定时器。
1 | struct timer_list { /*软件定时器结构体,是基于软中断的*/ |
常用API
1 | timer_list timer; //声明定时器 |
示例
1 | /*设备结构体*/ |
Linux中断
Linux 内核提供了完善的中断框架,我们只需要申请中断,然后注册中断处理函数即可,不需要一系列复杂的寄存器配置
调度器只能调度进程而不能调度中断,所以中断里面不允许执行会导致休眠或者阻塞的任务,否则调度器不知道接下来给谁安排进入CPU工作,调度异常,就会导致出错卡死
中断API
1 | /*申请中断,第一个参数是申请的中断号,第二个是中断回调函数,第三个是下表中的标志,第四个是使用共享中断的时候传入用来区分的设备参数*/ |

上半部下半部
中断会打断CPU运行的所有任务,所以要快速响应快速停止。为了处理一些耗时的任务,中断就有上半部和下半部之分,上半部使用硬中断来快速处理和接收,下半部使用软中断做一些稍微耗时一点的逻辑,因为软中断会在CPU空闲时执行,不会打断CPU也不会屏蔽其它硬件中断。而tasklet就是基于软中断的下半部机制,是简化版的封装。但是tasklet由于也是中断的一种,而在中断的上下文里面是不允许执行延时或者阻塞等会引起CPU睡眠的任务,所以又有了一种新的下半部机制叫工作队列,它会把工作加入一个工作队列里面,然后内核里面的工作者线程就会执行这里面的工作,因为它不是基于软中断的,而是将工作任务交给一个内核工作者线程执行,是进程上下文所以工作内部可以执行延时或者阻塞的任务。
两者的回调函数也有所不同,tasklet回调函数传入data,工作队列则只允许传入需要执行的工作work,两者如果想要拿到定义的dev设备,对于tasklet来说可以把dev地址传入回调函数,而工作队列回调函数因为无法传参,所以一般是将work定义在dev内部,再通过container_of拿到父结构体dev。之所以有这样子的差异是因为,tasklet比较早本身也只是一个小工具,不知道自己属于哪个设备,当时也还没有出现container_of函数,只能这样子传参。而工作队列出来的时候已经有container_of了,并且设计的时候就明确work是设备结构体自带的一部分,所以就不需要额外添加一个参数了
tasklet
1 | struct tasklet_struct tasklet; |
工作队列
1 | struct work_struct work; |
示例
1 | gpio1: gpio@0209c000 { |
1 | ... //头文件 |
阻塞和非阻塞IO
当应用程序对设备驱动进行操作的时候,如果不能获取到设备资源,那么阻塞式 IO 就会将应用程序对应的线程挂起,直到设备资源可以获取为止。对于非阻塞 IO,应用程序对应的线程不会挂起,它要么一直轮询等待,直到设备资源可以使用,要么就直接放弃。
阻塞访问就是,当应用程序read/write访问设备资源的时候,访问不到时会进入休眠,直到可以获取时再醒来
非阻塞访问就是,当应用程序read/write访问设备资源的时候,访问不到,立刻返回进行下一次访问
非阻塞访问会非常占用CPU,那为什么还要有呢。是因为阻塞访问的话只能用于读取一个设备,比如要获取多个资源的话,读取第一个的时候就进入睡眠了。后面的设备就只能等待了,而非阻塞就可以同时访问多个设备,因为它会立即返回。而且可能非阻塞使用方式也有问题,一般使用非阻塞就是while循环然后一直read,这样子肯定占CPU,正确的方法是使用轮询
阻塞和非阻塞的打开方式有所区别
1 | fd = open("/dev/xxx_dev", O_RDWR); /* 阻塞方式打开 */ |
等待队列
阻塞访问最大的好处就是当设备文件不可操作的时候进程可以进入休眠态,这样可以将CPU 资源让出来。当设备文件可以操作的时候就必须唤醒进程,一般在中断函数里面完成唤醒工作。Linux 内核提供了等待队列(wait queue)来实现阻塞进程的唤醒工作
如果我们要在驱动中使用等待队列,必须创建并初始化一个等待队列头。等待队列头就是一个等待队列的头部,每个访问设备的进程都是一个等待队列项,当设备不可用的时候就要将这些进程对应的等待队列项添加到等待队列里面。因为只有添加到等待队列头中以后进程才能进入休眠态,当设备可以访问以后再将进程对应的等待队列项从等待队列头中移除。在实际使用中只需要创建等待队列头,等待队列项只需要调用wait_event/wait_event_interruptible等函数就会自动创建。这两种API内部逻辑大概就是,先创建一个等待队列项,然后添加进入等待队列头,并将进程运行状态改成TASK_INTERRUPTIBLE/TASK_UNINTERRUPTIBLE,然后使用调度器放弃CPU进入休眠,当wait_event唤醒时,再将进程从等待队列移出去,并把状态改回运行态。
等待队列API
1 | struct __wait_queue_head head; //等待队列头 |
1 | // struct wait_queue_head_t wqh; //定义等待队列头 |
轮询
当使用非阻塞打开文件设备的时候,有以下三种方式实现不占CPU且可以同时访问多个文件描述符。首先就是以下三种都可以监听多个文件描述符,然后会阻塞等待监听的描述符中是否有可访问资源,当有可访问资源的时候就会退出阻塞,然后程序继续执行read/write,这样子实现了不阻塞read/write,且不会一直占用CPU,因为这三种方法会休眠等待,它们的实现方式也是基于等待队列的。至于为什么不直接阻塞read而是阻塞这三种,是因为它们可以同时监听多个文件描述符而read只能读一个,从而实现多设备同时监听、不占 CPU、read /write的IO非阻塞。所以之所以叫轮询,其实是指select/poll里面会不断循环判断文件描述符
| 特点 | select | poll | epoll |
|---|---|---|---|
| 最大监听数 | 受限制(1024) | 无限制 | 无限制 |
| 效率 | 低(每次要遍历全部) | 低(每次要遍历全部) | 高(只返回就绪的) |
| 内存拷贝 | 每次都要拷贝 fd 集合 | 每次都要拷贝 | 只需一次 |
| 使用难度 | 一般 | 一般 | 稍复杂 |
| 适用场景 | 少量 fd、嵌入式 | 少量 fd、嵌入式 | 高并发、海量连接服务器 |
之所以epoll快,就是因为select/poll不会告诉你谁就绪了,所以还需要自己判断所有文件描述符,但是epoll就可以告诉你谁就绪了。嵌入式基本上用不到epoll。当应用程序里面使用了select/poll的时候,驱动里面就会调用fops操作集里面的poll,在这个函数里面就可以调用poll_wait把当前进程添加进入等待队列里面

1 | fd_set *readfds; //读描述符集合 |
1 | static void timer_function(unsigned long arg){ |
异步通知
除了上述两种应用程序主动访问设备,还有驱动程序主动通知应用程序设备可以访问。这个时候使用的是信号,例如Ctrl+C的时候其实就是内核向进程发送了一个SIGINT信号,或者终端输入kill -9也是向进程发送了一个SIGKILL信号来终止进程,可以说信号就是类似于中断一样的东西,可以让驱动在设备资源可访问时给进程发消息通知它可以读写了
内核的信号有非常的种,常用于驱动通知应用程序的是SIGIO。首先用户在应用程序里面需要使用signal函数给SIGIO信号指定一个回调函数sign_function,然后是固定的流程,先fcntl(fd,F_SETOWN,getpid())告诉fd对应的驱动程序,信号要发送给这个进程,然后fcntl(fd,F_SETFL,flags | FASYNC)获取当前文件描述符标志位再或上FASYNC,这样子就不会干扰其它标志位,这一步会把上一步的进程加入异步通知表,这样子应用程序部分就完成了。
在驱动里面,首先需要定义一个异步通知表指针,然后还需要往fops里面添加一个.fasync操作函数,当应用程序给文件描述符标志位或上FASYNC的时候就会调用这个操作,这个操作内部就会调用fasync_helper函数把当前进程添加到异步通知表里面(这就是应用程序里面或上FASYNC之后可以把进程添加到异步通知表的原理)。然后在·需要发送信号的地方调用kill_fasync函数,就会给异步通知表里面的进程发送信号,应用程序里面的回调函数就会触发,然后在这个回调函数里面读取设备资源就行了。当关闭设备的时候还需要主动调用一次fops里面的.fasync,其实就是再次使用fasync_helper(-1,filp,0,&dev->fasync_mykey),这样子就会把当前进程从异步通知表里面去除。
1 | int fd; |
1 | struct mykey { |
Platform
在实际开发过程中,设备和驱动的分离是很重要的,可以避免大量重复代码。为此引出了总线(bus)、驱动(driver)和设备(device)模型,比如 I2C、SPI、USB 等总线。但是在 SOC 中有些外设是没有总线这个概念的,为了解决此问题,Linux 提出了 platform 这个虚拟总线,相应的就有 platform_driver 和 platform_device
platform总线已经在内核中注册好了,直接使用就可以。
platform_device设备,内核启动解析设备树,无专用总线(I2C/SPI)的普通外设节点,内核会自动创建 platform_device 并挂载到 platform 总线
我们只需要编写platform驱动就行了。platform驱动首先需要在驱动模块加载的时候注册进入platform总线,可以在原本的init/exit框架里面使用platform_driver_register完成注册,也可以使用module_platform_driver一次性完成。然后还需要实现of_match_table,probe和remove。of_match_table是用来匹配platform设备的,是通过设备树里面的compatible属性来匹配的,匹配成功之后就会执行probe函数,然后就是在probe函数里面实现设备操作集和创建设备节点,因为platform驱动和设备已经匹配成功了,probe 参数 dev 即为内核解析生成的 platform 设备,这样子就可以直接访问到设备而不需要再去主动找设备树节点。remove是卸载驱动的时候执行,完成设备号和设备节点的销毁等等。
这一套驱动-总线-设备模型和普通的方法比起来,原来不管有没有设备只要insmod加载就会创建设备节点,且驱动主动寻找设备树节点导致绑死一个设备树节点,无法通过compatible属性支持不同厂商或不同版本的硬件。而platform就会在驱动和设备都匹配的时候再创建设备节点,并且可以通过of_match_table匹配多种compatible,支持不同厂商或不同版本的硬件,实现了驱动和设备的解耦。
1 | ...//头文件 |
misc
misc驱动就是简化版的字符设备驱动,设备少的时候,不想占用和管理主设备号可以使用misc,一般和platform搭配使用。misc主设备号是10,内部自动完成设备注册,创建设备类并挂载到专用的misc_class(/sys/class/misc)下面,以及设备节点的添加。

misc已经预定了一些次设备号,也可以自己定义次设备号,只要没有被使用
1 | ...//头文件 |
input
input子系统分为驱动层,核心层,事件层,这种结构体现了驱动的分层,各司其职。
input核心层会向内核注册一个字符设备input,主设备号为13,并且创建input类,到时候所有创建的输入设备都会挂载在这个类下面
input_dev是核心层给驱动层提供的input设备结构体。驱动层首先需要使用input_allocate_device分配一个input_dev结构体指针,需要填充这个结构体如设备名称、设备输入类型和设备输入事件是什么。然后使用input_register_device把设备给核心层,核心层会分配次设备号,把该设备挂载到input_class(/sys/class/input内核自己创建的input类,所有input设备会在这个类下面),再根据输入设备类型匹配handler(绝大多数匹配的都是evdev_handler,这个是通用的,会把数据原封不动的传递给用户)。这个handler就是事件层处理函数,负责创建/dev/input/eventX设备节点,将接收到的输入事件传递给用户。驱动层里面会使用input_event和input_sync来给核心层发送输入数据包,核心层根据对应的类型传递给handler,handler事件层就可以把数据给放在/dev/input/eventX给用户读取。用户在应用程序里面可以通过input_event结构体读取数据。

1 | .... |
1 | ... |
RTC
RTC 也就是实时时钟,用于记录当前系统时间,是一个标准的字符设备驱动,应用程序通过 open、release、read、write 、ioctl等函数完成对 RTC 设备的操作
Linux把RTC设备抽象为结构体rtc_device,该结构体下面有统一的rtc操作集rtc_class_ops。Linux内核有一个标准的platform框架的RTC驱动,在fops操作集里面调用了rtc_class_ops里面的接口函数,所以需要Soc厂商编写设备驱动完善rtc_class_ops。Soc厂商根据自己的板子编写操作RTC设备寄存器的函数用于填充rtc_class_ops结构体,在probe函数里面调用rtc_device_register传入RTC设备结构体和rtc_class_ops结构体,会注册一个rtc_device结构体,并且在/dev创建rtc设备节点。这样子厂商RTC驱动和Linux的RTC标准驱动就连接上了,用户操作这个设备节点,rtc标准驱动就会调用rtc_device下面的操作集来访问RTC设备

1 | struct rtc_class_ops { |
一般板子里面已经编写好了rtc设备驱动,直接使用即可
1 | date //查看时间 |
I2C
I2C和platform一样,只不过platform是虚拟总线,而i2c是真实存在的总线。Linux的I2C驱动有三个部分,i2c适配器驱动,i2c设备驱动,i2c核心层。
i2c适配器驱动
i2c适配器驱动一般是芯片厂商根据自己的i2c外设编写,比如NXP的适配器驱动是基于platform的。Linux使用i2c_adapter结构体表示一个适配器,而适配器驱动的工作就是编写访问芯片i2c寄存器的方法,然后赋值给i2c_adapter->i2c_algorithm,i2c_algorithm 就是 I2C 适配器与 IIC 设备进行通信的方法。然后会调用i2c_add_adapter把适配器注册到核心层里面。
1 | static struct i2c_algorithm i2c_imx_algo = { //把I2C适配器与I2C设备进行通信的方法赋值给i2c_algorithm |
i2c核心层驱动
因为i2c_add_adapter是核心层的函数,在这个函数里面就会调用i2c_register_adapter,设置适配器名字类型和总线类型并注册进入内核,i2c_bus_type就是i2c总线的结构体,这样子就可以在 /sys/bus/i2c/devices/看到适配器i2c-x了。而且在核心层里面,会有统一定义的i2c数据传输函数i2c_transfer,并且进行二次封装实现发送和接收函数,而在i2c_transfer内部就会调用适配器的master_xfer函数,这样子就能根据厂商写的适配器驱动操作i2c设备了
1 | int i2c_add_adapter(struct i2c_adapter *adapter){ |
i2c设备驱动
这个是需要自己编写的驱动,调用核心层提供的收发函数完成与i2c设备通信。需要使用i2c_driver驱动框架来匹配设备树里面的i2c设备,匹配成功之后调用probe函数,并且在/sys/bus/i2c/devices/看到设备0-001e(使用i2c-0,从机地址001e)参数client就是Linux分配给设备的结构体,Linux用这个结构体描述i2c设备,核心层会自动把client挂载到i2c总线上。这样子当用户读取设备数据的时候,会通过client调用i2c_smbus_read_byte_data,该函数内部就会调用i2c_transfer,i2c_transfer调用适配器的master_xfer函数,master_xfer又连接到厂商写的适配器操作函数,这样子就连通起来了
1 | &i2c1 { |
1 | struct bus_type i2c_bus_type = { |
1 | ... //头文件 |
SPI
spi主机控制器
Linux下的SPI驱动分为spi_master主机控制器设备和spi_driver从机驱动。spi_master结构体就类似于iic的适配器,该结构体下面有transfer函数,这个transfer函数就是厂商需要根据自己的芯片spi来编写的与spi设备通信的时序方法。主机驱动首先要使用spi_alloc_master申请一个spi_master,编写好spi_master之后需要使用spi_register_master向spi总线注册这个spi主机,这个时候就可以在/sys/bus/spi/devices/看到这个主机设备了。
1 | struct spi_master { |
spi设备驱动
然后就是spi_driver设备驱动,和i2c的i2c_driver一样的驱动框架,调用module_spi_driver就会把设备驱动注册到spi总线,可以在/sys/bus/spi/drivers看到设备驱动,驱动和设备匹配成功后会调用probe函数,参数spi_device就是spi设备结构体,这时候就可以在/sys/bus/spi/devices看到设备。其中spi总线结构体就是spi_bus_type,里面的.match就是根据of_device_id和设备树节点的compatible用来匹配spi设备和spi设备驱动的。这样子主机和从机都有了,接下来就是通信方法。spi总线使用一个spi_transfer结构体由于存放需要收发的数据,然后会把spi_transfer放到一个spi_message结构体队列中,而主机驱动里面的transfer函数的参数就是spi_device和spi_message,当使用spi_sync发送数据的时候就会调用spi_master->transfer遍历spi_message列表里面的spi_transfer,根据厂商自己定义的时序方法来完成数据的收发
1 | &ecspi3 { |
1 | struct bus_type spi_bus_type = { |
1 | ...//spi从机寄存器地址 |