[5]开发一个Linux系统吧——IO模型与中断

IO模式

提及到了IO就一定包含了同步、异步、阻塞和非阻塞四大金刚,这些概念都是宏观概念,所以完全可以从字面上分析,接下来举例就可以了。

同步与异步就是并发的过程,这里需要使用到新的进程或者线程。阻塞就是系统发送信号进行锁住状态,同时也会产生相应的回调过程。在C语言中,scanf就是一个阻塞的过程。非阻塞IO就是在进行内核数据交换中,内核还可以进行其他处理,比如与其他进程2继续交换数据。

IO是可以出现多个的,这样的操作叫多路复用,在数据交互中,准备好的数据既可以继续向下传递,也可以现用现读。如果IO结合信号机制的话,也会有一种信号式IO。

这里罗列以下几种IO的操作顺序:

1

进程调度

这是内核负责管理那些程序需要投入使用的方法,进程调度其实是对进程文件的资源分配。

进程在运行的时候,用户空间需要传递很多变量,这些就是所谓的进程上下文,可以看作是用户空间的数据传递。一个进程包含三个上下文,分别为:

  • 用户上下文:数据、栈区
  • 寄存器上下文:通用寄存器,特殊寄存器(程序寄存器)、栈指针
  • 系统上下文:task_struct进程描述符,mm_struct(内存描述符)

上下文切换就是对上面的信息进行切换。

在硬件触发中断的时候,内核优先处理中断,这些就是中断上下文,可以看作是内核运行的其他环境。中断分为两个部分,一个是接受中断的部分,另一部分就是善后工作。

Linux的进程调度一共有三种策略,分别为:

  • SCHED_OTHER,分时调度(CFS),按优先级运行
  • SCHED_RR,实时调度,时间轮转
  • SCHED_FIFO,先到先服务

为了衡量时间,Linux定义了时间片的概念,他表示一个进程抢占的时间可以运行多久。

分时调度时Linux默认的程调度策略,默认的优先级都是0,在策略表上就是据此进行的。事实上,如果标记值越大运行的优先级越低,这样标记值小的可以获得更多的运行时间。标记值是会随着是按进行动态改变的,这样能确保更公平的占有处理器。同时这种方法摒弃了时间片,而是采用了优先级的概念,这样可以获得更为合理的处理时间。默认最少获得时间为1ms。

NICE是一个表达优先级的数值,我们可以使用ps -el进行查看,NI就是优先级修正的值,它可以更改优先级。

1
2
3
4
5
6
7
minloha@minloha:~$ ps -el
F S UID PID PPID C PRI NI ADDR SZ WCHAN TTY TIME CMD
4 S 0 1 0 0 80 0 - 454 - ? 00:00:00 init
5 S 0 7 1 0 80 0 - 457 - ? 00:00:00 init
1 S 0 8 7 0 80 0 - 457 - ? 00:00:00 init
4 S 1000 9 8 0 80 0 - 1551 do_wai pts/0 00:00:00 bash
0 R 1000 18 9 0 80 0 - 1869 - pts/0 00:00:00 ps

这里也显示了一些信息,包括:F表示程序的用户组,S表示状态,UID表示执行者的身份,PID表示进程的ID号,C表示CPU的资源占比,PRI表示优先权限,NI就是NICE值,ADDR表示内核函数,SA表示内存大小,WCHAN表示运行状态,TTY表示bash的位置,TIME就是消耗CPU的时间,CMD值下达的命令。

PRI:进程优先级,值越小,优先级越高

NI:变化值,PR = PRI + NI

在调度的过程中需要完成四个功能:

  • 运行时间记录

调度器必须记录进程的运行时间,因为CFS没有了时间片的概念,所以就需要统计时间以保证公平分配时间。

  • 进程选择

有限选择运行时间小的进程,这个查找使用的是红黑树,速度很快。红黑树也可以组织可运行进程队列,并利用它来迅速找到最小vruntime值的进程。

  • 调度器

进程调度的主要入口点函数是schedule(),他会调用pick_next_task(),pick_nexttask()会以优先级为序,从高到低依次检查每一个调度类,并且从最高优先级的调度类中选择最高优先级的进程,pick_next_task()会返回指向下一个可运行进程的指针,没有的话返回NULL。

  • 睡眠苏醒

休眠就是将进程移除进程的btree(Linux的红黑树),放入等待的队列里,然后选择一个复苏的进程或者其他新加入的进程加入红黑树。苏醒状态就是从队列移出到红黑树。

在用户抢占进程的过程中,内核从need_resched获取进程是否需要被调度,检查完成后如果表明这个进程需要被运行,那就尽快调用调度功能,再返回到用户空间一节从中断返回时,内核依然会检查need_resched。

