🍎作者:阿润菜菜
📖专栏:Linux系统编程
文章目录
- 一、预备知识
- 二、信号产生
- 1. 通过终端按键产生信号
- 1.1 signal()
- 1.2 core dump标志位、核心存储文件
- 2.通过系统调用向进程发送信号
- 3.由软件条件产生信号
- 3.1 alarm函数和SIGALRM信号
- 3.2 使用alarm()系统接口 验证 IO的效率 --- 很慢
- 4. 硬件异常产生信号
- 三、信号保存
- 1.认识信号的常见概念
- 2. 内核角度看看进程是怎么保存信号的
- 3. 信号集操作函数
- 3.1 sigset_t
- 3.2 sigprocmask() --- 修改block表
- 3.3 sigpending() -- 读取当前进程的未决信号集
- 四、信号捕捉(处理)
- 1. 用户态和内核态
- 1.1 理解用户态和内核态
- 1.1 底层 --- CPU里面的CR3寄存器
- 2.自定义hander方法呢?用户态权限去调用
- 3. sigaction() --- 对hander表的操作
- sigaction()函数和signal()函数 二者区别呢?
- 五、三个补充细节
- 1. 可重入函数
- 2. volatile
- 2.1 编译器优化问题
- 2.2 如何理解编译器的优化? ---- 在汇编阶段把代码改了
- SIGCHLD信号
一、预备知识
我们想要杀死某个后台进程的时候,无法通过ctrl+c热键终止进程时,我们就会通过kill -9的命令来杀死信号。
查看信号也比较简单,通过kill -l
命令就可以查看所有信号的种类,虽然最大的信号编号是64,但实际上所有信号只有62个信号,1-31是普通信号,34-64是实时信号,本文不对实时信号做讨论,只讨论普通信号,感兴趣的老铁可以自己下去研究一下。
以过红绿灯为例,其实在红绿灯出现之前,我们大脑里已经有了红灯停绿灯行的意识了,对于进程来说也是如此,进程在收到信号之前就已经知道怎么处理对应信号了,已经保存了对应处理的方法;那绿灯亮起,此时有朋友打来视频电话,我们也可以选择忽略该绿灯信号,暂时不通行,去忙别的事情,对应进程来说如果不能里面处理这个信号,那进程就要有信号保存的能力!
我们Linux操作时用户输入命令,在Shell下启动一个前台进程。.用户按下Ctrl-C ,这个键盘输入产生一个硬件中断,被OS获取,解释成信号,发送给目标前台进程, 前台进程因为收到信号,进而引起进程退出。这就是技术角度的信号处理过程。
注意:
- Ctrl-C 产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程
结束就可以接受新的命令,启动新的进程。 - Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl-C 这种控制键产生
的信号。 - 前台进程在运行过程中用户随时可能按下 Ctrl-C 而产生一个信号,也就是说该进程的用户空间代码执行
到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步
(Asynchronous)的。
所以有了下面的预备知识:
两个先导结论:
- 进程在没有收到信号之前,就已经知道怎么处理信号了
- 进程不能立马处理这个信号,就需要进程具有信号保存的能力
二、信号产生
1. 通过终端按键产生信号
1.1 signal()
我们最常用的发送信号方式就是一个热键ctrl+c,这个组合键其实会被操作系统解释成2号信号SIGINT,通过man 7 signal
命令就可以查看到对应的信号和其默认处理行为等等信息。
这里SIGINT的默认处理动作是终止进程 — Term,上图介绍了各种处理动作。但进程不是立即处理该信号的,而是先将信号保存下来,信号保存部分后面讲解;同时其实我们可以定义收到信号后的处理动作的 ---- 我们利用signal()系统调用
signal()是一个系统调用,用于设置或处理信号。
我们知道信号是一种通知进程发生了某种事件的机制。当进程收到一个信号时,它可以执行以下三种操作之一:
- 忽略信号,不做任何处理。(后面讲解)
- 执行默认动作,通常是终止进程或忽略信号。
- 调用一个自定义的信号处理函数,执行特定的操作。
signal()函数的原型是:
#include <signal.h>
void (*signal(int signum, void (*handler)(int)))(int);
它接受两个参数:signum
是要处理的信号的编号,handler
是要执行的信号处理函数的指针。它返回一个函数指针,指向之前设置的信号处理函数,或者SIG_ERR表示出错。所以这里我们可以自定义一个函数方法,当进程接收到某个信号后,利用signal函数调用我们的自定义信号处理函数。
注意:我们显示写signal函数其实相当于注册了一个信号处理时的自定义行为,然后这个自定义行为handler不会平白无故被调用的,只有当对应信号发送给进程时,这个handler才会被调用,否则这个函数是永远不会被调用的。
那么handler方法什么时候调用? 当2号信号产生的时候,就被调用,相当于提前设定好了方法。
同时注意调用singal并没有调用handler方法,只是更改了2号信号函数指针的映射关系。
例如,如果要捕获SIGINT信号(由键盘中断产生),并调用一个名为my_handler的函数来处理它,可以这样写:
#include <signal.h>
#include <stdio.h>
void my_handler(int signum) {
printf("Received signal %d\n", signum);
}
int main() {
signal(SIGINT, my_handler); // 设置SIGINT的处理函数为my_handler
while (1) {
// do something
}
return 0;
}
signo:自动填充对应的信号
如果要恢复SIGINT的默认动作,可以这样写:
signal(SIGINT, SIG_DFL); // 设置SIGINT的处理函数为默认动作
如果要忽略SIGINT信号,可以这样写:
signal(SIGINT, SIG_IGN); // 设置SIGINT的处理函数为忽略
注意:9号信号不可被自定义!!
Linux支持多种信号,每种信号都有一个名称和一个编号。我们可以在signal.h头文件中查看它们的定义。一些常见的信号有:
- SIGTERM:终止信号,通常用来请求进程正常退出。
- SIGKILL:杀死信号,强制进程立即退出,不能被忽略或捕获。
- SIGSEGV:段错误信号,由于无效的内存访问产生。
- SIGALRM:闹钟信号,由alarm()函数设置的定时器到期时产生。(后面讲解)
- SIGCHLD:子进程终止信号,当一个子进程退出或停止时产生。
我们可以使用kill()函数或kill命令来向一个进程发送一个信号。例如,如果要向进程1234发送SIGTERM信号,可以这样写:
#include <signal.h>
#include <unistd.h>
int main() {
kill(1234, SIGTERM); // 向进程1234发送SIGTERM信号
return 0;
}
在命令行中输入:
kill -TERM 1234 # 向进程1234发送SIGTERM信号
一个热键是ctrl+\,这个组合键会被操作系统解析为3号信号SIGQUIT,这个信号的默认处理行为是Core,除终止进程外还会进行核心转储。Core于Term有什么不同?
1.2 core dump标志位、核心存储文件
core dump 标志位是一个退出信息中的一个位,用来表示进程是否产生了core dump文件。core dump文件是一个包含进程内存内容的文件,当进程意外终止时,由操作系统生成,用于后期调试。core dump标志位为1,表示进程产生了core dump文件;为0,表示没有产生。core dump标志位可以通过kernel.core_pattern这个系统参数来设定,这个参数指定了core dump文件的保存位置和格式。例如,执行命令sysctl -w kernel.core_pattern=/var/crash/core.%u.%e.%p
,就可以将core dump文件保存在/var/crash目录下,文件名为core.用户ID.可执行文件名.进程ID。
什么是核心存储文件?
核心存储文件,也叫core dump文件,是一个当程序运行过程中发生异常,程序异常退出时,由操作系统把程序当前的内存状况存储在一个文件中的文件。核心存储文件包含了程序运行时的内存状态、寄存器状态、堆栈指针、内存管理信息以及各个函数使用堆栈信息等等。核心存储文件的作用是用于后期调试,可以通过gdb等工具分析核心存储文件,定位程序出错的原因和位置。
所以core dump文件有什么用? 事后调试!
生产环境中服务器为什么要关闭核心存储文件,有以下几个原因:
- 核心存储文件通常很大,占用大量的磁盘空间,如果不及时清理,可能会影响服务器的性能和稳定性³。
- 核心存储文件可能包含敏感的数据,比如用户信息、密码、密钥等,如果被恶意获取或泄露,可能会造成安全风险³。
- 生产环境中的程序应该经过充分的测试和优化,不应该频繁出现异常退出的情况,如果出现了,应该及时修复,并通过日志或监控等手段进行分析和定位³。
简单说服务器重启可是会疯狂写core文件的,所以要关!
控制core dump文件开关的方法我们一般使用ulimit命令的-c选项
,可以设置core文件的生成开关和大小限制。例如,ulimit -c 0
表示关闭core dump功能,ulimit -c unlimited
表示不限制core文件的大小,ulimit -c 1024
表示限制core文件的大小为1024KB。这种方法是临时的,系统重启后会失效。
另外补充一个知识点,linux规定,当用户在和shell交互时,默认只能有一个前台进程,所以当我们自己编写的程序运行时,bash进程就会自动由前台进程转换为后台进程。
2.通过系统调用向进程发送信号
其实除上面那种用组合键或者是手动的通过kill指令加信号编号的方式给进程发送信号外,我们还可以通过系统调用的方式给进程发送信号。操作系统有向进程发送信号的能力,但是他并没有这个权力,操作系统的能力是为用户提供的,用户才有发送信号的权力,操作系统通过给用户提供系统调用赋予用户让OS向进程发送信号的权力。就像我们将来可能都会变成程序员,我们有写代码的能力,我们的能力是服务于公司或老板的,让我们写代码的权力来自于老板。
kill系统调用和raise、abort库函数都是用来发送信号的函数,但是有一些区别:
- kill系统调用可以向任意进程或进程组发送任意信号,只要发送者有足够的权限。它的原型是:
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
它接受两个参数:pid是要发送信号的进程或进程组的标识,sig是要发送的信号的编号。它返回0表示成功,-1表示失败,并设置errno。
- raise库函数可以向自己发送一个信号,相当于kill(getpid(), sig)。它的原型是:
- 参数sig是要发送的信号的编号,可以参考kill -l命令查看信号列表
#include <signal.h>
int raise(int sig);
它接受一个参数:sig是要发送的信号的编号。它返回0表示成功,非0表示失败。
- abort库函数可以向自己发送一个SIGABRT信号,通常用来表示程序异常终止。它的原型是:
#include <stdlib.h>
void abort(void);
它没有参数和返回值,也不能被阻塞或忽略。如果SIGABRT信号没有被捕获或处理,程序会终止并生成一个core文件。
我们上面所说的raise()和abort()
都在man 3号手册,这代表他们都是库函数,而kill在2号手册,是纯正的系统调用。但3号手册的库函数可以分为两类,底层封装了系统调用的库函数和没有封装系统调用的库函数,很明显,raise和abort库函数就是底层封装了kill系统调用的库函数。就连kill指令底层其实也是封装的kill系统调用来实现的。
6号信号和2号信号的区别是什么?
- 6号信号是SIGABRT,表示进程异常终止,通常由abort()函数产生。它的默认动作是终止进程并生成一个core文件,用于保存进程的状态信息,方便调试。它不能被忽略或捕获。
- 2号信号是SIGINT,表示键盘中断,通常由Ctrl+C产生。它的默认动作是终止进程,但是它可以被忽略或捕获,并执行自定义的处理函数。
如果你在循环中使用abort()函数,那么程序会立即终止,并且不会再执行循环。如果你在循环中使用raise(SIGINT)函数,那么程序会收到2号信号,如果你没有设置信号处理函数,那么程序也会终止;如果你设置了信号处理函数,那么程序会执行你的处理函数,并且可能会继续执行循环。abort用于终止进程,类似于exit(),因为有了exit(),所以实际中我们很少用abort()
3.由软件条件产生信号
3.1 alarm函数和SIGALRM信号
- alarm函数是用来设置一个定时器,告诉内核在指定的秒数之后给当前进程发送一个SIGALRM信号。它的原型是:
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
它接受一个参数:seconds表示定时器的时间间隔,单位为秒。如果seconds为0,则取消之前设置的定时器。它返回0或者是以前设定的闹钟时间还剩余的秒数。
- SIGALRM信号是由alarm函数产生的定时信号,表示定时器时间到达。它的默认动作是终止当前进程,但是它可以被忽略或捕获,并执行自定义的处理函数。
我们可以使用alarm函数和SIGALRM信号来实现定时任务,例如定时执行某个操作或定时检查某个状态。需要注意的是,由于alarm函数只能设置一个定时器,因此如果需要同时设置多个定时器,需要使用其他方法,例如使用select()或poll()函数来等待多个信号。
3.2 使用alarm()系统接口 验证 IO的效率 — 很慢
通过alarm闹钟,我们可以计算出1s内CPU能够累加数据多少次,下面测试的代码中其实分了两种情况进行测试,一种是每次将累加数据之后的结果打印到显示器上,一种是在1s内只进行数据的累加,等到1s到了的时候,我们捕捉信号在handler里面进行累加后数据的值的打印
实验结果二者差了大概1w多倍数,可以看到一旦访问外设CPU的执行速度就会慢下来,因为等待硬件就绪很慢,硬件就绪的时间和CPU计算的时间根本不在一个量级。
注意:我们可以设置剩余的alarm时间 — 一个进程只能设置一个闹钟 — 会覆盖,闹钟会通过pid标识是谁设置的
那为什么alarm闹钟是软件条件异常呢?
闹钟实际就是软件,他不就是数据结构和属性的集合吗?所以闹钟本身就是软件,当前进程可以设定闹钟,那么其他进程也可以设定闹钟,所以操作系统内部一定会存在很多的闹钟,那么操作系统要不要对这些闹钟进行管理呢?当然要,管理的方式就是先描述,再组织。所以闹钟在操作系统中实际就是内核数据结构,此内核数据结构用于描述闹钟,组织的最常见方式就是通过链表,但闹钟的组织方式也可以通过堆,也就是优先级队列来实现。
下面是闹钟内核数据结构的伪代码,其内部有一个闹钟响铃的时间,表示在当前进程的时间戳下,经过所传参数second秒后,闹钟就会响铃,这个响铃时间即为当前进程时间戳+second参数大小。
另外闹钟还需要一个PCB结构体指针,用于和设置闹钟的进程进行关联,在闹钟响了之后,便于操作系统向对应进程发送14号信号SIGALRM,此信号默认处理动作也是终止当前进程。
OS会周期性的检查这些闹钟,也就是通过遍历链表的方式,检查当前时间戳超过了哪个闹钟数据结构中的when时间,一旦超过,说明此闹钟到达设定时间,那么这个时候操作系统就该给闹钟对应的进程发送14号信号,如何找到这个进程呢?通过alarm类型的结构体指针便可以拿到alarm结构体的内容,其结构体中有一个字段便是PCB指针,通过PCB指针就可以找到闹钟对应的进程了。
除链表这样经典的组织方式之外,另一种组织方式就是优先级队列,priority_queue,实际就是堆结构,按照闹钟结构体中的when的大小建大堆,如果堆顶闹钟的时间小于当前进程时间戳,则说明整个堆中所有的闹钟均为达到响铃的条件。如果堆顶闹钟的时间大于当前进程时间戳,那就要给堆顶闹钟对应进程发送14号信号了,检查过后再pop堆顶元素,重新看下一个堆顶闹钟是否超时,大概就是这么一个逻辑。
4. 硬件异常产生信号
为什么代码除0会收到警告?其实作系统检测到后会立即向进程PCB位图中写入信号(通俗叫发送信号) — 8号信号 SIGFPE
int main()
{
int n =3;
cout << n /0 <<endl;
return 0;
}
代码除0会收到警告,是因为除0是一种硬件异常,也就是由处理器检测到的非法操作。当处理器执行除0指令时,它会产生一个中断信号,通知操作系统发生了错误。操作系统收到中断信号后,会根据中断向量表找到对应的中断处理程序,然后执行它。在这个过程中,操作系统会向进程的PCB位图中写入信号(通俗叫发送信号),这个信号就是8号信号 SIGFPE**,表示浮点异常**。如果进程没有捕捉或忽略这个信号,那么它的默认动作是终止进程,并打印出错误信息。所以,代码除0会收到警告,是因为操作系统根据硬件异常产生了一个信号,并执行了默认的终止动作。
那这样那我们利用signal()系统调用捕捉8号信号,为什么循环打印呢?因为MMU硬件报错一直都存在,没有被修复;那为什么没有退出进程?因为我们捕捉信号自定义处理方法了
MMU在哪里呢? 被集成到CPU里了,硬件!MMU就是帮助地址转换,帮助页表映射的硬件!
空指针会引起程序崩溃 – 11号信号
所以程序崩溃的根本原因不一定是硬件异常,也可能是软件异常,比如内存访问越界,空指针解引用,非法指令等。操作系统向目标进程发送信号,只是一种通知进程发生了异常的方式,并不一定导致程序崩溃。如果进程能够捕捉或忽略信号,或者执行自定义的处理动作,那么程序可能还能继续运行。所以,程序崩溃的根本原因是进程无法处理异常的情况。
那既然这么多信号默认处理都是干掉进程,那要这么多信号干什么?进程的死法不一样,我们人类要知道啊,就像是你定义的退出码一样。
总结思考一下:
1.上面所说的所有信号产生,最终都要有OS来进行执行,为什么?OS是进程的管理者
2.信号的处理是否是立即处理的?在合适的时候
3.信号如果不是被立即处理,那么信号是否需要暂时被进程记录下来?记录在哪里最合适呢?
4.一个进程在没有收到信号的时候,能否能知道,自己应该对合法信号作何处理呢?
5.如何理解OS向进程发送信号?能否描述一下完整的发送处理过程?
三、信号保存
1.认识信号的常见概念
首先认识一下什么是信号递达和未决?
信号递达是指信号从产生到被进程处理的过程。
信号未决是指信号已经产生,但还没有被进程处理的状态。
同时进程可以选择阻塞和忽略某个信号,这是什么意思=?
信号阻塞是指进程可以选择不立即处理某个信号,而是将其保持在未决状态,直到解除阻塞为止。
信号忽略是指进程对某个信号的处理方式之一,即不执行任何操作。
注意:
这三者的关系是:阻塞和忽略都可以影响信号的递达,但阻塞只是暂时延迟信号的处理,而忽略是永久忽视信号的处理。
信号未决和上面三者的关系是:信号未决状态由信号阻塞和信号产生共同决定,只有当信号被阻塞且产生时,才会处于未决状态。信号未决状态的作用是保证信号不会丢失,只要解除阻塞,就可以处理信号。信号忽略不会影响信号的未决状态,但会影响信号的递达动作。
一个信号未决的例子:
假设有一个进程,它阻塞了 SIGINT 信号,也就是键盘输入中断信号。当用户按下 Ctrl-C 时,该信号会产生,但不会立即递达给进程,而是保持在未决状态。如果进程解除了对 SIGINT 的阻塞,那么该信号就会被处理,通常是终止进程。如果进程忽略了 SIGINT 信号,那么该信号就不会被处理,即使解除了阻塞。
2. 内核角度看看进程是怎么保存信号的
其实在进程的内核角度OS会帮助我们维护三种表:
- pending表:信号未决表,记录进程当前未处理的信号,每个进程都有一个信号未决表,用一个32位的整数表示,每一位对应一个信号,如果该位为1,表示该信号未处理,否则为0。它是一种位图结构。用比特位的位置,表示哪一个信号,比特位的内容代表是否收到该信号。
- block表:记录进程当前屏蔽的信号,也是一个32位的整数,每一位对应一个信号,如果该位为1,表示该信号被屏蔽,否则为0。同时也是一种位图结构
- handler表:函数指针数组 ---- 下标 - 信号编号 指向 - 处理动作 : 记录进程对每个信号的处理方式,有三种可能:忽略、执行默认动作或者调用自定义函数
所以进程进程能识别信号,并提前知道怎么处理信号!
再介绍一下三种处理方式:
SIG_DFL — 默认 : 比如2号信号的默认处理动作就是终止进程
SIG_IGN — 忽略 :注意kill和stop信号不能被忽略
SIG_ERR — 报错
其实这三个处理动作,再内核角度也是一种宏定义
对于OS来说默认和忽略都好处理,难处理的其实是用户自定义信号处理方式
我们要实现对信号控制,那怎么控制呢?下面我们看看信号集操作函数
3. 信号集操作函数
3.1 sigset_t
- sigset_t是一个数据类型,用来表示信号集,也就是一组信号的集合。信号集可以用来存储进程的阻塞信号和未决信号,阻塞信号是指被屏蔽的信号,未决信号是指产生了但还没有处理的信号。
- sigset_t类型的变量可以用一些函数来操作,例如:
- sigemptyset:清空信号集,使其不包含任何信号。
- sigfillset:填充信号集,使其包含所有可能的信号。
- sigaddset:在信号集中添加一个指定的信号。
- sigdelset:在信号集中删除一个指定的信号。
- sigismember:判断一个指定的信号是否在信号集中。
#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset (sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
我们用sigset_t ---- 来控制pending表和block表 — 内部其实是一种地址转化策略 – 位图结构,对应的地址转换策略是:
- _sigset_t类型的变量实际上是一个位图,每个位对应一个信号,如果该位为1,表示该信号在信号集中,否则为0。
- _sigset_t类型的变量通常有多个数值组成,因为一个数值的位数可能不足以表示所有可能的信号。例如,在bits/sigset.h中定义了_sigset_t类型为一个包含32个无符号长整数的结构体,每个无符号长整数有32位,所以总共可以表示1024种不同的信号。
- _sigset_t类型的变量在内存中的地址可以通过指针来访问,例如,在sigprocmask函数中,第二个参数是一个指向_sigset_t类型变量的指针,用来设置或改变进程的阻塞信号集¹。第三个参数也是一个指向_sigset_t类型变量的指针,用来保存进程原来的阻塞信号集。
3.2 sigprocmask() — 修改block表
我们可以调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
//返回值:若成功则为0,若出错则为-1
这里我们有两个sigset_t 数据类型的信号集变量,分别是set和oset,为什么这么设置?我们用set来保存当前要操作的信号集,sigpromask函数会自动将旧的信号集通过返回型参数oset参数传出。
如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则 更改进程的信
号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后
根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值
而且在实际操作中,我们应该注意一个重要的问题就是:我们要把信号集设置到实际的进程当中!
这种只是在栈上设置
通过调用接口去进程设置
3.3 sigpending() – 读取当前进程的未决信号集
#include <signal.h>
sigpending
//读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。
程序运行时,每秒钟把各信号的未决状态打印一遍,由于我们阻塞了SIGINT信号,按Ctrl-C将会 使SIGINT信号处于未决
状态,按Ctrl-\仍然可以终止程序,因为SIGQUIT信号没有阻塞
四、信号捕捉(处理)
上面说了,进程不能马上处理信号,而需要把信号保存起来,那信号什么时候处理合适呢?
那先回答信号可以被立即处理吗? 如果一个信号之前被block,当他解除block的时候,该对应的信号会被立即递达!
为什么? 因为信号的产生是异步的,当前进程可能在做更重要的事情!那什么时候hi是合适的时候呢?当进程从内核态
切换回用户态
的时候,进程会在OS的指导下,进行信号的检测(三种处理方式:默认、忽略、自定义)与处理!
那我们看看什么是用户态和内核态?
1. 用户态和内核态
1.1 理解用户态和内核态
需要知道的是,我们所写的代码在编译后运行时,其实是以用户态的身份去跑的,但用户态的代码难免会访问内核资源或硬件资源,而这些资源都是由操作系统管理的,所以想要访问这些资源则一定绕不开操作系统,那么操作系统就需要提供系统调用接口,让用户以操作系统的意愿去访问内核或硬件资源,因为操作系统不相信任何用户,所以操作系统必须自己实现系统调用,这个实现代码也就是我们常说的内核代码,然后把代码的接口提供给用户,用户只能通过这些系统调用接口来访问,不能自己随意访问内核或硬件资源。
所以例如printf() write() read() getpid() waitpid()等等接口,前部分需要访问显示器或键盘等硬件,后部分需要访问内核资源PCB,这些接口的底层一定是离不开系统调用接口的,因为他们都直接或间接的访问了内核或硬件资源。
再比如stl容器的各个接口,这些接口中有没有某些接口底层一定调用的也是系统调用呢?当然是有的!所有的stl容器都需要扩容,仅凭这一点就可以确定他们底层要调用系统调用了,因为扩容实际上就是在访问物理内存这一硬件资源,实际是先访问mm_struct,然后再通过MMU去访问内存硬件资源,那这些接口也一定绕不开操作系统,因为操作系统是软硬件资源的管理者,那这些接口底层也一定封装了系统调用。
(实际上按照我个人理解来看,访问硬件资源本质还是访问内核资源,因为所有的硬件都需要被管理,操作系统会在内核里面创建对应硬件的内核数据结构,对其进行描述和组织,所以你访问硬件说到底还是访问内核资源)
当代码运行到系统调用接口时,要执行对应的内核代码了,程序能否以用户态的身份去执行系统调用的内核代码呢?这当然是不可以的!因为在用户态下,进程只能访问受操作系统授权的用户空间的代码,用户态的进程运行级别太低,内核并不相信用户,所以如果想要执行内核代码,则进程的运行级别必须由用户态切为内核态,内核态下,进程可以访问内核代码或其他内核资源,等到系统调用结束之后,当然也不能以内核态的身份去执行用户态的代码,因为用户态的代码有可能被恶意利用去攻击操作系统,而内核态的执行权限大,所以在系统调用结束后,为防止发生意外,进程的运行级别还需要由内核态切换为用户态,此时如果某些代码想要攻击操作系统,用户态的执行权限是不够的,他无法访问任何内核资源或硬件,自然就保证了系统的安全性。
当调用系统调用接口,也就是执行内核代码时,我们称进程陷入了内核态,由于执行系统调用时和执行之后各需要进行一次身份的切换,所以系统调用往往要费时间一些,所以应尽量避免频繁调用系统调用接口,因为这会降低程序运行的效率。
所以stl的空间配置器在实际开空间的时候,往往要给用户多扩容一些,因为他怕你稍微还需要多用一些空间时再次调用系统调用,而这样会降低程序运行的效率。
总结一下:
进程中的用户态和内核态是指进程在执行时所处的不同权限和资源访问范围。用户态是最低权限级别,只能使用常规的CPU指令集和低位的虚拟地址空间,不能直接操作硬件资源和内核数据结构。内核态是最高权限级别,可以使用所有的CPU指令集和全部的虚拟地址空间,可以直接控制硬件资源和内核数据结构。
进程从用户态切换到内核态的主要方式有三种:
- 系统调用:用户态进程主动请求操作系统提供的服务,通过特定的中断指令(如Linux的int 80h)触发内核代码的执行。
- 异常:用户态进程在执行过程中发生了意外情况,如缺页异常、除零异常等,导致CPU转入异常处理程序。
- 外围设备中断:外围设备在完成用户请求的操作后,向CPU发送中断信号,使CPU暂停当前指令,转而执行中断处理程序。
进程从内核态切换回用户态的主要方式有两种:
- 系统调用返回:内核代码完成用户请求的服务后,将结果复制给用户进程,并恢复用户进程的现场,返回到系统调用的下一条指令。
- 中断返回:中断处理程序完成对外围设备或异常的处理后,恢复被中断的用户进程的现场,返回到中断发生时的指令。
我们知道32位下用户进程的内核空间大小是4GB,那是如何构成的呢?画图解释
所以所谓系统调用的本质就是:如同调用.so中的方法,直接在进程自己的进程空间内进行函数跳转并返回即可!
1.1 底层 — CPU里面的CR3寄存器
在linux系统中,当用户进程调用系统调用时,会提前执行一个int 0x80汇编指令(也称为中断指令),此指令会触发一个软中断(也称为陷阱),这个指令会让处理器从用户态切换为内核态,便于内核能够访问进程的上下文数据(这个上下文数据就是内核资源),其实内核访问进程的上下文数据还是通过处理器来实现的,不过此时处理器已经切换为内核态,能够取到相应的进程上下文数据
CPU中寄存器大致可以分为可见寄存器和不可见寄存器,其中有一个特殊的寄存器叫做CR3寄存器,他便为不可见寄存器,用户是无法对其进行修改的。还有一些其他的寄存器比如EBX EDI ESI等(我们这里方便叙述用cur寄存器来替代),保存的是指向当前运行进程的PCB指针
实际上这个CR3寄存器内部存储的是页表的地址,当进程运行级别是用户态时,这个CR3寄存器内部存储的是用户级页表的物理地址,当进程运行级别是内核态时,这个CR3寄存器内部存储的是内核级页表的物理地址。
通过这个CR3寄存器存储内容的变化,就可以实现进程运行级别的切换。=
这个页表地址有那么牛吗?变一变CR3存储的页表地址就能实现进程运行级别的切换?页表能有这么厉害呢?没毛病!页表确实挺牛的!你想要访问内核资源,这些内核数据结构或代码可能位于物理地址空间的不同位置上,所以想要找到他们就必须通过内核级页表,那么MMU进行地址转换时,会去CR3寄存器内部取内核级页表的地址,通过这个内核级页表才能实现内核资源的访问,因为内核级页表存储了内核资源从虚拟地址到物理地址转换的映射关系。
在进程切换时,操作系统会将新的进程的页目录表的物理地址加载到CR3寄存器中,MMU会根据新的页目录表地址进行虚拟到物理地址的转换。
实际上进程运行级别的切换,说到底还是处理器由用户态切换为内核态,或由内核态切换为用户态,你可以这么理解,进程在CPU上运行,如果此时处理器是用户态级别,那么处理器的寄存器存储的内容什么的是不包括任何进程的内核资源的,处理器无法取到进程中PCB,mm_struct,页表,文件描述符表,block信号集……等等信息,只有当处理器为内核态级别的时候,他就可以取到进程的内核资源了,并将这些资源加载到寄存器里面,那么内核就可以通过CPU的寄存器读取到进程的内核资源,进程如果想要执行内核代码,CPU也可以通过进程内部的内核空间找到对应的内核代码并执行。
所以与其说成是进程的运行级别的切换,不如说成是处理器级别的切换,不过处理器级别的切换底层还是通过CR3寄存器存储内容发生变化来实现的。
OS的本质是什么? OS是一个软件,本质是一个死循环。(开机了就不会自动关机)
OS时钟硬件 — 主板上的纽扣电池为计数器提供递增计时,另一方面作用是每隔很短的时间向OS发送时钟中断 - OS要执行对应的中断处理方法
2.自定义hander方法呢?用户态权限去调用
信号处理的自定义handler方法是用户为特定的信号注册的回调函数,用于在信号到达时执行相应的操作。信号处理的自定义handler方法需要用户态权限去调用,主要有以下几个原因:
- 用户态权限比内核态权限更低,更安全。如果自定义handler方法在内核态执行,可能会造成内核数据结构或硬件资源的破坏或泄露。
- 用户态权限比内核态权限更灵活,更方便。如果自定义handler方法在内核态执行,可能会受到内核代码规范或限制的约束,不能使用一些用户态的库函数或系统调用。
- 用户态权限比内核态权限更高效,更快速。如果自定义handler方法在内核态执行,可能会增加用户态和内核态之间的切换次数和开销,降低系统性能。
因此,信号处理的自定义handler方法一般在用户态权限下调用,除非有特殊的需求或目的。
下面画了一张图,帮助大家理解信号捕捉递达(处理行为是自定义行为)的完整流程,从左上角开始 到 再回到左上角的一个过程.
3. sigaction() — 对hander表的操作
信号处理的sigaction()函数用于检查或修改与指定信号相关联的处理动作,或同时执行这两种操作。它的函数原型如下:
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
其中,signum参数指出要捕获的信号类型,act参数指定新的信号处理方式,oldact参数输出先前信号的处理方式(如果不为NULL的话)。函数返回值为0表示成功,-1表示有错误发生。
sigaction()函数的第二个参数和第三个参数都是struct sigaction类型的指针,该结构体用来描述对信号的处理,定义如下:
struct sigaction {
void (*sa_handler)(int); // 信号处理函数
void (*sa_sigaction)(int, siginfo_t *, void *); // 另一个信号处理函数,可以获得更多信息
sigset_t sa_mask; // 在信号处理函数执行期间需要被屏蔽的信号集合
int sa_flags; // 用来设置信号处理的其他相关操作,如SA_RESETHAND, SA_RESTART等
void (*sa_restorer)(void); // 已经废弃的数据域,不要使用
};
sigaction()函数和signal()函数 二者区别呢?
sigaction()函数和signal()函数都是用于注册信号处理函数的函数,但是它们有以下几点区别:
- signal()函数比sigaction()函数简单,但是signal()函数注册的信号在处理函数被调用之前会把信号的处理函数指针恢复为默认值,而sigaction()函数注册的信号在处理函数被调用之后不会恢复处理函数指针,除非设置了SA_RESETHAND标志。这样,signal()函数就可能会丢失信号或者不能处理重复的信号,而sigaction()函数就可以做处理。
- signal()函数在调用过程中不支持信号阻塞,而sigaction()函数在处理函数执行期间可以阻塞某些信号,特别是当某个信号被处理时,它自身会被自动放入进程的信号掩码,因此在处理函数执行期间这个信号不会再次发生。这样就可以避免了信号重入或者信号丢失的问题。
- sigaction()函数提供了比signal()函数更多的功能,可以设置更多的信号处理方式,如忽略信号,使用默认处理方式,使用自定义处理函数等。而且sigaction()函数可以使用sa_sigaction成员作为信号处理函数,该函数有三个参数,可以获得关于信号的更多信息,如发送进程的PID等。
下面是一个使用sigaction()和signal()的代码示例:
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
void show_handler(int sig) {
printf("I got signal %d\n", sig);
int i;
for(i = 0; i < 5; i++) {
printf("i = %d\n", i);
sleep(1);
}
}
int main(void) {
int i = 0;
struct sigaction act, oldact;
act.sa_handler = show_handler; // 信号处理函数
sigaddset(&act.sa_mask, SIGQUIT); // 在信号处理函数执行期间阻塞SIGQUIT
act.sa_flags = SA_RESETHAND | SA_NODEFER; // 设置标志
// act.sa_flags = 0; // 不设置标志
sigaction(SIGINT, &act, &oldact); // 使用sigaction注册SIGINT
// signal(SIGINT, show_handler); // 使用signal注册SIGINT
while(1) {
sleep(1);
printf("sleeping %d\n", i);
i++;
}
}
运行结果如下:
$ ./a.out
sleeping 0
sleeping 1
sleeping 2
sleeping 3
^CI got signal 2 # 按下Ctrl+C发送SIGINT
i = 0
i = 1
i = 2
^\Quit (core dumped) # 按下Ctrl+\发送SIGQUIT
如果使用sigaction注册SIGINT,并且设置了SA_RESETHAND和SA_NODEFER标志,则在接收到SIGINT后,会执行show_handler函数,并且在执行期间阻塞SIGQUIT。同时,SIGINT的处理方式会被重置为默认值,即终止进程。因此,在show_handler执行完毕后,再按下Ctrl+C就会直接退出程序。
如果使用signal注册SIGINT,则在接收到SIGINT后,也会执行show_handler函数,但是在执行期间不会阻塞SIGQUIT。同时,SIGINT的处理方式会被恢复为show_handler。因此,在show_handler执行过程中,再按下Ctrl+C或者Ctrl+\都不会有任何效果。
如果使用sigaction注册SIGINT,并且不设置任何标志,则在接收到SIGINT后,也会执行show_handler函数,并且在执行期间阻塞SIGQUIT和SIGINT。同时,SIGINT的处理方式不会改变。因此,在show_handler执行完毕后,再按下Ctrl+C还会继续执行show_handler。
五、三个补充细节
1. 可重入函数
main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因
为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的 两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。想一下,为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?
可重入函数的特点是:
- 不使用全局变量或静态变量,或者使用互斥锁或信号量来保护全局变量或静态变量。
- 不调用malloc()或free()等可能修改堆的函数,或者使用自己的内存管理机制。
- 不调用标准I/O函数,或者使用线程安全的I/O函数。
- 不返回指向静态数据的指针,所有数据都由函数的调用者提供。
- 不调用不可重入函数,或者保证不可重入函数在同一时刻只能被一个任务或线程调用。
可重入函数的优点是:
- 可以提高程序的并发性能和可靠性,避免数据竞争和死锁等问题。
- 可以方便地在多任务或多线程环境下使用,无需额外的同步机制。
- 可以在信号处理程序中安全地调用,不会影响正常的程序执行。
下面是一个简单的可重入函数的示例:
// 计算两个整数的最大公约数
int gcd(int a, int b) {
if (b == 0) return a;
return gcd(b, a % b);
}
这个函数没有使用任何全局变量、静态变量、堆内存、标准I/O等,也没有调用其他不可重入函数,因此它是一个可重入函数。它可以被多个任务或线程同时调用,也可以在信号处理程序中调用,而不会出现错误。
那什么是不可重入函数:
2. volatile
2.1 编译器优化问题
以下代码中,正常情况下,进程收到2号信号时被handler方法捕捉,在handler方法里将quit置为1,当handler执行完毕返回的时候,while循环判断为假,进程代码执行结束,自动退出。以上叙述情况确实正常,但当gcc编译时如果开了-O3级别的优化,并且quit全局变量没有volatile修饰时,此时进程的运行结果就不尽如人意了。
当无volatile修饰quit时,即使quit已经被改为1了,但进程依旧没有退出,执行着main控制流里的while死循环代码。当有volatile修饰quit的时候,quit被改为1后,main控制流的while判断为假,代码执行完毕,进程退出。
2.2 如何理解编译器的优化? ---- 在汇编阶段把代码改了
编译器的优化是指编译器在将源代码转换为目标代码的过程中,对代码进行一些改进和调整,以提高目标代码的性能或节省资源。那什么阶段优化的代码呢?就是程序编译的汇编阶段。
从信号的角度来看,volatile关键字可以用于以下场景:
- 当一个变量可能被信号处理程序修改时,应该用volatile声明,以便在信号处理程序返回后,主程序可以获取到最新的值。
- 当一个变量可能被多个信号处理程序访问时,应该用volatile声明,以便在不同的信号处理程序之间保持数据的一致性。
- 当一个变量可能被硬件设备或寄存器修改时,应该用volatile声明,以便在每次访问该变量时都能获取到最新的状态。
下面是一个使用volatile关键字的示例:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
// 用volatile声明一个全局变量,表示它可能被信号处理程序修改
volatile int counter = 0;
// 定义一个信号处理函数,用于接收SIGINT信号,并修改counter的值
void handler(int sig) {
printf("Received signal %d\n", sig);
counter++;
}
int main(void) {
// 注册信号处理函数
signal(SIGINT, handler);
// 循环打印counter的值
while (1) {
printf("counter = %d\n", counter);
sleep(1);
}
return 0;
}
运行结果如下:
$ ./a.out
counter = 0
counter = 0
^CReceived signal 2 # 按下Ctrl+C发送SIGINT
counter = 1
counter = 1
^CReceived signal 2 # 按下Ctrl+C发送SIGINT
counter = 2
counter = 2
^\Quit (core dumped) # 按下Ctrl+\发送SIGQUIT
如果没有用volatile声明counter变量,那么编译器可能会优化掉对counter的读取操作,导致主程序无法感知到信号处理程序对counter的修改。这样就会出现数据不一致的问题。
SIGCHLD信号
其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程 终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可
父进程阻塞式检测和轮询式检测
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不 会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可 用。请编写程序验证这样做不会产生僵尸进程。
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
void handler(int sig)
{
pid_t id;
while( (id = waitpid(-1, NULL, WNOHANG)) > 0){
printf("wait child success: %d\n", id);
}
printf("child is quit! %d\n", getpid());
}
int main()
{
signal(SIGCHLD, handler);
pid_t cid;
if((cid = fork()) == 0){//child
printf("child : %d\n", getpid());
sleep(3);
exit(1);
}
while(1){
printf("father proc is doing some thing!\n");
sleep(1);
}
return 0;
}
实际上,除间接通过waitpid的方式回收僵尸进程外,还可以通过父进程调用sigaction()或者是signal()将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会向父进程发送信号。
但其实SIGCHLD的默认行为就是忽略,一般情况下,系统默认的忽略和我们手动设置的忽略,通常是没有区别的,但这里是一个例外。操作系统对我们手动设置的SIG_IGN做了特殊处理,帮我们做了回收子进程的工作。