【Linux练习生】进程信号(深度理解)

简单理解信号

前面我们介绍的进程间通信,都是常规状态下的工作模式。对于异常情况下的工作模式,就需要用「信号」的方式来通知进程。

信号跟信号量虽然名字相似度 66.66%,但两者用途完全不一样,就好像 Java 和 JavaScript 的区别。

在 Linux 操作系统中, 为了响应各种各样的事件,提供了几十种信号,分别代表不同的意义。我们可以通过 kill -l 命令,查看所有的信号:

$ 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个,每个信号都有一个编号和一个宏定义名

运行在 shell 终端的进程,我们可以通过键盘输入某些组合键的时候,给进程发送信号。
例如:

  • Ctrl+C 产生 SIGINT 信号,表示终止该进程;
  • Ctrl+Z 产生 SIGTSTP 信号,表示停止该进程,但还未结束;

如果进程在后台运行,可以通过 kill 命令的方式给进程发送信号,但前提需要知道运行中的进程 PID 号,例如:

  • kill -9 1050 ,表示给 PID 为 1050 的进程发送 SIGKILL 信号,用来立即结束该进程。

为什么使用Ctrl+C后,该进程就终止了?

实际上当用户按Ctrl+C时,这个键盘输入会产生一个硬中断,被操作系统获取并解释成信号(Ctrl+C被解释成2号信号),然后操作系统将2号信号发送给目标前台进程,当前台进程收到2号信号后就会退出。

我们可以使用signal函数对2号信号进行捕捉,证明当我们按Ctrl+C时进程确实是收到了2号信号。使用signal函数时,我们需要传入两个参数,第一个是需要捕捉的信号编号,第二个是对捕捉信号的处理方法,该处理方法的参数是int,返回值是void。

例如,下面的代码中将2号信号进行了捕捉,当该进程运行起来后,若该进程收到了2号信号就会打印出收到信号的信号编号。

#include <stdio.h>
#include <signal.h>
#include <unistd.h>

void handler(int signal1)
{
	printf("get a signal:%d\n", signal1);
}

int main()
{
	signal(2, handler); //注册2号信号
	while (1){
		printf("hello signal!\n");
		sleep(1);
	}
	return 0;
}

此时当该进程收到2号信号后,就会执行我们给出的handler方法,而不会像之前一样直接退出了,因为此时我们已经将2号信号的处理方式由默认改为了自定义了。由此也证明了,当我们按Ctrl+C时进程确实是收到了2号信号。

在这里插入图片描述
注意

  1. Ctrl-C 产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。
  2. Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl-C 这种控制键产生的信号。
  3. 前台进程在运行过程中用户随时可能按下 Ctrl-C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)的.

信号是进程间通信机制中唯一的异步通信机制,因为可以在任何时候发送信号给某一进程,一旦有信号产生,我们就有下面这几种,用户进程对信号的处理方式。

1.执行默认操作。Linux 对每种信号都规定了默认操作,例如,上面列表中的 SIGTERM 信号,就是终止进程的意思。Core 的意思是 Core Dump,也即终止进程后,通过 Core Dump 将当前进程的运行状态保存在文件里面,方便程序员事后进行分析问题在哪里。

2.捕捉信号。我们可以为信号定义一个信号处理函数。当信号发生时,我们就执行相应的信号处理函数。

3.忽略信号。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号是应用进程无法捕捉和忽略的,即 SIGKILL 和 SEGSTOP,它们用于在任何时候中断或结束某一进程。


在Linux当中,我们可以通过man手册查看各个信号默认的处理动作。

$ man 7 signal

在这里插入图片描述

————————————————
信号是如何记录的?

实际上,当一个进程接收到某种信号后,该信号是被记录在该进程的进程控制块当中的。我们都知道进程控制块本质上就是一个结构体变量,而对于信号来说我们主要就是记录某种信号是否产生,因此,我们可以用一个32位的位图来记录信号是否产生。
在这里插入图片描述
比特位的位置代表信号的编号,而比特位的内容就代表是否收到对应信号.

信号是如何产生的?

一个进程收到信号,本质就是该进程内的信号位图被修改了,也就是该进程的数据被修改了,而只有操作系统才有资格修改进程的数据,因为操作系统是进程的管理者。也就是说,信号的产生本质上就是操作系统直接去修改目标进程的task_struct中的信号位图。

Linux常规信号一览表

