跳到主要内容 Linux 信号的产生方式与保存机制 | 极客日志
C++
Linux 信号的产生方式与保存机制 Linux 信号是进程间异步通知机制,用于中断进程当前操作。信号产生方式包括键盘输入(如 Ctrl+C)、系统调用(kill、raise、abort)、硬件异常(除零、段错误)及软件条件(alarm 定时器)。信号在内核中通过 task_struct 的位图保存,涉及未决(pending)、阻塞(block)和处理(handler)三种状态。常用 API 包括 signal、sigprocmask、sigpending 等管理信号集。信号处理分为默认、自定义和忽略,部分信号如 SIGKILL 不可捕获或阻塞。核心转储(core dump)可用于调试进程异常退出原因。
一。认识信号
首先应该知道信号和信号量之间没有任何关系。
生活中的信号例如:闹钟、红绿灯、上课铃声等。闹钟响了我们就得起床,上课铃声响了我们就得进教室。什么是信号?中断我们人正在做的事情,是一种事件的异步通知机制 。
其实进程就相当于我们的人,当进程收到信号时,进程就要中断进程正在做的事情,这种方式就叫做信号。所以信号是给进程发送的,用来进行事件异步通知的机制 。
信号的产生相对于进程的运行是异步的
信号是发给进程的
基本结论:
信号处理在信号没有产生时就知道该如何处理了
信号的处理不是立即处理,可以等一会再处理,在合适的时候进行处理
人能识别信号前提是被'教育'过的(红灯亮了要等一等),进程也是如此,OS 程序员设计的进程,进程早已经内置的对于信号的识别和处理方式!
信号源是非常多的,给进程产生信号的信号源也非常多
二。产生信号的方式
2.1 键盘产生信号
我们先编写一段代码 testSig.cc。
#include <iostream>
#include <unistd.h>
int main () {
while (true ) {
std::cout << "Hello world" << std::endl;
sleep (1 );
}
return 0 ;
}
当这段代码运行起来后想终止,我们可以使用 Ctrl + C,但为什么 Ctrl + C 可以终止呢?因为 Ctrl + C 是给目标进程发送信号的。相当一部分信号的处理动作就是让进程自己终止。
查看 Linux 中的信号列表
指令:kill -l
在我们计算机中,信号就是一个整数的数字,未来想要用进程的话,可以用该数字向目标进程发送信号,但是数字的可读性不是很好,未来我们在使用的时候,会把数字定义成为大写的字母宏 ,该宏对应的值就是前面匹配的数字。
在 Linux 系统里一共有 62 个信号,从 34 到 64 这部分信号称为实时信号,这种信号往往一旦产生就需要立即处理。
而我们上面的 Ctrl + C 就是通过键盘给目标进程发送 2 号信号。
进程收到信号的处理方式:
默认动作处理
自定义动作处理
忽略处理!
更改一个信号的默认处理动作:signal
#include <signal.h>
;
;
微信扫一扫,关注极客日志 微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
相关免费在线工具 Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
Markdown转HTML 将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
HTML转Markdown 将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
JSON 压缩 通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
JSON美化和格式化 将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online
typedef void (*sighandler_t ) (int )
sighandler_t signal (int signum, sighandler_t handler)
参数
signum:信号对应的数字
handler: 返回值为 void,参数为 int 的函数
信号不再执行默认的方法,反而执行自定义的函数中指针指向的方法。
案例:将 2 号信号的默认处理改成打印:"获得了一个信号:" << sig
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handlerSig (int sig) {
std::cout << "获得了一个信号:" << sig << std::endl;
}
int main () {
signal (SIGINT, handlerSig);
int cnt = 0 ;
while (true ) {
std::cout << "Hello world: " << cnt++ << std::endl;
sleep (1 );
}
return 0 ;
}
当我们 Ctrl + C 的时候就会发现,此时的 Ctrl + C 不再是终止,而是打印出这段话。
此时我们想终止的话可以用 Ctrl + \ 3 号信号来终止。
我们上面说过,信号是发送给目标进程的,可是目标进程是谁呢?
当我们在命令行直接 ./XXX 运行时,这种进程叫做前台进程 ;当我们直接在命令行 ./XXX &,这叫做后台进程。
当我们的进程是后台进程时,我们 Ctrl + C 无法处理我们的信号,所以键盘产生的信号只能发送给前台进程。
当我们登陆 Linux 系统,不管我们是拿桌面登录还是拿 Xshell 登陆,Linux 系统首先会帮我们创建一个进程,这个进程叫做 shell,在 ubuntu 下这个进程叫做 bash 进程,这个 bash 进程首先会输出命令行,然后等待用户输入。当我们 ./xxx 它就会创建一个子进程,这个进程是前台进程,前台进程能从标准输入(键盘)中获取内容 ,后台进程是相反的。但是不管是前台进程还是后台进程,它们两个都能向标准输出上打印。
前台进程只能有一个,后台进程可以有多个。
前台进程的本质是从键盘上获取数据的。
我们以前的父进程退出,子进程 Ctrl + C 就杀不掉了,原因是父进程退出了子进程就变成了孤儿进程,被自动提到后台了,所以只能用 kill 杀掉。
jobs 查看所有的后台任务
fg + 任务号:表示把特定的进程提到前台
Ctrl + Z:暂停进程
前台进程不能被暂停,因为前台进程永远要接收用户输入
所以一个进程一旦被暂停,就会被自动提到后台
让暂停的进程运行起来:bg + 任务号,但是此时运行起来的进程是后台进程
信号产生之后并不是立即处理的,所以进程要把接收到的信号记录下来。记录的目的是为了在合适的时候处理。
记录在哪里?
进程的 struct task_struct
如何记录?
在进程的 pcb 里维护一个整数,位图结构,用比特位的位置表示信号编号,用内容表示是否收到信号。
发送信号的本质是向目标进程写信号,本质上是修改位图 (需要两个东西,一个是进程的 pid,一个是信号的编号)
task_struct 是操作系统内的数据结构对象,所以修改位图的本质是修改内核的数据。
不管信号怎么产生,发送信号在底层必须让给 OS 发送。所以操作系统必须提供发送信号的系统调用。kill 命令是用 c 语言写的,它调的就是操作系统的系统调用接口,来完成对目标进程发信号。
信号 VS 通信 IPC
狭义上讲,通信 IPC 和信号不一样
2.2 系统调用 #include <sys/types.h>
#include <signal.h>
int kill (pid_t pid, int sig) ;
.PHONY : all
all: testSig mykill
testSig: testSig.cc
g++ -o $@ $< -std=c++11
mykill: mykill.cc
g++ -o $@ $< -std=c++11
.PHONY : clean
clean: rm -f testSig mykill
#include <iostream>
#include <sys/types.h>
#include <signal.h>
int main (int argc, char * argv[]) {
if (argc != 3 ) {
std::cout << "./mykilee signumber pid" << std::endl;
return 1 ;
}
int signum = std::stoi (argv[1 ]);
pid_t target = std::stoi (argv[2 ]);
int n = kill (target, signum);
if (n == 0 )
{
std::cout << "send" << signum << "to" << target << "success" ;
return 0 ;
}
return 0 ;
}
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
void handlerSig (int sig) {
std::cout << "获得了一个信号:" << sig << std::endl;
}
int main () {
signal (SIGINT, handlerSig);
int cnt = 0 ;
while (true ) {
std::cout << "Hello world: " << cnt++ << ",pid" << getpid () << std::endl;
sleep (1 );
}
return 0 ;
}
我们的信号大部分都是可以自定义捕捉的,但是有一个信号不能自定义捕捉,这个信号是 9 号信号 SIGKILL,所以 9 号信号不能被自定义捕捉。
#include <signal.h>
int raise (int sig) ;
raise 表示自己给自己发送信号,raise 可以指明自己给自己发送哪一个信号
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.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);
}
int cnt = 0 ;
while (true ) {
std::cout << "Hello world: " << cnt++ << ",pid" << getpid () << std::endl;
sleep (1 );
}
return 0 ;
}
到 9 号信号停了下来原因是 9 号信号不能被捕捉。
第三个系统调用:abort(其实不属于系统调用)
abort 也是自己给自己发送信号,这个信号要求进程必须处理,它是用来终止进程的
2.3 硬件异常产生信号 先说问题:我们在执行 C/C++ 代码时,我们的程序有时会崩掉,一种叫做除 0,一种叫做野指针。
我们的代码里要是有除 0 错误代码就会崩掉是因为进程收到了 8 号信号(SIGFPE)。
我们的代码中要是有野指针,就会收到 11 号信号(SIGSEGV)。段错误
信号全部都是由操作系统发送的,我们的程序出错了,操作系统会识别我们的进程犯错了并且分析犯错类型,然后向我们的目标进程发送信号,整个过程其实和我们的用户是没有关系的。
现在的核心问题是操作系统怎么知道我们的进程犯错了???
操作系统怎么知道我们的程序除 0 了?
除 0 运算本质是在 CPU 上运行的,CPU 上有各种的寄存器,比如标志寄存器(EFLAGS)这个寄存器是若干个比特位,比如 000100,有一个比特位表示我们 CPU 当前计算时是否出现溢出。换句话说我们的操作系统怎么知道 CPU 计算出问题了呢?
答案是我们的 CPU 属于硬件,操作系统是软硬件资源的管理者,当我们的程序一旦出错,操作系统是能识别到是硬件上出错了的,然后它这个计算是溢出了的,而我们的 CPU 寄存器保存的是当前进程的上下文,当然也包括当前进程的 task_struct,它的硬件上出错了,所以操作系统就能识别到哪一个进程出错了,操作系统转而给我们的目标进程发送对应的信号。
操作系统怎么知道野指针(段错误)的问题?
我们在拿野指针访问 0 号地址,则进程的虚拟地址就是 0,而 0 号地址在我们的页表中并不存在映射关系,所以无法映射到我们的内存当中,CPU 中存在的地址都是虚拟地址,在我们的 CPU 内部存在着一个寄存器叫做 CR3 寄存器,这个寄存器会记录下当前页表的起始地址。在 CPU 内部继承了一种硬件单元,这个硬件单元叫做 MMU,将来 CPU 寻址是将虚拟地址交给 MMU,把 CR3 寄存器里的内容也交给 MMU,此时虚拟地址有了,页表有了,MMU 在硬件层面上拿着对应的页表完成虚拟地址到物理地址的转化。
MMU 在转化时也有可能转化失败,此时再往 0 号地址去写就会发生硬件报错。操作系统作为软硬件资源的管理者,硬件报错了,当前运行的依旧是当前进程的上下文,并且知道当前错误是虚拟到物理转化失败了,并且还要写入,所以就会给当前进程发送 11 号信号,让进程直接终止。
2.4 软件条件产生信号 存在一个系统调用,为当前进程设置闹钟:alarm
当闹钟时间到了的时候,它就会为当前进程推送一个信号。
比如 alarm(5) 定一个 5 秒的闹钟,5 秒之后它就会为当前进程发送一个信号。alarm(0) 表示取消闹钟。
#include <unistd.h>
unsigned int alarm (unsigned int seconds) ;
调用 alarm 函数可以设定⼀个闹钟,也就是告诉内核在 seconds 秒之后给当前进程发 SIGALRM 信号,该信号的默认处理动作是终止当前进程。
返回值
这个函数的返回值是 0 或者是以前设定的闹钟时间还余下的秒数。
我们来设定一种闹钟,这个闹钟 1 秒钟发送一个闹钟信号,此时进程就执行某种任务。以闹钟为驱动力,让进程每隔 1 秒做一次,每隔 1 秒做一次。
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
void handlerSig (int sig) {
std::cout << "获得了一个信号" << sig << "pid:" << getpid () << std::endl;
alarm (1 );
}
int main () {
signal (SIGALRM, handlerSig);
alarm (1 );
while (true ) {
std::cout << ".," << "pid:" << getpid () << std::endl;
sleep (1 );
}
return 0 ;
}
#include <unistd.h>
int pause (void ) ;
pause 的作用是等待一个信号,也就是没有信号时 pause 就暂停了,只有当我们的信号被捕捉,并且从信号捕捉函数返回的时候它才会直接返回。出错的时候会返回 -1,否则就会暂停起来。
让我们的进程一直处于暂停状态,受外部信号的驱动每隔一秒发送一个信号,信号到来时它会执行对应的任务,这就是操作系统的本质
#include <iostream>
#include <vector>
#include <functional>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
void Sche () { std::cout << "我是进程调度" << std::endl; }
void MemManger () { std::cout << "我是周期性的内存管理,正在检查有没有内存问题" << std::endl; }
void Fflush () { std::cout << "我是刷新程序,我在定期刷新内存数据" << std::endl; }
using func_t = std::function<void ()>;
std::vector<func_t > funcs;
void handlerSig (int sig) {
std::cout << "#########################" << std::endl;
for (auto f : funcs) {
f ();
}
std::cout << "#########################" << std::endl;
alarm (1 );
}
int main () {
funcs.push_back (Sche);
funcs.push_back (MemManger);
funcs.push_back (Fflush);
signal (SIGALRM, handlerSig);
alarm (1 );
while (true )
{
pause ();
}
return 0 ;
}
快速理解闹钟:
OS 内可能同时存在很多闹钟,所以操作系统就要对闹钟进行管理。所以所谓的闹钟就是一种数据结构,创建闹钟就是创建一种闹钟的结构体对象。
下面这就是一个闹钟的描述结构:
struct timer_list {
struct list_head entry;
unsigned long expires;
void (*function)(unsigned long );
unsigned long data;
struct tvec_t_base_s *base;
};
我们之前学过数据结构中的堆,我们设置的闹钟是以最小堆的方式,即堆顶元素为最短超时的闹钟,通过和时间戳做比较,如果堆顶元素的时间小于了我们的时间戳,则堆顶的闹钟出堆,向目标进程发送信号,执行对应的 SIGALRM。
所以闹钟超时之后,向目标进程发送信号的这种方式,叫做软件条件产生信号。
总结:上面产生信号的五种方式当中,不论哪一种方式,都是由 OS 进行信号的发送的。所谓的信号发送其实是向目标进程修改比特位。
三。信号的保存 我们上面就已经说过了,进程接受到信号不一定立即处理,所以前提就先得保存信号。
3.1 信号其他的相关常见概念
实际执行信号的处理动作称为信号抵达(Delivery)。
信号从产生到信号抵达中间的状态称为信号未决(Pending)。
进程可以选择阻塞(Block)某个信号,我们把阻塞信号也叫做屏蔽信号。
被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行抵达的动作。
注意,忽略和信号阻塞是不同的,忽略是信号抵达状态时一种可选择的执行信号的方式。
3.2 这些概念在内核中的表现 在进程的 PCB 中不仅仅有位图,其实进程当中与普通信号相关的有三张表,block 表 pending 表 handler 表。
pending:(unsigned int pending)表其实就是保存我们信号收到的位图,也叫做未决图。
比特位的位置:表示的是第几个信号
比特位的内容:是否收到信号,0 表示未收到该信号,1 表示收到了该信号
block:(unsigned int block) 也是位图。
比特位的位置:表示的是第几个信号
比特位的内容:是否阻塞,0 表示可以直接抵达,1 表示不能直接抵达
pending&(~block):表示可以直接抵达的信号
handler:我们之前用的系统调用 signal,它里面的一个参数就是 handler
这个表是一个函数指针数组。这个表的下标表示的就是信号编号。
所以我们的 signal 函数的本质就是在系统调用的内部,拿着我们传进去的信号编号作为该数组的索引,找到特定的位置,把我们自己设置的函数的地址传进去。
所以当前有没有信号,有没有被阻塞,怎么处理我们就知道了。这三张表合起来,共同承担了进程怎么识别信号。
回到最开始我们说的,信号再没抵达时我们就知道该信号如何处理了,即便是没有 block,pending,我们也知道它的 handler 是 SIG_DFL 默认处理动作,进程未来处理信号的方式本质上就是修改 handler 表
我们应该横着来看这张表,从上往下对应了 31 组描述信号的关系。这 3 这表支撑了进程对信号的识别。
3.3 sigset_t 从上图来看,每个信号只有一个比特位的未决标志,即非 0 即 1,不记录信号产生了多少次,阻塞信号也是这样的。因此,未决和阻塞标志可以用相同的数据类型 sigset_t 来存储,sigset_t 称为信号集,这个类型可以表示信号的'有效'或者'无效'状态。在阻塞信号集中'有效'和'无效'的含义是该信号是否被阻塞,在未决信号集中,'有效'和'无效'表示该信号是否处于未决状态。
阻塞信号集也叫当前进程的信号屏蔽字(Signal Mask),这里的'屏蔽'应该理解为阻塞而不是忽略。
3.4 信号集操作函数 sigset_t 类型对于每种信号用一个比特位表示'有效'或者'无效'状态,至于内部如何存储这些比特位依赖于系统的实现,从使用者的角度是不关心的,使用者只需要调用下面的函数来操作 sigset_t 变量,而不需要对它的内部做任何解释。
#include <signal.h>
int sigemptyset (sigset_t *set) ;
int sigfillset (sigset_t *set) ;
int sigaddset (sigset_t *set, int signo) ;
int sigdelset (sigset_t *set, int signo) ;
int sigismember (const sigset_t *set, int signo) ;
sigemptyset:函数 sigemptyset 用来初始化 set 所指向的信号集,使其中所有信号的对应比特位清零,表示该信号集不包含任何有效信号。
sigfillset:函数 sigfillset 用来初始化 set 所指向的信号集,使其中所有信号的对应比特位全部置 1,表示该信号集包含所有信号。
注意:在使用 sigset_t 变量之前,一定要调用 sigemptyset 或者 sigfillset 初始化,使信号集处于确定状态。初始化 sigset_t 后,就可以调用 sigaddset 和 sigdelset 在信号集中添加或者删除某种有效信号了。
3.4.1 sigprocmask 调用函数 sigprocmask 可以读取或者更改进程的信号屏蔽字(阻塞信号集)
哪一个进程调用这个函数就是在设置或者更新自己的 block 表
参数
第二个参数是未来我们用户自己传入的 sigset_t 类型(输入型)
第三个参数是一个输出型参数,在我们改 block 之前把旧的 block 带出去,这样如果我们就可以恢复到之前的 block
返回值
设置成功 0 被返回,否则 -1 被返回
sig_block:表示把传进来的设置为 1 的信号新增到 block 表中,如果已经屏蔽了就继续屏蔽,没有屏蔽的信号设置成屏蔽
sig_unblock:表示把已经屏蔽的信号设置为未屏蔽的
sig_setmask:表示未来自己定义的信号集,把整个 block 表全部更新一遍
3.4.2 sigpending #include <signal.h>
int sigpending (sigset_t *set) ;
这个函数是用来获取当前进程的 pending 信号集的,这个参数是一个输出型参数
demo 代码: 先将 2 号信号屏蔽,然后不断获取 pending 表,不断打印,再向目标进程发送 2 号信号,因为 2 号信号不会被立即抵达(执行),只是 pending 表由 0 变 1,不执行对应的操作,所以我们才能看到信号由 0 变为 1 的过程。
#include <iostream>
#include <cstdio>
#include <vector>
#include <functional>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
void PrintPending (sigset_t &pending) {
printf ("我是一个进程 (%d),pending:" , getpid ());
for (int signo = 31 ; signo >= 1 ; signo--)
{
if (sigismember (&pending, signo)) {
std::cout << "1" ;
} else {
std::cout << "0" ;
}
}
std::cout << std::endl;
}
void handler (int sig) {
std::cout << "抵达" << sig << "信号!" << std::endl;
}
int main () {
signal (SIGINT, handler);
sigset_t block, oblock;
sigemptyset (&block);
sigemptyset (&oblock);
sigaddset (&block, SIGINT);
int n = sigprocmask (SIG_SETMASK, &block, &oblock);
(void )n;
int cnt = 10 ;
while (true ) {
sigset_t pending;
int m = sigpending (&pending);
PrintPending (pending);
if (cnt == 0 ) {
std::cout << "解除对 2 号信号的屏蔽" << std::endl;
sigprocmask (SIG_SETMASK, &oblock, nullptr );
}
sleep (1 );
cnt--;
}
return 0 ;
}
在这个过程中我们会发现一个现象,我们要是把所有信号都屏蔽的话,那么这个进程就有了'金刚不坏之身了',这个进程要是病毒的话那岂不是完蛋了!但是由实验现象可以知道,9 号信号是不能被屏蔽的。所以9 号信号不可被捕捉,不可被阻塞!!!
pending 表中的由 1 变 0,是在 handler 执行之前变的还是 handler 执行之后变的。
结论是:当我们准备抵达的时候,首先要清空 pending 信号集中的对应的位图 1 -> 0。(在抵达之前由 1 变 0)
🌵补充细节问题: 我们保存信号用的是 pending 位图,pending 位图用一个比特位来表示是否收到,那如果将来有一种信号产生很多次该怎么办呢?
比如说今天向目标进程发了一百个 2 号信号,因为进程不一定是对信号做立即处理的,此时的普通信号该怎么处理呢?Linux 中是这样设计的,常规信号在抵达之前产生多次只记录一次,而实时信号在抵达之前产生多次,可以一次放在一个队列里。
🍒信号终止进程的方式有两种,core,term
core 🆚 term
core 是核心的意思,如果一个进程以 core 退出,往往会在当前路径下形成一个文件,进程异常退出的时候,进程在内存中的核心数据会从内存拷贝在磁盘,形成一个文件,称为核心转储 ,只要是为了支持 debug 调试的。
term 是进程因为异常而退出,但是 term 不做任何转储功能,直接退出
注意:在我们的云服务器上 core dump 功能是被禁止掉的。
为什么要禁止掉?
未来如果我们的程序发生错误,core dump 是要在当前路径下形成一个文件的,如果我们的程序挂一次,它形成一个文件,挂一次,形成一个文件就很容易把我们的磁盘打满。
如何查看?
ulimit -a ,其中有一个 core file size 为 0 表示关闭
如何打开?
ulimit -c (临时方案)
为什么要进行核心转储?
答案是:为了支持 debug
如果我们未来在找 bug 找不到,我们可以开启 core dump,直接让程序运行崩溃,gdb,core-file core,直接帮助我们来定位到出错行。(事后调试)
我们之前的进程等待,次低 8 位代表的是进程退出时的退出码,低 7 位表示的是该进程退出时的退出信号,一个进程在退出的时候如果信号为 0 表示正常退出的(因为信号是从 1-31 的,没有 0 号信号)。除了次低 8 位和低 7 位,还有一个标志位就是 core dump 标志位,表示是否 core dump