Linux系统编程系列之进程信号


一、一些基础知识

概念

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

其实信号本身在之前就已经见过几次了,比如管道读端关闭写端不关闭此时OS就会关掉进程---本质是进程收到了13号信号,Ctrl + C本质是进程收到了2号信号,进程控制中的waitpid也测试了一些信号。本篇正式讲解信号。这里先说一些前置知识:

1.进程必须识别+能够处理信号--信号没有产生,也要具备处理信号的能力 --信号的处理能力,属于进程内置功能的一部分! !

2.进程即便没有收到信号,也知道哪些信号怎么处理

3.当进程真的收到了一个具体的信号的时候,进程可能并不会立即处理这个信号,合适的时候再去处理

4.一个进程当信号产生,到信号开始被处理,就一定会有时间窗口,进程具有临时保存哪些信号已经发生的能力.

前后台进程

这个暂时提一嘴后面还会提到,Ctrl + C输入产生一个硬件中断,被OS获取,解释成信号,发送给进程,这个进程是前台进程。Linux下的进程分为前台进程、后台进程。后面会提到的守护进程是一种后台进程。产生后台进程就是加一个&,比如./process & . Linux中只允许存在一个前台进程,可以存在多个后台进程。

前后台进程最本质的区别:能接受键盘输入的就是前台进程,所以只能存在一个,自然Ctrl + C只能发送给前台进程了。

查看Linux下定义的信号

kill + l命令

信号不是连着的,32-33没有。

1 - 31称之为普通信号,34-64称之为实时信号,需要立即处理。这个名称本质是宏定义,#define SIGHUP 1

信号的处理方式

信号的处理方式有三种:1.默认动作 2.忽略 3.自定义动作

进程收到2号信号的默认动作就是终止自己.

可以用man 7 signal查看默认的动作

其中Term、IGN等是不同信号的默认处理方式,简单翻译一下。

Term:默认行为是终止进程。

Ign:默认行为是忽略该信号。

Core:默认行为是终止进程并生成核心转储,这个不着急谈,后面再说

Stop:默认行为是暂停进程。

Cont:默认行为是如果进程当前处于暂停状态,则恢复其运行。

自定义处理方式

上面提到信号的处理方式可以自定义处理,怎么自定义,系统调用接口--signal函数,man 2 signal,来捕捉信号

3号手册里也有,一定封装了上面的,这里介绍系统调用接口。

第一个参数就是是几号信号,第二个参数是一个函数指针!参数是int,返回值是void,这个参数不需要自己传,因为你调用的时候直接用的是函数名你也传不了,这个参数是OS填充的,和sig一样,代表几号信号。

cpp 复制代码
#include <bits/stdc++.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <string.h>
#include <fcntl.h>
#include <signal.h>
using namespace std;
void sighandler(int a)
{
  cout << "process get a signal:" << a << endl;
}
int main()
{
 signal(2,sighandler);//只需要设置一次,往后都有效
 //信号的产生和我们自己的代码运行是异步的
   while(true)
   {
    cout << "I am a process\n";
    sleep(1);
   }

  return 0;
}

当然此时ctrl + c无法杀掉信号了,可以用ctrl + \

二、信号的产生

1.键盘组合键

ctrl + C: 2号 ctrl + \ :3号

ctrl + Z: 20号 SIGTSTP

另外:不是所有的信号都可以被signal捕捉的! 9和19不行,9是杀掉进程,19是暂停进程,如果所有的信号都被捕捉,那么一个死循环的进程就无法终止了!

2.kill命令

kill -signo pid,对于知道pid,可以去grep,也可以pidof,用法就是pidof + 进程名来获取对应进程的pid。

3.调用接口

1.kill:

kill是一个系统调用接口,就和上面提到的kill命令一样用法!

2.raise

raise就是杀掉对应的进程,等价于raise(2) = kill(getpid(),2)

3.abort

描述是造成一个不一般的进程终止。

abort() 给自己发送6号信号,终止当前进程。abort函数总会成功,所以没有返回值

被signal捕捉之后仍然可以终止,kill -l就不行了。

4.异常

为什么/0,野指针等会让进程崩溃了? 本质给进程发送了信号 终止了进程

/0: OS给进程发送信号.CPU里有状态寄存器 里面有一个溢出标志位,一旦除0,溢出标志位从0变1。

野指针: CPU->MMU,地址转化失败,虚拟到物理转化失败,CPU内部有寄存器,记录异常。

5.软件条件

a.管道 写端往里写 读端关闭---发送信号--结束进程,13号信号---SIGPIPE

b.alarm

设定闹钟,告诉内核在second秒之后给当前进程发送SIGALRM信号,该信号的默认处理动作是结束当前进程。本质是14号信号 SIGALRM。

返回值就是代表距离上一个闹钟响剩余的时间

Core

之前一直有一个历史遗留问题,waitpid中的status中有一位存储core dump,这是啥我们来介绍介绍

Core Dump:

当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部 保存到磁盘上,文件名通常是core,这叫做Core Dump。进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因。一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存 在PCB中)。默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。比如ulimit -c 1024就是,将core文件的限制到1024K。

