本篇目标:

1. 掌握Linux信号的基本概念。

2. 掌握信号产⽣的⼀般⽅式。

一.初步认识信号

1.生活角度认识

首先要说明一点的是信号和信号量就如同老婆和老婆饼一样,没有任何关系。

在生活中,我们也会遇到各种各样的信号,例如:

闹钟响了,我们知道要起床了。

红绿灯,红灯亮了,我们知道要在原地暂停等绿灯。

上课铃声,铃声响了,学生知道改上课了。

古代的狼烟,点燃了狼烟,可能就是有敌人来进攻了。

敲门声,敲门声响了,我们知道门外有了,要去开门了...

那么举了这么多的例子,什么是信号呢?

在生活中,当某个信号产生时(例如电话铃声、门铃声、火灾警报),会打断我们正在做的事情,通知我们有新的事件发生,这是一种事件的异步通知机制。

对应到操作系统:

在操作系统中,信号(Signal)是一种向进程发送事件通知的机制。当某个事件发生时,操作系统会向目标进程发送一个信号,通知进程进行相应处理,这同样是一种异步通知机制

那么什么是异步机制呢?

假设小李是一名程序员。某天项目经理发现自己的快递到了,于是让小李帮忙去取。

如果项目经理要求其他员工暂停手头工作,等小李取完快递回来后再继续工作,那么大家都需要等

待小李完成任务,这就是同步机制。

如果项目经理让其他员工继续处理自己的工作,不必等待小李回来,那么小李取快递和其他员工工

作可以同时进行,彼此互不影响,这就是异步机制。

简单来说:同步需要等待,异步无需等待。

2.基本结论

然后这里我就先接合生活中的例子,给出几个基本的结论:

• 假如小李在网上买了很多件商品,正在等待不同商品快递的到来。但是即便快递没有到来,小李

也知道快递来临时,该怎么处理快递,也就是说小李能“识别快递”,所以在操作系统中,进程在信

号还没有产生的时候,早就知道信号该如何处理了。

• 当快递员到了小李的楼下时,小李也收到了快递到来的通知,但是小李正在打游戏,需要5min之

后才能去取快递。 那么在这5min之内,小李并没有下去去取快递,但是小李是知道有快递到来了

的,也就是是说取快递的行为并不是⼀定要立即执行,可以理解成“在合适的时候去取”,也就是

说:人必须要把处理的事情记录下来,那么在操作系统中,信号的处理并不是立即处理的,而是可

以等一会再处理,在合适的时候进行信号处理。

• 在收到通知,再到小李拿到快递期间,是有⼀个时间窗⼝的,在这段时间,小李并没有拿到快

递,但是小李知道有⼀个快递已经来了。本质上是小李“记住了有⼀个快递要去取

• 小李买了顺丰,圆通,京东等不同地方的快递,所以给他发的通知也非常的多,所以在操作系统

中信号源非常多,给进程产生信号的信号源也非常多

• 当小李时间合适,顺利拿到快递之后,就要开始处理快递了,而处理快递⼀般⽅式有三种:1.

行默认动作(幸福的打开快递,使用商品)2.执行自定义动作(例如快递是零食,小李要送给他的

女朋友) 3. 忽略快递(快递拿上来之后,扔掉床头,继续开⼀把游戏),那么在操作系统中,进

程收到信号后,在合适的时候处理信号的动作有三种:

<1>.默认处理动作 

<2>.自定义处理动作,也叫做信号捕捉

<3>.忽略处理

• 快递什么时候到达是不可预测的,小李也不会一直盯着手机等待电话,而是在正常生活过程中突

然收到通知。同样,进程无法准确预测信号何时产生,因此信号是一种典型的异步事件通知机制。

总结:

信号是一种异步事件通知机制。进程预先定义好信号的处理方式,当信号产生后,内核会先记录该信号,并在合适的时机通知进程执行对应的处理动作。

二.信号的产生

1.键盘产生信号

<1>.看见信号

想要了解信号的产生,我们需要先在操作系统中看到这些信号,操作如下:

kill -l

如图所示:

这时有人看见有整整64个信号,可能就会产生疑问:这么多,还全是英文,那我该咋记?

对于这个问题,我的答案是:

其实这些英文都是由宏定义的,其实都是一些数字,用英文是为了好看些,并且信号也不用记,也

没必要记。

学习 Linux 信号,就像学习英语单词一样,你不可能第一天就把所有单词背下来,而是在后面的学

习和使用过程中不断接触、不断重复,最后自然而然就记住了。

实际开发中,经常接触的信号其实并不多,编号34以上的是实时信号,本篇仅讨论编号34以下的

