Linux的进程信号 -- 信号产生,信号保存,信号捕捉,硬件中断,内核态和用户态,可重入函数,volatile,SIGCHLD

发布于:2025-03-26 ⋅ 阅读:(11) ⋅ 点赞:(0)

目录

1. 认识信号

1.1 信号的定义和基本结论

1.1.1 查看信号

1.2 技术应用角度的信号

1.2.1 一个样例

1.2.2 系统调用 signal 函数

1.3 信号的处理

2. 信号的产生

2.1 通过终端按键产生信号

2.1.1 基本操作

2.1.2 理解操作系统如何得知键盘信号

2.1.3 初步理解信号起源

2.2 使用 kill 命令向进程发信号

2.3 使用函数产生信号

2.3.1 kill函数

2.3.2 raise函数

2.3.3 abort函数

2.4 由软件条件产生信号

2.4.1 基本alarm验证 -- 体会IO效率问题

2.4.2 如何理解软件条件

2.4.3 简单快速理解系统闹钟

2.5 硬件异常产生信号

2.5.1 模拟除以0错误

2.5.2 模拟野指针

2.5.3 子进程退出core dump

2.5.4 Core Dump -- 核心转储

3. 信号的保存

3.1 信号其他的相关常见概念

3.2 信号在内核中的表示

3.3 sigset_t

3.4 信号集操作函数

3.4.1 sigprocmask函数

3.4.2 sigpending函数

3.4.3 使用上述函数的demo

4. 信号的捕捉

4.1 信号的捕捉流程

4.2 sigaction函数

4.3 操作系统时怎么运行的

4.3.1 硬件中断

4.3.2 时钟中断

4.3.3 死循环

4.3.4 软中断

4.4 如何理解内核态和用户态

5. 可重入函数

6. volatile 关键字

7. SIGHLD信号


1. 认识信号

1.1 信号的定义和基本结论

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

        基本结论:

                1. 进程在信号还没有产生的时候,早就知道信号该如何处理了。

                2. 信号的处理不是立即进行处理的,而是在合适的时候进行处理的。

                3. 操作系统在被设计的时候,就早已经内置了进程对于信号的识别和处理方式

                4. 产生信号的信号源是非常多的。

1.1.1 查看信号

        使用 kill -l 命名可以查看信号。

        这里只关系前面31个信号,后面的信号为实时信号,这里不做讨论。 使用 man 7 signal 可以在手册中查看各个信号的默认处理动作。

1.2 技术应用角度的信号

1.2.1 一个样例

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

using namespace std;

int main()
{
    while(true)
    {
        cout << "I'm a process, I am waiting signal! " << endl;
        sleep(1);        
    }
    return 0;
}

        在终端启动该进程的时候,默认该进程为一个前台进程,然后按下 Ctrl + c, 通过键盘产生一个终止进程信号,发给目标前台进程,目标前台进程收到该信号,进而导致进程终止。 

        其实,Ctrl + c 的本质是向前台进程发送 SIGINT 信号,即2号信号,下面通过一个系统调用函数进行证明。

1.2.2 系统调用 signal 函数

signal函数

头文件:
    #include <signal.h>

原型:
    typedef void (*sighandler_t)(int);
    sighandler_t signal(int signum, sighandler_t handler);

参数说明:
    signum:信号编号,或者信号名称
    handler:sighandler_t类型的函数指针,表示信号的处理动作。

返回值:
    返回signum信号上次处理函数的函数地址。

功能:
    signal函数将signum信号的处理动作修改为handler。

#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>

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

int main()
{
    signal(2, handler);
    while(true)
    {
        std::cout << "I'm a process, I am waiting signal! " << std::endl;
        sleep(1);        
    }
    return 0;
}

        这里使用signal函数将2号信号的默认处理动作改为打印一段字符串,再次按下 Ctrl + c 时,执行动作就从退出进行变为了打印字符串。 

知识点1:
        signal函数仅仅是设置了特定信号的处理方式,并不是直接调用处理动作。如果后续特定信号没有产生,设置的捕捉函数永远也不会被调用。

知识点2:

        Ctrl + c 产生的信号只能发给前台进程。启动进程命令时在后面加上 “&” 可以放到后台运行。

知识点3:
        前台进程在运行过程中随时可以按下Ctrl + c 产生一个信号,所以一个前台进程的用户控件代码执行到任何地方都有可能收到2号信号而终止,所以信号相对于进程的控制流程来说时异步的。

知识点4:

        (1)jobs:查看所有的后台任务。(2)Ctrl + z:将前台进程切换到后台,并停止进程。(3)fg + 任务号:将特定的进程提到前台。(4)bg + 任务号:让后台进程恢复运行。

