038 进程信号 —— 信号的处理

进程信号 —— 信号的处理

Linux 进程信号【信号处理】 | CSDN

1. 捕捉/处理信号(进程地址空间)

1. 内核空间与用户空间

每一个进程都有自己的进程地址空间,该进程地址空间由内核空间和用户空间组成:

  • 用户所写的代码和数据位于用户空间,通过用户级页表与物理内存之间建立映射关系。
  • 内核空间存储的实际上是操作系统代码和数据,通过内核级页表与物理内存之间建立映射关系。

内核级页表是一个全局的页表,它用来维护操作系统的代码与进程之间的关系。因此,在每个进程的进程地址空间中,用户空间是属于当前进程的,每个进程看到的代码和数据是完全不同的,但内核空间所存放的都是操作系统的代码和数据,所有进程看到的都是一样的内容。

image-20250717230508790

  • 操作系统本质上就是一个“基于时钟中断的死循环” ,它通过中断机制不断调度任务,实现多任务并发的“假象”。 OS 本身并没有“结束”的时候,它一直在“看有没有事要做”。 它开机后就进入一个大循环:看有没有进程要运行(调度)、看有没有中断发生、处理完后继续循环,这个循环不会退出,除非关机。
  • 时钟中断就像是操作系统的心跳,每隔一段时间就“敲一下操作系统”:“该换人干活了!” 计算机硬件中有一个 时钟芯片,它每隔一定时间(微秒级)发出一个 时钟中断,OS 利用这个中断来做 时间片调度

此外我们也会发现一个很有意思的事情:无论是台式机/笔记本,在开机后,无论联网与否,时间总是准的,原因是计算机主板中存在很小的纽扣电池,它负责在电脑断电时维持实时时钟(RTC)的运行,从而保证时间不会丢失,当机器断电数月后才可能开机后时间不准。


举个生活例子:餐厅的叫号系统

你去餐厅吃饭,点完菜后坐等。

  • 时钟中断 :就像服务员每隔一段时间查看叫号系统。
  • 进程调度 :服务员看到你号码到了,叫你去取餐。
  • 死循环 :服务员一直在柜台后面转悠,不停地看有没有新号码要处理。

操作系统就像这个服务员,一直在“看有没有事做”。

2. 什么是用户态和内核态?

状态 特征 权限等级 能做什么
用户态 执行用户代码,如普通应用程序 只能访问用户空间,不能访问硬件
内核态 执行操作系统核心代码 可以访问内核空间、控制硬件、管理内存、调度任务等

用户态不能直接访问内核态的数据,否则整个系统会变得非常不安全!

用户态和内核态的切换是由 CPU 的特权级机制控制的,CS 寄存器的 RPL 字段决定当前执行代码的权限级别,int 0x80 是一种触发系统调用、切换到内核态的方式,CR3 寄存器用于管理进程的虚拟地址空间,通常在进程切换时变化,但在用户态 ↔ 内核态切换时不变化。

int 0x80 定义
int 0x80
探索 Linux 系统调用机制的演变:int 0x80syscall

特权级、ecs、CR3 寄存器这些内容不做重点,点到为止了,个人能力有限,讲不太明白 🥹。

3. 什么是进程切换?

【操作系统】进程切换到底是怎么个过程?| CSDN
进程切换原理 | 博客园
深入理解进程切换 | CSDN

“进程切换”是指 CPU 当前执行的进程被挂起,另一个进程被调度到 CPU 上运行,这是一种 上下文切换所以:进程切换 = 保存旧进程的状态 + 加载新进程的状态 + CPU 执行新进程的代码。 进程切换不仅是操作系统内部实现并发的核心机制,也是作为系统程序员写代码时需要“尽量避免过度调度”的重要优化点。

  1. 保存当前进程的状态(上下文)

操作系统会把当前正在运行的进程的“执行状态”保存下来,比如:

  • 程序计数器(PC):下一条要执行哪条指令
  • 寄存器的值:临时数据
  • 栈指针、堆栈信息:函数调用现场