信号作用
SIGHUP:当用户退出shell时,由该shell启动的所有进程将收到这个信号,默认动作为终止进程
SIGINT:当用户按下了<Ctrl+C>组合键时,用户终端向正在运行中的由该终端启动的程序发出此信号。默认动作为终止进程。
SIGQUIT:当用户按下<ctrl+>组合键时产生该信号,用户终端向正在运行中的由该终端启动的程序发出些信号。默认动作为终止进程。
SIGILL:CPU检测到某进程执行了非法指令。默认动作为终止进程并产生core文件
SIGTRAP:该信号由断点指令或其他 trap指令产生。默认动作为终止里程 并产生core文件。
SIGABRT:调用abort函数时产生该信号。默认动作为终止进程并产生core文件。
SIGBUS:非法访问内存地址,包括内存对齐出错,默认动作为终止进程并产生core文件。
SIGFPE:在发生致命的运算错误时发出。不仅包括浮点运算错误,还包括溢出及除数为0等所有的算法错误。默认动作为终止进程并产生core文件。
SIGKILL:无条件终止进程。本信号不能被忽略,处理和阻塞。默认动作为终止进程。它向系统管理员提供了可以杀死任何进程的方法。
SIGUSE1:用户定义 的信号。即程序员可以在程序中定义并使用该信号。默认动作为终止进程。
SIGSEGV:指示进程进行了无效内存访问。默认动作为终止进程并产生core文件。
SIGUSR2:另外一个用户自定义信号,程序员可以在程序中定义并使用该信号。默认动作为终止进程。
SIGPIPE:Broken pipe向一个没有读端的管道写数据。默认动作为终止进程。
SIGALRM:定时器超时,超时的时间 由系统调用alarm设置。默认动作为终止进程。
SIGTERM:程序结束信号,与SIGKILL不同的是,该信号可以被阻塞和终止。通常用来要示程序正常退出。执行shell命令Kill时,缺省产生这个信号。默认动作为终止进程。
SIGSTKFLT:Linux早期版本出现的信号,现仍保留向后兼容。默认动作为终止进程。
SIGCHLD:子进程结束时,父进程会收到这个信号。默认动作为忽略这个信号。
SIGCONT:如果进程已停止,则使其继续运行。默认动作为继续/忽略。
SIGSTOP:停止进程的执行。信号不能被忽略,处理和阻塞。默认动作为暂停进程。
SIGTSTP:停止终端交互进程的运行。按下<ctrl+z>组合键时发出这个信号。默认动作为暂停进程。
SIGTTIN:后台进程读终端控制台。默认动作为暂停进程。
SIGTTOU:该信号类似于SIGTTIN,在后台进程要向终端输出数据时发生。默认动作为暂停进程。
SIGURG:套接字上有紧急数据时,向当前正在运行的进程发出些信号,报告有紧急数据到达。如网络带外数据到达,默认动作为忽略该信号。
SIGXCPU:进程执行时间超过了分配给该进程的CPU时间 ,系统产生该信号并发送给该进程。默认动作为终止进程。
SIGXFSZ:超过文件的最大长度设置。默认动作为终止进程。
SIGVTALRM:虚拟时钟超时时产生该信号。类似于SIGALRM,但是该信号只计算该进程占用CPU的使用时间。默认动作为终止进程。
SGIPROF:类似于SIGVTALRM,它不公包括该进程占用CPU时间还包括执行系统调用时间。默认动作为终止进程。
SIGWINCH:窗口变化大小时发出。默认动作为忽略该信号。
SIGIO:此信号向进程指示发出了一个异步IO事件。默认动作为忽略。
SIGPWR:关机。默认动作为终止进程。
SIGSYS:无效的系统调用。默认动作为终止进程并产生core文件。
SIGRTMIN ~ (64) SIGRTMAX:LINUX的实时信号,它们没有固定的含义(可以由用户自定义)。所有的实时信号的默认动作都为终止进程。

信号的产生

1.通过终端按键产生信号

当面对下面的死循环程序时,我们都知道可以按Ctrl+C可以终止该进程。

#include <stdio.h>
#include <unistd.h>

int main()
{
	while (1){
		printf("hello signal!\n");
		sleep(1);
	}
	return 0;
}

但实际上除了按Ctrl+C之外,按Ctrl+\也可以终止该进程。

按Ctrl+C终止进程和按Ctrl+\终止进程,有什么区别?

按Ctrl+C实际上是向进程发送2号信号SIGINT,而按Ctrl+\实际上是向进程发送3号信号SIGQUIT。查看这两个信号的默认处理动作,可以看到这两个信号的Action是不一样的,2号信号是Term,而3号信号是Core。
在这里插入图片描述
Term和Core都代表着终止进程,但是Core在终止进程的时候会进行一个动作,那就是核心转储
在这里插入图片描述

什么是核心转储?

在云服务器中,核心转储是默认被关掉的,我们可以通过使用ulimit -a命令查看当前资源限制的设定。

其中,第一行显示core文件的大小为0,即表示核心转储是被关闭的。
在这里插入图片描述

我们可以通过ulimit -c size命令来设置core文件的大小。
在这里插入图片描述

core文件的大小设置完毕后,就相当于将核心转储功能打开了。此时如果我们再使用Ctrl+\对进程进行终止,就会发现终止进程后会显示core dumped。
在这里插入图片描述
并且会在当前路径下生成一个core文件,该文件以一串数字为后缀,而这一串数字实际上就是发生这一次核心转储的进程的PID。
在这里插入图片描述

核心转储功能有什么用?

当我们的代码出错了,我们最关心的是我们的代码是什么原因出错的。如果我们的代码运行结束了,那么我们可以通过退出码来判断代码出错的原因,而如果一个代码是在运行过程中出错的,那么我们也要有办法判断代码是什么原因出错的。

当我们的程序在运行过程中崩溃了,我们一般会通过调试来进行逐步查找程序崩溃的原因。而在某些特殊情况下,我们会用到核心转储,核心转储指的是操作系统在进程收到某些信号而终止运行时,将该进程地址空间的内容以及有关进程状态的其他信息转而存储到一个磁盘文件当中,这个磁盘文件也叫做核心转储文件,一般命名为core.pid。

而核心转储的目的就是为了在调试时,方便问题的定位。

如何运用核心转储进行调试?

我们用下面这段代码进行演示:

#include<stdio.h>
#include<unistd.h>
 int main()
   {
     printf("i am running!\n"); 
     sleep(2); 
     int a=1/0;                                                                 return 0; 
   }

该代码当中出现了除0错误,该程序运行2秒后便会崩溃。
在这里插入图片描述
此时我们便可以在当前目录下看到核心转储时生成的core文件。
在这里插入图片描述

