代码存放在:https://github.com/sjmshsh/System-Call-Learn/tree/master/signal
我们先来看一张图,了解一下通过阅读本博客,你可以收获什么。
背景知识
首先我说明一点
信号 != 信号量
我们这篇文章讲解的是信号,不是信号量
信号在生活中处处有在,例如红绿灯,上课铃声等等。信号可以让我们知道我们要做什么事情。
其实Linux操作系统就像是一个社会,处处充满着生活中的哲学。Linux操作系统也是有信号的。
信号的产生就代表场景的触发,在Linux中,信号是给进程发的,进程要在合适的时候执行对应的动作。光有信号是没有意义的,重要的是一种类似协议的东西。也就是我制定一个规则,你看到信号的时候就固定触发某些场景,例如我看到红灯就停止走路,看到绿灯就继续走路。
Linux操作系统给进程发送信号,并且它具有识别和处理信号的能力。
那么我们看到信号就一定要处理吗?不一定,生活中有很多信号,例如上课铃声响了,但是我生病了没有去上课。我生病了,体温39°这个信号的优先级明显要高于上课铃声。
因此信号随时都有可能产生,但是并不是立即会处理,而是等到合适的时候再处理。
既然信号不能被立即处理,那么已经到来的信号是不是应该暂时存储起来呢?答案肯定是的,所以在进程在收到信号后,要先把信号保存起来,等到合适的时候再处理。
那么应该保存在哪里呢?task_struct
,这毫无疑问。
信号的本质也是数据,信号发送的本质就是往task_struct
结构体中写入对应的数据。
task_struct
是一个内核数据结构,用来定义进程的内核对象,而内核不相信任何人,用户不可以对内核数据结构进行写入,所以是谁向task_struct
中写入信号数据的呢?是OS!
所以无论我们的信号如何发送,本质都是通过OS发送的
信号产生的各种方式
signal函数修改信号处理动作
首先我们可以用kill -l
指令查看我们有哪些信号。
前31个是普通信号(1 - 31)
后31个是实时信号(34 - 64)
例如我们CTRL + C 其实就是在给操作系统发送2号(SIGINT)信号。
那么怎么证明呢?
我们先来介绍一个系统调用接口:
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
sighandler_t
是一个参数为int
,返回类型为void的函数指针。
第一个参数是一个整数,可以用信号名,也可以用信号的编号。
第二个参数是一个函数。
这个函数的作用是修改进程对信号的默认处理动作。
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void handler(int signal)
{
printf("I got a signal, signal id : %d, pid : %d \n", signal, getpid());
}
int main()
{
// 通过signal函数把2号动作处理为我们特定动作
signal(2, handler);
while (1)
{
printf("hello world! My pid : %d\n", getpid());
sleep(1);
}
return 0;
}
可以看到我CTRL + C就变成执行handler函数,而不是退出了。
所有信号,除了9号信号之外,都可以像这样进行操作。9号信号是用来杀死进程的,它是特殊的,不能被自定义,也不能被阻塞。
总结:进程收到信号后的处理方式有3种:
- 默认动作,一部分是终止自己,暂停等
- 忽略动作,也是信号处理的一种方式,就是什么都不干
- 自定义动作,例如上面展示的
这个函数介绍完了,那么现在我们来了解一下,有哪些方式可以发出信号。
键盘命令产生信号与运行时软硬件错误收到os发的信号
野指针或者数据越界的时候,有的时候会发生段错误(Segmentation fault
)
现在我们来证明一下:
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void handler(int signal)
{
printf("I got a signal, signal id : %d, pid : %d \n", signal, getpid());
}
int main()
{
// 我们不管三七二十一,把除了2之外所有的信号都处理为我们的特定动作,留个2是退出CTRL+C就行了,比较方便
for (int i = 0; i <= 31; i++)
{
if (i != 2)
signal(i, handler);
}
while (1)
{
int* p = NULL;
p = (int*)100;
*p = 100;
printf("hello world! My pid : %d\n", getpid());
sleep(1);
}
return 0;
}
看一下结果:
I got a signal, signal id : 11, pid : 14520
11号信号是SIGSEGV
.是段错误的意思。
再来看看经典的除零错误。
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void handler(int signal)
{
printf("I got a signal, signal id : %d, pid : %d \n", signal, getpid());
}
int main()
{
// 我们不管三七二十一,把除了2之外所有的信号都处理为我们的特定动作,留个2是退出CTRL+C就行了,比较方便
for (int i = 0; i <= 31; i++)
{
if (i != 2)
signal(i, handler);
}
while (1)
{
int a = 1;
a /= 0;
printf("hello world! My pid : %d\n", getpid());
sleep(1);
}
return 0;
}
结果是:
I got a signal, signal id : 8, pid : 15487
8号,也就是SIGFPE,浮点数错误。
我们的进程本来会因为错误程序直接崩溃,但是却没有,原因是进程崩溃的本质就是进程收到了对应的信号,执行信号的默认行为。
那么为什么会被发送对应信号呢?
操作系统是硬件的管理者,硬件的各种状态操作系统都要管理,这些错误会对硬件造成影响,那么操作系统必然不会视而不见,肯定要发送信号处理相关的错误。
软件上面的错误,通常会体现在对应的硬件上或者其他软件上。
a /= 0在CPU计算的时候,如果有浮点数错误,会有一个标志位标记出错。*p = 100野指针访问的时候,管理虚拟内存映射的mmu硬件会标记你越界了。
那么一个进程崩溃的时候,我们希望获得崩溃的原因,即获得对应收到的信号,而前面学习过,waitpid
时拿到的是status
,它的低7位(status & 0x7f
)就是对应的信号。
这里做一个回顾。
但是光获得报错信息是没有意义是,我们需要解决它,因此这里就需要用到core dump标志了,这也算是把前面一个没有解决的坑给填上了。
在Linux种中,档一个进程正常退出的时候,退出码和退出状态都会被设置,只不过退出码是0而已。当一个进程异常退出的时候,进程的退出信号会被设置,表明进程退出的原因。如果你设置了,那么会把core dump标志位设置成1,如果这个位是1的话,那么进程在内存中的数据会转出到磁盘中,方便后期调试。
ulimit -a
:查看系统资源,可以查看core dump是否开启。
0,说明没有开启。
ulimit -c 10240
:允许core dump操作。
当我们开启之后,如果进程崩溃了,就会生成一个文件。
这个文件是一个二进制文件。
然后在编译的时候带上-g,代表程序可以被调试,然后core-file core.pid
进行调试,就可以知道错误原因,然后解决了。
系统产生信号
这里介绍几个可以产生信号的系统调用接口:
#include <signal.h>
// 向某个进程发送指定信号
int kill(pid_t pid, int signo);
// 对自己发送某个信号
int raise(int signo);
这两个函数都是成功返回0,错误返回-1。
abort函数使当前进程接收到信号而异常终止。
#include <stdlib.h>
void abort(void);
就像exit函数一样,abort函数总是会成功的,所以没有返回值。
也就是发送6号信号。
软件条件产生信号
例如:进程间通信,读端不读且把fd关闭了,写端一直还在写,最终写进程会收到SIGPIPE(13号)信号。
还有系统调用接口alarm
。
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动
作是终止当前进程。
如何理解操作系统向task_struct写入信号数据
普通信号的取值范围是[1, 31]
,进程的task_struct
内部一定要有对应的数据变量来保存记录,表明是否收到了对应的信号。
很明显是一个位图uint32_t sigs
。
打个比方:
0000 0000 0000 0010 0101
表示进程收到了1号,3号,6号信号。
因此操作系统向task_struct
写入信号的本质就是OS向进程PCB的位图对应的比特位置1,完成信号的发送就是完成对信号的写入。
信号的保存状态
背景知识
实际执行信号的处理动作成为信号抵达,分为三种:
- 自定义捕捉
- 默认
- 忽略
信号从产生到抵达之间的状态叫做信号未决,本质是这个信号被存在task_struct
里面还没有被处理。
进程可以选择阻塞(Block)某个信号,本质就是操作系统允许进程暂时屏蔽指定的信号,它表明:该信号依然是未决的;该信号不会被抵达直到解决阻塞。
忽略,阻塞的区别:
- 忽略是一种信号处理方式,阻塞是没有抵达,是一种独立的状态
信号处理在内核中有三张表:pending
,blocking
,handler
上图:
pending
就是写入的那个位图,表示已经收到但是还没有抵达的信号。
handler
是一个函数指针数组void(*handler[31])(int)
,存放信号的处理方法。
block
是阻塞数据,如果标记是1的话,代表信号被阻塞,不会被执行。
这个图是横着看的,如果信号对应的位置的比特位是1,代表信号被阻塞了,后续就不用进行操作了,如果不是1,才有后面两个位图的事情。
os检测处理信号的伪代码如下:
int isHandler(int signo)
{
if (block & signo)
{
// 阻塞了 根本不管有没有信号
}
else
{
// 没有被block
if (signo & pending)
{
// 该信号被收到了
hadnler_array[signo](signo);
return 0;
}
}
return 1;
}
因此block
表又被称为信号屏蔽字
相关系统调用接口
不是只有接口才是系统调用,OS也会给用户提供数据类型,配合系统调用来完成,比如shmget中的key_t、struct ipc_perm等,这些是配合接口使用的数据类型。
sigset_t
:从上面的信号保存状态图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。 因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,
这个类型可以表示每个信号 的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有 效”和“无效”的含义是该信号是否处于未决状态。阻塞信号集也叫做当 前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
信息集操作函数:
#include <signal.h>
int sigemptyset(sigset_t *set);// 把位图集合清空 全部置0
int sigfillset(sigset_t *set);// 全部置1
int sigaddset (sigset_t *set, int signo);// 把一个信号添加到这个位图里 也就是把这个信号对应的位图的位置1
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);// 判定一个信号是否在集合中
除sigismember外,这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含 某种 信号,若包含则返回1,不包含则返回0,出错返回-1。
sigprocmask:修改进程的block位图。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
//返回值:若成功则为0,若出错则为-1
如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则 更改进程的信 号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后 根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。
SIG_BLOCK | set包含了我们希望添加到当前信号屏蔽字的信号,相当于mask = mask |
---|---|
SIG_UNBLOCK | set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask = mask & ~set |
SIG_SETMASK | 设置当前信号屏蔽字为set所指的值,相当于mask = set |
这个有相关代码去我GitHub上面看就可以了。
信号处理方式
信号发送后为什么是合适的时候才选择处理信号呢?这是因为信号的产生是异步的,当前进程可能会有更重要的工作要去做。
那么这些信号什么时候去处理呢?
当进程从内核态返回到用户态的时候,进行上面的检测与处理
那么什么是内核态和用户态呢?
内核态和用户态
内核态:执行OS的代码和数据时,计算机所处的状态。就叫做内核态。OS的代码的执行,全部都是在内核态。
用户态:就是用户代码和数据被访问或者执行的时候,所处的状态。我们自己写的代码,全部都是在用户态执行的。
主要区别:在于权限。
这里用一张大图解决所有的问题:
进程之间不管如何切换,我们一定可以找到同一个OS,因为每个进程都有3 - 4G的内核空间,使用同一张内核级别页表就可以找到内核相关的数据和代码。
所谓系统调用,本质就是进程身份转换成内核,然后根据内核页表转换成为内核态,然后根据内核页表找到对应的系统函数。
那么我们现在回到刚才的问题,信号执行的时机:
抽象一下就是:
那么为什么一定要回到用户态执行自定义函数呢?
其实在内核态也可以执行,但是这样很不安全,如果我这个handler函数里面有一个破坏操作系统内核的脚本的话,那么一套下来OS就废了。
sigaction - 注册信号捕捉函数
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
它修改的是handler
表,它也可以处理实时信号,第二个参数是输入型参数,动作方法填入这个结构体中,oact
是一个输出型参数,返回老的信号处理方法。
sa_mask的含义:处理信号时希望暂时屏蔽其他信号,不让其他信号影响当前信号的处理。
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来 的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。 如果 在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需 要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。
对应的代码,也可以在GitHub里面看,这里就不写了。
可重入函数
可重入函数实际上就是所谓的非线程安全函数,我有多个执行流可以进入一个函数执行逻辑就叫可重入函数,在STL中,大部分函数都是不可重入的,也就是线程安全的。
如果一个函数符合以下条件之一则是不可重入的:
- 调用了
malloc
或free
,因为malloc
也是用全局链表来管理堆的。 - 调用了标准I/O库函数。I/O库的很多实现都以不可重入的方式使用全局数据结构
volatile关键字
我们先来看一个代码:
#include <stdio.h>
#include <signal.h>
int flag = 0;
void handler(int signo)
{
flag = 1;
printf("change flag 0 to 1.\n");
}
int main()
{
signal(2, handler);
while (!flag);
printf("这个进程是正常退出的.\n");
return 0;
}
这个代码就是当我CTRL+C的时候发送2号信号,然后被捕获,在handler里面改变flag的值,从而while循环结束,程序退出。
测试一下发现完全没有任何问题。
原因是我们的Makefile是这么写的:
signal: test.cc
g++ -o $@ $^ -std=c++11 -g
.PHONY:clean
clean:
rm -f signal
没有写编译器优化的选项,我们加上-O3
然后允许,发现程序无法停止,然而在flag变量前面加上关键字volatile
就可以了,这是为什么呢?
这里解释一下编译器优化:
编译器编译,构建语法树的时候可以发现main主程序里面没有对flag变量做更改,所以会进行优化,为了提高速度,会把flag放入寄存器里面,而我们改flag的值是在内存里面的改的,对CPU不可见了。
那么volatile
的作用就很简单了,就是告诉编译器不要对我的这个变量做任何的优化
- 保持内存可见性
- 防止指令重排序
SIGCHILD信号
我们之前了解过用waitpid
和wait
清理回收僵尸进程,但是这样父进程会阻塞等待,或者每过一段时间就回去看一下,这样效率很低。而当子进程退出之后会向父进程发送SIGCHILD信号。因此,如果父进程不关心子进程的退出信息的话,我们可以直接把SIGCHILD忽略了,这样就不存在僵尸进程没有被回收的问题了。
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>
void handler(int sig)
{
pid_t id;
while ((id = waitpid(-1, NULL, WNOHANG)) > 0)
{
printf("wait child success: %d\n", id);
}
printf("child is quit! %d\n", getpid());
}
int main()
{
signal(SIGCHLD, handler);
pid_t cid;
if ((cid = fork()) == 0)
{ // child
printf("child : %d\n", getpid());
sleep(3);
exit(1);
}
while (1)
{
printf("father proc is doing some thing!\n");
sleep(1);
}
return 0;
}