这些信息保存在 进程控制块(PCB) 中。

  1. 选择下一个要运行的进程(调度): 操作系统通过调度器(scheduler)决定下一个该谁运行。

  2. 加载下一个进程的状态

操作系统从下一个进程的 PCB 中加载它的上下文,包括:

  • 程序计数器(PC)
  • 寄存器内容
  • 栈信息
  1. 继续执行下一个进程: CPU 开始执行下一个进程的代码。

注意:用户态 ↔ 内核态切换 ≠ 进程切换

类型 是否等同切换 是否改变进程 是否保存上下文 触发示例
用户态 ↔ 内核态 ❌ 不是进程切换 ❌ 不换进程 ❌ 不换上下文 系统调用、中断、异常
进程 A ↔ 进程 B ✅ 是进程切换 ✅ 换进程 ✅ 保存/恢复上下文 时间片用完、I/O 阻塞、调度触发
  • 用户态 ↔ 内核态: 你是一个顾客,在银行大厅(用户态)填单子,然后去柜台找工作人员帮你处理转账(进入内核态),处理完你回到大厅继续自己的事。你没换人,只是权限变了。
  • 进程 A ↔ 进程 B 切换: 你正在银行柜台办事,还没办完,系统叫下一个客户了。工作人员保存你当前的状态(办到哪一步),切换到下一个客户。你和别人换了,这才是真正的“切换”
  • 用户态 ↔ 内核态”切换只是权限切换,不换进程;而“进程切换”才是真正换人干活,需要保存和恢复上下文。

按 Ctrl+C 会不会进程切换?

不一定!

  • 按 Ctrl+C 会触发 中断(来自键盘设备)→ 导致进程进入内核态。
  • 由内核触发信号发送,进而 递达信号,调用 handler(仍在当前进程内)。

所以:不会进程切换,只是状态切换为内核态,然后再返回用户态。 但如果按 Ctrl+C 后进程终止,那么调度器会调度其他进程,才会产生真正的进程切换

4. 内核如何捕捉信号?

当进程从内核态准备返回用户态时,内核会检查是否有未决(pending)且未被阻塞的信号。如果信号的处理动作是默认或忽略,内核直接执行对应动作并清除 pending 标志,然后返回用户态继续执行主流程;如果处理动作是用户自定义函数,内核会伪造一次函数调用,让用户态执行该 handler。执行完毕后,通过 sigreturn 系统调用重新进入内核态,清理 pending 标志并恢复进程上下文,最后再次返回用户态,继续执行主控制流程。信号的捕捉不是直接调用函数,而是由内核精心安排的一次“用户态函数调用 + 内核态恢复”的过程,确保安全、稳定、可控。

image-20250717233930425

image-20250717234912707

当识别到信号的处理动作是自定义时,能直接在内核态执行用户空间的代码吗?
虽然内核有权限直接执行用户代码,但为了安全和稳定,操作系统绝不允许在内核态直接执行用户定义的代码。用户代码必须在用户态下执行,权限受限,防止破坏系统。


2. sigaction

1. sigaction 函数原型

1. 功能

用于设置某个信号的处理方式(三种)。sigaction()signal() 的“升级版”,功能更强大、更安全、更可移植,推荐在实际开发中使用它来注册信号处理函数。

2. 函数原型

1
2
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

3. 参数详解

