【Linux】进程信号全攻略

一:🔥 再谈信号的捕捉
关于信号捕捉有三种方式:

signal(2, handler); // 自定义捕捉
signal(2, SIG_IGN); // 忽略一个信号
signal(2, SIG_DFL); // 信号的默认处理动作

SIG_IGN 是一个特殊的宏,用于指示系统忽略该信号。

信号可能不会被立即处理,而是在合适的时候处理,那么合适的时候是什么时候呢?

先给结论:从进程的内核态返回到用户态的时候,进行处理。
💦 简单来说,执行自己的代码,访问自己的数据,这就叫做用户态。

💦 当我们进入系统调用时,我们以操作系统的身份来执行时,此时就进入了内核态,操作系统把我们的底层工作做完,做完这些工作后返回到我们的调用处,继续执行下面的代码,但是操作系统,由内核态返回到用户态时,在返回的这个时候信号的检测和处理

这是因为管理信号的数据结构(也就是我们的三张表)都位于进程的控制块(PCB)内,而PCB属于内核数据。因此,信号的检测和处理必须在内核态下进行。

当进程从内核态返回用户态时,内核会检查是否有待处理的信号,并根据信号的处理方式(默认处理、忽略或自定义处理)进行相应的操作。但操作系统不能直接转过去执行用户提供的handler方法,这是出于对安全性的考虑。

如果信号的处理动作是⽤⼾⾃定义函数,在信号递达时就调⽤这个函数,这称为捕捉信号。

由于信号处理函数的代码是在⽤⼾空间的,处理过程⽐较复杂,举例如下:

⽤⼾程序注册了 SIGQUIT 信号的处理函数 sighandler 。
当前正在执⾏ main 函数, 这时发⽣中断或异常切换到内核态。
在中断处理完毕后要返回⽤⼾态的 main 函数之前检查到有信号 SIGQUIT 递达。
内核决定返回⽤⼾态后不是恢复 main 函数的上下⽂继续执⾏,⽽是执⾏ sighandler 函数, sighandler 和 main 函数使⽤不同的堆栈空间,它们之间不存在调⽤和被调⽤的关系,是两个独⽴的控制流程。
sighandler 函数返回后⾃动执⾏特殊的系统调⽤ sigreturn 再次进⼊内核态。
如果没有新的信号要递达,这次再返回⽤⼾态就是恢复 main 函数的上下⽂继续执⾏了

🦋 关于信号捕捉的细节部分(sigaction函数)

signum:指定要设置或获取处理程序的信号编号。可以指定SIGKILL和SIGSTOP以外的所有信号。
act:指向 sigaction 结构体的指针,用于指定新的信号处理方式。如果此参数非空,则根据此参数修改信号的处理动作。
oldact:如果非空,则通过此参数传出该信号原来的处理动作。(如果你想恢复以前的方式,此参数就是保存之前的操作方式)
🦁 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); // 已废弃,不用关心
};

🎯 sigaction 函数和 signal 的明显区别:

如果你还想在处理 2 号信号(OS对2号自动屏蔽0),同时,对其它型号也进行屏蔽,你可以设置 sa_mask 变量。

当前如果正在对 2 号信号进行处理,默认 2 号信号会被自动屏蔽,对2号信号处理完成的时候,会自动解除对 2 号信号的屏蔽。为什么?这是因为,操作系统不允许同一个信号被连续处理。
如果 2 号信号处理完毕后,会自动解除对 2 号信号的屏蔽
下面是一段示例:

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

void PrintBlock()
{
sigset_t set, oset;
sigemptyset(&set);
sigemptyset(&oset);

sigprocmask(SIG_BLOCK, &set, &oset);
std::cout << "block :";
for(int signo = 31; signo > 0; signo--)
{
if(sigismember(&oset, signo))
{
std::cout << 1;
}
else
{
std::cout << 0;
}
}
std::cout << std::endl;
}

void PrintPending()
{
sigset_t pending;
::sigpending(&pending);

std::cout << "Pending :";
for(int signo = 31; signo > 0; signo--)
{
if(sigismember(&pending, signo))
{
std::cout << 1;
}
else
{
std::cout << 0;
}
}
std::cout << std::endl;
}

