[5]开发一个Linux系统吧——IO模型与中断
IO模式
提及到了IO就一定包含了同步、异步、阻塞和非阻塞四大金刚,这些概念都是宏观概念,所以完全可以从字面上分析,接下来举例就可以了。
同步与异步讲的是并发的过程,这里需要使用到新的进程或者线程。阻塞就是调用发起后进程会停下等待,直到条件满足才能继续,在C语言中,scanf就是一个阻塞的过程。非阻塞IO则是不管数据是否就绪都立即返回,进程可以继续做其他处理,比如与其他进程交换数据。
IO是可以出现多个的,这样的操作叫多路复用,在数据交互中,准备好的数据既可以继续向下传递,也可以现用现读。如果IO结合信号机制的话,也会有一种信号式IO。
这里罗列以下几种IO的操作顺序:

进程调度
这是内核负责管理哪些程序需要投入使用的方法,进程调度其实是对进程所需资源(主要是CPU时间)的分配。
进程在运行的时候,用户空间需要传递很多变量,这些就是所谓的进程上下文,可以看作是用户空间的数据传递。一个进程包含三个上下文,分别为:
- 用户上下文:数据、栈区
- 寄存器上下文:通用寄存器,特殊寄存器(程序寄存器)、栈指针
- 系统上下文:task_struct进程描述符,mm_struct(内存描述符)
上下文切换就是对上面的信息进行切换。
在硬件触发中断的时候,内核优先处理中断,这些就是中断上下文,可以看作是内核运行的其他环境。中断分为两个部分,一个是接受中断的部分,另一部分就是善后工作。
Linux的进程调度一共有三种策略,分别为:
- SCHED_OTHER,分时调度(CFS),按优先级运行
- SCHED_RR,实时调度,时间轮转
- SCHED_FIFO,先到先服务
为了衡量时间,Linux定义了时间片的概念,它表示一个进程抢占CPU后可以运行多久。
分时调度是Linux默认的进程调度策略,默认的优先级都是0,在策略表上就是据此进行的。事实上,如果标记值越大运行的优先级越低,这样标记值小的可以获得更多的运行时间。标记值是会随着时间进行动态改变的,这样能确保更公平的占有处理器。同时这种方法摒弃了时间片,而是采用了优先级的概念,这样可以获得更为合理的处理时间。默认最少获得时间为1ms。
NICE是一个表达优先级的数值,我们可以使用ps -el进行查看,NI就是优先级修正的值,它可以更改优先级。
1 | |
这里也显示了一些信息,包括:F表示进程的标志,S表示状态,UID表示执行者的身份,PID表示进程的ID号,C表示CPU占用率,PRI表示优先级,NI就是NICE值,ADDR表示内核函数的地址,SZ表示内存大小,WCHAN表示进程正在睡眠的内核函数,TTY表示终端,TIME就是消耗CPU的时间,CMD是下达的命令。
PRI:进程优先级,值越小,优先级越高
NI:变化值,PR = PRI + NI
在调度的过程中需要完成四个功能:
- 运行时间记录
调度器必须记录进程的运行时间,因为CFS没有了时间片的概念,所以就需要统计时间以保证公平分配时间。
- 进程选择
优先选择运行时间小的进程,这个查找使用的是红黑树,速度很快。红黑树也可以组织可运行进程队列,并利用它来迅速找到最小vruntime值的进程。
- 调度器
进程调度的主要入口点函数是schedule(),他会调用pick_next_task(),pick_next_task()会以优先级为序,从高到低依次检查每一个调度类,并且从最高优先级的调度类中选择最高优先级的进程,pick_next_task()会返回指向下一个可运行进程的指针,没有的话返回NULL。
- 睡眠苏醒
休眠就是将进程从红黑树(rbtree)中移除,放入等待队列里,然后选择一个复苏的进程或者其他新加入的进程加入红黑树。苏醒就是从队列移出到红黑树。
用户抢占的过程中,内核从need_resched获取进程是否需要被调度,检查完成后如果该进程需要被运行,就尽快调用调度功能,再返回用户空间;从中断返回时,内核依然会检查need_resched。
内核即将返回用户空间的时候,如果需要设置need_resched就会发生用户抢占,在进入用户空间的时候,他就可以继续执行,或者作为一个新进程运行。当然这些都是使用汇编实现的。
在内核运行中,如果没有持有锁,那么内核就可以被抢占。换言之,只要调度是安全的,内核就可以被抢占。内核抢占会发生以下事情:
- 中断处理程序返回内核空间前
- 内核代码重新允许抢占时
- 显式更改need_resched标志的时候
- 任务阻塞期间
中断与异常
中断指CPU运行期间收到外部干扰或者程序预先安排的中断,引起CPU停止运行当前程序、转去运行其他程序,运行完之后再重新执行被中断的程序。中断分为两种,一种是硬件中断,一种是软件中断:
- 硬件中断由外设产生,信号的到达时机和CPU正在执行的指令无关,所以是异步的。
- 软件中断由CPU执行特定指令或者程序出错产生,和当前指令相关,所以是同步的。
另外异常是程序错误或者内核必须处理的异常条件产生的。第一种通过信号处理,第二种是恢复异常需要的步骤。
这里注意,中断和进程切换不同,进程切换产生了新的进程,中断处理只是一个方法。同时中断也是CPU最敏感的操作,需要满足如下条件:
- 内核需要做其他事情的时候就会触发中断,中断存在的目的就是推迟处理。因此内核最后需要划分两部分,上半部和下半部
- 中断的产生具有随机性,而且是可以堆叠的,这样可以让更多的IO设备处于运行状态,所以必须要嵌套运行,这样才可以完成所有的进程。
- 中断存在临界区,如果中断处于这些区域内,中断就会被限制,其次中断必须处于可以随时被响应的状态(开中断)
当然如果你学过STM32,一定会知道NVIC的中断管理方式,其实等同于Linux内核的向量中断。

