【Linux系统】进程信号:信号的产生和保存

上篇文章我们介绍了Syetem V IPC的消息队列和信号量,那么信号量和我们下面要介绍的信号有什么关系吗?其实没有关系,就相当于我们日常生活中常说的老婆和老婆饼,二者并没有关系

1. 认识信号

1.1 生活角度的信号解释(快递比喻)

  • 识别信号(快递到来) :正如你等待快递时能识别快递员的到来,进程也能识别信号的产生。这是因为信号识别是操作系统内核的内置特性,由内核程序员实现。进程通过内核维护的数据结构(如位图表)自动检测信号是否产生,无需用户干预。这类似于你知道快递到来时会收到通知,但识别机制是预先定义的。
  • 处理方法的预先准备:在信号产生之前,进程已经知道如何处理信号。例如,你知道快递到来时该如何处理(打开、赠送或忽略),进程也预先定义了信号的处理动作(默认、自定义或忽略)。这通过内核中的“handler表”(一个函数指针数组)实现,其中存储了每个信号的处理函数。如果信号未产生,进程仍然“知道”处理方法,因为handler表在进程启动时已初始化。
  • 非立即处理(延迟执行) :信号产生后不一定会立即处理,就像快递到来时你可能在打游戏而延迟5分钟取件。进程可能因执行更高优先级的任务(如系统调用)而阻塞信号,导致信号处于“未决”(Pending)状态,直到合适时机(如进程从内核态切换到用户态)才处理。这体现了信号处理的异步性和优先级机制。
  • 时间窗口和信号保存:从信号产生到处理之间有一个时间窗口,信号被保存在“未决”状态。这类似于你知道快递已到楼下但还未取件的阶段,进程通过pending位图记录信号是否已产生但未处理(位图比特位置1表示未决)。信号保存确保进程在后续能“记住”信号,避免丢失。
  • 处理方式(递达后的动作) :当信号被处理(递达)时,进程执行三种动作之一,对应快递处理方式:
    • 默认动作:如幸福地打开快递(使用商品)。在信号处理中,默认动作通常是终止进程(如SIGINT)或忽略(如SIGUSR1),具体由内核定义。
    • 自定义动作:如将零食送给女朋友。进程可注册用户自定义函数(通过sigaction),在信号递达时执行特定逻辑(如打印日志或修改数据)。
    • 忽略动作:如取件后扔掉快递继续打游戏。进程可明确忽略信号(使用SIG_IGN),但某些信号(如SIGKILL)不能被忽略。
  • 异步特性:快递到来时间不可预测,类似信号产生是异步的——它可能由外部事件(如用户输入Ctrl+C)、内核或其他进程触发,进程无法准确预知信号何时产生。这要求信号机制必须支持非阻塞保存和延迟处理。

基本结论

  • 识别信号的内置特性:进程识别信号依赖于内核维护的数据结构,如pending位图(记录信号是否未决)和block位图(记录是否阻塞)。识别是自动的,由内核实现,无需用户代码干预。例如,描述进程通过“两个位图 + 一个函数指针数组”识别信号,这在内核中是硬编码的。
  • 处理方法的预先准备:信号处理动作在信号产生前已定义,存储在handler表中。程序可调用sigaction设置处理方式(默认SIG_DFL、忽略SIG_IGN或自定义函数)。如果未设置,内核使用默认动作。这确保了即使信号未产生,进程也知道如何处理。
  • 非立即处理(合适时机处理) :信号不立即处理的原因包括阻塞(Block)和优先级。阻塞时信号保持在未决状态,直到解除阻塞(如调用sigprocmask)。处理时机通常在进程从内核态返回用户态时,确保系统稳定性。和用快递延迟比喻解释此机制。
  • 信号保存和处理阶段:信号生命周期分为三个阶段:
    • 产生(Generation) :信号由事件触发(如键盘中断)。
    • 保存(Pending) :信号记录在pending位图中,处于未决状态。
    • 递达(Delivery) :信号被处理,执行handler表中定义的动作。
      阻塞信号会延长未决状态,直到阻塞解除。例如,和定义未决为“信号从产生到递达之间的状态”,并用位图实现保存。
  • 捕捉方式(信号处理动作) :处理动作统称为“信号捕捉”,包括:
    • 默认(SIG_DFL) :系统预定义行为,如终止进程。
    • 忽略(SIG_IGN) :丢弃信号,不执行任何操作。
    • 自定义:用户定义函数执行特定逻辑。

1.2 简单样例

我们先来一个简单的样例,来认识一下进程中的信号

#include <iostream>
#include <sys/types.h>
#include <unistd.h>int main()
{while (true){std::cout << "I am a process, pid: " << getpid() << std::endl;sleep(1);}return 0;
}

运行结果:

ltx@iv-ye1i2elts0wh2yp1ahah:~/gitLinux/Linux_system/lesson_sig/Sig$ make
g++ -o testsig testsig.cc -std=c++11
ltx@iv-ye1i2elts0wh2yp1ahah:~/gitLinux/Linux_system/lesson_sig/Sig$ ./testsig
I am a process, pid: 255198
I am a process, pid: 255198
I am a process, pid: 255198
I am a process, pid: 255198
I am a process, pid: 255198
I am a process, pid: 255198
^C    #用户按下Ctrl+C

用户输入命令启动Shell前台进程后:

  1. 当用户按下Ctrl+C时,系统会捕获这个键盘输入产生的硬件中断
  2. 操作系统将该中断解释为信号并发送给目标前台进程
  3. 前台进程收到信号后触发终止流程,最终退出执行

实际上,Ctrl+C 的本质是向前台进程发送 SIGINT(即 2 号信号)。为了验证这一点,我们需要引入一个系统调用函数来进行演示。

signal系统调用

更多相关内容可以通过man手册来查看

一、signal 系统调用的核心功能与定义

signal() 是 Linux 中用于修改进程对特定信号处理行为的系统调用,其核心功能包括:

  1. 捕获信号:注册自定义处理函数,替代默认行为(如 Ctrl+C 触发 SIGINT 时执行自定义逻辑)。
  2. 忽略信号:指定 SIG_IGN 使进程完全忽略信号(如 SIGCHLD 避免僵尸进程)。
  3. 恢复默认:指定 SIG_DFL 还原内核默认行为(如 SIGTERM 终止进程)。

函数原型与参数解析

#include <signal.h>  // 复杂声明(传统写法)  
void (*signal(int signum, void (*handler)(int)))(int);  // 简化类型定义(POSIX 标准)  
typedef void (*sighandler_t)(int);  
sighandler_t signal(int signum, sighandler_t handler);  
  • signum:信号编号(如 SIGINT=2SIGKILL=9)。
  • handler:处理函数指针,或预定义常量 SIG_IGN/SIG_DFL

二、内核实现机制与关键行为

1. 处理函数注册流程

  • 内核通过进程的 task_struct 维护 struct sigaction 数组,存储每个信号的处理配置。

  • 调用 signal() 时,内核更新对应信号的 sa_handler 字段:

    new_sa.sa.sa_handler = handler;  
    new_sa.sa.sa_flags = SA_ONESHOT | SA_NOMASK;  // 传统 signal 的隐式标志  
    do_sigaction(signum, &new_sa, &old_sa);      // 更新信号处理表  
    
    • SA_ONESHOT:处理函数执行一次后自动恢复为默认行为(遗留问题)。
    • SA_NOMASK:执行处理函数时不自动阻塞当前信号(可能导致重入)。

2. 信号递达时的关键操作

当信号递达(Delivery)时:

  1. 重置处理方式:若通过 signal() 注册自定义函数,内核会先将处理方式重置为 SIG_DFL(除非使用 sigaction 显式避免)。
  2. 执行处理函数:在用户态调用注册的 handler(int sig)
  3. 阻塞机制:默认不阻塞同类型信号,可能导致处理函数被重入(需手动屏蔽)。

那我们下面就来捕获一下,看看按下Ctrl+C后发送的是不是2号信号

#include <iostream>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>void handlerSig(int sig)
{std::cout << "获得了一个信号: " << sig << std::endl;
}int main()
{signal(SIGINT, handlerSig);while (true){std::cout << "I am a process, pid: " << getpid() << std::endl;sleep(1);}return 0;
}

运行结果:

ltx@iv-ye1i2elts0wh2yp1ahah:~/gitLinux/Linux_system/lesson_sig/Sig$ make
g++ -o testsig testsig.cc -std=c++11
ltx@iv-ye1i2elts0wh2yp1ahah:~/gitLinux/Linux_system/lesson_sig/Sig$ ./testsig
I am a process, pid: 257107
I am a process, pid: 257107
I am a process, pid: 257107
I am a process, pid: 257107
^C获得了一个信号: 2
I am a process, pid: 257107
I am a process, pid: 257107
I am a process, pid: 257107
^C获得了一个信号: 2
I am a process, pid: 257107
I am a process, pid: 257107
I am a process, pid: 257107
I am a process, pid: 257107
I am a process, pid: 257107
^\Quit (core dumped)

我们可以看到,按下 Ctrl+C 后打印消息而非终止进程。所以,Ctrl+C 触发了 SIGINT 信号,此时会执行我们写的自定义逻辑。这个时候我们 Ctrl+C 终止不了这个进程,但是我们还可以使用 Ctrl+\ 来结束这个进程。

也许你可能还是会有疑问:

1. 为什么进程不退出?

因为我们修改了 SIGINT 信号的默认处理方式

  • 信号的默认行为 (Default Action):每个信号都有一个默认行为。对于 SIGINT (信号编号2),其默认行为是 Term,即终止进程

  • 自定义行为 (Custom Action):在我们的代码中,通过 signal(SIGINT, handlerSig); 这行代码,告诉操作系统:“当我的进程收到 SIGINT 信号时,请不要执行默认的终止操作,请转而执行我提供的 handlerSig 函数”。

  • 因此,当我们按下 Ctrl+C,信号产生了,进程并没有终止,而是去执行了 cout << "获得了一个信号: 2" << endl;

2. 为什么还可以用 Ctrl+\ 来结束进程?

因为 Ctrl+\ 会发送另一个不同的信号:SIGQUIT (信号编号3)。

  • SIGQUIT 的默认行为:它的默认行为是 Core,即终止进程并生成一个核心转储文件 (core dump),用于调试。在输出中看到的 Quit (core dumped) 就印证了这一点。

  • 我们没有修改它的处理方式:我们的代码只捕获了 SIGINT,没有捕获 SIGQUIT。所以当 SIGQUIT 信号到来时,进程依然执行其默认行为——终止自己。


结合“快递”类比解释 Ctrl-C 的处理过程

让我们将快递类比和这个 Ctrl-C 的例子一一对应起来:

