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

038 进程信号 —— 信号的处理
小米里的大麦进程信号 —— 信号的处理
1. 捕捉/处理信号(进程地址空间)
1. 内核空间与用户空间
每一个进程都有自己的进程地址空间,该进程地址空间由内核空间和用户空间组成:
- 用户所写的代码和数据位于用户空间,通过用户级页表与物理内存之间建立映射关系。
- 内核空间存储的实际上是操作系统代码和数据,通过内核级页表与物理内存之间建立映射关系。
内核级页表是一个全局的页表,它用来维护操作系统的代码与进程之间的关系。因此,在每个进程的进程地址空间中,用户空间是属于当前进程的,每个进程看到的代码和数据是完全不同的,但内核空间所存放的都是操作系统的代码和数据,所有进程看到的都是一样的内容。
- 操作系统本质上就是一个“基于时钟中断的死循环” ,它通过中断机制不断调度任务,实现多任务并发的“假象”。 OS 本身并没有“结束”的时候,它一直在“看有没有事要做”。 它开机后就进入一个大循环:看有没有进程要运行(调度)、看有没有中断发生、处理完后继续循环,这个循环不会退出,除非关机。
- 时钟中断就像是操作系统的心跳,每隔一段时间就“敲一下操作系统”:“该换人干活了!” 计算机硬件中有一个 时钟芯片,它每隔一定时间(微秒级)发出一个 时钟中断,OS 利用这个中断来做 时间片调度。
此外我们也会发现一个很有意思的事情:无论是台式机/笔记本,在开机后,无论联网与否,时间总是准的,原因是计算机主板中存在很小的纽扣电池,它负责在电脑断电时维持实时时钟(RTC)的运行,从而保证时间不会丢失,当机器断电数月后才可能开机后时间不准。
举个生活例子:餐厅的叫号系统
你去餐厅吃饭,点完菜后坐等。
- 时钟中断 :就像服务员每隔一段时间查看叫号系统。
- 进程调度 :服务员看到你号码到了,叫你去取餐。
- 死循环 :服务员一直在柜台后面转悠,不停地看有没有新号码要处理。
操作系统就像这个服务员,一直在“看有没有事做”。
2. 什么是用户态和内核态?
状态 | 特征 | 权限等级 | 能做什么 |
---|---|---|---|
用户态 | 执行用户代码,如普通应用程序 | 低 | 只能访问用户空间,不能访问硬件 |
内核态 | 执行操作系统核心代码 | 高 | 可以访问内核空间、控制硬件、管理内存、调度任务等 |
用户态不能直接访问内核态的数据,否则整个系统会变得非常不安全!
用户态和内核态的切换是由 CPU 的特权级机制控制的,CS 寄存器的 RPL 字段决定当前执行代码的权限级别,
int 0x80
是一种触发系统调用、切换到内核态的方式,CR3 寄存器用于管理进程的虚拟地址空间,通常在进程切换时变化,但在用户态 ↔ 内核态切换时不变化。int 0x80 定义
int 0x80
探索 Linux 系统调用机制的演变:int 0x80
到syscall
特权级、ecs、CR3 寄存器这些内容不做重点,点到为止了,个人能力有限,讲不太明白 🥹。
3. 什么是进程切换?
“进程切换”是指 CPU 当前执行的进程被挂起,另一个进程被调度到 CPU 上运行,这是一种 上下文切换。所以:进程切换 = 保存旧进程的状态 + 加载新进程的状态 + CPU 执行新进程的代码。 进程切换不仅是操作系统内部实现并发的核心机制,也是作为系统程序员写代码时需要“尽量避免过度调度”的重要优化点。
- 保存当前进程的状态(上下文)
操作系统会把当前正在运行的进程的“执行状态”保存下来,比如:
- 程序计数器(PC):下一条要执行哪条指令
- 寄存器的值:临时数据
- 栈指针、堆栈信息:函数调用现场
这些信息保存在 进程控制块(PCB) 中。
选择下一个要运行的进程(调度): 操作系统通过调度器(scheduler)决定下一个该谁运行。
加载下一个进程的状态
操作系统从下一个进程的 PCB 中加载它的上下文,包括:
- 程序计数器(PC)
- 寄存器内容
- 栈信息
- 继续执行下一个进程: CPU 开始执行下一个进程的代码。
注意:用户态 ↔ 内核态切换 ≠ 进程切换
类型 | 是否等同切换 | 是否改变进程 | 是否保存上下文 | 触发示例 |
---|---|---|---|---|
用户态 ↔ 内核态 | ❌ 不是进程切换 | ❌ 不换进程 | ❌ 不换上下文 | 系统调用、中断、异常 |
进程 A ↔ 进程 B | ✅ 是进程切换 | ✅ 换进程 | ✅ 保存/恢复上下文 | 时间片用完、I/O 阻塞、调度触发 |
- 用户态 ↔ 内核态: 你是一个顾客,在银行大厅(用户态)填单子,然后去柜台找工作人员帮你处理转账(进入内核态),处理完你回到大厅继续自己的事。你没换人,只是权限变了。
- 进程 A ↔ 进程 B 切换: 你正在银行柜台办事,还没办完,系统叫下一个客户了。工作人员保存你当前的状态(办到哪一步),切换到下一个客户。你和别人换了,这才是真正的“切换” 。
- 用户态 ↔ 内核态”切换只是权限切换,不换进程;而“进程切换”才是真正换人干活,需要保存和恢复上下文。
按 Ctrl+C 会不会进程切换?
不一定!
- 按 Ctrl+C 会触发 中断(来自键盘设备)→ 导致进程进入内核态。
- 由内核触发信号发送,进而 递达信号,调用 handler(仍在当前进程内)。
所以:不会进程切换,只是状态切换为内核态,然后再返回用户态。 但如果按 Ctrl+C 后进程终止,那么调度器会调度其他进程,才会产生真正的进程切换。
4. 内核如何捕捉信号?
当进程从内核态准备返回用户态时,内核会检查是否有未决(pending)且未被阻塞的信号。如果信号的处理动作是默认或忽略,内核直接执行对应动作并清除 pending 标志,然后返回用户态继续执行主流程;如果处理动作是用户自定义函数,内核会伪造一次函数调用,让用户态执行该 handler。执行完毕后,通过 sigreturn
系统调用重新进入内核态,清理 pending 标志并恢复进程上下文,最后再次返回用户态,继续执行主控制流程。信号的捕捉不是直接调用函数,而是由内核精心安排的一次“用户态函数调用 + 内核态恢复”的过程,确保安全、稳定、可控。
当识别到信号的处理动作是自定义时,能直接在内核态执行用户空间的代码吗?
虽然内核有权限直接执行用户代码,但为了安全和稳定,操作系统绝不允许在内核态直接执行用户定义的代码。用户代码必须在用户态下执行,权限受限,防止破坏系统。
2. sigaction
1. sigaction 函数原型
1. 功能
用于设置某个信号的处理方式(三种)。sigaction()
是 signal()
的“升级版”,功能更强大、更安全、更可移植,推荐在实际开发中使用它来注册信号处理函数。
2. 函数原型
1 |
|
3. 参数详解
参数名 | 类型 | 含义 |
---|---|---|
signum |
int |
要设置的信号编号,如 SIGINT (2 号信号)、SIGTERM (15 号信号)等 |
act |
const struct sigaction * |
新的信号处理方式(结构体指针),可设置 handler、flags、mask 等 |
oldact |
struct sigaction * |
可选,用于保存旧的处理方式(可用于恢复、可为 NULL ) |
其中 struct sigaction
结构体定义如下:
1 | struct sigaction |
1. sa_handler
:最简单的处理函数
类似 signal()
的用法,接收一个整数参数(信号编号)。
1 | void handler(int signo) |
2. sa_sigaction
:带详细信息的处理函数(需要配合 SA_SIGINFO
标志使用,了解)
可以获取信号的详细信息(发送者、原因等),接收三个参数:
- 信号编号。
siginfo_t *
:包含信号来源等信息。void *
:指向ucontext_t
的指针(可获取寄存器等上下文信息)。
1 | void sigaction_handler(int signo, siginfo_t *info, void *context) |
3. sa_mask
:信号处理期间屏蔽的其他信号集合
在执行信号处理函数时,可以屏蔽其他信号,防止多个信号处理函数嵌套执行。
1 | sigemptyset(&act.sa_mask); |
4. sa_flags
:控制信号行为的标志位
标志 | 含义 |
---|---|
SA_RESTART |
自动重启被中断的系统调用(如 read/write) |
SA_SIGINFO |
使用 sa_sigaction 而不是 sa_handler |
SA_NODEFER |
不自动屏蔽当前信号(默认会屏蔽) |
SA_RESETHAND |
处理完信号后重置为默认行为 |
4. 返回值
- 成功返回
0
。 - 失败返回
-1
,并设置errno
。
5. 代码示例
1 |
|
2. pending
什么时候从 1 -> 0?
pending 位在信号递达时被清 0,早于 handler 的执行,确保信号不会重复处理。 pending 位图就像一个“未读消息列表”。
- 信号产生时,该信号在 pending 位图中被标记为
1
(未读)。 - 当信号递达时,内核会把该位清
0
,表示“这个信号我已经处理了”。
实验证明:
1 |
|
运行结果示例:
1 | [hcc@hcss-ecs-be68 Signal Processing]$ ./test1 |
信号的 pending 位在信号递达前被清 0,handler 执行期间该信号会被自动屏蔽,handler 执行完毕后恢复。如果在 handler 执行期间发送该信号,它会再次进入 pending,handler 会再次被调用。
3. 信号处理过程中,是否自动将该信号加入 blocked
?
在执行信号处理函数时,内核会自动将该信号加入 block 集合,防止递归调用;handler 执行完毕后,block 集合恢复原样。
1 |
|
运行结果示例:
1 | [hcc@hcss-ecs-be68 Signal Processing]$ ./test2 |
在 handler 执行期间,SIGINT 被自动加入 block 集合(屏蔽),即使多次按 Ctrl+C,也不会再次进入 handler。
3. 可重入函数
可重入函数 是指:函数在被中断后,其上下文环境不被破坏,并且可以 安全地重新调用,不影响原始执行。我们可以把一个函数想象成一个 银行柜台 :
- 不可重入函数 :只有一个柜员,只能服务一个客户。如果另一个客户来了,就会出错(比如数据混乱、死锁)。
- 可重入函数 :每个客户都有自己的工位,互不干扰,可以同时处理。
信号处理函数中 只能用可重入函数,否则可能引发程序崩溃或行为不确定。非可重入函数,不可在 handler 中使用。 好在目前我们所学习到的函数都是可重入函数,简单注意一下即可。
4. volatile
volatile 是 C 语言的一个关键字,该关键字的作用是保持内存的可见性。作用是告诉编译器:“这个变量的值可能随时被修改,不要做优化!” 当变量可能被信号处理函数或中断修改时,必须使用 volatile
修饰,否则优化器可能让主线程永远看不到值的变化,导致逻辑失效。
1 |
|
-O0
,-O1
,-O2
,-O3
是控制 编译器优化强度 的开关,等级越高程序执行速度越快,但调试越困难、对写法越敏感(如volatile
就必须加)。默认建议开发调试用-O0
,上线前用-O2
。在 man 手册中查看:man gcc
→ 按下/
进入搜索模式,输入:-O
→ 然后按n
(next)多次查找。(优化选项(使用 GNU 编译器集合(GCC)))
选项 含义(优化级别) 特点 / 行为 -O0
不做优化 编译快、便于调试、代码按字面意思执行 -O1
基本优化 小幅优化、不会改变代码结构 -O2
常规优化(推荐上线用) 去除冗余,常量折叠,循环展开,较稳定 -O3
激进优化(最大速度) 包括内联函数、多重循环展开,性能极高但可能更难调试 -Os
优化空间(减小生成文件大小) 类似 -O2
,但更注重体积-Ofast
忽略一些标准规范,疯狂优化(不推荐) 会跳过 IEEE
/C99
规范,速度最快但可能不准确
运行结果示例:
当我们将 int flag = 0;
改变成 volatile int flag = 0;
就会发现运行结果一致了:
5. SIGCHLD 信号
SIGCHLD
是子进程退出时,自动发送给其父进程的信号(通知父进程:子进程挂了”的信号)。 也叫:子进程状态改变信号,通常用于通知父进程去回收子进程,防止僵尸进程出现!
1. 什么时候会触发 SIGCHLD
?
当子进程发生以下情况,内核就会给它的父进程发送 SIGCHLD
信号:
子进程行为 | 会触发 SIGCHLD 吗? | 说明 |
---|---|---|
正常退出(return/exit) | 是 | 最常见用途 |
异常终止(段错误、除 0) | 是 | 会触发 |
被信号杀死(如 SIGKILL) | 是 | 依然会通知 |
被暂停/恢复执行 | 是 | 可配合 WUNTRACED 监控状态变化 |
2. 函数原型:signal
/ sigaction
监听 SIGCHLD
1 |
|
3. 示例代码:监听 SIGCHLD + 回收子进程
1 |
|
运行结果示例:
1 | [hcc@hcss-ecs-be68 Signal Processing]$ ./SIGCHLD |
如果不处理 SIGCHLD
会怎样?
子进程退出,父进程没有调用 wait
或 waitpid
,就会产生 僵尸进程,虽然不会占用内存,但会占用 PID 表项,多了会导致系统资源耗尽。
重点 | 说明 |
---|---|
SIGCHLD 什么时候触发 |
子进程退出、异常终止、被信号杀死、状态改变 |
为啥要处理它? | 防止僵尸进程! 并控制子进程生命周期 |
怎么处理? | signal 或 sigaction 监听 SIGCHLD ,调用 waitpid |
实战用途 | 写守护进程、多进程服务程序、提升系统稳定性 |