Linux信号产生

一、什么是信号

信号在日常中很常见,最常见的就像红绿灯。红绿灯亮起的时候路人行车都会接受到信号,继而采取相对应的措施。

在Linux中,也会有类似于红绿灯这类的信号。当进程捕捉到信号的时候,进程就会采取相应的行动。Linux中有62个信号。

注意32、33号信号没有。

kill指令

复制代码
kill [-信号编号] 进程PID

kill 指令是用来给进程发信号的,内核负责传递,进程自己处理。

信号的两大类

普通信号(1~31):普通信号是 Linux 早期提供的不可靠信号 ,同一信号多次触发时不排队、只保留一次、可能丢失 ,仅用于通知简单事件,无法携带附加数据

实时信号(34~64):实时信号是 POSIX 标准定义的可靠信号 ,支持排队、不丢失、按发送顺序递达 ,可通过sigqueue携带附加数据,适用于需要可靠通知的进程间通信。

学习中我们更关注普通信号。

进程收到信号的 3 种反应

执行默认动作:执行信号默认的行为,如现实中车看到红灯需要停下。

忽略信号:忽略该信号,如救护车执行任务时可以忽略红绿灯

自定义处理:捕获信号但不执行默认行为,如街头小丑在红灯时跳舞。

但总的来说,信号的这三种处理方式都是建立在信号被捕获的情况下。信号是外部或硬件发送给进程的一种异步(多事件互不影响,同时出现)的事件(异常、终止......)通知机制(告诉进程发生了什么)。

信号声明周期

对于为什么要将信号保存起来,是因为存在信号发出后不一定需要立刻生效,而是需要满足一些条件才行,这时候就需要将信号保存起来。

二、进程的产生方式

signal函数

signal函数是Linux系统专门用于将自定义函数绑定到一个信号上的。

参数说明:

signum表示被绑定的信号编码(1~31、34~64,但我们通常不处理34~64号信号);

sighandler是一个返回类型为void,参数类型为int的函数指针,其指向绑定的函数。

实例:

复制代码
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>

void exc(int signum)
{
    std::cout << "信号" << signum << "被替换" << std::endl;
}

int main()
{
    signal(SIGINT, exc);

    while (1)
    {
        std::cout << getpid() << std::endl;
        sleep(1);
    }

    return 0;
}

我们可以看到该进程的SIGINT(2号)信号被我们替换为自己的函数,并且执行完函数后该进程依旧没有退出。当我们摁下Ctrl+c的时候该进程竟然依旧调用我们自己的函数并没有退出。

这是因为Ctrl+c就是向前台进程发送SIGINT信号,而SIGINT信号的默认作用就是终止进程。

signal函数的返回值

signal() 函数返回值的核心作用是返回「之前」该信号的处理方式,相当于给一个 "操作记录",方便备份、恢复信号的原有行为。其返回类型也是函数指针类型。

复制代码
void exc(int signum)
{
    std::cout << "\n临时处理信号" << signum << ",3秒后恢复默认行为" << std::endl;
}

int main()
{
    // 1. 备份SIGINT原来的处理方式
    sighandler_t old_handler = signal(SIGINT, exc);

    std::cout << "阶段1:按Ctrl+C触发自定义处理..." << std::endl;
    sleep(5);

    // 2. 恢复SIGINT的原有处理方式(默认终止)
    signal(SIGINT, old_handler);
    std::cout << "\n阶段2:已恢复默认行为,按Ctrl+C会终止程序" << std::endl;

    while (1)
    {
        sleep(1);
    } // 等待测试

    return 0;
}

注意事项:

signal函数推荐放在程序开始位置,不要放到循环内部。

复制代码
   while (1)
    {
        signal(SIGINT, exc);

        std::cout << getpid() << std::endl;
        sleep(1);
    }

signal 函数的作用是修改进程对指定信号的处理方式,这个修改是全局且持久的(只要进程不退出,除非主动改回去)。将signal函数放在循环内部完全没有新作用。

把 signal 放在循环里:第一次按 Ctrl+C → 执行 exc,但系统自动把 SIGINT 重置为 "默认终止";

循环下一次执行 → 又调用 signal(SIGINT, exc),重新注册为自定义处理;

结果:看似 "正常",但每次处理信号后都有一个 "重置 - 重新注册" 的无效循环,且可能在 "重置瞬间" 收到信号导致程序终止。

小巧思

既然我们可以使用signal函数替换信号,而信号又是控制进程的方式,那我们可不可以把所有信号都替换掉,这样一来这个进程不就永远不会退出吗?

复制代码
    for (int i = 1; i <= 32; i++)
    {
        signal(i, exc);
    }

欸,我不是把所有信号都替换掉了吗,怎么进程还是会被9号信号SIGKILL杀死呢。这是因为OS在设计的时候能想到会有人钻这个空子,所以专门设计了一些无法被替换的信号确保安全,如SIGKILL就是典型的例子。