知识点5:
        什么叫做给进程发信号?

                在进程的结构体中,有一个位图,存储着各种信号是否被收到,当收到某中信号时,对应该信号的位图位置置为1。给进程发信号本质是修改目标进程存储收到信号的位图。

1.3 信号的处理

        进程对信号的处理有以下三种处理方式:(1)忽略此信号:收到信号后不做任何处理。(2)执行该信号的默认处理动作:收到信号后执行该信号的默认处理动作。(3)自定义捕捉信号:提供一个信号处理函数,使用signal函数将捕捉到的信号处理动作进行修改,要求内核在处理该信号时切换到用户态执行这个处理函数。 

2. 信号的产生

2.1 通过终端按键产生信号

2.1.1 基本操作

        Ctrl + c:给前台进程发送SIGNIT信号,即2号信号,终止进程。

        Ctrl + \:给前台进程发送SIGQUIT信号,即3号信号,终止进程并生成core dump文件,用于事后调试。

        Ctrl + z:给前台进程发送SIGTSTP信号,即20号信号,停止进程,将当前前台进程挂起到后台。

2.1.2 理解操作系统如何得知键盘信号

2.1.3 初步理解信号起源

        信号其实时从纯软件角度模拟硬件中断的行为,只不过硬件中断是发给CPU的,而信号是发给进程的,两者有相似性,但是层级不同。 

2.2 使用 kill 命令向进程发信号

        kill -信号编号 进程pid:向pid进程发送信号编号信号。

        常用的为 kill -9 pid,该命令用于杀掉pid进程,并且9号信号(SIGKILL)不能进行信号自定义捕捉

2.3 使用函数产生信号

2.3.1 kill函数

kill函数

头文件:
    #include<sys/types.h>
    #include<signal.h>

原型:
    int kill(pid_t pid, int sig);

参数说明:
    pid:发送信号的目标进程pid号。
    sig:发送的信号的编号。

返回值:
    发送成功返回0,失败返回-1。

功能:
    给pid进程发送sig信号。
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>

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

int main()
{
    for (int i = 1; i < 31; i++)
    {
        signal(i, handler);
    }
    while(true)
    {
        std::cout << "I'm a process, I am waiting signal! " << std::endl;
        int n = kill(getpid(), 2);
        sleep(1);        
    }
    return 0;
}

        上述代码自身循环向本进程发送2号信号。 

2.3.2 raise函数

raise函数

头文件:
    #include<signal.h>;

原型:
    int raise(int sig);

参数说明:
    sig:要发送的信号编号。

返回值:
    发送成功返回0,发送失败返回非0。

功能:
    给当前进程发送sig信号。

2.3.3 abort函数

abort函数

头文件:
    #include<stdlib.h>

原型:
    void abort(void);

功能:
    给当前进程发送6号信号,即使6号信号被捕捉依然异常终止,并生成core dump文件。
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <stdlib.h>

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

int main()
{
    for (int i = 1; i < 31; i++)
    {
        signal(i, handler);
    }
    while(true)
    {
        std::cout << "I'm a process, I am waiting signal! " << std::endl;
        int n = kill(getpid(), 2);
        sleep(1);        
        abort();
    }
    return 0;
}

        上述代码对31个信号进行自定义捕捉,然后先发送2号信号,休眠1秒调用abort函数,执行自定义捕捉函数,并且退出进程。 

2.4 由软件条件产生信号

        这里介绍 alarm 函数和 SIGALRM 信号。 

alarm函数

头文件:
    #include<unistd.h>

原型:
    unsigned int alarm(unsigned int seconds);

参数说明:
    seconds:seconds表示秒数。若 seconds 为 0,表示取消上次的闹钟,返回值为上次闹钟剩余时间。

返回值:
    返回上一个alarm函数剩余的秒数,如果上一个 alarm 没有剩余时间,则返回0。

功能:
    给调用该函数的进程过seconds秒发送一个SIGALRM(14号)信号。

         调用 alarm 函数可以设定一个闹钟,告诉内核在 seconds 秒之后给当前进程发送 SIGALRM 信号,该信号的默认处理动作使终止当前进程

2.4.1 基本alarm验证 -- 体会IO效率问题

        用下面两份代码通过alarm函数来体会IO的速率。因为打印是向字符文件进行打印,所以打印是IO操作。

//test_1.cpp

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

int main()
{
    int count = 0;
    alarm(1);
    while(true)
    {
        std::cout << "count: " << count << std::endl;
        count++;
    }
    return 0;
}
//test_2.cpp

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

long long count = 0;

void handler(int signumber)
{
    std::cout << "count: " << count << std::endl;
    exit(0);
}

