前言:信号和信号量没有任何关系
操作系统开机即处于死循环状态,直到给操作系统发送信号时,才会开始执行相应操作。
1. 信号的产生
给进程产生信号的信号源是非常多的!
1.1 键盘产生信号
打开VS,运行一个进程,当从键盘按下ctrl+c时,可以终止正在运行的进程
对于进程而言,相当一部分信号都是为了中止进程,信号有以下这么多,其中35~64为实时信号,不做考虑。
注:上面的所有数字代表宏,即
当一个进程收到信号时,会做以下三种操作:
①.默认处理动作,即上面ctrl+c中止进程
②.自定义信号处理动作
③.忽略处理
1.1.1 自定义信号
可以通过系统提供的函数,改变默认信号的处理动作
当我们通过上述函数改变默认信号的处理动作后,输出结果如下:
可以看到原本的ctrl+c 变成了打印我想要的字符串
1.1.2 前台进程和后台进程
当我以下述方式运行程序时,可以通过ctrl+\来结束进程
而当我通过以下方式运行程序时,ctrl+\无法结束进程
前台进程:./进程名
键盘产生的信号只能发给前台进程,前台进程的本质就是要从键盘读取数据
后台进程:./进程名 &
后台进程不对ctrl+c做处理
注1:无论是台前进程还是后台进程,都可以向标准输出进行打印
注2:对正在运行的后台进程,输入fg + 任务号,即可将后台进程转为前台进程
注3:对正在运行的前台程序,按下ctrl+z 暂停进程后,按下bg可将该进程转为后台进程继续运行
1.1.3 信号发送的本质
信号产生之后并不是立即处理的,而是必须把信号记录下来,在合适的时候处理。
问:如何记录?
答:进程的 task_struct 结构体中,包含unsigned int sigs,通过位图结构来记录信号,而task_struct属于操作系统内的数据结构对象,修改位图的本质就是修改内核数据,而内核数据只能够通过操作系统来修改,因此不管信号如何产生,发送信号在底层必须让操作系统来发送
1.2 系统调用产生信号
通过以下系统接口,可以给特定进程发送信号
1.2.1 简单实现一个mykill.cc
#include <sys/types.h>
#include <signal.h>
#include <string>
#include <iostream>// ./mykill sig pid
int main(int argc, char* argv[])
{if(argc != 3){std::cout << "error" << std::endl;exit(1);}pid_t pid = std::stoi(argv[2]);//指定进程的pidint sig = std::stoi(argv[1]);//执行的信号int n = kill(pid,sig);//调用成功就返回0if(n == 0)std::cout << "send success" << std::endl;return 0;
}
结果如图所示:
1.3 硬件异常产生信号
常见的硬件异常——除0操作、野指针
简单认识一下浮点型错误和段错误:
浮点型错误(对应信号:SIGFPE):操作系统是软硬件的管理者,代码中出现的错误导致硬件出错。所以操作系统知道了该错误
段错误(对应信号:SIGSEGV):零号地址在页表中不存在对应的映射关系,所以无法映射到内存中。CPU中存在MMU,MMU会拿到虚拟地址以及页表中的映射关系,但因为零号地址不存在映射关系,所以MMU会转化失败,即硬件出错被操作系统检测到了
概念认知:
问:操作系统怎么直到硬件出异常了?
答:所有的硬件异常都会被转为中断
1.4 软件产生信号
alarm以及SIGALRM函数
alarm:
原型:unsigned int alarm(unsigned int seconds);
功能:设定一个闹钟,在seconds秒后给当前进程发SIGALRM信号,该信号默认处理动作时终止当前进程
2. 信号的保存
2.1 信号保存的相关概念:
学习信号的保存前,需要先了解几个概念:
信号递达:实际执行信号的处理动作,信号递达一共有三种行为 ①自定义捕捉②默认③忽略
信号未决:信号从产生到递达之间的状态(信号存在位图中尚未处理)
阻塞:被则阻塞的信号将一直处于未决状态,直至解除该信号的阻塞状态才能递达
注:阻塞和忽略是不同的!信号只要阻塞就不会递达,而忽略是递达操作的一种。
阻塞信号又叫屏蔽信号
2.2 在内核中的表示
信号的保存操作是围绕着三张表进行的,如下图所示:
每个信号都有两个标志位还有一个函数指针表示处理动作
表中位置(下标):表示是第几个信号
表中的内容:表示是否收到该信号
block:为1表述阻塞。
pending:当block为0时,pending表中为1表示递达并执行相关动作,0表示未递达。
hander:递达时的处理动作
2.2.1 信号集操作函数
调用sigprocmask函数可以修改屏蔽字(阻塞信号)
函数原型:int sigprocmask(int how, const sigset_t *set, sigset_t *oset);、
参数:
how值:
set:how会根据我们所设定的set值进行相应的处理动作
oset:输出型参数,保存原来的屏蔽字,当我们想恢复原来的屏蔽字时,将set设定未oset,同时oset设定未nullptr
注:不是所有信号的阻塞位图都能够被修改,9号信号就不行!9号信号及不可被捕捉也不可被阻塞
sigpending读取当前进程的未决信号集
函数原型:int sigpending(sigset_t *set);
set:为输出型参数,会将当前未决信号集输出到set中
问1:通过sigprocmask函数可以修改阻塞位图,那么如何修改pending位图?
答:1中产生信号的四种方式均可修改pending位图
问2:假设block位图全为0,当pending中某个信号为1(即将进行递达时),当准备递达时,时先清空pending信号集中的信号(1->0),还是先递达才清空(1->0)?
答:先清空,在递达。
int sigemptyset ( sigset_t * set );初始化set所指向的信号集,使其中所有信号的对应bit清零,表⽰该信号集不包含任何有效信号int sigfillset ( sigset_t * set );初始化set所指向的信号集,使其中所有信号的对应bit置位,表⽰该信号集的有效信号包括系 统⽀持的所有信号int sigaddset ( sigset_t * set , int signo);int sigdelset ( sigset_t * set , int signo);sigaddset和sigdelset在该信号集中添加或删除某种有效信号。注:上述 四个函数都是成功返回0,出错返回-1。int sigismember ( const sigset_t * set , int signo);是⼀个布尔函数,⽤于判断⼀个信号集的有效信 号中是否包含 某种 信号,若包含则返回1,不包含则返回0,不包含返回0,出错返回-1。
2.3 core dump
先前在学习waitpid,父进程等待子进程时的输出型status参数如下:
其中会有一个core dump标志位。
其作用是:是否支持debug,1支持,0不支持。在大多云服务器上这个功能是默认被禁止的。
因为当进程异常退出时,进程在内存中的核心数据会从内存拷贝到磁盘,形成一个文件用于支持debug,一旦生成的文件多了,磁盘立马就爆了。
3.信号的处理
了解信号的处理前,需要先认识到几个概念:
用户态:执行用户的函数代码、方法、这些都叫做在用户态执行
内核态:系统调用函数虽然是用户写的,但执行上却是操作系统来进行的,执行操作系统代码进行系统数据访问时,计算机所处的形式叫内核态
3.1 信号的捕捉流程
信号捕捉的流程:(另外两种信号处理比捕捉简单)
当从内核态返回到用户态时,会做信号检查,检查pending表,没有被阻塞时,当pending表中为1就要处理,为0就直接返回
捋一下上述过程:
①.当进程在运行时,由于某些原因比如系统调用,导致当前进程陷入到内核
②.当把内核相关的代码执行完毕,然后操作系统准备返回到用户层代码,
③.但返回之前会做信号检查,检查block和pending表,若为 0 1 且有自定义代码,则会跳转到自定义代码执行(如果被block了直接返回)
④.再返回到内核当中,
⑤.最后返回到用户代码
注:因此上述过程一共进行了四次用户切换:用户→内核→用户(执行自定义代码)→内核→用户
问1:谁执行自定义代码?是用户?还是OS?
答:自定义代码是用户自己写的,而OS明确禁止用户执行某些操作,假如自定义代码是OS执行,那用户不就能够执行某些被OS禁止的操作了吗?因此自定义代码是用户态执行的。
问2:自己写的代码,即使没有调用系统函数,也会进入内核,为什么?
答:因为所有的进程都是要进入调度队列的,而进程的调度过程是由操作系统来执行的,所以这是OS强制介入的!
3.2 操作系统是如何运行的?
3.2.1 硬件中断
当外设(键盘,网卡等)向中断器发送高电平时,中断器能够自动识别哪个针脚被点亮,同时把对应的中断号写到中断控制器的寄存器当中,再由中断控制器给CPU的特定针脚发送高电平,此时CPU就知道外部有一个设备准备好了,但是不清楚具体是哪个设备,因此CPU再访问中断控制器的寄存器,读到对应的中断号。
经过上述过程,CPU就知道哪个外设准备好了。
上述过程只能够让CPU知道外部有数据,但是CPU做不到处理数据,只能由软件来处理,因此CPU需要再维护一张中断向量表(IDT),而中断号对应表中下标,表中内容为具体操作。即拿着中断号,查表,找到对应方法,再投喂给CPU执行中断处理方法。
注:上述过程是不太严谨的说法,但是大致过程就是如此。
因此OS不需要关注外部设备是否准备好,而是外部设备准备好时会通知OS。(即os不需要轮询查外部设备)
硬件中断描述了一个外设(硬件)通过中断控制器向CPU发送中断,CPU通过中断号在中断向量表找到对应方法,再执行的过程。仔细回想一下,这过程是否很眼熟?
发中断 → 发信号
保存中断号 → 保存信号
中断号 → 信号编号
处理终中断 → 处理信号(自定义捕捉)
注:用软件来模拟硬件中断,虽然两个方法实现上天差地别,但是思想是类似的。
再认识一下冯偌伊曼体系:
先前的认识:输入设备会将数据交给存储器,再由CPU处理存储器中的数据。
新的认识:输入设备有内容时,会先通过中断告诉CPU,CPU再根据中断号进行相应的操作
3.2.2 时钟中断
问:当没有中断时,操作系统在干什么?
答:是暂停的,但是你总真什么都不做吧?
所以!需要给CPU引入一个时钟中断,同时在中断向量表中加入进程调度的方法,CPU就能够在时钟中断的驱动下,每隔一段时间进行进程调度的中断服务。因此整个操作系统是基于中断进行工作的软件。
这个固定的间隔时间被称作:主频
重新理解一下时间片:CPU是以固定时间进行进程调度的(假设为1ns),每个进程的task_struct中会有一个count,假设为10ns,那么时钟中断每触发一次,进程调度执行一次,count减减一次,直到count==0,当前进程的时间片耗尽,则重新开始调度新的进程,因此时间片的本质就是计数器
知识补充:计算器如何记录正确的时间?
当计算器第一次联网得到时间时,就会有一个时间戳,根据计算器自己的频率,就能转换为一个历史总频率total,total不断加加就能够保证我们在离线的时候计算机也能够知道时间
3.2.3 软中断
3.2.3.1 被动触发(异常)
由外部硬件引发的中断叫做硬件中断,那么软中断即为由软件原因触发的中断。
比如除零等其他代码上的错误,可以在中断向量表中注册一个专门处理该异常的中断服务。当代码出现错误时,就会触发中断,执行中断服务。
问:野指针和指针重复释放呢?
答:所有的异常都会在中断向量表中注册,一旦出错操作系统就会执行相应的中断服务
补充:缺页中断是什么?
虚拟地址合法,但是物理地址不合法(找不到),即没有成功建立映射关系,此时就会转化成缺页中断,执行相应的中断服务。
3.2.3.2 主动触发(陷阱)
上述中断是因为软件出错被动触发了中断服务,为了让软件能够主动触发中断,CPU设计了对应的汇编指令(int 或者 syscall),可以让CPU内部触发中断逻辑。
汇编指令:
x86: int
x86_64: syscall
问:触发中断服务需要有对应的方法和编号,方法和编号是什么?
答:编号(中断号) → 0x80
问:在回答上述中断服务对应的方法之前,先来思考另一个问题:当我们进行系统调用的时候,具体是怎么进入操作系统,完成系统调用过程的?
答:所有的系统调用都写在一张系统调用的函数指针表中的,从此每一个系统调用都会有一个唯一的下标!!!该下标叫做系统调用号。
有了上述认识,我们再来理解中断服务中对应的方法是什么?
答:中断服务为执行下述代码:
问:这个是内核层面上的,那么用户层面上呢?
答:在用户层面上,我们所用的系统调用接口是open、fork,但实际在底层上是通过汇编来实现的。看下述汇编代码:
将5移动到寄存器中,通过int 0x80 来触发软中断,(进入到内核中)
在内核中
在将寄存器eax 移动到n中,最后sys_call_table[n] 调用方法
捋一下上述过程,系统调用的过程:
①.上层调用相应接口,通过汇编代码将系统调用号存放到寄存器中(此处会将函数名转为对应系统调用号)
②.int 0x80触发软中断(这一步就进入了操作系统)(这一步是在glibc封装的)
③.执行中断服务,查对应方法
④.将寄存器中的系统调用号赋值给n
⑤.执行相应系统调用
注:上述调用函数在调用过程中会转为对应的系统调用号,系统调用号是由内核提供的
![]()
认知刷新1:
OS不提供任何系统调用接口,只提供 系统调用号!
我们所学、所用的fork、open函数,都是由glibc封装的,在glibc内部中实现相应的方法
认知刷新2:
CPU内部的软中断
int 0x80 或者 syscall, 叫做陷阱
除零/野指针,叫做异常
3.3 内核态和用户态
操作系统也是软件,也会在内存中。因此对于操作系统而言,内核区也同样需要一张页表来维护虚拟地址到物理地址间的映射关系
内核页表:内核页表和用户页表在功能上没有大致区别,都是维护虚拟地址到物理地址的映射关系。但是用户页表可以存在多份,但是内核页表只能存在一份,所有进程共享。
结论:因此所有进程无论怎么调度,总能找到操作系统。
假设内存一共4GB,其中0~3GB为用户区,3~4GB为内核区。即用户区和内核区都在同一个地址空间上。
用户态:以用户的身份,只能访问自己0~3GB的空间
内核态:以内核的身份,运行用户通过系统调用的方式,访问OS的3~4GB
问1:如果用户拿着3~4GB的虚拟内存,不就可以随便访问内核中的代码和数据了吗?
答1:这显然是不可以的,用户态只能访问用户区代码,内核态只能访问内核态代码
问2:怎么用户和os怎么知道当前处于内核态还是用户态的?
答2:CPU中存在一个cs寄存器(权限寄存器),其低两个比特位专门用来记录当前处于用户态还是内核态 00→内核态、 11→用户态 上述标志位又被称为CPL → 权限位
注:当调用int 0x80/syscall时,在陷入内核区前,会将标志位由 11 → 00。 出内核区时标志位由 00 → 11。
而如果我们没有进行系统调用时,操作系统发现你越界就会触发中断走中断流程
认知:3.1中知道了信号捕捉的流程,3.2中有了时钟中断的认识,我们就可以意识到自己所写的代码由于CPU会不断的进行调度,因此代码是不停的从用户态转到内核态,再从内核态转到用户态,如果有自定义捕捉,会执行自定义捕捉。
3.4 sigaction
捕捉信号的另一种方法
函数原型:int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact)
signum :信号编号
*act:结构体变量
struct sigaction{
void (*sa_handler)(int);sigset_t sa_mask;
};
sa_handler为函数指针,指向自定义操作
sa_mask为信号集
问:为什么要有这个信号集?
答:当某个信号的处理函数被调⽤时,内核⾃动将当前信号加⼊进程的信号屏蔽字,当信号处理函数返回时⾃动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产⽣,那么 它会被阻塞到 当前处理结束为⽌。 如果在调⽤信号处理函数时,除了当前信号被⾃动屏蔽之外,还希望⾃动屏蔽另外⼀ 些信号,则⽤sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时⾃动恢复原来的信号屏蔽字。
*oldact:输出型参数,原信号的默认动作
4. 可重入函数
假设创建一个链表并进行头插,当插入的节点1指向原来的头结点时,因为某些原因产生了信号并递达自定义方法,而自定义方法也为头插操作,此时会将新的节点2头插进链表,当自定义操作执行完毕时,此时链表头指向节点2,返回源代码时链表头又指向了节点1。上述头插的函数被称作不可重入函数
不可重入函数的特点:
调用了malloc或free
调用了标准I/O库函数
5. volatile
分析上述代码:定义全局变量 flag,改变2号信号的递达方式,写一个死循环。
如果编译器不做优化,那么CPU对于这串代码所执行的操作为:
①.将物理内存中的flag搬到CPU寄存器中
②.CPU对寄存器中的值做逻辑运算或者算术运算,然后反复执行①②步骤
当我们ctrl+c 发送一个2号信号时,此时全局变量flag被修改成1,对应物理内存中的flag也会被修改为1,因此CPU经过上述步骤后会跳出循环,因此整个进程就结束了。
但是!有的编译器会对上述循环做优化,因为对于编译器而言flag没有被修改,而信号的自定义方法和flag没有直接的调用关系,因此编译器会优化掉第①步,只会做第②步,此时ctrl+c无法退出,哪怕修改了flag参数,寄存器覆盖了进程看到变量的真实情况,即内存不可见。
要让这部分内存可见,在初始化flag变量时,通过以下方法定义就可保持内存可见性:
volatile int flag = 0;
6.SIGCHILD信号
子进程退出时,会向父进程发退出信号,但是默认情况下,处理动作为SIG_DFL,该处理方式为忽略,这种忽略会使得子进程变成僵尸进程,若将处理动作设置为SIG_IGN,能够回收子进程,但是得不到退出码
注:二者虽然都叫做忽略,但是后者设置的模式可以正常回收子进程。