Linux进程间信号
目录
信号入门
生活角度中的信号
技术应用角度的信号
信号的发送与记录
信号处理常见方式概述
产生信号
通过终端按键产生
通过系统函数向进程发信号
由软件条件产生信号
由硬件异常产生信号
阻塞信号
信号其他相关常见概念
在内核中的表示
sigset_t
信号集操作函数
sigprocmask 修改block表
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。
sigpending 修改pending表
捕捉信号
内核空间与用户空间
内核态与用户态
内核如何实现信号的捕捉
sigaction
可重入函数
volatile
信号入门
生活角度中的信号
- 你在网上买了很多件商品,在等待不同商品快递的到来。但即便快递还没有到来,你也知道快递到了的时候应该怎么处理快递,也就是你能“识别快递”。
- 当快递到达目的地了,你收到了快递到来的通知,但是你不一定要马上下楼取快递,也就是说取快递的行为并不是一定要立即执行,可以理解成在“在合适的时候去取”。
- 在你收到快递到达的通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间内你并没有拿到快递,但是你知道快递已经到了,本质上是你“记住了有一个快递要去取”。
- 当你时间合适,顺利拿到快递之后,就要开始处理快递了,而处理快递的方式有三种:1、执行默认动作(打开快递,使用商品)2、执行自定义动作(快递是帮别人买的,你要将快递交给他)3、忽略(拿到快递后,放在一边继续做自己的事)。
- 快递到来的整个过程,对你来讲是异步的,你不能确定你的快递什么时候到。
在这个过程描述中,我们得知的信息都是通过某种方法让我们得到信号,我们才了解当前的情况。
技术应用角度的信号
在用户输入命令,在Shell下启动一个前台进程。(前台进程是指当前正在终端(Terminal)中运行并与用户直接交互的进程。就比如bash)
在用户按Ctrl + c,这个键盘输入产生一个硬件中断,被OS获取,解释成信号,发送给目标前台进程。前台进程因为受到信号,进而引起进程退出。(在规定上在同一时刻仅允许进行一个硬件中断)
下面我们在XShell下编写代码实现一下
编写以下程序并运行:
#include <stdio.h>
#include <unistd.h>
int main()
{while(1){printf("I am a process, I am waiting signal!\n");sleep(1);}return 0;
}
我们知道该程序的运行结果就是死循环地进行打印,而对于死循环来说,最好的方式就是按照前面说的,按下Ctrl + c,产生硬件中断,对其进行终止。
同样我们刚才说了, 按下Ctrl + c 后,会产生硬件中断,操作系统会获取并解释成信号,然后操作系统将2号信号发送给目标前台进程,从而使其终止。
那么我们如何验证呢?其实可以通过使用signal函数对2号信号进行捕捉,证明当我们按Ctrl+C时进程确实是收到了2号信号。使用signal函数时,我们需要传入两个参数,第一个是需要捕捉的信号编号,第二个是修改为我们自己的对捕捉信号的处理方法,该处理方法的参数是int,返回值是void。
就比如我们下面的代码中将2号信号进行了捕捉,当该进程运行起来后,若该进程收到了2号信号就会打印出收到信号的信号编号,而不是进行2号信号的默认操作:终止进程。
由此也证明了,当我们按Ctrl+C时进程确实是收到了2号信号。
那么此时就会有一个小问题,此时我们如何终止这个一直在终端死循环打印的进程呢?
这貌似是个问题,暴力的方法就是直接关闭该终端窗口,高级的方法就是使用下面的命令
kill -9 PID
那么问题又来了如何得该进程得PID呢?毕竟我们也没有打印出来啊。
其实可以通过另开一个终端窗口使用监视脚本,然后再kill -9 PID就可以了。
ps axj | head -1; ps axj | grep myproc | grep -v grep
补充:
前台进程就类比Windows中当前最大化/正在交互的窗口(如你正在编辑的Word文档)。
特点:
独占输入焦点:接收键盘鼠标输入(如打字、点击按钮)
界面实时更新:窗口内容可见且动态响应(如视频播放、游戏画面)
阻塞性:若进程卡死,整个系统可能无法操作其他窗口(如未响应时需强制关闭)
只能有一个
后台进程就类比:Windows中最小化或隐藏的窗口(如后台下载的迅雷)。
特点:
无输入焦点:不直接接收用户操作(但可通过通知交互)
资源限制:CPU/内存占用可能被系统抑制(避免影响前台体验)
持续运行:即使窗口不可见,任务仍在执行(如邮件接收、文件下载)
可以有多个
后台进程启动的方式是再后面加一个 &,这是Ctrl + c 就不管用了,因为他不是前台进程了,不可以用Ctrl + c 进行终止了。
比如如下
注意:
- Ctrl+C产生的信号只能发送给前台进程。在一个命令后面加个&就可以将其放到后台运行,这样Shell就不必等待进程结束就可以接收新的命令,启动新的进程。
- Shell可以同时运行一个前台进程和任意多个后台进程,但是只有前台进程才能接到像Ctrl+C这种控制键产生的信号。
- 前台进程在运行过程中,用户随时可能按下Ctrl+C而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都可能收到SIGINT信号而终止,所以信号相对于进程的控制流程来说是异步的。
- 信号是进程之间事件异步通知的一种方式,属于软中断。
补充:
信号的异步性(Asynchronous)
核心矛盾:
进程的正常代码流是顺序执行的(如函数A→函数B→函数C),但信号可能在任何时刻打断这一流程(比如执行到函数B时突然触发SIGINT
)。
异步体现:信号的到来与进程当前执行位置无关,就像你在看书时突然被电话打断,电话的来电时机与你看书的进度无关。
技术细节:
当用户按下Ctrl+C
时:
终端驱动检测到按键,生成
SIGINT
信号。内核强制将信号插入目标进程的待处理信号队列。
无论进程正在执行什么代码(除非阻塞信号),内核都会在下一次返回用户态前让进程处理信号。
软中断的本质:
信号是内核通过模拟中断机制实现的软件层事件通知。当信号到达时,内核会暂时打断进程的正常执行流,转而执行信号处理函数(类似硬中断(也叫硬件中断)服务例程),之后再恢复原流程。
信号的发送与记录
我们可以用kill -l 命令可以查看Linux当中的信号列表。
其中1~31号信号是普通信号,34~64号信号是实时信号,普通信号和实时信号各自都有31个,每个信号都有一个编号和一个宏定义名称:
这个文件没有找到,偷了一下别人的图。。。
信号是如何记录的?
实际上,当一个进程接收到某种信号后,该信号是被记录在该进程的进程控制块当中的。我们都知道进程控制块本质上就是一个结构体变量,而对于信号来说我们主要就是记录某种信号是否产生,因此,我们可以用一个32位的位图来记录信号是否产生。
其中比特位的位置代表信号的编号,而比特位的内容就代表是否收到对应信号,比如第6个比特位是1就表明收到了6号信号。
信号是如何产生的?
一个进程收到信号,本质就是该进程内的信号位图被修改了,也就是该进程的数据被修改了,而只有操作系统才有资格修改进程的数据,因为操作系统是进程的管理者。也就是说,信号的产生本质上就是操作系统直接去修改目标进程的task_struct中的信号位图。
注意: 信号只能由操作系统发送,但信号发送的方式有多种。
信号处理常见方式概述
- 执行该信号的默认处理动作。
- 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号。
- 忽略该信号
在Linux当中,我们可以通过man手册查看各个信号默认的处理动作。
man 7 signal
产生信号
通过终端按键产生
通过终端按键(如 Ctrl + C)终止刚才的死循环程序,实际上是利用了 Linux/Unix 系统的信号机制。当我们按下 Ctrl + C 时,终端会向当前前台进程发送一个 SIGINT
(中断信号),默认情况下这会终止该进程的执行。
但实际上我们还可以通过按Ctrl+\也可以终止该进程。
那么按Ctrl+C终止进程和按Ctrl+\终止进程,有什么区别?
按Ctrl+C实际上是向进程发送2号信号SIGINT,而按Ctrl+\实际上是向进程发送3号信号SIGQUIT。查看这两个信号的默认处理动作,可以看到这两个信号的Action是不一样的,2号信号是Term,而3号信号是Core。
Term和Core都代表着终止进程,但是Core在终止进程的时候会进行一个动作,那就是核心转储,也就是Core Dump。
Core Dump
首先解释一下什么是Core Dump。当一个进程要异常终止时,可以选择把这个进程的用户空间内存数据全部保存到磁盘上,文件名通常时core,这个做Core Dump。进程异常终止通常时因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检测core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存在PCB中)。默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。首先用ulimit命令改变Shell进程的 Resource Limit,允许core文件最大为1024K:ulimit -c 1024
刚才上面也说了,默认设计不允许产生core文件的,那么在我们服务器中Core Dump是默认关闭的,但是我们可以通过使用ulimit
命令进行改变。
ulimit -a
命令查看当前资源限制的设定。
图中,第一行显示core文件的大小为0,也在表示Core Dump是被关闭的,不允许常见core文件。
ulimit -c size
命令来设置core文件的大小。
core文件的大小设置完毕后,就相当于将核心转储功能打开了。此时如果我们再使用Ctrl+\对进程进行终止,就会发现终止进程后会显示core dumped
。
并且会在当前路径下生成一个core文件,该文件以一串数字为后缀,而这一串数字实际上就是发生这一次核心转储的进程的PID。
使用gdb对当前可执行程序进行调试,使用core-file core文件
命令加载core文件,即可判断出该程序在终止时收到了具体信号,借此可以准确找到错误。
core dump标志
在学习进程等待的时候,进程等待函数waitpid函数的第二个参数是status么?
pid_t waitpid(pid_t pid, int *status, int options);
waitpid函数的第二个参数status是一个输出型参数,其类型是一个int*,占4字节,但我们不把status看为简单的整型,而是将status的不同比特位代表不同的信息,具体细节如下(只关注status低16位比特位):
如果一个进程是正常终止的,那么status的次低8位就表示进程的退出状态,也就是常说的退出码,最常见的是0。若进程是被信号所杀,也就是异常终止,那么status的低7位表示终止信号,而第8位比特位是core dump标志,即进程终止时是否进行了Core Dump。
按照前面的步骤,打开Linux的Core Dump功能,并编写下列代码。代码中父进程使用fork函数创建了一个子进程,子进程所执行的代码当中存在野指针问题,当子进程执行到野指针问题时,必然会被操作系统所终止并在终止时进行Core Dump。此时父进程使用waitpid函数便可获取到子进程退出时的状态,根据status的第7个比特位便可得知子进程在被终止时是否进行了Core Dump。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>int main()
{if (fork() == 0){//childprintf("I am running...\n");int *p = NULL;*p = 1; // 野指针问题exit(0);}//fatherint status = 0;waitpid(-1, &status, 0);printf("exitCode:%d, core dump:%d, signal:%d\n",(status >> 8) & 0xff, (status >> 7) & 1, status & 0x7f);return 0;
}
可以看到core dump的标志位为1,是被第11号信号所终止。
因此,core dump标志实际上就是用于表示程序崩溃的时候是否进行了Core Dump。
总结:
进程一旦异常,OS会将进程在内存中的信息给dump(转储)到进程当前的目录下,从而形成core.PID文件,用于记录进程崩溃时的内存状态、寄存器值、调用栈等信息,方便开发者进行事后调试。这一操作叫为:核心存储(Core Dump)。
补充:
键盘是基于硬件终端进行工作的,某些信号是可以用组合键通过终端按键产生,比如Ctrl+C(2号信号)、Ctrl+\(3号信号)、Ctrl+Z(20号信号),这类信号可以通过signal捕捉到。但并不是所有的信号都可以被signal捕捉到的,比如在前31号信号中,19号信号(暂停),9号信号(杀死指定进程)都不能捕捉到。
如果允许捕获它们,可能会导致系统管理失控(如僵尸进程无法被杀死),即便是操作系统也为无法终止。
通过系统函数向进程发信号
首先我们在后台执行死循环程序,然后用kill命令给它发SIGSEGV信号
- 226481是myproc进程的PID。之所以要再次回车才显示Segmentation fault,是因为在226481进程终止掉之前已经回到了Shell提示符等待用户输入下一条命令,Shell不希望Segmentation fault信息和用户的输入交错在一起,所以等用户输入命令之后才显示。
- 指定发送某种信号的kill命令可以有多种写法,上面的命令还可以写成kill -SIGSEGV 226481或kill -11 226481,11是信号SIGSEGV的编号。以往遇到的段错误都是由非法内存访问产生的,而这个程序本身没错,给它发SIGSEGV也能产生段错误。
kill命令是调用kill函数实现的。kill函数可以给一个指定的进程发送指定的信号。raise函数可以给当前的进程发送指定的信号(自己给自己发信号)。
下面详细介绍下kill函数与raise函数
kill函数
kill函数的原型 :
int kill(pid_t pid, int sig);
如果信号发送成功,则返回0,否则返回-1。
我们可以用kill函数模拟实现一个kill命令,同样还用死循环代码进行展示效果,实现逻辑如下:
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>
void Usage(char* proc)
{printf("Usage: %s error\n", proc);
}
int main(int argc, char* argv[])
{if(argc != 3){Usage(argv[0]);return 1;}int signo = atoi(argv[2]);pid_t pid = atoi(argv[1]);if(kill(pid, signo) == -1) printf("error\n");return 0;
}
raise函数
raise函数的函数原型如下:
int raise(int sig);
raise函数用于给当前进程发送sig
号信号,如果信号发送成功,则返回0,否则返回一个非零值。
例如每个两秒给自己发送2号信号
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void handler(int sig)
{printf("get a signal:%d\n", sig);
}
int main()
{signal(2, handler); // 捕捉2号信号while(1){sleep(1);raise(2);sleep(1);}return 0;
}
abort函数使当前进程接受到信号而异常终止
函数原型如下:
#include <stdio.h>
void abort(void);
就像exit函数一样,abort函数总会成功的,所以没有返回值。
就按上面的代码进行修改,添加上abort函数。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
void handler(int sig)
{printf("get a signal:%d\n", sig);
}
int main()
{signal(2, handler); // 捕捉2号信号while(1){sleep(1);raise(2);abort();sleep(1);}return 0;
}
与之前不同的是,虽然我们对二号信号进行了捕捉,并且在捕捉后还对其行为进行了自定义方法,但当接受到二号信号后,还是会被终止。
说明一下: abort函数的作用是异常终止进程,exit函数的作用是正常终止进程,而abort本质是通过向当前进程发送SIGABRT信号而终止进程的,因此其与exit函数一样,总是会成功。
由软件条件产生信号
SIGPIPE信号
SIGPIPE信号实际上就是一种由软件条件产生的信号,当进程在使用管道进行通信时,读端进程将读端关闭,而写端进程还在一直向管道写入数据,那么此时写端进程就会收到SIGPIPE信号进而被操作系统终止。
alarm函数与SIGALRM信号
调用alarm函数可以设定一个闹钟,也就是告诉操作系统在若干时间后发送SIGALRM信号给当前进程,alarm函数的函数原型如下:
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒后给当前进程发送SIGALRM信号,该信号的默认处理动作是终止当前进程。
alarm函数的返回值是0或者是以前设定的闹钟时间还剩余下的秒数。打个比方,某人要小睡一觉,设定闹钟为30分钟之后响,20分钟后被吵醒了,但还想多睡一会,于是重新设定了一个闹钟为15分钟后响起,以前设定的闹钟时间还剩余下的时间就是10分钟。
如果seconds的值是0,表示取消以前设定的闹钟,函数的返回值是以前设定的闹钟时间还余下的秒数。
在上面的操作中被吵醒后
可能看完还有疑惑,不妨,我们实验一下。
1:我们可以用下面的代码,测试自己的云服务器一秒时间内可以将一个变量累加到多大。
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
int main()
{int count = 0;alarm(1);while (1){count++;printf("count: %d\n", count);}return 0;
}
运行代码后,可以发现我当前的云服务器在一秒内可以将一个变量累加到20万左右。
补充:
但实际上我当前的云服务器在一秒内可以执行的累加次数远大于20万,那为什么上述代码运行结果比实际结果要小呢?
主要原因有两个,首先,由于我们每进行一次累加就进行了一次打印操作,而与外设之间的IO操作所需的时间要比累加操作的时间更长,其次,由于我当前使用的是云服务器,因此在累加操作后还需要将累加结果通过网络传输将服务器上的数据发送过来,因此最终显示的结果要比实际一秒内可累加的次数小得多。
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>int count = 0;
void handler(int signo)
{printf("get a signal: %d\n", signo);printf("count: %d\n", count);exit(1);
}
int main()
{signal(SIGALRM, handler);alarm(1);while (1){count++;}return 0;
}
此时可以看到,count变量在一秒内被累加的次数变成了五亿多,由此也证明了,与计算机单纯的计算相比较,计算机与外设进行IO时的速度是非常慢的。
2: 先设定一个10秒闹钟,后设定一个7秒闹钟
#include <stdio.h>
#include <signal.h>
#include <unistd.h>int main()
{alarm(10); // 先设定一个10s的闹钟sleep(5); //还剩五秒unsigned int n = alarm(7);printf("n = %d\n", n);sleep(5);printf("五秒过了\n");sleep(10);return 0;
}
可以看到返回值为5是第一个闹钟还剩余的时间,但是过了5秒,第一次设定的闹钟并没有响,而是过了7秒后第二个闹钟响了,这就说明了,后面设定的闹钟会覆盖前面的闹钟。
由硬件异常产生信号
硬件异常是指被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE信号发送给进程。在比如当前进程访问了非法内存地址,MMU(内存管理单元)会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。
总结一下:
C/C++程序会崩溃,是因为程序当中出现的各种错误最终一定会在硬件层面上有所表现,进而会被操作系统识别到,然后操作系统就会发送相应的信号将当前的进程终止。
下面模拟一下野指针的异常
比如以下代码
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
int main()
{sleep(1);int *p = NULL;*p = 100;while(1);return 0;
}
运行起来后效果如下:
可见,进程会因为野指针会被异常终止。在这个过程中,我们先定义了一个定义了一个指针。但在访问这个指针的时候,他会先通过页表映射,将虚拟地址转换成物理地址,然后才可以进行接下来的访问操作。
其中页表属于一种软件映射关系,我们拿着虚拟地址去页表找对应的虚拟地址,然后映射找到对应的物理地址,但实际上从虚拟地址到物理地址映射的时候还有一个硬件叫做MMU,它是计算机硬件中的一个关键组件,负责处理CPU的内存访问请求,并实现操作系统的虚拟内存管理机制。因此映射工作不是由CPU做的,而是由MMU做的,但现在的计算机已经将MMU归并为了CPU的一个子系统。
当需要进行虚拟地址到物理地址的映射时,需要先将左侧的虚拟地址给MMU,然后MMU会计算出对应的物理地址,我们再通过这个物理地址进行相应的访问。
而MMU既然是硬件单元,那么它当然也有相应的状态信息,当我们要访问不属于我们的虚拟地址时,MMU在进行虚拟地址到物理地址的转换时就会出现错误,然后将对应的错误写入到自己的状态信息当中,这时硬件上面的信息也会立马被操作系统识别到,进而将对应进程发送SIGSEGV信号。
总结:
C/C++程序崩溃,是因为程序当中出现的各种错误最终一定会在硬件层面上有所表现,进而会被操作系统识别到,然后操作系统就会发送相应的信号将当前的进程终止。
阻塞信号
信号其他相关常见概念
- 实际执行信号的处理动作,称为信号递达(Delivery)。
- 信号从产生到递达之间的状态,称为信号未决(pending)。
- 进程可以选择阻塞(Block)某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后的一种处理动作。
对于此的理解,如果我们把信号递达比作为写作业,那么信号未决就类似于将老师布置的作业记下来。进程可以阻塞某个信号就可以类似比作为屏蔽某科布置的作业,但屏蔽归屏蔽,但还是知道是什么作业的,这就好比已读不回。而忽略则可以比作为未读。
在内核中的表示
信号在内核中的表示示意图如下:
- 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
- SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会在改变处理动作之后再接触阻塞。
- SIGQUIT信号未产生过,但一旦产生SIGQUIT信号,该信号将被阻塞,它的处理动作是用户自定义函数sighandler。如果在进程解除对某信号的阻塞之前,这种信号产生过多次,POSIX.1允许系统递达该信号一次或多次。Linux是这样实现的:普通信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里,这里只讨论普通信号。
总结一下:
- 在block位图中,比特位的每个位置都代表一个对某一信号的标记,比特位的内容代表该信号是否被阻塞,即0表示不屏蔽,1表示屏蔽。
- 在pending位图中,比特位的位置代表对某一信号的一个标识,比特位的内容代表是否收到该信号,即0表示没有收到,1表示收到。
- handler表本质上是一个函数指针数组,数组的下标代表某一个信号,数组的内容代表该信号递达时的处理动作,处理动作包括默认、忽略以及自定义。
- block、pending和handler这三张表的每一个位置是一一对应的。
sigset_t
从上面的内核表示中的图中可以看出,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。
因此未决和阻塞标志可以用相同的数据类型sigset_t来储存,sigset_t称为信号集,这个类型可以表示每个信号的有效或无效状态,在阻塞信号集中有效和无效的含义是该信号是否被阻塞,而在未决信号集中有效和无效的含义是该信号是否处于未决状态。
在Ubuntu的云服务中,sigset_t类型的定义如下:(不同操作系统实现sigset_t的方案可能不同)
#define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int)))
typedef struct {unsigned long int __val[_SIGSET_NWORDS]; // 通常是 64 位整数数组
} sigset_t;
信号集操作函数
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”,至于这个类型内部如何存储这些bit则依赖于系统的实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_t变量,而不应该对它内部数据做任何解释,比如printf直接打印sigset_t变量是没有意义的。
#include <signal.h>int sigemptyset(sigset_t *set);int sigfillset(sigset_t *set);int sigaddset(sigset_t *set, int signum);int sigdelset(sigset_t *set, int signum);int sigismember(const sigset_t *set, int signum);
函数解释:
- sigemptyset函数:初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。
- sigfillset函数:初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。
- sigaddset函数:在set所指向的信号集中添加某种有效信号。
- sigdelset函数:在set所指向的信号集中删除某种有效信号。
- sigismember函数:判断在set所指向的信号集中是否包含某种信号,若包含则返回1,不包含则返回0,调用失败返回-1。
- 注意,在使用sigset_t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号处于确定的状态。
- 初始化siggset_t变量之后就可以再调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号
前四个函数都是调用成功返回0,出错返回-1,只有最后一个函数包含是返回1,不包含返回0,出错返回-1。
这几个函数的作用是修改siggset_t类型,但是它并不能真正的修改block表于pending表。就好比只能修改草稿,但并不会真正影响进程。
sigprocmask 修改block表
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。
其该函数的函数原型如下:
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
返回值:若成功则为0,出错则为-1.
参数说明:
- 如果oset是非空指针,则读取进程当前的信号屏蔽字通过oset参数传出。
- 如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。
- 如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。
假设当前的信号屏蔽字为mask(屏蔽字),下表说明了how参数的可选值以及其各自含义
选项 | 含义 |
SIG_BLOCK | set包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask|set |
SIG_UNBLOCK | set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask|~set |
SIG_SETMASK | 设置当前信号屏蔽字为set所指向的值,相当于mask=set |
如果调用了sigprocmask解除了对当前若干个未决信号的阻塞,则再sigprocmask返回前,至少将其中一个信号递达。
sigpending 修改pending表
sigpending函数用于读取当前进程的未决信息集,通过set参数传出。
函数原型如下:
#include <signal.h>
int sigpending(sigset_t *set);
返回值:调用成功返回0,出错返回-1。
下面我们来用刚学的几个函数做一个简单的实验
实验步骤如下:
- 先定义两个siggset_t变量。然后进行初始化。
- 对将SIGINT信号添加为有效信号。
- 设置阻塞信号集,阻塞信号SIGINT信号。
- 使用kill命令或组合按键向进程发送2号信号。
- 此时2号信号会一直被阻塞,并一直处于pending(未决)状态。
- 使用sigpending函数获取当前进程的pending信号集进行验证。
代码如下:
#include <stdio.h>
#include <unistd.h>
#include <signal.h>void printPending(sigset_t *pending)
{int i = 1;for (i = 1; i <= 31; i++){if (sigismember(pending, i)){printf("1 ");}else{printf("0 ");}}printf("\n");
}
int main()
{sigset_t set, oset;sigemptyset(&set);sigemptyset(&oset);sigaddset(&set, 2); //SIGINTsigprocmask(SIG_SETMASK, &set, &oset); //阻塞2号信号sigset_t pending;sigemptyset(&pending);while (1){sigpending(&pending); //获取pendingprintPending(&pending); //打印pending位图(1表示未决)sleep(1);}return 0;
}
可以看到,程序刚开始运行的时候因为没有收到任何信号,所以此时的pending表一直全为0,我们使用kill命令向该进程发送2号信号后,由于2号信号是阻塞的,因此2号信号一直处于未决状态,所以我们看到pending表中的第二个数字一直是1。
捕捉信号
想要深入了解信号的捕捉,那么就需要了解进程地址空间。进程地址空间是操作系统为每个运行中的进程分配的虚拟内存范围,它是进程视角中的线性或结构化内存视图。除此之外,最终要的是:进程地址空间由内核空间与用户空间组成。
内核空间与用户空间
- 用户空间是供进程直接使用的内存区域,存放进程的代码、数据、堆、栈等的空间。
- 内核空间是用于供操作系统内核运行,管理硬件、进程调度、内存映射等核心功能的空间。
说到此,就又回到我们的老图了,就是如图:
如图就是进程地址空间的分布图(典型地址布局(以32位系统为例)
-
用户空间:
0x00000000
~0xBFFFFFFF
(3GB)-
进程独享,通过页表映射到物理内存或交换区。
-
-
内核空间:
0xC0000000
~0xFFFFFFFF
(1GB)-
所有进程共享,直接映射物理内存。
-
在以前的学习中我们只是以用户级页表来学习的,但实际上,还存在内核级页表。
内核级页表是一个全局的页表,它用来维护操作系统的代码与进程之间的关系,因此在理论上,操作系统只需要维护一个内核级页表就可以,对于不同的进程地址空间都可以共用这一张内核级页表用于映射 内核空间。但在在每个进程的进程地址空间中,用户空间是独立属于当前进程,每个进程看到的代码和数据是完全不同的,所以对于每一个进程,操作系统需要对每一个进程都需要独立维护一个用户级页表。
注意:虽然所有进程的地址空间中都有内核空间的映射(即能“看到”操作系统),但用户态进程无法直接访问或修改内核空间的内容,必须通过严格的权限控制机制(如系统调用)才能间接访问。
进程切换时
因为用户级页表(每个进程独立),但内核级页表(全局共享) 。
所以当CPU切换到另一个进程时,对于用户级页表,会加载该进程的页表(通过CR3
寄存器更新),从而切换用户空间视图。内核空间的页表部分 保持不变(仅用户空间页表切换)。
为什么这样设计?
-
效率:内核代码只需加载到物理内存一次,所有进程共享,避免重复映射。
-
安全性:用户进程无法直接修改内核页表或访问其他进程的用户空间。
-
一致性:内核管理的全局资源(如进程列表、文件系统缓存)对所有进程可见。
这里简单补充一下,以为下面提出用户态,内核态做一下引子。
注意: 当你访问用户空间时你必须处于用户态,当你访问内核空间时你必须处于内核态。
内核态与用户态
一句话总结:
-
用户态(User Mode):普通程序运行的状态,权限低,不能直接访问硬件或内核内存。
-
内核态(Kernel Mode):操作系统内核运行的状态,权限高,可以执行任何操作(管理硬件、修改内存等)。
通俗类比:
-
用户态 → 像普通游客在动物园:
-
只能看动物(用API),不能摸(不能直接操作硬件)。
-
需要喂食?得找管理员(系统调用)。
-
-
内核态 → 像动物园管理员:
-
能打开笼子(操作硬件)、调配资源(管理内存)。
-
对操作系统来说,当操作系统接受到信号的时候,并不是立刻处理信号的,而是在其适合的时候,适合的时候是指内核态与用户态的切换。
内核态和用户态之间是进行如何切换的?
操作系统在 用户态(User Mode) 和 内核态(Kernel Mode) 之间的切换是通过 硬件机制(CPU指令) 和 操作系统协作 完成的。
切换方向 | 触发方式 | CPU 行为 | 关键指令 |
---|---|---|---|
用户态 → 内核态 | 系统调用 / 中断 / 异常 | 保存上下文 → 提权 → 跳转内核代码 | syscall / int 0x80 |
内核态 → 用户态 | 系统调用返回 / 中断返回 | 恢复上下文 → 降权 → 返回用户代码 | iret / sysexit |
由用户态转为内核态的过程称为‘陷入内核’(Trap)。其本质就是:当用户程序需要执行高特权操作(如硬件访问或内存管理)时,由于用户态权限不足,必须通过‘陷入内核’机制,让操作系统内核代为完成。
例如,用户程序调用 open()
或 write()
等函数时,表面上使用的是封装好的接口,但实际执行的是内核中的 sys_open()
和 sys_write()
。用户程序在理论上是无法直接调用这些内核函数,必须通过系统调用触发陷入内核,由内核完成实际操作后,再将结果返回用户态。
内核如何实现信号的捕捉
当我们在执行主控制流程的时候,可能因为某些情况而陷入内核,当内核处理完毕准备返回用户态时,就需要进行信号pending的检查。(此时仍处于内核态,有权力查看当前进程的pending位图)。
在查看pending位图时,如果发现有未决信号,并且该信号没有被阻塞,那么此时就需要该信号进行处理。
如果待处理信号的处理动作是默认或者忽略,则执行该信号的处理动作后清除对应的pending标志位,如果没有新的信号要递达,就直接返回用户态,从主控制流程中上次被中断的地方继续向下执行即可。
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。即该信号的处理动作是由用户提供的,那么处理该信号时就需要先返回用户态执行对应的自定义处理动作,执行完后再通过特殊的系统调用sigreturn再次陷入内核并清除对应的pending标志位,如果没有新的信号要递达,就直接返回用户态,继续执行主控制流程的代码。
举例如下: 用户程序注册了SIGQUIT信号的处理函数sighandler。 当前正在执行 main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号 SIGQUIT递达。 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函 数,sighandler 和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是 两个独立的控制流程。 sighandler函数返 回后自动执行特殊的系统调用sigreturn再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复 main函数的上下文继续执行了。
那么这样记忆还是太麻烦了,有没有更简单的记忆方法?
有的,有的。
巧记
其中,改图与中间的直线有几个交线,那么就有几次内核态与用户态的切换次数。而箭头就代表状态切换的方向。园点就代表着信号检测,注意此时还在内核态。
这样的设计,就保证了效率,安全性,一致性。
sigaction
该函数与我们一开始用到的signal函数都是用与信号的捕捉,但signal() 因为存在局限性,signal() 比如在信号处理函数执行时,会自动重置信号处理方式为默认行为(如 SIGINT
重置后再次触发会直接终止进程)。后果:若信号频繁触发(如快速按下 Ctrl+C),可能导致信号丢失或进程意外终止。除此之外,不同的系统对于其是实现也有不同,所以可移植性也存在问题。
所以就有了更优的函数sigaction。
其函数原型如下:
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
sigaction函数可以读取和修改与指定信号相关联的处理动作,该函数调用成功返回0,出错返回-1。
参数说明:
- signo代表指定信号的编号。
- 若act指针非空,则根据act修改该信号的处理动作。其为输入型参数。
- 若oact指针非空,则通过oact传出该信号原来的处理动作。其为输出型参数。
- act与oact都是指向sigaction结构体。
其结构体原型如下:
struct sigaction {void (*sa_handler)(int); // 简单信号处理函数(类似 signal())1-31void (*sa_sigaction)(int, siginfo_t *, void *); // 实时信号处理函数 34-64sigset_t sa_mask; // 信号处理期间阻塞的信号集int sa_flags; // 控制信号行为的标志位void (*sa_restorer)(void); // 已废弃,勿用
};
- 其中结构体中sa_handler,将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动 作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函 数,该函数返回 值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信 号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。
- 当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。这样的目的是:防止同一信号嵌套触发导致处理函数重入(即函数未执行完又被调用),引发竞态条件或资源冲突。
- pending表是什么时候从1 --> 0呢?是在执行信号捕捉之前,就先清0,然后再调用方法信号被处理时,此时还会将对应就会内核自动将当前信号加入进程的信号屏蔽字。最后处理完后恢复。并不是处理完后才清0,而是先清0,再处理。
- 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需 要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。这样的含义是:防止其他信号干扰当前信号的处理过程。
sa_flags字段包含一些选项,大部分都是将sa_flags设为0,sa_sigaction是实时信号的处理函数。本篇文章不详细解释这两个字段。
下面简单给一个使用案例:
代码实现了一个信号处理程序:主进程循环打印自身PID,当捕获到信号2(SIGINT,通常是Ctrl+C)时,会触发handler函数,持续打印当前未决信号状态(1-31号信号的阻塞情况),直到手动终止进程。同时屏蔽了信号1、3、4的干扰。
#include <iostream>
#include <cstring>
#include <ctime>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>using namespace std;void PrintPending()
{sigset_t set;sigpending(&set);for (int signo = 1; signo <= 31; signo++){if (sigismember(&set, signo))cout << "1";elsecout << "0";}cout << "\n";
}void handler(int signo)
{cout << "catch a signal, signal number : " << signo << endl;while (true){PrintPending();sleep(1);}
}int main()
{// act: 用于设置新的信号处理动作。// oact: 用于保存原来的信号处理动作。struct sigaction act, oact;// 将 act 和 oact 的所有字段初始化为 0,以确保没有意外的垃圾数据。memset(&act, 0, sizeof(act));memset(&oact, 0, sizeof(oact));// 初始化 act.sa_mask,表示在信号处理期间不屏蔽任何信号。sigemptyset(&act.sa_mask);// 将信号 1、3 和 4 添加到 act.sa_mask 中。// 这些信号在处理信号 2 的过程中会被阻塞,避免它们干扰当前信号的处理。sigaddset(&act.sa_mask, 1);// 设置block表sigaddset(&act.sa_mask, 3);sigaddset(&act.sa_mask, 4);// 指定处理信号 2 的动作。act.sa_handler = handler; // SIG_IGN SIG_DFL 设置捕捉动作函数sigaction(2, &act, &oact);while (true){cout << "I am a process: " << getpid() << endl;sleep(1);}return 0;
}
效果运行如下:
可重入函数
这一部分内容,我们还是要从链表部分来解释,下面给出一个代码。
我么做的操作上是在主函数上调用insert函数向链表中插入节点node1,然后设计一个信号处理函数sighandler,然后该函数内调用insert。代码如图表示,乍一看完全没有问题。
但这实际上是存在隐藏问题的,下面给大家分析一下这个链表
1、首先,main函数中调用了insert函数,想要将node1结点插入链表,但其插入分两步,但刚做完第一步的时候,因为某种原因,发生了硬件中断使进程切换到内核,再次回到用户态之前检查到有信号待处理,于是切换到sighandler函数。
2、而对于 sighandler 也调用了insert函数,将结点node2插入到了链表中,插入操作完成第一步后的情况如下:
3、当结点node2插入的两步操作完成后,操作系统就会返回内核态,此时链表的布局如下:
4、再次回到用户态就从main函数调用的insert函数中继续往下执行,即继续进行结点node1的插入操作。此时链表就变为了:
最终结果是,main函数和sighandler函数先后向链表中插入了两个结点,但最后只有node1结点真正插入到了链表中,而node2结点就再也找不到了,造成了内存泄漏。
上述例子中,各函数执行的先后顺序如下: 像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称 为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之, 如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。
想一下,为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱
这是因为每次函数调用时,系统会为其分配独立的栈帧(Stack Frame),局部变量和参数存储在该栈帧中。
-
不同控制流程(如不同线程或嵌套调用)的栈帧彼此隔离,因此同名局部变量实际占用不同内存地址。
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标志I/O库函数,因为标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
volatile
该关键字在C语言当中我们已经有所学习,其作用为:是一个类型修饰符,用于告诉编译器不要对该变量进行激进的优化(如缓存、重排序等),因为它可能被意外修改(例如由硬件、中断或另一个线程修改)。
编译器通常会优化代码,将频繁读取的变量缓存到寄存器中。volatile
强制每次访问变量时都从内存中重新读取或写入,确保数据的实时性。
比如:
volatile int flag = 0;
while (flag == 0); // 编译器不会优化掉循环,每次都会检查内存中的 flag
今天我们就站在信号的角度来理解一下。
在下面的代码中,我们对2号信号进行了捕捉,当该进程收到2号信号时会将全局变量flag由0置1。也就是说,在进程收到2号信号之前,该进程会一直处于死循环状态,直到收到2号信号时将flag置1才能够正常退出。
#include <stdio.h>
#include <signal.h>int flag = 0;void handler(int signo)
{printf("get a signal:%d\n", signo);flag = 1;
}
int main()
{signal(2, handler);while (!flag);printf("process quit normal!\n");return 0;
}
标准情况下,键入Ctrl + C ,2号信号被捕捉,执行自定义动作,修改flag=1,while条件不满足,退出循环,进程退出。
该程序的运行过程好像都在我们的意料之中,但事实并非如此。可能会想到,代码中的main函数和handler函数是两个独立的执行流,而while循环是在main函数当中的,在编译器编译时只能检测到在main函数中对flag变量的使用。
此时编译器检测到在main函数中并没有对flag变量做修改操作,在编译器优化级别较高的时候。
此优化情况下,键入CTRL-C ,2号信号被捕捉,执行自定义动作,修改flag=1,但是while条件依旧满足,进 程继续运行!但是很明显flag肯定已经被修改了,但是为何循环依旧执行?很明显,while循环检查的flag, 并不是内存中最新的flag,这就存在了数据二异性的问题。while检测的flag其实已经因为优化,被放在了 CPU寄存器当中。
在编译代码时携带-O3
选项使得编译器的优化级别最高,此时再运行该代码,就算向进程发生2号信号,该进程也不会终止。
g++ -03 -o proc proc.cc // 注意是数字03,不要搞为字母O + 3
这里我在测试的时候遇见了一个小问题,按道理效果应该如上如,但是第一次学习的时候用的别的环境测试的Centos7,第二次用的是Ubuntu,第三次是换了个Ubuntu。同样的代码,前两次没问题,但第三次出现了小问题,并不是正确的运行结果,是直接打印process quit normal,然后我就猜测与实验,然后发现在while后面添加一个调用cout就可以了,目前也不知道为什么。应该是有什么bug之类的。
面对这种情况,我们就可以使用volatile关键字对flag变量进行修饰,告知编译器,对flag变量的任何操作都必须真实的在内存中进行,即保持了内存的可见性。
volatile int flag = 0;
此时就算我们编译代码时携带-O3
选项,当进程收到2号信号将内存中的flag变量置1时,main函数执行流也能够检测到内存中flag变量的变化,进而跳出死循环正常退出。
总结:
volatile作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量 的任何操作,都必须在真实的内存中进行操作