Linux信号相关函数

在观看本章节内容之前,建议先弄清楚Linux的内核信号的模型,链接:
https://blog.csdn.net/niceffking/article/details/157553326?fromshare=blogdetail&sharetype=blogdetail&sharerId=157553326&sharerefer=PC&sharesource=niceffking&sharefrom=from_linkhttps://blog.csdn.net/niceffking/article/details/157553326?fromshare=blogdetail&sharetype=blogdetail&sharerId=157553326&sharerefer=PC&sharesource=niceffking&sharefrom=from_link


目录

发送信号

用户通过shell给进程发送信号

通过系统调用发送信号

kill函数

killpg函数

raise函数

信号的权限

改变信号处置

signal函数

sigaction函数

阻塞信号


在了解进程的内核模型之后,再来了解如何手动让内核给进程发送数据。

发送信号

所有的手动发送信号的过程其实都需要知道你要发送信号给哪个进程,也就是知道进程的PID,这个可以通过ps指令来实现,如下:

bash 复制代码
# 方法1:经典过滤(推荐)
ps aux | grep '\[.*\]'
# 方法2:显示所有线程+内核进程
ps -efL | grep -v user
# 方法3:查看内核进程详细信息(htop需安装)
htop -k

或者你直接使用ps -aux来查看所有进程的信息,然后通过进程名来找到自己所需要的进程PID。

bash 复制代码
ps -aux

其中,PID那一列就是所需进程ID,右侧的COMMAND是启动的指令,通过路径启动的,路径就是其文件名。

通过grep 搜索进程名中包含init单词的进程:

刚好就能搜到上属进程PID为1的进程。

用户通过shell给进程发送信号

我们编写一个死循环的函数,来模拟出了问题的进程,然后让用户手动发送信号给进程,然后杀死这个进程。

编写如下程序test.c

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

int main()
{
    int count = 0;
    while(1) {
        printf("\r%d", count++);
        fflush(stdout);
        usleep(100000);
    }
    return 0;
}

然后编译为test可执行文件,并且运行。如下:

可以看到我们在输入一个**^C**之后,进程就停止了,为什么呢?用户从键盘按下ctrl + C到进程接收到信号的过程如下:

  • 终端作为硬件外设,是一种典型的字符设备,会检测ctrl+c这个按键组合,将其转化为硬件信号,具体来讲就是:
    • 按下ctrl + c之后,键盘控制器就会扫描键盘矩阵,识别出来这是一个特殊的按键组合,随后讲其转化为控制字符,对应的ascll值是0x03
    • CPU和硬件之间有一个中断请求线(IRQ),终端编码字符之后,通过特定的IRQ线向CPU发送了一个硬件中断请求
    • 当前正在执行用户程序,执行完当前用户程序的一个指令之后,就会检测是否有中断产生(通过IRQ来检测),如果有中断,就转而去内核态,由内核执行中断处理程序
    • 在中断处理程序的执行过程中,内核发现这是一个ctrl + c的指令,于是就会完成信号的生成和投递的任务。
  • 内核首先会检查要发送的指令是否是不可忽视,不可捕获的,此处ctrl+c的信号是sigint, 即可忽视也可捕获,但是假设此时进程没有捕获和忽视,此时就直接找到当前事件绑定的进程的PID并据此找到其PCB,然后拿到其未决仓库的地址并存入对应的信号,等待进程处理
  • 进程只要没有阻塞这个信号,信号来了,进程就会直接处理。

通过系统调用发送信号

kill函数

Linux中提供了一个给进程发送信号的函数kill,其定义如下:

cpp 复制代码
#include <sys/types.h>  // 必要头文件
#include <signal.h>  // 必要头文件

int kill(pid_t pid, int sig); // 函数声明

核心参数为:

  • pid: 进程的PID
  • sig:信号值

返回值为int类型的数据,函数执行成功之后返回0,失败返回-1,并且设置errno。

errno可以通过**strerror(int errno)**函数来返回对应errno的错误信息字符串。

这个pid的用法还有一些讲究:

  • pid > 0, 信号就会发送给这个pid对应的进程
  • pid = 0, 信号会发送给调用这个kill函数的进程同组的每个进程,包括调用进程本身。
  • pid = -1, 特权级调用这个函数,会发送给系统中所有的进程,除了PID=1和调用此函数的进程,显然这是一个广播信号的方式。
  • pid < -1, 向进程组ID = abs(pid)的组中的所有下属进程发送信号。

