【Linux】信号

一.信号概念

在日常生活中,我们定的闹钟、上课铃声、电话铃声、红绿灯、敲门声等等都是信号。当我们收到这些信号时,就会中断我们正在指定的事情,转而执行信号对应的事件。

那么信号其实是一种用来中断我们人正在做的事情的异步通知机制。而异步与同步相反,即信号的产生与我们正在执行的事情没有关系,两者之间不会互相影响。

而在Linux操作系统中,我们在前面学习进程时,或多或少都听过信号这个概念。

对于一个死循环的程序来说,当他启动起来就是一个进程,而我们可以利用ctrl+c来使该进程退出,而这个ctrl+c其实就是向该进程发送一个信号,而该进程处理该信号的动作就是杀掉程序。

对于Linux操作系统来说,信号是一种给进程发送事件的异步通知机制。信号是发送给进程的,信号的产生相对于进程的运行也是异步的。

对于信号有以下几个结论:

  • 信号处理,进程在信号没有产生的时候,早就知道信号该如何处理了。(对于我们人来说,我们定了一个闹钟,在闹钟还没响的时候,我们就知道我们要干什么了,闹钟只是用来通知我们罢了)
  • 信号处理,不一定要立即处理,而是可以等到合适的时候在进行信号的处理。(既然不一定会立即处理,所以进程就得将信号记录下来,以免到了合适的时候把信号忘了)
  • 人能识别各种信号,是因为我们提前被教育过了(红灯停绿灯行...),进程也是如此,操作系统的设计者设计进程时,已经在其内部内置了对于信号的识别和处理机制。
  • 在生活中,信号源非常多,对于进程也一样,给进程发送信号的信号源也非常多。

对于进程来说,信号机制的全过程分别是:信号的产生、信号的保存、信号的捕捉。下面我们先来了解一下信号的产生。

二.信号的产生

产生信号的方式有很多,用ctrl+c可以终止程序(键盘产生信号),我们在进程控制那里也可以使用kill -9命令杀死进程(系统命令产生信号)...下面我们利用键盘产生信号先宏观的认识一下信号产生的过程都发生了什么事情!

1.键盘产生信号

利用组合键ctrl+c给目标进程发送信号,而该信号的结果就是让进程结束(其实大多数信号的处理动作就是让进程终止)。首先当进程收到一个信号,要对信号进行处理,而对于信号有三个行为:

  • 默认行为:ctrl+c信号的默认行为就是让进程终止。
  • 自定义行为:我们可以在进程收到信号之后,让进程执行我们自己指定的行为
  • 忽略:即收到进程不做任何处理,继续做原来正在做的事情

如下图所示,这些信号对应的行为不是core、term就是ign。而core和term都是终止进程。但是这两者是有区别的。Core我们在进程等待时见过。

0x1.信号有哪些

既然信号可以产生,那么都有那些信号呢?我们可以利用kill -l查看所有的命令:

这些信号其实都是一些宏,而左边的数字就是该宏的值。这些信号一共只有62个,没有32和33号。而且34-64这些都是实时信号,我们不考虑。所以我们所要了解的信号只有前31个普通信号了。

而其实我们ctrl+c发送的信号就是2号信号!

0x2.证明ctrl+c == 2号信号

我们刚才说过,处理信号有三种动作:默认、自定义和忽略。我们可以将二号信号进行自定义捕捉,使其不在指定默认的终止进程的行为,这样我们在按下ctrl+c就应该会执行我们的自定义行为!

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

typedef void (*sighandler_t)(int);

sighandler_t signal(int signum, sighandler_t handler);

我们可以利用系统调用signal来自定义捕捉一个信号。

signum:指定要捕捉的信号

handler:指定该信号的行为。这个参数其实是一个函数指针类型,需要传入一个返回值为void,参数为int的函数。而当我们发送指定的信号时,就会执行该方法。

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

void handler(int signal)
{
    std::cout << "我是" << signal << "信号" << std::endl;
}

int main()
{
    signal(2, handler);
    while(true)
    {
        std::cout << "pid:" << getpid() << std::endl;
        sleep(1);
    }

    return 0;
}

说明:我们对2号信号进行自定义捕捉,当我们在发送二号信号的时候就会跳转到handler中,执行完毕后又会跳转到死循环继续执行。

如上图所示,我们先借助kill命令发送2号信号,确实执行了我们的自定义行为。当我们按下ctrl+c时可以看到确实也调用了自定义捕捉方法。综上所述,ctrl+c发送的就是2号信号!!!

但是当我们自定义捕捉了二号信号之后,我们就无法使用ctrl+c终止该进程了,而我们可以使用kill -9杀死该进程或者通过ctrl+\来终止进程。当然这两个本质上也是信号!!!

0x3.目标进程

我们使用ctrl+c给目标进程发送了信号,那么什么是目标进程呢?在Linux操作系统中,我们的进程分别前台进程和后台进程。

当我们直接./xxx时,该进程就为前台进程,而当我们以./xxx &,该进程就为后台进程。

而对于一个终端会话来说,只能有一个前台进程前台进程用来与用户进行交互 ,即可以从键盘上获取到用户输入的信息。前台进程会独占终端,用户无法运行其他命令。

而后台进程在一个终端会话中可以有多个,且无法与用户产生交互。就算此时后台进程在使用终端,用户依旧可以运行其他命令。

因为后台进程无法与用户进行交互,也就不能获取用户从键盘上输入的组合键,所以说,当一个进程成为后台进程后,我们无法通过组合键的方式终止该进程,只能使用kill -9杀死该进程。

所以,这里的目标进程就是前台进程!!!

对前后台进程的补充:

当我们fork创建子进程后,子进程的终端属性与父进程一致,父进程为前台,子进程也为前台,因为子进程会拷贝父进程的终端属性。

但是我们刚才又说,一个终端只能有一个前台进程,这样不就矛盾了么?实则不然,当父进程fork创建子进程后,父子进程会同属于一个进程组,而对于一个终端来说,只能有一个进程组在前台运行!!!

