Linux操作系统之进程信号

代码存放在:https://github.com/sjmshsh/System-Call-Learn/tree/master/signal

我们先来看一张图,了解一下通过阅读本博客,你可以收获什么。

Linux操作系统之进程信号_第1张图片

背景知识

首先我说明一点

信号 != 信号量

我们这篇文章讲解的是信号,不是信号量

信号在生活中处处有在,例如红绿灯,上课铃声等等。信号可以让我们知道我们要做什么事情。

其实Linux操作系统就像是一个社会,处处充满着生活中的哲学。Linux操作系统也是有信号的。

信号的产生就代表场景的触发,在Linux中,信号是给进程发的,进程要在合适的时候执行对应的动作。光有信号是没有意义的,重要的是一种类似协议的东西。也就是我制定一个规则,你看到信号的时候就固定触发某些场景,例如我看到红灯就停止走路,看到绿灯就继续走路。

Linux操作系统给进程发送信号,并且它具有识别和处理信号的能力。

那么我们看到信号就一定要处理吗?不一定,生活中有很多信号,例如上课铃声响了,但是我生病了没有去上课。我生病了,体温39°这个信号的优先级明显要高于上课铃声。

因此信号随时都有可能产生,但是并不是立即会处理,而是等到合适的时候再处理。

既然信号不能被立即处理,那么已经到来的信号是不是应该暂时存储起来呢?答案肯定是的,所以在进程在收到信号后,要先把信号保存起来,等到合适的时候再处理。

那么应该保存在哪里呢?task_struct,这毫无疑问。

信号的本质也是数据,信号发送的本质就是往task_struct结构体中写入对应的数据。

task_struct是一个内核数据结构,用来定义进程的内核对象,而内核不相信任何人,用户不可以对内核数据结构进行写入,所以是谁向task_struct中写入信号数据的呢?是OS!

所以无论我们的信号如何发送,本质都是通过OS发送的

Linux操作系统之进程信号_第2张图片

信号产生的各种方式

signal函数修改信号处理动作

首先我们可以用kill -l指令查看我们有哪些信号。

Linux操作系统之进程信号_第3张图片

前31个是普通信号(1 - 31)

后31个是实时信号(34 - 64)

例如我们CTRL + C 其实就是在给操作系统发送2号(SIGINT)信号。

那么怎么证明呢?

我们先来介绍一个系统调用接口:

#include 
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);

sighandler_t是一个参数为int,返回类型为void的函数指针。

第一个参数是一个整数,可以用信号名,也可以用信号的编号。

第二个参数是一个函数。

这个函数的作用是修改进程对信号的默认处理动作。

#include 
#include 
#include 

void handler(int signal)
{
    printf("I got a signal, signal id : %d, pid : %d \n", signal, getpid());
}

int main()
{
    // 通过signal函数把2号动作处理为我们特定动作
    signal(2, handler);
    while (1)
    {
        printf("hello world! My pid : %d\n", getpid());
        sleep(1);
    }
    
    return 0;
}

Linux操作系统之进程信号_第4张图片

可以看到我CTRL + C就变成执行handler函数,而不是退出了。

所有信号,除了9号信号之外,都可以像这样进行操作。9号信号是用来杀死进程的,它是特殊的,不能被自定义,也不能被阻塞。

总结:进程收到信号后的处理方式有3种:

  • 默认动作,一部分是终止自己,暂停等
  • 忽略动作,也是信号处理的一种方式,就是什么都不干
  • 自定义动作,例如上面展示的

这个函数介绍完了,那么现在我们来了解一下,有哪些方式可以发出信号。

键盘命令产生信号与运行时软硬件错误收到os发的信号

