一. 信号入门
1. 生活中的信号
信号处理方式和我们取快递的方式特别像
- 你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能“识别快递”
- 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成“在合适的时候去取”。
- 在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你“记住了有一个快递要去取”
- 当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:1. 执行默认动
作(幸福的打开快递,使用商品)2. 执行自定义动作(快递是零食,你要送给你你的女朋友)3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏) - 快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话
在Linux 中,是进程来处理信号
信号其实也是一种进程间通信的方式。只不过,信号是传递事件,而进程间通信是传递数据。
2. 技术应用角度的信号
用户输入命令,在 shell 下启动一个前台进程
- 用户按 ctrl+c,这个键盘输入产生一个硬件中断,被 OS 获取,解释成信号,发送给了目标前台
- 前台进程因为收到了信号,进而引起进程退出
注:
1. ctrl+c发送SIGINT(2)信号。ctrl+c只有前台接收到
2. 在一个bash中,只允许有一个前台进程,前台进程运行时不能输入其他的shell命令
3. 进程运行后加 & 在后台运行,此时可执行其他的shell命令
4. Linux中用fg命令让后台进程在前台运行(fg %n将编号为n的任务转向前台)
5. jobs -l 查看任务,返回任务号和进程号
信号没来时,但进程知道怎么做。
3. 信号概念
信号是进程之间事件异步通知的一种方式,属于软中断
用 kill -l 命令查看系统定义信号列表
共有62个信号
1-31是普通信号 34-64是实时信号
我们一般关心1-31的普通信号
4. 自定义信号
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum,sighandler_t handler);
注册一个对特定信号的处理动作,当来了这个信号时,就运行这个指针指向的函数
参数:
1. 对应信号的编号(1-31),可改变信号的处理方式。(9号信号不能自定义,不能被捕捉)
2. 捕捉动作,处理信号函数的指针
5. 注:
- shell 可同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 ctrl+c 这种控制键产生的信号
- 信号相对于进程的控制流程来说是 异步 的(信号的产生与进程的执行是不相干的)
6. 处理信号的常见方式
- 忽略此信号
- 执行该信号的默认处理动作
- 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个函数,这个方式称为:捕捉(catch)一个信号
在 Linux 中,9号信号不能被捕捉,不能被自定义
信号产生之后不是立即被执行的
...
int arr[100] = {0};
...
cout << arr[120] << endl; // 1
cout << "run here" << endl; // 2
...
当运行到1时发生段错误,但2语句还是可能执行。因为信号的传递需要时间(产生异常->处理异常有时间窗口)
二. 产生信号
1. 通过终端按键产生信号(也就是键盘)
SIGINT(2) 的默认处理动作是终止进程,SIGQUIT(3)(ctrl+\ ) 的默认处理动作是终止进程并且Core Dump。
Core Dump --核心转储
首先解释什么是Core Dump。当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是core,这叫做core dump。进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存在PCB中)。默认是不允许产生core文件的。因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。首先用ulimit命令改变Resource Limit,允许core文件最大为1024K:$ ulimit -c 1024。
ulimit -c 0 关闭Core Dump
形成 core.异常进程pid 文件名的文件。core文件是给调试器看的
为什么要有Core Dump
因为其可以保存错误原因给调试器看,我们也可通过gdb去查看,进行Linux的调试
不是所有信号都需要core dump
2. 调用系统函数向进程发信号
kill
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid,int sig);
参数:
1. 发给哪个进程
2. 发几号信号
返回值:
成功返回0
失败返回-1
raise
#include <signal.h>
int raise(int signo); //给自己发信号
参数:发几号信号
返回值:
成功返回0
失败返回-1
abort
使当前进程接收到信号而终止(自己给自己发六号信号 SIGABRT)
#include <stdlib.h>
void abort(void);
如同 exit 函数一样,abort 函数总会成功,所以没有返回值
该信号被自定义后,运行完信号,该程序也终止
3. 由软件条件产生信号
如:SIGPIPE 是一种由软件条件产生的信号。
alarm
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
设定一个闹钟,也就是告诉 内核 在 seconds 秒后,给当前进程发SIGALRM(12)信号,该信号默认处理动作是终止当前进程
4. 由硬件异常产生信号
I/O是影响效率最大的因素,当我们程序中I/O越少,效率越高
*
int* p = NULL;
*p = 10;
//会报错:Segmentation fault
一般虚拟内存地址映射到物理地址,而野指针没有虚拟地址,页表会发现该错误,OS会察觉到,发送信号,将其杀死
野指针导致程序崩溃,是因为出现野指针会产生错误,被OS捕捉到,发送11号SIGSEGV信号给它,导致系统崩溃
*
int a = 1/0;
//会报错:Floating point exception
状态寄存器(CPU)硬件里面记录的 a 溢出了,OS 管理硬件,发现该进程中除0了,向这个进程发送 8号SIGFPE 信号,终止它的运行
注:我们自定义 8号信号 后,如果自定义打印,它会一直打印,OS会一直给它发8号信号,因为它没有处理错误。知道ctrl+c终止
5. 总
- 所有的信号,都必须经过OS的手发出,为什么?
我们给进程发信号,是想让进程挂掉或者处理某些事情,而只有OS能对进程指手画脚,而且,OS是进程的管理者 - 刚才四个都是信号产生的条件,OS去发送信号
- 信号的处理不是立即被处理,而是合适的时候。它会被暂时保存起来
- 一个进程没有收到信号的时候,它知道自己应对合法信号该如何处理
三. 信号保存
保存信号我们要注意以下两个问题:
- 是谁(哪一个信号)
- 是否?(该信号是否被接收到)
1. 简述
用 位图 去保存信号,其存储在 进程PCB 中
我们给进程发信号,就是去它的PCB中,找到信号位图,去修改其中该信号对应的位
怎么表示不同的信号
通过比特位的位置代表是哪一个信号。
00000000000000000000000000000000 第一个位置代表信号1 第二个位置代表信号2
怎么表示该信号是否被接收到
同样用01的方式表示该信号是否接收到
00000000000000000000000000000001 代表信号1被接收到
00000000000000000000000000000010 代表信号2被接收到
信号是OS发送,OS如何发送信号
在其位图中找到信号对应位置修改即可
所以OS给进程“发送”信号,更像是给进程“写”信号
2. 阻塞信号
信号其他相关常见概念
- 实际执行信号的处理动作称为:信号递达(Delivery)(默认,忽略,捕捉自定义)
- 信号从产生到递达之间的状态,称为:信号未决(Pending)(不止阻塞在未决)
- 进程可以选择阻塞(Block)某个信号。(所以我们没收到该信号可能时该信号被阻塞了)
- 被阻塞的信号产生时保持在未决状态,直到进程解除对信号的阻塞,才执行递达动作
- 注意:阻塞和忽略是不同的,只要信号被阻塞就不会递达;而忽略是信号递达之后可选的一种处理方式
3. 信号在内核中
block记录信号是否被阻塞,哪个信号被阻塞(它是位图结构,位置代表是谁,内容代表是否被阻塞)。block表也可称为信号屏蔽字
pending保存信号,位置代表是哪一个信号,内容代表是否接收到该信号
handler表是一个函数指针数组,其中的内容都是一个个指向信号处理方式的函数指针。所以signal函数修改的是递达方式,实际修改的是handler表
所以进程创建地址空间应该包含PCB,虚拟地址空间,页表,file_struct,信号(block,pending,handler)
例:
如1位置上,block为0,pending为0,当前进程没有收到一号信号,一号信号也没有被阻塞,如果收到一号信号,其处理动作为SIG_DFL
如2位置上,block为1,在pending中为1,说明当前进程收到了2号信号;被阻塞了,处于未决状态。该信号递达时,处理动作为SIG_IGN
在3位置上,block为1,pending为0,则说明该信号没有收到,但被阻塞了。万一解除阻塞,收到该信号,递达的处理方式为自定义函数
注:
- 在内核中,sign_struct是实时信号,不属于我们现在的结构
- 每个信号都有两个标志位,分别表示阻塞(block)与未决(pending),还有一个函数指针表示处理动作
- 如果在信号阻塞时产生多次(在Linux中,常规信号在递达前产生多次只计一次,实时信号产生多次依次放在一个队列中)
4. sigset_t
一张位图
从之前信号内核的图来看,每个信号只有一个 bit 的未决标志,非0即1,不记录该信号产生多少次,阻塞标志也是这样,未决和阻塞标志可以用相同的数据类型 sigset_t 来存储,sigset_t 称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
一般我们不能直接 & |来修改,一般必须使用特定的接口。因为在不同的平台,sigset_t 的底层定义不同,所以不建议使用位图的操作进行。
5. 信号集操作函数
#include <signal.h>
sigemptyset
初始化 set 所指向的信号集,使其中所有信号的对应 bit 清0。意味着其中不包含任何有效信号。
#include <signal.h>
int sigemptyset(sigset_t* set);
sigfillset
将其中所有信号的对应 bit 设置为1。其中所有信号都是有效的。
#include <signal.h>
int sigfillset(sigset_t* set);
sigaddset
将 signo 信号的 bit 设为1,相当于添加该信号
#include <signal.h>
int sigaddset(sigset_t* set,int signo);
sigdelset
将signo信号由1设置为0,相当于删除该信号
#include <signal.h>
int sigdelset(sigset_t* set,int signo);
sigismember
判断 signo 信号是否在当前信号集中
#include <signal.h>
int sigismember(const sigset_t* set,int signo);
注:
- 前四个函数都是成功返回0,出错返回-1
- sigismember,包含返回1,不包含返回0,出错返回-1
- 在使用sigset_t类型的变量之前,一定要调用 sigemptyset or sigfillset 做初始化,使信号集处于确定的状态
6. sigprocmask
对进程的信号屏蔽字(block)进行修改
#include <signal.h>
int sigprocmask(int how,const sigset_t* set,sigset_t* oset);
返回值:
成功返回0,失败返回-1
参数:
1. SIG_BLOCK set包含了我们希望添加到信号屏蔽字的信号,相当于mask = mask | set
SIG_UNBLOCK set包含了我们希望从当前信号屏蔽字中清除的信号,相当于 mask & ~set
SIG_SETMASK 设置当前的信号屏蔽字为set所指向的值,相当于mask = set
2. 第二个参数配合第一个去使用
3. 输出型参数,在我修改之前,先把老的屏蔽字保存一下
注:
oset如果为空指针,则无法读取
为非空指针,才能保存老的屏蔽字
set如果为空指针,则无法修改
为非空指针,才能修改
sigpending
获取当前进程的 pending 信号集
#include <signal.h>
int sigpending(sigset_t* set);
参数:输出型参数,用它将pending信号集拿出来
返回值:成功返回0,失败返回-1
四. 信号的捕捉
1. 用户态和内核态
我们的程序分为内核态和用户态
用户态:权限小,内核态:权限大
当我们调用系统调用时,因为用户没有权限执行OS代码,此时角色变为OS来执行代码
所以我们的程序肯定是在用户态和内核态切换执行
进程怎么一会用户态,一会内核态
内核的代码与数据在不同进程中,虚拟地址空间分区一样,映射到物理内存中也一样
用户的代码与数据在不同进程中,虚拟地址空间分区一样,但映射到物理内存是不一样的
所以 CPU 在执行代码时,此时在用户态,执行用户区的代码。在内核态时,执行内核的代码。
怎么判断此时在用户态还是内核态?
在 CPU 中有一个 cr寄存器,里面有1个值,表示此时为用户态还是内核态
在返回时,怎么知道要检查哪个信号?
在返回时,此时在内核态,是能去找到进程PCB,找到block,handler,pending的。然后对信号进行检测,选择对信号进行处理。
为什么执行自定义函数时要返回用户态
因为如果该动作,该函数为非法的,OS权限高,一旦出了什么问题,不好管理,所以要切换回用户态去调用。
2. 内核如何实现捕捉
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下: 用户程序注册了SIGQUIT信号的处理函数sighandler。 当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函 数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是 两个独立的控制流程。 sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。
从内核态->用户态时,不仅仅要做信号的捕捉处理,线程和进程的切换也在这个时候检测
sigaction
作用与定位与 signal 一样
#include <signal.h>
int sigaction(int signum,const struc###t sigaction* act,struct sigaction* oldact);
参数:
1. signum 信号
2. 你收到该信号后,你想做什么动作
3. 该信号默认情况下的动作(老的方法)
struct 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);
}
sigset_t sa_mask作用
如果我们在执行2号信号时,我们再给一个2号信号,再给一个系统调用,我们去处理2号信号的同时,又有2号信号要处理。为了让我们不再重复执行2号。所以sa_mask中记录了我们在执行信号时,想要屏蔽的信号。
总
所以我们在运用该函数时,我们需构建struct sigaction结构体,构建一个处理动作的函数,将flags设为0,设置sa_mask。
3. volatile
对于handler
void handler(int sig){
switch(sig){
case 1:
......
}
多个信号可以调用一个handler,所以我们可用case对不同信号进行自定义处理
例:
#include <stdio.h>
#include <signal.h>
int quit = 0;
void handler(int sig){
quit = 1;
printf("quit is already set to !\n");
}
int main(){
signal(2,handler);
write(!quit);
printf("end process\n");
}
//此时该程序中有死循环,我们ctrl+c停止。
但如果在编译时,加上 -o2去优化它
ctrl+c后,一直打印quit is already set to!,不会终止程序。
为什么呢?
因为main不会修改quit,而handler与main不属于同一执行流,并且会一直拿quit做判断。
而加上-o2选项后,编译器会将quit优化到寄存器上,而我们handler修改的是内存里的quit,此时造成数据不一致
而while循环只在意寄存器的值,所以ctrl+c不会终止程序。
为了解决这样的问题,我们用volatile修饰变量
因为 volatile 修饰的变量不能被优化,只能去找实际存储位置的值
volatile的作用
保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行
五. SIGCHLD信号
进程一章讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻 塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不 能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自 定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程 终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不 会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可 用。请编写程序验证这样做不会产生僵尸进程。
六. 注
- **SIGKILL(9)**和 SIGTOP(19) 信号无法被阻塞,无法被自定义,无法被忽略
- 一个进程无法被 (kill 信号) 杀死的情况:
信号被阻塞
用户自定义该信号
进程可能是僵尸进程
进程可能处于停止状态
- 信号被阻塞,给该信号依然可以加入未决信号集中