文章目录
- 前言:
- 引入信号
- 生活中的例子
- 信号概念
- 见一见Linux中的信号
- 浅度理解信号
- 信号处理(浅谈):
- 如何自定义捕捉
- 信号保存(浅谈)
- 信号产生
- 系统调用产生
- 异常产生:
- 浅谈除0异常
- 浅谈解引用野指针异常
- Core && Term
- 历史问题关于core dump
- 信号保存
- 补充概念
- 在内核中的表示
- 关于sigset_t类型
- 信号集处理函数:
- 设置阻塞信号集
- 获取未决信号集
- 信号处理
- 内核态 && 用户态
- 聊聊信号捕捉
- 谈谈键盘输入数据的过程
- 谈谈如理解操作系统的正常运行
- 如何理解系统调用?
- 为什么操作系统能一直运行?
- 可重入函数
- volatile
- SIGCHLD信号
前言:
上一部分我们主在研究进程间通信的问题,我们了解到进程间通信在文件级的通行方式是依赖管道进行通信,而对于内核级别可以使用System V的通行方式,比如共享内存、消息队列和信号量。
而本文讲解的是Linux信号这一块相关知识,而我们首先要知道的是,System V下的信号量和Linux信号是两个独立的概念,它们之间没有任何关系。
引入信号
生活中的例子
- 信号在生活中,随时可以产生。并且信号的产生和我是异步的
- 我们能识别信号
- 我们知道当信号产生了,我们该如何处理
- 我们可以将这个信号进行忽略暂不处理,并且我得记住这个信号,然后在合适的时候去处理
现在,将“我”换成“进程”,”生活中“换成”操作系统中“。
因此本质上,我们还是在研究信号,科技源于生活,生活中我们会接触到来自各个地方的信号,那么在计算机操作系统中,同样也存在着多多少少的信号,这些信号标志着某个动作!
信号概念
Linux系统提供的一种,向指定进程发送特定事件的方式。进程会对信号做识别处理,并且时刻记住信号产生是异步的!
见一见Linux中的信号
输入指令 kill -l
即可查看当前的全部信号
在这之中呢 1~31号信号,是我们的普通信号,也是我们本章的重点
而34~64好信号,是我们的实时信号
对于这么多信号,每个信号又代表着一个动作,那我们也应该查看每个信号对应的具体功能吧
我们可以输入指令 man 7 signal
,然后往下拉,就可以查看每个信号的具体功能
对于指定的动作,就是最左边的描述,而中间的core和term,我们后续再谈这两者的区别。
现在拿(2)号新号SIGINT来举例,这个信号对应的动作是"Interrupt from keyboard",翻译过来就是从进程接收到来自键盘的终止信号,这个信号本质就是我们经常在键盘上输入的组合键[Ctrl + C]!
浅度理解信号
根据我们上面所引入的信号在生活中的例子,我们在执行信号的方式按顺序归结可以有:
先是有信号产生,接下来是信号保存,最后再是信号处理。
为什么我们要有信号保存呢?——其实对于信号处理上来说,当我正在做一件事情时,我会先暂时忽略信号,等我处于合适的时候我才会去接受你,因此我们当然需要对信号进行保存。
对于这三种方式,我想先对它们浅谈,让大家有一个大致的轮廓,然后我们再深入攻破每种方式。
信号处理(浅谈):
当进程接受到信号时,必然是要进行处理的,而对信号进行处理,又分了三种方式进行处理:
- 默认处理
- 忽略处理
- 自定义处理(一般都是在捕捉信号)
对于这三种方式,进程只会从中选一种进行处理。一般来说,进程处理信号都是默认处理的,而默认处理通常是“终止自己”、“暂停”什么的,这也就是为什么我们在将所以信号显示出来是用的kill
指令,也许当时命名就是根据这个来考虑的。
如何自定义捕捉
信号可以被自定义捕捉,进程接收到信号后可以不执行本来应该执行的任务,而是去执行自己定义的任务。
首先我们要先认识一个系统调用函数signal
#include <signal.h> typedef void (*sighandler_t)(int); // 函数指针sighandler_t signal(int signum, sighandler_t handler);
对于第一个参数signum,就是信号的编号,比如(2)号信号
第二个参数handler,则是一个函数指针,这个函数形式与我们在学习qsort函数的参数一致,也是存在一个函数指针在参数里。作用更改进程持有的函数指针数组中信号对应下标位置的函数指针所指向的函数为指定函数。捕捉到指定信号之后,去执行该函数指针所指向的函数,并且要将捕捉到的信号作为参数传递给该函数。
并且这个函数只要在代码里,就是一直存在的,所以你可能执行过了这行代码,但是只要你收到了(2)号信号,就会立马被捕捉!
下面我们就来用一用这个系统调用函数:
#include <iostream>
#include <signal.h>
#include <unistd.h>void handler(int sig)
{std::cout << "===============================================" << std::endl;std::cout << "哈哈![ctrl + c] 对我无效!" << std::endl;std::cout << "你发的(" << sig << ")号信号无法终止我!!" << std::endl;std::cout << "===============================================" << std::endl;
}int main()
{// 捕捉(2)号信号signal(2, handler);while(true){sleep(1);std::cout << "你好,我是进程-> " << getpid() << std::endl;std::cout << "请按[ctrl + c]终止我" << std::endl;}return 0;
}
我们不难看出,当我们按下[Ctrl + c]后,产生的(2)号信号,就被headler函数捕捉走了,这使得我们一旦产生了(2)号信号,就会直接被迫执行headler函数的内容,既然我们无法结束这个进程,那就只能用我们在学习“进程的状态”那篇博客中,处理孤儿进程所运用到的指令kill -9 ‘进程pid’
,来kill掉指定的进程。
信号保存(浅谈)
既然信号是针对进程的,因此每个进程肯定是可以与信号进行交涉和保存的,既然要保存了,那么进程内部的PCB不就也要将信号管理起来吗,那么谈及管理永远都是**“先描述、再组织”。
因此实际上每个PCB中都会有一个位图**,而这个位图有31位,每一位记录了一个信号。
简单来说呢,就是当我这个进程接收到了(2)号信号,就代表着我会将我PCB里的位图的第2个bit位,由0修改成1。这样我们就是利用位图来保存了我们的信号。
通过这个,我们也可以得知,其实根本没有所谓的发送信号,本质就是在进程内部的PCB**“写信号”**罢了,而唯一能对PCB进行修改的,也只能是一个人——> 操作系统!!!
信号产生
通过刚刚的代码示例,其实我们不难发现,我们平常在写使用Linux时就一直在发送信号。
为什么我会这么说呢,回顾以下我们是不是经常的使用[Ctrl + c]呢,而[Ctrl + c]其实是我们通过键盘实现的,所以我们可以通过键盘实现。补充一下,其实我们按[Ctrl + /],也可以终止进程,发送的其实是(3)号信号。
上面说的是通过硬件的方式产生字体,但其实我们也可以通过系统调用的方式产生信号
系统调用产生
-
使用kill函数,kill 命令其实是通过调用系统调用 kill 函数实现的。
#include <signal.h> #include <sys/types.h>int kill(pid_t pid, int sig);
- 功能:向 pid 指定的进程发送 sig 所指定的信号。
- 参数:pid 表示的就是进程的 pid,表示要对哪个进程发送信号。sig 表示要对 pid 所指向的进程发送的信号编号。
- 返回:如果信号发送成功则返回 0;失败则返回 -1,并设置错误码。
测试实例:
// testsig.cc#include <iostream> #include <signal.h> #include <unistd.h>void handler(int sig) {std::cout << "===============================================" << std::endl;std::cout << "哈哈![ctrl + c] 对我无效!" << std::endl;std::cout << "你发的(" << sig << ")号信号无法终止我!!" << std::endl;std::cout << "===============================================" << std::endl; }int main() {// 捕捉(2)号信号signal(2, handler);while(true){sleep(1);std::cout << "你好,我是进程-> " << getpid() << std::endl;std::cout << "请按[ctrl + c]终止我" << std::endl;}return 0; }
// myKill.cc#include <iostream> #include <string> #include <sys/types.h> #include <signal.h>// ./mykill -9 "pid" int main(int argc, char *argv[]) {if (argc != 3){std::cout << "please enter more command" << std::endl;return 1;}int sig = std::stoi(argv[1] + 1);pid_t pid = std::stoi(argv[2]);kill(pid, sig);return 0; }
-
使用raise,给进程本身发送任意信号
#include <signal.h>int raise(int sig);
- 参数:sig 任意信号编号。
- 返回:成功返回 0,失败返回 -1 并设置错误码。
测试实例:
#include <iostream> #include <signal.h> #include <unistd.h>void headler(int sig) {std::cout << "===============================================" << std::endl;std::cout << "Hey! I am function headler, I have catched (2)signal" << std::endl;std::cout << "===============================================" << std::endl; }int main() {std::cout << "I am a process and my pid is " << getpid() << std::endl;signal(2, headler);std::cout << "I will keep send (2)signal to me" << std::endl;while(true){sleep(1);raise(2);}return 0; }
-
使用abort是当前进程异常终止
#include <stdlib.h>void abort(void);
- 功能:向调用该函数的进程发送 SIGABRT (6 号) 信号引起异常终止。
-
使用alarm设定“闹钟”
#include <unistd.h>unsigned int alarm(unsigned int seconds);
- 功能:在特定的时间发送(14)号信号SIGALRM
- 返回值:代表上一个闹钟还有几秒响
测试实例:
#include <iostream> #include <signal.h> #include <unistd.h>int main() {alarm(5);sleep(1);int n = alarm(3); // alarm 的返回值代表上一个闹钟还有几秒响std::cout << n << std::endl;sleep(1);int cnt = 0;while (true){std::cout << cnt << std::endl;cnt++;}return 0; }
在这里,如果你想尝试把所以信号捕抓,当然是可以的,但是对于(9)号信号,你就根本无法捕捉了。
异常产生:
我们在讲解进程的时候曾聊过进程的异常,而对于异常处理的直观显示出来,我们都会认为程序崩溃了,那么程序为什么会崩溃呢?
————进程存在非法访问,导致OS给进程发送信号。
- 比如你对一个数进行了除0操作,那么OS就会给这个进程发送(8)号信号SIGFPE
- 又比如你对一个野指针进行了解引用的操作,那么OS就会给这个进程发送(11)号信号SIGSEGV
这是因为进程接收到了这些异常信号,所以就自然的终止了进程。
但其实,你可以选择不抓进程而不进行退出,但我还是建议你有异常就直接退出。
浅谈除0异常
上图是我们的CPU,在CPU内部有许许多多的寄存器,其中有一个名为eflag的寄存器。当其他寄存器在执行运算时,eflag就会去看看各个寄存器的运算有没有出现除0操作,如果在某个寄存器当中,我发现了你有除0的动作,那我eflag的溢出标记位的值,就会由0置为1,就代表你这里进行了除0操作。
而操作系统是管理软硬资源的管理者,当OS看到CPU的eflag溢出标记位为1,就会给当前进程发送(8)号信号。了解了硬件的原理,那为什么我这里建议退出呢?如果你不推出可能会导致以下问题
- 高 CPU 使用率:由于异常频繁触发,进程会陷入不断处理中断的循环,导致 CPU 资源被占用,从而产生异常高的 CPU 使用率。这种情况可能会影响系统性能,特别是当多个进程或线程出现类似问题时,系统资源会被大量占用。
- 影响其他进程的性能:如果该进程在异常循环中消耗大量 CPU 时间,会影响到其他进程的运行时间,导致系统整体的性能下降。这在多任务系统中尤其显著,因为 CPU 时间被该进程独占,其他任务的响应时间可能延迟。
- 系统或进程的崩溃风险:虽然单个异常循环一般不会直接导致系统崩溃,但在内核中引发类似循环的代码可能会导致系统崩溃或锁死。此外,如果异常处理涉及内存分配或资源管理,也可能导致内存耗尽或资源枯竭,最终导致系统崩溃。
浅谈解引用野指针异常
回顾以下之前经常用到的地址空间这一概念,这里我们引入几个新的寄存器和新概念。
CR3寄存器:用来查找指向页表的地址。
CR2寄存器:也故障线性地址寄存器。
MMU:硬件的内存管理单元(Memory manage unit),实现从虚拟地址向物理地址的转化
所以我们对一遍的代码数据比如int a = 4
,在硬件底层就是上图所示的结构。
🔺而对于野指针呢?
————首先野指针是存在自己对应的虚拟地址的,它在mm_struct的区域并不明确,大部分时候是处于用户空间中,而一般来说说在“未初始化数据”区,而对于这些数据来说,MMU和CR3并不会为它们创建与物理内存的映射,简单来说是把它们封锁起来的,因为它们太不安全了。
而一旦你尝试去访问(比如解引用操作)这部分的资源,那么经过MMU手上之后,发现这明明是个不安全的资源,你要让我访问它?因此MMU就会把这个野指针的虚拟地址放在CR2寄存器上,并且不会再对这个进程做任何处理了。
和上述一样,操作系统是软硬件资源的管理者,当我操作系统发现了你CPU的CR2寄存器上有数据,那我就直接给你发送(11)号信号。同理还是退出好,不然又出现会出现高占用CPU的问题,这里我就不过多的赘述了。
Core && Term
还记得我们一开始打开的man手册,关于信号分类的那一栏吗?
这里的Action一栏有Term和Core,当时我说过我们后面会谈,现在是时候谈谈了。
Term:就只代表着异常终止
Core:不仅会异常终止,还会帮我们生成一个Debug文件,而这个Debug文件在云服务器上默认是关闭的!
我们可以输入指令ulimit -a
查看:
如果你想解除关闭可以执行指令ulimit -c 1024
将这个Debug文件生成的权限给打开,这样我们在出现像解引用野指针而发送的(11)号信号,就会出现Core文件了!
在云服务器上默认是关闭的,你可以试试多运行几次,你会发现多了很多core的Debug文件,文件的内容主要存储了一些错误信息,这个过程也叫做“核心转储(core dump)”。而假如你未来在公司使用云服务器写了一个脚本,并且不会退出的脚本,那么假设你开了core dump的权限,那么一个可能在你没发现的时候出现了大量的core文件,最后磁盘崩溃,被迫系统重装。因此为了安全考虑,云服务器是会关闭核心转储的权限的!
历史问题关于core dump
还记得我们在学习进程等待的时候,讲解过查看进程的退出码和退出信号那一张位图吗,其中就有一个1比特位的core dump位我们没有讲解:
现在我们可以理解为,出现的异常进程如果会发生核心转储(core dump)时,core dump位就会置为1,我们可以通过代码检测:
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>int main()
{pid_t pid = fork();if(pid == 0){// childint *p = nullptr;*p = 3;}//fatherint status = 0;pid_t rid = waitpid(pid, &status, 0);if(rid < 0){std::cerr << "error tp wait" << std::endl;return 1;}std::cout << "exit code: " << ((status >> 8) & 0xFF) << std::endl;std::cout << "exit signal: " << (status & 0x7F) << std::endl;std::cout << "core dump position: " << ((status >> 7) & 0x01) << std::endl;return 0;
}
信号保存
补充概念
前面我们有对如何进行信号保存有过浅度理解,其实就是在我们每个PCB中都会有一张位图,而这个位图记录了每一个信号的产生与否,这一部分我们就来好好聊聊这个位图。
补充三个概念:
- 实际上,进程接收到信号后对信号做的处理动作(默认处理、忽略处理、自定义捕捉)称为信号递达
- 信号从产生到递达之间的状态,称为信号未决(pending)
- 进程可以选择**阻塞(block)**某个信号。
一旦某个信号被阻塞了,那么对应的信号一旦产生了,永不递达,一直未决,直到主动接触阻塞
一个信号如果阻塞和它是否未决无关!
在内核中的表示
在每个进程的PCB内部,都会维护这三张表,实际上之前我们浅谈的在位图中写信号,展开来说就是上图所示的!
- block表:
是一张位图和pending表一样
比特位的位置:代表信号的编号
比特位的内容:代表信号是否阻塞 - pending表:
也是一张位图和block一样
比特位的位置:代表信号的编号
比特位的内容:代表信号是否可以“收到” - handler表:
类型为sighandler_t handler[]是一个函数指针数组。
数组的下标:就是信号的编号
数组的内容:函数指针,指向处理的动作。
我们们从左往右看,对于(1)号信号,block表为0就代表未被封锁,pending表为1就代表可以收到,对于的处理动作为默认处理。
同理而对于(2)号信号,SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞
所以如何让进程识别信号呢,实际上我们通过 两张位图 + 一张函数指针数组 就能做到!!!
被阻塞的信号产生时将保持未决状态,知道进接触对此资源的阻塞,才执行递达的动作。
注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在抵达之后可选的一种处理操作。
关于sigset_t类型
- sigset_t是一个结构体,结构体内部存在一个数组,由于pending表和block表都是位图,因此它们可以共用一种数据类型sigset_t来进行存储
- sigset_t也被称为信号集,这个类型可以表示每个表对应位置的有效还是无效
- sigset_t内部依赖于系统的实现,用户并不关心内部,因此**用户只能通过OS内部提供的函数接口进行调用**
这些OS提供的接口,只能用来在你自己定义的sigset_t类型的位图上进行操作,但如果你需要修改/获取当前进程的pending/block表,你需要使用特定的接口来实现。
#include <signal.h>int sigemptyset(sigset_t *set); // 将信号集的全部位变成 0
int sigfillset(sigset_t *set); // 将信号集的全部位变成 1
int sigaddset(sigset_t *set, int signo); // 将指定信号添加到信号集中 (将特定比特位变为 1)
int sigdelset(sigset_t *set, int signo); // 将指定信号从信号集中删除 (将特定比特位变为 0)
int sigismember(const sigset_t *set, int signo); // 判断信号集的有效信号中是否包含指定信号 (判断特定比特位是否为 1)
其中我想讲讲,sigismember(const sigset_t *set, int signo)
sigismember 函数:判断信号集的有效信号中是否包含指定信号 (判断特定比特位是否为 1),若包含则返回 1,不包含则返回 0,出错返回-1。
假如我想自己定义一个sigset_t的位图,然后将(2)号信号添加到我的位图里:
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>int main()
{sigset_t myset;// 将信号机的全部位置变成0sigemptyset(&myset); // 将(2)信号添加到信号集中sigaddset(&myset, 2);// 打印信号集for (int i = 1; i <= 31; ++i){if(sigismember(&myset, i)){std::cout << "1";}else {std::cout << "0";}}std::cout << "\n";return 0;
}
信号集处理函数:
在前面讲解了我们上的提供的接口只能用来处理自己定义的位图,而这一部分讲解的函数接口,均是我们通过自己的位图来处理进程内部的block和pending表的!
设置阻塞信号集
#include <signal.h>int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
// 成功返回 0,失败返回 -1
- how:表示准备如何设置当前进程的 block 表。
- set:如果该参数非空,则该参数是用来修改当前进程的 block 表的。
- oldset:如果该参数非空,则该参数是用来获取当前进程的 block 表的。(备份)
how的可选参数:
选项 | 说明 |
---|---|
SIG_BLOCK | set 当中包含了希望添加到当前进程的 block 表的信号 |
SIG_UNBLOCK | set 当中包含了希望从当前进程的 block 表中解除阻塞的信号 |
SIG_SETMASK | 将当前进程的 block 表设置成 set 的内容 |
测试实例:
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>void printset(sigset_t& myset)
{for (int i = 1; i <= 31; ++i){if (sigismember(&myset, i)){std::cout << "1";}else{std::cout << "0";}}std::cout << "\n";
}int main()
{std::cout << "Hi I am process: " << getpid() << std::endl;sigset_t myset, oldset;// 将信号集的全部位置变成0sigemptyset(&myset);sigemptyset(&oldset);// 打印自己的信号集std::cout << "Initializing myset: ";printset(myset);std::cout << "adding (2)signal to myset..." << std::endl;// 将(2)信号添加到信号集中sigaddset(&myset, 2);// 打印自己的信号集std::cout << "Now myset: ";printset(myset);// 封锁(2)号信号sigprocmask(SIG_BLOCK, &myset, &oldset);std::cout << "For now, I have blocked (2)signal" << std::endl;std::cout << "You can try to enter [Ctrl + c] " << std::endl;while(true){sleep(1);}return 0;
}
获取未决信号集
#include <signal.h>int sigpending(sigset_t *set);
通过 sigset_t 信号集获取当前进程的 pending 表。
测试实例:
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>void printset(sigset_t &myset)
{for (int i = 1; i <= 31; ++i){if (sigismember(&myset, i)){std::cout << "1";}else{std::cout << "0";}}std::cout << "\n";
}int main()
{std::cout << "Hi I am process: " << getpid() << std::endl;sigset_t myset, oldset;// 将信号集的全部位置变成0sigemptyset(&myset);sigemptyset(&oldset);// 打印自己的信号集std::cout << "Initializing myset: ";printset(myset);std::cout << "adding (2)signal to myset..." << std::endl;// 将(2)信号添加到信号集中sigaddset(&myset, 2);// 打印自己的信号集std::cout << "Now myset: ";printset(myset);// 封锁(2)号信号sigprocmask(SIG_BLOCK, &myset, &oldset);std::cout << "For now, I have blocked (2)signal" << std::endl;std::cout << "You can try to enter [Ctrl + c] " << std::endl;sleep(3);// 获取当前未决信号集sigset_t newset;sigemptyset(&newset);sigpending(&newset);std::cout << "\n";if (sigismember(&newset, 2)){std::cout << "haha, you can't terminate me" << std::endl;std::cout << "\n";}else{std::cout << "dude, why not press [Ctrl + c]???" << std::endl;std::cout << "\n";}std::cout << "now the pending set: ";printset(newset);sleep(2);int cnt = 5;std::cout << "================================================" << std::endl;std::cout << "Just wait 5s then you can press[Ctrl + c]" << std::endl;while (true){sleep(1);std::cout << "Only need: " << cnt << "s" << std::endl;if (cnt == 0){std::cout << "success to unblock (2)signal!!!" << std::endl;// 利用之前备份的oldset,解除对(2)号信号的封锁sigprocmask(SIG_SETMASK, &oldset, &myset);while(true){// 等待用户按下[Ctrl + c]}}cnt--;}return 0;
}
情况一:最后按[Ctrl + c]
情况二:先按[Ctrl + c]
以上我分演示了两种关于我按下[Ctrl + c]的不同时机,第一种情况还好理解,但是对于第二种,我们发现当一解除对(2)号信号block,我就直接退出了???
原理其实很简单,我的pending表都已经写上了(2)号信号了,你一解除了那我不得立马执行吗?
所以当某个信号被pending了,一旦解除屏蔽,一般就会立即处理该信号!!!
而现在我很好奇,是不是我一解除屏蔽,我的pending表对于的信号位置就被设置为0了呢?
所以我设置了个自定义捕捉的函数signal,并写了一个自定义的headler。
然后我分别在headler内部和刚被解除封锁的位置获取pending表。
经过测试发现,一接触完就先被signal抓走了,并且还会把pending表对应的信号位设置为0
pending位图在递达之前就会被清为0
信号处理
其实我们一直有在做信号处理,信号递达的定义不就是进程接收到信号后对信号做的处理动作(默认处理、忽略处理、自定义捕捉)吗。
而信号可能不会立马被处理,而是会在合适的时候被处理。
具体的时候,就是在“当进程从内核态返回到用户态的时候,进行处理”。
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下: 用户程序注册了SIGQUIT信号的处理函数sighandler。 当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函 数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是 两个独立的控制流程。sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了 。
如果进程对信号的处理是默认或者忽略,那么在内核态经过检验后,就会直接返回当前代码的下一行,这没什么好说的。
但当你如果是什么利用signal进行信号捕捉的话,那操作系统就会从内核态回到用户态,然后执行你的headler,至于为什么要回到用户态呢?首先还是那句话————“操作系统不信任任何人用户”,万一我的用户在headler里写满了类似删除系统重要文件的代码怎么办?所以得进入用户态执行。
而至于为什么还得先回到内核态再返回用户态的下一条语句呢?
- 恢复执行上下文
当信号发生时,操作系统会保存当前进程的执行上下文(包括寄存器状态、程序计数器等),以便稍后能够恢复。为了进入用户定义的信号处理程序,内核会将这一上下文替换为信号处理程序的上下文。在处理完成后,sigreturn
的主要任务就是从内核态恢复原始的执行上下文,以确保程序能准确恢复到信号到达前的状态。
- 内核态权限控制和资源管理
信号处理会涉及到一些特权操作(如修改用户态的栈指针和寄存器状态),这些操作只有在内核态下才允许执行。sigreturn
会使用内核态的权限来恢复原始上下文,包括对程序计数器(PC)的恢复,确保信号处理结束后正确返回到用户态的下一条指令执行。
- 保证系统的一致性和安全性
如果直接从用户态跳转到下一条指令,内核的堆栈、寄存器等关键状态可能无法得到有效恢复。这不仅会影响当前进程的状态,还可能导致系统资源泄露或异常状态。而通过 sigreturn
在内核态进行状态恢复,系统可以确保所有资源和状态的一致性。
- 异常退出处理
在内核中调用 sigreturn
还能让内核检查信号处理是否正常完成。这样,当信号处理程序出现问题(例如被中断或出错)时,系统可以采取相应的补救措施,如结束该进程,避免潜在的错误继续影响系统稳定性。
内核态 && 用户态
用户态(User Mode)
- 定义:用户态是普通应用程序运行的模式。所有应用程序(如浏览器、文本编辑器等)都在用户态下运行。
- 权限:用户态的权限受到严格限制,无法直接访问硬件资源(如内存、CPU、设备等),只能通过操作系统提供的接口(如系统调用)间接操作硬件。
- 安全性:由于权限受限,用户态中的程序即便出错或被恶意修改,也不会影响到操作系统的核心和其他进程的安全性。
内核态(Kernel Mode)
- 定义:内核态是操作系统内核代码运行的模式。在这种模式下,操作系统拥有对系统全部资源的控制权。
- 权限:内核态拥有最高权限,可以直接访问硬件、管理内存和控制所有资源。
- 职责:内核态负责执行所有特权操作,如进程管理、内存管理、设备控制和文件系统操作等。
而对于各自的地址空间来说:
用户态:在 32 位系统中,用户态通常只能使用 0~3GB 的虚拟地址空间,而内核态使用剩余的 1GB;在 64 位系统中,由于虚拟地址空间的扩展,用户态和内核态的地址空间布局更灵活。每个用户进程拥有独立的虚拟地址空间,相互隔离,以确保进程之间不会干扰彼此的数据。
内核态:拥有整个地址空间的访问权,并可以在部分架构中以高地址段(例如 3GB~4GB 或更高)映射进入内核虚拟地址空间。内核态地址空间对所有进程来说是共享的,因为内核代码和数据需要在所有进程中保持一致。
简单来说,内核级页表在整个操作系统中只有一张,因此无论进程如何调度,CPU 都能直接找到 OS。
聊聊信号捕捉
sigaction函数:sigaction
是一个更强大和灵活的信号处理函数,可以设置更多的信号处理选项。它通过 struct sigaction
结构体来定义处理程序,并提供了更详细的信号处理控制。
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
signum
是信号编号。act
是指向包含信号处理程序的sigaction
结构体的指针。oldact
是指向旧信号处理程序的指针,可用于恢复之前的信号处理行为。
简单测试一下:
#include <stdio.h>
#include <signal.h>void handler(int sig) {printf("Caught signal %d\n", sig);
}int main() {struct sigaction action;action.sa_handler = handler;sigemptyset(&action.sa_mask);action.sa_flags = 0;sigaction(SIGINT, &action, NULL); // 使用 sigaction 设置自定义处理程序while (1); // 无限循环return 0;
}
最后的结果我们也是疯狂的按[ctrl + c]也没有任何作用,所以其实它和signal在这方面是一致的!
但是为什么要聊聊他呢?以及为什么要有他呢?
————其实在很久以前unix上,对于我们之前学习的signal函数它存在一个缺陷,signal
的行为可能不一致。例如,它可能会在信号处理程序执行时自动重置为默认处理程序。这意味着每次收到信号后,都需要重新设置处理程序。换言之,我就只能捕捉一次,而后面你再整个[ctrl + c]我就会直接推出了,而对于函数sigaction却不会出现该缺陷。
而在现代 Linux 系统中,signal
函数的行为已改进,不再会自动重置信号处理程序。对于 SIGINT
这样的信号,处理函数会在多次触发时继续保持有效,因此你可以连续按 Ctrl+C
而不会触发默认终止操作。
但是有几点我想说一下:
- 如果当前正在对n号进程进行处理,默认n号信号会被自动屏蔽。
- 对n号信号处理完成的时候,会自动接触对n号信号的屏蔽。
我们也很好的可以验证:一般来说,当我自定义捕捉了(2)号信号,那我一旦输入了[Ctrl + c],我就会被自动捕捉,然后我的pending表对应的(2)号比特位会由1置为0,代表已经实现递达。我可以先在自定义捕捉函数内部设置sleep(30),然后当我再次按下[Ctrl + c]我的pending表对应的(2)号比特位就会变为1,然后我的block表对应的(2)号比特位也会被置为1。
代码实例:
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>void printset(sigset_t &myset)
{for (int i = 1; i <= 31; ++i){if (sigismember(&myset, i)){std::cout << "1";}else{std::cout << "0";}}std::cout << "\n";
}void handler(int sig)
{std::cout << "you can't determinate me!!!" << std::endl;while(true){sigset_t pending_set, block_set;sigemptyset(&pending_set);sigemptyset(&block_set);sigpending(&pending_set);std::cout << "peding set: ";printset(pending_set);sleep(1);}
}int main()
{std::cout << "Hi I am process: " << getpid() << std::endl;struct sigaction act, oact;act.sa_handler = handler;sigemptyset(&act.sa_mask);act.sa_flags = 0;sigaction(2, &act, &oact);std::cout << "I have cathched (2)signal" << std::endl;std::cout << "you can try to press [Ctrl + c]" << std::endl;while(true){}return 0;
}
我退出是靠[Ctrl + \]进行退出的,本质是给进程发送了(3)号信号,你可以在代码里增加
sigaddset(&act.sa_mask, 3)
实现对(3)号进程的捕捉,这样你发送(3)号信号也没有用。
谈谈键盘输入数据的过程
首先我们需要思考一下,操作系统是如何检测键盘的呢?如果操作系统无时无刻的在检测键盘是否输入了,那假设我只开机并不对键盘操作,而操作系统却一直在检测,那岂不是会浪费很多时间和资源吗?因此我们不可能这样去想硬件和操作系统,因为它们是不会采用这么笨的方法的!
事实上,键盘是通过中断的方法来实现的!
中断是一种机制,使计算机系统可以在正常执行过程中暂停当前任务,去响应紧急或重要的事件。中断发生时,处理器会暂时中断当前的指令执行,转而去处理中断事件。处理完中断后,处理器会恢复之前的任务继续执行。这种机制用于快速响应外部或内部的各种事件,是现代计算机系统实现多任务处理的关键。
而8259芯片是一个可编程中断控制器(PIC, Programmable Interrupt Controller),用于管理和控制多个中断请求信号,以便在处理器上实现中断优先级和中断管理。
而每个硬件都会有自己的中断号,键盘也会有自己对应的中断号,而对键盘实现操作实现的方法,是在操作系统中的!既然是在操作系统当中,那我OS就会去找你的中断号然后通过中断号寻找对应专属于你的方法来实现。
在操作系统当中,有一个中断向量表,它的本质是一个函数指针,里面不仅包含了各个硬件执行操作对应的方法,以及还有系统调用的方法。
键盘产生中断请求:
- 键盘在按键按下或松开时,通过中断控制器(如8259 PIC)向CPU发送一个中断请求,通常对应的是中断请求线路
IRQ1
。
中断控制器处理中断请求:
- 中断控制器(8259)接收到键盘的中断请求后,判断该中断的优先级,并将对应的中断号(通常是
IRQ1
映射为中断号 0x21)发送给CPU。 - CPU通过
INTA
信号确认中断,并从中断控制器获取中断号。
CPU识别并跳转中断向量表:
- CPU获取中断号(如0x21)后,会查找中断向量表(IVT),找到与该中断号对应的中断服务程序(ISR,Interrupt Service Routine)的地址。
- CPU跳转到该中断服务程序的地址开始执行,从而进入操作系统内核。
操作系统处理键盘中断:
- 操作系统的键盘中断服务程序会从键盘控制器读取按键数据,并将这些数据放入缓冲区中,以供后续使用。
- 该中断处理完成后,操作系统向中断控制器发送EOI(End of Interrupt)信号,表示中断处理结束。
恢复原有程序执行:
- CPU返回到被中断的程序继续执行,除非有更高优先级的任务或中断需要处理。
当键盘触发中断时,通过中断控制器将中断号传递给CPU,CPU通过中断号在中断向量表中找到相应的中断服务程序,由操作系统负责执行具体的中断处理逻辑。这就是键盘输入数据在底层做的基础流程。
谈谈如理解操作系统的正常运行
如何理解系统调用?
本质上,和硬件发送中断号,然后操作系统拿着中断号去中断向量表中找方法一样,没区别。都是拿着一个号码去表里拿方法来实现。
区别在于,我使用系统调用是从用户的角度来使用的!也就是要以用户态的模式去内核态找到系统调用函数指针表拿取对应方法啊!
而原理是这样的,当我执行系统调用时,进程识别到这是一个系统调用,并且将这个系统调用对应的系统调用号记录下来会先调用int 0x80
指令从用户态切换成内核态,通过指令int 0x80
切换内核态的方法也可以称为是一种陷阱。切换为内核态后再将系统调用号存放至CPU的eax寄存器中,当操作系统识别到eax有数据时,就会通过eax里的系统调用号去寻找对应的方法,然后返回至用户态。
在从用户态切换成内核态前,一定是要对用户态当前处理的位置有保存处理,而通过调用指令int 0x80
切换为内核态时,int 0x80
会导致CPU检查当前代码段的特权级(由cs
寄存器指示)。由于用户程序运行在低特权级(Ring 3),而int 0x80
会将其切换到高特权级(Ring 0)。cs
寄存器保存当前代码段的选择子(Segment Selector),该选择子包含了当前代码段的特权级别。用户态通常处于较低的特权级(Ring 3),而内核态运行在最高的特权级(Ring 0)。而再返回时会从0->3。
CPU会更新cs
寄存器,将其指向内核的代码段选择子,以确保后续执行的是内核代码。一旦cs
寄存器被更新,CPU会转到内核模式,并根据系统调用号执行相应的内核服务例程。内核会根据寄存器中传递的参数执行所请求的操作(例如,文件读写、进程管理等)。
为什么操作系统能一直运行?
如果我们的操作系统处理完所有进程后,岂不是没有任务可以处理了,那操作系统不就会退出了吗?
为了保证操作系统永不退出,我们会在主板上防止一个时钟装置,根据上面了解键盘输入数据的过程,时钟会向CPU发送时钟中断,然后通过时钟中断号去中断向量表里寻找对应的方法,一般是调度方法。
调度方法是干什么的呢?一般是一种检测任务,负责让操作系统先检查对应进程的时间片,如果时间片到了,就先暂停对该进程的处理,将该进程出队列再入队列。如果时间片没到,则忽略。
因此操作系统能一直运行,其实是通过时钟中断不断调度任务实现的一个死循环
可重入函数
- main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的 两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
- 像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。想一下,为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
volatile
#include <stdio.h>
#include <signal.h>int g_flag = 0;void handler(int sig)
{printf("chage g_flag 0 to 1\n");g_flag = 1;
}
int main()
{signal(2, handler);while(!g_flag);printf("process quit normal\n");return 0;
}
标准情况下,键入 CTRL-C ,2号信号被捕捉,执行自定义动作,修改 flag=1 , while 条件不满足,退出循
环,进程退出
而我一旦在编译的时候输入了g++ -o1 test.cc
编译然后运行
然后我按下[Ctrl + c],被步骤后,并不会直接退出。这就是由于我对g++选项进行了优化处理。
上图是我一开始的状态,CPU寄存器的g_val将内存中的g_val的值拷贝进来,当我信号捕捉后,内存的值由0修改为1后,会将内存的值拷贝至CPU寄存器中,然后CPU再识别从而返回。
但当我使用了编译器优化处理后,我只会将内存中的g_val修改成1,并不会对CPU的值进行修改。
所以程序会一直处于进行,不会终止。
SIGCHLD信号
进程一章讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻 塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不 能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。
其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自 定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程 终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。
请编写一个程序完成以下功能:父进程fork出子进程,子进程调用exit(2)终止,父进程自定 义SIGCHLD信号的处理函数,在其中调用wait获得子进程的退出状态并打印。
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不 会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可 用。请编写程序验证这样做不会产生僵尸进程
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
void handler(int sig)
{pid_t id;while( (id = waitpid(-1, NULL, WNOHANG)) > 0){printf("wait child success: %d\n", id);}printf("child is quit! %d\n", getpid());
}
int main()
{signal(SIGCHLD, handler);cid = fork();if( cid == 0){//childprintf("child : %d\n", getpid());sleep(3);exit(1);}while(1){printf("father proc is doing some thing!\n");sleep(1);}
return 0;
}