一. 概念
在生活中我们常见的信号有红绿灯,下课铃等等。收到信号后我们就要执行跟信号有关的事情,比如说我们可以根据红绿灯的颜色决定是等待还是过马路,收到下课铃我们就可以做下课的一些事情了。进程信号也是类似的,进程会收到操作系统发送的信号,然后执行跟信号有关的操作。
二. 信号的产生
2.1 键盘产生
Ctrl + c: SIGINT信号
Ctrl +z: SIGTSTP信号
Ctrl + \: SIGQUIT信号
2.2 硬件异常
硬件异常被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。 例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释 为SIGFPE信号发送给进程。进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程等等。
2.3 软件条件
2.3.1 简述
我们之前在管道章节,当读端不读,写端的进程就会收到系统发送的SIGPIPE,从而终止写端的进程。还有alarm函数和SIGALRM信号这些就是属于软件条件的。
2.3.2 alarm
// 1. 头文件:
// #include <unistd.h>
// 2. 函数声明
// unsigned int alarm(unsigned int seconds);
// 3. 功能:告诉内核在seconds秒之后,向进程发送SIGALRM信号,以结束进程
// 4. 返回值:返回之前闹钟的剩余秒数,如果之前没有设置闹钟就返回0
#include <stdio.h>
#include <unistd.h>
int main()
{
alarm(1);
int count = 0;
while (1)
{
printf("%d\n", count++);
}
return 0;
}
// 下面这种方法计算出来的数更大,因为过程中不用打印,IO次数少,速度就快了
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
void handler_alarm(int signo)
{
printf("%d\n", count);
exit(1);
}
int main()
{
signal(SIGALRM, handler_alarm);
alarm(1);
while (1)
{
count++;
}
return 0;
}
2.4 函数调用
我们通常会在命令行调用kill终止一个进程,其实kill命令是调用kill函数实现的。kill函数可以给一个指定的进程发送指定的信号。raise函数可以给当前进程发送指定的信号(自己让操作系统给自己发信号)。
// kill函数接口
// 1. 头文件
// #include <sys/types.h>
// #include <signal.h>
// 2. 函数声明
// int kill(pid_t pid, int sig);
// 3. pid: 想要kill的进程的pid
// 4. sig: 发送的信号
// 5. 返回值:成功返回0, 失败返回-1
// raise函数接口(自己kill自己)
// int raise(inr sig);
#include <stdio.h>
#include <sys/types.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
int main(int argc, char* argv[])
{
if (argc != 3)
{
printf("argc != 3\n");
exit(1);
}
int signo = atoi(argv[1]);
int who = atoi(argv[2]);
int ret = kill(who, signo);
if (ret == 0)
{
printf("%d is killed by signo %d\n", who, signo);
}
return 0;
}
2.5 core dump
2.5.1 简述
一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是core.*,这叫做Core Dump。 进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存 在PCB中)。默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。
2.5.2 ulimit命令
# 显示系统资源设置
ulimit -a
# 设置core文件的最大值,单位为区块
ulimit -c size # size为想设置的大小
2.5.3 案例
- 修改core文件的最大值
ulimit -c 1024
- 写一个有Bug的程序
#include <stdio.h>
int main()
{
int a = 5;
a /= 0; // 除零错误
return 0;
}
- 用gcc编译时加-g选项,编译成可调试的可执行文件
gcc -o myexe test.c -g
- 运行生成的文件,生成core.xxx文件
./myexe
- 用gdb命令运行myexe
gdb myexe
- 在gdb模式中用core-file查看core.xxx文件
core-file core.23866
- 结果如图所示
三. 相关类型、术语、接口
3.1 信号在内核中的表示
如下图进程PCB的数据结构里面会有下面三张表,其中block和pending是两个sigset_t类型的变量(下有解释),可以想象成是一个int型有32个比特位,block的比特位为1表示该信号会被阻塞,pending表对应的比特位为1表示进程收到了对应的信号。bandler是一个函数指针数组,这个数组存放的是对应信号在信号递达时的处理方法,SIG_DFL表示默认,SIG_IGN表示忽略,还有我们自己定义捕捉方法的地址。
3.2 信号未决(Pending)
操作系统向进程发送信号,本质上是把pending信号集对应信号的比特位设置为1,这个过程就是信号未决。
3.3 信号递达(Delivery)
信号递达就是实际执行信号的处理动作,分为三种,默认(执行默认的动作),自定义捕捉(执行自定义的动作),忽略。信号被递达之后,对应的比特位又会被设置为0
3.4 信号阻塞(Block)
信号阻塞就是将block信号集里面对应信号的比特位设置为1,被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。 阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作
3.5 sigset_t类型
我们的pending和block的信号是用位图表示的,即对应的比特位为1或者为0,我们将保存这些信号数据的类型称为sigset_t,也称为信号集,这个类型可以表示每一个信号的有效和无效状态。阻塞信号集block也被叫做信号屏蔽字(Signal Mask),这里的屏蔽可以理解为阻塞,而不是忽略。
3.6 sigemptyset
// 1. 头文件
#include <signal.h>
// 2. 函数声明
int sigemptyset(sigset_t* set);
// 3. 功能:初始化set,将set指向的变量对应的比特位设置为0
3.7 sigfillset
// 1. 头文件
#include <signal.h>
// 2. 函数声明
int sigfillset(sigset_t* set);
// 3. 功能:初始化set,将set指向的变量对应的比特位设置为1
3.8 sigaddset
// 1. 头文件
#include <signal.h>
// 2. 函数声明
int sigaddset(sigset_t* set, int signum);
// 3. 功能:将set指向的变量的signum号信号对应的比特位设置为1
3.9 sigdelset
// 1. 头文件
#include <signal.h>
// 2. 函数声明
int sigdelset(sigset_t* set, int signum);
// 3. 功能:将set指向的变量的signum号信号对应的比特位设置为0
3.10 sigismember
// 1. 头文件
#include <signal.h>
// 2. 函数声明
int sigismember(const sigset_t* set, int signum);
// 3. 功能:判断set指向的变量的signum号信号对应的比特位为1还是0
3.11 sigprocmask
// 1. 头文件
#include <signal.h>
// 2. 函数声明
int sigprocmask(int how, const sigset_t* set, sigset_t* oldset);
// 3. how:有三个处理动作
// SIG_BLOCK:将set的信号添加到block表,相当于block |= set
// SIG_UNBLOCK:将set的信号在block表中删去,相当于block &= ~set
// SIG_SETMASK:将block的信号改成跟set一样的,相当于 block = set
// 4. 功能:跟据how这个动作将进程的block表做出修改,在修改前先将之前的block表数据备份到olset
// 5. 返回值:成功返回0,失败返回-1
// 注意:如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。
3.12 sigpending
// 1. 头文件
#include <signal.h>
// 2. 函数声明
int sigpending(sigset_t* set);
// 3. 功能:将pending表的数据拷贝到set
// 4. 返回值:成功返回0,失败返回-1
3.13 signal
// 1. 头文件
#include <signal.h>
// 2. 需要用到的函数指针类型
typedef void (*sighandler_t)(int); // 指针指向的函数返回值为void,有一个int型参数
// 3. 函数声明
sighandler_t signal(int signum, sighandler_t handler);
// 4. signum:为捕捉的信号
// 5. handler:信号捕捉后的处理方法的地址
// 6. 功能: 设置一个对signum信号的捕捉
// 7. 返回值:成功返回之前signum信号的处理方法的地址
// 8. 捕捉后的自定义处理方法例子
void handler(int sigon)
{
printf("i catch a signal --> %d\n", signo);
}
3.14 sigaction
// 1. 头文件
#include <signal.h>
// 2. 需要用到的类型
typedef void (*sighandler_t)(int); // 指针指向的函数返回值为void,有一个int型参数
// 下面这个结构体我们只需要关心两个成员变量就好了
struct sigaction
{
void (*sa_handler)(int); // 1. 捕捉成功后自定义处理方法的地址
void (*sa_sigaction)(int, siginfo_t*, void*); // 这个暂时可以不关心
sigset_t sa_mask; // 2. 需要屏蔽信号的屏蔽集
int sa_flags; // 这个暂时可以不关心
void (*sa_restorer)(void*); // 这个暂时可以不关心
};
// 3. 函数声明
int sigaction(int signum, const struct sigaction* act, struct sigacion* oldact);
// 4. signum:为捕捉的信号
// 5. act:信号捕捉后,对该信号处理的相关方法的对象
// 6. oldact:保存之前的处理相关方法的对象
// 7. 返回值:成功返回0,失败返回-1
// 8. 捕捉后的自定义处理方法例子
void handler(int sigon)
{
printf("i catch a signal --> %d\n", signo);
}
四. 信号捕捉
4.1 简述
如果信号的处理动作是自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。
4.2 简单的捕捉例子
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
// 自定义的信号处理函数
void handler(int signo)
{
printf("catch a signal --> %d\n", signo);
}
int main()
{
signal(2, handler); // 设置一个2号信号的捕捉,handler就是捕捉到信号后执行的函数
while (1)
{
sleep(1);
printf("find signal....\n");
}
return 0;
}
4.3 用户态和内核态
用户态和内核态是操作系统的两种状态。如下图所示,CPU里面会有一个寄存器存放的是当前进程的状态(内核态或者用户态),CPU会根据寄存器的数据决定操作系统处于哪一个状态。处于内核态时,用的是系统级页表,只能访问OS部分内存的数据和代码;处于用户态时,用的是用户级的页表,只能访问用户部分内存的数据和代码。 同时我们也可以看到,用户级页表每一个进程都有一份,但是系统级的页表只有一份,保证进程无论怎么切换,都可以找到同一份OS的数据。
如下图,在用户态调用open系统接口之后,操作系统的身份就由用户态转换成内核态,然后通过系统级页表找到open接口开始执行,执行完毕之后又由内核态转化成用户态。实际上内核态和用户态之间的转换是经常发生的,系统调用,中断,异常,进程的时间片用完都会从用户态转换成内核态。相应的系统调用结束,进程切换完毕,异常、终端、陷阱等处理完毕,就会从内核态转换成用户态
4.4 信号捕捉细节
如图,进程在收到信号后首先会保存起来,并没有立即处理。当进程由于系统调用等原因进入内核态,在内核态处理完对应的事情准备返回用户态时会先检测该进程待递达的信号。如果有自定义捕捉的信号就会转变成用户态执行自定义的信号处理函数。信号自定义函数处理完之后会执行sigreturn系统调用再次返回内核态。完成之后,再返回用户态执行先前没有执行完的程序。途中的四个红点就是操作系统内核态和用户态两种身份相互转换的时候。
4.5 信号捕捉的综合案例
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <string.h> // for memset
void show_pending(const sigset_t* set)
{
printf("current pending:");
int i = 0;
for (i = 1; i < 32; i++)
{
printf("%d", sigismember(set, i));
}
printf("\n");
}
// 自定义2号信号的处理方法
void handler(int signo)
{
printf("catch signal %d\n", signo);
sigset_t set;
sigemptyset(&set);
int times = 10;
while (times--)
{
sleep(1);
sigpending(&set);
show_pending(&set);
}
}
int main()
{
struct sigaction act;
memset(&act, 0, sizeof(act));
sigemptyset(&act.sa_mask);
act.sa_handler = handler;
sigaddset(&act.sa_mask, 1); // 2号信号递达的过程中,屏蔽(阻塞)1,2,3号信号
sigaddset(&act.sa_mask, 2);
sigaddset(&act.sa_mask, 3);
sigaction(2, &act, NULL); // 捕捉2号信号
sigset_t set;
sigemptyset(&set);
while (1)
{
sleep(1);
sigpending(&set);
show_pending(&set);
}
return 0;
}
五. 可重入函数
如图所示,在
test
函数内调用insert
插入node1
,insert
有两步,执行第一步之后,进程收到信号,然后去执行信号自定义函数。这个函数里面又调用了insert
插入node2
,node2
插入完毕之后head
指向node2
,然后又回去执行刚刚插入node1
没有执行的步骤。执行完毕之后head
执行node1
,最后node2
没有指针指向,出现了内存泄漏。像这样的,insert
函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert
函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。
一个函数符合以下条件之一则是不可重入的
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
六. volatile关键字
6.1 功能
保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作
6.2 例子
如下面一段代码,在没有加valatile关键字,没有编译器优化时, while循环在判断时,是从内存中的获取flag的数据的,所以在接收到2号信号时,会将flag改成0,循环退出。编译器优化了之后, CPU检测到后面的程序中没有改变flag的代码,就将flag拷贝一份到CPU寄存器,然后每一次while循环判断的时候是在寄存器获取flag的数据。当进程收到2号信号时,把内存里面的flag改成0,因为CPU读取的是寄存器的flag数据,所以最终会一直死循环。加了valatile关键字,编译器优化之后也不会对flag有影响,不会在寄存器里面保存flag的数据,每一次while循环判断,CPU都会去内存里面读取flag数据。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
//int flag = 1;
volatile int flag = 1;
void handler(int signo)
{
flag = 0;
printf("把flag改成0\n");
}
int main()
{
signal(2, handler);
while (flag);
printf("这个进程是正常退出的\n");
return 0;
}
七. SIGCHLD
父进程在等待子进程的时候有阻塞等待和非阻塞等待两种方案,阻塞等待父进程就不能处理自己的事情了,非阻塞状态父进程处理自己的事情的时候还要时不时轮询一下。其实子进程退出时候,操作系统会发送SIGCHLD信号给子进程,父进程可以通过自定义捕捉这个信号再调用wait或者waitpid接口去回收子进程,这样在子进程结束之前父进程就可以专心处理自己的事情了。其实父进程也可以在捕捉这个信号的时候选择的处理方式为忽略(SIG_IGN),这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <sys/types.h>
void handler(int signo)
{
sleep(5);
pid_t id = waitpid(-1, NULL, 0);
if (id > 0)
{
printf("wait success!!!\n");
}
}
int main()
{
//signal(SIGCHLD, handler);
signal(SIGCHLD, SIG_IGN);
if (fork() == 0)
{
// child
printf("I am the child process, and my pid is %d\n", getpid());
int times = 10;
while (times)
{
sleep(1);
printf("%d\n", times--);
}
exit(0);
}
while(1)
{
printf("do my own thing\n");
sleep(5);
}
return 0;
}