Linux信号机制:从生活哲学到内核位图,一文吃透进程信号的异步本质

信号,是操作系统向进程发送的一份“异步通知”。它不像管道那样搬运数据,不像消息队列那样排队报文,它只做一件事:告诉你,某件事发生了。 理解信号,不能只记编号和接口,必须从“生活本质”出发,一路追溯到 PCB 中的那张位图。


一、System V IPC 内核实现回顾:柔性数组与 C 语言多态

在正式进入信号之前,老师用约十分钟对上节课的 System V IPC 做了系统性收尾。共享内存、消息队列、信号量这三者在内核中并非孤立存在,而是被统一纳入一套高度复用的架构之中。

1.1 全局统一管理:ipc_ids 与柔性数组

内核中存在一个全局结构 ipc_ids,它内部包含一个柔性数组( flexible array )。这个数组的元素类型是 struct kern_ipc_perm *,即一个指向权限结构体的指针。

// 内核中的组织方式(以老版本 2.6.x 为例)
struct ipc_ids {
    int size;               // 当前数组大小
    int max_id;             // 最大 ID,用于回绕
    struct kern_ipc_perm *entries[0]; // 柔性数组
};

当我们在用户层调用 msgget()、semget()、shmget() 时,内核实际上是在这个柔性数组中申请一个下标,然后将对应 IPC 资源的结构体地址填入。用户层拿到的 msgid、semid、shmid,本质上就是这张柔性数组的下标。下标从 0 开始线性增长,达到 max_id 后会回绕(取模复用)。

1.2 C 语言实现的多态:基类指针指向子类对象

每种 IPC 资源的结构体(msg_queue、sem_array、shmid_kernel)在设计时有一个精妙的约定:它们的第一个成员都是 struct kern_ipc_perm。这意味着,子类结构体的起始地址与基类成员地址在数值上完全重合。

内核正是利用了 C 语言的这一指针特性,将不同子类对象的地址强制转换为基类指针存入柔性数组。当你需要访问共性属性(如 key、权限、拥有者)时,直接通过基类指针即可;当你需要访问个性属性(如消息队列的等待队列、共享内存的映射信息)时,再将指针强转回具体子类类型。这便是在 C 语言层面实现的“多态”。

老师敲黑板: “结构决定算法。只要画出这个数据结构,遍历、增删、查改的思路就自己跳出来了。”

1.3 三种 IPC 的个性化底层

IPC 类型内核结构关键机制
消息队列struct msg_queue无数据时读进程被阻塞:将当前进程 PCB 从运行队列取下,状态改为 S(sleep),链入消息队列的等待队列;待消息到达后再唤醒。
信号量struct sem_array本质是计数器。P/V 操作必须是原子操作以保护临界资源。二元信号量用于互斥,多元信号量用于同步与资源预定。
共享内存struct shmid_kernel基于文件系统实现(拥有 file 与 inode)。通过 vm_area_struct 将共享内存映射到进程虚拟地址空间,修改页表建立物理内存共享。访问时无需系统调用,直接通过虚拟地址读写。

值得一提的是,共享内存的创建本质上是以文件为载体在内存中开辟空间,再通过 shmat 将其挂接到进程的地址空间。此后进程拿到的起始虚拟地址就是可以直接读写的“内存窗口”。


二、信号的概念:一场从现实生活出发的哲学课

老师并未直接抛出定义,而是先抛出了一个灵魂拷问:“信号和信号量是什么关系?”

答案是:“老婆和老婆饼的关系”——名字像,但完全不是一回事。信号量是一个计数器,用于进程同步;而信号是一种事件的异步通知机制。

2.1 生活中的信号源

为了建立直觉,老师在课堂上让同学们疯狂举例:

  • 闹钟:闹钟响起,你知道该起床了。
  • 红绿灯:红灯亮起,你知道要停下。
  • 上课铃声:铃声响起,在操场上玩的学生知道该回教室了。
  • 古代狼烟:狼烟点起,士兵知道必须严阵以待。
  • 电话/外卖敲门:电话响了,你知道有快递或外卖到了。
  • 甚至父母脸色不好:这也是一种信号,意味着“暴风雨”即将来临。

