Linux进程信号:从产生、保存到递达的全流程解析

一、什么是信号

1. 信号本质上只是一个整数编号。

Linux 定义了很多信号:

信号就是 Linux 内核向进程发送的一种**"异步事件通知"**,它本身只携带事件类型(信号编号),用于告诉进程发生了什么事情。

2. 信号是异步的

信号是异步的 = 操作系统可以在任意时间给进程发通知,进程正在执行的任何代码都会被临时打断,去处理信号,处理完再恢复运行。

  • 同步:按顺序、主动调用、必须等待
  • 异步:随时来、被动接收、强制打断

3. 信号处理

进程必须处理产生的信号,处理方式有 3 种:

  1. 忽略(不理它)
  2. 默认动作(终止、暂停、继续...)
  3. 自定义处理(自己写函数处理)

4. 信号的生命周期

tex 复制代码
1. 产生  →  内核发出信号
   ↓
2. 未决  →  信号被阻塞,挂起等待(存在未决集)
   ↓
3. 递达  →  阻塞解除,信号送达进程
   ↓
4. 处理  →  执行默认/忽略/自定义函数
   ↓
5. 结束  →  信号消失

5. 初步见识信号

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

int main() {

    while (true) {
        std::cout << "test sig..." << getpid() << std::endl;
        sleep(1);
    }

    return 0;
}

通过命令 kill -信号 进程pid 给指定进程发送信号

此时进程776364接收到了信号 -2 做出了指定动作------停止。

函数 signal:

c 复制代码
#include <signal.h>

// 1. 定义了一个类型:sighandler_t
typedef void (*sighandler_t)(int);

// 2. signal 函数本身
sighandler_t signal(int signum, sighandler_t handler);

作用:给指定的信号,注册一个 "处理函数",告诉内核:当这个信号来的时候,执行我指定的动作。

typedef void (*sighandler_t)(int);这行的意思:定义了一个叫 sighandler_t 的类型 ;它代表:一个函数指针

这个函数的格式是:

  • 返回值:void
  • 参数:int(信号编号)

也就是:

c 复制代码
void handler(int sig);

两个参数

  1. int signum

    信号编号:SIGINTSIGTERM...(可以填字母符号,也可以用数字,因为二者一样,是通过宏定义的 #define SIGINT 2

  2. sighandler_t handler(传一个函数进来)

    信号处理方式:

    • SIG_IGN 忽略
    • SIG_DFL 默认
    • 自定义函数名

返回值

  • 成功:返回原来老的信号处理函数
  • 失败:返回 SIG_ERR

函数位置:要写在 main 开头!!!

因为:

  • signal 是注册动作,不是调用动作
  • 必须先注册,后接收信号
  • 如果你写在后面,前面的代码收不到信号

就像:

必须先安装门铃,有人按门铃你才听得见。


代码实例:

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

// signo是被signal中的2传递进来的
void hander(int signo) {
    std::cout << "收到了一个信号:" << signo << "执行自定义操作" << std::endl;
}

int main() {

    signal(2, hander);
    signal(3, hander);

    while (true) {
        std::cout << "test sig..." << getpid() << std::endl;
        sleep(1);
    }

    return 0;
}

那这么说的话,当我把所有信号都自定义了,都不退出,那进程是不是杀不掉呢?并不是这样,因为其中的9、19号等一些信号不可被自定义!!!

二、信号产生

1. 什么叫「信号产生」?

信号产生 = 操作系统内核创建了一个信号,并准备发给某个进程。此时信号还没被处理,只是出生了

2. 信号产生的 5 大来源

1)键盘产生(只能控制前台进程)

  • Ctrl + CSIGINT(终止信号)
  • Ctrl + \SIGQUIT(退出信号)
  • Ctrl + ZSIGTSTP(暂停信号,变后台)

2)硬件异常产生(程序出错)

  • 除 0 → SIGFPE
  • 非法访问内存(段错误)→ SIGSEGV

3)系统调用 / 函数产生(代码主动发)

  • kill():发给指定进程
c 复制代码
int kill(pid_t pid, int sig);

代码实例:

c 复制代码
//文件 mykill.cpp
#include <iostream>
#include <string>
#include <signal.h>
#include <cstring>

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

// ./mykill signo pid
int main(int argc, char* argv[]) {
    if (argc != 3) {
        Usage(argv[0]);
        exit(1);
    }
    int signo = std::stoi(argv[1]);
    pid_t pid = std::stoi(argv[2]);

    int r = kill(pid, signo);
    if (r == -1) {
        std::cerr << "kill失败:" << strerror(errno) << std::endl;
        exit(2);
    }

    return 0;
}
c 复制代码
//文件 loop.cpp
#include <iostream>
#include <unistd.h>

int main() {
    while (true) {
        std::cout << "我是一个进程:" << getpid() << std::endl;
        sleep(1);
    }
    return 0;
}
  • raise():发给自己
c 复制代码
int raise(int sig);
// 相当于
kill(getpid(), sig);

代码实例:

c 复制代码
#include <iostream>
#include <signal.h>
#include <cstring>

void hander(int signo) {
    std::cout << "进程捕捉到信号:" << signo << "; 捕捉进程pid:" << getpid() << std::endl;
}

int main() {
    signal(2, hander);
    while (true) {
        std::cout << "进程正在运行:" << getpid() << std::endl;
        sleep(2);
        int r = raise(2);
        if (r == -1) {
            std::cerr << "raise失败:" << strerror(errno) << std::endl;
            exit(1);
        }
    }
}
  • abort():自己崩溃,产生 SIGABRT
c 复制代码
#include <stdlib.h>
void abort(void);
// 完全等价于
kill(getpid(), SIGABRT);	//6 (core)
// 也相当于
raise(SIGABRT);

但比 raise :会立刻终止程序 ,不会执行后续代码,不会执行析构函数、清理函数,一定会触发 core dump(只要开了 ulimit -c unlimited)。

代码实例:

c 复制代码
#include <iostream>
#include <signal.h>
#include <cstring>

void hander(int signo) {
    std::cout << "进程捕捉到信号:" << signo << "; 捕捉进程pid:" << getpid() << std::endl;
}

int main() {
    signal(6, hander);
    while (true) {
        std::cout << "进程正在运行:" << getpid() << std::endl;
        sleep(2);
        abort();
    }
}

4)软件条件产生

  • 管道读端关闭,继续写 → SIGPIPE

  • 子进程退出 → SIGCHLD

  • alarm() 定时器

    c 复制代码
    #include <unistd.h>
    unsigned int alarm(unsigned int seconds);

    时间到 → 内核自动发 SIGALRM软件条件产生(不是函数调用产生);SIGALRM 默认动作:终止进程,程序会直接退出。若在倒计时的时间内,又遇到了alarm,会重置倒计时,重置时间为新的alarm的参数。

参数seconds:秒数。> 0:N 秒后发信号;0:取消之前设置的闹钟

返回值:返回重置时,上一次倒计时剩余的时间。

5)命令产生(shell 里用)

  • kill 进程号SIGTERM
  • kill -9 进程号SIGKILL

信号产生后保存在进程的 task_struct 通过位图 unsigned int sigs; 0000 ... 0000 储存 ;信号产生也就是写信号(修改位图),那么这样修改 task_struct 的操作只能是OS来做的!

3. 不同信号会做什么

命令 man 7 signal 可以查看信号

其中圈出标注的是不同的终止流程: Term 表示进程终止; Core 表示进程退出,核心转储(进程出错,需要检查)为了便于用户调试错误,内存会把错误信息转储到磁盘中(核心转储,为了后续Debug)!

手动开启核心转储功能后:

c 复制代码
int main() {
    // signal(2, hander);
    // signal(3, hander);
    while (true) {
        std::cout << "test sig..." << getpid() << std::endl;
        sleep(1);
        // 模拟除零
        int a = 10;
        a /= 0;
    }
    return 0;
}

(在Ubuntu系统下不会在当前目录下生成core-file文件,不好观察)

**为什么会默认关闭核心转储呢?**因为当软件服务挂掉之后,异常往往非常复杂,会将磁盘占满。

