第一章 设备驱动程序的简介
处于上层应用与底层硬件设备的软件层
区分机制和策略是Linux最好的思想之一,机制指的是需要提供什么功能,策略指的是如何使用这个功能!
通常不同的环境需要不同的方式来使用硬件,则驱动应当尽可能地不实现策略.
驱动程序设计需要考虑一下几个方面的因素:
① 提供给用户尽量多的选项
② 编写驱动程序所占用的时间,驱动程序的操作耗时需要尽量缩减.
③ 尽量保持程序简单
内核概览:
① 进程管理:
负责创建和销毁进程,并处理它们和外部世界之间的连接(输入输出),调度器也属于进程管理中的
② 内存管理:
内核在有限的可用资源上为每一个进程都创建了一个虚拟地址空间
③ 设备管理:
几乎所有的系统操作都会映射到具体的硬件设备上
④ 文件系统:
一切皆文件,一个kernel可能包含多个文件系统,比如 存储硬盘被抽象成ext3文件系统
⑤ 网络:
网络功能必须用操作系统来管理,因为大部分的网络操作和具体的进程无关:数据包的传入是异步事件
设备以及模块的分类:
模块:
内核模块可以随时载入到系统中,即便系统已经处于运行状态,通常使用的方法是insmod加载,rmmod卸载
字符模块,块模块,网络模块,这三个模块并不是必须单独存在的,在设计模块的时候,常常需要对于同一
个硬件设备或者功能定义成不同的模块,这样可以增强后期的扩展性。
设备:
字符设备: 那些可以以字节流的方式进行访问的设备, 字符设备与常规的文件的区别在于字符设备只是数据管道,另外,字符
设备也可以被看成数据区
块设备:块设备也是通过文件节点进行操作的,比如 disk硬件就是属于块设备并且挂载了一个文件系统.
和字符设备类似,块设备也是通过/dev 目录下的文件系统节点来访问。另外,Linux可以让应用
程序向字符设备一样地读写块设备,允许一次传递任意多字节的数据,那两者的区别是什么呢?
两者的区别仅仅在于内核内部管理数据的方式,也就是内核以驱动程序间的软件接口。
网络接口:
网络接口由内核中的网络子系统驱动,负责发送和接收数据包。由于不是面向流的设备,因此无法将网络设备映射到/dev 下
系统节点,所以内核和网络设备驱动程序间的通讯完全不同于内核和字符以及块驱动程序之间的通讯,内核拥有一套和数据包传输
相关的函数。
文件系统:
文件系统也是内核模块化的表现,它决定了如何在块设备上组织数据,以表示目录和文件形成的树,但是文件系统因为没有实际物理设备所以
并不是设备驱动程序,相反,文件系统是一个软件驱动程序,它将低层数据结构映射到高层数据结构,决定文件名以及在目录项中存储文件的
哪些信息等等。
文件系统模块必须实现访问目录和文件的最底层系统调用,方法是将文件名和路径(以及其他一些信息,比如访问模式等)映射到数据块中的数据
结构中,这种接口完全独立于在磁盘上传输的实际数据,而数据的传输由块设备驱动程序负责完成。
驱动程序编写者应当尽量避免在代码中实现安全策略,安全策略问题最好在系统管理员的控制之下,由内核的高层来实现。
任何从内核中得到的内存,都必须在提供给用户进程或者设备之前清零或者以其他方式初始化,否则就可能发生信息泄露(如数据和密码的泄露等)。
Linux内核可以编译为不支持模块方式,从而可以关闭任何模块相关的安全漏洞。
第二章 构造和运行模块
内核模块与应用程序的对比:
大多数应用程序是从头到尾执行单个任务,而内核模块却只是预先注册自己以便服务需要的某个请求,比如初始化函数的任务就是为了以后
调用模块函数预先作准备。
应用程序在退出时,可以不管资源的释放或者其他的清除工作,但内核模块的退出函数却必须仔细撤销初始化函数所做的一切,否则,在系统
重新引导之前某些东西就会残留在系统中。
应用程序可以调用它并未定义的函数,主要原因是链接过程能够解析外部引用从而使用适当的函数库。然后,内核模块仅仅被链接到内核,所以
它仅仅能够调用由内核导出的那些函数,而不存在任何可链接的函数库。
在各环境下的处理错误的方式两者也是不同的,应用程序开发过程中的段错误是无害的,并且总是可以使用调试跟踪到源代码中的问题所在,
而一个内核错误即使不影响整个系统,也至少会杀死当前进程。
用户空间和内核空间
模块运行在内核空间,应用程序运行在用户空间,Unix使用两种操作模式或级别来实现针对CPU的不同操作,内核运行在最高级别(也称为超级用户态),
在这个级别中可以进行所有的操作,而应用程序运行在最低级别(即所谓的用户态),在这个级别中,处理器控制着对硬件的直接访问以及堆内存的非授权
访问。两种的不同的级别是针对CPU而言的,所以当应用程序调用系统调用或者被硬件中断挂起的时候,CPU的保护级别就从最低级别硬件上切换到最高级别了。
另外,一旦从用户空间切换到内核空间,执行系统调用的内核代码运行在进程上下文中,它代表调用进程执行操作,因此能够访问进程地址空间的所有数据,
而处理硬件中断的内核代码和进程是异步的,与任何一个特定进程无关。
内核中的并发:
为什么内核编程需要考虑并发问题?
1. Linux系统中通常正在运行多个并发进程,并且可能有多个进程同时使用我们的驱动程序。
2. 大多数设备能够中断处理器,而中断处理程序异步运行,而且可能在驱动程序正试图处理其他任务时被调用
3. 部分软件抽象(内核定时器)也在异步运行着
4. 多处理器系统,可能存在多个CPU运行驱动程序
5. 内核已经实现可抢占
当前进程
struct task_struct *current;
内核代码可以通过访问全局项current来获得当前进程,而2.6后,current不再是一个全局变量(为什么呢? 因为需要支持多处理器系统),而是将
指向task_struct结构的指针隐藏在内核栈中。
其他:
应用程序在虚拟内存中布局,并具有一块很大的栈空间(栈是用来保存函数调用历史以及当前活动函数中的自动变量的),相反内核具有非常小的栈
(可能是4096字节大小),我们自己的函数必须和整个内核空间调用链一同共享这个栈,则尽量避免声明大的自动变量。
内核代码不能实现浮点数运算,为什么? 因为如果打开了浮点支持,在某些架构上需要在进入和退出内核空间时保存和恢复浮点处理器的状态,会带来一定的
额外开销,同时该开销无任何价值。
装载和卸载模块:
insmod: 将模块的代码和数据装入内核,使用sys_init_module给模块分配内核内存(vmalloc)以便装载模块,然后将模块正文复制到内存区域,并
通过内核符号表解析模块中的内核引用,最后调用模块的初始化函数。
modprobe: 和insmod类似,也是用来将模块装载到内核中,它与insmod的区别在于,它除了装入指定模块外还同时装入指定模块所依赖的其他模块。
rmmod: 从内核中移出模块
lsmod: 列出当前装载到内核中的所有模块,还提供了其他一些信息,lsmod是通过读取/proc/modules虚拟文件来获得这些信息,有关当前已装载模块的信息
也可以在sysfs虚拟文件系统的/sys/module下找到
内核符号表:
公共内核符号表中包含了所有的全局内核项(即函数和变量)的地址,当模块被装入内核后,它所导出的任何符号都会变成内核符号表的一部分。
新模块可以使用已有模块的导出的符号,这样就可以在其他模块上层叠新的模块。
modprobe 是处理层叠模块的一个实用工具。
EXPORT_SYMBOL(name)以及EXPORT_SYMBOL_GPL(name)均用来将给定的符号导出到模块外部,_GPL版本使得要导出的模块只能被GPL许可证下的模块使用,
导出到外部的变量在模块可执行文件的特殊部分(ELF段)中保存,在装载时,内核通过这个段来寻找模块导出的变量。
模块的初始化和关闭:
初始化函数应该被声明为static
__init:该标记(非必须)表明该函数仅在初始化期间使用,在模块被装载后,模块装载器就会将初始化函数扔掉,这样可将给函数占用的内存释放出来,
达到节省内存的作用
module_init: 这个宏会在模块的目标代码中增加一个特殊的段,用于说明内核初始化函数所在的位置,所以可见,如果没有这个定义,
初始化函数永远不会被调用
初始化过程中的错误处理:
内核经常使用goto来处理错误,因为对其的仔细使用可以避免大量复杂的、高度缩进的结构化逻辑。
在用户空间编写驱动程序:
通常用户空间的驱动程序被实现为一个服务器进程,其任务是替代内核作为硬件控制的唯一代理。
为什么需要在用户空间编写驱动程序?
① 可以和这整个C库链接。驱动程序不用借助外部程序就可以完成许多非常规任务
② 可以使用通常的调试器调试驱动程序代码
③ 如果用户空间程序被挂起,则简单地杀掉它就行了,驱动程序带来的问题不会挂起整个系统,除非所驱动的硬件已经发生严重故障
④ 和内核内存不同,用户内存可以换出,如果驱动程序很大但是不经常使用,则除了正在使用的情况之外,不会占太多内存。
⑤ 良好设计的驱动程序仍然支持对设备的并发访问
用户空间驱动程序缺点:
① 中断在用户空间不可用
② 只有通过mmap映射/dev/mem才能直接访问设备内存,但只有特权用户才可以执行这个操作
③ 只有在调用ioperm或iopl后才可以访问I/O端口,这两个系统调用并不是所有平台都支持,而且访问速度慢,同样也只有特权用户才能调用这两个接口
④ 响应时间慢
⑤ 如果驱动程序被换出到磁盘,响应时间会更长。
⑥ 用户空间中不能处理一些非常重要的设备,包括网络接口和块设备等
第三章 字符设备驱动程序
编写驱动程序的第一步就是定义驱动程序为用户程序提供的能力(机制)
主设备号和次设备号
对字符设备的访问是通过文件系统内的设备名称(文件系统树的节点)进行的,它们通常位于/dev目录。
ls -l : 第一列为c表示是字符设备,第一列为b表示块设备
通常而言,主设备号标识设备对应的驱动程序,次设备号由内核使用,用于正确确定设备文件所指的设备。比如设备中存在很多led,可以理解为所有的led
都是通过同一个驱动程序管理,对应的主设备号是同一个,但是不同的led设备却具有不同的次设备号。
设备编号的内部表达:
内核中,dev_t类型用来保存设备编号(主设备号和次设备号), 2.6.0 内核版本dev_t是一个32位的数,其中的12位用来表示主设备号,其余20位用来表示
次设备号。
内核重要的数据结构:
file_operations:
file_operations结构是用来建立file与驱动程序操作的连接,每一个打开的文件(内部有一个file结构表示)和一组函数关联(通过包含一个指
向file_operations结构的f_op字段)
file_operations结构介绍:
struct module *owner: 指向拥有该结构的模块的指针,内核使用这个字段以避免在模块的操作正在被使用时卸载该模块。
unsigned int (*poll) : 该方法是poll、epoll和select这三个系统调用的后端实现,这三个系统调用可用来查询某个或多个文件描述符
上的读取或写入是否会被阻塞
int (*mmap) : 该方法用于请求将设备内存映射到进程地址空间,如果设备没有实现这个方法,那么mmap系统调用将返回-ENODEV
int (*flush) : 对flush操作的调用发生在进程关闭设备文件描述符副本的时候,它应该执行设备上尚未完结的操作。
file结构:
file结构代表一个打开的文件(也可以理解为文件描述符)(它并不仅仅限定于设备驱动程序,系统中每个打开的文件在内核空间都有一个对应的file结构),它
由内核在open时创建,并传递给在该文件上进行的所有函数,直到最后的close函数。
file结构的重要成员:
loff_t f_pos:
这个成员之前一直没有注意到过,它主要用于表示当前的读/写位置.
struct file_operations *f_op:
与文件相关的操作,内核在执行open操作时对这个指针赋值,可以在任何需要的时候修改文件的关联操作,这有点而类似于面向对象编程技术中的方法重载。
inode结构:
内核用inode结构在内部表示文件,单个文件对应于唯一的inode,inode与file是一对多的关系,对于该结构可以参考linux文件系统的介绍。
字符设备的注册:
内核内部使用struct cdev结构来表示字符设备。
open方法:
open主要完成以下工作:
1. 检查设备特定的错误(诸如设备未就绪或类似的硬件问题)
2. 如果设备是首次打开,则对其进行初始化
3. 如有必要,更新f_op指针(这里就是之前所说的可以根据需要随时更新operations指针,从而实现方法重载)
4. 分配并填写至于filp->private_data里的数据结构
contianer_of(pointer, container_type, container_field):
已知结构体container_type的成员container_field的地址pointer,求解结构体container_type的结构指针.
release方法:
主要完成以下任务:
1. 释放有open分配的、保存在filp->private_data中的所有内容
2. 在最后一次关闭操作时关闭设备
read和write方法:
read: 拷贝数据到应用程序空间, write: 从应用程序空间拷贝数据
两个方法中的buff参数是用户空间的指针,内核代码不能直接引用其中的内容,主要原因如下:
① 随着驱动程序所运行的架构的不同或者内核配置的不同,在内核模式中运行时,用户空间可能是无效的,该指针地址可能根本无法被映射到内核空间
或者可能指向某些随机数据
② 即使该指针在内核空间代表相同的东西,但用户空间的内存是分页的,而在系统调用被调用时,涉及到的内存可能根本就不在RAM中,
对用户空间内存的直接引用将导致页错误。
③ 这里的指针可能是应用空间某个恶意程序调用系统调用传入的,如果在内核中直接访问则会带来系统安全性问题
read方法返回值:
① 如果返回值等于传递给read系统调用的count参数,则说明所请求的字节数传输成功完成了
② 如果返回值是正的,但是比count小,则说明只有部分数据成功传送,这种情况因设备的不同可能许多原因,大部分情况下,程序会重新
读数据。
③ 如果返回值为0,则表示已经到达了文件尾
④ 负值意味着发生了错误
⑤ 如果当前还没有数据的话,read系统调用会出现阻塞
write方法返回值:
① 如果返回值等于count,则完成了所请求数目的字节传送
② 如果返回值是正的,但小于count,则只传输了部分数据,程序很可能再次试图写入余下的数据
③ 如果返回值为0,意味着什么也没写入,这种结果不是错误
④ 负值意味发生了错误
copy_to_user(...)以及copy_from_user(...):
这两个函数首先会检查用户空间的指针是否有效,之后进行内核空间和用户空间之间的数据拷贝,否则不会进行拷贝。
这里需要注意的是,当内核空间内运行的代码访问用户空间时要多加小心,被寻址的用户空间的页面可能当前并不在内存中(当页面处于交换空间时),
于是虚拟内存子系统将该进程转入睡眠状态,直到页面被传送到期望的位置。
第四章 调试技术
内核中的调试支持
内核配置选项:
CONFIG_DEBUG_KERNEL
该选项仅仅使得其他的调试选项可用,它本身不会打开所有的调试功能
CONFIG_DEBUG_SLAB
用于打开内核内存分配函数中的多个类型的检查,打开该检查后,就可以检测许多内存溢出及忘记初始化的错误。该选项的主要原理是:
内核会将其中的每个字节设置为0xa5,而在释放后将其设置为0x6b,另外,它还会在每个已分配内存对象的前面和后面放置一些特殊的防护值,
这样,当这些防护值发生变化时,内核就可以知道有些代码超出了内存的正常访问范围。
CONFIG_DEBUG_PAGEALLOC
在释放时,全部内存页从内核地址空间中移出,该选项将大大降低运行速度,但可以快速定位特定的内存损坏错误的所在位置
CONFIG_DEBUG_SPINLOCK
打开该选项,内核将捕获对未初始化自旋锁的操作,也会捕获诸如两次解开同一锁的操作
CONFIG_DEBUG_SPINLOCK_SLEEP
该选项将检查拥有自旋锁时的休眠企图,实际上如果调用可能引起休眠的函数,这个选项也会生效,即使该函数可能不会导致真正的休眠
CONFIG_INIT_DEBUG
标记为__init(或者__initdata)的符号将会在系统初始化或者模块装载之后被丢弃,该选项可用来检查初始化完成之后对用于初始化的内存空间
的访问企图
CONFIG_DEBUG_STACKOVERFLOW
CONFIG_DEBUG_STACK_USAGE
这些选项可帮助跟踪内核栈的溢出问题。
CONFIG_KALLSYMS
该选项用于调试上下文,没有此符号,oops清单只能给出十六进制的内核反向跟踪信息。
CONFIG_IKCONFIG
CONFIG_IKCONFIG_PROC
这些选项会让完整的内核配置状态包含到内核中,并可通过/proc访问。
CONFIG_ACPI_DEBUG
该选项将打开ACPI(高级配置和电源接口)中的详细调试信息。
CONFIG_DEBUG_DRIVER
该选项会打开驱动程序核心中的调试信息
CONFIG_SCSI_CONSTANTS
打开详细的SCSI错误消息
CONFIG_INPUT_EVBUG
打开对输入事件的详细记录,该选项会导致一定的安全问题(会记录用户键入的任何东西,包括密码)
CONFIG_PROFILING
用于系统性能的调节
通过打印调试:
printk 与printf的差异:
printk与printf最大的不同在于printk缺乏对浮点数的支持
通过附加不同日志级别可让printk根据这些级别所表示的严重程度对消息进行分类。
例如KENR_INFO 表示日志界别的宏,该宏会展开为一个字符串,在编译时预处理器会将它和消息文本拼接在一起,这也是为什么
printk中的优先级和格式字串间没有逗号。
日志级别:
KERN_EMERG: 用于紧急事件消息,一般是系统崩溃之前提示的消息,值为0
KERN_ALERT: 用于需要立即采取动作的情况,值为1
KERN_CRIT: 临界状态,通常涉及验证的硬件或软件操作失败,值为2
KERN_ERR: 用于报告错误状态,例如驱动程序一般使用该宏来报告来自硬件的问题,值为3
KERN_WARNING: 对可能出现问题的情况进行警告,但这类情况通常不会对系统造成严重问题,值为4
KERN_NOTICE: 有必要进行提示的正常情形,许多与安全相关的状况用这个级别进行汇报,值为5
KERN_INFO:提示性信息,值为6
KERN_DEBUG: 用于调试信息,值为7
一般情况下,未指定优先级的printk语句采用的是KERN_WARNING,这个默认值可以在系统中进行人为修改,也可以通过对
文本文件/proc/sys/kernel/printk的访问来读取和修改控制台日志级别,这个文件包含了4个整数值,分别是: 当前的日志
级别、未明确指定日志级别时的默认消息级别、最小允许的日志级别以及引导时的默认日志级别。
向该文件写入单个数值,将会把当前日志级别修改至该值,例如,可以简单地输入下面的命令使所有的内核消息显示到控制台上:
echo 8 > /proc/sys/kernel/printk
重定向控制台消息:
内核可以将消息发送到一个指定的虚拟控制台
消息如何被记录
printk函数将消息写到一个长度为__LOG_BUF_LEN字节的循环缓冲区中(可以在配置内核时指定__LOG_BUG_LEN为4 KB~1MB之间的值),然后
该函数会唤醒任何正在等待消息的进程,即那些睡眠在syslog系统调用上的进程或者正在读取/proc/kmsg的进程。
对/proc/kmsg进行读操作时,日志缓冲区中被读取的数据就不再保留,而syslog系统调用却能通过选项返回日志数据并保留这些数据,
以便其他进程也能使用。
开启及关闭消息:
printk调试技巧:
① 通过在宏名字中删减或增加一个字母来启用或者禁用每一条打印语句
② 在编译前修改CFLAGS变量,则可以一次禁用所有消息
③ 同样的打印语句可以在内核代码中也可以在用户级代码使用
这里记录下内核中定义CFLAGS的代码:
DEBFLAGS = -O -g -DSCULL_DEBUG
CFLAGS += $(DEBFLAGS) //这样就可以在内核代码中判断SCULL_DEBUG宏定义了
速度限制
为了避免过多的打印信息,可以使用以下方法来解决:
int printk_ratelimit(void): 在打印一条可能被重复的信息之前,调用这个方法,如果返回值为非零值,则表示之前没有打印过,否则就表示
之前已经打印成功,我们应当跳过该printk,该方法通过跟踪发送到控制台消息数量工作,如果输出的速度超过一个阈值,则该方法将返回零,
从而避免发送重复消息。
/proc/sys/kernel/printk_ratelimit: 在重新打开消息之前应该等待的秒数
/proc/sys/kernel/printk_ratelimit_burst: 在进行速度限制之前可以接受的消息数量
通过查询调试
printk的缺点:
由于syslgd会一直保持对其输出文件的同步刷新,即使可以降低console_loglevel以避免装载控制台设备,但大量使用prink仍然会显著降低系统
性能
使用/proc文件系统
/proc文件系统是一种特殊的、由软件创建的文件系统,内核使用它向外界导出信息。
/proc下面的每一个文件都绑定于一个内核函数,用户读取其中的文件时,该函数动态地生成文件的内容。
注意: 对于之后的内核版本,内核开发者尽量避免使用/proc导出信息,而选择通过sysfs来向外界导出信息!
在/proc中实现文件
为创建一个只读的/proc 文件,驱动程序必须实现一个read_proc函数,用于在读取文件时生成数据。
当某个进程读取这个文件时(使用read系统调用),读取请求会通过这个函数发送到驱动程序模块,此时内核会分配一个
内存页(即PAGE_SIZE字节的内存块),驱动程序可以将数据通过这个内存页返回到用户空间
创建自己的/proc文件
create_proc_read_entry将read_proc函数指针关联到/proc 节点,remove_proc_entry便是相应的撤销create_proc_read_entry所做的工作的函数。
为什么现在内核不鼓励使用/proc文件,原因主要有以下几点:
① 删除操作可能在文件正在被使用时发生
② 内核不会检查某个名称是否已经被注册,所以如果不小心,将可能导致两个或多个入口项具有相同的名字。
seq_file接口
/proc下大文件的实现有些笨拙,为了让内核开发工作更加容易,现增加了seq_file接口,这一接口为大的内核虚拟文件提供了一组简单的函数。
如果用户的/proc文件包含大量的输出行,则建议使用seq_file接口来实现该文件
通过监视调试
strace命令是一个功能非常强大的工具,它可以显示由用户空间程序所发出的所有系统调用。
strace有许多命令行选项:
-t 显示调用的发生的时间
-T 显示调用所花费的时间
-e 限定被跟踪的调用类型
-o 将输出重定向到一个文件中,默认情况下,strace将跟踪信息打印到stderr上
strace 使用 strace ls /dev
调试系统故障
如果故障发生在驱动程序中,通常会导致进程被终止,大多数情况下的驱动故障都可以恢复,而唯一不可恢复的损失是,此时为进程上下文分配的一些内存可能会丢失
oops消息
大部分的错误都是因为对NULL指针取值或因为使用了其他不正确的指针值,这些错误通常会导致一个oops消息
由处理器使用的地址几乎都是虚拟地址,这些地址(除了内存管理子系统本身所使用的物理内存之外)通过一个复杂的被称为"页表"的结构
被映射为物理地址。当引用一个非法指针时,分页机制无法将改地址映射到物理地址,此时处理器就会向操作系统发出一个"page fault"的信号,
如果地址非法,内核就无法"page in"缺失页面,这时如果处理器恰好处于超级用户模式,系统就会产生一个oops。
系统挂起
如果代码进入一个死循环,内核就会停止调度,系统不会再相应任何动作。
通过在一些关键点插入schedule调用可以防止死循环,schedule函数会调用调度器,并因此允许其他进程偷取当前进程的CPU时间。但是如何进行插入呢?
SysRq魔法键,具体说明这里不记录。
调试器和相关工具
可以使用调试器来一步步地跟踪代码,查看变量和计算寄存器的值。
使用gdb,kgb: 这里不进行记录,后续使用的时候再补充
用户模式的Linux虚拟机(User-Mode Linux,UML)
作为一个独立的、可移植的Linux内核而被创建,包含在子目录arch/um中,可以理解为将一个内核副本当做用户模式下的进程。对于内核开发
人员来说UML可以很容易地利用gdb或者其他调试器来进行调试,所以UML理论上可以加快内核的开发过程.但是其缺点也很明显,无法进行硬件相关
的操作.
动态探测
可在系统的几乎任何一个地方放置一个探针,既可以是用户空间也可以是内核空间,这个探针有一些特殊代码组成,当控制到达给定点时,这些代码开始
执行,这种代码能向用户空间汇报数据、修改寄存器,或者完成许多其他工作.
该方法有点儿类似与prink功能,过多的处理会拖慢整个系统,所以慎用.
第五章 并发和竞态
并发及其管理
并发来源:
① 多进程调用
② 多处理器系统
③ 内核代码是可抢占的
④ 设备中断
⑤ 延迟代码执行机制(workqueue/tasklet/timer)
信号量和互斥体
休眠:
当一个Linux进程到达某个时间点,此时它不能进行任何处理时,它将进入休眠(或阻塞)状态,这将把处理器让给其他执行线程直到
将来它能够继续完成自己的处理为止。
信号量:
一个信号量本质上是一个整数值,它是一对函数联合使用,这一对函数通常称为P和V,希望进入临界区的进程将在相关信号量上调用P,
如果信号量的值大于零,则该值会减小一,而进程可以继续,相反,如果信号量的值为零(或者更小),进程必须等待直到其他人释放
该信号量,对信号量的解锁通过调用V完成,该函数增加信号量的值,并在必要时唤醒等待的进程。
当信号量用于互斥时(既避免多个进程同时在一个临界区中运行),信号量的值应初始化为1。这种信号量在任何给定时刻只能由单个进程或线程
拥有,在这种使用模式下,一个信号量有时也称为一个互斥体(mutex),Linux内核中几乎所有的信号量运用于互斥。
Linux信号量的实现
void sema_init(struct semaphore *sem, int val): 内核创建信号量
读取者/写入者信号量
允许多个并发的读取者是可行的,只要它们之中没有哪个要做修改,这样做可以大大提高性能。
Linux内核提供了一个特殊的信号量类型: rwsem(reader/writer sempaphore, 读取者/写入者信号量)
rwsem 初始化:void init_rwsem(struct rw_semaphore *sem)
一个rwsem 可允许一个写入者或者无限多个读取者拥有该信号量,写入者具有更高的优先级,当某个给定写入者试图进如临界区时,在所有写入者完成其
工作之前,不会允许读取者获得访问。如果有大量的写入者竞争该信号量,则这种实现会导致读取者”饿死“,即可能会长期拒绝读取者的访问,为此,
最好在很少需要写访问者只会短期拥有信号量的时候使用rwsem.
completion
completion是一种轻量级的机制,它允许一个线程告诉另一个线程某个工作已经完成。
一个completion通常是一个单次设备(只会被使用一次然后被丢弃),但是如果没有使用complete_all,则可以重复使用,否则就必须重新初始化才能重复
使用该completion。
自旋锁
自旋锁可以在不能休眠的代码中使用,比如中断处理例程。
一个自旋锁是一个互斥设备,它只能有两个值: 锁定和解锁,通常实现为某个整数值中的单个位。如果锁可用,则锁定位被设置,而代码继续进入临界区;
相反,如果锁被其他部分获得,则代码进入忙循环并重复检查这个锁,直到该锁可用为止,这个循环就是自旋锁的自旋部分。其中,"测试并设置单个位"的操作
必须是原子的,这样即使有多个线程在给定时间自旋,也只有一个线程可获得该锁。
自旋锁和原子上下文
如果驱动程序A获得了一个自旋锁,然后在临界区开始了它的工作,如果意外地在临界区内发生了休眠或者内核强占,让出了cpu,这样就会造成其他
试图拥有该自旋锁的驱动程序会一直等待直到A释放掉自旋锁,最坏的情况,可能会引起整个系统进入死锁状态,所以对于自旋锁有以下规则:
自旋锁的核心规则:
① 任何拥有自旋锁的代码都必须是原子的,不能进行休眠,事实上,它不能因为任何原因放弃处理器,除了服务中断以外(某些情况下此时也
不能放弃处理器)
② 自旋锁必须在可能的最短时间拥有,即拥有锁的时间越短越好。原因如下:
拥有自旋锁的时间越长,其他处理器不得不自旋以等待释放该自旋锁的时间就越长,而它不得不永远自旋的可能性就越大,从而阻止对当前
处理器的调度。
读取者/写入者自旋锁
与信号量类似,该锁允许任意数量的读取者同时进入临界区,但写入者必须互斥访问。
锁陷阱
不明确的规则
锁的顺序规则
在必须获取多个锁时,应该始终以相同的顺序获得。
细粒度锁和粗粒度锁的对比
早期存在一个大的内核锁,该锁让整个内核进入一个大的临界区,而只有一个CPU可以在任意给定时间执行内核代码。但是这种大锁机制不具有
良好的伸缩性,导致性能下降。
现在内核中存在更加细粒度的锁,但该机制又会导致系统复杂性提高。
除了锁之外的办法
免锁算法
经常用于免锁的生产者/消费者任务的数据结构之一是循环缓冲区。在这个算法中,一个生产者将数据放入数据的结尾,而消费者从数组的另一端
移走数据。
原子变量
atomic_t : 原子的整数类型,所以的CPU对于该类型数据的处理都是原子的(可能会被编译成单个机器指令)
位操作
原子位操作非常快,只要底层硬件允许,这种操作就可以使用单个机器指令来执行,并且不需要禁止中断。