使用gdb对当前可执行程序进行调试,然后直接使用core-file core文件命令加载core文件,即可判断出该程序在终止时收到了几号信号,并且定位到了产生该错误的具体代码。
在这里插入图片描述
事后用调试器检查core文件以查清错误原因,这种调试方式叫做事后调试

core dump标志

还记得进程等待函数waitpid函数的第二个参数吗:

pid_t waitpid(pid_t pid, int *status, int options);

进程等待时有两个函数wait和waitpid,这两个函数都有一个输出型参数status,这个参数是一个整型变量,由操作系统填充,获取进程的退出信息返回给父进程。
在status的低16位中,高8位表示进程的退出状态即退出码,若进程被信号杀掉,低7七位表示终止信号,第8位是core dump标志。一旦发生核心转储,core dump标志位就会被设置为1。

在这里插入图片描述


打开Linux的核心转储功能,并编写下列代码。代码中父进程使用fork函数创建了一个子进程,子进程所执行的代码当中存在野指针问题,当子进程执行到*p = 100时,必然会被操作系统所终止并在终止时进行核心转储。此时父进程使用waitpid函数便可获取到子进程退出时的状态,根据status的第7个比特位便可得知子进程在被终止时是否进行了核心转储。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>

int main()
{
	if (fork() == 0){
		//child
		printf("I am running...\n");
		int *p = NULL;
		*p = 100;
		exit(0);
	}
	//father
	int status = 0;
	waitpid(-1, &status, 0);
	printf("exitCode:%d, coreDump:%d, signal:%d\n",
		(status >> 8) & 0xff, (status >> 7) & 1, status & 0x7f);
	return 0;
}

在这里插入图片描述
通过 (status >> 7) & 1,可知core dump标志实际上就是用于表示程序崩溃的时候是否进行了核心转储。


小贴士:有些信号是不能被捕捉的,比如9号信号。
证明:
我们可以通过以下代码,将1~31号信号全部进行捕捉,将收到信号后的默认处理动作改为打印收到信号的编号。

#include <stdio.h>
#include <unistd.h>
#include <signal.h>

void handler(int signal)
{
	printf("get a signal:%d\n", signal);
}
int main()
{
	printf("pid:%d\n",getpid());
	int signo;
	for (signo = 1; signo <= 31; signo++){
		signal(signo, handler);
	}
	while (1){
		sleep(1);
	}
	return 0;
}

此时,当我们按下组合按键Ctrl+C ,Ctrl+Z后,便可以得知这些组合按键分别是向前台进程发送几号信号了。
在这里插入图片描述

但如果我们此时向该进程发送9号信号,该进程并不会打印收到了9号信号,而是执行收到9号信号后的默认处理动作,即被终止。在这里插入图片描述
因此, 有些信号是不能被捕捉的,比如9号信号。因为如果所有信号都能被捕捉的话,那么进程就可以将所有信号全部进行捕捉并将动作设置为忽略,此时该进程将无法被杀死,即便是操作系统。

2.通过系统函数向进程发信号
kill函数
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);

第一个参数表示想给哪个进程发信号,第二个参数表示想发几号信号。
返回值成功返回0,失败返回-1。

  • sig:不推荐直接使用数字,应使用宏名,因为不同操作系统信号编号可能不同,但名称一致。

  • pid
    pid > 0: 发送信号给指定的进程。
    pid = 0: 发送信号给 与调用kill函数进程属于同一进程组的所有进程。
    pid < 0: 取|pid|发给对应进程组。
    pid = -1:发送给进程有权限发送的系统中所有进程。

  • 进程组:每个进程都属于一个进程组,进程组是一个或多个进程集合,他们相互关联,共同完成一个实体任务,每个进程组都有一个进程组长,默认进程组ID与进程组长ID相同。

  • 权限保护:super用户(root)可以发送信号给任意用户,普通用户是不能向系统用户发送信号的。 kill -9 (root用户的pid) 是不可以的。同样,普通用户也不能向其他普通用户发送信号,终止其进程。 只能向自己创建的进程发送信号。普通用户基本规则是:发送者实际或有效用户ID == 接收者实际或有效用户ID。

练习:循环创建5个子进程,任一子进程用kill函数终止其父进程。
在这里插入图片描述
在这里插入图片描述

raise函数

raise函数可以给当前进程发送指定信号,即自己给自己发送信号,raise函数的函数原型如下:

#include <signal.h>
int raise(int sig);

raise函数用于给当前进程发送sig号信号,如果信号发送成功,则返回0,否则返回一个非零值。

练习:用raise函数每隔一秒向自己发送一个2号信号

#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);
	}
	return 0;
}

在这里插入图片描述

abort函数
#include <stdlib.h>
void abort(void);
  • abort函数使当前进程接收到信号SIGABRT(6号)信号而异常终止.
  • abort函数的作用是异常终止进程,exit函数的作用是正常终止进程,而abort本质是通过向当前进程发送SIGABRT信号而终止进程的,因此使用exit函数终止进程可能会失败,但使用abort函数终止进程总是成功的,所以没有返回值。

练习:每隔一秒向当前进程发送一个SIGABRT信号。

#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();
	}
	return 0;
}

在这里插入图片描述
虽然我们对SIGABRT信号进行了捕捉,并且设置在收到SIGABRT信号后执行我们给出的自定义方法,但是当前进程依然是异常终止了。

3.由软件条件产生信号
SIGPIPE信号