int main()
{
    signal(SIGALRM, handler);
    alarm(1);
    while(true)
    {
        count++;
    }
    return 0;
}

         第一份代码是每次count++都进行打印,是IO操作多的代码。第二份代码是count++一秒后,打印一次。

         通过上述两份代码的对比,可以看出IO操作的效率相对是非常低的。

2.4.2 如何理解软件条件

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

2.4.3 简单快速理解系统闹钟

        所谓的系统闹钟在内核当中就是一种数据结构,设置闹钟就是创建闹钟对象,每个闹钟都有自己的过期时间,并且有当闹钟超时对应的的执行方法。        

        内核中定时器数据结构:

struct timer_list 
{
    struct list_head entry;
    unsigned long expires;    //过期时间
    void (*function)(unsigned long);    //对应执行方法
    unsigned long data;
    struct tvec_t_base_s *base;
};

        可以使用一个最小堆的结构来维护闹钟对象,堆顶每次都是过期时间最早的闹钟对象,当闹钟对象超时的时候,将该对象从堆顶取出,执行对应方法。 

2.5 硬件异常产生信号

        硬件异常本质是硬件错误被硬件检测以某种方式检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释为 SIGFPE 信号发送给进程。再比如当前访问了非法内存地址,MMU 会产生异常,内核将这个异常解释为 SIGSEGV 信号发送给进程。

2.5.1 模拟除以0错误

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

void handler(int signum)
{
    printf("catch a sig: %d\n", signum);
}

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

    while(true);
    return 0;
}

         当执行到除0指令时,会发送8号信号,但是因为修改了8号信号的默认处理动作,所以进程没有退出,当信号的处理动作执行完毕之后,除0指令会被再次执行,所以会循环的发送8号信号,导致上述结果。

        CPU中存在标志寄存器 EFLAGS,是由 32 或 64 个 bit 位组成,其中有一个 bit 位表示 CPU 当前计算是否溢出。当发生除 0 错误的时候,操作系统识别到 CPU 中的标志寄存器有溢出错误,从而给当前进程发送 8 号信号。所以除 0 错误是由于硬件异常从而产生信号导致程序崩溃的。

2.5.2 模拟野指针

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

void handler(int signum)
{
    printf("catch a sig: %d\n", signum);
}

int main()
{
    signal(SIGSEGV, handler);
    sleep(1);
    int *p = nullptr;
    *p = 100;
    
    while(1);
    return 0;
}

         这里将指针p初始化为空指针,尝试对空指针进行解引用操作并赋值,但空指针并不指向有效的内存地址,对其进行解引用操作会访问非法内存,这种行为就会触发段错误。便会向该进程发送 SIGSEGV (11)信号。

        CPU 中有个CR3寄存器,该寄存器保存的是页目录表的物理地址。CPU中还有一个内存管理单元MMU,其中有一个功能就是进行虚拟地址到物理地址的转换。用户层使用的都是虚拟地址,上述错误是 CPU 将虚拟地址给到 MMU ,然后 MMU 通过 CR3 中的页表进行地址转换,然后因为页表中并没有 0 号地址的映射关系,所以转换失败,操作系统识别到 MMU 硬件报错,所以给当前进程发送 11 号信号,导致进程崩溃。

        上述两种异常本质是操作系统识别到用户代码导致的硬件出错,然后操作系统识别错误类型之后向进程发送对应信号。

2.5.3 子进程退出core dump

        wait和waitpid都有一个status参数,该参数是一个输出型参数。如果传递NULL,表示不关心子进程的退出状态信息。status不能简单的当作整型来看待,可以当作位图来看待,具体如上图(只研究status低16位)。

        进程正常退出时只有8-15位有效,表示进程退出码。

        进程异常退出或是被信号杀掉时,0-6位表示信号编号,而第7位为core dump 标志位。当core dump 标志位为 1 时,表示进程异常退出后会在工作目录下产生 core 文件,用于事后调试,当 core dump 标志位为 0 时,表示不会产生 core 文件。

2.5.4 Core Dump -- 核心转储

        SIGINT(2)信号默认处理动作是终止进程,SIGQUIT(3)信号默认处理动作是终止进程并Core Dump。

        当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到当前路径下,文件名通常是core,这就叫做Core Dump。事后可以用调试器检测 core 文件以查清错误原因,这叫做事后调试(Post-mortem Debug)。 使用 -g 选项进行编译,然后使用 gdb 进行调试,使用命名 core-file [core文件名] 可以直接定位到出错的行号。

        因为云服务器大部分情况下都是生产环境,大部分程序一出现问题就会自动重启,这样就会产生大量的 core 文件,会很浪费磁盘空间,所以在云服务器上,core dump 功能默认是禁止的。

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

        在开发调试阶段可以用 ulimit 命令改变这个限制,允许产生core文件。ulimit -a 命令用于显示当前用户的所以资源限制设置。ulimit -c [size] 命令可以改变core文件的大小为size,就可以打开 core dump 功能。