信号,不讨论实时信号,这些信号各自在什么条件 下产⽣,默认的处理动作是什么,在signal(7)中

都有详细说明: man 7 signal,如图:

常见的例如:

SIGINT   Ctrl+C产生的中断信号
SIGQUIT  Ctrl+\产生的退出信号
SIGKILL  强制终止进程
SIGTERM  终止进程
SIGSEGV  非法内存访问(段错误)
SIGCHLD  子进程退出

更重要的是,我们学习信号时关注的重点并不是:

这个信号叫什么名字?
这个信号编号是多少?

而是:

这个信号是怎么产生的?
它通知了什么事件?
收到之后会发生什么?

事实上,Linux 的很多信号都对应着我们日常使用 Linux 时的某个操作。接下来,我们就先从最熟

悉的一个信号开始认识。比如,当我们按下 Ctrl+C 时,到底发生了什么?

例如,在sig.cpp目录下,我们写了如下的代码:

#include <iostream>
#include <unistd.h>
int main()
{
    while(true)
    {
        std::cout << "I am a process, I am waiting signal!" << std::endl;
        sleep(1);
    }
    return 0;
}

makefile中的代码:

sig:sig.cpp
	g++ -o $@ $^ -std=c++11

.PHONY:clean
clean:
	rm -rf sig

当我们编译成sig时再执行程序,然后再按下ctrl+c,结果如图:

我们会发现,按下ctrl+c时,程序居然将死循环给暂停了,这是为什么呢?

原因:按下 ctrl+c后 ,这个键盘输入产生⼀个硬件中断,被OS获取,解释成信号,发送给目标

前台进程,也就是说ctrl+c是给目标进程发送信号的,并且相当一部分信号的处理动作就是让自己

终止。

<2>.如何证明产生信号?

你说产生了信号,那么你该如何证明信号产生了呢?也就是说:我想要看到信号处理的过程,因此

我们可以尝试更改进程的默认信号处理动作,这里需要用到一个系统函数。

我们在终端里面用man signal即可看见,如图所示:

参数说明:

signum :信号编号。

handler :函数指针,表示更改信号的处理动作,当收到对应的信号,就回调执行 handler 方法。

其实, Ctrl+C 的本质是向前台进程发送 SIGINT 即 2 号信号,我们证明⼀下,这里需要在代码里

面引入⼀ 个系统调用函数,如代码所示:

#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signumber)
{
    std::cout << "我是: " << getpid() << ",我获得了⼀个信号: " << signumber << std::endl;
}
int main()
{
    std::cout << "我是进程: " << getpid() << std::endl;signal(SIGINT , handler);
    while (true)
    {
        std::cout << "I am a process, I am waiting signal!" << std::endl;
        sleep(1);
    }
    return 0;
}

当我们编译成sig时再执行程序,然后再按下ctrl+c,结果如图:

我们居然发现按下ctrl+c不再是停止,而是打印我是: 3983476,我获得了⼀个信号: 2,这也确实证明了ctrl+c是停止进程的信号,可目前的问题是:我该如何终止这个循环呢?其实我们还可以用ctrl+\来终止,这也是个信号。

这里其实可以引出一个很重要的结论:

一个进程可以同时收到很多种不同的信号,而我们只修改了其中某一个信号的处理方式,并不会影响其他信号。

<3>.目标进程

这时,细心的读者可能注意到一个术语:"目标进程",这究竟指的是什么呢?

那么这里就要从前后台进程讲起了

1.前台进程

形如./XXX,例如:./sig,操作,如图:

可以发现当处在前台进程时,我们的ls,ll,等命令是无法进行的。

特点:前台进程能够从终端获取标准输入(stdin),因为一个终端对应的键盘输入在同一时刻只

能交给一个确定的进程组处理,所以一个终端在同一时刻只能有一个前台进程组所以前台进程的本

质就是从键盘获取数据的。

2.后台进程

形如./XXX,例如:./sig &,操作如图所示:

可以发现此时我们执行ls,ll等命令时是可以正常执行的。

特点:后台进程是无法从标准输入中获取内容的,并且后台进程可以有多个。

不过,如何终止这个循环确实是个问题。幸运的是,程序员们早已为我们准备好了解决方案。

首先用在当前终端输入fg,将当前后台进程变为前台进程,然后按下ctrl+\就可以了。

这里补充一些关于前后进程的命令

jobs:查看所有的后台进程

ctrl+z:进程切换到后台

bg+任务号:让后台进行恢复运行

3.bash进程

到这里可能还有人会疑惑:bash进程在前后进程中究竟属于哪一个?

默认情况下,终端的前台进程是 bash。当我们执行 ./sig 时,bash 会创建 sig 进程,并把终端

