一、快速认识信号
1-1 生活中的信号
- 你在网上买了很多件商品,再等待不同商品快递的到来;但即便快递没有到来,你也知道快递来临时,你该怎么处理快递;也就是你能"识别快递"
- 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递;那么在在这5min之内,你并没有下去取快递,但是你是知道有快递到来了;也就是取快递的行为并不是⼀定要立即执行,可以理解成"在合适的时候去取"
- 在收到通知,再到你拿到快递期间,是有⼀个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有⼀个快递已经来了;本质上是你"记住了有⼀个快递要去取"
- 当你时间合适,顺利拿到快递之后,就要开始处理快递了;而处理快递⼀般方式有三种:Ⅰ 执行默认动作(幸福的打开快递,使用商品)Ⅱ 执行自定义动作(快递是零⻝,你要送给你你的女朋友)Ⅲ 忽略快递(快递拿上来之后,扔掉床头,继续开⼀把游戏)
- 快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话
📍基本结论:
- 你怎么能识别信号呢?识别信号是内置的,进程识别信号,是内核程序员写的内置特性
- 信号产生之后,你知道怎么处理吗?知道,如果信号没有产生,你知道怎么处理信号吗? 知道;所以,信号的处理方法,在信号产⽣之前,已经准备好了
- 处理信号,立即处理吗?我可能正在做优先级更高的事情,不会立即处理?什么时候?合适的时候
- 信号到来 | 信号保存 | 信号处理
- 怎么进行信号处理啊?a.默认 b.忽略 c.自定义,后续都叫做信号捕捉
1-2 技术应用角度的信号
(1) 一个例子
cpp#include <iostream> #include <unistd.h> int main() { while(true) { std::cout << "I am a process, I am waiting signal!" << std::endl; sleep(1); } }
- 用户输⼊命令,在Shell下启动⼀个前台进程
- 用户按下Ctrl+C,这个键盘输⼊产生⼀个硬件中断,被OS获取,解释成信号,发送给目标前台进程
- 前台进程因为收到信号,进而引起进程退出
(2) 一个系统函数
cppNAME signal - ANSI C signal handling SYNOPSIS #include <signal.h> typedef void (*sighandler_t)(int); sighandler_t signal(int signum, sighandler_t handler); 参数说明: signum:信号编号[后⾯解释,只需要知道是数字即可] handler:函数指针,表⽰更改信号的处理动作,当收到对应的信号,就回调执⾏handler⽅法
- 而其实, Ctrl+C 的本质是向前台进程发送 SIGINT 即2号信号,我们证明⼀下,这里需要引入⼀个系统调用函数
cpp#include <iostream> #include <unistd.h> #include <signal.h> void handler(int signumber) { std::cout << "我是: " << getpid() << ", 我获得了⼀个信号: " << signumber << std::endl; } int main() { std::cout << "我是进程: " << getpid() << std::endl; signal(SIGINT/*2*/, handler); while(true) { std::cout << "I am a process, I am waiting signal!" << std::endl; sleep(1); } }📍 思考:
- 这里进程为什么不退出
- 这个例子说明哪些问题?信号处理,是自己处理
- 请将生活例子和 Ctrl + C 信号处理过程相结合,解释下信号处理过程?进程就是你,操作系统就是快递员,信号就是快递,发信号的过程就类似给你打电话
📍 注意:
- 要注意的是,signal函数仅仅是设置了特定信号的捕捉行为处理方式,并不是直接调用处理动作;如果后续特定信号没有产生,设置的捕捉函数永远也不会被调用!!
- Ctrl + C产生的信号只能发给前台进程,一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程
当我们把当前进程放到后台运行时,发送9号信号会杀死进程,但是并不会立即退出到命令行,因为前台进程并不知道后台进程接受到了信号,只会继续等待输入;只有当你发送2号信号给前台进程组后,才能重新退回到命令行
也可以使用下AI怎么把后台进程放到前台
- Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl+C这种控制键产生的信号
- 前台进程在运行过程中用户随时可能按下 Ctrl+C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号终止,所以信号相对于进程的控制流程来说是异步的
- 关于进程间关系,我们在网络部分会专门来讲
- 可以渗透 & 和 nohup
📍 实践结果
1-3 信号概念
信号是进程之间事件异步通知的⼀种方式,属于软中断
(1) 查看信号
每个信号都有⼀个编号和⼀个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定义
编号34以上的是实时信号,本章只讨论编号34以下的信号,不讨论实时信号;这些信号各自在什么条件下产生,默认的处理动作是什么,在signal(7)中都有详细说明:man 7 signal
(2) 信号处理
(sigaction 函数稍后详细介绍),可选的处理动作有以下三种:
- 忽略此信号
cpp#include <iostream> #include <unistd.h> #include <signal.h> void handler(int signumber) { std::cout << "我是: " << getpid() << ", 我获得了⼀个信号: " << signumber << std::endl; } int main() { std::cout << "我是进程: " << getpid() << std::endl; signal(SIGINT/*2*/, SIG_IGN); // 设置忽略信号的宏 while(true) { std::cout << "I am a process, I am waiting signal!" << std::endl; sleep(1); } }把2号信号忽略了,无法通过2号信号终止进程了
- 执行该信号的默认处理动作
cpp#include <iostream> #include <unistd.h> #include <signal.h> void handler(int signumber) { std::cout << "我是: " << getpid() << ", 我获得了⼀个信号: " << signumber << std::endl; } int main() { std::cout << "我是进程: " << getpid() << std::endl; signal(SIGINT/*2*/, SIG_DFL); while(true) { std::cout << "I am a process, I am waiting signal!" << std::endl; sleep(1); } }
- 提供⼀个信号处理函数,要求内核在处理该信号时切换到用户态执⾏这个处理函数,这种⽅式称为自定义捕捉(Catch)⼀个信号
cpp#include <iostream> #include <unistd.h> #include <signal.h> void handler(int signumber) { std::cout << "我是: " << getpid() << ", 我获得了⼀个信号: " << signumber << std::endl; } int main() { std::cout << "我是进程: " << getpid() << std::endl; signal(SIGINT/*2*/, handler); while(true) { std::cout << "I am a process, I am waiting signal!" << std::endl; sleep(1); } }📍 注意看源码:
cpp#define SIG_DFL ((__sighandler_t) 0) /* Default action. */ #define SIG_IGN ((__sighandler_t) 1) /* Ignore signal. */ /* Type of a signal handler. */ typedef void (*__sighandler_t) (int); // 其实SIG_DFL和SIG_IGN就是把0,1强转为函数指针类型上面的所有内容,我们都没有做非常多的解释,主要是先用起来,然后渗透部分概念和共识,下面我们从理论和实操两个层面,来进行对信号的详细学习、论证和理解;为了保证条理,我们采用如下思路来进行阐述:
二、产生信号
当前阶段:
2-1 通过终端按键产⽣信号
(1) 基本操作
- Ctrl+C (SIGINT) 已经验证过,这里不再重复
- Ctrl+\(SIGQUIT)可以发送终⽌信号并生成core dump文件,用于事后调试(后⾯详谈)
cpp#include <iostream> #include <unistd.h> #include <signal.h> void handler(int signumber) { std::cout << "我是: " << getpid() << ", 我获得了⼀个信号: " << signumber << std::endl; } int main() { std::cout << "我是进程: " << getpid() << std::endl; signal(SIGQUIT/*3*/, handler); while(true) { std::cout << "I am a process, I am waiting signal!" << std::endl; sleep(1); } }
- Ctrl+Z(SIGTSTP)可以发送停止信号,将当前前台进程挂起到后台等
cpp#include <iostream> #include <unistd.h> #include <signal.h> void handler(int signumber) { std::cout << "我是: " << getpid() << ", 我获得了⼀个信号: " << signumber << std::endl; } int main() { std::cout << "我是进程: " << getpid() << std::endl; signal(SIGTSTP/*20*/, handler); while(true) { std::cout << "I am a process, I am waiting signal!" << std::endl; sleep(1); } }(2) 理解OS如何得知键盘有数据
(3) 初步理解信号起源
📍 注意:
- 信号其实是从纯软件角度,模拟硬件中断的行为
- 只不过硬件中断是发给CPU,而信号是发给进程
- 两者有相似性,但是层级不同,这点我们后面的感觉会更加明显
首先在后台执行死循环程序,然后用kill命令给它发SIGSEGV信号
2-2调用系统命令向进程发信号
cpp#include <iostream> #include <unistd.h> #include <signal.h> int main() { while(true) { sleep(1); } }
- 1117415是sig进程的pid;之所以要再次回车才显示Segmentation fault,是因为在1117415进程终止掉之前已经回到了Shell提示符等待用户输入下一条命令,Shell不希望Segmentation fault信息和用户的输入交错在一起,所以等用户输入命令之后才显示
- 指定发送某种信号的kill命令可以有多种选法,所以上面的命令可以写成kill -11 1117415,11是信号SIGSEGV的编号;以往遇到的段错误都是由非法访问产生的,而这个程序本身没错,给它发SIGSEGV也能产生段错误
2-3 使用函数产生信号
(1) kill
kill 命令是调用 kill 函数实现的;kill 函数可以给⼀个指定的进程发送指定的信号
cppNAME kill - send signal to a process SYNOPSIS #include <sys/types.h> #include <signal.h> int kill(pid_t pid, int sig); RETURN VALUE On success (at least one signal was sent), zero is returned. On error,-1 is returned, and errno is set appropriately.自己的kill命令
cpp#include <iostream> #include <unistd.h> #include <sys/types.h> #include <signal.h> int main(int argc,char* argv[]) { if(argc!=3) { std::cerr<<"Usage: "<<argv[1]<<""<<std::endl; return 1; } int number=std::stoi(argv[1]+1); pid_t pid=std::stoi(argv[2]); int n=kill(pid,number); return 0; }(2) raise
raise函数可以给当前进程发送指定的信号 (自己给自己发信号)
cppNAME raise - send a signal to the caller SYNOPSIS #include <signal.h> int raise(int sig); RETURN VALUE raise() returns 0 on success, and nonzero for failure.实验现象
(3) abort
abort 函数使当前进程接收到信号而异常终止
cppNAME abort - cause abnormal process termination SYNOPSIS #include <stdlib.h> void abort(void); RETURN VALUE The abort() function never returns. // 就像exit函数⼀样,abort函数总是会成功的,所以没有返回值实验现象
2-4 由软件条件产生信号
SIGPIPE是⼀种由软件条件产生的信号,在"管道"中已经介绍过了;本节主要介绍 alarm函数和 SIGALRM 信号
cppNAME alarm - set an alarm clock for delivery of a signal SYNOPSIS #include <unistd.h> unsigned int alarm(unsigned int seconds); RETURN VALUE alarm() returns the number of seconds remaining until any previously scheduled alarm was due to be delivered, or zero if there was no previously scheduled alarm.
- 调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号,该信号的默认处理动作是终止当前进程
- 这个函数的返回值是0或者是以前谁当的闹钟时间还余下的秒数;打个比方,某人要小睡一觉,设定闹钟为30分钟后响,20分钟后被人吵醒了,还想多睡一会,于是重新设定闹钟为15分钟之后响,"以前设定的闹钟时间还余下的时间"就是10分钟;如果seconds值为0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数
(1) 基本alarm验证-体会IO效率问题
- 程序的作用是1秒钟之内不停地数数,1秒钟到了就被SIGALRM信号终⽌
- 必要的时候,对SIGALRM信号进行捕捉
cpp// IO 多 #include <iostream> #include <unistd.h> #include <signal.h> int main() { int count = 0; alarm(1); while(true) { std::cout << "count : " << count << std::endl; count++; } return 0; }实验现象
cpp// IO 少 #include <iostream> #include <unistd.h> #include <signal.h> int count = 0; void handler(int signumber) { std::cout << "count : " << count << std::endl; exit(0); } int main() { signal(SIGALRM, handler); alarm(1); while (true) { count++; } return 0; }实验现象
📍 结论
- 闹钟会响⼀次,默认终止进程
- 有IO效率低
(2) 设置重复闹钟
cpp#include <iostream> #include <unistd.h> #include <signal.h> #include <vector> #include <functional> using func_t = std::function<void()>; int gcount = 0; std::vector<func_t> gfuncs; // 把信号 更换 成为 硬件中断 void hanlder(int signo) { for(auto &f : gfuncs) { f(); } std::cout << "gcount : " << gcount << std::endl; int n = alarm(1); // 重设闹钟,会返回上⼀次闹钟的剩余时间 std::cout << "剩余时间 : " << n << std::endl; } int main() { //gfuncs.push_back([](){ std::cout << "我是⼀个内核刷新操作" << std::endl; }); //gfuncs.push_back([](){ std::cout << "我是⼀个检测进程时间⽚的操作,如果时间⽚ 到了,我会切换进程" << std::endl; }); //gfuncs.push_back([](){ std::cout << "我是⼀个内存管理操作,定期清理操作系统内 部的内存碎⽚" << std::endl; }); alarm(1); // ⼀次性的闹钟,超时alarm会⾃动被取消 signal(SIGALRM, hanlder); while (true) { pause(); std::cout << "我醒来了..." << std::endl; gcount++; } } NAME pause - wait for signal SYNOPSIS #include <unistd.h> int pause(void); DESCRIPTION pause() causes the calling process (or thread) to sleep until a signal is delivered that either terminates the process or causes the invocation of a signal-catching function. RETURN VALUE pause() returns only when a signal was caught and the signal-catching function returned. In this case, pause() returns -1, and errno is set to EINTR.📍 提前唤醒的闹钟,返回值为剩余的时间;即就是闹钟将要被执行,还需要多久的时间
- 闹钟设置⼀次,起效⼀次
- 重复设置的方法
- 如果时间允许,可以测试⼀下 alarm(0)
(3) 如何理解软件条件
在操作系统中,信号的软件条件指的是由软件内部状态或特定软件操作触发的信号产生机制;这些条件包括但不限于定时器超时(如alarm函数设定的时间到达)、软件异常(如向已关闭的管道写数据产生的SIGPIPE信号)等;当这些软件条件满足时,操作系统会向相关进程发送相应的信号,以通知进程进行相应的处理;简而言之,软件条件是因操作系统内部或外部软件操作而触发的信号产生
(4) 如何简单快速理解系统闹钟
系统闹钟,其实本质是OS必须自身具有定时功能,并能让用户设置这种定时功能,才可能实现闹钟这样的技术
现代Linux是提供了定时功能的,定时器也要被管理:先描述,在组织;内核中的定时器数据结构是:
cppstruct timer_list { struct list_head entry; unsigned long expires; void (*function)(unsigned long); unsigned long data; struct tvec_t_base_s *base; };我们不在这部分进行深究,为了理解它,我们可以看到:定时器超时时间expires和处理方法function
操作系统管理定时器,采用的是时间轮的做法,但是我们为了简单理解,可以把它在组织成为"堆结构
2-5 硬件异常产生信号
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号;例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE信号发送给进程;再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程
(1) 模拟除0
cpp#include <stdio.h> #include <signal.h> void handler(int sig) { printf("catch a sig : %d\n", sig); } // v1 int main() { //signal(SIGFPE, handler); // 8) SIGFPE sleep(1); int a = 10; a/=0; while(1); return 0; }(2) 模拟野指针
cpp//默认⾏为 #include <stdio.h> #include <signal.h> void handler(int sig) { printf("catch a sig : %d\n", sig); } int main() { //signal(SIGSEGV, handler); sleep(1); int *p = NULL; *p = 100; while(1); return 0; } //捕捉⾏为 #include <stdio.h> #include <signal.h> void handler(int sig) { printf("catch a sig : %d\n", sig); } int main() { //signal(SIGSEGV, handler); sleep(1); int *p = NULL; *p = 100; while(1); return 0; }由此可以确认,我们在C / C++当中除零,内存越界等异常,在系统层面上,是被当成信号处理的
📍 注意:
- 通过上⾯的实验,我们可能发现:
- 发现⼀直有8号信号产生被我们捕获,这是为什么呢?上面我们只提到CPU运算异常后,如何处理后续的流程,实际上 OS 会检查应用程序的异常情况,其实在CPU中有⼀些控制和状态寄存器,主要用于控制处理器的操作,通常由操作系统代码使用;状态寄存器可以简单理解为⼀个位图,对应着⼀些状态标记位、溢出标记位;OS 会检测是否存在异常状态,有异常存在就会调用对应的异常处理方法;除零异常后,我们并没有清理内存,关闭进程打开的文件,切换进程等操作,所以CPU中还保留上下文数据以及寄存器内容,除零异常会⼀直存在,就有了我们看到的⼀直发出异常信号的现象(就是会返回到原来程序,可能会一直执行div 0操作);访问非法内存其实也是如此,大家可以自行实验
(3) 子进程退出core dump
先见一见core
(4) Core Dump
- SIGINT的默认处理动作是终止进程,SIGQUIT的默认处理动作是终止进程并且Core Dump,现在我们来验证一下
- 首先解释什么是Core Dump;当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常时core,这叫做Core Dump
- 进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做 Post-mortem Debug(事后调试)
- 一个进程允许产生多大的core文件取决于进程的 Resource Limit(这个信息保存在PCB中);默认是不允许产生core为文件的,因为core文件中可能包含用户密码等敏感信息,不安全
- 在开发调试阶段可以用 ulimit 命令改变这个限制,允许产生core文件;首先用 ulimit 命令改变 Shell 进程的Resource Limit,如允许core文件最大为 1024k :$ ulimit -c 1024
(5) 总结
- 上面所说的所以信号产生,最终都要有OS来进行执行,为什么?OS是进程的管理者
- 信号的处理是否立即处理的?不,在合适的时候
- 信号如果不是被立即处理,那么信号是否需要暂时被进程记录下来?记录在哪里最合适?
- 一个进程在没有收到信号的时候,是否能知道,自己应该对合法信号做何处理呢?
- 如何理解OS向进程发送信号?能否描述下完整的发送处理过程?
三、保存信号
当前阶段
3-1 信号其他相关常见概念
- 实际执行信号的处理动作称为信号递达
- 信号从产生到递达之间的状态,称为信号未决
- 进程可以选择阻塞(block)某个信号
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作
- 注意:阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作
3-2 在内核中的表示
信号在内核中的表示意图
- 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作;信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志;在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作
- SIGINT信号产生过,但正在被阻塞,所以暂时不能递达;虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞
- SIGQUIT信号未产生过,⼀旦产生 SIGQUIT 信号将被阻塞,它的处理动作是用户自定义函数 sighandler
- 如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号⼀次或多次;Linux是这样实现的:常规信号在递达之前产生多次只计⼀次,而实时信号在递达之前产生多次可以依次放在⼀个队列里;本章不讨论实时信号
内核结构 2.6.18
cppstruct task_struct { ... /* signal handlers */ struct sighand_struct *sighand; sigset_t blocked struct sigpending pending; ... } struct sighand_struct { atomic_t count; struct k_sigaction action[_NSIG]; // #define _NSIG 64 spinlock_t siglock; }; struct __new_sigaction { __sighandler_t sa_handler; unsigned long sa_flags; void (*sa_restorer)(void); /* Not used by Linux/SPARC */ __new_sigset_t sa_mask; }; struct k_sigaction { struct __new_sigaction sa; void __user *ka_restorer; }; /* Type of a signal handler. */ typedef void (*__sighandler_t)(int); struct sigpending { struct list_head list; sigset_t signal; };actio[_NSIG]:里面同时存在默认的函数执行方式和我们自定义的函数处理方式
3-3 sigset_t
从上图来看,每个信号只有⼀个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的;因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,这个类型可以表示每个信号的"有效"或"无效"状态,在阻塞信号集中"有效"和"无效"的含义是该信号是否被阻塞,而在未决信号集中"有效"和"无效"的含义是该信号是否处于未决状态;下⼀节将详细介绍信号集的各种操作;阻塞信号集也叫做当前进程的这里的"屏蔽"应该理解为阻塞而不是忽略
3-4 信号集操作函数
sigset_t类型对于每种信号用⼀个bit表示"有效"或"无效"状态,至于这个类型内部如何存储这些 bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_t变量,而不应该对它的内部数据做任何解释,比如用 printf 直接打印 sigset_t变量是没有意义的
cpp#include <signal.h> int sigemptyset(sigset_t *set); int sigfillset(sigset_t *set); int sigaddset(sigset_t *set, int signo); int sigdelset(sigset_t *set, int signo); int sigismember(const sigset_t *set, int signo);
- 函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号
- 函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统⽀持的所有信号
- 注意:在使用sigset_t类型的变量之前,⼀定要调用sigemptyset或sigfillset做初始化,使信号集处确定的状态;初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号
这四个函数都是成功返回0,出错返回-1;sigismember是⼀个布尔函数,用于判断⼀个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1
(1) sigprocmask
调用函数 sigprocmask 可以读取或更改进程的信号屏蔽字(阻塞信号集)
cpp#include <signal.h> int sigprocmask(int how, const sigset_t *set, sigset_t *oset); 返回值:若成功则为0,若出错则为-1如果 set 是非空指针,则读取进程的当前信号屏蔽字通过set参数传出;如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改;如果 set 和 oset 都是非空指针,则先将原来的信号屏蔽字备份到set⾥,然后根据set和how参数更改信号屏蔽字;假设当前的信号屏蔽字为mask,下表说明了how参数的可选值
如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中⼀个信号递达
(2) sigpending
cpp#include <signal.h> int sigpending(sigset_t *set); 读取当前进程的未决信号集,通过set参数传出。 调⽤成功则返回0,出错则返回-1
四、 捕捉信号
当前阶段
4-1 信号捕捉的流程
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号
由于信号处理函数的代码是在用户空间的,处理过程⽐较复杂,举例如下:
- 用户注册了 SIGQUIT 信号的处理函数 sighandler
- 当前正在执行 main 函数,这时发生中断或异常切换到内核态
- 在中断处理完毕后要返回用户态的 main 函数之前检查到有信号 SIGQUIT 递达
- 内核决定返回用户态后不是恢复 main 函数的上下文继续执行,而是执行 sighandler 函数,sighandler 和 main 使用不同的堆栈空间,他们之间不存在调用和被调用的关系,两个独立的控制流程
- sighandler 函数返回后自动执行特殊的系统调用 sigreturn 再次进入内核态
- 如果没有新的信号要递达,这次再返回用户态就是恢复 main 函数的上下文继续执行了
4-2 sigaction
cpp#include <signal.h> int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
- sigaction 函数可以读取和修改指定信号相关联的处理动作;调用成功则返回0,出错则返回-1;signo是指定信号的编号;若act指针非空,则根据act修改该信号的处理动作;若oact指针非空,则通过oact传出该信号原来的处理动作;act和oact指向sigaction结构体
- 将 sa_handler 赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为⼀个函数指针表示用自定义函数捕捉信号,或者说向内核注册了⼀个信号处理函数,该函数返回值为void,可以带⼀个int参数,通过参数可以得知当前信号的编号,这样就可以用同⼀个函数处理多种信号;显然,这也是⼀个回调函数,不是被main函数调用,而是被系统所调用
当某个信号的处理函数被调用时,内核自动将当前信号加⼊进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止;如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外⼀些信号,则用 sa_mask 字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字;sa_flags 字段包含⼀些选项,本章的代码都把sa_flags设为0,sa_sigaction是实时信号的处理函数,本章不详细解释这两个字段,有兴趣的同学可以在了解⼀下
4-3 穿插话题 - 操作系统是怎么运行的
(1) 硬件中断
- 中断向量表就是操作系统的⼀部分,启动就加载到内存中了
- 通过外部硬件中断,操作系统就不需要对外设进行任何周期性的检测或者轮询
- 由外部设备触发的,中断系统运行流程,叫做硬件中断
(2) 时钟中断
问题:
- 进程可以在操作系统的指挥下,被调度,被执行,那么操作系统自己被谁指挥,被谁推动执行呢?
- 外部设备可以触发硬件中断,但是这个是需要用户或者设备自己触发,有没有自己可以定期触发的设备?
(3) 死循环
如果是这样,操作系统不就可以躺平了吗?对,操作系统自己不做任何事情,需要什么功能,就向中断向量表里卖弄添加方法即可;操作系统的本质:就是⼀个死循环!
- 这样,操作系统,就可以在硬件时钟的推动下,自动调度了
- 所以,什么是时间片?CPU为什么会有主频?为什么主频越快,CPU越快?主频可以作为OS调度执行速度的参考之一
(4) 软中断
- 上述外部硬件中断,需要硬件设备触发
- 有没有可能,因为软件原因,也触发上面的逻辑?有!
- 为了让操作系统支持进行系统调用,CPU也设计了对应的汇编指令(int 或者 syscall),可以让CPU内部触发中断逻辑
问题:
- 用户层怎么把系统调用号给操作系统? - 寄存器(比如EAX)
- 操作系统怎么把返回值给用户?- 寄存器或者用户传入的缓冲区地址
- 系统调用的过程,其实就是先int 0x80、syscall陷入内核,本质就是触发软中断,CPU就会自动执行系统调用的处理方法,而这个方法会根据系统调用号,自动查表,执行对应的方法
- 系统调用号的本质:数组下标!
问题:
- 可是为什么我们用的系统调用,从来没有见过什么int 0x80 或者 syscall呢?都是直接调用上层的函数的啊?
那是因为Linux的gnuC标准库,给我们把几乎所有的系统调用全部封装了;我们调用的是封装后的接口,而非直接写汇编指令;int 0x80和syscall是CPU指令级别的系统调用入口,就是从用户态切换到内核态的指令;是所有系统调用的底层入口;当我们执行到这两个指令,中间经历了两层封装
(5) 缺页中断?内存碎片处理?除零野指针错误?
- 缺页中断?内存碎片处理?除零野指针错误?这些问题,全部都会被转换成为CPU内部的软中断, 然后走中断处理例程,完成所有处理;有的是进行申请内存,填充页表,进行映射的;有的是用来处理内存碎片的,有的是用来给目标进行发送信号,杀掉进程等
📍 总结:
- 操作系统就是躺在中断处理例程上的代码块
- CPU内部的软中断,比如int 0x80或者syscall,我们叫做陷阱
- CPU内部的软中断,比如除零 / 野指针等,我们叫做异常
4-4 如何理解内核态和用户态
结论:
- 操作系统无论怎么切换进程,都能找到同一个操作系统!换句话说操作系统系统调用方法的执行,是在进程的地址空间中执行的!
- 关于特权级别,涉及到段,段描述符,段选择,DPL,CPL,RPL等概念,而现在芯片为了保证兼容性,已经非常复杂了,进而导致OS也必须得照顾它得复杂性,这块我们就不做深究了
- 用户态就是执行用户[0,3]GB时所处的状态
- 用户态就是执行用户[0,3]GB时所处的状态
- 区分就是按照CPU内的CPL决定,CPL的全称是Current Privilege Level,即当前特权级别
- 一般执行 int 0x80 或者 syscall 软中断,CPL会在校验之后自动变更(怎么校验看学生反应)
- 这样会不会不安全?
五、 可重入函数
- 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
- 该关键字在C当中我们已经有所涉猎,今天我们站在信号的角度重新理解⼀下
cpp#include <stdio.h> #include <signal.h> int flag = 0; void handler(int sig) { printf("chage flag 0 to 1\n"); flag = 1; } int main() { signal(2, handler); while(!flag); printf("process quit normal\n"); return 0; } [hb@localhost code_test]$ cat Makefile sig:sig.c gcc -o sig sig.c #-O2 .PHONY:clean clean: rm -f sig [hb@localhost code_test]$ ./sig ^Cchage flag 0 to 1 process quit normal标准情况下,键入CTRL+C,2号信号被捕捉,执行自定义动作,修改flag=1 ,while条件不满足,退出循环,进程退出
cpp#include <stdio.h> #include <signal.h> int flag = 0; void handler(int sig) { printf("chage flag 0 to 1\n"); flag = 1; } int main() { signal(2, handler); while(!flag); printf("process quit normal\n"); return 0; } [hb@localhost code_test]$ cat Makefile sig:sig.c gcc -o sig sig.c -O2 .PHONY:clean clean: rm -f sig [hb@localhost code_test]$ ./sig ^Cchage flag 0 to 1 ^Cchage flag 0 to 1 ^Cchage flag 0 to 1优化情况下,键⼊CTRL-C ,2号信号被捕捉,执行自定义动作,修改flag=1 ,但是 while 条件依旧满足,进程继续运行!但是很明显flag肯定已经被修改了,但是为何循环依旧执行?很明显,while循环检查的 flag,并不是内存中最新的flag,这就存在了数据⼆异性的问题;while 检测的 flag 其实已经因为优化,被放在了CPU寄存器当中;如何解决呢?很明显需要 volatile
cpp#include <stdio.h> #include <signal.h> volatile int flag = 0; void handler(int sig) { printf("chage flag 0 to 1\n"); flag = 1; } int main() { signal(2, handler); while(!flag); printf("process quit normal\n"); return 0; } [hb@localhost code_test]$ cat Makefile sig:sig.c gcc -o sig sig.c -O2 .PHONY:clean clean: rm -f sig [hb@localhost code_test]$ ./sig ^Cchage flag 0 to 1 process quit normal
- **volatile作用:**保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作
七、 SIGCHLD信号 - 选学了解
进程⼀章讲过用 wait 和 waitpid 函数清理僵⼫进程,父进程可以阻塞等待⼦进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式);采用第⼀种⽅式,父进程阻塞了就不能处理自己的工作了;采用第⼆种方式,父进程在处理自己的⼯作的同时还要记得时不时地轮询⼀下,程序实现复杂
其实,子进程在终止时会给⽗进程发SIGCHLD信号,该信号的默认处理动作是忽略,⽗进程可以自定义 SIGCHLD 信号的处理函数,这样父进程只需专心处理自己的⼯作,不必关心子进程了,⼦进程终⽌时会通知⽗进程,父进程在信号处理函数中调用 wait 清理⼦进程即可
请编写⼀个程序完成以下功能:父进程fork出⼦进程,⼦进程调用 exit(2) 终⽌,父进程自定义 SIGCHLD 信号的处理函数,在其中调用 wait 获得子进程的退出状态并打印
事实上,由于UNIX的历史原因,要想不产生僵尸进程还有另外⼀种办法:父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的⼦进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程;系统默认的忽略动作和用户用 sigaction 函数⾃定义的忽略通常是没有区别的,但这 是⼀个特例;此方法对于 Linux 可用,但不保证在其它UNIX系统上都可用;请编写程序验证这样做不会产生僵尸进程
cpp#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); pid_t cid; if((cid = fork()) == 0) { //child printf("child : %d\n", getpid()); sleep(3); exit(1); } while(1) { printf("father proc is doing some thing!\n"); sleep(1); } return 0; }
八、 补充
- 信号与信号量无关
- 识别信号是内置的,进程识别信号,是内核程序员写的内置特性
- 信号的生命周期:由内核的信号管理机制和目标进程的信号相关配置共同决定(信号产生------>信号保存------>信号处理)
- 1---31:普通信号;34--->64信号:实时信号(0信号)
- signal函数:调用一次即可,不需要重复调用,函数返回的是老动作的函数指针
- 细节1:
信号自定义捕捉如果你捕捉方法,不退出,进程就有可能不退出;如若我把信号全都自定义了,都不退出么?
答:错!9号信号不可被自定义,不可被忽略,又称管理员信号
- 细节2:
信号处理,是谁处理?有没有创建新进程?
答:信号处理,是进程自己完成的,不需要创建新进程
- 信号产生
- 信号只能用来控制前台进程,无法控制后台进程------>因为只有前台进程才能获取键盘输入
- 为什么bash进程不对信号做响应?
答:因为bash忽略了所有的信号
- 如果div 0或者 *野指针等,进程为什么会崩溃?
答:本质是因为程序异常,导致收到信号,然后让进程终止
- 为什么会收到信号?OS怎么知道你在div 0 | 野指针
答:div 0属于CPU出错,*p=xx,本质MMU+页表------>MMU报错;也并不是只有错误才让OS知道,像中断和系统调用照样能唤醒OS(唤醒的意思是,此时OS占用CPU资源,因为只有在CPU上才能运算)
- 信号位图在task_struct,修改位图本质就是修改 task_struct 内核数据结构(只能由OS修改),本质:修改信号位图------写信号
- div 0 | 野指针为什么会出发硬件报错?
答:CPU中有各种寄存器,其中有一个标志寄存器 EFlags,其中在这个寄存器上写信息0 / 1;0代表正常执行,1代表错误执行
- 核心转储(core dump)
📍 云服务器,默认是把核心转储功能关闭的
- 节省磁盘资源:core dump文件会完整保存进程崩溃时的内存镜像,大量的core文件会快速消耗尽磁盘空间,引发服务器异常等问题
- 降低安全隐患:core文件包含敏感数据;若服务器被入侵,攻击者通过读取core文件窃取信息;并且云服务器中多用户共享底层资源
- 避免高频崩溃的额外开销:在程序出现严重BUG时,进程高频崩溃且生成大量core文件,频繁I / O会占用CPU和宽带资源,影响其他正常服务运行;大量core会造成增加人员的运维和清理成本
- core dump功能用于调试阶段定位进程崩溃问题,调试完后建议立即关闭
🔺Term就是单纯终止进程
Core可能会发生核心转储,设置进程退出status core dump标志位给父进程(内核传给父的子退出状态中,专门设置一个标志位,用于标识该子进程是因为崩溃生成了core dump而退出)
- alarm函数
- 给caller设置闹钟(是一次性闹钟)
- 上一个闹钟的剩余时间(就是距离执行这个闹钟还要多久)
- 若second=0,代表取消闹钟
- 如何理解闹钟
- 本质:定时器
- 对于单进程一次性定时器,内核不会用最小堆来管理,因为一个进程只有一个定时器
- 对于内核的全局定时器子系统,会使用基于最小堆思想的高效数据结构
- 信号保存
📍 为什么要信号要立即保存------>信号不会立即处理,产生之后,处理之前,就有时间窗口!必须被保存起来
- block表(信号屏蔽字)
- 位图结构,本质是一个信号掩码(阻塞并不是一种处理信号的方式)
- 屏蔽字指定信号递达
- 9号信号和19号信号不会被block
- pending表(未决表)
- 位图结构,记录进程已经产生但尚未递达的信号
- 暂存被阻塞的信号
- 此表是只读的,用户进程无法直接修改,只能通过解除阻塞或处理信号让内核自动清零
- 处理函数表
- 函数指针数组,数组下标=信号编号 - 1
- 定义信号递达时的处理行为
📍 OS让用户控制信号,,本质就是访问和操作上面三个表
- 使用sigpending()可以获得 / 设置进程当前pending
- 使用sigprocmask()可以获得 / 设置block
🔺 调用对应函数,对应的pending表应该怎么变化?
答:在调用handler之前,变为0
- 信号捕捉
📍 处理信号合适时机------>从内核态返回用户态时,进行信号的检测与处理
- 用户态:进程执行时,访问数据,都在访问[0,3GB]地址空间的时候,就是访问用户自己的代码,自己的数据
- 内核态:都在访问[3GB,4GB]地址空间时,就是访问OS的过程
- 信号捕捉机制
- 用户态执行指令时,因中断、异常或系统调用切换到内核态
- 内核态处理完事件后,准备返回用户态前,检查当前进程是否有待处理的信号
- 若存在自定义信号处理函数,内核会修改返回地址,使进程回到用户态后先执行该信号处理函数
- 信号处理函数执行完毕后,会调用特殊系统调用再次进入内核态
- 内核完成收尾后,将进程切回用户态,回到原来程序被中断的位置继续执行
- 中断(详情见绑定的资源,有图不好操作)
- 系统调用表(数组)
系统调用号------>是OS内核位每个系统调用分配的唯一整数标识符
Ⅰ. 谁来做软中断前的所有操作?
答:由用户态程序、C标准库 / 系统调用封装层、编译器与CPU架构约定;核心是准备调用号、参数、上下文与出发指令,确保内核能安全准确地接管执行
Ⅱ. 内部系统调用怎么知道有多少个参数?参数分别是谁?
📍 核心:调用约定和系统调用号对应地预定义参数规则
- 系统调用号:内部维护一张 sys_call_table表,表中系统调用号都关联着内核函数,其参数个数都是编译期间固定,无需动态解析
- ABI规范约束:x86_32位价构:参数由右往左顺序压入用户态栈;x86_64位架构:参数使用寄存器传递
- 函数重入(特点):指的是一个函数在尚未执行完地情况下,被再次调用,且能保证执行结果准确、不出现数据错乱地特性
核心条件:Ⅰ- 不使用全局或者静态变量等共享数据
Ⅱ- 不调用非重入函数,且依赖的资源是局部而非共享
- volatile作用:禁止编译器对该变量进行优化,强制每次访问都直接从内存读取 / 写入,而非使用寄存器的缓存副本
- 信号屏蔽机制
Ⅰ. 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽一些其他信号,则用 sa_mask 字段说明这些额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字
Ⅱ. 当信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复为原来的信号屏蔽字
📍 是内核在执行用户注册的信号处理函数时,为了避免信号嵌套处理导致混乱而默认开启的保护逻辑
- 使用 sigaction() 函数触发条件
- 内核在调用目标信号处理函数前,会临时将当前加入进程的信号掩码;意味着,在处理函数期间,如果再次收到同一个信号,新信号会被阻塞,进入pending状态
- 在函数执行完毕后,内核会自动移除信号掩码中临时加入的该信号,进程恢复对信号的响应能力
- 特殊说明:这个机制只屏蔽当前正在处理的信号,不影响其他信号的接受与处理,需要通过sigaction的sa_mash手动指定
- MMU是实现虚拟地址到物理地址的转换
- 操作系统是基于中断处理的软件集合
- 重谈虚拟地址空间
📍 内核页表(进程共享一份)
- 为内核专属区域提供地址映射
- 实现内核空间的统一映射,多数系统中内核虚拟地址空间在所有进程的页表都存在,让进程陷入内核态时能直接访问内核资源,无需切换页表
- 管理物理内存,内核通过页表完成页表完成页框的分配、回收与映射管理,同时借助页表实现内存保护
- 细节
- 进程所有的函数调用,都是在自己的虚拟地址空间中完成
- 进程,每一个进程都有自己的一套用户级页表,但是内核级页表只有一份,被所有进程共享
- 进程在任何时候进行调度时,想找OS可以随时找到;如果进程切换了,并不会让我们找不到OS
- 用户态与内核态间的转换需要改变权限,权限是否通过由内核级页表决定


































