目录
什么是信号
Linux下的信号
信号的记录
信号处理的常见方式
产生信号
使用组合键产生信号(包含core dump)
使用系统调用向进程发送信号
由软件条件产生信号
由硬件异常产生信号
阻塞信号
内核表示
sigset_t
信号集操作函数
sigpending
sigprocmask
示例
捕捉信号
内核状态与用户状态
内核信号捕捉
sigaction
可重入函数
volatile关键字
SIGCHLD
什么是信号
在我们生活中就有很多的信号,比如道路上经常会看到的红绿灯,红灯停,绿灯行,那我们为什么会知道呢,那是因为我们记住了什么是信号,记住了看到这个信号后应该做什么。
Linux下的信号
Linux下的信号本质上是一种通知机制,用户或者操作系统通过发送信号,通知进程某件事已经发生了,之后就可以处理它了。
进程要处理信号,必须具有识别信号的能力,有时我们虽然接受到了这个信号,但是我可能不会立马处理这个信号,无法处理,那也要先被临时记住,然后在合适的时候处理。
对于信号的产生,它和进程运行是异步的。
以前我们运行代码的时候,程序是个死循环,我们想要关闭它就是用过Ctrl + c,它是一个热键,实际上它就是向进程发送2号信号,当然要先识别组合键,这种键盘的工作方式是通过中断方式进行的。
使用kill -l命令就可以查看Linux中的信号列表,信号编号是1~64,在这之中没有32、33,1~31我们叫做普通信号,34~64我们叫做实时信号。
输入man 7 signal就可以查看信号的信息。
信号的记录
既然是操作系统发送的信号,那么要不要管理起来呢,那是肯定的,我们要判断一个信号是否产生,进程必须具有保存信号的相关数据结构,很明显就是使用位图结构,并保存在进程的PCB中,这部分属于内核数据结构,所以只有操作系统有权利向目标进程写入。
例如:Ctrl + c,操作系统首先要识别组合键,再找到进程列表的前台进程,然后写入对应的信号到进程内部的位图结构中。
信号处理的常见方式
- 执行信号的默认处理动作(进程自带的)。
- 忽略该信号。
- 自定义动作(捕捉信号)。
产生信号
使用组合键产生信号(包含core dump)
通过方法捕捉信号,修改进程对特定信号的处理动作,并不会直接调用对应的处理动作。如果没有产生这个signum,函数指针指向的方法就不会被调用。
参数:
- signum表示要捕捉的信号。
- 参数类型为函数指针,传入一个函数指针,这个函数要处理这个捕捉到的信号。
#include <iostream> #include <unistd.h> #include <signal.h>using namespace std;void catchSignal(int signum) {cout << "捕捉到了一个信号,正在处理:" << signum << ", pid: " << getpid() << endl; }int main() {signal(SIGINT, catchSignal);while (true){cout << "进程运行中... pid: " << getpid() << endl;sleep(1);}return 0; }
信号的默认处理动作更改了,并没有终止进程,而是执行了特定的处理动作。
键盘上还有一个组合键就是Ctrl + \,它是向进程发送3号信号,也会终止掉进程。
可以看到两个信号对应的动作不一样,Term就是terminate表示终止,Core在终止进程的时候会进行一个动作:核心转储。
在进程控制的章节提到了status,那里有一张图。
进程等待返回的输出型参数status,被信号杀掉会在0~6这7为重存储终止信号,这一位7的位置就存放的是core dump标志。
在云服务器中,核心转储是默认被关掉的,我们可以通过使用ulimit -a命令查看当前资源限制的设定。
这里表示core文件的大小为0,表示核心转储是被关闭的,使用ulimit -c 大小来设置core文件的大小,这个操作也仅限于本次会话。
设置完了,核心转储该怎么用呢,就比如信号中有一个8号信号SIGFPE浮点数异常,它的操作也是Core。
随便写一个除0错误,这时在报错信息后就有了(core dumped),并且目录下也会多一个文件core.pid。
当运行中代码出错了,我们要知道出错原因是什么。这时我们就会用到核心转储,核心转储指的是操作系统在进程收到某些信号而终止运行时,将该进程地址空间的内容以及有关进程状态的其他信息转而存储到一个磁盘文件当中,这个磁盘文件也叫做核心转储文件。而核心转储的目的就是为了在调试。
我们重新编译一下,在g++选项中加入-g,使用gdb调试,输入core-file core.pid就可以得到这些信息。
所以core_dump标志表示的就是是否发生核心转储。
int main() {pid_t id = fork();if (id == 0){sleep(10);int a = 10;a /= 0;exit(1);}int status = 0;waitpid(id, &status, 0);cout << "父进程: " << getpid() << ", 子进程: " << id<< ", exit sig: " << (status & 0x7F) << ", is core: " << ((status >> 7) & 1) << endl;return 0; }
这里core dump标志就被设置成1,代表需要核心转储,并在磁盘中建立了文件。
当我们在这个除0错误之前向他发送其他信号,那么core dump就不会被设置
当我们把这个功能关掉也就不会发生核心转储了。
因为在我们平常使用的环境中是默认关闭core dump的,原因就是如果出错发生了核心转储,那么就要在磁盘上创建一个文件,如果一直出错,久而久之就会有大量的core dump文件,此时操作系统都有可能会挂掉,为了节约成本所以要关闭它。
使用系统调用向进程发送信号
作用:让操作系统向指定进程发送指定信号。
参数:进程pid和信号。
返回值:成功返回0,失败返回-1。
static void Usage(string proc) {cout << "Usage:\r\n\t" << proc << " signumber pid" << endl; }// ./mykill 2 pid int main(int argc, char* argv[]) {if (argc != 3){Usage(argv[0]);}int signumber = atoi(argv[1]);int procid = atoi(argv[2]);kill(procid, signumber);return 0; }
这就我们自己写的一个程序可以向指定进程发送信号。
作用:让操作系统向自己发送指定信号。
参数:信号。
返回值:成功返回0,非0表示失败。
作用:让操作系统给自己发送6号SIGABRT信号,就是自己终止自己。
由软件条件产生信号
SIGPIPE
上一篇进程间通信中就提到了,如果把读端关闭,写端一直写,管道已经没有人读了,这个写入就没有意义,操作系统会自动终止对应的写端进程,就是向写端进程发送13号SIGPIPE信号。这是一种由软件发现运行条件不满足的时候发送的一种信号,管道也是软件。
SIGALRM
参数:设置几秒后让操作系统向我发送SIGALRM信号
返回值:
- 若调用alarm函数前,进程已经设置了闹钟,则返回上一个闹钟时间的剩余时间,并且本次闹钟的设置会覆盖上一次闹钟的设置。
- 如果调用alarm函数前,进程没有设置闹钟,则返回值为0。
#include <iostream> #include <unistd.h> #include <signal.h> #include <string> #include <functional> #include <vector>#include <sys/wait.h> #include <sys/types.h>using namespace std;typedef function<void()> func; vector<func> callbacks;uint64_t count = 0;void showCount() {cout << "count: " << count << endl; }void catchSig(int signum) {cout << "捕捉到了一个信号,正在处理:" << signum << ", pid: " << getpid() << endl;for (auto& f : callbacks){f();}alarm(1); }int main() {// 要捕捉的信号signal(SIGALRM, catchSig);// 设置闹钟alarm(1);// 添加函数callbacks.push_back(showCount);while(true) count++;return 0; }
一个简单的累加器,通过这个代码我们也可以做到某一段时间后我们可以执行相应的操作。
由硬件异常产生信号
除0错误
void catchSig(int signum) {sleep(1);cout << "捕捉到了一个信号,正在处理:" << signum << ", pid: " << getpid() << endl; }int main() { signal(SIGFPE, catchSig);int a = 10;a /= 0;while (true) sleep(1);return 0; }
通过上面的代码运行结果就会发现问题,代码中有除0错误,也捕捉到了信号,但是为什么会一直循环的打印呢?
在我们的电脑中,是谁一直在帮我们计算,那就是CPU,CPU就是个硬件,我们也知道在CPU中有很多寄存器,其中就有一个状态寄存器,在这之中就有一个溢出标记位,操作系统在计算完毕之后就会检测,如果溢出标记位为1就代表发生了溢出问题,他就会找到当前正在运行的进程,向它发送信号,这个进程就会在合适的时候进行处理。
所以这种除0文件其实是一个硬件问题,一旦出现硬件异常,默认是终止进程,或者使用try/catch,实际也是做不了什么。
那么为什么会出现死循环?因为一旦触发了异常是要终止的,但是如果捕捉了这个信号就要自定义处理方式,处理了但没有终止进程,那寄存器中的异常信息没有处理,虽然有异常,进程切换的时候也会保存进程的上下文数据,当恢复的时候操作系统就会发现这个异常,然后不断的发送8号信号。解决这个问题那就捕捉到信号的时候直接终止进程就好了。
野指针或越界问题
无论是哪一个错误,他们都会通过地址找到目标位置,然而我们语言层面的地址都是虚拟地址,虚拟地址和物理地址映射的时候是通过页表+MMU(Memory Manager Unit 内存管理单元,这是一个硬件)。
MMU是一种处理CPU的内存访问请求的硬件,所以映射工作是由它做的,
当需要进行虚拟地址到物理地址的映射时,先将虚拟地址给MMU,它会计算出对应的物理地址,然后我们再访问这个物理地址。
而MMU既然是硬件,那么它当然也要记录相应的状态信息,当出现野指针或者越界访问非法地址时,MMU进行虚拟地址到物理地址的转换时就会报错,然后将错误信息写入,这时硬件上面的信息也会立马被操作系统识别到,然后找到当前进程,向该进程发送SIGSEGV信号。
阻塞信号
关于信号的其他相关概念:
- 实际执行信号的处理动作称为信号递达(Delivery)。
- 信号从产生到递达之间的状态称为信号未决(Pending)。
- 进程可以选择阻塞 (Block)某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
- 阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后的一种处理动作。
内核表示
这个pending表就是上述的位图结构,0代表没有收到该比特位的信号,1表示收到了;handler表就是函数指针数组,一旦pending表中收到了这个信号,那么就拿着信号的编号去handler表中找对应的函数处理方法,signal函数需要的参数一个是信号编号,另一个就是函数,signal就是通过这个信号的编号,找到handler表中对应的位置,再填入函数地址,这就是信号的自定义捕捉。
系统也提供了默认处理的宏,SIG_DEF和SIG_IGN。
还有一个block位图,和pending位图的结构一模一样,这个位图中表示的是信号是否被阻塞。
所以一个信号的处理流程应该是这样的:
- 操作系统向目标进程发送信号,其实就是修改pending位图。
- 进程合适的时候处理信号,找到pending位图中收到的信号。
- 再看block位图中该信号是否被阻塞。
- 最后再调用处理方法。
sigset_t
一般而言,语言会给我们提供头文件或者定义的结构体,操作系统也会提供这些,因为操作系统不希望我们直接访问这些数据结构,所以会给我们提供接口来访问他们。
sigset_t就是操作系统提供的位图结构,但是不允许用户直接进行位操作,它会给我们提供对应的方法来完成对应的功能。
sigset_t称为信号集,pending位图就叫做信号集,block信号集叫做信号屏蔽字,这个类型可以表示每个信号的“有效”或“无效”状态。
- 在信号屏蔽字中“有效”和“无效”的含义是该信号是否被阻塞。
- 在信号集中“有效”和“无效”的含义是该信号是否处于未决状态。
信号集操作函数
#include <signal.h>int sigemptyset(sigset_t *set); // 初始化set信号集,所有比特位清零int sigfillset(sigset_t *set); // 初始化set信号集,所有比特位置1int sigaddset(sigset_t *set, int signum); // 在set信号集中添加某种有效信号int sigdelset(sigset_t *set, int signum); // 在set信号集中删除某种有效信号// 上面的函数成功返回0,失败返回-1int sigismember(const sigset_t *set, int signum); // 判断set信号集中是否包含某种信号,若包含则返回1,不包含则返回0,调用失败返回-1
sigpending
int sigpending(sigset_t *set);
作用:获取当前进程的pending信号集
返回值:成功返回0,失败返回-1。
sigprocmask
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
作用:可以读取或更改进程内核中的信号屏蔽字(默认进程不会对任何信号block)
参数:
- how:
- SIG_BLOCK:添加set到当前信号屏蔽字的信号,相当于mask = mask | set
- SIG_UNBLOCK:从当前信号屏蔽字中解除阻塞set中的信号,相当于mask = mask | ~set
- SIG_SETMASK:设置当前信号屏蔽字为set,相当于mask=set
- set:信号屏蔽字
- oldset:输出型参数,拿到更改前的set信号屏蔽字
示例
这样就可以捕捉所有的信号了,是不是也就没有任何信号可以杀掉这个进程呢?
#include <iostream> #include <signal.h> #include <unistd.h>using namespace std;void catchSig(int signum) {cout << "获得信号:" << signum << endl; }int main() { for (int i = 1; i <= 31; i++) signal(i, catchSig);while (true) sleep(1);return 0; }
我们发现9号信号没有被捕捉,直接杀掉了进程,所以9号信号是不能被捕捉的,操作系统为了解决所有信号都被捕捉,没有信号能让进程退出的问题。
static void showPending(sigset_t &pending) {for (int sig = 1; sig <= 31; sig++){if (sigismember(&pending, sig)) cout << " 1";else cout << " 0";}cout << endl; }int main() {// 1.定义信号集sigset_t bset, obset; // 信号屏蔽字,阻塞信号集sigset_t pending; // 信号集// 2.初始化信号集sigemptyset(&bset);sigemptyset(&obset);sigemptyset(&pending);// 3.添加进行屏蔽的信号sigaddset(&bset, 2); // SIGINT// 4.设置到内核中int n = sigprocmask(SIG_BLOCK, &bset, &obset);assert(n != -1);(void)n;cout << "阻塞2号信号成功, pid: " << getpid() << endl; // 5.重复打印pending信号集while (true){// 5.1 获取进程的pending信号集sigpending(&pending);// 5.2 显示pending信号集中没有被递达的信号showPending(pending);sleep(1);}return 0; }
这样就屏蔽了2号信号,如果我们想要过一段时间解除阻塞2号,就可以修改一下。
// ...// 5.重复打印pending信号集int count = 0;while (true){// 5.1 获取进程的pending信号集sigpending(&pending);// 5.2 显示pending信号集中没有被递达的信号showPending(pending);sleep(1);count++;if (count == 10)sigprocmask(SIG_SETMASK, &obset, nullptr); // 使用修改之前的信号集} // ...
当我们解除阻塞之后,2号信号确实被递达了,直接执行了默认处理动作——终止进程。
如果不想让进程终止,也可以使用signal函数捕捉信号。
捕捉信号
内核状态与用户状态
在讲解信号如何被捕捉之前,我们再来处理一下上面的问题,前面我们说信号产生后,无法立即处理,可以在合适的时候处理,那什么是合适的时候呢?
信号的相关数据结构都放在PCB中,也就是在内核中的,所以普通用户无法直接访问,或者说必须要处于内核状态才能访问。
所以处理的时机就是从内核态返回用户态的时候检测和处理信号,那我们怎么进入内核态呢?
原来我们使用系统调用的时候就处于内核态,执行我们自己的代码的时候就叫做用户态。内核态是一种执行系统代码的状态,优先级很高;用户态是一个受管控的状态。
内核态和用户态是如何切换的呢?
内核态到用户态的转换:
- 进行系统调用的时候
- 进程时间片到了,要进程切换
- 发生异常、中断(interrupt 80)、陷阱等
用户态到内核态的转换:
- 系统调用返回
- 下次调度该进程继续执行时
- 异常处理完成
原来说过的进程地址空间中0~3G是用户空间,3~4G是内核空间。进程有多个,但是操作系统只有一个,原来的页表就是用户级页表,每个进程要进行虚拟地址和物理地址的映射;还要有一张内核级页表,可以简单理解为这张内核级页表是所有进程共享的,这张页表也要进行虚拟地址到物理地址的映射。
当我们在代码中使用系统调用的时候,这个系统调用的接口也在地址空间中,直接跳转到地址空间中,再通过页表映射找到物理内存中系统调用。
当进程的时间片到了,需要进程切换,操作系统底层就会发生时钟中断,它会找到正在执行的进程,通过地址空间找到进程切换的函数,再把上下文数据放到PCB中;下一个进程开始运行的时候,操作系统会通过内核空间恢复上下文数据。
那么我们为什么会有执行内核代码的权利呢?这也要用到CPU中某个寄存器,这个寄存器的某一个位置就表示当前的执行权限是内核态还是用户态。所以中断的时候就要从用户态转为内核态。
内核信号捕捉
当我们执行代码时,可能因为一些原因变成内核态,例如系统调用或者进程切换,既然进入了内核,操作系统就要知道为什么进入内核。处理完问题后,准备回到用户态前可以顺手处理一下信号,检测当前的pending位图,看看有没有收到信号,如果有再检查block位图,看看是否阻塞了这个信号,如果没有阻塞就去查看handler表。
handler表中信号处理有忽略,忽略就把pending位图中该信号由1置0;还有默认,默认情况下大多是终止,不再调度这个进程,释放地址空间和PCB,不用返回用户态。如果发生核心转储,在内核中也可以把相关数据通过IO放到磁盘中。
还有一种状态就是暂停,就是把R状态改为T状态,这也不用恢复到用户态,直接执行调度算法,放到等待队列中,在内核态中,这些默认和忽略动作都很好做。
下面我们就要谈一下捕捉了,当我们从pending中检测到某一个信号,此时的状态还是内核态,此时的内核状态也可以执行用户中的代码,但是操作系统不想这样做,如果有一些非法操作就会造成严重的问题,所以操作系统不会执行用户写的代码,或者说操作系统不能用内核态执行用户态代码,此时就要从内核态转变为用户态,再来执行handler方法。
执行完handler方法后也不能直接回到最开始进入内核的位置,因为要回到内核态将pending位图的该信号置为0,也不知道是从哪里进入内核的,所以必须要通过特殊的系统调用sigreturn进入内核,这样才能拿到进入内核的用户代码的位置。
箭头所向就是状态转换方向,几次状态转换就看流程图跨越了“边界线”。
sigaction
除了signal,还有一个函数sigaction也可以对信号捕捉。
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
返回值:成功返回0,失败返回-1
参数:
- signum:表示捕捉哪个信号
- act:如果act不是nullptr,根据act修改信号的处理动作,输入型参数
- oldact:如果oldact不是nullptr,返回原来的信号处理方法,输出型参数
其实这个sigaction也是一个结构体,只是它的名字和函数是一样的。
struct sigaction {void(*sa_handler)(int);void(*sa_sigaction)(int, siginfo_t *, void *);sigset_t sa_mask;int sa_flags;void(*sa_restorer)(void); };
成员变量:
- sa_handler:设为SIG_DFL为默认,设为SIG_IGN为忽略,设为函数指针就执行对应的自定义方法,要填入handler表中。
- sa_mask:信号屏蔽字,使用前要初始化。
- sa_flags:选项,现在设为0就行。
- 其他没有说的参数没有用到。
#include <iostream> #include <signal.h> #include <unistd.h> #include <cassert>using namespace std;void handler(int signum) {cout << "获取了一个信号:" << signum << endl; }int main() {// signal(2, SIG_IGN); 也可以这样修改信号的默认处理为忽略// 内核数据类型,用户栈定义struct sigaction act, oact;act.sa_flags = 0;sigemptyset(&act.sa_mask); // 初始化信号屏蔽字act.sa_handler = handler; // 捕捉方法// 设置进当前进程的PCB中sigaction(2, &act, &oact);// 默认处理动作cout << "default action: " << (int)(oact.sa_handler) << endl;while (true) sleep(1);return 0; }
那我们再来说一下sa_mask这个参数:
当某个信号的处理函数被调用,内核会自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复为原来的信号屏蔽字, 这就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。
如果调用信号处理函数时,除了当前信号被屏蔽外,还想屏蔽其他信号,这就可以在sa_mask中设置,当信号处理处理函数返回时自动恢复原来的信号屏蔽字。
就比如要捕捉的信号没收到之前,系统中没有设置信号屏蔽字,捕捉的信号到了,把该信号添加到信号屏蔽字的同时也要添加sa_mask中设置的信号屏蔽字,函数返回的时候就会自动恢复原来的信号屏蔽字,原来的没有设置信号屏蔽字,在sa_mask中设置的也就不会再阻塞了。
下面来演示一下,多设置了几个信号屏蔽字。
// sigaddset(&act.sa_mask, 3); // sigaddset(&act.sa_mask, 4); // sigaddset(&act.sa_mask, 5); // sigaddset(&act.sa_mask, 6); // sigaddset(&act.sa_mask, 7);void showPending(sigset_t* pending) {for (int i = 1;i <= 31; i++){if (sigismember(pending, i)) cout << " 1";else cout << " 0";}cout << endl; }void handler(int signum) {cout << "获取了一个信号:" << signum << endl;sigset_t pending;int i = 0;while (i++ < 10){sigpending(&pending);showPending(&pending);sleep(1);} }
这就可以看出,如果一个信号正在被处理,就会阻塞这个信号,sa_mask中的也会被阻塞,调用返回的时候就会处理这些信号,这个illegal instruction就是4号信号被递达了。
可重入函数
什么是重入,如果有两个执行流同时访问了某一个函数并且完成了某些任务。
如果我们在main函数中对一个结构做了修改,此时因为一些原因从用户态转变为内核态,假如在内核态中也对这个结构做了修改,这就可能会导致这个结构出现问题,比如出现内存泄漏。
我们原来使用的大多数函数都是不可重入的。
volatile关键字
这是C语言的一个关键字,有了信号的知识就可以更好的理解它了。
#include <iostream> #include <signal.h>using namespace std;int flag = 0;void catchSig(int signum) {cout << "捕捉到一个信号:" << signum << endl;flag = 1; }int main() {signal(2, catchSig);while (!flag);cout << "进程退出" << endl;return 0; }
这个代码也很好理解,只要有2号信号发来,我就修改flag的值,这样进程就可以退出了。
但是如果编译器的优化级别高的话,它就会自动检测,在main函数中的flag没有任何修改,每次都得让CPU从内存中读取这个值,判断后再放回内存,这样太慢了,所以编译器直接就把flag=0这个值放到了CPU的寄存器中。
我们看到的现象就是这样,编译器在编译的时候就已经决定好了,并不是在运行的时候才发现flag没有修改,CPU只是执行指令,你怎么编我就怎么执行。
所以在运行的时候检测的都是CPU寄存器中的flag=0,那循环就不会退出,即使我们看到flag已经被修改了。
当我们使用volatile关键字修饰flag的时候就没有问题了。
volatile就是说明flag可能会发生变化,每次使用它的时候必须从内存中读取,所以说volatile是保持了内存的可见性。
SIGCHLD
子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。
void handler(int signum) {cout << "捕捉到了信号:" << signum << endl; }int main() {signal(SIGCHLD, handler);pid_t id = fork();if (id == 0){int n = 5;while (n--){cout << "I am child, pid: " << getpid() << endl;sleep(1);}exit(1);}return 0; }
上面的代码我们没有等待子进程退出,一定会让子进程进入僵尸状态,父进程收到了SIGCHLD信号,但是又不想管,那么就可以直接设置为SIG_IGN。
int main() {signal(SIGCHLD, SIG_IGN);if (fork() == 0){cout << "child pid: " << getpid() << endl;sleep(5);exit(1);}while (true){cout << "parent pid: " << getpid() << endl;sleep(1);}return 0; }
虽然操作系统设置的SIGCHLD的默认处理动作就是忽略,但是并不是真的忽略,只是让子进程进入僵尸状态,一旦我们signal中设置了忽略,那就是告诉操作系统我不想管,直接帮我回收子进程就行。
这个信号不止子进程退出的时候会发送,子进程暂停也会发送这个信号,所以在进程等待中我们就说过waitpid要设置成WNOHANG非阻塞等待,因为子进程可能是退出也可能是暂停。
所以SIGCHLD这种做法是Linux操作系统采用的,其他的操作系统可能就不是这样的了。