✨                              眼里有诗,自向远方                        🌏 

 📃个人主页:island1314

🔥个人专栏:Linux—登神长阶

⛺️ 欢迎关注:👍点赞 👂🏽留言 😍收藏  💞 💞 💞


温馨提示:信号和信号量 二者之间没有任何关系

1, 信号概念 🚀 

🚀 信号是 Linux 系统提供的一种向指定进程发送特定事件的方式,进程会对信号进行识别和处理。

信号的产生是异步的

  • 即一个进程不知道自己何时会收到信号,在收到信号之前进程只能一直在处理自己的任务

使用 kill -l 指令查看信号()

  • 每个信号都有⼀个编号和⼀个宏定义名称,这些宏定义可以在 signal.h 中找到
  • 其中:1-30号信号为普通信号,31-64号信号为实时信号

🍉 具体的信号采取的动作和详细信息可查看:man 7 signal

分析:

  • Action列即为信号的默认处理方式
  • Core、Term即为进程终止,Stop为进程暂停……
  • (Core终止进程同时还会形成一个debug文件,Term仅终止进程)

基本特点:

  • 信号:Linux系统提供的一种,向指定进程发送特定事件的方式。
  • 信号的产生和进程是异步的。即进程不知道什么时候会收到信号。
  • 信号可以随时产生
  • 如果进程做着别的事,可以暂不处理信号,等到合适的时候再处理

2, 信号处理 ❓

  ( sigaction 函数后面博客来详细介绍),现在先说可选的以下三种处理动作

  1. 默认处理(通常为终止、暂停、忽略等)

  2. 忽略处理

  3. 自定义处理(信号捕捉)

在看相关内容之前,先插播一个小知识 signal

#include <signal.h>

typedef void (*sighandler_t)(int);

sighandler_t signal(int signum, sighandler_t handler);

参数:

  • signum:指定信号的编号

  • func:函数指针,该函数将在接收到sig信号时被调用。这个函数必须接受一个 int 参数(信号编号),并且返回类型为 void。

返回值:返回值为一个函数指针,指向之前的信号处理器;如果之前没有信号处理器,则返回 SIG_ERR

2.1 执行该信号的默认处理动作

如果signal函数的 func 参数为 SIG_DFL,则系统将使用默认的信号处理动作。

#include<iostream>
#include<signal.h>
#include<unistd.h>
 
int main()
{
    ::signal(2, SIG_DFL); // defalut 默认
    while(true)
    {
        std::cout << "I am a process, I am waiting signal!" << std::endl;
        sleep(1);
    }
}

2.2 忽略该信号

如果signal函数的 func 参数为 SIG_IGN,则系统将忽略该信号

  • 此时 将pending表中被忽略的信号置为 0 (pending 表下面会讲地)

注意看源码:

#define SIG_DFL ((__sighandler_t) 0) /* Default action. */
#define SIG_IGN ((__sighandler_t) 1) /* Ignore signal. */
/* Type of a signal handler. */
typedef void (*__sighandler_t) (int);
// 其实SIG_DFL和SIG_IGN就是把0,1强转为函数指针类型

2.3 自定义处理(信号捕捉)

信号自定义处理,其实是对信号进行捕捉,然后让信号执行自定义的方法

  • 注意:信号的捕捉,一次捕捉就会使其一直有效
#include<iostream>
#include<signal.h>
#include<unistd.h>
 
void hander(int signo)
{
    std::cout << "get a new signal: " << signo << std::endl;
}

int main()
{
    ::signal(2, hander);
    while(true)
    {
        std::cout << "I am waiting signal!, pid: " << getpid() << std::endl;
        sleep(1);
    }
}

运行如下:

这里 signal(2, handler)

  • signal是用来进行信号捕捉的。
  • 参数1是信号的编号,参数2是函数指针。如果进程收到参数1对应的信号,就会执行参数2对应的方法

注意: ^\Quit 表示 kill -3,相当于从键盘输入了 Ctrl + \

同时我们也可以对多个信号进行捕捉

信号的保存和发送理解:

  1. 进程pcb中,是用位图来保存信号的。收到什么信号,就把对应比特位上的数字变为1
  2. 发送信号:修改指定进程 pcb 中的信号的指定位图的比特位

3, 信号产生 🔥

  1. 键盘可以产生信号。ctrl+c(SIGINT)、ctrl+\(SIGQUIT)
  2. 通过 kill 命令,向指定进程发送指定信号
  3. 系统调用
  4. 软件条件
  5. 异常

3.1 通过终端按键产生信号 ⚽

  • Ctrl+C(SIGINT):已经验证过,这⾥不再重复?
  • Ctrl+\(SIGQUIT) :可以发送终⽌信号并生成 core dump 文件,用于事后调试(后⾯详谈)