参数名 类型 含义
signum int 要设置的信号编号,如 SIGINT(2 号信号)、SIGTERM(15 号信号)等
act const struct sigaction * 新的信号处理方式(结构体指针),可设置 handler、flags、mask 等
oldact struct sigaction * 可选,用于保存旧的处理方式(可用于恢复、可为 NULL

其中 struct sigaction 结构体定义如下:

1
2
3
4
5
6
7
8
struct sigaction
{
void (*sa_handler)(int); // 简单的信号处理函数(类似 signal 的用法)
void (*sa_sigaction)(int, siginfo_t *, void *); // 带详细信息的信号处理函数(需要 SA_SIGINFO 标志)
sigset_t sa_mask; // 在信号处理函数执行期间额外屏蔽的信号集合
int sa_flags; // 标志位,控制信号行为(如 SA_RESTART、SA_SIGINFO 等)
void (*sa_restorer)(void); // 已废弃,忽略
};
1. sa_handler:最简单的处理函数

类似 signal() 的用法,接收一个整数参数(信号编号)。

1
2
3
4
5
6
7
void handler(int signo)
{
printf("收到信号:%d\n", signo);
}

struct sigaction act;
act.sa_handler = handler;
2. sa_sigaction:带详细信息的处理函数(需要配合 SA_SIGINFO 标志使用,了解)

可以获取信号的详细信息(发送者、原因等),接收三个参数:

  • 信号编号。
  • siginfo_t *:包含信号来源等信息。
  • void *:指向 ucontext_t 的指针(可获取寄存器等上下文信息)。
1
2
3
4
5
6
7
8
void sigaction_handler(int signo, siginfo_t *info, void *context)
{
printf("收到信号:%d,发送者PID:%d\n", signo, info->si_pid);
}

struct sigaction act;
act.sa_sigaction = sigaction_handler;
act.sa_flags = SA_SIGINFO;
3. sa_mask:信号处理期间屏蔽的其他信号集合

在执行信号处理函数时,可以屏蔽其他信号,防止多个信号处理函数嵌套执行。

1
2
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, SIGUSR1); // 在处理当前信号时,暂时屏蔽 SIGUSR1
4. sa_flags:控制信号行为的标志位
标志 含义
SA_RESTART 自动重启被中断的系统调用(如 read/write)
SA_SIGINFO 使用 sa_sigaction 而不是 sa_handler
SA_NODEFER 不自动屏蔽当前信号(默认会屏蔽)
SA_RESETHAND 处理完信号后重置为默认行为

4. 返回值

  • 成功返回 0
  • 失败返回 -1,并设置 errno

5. 代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <cstring>
using namespace std;

void handler(int signum)
{
cout << "收到信号: " << signum << endl;
}

int main()
{
struct sigaction act; // 用于设置新行为
memset(&act, 0, sizeof(act)); // 初始化为 0,避免随机值干扰
act.sa_handler = handler; // 设置信号处理函数
sigemptyset(&act.sa_mask); // 清空屏蔽字
act.sa_flags = 0; // 无特殊行为

if (sigaction(SIGINT, &act, nullptr) == -1) // 设置 SIGINT(如按 Ctrl+C) 的处理方式
{
perror("sigaction error");
return 1;
}

while (true) // 模拟服务一直运行
{
cout << "程序运行中...(按 Ctrl+C 测试信号)" << endl;
sleep(2);
}

return 0;
}

2. pending 什么时候从 1 -> 0?

pending 位在信号递达时被清 0,早于 handler 的执行,确保信号不会重复处理。 pending 位图就像一个“未读消息列表”。

  • 信号产生时,该信号在 pending 位图中被标记为 1(未读)。
  • 当信号递达时,内核会把该位清 0,表示“这个信号我已经处理了”。

实验证明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

void handler(int signo)
{
printf("进入 handler\n");
sleep(3); // 模拟处理过程
printf("退出 handler\n");
}

int main()
{
struct sigaction act; // 定义信号处理结构体
act.sa_handler = handler; // 设置信号处理函数
sigemptyset(&act.sa_mask); // 阻塞信号集为空
act.sa_flags = 0; // 信号处理标志位

sigaction(SIGINT, &act, NULL); // 注册信号处理函数

sigset_t set; // 定义信号集
sigemptyset(&set); // 信号集为空
sigaddset(&set, SIGINT); // 加入 SIGINT 信号
sigprocmask(SIG_BLOCK, &set, NULL); // 阻塞 SIGINT 信号

printf("请在 5 秒内按 Ctrl+C(SIGINT 会被 pending)...\n");
sleep(5); // 等待 5 秒

sigset_t pending; // 定义 pending 信号集
sigpending(&pending); // 获取 pending 信号集
if (sigismember(&pending, SIGINT))
{
printf("SIGINT 当前处于 pending 状态(位为 1)\n");
}

sigprocmask(SIG_UNBLOCK, &set, NULL); // 解除 SIGINT 信号的阻塞
pause(); // 等待信号处理完成

sigpending(&pending); // 获取 pending 信号集
if (!sigismember(&pending, SIGINT))
{
printf("SIGINT 的 pending 位已被清 0\n");
}

return 0;
}