SIGPIPE信号实际上就是一种由软件条件产生的信号,当进程在使用管道进行通信时,读端进程将读端关闭,而写端进程还在一直向管道写入数据,那么此时写端进程就会收到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){
		//child
		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);
	}
	//father
	close(fd[1]); //父进程关闭写端
	close(fd[0]); //父进程直接关闭读端(导致子进程被操作系统杀掉)
	int status = 0;
	waitpid(id, &status, 0);
	printf("child get signal:%d\n", status & 0x7F); //打印子进程收到的信号
	return 0;
}

运行代码后,即可发现子进程在退出时收到的是13号信号,即SIGPIPE信号。
在这里插入图片描述

SIGALRM信号

调用alarm函数可以设定一个闹钟,也就是告诉操作系统在若干时间后发送SIGALRM(14)信号给当前进程,alarm函数的函数原型如下:

unsigned int alarm(unsigned int seconds);

alarm函数的作用就是,让操作系统在seconds秒之后给当前进程发送SIGALRM信号,SIGALRM信号的默认处理动作是终止进程。

每个进程都有且只有唯一个定时器。

alarm函数的返回值:

  • 若调用alarm函数前,进程已经设置了闹钟,则返回上一个闹钟时间的剩余时间,并且本次闹钟的设置会覆盖上一次闹钟的设置。
  • 如果调用alarm函数前,进程没有设置闹钟,则返回值为0。

例如,我们可以用下面的代码,测试自己的云服务器一秒时间内可以将一个变量累加到多大。

#include <stdio.h>
#include <signal.h>
#include <unistd.h>

int main()
{
	int count = 0;
	alarm(1);
	while (1){
		count++;
		printf("count: %d\n", count);
	}
	return 0;
}

运行代码后,可以发现我当前的云服务器在一秒内可以将一个变量累加到两万左右。
在这里插入图片描述
但实际上我当前的云服务器在一秒内可以执行的累加次数远大于两万,那为什么上述代码运行结果比实际结果要小呢?

主要原因有两个,首先,由于我们每进行一次累加就进行了一次打印操作,而与外设之间的IO操作所需的时间要比累加操作的时间更长,其次,由于我当前使用的是云服务器,因此在累加操作后还需要将累加结果通过网络传输将服务器上的数据发送过来,因此最终显示的结果要比实际一秒内可累加的次数小得多。

为了尽可能避免上述问题,我们可以先让count变量一直执行累加操作,直到一秒后进程收到SIGALRM信号后再打印累加后的数据。

#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;
}

在这里插入图片描述
此时可以看到,count变量在一秒内被累加的次数变成了五亿多,由此也证明了,涉及到IO操作,效率往往是较慢的。

4.由硬件异常产生信号

为什么我们平时写的程序会崩溃?

当我们程序当中出现类似于除0、野指针、越界之类的错误时,为什么程序会崩溃?本质上是因为进程在运行过程中收到了操作系统发来的信号进而被终止,那操作系统是如何识别到一个进程触发了某种问题的呢?

除0操作

我们知道,CPU当中有一堆的寄存器,当我们需要对两个数进行算术运算时,我们是先将这两个操作数分别放到两个寄存器当中,然后进行算术运算并把结果写回寄存器当中。此外,CPU当中还有一组寄存器叫做状态寄存器,它可以用来标记当前指令执行结果的各种状态信息,如有无进位、有无溢出等等。而操作系统是软硬件资源的管理者,在程序运行过程中,若操作系统发现CPU内的某个状态标志位被置位,而这次置位就是因为出现了某种除0错误而导致的,那么此时操作系统就会马上识别到当前是哪个进程导致的该错误,并将所识别到的硬件错误包装成信号发送给目标进程,本质就是操作系统去直接找到这个进程的task_struct,并向该进程的位图中写入8信号,写入8号信号后这个进程就会在合适的时候被终止。

那对于野指针问题,或者越界访问的问题时,操作系统又是如何识别到的呢?

我们知道,当我们要访问一个变量时,一定要先经过页表的映射,将虚拟地址转换成物理地址,然后才能进行相应的访问操作。

其中页表属于一种软件映射关系,而实际上在从虚拟地址到物理地址映射的时候还有一个硬件叫做MMU,它是一种负责处理CPU的内存访问请求的计算机硬件,因此映射工作不是由CPU做的,而是由MMU做的,但现在MMU已经集成到CPU当中了。

当需要进行虚拟地址到物理地址的映射时,我们先将页表的左侧的虚拟地址导给MMU,然后MMU会计算出对应的物理地址,我们再通过这个物理地址进行相应的访问。

而MMU既然是硬件单元,那么它当然也有相应的状态信息,当我们要访问不属于我们的虚拟地址时,MMU在进行虚拟地址到物理地址的转换时就会出现错误,然后将对应的错误写入到自己的状态信息当中,这时硬件上面的信息也会立马被操作系统识别到,进而将对应进程发送SIGSEGV信号。

阻塞信号

信号其他相关常见概念
  • 实际执行信号的处理动作,称为信号递达(Delivery)。
  • 信号从产生到递达之间的状态,称为信号未决(pending)。
  • 进程可以选择阻塞(Block)某个信号。
  • 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
  • 需要注意的是,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后的一种处理动作。

信号在内核中的表示
信号在内核中的表示示意图:

在这里插入图片描述

  • 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
  • SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会在改变处理动作之后再接触阻塞。
  • SIGQUIT信号未产生过,但一旦产生SIGQUIT信号,该信号将被阻塞,它的处理动作是用户自定义函数sighandler。如果在进程解除对某信号的阻塞之前,这种信号产生过多次,POSIX.1允许系统递达该信号一次或多次。Linux是这样实现的:普通信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里,这里只讨论普通信号。
  • 在block位图中,比特位的位置代表某一个信号,比特位的内容代表该信号是否被阻塞。
  • 在pending位图中,比特位的位置代表某一个信号,比特位的内容代表是否收到该信号。
  • handler表本质上是一个函数指针数组,数组的下标代表某一个信号,数组的内容代表该信号递达时的处理动作,处理动作包括默认、忽略以及自定义。
  • block、pending和handler这三张表的每一个位置是一一对应的(横着看)。