补充个知识:(前后台进程)--> 理解信号异步

  • Ctrl-C 产生的信号只能发给前台进。一个命令后面加个 & 可以放到后台运行,这样 Shell 不必等待进程结束就可以接受新的命令, 启动新的进程。
  • Shell 可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl-C 这种控制键产生的信号。
  • 前台进程在运行过程中用户随时可能按下 Ctrl-C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)

理解: OS如何得知键盘有数据?

初步理解 【信号起源】
• 信号其实是从纯软件⻆度,模拟硬件中断的行为
• 只不过硬件中断是发给CPU,而信号是发给进程?
• 两者有相似性,但是层级不同,这点我们后⾯的感觉会更加明显

3.2 通过系统命令向进程发信号 ⚾

#include <iostream>
#include <unistd.h>
#include <signal.h>
int main()
{
    while(true){
    sleep(1);
    }
}


$ g++ sig.cc -o sig // step 1
$ ./sig & // step 2
$ ps ajx |head -1 && ps ajx | grep sig // step 3
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
211805 213784 213784 211805 pts/0 213792 S 1002 0:00 ./sig
  • 首先在后台执行死循环程序,然后用kill 命令 给它发SIGSEGV信号
$ kill -SIGSEGV 213784
$ // 多按⼀次回⻋
[1]+ Segmentation fault ./sig
  • 213784 是 sig 进程的pid。之所以要再次回车才显示 Segmentation fault ,是因为在 213784 进程终止掉之前已经回到了 Shell 提示符等待用户输入下⼀条命令, Shell 不希望 Segmentation fault   信息和用户的输入交错在⼀起,所以等用户输入命令之后才显示。
  • 指定发送某种信号的 kill 命令可以有多种写法,上面的命令还可以写成 kill -11 213784,11 是信号 SIGSEGV 的编号。以往遇到的段错误都是由非法内存访问产生的,而这个程序本身没错,给它发 SIGSEGV 也能产生段错误

3.3 使用函数产生信号 🥎

3.3.1 kill 函数
  • kill 命令是调用 kill 函数实现的。 kill 函数可以给⼀个指定的进程发送指定的信号
NAME
    kill - send signal to a process

SYNOPSIS
    #include <sys/types.h>
    #include <signal.h>
    
int kill(pid_t pid, int sig);

参数分析:
pid:指定进程pid,如果 pid 是负数,信号将被发送到与 pid 的绝对值相同的进程组中的所有进程
sig:指定的信号编号

RETURN VALUE
    On success (at least one signal was sent), zero is returned. On error, -1 is returned, and errno is set appropriately.

实现自己的 kill 命令

#include<iostream>
#include<signal.h>
#include<unistd.h>
 
// 形成 自己的 kill 命令
void Usage(std::string proc){
    std::cout << "Usage: " << proc << " signumber processid " << std::endl;
}

int main(int argc, char *argv[]){
    if(argc != 3){
        Usage(argv[0]);
        exit(1);
    }

    int signumber = std::stoi(argv[1]);
    pid_t id = std::stoi(argv[2]);
    int n = ::kill(id, signumber);

    if(n < 0){
        perror("kill");
        exit(2);
    }
    exit(0);
}

运行结果如下: 

3.3.2 raise 函数
  • raise 函数可以给当前进程发送指定的信号(自己给自己发信号)。
#include <signal.h>

int raise(int sig);

样例:

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

void hander(int sig){
    std::cout << "get a sig: " << sig << std::endl;
}

int main() {
    int cnt = 3;
    ::signal(2, hander);
    while(true){
        raise(2);
        cnt--;
        if(cnt<=0) raise(9);
        sleep(1);
    }
}

过 3 s 后进程被杀死

3.3.3 abort 函数
  • abort 函数使当前进程接收到信号而异常终止
#include <stdlib.h>

void abort(void);
#include<iostream>
#include<signal.h>
#include<unistd.h>

int main(){
    int cnt = 3;
    while(true){
        std::cout << "IsLand 1314" << std::endl;
        cnt--;
        if(cnt<=0) abort();
        sleep(1);
    }
}

注意事项:

  1. 6号信号 SIGABRT 可以被自定义捕捉处理,但是捕捉后仍然会立即退出进程,比较特殊

  2. 9号信号 SIGKILL 无法被捕捉,否则如果所有的信号都被捕捉,那么进程将无法退出 

3.4 由软件条件产生信号 🪩