内核即将返回用户空间的时候,如果需要设置need_resched就会发生用户抢占,在进入用户空间的时候,他就可以继续执行,或者作为一个新进程运行。当然这些都是使用汇编实现的。


在内核运行中,如果进程没有锁,那么内核就可以抢占。换言之就是只要调度时安全的,那么内核就可以抢占。内核抢占会发生以下事情:

  • 在进入内核空间前执行中断程序
  • 内核的抢占机会过多的时候
  • 显示更改need_resched标志的时候
  • 任务阻塞期间

中断与异常

中断指CPU的运行期间收到了外部的干扰或者程序预先安排的中断时间引起了CPU停止运行程序,转去运行其他程序的过程,在运行之后再重新执行被中断的程序。中断分为两种,一种是硬件中断,一种是异步中断:

  • 硬件中断是CPU产生的,因为只有执行完相应的操作后才会引发中断,而不是代码执行期间。
  • 异步中断指其他设备或者程序按照CPU的始终产生的,意味着中断可以再指令之间异步发生。

同时异常产生的中断是程序的错误产生的,或者是内核必须处理的一场条件产生的。第一种是通过信号处理,第二种是恢复异常需要的步骤。

这里注意,中断和进程切换不同,进程切换产生了新的进程,中断处理只是一个方法。同时中断也是CPU最敏感的操作,需要满足如下条件:

  • 内核需要做其他事情的时候就会触发中断,中断存在的目的就是推迟处理。因此内核最后需要划分两部分,关键的部分和退出的部分
  • 中断的产生具有随机性,而且是可以堆叠的,这样可以让更多的IO设备处于运行状态,所以必须要嵌套运行,这样才可以完成所有的进程。
  • 中断存在临界区,如果中断处于这些区域内,中断就会被限制,其次中断必须处于可以随时被相应的状态(开中断)

当然如果你学过STM32,一定会知道NVIC的中断管理方式,其实等同于Linux内核的向量中断。

2

  • 中断信号在传递的过程就是中断号,其中中断控制器把中断信号发送给CPU就会产生一个中断向量,中断向量与中断号存在一一对应的关系。
  • 存在一种可以屏蔽的中断信号,中断控制器可以忽略他
  • 存在一种不可以被忽略的中断信号,一般是非常紧急的时间,区分它需要使用CPU
类别原因异步/同步返回行为
中断来自I/O设备的信号异步总是返回到下一条指令
陷阱有意的异常同步总是返回到下一条指令
故障潜在可恢复的错误同步返回到当前指令
终止不可恢复的错误同步不会返回

中断处理器在把中断号变成中断向量值得过程时,就会在内存中建立一个向量表,其中包含了所有中断向量,中断向量表明了中断程序的地址。

为了处理中断,每个硬件都会于一条IRQ线连接,每个IRQ都和一个可编程的中断向量控制器连接,控制器负责将收到的信号转化为对应的向量,这些向量存储于控制器内,CPU可以按照需求进行读取,一旦产生中断,COU的NTR引脚就会被激活。当一切结束后,NTR引脚重置。

中断向量控制器也是一种APIC,可以更好的发挥并行能力。

异常的种类非常多,为了处理这些异常,内核必须提供所有异常的处理方法,在执行某些方法时只需要返回一个错误码即可。

水印堆叠之法


为了能将信号与对应的中断向量建立联系,内核制定了一套中断描述符表(IDT)来存储中断和异常。

  • 任务门:中断信号发生后,把目标进程移到此区域内,用户不可以访问到,同时所有中断处理程序都是通过中断门激活的
  • 系统门:用户可以访问到的区域,可以使用对应的汇编指令进行操作
  • 系统中断门:可以被用户访问到的中断,异常处理程序从这里激活
  • 陷阱门:用户不可以访问的门,大部分异常处理程序都是通过此门激活
  • 任务门:不能被用户访问的门,处理浮点异常等

为了能让CPU获得更多的工作时间,中断控制器会确定中断和关联的中断向量:

  • 如果触发了中断,首先读取IDT的值,确定触发中断程序的地址
  • 确定中断发生源,并且判断中断的等级,这样可以避免进入了错的门
  • 检查等级的变化,读取tr寄存器,将中断的处理部分放入栈里,然后运行
  • 如果出现问题就需要保存在cs和cip寄存器内,并将引发故障的指令放入栈。
  • 如果差生了硬件码,则放入栈内

确定异常、中断向量。

权限、特权检查;针对不同类型的异常、中断,保存不同的内容。

将从异常、中断向量得到的中断或异常处理程序地址装入 cs、eip 寄存器。

最有意思的一点就是:中断可以触发中断,或者一个中断噎死另一个中断。


这样我们就知道了,异常的实现就是中断,既然如此我们就发现了java的try catch的语法就是一个中断信号的响应。那么中断的处理过程是怎样的呢?

中断机制包含三个部分:中断系统的初始化,中断的处理,中断的API

