目录
嘿!收到一张超美的风景图,希望你每天都能顺心!
一,什么是信号
操作系统中的信号是一种在进程间传递信息和通知的机制。它可以用来通知进程发生了某种事件,比如用户按下了某个键盘按键、进程收到了某个信号或者发生了某个错误等。
生活中的例子:
- 手机收到新短信或来电时会发出提示音,这就是一种信号,通知用户有新的事件发生。
- 交通信号灯会发出红、黄、绿三种不同的信号,指示车辆和行人何时可以通行。
- 火灾报警器发出警报声,通知人们有火灾发生,需要立即疏散。
- 门铃响起,通知主人有人来访。
- 警报器在发现入侵者时会发出警报声,通知屋主有危险。
进程面对信号常见的三种反应概述
1. 忽略此信号。2. 执行该信号的默认处理动作。3. 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉 (Catch)一个信号。
这里我们以:kill 信号为例
我们平时手动结束一个进程: kill -9 PID, 本质上是让操作系统向目标进程发送 9信号,从而结束该进程。下面是kill 相关的信号表:
以上普通信号,我们认识七八个即可
指令:man 7 signal
通过man 7 signal, 我们可以查看信号的详细信息
试问:如何理解键盘中,组合键如何实现其功能?
答: 首先我们得知道,键盘的工作方式:通过中断的方式产生信息。同时,操作系统中也一定有识别该组合键产生信息的记录表。
一个进程,在接受信号后,必然会对信号进程储存。进程则是通过PCB(task_struct)中位图unsigned int来记录,信号是否存在。
而PCB又是内核数据结构,能修改PCB的也就只有OS自身。
即:信号发送的本质是,OS对目标进程的PCB中信号位图的修改。
回到组合键的问题:
二,产生信号
1.终端按键产生信号
signal
signum: 捕获该进程信号
handler : 信号处理方法(函数指针)
比如这样:
void signalmain(int signal)
{
cout << "信号处理中... : " << signal << " gitpid :" << getpid() << endl;
}
int main()
{
signal(SIGINT, signalmain);
while ( 1)
{
cout << "接受信号中...... " << endl;
sleep(1);
}
return 0;
}
运行过程中,我们不断通过,ctrl + c的组合键进行操作。运行结果如下;
从上面我们可以得出2个点:1.键盘的组合键确实是系统向当前进程发送信号。 2. 可以通过signal注册信号处理函数。
(signal使用须知:signal接口,并不是调用就会触发信号处理方法,它只是提前注册了信号处理函数;只有捕捉到特定信号时才会调用特定方法; signal接口一般出现在main函数开始。)
2. 进程异常产生信号
核心转储
这个在进程控制,waitpid函数,status参数中提到过:
大概就是这样:在进程发生异常退出时,能进行核心转储,形成一个二进制的特殊文件。
解释一下,为什么云服务器核心转储默认是关闭?:因为服务器的管理,是有另外一层服务负责,他们的任务是将异常挂掉的服务进程自动重启,如果因为进程老是异常挂掉,这会导致磁盘中存在大量的转储文件,会导致资源浪费。
我们在终端,再次打开 man 7 signal, 文档中core的意思就是核心转储。
3. 系统调用函数发送信号
kill
我们在终端输入kill -9 PID等等信号,在底层是调用了kill系统函数。kill也很简单。
kill : 进程 PID
sig: 信号编号
raise
功能很简单,就是让OS向自身进程发送信号
abort
功能: 终止自身进程(相当于向自身进程发送:kill -6)
小结:
上面这些接口,本质上都是利用系统接口调用,执行OS对应的系统调用代码,OS在PCB中设置或者是修改特定的值,进程对信号进行处理。
4. 由软件条件产生
例子:
我们以曾经的管道为例,如果读端不仅没读,而且将读端关闭;写端一直在写。作为单向通信,写已经没有意义了,OS会终止写进程,发送SIGPIPE(13),这构成不了软件级的条件。
alarm
功能:就是过 seconds 秒后,OS将自动向该进程发送SIGALRM(14)信号。
注意:当进程开始,当alarm被触发后向进程发送信号,但我们要注意,alarm只是发送SIGALRM信号,并不会阻断进程正常进行。
5. 硬件异常产生信号
例如:当前进程执行了除以0的指令, CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE信号发送给进程。
void func(int st)
{
cout << "接受到信号: " << st << endl;
}
int mian()
{
signal(SIGFPF, func);
int count = 150;
z = count / 0;
while(1) {sleep(1);}
}
现象:会一直打印 “接受到信号: 8”
2,硬件出现异常,进程就一定会退出吗?
不一定,如果我们没有捕获信号,那么进程默认退出;而捕获后,我们的就可以控制进程是否退出。
3. 为什么上面代码,会进入死循环??
在捕获信号后,信号处理中并未退出进程,该进程还在CPU运行队列中,将会被再次调度,当再次调度时OS还会继续检测,会继续发送信号,然后被捕获处理,接着继续在CPU运行队列中。
再比如:当前进程访问了非法内存地址,MMU(硬件)会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。
注: 野指针的异常,常常会有段错误: Segmentation fault
void func(int st)
{
cout << "接受到信号: " << st << endl;
}
int mian()
{
signal(SIGSEGV, func);
int *count = nullptr;
*count = 100;
while(1) {sleep(1);}
}
现象还是:死循环打印 “接受到信号: 11"
1. 如何理解地址访问??
首先我们访问一个数据目标,我们一定得访问其物理地址。那么中间会有一段虚拟地址转换为物理地址的过程,由页表(并不是软件结构,而是一种硬件) + MMU(Memory Manager Unit, 是一种硬件) , 当错误的地址被MMU(硬件寄存器)读取后,一定会报错,OS将MMU的报错转换为信号发送给进程,让其退出。
2. 死循环原因??
因为状态寄存器储存进程的状态,在信号发送完一次后,再次被调度时,检测到进程状态寄存器中的异常,则又会发送信号,然后被切换保存进程上下文,就这样一直继续下去。
三,信号其他概念
1. 进程中储存信号的内核结构
我们可以回想,之前是使用的signal系统接口:
结合上图,可以这么理解:sig就是pending中,对应信号的位置;第二参数位,我们暂时叫func, func就是对handler[sig]中设置处理函数的地址。
上面是对信号的自定义处理。
而执行默认处理
signal(SIGSEGV, SIG_DFL); // 对应的下标是0
忽略处理
signal(SIGSEGV, SIG_IGN); // 下标为1
这有一点需要注意的是:当OS检测到进程的一个信号,下标值为signalsum,并不是直接handler[signalsum]直接访问,而是先比较是否是SIG_DEL(0)或者SIG_IGN(1),再比较自定义处理。
2. sigset_t类型——信号集类型
信号集处理,相关函数:
#include <signal.h>int sigemptyset(sigset_t *set); // 将信号集全设置为0,信号集初始化。int sigfillset(sigset_t *set); // 将信号集设置为1int sigaddset (sigset_t *set, int signo); // 添加某一信号int sigdelset(sigset_t *set, int signo); // 删除某一信号int sigismember (const sigset_t *set, int signo); // 判断一个信号集的有效信号中是否包含某种 信号, 若包含则返回 1, 不包含则返回 0, 出错返回 -1
3. sigpending接口
4. sigprocmask接口
功能:调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。
set: 是我们自定义的一个信号集。
how: 传入的set,对该进程的阻塞信号集进行怎样的操作,比如:添加屏蔽字,删除屏蔽字,覆盖屏蔽字。
oset: 传入一个新set, 储存修改前旧的阻塞信号集。
how可选值:
实践:
void cmpshow(const sigset_t& set)
{
for (int i = 1; i <= 31; i++)
{
if (sigismember(&set, i))
{cout<< "1";}
else cout << "0";
}
cout << endl;
}
int main()
{
sigset_t set, oset;
sigemptyset(&set);
sigemptyset(&oset);
sigaddset(&set, 2); // 目标阻塞信号 2
sigpending(&oset);
int n = sigprocmask(SIG_BLOCK, &set, &oset);
assert(n == 0); // n 必然==0
(void)n; // 目的是调用一次n,避免在release版本中,n未被调用的警告
while (1)
{
sigset_t tmp;
sigemptyset(&tmp);
sigpending(&tmp);
cmpshow(tmp);
sleep(1);
}
return 0;
}
问:为什么没有设置pending信号集的接口?? 答:没必要,像kill, raise, abort指令接口都可以修改pending。
小结:我们的进程中的信号,都有各自接口负责管理,处理函数——signal; 信号未决表——sigpending; 阻塞信号集——sigprocmask。
代码知识加餐:
int n = sigprocmask(SIG_BLOCK, &set, &oset);
assert(n == 0); // n 必然==0
(void)n; // 目的是调用一次n,避免在release版本中,n未被调用的警告
问题1,既然进程可以自己捕捉信号,那我们让进程能捕获任何信号,并且全部阻塞信号,那这样就可以制作一个无法被动退出的进程了吗??
回答:OS的设计者已经考虑到这种情况了,所以解决方法是:kill -9 PID 这个信号是管理者信号(SIGKILL & SIGSTOP)无法被阻塞,也无法修改其处理方法(指结束进程)。
5. 重新理解进程在计算机中的运行
四,捕捉信号
1. 捕捉信号流程
疑问:为什么在内核态时,不直接调用信号处理函数呢?
答:如果信号处理函数中存在非法操作,那么贸然让计算机内核进行访问数据,计算机数据安全无法得到保证。
2. sigaction
功能: 检查或者修改信号处理方法。平时用的最多的是signal 用法简单易上手。
sig : 目标信号
struct sigaction * :一种放多种信息的结构体,其中就包括自定义函数处理方法sa_handler
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
void sigint_handler(int signo) {
printf("Caught SIGINT, exiting...\n");
exit(1);
}
int main() {
struct sigaction sa;
// 对结构体内数据初始化
sa.sa_handler = sigint_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
if (sigaction(SIGINT, &sa, NULL) == -1) {
perror("sigaction");
exit(1);
}
printf("Press Ctrl+C to send SIGINT...\n");
while (1) {
// Do some work
}
return 0;
}
重入函数
讲解:
调用了malloc或free,因为malloc也是用全局链表来管理堆的。调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
关键字——volatile
int tmp = 0;
void func(int sig)
{
cout << "tmp: " << tmp << "-->";
tmp = 1;
cout << tmp << endl;
}
int main()
{
signal(2, func);
while (!tmp);
return 0;
}
// 编译
signal : signal.cc
g++ -std=c++11 -o $@ $^ -O3 -g
# -O3 ————编译器对代码进行三级优化
./PHONY: clean
clean:
rm -rf signal
现象:进程开始运行后,进行ctrl + c,进程结束,一切正常。但未来我们的代码会跑在各种各样的编译器上,其中一些优化就会影响这个过程。
在编译时,添加 -o3 进行三级优化,由于tmp没有进行写入操作,寄存器直接用0代替了tmp,这就会导致我们使用 ctrl + c,无法终止循环。
而 volatile(易变的) 就是提醒计算机,请不要优化该数据。
SIGCHLD信号
我们学习的信号,以及触发信号后的处理函数,本身是一个很轻型的操作,所以我们一般对信号处理函数,不会是一个很大的算法运算。
下期:多线程
结语
本小节就到这里了,感谢小伙伴的浏览,如果有什么建议,欢迎在评论区评论,如果给小伙伴带来一些收获请留下你的小赞,你的点赞和关注将会成为博主创作的动力。