这些场景有一个共性:它们都会中断你当前正在做的事情,通知你“有新事件发生了”。而且,信号的到来与你当下正在做的事是并行的——你在睡觉,闹钟在计时;你在走路,红绿灯在倒计时;你在长城上戍边,敌人在放羊。你们各忙各的,直到信号触发那一刻才产生交集。

这就是异步。

2.2 异步的本质:张三取快递的故事

老师为了讲清“异步”,举了一个极具画面感的例子:

老师在上课,快递到了。如果老师让张三去取,并且说“同学们自习,等张三回来我们再上课”,这就是同步——两件事情有先后依赖,上课流程被阻塞了。

但如果老师说“张三你去取,同学们我们继续上课,不等他”,那么上课与取快递两个过程就在同一时刻并行发生、互不干扰,这就是异步。

信号的产生与进程运行正是如此:信号的产生与进程当前执行流无关,互不干扰,仅在信号到达时产生交集。在这里插入图片描述

2.3 信号处理的三大黄金结论

基于生活认知,老师推导出了指导后续所有源码理解的三大结论:

  1. 信号未产生时,我早就知道该如何处理。
    你在睡觉前定闹钟,虽然闹钟还没响,但你早就知道“响了就起床”;士兵看到狼烟台,虽然没点烟,但早就知道“点了就备战”。进程也是如此——它在编码时就由程序员内置了对每一种信号的识别与处理逻辑。

  2. 信号不是立即处理的,而是在“合适的时候”处理。
    外卖员打电话说外卖到了,你可能正在开会,会说“稍等,我过会儿取”;上课铃响了,老师可能还要拖堂 5 分钟。进程收到信号后,通常不会立即放下手头所有工作,而是等到一个合适的时机(例如从内核态返回用户态时)再去处理。在这里插入图片描述

  3. 识别信号的能力是“被教育”出来的。
    人知道红灯停绿灯行,是幼儿园老师教的;进程知道收到信号该怎么办,是因为操作系统内核对 task_struct 的设计早已写好了处理逻辑。


三、信号的处理动作:三种人生选择

进程收到信号后,最终落到三种处理动作之一,这就像你收到“红灯亮了”这个信号后的反应:

3.1 默认动作(Default)

绝大多数信号的默认动作是 Term(终止)。比如 SIGINT(2号)收到后直接终止进程。也有默认动作为 Stop(暂停)、Ign(忽略)或 Cont(继续)的信号。

通过 man 7 signal 可以查看所有信号的默认动作。在这里插入图片描述

3.2 自定义捕捉(Handler)

老师举了“真心话大冒险”的例子:

朋友输了,你惩罚他:在繁华十字路口,红灯亮时,不准等待,必须在人群中疯狂跳舞。
正常人收到红灯信号是“停止”(默认动作),但这位朋友被强制修改为“跳舞”(自定义动作)。

在 Linux 中,我们可以通过 signal() 或 sigaction() 注册一个回调函数来改变默认行为:在这里插入图片描述

#include <signal.h>

void handler(int sig) {
    printf("收到信号: %d,但我选择不终止,而是跳支舞~\n", sig);
}

int main() {
    signal(SIGINT, handler); // 将 2 号信号的默认动作改为自定义捕捉
    while(1) {
        printf("hello world, pid: %d\n", getpid());
        sleep(1);
    }
    return 0;
}

当这个程序运行后,你按 Ctrl+C,进程不会终止,而是打印出一句“收到信号…”。这反向证明了 Ctrl+C 正是向进程发送了 2 号信号(SIGINT)。
在这里插入图片描述

3.3 忽略(Ignore)

老师戏称第三种情况为“素质不高的人”:

看到红灯亮了,有人等待,有人跳舞,而我不管不顾,继续过马路——车不敢撞我。这就是忽略信号。

代码中只需将 signal(SIGINT, SIG_IGN) 即可实现对特定信号的忽略。

3.4 不可被捕捉的“尚方宝剑”:9 号与 19 号