的控制权交给 sig,自己进入等待状态。因此键盘输入会发送给 sig,而不是 bash,所以无法执行

lspwd 等 shell 命令。当 sig 结束后,终端控制权重新回到 bash。如果使用 ./sig & 让程序在后

台运行,则终端控制权始终属于 bash,因此可以继续执行各种 shell 命令。

可以通过示意图理解:

<4>.什么叫做给进程发信号?

信号产生之后,可能并不会被立即处理。因此,操作系统必须先把该信号记录下来,等到进程合适的时候再进行处理。

那么信号记录在哪里呢?

本质上是记录在进程的 PCB(task_struct) 中。在 Linux 内核中,每个进程都有对应的内核数据结

里面会维护与信号相关的信息。

我们可以先把它简单理解成一个位图,例如一个无符号整数 sigs

sigs = 0000 0000 0000 0000 0000 0000 0000 0000

其中第 0 位不用,其他比特位的位置可以用来表示信号编号:

第1位 -> 1号信号
第2位 -> 2号信号
第3位 -> 3号信号
...

比特位的内容表示该信号是否已经产生:

0:没有收到该信号
1:已经收到该信号,但还没有处理

可以通过示意图理解:

这里需要明确一个概念:task_struct属于什么?它实际上是操作系统内部的数据结构对象。修改位

图的本质就是修改内核数据结构,那么这个修改由谁来完成呢?显然只能由操作系统自身通过调用

发送信号的系统调用来实现。因此,无论信号是如何产生的,在底层实现中,信号的发送都必须由

操作系统来完成。

2.系统调用

<1>.kill

kill 命令是调用kill 函数实现的,可以给⼀个指定的进程发送指定的信号。

如图所示:

参数:

pid是进程的id号,sig是要发送给目标进程的信号编号。

返回值:

代码:

在sig.cpp中

#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
// ./mykill signumber pid
int main(int argc, char *argv[])
{
    if(argc != 3)
    {
        std::cout<<"你需要这样写:";
        std::cerr << "Usage: " << argv[0] << " -signumber pid" << std::endl;
        return 1;
    }
    int number = std::stoi(argv[1]); 
    pid_t pid  = std::stoi(argv[2]);
    int n = kill(pid, number);
    if(n<0)
    {
        perror("kill failed");
        exit(n);
    }
    return 0;
}

在test.cpp中,

#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signumber)
{
    std::cout << "我是: " << getpid() << ",我获得了⼀个信号: " << signumber << std::endl;
}
int main()
{
    std::cout << "我是进程: " << getpid() << std::endl;signal(SIGINT , handler);
    while (true)
    {
        std::cout << "I am a process, I am waiting signal!" << std::endl;
        sleep(1);
    }
    return 0;
}

在makefiel中,

all:sig test

test:test.cpp
	g++ -o $@ $^ -std=c++11

sig:sig.cpp
	g++ -o $@ $^ -std=c++11

.PHONY:clean
clean:
	rm -rf sig test

然后我们在vscode上执行test,然后在xshell上执行./sig 3 进程id,如图所示:

然后就可以杀死./test这个进程了。

<2>.raise

作用:可以给当前进程发送指定的信号(自己给自己发信号)。

如图:

代码:

#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signumber)
{
    //整个代码就只有这⼀处打印
    std::cout<< "获取了⼀个信号 : " << signumber << std::endl;
}
// mykill -signumber pid
int main()
{
    for(int i=1;i<=31;i++)
    {
        signal(i, handler);
    }

    for(int i=1;i<=31;i++)
    {
        raise(i);
        sleep(1);
    }

    return 0;
}

结果如图所示:

当我们运行./sig时会发现,当i=9时,循环就会终止,所以这也说明9号进程不可被捕获,并且19号

进程也是一样子的,感兴趣的可以去尝试一下。

<3>.abort

作用:使当前进程接收到信号而异常终止。

如图:

代码:

#include <iostream>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
void handler(int signumber)
{
    std::cout << "获取了⼀个信号 : " << signumber << std::endl;
}

int main()
{
    signal(SIGABRT, handler);
    while (true)
    {
        sleep(1);
        abort();
    }
}

实验可以得知, abort 给自己发送的是固定 6 号信号,虽然捕捉了,但是还是要退出。

这时可能会有人有疑惑:为什么我之前的raise(6)时,没有终止进程?
raise(SIGABRT);

abort();

不是一回事

raise(SIGABRT) 的语义只是:

给当前进程发送 6 号信号

如果你已经写了:

signal(SIGABRT, handler);

那么 6 号信号的默认终止动作被你改成了自定义处理:

raise(SIGABRT)
    ↓
