信号
红绿灯,下课铃,闹钟等都是信号,我们是认识这些信号的,在没有收到这些信号之前,我们也知道如果收到了这些信号我们该怎么做
进程也是,在没有收到信号的之前,就知道如何处理这些信号
如何处理:1.有执行默认动作SIG_DFL
。2.执行自定义动作。3.忽略SIG_IGN
可能进程在运行的时候突然就收到了一个信号,所以进程要有记录信号的能力,要将信号记录下来
进程可能一次要处理多个信号,所以对于信号,进程需要组织管理起来,就是先描述,在组织
kill -l
查询所有信号
我们可以看到没有0信号,31信号之后直接就是34信号,1-31是普通信号,34-64是实时信号,括号里的数字就是信号,后面大写的字母就是信号名称,是宏
信号的产生对于进程来讲是异步的
进程对于信号的保存是只保存有无产生,不是保存收到的这个信号的数量
有31个普通信号,保存有无产生可以用0和1来保存,所以用位图结构就特别合适,信号的位图结构保存在进程的pcb中
所以保存信号,就是修改进程信号位图的特定比特位
比特位的位置是信号的编号,比特位的内容0或1表示是否收到信号
要修改进程pcb中的信号位图结构,进程的代码是没有权限的,要用系统调用接口来修改
信号产生
键盘输入信号
mysignal.cc
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
using namespace std;
int main()
{
while(1)
{
cout << "我是一个进程,我的PID:" << getpid() << endl;
sleep(1);
}
return 0;
}
makefile
mysingal:mysingal.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -f mysingal
通过指令向进程发信号,进程执行了9号信号的默认动作,杀死进程
我们按了ctrl c
的之后,键盘就会产生硬件中断,被操作系统将其解释成2号信号,发送给前台进程,前台进程收到信号,退出
- 前台进程:操作系统只允许一个进程处于前台,默认为bash,当你执行了这样一个死循环的代码之后,前台进程就不是bash了,是你的死循环进程,所以你输入的指令不起作用了,只有
ctrl c
终止进程- 后台进程:
./可执行文件 &
即可变成后台进程,之后我们执行指令是不受影响的,这个时候不能ctrl c
终止,只能发信号终止
捕捉信号singal
函数
signal
函数是一个用于处理信号的函数
sighandler_t signal(int signum, sighandler_t handler)
signum
:要修改执行方法的信号编号。handler
:指向信号新的执行方法的指针。- 返回值:是先前为该信号设置的处理函数的指针,如果该信号之前没有设置过处理函数,则返回
SIG_DFL
或SIG_IGN
。
要注意sighandler_t
是一个函数指针类型,参数是int,返回值是void的函数指针
signal
函数用于指定在接收到指定信号时调用的处理函数。它允许程序捕获和处理不同类型的信号,例如程序终止信号、键盘中断信号等。
mysignal.cc:
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
using namespace std;
void handler(int signo)
{
cout << "收到的信号:" << signo << endl;
}
int main()
{
signal(2, handler);
while(1)
{
cout << "我是一个进程,我的PID:" << getpid() << endl;
sleep(1);
}
return 0;
}
ctrl c
会被操作系统解释成2号信号,现在确实没有终止进程,而去执行了我们自定义的方法
所以,如果我们将所有信号都设置为执行我们的自定义方法,那么进程是不是就无法被杀死了?我们试一下
此时除了9号信号,其他信号都被修改为执行自定义方法,所以想创造一个无法被杀死的进程,这样是不可能的
系统调用输入信号
kill
给任意进程发任意信号
int kill(pid_t pid, int sig);
man 2 kill
2号手册中可以查询到
pid
参数是指定要发送信号的进程的进程ID(PID)。sig
参数是要发送的信号的编号。
我们可以自己写一个程序来向其他进程发信号
mysignal.cc
#include <iostream>
#include <cstdlib>
#include <cassert>
#include <unistd.h>
#include <cstring>
#include <sys/types.h>
#include <signal.h>
#include <string>
using namespace std;
void usage(string proc)
{
cout << "usage: \n";
cout << proc << " 信号编号 目标进程\n" << endl;
}
int main(int argc, char* argv[])
{
if(argc != 3)
{
usage(argv[0]);
exit(1);
}
int signo = stoi(argv[1]);
int id = stoi(argv[2]);
int n = kill(id, signo);
if(n != 0)
{
cout << errno << " :" << strerror(errno) << endl;
exit(2);
}
return 0;
}
raise
int raise(int sig);
给进程自己发任意信号,与kill
不同,kill
是可以向任意进程发任意信号,raise
就只能给自己发任意信号
abort
void abort(void);
这是一个c语言库函数,给进程自己发送指定的6号终止信号
abort
函数有点不同,它是c语言库函数,就算我们将6号信号改为执行我们自定义的方法,我们自定义的方法并没有退出,abort
函数还是会终止进程
软件条件产生信号
SIGPIPE
信号实际上就是一种由软件条件产生的信号,当进程在使用管道进行通信时,读端进程将读端关闭,而写端进程还在一直向管道写入数据,那么此时写端进程就会收到SIGPIPE
信号进而被操作系统终止。
alarm
unsigned int alarm(unsigned int seconds);
调用alarm
函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM
信号, 该信号的默认处理动作是终止当前进程。
alarm
函数在设置的时间结束后发送了SIGALRM
,14号信号
返回值为之前设置的闹钟的剩余时间
硬件异常产生信号
硬件异常是指在计算机系统的硬件层面上发生的异常情况
例如访问无效的内存地址、野指针、除以0、非法指令等。
当硬件异常发生时,操作系统会通过生成相应的信号来通知进程。
我们写一个除0的代码
程序可以正常被编译,只不过会有警告,运行之后程序崩溃了,浮点数异常
CPU中有状态寄存器,状态寄存器中包含了0,进位,溢出,符号,奇偶标志位
溢出标志位可以记录本次计算是否有溢出问题
除0就是一个溢出问题,如果发送了除0错误,溢出标志位会被置1
操作系统来检查发现CPU的溢出标志位为1,就是硬件异常,就会向进程发送SIGFPE
,8号信号
我们修改一下代码,让8号信号去执行我们的自定义方法
我们可以看到,我们没有退出进程,会一直执行我们写的自定义方法
因为我们的进程没有退出,执行了一次8号信号的自定义方法之后,进程会继续向下执行代码
执行之前操作系统会检查是否有硬件异常,而CPU的溢出标志位仍然为1,所以操作系统又识别到硬件异常,所以又向进程发送SIGFPE
,8号信号,一直这样循环
我们再来看看野指针错误发生的硬件异常
野指针问题引发的段错误
CPU中有个硬件MMU,操作系统是通过MMU这个硬件来查询页表的映射关系的,MMU报错,操作系统发现硬件异常,会向进程发送SIGSEGV
,11号信号
MMU(Memory Management Unit)是计算机系统中的一个硬件组件,用于管理内存的访问和映射。它负责将虚拟地址(在程序中使用的地址)转换为物理地址(内存中的实际地址)
我们的代码*a = 10;
这句代码运行的时候,首先第一步是通过虚拟地址找到物理地址
如果操作系统通过MMU没有找到虚拟地址到物理地址的这个映射关系,MMU硬件就会报错
如果找到了映射关系,然后就查页表中的这个映射关系的权限,如果没有写的权限,那么MMU硬件报错,有权限就写入成功
信号保存
man 7 signal
可以查询信号更详细的信息
在这里可以查询到这样的列表,其中Action
中有Term
和Core
,都是终止,但是有些不同,Term
只是终止了进程,而Core
会终止进程并且生成核心转储文件
- 核心转储(Core Dump):当一个程序发生崩溃或异常终止时,操作系统可以生成一个核心转储文件,记录程序在崩溃时的内存状态和执行堆栈信息。核心转储文件对于调试和分析程序崩溃非常有用,可以提供有关崩溃原因和上下文的重要信息。通过分析核心转储文件,开发人员可以了解程序在崩溃前的状态,并定位错误的源头。
- 终止(Term):终止是指一个程序的正常或异常结束。当一个程序完成其任务或因某种原因无法继续运行时,它可能会被终止。终止可以是正常的,例如程序运行完毕并顺利退出,或者是异常的,例如发生严重错误导致程序崩溃。终止可以由程序自身触发,也可以由操作系统或其他外部因素触发。
Core Dump核心转储
核心转储(Core Dump),也称为崩溃转储,是在计算机系统中发生严重错误或程序崩溃时生成的一种文件。它记录了程序在崩溃时的内存状态和执行堆栈信息,提供了诊断和调试错误的重要依据。
当一个程序发生崩溃或异常终止时,操作系统会捕获程序的当前内存状态,并将其保存为一个核心转储文件,在程序当前目录下形成一个core.pid
这样的二进制文件。这个文件包含了程序运行时的内存映像,包括堆、栈、寄存器状态以及其他相关的调试信息
核心转储文件对于调试和分析程序崩溃非常有用,它可以提供以下信息:
- 内存状态:核心转储文件记录了程序崩溃时的内存状态,包括堆和栈中的数据。这些信息可以帮助开发人员了解程序在崩溃前的运行状态,进而定位错误的原因。
- 执行堆栈:核心转储文件中包含了程序崩溃时的执行堆栈信息。通过分析执行堆栈,可以确定程序崩溃的位置和调用链,从而指导调试和修复错误。
- 变量和数据:核心转储文件还可以包含程序崩溃时的变量和数据的值。这些数据可以帮助开发人员理解程序崩溃的上下文,并有助于定位错误的来源。
使用核心转储文件进行调试时,可以使用调试器工具(如GDB)加载核心转储文件,并检查内存状态、执行堆栈以及变量的值,以便定位和修复错误。
我用的是云服务器,云服务器是默认关闭Core Dump这个功能的,首先我们先打开这个功能
ulimit -a
显示当前用户的资源限制信息
core file size
:核心转储文件的最大大小(以字节为单位)。如果该值为0,则表示禁用核心转储。data seg size
:数据段的最大大小(以字节为单位)。它限制了程序可以使用的堆和全局数据的大小。file size
:单个文件的最大大小(以字节为单位)。open files
:用户可同时打开的最大文件数。它限制了进程可以打开的文件数目。stack size
:栈的最大大小(以字节为单位)。它限制了进程可以使用的栈空间大小。cpu time
:CPU 时间限制,表示进程在用户模式下可以使用的最大 CPU 时间。max user processes
:用户可创建的最大进程数。max memory size
:进程可使用的最大内存大小(以字节为单位)。pipe size
:管道缓冲区的最大大小(以字节为单位)。max locked memory
:进程可以锁定的最大内存大小(以字节为单位)。
ulimit -c size
设置核心转储文件的大小
我们试试生成核心转储文件,一个正常运行的进程,如果收到了对应信号,那么也会生成核心转储文件,不是非要发生了错误异常,等操作系统发送信号才会生成核心转储文件
可以看到我们发送2号信号,并没有生成核心转储文件,因为2号信号是Term
,发送8号信号生成了核心转储文件,8号信号是Core
我们来试下用核心转储文件调试,我们编译生成的可执行文件默认是release模式的,我们要在编译的时候带上-g
选项,生成调试模式
gdb 可执行程序
进入调试模式,然后输入命令core-file 核心转储文件名
,就会直接跳转到程序出错的那一行,并且会说明报错的信号,报错的文件
ulimit -c 0
关闭生成核心转储文件
阻塞信号
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的被保存的状态,称为信号未决(Pending)。
- 进程可以选择阻塞 (Block )某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
- 阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是信号递达的一种处理动作。
-
pending表:位图结构。比特位的位置表示哪种信号,比特位的内容表示是否收到该信号,也就是是否未决
-
block表:位图结构,比特位的位置表示哪种信号,比特位的内容表示该信号是否被阻塞
-
handler表:函数指针数组,数字下表表示信号编号,数组的内容表示该信号的递达动作
SIG_DFL
,信号执行默认动作,实际上是把0强转成函数指针
SIG_IGN
,信号被忽略,实际上是把1强转成函数指针
信号集sigset_t
和信号集操作函数
sigset_t
是一个用于表示信号集的数据类型。它是一个用来存储一组信号的位图结构,可以用于管理和操作信号的状态,比如设置信号的阻塞或解除阻塞,以及检查信号是否在集合中。
sigemptyset(sigset_t *set)
:将信号集set
清空,即将所有位都设置为0,表示没有任何信号。sigfillset(sigset_t *set)
:将信号集set
填满,即将所有位都设置为1,表示包含所有信号。sigaddset(sigset_t *set, int signum)
:将信号signum
添加到信号集set
中,即将相应的位设置为1。sigdelset(sigset_t *set, int signum)
:从信号集set
中删除信号signum
,即将相应的位设置为0。sigismember(const sigset_t *set, int signum)
:检查信号signum
是否在信号集set
中,如果在则返回非零值,否则返回0。sigprocmask(int how, const sigset_t *set, sigset_t *oldset)
:用于管理进程的信号屏蔽字,可以阻塞或解除阻塞指定的信号集。how
参数指定操作类型,set
参数指定要设置的信号集,oldset
参数用于存储之前的信号集。sigpending(sigset_t *set)
:获取当前被阻塞的待处理信号集,在调用sigprocmask
函数设置了信号屏蔽字后,这些信号可能会被阻塞并排队等待处理
阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask)
sigprocmask
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
用于设置或修改进程的信号屏蔽字
-
how
参数指定了信号屏蔽字的操作类型,可以是以下三个值之一:SIG_BLOCK
:将 set 中的信号添加到当前信号屏蔽字中,即将相应的位设置为1。SIG_UNBLOCK
:从当前信号屏蔽字中移除set
中的信号,即将相应的位设置为0。SIG_SETMASK
:将当前信号屏蔽字替换为set
中的值。
-
set
参数指定了要设置的信号屏蔽字,它是一个指向sigset_t
结构的指针。
根据how
参数的不同,set
可以是一个新的信号屏蔽字,或者是一个包含要添加或移除的信号的信号集。 -
oldset
参数是一个指向sigset_t
结构的指针,是输出形参数,用于存储之前的信号屏蔽字。
如果oldset
不是NULL
,则sigprocmask
函数会将之前的信号屏蔽字存储在oldset
所指向的结构中。
#include <iostream>
#include <signal.h>
#include <cassert>
#include <unistd.h>
using namespace std;
void printpending(const sigset_t &pending)
{
cout << "pending位图";
for(int signo = 1; signo <= 31; signo++)
{
if(sigismember(&pending, signo))
cout << "1";
else
cout << "0";
}
cout << endl;
}
int main()
{
cout << "pid:" << getpid() << endl;
//1.屏蔽2号信号
sigset_t set, oset;
//1.1初始化
sigemptyset(&set);
sigemptyset(&oset);
//1.2将号信号添加到set中
sigaddset(&set, 2);
//1.3将新的信号屏蔽字设置到进程
sigprocmask(SIG_BLOCK, &set, &oset);
//2.不断获取进程的pending信号集,一直打印
while(1)
{
//2.1获取pending信号集
sigset_t pending;
sigemptyset(&pending);
int n = sigpending(&pending);
assert(n == 0);
(void)n;//防止出现警告
//2.2打印pending信号集
printpending(pending);
sleep(1);
}
return 0;
}
我们对2号信号阻塞,现在进程收到2号信号,确实存进了pending表中,并且2号信号没有被递达
我们修改一下代码,让2号信号执行我们自定义函数并且在阻塞2号信号10s后解除阻塞
可以看到,收到2号信号之后,pending位图对应2号信号的位置由0变1,到达10s后2号信号被解除屏蔽,解除屏蔽后信号会立马被递达,2号信号执行完我们自定义的方法之后,pending位图对应的位置由1变0,如果执行的是默认方法,进程会被终止
信号的处理
进程收到信号之后,可能并不会立即被处理,要到合适的时候处理
信号被立即处理的一种情况:如果信号之前被block阻塞了,当被解除阻塞的时候就会立即递达
信号的产生是异步的,当进程从内核态回到用户态的时候,进程会在OS的指挥下,进行信号的检测和处理
用户态:执行用户写的代码时,进程所处的状态
内核态:执行OS的代码的时候,进程所处的状态
用户态切换到内核态通常是系统调用,中断和异常处理等
每一个进程的地址空间中都分为用户空间和内核空间,内核空间存放着操作系统的代码和数据结构,内核空间在地址空间的高地址部分,只能由操作系统内核访问
进程的程序地址空间中有两张页表,一张用户级页表,让用户在物理内存中找到自己的代码,一张内核级页表,让操作系统找到自己的代码
操作系统的代码是不变的,用户的代码每个都是不同的,所以用户级页表每个进程都有一张,内核级页表只有一张,所有进程都是看到同一张内核页表
所以,所谓的系统调用的本质就是调用函数并返回
那操作系统的代码就在进程的地址空间中,进程就可以随意的访问操作系统的代码和数据吗?
不可以的,用户要访问操作系统的代码和数据就要由用户态切换到内核态,CPU中有一个CR3寄存器就存放着标志着用户态和内核态等的标志位,要切换到内核态就要修改CPU中CR3寄存器,而修改这个寄存器用户态是无法修改的,只有当调用了系统调用接口,这个时候操作系统就会先将修改CPU中CR3寄存器切换到内核态,然后再执行操作系统的代码
捕捉信号sigaction
前文我们一直都用的signal
对信号进行捕捉
signal
函数在处理信号时是一种较为底层的方式,更高级的信号处理方式可以使用sigaction
函数
sigaction
函数提供了更多的灵活性和可移植性,可以更好地处理信号
int sigaction(int signum, const struct sigaction *act,struct sigaction *oldact);
signum
:指定要设置处理方式的信号编号。act
:指向struct sigaction
结构的指针,用于设置新的信号处理方式。oldact
:指向struct sigaction
结构的指针,用于存储旧的信号处理方式。
struct sigaction {
void (*sa_handler)(int); // 处理函数的指针
void (*sa_sigaction)(int, siginfo_t *, void *); // 扩展处理函数的指针
sigset_t sa_mask; // 阻塞信号的集合
int sa_flags; // 信号标志
void (*sa_restorer)(void); // 已弃用,忽略即可
};
sa_handler
:指定一个函数,用于处理信号。当信号到达时,操作系统会调用这个函数来处理信号。sa_mask
:指定一个信号屏蔽集合,可以自定义阻塞其他信号的传递,直到当前信号处理完成,处理完成后会解除屏蔽。当前信号被处理时,当前信号会自动被屏蔽。sa_flags
:用于设置信号的标志,可以控制信号的行为,例如SA_RESTART
可以使被信号中断的系统调用自动恢复执行。
使用sigaction
方法对信号进行捕捉
volatile
我们来看看volatile
的用法
我们写的代码是捕捉2号信号,程序收到2号信号之后更改全局变量quit的值,然后循环条件不满足退出死循环
编译器有0,1,2,3等的优化级别,我们试着让编译器优化我们的代码
编译选项我们加了-O2,提高了编译器优化级别,现在发现我们程序在收到2号信号的时候并没有退出死循环,难道全局变量quit并没有被修改吗?quit确实被修改了
我们的进程的变量都要被加载到内存里,所以quit是在内存里的,
们while语句!quit,是一种计算,是要在CPU里计算的,所以quit要从内存加载到CPU的寄存器里
然后进行CPU进行计算,判断while循环是否成立
所以每一次判断while循环都要将quit从内存加载到CPU的寄存器中,quit是高频被不断判断的
而编译器优化就是发现我们的quit是不会被修改的,又是高频使用的,所以编译器就修改了我们的代码,省去了quit不断的从内存加载到CPU的寄存器这一步
quit只会从内存加载到CPU的寄存器一次,之后就一直拿CPU的寄存器的quit值去判断,所以编译器优化之后,我们内存中的quit确实被修改了,但是while循环判断的quit值确一直拿修改前的quit值去判断,所以死循环不会退出
编译器优化实际上就是修改我们的代码
上述这种情况叫做内存位置不可见,而volatile
的功能就是让编译器不要用在CPU寄存器中老的数据,要用新的,从内存中加载到CPU寄存器中的新的数据,volatile
就是确保变量可见性,禁止编译器优化
我们加上volatile
关键字,现在就是正常退出while循环了
SIGCHILD
SIGCHILD
,17号信号
之前在进程等待时,父进程都是阻塞式和非阻塞式等待子进程,这都是父进程主动的检测,这是之前我们不知道子进程有没有退出,只能阻塞式一直等待或轮询式等待
子进程退出其实是会向父进程会发送SIGCHILD
,17号信号的,只是这个信号默认动作就是忽略,什么都不做
#include <iostream>
#include <signal.h>
#include <cstdlib>
#include <unistd.h>
using namespace std;
void handler(int signo)
{
cout << "收到的信号:" << signo << "pid:"<< getpid() << endl;
}
int main()
{
signal(17, handler);
pid_t id = fork();
if(id == 0)
{
int cnt = 5;
while(cnt > 0)
{
printf("我是子进程, 我的pid: %d,ppid: %d\n", getpid(), getppid());
sleep(1);
cnt--;
}
exit(1);
}
while(true)
{
sleep(1);
}
return 0;
}
对照pid可以看到,父进程确实收到了子进程在退出时发送的SIGCHILD
,17号信号
#include <iostream>
#include <signal.h>
#include <cstdlib>
#include <unistd.h>
#include <sys/wait.h>
using namespace std;
pid_t id;
void handler(int signo)
{
cout << "收到的信号:" << signo << "pid:"<< getpid() << endl;
sleep(5);//让子进程僵尸5秒
pid_t res = waitpid(-1, NULL, 0);
if(res > 0)
{
printf("等待成功, res: %d, id: %d\n", res, id);//id是子进程的pid,res也是子进程的pid
}
}
int main()
{
signal(17, handler);
id = fork();
if(id == 0)
{
int cnt = 5;
while(cnt > 0)
{
printf("我是子进程, 我的pid: %d,ppid: %d\n", getpid(), getppid());
sleep(1);
cnt--;
}
exit(1);
}
while(true)
{
sleep(1);
}
return 0;
}
这样就可以让父进程运行自己的代码,然后子进程退出,发送信号了,父进程再去回收子进程
也可以不产生僵尸进程,直接让操作系统回收子进程,不用通知父进程,只需要
signal(SIGCHILD, SIG_IGN);
,SIG_IGN
在这里是特例,并不是忽略,而是自动清理子进程,不产生僵尸进程,此方法只保证在Linux中有效