Linux系统编程--进程信号
进程信号
- 第十一讲 Linux 进程信号
- 1. 初识信号
- 1.1 生活中的信号
- 1.2 技术应用中的信号
- 1.2.1 一个示例代码
- 1.2.2 系统函数 -- signal
- 1.2.2.1 函数介绍
- 1.2.2.2 使用说明
- 1.2.2.3 示例代码
- 1.2.3 前台进程与后台进程(重要!!!)
- 1.2.3.1 概念介绍
- 1.2.3.2 现象演示
- 1.2.3.3 现象辨析
- 1.2.3.4 任务控制
- 1.2.3.4.1 `jobs`:查看后台任务
- 1.2.3.4.2 `fg`:将后台任务切换到前台
- 1.2.3.4.3 `Ctrl+Z` 与 `bg`:将前台任务切换到后台
- 1.2.3.4.4 终止后台进程
- 1.3 信号的基础介绍
- 1.3.1 信号的定义
- 1.3.2 同步与异步
- 1.3.3 查看信号
- 补充:信号是如何记录的?
- 补充:信号如何产生?
- 1.3.4 信号处理
- 1.4 信号交互的基本原则
- 2. 信号的产生
- 2.1 通过终端按键产生信号
- 2.1.1 基础操作
- 2.1.2 终止(Term) vs. 核心转储(Core)
- 2.1.3 核心转储 (Core Dump)
- 2.1.3.1 Core Dump 的概念
- 2.1.3.2 云服务器中的核心转储
- 2.1.3.3 核心转储的管理和使用
- 2.1.4 子进程结束的状态码
- 2.2 通过系统函数/命令向进程发送信号
- 2.2.1 调用系统命令向进程发送信号
- 2.2.2 使用函数产生信号
- 2.2.2.1 kill
- 2.2.2.2 raise
- 2.2.2.3 abort
- 2.3 硬件异常产生信号
- 2.3.1 场景引入
- 2.3.2 硬件中断的介绍
- 2.3.2.1 硬件中断的流程:
- 2.3.2.2 完整的处理流程
- 2.3.3 CPU 内部的硬件中断
- 2.3.3.1 场景引入
- 2.3.3.2 处理流程
- 2.3.4 外部设备的硬件中断
- 2.3.4.1 场景引入
- 2.3.4.2 处理流程
- 2.3.4.3 内核源码
- 2.3.5 时钟设备的硬件中断
- 2.3.5.1 操作系统的普通状态
- 2.3.5.2 时钟中断的介绍
- 2.3.5.3 时间片与进程调度的介绍
- 2.3.5.4 调度流程
- 2.3.5.5 内核源码
- 2.4 由软件条件产生的信号
- 2.4.1 经典示例1:`SIGPIPE`
- 2.4.2 经典示例2:`alarm` 函数与 `SIGALRM`
- 2.4.2.1 函数介绍
- 2.4.2.2 代码示例
- 2.6.2.2.1 示例一:验证 `SIGALRM`
- 2.6.2.2.2 示例二:利用 `alarm` 衡量 CPU 与 I/O 的性能鸿沟
- 2.6.2.2.3 示例三:设置重复闹钟
- 2.4.3 Linux 中 `alarm` 的内核实现
- 2.4.3.1 描述与组织
- 2.4.3.2 内核工作流程
- 2.4.4 软中断
- 2.4.4.1 场景引入
- 2.4.4.2 异常 (Exception):错误的执行流
- 2.4.4.3 陷阱 (Trap):主动的内核请求
- 2.5 总结
- 3. 信号的保护
- 3.1 信号其他相关常见概念
- 3.1.1 为何需要保存信号
- 3.1.2 信号递达、未决与阻塞
- 理解“阻塞”与“忽略”的区别
- 3.2 内核中的信号中的表示
- 3.2.1 `pending` 表:信号的“签到簿”
- 3.2.2 `block` 表:信号的“屏蔽门”
- 3.2.3 `handler` 表:信号的“行为指南”
- 3.3 信号集的操作
- 3.3.1 信号集 `sigset_t`
- **`sigset_t`**:可以将其理解为一个**专门用来存储信号编号的集合**。其底层实现就是一个位图,但程序员无需关心其具体结构。
- 3.3.2 信号集操作函数
- 3.3.3 关键系统调用
- 3.3.3.1 `sigprocmask`:设置进程的信号屏蔽
- 3.3.3.2 `sigpending`:获取进程的未决信号集
- 3.4 一个综合实践的demo
- 3.4.1 设计思路
- 3.4.2 核心代码实现
- 3.4.3 实验过程与结果分析
- 3.4.4 深入探究:`pending`位何时被清零?
- 4. 信号的处理
- 4.1 信号处理的时机
- 4.2 用户态与内核态
- 4.2.1 内核空间与用户空间
- 4.2.1.1 进程虚拟地址空间的划分
- 4.2.1.2 页表:私有与共享的映射
- 4.2.1.3 CPU的权限级别 (CPL)
- 4.2.2 内核态与用户态
- 4.2.2.1 基础概念
- 4.2.2.2 不同状态的切换
- 4.3 信号处理的完整流程
- 4.3.1 完整流程
- 4.3.2 巧记方法
- 4.4 信号处理的系统调用:`sigaction`
- 4.4.1 函数介绍
- 4.4.2 核心部分: `struct sigaction`
- 4.4.3 实践示例
- 4.4.3.1 示例代码
- 4.4.3.2 实验过程与程与结果分析
- 5. 可重入函数
- 5.1 概念引入
- 5.2 场景剖析:一个不可重入的链表插入
- 5.3 定义可重入与不可重入
- 5.4 识别可重入与不可重入
- 6. volatile
- 6.1 引入场景:当编译器“优化”出错
- 6.2 问题的根源:编译器优化
- 6.2.1 原因探究
- 6.2.2 实践验证
- 7. SIGCHLD信号
- 7.1 场景引入
- 7.2 实践代码
- 7.3 解决僵尸进程的另一种方法
第十一讲 Linux 进程信号
1. 初识信号
1.1 生活中的信号
要理解信号在技术层面的含义,可以先从现实生活中的类比入手:
- 闹钟响起:中断你的睡眠,通知你起床时间已到。
- 交通信号灯变红:中断你过马路的行进,通知你需要等待。
- 上课铃声:中断你的课间休息,通知你课程即将开始。
- 古代战场上的狼烟:中断士兵的日常驻防,通知有敌情发生。
- 电话铃声或外卖员的敲门声:中断你当前正在做的事(如看电视、工作),通知有外部请求需要处理。
在这些场景中,“闹钟”、“红灯”、“铃声”、“狼烟”都是信号的具体形态。它们的核心共性在于:一个外部事件的发生,可能会中断我们(作为类比中的“进程”)当前正在执行的任务,以告知我们有更重要或更紧急的事情需要处理。
1.2 技术应用中的信号
1.2.1 一个示例代码
#include <stdio.h>
#include <unistd.h>int main()
{while (1){printf("hello signal!\n");sleep(1);}return 0;
}
该程序的运行结果就是死循环地进行打印,而对于死循环来说,最好的方式就是使用 Ctrl+C
对其进行终止。
实际上当用户按 Ctrl+C
时,这个键盘输入会产生一个硬中断,被操作系统获取并解释成信号( Ctrl+C
被解释成2号信号),然后操作系统将2号信号发送给目标前台进程,当前台进程收到2号信号后就会退出。
1.2.2 系统函数 – signal
针对上面的示例代码,可以使用 signal
函数对2号信号进行捕捉,证明当用户按 Ctrl+C
时进程确实是收到了2号信号。
1.2.2.1 函数介绍
NAMEsignal - ANSI C signal handlingSYNOPSIS#include <signal.h>typedef void (*sighandler_t)(int);sighandler_t signal(int signum, sighandler_t handler);参数说明:signum: 信号编号 [后面解释,只需要知道是数字即可]handler: 函数指针,表示要改信号的处理动作,当收到对应的信号,就回调执行 handler 方法
1.2.2.2 使用说明
使用 signal
函数时,需要传入两个参数,第一个是需要捕捉的信号编号,第二个是对捕捉信号的处理方法,该处理方法的参数是 int,返回值是 void 。
1.2.2.3 示例代码
#include <stdio.h>
#include <signal.h>
#include <unistd.h>void handler(int sig)
{printf("get a signal:%d\n", sig);
}int main()
{signal(2, handler); //注册2号信号while (1){printf("hello signal!\n");sleep(1);}return 0;
}
此时当该进程收到2号信号后,就会执行这里给出的 handler
方法,而不会像之前一样直接退出了,因为此时已经将2号信号的处理方式由默认改为了自定义了。
由此也证明了,当用户按 Ctrl+C
时进程确实是收到了2号信号。
1.2.3 前台进程与后台进程(重要!!!)
在上文明确了,按下 Ctrl+C
会向“目标进程”发送 SIGINT
信号。那么,究竟什么是“目标进程”?为什么有时 Ctrl+C
会起作用,有时又会失效?答案就在于**前台(Foreground)与后台(Background)**进程的概念。
1.2.3.1 概念介绍
在 Shell 环境中,进程的启动方式决定了它是前台进程还是后台进程:
- 前台进程 (Foreground Process): 在命令行中直接执行一个程序,如
./test
。- 特征:该进程会独占当前的终端。Shell会等待该进程执行完毕后,才会返回命令提示符,让你能够输入下一条命令。
- 后台进程 (Background Process): 在命令的末尾加上一个
&
符号,如./test &
。- 特征:Shell会立即返回命令提示符,让用户能够继续执行其他命令,而
./test
进程则在“后台”独立运行。
- 特征:Shell会立即返回命令提示符,让用户能够继续执行其他命令,而
这个简单的 &
符号,揭示了两种完全不同的进程运行模式。同时需要注意一个系统中每时每刻都有且只能有一个前台进程。
1.2.3.2 现象演示
前台进程:
后台进程:
1.2.3.3 现象辨析
根据上面将一个死循环的进程分别以前后台进程的方式运行,其本质区别就是是否作为键盘(标准输入)的交互对象。系统规定:在任何时刻,都只允许前台进程可以直接从键盘读取数据。
-
第一个图片,让进程以前台进程的方式运行,因为系统规定只能有一个前台进程,所以此时操作系统的
bash
就会被切换为后台进程,此时作为标准输入的ls
和pwd
命令都是与进程sig
进行交互,但是sig
中并没有设置对这两条命令进行处理的代码,所以没有任何现象出现。 -
第二个图片,让进程以后台进程的方式运行,此时系统的
bash
进程仍作为前台进程在运行,这里的ls
和pwd
命令都是与前台进程bash
进行交互,bash
作为命令行解释器可以对这两条命令进行解释并显示最后的结果在显示器上,与此同时后台进程sig
也同步运行,二者并没有相互干扰。
值得注意的是,默认情况下,前台和后台进程都可以向终端(标准输出)打印信息。这也就是为什么当后台进程运行时,它的输出会和你在 Shell 中输入命令的提示符“混杂”在一起。这种输出混杂的现象,本质上是因为终端这个“显示器文件”成为了一个被多个进程( Shell 进程和后台进程)同时访问的共享资源,而它们之间的写操作没有同步保护,导致了数据不一致的问题。
1.2.3.4 任务控制
既然存在后台进程,就需要一套机制来管理它们。Shell提供的**任务控制(Job Control)**功能允许用户在前后台之间灵活地切换进程。
1.2.3.4.1 jobs
:查看后台任务
jobs
命令可以列出当前 Shell 会话中所有在后台运行或被暂停的任务。
1.2.3.4.2 fg
:将后台任务切换到前台
fg
(foreground) 命令可以将一个后台任务重新切换到前台。
如果此时存在多个后台进程,可以使用 fg %任务号
的命令指定对应的后台进程,恢复为前台进程。
1.2.3.4.3 Ctrl+Z
与 bg
:将前台任务切换到后台
这个过程分两步:
-
暂停并放入后台:当一个进程在前台运行时,按下
Ctrl+Z
。这会向该进程发送SIGTSTP
信号,使其暂停(Stopped),并将其放入后台任务列表。此时,Shell返回了提示符,但
test
进程并未终止,只是暂停了。 -
让后台任务继续运行:使用
bg
(background) 命令,可以让一个已暂停的后台任务在后台继续运行。
1.2.3.4.4 终止后台进程
既然 Ctrl+C
无法终止后台进程,那么应该如何处理它们?
- 方法一(推荐):使用
fg
将其切换到前台,然后再用Ctrl+C
终止。 - 方法二(强制):使用
kill
命令直接向其PID发送信号。kill %1
: 向任务号为1的进程发送SIGTERM
(15号) 信号,尝试优雅地终止它。kill -9 <PID>
或kill -SIGKILL <PID>
: 向指定PID的进程发送SIGKILL
(9号) 信号,进行强制、不可忽略的终止。
1.3 信号的基础介绍
1.3.1 信号的定义
基于上述理解,可以引出信号在操作系统中的技术定义:
定义:信号是发送给进程的一种用于异步通知(Asynchronous Notification)的机制。
这个定义中有两个关键点:进程和异步。
- 信号是发给进程的:信号的作用对象是正在运行的程序(即进程)。因此,这一机制被准确地称为“进程信号”。
- 异步(Asynchronous):这是理解信号本质最重要的一个概念。异步意味着两件事情可以在同一时间段内独立发生,互不干扰。
1.3.2 同步与异步
可以通过一个例子来理解同步与异步的区别:
假设一个老师正在上课,快递员打电话说快递到了校门口。
- 同步(Synchronous)处理:让学生A去取快递,并宣布全班暂停上课,一起等待学生A取回快递后,再继续授课。在这个模型中,“上课”这个主任务的进程,因为“取快递”这个事件而被阻塞了。
- 异步(Asynchronous)处理:让学生A去取快递,但我的授课继续进行,不受任何影响。学生A取快递的过程与上课的过程在同时发生,彼此独立。
信号的产生过程对于接收它的进程而言,正是异步的。例如:
- 你在睡觉时(进程在执行睡眠代码),闹钟在独立地计时(另一个事件在发生)。二者互不干扰,直到闹钟响的那一刻,它才产生一个“信号”来通知你。
- 你在过马路时(进程在执行),交通灯在独立地倒计时(另一个事件在发生)。只有当倒计时结束、灯变色时,它才产生一个“信号”来通知你。
结论:信号的产生与目标进程的运行是两条独立的执行线。信号机制允许操作系统在某个特定事件发生时,去“打断”一个正在运行的进程,通知它这一事件的发生,而无需进程在原地空等。
1.3.3 查看信号
使用 kill -l
命令可以查看Linux当中的信号列表。
其中1~31号信号是普通信号,这是下面学习的重点;34~64号信号是实时信号,它们在处理机制上与普通信号有所不同(例如,实时信号支持排队,通常要求更及时的处理),这里暂时不深入探讨。综上普通信号和实时信号各自都有31个。
每个信号都有一个编号和一个宏定义名称,这些宏定义可以在 signal.h 中找到,例如其中就有熟悉的 #define SIGINT 2
。
补充:信号是如何记录的?
实际上,当一个进程接收到某种信号后,该信号是被记录在该进程的进程控制块当中的。可以知道进程控制块本质上就是一个结构体变量,而对于信号来主要就是记录某种信号是否产生,因此,也就可以用一个32位的位图来记录信号是否产生。
其中比特位的位置代表信号的编号,而比特位的内容就代表是否收到对应信号,比如第6个比特位是1就表明收到了6号信号。
补充:信号如何产生?
经过上面对信号的记录方式的介绍,可以知道一个进程收到信号,本质就是该进程内的信号位图被修改了,也就是该进程的数据被修改了,而只有操作系统才有资格修改进程的数据,因为操作系统是进程的管理者。也就是说,信号的产生本质上就是操作系统直接去修改目标进程的 task_struct
中的信号位图。
注意: 信号只能由操作系统发送,但信号发送的方式有多种。
1.3.4 信号处理
信号处理常见方式概述:
- 执行该信号的默认处理动作。
- 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号。
- 忽略该信号。
在 Linux 当中,可以通过 man
手册查看各个信号默认的处理动作。
$ man 7 signal
上面的知识并没有做详细的讲解,下面会一一详细介绍,为了保证条理下面会按照如下思路进行阐述:
1.4 信号交互的基本原则
通过上述的生活类比,可以推导出关于进程如何与信号交互的几个基本原则,这些原则将指导后续的学习。
- 进程对于信号的处理方式是预先定义的:
一个进程对于它可能收到的每一种信号,都已经预先知道了该如何去处理。就像我们在设定闹钟之前,就已经知道闹钟响了意味着该起床;一个士兵在狼烟点燃之前,就已经通过训练知道了那代表着什么。进程的代码中已经包含了处理特定信号的逻辑。
- 信号处理并不是立即处理而是可以在合适的时候处理:
信号的到来并不强制进程必须在接收到的那一瞬间立刻处理。就像闹钟响了,你可以选择按掉再睡五分钟;上课铃响了,你可能需要跑几步才能回到教室。进程接收到信号后,通常会在一个“合适”的时机去执行相应的处理动作,而不是立即中断其关键操作。
- 进程内置了对信号的识别能力:
不同的信号代表不同的事件,正如红灯停、绿灯行。进程本身就被设计为能够识别不同的信号类型,并执行与之对应的不同处理程序。这种识别和分发能力,是由操作系统和进程代码共同提供的,如同我们通过后天学习或被教育,而获得了对各种社会信号的识别能力。
-
信号的来源是多种多样的:
正如生活中的信号源自闹钟、交通灯或电话,操作系统中的进程也会面临来自多个源头的信号。一个事件的发生可能源于系统的不同层面,这些“信号源”主要包括:- 硬件异常:当CPU在执行程序时检测到错误,如除以零或访问非法内存,会通知内核,由内核向问题进程发送信号(例如
SIGFPE
,SIGSEGV
)。 - 终端按键:用户通过终端与程序交互时,特定的组合键会产生信号。最典型的例子就是按下
Ctrl+C
,这会向当前前台进程发送SIGINT
(中断)信号。 - 软件条件:由程序自身或系统状态触发。例如,
alarm()
函数设置的定时器到期(SIGALRM
)、管道的读端关闭后写端依然尝试写入(SIGPIPE
)等。 - 由系统函数/命令发送:一个进程可以通过
kill
命令或kill()
系统调用,向另一个进程显式地发送信号。这是进程间进行控制和通信的一种基本手段。
- 硬件异常:当CPU在执行程序时检测到错误,如除以零或访问非法内存,会通知内核,由内核向问题进程发送信号(例如
2. 信号的产生
当前阶段:
2.1 通过终端按键产生信号
2.1.1 基础操作
-
Ctrl+C
(SIGINT) 上文已经验证过,这里不再重复 -
Ctrl+\
(SIGQUIT) 可以发送终止信号并生成core dump
文件,用于事后调试。 -
Ctrl+Z
(SIGTSTP) 可以发送停止信号,将当前前台进程挂起到后台等。
2.1.2 终止(Term) vs. 核心转储(Core)
上述的介绍可以看到实际上除了按 Ctrl+C
之外,按 Ctrl+\
也可以终止该进程。
按 Ctrl+C
实际上是向进程发送2号信号 SIGINT
,而按 Ctrl+\
实际上是向进程发送3号信号 SIGQUIT
。查看这两个信号的默认处理动作,可以看到这两个信号的 Action
是不一样的,2号信号是 Term
,而3号信号是 Core
。
并且通过 man 7 signal
手册,可以观察到大多数信号的默认处理动作(Action)都是 Term,即终止进程。然而,还有一类特殊的终止动作,标记为 Core。
然而这两种 Action
有什么区别呢?
- Term (Terminate): 这是一种“干净”的终止。当进程收到这类信号(如
SIGINT
或SIGTERM
)时,它会立即停止运行,不做任何额外操作。 - Core (Terminate with Core Dump): 这是一种“异常”的终止。当进程收到这类信号(如
SIGQUIT
,SIGSEGV
,SIGFPE
)时,它在终止前会执行一个额外的步骤:核心转储(Core Dump)。
2.1.3 核心转储 (Core Dump)
2.1.3.1 Core Dump 的概念
核心转储是指当一个程序因异常而崩溃时,操作系统将该进程在内存中的核心数据镜像(包括进程的地址空间、寄存器状态、堆栈信息等)完整地写入到磁盘上的一个文件中。这个生成的文件通常被命名为 core
或 core.<pid>
。
核心转储的主要目的,是为了支持调试(Debugging)。具体来说,是事后调试(Post-mortem Debugging)。当一个线上服务崩溃时,开发者不可能让它停在那里等待开发者连接调试器。有了核心转储文件,开发者可以将这个文件拷贝到开发环境中,使用GDB等调试工具加载它,从而可以精确地重现程序崩溃时的完整现场,快速定位到导致问题的具体代码行。
2.1.3.2 云服务器中的核心转储
在云服务器中,核心转储是默认被关掉的,原因是,在大多数生产环境(尤其是云服务器)上,核心转储功能默认是被禁止的。这主要出于以下考虑:
- 磁盘空间:核心转储文件可能非常大,因为它包含了整个进程的内存镜像。如果一个有bug的服务频繁崩溃并重启,会迅速产生大量的
core
文件,耗尽服务器的磁盘空间。 - 安全与性能:在生产环境中,首要任务是保证服务的稳定运行。调试应当在开发或测试环境中进行。禁用核心转储可以避免不必要的磁盘I/O开销,并防止包含敏感数据(如密码、密钥)的内存信息被转储到磁盘上。
2.1.3.3 核心转储的管理和使用
管理命令:
这里可以通过 ulimit
命令来查看和设置核心转储文件的资源限制。
-
查看核心转储状态:
$ ulimit -a
输出为
0
表示核心转储功能被禁止(即允许生成的core
文件大小为0)。 -
临时开启核心转储:
$ ulimit -c 1024
这条命令将允许生成大小为 1024 字节的
core
文件。这个设置仅在当前终端会话中有效。
说明:
- 一个进程允许产生多个
core
文件取决于进程的Resource Limit
(这个信息保存在哪里的 PCB 中)。默认是不允许产生core
文件的,因为core
文件中可能包含用户密码等敏感信息,不安全。 - 在开发调试阶段可以用
ulimit
命令改变这个限制,允许产生core
文件。 - 首先用
ulimit
命令改变 Shell 进程的Resource Limit
,然后再运行用户自己的可执行文件,此时子进程的 PCB 是由 Shell 进程复制而来的,所以也具有和 Shell 进程相同的Resource Limit
值。
使用示例:
现在,可以通过一个实验来见证核心转储的威力:
-
编写一个会崩溃的程序 (
test.cc
):#include <iostream> int main() {int a = 10;a = a / 0; // 这将引发SIGFPE信号return 0; }
-
编译并启用调试信息:
# -g 选项至关重要,它会把调试信息编译进可执行文件 $ g++ -g test.cc -o test
-
开启核心转储并运行:
$ ulimit -c unlimited $ ./test Floating point exception (core dumped) # 系统提示发生了核心转储 $ ls test test.cc core
可以看到,目录下确实生成了一个
core
文件。 -
进行事后调试:
$ gdb ./test core ... (GDB启动信息) ... Core was generated by `./test'. Program terminated with signal SIGFPE, Floating point exception. #0 0x000000000040114c in main () at test.cc:5 5 a = a / 0; // 这将引发SIGFPE信号
GDB直接加载了
core
文件,并精确地定位到了导致程序崩溃的代码就在test.cc
的第5行。这就是核心转储对于调试的巨大价值。
2.1.4 子进程结束的状态码
现在,可以回头解决一个之前在学习 waitpid()
时遗留的困惑。曾讲过,waitpid
返回的状态码 status
是一个整数,它的低16位包含了子进程的退出信息:
- 低7位 (bits 0-6): 如果子进程是被信号终止的,这里存储的是终止它的信号编号。
- 第8位 (bit 7): 这是一个核心转储标志位 (Core Dump Flag)。
- 高8位 (bits 8-15): 如果子进程是正常退出的(调用
exit()
或main
返回),这里存储的是退出码。
当时对第8位的 core dump
标志位感到困惑,现在它的意义已经非常清晰了:如果这个标志位为1,就表示子进程在被信号终止的同时,还生成了一个 core
文件。
2.2 通过系统函数/命令向进程发送信号
2.2.1 调用系统命令向进程发送信号
kill
命令是一个Shell命令,用于从终端向指定PID的进程发送一个特定的信号。例如,kill -2 12345
就等同于向PID为12345的进程发送 SIGINT(2号)
信号。
如果要使用 kill
命令向一个进程发送信号时,可以以 kill -信号名 进程ID
的形式进行发送。
也可以以 kill -信号编号 进程ID
的形式进行发送。
2.2.2 使用函数产生信号
2.2.2.1 kill
函数介绍:
上面展示了使用系统命令 kill
向进程发送指定信号。而 kill
命令底层封装的就是 kill
系统调用函数。
kill
命令是调用kill
函数实现的。kill
函数可以给一个指定的进程发送指定的信号。
NAMEkill - send signal to a processSYNOPSIS#include <sys/types.h>#include <signal.h>int kill(pid_t pid, int sig);RETURN VALUEOn success (at least one signal was sent), zero is returned. On error, -1 is returned, and errno is set appropriately.
模拟实现:
// mykill.cc
#include <iostream>
#include <signal.h>
#include <stdlib.h>
#include <unistd.h>void usage(const char* proc)
{std::cout << "Usage: " << proc << " -signum pid" << std::endl;
}int main(int argc, char *argv[])
{if (argc != 3) {usage(argv[0]);exit(1);}int signum = atoi(argv[1] + 1); // argv[1] is "-9", +1 skips '-'pid_t pid = atoi(argv[2]);if (kill(pid, signum) == 0){std::cout << "Signal " << signum << " sent to PID " << pid << " successfully." << std::endl;} else {perror("kill");}return 0;
}
2.2.2.2 raise
函数介绍:
raise
函数可以给当前进程发送指定的信号 (自己给自己发信号)。
NAMEraise - send a signal to the callerSYNOPSIS#include <signal.h>int raise(int sig);RETURN VALUEraise() returns 0 on success, and nonzero for failure.
使用示例:
#include <iostream>
#include <unistd.h>
#include <signal.h>void handler(int signumber)
{// 整个代码就只有一处打印std::cout << "获取了一个信号: " << signumber << std::endl;
}// mykill -signumber pid
int main()
{signal(2, handler); // 先对2号信号进行捕捉// 每隔1s,自己给自己发送2号信号while(true){sleep(1);raise(2);}
}
2.2.2.3 abort
函数介绍:
abort
函数使当前进程接收到信号而异常终止。
NAMEabort - cause abnormal process terminationSYNOPSIS#include <stdlib.h>void abort(void);RETURN VALUEThe abort() function never returns.// 就像 exit 函数一样,abort 函数总是成功的,所以没有返回值。
使用示例:
#include <iostream>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>void handler(int signumber)
{// 整个代码就有这一处打印std::cout << "获取了一个信号: " << signumber << std::endl;
}// mykill -signumber pid
int main()
{signal(SIGABRT, handler);while(true){sleep(1);abort();}
}
与之前不同的是,虽然这里对 SIGABRT 信号进行了捕捉,并且在收到 SIGABRT 信号后执行了这里给出的自定义方法,但是当前进程依然是异常终止了。
这是因为 abort()
是一个来自C标准库的函数,它的唯一作用就是异常终止当前进程。其实现原理就是向调用进程自身发送一个**SIGABRT
**(6号)信号。
abort()
函数有一个非常重要的特性:它一定会终止进程。即使为 SIGABRT
信号编写了一个自定义的捕捉函数,abort()
在发送信号之前,会先将该信号的处理方式重置为默认动作(即 Core
),然后再发送信号。这个设计确保了 abort()
的调用总能达到其设计的目的——让程序立即以异常状态退出。
2.3 硬件异常产生信号
2.3.1 场景引入
上文已知可以通过按键(如 Ctrl+C
)或代码(如 kill()
函数)来产生信号。但还有一类信号,它们的源头并非来自用户或程序的“主观意图”,而是源于程序在执行过程中触发了底层的硬件错误。
思考一下这些常见的程序崩溃场景:
执行了
int x = 10 / 0;
这样的除零操作。
访问了一个空指针或无效的内存地址(野指针)。
这些错误最终都会导致进程收到一个特定的信号(如SIGFPE
、SIGSEGV
)而被终止。那么,这个信号不由用户中的代码主动发送而来,而如果要完成信号就发送就一定要修改进程中的信号位图结构,作为内核数据也仅仅操作系统有权限进行修改,所以答案就是:由CPU硬件检测到异常,并通知操作系统,再由操作系统最终转化为发送给进程的信号。
要理解这个过程,必须先了解一个操作系统中最核心的概念之一:硬件中断(Hardware Interrupt)。
2.3.2 硬件中断的介绍
想象一个场景:当一个程序执行
cin >> x;
时,它会阻塞等待。当用户按下键盘时,这个在内核之外的、正在休眠的进程,是如何被唤醒并得知有数据到来的?
如果让操作系统通过一个循环去不断地“轮询”检查键盘、鼠标、网卡、磁盘等所有设备的状态,效率会极其低下。因此,计算机系统采用了一种更高效的“事件驱动”模型,其核心就是硬件中断。
2.3.2.1 硬件中断的流程:
- 中断信号的传递:
- 现代计算机的所有外部设备(键盘、网卡等)都通过线路连接到一个名为**中断控制器(Interrupt Controller)**的专用硬件芯片上。
- 当一个设备有事件发生时(例如,键盘被按下,网卡收到数据包),它会向中断控制器发送一个电信号。
- 中断控制器会为这个事件生成一个唯一的中断号(Interrupt Number),这个数字标识了事件的来源(例如,中断号
1
可能代表键盘)。 - 随后,中断控制器向CPU发送一个通用的“中断”信号。
- CPU的响应:
- CPU在执行完每条指令后,都会检查中断控制器是否有中断信号传来。
- 一旦检测到中断,CPU会立即暂停当前正在执行的任何程序(无论是用户程序还是操作系统代码)。
- CPU会向中断控制器询问具体的中断号,从而得知是哪个设备触发了中断。
- 操作系统的介入 - 中断向量表:
- CPU只知道发生了什么中断,但并不知道该如何处理。具体的处理逻辑是由操作系统提供的。
- 操作系统在启动时,会在内存中建立一张名为**中断向量表(Interrupt Vector Table)或中断描述符表(IDT)**的表格。
- 这张表本质上是一个函数指针数组,其数组下标就是中断号。表中的每一项都指向一个由操作系统编写的、用于处理特定中断的函数(称为中断服务例程,Interrupt Service Routine, ISR)。
2.3.2.2 完整的处理流程
- 当CPU获取到中断号后,它会以此为索引,在中断向量表中查找到对应的处理函数的地址。
- CPU跳转到该地址,开始执行操作系统的这段代码(ISR)。例如,如果是键盘中断,ISR会从键盘的硬件缓冲区读取数据,并唤醒正在等待输入的进程。
- ISR执行完毕后,CPU会返回到之前被中断的地方,恢复程序的执行。
2.3.3 CPU 内部的硬件中断
2.3.3.1 场景引入
现在,可以将硬件中断的知识应用到程序错误上了。像“除零”、“非法内存访问”这类错误,在CPU的视角看来,是一种硬件异常(Hardware Exception)。异常可以被看作是一种由CPU内部产生的、同步的“中断”。
2.3.3.2 处理流程
当CPU在执行用户代码时,检测到这类无法继续执行的致命错误时,整个处理流程如下:
-
**用户进程运行:流程的起点是一个正在用户态(User Mode)**下正常执行的进程。在这个状态下,进程运行的是用户编写的应用程序代码,它所能访问的内存和可执行的指令都受到严格的限制。
-
CPU检测到异常:CPU在执行指令时,其内部的算术逻辑单元(ALU)或内存管理单元(MMU)检测到一个错误(例如,除数为零,或目标内存地址无权访问)。这个事件在硬件层面被称为异常(Exception)。
-
触发内部中断:CPU立即暂停当前的用户进程,并根据错误的类型,在内部产生一个特定的异常编号(这与外部设备的中断号在概念上是类似的)。
-
跳转到内核处理程序:CPU使用这个异常编号,同样去查询中断描述符表(IDT),找到操作系统预先为这类异常注册好的内核处理函数(Exception Handler),并跳转执行。
-
内核生成并发送信号:
-
此时,控制权已经从用户态切换到了内核态。内核的异常处理函数开始工作。
-
它会分析导致异常的上下文信息,确定是哪个进程引发了错误。
-
最后,内核会生成一个对应的软件信号,并将其发送给这个“犯错”的进程。
- 除零异常 -> 内核发送
SIGFPE
信号。 - 内存访问越界 -> 内核发送
SIGSEGV
信号。
- 除零异常 -> 内核发送
-
-
进程响应信号:
- 进程接收到这个由内核发来的信号后,执行其默认动作,通常是终止并可能产生核心转储(Core Dump)。
总结:硬件异常产生的信号,其传递链条是:用户代码执行 -> CPU检测到硬件异常 -> 控制权转交内核 -> 内核异常处理程序 -> 内核向进程发送软件信号 -> 进程处理信号。
2.3.4 外部设备的硬件中断
2.3.4.1 场景引入
在了解了那些**“除零”、“非法内存访问”这类错误,CPU内部的异常如何转化为信号后,现在回到最初的那个问题:一个正在等待输入的进程,是如何被键盘、网卡这类外部**设备“唤醒”的?这个过程是硬件中断最经典的应用场景。
与CPU内部同步发生的异常不同,来自外部设备的中断是异步的,它可能在任何时间点到达。
2.3.4.2 处理流程
这里以最常见的键盘输入为例,完整地追踪这个中断流程:
- 进程阻塞等待:
用户程序执行到cin >> x;
或read(0, ...)
时,由于标准输入(键盘)没有数据,操作系统会将该进程的状态设置为阻塞(或睡眠),并将其从CPU的运行队列中移出。进程就此“睡去”,不再消耗CPU资源。 - 硬件事件发生:
用户在键盘上按下了一个按键。键盘硬件本身捕捉到这个动作。 - 中断信号传递:
键盘通过总线向中断控制器发送一个电信号。中断控制器识别出这是来自键盘的中断请求,为其分配了预设的中断号(例如,IRQ 1),然后向CPU的特定引脚发送中断信号。 - CPU响应与内核介入:
CPU在执行完当前指令后,检测到中断信号。它会立即:- 暂停当前正在运行的任何进程(可能是与等待输入的进程完全无关的其他进程)。
- 保存当前进程的执行上下文(寄存器状态等)。
- 从中断控制器获取中断号
1
。 - 以
1
为索引,在**中断描述符表(IDT)中查找到对应的中断服务例程(ISR)**也就是操作系统键盘驱动程序中的核心处理函数。 - 跳转执行这个ISR,此时CPU已处于内核态。
- 中断服务例程(ISR)执行:
键盘驱动的ISR开始工作:- 它通过I/O端口从键盘的硬件缓冲区中读取用户按下的键值数据。
- 将这些数据放入内核中为该终端会话维护的输入缓冲区里。
- 检查是否有进程正在等待该输入。它发现我们最初那个“睡去”的进程正在等待。
- 因此,ISR会修改该进程的PCB,将其状态从阻塞更改为就绪(Runnable),并将其重新放回调度器的就绪队列中。
- 返回与进程唤醒:
ISR执行完毕,控制权返回到CPU。CPU恢复之前被中断的那个进程的上下文,让它继续运行,就好像什么都没发生过一样。 - 调度与执行:
在稍后的某个时间点(通常很快),操作系统的进程调度器(由时钟中断触发)运行时,会发现我们那个等待输入的进程已经处于“就绪”状态。于是,调度器可能会选择它在CPU上运行。当该进程重新获得CPU时,它会从上次阻塞的地方继续执行,此时内核输入缓冲区中已有数据,cin
或read
调用可以成功返回,程序继续向下执行。
这个过程完美地诠释了外部设备中断如何以一种高效、异步的方式驱动进程状态的变迁。无论是键盘、鼠标、网卡还是磁盘,所有基于I/O的阻塞与唤醒,其底层都遵循着这套由硬件和操作系统内核紧密协作的中断处理机制。
2.3.4.3 内核源码
在Linux 0.11的内核源码中,我们可以清晰地看到中断向量表的构建和注册过程,这印证了上述理论。
- 全局中断初始化 (
trap_init
):
操作系统在启动时会执行一个全局的初始化函数trap_init()
。它的核心工作是使用set_trap_gate()
和set_system_gate()
为CPU的内部异常(如0号中断对应除零错误)预先设置好处理函数(如divide_error
)。同时,它也会为外部硬件中断设置一个临时的默认处理程序。 - 设备驱动注册中断 (
rs_init
):
当特定的设备驱动程序(如此处的串行口驱动)被初始化时,它会执行自己的rs_init()
函数。该函数会调用set_intr_gate()
,将属于自己的硬件中断号(如0x24
和0x23
)与自己专属的中断服务例程(如rs1_interrupt
和rs2_interrupt
)精确地绑定)精确地绑定起来,覆盖掉全局的默认设置。
// Linux内核0.11源码
void trap_init(void)
{int i;set_trap_gate(0, ÷_error); // 设置除操作出错的中断向量。以下雷同。set_trap_gate(1, &debug);set_trap_gate(2, &nmi);set_system_gate(3, &int3); /* int3-5 can be called from all */set_system_gate(4, &overflow);set_system_gate(5, &bounds);set_trap_gate(6, &invalid_op);set_trap_gate(7, &device_not_available);set_trap_gate(8, &double_fault);set_trap_gate(9, &coprocessor_segment_overrun);set_trap_gate(10, &invalid_TSS);set_trap_gate(11, &segment_not_present);set_trap_gate(12, &stack_segment);set_trap_gate(13, &general_protection);set_trap_gate(14, &page_fault);set_trap_gate(15, &reserved);set_trap_gate(16, &coprocessor_error);// 下面将int17-48的陷阱门先均设置为reserved,以后每个硬件初始化时会重新设置自己的陷阱门。for (i = 17; i < 48; i++)set_trap_gate(i, &reserved);set_trap_gate(45, &irq13); // 设置协处理器的陷阱门。outb_p(inb_p(0x21) & 0xfb, 0x21); // 允许主8259A芯片的IRQ2中断请求。outb(inb_p(0xA1) & 0xdf, 0xA1); // 允许从8259A芯片的IRQ13中断请求。set_trap_gate(39, ¶llel_interrupt); // 设置并行口的陷阱门。
}void rs_init(void)
{set_intr_gate(0x24, rs1_interrupt); // 设置串行口1的中断门向量(硬件IRQ4信号)。set_intr_gate(0x23, rs2_interrupt); // 设置串行口2的中断门向量(硬件IRQ3信号)。init(tty_table[1].read_q.data); // 初始化串行口1(.data是端口号)。init(tty_table[2].read_q.data); // 初始化串行口2。outb(inb_p(0x21) & 0xE7, 0x21); // 允许主8259A芯片的IRQ3、IRQ4中断信号请求。
}
2.3.5 时钟设备的硬件中断
在已经了解,硬件中断是外部设备或CPU异常“唤醒”内核的方式之后。引出了几个更深层次的问题:
- 在没有任何中断发生的时候,操作系统(内核)本身在做什么?
- 外部设备可以触发硬件中断,但是这个需要用户或者设备自行触发,那么有没有自己可以定时触发的设备?
2.3.5.1 操作系统的普通状态
直观上,可能认为操作系统是一个永不停歇的管理者,总是在进行进程调度、内存管理等工作。但事实恰恰相反。如果没有任何事件需要处理,操作系统的核心会进入一种“无所事事”的暂停状态。
在早期的Linux 0.11内核源码中,可以清晰地看到这一点。其主函数 main()
在完成了所有初始化工作(如内存管理、设备驱动、中断向量表设置等)之后,最终会进入一个无限循环,循环体里只有一个核心核心动作:
// Simplified from Linux 0.11 main.c
void main(void) {// ... lots of initialization code ...while (1) {pause(); // Halt until an interrupt occurs}
}
pause()
系统调用会使进程挂起,直到有信号(在这里可以广义地理解为中断事件)递达。这说明,操作系统本身是被动的被信号和事件驱动的。它不会主动去做任何事,而是等待中断来“告诉”它需要做什么。
2.3.5.2 时钟中断的介绍
如果操作系统仅仅依赖外部设备中断(如键盘、网卡),那么当这些设备都处于空闲状态时,整个系统就会陷入停滞,无法实现多任务调度。为了解决这个问题,计算机硬件引入了一个至关重要的组件:时钟/计时器。
- 时钟硬件:这是一个硬件设备(现代CPU内部通常集成了可编程中断计时器),它能以一个极高的频率(这个频率与CPU的主频密切相关)持续不断地产生时钟中断。
- 周期醒:这意味着,无论系统多么“空闲”,CPU都会被这个时钟中断周期性地“唤醒”,强制它去执行操作系统中预设的一段代码。
这个时钟中断,就是操作系统的“心跳”。它为操作系统提供了一个恒定的动力来源,确保了即使在没有外部事件的情况下,内核也能周期性地获得CPU的控制权,去执行管理任务
2.3.5.3 时间片与进程调度的介绍
操作系统正是利用这个恒定的“心跳”来实现抢占式多任务调度的。
- 注册调度程序:操作系统在启动时,会将一个核心的进程调度函数注册到中断向量表中,与时钟中断的中断号相关联。
- 时间片的概念:内核为每个进程维护一个**时间片(Time Slice)**计数器。这本质上就是一个整数,代表该进程还能连续占用CPU的“滴答”数。
2.3.5.4 调度流程
- 时钟中断发生,CPU暂停当前正在运行的进程。
- 控制权转交给内核的时钟中断处理程序。
- 该程序将当前进程的时间片计数器减1。
- 检查时间片:
- 如果计数器仍大于0,说明时间片未用完。中断处理程序直接返回,CPU继续执行原来的进程。
- 如果计数器等于0,说明时间片已耗尽。此时,中断处理程序会调用进程调度器(Scheduler)。
- 调度器会根据调度算法(如优先级、轮转等)选择另一个进程来运行,并进行上下文切换。
2.3.5.5 内核源码
在Linux 0.11的源码中,可以清晰地看到这个逻辑:时钟中断(0x20
)触发汇编例程 timer_interrupt
,该例程最终会调用C函数 do_timer()
。do_timer()
的核心逻辑正是检查当前进程的计数器 current->counter
,并在其耗尽时调用 schedule()
。
// Linux 内核0.11
// main.c
sched_init(); // 调度程序初始化(加载了任务0 的tr,ldtr) (kernel/sched.c)// 调度程序的初始化子程序。
void sched_init(void)
{...set_intr_gate(0x20, &timer_interrupt);// 修改中断控制器屏蔽码,允许时钟中断。outb(inb_p(0x21) & ~0x01, 0x21);// 设置系统调用中断门。set_system_gate(0x80, &system_call);...
}// system_call.s
_timer_interrupt:...; // do_timer(CPL)执行任务切换、计时等工作,在kernel/shched.c,305 行实现。call _do_timer ; // 'do_timer(long CPL)' does everything from// 调度入口
void do_timer(long cpl)
{...schedule();
}void schedule(void)
{...switch_to(next); // 切换到任务号为next的任务,并运行之。
}
结论:进程调度并非由操作系统“主动”发起,而是被高频发生的时钟中断“被动”触发的。CPU的主频越高,时钟中断的频率就越高,操作系统进行调度的机会也就越多、越精细。
2.4 由软件条件产生的信号
上文已经探讨了来自键盘、系统调用和硬件异常的信号。最后一类信号的产生源于软件条件的变化。这意味着,当程序运行的状态不满足某个预设的软件逻辑时,操作系统内核会主动介入,向相关进程发送信号。
2.4.1 经典示例1:SIGPIPE
一个最经典的例子是在学习管道(Pipe)时遇到的情况:
如果管道的读端已经被关闭,而某个进程仍然尝试向该管道的写端写入数据。
这个行为在逻辑上是无意义的,因为数据写进去也永远不会被读取。为了避免资源浪费,操作系统内核会检测到这种“软件条件不满足”的状态,并向试图写入的进程发送 SIGPIPE
(13号) 信号。该信号的默认动作是终止进程,从而阻止了这种无效的写入操作。
#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 };// 使用pipe创建匿名管道if (pipe(fd) < 0){ perror("pipe");return 1;}pid_t id = fork(); // 使用fork创建子进程// childif (id == 0){close(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;
}
2.4.2 经典示例2:alarm
函数与 SIGALRM
2.4.2.1 函数介绍
NAMEalarm - set an alarm clock for delivery of a signalSYNOPSIS#include <unistd.h>unsigned int alarm(unsigned int seconds);RETURN VALUEalarm() returns the number of seconds remaining until any previouslyscheduled alarm was due to be delivered, or zero if there was no previously
- 功能:
调用alarm(n)
会请求操作系统为当前进程设置一个定时器。当n
秒倒计时结束后,操作系统会向该进程发送SIGALRM
(14号) 信号。 - 默认动作:
SIGALRM
信号的默认处理动作是终止当前进程。 - 特殊调用:
alarm(0)
会取消任何先前为该进程设置的、尚未触发的定时器。 - 返回值:
- 如果之前没有设置过定时器,
alarm()
返回0
。 - 如果之前已经设置过一个定时器,
alarm()
会取消旧的定时器,设定新的,并返回旧定时器剩余的秒数。
- 如果之前没有设置过定时器,
2.4.2.2 代码示例
2.6.2.2.1 示例一:验证 SIGALRM
我们可以通过自定义捕捉来验证 alarm
可以通过自定义捕捉来验证 alarm
的行为。下面的程序设置了一个1秒的闹钟,并捕捉所有信号。
#include <iostream>
#include <signal.h>
#include <unistd.h>void handler(int sig)
{std::cout << "get a signal: " << sig << std::endl;
}int main()
{// 捕捉所有信号for (int i = 1; i <= 31; ++i) {signal(i, handler);}alarm(1); // Set a 1-second alarmwhile(true); // Loop foreverreturn 0;
}
运行程序,大约1秒后,它会打印出 get a signal 14
,然后继续循环。这证明了 alarm(1)
确实在1秒后向进程发送了14号信号 (SIGALRM
),并且因为这里自定义了捕捉,进程没有被默认终止。
2.6.2.2.2 示例二:利用 alarm
衡量 CPU 与 I/O 的性能鸿沟
-
场景一:包含I/O操作
这里设置一个1秒的闹钟,在循环中不断地计数并打印。
#include <iostream> #include <signal.h> #include <unistd.h>int main() {long count = 0;alarm(1);while (true){std::cout << "count=" << count++ << std::endl;}return 0; }
程序运行1秒后,被
SIGALRM
终止。在典型的云服务器环境下,count
的值可能只增长到 6万 左右。这是因为std::cout
是一个I/O操作,涉及向终端文件写入、数据通过网络传输等,速度非常慢。 -
场景二:纯CPU计算
这里同样设置1秒的闹钟,但在循环中只进行计数,将打印操作移到信号处理器中。
#include <iostream> #include <signal.h> #include <unistd.h>long count = 0;void handler(int sig) {std::cout << "Final count: " << count << std::endl;exit(0); }int main() {signal(SIGALRM, handler);alarm(1);while (true){count++; // Pure computation}return 0; }
这次,在1秒钟内,
count
的值可以达到数亿级别。 -
总结:
纯CPU计算的速度比涉及I/O的操作要快上万倍甚至更多。这个简单的实验清晰地揭示了为何在高性能编程中,减少I/O是至关重要的优化方向。
2.6.2.2.3 示例三:设置重复闹钟
alarm
本身是一个一次性的定时器。所以可以通过在信号处理器中再次调用 alarm
来实现周期性任务。
#include <iostream>
#include <signal.h>
#include <unistd.h>void handler(int sig)
{std::cout << "tick..." << std::endl;alarm(1); // Re-register the alarm
}int main()
{signal(SIGALRM, handler);alarm(1); // Start the first alarm// Efficiently wait for signalswhile(true) {pause();}return 0;
}
pause()
是一个特殊的系统调用,它会使进程挂起,直到有信号被捕捉并从其处理函数返回。这是一种比while(true);
更高效的等待方式,因为它不会空耗CPU。
这个程序会每隔一秒打印一次 “tick…”,形成了一个由信号驱动的、周期性的任务循环。
2.4.3 Linux 中 alarm
的内核实现
上文已经知道,调用 alarm()
会在指定的秒数后,由操作系统向进程发送一个 SIGALRM
信号。这引出了一个更深层次的问题:
既然系统中可以有成百上千个进程,每个进程都可能在任意时刻调用
alarm()
设置不同时长的定时器,操作系统是如何精确、高效地管理所有这些“闹钟”的呢?
答案在于,操作系统将对“闹钟”的管理,完全转化为了对一种内核数据结构的管理。
2.4.3.1 描述与组织
首先,内核需要一种方式来描述一个闹钟。这通常是一个结构体,包含了闹钟的核心信息:
- 目标进程:这个闹钟响了之后,应该通知哪个进程。
- 过期时间:闹钟应该在哪个时间点响起。这通常是一个绝对的时间戳(例如,从系统启动开始的总滴答数)。
- 处理动作:闹钟到期后要执行的操作(即向目标进程发送
SIGALRM
信号)。
其次,当大量闹钟同时存在时,内核需要一种高效的方式来组织它们。一个非常适合此场景的数据结构是最小堆(Min-Heap)。
- 内核可以将所有待处理的闹钟(定时器)组织成一个以过期时间为排序关键字的最小堆。
- 最小堆的特性保证了堆顶的元素(闹钟)永远是最先即将到期的那一个。
Linux 内核中的定时器数据结构是:
struct timer_list {struct list_head entry;unsigned long expires;void (*function)(unsigned long);unsigned long data;struct tvec_t_base_s *base;
};
2.4.3.2 内核工作流程
结合之前对时钟中断的理解,操作系统管理闹钟的完整流程如下:
- 进程调用
alarm(n)
:- 进程陷入内核态。
- 内核计算出该闹钟的绝对过期时间(
当前时间戳 + n秒对应的滴答数
)。 - 内核创建一个新的闹钟(定时器)对象,并将其插入到全局的最小堆中。堆会自动调整,确保新的最先到期的闹钟位于堆顶。
- 时钟中断周期性发生:
- 硬件时钟以固定的高频率触发时钟中断,内核的时钟中断处理程序被执行。
- 在处理程序的众多任务中,有一项就是检查闹钟。
- 检查并处理到期闹钟:
- 内核仅需比较当前时间戳与堆顶闹钟的过期时间。
- 如果当前时间 < 堆顶闹钟的过期时间:这意味着连最紧急的闹钟都还没到期,那么其他所有闹钟更不可能到期。内核无需做任何事,直接从中断返回。
- 如果当前时间 >= 堆顶闹钟的过期时间:这意味着堆顶的闹钟已经到期。内核会:
- 将堆顶的闹钟对象移除。
- 执行该对象预设的动作:向其目标进程发送
SIGALRM
信号。 - 堆会自动调整,将下一个最快到期的闹钟置于堆顶。
- 内核会继续检查新的堆顶,因为可能在同一个时钟滴答内有多个闹钟同时到期。
这个基于最小堆的机制极其高效,因为它避免了在每次时钟中断时都去遍历所有定时器的开销。
alarm
的整个实现,从定时器的创建、组织(最小堆),到时间的流逝(时钟中断驱动的时间戳递增),再到过期条件的判断(软件比较),完全是一个在操作系统内部由纯软件逻辑构建的系统。因此,闹钟超时是“软件条件”产生信号的最典型范例。
2.4.4 软中断
2.4.4.1 场景引入
上一个部分介绍了硬件中断,需要硬件设备触发,但是其实也可以因为软件原因,而触发上面的逻辑。
与由外部设备(键盘、网卡)触发的、异步的硬件中断不同,软中断是由CPU在执行软件指令的过程中同步触发的。它主要分为两大类:异常(Exception)和陷阱(Trap)。
2.4.4.2 异常 (Exception):错误的执行流
异常,是指CPU在执行指令时,检测到无法正常处理的错误条件,从而被动触发的中断。在之前介绍的**“硬件异常”,其本质就属于软中断中的异常**。
- 触发源:
- 代码错误:执行了
int x = 10 / 0;
这样的除零操作。CPU的算术逻辑单元(ALU)会检测到溢出,并设置内部的标志寄存器(EFLAGS Register)。 - 内存访问错误:访问了空指针或非法的内存地址。CPU的内存管理单元(MMU)在进行虚拟到物理地址转换时,会发现页表中没有有效的映射或权限不足。
- 代码错误:执行了
- 处理流程:
- CPU一旦检测到这类异常,会立即暂停当前指令的执行。
- 它会根据异常的类型(如除零、段错误、页缺失),在内部生成一个特定的中断号。
- CPU使用这个中断号,查询中断描述符表(IDT),跳转到内核预设的异常处理程序。
- 内核的异常处理程序接管控制权,分析错误原因,并最终向引发错误的进程发送一个相应的信号(如
SIGFPE
、SIGSEGV
)。
**缺页中断(Page Fault)**是异常中最特殊也最常见的一种。当进程访问一个合法的、但尚未加载到物理内存的虚拟地址时,MMU会触发缺页异常。内核的缺页处理程序会负责分配物理内存、从磁盘加载数据,并更新页表。这个过程对用户进程是透明的,但其底层机制同样依赖于异常。
2.4.4.3 陷阱 (Trap):主动的内核请求
陷阱,与异常的被动触发不同,是用户程序为了请求操作系统服务而主动、有意执行特殊指令,从而“陷入”内核的一种机制。平时日常使用的所有系统调用,其底层都依赖于陷阱。
-
CPU的特殊指令集:
CPU为用户程序提供了一条或多条专门用于陷入内核的指令,例如在x86架构下的int 0x80
(中断) 和syscall
(系统调用) 使用这里两条指令就可以陷入内核。 -
系统调用的实现原理:
操作系统并非直接向用户程序暴露其内核函数的地址。相反,它提供了一套基于“陷阱”的标准化接口:-
系统调用表:内核自身维护一个名为
system_call_table
的函数指针数组,数组的每一项都指向一个具体的内核函数(如sys_fork
,sys_open
)。而这里的数组的下标,就是一个独一无二的系统调用号。// 系统调用函数指针表。用于系统调用中断处理程序 (int 0x80),作为跳转表。 fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read, sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link, sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod, sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount, sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm, sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access, sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir, sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk, sys_setgid, sys_getgid, sys_signal, sys_geteuid, sys_getegid, sys_acct, sys_phys, sys_lock, sys_ioctl, sys_fcntl, sys_mpx, sys_setpgid, sys_ulimit, sys_uname, sys_umask, sys_chroot, sys_ustat, sys_dup2, sys_getppid, sys_getpgrp, sys_setsid, sys_sigaction, sys_sgetmask, sys_ssetmask, sys_setreuid, sys_setregid };
-
用户空间的请求:当用户程序需要调用一个系统服务时(例如
open()
),它并不会直接调用内核代码而是调用C标准库中的封装函数。 -
C标准库(libc)的封装:编程时调用的
open()
、fork()
等系统函数,实际上是C标准库提供的封装函数。这些封装函数的底层实现(通常是汇编代码)会做两件核心的事:- 将该系统调用对应的系统调用号(例如,
open
的系统调用号是5
)放入一个约定的CPU寄存器中(例如EAX
)。 - 执行
int 0x80
或syscall
指令。
- 将该系统调用对应的系统调用号(例如,
-
内核的响应:
- CPU执行陷阱指令,立即将运行模式从用户态切换到内核态,并根据指令(如
int 0x80
)查询IDT,跳转到内核预设的系统调用总入口处理程序。 - 这个总入口程序从约定的寄存器(
EAX
)中读出用户传递的系统调用号。 - 以该系统调用号为下标,在
system_call_table
中查找到真正的内核函数地址。 - 调用该内核函数,完成用户请求的服务。
- CPU执行陷阱指令,立即将运行模式从用户态切换到内核态,并根据指令(如
-
结论:系统调用并非普通的函数调用,而是一次由**软中断(陷阱)**驱动的、从用户态到内核态的完整状态切换和受控服务请求过程。日常使用的所有高级语言,其底层都依赖于C标准库对这个机制的封装。
2.5 总结
至此,已经完整地探讨了信号产生的几种主要方式。无论其来源如何多样,最终都殊途同归:由操作系统内核向目标进程发送信号,这本质上是内核修改目标进程PCB中信号相关数据的过程。
回顾一下所有信号的来源:
- 终端按键 (
Ctrl+C
,Ctrl+\
,Ctrl+Z
)- 由用户物理操作触发,信号由终端驱动程序产生,并发送给前台进程。
- 系统命令 (
kill
)- 用户在Shell中执行的命令,其底层通过
kill()
系统调用实现。
- 用户在Shell中执行的命令,其底层通过
- 系统调用 (
kill()
,raise()
,abort()
)- 由进程代码主动调用,用于进程间的通信与控制,或进程的自我管理。
- 硬件异常 (
SIGFPE
,SIGSEGV
)- 由CPU硬件检测到执行错误(如除零、非法内存访问),触发向内核的陷入,再由内核翻译成对应的信号发送给进程。
- 软件条件 (
SIGPIPE
,SIGALRM
)- 由操作系统内核在运行过程中,检测到某个软件逻辑条件被满足(如向已关闭的管道写入、定时器到期),从而主动产生并发送信号。
理解了信号是如何产生的,下一个问题便是:信号在产生后,并不是立即被处理的。那么,在它被处理之前,它是如何被“保存”的呢?这将是下一面探讨的主题。
3. 信号的保护
当前阶段:
3.1 信号其他相关常见概念
3.1.1 为何需要保存信号
在之前的第一章节中得出一个重要结论:信号的处理并非总是即时的。当一个信号产生时,接收它的进程可能正在执行更重要的任务,无法立即响应。为了确保信号不会丢失,操作系统必须有一种机制,在信号产生后、被处理前,将其暂时保存起来。
这个“从产生到处理之间”的中间状态,引出了一系列关于信号保存和管理的核心概念。
3.1.2 信号递达、未决与阻塞
为了精确描述信号的整个生命周期,我们引入三个关键术语:
- 信号递达 (Delivery): 内核将一个待处理的信号最终传递给进程,并实际执行其处理动作(无论是默认、自定义还是忽略)的过程,称为信号的递达。
- 信号未决 (Pending): 一个信号从产生后,到被递达前的这段时间里,称该信号处于未决状态。可以理解为,信号已经“挂号”,正在等待被“叫号”处理。
- 信号阻塞 (Blocked / Masked): 这是进程可以主动选择的一种状态。进程可以声明它暂时不接收某些特定的信号。当一个被阻塞的信号产生时,它会一直保持在未决状态,但永远不会被递达,直到进程解除了对该信号的阻塞。我们将这个过程也称为屏蔽信号。
理解“阻塞”与“忽略”的区别
初学者很容易混淆阻塞(Block)和忽略(Ignore),但它们是两个截然不同的概念,发生在信号生命周期的不同阶段:
这里可以用一个“老师布置作业”的场景来类比:
- 默认/自定义处理:老师布置了作业(信号产生),你把作业记在小本子上(信号处于未决状态),下课后你开始做作业(信号递达)。
- 忽略:老师布置了作业,你记在了本子上,下课后你拿出本子,看了一眼题目,然后决定不做,把本子合上了。这个“决定不做”的动作,就是忽略。它是一种**处理(递达)**方式。
- 阻塞:因为你很讨厌这位老师,所以当他开始布置作业时,你压根就不往本子上记(或者记了但打了个叉,表示绝不做)。只要你还讨厌这位老师(信号处于阻塞状态),即使作业记在本子上了(处于未決状态),你也永远不会去做它(无法被递达)。直到有一天你不再讨厌这位老师了(解除阻塞),你才会把本子上所有积攒的作业拿出来处理。
总结:
- 阻塞是一个过滤器,它决定了一个处于未决状态的信号是否能够被递达。它发生在递达之前。
- 忽略是信号被递达时,可供选择的三种处理动作之一(默认、自定义、忽略)。它是一种处理结果。
3.2 内核中的信号中的表示
内核为每个进程维护了三张与信号处理密切相关的表用于表述信号的信息(在实际内核中,它们通常是位图或结构体成员)。
// 内核结构 2.6.18
struct task_struct {.../* signal handlers */struct sighand_struct *sighand;sigset_t blocked;struct sigpending pending;...
};
3.2.1 pending
表:信号的“签到簿”
- 结构:一张位图(bitmap)。在32位系统中,通常是一个
unsigned int
。 - 含义:
- 比特位的位置:代表信号的编号。例如,第2个比特位代表2号信号(
SIGINT
)。 - 比特位的内容(0或1):代表该信号是否处于未决状态。
1
表示已产生、待处理;0
表示未产生。
- 比特位的位置:代表信号的编号。例如,第2个比特位代表2号信号(
- 作用:当一个信号产生时,无论它是来自键盘、硬件异常还是
kill()
调用,内核所做的第一件事就是将该进程PCB中pending
表对应的比特位置为1
。这张表就是信号的“签到簿”,记录了所有已产生但尚未被处理的信号。
3.2.2 block
表:信号的“屏蔽门”
- 结构:同样是一张位图,结构与
pending
表完全相同。 - 含义:
- 比特位的位置:代表信号的编号。
- 比特位的内容(0或1):代表该信号是否被阻塞。
1
表示该信号被阻塞,不允许递达;0
表示未被阻塞。
- 作用:这张表由用户程序通过特定的系统调用(如
sigprocmask
)来修改,它赋予了进程主动屏蔽某些信号的能力。
3.2.3 handler
表:信号的“行为指南”
-
结构:一个函数指针数组,例如
void (*handler[32])(int)
。 -
含义:
- 数组的下标:代表信号的编号。
- 数组的内容:存储一个函数地址,指向该信号的处理函数。
-
作用:这张表记录了每个信号对应的处理方式。当我们调用
signal(signum, handler_func)
时,其本质就是修改这张表中下标为signum
的那一项,将其内容设置为handler_func
函数的地址。对于特殊的处理方式默认和忽略,内核定义了两个特殊的“地址”:
SIG_DFL
(通常是0
): 表示执行该信号的默认动作。SIG_IGN
(通常是1
): 表示忽略该信号。
内核在递达信号时,会检查handler
表中的值,如果发现是这两个特殊值,就会执行相应的特殊逻辑,而不是将其作为函数地址来调用。
3.3 信号集的操作
已经知道,信号的保存与屏蔽依赖于 pending
和 block
这两张位图。然而,让程序员直接在代码中通过位运算来操作这些内核数据是不现实、不安全且不具备可移植性的。
因此,Linux提供了一套标准的API,围绕一个名为**信号集(Signal Set)**的核心概念,来让用户程序安全、便捷地与这两张表交互。
3.3.1 信号集 sigset_t
为了屏蔽底层的位图实现,内核在用户空间定义了一个数据类型:sigset_t
。
#define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int)))
typedef struct
{unsigned long int __val[_SIGSET_NWORDS];
} __sigset_t;typedef __sigset_t sigset_t;
-
sigset_t
:可以将其理解为一个专门用来存储信号编号的集合。其底层实现就是一个位图,但程序员无需关心其具体结构。 - 信号屏蔽字 (Signal Mask):当一个
sigset_t
类型的变量被用来表示需要阻塞的信号集合时,通常称之为信号屏蔽字。这与文件权限中的umask
在概念上是类似的,凡是在屏蔽字中出现的信号,都将被阻塞。
3.3.2 信号集操作函数
由于 sigset_t
是一个“不透明”的类型,系统提供了一组标准函数来对它进行初始化和修改。这些函数是对底层位图操作的封装:
int sigemptyset(sigset_t *set);
- 功能:初始化
set
,将其清空,使其不包含任何信号(即将所有比特位清零)。
int sigfillset(sigset_t *set);
- 功能:初始化
set
,使其包含所有已定义的信号(即将所有比特位置为1
)。
int sigaddset(sigset_t *set, int signum);
- 功能:向
set
中添加指定的signum
信号(即将对应的比特位置为1
)。
int sigdelset(sigset_t *set, int signum);
- 功能:从
set
中移除指定的signum
信号(即将对应的比特位清零)。
int sigismember(const sigset_t *set, int signum);
- 功能:判断指定的
signum
信号是否存在于set
中(即判断对应的比特位是否为1
)。
在使用任何 sigset_t
变量之前,必须先使用 sigemptyset
或 sigfillset
进行初始化。在使用任何 sigset_t
变量之前,必须先使用 sigemptyset
或 sigfillset
进行初始化。
3.3.3 关键系统调用
3.3.3.1 sigprocmask
:设置进程的信号屏蔽
这是控制进程 block
表的核心函数。它允许进程读取或更新自己的信号屏蔽字。
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
-
int how
:指定如何修改当前的信号屏蔽字。有三种模式:-
SIG_BLOCK
:追加屏蔽。将set
中包含的信号添加到当前进程的信号屏蔽字中。new_mask = current_mask | set
。 -
SIG_UNBLOCK
:解除屏蔽。从当前进程的信号屏蔽字中移除set
中包含的信号。new_mask = current_mask & ~set
。 -
SIG_SETMASK
:覆盖设置。完全使用set
来替换当前进程的信号屏蔽字。new_mask = set
。
-
-
const sigset_t *set
:一个输入参数。它是一个由用户预先准备好的信号集,包含了要进行屏蔽、解除屏蔽或设置的信号。如果此参数为NULL
,则how
参数被忽略,函数仅用于读取当前的屏蔽字。 -
sigset_t *oldset
:一个输出参数。如果此参数不为NULL
,那么在修改之前的那个旧的信号屏蔽字,会被完整地保存到oldset
指向的变量中。这是一个非常重要的功能,它允许我们在临时修改了信号屏蔽之后,能够方便地将其恢复到原始状态。
3.3.3.2 sigpending
:获取进程的未决信号集
此函数用于读取进程的 pending
表。
int sigpending(sigset_t *set);
sigset_t *set
:一个输出参数。调用此函数后,内核会将当前进程的pending
位图(即所有处于未决状态的信号集合)拷贝到set
指向的变量中。
一个关键问题:为什么只提供了获取(
get
)pending
表的函数,而没有设置(set
)它的函数?答案:
pending
表是内核用来记录客观事实的——即哪些信号已经实际产生了。这个状态是由外部事件(键盘、硬件异常、kill
调用等)决定的,用户程序不应该也无法凭空“制造”一个未决信号。因此,pending
表对用户程序来说是只读的,用户只能查询它,以了解哪些信号正在等待处理。
3.4 一个综合实践的demo
理论学习之后,最好的检验方式就是编写代码来验证。下面将设计一个Demo,目标是利用前面学到的接口,完整地观测一个信号从产生 -> 因阻塞而未决 -> 解除阻塞后被递达的全过程。
3.4.1 设计思路
这里的实验思路如下:
- 主程序:启动一个进程。
- 设置屏蔽:进程首先调用
sigprocmask()
屏蔽 2号信号 (SIGINT
)。 - 持续观测:进程进入一个无限循环,在循环中每隔一秒调用
sigpending()
获取自己当前的未决信号集,并将其以位图的形式打印到屏幕上。 - 发送信号:在另一个终端,我们手动使用
kill -2 <PID>
命令向该进程发送2号信号。 - 观测未决:由于2号信号已被屏蔽,它不会被递达,而是会一直处于未决状态。我们将能从主程序的打印输出中,清晰地看到代表2号信号的比特位从
0
变为1
。 - 解除屏蔽:在程序运行一段时间后(例如10秒),主程序自动调用
sigprocmask()
解除对2号信号的屏蔽。 - 观测递达:一旦屏蔽被解除,处于未决状态的2号信号将立即被递达。为了能观测到递达后的状态变化,必须先为2号信号注册一个自定义的捕捉函数,防止进程被默认终止。递达后,能看到未决信号集中的2号比特位又从
1
变回了0
。
3.4.2 核心代码实现
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <vector>// 打印信号集位图的函数
void printPending(const sigset_t& pending)
{std::cout << "pending bitmap: ";for (int i = 31; i >= 1; --i) {if (sigismember(&pending, i)) {std::cout << "1";} else {std::cout << "0";}}std::cout << std::endl;
}// 自定义的信号处理函数
void handler(int sig)
{std::cout << "\nSignal " << sig << " is being delivered!" << std::endl;
}int main()
{// 1. 捕捉2号信号,防止进程退出,以便我们观察signal(SIGINT, handler);// 2. 屏蔽2号信号sigset_t block_set, old_set;sigemptyset(&block_set);sigaddset(&block_set, SIGINT); // 将2号信号添加到信号集中// 将自行定义的 block_set 设置到进程的 block 表中sigprocmask(SIG_BLOCK, &block_set, &old_set);std::cout << "Process " << getpid() << " has blocked signal 2." << std::endl;// 3. 循环获取并打印 pending 表int count = 0;while(true){sigset_t pending_set;sigemptyset(&pending_set);// 获取当前进程的 pending 表sigpending(&pending_set);printPending(pending_set);sleep(1);count++;// 10秒后,解除屏蔽if (count >= 10) {std::cout << "Unblocking signal 2..." << std::endl;sigprocmask(SIG_SETMASK, &old_set, nullptr); // 使用old_set恢复原样}}return 0;
}
3.4.3 实验过程与结果分析
-
编译并运行程序:
$ g++ mytest.cc -o mytest $ ./mytest Process 86912 has blocked signal 2. pending bitmap: 0000000000000000000000000000000 # (全0) pending bitmap: 0000000000000000000000000000000 ...
程序启动,屏蔽了2号信号,并开始每秒打印全
0
的pending
位图位图。 -
从另一终端发送信号:
$ kill -2 86912
-
观测
pending
位变化:
回到第一个终端,输出立刻发生了变化。... pending bitmap: 0000000000000000000000000000000 pending bitmap: 0000000000000000000000000000010 # 2号位变成了1 pending bitmap: 0000000000000000000000000000010 pending bitmap: 0000000000000000000000000000010 ...
这个结果清晰地证明:被阻塞的信号,其产生后会一直停留在未决(pending)状态,而不会被递达。
-
观测与
pending
位恢复:
等待程序运行10秒后,它会自动解除屏蔽。... pending bitmap: 0000000000000000000000000000010 pending bitmap: 0000000000000000000000000000010 Unblocking signal 2...Signal 2 is being delivered! # 自定义handler被立即执行 pending bitmap: 0000000000000000000000000000000 # 2号位恢复为0 pending bitmap: 0000000000000000000000000000000 ...
一旦屏蔽被解除,
handler
函数立即被调用,同时pending
位图中的对应位被清零。
3.4.4 深入探究:pending
位何时被清零?
一个更细致的问题是:内核是在执行handler
函数之前,还是之后,才将 pending
位清零的?可以通过在 handler
函数内部也打印一次 pending
集来找到答案。
void handler(int sig)
{std::cout << "\nSignal " << sig << " is being delivered!" << std::endl;sigset_t p_set;sigpending(&p_set);std::cout << "Inside handler -> ";printPending(p_set); // 在handler内部打印
}
修改后再次实验,会发现,在 handler
内部打印出的 pending
位图已经是全 0
了。
最终结论:当一个未决信号即将被递达时,内核的动作顺序是:
- 信号在
pending
位图中的对应位清零。 - 随后,才执行用户空间的
handler
函数。
这个设计是至关重要的。它确保了如果在执行信号处理函数的过程中,同一个信号再次到来,它能够被正确地记录在pending
表中,而不会因为前一个信号还在处理中而被丢失。
4. 信号的处理
当前阶段:
4.1 信号处理的时机
已经从之前的章节中得出了几个关键结论:
- 信号的产生方式多种多样,但最终都由内核向目标进程的
pending
位图写入一个比特位。 - 信号在产生后不一定被立即处理,它可能因为进程繁忙或被
block
位图屏蔽而处于未决状态。
这引出了信号处理的核心问题:进程总说要在“合适的时候”处理信号,那么,这个**“合适的时候”**究竟是什么时候?
核心结论:信号的检查与处理,发生在进程从**内核态(Kernel Mode)返回用户态(User Mode)**的前夕。
4.2 用户态与内核态
要理解信号处理的时机,必须先深入理解现代操作系统的一个基石性设计:**用户态(User Mode)与内核态(Kernel Mode)**的划分。
4.2.1 内核空间与用户空间
4.2.1.1 进程虚拟地址空间的划分
在32位Linux系统中,每个进程都拥有一个4GB大小的独立虚拟地址空间。这个空间并非铁板一块,而是被划分为两个核心区域:
- 用户空间 (0GB - 3GB):这个区域存放的是进程自身的代码、数据、堆栈、共享库等。
- 内核空间 (3GB - 4GB):这个区域存放的是操作系统内核的核心代码和数据。
4.2.1.2 页表:私有与共享的映射
虚拟地址终究需要通过页表映射到物理内存才能被CPU访问。
- 用户页表 (Per-Process):每个进程的0-3GB用户空间,都由其私有的一套用户页表来负责映射。这意味着不同进程的用户空间是完全隔离的,A进程无法直接访问B进程的数据。
- 内核页表 (System-wide):操作系统的代码和数据在物理内存中只存在一份。所有进程的3-4GB内核空间,都通过同一套共享的内核页表,映射到物理内存中这同一份操作系统实例。
这个设计的深远意义在于:任何一个进程,在其自己的地址空间内,都能“看到”整个操作系统。这为进程与内核的高效交互奠定了基础。
4.2.1.3 CPU的权限级别 (CPL)
“所有进程都能看到内核”引出了一个致命的安全问题:如果任何用户代码都能随意跳转到3-4GB的地址空间去执行内核代码、修改内核数据,那整个系统的安全性将荡然无存。
为了解决这个问题,现代CPU硬件层面引入了**权限级别(Current Privilege Level, CPL)**的概念。
- 用户态 (User Mode, CPL=3):当CPU执行用户空间的代码时,其内部的一个特殊寄存器(如x86架构下的CS寄存器)中的CPL位被设置为
3
。在此状态下,CPU被硬件禁止访问高于自身权限级别的内存区域,任何对3-4GB内核空间的直接访问都会被CPU拦截,并产生一个硬件异常。 - 内核态 (Kernel Mode, CPL=0):当CPU执行内核空间的代码时,CPL位被设置为
0
,即最高权限。在此状态下,CPU可以访问包括内核空间在内的所有内存,并执行所有特权指令。
一个进程虽然能“看到”内核,但只有在CPU处于内核态时,它才能真正“访问”内核。用户态和内核态是CPU的两种工作模式,是操作系统安全性的硬件基石。
4.2.2 内核态与用户态
4.2.2.1 基础概念
- 用户态 (User Mode):当一个进程在执行它自己的应用程序代码时(例如,执行
for
循环、调用自定义函数),CPU就处于用户态。在此状态下,进程的权限受到严格限制,它不能直接访问硬件或内核数据。 - 内核态 (Kernel Mode):当进程需要执行一些特权操作时(例如,进行文件I/O、申请内存、创建子进程),它必须通过**系统调用(System Call)**请求操作系统来代为完成。当CPU开始执行属于操作系统的代码时,它就切换到了内核态。在此状态下,CPU拥有最高权限,可以执行任何指令,访问任何资源。
4.2.2.2 不同状态的切换
从用户态切换为内核态通常有如下几种情况:
- 需要进行系统调用时。
- 当前进程的时间片到了,导致进程切换。
- 产生异常、中断、陷阱等。
与之相对应,从内核态切换为用户态有如下几种情况:
- 系统调用返回时。
- 进程切换完毕。
- 异常、中断、陷阱等处理完毕。
其中,由用户态切换为内核态称之为陷入内核。每当需要陷入内核时,本质上是因为用户需要执行操作系统的代码,比如系统调用函数是由操作系统实现的,如果要进行系统调用就必须先
由用户态切换为内核态。
4.3 信号处理的完整流程
4.3.1 完整流程
现在,可以回答那个核心问题了:
核心结论:信号的检查与处理,发生在进程从**内核态(Kernel Mode)返回用户态(User Mode)**的前夕。
一个进程的生命周期,就是在用户态和内核态之间不断切换的过程。而信号处理,正是嵌入在这个切换流程中的一个关键环节。
-
进程在用户态正常执行。
-
因系统调用、硬件中断或时间片耗尽,进程从用户态切换到内核态。
-
CPU在内核态执行操作系统的代码。
-
内核任务执行完毕,准备从内核态返回用户态。
-
关键步骤:就在返回前的最后一刻,内核会执行一个类似
do_signal()
的例程,对当前进程进行信号检查。 -
do_signal()
会根据进程PCB中的pending
、block
和handler
三张表,按以下逻辑执行:-
如果存在被阻塞的未决信号,则直接返回用户态,进程继续从之前中断的地方执行。
-
如果没有被阻塞的未决信号,则根据
handler
表的内容,执行相应处理信号的处理动作。-
如果处理信号的处理动作是默认或者忽略,则执行该信号的处理动作后清除对应的
pending
标志位,如果没有新的信号要递达,就直接返回用户态,从主控制流程中上次被中断的地方继续向下执行即可。 -
如果处理信号的处理动作是自定义的,即该信号的处理动作是由用户提供的,那么处理该信号时就需要先返回用户态执行对应的自定义处理动作,执行完后再通过特殊的系统调用
sigreturn
再次陷入内核并清除对应的pending
标志位,如果没有新的信号要递达,就直接返回用户态,继续执行主控制流程的代码。
-
-
4.3.2 巧记方法
当处理信号是自定义捕捉时的情况比较复杂,可以借助下图进行记忆:
其中,该图形与直线有几个交点就代表在这期间有几次状态切换,而箭头的方向就代表着此次状态切换的方向,图形中间的圆点就代表着检查pending表。
4.4 信号处理的系统调用:sigaction
4.4.1 函数介绍
虽然之前使用了 signal()
函数来注册信号处理函数,但它在某些方面存在兼容性问题。在Linux中,更推荐、功能也更强大的接口是 sigaction()
。
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
- 功能:检查或修改指定信号的处理动作。
- 参数:
int signum
: 要操作的信号编号。const struct sigaction *act
: 输入参数,一个指向sigaction
结构体的指针,包含了对信号的新处理方式。struct sigaction *oldact
: 输出参数,用于保存修改前的旧的处理方式,方便日后恢复。
sigaction
的核心在于 struct sigaction
结构体,推荐使用的时候重点改前的旧的处理方式,方便日后恢复。
4.4.2 核心部分: struct sigaction
sigaction
的核心在于 struct sigaction
结构体:
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
:- 该参数没有使用。
4.4.3 实践示例
4.4.3.1 示例代码
下面的代码将验证 sigaction
的自动屏蔽机制:
#include <iostream>
#include <signal.h>
#include <unistd.h>void printPending(const sigset_t& pending)
{std::cout << "pending bitmap: ";for (int i = 31; i >= 1; --i) {if (sigismember(&pending, i)) {std::cout << "1";} else {std::cout << "0";}}std::cout << std::endl;
}// 在handler中循环打印pending集
void handler(int sig)
{std::cout << "\nEntering handler for signal " << sig << std::endl;while (true) {sigset_t p;sigemptyset(&p);sigpending(&p);printPending(p);sleep(1);}
}int main()
{struct sigaction act;act.sa_handler = handler;sigemptyset(&act.sa_mask); // sa_mask为空,不额外屏蔽act.sa_flags = 0; // 不设置额外标志sigaction(SIGINT, &act, nullptr); // 为2号信号注册处理while (true) {std::cout << "Main loop running, PID: " << getpid() << std::endl;sleep(1);}return 0;
4.4.3.2 实验过程与程与结果分析
-
编译并运行程序:
$ g++ mytest.cc -o mytest $ ./mytest Main loop running, PID: 88456 Main loop running, PID: 88456 ...
程序启动,主循环开始正常运行,并打印出进程ID。
-
从另一终端发送第一个2号:
$ kill -2 88456
-
观测程序行为变化:
回到第一个终端,主循环的打印被中断,输出变为handler
函数的内容。... Main loop running, PID: 88456Entering handler for signal 2 pending bitmap: 0000000000000000000000000000000 # (进入handler后, pending集是空的) pending bitmap: 0000000000000000000000000000000 ...
程序的主循环被中断,执行流进入了
handler
函数。由于信号已被递达(pending
位已被清零),所以在handler
内部看到的pending
位图是全0
。 -
在
handler
时,再次发送2号信号:
当第一个终端正在循环打印pending
位图时,从第二个终端再次发送2号信号。$ kill -2 88456
-
观测
pending
位图的变化:
第一个终端的输出立刻发生了变化。... pending bitmap: 0000000000000000000000000000000 pending bitmap: 0000000000000000000000000000010 # 第二个2号信号被置为未决 pending bitmap: 0000000000000000000000000000010 ...
这个结果完美地证明了
sigaction
的核心特性:当信号的处理函数正在执行时,后续发来的同一个信号会被自动屏蔽,并保持在未决状态,从而防止了处理函数的递归调用。
5. 可重入函数
5.1 概念引入
信号处理机制引入了一种独特的编程场景:一个函数的执行过程,可能被一个信号“打断”,然后信号处理函数(handler)可能会再次调用同一个函数。这种“函数被重复进入”的现象,对函数的健壮性提出了新的挑战。
这个概念之所以重要,是因为它揭示了在并发环境(即使是信号这种“伪并发”)下,代码可能遇到的潜在风险。理解可重入性是编写健壮的信号处理程序以及后续学习多线程编程的基础。
5.2 场景剖析:一个不可重入的链表插入
首先通过一个经典的例子来理解“可重入”问题。
下面主函数中调用 insert
函数向链表中插入结点 node1
,某信号处理函数中也调用了 insert
函数向链表中插入结点 node2
,乍眼一看好像没什么问题。
下面分析一下,对于下面这个链表。
-
首先,
main
函数中调用了insert
函数,想将结点node1
插入链表,但插入操作分为两步,刚做完第一步的时候,完成了node1
结点的插入,因为突然产生一个硬件中断使进程切换到内核,再次回到用户态之前检查到有信号待处理,于是切换到 sighandler 函数。 -
而
sighandler
函数中也调用了insert
函数,将结点node2
插入到了链表中,插入操作完成第一步后的情况如下: -
当结点
node2
插入的两步操作都做完之后从sighandler
返回内核态,此时链表的布局如下:
-
再次回到用户态就从
main
函数调用的insert
函数中继续往下执行,即继续进行结点node1
的插入操作。
最终结果是,main函数和sighandler函数先后向链表中插入了两个结点,但最后只有node1结点真正插入到了链表中,而node2结点就再也找不到了,造成了内存泄漏。
上述例子中,各函数执行的先后顺序如下:
5.3 定义可重入与不可重入
通过上述例子,可以得出正式的定义:
- 不可重入函数 (Non-reentrant Function):
像上面例子中的insert
函数,如果一个函数在执行尚未完成时,因被中断而再次进入(重入)执行,且这种重入可能会破坏程序的逻辑或数据,导致不可预期的结果,那么这个函数就是不可重入的。 - 可重入函数 (Reentrant Function):
如果一个函数无论何时被中断,即使在中断处理中被再次调用,其执行结果和数据状态都不会受到影响,那么这个函数就是可重入的。
5.4 识别可重入与不可重入
一个函数是否可重入,是其固有的一种特性,无所谓好坏。在实际编程中,需要能够识别出哪些函数可能是不可重入的。通常,满足以下一个或多个条件的函数,大概率是不可重入的:
- 使用了全局变量或静态变量:像上面例子中的全局
head
指针,是导致问题的根源。多个执行流同时修改同一个全局资源,极易引发冲突。 - **调用了
malloc
或free
**:内存分配器通常会维护一个全局的内存链表,在并发调用时可能导致数据结构损坏。 - 使用了标准I/O库函数:像
printf
,cout
等函数,为了保证输出的有序性,通常会使用全局的缓冲区或锁,这使得它们在信号处理环境中是不可重入的。这也是为什么我们不推荐在信号处理函数中进行复杂的I/O操作。
结论:在编写信号处理函数时,必须极度谨慎,应确保其中调用的所有函数都是可重入的。否则,就可能引入难以追踪的、偶发的、灾难性的bug。这个概念在后续的多线程编程中会更加重要,因为在多线程环境下,函数的“重入”现象将不再是低概率事件,而是随时可能发生的高频常态。
6. volatile
6.1 引入场景:当编译器“优化”出错
volatile
是C/C++语言中的一个关键字,它的作用与信号处理本身没有直接关系,但信号处理恰好提供了一个绝佳的、能够、能够直观理解 volatile
用途的场景。
在下面的代码中,对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;
}
运行结果:
6.2 问题的根源:编译器优化
6.2.1 原因探究
可以看到最后的结果好像都在意料之中的,但实际并非如此。代码中的 main 函数和 handler 函数是两个独立的执行流,而 while 循环是在 main 函数当中的,在编译器编译时只能检测到在 main 函数中对 flag 变量的使用。
此时编译器检测到在 main 函数中并没有对 flag 变量做修改操作,在编译器优化级别较高的时候,就有可能将 flag 设置进寄存器里面。
此时 main 函数在检测 flag 时只检测寄存器里面的值,而 handler 执行流只是将内存中 flag 的值置为1了,那么此时就算进程收到2号信号也不会跳出死循环。
6.2.2 实践验证
在编译代码时携带 -O3
选项使得编译器的优化级别最高,此时再运行该代码,就算向进程发生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;
}
此时就算编译代码时携带 -O3
选项,当进程收到2号信号将内存中的flag变量置1时,main 函数执行流也能够检测到内存中 flag 变量的变化,进而跳出死循环正常退出。
7. SIGCHLD信号
7.1 场景引入
前期在学习进程控制的时候知道,为了避免出现僵尸进程,父进程需要使用 wait
或 waitpid
函数等待子进程结束,父进程可以阻塞等待子进程结束,也可以非阻塞地查询的是否有子进程结束等待清理,即轮询的方式。采用第一种方式,父进程阻塞就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。
然而其实,子进程在终止时会给父进程发生 SIGCHLD 信号,该信号的默认处理动作是忽略,父进程可以自定义 SIGCHLD 信号的处理动作,这样父进程就只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用 wait
或 waitpid
函数清理子进程即可。
7.2 实践代码
例如,下面代码中对 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);// childif (fork() == 0){printf("child is running, begin dead: %d\n", getpid());sleep(3);exit(1);}// fatherwhile (1);return 0;
}
此时父进程就只需专心处理自己的工作,不必关心子进程了,子进程终止时父进程收到SIGCHLD信号,会自动进行该信号的自定义处理动作,进而对子进程进行清理。
注意:
- SIGCHLD 属于普通信号,记录该信号的 pending 位只有一个,如果在同一时刻有多个子进程同时退出,那么在 handler 函数当中实际上只清理了一个子进程,因此在使用 waitpid 函数清理子进程时需要使用 while 不断进行清理。
- 使用 waitpid 函数时,需要设置 WNOHANG 选项,即非阻塞式等待,否则当所有子进程都已经清理完毕时,由于 while 循环,会再次调用 waitpid 函数,此时就会在这里阻塞住。
7.3 解决僵尸进程的另一种方法
事实上,由于 UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用 signa l 或 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);// childif (fork() == 0){printf("child is running, child dead: %d\n", getpid());sleep(3);exit(1);}// fatherwhile (1);return 0;
}
此时子进程在终止时会自动被清理掉,不会产生僵尸进程,也不会通知父进程。