当父进程先退出,子进程此时就会被1号进程领养,并且自动转为后台进程。
当我们一登录,bash就会启动,而此时终端上唯一的前台进程就是命令行解释器bash。 当我们执行命令或者程序时,此时bash进程就会变为后台进程,所以此时我们输入命令,bash无法为我们进行解释。当该命令/程序执行完,bash就又会回到前台执行。这也就是为什么前后台进程输入命令时有不同的效果!!!
可以使用ctrl+z暂停程序,此时该进程就会程序后台进程,可以再使用bg命令使该后台进程继续执行!

我们可以使用jobs查看当前终端所有的后台进程

使用fd命令,可以使指定进程转为前台进程
对于前台进程来说,终端关闭,该前台进程也就结束了,而对于后台进程来说,终端关闭,该后台进程并不会结束,除非该后台进程依赖终端。

0x4.给进程发送信号

信号产生了之后并不一定会直接处理,所以信号需要被记录下来,那么记录到哪里呢?记录在进程的pcb中。

但是我们了解的普通信号有31个,那么进程是如何保存信号,并且区分不同信号的呢?通过位图!!!

在进程的pcb中有一个unsigned int sigs成员,该无符号整数的32个bit位除了最高位外,每一位都表示一个信号,而每一位的0/1则表示该进程是否收到该信号。

所以发送信号的本质,就是修改进程pcb中存储信号的位图的某个bit位。 而进程pcb是内核数据结构,修改位图,也就意味着要修改内核的数据。但是用户是不能直接访问内核数据的,所以修改bit位本质上还是得要操作系统自己执行。

但是用户有可能有发送指定信号的需求,所以操作系统就设计出了发送信号的系统调用。

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

int kill(pid_t pid, int sig);

但即使发送了信号,底层还是得操作系统自己去修改进程pcb的位图。

0x5.信号与通信

信号,是用户借助信号机制,通过操作系统对进程pcb位图的修改,达到控制进程的目的

而进程间通信则是两个进程之间进行数据的流动。

0x6.不可被自定义捕捉的信号

我们将1~31号信号全部都进行自定义步骤,然后借助shell脚本,向该进程发送1~31号信号,判断那些信号会被自定义捕捉,那些信号不会!

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

void handler(int signal)
{
    std::cout << "我是" << signal << "号信号" << std::endl;
}

int main()
{
    // 自定义捕捉所有的普通信号
    for(int i=1; i<32; ++i)
        signal(i, handler);

    while(true)
    {
        sleep(1);
        std::cout << "pid : " << getpid() << std::endl;
    }

    return 0;
}
bash 复制代码
i=1; while [ $i -le 31 ]; do kill -$i 598733 ;sleep 1 ; ((i++)); done

我们看上图,9号信号和19号信号是不可以被自定义捕捉的。 如果所有的信号都可以被自定义捕捉的话,那么如果一个病毒提前自定义捕捉了所有的信号,我们就无法将该病毒杀死了。

2.系统调用产生信号

0x1.kill

cpp 复制代码
NAME
       kill - send signal to a process

SYNOPSIS
       #include <sys/types.h>
       #include <signal.h>

       int kill(pid_t pid, int sig);

kill可以向指定进程发送指定的信号!

我们可以借助kill以及自定义捕捉,将所有的信号除(9,19外)都进行发送,并自定义捕捉

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

void handler(int signal)
{
    std::cout << "我是" << signal << "号信号" << std::endl;
}

int main()
{
    for (int i = 1; i < 32; i++)
        signal(i, handler);

    while (true)
    {
        std::cout << "pid:" << getpid() << std::endl;
        sleep(1);
        for (int i = 1; i < 32; i++)
            if (i != 9 && i != 19)
                kill(getpid(), i);
    }
    return 0;
}

0x2.raise

cpp 复制代码
NAME
       raise - send a signal to the caller

SYNOPSIS
       #include <signal.h>

       int raise(int sig);

向调用者发送指定命令

我们可以利用raise实现与kill相同的效果。

0x3.abort

cpp 复制代码
NAME
       abort - cause abnormal process termination

SYNOPSIS
       #include <stdlib.h>

       void abort(void);

使进程异常终止。该系统调用发送的命令就是SIGABRT

注意,调用abort函数时,即使我们已经将6号信号自定义捕捉,但是进程还是会结束,因为abort函数内部除了发送信号外,还内置了其他方法使进程结束!!!

4.系统命令产生信号

我们可以在命令行通过kill 命令向指定进程发送指定信号

bash 复制代码
kill -signumber -process

5.硬件异常产生信号

当我们的程序出现异常之后,程序就会崩溃退出。而其实程序崩溃退出也是收到了操作系统发送到信号。

我们现在接触的异常有两种:除0异常和野指针。这两种都会导致进程崩溃,那么它们分别收到了操作系统发送的那些信号呢?

如上图所示,除0异常和野指针分别收到了8号和11号信号,而它们的默认行为都是终止进程。

首先,我们已经明确,信号只能由操作系统发送给进程。那么也就是说,当我们的程序出异常了,操作系统知道了,然后根据进程异常的类型,发送指定的信号,使进程终止。

那么操作系统是怎么知道进程出现异常了呢?以除零异常为例:

操作系统是软硬件资源的管理者,而我们的程序最后是要跑在cpu上面的。而在cpu中,有一个标志寄存器:EFLAGS。它包含多个标志位,每个标志位表示特定的处理器状态或条件。这些标志位通常由算术和逻辑指令设置,用于控制程序的执行流程。当我们发生除0异常时,其中的OF标志位就会被设置,表示结果超出范围。OF(溢出标志):在有符号算术运算中,如果结果超出表示范围,OF 会被设置为 1。

当我们的进程被cpu切换下去时,进程的pcb中会保存该程序的硬件上下文数据,里面就包含了OF标志位溢出,所以操作系统就能知道我们的进程出错了。

那么操作系统怎么知道要发送什么信号呢?

