目录
一.信号入门
1.1概念
1.2信号发送与记录
1.3信号的处理方式
二.产生信号的方式
2.1通过终端按键产生
2.2通过系统函数向进程发信号
2.3由软件条件产生信号
2.4由硬件异常产生信号
三.阻塞信号
3.1信号相关概念
3.2信号在内核的表示
3.3sigset_t:
3.4信号集操作函数
3.5 sigprocmask
3.6sigpending
四.捕抓信号
4.1内核实现信号的捕抓
4.2sigaction
五.可重入函数
六.SIGCHLD信号
一.信号入门
1.1概念

注意:程序运行时后面加上&,就是让它在后台运行,后台运行时无法通过ctrl+c终止,可用fg+编号提到前台,ctrl+z暂停后,可用bg+编号提到后台。可用jobs命令查看。
补充:这个键盘输入产生的硬中断,被OS获取后解释成了2号信号,可以使用signal函数对2号信号进行捕捉。
参数解释:第一个参数表示的信号参数,第二个参数表示的是处理方法,该处理方法的参数是int,返回值是void。
例如:
#include<iostream>
using namespace std;
#include<unistd.h>
#include<signal.h>
void handler(int s)
{
cout<<"获取一个信号"<<endl;
}
int main()
{
signal(2, handler);
while(true)
{
cout<<"hello world"<<endl;
sleep(2);
}
return 0;
}
结果:
当进程收到2号信号后,执行的就是我们自定义处理的handle方法,而不会退出。但9号命令不会执行我们自定义的方法。
注意:
1.Ctrl+C产生的信号只能发送给前台进程。在一个命令后面加个&就可以将其放到后台运行,这样Shell就不必等待进程结束就可以接收新的命令,启动新的进程。
2.Shell可以同时运行一个前台进程和任意多个后台进程,但是只有前台进程才能接到像Ctrl+C这种控制键产生的信号。
3.前台进程在运行过程中,用户随时可能按下Ctrl+C而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都可能收到SIGINT信号而终止,所以信号相对于进程的控制流程来说是异步的。
4.信号是进程之间事件异步通知的一种方式,属于软中断。
1.2信号发送与记录
可以使用 kill -l 查看信号
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3
38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8
43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7
58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2
63) SIGRTMAX-1 64) SIGRTMAX
其中1~31号信号是普通信号,34~64号信号是实时信号,普通信号和实时信号各自都有31个,每个信号都有一个编号和一个宏定义名称。
那么信号的记录方式是怎样的呢?又是怎样产生的呢?
当一个进程接收到某种信号后,该信号是被记录在该进程的进程控制块当中的。我们都知道进程控制块本质上就是一个结构体变量,而对于信号来说我们主要就是记录某种信号是否产生,因此,我们可以用一个32位的位图来记录信号是否产生。信号的产生就是操作系统直接去修改目标进程的task_struct中的信号位图。
1.3信号的处理方式
- 执行该信号的默认处理动作。
- 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号。
- 忽略该信号。
我们可以通过man手册查看各个信号默认的处理动作。
二.产生信号的方式
2.1通过终端按键产生
例如在键盘上的ctrl+c/ctrl +/,OS会将其识别为对应的信号,其实Ctrl+c是向进程发送2号信号SIGINT,Ctrl+/是发送3号信号,看这两个信号的默认处理动作,可以看到这两个信号的Action是不一样的,2号信号是Term,而3号信号是Core。但是Core在终止进程的时候会进行一个动作,那就是核心转储。
先解释下Core Dump,一个程序异常终止时,可以把该进程用户空间的数据保存在磁盘上,磁盘文件名叫Core,这叫做Core Dump。事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。一个进程允许 产生多大的core文件取决于进程的Resource Limit(这个信息保存 在PCB中)。默认是不允许产生core文件的,

