Linux 信号(二):信号的产生

在上一节中,我们已经了解了信号的基本概念,以及信号作为一种异步事件通知机制的本质。但仅仅知道"什么是信号"是不够的,我们必须理解:

信号是如何产生的。

1 信号的产生

1.1 通过终端按键产生信号

通过终端按键只能向前台进程发送信号,不能向后台进程发送信号。

通过 Ctrl + C 按键产生 SIGINT(2) 信号,默认处理动作是终止进程。

通过 Ctrl + \ 按键产生 SIGQUIT(3) 信号,默认处理动作是终止进程并生成 core dump 文件,core dump 文件主要用于进程程序崩溃的事后调试。(后面详细演示)

通过 Ctrl + Z 按键产生 SIGTSTP(20) 信号,默认处理动作是暂停进程,使其成为后台进程。

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>

void sighander(int signum)
{
    std::cout << "我是: " << getpid() << ", 我获得了一个信号: " << signum <<std::endl;
}

int main()
{
    signal(SIGINT, sighander);
    signal(SIGQUIT, sighander);
    signal(SIGTSTP, sighander);

    std::cout << "我是进程: " << getpid() << std::endl;
    while(1)
    {
        std::cout << "I am process, I am waiting signal!" << std::endl;
        sleep(1);
    }
    return 0;
}

1.2 通过系统调用产生信号

除了通过终端按键由内核自动产生信号之外,进程还可以通过系统调用主动请求内核发送信号

也就是说:信号不仅可以由外部事件触发,也可以由进程本身或其他进程显示触发。

1.2.1 kill 系统调用

cpp 复制代码
#include <signal.h>
#include <sys/types.h>

int kill(pid_t pid, int sig);

功能:向指定进程发送指定信号。

参数说明:

pid:目标进程 pid

sig:要发送的信号编号或宏
注意:kill 不等于 杀死进程,它的本质是向目标进程发送信号,而不是直接终止进程。进程是否终止取决于信号的处理方式。

下面实现一个简单的命令行程序,可以向指定进程发送任意信号:

cpp 复制代码
#include <iostream>
#include <signal.h>
#include <string>
#include <unistd.h>

int main(int argc, char* argv[])
{
    if(argc != 3)
    {
        std::cerr << "Usage: " << argv[0] << " -signumber pid" << std::endl;
        return 1;
    }

    int signum = std::stoi(argv[1] + 1); // 去掉-​
    pid_t pid = std::stoi(argv[2]);
    
    kill(pid, signum);

    return 0;
}

1.2.2 raise 系统调用

cpp 复制代码
#include <signal.h>

int raise(int sig);

功能:进程给自身发送信号。

raise(SIGINT); == kill(getpid(), SIGINT);

样例:

cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>

void handler(int signumber)
{
    std::cout << "获取了一个信号: " << signum << std::endl;
}

int main()
{
    signal(2, handler); // 先对2号信号进行捕捉​
    // 每隔1S,自己给自己发送2号信号​
    while (true)
    {
        sleep(1);
        raise(2);
    }
}

1.2.3 abort 函数

cpp 复制代码
#include <stdlib.h>

void abort(void);

功能:进程给自身发送 SIGABRT(6) 信号,默认行为是终止进程并生成 core dump 文件。

样例:

cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <cstdlib>

void handler(int signumber)
{
    std::cout << "获取了一个信号: " << signumber << std::endl;
}

int main()
{
    signal(SIGABRT, handler);
    while (true)
    {
        sleep(1);
        abort();
    }
}

SIGABRT(6) 特点:可以被自定义捕捉,但是进程依旧会终止。

1.3 通过硬件异常触发的信号

在 Linux 中,当进程在执行过程中发生某些硬件层面的异常时,CPU 会首先检测到错误,并通过异常机制通知内核。随后,内核会将该异常转换为对应的信号,发送给目标进程。

这一类信号的特点是:

由 CPU 硬件检测异常 -> 内核处理 -> 转换为信号 -> 发送给进程

1.3.1 SIGSEGV(11) (段错误)

当进程访问了非法的内存地址时,MMU检测到非法访问而产生异常,内核将这个异常转换为 SIGSEGV 信号发送给进程。
常见原因:

访问空指针

访问未分配的内存

越界访问数组

写只读内存

样例:访问空指针

cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>

void handler(int sig)
{
    std::cout << "收到了一个" << sig << "号信号" << std::endl;
}
int main()
{
    signal(SIGSEGV, handler);
    
    sleep(1);
    int *p = NULL;
    *p = 100;
    while (true) ;
    return 0;
}

1.3.2 SIGFPE(8)(算术异常)

当进程执行非法算术操作时,CPU 检测到非法运算触发异常而产生异常,内核将这个异常转换为 SIGFPE 信号发送给进程。
常见原因:

整数除以0

非法算术操作

浮点异常

样例:模拟除0

cpp 复制代码
void handler(int sig)
{
    std::cout << "收到了一个" << sig << "号信号" << std::endl;
}

int main()
{
    signal(SIGFPE, handler);
    sleep(1);
    int a = 10;
    a /= 0;

    while (true);
    return 0;
}

1.4 通过软件条件产生信号