操作系统可以识别异常类型,并且根据异常类型发送对应的信号,这些都是提前设置好的。

对于野指针来说:

我们的程序对地址的访问其实是对虚拟地址的访问,而虚拟地址会通过查询页表转化为对应的物理地址。而这个过程发生在cpu中,当我们的进程被cpu执行时,cpu会拿到虚拟地址通过MMU进行虚拟到物理地址的转化。但是当MMU访问到一个无效地址时,MMU会检测到无效访问,触发页面错误处理程序。此时cpu停止执行程序,将控制权交给操作系统,操作系统检查错误类型,并映射对应的信号(SIGSEGV),再将信号发送给进程。

硬件异常产生信号,无一例外都是cpu在执行程序的时候出现了异常, 此时cpu就会将控制权交给操作系统,操作系统捕获异常并识别异常类型,内核将硬件异常映射到相应的信号类型,并将信号发送给出现异常的进程,进程可以捕获并处理该信号或者让操作系统执行该信号的默认行为。

6.软件条件产生信号

当我们利用管道文件使用进程间通信的时候,如果将写端关闭,此时读端会读到0;而将读端关闭,此时写端write()系统调用会返回-1,并设置退出码,最重要的是,操作系统会向进程发送SIGPIPE信号,如果没有自定义捕捉,操作系统就会执行该信号的默认行为,即终止程序!!

而在上述这个过程中,设计的都是文件,也就是软件。在两个进程通信的时候,读端关闭,也就意味着通信条件不满足,也就是软件条件不满足,所以此时操作系统就会产生SIGPIPE信号,来使进程终止!!!

0x1.alarm

cpp 复制代码
NAME
       alarm - set an alarm clock for delivery of a signal

SYNOPSIS
       #include <unistd.h>

       unsigned int alarm(unsigned int seconds);

alarm其实就是设置一个闹钟,当闹钟超时(过了后面的时间),此时就会像调用该函数的进程发送SIGALRM信号,默认动作就是让进程终止~

  1. SIGALRM

SIGALRM P1990 Term Timer signal from alarm(2)

说明:我们首先捕捉14号信号,接着设置一个5秒的闹钟,闹钟结束后就会像进程发送14号信号。

alarm的返回值是返回前一个闹钟的剩余秒数,如果只有当前闹钟则返回0

通过alarm证明IO效率低,打印的效率低

定义一个3秒超时的闹钟,我们在期间进行打印操作,看看可以打印多少次:

另一组,也定义一个3秒超时的闹钟,在运行期间只让进程进行++操作,最后自定义捕捉14号信号时,再打印出结果:

我们观察上面两个测试结果,很明显的可以看到打印的次数要少于没有进行IO的次数。

0x2.alarm+pause重复设置闹钟

有了闹钟,我们可以再借助pause系统调用,实现让一个进程暂停,闹钟响了就去执行指定任务,同时重新设置闹钟重复该过程!!

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

funcs/
void Sched()
{
    std::cout << "我是进程调度" << std::endl;
} 

void Manager()
{
    std::cout << "内存管理" << std::endl;
}

void Fflush()
{
    std::cout << "我是刷新程序,定期刷新内存到磁盘" << std::endl;
}
funcs


// 利用包装器包装返回值为void没有参数的方法
using func_t = std::function<void()>;

// 利用容器将所有的方法存储起来
std::vector<func_t> funcs;

void handler(int signal)
{
    // 执行方法
    for(int i=0; i<funcs.size(); ++i)
        funcs[i]();
    
    // 重新设定闹钟
    alarm(2);
}

int main()
{
    // 注册方法
    funcs.emplace_back(Sched);
    funcs.emplace_back(Manager);
    funcs.emplace_back(Fflush);


    // 自定义捕捉闹钟
    signal(SIGALRM, handler);

    // 设置闹钟
    alarm(1);

    // 让进程一直处于暂停,直到闹钟响了,跳转闹钟的自定义捕获函数处
    while (true)
    {
        pause();
    }
    return 0;
}

0x3.理解系统闹钟

在操作系统中,同一时间可能会存在多个闹钟,所以要对闹钟进行管理------先描述,再组织。

cpp 复制代码
struct timer_list {
    struct list_head entry;

    unsigned long expires;
    void (*function)(unsigned long);

    unsigned long data;
    struct tvec_t_base_s *base;
}

当我们定义一个闹钟,就会在内核中创建一个timer_list结构,而操作系统会将所有的节点利用小堆维护起来,这样操作系统就只需要查看堆顶的闹钟是否超时,超时则向指定进程发送信号;反之不用管。

当又新建一个闹钟时,只需要将其插入到该小堆中,其会自动进行调整。

总结:软件条件产生信号,可以由多种方式产生,类如闹钟超时、管道文件读端被关闭等等。而对于信号的产生这一个阶段来说,不管是哪种方式产生的信号,都得通过操作系统,向对应进程发送指定信号,而发送信号的本质其实就是修改进程pcb中,对应的位图中的bit位。

三.Term和Core

这么多的信号,有相当一部分的信号的默认行为都是Term和Core,而这两个都是终止进程,那么这两个有什么区别呢?

Term行为就是直接让进程终止,没有其他任何行为!

而Core主要是为了支持debug。

Core的意思是核心,当我们的进程异常退出的时候,会在当前目录下形成一个文件,里面包含了进程在内存中运行时的核心数据,操作系统将这些数据从内存拷贝到磁盘,形成一个文件,而这个过程就叫做核心转储!!! 形成该文件后,该进程才会退出。

但在云服务器上,核心转储功能(Core dump)一般是被关闭的!

为什么要关闭Core dump呢?