步骤快递场景 (你)信号处理 (进程)对应代码/现象
1. 识别与准备你知道快递来了该怎么处理(拆开、送人、忽略)。进程提前知道收到 SIGINT 该怎么处理。signal(SIGINT, handlerSig);
2. 信号产生快递员到了楼下,给你打电话(通知到来)。用户按下 Ctrl+C,硬件产生中断,内核识别到并给前台进程发送 SIGINT 信号。你按下 Ctrl+C
3. 信号保存你正在打游戏,记住“有快递要取”,但不立即处理进程可能正在执行 cout 或 sleep。内核将 SIGINT 信号标记在该进程的未决信号集中,等待处理。进程此时并不会被立即打断。按下 Ctrl+C 后,sleep 或 cout 语句可能还会执行完。
4. 信号处理你一局游戏打完(到达一个合适的时机),下楼取快递并按照预定方式处理(比如送给女朋友)。进程从内核态返回用户态时(这是一个合适的时机,例如 sleep 函数被信号中断返回、或者一个系统调用结束),会检查是否有未决信号。发现有 SIGINT,于是执行自定义处理函数打印出 获得了一个信号: 2
5. 行为差异你只处理了A快递(送人),但B快递(水电费账单)来了你还是会按默认方式处理(拆开查看)。进程只自定义了 SIGINTSIGQUIT 仍按默认方式处理(终止进程)。Ctrl+\ 可以杀掉进程

解释和补充

  • “signal函数仅仅是设置了特定信号的捕捉行为处理方式,并不是直接调用处理动作。”

    • signal() 只是一个注册设置操作。它像是在门口贴了一张纸条:“如果快递是零食(SIGINT),请放在门口;如果是账单(SIGQUIT),照常敲门”。纸条本身不会引来快递,只有快递真正到来时,纸条上的指示才会被读取和执行。

  • “Ctrl-C 产生的信号只能发给前台进程。” && “Shell可以同时运行一个前台进程和任意多个后台进程”

    • 这是Shell的工作机制。& 会将进程放到后台运行,Shell会给它分配一个作业号(job number),但它无法接收来自终端的控制信号(如 Ctrl-CCtrl-\Ctrl-Z)。只有前台进程独占终端输入,才能接收这些信号。

  • “信号相对于进程的控制流程来说是异步(Asynchronous)的”

    • 这是信号最核心的特性。进程完全无法预测信号到来的准确时间。你的 main 函数中的代码可能执行到 coutsleep 或者任何一条指令时,信号都可能突然到来。进程的控制流程就像是在一条主路上开车,信号就像路边突然出现的广告牌或指示牌,你不知道它何时会出现,但出现时你就需要根据上面的信息做出反应(处理信号)。

总结一下:信号机制就是这样一个 “异步通知” 机制,进程需要 “提前预约” 处理方式,然后在 “合适的时机” 去处理已经 “被记录在案” 的信号。


前台进程VS后台进程

我们可以先来看一个现象

ltx@iv-ye1i2elts0wh2yp1ahah:~/gitLinux/Linux_system/lesson_sig/Sig$ ./testsig
I am a process, pid: 262958
I am a process, pid: 262958
I am a process, pid: 262958
I am a process, pid: 262958
ls
I am a process, pid: 262958
I am a process, pid: 262958
I am a process, pid: 262958
pwI am a process, pid: 262958
d
I am a process, pid: 262958
I am a process, pid: 262958
I am a process, pid: 262958
I am a process, pid: 262958
I am a process, pid: 262958
I am a process, pid: 262958
I am a process, pid: 262958
I am a process, pid: 262958
I am a process, pid: 262958
I am a process, pid: 262958
^C获得了一个信号: 2
I am a process, pid: 262958
I am a process, pid: 262958
I am a process, pid: 262958
I am a process, pid: 262958

我们可执行程序跑起来之后(此时是前台进程),我们再输入ls,pwd等指令是没有用的,

那我们再来把该进程变为后台进程,如: ./testsig &,也就是在后面加上&,执行这个命令后,我们的进程就成为了后台进程

ltx@iv-ye1i2elts0wh2yp1ahah:~/gitLinux/Linux_system/lesson_sig/Sig$ ./testsig &
[1] 263501
ltx@iv-ye1i2elts0wh2yp1ahah:~/gitLinux/Linux_system/lesson_sig/Sig$ I am a process, pid: 263501
I am a process, pid: 263501
I am a process, pid: 263501
ls
Makefile  testsig  testsig.cc
ltx@iv-ye1i2elts0wh2yp1ahah:~/gitLinux/Linux_system/lesson_sig/Sig$ I am a process, pid: 263501
I am a process, pid: 263501
I am a process, pid: 263501
pwdI am a process, pid: 263501/home/ltx/gitLinux/Linux_system/lesson_sig/Sig
ltx@iv-ye1i2elts0wh2yp1ahah:~/gitLinux/Linux_system/lesson_sig/Sig$ I am a process, pid: 263501
I am a process, pid: 263501
I am a process, pid: 263501
I am a process, pid: 263501
I am a process, pid: 263501
I am a process, pid: 263501
I am a process, pid: 263501
I am a process, pid: 263501
I am a process, pid: 263501
I am a process, pid: 263501
I am a process, pid: 263501

变成后台进程后,我们可以看到,使用ls, pwd等命令就没有问题了

注意:Ctrl+C只能终止前台进程,并不能终止后台进程

这是为什么呢?如何查看后台进程呢?那后台进程又如何终止呢?

为什么前台进程会“霸占”终端,而后台进程不会?

这完全是由 Shell 的行为决定的,目的是为了提供良好的人机交互体验。

  1. 前台进程 (Foreground Process)

    • 当你直接执行 ./testsig 时,Shell 会将自己挂起,并将终端的标准输入(stdin)、标准输出(stdout)、标准错误(stderr)的控制权完全交给这个子进程

    • 此时,testsig 进程是终端的“前台所有者”。你输入的每一个字符(lspwdEnter)都会直接发送给它,而不是 Shell。

    • 你的 testsig 程序在设计上只会在循环中打印信息和睡眠,它并没有编写处理 lspwd 这些命令的逻辑,所以这些你输入的字符对它来说是无意义的,它不会做出你期望的响应。你看到的 ls 和 pwI am a process... 混杂的输出,正是因为 testsig 的打印和你的输入同时竞争同一个终端输出造成的混乱。

  2. 后台进程 (Background Process)

    • 当你使用 & 执行 ./testsig & 时,Shell 会创建一个子进程来运行程序,但不会将自己挂起,也不会将终端的标准输入交给它

    • 终端的标准输入(你的键盘输入)始终由 Shell 自己管理。因此,你之后输入的 lspwd 等命令都会被 Shell 正常接收并解释执行。

    • 后台进程 testsig 仍然拥有向终端输出的权利(所以你能看到它的打印信息),但它无法从终端读取输入。如果它尝试读取输入,Shell 会将其自动挂起(Stopped),以防止它阻塞等待一个永远无法获得的输入。


为什么 Ctrl+C 只能终止前台进程?

  • Ctrl+C 产生的 SIGINT 信号,内核会发送给当前拥有该终端的前台进程组中的所有进程

  • 当你运行前台 ./testsig 时,它的进程组就是前台进程组,所以它能收到 SIGINT

  • 当你运行后台 ./testsig & 时,它的进程组不再是前台进程组。你此时在终端输入的 Ctrl+C,内核会将其发送给当前的前台进程组,也就是 Shell 本身。Shell 收到这个信号后,通常不会终止自己,而是会忽略它或者用它来做一些其他的交互提示(比如给你一个新提示符),所以后台进程完全收不到这个信号。


如何管理后台进程?

1. 查看后台进程

使用 Shell 内置命令 jobs

$ ./testsig &
[1] 263501 # [job_number] pid
$ jobs
[1]+  Running                 ./testsig &
  • [1]:作业编号(Job Number),由 Shell 分配管理,在当前 Shell 会话内有效

  • 263501:进程ID(PID),由操作系统分配,在整个系统内有效。

2. 将后台进程拉回前台

使用 fg %<job_number>。这样你就能再用 Ctrl+C 来终止它了。

$ fg %1 # 将作业编号为1的后台作业变为前台作业
# 此时终端再次被 testsig 霸占,可以用 Ctrl+C 终止

3. 终止后台进程

既然 Ctrl+C 无效,就必须使用 kill 命令通过发送信号来终止。

方法一:通过作业编号(推荐,在当前终端内操作最方便)

$ kill %1 # 向作业1发送默认的 TERM 信号,请求终止
$ jobs
[1]+  Terminated              ./testsig # 确认它已终止

方法二:通过进程ID(PID)

$ kill 263501    # 发送 SIGTERM (15),友好地请求终止
$ kill -9 263501 # 发送 SIGKILL (9),强制杀死,无法被捕获或忽略

示例:

ltx@iv-ye1i2elts0wh2yp1ahah:~/gitLinux/Linux_system/lesson_sig/Sig$ ./testsig
I am a process, pid: 265199
I am a process, pid: 265199
I am a process, pid: 265199
I am a process, pid: 265199
I am a process, pid: 265199
^Z
[1]+  Stopped                 ./testsig
ltx@iv-ye1i2elts0wh2yp1ahah:~/gitLinux/Linux_system/lesson_sig/Sig$ jobs
[1]+  Stopped                 ./testsig
ltx@iv-ye1i2elts0wh2yp1ahah:~/gitLinux/Linux_system/lesson_sig/Sig$ fg %1
./testsig
I am a process, pid: 265199
I am a process, pid: 265199
I am a process, pid: 265199
I am a process, pid: 265199
I am a process, pid: 265199
I am a process, pid: 265199
I am a process, pid: 265199
^C获得了一个信号: 2
I am a process, pid: 265199
I am a process, pid: 265199
I am a process, pid: 265199
I am a process, pid: 265199
^\Quit (core dumped)

注意:Ctrl+Z 可以将前台进程暂停,此时会成为后台进程

你可能又会问,为什么刚刚使用&运行后台进程的时候还会一直在终端上打印,那我Ctrl+Z就不行呢?

核心区别:进程状态

当使用不同方式创建"后台进程"时,进程的实际状态是不同的:

  1. & 启动的后台进程:处于 运行中 (Running) 状态。它一直在执行,只是没有控制终端输入。

  2. Ctrl+Z 暂停的进程:被置于 停止 (Stopped) 状态。它被暂停执行了,就像被按下了"暂停键"。

我们可以分别在这两种情况时通过ps指令查看

ltx@iv-ye1i2elts0wh2yp1ahah:~/gitLinux/Linux_system/lesson_sig/Sig$ ps ajx | grep testsig254117  265199  265199  254117 pts/6     254117 T     1004   0:00 ./testsig263627  265245  265244  263627 pts/1     265244 S+    1004   0:00 grep --color=auto testsig
ltx@iv-ye1i2elts0wh2yp1ahah:~/gitLinux/Linux_system/lesson_sig/Sig$ ps ajx | grep testsig254117  265600  265600  254117 pts/6     254117 S     1004   0:00 ./testsig263627  265603  265602  263627 pts/1     265602 S+    1004   0:00 grep --color=auto testsig

注意:+号代表前台进程

那有没有办法将暂停的后台进程,重新在后台运行起来呢?可以使用bg命令(和fg用法相同),如下:

ltx@iv-ye1i2elts0wh2yp1ahah:~/gitLinux/Linux_system/lesson_sig/Sig$ ./testsig
I am a process, pid: 265703
I am a process, pid: 265703
I am a process, pid: 265703
I am a process, pid: 265703
I am a process, pid: 265703
^Z
[1]+  Stopped                 ./testsig
ltx@iv-ye1i2elts0wh2yp1ahah:~/gitLinux/Linux_system/lesson_sig/Sig$ bg %1
[1]+ ./testsig &
I am a process, pid: 265703
ltx@iv-ye1i2elts0wh2yp1ahah:~/gitLinux/Linux_system/lesson_sig/Sig$ I am a process, pid: 265703
I am a process, pid: 265703
I am a process, pid: 265703
I am a process, pid: 265703
I am a process, pid: 265703
...

详细解释

1. 使用 & 启动后台进程

$ ./testsig &
[1] 263501
$ I am a process, pid: 263501
  • 进程状态Running (运行中)

  • 终端访问:保留了向终端输出的权限(stdout/stderr)

  • 机制

    • Shell 创建子进程后,立即继续运行,不等待子进程结束。

    • 子进程被放入后台进程组,但仍然可以自由地向终端输出

    • 这就是为什么你能看到 I am a process... 与 Shell 提示符和命令输出交错显示的原因 - 两个进程在竞争同一个输出设备。

2. 使用 Ctrl+Z 然后 bg

$ ./testsig
^Z # 按下 Ctrl+Z
[1]+  Stopped                 ./testsig
$ bg %1 # 将其在后台继续运行
[1]+ ./testsig &
# 此时不再有输出
  • Ctrl+Z 的效果:向进程发送 SIGTSTP (Terminal Stop) 信号

  • 进程状态变化:从 Running 变为 Stopped (停止)

  • bg 命令的效果:向进程发送 SIGCONT (Continue) 信号,但不将其带回前台

  • 关键点:虽然 bg 让进程继续执行,但 Shell 和内核对其处理方式与直接用 & 启动的进程有细微差别

总结与类比

特性前台进程后台进程 (&)
终端输入 (stdin)独占无法获取(读取会被挂起)
终端输出 (stdout/stderr)独占共享(输出会与Shell提示符等混杂)
控制信号 (Ctrl+C)可以接收无法接收(信号发给Shell)
Shell 状态Shell 被挂起,等待其结束Shell 继续运行,可接受新命令
管理命令Ctrl+CCtrl+ZCtrl+\jobskill %nfg %nbg %n

一个简单的比喻:

  • 前台进程 就像你正在全屏玩的一款游戏,键盘和显示器都被它独占,你无法同时做别的事。

  • 后台进程 就像你在电脑上开启了一个音乐播放器然后最小化,音乐在放(输出),但你可以在前台用浏览器(Shell)做其他事情,并且你不能直接用键盘控制播放器(除非你把它切回前台)。想关掉音乐播放器,你不能在浏览器里按“关机键”,必须去任务管理器(kill)里结束它。


1.3 信号概念

信号是进程间通信(IPC)的一种重要机制,它提供了一种异步事件通知的方式。在Unix/Linux系统中,信号本质上是一种软件中断,用于通知进程发生了某个特定事件或异常情况。

信号的主要特点包括:

  1. 异步性:信号可以在任何时候发送给进程,进程无法预知信号何时到达
  2. 软中断:信号机制在软件层面模拟了硬件中断的行为
  3. 基本通信:信号提供了最基本的进程间通信方式

常见的信号类型有:

  • SIGINT(2):中断信号,通常由Ctrl+C触发
  • SIGKILL(9):强制终止进程信号
  • SIGSEGV(11):段错误信号
  • SIGTERM(15):终止信号

信号处理流程:

  1. 信号产生:由内核、其他进程或终端产生
  2. 信号传递:内核将信号传递给目标进程
  3. 信号处理:目标进程执行预先注册的信号处理函数

在实际应用中,信号常用于:

  • 进程间简单通信
  • 系统异常处理
  • 进程控制(如终止、暂停等)
  • 用户交互响应

查看信号

我们可以使用 kill -l 命令来查看有哪些信号

ltx@iv-ye1i2elts0wh2yp1ahah:~/gitLinux/Linux_system/lesson_sig/Sig$ kill -l1) SIGHUP	 2) SIGINT	 3) SIGQUIT	 4) SIGILL	 5) SIGTRAP6) SIGABRT	 7) SIGBUS	 8) SIGFPE	 9) SIGKILL	10) SIGUSR1
11) SIGSEGV	12) SIGUSR2	13) SIGPIPE	14) SIGALRM	15) SIGTERM
16) SIGSTKFLT	17) SIGCHLD	18) SIGCONT	19) SIGSTOP	20) SIGTSTP
21) SIGTTIN	22) SIGTTOU	23) SIGURG	24) SIGXCPU	25) SIGXFSZ
26) SIGVTALRM	27) SIGPROF	28) SIGWINCH	29) SIGIO	30) SIGPWR
31) SIGSYS	34) SIGRTMIN	35) SIGRTMIN+1	36) SIGRTMIN+2	37) SIGRTMIN+3
38) SIGRTMIN+4	39) SIGRTMIN+5	40) SIGRTMIN+6	41) SIGRTMIN+7	42) SIGRTMIN+8
43) SIGRTMIN+9	44) SIGRTMIN+10	45) SIGRTMIN+11	46) SIGRTMIN+12	47) SIGRTMIN+13
48) SIGRTMIN+14	49) SIGRTMIN+15	50) SIGRTMAX-14	51) SIGRTMAX-13	52) SIGRTMAX-12
53) SIGRTMAX-11	54) SIGRTMAX-10	55) SIGRTMAX-9	56) SIGRTMAX-8	57) SIGRTMAX-7
58) SIGRTMAX-6	59) SIGRTMAX-5	60) SIGRTMAX-4	61) SIGRTMAX-3	62) SIGRTMAX-2
63) SIGRTMAX-1	64) SIGRTMAX	

注意:1~31是普通信号,34~64是实时信号(实时信号在后文中不做考虑)

每个信号都对应一个编号和宏定义名称,这些宏定义可在signal.h头文件中查询。例如,该文件中定义了

还可以通过man手册查询,如:man 7 signal

普通信号的特点是:不支持排队。如果同一个信号在进程处理它之前多次产生,它只会被记录一次,这可能会导致信号丢失。

普通标准信号详解表

下表列出了最常见和重要的标准信号:

编号信号名称默认行为触发场景说明可否捕获或忽略重要说明
1SIGHUPTerm挂起。终端连接断开(如网络断开、关闭终端窗口)、控制进程终止。Yes常被用于通知守护进程重新读取配置文件(如 nginx -s reload)。
2SIGINTTerm中断。来自键盘的中断,通常是用户按下 Ctrl+CYes请求优雅地终止前台进程。
3SIGQUITCore退出。来自键盘的退出,通常是用户按下 Ctrl+\Yes不仅终止进程,还会生成 core dump 文件用于调试。表示用户希望进程终止并留下调试信息。
4SIGILLCore非法指令。进程尝试执行一条非法、错误或特权的指令。Yes通常由程序 bug 引起,例如执行了损坏的二进制文件、栈溢出等。
5SIGTRAPCore跟踪/断点陷阱。由调试器使用,用于在断点处中断进程的执行。Yes这是调试器(如 gdb)实现断点功能的机制。
6SIGABRTCore中止。通常由 abort() 函数调用产生。Yes进程自己调用 abort() 来终止自己,通常表示检测到了严重的内部错误(如 assert 断言失败)。也会生成 core dump。
7SIGBUSCore总线错误。无效的内存访问,即访问的内存地址不存在或违反了内存对齐要求。Yes硬件级别的错误。例如,在支持对齐要求的架构上访问未对齐的地址。与 SIGSEGV 类似但原因更底层。
8SIGFPECore浮点异常。错误的算术运算,如除以零、溢出等。Yes不仅是浮点数,整数除以零也会触发此信号。
9SIGKILLTerm杀死。无条件立即终止进程。No无法被捕获、阻塞或忽略。是终止进程的最终极、最强制的手段。kill -9 的由来。
10SIGUSR1Term用户自定义信号 1Yes没有预定义的含义,完全留给用户程序自定义其行为。常用于应用程序内部通信(如通知进程切换日志文件、重新加载特定数据等)。
11SIGSEGVCore段错误。无效的内存引用,即访问了未分配或没有权限访问的内存(如向只读内存写入)。YesC/C++ 程序中最常见的崩溃原因之一(解空指针、访问已释放内存、栈溢出、缓冲区溢出等)。
12SIGUSR2Term用户自定义信号 2Yes同上,另一个用户可自定义用途的信号。
13SIGPIPETerm管道破裂。向一个没有读者的管道(或 socket)进行写入操作。Yes常见场景:一个管道中,读端进程已关闭或终止,写端进程还在写入。如果忽略此信号,write 操作会返回错误并设置 errno 为 EPIPE
14SIGALRMTerm定时器信号。由 alarm() 或 setitimer() 设置的定时器超时后产生。Yes常用于实现超时机制或周期性任务。
15SIGTERMTerm终止。这是一个友好的终止进程的请求。Yeskill 命令的默认信号。进程收到此信号后,应该执行清理工作(关闭文件、释放资源等)然后退出。是优雅关闭服务的首选方式。
16SIGSTKFLTTerm协处理器栈错误。极少使用。Yes与早期的数学协处理器有关,现代 Linux 系统上基本不会见到。
17SIGCHLDIgn子进程状态改变。一个子进程停止终止时,内核会向父进程发送此信号。Yes非常重要!父进程可以通过捕获此信号来调用 wait() 或 waitpid() 回收子进程资源,防止出现僵尸进程。默认行为是 Ignore,但最好显式处理。
18SIGCONTCont继续。让一个停止的进程继续运行。Yes无法被停止的进程忽略。常用于作业控制(fg / bg 命令的底层实现)。
19SIGSTOPStop停止。暂停进程的执行(进入 Stopped 状态)。No无法被捕获、阻塞或忽略。是 Ctrl+Z (SIGTSTP) 的强制版本。
20SIGTSTPStop终端停止。来自终端的停止信号,通常是用户按下 Ctrl+ZYes请求优雅地暂停前台进程。进程可以被捕获,在捕获函数中它可以做一些准备工作后再决定是否暂停自己。
21SIGTTINStop后台进程读终端。一个后台进程尝试从控制终端读取输入。Yes为了防止后台进程干扰前台,内核会自动停止该后台进程。
22SIGTTOUStop后台进程写终端。一个后台进程尝试向控制终端写入输出。Yes类似于 SIGTTIN,但用于写入操作。是否停止取决于终端配置(stty tostop)。
..................

说明

  • 默认行为:

    • Term: 终止进程

    • Core: 终止进程并生成 core dump 文件

    • Ign: 忽略信号

    • Stop: 暂停(停止)进程

    • Cont: 如果进程已停止,则继续运行

  • SIGKILL (9) 和 SIGSTOP (19) 是两个特殊的信号,无法被捕获、阻塞或忽略。这是为了给系统管理员一个最终能控制任何进程的手段。


