北京时间:2023/6/13/19:07,伴随着期末考的来临,最近停课啦!无论是线上课,还是学校的课,开心,那这不是咱持续更文的好时候嘛,但是今天在学习相关C++知识时,涉及到了线程相关知识,虽然能听,但是听起来就比较费劲,所以我们要先暂停C++相关知识的学习,转而投向有关系统编程的学习,因为只有在系统编程中,我们才可以深入的接触到线程的概念,当然由于系统编程相关知识我们已经很久没有更新了,所以现在脑子就是一片空白,哈哈哈!想要进行转换肯定是一个费时且痛苦的过程,但,这也是没有办法的办法,一定需要勇敢去面对,因为如果没有系统编程底层的知识,那么C++中学习有关底层知识,就会非常痛苦,长痛不如短痛,所以我毅然决然的准备切换形态,等我学成归来,再去学习C++相关知识时,就如同砍瓜切菜,所以接下来10篇博客左右的内容都将是有关系统编程学习,接下来我们就正式进入该篇博客的正题,深入信号相关知识,虽然之前我们已经学习过了信号有关知识,但是因为时间过去已经很久了,该篇博客就重点在于回顾,So,gogogo!
同理,虽然在以前的博客中,我们已经详细的讲解过了该部分知识,但是由于时间问题,对这部分知识的淡忘,已经到达了一个新高度,所以如果我们想要承接这部分知识继续向下学习,那么就需要将之前学习的知识给回忆起来,所以我们正式进入信号相关知识的学习,回顾第一个问题,什么是信号?如下就是Linux系统下的所有信号:
如上图所示,指令:kill -l
,可以看出在Linux系统中的信号和我们日常生活中的信号并不一样,对于日常生活中的信号,我们的理解一般是什么手机信号、WiFi信号之类,再抽象点可能是闹钟信号、上课信号、红绿灯信号等!总而言之,生活中的信号没有标准定义,站在不同的角度,任何事物都可以理解成信号。但,对于操作系统中的信号,存在着一套标准,该标准由POSIX(可移植操作系统接口)指定,也就是这个世界那些第一口吃螃蟹的人定义的一个标准,上述62个信号就是POSIX中定义的操作系统的信号标准,其中规定了每个信号的含义和用途,但注意,此时操作系统中的信号还分为标准信号和实时信号,所以为了可以更好的深入了解它们,今天我们主要讲解的是1~31号的标准信号,如下就是这些信号的含义和用途:
信号 | 功能 | 编号 |
---|---|---|
SIGHUP | 挂起信号,常用于重新读取配置文件等操作 | 1 |
SIGINT | 中断信号,通常由 Ctrl+C 触发,用于中断进程运行 |
2 |
SIGQUIT | 退信,通常由 Ctrl+\ 触发,用于强制终止进程并生成核心转储(core dump ) |
3 |
SIGILL | 非法指令异常信号,通常表示进程执行了非法、损坏或未知的指令 | 4 |
SIGTRAP | 调试陷阱信号,通用于调试器与进程之间的交互 | 5 |
SIGABRT | 终止信号,由 abort() 库函数或 assert() 宏触发,用于指示应用程序遭遇了 unrecoverable 错误并退出 |
6 |
SIGBUS | 总线错误异常信号,通常表示进程访问了非法、损坏或未知的地址或内存区域 | 7 |
SIGFPE | 浮点数异常信号,通常表示进程尝试对无效的浮点运算进行操作 | 8 |
SIGKILL | 终止信号,常用于强制终止非响应的进程 | 9 |
SIGUSR1 | 用户定义信号1,通常用于向进程发送自定义事件或命令 | 10 |
SIGSEGV | 段错误异常信号,通常表示进程尝试访问未分配或未授权的物理地址 | 11 |
SIGUSR2 | 用户定义信号2,与 SIGUSR1 很相似,通常用于处理进程特定的自定义事件 |
12 |
SIGPIPE | 管道信号,当进程向已关闭且无读者的管道写入数据时触发 | 13 |
SIGALRM | 闹钟信号,用于给进程设置一个定时器,在指定时间后触发信号 | 14 |
SIGTERM | 终止信号,通常用于请求正常终止进程,并允许进程有机会做清理和收尾工作 | 15 |
SIGSTKFLT | 协处理器堆栈异常信号,常见于数学协处理器出现错误或不可恢复的问题时 | 16 |
SIGCHLD | 子进程状态改变信号,子进程退出或停止时会向父进程发送此信号 | 17 |
SIGCONT | 继续进程信号,用于让暂停的进程恢复运行 | 18 |
SIGSTOP | 停止信号,通常由 Ctrl+Z 触发,用于让进程暂停运行并等待恢复 |
19 |
SIGTSTP | 挂起或停止进程信号,类似于 SIGSTOP ,但可以被捕获、忽略或处理。 |
20 |
SIGTTIN | 在试图从等待输入的在后台运行的作业中读数据时向作业发送的信号 | 21 |
SIGTTOU | 类似于SIGTTIN ,但是由写入后台进程终端时发送 |
22 |
SIGURG | 带外数据信号通知程序数据接收 | 23 |
SIGXCPU | 超出了允许的 CPU 时间限制 | 24 |
SIGXFSZ | 超出了允许的文件大小 | 25 |
SIGVTALRM | 虚拟闹钟信号. 触发定时器与前面提到的 SIGALRM 不同的是,在本信号引起的定时器上不消耗系统时钟资源,而且它可以使用setitimer() 函数间隔触发 |
26 |
SIGPROF | 类似于 SIGVTALRM ,但 SIGPROF 按照配置编译所定义的计时器来测量可分配给进程的处理器时间 |
27 |
SIGWINCH | 窗口改变信号;当终端的宽度或高度发生变化时,将向为该终端打开并且正在执行的所有进程发出此信号 | 28 |
SIGIO | I/O 事件信号,常用于异步 I/O 操作 |
29 |
SIGPWR | 电源故障警告信号 | 30 |
SIGSYS | 报告非法系统调用 | 31 |
从上表中,我们可以知道每一个编号信号的功能和具体使用场景,知道在Linux系统中,无论是键盘输入的各种快捷键还是各种Linux指令,本质都是通过上述信号完成,并且因为 bash
(命令行解释器)本质是操作系统维护的一个子进程,所以我们在使用指令完成各种操作时,本质就是在对进程发送信号,只不过此时这个进程可能是bash进程,也可能是其它进程, 所以简单来说,操作系统中的信号就是用于通知进程发生某个事件,是操作系统中一种传递信息的方式。
第一个重点:信号的产生是异步的,那么如何理解这个异步呢?
从现实生活中理解,某个老师,手机微信中收到一个教务处发布的紧急任务需要处理,该老师可能处于上课状态也可能处于休闲状态,但无论该老师处于什么状态,都和教务处没有关系,并不影响教务处向别的老师下达任务。同理,真正从信号的角度来看,那么类比,此时就是向某一个进程发送信号,但是该进程可能正处于运行状态,但,我不管它是不是处于运行状态,我的责任只是将信号发送给它,并且在我将信号发送给该进程的同时,并不影响我将信号发送给另一个进程,这就是传说中信号的产生是异步的,这样处理的好处不言而喻,可以提高程序的并发性和响应性,减少资源浪费,最重要的是可以保证程序的稳定性和可靠性,简单来说就是一但产生某个信号,就要第一时间发送给进程,而不是阻塞!
第二个重点:进程如何记录信号?记录在哪里?也就是当一个进程同时接收到了多个信号,怎么处理?
同理,任何事物都具有关联性,如果上述知识点是合理的、正确的,那么该问题的产生就是合理的不能再合理,因为信号的产生是异步的,需要第一时间发送给进程,那么就会导致某一个进程在运行过程中收到信号,那么这个信号肯定是不能丢失,而是需要保存下来,等待进程处理。那么如何解决这个问题呢?简简单单,从以前有关进程的知识,我们可以知道,一个进程都有与其相对应的进程pcb(操作系统完成一系列创建工作),并且知道该进程pcb本质是一个task_struct,所以林子大了什么鸟都有,这个task_struct中存在一个专门用于记录信号的位图结构( uint32_t signals),所以进程就是通过这个位图结构实现对信号的存储和管理。
第三个重点:分时信号和实时信号的区别(1 ~ 31和32 ~ 64信号编号的区别)
想要明白这两种信号的区别,首先明白,目前主流的操作系统分为两种,一种是实时操作系统,一种分时操作系统,其中分时操作系统,也就是我们正在使用的Linux操作系统、Windows操作系统,它们的特点是:基于时间片,基于调度器调度,强调公平调度,均享资源的操作系统。而实时操作系统,则是,如果来了一个任务,那么就需要将优先级较高的任务立马处理,只有当把优先级高的任务处理完,才处理下一个任务,特点:高响应(车载系统),明白了上述有关实时操作系统和分时操作系统的知识,此时实时信号和分时信号同理,这里不详细讲解。
第四个重点:如何管理信号(1-31号)
上述在如何存储信号之时,我们已经知道是使用位图结构来存储信号,所以管理信号本质就是在管理位图结构,所以如何管理位图结构呢?如下:因为只需要管理1~31号信号,所以我们使用32个比特位就可以很好的将31个信号都表示出来,注意:0000 0000 0000 0000 0000 0000 0000 0000 在这32个比特位中,不是通过二进制序列对应的10进制数来决定是几号信号,比特位所处位置的编号表示的就是信号编号,第一个比特位对应一号信号,第二个比特位对应二号信号,只有当我们需要判断该进程pcb中的所有信号有哪些,此时才有可能通过计算该二进制序列对应的十进制数来确定, 当然也有可能是通过遍历该二进制序列或者是使用按位与、按位或来确定,具体需要看底层实现
第五个重点:如何理解向进程发送信号
通过对上述知识的理解,此时搞定这个问题,就是水到渠成,明白,信号在进程pcb中是使用位图结构存储,并且位图结构中比特位的位置和信号编号对应,所以所谓的向进程发送信号,本质其实就是向进程pcb中的位图结构写入信号,也就是直接将对应进程pcb中的位图结构特定的比特位从0置1,此之谓发送信号。并且最后明白,比特位的内容代表是否收到该信号,0表示未收到,1表示收到。
第六个重点:谁有资格修改进程pcb中的位图结构?
在之前有关进程相关知识理解,我们(用户)想要创建一个进程,就一定要去调用系统调用接口,从而让操作系统去创建和维和对应的进程,并且操作系统每当生成一个进程都需要创建对应进程的进程pcb,所以明白无论是进程,还是进程pcb都是内核数据,由操作系统管理,我们是摸不着,看不见的,因此进程pcb中的位图结构,同理只有操作系统有权利去修改,具体如何修改,在上述发送信号我们也简单了解了。
最后明白,信号编号和信号本身只是宏定义而已,如下图所示:当然具体每个信号对应的默认执行动作是什么,在系统调用接口中已经帮我们封装好了,并且帮我们实现了一套映射机制,操作系统只要拿着对应的信号编号(下标),就可以去执行对应“信号处理程序表”
或者“信号向量表”
中对应函数指针指向的函数(默认执行动作)
上述回顾完信号相关基础知识,此时正式深入信号,当然前提是对上述知识有很好的理解,首先明白,当进程收到一个信号时,进程有三种处理方式,如下:(很关键)
1.执行默认动作
2.忽略信号
3.执行自定义动作
最好的举例:头天晚上睡觉的时候,设置了一个第二天7点起床的闹钟,但是由于你睡的比较迟,所以当闹钟响的时候,你可能直接将闹钟关闭,也可能是将闹钟往后延迟了20分钟,当然也可能是直接起床。上述日常生活场景,将进程接收信号时的处理方式给展现的淋漓尽致,一眼就知道,如果你直接起床就是执行默认动作,如果你直接关闭就是忽略信号,如果你延后就是执行自定义动作。明白了这个知识点之后,下述知识理解起来就较为容易啦!
首先明白,外设产生的信号都称为中断信号,外设可以通过特殊的电路(中断控制器)向硬件处理单元(CPU)发送中断信号,所以当外设产生信号时,操作系统并不关心该信号对应的数据是什么,而关心是那个外设触发中断控制器发送的中断信号,并且明白,该过程是一个产生信号过程,不是操作系统发送信号过程,此时信号的产生是通过CPU上许许多多的针脚,来接收外设通过中断控制器发送过来的信号(电脉冲),并且当某个针脚接收到电脉冲之后,通过检测对应针脚是否处于高电频,来判断具体是那一个针脚接收到了信号(存在映射关系),并且将该针脚对应的编号写入到CPU的寄存器中,最终通过针脚对应的编号找到“中断向量表”
中对应函数指针指向的默认动作,具体如下图所示:
最后明白,对应函数指针的默认动作是由操作系统管理,操作系统通过调用对应外设驱动程序在操作系统内部相对应的中断处理程序来处理,而不是直接的映射关系,并且当匹配完成之后,那么操作系统就会将产生的信号,发送给对应的进程(修改其对应的位图结构),最终通过进程执行某个动作,实现预期想要达到的效果。
首先明白,可以向进程发送信号的系统调用接口很多,如:kill、raise、alarm、signal、sigqueue、pkill
,这些接口各有各的特性,使用方式也有很大的不同,具体如何使用可以去查看具体的说明,这里我们主要介绍两个向进程发送信号的接口:kill
和signal
,剩下的简单了解就行,如下所述:
kill接口:
首先明白,kill接口可以向指定进程发送信号,具体使用方式: int kill(pid_t pid, int sig);
该接口两个参数,第一个参数表示指定进程的pid,第二个参数表示对应发送的信号编号,所以表示的意思也就是向指定进程发送对应编号的信号,如下代码所示,我们就可通过调用该kill接口,封装出一个属于自己的kill接口,如下:
上述代码,我们就自己实现了一个kill程序,此时利用该程序就可以杀死任意进程,当然前提第是环境变量表中的第二个参数给的是9号信号,因为9号信号不允许你自定义捕捉(也就是更改它的默认动作),所以9号信号也叫管理员信号,并且明白之所以除了9号信号外,别的信号都支持修改默认动作,是因为有signal系统调用接口,允许你修改默认动作,执行自定义动作,具体如下所述:
signal接口:
据上述所说,此时明白,signal接口的作用就是更改某个信号的默认执行动作为自定义动作,具体使用方式:sighandler_t signal(int signum, sighandler_t handler);
,分析可知,第一个参数是对应的信号编号,第二个参数是一个函数指针(也就是自定义方法),如下代码所示:
此时发现,给该程序发送2号信号已经不能将其终止了,并且发送2号信号后,该程序还会去执行对应的打印动作,这就说明,我们的signal接口成功的将2号信号的默认动作给修改啦!如果深入理解该接口的话,本质还是涉及相关映射问题,和上述所说同理,就是对应的信号编号和默认动作存在一定的映射关系,也就是操作系统会维护一个函数指针数组,操作系统通过对应的信号编号和对应函数指针数组的映射关系,找到对应的默认动作,所以signal接口将默认动作改为的自定义动作的本质就是更改对应的映射关系而已,具体原理有待分析,这里不详细讲解!
剩余向进程发送信号接口:
raise函数
:用于将指定信号发送给当前进程
alarm函数
:用于设置一个定时器,在指定时间后发送SIGALRM信号
abort函数
:用于在程序中异常终止执行,调用abort函数会向程序发送SIGABRT信号,使得程序立即停止运行并且产生core dump文件
killall命令
:用于向所有同名进程(由进程名指定)发送指定的信号。killall命令可以在终端上使用
pkill命令
:根据指定的进程名或其他条件(例如用户ID、会话ID)查找匹配进程,并向其发送指定的信号,pkill命令也可以在终端上使用
sigqueue函数
:与kill函数类似,不过可以向进程发送一个带数据的信号
搞定了上述两点知识,剩下的知识就没那么重要了,在之前博客中写过,这里就不再赘述。下面,我们来看看几个对于发送信号很关键的点,如下:
什么是终端
终端就是一个计算机系统中连接到操作系统的命令行界面,通过这个界面我们就可以是实现直接和操作系统进行交互,当然首先交互的方式是通过指令的形式,因为指令的本质就是文件,操作系统可以通过我们的指令去识别到各个文件,从而执行文件中对应的操作,进行达到用于的预期。
前台进程和后台进程区分
Ctrl+c产生的信号只能发送给前台进程,一个命令后面加个&
,就表示后台进程,这样Shell不必等待进程结束就可以接收新的命令,启动新的进程(说白了就是有&就是后台进行,没有&就是前台进程),Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接收像Ctrl+c这种控制键产生的信号(也就是后台进程不能使用快捷键杀死,而是只能用Linux指令,发送特定的信号才能杀死),而前台进程可以直接用Ctrl+c这种控制键杀死。
总之:前台进程就是用来和用户进行交互使用的,而后台进程则是操作系统控制的进程,注重的是效率和资源利用率。
程序导入到环境变量中
注意:上述对kill系统调用接口的封装,是可以直接导入到我们的环境变量中(export
),然后就可以直接使用,也就是不需要使用./
使用,从这个方面,我们更加可以验证,软件的底层一定要调用系统调用接口(kill),充分表明,只有操作系统可以控制进程,用户只能借助操作系统去控制进程。