知识点1:
        上述所说的所有信号产生,最终都要由操作系统来进行执行,因为操作系统是进程的管理者,也是软硬件资源的管理者。 

3. 信号的保存

3.1 信号其他的相关常见概念

        1. 实际执行信号的处理动作称为信号递达(Delivery)。

        2. 信号从产生到递达之间的状态,称为信号未决(Pending)。

        3. 进程可以选择阻塞(block)某个信号。

        4. 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。

        5. 阻塞和忽略是不同的,阻塞是将产生的信号一直位于未决状态,而忽略是信号递达的一种处理动作

3.2 信号在内核中的表示

        每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针数组,表示每个信号的处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。

        如上图所示。SIGHUP 信号未阻塞也未产生过,当它递达时执行默认处理动作 SIG_DFL。SIGINT 信号产生过,但是正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但是没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。SIGQUIT 信号未产生过,一旦产生 SIGQUIT 信号将被阻塞,它的处理动作是用户自定义函数 sighandler。

        在一个进程中,上述 block 可以理解为一个 32 位的整数,就是一个位图,每一个 bit 位表示对应信号是否被阻塞。pending 也是一个 32 位的位图,每一个 bit 位表示对应信号是否未决

        如果在进程解除对某信号的阻塞之前这种信号产生过多次,POSIX.1 允许系统递送该信号一次或多次。而在 Linux 系统中,常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。

3.3 sigset_t

        从上图看,每个信号只有一个 bit 的未决标志,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型 sigset_t 来存储

        sigset_t 称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态。在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞;在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),凡是在信号屏蔽字中的信号,都处于阻塞状态,不能被递达。

        sigset_t 也可以简单的理解一个整型,每个bit位的0,1表示该 bit 位对应的信号是否在该信号集中。

3.4 信号集操作函数

        sigset_t 类型对于每种信号用一个bit表示“有效”或“无效”状态,使用者只能调用以下函数来操作 sigset_t 变量,而不应该对它的内部数据做任何解释,比如用 printf 直接打印 sigset_t 变量没有任何意义。

sigemptyset函数
头文件:
    #include <signal.h>

原型:
    int sigemptyset(sigset_t* set);

参数说明:
    set:指向一个信号集。

返回值:
    成功时返回0,失败返回-1。

功能:
    sigemptyset函数初始化set指向的信号集,使其中所有信号的对应bit位清零。
    
sigfillset函数
头文件:
    #include <signal.h>

原型:
    int sigfillset(sigset_t* set);

参数说明:
    set:指向一个信号集。

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

功能:
    初始化set所指向的信号集,使其中所有信号的对应bit位置1。
sigaddset函数
头文件:
    #include <signal.h>

原型:
    int sigaddset(sigset_t* set, int signo);

参数说明:
    set:指向一个信号集。
    signo:信号编号。

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

功能:
    给set指向的信号集中,signo信号的bit位置1,表示给信号集中加入signo信号。
sigdelset函数
头文件:
    #include <signal.h>

原型:
    int sigdelset(sigset_t* set, int signo);

参数说明:
    set:指向一个信号集。
    signo:信号编号。

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

功能:
    给set指向的信号集中signo信号的对应bit位置0,表示将signo信号从该信号集中去除。
sigismemeber函数
头文件:
    #include <signal.h>

原型:
    int sigismember(const sigset_t* set, int signo);

参数说明:
    set:指向一个信号集。
    signo:信号编号。

返回值:
    如果该信号对应的bit位在信号集中为1,返回1,反之,返回-1。

功能:
    测试signo信号是否在信号集中,如果signo在set指向的信号集中,返回1,如果不在返回-1。

3.4.1 sigprocmask函数

sigprocmask函数
头文件:
    #include <signal.h>

原型:
    int sigprocmask (int how, const sigset_t* set, sigset_t* oset);

参数说明:
    how:表示信号集合中的信号被设置之后的状态。
    set:指向被设置的信号集合。
    oset:指向修改之前的信号集合。

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

功能:
    将信号集中有的信号修改为对应的状态。

        下表说明how参数的可选值。mask 指的是当前进程 block 表表示的信号屏蔽字,set 表示待设置的信号集。

        如果调用sigprocmask解除了当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。 

3.4.2 sigpending函数

