第六章 高级字符驱动程序操作
ioctl方法
为了保证ioctl命令的唯一性,对于命令的定义使用了4个位字段,其含义如下:
- type
幻数,选择一个的号码(参考ioctl-number.txt),并在整个驱动程序中使用这个号码,这个字段有8位宽(
_IOC_TYPEBITS
)
- number
序数(顺序编号),它也是8位宽(
_IOC_NRBITS
)
- direction
如果相关命令涉及到数据的传输,则该位字段定义数据传输方向。可以使用的值包括
_IOC_NONE
(没有数据传输)、_IOC_READ
、_IOC_WRITE
以及_IOC_READ|_IOC_WRITE
(双向传输数据)。数据传输是从应用程序的角度看的,也就是说,IOC_READ
意味着从设备中读取数据。
- size
所涉及的用户数据大小,这个字段的宽度与体系结构有关,通常是13位或者14位,具体可通过宏
_IOC_SIZEBITS
找到针对特定体系结构的具体数值,该字段并不强制使用。
预定义命令
尽管ioctl系统调用绝大部分用于操作设备,但还有一些命令式可以由内核识别的。什么意思呢? 有一些ioctl命令是内核专有的,在下发到具体驱动程序前已经被内核拦截并处理了。
预定义命令:
- 可用于任何文件(普通、设备、FIFO和套接字)的命令,该类幻数是“T”
- 只用于普通文件的命令
- 特定于文件系统类型的命令
使用ioctl参数
access_ok
用于验证地址(而不传输数据)
access_ok
有两点需要注意:
- 该方法并没有完成验证内存的全部工作,而只是检查了所引用的内存是否位于进程有对应访问权限的区域内,特别是要确保访问地址没有指向内核空间的内存区。
- 大多数驱动程序都不需要真正显示地调用access_ok,换句话说,内存管理程序会去处理
权能与受限操作
权能(capability):Linux内核提供的一个针对权限管理的更为灵活的系统。它将特权操作划分为独立的组,这样,某个特定的用户或程序就可以被授权执行某一指定的特权操作,同时有没有执行其他不相关操作的能力。
全部权能操作见 <linux/capability.h>,对于驱动程序开发者来讲有意义的权能如下所示:
CAP_DAC_OVERRIDE
: 越过文件或目录的访问限制(数据访问控制或DAC)的能力CAP_NET_ADMIN
: 执行网络管理任务的能力,包括那些能影响网络接口的任务CAP_SYS_MODULE
: 载入或卸除内核模块的能力CAP_SYS_RAWIO
: 执行“裸”I/O操做的能力,例如,访问设备端口或直接与USB设备通信CAP_SYS_ADMIN
: 截获的能力,它提供了访问许多系统管理操作的途径CAP_SYS_TTY_CONFIG
: 执行tty配置任务的能力
在执行一项特权操作之前,设备驱动程序应该检查调用进程是否有合适的权能,使用如下函数进行检查:
int capable(int capability)
非ioctl的设备控制
有时通过向设备写入控制序列可以更好地控制设备,称为转义序列,换句话说就是通过发送约定俗成的一系列字符串到驱动程序来达到控制驱动设备的目的。
该技术的缺点是,它给设备增加了策略限制。这种技术在之前的扫描头的驱动(honeywell的扫描头)上表现得淋漓尽致。
阻塞型I/O
休眠的简单介绍
当一个进程被置入休眠时,他会被标记为一种特殊状态并从调度器的运行队列中移走,直到某些情况下修改了这个状态,进程才会在任意CPU上调度,也即运行该进程,休眠的进程会被搁置在一边,等待将来的某个事件发生。
休眠的规则:
- 永远不要在原子上下文(在执行多个步骤时,不能有任何的并发访问)进入休眠,对于休眠来讲,驱动程序不能在拥有自旋锁、
seqlock
或者RCU
锁时休眠,如果已经禁止了中断也不能休眠。 - 当线程被唤醒时必须检查以确保之前休眠等待的条件真正为真
- 进程休眠必须有被唤醒的地方,否则不能进行休眠
Tips: 休眠进程存储在一个等待队列上,该等待队列就是一个进程链表,其中包含等待某个特定事件的所有进程。
简单休眠
当一个休眠进程被唤醒时,它必须再次检查它所等待的条件的确为真!
Linux内核中最简单的休眠方式是称为wait_event
的宏,在实现休眠的同时,它也检查进程等待的条件。
wait_evet(queue, condition)
: 将当前进程放入等待队列头,condition
是任意一个布尔表达式,在休眠前后都要对该表达式求值。
wakt_up(wait_queue_head_t *queue)
: 唤醒等待在给定queue
上的所有满足condition
条件的进程,
高级休眠
进程如何休眠
位于linux/wait.h
文件中的wait_queue_head_t
数据结构主要由一个自旋锁和一个链表组成,链表中保存的是一个等待队列入口,该入口声明为wait_queue_t
类型,这个结构中包含了休眠进程的信息及其期望被唤醒的相关细节信息将进程置于休眠的几个步骤:
- 分配并初始化一个
wait_queue_t
结构,然后将其加入到对应的等待队列。 - 设置进程的状态,将其标记为休眠。进程状态主要有:
TASK_RUNNING
(表示进程可运行),TASK_INTERRUPTIBLE
和TASK_UNINTERRUPUTIBLE
(进程处于休眠状态前者是可中断后者是不可中断) - 再次检查休眠等待的条件,如果依然等待就调用schedule调用调度器让出CPU,否则继续该进程的操作独占等待,由于
wait_up
会唤醒所有处于等待队列中的进程,但是一般只有一个进程会真正被唤醒,其他的进程会再次进入休眠状态但是这些被假唤醒的进程都会去尝试获得处理器,为资源竞争,所以会严重影响系统性能。为了解决这个问题,内核增加了"独占等待"选项:
一个独占等待与通常的休眠有如下两个重要的不同:- 等待队列入口设置了
WQ_FLAG_EXCLUSIEV
标志时,则会被添加到等待队列的尾部。而没有这个标志的入口会被添加到头部 - 在某个等待队列上调用
wake_up
时,它会在唤醒第一个具有WQ_FLAG_EXCLUSIEVE
标志的进程之后停止唤醒其他进程
- 等待队列入口设置了
唤醒的相关细节
当一个进程被唤醒时,实际的结果由等待队列入口中的一个函数控制,默认的唤醒函数将进程设置为可运行状态,并且如果该进程具有更高的优先级,则会执行一次上下文切换以便切换到该进程。
poll和select
poll、select
和epoll
的功能本质上是一样的:都允许进程决定是否可以对一个或多个打开的文件做非阻塞的读取或写入,这些调用也会阻塞进程,直到给定的文件描述符集合中的任何一个可读取或写入。
三个系统调用均通过驱动程序的poll方法提供,该方法原型如下:
unsigned int (*poll)(struct file *filp, poll_table *wait)
该方法的处理分为以下两步:
- 在一个或多个可指示poll状态变化的等待队列上调用poll_wait,如果当前没有文件描述符可用来执行I/O,则内核将使进程在传递到该系统调用的所有文件描述符对应的等待队列上等待。
- 返回一个用来描述操作是否可以立即无阻塞执行的位掩码
poll几个标志位说明:POLLIN
:如果设备可以无阻塞地读取,就设置该位POLLRDNORM
:如果通常的数据已经就绪,可以读取,就设置该位,一个可读设备返回(POLLIN|POLLRDNORM
)POLLRDBAND
: 这一位指示可以从设备读取out-of-band(频带之外)的数据。POLLPRI
:可以无阻塞地读取高优先级(即out-of-band
)的数据。设置该位会导致select报告文件发生一个异常,这是由于select
把"out-of-band
"的数据作为异常对待POLLHUP
:当读取设备的进程到达文件尾时,驱动程序必须设置POLLHUP(挂起)位。依照select的功能描述,调用select的进程会被告知设备是可读的。POLLERR
:设备发生了错误,如果调用poll,就会报告设备即可读也可以写,因为读写都会无阻塞地返回一个错误码POLLOUT
:如果设备可以无阻塞地写入,就在返回值中设置该位POLLWRNORM
:该位和POLLOUT
的意义一样,有时其实就是同一个数字,一个可写的设备将返回(POLLOUT|POLLWRNORM
)POLLWRBAND
:与PLLRDBAND
类似,这一位表示具有非零优先级的数据可以被写入设备。
与read
和write
的交互
从设备读取数据
- 如果输入缓冲区有数据,那么即使就绪的数据比程序所请求的少,并且驱动程序保证剩下的数据马上就能到达,read调用仍然立即返回。
- 如果输入缓冲区中没有数据,那么默认情况下
read
必须阻塞等待,直到至少有一个字节到达。另一方面,如果设置了O_NONBLOCK
标志,read
应立即返回,这个时候应当配合poll
使用。 - 如果已经到达文件尾,
read
应该立即返回0
向设备写数据
- 如果输出缓冲区中有空间,则
write
应该无延迟地立即返回。 - 如果输出缓冲区已满,那么默认情况下write被阻塞直到有空间释放,如果设置了
O_NONBLOCK
标志,write
应立即返回,同时应当配合poll
使用等待可写状态 - 永远不要让
write
调用在返回前等待数据的传输结束,即使O_NONBLOCK
标志被清除。
底层的数据结构
当用户程序调用了poll、select
或epoll
函数时,内核会调用由该系统调用引用的全部文件的poll
方法,并向它们传递同一个poll_table
.
poll_table
结构是构成实际数据结构的一个简单封装。对poll
和select
系统调用,后面这个结构是包含poll_table_entry
结构的内存页链表。
每个poll_table_entry
结构包括一个指向被打开设备的struct file
类型的指针、一个wait_queue_head_t
指针以及一个关联的等待队列入口。
对poll_wait
的调用有时也会将进程添加到这个给定的等待队列。整个结构必须由内核维护,因而在poll
或select
返回前,进程可从所有这些队列中移除。
如果轮询(poll
)时没有一个驱动程序指明可以进行非阻塞I/O,这个poll调用就进入休眠,直到休眠在其上的某个(或多个)等待队列唤醒它为止。
epoll
系统调用用来优化poll
和select
同时监听多数量的文件而造成的效率低下的问题
异步通知
使用异步通知,应用程序可以在数据可用时收到一个信号,而不需要不停地使用轮询(poll
)来关注数据。
为了启用文件的异步通知机制,用户程序必须执行两个步骤:
- 指定一个进程作为文件owner,当进程使用fcntl系统调用执行
F_SETOWN
命令时,owner
进程的进程ID号就被保存在filp->f_owner
中。 - 通过
fcntl
的F_SETFL
命令在设备中设置FASYNC
标志
执行完上面两个步骤之后,输入文件就可以在新数据到达时请求发送一个SIGIO
信号,该信号被发送到存放在filp->f_owner
中的进程
从驱动程序的角度考虑
从内核角度来看,异步通知的详细操作过程:
F_SETOWN
被调用时对filp->f_owner
赋值,此外什么也不做- 在执行
F_SETFL
启用FASYNC
时,调用驱动程序的fasync
方法。只要filp->f_flags
中的FASYNC
标志发生了变化,就会调用该方法,以便把这个变化通知驱动程序,使其能正确响应。文件打开时,FASYNC
标志被默认为是清除的。 - 当数据到达时,所有注册为异步通知的进程都会被发送一个
SIGIO
信号。
定位设备
llseek实现
llseek
方法实现了lseek
和llseek
系统调用
如果设备操作未定义llseek
方法,内核默认通过修改filp->f_po
而执行定位,filp->f_pos
是文件的当前读取/写入位置。
为了是lseek系统调用能正确工作,read和write方法必须通过更新它们收到的偏移量参数来配合。
如果设备有一个明确定义的数据区,实现lseek是有意义的,然而大多数设备只提供了数据流(串口和键盘),而不是数据区,定位这些设备是没有意义的。
设备文件的访问控制
-
独享设备
一次只允许一个进程打开设备(独享),最好避免使用这种技术,因为它制约了用户的灵活性。 -
替代
EBUSY
的阻塞型open
当设备不能访问是返回一个错误,通常这是最合理的方式,但有些情况下可能需要让进程等待设备。 -
在打开时复制设备
在进程打开设备时创建设备的不同私有副本,这也是实现访问控制的一个方法这种方式只有在没有绑定到某个硬件对象时才能实现。比如/dev/tty
内部就使用了这类技术,以提供给它的进程一个不同于/dev
入口点所表现出的"情景",如果复制的设备是由软件驱动程序创建的,我们称它们为"虚拟设备"
第七章 时间、延迟及延缓操作
度量时间差
内核通过定时器中断来跟踪时间流.
时钟中断由系统定时硬件以周期性的间隔产生,这个间隔由内核根据HZ的值设定,HZ是一个与体系结构有关的常数。
每次当时钟中断发生时,内核内部计数器的值就增加一,这个计数器的值在系统引导时被初始化为0,表示的是自上次操作系统引导以来的时钟滴答数。
使用jiffies计数器
jiffies
变量被声明为volatile
,这样避免编译器对访问该变量的语句的优化。
用户空间的时间表述方法(struct timeval
和struct timespec
)可以通过以下方法和内核计数器进行转换。
unsigned long timespec_to_jiffies(struct timespec *value)
void jiffies_to_timespec(unsigned long jiffies, struct timespec *value)
在64位计算机架构上jiffies_64
与jiffies
其实是同一个,但是在32位上,对jiffies_64
的访问不是原子的,需要通过特殊辅助函数进行访问:u64 get_jiffies_64(void)
通过/proc/interrupts
获得的计数值除以/proc/uptime
文件报告的系统运行时间,可以获得内核确切的HZ
值
处理器特定的寄存器
绝大多数现代处理器都包含一个随时钟周期不断递增的计数寄存器,这个时钟计数器是完成高分辨率计数任务的唯一可靠途径。
TSC(
timestamp counter
,时间戳计数器)便是其中一种计数器寄存器,它是一个64位寄存器,记录CPU时钟周期数,从内核空间和用户空间都可以读取它
获取当前的时间
内核一般通过jiffies
值来获取当前的时间,该数值表示的是子最近一次系统启动到当前的时间间隔,它和设备驱动程序无关,因为它的生命期只限于系统的运行期(uptime
)
墙钟时间
日常生活使用的时间,用年月日来表达。
do_gettitmeofday
:该函数用秒或微秒来填充一个指向struct timeval的指针变量,该内核函数在许多体系结构上有“接近微秒级的分辨率”,它依赖于定时硬件
延迟执行
长延时
-
忙等待
如果想把执行延迟若干个时钟滴答或者对延迟的精度要求不高,最简单的实现方法就是一个监视jiffies计数器的循环。
代码如下:while(time_before(jiffies, j1)) cpu_relax();
该方式的缺点是会严重降低系统性能。如果内核并非抢占型的,那么这个循环将在延迟期间整个锁住处理器,而调度器从来不会抢占运行在内核空间的进程,这样在并未到达指定时间时,计算机处于忙碌状态。如果内核时抢占型,则除非代码拥有一个锁,否则处理器的时间还可以用作他用,但是依然还是会造成CPU资源浪费。最为严重的情况是在进入循环之前正好禁止了中断,
jiffies
值就不会得到更新,那将永远处于循环中,造成计算机一直死掉。 -
让出处理器
在不需要CPU时主动释放CPU,通过schedule
函数实现
代码如下:while(time_before(jiffies, j1)) schedule();
该方式的实现所产生的延时可能要比所请求的时间长几个时钟滴答,因为无法保证让出CPU后何时才会获取CPU资源。
- 超时
现在存在两种构造基于jiffies超时的途径:- 如果驱动程序使用等待队列来等待其他一些事件,而我们同时希望在特定时间段中运行,则可以使用wait_event_timeout或者wait_event_interruptible_timeout函数,该两个函数会在给定的等待队列上休眠,并会在超时到期时返回,该方式可能会被其他进程调用wake_up唤醒。
- 为了适应不等待特定事件而延迟,内核提供了schedule_timeout函数:
set_current_state(TASK_INTERRUPTIBLE或者TASK_UNINTERRUPTIBLE) schedule_timeout(delay)
短延时
ndelay/udelay/mdelay
这几个内核函数可很好地完成短延迟任务,该三个方法均是忙等待函数,因而在延迟过程中无法运行其他任务。
-
udelay
(以及可能的ndelay
)的实现使用了软件循环,它根据引导期间计算出的处理器速度以及loops_pre_jiffy
整数变量确定循环的次数。 -
msleep/msleep_interruptible/ssleep
实现毫秒级的延迟,这系列方式不涉及忙等待,都是将驱动程序放入某个等待队列中等待。
内核定时器
如果需要在将来的某个时间点调度执行某个动作,同时在该时间点到达之前不会阻塞当前进程,则可以使用内核定时器,内核定时器可用来在未来的某个特定时间点(基于时钟滴答)调度执行某个函数。
注意:内核定时器常常作为“软件中断”的结果而运行
当定时器运行时,调度该定时器的进程可能正在休眠或在其他处理器上执行,或者干脆已经退出,所以定时器函数通常运行在进程上下文之外,对于运行进程上下文之外,则必须遵守如下规则:
- 不允许访问用户空间,因为没有进程上下文,无法将任何特定进程与用户空间关联。
current
指针在原子模式下是没有任何意义- 不能执行休眠或调度,原子代码不可以调用
schedule
或者wait_event
,也不能调用任何可能引起休眠的函数例如调用
kmalloc(..., GFP_KERNEL)
以及信号量由于可能引起休眠,也不能用。
内核代码可以通过调用函数in_interrupt()
来判断自己是否运行与中断上下文。
重要特性
- 任务可以将自己注册以在稍后的时间重新运行,因为每个timer_list结构都会在运行之前从活动定时器链表中移走,这样就可以立即链入其他的链表。
- 在多处理器系统中,定时器函数会由注册它的同一个CPU执行。
- 任何通过定时器函数访问的数据结构都应该针对并发访问进行保护。
内核定时器的实现
- 定时器的管理必须尽可能做到轻量级
- 其设计必须在活动定时器大量增加时具有很好的伸缩性
- 大部分定时器会在最多几秒或者几分钟内到期,而很少存在长期延迟的定时器
- 定时器应该在注册它的同一CPU上运行
tasklet
与内核定时器类似,始终在中断期间运行(定时器类似于软中断),始终会在调度它们的同一CPU上运行,而且都接收一个unsigned long参数。
与内核定时器不同的是,无法要求tasklet在某个给定时间执行。
软件中断是打开硬件中断的同时执行某些异步任务的一种内核机制。
tasklet特性
- 一个
tasklet
可在稍后被禁止或者重新启用;只有启用的次数和禁止的次数相同时,tasklet
才会被执行 - 和定时器类似,
tasklet
可以注册自己本身 tasklet
可被调度以在通常的优先级或者高优先级执行,高优先级的tasklet
总会首先执行- 如果系统负荷不重,则
tasklet
会立即得到执行,但始终不会晚于下一个定时器滴答 - 一个
tasklet
可以和其他tasklet
并发,但对于自身来讲是严格串行处理的,也就是说,同一tasklet
永远不会在多个处理器上同时运行。
工作队列
工作队列类似于tasklet
,它们都允许内核代码请求某个函数在将来的时间被调用。
与tasklet
的区别如下:
- tasklet在软件中断上下文中运行,所以所有的tasklet代码都必须是原子的,相反,工作队列函数在一个特殊内核进程的上下文中运行,因此它们具有更好的灵活性,工作队列函数可以休眠
- tasklet始终运行在被初始化提交的统一处理器上,但这只是工作队列的默认方式
- 内核代码可以请求工作队列函数的执行延迟给定的时间间隔
共享队列
内核提供了共享的默认工作队列
第八章 分配内存
kmalloc函数
#include <linux/slab.h>
void *kmalloc(size_t size, int flags);
该函数运行得很快,而且不对所获取的内存空间清零,也就是说,分配给它的区域仍然保持着原有的数据(所以我们要将内存显式地清空,有其是可能导出给用户空间或者写入设备的内存,否则,就可能将私有信息泄露出去),另外,它分配的区域在物理内存中也是连续的.
flags参数
-
GFP_KERNEL
: 内核内存的通常分配方法,可能引起休眠该参数是最常用的,它表示内存分配(最终总是调用get_free_pages来实现实际的分配)是代表运行在内核空间的进程执行的。该参数允许kmalloc在空闲内存较少时把当前进程转入休眠以等待一个页面,因此使用GFP_KERNEL分配内存的函数必须是可重入的,在当前进程休眠时,内核会采取适当的行动,或者是把缓冲区的内容刷写到硬盘上,或者是从一个用户进程换出内存,以获取一个内存页面。
-
GFP_ATOMIC
:用于在中断处理例程或其他运行进程上下文之外的代码中分配内存,不会休眠。内核通常会为原子性的分配预留一些空闲页面。使用该参数时,kmalloc甚至可以用掉最后一个空闲页面,如果连最后一页都没有了,便会分配失败。
内存区段
Linux内核把内存分为三个区段: 可用于DMA的内存、常规内存(normal
)以及高端内存(highmem
)。
通常的内存分配都发生的在常规内存区,但通过设置上面介绍过的标志也可以请求在其他区段中分配。
DMA的内存
指存在与特别地址范围内的内存,外设可以利用这些内存执行DMA访问,在x86平台上,DMA区段是RAM的前16MB。
高端内存:
该部分内存是32位平台为了访问(相对)大量的内存而存在的一种机制。
当一个新页面为满足kmalloc
的要求被分配时,内核会创建一个内存区段的列表以供搜索,如果指定为__GFP_DMA
标志,则只有DMA区段会被搜索,如果低地址段上没有可用内存,分配就会失败。如果没有指定特定的标志,则常规区段和DMA区段都会被搜索;如果设置了__GFP_HIGHMEM
标志,则所有三个区段都会被搜索以获取一个空闲页(但是kmalloc
不能分配高端内存)
size 参数
Linux处理内存分配的方法是,创建一系列的内存对象池,每个池中的内存块大小是固定一致的。处理分配请求时,就直接在包含有足够大的内存块的池中传递一个整块给请求者。
后备高速缓存
Linux内核的高速缓存管理被称为"slab分配器",内核可以统计高速缓存的使用情况,高速缓存的使用统计情况可以从/proc/slabinfo
获得
内存池(mempool)
内存池其实就是某种形式的后备高速缓存,它试图始终保存空闲的内存,以便在紧急状态下使用
注意: 尽量避免在驱动程序代码中使用mempool,为什么? 因为mempool会分配一些内存块,而这些内存块有可能一直处于空闲状态且不会真正得到使用,这样会浪费大量内存。
get_free_page和相关函数
如果模块需要分配大块的内存,使用面向页的分配技术会更好些。
分配页面可使用下面的函数:
get_zeroed_page(unsigned int flags)
: 返回指向新页面的指针并将页面清零__get_free_page(unsigned int flags)
: 类似于get_zeroed_page,但不清零页面__get_free_pages(unsigned int flags, unsigned int order)
: 分配若干(物理连续的)页面,并返回指向给内存区域的第一个字节的指针,但不清零页面区域的第一个字节的指针,但不清零页面
/proc/buddyinfo
存储了系统中每个内存区段上每个阶数下可获得的数据块数目
与kmalloc相比,按页分配不会浪费内存空间,而用kmalloc函数则会因分配粒度的原因而浪费一定数量的内存。
__get_free_page
函数的最大优点是这些分配的页面完全属于我们自己,而且在理论上可以通过适当地调整页表将它们合并成一个线性区域,例如可以允许用户进程对这些单一但互不相关的页面分配得到的内存区域进行mmap
alloc_pages接口
struct page
是内核用来描述单个内存页的数据结构,内核中有许多地方需要使用page结构,尤其在需要使用高端内存(高端内存在内核空间没有对应不变的地址)的地方。
这里插一句介绍下NUMA计算机的概念:
NUMA计算机是多处理器系统,其中的内存对特定处理器组(节点)来讲是"本地的"。访问本地内存要比访问非本地内存快,在这类系统中,在正确节点上的内存分配非常重要。
vmalloc及其辅助函数
该函数用于分配虚拟地址空间的连续区域(这段区域在物理上可能不是连续的),但内核区认为它们在地址上是连续的。
vmalloc在发生错误时返回0(NULL地址),成功时返回一个指针,该指针指向一个线性的、大小最少为size
的线性内存区域
注意:内存编程中不鼓励使用
vmalloc
,因为通过该方法获得的内存使用起来效率不高,而且在某些体系架构上,用于vmalloc
的地址空间总量相对较小。
由kmalloc
和__get_free_pages
返回的内存地址也是虚拟地址,其实际值仍然要由MMU(内存管理单元,通常是CPU的组成部分)处理才能转为物理内存地址,两者使用的虚拟地址范围与物理内存是一一对应的,可能会有基于常量PAGE_OFFSET
的一个偏移,它们不需要为该地址修改页表,但另一方面,vmalloc
和ioremap
使用的地址范围完全是虚拟的,每次分配都要通过对页表的适当设置来建立(虚拟)内存区域。用vmalloc分配得到的地址是不能在CPU之外使用的,因为它只在处理器的内存管理单元上才有意义。
使用vmalloc函数的正确场合是在分配一大块连续的、只在软件中存在的、用于缓冲的内存区域的时候。
vmalloc函数的缺点:
不能在原子上下文中使用,因为它内部实现调用了kmalloc(GFP_KERNEL)
来获取页表的存储空间,因而可能休眠。
per-CPU变量
当建立一个per-CPU
变量时,系统中的每个处理器都会拥有该变量的特有副本。由于每个处理器在其自己的副本上工作,所以对per-CPU
变量的访问(几乎)不需要锁定,另外,per-CPU
变量还可以保存在对应处理器的高速缓存中,这样就可以在频繁更新时获得更好的性能。
per-CPU
变量可使用的地址空间是受限制的,所以如果要创建per-CPU
变量,则应该保持这些变量较小。
获取大的缓冲区
在引导时获得专用缓冲区如果的确需要连续的大块内存用作缓冲区,则最好在系统引导期间通过请求内存来分配。
在引导时就进行分配是获得大量连续内存页面的唯一方法,它绕过了__get_free_pages
函数在缓冲区大小上的最大尺寸和固定粒度的双重限制。
由于它通过保留私有内存池而跳过了内核的内存管理策略,所以这种技术不推荐使用。
第九章 与硬件通讯(这一章需要多次理解)
裸I/O: 写到设备的数据位出现在输出引脚上,而输入引脚的电压值可以由处理器直接获取。
I/O端口和I/O内存
每种外设都通过读写寄存器进行控制,大部分外设都有几个寄存器,不管是在内存地址空间还是在I/O地址空间,这些寄存器的访问地址都是连续的。
在硬件层面,内存区域和I/O区域没有概念上的区别: 它们都是通过向地址总线和控制总线发送电平信号(比如读和写信号)进行访问,再通过数据总线读写数据。
I/O寄存器和常规内存
I/O寄存器和RAM的最主要的区别就是I/O操作具有边际效应,而内存操作则没有: 内存写操作的唯一结果就是在指定位置存储一个数值;内存读操作则仅仅返回指定位置的最后一次写入的数值。
编译器能够将数值缓存在CPU寄存器中而不写入内存,即使存储数据,读写操作也都能在高速缓存中进行而不用访问物理RAM。
处理器无法预料某些其他进程(在另一个处理器上运行,或在某个I/O控制器中发生的操作)是否会依赖于内存访问的顺序。所以驱动程序必须确保不使用高速缓存,并且在访问寄存器时不发生读或写指令的重新排序。关于这一点,我自己的理解是这样的:
针对硬件的控制也是通过一系列存储在内存中的命令通过I/O寄存器来传入硬件设备中,如果使用了高速缓存,而缓存中进行了内存的一系列优化,所以会导致命令可能的错乱,从而导致发送至硬件设备的命令出现错误!
为了解决这个问题,只要把底层硬件配置成(可以是自动的或是由Linux初始化代码完成)在访问I/O区域(不管是内存还是端口)时禁止硬件缓存即可。
由编译器优化和硬件重新排序引起的问题的解决办法是:
对硬件(或其他处理器)必须以特定顺序执行的操作之间设置内存屏障(memory barrier),但是很显然内存屏障会影响系统性能。
使用I/O端口
I/O端口是驱动程序与许多设备的之间的通信方式。
I/O端口分配
内核提供了一个注册用的接口,它允许驱动程序声明自己需要操作的端口。
核心函数如下:
struct resource *request_region(unsigned long first, unsinged long n, const char *name)
所有的端口分配可从/proc/ioports
中得到。
释放端口的占用,核心函数如下:
void release_region(unsigned long start, unsiged long n)
操作I/O端口
在用户空间访问I/O端口
-
串操作
有些处理器上实现了一次传输一个数据序列的特殊指令,序列中的数据单元可以是字节、字或者双字。
在使用串I/O操作函数时,它们直接将字节流从端口中读取或写入。但当端口和主机系统具有不同的字节序时,将导致不可预期的结果。 -
暂停式I/O
当处理器时钟相比外设时钟快时就会出现问题,并且在设备板卡特别慢时表现出来。如何解决呢? 主要是在每条I/O指令之后,如果还有其他类似指令,则插入一个小的延迟。
I/O端口示例
数字I/O端口最常见的形式是一个字节宽度的I/O区域,它或者映射到内存,或者映射到端口。当数值写入到输出区域时,输出引脚上的电平信号随着写入的各位而发生相应变化。从输入区域读取到的数据则是输入引脚各位当前的逻辑电平值。
大多数情况下,I/O引脚是由两个I/O区域控制的:一个区域中可以选择用于输入和输出的引脚,另一个区域中可以读写实际的逻辑电平。
并口简介
并口的最小配置(不涉及ECP和EPP模式)由3个8位端口组成。
使用I/O内存
和设备通讯的另一种主要机制是通过使用映射到内存的寄存器或设备内存。
I/O内存仅仅是类似RAM的一个区域,在那里处理器可以通过总线访问设备。
第十章 中断处理
中断仅仅是一个信号,当硬件需要获得处理器对它的关注时,就可以发送这个信号。
中断信号线是非常珍贵且有限的资源,内核维护了一个中断信号线的注册表,该注册表类似于I/O端口的注册表。模块在使用中断前要先请求一个中断通道(或者中断请求IRQ),然后再使用后释放该通道。
由于中断信号线资源有限,通常并不是在设备初始化的时候而是在设备打开的时候注册分配中断信号线。
/proc 接口
产生的中断报告显示在文件/proc/interrupts
中。之前开发扫描头驱动的时候为了查看isp
中断,cat
过这个文件
Linux内核通常会在第一个CPU上处理中断,以便最大化缓存的本地性。
/proc
树结构中还包含另一个与中断相关的文件,即/proc/stat
. /proc/stat
记录了一些系统活动的底层统计信息,包括从系统启动开始接收到的中断数量。
自动检测IRQ号
内核帮助下的探测
Linux内核提供了一个底层设施来探测中断号,它只能在非共享中断的模式下工作,但是大多数硬件有能力工作在共享中断的模式下,并可提供更好的找到配置中断号的方法,函数如下:
-
unsigned long probe_irq_on(void)
这个函数返回一个未分配中断的位掩码,驱动程序必须保存返回的位掩码,并且将它传递给后面的probe_irq_off函数,调用该函数之后,驱动程序要安排设备产生至少一次中断。
-
int probe_irq_off(undigned long)
在请求设备产生中断之后,驱动程序调用这个函数,并将前面
probe_irq_on
返回的位掩码作为参数传递给它,probe_irq_off
返回"probe_irq_on
"之后发生的中断编号。如果没有中断发生,就返回0,如果产生了多次中断,probe_irq_off
会返回一个负值。
快速和慢速处理例程
x86平台上中断处理的内幕(中断处理流程)
-
在所有情况下,一旦有中断产生,处于
entry.S
文件中的中断处理代码(最底层的中断处理代码可见entry.S
文件,该文件是一个汇编语言文件,完成了
许多机器级的工作,这个文件利用几个汇编技巧及一些宏,将一段代码用于所有可能的中断)将中断编号压入栈,然后跳到一个公共段,而这个公共段会调用在irq.c
中定义的do_IRQ
函数 -
do_IRQ
中首先会应答中断,这样中断控制器就可以继续处理其他的事情了,然后该函数对于给定IRQ号获得一个自旋锁,这样就阻止了任何其他的CPU处理这个IRQ,接着清除几个状态位(包括IQR_WAITING
),然后寻找这个特定IRQ的处理例程,如果没有处理例程,就什么也不做,自旋锁释放,处理任何待处理的软件中断,然后do_IRQ返回。 -
如果某个中断注册了处理例程,则一旦发生了中断,函数
handle_IRQ_event
会被调用以便实际调用处理例程,如果处理例程是慢速类型(即SA_INTERRUPT
未被设置),将会重新启用硬件中断,并调用处理例程。然后只是做一些清理工作,接着运行软件中断,最后返回到常规工作,作为中断的结果(例如,处理例程可以wake_up一个进程),”常规工作“可能已经被改变,所以,从中断返回时发生的最后一件事情可能就是一次处理器的重新调度。
处理中断例程的参数及返回值
中断处理例程有三个参数:
- int irq: 中断号
- void dev_id: 是一种客户数据类型(即驱动程序可用的私有数据),一般会为dev_id传递一个指向自己设备的数据结构指针,这样,一个管理若干同样设备的驱动程序在终端处理例程中不需要做任何额外的代码,就可以找出哪个设备产生了当前的中断事件
- struct pt_reg *regs: 保存了处理器进入中断代码之前的处理器上下文快照,该寄存器可被用来监视和调试,对一般的设备驱动程序任务来说通常不是必需的。
启用和禁用中断
禁用单个中断
禁用某个特定中断线的中断产生,主要方法如下:
void disable_irq(int irq)
: 该函数不但会禁止给定的中断,而且也会等待当前正在执行的中断处理例程完成,如果调用disable_irq
的线程拥有任何中断处理例程需要的资源(比如自旋锁),则系统会死锁。void disable_irq_nosync(int irq)
: 和disable_irq
不同,该函数是立即返回的。void enable_irq(int irq)
: 调用这些函数中的任何一个都会更新可编程中断控制器中指定中断的掩码,因而就可以在所有的处理器上禁用或者启用IRQ。
禁用所有的中断
以下两个函数用于关闭当前处理器上所有中断处理:
void local_irq_save(unsigned long flags)
void local_irq_disable(void)
通过如下函数打开中断:
void local_irq_restore(unsigned logn flags)
void local_irq_enable(void)
上半部和下半部
由于中断处理例程需要尽快结束而不能使中断阻塞的时间过长同时又需要在一次设备中断中需要完成一定数量的工作,内核将中断处理例程分为两部分来解决这个问题。
- 上半部: 实际响应中断的例程,也就是用
request_irq
注册的中断例程。 - 下半部: 被上半部调度,并在稍后更安全的时间内执行的例程。
上半部和下半部最大的不同就是当下半部处理例程执行时,所有中断都是打开的,就是在更安全的时间内运行。
例如以下一个中断处理例程:
- 上半部: 保存设备的数据到一个设备特定的缓冲区并调度它的底半部然后退出
- 下半部: 执行诸如唤醒进程、启动另外的I/O操作等必要的工作
Linux内核使用tasklet
以及工作队列来实现下半部处理。两者的优缺点如下:
- tasklet: 运行非常快,但是必须是原子的
- 工作队列: 具有更高的延迟,但是允许休眠
中断共享
Linux内核支持所有总线的中断共享。
安装共享的处理例程
共享的中断处理例程也是通过request_irq
安装的,但是存在以下两点不同:
- 请求中断时,必须指定
flags
参数中的SA_SHIRQ
位 dev_id
参数必须是唯一的。 任何指向模块地址空间的指针都可以使用,但dev_id
不能设置成NULL
内核为每个中断维护了一个共享处理例程的列表,这些处理例程的dev_id
各不相同,就像是设备的签名。
为什么dev_id
不能设置成NULL?
因为如果有两个驱动程序在同一个中断上都注册NULL作为它们的签名,那么在卸载的时候引起混淆,当中断到达时造成内核出现
oops
消息。
当请求一个共享中断时必须满足以下两个条件:
- 中断信号线空闲
- 任何已经注册了该中断信号线的处理例程也标识了IRQ是共享的。
使用共享处理例程的驱动程序不能使用enable_irq
和disable_irq
。
proc接口和共享的中断:
共享的中断处理例程不会对/proc/stat造成影响,但是对于/proc/interrupts而言共享的中断处理例程会在最后累计显示多个。
中断驱动的I/O
如果与驱动程序管理的硬件之间的数据传输因为某种原因被延迟的话,驱动程序就应该实现缓冲。数据缓冲区有助于将数据的传送和接收与系统调用write和read分离开来,从而提高系统的整体性能。
一个好的缓冲机制需要采用中断驱动的I/O,这种模式下,一个输入缓冲区在中断时间内被填充,并由读取该设备的进程取走缓冲区内的数据;一个输出缓冲区由写入设备的进程填充,并在中断时间内取走数据。要正确进行中断驱动的数据传输,则要求硬件应该能按照下面的语义来产生中断:
- 对于输入来说,当新的数据已经到达并且处理器准备好接收它时,设备就中断处理器。实际执行的动作取决于设备使用的是I/O端口、内存映射,还是DMA
- 对于输出来说,当设备准备好接收新数据或者对成功的数据传送进行应答时,就要发出中断。内存映射和具有DMA能力的设备,通常通过产生中断来通知系统它们对缓冲区的处理已经结束。