下面,我们将通过kill函数,发送sigint信号给上面的test进程,来和终端输入ctrl+c达到一样的效果:

编写send_kill.c的代码,在代码中接受来自终端的pid参数,然后调用kill并 传入该pid来将sigint信号投递给这个进程:

代码如下:

cpp 复制代码
// send_kill.c

#include <string.h>
#include <stdio.h>
#include <signal.h>
#include <sys/types.h>
#include <stdlib.h>
#include <errno.h>
int main(int argc, char* argv[])
{
    if (argc != 2) {
        printf("参数错误 usage: 可执行文件 <pid>\n");
        exit(-1); 
    }
    int pid;
    sscanf(argv[1], "%d", &pid);
    int ret = kill(pid, SIGINT);
    if (ret == -1) {
        fprintf(stderr, "%s\n", strerror(errno));
        exit(-1);
    }
    return 0;
}

编译生成send_kill可执行文件,先运行test,然后运行send_kill终结掉test:

killpg函数

killpg向某一进程组的所有成员发送信号:

cpp 复制代码
#include <signal.h>
int killpg(int pgrp, int sig);

成功返回0,失败返回-1。

调用killpg(int pgrp, int sig)相当于kill ( -pgrp ,sig)。

如果pgrp的值为0,那么就会向调用者所属的组发送信号。

raise函数

进程需要向自身发送一个信号,就需要用到raise函数。具体声明如下:

cpp 复制代码
#include <signal.h>  // 必要头文件
int raise(int sig); // 函数声明
  • sig为想要发送的信号值
  • 返回值:成功返回0,失败返回非0

在单线程程序中,调用raise就相当于调用:

cpp 复制代码
kill(getpid(), sig);

getpid()函数返回的是当前进程的PID。

在多线程情况下调用上述代码,信号会发送给调用进程的任一线程。

下面写一个函数自己给自己发送SIGINT信号的例子:

cpp 复制代码
#include <stdio.h>
#include <signal.h>
#include <sys/types.h> // 定义了SIG的一系列信号,例如SIGINT
int main()
{
    printf("raise.c开始执行....\n");
    int ret = raise(SIGINT);
    if (ret) {
        printf("raise 失败...\n");
    }
    printf("raise.c执行结束....\n");
    return 0;
}

输出:

可以看到直接就执行结束,并没有执行raise函数后面的打印函数,可见进程在raise这行就退出了。说明他给自己发送了一个ctrl+c信号。

信号的权限

一个进程并不是无法无天的,想给谁发送什么信号,就发送什么信号。例如如果一个恶意的进程,随意发送kill指令终结服务进程,那么就会导致服务器服务不可用,就无法为用户提供服务,那么这将是致命的。因此,一个进程在发送信号的时候,并不是什么信号都可以发送的,需要遵守一定的规则:

  • 特权级进程可以向任何进程发送信号
  • init进程(pid=1的进程,一般作为一些父进程被噶调的子进程的继父),如果没有手动修改init进程的默认处置函数,也就是没有将默认处置替换为自定义函数,那么init是不会接受该信号的。这是为了安全考虑,init进程是系统的基石,很重要,所以不能随意接受别的进程让内核给他发的信号。

上属两个规则是应用于特殊的进程,那么用户进程有着不同的规则:

每个进程维护了一个如上图所述的数据结构,此结构里面有三个字段,分别是,实际用户id,有效用户id,和saved set-user-ID,这三个字段是进程发送信号的核心标识:

  • 实际用户 ID:「谁启动了这个进程」,是进程的 "实际所有者",例如一个进程A启动了进程B,那么A就是B的实际所有者。
  • 有效用户 ID:「进程当前用哪个 ID 拿权限」,是判断进程能做啥的关键(比如passwd是 set-user-ID 程序,你运行它时,EUID 会变成 root,所以能修改密码)。
  • saved set-user-ID :备份 "切换前的有效用户"

拿 ping 来说,终端 shell 启动了 ping,那么 ping 初始化为:实际用户是启动 ping 的用户(和 shell 进程的用户一致,比如张三),有效用户和 saved set-user-ID(savedid)也与启动 ping 的用户一致,但是 ping 在执行过程中要用到 root 权限(比如创建原始套接字),内核会自动把有效用户切为 root(因为 ping 是 set-user-ID 程序,所有者为 root),然后执行完 root 权限级别的代码后,ping 会主动根据 saved set-user-ID(savedid)把有效用户切回启动时的原用户。