- 中断信号在传递的过程就是中断号,其中中断控制器把中断信号发送给CPU就会产生一个中断向量,中断向量与中断号存在一一对应的关系。
- 存在一种可以屏蔽的中断信号,中断控制器可以忽略他
- 存在一种不可以被忽略的中断信号,一般是非常紧急的事件,区分它需要使用CPU
| 类别 | 原因 | 异步/同步 | 返回行为 |
|---|---|---|---|
| 中断 | 来自I/O设备的信号 | 异步 | 总是返回到下一条指令 |
| 陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
| 故障 | 潜在可恢复的错误 | 同步 | 返回到当前指令 |
| 终止 | 不可恢复的错误 | 同步 | 不会返回 |
中断处理器在把中断号变成中断向量值的过程时,就会在内存中建立一个向量表,其中包含了所有中断向量,中断向量表明了中断程序的地址。
为了处理中断,每个硬件都会与一条IRQ线连接,每个IRQ都和一个可编程的中断向量控制器连接,控制器负责将收到的信号转化为对应的向量,这些向量存储于控制器内,CPU可以按照需求进行读取,一旦产生中断,CPU的INTR引脚就会被激活。当一切结束后,INTR引脚重置。
中断向量控制器也是一种APIC,可以更好的发挥并行能力。
异常的种类非常多,为了处理这些异常,内核必须提供所有异常的处理方法,在执行某些方法时只需要返回一个错误码即可。

