万字博客半小时带你学会信号(Linux)

132 阅读12分钟

序论

在我们的日常生活中,有很多东西都可以说得上是信号,例如:红绿灯、别人的一个眼神、烽火狼烟、上课铃。当我们遇见对应的信号的时候,我们通常也会做出相应的反应,像:当我们遇见红灯的时候,会自然而然地停下自己的步伐,等到绿灯的时候,再走过斑马线;当上级向我们投出犀利的目光的时候,我们通常也大概会猜到自己可能哪里犯错了;当我们听到上课铃的时候,便会自觉地回到自己的座位上,等着老师正式开始上课。从这些例子中,我们可以得到信号从产生到完成相应的动作的一个流程。

但这些都是生活中的例子,那么有人可能会想计算机中存在信号这样的概念和对应的操作吗?答案也是必然的!

信号基本理解

信号的概念:信号是进程之间时间异步通知的一种方式,属于软中断。

我们都知道计算机中的信号概念是从现实中引进的,那么这个信号的整个流程也应该与现实相近。

信号的流程:

image-20250227092422355.png

信号的种类

通过 kill -l 我们可以查看信号的种类和数目:

wjy@VM-4-8-ubuntu:~$ kill -l
 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR
31) SIGSYS      34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX

注意:一共有62种信号,并非64种,因为没有32,33号信号。

我们知道宏通常是全大写字母的,所以我们也可以借此知道,每个信号前面的数字就是其对应的整形。

看到31这个数字的时候,我们通常有种违和感:这不就是一个有符号整形(signed int)对应的位吗?

事实上,也确实如此。

信号对应的描述

PixPin_2025-02-27_10-55-35.png

信号的操作

当信号完成传递的时候,操作系统也会对相应的信号进行对应的操作:

默认:操作系统从被设计开始的时候,程序员们设计的操作。

自定义:用户通过系统调用对信号进行自定义操作。通过上面对信号的描述,我们可以知道 SIGKILL 和 SIGSTOP 无法被捕捉,也就是没有自定义操作。

忽略:由于操作系统目前正在执行其他的进程,没有时间处理相应的信号,因此该信号被搁置。

对于操作系统而言,假如要进行上述的操作,有一个前提就是那就是首先得要认识这些信号,然后才能进行调用对应的系统调用操作。换而言之,进程识别信号的方式是:认识 + 动作

我又可以知道进程本身其实是由程序员编写的属性和逻辑的集合,所以里面的默认动作之类的自然而然也是由程序员完成的。

对于现实生活而言,当我们在做一件很重要的事情的时候,上课铃响了,我们或许也不会立刻停止手上的工作,只有等到工作完成了,然后再继续进行执行刚刚的上课铃响回到自己的座位上。

那么,对于进程而言呢?

也是如此,当进程收到信号的时候,进程可能也正在执行一个更重要的代码,这时候信号就不会先进行对应信号的动作,而是等当前代码执行完,然后才进行对应的信号的动作。

信号的产生

image-20250227102724523.png

一、通过终端传递信号

编写一个死循环程序

首先,我们先写一个死循环的文件,并执行其相应的可执行二进制程序。

circle.cc:

#include <iostream>
#include <unistd.h>

int main()
{
    int cnt = 0;
    while(true)
    {
        std::cout << "hello : " << cnt++ << std::endl;
        sleep(1);
    }
    return 0;
}


Makefile:
circle:circle.cc
	g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
	rm -f circle
        
        
wjy@VM-4-8-ubuntu:~/blog_signal$ make
g++ -o circle circle.cc -std=c++11
wjy@VM-4-8-ubuntu:~/blog_signal$ ./circle 
hello : 0
hello : 1
hello : 2
hello : 3
hello : 4
hello : 5
hello : 6
hello : 7
hello : 8
^C  //ctrl + c
wjy@VM-4-8-ubuntu:~/blog_signal$ 
//进程终止

两种热键产生信号

有人可能就会问:这样也算通过终端传递信号吗?

是的,算,因为 ctrl + c 是一组热键,本质上是向操作系统( OS ) SIGINT 的信号。

//通过man 7 signal 我们可以查看每个信号对应的功能。
wjy@VM-4-8-ubuntu:~/blog_signal$ man 7 signal

//我们可以看到 SIGINT 的描述
SIGINT       P1990      Term    Interrupt from keyboard

Term   Default action is to terminate the process.//对 Term 的描述:默认操作为终止进程。

这时有人会有疑问:你说的不一定对,你怎么能确定这组热键就是调用了 SIGINT 呢?说不定是调用了其他的信号才完成的呢?

下面我就要介绍一个函数:sighandler_t signal(int signum, sighandler_t handler)

这个函数的函数头的描述:*typedef void (sighandler_t)(int)

这里我们可以知道这是一个函数指针,指向的是一个返回类型为 void ,传入参数的类型是 **int **的一个函数,因此我们可以传入各种与这返回类型和传入的参数相同的函数进去,从而完成一种多态的功能。

其对应功能: signal() sets the disposition of the signal signum to handler, which is either SIG_IGN, SIG_DFL, or the address of a programmer- defined function (a "signal handler").

翻译过来就是这个函数将 signum 的信号的处理方式设置为我们自定义的一个 handler 函数,其可以是默认的忽略操作和设计OS的程序员设置的默认操作。

因此,我们其实也可以知道:信号的处理方式其实默认已经调用了这个系统调用。

下面我们通过这个函数来进行一个简单的验证:

circle.cc:

#include <iostream>
#include <signal.h>
#include <unistd.h>

void myhandler(int signum)
{
    std::cout << "I am signum " << signum << std::endl;
    //预期结果:当我们用 ctrl + c,OS会收到2号信号的时候,signum会为2
}


int main()
{
    signal(SIGINT, myhandler);//signal(2, myhandler); 也可以
    int cnt = 0;
    while(true)
    {
        std::cout << "hello : " << cnt++ << std::endl;
        sleep(1);
    }
    return 0;
}

wjy@VM-4-8-ubuntu:~/blog_signal$ make
g++ -o circle circle.cc -std=c++11
wjy@VM-4-8-ubuntu:~/blog_signal$ ./circle 
hello : 0
hello : 1
hello : 2
^CI am signum 2
hello : 3
hello : 4
^CI am signum 2
hello : 5
^CI am signum 2
hello : 6
^CI am signum 2
hello : 7
^CI am signum 2
hello : 8
^CI am signum 2
hello : 9
^CI am signum 2
hello : 10
^CI am signum 2
hello : 11
hello : 12
hello : 13
^\Quit (core dumped)
wjy@VM-4-8-ubuntu:~/blog_signal$ 

