【Linux】信号

发布于:2022-11-09 ⋅ 阅读:(17) ⋅ 点赞:(0) ⋅ 评论:(0)

1.信号基础

信号是进程之间事件异步通知的一种方式,属于软中断。

信号机制

信号的特质:由于信号是通过软件方法实现,其实现手段导致信号有很强的延时性。但对于用户来说,这个延迟时间非常短,不易察觉。

信号是给进程发送的,进程具备处理信号的能力,接收信号的能力。识别信号的能力。

#include<iostream>
#include<unistd.h>
using namespace std;
int main(){
    int cnt=0;
    while(1){
        cout<<"cnt:"<<cnt<<endl;
        cnt++;
        sleep(1);
    }
    return 0;
}

image-20221107104609206

我们按Ctrl+\也可以终止程序。Ctrl+C和Ctrl+\的区别:Ctrl+C发送的是2号信号SIGINT,Ctrl+\发送是3号信号SIGOUT。

image-20221107104951633

  • 前31个信号是常用信号,后面的信号属于实时信号,常用于嵌入式开发中

  • 其中比较特殊的是SIGKILL和SIGSTOP信号不能被捕捉、忽略、阻塞。分别表示杀死信号和暂停信号。

常见信号的处理方式

image-20221107112044529

1.1信号的原理

信号的异步机制:信号是随时随地可以产生的,当信号产生时,进程可能还在做更重要的事情,进程可以暂时不处理这个信号,等事情处理完在处理信号。

结论:

  • 进程可以暂时不处理信号
  • 进程需要记住需要处理什么信号,并且按照相应的动作处理信号。1.默认动作【系统默认】2.忽略信号 3.自定义动作。

进程任何记住信号,在哪里记住信号。

在每一个进程的PCB中,存在一个位图,记录该进程有那些信号需要处理。

struct task_struct{
    uint32- t sig; //一个位图,比特位的位置记录是了什么信号,比特位的值记录有没有该信号。
}

OS是进程的管理者,PCB的数据需要OS进行修改。

2.信号的产生

SIGINT的默认处理动作是终止进程,SIGQUIT的默认处理动作是终止进程并且Core Dump。

2.1signal函数

上面我们提到,进程对一个信号有三种处理方式。而signal函数可用于在该进程中捕捉一个信号,并注册该信号的处理方式。

#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);

参数:
    signum:注册的信号编号
    handler:一个函数指针,注册信号的处理方式
 
返回值:
    返回函数指针,返回上一次处理该信号的方式

下面我们修改信号SIGINT的处理方式

#include<iostream>
#include<unistd.h>
#include <signal.h>
using namespace std;
void handler(int signo){
    cout<<"捕捉信号sign:"<<signo<<endl;
}
int main(){
    signal(SIGINT,handler);
    sleep(3);
    cout<<"进程设置完:"<<endl;
    int cnt=0;
    while(1){
        cout<<"pid:"<<getpid()<<" "<<"cnt:"<<cnt<<endl;
        cnt++;
        sleep(1);
    }
    return 0;
}

image-20221107114304422

2.2kill指令和函数

kill -signal pid	#表示向pid进程发送信号signal

kill接口

#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);

用法和kill指令一样,pid是目标进程,sig是发送的信号编号

实现一个mykill指令,格式为:mykill signo pid

using namespace std;
void Useag(){
    cout<<"mykill signo pid"<<endl;
}
int main(int argc,char* argv[]){
    if(argc!=3){
        Useag();
    }
    int sign=atoi(argv[1]);
    int pid=atoi(argv[2]);
    if(kill(static_cast<pid_t>(atoi(argv[2])), atoi(argv[1])) == -1)
    {
        cerr << "kill: " << strerror(errno) << endl;
        exit(2);
    }
    return 0;
}

image-20221107125032895

2.3信号产生的方式

2.3.1键盘产生

键盘参数----> 参数硬件中断----> OS识别中断-----> 发送信号

#include<iostream>
#include<unistd.h>
using namespace std;
int main(){
    int cnt=0;
    while(1){
        cout<<"cnt:"<<cnt<<endl;
        cnt++;
        sleep(1);
    }
    return 0;
}

