【Linux】进程信号(1)_信号产生

hello~ 很高兴见到大家! 这次带来的是Linux系统中关于进程间通信这部分的一些知识点,如果对你有所帮助的话,可否留下你宝贵的三连呢?
个 人 主 页 : 默|笙


文章目录

  • 一、快速认识信号
    • [1.1 生活中的信号](#1.1 生活中的信号)
    • [1.2 信号的生命周期](#1.2 信号的生命周期)
    • [1.3 见一见信号](#1.3 见一见信号)
    • [1.3 函数signal](#1.3 函数signal)
  • 二、信号产生
    • [2.1 通过系统命令触发](#2.1 通过系统命令触发)
    • [2.2 通过键盘触发](#2.2 通过键盘触发)
    • [2.3 由硬件异常触发](#2.3 由硬件异常触发)
    • [2.4 核心转储](#2.4 核心转储)
    • [2.5 通过系统调用产生](#2.5 通过系统调用产生)
    • [2.6 由软件条件产生](#2.6 由软件条件产生)

一、快速认识信号

1.1 生活中的信号

  1. 首先,要明确信号和信号量毫无关系,二者就像 "老婆" 和 "老婆饼" 一样,只是名字相近,本质完全不同。
  2. 它和现实生活中的信号很像:红绿灯、狼烟、闹钟等都是信号,信号一旦产生,我们就知道该做什么。也就是说,信号的处理方式,在信号产生之前就已经提前设定好了
  3. 处理信号必须立刻执行吗?并不是。就像现实生活中,我们一般会先把手头的事做完,有空了再去处理信号,只有特别紧急的信号才会打断当前操作。比如你正在打一局游戏,中途点的外卖到了,外卖员打电话通知你,这通电话就是一个信号。你通常不会立刻暂停游戏去拿外卖,而是等这局游戏打完再去处理。
  4. 信号的处理方式主要有三种:默认、忽略与自定义。我们还拿上面外卖的例子打比方:默认处理:就是正常去拿外卖、吃掉;忽略处理:就是当作没听见,完全不去理会;自定义处理:就是不按常规取餐,而是自己设定一套行为,比如接到通知后先唱首歌。

操作系统层面,大部分信号的默认处理行为,都是终止触发该信号的对应进程。

  1. 而至于进程如何识别信号,是由程序员预先内置好的,属于进程自带的机制。
  2. 快递送达这件事,和我们打游戏是异步的。外卖员送外卖,并不会干扰、打断我们玩游戏。你无法知道外卖员什么时候会给我们打电话。同理,信号就是由外部进程、用户或硬件向进程发送的一种异步事件通知机制。其中,通知就是告诉进程发生了什么事;异步就是这些事件之间互不影响、互不阻塞;这里的 "事件",具体涵盖进程异常、外部强制终止指令、硬件触发通知等多种场景。

1.2 信号的生命周期

  1. 信号的生命周期有三个阶段,首先是信号产生,再就是信号保存,最后是信号处理。
  2. 为什么需要信号保存?因为信号通常不会被立即处理。还是用外卖的例子:如果不把外卖员通知送达这件事记下来,我们很可能直接忘掉,也就没法在空闲时去处理它了。程序中的信号机制也是如此。

1.3 见一见信号

  1. 快速查看所有信号列表:执行 kill -l 命令,该命令会列出系统支持的所有信号编号及对应的名称;查看信号的详细说明:执行 man 7 signal,该命令可查看每个信号的含义、默认处理行为、是否可被捕获 / 忽略等核心信息。
  1. 其中,1~31 号为普通信号(非实时信号),也是我们日常开发和运维中最常用的信号;34 号及以后的为实时信号,在普通场景下基本用不到。信号编号从1开始,没有0号信号。
  1. 用 man 7 signal 指令就可以看到每个信号的默认处理方式了。

1.3 函数signal

介绍

  1. signal函数是用来自定义信号的处理方式的,使用它需要包头文件 signal.h。
  2. 它的第一个参数是目标信号的编号(如 SIGINT 对应 2,可直接写数字 2 或宏定义 SIGINT);传递一个函数指针(该函数需满足「入参为 int 类型,返回值为 void」的格式),指向你自定义的信号处理函数返回值:调用成功时返回该信号原先的处理方式(也是同类型的函数指针);调用失败时返回 SIG_ERR
  3. 为什么自定义的信号处理函数必须设计成「入参为 int 类型」?核心原因是:这个 int 类型的形参,专门用来接收触发该处理函数的信号编号------ 毕竟我们可以把多个不同信号的处理逻辑都绑定到同一个自定义函数上,函数需要通过这个编号区分 "到底是哪个信号被触发了",进而执行对应的逻辑(比如对 SIGINT 做中断处理,对 SIGUSR1 做自定义业务处理)。
  4. 从代码实现层面来看,信号的本质其实是宏定义(宏常量) ------Linux 系统中所有信号(如 SIGINT、SIGKILL 等),在 <signal.h> 头文件里都是以数字常量的形式通过 #define 定义的宏,而非复杂的结构体或函数。

使用

cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>

void handler(int signo)
{
    std::cout << "收到了一个信号" << signo << "who:" << getpid() << std::endl;
    exit(12);
}
int main()
{
    signal(2, handler);
    while (true)
    {
        std::cout << "进程运行中..." << std::endl;
        sleep(1);
    }
    return 0;
}
  1. 修改了2号信号的处理方式后再向目标进程发2号信号之后,果然按照我们设置的处理方式执行了,退出码为我们设置的12。如果我们不设置 exit 进行退出,那么发送2号信号之后就不会退出了。

小点

  1. 倘若我们把所有信号的处理方式都改成自定义函数,且在函数中不主动终止程序,那是不是这个进程就无法被信号终止了?其实设计信号机制的开发者早就考虑到了这个问题 ------ 因此系统保留了少数 "不可被捕获 / 修改" 的核心信号,比如 9 号 SIGKILL(强制杀死进程)、19 号 SIGSTOP(暂停进程),这类信号的处理逻辑被内核固定,无法通过 signal() 等函数自定义,确保管理员能强制管控进程。
  2. 信号的处理工作由谁来做?是否需要创建新进程?答案是:无需创建任何新进程,信号的处理逻辑完全由目标进程自身执行。还是上面那个例子,外卖到了之后取外卖这个动作是你自己做的。
  3. signal() 函数不仅能自定义信号的处理方式,也能将信号恢复为默认逻辑或设置为忽略 ------ 只需给第二个参数传入系统预设的宏值:SIG_DFL(恢复默认处理)、SIG_IGN(设置为忽略信号)

二、信号产生

信号产生有多种方式:

  1. 用系统命令kill产生。
  2. 用键盘产生。
  3. 由硬件异常产生。
  4. 由系统调用产生。
  5. 由软件条件产生。
    接下来会一一进行讲解。

2.1 通过系统命令触发

  1. 通过系统命令kill触发,kill + 信号编号 + 进程pid。这个就不再细讲了。

2.2 通过键盘触发

  1. ctrl + c 是向目标进程发送2号信号,默认是终止进程。

  2. ctrl + \ 是向目标进程发送3号信号,默认是终止进程,并生成core dump,core dump之后会进行讲解。

  3. ctrl + z 是向目标发送20号信号,默认就是暂停进程。

  4. 终端键盘快捷键(Ctrl+C/+Z)发出的信号,只作用于前台进程组。只有前台进程组能占用终端、接收键盘输入与信号。后台进程组收不到这些键盘信号。同一个终端里,前台进程组同一时刻只有一个,后台进程组可以有多个。想给后台进程发信号,不能用键盘,必须用 kill 命令。

  5. bash 进程自己不对键盘信号做出响应,这是因为 bash有选择地忽略了终端相关信号。但是像 9 号这种特殊信号,任何进程都无法忽略,如果向它发送 9 号信号,它就会被强制终止。

2.3 由硬件异常触发

  1. 为什么我们的程序在遇到除 0 错误和野指针时会崩溃?本质原因是内核检测到程序的非法操作后,向进程发送了终止类信号(除 0 触发 SIGFPE、野指针触发 SIGSEGV),进程默认响应这些信号终止运行,外在表现就是程序崩溃。
  1. 这个是除0错误,对应8号信号 SIGFPE。
  1. 这个是野指针错误,对应11号信号 SIGSEGV。
  2. 我们可以通过修改8号信号和11号信号对应的处理方法,通过传递给函数的信号编号来进行验证:
cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>

void handler(int signo)
{
    std::cout << "收到了一个信号" << signo << "who:" << getpid() << std::endl;
    exit(12);
}
int main()
{
		signal(8, handler);
    signal(11, handler);
    // int x = 10;
    // x /= 0;
    

    int *p = nullptr;
    *p = 10;
    return 0;
}
  1. 进程为什么会收到终止信号?OS 又如何检测到程序的除 0 错误和野指针?本质是 CPU(检测除 0)和 MMU(内存管理单元,检测野指针 / 非法内存访问)在执行指令时,检测到程序的非法操作并触发硬件异常中断;OS(内核)作为硬件的管理者,会第一时间捕获这些硬件中断,随后向出错的进程发送对应的终止信号

  2. 信号最终会被记录在进程的 task_struct 结构体中(信号保存),内核采用信号位图来存储待处理信号:位图的第 N 个比特位对应第 N 号信号,比特位值为 1 表示该信号已送达(待处理),0 则表示未收到。当进程需要接收信号时,内核会修改这个位图中对应比特位的值 ------ 本质是修改内核维护的进程数据结构。因此无论信号是通过键盘(Ctrl+C)、硬件异常(除 0 / 野指针)还是kill命令产生,最终都需要由 OS(内核)来修改目标进程的信号位图,完成信号的 "写入"。向目标进程发送信号本质就是OS修改信号位图

  3. 那么为什么除 0 错误会触发 CPU 异常,而野指针会触发 MMU 异常?因为前者是 CPU 在做运算时直接检测到除 0 非法操作,立刻在标志寄存器中标记异常位;CPU 每执行完一条指令都会检查标志寄存器,一旦检测到异常位为 1,就知道发生了硬件异常,会立即停止执行当前程序、触发异常(只是暂停没有终止),并把控制权交给操作系统内核处理。至于野指针导致 MMU 异常,这是因为 MMU 会根据传递给它的虚拟地址去查页表,该地址没有合法的物理内存映射或权限不合法,MMU 就会触发硬件异常。地址 0 (空指针)是操作系统人为保留的「无效地址」,页表里根本没给它映射物理内存,也不给任何权限,所以 MMU 直接判定不合法。

  1. 标志寄存器(也叫状态寄存器)是 CPU 内部的核心寄存器,专门用来记录 CPU 执行每条指令后的状态与错误标记(如除 0 错误、运算溢出等)。CPU 每执行完一条指令都会检查该寄存器,一旦发现错误标志被置位,就判定指令执行异常,不再继续执行后续指令,转而触发硬件异常并交给操作系统处理。可以将它理解为一张硬件级位图,里面每一个比特位,对应一种执行状态或错误(如除 0、溢出、进位等)。比特位为 1:表示发生了对应状态 / 错误,比特位为 0:表示正常。
  2. CR3 是 x86 CPU 里的一个控制寄存器,里面存的是:当前进程的页表起始物理地址。每个进程有自己的页表,进程在哪个 CPU 上运行,这个 CPU 的 CR3 就指向它的页表,MMU 做虚拟地址→物理地址转换时,就是从 CR3 拿到页表基地址去查的,进程切换时,OS 会自动修改 CR3,换成新进程的页表地址。
  1. 如果我们给信号自定义了处理函数,去掉了默认的终止功能,为什么程序会因为硬件异常陷入死循环?这是因为如果不终止进程,当发生进程切换时,进程会保存自己的所有上下文数据;切换回来时,又会把上下文恢复给 CPU,回到刚才出错的那条指令重新执行。CPU 一执行这条错误指令,就又会在标志寄存器里标记异常,再次触发异常、交给操作系统,如此反复,就会一直死循环下去
  2. OS 怎么知道当前执行的进程是哪一个?内核中有一个 current 指针(类型task_struct*),它会指向当前 CPU 正在执行的进程。

2.4 核心转储

  1. 我们使用 man 7 signal 命令详细查看信号相关信息时,会发现终止类信号分为两类:一类是 Term 信号,另一类是 Core 信号。这两类信号虽均用于终止进程,但 Term 信号仅会直接终止进程;而 Core 信号触发时,进程除终止外还可能产生核心转储(core dump),并且在子进程因该信号终止时,会将 core dumped 标志位传递给父进程
  2. 核心转储(Core Dump)是操作系统在进程因严重异常(如段错误、非法指令、Core 类信号触发等)终止时,将该进程当前的内存镜像、寄存器状态、调用栈、程序计数器等关键运行信息完整保存到磁盘文件(默认文件名为 core 或 core.)的机制。简单来说,就是把进程崩溃瞬间的 "内存快照" 打包写入磁盘,本质是一种事后调试方式 ------ 开发者可通过 GDB 等调试工具加载生成的 core 文件,回溯进程崩溃时的执行状态,定位代码中的错误(比如空指针访问、数组越界、内存非法操作等)。
  3. 核心转储功能在云服务器中默认是关闭的。原因在于,云服务器上的软件服务通常配置了自动重启机制:当服务进程因异常崩溃触发 Core 类信号时,会生成 core 文件;而自动重启机制会立刻重新拉起进程,若故障未修复,进程会反复崩溃 - 重启,持续生成大量 core 文件(单个 core 文件体积可能从几十 MB 到数 GB 不等),短时间内就会占满云服务器的磁盘空间,进而导致服务无法写入日志、新进程无法创建等更严重的问题。
  1. 那么我们怎么知道我们当前的进程发生了核心转储呢?父进程可以通过 wait()/waitpid() 系统调用获取子进程的退出状态信息;当子进程因信号终止时,退出状态中会包含两个关键信息:① 终止子进程的信号编号 ② core dump 标志位(布尔值)。通过系统提供的宏(WCOREDUMP)解析这个状态,就能判断子进程是否生成了核心转储文件 ------ 标志位为1表示发生了 core dump,为 0 则未发生。

2.5 通过系统调用产生

kill

  1. kill 既是 Linux 命令行工具,它也是操作系统提供的系统调用函数,核心功能都是向指定进程 /进程组发送信号。第一个参数是目标进程/进程组的 pid,第二个参数要发送的信号编号。它的返回值0代表发送成功,-1代表发送失败。
  2. 我们可以利用这个函数创建属于我们自己的 kill 命令行工具:
cpp 复制代码
#include<iostream>
#include<signal.h>
#include<string>

void Usage(const std::string& cmd)
{
    std::cout << "Usage: " << cmd << "signumber who" << std::endl; 
}

int main(int argc, char* argv[])
{
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(0);
    }

    int signumber = std::stoi(argv[1]);
    pid_t pid = std::stoi(argv[2]);

    int n = kill(pid, signumber);
    (void)n;

    return 0;
}

raise

  1. raise() 是 C 标准库函数(而非系统调用),核心作用是:调用该函数的进程,向自身发送指定的信号。它的行为几乎等价于 kill(getpid(), sig)。

abort

  1. abort() 会让调用它的进程无条件、不可挽回地异常终止,核心行为是:向进程自身发送 SIGABRT 信号(信号编号 6) 。这个信号6也挺特别的,用signal()为它注册自定义处理方式时,并不会覆盖其原本 "终止进程" 的默认行为,而是在默认终止行为执行前追加一段自定义的处理逻辑

2.6 由软件条件产生

  1. 信号既有由硬件异常产生的,也有由软件条件产生的:比如 13 号信号(SIGPIPE,管道破裂信号),它的触发场景是管道读端被关闭后,若写端进程仍尝试向管道写入数据,操作系统会向该写端进程发送这个信号。

  2. 再介绍一个 alarm() 函数(俗称闹钟函数):它的参数是整数类型的秒数,调用该函数后,操作系统会在指定秒数到达时,向调用此函数的进程发送 14 号信号(SIGALRM,时钟信号)。

  3. 将 alarm() 的参数设为 0 即可取消当前已设置但未触发的闹钟;若已设置的闹钟尚未发送 SIGALRM 信号时再次调用 alarm(),则会重置闹钟时长,以新调用时传入的秒数重新开始计时

  4. alarm() 函数的返回值为无符号整数,代表上一个已设置但未触发的闹钟距离触发所剩余的秒数;若上一个闹钟已正常触发(即 SIGALRM 信号已发送),则本次调用 alarm() 的返回值为 0。

  5. 若想实现重复触发的闹钟效果,需为 14 号信号(SIGALRM)注册自定义处理函数,并在该处理函数中重新调用alarm()来重置闹钟;切忌在主程序中循环调用alarm()尝试实现重复闹钟,这种方式会不断覆盖未触发的闹钟,导致 SIGALRM 信号永远无法触发。

  1. alarm() 实现的闹钟本质是进程级定时器,操作系统需要为所有进程的定时器做统一管理 ------ 而管理的核心思路遵循 "先描述、再组织" 的设计原则:每个定时器会被封装成包含剩余时长、所属进程等信息的结构体,再通过特定数据结构组织起来。定时器管理的核心是优先关注剩余秒数少的定时器(这类定时器最先到期,若它们未触发,剩余时长更长的定时器必然也未到期);虽然理论上用最小堆能高效筛选 "剩余时长最短的定时器",但实际 Linux 内核并未采用最小堆,而是通过队列来管理定时器

今天的分享就到此结束啦,如果对读者朋友们有所帮助的话,可否留下宝贵的三连呢~~
让我们共同努力, 一起走下去!

相关推荐
一只自律的鸡2 小时前
【Linux系统编程】信号 kill/raise/alarm/pause/alarm实例/漏桶算法
linux·运维·服务器
Trouvaille ~2 小时前
【项目篇】从零手写高并发服务器(七):定时器TimerWheel与线程池
运维·服务器·网络·c++·reactor·高并发·muduo库
co_wait2 小时前
【c语言】linux下静态库和动态库制作
linux·c语言·restful
莫白媛2 小时前
Linux中Docker介绍与使用小白篇
linux·运维·docker
ljh5746491192 小时前
linux xargs 命令
linux·运维·windows
工头阿乐2 小时前
企业级统一身份认证全景指南:深入解析 Keycloak、OAuth2、OIDC 与周边生态
linux
xingyuzhisuan2 小时前
4090服务器内存怎么配?128GB起步还是256GB才够用?
运维·服务器
夏语灬2 小时前
CST Studio Suite软件安装步骤(附安装包)CST Studio Suite 2024超详细下载安装教程
运维·服务器
GY—Monkey2 小时前
ubuntu (V100)中 部署openclaw,并链接飞书
linux·ubuntu·飞书