野指针或者数据越界的时候,有的时候会发生段错误(Segmentation fault

现在我们来证明一下:

#include 
#include 
#include 

void handler(int signal)
{
    printf("I got a signal, signal id : %d, pid : %d \n", signal, getpid());
}

int main()
{
    // 我们不管三七二十一,把除了2之外所有的信号都处理为我们的特定动作,留个2是退出CTRL+C就行了,比较方便
    for (int i = 0; i <= 31; i++)
    {
        if (i != 2)
            signal(i, handler);
    }
    while (1)
    {
        int* p = NULL;
        p = (int*)100;
        *p = 100;
        printf("hello world! My pid : %d\n", getpid());
        sleep(1);
    }
    return 0;
}

看一下结果:

I got a signal, signal id : 11, pid : 14520 

11号信号是SIGSEGV.是段错误的意思。

再来看看经典的除零错误

#include 
#include 
#include 

void handler(int signal)
{
    printf("I got a signal, signal id : %d, pid : %d \n", signal, getpid());
}

int main()
{
    // 我们不管三七二十一,把除了2之外所有的信号都处理为我们的特定动作,留个2是退出CTRL+C就行了,比较方便
    for (int i = 0; i <= 31; i++)
    {
        if (i != 2)
            signal(i, handler);
    }
    while (1)
    {
        int a = 1;
        a /= 0;
        printf("hello world! My pid : %d\n", getpid());
        sleep(1);
    }
    return 0;
}

结果是:

I got a signal, signal id : 8, pid : 15487 

8号,也就是SIGFPE,浮点数错误。

我们的进程本来会因为错误程序直接崩溃,但是却没有,原因是进程崩溃的本质就是进程收到了对应的信号,执行信号的默认行为。

那么为什么会被发送对应信号呢?

操作系统是硬件的管理者,硬件的各种状态操作系统都要管理,这些错误会对硬件造成影响,那么操作系统必然不会视而不见,肯定要发送信号处理相关的错误。

软件上面的错误,通常会体现在对应的硬件上或者其他软件上。

a /= 0在CPU计算的时候,如果有浮点数错误,会有一个标志位标记出错。*p = 100野指针访问的时候,管理虚拟内存映射的mmu硬件会标记你越界了。

那么一个进程崩溃的时候,我们希望获得崩溃的原因,即获得对应收到的信号,而前面学习过,waitpid时拿到的是status,它的低7位(status & 0x7f)就是对应的信号。

这里做一个回顾。

Linux操作系统之进程信号_第5张图片

但是光获得报错信息是没有意义是,我们需要解决它,因此这里就需要用到core dump标志了,这也算是把前面一个没有解决的坑给填上了。

在Linux种中,档一个进程正常退出的时候,退出码和退出状态都会被设置,只不过退出码是0而已。当一个进程异常退出的时候,进程的退出信号会被设置,表明进程退出的原因。如果你设置了,那么会把core dump标志位设置成1,如果这个位是1的话,那么进程在内存中的数据会转出到磁盘中,方便后期调试。

ulimit -a:查看系统资源,可以查看core dump是否开启。

Linux操作系统之进程信号_第6张图片

0,说明没有开启。

ulimit -c 10240:允许core dump操作。

当我们开启之后,如果进程崩溃了,就会生成一个文件。

Linux操作系统之进程信号_第7张图片

这个文件是一个二进制文件。

Linux操作系统之进程信号_第8张图片

然后在编译的时候带上-g,代表程序可以被调试,然后core-file core.pid进行调试,就可以知道错误原因,然后解决了。

系统产生信号

这里介绍几个可以产生信号的系统调用接口:

#include 
// 向某个进程发送指定信号
int kill(pid_t pid, int signo);
// 对自己发送某个信号
int raise(int signo);
这两个函数都是成功返回0,错误返回-1

abort函数使当前进程接收到信号而异常终止。

#include 
void abort(void);
就像exit函数一样,abort函数总是会成功的,所以没有返回值。

也就是发送6号信号。

软件条件产生信号

例如:进程间通信,读端不读且把fd关闭了,写端一直还在写,最终写进程会收到SIGPIPE(13号)信号。

还有系统调用接口alarm

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

如何理解操作系统向task_struct写入信号数据

普通信号的取值范围是[1, 31],进程的task_struct内部一定要有对应的数据变量来保存记录,表明是否收到了对应的信号。

很明显是一个位图uint32_t sigs

打个比方:

0000 0000 0000 0010 0101表示进程收到了1号,3号,6号信号。

因此操作系统向task_struct写入信号的本质就是OS向进程PCB的位图对应的比特位置1,完成信号的发送就是完成对信号的写入。

信号的保存状态

背景知识

实际执行信号的处理动作成为信号抵达,分为三种:

  • 自定义捕捉
  • 默认
  • 忽略

信号从产生到抵达之间的状态叫做信号未决,本质是这个信号被存在task_struct里面还没有被处理。

进程可以选择阻塞(Block)某个信号,本质就是操作系统允许进程暂时屏蔽指定的信号,它表明:该信号依然是未决的;该信号不会被抵达直到解决阻塞。

忽略,阻塞的区别:

  • 忽略是一种信号处理方式,阻塞是没有抵达,是一种独立的状态

信号处理在内核中有三张表:pendingblockinghandler

上图:

Linux操作系统之进程信号_第9张图片

pending就是写入的那个位图,表示已经收到但是还没有抵达的信号。

handler是一个函数指针数组void(*handler[31])(int),存放信号的处理方法。

block是阻塞数据,如果标记是1的话,代表信号被阻塞,不会被执行。

这个图是横着看的,如果信号对应的位置的比特位是1,代表信号被阻塞了,后续就不用进行操作了,如果不是1,才有后面两个位图的事情。

os检测处理信号的伪代码如下:

int isHandler(int signo)
{
    if (block & signo)
    {
        // 阻塞了 根本不管有没有信号
    }
    else
    {
        // 没有被block
        if (signo & pending)
        {
            // 该信号被收到了
            hadnler_array[signo](signo);
            return 0;
        }
    }
    return 1;
}

因此block表又被称为信号屏蔽字

相关系统调用接口

不是只有接口才是系统调用,OS也会给用户提供数据类型,配合系统调用来完成,比如shmget中的key_t、struct ipc_perm等,这些是配合接口使用的数据类型。

sigset_t:从上面的信号保存状态图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。 因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,

这个类型可以表示每个信号 的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有 效”和“无效”的含义是该信号是否处于未决状态。阻塞信号集也叫做当 前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。

信息集操作函数:

#include 
int sigemptyset(sigset_t *set);// 把位图集合清空 全部置0
int sigfillset(sigset_t *set);// 全部置1
int sigaddset (sigset_t *set, int signo);// 把一个信号添加到这个位图里 也就是把这个信号对应的位图的位置1
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);// 判定一个信号是否在集合中 

