Linux信号¶
约 11879 个字 793 行代码 15 张图片 预计阅读时间 50 分钟
信号介绍¶
在前面的进程状态与进程优先级部分提到,如果想要终止一个进程可以使用Ctrl+C,其本质就是进程收到一个终止的信号
所谓信号,就是一种用户、操作系统或者其他进程向目标进程发送异步事件的一种方式,而其中的异步事件表示不会立即处理的事件,因为进程收到信号之前一直在执行其代码,收到信号的那一刻之前进程并不知道自己会收到信号,当进程收到信号时,就需要做出对应的操作,此时进程可能并不是立即去完成对应的操作
知道了何为信号,接下来就需要了解产生信号的方式
信号产生与信号捕捉¶
在Linux中,产生信号的方式可以有下面五种:
- 键盘产生,通过触发指定按键发出对应的信号
- 系统指令,通过系统指令向进程发出信号
- 系统调用,通过系统调用向进程发送特定的信号
- 软件条件,当某种软件条件被触发时,进程就会收到对应的信号
- 程序异常,当程序出现异常时,进程会收到对应的信号
为了验证上面的五种方式,需要先了解一个接口signal
,该接口可以将信号的默认行为指定为用户自定义的行为,这个动作也被称为信号捕捉:
C | |
---|---|
1 |
|
该接口的第一个参数表示信号编号,可以通过kill -l
指令查看,其中前31个信号是用户可以指定的信号,如下图所示:
第二个参数表示自定义的行为,其中sighandler_t
是一个函数指针,定义如下:
C | |
---|---|
1 2 |
|
该接口如果存在自定义的行为函数,就会返回该函数,否则返回SIG_DFL
(表示信号默认行为)或者SIG_IGN
(表示信号忽略行为)
其中,SIG_DFL
和SIG_IGN
为预定的宏,分别为0和1:
C | |
---|---|
1 2 |
|
其中的__sighandler_t
就是sighandler_t
类型:
C | |
---|---|
1 |
|
键盘产生¶
接下来以下面的代码进行演示:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
如果程序正常运行时会不断打印,但是如果按下键盘的Ctrl+C就会终止进程,实际上这个过程中是进程收到了2号信号,为了验证就可以将2号信号的默认行为改为自定义行为,自定义行为的函数如下:
C++ | |
---|---|
1 2 3 4 |
|
上面的函数中,参数表示收到的信号编号
在主程序中使用siganl
将默认行为修改为自定义行为:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 |
|
需要注意,signal
接口不要放在循环里,因为这个接口只需要在一开始将2号信号的默认行为修改为自定义行为即可,只要修改过就会生效,也就是说,一切信号的行为在信号产生前就需要准备好
此时再运行程序并按下Ctrl+C就会看到对应的「2号信号的默认行为被修改为自定义行为」语句被打印,如下图所示:
由于更改了2号信号的默认行为,所以此时就无法使用Ctrl+C终止进程,但是可以使用Ctrl+\终止
上面的过程就演示了如何使用键盘向一个进程发送信号,但是实际上,并不是键盘直接向进程发送信号,而是键盘触发事件被操作系统捕捉,再有操作系统识别到对应按键对应的事件转换为信号发送给对应的进程
所以现在的问题就转化为:操作系统为什么知道键盘触发了事件,操作系统不会一直等待键盘触发事件,而是由CPU的控制器收到了键盘的数据并触发了中断,再通知操作系统需要从键盘上读取对应的数据,通过这个操作就可以做到硬件和操作系统并行执行,操作系统也不需要一直等待某一个硬件,其中此处提到的中断就是硬件中断,其是计算机体系结构中的一个重要概念,它允许外部设备或内部硬件组件在CPU执行程序的过程中插入一个信号,请求CPU暂停当前任务并转向处理特定的事件
对比硬件中断和信号可以发现,信号本质也可以算是一种中断,只不过信号是在软件层面上模拟硬件中断,二者的共同点都是通过指定的方式告诉某一方可以进行某行为
从上面的过程中,了解了进程收到信号执行对应的行为,其中的默认行为可以通过signal
接口进行修改,但是如何知道一个信号的默认行为,对于这个问题,可以使用下面的指令查看信号的默认行为以及事件:
Bash | |
---|---|
1 |
|
在终端中输入上面的指令即可看到下面这张表:
其中2号信号对应的字段是SIGINT
,对应的事件就是Interrupt from keyboard
,表示键盘中断。同样,Ctrl+\表示3号信号,对应的字段为SIGQUIT
,事件就是Quit from keyboard
,表示键盘退出
系统指令¶
在Linux中,如果想向一个进程发送信号,可以使用kill
指令,实际上在前面Linux进程基础部分已经提到过该指令,其中-9
就表示信号编号为9号,对应的行为查表就是SIGKILL
,对应的事件就是Kill signal
需要注意的是,9号信号的默认行为是无法被自定义行为给替换的,也就是说,无法使用signal
接口将9号信号的默认行为替换为自定义行为:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
|
运行上面的程序,并向对应的进程发送9号信号可以看到并没有打印自定义行为中的语句,并且进程被终止:
系统调用¶
在Linux中,如果想通过代码的方式向进程发送信号就可以通过发送信号相关的系统调用,下面是常见的系统调用接口用于向进程发送信号:
kill
:int kill(pid_t pid, int sig);
,该接口作用是向指定的进程发送指定的信号,其中第一个参数就是进程的pid
,通过getpid
接口获取,第二个参数表示信号编号。如果发送成功,接口返回0,否则返回-1raise
:int raise(int sig);
,该接口作用是向当前调用该接口的进程发送指定的信号,其中的参数表示信号编号。如果发送成功,接口返回0,否则返回非0abort
:void abort(void);
,该接口作用是终止当前调用该接口的进程,这个接口比较固定,因为他固定了是当前进程并且固定了信号是SIGABRT
,即6号信号alarm
:unsigned int alarm(unsigned int seconds);
,该接口作用是创建一个定时器,当定时器设定的时间截止时,会向当前进程发送固定信号SIGALRM
,其中的参数表示定时器的秒数。该接口的返回值表示定时器剩余秒数,如果定时器的时间自动截止时,则该接口返回0,否则返回剩余秒数pause
:int pause(void)
,该接口作用是让进程睡眠直到被某个信号唤醒
Note
关于上述接口参数列表存在void
,见C语言基础知识
下面是上面提到的接口的基本使用:
对于kill
来说,可以设计需求:当进程中的计数器加到5时,向当前进程发送2号信号,并且2号信号的默认行为修改为自定义行为,当计数器加到10时,向当前进程发送3号信号结束进程,代码如下:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
|
运行结果如下:
因为raise
接口的作用是向当前进程发送指定的信号,所以其作用等同于kill(getpid(), signal)
,所以上面的代码中kill
的部分可以分别替换为:
C++ | |
---|---|
1 2 3 4 5 |
|
6号信号的作用比较固定,对于SIGABRT
信号的效果实际上类似于exit
,但是abort
接口更确切得说是非正常终止进程,也就是说进程退出码不为0,例如下面的的代码:
C | |
---|---|
1 2 3 4 5 6 7 8 9 10 |
|
运行该程序,程序结束时查看其退出码如下图:
最后是关于alarm
接口,因为alarm
表示设计定时器,所以可以设计需求:设计一个5秒的定时器,当定时器时间到时触发对应的自定义行为
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
|
运行上面的程序即可看到下面的结果:
实际上操作系统就是一个定时器,如果某一个定时器设定的时间截止时,就触发对应的任务,此处可以用alarm
进行模拟:
设定一个1秒的定时器,执行下面的三种任务
- 内核刷新
- 进程根据时间片切换
- 清理操作系统内部的内存碎片
每一种任务执行完后因为定时器会被清理,所以需要重新设置定时器,示例代码如下:
C | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
|
运行结果如下:
软件条件¶
所谓软件条件触发信号,就是当某个软件条件满足时,就向进程发送对应的信号,例如上面系统调用中的alarm
接口利用的就是软件条件,当定时器设定的时间截止时满足软件条件,此时向进程发送对应的SIGALRM
信号
因为操作系统底层不止一个定时器,所以操作系统本身也需要对创建的定时器进行管理,如果直接使用链表,那么就会出现每一次都需要遍历一遍定时器链表找出截止的定时器节点执行其中的操作,再对其进行销毁,此时的时间复杂度就会比较高,所以操作系统会考虑使用更优秀的方法来完成这个动作,例如可以考虑使用小堆结构,注意操作系统底层并不一定使用堆
因为软件条件无法一一列举,所以此处只是介绍进程收到的信号来源可能是软件条件
异常¶
异常是比较常见的进程收到信号的方式,例如除0异常、野指针异常等,在Linux中,除0时进程会收到SIGFPE
信号从而终止进程,野指针时进程会收到SIGSEGV
信号从而终止进程,对于这两种异常,下面主要考虑为什么进程会收到这两种异常对应的信号
前面提到,进程收到的信号都是由操作系统发送的,但是操作系统为什么知道哪一个进程出现了何种异常
首先是除0异常,对应的代码如下:
C | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
注意上面的代码没有任何退出进程的接口,例如exit
接口,运行上面的代码就会发现:终端一直循环打印「除0异常」,根据这个现象,下面的问题就是:
- 操作系统如何知道当前进程出现了除0异常
- 为什么程序一直循环打印「除0异常」
对于第一个问题,直观的理解可能认为是操作系统一直在监测当前进程的运行,一旦进程出现异常就向进程发送信号,但是这个理解并不准确,因为如果操作系统管理的进程非常多时,就需要监测非常多的进程,此时操作系统的负载就非常高,另外操作系统除了要监测进程,还需要处理其他的业务逻辑,所以这个理解并不实际。实际上,操作系统并不需要监测任何一个进程,只需要执行当前进程的CPU告诉操作系统,操作系统收到CPU的反馈后就向该进程发送信号即可
对于除0异常来说,在进程的代码被CPU执行时,会有对应的状态寄存器Eflags,其中存在溢出标记位,如果进行了除0,那么此时这个标记位就会由0变为1,当前CPU监测到这一位变为1后,就会出现硬件错误从而提醒操作系统需要将这个进程从运行队列中移除,此时操作系统就会给对应的进程发送信号,这就是为什么「操作系统如何知道当前进程出现了除0异常」
对于第二个问题,因为进程离开运行队列时,只要task_struct
结构体没有被销毁,那么其中一定就存在着上下文数据,同时也包括Eflags为1的标记,而在上面的代码中将SIGFPE
信号的默认行为修改为自定义行为,所以上面程序对应的进程并不会被终止,而是回到了等待队列,当进程再次进入就绪队列时就会回到上一次执行的位置,但是因为上一次在计算a/=0
时出错,所以这行代码并没有执行完毕,再次调度又会执行这一行导致再次出现除0异常,如此往复就会一直循环打印「除0异常」
接着是野指针异常,对应的代码如下:
C | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
注意上面的代码没有任何退出进程的接口,例如exit
接口,运行上面的代码就会发现:终端一直循环打印「野指针异常」,根据这个现象,下面的问题就是:
- 操作系统如何知道当前进程出现了除0异常
- 为什么程序一直循环打印「野指针异常」
对于第一个问题,与上面除0异常非常类似,只是CPU并不是通过Eflags标记错误,而是MMU无法在对应页表中查找到有效的物理地址从而引发错误通知操作系统,后面的过程与除0异常一致。对于第二个问题和除0异常一致
核心转储¶
在使用man 7 signal
查看信号表格时,有一列Action
,表示信号触发时对应的行为,其中的值有:Core
、Term
、Ign
、Stop
和Cont
。对于Core
和Term
来说,二者表面上都是结束当前进程,而Ign
表示进程做出的行为是忽略
本次主要考虑Core
和Term
,二者更细致的区别如下:
Term
表示正常退出进程,并且不需要进行debugCore
表示核心转储,即当进程退出时,会在进程代码所在的目录下生成一个core.pid
或者core
文件,当进程崩溃时,这个文件中会保存在内存中的部分信息,以便后续debug
关于core.pid
或者core
文件
不论是core.pid
或者core
文件,二者作用和内容都是一样的,只是文件名不同,在较老内核的操作系统一般使用core.pid
,较新的操作系统是直接使用core
,二者的区别就是一个带进程pid
,一个不带,本质原因就是因为进程pid
每一次运行都有可能不一样,导致可能多次出错就多次创建这个文件,最后可能这个文件的占用会非常高,所以后续就使用core
代替,这样就可以做到每次都是覆盖写。但是当代Linux操作系统为了保证系统性能和稳定,一般会关闭核心转储功能
如果想查看当前操作系统是否开启核心转储这个功能,可以使用下面的指令:
Bash | |
---|---|
1 |
|
查看core file size
是否为0,如果为0表示当前操作系统已经关闭当前功能,否则就是表示核心转储文件的大小,如果想要打开这个功能,可以使用下面的命令:
Bash | |
---|---|
1 |
|
一旦存在核心转储文件,在使用GDB
调试时就可以使用core-file 核心转储文件名
快速跳到出现错误的代码
如果子进程出现异常,为了保证可以看到异常位置,在进程的退出信息中的第8位比特位存在一个core dump标志,只要父进程查看这个标记位为1,就说明子进程出现了异常
信号保存¶
前面介绍了进程在收到一个信号时会如何进行处理,但是一个进程收到对应的信号并不会立即做出处理,而是在合适的时机才会去处理,此时就涉及到进程需要保存收到的信号。在信号部分涉及到下面的概念:
- 信号递达:表示信号的实际处理动作被执行
- 信号未决:表示信号从产生到信号递达之间的状态
- 信号阻塞:一个信号处于阻塞时,该信号就会在产生时一直处于未决状态,直到进程解除对指定信号的阻塞才会进行递达动作
在Linux中,一个进程可以选择阻塞某个信号,但是阻塞并不等同于SIG_IGN
的忽略行为,忽略本质也是信号递达的动作,而阻塞是未递达
一个进程保存自己的数据就是在其对应的task_struct
中,所以一个进程想保存对应的信号也是在task_struct
中保存,在其中,有下面的三种结构:
- 信号
block
表:表示信号阻塞位图 - 信号
pending
表:表示信号未决位图 - 信号
handler
数组:表示信号行为数组
注意,前两个都是位图,而只有最后一个是数组,因为信号的有无可以用两种状态:有或者无,所以只需要用二进制的0和1表示即可,所以一个信号是否被阻塞与一个信号是否未决就都可以使用位图
在信号block
表中,位图的每一位的位置就代表信号的编号,从右向左(不包含最左侧高位)的第一位即为编号为1的信号,在信号pending
表中也是如此
在Linux内核中,这三张表对应的结构如下:
C | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
|
在内核代码中,sigset_t
类型的变量称为信号集(注意不是信号量集),对应的阻塞信号集也称为当前进程的信号屏蔽字
根据对应的结构,操作系统也提供了相应的操作接口:
sigprocmask
:用于操作block
表sigpending
:用于检测pending
表signal
:用于操作handler
表
除了上面的操作三个表的方式外,还有用于读取和修改前两个位图表中内容的接口:
sigemptyset
:用于将指定表全设置为0sigfillset
:用于将指定表全设置为1sigaddset
:用于将指定表的某一个信号对应的标记设为1sigdelset
:用于将指定表的某一个信号对应的标记设为0sigismember
:判断某一个信号是否在指定表中为1
下面是所有接口的详细介绍:
sigprocmask
C | |
---|---|
1 |
|
其中的三个参数分别表示如下:
-
第一个参数表示操作标记:有三种选择:
SIG_BLOCK
:相当于mask = mask | set
,表示将新的set
与原始的mask
进行或操作,即添加阻塞标记位SIG_UNBLOCK
:相当于mask = mask & (~set)
,表示根据新的set
和原始的mask
进行取反相与,做到解除阻塞标记位SIG_SETMASK
:相当于mask = set
,直接赋值表示将新的set
覆盖原始的mask
-
第二个参数表示新的
set
表,其为输入型参数 - 第三个参数表示旧的
set
表,其为输出型参数,当函数用新的set
表替换旧的set
表时,会将旧的set
表存储到该变量中
sigpending
C | |
---|---|
1 |
|
用于查看pending
表,接口的参数为输出型参数,该接口会将指定的表存储到参数变量中
C | |
---|---|
1 |
|
C | |
---|---|
1 |
|
C | |
---|---|
1 |
|
C | |
---|---|
1 |
|
C | |
---|---|
1 |
|
上面的接口中,除了最后一个接口在指定信号存在于指定表中会返回1,不存在返回0,失败返回-1,其他均是成功返回1,失败返回-1
根据上面的接口介绍,可以设计需求:屏蔽2号信号,获取pending
表并打印该表的内容,接着发送2号信号查看打印结果,一段时间过后取消屏蔽再查看打印结果:
C | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
|
对应的运行结果如下:
因为信号本质是会被操作系统进行保存,所谓的操作系统发送信号实际上就是更改pending
表中对应位为1,所以操作系统发送信号本质是写入信号
信号捕捉与操作系统运行原理¶
前面已经基本了解了信号对应的处理行为和信号如何在进程中保存,为了更细致地了解信号,接下来需要了解信号是如何被进程捕捉到的
所谓信号捕捉,就是信号的递达时执行,在Linux中,信号捕捉的方式有三种:
- 默认行为
- 忽略行为
- 自定义行为
对于这三种方式,其捕捉方式都是一致的,但是第一种方式和第二种方式的执行方式却有所不同,首先考虑整体的捕捉流程,如下图所示:
针对自定义行为,就会走完上述的5个步骤,但是对于第一种和第二种行为就会直接处理而不需要在执行时回到用户态,在上面的步骤中的第二步中,进程会检查对应的pending
表和block
表,确保存在需要递达的信号
简化上面的过程图如下图所示:
在上图中,一共四个交点,代表着四次用户态和内核态的切换,注意,检查pending
表和block
表的步骤一定在内核态中,所以其位置一定在用户态和内核态的分界线之下
所以,进程收到信号时并不是立即处理该信号,而是在一个「合适」的时候,这里的「合适」就是在内核态准备切换回用户态之前(即上图中的第二步)
硬件中断
在上面讨论信号捕捉时提到了「用户态」和「内核态」,为了更好得理解这两个名词,需要对操作系统运行的原理进行了解
操作系统,作为计算机启动时可以说是第一个启动的软件,其必须要做好管理所有设备的准备,前面提到每一个硬件有数据时就会触发硬件中断通过CPU通知操作系统,但是外设是如何通知CPU的、操作系统又是如何协助CPU处理中断的就是接下来需要考虑的问题
以下图为例:
当某一个外部设备有数据时,外部设备会向中断控制器发送中断,中断控制器此时就会通知CPU存在外部设备有数据准备进行处理,CPU此时收到中断通知就会向中断控制器获取中断号为后面的中断服务做准备,但是因为CPU在处理中断之前不可能是闲置的,所以在执行中断的流程之前需要先保存当前执行的一些寄存器数据以便执行完中断流程后可以继续当前的任务
在保存好数据后,CPU根据获取到的中断号在操作系统提供的中断向量表中查找相关的中断行为处理方案执行对应的逻辑,等到执行完毕后,CPU会恢复中断前的任务继续执行
在上面整个流程下来,操作系统实际上就是给CPU提供中断向量表,以便CPU知道如何处理对应的中断,所以也可以理解为操作系统实际上就是「躺在」中断向量表上的程序
其中,中断向量表(Interrupt Descriptor Table)就是一个函数指针数组,每一个元素都是函数指针类型指向着对应的用于中断处理的函数,而中断号就是对应函数指针数组的下标
时钟中断
上面的过程就是用户操作外部设备,外部设备再给CPU发送中断进而提醒操作系统有对应的数据需要进行处理,但是操作系统本身也需要运行,例如操作系统还需要调度进程,如果只是外部设备有数据时操作系统才进行处理,那么进程就无法实现调度,所以现在就会存在第二个问题,操作系统本身又是如何被控制的,此时就需要一个定期可以触发中断的设备,该设备会在每隔一段时间向CPU发送一次中断,提示操作系统需要进行特定的任务,例如进程调度,这个也就是时钟中断
实现时钟中断同样需要硬件,而这个硬件在当前CPU中是继承在CPU内部的,这个硬件被称为时钟源,有了这个硬件就可以实现每隔一段时间向CPU发送一次中断,根据对应的中断号查找中断向量表执行对应的操作,示意图如下:
Note
注意,时钟源也属于硬件,所以时钟中断也属于硬件中断
根据上面的原理,现在理解为什么CPU主频越高,性能越好,首先,主频表示CPU时钟源在单位时间内可以向CPU发送的中断次数,如果这个中断次数很多,那么操作系统就会执行很多次对应的任务,例如进程调度,如果进程调度被多次执行,此时就会更加快速得切换进程从而达到更高的操作响应速度
根据这个原理,现在来理解一下什么是时间片切换
实际上,时间片切换可以理解为计数器+时钟源,在进程的task_struct
中存在一个成员表示一个计数器,这个计数器用于判断进程是否时间片已到,当进程在被调度时就会一直更新这个变量,如果这个变量减到了0,此时就会让进程进入等待队列等待下一次调度,所以所谓的时间片切换,就是利用每一次时钟源触发中断通知操作系统进行进程调度时判断进程的计数器是否为0
上面的提到的时钟中断可以在Linux第一版本的源码中查找到对应的内容:
C | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
|
死循环与软中断
有了上面的理解后,既然操作系统需要一直等待CPU收到中断时执行对应的任务,就需要处于循环等待的状态,但是因为操作系统并不能确定CPU何时会处理何种中断,所以就需要循环等待,这也就是操作系统为什么是死循环的原因,对应在Linux第一版源码如下:
C | |
---|---|
1 2 3 4 5 6 |
|
上面提到的中断是需要硬件进行触发,但是有些行为可能并不需要硬件但是有需要触发中断,例如因为软件原因,对于这种情况就需要使用软中断,利用软中断就可以实现系统调用,CPU也有对应的汇编指令(在之前的操作系统下是int(interrupt) 0x80,在现在的操作系统下是syscall)用于触发中断的逻辑
同样,类似于硬件中断的步骤,在操作系统的中断向量表中根据软中断处理逻辑的中断号找到对应的系统调用函数,在该函数中根据系统调用号(系统调用数组的下标)找到对应系统调用函数进行参数传递从而执行系统调用
在关于函数栈帧中提到每一个函数都有自己的栈帧空间,而系统调用本质也是也是一个函数,不同的函数之间传递数据就需要利用到寄存器,此时就需要用户层的函数通过寄存器将对应的数据,例如系统调用号传递给操作系统,对应的系统调用如果有返回值也需要通过寄存器将该返回值返回给用户
所以,实际上用户层使用的系统调用是通过标准库对系统调用数组进行的封装,整个系统调用的过程就是通过软中断的过程实现的,对应的源码如下:
C | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 |
|
在CPU中软中断的int 0x80或者syscall称为陷阱,而对应的一些问题,例如除0、野指针等被称为异常,所以当某个程序出现了异常时,就会触发软中断通知操作系统执行对应的中断处理逻辑
用户态和内核态
有了上面的知识铺垫,下面就可以深入理解用户态和内核态
前面在Linux进程地址空间中提到过,一个进程的虚拟地址空间分为两部分:
- 内核空间,在总大小为4GB的情况下,内核空间大小一般为1GB
- 用户空间,在总大小为4GB的情况下,用户空间大小一般为3GB
对于每一个进程来说,用户空间彼此并不相同,但是对于内核空间来说,为了能保证每一个进程都可以找到同一个操作系统,就需要保证每一个进程的虚拟地址空间中内核空间的页表映射到的是同一个操作系统,所以实际上对于不同的进程来说,只要是运行在同一个操作系统上,那么其虚拟地址空间中的内核空间一定是相同的
如果进程需要调用系统调用,就会通过在自己的虚拟地址空间中从用户空间进入内核空间执行对应的系统调用,此时就会从用户态转换为内核态,当系统调用执行完毕后,就会从内核态恢复到用户态,这就是两态之间的转化过程,但是这个过程存在一个问题,如果用户并不是通过封装好的系统调用进入内核态,而是直接调用操作系统的底层接口,此时就会对操作系统造成威胁,所以为了避免这种情况的发生,除了判断是否调用系统调用表示进入内核态外,CPU中还有一个CS段寄存器,这个寄存器中存在一个标记位,该标记位是两位2进制,其中在Linux中,0表示内核态,3表示用户态,这个标记位只能通过操作系统来修改,而不允许用户进行修改,在调用操作系统底层的系统接口时会先检查这个标记位是否为0,再决定是否进入内核态,此时就可以防止用户自行调用操作系统底层接口
根据上面的内容,对用户态和内核态的总结如下表所示:
特性 | 用户态(User Mode) | 内核态(Kernel Mode) |
---|---|---|
定义 | 用户态是操作系统中的一种执行模式,在这种模式下运行的程序具有受限权限。它为每个进程提供了一个隔离的环境,确保一个进程的操作不会影响到其他进程或系统的稳定性。 | 内核态是另一种执行模式,允许程序无限制地访问所有系统资源和执行任何指令。操作系统的核心部分——即内核,在这种模式下运行,负责诸如进程管理、内存管理和设备驱动等功能。 |
地址空间 | 在32位Linux系统中,每个进程拥有独立的3GB用户空间虚拟地址。这意味着各个进程的用户空间彼此之间是完全隔离的,从而增强了安全性和稳定性。 | 对于所有的进程来说,1GB的内核空间是相同的,并且映射到同一个物理地址空间。这使得所有进程都能找到并使用相同的操作系统服务,而不需要各自维护一份副本。 |
限制与保护 | 用户态下的代码不能直接访问硬件资源或使用特权指令。如果需要执行特权操作,如I/O操作或修改内存映射,则必须通过系统调用来请求内核的帮助。 | 在内核态中,可以执行包括但不限于修改寄存器内容、控制硬件设备、更改内存映射等特权指令。这些操作对于维持系统的正常运作至关重要,但同时也伴随着潜在的风险,因此只有经过特别授权的代码(如内核本身)才能在此模式下运行。 |
系统调用接口 | 当用户态程序需要操作系统的服务时,它会发出系统调用请求,这将触发CPU从用户态切换到内核态以执行必要的任务。一旦完成,CPU状态会被恢复,继续以用户态运行。 | 为了防止未经授权的应用程序滥用内核态的能力,CPU内部有一个段选择子寄存器(CS),其中包含两个二进制位来表示当前的特权级别。在Linux中,0代表内核态,而3代表用户态。这个标记位只能由操作系统修改,不允许普通用户程序改变。在尝试进入内核态之前,会检查该标记位是否为0,以此决定是否允许转换。这样的设计有效阻止了恶意用户程序绕过系统调用接口直接访问底层操作系统功能的可能性,从而提高了系统的安全性。 |
理解了用户态和内核态后,现在再看为什么在信号捕捉过程中执行用户自定义的行为函数需要进行状态切换,本质就是为了防止自定义行为函数中涉及对内核的操作,如果在用户态,因为其权限为用户,也就无法使用系统的底层接口
信号捕捉接口
除了可以使用前面的signal
接口进行设置信号捕捉的自定义行为以外,还可以使用sigaction
接口,这个接口与sigprocmask
的函数定义非常像:
C | |
---|---|
1 |
|
该接口的第一个参数传递信号编号,第二个参数和第二个参数都表示行为结构,但是第二个参数为输入型参数,第三个参数为输出型参数,对应的sigaction
结构体原型如下:
C | |
---|---|
1 2 3 4 5 6 7 8 |
|
Note
注意,在C/C++中,结构体的名称允许与函数名称同名
在sigaction
结构体中,第一个成员表示自定义行为函数,与signal
的第二个参数一致,第二个成员表示实时信号行为函数,此处不做介绍,第三个成员是阻塞列表,即用于替换block
表,第四个成员和第五个成员不做介绍
使用sigaction
接口时,需要创建一个新的结构体,可以指定自定义行为函数和阻塞表替换当前系统中已有的,并且函数可以通过第三个参数将原有的函数和阻塞表保存
如果新的结构体只设置了第一个成员,其余的成员保持默认,则效果与signal
一致,此处不再演示,接下来主要考虑使用第一个成员和第三个成员
首先了解操作系统在处理信号时具体做了哪些事情,当信号的处理函数被调用时,操作系统会自动将对应进程的block
表中当前信号的位置设置为1,这样就可以确保在执行信号行为时不会多次重复调用信号行为函数导致的栈溢出风险,在Linux中,除了当前信号会被设置屏蔽外,可能存在相关其他的信号也被屏蔽,如果需要同时设置其他不同的信号在执行信号行为函数时被屏蔽就可以设置sigaction
结构中的第三个成员,设置方式与sigprocmask
处提到的方式一致,一旦信号行为函数处理完毕,所有被屏蔽的信号就会自动解除屏蔽
另外需要注意的是,前面提到当进程收到信号时不会立即执行信号,而是会对信号进行保存,信号既然会被保存那么也会被释放,现在的问题是信号是何时从pending
表中移除的,对于这个问题存在两种可能性:
- 在信号执行信号行为函数之前解除
- 在信号执行信号行为函数之后解除
实际上,信号在在信号执行信号行为函数之前解除,之所以是「之前」是因为可以确保信号在执行行为函数时可以继续收到信号,并且如果是「之后」就会出现无法区分pending
表对应的信号是已经执行的1还是新修改的1
所以,基于上面的两点,可以使用下面的代码进行验证:
- 验证当前信号在执行自定义行为函数时会被阻塞:向进程发送2号信号,并设置
sa_mask
中3、4号信号在处理自定义行为函数时被阻塞,观察block
表中对应的信号位是否为1 - 验证当前信号在信号行为函数之前解除:执行信号行为函数时打印
pending
表,如果pending
表中对应的信号已经不存在,就说明是在信号行为函数之前解除,否则就是之后解除
对应的代码如下:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 |
|
可重入函数¶
有了信号之后,就存在着两个执行流:
- 用户的代码:第一执行流
- 信号处理代码:第二执行流
这两个执行流彼此完成自己的行为,互不干扰
根据这个特点,此时就会出现一个问题,例如链表插入节点的例子:
存在两个待插入的节点node1
和node2
,当主函数插入节点node1
时(对应步骤1)收到信号需要执行sighandler
函数中的逻辑,该函数中又存在着插入节点的逻辑,因为需要将当前信号的行为处理函数执行完毕才会返回到内核态,最后回到用户态,所以此时如果在与插入node1
节点的位置(注意,此时node1
还没有插入完成)插入node2
节点(对应步骤2和3)。当信号行为函数执行完毕返回用户态时,CPU会继续执行上次未完成的任务(对应步骤4),此时就会出现head
本来已经指向了新的node2
,但是因为进入信号处理之前还没有更改头指针指向node1
,导致现在由指向node2
修改为指向node1
,此时就出现了node2
节点丢失从而造成内存泄漏问题
像这种会出现问题的函数就称为不可重入函数,相反就是可重入函数,在标准库中,实际上绝大部分的函数都是不可重入函数,一般来说,如果满足下面的条件之一的就是不可重入函数:
- 调用了
malloc
或者free
的 - 调用了标准I/O库函数的
volatile
关键字¶
在C++中的类型转换中提到过volatile
关键字,当时只是描述一下volatile
的作用,实际上,volatile
关键字更多的应用场景是确保某一个关键字在当前执行流之外的位置被改变依旧可以正确读取到对应的值从而阻止编译器对该关键字修饰的变量进行优化,因为当前信号的产生会出现两个执行流,所以可以考虑使用volatile
假设有应用场景:存在一个全局变量flag
,在2号信号的自定义行为中对该变量进行修改,但是主函数中不修改,在主函数中通过判断该变量的值执行对应的语句。如果不使用volatile
关键字,并且使用GCC
的O1
到O3
优化,观察没有优化的结果O0
和O1
到O3
的优化结果,再对比使用volatile
关键字下观察对应的结果
关于GCC
优化
GCC
编译器提供了不同的优化级别,从 -O0
(无优化)到 -O3
(高级别优化),以及更激进的 -Ofast
等。每个优化级别都会启用一组特定的优化选项,默认情况下随着优化级别的提高,编译时间会增加,同时可能会导致代码体积增大,但目标是生成更快、更高效的可执行文件。以下是-O1
到-O3
三种优化的具体优化内容:
以下是关于-O1
到 -O3
优化级别的概述:
-O1
(基本优化):
- 内联小函数:将一些简单的小函数直接插入调用点,以减少函数调用开销
- 常量和寄存器分配:尽可能使用寄存器存储局部变量,并且合并冗余的常量表达式计算
- 死代码消除:移除不会被执行或没有效果的代码段
- 循环优化:例如,简化循环条件,提升循环内的不变表达式等
- 跳转优化:通过重组控制流结构来减少不必要的跳转指令
-O2
(中级优化):
除了包含 -O1
的所有优化外,还增加了更多积极的优化措施:
- 函数内联:不仅限于小函数,还会考虑更大范围内的内联机会,特别是那些被频繁调用的函数
- 跨函数优化:对跨越多个函数的情况进行优化,如全局值编号、过程间分析等
- 更好的寄存器使用:改进寄存器分配算法,尝试让更多的变量驻留在寄存器中
- 向量化:尝试将循环转换为 SIMD 指令集可以处理的形式,以利用现代 CPU 的并行处理能力
- 代码布局调整:优化函数和数据的排列顺序,以改善缓存命中率
- 延迟槽填充:对于 RISC 架构,优化分支指令后的延迟槽以充分利用流水线
- 其他性能优化:包括但不限于展开部分循环、优化内存访问模式等
-O3
(高级优化):
在 -O2
的基础上进一步增强了优化力度,引入了一些更为激进的技术:
- 激进的内联:更加广泛地应用函数内联,即使这可能导致代码膨胀
- 预测性公共子表达式消除:识别并移除重复出现的计算,即使它们可能看起来不同
- 浮点精度放松:允许某些数学运算不符合严格的 IEEE 标准,以便换取速度上的优势(注意这可能会改变数值结果)
- 自动并行化:尝试自动检测和转换适合多线程执行的代码区域
- 轮廓指导优化 (Profile-Guided Optimization, PGO):如果提供了运行时性能数据,则根据这些信息做出更智能的优化决策
- 额外的向量化:增强对循环和其他代码结构的向量化尝试
- 全程序优化:考虑整个程序范围内更广泛的优化可能性,比如跨文件边界的过程间优化
C | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
|
C | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
SIGCHLD
信号(了解)¶
在父进程创建子进程时,如果子进程退出就会给父进程发送SIGCHLD
信号,代表子进程已经退出,因为该信号的默认行为是IGN
,所以并没有实际的效果展现,实际上,如果利用这个信号就可以做到在不影响父进程的执行流的同时回收子进程
在前面Linux进程控制提到,可以使用wait
系列函数回收子进程,所以可以在父进程收到SIGCHLD
时才回收对应的子进程,其他时刻父进程执行自己的任务,对应的代码如下:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
|
上面的代码就可以确保父进程执行自己的代码,一旦收到SIGCHLD
信号,就回收子进程。但是这个代码存在一个很大的问题,就是一旦有多个子进程同时结束,因为当某个信号的信号行为函数还没有执行完时,pending
表可以记录一次,但是有超过两个以上的子进程结束时就只会保存一次,所以就会出现子进程没有回收导致的内存泄漏,例如下面的代码:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
|
运行代码查看可以发现只回收了6个子进程,还有4个子进程没有回收(具体回收数量取决于对应的系统):
为了解决这个问题,就需要使用死循环等待,对应的如果等待失败,就退出:
C++ | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
|
可以看到此时就已经解决了上面出现的问题,但是因为此时还是阻塞式等待,结合信号可以知道,一旦子进程退出就会有信号产生,所以可以考虑结合非阻塞式等待:
C | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
|
尽管可以实现回收所有的子进程,但是还是有概率会出现某些进程没有被回收,所以可以考虑使用最后一种方案,这种方案是Linux对SIGCHLD
信号的SIG_IGN
的忽略行为的一种特殊处理,这种情况下可以得到效果:子进程一旦退出就自动销毁,不需要等待父进程回收。所以对应的代码如下:
C | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
|