产生 SIGABRT
    ↓
执行 handler
    ↓
handler 返回
    ↓
继续往后执行

所以不会退出。

abort() 的语义是:

异常终止当前进程

它内部也会产生 SIGABRT,但如果你的 handler 返回了,abort() 仍然会继续想办法让进程终止。

所以区别是:

raise(SIGABRT):只是发送 6 号信号,是否退出取决于 SIGABRT 当前的处理方式

abort():目标就是让进程异常终止,即使 SIGABRT 被捕捉,最终也要退出

你可以这样记:

raise(SIGABRT) ≈ 发送信号
abort()        ≈ 发送 SIGABRT + 保证异常退出

总结:raise(SIGABRT) 只是向自己发送一个 6 号信号,被捕获后执行捕获的动作;而 abort()

会发送 6 号信号,但它的目标是保证进程最终终止。所以即使用户捕捉了 SIGABRT,abort 会在

处理捕获函数返回后恢复默认动作并再次触发 SIGABRT,从而确保进程退出。并不是直接绕过信

号机制调用某个“强制终止系统调用”。

可以通过示意图理解:

3.硬件异常

硬件异常是 CPU/MMU 在执行指令或进行地址转换时检测到的异常,随后硬件通过异常机制通知内核,内核再根据异常类型向当前进程发送合适的信号。例如当前 进程执行了除以0的指令,CPU的运算单元会产⽣异常,内核将这个异常解释为SIGFPE信号发送给进 程。再⽐如当前进程访问了⾮法内存地址,MMU会产⽣异常,内核将这个异常解释为SIGSEGV信号发送 给进程。

那么操作系统是如何知道硬件异常了呢?

<1>.模拟除0

代码:

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
void handler(int sig)
{
    printf("catch a sig : %d\n", sig);
    exit(1);
}
// v1
int main()
{
    for(int i=1;i<=31;i++)
    {
        signal(i, handler);
    }
    sleep(1);
    int a = 10;
    a/=0;
       
    return 0;
}

结果如图所示:

解释:

执行时:CPU是硬件,它知道:

除0不合法

于是CPU不会继续执行,然后CPU就会记录错误,而CPU内部有很多寄存器,例如:

PC(IP)
SP
AX
BX
EFLAGS
...

其中:

EFLAGS

状态寄存器

例如:

ZF  零标志
CF  进位标志
OF  溢出标志
SF  符号标志

000100就是在表达:

某个标志位被置1

例如:

OF = 1

表示发生了溢出

然后当OS开始接管时,而操作系统是软硬件资源的管理者,它知道当前进程是谁,通过硬件出错

识别到当前进程出错了,然后读取异常原因,判断错误类型,然后发送信号给当前进程,其实就是

给当前进程发送SIGFPE。

本质就是:

task_struct
    ↓
pending位图
    ↓
第8位置1

示意图:

<2>.模拟野指针

代码:

#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
void handler(int sig)
{
    printf("catch a sig : %d\n", sig);
    exit(1);
}
int main()
{
    for (int i = 1; i <= 31; i++)
    {
        signal(i, handler);
    }
    sleep(1);
    int *p = NULL;
    *p = 100;
    return 0;
}

结果如图:

解释:

程序执行:

*p=100;

CPU需要访问:

虚拟地址 0x0

因为:

p == NULL

CPU把地址:

0x0

交给 MMU,它可以查当前进程的页表,但是MMU发现:

0x0

对应的页表项不存在,这说明这个地址没有映射到任何物理地址,所以就导致了地址转换失败

然后MMU通知CPU:Page Fault

CPU产生:#PF异

然后:

用户态
    ↓
内核态

内核分析:

访问地址:0x0

异常原因:
非法内存访问

发现:这个错误无法修复

因为:

不是缺页
不是栈扩展
不是按需分配

而是真正的野指针

硬件报错,OS向当前进程发送:

SIGSEGV

即:

11号信号

文字描述:

当程序通过野指针访问内存时,CPU会将对应的虚拟地址交给MMU进行地址转换。MMU查找当前

进程页表后发现该地址没有合法映射,于是产生Page Fault异常。CPU随后陷入内核态,由操作

系统分析异常原因。由于该异常属于非法内存访问,操作系统会向当前进程发送SIGSEGV(11号

信号)

示意图:

总结:

信号是一种异步事件通知机制。当某个事件发生时(键盘输入、系统调用、硬件异常等),操作系统会向目标进程发送对应信号。本质上就是修改进程PCB中的信号状态,并在合适时机递达给进程执行默认处理、信号捕捉或忽略处理。所有信号最终都由操作系统发送。

下一篇结束信号。

Logo

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

更多推荐