sigpending函数
头文件:
    #include <signal.h>

原型:
    int sigpending(sigset* set);

参数说明:
    set:set指向一个信号集。

返回值:
    成功返回0,出错返回-1。

功能:
    读取当前进程的未决信号集,通过set参数传出。

3.4.3 使用上述函数的demo

#include <iostream>
#include <unistd.h>
#include <cstdio>
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>

void PrintPending(sigset_t& pending)
{
    std::cout << "current process[" << getpid() << "]的pending: ";
    for (int signo = 31; signo >= 1; signo--)
    {
        if (sigismember(&pending, signo))
        {
            std::cout << 1;
        }
        else
        {
            std::cout << 0;
        }
    }
    std::cout << std::endl;
}

void handler(int signum)
{
    std::cout << signum << "号信号被递达!!!" << std::endl;
    std::cout << "-----------------------" << std::endl;
    sigset_t pending;
    sigpending(&pending);
    PrintPending(pending);
    std::cout << "-----------------------" << std::endl;
}

int main()
{
    // 0.捕捉2号信号
    signal(2, handler);

    // 1.屏蔽2号信号
    sigset_t block_set, old_set;
    // 将信号集初始化为没有任何信号
    sigemptyset(&block_set);    
    sigemptyset(&old_set);
    // 给block_set信号集添加2号信号
    sigaddset(&block_set, SIGINT);
    // 将信号集合设置到进程的block表中,修改当前进程的内核block表,完成对2号信号的屏蔽
    // 并且将原先进程的信号集给到old_set中
    sigprocmask(SIG_BLOCK, &block_set, &old_set);

    int cnt = 15;
    while(true)
    {
        // 2.获取当前进程的pending信号集
        sigset_t pending;
        sigpending(&pending);

        // 3.打印pending信号集
        PrintPending(pending);
        cnt--;

        // 4.解除对2号信号的屏蔽
        if (cnt == 0)
        {
            std::cout << "解除对2号信号的屏蔽!!!" << std::endl;
            // 将原先的信号集合(没有任何信号),设置掩码
            // 就是不给任何信号设置掩码,等于解除任何信号的信号屏蔽字
            sigprocmask(SIG_SETMASK, &old_set, &block_set);
        }

        sleep(1);
    }

    return 0;
}

        上述代码首先对2号信号进行捕捉,修改2号信号的处理方式,然后将2号信号进行屏蔽。每秒打印该进程对应的pending表。当2号信号被屏蔽的时候,使用Ctrl + c 发送2号信号,可以pending表的次低位会被置为1,当解除2号信号的拼搏的时候,2号信号的处理动作还没有做完就会修改pending表,后续再次触发2号信号打印的pending表次低位都为0,表示2号信号没有处于未决状态。 

        当2号信号被屏蔽的时候,没有使用Ctrl + c发送2号信号,该进程的pending表如上图1所示。当被屏蔽的时候使用Ctrl + c 发送2号信号,pending表如上图2所示。当15秒后解除2号信号的屏蔽的时候,2号信号立即被递达,在2号信号处理方式还未结束的时候 pending 表的次低位就被置为0,所以 pending 表的改变在信号被递达的时候就改变,并不是处理方法执行完才改变,如上图的3所示,后续再次发送2号信号,则2号信号都会被立即递达,所以后续次低位都为0。

4. 信号的捕捉

        信号的递达有三种方式:(1)忽略此信号。(2)执行该信号的默认处理动作。(3)自定义捕捉信号.

        信号处理的合适时间是:进程从内核态返回到用户态的时候,对进程中的 block 表和 pending 表进行检测,如果有未阻塞且未决的信号,则对该信号进行处理。

        一个信号被递达后,如果信号的处理动作是用户自定义的函数,对信号的处理流程就称为信号捕捉。

4.1 信号的捕捉流程

        由于信号自定义的处理函数的代码在用户空间,处理过程比较复杂。一个程序被操作系统高频调度,只有操作系统才能进行调度,所以进程调度需要用户态和内核态之间的转换。而且程序中会涉及到很多的系统调用,如prinf函数需要向显示器打印信息,而显示器是一种硬件资源,只能由操作系统进行操作,所以prinf函数中肯定是封装了系统调用的。而进行系统调用的时候,需要切换到内核态才能调用。所以程序在运行的时候是高频的不断的在用户态和内核态之间进行转换的

        在执行主控制流程的某条指令时,因为终端、异常或系统调用进入内核,处理完异常等准备回到用户模式之前,会先处理当前进程中可以递送的信号。所以如果处理的信号的处理动作是用户自定义的,需要从内核态切换到用户态。

        下图表示了信号捕捉的流程:

        1. 当前进程执行的某条指令因为中断、异常或系统调用时,进程会陷入到内核中。

        2. 陷入内核后,处理完异常等之后,会进行 pending 表和 block 表的查看,处理当前信号中可以递送的信号。

        3. 如果当前处理的信号的处理方式是用户自定义的,则会从内核态切换到用户态,执行信号的处理函数。

        4. 信号处理完返回时,执行特殊的系统调用 sigreturn 系统调用再次进入内核。

        5. 在内核中返回上次用户模式下中断的地方,从内核态切换到用户态,并继续向下执行。

        所以对信号的捕捉可以用一个无穷大的符号进行表示:

4.2 sigaction函数

        sigaction的作用和signal差不多,都是修改信号对应的处理方式。

sigaction函数
头文件:
    #include <signal.h>

原型:
    int sigaction (int signum, const struct sigaction *act, struct sigaction *oact);

参数说明:
    signum:信号编号。
    act:指向signaction结构体,若act指针非空,则根据act修改该信号的处理动作。
    oact:(输出型参数)指向signaction结构体,若oact非空,则通过oact传出该信号原来的处理动作。

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

功能:
    将signum信号的处理动作修改为act指向的处理动作,并将原本的处理动作返回给oact。
struct sigaction 
{
    void     (*sa_handler)(int);
    void     (*sa_sigaction)(int, siginfo_t *, void *);
    sigset_t   sa_mask;
    int        sa_flags;
    void     (*sa_restorer)(void);
};

         该结构体的第一个字段就等于 signal 函数传入的参数。

        为什么可以使用 signal 对信号的处理动作进行修改,还要有 sigaction 函数。原因如下:

        当使用 sigaction 函数对信号的处理动作进行修改后,该信号被捕捉后,就会把进程的中该信号 block 表中的bit为置为1,表示阻塞该信号。这样就能保证,在进行该信号处理的时候,不会有相同的信号再次被递达,被阻塞到当前处理结束为止。

        如果在调用信号处理函数时,除了想自动屏蔽当前信号,还希望自动屏蔽另外一些信号,则可以用 sa_mask  字段说明这些需要额外屏蔽的信号,当信号处理函数返回时,自动恢复原来的信号屏蔽字。

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

void handler(int signum)
{
    std::cout << "hello signal: " << signum << std::endl;
    while(true)
    {
        sigset_t pending;
        sigpending(&pending);
        for (int i = 31; i >= 1; i--)
        {
            if (sigismember(&pending, i))
            {
                std::cout << "1";
            }
            else
            {
                std::cout << "0";
            }
        }
        std::cout << std::endl;
        sleep(1);
    }
    // exit(0);
}

int main()
{
    struct sigaction act, oact;
    act.sa_handler = handler;
    sigaction(SIGINT, &act, &oact);

    while(true)
    {
        std::cout << "hello world" << std::endl;
        sleep(1);
    }
    
    return 0;
}

        在上述函数中,将 2 号信号的处理动作改为循环打印 pending 表,当第一次发送 2 号信号的时候,触发处理动作,打印 pending 表应该为全0,这是 2 号信号的处理动作还未完成,再次发送 2 号信号,此时 2 号信号就会处于未决状态。 

4.3 操作系统时怎么运行的

4.3.1 硬件中断

        在计算机主板上会集成一个中断控制器,用于管理外部设备发出的中断号。

        例如从键盘上输入一个字符到显示器上,就是外部设备键盘向中断控制器(如8259)发送了一个中断号,然后中断控制器就会通知 CPU,CPU 接到通知之后,会停下当前的动作,并保护当前调用进程的上下文,CPU 会根据操作系统中的中断向量表(类似函数指针数组)查询该中断的处理方式,然后进行中断处理,处理完毕之后,恢复之前进程的上下文,继续之前的工作。

        由外部设备触发的中断系统运行流程,叫做硬件中断。

知识点1:

        中断向量表就是操作系统的一部分,启动就加载到内存中了。

知识点2:
        通过外部硬件中断,操作系统就不需要对外设进行任何周期性的检测或者轮询,由外部中断控制器通知CPU外部设备的状态。

知识点3:

        寄存器不仅在CPU中存在,其实在很多外设上也存在对应的寄存器。比如磁盘,在和内存交互的时候,内存将对应的地址和数据给到磁盘的控制器中(内置寄存器)然后又磁盘中的控制器将数据写到磁盘对应位置。