void handler(int signo)
{
static int cnt = 0;
cnt++;
while (true)
{
std::cout << "get a sig" << signo << ", cnt: " << cnt << std::endl;
PrintBlock();
::sleep(1);
break;
}
}

int main()
{
struct sigaction act, oact;
act.sa_handler = handler;
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, 3);
sigaddset(&act.sa_mask, 4);
sigaddset(&act.sa_mask, 5);
sigaddset(&act.sa_mask, 6);
sigaddset(&act.sa_mask, 7);

::sigaction(2, &act, &oact);

while (true)
{
// PrintBlock();
PrintPending();
::pause();
}

return 0;
}

🌳 我对3,4,5,6,7号信号也同时做了屏蔽,此时发送2号信号,pending值也是由0置为1的。

二:🔥 穿插话题 - 操作系统是怎么运⾏的
🦋 硬件中断
• 中断向量表就是操作系统的⼀部分,启动就加载到内存中了
• 通过外部硬件中断,操作系统就不需要对外设进⾏任何周期性的检测或者轮询
• 由外部设备触发的,中断系统运⾏流程,叫做硬件中断

🦋 时钟中断
☁️ 定义:Linux 时钟中断是指在 Linux 操作系统中,系统定时器周期性地触发中断,这个中断被称为时钟中断。时钟中断源于硬件定时器,通常由计算机的主板芯片或处理器芯片提供,通过定时器计数器来实现定时中断功能。

功能:

维护系统时间:每当一个时钟中断发生时,内核会更新系统时间的计数值。这个计数值可以是自世界时间开始的毫秒数,也可以是自系统启动以来的滴答数(tick)。通过定时更新系统时间,系统可以保持时间的准确性,为用户提供可靠的时间信息。

任务调度:在多任务操作系统中,内核需要决定哪个进程将获得CPU的控制权。时钟中断提供了一个计时器,每当中断发生时,内核会检查当前运行的进程是否到达了它应该运行的时间片。如果一个进程的时间片用完了,内核就会重新选择下一个要运行的进程,并切换上下文,将控制权交给新的进程。这样保证了系统中进程的公平调度,提高了系统的整体性能。

计算进程执行时间:每当一个进程或线程被抢占,切换到另一个进程或线程时,时钟中断记录下了抢占发生的时间。通过记录不同进程和线程的执行时间,可以分析其调度情况,了解系统中进程的运行情况,为性能优化提供依据。

🦋 OS 死循环
💜 如果是这样,操作系统不就可以躺平了吗?对,操作系统⾃⼰不做任何事情,需要什么功能,就向中断向量表⾥⾯添加⽅法即可.操作系统的本质:就是⼀个死循环!

void main(void) /* 这⾥确实是void,并没错。 */
{
/* 在startup 程序(head.s)中就是这样假设的。 */
...
/*
* 注意!! 对于任何其它的任务,'pause()'将意味着我们必须等待收到⼀个信号才会返
* 回就绪运⾏态,但任务0(task0)是唯⼀的意外情况(参⻅'schedule()'),因为任
* 务0 在任何空闲时间⾥都会被激活(当没有其它任务在运⾏时),
* 因此对于任务0'pause()'仅意味着我们返回来查看是否有其它任务可以运⾏,如果没
* 有的话我们就回到这⾥,⼀直循环执⾏'pause()'。
*/

for (;;)
pause();
} // end main

🦋 小结
操作系统的本质就是一个 死循环 + 时钟中断(不断调度系统的任务): 操作系统中的进程调度依赖于时钟来分配处理器时间。时钟中断定期触发,使操作系统能够检查当前进程的运行状态,并根据需要进行进程切换或调整进程的优先级。时钟通过产生时钟中断来实现进程的时间片管理。每个进程被分配一个固定的时间片来执行,当时钟中断发生时,如果当前进程的时间片已经用完,则操作系统会将其挂起,并选择另一个进程来执行。这种方式确保了每个进程都有机会获得处理器资源,从而提高了系统的整体性能。因此,时钟通过提供稳定的时间基准、实现进程调度、处理中断以及提高系统稳定性与可靠性等方面来推动操作系统的运行。它是操作系统中不可或缺的一部分,对于保证系统的正常运行和任务的有序执行具有重要意义。

