Linux 信号与中断 详解
文章目录
- 1. 认识信号
- 1.1 生活角度
- 1.2 进程角度
- 2. 信号产生
- 2.1 信号有哪些
- 2.2 键盘产生信号
- 2.2.1 键盘组合键
- 2.2.2 键盘如何发送信号
- 2.3 使用系统命令向进程发送信号
- 2.4 通过系统调用向进程发送信号
- 2.5 通过软件条件发送信号
- 2.6 通过硬件中断发送信号
- 2.6.1 除0错误
- 2.6.2 野指针
- 2.6.3 Term 和 Core 的区别
- 3. 信号保存
- 3.1 信号的三张表
- 3.2 sigset_t 类型
- 3.3 信号集操作函数
- 3.4 两个细节问题
- 4. 信号处理
- 4.1 signal
- 4.2 sigaction
- 5. 中断
- 5.1 硬件中断
- 5.1.1 外设触发的硬件中断
- 5.1.2 时钟中断
- 5.2 软件中断
- 5.2.1 陷阱 trap
- 5.2.2 异常
- 5.3 操作系统是什么
- 5.4 内核态与用户态
1. 认识信号
1.1 生活角度
信号是什么呢?在生活中,有红绿灯信号,有时钟信号等等,简而言之,信号是用来传递信息的媒介。
生活中不同的信号具有不同的特征,人可以根据这些不同的特征去识别不同的信号,进而采取不同的不同的处理方式。
从中我们可以看出,信号产生后,人识别到信号后处理信号,但有些时候,信号并不被立即处理,人可能有更重要的事情去做,因此需要记忆信号,既信号保存,之后再做信号处理:信号产生->信号保存->信号处理。
1.2 进程角度
在计算机中,进程与信号紧密联系在一起,因为信号是发送给进程的。
接下来,我们看一段简单的程序,初步感受向进程发送信号的过程。
这是一个一旦运行便陷入死循环的进程。如果我们想要终止这个进程,可以在进程运行起来,在键盘中使用ctrl + c
的组合键,这实质上就是向进程发送了一个信号,而这个信号的处理方式便是进程终止。
结果如下所示:
2. 信号产生
初步了解信号后,我们首先来具体研究信号的产生问题。
2.1 信号有哪些
Linux中,所有信号可以通过kill -l
命令进行查看。
从上图中,我们可以看到,一共有62种信号,特别注意,32和33号信号是不存在的。
在Linux中,信号分为传统信号和实时信号, 就上图而言,编号1~31是传统信号,其余信号则为实时信号,本篇博客重点讲解传统信号。
另外,在上图中,信号的编号从1开始,后面紧跟信号的名称,这些全大写的信号名称,实际上就是宏值。
我们可以从Linux内核源码中清楚看到这一点:
2.2 键盘产生信号
2.2.1 键盘组合键
我们可以通过键盘组合键产生信号。
键盘一般通过组合键来产生信号:
ctrl + c
:产生的是2号信号,即SIGINT
,这是一个可从键盘中产生的终止信号,收到该信号的进程默认行为是终止,即Terminate
ctrl + \
:产生的是3号信号,即SIGQUIT
,这也是一个可从键盘产生的终止信号,但是这个终止行为并不是Terminate
,而是core
,即还会生成一个core dump
文件。
ctrl + z
:产生的是18号信号,即SIGSTOP
,这个信号是让进程进入停止状态,并从前台进程转变为后台进程。
这里就牵扯到一个前台进程和后台进程的概念。
前台进程只能有一个,而后台进程能有多个。那前台进程与后台进程的区别是什么呢?简单来说,只有前台进程能够从键盘中获取数据,后台进程则不能,当然无论是前台进程还是后台进程,都可以向显示器文件进行输出。
而我们所使用的键盘组合键发送信号,本质都是向前台进程发送信号,而非后台进程。
2.2.2 键盘如何发送信号
为什么使用键盘的组合键就能发送信号呢?
首先,我们要明确一点,信号最终都是由操作系统向进程发出的。键盘能够发送信号,一定是OS知道键盘中输入了特定的数据,从而做出相应发送信号的处理。
可是,操作系统是如何得知键盘中输入了数据呢?首先,键盘中每次输入的数据一定进行了存储(存储在外设中),如果操作系统不断对键盘进行检测,肯定能够拿到键盘中输入的数据,但是这样损耗太大了,因为当键盘实际没有任何输入的时候,操作系统也在频繁检测,这是不必要的。
因此,键盘与CPU的特定针脚有连接,一旦键盘按下,代表有数据输入,特定的针脚变为高电平,即触发键盘所对应的硬件中断,而OS检测到这个中断信息,便会到中断向量表中,执行键盘对应的中断处理方法,即获取处理键盘中的数据,进而也就可以识别特殊的组合键,向前台进程发送信号了。
通过中断的方式来获取键盘的数据,这样就避免了OS的频繁却无意义的检测,减小了CPU的负担。
需要说明的是,此处仅简单引入中断的概念,后文会有关于中断的详细阐释。
2.3 使用系统命令向进程发送信号
我们可以在命令行中,使用系统命令kill
来向进程发送信号。
kill发送信号的具体格式:kill -信号编号(可以是数字,也可以是宏值) 相应进程的pid号
2.4 通过系统调用向进程发送信号
接下来介绍三个系统调用来实现信号发送。
kill 函数,kill命令实际也是通过kill函数实现的。
kill函数用来给特定进程发送特定信号。
raise函数,用于进程自己给自己发信号。
abort函数,用于进程自己给自己发送6号,即SIGABRT信号。
2.5 通过软件条件发送信号
通过软件条件发送信号,即当一定的软件条件满足时,操作系统自动向特定进程发送信号。
以之前学习的进程间通信管道为例,当读端进程关闭后,写端进程如果向管道中写入,则会被操作系统发送SIGPIPE
信号,使写端进程终止。
接下来介绍另外一个通过软件条件发送的信号SIGALRM
,以及与该信号相关的系统调用alarm
.
简单来说,alarm
完成的就是一个定时的功能,在设定的秒数后,向目标进程发送一个SIGALRM
信号。
关于alarm
,有额外几点需要说明:
- 一次只能设置一个闹钟,后一个闹钟设定后,会将前一个闹钟取消。
- 该系统调用的返回值,如果不存在前一个闹钟,则返回0,否则,返回前一个闹钟剩余的秒数。
- 如果调用
alarm(0)
,则表示取消所有设置的闹钟。
2.6 通过硬件中断发送信号
通过硬件中断发送信号,本质就是因为硬件层面的出错,而触发硬件中断,进而执行相应的中断处理方法,在相应的中断处理方法中,由操作系统向相应进程发送信号。
接下来,介绍两个常见的CPU硬件出错而产生信号的例子。
2.6.1 除0错误
除0错误本质是一种浮点异常,是CPU运算单元中发生的异常。在发生这种异常后,CPU中的状态标志寄存器中的相应标志位(可以理解为一个位图结构)会被置高电平,触发硬件中断后,OS检测到该中断,便执行中断向量表中相应中断的处理方法,向目标进程发送SIGFPE(8号信号),默认处理行为是终止进程。
但是,如果我们使用signal
更改相应信号的处理方式,那么就会一直触发这个异常,因为在执行相应中断方法前,会保存当前运行进程的硬件上下文,如果信号的处理方式不是终止该进程的话,那么该进程硬件上下文恢复时,相应标志位寄存器中的相应位仍为高电平,那么仍会触发硬件中断而循环往复。
2.6.2 野指针
野指针错误是一个非常经典的硬件报错。
本质上,野指针所触发的段错误,是因为CPU中的MMU,即内存管理单元,从CR3寄存器中拿到当前进程页表的地址后,再查找页表映射,完成从虚拟地址到物理地址的转换。而如果是野指针,那么这个虚拟地址在页表中一定是没有相应映射的,因此就会触发段错误,在CR2控制寄存器储存该触发页错误的虚拟地址,然后触发硬件中断,OS发送信号SIGSEGV,终止相应进程。
2.6.3 Term 和 Core 的区别
通过上面的学习,我们可以发现,有的信号让进程终止是Term,而有的信号让进程终止是Core ,这两种默认信号处理动作都是终止,那么有什么区别呢?
Term就是单纯的终止进程,而Core除了终止进程外,还会生成一个core dump 文件。
那么,什么是core dump呢?core dump,即核心转储。
结合之前在进程控制中所学的内容,waitpid
中的status
形参中,有一个core dump
标志位。默认处理动作是core
的信号,除了终止进程,还会将相应进程中的core dump
标志位置为1,操作系统检测到core dump
标志位为1后,就会将该进程的硬件上下文全部转储到一个core
文件中,以便事后调试,进而查清错误原因。
需要说明的是,云服务器中默认是将core dump功能禁用的。
我们可以看到允许生成的core file size
大小为0KB,也就是说不允许生成(使用ulimit -a 可以进行查看)
我们可以使用ulimit -c
来进行修改。
需要特别说明的是,ubuntu或centos系统中,基本使用systemd-coredump来管理core文件,如果想要直接让core文件生成在当前目录中,需要禁用systemd-coredump功能,即做一些额外配置。
在ubuntu中,生成的core文件就以core命名,而centos中,core文件往往以core+相应进程pid的形式命名。
如果想要调试core文件来寻找错误,可以使用gdb或cgdb,在命令行中输入如下语句:gdb + 相应可执行文件(需包含可调试信息) + 相应core文件 。
可以在gdb命令行中,输入bt,即可查看错误发生位置,或使用info registers,查看错误发生时,各寄存器中的值。
3. 信号保存
由于信号产生后,信号并不会立刻处理,因此信号必须要保存,那么信号是如何保存呢?
在了解信号保存前,我们先要了解几个概念。
信号未决:信号未决是指信号处于pending状态,即从信号产生到信号处理这之间的状态。
信号递达: 实际的信号处理动作被称之为信号递达。
信号阻塞: 进程可以选择阻塞(block) 某个信号,这样该信号就会处于未决状态,而无法递达,也就是说进程能够收到该信号, 但却无法处理该信号。
3.1 信号的三张表
在进程struct task_struct
的结构中,有三张表结构:一张block表,一张pending表,还有一张handler表。
block表和pending表本质上都是一个64位的位图结构,位图中的一位分别对应一个信号——block位图中,位为1,表示该信号被阻塞;pending位图中,位为1,表示该信号为未决状态。
handler表中则是储存不同信号的处理方式,也就是存储一个函数指针。
3.2 sigset_t 类型
这个类型就是一个信号集类型,也就是一个位图结构,即上图信号三张表中block表和pending表的结构。
其中block表被称为阻塞信号集,或者说信号屏蔽字(Signal Mask)。
3.3 信号集操作函数
以下,我们介绍一些常用的信号集操作函数。
sigemptyset:用于将信号集中的每一位置0。
sigfillset:用于将信号集中的每一位置1.
sigaddset:用于向信号集中添加特定信号。
sigdelset:从信号集中删除特定信号。
sigismember:用于判定特定信号是否存在信号集中。
这是专门用于更改信号屏蔽字的系统调用。
how:how主要用于规定更改的行为,具体而言有以下三种行为。
set:这是用于置位的用户层面的阻塞信号集
oldset:这个用于存储修改前的信号屏蔽字。
简单来说,这个函数可以在用户层面获取未决信号集。
3.4 两个细节问题
- 信号屏蔽与信号未决:信号屏蔽并不影响信号未决,仅仅使得信号无法递达到。
- 信号未决的状态改变时机:进程收到信号,即代表该进程的pending表中相应位被置为1,接下来要进入信号的处理逻辑,但在进入该信号的处理逻辑之前,操作系统会将pending表中的相应位重新置为0。
4. 信号处理
4.1 signal
信号处理的方式有三种:SIG_DFL,即信号的默认处理方式,通常都是Term或这Core
终止;SIG_IGN,即忽略该信号;最后一种则是信号的自定义捕捉,也就是可以自己定义信号的处理方式。
上述需要特别说明的是,要区分忽略信号和屏蔽信号,屏蔽信号是让信号产生后始终处于未决状态,不处理该信号;而忽略信号会处理信号,只不过处理方式为忽略。
接下来介绍一个可以设置信号处理方式,即更改进程handler表的系统调用。
signum:传入想要更改handler方式的信号。
handler:传入具体的handler方式——可以传入SIG_DFL,表示使用该信号的默认处理方式;可以传如SIG_IGN,表示忽略该信号;或者传入自定义的方式,但是该函数指针类型一定要与sighandler_t相同。
需要额外说明的是,Linux的传统信号中,有一些信号很特殊,比如9号信号(用于终止进程),19号信号(用于停止进程)。其中,9号信号不能被忽略,不能被屏蔽,不能被阻塞;而9号信号,同样不能被忽略和阻塞,但是可以被屏蔽。
4.2 sigaction
该系统调用同样可以设置信号的处理方式,不过它会额外涉及到一个结构体struct sigaction
通过这个结构体,既可以完成对传统信号的设置,也可以完成对实时信号的设置。
我们重点关注sa_handler和sa_mask
这两个成员,sa_sigaction
则于实时信号有关,sa_flags
设置为 0 即可。
sa_handler
是用来设置传统信号的处理方式,那么sa_mask
是什么作用呢?
这里必须要提及的是,当一个信号进入处理逻辑时,该信号的block表中相应位就会被置1,这样当前进程能够再次接收到该信号,但是却无法再次处理该信号,这样就可防止信号被递归处理,当一个信号处理完毕时,block表中的相应位则再被置为0。
如果,当一个信号进入处理时,我们不仅仅想屏蔽当前信号,我们还想屏蔽其它信号,就可以使用这个sa_mask,也就是一个位图结构,进行额外的设置。
需要特别注意的是,使用sa_mask去设置额外屏蔽其它信号时,不要再使用signal去设置信号的处理方式,因为signal的设置,会使得进入信号处理逻辑时,只临时屏蔽当前信号。
5. 中断
在前面的讲解中,屡次提到中断这个话题,接下来就要详细讲一讲中断,这也是想要理解信号捕捉具体流程的一个重点。
5.1 硬件中断
5.1.1 外设触发的硬件中断
什么是硬件中断呢?本质上就是由外部设备异步触发,中断当前的工作,转而执行相应的硬件中断处理方法。这样操作系统就不需要对外部硬件进行周期性地检测,而是通过中断触发即可。
不同的外部设备,如键盘、鼠标等,都可以触发硬件中断。
但不同的外部设备,触发不同的硬件中断,在操作系统中,会有不同的中断号与之对应。中断号有什么用呢?
操作系统中有一张中断向量表,本质上可以理解为一个数组结构,不同的下标处对应了不同的中断处理方法,中断号实际就是数组对应的下标。
需要说明的是,中断向量表就是操作系统极其重要的一部分,在计算机启动时,就会加载到内存中。
5.1.2 时钟中断
时钟中断,本质上也是硬件中断的一种,是由时钟或定时器每隔一段时间发出的中断,在较早的计算机中,触发时钟中断为独立的硬件,而现在计算机中,相关硬件往往集成到CPU中。
但与其它硬件中断不同的是,其它硬件中断,如键盘和鼠标的硬件中断,是需要由用户触发的,但是时钟中断,是每隔一定时间自动触发的。
那么时钟中断有什么用呢?
在讲作用前,我们先要了解几个概念:CPU主频、分时操作系统和时间片。
每个CPU都有一个主频,主频决定了CPU在单位时间内能够运行多少个时钟周期——因此,相对而言,CPU主频越高,CPU性能越强,即CPU运算越快,因为单位时间内,能够运行的时钟周期越多。
我们日常生活中常见的windows、linux、mac这些操作系统,都是分时操作系统,也就是基于进程的时间片来进行进程的轮转调度。
在分时操作系统中,每个进程在CPU上一次能运行的时间是有限的,这个时间我们称作该进程的时间片。当一个进程的时间片用完后,该进程便不能继续运行,保存硬件上下文后,就从CPU上剥离,重新进入进程的调度队列中,恢复时间片,等待下一次调度。
那么一个进程的时间片具体有什么确定呢?简单来说,每一个进程都有一个对应的计数变量,每当一个时钟中断到来后,该计数变量便自减1,当该变量减到0时,就代表进程的时间片用完。
这里需要区分一下概念,CPU主频,时钟周期,系统频率。CPU主频确定了时钟周期,主频的数值就是单位时间内CPU能运行多少个时钟周期。系统频率,是时钟中断触发的频率,即单位时间内,发出时钟中断的个数。
上述的时间片,是与系统频率和相应进程的时间片计数变量相关的。
现在我们可来谈一谈时钟中断的中断处理方法。
每当时钟中断触发后,CPU就会执行do_timer
方法,在这个调用中,会检查当前进程的时间片情况,根据不同的时间片情况对进程进行不同的标记,也会查看当前进程调度队列中的其它进程,如果需要调度运行,就会进行标记,而实际的进程调度与切换工作,在do_timer中相应准备工作完成后,调用schedule方法来完成——进程的实际调度与切换都是由该方法完成的。
所以,时钟中断的触发,是推动进程调度与切换的基础,而相邻时钟中断触发之间的时间间隔便是其余进程任务实际在CPU上可运行的时间。
5.2 软件中断
有通过外部设备触发的硬件中断,那有没有通过软件条件触发的软中断呢?
答案是肯定的。不过软中断需要做一个特别区分,有Linux内核层面的软中断,也有CPU架构层面的软中断,下面所讲的软中断,全部都是CPU架构层面的软中断。
5.2.1 陷阱 trap
什么是陷阱呢?
简单来说,陷阱是一种同步中断,通常是由程序主动触发的,并且会返回下一条指令的地址,常见的陷阱有系统调用和调试指令。
我们重点来讲系统调用的流程。
系统调用本质上是操作系统提供的方法。在操作系统内核中,存在一张系统调用表,本质上就是一个函数指针数组,可以通过数组下标,访问不同的系统调用,而这个数组下标,就是系统调用号。
系统调用的本质,其实就是通过系统调用号和系统调用表的地址,进而找到要执行的具体系统调用的地址,进而执行相应方法。
具体来说,用户层面主动使用系统调用,当程序运行到调用该系统调用时,会将相应的系统调用号存入CPU的eax寄存器中,然后通过int 0x80 或者 syscall这样的指令跳转到相应的系统调用处理方法中,CPU也同时由用户态转为内核态。
在系统调用处理方法中,最重要的一点就是根据eax中的系统调用号和操作系统内核中的系统调用表的地址,具体计算出相应系统调用函数指针的地址,然后就可以拿到相应的函数指针,进而执行具体的系统调用了。
但是,我们使用系统调用的时候,并没有看到什么eax
寄存器,也没有看到什么int 0x80 或者 syscall
这样的指令,这是因为C语言库已经将这些系统调用全部进行了封装。
5.2.2 异常
异常,即excaption
,是一种同步中断,常见的异常有除0异常,缺页异常等。
异常本质上是CPU出现某种错误,进而触发中断,对该错误进行修复处理一种机制。
异常产生时,CPU自动由用户态转变为内核态,然后跳转到操作系统的中断向量表中的异常处理中,根据不同的异常,进一步执行不同的异常处理方式。
除0异常,最终的处理方式就是由操作系统向相应进程发送一个SIGFPE的信号,进而终止进程。
缺页异常,顾名思义,与页表相关的异常,一般是由于非法内存访问,或者说虚拟地址缺少物理映射而产生的。
非法内存访问有很多,访问无效的内存地址,比如野指针,或者违反内存访问权限,比如修改只读内存,或者用户态访问内核态等等,这些都会触发段错误,即被发送SIGSEGV信号,进而终止进程。
而虚拟地址缺少物理地址映射,这种缺页异常,会进行映射后,继续执行原进程,而不终止进程。
5.3 操作系统是什么
通过上述的介绍,我们可以发现,操作系统是很“懒”的。一旦开机后,操作系统内核便会加载到内存中,但是并不会主动执行其中的大部分代码,而是当一个个中断到来时,再根据不同的中断,执行内核中不同的中断处理方法。
所以,究其本质,操作系统是由一个个中断推动运行的。
当计算机启动后,操作系统会创建一个0号IDLE进程,当没有任何其它任务时,这个进程会不断地在CPU上运行,它的作用是使得CPU工作在一个低功耗的状态,同时它也会尝试调用schedule()方法,进行进程调度。
而当有其它任务,对应有其它进程时,CPU便运行其它进程,当其它进程都结束时,再重新运行0号IDLE进程。
5.4 内核态与用户态
内核态与用户态,本质就是CPU所处的两种状态。
在32位的Linux系统中,进程地址空间共4GB,0~3GB是用户态空间,3-4GB是内核态空间。
不同的进程中,0~3GB的映射情况是各不相同的,对应的每个进程的页表是独立的,但是3-4GB的映射情况是相同的,所有进程共用一张主内核页表(与每个进程的独立页表,不是同一张表)。
内核态与用户态的区分,是通过CPU中的CPL(当前特权级别)来决定的,CPL为0表示内核态,CPL为3表示用户态。
用户态只能访问0~3GB的空间,而内核态理论上能够访问4GB的所有空间,但主要是在访问内核态的空间。
为什么可以实现限制访问呢?因为在页表中,存在记录访问不同地址空间所需特权级别的变量,当实际在查页表时,MMU会将CPU中的CPL与当前访问地址所需的CPL做比较,进而进行访问的限制。
处在用户态的进程,如果强行访问内核态空间,一般会触发段错误,而终止进程。