本篇博客整理了进程信号从产生到处理的过程细节,通过不同过程中的系统调用和其背后的原理,旨在让读者更深入地理解操作系统的设计与软硬件管理手段。
目录
一、信号是什么
1.以生活为鉴
2.默认动作与自定义动作
3.信号的分类、保存、产生
二、产生信号
1.通过终端按键产生
2.通过系统调用向进程发送信号
2.1-kill()
2.2-raise()
2.3-abort()
3.由软件条件产生
3.1-SIGPIPE信号
3.2-SIGALRM信号
4.由硬件异常产生
4.1-除 0 错误
4.2-野指针和越界访问
三、阻塞信号
1.信号如何被保存
2.信号集 sigset_t
3.信号集相关接口
3.1-sigprocmask()
3.2-sigpending()
四、捕捉信号
1.内核态和用户态
2.信号如何被捕捉
3.相关接口
3.1-signal()
3.2-sigaction()
补、函数的可重入与不可重入
补、C的关键字 volatile
补、17号信号 SIGCHLD
一、信号是什么
1.以生活为鉴
在生活中,红绿灯是一个常见的、动态的交通信号。
红灯停,绿灯行,人们一看到红灯,就知道应该在斑马线前停下来,一看到绿灯,就知道可以过马路了。识别红绿灯信号的这个过程,既需要人们懂得红绿灯的含义,又需要人们能够做出匹配含义的行为,这样才能算是识别了红绿灯信号。
而在红灯的时候,人们未必会停下来,也有可能闯红灯;在绿灯的时候,人们也未必会立刻出发,可能会因避让车辆而等待一小段时间,等待之后如果绿灯没变再立刻出发。也就是说,信号在获取后,既可以立即处理,也可以延期处理,在延期处理时,信号需要被暂时保存下来。
于是乎,一个信号的一生就可以大致分为产生信号、保存信号、处理信号。
回到 Linux 中的进程信号,信号是发送给进程的,实然,信号的种类和逻辑已被设计好,以便进程能够保存和处理。当信号发送给进程、进程保存了这个信号后,可以立即处理,也可以因优先级更高的任务而等时机合适再去处理。进程收到信号和处理信号之间存在一个窗口期,在窗口期,进程会将将收到的信号保存起来。
2.默认动作与自定义动作
以下程序,会不停执行一个死循环,以打印字段 “hello signal!” 到屏幕。
//test.c
#include <stdio.h>
#include <unistd.h>int main()
{while (1){printf("hello signal!\n");sleep(1);}return 0;
}
对于一个死循环程序,终止程序的最好方式,就是使用快捷指令 Ctrl + C 。
使用快捷指令 Ctrl + C,这个死循环进程就终止了,这是因为,快捷指令 Ctrl + C 会使键盘会产生一个硬件中断,然后操作系统会获取它,并将 Ctrl + C 解释成 2 号信号。
如果对以上代码进行修改,通过一个系统调用将由 Ctrl + C 产生的 2 号信号捕获,那么程序是不是不会终止了呢?
//test2.c
#include <stdio.h>
#include <signal.h>
#include <unistd.h>//自定义的处理方法
//收到2号信号时,打印以下内容
void handler(int sig)
{printf("get a signal:%d\n", sig);
}int main()
{signal(2, handler); //系统调用 signal() ———— 自定义信号的处理动作(若不设置自定义动作,则用系统内置的默认动作)//sighandler_t signal(int signum, sighandler_t handler);//typedef void (*sighandler_t)(int); /* 这是一个函数指针类型的重定义,其参数为int,返回值类型为void *///参数是捕获信号的编号、处理信号的方法//设置成功,返回信号在自定义前的处理方法;失败,返回SIG_ERR用于表示错误,并且错误码会指明错误信息。while (1){printf("hello signal!\n");sleep(1);}return 0;
}
由演示图,在修改过的程序运行起来后,再使用快捷指令 Ctrl + C,这个死循环进程不会终止了,而是像代码设计的那样打印字段 “ get a signal:2 ”。
像以上这样,按下快捷指令 Ctrl + C后,正在运行的死循环程序被终止,这叫作信号处理的默认动作;在使用系统调用 signal() 后按下快捷指令 Ctrl + C,正在运行的死循环程序不会被终止,而是执行新的自定义任务,这叫作信号处理的自定义动作,或称捕捉了信号。
【Tips】常见的信号处理方式
- 默认动作:系统内置的信号处理方式,在一个信号未自定义它的处理方式时优先通过默认动作进行信号处理。在man手册中可以找到各个信号处理的默认动作(指令:man 7 signal)。其实所有信号的默认动作都是结束进程,只是不同的信号表示了不同的异常情况。
- 自定义动作:或称捕捉信号,通过系统提供的信号处理接口,使内核在处理一个信号时,切换到用户态执行这个接口,以完成信号处理的自定义动作。
- 忽略动作:忽略信号,不做处理。具体的方式是,将封装在进程PCB中、用于表示信号未决状态的 pending 表置空,然后直接将进程置为用户态,以直接退出内核态。
【补】快捷指令 Ctrl + C 引申出的一些细节
- Ctrl + C 产生的信号只能发送给前台进程。Shell可以同时运行一个前台进程和任意多个后台进程,但是只有前台进程才能接到像Ctrl+C这种控制键产生的信号。
- 在一个指令后面加上 “ & ” 就可以将其放到后台运行,这样 Shell 就不必等待进程结束才接收新的指令、启动新的进程。
- 前台进程在运行过程中,用户随时可能按下Ctrl+C而产生一个信号,也就是说,该进程的用户空间代码执行到任何地方,都可能因收到 SIGINT 信号而终止,所以信号对于进程的控制流程来说是异步的。
- 中断,指当出现需要时,CPU暂停当前程序的执行,转而执行处理新情况的程序和执行过程。换句话说,在程序运行过程中,系统出现了一个必须由CPU立即处理的情况,此时,CPU暂时中止程序的执行转而处理这个新的情况,而这个过程就叫做中断。
- 中断分为硬件中断和软件中断。硬件中断是由与系统相连的外设(比如网卡 硬盘 键盘等)自动产生的;信号是进程之间事件异步通知的一种方式,属于软件中断。
3.信号的分类、保存、产生
指令 kill -l,可以查看 Linux 中的信号列表。其中,普通信号和实时信号各有31个,1~31号是普通信号,34~64号信号是实时信号,没有32号和33号信号,每个信号都有一个编号和一个宏定义名称。
【补】普通信号的内容及其含义
- 程序不可捕获、阻塞或忽略的信号有:SIGKILL,SIGSTOP
- 不能恢复至默认动作的信号有:SIGILL,SIGTRAP
- 默认会导致进程流产的信号有:SIGABRT,SIGBUS,SIGFPE,SIGILL,SIGIOT,SIGQUIT,SIGSEGV,SIGTRAP,SIGXCPU,SIGXFSZ
- 默认会导致进程退出的信号有:SIGALRM,SIGHUP,SIGINT,SIGKILL,SIGPIPE,SIGPOLL,SIGPROF,SIGSYS,SIGTERM,SIGUSR1,SIGUSR2,SIGVTALRM
- 默认会导致进程停止的信号有:SIGSTOP,SIGTSTP,SIGTTIN,SIGTTOU
- 默认进程忽略的信号有:SIGCHLD,SIGPWR,SIGURG,SIGWINCH
- 此外,SIGIO在SVR4是退出,在4.3BSD中是忽略;SIGCONT在进程挂起时是继续,否则是忽略,不能被阻塞。
进程收到一个信号后,会将这个信号记录在自己的进程控制块(PCB)中,进程PCB本质是一个结构体对象,其中封装了一个拥有32个比特位的位图,比特位的下标表示信号的编号,比特位上的值表示是否收到几号信号,1表示收到了,0表示没有收到。
进程通过进程PCB中封装的位图,来记录是否收到过几号信号,以此完成信号的保存操作。
进程PCB封装的位图被修改,意味着进程收到了一个信号,同时也标志着信号产生了。信号位图一旦被修改,就意味着这个进程的数据被修改了,而修改进程数据的操作只有进程的管理者——操作系统才有资格来进行。
所以,一个信号产生了,其实就是操作系统对进程PCB中信号位图进行了修改操作。因此,尽管信号发送的方式有多种,但信号只能由操作系统来发送。
二、产生信号
1.通过终端按键产生
以上文中死循环程序为例,Ctrl + C 和 Ctrl + \ 都可以终止进程。
Ctrl + C 会向进程发送 2 号信号 SIGINT,Ctrl + \ 会向进程发送 3 号信号 SIGQUIT。这两个信号的默认动作是不同的,2 号信号对应的是Term,3 号信号对应的是 Core。Term 和 Core 都会终止进程,但 Core 会多做一步,即核心转储。
一个进程的终止,分为正常终止和异常终止,正常终止通常是代码运行到头、进程完成了自己的任务,异常终止通常是进程收到信号、被信号所“杀”。
当一个进程异常终止时,例如发生了代码崩溃,在哪一段语句崩溃、为什么崩溃,这些信息显然是十分重要的。在进程异常终止的对应时刻,系统会将内存中的有效数据立即转储到磁盘中,这个过程就称为核心转储(core dump)。而这些与异常终止有关的有效数据,被存到了一个核心转储文件中(以“core”为文件名,以进程pid为文件后缀:core.pid),借助这个核心转储文件进行调试,能够快速定位到代码出现异常的位置。
【补】云服务器下启用核心转储
指令 ulimit -a 可以查看当前资源限制的设定。在云服务器中,核心转储是默认被关闭的(core.pid文件的大小默认设置为0),所以在小编的 Linux 环境下,不能直接观察到 core 方式终止进程的现象(进程异常终止后,系统生成了core.pid文件)。
指令 ulimit -c + 文件大小(字节)可以启动核心转储。
(ps:ulimit 指令实际改变的是bash的Resource Limit 值,但由于 test 是 bash 的子进程,因此 test 也有和 bash 相同的Resource Limit 值)
此时,再使用 Ctrl + \ 发送信号终止进程,会显示“core dumped”,且在当前路径下,会生成一个 core.pid 文件。
要了解进程异常终止的原因,或快速定位到代码出现异常的位置,只需在进入 gdb 调试器后,输入指令 core-file + core.pid 加载核心转储文件即可。
其实,在往期的进程等待一节,核心转储(core dump)就已经出现过了,这一节中,主要整理的是系统调用waitpid()的第二个参数 status 中的core dump标志。
pid_t waitpid(pid_t pid, int *status, int options);
waitpid()的第二个参数 status是一个位图,它的16个低比特位与进程的退出信息有关,其中,0到7位保存了进程收到的异常信号(特别的,第7位是 core dump 标志),8到15位保存了进程的退出码。
(详情移步至:【Linux系统】进程控制-CSDN博客)
根据参数 status 的第7个比特位,即可得知子进程在被终止时,系统是否进行了核心转储。
为了方便演示,此处引入以下代码:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>int main()
{if (fork() == 0){//子进程部分printf("I am running...\n");int *p = NULL;//子进程所执行的代码当中存在野指针*p = 100;//代码执行到这句时,系统会终止进程并进行核心转储exit(0);}//父进程部分int status = 0;waitpid(-1, &status, 0);//waitpid函数便可获取到子进程退出时的状态,//根据参数 status 的第7个比特位,//即可得知子进程在被终止时,系统是否进行了核心转储。printf("exitCode:%d, coreDump:%d, signal:%d\n",(status >> 8) & 0xff, (status >> 7) & 1, status & 0x7f);//通过位运算,获取status 的第n个比特位return 0;
}
2.通过系统调用向进程发送信号
2.1-kill()
指令 kill -信号名称/信号编号 + 进程ID,可以向一个进程发送信号以终止进程。
#include <iostream> #include <unistd.h> using namespace std;int main() {while(1){cout << "I am a process,my pid: " << getpid()<< endl;sleep(1);}return 0; }
实际上,kill 指令的功能是通过在底层调用系统接口 kill() 实现的。
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
功能:发送信号并终止进程
参数:1.pid:要终止进程的PID2.sig:要发生信号的编号
返回值:信号发送成功,则返回0;发生失败,则返回-1
此处引入以下代码,用 kill() 模拟实现一个 mykill 指令:
#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;
}
2.2-raise()
#include <unistd.h>
#include <signal.h>
int raise(int sig);
功能:给当前进程发送信号(自己给自己发信号)
参数:要发生信号的编号
返回值:发送成功,则返回0;失败,则返回一个非零值
为演示 raise() 的使用,此处引入以下代码:
#include <stdio.h>
#include <unistd.h>
#include <signal.h>void handler(int signo)
{printf("get a signal:%d\n", signo);
}
int main()
{signal(2, handler);while (1){sleep(1);raise(2);//每隔一秒向自己发送一个2号信号}return 0;
}
2.3-abort()
#include <unistd.h>
#include <signal.h>
void abort( void );
功能:给当前进程发送 SIGABRT 信号(给自己发生6号信号),异常终止当前进程效果等价于 raise(6) 和 kill(getpid(),6)
参数:无
返回值:无
为演示 abort() 的使用,此处引入以下代码:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>void handler(int signo)
{printf("get a signal:%d\n", signo);
}
int main()
{signal(6, handler);while (1){sleep(1);abort();//每隔一秒向当前进程发送一个SIGABRT信号}return 0;
}
3.由软件条件产生
3.1-SIGPIPE信号
SIGPIPE信号是 13 号信号,与管道的使用有关。
当进程在通过管道进行通信时,如果读端进程将读端关闭,而写端进程还在继续向管道写入数据,那么写端进程就会收到SIGPIPE信号然后被操作系统终止。
为演示SIGPIPE信号的作用,此处引入以下代码:
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{int fd[2] = { 0 };if (pipe(fd) < 0){ //使用pipe创建匿名管道perror("pipe");return 1;}pid_t id = fork(); //使用fork创建子进程if (id == 0){//子进程 - 写端//关闭读端close(fd[0]); //向管道写入数据const char* msg = "hello father, I am child...";int count = 10;while (count--){write(fd[1], msg, strlen(msg));sleep(1);}//写入完毕,关闭文件,并退出进程close(fd[1]); exit(0);}//父进程 - 读端//关闭写端close(fd[1]); //直接关闭读端(导致子进程被操作系统杀掉)close(fd[0]); int status = 0;waitpid(id, &status, 0);//打印子进程收到的信号printf("child get signal:%d\n", status & 0x7F); return 0;
}
以上代码中,父进程是读端进程,子进程是写端进程,两者通过匿名管道进行通信。通信一开始,父进程就将读端关闭了,接下来,子进程只要向管道写入数据,就会收到SIGPIPE信号,然后被终止。
3.2-SIGALRM信号
SIGALRM信号是 14 号信号,与系统调用 alarm() 和时间有关。
系统调用 alarm() 可以设定一个闹钟,使操作系统在一段时间之后,发送SIGALRM信号给当前进程。
#include <signal.h>
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
功能:定时终止进程
参数:发送信号前的等待时间(秒)
返回值:1.若调用alarm()前,进程已经设置了闹钟,则返回上一个闹钟时间的剩余时间,且本次闹钟的设置会覆盖上一次闹钟的设置。2.若调用alarm()前,进程没有设置闹钟,则返回0。
为演示,此处引入以下代码:
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>int count = 0;
void handler(int signo)
{printf("get a signal: %d\n", signo);printf("count: %d\n", count);exit(1);
}
int main()
{signal(SIGALRM, handler);alarm(1);while (1){count++;}return 0;
}
以上代码中,alarm() 设置了当前进程在 1 秒后就终止。在这 1 秒中,不断对一个变量做累加,等到 1 秒后、进程终止之前,打印累加的结果。
4.由硬件异常产生
当一个程序当中出现除0、野指针、越界等异常错误时,一定会在硬件层面上有所反应,进而被操作系统识别并发送相应的信号将这个进程终止。
4.1-除 0 错误
为方便演示除 0 错误,此处引入以下代码:
#include<iostream>
using namespace std;
int main()
{int a = 1;int b = 0;a / b; //这一句代码,由于没有将“/”的表达式结果进行存储。//因此计算无意义,编译器会直接优化,不对 a/b 做相关计算cout << "div zero before." << endl; int c = a / b;//而这一句代码,由于对“/”的表达式结果进行了存储,且出现除0,//因此会引发异常cout << "div zero after." << endl;return 0;
}
由演示图,在执行有除 0 错误的语句前, "div zero before."被成功打印;在执行有除 0 错误的语句后,"div zero after."并没有被打印,程序出现了异常信息并直接终止了。
其实,除 0 错误发生时,进程会因接收到 SIGFPE 信号而终止。
SIGFPE 信号是 8 号信号,是一种由硬件异常产生的信号,用于表示算术运算异常(如除0、浮点溢出等),它的默认动作是终止进程。
它的原理与 CPU 的状态寄存器有关。CPU中存在很多寄存器,例如 eax,ebx,eip 等。为了运行一个进程,CPU会从内存中将进程代码中的变量拿到寄存器中进行运算,如有必要,还会将运算的结果取回到内存中。其中,状态寄存器(本质是一个位图)用于标记代码执行结果的各种状态信息,如有无进位、有无溢出等。如果CPU在运算时发现了除 0 操作,就会将状态寄存器的溢出标志位进行置位,以第一时间提醒操作系统,硬件产生了异常,然后由操作系统给相应的进程发送SIGFPE信号。
如果对以上代码进行修改,将SIGFPE 信号的处理方法设置为自定义动作,不让进程退出,那将会发生什么呢:
#include<iostream>
#include<signal.h>
#include<unistd.h>
using namespace std;
void handler(int sig)
{cout << "catch sig: " << sig << " my pid is " << getpid() << endl; sleep(1);//exit(-1);
}
int main()
{signal(SIGFPE,handler);int a = 1;int b = 0;cout << "div zero before." << endl;int c = a / b;cout << "div zero after." << endl;return 0;
}
由演示图,执行代码修改后的自定义动作后,进程不再立即终止,而是在死循环执行着,其间,一直能收到操作系统发送的 SIGFPE 信号。
这个不再终止的进程一直能够收到操作系统发生的 SIGFPE 信号,是因为,这个进程在收到信号后不终止,就会因CPU时间片的轮转而再次被调度,CPU 中只有一份寄存器,但寄存器中的内容属于当前进程的上下文,当进程被切换的时候,就伴随着无数次的状态寄存器被保存和恢复的过程,除 0 操作导致的溢出标志位的数据会被恢复到CPU中,所以,操作系统就不停地识别到硬件异常,且不停地给这个进程发送 SIGFPE 信号。
【Tips】除 0 错误引发的硬件异常,与 CPU 中的状态寄存器、8 号 SIGFPE信号有关。
4.2-野指针和越界访问
野指针问题一般有,对空指针进行解引用操作。
为方便演示野指针问题,此处引入以下代码:
#include<iostream>
using namespace std;
int main()
{int *p = NULL;*p;//这句代码,对*p解引用而没有取值动作,因此编译器直接进行了优化,不做任何处理cout << "dereference before" << endl;*p = 0;//但这句代码,对*p解引用并赋值,因此引发了野指针问题cout << "dereference after" << endl;return 0;
}
由演示图,在执行存在野指针问题的语句前,"dereference before" 被成功打印;在执行存在野指针问题的语句后,"dereference after" 没有被打印,程序出现了异常信息并直接终止了。
空指针的本质是宏定义的 (void*)0,而 0 地址属于内核空间,是不允许用户访问的,强行访问就会引发野指针问题。
野指针问题发生时,进程会因接收到 SIGSEGV 信号而终止。
SIGSEGV 信号是 11 号信号,也是是一种由硬件异常产生的信号,用于表示进程进行了一次无效的内存访问,它的默认动作是终止进程。
当代码访问到一个变量时,一定要先经过页表的映射,将虚拟地址转换成物理地址,然后才能进行相应的访问操作。页表是一种软件映射关系,而实际上,在从虚拟地址到物理地址映射的过程中,还有一个叫 MMU 的硬件参与。
MMU 是一种负责处理 CPU 的内存访问请求的、集成在 CPU 中的计算机硬件。虚拟地址与物理地址的映射工作,其实是由 MMU 来完成的,当需要将虚拟地址转换成物理地址的时候,页表的的虚拟地址会导出给 MMU ,然后由 MMU 会计算出相应的物理地址,并返回给页表,以供进程访问。
既然 MMU 是硬件单元,那么它当然也有相应的状态信息,如果进程访问了不允许访问的虚拟地址,MMU 在进行虚拟地址到物理地址的转换时,就会出现错误,并将相应的错误信息写入到自己的状态信息中,以第一时间提醒操作系统,硬件产生了异常,然后由操作系统给相应的进程发送 SIGSEGV 信号。
【Tips】野指针和越界访问引发的硬件异常,与集成在 CPU 中的 MMU 、11 号 SIGSEGV 信号有关。
三、阻塞信号
信号的保存一般被称为阻塞,或称为信号的未决。
【补】信号从产生到被处理
- 信号递达(Delivery):实际执行信号的处理动作。
- 信号未决(Pending):信号从产生到递达之间的状态。
- 信号阻塞(Block):进程可以选择阻塞某个信号,使其在产生后一直保持在未决状态,直到解除阻塞,才有递达后的动作(注意:阻塞和忽略是不同的,信号一旦阻塞就不会递达,而忽略是信号递达之后的一种处理动作)。
1.信号如何被保存
信号的保存,与进程PCB中封装的三个数据结构有关。
操作系统为信号在内核中维护了三张表,它们分别是:
- 信号未决表(pending):是一个位图,用于存放进程接收到的信号,位图中的比特位表示一个指定的信号,比特位上的值表示是否收到该信号。只要进程收到了操作系统发送的信号, pending 表就会被修改。
- 信号阻塞表(block):是一个位图,用于存放被阻塞的信号,位图中的比特位表示一个指定的信号,比特位上的值表示该信号是否被阻塞。只要存在信号需要被阻塞,block 表就会被修改。
- 处理方法表(handler):是一个函数指针数组,用于存放不同信号的处理方法,数组的下标表示相应的信号编号,下标位置上的值表示该信号递达时的处理动作(默认、自定义、忽略)。handler 数组中初始的函数指针是信号的默认处理动作,如果不使用系统调用 signal() 注册一个自定义处理动作(此时操作系统会将自定义的函数指针放在handler表中),则默认使用初始的函数指针。
它们被封装在进程PCB中,当进程收到信号后,会对其进行针对性的修改操作。
一开始,由于没有信号产生,因此所有信号都是不被阻塞的,也就是说,pending 表和 block 表的值是0。
当信号产生后,操作系统会修改 pending 表和 block 表,使信号处于未决状态,然后按一定的顺序来检查 pending 表和 block 表。先检测的是 block 表,如果一个信号所映射的比特位被置为1,则说明这个信号被阻塞,也就不再去检测 pending 表;如果信号没有被阻塞,才会去检测pending 表,当 pending 表相应的比特位也被置为 1,才会去调用 handler 表中的处理方法。
//【补】操作系统检测 pending 表和 block 表的伪代码:
// signo - 信号编号
if(1<<(signo - 1) & pcb->block)
{//signo信号被阻塞,不会递达
}
else
{if(1<<(signo - 1) & pcb->pending){//信号递达,调用该信号的处理方法handler[signo - 1];}
}
此外,一个还没有产生的信号,也是可以被阻塞的。当被设为阻塞的信号产生后,信号会一直处于未决状态而不会递达,只有当阻塞解除后才会递达。
2.信号集 sigset_t
每个信号在内核中的未决标志和阻塞标志,都由位图中的一个比特位来表示且非 0 即 1 。而在代码层面,信号的未决标志和阻塞标志存储在 sigset_t 中。
sigset_t 是一个数据类型,被称为信号集,可以表示每个信号的“有效”或“无效”:
- 在未决信号集中,“有效”和“无效”的含义是信号是否处于未决状态。
- 在阻塞信号集中(或称进程的信号屏蔽字),“有效”和“无效”的含义是信号是否被阻塞。
在小编的 Linux 环境中,sigset_t 类型的定义如下:
//ps:不同操作系统实现sigset_t的方案可能不同
#define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int)))
typedef struct
{unsigned long int __val[_SIGSET_NWORDS]; //位图
} __sigset_t;typedef __sigset_t sigset_t;
3.信号集相关接口
sigset_t 类型可以对每种信号仅用一个比特位表示其“有效”或“无效”,但 sigset_t 内部如何存储这些比特位则由操作系统决定,用户不必关心,用户只关心与 sigset_t 相关的系统调用即可。
#include <signal.h>
//ps:在使用sigset_t类型的变量前,
// 一定要调用sigemptyset()或sigfillset()进行初始化,使信号处于确定的状态。
int sigemptyset(sigset_t *set);/* 对传入的 set 指针所指向的变量进行清空 */
int sigfillset(sigset_t *set);/* 对指向的变量添上所有支持的信号位 */
int sigaddset (sigset_t *set, int signo);/* 对set指向的变量中,添入 signo 变量的编号 */
int sigdelset(sigset_t *set, int signo);/* 对set指向的变量中,删除 signo 对应的编号 */
int sigismember(const sigset_t *set, int signo);/* 检测 signo 信号是否在set指向的变量中,在则返回1,不在则返回0 */
//以上函数的返回值统一为:调用成功则返回1,失败则返回-1,并设置合适的错误码
//...
以上接口的使用示例请见下文。
3.1-sigprocmask()
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
功能:读取或更改进程的信号屏蔽字
参数:1.how: 修改选项。假设信号屏蔽字为mask————1)SIG_BLOCK,将set中的信号添加到block表中,相当于 mask = mask | set。2)SIG_UNBLOCK,将set中的信号从block表中删除,相当于 mask = mask | ~set。3)SIG_SETMASK,最常用,将set覆盖block表,相当于 mask = set。 2.set:输入型参数,设置好的sigset_t变量,即可以修改block表的数据。3.oset:输出型参数,即保存在block被修改前的所有信息,便于以后进行恢复。【ps】(1)若oset是非空指针,则读取进程当前的信号屏蔽字并由oset传出。(2)若set是非空指针,则更改进程的信号屏蔽字,如何更改由参数how决定。(3)若oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。
返回值:成功则返回0;失败则返回-1,并设置合适的错误码。
sigprocmask() 的使用演示请见下文。
3.2-sigpending()
#include<signal.h>
int sigpending(sigset_t *set);
功能:读取进程的未决信号集
参数:set:输出型参数,用于接收内核中的pending表,并将其传出。
返回值:调用成功则返回0;失败则返回-1,并设置合适的错误码。
为演示 sigpending() 、sigprocmask() 和其他信号集接口的使用,此处引入以下代码:
//演示信号的未决、阻塞、递达
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
//打印pending表
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 signo)
{printf("handler signo:%d\n", signo);
}int main()
{//打印当前进程的pidprintf("My PID is:%d\n",getpid());//注册2号信号的自定义动作signal(2, handler);//初始化set、osetsigset_t set, oset;sigemptyset(&set);sigemptyset(&oset);//将2号信号添加进setsigaddset(&set, 2); //阻塞2号信号sigprocmask(SIG_SETMASK, &set, &oset); //初始化pendingsigset_t pending;sigemptyset(&pending);int count = 0;while (1){//获取进程的pending表sigpending(&pending); //打印pending表,相应位置上 1 表示处于未决,0 表示不处于未决printPending(&pending); sleep(1);//解除2号信号的阻塞count++;if (count == 20){printf("开始解除阻塞\n");sigprocmask(SIG_SETMASK, &oset, NULL); printf("解除成功\n");}}return 0;
}
由演示图,进程刚刚运行起来的时候,pending 表的比特位全为 0,当进程收到因 kill 指令产生的 2 号信号后,pending 表中映射 2 号信号的比特位上为 1 ,一段时间后,阻塞解除,2 号信号递达,它的自定义动作被执行,pending 表中映射 2 号信号的比特位上又恢复为 0 。
四、捕捉信号
处理信号时,执行为该信号注册的自定义动作,一般被称为信号的捕捉。
信号可以立即处理,也可以延时处理,这些都是可能发生的信号处理的自定义动作。总之,这些处理动作一定伴随着一个合适的时机,但合适的时机又是什么呢?要了解这一点,首先得明白什么是内核态、什么是用户态。
1.内核态和用户态
简单来说,内核态和用户态是进程运行时的两种身份状态。
- 用户态:是一种仅有有限权限的状态,CPU 正在执行用户层的代码时,进程处于用户态。
- 内核态:是一种拥有极高权限的状态,CPU 正在通过访问内核或硬件资源时,进程处于内核态。
由计算机层状结构,计算机可以大致分为用户层、系统调用层、内核层、硬件驱动层、硬件层。
进程就相当于是用户的“替身使者”,而进程是在用户层的,要访问内核资源或硬件资源,就必须在进程内部调用系统调用接口才能进入内核层、甚至硬件层成功访问。
虽然系统调用接口会被用户写在进程的代码中,以使进程调用这些接口,但这些接口的具体执行是由内核,也就是由操作系统来完成的。但操作系统是怎么知道一个进程的身份状态,以确定当前该执行进程的代码还是内核的代码呢?——答案是通过 CPU 中的 CR3 寄存器。
CPU 中的寄存器虽然只有一套,但一套有很多类别,有可见很多寄存器,如eax,ebx等,还有很多不可见寄存器,有的用来存放进程的PCB指针,还有的专门存放进程的页表指针。其中,CR3 寄存器专门用来表示进程的运行级别,0 表示内核态,此时CPU访问的是内核资源或者硬件,3 表示用户态,此时CPU执行的是用户层的代码。
操作系统是软硬件资源的管理者,很容易获取 CR3 寄存器中的数据,从而得知一个进程的身份状态是用户态还是内核态。
但,由于系统调用接口的代码在内核中,因此进程代码中的系统调用接口,并不是由进程来执行的,而是由操作系统执行的,那么,一个进程是怎么跑到内核层去执行代码的呢?
其实,进程并没有真的跑到内核层去,而在进程的进程地址空间中,有一块内核空间,进程通过内核空间就可以访问到内核中,系统调用接口的代码。
一份代码经过编译链接,生成一个可执行程序,这个可执行程序再加载到内存成为进程,以供 CPU 调度运行。程序被加载到内存,操作系统既会为它创建进程控制块,也会为它维护进程地址空间,而进程地址空间由用户空间和内核空间两部分组成。
- 用户空间,存储的是用户的代码和数据,通过用户级页表与物理内存之间建立映射关系。
- 内核空间,存储的是操作系统的代码和数据,通过内核级页表与物理内存之间建立映射关系。
其中,内核级页表是一个全局的页表,用于维护操作系统的与进程之间的关系,使每个进程都能看到相同的代码和数据,都能意识到操作系统的存在(但这并不意味着每个进程都能随时访问操作系统)。而用户级页表是一个局部的页表,用于维护进程与进程的代码、数据之间的关系,使每个进程都能看到独属于自己的代码和数据,但一般不能看到不属于自己的代码和数据。
进程访问用户空间时,必须处于用户态,访问内核空间时,必须处于内核态。
当进程的代码执行到有系统调用接口的一行时,会发生程序替换。通过内核空间,能够找到操作系统的代码和数据,在执行系统调用接口的代码时,也就是执行操作系统的代码时,CR3 寄存器中的数据会从 3 修改成 0,使进程完成用户态向内核态的转变,与此同时,当前进程的代码和数据会被剥离出来,紧接着换上另一个进程的代码和数据。当系统调用接口执行完毕时,CR3 寄存器中的数据会从 0 修改成 3 ,使进程完成内核态向用户态的转变,以继续执行用户空间中属于进程的代码。也就是说,系统调用接口之前和之后的代码,是由进程在执行,而系统调用接口的部分是由操作系统在执行。
由用户态切换为内核态,被称为陷入内核,进程需要陷入内核的时候,也就是进程需要执行操作系统代码的时候。
从用户态切换为内核态的情况,通常有以下几种:
- 代码执行到系统调用接口时。
- 进程的时间片到期,导致程序替换。
- 进程运行时发生异常、中断、陷阱等。
从内核态切换为用户态的情况,通常有以下几种:
- 系统调用执行完毕时。
- 程序替换完毕时。
- 将异常、中断、陷阱等处理完毕时。
2.信号如何被捕捉
信号的处理动作,一定伴随着一个合适的时机,而这个合适的时机,其实就是进程从内核态切换回用户态的时候。
当进程收到操作系统发生的信号,并试图将信号保存的时候,将切换到内核态,通过系统调用接口,以修改维护在内核中的 pending 表、block 表、handler 数组;保存完信号后,又将切换回用户态,以执行信号的处理动作(无论默认、自定义、忽略)。
一个进程在运行的时候,可能会因某些情况而陷入内核,当内核部分执行完毕、准备回到用户态之前,会对 pending 表进行检查。
如果存在未决信号,且该信号没有被阻塞,就需要查询 handler 数组 以执行该信号的处理动作。如果该信号的处理动作,是默认或忽略,那么在执行完该信号的处理动作之后,pending 表中映射该信号的标志位会被清除,然后回到用户态,从上次进程代码被中断的位置继续向后执行。
如果该信号的处理动作,是自定义的,即该信号的处理动作是由用户自己提供的,那么在执行完该信号的处理动作时,就先回到用户态,执行相应的自定义处理方法,等执行完后,再通过特殊的系统调用 sigreturn() 再次陷入内核,pending 表中映射该信号的标志位也会被清除,最终又回到用户态,从上次进程代码被中断的位置继续向后执行。
【ps】sighandler() 和 main() 是两个使用不同堆栈空间的、独立的控制流程,它们之间不存在调用和被调用的关系。
【附】信号捕捉过程的简化图:
如果不存在未决信号,就直接返回用户态,从上次进程代码被中断的位置继续向后执行。
总得来说,如果一个信号以自定义动作来处理,自定义动作一般执行一次,就会发生四次身份切换,而如果是默认动作或忽略动作,就只会发生两次身份切换,这是因为,默认动作或忽略动作是被写入到操作系统中的,是被操作系统所信任的信号处理动作,只要进程是内核态就可以直接执行。
3.相关接口
3.1-signal()
#include <signal.h>
sighandler_t signal(int signum, sighandler_t handler);
typedef void (*sighandler_t)(int); /* 这是一个函数指针类型的重定义,其参数为int,返回值类型为void */
功能:为一个信号注册自定义的处理动作(若不设置自定义动作,则用系统内置的默认动作)
参数:1.signum:捕捉信号的编号2.handler:自定义的处理方法
返回值:注册成功,返回信号在自定义前的处理方法;失败,返回SIG_ERR用于表示错误,且错误码会指明错误信息。
ps:9号信号、19号信号不能被自定义
在上文 “默认动作与自定义动作” 一节中,已演示 signal() 的使用,在此不作赘述。
3.2-sigaction()
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
功能:读取或修改一个信号的处理动作。
参数:1.signum:信号的编号。2.act:用于修改信号的处理动作。若act指针非空,则根据act修改信号的处理动作。3.oldact:用于获取信号原本的处理动作。若oldact指针非空,则通过oldact传出信号原本的处理动作。
返回值:调用成功,则返回0;失败,则返回-1,并设置合适的错误码。
【补】参数 act 和 oldact 的类型—— struct sigaction
//struct sigaction 的定义: struct sigaction {void(*sa_handler)(int);void(*sa_sigaction)(int, siginfo_t *, void *);sigset_t sa_mask;int sa_flags;void(*sa_restorer)(void); };
(1)第一个成员 sa_handler:
- 将 sa_handler 赋值为常数 SIG_DFL 传给sigaction函数,表示执行默认动作。
- 将 sa_handler 赋值为常数 SIG_IGN 传给sigaction函数,表示执行忽略动作。
- 将 sa_handler 赋值为一个函数指针,表示向内核注册了一个信号处理方法以捕捉信号,执行自定义动作;信号处理方法是一个回调函数,不是被 main() 调用,而是被操作系统调用。
(2)第二个成员 sa_sigaction:是实时信号的处理函数,它的三个参数可以获取更详细的信号信息。当第四个成员 sa_flags 的值,包含了 SA_SIGINFO 标志时,系统将使sa_sigaction 作为信号的处理函数,否则使用 sa_handler 作为信号的处理函数。
(3)第三个成员 sa_mask:用于标识需另外屏蔽(阻塞)的信号,
以及恢复信号原本的屏蔽字。
ps:当信号自定义的处理方法被调用时,内核会先将其加入进程的信号屏蔽字,直到信号的处理函数返回时,再恢复原本的信号屏蔽字,以保证正在处理一种信号时,如果这种信号再次产生,那么新产生的信号会被阻塞,等到处理结束后再递达。在信号的处理函数被调用时,除了要让当前信号被屏蔽之外,可能还需要屏蔽另外一些信号,此时可以用 sa_mask 字段来标识这些需另外屏蔽的信号,直到信号的处理函数返回时,再恢复其原本的屏蔽字。
(4)第四个成员 sa_flags(一般设置为 0 即可):
- SA_RESETHAND:当调用信号的处理函数时,将其重置为缺省值 SIG_DFL。
- SA_RESTART:如果信号中断了进程的某个系统调用,则系统自动启动该系统调用。
- SA_NODEFER :一般在信号的处理函数被执行时,内核会阻塞该信号,设置了 SA_NODEFER 标记,会使内核取消阻塞该信号。
- SA_NOCLDSTOP:使父进程在它的子进程暂停或继续运行时不会收到 SIGCHLD 信号。
- SA_NOCLDWAIT:使父进程在它的子进程退出时不会收到 SIGCHLD 信号,这时子进程如果退出也不会成为僵尸进程。
- SA_SIGINFO:使用 sa_sigaction 成员而不是 sa_handler 作为信号处理函数。
(5)第五个成员 sa_restorer:是一个已经废弃的数据域,无须使用。
为演示 sigaction() 的用法,此处引入以下代码:
//将2号信号捕捉,执行一次自定义动作后,恢复为原来的默认动作
//
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>//sigaction()的第二个参数和第三个参数需提前定义
struct sigaction act, oact; //自定义的处理函数
void handler(int signo)
{printf("get a signal:%d\n", signo);sigaction(2, &oact, NULL);
}int main()
{//初始化 sigaction() 的第二个参数和第三个参数memset(&act, 0, sizeof(act));memset(&oact, 0, sizeof(oact));//调整第二个参数里封装的字段的值act.sa_handler = handler; //注册自定义动作act.sa_flags = 0; //一般设置 sa_flags 为 0sigemptyset(&act.sa_mask);//初始化屏蔽字//传参调用 sigaction()sigaction(2, &act, &oact);//使进程一直运行下去,以便观察信号的处理while (1){printf("I am a process...\n");sleep(1);}return 0;
}
由演示图,先后两次按下了输入终端指令 ctrl + c 向进程发送 2 号信号,第一次执行了自定义动作,第二次执行了默认动作。
补、函数的可重入与不可重入
以一个进程进行链表的头插操作为例:
由图,main() 和 sighandler() 是两个使用不同堆栈空间的、独立的控制流程,它们之间不存在调用与被调用的关系。main() 中调用了 insert() 向一个全局链表中插入节点 node1,而sighandler() 中也调用了 insert() 向链表中插入节点 node2。
假设,进程在对链表插入节点时,收到了一个信号,那么对于这个待插入节点的链表——
进程最开始运行时,运行的是流程 main()。 main() 中调用了insert(),目的是将 node1 头插入链表,但头插操作分为两步,第一步是将插入节点的 next 指针与原本的头节点链接起来,第二步是将插入节点设为新的头节点,可能刚做完第一步的时候,就因收到信号而引发硬件中断,使进程切换到内核态;而进程再次回到用户态之前,检查到有信号待处理,于是切换到流程sighandler() 以获取信号的处理方法。然而,sighandler() 中也调用了 insert(),会将 node2 头插入链表中。
当 node2 头插操作的两步都做完之后,进程从 sighandler() 流程回到内核态,最终回到用户态,就继续进行 node1 的头插操作,将插入节点设为新的头节点。
虽然 main() 和sighandler() 先后向链表进行了头插操作,但最终只有 node1 真正头插到了链表中,而 node2 就此丢失,可能引起内存泄漏。
【附】过程汇总图
像上文 insert() 这样,一个函数被不同的控制流调用,发生函数在某一次调用还未返回时,就再次被调用和进入,这种现象称之为重入。
insert() 访问的是一个全局链表,可能会因重入而造成数据的错乱。像这样,一个函数如果会访问全局变量或其他域的变量,就可能存在造成错乱的隐患,则称这个函数为不可重入函数。
【Tips】一个函数只要符合以下条件之一,就是不可重入函数:
- 调用了malloc()或free()(因为malloc也是用全局链表来管理堆的)。
- 调用了标准I/O库函数(因为标准I/O库的很多实现都以不可重入的方式使用全局数据结构)。
而如果一个函数只访问自己的局部变量或参数,就不会造成错乱,则称这个函数为可重入函数。
补、C的关键字 volatile
volatile是C语言的一个关键字,其作用是保持内存的可见性。
为方便理解 volatile 的使用情景,此处引入以下代码:
//对2号信号进行了捕捉,当该进程收到2号信号时会将全局变量flag由0置1
//在进程收到2号信号之前,会一直处于死循环,直到收到2号信号才能正常退出
#include <stdio.h>
#include <signal.h>int flag = 0;void handler(int signo)
{printf("get a signal:%d\n", signo);flag = 1;
}
int main()
{signal(2, handler);while (!flag);printf("Proc Normal Quit!\n");return 0;
}
由演示图,在进程收到2号信号之前,一直处于死循环,直到收到2号信号才正常退出。可实际并非如此简单。
以上代码中,main() 和 handler() 其实是两个独立的执行流,而 while 循环是在 main() 中的,所以在编译器编译时只能检测到 main() 中在使用对 flag 变量。但 flag 的修改是在 handler() 中完成的,编译器检测不到,只能认为在 main() 中不存在对 flag 变量的修改操作,此时如果编译器的优化级别较高,flag 就很可能从内存转存到寄存器里。如此一来,编译器只检测寄存器里 flag 的值,而 handler() 只是将内存中 flag 的值修改了,那么,就算进程收到了 2 号信号,也不会跳出死循环并终止了。
gcc 编译器携带参数 -O3 时,会调至最高的优化级别,此时再运行该代码,就算进程收到了2号信号也不会终止。
面对这种情况,volatile 关键字就派上用场了。
volatile 关键字修饰变量,可以使其保持内存的可见性。volatile 对 flag 变量进行修饰,相当于特别告知编译器,对 flag 变量的任何操作都必须真实地在内存中进行。
此时,就算编译器的优化级别再高,进程只要收到2号信号且 flag 的值被置为1,main() 执行流也能够检测到 handler() 执行流对内存中 flag 的修改,进而使进程跳出死循环并终止。
#include <stdio.h>
#include <signal.h>volatile int flag = 0; //保持内存的可见性void handler(int signo)
{printf("get a signal:%d\n", signo);flag = 1;
}
int main()
{signal(2, handler);while (!flag);printf("Proc Normal Quit!\n");return 0;
}
补、17号信号 SIGCHLD
为了避免出现僵尸进程而导致资源泄漏,父进程需要用到系统调用 wait() 或 waitpid() 来等待子进程结束。
父进程可以选择阻塞式等待的方式来等待子进程结束,也可以选择非阻塞轮询的方式来等待子进程结束。
- 阻塞式等待,父进程处于阻塞状态,除了干等子进程退出,其余什么也做不了。
- 非阻塞轮询,父进程一边做自己的事一边检查子进程的情况,程序实现比较复杂。
子进程在终止时,会给父进程发送 SIGCHLD 信号。
SIGCHLD 信号是17号信号,它的信号处理默认是忽略动作。如果父进程希望被告知其子进程的这种状态改变,也可以自定义 SIGCHLD信号的处理动作,具体方式是,在信号处理函数中调用 wait() 或 waitpid() 清理子进程的退出信息,这样,父进程就只需专心处理自己的工作,不必关心子进程了,子进程终止时会自动通知父进程,父进程即可。
以下面的代码为例:
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
#include <sys/wait.h>void handler(int signo)
{printf("get a signal: %d\n", signo);int ret = 0;while ((ret = waitpid(-1, NULL, WNOHANG)) > 0){printf("wait child %d success\n", ret);}//【ps】//1.//SIGCHLD是一个普通信号,记录该信号的 pending 位只有一个,//如果同一时刻有多个子进程同时退出,那么在handler()当中实际上只清理了一个子进程,//因此在使用waitpid()清理子进程时,需要使用while不断进行清理。//2.//使用waitpid()时,需设置第三个参数为 WNOHANG(非阻塞轮询),//否则,当所有子进程清理完毕时,while循环中会再次调用waitpid(),从而在这里阻塞住。
}
int main()
{signal(SIGCHLD, handler);if (fork() == 0){//childprintf("child is running, begin dead: %d\n", getpid());sleep(3);exit(1);}//fatherwhile (1);return 0;
}
如果父进程不想等待子进程,但想让子进程退出之后,自动释放僵尸子进程,该怎么做呢?
父进程可以选择调用 signal() 或 sigaction() 将 SIGCHLD 信号的处理动作设置为 SIG_IGN(忽略),如此一来,由 fork() 创建的子进程在终止时,就会被自动清理掉,不会产生僵尸进程,也不会让父进程收到退出信息。
以下面的代码为例:
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>int main()
{signal(SIGCHLD, SIG_IGN);//将 SIGCHLD 信号的处理动作设置为 SIG_IGN,//让子进程退出之后,自动释放僵尸子进程,全程不通知父进程//注:此方法仅在 Linux 中可用!if (fork() == 0){//子进程部分printf("child is running, child dead: %d\n", getpid());sleep(3);exit(1);}//父进程部分while (1);return 0;
}
//注:
//通过 signal() 或 sigaction() 自定义的忽略动作,
//和系统默认的忽略动作,通常是没有区别的,
//上文中的情况其实是一个特例。