信号
什么是Linux信号
信号是进程之间事件异步通知的一种方式,属于软中断。
是一种通信机制,用户or操作系统通过发送一定的信号,通知进程某些事件已经发生,进程可以进行处理。
a.进程要处理信号,必须具备信号“识别”的能力(看到+处理动作)
b.信号产生是随机的,进程可能正在忙自己的事,所以信号的后续处理不是立即处理的
c.信号会临时记录下对应的信号,方便后续处理
d.什么时候处理信号?合适的时候
信号的声明周期
信号的产生
当按下键盘的ctrl+c后,os识别到组合键,然后解释组合键的意思(在os内编码已经编好了,一旦用户按了对应的组合键,就得到了从键盘输入的命令想要执行的什么动作),os查找进程列表,找到在前台运行的进程,找到后os写入对应的信号到进程内部的位图结构中(至此完成信号的发送)
信号处理的常见方式
1.默认
2.忽略
3.自定义动作(捕捉信号)
普通信号有32个,一般用位图保存,如(前面还有24个0)0000 0100,这里第3位是1,就代表是第3号信号,如果该位是1,就代表该号信号有产生
发信号实际上是修改位图上相应比特位,把0改为1,就是发送了相关信号
进程PCB内部保存了代表信号的位图字段,信号位图由进程pcb维护,而PCB是内核数据结构,只有os有发送信号
信号发送的本质:os向目标进程写信好,os直接修改pcb中的指定位图结构,完成“发送”信号的过程
信号注册函数signal函数的理解
信号捕捉是指注册一个信号处理函数来响应特定信号的过程。
在Linux系统中,当进程接收到一个信号时,它可以做出几种响应:执行默认操作(例如,终止进程)、忽略信号或执行用户定义的信号处理函数。这里的“捕捉”一词指的是通过指定一个用户自定义的函数来处理特定的信号。
请先看这段代码,简单的使用signal函数
//编写了一个简单的函数catchSig
void catchSig(int signum)
{
cout << "进程捕捉到一个信号,正在处理中: " << signum << "Pid: " << getpid() << endl;
}
int main()
{
signal(SIGINT, catchSig); // 第一个参数是信号可以写对应的宏英文名,第二个参数是方法
// signal(2,fun);//第一个参数可以写对应的信号编号数字
//设置死循环,等待信号,如果不设置,且一直不发送信号的话,会直接结束
while (true)
{
cout << "我是一个进程,我在正在运行...,Pid: " << getpid() << endl;
sleep(1);
}
return 0;
}
由上至下理解signal函数
1.signal函数主要用于设置信号处理函数,即当某个特定信号到达时,程序会执行预先指定的函数。
这句话也有另外一层意思,当没有特定信号产生/到达,程序不会执行预定函数
程序一开始运行到signal函数这行代码,只是注册了一个信号函数
两个参数:
signal
函数有两个参数,第一个参数signum
是整型,表示要处理的信号的编号;第二个参数handler
是一个函数指针,指向当信号发生时要调用的处理函数。
采用回调的方式设置一个函数指针,说白了就是把函数地址传给signal函数,然后这个函数就和对应的信号关联起来
2.signal两个参数的相互起的关联
当程序运行到signal这行代码的时候,os会自动将第一个参数SIGINT与第二个函数指针类型参数通过某种方法关联起来。那么如果我一直不按ctrl c,岂不是不会触发信号?
答案:
signal
函数在被调用时会将指定的信号处理函数与特定信号关联起来。如果一直不按Ctrl+C,确实不会触发信号处理函数。
当程序运行到signal
这行代码时,操作系统会把SIGINT信号和signalHandler
函数通过某种方式关联起来,这个过程涉及到用户态和内核态的交互。signal
函数最终会调用系统调用__NR_rt_sigaction
,进入内核态后,内核会将注册的函数保存在task_struct
的成员sighand
中。
当按下Ctrl+C时,会产生一个SIGINT信号,操作系统会检查是否有信号处理函数与SIGINT相关联。如果有,就会调用该信号处理函数。如果没有,程序会按照默认行为来处理这个信号,通常是终止程序。
signal只是一个注册函数,用于设置当特定信号发生时应该调用哪个信号处理函数。
当signal
函数被调用时,它会将指定的信号处理函数与特定信号关联起来(注意,只是关联起来,这时候还没调用相关处理函数,也就是第二个参数指向的函数)。如果有信号产生,该信号在程序运行过程中被接收到时,操作系统会自动调用相应的信号处理函数(指第二个参数–函数),并将信号编号作为参数传递给它。
因此,当用户按下Ctrl+C键发送中断信号(SIGINT)时,操作系统会检查是否有信号处理函数与SIGINT相关联。如果存在这样的关联,操作系统会自动调用相应的信号处理函数,并将SIGINT作为参数传递给它。
3.当有信号产生时,SIGINT的值会被os自动传给要调用的信号处理函数catchSig
signal(SIGINT, signalHandler);在这行代码中,signalHandler怎么知道传入它的int signum(信号编号)是什么?好像没有看到signum作为参数显式的传入signalHandler函数?
答案:
在signal
函数中,第二个参数是一个指向信号处理函数的指针。当某个信号发生时,操作系统会自动调用该函数,并将信号编号作为参数传递给它。
在上面的示例中,signalHandler
函数只有一个参数int signum
,这个参数就是信号编号。当用户按下Ctrl+C键发送中断信号(SIGINT)时,操作系统会调用signalHandler
函数,并将SIGINT作为参数传递给它。因此,在signalHandler
函数中,我们可以使用signum
参数来获取信号编号,并进行相应的处理。
有人可能回想,假如我自定义的信号处理函数有多个参数传入呢?会报错
void catchSig(int signum,int x,char c)//自定义的信号处理函数传入多个参数
{
cout << "进程捕捉到一个信号,正在处理中: " << signum << "Pid: " << getpid() << endl;
}
int main()
{
signal(SIGINT, catchSig);
return 0;
}
上面这段代码会报错,提示带有多个参数的catchSig不符合_sighandler_t的类型
这又引出新的问题,_sighandler_t 这个类型是什么?
答案:
在C/C++中,__sighandler_t
是一个函数指针类型,它用于声明信号处理函数。该类型定义在signal.h
头文件中,通常与特定的操作系统和编译器相关联。
当使用signal
函数注册信号处理函数时,第二个参数需要是__sighandler_t
类型的函数指针。这意味着信号处理函数必须具有特定的原型,即接受一个整数参数(表示信号编号)并返回void。
在上图代码中,catchSig
函数的原型不符合__sighandler_t
的要求,因为它有三个参数(int signum, int x, char c
),而不仅仅是一个整数参数。这就导致了编译错误。
核心转储
####core dump
Core Dump
首先解释什么是Core Dump。当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部 保存到磁盘上,文件名通常是core,这叫做Core Dump。
进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。
一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存 在PCB中)。默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。
首先用ulimit命令改变Shell进程的Resource Limit,允许core文件最大为1024K:
一般的云服务器核心转储是关闭的,虚拟机的核心转储是没有关闭的
如何查看当前服务器的核心转储有没有被关闭
ulimit -a //查看当前服务器的资源配置
可以看到云服务器的core file size 是0,是默认被关闭的
如果想打开云服务器的core 机制,该怎么打开?使用命令
ulimit -c 10420
这里的-c是对应的上面core fiel size 的选项,10240是你要设置的大小,这时候再查看就能看到core file被设置为了10240大小
不过当你把这个会话关闭后,又恢复原样了
当文件出现某种异常,os会依据core dump标志位是否被设置,将当前进程在内存的相关核心数据是否转存到磁盘中
如果core dump标志位被设置为1,那么当进程出现异常的时候,操作系统会将当前进程在内存中的相关核心数据转存到磁盘中(这个转存的文件就是core.file,所以core.file就是程序出现异常的时候在你当前的目录下生成的,当然前提是core dump位被设置),目的主要是为了调试
如果该信号的core dump被设置为0,那么进程出现异常发送对应的信号时,不会发生核心转储
在多个信号编号中,信号的Action标志是core,代表着会发生核心转储(当然也会终止),Term是终止,但不会发生核心转储
是否记得在进程等待文章讲过的waitpid函数
waitpid传入的status参数,我们说过,信号编号是这个status的低7位,而status第8位表示是core dump位,0或者1表示是否可以发送核心转储
如果是core dump标志位是1,当进程出现某种异常的时候,OS会将当前进程在内存中的相关核心数据,转存到磁盘中。
如果向进程发送的信号它的Action标志位是core,那么你当前的目录下会形成core.file文件,这个文件在gdb调试阶段加载进去,会提示是什么错误导致进程终止的
上面所述的都是核心转储打开的情况,如果用命令把核心转储关闭了,不管action标志是不是core,都不会发生核心转储
ulimit -c 0 关闭核心转储
通过上面的例子可以看出
当进程出现异常的时候回向os发送信号,这个信号是可以被捕捉的
除了键盘组合键发送信号,也可以系统调用接口发送信号
产生信号的方式
通过按键组合键产生信号
1.系统调用接口发送信号
1.1命令行使用kill
用法
首先在后台执行死循环程序,知道它的进程号,然后用kill命令给它发SIGSEGV信号。
4568是test进程的id。之所以要再次回车才显示 Segmentation fault ,是因为在4568进程终止掉之前已经回到了Shell提示符等待用户输入下一条命令,Shell不希望Segmentation fault信息和用 户的输入交错在一起,所以等用户输入命令之后才显示。
指定发送某种信号的kill命令可以有多种写法,上面的命令还可以写成 kill -SIGSEGV 4568 或 kill -11 4568 , 11是信号SIGSEGV的编号。以往遇 到的段错误都是由非法内存访问产生的,而这个程序本身没错,给它发SIGSEGV也能产生段错误。
kill命令是调用kill函数实现的。kill函数可以给一个指定的进程发送指定的信号。raise函数可以给当前进程发送指定
的信号(自己给自己发信号)
1.2Kill接口(代码层面)
第一个参数pid是进程号,第二个sig参数是要发送的信号,可以看出kill是向pid号进程发送信号
1.2raise接口
raise是向自己发送指定的信号
1.3abort接口
给自己发送一个确定的abort(中止的意思)信号,就是自己终止自己,如6号信号。
可以理解成底层就是调用raise(6),然后raise(6)底层又调用kill(getpid,6)
abort通常用来终止进程,就像exit函数一样,abort函数总是会成功的,所以没有返回值。
如何理解系统调用接口
用户调用系统接口 -> 执行os对应的系统调用代码 -> os提取参数(如获取进程的pid、信号),或者设置特定的数值 -> os再向目标进程写信号,即向目标进程的对应信号比特位(就是上文提到的位段)修改为1 -> 目标进程收到信号会处理信号,执行对应的处理动作
2.由软件条件产生信号
在讲管道的时候,读端不读,而且还关闭了管道,但是写端一直在写,会发生什么。
这时候os发现写端再写也没有意义,os会自动终止写端进程,通过发送SIGPIPE(13号)信号
验证方法:
1.创建匿名管道,让父进程读取,子进程写入(如果是子进程读而父进程写,会让父进程终止,子进程称为孤儿进程)
2.父进程通信一段事件,然后让父进程关闭读端,调用waitpid,子进程一直写入即可
3.子进程退出,父进程通过waitpid拿到status,然后提取信号即可
管道是内存级别的文件,是软件,os识别到管道没有人读了这种场景下,我们称之为软件条件不满足,此时os会向目标进程发送SIGPIPE信号
SIGPIPE是一种由软件条件产生的信号,在“管道”中已经详细介绍过了。本节主要介绍alarm函数 和SIGALRM信号
2.1由闹钟函数发送信号
alarm函数可以设定一个个闹钟,也就是告诉内核在second秒之后给进程发送SIGALRM信号,该信号的默认处理动作是终止当前进程
这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。
打个比方,某人要小睡一觉,设定闹钟为30分钟之后响,20分钟后被人吵醒了,还想多睡一会儿,于是重新设定闹钟为15分钟之后响,“以前设定的闹钟时间还余下的时间”就是10分钟。如果seconds值为0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数。
int main()
{
int count = 1;
alarm(1);//设定1秒,1秒过完向进程发送闹钟信号
for (; 1; count++)
{
cout << "count = " << count << endl;
}
}
设定一个闹钟,只会触发一次,触发后被移除
如果想让进程周期性的去做某件事,可以这样做,每当前一个闹钟被触发然后移除,在catchSig函数里就再设置一个闹钟
// sig
void _catchSig(int signum)
{
cout<<"捕捉到信号:"<<signum<<endl;
alarm(1);//每次捕捉完就再设一个闹钟
}
int main()
{
signal(SIGALRM,_catchSig);
alarm(1);
}
总结,怎么理解软条件给进程发送信号
a.OS先识别到某种软件条件触发或者不满足
b.OS构建信号,发送给指定的进程
3.硬件异常产生信号
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释 为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。
3.1除0错误
cpu内部在计算的时候,状态寄存器(单纯的用来保存本次计算的状态,当成位图数据结构–计算的结果是否对、有没有进位、有没有出现溢出),状态寄存器又有对应的状态标志位,当计算完成后,os在把结果写回内存,检测该状态标志位(如溢出标志位),如果为0,直接把结果写回内存,如果为1(出现硬件异常),os立马意识到除0了,进程退出。硬件和软件通过寄存器耦合
寄存器中的异常一般都没办法解决,只能终止进程,让进程退出,资源释放
3.2野指针或者越界问题(在Linux中一般称为段错误)
越界产生的错误都是段错误(11号信号)
int *p = nullptr;
*p=100;
野指针或者越界访问的底层理解:
野指针或者越界访问都必须通过地址找到目标,,这个地址都是虚拟地址(逻辑地址)。
当我们对空间访问时,首先将逻辑地址转换为物理地址,这样就知道要访问的物理空间在哪。
通过页表和MMU(Memory Manager Unit,内存管理单元的意思,是一个硬件)的方式将虚拟地址转换为物理地址.
有了上面的理解,野指针和越界访问的时候,一定是访问一个非法的地址,这个地址在mmu(mmu里等常见的设备也有寄存器)转换成物理地址的时候就一定会报错,os会立马知道是哪个进程mmu转换报错(页表用的是哪个进程的,就是哪个进程的mmu报错)。
os把mmu报错转换成对应的信号发送给对应的进程,然后进程才会退出。
总结思考一下
所有的信号都有它的来源,但最终全部都会被OS识别、解释、并发送的。
1.上面所说的所有信号产生,最终都要有OS来进行执行,为什么?OS是进程的管理者。
2.信号的处理是否是立即处理的?在合适的时候处理(什么时候算是合适的时候在信号保存章讲)。
3.信号如果不是被立即处理,那么信号是否需要暂时被进程记录下来?记录在哪里最合适呢?
4.一个进程在没有收到信号的时候,能否能知道,自己应该对合法信号作何处理呢?
5.如何理解OS向进程发送信号?能否描述一下完整的发送处理过程?