上面介绍过,可以Ctrl+C或者Ctrl+\终止进程。

2.3.2由系统向进程发送信号

当我们要使用kill命令向一个进程发送信号时,我们可以以kill -信号名 进程ID的形式进行发送。

int main(){
    cout<<"进程设置完:"<<endl;
    int cnt=0;
    while(1){
        cout<<"pid:"<<getpid()<<" "<<"cnt:"<<cnt<<endl;
        cnt++;
        sleep(1);
    }
    return 0;
}

image-20221107120023274

raise()函数:向该进程发送信号

#include <signal.h>
int raise(int sig);

示例:

int main()
{
    printf("I will die\n");
    sleep(2);
    raise(SIGSEGV);
    sleep(3);
    return 0;
}

image-20221107120727901

abort()函数:给自己发送异常终止信号 6) SIGABRT 信号,终止并产生core文件

 void abort(void); 该函数无返回

2.4由软件条件产生信号

2.4.1alarm函数
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
参数:
    seconds:过多少秒发送一个SIGALRM信号
 
返回值:
    返回上一个alarm函数还剩下多少时间发送信号

调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理
动作是终止当前进程。进程收到信号SIGALRM,默认动作为终止进程。

int main()
{
    int ret=alarm(7);
    printf("alarm返回值:%4d\n",ret);
    sleep(2);
    ret=alarm(4);
    printf("alarm返回值:%4d\n",ret);
    int cnt=0;
    while(1)
    {
        printf("cnt:%4d\n",cnt++);
        sleep(1);
    }
    return 0;
}

image-20221107131218551

2.2.4setitimer函数

设置定时器,可以替代alarm函数。

int setitimer(int which, const struct itimerval *new_value,struct itimerval *old_value);
/*
参数说明:
	which有三个宏,可以指定定时的方式
	1.自然对数:ITIMER_REAL 	函数调用发送信号 14)SIGALRM
	2.虚拟空间计时(用户空间):ITIMER_VIRTUAL 	函数调用发送信号 26)SIGVTALRM
	3.运行时计时(用户+内核):ITIMER_PROF		函数调用发送信号 27)SIGPROF
	
	new_value:该计时器的信息
	old_value:传出型参数,可以得到上一个计时器的信息
*/

image-20220730165725142

struct itimerval类型嵌入了两个struct timeval结构体。it_interval表示周期性的值,每多少时间为一个周期发送信号。it_value是设置第一次发送信号的时间。

stuct timeval中的 tv_sec表示秒,tv_usec表示微秒

模拟实现alarm

using namespace std;
int myalarm(int second){
    struct itimerval old_value,new_value={{0,0},{0,0}};
    new_value.it_value.tv_sec=second;
    setitimer(ITIMER_REAL,&new_value,&old_value);
    return old_value.it_value.tv_sec;
}
int main(){
    int ret1=myalarm(7);
    printf("第一次返回值:%d\n",ret1);
    sleep(2);
    int ret2=myalarm(5);
    printf("第二次返回值:%d\n",ret2);
    int cnt=0;
    while(1){
        cout<<"cnt:"<<cnt<<endl;
        sleep(1);
        cnt++;
    }
    return 0;
}

image-20221107133057535

2.2.5IO优化

程序实际执行时间(real)=系统时间(sys)+用户时间(user)+等待时间。我们可以观察一秒钟cnt++的次数进行判断。

假如输出到标准输出中:

int main(){
    int cnt=0;
    alarm(1);
    while(1){
        cout<<"cnt:"<<cnt<<endl;
        cnt++;
    }
    return 0;
}

image-20221107133443198

不进行IO:

int cnt=0;
void handler(int signo){	//捕捉信号
    cout<<"cnt:"<<cnt<<endl;
    exit(0);
}
int main(){
    signal(SIGALRM,handler);
    alarm(1);
    while(1){
        cnt++;
    }
    return 0;
}

image-20221107201543767

可以看到,加法的执行次数达到了5亿多次。这样表明,计算机CPU与外设进行IO交互时,速度非常慢。

