当前位置: 首页 > news >正文

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.  默认动作(退出)
  2. 忽略
  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函数设定闹钟后,操作系统需管理大量此类闹钟,确保超时后能准确触发信号。
  • 核心数据结构:
    • 每个闹钟对应一个结构对象,包含:
      • 进程指针:闹钟触发时快速定位所属进程。
      • 时间戳:记录设定的未来超时时间,用于判断是否超时(当前时间≥设定时间即视为超时)。
    • 所有闹钟对象通过最小堆管理。
  • 管理逻辑(基于最小堆特性):
  • 若堆顶元素未超时:堆中所有元素均未超时,无需遍历全部节点。
  • 若堆顶元素超时:
  1. 执行对应操作(如向进程发送闹钟信号);
  2. 移除堆顶元素,调整堆结构,使下一个最近可能超时的元素成为新堆顶;
  3. 重复上述步骤,直至堆中无超时元素。

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  类型的变量,用于保存修改前的信号掩码。
  • 返回值:
    • 成功:返回 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需手动回收是(若未手动回收)
http://www.lryc.cn/news/607093.html

相关文章:

  • Docker学习其二(容器卷,Docker网络,Compose)
  • cocosCreator2.4 googlePlay登录升级、API 35、16KB内存页面的支持
  • 特征工程 --- 特征提取
  • (一)LoRA微调BERT:为何在单分类任务中表现优异,而在多分类任务中效果不佳?
  • 【C++】类和对象 上
  • 逻辑回归算法中的一些问题
  • Leetcode——53. 最大子数组和
  • elementui中rules的validator 用法
  • 在线教程丨全球首个 MoE 视频生成模型!阿里 Wan2.2 开源,消费级显卡也能跑出电影级 AI 视频
  • Windows11 WSL安装Ubntu22.04,交叉编译C语言应用程序
  • 网站建设服务器从入门到上手
  • 《n8n基础教学》第一节:如何使用编辑器UI界面
  • 如何优雅删除Docker镜像和容器(保姆级别)
  • 服务器地域选择指南:深度分析北京/上海/广州节点对网站速度的影响
  • FreeSWITCH与Java交互实战:从EslEvent解析到Spring Boot生态整合的全指南
  • 分布式弹幕系统设计
  • Git 误删分支怎么恢复
  • ABP VNext + Dapr Workflows:轻量级分布式工作流
  • stl的MATLAB读取与显示
  • Blender 4.5 安装指南:快速配置中文版,适用于Win/mac/Linux系统
  • 【Mysql】字段隐式转换对where条件和join关联条件的影响
  • 安全专家发现利用多层跳转技术窃取Microsoft 365登录凭证的新型钓鱼攻击
  • 基于Pipeline架构的光存储读取程序 Qt版本
  • 九、Maven入门学习记录
  • 学习游戏制作记录(各种水晶能力以及多晶体)8.1
  • k8s之NDS解析到Ingress服务暴露
  • Wisdom SSH开启高效管理服务器的大门
  • Git之远程仓库
  • 【全网首个公开VMware vCenter 靶场环境】 Vulntarget-o 正式上线
  • Linux(15)——进程间通信