wordpress临时关站微信导入wordpress
目录
信号保存
信号其他相关常见概念
在内核中的表示
信号集操作函数
信号处理
信号处理流程
操作系统是如何运行起来的?
硬件中断
时钟中断
死循环
软中断
内核态与用户态
信号捕捉的操作
可重入函数
volatile
SIGCHLD信号
信号保存
信号其他相关常见概念
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending)
- 进程可以选择阻塞 (Block )某个信号。若某个信号被阻塞了,这个信号产生了,一定会将这个信号pending(保存),但是永远不递达(执行),除非解除阻塞。这里的阻塞与之前的scanf、cin等lO完全不同。
- 被阻塞的信号产⽣时将保持在未决状态,直到进程解除对此信号的阻塞,才执⾏递达的动作
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,⽽忽略是在递达之后可选的⼀种处理动
作。阻塞是不会被递达的,而忽略是会被递达的。所以,这两个概念是不同阶段的概念。
在内核中的表示
信号在内核中的表示示意图
在每个进程的PCB中,都会维护3张表
a. pending
称为pending位图,是一个位图[1,31],表示当前进程收到的信号列表,理论上使用一个int就行了,但是这里使用的是一个自定义的类型。这里[1, 31]是因为普通信号就是1-31
比特位的位置:信号编号;
比特位的内容:1/0,是/否收到对应的信号。
b. handler
handler_t XXX[N]:函数指针数组
信号编号-1:就是函数指针数组的下标,对应的内容就是处理这个信号的方法
所以signal(2,handler)的本质就是根据第一个参数去修改handler表中的内容
c. block
是一个位图[1,31]
比特位的位置:信号编号
比特位的内容:1/0,是/否阻塞对应的信号
如果想阻塞一个信号,与当前进程是否收到这个信号有没有关系呢?是没有关系的,因为是两张位图。
当我们将这3张表横着看,就可以知道每一个信号的处理方法了。之前说过,进程能够识别信号是内置的,因为内核中有相关的数据结构,所以是内置的。
我们来看看在Linux内核中,这3张表的结构
struct task_struct {/*signalhandlers*/struct sighand_struct *sighand;sigset_t blocked;struct sigpending pending;
};
typedef struct{unsigned long sig[_NSIG_WORDS];
}sigset_t;
sigset_t是一个结构体,就是结构体套了一个数组,本质就是一个位图。不会直接使用一个整数,因为那样可延展性太差了。
struct sighand_struct{atomic_tcount;struct k_sigactionaction[_NSIG];// #define _NSIG64spinlock_tsiglock;
};
struct k_sigaction{struct __new_sigaction sa;void_user* ka_restorer;
};
struct __new_sigaction{__sighandler_t sa_handler; // 这个就是函数指针数组unsigned longsa_flags;void(*sa_restorer)(void) ;new_sigset_t sa_mask;
};
typedef void (*__sighandler_t)(int);
struct sigpending {struct list_head list;sigset_t signal; // 位图
};
可以看到,两张位图的数据类型都是sigset_t,而sigset_t里面就是一个unsigned long类型的数组sig,sig[0]是一个unsigned long类型类型的整数,这个整数有64个比特位,就能表示64个信号,当然,我们这里只看1-31号信号。因为数据的类型都是sigset_t,所以未来要访问这两张表,都需要使用sigset_t类型来操作。sigset_t是OS提供给用户的一个数据类型。这里的sig[1]、sig[2]等是可以用来扩展位图容量的,这也正是为什么不使用整数的原因,整数的延展性太差了。
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,只记录这个信号是否产生过,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效"或”无效"状态,在阻塞信号集中"有效"和"无效”的含义是该信号是否被阻塞,而在未决信号集中“有效"和“无效"的含义是该信号是否处于未决状态。下一节将详细介绍信号集的各种操作。阻塞信号集(block表)也叫做当前进程的信号屏蔽字(SignalMask),这里的"屏蔽"应该理解为阻塞而不是忽略。
信号集操作函数
接下来的操作都是围绕这3张表展开的,因为这3张表是OS的内核数据结构,不允许我们直接访问,需要通过系统调用来访问,并且我们之前的操作也是在操作这3张表。有两张表是位图,我们是不建议使用位操作直接对两张表就行操作的,Linux操作系统也提供了一组函数,可以对两个信号集直接就行比特位级的操作。sigset_t是OS提供给用户的一个数据类型,所以用户是可以用它来定义一些对象的,但是不建议自己对定义出的对象进行操作,而是使用针对这个数据类型的接口更好,这批接口就是对位图的增删查改。
#include <signal.h>int sigemptyset(sigset_t *set); // 清空信号集
int sigfillset(sigset_t *set); // 将信号集全部置为1
int sigaddset(sigset_t *set, int signum); // 向指定的信号集中添加信号
int sigdelset(sigset_t *set, int signum); // 从指定的信号集中移除信号
int sigismember(const sigset_t *set, int signum); // 判断一个信号是否在指定的信号集中
sigprocmask
sigprocmask是用来检查和修改信号屏蔽字(block表)的系统调用
#include <signal.h>int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
how的取值有3个:
- SIG_BLOCK:set包含了我们希望添加到信号屏蔽字的信号,相当于mask = mask | set
- SIG_UNBLOCK:set包含了我们希望从当前信号屏蔽字解除阻塞的信号,mask = mask&~set
- SIG_SETMASK:设置当前信号屏蔽字为set指向的值,相当于mask = set
set是一个输入型参数,也是一个信号屏蔽字,如何修改block表是由how和set共同决定的。oldset是一个输出型参数。是保存的老的信号屏蔽字,方便恢复。调用成功返回0,失败返回-1,并设置errno。
sigpending
sigpending是获取当前进程的未决信号集(pending表)的系统调用
#include <signal.h>int sigpending(sigset_t *set);
set的一个输出型参数,调用成功返回o,失败返回-1。不需要提供修改pend表的方法,因为之前学的5种产生信号的方法都会修改pending表。
之前学的signal就是修改handler表的。
现在,通过系统调用,我们已经能够操作这3个表了。
我们来写一段综合代码:首先将2号信号屏蔽,然后不断地获取并打印pending表,然后再给这个进程发送2号信号,此时2号信号肯定不给被递达,也就不会调用它的handler方法,但是我们可以看到pending表的变化。
void PrintPending(const sigset_t& pending)
{std::cout << "curr pending list [" << getpid() << "]: " ;for(int signo = 31; signo > 0; signo --){if(sigismember(&pending, signo))std::cout << 1 ;else std::cout << 0 ;}std::cout << std::endl;
}int main()
{// 1. 对2号信号进行屏蔽// block和oblock都是栈上的临时变量,可能是乱码,所以我们手动清0一下sigset_t block, oblock;sigemptyset(&block);sigemptyset(&oblock);// 2. 添加2号信号// 注意:此时并没有设置进内核中,只是将2号信号添加到了我们自己的block位图中sigaddset(&block, 2);// 3. 设置进入内核中sigprocmask(SIG_SETMASK, &block, &oblock);while(true){// 4. 获取内核中的pending表sigset_t pending;sigpending(&pending);// 5. 打印PrintPending(pending);sleep(1);}return 0;
}
因为2号信号被屏蔽了,所以不会被递达,所以一直是1。
上面的代码种,2号信号会一直是1,现在让其10秒后变成0。我们一开始仍然屏蔽2号信号,等到10秒后解除屏蔽。此时就可以利用oldset了。
int main()
{// 1. 对2号信号进行屏蔽// block和oblock都是栈上的临时变量,可能是乱码,所以我们手动清0一下sigset_t block, oblock;sigemptyset(&block);sigemptyset(&oblock);// 2. 添加2号信号// 注意:此时并没有设置进内核中,只是将2号信号添加到了我们自己的block位图中sigaddset(&block, 2);// 3. 设置进入内核中sigprocmask(SIG_SETMASK, &block, &oblock);int cnt = 0;while(true){// 4. 获取内核中的pending表sigset_t pending;sigpending(&pending);// 5. 打印PrintPending(pending);sleep(1);cnt ++;if(cnt == 10){std::cout << "解除对2号信号的屏蔽" << std::endl;sigprocmask(SIG_SETMASK, &oblock, nullptr);}}return 0;
}
此时会发现,解除屏蔽后进程立刻就退出了。因为我们之前已经向进程发送过2号信号,等到2号信号解除屏蔽后,立刻就会处理2号信号,而2号信号的默认行为就是让进程退出,所以进程就退出了
若我们想看到2号信号解除屏蔽后的pending表,就不能让进程退出。可以自定义2号信号的行为,或者直接忽略2号信号。
int main()
{::signal(2, SIG_IGN);// 1. 对2号信号进行屏蔽// block和oblock都是栈上的临时变量,可能是乱码,所以我们手动清0一下sigset_t block, oblock;sigemptyset(&block);sigemptyset(&oblock);// 2. 添加2号信号// 注意:此时并没有设置进内核中,只是将2号信号添加到了我们自己的block位图中sigaddset(&block, 2);// 3. 设置进入内核中sigprocmask(SIG_SETMASK, &block, &oblock);int cnt = 0;while(true){// 4. 获取内核中的pending表sigset_t pending;sigpending(&pending);// 5. 打印PrintPending(pending);sleep(1);cnt ++;if(cnt == 10){std::cout << "解除对2号信号的屏蔽" << std::endl;sigprocmask(SIG_SETMASK, &oblock, nullptr);}}return 0;
}
可以看到,解除对2号信号的屏蔽之后,2号信号被成功递达了。
信号处理
信号处理有3种方式,默认、忽略、自定义,前面谈的都是自定义,对于默认和忽略并没有详谈
信号处理流程
我们之前说过,当一个进程收到了一个信号,不一定是立即处理,而是等到合适的时候再处理,这个合适的时候是什么时候呢?
是进程从内核态切换回用户态的时候,这时候回检测当前进程的Pending表和block表,决定是否处理收到的信号,若需要处理,再结合handler表处理信号。OS的运行状态:用户态、内核态。
当我们写代码时,代码中有while、for、if、定义变量等,都是将代码编译在地址空间的代码区,此时是用户态的;代码中的系统调用,或者封装了系统调用的库函数如printf等,此时进程会进入内核态,来完成系统调用,此时是内核态的。所以,用户态就是CPU在执行我们自己写的代码,内核态就是CPU在执行OS的代码。
信号处理的流程:在执行主控制流程的某条指令时因为中断、异常或系统调用进入内核(用户态->内核态),内核处理异常,准备回用户模式之前,先处理当前进程中的信号,其实就是检测3张表,若pending表全为0,或者pending表为1的信号的block表为1,也就是没有需要处理的信号,则直接回到用户模式,从系统调用的下一条开始执行,若有需要处理的信号,此时回查看handler表,若这个信号的handler表中是SIG_DLF,SIG_DLF是终止进程或者让进程暂停,此时就将进程的状态设置成对应的状态;若是SIG_IGN,会将Pending表对应位置由1改回o,然后继续向后运行,所以,若是SIG_DLF或者SIG_IGN,在内核态返回用户态时,就已经处理完信号了;若是自定义的函数,当处理方式是自定义时,会根据do_signal跳转到用户态的自定义函数哪里,并执行这个方法,完成信号处理,然后再回到内核态,调用sys_sigreturn,返回用户态,从系统调用的下一条开始执行。
信号自定义捕捉会涉及4次用户态、内核态的切换。
操作系统是如何运行起来的?
OS是计算机开机之后启动的第一个程序,并且这个程序只要我们不关机就不会被关闭
硬件中断
当代计算机的外设是非常多的,所以外设并不是直接连接CPU的,而是连接在了中断控制器上,如键盘被按下之后,此时外设就准备就绪了,就会向中断控制器发起中断,中断控制器就能够得到发起中断设备的中断号,也就是知道是那一台设备发送的中断,而中断控制器是直接连接CPU的,得到中断号后,就会向CPU发送中断,首先通知CPU,CPU得知中断后,就会去中断控制器中获取中断号。
OS为了能够处理每一个设备,在设计的时候,就提供了一个中断向量表,我们把它理解成一个函数指针数组即可,IDT[N],当我们要执行这个中断向量表中的某一个方法时,就需要使用下标来访问,这个下标就称为中断号,中断向量表中会有对各种外设的中断从处理方法,这些方法是OS内置的或者从外设对应的驱动程序中获取的。中断向量表就是操作系统的一部分,启动就加载到内存中了。通过外部硬件中断,操作系统就不需要对外设进行任何周期性的检测或者轮询。由外部设备触发的,中断系统运行流程,叫做硬件中断。
现在,CPU获取了中断号,而OS内部又有这个表,此时就可以处理中断了。
- 在硬件上,CPU得知有中断后,CPU此时可能正在执行某项任务,寄存器上都是这个任务的数值,此时会先将这些上下文信息保存,将CPU变成空闲状态,称为CPU保护现场;
- 在软件上,OS会拿着CPU获取的这个中断号去查中断向量表,获取处理中断的方法。
在硬件和软件上做的事情,称为中断处理历程:1.保护线程 2.根据中断号n,查表 3.调用对应的中断方法。中断处理完毕之后,会恢复现场,继续之前的工作。这样,OS再也不关心外设是否准备好了,外设自己准备自己的,OS自己忙自己的,等到外设准备好了之后,会通过CPU主动告知OS
举几个例子:
1.假设现在要访问磁盘,从磁盘中读取数据,假设读取某一个文件,OS就会告诉磁盘,这个文件对应的LBA地址,磁盘就开始准备了,OS就忙它自己的,准备好了之后,就会向CPU发送中断,OS就会知道磁盘准备好了,OS就会根据中断号去执行对应的方法,将外设的数据拷贝到内存的缓冲区当中;
2.假设现在要打印,OS将要打印的数据放在内核缓冲区当中,若显示器还没有准备好,OS不管它,继续忙自己的事情,等到显示器准备好了之后,就会像CPU发送中断,OS就会知道显示器准备好了,OS就会根据中断号去执行对应的方法,将缓冲区内的数据刷新到外设。
时钟中断
进程可以在操作系统的指挥下,被调度,被执行,那么操作系统自已被谁指挥,被谁推动执行呢?
外部设备可以触发硬件中断,但是这个是需要用户或者设备自己触发,有没有自己可以定期触发的设备?
在外部设备中,有一个叫时钟源的东西,它会不断地给CPU发送中断,每次中断间间隔的时间非常非常短,这个时钟源也是有中断号的,假设为n,如果在中断向量表中给下标为n的位置放的方法是进程调度。因为它不断地给CPU发送中断,所以OS会不断地执行它对应的中断服务,也就是不断地进行进程调度。所以,OS为什么能够跑起来呢?就是因为时钟中断,一直在推动OS进行进行调度。所以,OS是基于中断向量表进行工作的。
当代的x86芯片认为时钟源若要通过中断控制器向CPU发送中断,太慢了,并且还会占用中断控制器的资源,所以,当代的CPU已经将时钟源集成到CPU内部了,所以CPU的参数里面会有一个指标叫主频,CPU的主频表示每秒钟可完成的时钟周期数。主频越高,理论上CPU执行指令的速度越快。简单而言,主频就是CPU每秒时钟中断的次数,当次数越多,说明OS进行进程调度的频率比较高,进行进程调度的频率比较高,则说明处理任何中断服务的速度都比较快,CPU响应速度比较快,所以效率高。所以,OS就在硬件的推动下,自动调度了。
死循环
如果是这样,操作系统不就可以躺平了吗?对,操作系统自己不做任何事情,需要什么功能,就向中断向量表里面添加方法即可。操作系统的本质:就是一个死循环!所以,OS只需将中断向量表初始化完成,OS直接让自己进入死循环,什么都不做,等待别人给它发送中断即可。
void main(void)
{for(;;)pause();
}
之前写这个代码就是用信号的方式模拟出了OS的执行过程
int main()
{gfuncs.push_back([](){std::cout << "我是一个内核刷新操作" << std::endl;});gfuncs.push_back([](){std::cout << "我是一个检测进程时间片的操作,如果时间片到了,我会切换进程" << std::endl;});gfuncs.push_back([](){std::cout << "我是一个内存管理操作,定期清理操作系统内部的内存碎片" << std::endl;});alarm(1);signal(SIGALRM, hanlder);while(true){pause();std::cout << "我醒来了..." << std::endl;gcount ++;}
什么是时间片?时钟中断的频率是固定的,假设每隔1纳秒发送一次时间中断。我们可以给每一个进程设置一个时间片,假设当前进程的时间片是1微秒。也就是说,当前进程的时间片是时钟中断频率的1000倍。
struct task_struct
{int count = 1000; // 时间片
}
所以,时间片的本质就是一个int,我们前面说了,时钟中断的中断处理是进程调度,但是这个进程调度不一定是切换进程,只是让正在被调度的时间片--,只要当减为0了,才会被切换。所以,时间片的本质就是PCB内部的一个计数器。
软中断
上面说的外设触发中断,或者时钟中断都是外部硬件中断,需要硬件设备触发。有没有可能,因为软件的原因,也触发上面的逻辑呢?有,如除0、野指针、缺页中断(这3个都是异常)、系统调用等。我们之前的都是外设通知CPU,然后CPU和OS一起执行中断处理历程,可以在CPU内部设置CPU支持的指令,让其在没有外设通知的情况下,自己执行一次中断处理流程。
我们以系统调用为例,其他的也是类似的。为了让操作系统支持进行系统调用(除0、野指针等都是一样的),CPU也设计了对应的汇编指令(int 0x80(32位下)或者syscall(64位下)),可以让CPU内部触发中断逻辑。因为是汇编指令,所以就可以写到软件当中了。所以,未来就可以使用软件来触发CPU执行中断方法。用软件推动CPU执行中断的方法称为软中断。说得通俗一点,硬件中断就是给了CPU一个中断号,让CPU执行中断处理方法;CPU可以自己形成一个唯一的中断号,使用汇编指令让CPU在不需要外部设备驱动的前提下自动就能执行中断处理方法,这就是软中断。int的中断号是0x80。
既然中断向量表中可以各种硬件的中断服务、进程调度的方法等,就一定可以在中断向量表中设计一个系统调用的入口函数sys_function,并将0x80 对应的函数指针设置为这个函数。OS会提供大量的系统调用,OS是将这些系统调用的函数指针放在一个数组当中,形成一个系统调用表。未来要调用那个系统调用,使用这个系统调用的数组下标即可,这个数组下标称为系统调用号。系统调用的入口函数有一个参数,就可以根据这个参数去调用对应的系统调用。
void sys_function(int index)
{sys_call_table[index]();
}
所以,软中断就是CPU执行到特定的指令(如intOx80或syscall,这两者都是写在可执行程序内部的),触发软中断,主动请求内核服务,也就是去执行中断向量表中对应的函数。
问题:
1.用户写的代码中调用了系统调用,我们现在知道了调用系统调用后会让cPU执行intOx80或者syscall来触发软中断,然后软中断会去中断向量表中调用系统调用的入口函数sys_function,然后根据传入的系统调用号调用对应的系统调用,也就是说,OS需要知道一个系统调用的系统调用号,此时才能在调用sys_function时进行传参,用户层怎么把系统调用号给操作系统呢?
此时是使用寄存器的(如EAX),在触发软中断之前,会先将要调用的系统调用的系统调用号写到CPU的一个寄存器当中,设置系统调用参数完成后,再执行int0x80或syscall触发软中断,此时就可以根据寄存器中的值进行调用对应的系统调用了。
2. 系统调用的返回值如何返回给用户呢?是通过寄存器或者用户传入的缓冲区地址。
通过上面两个问题,我们可以知道:OS在用户和内核之间传递信息使用的是CPU的寄存器,实际上,C/C++中调用函数传参,函数的返回值都是利用CPU的。
系统调用的过程,其实就是先将系统调用号写入寄存器,然后根据系统调用号自动查表,执行对应的方法。所以,系统调用也是通过中断完成的。
其实Linux内核提供的系统调用接口,是系统调用号+约定的传递参数+返回值的寄存器组成的,但是这样使用是十分困难的,所以对这些系统调用进行了封装,也就是GNU glibc这个库给我们提供了系统调用的C语言封装版。我们之前使用的系统调用fork,都是经过GNU glibc封装过后的,当我们调用fork时,会自动完成将系统调用号放到寄存器中,触发软中断,执行对应的中断方法等一切操作。看看fork的实现:
刚刚我们说的是在系统调用的角度看待软中断,实际上只要是软件问题,都可以触发软中断。
注意:这些触发软中断就与syscall等无关了,syscal是系统调用触发软中断时会调用的,像除0、野指针、缺页中断等是因为触发了异常。
缺页中断?内存碎片处理?除零野指针错误?这些问题,全部都会被转换成为CPU内部的软中断,OS会提前给这些问题设置对应的中断处理方法,并且每一个每一个问题都有对应的中断号,然后走中断处理例程,完成所有处理。有的是进行申请内存,填充页表,进行映射的。有的是用来处理内存碎片的,有的是用来给目标进行发送信号,杀掉进程等等。
总结:
- 操作系统是躺在中断处理历程上的代码块
- CPU内部的软中断,比如intOx80或者syscall,我们叫做陷阱
- CPU内部的软中断,比如除零/野指针/缺页中断等,我们叫做异常。所以,缺页中断也叫做缺页异常
内核态与用户态
A函数中调用了B函数,怎么在调用完B函数后,向下继续执行A函数的代码呢?
会将A函数调用B的下一句语句先入栈,当调用完B函数,弹栈后,根据栈内的信息继续执行A函数的下一个语句。
进程访问用户区直接就可以进行访问,不用系统调用,使用的是虚拟地址。页表不仅仅存在用户页表,还存在内核页表。我们作为普通用户,我们最关心内核区中的内容就是系统调用,因为我们只能通过系统调用访问OS。我们在调用我们自己的函数时,是通过虚拟地址,调用动态库中的方法时,是通过虚拟地址+偏移量,所以,调用系统调用时,是不是也要使用系统调用的虚拟地址呢(因为系统调用会通过内核页表映射到内核区,所以我们是可以通过内核区拿到系统调用的虚拟地址的)?实际上,我们并不关心系统调用的地址,无论是虚拟地址,还是物理地址,因为我们只需要知道系统调用号,OS会自已索引查找系统调用。未来在代码中调用了系统调用,与glibc一链接,就会有系统调用号。我们调用系统调用时,当通过系统调用号拿到了系统调用后,调用系统调用与调用普通函数是一样的,都是压栈、弹栈等,并且传参、返回值也是通过CPU。所以,我们调用任何函数(库,系统调用等),都是在我们自己进程的地址空间中进行调用。当一个OS中有很多个进程时,每个进程的进程地址空间的用户区是各不相同的,但是内核区是相同的。操作系统无论怎么切换进程,都能找到同一个操作系统!换句话说,操作系统系统调用方法的执行,是是在进程的地址空间中执行的!
当我们处于用户态时,我们可不可以使用汇编,去修改CPU中,如pc指针的值,让我们去当问3-4GB的空间呢?是不行的,要进入内核区,需要将用户态切换成内核态。每一个进程都有内核区,无论是通过哪一个进程的地址空间进入内核,都是通过软中断进入OS的。实际上,一个进程处于用户态还是内核态,是由CPU上的Cs段寄存器决定的,Cs段寄存器上有2个bit位,0表示内核态,1表示用户态,称为CPL。处于用户态时,只允许访问用户区,因为CPU中集成了MMU,在虚拟地址到物理地址的转化时,用户态只允许0-3GB的转化;当处于内核态时,才允许3-4GB的转化。所以,用户态->内核态:硬件上修改CPU寄存器上的值,软件上执行int0x80或syscall。当调用int0x80或syscall时,CPU会自己修改寄存器上的值。
因为有了MMU,所以在用户态时是不能通过3-4GB中的虚拟地址区转化的。那我们可不可以获取3-4GB中的一个系统调用的地址,并调用int0x80,将用户态切换成内核态,不就可以访问了吗?
是不行的。因为当我们调用了int0x80后,直接就从寄存器中读取系统调用号了,并不会管下面访问3-4GB的地址。通俗一点说,访问自己的代码时是用户态,当问系统代码时是内核态。关于特权级别,涉及到段,段描述符,段选择子,DPL,CPL,RPL等概念,这块我们不做深究了,现在芯片为了保证兼容性,已经非常复杂了,进而导致OS也必须得照顾它的复杂性。
用户是如何进入内核态的?
- 时钟/外设中断
- 异常(除0、野指针、缺页中断等)
- 陷阱(int 0x80、syscall)
总结:OS是如何运行的?
当刚开机,只有OS一个软件在运行时,OS就会停在它的pause,CPU也是闲置的。但是我们之前也说过,哪怕一个进程也没有,OS还是会定期地将内核缓冲区内的数据刷新到外设,此时也没有异常等情况,OS要怎么做呢?实际上,OS在创建时会先fork出一些子进程,这些子进程的任务就是定期地检查各个进程的缓冲区内的数据并进行刷新,这些子进程与未来用户创建的进程一样,都是要被OS调度的进程,这种进程称为OS的内核固定历程。我们上一节介绍的闹钟,OS为什么知道闹钟超时了,就是因为有这些进程在检查。当用户创建了一些进程时,只要还有进程没退出,OS的死循环就不会被执行了,因为CPU一直都在调度我们运行的那个进程。
所以,OS就是一个混合体,既有自己的固定历程,又躺在中断向量表上的一个软件集合。
我们再来补充信号捕捉的一些细节:
1.在执行信号处理函数sighandler时,为什么要做权限切换呢?直接在内核态执行不就行了吗?
因为信号处理函数是可以用户自定义的,如果这个自定义函数中有一些非法操作,如删除用户、删除root的配置文件、给自己赋权等,让OS来执行这些非法操作是不行的,这样就是用户利用OS的更高权限来做一些非法的事情,是不安全的,所以必须进行权限切换。
2.当我们执行完信号处理函数后,为什么又要切换回内核态,再切换回用户态,而不是执行完信号处理函数,就直接继续向后执行呢?
如果在一个函数中想调用另一个函数,只需要知道函数名,但是若想从一个函数执行完毕返回另一个函数,这两个函数必须存在调用关系,因为当一个函数调用另一个函数时,会将调用函数的下一条指令入栈,调用完成之后弹栈就可以回来了。而我们这里的main和sighandler是没有关系的,但是main函数和内核是有关系的,因为陷入内核了,所以必须先返回内核,再返回用户,执行下一条指令。OS怎么知道中断、异常、系统调用等处理完成后执行那一条指令呢?当我们遇到中断、异常、系统调用等,CPU会将下一条指令放入到pc指针中。
信号捕捉的操作
我们之前介绍过signal,其实还要另外一个sigaction,功能更加丰富
#include <signal.h>int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
struct sigaction {void (*sa_handler)(int); // 简单信号处理函数void (*sa_sigaction)(int, siginfo_t *, void *); // 高级信号处理函数sigset_t sa_mask; // 执行处理函数时要阻塞的信号int sa_flags; // 修改行为的标志位void (*sa_restorer)(void); // 已废弃
};
sigaction本质就是更改handler表中特定下标的内容,并且可以带回老的处理方法。sigaction是OS在用户级给我们提供的一个结构体,sigaction中,第一个参数就是信号具体的处理函数,第二个是处理实时信号的,我们不管,第四个设为0即可,不用管,第五个也不管。第三个参数下面会详细介绍。
void handler(int signo)
{std::cout << "get a sig: " << signo << std::endl;exit(1);
}int main()
{struct sigaction act, oact;act.sa_handler = handler;::sigaction(2, &act, &oact);while(true){pause();}return 0;
}
可以看到,当没有使用sa_mask时,与signal是非常类似的
为了理解sa_mask,再来看看信号保存
根据pending表可知,信号保存使用的是位图,如果在一个信号没有被递达之前,又接收到了这个信号,甚至多次接收到呢?此时这个进程只能记录下接收到了这个信号,并不能记录接收到了几个。其实也只需要记录一次,因为大部分信号都是让进程暂停或者退出。当我们在调用信号的处理函数时,是有可能陷入内核的,比如上面的信号处理函数中有cout,若刚好刷新,就会调用write这个系统调用,从而陷入内核,若我们现在正在执行这个信号的处理函数,并且这个函数的处理函数处理的时间比较长,又不断接收到了这个信号,是不是会一直调用这个处理函数,一直递归,导致栈溢出呢?
会发现,并不会出现这个情况。OS不允许信号处理的方法进行嵌套,如何做到的 --- 当某一个信号正在被处理时,OS会自动把这个信号的block位设置为1,当信号处理完成,才设置回0。所以,前面说的处理完成后还要回到内核,回到内核需要完成的任务之一还有将block表设置为0。所以,只支持串行处理,并不支持嵌套处理。串行是指处理完一个信号后,再处理这个信号。嵌套是指处理这个信号的同时还处理这个信号。
怎么证明会修改block表呢?
可以将block表打印出来看看,要打印出block表可以使用sigprocmask,这个接口可以对block表进行设置,不能直接拿到block表,我们可以利用它可以拿到老表的特性,我们可以设置一个无关信号,然后获取老的表即可拿到。这里直接将表置空了。
void PrintBlock()
{sigset_t set, oset;sigemptyset(&set);sigemptyset(&oset);sigprocmask(SIG_BLOCK, &set, &oset);std::cout << "block: " ;for(int signo = 31; signo > 0; signo --){if(sigismember(&oset, signo))std::cout << 1;else std::cout << 0;}std::cout << std::endl;
}void handler(int signo)
{// 使用静态变量证明函数被重复调用了static int cnt = 0;cnt ++;while(true){std::cout << "get a sig: " << signo << ", cnt: " << cnt << std::endl;PrintBlock();sleep(1);}exit(1);
}int main()
{struct sigaction act, oact;act.sa_handler = handler;::sigaction(2, &act, &oact);while(true){PrintBlock();pause();}return 0;
}
可以看到,最先开始时block表全是0,ctrl + c让进程收到2号信号后,2号位置就变成了1。当然,还有一些其他位置的也被设置成了1,这里就不管了。
如果我们想在进程处理信号时,自定义屏蔽一些信号呢?此时就可以使用sa_mask了。
void PrintBlock()
{sigset_t set, oset;sigemptyset(&set);sigemptyset(&oset);sigprocmask(SIG_BLOCK, &set, &oset);std::cout << "block: " ;for(int signo = 31; signo > 0; signo --){if(sigismember(&oset, signo))std::cout << 1;else std::cout << 0;}std::cout << std::endl;
}void handler(int signo)
{// 使用静态变量证明函数被重复调用了static int cnt = 0;cnt ++;while(true){std::cout << "get a sig: " << signo << ", cnt: " << cnt << std::endl;PrintBlock();sleep(1);}exit(1);
}int main()
{struct sigaction act, oact;act.sa_handler = handler;// 在处理2号信号时,将3、4、5、6号信号都屏蔽掉sigemptyset(&act.sa_mask);sigaddset(&act.sa_mask, 4);sigaddset(&act.sa_mask, 5);sigaddset(&act.sa_mask, 6);sigaddset(&act.sa_mask, 7);::sigaction(2, &act, &oact);while(true){PrintBlock();pause();}return 0;
}
所以,sa_mask就是在我们处理信号时,指定需要主动屏蔽的一些信号。当信号处理完成后,就会自动恢复,这个又怎么证明呢?
void handler(int signo)
{// 使用静态变量证明函数被重复调用了static int cnt = 0;cnt ++;while(true){std::cout << "get a sig: " << signo << ", cnt: " << cnt << std::endl;PrintBlock();sleep(1);break; // 收到2号信号后,只让其打印一遍}// exit(1);
}
可以看到,处理完后就恢复正常了。
我们知道,进程收到信号,会将这个信号在pen开始处理信号时清0,还是在处理完信号时清0呢?
是开始处理信号时就清0了。因为如果是处理完信号后再清0,如如何区分这是刚刚处理的信号,送还是处理信号过程中收到的信号呢?
可重入函数
假设现在有一个全局的链表,main函数中正在执行头插操作,若刚刚执行完将新结点的next指向要插入位置的下一个结点,此时信号来了,并且进程去执行信号处理了。此时就处在用户态,并没有从内核态切换回用户态,怎么会处理信号呢?因为进程会切换,执行完这一句刚好进程切换了,进程切换是因为时钟中断,触发时钟中断时就会有从内核态切换回用户态,从而处理信号。若此时这个信号的处理方法也是向这个全局链表进行头插。信号处理完,也就是头插完成后,会继续之前的头插。此时会造成信号处理时头插的结点丢失。这就是一种内存泄露。这个问题在于当信号处理时,执行插入函数,并且main函数也在执行插入函数,同一个函数被两个执行流重复进入了。一个函数被两个及以上执行流同时进入了,我们称这个函数被重入了。之前多进程时,父子进程同时进入一个函数,是重入,但是对于数据会发生写时拷贝,不会出错。像刚刚的函数被重入后,会出问题,称这样的函数为不可重入函数;若没出问题,则称这个函数为可重入函数。
什么样的函数是可重入函数,什么样的函数是不可重入函数呢?
函数使用了全局资源,包括static,则是不可重入的。使用了malloc,free,使用了lO标准库,都是不可重入的。当一个函数不使用任何的全局资源,则是可重入的。我们遇到的大部分库函数,大部分是不可重入的,像STL中的接口基本上都是不可重入的,因为STL中会有全局的空间配置器,并且会自动扩容,使用了全局资源,所以是不可重入的。可重入与不可重入是一种特性,没有好坏之分。大部分函数是不可重入的,带_r的就是可重入的(有一些函数会有可重入版和不可重入版)。
volatile
volatile是C语言的一个关键字,C++也是支持的
int flag = 0;void change(int signo)
{flag = 1;printf("change flag 0 -> 1\n");
}int main()
{signal(2, change);while(!flag);printf("我是正常退出的!\n");return 0;
}
我们使用C程序来写。对于上面这个程序,是有两个执行流的,第一个执行流是main,第二个执行流是信号捕捉的执行流,但是这两个执行流是属于同一个进程的。
我们可以让编译器对我们的代码进行优化
-O0表示不进行优化,1表示优化,并且后面的数字越大,优化的级别越高,并且会发现,当我们进行优化后,让进程收到2号信号,进程不会退出了。
我们先来谈为什么之前会退:当我们定义了一个全局变量时,是会在进程地址空间的已初始化数据区开辟一段空间的,并且也会在物理内存真正开辟一个空间。当我们的主逻辑,也就是main这个执行流在进行while时,是计算吗?是计算,CPU的计算有两种,一种是算术计算,也就是加减乘除,另一种是逻辑计算,为真为假。但是CPU没办法直接对内存的数据进行计算,所以CPU内部会有很多的寄存器,要进行计算,首先需要将需要计算的值从内存拷贝到CPU的寄存器上面,如eax,然后CPU开始判断,然后将下一条要执行的语句写在pc指针中,flag为0就在while内部,不为0就在while外部。下一次要判断时,继续将flag拷贝到CPU的寄存器中。当flag变成1时,就退出了。所以,没有优化时,每一次判断都会从内存拷贝到CPU内。
当编译器优化时为什么不退:编译器优化时,会定义一个寄存器变量,就是编译器认为flag的值不会变,在CPU内部使用一个寄存器存储这个变量,这样,在while判断时,直接去查看CPU寄存器中的值即可,不需要再将内存中的值拷贝到CPU当中。像上面,我们通过信号改变的是内存中的flag,CPU内的flag并没有变化,所以不退。寄存器+优化:屏蔽了内存的可见性。
volatile称为易变关键字
可以看到,加了volatile后,即使加了优化,也是会直接退的。volatile修饰变量:保持变量的内存可见性,叫易变是因为这个变量就是易变的,所以要保持内存可见性。
所有的关键字,都是给编译器看的,编译成汇编、二进制时,是没有关键字的。实际上,只要不是被执行的,都是给编译器看的。
SIGCHLD信号
进程一章讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。其实,子进程在终止时会给父进程发SIGCHLD信号,是17号信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。
先来验证一下子进程退出时,是否会给父进程发送17号信号。
void handler(int signo)
{std::cout << "get a signo: " << signo << std::endl;
}int main()
{signal(SIGCHLD, handler);if(fork() == 0){sleep(5);std::cout << "子进程退出" << std::endl;exit(0);}while(true) ;return 0;
}
可以看到,子进程确实向父进程发送了17号信号。此时没有对子进程进行回收,子进程退出后,仍然处于僵户状态。
因为子进程在退出时会向父进程发送信号,所以我们可以基于信号对子进程进行回收。直接去17号信号的自定义函数中等待子进程即可。
void handler(int signo)
{std::cout << "get a signo: " << signo << ", I am: " << getpid() << std::endl;pid_t rid = ::waitpid(-1, nullptr, 0);if(rid > 0){std::cout << "子进程退出了, 回收成功, child id: " << rid << std::endl;}
}int main()
{signal(SIGCHLD, handler);if(fork() == 0){sleep(5);std::cout << "子进程退出" << std::endl;exit(0);}while(true) ;return 0;
}
此时就回收成功了,可以看到是没有僵尸状态的进程的。
使用信号来回收虽然好,但是上面的代码是有几个问题的:
刚刚只有一个子进程,若有多个子进程呢?
若这多个子进程同时退出,此时是存在风险的。因为保存信号使用的是位图,无法记录有几个信号同时传过来。并且当信号处理函数正在被调用时,若此时发送多个相同信号,只会记录一个。
我们现在一次性创建10个子进程。
void handler(int signo)
{std::cout << "get a signo: " << signo << ", I am: " << getpid() << std::endl;pid_t rid = ::waitpid(-1, nullptr, 0);if (rid > 0){std::cout << "子进程退出了, 回收成功, child id: " << rid << std::endl;}
}int main()
{signal(SIGCHLD, handler);for (int i = 0; i < 10; i++){if (fork() == 0){sleep(5);std::cout << "子进程退出" << std::endl;exit(0);}}while (true);return 0;
}
可以看到,此时只成功回收了7个子进程,剩下3个成为了僵尸进程。
为了解决这个问题,可以让信号处理函数一直循环。
void handler(int signo)
{std::cout << "get a signo: " << signo << ", I am: " << getpid() << std::endl;while (true){pid_t rid = ::waitpid(-1, nullptr, 0);if (rid > 0){std::cout << "子进程退出了, 回收成功, child id: " << rid << std::endl;}}
}
可以看到,此时子进程确实全部回收成功了。waitpid(-1,nullptr,0)是阻塞等待,它会一直等待,直到至少有一个子进程退出,然后回收它,并返回该子进程的PID,由于while循环中反复调用,所以大部分情况下可以处理所有已退出的子进程。但是如果17号信号在waitpid期间触发,仍然有可能导致部分信号丢失,所以最好使用非阻塞等待:waitpid(-1,nullptr,WNOHANG)。当我们换成非阻塞等待后,不需要考虑信号能不能被父进程成功接收(写入pending表中),只要父进程创建的子进程中还有僵户进程,就可以进行回收。因为非阻塞等待会主动检查所有子进程状态,直接回收僵户进程,不依赖信号通知。但是阻塞等待是不会的主动检查所有子进程的状态的,而是被动等待子进程退出事件。
在上面的阻塞等待中,我们创建了10个进程,只退出了7个会怎么样呢?当我们已经回收了退出的7个,是否还会继续等待第8个呢?是会等待的,因为是阻塞等待,此时又没有进程退出,所以父进程会一直阻塞在哪里,所以,应该使用非阻塞等待。
进程回收的做法:
- 阻塞等待
- 非阻塞等待
- 基于信号 + 非阻塞等待
- 若我们只是不想要僵户进程,不关心子进程的执行情况,可以父进程调用sigaction/signal将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵户进程,也不会通知父进程。这种做法只能在Linux下使用
对于上面的第4点。我们对于17号信号默认不就是IGN吗?为什么还要手动来一次呢?
因为默认的IGN和手动设置的IGN是不同的东西,这是在Linux中被做了特殊处理,系统默认的忽略动作和用户用sigaction函数自定义的忽略通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。