改变信号处置

signal函数

每个进程都有自己PCB,在PCB中每个进程都维护了一个task_struck的结构体,这个结构体中有一个signal_struct的子结构体,里面维护了一个类似于函数指针数组的东西,如下:

  • 数组下表 = 信号编号,例如SIGINT = 2, SIGKILL=9等。
  • 数组元素 = 该信号的处置函数的指针。

内核检查到有未决信号的时候,就会检测该信号值对应的处置,到底是默认的,还是忽略的,还是自定义的,如果是默认的,就会进入内核态,执行处置函数,如果是忽略的,那么内核还会根据该信号是否可被忽略来进行判断,如果是自定义,那么内核将会强制转为用户态并强制执行自定义函数。

内核和用户态的本质就是隔离,用户态进程只能访问自己的内存,如果在内核态执行自定义函数,将会面临非常严重的安全问题。

改变信号处置的函数:signal

其声明如下:

cpp 复制代码
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
  • signum是个信号值,也就是你要修改默认处置的信号值,可以通过在sys/types.h中获取。
  • handler:处理函数,sighandler_t类型,这个类型以及在上面定义了,是一个函数指针,返回值为void, 参数为一个int。
  • 传入的handler参数要求了一个int类型的参数,现在暂时先忽略这个参数。

返回值为旧的处置函数的函数指针,失败了就返回SIG_ERR并设置errno。

  • SIG_ERR是一个宏定义,本质是一个 "无效的信号处理函数指针",简单说 他就是伪装成失败的时候的函数指针,可以直接用返回值和SIGERR来判断(如果它两想等:返回值==SIGERR,就说明函数执行失败)。

接下来,我们调用这个函数,将SIGINT(ctrl+c)的默认处置改为打印输出一个"Hello world"。

首先编写如下程序signal_example.c:

cpp 复制代码
// 关键:必须在引入signal.h前定义这个宏,暴露sighandler_t类型
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <sys/types.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>

void new_handler(int sig) {
    printf("hello world\n");
}
int main()
{
    void (*old_handler) (int);
    old_handler = signal(SIGINT, new_handler);
    if (old_handler == (void(*)(int))SIG_ERR) {
        printf("signal函数执行出错\n");
        exit(-1);
    }
    printf("等待sigint信号\n");
    while(1);
    return 0;
}

随后我们运行然后按下ctrl+c看看结果:

可以看到按下ctrl+c之后(^c)就打印了hello world,而不是直接终止,可以得知默认处置已经被修改。

但是signal不被推荐。signal() 被 Linux 手册明确警告 "避免使用",核心是行为不可控、不可移植、不安全,具体可归纳为 4 点:

  • 跨系统 / 跨版本语义极不一致(最核心缺陷),signal() 的关键行为(信号处理后是否重置处置、是否阻塞同信号、是否重启被中断的系统调用)在不同环境下差异极大
  • 行为无法精细控制,signal() 没有任何配置参数,无法主动控制信号处理的细节(比如是否重置处置、是否阻塞同信号、是否重启系统调用),所有行为全靠系统默认,开发者无法预判和控制。
  • 多线程环境行为未定义,手册明确指出:多线程进程中使用signal()的效果是 "未指定" 的 ------ 可能出现信号不触发、处理函数执行异常甚至程序崩溃,完全无法保证稳定性。
  • 仅有限场景可移植,signal() 唯一 安全可移植 的用法,仅局限于将信号处置设为SIG_DFL(默认)或SIG_IGN(忽略);若用它绑定自定义处理函数(如你的new_handler),跨平台行为完全不可预测

因此更推荐sigaction来替代。

sigaction函数

sigaction是一种替代方案:

函数声明如下:

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

int sigaction(
    int signum, 
    const struct sigaction *act,
     struct sigaction *oldact
);
  • signum依旧信号值
  • act,**const struct sigaction ***类型,可以看到是一个const类型,那说明他才是真正的入参,也就是信号的新处理规则的结构体地址
  • oldact,看名字就知道,是旧的处置结构体指针,没有加const,说明可能被修改,那么可以得知他是返回值,传入一个struct sigaction地址来存旧值。

返回值

成功则返回0,失败返回-1,并设置errno

