进程信号
- 一.信号概念
- 二.信号产生
- 1.键盘
- 2.指令
- 3.系统调用
- 1.kill
- 2.raise
- 3.abort
- 4.软件条件
- 1.alarm
- 5.硬件异常
- 1.除零
- 2.野指针
- 3.核心转储:core dump
- 三.信号保存
- 1.信号状态:阻塞、未决、递达
- 2.在内核中的表示
- 3.信号集:sigset_t
- 4.信号集操作的系统调用
- 1.sigprocmask
- 2.sigpending
- 四.信号处理
- 1.信号捕捉的流程
- 2.信号捕捉操作的系统调用
- 1.signal
- 2.sigaction
- 3.操作系统是怎么运行的
- 1.硬件中断
- 2.时钟中断
- 3.死循环
- 4.软中断
- 5.缺页中断?内存碎片处理?除零?野指针错误?
- 4.用户态和内核态
- 五.可重入函数
- 六.关键字:volatile
- 七.SIGCHLD信号 - 选学了解
本节重点:
- 掌握Linux信号的基本概念。
- 掌握信号产生的一般方式。
- 理解信号递达和阻塞的概念,原理。
- 掌握信号捕捉的一般方式。
- 了解中断过程,理解中断的意义。
- 掌握操作系统运行,系统调用原理,理解缺页异常或其它软件异常的基本原理。
- 重新了解可重入函数的概念。
- 了解竞态条件的情景和处理方式。
- 了解SIGCHLD信号,重新编写信号处理函数的一般处理机制。
一.信号概念
信号:一种软件中断机制,用于在进程间传递异步通知(进程无法预测信号何时会到来),以处理各种系统事件和异常情况。
同步:在执行一个操作时,调用者必须等待该操作完成后才能继续执行后续的代码。
异步:在执行一个操作时,调用者不必等待该操作完成,而是可以继续执行后续的代码。
- 你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能“识别快递”。
- 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成“在合适的时候去取”。
- 在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你“记住了有一个快递要去取”。
- 当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:1. 执行默认动作(幸福的打开快递,使用商品)2. 执行自定义动作(快递是零食,你要送给你的女朋友)3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)
- 快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话。
对比同步场景:假如快递送达是同步过程,收件人就需要一直守在某个固定的地方等待快递员,在快递送达之前不能去做其他任何事情,这显然不符合实际情况,也会极大地降低收件人的效率。
二.信号产生
1.键盘
#include <iostream>
#include <unistd.h>int main()
{while(true){std::cout << "Hello world" << std::endl;sleep(1);}return 0;
}
- ./sig:前台进程,Ctrl + C 可以终止前台进程,Ctrl + C 的本质是操作系统给进程发送2号信号(SIGINT),该信号的默认处理操作是终止进程。Ctrl + \ 是发送3号信号(SIGQUIT),默认也是终止进程。
- ./sig &:后台进程,bash进程依旧可以进行命令行解释,但是 Ctrl + C 无法终止后台进程,只能通过打开另一个终端使用 kill -9 进程pid 杀掉进程。查看进程 pid 命令:ps -axj | head -1 && ps -axj | grep sig
- nohup ./sig &:将后台进程的执行结果打印到 nohup.out 文件中。
- fg 1:命令的作用是将作业编号为 1 的后台作业移动到前台继续执行,再执行 Ctrl + C 终止该进程。
# 查看信号, 只关心1~31号普通信号, 之后的实时信号不关心
xzy@hcss-ecs-b3aa:~$ kill -l1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
31) SIGSYS
#include <iostream>
#include <unistd.h>
#include <signal.h>void Hander(int signo)
{std::cout << "Get a signal, signal number is: " << signo << std::endl;
}int main()
{// 将SIGINT信号: 默认终止进程 -> 执行自定义方法: Hander// 当SIGINT被触发(Ctrl + C), 内核会将对应的信号编号传递给自定义方法Handersignal(SIGINT, Hander); while(true){std::cout << "Hello world" << std::endl;sleep(1);}return 0;
}
xzy@hcss-ecs-b3aa:~$ ./a.out
Hello world
Hello world
^CGet a signal, signal number is: 2
Hello world
Hello world
^\Quit (core dumped)
- signal 只需要执行一次,不需要发在循环中。
- 若没有触发信号,Hander 将不会被调用。
#include <iostream>
#include <unistd.h>
#include <signal.h>void Hander(int signo)
{std::cout << "Get a signal, signal number is: " << signo << std::endl;
}int main()
{for(int signo = 1; signo < 32; signo++){signal(signo, Hander);std::cout << "自定义捕捉信号: " << signo << std::endl;}while(true){std::cout << "Hello world" << std::endl;sleep(1);}return 0;
}
若信号全部被捕捉时,是不是所有的操作都无法杀掉进程?其实操作系统存在保护机制,9号信号无法被捕捉,kill -9 进程pid 可以杀掉进程。
- 问题:当键盘按下 Ctrl+C 时,操作系统是硬件的管理者,本质是操作系统给进程发送信号,但是进程不一定会立刻执行该信号,所以需要记录信号,那么进程如何记录信号?
- 答案:进程 task_struct 中存在位图;比特位的位置:信号的编号,比特位的内容:0表示未收到信号,1表示收到信号。进程 task_struct 中还存在函数指针数组,对应的就是信号的处理方法,捕捉信号就是修改该数组中的函数指针。执行完信号对应的处理方法时位图对应的位置就由1变成0,等待下一次信号的到来。发送信号的本质:操作系统向目标进程 task_struct 中信号位图所对应的位置修改为1。
操作系统如何得知键盘有数据?
Ctrl+C 的过程:当键盘被按下时,键盘向CPU发送硬件中断,告操作系统键盘已经准备好了,操作系统读取 Ctrl+C 将其解释为2号信号,并将2号信号转换成一份代码(task_struct 中的比特位置1),在合适的时候执行2号信号的处理方式,将自己退出。让操作系统将外设的数据拷贝到内存中。
2.指令
kill -信号编号 pid:例如 kill -9 pid 杀掉进程。
3.系统调用
1.kill
#include <sys/types.h>
#include <signal.h>
功能: 给指定的进程发送指定的信号
原型: int kill(pid_t pid, int sig);
参数pid: 进程id
参数sig: 信号编号
返回值: 成功返回0, 失败返回-1
利用系统调用 kill 模拟实现指令 kill:
// 用系统调用kill模拟实现kill指令
#include <iostream>
#include <sys/types.h>
#include <signal.h>
#include <string>void Usage(std::string proc)
{std::cout << "Usage: " << proc << " signumber processid" << std::endl;
}int main(int argc, char *argv[])
{if(argc != 3){Usage(argv[0]);exit(1);}int signumber = std::stoi(argv[1]);pid_t processid = std::stoi(argv[2]);int n = ::kill(processid, signumber);if(n < 0){perror("kill");exit(2);}exit(0);return 0;
}
2.raise
#include <signal.h>
功能: 给当前进程发送指定信号
原型: int raise(int sig);
参数sig: 信号编号
返回值: 成功返回0, 失败返回非0
样例:
#include <iostream>
#include <unistd.h>
#include <signal.h>void Hander(int signo)
{std::cout << "获取了一个信号: " << signo << std::endl;
}int main()
{// 先对2号信号进行捕捉signal(2, Hander);while(true){// 每隔1秒给自己发送2号信号: 执行Hander函数sleep(1);raise(2);}return 0;
}
xzy@hcss-ecs-b3aa:~$ g++ -o sig sig.cc
xzy@hcss-ecs-b3aa:~$ ./sig
获取了一个信号: 2
获取了一个信号: 2
获取了一个信号: 2
3.abort
#include <stdlib.h>
功能: 给当前进程发送6号信号SIGABRT, 终止当前进程
原型: void abort(void);
样例:
#include <iostream>
#include <unistd.h>
#include <signal.h>void Hander(int signo)
{std::cout << "获取了一个信号: " << signo << std::endl;
}int main()
{// 先对6号信号进行捕捉signal(6, Hander);while(true){// 每隔1秒给自己发送6号信号: 执行Hander函数sleep(1);abort();}return 0;
}
# abort给自己发送的是6号信号, 虽然捕捉了, 但还是要退出, 不会一直打印
xzy@hcss-ecs-b3aa:~$ ./sig
获取了一个信号: 6
Aborted (core dumped)# 注释掉 signal(6, Hander), 进程直接退出
xzy@hcss-ecs-b3aa:~$ ./sig
Aborted (core dumped)
4.软件条件
- 在操作系统中,信号的软件条件指的是由软件内部状态或特定软件操作触发的信号产生机制。这些条件包括但不限于定时器超时(如alarm函数设定的时间到达)、软件异常(如向已关闭的管道写数据产生的SIGPIPE信号)等。当这些软件条件满足时,操作系统会向相关进程发送相应的信号,以通知进程进行相应的处理。简而言之,软件条件是因操作系统内部或外部软件操作而触发的信号产生。
- 匿名管道:管道的读端关闭 && 写端继续:此时操作系统识别到向管道中写时,已经没有读端了,操作系统给进程发送 SIGPIPE 信号,进程直接退出。
1.alarm
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
- 调用 alarm 函数可以设定一个闹钟,也就是告诉内核在 seconds 秒之后给当前进程发 SIGALRM 信号,该信号的默认处理动作是终止当前进程。
- 这个函数的返回值是 0 或者是以前设定的闹钟时间还余下的秒数。如果 seconds 值为0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数。
基本 alarm 验证,体会IO效率问题:
程序的作用是1秒钟之内不停地数数,1秒钟到了就被SIGALRM信号终止。必要的时候,对SIGALRM信号进行捕捉。
系统闹钟,其实本质是OS必须自身具有定时功能,并能让用户设置这种定时功能,才可能实现闹钟这样的技术。现代Linux是提供了定时功能的,定时器也要被管理:先描述,再组织。内核中的定时器数据结构是:
struct timer_list
{struct list_head entry;unsigned long expires;void (*function)(unsigned long);unsigned long data;struct tvec_t_base_s *base;
};
我们不在这部分进行深究,为了理解它,我们可以看到:定时器超时时间 expires 和处理方法function。操作系统管理定时器,采用的是时间轮的做法,但是我们为了简单理解,可以把它再组织成为"堆结构"。alarm 系统调用相当于在系统中构建一个节点,超时时发送SIGALRM信号。
以下代码将信号更换为硬件中断就是操作系统执行的原理:
#include <iostream>
#include <vector>
#include <functional>
#include <unistd.h>
#include <signal.h>using func_t = std::function<void()>;long long int gcount = 0;
std::vector<func_t> gfuncs;void handler(int signo)
{for (auto &f : gfuncs){f();}std::cout << "gcount: " << gcount << std::endl;alarm(1);
}int main()
{signal(SIGALRM, handler);gfuncs.push_back([](){ std::cout << "我是一个日志任务" << std::endl; });gfuncs.push_back([](){ std::cout << "我是一个下载任务" << std::endl; });gfuncs.push_back([](){ std::cout << "我是一个上传任务" << std::endl; });alarm(1); // 一次性的闹钟, 超时alarm会自动取消while (true){pause(); // 阻塞等待信号的到来std::cout << "我醒来了..." << std::endl;gcount++;}return 0;
}
xzy@hcss-ecs-b3aa:~$ ./a.out
我是一个日志任务
我是一个下载任务
我是一个上传任务
gcount: 0
我醒来了...
我是一个日志任务
我是一个下载任务
我是一个上传任务
gcount: 1
我醒来了...
我是一个日志任务
我是一个下载任务
我是一个上传任务
gcount: 2
我醒来了...
5.硬件异常
1.除零
#include <iostream>
#include <unistd.h>
#include <signal.h>void handler(int signo)
{std::cout << "get a signal: " << signo << std::endl;
}int main()
{signal(SIGFPE, handler);int a = 10;a /= 0;return 0;
}
xzy@hcss-ecs-b3aa:~$ ./a.out
...
get a signal: 8
get a signal: 8
get a signal: 8
^C
2.野指针
#include <iostream>
#include <unistd.h>
#include <signal.h>void handler(int signo)
{std::cout << "get a signal: " << signo << std::endl;
}int main()
{signal(SIGSEGV, handler);int *p = nullptr;*p = 10;return 0;
}
xzy@hcss-ecs-b3aa:~$ ./a.out
...
get a signal: 11
get a signal: 11
get a signal: 11
^C
- 问题1:操作系统怎么知道进程内部出错?
- 答案:程序内的错误,最终都会表现在硬件错误上,导致操作系统给进程发送信号!
- 问题2:为什么会死循环?
- 答案:问题没有解决导致硬件一直异常,已知给进程发信号,进而导致死循环
3.核心转储:core dump
- SIGINT的默认处理动作是终止进程,SIGQUIT的默认处理动作是终止进程并且 Core Dump,现在我们来验证一下。
- 首先解释什么是 Core Dump。当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘,文件名通常是core,这叫做 Core Dump。
- 进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查 core 文件以查清错误原因,这叫做事后调试。
- 一个进程允许产生多大的 core 文件取决于进程的 Resource Limit (这个信息保存在PCB中)。默认是不允许产生 core 文件的,因为 core 文件中可能包含用户密码等敏感信息,不安全。
- 在开发调试阶段可以用 ulimit 命令改变这个限制,允许产生 core 文件,进而使用 gdb core 调试。首先用 ulimit 命令改变 Shell 进程的 Resource Limit,如允许 core 文件最大为 1024K:
ulimit -c 1024
,ulimit -a
查看信息。
xzy@hcss-ecs-b3aa:~$ ulimit -c 1024
xzy@hcss-ecs-b3aa:~$ ulimit -a
real-time non-blocking time (microseconds, -R) unlimited
core file size (blocks, -c) 1024
data seg size (kbytes, -d) unlimited
scheduling priority (-e) 0
file size (blocks, -f) unlimited
pending signals (-i) 6628
max locked memory (kbytes, -l) 226728
max memory size (kbytes, -m) unlimited
open files (-n) 65535
pipe size (512 bytes, -p) 8
POSIX message queues (bytes, -q) 819200
real-time priority (-r) 0
stack size (kbytes, -s) 8192
cpu time (seconds, -t) unlimited
max user processes (-u) 6628
virtual memory (kbytes, -v) unlimited
file locks (-x) unlimited
子进程退出 core dump:
#include <iostream>
#include <stdlib.h>
#include <sys/wait.h>int main()
{if (fork() == 0){sleep(1);int a = 10;a /= 0;exit(0);}int status = 0;waitpid(-1, &status, 0);printf("exit signal: %d, core dump: %d\n", status & 0x7F, (status >> 7) & 1);return 0;
}
xzy@hcss-ecs-b3aa:~$ ./a.out
exit signal: 8, core dump: 1
三.信号保存
1.信号状态:阻塞、未决、递达
- 信号递达(Delivery):实际执行信号的处理动作。
- 信号未决(Pending):信号从产生到递达之间的状态。
- 进程可以选择阻塞(Block)某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
- 注意:阻塞和忽略是不同的。只要信号被阻塞就不会递达,除非解除阻塞,而忽略是在递达之后可选的一种处理动作。
2.在内核中的表示
- 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
- SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
- SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。
如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号(1 ~ 31)在递达之前产生多次只计一次,而实时信号(32 ~ 64)在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号。
- block 和 pending:类似位图。比特位的位置:信号编号;比特位的内容:是否阻塞(屏蔽),是否收到信号。
- handler:函数指针数组。数组下标:信号的编号 - 1;内容:信号所对应的处理方式。
- 进程就是通过横着看:识别信号。
3.信号集:sigset_t
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集
,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask)
这里的“屏蔽"应该理解为阻塞而不是忽略。
4.信号集操作的系统调用
sigset_t 类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作 sigset_t 变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的。
#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置位1,表示该信号集的有效信号括系统支持的所有信号。
- 注意:在使用sigset_t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
- sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含"某种"信号,若包含则返回1,不包含则返回0,出错返回-1。其余这四个函数都是成功返回0,出错返回-1。
1.sigprocmask
调用函数 sigprocmask 可以读取或修改进程的信号屏蔽字(阻塞信号集)
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
如果oldset是非空指针,则读取进程的当前信号屏蔽字通过oldset参数传出。如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。如果oldset和set都是非空指针,则先将原来的信号屏蔽字备份到oldset里,然后根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。
如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。
2.sigpending
#include <signal.h>
int sigpending(sigset_t *set);
输出型参数set:将进程的pending信号集带出来,可以得知进程接收的所有信号。
- 问题:为什么没有修改pending的函数?
- 答案:信号产生的5中方式就可以修改pending!同理signal信号捕捉函数可以修改handler表!
下面用刚学的几个函数做个实验。程序如下:
#include <iostream>
#include <unistd.h>
#include <signal.h>void PrintPending(const sigset_t &pending)
{std::cout << "pending list [" << getpid() << "]: ";for (int signo = 31; signo > 0; signo--){if (sigismember(&pending, signo)){std::cout << 1;}else{std::cout << 0;}}std::cout << std::endl;
}void non_handler(int signo)
{std::cout << "处理" << signo << "号信号" << std::endl;
}int main()
{::signal(2, SIG_DFL); //默认信号::signal(2, SIG_IGN); //忽略信号::signal(2, non_handler); //自定义信号// 1. 对2号信号进行屏蔽// 需要清空栈上开辟的空间sigset_t block, oldblock;sigemptyset(&block);sigemptyset(&oldblock);// 1.1 添加2号信号// 这里我们有没有把2号信号屏蔽,设置进入内核中?// 并没有!只是在用户栈上设置了位图结构sigaddset(&block, 2);// 1.2 设置进入内核中sigprocmask(SIG_SETMASK, &block, &oldblock);int cnt = 0;while (true){// 2.获取pending表sigset_t pending;sigpending(&pending);// 3.打印pending表PrintPending(pending);sleep(1);cnt++;if(cnt == 10){std::cout << "解除对2号信号的屏蔽" << std::endl;sigprocmask(SIG_SETMASK, &oldblock, nullptr);}}return 0;
}
xzy@hcss-ecs-b3aa:~/code/test_2_25$ ./process
pending list [474514]: 0000000000000000000000000000000
pending list [474514]: 0000000000000000000000000000000
pending list [474514]: 0000000000000000000000000000000
^Cpending list [474514]: 0000000000000000000000000000010
pending list [474514]: 0000000000000000000000000000010
pending list [474514]: 0000000000000000000000000000010
pending list [474514]: 0000000000000000000000000000010
pending list [474514]: 0000000000000000000000000000010
pending list [474514]: 0000000000000000000000000000010
pending list [474514]: 0000000000000000000000000000010
解除对2号信号的屏蔽
处理2号信号
pending list [474514]: 0000000000000000000000000000000
pending list [474514]: 0000000000000000000000000000000
pending list [474514]: 0000000000000000000000000000000
^\Quit (core dumped)
程序运行时,每秒钟把各信号的未决状态打印一遍,由于我们阻塞了SIGINT信号,按 Ctrl + C 将会使SIGINT信号处于未决状态,按 Ctrl + \ 仍然可以终止程序,因为SIGQUIT信号没有阻塞。
四.信号处理
当一个进程接收到信号时,它的正常执行流程会被打断,就像发生了中断一样。这是因为信号是异步的,它可以在进程执行的任何时刻到达。当信号到达时,操作系统会暂停当前进程的执行,保存其上下文信息(如程序计数器、寄存器的值等),然后根据信号的类型和进程对该信号的处理方式来执行相应的操作。
- 问题:处理信号并不是立即处理,那是在什么时候处理?
- 答案:进程从内核态切换为用户态的时候,检测block和pending表,决定是否执行handler表对应的处理信号的方法。
用户态:执行我自己写的代码。
内核态:执行操作系统的代码。
1.信号捕捉的流程
捕捉信号:如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数。
由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下:
- 用户程序注册了 SIGQUIT 信号的处理函数 sighandler。
- 当前正在执行 main 函数,这时发生中断、异常或系统调用切换到内核态。
- 内核态中断处理完毕后,要检查是否有接收到被捕捉的信号(SIGQUIT),这里接收到了。
- 内核决定返回用户态后不是恢复 main 函数的上下文继续执行,而是执行 sighandler 函数,sighandler 和 main 函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。
- sighandler 函数返回后自动执行特殊的系统调用 sigreturn 再次进入内核态。
- 如果没有新的信号要递达,这次再返回用户态就是恢复 main 函数的上下文继续执行了。
注意:若收到的是未捕捉的信号,执行完信号对应的方法后,直接返回 main 函数的上下文继续执行。
- 信号捕捉方法的执行,为什么要从内核态切换为用户态?存在安全风险,防止方法中的代码存在漏洞或恶意代码!
- 方法处理完后,为什么要返回内核?因为main函数中没有调用sighandler函数。CPU存在pc指针
2.信号捕捉操作的系统调用
1.signal
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
在产生信号中讲过!
2.sigaction
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);struct sigaction
{void (*sa_handler)(int);void (*sa_sigaction)(int, siginfo_t *, void *); //不管sigset_t sa_mask;int sa_flags; //设置为0void (*sa_restorer)(void); //不管
};
- sigaction函数可以读取和修改与指定信号相关联的处理动作,调用成功则返回0,出错则返回-1。
- signum是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oldact指针非空,则通过oldact传出该信号原来的处理动作。act和oldact指向sigaction结构体。
- 将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。
- 当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。否则在执行处理函数时一直频繁发送该信号,将一直调用该函数导致栈溢出。
- 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。
- sa_flags字段包含一些选项,本章的代码都把sa_flags设为0,sa_sigaction是实时信号的处理函数,本章不详细解释这两个字段。
注意:pending表中的某个信号清零,是在信号处理函数执行前清零的,若执行后清零的话,当在执行过程中,操作系统又发送相同的信号给进程时,此时存在歧义。
#include <iostream>
#include <signal.h>
#include <unistd.h>void PrintBlock()
{sigset_t set, oldset;sigemptyset(&set);sigemptyset(&oldset);sigprocmask(SIG_BLOCK, &set, &oldset);std::cout << "block list: ";for (int signo = 31; signo > 0; signo--){if (sigismember(&oldset, signo)){std::cout << 1;}else{std::cout << 0;}}std::cout << std::endl;
}void PrintPending()
{sigset_t pending;::sigpending(&pending);std::cout << "pending list: ";for (int signo = 31; signo > 0; signo--){if (sigismember(&pending, signo)){std::cout << 1;}else{std::cout << 0;}}std::cout << std::endl;
}void handler(int signo)
{static int cnt = 0;cnt++;while (true){std::cout << "get a signal: " << signo << ", cnt: " << cnt << std::endl;PrintBlock();sleep(1);break;}
}int main()
{struct sigaction act, oldact;act.sa_handler = handler;sigemptyset(&act.sa_mask);sigaddset(&act.sa_mask, 30);sigaddset(&act.sa_mask, 31);// 注意:当执行完sigaction函数时, 2号信号被捕捉// 但是30和31号信号并没有被屏蔽, 只有执行handler函数时才被屏蔽// 执行完handler函数之后, 解除30和31号信号的屏蔽::sigaction(2, &act, &oldact);while (true){// 打印BlockPrintBlock();// 调用进程暂停执行,直到接收到一个信号pause();}return 0;
}
xzy@hcss-ecs-b3aa:~$ ./sig
block list: 0000000000000000000000000000000
^Cget a signal: 2, cnt: 1
block list: 1100000000000000000000000000000
block list: 0000000000000000000000000000000
3.操作系统是怎么运行的
1.硬件中断
硬件中断是由计算机外部硬件设备产生的电信号引发的中断请求。当硬件设备需要 CPU 的服务时,会向 CPU 发送一个特定的电信号,CPU 在接收到这个信号后,会暂停当前正在执行的程序,转而去处理该硬件设备的请求,处理完成后再返回原来的程序继续执行。硬件和操作系统可以并行执行。
- 中断向量表就是操作系统的一部分,启动就加载到内存中了。
- 通过外部硬件中断,操作系统就不需要对外设进行任何周期性的检测或者轮询。
- 由外部设备触发的,中断系统运行流程,叫做硬件中断。
2.时钟中断
问题:
- 进程可以在操作系统的指挥下,被调度,被执行,那么操作系统自己被谁指挥,被谁推动执行呢?
- 外部设备可以触发硬件中断,但是这个是需要用户或者设备自己触发,有没有自己可以定期触发的设备?
- 时钟源一直给CPU发送时钟中断,不断推动操作系统进行进程调度。
- 操作系统就是基于中断向量表,进行工作的。
- CPU中的主频属性:主频也叫时钟频率,主频越高发送的时钟中断就越多,操作系统进行进程调度的速度就越快,CPU在单位时间内完成的指令数就越多,运算速度也就越快。
int main()
{//...// 调度程序初始化, 设置时钟中断和系统调用中断sched_init();//...
}void sched_init(void)
{//...// 向中断向量表中添加时钟中断timer_interruptset_intr_gate(0x20, &timer_interrupt);//...
}// 当时钟中断发生时, CPU 跳转到 _timer_interrupt
_timer_interrupt://...call _do_timer;//...// 处理时钟中断
void do_timer(long cpl)
{//...// 进程调度入口schedule();//...
}void schedule(void)
{//...// 进程切换switch_to(next);//...
}
3.死循环
- 操作系统就在硬件的推动下,自动调度!操作系统的本质:就是一个死循环!
- 操作系统需要什么功能,就向中断向量表里面添加方法即可。
void main(void)
{/** 注意! 对于任何其它的任务, pause()将意味着我们必须等待收到一个信号才会返回* 就绪运行态, 但任务0 是唯一的意外情况, 因为任务0 在任何空闲时间里都会被激活* (当没有其它任务在运行时), 因此对于任务0, pause()仅意味着我们返回来查看是否* 有其它任务可以运行, 如果没有的话我们就回到这里, 一直循环执行pause()*/for (;;)pause();
}
时间片:操作系统分配给每个正在运行的进程的一段固定时长的 CPU 执行时间。
在分时操作系统中,系统会将 CPU 的执行时间划分成多个小的时间片段,每个进程被允许在一个时间片内占用 CPU 资源来执行其指令。每次时钟中断发生时,操作系统会检查当前正在执行的进程的时间片是否已经用完。当一个进程的时间片用完后,通知操作系统进行进程切换。操作系统会保存当前进程的上下文,然后选择另一个就绪的进程,恢复其上下文并将 CPU 资源分配给它,使其继续执行。从而实现多个进程的并发执行。
4.软中断
- 上述外部硬件中断,需要硬件设备触发。
- 为了让操作系统支持进行系统调用,CPU也设计了对应的汇编指令(int或者syscall),可以让CPU内部触发软中断逻辑。例如:缺页中断、野指针、除零、系统调用…
问题:
- 用户层怎么把系统调用号给操作系统?寄存器(比如EAX)
- 操作系统怎么把返回值给用户?寄存器或者用户传入的缓冲区地址。
- 系统调用的过程:把我们要调用的系统调用号写入寄存器EAX,然后执行 int 0x80、syscall 陷入内核,本质就是触发软中断,CPU就会自动执行系统调用的处理方法,该方法会根据系统调用号,自动查系统调用表,执行对应的方法。系统调用是通过软中断完成的!
- 系统调用号的本质:数组下标!
int main()
{//...// 调度程序初始化, 设置时钟中断和系统调用中断sched_init();//...
}void sched_init(void)
{//...// 向中断向量表中添加系统调用中断set_system_gate(0x80, &system_call);//...
}// 当系统调用中断发生时, CPU 跳转到 system_call
system_call ://...call[_sys_call_table + eax * 4]; // 系统调用表的起始地址 + 系统调用号 * 4 = 系统调用地址
//...
- 其实Linux内提供的系统调用接口,根本就不是C函数,而是 系统调用号+约定传递的参数,系统调用号,返回值的寄存器。
- 可是为什么我们用的系统调用,从来没有见过什么 int 0x80 或者 syscall 呢?都是直接调用上层的函数的啊?
- 那是因为Linux的gnu glibc(C标准库),给我们把几乎所有的系统调用全部封装了。
5.缺页中断?内存碎片处理?除零?野指针错误?
缺页中断是一种特殊的中断,当程序访问的页面不在物理内存中,而在磁盘的交换空间(如交换分区或交换文件)时,就会触发缺页中断。操作系统会暂停当前程序的执行,将所需的页面从磁盘加载到物理内存中,然后再恢复程序的执行。
缺页中断?内存碎片处理?除零?野指针错误?这些问题,全部都会被转换成为CPU内部的软中断,然后走中断处理例程,完成所有处理。有的是进行申请内存。填充页表,进行映射的。有的是用来
处理内存碎片的。有的是用来给目标进行发送信号,杀掉进程等等。
4.用户态和内核态
结论:
- 操作系统无论怎么切换进程,都能找到同一个操作系统!换句话说系统调用方法的执行,是在进程的地址空间中执行的!
- 用户态就是执行用户[0,3]GB时所处的状态。
- 内核态就是执行内核[3,4]GB时所处的状态。
- 区分就是按照 CPU 内的 CPL 决定,CPL的全称是Current Privilege Level,即当前特权级别,CPL为0表示内核态,CPL为3表示用户态。
- 一般执行 int 0x80 或者 syscall 软中断,CPL会在校验之后自动变更。
- 用户态到内核态:中断、异常、系统调用。内核态到用户态:中断、异常、系统调用处理完成后。
五.可重入函数
- main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
- 像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之如果一个函数只访问自己的局部变量或参数,则称为可重入函数。想一下,为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?因为函数退出数据都没了。
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
六.关键字:volatile
#include <stdio.h>
#include <signal.h>
#include <unistd.h>// 易变关键字: 保持内存可见性, 容易改变, 优化到寄存器中无意义
volatile int flag = 0;// 信号捕捉执行流
void change(int signo)
{(void)signo;flag = 1; // 对内存中的flag进行了修改printf("change flag 0->1, pid: %d\n", getpid());
}int main()
{signal(2, change);// 主执行流: flag并未修改, 若编译器进行了优化(gcc test.cc -O1), 此时flag直接写入寄存器中,// CPU计算 !flag 时, 直接访问寄存器, 不再访问内存, 导致出错// 解决方法: volatile进行修饰printf("T am main process, pid: %d\n", getpid());while(!flag);printf("我是正常退出的\n");return 0;
}
xzy@hcss-ecs-b3aa:~$ ./a.out
T am main process, pid: 654646
^Cchange flag 0->1, pid: 654646
我是正常退出的
- 标准情况下,键入Ctrl-C时,2号信号被捕捉,执行自定义动作,修改 flag=1,while条件不满足,退出循环,进程退出
- 优化情况下,键入Ctrl-C时,2号信号被捕捉,执行自定义动作,修改 flag=1,但是 while 条件依旧满足,进程继续运行!但是很明显 flag 肯定已经被修改了,但是程序会循环依旧执行。因为 while 循环检查的 flag,并不是内存中最新的 flag,这就存在数据二异性的问题。while 检测的 flag 其实已经因为优化,被放在了CPU寄存器当中。如何解决呢?需要加上 volatile
- volatile 作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作。
七.SIGCHLD信号 - 选学了解
- 已知wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理。采用第一种方式,父进程阻塞了就不能处理自己的工作了。采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。
- 其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用 waitpid 清理子进程即可,其中的 status 可以将信号带出。
请编写一个程序完成以下功能:父进程fork出子进程,子进程调用exit(0)终止,父进程自定义SIGCHLD信号的处理函数,在其中调用waitpid获取子进程的退出状态并打印。
#include <iostream>
#include <signal.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>void handler(int signo)
{std::cout << "father pid: " << getpid() << ", get a signal: " << signo << std::endl;pid_t rid = ::waitpid(-1, nullptr, 0);if(rid > 0){std::cout << "child exit and recycle success, child pid: " << rid << std::endl;}
}// 1.验证子进程退出,给父进程发送SIGCHLD
// 2.我们可以基于信号进行子进程回收
int main()
{signal(SIGCHLD, handler);if (fork() == 0){// 子进程sleep(5);std::cout << "子进程退出" << std::endl;exit(0);}while (true){sleep(1);}return 0;
}
xzy@hcss-ecs-b3aa:~$ ./sig
子进程退出
father pid: 515082, get a signal: 17
child exit and recycle success, child pid: 515083
- 问题:若存在10子进程?子进程都向父进程发送SIGCHLD,可能导致在调用handler函数时,收到多个子进程的信号,而pending表中最多只能保存一个信号,将会导致僵尸进程问题!
- 解决办法:在handler函数中使用while循环的方式不断waitpid子进程。
while : ; do ps axj | head -1 && ps axj | grep sig; sleep 1; done
#include <iostream>
#include <signal.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>void handler(int signo)
{std::cout << "father pid: " << getpid() << ", get a signal: " << signo << std::endl;while (true){pid_t rid = ::waitpid(-1, nullptr, 0);// 存在子进程时, waitpid一直阻塞, 当子进程全部被回收时(不存在子进程), 返回-1if (rid > 0){std::cout << "child exit and recycle success, child pid: " << rid << std::endl;}else if (rid < 0){std::cout << "all child are recycled successful" << std::endl;break;}}
}int main()
{signal(SIGCHLD, handler);for (int i = 0; i < 10; i++){if (fork() == 0){// 子进程sleep(5);std::cout << "子进程退出" << std::endl;exit(0);}}while (true){sleep(1);}return 0;
}
xzy@hcss-ecs-b3aa:~$ ./sig
子进程退出
子进程退出
子进程退出
father pid: 515060, get a signal: 17
child exit and recycle success, child pid: 515061
child exit and recycle success, child pid: 515062
子进程退出
子进程退出
子进程退出
子进程退出
子进程退出
child exit and recycle success, child pid: 515063
child exit and recycle success, child pid: 515066
child exit and recycle success, child pid: 515067
子进程退出
子进程退出
child exit and recycle success, child pid: 515064
child exit and recycle success, child pid: 515065
child exit and recycle success, child pid: 515068
child exit and recycle success, child pid: 515069
child exit and recycle success, child pid: 515070
all child are recycled successful
father pid: 515060, get a signal: 17
all child are recycled successful
- 问题:如果10个子进程,其中5个退出了,5个进程永不退出?若采用阻塞等待子进程的话,将导致永远存在子进程waitpid阻塞,进程将卡死。
- 解决办法:采用非阻塞轮询的方式等待子进程!waitpid(-1, nullptr, NOWANG),-1表示任意等待一个子进程(优先是退出的子进程,其次是未退出的子进程)
#include <iostream>
#include <signal.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>void handler(int signo)
{std::cout << "father pid: " << getpid() << ", get a signal: " << signo << std::endl;while (true){pid_t rid = ::waitpid(-1, nullptr, WNOHANG); //非阻塞轮询// 若 rid==0: 该子进程没有退出// 若 rid==-1: 不存在子进程if (rid > 0){std::cout << "child exit and recycle success, child pid: " << rid << std::endl;}else if (rid == 0){std::cout << "退出的子进程, 都被回收" << std::endl;break;}else{std::cout << "wait error" << std::endl;break;}}
}int main()
{signal(SIGCHLD, handler);for (int i = 0; i < 10; i++){if (fork() == 0){// 子进程sleep(5);if (i < 5){std::cout << "子进程退出" << std::endl;exit(0);}else{std::cout << "子进程永不退出" << std::endl;while (true){sleep(1);}}}}while (true){sleep(1);}return 0;
}
xzy@hcss-ecs-b3aa:~$ ./sig
子进程退出
子进程退出
father pid: 516585, get a signal: 17
child exit and recycle success, child pid: 516586
child exit and recycle success, child pid: 516587
退出的子进程, 都被回收
子进程退出
子进程永不退出
子进程永不退出
子进程退出
子进程永不退出
子进程永不退出
子进程退出
father pid: 516585, get a signal: 17
child exit and recycle success, child pid: 516588
child exit and recycle success, child pid: 516589
子进程永不退出
child exit and recycle success, child pid: 516590
退出的子进程, 都被回收
father pid: 516585, get a signal: 17
退出的子进程, 都被回收
事实上,由于UNIX的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用signal将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用signal函数自定义的忽略[signal(SIGCHLD, SIG_IGN)],通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。
#include <iostream>
#include <signal.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>int main()
{::signal(SIGCHLD, SIG_IGN);for (int i = 0; i < 10; i++){if (fork() == 0){// 子进程sleep(5);std::cout << "子进程退出" << std::endl;exit(0);}}while (true){sleep(1);}return 0;
}
xzy@hcss-ecs-b3aa:~/code/test_2_27$ ./sig
子进程退出
子进程退出
子进程退出
子进程退出
子进程退出
子进程退出
子进程退出
子进程退出
子进程退出
子进程退出