2.5由硬件异常产生信号

C/C++程序进程崩溃的本质是,该进程收到了异常信息。比如发生了段错误,野指针,除0错误等。

image-20221107203355907

原理:

  • 除0错误:CPU内部,有一组寄存器叫做状态寄存器,标记本次计算的状态。当进程发生除0等计算错误时,CPU内部的状态寄存器中的标志位会修改为错误。
  • 越界/野指针:程序使用的是虚拟地址,当进程运行时,虚拟地址会通过MMU和页表的作用,转换为真实的物理地址,读取对应的数据;如果虚拟地址有问题,那么在转化的过程中,MMU就会发现问题并发生硬件中断,产生信号。

2.6code dump

code dump机制:又叫做核心转储机制。当程序异常退出时,会把进程运行过程中对应的异常上下文数据code dump到磁盘上。并产生一个code.pid文件。【在云服务器上,该选项常常是关闭的】

image-20220814174325445

ulimit -a #查看OS配置信息

image-20221107204627391

修改code的大小。

image-20221107204732511

int main(){
    pid_t pid=fork();
    if(pid==0){
        int *p=nullptr;
        *p=1;
        exit(1);
    }
    int status=0;
    waitpid(pid,&status,0);
    printf("exitcode:%d,signo:%d,code%d\n",(status>>8)&0xFF,status&0x7F,(status>>7)&0x1);
    return 0;
}

image-20221107205459245

生成了对应的code文件

image-20221107205527704

2.7前台进程和后台进程

./proc&		#可以将进程proc变成一个后台进程

jobs		#可以查看当前的后台进程

fg	工作编号	#将对应的编号变成变为前台进程

bg	工作编号	#变为后台进程

3.阻塞信号

3.1信号其他相关常见概念

  • 信号递达:实际执行信号的处理动作
  • 信号未决(Pending) :信号从产生到递达之间的状态
  • 信号阻塞(Block):进程可以选择阻塞(Block)某个信号。
  • 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.

阻塞和忽略的区别:

  • 忽略:信号已经递达,处理动作是什么都不做
  • 阻塞:信号未递达,进程等待信号。

3.2内核中的信号表现

image-20221107211157005

block和pending是一个位图,分别表示阻塞信号集(也叫信号屏蔽字)和未决信号集;handler表示一个函数指针数组,每个元素存放下标对应信号的信号处理方式

  • 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志 。
  • 如果信号被阻塞,那么阻塞信号集对应的比特位设置为1;在没有解除阻塞之前不能忽略这个信号 。

如果一个信号被传递多次:

  • POSIX:允许系统递送该信号多次
  • Linux:常规信号在递达前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列中。

3.3信号集函数

#include <signal.h>
int sigemptyset(sigset_t *set);	

int sigfillset(sigset_t *set);

int sigaddset(sigset_t *set, int signum);

int sigdelset(sigset_t *set, int signum);

int sigismember(const sigset_t *set, int signum);  

函数用法:

  • sigemptyset函数:初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。
  • sigfillset函数:初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。
  • sigaddset函数:在set所指向的信号集中添加某种有效信号。
  • sigdelset函数:在set所指向的信号集中删除某种有效信号。
  • sigismember函数:判断在set所指向的信号集中是否包含某种信号,若包含则返回1,不包含则返回0,调用失败返回-1。

3.4sigprocmask函数

用来屏蔽信号、解除屏蔽也使用该函数。其本质,读取或修改进程的信号屏蔽字(PCB中)

int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

成功:0;失败:-1,设置errno
参数:
	1.set:传入参数,是一个位图,set中哪位置1,就表示当前进程屏蔽哪个信号。
	2.oldset:传出参数,保存旧的信号屏蔽集。

参数how

选项 作用
SIG_BLOCK 当how设置为此值,set表示需要屏蔽的信号。相当于 mask = mask
SIG_UNBLOCK how设置为此,set表示需要解除屏蔽的信号。相当于 mask = mask & ~set
SIG_SETMASK 当how设置为此,set表示用于替代原始屏蔽集的新屏蔽集。相当于 mask = set,调用sigprocmask解除了对当前若干个信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。