所以Term 和 Core 信号是不同的,Core相当于Term + 生成核心转储,怎么生成?你打开Core功能之后,运行的进程如果异常终止就会生成对应的文件。

比如我写了一个除0的代码来运行

此时发现形成了一个core.30182文件,这个30182就是进程的pid。

打开系统的core dump功能,一旦进程异常,OS会将进程在在内存中的运行信息,dump(转储)到进程的的当前目录,形成core.pid文件, 称为核心转储(core dump)

怎么查看? gdb可执行文件,输入 core-file core.pid,

可以查看错误信息,收到信号,哪行代码出问题了

三、信号的发送

对于进程而言,自己有还是没有,收到了哪一个信号,是怎么区分的呢?

存储在了task_struct中,当信号收到时修改位图即可。

task_struct {

int signal;//0000 0000 0000 0000 0000 0000 0000 0000

//普通信号 -- 位图管理信号

}

1.比特位的内容是0还是1,表明是否收到

2.比特位的位置(第几个),表示信号的编号

3.所谓的"发信号",本质就是OS去修改task_struct的信号位图对应的比特位.

意味OS是进程的管理者,只有它有资格才能修改task_struct内部的属性

四、信号的保存

概念

上面说到,进程收到信号之后,可能不会立即处理这个信号,就要有一个时间窗口

实际执行信号的处理动作称为信号递达(Delivery)

信号从产生到递达之间的状态,称为信号未决(Pending)

进程可以选择阻塞(Block)某个信号,阻塞是一种状态,和产不产生没关系

普通信号的范围[1,31],每一种信号都有对应的处理方法

typedef void(*handler_t)(int);

handler_t handler[31]; 函数指针数组

指向默认方法,如果自定义,就把指定方法的地址填入

pending表:位图,记录收到了哪条信号,比如信号被阻塞了,但是收到了信号就是未决。

block表:位图、和pending结构类似,0表示不阻塞,1表示阻塞

两张位图 + 一张函数指针数组,用图来表示就是

被阻塞的信号产生将保持未决状态直到解除才递达.

sigset_t

从上图来看,每个信号只有一个bit的未决标志,非0即1,阻塞标志也是这样。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的"有效"或"无效"状态。在阻塞信号集中"有效"和"无效"的含义是该信号是否被阻塞,而在未决信号集中"有效"和"无效"的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的"屏蔽"应该理解为阻塞而不是忽略。

信号集操作函数

sigset_t是内核规定的一种数组结构,怎么修改设置这些位图呢?一定不能直接操作,因为OS不可能完全相信用户,所以一定会提供一套接口来让用户使用。这里引入了信号集操作函数。

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);

这些接口就和名字一样看了就知道怎么使用,当然在使用之前要调用empty全部置为0或者fill全部置为1,再去调用其他函数,sigismenber是检测signo是否在位图中。

sigprocmask

已经有了对应的位图,怎么设置进去呢?sigprocmask

作用就是读取或更改进程的信号屏蔽字(阻塞信号集)

how: 就是怎么修改,还是宏,SIG_BLOCK, SLG_UNBLOCK, SIG_SETMASK

BLOCK就是添加,UNBLOCK就是去掉,SET就是设置。

sigset_t *set用来修改

sigset_t *oldset --输出型参数 保存上一次进程的Block表

pending

int sigpending(sigset_t*set)

输出型参数,以位图的形式带出来pending表,怎么查看pending表?必须使用提供的信号集操作函数!

简单实验一下,先屏蔽7s,再解除2号信号:

cpp 复制代码
void PrintPending(const sigset_t &sp)
{
  for(int i = 31;i >= 1;--i)
  {
  if(sigismember(&sp,i))
  {
      cout << "1" ;
  }
  else cout << "0";
  }
}
void func(int c)
{
  cout << "catch a signo" << c << endl;
}
int main()
{
  //1.对2号信号进行屏蔽
  sigset_t s,olds;
  sigemptyset(&s);
  sigemptyset(&olds);
  sigaddset(&s,2);//还没有屏蔽!
  sigprocmask(SIG_SETMASK,&s,&olds);//屏蔽了,没收到
  signal(2,func);
  //2.重复打印当前进程的pending
   //2.1发送2号 pending结构改变!
  sigset_t sp;
  int cnt = 10;
  while(cnt --)
  {
    int n = sigpending(&sp);
    if(n == 0)
    {
        PrintPending(sp);
        cout << endl;
        sleep(1);
    }
     sleep(1);
     //解除阻塞
     if(cnt == 3)
     {
      cout << "unblock 2 signo" << endl;
      sigprocmask(SIG_SETMASK,&olds,nullptr);
     }
  }

  return 0;
}

五、信号的捕捉

信号是什么时候被处理的呢?当我们的进程从内核态返回到用户态的时候!

示意图

来看信号捕捉的示意图:上面是用户态,下面是内核态

简单解释一下这个图,1->2就是执行main函数中碰到了异常、中断或者系统调用进入内核,2->3处理完准备返回用户态的时候处理信号,如果是忽略或者默认行为就直接在内核态处理然后执行第5步调用sys_sigreturn(),如果是自定义行为就得重新进入用户态执行代码返回再到5再回到之前执行的代码的位置。