为了能将信号与对应的中断向量建立联系,内核制定了一套中断描述符表(IDT)来存储中断和异常。
- 任务门:中断信号发生后,把目标进程移到此区域内,用户不可以访问到
- 系统门:用户可以访问到的区域,可以使用对应的汇编指令进行操作
- 系统中断门:可以被用户访问到的中断,程序可以通过指令触发
- 中断门:用户不可以访问的门,所有中断处理程序都是通过中断门激活的
- 陷阱门:用户不可以访问的门,大部分异常处理程序都是通过此门激活,比如浮点异常
为了能让CPU获得更多的工作时间,中断控制器会确定中断和关联的中断向量:
- 如果触发了中断,首先读取IDT的值,确定触发中断程序的地址
- 确定中断发生源,并且判断中断的等级,这样可以避免进入了错的门
- 检查等级的变化,读取tr寄存器,将中断的处理部分放入栈里,然后运行
- 如果出现问题就需要保存在cs和eip寄存器内,并将引发故障的指令放入栈。
- 如果产生了硬件码,则放入栈内
确定异常、中断向量。
权限、特权检查;针对不同类型的异常、中断,保存不同的内容。
将从异常、中断向量得到的中断或异常处理程序地址装入 cs、eip 寄存器。
最有意思的一点就是:中断可以触发中断,或者一个中断噎死另一个中断。
由此我们知道,异常的处理和中断机制很相似,java的try catch也可以理解为对异常信号的一种响应。那么中断的处理过程是怎样的呢?
中断机制包含三个部分:中断系统的初始化,中断的处理,中断的API
初始化
首先分配IDT一定的内存空间,我们知道中断向量每个都是八字节,而且有256个中断向量,所以内存空间为2048字节,也就是2KB。第二步就是初始化保留的中断向量,完成其余的中断向量初始化。
这里区分一下向量中断和非向量中断。向量中断是中断有不同的入口,非向量中断仅有一个入口地址,然后进行判断是哪种中断。
- 向量中断模式用于RESET、NMI(非屏蔽中断)、异常处理。当向量中断产生时,控制器直接将PC赋值
- 非向量中断模式,有一个寄存器标识位,跳转到统一的函数地址,此函数通过判别寄存器标识位和优先级关系进行中断处理。
中断的处理
设备产生中断后,通过信号发送给中断控制器,然后变化为中断向量给CPU的INTR口,然后执行相应的程序。

中断控制器执行:处理中断请求,比较中断的优先级,返回中断向量给CPU。
CPU执行:使用中断向量在IDT内查询,等级变化进入栈,进入中断处理程序。
API
1 | |
1 | |

中断分为两部分,上半部分在收到信号后就会立刻执行,主要完成一些必要操作,在运行上半部分时可以被其他中断扼杀。下半部分完成比较耗时的工作。
上半部分:

下半部分:

软中断(可以被加塞):




软中断拥有中断上下文的全部性质,同时他有自己定义的32个接口,软中断不能同类打断,只能被硬中断打断,同时软中断是并发的,也就意味着软中断需要锁保护。
1 | |
tasklet
这是一种延迟型的软中断,一般的软中断都是内核分配的,而tasklet是可以进行动态分配,不过tasklet绑定CPU,性能就会有所下降
1 | |
因为tasklet使用较为轻松,所以一般都会使用它完成中断操作。
为了简化内核的线程创建,Linux引入了workqueue(工作队列),它可以根据内核数创建线程。
- work :工作。
- workqueue :工作的池子
- worker :工人。
- worker_pool:工人的池子
- pwq(pool_workqueue):建立工人队列与工人池子的关系


work_struct负责描述work,初始化后会被加入到工作队列,然后传递到合适的内核进行处理。
workqueue可以绑定worker到CPU上,也可以在处理器间来回运行
每个worker包含了一个内核线程,可以根据工作状态添加worker_pool内
worker_pool就是一个线程池,可以管理线程。
pool_workqueue用于将workqueue和worker_pool关联起来

使用的API如下:
1 | |
中断的使用需要在使用Linux内核的单片机,同时编译内核时也需要开启GPIO,具体可以参考第一期编译的过程。
总结
过年了,休息一下,暂停更新
本期博文主要介绍了中断和IO模型,通过阅读本期博文,读者可以更好地理解包括异常处理、线程池等,读者可以使用已有的API完成C对系统的更好的使用。