这样的信号有2个,也被称为管理员信号:

信号 编号 名称 核心特性 用途
SIGKILL 9 杀死进程 不可捕获、不可忽略、不可替换 强制终止任何进程(哪怕进程卡死)
SIGSTOP 19 暂停进程 不可捕获、不可忽略、不可替换 强制暂停进程(用fg/bg可恢复)

信号与进程间的关联

PCB(task_struct) 数据结构中,存在专门管理信号的结构。当信号产生时:

  1. 内核负责将信号发送给目标进程 信号被保存在进程的 task_struct(PCB)中,使用位图(unsigned int sigs 进行高效管理:

    • 位图的比特位位置:对应信号编号(1~31)
    • 位图的比特位内容1 表示进程已收到该信号,0 表示未收到
    • 只有操作系统内核(OS) 有权修改进程 task_struct 中的信号位图
    • 无论信号来源是 kill 指令、键盘操作还是程序运行错误,最终都由内核向目标进程的信号位图写入标记(将对应比特位置为 1
    • 向进程 "发送信号" 的本质,就是内核修改目标进程 task_struct 中的信号位图
  2. 进程根据自身 PCB 记录的处理方式处理信号进程对信号的处理方式分为三种:

    • 默认处理:由系统定义(如终止进程、生成 core dump)
    • 忽略处理:进程收到信号后不执行任何操作
    • 自定义处理:进程注册自定义函数,由该函数处理信号
  3. 信号由进程自身完成处理 信号处理在当前进程的上下文中执行,不会创建新的进程或线程,PID 保持不变。进程只是被内核临时打断,跳转到信号处理函数执行,执行完毕后回到原代码位置继续运行。

通过上面的解释,我们可以总结信号的产生方式分为:kill指令、键盘、程序报错崩溃这三种。

键盘中常见的信号:

键盘操作 信号名称 信号编号 作用
Ctrl + c SIGINT 2 终止前台进程
Ctrl + \ SIGQUIT 3 终止进程并生成 core dump
Ctrl + z SIGTSTP 20 暂停 / 挂起前台进程

需要注意的是,因为只有前台进程才能从键盘上获取信息,所以键盘产生的信号都只能用于前台进程。

另外,虽然我们都是通过bash输入指令的,但bash本身是能够忽略全部信号的。

核心转储core dumped

core dumped是程序崩溃时,把内存现场保存成一个文件(core 文件)。因为每报错一次就会生成一个core文件(把报错信息存入到磁盘),这在某些场景下会导致core文件大量占用空间导致OS挂掉的情况,故一般情况下云服务器会把这个功能关掉。

这时候发现并没有产生core文件,这就是因为OS把这个功能关了,不过我们可以使用下面指令打开。

复制代码
ulimit -c unlimited

不过新版本的OS可能会把这个文件藏得比较深,OS并不推荐使用这个文件,日常开发中用的也不多。不过,使用core文件确实能快速定位到出错地方,使用方法如下:

复制代码
gdb ./可执行程序名 core文件名
# 示例:gdb ./sig core (若core文件是core.12345,就写core.12345)

OS是怎么知道出错,崩溃的

一、CPU 层面出错

  1. 代码 int a = 10; a /= 0; 会被编译成 CPU 能执行的除法指令 (比如 div 指令)。
  2. CPU 执行这条指令时,会从寄存器里拿到两个数:被除数 10(存在 eax)、除数 0(存在 ebx),送入算术逻辑单元(ALU) 计算。
  3. CPU 硬件电路在设计时就规定:除数不能为 0,一旦检测到除数是 0,这个除法操作无法完成,硬件会直接触发一个 "除法错误异常"(硬件级中断)。
  4. 触发异常后,CPU 会做两件事:
    • 状态寄存器(EFlags) 里标记 "当前发生了除法错误";
    • 暂停当前进程的执行,强制切换到内核态把控制权交给操作系统内核

二、OS 层面:内核捕获并识别异常

  1. CPU 把控制权交给内核后,内核会先读取 CPU 提供的异常编号,这个编号明确告诉内核:"刚才发生了除零错误"。
  2. 内核通过当前 CPU 上下文(比如 cr3 寄存器,它保存了当前进程页表的物理地址),精准定位到触发这个异常的进程(就是刚才在 CPU 上运行的那个进程)。
  3. 内核会在这个进程的 task_struct(PCB)里,找到专门管理信号的位图结构 ,把对应 SIGFPE(浮点异常信号)的那一位从 0 改成 1 ------ 这就是 "向进程发送信号" 的本质。

三、进程层面:后续处理信号

  1. 当这个进程再次被调度到 CPU 上运行时,内核会先检查它的信号位图。
  2. 发现 SIGFPE 对应的位是 1,就会让进程执行对应的处理逻辑:
    • 默认处理:终止进程,输出 Floating point exception (core dumped)
    • 忽略处理:进程假装没收到这个信号,继续运行(通常不推荐);
    • 自定义处理:执行你提前注册好的信号处理函数。

当发生错误时,进程往往锁定的是当前进程,因为前面的进程已经离开,后面的进程还没开始。并且OS中有专门的结构用于指向当前进程,不用担心OS找不到。

使用函数产生信号

kill函数

kill 函数是 Linux 系统编程中用于进程间发送信号的系统调用(和终端的 kill 指令本质是一回事,kill 指令底层就是调用这个函数实现的)。

简单说:终端输入 kill -9 1234 是 "用户层面给进程发信号",而代码里调用 kill() 函数是 "程序层面给进程发信号"。

复制代码
#include <signal.h> 

// 返回值:成功返回0,失败返回-1(并设置errno)
int kill(pid_t pid, int sig);

pid是目标进程PID,sig是所需要执行的信号。

raise函数

raise 函数是 Linux 系统编程中专门用于 "进程给自己发信号" 的函数(可以理解为 kill 函数的 "简化版"------ 不用指定目标 PID,默认发给当前进程)。

简单说:kill(getpid(), sig) 等价于 raise(sig),前者是 "手动指定自己的 PID 发信号",后者是 "直接给自己发信号",底层最终都会调用内核的信号发送逻辑。

复制代码
#include <signal.h> 

// 返回值:成功返回0,失败返回非0(注意:和kill函数返回值规则不同)
int raise(int sig);

abort函数

abort 函数是 Linux 系统编程中专门用于让进程 "主动异常终止" 的函数,它的核心作用是:强制当前进程崩溃退出,并生成 core dump 文件(默认行为)。

简单说:abort() = 进程主动给自己发送无法被捕获 / 忽略的 SIGABRT 信号,最终效果是 "程序崩溃 + 生成 core 文件",常用于程序检测到致命错误(如参数非法、资源初始化失败)时主动终止。

复制代码
#include <stdlib.h>  

// 无参数,无返回值(因为调用后进程必然终止,不会返回)
void abort(void);

由软件产生信号

简单闹钟程序

alarm函数

alarm 函数是 Linux 系统中给当前进程设置 "定时器" 的函数 ------ 你指定一个秒数,内核会在这个时间到后,给当前进程发送 SIGALRM(14 号信号,也叫闹钟信号,默认直接终止进程)。

简单说:alarm(5) = 告诉内核 "5 秒后给我这个进程发一个 SIGALRM 信号",本质是内核层面的定时提醒,常用于实现 "超时控制"(比如让程序执行某操作最多等 5 秒,超时就终止)。

复制代码
#include <unistd.h> 

// 返回值:成功返回"上一个定时器剩余的秒数",失败返回-1(几乎不会失败)
unsigned int alarm(unsigned int seconds);

seconds > 0时设置定时器,seconds 秒后发 SIGALRM; seconds = 0时取消当前所有定时器。

alarm 函数的返回是返回上一个定时器剩余的秒数。

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

// 14号信号处理函数:闹钟响了就打印提醒
void alarm_ring(int sig) {
    printf("\n 时间到了\n");
}

int main() {
    int sec;
    printf("输入定时秒数:");
    scanf("%d", &sec);

    signal(SIGALRM, alarm_ring);  // 注册14号信号处理函数
    alarm(sec);                   // 设置定时器(sec秒后发14号信号)

    printf("等待%d秒...\n", sec);
    while(1);  // 死循环等待信号(不退出)
    return 0;
}

在 SIGALRM 信号处理函数里重新调用 alarm(),就能实现 "响铃后自动重置定时器",达到重复提醒的效果。

复制代码
int alarm_sec;

void alarm_ring(int sig) {
    printf("\n 时间到了!%d秒后再次提醒\n", alarm_sec);
    alarm(alarm_sec);  // 重新设置定时器,实现重复提醒
}
相关推荐
Je1lyfish2 小时前
CMU15-445 (2026 Spring) Project#2 - B+ Tree
linux·数据结构·数据库·c++·sql·spring·oracle
乐大师2 小时前
Linux普通用户设置开机自启服务
linux·服务器·开机自启动
fengyehongWorld2 小时前
Linux wsl中使用windows命令
linux·运维·windows
躺不平的小刘3 小时前
视觉SLAM十四讲:全攻略 —— 逻辑脉络、学习路线与Ubuntu 18.04实践准备
linux·学习·ubuntu·slam
默|笙3 小时前
【Linux】进程间通信(2)_进程池
linux
项目工程打工马3 小时前
Ubuntu 上 Redis 安装和使用详细指南(新手友好版)
linux·redis·ubuntu
生活很暖很治愈3 小时前
Linux——HTTP协议
linux·服务器·c++·网络协议·ubuntu·http
**蓝桉**4 小时前
Prometheus时间出现误差
linux·运维·prometheus
vortex54 小时前
文件上传漏洞绕过技术总结(含实操指南与防御方案)
linux·服务器·网络安全·渗透测试