文章目录
![请添加图片描述](https://i-blog.csdnimg.cn/blog_migrate/5035e2a8e536d8233016116bc708b240.png)
1 总览信号
- 在信号还没有产生的时候,对于进程来说,应该知道信号产生之后应该有怎样的对应默认行为。
- 信号产生的时候,进程应该知道信号对应的含义,操作系统中已经内置了信号的处理方案
- 进程应该可以识别信号的种类,因为操作系统中已经内置了信号的种类
- 信号随时随地都有可能产生,而信号的产生和进程运行是一种“异步关系”
- 当信号产生后,进程不一定要马上去处理信号,因为可能有优先级更高的事情要做
- 信号已经到来,暂时没有处理,需要在“合适”的时候处理
- 信号已经到来,但是没有立刻处理,在这个“时间窗口”内,进程需要暂时保存信号
- 当准备处理信号的时候,有不同的处理方式
- 默认行为(终止进程,暂停进程,继续运行等操作系统中内置的处理方式)
- 自定义行为(自己定义信号到来的进程需要做哪些操作)
- 忽略行为(不处理这个信号了)
查看信号的种类
在Linux使用kill -l
查看所有的信号
一共有62种信号,其中131是一组信号,称之为“普通信号”,3464是一组信号,称之为“实时信号”,没有32,33两种信号。
更详细的信号内容可以使用man 7 signal
查看
信号是如何记录的?如何发送的?
信号记录在进程的task_struct
进程控制块中。并且使用位图来进程“是否”接收信号。其中位图中比特位的位置表示信号编号,比特位的内容表示是否收到信号。
进程收到信号其实就是进程控制块内的信号位图被修改了。而操作系统是进程的管理者,所以虽然信号发送的方式有很多种(kill
命令或者使用按键),但是信号位图只会被操作系统修改的。因此信号发送的本质就是操作系统直接去修改目标进程task_struct
中的信号位图。
补充:
有两个可以杀死进程的方式
- 使用
kill -9 进程号
的方式(发9号信号) - 使用
killall 进程名称
的方式
2 产生信号的方式
2.1 键盘组合键
ctrl + c
组合:发送2号信号,SIGINT
ctrl + \
组合:发送3号信号,SIGQUIT
ctrl + z
组合:发送20号信号,SIGTSTP
注意:
- shell中可以同时运行一个前台进程和多个后台进程,但是键盘组合键只能发送给前台进程。
- 运行一个进程时后加一个
&
可以将进程放在后台运行。
相关命令:
进程 &
:将进程后台后台运行
jobs
:查看后台任务
fg
:将后台任务放到前台
2.2 程序异常导致硬件问题
核心转储
当代码正常运行完毕,可以通过查看进程对应的退出码判断运行中的情况。
但代码运行中出错而异常退出时,通常都是通过调试来定位错误位置。在Linux中还有另一种方式:核心转储。即将进程在内存中的核心数据转而存储在磁盘上,形成core.pid
文件。
在使用gdb
调试的时候,就可以使用core-file core.pid
命令直接定位跳转到出错的位置。这种调试方式是“事后调试”。
如何打开核心转储?
如果使用的是虚拟机,默认核心转储功能是自动打开的。但是如果使用的线上服务器,默认该功能是关闭的。
可以使用**ulimit -a
命令查看系统资源**。
然后使用ulimit -c xxx
设置core file size
大小,就相当于打开核心转储功能了。
为什么进程会崩溃,程序会异常?
本质是因为收到了信号。当发生除零,野指针,越界访问的时候,都是因为收到了信号。进而进程执行默认行为。
为什么出现错误会收到信号?
当出现错误的时候,一定会在硬件上有所表现,而操作系统是软硬件的管理者,所以操作系统会识别到硬件错误,最终导致操作系统会向进程发送信号。
如:
- 当发生除零错误的时候,cpu中的状态寄存器中的状态会变化,操作系统识别到后将错误包装成信号的形式发送给对应的进程,找到进程pcb修改信号位图的第8号比特位,就相当于给进程发送8号信号了,然后进程就会执行8号信号对应的默认行为。
- 当发生野指针或者越界的时候,会先通过MMU(一种硬件单元)和页表配合使得虚拟地址和物理内存相互映射,但是发生错误时,MMU映射出错就会改变其状态信息,此时会被操作系统识别到,然后操作系统就会修改发生错误的进程中的pcb中的信号位图的第11位,相当于给进程发送了11号信号。
补充:其实语言级别中的try catch
机制底层其实是类似的,也就是当发生错误的时候,操作系统发送给进程的信号被捕捉了,因此进程就没有执行信号的默认行为。
2.3 系统调用函数
kill
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
- 作用
- 给pid号进程发送sig号信号
使用kill()函数,模拟实现kill命令
#include <stdio.h>
#include <sys/types.h>
#include <signal.h>
#include <stdlib.h>
void Usage(const char*proc)
{
printf("Usage: %s pid signo\n");
}
int main(int argc, char* argv[])
{
if (argc != 3)
{
Usage(argv[0]);
return 1;
}
pid_t pid = atoi(argv[1]);
int signo = aoti(argv[2]);
kill(pid, signo);
return 0;
}
raise
#include <signal.h>
int raise(int sig);
- 作用
- 进程给自己发送sig号信号
示例:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
int main()
{
// 一秒钟后会接收到2号信号
while (true)
{
printf("hello process\n");
sleep(1);
raise(2);
}
return 0;
}
abort
#include <stdlib.h>
void abort();
- 作用
- 进程给自己发送6号信号
SIGABRT
,也就是强制终止进程,就算信号被捕捉了也会终止当前进程
- 进程给自己发送6号信号
abort()
函数和exit()
函数类似都会使得进程终止。但是abort
是通过发送信号的方式终止终止进程,并且一定会调用成功。但是exit()
可能会调用失败。
2.4 软件条件
软件条件产生信号就是不满足某种条件而发送的信号。
- SIGPIPE
在使用匿名管道通信的时候,如果文件的读端关闭的话,此时写端再写入也没有意义了,所以此时操作系统就会发送SIGPIPE
信号给写端进程,终止该进程。
- SIGALAM
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
- 作用
- 设置一个闹钟,在seconds秒后给当前进程SIGALAM信号,默认行为是终止当前进程
- 返回值
- 闹钟剩余的秒数
示例:
要求:统计1秒钟之内可以打印的次数
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
int count = 0;
void handler(int signo)
{
printf("count: %d\n", count);
exit(1);
}
int main()
{
signal(14, handler);
alarm(1);
while (1)
{
count ++;
}
return 0;
}
总结:产生信号的方式有很多,但是所有的方式最终都是通过操作系统发送信号,只有操作系统才可以发送信号。
3 保存信号
- 信号相关补充概念
- 实际执行信号的处理行为称为信号递达
- 信号产生到信号递达之间的状态称为信号未决
- 进程可以选择阻塞某个信号
- 被阻塞的信号被接收时保持在未决状态,直到进程解除对此信号的阻塞,才能执行递达行为
- 阻塞和忽略不同
- 信号阻塞是让信号住在阻塞状态不让信号递达,直到解除阻塞状态
- 忽略是信号递达后的一种信号处理方式
- 内核数据结构
操作系统给进程发送信号的时候,会在pending
这个信号位图中标识信号是否被接收到。还有一个handler
函数指针数组,数组保存的是操作系统中定义的信号的处理方式,有三种处理方式:默认,忽略和自定义。
其中还有一个和pending
一模一样的位图,block
位图。其中比特位的位置表示信号的编号。比特位的内容表示是否阻塞该信号。
![[Pasted image 20220208160738.png]]
pending
:判断是否接收到信号- 因为是位图保存,所以如果同时被发送了同一个普通信号,只能接收一个普通信号。实时信号可以同时接收多个相同的信号。
block
:判断信号是否被阻塞handler
:对应信号的处理方式
流程:
-
操作系统给进程发送信号
-
修改该进程的
pending
信号位图 -
当时间合适的时候,检查
block
位图。- 如果
block
位图对应信号编号为1,说明该信号被阻塞,那么信号不可以被递达,直到解除阻塞状态才可以递达该信号。 - 如果
block
位图对应信号编号为0,说明该信号没有被阻塞,那么信号可以递达,然后处理。
- 如果
-
sigset_t
如果想要获得,修改上述的block
,pending
位图的话,可以使用sigset_t
定义的变量来操作。sigset_t
叫做“信号集”是Linux版本下专门用于获得信号位图的数据类型。
未决位图和阻塞位图都是使用segset_t
获得,使用sigset_t
可以设置信号集“有效”或“无效”状态。在未决信号集中可以表示“有效”或“无效”表示信号是否被接收,是否处于未决状态。 在阻塞信号集中可以表示“有效”或“无效”表示信号是否被阻塞。其中阻塞信号集也称“信号屏蔽字”。
- 信号集操作函数
虽然block
和pending
都是位图,按道理来说sigset_t
也是一个位图,我们可以直接使用微操作直接操作,但是不同的操作系统设计的sigset_t
可能有所不同,所以如果想要操作block
和pending
的sigset_t
变量必须要使用对应的接口来修改。
#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
sigemptyset
函数用于初始化set
指向的信号集,清空其中所有信号对应的比特位,表示没有任何有效信号sigfillset
函数用于初始化set
指向的信号集,填满其中所有信号对应的比特位,表示支持所有有效信号sigaddset
函数用于将信号设置成为“有效”sigdelset
函数用于将信号设置成为“无效”sigismember
函数用于判断一个信号集的有效信号是否包含某种信号
注意:在使用sigset_t
类型的变量前,一定要调用sigemptyset
或者sigfillset
做初始化。
sigprocmask
上面对sigset_t
设置的函数,只是对用户空间中的一个变量进行操作,但是并没有影响进程的信号位图。需要使用sigprocmask
函数读取或者更改进程的信号屏蔽字。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
- 作用
- 读取或者更改进程的信号屏蔽字
- 参数
how
:设置sigset_t
变量设置进进程信号屏蔽字的方式SIG_BLOCK
:将set
中的信号添加到进程的信号屏蔽字中,相当于mask=mask|set
SIG_UNBLOCK
:将set
中的信号从进程的信号屏蔽字中解除,相当于mask=mask&~set
SIG_SETMASK
:将set
覆盖在进程的信号屏蔽字上,相当于mask=set
set
:输出型参数,将set
输入进去之后可以返回被设置后的进程屏蔽字oset
:输出型参数,获得原来输入进去的sigset
变量
sigpending
#include <signal.h>
int sigpending(sigset_t *set);
- 作用
- 将进程的
pending
位图通过set
输出型参数返回
- 将进程的
案例:
- 先将2号信号屏蔽(使用
sigprocmask
修改block
位图) - 使用
kill
或者按键发送2号信号(因为已经阻塞了2号信号,所以信号不会递达) - 获取进程的
pending
信号集(使用sigpending
获取pending
位图) - 10秒钟后,解除2号信号的屏蔽(使用
sigprocmask
将信号屏蔽字恢复成没有阻塞的状态)
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void printPending(sigset_t *pending)
{
int i = 0;
for (i = 1; i < 32; i ++)
{
if (sigismember(&pending, i))
{
printf("1 ");
}
else
{
printf("0 ");
}
}
puts("");
}
void handler(int signo)
{
printf("信号屏蔽字已经恢复了\n");
}
int main()
{
sigset_t set, oset;
sigemptyset(&set);
sigemptyset(&oset);
sigaddset(&set, 2);
sigprocmask(SIG_SETMASK, &set, &oset);
sigset_t pending;
int count = 0;
while (1)
{
sigemptyset(&pending);
sigpending(&pending);
printPending(&pending);
sleep(1);
count ++;
if (count == 10)
{
sigprocmask(SIG_SETMASK, &oset, NULL);
signal(2, handler);
}
}
return 0;
}
4 处理信号
信号处理的方式有三个:默认行为,自定义行为,忽略行为。
- 默认行为:操作系统的实现函数,一般是终止进程或者暂停进程
- 自定义行为:用户自己写一个
sighandler
信号处理函数,当捕捉完信号后自动执行捕捉信号的信号处理函数 - 忽略行为:直接忽略该信号的接收
4.1 捕捉信号
如果信号的处理动作是用户自定义函数,在信号递达是调用这个函数,这称为“捕捉信号"
捕捉信号函数
- signal
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
- 作用
- 给进程注册信号,当进程接收
signum
信号的时候,不会执行默认行为,而是执行handler
函数。handler
函数的返回值一定是void
,参数是int
,即进程接收的信号的序号。
- 给进程注册信号,当进程接收
- 参数
signum
:信号种类handler
:回调函数
有些信号是不能被捕捉的,比如:9号信号
示例:
一直在打印"hello signal",并且可以捕捉2号信号。
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void handler(int signo)
{
printf("%d 信号已经被捕捉了\n", signo);
}
int main()
{
signal(2, handler);
while (1)
{
printf("hello signal\n");
sleep(1);
}
return 0;
}
- sigaction
#include <signal.h>
int sigaction(int signo, const struct sigaction* act, struct sigaction* oact);
- 作用
- 在捕捉了signo号信号之后,可以自定义执行
sigaction
中的自定义函数
- 在捕捉了signo号信号之后,可以自定义执行
- 参数
sigaction
是一个结构体,其中有一个变成是自定义函数,并且还有一些其他设置的参数act
:是用户自己设置的信号处理方法和参数oact
:是信号原来的默认信号处理方法和参数
struct sigaction
{
void (*sa_handler)(int);
void (*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask;
int sa_flags;
void (*sa_restorer)(void);
};
案例:
一直打印"hello signal",并且捕捉一次2号信号。
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
struct sigaction act, oact;
void handler(int signo)
{
printf("捕捉%d号信号", signo);
sigaction(2, &oact, NULL);
}
int main()
{
memset(&act, 0, sizeof act);
memset(&oact, 0, sizeof oact);
act.sa_handler = handler;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
sigaction(2, &act, &oact);
while (1)
{
printf("hello signal\n");
sleep(1);
}
return 0;
}
4.2 处理信号的时机和流程
Q:信号递达之后,会在“合适”的时候做处理。那么“合适”的时候是什么时候?
A:信号是在由内核态返回到用户态的时候处理的。
- 在执行用户自己的代码的时候,系统所处的状态叫做用户态。它是一种受监管的普通状态。
- 在执行内核的代码,系统所处的状态叫做内核态。它是一种权限非常高的状态。
什么时候系统会在用户态和内核态之间切换呢?
- 用户态切换为内核态
- 系统调用
- 时间片到了导致进程切换
- 异常、中断、 陷阱
- 内核态切换为用户态
- 系统调用返回
- 进程切换完毕
- 异常、中断、陷阱处理完毕
如图所示:
![[Pasted image 20220209095732.png]]
进程的进程地址空间的内核区中通过内核级页表映射的都是操作系统的代码,也就是所有的进程中内核空间的代码和数据都是一样的。因此进程无论如何切换,都能看到操作系统。但是因为权限的限制,只有进程在内核态的时候才能访问操作系统。
cpu中有寄存器专门用来表示当前主语内核态还是用户态;还有专门用来表示当前是否的是内核态页表还是用户态页表。通过状态和页表的识别就可以确定当前使用的是谁的数据,并且数据存放在哪里。
当用户态和内核态相互切换的时候,将页表进行切换(将用户/内核页表的地址切换)状态寄存器发生变化即可。
当在内核态的时候,识别到信号可以捕捉,可以直接访问用户态的代码和数据吗?
理论是可以的,因为内核态的权限是最高的,可以访问认为数据。但是实际上操作系统不是这样设计的,正是因为内核态的权限太大的,所以为了保护操作系统本身,所以它不能信任任何的用户,它不执行任何用户会对操作系统有伤害的代码。
因此当在内核态识别到捕捉的信号由自定义的信号处理函数的时候,需要切换到用户态执行用户自定义的函数,在执行完自定义函数之后,自动调用系统调用接口sigreturn
再次回到内核,最后调用sig_sigreturn()
函数返回到上次从用户态切换到内核态的位置。
信号处理流程图
补充:
- 记忆方法:其中处理自定义信号的流程中用户态和内核态切换有4次。可以将整个流程中4个箭头看成一个∞,∞中间画一条线其中会有4个交点,而交点的位置就是两态切换的时间。
sighandler
自定义信号处理函数和main
函数使用的是不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程
5 补充了解
5.1 可重入函数
举个栗子:
当main
函数调用了insert
函数向一个链表头插的时候,执行到一半,由于时间片到了,同时这个进程也接收到了一个信号,此时系统就会陷入内核,当处理完毕进行返回时需要处理接收的信号,而这个信号的处理方式是自定义方式,所以就进入了信号处理函数,而这个函数中正好也在使用insert
对相同的链表进行头插,头插完毕后,回到原来的执行流再次完成头插。
![[Pasted image 20220209120740.png]]
这样最终导致Node2
丢失,而存在内存泄漏的问题。
一个函数被不同的执行流重复调用,就叫做一个函数被重入了。像这样当函数被被重入后,出现错误就叫做“不可重入函数”;反之,如果一个函数只自己的局部变量或者参数就叫做“可重入函数”。
5.2 volatile
volatile
的作用:保持内存可见性
案例:
捕捉一次2号信号,然后正常退出
#include <stdio.h>
#include <signal.h>
int flag = 0;
void handler(int signo)
{
printf("捕捉%d号信号\n", signo);
flag = 1;
}
int main()
{
signal(2, handler);
while (!flag);
printf("process quit\n");
return 0;
}
handler
信号处理函数和main
函数是两个独立的执行流,也就是两个函数中的变量相互之间是不可见的。所以main
函数中的flag
因为只有取反判断,所以在gcc
编译优化级别较高的时候,这个变量就会直接本设置进入寄存器中保存和判断。此时如果捕捉了2号信号,将flag
设置成1,其实是将内存中的flag
设置成1,但是main
函数中的while
判断只从寄存器中判断,所以这个时候仍然会执行死循环。
使用gcc -O3
对程序进行编译,可以执行最高级别优化。
#include <stdio.h>
#include <signal.h>
// flag使用volatile修饰
volatile int flag = 0;
void handler(int signo)
{
printf("捕捉%d号信号\n", signo);
flag = 1;
}
int main()
{
signal(2, handler);
while (!flag);
printf("process quit\n");
return 0;
}
使用volatile
修饰,可以使得flag
变量在判断的是一定会先从内存中判断,这样就保持了内存的可见性。
5.3 SIGCHLD信号
使用wait
和waitpid
函数可以清理僵尸进程,父进程可以阻塞等待子进程的结束并清理,但是这样父进程就阻塞不能处理自己的工作了。也可以非阻塞式轮询地判断子进程是否结束并清理子进程,这样父进程在处理自己工作的同时需要关心子进程是否退出了,程序实现复杂。
其实还有一种清理子进程的方式。子进程在终止的时候会给父进程发送SGICHLD信号,这个信号的默认处理行为是忽略,所以父进程只需要自定义信号处理函数,在函数中清理子进程,这样就可以在子进程退出的时候自动清理,同时不用特别关心子进程的情况。
案例:
父进程fork
出一个子进程,子进程直接退出,父进程自定义SIGCHLD
信号的处理函数,在函数中清理子进程。
#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
void handler(int signo)
{
printf("child quit\n");
pid_t id;
// 循环式地非阻塞地等待收回子进程
while ( (id = waitpid(-1, NULL, WNOHANG)) > 0)
{
printf("wait child: %d\n", id);
}
printf("childs quit\n");
}
int main()
{
signal(SIGCHLD, handler);
pid_t id = fork();
if (id == 0)
{
printf("child is %d\n", getpid());
exit(2);
}
// 父进程不要退出
while (1)
{
printf("father is running\n");
sleep(1);
}
return 0;
}
注意点:
- 在
handler
函数中,需要while
循环清理子进程,因为pending
只能表示两态,但是可能多个子进程同时退出,所以需要循环将每一个子进程都回收。 waitpid
需要设置成为非阻塞等待式清理子进程,因为waitpid
的功能是检测是否还有子进程没有退出,如果退出就回收子进程。如果只有部分子进程退出,还有部分子进程还在执行任务的时候,如果设置成阻塞式清理的话,程序就会卡在handler
中不能回到main
函数的执行流中继续执行了。
补充:
由于UNIX的历史原因,还有可以有一种不产生僵尸进程的方法:父进程可以调用signal
函数将SIGCHLD
信号对应的行为改成SIG_IGN
,这种fork
出的子进程终止的时候, 会自动清理掉,不会变成将僵尸进程。
系统默认的忽略动作和signal
自定义的忽略通常是没有区别的,但是在这种情况下是一个特外。而且只有在Linux系统下才有这种情况
#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
#include <unistd.h>
int main()
{
// 将SIGCHLD信号处理方式设置为忽略
signal(SIGCHLD, SIG_IGN);
pid_t id = fork();
if (id == 0)
{
printf("child is %d\n", getpid());
exit(2);
}
// 父进程不要退出
while (1)
{
printf("father is running\n");
sleep(1);
}
return 0;
}
最后用一张图总结整个信号的从生到死的过程