所以实际上检测信号递达的时候,第一步看pending中看是否收到信号,发现有这个信号,第二步发现对应block位图中是0,也就是不会被阻塞,然后才会执行hander数组中对应的处理方法,否则block中如果为1,即便pending位图中为1也不会被递达。
检测信号是否会被递达,是否被阻塞,都是OS的任务。

sigset_t

pending位图中每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态。在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。

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;
信号集操作函数

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 signum);

int sigdelset(sigset_t *set, int signum);

int sigismember(const sigset_t *set, int signum);  

函数解释:

  • sigemptyset函数:初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。
  • sigfillset函数:初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。
  • sigaddset函数:在set所指向的信号集中添加某种有效信号。
  • sigdelset函数:在set所指向的信号集中删除某种有效信号。
  • sigemptyset、sigfillset、sigaddset和sigdelset函数都是成功返回0,出错返回-1。
  • sigismember函数:判断在set所指向的信号集中是否包含某种信号,若包含则返回1,不包含则返回0,调用失败返回-1。

注意: 在使用sigset_t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号处于确定的状态。

sigprocmask

sigprocmask函数可以用于读取或更改进程的信号屏蔽字(阻塞信号集),该函数的函数原型如下:

int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

参数说明:

  • 如果oset是非空指针,则读取进程当前的信号屏蔽字通过oset参数传出。
  • 如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。
  • 如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。

假设当前的信号屏蔽字为mask,下表说明了how参数的可选值及其含义:

选项 :含义

  • SIG_BLOCK: set包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask|set
  • SIG_UNBLOCK: set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask|~set
  • SIG_SETMASK :设置当前信号屏蔽字为set所指向的值,相当于mask=set
    返回值说明:

sigprocmask函数调用成功返回0,出错返回-1。

例子:
1.屏蔽(阻塞)2号和9号信号。
在这里插入图片描述
此时我们在键盘输入ctrl+c或者kill -2 28424(进程pid),因为屏蔽了2号信号,所以将没有任何反应。但是如果此时 kill -9 28424 时,将会直接杀死进程,这就再次证明了,有些信号是屏蔽不了的。
在这里插入图片描述


sigpending

sigpending函数可以用于读取进程的未决信号集,该函数的函数原型如下:

int sigpending(sigset_t *set);

sigpending函数读取当前进程的未决信号集,并通过set参数传出。该函数调用成功返回0,出错返回-1。

下面我们来做一个简单的验证:

  • 先用上述的函数将2号信号进行屏蔽(阻塞)。
  • 不断的获取pending信号集并打印。
  • 使用kill命令或组合按键向进程发送2号信号(此时因为2号信号被阻塞,一直处于pending(未决)状态)。
  • 过一段时间恢复对2号信号的block(取消2号的block)。
  • 2号信号立马会被递达.
  • 依旧打印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);


    sigaddset(&in, 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;
}

在这里插入图片描述
刚开始没有信号,pending信号集当中2号信号的位置为0

过了4秒后ctrl+c发送了2号信号,但是由于2号信号被屏蔽,处于未决状态,即信号集2号位置一直是1

10s后解除阻塞(恢复老的信号屏蔽字),信号立马被抵达-收到2号信号执行了自定义捕捉动作

此后pending信号集2号信号位置恢复为0,即信号抵达状态。

捕捉信号

我们说捕捉信号之前,先来简单了解一下内核空间和用户空间以及内核态和用户态。

内核空间和用户空间

对 32 位操作系统而言,它的寻址空间(虚拟地址空间,或叫线性地址空间)为 4G(2的32次方)。也就是说一个进程的最大地址空间为 4G。操作系统的核心是内核(kernel),它独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证内核的安全,现在的操作系统一般都强制用户进程不能直接操作内核。具体的实现方式基本都是由操作系统将虚拟地址空间划分为两部分,一部分为内核空间,另一部分为用户空间。针对 Linux 操作系统而言,最高的 1G 字节(从虚拟地址 0xC0000000 到 0xFFFFFFFF)由内核使用,称为内核空间。而较低的 3G 字节(从虚拟地址 0x00000000 到 0xBFFFFFFF)由各个进程使用,称为用户空间。

对上面这段内容我们可以这样理解:

每个进程的 4G 地址空间中,最高 1G 都是一样的,即内核空间。只有剩余的 3G 才归进程自己使用。

换句话说就是, 最高 1G 的内核空间是被所有进程共享的!

下图描述了每个进程 4G 地址空间的分配情况:

  • 用户所写的代码和数据位于用户空间,通过用户级页表与物理内存之间建立映射关系。
  • 内核空间存储的实际上是操作系统代码和数据,通过内核级页表与物理内存之间建立映射关系。

为什么需要区分内核空间与用户空间

  • 在 CPU 的所有指令中,有些指令是非常危险的,如果错用,将导致系统崩溃,比如清内存、设置时钟等。如果允许所有的程序都可以使用这些指令,那么系统崩溃的概率将大大增加。
  • 所以,CPU 将指令分为特权指令和非特权指令,对于那些危险的指令,只允许操作系统及其相关模块使用,普通应用程序只能使用那些不会造成灾难的指令。比如 Intel 的 CPU 将特权等级分为 4 个级别:Ring0~Ring3。
  • 其实 Linux 系统只使用了 Ring0 和 Ring3 两个运行级别(Windows 系统也是一样的)。当进程运行在 Ring3 级别时被称为运行在用户态,而运行在 Ring0 级别时被称为运行在内核态。

