【Linux篇章】Linux 进程信号2:解锁系统高效运作的 “隐藏指令”,开启性能飞跃新征程(精讲捕捉信号及OS运行机制)
本篇文章将以一个小白视角,通俗易懂带你了解信号在产生,保存之后如何进行捕捉;以及在信号这个话题中;OS扮演的角色及背后是如何进行操作的;如何理解用户态内核态;还有一些可以引出的其他知识点;如:可重入函数,volatile关键字;SIGCHLD信号等一些相关知识点的介绍;欢迎大家阅读!!
欢迎大家支持:
羑悻的小杀马特.-CSDN博客
欢迎拜访:羑悻的小杀马特.-CSDN博客本篇主题:秒懂百科之Linux进程信号2(捕捉信号及OS如何运转等)
制作日期:2025.05.12
隶属专栏:linux之旅
目录
一·信号捕捉流程:
二·操作系统是如何运行的:
2.1硬件中断:
2.2OS的时钟中断与死循环:
2.3软中断:
三·何为用户态与内核态:
四·可重入函数:
五·volatile关键字:
六·SIGCHLD信号:
七·本篇小结:
一·信号捕捉流程:
在前一篇;我们讲了信号产生与保存;下面具体讲一下它是如何被捕捉的:
那么信号是如何被执行的呢?
还是先看一张图:
这里就是在执行的时候进行了四次用户态与内核态的交互来完成的来完成了信号的发送与执行;下面我们以一个具体的例子来讲述一下(这里我们以上一篇讲过的硬件异常引起的3号信号为例子)
对于这里执行完自定义处理方式是如何返回内核态:
这里在执行这个自定义捕捉方法的时候需要展开栈;在展开之前先保存这个回内核的入口;然后在展开进行执行;执行完后销毁这块栈;然后就从返回地址处开始走;就回到内核了。
解释一下:首先遇到了除0;cpu内计算出现错误(硬件异常引起软中断);产生中断号给os【用户态到内核态】;然后os去执行对应的任务:给当前进程发送3号信号以及进行相关信息收集;然后 os就会检测pending表看是否有信号没处理如果是默认或者忽略就直接执行如果是自定义类型就跑到用户态执行自定义的代码【内核到用户态】;然后执行完就返回内核态【用户态到内核态】;然后再看看是否还有;当没有了就返回用户去执行下面逻辑代码【内核态到用户态】。
为了简化一下我们可以根据图这么来理解这个过程:
这四个交点就是上面我们所说的那几个转换状态;然后把上面的例子带进去即可。
二·操作系统是如何运行的:
对于操作系统是如何运行;可以分为下面几个部分来理解:
2.1硬件中断:
首先先要明白:
中断向量表就是操作系统的⼀部分,启动就加载到内存中了。
通过外部硬件中断,操作系统就不需要对外设进⾏任何周期性的检测或者轮询。
由外部设备触发的,中断系统运⾏流程,叫做硬件中断。
下面我们举个例子理解下:
当外设(如键盘)输入时候;中断控制器的对应位置的中断号就变成0;然后就会通过cpu针脚被cpu识别到;然后本身有注册的中断向量表;根据中断号执行对应比如键盘的任务等。
中断向量表IDT有很多对应方法:
set_trap_gate(0,÷_error);// 设置除操作出错的中断向量值。以下雷同。
set_trap_gate(1,&debug);
set_trap_gate(2,&nmi);
set_system_gate(3,&int3); /* int3-5 can be called from all */
set_system_gate(4,&overflow);
set_system_gate(5,&bounds);
set_trap_gate(6,&invalid_op);
set_trap_gate(7,&device_not_available);
set_trap_gate(8,&double_fault);
set_trap_gate(9,&coprocessor_segment_overrun);
set_trap_gate(10,&invalid_TSS);
set_trap_gate(11,&segment_not_present);
set_trap_gate(12,&stack_segment);
set_trap_gate(13,&general_protection);
set_trap_gate(14,&page_fault);
set_trap_gate(15,&reserved);
set_trap_gate(16,&coprocessor_error);
os就可以拿着对应中断号去匹配执行它;说白了;它就是个函数指针数组而已;此时;OS再也不关注,外部设备是否准备好,而是它准备好,会叫我!!
下面我们以键盘输入然后写入文件然后再访问打印在屏幕上过程结合上面的硬件中断配合冯.诺依曼体系理解下:
2.2OS的时钟中断与死循环:
可以理解成在外设或者cpu内部有个时钟;每一定时间就会响然后告诉os去检查是否存在中断号然后去执行对应任务。
下面看张图:
时钟可以在外面但是大部分都集中在cpu内;也就是它会按照频率去响:
下面我们以进程调度为例子来说明时钟中断是怎么进行的:
os会在时钟的一定频率催促下去检查是否存在中断号去执行;这里我们可以这么认为->只要cpu内的时钟一响;os就拿到对应中断号去执行相关进程调度工作看是否可以执行:
这里时间片可以粗略理解为每个进程被分配的执行任务的时间;当到0后就会跑去等待队列,换其他进程来执行;等待下一次被分配时间片再去执行任务(不考虑优先级进程)。
这里counter就可以理解成时间片。
这里可以理解成调用进程完成任务;如果当前进程时间片还没有到时就继续走它的任务;到时间了就换另一个进程来替他;把原先的放入等待队列休息
所以说对于os只能喂它指定的中断号它才会动否则死死的呆在那里。
os:
void main(void)
{
for (;;)
pause();
}
pause只有接收到相关中断号才回干别的事情;但是干完别的事情又会回到这个for循环。
操作系统不就可以躺平了吗?对,操作系统自己不做任何事情,需要什么功能,就向中断向量表里面添加方法即可.操作系统的本质:就是一个死循环!
操作系统依赖于中断号和中断向量表。
这里对于任务0比较特殊;当os处于悠闲即pause状态;它就会时刻执行0任务;看是否还有其他中断号促使它去执行 。
2.3软中断:
其实就是用软件来模拟硬件中断。
这里软中断也是通过中断向量表处理的一部分;下面请看图:
这里就是cpu内部会通过某些能发生软中断的方法产生执行软中断对应的中断号以及处理软中断的系统调用号等。
也就是处理软中断的方法中有是一个函数指针数组;所有的系统调用都会被os转化成一个系统调用号然后去调用指定的那些函数。
从此往后,对于软中断而言;每一个系统调用,都有一个唯一的下标;叫做系统调用号。
/ 系统调⽤函数指针表。⽤于系统调⽤中断处理程序(int 0x80),作为跳转表。
fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,
sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link,
sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod,
sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount,
sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm,
sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access,
sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir,
sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk, sys_setgid,
sys_getgid, sys_signal, sys_geteuid, sys_getegid, sys_acct, sys_phys,
sys_lock, sys_ioctl, sys_fcntl, sys_mpx, sys_setpgid, sys_ulimit,
sys_uname, sys_umask, sys_chroot, sys_ustat, sys_dup2, sys_getppid,
sys_getpgrp, sys_setsid, sys_sigaction, sys_sgetmask, sys_ssetmask,
sys_setreuid, sys_setregid
};
下面我们细谈一下:
为了让操作系统支持进行系统调用,CPU也设计了对应的汇编指令(int或者syscall),可以让CPU内部触发中断逻辑(也就是说这俩会直接产生对应去处理软中断的中断号)。
int0x80:x86
syscall:x64
本质就是用汇编代码搞成的相关指令集,可以让CPU内部触发中断逻辑(软中断号)。
当出现能引起软中断的事件时;在它俩作用下就会陷入内核;也就是系统去执行对应的软中断处理方法(比如之前讲的硬件中断:除0;野指针;缺页中断都是软中断;它们对应的方法都会被注册在上面的那张表中)
下面我们以fork函数为例子来探究下软中断是如何进行的:
先普及个知识:我们调用的fork函数常说是系统调用;其实它是被glibc封装的;也就是说它不是真正的系统调用(它内部会调用c+汇编写的一个函数;也就是真正的系统调用来完成;因为os只认识系统调用号)。
我们调用类似fork等系统接口;他就会自动给os一个系统调用号;然后os拿着它去执行对应任务。
下面看张图清晰了解下:
这里syscall会陷入内核给os对应中断号告诉os将要去执行软中断处理 ;对于我们用户看到的fork函数或者其他系统调用函数有参数最后都会变成汇编然后被os知道的;因此我们只要明白这个os是如何处理这个软中断的就行;完成后操作系统通过寄存器或者⽤⼾传⼊的缓冲区地址。
对于软中断;得出结论:OS不提供任何系统调用接口! ! OS只提供系统调用号!! ! ;OS只知道对应的系统调用号和对应的调用表。
因此我们可以总结一下:
比如调用的fork open等函数其实都是libc库封装的系统调用(汇编+c):
给用户端呈现的是那些;然后其实里面套了层c+汇.编:拿到用户输入的位置权限等;搞成汇编或者c打包然后把调用号给os;让os知道;os就会调用指定任务+打的包然后进行执行。
软件中断可以分为所有的系统调用函数,除0,野指针,缺页中断等等都输入软中断。
缺⻚中断?内存碎⽚处理?除零野指针错误?这些问题,全部都会被转换成为CPU内部的软中断,然后⾛中断处理例程,完成所有处理。有的是进⾏申请内存,填充⻚表,进⾏映射的。有的是⽤来处理内存碎⽚的,有的是⽤来给⽬标进⾏发送信号,杀掉进程等等。
比如;它们通过系统调用号执行对应任务:
void trap_init(void)
{
int i;
set_trap_gate(0,÷_error);// 设置除操作出错的中断向量值。以下雷同。
set_trap_gate(1,&debug);
set_trap_gate(2,&nmi);
set_system_gate(3,&int3); /* int3-5 can be called from all */
set_system_gate(4,&overflow);
set_system_gate(5,&bounds);
set_trap_gate(6,&invalid_op);
set_trap_gate(7,&device_not_available);
set_trap_gate(8,&double_fault);
set_trap_gate(9,&coprocessor_segment_overrun);
set_trap_gate(10,&invalid_TSS);
set_trap_gate(11,&segment_not_present);
set_trap_gate(12,&stack_segment);
set_trap_gate(13,&general_protection);
set_trap_gate(14,&page_fault);
set_trap_gate(15,&reserved);
set_trap_gate(16,&coprocessor_error);
}
因此,对于软中断;我们再做最后的总结:
操作系统就是躺在中断处理例程上的代码块!
CPU内部的软中断,⽐如int0x80或者syscall(系统调用),我们叫做陷阱(故意陷入内核)。
CPU内部的软中断,⽐如除零/野指针等,我们叫做异常。
三·何为用户态与内核态:
首先看张图:
用户态:以用户身份,只能访问自己的[0,3GB]
内核态:以内核的身份,运行你通过系统调用的方式,访问OS[3,4GB]
操作系统也是软件也是有一定内存的。
这里如果有多个进程;那么就有多个不同虚拟地址空间;然后用户级页表也是多个;但是内核级页表只有一个。
我们不难发现os在操作系统只有一份:然后被各个经常映射到自己的虚拟地址空间--->因此每个进程都共亨同一个os;这意味着:操作系统无论怎么切换进程,都能找到同一个操作系统!换句话说操作系统系统调用方法的执行,是在进程的地址空间中执行的!
那么此时可能有一个问题:
用户和内核,都在同一个[0,4GB]的地址空间上了;如果用户,随便拿一个虚拟地址[3,4GB],用户不就可以随便访问内核中的代码和数据了吗?
当然os也不是傻子;因此又出了一套机制:
os只允许使用系统调用;且只能以os身份去访问这块物理内存。
也就是用户态如果出来先系统调用函数就会走int0x80 sysca11 那—套然后陷入内核进行调用号进行相关系统调用。
对于如何判断是内核态还是用户态;这里粗略理解下:
cpu内部cs段寄存器有标志位:
3:用户;0:内核;当进程从用户区跑到内核区的时候;它会进行检查;符合那么就放行否则拦截。
下面我们距离说明一下:
用户拿着自己0-3g的虚拟地址访间用户态无关;﹔比如我们此时调用os的fork进行访间内核区执行系统调用﹔首先这个fork先会把对应的权限改成0﹔然后陷入了内核区;此时cs检测到允许访间;然后fork又把调用号告诉os;让它执行对应系统调用任务,搞完后返回用户态;此时cs标记位再次恢复3;每一次执行系统调用就这样来---->确保了操作系统只允许自己的os调用陷入内核去访间自己。
四·可重入函数:
首先先来个图:
原本是把两个节点头插;但是由于设置了自定义信号捕捉导致如果突然收到信号会立刻去处理它(此时不能保证刚才是否头插完成)然后就会如图所示导致两次并没有成功完成头插--->因此这个insert函数就是不可重入函数。
因此这里又划分了可重入与不可重入函数。而stl里面其实大多数都是不可重入函数。
一般像全局的变量之类的一般函数都是不可重入的(因为做了修改;比如再次重入会有判断;这样就会误判)﹔而局部的话;再次重入某些量还是不变的;因此即可重入。
判断不可重入函数的条件:
调⽤了malloc或free,因为malloc也是⽤全局链表来管理堆的。
调⽤了标准I/O库函数。标准I/O库的很多实现都以不可重⼊的⽅式使⽤全局数据结构。
五·volatile关键字:
保持内存的可⻅性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进⾏操作。
该关键字在C当中我们已经有所涉猎,今天我们站在信号的⻆度重新理解⼀下;
首先我们先看个例子:
写一个如下的代码:
#include <stdio.h>
#include <signal.h>
volatile int flag = 0;
void handler(int sig)
{
printf("chage flag 0 to 1\n");
flag = 1;
}
int main()
{
signal(2, handler);
while(!flag);
printf("process quit normal\n");
return 0;
}
效果:
下面来解释一下:
这里f1ag确实改了但是修改的是内存里的f1ag;当执行完二号信号﹔我们回到main函数的时候;它所取的f1ag是在寄存器中而不去内存了。
如果编译器优化:这里编译器发现f1ag的值当一直在执行死循环发现它不变就会直接放到寄存器中(一般可以放不变的量;到时侯不用去内存看了)﹔因此就对f1ag变成了内存不可见,因此即便我们修改了内存的f1ag的值;它仍旧是从寄存器取;故还是拿到0。
如图:
加上volatile关键字后:
六·SIGCHLD信号:
简单理解成子进程退出时候给给父进程发送的信号。
默认处理方式是忽略;也就是不回收这个发信号的子进程。
因此我们可以和父进程回收子进程结合起来;使用自定义捕捉来;如果收到这个信号就进行捕捉;然后进行回收。
下面我们来演示下:
有十个进程;我们使用阻塞状态来回收;看一下效果:
测试1代码:
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cstdlib>
#include <sys/wait.h>void chld(int sig){while (true){pid_t n = waitpid(-1, nullptr,0); // waitpid默认是阻塞的!}}
int main()
{// 父进程signal(SIGCHLD, chld); // 父进程for (int i = 0; i < 10; i++){pid_t id = fork(); if (id == 0){sleep(3);exit(3);}}while (true){std::cout << "I am fater" << std::endl;sleep(1);}return 0;
}
发现会阻塞:
首先父进程会走3s打印三次;然后就收到信号父进程就会跑到自定义捕捉函数里进行循环( sigch1d只接受一次,屏蔽了)·然后当回收完十次还是处于阻塞状态不是我们想要的。
因此下面我们采用非阻塞(WNOKANG):
这里考虑了可能很多孩子同时退出故写成循环形式来确保都回收了(结合执行信号时候b1ock的标记)。
使用检测脚本来时刻看着进程情况:
while :; do ps ajx|head -1 && ps ajx lgrep a.out; s1eep 1; done
这里如果还有子进程没有退出就返回0﹔如果子进程全部退出就返回-1,而后面我们创建10个子进程但是只回收6个。
发现确实退出了6个:
测试2代码:
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cstdlib>
#include <sys/wait.h>void chld(int sig){while (true){//这里考虑了可能很多孩子同时退出故写成循环形式来确保都回收了pid_t n = waitpid(-1, nullptr, WNOHANG); if (n == 0){break;}else if (n < 0){std::cout << "waitpid error " << std::endl;break;}else{ std::cout<<"回收孩子的pid: "<<n<<std::endl;}}
}
int main()
{// 父进程signal(SIGCHLD, chld); // 父进程for (int i = 0; i < 10; i++){pid_t id = fork(); if (id == 0){sleep(3);if(i < 6){std::cout << "I am child, exit" << std::endl;exit(3);}else pause();}}while (true){std::cout << "I am fater" << std::endl;sleep(1);}return 0;
}
因此上面的办法是可行的;但是过于麻烦;下面对应SIGCHLD这个信号的忽略函数就支持了这个功能:SIG_IGN。
只是这里稍作修改:
这两种方法区别。如果自定义可以查看孩子pid及孩子的信息,但是这种忽略作为处理函数只能防止僵尸进程出现﹔无法查看pid等孩子的信息,因此根据需要选择。
有个疑问:这里默认不就是忽略吗;为什么还要SIG_IGN?
解释下:
SIGCHLD默认处理方式(SIG_DFL)是忽略不管它;也就是让僵尸进程产生了;但是如果我们传递SIG_IGN是走它忽略的处理方式并不是真正的忽略。即如果是SIG_IGN;它内置处理就会回收僵尸进程;如果是SIG_DFL它内置就啥也不做。
七·本篇小结:
本篇博主同样是根据自己学习然后根据整理的笔记;进行了一次博文疏导;也起到了一定查漏补缺作用;重温了信号捕捉流程;OS背后是如何运行的一些原理;以及根据信号章节透露出的一些其他小知识点;信号章节分为两篇(只不过这里目前只研究的是普通信号;对于实时信号还未涉及)也就到此结束;希望对大家学习普通信号这一块有所帮助!!!