但是还有记点需要说明:

  1. 如果act(新传入的处置函数)不为空,系统就会按照act中写的新处置来给signum这个信号设置新处置,就如上面的给sigint设置新处置函数那样。
  2. 如果oldact不为空,那么该方法就会修改oldact指针指向的内存,返回旧的处置对应的结构体。
  3. signal函数想查某个信号的处置,就必须给他换一个新的处置,才会发回给你旧处置
  4. sigaction可以只查不改,例如传入的act为null,oldact不为空,那么就会返回旧的处置到oldact中。可以完全不修改原来的处置。

struct action本质是一个结构体,他表示为一个信号的处置方式的抽象,也就是给一个信号规定信号来了的时候,进程该做什么,处理时别被啥打断,有无特殊规矩。

接下来看看struct sigaction中都有什么:

(struct sigaction是sigaction函数的参数act和oldact的参数类型)

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);
};

可以看到里面一共有三个函数指针和两个变量:

  • sa_handler ,这个参数用来设置处置函数,处置函数分为默认忽略自定义函数指针,可以用宏定义来直接表明默认和忽略的处置方法的指针:
    • SIG_DFT:(signal default),看英文名就知道,这是选择默认处置,例如sigint的默认处置为退出进程,如果信号来了之后,进程想要忽略他,那么可以选择SIG_IGN
    • SIG_IGN: 内核程序识别到进程忽略了该信号之后,就不会把该信号扔给进程的未决仓库。
    • 自定义函数指针,也就是我们上述举例中的函数指针。
  • sa_sigaction:普通的sa_handler只知道收到了哪个信号,但是这个参数,可以让你知道是谁发的这个信号(比如如果是某个进程发起的,那么就能拿到他的PID)、信号是怎么发送的(比如是进程使用kill命令发送的,还是用户使用ctrl+c)。
  • sa_mask:处理信号的屏蔽列表。处理信号的时候,这个名单里面的信号会被挡住。比如你在使用ctrl+c的时候,不想被ctrl+\来打断,就把SIGQUIT加入到这个sa_mask中,处理sigint的时候,就会屏蔽sigquit信号,处理完了才会触发。一般直接清空,无需屏蔽,等要用到的时候再屏蔽即可。
  • sa_flags :特殊规矩的开关,可以使用|来同时打开多个,开关一般使用宏定义来选择,就例如open中的打开模式一样。举个例子:SA_NODEFER | SA_RESETHAND 表示同时开启这两个开关。sa_flags有很多选择,如下:
    • SA_NODEFER:处理信号时,不屏蔽这个信号本身 ------ 比如处理 SIGINT 时,再按 Ctrl+C 会再次触发处理函数(可能递归),默认是屏蔽的。
    • SA_RESETHAND:处理完信号后,自动把信号规则恢复成 "默认"------ 比如处理一次 Ctrl+C 后,下次按就又直接终止程序了(像 System V 的 signal ())。
    • 。。。等等还有很多,不一样列举,可以去man手册查询
  • sa_restorer:完全不用管,是历史遗留参数。是个老古董参数,现在系统一般都不用,直接忽略。

对于sa_mask参数,还有几点需要注意:

  1. sigset_t 这个类型,是在signal.h中定义的,本质是一个位图。表示bit位为1的表示被屏蔽。底层是unsigned long。
  2. 可以通过下面三个函数来修改这个sigset_t(下面的set)类型,也就免得你去手动设置了:
    1. sigemptyset(&set) :清空集合(所有位置为1)。
    2. sigaddset(&set, sig):添加一个需要被屏蔽的信号(信号值为sig)
    3. sigdelset(&set, sig): 解除屏蔽sig值的信号。

例如:

cpp 复制代码
// 配置:SIGINT的sa_mask里加SIGQUIT,sa_flags=0(默认)
act.sa_handler = sigint_handler; // 讲
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, SIGQUIT); // 加SIGQUIT到屏蔽集
act.sa_flags = 0;
sigaction(SIGINT, &act, NULL);

接下来,我们通过sigaction函数,来讲sigint的默认处置设置为打印"hello world",并且屏蔽ctrl+\(也就是sigquit信号)。

cpp 复制代码
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <signal.h>
#include <stdio.h>
#include <unistd.h>

void new_handler(int sig) {
    printf("进入new_handler, 打印等待10秒,请尝试输入ctrl + \\ \n");
    sleep(10);
    printf("hello world\n");
}