有一个小问题:为什么要从4到5?直接返回不行吗?

一定不行,首先sys_sigreturn()是系统调用接口用来执行收尾动作,其次进程的上下文是保存在内核中的,用户态看不到也无法操作。

对于第一种情况方便记忆可以用一个倒着的8字,一共四次切换。

sigaction

act:输入型 oldact:输出型 和sigprocmask类似

成员:

对于普通信号sa_mask和sa_handler就够了,sa_handle忽略就是SIG_IGN,默认就是SIG_DFL.

对于sa_mask,它是防止这个信号被重复发生,在信号处理的过程中如果再次收到了信号就会一直阻塞到结束为止!

实验代码:

cpp 复制代码
void ha(int sig)
{
  cout << "sig" << endl;
  sleep(5);
}
int main()
{
 struct sigaction act,oact;
 act.sa_flags = 0;
 act.sa_handler = ha;
 sigemptyset(&act.sa_mask);
 sigemptyset(&oact.sa_mask);
 sigaddset(&act.sa_mask,2);
 sigaction(2,&act,&oact);
 sleep(100);
  return 0;
}

六、可重入函数

这里简单理解一下即可,这里在线程互斥、线程安全那里可以再次理解一下。

一个函数在一个特定时间端内,被多个执行流重复进入,叫做"函数重入",而函数重入后逻辑主题不受影响时,把该函数叫做可重入函数,而重入后逻辑出现问题时,把该函数叫做不可重入函数。

如果一个函数符合下面条件之一就是不可重入函数:

调用了malloc和free,new和delete等内存操作函数,因为这些函数也是用全局链表来管理堆空间的。

调用了标准I/O库的函数,因为标准I/O库的很多实现都是以不可重入的方式使用全局数据结构。

我们目前使用的90%的函数都是不可重入的函数。要保证函数可重入,必须保证函数是独立的,不访问任何全局函数,但是我们目前涉及到的系统接口大多数都跟全局变量有关系,比如很多接口成功返回0失败返回-1,错误码被设置,而这个错误码就是errno,是全局数据

七、volatile

volatile是C语言的一个关键字,该关键字的作用是保持内存的可见性。

比如下面这个代码:

cpp 复制代码
int flag = 0;
void Flag(int signum)
{
    flag = 1;
    cout << " -> " << flag << endl;
}
int main()
{
    signal(2, Flag);
    while (!flag)
    {}
    cout << "进程正常退出后:" << flag << endl;
}

再带上O2优化 ,就会出现下面这个结果,本来应该结束的代码没有结束:

这是吸氧优化的结果,由于死循环中一直在做运算,CPU需要从内存中反复取出放回数据,这样效率低,CPU直接把数据放入了寄存器中,每次运算直接从寄存器中拿去,这就导致了虽然外部看到flag是0,但是while循环中的还是1.

由于这样会出现一定问题,我们就可以加上volatile关键字,告诉CPU这个数据必须每次都从内存中拿。

ps:建议while循环中不要加太多代码,否则不一定会看到优化。

八、SIGCHLD

子进程退出时需要父进程回收资源,父进程如何做到知道子进程退出的呢?信号!

子进程退出时,会给父进程发送SIGCHLD信号,17号信号,可以手动signal一下验证。

我们可以signal(SIGCHLD, SIG_IGN);把这个信号搞成忽略,这样子进程退出后会自动释放僵尸进程了,不需要父进程去关心。

这里有一个小问题,SIGCHID的默认处理方式也是忽略,那我这么设置一下有什么区别呢?当然有区别啊,自己写的时候如果不wait子进程会成僵尸啊!也就是我这个手动设置相当于告诉OS父进程不关心了,可以释放僵尸进程了!默认情况下OS不能瞎搞

相关推荐
开开心心就好2 小时前
卸载工具清理残留,检测垃圾颜色标识状态
linux·运维·服务器·python·安全·tornado·1024程序员节
小舞O_o2 小时前
gitlab文件上传
linux·服务器·git·python·目标检测·机器学习·gitlab
HalvmånEver2 小时前
Linux:信号捕捉下(信号四)
linux·运维·数据库
咕咕嘎嘎10242 小时前
Socket编程
linux·服务器·网络
_OP_CHEN2 小时前
【Linux系统编程】(十九)深入 Linux 文件与文件 IO:从底层原理到实战操作,一文吃透!
linux·运维·操作系统·系统文件·系统调用·c/c++·文件i/o
杜子不疼.4 小时前
【Linux】基础IO(二):系统文件IO
linux·运维·服务器
郝学胜-神的一滴4 小时前
深入理解网络IP协议与TTL机制:从原理到实践
linux·服务器·开发语言·网络·网络协议·tcp/ip·程序人生
松涛和鸣4 小时前
DAY61 IMX6ULL UART Serial Communication Practice
linux·服务器·网络·arm开发·数据库·驱动开发
chinesegf12 小时前
ubuntu中虚拟环境的简单创建和管理
linux·运维·ubuntu