我们发现我们无法通过 ctrl + c 的操作将该进程杀死了(最后通过是 ctrl + \ 将进程杀死的),并且打印出来的信号为 2,这也就证明了我们的 ctrl + c 就是向 OS 发送 SIGINT (2) 信号,然后操作系统再进行设置的操作。

ctrl + \ 本质上是发送我们的 SIGQUIT 的信号给 OS ,然后 OS 再对进程进行对应的操作。

当然,我们也可以来看看 SIGQUIT 的信号。

SIGQUIT      P1990      Core    Quit from keyboard

Core   Default action is to terminate the process and dump core//默认动作是终止进程并转储核心
//转储核心这个操作可以用于事后调试
    
Circle.cc:
#include <iostream>
#include <vector>
#include <signal.h>
#include <unistd.h>

std::vector<int> sig_vc = {2, 3};

void myhandler(int signum)
{
    std::cout << "I am signum " << signum << std::endl;
}

int main()
{
    for(auto it: sig_vc)  signal(it, myhandler);
    // signal(SIGINT, myhandler);//signal(2, myhandler); 也可以
    int cnt = 0;
    while(true)
    {
        std::cout << "hello : " << cnt++ << std::endl;
        sleep(1);
    }
    return 0;
}

//运行结果:
wjy@VM-4-8-ubuntu:~/blog_signal$ ./circle 
hello : 0
hello : 1
hello : 2
hello : 3
^CI am signum 2
hello : 4
hello : 5
hello : 6
hello : 7
hello : 8
^\I am signum 3 //输入 ctrl + \
hello : 9
hello : 10
hello : 11
hello : 12
hello : 13
hello : 14
hello : 15
hello : 16
hello : 17
Killed//最后通过 kill -9 将该进程杀死
wjy@VM-4-8-ubuntu:~/blog_signal$ 
//所以,ctrl + \ 调用的就是3号信号

EXTRA:核心转储

上面我提了一下 core 指的是核心转储,下面我就给大家来介绍一下核心转储。

PixPin_2025-03-01_15-11-55.png

我们可以看见 core file size 那里的大小为0,所以,对于我们的云服务器而言,核心转储的功能是默认关闭的。

image-20250301151759836.png

假如我们想要启用核心转储功能的话,我们可以先用 ulimit -c 对应的大小 来启用对应的功能。

wjy@VM-4-8-ubuntu:~/blog_signal$ ulimit -c 1024
wjy@VM-4-8-ubuntu:~/blog_signal$ ulimit -a
real-time non-blocking time  (microseconds, -R) unlimited
core file size              (blocks, -c) 1024
data seg size               (kbytes, -d) unlimited
scheduling priority                 (-e) 0
file size                   (blocks, -f) unlimited
pending signals                     (-i) 6563
max locked memory           (kbytes, -l) 219108
max memory size             (kbytes, -m) unlimited
open files                          (-n) 1048576
pipe size                (512 bytes, -p) 8
POSIX message queues         (bytes, -q) 819200
real-time priority                  (-r) 0
stack size                  (kbytes, -s) 8192
cpu time                   (seconds, -t) unlimited
max user processes                  (-u) 6563
virtual memory              (kbytes, -v) unlimited
file locks                          (-x) unlimited

这里我们就可以看见我将核心转储的文件大小设置为了1024,下面先一起来看一下其作用。

//这里我先写一份错误代码
test_core_dump.cc:
#include <iostream>

int main()
{
    int array[10] = {0};
    array[10000] = 100;
    return 0;
}