int main()
{
    struct sigaction act; // 新的处置
    struct sigaction oldact;  // 旧的处置
    // 设置新处置的信息
    act.sa_handler = new_handler; // 设置新处置的函数指针
    act.sa_flags = 0; // 0表示不设置任何特殊规则
    sigemptyset(&act.sa_mask); // 清空,避免随机值扰乱
    sigaddset(&act.sa_mask, SIGQUIT); // 屏蔽sigquit信号
    
    int ret = sigaction(SIGINT, &act, NULL);  // 修改默认处置
    if (ret == -1) {
        // 失败
        fprintf(stderr, "sigactoin 执行失败:%s", strerror(errno));
        exit(-1);
    }
    while (1) { // 模拟持续运行
        sleep(1);
    }
    return 0;
}

可以看到在这个期间,我们屏蔽了ctrl+\ , 输入多次他都没有生效。

我们继续修改这个代码,来看看flag的作用,我们现在这个程序,多次输入SIGINT信号的结果如下:

可以看到这些信号其实屏蔽了本身,输入多次只会执行一个,先我们将程序的act的sa_flags添加SA_NODEFER,如下:

cpp 复制代码
act.sa_flags = SA_NODEFER;

继续运行,然后测试,结果如下:

可以看到,sigint信号没有屏蔽本身。

阻塞信号

我们上面提到了一个struct sigaction结构体,里面有一个变量叫做sa_mask,这个变量本质是一个"位图",每一位都代表着一个信号是否被屏蔽。换言之他也是一种阻塞,只不过这种阻塞一定发生在信号正在处理的时候。

比如我在信号sigint的act中的sa_mask中添加了sigquit信号,就说明在执行sigint处理函数的时候,是会阻塞sigquit信号的。

这种阻塞是短暂的,还有一种全局阻塞,即sigprocmask函数。

cpp 复制代码
#include <signal.h>
/* Prototype for the glibc wrapper function */
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
  • how:阻塞 / 解除的规则:
    • SIG_BLOCK:把set里的信号加入 "进程屏蔽集"(阻塞这些信号);
    • SIG_UNBLOCK:把set里的信号从屏蔽集移除(解除阻塞);
    • SIG_SETMASK:直接把屏蔽集设为set(覆盖原有规则);
  • set:要操作的信号集(比如包含 SIGINT);
  • oldset:保存原来的屏蔽集(可选,传 NULL 则不保存)。

我们还是用sigaction的代码,来演示全局阻塞SIGQUIT信号:

cpp 复制代码
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <signal.h>
#include <stdio.h>
#include <unistd.h>

void new_handler(int sig) {
    printf("hello world\n");
}

int main()
{
    struct sigaction act; // 新的处置
    struct sigaction oldact;  // 旧的处置
    // 设置新处置的信息
    act.sa_handler = new_handler; // 设置新处置的函数指针
    act.sa_flags = 0; // 0表示不设置任何特殊规则
    sigemptyset(&act.sa_mask); // 清空,避免随机值扰乱
    sigaddset(&act.sa_mask, SIGQUIT);
    // 关键...  添加全局阻塞,阻塞SIGQUIT
    sigprocmask(SIG_BLOCK, &act.sa_mask, NULL);
    // 关键...

    act.sa_flags = SA_NODEFER;    
    int ret = sigaction(SIGINT, &act, NULL);  // 修改默认处置
    if (ret == -1) {
        // 失败
        fprintf(stderr, "sigactoin 执行失败:%s", strerror(errno));
        exit(-1);
    }
    while (1) { // 模拟持续运行
        sleep(1);
    }
    return 0;
}

可以看到就算没有按下ctrl + c,直接输入ctrl + \也不会退出。

解除全局屏蔽的代码也不再啰嗦,照猫画虎即可。

相关推荐
蚰蜒螟1 小时前
Linux 7 中的系统调用原理
linux·运维·服务器
AC赳赳老秦1 小时前
DeepSeek一体机部署:中小企业本地化算力成本控制方案
服务器·数据库·人工智能·zookeeper·时序数据库·terraform·deepseek
Reuuse1 小时前
【linux】进程间通信
linux·运维·服务器
code monkey.1 小时前
【Linux之旅】Linux 动静态库与 ELF 加载全解析:从制作到底层原理
linux·服务器·c++·动静态库
Pluto_CSND2 小时前
CentOS系统中创建定时器
linux·运维·centos
好好沉淀2 小时前
Docker 部署 Kibana:查 ES 版本 + 版本匹配 + 中文界面
linux·docker
!chen2 小时前
PLG log server note
运维·jenkins
Honmaple2 小时前
OpenClaw 远程访问配置指南:SSH 隧道与免密登录
运维·ssh