🔥 使用管道通信时,当读端关闭,但是写端一直写,操作系统就会给写端进程发送13号信号SIGPIPE,终止进程。SIGPIPE 就是一种由软件条件产生的信号,SIGPIPE 是⼀种由软件条件产生的信号。

  • 主要介绍 alarm 函数和 SIGALRM 信号
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
  • alarm 函数用于设置一个定时器,在指定时间后向进程发送14号信号SIGALRM,终止进程

  • seconds:指定定时器的时间,单位为秒。如果这个值是 0,则会取消之前设置的闹钟

  • 返回值:alarm 函数返回自上次调用的 alarm 闹钟剩余的秒数。如果之前没有设置定时器,或者定时器已经触发,返回 0

3.4.1 基本alarm验证 - 体会IO效率问题
  • 程序的作用:是1秒钟之内不停地数数,1秒钟到了就被SIGALRM信号终止,必要的时候,对SIGALRM信号进行捕捉?

结论:

  • 闹钟会响⼀次,默认终止进程
  • 有IO效率低
3.4.2 设置重复闹钟
#include<iostream>
#include<signal.h>
#include<unistd.h>
#include <functional>
#include <vector>
#include <string>
#include <signal.h>
#include <sys/wait.h>

// 定时器功能
using func_t = std::function<void()>;

int gcount = 0;
std::vector<func_t> gfuncs;

// 1. handler 是一个信号处理函数,当接收到 SIGALRM 信号时被调用
// 2. 在处理函数内部,通过遍历 gfuncs 中的所有函数并调用它们,执行所有注册的任务
// 3. 然后输出 gcount 的值,并重新设置一个定时器(alarm(1)),使得下一个 SIGALRM 信号在 1 秒后再次发送

// 如果我们把下面操作,信号 更换成 硬件中断,那么就是 OS 的操作原理

void handler(int signo)
{
    for(auto &f: gfuncs){
        f();
    }
    std::cout << "gcount : "<< gcount << std::endl;
    int n = alarm(1); // 重设闹钟,会返回上⼀次闹钟的剩余时间
    std::cout << "剩余时间 : " << n << std::endl;
}

int main()
{
    gfuncs.push_back([](){
        std::cout << "我是一个内核刷新操作" << std::endl;
    });

    gfuncs.push_back([](){
        std::cout << "我是一个检测进程时间片的操作,如果时间片到了,我会切换进程" << std::endl;
    });
    
    gfuncs.push_back([](){
        std::cout << "我是一个内存管理操作,定期清理操作系统内部的内存碎片" << std::endl;
    });

    alarm(1); // 一次性的闹钟,一旦超时 alarm 自动取消
    signal(SIGALRM, handler);

    while(true)
    {
        pause();
        std::cout << "我醒来了..." <<std::endl;
        gcount++;
    }
}

运行结果如下:

结论:

  • 闹钟设置一次,起效一次
  • 重复设置的方法
  • alarm(0)

3.4.3 如何理解软件条件

🔥 在操作系统中,信号的软件条件指的是由 软件内部状态特定软件操作触发 的信号产生机制。

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

3.4.4 简单理解系统闹钟

🐅 系统闹钟,其实本质是OS必须自身具有定时功能,并能让用户设置这种定时功能,才可能实现闹钟这样的技术,现代Linux是提供了定时功能的,定时器也要被管理:先描述,在组织。

内核中的定时器数据结构是:

struct timer_list {
	struct list_head entry;
	unsigned long expires;
	void (*function)(unsigned long);
	unsigned long data;
	struct tvec_t_base_s* base;
};

我们不在这部分进行深究,为了理解它,我们可以看到:定时器超时时间 expires 和 处理方法function

  • 操作系统管理定时器:采用的是时间轮的做法,但是我们为了简单理解,可以把它在组织成为"堆结构"。

3.5 硬件异常产生信号 🏀

硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号

  • 例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE 信号 发送给进程。
  • 再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为 SIGSEGV信号 发送给进程。
 3.5.1 除 0 问题

关于进程中的计算问题,一般都是交由 cpu 来完成的,在计算的过程中,难免会出现错误的计算,比如说除0,那么 cpu 又是如何知道的呢? 如下:

🐇 这就要提到 cpu 中的寄存器了,cpu 中是有很多的寄存器的,其中有一个寄存器:EFLAGS 寄存器(状态寄存器)

  • 该寄存器中有很多状态标志:这些标志表示了算术和逻辑操作的结果,如溢出(OF)、符号(SF)、零(ZF)、进位(CF)、辅助进位(AF)和奇偶校验(PF)。
  • 除 0 操作就会触发溢出,就会标定出来运算在 cpu 内部出错了。OS 是软硬件资源的管理者!OS 就会处理这种硬件问题,向目标进程发送信号,默认终止进程。

