Linux(信号)
目录
一 什么是信号
二 Linux中的信号
1. 查看信号:kill -l
2. 自定义信号的处理方式
2.1 API
2.2 demo
3. 理解信号的发送
4. 信号产生的方式
三 信号保存
四 捕捉信号
1. 先来说说硬件中断:
1. 谁调度操作系统?
2. 理解时间片
2. 软件中断
1. 系统调用如何工作
2. CPU内部错误如何处理
3. 用户态 VS 内核态
五 可重入函数/volation关键字/SIGCHLD信号
一 什么是信号
1. 生活中的信号:红绿灯,闹钟,上课铃声....都称为信号。
2. 当得知了这些信号,是执行默认动作?还是不管直接忽略掉?还是做你想做的动作?还是等会在做?都取决于自己。
3. 当你正在做某件事情的时候,信号可能随时产生,也就是说信号的产生是异步的,比如3个人吃饭,突然有一个人干别的事情去了,无需等待他,继续吃饭称为异步,如果等他把事情做完了再回来在继续吃饭称为同步。
二 Linux中的信号
1. 查看信号:kill -l
1 ) SIGHUP 2 ) SIGINT 3 ) SIGQUIT 4 ) SIGILL 5 ) SIGTRAP
6 ) SIGABRT 7 ) SIGBUS 8 ) SIGFPE 9 ) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3
38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8
43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7
58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2
63) SIGRTMAX-1 64) SIGRTMAX
2. 自定义信号的处理方式
2.1 API
#include <signal.h>typedef void (*sighandler_t)(int); // 返回值为 void 参数为 int 的函数指针类型sighandler_t signal(int signum, // 信号编号 sighandler_t handler // 处理方法);
通过这个系统调用就能捕捉上图的信号集合里面的信号。
2.2 demo
#include <iostream>
#include <unistd.h>
#include <signal.h>void fun(int sig)
{std::cout << sig << " 号信号" << std::endl;
}
int main()
{signal(SIGINT,fun);while(1){sleep(1);}return 0;
}
1. SIGINT表示键盘上的 ctrl c 组合键表示终止进程,但当捕捉了这个信号,就不会执行默认动作,转而去执行你自定义的方法,这个触发时机是异步的。
2. 所有信号都捕捉是否会没有办法终止进程了?有些信号捕捉也没用,比如:SIGKILL 9 号信号。
3. 理解信号的发送
1. 本章只关注前31个信号,后面的实时信号不关注。
2. 设想一下,从键盘输入 ctrl c 是直接发给进程了吗?
首先硬件是操作系统的管理者,进程也是,所以按下这个组合键,首先操作系统会捕捉到这个操作,转而在给进程发送信号。
下面是简易的说法:
1 ~ 31个信号,正好是32个bit位,一个整形的大小,是否可以用位图的方式统一管理?0和1表示信号是否产生。
3. 在看看 signal 系统调用的第二个参数,是个回调,那是不是 1 ~ 31 个信号都对应着特定的处理函数?比如自定义的,默认,忽略。用函数指针数组(大小32)就可以把这 1 ~ 31 个信号处理方法维护起来。
所以发送信号本质是,操作系统把这个信号对应的位图结构由0置1,并且在去查这个bit位的位置,再去函数指针数组里执行这个信号的处理方式。
4. 信号产生的方式
1. 硬件:键盘,鼠标,网卡.....等。
2. 软件条件:
管道:读端关闭,写端再写发送SIGPIPE信号。
闹钟:设置闹钟,如果超时了就给创建该闹钟的进程发送SIGALRM信号。
3. 指令 :kill -信号 进程PID
4. 系统调用:
kill:给特定进程发送信号
#include <sys/types.h>
#include <signal.h>int kill(pid_t pid, // 指定进程PIDint sig // 发送什么信号);
raise:谁调用我,我就给谁发送信号
#include <signal.h>// 发送什么信号
int raise(int sig);
abort:调用该函数发送SIGABRT信号
#include <stdlib.h>// 执行该函数发送SIGABRT信号
void abort(void);
5. 异常
比如:除零,非法地址的寻址,越界,栈溢出,整形溢出等,都属于程序内部的错误。
一般这种错误都是 core 标志,而有些是 perm 标志。
core一般是程序内部错误,会在当前目录形成core文件,主要是用来定位错误位置的。
perm主要是程序终止无需关系错误信息,比如自己kill -9,键盘按下ctrl c,都属于自己想让进程终止就不需要知道错误信息了,编译文件携带 -g 选项,调试的时候 file core (core 文件名)。
三 信号保存
1. 信号捕捉了不一定就要执行,而是等某个时间段在执行,所以要先保存起来。
下面看看是如何保存起来的。
首先在进程PCB中会维护一个信号集表,每一行代表每个信号的操作:
pending:信号是否被触发,0表示没被触发,1表示被触发。
bolck:信号是否未决
未决表示,信号被触发了,也就是pending某个标记为置为1,但还没有被执行这个handler方法也就是递达,这个中间状态就是未决(看block这个表[ x ]状态),也可以理解成保存。
为1表示阻塞也就是屏蔽这个信号,触发了就会卡在这里,直到解除阻塞,为0表示可以递达了。
hander:信号是否递达,被递达就表示执行里面对应的方法,默认/忽略/自定义。
所以进程就知道哪些信号被触发可以执行了,直接查这张表横着看一一对应。
sigset_t :每个表的信息。
2. demo
下面来查看block被阻塞,但pending被触发没有执行handler的效果。
除了前面 signal() 对handler 的操作,下面还要对block进行操作。
int sigemptyset(sigset_t *set); // 清空 signal_t 集合int sigfillset(sigset_t *set); // 全部设置为1int sigaddset(sigset_t *set, int signum); // 把 1 设置进block表int sigdelset(sigset_t *set, int signum); // 删除设置的信号int sigismember(const sigset_t *set, int signum); // 判断某个信号是否被设置
上面是系统提供的函数,用户无需自己对 signal_t 类型修改。
#include <signal.h>/* Prototype for the glibc wrapper function */
// 对 block 表的操作
int sigprocmask(int how, // 怎么操作 const sigset_t *set, // 信号集的类型sigset_t *oldset // 旧的信号集的类型);how ->
SIG_BLOCK // 设置block并在旧的block表里新增
SIG_UNBLOCK // 解除block
SIG_SETMASK // 覆盖旧的block
#include <signal.h>// 获取pending表
int sigpending(sigset_t *set);
上面是修改block表的系统调用。
#include <iostream>
#include <unistd.h>
#include <signal.h>void fun(const sigset_t& pt)
{for (int i = 1; i <= 31; i++){std::cout << (sigismember(&pt, i));}std::cout << std::endl;
}
int main()
{// 设置 block 表sigset_t st;sigemptyset(&st);sigaddset(&st, 2);// 设置进内核sigprocmask(SIG_BLOCK, &st, nullptr);// 设置pending表sigset_t pt;sigemptyset(&pt);while (1){// 获取pending表std::cout << "pending :" << std::endl;fun(pt);//获取block表sigpending(&pt);std::cout << "block :" << std::endl;fun(st);sleep(1);}return 0;
}
四 捕捉信号
信号捕捉了到回显这个期间发生了什么?下面来看一张图
1. 先来说说硬件中断:
比如:按下键盘,操作系统是怎么知道键盘有数据,是定期轮询检测吗?磁盘寻址完毕,操作系统怎么知道磁盘数据就绪了呢?也是定期轮询检测吗?
其实,这些硬件一旦资源就绪,就会发起中断 -> 中断控制器 -> CPU -> 操作系统 -> 查中断向量表对应的方法。
硬件发起中断:按下键盘,磁盘寻址,显示屏显示。
然后这些设备会被中断控制器检测到,并记录这些设备的中断号,然后给CPU进行保存。
操作系统在电脑刚开机时会注册这些设备的执行方法,并用数组维护起来,这个数组称为中断向量表,所以当CPU保存了设备的中断号,操作系统就能拿着这个中断号查中断向量表(下标)执行对应的方法也就可以获取设备资源了,整个过程操作系统完全被动,直到中断被触发催促操作系统进行操作。
1. 谁调度操作系统?
都说进程被操作系统调度,那么谁调度操作系统呢?
有一个设备叫做时钟源,每隔一段时间给CPU发送中断,操作系统就会查这个时钟源对应的方法,执行该方法,操作系统也就被调度了,不过当代时钟源已经被集成到CPU内部了,一方面是因为外设太慢了,且占用中断控制器妨碍其他设备的中断请求。在CPU内部叫做主频,所以主频越快,操作系统被调度的也就越快,处理任务也就越快,CPU也就越贵。
2. 理解时间片
CPU调度进程的时候,当进程的时间片到了就会切换到下一个进程,那么这个时间片究竟是什么呢?
在内核角度来看时间片就是个计数器,上面说的操作系统被调度,本质是查看当成被调度的进程时间片到没到,如果没到,就 -- 时间片,上面也不干,如果为0就切换下一个进程继续调度,所以时间片本质就是内核维护的一个计数器,由操作系统被调度不断检测时间片并决定是否切换进程。
2. 软件中断
上面的硬件中断能说通过硬件触发的中断,那么软件可以触发中断吗,CPU为了支持软件也能触发中断,CPU内部设计了对应的汇编指令,比如 int 0x80 / syscall,既然是汇编指令,那不也就是代码吗,也就是软件,所以当触发这个汇编指令软件也能触发中断,查表索引。
1. 系统调用如何工作
当CPU支持 int 0x80 / syscall 指令触发中断,中断向量表既然能存放硬件对应的方法,是不是也能存放系统调用对应的处理方法?是的,当触发 int 0x80,执行中断查中断向量表对应的系统调用方法集合,在根据系统调用号(和硬件中断号一样),去索引这个方法集合里某个方法,也就是执行了系统调用的方法,实际系统调用号没有当作参数来传递,而是先写到寄存器中,在进行索引系统调用集的时候把寄存器中的值 move 到下标里。所以系统调用本质也是通过中断来完成的,只不过是软件中断而已,属于主动中断,称为陷阱。
所以系统调用本质是:int 0x80/syscall + 系统调用号 + 传递的参数 + 返回值 + 他们将来存入的寄存器。
那可不可以不用系统调用直接用上面的一些字段来做到执行系统调用的方法呢?可以
那为什么还要把上面的这一套逻辑封装成系统调用函数呢?
因为上面这套逻辑难用,不如直接封装成函数来给上层直接只用,简单明了。
2. CPU内部错误如何处理
比如:除0,野指针,缺页异常,非法寻址....等状态,统称为异常。
那么这些异常,肯定要配对一批的处理方法吧?所以当出现这些异常的时候,CPU会把他们转换成软件中断,转而去中断向量表查对应的方法,比如重新分配物理页,给进程发送信号....等,几乎大多数情况都是通过中断来处理的,所以操作系统就是基于中断来轮转的。
3. 用户态 VS 内核态
前面对信号的捕捉到回显流程,其实也是调用 signal() 通过软中断陷入内核态,并查系统调用表进行查表执行对应的方法。但这里提到了用户态和内核态,下面来具体说说用户态和内核态的概念。
虚拟地址空间是每个进程独有一份的,每个新的进程最开始都会初始化这个空间(mm_struct),这个空间被划分成,用户空间(0G~3G),内核空间(3G~$G),那么问题来了,用户空间是每个进程内部自己的资源,独有一份,但内核空间整个系统只有一份,也就是操作系统的各自方法和调用,和用户空间一样,也有个页表,只不过叫做内核级页表。
当在用户态调用了系统调用,此时会陷入内核态,系统调用也是内核态提供的方法,那么怎么陷入到内核态?CPU有一个CS段寄存器,低2位储存 CPL,也就是用户和内核的状态,0表示内核,3表示用户,所以当调用系统调用,首先触发软中断 int 0x80 / syscall,CPU自己修改CPL状态位为0表示内核态,此时状态已经切换成功,转而去执行比如系统调用等操作,结束在把状态修改成3表示用户态,也就是完成了系统调用之间的状态切换。
简单的来说用户态就是用户那一段空间,内核态就是内核的那一段空间,当某个条件满足,比如:硬件中断,软中断,时间中断,异常。就会进入状态切换到内核去执行操作系统注册的方法,结束在返回在进行状态切换。
为什么要有这2种状态?内核态属于操作系统,比如:内核数据结构PCB,注册的一堆方法等..如果用户能直接访问这些资源并滥用,错误的使用,直接导致操作系统崩溃了,所以本质是让用户与内核进行隔绝,互不影响,提高了系统的安全性,稳定性等方面的问题。
五 可重入函数/volation关键字/SIGCHLD信号
1. 可重复函数
假设一个场景,有一个全局的链表,L1,在某个函数中插入 L2到L1的尾部,然后又有个信号自定义捕捉了,里面调用的就是插入链表的那个函数,这里叫做L3,当第一次执行函数中的插入逻辑的时候,这时信号被捕捉,也去执行那个函数,结束,这时 L1 -> L3,然后最开始执行的执行流恢复继续执行,这时候 L1 -> L3,然后也进行插入逻辑,这时在第一次的时候已经确定插入到L1的尾部,结束变成 L1 -> L2,此时L3找不到了,也就造成了内存泄露。
所以由于这样的函数被多个执行流并发访问造成数据不一致问题,称为不可重入函数,相反就是可重入函数。
一般全部变量,堆空间分配的对象在函数内操作,再不做保护的情况下,这个函数都是不可重入的,相反函数里都是局部变量....等,一般是可重复函数。
2. volation关键字
假设一个场景,定义一个全部变量,在主main函数内部不对这个对象进行修改,这时候对某个信号进行自定义处理方法,正常编译的情况下,不会有任何问题,如果编译的时候携带优化:gcc -O 0/1/2/3 .... ,因为主函数没有和你自定义的方法有任何关联,所以触发这个信号对这个全局变量进行修改的时候,主函数查看这个变量还是原来的值,因为携带了优化选项,CPU就预先从内存把这个变量放到寄存器里,后续查看这个变量就不会去内存拿,虽然提高了效率,但不去内存拿,也就屏蔽了对内存的可见性,所以这种情况如果想让CPU继续去内存拿,就要在变量前面加上 volation关键字,字面意思:易变的/不稳定的,就是告诉CPU这个变量不稳定随时随地可能被修改,所以加上了这个关键字,不管优化等级是多少,CPU都会去内存取这个变量。
3. SIGCHLD信号
当fork()派生子进程的时候,子进程任务处理结束,是不是什么都不干?不是。
当子进程结束的时候会给父进程发送 SIGCHLD 信号,默认处理动作是忽略,那么是不是可以捕捉这个SIGCHLD信号并自定义处理方法,并在里面自动等待子进程回收?
这里回收要注意:必须循环回收 + 非阻塞等待。
循环回收,如果有10个子进程同时结束,此时pending表只会记录一次,也就是把对应的位图由0置1,如果不循环回收,此时后面9个子进程发了信号,但pending只会触发一次,不循环就只会回收一个其他的也就僵尸了。
非阻塞等待:如果阻塞等待,此时只有5个退了,还有5个没退,waitpid()就会一直阻塞,因为还有子进程没退,采用非阻塞,把第三个参数设置为 WNOHANG,为非阻塞等待,即使还有没退的,返回值为0,自行处理,比如:break,等待下次子进程发送SIGCHLD信号,直到所有子进程回收完毕,然后返回-1代表结束。
还有一种忽略子进程的方法:虽然系统对子进程的处理动作是忽略的,但用户手动对SIGCHLD进行忽略的话,子进程发送信号会自己释放自己,也就不用等待他了,前提是不关心子进程的退出信息,此方法在Linux上有效,其他自行测试。