我们可以通过ulimit -c size
命令来设置core文件的大小。
core文件的大小设置好后,就相当于将核心转储功能打开了。此时如果我们再使用Ctrl+\对进程进行终止,就会发现终止进程后会显示core dumped
。
举例验证:
并且会在当前路径下生成一个core文件,该文件以一串数字为后缀,而这一串数字实际上就是发生这一次核心转储的进程的PID。
说明一下: ulimit命令改变的是Shell进程的Resource Limit,但test进程的PCB是由Shell进程复制而来的,所以也具有和Shell进程相同的Resource Limit值。
我们可以运用核心转储进行调试,用那个生成的core文件。gdb +文件名,core-file core文件查看。
举例:
int main()
{
int k=10;
for(int i=5;i>=0;i--)
{
k/=0;
cout<<i<<endl;
sleep(1);
}
return 0;
}
core dump标志:
pid_t waitpid(pid_t pid, int *status, int options);
这个status参数是一个输出型参数,用于获取子进程的退出状态。status是一个整型变量,但status不能简单的当作整型来看待,status的不同比特位所代表的信息不同,具体细节如下(只关注status低16位比特位):
若进程是正常终止的,那么status的次低8位就表示进程的退出状态,即退出码。若进程是被信号所杀,那么status的低7位表示终止信号,而第8位比特位是core dump标志,即进程终止时是否进行了核心转储。
举例验证:
int main()
{
int k=99;
int a=0;
pid_t id= fork();
if(id==0)
{
k/=a;
}
int status = 0;
waitpid(-1, &status, 0);
cout<<"退出信号是:"<<(status&0x7f)<<" "<<"core:"<<((status>>7)&1)<<endl;
return 0;
}
2.2通过系统函数向进程发信号
当我们要使用kill命令向一个进程发送信号时,我们可以以kill -信号名 进程ID或者kill -信号编号 进程ID
的形式进行发送的形式进行发送.
实际上kill命令是通过调用kill函数实现的,kill函数可以给指定的进程发送指定的信号,kill函数的函数原型如下
int kill(pid_t pid, int sig);
如果信号发送成功,则返回0,否则返回-1。
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>
void Usage(char* proc)
{
printf("Usage: %s pid signo\n", proc);
}
int main(int argc, char* argv[])
{
if (argc != 3){
Usage(argv[0]);
return 1;
}
pid_t pid = atoi(argv[1]);
int signo = atoi(argv[2]);
kill(pid, signo);
return 0;
}
raise函数:raise函数可以给当前进程发送指定信号,即自己给自己发送信号,raise函数的函数原型如下
int raise(int sig);
例如:
void handler(int signo)
{
printf("get a signal:%d\n", signo);
}
int main()
{
signal(8, handler);
while (1){
sleep(1);
raise(8);
}
return 0;
}
结果:
abort 函数可以给当前进程发送SIGABRT信号,使得当前进程异常终止.
void abort(void);
例如:
void handler(int signo)
{
printf("get a signal:%d\n", signo);
}
int main()
{
signal(6, handler);
while (1){
sleep(1);
abort();
}
return 0;
}
结果:
说明一下: abort函数的作用是异常终止进程,exit函数的作用是正常终止进程,而abort本质是通过向当前进程发送SIGABRT信号而终止进程的,因此使用exit函数终止进程可能会失败,但使用abort函数终止进程总是成功的。
2.3由软件条件产生信号
SIGPIPE信号:SIGPIPE信号是一种由软件条件产生的信号,当进程在使用管道进行通信时,读端进程将读端关闭,而写端进程还在一直向管道写入数据,那么此时写端进程就会收到SIGPIPE信号进而终止。
SIGALRM信号:其实alarm函数设定一个闹钟,也就是告诉操作系统在若干时间后发送SIGALRM信号给当前进程,alarm函数的函数原型如下:
unsigned int alarm(unsigned int seconds);
该信号默认的执行动作也是终止进程。
alarm函数的返回值:
- 若调用alarm函数前,进程已经设置了闹钟,则返回上一个闹钟时间的剩余时间,并且本次闹钟的设置会覆盖上一次闹钟的设置。
- 如果调用alarm函数前,进程没有设置闹钟,则返回值为0。
可以用下alarm函数:
int a=0;
void handler(int sin)
{
printf("获取一个信号:%d\n",sin);
cout<<a<<endl;
exit(-1);
}
int main()
{
alarm(1);
signal(SIGALRM, handler);
while(1)
{
a++;
}
return 0;
}
结果:
2.4由硬件异常产生信号
例如一些除零错误,越界,野指针等。因为硬件异常,而导致OS向目标进程发送信号,进而导致进程终止。
除零错误问题:在进行运算的时候,cpu内部有一些寄存器,当发生错误时,某些寄存器会保存错误信息,OS识别后,发现状态寄存器被置位了,此时操作系统会识别到当前是哪个进程导致的错误,并将所识别到的硬件错误包装成信号发送给目标进程。本质就是操作系统去直接找到这个进程的task_struct,并向该进程的位图中写入8信号,写入8号信号后这个进程就会在合适的时候被终止。
野指针问题:首先要知道我们语言层所使用的地址是虚拟的地址,要经过页表的映射后才能与物理地址相关联。而地址转化的工作是由(MMU(memory maneger unit 内存管理单元,是硬件)+页表(软件)),MU既然是硬件单元,那么它当然也有相应的状态信息,当地址转换过程中出现错误时,也会记录错误信息,当被OS识别后,也会相对应的目标进程发送信号,该进程收到信号后会在合适时候被终止。
三.阻塞信号
3.1信号相关概念
实际执行信号的处理动作称为信号递达 (Delivery)信号从产生到递达之间的状态 , 称为信号未决 (Pending) 。进程可以选择阻塞 (Block ) 某个信号。被阻塞的信号产生时将保持在未决状态 , 直到进程解除对此信号的阻塞 , 才执行递达的动作 .注意 , 阻塞和忽略是不同的 , 只要信号被阻塞就不会递达 , 而忽略是在递达之后可选的一种处理动作。
3.2信号在内核的表示
在内核中的表示:
解释:
每一个信号都有两个标志位,分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。当信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。中。当信号被阻塞时,它不能被递达,只能处于未决状态,无论它的处理方式是什么。
补充:
1.在block位图中,比特位的位置代表某一个信号,比特位的内容代表该信号是否被阻塞。
2.在pending位图中,比特位的位置代表某一个信号,比特位的内容代表是否收到该信号。
3.handler表本质上是一个函数指针数组,数组的下标代表某一个信号,数组的内容代表该信号递达时的处理动作,处理动作包括默认、忽略以及自定义。
4.block、pending和handler这三张表的每一个位置是一一对应的。
3.3sigset_t:
从上面可知,每个信号的阻塞与未决状态都是一个比特位,0或1。所以,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集。这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有 效”和“无效”的含义是该信号是否处于未决状态。
3.4信号集操作函数
sigset_t类型表示了信号的阻塞与未决状态,至于该类型的具体实现不必关心,我们可以使用以下函数来操作sigset_t。
int sigemptyset(sigset_t *set);初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。
int sigfillset(sigset_t *set);初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。
int sigaddset(sigset_t *set, int signum);在set所指向的信号集中添加某种有效信号。
int sigdelset(sigset_t *set, int signum);在set所指向的信号集中删除某种有效信号。
sigemptyset、sigfillset、sigaddset和sigdelset函数都是成功返回0,出错返回-1。
int sigismember(const sigset_t *set, int signum); 判断在set所指向的信号集中是否包含某种信号,若包含则返回1,不包含则返回0,调用失败返回-1。
3.5 sigprocmask
sigprocmask函数可以用于读取或更改进程的信号屏蔽字(阻塞信号集).
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
参数说明:
参数说明:
- 如果oldset是非空指针,则读取进程当前的信号屏蔽字通过oldset参数传出。
- 如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。
- 如果oldset和set都是非空指针,则先将原来的信号屏蔽字备份到oldset里,然后根据set和how参数更改信号屏蔽字。
调用成功返回0,失败返回-1.
假设当前的信号屏蔽字为mask,下表说明了how参数的可选值及其含义:
3.6sigpending
sigpending函数可以用于读取进程的未决信号集,该函数的函数原型如下:
int sigpending(sigset_t *set);
解释:sigpending函数读取当前进程的未决信号集,并通过set参数传出。该函数调用成功返回0,出错返回-1。
代码演示:
void printPending(sigset_t *pending)
{
int i = 1;
for (i = 1; i <= 31; i++){
if (sigismember(pending, i)){
printf("1 ");
}
else{
printf("0 ");
}
}
printf("\n");
}
void handler(int sin)
{
printf("获取一个信号%d\n",sin);
}
int main()
{
sigset_t set, oset;
sigemptyset(&set);
sigemptyset(&oset);
sigaddset(&set, 2);
sigprocmask(SIG_SETMASK, &set, &oset); //阻塞2号信号
sigset_t pending;
sigemptyset(&pending);
int k=10;
signal(2, handler);
while (1){
sigpending(&pending); //获取pending
printPending(&pending); //打印pending位图(1表示未决)
k--;
sleep(1);
if(k==0)
{
printf("2号信号阻塞解除了\n");
sigprocmask(SIG_UNBLOCK, &set, NULL);
}
}
return 0;
}
结果:
四.捕抓信号
4.1内核实现信号的捕抓
先来看一下内核空间与用户空间:
当进程创建好后,进程地址空间分为内核空间与用户空间,
用户所写的代码和数据位于用户空间,通过用户级页表与物理内存之间建立映射关系。内核空间存储的是操作系统代码和数据,通过内核级页表与物理内存之间建立映射关系,而内核级页表只有一份,所有的进程共享(前提是有权力去访问).
内核态:一般用来执行操作系统的代码,权限是很高的状态。
进程如果处于用户态,则只能访问用户级页表,若处于内核态,则都可以访问。(CPU内部有对应的状态寄存器来标识处于哪种状态)
从用户态切换为内核态通常有如下几种情况:
- 需要进行系统调用时。
- 当前进程的时间片到了,导致进程切换。
- 产生异常、中断、陷阱等。
与之相对应,从内核态切换为用户态有如下几种情况:
- 系统调用返回时。
- 进程切换完毕。
- 异常、中断、陷阱等处理完毕。
用户态切换为内核态我们称之为陷入内核,比如系统调用等。
当从内核态切换回用户态时,操作系统会进行信号的检测与处理。会去查看pending位图,如果发现有未决信号,并且该信号没有被阻塞,那么此时就需要该信号进行处理。
重点:此时对该信号处理又分位两种情况。1.执行默认的处理方法,或者忽略。2.该处理方法是用户自定义的。
1.如果待处理信号的处理动作是默认或者忽略,则执行该信号的处理动作后清除对应的pending标志位,如果没有新的信号要递达,就直接返回用户态,从主控制流程中上次被中断的地方继续向下执行。
2.如果待处理信号是自定义捕捉的,那么处理该信号时就要先返回用户态执行对应的自定义处理动作(若以内核态去执行用户写的代码,权限高,可能会出问题),执行完后再通过特殊的系统调用sigreturn再次陷入内核并清除对应的pending标志位,如果没有新的信号要递达,就直接返回用户态,继续执行主控制流程的代码。
4.2sigaction
可以使用sigaction函数对信号进行捕捉,sigaction函数的函数原型如下:
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
参数解释:
- signum代表指定信号的编号。
- 若act指针非空,则根据act修改该信号的处理动作。
- 若oldact指针非空,则通过oldact传出该信号原来的处理动作。
act和oldact都是结构体指针变量,该结构体的定义如下:
struct sigaction {
void(*sa_handler)(int);
void(*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask;
int sa_flags;
void(*sa_restorer)(void);
};
该结构体的变量解释:
sa_handler:是注册信号的函数处理方法,SIG_IGN传给sigaction函数,表示忽略信号。赋值为常数SIG_DFL传给sigaction函数,表示执行系统默认动作。sa_handler赋值为一个函数指针,表示用自定义函数捕捉信号
sa_sigaction:表示的是实时信号
sa_flags:设置为0
sa_mask:当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。
举例:
int main()
{
struct sigaction act, oact;
act.sa_handler = handler;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
sigaction(2, &act, &oact);
while(1)
{
cout<<"main running"<<endl;
sleep(1);
}
return 0;
}
结果:
五.可重入函数
主函数中调用insert函数向一个链表中插入结点node1,某信号处理函数中也调用了insert函数向该链表中插入结点node2,如图:
函数insert有两条语句,当执行完第一条指令时,被中断切换为内核态,处理完后回到用户态之前,检查到有信号待处理,于是会去调用该信号处理函数,而该信号处理函数中也调用了Insert函数,如图:
第一步:
调用信号处理函数后:
main函数中的insert函数执行完后:
而此时node2结点便丢失了,会造成内存泄露。
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标志I/O库函数,因为标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
六.SIGCHLD信号
为了避免出现僵尸进程,父进程需要使用wait或waitpid函数等待子进程结束,父进程可以阻塞等待或则使用非阻塞等待的去查询是否有子进程结束等待清理,即轮询的方式。采用第一种方式,父进程阻塞就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。
其实,子进程在终止时会给父进程发生SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理动作,这样父进程就只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait或waitpid函数清理子进程即可。
注意:如果在同一时刻有多个子进程同时退出,那么在handler函数当中实际上只清理了一个子进程,因此在使用waitpid函数清理子进程时需要使用while不断进行清理。
例如:
void FreeChild(int signo)
{
cout<<"获取一个信号:"<<signo<<endl;
assert(signo == SIGCHLD);
while (true)
{
pid_t id = waitpid(-1, nullptr, 0);
if (id > 0)
{
cout << "父进程等待成功,chld id:" << id << endl;
}
else
{
cout<<"所有子进程已退出"<<endl;
break;
}
}
}
int main()
{
signal(SIGCHLD, FreeChild);
for (int i = 0; i < 10; i++)
{
pid_t id = fork();
if (id == 0)
{
int cnt = 5;
while (cnt)
{
cout << "我是子进程,pid:" << getpid() << "当前cnt:" << cnt-- << endl;
sleep(1);
}
cout << "子进程退出,进入僵尸状态" << endl;
exit(0);
}
}
while (1)
{
cout << "我是父进程,pid:" << getpid() << endl;
sleep(1);
}
return 0;
}
结果:
如果子进程退出的时间间隔很大,可以使用非阻塞等待。 waitpid(-1,nullptr,WNOHANG):此时是父进程非阻塞等待,那么当父进程等待先结束的子进程后,此时父进程进入FreeChld中的循环中,父进程是非阻塞等待会通过id==0(等待成功,但子进程没有退出 )这个条件跳出循环去做父进程的事情。
例如:
void FreeChld(int signo)
{
assert(signo == SIGCHLD);
while (true)
{
pid_t id = waitpid(-1, nullptr, WNOHANG);
if (id > 0)
{
cout << "父进程等待成功,chld id:" << id << endl;
}
else if (id == 0) //等待成功,但子进程没有退出
{
cout << "还有子进程没有退出,父进程要忙自己的事了" << endl;
break;
}
else
{ // waitpid 已经没有子进程了调用失败,id<0
cout << "所有子进程已退出" << endl;
break;
}
}
}
事实上,由于UNIX的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用signal或sigaction函数将SIGCHLD信号的处理动作设置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用signal或sigaction函数自定义的忽略通常是没有区别的,但这是一个特列。此方法对于Linux可用,但不保证在其他UNIX系统上都可用。
signal(SIGCHLD, SIG_IGN);