Linux设备驱动开发详解——学习笔记(一)
Linux 设备驱动概述
计算机系统的运转需要软件和硬件共同参与,硬件是底层基础,软件则实现了具体的应用。硬件和软件之间则通过设备驱动来联系。在没有操作系统的情况下,工程师可以根据硬件设备的特点自行定义接口。而在有操作系统的情况下,驱动的架构则由相应的操作系统来定义。驱动存在的意义就是给上层应用提供便利。
驱动针对的对象是存储器和外设。Linux将存储器和外设分为 3 个基础大类:字符设备、块设备、网络设备。
字符设备和块设备都被 Linux 映射到文件系统的文件和目录中,通过文件系统的接口(open、read、write、close等)来访问。其中,块设备可以通过类似 dd 命令对应的原始块设备来访问,也可以通过建立文件系统,以文件路径来访问。
学习 Linux 设备驱动,要求非常好的硬件基础、非常好的软件基础、一定的 Linux 内核基础和非常好的多任务并发控制和同步的基础。学习 Linux 设备驱动要将学习的函数、数据结构等放到整体架构中去理解,才能理清驱动中各组成部分之间的关系。
驱动设计的硬件基础
驱动工程师需要掌握 处理器、存储器、接口和总线、可编程门电路、原理图、硬件时序、芯片手册、仪器使用 等方面的内容。
【处理器】
处理器分为冯诺依曼结构和哈佛结构。冯诺依曼结构将指令和数据放在一起存储,二者数据宽度相同。哈佛结构则将数据和指令分开存储,二者各自使用独立的总线。
处理器在不同的领域上也有不同的设计。数字信号处理器(DSP)用于通信、图像、语音、视频处理等算法处理,对于复杂运算进行了优化。DSP 分为定点 DSP 、浮点 DSP 两种。网络处理器用于电信领域上,通常包括微码处理器和若干硬件协处理器。
【存储器】
存储器可分为只读存储器(ROM)、闪存(Flash)、随机存取存储器(RAM)、光/磁介质存储器。
如今常见的 ROM 已经是非常方便的 E2PROM了,完全可以用软件来擦写。
闪存可以分为 NOR FLASH 和 NAND FLASH 两种。NOR FLASH 可在芯片内执行程序,而 NAND FLASH 需要相应的控制电路进行转换。NOR FLASH 和 CPU 是典型的类 SRAM 接口。
FLASH 的擦写只能将 1 写成 0。所以 FLASH 的擦写需要先对块擦除(全写为 1),再在要求的位置写 0 。FLASH 的擦写还需要注意避免反复擦写同一个块,避免出现坏块。
NOR FLASH 可以使用 SPI 进行访问以节省引脚。NAND FLASH 容量大,价格低,但更容易发生数据错误,应该使用错误探测/错误更正算法(EDC/ECC)。
FLASH 可以通过 CFI (Common Flash Interface)或 JEDEC (Joint Electron Device Engineering Council)接口来读出设备信息。
RAM 包括静态RAM(SRAM )和动态RAM(DRAM )两种。DDR SDRAM 也属于 DRAM 的范畴,它同时利用时钟脉冲的上升沿和下降沿传输数据,因此传输速率更高(加倍)。另外,还有 DPRAM、CAM 、FIFO 这几种流行的 RAM。
DPRAM 是双端口的 RAM,它具有两套完全独立的地址总线、数据总线、控制总线,能够利用芯片内部的逻辑电路支持双 CPU 的同时访问。
CAM 使用内容进行寻址,将输入项与 CAM 中所有数据进行比较,输出匹配信息的地址,在数据检索上有非常大的优势。
FIFO 多用于数据串口,但只能串行单字节读取,每次读取后目标地址自增。
【接口和总线】
串口从时间线上来看,依次有 RS-232、RS-422、RS-485等。现用最广泛的是经过修改的 RS-232C 标准。在 RS-232C 和 UART 之间,还需要进行 COMS/TTL 电平转换。
I2C 的特点是支持多主控,IIC 总线上的任何能够发送和接收的设备都能成为主控设备。
USB 1.1 包含全速和低速两种模式。在 USB 2.0 中,增加了高速模式,半双工工作,数据传输率可达 480Mbit/s。 USB 3.0 全双工工作,数据传输率高达 5.0 Gbit/s。
当电路板需要挂载 USB 设备时,需要提供 USB 主机控制器和连接器。若作为 USB 设备,则需要提供 USB 适配器和连接器。USB 总线具备热插拔的能力。
【原理图分析】
原理图的基本分析方法是以主CPU为中心,向存储器和外设辐射。对于 CPU 重点了解片选、中断、集成的外设控制器。
硬件原理图包含的元素:符号(如 PIN 脚符号)、网络(芯片、器件等之间的互联关系)、描述(模块功能辅助描述)。
【硬件时序】
硬件时序并不是非常重点的一项,但如果需要进行电路板调试就有必要掌握。最经典的硬件时序是 SRAM 的读写时序,过程中涉及地址、数据、片选、读/写、字节使能、就绪/忙 等信号。NOR Flash 以及许多外设控制器都采取了类似的时序,所以该时序有必要掌握。
【芯片手册阅读方法】
正确阅读方法:快速准确地定位有用信息,重点阅读这部分,忽略无关内容。
在芯片手册中,OVERVIEW 非常有必要阅读,它会告知产品或芯片的整体信息和结构。
MemoryMap 也比较重要,对工程师了解存储器和外设的基址很有帮助。
对于各种外设接口的信息,应当在编写某个接口驱动时去了解对应部分,一般是分析数据、控制、地址寄存器的访问控制和具体设备的操作流程。
【仪器使用】
常规会接触到的仪器包括万用表、示波器、逻辑仪等。
Linux 内核及内核编程
【Linux 内核的子系统】
Linux 也是一种类 Unix 系统,由 Linus 参照 Minix 系统开发而来。Linux 受 GNU 计划的广大开源程序以及互联网上广大开发者的支持,不断根据 POSIX 标准优化自身设计,已经成为风靡世界的操作系统。驱动编程的本质是内核编程,Linux 内核包括进程调度、内存管理、虚拟文件系统、网络接口、进程间通信这五大子系统。
- 进程调度:使进程在就绪、执行、睡眠、暂停等几个状态之间切换。Linux 内核通过 task_struct 结构体描述进程并管理各种资源。当用户进程有访问底层资源或硬件的需求时,则通过系统调用进入内核空间。对于需要并发支持的进程,还可以启动内核线程。
- 内存管理:将低地址空间作为用户空间,高地址空间作为内核空间。(如 4G 内存则按 3G 用户空间及 1G 内存空间来划分)内存管理主要对页面管理、内核空间 slab、用户空间 C 库二次管理提供支持。
- 虚拟文件系统:对硬件系统进行抽象,对上层应用提供统一的操作接口,由底层文件系统,或者设备驱动中实现的 operations 结构体的操作函数提供支持。
- 网络接口包括网络协议和网络驱动程序,协议规定了通信方式,驱动程序则完成具体的通信工作。
- 进程间通信:Linux 提供了多种 IPC 方式,如信号量、共享内存、消息队列、管道、UNIX 域套接字 等。Android 中则还提供了 Binder 的 IPC 方式。
Linux 内核的配置系统包括 3 个部分:Makefile、Kconfig 配置文件、配置工具。
【Linux 内核的引导】
对于 RAM Linux 来说,Soc 一般都内置了 bootrom ,上电后 CPU0 去执行 bootrom,再由 bootrom 引导 bootloader。其他 CPU 则会进入 WFI 状态等待 CPU0 的唤醒。bootloader 会去引导内核启动,在这个启动阶段 CPU0 使得用户空间的 init 程序被调用,派生出其他进程。启动的过程中,CPU0 还会唤醒其他 CPU 均衡负载。
zImage 内核镜像实际上包括未被压缩的解压算法和被压缩的内核组成。程序从 bootloader 跳入 zImage 后,调用 zImage 中的解压算法,将内核解压出来。
Linux 内核模块
要想将自己的功能包含到内核中,可以有两种方式:直接编入内核、编译为模块导入。编译为模块的方式,能够使内核更加简洁,而且使系统更加灵活。模块通常会包括:模块加载函数、模块卸载函数、模块许可证声明、模块参数(可选)、模块导出符号(可选)、模块作者等信息声明(可选)等。
模块参数可以在 insmod 时传递:insmod
模块导出符号是指将本模块的符号导出到内核符号表中,供其他模块使用。
Linux 文件系统与设备文件
Linux 系统的字符设备和块设备都体现 “一切皆文件” 的设计思想。Linux 系统对文件的基本操作包括:创建、打开、读写、定位、关闭等。系统调用和 C 库文件在文件操作 API 的定义上有些许不同。
Linux 的目录中,包括真实存在的文件系统,以及虚拟文件系统。
虚拟文件系统是应用程序和驱动程序之间的桥梁,二者之间的沟通就是通过 VFS 提供的设备节点来实现的。VFS 和 驱动之间则通过驱动程序提供的 file_operations 来沟通。对于块设备,可以有两种访问方法:一是跨过文件系统直接访问裸设备,通过 Linux 统一的 def_blk_fops 这一 operations 来实现;二是借助文件系统访问块设备。文件系统会把对文件的读写转换为对块设备原始扇区的读写。
驱动程序设计中,最关心与文件相关的两个结构体:file、inode。file 结构由内核创建,其成员与各个文件操作相关。inode 结构包含了访问权限等许多文件信息,对于设备文件,该包括了设备编号(高12位主设备号+低20位次设备号)。主设备号则对应一类驱动,次设备号则描述一个具体的设备。
在 Linux 2.4 版本的内核中引入了 devfs 来管理设备驱动,它能够自动在驱动初始化时创建节点,在卸载时删除节点,自动分配主设备号,并且为用户空间提供修改驱动所有者和权限的方法。而在 Linux 2.6 版本的内核中,udev 取代了 devfs。取代的理由是,devfs 的灵活性不符合内核设计中对于机制固定的要求,应当到用户空间中去实现对设备驱动的管理。
udev 工作在用户空间,在设备加入或移除时通过 netlink 发送热插拔事件 uevent,根据内核反馈的信息来完成节点创建等内容。与 devfs 在访问设备的时候采取加载驱动的方式不同,udev 在发现设备的时候就会去加载对应的驱动。
sysfs 文件系统也是一种虚拟文件系统,产生所有硬件的层级视图,其下包括块设备、总线类型、设备类型、所有设备等目录。所有设备和驱动都会挂到总线上,由总线负责匹配。在 Linux 内核中,具体使用 bus_type、device_driver、device 来描述总线、驱动和设备。驱动和设备分开来注册,注册时并要求另一方已经存在。当设备或驱动在内核中注册时,内核都会借助 bus_type 的 match( ) 成员来对两者进行绑定。
在配置 udev 时,每一行代表一个配置规则,包括匹配部分和赋值部分。匹配部分理解为固定项选择,赋值部分理解为用户传递值(如设备文件名称等)。udev 的赋值功能使得内核能够根据设备的序列号或其他固定信息来进行确定的映射,避免了 devfs 中的不确定映射带来的用户无法直接确定物理设备对应的设备文件这一方面的困扰。
Android 中没有采取 udev,而是使用了 vold,它们的机制是一样的。在 vold 中,也是监听基于 netlink 的套接字(NetlinkManager.cpp 中实现),解析收到的消息。
字符设备驱动
内核通过 cdev 结构描述一个字符设备,cdev 结构包含两个重要的成员:驱动设备的设备号(12位主设备号+20位次设备号)、file_operations 接口。字符设备最先经历 init 阶段,然后按照用户程序的调用对设备文件节点进行各种操作,最后在需要的时候 exit 设备。下面围绕 globalmem 字符设备来描述驱动的一般结构。
globalmem 驱动在内核中使用 globalmem_dev 结构来描述,它包含两个成员:设备号、用于数据交换的 char 数组。globalmem 设备文件的 file 句柄中的 private_data 成员将指向 globalmem_dev 结构。
在驱动设备的 init 入口函数中,将会通过 register_chardev_region 或 alloc_chrdev_region 去申请设备号。register_chardev_region 是使用指定的设备号去申请,alloc_chrdev_region 则是由系统自动分配并返回结果。两者在程序中可以结合起来用,先指定申请,失败后让系统分配。init 入口还进行了 file_operations 结构的绑定。最后,要调用 cdev_add 函数,向内核注册这个字符设备。
在 exit 出口函数中,则是要先通过 cdev_del 让内核注销此设备,再释放占用的设备号。
file_operations 中的各个成员是与文件操作对应的回调函数,由驱动程序实现与文件操作对应的具体的驱动操作函数。驱动程序运行于内核空间,在操作用户空间缓冲区时需要使用 access_ok 函数来判断传输的地址参数是否属于用户空间,避免因传入地址错误而错误地向内核空间写入数据。
驱动设备的 ioctl 函数,一般通过对接收的 cmd 参数进行 switch 分支判断,来根据命令执行不同的操作。对于 ioctl 命令格式,ioctl 有推荐的命令格式:数据类型(8 位)+ 序列号(8 位)+ 方向(2 位)+ 数据尺寸(13/14位)。内核中也提供了特殊的宏来生成 iotcl 命令。
Linux 设备驱动中的并发控制
在 Linux 内核中,广泛存在多个执行单元对共享资源的同时访问,这些并发让 Linux 系统陷入竞态。Linux 内核中的竞态主要发生在:多 CPU 核之间、单 CPU 核的进程之间、中断与进程之间。解决竞态问题的方法是保证对共享资源的互斥访问,Linux 中将访问共享资源的代码区域称为临界区,提供了中断屏蔽、原子操作、自旋锁、信号量、互斥体等互斥访问方式。
Linux 内核的锁机制是针对编译器的编译乱序和处理器的执行乱序去实现的。
编译乱序是由于 CPU 不考虑线程安全,仅以单线程视角去优化指令排序。解决编译乱序的方法是在内嵌汇编中使用编译屏障 barrier( ) ,以阻止编译器仅 barrier( ) 之后的代码进行优化。而与此功能相似的 volatile 关键字只是避免内存访问行为的合并,不具备保护临界资源的作用。
执行乱序是指处理器在执行指令时,根据资源的实际情况,调整了访存指令的执行顺序。解决执行乱序的方法是内存屏障:DMB(数据内存屏障)、DSB(数据同步屏障)、ISB(指令同步屏障)。
中断屏障是指通过关闭 CPU 对中断的响应,避免了 CPU 进程和中断之间的并发。但长时间屏蔽中断在 Linux 系统中是非常危险的,这就要求屏蔽中断后要尽可能快地执行完临界区代码。屏蔽中断的方法只能屏蔽本 CPU 内的中断,而无法解决多 CPU 引发的竞态。中断屏蔽适合与自旋锁一起使用。
local_irq_diable() /* 屏蔽中断 */
...
critical section /* 临界区 */
...
local_irq_enable() /* 开中断 */
原子操作由内核提供,可以对位或整型进行排他性修改,依赖于 CPU 自身的原子操作,在 ARM 处理器中是 LDREX 和 STREX 指令。LDREX 指令设置访问标志,STREX 则用于取消访问标志并执行存储行为。重点在于 STREX 只在存在访问标志时才能执行成功并完成存储操作。否则就会重新进入 LDREX 到 STREX 的循环尝试。原子操作能够同时满足多核之间并发和单核内部并发的竞态。
自旋锁依赖于硬件上的原子操作访存实现(测试并设置某内存变量),保证只有一个 CPU 能够获得锁,无法正在请求此锁的 CPU 则会因获取失败而循环地进行获取锁的尝试。多核 CPU 系统中,一个拿到自旋锁的 CPU 会禁止本 CPU 上的抢占调度。这就是基础自旋锁的实现原理。为了确保自旋锁不被中断和底半部影响,基础自旋锁还需要配合中断屏蔽,构成衍生的自旋锁。对于衍生的自旋锁,单 CPU 占用的特性避免了核间并发,补充的中断屏蔽则避免了核内的并发。
自旋锁的自旋等待特性要求使用者不能长时间占用一个自旋锁,并且要避免诸如对一个自旋锁二次请求之类的局面,防止造成死锁。
/* 基础自旋锁的使用 */
spinlock_t lock;
spin_lock_init(&lock);
spin_lock(&lock);
... /* critical section */
spin_unlock(&lock);
/* 衍生的自旋锁 */
spin_lock_irq() = spin_lock() + local_irq_disable()
spin_unlock_irq() = spin_unlock() + local_irq_enable()
spin_lock_irqsave() = spin_lock() + local_irq_save()
spin_lock_irqrestore() = spin_unlock() + local_irq_restore()
spin_lock_bp() = spin_lock() + local_bh_disable()
spin_unlock_bp() = spin_unlock() + local_bh_enable()
为了以更细的粒度管理读和写,衍生出了读写自旋锁。读写自旋锁允许读操作的同时进行,但拒绝同时同时写以及边读边写的操作。
顺序锁在读写锁的基础上,放宽了对于写时读的限制,但依旧禁止同时写的操作。在读取被顺序锁保护的资源后,要检查读期间是否有过写操作,如果有,则要重新读。
RCU (Read-Copy-Update)不使用互斥访问方法,而是在修改共享资源时,先拷贝一份副本,在副本上进行修改,再用修改后的副本替代原始资源。在修改完成之前原始资源的程序仍旧能够访问原始资源,直到原始资源不再被使用,系统就会自动释放。这个过程实际上和 Java 的垃圾回收机制很相似。
信号量的操作对应操作系统的 PV 概念,信号量的值可以是 0、1、N 。在信号量为 0 时,进程等待状态,等待资源可用。
互斥体类似于信号量,其实现依赖于自旋锁。互斥体的机制是以进程的角度实现的,当进程无法获取到资源时,则发生上下文切换。从 CPU 的角度来说,上下文切换造成的开销也是很大的,所以互斥体只适宜在临界区较大的时候使用,在临界区较小的时候还是更适合使用自旋锁。
Linux设备驱动中的阻塞与非阻塞
应用程序对设备的 IO 访问方式有阻塞和非阻塞两种。阻塞的方式是指如果用户进程无法获得目标资源,就被从运行队列移动到等待队列,直到能够得到目标资源时才被唤醒重新调度。进程在阻塞后一般进入休眠状态,而唤醒该进程的动作则一般发生在中断中,因为获得硬件资源通常也伴随着一个中断。非阻塞的方式,则是在发现资源无法获得后直接返回,将资源不可获得这一情况告知应用程序。
用户程序使用 open 接口访问文件时,默认为阻塞方式,可以传递 flag 标志 O_NONBLOCK 来选择为非阻塞的访问方式。在打开了文件之后,也可以通过 ioctl 和 fcntl 接口来设置文件的 IO 访问方式。
阻塞发生时,用户进程会被移动到等待队列。等待队列由内核拥有,队列中的每个元素都对应一个进程,这些进程都在等待驱动程序所管理的设备资源可用。在设备资源可用时,设备通知内核,内核将等待该设备资源的等待队列中的进程全部唤醒,然后让这些进程就绪执行。
在驱动程序中,等待队列一般在 init 函数中创建。在执行读写操作时,先为本用户进程创建一个队列元素,并加入等待队列。若发生资源不可用而阻塞等待,需要先改变进程状态为 TASK_INTERRUPTIBLE 并释放互斥体(避免死锁),再启用 schedule( ) 请求内核进行调度。(发起请求后不一定会马上失去 CPU 的执行权。)在资源可访问后,驱动则会继续去执行设备资源的访问,访问完成后,将进程唤醒(本质是将进程移出等待队列,并将其状态修改为 TASK_RUNNING)。
__set_current_state(TASK_INTERRUPTIBLE); /* 给进程一个浅度睡眠标记 */
mutex_unlock(&dev->mutex); /* 释放互斥体,避免其他进程无法访问驱动,造成死锁 */
schedule(); /* 请求CPU执行调度,进程进入睡眠 */
需要注意的是,等待队列并不是只有一个,等待不同的资源一般会加入不同的等待队列。而且,同一个设备的读操作和写操作,一般也区分为两个不同的等待队列。
而如果需要在一个用户进程中监听多个路 IO 设备时,则可以通过 select & poll 轮询机制来实现。
select 使用于用户空间,可以监听读、写、异常(readfds、writefds、exceptfds)三种文件描述符集合,用户可以传入 timeout 参数限制轮询时间。第一次执行 select时,若没有满足要求的文件,则直接返回。再次调用 select 时,如果依旧没有文件可读写,则会让用户进程阻塞并睡眠,,并将此进程挂到每个驱动的等待队列中。
poll 则用于内核空间的驱动程序中,驱动程序需要在 file_operations 中实现 poll( ) 的回调。驱动程序的的 poll 回调中,借助 poll_wait 函数,将等待队列传递给系统的 poll_talbes,并返回一个包含设备资源可获取状态的掩码位图(POLLIN、POLLOUT、POLLPRI、POLLERR、POLLNVAL等的“位或”)。
对于高并发的多路 IO 监听,用户空间中则不太适合使用 select,而是更适合采取 epoll。epoll 的优点在于不会因为待监视的 fd 数量的增长而降低效率。
(书中此处并未解释内核空间驱动程序中为什么要传递 poll_tables,也没有具体解释为什么 epoll 就没有 select 的缺点。待研究)