linux中断
内核态和用户态用户态进程执行代码访问数据都在访问[0,3GB)进程地址空间访问用户代码和数据内核态进程访问[3GB,4GB)地址空间的时候就是访问OS的过程内核态的权限级别更高如果OS定期主动检测外设是否准备好而OS有很多工作要做比如进程调度比如内存、文件管理等所以效率低并且OS属于内存键盘是外设轮询的效率就更低了所以采用中断当键盘有被按下键盘通知OS我好了OS再去读取键盘输入的数据也就是硬件层面的中断机制如下图我们知道数据层面通信CPU不会和外设直接打交道但是CPU可以直接给外设发送控制信号直接控制外设通信和控制都是信息传递并且外设比如输入设备也可以通过导线和中央处理器进行某种连接也就是不仅CPU可以给外设发控制信号外设也可以给CPU传递一些必要的信号中断OS为了实现硬件级别CPU对外部数据搬运和管理的硬件工程师设立硬件中断1.硬件中断1.1 原理常规事件发生期间发生突然事件需要停下来当前正在执行的任务去处理突发事件处理完毕回到任务被中断处继续执行外部设备可以触发中断计算机组成中CPU正在执行某行代码准确的说是某行指令肯定是以进程为载体OS调度某个进程执行某个进程的代码或任务打破学科之间的壁垒降低记忆成本谈硬件中断也与软件分不开而既然CPU在执行某个进程那CPU寄存器内部肯定保存当前进程的硬件上下文假设进程需要从键盘读入数据之后进程以阻塞/非阻塞的方式运行键盘数据就绪之后硬件层面上键盘作为外设会间接地给CPU特定地针脚触发高电平CPU背面针脚可以是侧面式的一圈单片机的ARMCPU跟连接方式有关英特尔的针脚在CPU背面排针上是导线导线连接主板主板就可以让键盘和CPU走线键盘被按下直接或间接向CPU触发高电平触发电脉冲CPU识别到某个特定针脚上有电平就识别到外设给自己发中断中断的本质是高低电平实际情况复杂的多因为计算机体系结构里有很多外设键盘内存显卡网卡磁盘包括外接的各种设备鼠标话筒音响CPU针脚有限而且大部分针脚要做通信跟内存对接跟数据总线/控制总线勾连CPU要用相对有限的针脚来处理相对多甚至相对无限的外设上面是简化情况外设并没有和CPU直接关联而是通过中断控制器当中断控制器收到并受理来自外设的中断中断控制器通知CPU中断控制器的作用一根和CPU相连的导线可以复用来处理多个外部设备发的中断类似拓展坞一端在CPU另一端连多个USB接口拓展CPU处理中断的能力间接地获取外设触发中断时中断的相关信息中断控制器在x86或老式设备诸如8259这样的板子中断控制器可以做各种级联复用一根导线向CPU触发中断也就是发高电平单片机中断用的比较多OS比较发达所以我们学习的是带OS的平台硬件中断是被隐藏起来的我们一般感受不到外设就绪给中断控制器发中断触发高电平中断控制器在主板上和CPU是连在一起的中断控制器收到之后通过导线向CPU触发高电平发送电脉冲告诉CPU有外设准备好了CPU内部规定特定针脚如果低电平没有中断高电平就是有中断也就是要实现中断效果CPU首先要支持中断如果CPU在硬件层面上不支持中断就不会读取来自中断控制器的电脉冲更不会处理中断也就是说中断需要多方面而不是单一设备的支持包括外部设备中断控制器和CPU看到中断识别中断分析中断处理中断并且CPU是不知道什么时候产生中断键盘什么时候被按下磁盘什么时候准备好网卡什么时候有数据因此中断是异步产生的中断以及中断号需要硬件电路来获取真实情况复杂的多所以下图有一些不严谨外部设备可以是网卡网络里来了数据CPU怎么知道磁盘进程要打开文件OS把文件代码数据加载到内存本质是拷贝OS肯定不可能在旁边干等着所以会去做其它的事那OS怎么知道数据拷贝好了来自不同设备的中断处理动作是不一样的CPU在中断来之前肯定要内置中断的处理方法如果硬件实现首先根本就不知道到底会有多少外设涉及到走线、布局、硬件电路的设计就会非常复杂所以用软件实现OS内置中断向量表可以简单理解为函数指针数组typedefvoid(*handler_t)(void)handler_t IDT[NUM];外设接入之后有唯一的中断号中断控制器能够分辨外设对应的中断号中断控制器也有寄存器把中断后存储在寄存器中CPU得知有中断就向中断控制器问询中断号软件思维真实情况是纯硬件电路完成的转化为数组下标通过索引中断向量表就可以获取处理中断的方法假设得到的数组下标是0通过索引之后查到函数指针接着OS执行处理键盘的操作处理结束键盘输入的数据就拷贝到内存了CPU就可以被动式的相应中断如果要执行键盘处理的函数CPU要取指令读指令分析指令形成临时数据修改PC指针也就是要利用CPU内部的寄存器那本来正在执行的“进程”也有自己的上下文存在CPU的寄存器里如果直接执行键盘处理函数就会覆盖寄存器里的值等到执行完处理函数接着执行进程的时候就会出现混乱所以CPU在识别到有中断之后硬件层面上就通过压栈保护现场恢复现场就是弹栈暂停CPU正在执行的任务处理硬件突发事件结合中断号和中断向量表称为硬件中断由外部触发中断系统处理流程中断向量表由软件OS提供其它都是硬件自动完成和信号很像当中断来的时候CPU的工作被打断要处理中断当信号来的时候进程要被打断转而执行信号处理函数handler表就是中断的中断向量表信号编号就是中断向量号本来是先有的硬件中断由硬件处理后来发现进程也需要这样的机制发明了信号机制模拟硬件中断纯软件方式模仿中断完成特定任务处理中断和信号原理类似但底层实现不同类似康师傅和庚师傅中断有中断号可以被屏蔽类似block表有优先级比如抢占优先级次优先级中断向量表是OS一部分启动的时候加载到内存因为有中断所以OS不需要对外设进行周期性的检测或轮询了解中断原理中断描述符表里有很多表项比如中断服务是否开启中断优先级等设定标志位要字节来保留更多的选项键盘1.2 OS本质那么键盘可以固定例程给CPU触发中断OS没有生命就是一个软件块一个躺在中断向量表中的软件集合入口软件程序外部晶振对电力敏感的设备硬件电路完成主板上集成和CPU在一起1.3 时间计算原理在电脑拔掉电源关机一个礼拜开机时间还是正确的因为主板上有一个纽扣电池在断电的情况下可以向主板上的计时单元供电硬件电路会计算当前的时间一旦开机即可获取并且给纽扣电池充电但如果关机时间过长开机时间不对因为纽扣电池没电了但是联网就可以了因为OS有时间校准功能通过网络1.3.1 进程调度原理下图中sched是关于调度的初始化在main_rename也就是入口中接着把timer_interrupt方法绑定到20中断向量号timer_interrupt是由反汇编写的调用do_timerdo_timer会检测时间片进行调度等所以通过do_timer把进程调起来进程通过系统调用把OS调起来OS调度进程进程进行申请内存、打开文件、访问设备、通信、发信号调OS时间片耗尽就是当前进程PCB的counter0OS为什么能执行调度算法固定时间间隔的时钟中断当代时钟源硬件电路集成在CPU内部因为如果作为外设太慢每次要做很多事情发中断还要自动控制器忙前忙后并且1ms的周期是不是有些短可以查一下电脑的处理器主频GHz的频率ns级别的周期OS也就是通过时钟源时钟中断硬件中断来运行的·OS内部会启动很多内核级进程/线程不存在没有进程的情况定期刷新数据到磁盘2.软件中断内核层面每一个系统调用都有一个系统调用号也就是数组下标因为系统调用表是函数指针数组假设用户调用ssize_t read(fd,xxx);linux的gnu c标准库几乎把所有的系统调用都封装了我们调用上层函数ubuntu 24.04的man手册都直接明牌了就是C标准库的用户直接通过syscall/int 0x80系统调用号也能直接进行软中断来系统调用//源代码路径linux-2.6.18\linux-2.6.18\include\asm-x86_64\unistd.h/* at least 8 syscall per cacheline */#define__NR_read0__SYSCALL(__NR_read,sys_read)#define__NR_write1__SYSCALL(__NR_write,sys_write)#define__NR_open2__SYSCALL(__NR_open,sys_open)#define__NR_close3__SYSCALL(__NR_close,sys_close)#define__NR_stat4__SYSCALL(__NR_stat,sys_newstat)#define__NR_fstat5__SYSCALL(__NR_fstat,sys_newfstat)#define__NR_lstat6__SYSCALL(__NR_lstat,sys_newlstat)#define__NR_poll7__SYSCALL(__NR_poll,sys_poll)#define__NR_lseek8__SYSCALL(__NR_lseek,sys_lseek)#define__NR_mmap9__SYSCALL(__NR_mmap,sys_mmap)#define__NR_mprotect10__SYSCALL(__NR_mprotect,sys_mprotect)#define__NR_munmap11__SYSCALL(__NR_munmap,sys_munmap)#define__NR_brk12__SYSCALL(__NR_brk,sys_brk)#define__NR_rt_sigaction13__SYSCALL(__NR_rt_sigaction,sys_rt_sigaction)#define__NR_rt_sigprocmask14__SYSCALL(__NR_rt_sigprocmask,sys_rt_sigprocmask)#define__NR_rt_sigreturn15__SYSCALL(__NR_rt_sigreturn,stub_rt_sigreturn)...部分版本在glibc中系统调用实现方式//glibc-2.4/sysdeps/unix/sysv/linux/x86_64/sysdep.h#defineINTERNAL_SYSCALL_NCS(name,err,nr,args...)\({\unsignedlongresultvar;\LOAD_ARGS_##nr(args)\LOAD_REGS_##nr\asmvolatile(\syscall\n\t\:a(resultvar)\:0(name)ASM_ARGS_##nr:memory,cc,r11,cx);\(long)resultvar;})那是怎么知道有多少参数呢既然能把系统调用号压到寄存器eax那也可以通过寄存器传入参数个数比如约定edx比如3可以使用寄存器3可以使用栈空间函数调用有栈帧形成临时拷贝把所有参数可以放到一张表把表的地址和参数个数传进来宏定义来处理多参数系统调用只有syscall/int 0x80能够修改状态位而访问内核只能通过系统调用把syscall/int 0x80和中断向量号绑定只能通过中断访问内核如果通过syscall/int 0x80来访问内核呢还有一层屏障因为只能通过系统调用访问内核所以syscall之后的例程是固定的因为一般会把系统调用号传给寄存器比如eax接着syscall之后直接通过系统调用号访问系统调用表因此即使syscall可以进入内核也只能进行系统调用而不是胡作非为修改内核数据结构硬件和软件结合保证系统调用信号就是纯软件模仿中断信号编号就是中断向量号信号处理方法就是IDT而信号的信息保存在PCB只有在内核态才能访问所以在系统调用结束内核态返回用户态时顺手就看一下有没有需要处理的信号并处理3.异常硬件中断–时钟中断外设中断CPU内部的软件中断比如syscall/int 0x80叫做陷阱–系统调用的实现CPU内部的软中断比如除零/野指针等叫做异常比如缺页异常比如缺页中断–MMUOS进行调页内存碎片处理–内存硬件报错OS检查可以把不活跃进程的代码和数据放到swap分区把缓冲区内容刷新到磁盘除零野指针错误–CPU或者MMU报错如果是野指针OS继续检查是没有加载到内存还是真的越界了系统内提前把常见错误的处理方法绑定中断号OS是一个软件块躺在中断处理例程上的代码块OS是一个基于中断处理的软件集合比如文件内核缓冲区是由OS刷新到磁盘比如父子进程发生写时拷贝w的时候权限是r由OS来做这种由OS来做的80%的工作都是通过中断来实现的处理当前任务的时候又来了一个需要紧急处理的任务当前任务/执行流被打断转而执行突发事件处理完回到当前执行流很形象的名字中断4.虚拟地址空间4.1 再谈地址空间linux中很多地方需要进行权限管理内核态和用户态需要硬件级别的支持是CPU两种执行级别CS code segment进程的虚拟地址空间以32位为例共有4GB[0,3GB)为用户区[3GB,4GB)为代码区CPL也就是Current Privilege Level当前特权级一般会用到两类内核态和用户态执行内核代码和数据处于内核态00表示处于用户态CPL为11两个比特位是CPU的CS寄存器中的低两位当出现中断/软中断陷阱/异常CPU硬件自动将CS.RPL改为00允许CPU执行内核态的代码和访问内核态的数据[3BG,4GB)也可以访问用户态的数据才能进行系统调用CPU中的寄存器内部保存进程运行大量的临时值也就是进程的硬件上下文包括CS其实现在段式管理在弱化在虚拟地址空间出来低两位是RPL内核空间和用户空间通过页表映射为虚拟地址空间页表中有每一个地址单元的权限位其中[0,3GB)的DPL为11[3GB,4GB)的DPL为11当CPU要访问虚拟地址空间的某一地址单元比较CPLDPL如果成立就可以访问如果不成立就不能访问因此用户是不能直接通过手动设置一个范围属于内核区的地址来直接访问和修改内核区数据的那不同的进程用户区的内容肯定有不同定义变量不同则栈空间不同动态库加载到共享区也不一定加载到相同的位置命令行参数环境变量也不尽相同动态申请的区域大小/分配的起始地址/数据内容不同等等那么CPU进行进程调度之后每个进程有不同的页表用户区的虚拟地址空间肯定变化不小但是内核区呢我们知道内核区映射到物理内存包括加载OS的程序系统调用号等而且用户通过中断等方式陷入到内核也只能通过中断向量表中断向量号来访问内核而不是可以肆意修改因为每个进程用到的是同一份OS同一份内核区所以进程调度之后内核区的内容不变因此任意一个进程都能找到OS而且各个进程的用户页表各不相同有一套自己的用户级页表但是共享同一份内核页表那么用户调用的函数要么是自己实现的就在代码区跳转要么是库的静态库的和自己实现的都是在代码区跳转动态库就跳转到共享区如果是内核的比如系统调用表各种异常处理方法等就通过软中断也就是指令0x80或syscall来进入内核跳转到内核调用因此所有的函数调用都是在进程的虚拟地址空间完成的下面是画了两个页表但其实是一个只是简单说明情况我们来重新看一下这张图基于上面对于内核态和用户态的理解CPU执行某个任务准确的说是某个进程代码中某行代码编译汇编生成二进制机器指令中的一行此时CPU处于用户态以32位系统为例执行的是[0,3GB)用户空间的代码和数据CPL为11处于用户态此时因为硬件中断/软中断/异常陷入内核态CPL为00执行系统调用或中断执行完要切换回用户态的时候首先检查pending表和block表对于没有在block表但在pending表中为1的比特位代表的信号可以递达查handler表如果是SIGIGN忽略pending表对应得比特位由0置1即可如果是SIGDFL也就是默认也是在内核态执行执行完切换回用户态也就是主控制流被中断的地方如果是用户自定义处理方式也就是函数指针在用户态需要切换到用户态执行完代码通过系统调用再回到内核态再回到主控制流那为什么不通过用户态直接访问内核态数据因为具有安全风险OS只有一份万一不小心或者恶意破坏OS崩了那么为什么在执行完handler表中的方法后还能回到内核中被中断的地方举个例子本来下一条指令是while(1)地址是0x11223344放在eip中但是执行func()要跳转就把0x11223344压栈接着eip更新为func()地址等func()执行完弹栈获取while(1)地址继续往下执行也就是说会通过压栈等技术保留被中断位置的地址4.2 使用信号模拟OS#includecstdio#includecstdlib#includesignal.h#includetime.h#includevectorintcurrent0;classtask_struct{public:task_struct(intp):pid(p),counter(5){}~task_struct(){}voidDesc(){counter--;}intPid(){returnpid;}intExperied(){returncounter0;}voidRun(){printf(%d号进程正在运行\n,pid);}voidReset(){counter5;}private:intpid;intstatus;intcounter;};std::vectortask_structtask_structs;voiddo_timer(intsigno){task_structs[current].Desc();if(task_structs[current].Experied()){printf(%d号进程过期了\n,task_structs[current].Pid());currentrand()%task_structs.size();task_structs[current].Reset();printf(选择%d号进程\n,task_structs[current].Pid());}task_structs[current].Run();alarm(1);}intmain(){srand(time(nullptr));alarm(1);signal(SIGALRM,do_timer);task_structs.emplace_back(1);task_structs.emplace_back(2);task_structs.emplace_back(3);task_structs.emplace_back(4);task_structs.emplace_back(5);for(;;)pause();return0;}5.其它5.1 sigaction可以看到默认情况下sigaction在执行handler方法时会默认阻塞正在处理的信号信号处理完毕会恢复原来的屏蔽字通过sa_mask字段可以阻塞其它信号信号处理完毕会恢复原来的屏蔽字5.2 volatile为什么会出现上面的情况呢寄存器覆盖了内存flag的内存不可见了有没有什么做法能够让编译器不要优化flag呢加上volatile关键字保持flag变量的内存可见性即使是O3也不会优化flag但其它优化正常进行还有register关键字建议优化到寄存器中但是只能用于局部变量不能用于全局变量5.3 可重入函数下面举了一个例子当头插node1到一半的时候被信号中断在信号处理的时候头插同一个链表最后导致内存泄漏最后析构链表没办法析构node2因为insert是不可重入函数如果重入就可能出问题那什么是可重入呢就是在执行到一半的时候再次执行同一函数不会出问题重入/不可重入是函数的特点特别是处理全局变量的时候比如整个内存中动态申请的空间通过一张链表来维持那么malloc就是不可重入的C中有很多容器都会使用文件配置器所以C中容器的增删一般都是不可重入的5.4 SIGCHLD子进程在退出的时候会给父进程发SIGCHLD信号只不过对于SIGCHLD的默认处理方式是忽略所以一般感受不到如下既然子进程退出的时候会给父进程发信号那在SIGCHLD自定义处理方式中回收子进程这样父进程就不需要阻塞等待子进程退出更加高效本来阻塞等待的时间可以去处理其它任务这样做的问题是什么呢1.如果多个子进程同时退出我们知道在处理某个信号的时候一般阻塞该信号而pending表是一个位图只能表示两种状态收到和没收到某个信号那肯定会有进程发送的SIGCHLD丢失父进程没办法回收子进程2.如果进程没有退出完呢如果我们在handler内部搞一个循环循环十次每次回收一个进程比如有10个子进程只退了9个那剩下一个父进程就阻塞在handler的死循环吗下面是阻塞等待可以看到在回收完9个进程后父进程就在处理函数内部阻塞因为第10个子进程没有退出就阻塞在waitpid内部阻塞等待非阻塞等待没有退出的子进程就造成了内存泄漏进程等待要么就是在父进程收到SIGCHLD信号之后回收这样父进程要在子进程之后退出如果父进程在子进程之前退出子进程退出时发信号父进程都不在了怎么回收呢这时候可能就需要阻塞或者非阻塞轮询回收根据实际业务场景抉择那10个进程9个进程退出还有必要进行第10次waitpid吗到底多少子进程退出是上帝视角程序员不知道嘞所以当然有必要实际上由于Unix系统历史原因父进程可以把SIGCHLD的处理方法设置为SIG_IGN这样OS就会自动回收退出的子进程不会通知父进程那SIGCHLD默认处理就是忽略可以看到用户定义的忽略和系统的忽略不完全一致但在Liunx下是这样其它类Unix系统具体情况具体分析