进程信号
本节主要内容
- 掌握Linux信号的基本概念
- 掌握信号产生的一般方式
- 理解信号递达和阻塞的概念,原理。
- 掌握信号捕捉的一般方式。
- 重新了解可重入函数的概念。
信号主要会涉及到:
- 信号的概念
- 信号的产生
- 信号的注册
- 信号的注销
- 信号的捕捉处理
- 自定义信号处理函数
信号的概念
- 信号是一个软件中断(信号是操作系统内核所给我们进程所传递的东西,当进程拿到这个东西的时候他是有权利选择不去处理,也就是说,比如操作系统现在给我们进程一个信号,这个信号的作用就是杀死这个进程,但是如果进程收到了这个信号,但是没有去处理这个信号的话,那么我的这个进程实际上还是存在的,只有说是他接收到了这个信号,并且他去执行了这个信号的话,那么这个进程就不再继续存在了)
- 信号的种类,我们在终端敲下kill -l的命令可以使得我们看见全部信号的名称
- 1~31被称之为非可靠信号—也就是说信号有可能会丢失(非可靠进程的意思其实就是当一样的信号给了同一个进程多次的时候,这个进程可能只处理一次这个信号,当然,也可能去多次处理这个进程,这是一件不确定的事情,如果是2号信号出现了两次的话,那么有可能处理一次,有可能处理两次,这都是有可能的)
- 34~64被称为可靠信号—信号是不可能丢失的(就比如说我现在给一个进程一个40号信号,当这个信号给了一个进程之后,给一次就给到这个进程了,就相当于说是他是软件当中的,告诉给进程意思就是说,你现在已经有了一个40号的进程了,当下一次再来一个40号进程的时候,那么当进程处理信号的时候,这个进程就是会去处理两次40号进程的,就是说会处理两次)
信号的产生
3.1 硬件产生
- ctrl+c----当我们在终端敲下ctrl+c的时候,系统给当前进程发送了一个2号信号SIGINT(如果不知道信号的含义的话, 可以再在终端敲下man 7 singal,SIGINT意思就是从键盘打断,同时发送给前台进程
- SIGINT信号,对应的信号变好是2,动作是Term,是终止的意思,意思是从键盘当中打断
- Term的解释如下所示----默认的动作是终止进程
- 给出一个死循环的操作
- 运行结果如下所示,这个时候我们按下ctrl+c,程序其实就会结束掉了,当按下ctrl+c的时候,其实发送了一个2号SIGINT信号
- 那么,如何把一个进程放到后台去运行,就是在启动命令之后加&符号,就是将其放到后台去运行,fg就是把刚刚放到后台的进程在重新放回到前台来运行
- 放到后台去运行就是你现在有一个程序正在死循环,你把他放到后台去运行,你现在在终端敲下ls,也实际上还是会有反应的
- 有加号的就表示是一个前台进程,没有加号的就表示不是前台进程
- 可以使用fg将其变为前台进程, 这样子再去是用ls的话,其实就没有任何的作用了
- 但是当你把一个进程放到后台去运行的话,你这个时候再去使用ctrl+c实际上也就没有任何的作用了,无法终止掉一个进程
- 所以说ctrl+c实际上是发送给前台进程的
- ctrl+z----会给当前进程发送一个SIGTSTP(20)信号
- 使用ctrl+z之后所处的状态是停止状态
- ctrl+|(竖线)----SIGQUIT(3) 当接收到了这个信号之后,当前目录下就会多出一个core.xxxx文件,这个文件被称为核心转储文件,就是程进程在崩溃的那一瞬间内存映像保存下来的文件,为什么会有内存映像保存下来的文件,其实就是操作系统给我们程序员保存下来的一个证据,保存的是代码中到底哪里有问题,到底是哪里越界了等等,从而我们可以去排查代码中哪里有错误
- 下面的代码解引用空指针是一定会导致代码的崩溃的
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <signal.h>
void func()
{
int* lp = NULL;
*lp = 10;
}
int main()
{
func();
while(1)
{
printf("linux-57\n");
sleep(1);
}
return 0;
}
- 然后我们可以看出来,上面的代码其实是崩溃了的
- 然后多出了一个文件
- 崩溃之后, 使用gdb +可执行程序名称+coredump文件
- 打开之后,我们先不开始调试,我们首先会看到一个11号信号,接收到11号信号导致了一个段错误的发生
- 然后这个时候们可以使用bt去查看调用堆栈,看调用堆栈的时候我们需要从下往上的去查看,我们在main函数的第12行调用了func这个函数,崩溃的原因在于我们func函数里面的第七行的位置产生了崩溃
- 然后我们现在使用f + 堆栈号,就可以跳到相应的堆栈里面去,然后就可以去查看为什么这行代码可以使得我们的程序崩溃,然后就可以看出来代码崩溃的原因其实是因为解引用了空指针而导致的代码崩溃
- 但是假如说我们现在单看下面的这一行代码,其实我们是无法知道到底是因为什么原因才导致的我们的代码崩溃掉了
- 那么我们如何查看指针的内容,从而知道这个指针到底是合法的还是非法的呢?
- 我们可以通过打印变量的内容来进行查看
- 针对内存访问越界的情况,代码其实不一定是会崩溃的,代码到底会不会崩溃主要取决你你越界访问的这一块内存有么有被别人所使用,如果没有被别人所使用的话,那么其实代码是不会崩溃的,也就是说,内存的越界访问不一定会导致代码 的崩溃
- 最好不要限制coredump的大小
- 还有一个,如何查看当前操纵系统的磁盘大小,我们可以使用df -h
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <signal.h>
void func()
{
int* lp = NULL;
*lp = 10;
}
void func1()
{
int arr[10] = {1,2,3,4,5,6,7,8,9,10};
printf("arr[9] = %d\n", arr[9]);
printf("arr[10] = %d\n", arr[10]);
}
void func3()
{
char* lp = (char*)malloc(10);
strcpy(lp, "so easy");
printf("%s\n", lp);
free(lp);
//如果调用两次free(lp)代码是一定会崩溃的
lp = NULL;
//这是不会发生崩溃的
//free(NULL)是不会崩溃的
free(NULL);
}
int main()
{
while(1)
{
printf("linux-57\n");
sleep(1);
}
return 0;
}
软件产生
- 函数
- 也就是说,下面的代码如果kill函数调用成功的话,就不会再去打印while循环内部的内容了
- getpid,就是当前谁正在调用,就去获取谁的进程号,然会讲信号值发送给这个进程就可以了
- 命令
- kill命令 ,你想要给进程传递哪个信号,就在kill命令的后面加上信号的序号,然后后面再跟上你想要操作的进程的pid
- 下面的意思其实也就是说给33096号进程加上一个2号信号,也就是ctrl+c的信号
- abort函数—谁调用给谁发送信号
- abort其实是对kill的封装
- 程序运行结果如下所示:
- 也就是说当前进程收到了6号信号
信号注册
- 信号的注册其实会牵扯到1个位图+1个sigqueue队列
- 因为信号是一个软件,所以当前进程中一定有存储信号的地方
- 现在去task_struct结构体里面查看一下描述信号的成员变量是哪个,先去查看源码
- sched.h里面定义了我们task_strcut结构体
- 和信号相关的成员变量----signal handlers
- 下面的都是和信号相关的变量
- 下面的这个其实就是位图
- sigpending这个结构体在signal.h中
- 下面的list_head其实就是一个双向链表
- sigset_t 他本质上其实是一个结构体,拥有一个无符号长整形的数组,那么在哪找这个东西的本质呢?在linux下如何去查看源码的定义呢?我们可以使用ctags,他会给我们的源码去建立一堆的索引
- task_struct结构体里面有一个struct sigpending pending这样子的一个变量
- struct sigpending pending是一个结构体的对象,对象所占有的内存是在task_strcuct结构体内部进行展开的,而结构体指针指向的内存并不是在内部所展开的
- 虚线的意思其实就是不是真正的指向,以为其实实在内部展开的,但是内部画起来不太方便展示,所以指到外面,这样看起来其实是会更加的方便一些
- 比特位的下标是从0开始的还是从1开始的,答案就是比特位的下标其实是从0开始的,如果有8个比特位的话,那么比特位的下标其实就是0~7
- 下图说明比比特位是从0开始的
- 当前操作系统中是没有0号信号的,本身位图被初始化出来的话,其实他应该全部都是0的,当我们来到一个信号的位置的时候,我们会把这个位置的信号置为1,就是将对应的比特位置为1
- 是数组元素的大小
- _BITS_PER_LONG 定义的大小是32
- 所以数组元素的个数就是64/32=2,那么结果就是数组元素的个数是2个,也就是说要有2个long,就是说有128个比特位,那么很显然,128远远是大于62的,系统中有62个信号,所以说,现在没有被用到的比特位其实就是预留比特位
非可靠信号的注册
- sigqueue结点是和非可靠信号息息相关的,也就是说,假设我现在来了一个2号信号,那么我就需要把2号信号的sigqueue结点添加到sigqueue队列中去,假设我现在来了一个16号信号,那么我就需要把16号信号的sigqueue结点添加到sigqueue队列中去
- 下图中的椭圆表示的是结点
可靠信号的注册
信号的注销(就相当于我们把我们注册的进程取消掉)
非可靠信号的注销
- 首先需要将信号在sig位图当中对应的比特位从1置为0
- 将该信号的singqueue结点从sigqueue队列中进行出队操作
- 但是这里我们还需要注意一个点就是,虽然我们现在把这个信号注销掉了,但是我们之前确确实实收到了这个信号,那么当我把信号的结点进行出队操作的时候,其实操作系统还有对当前的信号进行处理的操作也就是说,信号出队之后我们还需要对信号进行处理的操作
可靠信号的注销
- 首先需要将信号的sigqueue结点从sigqueueui列中进行出队的操作
- 然后需要去进判断,判断sigqueue队列当中是否还有相同的sigqueue结点,如果没有了的话,首先需要将信号在sig位图当中对应的比特位从1置为0,如果还存在的话,就不会去更改sig位图中对应的比特位,也就是说,不会将其从1置为0,因为还存在有相同的信号在队列中
信号的处理
- 前两种信号处理方式是操作系统内部给我们进行定义的,就是说,内部其实已经给我们定义好了的
- 自定义信号其实就可以理解成,比方收我们都知道其实2好进程是用来终止一个进程的,但是现在我不想让2号进程拥有终止进程的这个功能,那么我就需要使用自定义信号处理方式去更好2号信号所代表的意思—那么,其实现在号信号表示的是将一个进程去终止,但是现在我允许你去更改2号信号的处理方式,也就是说让他去执行一个不同的函数
- 信号的处理方式有三种
- 有可能会被问到什么是僵尸进程,僵尸进程实际上就是子进程先于父进程退出,但是父进程并没有注意到子进程的退出,那么这个时候子进程就会成为僵尸进程,但是其实子进程在退出的时候还会给父进程发送一个SIGCHILD信号,而且父进程也确实是收到了这个信号,只不过父进程对这个信号的处理方式其实是忽略处理的,所以父进程其实还是没有注意到子进程的退出,那么子进程其实还是成为了僵尸进程
- 使用man 2 signal函数来进行查看,这个是操作系统内核给我们提供的接口
- typdef那一行的内容其实就是一个函数指针,返回值类型为void类型,参数为int类型
- 下面那一个的第二个参数其实是一个函数指针,那么很明显了, 就是用来接收函数的地址的
- sighandler_t其实是一个函数指针,函数指针里面包含的其实就是函数的地址
- signal函数的功能是用来更改掉信号的处理动作的,就是假如说我现在传进去的第一个参数是2,也就是说我吧2号信号传递进去了,那么我要把2号进程的动作更改成什么呢?函数的第二个参数就是我要把2号信号所改成的动作,言外之意其实就是当我的这个进程再次收到2号信号的时候,我就调用那个函数指针所指向的函数,就不使用操作是同原先2号信号所表示的动作了
- 当我们收到2号信号的时候,当前的操作系统会去调用callback函数,callback函数他是没有返回值的,但是他有一个参数,这个参数是有真正的含义的他的含义就是告诉你,哪个信号的触法会使得其去调用callback函数
- 这个函数的好处其实就是可以执行自已的自定义的函数,这会儿,就不再是依赖操作系统内核的动作,而是代码可以被程序员所掌握
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void sigcallback(int signo)
{
printf("signo : %d\n", signo);
}
int main()
{
signal(2, sigcallback);
signal(20, sigcallback);
while(1)
{
printf("linux so easy\n");
sleep(1);
}
return 0;
}
- 运行结果如下所示:
- 那么,我们现在需要去内核当中探究一下,到底signal函数做了什么工作?同样,我们现在需要去操作系统中打开源码去进行查看
- 现在,我们就去看一下标白的部分,到底是一个什么样子的东西,标白的地方其实是一个结构体指针
- 上面的那个结构体指针指向的是下面的这个结构体
- 上面的那个结构体里面有一个元素是数组,数组里面的元素类型是struct k_sigaction类型的,然后这个数组展开的话,其实就是在struct sighand_struct结构体里面展开的,他并不是一个指针的类型,为了看的方便,在这里我在外部进行详细的解释
- 然后我们转到struct k_sigaction这个结构体的定义,我们会发现其实这个结构体的内部其实还有一个结构体存在
- sigaction 这个结构体的内容如下所示:
- 方框所框起来的内容本质上其实就是一个函数指针
- 那么由图示,其实我们也可以看出,signal函数其实更改的就是sa_handler这个东西
- 也就是说,假设现在这个位置接收到的是2号信号,那么我们又知道,2号信号本身采用的是默认的处理方式,然后这个时候我去使用了signal函数,signal函数给他传递了一个函数的地址,那么就是会把默认的情况替换成我们所传递过去的信号
- 然后,我们都知道,代码其实是顺序进行执行的,那么也就是说,我们会先去执行上面的那个signal函数,然后紧接着去执行下面的那个signal函数,然后去执行while循环
- 现在问题来了,那么我现在操作系统中有2个signal函数的东西,那么当我的进程现在收到了2号信号这个信号,是由谁去执行callback这个函数的呢?—其实callback这个函数是操作系统中内核去执行的,是由内核当中的执行流去执行这个callback函数的
- 因为当代码顺序执行的时候,走到while循环的位置,我的代码就开始进行死循环的动作了,他是无法再去执行callback函数的,所以callback函数是由另一个部分去执行的
- 其实这也就涉及到了多线程的问题
sigaction函数
- 这个函数也是用来更改信号的自定义处理的函数
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void sigcallback(int signo)
{
printf("signo : %d\n", signo);
}
int main()
{
//act ---- 入参,表明其在sigaction函数内部起作用
struct sigaction act;
sigemptyset(&act.sa_mask); //将所有的比特位全部置为0
act.sa_flags = 0;
act.sa_handler = sigcallback; //然后使用sa_handler保存地址,然后把函数指针改成我自己函数的地址
//oldact 出参,也就是说oldact需要在sigaction函数内部赋值,然后再外面起作用
struct sigaction oldact;
sigaction(2, &act, &oldact);
while(1)
{
printf("linux so easy\n");
sleep(1);
}
return 0;
}
- 程序的执行结果如下所示:
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void sigcallback(int signo)
{
printf("signo : %d\n", signo);
}
int main()
{
//act --》 入参
struct sigaction act;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
act.sa_handler = sigcallback;
//oldact 出参
struct sigaction oldact;
//先把3号信号的处理方式改成act
sigaction(3, &act, &oldact);
getchar();
//然后这一步我们又把3好信号的处理方式修改为他原先的修改方式了
sigaction(3, &oldact, NULL);
while(1)
{
printf("linux so easy\n");
sleep(1);
}
return 0;
}
- 所以说signal函数修改的是的那个变量,sigaction函数修改的是结构体
信号的捕捉流程
- 就是说假设现在信号已经在task_struct结构体内部了,那么进程什么时候去执行这个信号呢?
- 我们的程序是从main函数进来的,main函数是我们自己所写的代码,那么这个时候他其实就从用户空间进来了,当我们的进程现在收到了一个信号的时候,就会从用户空间移步到内核空间,那么他怎么从用户态切换到内核态呢?假如说我现在要去调用sleep函数,sleep函数他本身就是一个库函数,并不是系统调用函数。这个时候我们再来看,当我们调用了系统调用函数之后,我么势必是会进入内核空间当中的,然后去执行内核代码,紧接着我们要执行我们刚刚的函数,就比如说我们刚刚的sleep函数,当sleep函数执行完成的时候,现在就要返回用户空间了,那么在返回用户空间的时候其实是需要调用一个函数的,这个函数是do_signal函数,从这一点可以看出来,当我们的执行流要从内核空间返回到用户空间的时候其实是需要去调用函数的,这个函数是do_signal函数,这个函数的作用是判断当前函数是否收到了信号,如果收到了信号,则去处理这个函数,如果没有收到信号的话,那么就返回,当返回的时候会去调用sys_return函数,然后就返回用户空间了这时第一种情况,第二种情况是,我当前的进程接收到了一个信号,比如我接收到了2号信号,而且我现在并没有去改掉2号信号的默认处理函数,那么2号信号的处理函数就还在操作系统内核当中,当操作系统去调用这个函数的时候就把2号信号的处理方式进行了更改,当你将2好信号处理完成的时候,这个时候你想返回用户空间了,你仍然是需要去调用do_signal函数的,也就是说,只要你想回到用户空间你都是需要去调用do_signal函数的
- 现在来说第二种情况,当我调用do_signal函数的时候,我发现其实我是接收到了一个信号的,并且我自己去更改了这个信号的处理方式,也就是说是我自定义的这个信号的处理方式,也就是说我在用户空间中定义了一个sigcb函数,这会我们的执行流就会去调用sigcb函数了
信号阻塞
sigpromask函数
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void sigcallback(int signo)
{
printf("signo : %d\n", signo);
}
int main()
{
signal(2, sigcallback);
signal(40, sigcallback);
sigset_t set;
sigfillset(&set);
sigset_t oldset;
sigprocmask(SIG_SETMASK, &set, &oldset);
getchar();
sigprocmask(SIG_SETMASK, &oldset, NULL);
while(1)
{
printf("linux so easy\n");
sleep(1);
}
return 0;
}
在信号阻塞的这个位置,需要去注意一个问题,其实就是,9号信号是不可以被阻塞的,并且是永远不可以被阻塞的,9号信号不可以被阻塞,其实也可以理解成linux内核给自己留了一个退路,假如说哦我们写出来了以个质量很低的程序,那如果现在现在操作系统没有机制把我写的这个质量很低的代码杀死掉的话, 我的这个进程就会一直阻塞了,所以操作系统提供出来9号信号是不可以被阻塞的,而且9号信号也是不可以被更改自定义方式的
SIGCHLD信号(牵扯到僵尸进程)
- 当子进程先于父进程退出的时候,子进程会给父进程发送一个SIGCHLD的信号,但是父进程对于SIGCHLD信号默认是忽略处理的,所以子进程就成为了僵尸进程
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
void sigcb(int signo)
{
wait(NULL);
printf("wait..done\n");
}
int main()
{
pid_t pid = fork();
if(pid < 0)
{
perror("fork");
return -1;
}
else if(pid == 0)
{
//child ==> getpid();
sleep(5);
exit(0);
}
else
{
signal(SIGCHLD, sigcb);
while(1)
{
printf("hello world\n");
sleep(1);
}
}
return 0;
}
- volatile关键字的作用是使得变量保存内存可见性,意味着我每次用到这个变狼的时候都需要去内存里面拿值,而不是从寄存器中拿值
volatile int g_val = 1;
void sigcb(int num)
{
printf("num : %d\n", num);
g_val = 0;
}
int main()
{
signal(2, sigcb);
while(g_val)
{}
return 0;
}