如何理解进程切换?

1.在当前进程的进程地址空间中的内核空间,找到操作系统的代码和数据。
2.执行操作系统的代码,将当前进程的代码和数据剥离下来,并换上另一个进程的代码和数据。

注意: 当你访问用户空间时你必须处于用户态,当你访问内核空间时你必须处于内核态。

内核态与用户态

我们现在需要再解释一下什么是内核态、用户态:

当进程运行在内核空间时就处于内核态,而进程运行在用户空间时则处于用户态。

在内核态下,进程运行在内核地址空间中,此时 CPU 可以执行任何指令。运行的代码也不受任何的限制,可以自由地访问任何有效地址,也可以直接进行端口的访问。

在用户态下,进程运行在用户地址空间中,被执行的代码要受到 CPU 的诸多检查,它们只能访问映射其地址空间的页表项中规定的在用户态下可访问页面的虚拟地址,且只能对任务状态段(TSS)中 I/O 许可位图(I/O Permission Bitmap)中规定的可访问端口进行直接访问。

对于以前的 DOS 操作系统来说,是没有内核空间、用户空间以及内核态、用户态这些概念的。可以认为所有的代码都是运行在内核态的,因而用户编写的应用程序代码可以很容易的让操作系统崩溃掉。

对于 Linux 来说,通过区分内核空间和用户空间的设计,隔离了操作系统代码(操作系统的代码要比应用程序的代码健壮很多)与应用程序代码。即便是单个应用程序出现错误也不会影响到操作系统的稳定性,这样其它的程序还可以正常的运行(Linux 可是个多任务系统啊!)。

所以,区分内核空间和用户空间本质上是要提高操作系统的稳定性及可用性。

如何进行内核态和用户态的转换

其实所有的系统资源管理都是在内核空间中完成的。比如读写磁盘文件,分配回收内存,从网络接口读写数据等等。我们的应用程序是无法直接进行这样的操作的。但是我们可以通过内核提供的接口来完成这样的任务。

比如应用程序要读取磁盘上的一个文件,它可以向内核发起一个 “系统调用” 告诉内核:“我要读取磁盘上的某某文件”。其实就是通过一个特殊的指令让进程从用户态进入到内核态(到了内核空间),在内核空间中,CPU 可以执行任何的指令,当然也包括从磁盘上读取数据。具体过程是先把数据读取到内核空间中,然后再把数据拷贝到用户空间并从内核态切换到用户态。此时应用程序已经从系统调用中返回并且拿到了想要的数据,可以往下执行了。

简单说就是应用程序把高科技的事情(从磁盘读取文件)外包给了系统内核,系统内核做这些事情既专业又高效。

对于一个进程来讲,从用户空间进入内核空间并最终返回到用户空间,这个过程是十分复杂的。举个例子,比如我们经常接触的概念 “堆栈”,其实进程在内核态和用户态各有一个堆栈。运行在用户空间时进程使用的是用户空间中的堆栈,而运行在内核空间时,进程使用的是内核空间中的堆栈。所以说,Linux 中每个进程有两个栈,分别用于用户态和内核态。

下图简明的描述了用户态与内核态之间的转换:

从用户态切换为内核态通常有如下几种情况:

  • 需要进行系统调用时。
  • 当前进程的时间片到了,导致进程切换。
  • 产生异常、中断、陷阱等。

与之相对应,从内核态切换为用户态有如下几种情况:

  • 系统调用返回时。
  • 进程切换完毕。
  • 异常、中断、陷阱等处理完毕。

其中,由用户态切换为内核态我们称之为陷入内核。每当我们需要陷入内核的时,本质上是因为我们需要执行操作系统的代码,比如系统调用函数是由操作系统实现的,我们要进行系统调用就必须先由用户态切换为内核态。

4G地址空间解析
在这里插入图片描述

———————————————————————————————————————
关于虚拟内存有三点需要注意:

  • 4G的进程地址空间被人为的分为两个部分–用户空间与内核空间。用户空间从0到3G(0xc0000000),内核空间占据3G到4G。用户进程通常情况下只能访问用户空间的虚拟地址,不能访问内核空间的虚拟地址。例外情况只有用户进程进行系统调用(代表用户进程在内核态执行)等时刻可以访问到内核空间。
  • 用户空间对应进程,所以每当进程切换,用户空间就会跟着变化;而内核空间是由内核负责映射,它并不会跟着进程变化,是固定的。内核空间地址有自己对应的页表,用户进程各自有不同的页表。
  • 每个进程的用户空间都是完全独立、互不相干的。

———————————————————————————————————————

内核如何实现信号的捕捉

我们先来回忆一下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 调用处理程序。 如果处理程序的调用导致信号被阻塞,则信号在从处理程序返回时被解除阻塞。

当我们在执行主控制流程(main函数)的时候,可能因为某些情况而陷入内核,当内核处理完毕准备返回用户态时,就需要进行信号pending的检查。(此时仍处于内核态,有权力查看当前进程的pending位图)

在查看pending位图时,如果发现有未决信号,并且该信号没有被阻塞,那么此时就需要该信号进行处理。