🦋 如何理解系统调用
软中断
• 上述外部硬件中断,需要硬件设备触发。
• 有没有可能,因为软件原因,也触发上⾯的逻辑?有!
• 为了让操作系统⽀持进⾏系统调⽤,CPU 也设计了对应的汇编指令 (int 0x80 或者 syscall), 可以让CPU内部触发中断逻辑。
所以:

用户程序在代码中调用系统调用时,会执行一个特殊的中断指令,如 int 0x80(在x86架构中)或 syscall 指令。在执行中断指令前,将系统调用号放入特定的寄存器中(如eax寄存器)。CPU 暂停当前执行的代码,根据中断的中断号,在中断向量表中找到对应的中断处理程序(如Linux中的 system_call ),并调用它。中断处理程序会检查系统调用号的有效性,并从系统调用表中找到相应的系统调用函数进行调用。

我们只要找到特定数组下标的方法,就能执行系统调用了。

问题:
• ⽤⼾层怎么把系统调⽤号给操作系统? - 寄存器(⽐如EAX)
• 操作系统怎么把返回值给⽤⼾?- 寄存器或者⽤⼾传⼊的缓冲区地址
• 系统调⽤的过程,其实就是先int 0x80、syscall陷⼊内核,本质就是触发软中断,CPU就会⾃动执⾏系统调⽤的处理⽅法,⽽这个⽅法会根据系统调⽤号,⾃动查表,执⾏对应的⽅法
• 系统调⽤号的本质:数组下标

可是为什么我们⽤的系统调⽤,从来没有⻅过什么 int 0x80 或者 syscall 呢?都是直接调⽤上层的函数的啊?
• 那是因为 Linux 的 gnu C 标准库,给我们把⼏乎所有的系统调⽤全部封装了。

三:🔥 缺⻚中断?内存碎⽚处理?除零野指针错误?
缺⻚中断?内存碎⽚处理?除零野指针错误?这些问题,全部都会被转换成为CPU内部的软中断,然后⾛中断处理例程,完成所有处理。有的是进⾏申请内存,填充⻚表,进⾏映射的。有的是⽤来处理内存碎⽚的,有的是⽤来给⽬标进⾏发送信号,杀掉进程等等。
📌 所以:

操作系统就是躺在中断处理例程上的代码块!
CPU内部的软中断,⽐如 int 0x80 或者 syscall,我们叫做 陷阱
CPU内部的软中断,⽐如除零/野指针等,我们叫做 异常。(所以,能理解“缺⻚异常”为什么这么叫了吗?)
四:🔥 如何理解内核态和⽤⼾态

结论:
• 操作系统⽆论怎么切换进程,都能找到同⼀个操作系统!换句话说操作系统系统调⽤⽅法的执⾏,是在进程的地址空间中执⾏的!

内核态: 0-4G 范围的虚拟空间地址都可以操作,尤其是对 3-4G 范围的⾼位虚拟空间地址必须由内核态去操作。

3G - 4G 部分⼤家是共享的(指所有进程的内核态逻辑地址是共享同⼀块内存地址),是内核态的地址空间,这⾥存放在整个内核的代码和所有的内核模块,以及内核所维护的数据。

关于特权级别,涉及到段,段描述符,段选择⼦,DPL,CPL,RPL等概念, ⽽现在芯⽚为了保证兼容性,已经⾮常复杂了,进⽽导致OS也必须得照顾它的复杂性,这块我们不做深究了。

⽤⼾态就是执⾏⽤⼾ [0,3] GB 时所处的状态

内核态就是执⾏内核 [3,4] GB 时所处的状态

区分就是按照CPU内的CPL决定,CPL的全称是Current Privilege Level,即当前特权级别。

⼀般执⾏ int 0x80 或者 syscall 软中断,CPL会在校验之后⾃动变更

这样会不会不安全??

五:🔥 可重入函数

