【Linux】信号

目录

一、概念

二、信号的产生

(一)相关指令

1、kill指令

(二)相关函数

[1、kill() 函数](#1、kill() 函数)

[2、raise() 函数](#2、raise() 函数)

[3、abort() 函数](#3、abort() 函数)

(三)硬件异常产生信号

1、除零异常产生信号SIGFPE

2、段错误产生信号SIGSEGV

(四)软件异常产生信号

1、管道异常信号SIGPIPE

2、定时器信号SIGALRM

三、信号的保存

(一)概念

(二)在内核中的表示

四、信号的捕捉

[(一)signal() 函数](#(一)signal() 函数)

[(二)sigaction() 函数](#(二)sigaction() 函数)

五、信号的处理

(一)用户态与内核态

1、概念

2、进程切换至内核态后如何找到对应的内核代码

(二)信号的捕捉流程

(三)相关函数

[1、sigprocmask() 函数](#1、sigprocmask() 函数)

[2、sigpending() 函数](#2、sigpending() 函数)

3、代码示例

六、有关信号的其他概念

(一)核心转储

(二)可重入函数

(三)volatile关键字

(四)SIGCHLD信号


一、概念

信号是进程之间事件异步通知的一种方式,属于软中断

在Linux中信号一共有62个,其中31个为普通信号,另31个为实时信号,没有32,33号信号。

bash 复制代码
kill -l    //查看Linux中的信号列表

信号可以随时产生,进程在接收到信号后在合适的时机处理该信号,因此进程对信号必须拥有保存能力,进程在处理信号时一般有三种动作:默认,自定义和忽略。

bash 复制代码
man 7 signal    //Linux中的信号含义

其实大部分的信号都是终止信号,即终止进程的运行,但是其代表着不同的事件,但从最后效果来看都是终止了进程的运行。

本文从信号的产生至信号的处理对Linux中的信号进行阐述:

二、信号的产生

(一)相关指令

1、kill指令

bash 复制代码
kill [信号种类] [进程PID]    //向指定进程发送信号

上文中我们提到了 kill -l 指令可以查看Linux中所有信号种类,同样我们也可以使用 kill 指令向指定进程发送信号。

(二)相关函数

1、kill() 函数

cpp 复制代码
NAME
       kill - send a signal to a process or a group of processes
SYNOPSIS
       #include <signal.h>
       int kill(pid_t pid, int sig);
RETURN VALUE
       Upon successful completion, 0 shall be returned. Otherwise, -1 shall be returned and errno set to indicate the error.

该函数向指定进程发送 kill 信号,与上文中的 kill 指令区别不大,我们可以利用该函数模拟 kill 指令。

cpp 复制代码
#include <iostream>
#include <signal.h>
#include <string.h>
using namespace std;
void Usage(const string &s)
{
    cout << "Usage:\n"
         << s << " signo " << "PID" << endl;
}
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(1);
    }
    //从命令函获取参数
    int signo = atoi(argv[1]);
    pid_t pid = atoi(argv[2]);
    //指令kill指令
    int ret = kill(pid, signo);
    if (ret == -1)
        cerr << strerror(errno) << endl;
    return 0;
}

上述代码涉及到命令行参数,详见【Linux】环境变量_linux读取环境变量-CSDN博客

2、raise() 函数

cpp 复制代码
NAME
       raise - send a signal to the caller
SYNOPSIS
       #include <signal.h>
       int raise(int sig);
RETURN VALUE
       raise() returns 0 on success, and nonzero for failure.

给调用者发送信号,即给进程本身发送信号。下面是实现五秒后进程将自动退出的例子。

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <string.h>
using namespace std;
//倒计时
void Count(int count)
{
    while (count)
    {
        cout << count-- << endl;
        sleep(1);
    }
}
int main()
{
    Count(5);
    //发送指令
    int ret = raise(SIGQUIT);
    if (ret != 0)
        cerr << strerror(errno) << endl;
    return 0;
}

3、abort() 函数

cpp 复制代码
NAME
       abort - cause abnormal process termination
SYNOPSIS
       #include <stdlib.h>
       void abort(void);
RETURN VALUE
       The abort() function never returns.

给进程本身发送六号信号,将上个例子中的函数替换并去除检验返回值即可得到以下效果。

(三)硬件异常产生信号

硬件异常指非人为调用系统接口等行为,因软件问题造成的硬件发生异常。操作系统通过获知对应硬件的状态,即可向对应进程发送指定信号。

1、除零异常产生信号SIGFPE

出现除0错误,操作系统将会发送8号信号SIGFPE。

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <string.h>
using namespace std;
//定义自定义动作
void handler(int signo)
{
    cout << "信号被触发:" << signo << endl;
    sleep(1);
}
int main()
{
    //为信号8自定义动作
    signal(SIGFPE, handler);
    int a = 1;
    int b = a / 0;
    while (1)
        ;
    return 0;
}

上述代码中,signal() 为信号定义自定义动作在下文中会讲到因为进程触发了除零异常(硬件检测),操作系统向进程发送信号 SIGFPE ,进程在收到信号后执行以上自定义动作。因为进程并没有终止,该进程会因为CPU调度而导致其上下文被反复读取保存,而每一次保存运行都会再次触发除零异常,因此操作系统会不停地给进程发送信号 SIGFPE。

在CPU中的状态寄存器用于存储进程运行的状态。当硬件检测到除数为0时,操作系统会发送除零异常信号同时也会将状态寄存器中的溢出位修改为1,因为CPU调度该状态寄存器同时也会被反复读取保存。当状态寄存器中溢出位为1也会触发SIGFPE信号。

2、段错误产生信号SIGSEGV

出现段错误例如数据越界访问或空指针解引用等,操作系统将会发送11号信号SIGSEGV。

该错误在学习C++时可能会经常出现,这里就不进行赘述了,以下主要讲下发生段错误时系统是如何检测到的。

在进程运行过程中,虚拟地址到物理地址的转换实际是由CPU中的MMU(内存管理单元)与页表共同完成的。

当CPU开始执行代码,当执行到指针解引用时即访问目标内容时,CPU需要访问物理地址的内容,由MMU去页表中查找该虚拟地址对应的物理地址,当找到时会将该虚拟地址转换为物理地址,CPU则根据该物理地址去访问物理内存。

当发生空指针解引用时,MMU同样按照该虚拟地址去页表进行查找,经查找后发现该虚拟地址为非法地址,因此MMU并不能找到对应的物理地址,因此会触发段错误由操作系统将该信号发送给运行进程。

(四)软件异常产生信号

1、管道异常信号SIGPIPE

例如当匿名管道的读端关闭后,操作系统会向写端发送13号信号SIGPIPE终止写端。

详见【Linux】进程间通信-CSDN博客

2、定时器信号SIGALRM

cpp 复制代码
NAME
       alarm - schedule an alarm signal
SYNOPSIS
       #include <unistd.h>
       unsigned alarm(unsigned seconds);
RETURN VALUE
       If there is a previous alarm() request with time remaining, alarm() shall return a non-zero value that is the number of seconds until the previous request would have generated a SIGALRM signal. Oth‐
       erwise, alarm() shall return 0.

alarm函数的作用是将在设定的时间到来时,向本进程发送14号信号终止进程。该函数返回值为剩余的秒数,可以使用 alarm(0) 取消之前设定的闹钟。

下面是示例代码:

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void Count(int count)
{
    while (count)
    {
        cout << count-- << endl;
        sleep(1);
    }
}
int main()
{
    // 五秒后终止本进程
    alarm(5);
    // 记时五秒
    Count(5);
    return 0;
}

三、信号的保存

(一)概念

首先我们得先了解下以下概念

1、信号递达:实际执行信号对应的动作;

2、信号未决:信号从产生到递达之间的状态;

进程是可以阻塞 某些信号的,当信号被阻塞时将保持信号未决状态,直到进程解决对此信号的阻塞,才可以执行信号对应的作用。阻塞和忽略不同,阻塞会使得信号不会递达,而忽略则是信号递达对应的动作(默认、自定义和忽略)

(二)在内核中的表示

在进程PCB中存在着两个变量 pending 和 block 以及一个函数指针数组 handler,pending 变量用于保存递达的信号,block 变量用于保存阻塞的信号,两个变量都采用位图的思想:每个比特位都代表着一个信号,而 函数指针数组 handler 则保存的是每个信号对应的递达动作。

当进程触发信号后,操作系统会直接修改进程的 pending 变量以达到发送信号的目的,进程首先会依次检查 block 位图,只要该位置数据为0才会去查看对应的 pending 位图中的数据,若对应的 pending 位图中的数据为1则通过函数指针数组 handler 去执行对应的递达动作;若 block 位图的数据为1则不会查看对应的 pending 位图并继续查找位图其他位置信息。

四、信号的捕捉

(一)signal() 函数

cpp 复制代码
NAME
       signal - ANSI C signal handling
SYNOPSIS
       #include <signal.h>
       typedef void (*sighandler_t)(int);
       sighandler_t signal(int signum, sighandler_t handler);
RETURN VALUE
       signal() returns the previous value of the signal handler
 or SIG_ERR on error.  In the event of an error, errno is set to 
indicate the cause.

该函数为指定信号设置自定义函数。其中 signum 为指定信号,而 handler 为自定义函数的函数地址,该函数成功时会返回自定义之前的递达动作函数,失败时返回 SIG_ERR 同时设置错误码。

cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
void sigcb(int signo)
{
    // 自定义递达动作
    cout << "信号自定义动作被触发:" << signo << endl;
}
int main()
{
    // 设置SIGINT信号的递达动作
    signal(SIGINT, sigcb);
    // 进程持续进行
    while (1)
    {
        // 输出进程PID供外部指令使用
        std::cout << getpid() << std::endl;
        sleep(1);
    }
    return 0;
}

SIGINT信号可以在进程运行时,敲击 ctrl + c 触发信号,也可以使用 kill 命令向进程发送信号。可以发送9号信号终止进程,系统不允许用户自定义9号信号的递达动作(自定义不会生效)。

bash 复制代码
kill -9 PID    //杀死目标进程

**(二)**sigaction() 函数

cpp 复制代码
NAME
       sigaction - examine and change a signal action
SYNOPSIS
       #include <signal.h>
       int sigaction(int signum, const struct sigaction *act,
                     struct sigaction *oldact);
struct sigaction {
   void (*sa_handler)(int);//回调方法
   void (*sa_sigaction)(int, siginfo_t *, void *);
   sigset_t sa_mask;//阻塞信号集
   int sa_flags;
   void (*sa_restorer)(void);//用于支持旧版本的sigaction函数的信号处理函数地址,一般不使用。
};
RETURN VALUE
       sigaction() returns 0 on success; on error, 
-1 is returned, and errno is set to indicate the error.

该函数与 signal() 类同,但sigaction功能更加丰富点。signum 为指定信号,act 和 oldact 分别作为输入型参数和输出型参数,当函数运行成功时返回0,失败时返回-1并设置退出码。

cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
void sigcb(int signo)
{
    // 自定义递达动作
    while (1)
    {
        cout << "信号自定义动作被触发:" << signo << endl;
        sleep(3);
    }
}
int main()
{
    // sigaction函数操作
    struct sigaction iact; // 用于输入
    struct sigaction oact; // 用于输出
    // 设置信号对应的递达动作
    iact.sa_handler = sigcb;
    // 初始化阻塞信号集
    sigemptyset(&iact.sa_mask);
    // 设置阻塞信号集 SQGQUIT为3号信号
    sigaddset(&iact.sa_mask, SIGQUIT);
    // SIGINT为2号信号
    sigaction(SIGINT, &iact, &oact);
    while (1)
    {
        // 输出进程PID供外部指令使用
        std::cout << getpid() << std::endl;
        sleep(5);
    }
    return 0;
}

上述代码在设置了信号2对应的递达动作,同时也设置了阻塞信号集。在代码运行当中,iact中的 sa_flags 会默认为0,即在执行信号2对应的抵递达动作时会屏蔽阻塞信号集中的3号信号直到信号2对应的递达动作执行完成后再执行3号信号的递达动作。

以上代码中2号信号的递达动作会一直循环,因此会一直屏蔽3号信号。

五、信号的处理

(一)用户态与内核态

1、概念

在开始信号的捕捉之前,我们先得知道什么是用户态,什么是内核态。

用户态是操作系统中的一种模式,当程序在执行用户级别的任务时,CPU就处于用户态。在这种模式下,程序只能访问有限的资源,并且不能直接访问硬件设备。

内核态是操作系统中的一种模式,当操作系统内核在执行任务时,CPU就处于内核态。在这种模式下,程序拥有最高的权限,可以直接访问所有的硬件资源和内存。

用户态和内核态实际时进程运行时的两种状态,在CPU中存在一个CS寄存器,该寄存器的CPL值用于表示进程运行所处的状态,寄存器为0时表示内核态,为3时表示用户态。进程处于用户态需要访问系统资源或者硬件时都需要调用通过系统调用陷入内核态后执行内核代码。

2、进程切换至内核态后如何找到对应的内核代码

在32位系统下,每个进程的虚拟地址空间一般都有一块大小为1G大小的内核空间,进程在执行过程中可通过内核级页表找到对应的内核代码并执行。

由于内核级页表和物理内存中的内核级代码的映射关系是一样的,因此每个进程都可以通过同一张的内核级页表进行映射。

用户态切换至内核态是通过系统调用或中断触发,CPU 会自动切换到内核态。在这过程中需要修改CS寄存器中的CPL字段使得进程状态由用户态切换至内核态(陷入内核),由进程地址空间的内核空间通过内核级页表中找到物理内存中的内核代码进行执行,执行完毕后再通过系统调用将控制权返回给用户进程。

(二)信号的捕捉流程

进程在收到信号后并不会对信号进行立即处理,而是进程从内核态返回用户态的时候对信号进行处理。

当进程执行触发中断、异常或系统调用时,进程会被切换至内核态执行对应的内核代码,但执行完毕后并不会直接返回用户态,执行完毕后操作系统会通过进程PCB去检查是否接收到了信号。

首先操作系统会检查block位图,根据block位图中的值再决定是否去查看对应pending位图中的值,若某一信号在block位图中显示并没有被阻塞且pending位图中该信号为1,则去执行对应的递达动作。

若这里我们假设我们自定义了递达动作,因为进程此时处于内核态,若自定义函数中存在一些非法操作会对我们的系统造成影响,因此在执行自定义函数时需切换至用户态执行。在执行完毕后需通过系统调用先返回内核态再返回用户态继续执行代码。

(三)相关函数

未决和阻塞标志都使用相同的数据类型sigset_t来存储,sigset_t称为信号集。这个类型可以表示每个信号的"有效"或"无效"状态。 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的"屏蔽"应该理解为阻塞而不是忽略。

cpp 复制代码
#include <signal.h>
//函数 sigemptyset 初始化set所指向的信号集,所有 bit 位置0
int sigemptyset(sigset_t *set);
函数 sigemptyset 初始化set所指向的信号集,所有 bit 位置1 
int sigfillset(sigset_t *set);
//将指定的信号 signo 添加到信号集 set 中
int sigaddset (sigset_t *set, int signo);
//从信号集 set 中删除指定的信号 signo
int sigdelset(sigset_t *set, int signo);
//检查信号集 set 中是否包含信号 signo
int sigismember(const sigset_t *set, int signo);

1、sigprocmask() 函数

cpp 复制代码
NAME
       sigprocmask - examine and change blocked signals
SYNOPSIS
       #include <signal.h>
       int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
RETURN VALUE
       sigprocmask() returns 0 on success and -1 on error.  In the event of 
an error, errno is set to indicate the cause.

how 参数代表如何屏蔽信号集,而 set 参数为传入的信号集, oldset 为读出(出入前)的信号集。 获取当前进程的信号集,若成功返回0,失败返回-1并设置信号集。

如果oset是非空指针,则读取进程的当前信号屏蔽字并通过oset参数传出。

如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。

how参数:

|-------------|-----------------------------------------------|
| SIG_BLOCK | set包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask | set |
| SIG_UNBLOCK | set包含了我们希望从当前信号屏蔽字解除阻塞的信号,相当于mask=mask&~set |
| SIG_SETMASK | 设置当前信号屏蔽字为set所指向的值,相当于mask=set |

2、sigpending() 函数

cpp 复制代码
NAME
       sigpending - examine pending signals
SYNOPSIS
       #include <signal.h>
       int sigpending(sigset_t *set);
RETURN VALUE
       sigpending() returns 0 on success and -1 on error.  In the event of 
an error, errno is set to indicate the cause.

获取当前进程的信号集,若成功返回0,失败返回-1并设置信号集。

3、代码示例

cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
void show_pending(const sigset_t &pending)
{
    for (int signo = 31; signo >= 1; signo--)
    {
        if (sigismember(&pending, signo))
        {
            cout << "1";
        }
        else
            cout << "0";
    }
    cout << "\n";
}
int main()
{
    sigset_t isig, osig, pending;
    // 初始化输入信号集
    sigemptyset(&isig);
    // 输入信号集添加2号信号
    sigaddset(&isig, SIGINT);
    // 屏蔽二号信号
    sigprocmask(SIG_SETMASK, &isig, &osig);
    int cnt = 5;
    while (1)
    {
        // 获取pending位图
        sigpending(&pending);
        // 打印pend位图
        show_pending(pending);
        sleep(1);
        --cnt;
        // 5秒后解除阻塞
        if (cnt == 0)
        {
            // 解除阻塞
            cout << "解除对信号的屏蔽" << endl;
            sigprocmask(SIG_SETMASK, &osig, &isig);
        }
    }
    return 0;
}

六、有关信号的其他概念

(一)核心转储

虽然大部分的信号都会将终止进程的运行,但是终止后的处理也是不相同的。

在 Action 列, 我们可以看到有三种类型,Term、Core 和 Ign。其中 Term 为普通终止,Ign为忽略,而Core代表在中之前会进行核心转储。

核心转储:当进程出现异常时,将进程异常前的有效数据由内存存储至磁盘。核心转储有利于我们后续调试。

云服务器默认关闭了核心转储。在终端输入ulimit -a显示操作系统各项资源上限;使用ulimit -c 1000允许操作系统最大设置1000个block大小的数据块。

(二)可重入函数

假如主函数调用 insert 函数向一个不带头链表head中插入节点P1,执行完第一句代码后触发信号进程切换到内核,执行handler函数新增一个节点插入至链表中,此时 head 指针指向 p2,执行完自定义 handler 函数最终返回用户态继续执行代码,将 head 指针指向 p1。结果 insert函数和 handler 函数先后向链表中插入两个节点,而最后只有 p1 真正插入链表中,p2 节点的位置丢失了,发生内存泄漏。

**像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入。**insert函数访问一个全局链表,有可能因为重入而造成错乱。像这样的函数称为不可重入函数,反之, 如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。

一般只要申请了系统资源、访问全局变量或者IO库函数的函数,都为不可重入函数。

(三)volatile关键字

对于同一份代码的执行效果却不同,这是编译器进行优化的结果。

O3优化时:编译器认为q在main执行流中没有被修改,所以编译器对q做了优化,直接将q放在了寄存器中,这样后续执行时就不用再去内存中读取q了,提高了程序运行效率。虽然handler中修改了内存中的q并没有寄存器中的 q 值,因此会发生上图效果。

而当给变量 q 添加 volatile 关键字后便可以解决上述问题,该关键字是确保变量在程序运行中的一致性。

cpp 复制代码
#include <iostream>
#include <signal.h>
using namespace std;
volatile int q = 1;
void handler(int signo)
{
    q = 0;
}
int main()
{
    //定义2号信号的递达动作
    signal(2, handler);
    while (q)
        ;
    return 0;
}

(四)SIGCHLD信号

在父子进程中,子进程退出时会向父进程发送17号信号SIGCHLD。

当子进程退出需要父进程来进行资源和状态回收,若父进程持续运行没有进行回来,子进程便会进程僵尸状态。详见:【Linux】Linux操作系统------进程-CSDN博客

对于以上情况,可以通过父进程调用 sigation函数(本节四(二)) 将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。

cpp 复制代码
//忽略子进程发出的17号信号
signal(SIGCHLD, SIG_IGN);
sigaction(SIGCHLD, act, oldact);//act中忽略17号信号

SIG_IGN 和 普通的忽略递达动作通常是没有区别的,而对于 SIGCHLD 信号来说,虽然该信号 SIGCHID 的默认动作也是忽略,但这个忽略是不对该信号进行额外处理;而显示在handler方法中使用 SIG_IGN ,子进程退出时发送给父进程的信号将会被父进程忽略,但子进程会被操作系统回收,这就是区别所在。

相关推荐
涛ing3 小时前
32. C 语言 安全函数( _s 尾缀)
linux·c语言·c++·vscode·算法·安全·vim
__雨夜星辰__3 小时前
Linux 学习笔记__Day2
linux·服务器·笔记·学习·centos 7
大耳朵土土垚3 小时前
【Linux】日志设计模式与实现
linux·运维·设计模式
学问小小谢3 小时前
第26节课:内容安全策略(CSP)—构建安全网页的防御盾
运维·服务器·前端·网络·学习·安全
yaoxin5211234 小时前
第十二章 I 开头的术语
运维·服务器
ProgramHan4 小时前
1992-2025年中国计算机发展状况:服务器、电脑端与移动端的演进
运维·服务器·电脑
马立杰7 小时前
H3CNE-33-BGP
运维·网络·h3cne
云空8 小时前
《DeepSeek 网页/API 性能异常(DeepSeek Web/API Degraded Performance):网络安全日志》
运维·人工智能·web安全·网络安全·开源·网络攻击模型·安全威胁分析
深度Linux8 小时前
Linux网络编程中的零拷贝:提升性能的秘密武器
linux·linux内核·零拷贝技术
没有名字的小羊9 小时前
Cyber Security 101-Build Your Cyber Security Career-Security Principles(安全原则)
运维·网络·安全