前言
Linux 不可能让一个进程永远占着 CPU,也不能让正在运行的进程完全感知不到外部设备。
因此,操作系统需要一种机制:
外部事件发生 ↓ CPU 暂停当前执行流 ↓ 进入内核处理 ↓ 必要时唤醒进程或切换进程这就是中断。
I. 什么是中断
0x00 中断的基本概念
中断可以理解为:
外部事件通知 CPU,当前有更重要的事情需要处理。
比如:
- 键盘输入;
- 网卡收到数据;
- 磁盘完成读写;
- 定时器到期;
- 设备发出硬件请求。
如果没有中断,CPU 就只能不断轮询设备:
设备完成了吗? 设备完成了吗? 设备完成了吗?使用中断之后:
CPU 正常执行进程 ↓ 设备产生中断 ↓ CPU 响应中断 ↓ 执行中断处理代码 ↓ 返回原来的执行流0x01 中断和异常的区别
中断:通常来自 CPU 外部的硬件或定时器 异常:通常由当前正在执行的指令引发例如:
键盘按键 -> 外部中断 时钟到期 -> 时钟中断 除 0 -> 异常 访问非法地址 -> 异常 系统调用 -> 软件触发的内核入口II. CPU 响应中断的过程
一个简化后的中断处理流程如下:
1. 当前进程正在运行 2. 硬件向 CPU 发送中断请求 3. CPU 保存必要的执行现场 4. CPU 根据中断号查找处理入口 5. 进入内核态 6. 执行中断处理程序 7. 判断是否需要调度 8. 恢复现场或切换到其他进程0x00 中断号
不同的中断通常有不同的编号。
中断号 ↓ 中断描述表 ↓ 对应的处理函数这和信号编号有点相似,但二者不是一回事:
信号:面向进程的异步通知 中断:面向 CPU 和内核的硬件事件入口0x01 中断处理函数
中断处理函数通常运行在内核态,需要尽快完成工作。
常见工作包括:
- 读取设备状态;
- 从硬件寄存器取数据;
- 清除中断状态;
- 唤醒等待队列中的进程;
- 设置后续软中断或工作队列。
III. 时钟中断
0x00 为什么需要时钟中断
如果没有时钟中断,操作系统很难可靠地实现:
- 进程时间片;
- 定时任务;
- 睡眠唤醒;
- 超时检测;
- 周期性调度。
所以系统会周期性地产生时钟中断:
时钟中断 ↓ 内核获得执行机会 ↓ 更新时间信息 ↓ 检查当前进程时间片 ↓ 必要时进行调度0x01 时间片
多个进程需要共享 CPU。
进程 A 使用一段时间 ↓ 进程 B 使用一段时间 ↓ 进程 C 使用一段时间 ↓ 回到进程 A每个进程允许连续运行的时间,就是时间片的一种抽象。
时钟中断到来时,内核会减少当前进程的时间片计数。如果计数耗尽,就需要重新选择进程。
IV. 用 alarm 模拟时钟中断
用户程序不能直接编写真正的内核时钟中断,但是可以使用 alarm 模拟周期性事件。
0x00 alarm 的基本使用
#include<iostream>#include<signal.h>#include<unistd.h>voidhandler(intsig){std::cout<<"捕捉到信号: "<<sig<<std::endl;alarm(1);}intmain(){signal(SIGALRM,handler);alarm(1);while(true)pause();return0;}程序流程:
alarm(1) ↓ 1 秒后产生 SIGALRM ↓ 进入 handler ↓ 再次 alarm(1) ↓ 重复执行这和真正的内核时钟中断不是同一个层次,但可以帮助理解周期性事件驱动处理的思想。
0x01 alarm 的返回值
取消旧闹钟:
unsignedintleft=alarm(0);返回值表示原闹钟还剩多少秒。
alarm(200);sleep(1);intn=alarm(0);std::cout<<"剩余时间: "<<n<<std::endl;这里 alarm(0) 的作用不是等待,而是取消当前闹钟。
V. 用信号模拟简单调度器
代码中使用了一个简化的 task_struct:
classtask_struct{private:intpid;intcounter;public:task_struct(intp):pid(p),counter(5){}voiddesc(){counter--;}voidResetCounter(){counter=5;}intPid(){returnpid;}boolExpired(){returncounter<=0;}voidrun(){std::cout<<"process "<<pid<<" running"<<std::endl;}};这里的 counter 可以理解为剩余时间片。
0x00 do_timer
voiddo_timer(intsigno){tasks[current].desc();if(tasks[current].Expired()){std::cout<<tasks[current].Pid()<<" 过期了,重新选择进行调度"<<std::endl;current=rand()%tasks.size();tasks[current].ResetCounter();}else{tasks[current].run();}alarm(1);}执行过程:
收到 SIGALRM ↓ 当前任务 counter-- ↓ counter 是否耗尽 ↓ 没有耗尽:继续运行 耗尽:重新选择任务 ↓ 重新设置 alarm0x01 main
intmain(){alarm(1);signal(SIGALRM,do_timer);srand(time(nullptr));tasks.emplace_back(1);tasks.emplace_back(2);tasks.emplace_back(3);tasks.emplace_back(4);tasks.emplace_back(5);for(;;)pause();}这个程序的关键点是:
main 不主动轮询 main 使用 pause() 等待事件 SIGALRM 到来后执行 do_timer do_timer 决定是否切换任务VI. 调度发生在什么时候
0x00 进程主动让出 CPU
进程可能因为以下原因主动离开运行状态:
- 调用 sleep;
- 调用 pause;
- 读取管道时没有数据;
- 等待锁;
- 等待子进程;
- 等待网络数据。
这些操作通常会让当前进程进入等待状态。
0x01 时间片耗尽
即使进程不主动等待,时钟中断也会让内核定期检查它的运行时间。当时间片用完之后,内核可以选择其他可运行进程。
0x02 更高优先级任务唤醒
如果某个等待中的进程被设备或信号唤醒,内核可能需要重新比较可运行任务的优先级。
调度会综合考虑:
运行状态 优先级 时间片 等待原因 是否被唤醒VII. 信号处理和调度的关系
0x00 信号处理不是独立线程
信号 handler 通常运行在接收信号的进程上下文中,不是操作系统额外创建的一个普通线程。
进程 A 正在运行 main ↓ 信号到来 ↓ 内核保存 main 的现场 ↓ 同一个进程转去执行 handler ↓ handler 返回 ↓ 恢复 main0x01 handler 执行期间的信号
handler 执行期间,当前信号通常会被暂时屏蔽,另外还可以通过 sigaction 的 sa_mask 指定额外屏蔽的信号。
新的信号产生 ↓ 进入 pending ↓ handler 返回后再处理VIII. 从中断到信号
硬件事件和进程信号之间可以通过内核连接起来:
硬件设备产生中断 ↓ CPU 进入内核态 ↓ 中断处理函数读取设备状态 ↓ 内核更新某个进程状态 ↓ 唤醒进程或产生信号 ↓ 进程返回用户态时检查信号 ↓ 执行 handler例如终端输入 Ctrl + C 时,可以抽象为:
键盘产生硬件事件 ↓ 终端驱动处理输入 ↓ 终端识别控制字符 ↓ 向前台进程发送 SIGINT ↓ SIGINT 进入目标进程 pending ↓ 目标进程执行默认动作或 handlerIX. 信号处理的结束阶段
0x00 handler 返回
handler 执行完之后,内核需要恢复原来的执行现场:
handler 执行完成 ↓ 恢复寄存器 恢复程序计数器 恢复栈指针 恢复原来的信号屏蔽字 ↓ 回到 main 原来的位置0x01 还有 pending 信号怎么办
如果返回用户态之前,pending 中还存在可递达信号,内核可能继续处理这些信号。
处理一个信号 ↓ 重新检查 pending ↓ 还有可处理信号? ├── 是:继续处理 └── 否:恢复用户态执行0x02 默认动作和进程终止
信号到来 ↓ 加入 pending ↓ 内核检查到信号可递达 ↓ 查到默认动作是 Terminate ↓ 释放进程资源 ↓ 通知父进程 ↓ 父进程收到 SIGCHLD如果信号导致异常终止,父进程还可以通过 waitpid 得到退出信号编号。
X. 退出信息和 SIGCHLD
intstatus=0;pid_t ret=waitpid(child,&status,0);if(ret>0){std::cout<<"exit signal: "<<(status&0x7F)<<std::endl;}子进程执行除 0 的链路:
子进程执行异常指令 ↓ 产生 SIGFPE ↓ 默认动作终止子进程 ↓ 父进程收到 SIGCHLD ↓ 父进程 waitpid 回收XI. 用户态、内核态、中断和信号的完整链路
用户程序运行在用户态 ↓ 系统调用、异常或硬件中断发生 ↓ CPU 进入内核态 ↓ 内核处理中断或异常 ↓ 更新进程状态、pending 或等待队列 ↓ 必要时触发调度 ↓ 选择当前应该运行的进程 ↓ 返回用户态 ↓ 检查目标进程的信号 ↓ 执行 handler 或默认动作 ↓ 恢复现场继续运行,或者结束进程这条链路就是“信号结束”的完整含义:
信号不是停在 pending 表里 而是经过内核判断后完成递达 最后回到用户态或终止进程XII. 总结
中断
硬件或定时器通知 CPU异常
当前指令执行时出现特殊情况时钟中断
周期性进入内核 更新时间 检查时间片 触发调度信号递达
事件产生 ↓ 进入内核 ↓ 保存到 pending ↓ 检查 block ↓ 执行 handler 或默认动作 ↓ 恢复现场最终可以这样理解:
用户态负责运行普通程序,内核态负责管理系统资源,中断负责把外部事件带进内核,信号负责把事件结果通知给进程,调度器负责决定接下来谁运行。
至此,消息队列、信号量、信号、用户态/内核态、中断和调度就串成了一条完整的 Linux 学习路线。