main 函数调用 insert 函数向一个链表 head 中插入节点 node1, 插入操作分为两步,刚做完第一步的 时候, 因为硬件中断使进程切换到内核, 再次回用户态之前检查到有信号待处理, 于是切换 到 sighandler 函数, sighandler 也调用 insert 函数向同一个链表 head 中插入节点 node2 , 插入操作的两步都做完之后从 sighandler 返回内核态, 再次回到用户态就从 main 函数调用的 insert 函数中继续往下执行, 先前做第一步之后被打断, 现在继续做完第二步。结果是 main 函数和 sighandler 先后向链表中插入两个节点, 而最后只有一个节点真正插入链表中了。
像上例这样, insert 函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数, 这称为重入, insert 函数访问一个全局链表, 有可能因为重入而造成错乱, 像这样的函数称为不可重入函数, 反之, 如果一个函数只访问自己的局部变量或参数, 则称为可重入 (Reentrant) 函数。想一下, 为什么两个不同的控制流程调用同一个函数, 访问它的同一个局部变量或参数就不会造成错乱?
\qquad
如果一个函数符合以下条件之一则是不可重入的:(大部分函数是不可被重入的,可重入或者不可重入,描述的是函数的特征)

调用了malloc或free,因为malloc也是用全局链表来管理堆的。
调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
六:🔥 使用信号对全局变量进行操作出现的问题(volatile)
int gflag = 0;

void changedata(int signo)
{
std::cout << "get a signo:" << signo << ", change gflag 0->1" << std::endl;
gflag = 1;
}

int main() // 没有任何代码对gflag进行修改!!!
{
signal(2, changedata);

while(!gflag); // while不要其他代码
std::cout << "process quit normal" << std::endl;
}

我们捕捉了2号信号,当我们执行了2号信号后,全局变量 gflag 就会被更改为1,那么 main 函数中的 while 就会停止执行,因为 cpu 在执行 while 循环的时候,实时的从内存中取 gflag 来进行比较,但在这里我们对编译进行优化,这会让 cpu 保存之前在内存中取的 gflag 的值,只在内存中取最开始的一次值, 其余都在寄存器的缓存中读取,这就会导致 gflag 的变化无法被在 while 中实时更新,导致 while 循环无法结束:g++ -o test test.cc -O1(O1,是基础优化)

如果想让 gflag 在此优化下生效,就要使用 volatile(volatile关键字可以确保变量的可见性(即确保变量每次访问时都直接从内存中读取)关键字调整后,程序成功退出:

volatile int gflag = 0;

七:🔥 SIGCHLD信号
子进程退出时,不是静悄悄的退出的,会给父进程发送信号–SIGCHLD信号。
下面是一段示例:

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

void notice(int signo)
{
std::cout << "get a signal: " << signo << " pid: " << getpid() << std::endl;
while (true)
{
pid_t rid = waitpid(-1, nullptr, WNOHANG); // 阻塞啦!!--> 非阻塞方式
if (rid > 0)
{
std::cout << "wait child success, rid: " << rid << std::endl;
}
else if (rid < 0)
{
std::cout << "wait child success done " << std::endl;
break;
}
else
{
std::cout << "wait child success done " << std::endl;
break;
}
}
}

void DoOtherThing()
{
std::cout << "DoOtherThing~" << std::endl;
}

int main()
{
signal(SIGCHLD, notice);
for (int i = 0; i < 10; i++)
{
pid_t id = fork();
if (id == 0)
{
std::cout << "I am child process, pid: " << getpid() << std::endl;
sleep(3);
exit(1);
}
}
// father
while (true)
{
DoOtherThing();
sleep(1);
}

return 0;
}

这段代码创建了多个子进程,并在子进程结束时通过 SIGCHLD 信号进行处理。

当 SIGCHLD 信号被捕获时,notice 函数会被调用。这个函数会进入一个无限循环,尝试使用 waitpid 以非阻塞方式 (WNOHANG) 等待任何已终止的子进程。这是合理的,因为它允许父进程在子进程终止时及时回收资源,同时不阻塞父进程的其他操作。
————————————————

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。

原文链接:https://blog.csdn.net/weixin_50776420/article/details/143629991

版权声明:
作者:SE_Wang
链接:https://www.cnesa.cn/2403.html
来源:CNESA
文章版权归作者所有,未经允许请勿转载。

THE END
打赏
海报
【Linux】进程信号全攻略
一:🔥 再谈信号的捕捉 关于信号捕捉有三种方式: signal(2, handler); // 自定义捕捉 signal(2, SIG_IGN); // 忽略一个信号 signal(2, SIG_DFL); // 信号的默……
<<上一篇
下一篇>>