3.5sigpending函数

读取当前进程的未决信号集

int sigpending(sigset_t *set);

set:传出参数    

打印未决信号集

int main(){
    sigset_t pend,sigproc;
    sigemptyset(&pend);
    sigemptyset(&sigproc);
    //设置阻塞信号集
    sigaddset(&sigproc,SIGINT);
    sigaddset(&sigproc,SIGQUIT);
    sigaddset(&sigproc,SIGABRT);
    sigprocmask(SIG_BLOCK,&sigproc,nullptr);
    while (1)
    {
        sigpending(&pend);
        for(int i=0;i<32;i++){
            if(sigismember(&pend,i)==1){
                std::cout<<1<<" ";
            }
            else{
                std::cout<<0<<" ";
            }
        }
        std::cout<<"pid:"<<getpid()<<std::endl;
        sleep(1);
        std::cout<<"\n";
    }
    return 0;
}

image-20221107215548122

4.理解信号捕捉

进程处理信号,不是立即处理,而是需要在合适的时间处理。

当当前进程从内核态,切换回到用户态的时候,该进程会处理信号。

4.1内核空间和用户空间

image-20221107222713661

  • OS中的所有进程的内核区共享一个内核物理地址
  • 每个进程的用户区都有自己的用户物理地址

用户态下,进程只能访问用户级页表;内核态下,进程才有权限访问系统级页表。

4.2内核态和用户态

当进程变为用户态时,进程才有权限访问内核的页表和数据结构。

CPU内部有对应的状态寄存器CR3,标志位为0时对应内核态,3对应用户态。

什么情况下,从用户态转变为内核态?

  • 系统调用,进程调用系统调用时,会陷入内核中。
  • 时间片到了,进行进程间切换。
  • 异常、中断、陷阱等处理完毕。

其中,由用户态切换为内核态我们称之为陷入内核。每当我们需要陷入内核的时,本质上是因为我们需要执行操作系统的代码,比如系统调用函数是由操作系统实现的,我们要进行系统调用就必须先由用户态切换为内核态。

4.3信号捕捉流程

信号捕捉流程图

image-20221108195321786

  • 如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。
  • 由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下: 用户程序注册了SIGQUIT信号的处理函数sighandler。 当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。
  • 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是 两个独立的控制流程。
  • sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。
  • 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。

4.4sigaction函数

捕捉信号除了用前面用过的signal函数之外,我们还可以使用sigaction函数对信号进行捕捉,sigaction函数的函数原型如下:

int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

参数

  • signum:捕捉的信号
  • 若act指针非空,则根据act修改该信号的处理动作。
  • 若oldact指针非空,则通过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:函数指针,捕捉信号的处理方式。赋值为常数SIG_IGN传给sigaction函数,表示忽略信号。赋值为常数SIG_DFL传给sigaction函数,表示执行系统默认动作。如果传递一个函数指针,就是自定义方式
  • sa_sigaction:实时信号处理函数,通常为NULL
  • sa_mask:阻塞信号集
  • sa_flags:一般为0
  • sa_restorer:该参数没有被使用

关于sa_mask

  • 首先需要注意的是,当某个信号的处理函数被调用。内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。【Linux环境下,信号在递达前计数为1次】
  • 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时,自动恢复原来的信号屏蔽字。

实例

//阻塞2号信号和3号信号
void handler(int signo){
    std::cout<<"捕捉到一个信号,信号的编号是:"<<signo<<std::endl;
    sigset_t pending;
    while(true){
        sigpending(&pending);
        for(int i=1;i<=31;i++){
            if(sigismember(&pending,i)){
                std::cout<<1<<" ";
            }
            else{
                std::cout<<0<<" ";
            }
        }
        std::cout<<std::endl;
        sleep(1);
    }
}
int main(){
   struct sigaction act,oact;
   sigset_t mask;
   //添加阻塞信号
   sigaddset(&mask,SIGINT);
   sigaddset(&mask,SIGQUIT);
   act.sa_handler=handler;
   act.sa_flags=0;
   act.sa_mask=mask;
   sigaction(SIGINT,&act,&oact);
   while(1){
    std::cout<<"main running"<<std::endl;
   }
   return 0;
}