核心总结与要点
  1. 信号来源

    • 硬件异常:由 CPU 检测到错误产生(如 SIGSEGVSIGFPESIGILL)。

    • 终端交互:用户通过键盘产生(如 SIGINTSIGQUITSIGTSTP)。

    • 软件事件:由软件条件触发(如 SIGPIPESIGCHLDSIGALRM)。

    • 系统调用/命令:由 kill() 函数或 kill 命令发出。

  2. 处理方式

    • 执行默认操作:大多数信号的默认操作是终止进程。

    • 忽略信号SIG_IGN,但 SIGKILL 和 SIGSTOP 不能忽略。

    • 自定义信号处理函数signal() 或更强大的 sigaction()

  3. 不可靠信号与可靠信号

    • 普通信号 (1-31) 是不可靠信号,因为它们不支持排队,可能会丢失。

    • 实时信号 (34-64, SIGRTMIN 到 SIGRTMAX) 是可靠信号,支持排队,多个相同的信号会被依次处理,不会丢失。它们没有预定义含义,完全由应用程序使用。

  4. 最佳实践

    • 使用 SIGTERM (15) 来优雅地终止进程,给进程一个清理现场的机会。

    • 仅在进程不响应 SIGTERM 时,使用 SIGKILL (9) 作为最后手段。

    • 在编写服务器或长时间运行的程序时,妥善处理 SIGHUP(重读配置)和 SIGUSR1/SIGUSR2(自定义行为)。

    • 父进程一定要处理 SIGCHLD 信号,以避免产生僵尸进程。

所以信号的核心特性我们可以来做一下总结

核心特性
  1. 异步通信机制

    • 信号是最短小的进程间消息(仅携带信号编号),用于通知进程特定事件发生(如用户中断、内存错误等)。
    • 本质是内核向用户态进程推送的软中断,进程可能在任何代码位置被信号中断。
  2. 不可靠性与局限性

    • 无排队机制:连续发送相同信号时,进程可能仅收到一次(实时信号支持排队)。
    • 信息量极小:仅传递信号编号,无法携带附加数据(实时信号可携带)。
    • 部分信号不可控SIGKILL(9)和SIGSTOP(19)无法被阻塞、捕获或忽略。
  3. 生命周期三阶段

    • ​​​​​​​未决(Pending) :信号产生后暂存于内核位图,等待递达。
    • 阻塞(Block) :进程可主动屏蔽信号,延迟处理。

2. 产生信号

至此,我们对信号有了一个基本认识,那么接下来我们就先从信号的产生介绍

在前文中,我们知道通过键盘就能产生信号(Ctrl+C,Ctrl+\和Ctrl+Z)

下面我们就来介绍其他几种产生信号的方式

2.1 系统调用命令

在前文中我们知道,后台进程对于键盘输入(Ctrl+C)产生的信号,不能接收也不做反应,所以当时我们提到了可以使用kill命令来发送信号,从而终止后台进程。在系统调用中同样也有kill函数,也可以发送信号。

1. kill - 向指定进程或进程组发送信号

kill() 是最核心、最通用的信号发送函数,它的名字有点误导性,因为它不仅可以发送终止信号(如 SIGKILL),还可以发送任何信号。

函数原型

#include <sys/types.h>
#include <signal.h>int kill(pid_t pid, int sig);
  • 参数 pid:指定目标进程或进程组,取值及其含义非常关键:

    pid 值含义
    > 0将信号发送给进程ID为 pid 的特定进程
    0将信号发送给与调用进程属于同一个进程组的所有进程(包括自己)。
    -1将信号发送给调用进程有权限发送信号的所有进程(除了 init 进程和自身)。 规则复杂,较少使用。
    < -1将信号发送给进程组ID为 -pid 的所有进程。例如 pid = -1234 发送给 PGID 为 1234 的进程组。
  • 参数 sig:要发送的信号编号(如 SIGINTSIGTERM)。如果 sig 为 0,则不发送任何信号,但依然会执行错误检查(用于检查目标进程是否存在)。

  • 返回值:成功返回 0;失败返回 -1 并设置 errno

关键特性与工作原理

  1. 权限检查kill() 调用会进行严格的权限检查。超级用户(root) 可以向任何进程发送信号,而普通用户只能向属于自己的进程发送信号。否则调用会失败,errno 被设置为 EPERM

  2. NULL 信号 (sig=0):这是一个非常有用的特性。它用于检测目标进程是否存在且是否有权限向其发送信号。如果 kill(pid, 0) 成功返回,说明进程存在且有权限;如果返回 -1 且 errno 为 ESRCH,则进程不存在;如果为 EPERM,则无权限。

  3. 底层机制:当 kill() 被调用时,内核会检查参数有效性及权限。如果通过,内核就会在目标进程(或进程组中每个进程)的 task_struct 中的未决信号集(pending) 里设置对应的信号位。至于目标进程何时以及如何处理这个信号,就取决于它的信号掩码和处理函数了,这与 kill() 调用本身异步。

示例:

这里我们使用kill系统调用来实现一个我们自己的kill命令

#include <iostream>
#include <sys/types.h>
#include <signal.h>
#include <string>// ./mykill signalnum pid 
int main(int argc, char* argv[])
{if(argc != 3){std::cout << "./mykill signalnum pid" << std::endl;return 1;}int signalnum = std::stoi(argv[1]);pid_t target = std::stoi(argv[2]);  int n = kill(target, signalnum);if(n == 0){std::cout << "send " << signalnum << " to " << target << " success" << std::endl;}return 0;
}

运行结果:

我们分别使用内置命令kill和我们自己实现的mykill给 testsig进程 发送2号信号,都可以被捕获


2. raise - 向当前进程发送信号

raise() 是一个简化版的 kill(),它的目标只有一个,就是调用者进程自身

函数原型

#include <signal.h>int raise(int sig);
  • 参数 sig:要发送给自己的信号编号。

  • 返回值:成功返回 0;失败返回非零值。

关键特性与工作原理

  1. 单线程程序:在单线程程序中,raise(sig) 几乎等价于 kill(getpid(), sig)

  2. 多线程程序:在多线程环境中,raise(sig) 的含义是将信号发送给调用它的特定线程,而不是整个进程。这是它与 kill(getpid(), sig) 的一个重要区别,后者会将信号发送给进程中的任意一个线程。

  3. 便捷性:它的存在纯粹是为了方便,让代码意图更清晰——“我要给自己发个信号”。

示例:

这里我们把普通信号都捕获了,方便我们使用raise系统调用给当前进程发送信号时,都能捕获到然后打印出来我们查看

#include <iostream>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>void handlerSig(int sig)
{std::cout << "获得了一个信号: " << sig << std::endl;
}int main()
{for(int i = 1; i < 32; i++)signal(i, handlerSig);for(int i = 1; i < 32; i++){sleep(1);raise(i);}while (true){std::cout << "I am a process, pid: " << getpid() << std::endl;sleep(1);}return 0;
}

运行结果:

ltx@iv-ye1i2elts0wh2yp1ahah:~/gitLinux/Linux_system/lesson_sig/Sig$ ./testsig
获得了一个信号: 1
获得了一个信号: 2
获得了一个信号: 3
获得了一个信号: 4
获得了一个信号: 5
获得了一个信号: 6
获得了一个信号: 7
获得了一个信号: 8
Killed

在前文信号概念中,我们有说过,有两个信号9号和19号信号不能被捕获,所以当我们进程在准备捕获9号信号时,由于9号信号不能被捕获,所以当前进程被终止


3. abort - 使当前进程异常终止

abort() 函数的功能非常明确和强硬:立即异常终止当前进程,并生成一个 core dump 文件(如果系统配置允许)。

函数原型

#include <stdlib.h>void abort(void);
// 注意:该函数无参数,且无返回值,因为它永远不会返回到调用者。

关键特性与工作原理

  1. 不可阻挡abort() 函数会无条件地终止进程。它会首先解除对 SIGABRT 信号的阻塞,然后向自己发送 SIGABRT 信号。

  2. 信号处理

    • 如果进程为 SIGABRT 设置了自定义处理函数abort() 会先调用这个函数。

    • 关键点:如果自定义处理函数没有终止进程(例如,它调用了 longjmp 跳转走了),那么 abort() 函数在自定义处理函数返回后,会确保进程被强制终止(通常是恢复 SIGABRT 的默认行为并再次发送它)。这意味着你无法真正“捕获” abort() 来阻止进程终止。

  3. 刷新缓冲区abort() 会刷新并关闭所有标准 I/O 流(类似于 fflush(NULL)),但这不保证所有数据都能正确写入(因为终止是强制的)。

  4. 生成 Core Dump:其默认行为是产生 SIGABRT 信号,该信号的默认动作是 Core,所以它会创建一个 core dump 文件,用于事后调试,帮助定位程序调用 abort() 的位置和原因。

示例:

#include <iostream>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>void handlerSig(int sig)
{std::cout << "获得了一个信号: " << sig << std::endl;
}int main()
{for(int i = 1; i < 32; i++)signal(i, handlerSig);while (true){std::cout << "I am a process, pid: " << getpid() << std::endl;sleep(1);abort();}return 0;
}

运行结果:

ltx@iv-ye1i2elts0wh2yp1ahah:~/gitLinux/Linux_system/lesson_sig/Sig$ ./testsig
I am a process, pid: 22993
获得了一个信号: 6
Aborted (core dumped)

可以看到我们捕获了6号信号,也就是SIGABRT 信号,同时进程也被终止了


总结与对比

特性kill()raise()abort()
目标任意进程或进程组仅调用者自身(或当前线程)强制终止调用者自身
主要用途进程间通信(IPC)、管理系统进程进程内部触发信号处理逻辑紧急情况下的程序终止(断言失败、严重错误)
灵活性极高,可发送任何信号高,可发送任何信号给自己无,固定产生 SIGABRT
返回值int (0成功, -1失败)int (0成功, 非0失败)void (永不返回)
是否可被捕获取决于发送的信号取决于发送的信号可被捕获但无法真正阻止终止
底层关联kill 系统调用的封装通常用 kill(getpid(), sig) 实现内部使用 raise(SIGABRT) 并确保进程死亡

核心要点

  • 使用 kill() 进行精细的进程控制。

  • 使用 raise() 进行简洁的自我信号触发。

  • 使用 abort() 作为处理不可恢复错误的最后手段,通常紧随类似 assert() 的检查之后。


2.2 硬件异常

核心概念:硬件异常 -> 内核 -> 信号

这个过程并不像“快递员主动打电话”,而更像“你在拆快递时突然被包装盒里的机关扎伤了手”——伤害是在执行操作的过程中由硬件直接检测并立即报告的

其核心流程如下:

  1. CPU 执行指令:进程在执行一条指令。

  2. 硬件检测异常:CPU 在执行过程中检测到一个错误条件(如除以零、访问非法地址)。

  3. 陷入内核:CPU 中止当前指令的执行,保存现场,并切换到内核模式,将控制权交给内核的陷阱处理程序

  4. 内核处理陷阱:内核的陷阱处理程序检查异常原因。

  5. 内核发送信号:内核将异常原因映射为一个对应的信号。

  6. 信号交付:内核将这个信号发送给导致异常的当前正在运行的进程

这个过程是同步的:信号的产生是由进程自己的某条特定指令直接导致的,而不是像 kill 或 Ctrl+C 那样是外部异步事件。