如果所有信号都能被自定义捕捉,那恶意程序完全可以捕捉所有信号让自己变成“刀枪不入”的僵尸,连 kill 都奈何不了它。

为了防止这种情况,操作系统预留了两道“尚方宝剑”:

  • SIGKILL(9号):无法被自定义捕捉或忽略,强制终止进程。
  • SIGSTOP(19号):无法被自定义捕捉或忽略,强制暂停进程。

老师原话: “道高一尺,魔高一丈。操作系统是正规程序员写的,他们早就考虑过是否有人恶意操作。”

在课堂上,老师特意写了一段代码,将 1~31 号普通信号全部注册为 handler,制造出一个“刀枪不入”的进程。结果 Ctrl+C(2号)、Ctrl+\(3号)、Ctrl+Z(20号)全部失效。最后,祭出 kill -9——进程瞬间被终结。这一实验给所有人留下了极其深刻的印象。


四、信号的五种产生方式

信号不是凭空出现的,老师将产生方式归纳为五种,并强调:无论哪种方式,最终发信号的只能是操作系统。

4.1 键盘终端产生(仅发给前台进程)

这是最直观的交互方式:

组合键信号名称默认动作
Ctrl+C2号SIGINT终止进程(Term)
Ctrl+\3号SIGQUIT终止并产生 core dump(Term+Core)
Ctrl+Z20号SIGTSTP暂停进程(Stop)

这里有一个关键限制:键盘产生的信号只能发送给前台进程。 为什么?因为键盘输入的数据只有一份,操作系统必须明确把它交给一个确定的进程。前台进程就是那个“站在最前面与键盘对话”的唯一进程。

如果你将进程放到后台运行(命令后加 &),Ctrl+C 就杀不死它了,因为键盘信号不会发给后台。此时你只能使用 kill 命令。

4.2 系统调用产生

操作系统必须提供系统调用接口,让用户层有能力请求“发信号”。核心接口有三个:

① kill():向任意进程发任意信号
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);

老师现场带同学们写了一个 mykill 命令,模拟系统自带的 kill 工具:

#include<iostream>
#include<sys/types.h>
#include<signal.h>
#include<string>

int main(int argc, char*argv[])
{
    if (argc != 3)
    {
        std::cout << "./mykill signalnum pid" << std::endl;
        return 1;
    }

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

    int n = kill(pid, signalnum);
    if (n == 0)
    {
        std::cout << "send " << signalnum << " to " << pid << std::endl;
    }

    return 0;
}
② raise():进程向自己发信号
int raise(int sig); // 给自己发 sig 信号

课堂上,老师在代码中循环调用 raise(i),让进程每隔一秒给自己发一个信号(1~31),逐一验证哪些能被捕捉。当发到 9 号时,进程直接终止;19 号也一样。在这里插入图片描述

③ abort():异常终止
#include <stdlib.h>
void abort(void);

abort 比较特殊:它会向自己发送固定的 6 号信号(SIGABRT),目的是引发进程异常终止。它的特殊之处在于,即便你之前用 signal() 捕捉了 6 号信号,abort 内部也会先恢复 6 号信号的默认动作,然后再发送信号,确保进程最终被终止。在这里插入图片描述

这就像你明明跟保安打了招呼“见到我就放行”,但 abort 直接绕过保安,恢复门禁规则,强行闯关。

4.3 硬件异常产生(程序崩溃的真相)

这是最让老师兴奋的部分,因为它串联了 CPU、MMU、页表、寄存器等底层硬件知识。

我们平时写 C/C++ 时,程序最常见的崩溃有两种:除零错误 和 野指针/非法内存访问。老师通过代码实验证明:

// 实验1:除零
int a = 10;
a /= 0;  // 触发 8 号信号 SIGFPE(Floating-Point Exception)

// 实验2:野指针
int *p = NULL;
*p = 100; // 触发 11 号信号 SIGSEGV(Segmentation Fault,段错误)

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

崩溃的真正原因不是“代码写错了”这么简单,而是操作系统向进程发送了信号。

操作系统怎么知道出错了?

