一、信号的基础知识
1.初步认识信号
2.操作系统中的信号
3.信号处理方式的注册
二、信号的产生
1.终端热键
2.系统调用
(1)kill函数
(2)raise函数
(3)abort函数
3.硬件
(1)除零错误
(2)空指针解引用
4.软件
(1)匿名管道
(2)闹钟
三、核心转储
四、信号保存
1.三大概念
2.信号集操作
(1)什么是信号集操作
(2)sigset_t类型
(3)具体使用
五、信号处理
1.内核态和用户态的概念
2.信号处理的时机
3.信号处理的细节理解
4.系统调用sigaction()
(1)使用
(2)特点
六、不可重入函数
七、volatile关键字
八、SIGCHLD信号(了解)
在生活中,像发令枪的枪声,闹钟的铃声,红绿灯等等,这些都是信号。
但信号必须是动态的,像路标这样一成不变的标志就不能称之为信号。
我们每个人可以识别街道上的红绿灯,而所谓的识别应包括认知和行为产生两部分,二者缺一不可。
我们一看到红绿灯就知道它是指挥交通的,表明我们能认出红绿灯,这就是认知。
而看到红绿灯,脑子里也能反应出红灯行,绿灯停的规矩,也就是知道应该干什么。
在讲信号概念前需要这几个广泛共识,我们在看到十字路口的信号灯时的以下行为都是合理的:
现在将这些迁移到进程中也是一样:
我们学习信号的顺序即是它的整个生命周期,分为信号产生,信号保存,信号处理三部分。
在Linux中输入kill -l可以查看可以被进程识别的信号。
其中编号1-31为普通信号,编号34-64为实时信号。因为32和33号信号不存在,所以共有62个信号。我们只学习普通信号,对实时信号暂不做研究。
由于信号是由宏定义的,所以在使用信号时既可以用信号名,又可以用信号编号。
既然信号是发送给进程的,而进程是通过其PCB管理的,所以不难推断出被接收的信号就放在维护进程的task_struct结构体中。
根据二进制的思想,PCB中的31个信号都以0和1表示收到与否。设置31个变量存放信号或者使用数组都太浪费空间了,而根据之前学过的位图,31个信号正好可以放在一个32位的整形变量中,每个比特位的偏移量表示号码,0或1代表一个信号是否收到。事实上操作系统也确实是按这样的思想做的。
因为操作系统不相信任何人的特质,所以PCB的修改者必定是操作系统。那我们不难推断出,无论哪个信号,最后都是由操作系统发生给进程的。而发送的本质就是在修改task_struct中保存信号变量的对应比特位,也就是修改PCB中的信号位图。
因为信号必须由操作系统传递的特质,所以我们之前才能使用过的这些信号:
所谓注册就是告诉操作系统某个进程接收到某个信号后的处理方式。其中我们第一个要学习的就是signal系统调用。
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
头文件:signal.h
功能:自定义信号的处理方式。
参数:int signal是要注册的信号编号,sighandler_t handler是自定义处理方式的函数指针。
我们可以将信号的处理方式写成一个返回类型为void,参数为int的函数。然后该函数的指针(函数名就是函数指针)传递给signal,此时当进程接收到指定的信号编号时,就会执行我们定义的函数。
下面是一个测试代码:
#include
#include
#include
void handler(int signum)
{
std::cout << "进程收到信号,编号为" << signum << std::endl;
}
int main()
{
signal(2, handler);
int i = 0;
while(1)
{
std::cout << "我是一个进程,pid为:" << getpid() << std::endl;
sleep(1);
}
return 0;
}
我们之前也学过在Xshell下通过Ctrl+C可以终止前端进程,其实这就是通过给进程发送信号2实现的。(大部分信号的默认处理方式都是终止进程,只是细节有一定差距)所以我们此时改变2信号的处理代码,进程运行起来。
你按Ctrl+C只会使用你之前设置过的代码,程序不会退出。
2号信号SIGINT的默认处理方式就是结束进程,而我们使用了自定义的处理方式,所以进程在收到2号进程后只打印显示收到信号。
既然我们可以对信号进行自定义,如果我们对31个信号都自定义处理,好像就可以创造一个关不掉的进程了?我们不妨试试看。
#include
#include
#include
#include
using namespace std;
void handler(int signum)
{
std::cout << "进程收到信号,编号为" << signum << std::endl;
}
int main()
{
vector v;
for(int i = 1; i<=31; ++i)
{
v.push_back(i);
}
for(int i = 0; i
编译运行后,我们试着给进程发送了很多信号,它们使用的大多是自定义处理方式。但当我们发送9号信号时进程还是会退出。
操作系统又不傻,你能想到的它早就想到了。为了避免关不掉的进程产生,操作系统设置部分信号,不能被注册自定义处理,比如9号信号,它依旧可以直接关闭进程。
像Xshell这样的终端常设置一些热键用于给进程发送相应信号,比如ctrl+c可以发送2号信号SIGINT。还有一个常用热键ctrl+\,用于发送3号信号SIGQUIT。
我们可以使用循环将2号和3号信号的处理方式全部自定义为打印收到的信号,这样就能清晰看到传递的信号编号了。
#include
#include
#include
#include
using namespace std;
void handler(int signum)
{
std::cout << "进程收到信号,编号为" << signum << std::endl;
}
int main()
{
vector v = {2,3};
for(int i = 0; i
测试结果
int kill(pid_t pid, int sig);
头文件:sys/types.h、signal.h
功能:给一个指定的进程发送一个信号。
参数:pid_t pid是信号发送的目标进程,int sig是发送信号的编号
返回值:成功发送返回0,失败返回-1
下面需要使用我们之前就讲过的main函数参数,那时我们主要讲了envp而忽略了前两个参数。那么,让我们先复习一下。
发送信号的进程,send_sig.cc编译为ss。
#include
#include
#include
#include
#include
#include
#include
using namespace std;
void Usage(const string& proc)
{
cout << "Usage:" << proc << "signo pid:" << endl;
}
int main(int argc, char* argv[])
{
//我们每次都以固定方式发送信号
//指令 ./send_sig 信号编号 接收信号进程pid
//示例 ./send_sig 2 21224
//对应 argv[0] argv[1] argv[2]
if(argc != 3)
{
Usage(argv[0]);
exit(1);
}
//atoi可以把字符串转换成整型
int signo = atoi(argv[1]);
pid_t id = atoi(argv[2]);
int ret = kill(id, signo);
if(ret != 0)
{
cerr << "error code:" << errno << strerror(errno) <
接收信号的进程,get_sig.cc编译为gs
#include
#include
using namespace std;
int main()
{
while(1)
{
cout << "我是一个进程,pid为:" << getpid() << endl;
sleep(1);
}
return 0;
}
右侧终端打开接收信号的进程,左侧终端加上参数运行发送信号的进程,可以很明显看到不同信号对进程的处理,大部分都是进程退出。
int raise(int sig);
头文件:signal.h
功能:给进程自己发送一个信号。
参数:int sig是发送信号的编号
返回值:成功发送返回0,失败返回-1
测试代码:
#include
#include
#include
#include
#include
using namespace std;
int main(int argc, char* argv[])
{
int cnt = 0;
int signo = atoi(argv[1]);
while(1)
{
printf("cnt:%d,pid:%d\n" ,++cnt, getpid());
if(cnt == 5)
{
raise(signo);
}
sleep(1);
}
return 0;
}
在cnt == 5时进程的确给自己发送了对应信号。
void abort(void);
头文件:stdlib.h
功能:给自己发送6号信号SIGABRT
测试代码:
#include
#include
#include
#include
#include
using namespace std;
int main(int argc, char* argv[])
{
int cnt = 0;
while(1)
{
printf("cnt:%d,pid:%d\n" ,++cnt, getpid());
if(cnt == 5)
{
abort();
}
sleep(1);
}
return 0;
}
测试结果:
在底层raise和abort函数其实底层都是kill实现的,它们分别是kill(getpid(),signo)和kill(getpid(),SIGABRT)
在除法运算中,除数不能为0,而在计算机运算中除零会导致硬件抛异常。
我们编写一个程序看看(该程序可编译通过,但编译器会报警告)。
#include
#include
using namespace std;
int main(int argc, char* argv[])
{
int cnt = 0;
while(1)
{
cout << "进程运行中..." << endl;
if(cnt == 3)
{
int resualt = 10;
resualt /= 0;
}
sleep(1);
++cnt;
}
return 0;
}
运行结果如下:
在运行到除零语句的时候进程接收到了8号SIGFPE信号(Floating point exception由8号信号产生),进程终止运行。
那么这个过程在底层时如何实现的呢?
首先,CPU内有很多寄存器,例如之前学过的eax,ebx,eip等等。程序运行时,内存中的数据会被拷贝到寄存器中通过CPU运算,如果有必要运算的结果还会被覆盖到内存中。
在这些寄存器中有一个状态寄存器,一旦CPU在运算时发现了除0操作,就将状态寄存器的溢出标志位置由0变为1,此时硬件产生了异常。
因为操作系统是一个通过实时获取到的信息进行软硬件资源管理的软件,所以操作系统第一时间发现异常并给对应进程抛8号异常,进程也会终止。
用signal自定义8号信号的处理。
#include
#include
#include
using namespace std;
void handler(int signum)
{
cout << "接收到" << signum << "号信号" << endl;
}
int main(int argc, char* argv[])
{
signal(8, handler);
int cnt = 0;
while(1)
{
cout << "进程运行中..." << endl;
if(cnt == 3)
{
int resualt = 10;
resualt /= 0;
}
sleep(1);
++cnt;
}
return 0;
}
在进程运行起来后,8号信号被操作系统不停地发送给进程,8号信号也不断被处理,发送其他信号进程退出。
那这到底怎么回事呢?
我们学过每个进程使用CPU都有固定的时间片,达到时间该进程就会被切走。而CPU中又只有一份寄存器,寄存器中的内容是当前进程的上下文。
进程被切换下CPU的时候,上下文数据也会被带走,而当该进程再次被CPU处理时上下文数据又会被恢复到寄存器内,就这样状态寄存器数据被不断被保存和恢复。
虽然PCB中的信号标志位经过处理后会变为0,但是此时代码的上下文数据就是给该进程发送8号信号,寄存器中的溢出标志位的那个1还会被恢复。
所以每一次轮转,操作系统也都能识别并且发送SIGFPE信号到进程。这样就导致了上面不停调用自定义处理函数,不停打印接收到的信号编号。
示例代码
#include
#include
using namespace std;
int main(int argc, char* argv[])
{
int cnt = 0;
while(1)
{
cout << "进程运行中..." << endl;
if(cnt == 3)
{
int* p = NULL;
*p = 1;
}
sleep(1);
++cnt;
}
return 0;
}
运行结果
对空指针的解引用操作本质是获取编号为0的内存块的内容,而0地址的内容属于内核空间,是不允许用户访问的。空指针解引用终止进程同样也是因为接收到了信号,而这次接收到的是11号信号SIGSEGV。
在理解上述过程的产生前,我们需要认识一下MMU(内存管理单元)
MMU集成在CPU中,它负责管理和维护进程地址空间和物理内存的映射关系。页表只是一种数据结构,而真正通过地址映射完成数据查找等操作的是MMU。
所以,我们在地址空间里谈论的页表更准确地说应当是页表加上MMU。
当我们对空指针解引用时,MMU会拒绝用户不正当的操作,同时也会改变异常标识。操作系统检测到MMU产生的异常后就会给对应进程发送11号信号。进程接收到11号信号以后,默认处理结束进程。
如果我们对11号信号注册自定义处理方式,它也会不断打印接收到的信号编号,进程也不会结束。
我们之前在学习匿名管道时说过,如果管道的读端关闭,那么写端也会随之关闭。而它关闭写端的原理就是给写端进程发送13号信号
我们编写一段代码让父进程给子进程发消息,在cnt==5时关闭读端,注册13号信号SIGPIPE为自定义处理。
#include
#include
#include
#include
#include
#include
#include
#include
using namespace std;
void handler(int signum)
{
printf("父进程接收到信号%d\n", signum);
}
int main()
{
int fds[2];
int ret = pipe(fds);
assert(ret != -1);
pid_t id = fork();
int cnt = 0;
if(id == 0)
{
//子进程负责读
close(fds[1]);
while(1)
{
char message[1024];
ssize_t ret = read(fds[0], message, sizeof(message)-1);
printf("我是子进程,pid为%d,接收到的信息为: %s\n", getpid(), message);
if(++cnt == 5)
{
close(fds[0]);
printf("关闭读端\n");
}
sleep(1);
}
}
//父进程负责写
close(fds[0]);
signal(13, handler);
while(1)
{
char buffer[1024] = "你是子进程。";
ssize_t n = write(fds[1], buffer, sizeof(buffer)-1);
printf("我是父进程,我的pid为%d\n", getpid());
sleep(1);
}
return 0;
}
运行结果可以很明显看到父进程的确接收到了13号信号。在这里读端是否关闭可看作软件中的条件,条件达成即发送信号。
闹钟就是计时器,而在系统调用确实有一个这样的函数。
unsigned int alarm(unsigned int seconds);
头文件:unistd.h
功能:从执行至该函数开始计时seconds秒,时间到后给本进程发送14号信号SIGALRM
参数:unsigned int seconds需要计时的秒数
返回值:距离该时间的剩余秒数。
我们写一段代码查看一秒钟内循环代码对0不断加一可以加多少次
#include
#include
#include
#include
#include
using namespace std;
int cnt = 0;
void handler(int signum)
{
printf("接收到%d号信号,cnt = %d\n", signum, cnt);
exit(0);
}
int main()
{
signal(14, handler);
alarm(1);
while(1)
{
++cnt;
}
return 0;
}
运行1秒接收到了14号信号,cnt也打印出来了,可以看到计算机的运行速度是相当快的。
我在自定义处理的最后用exit(0)关闭了进程,如果我们不关闭进程,进程就不会退出,还是会继续不断对cnt加一。
要是我们想每隔一秒打印一次cnt,只需在自定义处理最后再订上一次闹钟就可以了。
#include
#include
#include
#include
#include
using namespace std;
int cnt = 0;
void handler(int signum)
{
printf("接收到%d号信号,cnt = %d\n", signum, cnt);
alarm(1);
}
int main()
{
signal(14, handler);
alarm(1);
while(1)
{
++cnt;
}
return 0;
}
执行结果如下,对于软件而言达成条件就会发送信号,这里也是一样的。
计时器的系统调用也是通过类实现的,我们只在这里讲一下设计思路,具体实现就不讲了,可以查阅相关资料。
首先,一个系统中会有很多进程,而这些进程都可以创建闹钟,所以操作系统中必定会有很多闹钟对象。
然后,只要有对象就必须通过先描述后组织的方式实现类,比如说:
struct alarm
{
unit64_t when;//计时时长
int type;//闹钟的类型,比如是一次性还是周期性
task_struct* p;//所属进程的PCB地址
struct alarm* next;//下一个闹钟的地址,需要根据当前数据结构而定
//其他省略的属性
}
这么多对象就必须通过一定的数据结构进行管理,而在Linux中使用了这样的方式:
闹钟的管理使用优先级队列(本质为堆), 系统将计时时间最短的闹钟放在头部,时间长的放在尾部(本质就是小根堆)。所以,操作系统只需要检测队首(根节点)的时间是否到就可以控制所有的闹钟。时间达到就向队首进程发送14号SIGALRM信号并且将闹钟移出队列,并继续检测下一个成为队首的闹钟。
既然大部分信号对进程的处理都是终止进程,那么直接把这么多合并不就好了。
如果只看结果的话确实没问题,但进程一旦发生错误就必定会收到信号,进程错误的原因包含在发送的信号内,所以产生信号的原因更加重要,不同的信号可能处理相同但错因完全不同。
输入man 7 signal命令就可以查看七号手册中信号的信息,通过Enter键向下查看我们就能找到信号对应的名称、编号、默认处理方式及信号产生原因等信息。
我们以2号和3号信号举例:
2号信号叫做SIGINT,默认处理方式是Term。3号信号叫做SIGQUIT,默认处理方式是Core,Core和Term都可以终止进程,但Term是直接终止进程,而Core是会保存一些信息后再终止进程。
为了便于认识这二者的区别,我举一个例子,当数组严重越界时,操作系统会给进程发11号信号SIGSEGV,11号信号的默认处理就是Core。我们写一段严重越界的代码。
#include
int main()
{
while(1)
{
int arr[10];
arr[10000] = 1;
}
return 0;
}
虽然进程会以Core方式退出,但是云服务器默认关闭了file core选项,直接运行是看不到现象的。
我们输入ulimit -a可以查看云服务器选项,其中core file size为0就表示不生成core信息的文件。除了core file,这里还有一些选项。比如,能够打开的最多文件个数open file是100001个,管道文件可写入的最大值pipe size为8×512 = 4096字节,以及栈的大小stack size为8192×1024字节等等信息。
输入ulimit -c 1024将core文件的大小改为1024个数据块就相当于打开了该选项。此时我们再次运行程序就能发现当前目录下多出一个core.6985文件,其中6985是进程的pid。在进程出现错误时,内存中的有效数据会储存到文件内,这个过程就叫做核心转储,这个文件叫核心转储文件。
用gdb打开生成的可执行程序test并输入core-file 核心转储文件,此时就能定位到出错的地方在哪里了。(我这里不知道为什么只显示错误在main函数,其实是可以定位到某一行的)
核心转储Core相比Term方式能够让我们快速定位出现异常的位置。
信号保存中有三个新的概念:
既然说到了这些概念,那么它们在内核中也一定会有对应的数据结构。
PCB中关于三者的数据结构有pending位图、block位图和handler表。
当我们使用signal注册自定义处理方式时,操作系统会将我们定义的函数的指针放在handler表的对应下标位上,在信号递达后就会调用该函数。如果是默认处理方式,就直接调用handler默认的初始函数指针所对应的函数。
信号产生并发送后,操作系统就会修改pending位图,表示信号被接收并处于未决状态。
然后操作系统先检测block位图,如果对应信号的比特位为1,则说明该信号被阻塞,就不再去检测pending位图。比特位为0,信号没有被阻塞,则会继续检测pending位图。如果相应的位置为1,则会在适当时刻调用handler表中的处理函数。
根据上面的过程我们得到的结论如下:
对于block位图和pending位图都是操作系统修改的,所以操作系统当然提供了一些相关的系统调用,这些接口的集合称为信号集操作,使用它们需要引用signal.h头文件。
我们需要使用的是以下7个:sigemptyset、sigfillset、sigaddset、sigdelset、sigismember、sigprocmask、sigpending。
在每一个函数中都出现了sigset_t,它是信号集变量的类型,用户需要建立一个信号集变量,然后对这个变量进行预设置,最后交给操作系统。操作系统会通过解读该变量去修改pending位图或block位图。
这个变量不单单是一个32位的整形变量,它的结构和内核是对应的。所以直接用printf打印sigset_t变量的结果是没有意义的。不过我们用户是不必关心有效改变变量的具体操作的,我们只需要用上面的信号集操作函数处理sigset_t变量就可以了。
int sigemptyset(sigset_t *set);
头文件:signal.h
功能:使所有信号对应的比特位清零,表示该信号集不包含任何有效信号。
参数:sigset_t *set是某个信号集变量的指针。
返回值:成功返回0,失败返回-1
int sigfillset(sigset_t *set);
头文件:signal.h
功能:使所有信号对应的比特位变为1,表示该信号集的有效信号包括系统支持的所有信号。
参数:sigset_t *set是某个信号集变量的指针。
返回值:成功返回0,失败返回-1
int sigaddset(sigset_t *set, int signo);
头文件:signal.h
功能:使signo信号对应的比特位变为1,表示该信号集不包含任何有效信号。
参数:sigset_t *set是某个信号集变量的指针,int signo是需要被信号编号.
返回值:成功返回0,失败返回-1
int sigdelset(sigset_t *set, int signo);
头文件:signal.h
功能:使指定信号所对应的比特位变为0,表示该信号集中对应信号无效。
参数:sigset_t *set是某个信号集变量的指针,int signo是需要被信号编号
返回值:成功返回0,失败返回-1
int sigismember(const sigset_t *set, int signo);
头文件:signal.h
功能:判断指定信号所对应的比特位是否为1,返回类型是bool类型。
参数:sigset_t *set是某个信号集变量的指针,int signo是需要被信号编号。
返回值:成功返回0,失败返回-1
#include
#include
using namespace std;
int main()
{
sigset_t block;
sigset_t pending;//建立两个信号集变量
sigemptyset(&block);
sigemptyset(&pending);//将两个变量初始化
sigaddset(&block, 2);
sigaddset(&pending, 2);//将2号信号的比特位变为1
sigfillset(&block);
sigfillset(&pending);//将所有信号的比特位变为1
sigdelset(&block, 2);
sigdelset(&pending, 2);//将2号信号的比特位变为0
bool ret1 = sigismember(&block, 2);
bool ret2 = sigismember(&pending, 1);//2号信号比特位是否为1
cout << ret1 << endl;
cout << ret2 << endl;
return 0;
}
最后ret1打印为0,2号信号在block中比特位为0。ret2打印为1,2号信号在block中比特位为1。此时我们知道了信号集变量的修改方式,此时就需要使用系统调用发送该变量到操作系统来改变pending或block位图,这才是最终目的。
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
头文件:signal.h
功能:修改内核数据结构中的block位图同时获取原来的block位图。
参数:int how指修改方式,共有三个选项:SIG_BLOCK,在block原有位图基础上添加sigset_t变量中设置的比特位;SIG_UNBLICK,在bolck原有位图解除上删除sigset_t变量中设置的比特位;SIG_SETMASK,用sigset_t变量覆盖原有的block位图。一般使用最后一个。
const sigset_t *set是我们设置好的sigset_t变量。sigset_t *oset是一个输出型参数,将原本block位图输出到这个sigset_t变量中。最终const sigset_t *set会被发送给操作系统并设置进block位图内,而操作系统会将原来的sigset_t *oset会
返回值:设置成功返回0,失败返回-1。
int sigpending(sigset_t *set);
头文件:signal.h
功能:获取内核数据结构中的pending位图。
参数:sigset_t* setset是一个输出型参数,返回从内核中获取的pending位图情况保存到该变量中。
返回值:成功返回0,失败返回-1。
我们写一段代码进行观察:
#include
#include
#include
using namespace std;
void handler(int signum)
{
cout << "接收到" << signum << "号信号" << endl;
}
void show_pending(const sigset_t& pending)
{
for(int signum = 31; signum>=1; --signum)
{
if(sigismember(&pending, signum))
{
cout << "1";
}
else
{
cout << "0";
}
}
cout << endl;
}
int arr[] = {2,3};
int main()
{
sigset_t block;
sigset_t oblock;
//初始化信号集变量
sigemptyset(&block);
sigemptyset(&oblock);
//将所有数组内的信号设置进信号集变量内
for(int i = 0; i
当我向程序发送2号和三号信号时,pending位图的第二位和第三位都变为1。正是因为两个信号在block位图内标志位变为1,它们也就无法被抵达。两个信号都处于未决的状态当然pending位图对应位也变为1。
如果我们修改一下代码使两个信号在10秒后解蔽,15秒后退出。
#include
#include
#include
#include
using namespace std;
void handler(int signum)
{
cout << "接收到" << signum << "号信号" << endl;
}
void show_pending(const sigset_t& pending)
{
for(int signum = 31; signum>=1; --signum)
{
if(sigismember(&pending, signum))
{
cout << "1";
}
else
{
cout << "0";
}
}
cout << endl;
}
int arr[] = {2,3};
int main()
{
sigset_t block;
sigset_t oblock;
//初始化信号集变量
sigemptyset(&block);
sigemptyset(&oblock);
//将所有数组内的信号设置进信号集变量内
for(int i = 0; i
我们发现在信号解蔽的一瞬间信号便被递达,pending位图也回到了全零状态。
现在我们知道了进程在接收到信号后并不是立刻处理的,而是会选择适当时刻处理。有这样一句话大家暂时不能理解:从内核态返回到用户态的时候,信号会被递达。
想要理解这句话就需要知晓两个概念:进程的内核态和用户态。
由于操作系统不信任任何人的特质,所以用户只有通过系统调用才能访问内核数据或者其管理的硬件资源。
虽然系统调用在我们写代码的时候是可以使用的,但是这些系统调用的实际的执行者都是操作系统。所以在进程执行不同的代码时,,其状态就被分为用户态和内核态。
在进程运行时,操作系统就需要知道当前进程的身份状态。CPU中有一套寄存器,其中有一个CR3寄存器就是专门用来表征当前进程的运行级别的。
操作系统是一个进行软硬件资源管理的软件,它很容易就可以获取到CPU中CR3寄存器中储存的是0还是3,从而判断当前是用户态还是内核态。
首先我们要知道,系统调用的执行者是操作系统,而不是用户。那么问题就出现了,程序是我写的,也是我运行的,怎么代码的执行者又有操作系统呢?
这里就需要重提之前的进程地址空间:
还能记起之前讲过的动态库吗?
位置无关码会被写入到可执行程序内,一旦执行到该位置,执行流就会跳转到共享区并找到已载入内存动态库,从而执行相应的方法。
系统调用和它是一样的,当执行到代码段中的系统调用时,就直接跳转到当前进程虚拟地址空间的的内核空间中,系统调用的具体实现都放在这1GB的内核空间中,然后就能根据内核级页表映射到载入内存的内核数据,从而执行系统调用。
由于只有操作系统能访问内核空间,所以系统调用的执行者必须是操作系统。
然而此时问题又出现了,为什么只有系统调用能访问这1GB的内核空间呢?
这是因为代码执行至系统调用时,CPU中的CR3寄存器从3变为0。进程的运行级别从用户态变成了内核态,相当于程序的执行者从用户变成了操作系统,此时就可以对这1GB的内核空间进行访问。
所以,系统调用前一部分是由用户在执行,其余部分由操作系执行。
此时再来理解"从内核态返回到用户态的时候,信号会被递达。",这句话的含义。
以我们最熟悉的系统调用为例,以黑色长线为界,上面是用户态,下面是内核态,黑色细线代表默认处理方式,红色细线代表自定义处理方式。
默认处理方式和自定义处理方式有一定的不同:
还需要我们知道的是,内核态与用户态的转换是有一定开销的,会减慢程序的运行速度。所以在我们以后编写的代码中应当尽量减少系统调用的使用以保证程序效率。
对信号的自定义处理过程可以看成一个无穷大符号加一条线,线的上边是用户态,下边是内核态。每经过一次黑线就会发生一次状态改变,一共改变四次。
上面这种方式是最复杂的,如果是默认处理或者忽略的处理方式,只需要转变两次。
进程切换为内核态直接在handler表中找函数处理(如果是忽略则直接将该信号pending位图比特位置为0),然后返回用户代码并转为用户态就可以了。
上面过程中还是存在问题,进程的内核态权限是高于用户态的,内核态进程可以访问地址空间的所有区域。那么在执行自定义处理方式的时候,为什么必须从内核态切换成用户态才去去执行用户定义的处理方式呢?
理论上保持内核态确实可以自定义处理,但是操作系统不相信任何人,如果自定义处理中有恶意代码,而进程执行者又是操作系统,就可能导致操作系统的崩溃。所以为了操作系统的安全,进程必须切换到用户态才能执行自定义处理方式。
最后还有个细节,由于自定义处理函数和main函数运行时使用不同的堆栈空间,也不存在调用和被调用的关系,所以二者是各自独立的控制流程。所以,在执行完系统调用后不需要恢复main函数的上下文数据就可以继续执行。这里有些类似于线程的概念,但是线程就要放到以后去讲了。
int sigaction(int signum, const struct sigaction* act, struct sigaction* oldact);
头文件:signal.h
功能:注册更改信号自定义处理方式。
参数:int signum表示信号的编号, const struct sigaction* act是一个保存属性的结构体(具体定义在下面),其中sa_handler是保存自定义处理函数的函数指针,整形变量sa_flags设为0就可以了,其他暂时不用管。struct sigaction* oldact是输出型参数,会将原本的处理方式的属性放入这个结构体变量中。
返回值:成功返回0,失败返回-1。
struct sigaction
{
void (*sa_handler)(int);
void (*sa_sigaction)(int, siginfo_t*, void*);
sigset_t sa_mask;
int sa_flags;
};
我们不妨使用一下这个系统调用并观察一个现象:
将自定义处理函数(打印接收到的信号并计时10秒)赋值到结构体变量act中的sa_handler,使用系统调用sigaction注册自定义处理方式。
#include
#include
#include
#include
#include
using namespace std;
//倒计时
void count(int cnt)
{
while(cnt)
{
printf("counting:%2d\r",cnt);
fflush(stdout);
cnt--;
sleep(1);
}
printf("\n");
}
//自定义处理
void handler(int signum)
{
cout << "接收到" << signum << "号信号" << endl;
count(10);
}
int main()
{
struct sigaction act;
struct sigaction oldact;
act.sa_handler = handler;
act.sa_flags = 0;
sigaction(2, &act, &oldact);
while(1)
sleep(1);
return 0;
}
运行结果:
我在第一次发送2号信号后,又在这个信号被处理的10s内多次发送了二号信号。但最后只有两个2号信号被处理了,这是为什么呢?
在2号信号被进程捕获时,操作系统会自动将block位图2号信号的比特位变为1,然后将pending表对应比特位变为0,最后进行递达。而当递达第一个信号的时候,同类型(这里表现为同编号)的信号就无法被递达。我们在2号信号递达中继续发送2号信号,对应pending位图的比特位确实会变为1,你发送的每一个信号都只会让那个比特位变为1。所以当第一个2号信号处理完毕以后,解除对2号信号的屏蔽后,因为pending位图对应位为1,第二个2号信号就会被递达,pending位图对应比特位变为0。
所以我们能看到的现象就是除了两个2号信号能递达,其余的2号信号都被舍弃了。
那又是为何上述情况会发生呢?
那是因为,进程对同类型信号的处理是串行的处理,不允许递归,同类型的多个信号同时产生,结构上最多可以处理两个。
sigaction和signal看上去是重复的,事实上是这样吗?
在上面的代码中,我使用3号信号结束进程。那如果我想要在2号信号被捕获后,2号信号被屏蔽的同时也将3号信号屏蔽呢?
此时就需要设置结构体变量act中的sa_mask成员。上面的代码不动,只是在act结构体变量中sa_mask成员中增加了3号信号,并且给3号信号注册了自定义处理方式。
#include
#include
#include
#include
#include
using namespace std;
//倒计时
void count(int cnt)
{
while(cnt)
{
printf("counting:%2d\r",cnt);
fflush(stdout);
cnt--;
sleep(1);
}
printf("\n");
}
//自定义处理
void handler(int signum)
{
cout << "接收到" << signum << "号信号" << endl;
count(10);
}
int main()
{
signal(3, handler);
struct sigaction act;
struct sigaction oldact;
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, 3);//在sa_mask内添加
act.sa_handler = handler;
act.sa_flags = 0;
sigaction(2, &act, &oldact);
while(1)
sleep(1);
return 0;
}
先发送2号信号,在处理的10s内,发送多个2号和3号信号。
所以我们就能看到2号信号被递达两次,而3号信号被递达一次。
以我们之前学过的链表为例,如果又许多个进程同时在一个链表中插入节点,那会不会出现冲突呢?
观察下面这张图:
在main函数中,使用insert向链表中插入一个节点node1,在执行insert的时,刚让头节点指向node1以后(如上图序号1),捕获到了信号,进入到了该信号的自定义处理方式中。
在自定义处理方式中,同样调用了insert函数向链表中插入一个节点node2,此时完整的执行了insert函数,但是在头节点和最开始那个节点之间同时有了node1和node2(如上图序号2和3)。
当第二次调用insert中让头节点指向node2后(如上图序号3),流程返回到信号的自定义处理函数中,然后再返回到第一次调用insert处,头节点指向node1(如上图序号4)。
最后可以看到,该链表是丢了一个节点的。
所以那到底什么是可重入函数和不可重入函数呢?
符合以下条件之一的就是不可重入函数:
定义一个全局变量quit,当quit为0的时,while进行死循环。注册2号信号的处理方式自定义为将quit改为1。quit变成1的时候,跳出死循环,进程正常退出并打印信息。
#include
#include
using namespace std;
int quit = 0;
void handler(int signum)
{
cout << "捕捉到" << signum << "号信号" << endl;
cout << "quit:" << quit;
quit = 1;
cout << "->" << quit << endl;
}
int main()
{
signal(2, handler);
while(!quit){}
cout << "quit变为1,进程正常退出" << endl;
return 0;
}
我们正常编译运行发信号,一切都不会有问题
我们之前讲过,编译器在编译代码时会进行一定的优化,最简单的就是debug运行的程序assert是能够起作用的,而relase版本中的assert就会失效。
g++编译器支持不同的优化级别,不同的级别对优化的激进程度不同,我们可以通过指令使g++以某个优化级别编译代码。
比如我们以3级优化级别编译代码(三级编译的选项是-O3),此时现象就会不同:
代码没变,但发送2号信号后,quit确实从0变成了1,但是进程还在运行。再次发送2号信号,quit又从1变成1,进程依旧不终止。
虽然quit确实被改成了1,但是while(!quit){}还是死循环。因为我们加了-O3选项,所以原因肯定是优化。
那优化后的程序又是如何运行的呢?
quit在物理内存中一定有一块空间,最开始是0。
当CPU处理到while(!quit){}指令时,它会从内存将quit的数据放到寄存器中。
当我们发送信号,quit被修改后,内存中quit的数据从0变成1。
正常编译时,CPU每完成一次while循环时,都要从内存中拿取quit的数据并更新到寄存器内,再去判断while循环。所以当quit从0变成1后,CPU中寄存器的数据也会从0更新为1,此时while循环也会停下来。
但是优化编译后,在main函数代码中,quit只被读取不被修改,所以编译器认为这个数据在main执行流内不会变化。编译器在第一次将数据从内存读取到寄存器中便会不再从内存读取了,即使你改变了内存中的quit,寄存器中的数据也不会更新。所以每次执行while时候都是使用的寄存器中没有更新的quit值,所以循环始终不退出。
总之,出现上述现象的原因就是CPU判断循环时使用的quit值和内存中的quit不一样。所以,为了让CPU每次在使用该变量时都从物理内存中取数据更新至寄存器,可以使用volatile关键字来修饰这个quit变量。
可以看到,此时即使使用了三级优化,程序也可以正常退出。
我们知道父进程使用wait和waitpid可以回收僵尸子进程,父进程既可以阻塞等待,也可以非阻塞轮询等待,不断查询子进程是否退出。那父进程是怎么知道子进程退出了呢?
实际上,在子进程退出时,会给父进程发送SIGCHLD信号。
我们写一段代码,注册SIGCHLD信号为打印信号编号的自定义处理方式。父进程创建处子进程后,子进程在5次循环后退出,父进程在8次循环后退出。
#include
#include
#include
#include
#include
void handler(int signum)
{
printf("捕捉到信号%d,捕捉该信号的进程pid为:%d\n", signum, getpid());
}
int main()
{
signal(SIGCHLD, handler);
pid_t id = fork();
if(id < 0)
{
perror("进程创建失败\n");
return 1;
}
else if(id == 0)
{
int i = 1;
while(1)
{
printf("我是子进程,我的PID是:%d,%d\n", getpid(), i);
if(i == 5)
{
printf("子进程退出\n");
exit(0);
}
i++;
sleep(1);
}
}
int j = 1;
while(1)
{
printf("我是父进程,我的PID是:%d,%d\n", getpid(), j);
sleep(1);
if(j == 8)
exit(0);
j++;
}
return 0;
}
运行结果中确实发现子进程退出时,父进程捕捉到了17号信号。
那我们不妨试着写一段代码,实现用自定义17号信号的方式对优先退出的子进程进行回收。
#include
#include
#include
#include
#include
#include
void handler(int signum)
{
printf("捕捉到信号%d,捕捉该信号的进程pid为:%d\n", signum, getpid());
pid_t id;
while((id = waitpid(-1, NULL, WNOHANG)) > 0)
//waitpid参数为-1表示父进程会等待任意一个子进程
//id大于0则回收成功并打印信息,id小于等于0都表明没有可回收的子进程,退出循环
{
printf("等待子进程成功: %d\n", id);
}
printf("子进程退出%d\n", getpid());
}
int main()
{
signal(SIGCHLD, handler);
pid_t id = fork();
if(id < 0)
{
perror("进程创建失败\n");
return 1;
}
else if(id == 0)
{
int i = 1;
while(1)
{
printf("我是子进程,我的PID是:%d\n", getpid());
if(i == 5)
{
printf("子进程退出\n");
exit(1);
}
i++;
sleep(1);
}
}
int j = 1;
while(1)
{
printf("我是父进程,我的PID是:%d\n", getpid());
sleep(1);
}
return 0;
}
运行结果可以正常回收子进程。
大家还记得这张图吗?我们观察一下17号信号的默认处理。
SIGCHLD的信号编号是20,17,18,对应的处理方式是Ign(ignore),也就是忽略。
那我们何不使用默认处理先看看它的效果呢?
同样是上面的代码,但是我们注释掉signal函数运行。
在进程运行的过程中我们查询三次。第一次查询,父子进程正常运行时状态都为睡眠;第二次查询,子进程退出变为僵尸状态,但父进程收到信号后却并没有回收子进程信息;第三次查询,父进程退出,子进程父进程都已经被回收了。
那既然它的默认处理就是忽略,我们将17号信号自定义处理为一个什么都不做的函数能不能达到一样的效果呢?
#include
#include
#include
#include
#include
#include
void handler1(int signum)
{
printf("捕捉到信号%d,捕捉该信号的进程pid为:%d\n", signum, getpid());
pid_t id;
while((id = waitpid(-1, NULL, WNOHANG)) > 0)
{
printf("等待子进程成功: %d\n", id);
}
printf("子进程退出%d\n", getpid());
}
void handler2(int signum)
{}
int main()
{
//signal(SIGCHLD, handler1);
signal(SIGCHLD, handler2);
//signal(SIGCHLD, SIG_IGN);
pid_t id = fork();
if(id < 0)
{
perror("进程创建失败\n");
return 1;
}
else if(id == 0)
{
int i = 1;
while(1)
{
printf("我是子进程,我的PID是:%d\n", getpid());
if(i == 5)
{
printf("子进程退出\n");
exit(1);
}
i++;
sleep(1);
}
}
int j = 1;
while(1)
{
printf("我是父进程,我的PID是:%d\n", getpid());
sleep(1);
}
return 0;
}
我们稍微修改一下,自定义注册为handler2函数,此时我们可以观察到默认处理与自定义不干活的方式效果相同。最后的父子进程都是操作系统自己回收的。
SIG_IGN是Linux内部定义的忽略函数,如果我们使用signal(SIGCHLD, SIG_IGN);显式注册内置的忽略处理函数,此时父进程就可以在信号递达时回收子进程。
此时经过同样的三次查询就不会出现僵尸进程。
总之,虽然SIGCHLD默认的处理方式是忽略,但是默认的忽略并不会回收子进程,只有显式注册为内置的SIG_IGN忽略方式才会自动回收退出的子进程。
但这个现象只能在Linux中出现,在其他的unix操作系统中,对于默认忽略和显式定义内置忽略函数这两种方式,结果一般都是一致的。至于会不会产生僵尸进程,不同的操作系统的表现不同,还需要验证才能证明。