核心机制与流程

  1. 硬件检测阶段

    • 触发源:CPU 运算单元、内存管理单元(MMU)、浮点运算单元(FPU)等硬件组件。
    • 异常类型
      • 除零错误:CPU 检测到除法指令分母为 0(如 x = 5 / 0),触发算术异常。
      • 非法内存访问:MMU 检测到访问未分配内存或越界地址(如解引用 NULL 指针)。
      • 其他硬件错误:如无效指令、对齐错误、设备故障等。
    • 硬件行为
      • 设置状态寄存器标志位(如 x86 的 #DE(Divide Error)异常码)。
      • 向内核发送中断请求(IRQ),携带异常类型和上下文信息。
  2. 内核响应阶段

    • 异常解释:内核接收中断后,根据硬件提供的信息生成对应信号:
硬件异常内核生成信号信号含义
除零/算术溢出SIGFPE (8)浮点或算术异常
非法内存访问SIGSEGV (11)段错误(无效内存引用)
总线错误(对齐问题)SIGBUS (7)内存访问对齐错误
  • 信号注入:内核将信号加入目标进程的 未决(Pending)队列,标记为待处理状态。
  1. 进程处理阶段
    • 递达时机:当进程从内核态返回用户态时(如系统调用结束),检查未决信号。
    • 默认行为
      • SIGFPE/SIGSEGV:终止进程并生成 core dump(内存转储文件)。
      • SIGBUS:终止进程。
    • 自定义处理:进程可通过 signal() 或 sigaction() 注册处理函数覆盖默认行为。

常见的硬件异常信号及详解

以下是三种最典型的由硬件异常产生的信号:

1. SIGFPE (信号 8) - 浮点异常

  • 硬件根源:由 CPU 的算术逻辑单元 (ALU) 在执行算术运算时检测到错误。

  • 触发原因

    • 整数除以零:这是最常见的原因。

    • 浮点数除以零:可能产生 Inf 或 NaN,但在某些上下文或系统配置下也会触发信号。

    • 数值溢出:例如,对一个有符号整数进行运算,结果超出了其数据类型能表示的范围。

  • 示例代码

#include <iostream>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>void handlerSig(int sig)
{std::cout << "获得了一个信号: " << sig << std::endl;exit(12);
}int main()
{for(int i = 1; i < 32; i++)signal(i, handlerSig);while (true){std::cout << "I am a process, pid: " << getpid() << std::endl;sleep(1);int a = 10;a /= 0; // 除0错误}return 0;
}

运行结果:

这里我们直接忽略编译报警,直接运行可以看到捕获8号信号后退出

2. SIGSEGV (信号 11) - 段错误

  • 硬件根源:由 CPU 的内存管理单元 (MMU) 在执行内存访问时检测到错误。

  • 触发原因

    • 访问空指针 (NULL):解引用 0x0 地址。

    • 访问未分配的内存:解引用一个随机的、无效的指针值。

    • 访问只读内存:尝试向代码段(.text)或字符串常量(如 char *p = "hello"; p[0] = 'H';)写入数据。

    • 栈溢出:或者访问了栈保护页(Stack Guard Page)。

    • 缓冲区溢出:访问了数组边界之外的内存。

  • 示例代码

#include <iostream>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>void handlerSig(int sig)
{std::cout << "获得了一个信号: " << sig << std::endl;exit(12);
}int main()
{for(int i = 1; i < 32; i++)signal(i, handlerSig);while (true){std::cout << "I am a process, pid: " << getpid() << std::endl;sleep(1);int* p = NULL;*p = 100; // 野指针}return 0;
}

运行结果:

发生段错误,我们也成功捕获到了11号信号

3. SIGILL (信号 4) - 非法指令

  • 硬件根源:由 CPU 的指令解码单元检测到错误。

  • 触发原因

    • 执行了损坏的二进制代码:程序文件在磁盘或内存中被破坏。

    • 尝试执行数据:例如,函数指针指向了一个数据区而非代码区。

    • CPU架构不匹配:尝试在一种CPU上运行为另一种CPU编译的二进制程序。

    • 使用了特权指令:用户态进程尝试执行只有内核态才能执行的指令。


操作系统是怎么检测到硬件异常?或者说硬件是怎么检测到出错的?

核心机制:CPU 的“异常处理”硬件单元

现代 CPU 并不是傻傻地执行指令,它的内部有一套复杂的监控电路。当这些电路在执行指令的过程中检测到某些特定条件时,会立即中断当前控制流,并强制 CPU 去执行一段预设好的、属于操作系统的代码。这个过程是硬件自动完成的。

我们可以用以下流程图来概括这个硬协同的过程:

下面,我们以最常见的 SIGSEGV (段错误) 和 SIGFPE (除零错误) 为例,拆解图中的每一步。

1. 硬件层面:检测与触发

对于 SIGSEGV (非法内存访问):

  • 关键硬件:MMU (内存管理单元)

    • CPU 的核心部件之一,负责将进程使用的虚拟地址翻译为实际的物理地址

  • 检测过程

    1. 当执行一条像 movl $100, (%eax)(假设 eax=0)的指令,试图向地址 0 写入时,CPU 会将虚拟地址 0 交给 MMU。

    2. MMU 会查询当前进程的页表(Page Table)。页表是内核数据结构,定义了虚拟地址到物理地址的映射关系以及访问权限(是否可读、可写、可执行)。

    3. MMU 发现虚拟地址 0 在页表中根本没有有效的映射,或者它的权限是只读的(比如尝试写入代码段)。

    4. MMU 立刻会产生一个 Page Fault(缺页错误)或 General Protection Fault(通用保护错误)。这就是一个硬件异常。

对于 SIGFPE (算术异常):

  • 关键硬件:ALU (算术逻辑单元)

    • CPU 的核心部件,负责执行所有算术和逻辑运算。

  • 检测过程

    1. 当执行一条 idivl 指令(整数除法)时,如果除数是 0,ALU 中的除法电路会直接检测到这个非法操作。

    2. 除法电路立即产生一个 Divide Error Fault。这同样是一个硬件异常


2. 内核层面:接管与处理

硬件只负责“发现问题并拍下紧急刹车”,接下来必须由操作系统内核来“处理事故现场”。

  • 中断描述符表 (IDT)

    • CPU 在设计之初就和操作系统约定好:发生不同类型的异常时,你应该去哪段代码找我。这个约定就是 IDT。IDT 是由操作系统在启动时精心设置的,它将每一种异常(如 Page Fault, Divide Error)和一个特定的内核函数地址(称为中断服务例程或陷阱处理程序)关联起来。

  • 内核的陷阱处理程序 (Trap Handler)

    1. 接管控制权:当 CPU 触发上述硬件异常时,它会根据异常类型自动查找 IDT,并立即跳转到对应的内核陷阱处理程序代码执行。此时,CPU 从用户态切换到了内核态

    2. 保存现场:CPU 会自动将当时的寄存器状态、指令指针(EIP/RIP)等压入内核栈,这样之后才能恢复。

    3. 分析原因:内核代码开始分析异常原因。对于 Page Fault,它会检查出错的地址和错误类型。

    4. 判断与决策

      • 可修复错误:有些 Page Fault 是正常的,比如访问一个已被换出到硬盘的内存页(按需分页)。内核会默默地修复这个问题(从硬盘把页换回内存,建立映射),然后让进程重新执行刚才那条指令,进程对此毫无感知。

      • 不可修复错误:如果内核发现这个错误无法修复(比如访问了根本不存在的地址、权限错误、除以零),它就会认定是进程自己犯了严重的错误


3. 信号传递:内核 -> 用户进程

当内核断定这是一个由用户进程导致的、无法恢复的错误时,它就会动用信号机制来“通知”进程。

  1. 映射异常到信号:内核中有一个固定的映射关系:

    • Page Fault / General Protection Fault -> SIGSEGV

    • Divide Error -> SIGFPE

    • Illegal Instruction -> SIGILL

  2. 发送信号:内核会直接修改当前出错进程的 task_struct 结构体,在其待处理信号集(pending) 中设置对应的信号位(比如设置 SIGSEGV 位为 1)。

  3. 返回用户态并交付信号:当内核的陷阱处理程序执行完毕,准备返回用户态让进程继续执行时,它会检查当前进程是否有待处理的信号。一检查,发现有一个 SIGSEGV pending,于是它不会返回进程原来出错的那条指令,而是转而执行进程注册的 SIGSEGV 信号处理函数。如果进程没有注册自定义处理函数,就执行默认动作(终止并生成 core dump)。

总结:这不是“检测”,而是“报告”和“处理”

  • 硬件 (CPU):就像一个严格执行命令但又严格遵守规则的工人。它的职责是报告:“老板,你让我做的这个操作,根据你(操作系统)给我的规则,我没法执行!”

  • 操作系统内核:就像是工地的总包项目经理。它制定了规则(页表),并负责处理工人的报告。它能修的小问题就自己修了(缺页异常),修不了的严重问题就通知具体的小包工头(用户进程):“你手下的工人犯了致命错误,你自己看着办吧(发送信号)。”

  • 用户进程:就是那个小包工头。它提前告诉项目经理:“如果我的工人出了那种错,你就叫我这个处理函数(signal handler)”。

所以,整个过程是:CPU硬件电路在运行中自动触发异常 -> 内核预设的陷阱处理程序接管 -> 内核将异常类型转换为信号 -> 内核将信号注入目标进程 -> 进程在合适时机处理信号


Core Dump

在前面章节介绍进程等待时,我们在获取子进程status时,有见过Core Dump(如下图),那也是我们第一次知道Core Dump,当然我们当时只知道有这个东西,但不知道也不了解它,在前文中我们也能见到有的信号的默认动作是Term,有的信号默认动作则是Core,下面我们就来了解一下Core Dump。

一、Core Dump 是什么?

Core Dump(核心转储),在 Linux 和类 Unix 系统中,是指当进程异常终止(崩溃)时,操作系统将该进程在崩溃瞬间的整个用户空间内存内容(以及部分内核数据结构)完整地保存到一个磁盘文件中的过程。生成的这个文件通常命名为 core 或 core.<pid>

你可以把它想象成进程的 “死亡现场的快照” 或 “黑匣子”。它完整记录了进程在“死亡”那一刻的:

  • 内存数据:堆(heap)、栈(stack)、数据段(data segment)、BSS 段。

  • 寄存器状态:程序计数器(PC)、栈指针(SP)等,这直接指向了崩溃时正在执行的代码。

  • 程序计数器值:明确指出是哪条指令导致了崩溃。

  • 内存管理信息:页表、文件描述符表等资源信息。


二、为什么会产生 Core Dump?

Core Dump 主要由一些特定的信号触发,这些信号的默认行为是 Core。常见的触发信号有:

信号编号原因默认行为
SIGQUIT3用户按下 Ctrl+\Core
SIGILL4执行了非法指令Core
SIGABRT6程序自己调用 abort() 函数Core
SIGFPE8算术异常,如除以零Core
SIGSEGV11段错误,非法内存访问(最最常见的原因!)Core

当一个进程收到上述信号,并且没有捕获它或者捕获后依然决定终止,操作系统就会执行默认动作:终止进程并生成 core dump。


三、Core Dump 有什么用?(为什么它如此重要?)

核心用途:事后调试(Post-mortem Debugging)

程序员不可能 7x24 小时盯着程序运行。很多崩溃(尤其是段错误 SIGSEGV)是随机发生的,在测试环境中难以复现。Core Dump 文件提供了重现崩溃现场的一切信息。借助调试器(如 GDB),你可以:

  1. 精确定位崩溃位置:直接看到崩溃时程序执行到了哪一行代码、哪个函数。

  2. 查看调用栈(Backtrace):看到函数调用的完整链条,了解是如何一步步走到崩溃点的。

  3. 检查变量值:查看在崩溃瞬间,各个全局变量、局部变量的值是什么,这对于分析逻辑错误至关重要。

  4. 分析内存状态:检查指针是否为空、是否被释放、数组是否越界等。

没有 core dump,调试这种崩溃就如同刑侦破案没有监控录像和物证,只能靠猜测和打印日志,效率极低。


四、如何启用和配置 Core Dump?

默认情况下,很多系统为了节省磁盘空间,core dump 功能是关闭的。你需要进行配置。

1. 解除资源限制:ulimit -c

Shell 内置命令 ulimit 用于控制 shell 启动的进程所占用的资源。

  • 检查当前限制

    $ ulimit -c
    0 # 如果结果是 0,表示禁止生成 core 文件
  • 设置 core 文件大小限制

    $ ulimit -c unlimited  # 设置为无限制(最常用)
    # 或者指定大小(单位是 KB)
    $ ulimit -c 102400    # 设置最大为 100MB

    注意:这个设置只对当前终端会话有效。要永久生效,需要将 ulimit -c unlimited 添加到你的 ~/.bashrc 或 /etc/profile 等配置文件中。

2. 配置 Core 文件名称和路径:/proc/sys/kernel/core_pattern

core_pattern 文件决定了 core 文件的生成位置和命名方式。

  • 查看当前设置

    $ cat /proc/sys/kernel/core_pattern

    可能是简单的 core,也可能是 |/usr/share/apport/apport %p %s %c %d %P(像 Ubuntu 就使用 apport 来管理 core dump)。

  • 自定义设置(需要 root 权限)

    # 将 core 文件生成到 /var/cores/ 目录下,并以 core-pid-timestamp 的格式命名
    $ sudo echo "/var/cores/core-%p-%t" > /proc/sys/kernel/core_pattern# 确保目录存在且有写入权限
    $ sudo mkdir /var/cores
    $ sudo chmod 777 /var/cores # 或者设置为一个更安全的权限

    常用格式符

    • %p:进程 ID (PID)

    • %u:用户 ID

    • %t:时间戳 (Unix epoch)

    • %s:导致 dump 的信号编号

    • %e:可执行文件名


五、如何使用 Core Dump 进行调试?

假设你的程序 my_program 崩溃并生成了一个 core 文件。

使用 GDB 进行分析:

# 基本命令格式:gdb <可执行程序> <core文件>
$ gdb my_program core# 或者如果 core 文件有复杂的名字
$ gdb my_program /var/cores/core-12345-1620000000

进入 GDB 后,最关键的几个命令:

  1. bt (backtrace)立即查看调用栈。这是你第一个应该执行的命令。它会显示出崩溃时函数调用的层次关系,直接指向问题代码。

    (gdb) bt
    #0  0x0000000000400556 in foo () at main.c:10
    #1  0x0000000000400582 in main () at main.c:20

    这清楚地告诉我们:在 main.c 的第 20 行,main 函数调用了 foo 函数,然后在 main.c 的第 10 行,foo 函数内部发生了崩溃。

  2. f <帧号> (frame):切换到调用栈的某一具体帧,查看该层的上下文。

    (gdb) f 0 # 切换到第0帧(崩溃发生的地方)
    (gdb) list # 查看崩溃点附近的代码
  3. p <变量名> (print):打印变量的值。这对于检查指针是否为 NULL 或变量值是否符合预期至关重要。

    (gdb) p ptr
    $1 = (int *) 0x0  # 啊哈!发现一个空指针!
  4. info registers:查看寄存器的值。


六、注意事项与最佳实践

  • 编译时请带上 -g 选项:在编译你的程序时(gcc -g -o my_program my_program.c),-g 选项会在可执行文件中包含调试符号信息。如果没有这个信息,GDB 只能告诉你崩溃的机器指令地址,而无法告诉你对应的源代码文件名和行号,调试难度大大增加。

  • 确保权限和磁盘空间:进程要对 core_pattern 指定的目录有写入权限,并且磁盘有足够空间。

  • 生产环境:在生产服务器上,通常不会设置 ulimit -c unlimited,因为 core 文件可能非常大(几个GB),填满磁盘会导致更严重的问题。生产环境的做法通常是:

    1. 使用 core_pattern 将 core 文件重定向到一个有充足空间、专门用于监控的目录。

    2. 或者集成更高级的监控系统,在崩溃时自动捕获 core 文件并上传到中央服务器进行分析,然后删除本地的文件。


2.3 软件条件

这类信号的特点是:它们并非由外部进程或用户通过 kill 发送,也非由硬件错误触发,而是由操作系统内核在检测到某种特定的、预先定义的“软件条件”满足时,自动向进程发送的。

常见的软件条件信号及详解

以下是几个最典型的由软件条件产生的信号:

1. SIGPIPE (信号 13) - 管道破裂

这是最经典的“软件条件”信号。

  • 触发条件:当一个进程试图向一个已经没有任何读者的管道(pipe)、FIFO(命名管道)或套接字(socket) 进行写入操作时,内核会自动向这个写入进程发送 SIGPIPE 信号。

  • 为什么需要它? 这是一种“断连”通知。想象一下,你用 pipe 创建了一个管道,进程A读,进程B写。如果进程A意外退出了,进程B还在不停地写,这些数据将永远无人读取,写操作也就失去了意义。内核通过 SIGPIPE 来强行阻止这种无意义的操作。

  • 默认行为:终止进程。

  • 如何处理:很多时候,我们并不希望写入进程因为读端关闭就直接崩溃。因此,一个常见的做法是忽略 SIGPIPE 信号(signal(SIGPIPE, SIG_IGN);)。这样,当写入发生时,系统调用(如 write())不会导致进程终止,而是会返回 -1 并设置错误码 errno 为 EPIPE。程序可以通过检查返回值来进行更优雅的错误处理。

我们在进程间通信中介绍管道时,有详细介绍这种情况,所以这里不过多介绍

2. SIGALRM (信号 14) - 定时器信号

这是一个由软件定时器超时这一条件触发的信号。

  • 触发条件:当一个由 alarm() 或 setitimer() 函数设置的实时定时器(Real-time Timer)超时后,内核会向调用该定时器的进程发送 SIGALRM 信号。

  • 为什么需要它? 用于实现超时机制周期性任务。例如,设置一个读写操作的超时时间,或者让一个任务每隔一段时间执行一次。

  • 默认行为:终止进程。

  • 如何使用:进程通常会捕获 SIGALRM 并提供一个处理函数。在处理函数中设置一个标志位,主程序通过检查这个标志位来判断是否超时。

alarm 系统调用详解

1. 函数原型与功能

#include <unistd.h>unsigned int alarm(unsigned int seconds);
  • 功能:设置一个实时定时器(也叫“闹钟”)。这个定时器会在指定的秒数后到期。当定时器到期时,内核会向调用进程发送一个 SIGALRM 信号。

  • 参数seconds - 指定定时器到期的时间,单位是。如果 seconds 为 0,则表示取消之前设置的所有尚未触发的 alarm 定时器

  • 返回值

    • 返回之前设置的闹钟还剩余的秒数

    • 如果之前没有设置过闹钟,则返回 0


2. 关键特性与工作机制

  1. 单一定时器:对于一个进程,alarm 调用只维护一个定时器。新的 alarm 调用会覆盖之前设置的定时器。

    • 示例:

      alarm(10);  // 设置一个10秒后触发的定时器
      sleep(2);   // 等待2秒
      unsigned int remaining = alarm(5); // 设置一个新的5秒定时器
      // remaining 的值将是 8 (10 - 2 = 8)
      // 现在,旧的10秒定时器被取消了,取而代之的是一个5秒后触发的定时器。
  2. 信号交付:定时器到期后,内核向进程发送 SIGALRM 信号。该信号的默认行为是终止进程。这意味着,如果你只是调用 alarm(5) 而不做任何处理,你的进程将在5秒后默默退出。

  3. 异步性:信号的产生和处理是异步的。定时器到期可能发生在进程执行流中的任何一点。

  4. 精度alarm 的精度是秒级,这对于需要更高精度(毫秒、微秒)定时任务的场景来说太粗糙了。


示例

我们可以通过闹钟来验证一下IO效率问题

我们先看一下一秒钟内,不停IO可以输出多少次

#include <iostream>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>void handlerSig(int sig)
{std::cout << "获得了一个信号: " << sig << std::endl;exit(1);
}int cnt = 0;
int main()
{signal(SIGALRM, handlerSig);alarm(1);while (true){std::cout << "count: " << cnt << std::endl;cnt++;}return 0;
}

运行结果:

可以看到不停IO输出,可以一秒钟打印76136次,那如果我们在一秒钟内只让cnt++,但只在闹钟结束之后IO一次输出cnt的值,这种情况会是多少呢?

#include <iostream>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>int cnt = 0;void handlerSig(int sig)
{std::cout << "获得了一个信号: " << sig <<  "count: "  << cnt << std::endl;exit(1);
}int main()
{signal(SIGALRM, handlerSig);alarm(1);while (true){cnt++;}return 0;
}

运行结果:

可以看到输出结果为4亿多

IO的本质: xshell->./XXX->云服务器->网络->我们看到

如果我们不停IO,这些过程也会不停重复,所以效率就会要慢很多


我们还可以设置一个闹钟每隔一秒发送一次信号,然后捕获

#include <iostream>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>void handlerSig(int sig)
{std::cout << "获得了一个信号: " << sig << std::endl;alarm(1);
}int main()
{signal(SIGALRM, handlerSig);alarm(1);while (true){std::cout << "I am a process, pid: " << getpid() << std::endl;sleep(1);}return 0;
}

运行结果:

可以看到,闹钟每次时间到了就会发送一个14号信号

3. SIGCHLD (信号 17) - 子进程状态改变

这是一个极其重要的、由内核自动管理的信号。

  • 触发条件:当一个进程的子进程终止停止(例如被 SIGSTOP 暂停),又或者从停止状态恢复继续运行时,内核会自动向父进程发送 SIGCHLD 信号。

  • 为什么需要它? 这是一种通知机制,告诉父进程:“你的一个子进程的状态变了,你该来处理一下了(比如回收资源)”。它解决了父进程如何高效地知道子进程结束的问题,避免了父进程不断轮询调用 wait()(忙等待)。

  • 默认行为:忽略(SIG_IGN)。但请注意,默认忽略和手动忽略在底层有巨大差别

  • 如何响应:父进程必须捕获 SIGCHLD 信号,并在其处理函数中调用 wait() 或 waitpid() 来回收子进程资源,从而防止出现僵尸进程(Zombie Process)。

这里也不过多介绍,进程等待部分介绍过


总结:软件条件信号的特点

信号触发条件核心用途常见处理方式
SIGPIPE向无读者的通道写入处理断裂的管道/Socket连接忽略,并检查 write 返回值
SIGALRM定时器超时实现超时、轮询、周期性任务捕获,在handler中设置标志位
SIGCHLD子进程状态改变异步通知父进程回收子进程资源捕获,并在handler中调用 waitpid

核心思想
这些信号体现了操作系统内核的一种设计哲学:“让我来替你盯着这些繁琐的事情,当条件发生时,我会主动通知你”。这极大地简化了应用程序的设计,使其从同步轮询的负担中解脱出来,转向异步事件驱动的高效模型。


3. 保存信号

介绍完信号的产生,那么接下来就要来介绍一下信号的保存

3.1 信号相关概念详解

在前文通过快递引入信号概念后,我们总结了一个基本结论,其中涉及到的一些概念,我们并不明白其中的含义,下面我们就来了解一下

信号递达 (Delivery)

信号递达指的是操作系统实际执行信号处理程序的过程。当信号递达时,系统会根据以下三种可能的处理方式之一来响应:

  1. 默认处理:执行系统预定义的操作(如终止进程)
  2. 忽略处理:完全丢弃该信号
  3. 自定义处理:执行用户注册的信号处理函数

例如,当进程收到SIGINT信号(Ctrl+C)时,默认处理方式是终止进程,但如果用户注册了处理函数,则会执行该函数。

信号未决 (Pending)

信号从产生到递达之间会经历未决状态。这个过程中:

  • 信号被记录在进程的未决信号集合中
  • 每个信号都有一个对应的未决标志位
  • 信号可能因为阻塞而长时间保持未决状态

信号阻塞 (Block)

进程可以通过信号掩码主动阻塞某些信号:

  1. 阻塞的信号仍可被接收,但不会立即递达
  2. 被阻塞的信号会一直保持在未决状态
  3. 常见阻塞场景包括:
    • 关键代码段执行期间
    • 信号处理函数执行时
    • 进程初始化阶段

阻塞与忽略的区别

特性阻塞 (Block)忽略 (Ignore)
作用时机信号递达前信号递达后
信号状态保持未决已被处理
后续影响解除阻塞后会递达直接丢弃
典型应用临时屏蔽关键信号永久忽略无关信号

例如,在银行交易处理中:

  • 阻塞SIGINT可防止交易中途被中断
  • 忽略SIGCHLD可避免处理子进程状态变化

3.2 信号在内核中的表示

内核中通过三张表来表示信号

三张核心表

1. 信号处理动作表 (sighand_struct->action[])

  • 位置task_struct->sighand->action[64]

  • 作用:定义了对每个信号的处理方式

  • 大小_NSIG(通常为64),对应64种可能的信号

  • 内容:每个元素是一个k_sigaction结构,包含:

    • sa_handler:信号处理函数指针(可以是SIG_DFLSIG_IGN或用户自定义函数)

    • sa_flags:控制信号处理的各种标志

    • sa_mask:在执行此信号处理函数时,需要阻塞的其他信号集

    • sa_restorer:恢复函数(通常不由应用程序直接使用)

2. 阻塞信号表 (blocked)

  • 位置task_struct->blocked

  • 作用:记录当前被进程阻塞(屏蔽)的信号

  • 类型sigset_t(一个位掩码,每位对应一个信号)

  • 功能:即使信号产生,如果它在阻塞集中,也不会被递送给进程,直到解除阻塞

3. 未决信号表 (pending)

  • 位置task_struct->pending

  • 作用:记录已经产生但尚未递达(处理)的信号

  • 结构:包含一个sigset_t(位图)和一个list_head(链表)

  • 特殊功能:对于实时信号,list_head用于实现信号队列,可以存储多个相同的信号

信号处理示例分析

我们通过上图中的例子来解释这三种表如何协同工作:

SIGHUP 信号(信号1)

  • 阻塞位:0(未阻塞)

  • 未决位:0(未产生)

  • 处理动作:默认处理动作(SIG_DFL

  • 行为:当SIGHUP信号产生时,内核会设置未决标志,然后在合适的时候执行默认处理动作

SIGINT 信号(信号2)

  • 阻塞位:1(被阻塞)

  • 未决位:1(已产生但未处理)

  • 处理动作:忽略(SIG_IGN

  • 行为:虽然处理动作是忽略,但由于信号被阻塞,它暂时不能被处理。进程有机会在解除阻塞前改变处理动作

SIGQUIT 信号(信号3)

  • 阻塞位:1(被阻塞)

  • 未决位:0(未产生)

  • 处理动作:用户自定义函数sighandler

  • 行为:一旦产生SIGQUIT信号,它将被阻塞,直到解除阻塞后才会调用sighandler

关键机制:信号阻塞与未决

阻塞与未决的关系

  1. 信号产生时,内核首先检查该信号是否被阻塞

  2. 如果未被阻塞,内核可能直接递送信号(取决于信号类型和当前状态)

  3. 如果被阻塞,内核设置该信号的未决标志,但不立即递送

  4. 当进程解除对某信号的阻塞时,内核检查该信号的未决标志

  5. 如果未决标志被设置,内核随后会递送该信号

常规信号 vs 实时信号

  • 常规信号(1-31):在递达之前产生多次只计一次,会丢失额外的信号

  • 实时信号(34-64):支持排队,多次产生的信号会依次存放在队列中,不会丢失

内核数据结构详解

// 进程描述符中与信号相关的字段
struct task_struct {// ...struct sighand_struct *sighand;  // 指向信号处理表sigset_t blocked;                // 阻塞信号表(位图)struct sigpending pending;       // 未决信号表// ...
};// 信号处理表结构
struct sighand_struct {atomic_t count;                  // 引用计数struct k_sigaction action[_NSIG]; // 每个信号的处理动作spinlock_t siglock;              // 保护该结构的自旋锁
};// 信号处理动作详情
struct k_sigaction {struct __new_sigaction sa;       // 信号处理结构void __user *ka_restorer;        // 恢复函数指针
};// 信号处理结构
struct __new_sigaction {__sighandler_t sa_handler;       // 信号处理函数指针unsigned long sa_flags;          // 标志位void (*sa_restorer)(void);       // 恢复函数(通常不使用)__new_sigset_t sa_mask;          // 执行处理函数时要阻塞的信号集
};// 未决信号结构
struct sigpending {struct list_head list;           // 实时信号的队列sigset_t signal;                 // 未决信号的位图
};

总结

Linux内核通过三张表精细地管理信号:

  1. 处理动作表决定了信号最终如何被处理

  2. 阻塞表控制哪些信号暂时不被处理

  3. 未决表记录已产生但尚未处理的信号


3.3 sigset_t和信号集操作函数

 sigset_t:信号集

1. 本质:一个位掩码(Bitmask)

  • sigset_t 是一个不透明的数据类型,通常在内核中定义为一个大整数或整数数组。

  • 它的每一位(bit)对应一个信号编号

    • 例如,第 1 位代表信号 1 (SIGHUP),第 2 位代表信号 2 (SIGINT),以此类推。

  • 位的值只有两种状态:

    • 1(有效):表示该信号处于“有效”状态。

    • 0(无效):表示该信号处于“无效”状态。

2. 两种角色,两种含义

sigset_t 类型的变量在不同的上下文中扮演不同角色,因此相同的“有效”状态有着截然不同的含义:

上下文集合名称“有效”(bit = 1)的含义“无效”(bit = 0)的含义
阻塞信号集阻塞集/屏蔽集该信号被当前进程阻塞(Blocked)该信号未被阻塞
未决信号集未决集该信号已产生,但尚未递达(处理),即处于未决(Pending)状态该信号未产生或已处理(未决标志已清除)

关键区别

  • 阻塞集是一个设置。它由进程主动通过系统调用(如 sigprocmask)来设定,表示“我不想现在接收这些信号”。

  • 未决集是一个记录。它由内核自动维护,表示“这些信号已经送达门口,但还没被处理”。

3. 为什么“屏蔽”应理解为“阻塞”而不是“忽略”?

这是一个非常关键的概念区分:

  • 忽略(Ignore):是一种信号处理动作。当信号已递达时,进程选择不做任何操作。它通过 signal(sig, SIG_IGN) 来设置。

  • 阻塞/屏蔽(Block):是一种信号递达前的状态管理。它阻止信号被递达,信号会一直保持在未决状态,直到解除阻塞。它通过 sigprocmask 等函数操作阻塞集来实现。

一个信号的旅程
产生 -> (检查阻塞集?是:进入未决集;否:准备递达) -> 递达 -> (检查处理动作:默认、忽略、捕获)

举个例子
假设你对 SIGINT 的处理动作是“忽略”(SIG_IGN),但同时你又阻塞了 SIGINT

  1. 当 SIGINT 产生时,因为它被阻塞,所以不会立即递达,而是先挂在未决集里

  2. 在此期间,你有机会将处理动作从“忽略”改为“自定义处理函数”。

  3. 当你解除对 SIGINT 的阻塞时,内核发现它未决,于是开始递达。

  4. 此时,内核才会去看处理动作表,发现是“忽略”,于是直接清除其未决位,什么都不做。

如果在第 2 步你没有改变处理动作,那么最终结果看起来和直接“忽略”没区别。但阻塞为你提供了一个改变决策的机会窗口,这是纯粹的“忽略”所不具备的。


信号集操作函数

因为 sigset_t 是不透明类型,你不能直接对其使用位操作(如 &|)。POSIX 定义了一套标准函数来操作它。

1. 初始化与基本操作

#include <signal.h>int sigemptyset(sigset_t *set);  // 初始化set为空集合(所有位设为0)
int sigfillset(sigset_t *set);   // 初始化set为包含所有信号的集合(所有位设为1)
int sigaddset(sigset_t *set, int signum); // 将指定信号signum添加到set中
int sigdelset(sigset_t *set, int signum); // 从set中删除指定信号signum
int sigismember(const sigset_t *set, int signum); // 判断信号signum是否在set中

这些函数成功返回 0,失败返回 -1

示例:创建一个只包含 SIGINT 和 SIGQUIT 的信号集。

sigset_t my_set;
sigemptyset(&my_set); // 必须先初始化为空!
sigaddset(&my_set, SIGINT);
sigaddset(&my_set, SIGQUIT);

其实和我们学习哈希扩展——位图时的操作,在本质上是差不多的

2. 核心应用:修改进程信号屏蔽字(阻塞集)

#include <signal.h>int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
  • 功能:读取或更改进程的信号屏蔽字(阻塞集)

  • 参数 how:指定如何修改阻塞集。

    • SIG_BLOCK阻塞 set 中的信号。新屏蔽字 = 当前屏蔽字 | set

    • SIG_UNBLOCK解除阻塞 set 中的信号。新屏蔽字 = 当前屏蔽字 & ~set

    • SIG_SETMASK:直接用 set 替换当前屏蔽字。

  • 参数 set:指向一个由之前 sigaddset 等函数准备好的信号集。如果为 NULL,则 how 参数被忽略,函数只用于获取旧的屏蔽字。

  • 参数 oldset:用于保存旧的信号屏蔽字,以便后续恢复。如果为 NULL 则不保存。

  • 返回值:成功返回 0,失败返回 -1

示例:阻塞 SIGINT 信号。

sigset_t new_set, old_set;
sigemptyset(&new_set);
sigaddset(&new_set, SIGINT);// 阻塞SIGINT,并保存旧的屏蔽字到old_set
if (sigprocmask(SIG_BLOCK, &new_set, &old_set) == -1) {perror("sigprocmask");
}
// ... 在这段代码中,SIGINT信号会被阻塞 ...
// 恢复旧的屏蔽字(解除对SIGINT的阻塞)
if (sigprocmask(SIG_SETMASK, &old_set, NULL) == -1) {perror("sigprocmask");
}

3. 获取当前未决信号集

#include <signal.h>int sigpending(sigset_t *set);
  • 功能:获取当前进程的未决信号集,并通过 set 参数返回。

  • 参数 set:输出型参数,用于存放获取到的未决信号集。

  • 返回值:成功返回 0,失败返回 -1

示例:检查 SIGINT 是否处于未决状态。

sigset_t pending_set;
sigpending(&pending_set); // 获取未决集
if (sigismember(&pending_set, SIGINT)) {printf("SIGINT is pending!\\n");
}

总结

  • sigset_t 是一个位掩码,用于表示信号的集合。

  • 它在阻塞集中表示“是否被屏蔽”,在未决集中表示“是否已产生但未处理”。

  • “阻塞” 和 “忽略” 是截然不同的概念:阻塞是递达前的延迟,忽略是递达后的处理动作。

  • 必须使用 sigemptysetsigaddsetsigprocmask 等标准函数来操作 sigset_t,不能直接进行位运算。


完整示例:

void PrintPending(sigset_t& pending)
{std::cout << "我是一个进程, pid: " << getpid() << ", pending: ";for(int signo = 31; signo >= 1; signo--){if(sigismember(&pending, signo)){std::cout << "1";}else{std::cout << "0";}}std::cout << std::endl;
}int main()
{// 1. 初始化sigset_t block, oldblock;sigemptyset(&block);sigemptyset(&oldblock);// 2. 添加信号到阻塞表中for(int i = 31; i >= 1; i--)sigaddset(&block, i);// 3. 屏蔽信号int n = sigprocmask(SIG_SETMASK, &block, &oldblock);if(n < 0){perror("sigprocmask");}while(true){// 4. 获取pending信号集合sigset_t pending;int m = sigpending(&pending);if(m < 0){perror("sigpending");}// 5. 打印pending信号集合PrintPending(pending);sleep(1);}return 0;
}

运行结果:

我们把所有普通信号的阻塞表都设为屏蔽,可以看到我们通过kill命令发送信号时,信号被屏蔽了,所以可以看到未决表中记录的已产生但未递达的信号集,我们发送一个信号,该信号集对应位置为1,但是由于9号信号比较特殊,无法被捕获,阻塞和忽略,我们发送9号信号时,进程就被杀死了


下一篇文章我们再介绍信号的处理

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:http://www.pswp.cn/news/919599.shtml
繁体地址,请注明出处:http://hk.pswp.cn/news/919599.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

WEB服务器(静态/动态网站搭建)

简介 名词:HTML(超文本标记语言),网站(多个网页组成一台网站),主页,网页,URL(统一资源定位符) 网站架构:LAMP(linux(系统)+apache(服务器程序)+mysql(数据库管理软件)+php(中间软件)) 静态站点 Apache基础 Apache官网:www.apache.org 软件包名称:…

开发避坑指南(29):微信昵称特殊字符存储异常修复方案

异常信息 Cause: java.sql.SQLException: Incorrect string value: \xF0\x9F\x8D\x8B\xE5\xBB... for column nick_name at row 1异常背景 抽奖大转盘&#xff0c;抽奖后需要保存用户抽奖记录&#xff0c;用户再次进入游戏时根据抽奖记录判断剩余抽奖机会。保存抽奖记录时需要…

leetcode-python-242有效的字母异位词

题目&#xff1a; 给定两个字符串 s 和 t &#xff0c;编写一个函数来判断 t 是否是 s 的 字母异位词。 示例 1: 输入: s “anagram”, t “nagaram” 输出: true 示例 2: 输入: s “rat”, t “car” 输出: false 提示: 1 < s.length, t.length < 5 * 104 s 和 t 仅…

【ARM】Keil MDK如何指定单文件的优化等级

1、 文档目标解决在MDK中如何对于单个源文件去设置优化等级。2、 问题场景在正常的项目开发中&#xff0c;我们通常都是针对整个工程去做优化&#xff0c;相当于整个工程都是使用一个编译器优化等级去进行的工程构建。那么在一些特定的情况下&#xff0c;工程师需要保证我的部分…

零基础学Java第二十二讲---异常(2)

续接上一讲 目录 一、异常的处理&#xff08;续&#xff09; 1、异常的捕获-try-catch捕获并处理异常 1.1关于异常的处理方式 2、finally 3、异常的处理流程 二、自定义异常类 1、实现自定义异常类 一、异常的处理&#xff08;续&#xff09; 1、异常的捕获-try-catch捕…

自建开发工具IDE(一)之拖找排版—仙盟创梦IDE

自建拖拽布局排版在 IDE 中的优势及初学者开发指南在软件开发领域&#xff0c;用户界面&#xff08;UI&#xff09;的设计至关重要。自建拖拽布局排版功能为集成开发环境&#xff08;IDE&#xff09;带来了诸多便利&#xff0c;尤其对于初学者而言&#xff0c;是踏入开发领域的…

GitHub Copilot - GitHub 推出的AI编程助手

本文转载自&#xff1a;GitHub Copilot - GitHub 推出的AI编程助手 - Hello123工具导航。 ** 一、GitHub Copilot 核心定位 GitHub Copilot 是由 GitHub 与 OpenAI 联合开发的 AI 编程助手&#xff0c;基于先进大语言模型实现代码实时补全、错误检测及文档生成&#xff0c;显…

基于截止至 2025 年 6 月 4 日,在 App Store 上进行交易的设备数据统计,iOS/iPadOS 各版本在所有设备中所占比例详情

iOS 和 iPadOS 使用情况 基于截止至 2025 年 6 月 4 日&#xff0c;在 App Store 上进行交易的设备数据统计。 iPhone 在过去四年推出的设备中&#xff0c;iOS 18 的普及率达 88。 88% iOS 188% iOS 174% 较早版本 所有的设备中&#xff0c;iOS 18 的普及率达 82。 82% iOS 189…

云计算-k8s实战指南:从 ServiceMesh 服务网格、流量管理、limitrange管理、亲和性、环境变量到RBAC管理全流程

介绍 本文是一份 Kubernetes 与 ServiceMesh 实战操作指南,涵盖多个核心功能配置场景。从 Bookinfo 应用部署入手,详细演示了通过 Istio 创建 Ingress Gateway 实现外部访问,以及基于用户身份、请求路径的服务网格路由规则配置,同时为应用微服务设置了默认目标规则。 还包…

Vue 3项目中的路由管理和状态管理系统

核心概念理解 1. 整体架构关系 这两个文件构成了Vue应用的导航系统和状态管理系统&#xff1a; Router&#xff08;路由&#xff09;&#xff1a;控制页面跳转和URL变化Store&#xff08;状态&#xff09;&#xff1a;管理全局数据和用户状态两者协同工作实现权限控制 2. 数据流…

Linux Capability 解析

文章目录1. 权限模型演进背景2. Capability核心原理2.1 能力单元分类2.2 进程三集合2.3 文件系统属性3. 完整能力单元表4. 高级应用场景4.1 能力边界控制4.2 编程控制4.3 容器安全5. 安全实践建议6. 潜在风险提示 1. 权限模型演进背景 在传统UNIX权限模型中&#xff0c;采用二进…

vue 监听 sessionStorage 值的变化

<template><div class"specific-storage-watcher"><h3>仅监听 userId 变化</h3><p>当前 userId: {{ currentUserId }}</p><p v-if"changeRecord">最近变化: {{ changeRecord }}</p><button click"…

IDEA:控制台中文乱码

目录一、设置字符编码为 UTF-8一、设置字符编码为 UTF-8 点击菜单 File -> settings -> Eitor -> File Encodings , 将字符全局编码、项目编码、配置文件编码统一设置为UTF-8, 然后点击 Apply 应用设置&#xff0c;点击 OK 关闭对话框:

[Sql Server]特殊数值计算

任务一&#xff1a;求下方的Num列的中值:参考代码:use Test go SELECT DISTINCTPERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY Num) over()AS MedianSalary FROM MedianTest;任务二: 下方表中,每个选手有多个评委打分&#xff0c;求每个选手的评委打分中值。参考代码:use Tes…

01-Docker概述

Docker 的主要目标是:Build, Ship and Run Any App, Anywhere,也就是通过对应用组件的封装、分发、部署、运行等生命周期的管理,使用户的 APP 及其运行环境能做到一次镜像,处处运行。 Docker 运行速度快的原因: 由于 Docker 不需要 Hypervisor(虚拟机)实现硬件资源虚拟化…

Laravel中如何使用php-casbin

一、&#x1f680; 安装和配置 1. 安装包 composer require casbin/laravel-authz2. 发布配置文件 php artisan vendor:publish这会生成两个重要文件&#xff1a; config/lauthz.php - 主配置文件config/lauthz-rbac-model.conf - RBAC 模型配置文件 3. 运行数据库迁移 php…

算法题打卡力扣第4题:寻找两个正序数组的中位数(hard))

题目描述 提示&#xff1a; nums1.length m nums2.length n 0 < m < 1000 0 < n < 1000 1 < m n < 2000 -106 < nums1[i], nums2[i] < 106 解答思路 我的想法是先归并排序再直接返回下标中位数 代码 double findMedianSortedArrays(int* nums1,…

无人机抗噪模块技术概述!

一、 技术要点1. 传感器数据融合与滤波&#xff08;解决感知噪声&#xff09;核心思想&#xff1a;单一传感器易受干扰且不全面&#xff0c;通过融合多种传感器&#xff08;IMU惯性测量单元、GPS、气压计、磁力计、视觉传感器、激光雷达等&#xff09;的数据&#xff0c;利用算…

Horse3D游戏引擎研发笔记(六):在QtOpenGL环境下,仿Unity的材质管理Shader绘制四边形

在上一篇笔记中&#xff0c;我们已经实现了基于QtOpenGL的BufferGeometry管理VAO和EBO绘制四边形的功能。这一次&#xff0c;我们将深入探讨材质管理系统的实现&#xff0c;包括Shader的加载与编译、材质的创建与使用&#xff0c;以及如何通过材质系统绘制带有自定义Shader效果…

MySQL-分库分表(Mycat)

目录 1.介绍​ 概述 拆分策略 垂直拆分​ 水平拆分​ 实现技术​ shardingJDBC: MyCat: 2.Mycat概述 环境准备​ 分片配置 schema.xml​ server.xml 启动服务​ 分片测试​ 3.MyCat配置 schema.xml​ schema标签 datanode标签 ​datahost标签​ rule.xml …