信号 vs IPC 板书最后提到了 “信号 vs IPC”,暗示了信号也是一种进程间通信 (Inter-Process Communication, IPC) 的机制。虽然信号的主要目的是事件通知,但它也可以携带少量的信息(即信号的类型)。
在我们正式开始学习操作系统中的“信号”(Signal)之前,有几个概念需要先厘清:
想象一下你的日常生活:
这些生活中的“信号”有什么共同点?它们都在中断你当前正在做的事情,并向你传递一个事件已经发生的通知。
现在,让我们把这个概念映射到操作系统中:人 就像 进程 (Process)。
操作系统中的信号,就是一种**发送给进程的、用来进行事件异步****通知**的机制。它告诉进程,某个特定的事件发生了,需要进程注意或处理。
关键点:
在我们深入学习具体细节之前,先建立几个关于信号的基本认知:
小结
通过以上预备知识,我们对“信号”有了初步的印象:它是一种异步的、发送给进程的事件通知机制。进程对信号的处理方式是预先定义的,处理时机可以稍有延迟,并且进程拥有内置的识别能力。同时,信号的来源是多种多样的。
带着这些基本概念,让我们接下来深入探索操作系统中信号的具体类型、产生方式、以及进程如何捕获和处理它们。
正如我们之前所说,信号是操作系统用来通知进程发生了某些事件的异步机制。那么,这些“信使”是如何被发送到进程手中的呢?产生信号的方式非常多,下面我们逐一进行详细讲解:
这是用户与操作系统交互时最直接产生信号的方式。当用户在终端操作时,按下特定的组合键,操作系统会将这些按键操作转化为相应的信号,并发送给当前正在运行的**前台进程**。
我们所经常使用的Ctrl + C
就是通过键盘的组合键向进程发送信号,中断进程。不是有很多信号吗,那么Ctrl + C
具体是发送哪一个呢?
kill -l // 查看所有信号
通过查询可以得知,信号实际上就是数字,宏定义的!
Ctrl + C
通常会发送 SIGINT 信号,其信号编号为 2
,含义是“交互式注意信号”。这个信号通常被解释为用户希望中断当前正在运行的程序。
这么多的信号,要怎么进行记忆和学习?
实际上有相当一部分信号的默认处理动作都是让进程终止。并且我们学习的是前31个信号,34-64信号是实时信号,会在发送后接收后立即处理。
为什么又说是默认处理动作呢?
进程在收到信号后,会在合适的时候处理信号,而处理信号的动作有三种:
大部分信号的默认处理动作是中断进程。而有一部分进程支持自定义信号处理动作,也就是说可以自定义进程在接收信号后的行为,也就是自定义捕捉。忽略处理就是不做任何处理。
signal
函数)#include <signal.h>
typedef void (*sighandler_t)(int); // 函数指针类型
sighandler_t signal(int signum, sighandler_t handler);
signal()
函数用于设置自定义信号处理程序,即当特定信号发生时,程序应该执行的函数。#include <signal.h>
void (*signal(int signum, void (*handler)(int)))(int);
参数:
signum
:要处理的信号的编号。handler
:指向信号处理函数的指针,或者是一些预定义的信号处理常量,用于自定义信号的处理函数。返回值:
signal()
返回先前与指定信号关联的信号处理函数的指针。SIG_ERR
。信号处理方式:
handler
参数可以设置为以下几种值:
**SIG_DFL**
:使用信号的默认处理方式。**SIG_IGN**
:忽略该信号。示例:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
void sigint_handler(int signum) // signum 为实际接收到的信号
{
printf("Caught signal %d, exiting...\n", signum);
_exit(0);
}
int main() {
signal(SIGINT, sigint_handler); // SIGINT 为要修改触发动作的信号
while (1) {
printf("Running...\n");
sleep(1);
}
return 0;
}
当按下ctrl+c
时就会触发sigint_handler
这个信号处理函数,打印对应信息。
如此就可以把捕捉的信号处理方式改变,但是也有无法捕捉的信号,如9号信号,用来防止恶意进程占用前台。
程序运行进行信号读取或者发送时进行操作的目标进程只有一个,shell
命令行运行的就是前台进程,也就是所谓的目标进程。
后台进程不是目标进程,所以可以有多个后台进程。
为什么前台进程无法从键盘获取数据(标准输入)?
当执行一个程序时候,它成为了前台进程,运行时可以发现无法从键盘读取数据(除非程序设定)。
因为目标进程只能有一个。当执行这个程序的时候,命令行提示符,也就是bash
进程会被操作系统自动放到后台,所以通过键盘输入的时候没有命令行bash
来读取。
后台进程,无法从标准输入中读取内容
那为什么可以使用键盘组合键?
键盘组合键产生的信号就是发送给前台进程的,所以可以使用。
前台进程,能从键盘获取标准输入,键盘只有一个,输入的数据一定是给一个确定的进程的。 所以:
相关命令
jobs
:查看所有后台任务fg 任务号
:将特定的任务提到前台ctrl + z
:将当前进程切换到后台bg 任务号
:让后台进程恢复运行&
:通过在命令末尾添加 &
符号放到后台运行的进程发送信号的本质是什么?
之前所述,信号产生后并不是立即处理,那么一定会存在一个数据结构将信号存储记录。
在task_struct
中有这类似成员变量:
struct task_struct
{
unsigned int sigs;
}
其中**sigs**
为位图,比特位的位置为信号编号,内容为是否收到该信号。
task_struct
作为操作系统内核数据结构,一切操作由操作系统执行,当发送信号,操作系统修改sigs
位图,从而达到给进程发送信号的目的。
会提供系统调用供上层用户使用,如
kill()
,封装了系统调用kill
结论
向目标进程写信号就是修改位图!
**1. **int kill(pid_t pid, int sig);
kill
#include <signal.h>
:需要包含 <signal.h>
头文件。int kill(pid_t pid, int sig);
:函数原型。pid_t pid
:要发送信号的进程的进程ID(PID)。int sig
:要发送的信号的编号。kill
系统调用允许一个进程向另一个进程(或进程组)发送信号。SIGTERM
或 SIGKILL
),但也可以用于其他目的,例如通知进程发生特定事件。kill
函数并不是直接杀死进程,而是给进程发送一个能够终止进程的信号,接受到信号的进程才会进行终止。**2. **void abort(void);
abort
#include <stdlib.h>
:需要包含 <stdlib.h>
头文件。void abort(void);
:函数原型。abort
函数会立即终止当前进程。abort
函数会发送 SIGABRT
信号给当前进程,这个信号默认行为就是终止进程。abort
会重置SIGABRT
信号的自定义处理方式,然后发送SIGABRT
信号,从而保证进程能够终止。#include <stdio.h> // 包含标准输入输出库,用于 printf
#include <stdlib.h> // 包含标准库,其中定义了 abort() 函数
int main() {
printf("程序开始运行。\n"); // Program starts running.
// 模拟一个临界错误情况:例如,一个重要的资源指针为空
// 在实际程序中,这可能是 malloc 失败,或者其他原因导致一个本应有效的指针变成了 NULL
int* critical_resource = NULL;
printf("正在检查临界资源...\n"); // Checking critical resource...
// 检查是否存在无法处理的严重错误
if (critical_resource == NULL) {
printf("错误:临界资源为NULL,这是一个无法恢复的错误!即将调用 abort() 终止程序。\n"); // Error: Critical resource is NULL, this is an unrecoverable error! About to call abort() to terminate the program.
// 调用 abort() 函数
// 当 abort() 被调用时,程序会立即异常终止
abort();
// 注意:下面的这行代码永远不会被执行到,因为程序已经在上面终止了
printf("这行代码在调用 abort() 后不会被打印出来。\n"); // This line of code will not be printed after calling abort().
} else {
// 如果临界资源有效(在这个例子中不会发生),程序会继续
printf("临界资源检查通过。\n"); // Critical resource check passed.
// ... 实际程序中会在这里处理资源 ...
}
// 这行代码只会在上面 if 条件为 false (即 abort() 没有被调用) 时执行
printf("程序正常结束。(如果你看到这行,说明前面的 abort() 没有被触发)\n"); // Program ends normally. (If you see this line, it means the preceding abort() was not triggered)
return 0; // 正常退出,但在本例中如果 abort() 被调用,return 0 不会被达到
}
**3. **int raise(int sig);
raise
#include <signal.h>
:需要包含 <signal.h>
头文件。int raise(int sig);
:函数原型。int sig
:要发送的信号的编号。raise
函数允许一个进程向自身发送信号。kill
不同,raise
只影响调用它的进程本身。总结
kill
用于向其他进程发送信号。abort
用于立即终止当前进程。raise
用于向当前进程发送信号。
bash
中使用指令
效果:程序崩溃
当程序试图访问一个未分配或不允许访问的内存地址时,CPU会产生一个硬件异常。例如,试图读取或写入一个野指针指向的内存区域。
硬件异常会发送11
号信号,段错误。
代码示例:
#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
void handle_sigsegv(int signum) {
printf("Caught SIGSEGV (Invalid memory access)!\n");
exit(1);
}
int main() {
signal(SIGSEGV, handle_sigsegv); // 设置信号处理函数
int *ptr = NULL;
*ptr = 10; // 无效内存访问
printf("Value: %d\n", *ptr); // 这行代码不会被执行
return 0;
}
结果:
[fz@VM-20-14-centos 信号]$ ./a.out
Caught SIGSEGV (Invalid memory access)!
说明硬件异常后发送信号,触发自定义行为。
当程序试图将一个数除以零时,CPU会产生一个硬件异常,因为这是非法的数学运算。
触发过程:
SIGFPE
(浮点异常)信号。SIGFPE
信号。示例代码:
#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
void handle_sigfpe(int signum) {
printf("Caught SIGFPE (Division by zero)!\n");
exit(1);
}
int main() {
signal(SIGFPE, handle_sigfpe); // 设置信号处理函数
int a = 10;
int b = 0;
int result = a / b; // 除零错误
printf("Result: %d\n", result); // 这行代码不会被执行
return 0;
}
结果:
[fz@VM-20-14-centos 信号]$ ./a.out
Caught SIGFPE (Division by zero)!
除零错误后触发硬件异常,发送信号,触发自定义行为。
关键点:
程序访问非法地址 → MMU 地址转换失败 → CPU 异常 → EFLAGS 设置 → 操作系统介入
→ 通过 current->task_struct 获取当前进程 → 给进程发送信号 → 信号处理函数 or 杀死进程
用户程序访问虚拟地址
↓
MMU 尝试转换 → 失败
↓
CPU 触发 Page Fault 异常
↓
操作系统接管 → 保存现场
↓
读取进程上下文 current->task_struct
↓
发送信号给进程(如 SIGSEGV)
↓
进程终止 or 执行信号处理函数
所以:无论是什么信号,都是由操作系统发送的
这意味着信号的产生不是由于硬件故障或外部事件,而是由于操作系统内部的状态变化或特定的软件操作而触发的。
重点介绍两种由软件条件产生的信号:SIGPIPE
和 SIGALRM
。
简单来说,当一个进程尝试向一个已经关闭的管道的写入端写入数据时,操作系统会向该进程发送 SIGPIPE
信号。这通常表明管道的读取端已经关闭,继续写入数据已经没有意义。**SIGPIPE**
** 信号的默认行为是终止进程**。
alarm
函数SIGALRM
信号,它与 alarm
函数密切相关。
**alarm**
** 函数的作用:**
alarm
函数允许进程设置一个闹钟。当设定的时间到达后,操作系统会向该进程发送 SIGALRM
信号。
alarm
** 函数的原型和返回值:**
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
seconds
: 指定了在多少秒之后发送 SIGALRM
信号。seconds
的值为 0,则会取消之前设置的闹钟,并返回之前闹钟剩余的时间。SIGALRM
** 信号的默认行为:**
SIGALRM
信号的默认处理动作是终止当前进程。
基本 alarm
验证 - 体会 IO 效率问题
两个简单的示例代码,用于演示 alarm
函数和 SIGALRM
信号的基本使用,强调了 I/O 操作对程序效率的影响。
alarm(1)
设置了一个 1 秒后的闹钟。由于 SIGALRM
的默认行为是终止进程,所以程序会在运行大约 1 秒后被终止。#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;
}
运行结果显示,在 1 秒内,程序执行了大量的 std::cout
操作,但计数值相对较小。这说明频繁的 I/O 操作会显著降低程序的执行效率。
SIGALRM
信号,并在信号处理函数中打印最终的计数值,然后退出。#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;
}
运行结果显示,在 1 秒内,count
的值远大于前一个程序。这是因为这个程序在循环中只进行了简单的自增操作,避免了大量的 I/O 操作,因此效率更高。
结论:
alarm
函数设置的闹钟会响一次,并且默认会终止进程。alarm
函数设置的闹钟是一次性的,超时后会自动被取消。如果需要周期性地执行某个任务,可以利用信号处理函数来重新设置闹钟。
设置重复闹钟的示例:
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <vector>
#include <functional>
int gcount = 0;
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()
{
alarm(1); // 一次性的闹钟,超时alarm会自动被取消
signal(SIGALRM, hanlder);
while (true)
{
pause();
std::cout << "我醒来了..." << std::endl;
gcount++;
}
}
在这个例子中:
signal(SIGALRM, hanlder)
注册了 SIGALRM
信号的处理函数 hanlder
。main
函数中调用 alarm(1)
设置了一个 1 秒后的闹钟。pause()
函数。pause()
函数会使进程进入睡眠状态,直到接收到一个信号。SIGALRM
信号,进程被唤醒,执行 hanlder
函数。hanlder
函数中,会执行一些预定义的回调函数(被注释掉了),打印 gcount
的值,然后再次调用 alarm(1)
重新设置了一个 1 秒后的闹钟。hanlder
函数执行完毕后返回,pause()
函数也会返回(返回值为 -1,errno
被设置为 EINTR
),然后程序继续执行循环中的 std::cout << "我醒来了..." << std::endl;
和 gcount++;
。通过在信号处理函数中再次调用 alarm
,就实现了每隔 1 秒执行一次特定操作的效果,从而创建了一个重复的闹钟。
pause
** 函数**
#include <unistd.h>
int pause(void);
pause()
函数会使调用它的进程(或线程)睡眠,直到接收到一个信号,该信号要么终止进程,要么调用一个信号处理函数。pause()
只有在信号被捕获并且信号处理函数返回时才会返回。在这种情况下,pause()
返回 -1,并且 errno
被设置为 EINTR
。联系操作系统:
循环的闹钟不就是操作系统吗。
操作系统从开机的那一刻开始,作为一个软件程序,一直处于死循环状态,每一次循环都会进行内核刷新、进程时间片处理、内存管理等操作。
#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++;
}
}
结论:
alarm
设置的闹钟只会生效一次。alarm
来实现重复闹钟的效果。alarm(0)
来取消之前设置的闹钟。从操作系统层面解释系统闹钟的实现原理:
实现闹钟这样的技术,本质上是操作系统必+须自身具有定时功能,并且能够让用户设置这种定时功能。
现代 Linux 提供了定时功能,操作系统内会存在多个闹钟。内核需要管理这些定时器,所以存在数据结构来管理闹钟。先描述,后组织!
内核中使用 timer_list
结构来描述定时器:
struct timer_list {
struct list_head entry; // 链表管理所有的闹钟
unsigned long expires; // 定时器超时时间
void (*function)(unsigned long); // 超时后执行的处理方法
unsigned long data;
struct tvec_t_base_s *base;
};
timer_list
结构中包含了定时器超时的时间 (expires
) 和超时后需要执行的处理方法 (function
)。
操作系统管理定时器通常采用时间轮这种数据结构,但为了简单理解,可以将其组织成堆结构。这两种方式都是为了高效地管理大量的定时器,并在设定的时间到达时触发相应的操作。
怎么理解这个堆结构呢?
这个·堆结构应该是一个最小堆。expires
作为超时的时间,比如有一个进程的expires
是1005,而当前操作系统的启动时间为1000,也就是说再过5秒就会轮到这个进程。而这个进程又是现有的进程中expires
最小的,也就是说会是最早启动的进程,所以会存在于堆顶,当系统运行时间到1005的时候就会触发闹钟,执行超时后执行的处理方法void (*function)(unsigned long)
。
在操作系统中,信号的软件条件指的是由软件内部状态或特定软件操作触发的信号产生机制。这些条件包括但不限于:
alarm
函数设定的时间到达,触发 SIGALRM
信号。SIGPIPE
信号。当这些软件条件满足时,操作系统会向相关的进程发送相应的信号,以通知进程进行相应的处理。简单来说,软件条件是由操作系统内部或外部软件操作而触发的信号产生。
所以不论产生信号的原因是什么,所有的信号都是由操作系统进行发送,即便是用户主动发送,那也是使用的上层的接口,实际上还是操作系统来修改对应的比特位来实现信号的产生。
核心转储 (Core Dump) 是什么?
centos7: core.pid
(pid 是进程ID) 或 core.1234
,以及 ubuntu: core.XX
等形式。为什么会发生核心转储?
Floating point exception (core dumped)
就是一个典型的例子,程序因为浮点数运算错误收到了 SIGFPE 信号,进而触发了 Core Dump。Core Dump 的用途是什么?
gdb <executable_file> <core_file>
·如何控制 Core Dump 的生成?
**ulimit**
** 命令**:使用 ulimit -a
命令查看所有资源限制,其中 core file size
就是控制 Core Dump 文件大小的参数。 core file size (blocks, -c) 0
:表示不允许生成 Core Dump 文件(大小限制为 0)。这就是为什么很少见过coredump文件,因为默认可能是关闭的。特别在云服务器上会关闭,因为云服务器是生产环境,如果一个项目在运行的实际一直生成Core Dump文件,可能会造成磁盘爆满。ulimit -c unlimited
:这个命令可以解除大小限制,允许生成 Core Dump 文件。先查看限制为 0,然后使用 ulimit -c unlimited
打开限制,再次查看就变成了 unlimited
。ulimit -c unlimited
(或设置一个足够大的值) 开启 Core Dump 功能。总结来说,Core Dump 是操作系统在程序异常崩溃时记录其内存状态的一种机制,主要目的是为了方便开发者进行事后调试,找出程序崩溃的原因。它的生成受到系统资源限制的控制,可以使用 ulimit
命令来管理。
在进程PCB中有三个表:
**block**
(阻塞):unsigned int block
位图,比特位位置表示第几个信号,内容表示是否阻塞。**pending**
(未决):unsigned int pending
位图,比特位表示第几个信号,内容表示是否收到该信号。**handler**
:**sighandler_t handler[31]**
**,**函数指针数组,下标索引对应信号编号,每个函数指针也就是之前所讲述的typedef void (*sighandler_t)(int)
,当信号递达时执行对应的下标的函数动作。例如提前阻塞SIGINT
,此时block
中SIGINT
对应的位置数值改为1
。然后发送SIGINT
信号,pending
中SIGINT
对应的位置数值改为1
,但是不会被递达,因为SIGINT
被阻塞!
再举例,可以提前将一个进程PCB中的handler
中各个信号对应的递达后操作函数的实现改变,然后当之后发送对应信号递达后就会执行提前设定好的信号。
所以,三个表互相独立,处理函数可以提前设定,并且也说明了为什么信号不会立即触发。
内核代码:
// 内核结构 2.6.18
struct task_struct {
// 其他进程控制块字段
// 信号处理相关字段
struct sighand_struct *sighand; // 指向信号处理结构的指针
sigset_t blocked; // 被阻塞的信号集
struct sigpending pending; // 未决信号集
// 其他进程控制块字段
};
// 信号处理结构体
struct sighand_struct {
atomic_t count; // 原子计数器,用于同步访问
struct k_sigaction action[_NSIG]; // 信号处理动作数组,_NSIG 定义了信号的数量
spinlock_t siglock; // 自旋锁,用于保护信号处理结构的一致性
};
// 新的信号处理动作结构体
struct __new_sigaction {
__sighandler_t sa_handler; // 用户定义的信号处理函数
unsigned long sa_flags; // 信号处理标志
void (*sa_restorer)(void); // 用于恢复信号处理状态的回调函数,Linux/SPARC未使用
__new_sigset_t sa_mask; // 信号集掩码,用于屏蔽不需要处理的信号
};
// 内核信号动作结构体
struct k_sigaction {
struct __new_sigaction sa; // 新的信号处理动作结构
void __user *ka_restorer; // 用户定义的恢复函数指针
};
// 信号处理函数类型定义
typedef void (*__sighandler_t)(int); // 定义信号处理函数的类型
如果在进程接触对某个信号的阻塞之前这个信号产生过很多次,那该如何处理?
之后的关于存储的知识点将以这三张表为基础。
sigset_t
想象你有一排开关,每个开关可以处于两种状态:开(ON)或关(OFF)。我们可以用数字 1 来表示开,用 0 来表示关。
位图(或者更准确地说是位掩码,Bitmask)就是这样一种思想的延伸。它使用一个或多个连续的比特位(0 或 1)来表示一组离散的项或者状态。
char
, int
, long
等)。举个简单的例子:
假设我们有 8 个不同的权限,我们可以用一个 8 位的二进制数(例如一个 unsigned char
)来表示哪些权限被启用。
比特位位置(从右往左,从 0 开始) | 代表的权限 |
---|---|
0 | 读权限 |
1 | 写权限 |
2 | 执行权限 |
3 | 删除权限 |
4 | 修改权限 |
5 | 查看权限 |
6 | 管理员权限 |
7 | 特殊权限 |
那么,如果一个字节的值是 00000110
(二进制),它就表示写权限(第 1 位为 1)和执行权限(第 2 位为 1)被启用,而其他权限没有启用。
位图的优点:
sigset_t
与位图现在,我们将上面讲解的位图概念应用到 sigset_t
上。
在 POSIX 系统中,存在着多种不同的信号(例如 SIGINT
、SIGTERM
、SIGKILL
等),每种信号都有一个唯一的整数编号。sigset_t
的作用就是用来表示一组信号的集合。
sigset_t
** 本质上就是一个位图。** 操作系统使用 sigset_t
类型的变量来跟踪哪些信号需要被阻塞(暂时忽略)或等待。
sigset_t
内部使用一个或多个整数来存储比特位。每一个比特位的位置都对应着一个特定的信号编号。sigset_t
中的某个比特位被设置为 1,则表示该比特位对应的信号是这个信号集的成员;如果该比特位是 0,则表示该信号不在这个集合中。可能的 **sigset_t**
的实现方式,使用一个名为 bitmap
的整数数组:
struct bits
{
int bitmap[10]; // 32 * 10 = 320
};
现在,让我们来看一下信号编号是如何映射到这个 bitmap
数组中的特定比特位的:
int index = 39 / 32 = 1 --- bitmap[1]
int pos = 39 % 32 = 7 --- bitmap[1]第7个比特位
假设我们要表示信号编号为 39 的信号是否在信号集中:
**index**
): 信号编号除以每个整数所占的比特数(这里是 32)。结果的整数部分就是该信号对应的 bitmap
数组的索引。 39 / 32 = 1
。这表示信号 39 的信息存储在 bitmap
数组的第二个元素中(索引为 1,因为数组索引从 0 开始)。**pos**
): 信号编号对每个整数所占的比特数取模。结果就是该信号在对应整数中的比特位偏移量。 39 % 32 = 7
。这表示信号 39 对应于 bitmap[1]
这个整数的第 7 个比特位(通常从 0 开始计数)。可以将 bitmap
数组想象成一个连续的比特序列。bitmap[0]
存储着信号 0 到 31 的状态(第 0 到 31 位),bitmap[1]
存储着信号 32 到 63 的状态(第 32 到 63 位),以此类推,直到 bitmap[9]
存储着信号 288 到 319 的状态。
当我们需要判断某个信号(比如信号 39)是否在信号集中时,我们会:
因此,sigset_t
** 就像一个“信号开关盒”,每个开关(比特位)对应一个特定的信号。当我们需要阻塞某些信号或者等待某些信号时,我们就可以通过操作这个“开关盒”中相应的开关来实现。**
操作系统提供的 sigemptyset()
, sigfillset()
, sigaddset()
, sigdelset()
, sigismember()
等函数,实际上就是在操作 sigset_t
内部的这些比特位,将上述的三个表进行管理和使用,从而方便我们管理信号集合,而无需直接关心底层的位操作细节。
核心概念:sigset_t
sigset_t
类型用于表示一组信号的集合。sigset_t
内部使用一个比特位来表示该信号的“有效”(在集合中)或“无效”(不在集合中)状态。sigset_t
类型的内部存储结构依赖于系统实现,使用者无需关心。sigset_t
变量,不应该直接对其内部数据做任何解释或操作(例如,直接使用 printf
打印 sigset_t
变量是无意义的)。头文件:
#include <signal.h>
信号集操作函数:
int sigemptyset(sigset_t *set);
set
所指向的信号集,将其中所有信号对应的比特位清零,表示该信号集不包含任何有效信号(空集)。int sigfillset(sigset_t *set);
set
所指向的信号集,将其中所有信号对应的比特位置位,表示该信号集的有效信号包括系统支持的所有信号。int sigaddset(sigset_t *set, int signo);
signo
添加到 set
所指向的信号集中,即将对应信号的比特位置位。int sigdelset(sigset_t *set, int signo);
set
所指向的信号集中移除指定的信号 signo
,即将对应信号的比特位清零。int sigismember(const sigset_t *set, int signo);
signo
是否是 set
所指向的信号集的成员,即检查对应信号的比特位是否被置位。重要注意事项:
sigset_t
类型的变量之前,必须调用 sigemptyset
或 sigfillset
进行初始化,以确保信号集处于确定的状态。sigaddset
和 sigdelset
函数在信号集中添加或删除特定的信号。进程信号屏蔽字操作函数:
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
how
: 指示如何更改信号屏蔽字。set
: 指向包含新信号屏蔽字的 sigset_t
变量的指针。如果为 NULL
,则不更改信号屏蔽字。oset
: 指向用于存储原始信号屏蔽字的 sigset_t
变量的指针。如果为 NULL
,则不保存原始信号屏蔽字。how
的可选值(根据常见用法): SIG_BLOCK
: 将 set
中指定的信号添加到当前信号屏蔽字中(相当于执行 mask = mask | set
)。SIG_UNBLOCK
: 将 set
中指定的信号从当前信号屏蔽字中移除(相当于执行 mask = mask & ~set
)。SIG_SETMASK
: 将当前信号屏蔽字设置为 set
所指向的信号集(相当于执行 mask = set
)。oset
是非空指针,则将进程当前的信号屏蔽字通过 oset
参数传出。set
是非空指针 ,则根据 how
参数和 set
的内容更改进程的信号屏蔽字。oset
和 set
都是非空指针,则先将原来的信号屏蔽字备份到 oset
中,然后根据 set
和 how
参数更改信号屏蔽字。sigprocmask
解除了对当前若干个未决信号的阻塞,则在 sigprocmask
返回前,至少会将其中一个信号递达给进程。获取未决信号集函数:
int sigpending(sigset_t *set);
- **功能:** 读取当前进程的未决信号集,并通过 `set` 参数传出。未决信号是指已经产生但由于进程信号屏蔽字的设置而被阻塞而尚未递达的信号。
- **返回值:** 调用成功则返回 0,出错则返回 -1。
综合代码示例:
#include <stdio.h>
#include <signal.h> // 包含信号处理相关的函数和数据结构,如 signal, sigset_t, sigemptyset, sigaddset, sigprocmask, sigpending, sigismember, SIGINT
#include <iostream> // 包含输入输出流库,用于 std::cout 和 std::endl
#include <unistd.h> // 包含 UNIX 标准函数库,用于 getpid 和 sleep 函数
// 函数用于打印当前进程的挂起信号集
void PrintPending(sigset_t &pending)
{
printf("我是一个进程(%d), pending: ", getpid()); // 打印当前进程的 ID
for (int signo = 31; signo >= 1; signo--) // 遍历所有可能的信号编号(从 31 到 1)
{
if (sigismember(&pending, signo)) // 检查信号 'signo' 是否是挂起信号集中的成员
{
std::cout << "1"; // 如果是,打印 '1'
}
else
{
std::cout << "0"; // 如果不是,打印 '0'
}
}
std::cout << std::endl; // 打印换行符
}
// 信号处理函数,用于处理 SIGINT 信号 (Ctrl+C)
void handler(int sig)
{
std::cout << "#######################" << std::endl;
std::cout << "递达" << sig << "信号!" << std::endl; // 表明信号 'sig' 已经被接收(递达)
sigset_t pending; // 声明一个信号集,用于存储挂起信号
int m = sigpending(&pending); // 获取当前进程的挂起信号集
(void)m; // 将 m 强制转换为 void 类型,以消除可能出现的未使用变量警告
PrintPending(pending); // 打印挂起信号集
// 下面的注释解释了在此处观察到的输出:
// 0000 0010 (如果信号被阻塞,并且刚刚被递达,它可能在被清除之前短暂地出现在挂起状态中)
// 0000 0000 (如果信号被阻塞,然后解除阻塞并递达,在处理函数运行时,该信号的挂起状态可能已经被清除)
std::cout << "#######################" << std::endl;
}
int main()
{
signal(SIGINT, handler); // 注册 'handler' 函数,使其在接收到 SIGINT 信号时被调用
// 1. 屏蔽 SIGINT 信号
sigset_t block, oblock; // 'block' 用于存储需要屏蔽的信号集,'oblock' 用于存储原始的信号掩码,用于后续恢复
sigemptyset(&block); // 将 'block' 初始化为空集
sigemptyset(&oblock); // 将 'oblock' 初始化为空集
sigaddset(&block, SIGINT); // 将 SIGINT 信号(信号编号为 2)添加到 'block' 集合中。
// 这意味着我们打算屏蔽这个信号。
// 下面被注释掉的循环会屏蔽所有编号从 1 到 31 的信号。
// for(int i = 1; i<32; i++)
// sigaddset(&block, i);
int n = sigprocmask(SIG_SETMASK, &block, &oblock); // 设置进程的信号掩码为 'block'。
// SIG_SETMASK 表示 'block' 中的信号将被屏蔽。
// 'oblock' 将存储之前'block'的信号掩码。
(void)n; // 将 n 强制转换为 void 类型,以消除可能出现的未使用变量警告
// 4. 重复获取并打印挂起信号集
int cnt = 0;
while (true)
{
// 2. 获取挂起信号集
sigset_t pending; // 声明一个信号集,用于存储挂起信号
int m = sigpending(&pending); // 获取当前进程的挂起信号集
(void)m; // 将 m 强制转换为 void 类型,以消除可能出现的未使用变量警告
// 3. 打印挂起信号
PrintPending(pending);
if (cnt == 10)
{
// 5. 恢复原始的信号掩码,有效地解除对 SIGINT 的屏蔽
std::cout << "解除对2号的屏蔽" << std::endl;
sigprocmask(SIG_SETMASK, &oblock, nullptr); // 将进程的信号掩码恢复为存储在 'oblock' 中的原始掩码。
// 这将允许在 SIGINT 信号处于挂起状态时将其递达。
}
sleep(1); // 暂停执行 1 秒
cnt++; // 增加计数器
}
return 0;
}
当运行程序,先屏蔽SIGINT
信号,然后开始打印,在打印时如果向进程发送SIGINT
信号,会在打印的时候显示在pending
中的修改结果,但不会直接递达,因为SIGINT
被屏蔽了。当打印结束后,将屏蔽的SIGINT
取消屏蔽,这样就会递达,触发自定义的信号处理函数handler
。
一直灌输的是,信号的处理不是立即处理,而是在合适的时候进行处理。 那么这个合适的时候是什么时候? 信号递达方式有三种,每一种在处理的时候具体是怎样的?
当程序代码中遇到中断、异常、系统调用的时候就会进入内核,进入内核后将异常处理完成后会先将当前进程中可以递达的信号给处理,然后返回用户态,然后执行程序的运行会在用户态和内核态之间切换。
用户态 (User Mode):
main
函数中的指令,处于用户态。在执行过程中,由于发生了外部中断(例如硬件设备发出的信号)、程序内部的异常(例如除零错误)或者程序主动发起的系统调用(例如读写文件),程序的执行会从用户态切换到内核态,以便操作系统进行相应的处理。内核态 (Kernel Mode):
**do_signal()**
: 这是一个内核函数,负责实际的信号递达过程,通过查看pending
表来判断应该处理哪些信号。以下操作都是由它完成: do_signal()
会直接丢弃或忽略这个信号 。do_signal()
(或内核中负责信号处理的相应部分)会执行与该特定信号关联的默认行为。signal
或 sigaction
系统调用),那么内核会暂时中断进程的主控制流程,并将控制权转移到用户态去执行这个信号处理函数。注意,此时程序的执行流并没有直接返回到 **main**
函数中被中断的地方。用户态 (User Mode - 信号处理函数):
sigreturn
来通知内核信号处理已经完成。这个系统调用会再次将程序的执行切换到内核态。要注意:
内核态 (Kernel Mode - 返回信号处理):
sigreturn
系统调用的内核实现。sigreturn
系统调用会告诉内核恢复进程在接收到信号之前被中断时的状态(包括程序计数器等),然后内核会将控制权返回到用户态,进程的主控制流程会从之前被中断的那条指令之后继续执行。总结:
如果该程序没有触发进入内核的指令,要如何处理信号?
2、 信号处理的整个过程可以概括为:
sigreturn
系统调用返回内核态。3、 下图可以很形象的描述该过程在用户态和内核态之间切换:
**四个红圈:**在用户态和内核态之间切换的时机。
**中间的交点:**检查**pending**
表的时机,
问题一:
操作系统无论在怎么切换进程,都能找到同一个操作系统。不管在哪个进程中使用系统调用等涉及操作系统的操作时都可以找到对应的操作系统内核程序代码。
实际情况是:所有的用户页表中映射的关于进程地址空间中的[3, 4GB]的内核区的地址都是同一个物理地址,也就是每个进程的进程地址空间中的内核区都是同一块内核区。
**结论:**无论进程进程如何调度,我们总能找到操作系统!
问题二:
用户和内核都在同一个[0, 4GB]的进程地址空间上,如果用户随便那一个地址恰好是[3, 4GB]内核区的地址,那么用户岂不是可以随便访问内核中的代码和数据了吗?
操作系统有自己的保护机制,不会相信任何用户,用户必须使用系统调用的方式访问!
用户态:以用户身份,只能访问自己的[0, 3GB] 内核态:以内核的身份,运行用户通过系统调用的方式,访问[3, 4GB]
操作系统也是软件,一定也存在内存,现在对上文说所有地址空间中的内核区只有一个的说法进行完善。
有这全局段描述符表和局部段描述符表:
为什么要分全局/局部?
所以所以!
内核页表作为全局段描述符表,只存在一份;而用户页表作为局部段描述符表,可以存在多份,每个进程持有一份。
问题三:
在操作系统中,用户和操作系统怎么知道当前处于内核态还是用户态?
在操作系统中,用户态和内核态的切换是通过 CPU 的特权级(CPL, Current Privilege Level) 来管理的。
cs
(代码段寄存器) cs
寄存器用于存储当前执行的代码段的选择子(segment selector)。它的低两位决定了当前的 特权级(CPL)。在操作系统中,CPL 是一个重要的概念,用来区分当前执行的是用户态代码还是内核态代码: int 0x80
),并通过这个中断请求操作系统的帮助。cs
** 寄存器的低两位(CPL)** 来判断当前是内核态还是用户态。如果 CPL
为 0,操作系统知道当前处于内核态;如果 CPL
为 3,则说明当前处于用户态。int 0x80
)检查和控制该切换,确保用户程序不能直接访问内核空间,从而实现安全的进程隔离。简而言之,操作系统和用户程序通过 CPL 来区分当前是否在内核态或用户态,0是内核态,3是用户态,并通过中断(如 int 0x80
)来进行状态切换。
<font style="color:rgb(31,35,41);">sa_flags</font>
字段包含⼀些选项,本章的代码都把<font style="color:rgb(31,35,41);">sa_flags</font>
设为0,<font style="color:rgb(31,35,41);">sa_sigaction</font>
是实时信号的处理函数,本章请根据需要自行跳过这两个字段,有兴趣的同学可以了解⼀下。
sigaction
是 POSIX 标准定义的一个系统调用(及其库函数封装),用于检视或修改某个信号的处理动作。相比老式的 signal
函数,它更加灵活、安全,支持:
#include <signal.h>
int sigaction(int signo,
const struct sigaction *act,
struct sigaction *oact);
signo
要操作的信号编号(如 SIGINT
、SIGTERM
、SIGUSR1
、实时信号 SIGRTMIN+n
等)。act
(可选)指向新的动作描述结构;若为 NULL
,表示不修改,纯粹查询当前设置。oact
(可选)若非 NULL
,函数会写回调用前该信号的旧动作设置,方便后续恢复。调用成功返回 0
,出错返回 -1
并设置 errno
(例如 EINVAL
、EFAULT
、EDEADLK
等)。
struct sigaction
详解struct sigaction {
union {
void (*sa_handler)(int); /* 传统信号处理函数 */
void (*sa_sigaction)(int, /* 扩展的信号处理函数 */
siginfo_t *,
void *);
} __sigaction_handler;
sigset_t sa_mask; /* 在处理本信号期间额外屏蔽的信号集 */
int sa_flags; /* 行为选项(SA_*) */
void (*sa_restorer)(void); /* 仅内核使用,对用户不可见 */
};
#define sa_handler __sigaction_handler.sa_handler
#define sa_sigaction __sigaction_handler.sa_sigaction
sa_handler
** vs **sa_sigaction
sa_flags
中不包含 SA_SIGINFO
,则使用 sa_handler(int signo)
,这是最常见的简单形式。SA_SIGINFO
,则使用 sa_sigaction(int signo, siginfo_t *info, void *context)
,可获取发送者 PID、UID、信号代码、附带值等额外信息。sa_mask
表示在执行信号处理函数期间,除了内核自动屏蔽的该信号本身之外,还要额外屏蔽哪些信号。通常用:sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, SIGUSR2); /* 处理 SIGUSR1 时,屏蔽 SIGUSR2 */
sa_flags
常用选项有: SA_RESTART
使某些被信号打断的系统调用自动重启(比如 read
, write
),避免返回 EINTR
。SA_NODEFER
默认情况下,在正在处理的信号,其自身会被自动屏蔽直到 handler 返回;加上此标志后,可在 handler 内再次接收同一信号。SA_RESETHAND
处理一次后,自动将该信号的动作用重置为默认(相当于一次性 handler)。SA_NOCLDSTOP
如果对 SIGCHLD
设此标志,则子进程停止/继续时不会发送 SIGCHLD
给父进程。SA_NOCLDWAIT
对 SIGCHLD
生效,自动回收子进程,父进程不会因其僵尸化而产生信号或僵尸进程。SA_SIGINFO
启用 sa_sigaction
接口,获得更丰富的 siginfo_t
信息。sa_restorer
供内核架构相关地恢复现场用,用户无需设置或调用。下面是一个典型的例子:捕获 SIGINT
(Ctrl+C),打印一条消息,然后优雅退出;并且在处理该信号时屏蔽 SIGTERM
:
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
void handle_sigint(int signo) {
printf("Caught SIGINT, exiting gracefully...\n");
exit(0);
}
int main(void) {
struct sigaction act, old;
/* 1. 设置 sa_handler */
act.sa_handler = handle_sigint;
/* 2. 清空 sa_mask 增加要屏蔽的信号 */
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, SIGTERM);
/* 3. 设置行为选项 */
act.sa_flags = SA_RESTART; /* 被打断的系统调用自动重启 */
/* 安装新的信号处理动作,并保存旧动作 */
if (sigaction(SIGINT, &act, &old) < 0) {
perror("sigaction");
return 1;
}
/* 模拟长时间运行 */
for (;;) {
printf("Working...\n");
sleep(1);
}
return 0;
}
handle_sigint
。sigaddset(SIGTERM)
,此时 SIGTERM 也会被屏蔽。sleep
)若被打断将自动重启(因 SA_RESTART
)。sigaction(signo, NULL, &old)
sigaction(signo, &act, &old)
sigaction(signo, &act, NULL)
sigaction(signo, NULL, &old)
字段 | 作用 |
---|---|
sa_handler | 传统回调 void handler(int) |
sa_sigaction | 扩展回调 void handler(int, siginfo_t*,void*) |
sa_mask | 处理期间额外屏蔽的信号集 |
sa_flags | 行为控制(如 SA_RESTART、SA_SIGINFO等) |
oact | 输出参数,用于存放老的动作设置 |
这样你就可以灵活、可靠地控制信号处理的各种细节,避免信号到来时状态不一致或系统调用被意外中断等问题。
“可重入”(reentrant)函数,指的是在任意时刻,函数被打断(比如被信号处理程序、中断或另一个线程)后再次进入调用,都不会因共享数据竞态而出问题。反之,如果函数在执行过程中修改了某些全局或静态数据,再次重入时就可能破坏这些数据,就叫不可重入。
下面结合图示,逐步说明为什么这个 insert
函数 不可重入,以及“可重入”函数究竟是怎样的。
insert
的两步插入操作void insert(node_t *p) {
// Step 1: p->next = head;
p->next = head;
// ……(假设在此可能被打断)
// Step 2: head = p;
head = p;
}
p
的 next
指向当前的 head
head
更新成 p
正常调用时,这两步连贯执行,就能把 p
插到链表头部。
head → […] (head 指向旧链表)
node1, node2 都尚未链接
insert(&node1)
并做完第 1 步(图示 1)node1.next = head;
head ——┐
↓
[…旧链表…]
但还没执行 head = node1
,函数即将完成前被硬件中断打断。
sighandler
信号处理函数里也调用 insert(&node2)
,进入同一个 insert
函数。sighandler
** 的 insert(&node2)
执行完两步**(图示 2→3) node2.next = head;
这里的 head
还是原来的旧链表指针。head = node2;
于是链表头变成 node2
,且 node2.next
指向旧链表。main
的 **insert(&node1)
(图示 4)
恢复到未完成第 2 步的状态,继续执行:head = node1;
这一步把 head
又改成 node1
,覆盖掉了 sighandler
插入的 node2
,最终链表只剩 node1
。
insert
不是可重入的?head
p->next
已改,head
还没改)。一个函数若要称为可重入,通常要满足:
malloc
/free
(内部用全局结构管理堆),printf
/fopen
(标准 I/O 常用全局缓冲区)。举例,一个可重入版的插入函数可以改成:
node_t *insert_reentrant(node_t *head, node_t *p) {
// 只改 p->next,不改全局 head
p->next = head;
// 返回新的 head,由调用者赋值
return p;
}
/* main 或 sighandler 里:
head = insert_reentrant(head, &node1);
*/
这样,每次调用都只是操作局部的 head
副本,最后再赋值,相当于让“写全局”在外层集中完成,避免在中间被打断造成半成品状态。
在信号(异步事件)处理的场景下,volatile
关键字的作用可以简单归纳为——告诉编译器:这个变量随时可能在“外部”被改变,千万不要对它做“缓存”或“优化”。
volatile
while (!flag);
在开启 -O2
等较高级别优化时,编译器会觉得:
结果,程序一进入循环就再也不会去内存读 flag
,即使后续信号处理函数里真的把内存中的 flag
改成了 1
,循环也看不到这个变化——变成了“死循环”。
- “`flag` 在这个函数里没其他地方写,只读且永远不变”
- “那我把它从内存中加载一次,存在寄存器里,后面都用寄存器的拷贝就好了”
handler
函数,执行:flag = 1;
但如果主循环里对 flag
做了寄存器缓存,就永远读不到这次修改。
volatile
的作用volatile int flag = 0;
flag
的读写操作时,编译器都会强制生成对应的内存访问指令,绝不“偷懒”用寄存器拷贝。while (!flag);
会在每次迭代都去内存重新加载 flag
,才能及时观察到信号处理函数里赋 flag = 1
的结果,从而跳出循环。
#include <stdio.h>
#include <signal.h>
volatile int flag = 0; // 保证内存可见性
void handler(int sig) {
printf("change flag 0 to 1\n");
flag = 1; // 异步写内存
}
int main() {
signal(SIGINT, handler);
while (!flag) // 每次都去内存读 flag
; // 不会被寄存器“钉死”
printf("process quit normal\n");
return 0;
}
gcc -O2 -o sig sig.c
./sig
^Cchange flag 0 to 1
process quit normal
volatile
** 只保证对变量访问不被优化**,并不提供原子性,也不保护多线程间的内存可见性(在多线程场景下一般需要使用 C11 原子类型或锁)。printf
、malloc
等非可重入接口,否则可能引起死锁或未定义行为。volatile
已足够;更复杂的跨线程/跨 CPU 缓存同步,需要更高级的内存屏障或原子操作。在 UNIX/Linux 下,每当子进程终止(正常 exit、被信号杀掉,或者被 stop/continue)时,内核都会给它的父进程发一个 SIGCHLD 信号。默认情况下,SIGCHLD 信号的动作是忽略(SIG_IGN),也就是父进程不会因此中断,也不会自动去清理子进程的僵尸状态——子进程变成僵尸后,父进程必须调用 wait 或 waitpid 来回收它,否则就留在进程表里。
signal(SIGCHLD, handler)
(或者更安全的 sigaction
)安装一个处理函数 handler
,当收到 SIGCHLD 时被调用。handler
里循环调用while ((pid = waitpid(-1, NULL, WNOHANG)) > 0) {
printf("reaped child %d\n", pid);
}
这样既不会阻塞父进程,又能及时回收所有已经终止的子进程,避免僵尸进程累积。
handler
会被异步触发去收尸。在父进程里直接写:
signal(SIGCHLD, SIG_IGN);
或者用 sigaction
:
struct sigaction sa = { .sa_handler = SIG_IGN };
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGCHLD, &sa, NULL);
此时,子进程退出时,内核会自动替父进程调用 wait,把子进程的资源立即回收掉——父进程既不收不到信号,也不会产生僵尸进程。但要注意,这种“自动清理”只是 Linux 上的特殊行为,不一定在所有 UNIX 平台都可用。
这样,父进程既能专心做自己的事,又不用担心僵尸进程的问题。