**进程不是挂掉了吗?那Floating point exception (core dumped)这些是谁打出来的?**是bash打印在屏幕上的。因为我们的代码是bash的子进程,代码终止时,会标识 core dump 传递给父进程(bash)。

三、信号保存

1. 相关状态

  • 实际执行信号的处理动作称为信号递达(Delivery)
  • 信号从产生到递达之前的状态,称为信号未决(Pending)
  • 进程可以选择阻塞某个信号(Block)

2. 信号的存储

3. 信号控制

OS需要用户控制信号,本质就是访问和操作上面的三张表!(操作内核数据结构,就得用系统调用!就像 signal()

pending未决信号集 ;而 阻塞信号集 block 一般叫做信号屏蔽字

sigset_t 就是一个 "信号集合" 的类型 :它专门用来存放一堆信号 ,本质就是一个位图(bit mask) ,用来表示哪些信号要阻塞、哪些要忽略、哪些要处理 。可以把它理解成:一个开关面板,每一位代表一个信号的开关

c 复制代码
// 伪代码
struct sigset_t {
    unsigned long signals[...]; // 存一堆信号:要不要阻塞、要不要响应
};

4. 相关系统调用

(1)sigprocmask

c 复制代码
#include <signal.h>

// 成功返回0,失败返回-1并设置errno
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

作用:是阻塞 / 解除阻塞指定信号,让进程临时忽略某些信号(防止被信号中断关键代码)。

how 的 3 种取值

  1. SIG_BLOCK添加阻塞信号 → 新屏蔽字 = 旧屏蔽字 | set(set 中的信号都会被阻塞)
  2. SIG_UNBLOCK解除阻塞信号 → 新屏蔽字 = 旧屏蔽字 & (~set)(set 中的信号不再阻塞)
  3. SIG_SETMASK直接覆盖屏蔽字 → 新屏蔽字 = set(完全替换原有屏蔽字)

**第2、3个参数都是要自己事先声明出来的!!!**声明出来之后肯定要初始化呀,但是系统自带的类型 sigset_t 怎么初始化?当然有系统函数调用来初始化

(2)sigemptyset(&set)

作用:清空信号集 → 全部变成 0

那初始化之后肯定也要设置将哪一个信号block阻塞吧!

(3)sigaddset(&set, 信号)

作用:往空集合里添加一个你要阻塞的信号

(4)sigpending

c 复制代码
#include <signal.h>

// 成功返回0,失败返回-1并设置errno
int sigpending(sigset_t *set);

作用:查看 "被pending" 的信号

参数:set 只是输出型参数,获取当前的pending表。

那我们可以获取pending表,那怎么修改pending表呢?其实修改pending表就是产生信号嘛!!!信号产生的5中方法已经讲过了。就像 kill() 之类的操作。

(5)sigismember

作用:判断某个信号在不在信号集里

c 复制代码
int sigismember(const sigset_t *set, int signum);

返回值:

  • 1 → 在里面(真)
  • 0 → 不在(假)
  • -1 → 出错

5. 代码综合测试

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

void PrintPending(const sigset_t pending) {
    for (int signo = 31; signo > 0; --signo) {
        if (sigismember(&pending, signo)) {
            std::cout << "1";
        }
        else {
            std::cout << "0";
        }
    }
    std::cout << std::endl;
}

int main() {
    // 1. 屏蔽2号信号
    sigset_t block_set, old_set;
    sigemptyset(&block_set);
    sigemptyset(&old_set);
    sigaddset(&block_set, SIGINT);

    int r = sigprocmask(SIG_SETMASK, &block_set, &old_set);
    if (r == -1) {
        std::cerr << "sigprocmask失败:" << strerror(errno) << std::endl;
        exit(1);
    }

    while (true) {
        sigset_t pending;
        sigemptyset(&pending);

        // 2. 获取pending表
        int r = sigpending(&pending);
        if (r == -1) {
            std::cerr << "sigpending失败:" << strerror(errno) << std::endl;
            exit(2);
        }

        // 3. 打印pending表
        PrintPending(pending);
        sleep(2);
    }
    return 0;
}

屏蔽了2号信号,但是pending表能接收,所以pending表中会显示000...0010,但是不会终止进程

测试解除信号

c 复制代码
while (true) {
    sigset_t pending;
    sigemptyset(&pending);

    // 2. 获取pending表
    int r = sigpending(&pending);
    if (r == -1) {
        std::cerr << "sigpending失败:" << strerror(errno) << std::endl;
        exit(2);
    }

    // 3. 打印pending表
    PrintPending(pending);

    if (cnt == 15) {
        // 4. 解除2号信号的屏蔽
        int r = sigprocmask(SIG_SETMASK, &old_set, nullptr);
        if (r == -1) {
            std::cerr << "sigprocmask解除2号屏蔽失败:" << strerror(errno) << std::endl;
            exit(3);
        }
        std::cout << "2号信号屏蔽已解除" << std::endl;
    }
    ++cnt;
    sleep(1);
}

我们发现程序最后并没有将2号信号屏蔽已解除 打印到屏幕上,因为一旦信号解除了屏蔽,就会立即被递达,进行处理;所以2好信号直接将进程终止,没有执行之后的代码。那么我们可以修改代码,捕捉2号信号,改为打印内容:

c 复制代码
void handler(int signo) {
    std::cout << "处理了" << signo << "号信号" << std::endl;
}    
    
// 0. 捕捉2号信号
signal(2, handler);

注意:只要被信号解除了屏蔽,pending表中即刻变为0,也就是在调用处理方法前就已经变为0了

四、信号处理

1. 用户态 && 内核态

用户态:进程执行代码,访问数据都在**0, 3GB**地址空间到时候,就是访问用户自己的代码,自己的数据

内核态:都在访问**3GB, 4GB**地址空间的时候,就是访问OS的过程

内核态权限级别更高

2. 信号处理时的转移

处理时刻:就是do_signal()的时刻(分三种情况的时刻)

3. 补充"中断"

(1)当调用 scanf 时,OS怎么知道键盘被按下了?

有没有可能是轮询?不对,效率太低了;其实是通过一种**"硬件中断"**技术;

也就是CPU是很忙的,当外设就绪(键盘写入了),会通知CPU停止正进行的进程并保护现场 ,寄存器保存的就是进程上下文;CPU根据中断信号,去执行中断方法,这些方法都是安排在OS中(如果在硬件层面的话太复杂)的中断向量表中,中断向量表就是一些函数指针的集合,它是OS的一部分,启动就加载到内存中了。执行完毕之后恢复现场,继续之前的进程。

(2)操作系统OS由谁来"调度"?

"时钟中断"

CPU 上安装了一个闹钟,每隔固定时间响一次,强制 CPU 暂停当前进程,转而执行操作系统代码。

进程需要被调度,因为多个进程要竞争CPU;而OS(内核)不需要被调度,因为它本身就是CPU在处理系统调用、中断、异常时执行的代码。CPU从用户态切到内核态,本质上不是"调度OS",而是"开始执行内核代码"。

(3)为什么OS能计算时间?

OS 能计算时间,不是因为它自己有时间概念,而是因为硬件定时器会周期性地产生时钟中断,OS 通过统计这些中断次数来计算时间。

(4)什么是时间片?

OS 通过硬件定时器产生的时钟中断来统计进程已经运行了多久,当发现当前进程占用 CPU 达到一定额度时,就触发调度,把 CPU 切换给其他进程,这个额度就是时间片。

(5)什么叫时间片耗尽?

c 复制代码
struct task_struct {
    // 1. 时间片 → 就在这里!
    int time_slice;      // <--- 剩余时间片(核心!)

    // 2. 进程状态
    volatile long state; // 运行/就绪/睡眠...

    // 3. 优先级、调度类等
    // ...
};

当进程运行时:

  1. 时钟中断到来(每 10ms 一次)
  2. 内核执行:current->time_slice--; 当前进程剩余时间片 -1
  3. 直到:current->time_slice == 0 这就叫:时间片耗尽!

时间片耗尽后,内核立刻调用 schedule() 切换进程

c 复制代码
if (current->time_slice == 0) {
    // 1. 触发调度
    schedule();
}

(6)CPU主频是什么?

主频 = CPU 内部时钟脉冲频率,1GHz = 109次 / 秒

和时钟中断、时间片区分

  1. CPU 主频:CPU 芯片本身运行快慢(微观:指令执行速度)
  2. 系统时钟 (HZ,100Hz) :主板定时器,10ms 一次时钟中断(宏观:操作系统节拍、扣时间片)

时钟发生器:3.2GHz;

时钟计数器(系统可配置):值设置成3.2GHz->时钟中断为1s触发;设置成3.2GHz/10->时钟中断为100ms触发。

时钟发生器+时钟计数器 = 时钟源

(7)系统调用

用户程序想调用内核 → 必须用软中断跳进去!

在内核层面,每一个系统调用,都是一个系统调用号!也就是数组下标

tex 复制代码
用户程序
   ↓
调用系统调用(read、sigprocmask...)
   ↓
触发 软中断 (int 0x80 / syscall)
   ↓
CPU 切换到 内核态
   ↓
内核执行服务
   ↓
返回用户态

C 函数封装真正的系统调用

c 复制代码
sigprocmask(...);
signal(...);
fork();

这些都不是 "真正的系统调用"!

它们只是:C 语言库(glibc)给你包的一层 "壳函数"

系统调用是内核提供的功能编号

c 复制代码
// 伪代码
int sigprocmask(...) {
    把参数放进寄存器
    把系统调用号 120 放进寄存器
    执行 **软中断指令 int 0x80**  // 进入内核!
}

(8)操作系统的本质

操作系统就像是一个被各种中断、异常、系统调用不断唤醒的躺在那的代码块

软中断:比如int 0x80或者syscall等,叫做"陷阱"

软中断:比如除零/野指针等,叫做"异常"

(9)用户态 (Ring3)↔内核态 (Ring0)

用户态 = 受限制的普通程序(你的代码)

内核态 = 拥有最高权限的操作系统(管家)

CS = Code Segment 代码段寄存器 ,它是CPU 硬件级的寄存器

CS 寄存器的 最低 2 位 = CPL (Current Privilege Level)表示当前 CPU 处于什么态

  • 00 → Ring 0(内核态)
  • 11 → Ring 3(用户态)

调用系统调用、发生中断时:CPU 硬件自动把 CS 最低两位从 11 → 00(不是软件改的,是硬件强制改!)

4. sigaction

c 复制代码
int sigaction(int signum,          // 你要捕捉哪个信号
              const struct sigaction *act,  // 新的处理方式
              struct sigaction *oldact);    // 保存旧的处理方式(输出)
// 返回值:成功 → 0;失败 → -1

作用:注册一个信号处理函数,当信号来的时候:

  1. 不执行默认动作
  2. 执行你自己写的函数
  3. 自带屏蔽信号、防止重入等高级功能

核心结构体 struct sigaction 彻底解析

c 复制代码
// 老样子,得先自己声明一个结构体变量,再给这个变量赋值
struct sigaction {
    void (*sa_handler)(int);       // 信号处理函数
    sigset_t sa_mask;              // 信号屏蔽集
    int sa_flags;                  // 行为标志,不管
    void (*sa_restorer)(void);     // 废弃,不用管
};

① sa_handler ------ 处理函数指针

三种取值:

  1. SIG_IGN → 忽略信号
  2. SIG_DFL → 使用默认动作
  3. 自己写的函数 → 自定义捕捉
c 复制代码
act.sa_handler = my_handler;

② sa_mask ------ 信号屏蔽集(超级重要)

作用:屏蔽 sa_mask 里的所有信号(屏蔽额外的,不设置时默认一定会屏蔽自身),防止被嵌套打断。

补充 :当某个信号的处理函数被调用时,内核自动将当前进程信号加入到信号屏蔽字,当信号处理函数返回时,自动恢复原来的信号屏蔽字。这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。防止信号处理函数被 "嵌套打断",保证它能完整、安全、不被干扰地执行完!

使用步骤:

c 复制代码
sigemptyset(&act.sa_mask);    // 先清空
sigaddset(&act.sa_mask, SIGINI); // 处理信号时屏蔽 SIGINI

五、可重入函数

什么是重入函数?假如一个函数 insert(),main函数在调用insert,一个信号来了,执行handler,也调用insert,两个执行流都执行insert导致出了问题,那么insert() 函数就是不可重入函数 ;一些函数也发生这样的多执行流同时执行时,但是没有影响,那就是可重入函数

不可重入函数:一般是用了内存管理,比如malloc、free或者I/O的库函数(标准I/O库的很多实现都以不可重入的方式使用全局数据结构)。

六、volatile关键字

volatile = 告诉编译器:这个变量随时可能被意外改变 ,不许优化,每次都必须去内存里读最新值!

c 复制代码
// 全局变量,被信号和 main 同时访问
int flag = 0;

void handler(int sig) {
    flag = 1;  // 信号来了,把 flag 改成 1
}

int main() {
    // 注册信号...
    while (flag == 0) {
        // 死等信号
    }
    printf("退出循环\n");
}

不加 volatile 会发生什么灾难?

编译器一看:

flag 在 main 里根本没被修改!

我帮你优化成:直接读寄存器,不用去内存读!

结果:

就算信号把 flag 改成 1,main 循环也永远看不见!→ 死循环卡死!

c 复制代码
// 修改代码
volatile int flag = 0;

作用:

  1. 告诉编译器:这个变量会被别人偷偷改(信号 / 硬件 / 其他线程)
  2. 不许优化!
  3. 每次使用都必须去内存里重新读取最新值!

七、SIGCHLD信号

SIGCHLD:子进程退出时,内核发给父进程的信号

作用 :通知父进程来回收子进程尸体

标准做法

  • sigaction 注册
  • 处理函数里 while(waitpid(...WNOHANG))
  • 彻底杜绝僵尸进程

代码示例:

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/wait.h>
#include <unistd.h>

// 信号处理函数:回收僵尸子进程
void handler(int sig) {
    // -1,循环回收所有退出的子进程
    // WNOHANG = 非阻塞,没有僵尸就立刻返回
    while (waitpid(-1, NULL, WNOHANG) > 0);
}

int main() {
    // 1. 注册 SIGCHLD 捕捉
    struct sigaction act;
    act.sa_handler = handler;
    sigemptyset(&act.sa_mask);
    act.sa_flags = 0;
    
    sigaction(SIGCHLD, &act, NULL); // 监听子进程死亡

    // 2. 创建子进程
    pid_t pid = fork();

    if (pid == 0) {
        // 子进程
        printf("我是子进程 %d,马上退出\n", getpid());
        sleep(1);
        exit(0); // 子进程退出 → 发 SIGCHLD
    }

    // 父进程继续干自己的活,不用死等 wait
    while (1) {
        printf("父进程干活中...\n");
        sleep(1);
    }

    return 0;
}
相关推荐
chushiyunen2 小时前
linux环境部署php、php-npm
linux·npm·php
草莓熊Lotso2 小时前
【Linux网络】深入理解 HTTP 协议(四):完善 C++ HTTP 服务器:从协议原理到生产级实现
linux·运维·服务器·c语言·网络·c++·http
sulikey2 小时前
个人Linux操作系统学习笔记7 - 进程理解
linux·笔记·学习·操作系统·进程·pid
兔老大RabbitMQ2 小时前
涉及泛型的强制转换
linux·windows·microsoft
老约家的可汗2 小时前
Linux中yum、vim和gcc
linux·运维·vim
Anthony_2312 小时前
Linux 从基础操作到故障排查
linux·运维·服务器·网络·nginx·ubuntu·centos
2301_789015622 小时前
Lnux权限
linux·开发语言·c++·权限
楚枫默寒10 小时前
Linux 编辑文件后自动添加修改日期
linux·运维·bash
2601_9611940212 小时前
27考研刘晓艳单词pdf
linux·sql·ubuntu·华为·pdf·.net