4.3.2 时钟中断

        进程可以在操作系统的指挥下被调度、被执行,那么操作系统也是软件,操作系统是被谁推动着运行的呢?

        当没有中断到来的时候,操作系统什么都没做。其实在主板上还集成了一个时钟源,当代的时钟源已经集成到了CPU中,这个时钟源以固定的频率发送特定的中断号,这种通过时钟源发送中断号的方式叫做时钟中断。

        每次时钟发送中断信号,驱动着操作系统运行,然后操作系统运行起来后才进行进程之间的自动调度或其他工作。所以操作系统是躺在时钟中断上的一款软件。

知识点1:

        CPU中的主频就表示的是该时钟源的频率。 时钟源产生的时钟信号频率决定了CPU执行指令的速度,所以主频越高,在单位时间内CPU能够完成的指令数通常就越多,CPU的运算速度相对就越快。

        但是CPU的执行速度不仅由主频决定,还和执行的指令集有关。

知识点2:
        一个进程的时间片,就是一个进程在这次被调用的时候使用多少个时钟周期。

4.3.3 死循环

        操作系统本身不做任何事情,需要什么功能就向中断向量表里面添加方法即可,所以操作系统的本质就是一个死循环,由时钟进行驱动,对应的功能都在中断向量表中。 

4.3.4 软中断

        上述的硬件中断和时钟中断,都是由硬件设备进行触发的。当然还有因为软件原因触发的中断,称为软中断

        软中断是由软件条件在 CPU 内部产生中断号,然后查询中断向量表执行对应的中断处理。

        操作系统不提供任何系统调用接口,操作系统只提供系统调用号。我们用到的系统调用接口都是glibc封装的。系统调用函数中封装了 int 0x80 或者 syscall 这种指令集,系统调用的过程,其实就是先执行指令 int 0x80 或者 syscall 陷入内核,本质就是触发软中断,然后 CPU 就会自动执行系统调用函数的处理方法,而这个方法会根据系统调用号(数组下标),自动查系统调用表,执行对应的方法。

        上述通过 CPU 主动触发的中断叫做软中断,比如系统调用中的 int 0x80 或者 syscall 叫做陷阱。

        而由于除 0 错误导致的计算溢出、野指针导致的地址转换失败以及缺页中断由软件导致的硬件出错叫做异常

4.4 如何理解内核态和用户态

        这里以 32 位的机器为例,用户的进程地址空间的范围是 [0, 4GB]。其中 [0, 3GB] 是用户空间,[3, 4GB] 是内核空间。

        1. 在物理内存中,每一个进程都有自己的代码和数据,通过用户页表就可以将自己在物理内存中的代码和数据映射到自己进程的虚拟地址空间中。

        2. 操作系统也是软件,所以当计算机启动的时候,会将操作系统加载到内存当中。每个进程虚拟地址空间中的内核空间,就是通过内核页表,将操作系统映射到每一个进程的虚拟地址空间中。所以每一个进程都可以找到操作系统。这里多进程对操作系统的映射,就和多进程对动态库的映射一样,在物理内存中只有一份操作系统的代码和数据,但是通过内核页表映射到每一个进程的地址空间中。

        3. 所以进程访问用户空间的时候,就处于用户态,当进程访问内核空间的时候,就处于内核态。

        4. 在 CPU 中有一个 cs 段寄存器,其中该寄存器中的低两位,如果都是 0,表示处于用户态,如果都是 1,表示处于内核态。当进程执行系统调用的时候,执行 int 0x80 或者 syscall 指令的时候,将 cs 段寄存器的低两位置 1,此时就代表陷入内核。

5. 可重入函数

        如上图,在 main 函数中,调用 insert 函数向一个全局链表 head 中通过头插方式插入节点 node1,插入操作分两步,第一步是将 node1 的 next 指针指向 head 指向的链表,第二步是将 head 指向 node1节点。当刚做完第一步的时候,因为中断或者异常等使进程陷入内核,处理完之后返回用户态之前检测到有信号待处理,于是切换到用户自定义的处理函数 sighandler 中,在 sighandler 中也调用 insert 函数向同一个 head 链表中插入 node2 节点,两步都做完之后从sighandler 返回内核态,再返回用户态,从 main 函数中 insert 的第二步往下执行。这样就会导致只有 node1 节点插入到了链表当中。而 node2 没有在链表中就会导致内存泄漏问题。

        像上述一样,一个函数被不同的控制流程调用,在第一次调用的时候还没有返回就再次进入该函数,这就称为重入。 

        如果该函数执行的时访问一个全局资源,有可能因为重入而造成错乱,这种函数就称为不可重入函数。

        如果一个只访问自己的局部变量或参数,则称为可重入函数。

        如果一个函数符合以下条件之一则是不可重入的:(1)调用 malloc 或 free,因为 malloc 也是用全局链表来管理堆的。(2)调用标准 I/O 库函数。标准 I/O 库函数的很多实现都以不可重入的方式使用全局数据结构。