初始化

首先分配IDT一定的内存空间,我们知道中断向量每个都是八字节,而且有256个中断向量,所以内存空间为2048b,也就是2kb。第二部就是初始化保留的中断向量,完成其余的中断向量初始化。

这里区分一下向量中断和非向量中断。向量中断时中断有不同的入口,非向量中断仅有一个入口地址,然后进行判断是哪种中断。

  • 向量中断模式用于RESET、NMI(非屏蔽中断)、异常处理。当向量中断产生时,控制器直接将PC赋值
  • 非向量中断模式,有一个寄存器标识位,跳转到统一的函数地址,此函数通过判别寄存器标识位和优先级关系进行中断处理。

中断的处理

设备产生中断后,通过信号发送给中断控制器,然后变化为中断向量给CPU的NTR口,然后执行相应的程序。

4

中断控制器执行:处理中断请求,比较中断的优先级,返回中断向量给CPU。

CPU执行:使用中断向量在IDT内查询,等级变化进入栈,进入中断处理程序。

API

1
2
3
4
5
// 注册IRQ
int request_irq(unsigned int irq, irq_handler_t handler, unsigned long flags, const char *name, void *dev);

// 释放IRQ
void free_irq(unsigned int, void *);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 激活当前CPU中断:
local_irq_enable();

// 禁止当前CPU中断:
local_irq_disable();

// 激活指定中断线:
void enable_irq(unsigned int irq);

// 禁止指定中断线:
void disable_irq(unsigned int irq);

// 禁止指定中断线:
void disable_irq_nosync(unsigned int irq);

5

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

上半部分:

6

下半部分:

7

软中断(可以被加塞):

8

Tasklet

队列

11

软中断拥有中断上文的全部性质,同时他有自己定义的32个接口,软中断不能同类打断,只能被硬中断打断,同时软中断时并发的,也就意味着软中断需要锁保护。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
enum{
// 最高优先级软中断
HI_SOFTIRQ=0,
// Timer定时器软中断
TIMER_SOFTIRQ,
// 发送网络数据包软中断
NET_TX_SOFTIRQ,
// 接收网络数据包软中断
NET_RX_SOFTIRQ,
// 块设备软中断
BLOCK_SOFTIRQ,
// 块设备软中断
IRQ_POLL_SOFTIRQ,
// tasklet软中断
TASKLET_SOFTIRQ,
// 进程调度及负载均衡的软中断
SCHED_SOFTIRQ,
HRTIMER_SOFTIRQ,
// RCU相关的软中断
RCU_SOFTIRQ,
NR_SOFTIRQS
};

tasklet

这是一种延迟型的软中断,一般的软中断都是内核分配的,而tasklet时可以进行动态分配,不过tasklet绑定CPU,性能就会有所下降

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 静态tasklet
DECLARE_TASKLET(name, func, data)

// 动态tasklet
void tasklet_init(struct tasklet_struct *t, void (*func)(unsigned long), unsigned long data);

// 禁止tasklet执行
void tasklet_disable(struct tasklet_struct *t);

// 使能tasklet
void tasklet_enable(struct tasklet_struct *t);

// 调度tasklet
void tasklet_schedule(struct tasklet_struct *t);

// 删除tasklet
void tasklet_kill(struct tasklet_struct *t)

因为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关联起来

14

使用的API如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 创建工作队列
struct workqueue_struct *create_workqueue(const char *name);

// 编译时创建
DECLARE_WORK(name, void (*function)(void *), void *data);

// 运行时创建
INIT_WORK(struct work_struct *work, void (*function)(void *), void *data);

// 添加到指定工作队列
int queue_work(struct workqueue_struct *queue, struct work_struct *work);
<br>
int queue_delayed_work(struct workqueue_struct *queue, struct work_struct
<br>
*work, unsigned long delay);


// 添加到内核默认工作队列
int schedule_work(struct work_struct *work);
int schedule_delayed_work(struct work_struct *work, unsigned long delay);

// 取消任务
int cancel_delayed_work(struct work_struct *work);

// 清空队列中的所有任务
void flush_workqueue(struct workqueue_struct *queue);

// 销毁工作队列
void destroy_workqueue(struct workqueue_struct *queue);

中断的使用需要在使用Linux内核的单片机,同时编译内核时也需要开启GPIO,具体可以参考第一期编译的过程。

总结

过年了,休息一下,暂停更新

本期博文主要介绍了中断和IO模型,通过阅读本期博文,读者可以更好的理解包括异常处理、线程池等,读者可以使用已有的API完成C对系统的更好的使用。


[5]开发一个Linux系统吧——IO模型与中断
https://blog.minloha.cn/posts/131831f428382a2023011829.html
作者
Minloha
发布于
2023年1月18日
更新于
2024年9月15日
许可协议