信号概念
信号是进程之间发送异步信息的一种方式`在Linux
命令行中,我们可以通过ctrl + c
来终止一个前台运行的进程,其实这就是一个发送信号的行为。我们按下ctrl + c
是在shel
l进程中,而被终止的进程,是在前台运行的另外一个进程。因此信号是一种进程之间的通知方式。
可以通过指令kill -l
来查询信号:
以上就是Linux中的全部信号,它们分为两个区间:[1, 31]
和[34, 64]
,也就是说没有32,33
这两个个信号,虽然信号的最大编号为64
,但实际上只有62
个信号。
[1, 31]
:这些信号称为非实时信号,当进程收到这些信号后,可以自己选择合适的时候处理[34, 64]
:这些信号称为实时信号,当进程收到这些信号后,必须立马处理
由于现在的操作系统基本都是分时操作系统,因此实时信号其实是不符合设计理念的,几乎用不到实时信号,我们只学习非实时信号。
上图中,所有信号都是大写的单词,在C/C++中,一般来说宏就是大写的,其实信号名就是宏。
那么进程收到信号后要怎么处理呢?
进程有三种处理信号的方式:
- 忽略此信号
- 执行信号的默认处理函数
- 执行信号的自定义处理函数,这种方式也称为信号捕捉
可以通过man 7 signal
来查看信号的默认处理行为:
在开头,可以看到如下页面:
其中Term
,Ign
,Core
,Stop
,Cont
就是信号处理的默认行为:
Term
: 默认操作是终止进程Ign
: 默认操作是忽略信号Core
: 默认操作是终止进程并转储核心Stop
: 默认操作是暂停进程Cont
: 默认操作是,如果该进程当前已暂停,则继续该进程
以上五种
其中Term
和Core
都是终止进程,但是Core
会额外进行 core dump (核心转储)
再往下翻阅,就可以看到每个信号的描述:
各列的含义如下:
Signal
:信号的名称Standard
:该信号在哪一个标准中提出Action
:进程收到该信号后的默认处理行为Comment
:对信号的简单描述
信号常见处理方式:
忽略此信号 -> 即 Action
为 Ign
执行信号的默认处理函数 -> 即Action
不为 Ign
提供一个信号处理函数,要求内核在处理该信号时从内核态
切换到用户态
执行这个处理函数,这种方式称为捕捉(Catch)
信号。
自定义信号处理方式
signal
函数可以自定义信号的处理方式
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
参数说明
signum
:要捕获的信号的编号。例如,SIGINT、SIGTERM 等。
handler
:指向信号处理函数的指针。该函数在接收到指定信号时被调用。处理函数的原型应为
void handler(int signum)
。
返回值:
- 成功时,返回之前的信号处理程序的地址。
- 失败时,返回
SIG_ERR
,并设置errno
。
示例:
void handler(int sig)
{
cout << "get sig: " << sig << endl;
}
int main()
{
signal(2, handler);
while (true)
{
cout << "waiting for sig..." << endl;
sleep(1);
}
return 0;
}
通过自定义2号信号的执行函数handler,此后进程收到2号信号,只会执行打印,
而(2) SIGINT
就是ctrl + C
发送的信号,在终端输入ctrl + C
进行验证
输出结果:
可以看到进程并没有终止
通过上面的文档,我们可以得出下面的结论:
- 在信号没有发生的时候,经常已经知道怎么进行处理了
- 进程可以识别信号
- 信号到来时,如果暂时不能处理,需要进行临时保存
- 信号到来可以不进行处理,在合适的时候处理
- 信号的产生是随时的,无法准确预料,所以信号是异步发送的
信号产生
通过键盘产生信号
Ctrl+C
对应 (2) SIGINT
的默认处理动作是终止进程,Ctrl+\
对应(3) SIGQUIT
的默认处理动作是终止进程并且并生成一个 Core Dump
文件。这个文件包含了程序在崩溃时的内存状态,可以用于调试。Ctrl+z
对应(20) SIGTSTP
信号,通常用于暂停程序的执行。
关于
Core Dump(核心转储)
:当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部 保存到磁盘上,文件名通常是
core
,这叫做Core Dump
。进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)
。一个进程允许产生多大的core文件取决于进程的Resource Limit
(这个信息保存在PCB
中)。默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit
命令改变这个限制,允许产生core文件。 首先用ulimit命令改变Shell进程的Resource Limit
,允许core文件最大为1024 Kb:ulimit -c 1024
前台运行一个死循环程序,通过 ctrl+\
发送(3) SIGQUIT
信号,可以看到生成了core
文件
ulimit
命令改变了Shell
进程的Resource Limit
,testsig
进程的PCB由Shell进程复制而来,所以也具 有和Shell进程相同的Resource Limit
值,这样就可以产生Core Dump了。
使用gdb
分析core文件:
gdb <程序名> <core_file>
可以看到,程序由于
SIGQUIT
信号退出,另外,可以在gdb
中通过bt
命令查看崩溃时的调用栈,可以看出是在main中调用sleep时崩溃的
硬件中断
+ OS如何得知键盘输入了数据?键盘输入是由键盘驱动和OS联合解释的,输入字符会将其放在显示器文件的缓冲区,输入组合键会被解释为命令,OS得知外设是否进行数据传输,不是轮询查询,而是通过硬件中断
技术!
OS在开机时,会产生一张中断向量表
,在表中提前注册对软硬件的操作方法,存放各种中断处理程序实际是一个函数指针数组,保存中断后的对应处理方法
当用户在键盘上按下一个键时,键盘硬件会生成一个中断请求信号,键盘控制器将中断请求信号发送给 8259 中断控制器
。8259 中断控制器接收到来自键盘的中断请求,并将其记录在中断请求寄存器中。8259 通过对应针脚向 CPU 发送中断请求信号,CPU在REQ
寄存器中存储对应的中断请求标志,CPU 保存当前执行状态和上下文,根据中断类型(在此情况下是键盘中断)在OS中查找中断向量表
,确定对应的中断处理程序地址
,跳转到相应的中断处理程序,在这里,是唤醒阻塞的read(0)的进程,从此操作系统就得知键盘输入了数据,并可以将数据读取到键盘文件的缓冲区。
- ctrl+c 的组合如何被解释为命令并发送信号给进程?
进程收到信号时,会先进行保存,保存在进程PCB的uint32_t pending
位图中(31个非实时信号),给进程发送信号,写入信号实际是用户通过OS的系统调用由OS向task_struct
的内核数据写入,OS
在收到键盘输入的控制命令后,就会向位图对应的比特位写入,由此向进程发送了信号。
无论信号产生的方式有多少种,最终都是通过OS向进程写入信号!`
由软件条件产生信号
kill
首先在后台执行死循环程序,然后用kill
命令给它发(11) SIGSEGV
信号。一个命令后面加个&
可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。
- 786879是testsig进程的id。之所以要再次回车才显示
Segmentation fault
,是因为在786879进程终止掉 之前已经回到了Shell提示符等待用户输入下一条命令,Shell不希望Segmentation fault信息和用户的输入交错在一起,所以等用户输入命令之后才显示。 - 指定发送某种信号的kill命令可以有多种写法,上面的命令还可以写成
kill -SIGSEGV 786879
或kill -11 786879
, 以往遇到的段错误都是由非法内存访问产生的,而这个程序本身没错,给它发SIGSEGV也能产生段错误。
kill命令是通过调用<font style="color:#AE146E;background-color:rgb(249, 242, 244);">kill
函数实现的`,实际是以进程间通信的方式发送信号。kill函数可以给一个指定的进程发送指定的信号。
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
``raise``
`raise`函数可以给当前进程发送指定的信号(自己给自己发信号)。#include <signal.h>
int raise(int sig);
前面两个函数都是成功返回0
,错误返回-1
。
``abort``
`abort`函数会向进程发送`(6) SIGABRT`信号,用于强制终止当前进程并生成核心转储(core dump)#include <stdlib.h>
void abort(void);
(13) SIGPIPE
是一种由软件条件产生的信号,在“管道”中已经介绍过了。本节主要介绍alarm
函数 和SIGALRM
信号。
调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds
秒之后给当前进程发(14) SIGALRM
信号, 该信号的默认处理动作是终止当前进程。
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
如果seconds
不为0,会设置对应的秒数后发送信号,返回之前设置的定时器剩余时间并覆盖,没有之前的定时器返回0
如果seconds
设置为0,会取消设定的闹钟,返回剩余秒数
int main()
{
alarm(5);
sleep(3);
int ret = alarm(10);
cout << "alarm: 5, sleep: 3, alarm: 10, ret: " << ret << endl;
sleep(5);
ret = alarm(0);
cout << "sleep: 3, alarm: 0, ret: " << ret << endl;
alarm(5);
cout<<"alarm: 5"<<endl;
sleep(10);
return 0;
}
硬件异常产生信号
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。常见的硬件异常有:
段错误(Segmentation Fault):
- 信号:
(11) SIGSEGV
- 原因:
CR2
寄存器用于存储最近一次发生的页面错误(page fault)时的虚拟地址。CR3
寄存器用于存储当前正在使用的页表基址。如果进程使用了一个野指针,虚拟地址在转化为物理地址,由OS+CPU(MMU)
完成,此时CR3页表和寄存器中存放的要修改的地址交由MMU硬件进行转化,MMU转化失败,就会将错误地址存放在CR2中,CPU会切换到内核态,通知OS进程出现异常,OS检查后发现CR2异常,就会设置进程PCB信号位图(11) SIGSEGV
对应位
非法指令(Illegal Instruction):
- 信号:
(4) SIGILL
- 原因:当程序尝试执行无效的机器指令时,会触发此信号。
浮点异常(Floating Point Exception):
- 信号:
(8) SIGFPE
- 原因:在 x86 架构中,状态寄存器主要是
EFLAGS
寄存器(32 位)或RFLAGS
寄存器(64 位),主要功能是指示当前的 CPU 状态以及控制程序的执行流。 EFLAGS 寄存器包含多个标志位,包括一个OF
(Overflow Flag)溢出标志,表示算术运算是否产生溢出。发生初零/溢出错误时,OF被设置为1,计算错误就能反映在CPU寄存器(硬件)上,CPU会切换到内核态,通知OS进程出现异常,OS检查后发现OF标志位异常,就会设置进程PCB信号位图(8) SIGFPE
对应位
中断(Trap):
- 信号:
(5) SIGTRAP
- 原因:通常由调试器或程序中的断点指令引发。
信号阻塞
信号集
前面提到,信号到来时,如果不能立即处理,可以保存起来,在合适的时候处理,Linux中通过3个位图保存收到的信号处理信号的3种状态:
信号递达(Delivery)
:进程处理信号的过程称为递达
,递达可以是执行默认处理函数,或者执行自定义的信号处理函数,忽略信号Ign
也是一种处理信号的方式,也算递达
信号未决(Pending)
:当进程收到一个信号,但是还没有处理这个信号,称为未决
信号阻塞(Block)
:当一个信号被阻塞,就会一直保留在未决
状态,不会执行任何处理函数,无法递达
对应三种状态Linux中进程的PCB维护了3个表pending
, block
, handler
pending
:该表的本质是一个位图
,也称为未决信号集
。当进程接收到一个信号,会把对应的比特位修改为1,表示进程已经接收到该信号
block
:该表的本质是一个位图
,也称为阻塞信号集
。当进程收到信号,在pending
中把对应的位修改1
,此时就要经过block
,如果block
中对应的位为1
,表示该信号被阻塞,不会被递达,penidng
上的该位一直保持为1
;如果block
中对应的位为0
,表示该信号未被阻塞,进程挑选合适的时候递达该信号。
handler
:该表本质是一个函数指针数组,指向信号的处理函数。如果时机合适,进程会检测pending
表和block
表,然后检测出已经接收到的信号,若该信号未被阻塞,执行对应信号的处理函数,并把pending
中的该位变回0
,表示该信号已经处理完了。
以上表还有以下特性:
- 当用户通过
signal()
修改信号的默认处理方式,其实就是在修改这个handler
内部的函数指针。 - 如果连续收到多个相同的
非实时信号
,此时pending
位图只会记录一次,如果是实时信号
,则会把收到的所有信号放进队列中,每个信号都会被处理。
操作信号集
简单了解了这三张表后,我们又要如何操纵这三种表呢?对于handler
表,可以通过signal()
函数来修改内部的处理函数,而block
和pending
叫做信号集
,本质是位图
,要做的无非就是修改某一个位是0
还是1
,因此这两个表的操作是一样的。
操作这两个信号集,都依赖一个类型sigset_t
,其包含在<signal.h>
中,Linux中该类型的源码如下:
typedef struct
{
unsigned long int __val[_SIGSET_NWORDS];
} __sigset_t;
typedef __sigset_t sigset_t;
也就是说,sigset_t
本质是一个结构体,结构体内部只有一个成员,且该成员是一个数组。这个数组就是用于存储位图的工具。__SIGSET_NWORDS
是常量,表示需要的位数,从宏观上看,可以理解为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中的所有比特位变为0sigfillset
:使信号集set中的所有比特位变为1sigaddset
:使信号集set的第signum位变为1sigdelset
:使信号集set的第signum位变为0sigismember
:检测信号集set的第signum位是0还是1
前四个函数的返回值都是:如果成功返回0,失败返回-1。
也就是说,我们可以通过以上函数,来操作信号集这个位图,但通过这个函数操作的信号集,既不是block也不是pending,它目前只是一个进程中的变量而已。
那么我们接下来要做的,就是把我们自己创建并设置的信号集,与进程的block和pending交互。
sigprocmask
`sigprocmask`函数用于读取或者更改进程的`block`,原型如下:#include <signal.h>
/* Prototype for the glibc wrapper function */
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
参数:
how
: 指定操作的方式,可以是以下值之一:
SIG_BLOCK
:将set
中为1
的比特位添加到block
中,相当于block = block | set
SIG_UNBLOCK
:将set
中为1
的比特位,从block
中删除,相当于block = block & ~set
SIG_SETMASK
:直接将block
设置成当前set
的样子,相当于block = set
set
:指向自己维护的信号集sigse_t
的指针
oldset
:输出型参数,用于接收修改前的block
(如果不需要可以传递 NULL
)
返回值:
成功时返回 0
,失败时返回 -1
,并设置 errno
以指示错误类型。
sigpending
`sigpending`函数用于读取进程的`pending`,原型如下:#include <signal.h>
int sigpending(sigset_t *set);
参数:
set:
输出型参数,将pending
传入到set
中
返回值:
成功时返回 0
,失败时返回 -1
,并设置 errno
以指示错误类型。
接下来我们综合以上的所有接口,进行几个实验:
测试
block
可以阻塞信号,信号确实保存在pending
中
int main()
{
sigset_t set;
sigemptyset(&set);
sigaddset(&set,2);
sigprocmask(SIG_BLOCK,&set,nullptr);
while(1)
{
sigset_t pending;
sigpending(&pending);
for(int i=31;i>0;i--)
{
if(sigismember(&pending,i))
cout<<"1";
else
cout<<"0";
}
cout<<endl;
sleep(1);
}
}
这个pending
是用于保存进程中的未决信号的,我们已经把(2) SIGINT
阻塞了,如果预测没有错误的话,那么输入ctrl + C
时,pending
的第二位会变成1,这就说明我们已经接收到该信号了。但是block
把(2) SIGNAL
给阻塞了,导致其一直处于pending
中,无法被递达,所以pending的第二位会一直是1。
输出结果:
可以看到,输入ctrl + C
之后,第二位从0
变成了1
,并且持续为1
,即信号被阻塞了。
检测是否所有信号可以被阻塞
void shouSet(sigset_t* set)
{
for(int i=31;i>0;i--)
{
if(sigismember(set,i))
cout<<1;
else
cout<<0;
}
cout<<endl;
}
int main()
{
sigset_t set,oldset;
sigemptyset(&oldset);
sigfillset(&set);
sigprocmask(SIG_SETMASK,&set,&oldset); //设置block
cout<<"oldset: ";
shouSet(&oldset);
sigprocmask(SIG_SETMASK,&set,&oldset); //获取我们设置后的block
cout<<"newset: ";
shouSet(&oldset);
return 0;
}
将set
的所有位变成1
,然后添加到block
中,此时old_block
为系统默认block
,第二次设置block
,通过oldblock
获得我们设置后的系统block
。
输出结果:
第一次输出为全0,说明默认block不阻塞任何信号,第二次输出我们虽然传入了全1,但是可以看到有两个信号(9) SIGKIILL
和(19) SIGSTOP
没有被阻塞, 这种设计确保了用户和管理员在任何情况下都可以控制进程的执行,维护系统的正常运行。
捕捉信号后,信号递达时,
block
表和pending
表是什么状态
void handler(int signum)
{
cout<<"catch signal: "<<signum<<endl;
sigset_t pending;
sigpending(&pending);
cout<<"pending: ";
shouSet(&pending);
sigset_t block,oldblock;
sigemptyset(&block);
sigprocmask(SIG_BLOCK,&block,&oldblock);
cout<<"block: ";
shouSet(&oldblock);
}
int main()
{
signal(2,handler);
raise(2);
return 0;
}
设置信号(2) SIGINT
的自定义处理函数,然后在handler
中查看捕捉信号后的block
表和pending
表
输出结果:
可以发现,虽然发送了信号,pending
却变回0
了,说明信号捕捉后会先清除,再递达
另外可以发现我们没有主动阻塞这个信号,但是block
的对应位是1
,也就是信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字。
void handler(int signum)
{
cout << "catch signal: " << signum << endl;
while (1)
{
sigset_t pending;
sigpending(&pending);
cout << "pending: ";
shouSet(&pending);
sigset_t block, oldblock;
sigemptyset(&block);
// 不修改block
sigprocmask(SIG_BLOCK, &block, &oldblock);
// 解除block
// sigprocmask(SIG_SETMASK, &block, &oldblock);
cout << "block: ";
shouSet(&oldblock);
sleep(3);
}
}
int main()
{
signal(2, handler);
raise(2);
return 0;
}
在信号执行自定义处理程序的过程中,因为对应信号被阻塞
,所以再发送对应信号,信号会处于未决
状态,不会重新进入处理程序
如果我们在handler
中解除对应信号的阻塞
,再发送对应信号,可以重新进入处理程序
信号捕捉后将对应的信号阻塞设置为 1
可以防止信号处理程序的重入
,确保信号处理的原子性
。即 当一个信号处理程序正在执行时,不会有其他同类信号被递送到该进程。当信号处理函数返回时自动恢复原来的信号屏蔽字,这这一机制使得信号处理更加安全和可靠,也是信号处理设计中的一个重要原则。
sigaction
刚刚我们说:操作系统在处理信号的时候,会把对应的位阻塞。sigaction
是一个用于设置信号处理程序的系统调用,提供了比 signal
更加灵活和可靠的信号处理机制。它允许程序指定如何处理特定的信号,包括自定义处理函数、信号掩码和其他选项。 原型如下:
#include <signal.h>
int sigaction(int signum, const struct sigaction *act,struct sigaction *oldact);
参数:
signum
:要设置处理程序的信号编号。
act
:指向一个 struct sigaction
结构的指针,用于指定新的信号处理行为。
oldact
:指向一个 struct sigaction
结构的指针,用于保存之前的信号处理行为(如果不需要可以传递 NULL
)。
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);
};
sa_handler
:指向信号处理函数。如果设置为 SIG_IGN
,则忽略信号;如果设置为 SIG_DFL
,则使用默认处理。
sa_mask
:在信号处理期间要阻塞的信号集,避免信号处理程序被打断。
sa_flags
:可以设置以下标志(一般设为0
):
- SA_RESTART:使被信号中断的系统调用自动重新启动。
- SA_SIGINFO:使用 sa_sigaction 指定信号处理函数,以便接收更多信息。
sa_sigaction:如果 SA_SIGINFO 被设置,使用此字段指定信号处理函数,它可以接收更多参数。
sa_restorer:在早期的 Linux 内核中用于指定一个恢复函数,以恢复用户态的状态,在现代的 POSIX 标准中,这个字段通常不再使用
示例:
void handler(int signum)
{
cout << "catch signal: " << signum << endl;
sigset_t block, oldblock;
sigemptyset(&block);
sigprocmask(SIG_BLOCK, &block, &oldblock);
cout << "block: ";
shouSet(&oldblock);
}
int main()
{
struct sigaction act;
act.sa_handler = handler;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
for (int i = 0; i < 6; i++)
sigaddset(&act.sa_mask, i);
sigaction(2, &act, nullptr);
raise(2);
return 0;
}
以上示例中,先定义了struct sigaction act
,用于传入信号处理方式。其sa_handler
设为处理函数handler
,sa_flags
设为0
即可。而后将sa_mask
设为一个前五位为1
的block位图
输出结果:
可以看到,此时进程会阻塞前五个信号
信号捕捉与处理
前面提过,在合适的时候,操作系统会处理信号,那么什么时候才是合适的时候?也就是说,什么时候操作系统会去处理`pending`中的信号?为了解决这个问题,我们要先了解操作系统的用户态
与内核态
。
用户态与内核态
`CPU指令集 (Code Segment) `权限:指令集是CPU实现软件指挥硬件的媒介,具体来说每一条汇编语句都对应一个CPU指令
,指令的集合叫CPU指令集
,使用CPU指令集需要对应权限,<font style="color:rgb(48, 48, 48);">权限分级是硬件级别的,以 ``Inter CPU
为例,Inter 把使用 CPU指令集<font style="color:rgb(48, 48, 48);">需要的权限由高到低划分为`` ring 0
- ring 3`<font style="color:rgb(48, 48, 48);"> 共4级,其中
ring 0<font style="color:rgb(48, 48, 48);"> 权限最高,可以使用所有
CPU指令集,``ring 3<font style="color:rgb(48, 48, 48);"> 权限最低,仅能使用常规
CPU指令集,不能使用操作硬件资源的CPU指令集<font style="color:rgb(48, 48, 48);">,比如
IO 读写、网卡访问、申请内存都不行,**<font style="color:#0C68CA;">Linux 系统仅采用 ring 0 和 ring 3 这两个权限。
**CPU中的CS寄存器
存储当前代码段的选择子(Selector), 指向与正在执行的代码相关的段描述符
,段描述符中包含特权级(DPL)
等属性。
- 执行内核空间的代码,具有
ring 0
保护级别,即内核态
,有对硬件的所有操作权限,可以执行所有CPU指令集,访问任意地址的内存,在内核模式下的任何异常都是灾难性的` - 在用户模式下,具有
ring 3
保护级别,即用户态
,代码没有对硬件的直接控制权限,也没有内核空间地址的访问权限,即时程序发生崩溃也是可以恢复的`
进程地址空间被分为两部分:用户空间
和内核空间
,每个空间都有自己的页表去映射内存,内核空间使用的页表叫做内核级页表
,用户空间使用的页表叫做用户级页表
。`
内核空间
中的虚拟地址指向操作系统的代码和数据,操作系统本身也是一个软件,有自己的代码和数据,任何用户访问操作系统的行为,都是切换到内核态通过内核空间(地址空间的[3, 4]GB
)执行操作系统的代码。** 进程地址空间和页表是每个进程独有的,但内核部分的映射是共享的,OS的代码和数据在每个进程都有映射。**所有进程的内核空间(
[3, 4]GB)都是相同的,每个进程都可以通过自己的内核空间执行系统调用。进程从用户态切换到内核态的过程叫做
陷入内核,从内核态切换到用户态的过程叫做
返回用户态`。
进程从用户态切换到进程态主要有以下几种情况:
系统调用(System Call)
用户态程序需要执行一些需要内核权限的操作,例如读写文件,创建进程,访问网络等
硬件中断(Hardware Interrupt)
硬件设备(如键盘、鼠标、网络接口等)发生中断,CPU收到中断保存当前进程上下文,切换到内核态执行中断处理程序
异常处理(Exception Handling)
程序执行过程中发生错误(初零,野指针等),CPU会切换到内核态,通知OS进行处理,发送信号
定时器中断(Clock Interrupt)
操作系统使用定时器中断进行时间管理和进程调度,定时器到达预设时间后,CPU切换到内核态,操作系统检查当前进程状态决定是否进行上下文切换。
另外,之前提到的写时拷贝,只会拷贝用户空间的数据,例如C语言的用户级缓冲区,不会拷贝内核级缓冲区的数据
信号捕捉的时机
当操作系统因为某些原因陷入内核后,会先处理用户的需求,当处理完需求后,就会检测当前是否有需要处理的信号。检测的结果有三种:
- 没有要处理的信号,直接返回用户态
- 有要处理的信号,且该信号的处理方式是默认处理函数,那么直接在内核态处理该信号,处理完毕后返回用户态
- 有要处理的信号,且该信号的处理方式是
用户自定义函数
,那么要先返回用户态
执行自定义函数,执行完函数再次陷入内核
,最后再返回用户态
如果信号的处理方式是默认处理方式,此时直接在内核态
执行代码,主要有两个原因:
- 信号的默认处理方式,是操作系统自己提供的,因此不会有安全性问题,可以直接以内核态的高级权限执行
- 内核态允许执行
特权操作
(如修改进程状态、唤醒被阻塞的进程等),这是用户态无法直接完成的 - 减少上下文切换的开销
,<font style="color:rgb(48, 48, 48);">用户态和内核态切换的开销大
,包括保留用户态现场(上下文、寄存器、用户栈等),复制用户态参数,用户栈切到内核栈,进入内核态,额外的检查(因为内核代码对用户不信任),执行内核态代码,复制内核态代码执行结果,回到用户态,恢复用户态现场(上下文、寄存器、用户栈等)一系列操作`
当信号的处理方式是用户自定义函数,那么要先切换回用户态执行,这是因为用户自定义的handler函数,其安全性是不确定的。sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。 sighandler函数返回后自动执行特殊的系统调用
sigreturn`再次进入内核态。
还有一个问题,执行完handler函数后,CPU已经处于用户态了,为什么还要先到内核态
,再回到用户态
?
当用户态进程陷入内核态时,内核会保存用户态进程的上下文信息,包括:
- 寄存器值:例如程序计数器(PC)、堆栈指针(SP)、通用寄存器等。
- 内存状态:例如内存页表、虚拟地址空间等。
- 其他状态:例如进程状态、信号掩码等。
内核通过保存这些上下文信息,可以记录用户态进程执行到哪个位置,以及该进程的运行状态。当内核处理完用户态进程的请求后,会恢复用户态进程的上下文信息,并将控制权返回给用户态进程。由于恢复过程涉及操作系统的管理和资源控制,因此只能在内核态完成这些操作。用户态进程恢复执行后,会从之前中断的位置继续执行。
到这里就可以正式给出问题的答案了:每一次在从内核态返回用户态之前,操作系统都会处理信号。`
由于信号是一种异步事件,可能在进程执行的任何时刻到达。所以每次返回用户态前都进行信号检查。
OS如何正常运行`
操作系统会不断进行内核态和用户态的切换,保证系统的正常运行信号技术就是通过软件的方式,模拟硬件中断**<font style="color:#0C68CA;">。
**硬件通过高频的、短时间的给CPU发送中断,让CPU不停的处理中断,在中断向量表中执行对应的方法(例如响应外设、进程调度),让OS运行起来,这个过程叫做OS的周期时钟中断
,OS是一个死循环,不断接受外部的硬件中断。
源码示例(小型操作系统):
在入口函数初始化完毕后,通过for(;;) pause();
进入死循环,等待中断
中断向量表:
在初始化中有一个sched_init()
,调度程序初始化,其中会执行set_intr_gate(0x20, &timer_interrupt);
设置中断信号20,int timer_interrupt(void);
即为汇编语言写的时钟中断处理程序
- 源码中系统调用的触发过程
用户程序 -> 调用 read() ->if(cs&0x3)==0
通过CS寄存器检查是否处于内核态,将系统调用号存放在eax中,触发中断 (int 0x80) (这个 int 指 interrupt )-> 查找中断向量表 -> 执行 sys_read() 处理函数
在sched_init()
调度程序初始化中也有一个set_system_gate(0x80, &system_call)
,表示设置系统调用中断门,int system_call(void);
即为系统调用中断处理程序,实现在kernel/syztesm_call.s文件,在文件的_system_call:
中,有一个push eax
,把系统调用号入栈,而后call [_sys_call_table+eax*4]``_sys_call_table
即系统调用函数指针表,数组的下标即为系统调用号
可重入函数
在上图中,main函数调用insert函数向一个链表head中插入节点node1,插入操作分两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后向链表中插入两个节点,但最后只有一个节点真正插入链表中了。
像这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入
,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数
,反之如果一个函数不依赖于任何静态或全局变量的状态,行为完全依赖于输入参数,只访问自己的局部变量和参数,不修改共享资源,则称为可重入(Reentrant) 函数
。
如果一个函数符合以下条件之一则是不可重入的:
- 调用了
malloc
或free
,因为malloc也是用全局链表
来管理堆的。 - 调用了
标准I/O库
函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
volatile
``volatile``(译为不稳定的)是一种关键字,主要用于告知编译器某个变量的值可能会在任何时间被其他因素改变,从而阻止编译器进行优化。编译器通常会假设一个变量在某个作用域内的值不会改变,但如果该变量是 ``volatile``,编译器会在 每次使用该变量时强制从内存中读取其值`,而不是使用寄存器中的缓存值。#include <stdio.h>
#include <signal.h>
int flag = 0;
void handler(int sig)
{
printf("chage flag 0 to 1\n");
flag = 1;
}
int main()
{
signal(2, handler);
while(!flag);//故意这样写,编译器会默认对我们的代码自动优化
printf("flag changed\n");
return 0;
}
在通常情况下,输入ctrl+c
命令,CPU每次进行逻辑判断时都会从内存中读数据,执行handler方法改变flag,可以退出循环,打印结果
如果我们执行gcc时使用 -O2
选项进行优化(如果加上#
,选项会被注释,命令行中#
通常表示注释的开始)
可以发现,即使执行了自定义动作修改了flag,while循环条件依然成立,没有退出循环,这是CPU发现逻辑判断后并没有其他代码,就进行了优化,只通过寄存器
中的数据判断while循环检查的flag,并不是内存中最新的flag,这就是寄存器屏蔽了内存,产生数据二异性
的问题。
接下来尝试使用volatile
可以看到读取到了内存中最新的flag
SIGCHLD
进程一章讲过用`wait`和`waitpid`函数清理僵尸进程,父进程可以`阻塞`等待子进程结束,也可以`非阻塞轮询查询`是否有子进程结束等待清理。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。其实,子进程在终止时会给父进程发 (17) SIGCHLD
信号,该信号的默认处理动作是忽略,父进程可以自定义(17) SIGCHLD
信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait
清理子进程即可。
void handler(int sig)
{
pid_t id;
while (true)
{
pid_t rid = waitpid(-1, nullptr, WNOHANG);
if (rid > 0)
cout << "wait child success, pid: " << rid << endl;
else if (rid <= 0)
break;
}
cout << "wait sub process done" << endl;
}
int main()
{
//signal(SIGCHLD, handler);
signal(SIGCHLD, SIG_IGN);
for (int i = 0; i < 100; i++)
{
pid_t id = fork();
if (id == 0)
{
int cnt = 5;
while (cnt--)
{
cout << "I am child process, pid:" << getpid() << endl;
sleep(1);
}
cout << "child process exit" << endl;
exit(0);
}
}
while (true)
sleep(1);
}
输出结果:
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用sigaction将SIGCHLD
的处理动作置为SIG_IGN
,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用