0x1.安全方面

  • **敏感信息泄露风险:**核心转储文件(core dump)包含了进程在崩溃时的内存映像。这可能包含各种敏感信息,如用户密码、加密密钥、信用卡号等。例如,在一个电商云服务器上,如果应用程序崩溃产生核心转储文件,其中可能包含正在处理的用户订单信息,包括用户的姓名、地址、支付信息等。这些信息一旦被恶意用户获取,将导致严重的隐私泄露和安全问题。
  • **防止恶意利用:**攻击者可以分析核心转储文件来寻找软件漏洞。通过对核心文件的分析,他们可以了解程序的内存布局、变量存储位置等细节。例如,在一个存在缓冲区溢出漏洞的云服务器应用程序中,攻击者可以利用核心转储文件来确定如何构造恶意输入,以便更好地利用这个漏洞来获取系统权限或者执行恶意代码。

0x2.性能和存储方面

  • **存储空间占用:**核心转储文件通常会占用大量的磁盘空间。对于一些大型的云服务器应用程序,其内存占用可能达到数 GB 甚至数十 GB。当程序崩溃产生核心转储时,这个巨大的文件可能会迅速填满磁盘空间。例如,在一个大数据处理云服务器上,运行着内存密集型的数据分析任务,如果发生崩溃,产生的核心转储文件可能会占用数 TB 的空间,从而影响其他正常服务的存储需求,如日志文件的存储、新数据的写入等。
  • **性能影响:**生成核心转储文件的过程本身可能会对系统性能产生影响。在程序崩溃时,系统需要将大量的内存内容写入磁盘,这会消耗一定的系统资源,包括 CPU、内存带宽和磁盘 I/O。在一些对实时性要求较高的云服务场景中,如金融交易云服务器,这种性能开销可能会导致交易延迟,影响业务的正常运行。

我们可以使用ulimit -a命令查看核心转储功能,第一项core file size默认为0,即关闭了核心转储功能,我们可以通过设置这个核心转储文件的大小来达到打开该功能的目的!

此时,我们已经打开了核心转储功能,下面再来测试除零异常:

我们可以看到这个core文件非常大,而且是二进制。 这个文件主要是为了让我们进行debug的。

当我们的程序崩溃退出,且生成了核心转储文件,当代码量很多时,我们寻找bug不是很方便,我们可以在dbg时,通过core-file core dump文件直接定位到程序的异常位置,帮助我们快速定位问题:

我们在进程等待的时候了解过一个进程退出时它的退出状态和退出信号,当进程被信号终止时,它的低7位是终止信号,而第8位的core dump标志表示的是是否进行了核心转储!

以上,就是Core终止进程和Term终止进程的区别,它们的主要区别就在于Core会生成core dump文件用来调用程序!!

四.信号的保存

  • 实际执行信号的处理动作称为信号递达(Delivery)------自定义、默认、忽略
  • 信号从产生到递达之间的状态,称为信号未决(Pending)------信号已经被写入到进程pcb中的位图中
  • 进程可以选择阻塞(Block)某个信号,被阻塞的信号如果产生了,则会一直保持在未决状态,直至进程解除对此信号的阻塞,才执行递达动作。阻塞信号也叫屏蔽信号
  • 注意:阻塞和忽略是不同的,只要信号被阻塞了就不会递达,而忽略是在递达之后可选的一种处理动作

1.内核中如何保存信号

信号是保存在进程pcb中的位图中的,但实际上,进程为了保存信号设计出了三张表:

宏观理解:

pending表实际上就是一个unsigned int位图,bit位的位置表示是第几号信号,而该位上的0/1表示是否收到该信号

block也是一个位图,bit位的位置表示第几号信号,而该位0/1表示该信号是否被阻塞。收到信号与信号被阻塞之间没有任何关系,信号被阻塞只是表明该信号不能被递达,要么没有收到,要么处于未决状态!

handler是一张函数指针数组,下标代表第几个信号,而内容则表明如何处理信号,如果是SIG_DFL表明使用该信号的默认行为,如果是SIG_IGN表明忽略该信号不做处理,如果使用了其他的函数指针填充了该数组,表明使用自定义捕捉。而我们自定义捕捉的时候使用的函数指针类型是sighandler_t,实际上是typedef出来的。

cpp 复制代码
typedef __sighandler_t sighandler_t;

/* Type of a signal handler.  */
typedef void (*__sighandler_t) (int);

#define	SIG_DFL	 ((__sighandler_t)  0)	/* Default action.  */
#define	SIG_IGN	 ((__sighandler_t)  1)	/* Ignore signal.  */

下面是内核数据结构对这三张表的描述:

cpp 复制代码
struct task_struct {
    ...
    /* signal handlers */
    struct sighand_struct *sighand;
    sigset_t blocked
    struct sigpending pending;
    ...
};

// pending表
struct sigpending {
    struct list_head list;
    sigset_t signal;
};

// 函数指针数组
struct sighand_struct {
    atomic_t count;
    struct k_sigaction action[_NSIG]; // #define _NSIG 64
    spinlock_t siglock;
}

struct k_sigaction {
    struct __new_sigaction sa;
    void __user *ka_restorer;
};

struct __new_sigaction {
    __sighandler_t sa_handler;
    unsigned long sa_flags;
    void (*sa_restorer)(void); /* Not used by Linux/SPARC */
    __new_sigset_t sa_mask;
};

对于pending位图和block位图来说,他们的类型都是sigset_t的,而sigset_t本质上就是一个unsigned int的数组,大小为 8个整型。因为除了普通信号外,进程还要保存其他的实时信号。我们将这个类型叫做信号集。

那么信号存储在那个整型的哪个bit位上呢?

我们可以采取除和模的方式,假如找39号信号,我们可以先用39/32 = 1,找到该信号在第二个整型处,再通过39%32 = 7,找到该信号在第二个整型的第七个比特位上。

cpp 复制代码
/* A set of signals to be blocked, unblocked, or waited for.  */
typedef __sigset_t sigset_t;

#define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int)))
typedef struct
{
  unsigned long int __val[_SIGSET_NWORDS];
} __sigset_t;

而对handler函数指针数组表来说,它里面存储的其实就是函数指针,只不过被包装了。

2.信号集操作函数

cpp 复制代码
NAME
       sigemptyset, sigfillset, sigaddset, sigdelset, sigismember - POSIX signal set operations

