上文:进程信号(上)-CSDN博客
在上篇中,我们讲了关于信号的保存,信号集的操作,那么这篇我们就来看看信号的原理。
目录
1. 键盘产生信号的原理
2. 信号是如何被处理的?
2.1 信号处理的原理
2.2 内核态与用户态
2.2.1 内核空间
2.2.2 内核态与用户态的切换
3. 捕捉信号的其他方式 sigaction
3.1 函数定义
3.2 参数说明
3.3 sigaction结构体
3.4 sa_flag标志
3.5 使用示例
4. 可重入函数
不可重入
可重入
5. volatile
1. 键盘产生信号的原理
在上篇中,我们讲了信号产生的几种方法, 其中键盘产生信号的原理是什么呢?
那么在看完上面这张图,有没有觉得似曾相识,他的原理和信号技术非常的相似,如果你有这样的想法,那可就有点倒反天罡了,因为信号技术源自于硬件中断技术。
2. 信号是如何被处理的?
上一节我们讲了信号处理的操作与过程,那么信号到底是怎么被处理的呢?
2.1 信号处理的原理
我们上节已经谈到,对信号的写入工作是OS做的,其实啊,对信号的相关工作都是OS做的。
当进程收到信号时,进程就会进入内核态,由内核对信号进行处理,如果我们没有对信号进行捕捉,那么当信号的默认处理是忽略时,进程会重新回到用户态;当信号的默认处理是终止时,进程会直接终止。当我们对信号进行了捕捉时,进程会切换到用户态执行处理函数,信号处理函数在最后是会执行特殊的系统调用进入内核态的(注意:信号捕捉函数与main函数是不同的控制流程)。进入内核态后一切正常时进入用户态继续执行主控制流程的代码。
有人会问,为什么不直接在内核态执行处理函数呢?那可不是内核态没有权限,而是人家压根不相信你的处理函数啊。用户态的权限是很小的,万一你的代码里有违法犯罪的动作,用户态根本执行不了,但要是内核态执行,那可就完蛋了,因此自定义的信号处理函数是由用户态执行的。
2.2 内核态与用户态
说了这么多,但还没说内核态和用户态到底是什么啊。
记得我们之前学习进程时的一张图吗?没错,这是进程的地址空间。
但此前我们只知其然而不知其所以然,我们知道进程的地址空间内有栈、堆、代码区常量区,但这可都是用户空间内的,内核空间可是没说一点儿。
2.2.1 内核空间
那么内核空间又是什么呢?
我们知道,进程的地址空间是一个个虚拟地址,而用户空间即指向内存中进程所需资源的部分,而内核空间即指向内存中OS运行所需资源的部分。
注意: 每个进程的内核空间指向相同,即所有进程共享内核空间。
有人会说,那所有的进程都指向同一个内核空间,那大家都可以访问它,我们之前学习的那么多进程间通信方式算什么?我们所学的进程具有独立性又算什么?每个进程都有交集了,还能称作独立吗吗?
别急,所有进程指向同一个内核空间,可不代表进程都能够访问它,这就是内核态与用户态的意义了。
2.2.2 内核态与用户态的切换
很好,知道了这些,但我们还不知道用户态与内核态是怎么进行切换的啊。
那么到底为什么要这么设计呢?
不仅仅是因为系统调用,更是因为进程间调度的问题,我们知道,进程的时间片一到,OS就会进行进程调度,切换进程以达到进程并发的目的。进程的调度是需要OS来做的,OS必须要拿到自己的资源才能做事,所以在每个进程里都存放一个内核空间,使得OS能够随时随地拿到自己的资源,保证自己的超然。
3. 捕捉信号的其他方式 sigaction
sigaction
是一个在 Unix 和类 Unix 系统中用于查询或设置信号处理方式的函数。它是 POSIX 信号处理接口的一部分,提供了比标准 C 库中的 signal
函数更为灵活和强大的功能。以下是关于 sigaction
函数的详细解释:
3.1 函数定义
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
3.2 参数说明
signum
:指定要查询或修改的信号编号。可以指定除 SIGKILL
和 SIGSTOP
以外的所有信号。
act
:指向一个 sigaction
结构体的指针,该结构体包含了新的信号处理函数和相关标志。如果此参数为 NULL,则仅查询而不修改信号的处理方式。
oldact
:如果此参数不为 NULL,则函数会将当前信号的处理方式保存到这个指向 sigaction
结构体的指针中。
3.3 sigaction结构体
struct sigaction {
void (*sa_handler)(int); // 信号处理函数,类似于 signal 函数的 handler
void (*sa_sigaction)(int, siginfo_t *, void *); // 另一个信号处理函数,提供额外信息
sigset_t sa_mask; // 在处理信号时,要阻塞的信号集
int sa_flags; // 信号处理选项标志
void (*sa_restorer)(void); // 废弃的字段,不再使用
};
3.4 sa_flag标志
SA_RESETHAND:当调用信号处理函数时,将信号的处理函数重置为缺省值 SIG_DFL。
SA_NODEFER:一般情况下,当信号处理函数运行时,内核会阻塞该信号。但如果设置了此标志,则不会阻塞。
SA_RESTART:如果信号中断了进程的某个系统调用,则系统自动启动该系统调用。
SA_SIGINFO:如果设置了此标志,则使用 sa_sigaction 字段作为信号处理函数,并且可以向处理函数发送附加信息。
3.5 使用示例
#include <unistd.h>
#include <iostream>
#include <signal.h>
void print(sigset_t pending)//打印当前pending位图
{
std::cout<<"pending: ";
for (int i = 31; i > 0; i--)
{
if (sigismember(&pending, i))
{
std::cout << "1";
}
else
{
std::cout << "0";
}
}
std::cout<<std::endl;
}
void handler(int signo)//二号信号的自定义捕捉函数
{
std::cout<<"signo:"<<signo<<std::endl;
sigset_t pending;
sigemptyset(&pending);
while (true)
{
sleep(1);
sigpending(&pending);
print(pending);
}
}
int main()
{
std::cout<<getpid()<<std::endl;
struct sigaction act;
act.sa_handler = handler;
act.sa_flags = 0;
sigaddset(&act.sa_mask, 11);
sigaddset(&act.sa_mask, 15);
sigaddset(&act.sa_mask, 5);
sigaddset(&act.sa_mask, 3);
sigaddset(&act.sa_mask, 4);//向阻塞信号集内添加信号
sigaction(2, &act, nullptr);//修改二号信号的捕捉函数,不需要返回旧的处理方式
while (1)
{
sleep(1);
}
return 0;
}
在进程运行时我们发现,在进程未接受到二号信号时,其他信号不会被阻塞,只有在进程处理二号信号的过程中,其他信号才会被阻塞。
这是因为sigaction与signal一样,只是告诉OS当进程收到该信号时这样处理,因此只有进程收到该信号时,才会执行sigaction函数。
4. 可重入函数
main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的 两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。想一下,为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?
如果一个函数符合以下条件之一则是不可重入的:
调用了malloc或free,因为malloc也是用全局链表来管理堆的。
调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
举个例子:
不可重入
#include <iostream> #include <unistd.h> #include <signal.h> #include <sys/types.h> #include <sys/wait.h> #include <string> void Print(std::string str) { std::cout << str << std::endl; } int Add(int a, int b) { return a + b; } int main() { pid_t pid = fork(); if (pid == 0) { int cnt = 5; while (cnt--) { sleep(1); std::string str = "我是子进程"; Print(str); } exit(0); } int cnt = 5; while (cnt--) { sleep(1); std::string str = "我是父进程"; Print(str); } wait(0); return 0; }
可重入
pid_t pid = fork();
if (pid == 0)
{
int cnt = 5;
while (cnt--)
{
int ret=Add(1,2);
}
exit(0);
}
int cnt = 5;
while (cnt--)
{
int ret=Add(3,4);
}
wait(0);
5. volatile
在有的平台下运行下面这段代码,程序收到二号信号后不会终结,这是为什么呢?
int g_val=0;
void handler(int signo)
{
std::cout<<"g_val: 0-> 1"<<std::endl;
g_val=1;//修改g_val
}
int main()
{
signal(2,handler);//捕捉SIGINT
while(!g_val);//当g_val=1,退出循环
std::cout<<"g_val:"<<g_val<<std::endl;
return 0;
}
在很多编译器里,会对代码进行优化。而在上面这段代码里是具有两个执行流的,编译器不会对捕捉函数进行扫描,编译器在主控制流程里并没有检测到对g_val的修改,就会将g_val存放在cpu的寄存器里以方便取用,而我们修改的g_val是内存里的,但程序拿g_val是从寄存器拿的,因此程序不会终结。
这个时候volatite就起到了至关重要的作用,在变量前加volatite,可以强制变量不被放入寄存器,而是从内存读取,这样上面的内存忽略问题就不存在了。