目录
[使用信号的简单例子:ctrl + c](#使用信号的简单例子:ctrl + c)
[系统调用 - signal](#系统调用 - signal)
[通过 kill 指令产生信号](#通过 kill 指令产生信号)
[volatile 关键字](#volatile 关键字)
信号入门
生活角度的信号
- 你在网上买了很多件商品,等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能识别 "快递到来" 的信号
- 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成"在合适的时候去取"。
- 在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你"记住了有一个快递要去取"
- 当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:1. 执行默认动作(幸福的打开快递,使用商品)2. 执行自定义动作(快递是零食,你要送给你的女朋友)3. 忽略快递(快递拿上来之后,甩在地上,继续开一把游戏)快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话。
信号初步总结
1、信号是可以被识别的
2、接收到信号之后,执行什么动作是明确的
3、接收到信号之后,可能并不立刻处理这个信号,因为我正在做更重要的事情
4、从信号产生到处理信号有时间窗口,在该时间窗口内必须记住信号
5、信号的处理能力是进程内置功能的一部分

使用信号的简单例子:ctrl + c
用户输入命令,在Shell下启动一个前台进程 。
用户按下Ctrl+c ,这个键盘输入产生一个硬件中断,被OS获取,解释成信号,发送给目标前台进程
,前台进程因为收到信号,进而引起进程退出
cpp
[hb@localhost code_test]$ cat sig.c
#include <stdio.h>
int main()
{
while(1){
printf("I am a process, I am waiting signal!\n");
sleep(1);
}
return 0;
}
[hb@localhost code_test]$ ./sig
I am a process, I am waiting signal!
I am a process, I am waiting signal!
I am a process, I am waiting signal!
^C
[hb@localhost code_test]$
注意:
1、前台进程与后台进程 :以 ./ 文件名 方式运行的进程,是前台进程;以 **./ 文件名 &**方式运行的进程,是后台台进程
2、 前台进程:运行时 shell 不能再执行其他指令,后台进程:运行时 shell 可以执行其他指令。对于每个用户,一个终端配备一个bash,Shell 可以同时运行一个前台进程和任意多个后台进程,但对每个用户,只允许一个进程是前台进程
3、只有前台进程才能被 ctrl + c 杀死。后台进程用 kill -9 信号杀死
4、前台进程与后台进程的区别是:谁能获取键盘输入,谁就是前台进程。
输入 kill -l 可以查看 Linux 支持的所有信号

一共有 62 种信号(没有 32 、33 号信号),其中第 1 - 31 号信号是普通信号,34 - 64 是实时信号(这种信号一旦产生,必须尽快处理)。这些信号都是宏(#define SIGINT 2),之后只学习普通信号。上面的 ctrl + c 就是 2 号信号 SIGINT(INT:interrupt)。
了解实时信号:
普通信号的痛点:1、不支持排队:如果进程暂时屏蔽了 SIGUSR1,在此期间有 10 个 SIGUSR1 发送给该进程,当进程解除屏蔽后,它只会收到一次 SIGUSR1。其余的 9 个信号会被合并或丢弃。2、无携带信息:除了能知道"来了一个信号",程序无法通过信号传递额外的整型数据或指针。3编号范围有限:只有 1-31 这 31 个可靠的编号。
实时信号的核心特性:1. 排队机制:如果一个进程屏蔽了某个实时信号,多次发送同一个实时信号给该进程,内核会将这些信号全部排队。当进程解除屏蔽后,它会按顺序接收所有挂起的信号,接收次数严格等于发送次数 2. 顺序保证FIFO: 信号的传递顺序是先进先出的。先发送的实时信号会被先接收。如果同时有多个不同类型的实时信号(如 SIGRTMIN+1 和 SIGRTMIN+2)在排队,编号较小的实时信号(即 SIGRTMIN+1)优先级较高,会被优先递送。实时信号的优先级高于普通信号。如果同时有挂起的普通信号和实时信号,实时信号会被优先处理。3. 携带数据:这是实时信号最具威力的特性。通过 sigqueue() 系统调用(替代传统的 kill()),发送者可以随信号附带一个数据字段。
输入 man 7 signal 查看信号详细信息

信号的种类
1. Term (Terminate)
含义: 终止进程。
解释: 这是最常见的默认动作。表示进程接收到该信号后,会直接退出。
典型信号:
SIGINT(2, 键盘 Ctrl+C)、SIGTERM(15, 软件终止信号)、SIGHUP(1, 挂断)、SIGKILL(9, 强制杀死,不可捕获或忽略)。2. Core (Core Dump)
含义: 终止进程并生成核心转储文件。
解释: 进程在退出前,内核会将进程的内存映像(包括程序计数器、寄存器状态、内存数据等)写入当前工作目录(或通过
/proc/sys/kernel/core_pattern指定的路径)的一个名为core.pid的文件中。这通常用于事后调试,分析程序崩溃时的状态。典型信号:
SIGQUIT(3, 键盘 Ctrl+\)、SIGSEGV(11, 段错误,如访问非法内存地址)、SIGABRT(6, 调用abort()函数触发)、SIGFPE(8, 浮点异常)。3. Ign (Ignore)
含义: 忽略该信号。
解释: 内核直接将信号丢弃,对进程的执行没有任何影响。
典型信号:
SIGCHLD(17, 子进程状态改变时发给父进程,通常不需要处理,默认忽略)、SIGURG(带外数据到达)。需要注意的是,SIGKILL和SIGSTOP的默认动作不能设置为忽略。4. Stop (Stop)
含义: 暂停(停止)进程的执行。
解释: 进程被挂起,不再获得 CPU 时间片,进入暂停状态。它仍然存在于内存中,只是不再被调度执行。这不同于 Term(终止),Stop 后可以通过 Cont 信号恢复。
典型信号:
SIGSTOP(19, 强制暂停,不可捕获或忽略)、SIGTSTP(20, 终端暂停信号,如键盘 Ctrl+Z)、SIGTTIN/SIGTTOU(后台进程读写终端时发出)。5. Cont (Continue)
含义: 恢复执行被暂停的进程。
解释: 如果进程当前处于 Stopped 状态,该信号会使其恢复运行;如果进程正在运行,则忽略该信号。
典型信号:
SIGCONT(18, 继续)。通常用于唤醒被 Ctrl+Z 暂停的后台任务。
信号的处理方式
一个进程收到信号,有 3 种处理方式:1、默认动作 2、忽略 3、自定义动作(提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号)。
系统调用 - signal
Linux的signal系统调用是Unix/Linux信号机制最古老的接口,用于设置信号处理方式,即捕捉信号 。由于存在可移植性和语义不一致的问题,现代Linux编程强烈建议使用sigaction代替。但为了理解历史遗留代码和信号基础,依然有必要掌握它。
cpp
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
signum:要设置的信号编号(如SIGINT、SIGTERM,也可以是数字编号)。
handler:可以是:
函数指针:自定义处理函数。
SIG_IGN:忽略信号。
SIG_DFL:恢复默认行为。返回值 :返回上一次 的处理函数指针,失败返回
SIG_ERR并设置errno。作用:进程捕捉编号为 signum 的信号,注册新的处理方法 handler
注意:signal 函数只要设置一次,直到下一次重新调用 signal 函数都有效。只有进程收到对应的信号,handler函数才会被执行。为什么还会给 handler 函数传递收到的信号编号?因为可能对多个信号的处理方式都使用同一个 handler,这样便于区分。如果把所有信号都设置成打印,死循环的进程岂不是永远无法退出?操作系统不允许出现这种情况,所以并不是所有的信号都可以被 signal 函数重新设置的,只有 9 号信号 SIGKILL,19 号信号 SIGSTOP 不能被重新设置。
使用举例:修改上面的 sig 进程,使得该进程在收到 ctrl + c 时不退出而是打印
cpp
#include <iostream>
#include <csignal>
#include <unistd.h>
using namespace std;
void myhandler(int signo)
{
cout << "get a signal :" << signo << endl;
}
int main()
{
signal(SIGINT,myhandler);
while(true)
{
cout << "I am a process" << endl;
sleep(1);
}
return 0;
}
cpp
[hxh@VM-16-12-centos 2026_2_13]$ ./sig
I am a process
I am a process
^Cget a signal :2
killed
[hxh@VM-16-12-centos 2026_2_13]$
硬件中断
操作系统是怎么知道键盘、鼠标、网卡等外设有数据呢?如果操作系统采用轮询的方式时刻查看外设是否有数据,那么负担太大了。以键盘为例,解决方法是键盘可以直接向 cpu 针脚 发送中断号 (高低电平),提醒cpu:现在有事情必须立刻让内核处理。操作系统执行在中断向量表 (一个指针数组,存储的是直接访问外设的方法)下标为中断号的方法,拷贝数据到内核缓冲区。操作系统会判断数据是纯数据还是控制数据(ctrl 组合键),如果是控制数据,转换为对应的信号发送给进程。这个过程叫做硬件中断。信号就是在软件层面模拟硬件中断的过程。

异步
异步 = 你不知道它什么时候来,但你得准备好它随时会来。所有异步,源头都是物理世界的不确定性,异步不是计算机科学的一个分支,它是现实世界在计算机中的投影。
同步:
cpp
int result = read(fd, buf, size);
// 你主动去拿,拿不到就等,拿到才继续
异步:
cpp
// 你注册一个处理函数
signal(SIGIO, my_handler);
// 然后你继续做自己的事
while(1) {
play_game();
eat();
sleep();
// 数据什么时候来?不知道
// 但它来了会调用my_handler
}
物理世界是异步的:你不知道用户什么时候按键盘
硬件中断是CPU应对物理异步的机制:设立一个紧急入口
内核把硬件异步翻译成软件异步:中断 → 信号,信号属于软中断
进程通过信号注册自己的异步回调:signal(SIGINT, handler)
信号的产生
无论信号如何产生,最终都是由 OS 发送给进程的,因为 OS 是进程的管理者。下面介绍的产生信号的方法,都是命令 OS 对进程发送信号,而不是直接向进程发送信号。
通过终端按键产生信号
2号信号 SIGINT (ctrl + c)的默认处理动作是终止进程,3 号信号 SIGQUIT (ctrl + \)的默认处理动作是终止进程并且 Core Dump。
通过 kill 指令产生信号
bash
kill -信号编号 进程名
通过系统调用产生信号
系统调用 - kill
cpp
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
pid:目标进程的 ID(或进程组 ID)
sig :要发送的信号编号(如
SIGTERM、SIGKILL、SIGSTOP等)作用:向 pid 进程发送 sig 信号
返回值 :成功返回 0,失败返回 -1 并设置
errno
模拟实现 kill 指令
cpp
#include <iostream>
#include <sys/types.h>
#include <signal.h>
#include <cstdlib>
using namespace std;
void Usage(char* proc)
{
cout << "Usage:\n\t" << proc << " signum pid\n\n";
}
int main(int argc,char* argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(1);
}
int signum = stoi(argv[1]);
pid_t pid = stoi(argv[2]);
int n = kill(pid,signum);
if(n == -1)
{
perror("kill");
exit(2);
}
return 0;
}
系统调用 - raise
cpp
#include <signal.h>
int raise(int sig);
sig:要发送的信号编号
作用:进程自己给自己发送编号为 sig 的信号,相当于 kill(getpid(),sig)
返回值:成功返回 0,失败返回非 0
系统调用 - abort
abort() 是 C 标准库中最激进 的程序终止函数。它用于异常终止 当前进程,并总是产生核心转储(core dump)。
cpp
#include <stdlib.h>
void abort(void);
参数:无
作用:向调用该函数的进程发送 6 号信号
SIGABRT,进程一定终止返回值 :永不返回(如果返回,说明出了问题)
注意:只要调用了 abort 函数,无论是否捕获
SIGABRT,进程最终都会被终止,但如果使用 kill -6 向进程发送信号,只会执行自定义 handler。
通过异常产生信号
进程出现异常了,OS 明明有权力有能力直接杀掉进程,但却以向进程发送信号的方式委婉的告诉进程出问题了,让进程自行终止。目的是保护其他更重要的数据。
硬件异常产生的信号
除0错误、野指针,这些都会引发硬件层面的错误
通过下面的除0错误,验证进程确实是收到信号而终止的。
cpp
#include <iostream>
#include <csignal>
using namespace std;
void myhandler(int signo)
{
cout << "get a signal,num: " << signo << endl;
}
int main()
{
signal(SIGFPE,myhandler);
cout << "div before" << endl;
int a = 10;
a /= 0;
cout << "div after" << endl;
}
运行后发现:进程一直在打印 get a signal,num: 8,在 cpu 内部有状态寄存器,当 a /= 0;时,a 变得无穷大,状态寄存器的溢出标志位(比特位)由 0 变 1,OS 获取这一信息后向进程发送信号。OS 必须知道溢出标志位的情况,因为 OS 是硬件的管理者。上面对除 0 错误进行捕捉之后没有退出,当下一次进程再次被调度时,溢出标志位仍然是 1,OS 仍然向进程发送信号,进程仍然不退出,现象就是一直打印 get a signal,num: 8。状态寄存器属于进程的上下文信息,当一个进程出错,不会影响其他进程和 cpu 的运行。用 signal 函数捕捉这些异常的目的不是"解决该异常",而是做后续的工作比如打印日志。
访问野指针的错误: 的通过页表的 kv 结构实现从虚拟地址到物理地址虽然很快,但还不够快,有一个硬件叫MMU(内存管理单元),集成在 cpu 上,专门用于虚拟地址到物理地址的转换,访问物理地址的前提是虚拟地址到物理地址的转换是成功的,访问野指针就是虚拟地址到物理地址的转换失败,cpu 内部有寄存器表征它的失败,进而被 OS 检测到,向进程发送信号
软件条件产生的信号
异常并不单单由硬件产生,软件也可以产生。一个例子:管道的读端关闭,写端就会被 OS 通过发送 13 号信号 SIGPIPE 终止。
alarm 函数
cpp
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
seconds:请求内核在
seconds秒后向当前进程发送 14SIGALRM信号返回值:返回之前设置的闹钟剩余的秒数,如果之前没有设置闹钟则返回 0
注意:闹钟只触发一次,如需重复需重新设置。多次调用会覆盖前一个闹钟设置。有了 alarm 函数,进程可以在执行 main 函数时,每隔一定时间做一些定时任务。
基础示例
cpp
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
#include <iostream>
using namespace std;
void alarm_handler(int sig) {
printf("闹钟时间到!收到了信号: %d\n", sig);
// 可以在这里执行定时任务
}
int main() {
// 注册信号处理函数
signal(SIGALRM, alarm_handler);
printf("设置 3 秒闹钟\n");
unsigned int prev = alarm(3); // 3秒后触发
printf("之前的闹钟剩余: %u 秒\n", prev);
whlie(1)
{
cout << "proc is running" << endl;// 等待信号
}
return 0;
}
多次设置示例
cpp
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void alarm_handler(int sig) {
static int count = 0;
count++;
printf("第 %d 次闹钟触发\n", count);
// 可以在这里执行定时任务
if (count < 3) {
alarm(2); // 重新设置,实现重复定时
} else {
printf("定时任务完成\n");
}
}
int main() {
signal(SIGALRM, alarm_handler);
printf("启动定时器,每2秒触发一次,共3次\n");
alarm(2); // 首次设置
whlie(1)
{
cout << "proc is running" << endl;// 等待信号
}
return 0;
}
core dump 标志
waitpid 函数的第二个参数 int *status,第 8 个比特位是 core dump 标志,如果为 1 ,表明该进程收到了 core 类的信号。如果为 0,表明收到了 term 类的信号
在云服务器上,如果进程收到 core 类的信号,默认是不生成 core.pid 的文件的,可以通过 ulimit -a 指令查看 core.pid 文件的最大大小:
bash[hxh@VM-16-12-centos 2026_2_21]$ ulimit -a core file size (blocks, -c) 0 // ...默认为 0,表示不生成,使用 ulimit -c 最大大小重新设置。如果想要关闭,只需要重新设置为 0 即可。
bash[hxh@VM-16-12-centos 2026_2_21]$ ulimit -c 10240 // 开启 [hxh@VM-16-12-centos 2026_2_21]$ ulimit -c 10240 [hxh@VM-16-12-centos 2026_2_21]$ ulimit -c 0 // 关闭 [hxh@VM-16-12-centos 2026_2_21]$ ulimit -c 0接下来提取 status 的 core dump 标志,验证一下:
cpp#include <iostream> #include <unistd.h> #include <signal.h> #include <sys/types.h> #include <sys/wait.h> int main() { pid_t id = fork(); if(id == 0) { //child int cnt = 500; while(cnt) { cout << "i am a child process, pid: " << getpid() << "cnt: " << cnt << endl; sleep(1); cnt--; } exit(0); } // father int status = 0; pid_t rid = waitpid(id, &status, 0); if(rid == id) { cout << "child quit info, rid: " << rid << " exit code: " << ((status>>8)&0xFF) << " exit signal: " << (status&0x7F) << " core dump: " << ((status>>7)&1) << endl; } return 0; }运行后手动向进程发送任意一个 core 类的信号,发现在当前目录下生成了 core.pid 的文件。
如何使用 core.pid 文件:
cppint main() { int a = 10; int b = 0; a /= b; return 0; }编译运行上面的代码,(用 g++ 编译时记得带 -g 选项),生成 core.32273 文件。使用 gdb 调试,输入 core-file core.32273 :
bash(gdb) core-file core.32273 [New LWP 32273] Core was generated by `./mysignal'. Program terminated with signal 8, Arithmetic exception. #0 0x0000000000400663 in main () at mysignal.cpp:24 24 a /= b; Missing separate debuginfos, use: debuginfo-install glibc-2.17-326.el7_9.3.x86_64 libgcc-4.8.5-44.el7.x86_64
信号的保存
递达、未决与阻塞
实际执行信号的处理动作称为信号递达(Delivery)
信号从产生到递达之间的状态,称为信号未决(Pending)。
进程可以选择阻塞 (Block ) 某个信号。
被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,忽略是在递达后可选的处理动作。
信号在内核是如何保存的
信号如果不是被立即处理,那么信号是否需要暂时被进程记录下来?记录在哪里最合适呢?答案是记录在进程的 task_struct ,普通信号和实时信号都刚好有 31 个,这难道是巧合吗?注意到 int 是有 32 个比特位,很容易想到信号是如何用一个 int 变量表示的:最低位表示这是普通信号(0)还是实时信号(1),其余 31 位与信号编号一一对应 。所谓的"发送信号",其实是 OS 修改进程 task_struct 的保存信号的位图。用一个位图来表示信号是否被阻塞,称为 block 表 ,用一个位图来表示信号是否被递达,称为pending 表。

- 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。(SIG_DFL : 默认处理动作,SIG_IGN : 忽略,操作系统向进程发送信号,实际就是修改进程的 pending 表)。
- 在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作
- SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
- SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。 如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号。
sigset_t
从上图来看,每个信号只有一个 bit 的未决标志,非 0 即 1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型 sigset_t 来存储,sigset_t 称为信号集 (这是 OS 给用户提供的专门用来编辑内核的 blcok 表和 pending 表的类型),这个类型可以表示每个信号的"有效"或"无效"状态,在阻塞信号集 中"有效"和"无效"的含义是该信号是否被阻塞,而在未决信号集 中"有效"和"无效"的含义是该信号是否处于未决状态。下面将详细介绍信号集的各种操作。 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的"屏蔽"应该理解为阻塞而不是忽略
信号集操作函数
如果想要用sigset_t 类型修改内核的 block 表和 pending 表,必须使用相应的系统调用接口。 sigset_t 类型对于每种信号用一个bit表示"有效"或"无效"状态,至于这个类型内部如何存储这些 bit 则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作 sigset_ t 变量,而不应该对它的内部数据做任何解释(不能对它直接做位操作),比如用 printf 直接打印 sigset_t 变量或按位与或另一个值是没有意义的。
cpp
#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset (sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
- sigemptyset:使set所指向的信号集所有信号的bit清零,表示该信号集不包含有效信号。
- sigfillset:使set所指向的信号集所有信号的 bit 置1,表示该信号集的有效信号包括系统支持的所有信号。
- 注意,在使用 sigset_ t 类型的变量之前,一定要调用 sigemptyset 或 sigfillset 做初始化,使信号集处于确定的状态。初始化 sigset_t 变量之后就可以在调用 sigaddset 和 sigdelset 在该信号集中添加或删除有效信号
- sigaddset:在set所指向的信号集中添加 signo 信号
- sigdelset:在set所指向的信号集中删除 signo 信号
- sigismember:判断set所指向的信号集中是否有 signo 信号
- 前四个四个函数都是成功返回 0,出错返回-1,sigismember:若包含返回 1, 不包含返回 0, 出错返回 -1
sigprocmask
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。
cpp
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
- 如果oset(old set)是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。
- 如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。
- 如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。
假设当前的信号屏蔽字为mask,下面说明了how参数的可选值。
- SIG_BLOCK:set包含了我们希望添加到当前信号屏蔽字的信号,相当Fmask=mask|set
- SIG_UNBLOCK:set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask&~set
- SIG_SETMASK:设置当前信号屏蔽字为set所指向的值,相当于mask=set
注意:9 号信号和 19 号信号同样不能被阻塞
sigpending
cpp
#include <signal.h>
int sigpending(sigset_t *set);
//读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。
总结:
修改 handler 表:signal 函数
修改 block 表:sigprocmask 函数
修改 pending 表:kill 函数或 raise 函数
综合运用:
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
void PrintPending(sigset_t& pending)
{
for (int signo = 31; signo >= 1; signo--)
{
if (sigismember(&pending, signo))
{
cout << "1";
}
else
{
cout << "0";
}
}
cout << "\n\n";
}
int main()
{
//1. 先对2号信号进行屏蔽 --- 数据预备
sigset_t bset, oset; // 在哪里开辟的空间???用户栈上的,属于用户区
sigemptyset(&bset);
sigemptyset(&oset);
sigaddset(&bset, 2); // 我们已经把2好信号屏蔽了吗?并没有设置进入到你的进程的task_struct
// 1.2 调用系统调用,将数据设置进内核
sigprocmask(SIG_SETMASK, &bset, &oset); // 我们已经把2好信号屏蔽了吗?ok
// 2. 重复打印当前进程的pending 0000000000000000000000000
sigset_t pending;
int cnt = 0;
while (true)
{
// 2.1 获取
int n = sigpending(&pending);
if (n < 0)
continue;
// 2.2 打印
PrintPending(pending);
// 在终端按下 ctrl+c 打印 0000000000000000000000010
sleep(1);
cnt++;
// 2.3 20秒后解除阻塞
if(cnt == 20)
{
cout << "unblock 2 signo" << endl;
sigprocmask(SIG_SETMASK, &oset, nullptr); // 我们已经把2好信号屏蔽了吗?ok
// 解除阻塞之后,2 号信号被递达,进程退出
}
}
return 0;
}
bash
0000000000000000000000000000000
0000000000000000000000000000000
// 按下 ctrl+c
0000000000000000000000000000010
0000000000000000000000000000010
0000000000000000000000000000010
// 20 秒后...
unblock 2 signo
信号的处理
信号是什么时候处理的
当进程从内核态返回用户态时,进行信号的检测和处理。并不是只有调用系统调用或异常时才会进入内核,当进程的时间片耗尽、再次被 cpu 调度时,都会进入内核,也就是说,进程在运行时有非常多的机会可以进行信号的检测和处理。

如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下:
- 用户程序注册了 SIGQUIT 信号的处理函数 sighandler。
- 当前正在执行 main 函数,这时发生时钟中断、异常、调用系统调用切换到内核态。
- 在时钟中断、异常、系统调用处理完毕后要返回用户态的 main 函数之前进行信号的检测和处理,检查到有信号 SIGQUIT 递达,并且 SIGQUIT 没有被阻塞。
- 内核决定返回用户态后不是恢复 main 函数的上下文继续执行,而是执行 sighandler 函数,
- sighandler 和 main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。
- sighandler 函数返回后自动执行特殊的系统调用 sigreturn 再次进入内核态。
- 如果没有新的信号要递达,这次再返回用户态就是恢复 main 函数的上下文继续执行了。
sigaction
捕捉信号不仅可以用 signal 函数,还可以用 sigactio 函数。sigaction函数可以读取和修改与指定(普通或实时)信号相关联的处理动作。在 C 语言里,一个结构体可以与一个函数同名,但不建议这样干。
cpp
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
- signo 是指定信号的编号。
- 若 act 指针非空,则根据 act 修改该信号的处理动作。
- 若 oact 指针非空,则通过 oact 传出该信号原来的处理动作。
- 调用成功则返回0,出错则返回- 1。
sigaction结构体:
cpp
struct sigaction {
void (*sa_handler)(int);
void (*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask;
int sa_flags;
void (*sa_restorer)(void);
};
对于普通信号而言,一般只关心 void (*sa_handler)(int); 和 sigset_t sa_mask; 这两个字段,其他字段默认为 0 即可。
sa_handler:
- 赋值为常数SIG_IGN表示忽略信号,
- 赋值为常数SIG_DFL表示执行系统默认动 作,
- 赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,该函数返回 值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。
sa_mask:
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。
下面举例使用 sigaction 函数,同时研究问题:
问题1:在处理信号时,pending 表是什么时候 1->0,是在执行自定义信号处理函数之前还是之后
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cstring>
void PrintPending()
{
sigset_t set;
sigpending(&set);
for (int signo = 1; signo <= 31; signo++)
{
if (sigismember(&set, signo))
cout << "1";
else
cout << "0";
}
cout << "\n";
}
void handler(int signo)
{
PrintPending();
// 如果打印的结果中 2 号信号为 0,说明是在执行自定义信号处理函数之前 1->0 反之是之后
cout << "catch a signal, signal number : " << signo << endl;
}
int main()
{
struct sigaction act, oact;
memset(&act, 0, sizeof(act));
memset(&oact, 0, sizeof(oact));
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;
}
结论:在处理信号时,pending 表是在执行自定义信号处理函数之前 1->0
问题2:验证信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字。
cpp
// 其余代码与上面一样,只需要将 handler 改为死循环
void handler(int signo)
{
cout << "catch a signal, signal number : " << signo << endl;
while(true)
{
// 死循环处理 2 号信号,该信号一直被屏蔽
// 第一次按下 ctrl+c,pending 表 2 号信号 0->1,1->0
// 往后按下 ctrl+c,pending 表 2 号信号一直为 1
PrintPending();
sleep(1);
}
}
结论:验证成功
问题3:利用 sa_mask,在处理 2 号信号时,同时屏蔽1、3、4号信号
cpp
// 其余代码与问题 1 相同
int main()
{
struct sigaction act, oact;
memset(&act, 0, sizeof(act));
memset(&oact, 0, sizeof(oact));
act.sa_handler = handler; // SIG_IGN SIG_DFL
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, 1);
sigaddset(&act.sa_mask, 3);
sigaddset(&act.sa_mask, 4);
sigaction(2, &act, &oact);
while (true)
{
cout << "I am a process: " << getpid() << endl;
sleep(1);
}
return 0;
}
函数重入

main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因 为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到sighandler函 数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的 两步都做完之后从 sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步 之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只 有一个节点真正插入链表中了。
像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入 ,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数 ,反之, 如果一个函数只访问自己的局部变量或参数,则称为可重入函数。想一下,为什么两个不同的 控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构
volatile 关键字
下面研究 volatile 关键字在信号方面的运用
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cstring>
using namespace std;
int flag = 0;
void handler(int signo)
{
cout << "get a signal : " << signo << endl;
flag = 1;
}
int main()
{
signal(2, handler);
while (!flag);
cout << "process quit normal" << endl;
return 0;
}
执行上面的代码,在按下 ctrl + c 时,flag 由 0->1,退出循环,达到我们的预期。在 main 函数内,没有对 flag 变量做任何的修改,在优化条件下,flag 变量可能被直接优化到 cpu 的寄存器中(在程序执行时就不用在内存中读取)。gcc/g++ 默认不做优化,如果要做优化,可以带 -O1、-O2、-O3 选项,当带上这些选项时,程序运行时按 ctrl + c,进程没有退出了。
SIGCHLD信号
用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞轮询地查询是否有子进程结束等待清理。采用第一种方式,父进程阻塞就不能处理自己的工作;采用第二种方式,父进程在处理自己的工作的同时还要轮询,程序实现复杂。其实,子进程在终止时会给父进程发 17 号 SIGCHLD 信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程是否退出,父进程在信号处理函数中调用wait即可。
基于信号的异步式轮询等待
cpp
#include <iostream>
#include <cstring>
#include <ctime>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;
void handler(int signo)
{
sleep(5);
pid_t rid = waitpid(-1,nullptr,0);
cout << "I am proccess: " << getpid() << " catch a signo: " << signo << "child process quit: " << rid << endl;
}
int main()
{
signal(17, handler);
for (int i = 0; i < 10; i++)
{
pid_t id = fork();
if (id == 0)
{
while (true)
{
cout << "I am child process: " << getpid() << ", ppid: " << getppid() << endl;
sleep(5);
break;
}
cout << "child quit!!!" << endl;
exit(0);
}
sleep(1);
}
// father
while (true)
{
cout << "I am father process: " << getpid() << endl;
sleep(1);
}
return 0;
}
上面的代码只是父进程等待一个子进程,那如果有 10 个子进程同时退出呢?父进程在处理 SIGCHLD 信号时,SIGCHLD 信号被屏蔽,父进程在处理完第一个子进程的SIGCHLD 信号之后,由于其他子进程都已经退出,不再向父进程发送 SIGCHLD 信号,于是其他子进程都一直处于僵尸状态。怎么解决该问题呢,我们可以让父进程循环等待子进程,等待是非阻塞轮询。
cpp
void handler(int signo)
{
sleep(5);
pid_t rid;
while ((rid = waitpid(-1, nullptr, WNOHANG)) > 0)
{
cout << "I am proccess: " << getpid() << " catch a signo: " << signo << "child process quit: " << rid << endl;
}
}
这样,只要父进程一收到 SIGCHLD 信号,就会回收处理一批子进程,就算 10 个子进程 5 个同时退出,之后另外 5 个也同时退出,也能应付。
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程 。系统默认的忽略动作和用户用sigaction函数自定义的忽略通常是没有区别的,但这是一个特例(必须显式的指明SIGCHLD的处理动作是SIG_IGN signal(17, SIG_IGN); 才会起作用)。此方法对于Linux可用,但不保证在其它UNIX系统上都可用
cpp
int main()
{
signal(17, SIG_IGN);
for (int i = 0; i < 10; i++)
{
pid_t id = fork();
if (id == 0)
{
while (true)
{
cout << "I am child process: " << getpid() << ", ppid: " << getppid() << endl;
sleep(5);
break;
}
cout << "child quit!!!" << endl;
exit(0);
}
sleep(1);
}
// father
while (true)
{
cout << "I am father process: " << getpid() << endl;
sleep(1);
}
return 0;
}