在 Linux 中通过软件条件产生信号有很多种,例如:SIGPIPE、SIGCHLD、SIGALRM。本节主要介绍 SIGALRM(14) 信号和 alarm 函数。

1.4.1 SIGALRM 的基本概念

SIGALRM 是 Linux 提供的一种定时器到期通知信号。当进程调用定时器接口 alarm 后,内核会记录倒计时,当时间到达时内核会向进程发送 SIGALRM 信号。

SIGALRM 信号的默认处理是终止当前进程。

1.4.2 alarm 系统调用

cpp 复制代码
#include <unistd.h>

unsigned int alarm(unsigned int seconds);

功能:设置一个倒计时定时器,当时间到达后向当前进程发送 SIGALRM 信号。

返回值:上一个设置倒计时的剩余秒数,如果没有上一个倒计时返回 0。

样例1:体会 IO 效率问题
cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>

int main()
{
    int cnt = 0;
    alarm(1);
    while(1)
    {
        std::cout << "cnt: " << cnt << std::endl;
        ++cnt;
    }
    
    return 0;
}
cpp 复制代码
cnt: 20458Alarm clock
cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>

int count = 0;
void handler(int signum)
{
    std::cout << "count : " << count << std::endl;
    exit(0);
}
int main()
{
    signal(SIGALRM, handler);
    alarm(1);
    while (true)
    {
        count++;
    }
    return 0;
}
cpp 复制代码
count : 578003512

结论:倒计时结束后,进程会收到 SIGALRM 信号,默认处理是终止进程

有 IO 的效率远低于无 IO 的效率

样例2:基于 SIGALRM 的周期性任务调度器
cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <functional>
#include <vector>

using func_t = std::function<void()>;
std::vector<func_t> gfuncs;

void handler(int signum)
{
    // 进程收到一次信号,轮询执行一次方法
    for(auto &f : gfuncs)
    {
        f();
    }
    // 重复设置闹钟
    int n = alarm(1);
    std::cout << "剩余时间 : " << n << std::endl;
}

int main()
{
    // 在 gfuncs 中注册很多方法

    signal(SIGALRM, handler);
    alarm(1);

    while (true)
    {
        pause();
    }
    return 0;
}

代码解释:

  1. signal(SIGALRM, handler);

注册信号处理函数,SIGALRM 到来时,不执行默认终止行为,执行 handler()

  1. alarm(1);

启动一个 1 秒倒计时定时器,1 秒后内核向当前进程发送 SIGALRM 信号

  1. pause();

让进程挂起,直到收到信号,如果不这样做,会浪费 CPU 资源

  1. handler()

for(auto &f : gfuncs) f(); 本质:用 SIGALRM 触发"任务轮询执行"

alarm(1); 重新设置定时器,实现周期性 SIGALRM

1.4.3 理解软件条件

在操作系统中,信号的软件条件指的是由软件内部状态或特定软件操作触发的信号产生机制。这些条件包括但不限于定时器超时(如alarm函数设定的时间到达)、软件异常(如向已关闭的管道写数据产生的SIGPIPE信号)等。当这些软件条件满足时,操作系统会向相关进程发送相应的信号,以通知进程进行相应的处理。简而言之,软件条件是因操作系统内部或外部软件操作而触发的信号产生

1.5 core dump

当一个进程要异常终止时,操作系统把进程的用户空间内存数据全部保存到磁盘上,文件名通常是 core,这被称作 core dump。

进程异常终止通常是因为有 bug ,比如非法内存访问导致段错误,事后可以用 gdb 调试器检查 core 文件来查清错误原因,这被称作 post-morem debug (事后调试)。

core 文件的大小取决于进程 Resource Limit (这个信息保存在进程 PCB 中)。在现代操作系统中默认是不允许产生 core 文件的,因为 core 文件中可能包含用户密码等敏感信息,不安全。

在开发调试阶段可以用 ulimit 命令改变这个限制,允许产生 core 文件。

样例过程:

ulimit -a 查看 core file size

ulimit -c 设置 core file size

cpp 复制代码
#include <iostream>
#include <unistd.h>

int main()
{
    while(true)
    {
        std::cout << "我是一个进程,我的pid: " << getpid() << std::endl;
        sleep(1);
    }
    return 0;
}

SIGINT 信号默认处理是 Term ,SIGQUIT 信号默认处理是 Core,进程终止之前,进行核心转储。

core 文件的使用方法

注意:编译器编译时一定要带 -g 选项,确保编译出来的可执行程序能被 gdb 调试

cpp 复制代码
#include <iostream>

int main()
{
    int *p = NULL;
    *p = 100;

    return 0;
}

1.6 子进程的退出码

cpp 复制代码
#include <iostream>
#include <string>
#include <unistd.h>
#include <cstdlib>
#include <sys/wait.h>

int main()
{
    if (fork() == 0)
    {
        sleep(1);
        int a = 10;
        a /= 0;
        exit(0);
    }
    int status = 0;
    waitpid(-1, &status, 0);
    printf("exit signal: %d, core dump: %d\n", status & 0x7F, (status >> 7) & 1);
    return 0;
}
cpp 复制代码
exit signal: 8, core dump: 1

core dump:core 文件大小存在时为1,不存在时为0,即使该信号的默认处理是 Core 也是 0。