运行结果示例:

1
2
3
4
5
6
7
8
9
10
[hcc@hcss-ecs-be68 Signal Processing]$ ./test1 
请在 5 秒内按 Ctrl+C(SIGINT 会被 pending)...
^C
SIGINT 当前处于 pending 状态(位为 1)
进入 handler
退出 handler
^C
进入 handler
退出 handler
SIGINT 的 pending 位已被清 0

信号的 pending 位在信号递达前被清 0,handler 执行期间该信号会被自动屏蔽,handler 执行完毕后恢复。如果在 handler 执行期间发送该信号,它会再次进入 pending,handler 会再次被调用。

3. 信号处理过程中,是否自动将该信号加入 blocked

在执行信号处理函数时,内核会自动将该信号加入 block 集合,防止递归调用;handler 执行完毕后,block 集合恢复原样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

void handler(int signo)
{
printf("进入 handler,此时 SIGINT 被屏蔽\n");

sigset_t blocked; // 保存当前信号集
sigprocmask(0, NULL, &blocked); // 获取当前信号集

if (sigismember(&blocked, SIGINT)) // 判断 SIGINT 是否被屏蔽
{
printf("SIGINT 当前被阻塞(屏蔽中)\n");
}
else
{
printf("SIGINT 未被阻塞\n");
}

sleep(5); // 模拟处理过程
printf("退出 handler\n");
}

int main()
{
struct sigaction act; // 信号处理结构体
act.sa_handler = handler; // 设置信号处理函数
sigemptyset(&act.sa_mask); // 初始化信号集
act.sa_flags = 0; // 信号处理标志

sigaction(SIGINT, &act, NULL); // 注册信号处理函数

printf("发送 SIGINT 将触发 handler\n");
while (1)
{
pause(); // 阻塞进程,等待信号
}

return 0;
}

运行结果示例:

1
2
3
4
5
6
7
8
9
[hcc@hcss-ecs-be68 Signal Processing]$ ./test2
发送 SIGINT 将触发 handler
^C进入 handler,此时 SIGINT 被屏蔽
SIGINT 当前被阻塞(屏蔽中)
退出 handler
^C进入 handler,此时 SIGINT 被屏蔽
SIGINT 当前被阻塞(屏蔽中)
退出 handler
……

在 handler 执行期间,SIGINT 被自动加入 block 集合(屏蔽),即使多次按 Ctrl+C,也不会再次进入 handler。


3. 可重入函数

可重入函数到底是什么? | stack overflow
c 语言学习 432 可重入函数 | B 站

可重入函数 是指:函数在被中断后,其上下文环境不被破坏,并且可以 安全地重新调用,不影响原始执行。我们可以把一个函数想象成一个 银行柜台

  • 不可重入函数 :只有一个柜员,只能服务一个客户。如果另一个客户来了,就会出错(比如数据混乱、死锁)。
  • 可重入函数 :每个客户都有自己的工位,互不干扰,可以同时处理。

信号处理函数中 只能用可重入函数,否则可能引发程序崩溃或行为不确定。非可重入函数,不可在 handler 中使用。 好在目前我们所学习到的函数都是可重入函数,简单注意一下即可。

4. volatile

volatile 是 C 语言的一个关键字,该关键字的作用是保持内存的可见性。作用是告诉编译器:“这个变量的值可能随时被修改,不要做优化!” 当变量可能被信号处理函数或中断修改时,必须使用 volatile 修饰,否则优化器可能让主线程永远看不到值的变化,导致逻辑失效。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <signal.h>
using namespace std;

int flag = 0;