① 除零错误:CPU 状态寄存器(EFLAGS)

运算在 CPU 内部的寄存器中进行。CPU 有一个状态寄存器(32位或64位,x86 下通常叫 EFLAGS / RFLAGS),其中的某些比特位记录了运算状态,比如是否发生溢出。当除零发生时,CPU 硬件在运算瞬间就会发现异常,这个信息保存在当前进程的上下文(寄存器中)。操作系统作为硬件的管理者,在调度或处理中断时识别到这一硬件错误,随即向当前进程发送 8 号信号(SIGFPE),进程默认终止,于是我们看到“程序崩了”。在这里插入图片描述

② 野指针:MMU 地址转换失败

CPU 访问内存时,使用的是虚拟地址。现代 CPU 内部集成了 MMU(Memory Management Unit) 硬件单元。MMU 会拿着虚拟地址和 CR3 寄存器中的页表起始地址,进行虚实地址转换。

当你访问 0 号地址(或越界地址)时,页表中根本不存在对应的有效映射,MMU 硬件转化失败,直接硬件报错。操作系统再次捕获到这个硬件错误,识别出当前运行的进程是谁,于是向它发送 11 号信号(SIGSEGV)。在这里插入图片描述

核心结论:程序崩溃,归根结底是硬件错误被操作系统捕获,再以信号的形式“合法合规”地杀掉进程。 用户层看到的“段错误”“浮点异常”,只是信号处理后的表象。

老师还顺带提了一句:C++ 的 try-catch 异常捕获机制,在底层编译后,很可能就是转化为对这类硬件信号的安装与捕捉过程。

4.4 软件条件产生

硬件说完了,还有“软件层面”的条件。最典型的例子就是管道:

当管道的读端被关闭后,写端进程如果继续写入,操作系统会向写端进程发送 13 号信号(SIGPIPE),终止它。因为管道是软件实现的(基于文件),读端关闭导致“写条件不满足”,这属于软件条件触发信号。

4.5 系统命令产生

你每天在终端使用的 kill -9 [pid],本质上就是一个用 C 语言编写的用户层程序,它内部调用了 kill() 系统调用,请求操作系统向目标进程发信号。


五、前台与后台进程:谁才有资格接收键盘信号?

这一节是信号机制中极易被忽略、却又至关重要的衍生知识。只有理解了前后台,你才能真正明白“为什么我的 Ctrl+C 有时失效”。

5.1 本质区别:谁独占键盘输入

  • 前台进程:唯一能从 标准输入(键盘) 获取数据的进程。命令行直接运行的程序默认就是前台进程。
  • 后台进程:无法从标准输入读取数据,但可向标准输出(显示器)打印。启动时加 & 即放入后台。

为什么键盘数据只能给前台?因为键盘输入的数据只有一份,不像显示器可以“你写你的,我写我的”。输入涉及对数据的加工与处理,必须明确交给一个确定的进程。所以,前台进程有且只有一个。

5.2 Shell、前台与后台的“三角关系”

当你登录 Linux,系统会创建一个 Shell 进程(如 bash)。此时 Shell 在前台,等待你输入命令。

  • 当你直接运行 ./test:Shell 创建一个子进程运行 test,test 被提到前台,Shell 自动退居后台。所以你输入 ls 没有反应——不是Shell死了,而是Shell在后台听不到键盘输入了。
  • 当你运行 ./test &:test 直接在后台运行,Shell 保持前台。所以你输入 ls,Shell 能正常响应。

5.3 孤儿进程自动转后台

老师提到了一个经典现象:

父进程先退出,子进程变成孤儿进程,被 init(PID 1)领养。此时你会发现,如果孤儿进程仍在打印,Ctrl+C 杀不掉它了。

为什么?因为孤儿进程被系统自动踢到了后台。既然它不在前台,键盘信号自然发不到它那里。你只能使用 kill 命令来终结它。

5.4 进程前后台切换命令

命令作用
jobs查看当前终端会话中的所有后台任务
fg %n将编号为 n 的后台任务提至前台(foreground)
bg %n将暂停的后台任务恢复运行(background)
Ctrl+Z暂停当前前台进程,并将其转入后台