我们要知道 cup 内部是只有一套寄存器的,寄存器中的数据是属于每一个进程的,是需要对进程上下文进行保存和恢复的。

  • 如果进程因为除0操作而被操作系统标记为异常状态,但没有被终止,那么它可能会被挂起,等待操作系统的进一步处理。
  • 当操作系统决定重新调度这个进程时,会进行上下文切换,即将当前进程的上下文保存到其PCB(进程控制块)中,并加载异常进程的上下文到CPU寄存器中。

上下文切换是一个相对耗时的过程,包括保存和恢复寄存器、堆栈等信息

  • 当切换回这个进程的时候,溢出标志位的错误信息同样会被恢复,会频繁的导致除0异常而触发上下文切换,会大大增加系统的开销。
     
3.5.2 空指针问题

代码演示如下:

这个问题就与页表,MMU及CR2,CR3寄存器有关联了

🐍 MMU 和 页表 是操作系统实现虚拟内存管理和内存保护的关键机制,它们通过虚拟地址到物理地址的转换来确保程序的正确运行和内存安全。

  • CR2 和 CR3 寄存器在内存管理和错误处理中扮演着重要角色
  • CR3 寄存器用于切换不同进程的页表
  • CR2 寄存器则用于存储引起页错误的虚拟地址,帮助操作系统定位和处理错误

🐍 CR2 寄存器用于存储引起页错误的线性地址(即虚拟地址)。

  • 当 MMU 无法找到一个虚拟地址对应的物理地址时(例如,解引用空指针或野指针),会触发一个页错误(page fault)。
  • 此时,CPU会将引起页错误的虚拟地址保存到 CR2 寄存器中,并产生一个异常,此时就会向进程发送11号信号。
  • 由此可以确认:我们在C/C++当中除零,内存越界等异常,在系统层⾯上,是被当成信号处理

4, Core Dump 理解 🖊

先来看看 Core 的意思

  • Core:这个动作表示在终止进程的同时,还会生成一个 core dump 文件。这个文件包含了进程在内存中的信息,通常用于调试。例如,SIGQUIT(编号3)和 SIGSEGV(编号11)等信号的默认动作就是终止进程并生成 core dump

  • 但当进程因某个信号而 core(终止并 核心转储,这个动作在云服务器下是被默认关掉的)时,会生成一个 core dump 文件。这个文件包含了进程在内存中的状态信息,对于程序员来说是非常有用的调试工具。
  • core 动作则更常用于在进程崩溃时生成调试信息,帮助程序员找出崩溃的原因。(以gbd为例,先使用gdb打开目标文件,然后将core文件加载进来,就直接可以定位到错误在哪一行)

信号示例:

  • SIGTERM(编号15):默认动作为term,即请求进程正常退出。
  • SIGQUIT(编号3)和SIGSEGV(编号11):默认动作为core,即终止进程并生成core dump。
    当进程退出时,如果core dump为0就表示没有异常退出,如果是1就表示异常退出了

  • SIGINT的默认处理动作是终止进程,SIGOUIT的默认处理动作是终止进程并且Core Dump,现在我们来验证一下
  • 首先解释什么是Core Dump。当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是core,这叫做Core Dump。
  • 进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做 Post-mortem Debug(事后调试)
  • 一个进程允许产生多大的 core 文件取决于进程的 Resource Limit(这个信息保存 在PCB中)。默认是不允许产生 core 文件的,因为 core 文件中可能包含用户密码等敏感信息,不安全
  • 在开发调试阶段可以用 ulimit 命令改变这个限制,允许产生 core 文件。首先用 ulimit 命令改变 Shell 进程的 Resource Limit,如允许 core 文件最大为 1024K: $ ulimit -c 1024

eg:关于core dump的演示

  • 如果你是云服务器,那么就需要手动的将core dump功能打开

  • ulimit -c 10240 打开

注意:如果 ulimit -c 10240 失败,如下:

已经ulimit打开了core,但是运行的时候没有生成core文件,图片是我的运行情况,然后右边是我把core关了,但是用代码去记录core的时候,打印的确还是1(正确来说,应该是0才对)

echo "./core.%e.%p"  |  sudo tee /proc/sys/kernel/core_pattern

./core.%e.%p  后面的 %e %p 代表对应后缀
执行这一句,普通用户的话,就在前面加sudo

然后这个我们就要切换成主用户 root 来【su -root】操作


那么关于进程信号的处理与产生我们就讲到这里啦,后面将会更新关于信号保存和处理的知识,敬请期待吧

【*★,°*:.☆( ̄▽ ̄)/$:*.°★* 】那么本篇到此就结束啦,如果我的这篇博客可以给你提供有益的参考和启示,可以三连支持一下 !💞💖!

 

Logo

助力广东及东莞地区开发者,代码托管、在线学习与竞赛、技术交流与分享、资源共享、职业发展,成为松山湖开发者首选的工作与学习平台

更多推荐