void handler(int signo)
{
printf("收到信号:%d\n", signo);
flag = 1; // 设置flag为1,表示收到信号(// 信号处理函数中修改 flag 的值)
}
int main()
{
signal(SIGINT, handler); // 注册信号处理函数
while (!flag); // 等待信号处理函数执行完毕
printf("进程正常退出!\n");

return 0;
}

-O0, -O1, -O2, -O3 是控制 编译器优化强度 的开关,等级越高程序执行速度越快,但调试越困难、对写法越敏感(如 volatile 就必须加)。默认建议开发调试用 -O0,上线前用 -O2。在 man 手册中查看:man gcc → 按下 / 进入搜索模式,输入:-O → 然后按 n(next)多次查找。(优化选项(使用 GNU 编译器集合(GCC))

选项 含义(优化级别) 特点 / 行为
-O0 不做优化 编译快、便于调试、代码按字面意思执行
-O1 基本优化 小幅优化、不会改变代码结构
-O2 常规优化(推荐上线用) 去除冗余,常量折叠,循环展开,较稳定
-O3 激进优化(最大速度) 包括内联函数、多重循环展开,性能极高但可能更难调试
-Os 优化空间(减小生成文件大小) 类似 -O2,但更注重体积
-Ofast 忽略一些标准规范,疯狂优化(不推荐) 会跳过 IEEE/C99 规范,速度最快但可能不准确

运行结果示例:

image-20250719164021603

当我们将 int flag = 0; 改变成 volatile int flag = 0; 就会发现运行结果一致了:

image-20250719165429223

5. SIGCHLD 信号

SIGCHLD 是子进程退出时,自动发送给其父进程的信号(通知父进程:子进程挂了”的信号)。 也叫:子进程状态改变信号,通常用于通知父进程去回收子进程,防止僵尸进程出现!

1. 什么时候会触发 SIGCHLD

当子进程发生以下情况,内核就会给它的父进程发送 SIGCHLD 信号:

子进程行为 会触发 SIGCHLD 吗? 说明
正常退出(return/exit) 最常见用途
异常终止(段错误、除 0) 会触发
被信号杀死(如 SIGKILL) 依然会通知
被暂停/恢复执行 可配合 WUNTRACED 监控状态变化

2. 函数原型:signal / sigaction 监听 SIGCHLD

1
2
3
4
5
6
#include <signal.h>
#include <sys/wait.h>

// 可以用如下方式监听 SIGCHLD 信号:
signal(SIGCHLD, handler); // 简单方式
sigaction(SIGCHLD, &act, NULL); // 更稳定推荐方式

3. 示例代码:监听 SIGCHLD + 回收子进程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
using namespace std;

void handler(int signo)
{
// 回收所有退出的子进程,防止僵尸进程
while (waitpid(-1, NULL, WNOHANG) > 0)
{
printf("子进程已退出,回收完成\n");
}
}

int main()
{
signal(SIGCHLD, handler);

pid_t pid = fork();
if (pid == 0)
{
printf("子进程运行中,PID = %d\n", getpid());
sleep(2);
exit(0); // 正常退出,触发 SIGCHLD
}
else
{
while (1)
{
printf("父进程在运行中,PID = %d\n", getpid());
sleep(1);
}
}

return 0;
}

运行结果示例:

1
2
3
4
5
6
7
8
9
[hcc@hcss-ecs-be68 Signal Processing]$ ./SIGCHLD 
父进程在运行中,PID = 27055
子进程运行中,PID = 27056
父进程在运行中,PID = 27055
父进程在运行中,PID = 27055
子进程已退出,回收完成
父进程在运行中,PID = 27055
父进程在运行中,PID = 27055
^C

如果不处理 SIGCHLD 会怎样?

子进程退出,父进程没有调用 waitwaitpid,就会产生 僵尸进程,虽然不会占用内存,但会占用 PID 表项,多了会导致系统资源耗尽。

重点 说明
SIGCHLD 什么时候触发 子进程退出、异常终止、被信号杀死、状态改变
为啥要处理它? 防止僵尸进程! 并控制子进程生命周期
怎么处理? signalsigaction 监听 SIGCHLD,调用 waitpid
实战用途 写守护进程、多进程服务程序、提升系统稳定性