SYNOPSIS
       #include <signal.h>

       int sigemptyset(sigset_t *set);

       int sigfillset(sigset_t *set);

       int sigaddset(sigset_t *set, int signum);

       int sigdelset(sigset_t *set, int signum);

       int sigismember(const sigset_t *set, int signum);
  • sigemptyset:将信号集set清空,本质上就是让所有的bit位置0
  • sigfillset:将信号集全部填满,就是将所有的bit位置1,即包含所有信号
  • sigaddset:向指定信号集set中,添加指定信号,就是将该信号对应的比特位由零置一
  • sigdelset:从指定信号集set中,删除指定信号,将信号对应的比特位由一置零
  • sigismember:检查指定信号是否在set信号集中
  • 前四个都是成功返回0,失败返回-1;最后一个存在1,不存在0,失败-1.

3. sigprocmask、sigpending

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

/* Prototype for the glibc wrapper function */
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

int sigpending(sigset_t *set);

sigprocmask可以设置信号屏蔽字,类似于文件那里的权限掩码(umask)。不想让那些信号被递达,我们既可以屏蔽该信号,即加入到block位图。

how:如何进行设置信号屏蔽字

set和oldset分别作为输入型参数和输出型参数,set作为待设置的信号屏蔽字,oldset将保存之前的信号屏蔽字。

sigpending,读取当前进程的未决信号集,通过set输出型参数获取。

4.结合信号集操作进行试验

cpp 复制代码
#include <iostream>
#include <cstdio>
#include <vector>
#include <unistd.h>
#include <sys/wait.h>
#include <signal.h>
#include <functional>

void Printpending()
{
    sigset_t pending;
    sigemptyset(&pending);

    int n = sigpending(&pending);
    if (n < 0)
    {
        perror("sigpending");
        exit(1);
    }

    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 signal)
{
    std::cout <<"" << std::endl;

    std::cout <<signal << "信号递达" << std::endl;
    Printpending();

    std::cout <<"" << std::endl;
    sleep(1);
}

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

    // 1.初始化信号集,并初始化为全0
    sigset_t set, oldset;
    sigemptyset(&set);
    sigemptyset(&set);

    // 1.5 屏蔽2号信号
    sigaddset(&set, 2);

    // 2.设置信号屏蔽字
    sigprocmask(SIG_SETMASK, &set, &oldset);

    // 3.获取pending表内容
    int cnt = 0;
    while (true)
    {
        Printpending();
        if(cnt == 10)
        {
            // 接触对2号信号的屏蔽
            std::cout << "接触对2号信号的屏蔽" << std::endl;
            sigprocmask(SIG_SETMASK, &oldset, nullptr);
            Printpending();
        }

        sleep(1);
        cnt++;
    }

    return 0;
}

实验结果:

一运行进程,未收到任何信号,我们利用ctrl+c发送2号信号,但由于我们提前对2号信号做了屏蔽,所以2号信号只能处于未决状态,10秒后,接触对2号信号的屏蔽,接触之后,立马被递达,因为后面的打印语句并没有指定,直接跳转到handler函数。

而且,只要被递达,信号立马就会被置为0,因为handler函数还没有跑完,打印语句就已经清零了。

细节1:如果在进程解除对某信号的阻塞之前这种信号产⽣过多次,将如何处理?POSIX.1允许系统递送该信号⼀次或多次。Linux是这样实现的:常规信号在递达之前产⽣多次只计⼀次,⽽实时信号在递达之 前产⽣多次可以依次放在⼀个队列⾥。

细节2:当信号要被递达的时候,要先将其pending表中的比特位由1->0

五.信号的捕捉

信号产生之后,会由操作系统将信号写入进程pcb中的pending表,等到合适的时候将信号递达,执行信号的处理行为。而对于递达之后的动作有默认、自定义、忽略三种。

1.信号捕捉的流程

进程将在合适的时候处理信号,那么这个合适的时候是什么时候?进程从内核态返回用户态的时候。

++当我们的进程正在执行自己的代码的时候,我们处在用户态的,当我们执行到某个系统调用的时候,此时需要操作系统执行系统调用,此时进程会进入内核,执行完系统代码后,准备从内核态返回用户态的时候,先检查进程的pending表,判断比特位是否为1,并且没有被屏蔽,如果都成立的话,就会执行handler表中的处理方法(这里我们以自定义捕捉为例),检查后如果要递达,此时就要从内核态跳转到用户态,执行用户自己写的handler方法。执行完毕后,再返回内核态,最后再由内核态返回到用户态继续执行当初调用系统调用的地方。++

那如果handler表中对应的捕捉方式是默认或者忽略呢?

这种方法一般都已经内置在了内核中,此时直接由操作系统执行对应信号的处理方法,然后直接返回用户态继续执行main函数代码即可。

那为什么不直接让操作系统执行信号的自定义捕捉方法呢?

信号的自定义捕捉方法中可能涉及到了一些非法操作,如果有操作系统执行的话权限高,可能会执行一些危险操作。并且该方法是定义在用户空间的,当检查信号完毕后,操作系统就会将控制权转移到给自定义方法处。

那么还有一个问题,我们写的自定义捕捉方法明明没有涉及进入内核态的代码,它怎么又会回到内核态,最后再从内核态返回到用户态呢?

当我们在main函数中调用另一个func函数时,此时会为func函数开辟栈帧,但是在开辟栈帧之前,会先将main函数执行到哪一个位置的地址压栈,也就是用pc寄存器保存func函数要返回地址。而我们可以通过将我们自己的另一个函数的地址压入pc寄存器中,此时func返回就不会返回到main函数了,而是新压入栈的函数地址。

那么什么是用户态什么是内核态呢?

先简单的理解:用户态就是以用户的身份,访问虚拟地址空间的0~3GB的地址空间,也就是自己的代码和数据

内核态:以内核的身份,运行我们调用的系统调用,去访问虚拟地址空间的3~4GB的地址空间。

