Linux进程信号
Posted 北川_
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Linux进程信号相关的知识,希望对你有一定的参考价值。
目录
信号入门
信号是进程之间事件异步通知的一种方式,属于软中断。
用kill -l命令可以察看系统定义的信号列表:
其中1号信号到31号信号叫做普通信号,从34到64号信号每一个信号都带了RT称为实时信号。
信号的本质
进程收到信号其实不是立即处理的,而是选择在合适的时候。因为信号的产生是在进程运行的任何时间点都可以产生的,有可能进程正在做更重要的事情。
因为信号不是立即处理的,所以信号一定要先被保存起来。
在哪里保存?
进程的PCB,进程控制块task_struct。
如何保存?
对进程而言,核心要保存的是"是否有信号"+“是哪个信号”。信号编号1-31一共31个,所以使用位图表示。
比特位的位置代表是哪个信号,比特位的内容(0 or 1)代表是否有信号。
00000000 00000000 00000000 00000000
二进制序列全0证明没有收到任何信号。
00000000 00000000 00000000 00000010
代表收到了2号信号。
谁发的,如何发?
发送信号的本质,就相当于写对应进程的task_struct信号位图。
因为OS是进程的管理者,有能力和义务对进程数据做修改。
信号的本质:信号是OS发送的,通过修改对应进程的信号位图(0->1),完成信号的发送。
信号的处理
1.执行该信号的默认处理动作。(部分是终止进程,部分有特定的功能)
2.忽略此信号。
3.自定义方式捕捉信号。提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号。
产生信号
通过终端按键产生信号
有一个mysignal.cc
#include<iostream>
#include<sys/types.h>
#include<unistd.h>
int main()
while(1)
std::cout << "i am a process: " << getpid() << std::endl;
sleep(1);
当在键盘中Ctrl+c其实就是向进程发送2号信号。
./mysignal进程在前台跑起来,当Ctrl+c的时候其实是向前台进程发送二号信号SIGINT。
进程在收到信号的时候,一共31个信号,对于相当一部分信号而言,当进程收到信号的时候,默认的处理动作就是终止当前进程。
注意
1.Ctrl-C 产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。
2.Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl-C 这种控制键产生的信号。
3.前台进程在运行过程中用户随时可能按下 Ctrl-C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)的。
另外还可以使用Ctrl+\\ 来终止进程
Ctrl+\\实际上是向进程发送3号信号SIGQUIT。
也可以通过kill命令的方式向进程发送信号:kill -信号 进程pid
调用系统函数向进程发信号
abort函数
#include <stdlib.h>
void abort(void);
abort函数使当前进程接收到信号SIGABRT(6号)信号而异常终止,就像exit函数一样,abort函数总是会成功的,所以没有返回值。
raise函数
#include <signal.h>
int raise(int sig);
raise函数给当前进程发送指定信号,参数传几号就发几号。成功返回0,失败返回非0值。
kill函数
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
第一个参数表示想给哪个进程发信号,第二个参数表示想发几号信号。
返回值成功返回0,失败返回-1。
#include<iostream>
#include<sys/types.h>
#include<unistd.h>
#include<signal.h>
#include<stdlib.h>
// kill pid signo
int main(int argc, char* argv[])
if(argc != 3)
std::cerr << "Usage: " << argv[0] << " signum pid" << std::endl;
exit(1);
kill(atoi(argv[2]), atoi(argv[1]));
return 0;
有一个运行的进程,向该进程发送9号信号:
实际上Linux中的kill命令也是通过kill系统调用接口实现的。
由软件条件产生信号
进程间通信使用管道的时候,读端不光不读管道,而且把自己的读文件描述符还关了,写端此时一直在写的时候,写端会收到SIGPIPE(13号)信号,进而写进程退出。
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM(14号)信号, 该信号的默认处理动作是终止当前进程。
这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。
#include<iostream>
#include<sys/types.h>
#include<unistd.h>
int main()
alarm(1); // 1s之后,会给目标进程发送SIGALRM信号
int count = 0;
while(1)
std::cout << count++ << std::endl;
return 0;
设置一个闹钟,时间为1s。
硬件异常产生信号
Core Dump
进程等待时有两个函数wait和waitpid,这两个函数都有一个输出型参数status,这个参数是一个整型变量,由操作系统填充,获取进程的退出信息返回给父进程。
在status的低16位中,高8位表示进程的退出状态即退出码,若进程被信号杀掉,低7七位表示终止信号,第8位是core dump标志。一旦发生核心转储,core dump标志位就会被设置为1。
在Linux下使用ulimit -a查看系统的各种资源内容。
用ulimit -c 1024把core file size的大小调整成1024。
一旦放开core dump后,进程运行的时候收到信号,就会在当前目录形成一个core文件。core后面跟个进程pid。
core dump叫做核心转储,当一个进程在运行时突然崩溃了,OS将进程运行时的核心数据dump到磁盘上,方便用户进行调试使用。
一般而言,线上环境核心转储是被关闭的。因为程序崩一次,就要dump一次,占用内存资源。
现在有这样一份代码,其中第13行有除0错误:
#include<iostream>
#include<sys/types.h>
#include<unistd.h>
int main()
while(1)
std::cout << "i am a process: " << getpid() << std::endl;
sleep(1);
int a = 10;
int b = 0;
a /= b;
return 0;
程序在运行1s之后core dump了。
使用gdb调试程序,用core-file把生成的core文件导入,这时gdb就会定位错误。
terminated with signal 8表示进程收到8号信号,in main () at mysignal.cc:13表示错误的代码行数是在mysignal中main函数的第13行,并且给出了出错的语句。这种调试方案叫做事后调试。
硬件异常被硬件以某种方式检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释 为SIGFPE(8号)信号发送给进程。再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV(11号)信号发送给进程。
当程序出现问题时,例如越界,除0,野指针等,当运行程序时,站在语言的角度叫程序崩溃了,站在系统的角度,叫做进程收到了信号。
阻塞信号
信号其他相关常见概念
- 实际执行信号的处理动作称为信号递达(Delivery)。信号递达的方式其实就三种:默认、忽略、自定义捕捉。
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞 (Block )某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
信号在内核中的表示
信号在内核中的表示示意图:
pending位图:比特位的位置代表信号的编号,比特位的内容(0 or 1)代表是否收到信号。OS发送信号本质是修改task_struct中pending位图的内容。
handler数组:用信号的编号作为数组的索引,找到该信号对应的信号处理方式,然后指向对应的方法(递达)。
block位图:比特位的位置代表信号的编号,比特位的内容(0 or 1)代表是否阻塞该信号。
如果没有收到对应的信号,照样可以阻塞特定信号。阻塞更准确的理解,可以理解成为一种“状态”。
所以实际上检测信号递达的时候,第一步看pending中看是否收到信号,发现有这个信号,第二步发现对应block位图中是0,也就是不会被阻塞,然后才会执行hander数组中对应的处理方法,否则block中如果为1,即便pending位图中为1也不会被递达。
检测信号是否会被递达,是否被阻塞,都是OS的任务。
sigset_t
pending位图中每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态。在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
信号集操作函数
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_ t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印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所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含 任何有效信号。
sigfillset:初始化set所指向的信号集,使其中所有信号的对应bit位置为1。
sigaddset:把特定的信号设置到信号集当中,指定位设置为1。
sigdelset:指定位设置为0。
这四个函数都是成功返回0,出错返回-1。
sigismember:判断信号集中是否包含特定信号,若包含则返回1,不包含则返回0,出错返回-1。
sigprocmask
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
参数解释:
第一个参数表示想怎样调用这个函数,有三个选项:
选项 | 功能 |
---|---|
SIG_BLOCK | set包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask|set |
SIG_UNBLOCK | set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask&~set |
SIG_SETMASK | 设置当前信号屏蔽字为set所指向的值,相当于mask=set |
如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。
如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。
如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里,然后
根据set和how参数更改信号屏蔽字。
返回值调用成功返回0,出错返回-1。
sigpending
sigpending函数读取当前进程的未决信号集,并通过set参数传出。
#include <signal.h>
int sigpending(sigset_t *set);
参数set是一个输出型参数,获取进程的pending信号位图。
返回值调用成功返回0,出错返回-1。
下面做一个简单的实验,实验步骤:
1.屏蔽(阻塞)2号信号。
2.不断的获取pending信号集并打印。
3.发送2号信号给进程。
4.过一段时间恢复对2号信号的block(取消2号的block)。
5.2号信号立马会被递达
6.依旧打印pending位图。
#include<iostream>
#include<sys/types.h>
#include<unistd.h>
#include<signal.h>
#include<stdlib.h>
using namespace std;
void handler(int signo) // 自定义捕捉
cout << "get a signo: " << signo << endl;
void show_pending(sigset_t* pending)
for(int i = 1; i <= 31; ++i)
if(sigismember(pending, i))
cout << "1";
else
cout << "0";
cout << endl;
int main()
signal(2, handler);
sigset_t in, out;
sigemptyset(&in);
sigemptyset(&out);
// 设置2号信号被阻塞,这里仅在用户栈上设置,并不影响内核
sigaddset(&in, 2);
// 在内核中阻塞2号信号
sigprocmask(SIG_SETMASK, &in, &out);
int count = 0;
sigset_t pending;
while(true)
sigpending(&pending); // 获取pending信号集
show_pending(&pending); // 打印pending信号集
sleep(1);
if(count == 10)
// 恢复之后2号信号立马递达并且执行默认动作就会终止进程
// 所以想看到2号信号被递达pending位图的变化就要进行自定义捕捉
sigprocmask(SIG_SETMASK, &out, &in);
cout << "my: ";
show_pending(&in);
cout << "recover default: ";
show_pending(&out);
++count;
return 0;
实验结果:
实验结果解释:
捕捉信号
signal系统调用
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
signal() 将信号的处置设置为处理程序,它可以是 SIG_IGN、SIG_DFL 或程序员定义的函数(“信号处理程序”)的地址。
第一个参数填各种信号的编号。
第二个参数:
如果设置为 SIG_IGN,则忽略该信号。
如果设置为 SIG_DFL,则与信号关联的默认操作发生。
如果设置为函数,则首先第二个参数被重置为 SIG_DFL,或者信号被阻塞,然后使用参数 signum 调用处理程序。 如果处理程序的调用导致信号被阻塞,则信号在从处理程序返回时被解除阻塞。
信号捕捉的过程
信号被捕捉的时间点:内核态->用户态的时候:
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下:
用户程序注册了SIGQUIT信号的处理函数sighandler。 当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函 数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。 sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。
sigaction
sigaction函数的作用和signal函数是一样的,都可以进行信号捕捉。
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
参数说明:
signum表示想要捕捉的信号。
第二个参数和第三个参数是一个结构体:
struct sigaction
void(*sa_handler)(int);
void(*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask;
int sa_flags;
void(*sa_restorer)(void);
;
第二个参数act表示想怎么处理这个信号。
其中sa_handler就是捕捉信号函数的函数指针。
其中sa_flags这个参数通常设置为0,执行默认的动作就可以了。
sa_mask:默认情况下,当系统正在处理某个信号的时候,当前该信号会被短暂的block,直到当前信号处理完毕。
在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。
第三个参数oldact可以把老的信号捕捉方式返回,如果不需要设置为null。
返回值成功返回0,失败返回-1。
下面有一段程序,我们发送二号信号,同时将3号信号也加入信号集,看看有什么现象。
#include<iostream>
#include<unistd.h>
#include<signal.h>
using namespace std;
// 信号捕捉函数执行死循环
void handler(int signo)
while(1)
cout << "get a signo: " << signo << endl;
sleep(1);
int main()
struct sigaction act, oact;
act.sa_handler = handler;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, 3); // 将3号信号block
// act.sa_restorer = nullptr;
// act.sa_sigaction = nullptr;
sigaction(SIGINT, &act, &oact);
while(1)
cout << "process is running...\\n" << endl;
sleep(1);
return 0;
结果解释:
这个实验说明了正在处理某个信号的时候,这个信号自动会被block。处理完后才会去掉block,然后才可以触发第二次该信号,可以把这个理解为OS为了防止大量信号产生时导致进程频繁处理信号的一种策略。
可重入函数
#include<iostream>
#include<unistd.h>
#include<signal.h>
using namespace std;
void show(int signo)
int i = 0;
while(i < 5)
cout << "get a signo: " << signo << endl;
sleep(1);
++i;
void handler(int signo)
show(signo);
int main()
struct sigaction act, oact; // 注册信号的捕捉动作
act.sa_handler = handler;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
sigaction(SIGINT, &act, &oact); // 注册对二号信号的捕捉
show(9999);
main函数前部分设置信号捕捉处理动作,接下来main函数执行show方法,需要5s钟,在这5s内发送2号信号,此时主流程就跑过去执行handler,而handler内部又调了show方法,又进到show函数中。相当于mian函数正在执行show,而信号处理函数又执行了show。
运行结果:
一个函数有可能被多个执行流同时进入,函数被多个执行流同时进入的情况叫做重入。
下面看一个例子:
main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步(node1->next = head; head = node1;),刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步(head = node1;)。结果是,main函数和sighandler先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
一旦多个执行流同时进入时,这个函数的访问是不安全的,就叫做不可重入函数,如果访问的时候不会出任何问题,就叫做可重入函数。
如果一个函数符合以下条件之一则是不可重入的:
1.调用了malloc或free,因为malloc也是用全局链表来管理堆的。
2.调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
volatile
volatile是C语言当中的一个关键字,这个关键字的作用是保持内存的可见性。
#include<stdio.h>
#include<signal.h>
#include<unistd.h>
int flag = 0;
void handler(int signo)
flag = 1;
printf("handler signo: %d, set flag == %d\\n", signo, flag);
int main()
signal(2, handler);
while(!flag);
printf("process end...\\n");
有一个全局变量flag是0,在main函数中以flag做循环条件一直死循环。当发送2号信号的时候,信号递达执行自定义捕捉函数把flag置为1,循环条件!flag就是0应该退出循环,打印process end,然后进程退出。
运行结果:
疯狂的向进程发2号信号,通过运行结果可以发现,flag的值确实被置为1了,但是循环没退出,进程没结束,非1是0啊,但是还在循环,这是为什么呢?
在main函数执行流中没有发现任何一个对flag变量做修改的操作,所以编译器在优化的情况下极有可能会将flag变量优化成为寄存器变量。
这个全局变量在内存中肯定是存在的,只不过在编译的时候同步的把这个flag也优化成寄存器变量,也就是当进程启动的时候默认的把flag变量加载到寄存器中,这时候寄存器拿到的值就是0。
然后while循环就开始不断检测flag,它检测的是寄存器当中的flag值,当信号产生时对flag变量进行写入,这个写入动作不会修改寄存器的值,而只是把内存中的flag值由0改成了1。这个flag确实变成了1,但while循环它依旧只检测ebx寄存器中的值,内存的值变为1了,但是寄存器中的值依旧是0。
所以就出现了内存的数据和寄存器的数据不一致,好像把flag的值改了,但while循环一直不退出的原因就在这里,因为while循环检测的flag照样是0值。
编译器在优化的时候,要么是代码的体积不要形成那么大的程序,要么是提高效率。编译器发现main函数执行流中没有修改flag所以直接把flag优化成寄存器变量,可是我们发现此时这个优化就出问题了。如果我们在不知道编译器优化的前提下,运行代码ctrl+c发现它不终止,我们会发现这个问题特别难调试,基本上再怎么看代码,都会认为代码是没问题的。
怎么解决呢?
使用volatile关键字,在flag前加volatile关键字。
volatile int flag = 0;
Linux中的信号