image-20221109001432796

5.可重入函数

考虑下面常见:

主函数对一个全局链表进行头插时,insert()函数执行到一半,接收到信号发送中断;而信号的捕捉函数也是向链表进行头插。

image-20221109002622841

分析执行过程:

image-20221109002754295

执行主函数中的insert(&node1);由于发生了信号中断,所以后半部分重新需要在处理信号后再继续执行。

image-20221109003038240

捕捉信号:

image-20221109003329726

执行insert(&node1)的后部分程序:

image-20221109003423845

最后左右一个结点插入到链表中,在销毁链表时会发生内存泄漏。

  • 像上例这样,insert函数被不同的控制流调用(main函数和sighandler函数使用不同的堆栈空间,它们之间不存在调用与被调用的关系,是两个独立的控制流程),有可能在第一次调用还没返回时就再次进入该函数,我们将这种现象称之为重入。

  • insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。

**如果一个函数符合以下条件之一则是不可重入的: **

  • 内部调用了malloc和free的函数,因为malloc是用全局链表管理内存的
  • 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构

5.1volatile关键字

volatile的作用是:

volatile是C语言的一个关键字,该关键字的作用强制从内存中取数据,保持内存的可见性。

int flag = 0;
void handler(int signo)
{
	printf("捕捉到一个信号:%d\n", signo);
	flag = 1;
}
int main()
{
	signal(2, handler);
	while (!flag);
	printf("进程是正常退出的!\n");
	return 0;
}

image-20221109004218112

代码中的main函数和handler函数是两个独立的执行流,而while循环是在main函数当中的,在编译器编译时只能检测到在main函数中对flag变量的使用。

当捕捉到信号时,全局变量flag被修改为1,进程退出。

gcc ....... -O2/O3 选项可以优化编译器,只从当前执行流的寄存器中取数据。

image-20221109005704520

C99标准会进行优化,只能捕捉一次信号。

使用volatile关键字,强制进程从真实的内存中取数据,而不是寄存器

//阻塞2号信号和3号信号
#include <stdio.h>
#include <signal.h>
volatile int flag = 0;
void handler(int signo)
{
	printf("捕捉到一个信号:%d\n", signo);
	flag = 1;
}
int main()
{
	signal(SIGINT, handler);
	while (!flag);
	printf("进程是正常退出的!\n");
	return 0;
}

image-20221109010011357

5.2SIGCHLD信号

子进程在终止时会给父进程发生SIGCHLD信号,该信号的默认处理动作是忽略。

void handler(int signo)
{
	std::cout<<"捕捉到信号:"<<signo<<std::endl;
	exit(1);
}
int main()
{
	pid_t pid=fork();
	if(pid==0){
		int cnt=5;
		while (cnt)
		{
			std::cout<<"我是子进程:pid=%d"<<getpid()<<" 还有"<<cnt<<"秒退出"<<std::endl;
			cnt--;
			sleep(1);
		}
		exit(1);
	}
	signal(SIGCHLD,handler);
	int ret=waitpid(pid,nullptr,0);
	if(ret<0){
		std::cerr<<"waitpid error"<<std::endl;
	}
	return 0;
}

image-20221109012329036

不只是子进程退出,子进程暂停和再允许都会向父进程发生SIGCHLD信号。

void handler(int signo)
{
	std::cout<<"捕捉到信号:"<<signo<<std::endl;
	exit(1);
}
int main()
{
	pid_t pid=fork();
	if(pid==0){
		int cnt=5;
		while (cnt)
		{
			std::cout<<"我是子进程:pid=%d"<<getpid()<<std::endl;
			sleep(1);
		}
		exit(1);
	}
	signal(SIGCHLD,handler);
	int ret=waitpid(pid,nullptr,0);
	if(ret<0){
		std::cerr<<"waitpid error"<<std::endl;
	}
	return 0;
}

image-20221109012725757

发送SIGSTOP,子进程暂停一样会给父进程发送SIGCHLID信号。