在信号的捕捉流程中,涉及到了多次用户态与内核态之间的转换,我们可以将上述的过程简化一下:从左上角开始,先执行我们自己的代码,接着因为系统调用或者其他原因,进程要进入内核执行系统代码,然后在返回之前,先检查pending表,如果有信号,就继续执行handler方法......

2.sigaction

我们在前面对信号进行自定义捕捉的时候使用的是signal系统调用,而sigaction也可以对信号进行自定义捕捉。

cpp 复制代码
NAME
       sigaction, rt_sigaction - examine and change a signal action

SYNOPSIS
       #include <signal.h>

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

strcut sigaction是该方法提供的一个结构体,里面包含了自定义方法等其他属性:

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_flags和sa_restorer不用管,一个初始化为0另一个不用管。**sa_handler就是自定义方法,**sa_sigaction也不用管。

这个系统调用与signal不同之处在于,当我们自定义捕捉指定的信号以后,收到该信号执行其自定义方法时会将该信号自动进行block屏蔽,等到执行完方法后在取消屏蔽。也就是说,但我们依旧捕捉了一次该信号,在还没有执行完上一次的处理行为前,再次发送该信号其会被阻塞在pending表中!!!

当除了想屏蔽正被捕捉的信号之外的其他信号时,我们可以设置sigaction的sa_mask信号集,来达到同样的目的,当执行完毕后,自动取消屏蔽。

cpp 复制代码
void handler(int sig)
{
    std::cout << "signal: " << signal << 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);
    }
}