终端:
wjy@VM-4-8-ubuntu:~/blog_signal$ g++ -o test_core_dump test_core_dump.cc -std=c++11 -g
// -g 是为了生成一个debug版本文件,因为只有debug版本的文件才可以被调试,而默认生成的文件时release版的
wjy@VM-4-8-ubuntu:~/blog_signal$ ./test_core_dump 
Segmentation fault (core dumped)
//通过ll,我们可以看见当前目录下有一个core文件
wjy@VM-4-8-ubuntu:~/blog_signal$ ll
//假如没有的话,可以先cat /proc/sys/kernel/core_pattern
//假如打印的是这个:|/usr/share/apport/apport -p%p -s%s -c%c -d%d -P%P -u%u -g%g -- %E
//说明core文件由于被apport拿去检测是否有bug而被吞了
//这时候再输入:sudo service apport stop,将这个apport这个进程关掉,再运行,就可以了
-rw------- 1 wjy wjy 552960 Mar  1 15:35 core //这个就是我们的core文件
wjy@VM-4-8-ubuntu:~/blog_signal$ gdb test_core_dump //然后就可以开始我们的事后调试了
...
(gdb) core-file core//输入这个我们就可以直接跳转到代码出错的位置了
[New LWP 1305362]
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
Core was generated by `./test_core_dump'.
Program terminated with signal SIGSEGV, Segmentation fault.
#0  main () at test_core_dump.cc:6
6           array[10000] = 100;
(gdb)

这就是我们的核心转储,因为我们知道当一个程序出现了异常,我们的想法是找到 bug 所在的位置和原因,而核心转储的出现就很好的解决了这个问题,因为通过事后调试的方式,我们可以直接找 bug 位置和原因,可以提高我们 debug 的效率,节省时间。

这时候可能有人会有问题:为什么我用 array[100] 和 array[1000]的时候没有出现这样的信号呢?

每当我们向 OS 申请内存的时候,OS 事实上会多分配一些空间给我们用户,我们知道当我们进行动态链接的时候,之后我们运行的代码每一次调用库函数的时候,都要通过环境变量的 PATH 去寻找我们对应的函数本身(当然也可以进行静态链接,但是其文件大小会扩大进100倍),这会影响到程序运行的效率,因此,OS 会多分配部分空间,借此来提高代码运行效率。而到了 array[10000] 才开始出错是因为 array 已经开始访问别的区域了。

kill 命令产生信号

还有一种就是用 kill 指令来对进程发送信号。

circle.cc:

#include <iostream>
#include <vector>
#include <signal.h>
#include <unistd.h>

std::vector<int> sig_vc = {2, 3};

void myhandler(int signum)
{
    std::cout << "I am signum " << signum << std::endl;
}

int main()
{
    pid_t pid = getpid();
    for(auto it: sig_vc)  signal(it, myhandler);
    // signal(SIGINT, myhandler);//signal(2, myhandler); 也可以
    int cnt = 0;
    while(true)
    {
        std::cout << "hello : " << cnt++ << " mypid : " << pid << std::endl;
        sleep(1);
    }
    return 0;
}


终端1:
wjy@VM-4-8-ubuntu:~/blog_signal$ ./circle 
hello : 0 mypid : 1178832
hello : 1 mypid : 1178832
hello : 2 mypid : 1178832
hello : 3 mypid : 1178832
hello : 4 mypid : 1178832
hello : 5 mypid : 1178832
hello : 6 mypid : 1178832
hello : 7 mypid : 1178832
hello : 8 mypid : 1178832
hello : 9 mypid : 1178832
    
终端2:
wjy@VM-4-8-ubuntu:~/blog_signal$ kill -9 1178832
    
终端1:
Killed
wjy@VM-4-8-ubuntu:~/blog_signal$ 

二、调用系统调用对进程发送信号

对于 OS 而言,假如我们用户想要对进程发送信号,那么就一定要为用户提供一个接口,因为 OS 不相信任何用户。

为什么 OS 不相信用户以及为什么产生命令行解释器( shell )?

假如 OS 相信用户,那么用户可以直接对 OS 发送命令,而 OS 没有对于命令合法的判断能力,会直接执行我们的命令,假如我们此时向 OS 发送一些恶意的命令,这可能会导致安全问题,因此 OS 不相信任何用户。所以,此时产生了命令行解释器( shell ),shell 有两个作用:1. 将命令转化成二进制可执行程序(因为 OS 只认识机器语言即二进制可执行代码,这降低用户的使用标准), 2. 检测命令的合法性(防止用户的恶意操作)。在 LInux 中常用的 shell 是 bash。

系统调用的来源:

在 OS 中,为了使我们用户在代码中能够对 OS 进行操作,也就有了系统调用。

在我们写代码中,也可以使用系统调用来对进程进行操作。

//系统调用:kill
int kill(pid_t pid, int sig);

The kill() system call can be used to send any signal to any process group or process.//对于该系统调用的描述
//翻译:kill()系统调用可以向任何进程组或进程发送信号。
    
//使用实例:
test_kill.cc:
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <cstdio>

#define SIGNUM 3

int main()
{
    pid_t pid = getpid();
    int cnt = 10;
    while(cnt--)
    {
        //自己测试的时候可以使用这种方式,更直观
        //printf("cnt: %2d\r",cnt);
        //fflush(stdout);
        printf("cnt: %2d\n",cnt);
        if(!cnt)/*cnt == 0*/ 
        {
            kill(pid, SIGNUM);//直接退出进程
            std::cout << "EXIT" << std::endl;
        }
        sleep(1);
    }
    return 0;
}
//预期:从10依次打印到0,然后调用3号信号退出进程。


终端:
wjy@VM-4-8-ubuntu:~/blog_signal$ ./test_kill 
cnt:  9
cnt:  8
cnt:  7
cnt:  6
cnt:  5
cnt:  4
cnt:  3
cnt:  2
cnt:  1
cnt:  0
Quit (core dumped)//这里就是调用了kill()系统调用
wjy@VM-4-8-ubuntu:~/blog_signal$ 
//与预期结果相符
    
借此我们可以实现自己在 shell 中的 kill 操作:

mykill.cc:

#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <cstdlib>

void Usage()/*该函数用于提示这个可执行程序需要3个参数*/
{
    std::cout << "argc need 3: ./mykill, pid, signum" << std::endl;
    exit(1);
}

int main(int argc, char* argv[])
{
    //测试日志
    //std::cout << "argc num :" << argc << std::endl;
    if(argc < 3)
    {
        Usage();
    }
    /*
    int signum = atoi(argv[1]);
    pid_t pid = atoi(argv[2]);
    也可以这样,可以使其形式跟 kill 的一样
    但这里为了使代码更有区分度,所以就用下面的方式
    */
    //./mykill pid signum
    //atoi的作用就是将字符串变成对应的整形
    int signum = atoi(argv[2]);
    pid_t pid = atoi(argv[1]);
    // 测试日志
    //std::cout << "signum: " << signum << " pid: " << pid << std::endl;
    kill(pid, signum);
    return 0;
}


终端1:
wjy@VM-4-8-ubuntu:~/blog_signal$ ./circle 
hello : 0 mypid : 1215262
hello : 1 mypid : 1215262
hello : 2 mypid : 1215262
hello : 3 mypid : 1215262
hello : 4 mypid : 1215262
hello : 5 mypid : 1215262
hello : 6 mypid : 1215262
hello : 7 mypid : 1215262
hello : 8 mypid : 1215262
hello : 9 mypid : 1215262
hello : 10 mypid : 1215262
hello : 11 mypid : 1215262
hello : 12 mypid : 1215262

终端2:
wjy@VM-4-8-ubuntu:~/blog_signal$ ./mykill 1215262 9
    
终端1:
Killed
wjy@VM-4-8-ubuntu:~/blog_signal$ 
//上面我们就是通过自己的 ./mykill程序将我们的 circle 程序进程杀死
    
//我们知道大步幅的 c 语言接口都是由系统调用封装而成的
//下面讲的 raise(), abort() 就是由 kill() 这个系统调用封装而成的

int raise(int sig);

//该函数的描述
The  raise() function sends a signal to the calling process or thread.  In a single-threaded program it is
equivalent to kill(getpid(), sig);
//翻译:raise()函数向调用进程或线程发送信号。在单线程程序中,它相当于 kill(getpid(), sig);
//这里就描述到了 raise() 是由 kill() 封装而成的

//使用实例:
test_raise.cc:
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <cstdio>

#define SIGNUM 2

void myhandler(int signum)
{
    std::cout << "signum: " << signum << std::endl;
    exit(0);
}

int main()
{
    signal(SIGNUM, myhandler);
    int cnt = 10;
    while(cnt--)
    {
        printf("cnt: %d\n",cnt);
        sleep(1);
        if(!cnt)
        {
            raise(SIGNUM);
        }
    }
    return 0;
}


终端:
wjy@VM-4-8-ubuntu:~/blog_signal$ ./test_raise 
cnt: 9
cnt: 8
cnt: 7
cnt: 6
cnt: 5
cnt: 4
cnt: 3
cnt: 2
cnt: 1
cnt: 0
signum: 2
wjy@VM-4-8-ubuntu:~/blog_signal$ 
    
void abort(void);

//该函数的描述
The  abort()  function  first  unblocks  the  SIGABRT  signal, and then raises that signal for the calling
process (as though raise(3) was called).  This results in the abnormal termination of the  process  unless
the SIGABRT signal is caught and the signal handler does not return (see longjmp(3)).
//翻译:abort()函数首先解除 SIGABRT 信号的阻塞状态,然后对于调用该函数的进程中发送该信号(就像调用 raise(3) 一样)。这会导致进程异常终止,除非捕获了 SIGABRT 信号,并且信号处理程序没有返回(参见 longjmp(3))。
    
//使用实例:
test_abort.cc:
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <cstdio>

#define SIGNUM 2

void myhandler(int signum)
{
    std::cout << "signum: " << signum << std::endl;
    exit(0);
}

int main()
{
    signal(SIGNUM, myhandler);
    int cnt = 10;
    while(cnt--)
    {
        printf("cnt: %d\n",cnt);
        sleep(1);
        if(!cnt)
        {
            raise(SIGNUM);
        }
    }
    return 0;
}

终端:
wjy@VM-4-8-ubuntu:~/blog_signal$ ./test_abort 
cnt: 9
cnt: 8
cnt: 7
cnt: 6
cnt: 5
cnt: 4
cnt: 3
cnt: 2
cnt: 1
cnt: 0
signum: 6//我们可以对这个信号进行查询
wjy@VM-4-8-ubuntu:~/blog_signal$
wjy@VM-4-8-ubuntu:~/blog_signal$ kill -l
 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR
31) SIGSYS      34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX
wjy@VM-4-8-ubuntu:~/blog_signal$
//然后既可以发现 SIGABRT 就是 6 号信号
wjy@VM-4-8-ubuntu:~/blog_signal$ man 7 signal 
//我们继续向 manual 中查找该信号的相关信息

SIGABRT      P1990      Core    Abort signal from abort(3)
//我们可以看到这个信号是 abort 这个函数发送的,也会设置核心转储,便于之后的事后调试。

所以,从目前见到的这些信号来看,有人或许有疑问:为什么要有这么多的默认动作都是退出/终止进程的信号?这样的信号留有一两个不就够了吗?

对于这类疑问,我们首先要明白异常有许多种,假如我们的默认动作为终止进程的信号只有少数几个,对于我们程序员而言是一件很不友好的一件事,因为我们现在的每一种这类的信号就对应了一种异常状态,假如只有少数几个,我们将很难一下判断出代码出错的原因,只能够先判定一个范围,然后不断地进行 debug ,这也就是我们有这么多种信号的原因。

借此,我们也可以得到信号存在的意义:不同的信号可以代表不同时间。

三、硬件产生信号

除0报错的原因

我们首先来看一段代码。

mytest.cc:
#include <iostream>

int main()
{
    int a = 10;
    a /= 0;
    return 0;
}

终端:
wjy@VM-4-8-ubuntu:~/blog_signal$ ./mytest 
Floating point exception
//此时报的错是浮点数溢出
wjy@VM-4-8-ubuntu:~/blog_signal$ kill -l
 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR
31) SIGSYS      34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX
//我们从上面的那些,目前也应该对信号的命名有了一定自己的想法。
//通过观察这些信号,我们可以推测出 8 号信号( SIGFPE )就是我们对应的信号。
//下面对此进行一次验证
//我先对mytest.cc先进行稍微得修改一下

mytest.cc:
#include <iostream>
#include <signal.h>
#include <unistd.h>

void myhandler(int signum)
{
    std::cout << "signum: " << signum << std::endl;
}

int main()
{
    signal(SIGFPE, myhandler);
    int a = 10;
    int cnt = 0;
    while(true)
    {
        std::cout << "cnt: " << cnt++ << std::endl;
        a /= 0;
        sleep(1);
    }
    return 0;
}

wjy@VM-4-8-ubuntu:~/blog_signal$ ./mytest
0
signum: 8
signum: 8
signum: 8
signum: 8
signum: 8
...
signum: 8
signum: 8
signum: 8
signum: 8
signum: 8
^C
wjy@VM-4-8-ubuntu:~/blog_signal$ 
//我们可以发现这里输出的就是 8 号信号

但是此时我们发现有些不对啊:代码中不是会打印 cnt 吗?这里为什么没有打印呢?

通过我们对于代码的观察,确实应该如此,但是这里显然没有做到,这是为什么呢?

屏幕截图 2025-03-01 162836.png

我们首先先来看一下 CPU 的结构,我们知道 CPU 里面有寄存器,而寄存器中又存在一个状态寄存器的寄存器,这个寄存器专门存放两类信息:1、一类是体现当前指令执行结果的各种状态信息 2、一类是存放控制信息。第一类存放中就包括了上图中的溢出标志位。

屏幕截图 2025-03-01 191627.png

状态寄存器存在的缘由:像上面的这个,我们知道寄存器里面可以存放我们当前的数据,当我们的 eax 中的数据对 ebx 中的数据进行除法,因为得到结果没有意义,此时就会对溢出标志位设为1,ecx中也会因为得到的数据没有意义,而不会对其进行保存。

0 在我们数学中就是无穷小,在我们的寄存器中存放其某种意义上是存放了一个非常非常小的浮点型,就如 IEEE 754 规定的一样,因为这个数的E全为0,因为这个数很小所以才把它看作是 0,所以当我们的数字对这个非常小的数字进行除法的时候,寄存器溢出了,然后对这个溢出标志位将 0 设置为 1,然后向 OS 发送信号,OS 收到信号后,再对 CPU 当前的进程发送信号(CPU 内部有一个 correct 寄存器专门用于存放当前进程的 task_struct),接收到信号后,进程不一定会退出,进程可能会继续运行,CPU 运行进程其实是有一个时间片的概念,每当一个进程运行到该时间片结束,OS 会将该进程的上下文保存到对应的 PCB 中且进程进行切换,然后该进程重新进入等待队列中,排队等待 CPU 运行到该进程(我们知道我们 OS 中只有一个 CPU,为了使进程运行的效率,所以才提出了时间片的概念),当重新轮到该进程的时候,会将其上下文恢复。我们也知道当进程的上下文被保存的时候,其下一次重新运行的时候,还是会重新在这里开始运行代码,然后 CPU 又检测到溢出标志位,然后循环反复,就产生了上面的情况。

注意:

我们虽然知道了异常是如何产生的,但是,我们得思考一个问题就是,我们用户能对 CPU 进行操作吗?

答案是否定的,因为只有 OS 能对硬件进行操作,因此,我们捕捉硬件产生的信号本质上是没有意义的,所以正常情况下,我们捕捉信号后,就直接退出即可。

野指针报错的原因

我们再来看一个错误代码。

#include <iostream>

int main()
{
    //野指针
    int *p = nullptr;
    *p = 10;
    return 0;
}

终端:
wjy@VM-4-8-ubuntu:~/blog_signal$ ./mytest 
Segmentation fault
//此时的错误是段错误
wjy@VM-4-8-ubuntu:~/blog_signal$ kill -l
 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR
31) SIGSYS      34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX
//我们通过查表就可以很快发现是 11 号信号( SIGSEGV )
//下面对其进行一个验证
mytest.cc:
#include <iostream>
#include <signal.h>
#include <unistd.h>

void myhandler(int signum)
{
    std::cout << "signum: " << signum << std::endl;
    // exit(1);
}

int main()
{
    signal(SIGSEGV, myhandler);
    //野指针
    int *p = nullptr;
    int cnt = 0;
    while(true)
    {
        std::cout << "cnt: " << cnt ++ << std::endl;
        *p = 10;
        sleep(1);
    }
    return 0;
}

终端:
wjy@VM-4-8-ubuntu:~/blog_signal$ ./mytest
cnt: 0
signum: 11
signum: 11
signum: 11
signum: 11
signum: 11
signum: 11
signum: 11
signum: 11
...
signum: 11
signum: 11
signum: 11
signum: 11
signum: 11
signum: 11
^C
wjy@VM-4-8-ubuntu:~/blog_signal$ 

这里可以看到打印出来的信号是 11,对于这里的只打印了一次 cnt,其余全是在调用自己的 handler(),我主要解释一下这个信号是怎么产生和存储的位置,别的跟上面的几乎一致。

我们从上面的图中看到了 CPU 中存在一个 MMU 的内存管理单元。

屏幕截图 2025-03-01 201635.png

当我们的程序运行时,会通过页表与物理地址空间创建一个映射关系,但实际上是 MMU 先读取页表的中的虚拟地址在其本身中产生一个物理地址,然后再跟物理地址空间创建联系,当MMU 开始越界访问的时候,MMU 就会发生异常,由于 OS 可以检测到硬件的异常,所以就会向 CPU 内当前的进程发送信号,然后进程再进行对应的操作。

四、由软件产生信号

介绍 alarm 系统调用

简单介绍一下 unsigned int alarm(unsigned int seconds) 这个系统调用。

该函数的描述: alarm() arranges for a SIGALRM signal to be delivered to the calling process in seconds seconds.

翻译:alarm() 将会在数秒内将 SIGALRM 信号传送到调用该系统调用的进程中。

返回值:alarm() returns the number of seconds remaining until any previously scheduled alarm was due to be delivered, or zero if there was no previously scheduled alarm.

翻译:alarm()假如提前结束,则返回先前计划的警报剩余的秒数,如果没有,则返回 0。

有人会问:为什么会提前结束?

举个栗子:在我们的现实生活中,我们一般会自己订闹钟,然后起床,但有时候我们的父母会提前将我们叫起来,相同,在进程运行的时候,可能会提前遇到进程已经结束了,作为函数也自然地得提前结束。

alarm 的管理模式:

我们知道操作系统对于进程的管理都是:先描述,再组织。我们进程中对 alarm 订的闹钟也是如此,在 OS 中,有一个专门用于管理闹钟的数据结构:strcut alarm。

struct alarm

{

​ uint_64 when;//未来的时间戳

​ int type;//闹钟的类型:一次性/周期性

​ task_struct *p;//进程对应的进程块

​ struct alarm *next;//下一个闹钟

​ ...

};

从这里的伪代码,我们就能看到,在 OS 中,将这些闹钟通过链表连接起来了,但是其跟普通的链表有点不太一样,在 OS 中,这个链表是一个有序链表,有点类似 C++ 中的 STL 库中的 priority_queue,当闹钟被放入这个链表中的时候就会自动排序,然后 OS 通过当前的时间戳和这个 alarm 结构体的 when 这个时间戳进行比较大小,假如超过了就向对应的进程发送 SIGALRM 这个信号,而使用这种数据结构有一个优势,就是假如当前的时间戳小于该链表的第一个节点上存放的时间戳,就不需要继续向后比较,这样可以提高代码运行的效率。

使用实例:

myalarm.cc:
#include <iostream>
#include <unistd.h>

int main()
{
    alarm(1);
    int cnt = 0;
    while(true)
    {
        cnt++;
        std::cout << cnt << std::endl;//一次一次输出
    }
    return 0;
}

终端:
wjy@VM-4-8-ubuntu:~/blog_signal$ ./myalarm
1
2
3
...
159960
159961
159962
159963
159964
159965
159966
159967
Alarm clock//闹钟响了
wjy@VM-4-8-ubuntu:~/blog_signal$ 


myalarm.cc:
#include <iostream>
#include <signal.h>
#include <unistd.h>

int cnt = 0;

void myhandler(int signum)
{
    std::cout << "signum: " << signum <<  " cnt: " << cnt << std::endl;//1s后输出结果
    exit(0);
}

int main()
{
    alarm(1);
    signal(SIGALRM, myhandler);
    while(true)
    {
        cnt++;
        // std::cout << cnt << std::endl;
    }
    std::cout << cnt << std::endl;
    return 0;
}

终端:
wjy@VM-4-8-ubuntu:~/blog_signal$ ./myalarm 
signum: 14 cnt: 559493936
wjy@VM-4-8-ubuntu:~/blog_signal$ kill -l
 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR
31) SIGSYS      34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX

看到14号信号,我们可以发现就是 SIGALRM。 这里的一次一次地输出和1s后再输出相比,1s 后再输出的 cnt 更大。 根据冯诺依曼体系结构,我们知道内存的运行速度更快,外设的运行效率较低,而 IO 的过程中在反复地与外设进行互动,内存中的缓冲区中的内容一直在增加,但是外设无法跟上内存给外设的速度,所以最后输出的 cnt 较小。

这里就是 OS 对文件写入的时候采用的是全缓冲的原因,到最后再将内容全都一次型写入(事实上,文件写入的过程中,等资源的时间远大于写入的时间)。

alarm 发送信号给进程实际上是存在一个 alarm 对应的软件向进程发送信号。

信号的保存

屏幕截图 2025-03-01 213010.png

那么,进程应该要求有一个相应的功能或结构用于保存该信号!

我们知道在一个操作系统中,可能会出现很多的进程,假如对每一个进程的各种信息都一个一个地进行直接管理,那么这将会导致我们的操作系统对这些进程的管理的效率很低,拿校园的校长举例,假如让一个校长对校园内的几千号的学生进行一个独立的管理,那么这会严重的降低校长的管理效率,假如校长想查找一个学生的信息,还得一个一个地询问过去这些学生,最后才能够查到这个学生的信息,但如果校长选择将这些学生的个人信息,成绩之类的都用一个档案系统来保存,之后,想查找某一个学生的某门信息就直接先查找这名学生的档案,最后再从其档案中查找其的成绩即可,这样就可以大幅度地提高校长的工作效率,对于操作系统而言也是如此,会先将每一个进程用一个对应的 “档案” 来进行保存,这个档案就是 task_struct ,然后再通过某种数据结构将这些结构体连接到一起,这样就可以帮助操作系统管理进程,所以最后对于进程的管理变成了对这个数据结构的管理,这个数据结构全称:Process Control Block(简称:PCB),所以对进程管理本质就是对 PCB 管理。

所以这里很明显的就是将信号保存至其对应的 task_struct 中,然后再对信号进行处理。

那么,我们知道了是 PCB 是为 OS 服务的, 那么 PCB 也就是由 OS 操控的,我们知道信号是要保存到 task_struct 中的,那么就一定要用到 PCB,所以一定是 OS 来操作的。

//简述:Linux 中 task_struct 对于 signal 的描述

task_struct
{
    ...
   	unsigned int signal;
    ...
}

这上面的大概就是 task_struct 对于 signal 的描述,我们可以看见其类型是 unsigned int ,有人可能就会想一个 32 位的数字该如何表示这个信号的保存呢?

其实这里就是用到了位图,因为我们知道信号的有无其实就两种状态:1. 有 2. 无,在我们编程中,我们通常都是用 0 来表示无, 1 来表示有(电路的开关),我们知道 unsigned int 是一个 32 位的数据类型,假如我们以位的视角来看,我们就能看到 32 个位置,这样就可以最多来表示 32 个信号的状态。

0000 0000 0000 0000 0000 0000 0000 0100 // signal 的位图

这里就是1代表的是有该信号,0代表的是无该信号。而比特位的位置就是该信号的编号。

所以,我们可以得出一个结论:发送信号的本质就是修改位图。

信号的传递

屏幕截图 2025-03-01 213616.png

信号传递的情况

信号传递分了三种情况:

抵达:向进程发送了信号,且进程完成了信号对应的动作。

未决:向进程发送了信号,但是进程还没有完成信号对应的动作。

阻塞:信号被阻塞了(无论是否收到相应的信号),直到被解除阻塞状态,才可能完成对应的信号。

信号传递在内核中的表现

屏幕截图 2025-03-01 215646.png

上面的阻塞对应的就是这里的 block,当我们想设置信号为阻塞状态的时候,这里就出现了一个新概念:信号屏蔽字。

信号屏蔽字指的是进程被屏蔽的信号的集合,其有一个专门的数据类型:sigset_t

注意:一个进程只有一个信号屏蔽字,所有的操作都是对这个唯一的信号屏蔽字进行操作。

我们在上面的信号的保存里面,我们知道了信号在 task_struct 中的保存是通过位图来保存的,在信号传递中相关进程的信号属性相同,

也是通过位图的方式来保存。

block:0000 0000 0000 0000 0000 0000 0000 0000   //信号屏蔽字
pending:0000 0000 0000 0000 0000 0000 0000 0000 //未决的信号集合

而通过上图我们可以借此来大概推测出 block 和 pending 相应的逻辑

//伪代码
//进程对于信号处理逻辑
for(int signum = 1; signum <= 31; signum++)
{
    if(block/*检测对应的信号是否在信号屏蔽字内*/)
	{
		//假如在,那就什么都不做。    
	}
    else
    {
        //说明该信号不在信号屏蔽字内
        if(pending/*检测对应的信号在 pending 中的状态是否为 1*/)
        {
            //假如是,就会将其 pending 中的信号设置为 0,和执行该信号对应的动作
            //这两个先后顺序在不同可能会有差异
        }
        //假如没有就继续寻找下一位
    }
}

sigset_t 这个数据类型就是表示的信号的状态,有或无( 1 or 0 ),所以单拿出这个数据类型没有意义,需要配合对应的信号集操作函数使用。

信号集操作函数

int sigemptyset(sigset_t *set);//将信号屏蔽字全设为 0,即表示没有信号被屏蔽,用于初始化。
int sigfillset(sigset_t *set);//将信号屏蔽字全设为 1,即 OS 支持的信号全部被屏蔽,用于初始化
int sigaddset(sigset_t *set, int signum);//将 signum 加入信号屏蔽字内。
int sigdelset(sigset_t *set, int signum);//将 signum 从信号屏蔽字内删除。
//以上 4 种函数返回值都是成功返回 0,失败返回 -1
int sigismember(const sigset_t *set, int signum);//判断这个信号是否在屏蔽字里面,返回值是 bool,在则返回 true,失败则返回 false。(1 / 0)

int sigprocmask(int how, const sigset_t *_Nullable restrict set, sigset_t *_Nullable restrict oldset);
//how(行为): 
SIG_BLOCK   The set of blocked signals is the union of the current set and the set argument.
//翻译:将信号屏蔽字 | 上我们参数中的 set 的信号
SIG_UNBLOCK   The  signals  in set are removed from the current set of blocked signals.  It is permissible to attempt to unblock a signal which is not blocked.
//翻译:从信号屏蔽字中,解除set指向的的信号的信号屏蔽状态
SIG_SETMASK   The set of blocked signals is set to the argument set.
//翻译:将信号屏蔽字设置为参数中的set
//set:指向一个sigset_t的数据类型(信号集)的指针,表示要操作的信号屏蔽字,是一个输入型参数。
//oldset:用于保存原来的信号屏蔽字,是一个输出型参数	
//返回值:成功返回 0,失败返回 -1,并且会设置错误码。
    
int sigpending(sigset_t *set);//用于获取未觉的信号集,成功则返回 0,失败返回 -1

小实验:

test_sig.cc:
#include <iostream>
#include <signal.h>
#include <unistd.h>

#define SIG_BCK 2 /*该信号被屏蔽,所以不会进行相关动作*/

void show_pending(const sigset_t& pending)
{
    for(int signum = 31; signum >= 1; signum--)
    {
        if(sigismember(&pending, signum)) std::cout << "1" ;//说明收到了该信号
        else std::cout << "0";//没有收到该信号
    }
    std::cout << std::endl;
}

int main()
{
    sigset_t block/*信号屏蔽字*/,pending/*未决信号*/,oldblock/*原来的信号屏蔽字*/;
    //1.1 初始化
    sigemptyset(&block);
    sigemptyset(&pending);
    sigemptyset(&oldblock);
    
    //1.2 设置信号屏蔽字
    sigaddset(&block, SIG_BCK);

    //1.3 将刚刚设置的信号屏蔽字放到进程的信号屏蔽字内
    sigprocmask(SIG_SETMASK, &block, &oldblock);

    while(true)
    {
        //2.1 获取未决信号
        sigpending(&pending);

        //2.2 一直打印未决信号
        show_pending(pending);

        //2.3 休眠1s
        sleep(1);
    } 
    return 0;
}

终端:
wjy@VM-4-8-ubuntu:~/blog_signal$ ./test_sig 
0000000000000000000000000000000
0000000000000000000000000000000
^C0000000000000000000000000000010
0000000000000000000000000000010
0000000000000000000000000000010
^\Quit
wjy@VM-4-8-ubuntu:~/blog_signal$

在这个小实验里面,我们凭借上面的信号集操作函数在屏幕上输出了当前未决信号集。

但是此时我们有个疑问?我们要是在某个信号处于未决状态的时候还是继续反复的发送这个同样的信号会发生什么?

我们通过上面的 pending 就知道了每个信号都只有一个比特位,当其处于未决状态的时候,那么,该比特位就已经被置为了 1,那么别的同样的信号就会被丢失

test_sig.cc:
#include <iostream>
#include <signal.h>
#include <unistd.h>

#define SIG_BCK 2 /*该信号被屏蔽,所以不会进行相关动作*/

void show_pending(const sigset_t& pending)
{
    for(int signum = 31; signum >= 1; signum--)
    {
        if(sigismember(&pending, signum)) std::cout << "1" ;//说明收到了该信号
        else std::cout << "0";//没有收到该信号
    }
    std::cout << std::endl;
}

void catchsig(int signum)
{
    std::cout << "I am unblocked" << std::endl;

}

int main()
{
    signal(SIGINT, catchsig);
    sigset_t block/*信号屏蔽字*/,pending/*未决信号*/,oldblock/*原来的信号屏蔽字*/;
    //1.1 初始化
    sigemptyset(&block);
    sigemptyset(&pending);
    sigemptyset(&oldblock);
    
    //1.2 设置信号屏蔽字
    sigaddset(&block, SIG_BCK);

    //1.3 将刚刚设置的信号屏蔽字放到进程的信号屏蔽字内
    sigprocmask(SIG_SETMASK, &block, &oldblock);

    int cnt = 10;
    while(true)
    {
        //2.1 获取未决信号
        sigpending(&pending);

        //2.2 一直打印未决信号
        show_pending(pending);

        //2.3 休眠5s
        sleep(5);
        cnt--;
        if(!cnt)
        {
            //解除阻塞状态
            sigprocmask(SIG_SETMASK, &oldblock, &block);
        }
    } 
    return 0;
}

终端:
wjy@VM-4-8-ubuntu:~/blog_signal$ ./test_sig 
0000000000000000000000000000000
^C^C^C^C^C^C^C^C0000000000000000000000000000010
0000000000000000000000000000010
0000000000000000000000000000010
0000000000000000000000000000010
0000000000000000000000000000010
0000000000000000000000000000010
0000000000000000000000000000010
0000000000000000000000000000010
0000000000000000000000000000010
I am unblocked
0000000000000000000000000000000
0000000000000000000000000000000
^\Quit

这里就佐证了我们的思路。

如果我此时在执行该信号的操作的时候,同时向其发送多个相同信号,那么此时,进程会发生什么呢?

由于进程运行信号的时候,会将该信号对应的 pending 上的信号位设为 0,并运行其对应的操作。所以最后这些信号只有第一个信号被写入 pending 上,其余信号都被丢失了。

test_sig.cc:
#include <iostream>
#include <signal.h>
#include <unistd.h>

#define SIG_BCK 2 /*该信号被屏蔽,所以不会进行相关动作*/

void show_pending(const sigset_t& pending)
{
    for(int signum = 31; signum >= 1; signum--)
    {
        if(sigismember(&pending, signum)) std::cout << "1" ;//说明收到了该信号
        else std::cout << "0";//没有收到该信号
    }
    std::cout << std::endl;
}

void catchsig(int signum)
{
    std::cout << "I am unblocked" << std::endl;
    sleep(5);
}

int main()
{
    signal(SIGINT, catchsig);
    sigset_t block/*信号屏蔽字*/,pending/*未决信号*/,oldblock/*原来的信号屏蔽字*/;
    //1.1 初始化
    sigemptyset(&block);
    sigemptyset(&pending);
    sigemptyset(&oldblock);
    
    //1.2 设置信号屏蔽字
    sigaddset(&block, SIG_BCK);

    //1.3 将刚刚设置的信号屏蔽字放到进程的信号屏蔽字内
    sigprocmask(SIG_SETMASK, &block, &oldblock);

    int cnt = 10;
    while(true)
    {
        //2.1 获取未决信号
        sigpending(&pending);

        //2.2 一直打印未决信号
        show_pending(pending);

        //2.3 休眠1s
        sleep(1);
        cnt--;
        if(!cnt)
        {
            //解除阻塞状态
            sigprocmask(SIG_SETMASK, &oldblock, &block);
        }
    } 
    return 0;
}

终端:
wjy@VM-4-8-ubuntu:~/blog_signal$ ./test_sig 
0000000000000000000000000000000
0000000000000000000000000000000
0000000000000000000000000000000
^C0000000000000000000000000000010
0000000000000000000000000000010
0000000000000000000000000000010
0000000000000000000000000000010
0000000000000000000000000000010
0000000000000000000000000000010
0000000000000000000000000000010
I am unblocked
^C^C^C^CI am unblocked
0000000000000000000000000000000
0000000000000000000000000000000
0000000000000000000000000000000
^\Quit

这里也同样作证了我们上面说的内容。

用户态和内核态

大部分人们认为我们写代码的时候,当代码中调用了系统调用,代码运行的时候就只是简单地调用了系统调用就是了。

屏幕截图 2025-03-02 161252.png

其实在这个过程中还发生了进程运行的级别的转换:从 用户态 -> 内核态。

屏幕截图 2025-03-02 161526.png

上面提到过,OS 不相信任何人,所以,为了确保 OS 的安全性和用户的使用,就有了系统调用以及内核态和用户态。涉及到 OS 的各种操作只有在内核态下才能进行。现在或许有一种感觉:内核态的权限比用户态更高。

但是,包含权限级别的切换的函数效率肯定会比直接进行函数中的逻辑操作是低的,这就是系统调用要尽量少的进行调用的原因,者可以提高代码运行的效率。

那么,OS 是如何判别你是用户态呢?还是用户态呢?

那么我们首先需要知道一点:

CPU 中包含了各种寄存器,像我们在查看汇编语言的时候,里面的 eax, ebx, ecx等等,还有上面我们见到过的状态寄存器,这些都属于可见寄存器,而 CR3 属于不可见寄存器,其功能是表征当前进程的权限的级别,其中,0 属于内核态, 3 属于用户态。

补充:

1.可见寄存器和不可见寄存器:

可见寄存器指的是能够被程序员直接访问的或者看见的寄存器就是可见寄存器。最典型的就是我们查看代码的汇编语言的时候就能看见 eax,ebx 这些寄存器。

不可见寄存器就是刚好相反。

2.上下文数据:

和进程强相关的数据,或者寄存器上保存的数据就是上下文数据。

相关应用场景

1.代码运行:

屏幕截图 2025-03-02 170730.png

背景介绍:

冯诺依曼体系结构明确了,外设只能跟物理内存进行交互,物理内存能跟 CPU 进行交互。当我们要执行二进制可执行程序时,OS 会先将该程序预加载到物理内存中,当程序被加载到内存中时,就有了自己对应的物理地址,但是为了程序员有一个更好的开发环境,OS 会假装为向每一个进程都分配 4 G 的空间,这就是虚拟地址空间,然后再通过页表将该虚拟地址空间和物理内存构建一个映射关系,这样做的好处是可以通过页表和 CPU 中 MMU 来防止有越界访问之类的非法行为,可以直接拦截。在编码的过程中,我们可以看到每一个变量都有自己的空间,也就是说明了我们的编译器也支持虚拟地址空间这样的方式,因此,生成的二进制可执行程序内部也必然是支持这样的方式的,那么此时加载到内存中的程序有了两种地址:虚拟地址,物理地址。但是其内部还是采用的虚拟地址的方式来进行操作,因为 CPU 也是用的虚拟地址(这个在查看代码的汇编语言的时候就能够看到,CPU 中的部分寄存器是跟当前进程强相关的,而进程中的代码就是采用的虚拟地址空间),因此这样都采用虚拟地址,OS 运行代码的时候可以采用统一的视角,降低了编写 OS 程序员编写代码的成本,也使内存跟代码运行上的一定的解耦,也在某种意义上实现了多态。

当进程调用系统调用的时候,会先将 CR3 中的值由 3 设为 0,然后再跳入到内核级空间中,然后寻找到该系统调用的虚拟地址,通过内核级页表来找到其在物理内存中的地址,随后执行对应的系统调用,完成后再将 CR3 由 0 设为 3,然后返回到上一次运行的位置处。

注意:物理内存中只有一份系统调用,因为无论是哪个进程调用系统调用,其内部的函数都是一样的,所以内存中不需要多份的系统调用。

2.信号运行:

屏幕截图 2025-03-02 193445.png

当进程出现异常,系统调用,中断等情况的时候,进程会从用户态转化为内核态,当该操作完成的时候,由于权限的切换会花费一定的时间,所以进程不会直接返回,而是先检查信号,假如该信号对应的 block 为 1,则直接往下检查,block 为 0 且 pending 为 1,则调用对应的动作,这里我直接讲调用自定义函数,因为内核态的权限过高,所以,假如自定义函数中有恶意操作的话,可能会对 OS 造成危害,为了杜绝这种情况,进程会先且回用户态,等到自定义函数结束,则继续切回内核态,便调用 sys_sigreturn(),切换为用户态,返回原来的位置继续运行代码。

系统调用 sigaction()

int sigaction(int signum, const struct sigaction *_Nullable restrict act, struct sigaction *_Nullable restrict oldact);

该函数描述:The sigaction() system call is used to change the action taken by a process on receipt of a specific signal.

翻译:sigaction()系统调用用于更改进程在收到特定信号时执行的操作。

这里我们可以发现其功能和上面的 signal() 很像,因为 signal() 就是 sigaction() 封装而来的。

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); 这个就是我们的设置的自定义函数,跟 signal() 中的一样。

sigset_t sa_mask; 这个数据类型是信号集,在这里设置自定义操作的时候,还可以设置相应的信号屏蔽字。

使用实例:

sigaction.cc:
#include <iostream>
#include <signal.h>
#include <unistd.h>

#define SIG_MASK 3

void myhandler(int signum)
{
    pid_t pid = getpid();
    std::cout << "signum: " << signum << " pid: " << pid << std::endl;
    sleep(5);
    exit(1);
}

int main()
{
    struct sigaction ac, oac;
    ac.sa_handler = myhandler;
    sigaddset(&ac.sa_mask, SIG_MASK);
    sigaction(SIGINT, &ac, &oac);
    while(true) sleep(1);    
    return 0;
}

终端:
wjy@VM-4-8-ubuntu:~/blog_signal$ ./sigaction 
^Csignum: 2 pid: 1836125
^\

可以发现 3 号信号被屏蔽了,向进程发送 3 号信号没有退出,而 2 号信号的动作也被成功设置了。

可重入函数

我们先一起来看一个例子:

屏幕截图 2025-03-02 202725.png

像图上这种情况,main()函数没有错,信号对应的操作没有错,但是最后因为执行流引发的代码运行的未知问题,我们对这样的 insert() 函数为不可重入函数,假如最后没有出问题,则称 insert() 函数为可重入函数。