Linux----信号
1.什么是信号
1.1 引入
信号的基本认知
- 认识信号主要包括两方面:一是能识别信号,二是知道该怎么处理信号。
进程与信号的关系
- 进程天生就具备处理信号的能力,这是它自带的功能。
- 就算还没收到信号,进程也清楚遇到信号时该怎么处理。
进程处理信号的特点
- 进程收到信号后,不一定马上就处理(可能在忙更重要的事)。
- 从信号出现到被处理有个时间段,进程得暂时记住 “有信号来了”,确保在这个时间段内处理它。
1.2 前台进程和后台进程
在Linux中,一次登录会话通常会为每个终端分配一个bash。在这一终端环境下,同一时刻只能有一个进程作为前台进程运行,它会占据终端的输入输出控制权并与用户直接交互;而后台进程则可以同时存在多个,它们在后台运行,不占用终端的交互控制权,用户可继续通过终端执行其他操作。
所以,前台进程和后台进程的核心区别在于是否占据终端输入输出控制权。
1.2.1 前台进程
当我们直接用 ./[进程名] 启动的进程会占据终端,此时终端的输入输出完全与该进程绑定。用户就算输入其他命令也是会被前台进程接收,不会被bash接收(因为它不再是前台进程了),直到进程结束或被暂停。若按下 ctrl + c ,会向前台进程发送 SIGINT 信号,通常会直接终止该进程,终端随后恢复到可输入状态。
1.2.2 后台进程
当我们用 ./[进程名] & 启动时, & 会让进程转入后台运行,终端会立即显示命令提示符(如 $ ),用户输入其他命令会正常执行并显示结果,因为此时前台进程还是bash。后台进程的输出可能仍会混杂显示在终端上,但 ctrl + c 此时仅作用于当前终端的bash,不会影响后台进程,需在另一个会话下用 kill 命令配合进程ID终止。
例如示例中, ./myprocess & 启动后终端迅速返回提示符,用户可输入 pwd 、 ll 等命令并正常执行,而后台进程持续输出的“I'm a process.Continue...”与这些命令的输入和输出混杂在一起。
这种现象的原因是:bash输入的内容会默认回显(键盘输入既传给进程,也同步给显示器打印),因此输入的命令能正常显示且最终被进程接收,这也解释了输入密码时不回显但仍能正常输入的情况(仅关闭了回显功能)。但后台进程和bash会同时访问显示器这一共享资源,且未做保护机制,导致两者的输出相互干扰、显示混乱,比如示例中用户输入“pw”时,后台进程的输出穿插其中,就体现了这种干扰。
1.3 linux下的信号列表
在 Linux 系统中,信号以数字形式存在,每个信号数字都对应着一个英文宏。需要注意的是,系统中共有 62 个信号,其中不存在 0、32、33 号信号,信号编号从 1 到 64(去除空缺后共 62 个)。
这些信号可分为两类:1-31 号为普通信号,34-64 号为实时信号。两者的主要区别在于,实时信号产生后需要被立即处理,而后续我们的讲解将以普通信号为主。
举个例子,为什么按下 ctrl+c 能杀掉前台进程?这是因为 ctrl+c 作为键盘输入,会被前台进程接收,其本质是进程收到了 2 号信号。而进程收到 2 号信号的默认动作,就是终止自身运行。
1.4 信号的3种处理方式
- 默认动作(退出)
- 忽略
- 自定义动作(信号的捕捉)
1.5 捕捉信号的接口 signal
#include <signal.h>typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
其中 sighandler_t 是通过 typedef 定义的函数指针类型,指向一个“参数为 int (表示收到的信号)、返回值为 void ”的函数(用于处理信号的回调函数)。
参数说明
signum :表示要处理的信号编号。
handler :自定义处理信号的方式,有三种取值:
- 自定义的信号处理函数(类型为 sighandler_t ),当信号发生时会调用该函数。
- 特殊常量 SIG_IGN :表示忽略该信号(不做任何处理)。
- 特殊常量 SIG_DFL :表示使用系统默认的处理方式(如终止进程、暂停进程等)。
返回值
- 成功时,返回之前设置的信号处理函数的指针( sighandler_t 类型)。
- 失败时,返回 SIG_ERR (一个特殊的常量),并设置 errno 以指示错误原因。
代码示例
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;typedef void (*sighandler_t)(int);// 自定义信号处理函数
// sigid: 接收到的信号编号
void sighandler(int sigid)
{cout << "Catch No." << sigid << " signal" << endl;
}
int main()
{// 注册SIGINT(中断信号,通常由Ctrl+C触发)的处理函数// 当进程接收到SIGINT信号时,将调用sighandler函数而非默认终止进程signal(SIGINT, sighandler);while (1){cout << "I'm a process.My pid is " << getpid() << ".Continue..." << endl;sleep(3);}return 0;
}
结论:信号的特点
1. 信号通过调用signal函数设置一次,往后都有效。
2. 信号的产生和自己的代码是异步的(信号的出现不受代码执行流程的控制,二者在时间上没有固定关联),属于软中断。
1.6 Ctrl+C 转换为信号的机制(键盘数据向内核的输入过程)
1.6.1 键盘与进程的访问限制(基于冯・诺依曼体系结构)
根据冯・诺依曼体系结构,键盘作为外部设备,不能被进程直接访问,只能由其管理者(操作系统)访问。当键盘按下某个键时,首先被操作系统知晓,而非直接传递给进程。
1.6.2 Linux 中键盘的文件抽象与数据输入流程
在冯・诺依曼体系结构中,操作系统开机时被加载到内存运行;Linux 系统遵循 “一切皆文件”,键盘被抽象为文件,拥有内核分配的文件描述符和对应的内核缓冲区。
操作系统通过定期检查该键盘文件知晓是否有数据输入,本质是将键盘硬件上的数据拷贝到该文件对应的缓冲区,完成输入过程。
用户可通过 read、scanf、fgets 等函数,以文件操作的方式将键盘文件中的数据读取到用户缓冲区。
1.6.3 键盘与 CPU 的交互:硬件层面的中断机制
数据层面上,CPU 不与外设直接打交道;但控制层面上,CPU 能读取外设状态,通过主板针脚与键盘、显示器、内存等设备间接连接。键盘有数据输入时,会在硬件层面给 CPU 发送硬件中断:操作系统无需专门关注键盘,一旦有数据输入,键盘会通过硬件单元转换信息并发送给 CPU,让操作系统知晓。
外设数据需经操作系统从外设拷贝到内存后才能被 CPU 处理,外设有数据时通过发送硬件中断触发这一过程(显示器、网卡等设备均如此)。不同设备的中断对应唯一中断号,设备通过与 CPU 连接的特定针脚发送高低电平信号传递中断号,CPU 识别针脚电平解读出中断号并记录对应设备信息。从硬件层面看,计算机仅识别二进制,CPU 寄存器通过充放电(1 为有电信号,0 为无电信号)保存数据,不理解数据具体意义;中断的高低电平会被 CPU 解释为二进制数据,形成可识别信息。
示例:键盘被分配中断号 1,有数据就绪时,通过与 CPU 连接的 1 号针脚发送高低电平,CPU 识别后解读为 32 位二进制中的 “000001”,结合 1 号中断对应键盘的规定,知晓是键盘有数据就绪(此时仅硬件层面知晓,未进行数据拷贝,后续由操作系统触发拷贝到内存供 CPU 处理)。
1.6.4 键盘数据处理:软件层面的中断向量表
软件层面,操作系统开机启动时会在靠前位置构建一张自身维护的中断向量表(本质是数组),表中存储直接访问外设(磁盘、显示器、键盘等)的方法地址,这些方法由操作系统预先实现,开机即存在,可理解为函数指针,指向操作外设的具体方法。
示例:读取键盘数据时,操作系统启动时已预设好对应处理方法;当键盘有数据(如按下回车),通过 CPU 针脚发送硬件中断,中断以充放电形式被 CPU 记录为中断号;CPU 收到中断后,操作系统识别中断号,以其为索引查询中断向量表,找到对应的键盘数据处理方法并执行(该方法将键盘数据从外设拷贝到内存)。由于每个外设有唯一中断号,且在中断向量表中注册了对应读取方法,操作系统无需主动检测外设,只需等待外设就绪时的中断,再按中断号执行相应方法完成数据拷贝,键盘等设备基于此中断机制工作。
信号是用软件方式对进程模拟的硬件中断,二者设计类似。
1.6.5 键盘输入的分类与处理逻辑
键盘按键分为输入类(纯字母、纯数字等)和控制类(组合键 ctrl + 字母等)。
操作系统从键盘拷贝数据前会先判断输入类型:
- 若为控制类输入(如 ctrl+c),不会通过 read 等系统调用让进程读取,而是转换为对应信号(如 ctrl+c 转换为 2 号信号)并发送给进程。
- 若为输入类数据,则按正常流程拷贝供进程读取。
1.6.6 设备文件与缓冲区:键盘与显示器的特性
显示器和键盘作为设备文件,各有对应的 struct file 结构体和独立的文件缓冲区。当用户通过键盘输入(如 “ls”),数据先写入键盘的文件缓冲区,之后操作系统将其拷贝到显示器文件缓冲区,这一过程称为回显;若未拷贝到显示器文件缓冲区,则为不回显。由于键盘和显示器是不同文件,使用不同的文件缓冲区。
回顾示例:./myprocess & 启动后,后台进程持续输出与用户输入的 “ls” 等命令的输入输出可能混杂,但不影响系统工作,因为用户输入 “ls” 时,键盘文件缓冲区会有序接收,操作系统最终能正确获取并执行。
2.信号的产生
2.1 键盘产生的信号
- 2 号信号:通过组合键
ctrl+c
触发。 - 3 号信号:通过组合键
ctrl+\
触发。 - 19 号信号:通过组合键
ctrl+z
触发(作用是暂停进程)。
注意:并非所有信号都能被 signal
捕捉,例如 19 号(暂停进程)和 9 号(杀死进程)信号不可被捕捉。
- 设计原因:
- 若所有信号都可被捕捉,恶意病毒或软件可能通过捕捉信号逃避操作系统的查杀,导致无法被终止。
- 对于执行重要工作的进程,即使暂时失控或用户想暂停其运行,因其重要性不能直接杀掉,但可通过 19 号信号暂停它,将影响降到最低。
2.2 通过 kill 命令产生信号
- 命令格式:kill -[信号编号] [进程pid]
- 作用:向指定 PID 的进程发送对应编号的信号。
2.3 系统调用接口
2.3.1 kill发送信号给进程
#include <sys/types.h>
#include <signal.h>int kill(pid_t pid, int sig);
参数:
- pid:进程pid
- sig:信号编号
返回值:
- 成功:返回0,表示信号已成功发送给目标进程。
- 失败:返回-1,并设置 errno 来指示具体错误原因,例如目标进程不存在( ESRCH )、没有发送信号的权限( EPERM )、信号编号无效( EINVAL )等。
代码示例:自定义信号发送命令
proc.cc
功能:每隔 2 秒输出一次自己的进程 ID(PID),形成一个无限循环,用于演示如何接收外部信号。
#include <iostream>
#include <unistd.h>
using namespace std;int main()
{while (1){cout << "I'm a process.My pid is " << getpid() << endl;sleep(2);}return 0;
}
mykill.cc
功能:向指定 PID 的进程发送指定编号的信号(类似系统 kill 命令)。
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;void usage(string procname)
{cout << "Usage: " << procname << " sigid pid" << endl;
}int main(int argc, char *argv[])
{if (argc != 3){usage(argv[0]);exit(1);}int sigid = stoi(argv[1]);pid_t pid = stoi(argv[2]);kill(pid, sigid);return 0;
}
2.3.2 raise 向当前进程发送指定信号
#include <signal.h>int raise(int sig);
参数:
- sig :需要发送的信号编号。
返回值:
- 成功发送信号时,返回0。
- 若信号编号无效,返回非0值。
代码示例:
myproc.cc:每 3 秒主动触发一次 2 号信号,执行自定义处理函数
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;// 自定义信号处理函数:捕获信号时打印信号编号
void sighandler(int sigid) {cout << "Catch No." << sigid << " signal" << endl;
}int main() {// 注册2号信号(SIGINT)的处理函数为sighandler// 当进程收到2号信号时,不再默认终止,而是执行sighandlersignal(2, sighandler);int cnt = 0;while (1) {cout << "I'm a process.My pid is " << getpid() << "." << endl;sleep(1);// 每3秒通过raise函数主动触发一次2号信号if (++cnt % 3 == 0) {raise(2); }}return 0;
}
2.3.3 abort 终止进程(对应6号信号)
#include <stdlib.h>void abort(void);
注意:通过kill命令向进程发送6号信号不会导致进程终止,但是通过调用abort函数发送6号信号会导致进程终止。
2.4 异常——硬件条件
进程异常本质是收到操作系统发送的信号,且被捕捉后不一定退出,可通过信号机制处理。
接下来会通过代码中/0和野指针的报错来分析异常。
2.4.1 示例1:除零(/0)错误的触发与处理
1. 硬件层面的异常标记
- CPU 执行指令时,通过状态寄存器记录计算状态(含预设标志位)。
- 除零操作会触发计算溢出,导致状态寄存器的对应标志位由 0 变为 1,这是硬件直接产生的异常标记。
- 这些寄存器数据属于当前进程的上下文(进程运行状态的一部分)。
2. 进程调度与异常隔离
- CPU 是共享资源,进程调度时会切换上下文:当前进程的寄存器状态(包括异常标志位)会被保存,待再次调度时恢复。
- 因此,单个进程的异常状态仅影响自身,其他进程运行时加载自身正常上下文,不受影响,保障了进程独立性。
3. 操作系统的介入流程
- 除零操作触发异常后,CPU 暂停当前执行,进入内核态异常处理。
- 操作系统识别到这是进程不可恢复的错误,会向其发送对应信号(如除零对应SIGFPE)。
- 操作系统通过终止异常进程避免错误扩散,维护系统稳定性(所有任务被进程包裹,异常局限在单个进程内)。
2.4.2 野指针错误的触发与处理
1. 本质:虚拟地址转换失败
- CPU 处理的是虚拟地址,虚拟地址与物理地址的映射关系存储在页表中,而地址转换由硬件 MMU(内存管理单元)执行(效率更高)。
- 野指针指向的虚拟地址在转换时会出现问题:地址损坏、无映射关系、越界 / 越权访问等,导致 MMU 转换失败。
- 此时,MMU 会报错,且 CPU 的CR2 寄存器会存储转换失败的虚拟地址,操作系统通过识别该硬件报错感知野指针错误。
2. 错误区分与进程独立性
- 操作系统通过 CPU 不同寄存器的反馈信息(如状态寄存器、CR2 寄存器),可区分溢出、越界等具体错误类型。
- 寄存器内容属于进程上下文,调度时会随进程带走,因此单个进程出错不影响其他进程。
3. 信号捕捉的特殊情况
- 若进程设置了信号捕捉,异常时可能不崩溃,但问题代码仍存在:
- 进程被调度时,上下文载入寄存器后,错误代码会再次触发硬件异常,导致进程被挂起并切换出去。
- 硬件异常持续存在,操作系统会不断向该进程发送信号。
示例代码:
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;// 信号处理函数:当收到信号时执行,打印信号编号并休眠1秒
void handler(int sigid)
{cout << "Catch a signal:" << sigid << endl;sleep(1);
}int main()
{// 注册SIGSEGV信号(11号信号,对应段错误,如野指针访问)的处理函数signal(SIGSEGV, handler);// 输出提示信息,表明野指针访问前的状态cout << "wild pointer before" << endl;// 休眠3秒,便于观察程序执行流程sleep(3);// 定义野指针(未初始化的指针,指向随机非法地址)int *ptr ;// 访问野指针指向的内存(非法操作,触发硬件异常)*ptr = 0;return 0;
}
运行结果:
由于进程设置了对 SIGSEGV 信号的捕捉,野指针访问引发硬件异常时进程不会直接崩溃,但问题代码(*ptr = 0)依然存在;当进程被调度时,其上下文(包含异常相关的寄存器状态)载入寄存器后,错误代码会再次触发硬件异常(MMU 地址转换失败),导致进程被挂起并切换出去,而硬件异常的持续存在使得操作系统不断向该进程发送 SIGSEGV 信号(11 号),最终表现为程序反复执行信号处理函数,持续输出 “Catch a signal:11”,形成无限循环,直至被强制终止。
2.5 软件条件——闹钟
异常并非仅由硬件产生,软件条件也可能引发异常,比如进程通信中,若管道的写端正常而读端关闭,操作系统会向写端发送SIGPIPE信号,这便是一种软件异常。类似地,软件条件下的闹钟功能也是通过软件机制产生信号的典型例子:程序可预先设定时间,当到达设定时间时,系统会触发相应的提醒信号,这一过程完全由软件逻辑控制,属于软件条件引发的信号产生场景。
2.5.1 alarm 接口
#include <unistd.h>unsigned int alarm(unsigned int seconds);
功能:设置一个定时器,当指定时间(秒数)到达后,系统会向当前进程发送 (14)SIGALRM 信号。
参数: seconds 为定时秒数,若为0则取消之前设置的闹钟。
返回值:返回之前设置的闹钟剩余秒数;若之前无未过期的闹钟,返回0。
示例代码:每5秒触发一次SIGALRM信号
#include <unistd.h>
#include <iostream>
#include <signal.h>
using namespace std;typedef void (*sighandler_t)(int);
void handler(int sigid)
{cout << "Catch No." << sigid << endl;alarm(5);// 再次设置5秒后触发闹钟,实现循环
}int main()
{signal(14, handler);alarm(5);// 首次设置5秒后触发闹钟while (1){cout << "I'm a process.My pid is " << getpid() << endl;sleep(1);}return 0;
}
运行结果:
操作系统对闹钟(alarm)的管理机制
- 背景:进程通过alarm函数设定闹钟后,操作系统需管理大量此类闹钟,确保超时后能准确触发信号。
- 核心数据结构:
- 每个闹钟对应一个结构对象,包含:
- 进程指针:闹钟触发时快速定位所属进程。
- 时间戳:记录设定的未来超时时间,用于判断是否超时(当前时间≥设定时间即视为超时)。
- 所有闹钟对象通过最小堆管理。
- 每个闹钟对应一个结构对象,包含:
- 管理逻辑(基于最小堆特性):
- 若堆顶元素未超时:堆中所有元素均未超时,无需遍历全部节点。
- 若堆顶元素超时:
- 执行对应操作(如向进程发送闹钟信号);
- 移除堆顶元素,调整堆结构,使下一个最近可能超时的元素成为新堆顶;
- 重复上述步骤,直至堆中无超时元素。
2.5.2 补充:关于code dump
- core dump 是什么?
在 1-31 号主要信号中,大部分属于终止信号,这类信号又细分为 Term(终止)和 Core(核心转储)两种类型,仅有少数信号可用于进程的个性化处理,如暂停(Stop)、忽略(Ign)、继续(Cont)等。当进程因信号终止时,其终止状态会被记录,这与进程等待机制密切相关 —— 通过 waitpid 函数获取的 status 参数(共 32 位,实际有效为低 16 位),就包含了进程终止的关键信息。
status 参数的低 16 位中,8-15 位用于存储进程正常退出时的退出码,而 0-7 位则记录了进程异常终止时收到的信号。值得注意的是,其中还包含一个 1 位的核心转储(core dump)标志,正是这个标志区分了进程终止时采用的是 Term 方式还是 Core 方式,这也引出了核心转储这一特殊的进程终止行为。
云服务器 core 功能的默认状态与配置方式
默认情况下,云服务器的 core dump 功能处于关闭状态,通过ulimit -a命令可查看相关设置,其中core file size显示为 0 即表示功能关闭。若需开启,可执行ulimit -c 1024命令,将core file size设置为 1024,此时功能即被启用。
云服务器默认关闭 core 功能的原因
主要出于线上服务稳定性考虑:core dump 会在进程异常时生成较大的核心转储文件,而线上服务器集群依赖自动化运维(服务挂掉后立即重启恢复),后续通过日志排查问题即可保障持续可用。但如果服务频繁崩溃,每次崩溃生成的 core 文件可能短时间占满磁盘,甚至导致操作系统故障,影响远超服务暂时中断。因此,生产环境通常关闭该功能;不过腾讯云、阿里云等平台允许手动开启,以满足用户将服务器作为开发机使用的需求。
验证代码
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;int main()
{pid_t id = fork();if (id == 0){// childint cnt = 500;while (cnt){cout << "I'm a child.My pid is " << getpid() << ".cnt:" << cnt << endl;sleep(1);--cnt;}exit(1);}else if (id > 0){// fatherint status = 0;pid_t ret = waitpid(id, &status, 0);if (ret == id){cout << "Child's pid:" << id << " Exit code:" << ((status >> 8) & 0XFF) << " Exit signal:" << (status & 0x7f) << " Core dump:" << ((status >> 7) & 1) << endl;}else{perror("waitpid");}}else{perror("fork");}return 0;
}
编译运行后,输入 kill -2 [子进程pid] 会显示core dump为0,kill -8 [子进程pid] 会显示core dump为1。说明信号类型为Term(如SIGINT)的信号默认终止进程但不生成core dump,而Core类型(如SIGFPE)的信号会在终止进程时触发核心转储,导致Core dump标志位为1。
一旦开启core dump功能,当进程发生异常时,操作系统会将该进程在内存中的运行信息转储(dump)到进程当前所在的目录(磁盘),生成名为core.[进程pid]的文件,这一过程即称为核心转储(core dump)。
- 为什么要核心转储?
核心转储针对的是运行时错误,可用于定位程序运行过程中出错的具体行。
- 如何进行核心转储?
通过编译生成可调试文件后(带-g参数),若程序运行出错并显示“(core dumped)”,说明已生成核心转储文件。此时,输入 gdb 程序名 打开调试,再用 core-file 命令导入核心转储文件,就能直接查看出错的行数。这种先运行程序、发生错误后再通过核心转储文件进行调试的方式,称为事后调试。
3.信号的发送与保存
3.1 信号的发送(针对 1-31 号普通信号)
3.1.1 信号的存储与表示
进程的 PCB(task_struct)中通过一个 32 位的signal整数(位图)管理普通信号,每个比特位对应一个信号:
- 比特位的0/1 状态:表示是否收到该信号;
- 比特位的位置:对应信号编号(如第 1 位为 1 表示收到 1 号信号)。
3.1.2 发信号的本质
其实是操作系统修改目标进程task_struct中signal位图的对应比特位(即 “写信号”)。
为何只能由操作系统发送:操作系统是进程的管理者,拥有修改进程数据(如task_struct)的唯一权限,确保信号发送的可控性和安全性。
3.2 信号的保存
3.2.1 信号处理的核心概念与机制
信号的时间窗口:进程收到信号后可能不会立即处理,存在 “信号未立即处理” 的时间窗口。
关键概念:
- 信号抵达(Delivery):实际执行信号处理动作的过程。
- 信号未决(Pending):信号从产生到处理之间的状态。(不抵达/等待处理)
- 阻塞(Block):进程可选择屏蔽某个信号(类似 “拦截”),被阻塞的信号会保持未决状态,直至解除阻塞才会处理(递达)。
- 阻塞 vs 忽略:阻塞是信号未抵达(不触发处理),忽略是信号抵达后的一种处理方式(已触发但不执行操作)。
- 信号产生时,内核在进程控制块中设置其未决标志,直至递达后清除。
信号的管理结构(针对 1-31 号信号):
- pending 表:记录进程是否收到某个信号(修改该表即 “发送信号”)。
- block 表:记录信号是否被屏蔽(0 = 不屏蔽,1 = 屏蔽)。
- handler 表:存储每种信号对应的处理方法,信号抵达时内核会查表调用。
3.2.2 数据类型和相关接口
下面所有接口的头文件均为 <signal.h>
sigset_t
sigset_t是一种用位图(每个bit对应一个信号)表示信号集合的数据类型,bit的状态(0或1)表示信号是否在集合中。
sigempty 函数
int sigemptyset(sigset_t *set);
- 功能:清空信号集(位图全部置0)。
- 参数:信号集的地址。
- 返回值:若成功,返回0;若失败,返回-1,并会设置 errno 。
sigfillset 函数
int sigfillset(sigset_t *set);
- 功能:信号集位图全部置1。
- 参数: 信号集的地址。
- 返回值:成功时返回0,失败时返回-1,同时设置 errno 。
sigaddset 函数
int sigaddset(sigset_t *set, int signum);
- 功能:将指定的信号添加到信号集中(指定信号置1)。
- 参数:
- set :信号集的地址。
- signum :要添加的信号编号(像 SIGINT 、 SIGTERM 这类)。
- 返回值:成功返回0,失败返回-1,并设置 errno 。
sigdelset 函数
int sigdelset(sigset_t *set, int signum);
- 功能:从信号集中删除指定的信号(指定信号置0)。
- 参数:
- set :信号集的地址。
- signum :要删除的信号编号。
- 返回值:成功返回0,失败返回-1,同时设置 errno 。
sigismember 函数
int sigismember(const sigset_t *set, int signum);
- 功能:检查某个信号是否是信号集的成员。
- 参数:
- set :信号集的地址。
- signum :要检查的信号编号。
- 返回值:
- 若信号是信号集的成员,返回1。
- 若信号不是信号集的成员,返回0。
- 若传入无效的信号编号,返回-1,并设置 errno 。
sigprocmask 函数
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
- 功能:读取或更改进程的信号屏蔽字(控制阻塞信号集block表)
- 参数:
- how:指定修改信号掩码的方式,有3种取值:
- SIG_BLOCK :将 set 中的信号添加到当前信号掩码中(即阻塞这些信号)。
- SIG_UNBLOCK :从当前信号掩码中移除 set 中的信号(即解除对这些信号的阻塞)。
- SIG_SETMASK :直接将当前信号掩码设置为 set 中的信号(覆盖原掩码)。
- set:信号集的地址,用于指定要添加、移除或设置的信号。
- oldset:指向一个 sigset_t 类型的变量,用于保存修改前的信号掩码。
- how:指定修改信号掩码的方式,有3种取值:
- 返回值:
- 成功:返回 0。
- 失败:返回 -1,并设置 errno (常见错误为 EINVAL ,表示 how 参数无效)。
sigpending 函数
int sigpending(sigset_t *set);
- 功能:获取当前进程中处于“待处理状态”的信号集(将pending表带出来方便做检查)
- 参数:信号集地址
- 返回值
- 成功:返回 0。
- 失败:返回 -1,并设置 errno (常见错误为 EINVAL ,表示 set 为 NULL 等无效参数)。
signal 函数
handler表中信号与处理方法的匹配可通过signal函数来实现,即该函数用于将特定信号与对应的处理方法关联起来,从而填充handler表。
示例代码:屏蔽 SIGINT 信号(2 号)并每秒打印一次未决信号集合
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;// 信号处理函数:当接收到指定信号时被调用
void handler(int sigid)
{cout << "Catch a signal:" << sigid << endl;
}// 打印当前未决信号集合:遍历1-31号信号,输出每个信号的未决状态(1表示未决,0表示已处理)
void printPending(sigset_t &pending)
{for (int i = 31; i >= 1; --i){if (sigismember(&pending, i)){cout << 1;}else{cout << 0;}}cout << endl;
}int main()
{// 注册SIGINT(2号信号)的处理函数signal(2, handler);// 创建信号集并设置屏蔽SIGINTsigset_t set;sigemptyset(&set); // 清空信号集sigaddset(&set, 2); // 将SIGINT添加到信号集中sigprocmask(SIG_BLOCK, &set, nullptr); // 屏蔽信号集中的信号sigset_t pending;int cnt = 0;// 每秒打印一次当前未决信号集合while (1){int ret = sigpending(&pending); // 获取当前未决信号集合if (ret < 0)continue;printPending(pending); // 打印未决信号位图sleep(1);++cnt;// 20秒后解除对SIGINT的屏蔽if (cnt == 20){cout << "unblock 2 sigNo" << endl;sigprocmask(SIG_UNBLOCK, &set, nullptr);}}return 0;
}
在 20 秒内通过kill -2发送信号时,信号会被阻塞且未决标志置 1(打印 1);20 秒解除屏蔽后,信号立即被处理(触发 handler 函数),未决标志清零。
注意:9号和19号信号无法被屏蔽。
4.信号的捕捉处理
信号产生后不会立即处理,要等进程从内核态返回用户态时,先由内核在内核态检测是否有未处理信号;若有,默认处理(如终止进程)由内核在内核态完成,自定义处理则让进程回到用户态执行对应函数,之后再继续原流程。简单说就是:内核态负责在特定时机检测信号,处理则分情况——默认的内核做,自定义的回用户态做。
进程执行时,CPU 调度的代码不仅包括用户自己编写的部分,还涉及操作系统和库中的代码。由于操作系统对所有用户保持不信任态度,许多操作需要通过身份切换才能执行:通常,用户编写的代码(包括库代码)主要在用户态直接运行;而在调用系统调用等场景中,进程会从用户态陷入内核态,由内核介入完成用户态无权执行的操作(如 IO、进程管理等),执行完毕后再返回用户态继续运行,Linux 中int 80就是一种让程序从用户态进入内核态的方式。
接下来我们会对上面设计的一下概念和原理进行详细讲解。
4.1 再谈进程地址空间与页表映射
操作系统是计算机最先加载的软件,通常位于物理内存靠前的区域。
4.1.1 地址空间映射机制
进程地址空间分为用户空间(0~3GB)和内核空间(3~4GB),分别通过两类页表与物理内存映射:
- 内核级页表:仅 1 份,映射物理内存中操作系统的代码和数据,因此所有进程看到的 3~4GB 内核空间内容完全一致,且不随进程切换改变。
- 用户级页表:每个进程独立拥有 1 份(体现进程独立性),映射物理内存中进程自身的代码和数据,因此不同进程的用户空间相互隔离。
4.1.2 系统调用的执行视角
- 进程视角:调用系统方法时,在自身地址空间中执行(从用户空间代码进入内核空间代码,完成后返回用户空间)。
- 操作系统视角:任何时刻都有进程在运行,可随时通过内核级页表执行操作系统代码(因内核空间对所有进程一致)。
4.2 操作系统的本质:基于时钟中断的死循环
硬件基础:计算机中有一个时钟芯片,会以短时间间隔向系统发送时钟中断;时钟通过连接 CPU 针脚,将信号传递给 CPU 中专门接收时钟中断的寄存器。
中断响应流程:当时钟中断信号到达 CPU,接收时间中断的寄存器会触发 操作系统 执行中断向量表中对应的操作系统处理逻辑(即中断对应的方法,属于操作系统代码)。
操作系统的核心运行逻辑:
- 操作系统通过一个死循环持续检测 CPU 是否收到时钟中断。
- 一旦中断到来时,CPU 执行对应处理方法(以进程调度为例:检查当前进程时间片,未到则直接返回;已到则剥离 CPU,调度新进程运行)。
- 时钟中断再次到来时,重复上述过程。
核心结论:操作系统的运行本质是被动的,完全由时钟这一硬件驱动,而非主动持续运行。
4.3 用户态和内核态(CPU的两种工作模式)
用户态和内核态是 CPU 的两种工作模式,由 CPU 的指令指针寄存器(如 ecs 寄存器)的低两位状态区分:
- 内核态:低两位为 00,允许访问操作系统的代码和数据;
- 用户态:低两位为 11(即 3),只能访问用户自己的代码(包括库代码),禁止访问内核代码。
两种状态的切换由 CPU 控制:需访问内核时,通过int 80(Linux 中陷入内核的方式)让 CPU 将 ecs 寄存器低两位从 11 改为 00,进入内核态;内核态任务完成后,CPU 将低两位改回 11,返回用户态。
4.4 信号捕捉流程
4.5 sigaction函数
#include <signal.h>int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);//sigaction结构体
struct sigaction {void (*sa_handler)(int); //捕捉信号的处理方法sigset_t sa_mask;//处理信号时阻塞的信号集int sa_flags;//默认设为0void (*sa_sigaction)(int, siginfo_t *, void *);//用来处理实时信号,不用关心void (*sa_restorer)(void);//不关心
};
功能:用于查询或修改指定信号的处理动作
参数:
- signum:要操作的信号编号。特殊值 0 用于检测函数有效性,不对应任何信号。
- act(输入参数):输入型参数,用于设置新的信号处理方式。若为 NULL ,则仅查询当前处理方式(不修改)。
- oldact(输出参数):输出型参数,用于存储信号原来的处理方式。若为 NULL ,则不获取旧设置。
返回值:
- 成功时返回 0;
- 失败时返回 -1,并设置errno来指示具体错误原因(如无效信号、权限不足等)。
代码示例:
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <string.h>using namespace std;void sig_handler(int signo)
{cout << "正在处理" << signo << "号信号,请稍等..." << endl;sleep(3);
}int main()
{struct sigaction act;act.sa_handler = sig_handler;sigemptyset(&act.sa_mask);sigaddset(&act.sa_mask, 3);sigaction(2, &act, nullptr);while (true){cout << "I'm a process.My pid is " << getpid() << endl;sleep(1);}return 0;
}
运行程序后,按 Ctrl+C (发送 SIGINT ),会进入 handle_sigint 的 3 秒处理过程。在这 3 秒内按 Ctrl+\ (发送 SIGQUIT ),此时 SIGQUIT 会被阻塞,不会立即生效。待 SIGINT 处理完成后,被阻塞的 SIGQUIT 会立即触发(默认行为是终止程序)。
通过这个例子可以直观看到: sigaction 不仅能设置处理函数,还能通过 sa_mask 精确控制信号处理期间的阻塞信号。
4.6 未决信号位图中信号位的清零时机
当一个信号产生但尚未被处理时,内核会将该信号在进程的“pending位图”中标记为1(表示“未决”,即等待处理)。而当内核准备调用该信号的处理函数时(执行信号捕捉方法之前),会先将pending位图中该信号对应的位从1改为0(表示“已处理”,不再标记为未决),然后才执行用户定义的信号处理函数。
4.7 信号处理期间的自屏蔽机制(防止处理函数嵌套调用)
当进程收到某个信号,内核准备调用其处理函数时,会自动把这个信号添加到进程的“信号屏蔽字”中。这意味着,在处理函数执行期间,如果再次收到该信号或其他信号,内核会暂时“屏蔽”它(不立即处理,标记为未决),直到处理函数执行完毕。当信号处理函数执行完返回时,内核会自动把之前添加的信号从屏蔽字中移除,恢复成处理函数执行前的屏蔽状态。这样后续再收到该信号时,就能正常触发处理了。
这个机制的作用是防止信号处理函数被自身重复打断,比如避免 SIGINT 的处理函数还在执行时,又收到新的 SIGINT 或其他信号导致嵌套调用,引发逻辑混乱。
5.可重入函数
如果一个函数在被重复进入时会出错或可能出错,则为不可重入函数;反之,在被重复进入时不会出错的函数则为可重入函数。
目前我们只需要知道:当main函数正常执行时,若收到信号并触发信号捕捉函数,此时main函数的执行流与信号捕捉函数的执行流是两个独立的、交替运行的“控制路径”。
更多详细内容会在线程部分讲解到。
6.volatile 关键字
6.1 作用
保持内存可见性。
6.2 具体场景(如何理解volatile关键字的作用)
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;// 全局变量flag:用于标记是否收到信号
int flag = 0;// 信号处理函数:收到信号后修改flag的值
void handler(int signo)
{cout << "Catch a signal:" << signo << endl;flag = 1; // 异步修改全局变量flag
}int main()
{// 注册SIGINT信号(2号信号,Ctrl+C触发)的处理函数signal(2, handler);// 循环等待flag变为1:while (!flag);// 当flag变为1时,退出循环并结束程序cout << "Process quit now!" << endl;return 0;
}
CPU 执行计算分两类:一类是算术计算(像 +、-、*、/、% 这些操作符参与的运算 ),另一类是逻辑运算(判断真假、与或非等,也是在 CPU 里执行 )。而这两类运算要先把数据读到 CPU 寄存器,才能开展运算 。
编译器在编译代码时,会做 “优化” 来提升程序运行效率 。对于 main 函数里的 while(!flag) 循环,它的行为是单纯检测 flag 的值(进行逻辑运算判断真假),且循环内不修改 flag 。
对于 while(!flag) 里的逻辑判断(!flag 这一逻辑计算 ),因为本质是逻辑运算,CPU 得先把 flag 的值读到寄存器,才能完成 “判断 flag 是否为 0,再取反” 的操作。
编译器发现:整个 while 循环过程,只是反复用 flag 的值做逻辑判断,没有代码去修改 flag 在内存里的原始值(信号处理函数修改属于 “异步干扰”,编译器按常规流程分析时,可能没考虑这种异步场景 )。于是,为了让后续逻辑运算更快,编译器就可能把 flag 直接优化到 CPU 的寄存器里 ,后续 while 循环检测 !flag 时,直接从寄存器读值做判断,不再每次都从内存重新加载 flag 。
正常逻辑是:信号处理函数收到信号后,修改内存里 flag 的值,让 main 函数的 while 循环条件不满足,从而退出循环。但因为编译器把 flag 优化到寄存器,信号处理函数实际修改的是内存里的 flag ,可 main 函数循环检测的是寄存器里缓存的 flag 旧值 ,就会出现 “信号处理函数明明改了 flag,main 函数却像没感知到,循环退不出去” 的问题 。
所以,为避免这种优化影响,通常要用 volatile 修饰 flag ,强制编译器每次从内存读取 flag 的真实值 。
6.3 g++编译器的优化级别
不同的编译器优化级别不同,在g++编译下可能正常退出,是因为其默认优化级别较低(如-O0)时,不会将 flag 优化到寄存器, main 函数会每次从内存读取 flag ,从而能感知到信号函数对内存中 flag 的修改,使循环正常退出。
补充:GCC/G++中常见的优化级别主(按优化程度从低到高):
- -O0:默认级别,不进行优化,编译速度快,便于调试(保留更多调试信息)。
- -O1(-O):基础优化,优化编译时间和执行速度,不增加太多编译开销。
- -O2:更全面的优化,启用更多优化算法(如循环展开、指令重排等),执行效率更高,编译时间更长。
- -O3:在-O2基础上增加更激进的优化(如函数内联、向量优化等),可能提升性能,但也可能因过度优化引发问题。
如果我们在编译时,带上-O3参数,可能无法正常退出。
7.SIGCHLD信号(选学)
7.1 从进程等待引入
进程等待是父进程等待子进程结束并回收资源的操作。父进程并不知道子进程具体的结束时间,所以只能一直以阻塞或非阻塞(轮询)的方式通过wait/waitpid检测子进程是否结束。
回顾调用wait/waitpid的好处:获取子进程退出状态,释放子进程的僵尸。
虽然不知道父子谁先运行,但是我们知道一定是父进程最后退出。所以父进程必须保证自己一直在运行!
子进程的退出并不是静悄悄的退出,而是会向父进程发送信号的,而(17) SIGCHLD 信号是子进程终止时,内核自动向父进程发送的信号。所以,可以子进程在等待的时候,可以采用基于信号的方式进行等待。
7.2 基于 SIGCHLD 信号的异步等待(高效方案)
核心思路:父进程捕获 SIGCHLD 信号,在信号处理函数中调用wait()/waitpid()回收子进程资源。
优势:无需阻塞或轮询,子进程结束时主动触发回收,适合多子进程并发场景,避免僵尸进程产生。
示例代码:
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;// 信号处理函数,用于处理子进程退出信号SIGCHLD
void handler(int signo)
{sleep(1); // 休眠1秒,模拟一些处理时间int status = 0; pid_t pid; // 循环回收所有已退出的子进程,WNOHANG表示非阻塞模式while ((pid = waitpid(-1, &status, WNOHANG)) > 0){// 判断子进程是否正常退出if (WIFEXITED(status)){cout << "子进程" << pid << "正常退出,退出码是" << WEXITSTATUS(status) << endl;}// 判断子进程是否被信号终止else if (WIFSIGNALED(status)){cout << "子进程" << pid << "被信号" << WTERMSIG(status) << "终止!" << endl;}}
}int main()
{// 注册SIGCHLD信号的处理函数,当子进程退出时会触发此信号signal(SIGCHLD, handler);// 创建3个子进程for (int i = 1; i <= 3; ++i){pid_t id = fork(); // 创建子进程if (id == 0){// 子进程执行的代码cout << "子进程" << getpid() << "启动,将休眠" << i << "秒..." << endl;sleep(i); // 子进程休眠i秒exit(i); // 子进程退出,退出码为i}else if (id == -1){perror("fork error");exit(9);}}// 父进程执行的代码cout << "父进程" << getpid() << "启动,等待子进程退出..." << endl;sleep(10); // 父进程休眠10秒,等待所有子进程退出cout << "父进程已回收所有进程,退出!" << endl;return 0;
}
程序启动后,父进程会创建 3 个子进程,每个子进程会分别休眠 1 秒、2 秒和 3 秒,当子进程退出时,会触发SIGCHLD信号,父进程的信号处理函数会被调用,信号处理函数会循环回收所有已退出的子进程。
7.3 SIGCHLD 信号的处理方式及差异
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。
示例代码:
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;int main()
{signal(SIGCHLD, SIG_IGN);for (int i = 1; i <= 3; ++i){pid_t id = fork();if (id == 0){cout << "子进程" << getpid() << "启动,将休眠" << i << "秒..." << endl;sleep(i);exit(i);}else if (id == -1){perror("fork error");exit(9);}}while(true){cout << "父进程" << getpid() << "启动,等待子进程退出..." << endl;sleep(1);}return 0;
}
(1)设置为忽略(SIG_IGN)
- 操作:通过signal(17, SIG_IGN);或sigaction将 SIGCHLD 的处理动作设为SIG_IGN。
- 内核行为:Linux 内核会触发特殊处理逻辑,主动清理子进程的进程控制块(PCB)等资源,无需父进程调用wait()/waitpid()。
- 结果:子进程终止后不会成为僵尸进程,系统自动完成资源回收(此行为在 Linux 有效,不保证所有 UNIX 系统通用)。
(2)默认行为(SIG_DFL)
- 操作:未显式设置 SIGCHLD 的处理方式时,信号按默认行为处理。
- 内核行为:仅 “忽略信号通知”,不自动回收子进程资源。
- 结果:若父进程未手动调用wait()/waitpid(),子进程会成为僵尸进程(资源未释放)。
(3)关键差异
处理方式 | 资源回收方式 | 是否产生僵尸进程 | 依赖父进程调用wait() /waitpid() |
---|---|---|---|
SIG_IGN | 内核自动回收 | 否 | 否 |
SIG_DFL | 需手动回收 | 是(若未手动回收) | 是 |