【Linux】信号的产生、保存、处理

目录

信号入门

生活角度的信号

信号初步总结

[使用信号的简单例子:ctrl + c](#使用信号的简单例子:ctrl + c)

信号的种类

信号的处理方式

[系统调用 - signal](#系统调用 - signal)

硬件中断

异步

信号的产生

通过终端按键产生信号

[通过 kill 指令产生信号](#通过 kill 指令产生信号)

通过系统调用产生信号

通过异常产生信号

信号的保存

递达、未决与阻塞

信号在内核是如何保存的

sigset_t

信号集操作函数

信号的处理

信号是什么时候处理的

sigaction

函数重入

[volatile 关键字](#volatile 关键字)

SIGCHLD信号


信号入门

生活角度的信号

  • 你在网上买了很多件商品,等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能识别 "快递到来" 的信号
  • 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需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 (带外数据到达)。需要注意的是,SIGKILLSIGSTOP 的默认动作不能设置为忽略。

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 :要设置的信号编号(如SIGINTSIGTERM,也可以是数字编号)。

  • 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
}
  1. 物理世界是异步的:你不知道用户什么时候按键盘

  2. 硬件中断是CPU应对物理异步的机制:设立一个紧急入口

  3. 内核把硬件异步翻译成软件异步:中断 → 信号,信号属于软中断

  4. 进程通过信号注册自己的异步回调: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 :要发送的信号编号(如 SIGTERMSIGKILLSIGSTOP 等)

  • 作用:向 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 秒后向当前进程发送 14 SIGALRM 信号

返回值:返回之前设置的闹钟剩余的秒数,如果之前没有设置闹钟则返回 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 文件:

cpp 复制代码
int 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;
}
相关推荐
Haoea!1 小时前
Docker + Harbor 私有镜像仓库搭建
运维·docker·容器
iambooo1 小时前
Docker 架构与核心原理深度解析:容器到底是怎么实现的?
运维·docker·容器
peng_YuJun2 小时前
openEuler 虚拟机从零到一:完整部署指南
linux·运维·服务器·vmware·openeuler
大志若愚YYZ2 小时前
野火嵌入式Linux——内核编程模块 (进程)
linux
红豆子不相思2 小时前
Keepalived
运维·服务器·网络
古月-一个C++方向的小白2 小时前
Linux——进程控制
linux·运维·服务器
文静小土豆3 小时前
CentOS 7 OpenSSH 10.2p1 升级全攻略(含离线安装与回退方案)
linux·运维·centos·ssh
牢七3 小时前
反序列化重点模块 private Object readOrdinaryObject(boolean unshared)废案与反思
java·服务器·前端
五阿哥永琪3 小时前
进程的调度算法
linux·运维·服务器