如果待处理信号的处理动作是默认或者忽略,则执行该信号的处理动作后清除对应的pending标志位,如果没有新的信号要递达,就直接返回用户态,从主控制流程中上次被中断的地方继续向下执行即可。
在这里插入图片描述
但如果待处理信号是自定义捕捉的,即该信号的处理动作是由用户提供的,那么处理该信号时就需要先返回用户态执行对应的自定义处理动作(通过signal函数执行自定义信号处理方法-sighandler函数),执行完后再通过特殊的系统调用sigreturn再次陷入内核并清除对应的pending标志位,如果没有新的信号要递达,就直接返回用户态,继续执行主控制流程的代码。

在这里插入图片描述

信号被捕捉的时间点:内核态->用户态的时候

信号捕捉处理的过程我们可以借用下面这张图进行记忆:
在这里插入图片描述
总结:

如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂举例如下:

用户程序注册了SIGQUIT信号的处理函数sighandler。 当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。
内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。
sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。
如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。

特别注意:当识别到信号的处理动作是自定义时,能直接在内核态执行用户空间的代码吗?

理论上来说是可以的,因为内核态是一种权限非常高的状态,但是绝对不能这样设计。

如果允许在内核态直接执行用户空间的代码,那么用户就可以在自定义捕捉代码中设计一些非法操作,比如清空数据库等,虽然在用户态时没有足够的权限做到清空数据库,但是如果是在内核态时执行了这种非法代码,那么数据库就真的被清空了,因为内核态是有足够权限清空数据库的。

也就是说,不能让操作系统直接去执行用户的代码,因为操作系统无法保证用户的代码是合法代码,即操作系统不信任任何用户。

sigaction

捕捉信号除了用前面用过的signal函数之外,我们还可以使用sigaction函数对信号进行捕捉,sigaction函数的函数原型如下:

int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

sigaction函数可以读取和修改与指定信号相关联的处理动作,该函数调用成功返回0,出错返回-1。

参数说明:

  • signum代表指定信号的编号。

  • 若act指针非空,则根据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:

  • 将sa_handler赋值为常数SIG_IGN传给sigaction函数,表示忽略信号。
  • 将sa_handler赋值为常数SIG_DFL传给sigaction函数,表示执行系统默认动作。
  • 将sa_handler赋值为一个函数指针,表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数。

注意: 所注册的信号处理函数的返回值为void,参数为int,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然这是一个回调函数,不是被main函数调用,而是被系统所调用。

结构体的第二个成员sa_sigaction:

sa_sigaction是实时信号的处理函数。

结构体的第三个成员sa_mask:

首先需要说明的是,当某个信号的处理函数被调用,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。

如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时,自动恢复原来的信号屏蔽字。

结构体的第四个成员sa_flags:

sa_flags字段包含一些选项,这里直接将sa_flags设置为0即可。

下面我们进行一个测试:
我们发送二号信号,同时将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;
}

在这里插入图片描述
我们来解释一下:

刚开始程序正常运行(死循环打印),然后在键盘输入 CTRL+c(即发送2号信号)后,2号信号通过sigaction执行了自定义捕捉,那么此时就是在进行2号信号处理的阶段,当前我们再一次发送2号信号,可以看到没反应,因为它已经被阻塞了。同时,因为我们在sa_mask字段说明了3号信号,用sigaddset函数将3号信号添加到了block信号集,当我们在键盘输入CTRL+(即发送3号信号)时,也没有反应,这就证明了3号信号也被屏蔽,直到2号信号处理完,3号信号也就跟着取消了屏蔽。

可重入函数

引入
我们来看,下面的主函数中调用insert函数向链表中插入结点node1,而在某信号处理函数中也调用了insert函数向链表中插入结点node2,乍眼一看好像没什么问题。
在这里插入图片描述
下面我们来分析一下,对于下面这个链表:
在这里插入图片描述

1.首先,main函数中调用了insert函数,想将结点node1插入链表,但插入操作分为两步,第一步:node1->next = head,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回到用户态之前检查到有信号待处理,于是切换到sighandler函数。

在这里插入图片描述

2、此时sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作分为两步,第一步:node2->next = head,第二步:head = node2,插入操作的两步都做完之后从sighandler返回内核态.
在这里插入图片描述
3.再次回到用户态就从main函数调用的insert函数中继续往下执行,即继续进行结点node1的插入操作的第二步:head = node1;

在这里插入图片描述
因此最终结果就是,main函数和sighandler函数先后向链表中插入了两个结点,但最后只有node1结点真正插入到了链表中,而node2结点因为没有指向它的指针变量,所以再也找不到了,造成了内存泄漏。

像上例这样,insert函数被不同的控制流调用(main函数和sighandler函数使用不同的堆栈空间,它们之间不存在调用与被调用的关系,是两个独立的控制流程),有可能在第一次调用还没返回时就再次进入该函数,我们将这种现象称之为重入。

而insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数我们称之为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称之为可重入(Reentrant)函数。

如果一个函数符合以下条件之一则是不可重入的:

  • 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
  • 调用了标志I/O库函数,因为标准I/O库的很多实现都以不可重入的方式使用全局数据结构。

volatile

volatile是C语言的一个关键字,该关键字的作用是保持内存的可见性,确保本条指令不会被编译器的优化而忽略,怎么理解呢?

先从一个简单的例子说起吧~

看下面一段代码:

int main()
{
	int i = 10;
	int a = i;

	printf("%d", i);

	//下面汇编语句的作用就是改变内存中i的值,但是又不让编译器知道
	__asm
	{
		mov dword ptr[ebp - 4], 20h
	}
	int b = i;
	printf("i=%d", b);
	return 0;
}

在debug(调试)版本模式运行程序,输出结果如下:

i = 10
i = 32