课堂上老师现场演示了这套组合拳:

  1. ./test & 启动后台任务。
  2. jobs 看到任务号为 1,状态 Running。
  3. fg 1 将其提到前台,此时 Ctrl+C 可以杀掉它。
  4. 或者在前台运行时按 Ctrl+Z,进程暂停并进入后台;再用 bg 1 让它在后台继续跑。

插曲: 老师特别指出了前后台进程同时向显示器打印时,输出会混在一起,这是数据不一致问题——显示器作为临界资源,此时成了一份未被保护的共享资源。


六、信号的底层存储与发送本质:一切归于 PCB 位图

讲完了所有产生方式,老师将整条逻辑链收束到了一个极其硬核的内核实现上:

6.1 信号存在哪里?

每个进程的 task_struct 中,维护着一个用于记录信号的位图(通常是一个 unsigned int 或更复杂的 sigset_t)。以 32 位系统为例:

  • 这是一个 32 位的整数。
  • 比特位的位置代表信号编号(如第 1 位对应 1 号信号,第 2 位对应 2 号信号……)。
  • 比特位的内容(0 或 1)代表是否收到该信号。
// 简化理解
struct task_struct {
    // ...
    unsigned int pending; // 信号位图:比特位位置=信号编号,内容=是否收到
    // ...
};

当进程收到 2 号信号时,操作系统就将这个整数的第 2 个比特位由 0 置为 1。收到 31 个普通信号,本质上是修改了这个整数中的 31 个比特位。

6.2 发信号的本质是什么?

不是“飞鸽传书”,不是“数据拷贝”,而是——

向目标进程写信号 = 修改目标进程 PCB 中的位图。

因为 task_struct 是操作系统内部的核心数据结构,普通用户绝无权限直接修改。因此,无论信号通过键盘、系统调用、硬件异常还是软件条件产生,最终都必须由操作系统出面,亲自修改目标进程的位图。

这就解释了为什么 kill 是一个系统调用,为什么 Ctrl+C 最终是操作系统识别了键盘硬件中断后再发信号,为什么硬件异常是操作系统捕获 CPU/MMU 错误后再发信号。操作系统是唯一的“信号发出者”。

6.3 信号 vs 进程间通信

老师最后辩证地总结了两者的关系:

  • 狭义上:信号 ≠ 通信。传统 IPC(管道、消息队列、共享内存)是为了在用户层进程之间搬运数据;而信号是操作系统向进程发送的事件通知,数据交互发生在内核与进程之间。
  • 广义上:信号也属于通信范畴。因为它完成了“人或进程 → 通知事件 → 目标进程”的信息传递,尽管不以搬运数据为目的。

七、总结:一张图看清信号的完整链路

从生活到内核,信号的完整认知链路如下:

生活场景(闹钟/红灯/外卖)
        ↓
技术抽象:事件的异步通知机制
        ↓
三大结论:未发先识、择机处理、内置响应
        ↓
信号产生(5种方式)
    ├── 键盘(Ctrl+C/Z/\)→ 仅前台
    ├── 系统调用(kill/raise/abort)
    ├── 系统命令(kill -9)
    ├── 硬件异常(除零→8号SIGFPE,野指针→11号SIGSEGV)
    └── 软件条件(管道破裂→13号SIGPIPE)
        ↓
内核处理:OS 修改目标进程 PCB 中的信号位图(0→1)
        ↓
信号处理(3种动作)
    ├── 默认(Term/Stop/Ign/Cont)
    ├── 自定义捕捉(signal/sigaction)
    └── 忽略

信号机制之所以复杂,不是因为它接口多,而是因为它像一张网,把进程管理、硬件体系(CPU/MMU/寄存器)、文件系统(管道/显示器)、系统调用、内核数据结构全部串联在了一起。

正如老师所言:“关键不在于信号操作本身,而在于它衍生出来的话题——为什么异常会导致程序崩溃?为什么 9 号信号无法被捕捉?为什么前台前台只能有一个?这些,才是理解操作系统设计哲学的真正入口。”

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