目录
十七.进程信号
导言
17.1 linux中的信号列表
17.2 标准信号与实时信号
17.3 信号的产生
17.3.1 通过终端按键产生信号
17.3.2 调用系统函数产生信号
17.3.3 软件条件产生信号
17.3.4 硬件异常产生信号
17.3.5 【补充】核心转储 Core Dump
17.4 信号的阻塞
17.4.1 信号相关常见概念补充
17.4.2 信号在内核的表示
17.4.3 信号集
sigset_t:
信号集操作函数:
sigprocmask :
sigpending:
17.4.4 代码测试
17.5 信号的捕捉
17.5.1 signal:
17.5.2 sigaction:
17.5.3 内核态与用户态
17.5.4 重谈进程地址空间
17.5.5 信号捕捉流程图
17.6 SIGCHLD
十七.进程信号
导言
生活中充满着各种信号,从交通灯的红绿指示,到手机的震动提示,信号贯穿着我们日常的方方面面。这些信号是为了在复杂的环境中传递简洁而重要的信息,让人们能够迅速做出反应。有趣的是,计算机科学中也有一种类似的机制,那就是进程信号。
想象一下,当你在驾驶时看到红灯亮起,你会停下车子;或者当你的手机收到一条紧急的消息,震动提醒你关注。这些都是生活中的信号,通过它们,我们获得了环境中的重要信息,并且采取了相应的行动。
在Linux操作系统中,进程信号就像是计算机世界中的这些生活信号。它们是一种轻量级的通信方式,操作系统通过发送信号来通知进程发生了特定事件。这些事件可以包括需要终止进程、重新加载配置、处理错误等。通过对进程信号的理解,我们能够使程序更加智能地响应各种情境,就像在生活中接收并处理各种信号一样。在本文中,我们将以生活中信号的概念为切入点,引领你深入了解Linux系统中进程信号的奥妙,以及如何利用这种机制构建更加灵活和可靠的计算机程序。随着我们的探索,你将发现信号在计算机世界中的重要性,以及它们如何成为程序与操作系统之间交流的桥梁。
17.1 linux中的信号列表
在Linux中,有一系列预定义的信号,每个信号都有一个唯一的编号。这些信号的编号通常以SIG开头,后跟一个描述信号用途的大写字母缩写。
kill -l
通过kill -l 命令我们可以查看系统为我们定义好的信号列表
值得注意的是,信号编号范围是1到64,但并不是每一个编号都对应了一个信号,32和33
便没有对应信号
这时,大家也能猜到实际上,信号的本质就是宏定义,这里我们通过源码确定
事实上,通过名字和注释,我们大概可以知道每个信号其实是由大概的含义。这实际上表示了进程对信号的默认处理方式
17.2 标准信号与实时信号
在Linux系统中,信号可以分为两类:标准信号(Standard Signals)和实时信号(Real-time Signals),这两类信号在其产生和传递的方式上有一些区别。
标准信号(Standard Signals):
- 范围: 标准信号的编号范围通常是1到31。
- 产生: 这些信号是由操作系统或进程直接产生的,例如用户按下Ctrl+C产生的
SIGINT
,或者由操作系统发出的SIGHUP
。- 语义: 标准信号通常用于表示一些常见的事件,如终止进程、中断操作等。
- 处理: 标准信号的处理方式包括终止进程、忽略信号、使用默认的信号处理函数或者注册自定义的信号处理函数。
实时信号(Real-time Signals):
- 范围: 实时信号的编号范围是34到63。
- 产生: 实时信号是由内核和进程共同产生的,通常用于实时进程间通信。
- 语义: 实时信号的语义更为灵活,可用于应用层定义的目的,如实现自定义的进程间通信。
- 处理: 实时信号的处理方式与标准信号类似,可以忽略、使用默认处理函数或者注册自定义的处理函数。
总的来说,标准信号是操作系统提供的常见信号,用于表示一些标准的事件,而实时信号则更为灵活,可用于实现更复杂的应用层通信。在处理方式上两者并没有本质的区别,都可以通过系统调用注册处理函数。这里我们不会继续深入,我们后续讲解只涉及标准信号。
17.3 信号的产生
信号是进程之间事件异步通知的一种方式,属于软中断,本质也是数据 。信号是给进程发的,进程在收到信号后,会在合适的时候执行对应的命令。
17.3.1 通过终端按键产生信号
在UNIX或类UNIX系统中,信号可以通过多种方式产生,其中之一是通过终端按键。用户在终端上按下特定的键盘组合时,会生成相应的信号,通常用于与正在运行的程序进行交互或控制。
下面是一些常见的通过终端按键产生的信号:
Ctrl+C (SIGINT):
- 产生原因:用户按下Ctrl+C组合键。
- 默认行为:中断(Interrupt)当前运行的程序。
- 通常用途:允许用户通过终端中断正在运行的程序。
Ctrl+Z (SIGTSTP):
- 产生原因:用户按下Ctrl+Z组合键。
- 默认行为:挂起(Suspend)当前运行的程序,将其放到后台。
- 通常用途:将一个正在前台运行的程序放到后台,暂停其执行。
Ctrl+\ (SIGQUIT):
- 产生原因:用户按下Ctrl+\组合键。
- 默认行为:类似于Ctrl+C,但可能会生成core dump文件,用于调试。
- 通常用途:在出现问题时,用户可以使用该信号强制终止程序,并生成core dump以供调试。
17.3.2 调用系统函数产生信号
在UNIX或类UNIX系统中,信号还可以通过系统函数的调用而产生。这种方式通常是由进程主动调用系统函数,而不是依赖于外部的用户或终端输入。以下是一些产生信号的常见系统函数:
kill函数:
- 函数原型:
int kill(pid_t pid, int sig);
- 作用:向指定进程发送特定信号。
- 例子:
kill(pid, SIGTERM);
将向进程ID为pid的进程发送SIGTERM信号,请求它正常终止。raise函数:
- 函数原型:
int raise(int sig);
- 作用:使当前进程给自己发送指定信号。
- 例子:
raise(SIGALRM);
将使当前进程收到SIGALRM信号。alarm函数:
- 函数原型:
unsigned int alarm(unsigned int seconds);
- 作用:设置一个定时器,经过指定秒数后发送SIGALRM信号给调用进程。
- 例子:
alarm(5);
将在5秒后给当前进程发送SIGALRM信号。abort函数:
- 函数原型:
void abort(void);
- 作用:使当前进程接收到
SIGABRT
信号,导致进程异常终止。- 例子:
abort();
调用该函数将导致当前进程异常终止,可能生成包含堆栈跟踪的核心转储文件。
17.3.3 软件条件产生信号
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;
}
17.3.4 硬件异常产生信号
在操作系统中,硬件异常通常指的是由 CPU 探测到的一些错误或异常情况。当这些异常发生时,CPU 会向操作系统发出信号,操作系统会相应地采取措施,例如发送相应的信号给受影响的进程。
以下是一些常见的硬件异常,以及它们在信号中的映射:
-
除零异常(Division by Zero):
- 当程序试图执行除以零的操作时,CPU 探测到除零异常。
- 操作系统通常会将这个异常映射为
SIGFPE
信号(浮点异常)。
-
非法指令异常(Illegal Instruction):
- 当程序尝试执行一条非法的机器指令时,CPU 探测到非法指令异常。
- 操作系统通常会将这个异常映射为
SIGILL
信号。
-
非法地址访问异常(Illegal Address Access):
- 当程序尝试访问未映射到其地址空间的内存区域时,CPU 探测到非法地址访问异常。
- 操作系统通常会将这个异常映射为
SIGSEGV
信号(段错误)。
-
浮点溢出异常(Floating-Point Overflow):
- 当程序中进行的浮点运算结果过大,无法表示时,CPU 探测到浮点溢出异常。
- 操作系统通常会将这个异常映射为
SIGFPE
信号。
这些硬件异常产生信号的方式是由操作系统内核管理的,操作系统负责捕获硬件异常并向受影响的进程发送相应的信号。
这里我们就常见的除零错误,野指针或越界来分解一下从硬件到软件的信号处理
除零错误:
硬件层面: 当 CPU 进行除法运算时,如果除数为零,就会导致除零错误。这是硬件级别的异常,通常由硬件内部的控制逻辑进行检测。
状态标志: CPU 内部有状态标志位,例如溢出标志。如果发生除零错误,会设置相应的状态标志。操作系统会定期检查这些标志,如果发现异常状态,就会采取相应的措施。
信号处理: 操作系统可以通过发送信号的方式通知进程。在除零错误的情况下,通常会发送
SIGFPE
(浮点异常)信号给进程。进程退出: 默认情况下,操作系统可能会终止发生除零错误的进程。但这并非绝对,操作系统的具体行为可能受到进程对信号的处理方式的影响。
死循环: 如果除零错误引发了异常而进程没有适当处理,可能导致程序陷入死循环。这是因为异常没有得到解决,寄存器中的异常标志一直保持未解决状态。
野指针或越界:
虚拟地址和物理地址: 计算机程序中使用的地址是虚拟地址,需要通过页表和内存管理单元(MMU)将其转化为物理地址。MMU 是硬件的一部分,负责地址转换。
异常检测: 当程序尝试访问非法地址(野指针、越界等),MMU 会检测到这一异常,触发硬件异常。
页表: 操作系统通过页表管理虚拟地址到物理地址的映射。非法地址访问可能导致页表中的相应条目不存在,从而引发异常。
操作系统处理: 操作系统可以通过信号通知进程发生了非法地址访问。常见的是
SIGSEGV
(段错误)信号。死循环: 类似于除零错误,如果异常没有被适当处理,程序可能会进入死循环,导致无法正常执行。
总的来说,硬件和操作系统协同工作,通过硬件异常检测和操作系统的信号处理,确保程序在发生错误时能够得到适当的通知,从而采取相应的措施。
对于异常,进程不一定会立即退出,具体行为可能受到信号处理的影响。但是,即使我们不退出,基本上也做不了什么,因为寄存器上中的异常还没有解决,程序会陷入死循环。
17.3.5 【补充】核心转储 Core Dump
Core Dump(核心转储)是在进程发生异常终止时,将进程的内存数据保存到磁盘上的文件。
一般情况下,当进程遇到致命错误(如段错误)时,操作系统会生成一个 Core Dump 文件。这个文件可以被调试器用于事后调试,帮助开发人员定位程序崩溃的原因。通常,Core Dump 文件保存在进程当前工作目录中,文件名为 "core"。
在Linux和类Unix系统上,通过ulimit命令可以控制 Core Dump 文件的生成大小。默认情况下,操作系统可能会禁止生成 Core Dump 文件,因为它们可能包含敏感信息。
在云服务器中,核心转储是默认被关掉的,我们可以通过使用ulimit -a
命令查看当前资源限制的设定。
ulimit -a
在开发和调试阶段,开发人员可以使用ulimit命令来调整 Core Dump 文件的大小限制。例如:
ulimit -c 1024
core文件的大小设置完毕后,就相当于将核心转储功能打开了。
此时如果我们再使用Ctrl+\对进程进行终止,就会发现终止进程后会显示core dumped。
并且会在当前路径下生成一个core文件,该文件以一串数字为后缀,而这一串数字实际上就是发生这一次核心转储的进程的PID。
这里我们写一个除0错误的代码进一步演示:
使用gdb对当前可执行程序进行调试,然后直接使用core-file core文件
命令加载core文件,即可判断出该程序在终止时收到了8号信号
还记得进程等待函数waitpid函数的第二个参数吗:
pid_t waitpid(pid_t pid, int *status, int options);
core dump标志实际上就是用于表示程序崩溃的时候是否进行了核心转储。如果该位是1,则进行了核心转储;0,则没有进行了核心转储
说明一下:
ulimit命令是用来设置用户级资源限制的命令,它会影响当前Shell进程及其子进程。在Unix-like系统中,每个进程都有与之相关的资源限制,这些限制定义了进程在运行时可以消耗的资源的上限。
ulimit命令改变的是Shell进程的Resource Limit,但myproc进程的PCB是由Shell进程复制而来的,所以也具有和Shell进程相同的Resource Limit值。
17.4 信号的阻塞
17.4.1 信号相关常见概念补充
信号递达(Signal Delivery):
- 信号递达指的是实际执行信号处理动作的过程。一旦信号递达,进程就会执行与该信号关联的处理函数或默认动作。
信号未决(Signal Pending):
- 信号从产生到递达之间的状态称为信号未决。即,信号已经产生,但尚未被进程处理。在信号未决状态下,信号可以被阻塞,也可以等待进程处理。
信号阻塞(Signal Blocking):
- 进程可以选择阻塞某个信号,使得被阻塞的信号在进程解除对其阻塞之前,不会递达。这种机制允许进程对特定信号进行控制,以便在某些时候忽略或推迟对该信号的处理。
阻塞状态下的信号保持未决状态:
- 当一个信号被阻塞时,即使信号已经产生,它会一直保持在信号未决状态。直到进程解除对该信号的阻塞,信号才能递达并触发相应的处理动作。
阻塞与忽略的区别:
- 阻塞和忽略是两个不同的概念。当信号被阻塞时,它会保持在未决状态,直到解除阻塞;而当信号被忽略时,它在递达时不会触发任何处理动作。忽略是一种处理信号的方式,而阻塞是一种控制信号递达的方式。
17.4.2 信号在内核的表示
在内核中,每个进程的内核控制块(PCB)中会包含阻塞位图、未决位图以及处理动作表,用于跟踪和管理该进程的信号状态
阻塞位图(Block Bitmap):
- 阻塞位图是一个比特位图,每个比特位表示一个特定的信号。如果某个信号的阻塞位被设置为 1,说明该信号被阻塞;如果为 0,说明该信号没有被阻塞。
未决位图(Pending Bitmap):
- 未决位图也是一个比特位图,每个比特位表示一个特定的信号。如果某个信号的未决位被设置为 1,说明该信号在进程中是未决状态;如果为 0,说明该信号没有产生或已经被处理。
处理动作表(Handler Table):
- 处理动作表是一个函数指针数组,每个数组元素对应一个特定的信号。函数指针指向信号递达时要执行的处理动作,可能是默认动作、忽略动作或用户自定义函数。
- 在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
- SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
- SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。
如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?
POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号。
17.4.3 信号集
信号集(Signal Set)是一个用于表示一组信号状态的数据结构。在 POSIX 操作系统中,通常使用 sigset_t 数据类型来表示信号集。每个信号集包含了系统所支持的所有信号,并使用比特位图的形式表示每个信号的状态。
在信号集中,每个比特位(bit)代表一个特定的信号。如果某个比特位的值为 1,表示该信号在信号集中是有效的(被包含),如果为 0,则表示该信号是无效的(未包含)。
sigset_t:
sigset_t 是一个数据类型,用于表示信号集。信号集是一个集合,其中每个元素对应一个可能的信号。从上面阻塞位图(Block Bitmap)和 未决位图(Pending Bitmap)来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。 因此,未决和阻塞标志可以用相同的数据类型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
:将信号集中的所有比特位都清零,表示该信号集不包含任何信号。sigfillset
:将信号集中的所有比特位都设置为 1,表示该信号集包含所有系统支持的信号。添加和删除信号:
sigaddset
:将指定信号的比特位设置为 1,将该信号添加到信号集中。sigdelset
:将指定信号的比特位清零,将该信号从信号集中删除。查询信号是否包含在信号集中:
sigismember
:用于判断指定信号是否包含在信号集中,返回 1 表示包含,返回 0 表示不包含。
sigprocmask :
函数可以读取或更改进程的信号屏蔽字(阻塞信号集)。
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
参数说明:
- 如果oset是非空指针,则读取进程当前的信号屏蔽字通过oset参数传出。
- 如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。
- 如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。
sigpending:
该函数用于读取当前进程的未决信号集
int sigpending(sigset_t *set);
sigpending函数读取当前进程的未决信号集,并通过 set 参数传出。该函数调用成功返回0,出错返回-1
17.4.4 代码测试
首先我们编写一段程序 signal ,阻塞1~31号信号,并不断的打印pending位图
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
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()
{
signal(2, handler);
sigset_t set, oset;
sigemptyset(&set);
sigemptyset(&oset);
for(int i=1;i<=31;i++)
{
sigaddset(&set, i); //SIGINT
}
sigprocmask(SIG_SETMASK, &set, &oset);
sigset_t pending;
sigemptyset(&pending);
int count = 0;
while (1){
sigpending(&pending); //获取pending
printPending(&pending); //打印pending位图(1表示未决)
sleep(1);
count++;
}
return 0;
}
再编写一个shell脚本,来向运行后的程序 signal 发送1~31 号信号(这里我们避开9号与19号,原因后面会将)
#!/bin/bash
i=1
id=$(pidof signal)
while [ $i -le 31 ]
do
if [ $i -eq 9 ];then
let i++
continue
fi
if [ $i -eq 19 ];then
let i++
continue
fi
kill -$i $id
echo "kill -$i $id"
let i++
sleep 1
done
程序运行后的效果如下
17.5 信号的捕捉
17.5.1 signal:
signal 函数是一个用于处理信号(signals)的函数,通常在UNIX和类UNIX系统上使用。信号是在计算机系统中用于通知进程发生了某些事件的一种机制。例如,当用户按下键盘上的中断键(Ctrl+C),操作系统会向前台进程发送一个中断信号(SIGINT)。程序可以通过注册信号处理函数来捕捉和处理这些信号。
在C语言中,signal 函数的原型为:
#include <signal.h>
void (*signal(int signum, void (*handler)(int)))(int);
这个函数的作用是设置对信号 signum 的处理方式。其中,signum 是信号的编号,handler 是一个指向处理函数的指针。
signal 函数有三种可能的返回值:
- 如果 signum 无效,返回 SIG_ERR。
- 如果 handler 为 SIG_DFL,表示使用默认的信号处理方式,返回当前的信号处理函数。
- 如果 handler 为 SIG_IGN,表示忽略该信号,返回当前的信号处理函数。
以下是一个使用 signal 函数注册信号处理函数的简单示例:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
// 信号处理函数
void signal_handler(int signum) {
printf("Received signal %d\n", signum);
}
int main() {
// 注册信号处理函数
if (signal(SIGINT, signal_handler) == SIG_ERR) {
perror("Error setting up signal handler");
return 1;
}
printf("Press Ctrl+C to send a SIGINT signal\n");
// 进入一个无限循环
while (1) {
sleep(1);
}
return 0;
}
在这个例子中,程序注册了一个处理 SIGINT 信号的处理函数 signal_handler。当用户按下 Ctrl+C 时,操作系统将发送 SIGINT 信号,触发 signal_handler 函数的执行。
17.5.2 sigaction:
sigaction 函数是用于设置和检查信号处理动作的系统调用。它提供了更灵活和可移植的信号处理方式,相较于 signal 函数,sigaction 函数的接口更为强大,可以更精确地控制信号处理。
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
以下是关于 sigaction 函数参数的详细说明:
- signum: 指定信号的编号。
- act: 如果非空,根据 act 修改该信号的处理动作。
- oldact: 如果非空,通过 oldact 传出该信号原来的处理动作。
其中,参数act和oldact都是结构体指针变量,该结构体的定义如下:
struct sigaction {
void (*sa_handler)(int); // 信号处理函数,类似于 signal 函数的第一个参数
void (*sa_sigaction)(int, siginfo_t *, void *); // 实时信号的处理函数
sigset_t sa_mask; // 用于屏蔽的信号集合
int sa_flags; // 一些标志位
void (*sa_restorer)(void); // 未使用
};
- sa_handler: 与 signal 函数中的信号处理函数类似,用于处理信号的函数指针。
- sa_sigaction: 用于处理实时信号的函数指针。如果设置了 sa_sigaction,则 sa_handler 会被忽略。
- sa_mask: 一个信号集合,用于指定在处理当前信号时需要阻塞的其他信号。当信号处理函数执行时,系统会自动将 sa_mask 中的信号添加到进程的信号屏蔽字中,以避免处理函数的递归调用。
- sa_flags: 一些标志位,用于设置 sigaction 的行为。一般将其设置为0。
- sa_restorer: 未使用,忽略即可。
下面是一个简单的使用 sigaction 函数的例子:
#include <stdio.h>
#include <signal.h>
// 信号处理函数
void signal_handler(int signum) {
printf("Received signal %d\n", signum);
}
int main() {
struct sigaction sa;
// 设置信号处理函数
sa.sa_handler = signal_handler;
sa.sa_flags = 0;
// 注册信号处理函数
if (sigaction(SIGINT, &sa, NULL) == -1) {
perror("Error setting up sigaction");
return 1;
}
printf("Press Ctrl+C to send a SIGINT signal\n");
// 进入一个无限循环
while (1);
return 0;
}
在这个例子中,sigaction 函数被用来注册 SIGINT 信号的处理函数,该处理函数为 signal_handler。
17.5.3 内核态与用户态
在计算机系统中,操作系统内核和用户程序运行在不同的特权级别或者说不同的模式下,这被称为内核态(Kernel Mode)和用户态(User Mode)。这种划分是为了提高系统的稳定性和安全性。
内核态(Kernel Mode):
- 内核态是操作系统运行的特权级别,具有最高的权限。
- 在内核态下,操作系统可以直接访问所有硬件资源、执行敏感指令,并对系统进行完全的控制。
- 在内核态下运行的代码被称为内核代码。
用户态(User Mode):
- 用户态是普通应用程序运行的特权级别,权限较低。
- 在用户态下,应用程序不能直接访问硬件资源,不能执行一些敏感指令,只能通过操作系统提供的服务来访问硬件和执行需要特权的操作。
- 在用户态下运行的代码被称为用户代码。
切换模式是通过中断(Interrupt)或异常(Exception)来触发的。当应用程序需要操作系统的服务时(例如,申请内存、进行文件操作等),会触发一个中断或异常,将控制权从用户态切换到内核态。在完成服务后,操作系统会再次将控制权切回用户态。
这种划分有助于保护操作系统的稳定性,因为用户程序在用户态下运行时受到较为严格的限制,无法直接影响到操作系统的核心部分。操作系统通过中断、异常等机制掌握对硬件的控制,确保系统的安全性和可靠性。
17.5.4 重谈进程地址空间
首先简单回顾下 进程地址空间 的相关知识:
- 进程地址空间 是虚拟的,依靠 页表+
MMU
机制 与真实的地址空间建立映射关系 - 每个进程都有自己的 进程地址空间,不同 进程地址空间 中地址可能冲突,但实际上地址是独立的
- 进程地址空间 可以让进程以统一的视角看待自己的代码和数据
- 所有进程的用户空间 [0, 3] GB 是不一样的,并且每个进程都要有自己的 用户级页表 进行不同的映射
- 所有进程的内核空间 [3, 4] GB 是一样的,每个进程都可以看到同一张内核级页表,从而进行统一的映射,看到同一个 操作系统
- 操作系统运行 的本质其实就是在该进程的 内核空间内运行的(最终映射的都是同一块区域)
- 系统调用 的本质其实就是在调用库中对应的方法后,通过内核空间中的地址进行跳转调用
当我们执行系统调用时,会从用户态切换到内核态。这个切换是通过 CPU 中的 CR3 寄存器实现的。
- 当 CR3 寄存器中的值为用户态对应的页表时,表示正在执行用户的代码,即处于用户态。
- 当 CR3 寄存器中的值为内核态对应的页表时,表示正在执行操作系统的代码,即处于内核态。
通过修改 CR3 寄存器中的值,操作系统可以在用户态和内核态之间进行切换。这个寄存器的值的变化涉及到地址空间的切换,确保用户程序无法直接访问内核空间的代码和数据。
17.5.5 信号捕捉流程图
- 当我们在执行主控制流程的时候,可能因为某些情况而陷入内核,当内核处理完毕准备返回用户态时,就需要进行信号pending的检查。(此时仍处于内核态,有权力查看当前进程的pending位图)
- 在查看pending位图时,如果发现有未决信号,并且该信号没有被阻塞,那么此时就需要该信号进行处理。
- 如果待处理信号的处理动作是默认或者忽略,则执行该信号的处理动作后清除对应的pending标志位,如果没有新的信号要递达,就直接返回用户态,从主控制流程中上次被中断的地方继续向下执行即可。
- 但如果待处理信号是自定义捕捉的,即该信号的处理动作是由用户提供的,那么处理该信号时就需要先返回用户态执行对应的自定义处理动作,执行完后再通过特殊的系统调用sigreturn再次陷入内核并清除对应的pending标志位,如果没有新的信号要递达,就直接返回用户态,继续执行主控制流程的代码。
对于上图的信号捕捉,为了方便好记可以画个简化的图:
当识别到信号的处理动作是自定义时,能直接在内核态执行用户空间的代码吗?
当检测到信号的处理动作是自定义时,理论上是可以在内核态执行用户空间的代码的,因为内核态拥有极高的权限。然而,绝对不能采用这样的设计方案。
如果允许在内核态直接执行用户空间的代码,用户就有可能在其代码中包含一些非法操作,比如尝试清空数据库等。虽然在用户态时没有足够的权限执行这类敏感操作,但如果在内核态执行这种非法代码,由于内核态权限的高度,这些操作就有可能被成功执行,导致潜在的系统数据损坏或丧失。
简而言之,不应该让操作系统直接执行用户提供的代码,因为操作系统无法信任任何用户提供的代码。为了确保系统的稳定性和安全性,操作系统采用了分层设计的原则,限制了用户空间和内核空间之间的直接交互。在信号处理的背景下,通常选择在用户态执行用户提供的信号处理函数,通过明确定义的系统调用接口和权限机制来控制用户代码的执行,以维护系统的完整性。
17.6 SIGCHLD
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/wait.h>
#include <unistd.h>
void handler(int sig) {
pid_t id;
while ((id = waitpid(-1, NULL, WNOHANG)) > 0) {
printf("等待子进程成功:%d\n", id);
}
printf("子进程退出:%d\n", getpid());
}
int main() {
signal(SIGCHLD, handler);
pid_t cid;
if ((cid = fork()) == 0) { // 子进程
printf("子进程:%d\n", getpid());
sleep(3);
exit(1);
}
while (1) {
printf("父进程正在执行一些操作!\n");
sleep(1);
}
return 0;
}
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 用sigaction将SIGCHLD的处理动作 置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不 会产生僵尸进程,也不会通知父进程。系统默认的忽 略动作和用户用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;
}
此时子进程在终止时会自动被清理掉,不会产生僵尸进程,也不会通知父进程。