在release版本模式运行下,输出结果如下:

i = 10
i = 10

输出结果表明一个问题,在release模式下,编译器对代码进行了优化。


下面,我们把i的声明加上关键字volatile,看看有什么变化没

int main()
{
	volatile int i = 10;
	int a = i;

	printf("%d", i);

	//下面汇编语句的作用就是改变内存中i的值,但是又不让编译器知道
	__asm
	{
		mov dword ptr[ebp - 4], 20h
	}
	int b = i;
	printf("i=%d", b);
	return 0;
}

分别在debug和release版本运行结果都是如下:

i = 10
i = 32

这说明volatile发挥了它的作用,保持内存的可见性。

那么这里所谓的优化指的是什么呢?

我们再结合信号通过一个例子理解一下:

在下面的代码中,我们对2号信号进行了捕捉,当该进程收到2号信号时会将全局变量flag由0置1。也就是说,在进程收到2号信号之前,该进程会一直处于死循环状态,直到收到2号信号时将flag置1才能够正常退出。

#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);//当flag为0时,!flag为真,进入循环
	printf("这个进程时正常退出的!\n");
	return 0;
}

在这里插入图片描述
该程序的运行过程好像在我们的意料之中,但实际并非如此。这是因为当前操作系统没有做优化,如果我们在编译代码时携带-O3选项使得编译器的优化级别变高,此时再运行该代码,就算向进程发生2号信号,该进程也不会终止(也就是说flag依旧是0)。

在这里插入图片描述

在这里插入图片描述
这说明了什么呢?这说明虽然在handler执行流里,flag被置为了一,但是在main函数执行流中的while循环中,并没有检测到flag被置为了1,也就是说,代码中的main函数和handler函数是两个独立的执行流,而while循环是在main函数当中的,在编译器编译时只能检测到在main函数中对flag变量的修改和使用。


深入理解一下,在编译器优化级别较高的时候,就有可能将flag值暂存进寄存器里面,方便下次直接读取计算,而不用先从内存加载到cpu,然后进行类似(!flag)这样的逻辑运算。
此时main函数在检测flag进行计算时只检测寄存器里面的值,而handler执行流是将内存中flag的值置为1了,那么此时while循环里判断条件中的flag值还是0,因此不会跳出死循环。
在这里插入图片描述

面对这种情况,我们就可以使用volatile关键字对flag变量进行修饰,告知编译器,对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;
}

此时就算我们编译代码时携带-O3选项,当进程收到2号信号将内存中的flag变量置1时,main函数执行流也能够检测到内存中flag变量的变化,进而跳出死循环正常退出。

这种原理就好比什么呢?

发薪资时,会计每次都把员工叫来登记他们的银行卡号;一次会计为了省事,没有即时登记,用了以前登记的银行卡号;刚好一个员工的银行卡丢了,已挂失该银行卡号;从而造成该员工领不到工资

员工 -- 原始变量地址

银行卡号 -- 原始变量在寄存器的备份

volatile应该解释为“直接存取原始内存地址”比较合适。

SIGCHLD信号

为了避免出现僵尸进程,父进程需要使用wait或waitpid函数等待子进程结束,父进程可以阻塞等待子进程结束,也可以非阻塞地查询的是否有子进程结束等待清理,即轮询的方式。采用第一种方式,父进程阻塞就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。

其实,子进程在终止时会给父进程发生SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理动作,这样父进程就只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程。也就是说,子进程退出时给父进程发生SIGCHLD信号,父进程通过在信号处理函数中调用wait或waitpid函数清理子进程即可。

例如,下面代码中对SIGCHLD信号进行了捕捉,并将在该信号的处理函数中调用了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);
	}
}
int main()
{
	signal(SIGCHLD, handler);
	if (fork() == 0){

		printf("child is running, begin dead: %d\n", getpid());
		sleep(3);
		exit(1);
	}
	while (1);
	return 0;
}

在这里插入图片描述
此时父进程就只需专心处理自己的工作,不必关心子进程了,子进程终止时父进程收到SIGCHLD信号,会自动进行该信号的自定义处理动作,进而对子进程进行清理。

注意:

  • SIGCHLD属于普通信号,记录该信号的pending位只有一个,如果在同一时刻有多个子进程同时退出,那么在handler函数当中实际上只清理了一个子进程,因此在使用waitpid函数清理子进程时需要使用while不断进行清理。
  • 使用waitpid函数时,需要设置WNOHANG选项,即非阻塞式等待,否则当所有子进程都已经清理完毕时,由于while循环,会再次调用waitpid函数,此时就会在这里阻塞住。

事实上,由于UNIX的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用signal或sigaction函数将SIGCHLD信号的处理动作设置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用signal或sigaction函数自定义的忽略通常是没有区别的,但这是一个特列。此方法对于Linux可用,但不保证在其他UNIX系统上都可用。

例如,下面代码中调用signal函数将SIGCHLD信号的处理动作自定义为忽略。

#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>

int main()
{
	signal(SIGCHLD, SIG_IGN);
	if (fork() == 0){
		//child
		printf("child is running, child dead: %d\n", getpid());
		sleep(3);
		exit(1);
	}
	//father
	while (1);
	return 0;
}

此时子进程在终止时会自动被清理掉,不会产生僵尸进程,也不会通知父进程。

– the End –

以上就是我分享的进程信号相关内容,感谢阅读!

本文收录于专栏Linux
关注作者,持续阅读作者的文章,学习更多知识!
https://blog.csdn.net/weixin_53306029?spm=1001.2014.3001.5343

————————————————

评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

…狂奔的蜗牛~

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值