计算机系统的运转须要软件和硬件共同参与,硬件是底层基础,软件则实现了具体的应用。硬件和软件之间则通过设备驱动来联系。在没有操作系统的情况下,工程师可以按照硬件设备的特性自行定义插口。而在有操作系统的情况下,驱动的构架则由相应的操作系统来定义。驱动存在的意义就是给下层应用提供便利。
驱动针对的对象是储存器和外设。Linux将储存器和外设分为3个基础大类:字符设备、块设备、网络设备。
字符设备和块设备都被Linux映射到文件系统的文件和目录中,通过文件系统的插口(open、read、write、close等)来访问。其中,块设备可以通过类似dd命令对应的原始块设备来访问,也可以通过构建文件系统,以文件路径来访问。
学习Linux设备驱动,要求特别好的硬件基础、非常好的软件基础、一定的Linux内核基础和特别好的多任务并发控制和同步的基础。学习Linux设备驱动要将学习的函数、数据结构等放在整体构架中去理解,能够理清驱动中各组成部份之间的关系。
驱动设计的硬件基础
驱动工程师须要把握处理器、存储器、接口和总线、可编程门电路、原理图、硬件时序、芯片指南、仪器使用等方面的内容。
【处理器】
处理器分为冯诺依曼结构和耶鲁结构。冯诺依曼结构将指令和数据放到一起储存,两者数据长度相同。耶鲁结构则将数据和指令分开储存,两者各自使用独立的总线。
处理器在不同的领域上也有不同的设计。数字讯号处理器(DSP)用于通讯、图像、语音、视频处理等算法处理,对于复杂运算进行了优化。DSP分为定点DSP、浮点DSP两种。网路处理器用于联通领域上,一般包括微码处理器和若干硬件协处理器。
【存储器】
储存器可分为只读储存器(ROM)、闪存(Flash)、随机存取储存器(RAM)、光/磁介质储存器。
现今常见的ROM早已是十分便捷的E2PROM了,完全可以用软件来擦写。
闪存可以分为NORFLASH和NANDFLASH两种。NORFLASH可在芯片内执行程序,而NANDFLASH须要相应的控制电路进行转换。NORFLASH和CPU是典型的类SRAM插口。
FLASH的擦写只能将1写成0。所以FLASH的擦写须要先对块擦除(全写为1),再在要求的位置写0。FLASH的擦写还须要注意防止反复擦写同一个块,防止出现坏块。
NORFLASH可以使用SPI进行访问以节约引脚。NANDFLASH容量大,价钱低,但更容易发生数据错误,应当使用错误侦测/错误更正算法(EDC/ECC)。
FLASH可以通过CFI(CommonFlashInterface)或JEDEC(JointElectronDeviceEngineeringCouncil)插口来读出设备信息。
RAM包括静态RAM(SRAM)和动态RAM(DRAM)两种。DDRSDRAM也属于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总线上的任何就能发送和接收的设备都能成为主控设备。
USB1.1包含全速和低速两种模式。在USB2.0中,降低了高速模式,半双工工作,数据传输率可达480Mbit/s。USB3.0全双工工作,数据传输率高达5.0Gbit/s。
当电路板须要挂载USB设备时,须要提供USB主机控制器和联接器。若作为USB设备,则须要提供USB适配器和联接器。USB总线具备热拔插的能力。
【原理图剖析】
原理图的基本剖析方式是以主CPU为中心,向储存器和外设幅射。对于CPU重点了解片选、中断、集成的外设控制器。
硬件原理图包含的元素:符号(如PIN脚符号)、网络(芯片、器件等之间的互联关系)、描述(模块功能辅助描述)。
【硬件时序】
硬件时序并不是特别重点的一项,但若果须要进行电路板调试就有必要把握。最精典的硬件时序是SRAM的读写时序,过程中涉及地址、数据、片选、读/写、字节使能、就绪/忙等讯号。NORFlash以及许多外设控制器都采取了类似的时序,所以该时序有必要把握。
【芯片指南阅读技巧】
正确阅读技巧:快速确切地定位有用信息,重点阅读这部份,忽视无关内容。
在芯片指南中,OVERVIEW十分有必要阅读,它会告知产品或芯片的整体信息和结构。
MemoryMap也比较重要,对工程师了解储存器和外设的基址很有帮助。
对于各类外设插口的信息,应该在编撰某个插口驱动时去了解对应部份,通常是剖析数据、控制、地址寄存器的访问控制和具体设备的操作流程。
【仪器使用】
常规会接触到的仪器包括万用表、示波器、逻辑仪等。
Linux内核及内核编程
【Linux内核的子系统】
Linux也是一种类Unix系统,由Linus参照Minix系统开发而至。Linux受GNU计划的广大开源程序以及互联网上广大开发者的支持,不断依据POSIX标准优化自身设计,早已成为享誉世界的操作系统。驱动编程的本质是内核编程高剑林linux内核探秘:深入解析文件系统和设备驱动的架构与,Linux内核包括进程调度、内存管理、虚拟文件系统、网络插口、进程间通讯这五大子系统。
Linux内核的配置系统包括3个部份:Makefile、Kconfig配置文件、配置工具。
【Linux内核的引导】
对于RAMLinux来说,Soc通常都外置了bootrom,上电后CPU0去执行bootrom,再由bootrom引导bootloader。其他CPU则会步入WFI状态等待CPU0的唤起。bootloader会去引导内核启动,在这个启动阶段CPU0促使用户空间的init程序被调用,派生出其他进程。启动的过程中,CPU0就会唤起其他CPU均衡负载。
zImage内核镜像实际上包括未被压缩的解压算法和被压缩的内核组成。程序从bootloader坠入zImage后,调用zImage中的解压算法,将内核解压下来。
Linux内核模块
要想将自己的功能包含到内核中,可以有两种形式:直接编入内核、编译为模块导出。编译为模块的形式,才能使内核愈发简练,并且使系统愈发灵活。模块一般会包括:模块加载函数、模块卸载函数、模块许可证申明、模块参数(可选)、模块导入符号(可选)、模块作者等信息申明(可选)等。
模块参数可以在insmod时传递:insmod
=。也可以由bootloader通过bootargs进行传递。
模块导入符号是指将本模块的符号导入到内核符号表中,供其他模块使用。
Linux文件系统与设备文件
Linux系统的字符设备和块设备都彰显“一切皆文件”的设计思想。Linux系统对文件的基本操作包括:创建、打开、读写、定位、关闭等。系统调用和C库文件在文件操作API的定义上有些许不同。
Linux的目录中,包括真实存在的文件系统,以及虚拟文件系统。
虚拟文件系统是应用程序和驱动程序之间的桥梁,两者之间的沟通就是通过VFS提供的设备节点来实现的。VFS和驱动之间则通过驱动程序提供的file_operations来沟通。对于块设备,可以有两种访问方式:一是越过文件系统直接访问裸设备,通过Linux统一的def_blk_fops这一operations来实现;二是利用文件系统访问块设备。文件系统会把对文件的读写转换为对块设备原始磁道的读写。
驱动程序设计中,最关心与文件相关的两个结构体:file、inode。file结构由内核创建,其成员与各个文件操作相关。inode结构包含了访问权限等许多文件信息,对于设备文件,该包括了设备编号(高12位主设备号+低20位次设备号)。主设备号则对应一类驱动,次设备号则描述一个具体的设备。
在Linux2.4版本的内核中引入了devfs来管理设备驱动,它还能手动在驱动初始化时创建节点,在卸载时删掉节点,手动分配主设备号,但是为用户空间提供更改驱动所有者和权限的方式。而在Linux2.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结构的绑定。最后linux系统安装,要调用cdev_add函数,向内核注册这个字符设备。
在exit出口函数中,则是要先通过cdev_del让内核注销此设备,再释放占用的设备号。
file_operations中的各个成员是与文件操作对应的反弹函数,由驱动程序实现与文件操作对应的具体的驱动操作函数。驱动程序运行于内核空间,在操作用户空间缓冲区时须要使用access_ok函数来判定传输的地址参数是否属于用户空间高剑林linux内核探秘:深入解析文件系统和设备驱动的架构与,防止因传入地址错误而错误地向内核空间写入数据。
驱动设备的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()
为了以更细的细度管理读和写,衍生出了读写载流子锁。读写载流子锁容许读操作的同时进行,但拒绝同时同时写以及边读边写的操作。
次序锁在读写锁的基础上,放宽了对于写时读的限制,但仍然严禁同时写的操作。在读取被次序锁保护的资源后,要检测读期间是否有过写操作,假如有linux是什么系统,则要重新读。
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的缺点。待研究)