【Linux】进程信号
文章目录
- 一、认识信号
- 1.生活角度的信号
- 2.技术应用角度的信号
- 3.信号概念
- 4.信号处理常见方式
- 5. 信号的存储位置
- 二、初识捕捉信号
- 三、产生信号
- 1. 调用系统函数向进程发信号
- 2. 硬件异常产生信号
- 3. 软件条件产生信号
- 4. 通过终端按键产生信号
- 四、保存信号
- 1.信号其他相关常⻅概念
- 2.在内核中的表示
- 3. sigset_t
- 4. 信号集操作函数
- 5. sigprocmask
- 6. sigpending
- 五、捕捉信号
- 1. 知识点一
- 2.知识点二
- 3. 知识点三
- 4. 知识点四
- 5.内核如何实现信号的捕捉
- 6.sigaction
- 六、操作系统是怎么运⾏的
- 1.硬件中断
- 2.时钟中断 和死循环
- 3.软中断
- 4.系统调用的原理
- 七、可重⼊函数
- 八、volatile
- 九、SIGCHLD信号
- 十、信号总结
一、认识信号
1.生活角度的信号
这里引入一个生活中取快递的例子:
- 你在⽹上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能 “识别快递”。
- 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的⾏为并不是⼀定要⽴即执⾏,可以理解成 “在合适的时候去取”。
- 在收到通知,再到你拿到快递期间,是有⼀个时间窗⼝的,在这段时间,你并没有拿到快递,但是你知道有⼀个快递已经来了。本质上是你 “记住了有⼀个快递要去取”。
- 当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:
-
- 执行默认动作(幸福的打开快递,使用商品)
-
- 执行自定义动作(快递是零食,你要送给你你的女朋友)
-
- 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)
- 快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话
2.技术应用角度的信号
我们输入命令,在 Shell 下启动一个前台进程:
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
using namespace std;// 我写了一个将来一直会运行的程序, 用来进行后续的测试
int main()
{while (true){cout << "我是一个进程, 我的pid是: " << getpid() << ", 我的ppid是: " << getppid() << endl;sleep(1);}return 0;
}
接着按下 Ctrl + C,这个键盘输入产生一个硬件中断,被 OS 获取,解释成信号,发送给目标前台进程,前台进程因为收到信号,进而引起进程退出。
除此之外,我们还可以使用 kill -9 457 来终止掉这个进程!
注意:
- Ctrl + C 产生的信号只能发给前台进程。一个命令后面加个 & 可以放到后台运行,这样 Shell 不必等待进程结束就可以接受新的命令,启动新的进程。
- Shell 可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl + C 这种控制键产生的信号。
- 前台进程在运行过程中,用户随时可能按下 Ctrl + C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)的。
前台进程和后台进程?
3.信号概念
信号是进程之间事件异步通知的一种方式,属于软中断。
在 Linux 下可以使用 kill -l 命令查看系统定义的信号列表。
每个信号都有一个编号和一个宏定义名称,这些宏定义可以在 signal.h 中找到,例如其中有定义 #define SIGINT 2。
编号 [34 ~ 64] 的是实时信号,本文章只讨论编号 [1 ~ 31] 的普通信号,不讨论实时信号。
这些信号各自在什么条件下产生,默认的处理动作是什么,在 signal(7) 中都有详细说明 man 7 signal:比如
4.信号处理常见方式
信号是操作系统向进程发送的异步通知机制(例如通过 kill -9 8888 命令),但进程不一定能立即处理它。由于信号的产生是异步的,可能随时发生,而进程可能正在执行优先级更高的任务。
因此,进程需要具备两个核心能力:
- 信号识别机制:进程通过程序员预先编码的逻辑来识别特定信号(如 SIGTERM、SIGKILL)
- 信号存储能力:当信号到达时,进程会将其存入内部队列(如未决信号集),待当前执行流程允许时再处理
信号处理有三种典型方式:
- 默认动作:由操作系统定义的默认行为(如终止进程)
- 自定义处理:通过 signal() 等函数注册自定义处理函数
-
- 提供一个信号处理函数 handler,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉 (Catch) 一个信号。
- 忽略信号:对特定信号(如 SIG_IGN)不做任何处理
当进程从队列中取出信号并执行对应动作时,称为"信号被捕捉"。整个机制确保了系统在响应外部事件的同时,不会中断关键任务的执行。
5. 信号的存储位置
当操作系统需要向进程发送信号时,信号状态存储在进程控制块(PCB)的 task_struct 结构体中。对于 [1 ~ 31] 范围内的普通信号,Linux 内核采用位图(bitmap)机制来高效管理:
struct task_struct {// ...其他字段unsigned int signal; // 32位无符号整数,每比特代表一个信号// ...其他字段
};
我们是用比特位的位置来代表信号编号,而用比特位的内容来表示是否收到该信号,其中 0 表示没有收到相应信号,1 则表示收到了。简单来讲,信号在进程控制块(PCB)里呈现的就是位图结构。
接下来再说说如何理解信号的发送。发送信号,其实质就是修改 PCB 中的信号位图。打个比方,假如要发送 9 号信号,那么只需要把第 9 个比特位由 0 设置为 1 就可以了,这就是所谓的信号发送。
我们都清楚,PCB 是由内核进行维护的数据结构对象,所以 PCB 的管理者是操作系统(OS),这也就意味着只有 OS 才有权利去修改 PCB 中的相关内容。
无论在后续学习过程中,我们会接触到多少种发送信号的方式,其本质都是通过 OS 向目标进程发送信号。因此, OS 必须要提供用于发送信号以及处理信号的相关系统调用。就像我们平常经常使用的kill命令,它在底层一定是运用了系统调用的。
二、初识捕捉信号
函数原型
#include <signal.h>sighandler_t signal(int signum, sighandler_t handler);
功能:
- 为指定的信号 signum 设置一个新的信号处理函数 handler。
- 当进程接收到信号 signum 时,将执行 handler 函数。
参数:
- signum:要捕获的信号编号,例如 SIGINT(Ctrl+C,编号为 2)、SIGTERM(终止信号,编号为 15)等。
- handler:信号处理函数,类型为 sighandler_t,它是一个接受 int 参数并返回 void 的函数指针
- 此外,handler 还可以取以下特殊值:
-
- SIG_DFL:恢复信号的默认处理行为。
-
- SIG_IGN:忽略该信号。
代码实现:
#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <signal.h>
#include <cassert>// 给2号信号设置的默认捕捉方法
void handler(int signo)
{std::cout << "进程捕捉到了一个信号, 信号编号是: " << signo << std::endl;
}int main()
{signal(2, handler); while (true){std::cout << "我是一个进程, 我的pid是: " << getpid() << ", 我的ppid是: " << getppid() << std::endl;sleep(1);}return 0;
}
运行结果
那么为什么这里 Ctrl + C 不能终止掉进程了呢?因为我把它的默认动作改成了自定义动作,那么它的自定义动作不再是终止进程,而是进程捕捉。如果想终止,只需要在函数中添加 exit(0) 即可。
// 给2号信号设置的默认捕捉方法
void handler(int signo)
{cout << "进程捕捉到了一个信号, 信号编号是: " << signo << endl;exit(0);
}
此时就能退出了
三、产生信号
1. 调用系统函数向进程发信号
当我们要使用kill命令向一个进程发送信号时,我们可以以kill -信号名 进程ID的形式进行发送。
也可以以kill -信号编号 进程ID的形式进行发送。
实际上kill命令是通过调用kill函数实现的,kill函数可以给指定的进程发送指定的信号,kill函数的函数原型如下:
int kill(pid_t pid, int sig);
kill函数用于向进程ID为pid的进程发送sig号信号,如果信号发送成功,则返回0,否则返回-1。
我们可以用kill函数模拟实现一个kill命令,实现逻辑如下:
mysignal.cpp<.code>
#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <signal.h>
#include <cassert>
#include <string>
#include <sys/types.h>using namespace std;static void Usage(const string &proc)
{cout << "\nUsage: " << proc << " pid signo\n" << endl;
}int main(int argc, char *argv[])
{if (argc != 3){Usage(argv[0]);exit(1);}pid_t pid = atoi(argv[1]);int signo = atoi(argv[2]);int n = kill(pid, signo);if (n != 0){perror("kill");}return 0;
}
mytest.cpp
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
using namespace std;// 我写了一个将来一直会运行的程序, 用来进行后续的测试
int main()
{while (true){cout << "我是一个进程, 我的pid是: " << getpid() << ", 我的ppid是: " << getppid() << endl;sleep(1);}return 0;
}
Makefile
.PHONY:all
all:mysignal mytestmytest:mytest.cppg++ -o $@ $^ -std=c++11mysignal:mysignal.cppg++ -o $@ $^ -std=c++11 -g.PHONY:clean
clean:rm -f mytest mysignal
运行结果如下:
raise() 函数可以给当前进程发送指定的信号(自己给自己发信号)
函数原型如下:
#include <signal.h>int raise(int sig);
代码示例
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>using namespace std;int main()
{// 系统调用向目标进程发送信号// raise 给自己发送任意信号 kill(getpid(), 任意信号)int cnt = 0;while (cnt <= 10){printf("cnt: %d\n", cnt++);sleep(1);if (cnt >= 5){raise(3); // 当cnt=5时, 给自己发送3号信号}}return 0;
}
运行结果
abort() 函数使当前进程接收到信号而异常终止。
函数原型如下:
#include <stdlib.h>
void abort(void);
代码实现
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>void handler(int signo)
{printf("get a signal:%d\n", signo);
}
int main()
{signal(6, handler);while (1){sleep(1);// 系统调用向目标进程发送信号// abort 给自己发送 指定的6号信号SIGABRT == kill(getpid, SIGABRT)abort();}return 0;
}
与之前不同的是,虽然我们对SIGABRT信号进行了捕捉,并且在收到SIGABRT信号后执行了我们给出的自定义方法,但是当前进程依然是异常终止了。
说明一下: abort函数的作用是异常终止进程,exit函数的作用是正常终止进程,而abort本质是通过向当前进程发送SIGABRT信号而终止进程的,因此使用exit函数终止进程可能会失败,但使用abort函数终止进程总是成功的。
2. 硬件异常产生信号
硬件异常被硬件以某种方式即被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。
例如当前进程执行了除以 0 的指令,CPU 的运算单元会产生异常,内核将这个异常解释为 SIGFPE 信号发送给进程。
再比如当前进程访问了非法内存地址,MMU 会产生异常,内核将这个异常解释为 SIGSEGV 信号发送给进程。
代码示例
#include <iostream>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>using namespace std;// 自定义捕捉信号
void catchSig(int signo)
{cout << "获取到一个信号, 信号编号是: " << signo << endl;sleep(1);
}int main(int argc, char *argv[])
{signal(SIGFPE, catchSig); while (true){cout << "我在运行中......" << endl;sleep(1);int a = 10;a /= 0;}return 0;
}
运行结果
思考一下:操作系统是如何得知应该给当前进程发送 8 号信号的呢?也就是说,OS怎么知道 a / 0 了呢?
CPU 具备众多寄存器,像通用寄存器就有 eax、ebx、ecx、edx 等等。由于在 CPU 内部必然会进行大量的计算,所以每当在 CPU 上开展相应运算时,除了要得出正常的运算结果外,这里所说的正常运算,例如把 10 加载到 eax 中,把 0 加载到 ebx 中,再把 10 除以 0 的结果存放到 ecx 中,这就是所谓的正常计算情况。
也就是说,CPU 在进行对应运算时,不但要算出结果,还得确保此次运算是否存在问题,正因如此,CPU 内部设有一个名为 状态寄存器 的部件。
状态寄存器当中包含大量的数据,不过这些数据并不属于代码里的数据范畴,它们主要是用于衡量此次运算的结果。
打个比方,当你在运算过程中出现除以零的情况时,计算机里 除以零 就等同于除以无穷大,就像 10 / 0,其结果就是数据无穷大,这样最终就会致使状态寄存器当中的溢出标志位从 0 变为 1。
要知道寄存器是由二进制序列构成的,其内部的比特位都有各自的含义,在状态寄存器里就有一个溢出标记位。
这个标记位默认是 0,若为 0 则代表此次运算没有问题,不存在溢出情况。而要是进行了除以 0 的运算,那么 CPU 在运算时马上就能发现会出现溢出,一旦溢出,状态寄存器中的该标记位就会被置为 1,这意味着本次计算处于溢出状态。
所以,经过这样的运算后得出的结果是没有意义的,不能被采纳。
至此,也就相当于出现了 CPU 的运算异常,鉴于操作系统是软硬件资源的管理者,所以操作系统(OS)必须要能够识别出这个异常。
那它是如何识别的呢?其实就是查看状态寄存器中的标志位!
只要这个标志位被置为 1 了,操作系统就能立刻知晓硬件上的 CPU 发生了溢出,并且清楚是由谁导致了这个溢出。
随后,操作系统会向目标进程修改相应标记位,并发送 8 号信号,如此一来,这个进程在收到 8 号信号后,经过相应处理就会自行终止了。
那么,为什么上述代码运行后会持续循环打印呢?
答:当进程接收到信号时,并不一定会直接退出。若进程未退出,则仍有可能被CPU调度执行。需要明确的是,CPU内部的寄存器是唯一的,但寄存器中的内容却属于当前运行进程的上下文。
由于我们无法手动修正状态寄存器的异常,因此在进程调度切换的过程中,状态寄存器会被反复保存和恢复。每次恢复时,操作系统都会检测到状态寄存器中的溢出标志位(Overflow Flag)为 1,这就使得进程不断收到操作系统发送的异常信号,进而导致持续打印的现象。
本质上,C/C++ 代码中的除零操作在底层会触发硬件异常。当 CPU 执行除法指令时,如果除数为零,状态寄存器的溢出标志位会被置为 1。操作系统在检测到这一硬件异常后,会将其转换为软件层面的信号(如 SIGFPE,即 8 号信号)发送给目标进程。若进程未对该信号进行特殊处理(如忽略或捕获),默认行为就是终止进程,这就是为什么除零操作会导致进程崩溃的根本原因。
再来一个经典的例子:
#include <iostream>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>using namespace std;// 自定义捕捉信号
void catchSig(int signo)
{cout << "获取到一个信号, 信号编号是: " << signo << endl;exit(1); // 收到信号以后就终止
}int main()
{signal(SIGSEGV, catchSig); while (true){cout << "我在运行中......" << endl;sleep(1);int *p = nullptr;*p = 100;}return 0;
}
运行结果
为什么野指针就会崩溃呢?因为 OS 会给当前进程发送指定的 11 号信号 SIGSEGV(代表非法内存引用)
那么 OS 怎么知道野指针的呢?
在进程的虚拟地址空间中,任何内存访问都需要通过页表映射到物理内存。例如,当执行 int* p; *p = nullptr
后,解引用操作 *p 本质上是访问虚拟地址(指针的本质就是虚拟地址),而虚拟地址到物理地址的转换依赖两个核心组件:页表 和 MMU(内存管理单元)。其中 MMU 是集成在 CPU 内部的硬件单元,负责执行地址转换。
当解引用 p 时,若 p 的值为 nullptr(即 0 号虚拟地址),MMU 会通过页表进行地址转换。此时页表会明确标记:0 号地址属于内核空间,禁止用户进程访问。因此,MMU 在执行转换时会因 越界访问 触发硬件异常。
操作系统作为硬件的管理者,会立即捕获这个异常。由于异常发生在地址转换阶段,操作系统会将其翻译为 SIGSEGV(11号信号),并发送给目标进程。进程接收到该信号后,默认行为是终止并生成核心转储文件(Core Dump)。
总结来说,野指针的本质是 非法内存访问。当进程尝试访问未被页表映射的虚拟地址(如 nullptr )时,MMU 会因地址转换失败触发硬件异常,操作系统将其转换为信号发送给进程,最终导致进程崩溃。这一机制确保了系统的安全性 —— 任何越界访问都会被及时拦截,防止恶意或误操作破坏系统稳定性。
3. 软件条件产生信号
SIGPIPE信号实际上就是一种由软件条件产生的信号,当进程在使用管道进行通信时,读端进程将读端关闭,而写端进程还在一直向管道写入数据,那么此时写端进程就会收到SIGPIPE信号进而被操作系统终止。
例如,下面代码当中,创建匿名管道进行父子进程之间的通信,其中父进程是读端进程,子进程是写端进程,但是一开始通信父进程就将读端关闭了,那么此时子进程在向管道写入数据时就会收到SIGPIPE信号,进而被终止。
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{int fd[2] = { 0 };if (pipe(fd) < 0){ //使用pipe创建匿名管道perror("pipe");return 1;}pid_t id = fork(); //使用fork创建子进程if (id == 0){//childclose(fd[0]); //子进程关闭读端//子进程向管道写入数据const char* msg = "hello father, I am child...";int count = 10;while (count--){write(fd[1], msg, strlen(msg));sleep(1);}close(fd[1]); //子进程写入完毕,关闭文件exit(0);}//fatherclose(fd[1]); //父进程关闭写端close(fd[0]); //父进程直接关闭读端(导致子进程被操作系统杀掉)int status = 0;waitpid(id, &status, 0);printf("child get signal:%d\n", status & 0x7F); //打印子进程收到的信号return 0;
}
运行代码后,即可发现子进程在退出时收到的是13号信号,即SIGPIPE信号。
调用 alarm() 函数可以设定一个闹钟,也就是告诉内核在 seconds 秒之后给当前进程发 SIGALRM 信号,该信号的默认处理动作是终止当前进程。
#include <unistd.h>unsigned int alarm(unsigned int seconds);
这个函数的返回值是 0 或者是以前设定的闹钟时间还余下的秒数。
打个比方,某人要小睡一觉,设定闹钟为 30 分钟之后响,20 分钟后被人吵醒了,还想多睡一会儿,于是重新设定闹钟为 15 分钟之后响,“以前设定的闹钟时间还余下的时间” 就是 10 分钟。
如果 seconds 值为 0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数。
下面代码的作用是 1 秒钟之内不停地数数,1 秒钟到了就被 SIGALRM 信号终止。
#include <iostream>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>using namespace std;// 自定义捕捉信号
void catchSig(int signo)
{cout << "获取到一个信号, 信号编号是: " << signo << endl;exit(1); // 收到信号以后就终止
}int main()
{signal(SIGALRM, catchSig); // 统计1s左右, 计算机能够将数据累计相加多生成!int cnt = 14;alarm(1);while (true){cnt++;cout << "cnt: " << cnt << endl;}return 0;
}
运行结果
那么,该如何理解操作系统(OS)中的闹钟呢?
闹钟实际上是通过软件来实现的。任意一个进程都能够借助 “alarm” 系统调用,在内核里设置闹钟。这也就意味着在操作系统内部可能存在众多的闹钟,所以操作系统需要采用先描述再组织的方式对这些闹钟进行管理。
在操作系统内部,要为闹钟创建特定的数据结构对象,其伪代码如下:
struct alarm
{uint64_t when; // 未来的超时时间int type; // 闹钟类型task_struct *p;struct alarm *next;
}
随后创建一个对象,比如:struct alarm myalarm = {…}。
接着,创建一个名为 struct alarm *head
的头指针,我们可以将设置好的闹钟放入对应的队列当中,然后把所有人设定的闹钟通过特定的数据结构连接起来,而操作系统会周期性地去检查这些闹钟。
那操作系统是如何进行检查的呢?它首先会获取当前的时间戳 curr_timestamp
,再将其与 alarm.when
进行对比,如果 curr_timestamp
大于 alarm.when
,那就表明已经超时了。一旦超时,操作系统会向 alarm.p
(这里的 p 代表指定的进程)发送 SIGALARM
信号。
若要理解这个闹钟,从本质上来说,操作系统内部对闹钟的管理最终就演变成了对链表的增删查改操作。
所谓闹钟是软件条件,其过程是这样的:操作系统需要定期去检查超时条件,这里所说的超时,就是指操作系统这样的软件去对闹钟以及闹钟所维护的软件集合进行检查。当时间到达设定值的时候,操作系统会从相应的结构当中查找对应的超时时间。它的这类行为全部是由软件来完成的,而我们所说的条件就体现在超时这个方面,所以才将其称作软件条件。
4. 通过终端按键产生信号
当面对下面的死循环程序时,我们都知道可以按Ctrl+C可以终止该进程。
#include <stdio.h>
#include <unistd.h>int main()
{while (1){printf("hello signal!\n");sleep(1);}return 0;
}
但实际上除了按Ctrl+C之外,按Ctrl+\也可以终止该进程。
按Ctrl+C终止进程和按Ctrl+\终止进程,有什么区别?
按Ctrl+C实际上是向进程发送2号信号SIGINT,而按Ctrl+\实际上是向进程发送3号信号SIGQUIT。查看这两个信号的默认处理动作,可以看到这两个信号的Action是不一样的,2号信号是Term,而3号信号是Core。
Term和Core都代表着终止进程,但是Core在终止进程的时候会进行一个动作,那就是核心转储。
什么是核心转储?
在云服务器中,核心转储是默认被关掉的,我们可以通过使用ulimit -a
命令查看当前资源限制的设定。
其中,第一行显示core文件的大小为0,即表示核心转储是被关闭的。
我们可以通过ulimit -c size
命令来设置core文件的大小。
core文件的大小设置完毕后,就相当于将核心转储功能打开了。此时如果我们再使用Ctrl+\对进程进行终止,就会发现终止进程后会显示core dumped。
并且会在当前路径下生成一个core文件,该文件以一串数字为后缀,而这一串数字实际上就是发生这一次核心转储的进程的PID。
说明一下: ulimit命令改变的是Shell进程的Resource Limit,但myproc进程的PCB是由Shell进程复制而来的,所以也具有和Shell进程相同的Resource Limit值。
核心转储功能有什么用?
当我们的代码出错了,我们最关心的是我们的代码是什么原因出错的。如果我们的代码运行结束了,那么我们可以通过退出码来判断代码出错的原因,而如果一个代码是在运行过程中出错的,那么我们也要有办法判断代码是什么原因出错的。
当我们的程序在运行过程中崩溃了,我们一般会通过调试来进行逐步查找程序崩溃的原因。而在某些特殊情况下,我们会用到核心转储,核心转储指的是操作系统在进程收到某些信号而终止运行时,将该进程地址空间的内容以及有关进程状态的其他信息转而存储到一个磁盘文件当中,这个磁盘文件也叫做核心转储文件,一般命名为core.pid。
而核心转储的目的就是为了在调试时,方便问题的定位。
如何运用核心转储进行调试?
我们用下面这段代码进行演示:
很明显,该代码当中出现了除0错误,该程序运行3秒后便会崩溃。
此时我们便可以在当前目录下看到核心转储时生成的core文件。
使用gdb对当前可执行程序进行调试,然后直接使用core-file core文件
命令加载core文件,即可判断出该程序在终止时收到了8号信号,并且定位到了产生该错误的具体代码。
说明一下: 事后用调试器检查core文件以查清错误原因,这种调试方式叫做事后调试。
还记得进程等待函数waitpid函数的第二个参数吗:
pid_t waitpid(pid_t pid, int *status, int options);
waitpid函数的第二个参数status是一个输出型参数,用于获取子进程的退出状态。status是一个整型变量,但status不能简单的当作整型来看待,status的不同比特位所代表的信息不同,具体细节如下(只关注status低16位比特位):
若进程是正常终止的,那么status的次低8位就表示进程的退出状态,即退出码。若进程是被信号所杀,那么status的低7位表示终止信号,而第8位比特位是core dump标志,即进程终止时是否进行了核心转储。
打开Linux的核心转储功能,并编写下列代码。代码中父进程使用fork函数创建了一个子进程,子进程所执行的代码当中存在野指针问题,当子进程执行到*p = 100时,必然会被操作系统所终止并在终止时进行核心转储。此时父进程使用waitpid函数便可获取到子进程退出时的状态,根据status的第7个比特位便可得知子进程在被终止时是否进行了核心转储。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>int main()
{if (fork() == 0){//childprintf("I am running...\n");int *p = NULL;*p = 100;exit(0);}//fatherint status = 0;waitpid(-1, &status, 0);printf("exitCode:%d, coreDump:%d, signal:%d\n",(status >> 8) & 0xff, (status >> 7) & 1, status & 0x7f);return 0;
}
可以看到,所获取的status的第7个比特位为1,即可说明子进程在被终止时进行了核心转储。
因此,core dump标志实际上就是用于表示程序崩溃的时候是否进行了核心转储。
其他组合按键?
我们可以通过以下代码,将1~31号信号全部进行捕捉,将收到信号后的默认处理动作改为打印收到信号的编号。
#include <stdio.h>
#include <unistd.h>
#include <signal.h>void handler(int signal)
{printf("get a signal:%d\n", signal);
}
int main()
{int signo;for (signo = 1; signo <= 31; signo++){signal(signo, handler);}while (1){sleep(1);}return 0;
}
此时,当我们按下组合按键Ctrl+C、Ctrl+\、Ctrl+Z后,便可以得知这些组合按键分别是向前台进程发送几号信号了。
但如果我们此时向该进程发送9号信号,该进程并不会打印收到了9号信号,而是执行收到9号信号后的默认处理动作,即被终止。
说明: 有些信号是不能被捕捉的,比如9号信号。因为如果所有信号都能被捕捉的话,那么进程就可以将所有信号全部进行捕捉并将动作设置为忽略,此时该进程将无法被杀死,即便是操作系统。
四、保存信号
1.信号其他相关常⻅概念
- 实际执行信号的处理动作,称为信号递达(Delivery)。
- 信号从产生到递达之间的状态,称为信号未决(pending)。
- 进程可以选择阻塞(Block)某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
- 需要注意的是,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
2.在内核中的表示
信号在内核中的表示示意图:
- 每个信号都有两个标志位分别表⽰阻塞(block)和未决(pending),还有⼀个函数指针表⽰处理动
作。信号产⽣时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上
图的例⼦中,SIGHUP信号未阻塞也未产⽣过,当它递达时执⾏默认处理动作。 - SIGINT信号产⽣过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻
塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。 - SIGQUIT信号未产⽣过,⼀旦产⽣SIGQUIT信号将被阻塞,它的处理动作是⽤⼾⾃定义函数
sighandler。 - 如果在进程解除对某信号的阻塞之前这种信号产⽣过多次,将如何处理?POSIX.1允许系统递送该信号⼀次或多次。Linux是这样实现的:常规信号在递达之前产⽣多次只计⼀次,⽽实时信号在递达之前产⽣多次可以依次放在⼀个队列⾥。本章不讨论实时信号。
总结一下:
- 在block位图中,比特位的位置代表某一个信号,比特位的内容代表该信号是否被阻塞。
- 在pending位图中,比特位的位置代表某一个信号,比特位的内容代表是否收到该信号。
- handler表本质上是一个函数指针数组,数组的下标代表某一个信号,数组的内容代表该信号递达时的处理动作,处理动作包括默认、忽略以及自定义。
- block、pending和handler这三张表的每一个位置是一一对应的。
你是否还记得 signal(signo, handler) 函数?它的作用是针对特定信号设定专门的回调(捕捉)方法。
下面以 3 号信号为例详细说明,当调用 signal(3, handler) 时,系统会进行如下操作:
- 在内核维护的 handler[32] 数组里查找 3 号信号对应的位置。由于信号编号从 1 开始,所以 3 号信号存于数组的索引 3 处。
- 把用户定义的信号处理函数 handler 的地址,写入到 handler[32] 数组索引为 3 的位置。
当 3 号信号产生时,系统会按以下步骤处理:
- 在内核的 pending 位图里,将对应 3 号信号的比特位置为 1。
- 若该信号未被阻塞(即 block 位图中对应位为 0),操作系统就会把这个信号传递给进程。
- 信号传递时,操作系统依据 pending 位图中被置为 1 的位,反推出信号编号。
- 通过信号编号访问 handler[32] 数组,获取事先注册的处理函数并执行。
这里的 pending 位图、block 位图和 handler 数组,是内核为每个进程都设置好的数据结构。借助系统调用(像 sigaction ),用户进程能够操作这些结构,进而构建起完整的信号处理机制。
总结如下:
- 信号即便还未产生,也能够预先将其阻塞,这是合理可行的。
- 进程能够识别并处理信号,依靠的是内核为每个进程维护的三种数据结构:
-
- pending 位图:其作用是记录已经产生但尚未被处理的信号。
-
- block 位图:用于标记被阻塞(暂时不处理)的信号。
-
- handler 数组:里面存储着每个信号对应的处理函数指针。
这三种结构协同工作,实现了进程对信号的识别与响应。
3. sigset_t
根据信号在内核中的表示方法,每个信号的未决标志只有一个比特位,非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 置位,表示该信号集的有效信号包括系统支持的所有信号。
- 注意,在使用 sigset_ t 类型的变量之前,一定要调用 sigemptyset 或 sigfillset 做初始化,使信号集处于确定的状态。初始化 sigset_t 变量之后,就可以再调用 sigaddset 和 sigdelset 在该信号集中添加或删除某种有效信号。
这四个函数都是成功返回 0,出错返回 -1。sigismember 是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回 1,不包含则返回 0,出错返回 -1。
5. sigprocmask
调用函数 sigprocmask 可以读取或更改进程的信号屏蔽字(阻塞信号集)。
#include <signal.h>int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
参数说明:
-
如果 oset 是非空指针,则读取进程的当前信号屏蔽字通过 oset 参数传出。
-
如果 set 是非空指针,则更改进程的信号屏蔽字,参数 how 指示如何更改。
-
如果 oset 和 set 都是非空指针,则先将原来的信号屏蔽字备份到 oset 里,然后根据 set 和 how 参数更改信号屏蔽字。
假设当前的信号屏蔽字为 mask,下表说明了 how 参数的可选值。
返回值:若成功则为 0,若出错则为 -1。
注意: 如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask函数返回前,至少将其中一个信号递达。
6. sigpending
读取当前进程的未决信号集,通过 set 参数传出。调用成功则返回 0,出错则返回 -1。
#include <signal.h>int sigpending(sigset_t *set);
下面用刚学的几个函数做个实验,代码如下:
#include <iostream>
#include <vector>
#include <signal.h>
#include <unistd.h>using namespace std;#define BLOCK_SIGNAL 2
#define MAX_SIGNUM 31static void show_pending(const sigset_t &pending)
{for (int signo = 31; signo >= 1; signo--){if (sigismember(&pending, signo)){std::cout << 1;} else{std::cout << 0;}cout << "\n";}
}int main()
{// 1. 先尝试屏蔽指定的信号sigset_t block, oblock, pending;// 1.1 初始化sigemptyset(&block);sigemptyset(&oblock);sigemptyset(&pending);// 1.2 添加要屏蔽的信号sigaddset(&block, BLOCK_SIGNAL); // 屏蔽2号信号// 1.3 开始屏蔽, 设置进内核, 即进程的PCB中sigprocmask(SIG_SETMASK, &block, &oblock);// 2. 遍历打印所有的pending信号集while (true){// 2.1 初始化sigemptyset(&pending);// 2.2 获取pending信号集sigpending(&pending);// 2.3 打印它show_pending(pending);// 3. 慢一点打印sleep(1);}return 0;
}
运行结果:
- 每行 31 个字符,表示信号 1~31 的挂起状态,从高位(信号 31)打印到低位(信号 1)。
- ^C 是你按下 Ctrl+C(发送 SIGINT)的表现。
- 因为信号 2 被阻塞了,所以不能终止程序,而是进入 pending 状态。
- 所以我们看到:
0000000000000000000000000000010↑↑信号2被挂起
- 连续打印了几次 …0010,表示 SIGINT 一直处于挂起状态(直到被解除阻塞或处理)。
- 最后输入 ^\ 是你按下 Ctrl + \(发送 SIGQUIT,信号3),这是未被阻塞的,会终止程序(显示 Quit)。
上面是由 0 置1,再来看看由 1 置 0 的过程,也就是说:重新设置恢复,我就应该看到这个信号,它在进程从内核态到用户态返回时,它就应该要进行我们对应的信号递达,然后我们就应该看到它的二进制序列的第二个位置由 0 变成 1,再由 1 再变成 0。
代码如下:
#include <iostream>
#include <vector>
#include <signal.h>
#include <unistd.h>using namespace std;#define BLOCK_SIGNAL 2
#define MAX_SIGNUM 31static void show_pending(const sigset_t &pending)
{for (int signo = MAX_SIGNUM; signo >= 1; signo--){// 判断当前的signo信号是否被pendingif (sigismember(&pending, signo)){cout << "1";}elsecout << "0";}cout << "\n";
}static void myhandler(int signo)
{cout << signo << " 号信号已经被递达!!!" << endl;
}int main()
{// 自定义捕捉2号信号signal(BLOCK_SIGNAL, myhandler);// 1. 先尝试屏蔽指定的信号sigset_t block, oblock, pending;// 1.1 初始化sigemptyset(&block);sigemptyset(&oblock);sigemptyset(&pending);// 1.2 添加要屏蔽的信号sigaddset(&block, BLOCK_SIGNAL); // 屏蔽2号信号// 1.3 开始屏蔽, 设置进内核, 即进程的PCB中sigprocmask(SIG_SETMASK, &block, &oblock);// 2. 遍历打印所有的pending信号集int cnt = 6;while (true){// 2.1 初始化sigemptyset(&pending);// 2.2 获取pending信号集sigpending(&pending);// 2.3 打印它show_pending(pending);// 3. 慢一点打印sleep(1);if (cnt-- == 0){sigprocmask(SIG_SETMASK, &oblock, &block);cout << "恢复对信号的屏蔽, 即不屏蔽任何信号" << endl;}}return 0;
}
运行结果:
细节: 在解除2号信号后,2号信号的自定义动作是在打印“恢复对信号的屏蔽, 即不屏蔽任何信号”之前执行的。因为如果调用sigprocmask解除对当前若干个未决信号的阻塞,则在sigprocmask函数返回前,至少将其中一个信号递达。
五、捕捉信号
我们知道,信号产生的时候,不会被立即处理,而是在合适的时候。
那么什么时候才算合适呢?其实是从内核态返回用户态的时候,进行处理!
当进行系统调用或者进程切换的时候,就会进入到内核态!
1. 知识点一
在操作系统里,用户态和内核态是两个重要的概念。当我们日常编写的代码被编译运行后,通常处于用户态。在这种状态下,若要访问内核功能或者硬件资源,就需要借助系统调用。不过,普通用户没办法直接以用户态的身份去执行系统调用,而是要先从用户态转变为内核态才行。
这里要明确的是,虽然表面上看是进程在执行系统调用,但实际上执行这些操作的是内核。由于系统调用的过程相对复杂,会消耗较多的时间,所以在进行编程时,应尽量避免频繁地进行系统调用,以此来提升程序的运行效率。
那用户态和内核态到底是什么呢?又怎样判断当前处于用户态还是内核态呢?
- 用户态:以用户身份(CR3权限标志位为3),只能访问用户区【0,3】GB的代码和数据。
- 内核态:以内核身份(CR3权限标志位为0),允许以系统调用的方式访问OS【3,4】GB的代码和数据。
从用户态切换为内核态通常有如下几种情况:
1.需要进行系统调用时。
2. 当前进程的时间片到了,导致进程切换。
3. 产生异常、中断等。
与之相对应,从内核态切换为用户态有如下几种情况:
1.系统调用返回时。
2.进程切换完毕。
3.异常、中断等处理完毕。
其中,由用户态切换为内核态我们称之为陷入内核(陷阱)。每当我们需要陷入内核时,本质上是因为陷阱用于用户主动请求服务(如系统调用)或处理程序内部错误(如除零)。
2.知识点二
当进程真正开始执行时,必然要把自身的上下文信息传递给 CPU。而 CPU 里存在着数量众多的寄存器,这些寄存器通常可分为两类:
- 可见寄存器(像 eax、ebx、ecx 等)
- 不可见寄存器(例如状态寄存器)
那些与当前进程紧密相连的信息,都属于进程的上下文数据。
实际上,CPU内部还有不少在进程里有着特定用途的寄存器。
- 有一类被称作 correct 的寄存器,它的作用是在寄存器里直接指向当前正在运行进程的 PCB。
- 还有一些寄存器能够直接保存当前进程用户级页表,也就是指向页表的起始地址。
- 另外有个名为 CR3 的寄存器,其内部的标志位可用来表明当前进程的运行级别:
-
- 0 代表内核态
-
- 3 代表用户态
3. 知识点三
思考这样一个问题:我作为一个进程,怎么会跑到操作系统的内核里去执行方法呢?
我们都知道,task_struct 是直接指向当前进程的地址空间 mm_struct 的。随后,mm_struct 会通过页表映射到与之对应的物理内存的特定位置,如此一来,便能访问相应的代码和数据了。
以 32 位操作系统为例,mm_struct 的总大小是 4GB,其中用户空间占据了 0~3GB 的范围,而内核空间则占了 3~4GB,这个内核空间就是供当前进程去映射操作系统的。
每个进程都拥有各自独立的用户级页表,也就是说,每一个进程都需要凭借自己对应的虚拟地址,经过自身的用户级页表,被映射到对应的不同物理内存位置。由于每个进程的页表不一样,映射关系也各有不同,所以每个进程能够确保自身的独立性。
在操作系统内部,还维护着一张内核级页表,这张表实际上是操作系统为了维护从虚拟到物理之间的操作系统级别代码而构建的一张内核级映射表。
在开机时,操作系统会被加载到内存当中,并且操作系统在物理内存里只会存在一份,而进程及其代码却可以有多个副本。
因为操作系统在物理内存当中只有一份,所以它的代码和数据在系统内是唯一的,也就是在内存里仅有一份,这也就决定了内核级页表的相关情况。
最终,当前进程在 mm_struct 中映射这 1GB 的内核空间时,会把内核的代码和数据映射到对应的物理内存中的 1GB 空间里,这个时候我们只需要使用内核级页表就可以了,所以内核级页表只需要一份就足够了。
因此,每个进程都可以在自己的地址空间特定区域内,通过内核级页表的方式去访问操作系统的代码和数据。
每一个进程都有自己的地址空间,用户空间是独占的,而内核空间则被映射到了每一个进程的 3~4GB 的范围,那么进程要访问操作系统的接口,其实只需要在自己的地址空间上进行跳转就行。
例如,当我们要进行系统调用时,由于操作系统的系统接口其实是可以通过内核级页表以及这 3~4GB 的空间,直接让进程看到的,所以在代码当中调用所谓的系统调用时,其实就是在自己的上下文当中,从用户空间的正文代码段跳转到内核空间区域,找到对应的方法,在执行时,要借助内核级页表来找到操作系统的代码和数据,执行完之后再返回到用户空间的代码处,继续往后运行。
所以,执行操作系统的代码在自己的上下文当中就能够完成了!
4. 知识点四
每个进程都拥有 3~4GB 的内核空间,并且所有进程会共享一个内核级页表。无论进程怎样进行切换,这 3~4GB 的内核空间都不会发生改变。换而言之,0~3G 的用户空间是属于进程自身的,而 3~4G 的内核空间则归操作系统所有。
那么,作为用户,凭什么能够访问指向内核的接口呢?其实很简单,必须要确保我们当前进程在 CPU 的 CR3 寄存器内,其所对应的运行级别处于内核态才行。
不妨思考一下:在调用系统调用接口之前,进程肯定是处于用户态的,那它是什么时候转变为内核态的呢?不用担心,当你进行系统调用接口操作时,起始位置会负责完成这个状态转变的工作。
总结一下就是:假如我是一个进程,那我是怎样进入操作系统内部去执行相关操作的呢?答案并不复杂。
- 首先,我们进程凭借自身的 3~4G 地址空间,是能够访问到操作系统相关的代码与数据的。
- 其次,我们的操作系统内部维护着一张公共的内核级页表,所有进程都共享这张页表,所以不管- 进程如何切换,当前进程始终都能够访问到内核的代码与数据。
- 接着,在进行相应切换的时候,我们需要将自身的状态从用户态切换为内核态。
- 然后,再在自身地址空间的范围内进行跳转,去执行系统调用,等执行完毕后再返回就可以了。
5.内核如何实现信号的捕捉
当我们在执行主控制流程的时候,可能因为某些情况而陷入内核,当内核处理完毕准备返回用户态时,就需要进行信号pending的检查。(此时仍处于内核态,有权力查看当前进程的pending位图)
在查看pending位图时,如果发现有未决信号,并且该信号没有被阻塞,那么此时就需要该信号进行处理。
如果待处理信号的处理动作是默认或者忽略,则执行该信号的处理动作后清除对应的pending标志位,如果没有新的信号要递达,就直接返回用户态,从主控制流程中上次被中断的地方继续向下执行即可。
但如果待处理信号是自定义捕捉的,即该信号的处理动作是由用户提供的,那么处理该信号时就需要先返回用户态执行对应的自定义处理动作,执行完后再通过特殊的系统调用sigreturn再次陷入内核并清除对应的pending标志位,如果没有新的信号要递达,就直接返回用户态,继续执行主控制流程的代码。
注意: sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。
巧计
其中,该图形与直线有几个交点就代表在这期间有几次状态切换,而箭头的方向就代表着此次状态切换的方向,图形中间的圆点就代表着检查pending表。
6.sigaction
捕捉信号除了用前面用过的signal函数之外,我们还可以使用sigaction函数对信号进行捕捉,sigaction函数的函数原型如下:
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
sigaction函数可以读取和修改与指定信号相关联的处理动作,该函数调用成功返回0,出错返回-1。
参数说明:
- signum代表指定信号的编号。
- 若act指针非空,则根据act修改该信号的处理动作。
- 若oldact指针非空,则通过oldact传出该信号原来的处理动作。
其中,参数act和oldact都是结构体指针变量,该结构体的定义如下:
struct sigaction {void(*sa_handler)(int);void(*sa_sigaction)(int, siginfo_t *, void *);sigset_t sa_mask;int sa_flags;void(*sa_restorer)(void);
};
结构体的第一个成员sa_handler:
- 将sa_handler赋值为常数SIG_IGN传给sigaction函数,表示忽略信号。
- 将sa_handler赋值为常数SIG_DFL传给sigaction函数,表示执行系统默认动作。
- 将sa_handler赋值为一个函数指针,表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数。
注意: 所注册的信号处理函数的返回值为void,参数为int,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然这是一个回调函数,不是被main函数调用,而是被系统所调用。
结构体的第二个成员sa_sigaction:
- sa_sigaction是实时信号的处理函数。
结构体的第三个成员sa_mask:
首先需要说明的是,当某个信号的处理函数被调用,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。
如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时,自动恢复原来的信号屏蔽字。
结构体的第四个成员sa_flags:
- sa_flags字段包含一些选项,这里直接将sa_flags设置为0即可。
结构体的第五个成员sa_restorer:
- 该参数没有使用。
例如,下面我们用sigaction函数对2号信号进行了捕捉,将2号信号的处理动作改为了自定义的打印动作,并在执行一次自定义动作后将2号信号的处理动作恢复为原来默认的处理动作。
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>struct sigaction act, oact;
void handler(int signo)
{printf("get a signal:%d\n", signo);sigaction(2, &oact, NULL);
}
int main()
{memset(&act, 0, sizeof(act));memset(&oact, 0, sizeof(oact));act.sa_handler = handler;act.sa_flags = 0;sigemptyset(&act.sa_mask);sigaction(2, &act, &oact);while (1){printf("I am a process...\n");sleep(1);}return 0;
}
运行代码后,第一次向进程发送2号信号,执行我们自定义的打印动作,当我们再次向进程发送2号信号,就执行该信号的默认处理动作了,即终止进程。
六、操作系统是怎么运⾏的
1.硬件中断
2.时钟中断 和死循环
3.软中断
4.系统调用的原理
七、可重⼊函数
下面主函数中调用insert函数向链表中插入结点node1,某信号处理函数中也调用了insert函数向链表中插入结点node2,乍眼一看好像没什么问题。
下面我们来分析一下,对于下面这个链表。
1、首先,main函数中调用了insert函数,想将结点node1插入链表,但插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回到用户态之前检查到有信号待处理,于是切换到sighandler函数。
2、而sighandler函数中也调用了insert函数,将结点node2插入到了链表中,插入操作完成第一步后的情况如下:
3、当结点node2插入的两步操作都做完之后从sighandler返回内核态,此时链表的布局如下:
4、再次回到用户态就从main函数调用的insert函数中继续往下执行,即继续进行结点node1的插入操作。
最终结果是,main函数和sighandler函数先后向链表中插入了两个结点,但最后只有node1结点真正插入到了链表中,而node2结点就再也找不到了,造成了内存泄漏。
上述例子中,各函数执行的先后顺序如下
像上例这样,insert函数被不同的控制流调用(main函数和sighandler函数使用不同的堆栈空间,它们之间不存在调用与被调用的关系,是两个独立的控制流程),有可能在第一次调用还没返回时就再次进入该函数,我们将这种现象称之为重入。
而insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数我们称之为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称之为可重入(Reentrant)函数。
如果一个函数符合以下条件之一则是不可重入的:
• 调⽤了malloc或free,因为malloc也是⽤全局链表来管理堆的。
• 调⽤了标准I/O库函数。标准I/O库的很多实现都以不可重⼊的⽅式使⽤全局数据结构。
八、volatile
volatile是C语言的一个关键字,该关键字的作用是保持内存的可见性。
在下面的代码中,我们对2号信号进行了捕捉,当该进程收到2号信号时会将全局变量flag由0置1。也就是说,在进程收到2号信号之前,该进程会一直处于死循环状态,直到收到2号信号时将flag置1才能够正常退出。
#include <stdio.h>
#include <signal.h>int flag = 0;void handler(int signo)
{printf("get a signal:%d\n", signo);flag = 1;
}
int main()
{signal(2, handler);while (!flag);printf("Proc Normal Quit!\n");return 0;
}
运行结果如下:
该程序的运行过程好像都在我们的意料之中,但实际并非如此。代码中的main函数和handler函数是两个独立的执行流,而while循环是在main函数当中的,在编译器编译时只能检测到在main函数中对flag变量的使用。
此时编译器检测到在main函数中并没有对flag变量做修改操作,在编译器优化级别较高的时候,就有可能将flag设置进寄存器里面。
此时main函数在检测flag时只检测寄存器里面的值,而handler执行流只是将内存中flag的值置为1了,那么此时就算进程收到2号信号也不会跳出死循环。
在编译代码时携带-03选项使得编译器的优化级别最高,此时再运行该代码,就算向进程发生2号信号,该进程也不会终止。
面对这种情况,我们就可以使用volatile关键字对flag变量进行修饰,告知编译器,对flag变量的任何操作都必须真实的在内存中进行,即保持了内存的可见性。
#include <stdio.h>
#include <signal.h>volatile int flag = 0;void handler(int signo)
{printf("get a signal:%d\n", signo);flag = 1;
}
int main()
{signal(2, handler);while (!flag);printf("Proc Normal Quit!\n");return 0;
}
此时就算我们编译代码时携带-03选项,当进程收到2号信号将内存中的flag变量置1时,main函数执行流也能够检测到内存中flag变量的变化,进而跳出死循环正常退出。
九、SIGCHLD信号
为了避免出现僵尸进程,父进程需要使用wait或waitpid函数等待子进程结束,父进程可以阻塞等待子进程结束,也可以非阻塞地查询的是否有子进程结束等待清理,即轮询的方式。采用第一种方式,父进程阻塞就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。
其实,子进程在终止时会给父进程发生SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理动作,这样父进程就只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait或waitpid函数清理子进程即可。
例如,下面代码中对SIGCHLD信号进行了捕捉,并将在该信号的处理函数中调用了waitpid函数对子进程进行了清理。
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
#include <sys/wait.h>void handler(int signo)
{printf("get a signal: %d\n", signo);int ret = 0;while ((ret = waitpid(-1, NULL, WNOHANG)) > 0){printf("wait child %d success\n", ret);}
}
int main()
{signal(SIGCHLD, handler);if (fork() == 0){//childprintf("child is running, begin dead: %d\n", getpid());sleep(3);exit(1);}//fatherwhile (1);return 0;
}
注意:
- SIGCHLD属于普通信号,记录该信号的pending位只有一个,如果在同一时刻有多个子进程同时退出,那么在handler函数当中实际上只清理了一个子进程,因此在使用waitpid函数清理子进程时需要使用while不断进行清理。
- 使用waitpid函数时,需要设置WNOHANG选项,即非阻塞式等待,否则当所有子进程都已经清理完毕时,由于while循环,会再次调用waitpid函数,此时就会在这里阻塞住。
此时父进程就只需专心处理自己的工作,不必关心子进程了,子进程终止时父进程收到SIGCHLD信号,会自动进行该信号的自定义处理动作,进而对子进程进行清理。
事实上,由于UNIX的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用signal或sigaction函数将SIGCHLD信号的处理动作设置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用signal或sigaction函数自定义的忽略通常是没有区别的,但这是一个特列。此方法对于Linux可用,但不保证在其他UNIX系统上都可用。
例如,下面代码中调用signal函数将SIGCHLD信号的处理动作自定义为忽略。
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>int main()
{signal(SIGCHLD, SIG_IGN);if (fork() == 0){//childprintf("child is running, child dead: %d\n", getpid());sleep(3);exit(1);}//fatherwhile (1);return 0;
}
此时子进程在终止时会自动被清理掉,不会产生僵尸进程,也不会通知父进程。
十、信号总结
1. 所有信号产生,最终都要有 OS 来进行执行,为什么?
原因:因为信号是一种由操作系统提供的异步通信机制。
信号的产生可能来自:
- 硬件中断(如 Ctrl+C )
- 系统调用(如 kill() )
- 内核检测(如非法内存访问导致的 SIGSEGV)
这些行为都发生在内核态,用户态的进程不能主动处理信号,必须等 OS 转而调度执行进程的信号处理函数,所以信号的分发和处理调度,必须由 OS 统一协调。
2. 信号的处理是否是立即处理的?
不一定是立即处理。
如果进程 当前是可中断状态(比如处于用户态运行中),并且信号未被屏蔽,那么可以 立即处理中断执行流,调用相应的信号处理函数。
如果进程 正在内核态执行系统调用、信号被屏蔽、或者被阻塞(如 sleep()),则信号的处理会被延迟,直到进程回到用户态或解除阻塞状态。
3. 信号如果不是被立即处理,那是否需要暂时被进程记录下来?记录在哪里最合适?
是的,信号需要暂时 “挂起” 并被记录下来。
- 内核为每个进程维护一个 “挂起信号集”(pending signal set)。
- 这是一个 位图(bitmap),表示每个信号是否已经被送达但尚未处理。
- 该结构保存在进程的 task_struct(任务控制块)中,是 OS 维护进程状态的一部分。
4. 一个进程在没有收到信号的时候,能否知道自己应该对合法信号作何处理?
可以。
每个进程都维护一个 信号处理表(signal disposition table):
- 默认处理(如终止、忽略、core dump)
- 用户自定义处理函数(通过 signal() 或 sigaction() 设置)
这些设置由进程自己在运行时决定和注册,操作系统在信号到达时,会参考这张表来判断该信号应该如何被处理。
5. 如何理解 OS 向进程发送信号?能否描述一下完整的发送处理过程?
完整过程如下:
- 信号产生:由其他进程调用 kill(pid, sig),或者 OS 检测到某种条件(如段错误)决定发送信号。
- 内核接收信号请求:内核确认目标进程存在,并判断权限是否允许发送。
- 挂起信号:
-
- 内核将信号标记到目标进程的 挂起信号集 中。
-
- 如果进程正在屏蔽该信号,则不立即处理,只是挂起。
- 进程调度时检查信号:
-
- 每次返回用户态或系统调用结束时,内核会检查进程的挂起信号集。
-
- 如果存在未屏蔽的信号,则:
-
-
- 暂停当前执行
-
-
-
- 调用信号处理函数(若注册)
-
-
-
- 或执行默认处理动作(终止、忽略等)
-
- 处理完成后,恢复原执行流。