6. volatile 关键字

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

int flag = 0;

void handler(int signum)
{
    std::cout << "更改全局变量flag -> 1" << std::endl;
    flag = 1;
}

int main()
{
    signal(2, handler);

    while(!flag);
    std::cout << "process quit normal! " << std::endl;
    return 0;
}

        这里先给出一份 demo 代码,这段代码表示,程序启动会进入一个死循环,然后发送 2 号新号处理自定义捕捉动作之后,修改全局变量 flag 的值,程序跳出 while 循环正常结束程序。

        1. 标准默认情况下,发送 2 号信号, 修改 flag = 1,跳出 while 循环,程序正常退出。

        2. 编译器优化情况下,发送 2 号信号,修改 flag = 1,但是没有跳出 while 循环,进程继续运行不退出。为什么会这样呢?原因是 while 循环检查 flag 并不是内存中最新的被修改过的 flag,这就存在了数据二义性的问题。while 检测的 flag 其实已经因为优化被放在了 CPU 的寄存器中了,因为在编译的时候,编译器识别到该 while 循环代码块中没有对 flag 进行修改,所以编译之后,flag 就直接被放到了 CPU 寄存器中,而每次 while 循环检测的时候,不从内存中读取 flag 的值,而是用寄存器中的 flag 进行判断,所以导致下图中的情况。

        上述代码使用 gcc 中 O1 的优化级别,就会出现优化后的现象。优化级别 O1 < O2 < O3。

        使用 volatile 修饰全局变量 flag,这样之后就算优化级别开到最大,每次访问 flag 变量的时候都会进行访存。 

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

volatile int flag = 0;

void handler(int signum)
{
    std::cout << "更改全局变量flag -> 1" << std::endl;
    flag = 1;
}

int main()
{
    signal(2, handler);

    while(!flag);
    std::cout << "process quit normal! " << std::endl;
    return 0;
}

        volatile 作用:保持内存的可见性,告知编译器,被该关键字修饰的变量不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作。 

7. SIGHLD信号

        其实,子进程在终止时会给父进程发送 SIGCHLD 信号,进程对该信号的默认处理动作是SIG_DFL(所有信号的默认处理动作都是 SIG_DFL),而该信号的SIG_DFL 对应的是 Ign。下列先用一个程序来验证子进程终止时会向父进程发送 SIGCHLD 信号。

#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cstdlib>
#include <sys/types.h>
#include <sys/wait.h>

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

int main()
{
    signal(SIGCHLD, handler);

    pid_t id = fork();
    if (id == 0)
    {
        std::cout << "I am child" << std::endl;
        sleep(1);
        exit(1);
    }

    waitpid(id, nullptr, 0);
    return 0;
}

        下面代码给出一种使用信号处理的方式对子进程进行回收。 

#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cstdlib>
#include <sys/types.h>
#include <sys/wait.h>

void handler(int signum)
{
    while(true)
    {
        pid_t n = waitpid(-1, nullptr, WNOHANG);    //若回收的进程不处于僵尸状态,则不会阻塞在此处,返回值为0
        if (n == 0) 
        {
            break;
        }
        else if (n < 0)
        {
            std::cout << "waitpid error" << std::endl;
            break;
        }
    }
}

int main()
{
    signal(SIGCHLD, handler);

    for (int i = 0; i < 10; i++)
    {
        pid_t id = fork();
        if (id == 0)
        {
            sleep(3);
            std::cout << "I am child" << std::endl;
            if (i < 6)
                exit(1);
            else
                pause();
        }
    }

    while(true)
    {
        std::cout << "I am father" << std::endl;
        sleep(1);
    }
    return 0;
}

        上述代码会在 3 秒后回收前 6 个子进程,后续子进程可以通过 kill -9 命令进行手动终止。 

        想要不产生僵尸进程还有另外一种方式:父进程调用 sigaction 将 SIGCHLD 的处理动作设置为 SIG_IGN,这样 fork 出来的子进程在终止时会自动清理掉。此方法对于 Linux 可用,但不保证在其他 UNIX 系统上可用。

        但是 SIGCHLD 的默认处理动作就是忽略,这里设置 SIG_IGN 也是忽略。这里要说明一下,SIGCHLD 信号的 SIG_DFL 是 Ign 和SIG_IGN 不是同一个处理动作

        所以在没有设置 SIG_IGN 之前,SIGCHLD 的处理动作是 SIG_DFL,而这个信号的 SIG_DFL 是 Ign,设置之后, SGICHLD 的处理动作是 SIG_IGN。