int main()
{
    struct sigaction act, oldact;
    act.sa_flags  = 0;
    act.sa_handler = handler;

    // 设置其他想屏蔽的信号
    sigemptyset(&act.sa_mask);
    sigaddset(&act.sa_mask, 1);
    sigaddset(&act.sa_mask, 3);

    // 自定义捕捉2号信号
    sigaction(2, &act, &oldact);

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

3.操作系统是如何运行的?

0x1.硬件中断

当进程正在运行时,我们按下ctrl+c可以杀死进程,我们现在已经知道是因为进程收到了信号,而信号是由操作系统写给进程的,那么操作系统是怎么知道键盘被按下了呢?

操作系统是软硬件资源的管理者,那么它是否是通过轮询每一个设备,看其是否就绪?不,操作系统是通过硬件中断的方式获悉键盘就绪的!!!

那么何为硬件中断呢?

在计算机中,cpu的背面并不是光滑的,而是有很多的针脚,而这些针脚通过主板与各个设备相连接。

  1. 当某个设备准备就绪后,就会向cpu的指定针脚发送高低电频,cpu通过高低电频来确定该设备是否就绪,而发送高低电频的过程就叫做发起中断。
  2. 而cpu并不是直接接收这个高低电频,而是通过中断处理器,中断处理器会根据收到的某个设备的高低电频生成中断号,将该中断号保存在某个寄存器中,然后通知cpu发生了中断。
  3. cpu得知发生了中断之后,就会从寄存器中拿出中断号,执行该中断处理办法,但是因为cpu当前可能正在执行其他进程,所以cpu要进行保护现场的操作,即保存当前进程的上下文数据。
  4. 在操作系统中,有一张全局的中断向量表(Interrupt Descriptor Table, IDT),本质上就是一张函数指针数据,它的索引就是中断号,也就是说这张表中存储了每一种中断对应的中断处理方法。cpu拿到中断号就查该表,执行对应的中断处理方法。
  5. 处理完成之后,cpu恢复现场,继续执行刚才正在做的事。

中断向量表在操作系统启动的时候,就加载到了内存中。有了上面的中断机制,操作系统再也不会管哪个设备就绪了,只需要等待接收中断即可,收到中断了,拿到中断号就知道哪个设备就绪了。由外部设备触发,中断系统运行流程,叫做硬件中断。

cpp 复制代码
//Linux内核0.11源码 
void trap_init(void)
{
    // 中断号以及对应的处理方法
    int i;
    set_trap_gate(0,&divide_error);// 设置除操作出错的中断向量值。以下雷同。 
    set_trap_gate(1,&debug);
    set_trap_gate(2,&nmi);
    set_system_gate(3,&int3); /* int3-5 can be called from all */
    set_system_gate(4,&overflow);
    set_system_gate(5,&bounds);
    set_trap_gate(6,&invalid_op);
    set_trap_gate(7,&device_not_available);
    set_trap_gate(8,&double_fault);
    set_trap_gate(9,&coprocessor_segment_overrun);
    set_trap_gate(10,&invalid_TSS);
    set_trap_gate(11,&segment_not_present);
    set_trap_gate(12,&stack_segment);
    set_trap_gate(13,&general_protection);
    set_trap_gate(14,&page_fault);
    set_trap_gate(15,&reserved);
    set_trap_gate(16,&coprocessor_error);

    // 下⾯将int17-48 的陷阱⻔先均设置为reserved,以后每个硬件初始化时会重新设置⾃⼰的陷阱⻔。 
    for (i=17;i<48;i++)
    set_trap_gate(i,&reserved);
    set_trap_gate(45,&irq13);                // 设置协处理器的陷阱⻔。 
    outb_p(inb_p(0x21)&0xfb,0x21);           // 允许主8259A 芯⽚的IRQ2 中断请求。 
    outb(inb_p(0xA1)&0xdf,0xA1);             // 允许从8259A 芯⽚的IRQ13 中断请求。 
    set_trap_gate(39,&parallel_interrupt);   // 设置并⾏⼝的陷阱⻔。 
}

void rs_init (void)
{
    set_intr_gate (0x24, rs1_interrupt); // 设置串⾏⼝1 的中断⻔向量(硬件IRQ4 信号)。 
    set_intr_gate (0x23, rs2_interrupt); // 设置串⾏⼝2 的中断⻔向量(硬件IRQ3 信号)。 
    init (tty_table[1].read_q.data);     // 初始化串⾏⼝1(.data 是端⼝号)。 
    init (tty_table[2].read_q.data);     // 初始化串⾏⼝2。 
    outb (inb_p (0x21) & 0xE7, 0x21);    // 允许主8259A 芯⽚的IRQ3,IRQ4 中断信号请求。 
}

我们观察这个中断机制是不是和我们的信号机制特别相似呢?

  • 发中断---发信号
  • 保存中断号---记录信号
  • 中断号---信号编号
  • 处理中断---处理信号

信号,是纯软件的,它本质上就是在借助软件来模拟实现硬件中断!!!我们在了解冯诺依曼体系结构的时候说过cpu不能访问外设,要通过内存来间接访问。但硬件中断好像cpu直接访问了外设?

我们之前说的是数据信号,对于数据的流动,确实cpu只能访问内存。但是硬件中断发送的并不是数据,而是一种控制信息,cpu可以直接访问外设来获取控制信息。

0x2.时钟中断

进程可以在操作系统的指挥下,被调度,被执行,那么操作系统自己被谁指挥,被谁推动执行?

外部设备可以触发硬件中断,但是这个是需要用户或设备自己触发,有没有自己可以定期触发的设备?

首先操作系统在没有中断到来的时候,一直处于暂停状态!!!

cpp 复制代码
void main(void)
{ 
    ...
    for (;;)
        pause();
} // end main

也就是说操作系统本质上就是个死循环!!!它是通过时钟中断来推动其进行进程调度等工作的。

在外设中,有一个时钟源,它会以固定的频率发送中断,cpu在拿到该中断号后,会执行其对应的中断处理方法:进程调度!自此,操作系统就会由暂停状态跳转去执行进程调度。

而我们知道为了让各个进程都可以得到cpu资源以执行自己的代码,所以就有了时间片。当发生时钟中断,此时进行进程调度,同时检查该进程的时间片是否超时,如果超时了就根据优先级选择另一个进程继续执行。

而在现在,这个外部的时钟源已经被集成到了cpu内部,它同样以固定的频率发送时钟中断,而这个固定的频率叫做主频。

那么操作系统到底是怎么运行起来的呢?

操作系统借助死循环和时钟中断来实现正常运行,当发生时钟中断的时候,会处理该时钟中断注册的处理服务,执行该处理服务时其实就是去检查当前正在运行的进程的时间片是否结束,如果没有结束则直接返回,如果结束了则进行进程调度,根据优先级挑选下一个进程进行执行,同时将该进程链入到过期队列中。

0x3.软中断

上面的中断追根揭底是由硬件产生的,那么有没有一种中断是完全由软件引起的?有的,我们之前的异常就会引起软中断!

异常:

当我们在执行自己的代码和数据时,此时遇到了除零异常或者访问了野指针,此时cpu内部的寄存器就会向cpu发送中断,cpu拿着该中断号,查询中断向量表,去执行该异常对应的中断服务,而该中断服务就可能是给目标进程发送信号,使进程结束。

而我们之前在学习虚拟地址空间的时候,加载程序时,可以先不加载程序的所有代码和数据,而是先初始化虚拟地址空间,当我们访问该地址空间,拿着虚拟地址通过页表进行虚拟到物理的地址转换的时候发现,没有对应的物理地址,此时就会发生缺页中断,从磁盘加载对应的代码和数据,重新填写页表。

而缺页中断的过程其实就是cpu中MMU进行虚拟到物理的转化失败了,发送了中断号,cpu执行中断号对应的服务,其实就是开辟空间,加载代码和数据,构建映射关系。这就是为什么缺页中断也叫做缺页异常。

主动发生软中断:

cpu提供了汇编指令:int 0x80、syscall,用户可以使用这些指令来主动发生中断。而这两个指令触发的中断服务其实是执行系统调用。

当我们进行系统调用的时候,具体是怎么进入操作系统,完成系统调用过程的,毕竟cpu只有一个?

其实在操作系统中存在着一张系统调用表,函数指针表,该表里存储的都是系统调用的,而该表的索引叫做系统调用号。

cpp 复制代码
// 系统调⽤函数指针表。⽤于系统调⽤中断处理程序(int 0x80),作为跳转表。 
fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,
    sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link,
    sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod,
    sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount,
    sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm,
    sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access,
    sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir,
    sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk, sys_setgid,
    sys_getgid, sys_signal, sys_geteuid, sys_getegid, sys_acct, sys_phys,
    sys_lock, sys_ioctl, sys_fcntl, sys_mpx, sys_setpgid, sys_ulimit,
    sys_uname, sys_umask, sys_chroot, sys_ustat, sys_dup2, sys_getppid,
    sys_getpgrp, sys_setsid, sys_sigaction, sys_sgetmask, sys_ssetmask,
    sys_setreuid, sys_setregid
};

所以,我们在使用系统调用的本质其实就是调用int 0x80/syscall 来产生中断,而该中断服务的操作就是查询系统调用表进行跳转,执行对应的系统调用。

那么也就是说,我们平时使用的open/fork等系统调用其实是被封装的,里面包含了int 0x80/syscall来引起中断,以及该系统调用在底层系统调用表的下标。本质上调用这些就是先将系统调用号move到eax寄存器中,然后再产生中断。根据中断服务方法,再结合eax中的系统调用号,查表执行对应的系统调用!!!

总结:cpu内部的软中断,如:int 0x80或者syscall,我们叫做陷阱,当我们使用该指令产生中断时,其实就是为了使用系统调用,此时操作系统就会从用户态陷入内核态,执行系统调用代码。

而除零/野指针等,我们叫做异常,这些错误转化为软中断后,走中断处理流程,有的是申请内存,填充页表进行映射;有的是用来处理内存碎片的,有的是用来给目标进行发送信号,杀掉进程的等等。

六.内核态和用户态

每一个进程都有自己的虚拟地址空间,而在虚拟地址空间上,0~3GB是用户空间,3~4GB是内核空间,这意味着,不论进程如何被调度,我们总能找到操作系统。

