[Linux]信号底层概念与操作
目录
1.信号的概念
2.发送信号的本质
3.信号的产生
OS怎么知道键盘有输出
键盘产生信号
异常产生信号
软件条件产生信号
系统调用产生信号
4.信号的保存与处理
设置信号的处理函数
信号的保存机制
信号屏蔽字
5.信号处理的底层逻辑
内核态与用户态
内核空间
信号的处理
信号捕捉处理函数
6.信号的补充问题
可重入函数
volatile关键字
SIGCHLD信号
1.信号的概念
什么是信号呢,在生活中我们穿梭在大街上看到的红绿灯,在学校下课听到的下课铃,古代传消息用的狼烟,这些都可以说是信号。而且信号在没有产生之前,我们也已经直到信号的处理方式了,当信号产生的时候,我们会认识该信号,并作出相应的操作,当然这种操作不需要立即处理,再合适的时候处理即可。那么就会有一个能力,就是不及时处理,需要将信号保存起来的能力了。
所以说信号就是向目标进程发送通知消息的一种机制,目的是让目标进程做出一些动作。例如kill -9就是告诉目标进程立即关闭的信号。上述也说了在信号产生之前,就知道如何处理信号了,所以操作系统也需要知道不同信号的不同处理方式,在Linux中,信号都已经被编号了,0号表示没有收到信号,所以信号是从1号开始编号的,并把信号数字以及信号处理方式形成了一一对应的映射关系,所以当信号到来的时候,操作系统就会根据信号数字去执行相应的操作了。
kill -l 用于查看信号的指令
2.发送信号的本质
信号是可以不用立即处理,那么是如何保存信号的呢?1~31号信号是属于普通信号,34~64为实时信号,在进程PCB中包含一个32位大小的位图,记录了该进程收到了哪些普通信号(uint32_t sigmap),对于实时信号则会维护一个链表。
因为操作系统是进程的管理者,所以无论信号有多少种产生的方式,都需要也只能是操作系统向目标进程发送信号。所谓的操作系统向进程发送信号,其实就是操作系统修改了进程PCB中的sigmap位图。
3.信号的产生
OS怎么知道键盘有输出
当我们键盘输入内容之后,操作系统会将数据拷贝给对应的程序进行处理使用,但是操作系统怎么知道键盘有内容输入呢?采用轮询的方式吗?显然不是,因为如果硬件设备也需要操作系统轮询检测的话,操作系统会变得非常忙,需要花时间去遍历,效率会大大降低,所以要让硬件设备去通知操作系统。
CPU上有一定数量的针角,硬件设备会通过主板电路和CPU的针角进行建立连接,当设备就绪了之后,针角会产生光电信号,CPU就能够读取到针角是对应的哪一个设备了,并把针角的编号放到寄存器当中,操作系统读取到寄存器的编号,会去对应的设备中取出数据的。对于分配个外设的每个针角对应的编号称之为中断号。
那么如何取出数据呢,操作系统会维护一个函数指针数组(中断向量表),也是操作系统最开始形成的表,在开机的时候,会把各个硬件的特定存取方法写入到数组当中,数组的下标就是对应设备的中断号。
和信号的原理类似,在设备又没就绪的时候,就已经认识设备并知道设备就绪后如何进行操作的。那么信号的话,本质上来说就是用软件去模拟硬件的中断行为。
键盘产生信号
当我们在键盘中输入组合键时,该数据不会立刻给到进程,而是由操作系统转化为信号,因为数据是操作系统从键盘设备文件中取出来的,所以会知道输入的是什么,通过解析,是组合键,那么就会转化为信号了。例如ctrl + c会转化为2号信号发送给进程,让程序终止运行。
man 7 signal 查看信号对应的处理操作
对于信号来说每个进程PCB中也会有一张函数指针数组表,对应的下标就是信号的编号,映射的内容就是对应执行该信号操作的方法。
异常产生信号
当我们计算一个x / 0的时候会出错是因为,当CPU计算的时候,除了0,会得到一个无限大的数,状态寄存器中的溢出标志会被置为1,CPU会通知操作系统,操作系统会向进程发送8号信号,进程就会被终止了。
当我们修改8号信号的处理方法,把终止进程改为别的时,该进程则会无线的循环执行自定义的8号信号对应的处理方法,因为进程不终止的话,则进程上下文中的状态寄存器中的溢出标记位则会一直是1,CPU就会给操作系统一直反应,操作系统就会一直向进程发送8号信号,所以会一直循环执行。
当我们执行以下代码的时候,也会报异常终止进程
int* p = nullptr; *p = 100;
nullptr是0地址,该地址下对应的页表是没有和物理内存建立映射关系的,所以无法访问呢写入,会报异常错误。CPU在读取该操作指令的时候,会将p指向的地址通过MMU内存管理单元的硬件进行将虚拟地址,转化为物理地址,但是0地址没有映射,会转化失败,然后还向该地址进行写入的操作那么就会报错了,之后操作系统会向进程写入11号信号终止程序。
void abort(void); 可以使进程因为异常终止,发送的是6号信号
软件条件产生信号
例如对于管道文件来说,当读取端关闭了,发送端在向管道中写入的时候,会向进程发送SIGPIPE13号信号用来终止进程。
unsigned int alarm(unsigned int seconds) 头文件 <unistd.h>
alarm函数接口是用来设置一个进程的闹钟,在seconds秒之后,向进程发送SIGALRM信号,默认会终止进程,可以同时设置多个闹钟,所以操作系统也需要将这些闹钟管理起来的。
用户的所有行为都是以进程在操作系统中表现的,只要操作系统调度好进程的话,就能完成用户的任务,那么谁来调度操作系统呢?CMOS周期性高频的向CPU发送时钟中断,CPU通过中断编号读取中断向量表,对应的方法就是操作系统的调度方法。所以说OS的执行也是基于硬件中断的死循环。
系统调用产生信号
int kill(pid_t pid, int sig) 头文件 <sys/types.h> <signal.h>
传递的参数为进程id以及发送的信号编号。我们平时在命令行中使用的kill指令内部封装的就是该kill函数。简单的模拟实现如下:
#include <string>
#include <sys/types.h>
#include <signal.h>int main(int argc, char* argv[])
{if(argc != 2)return -1;//获取信号int sig = std::stoi(argv[1] + 1);//获取进程idpid_t pid = std::stoi(argv[2]);//执行操作kill(pid, sig);return 0;
}
int raise(int sign); 给自己发信号的接口
4.信号的保存与处理
设置信号的处理函数
typedef void (*sighandler)(int);
sighandler_t signal(int signum, sighandler_t handler); 头文件 <signal.h>
当第二个参数为信号处理方式的参数,设置为SIC_IGN的时候表示忽略该信号,设置为SIG_DFL表示使用默认的处理方式,也可以自定义一个void (*sighandler) (int)函数作为参数传递,那么signal对于signum信号的处理方式就变为了自定义的处理方式。成功时返回指向该处理函数的一个指针,一般情况下没用,但是在像保存旧的处理函数,执行新的处理函数的时候还是比较有用的。
信号的保存机制
执行信号的处理动作称为信号递达,信号从产生到递达的状态,称之为信号未决。同时进程也可以选择阻塞某个信号,那么信号到来的话不会执行,而是一直处于未决的状态。
进程会为上述的递达、未决以及阻塞三种状态维护三张表,每一个表是一个指针数组,存在于进程PCB中。我们对于阻塞状态的设置是将block表中对应的位置设置为0,对应自定义函数的处理方法会设置到handler表。在一个信号到来的时候,会将pending表对应的信号的位置设置为1,然后再决定要不要处理该信号的时候,会横向的看这三张表,先看block表该信号是否被阻塞,如果没有再确定该信号是否到来,最后都符合条件的话,再去访问第三张表找到对应的处理方法。虽然阻塞的信号不会被处理,但是也被记录了下来,等到信号阻塞结束后,会继续执行信号的。
对于一个常规信号到来之后,没有及时处理的话,就会处于未决状态,如果之后又到来了几次该信号也只会被记录一次并处理一次。但是实时信号是不一样的,他们的到来是用队列进行接收的,所以一个信号可以作为多个队列的成员。
信号屏蔽字
阻塞的信号集又叫做信号屏蔽字,下面是信号集操作的函数。当用户想要操作信号的阻塞、挂起等操作的时候,就相当于修改PCB中的位图,系统提供了一个sigset_t类型,是一个位图类型,我们通过生成一个sigset_t类型的对象,修改该对象的值,然后把他传给修改PCB内部信号位图的函数,那么系统就可以根据该sigset_t对象修改信号集了。但是不可以屏蔽9号和19号信号。
用于修改sigset_t位图的函数:
int sigemptyset(sigset_t* set); //将位图全部置为0
int sigfillsetset(sigset* set); //将位图全部置为1
int sigaddset(sigset* set, int signo); //将指定信号位置置为1
int sigdelset(sigset* set, int signo); //将指定信号位置置为0
int sigismember(const sigset* set, int signo); //判断信号位置是否为1
修改PCB中的block位图:
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
how表示操作的类型,SIG_BLOCK为添加信号屏蔽字,也就是原有的mask位图 | set位图得到新的mask位图,SIG_UNBLOCK则相反是解除信号屏蔽字,也就是mask位图 &(~set)位图,SIG_SETMASK则是让set覆盖mask位图。 set参数就是传递的sigset_t类型的对象,而oldset则是返回原来旧的mask信号屏蔽字位图。
获取信号未决位图:
int sigpending(sigset_t* set);
5.信号处理的底层逻辑
内核态与用户态
上述我们说了信号到来的时候,会先保存在pending位图内部,等到合适的时候在被处理执行信号函数,但是什么时候是合适的时间呢?当进程从内核态转变为用户态的时候,就会进行信号的检测和处理操作了。
用户态是一种受控的状态,访问的资源数据都是有限的;内核态是操作系统的工作状态,能访问绝大多数的系统资源。当进行系统调用时,就会发生从用户态转变为内核态的身份变化的。那么对于CPU在执行进程的时候,是将进程的PCB等数据放到了CPU中,那么CPU如何找到操作一系统的呢?
内核空间
操作系统也是一个软件程序,CPU在执行操作的时候会访问虚拟地址空间通过页表映射访问到程序的数据和代码,访问操作系统也是一样的,操作系统也会被映射到虚拟地址空间的内核空间区域当中,只不过是通过内核级的页表完成的。当然操作系统每一个进程都要使用,而且操作系统在加载到物理内存后的位置也是不变的,因为操作系统挂了的话,电脑也就没法用了,而且每个进程的进程地址空间对于内核空间的大小定义是一样的,所以说内核级页表只有一份就可以,每个进程对于操作系统的映射是一样的,共用一个内核级页表。 那么无论如何调度,内核区域都是一样的,那么CPU就随时都可以找到操作系统了。
用户只能访问0~3GB的区域,需要转化身份才能访问3~4GB的内核空间区域,CPU中又CS寄存器,低两位表示:01是内核态、11是用户态。当进行系统调用的时候,就发生了身份的转化,从进程地址空间来看的话,是从用户级的代码区跳转到了内核空间,和动态库的跳转类似,都是在进程地址空间内部进行跳转的。
信号的处理
当执行系统调用接口的时候,会转变为内核态,访问内核空间的系统调用代码 ,执行完毕之后,在返回之前回去检查信号表,如果有非阻塞的未决的信号,就会进行信号处理操作,如果是自定义信号那么会回到用户态进行执行代码,是系统默认的则会继续在内核态执行信号函数。执行完毕之后继续查看信号表,直到没有了之后在返回到用户态的程序代码中。
系统调用函数执行完之后,本来应该返回用户态的程序代码的,怎么实现的去执行信号函数了呢?可以通过修改函数的返回值实现,返回值也是一个地址,把地址修改为信号函数的地址,就去执行信号函数了。
其实如果所有的信号都处理完之后,最后一次也是要先返回到内核态的,因为根据栈帧来说,函数的返回地址被压在了底部,所以要先返回内核的位置,将上面的栈帧一次pop出去,才能查看返回用户态程序代码的地址。
也不是所有的程序都使用了系统调用接口的,但是整个进程的生命周期中,一定会涉及到很多系统调用的,一定会有操作系统的参数,有操作系统的参与就会有身份的转变,例如进程在CPU上运行时,会把进程的上下文交给CPU以及各种数据交给CPU,都需要操作系统来完成的。
信号捕捉处理函数
int sigaction(int signo, const struct sigaction* act, struct sigaction *oact);
在C/C++当中允许函数名和结构体名称一样,对于该结构体来说我们绝大多数的情况下只需要使用两个参数,void (*sa_handler)(int); sigset_t sa_mask。第一个参数是传递的信号的处理函数,第二个参数是传递的信号屏蔽字。signo是要捕捉的信号,oact则是保存返回旧的该信号的处理方法,如果不需要的话可以设置为nullptr。
#include <iostream>
#include <signal.h>void handler(int signo)
{//自定义信号处理函数体
}int main()
{//初始化结构体struct sigaction act;//设置信号处理函数act.sa_handler = handler;//设置信号处理时的信号屏蔽字sigset_t set;sigemptyset(&set); //先清空sigaddset(&set, 3); //添加3号信号//设置捕捉信号sigaction(2, &act, nullptr);return 0;
}
当某种信号的处理函数被调用的时候,内核会自动将该信号假如到进程信号屏蔽字当中,处理完之后自动恢复为非阻塞,保证在处理信号的时候,再次产生该信号,可以把该信号阻塞住,防止该信号没处理完的时候,操作系统以为我们没处理,而重复发送信号造成重复处理该信号,导致信号处理函数被冲入了。而且信号的处理再底层是有一定的优先级的,所以我们如果想处理某些信号的时候不被打扰,可以通过设置sa_mask来屏蔽掉一些信号。该信号屏蔽字只在当前信号处理的时候起作用。
6.信号的补充问题
可重入函数
如图的操作,是一个链表头插的操作,进入insert函数体内部,当node1节点刚刚连接到链表上的时候,接收到了信号,去 执行了信号处理函数,又一次的调用了insert函数,执行完毕之后,node2也放到了链表的头部了,之后head指向的是node2,执行完信号函数之后,执行head = p;那么head又执行了node1,这样就出现了问题,所以这种重入之后造成了错乱的函数称为不可重入函数。重入指的是当一个函数体没有执行完的时候,又一次进入的该函数体。
volatile关键字
int flag = 0;
//信号处理函数
void handler(int signo)
{flag = 1;
}int main()
{//自定义信号处理函数signal(2, handler);while(flag == 0);return 0;
}
当我们在使用g++编译的时候,带上-o2选项的话,可以ctrl + c无法终止程序,而是一直再while内部循环,-o2选项属于编译器高级优化选项,该代码下函数体内部没有修改flag和使用flag的地方,所以对于flag的值,CPU一直都是从寄存器中取出flag的值,所以flag的值一直会为0,而信号函数修改的是物理内存中的flag,不影响寄存器中存储flag位置的值,所以会一直循环。这样的话也造成了数据的二义性。
而加上volation关键字修饰的变量的话,再任何时候对于该值的读取都不能被优化,都必须去物理内存中读取该值,以及对于该变量的任何操作都必须再物理内存中完成。
SIGCHLD信号
当一个子进程退出的时候,内核会向其父进程发送 SIGCHLD 信号。这个信号的主要目的是通知父进程其子进程回收子进程的资源。该信号默认来说相当于是被屏蔽的,如果父进程没有定义一些wait系列接口,该信号不会有任何作用。
我们可以基于该信号进行子进程的回收处理操作,在信号处理函数中定义waitpid处理就可以实现基于信号对子进程的回收操作。但是在信号处理函数中要循环执行
while((id = waitpid(-1, nullptr, 0) > 0);
因为pending位图他只会记录一次,当在处理该信号的时候,有其他子进程退出发送该信号也不会被记录。所以要循环执行。
// 信号处理函数,用于回收子进程资源
void handle_sigchld(int signum)
{pid_t pid;int status;// 使用循环结合waitpid来确保回收所有结束的子进程while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {if (WIFEXITED(status)) {// 子进程正常退出printf("Child %d exited normally with status %d.\n", pid, WEXITSTATUS(status));} else if (WIFSIGNALED(status)) {// 子进程被信号终止printf("Child %d terminated by signal %d.\n", pid, WTERMSIG(status));}}
}
对于waitpid如果说进行阻塞等待的话,没有子进程也条用waitpid则会直接返回-1,错误码被设置,因为系统会检查出来没有子进程了。但是对于上述来说,采用循环阻塞的形式等待的话,如果说有10个进程,退出回收资源有6个,4个一直不退出的话,那么就会一直阻塞。所以需要设置为非阻塞,当没有子进程退出的话,返回0,退出该信号处理函数。等到其他子进程退出了,还是会触发该信号的处理函数的。所以也没必要一直等待。所以采用非阻塞循环的方式回收子进程的资源。
也可以手动忽略该信号,那么子进程退出之后,会立即交给1号操作系统进程领养,不会等待父进程退出后,一起将子进程交给操作系统领养了。因为等待子进程是为了回收资源并获取子进程执行任务的结果,但是如果不需要直到子进程执行的怎么样的话,交给操作系统还是自己回收资源都是一样的,只要说解决了系统内存泄露的问题就可以。
父进程进行进程等待的一大重要原因是,如果父进程一直不退出还不回收子进程资源的话,就会造成系统级别的内存泄露,但是如果不退出也能交给操作系统领养释放资源的话也没有问题。