@
文章导航
1. 信号
1.1 前言
在 Linux 中,信号是一种用于进程之间的通信机制,通常是异步的,也就是进程随时都可以收到信号,可以通过信号来通知进程发生了什么事,并且进程可以马上对这个信号做出反应和处理
在 Linux 中输入 kill -l
可以得到信号的编号以及这些信号大概代表的是什么,常见的比如 9
号信号就是常见的强制杀死进程信号,以及 11
号就是段错误,具体点就是空指针,指针越界这些异常了,还有Ctrl + C
终止信号是 2
号信号。
(注意:信号是从 1 开始的,没有 0 号信号)
虽然总共有 64 个信号,但是前 32 个信号被称为核心信号,现在更关注的也是这前 32 个信号,用的也很频繁,通常用于程序的各种异常问题
1.2 信号的位置
操作系统随时都可能会给进程发送信号,而且还可能连续发送,进程收到信号之后,可能也不会马上处理,手头可能有优先级更高的事,甚至有可能直接忽略这个信号
先来了解一下信号的三种状态
① 递达(Delivery
)
- 当进程收到信号之后,就直接执行该信号的处理方法,称为 信号递达,也就是 处理信号
② 未决(Pending
)
- 当信号到达的时候,进程不会马上处理这个信号,先放着,称为 未决,即暂时不处理
③ 阻塞 (Block
)
- 如果某个信号被设为阻塞,那么进程收到这个信号的时候,会将这个信号置为 未决 状态,并且阻塞如果不取消,这个信号会一直得不到处理
在 task_struct
中,可以理解成存储了关于 32
个信号对应的 3
个数据结构,这三结构就是 block
,pending
,delivery
。并且 pending
和 block
可以理解为位图
① 比方说,1
号信号在 pending
位图中对应的标志位为 1,那么说明这个信号暂时不被处理,但是当操作系统识别到这个 1
的时候,就会执行相应的处理函数(如果没被屏蔽的话)
② delivery
结构存储了关于这些信号的处理方法
③ block
中如果对应的 1
号信号为 1,那么说明接下来进程如果收到 1
号信号,那么会将 pending
中关于 1
号信号对应的标志位设为 1,表示 未决,暂时不处理
其中,delivery
存储了某个信号对应的处理方法,⭐「处理方法」包括三种:默认,忽略(不处理),用户自定义。并且 Linux 为默认和忽略提供了宏:默认 —— SIG_DFL
,忽略 —— SIG_IGN
,而每个信号都有各自默认的处理方法
1.3 接口
1.3.1 sigset_t
首先认识一个数据结构 sigset_t
,这个数据结构可以用来表示一个信号集,可以表示某个信号的状态。
- 可以理解成一个位图,比方说,
1
号信号在里面的比特位为1,那么表示有效;反之,0
表示无效,或者说没有这个信号
并且这个结构可以用来表示 block
和 pending
,意义也是一样的,比如 sigset_t pendings
,如果 1
号信号在里面的标志位为 1
,那么表示 1
号信号在这个进程中的状态是未决的
1.3.2 信号集操作接口
1. sigemptyset(sigset_t* set) 清空信号集,全部置0就对了
2. sigfillset(sigset_t* set) 将信号集全置 1
3. sigaddset(sigset_t* set, int signo) 将某个信号在这个信号集中标记为 有效
4. sigdelset(sigset_t* set, int signo) 将某个信号在这个信号集中标记为 无效
5. sigismember(const sigset_t set, int signo) 这个信号在信号集中是否 有效
这里面就是各种对 sigset_t
的操作了,后面举例子
1.3.3 signal
上面不是说信号的处理方法有三种吗,而自定义处理方法就可以通过这个函数来指定
signum
表示要处理的信号,handler
表示函数指针,就是自己定的函数了
(sigaction
接口也可以修改信号对应的处理方案,signal
相对于它来说是简化版的)
举个例子:在进程中设置对 2 号信号的捕捉,捕捉之后打印一段话。之后对这个进程发送 2 号信号,这时候就会执行我们绑定的自定义函数了
1.3.4 sigprocmask
作用:修改进程的阻塞信号集(block
)
how
参数常见的可以有 3 个值,假设当前进程的 信号屏蔽集为 block
SIG_BLOCK
:往阻塞信号集中添加set
中有效的信号,相当于让当前进程的block | set
SIG_UNBLCOK
:往阻塞信号集中去掉set
中有效的信号,相当于让当前进程的block | ~set
SIG_SETMARK
,让当前进程的block = set
oset
是输出型参数,如果不为空,那么会返回变化之前的 block
,便于后续恢复;如果不关心之前的状态,设为 NULL
就好了
举个例子
屏蔽之后 2
号信号怎么处理?当然可以在程序中定期扫描没有处理的信号,也可以将 2
号信号的屏蔽字恢复为 0,比如:在后面将程序对 2
号信号的屏蔽取消之后,这个进程就会被终止了
可以看出虽然 pending
中 2
号信号有效,但是由于屏蔽字,2
号信号的处理被拖延了。但是我们一旦取消屏蔽,而pending
中 2
号依然有效,2
号信号的处理函数就会被执行
1.3.5 sigpending
这个很简单了
set
:输出型参数,会得到进程的pending
集合,返回 0 表示一切顺利
2. 信号的处理
当进程收到信号之后,pending
中对应的标志位会被置为 1
,然后⭐进程每次陷入内核态,再从内核态切换回用户态之前,内核大多会检查进程的信号状态,以处理这些未处理的信号,处理完后,对应标志位置为 0
先了解一下内核态和用户态
2.1 内核态和用户态
还记得虚拟地址空间吗,在 32
位下,虚拟地址空间中有 1G
内核空间,但是这部分空间不使用普通的页表,这 1G
内核空间有自己独有的页表 —— 内核级页表,不同于用户空间的页表中 —— 每个进程都有一个自己的用户页表,而内核级页表所有进程共享,操作系统自己的数据和代码就是通过内核页表映射到物理内存上的
操作系统的代码数据必然不允许被随便访问,所以内核态页表带有权限验证。
- 而进程的内核态和用户态其实就相当于两个身份,如果进程是内核态,那么也就可以访问内核的页表,也就可以访问所有内核代码数据,用户级的代码和数据当然也可以访问。内核态是特权模式,可以访问操作系统的数据和代码,也可以访问硬件设备,执行速度较快。
当进程需要从用户态切换成内核态的时候,会修改处理器的特权等级,从 3(用户态)改成 0(内核态),这里的处理器包括(CR0,CR3,CR4寄存器),当从用户态转化成内核态的时候,会保存用户态的上下文信息,并加载内核态的上下文信息,然后就可以访问内核态的页表,执行操作系统的内置代码,比如进程调度,异常处理等操作,当工作完成后,就会拿着计算结果,恢复用户态的上下文数据,再返回给用户,然后回到刚刚中断的代码后继续执行
总之,在程序的运行过程中,操作系统无形中会大量地访问系统硬软件资源,在这些访问过程中,操作系统都会切换成内核态,使用内核页表,然后调用内核部分的代码,最终再拿着计算的结果切换成用户态返回给用户
2.2 信号的监测和处理
所以当一个进程收到操作系统发送的信号之后,首先做的就是将 pending
中将对应的信号有效位置为 1,而该信号的处理一般是等到进程下一次嵌入内核态,再从内核态切换回用户态之前,因为在这之前,操作系统大都会检查该进程的信号状态。
- 假设当前有个进程收到了信号
2
,那么pending
上的相应位置也会被置为1
- 然后该进程突然执行了
write
等系统调用,就需要切换成内核态来执行操作系统的代码,以及访问硬件资源。然后按理来说,执行完write
的时候,就应该拿着执行结果切回用户态返回给程序了 - 但是在这之前,操作系统会以内核态的身份检查该进程的
pending
和block
,如果block
中某信号标志位为 1,那么说明被屏蔽了,不用管它,而如果block = 0, pending = 1
,说明这个信号现在需要被处理 - 如果信号的处理方法是
SIG_IGN
,那么就忽略,将结果返回给用户,并从中断处继续往下执行 - 如果信号的处理方法是
SIG_DEL
,那么操作系统还是在内核态执行处理方法,比如终止进程... - 如果该信号的处理方法是用户自定义的函数,那么要执行这个函数,就又需要切换成用户态来执行这个处理函数(如果代码是恶意代码,那么以内核态的身份执行就存在风险)
- 处理完这个函数之后,也不能直接将结果从用户层返回,因为还需要继续回到内核态,执行内核代码,比如更新进程的状态数据,在内核态中执行信号处理程序的收尾工作等
- 最后再拿着执行结果返回给用户,从系统调用
write
处继续往后执行 - 就完成了一次信号的处理