但是操作系统对于所有的进程来说,都只有一份。用户空间的虚拟地址可以通过页表与物理地址进行映射,而对于内核空间来说,它也有自己的页表,映射对应的物理内存。但因为操作系统只有一份,所以内核页表也只有一份,而每一个进程都有自己独立的用户页表~

但是用户空间和内核空间都在这0~4GB的地址空间上,如果用户,随便拿一个虚拟地址[3,4]GB去访问,用户不就可以随便访问内核的代码和数据了嘛?

操作系统为了保护自己,不相信任何人!!!要访问内核的代码和数据必须采取系统调用的方式进行访问!所以就有了内核态和用户态的出现:

  • 用户态:以用户身份,只能访问自己的[0,3]GB的空间
  • 内核态:以内核身份,运行我们使用的系统调用,访问[3,4]GB的空间

但是在系统中,用户或者操作系统,怎么知道自己当前处在什么态呢?

cpu中的cs寄存器中,保存了正在运行的代码段的地址,以及CPL,比特位的后两位全0表示用户,全1表示内核。也就是说,在系统中,CPL为0表示用户,CPL为3表示内核。

当我们使用int 0x80或者syscall时,就会更改此时的cpu权限,让其陷入内核态。

七.可重入函数

示例:我们定义了两个全局的节点,在main函数调用insert方法头插node1,但是还没有让头结点的next指向node1时,此时收到了信号,导致控制权进入了sighandler方法,而在该方法内由头插了node2,等到返回insert时,又将head指向了node1,这样就导致node2没有人指向,就导致内存泄露的问题。

像上面insert这样的函数,有可能在一次调用还没结束前,就有另一个执行流也进入了该函数,这个就叫做函数的重入。而该函数访问了公共的链表,这样就有可能导致错误现象,而这种称为不可重入函数。如果两个执行流都进入了同样一个函数,但只访问自己的临时数据,栈上的数据,不会产生错误,这种叫做可重入函数。

我们使用的大部分函数都是不可重入的。需要注意的是,可重入和不可重入不是优缺点,而是特点。我们可以根据自己的需求,设计不同特点的函数。

八.volatile

volatile 是 C 和 C++ 编程语言中的一个关键字,用于修饰变量,表示变量的值可能会被程序之外的因素(如硬件设备、其他线程等)改变。编译器在编译过程中不会对 volatile 修饰的变量进行优化,从而确保每次使用该变量时都从内存中重新读取其值,而不是使用寄存器中的缓存值。

cpp 复制代码
int flag = 0;
void handler(int signal)
{
    std::cout << "更改全局变量flag" << flag << 1 << std::endl;
    flag = 1;
}

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

    while(!flag) ;
    std::cout << "process end!" << std::endl;

    return 0;
}

说明:我们定义全局变量flag=0,并且让程序进入死循环。我们发送二号信号,并让其调用自定义捕捉方法,此时flag变为1,当回到main函数时,就会退出循环,打印退出语句。

当我们运行该程序时,cpu在执行到循环判断语句时,会从物理内存中读取flag的值,然后判断循环条件是否为真,当我们修改该flag时,下一次cpu从内存读取falg的值时就发生了改变,导致循环结束。

但是现在的编译器的优化都做得很重,编译器检查发现我们在while循环中没有做任何改变flag的动作,就会将falg的值保存在寄存器中,这样,在每一次循环检查时,就不会再从内存中读取flag的值了,即使我们发送2号信号修改了flag。但是cpu已经不从内存读了,所以该循环依旧不会停止!!

我们可以通过-O的方式来指定编译器的优化强度。最高是3,我们默认使用g++编译器时,优化为-O0,即没有优化。

当我们用volatile修饰flag变量时,就等于告诉cpu,你每次查询都得从内存中读,不要保存到寄存器中。

九.SIGCHLD信号

子进程退出的时候会给父进程发送sigchld信号:17) SIGCHLD

子进程退出后会进入僵尸状态,等待父进程回收该退出信息。而父进程收到子进程发送的sigchld信号不会有任何动作,因为其的默认动作是忽略该信号。

但如果父进程不关心子进程的退出信息,我们就可以让父进程利用signa忽略捕捉该信号,这样子进程就不会进入僵尸,而会直接退出。

cpp 复制代码
int main()
{
    signal(SIGCHLD, SIG_IGN);
    for(int i=0; i<10; i++)
    {
        pid_t id = fork();
        if(id == 0)
        {
            sleep(1);
            int cnt = 5;
            while(cnt--)
                std::cout << "i am child :" << i << std::endl;

            exit(1);
        }
    }
    while(true) ;

    return 0;
}

说明:创建10个子进程并让每一个子进程在5秒后退出,当我们使用sigchld的默认捕捉时,父进程不会做任何处理,所有结束的子进程都是进入僵尸状态,如下图1.而当我们使用signal对sigchld信号进行默认捕捉时,此时子进程就会直接退出!!!如下图2.


以上,就是Linux进程信号的所有的内容!

相关推荐
李匠20246 分钟前
C++学习之游戏服务器开发十五QT登录器实现
服务器·c++·学习·游戏
一勺菠萝丶11 分钟前
VMware中CentOS 7虚拟机设置固定IP(NAT模式)完整教程
linux·tcp/ip·centos
爬菜20 分钟前
rpm包管理
linux
噗噗bug44 分钟前
计算机网络 3-4 数据链路层(局域网)
服务器·网络·计算机网络
Space-oddity-fang1 小时前
Ubuntu启动SMB(Samba)服务步骤
linux·服务器·github
dustcell.1 小时前
vim 命令复习
linux·编辑器·vim
像风一样自由20202 小时前
配置MCP服务器以提升Cursor功能(专业进阶版)
运维·服务器
龙仔7252 小时前
windows使用openssl生成IIS自签证书全流程
linux·运维·服务器
WG_172 小时前
Linux:42线程控制lesson30
linux
GanGuaGua2 小时前
Linux:进程地址空间
linux·运维·服务器