除sigismember外,这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含 某种 信号,若包含则返回1,不包含则返回0,出错返回-1。

sigprocmask:修改进程的block位图。

#include 
int sigprocmask(int how, const sigset_t *set, sigset_t *oset); 
//返回值:若成功则为0,若出错则为-1 

如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则 更改进程的信 号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后 根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。

SIG_BLOCK set包含了我们希望添加到当前信号屏蔽字的信号,相当于mask = mask
SIG_UNBLOCK set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask = mask & ~set
SIG_SETMASK 设置当前信号屏蔽字为set所指的值,相当于mask = set

这个有相关代码去我GitHub上面看就可以了。

信号处理方式

信号发送后为什么是合适的时候才选择处理信号呢?这是因为信号的产生是异步的,当前进程可能会有更重要的工作要去做。

那么这些信号什么时候去处理呢?

当进程从内核态返回到用户态的时候,进行上面的检测与处理

那么什么是内核态和用户态呢?

内核态和用户态

内核态:执行OS的代码和数据时,计算机所处的状态。就叫做内核态。OS的代码的执行,全部都是在内核态。

用户态:就是用户代码和数据被访问或者执行的时候,所处的状态。我们自己写的代码,全部都是在用户态执行的。

主要区别:在于权限。

这里用一张大图解决所有的问题:

Linux操作系统之进程信号_第10张图片

进程之间不管如何切换,我们一定可以找到同一个OS,因为每个进程都有3 - 4G的内核空间,使用同一张内核级别页表就可以找到内核相关的数据和代码。

所谓系统调用,本质就是进程身份转换成内核,然后根据内核页表转换成为内核态,然后根据内核页表找到对应的系统函数。

那么我们现在回到刚才的问题,信号执行的时机:

Linux操作系统之进程信号_第11张图片

抽象一下就是:

Linux操作系统之进程信号_第12张图片

那么为什么一定要回到用户态执行自定义函数呢?

其实在内核态也可以执行,但是这样很不安全,如果我这个handler函数里面有一个破坏操作系统内核的脚本的话,那么一套下来OS就废了。

sigaction - 注册信号捕捉函数

#include 
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact); 

它修改的是handler表,它也可以处理实时信号,第二个参数是输入型参数,动作方法填入这个结构体中,oact是一个输出型参数,返回老的信号处理方法。

Linux操作系统之进程信号_第13张图片

sa_mask的含义:处理信号时希望暂时屏蔽其他信号,不让其他信号影响当前信号的处理。

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

对应的代码,也可以在GitHub里面看,这里就不写了。

可重入函数

可重入函数实际上就是所谓的非线程安全函数,我有多个执行流可以进入一个函数执行逻辑就叫可重入函数,在STL中,大部分函数都是不可重入的,也就是线程安全的。

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

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

volatile关键字

我们先来看一个代码:

#include 
#include 

int flag = 0;

void handler(int signo)
{
    flag = 1;
    printf("change flag 0 to 1.\n");
}

int main()
{
    signal(2, handler);

    while (!flag);

    printf("这个进程是正常退出的.\n");
    
    return 0;
}

这个代码就是当我CTRL+C的时候发送2号信号,然后被捕获,在handler里面改变flag的值,从而while循环结束,程序退出。

测试一下发现完全没有任何问题。

原因是我们的Makefile是这么写的:

signal: test.cc
	g++ -o $@ $^ -std=c++11 -g
.PHONY:clean
clean:
	rm -f signal

没有写编译器优化的选项,我们加上-O3

然后允许,发现程序无法停止,然而在flag变量前面加上关键字volatile就可以了,这是为什么呢?

这里解释一下编译器优化:

编译器编译,构建语法树的时候可以发现main主程序里面没有对flag变量做更改,所以会进行优化,为了提高速度,会把flag放入寄存器里面,而我们改flag的值是在内存里面的改的,对CPU不可见了。

Linux操作系统之进程信号_第14张图片

那么volatile的作用就很简单了,就是告诉编译器不要对我的这个变量做任何的优化

  1. 保持内存可见性
  2. 防止指令重排序

SIGCHILD信号

我们之前了解过用waitpidwait清理回收僵尸进程,但是这样父进程会阻塞等待,或者每过一段时间就回去看一下,这样效率很低。而当子进程退出之后会向父进程发送SIGCHILD信号。因此,如果父进程不关心子进程的退出信息的话,我们可以直接把SIGCHILD忽略了,这样就不存在僵尸进程没有被回收的问题了。

#include 
#include 
#include 
#include 
#include 

void handler(int sig)
{
    pid_t id;
    while ((id = waitpid(-1, NULL, WNOHANG)) > 0)
    {
        printf("wait child success: %d\n", id);
    }
    printf("child is quit! %d\n", getpid());
}
int main()
{
    signal(SIGCHLD, handler);
    pid_t cid;
    if ((cid = fork()) == 0)
    { // child
        printf("child : %d\n", getpid());
        sleep(3);
        exit(1);
    }
    while (1)
    {
        printf("father proc is doing some thing!\n");
        sleep(1);
    }
    return 0;
}

你可能感兴趣的:(Linux操作系统精讲,linux,运维,服务器)