Linux《进程信号(下)》

发布于:2025-09-09 ⋅ 阅读:(17) ⋅ 点赞:(0)

在之前的Linux《进程信号(上)》当中我们已经了解了进程信号的基本概念以及知道了信号产生的方式有哪些,还了解了信号是如何进行保存的,那么接下来在本篇当中就将继续之前的学习了解信号是如何处理的。除此之外还会了解到中断的概念,以及中断实际运行的原理是什么样的,在中断的学习当中将引入用户态和内核态相关对的概念,相信通过本篇的学习会让你对操作系统有更深的理解,一起加油吧!!!



1. 信号捕捉

之前我们已经学习了信号的产生以及信号的保存,那么接下来就到了信号处理的阶段。

首先我要知道的是信号的处理实际上不是立即处理的,而是要等到合适的时候再处理的,但是问题就来了,这里提到的合适的时候具体是什么呢?

如果信号的处理动作是自定义处理的,那么这时抵达调用该函数的时候就称为捕捉信号。

但实际上信号的处理具体的流程是较为复杂的,实际上信号的的处理流程是如下所示的:

例如当中用户注册了SIGQUIT信号的处理函数sighandler时,那么接下来当程序在执行main函数的时候,这时就会因为中断或者异常进入到内核态当中,那么接下来在内核当中就会处理信号的自定义处理动作,但是这时候执行具体的函数又需要回到用户态当中,将函数执行完之后再回到内核当中,最后将处理的结果返回给程序,程序会从原来中断的地方重新执行下去。

因此以上的执行流程图就简单来说就可以描述为以下的形式:

以上的流程图就很类似一个数学当中的∞符号,之后记忆信号执行的流程我们就可以通过以上的图来进行。

实际上除了处理自定义信号的捕捉方法之外,其余的忽略或者执行原来默认的方法就简单多了,在内核当中即可完成函数的执行,之后直接将结果返回给用户态即可。

以上就大致的了解了处理信号的流程是什么样的,但是实际上我们还是对以上的一些概念不是很了解,就例如用户态和内核态就是之前没有听过的概念,具体用户态和内核态之间的切换是什么样的我们现在也还是不知道如何进行的,那么是不是接下来机需要来了解这些概念了呢?

接下来去确实是会讲解以上提到的问题,但是在这之前我们需要先来了解中断相关的概念

2. 中断

在之前的学习当中我们就了解到了操作系统实际上可以看作一个死循环,并且这个死循环还是暂停住的,只有当有任务被调度到的时候操作系统才会运行起来。

void main(void) /* 这⾥确实是void,并没错。 */
{ /* 在startup 程序(head.s)中就是这样假设的。 */
...
/*
* 注意!! 对于任何其它的任务,'pause()'将意味着我们必须等待收到⼀个信号才会返
* 回就绪运⾏态,但任务0(task0)是唯⼀的意外情况(参⻅'schedule()'),因为任
* 务0 在任何空闲时间⾥都会被激活(当没有其它任务在运⾏时),
* 因此对于任务0'pause()'仅意味着我们返回来查看是否有其它任务可以运⾏,如果没
* 有的话我们就回到这⾥,⼀直循环执⾏'pause()'。
*/
    for (;;)
    pause();
} 

但是在之前我们没有了解到对应的硬件是如何通知操作系统要调度对应的进程的,实际上是通过中断来实现的,来看以下的图:

2.1 硬件中断

实际上在计算机当中当硬件出现对应的异常或者需要CPU执行对应的程序时,是会先向中断控制器当中发送对应的中断号,接下来中断控制器会将该中断号再发送给CPU,CPU在得到中断号之后再根据中断号从操作系统当中的中断向量表当中启动对应的中断服务。

综上就会发现在中断执行的流程当中,简单来说就是从硬件->中断控制器->CUP->操作系统中的中断向量表->CUP执行对应的中断方法

在冯诺依曼体系当中,CPU是进行所有操作的核心,这就要求所有的硬件设备都需要和CPU建立连接才能进行交互,那么在早期的计算机当中大多数硬件都是有和CPU有线连接起来的,就是为了能让CPU能直接的和其他的硬件进行交互。

但是到了现代的计算机当中实际上已经比直接将硬件和CPU直接连接的方式要复杂的多,多数外设不会直接连 CPU,而是通过 总线(PCIe、USB、SATA 等)连接。但是我们依旧可以理解硬件是直接和CPU进行连接的。

注:中断向量表本质上就是一个存储对应中断方法的数组,在操作系统运行起来的时候就已经被加载到内存当中了。

那么当中断没到来的时候操作系统是不是就是一直在暂停呢?实际上就是这样的,因此就可以说操作系统是在硬件的时钟中断驱动下进行调度的。操作系统就是基于中断,进行工作的软件。

到这里你可能就会蒙了,这里提到的时钟中断又是什么,怎么之前都没听过呢。

其实以上我们提到的硬件设备产生的中断就是硬件中断,而时钟中断又是硬件中断当中的一种,发送时钟中断的硬件就是时钟源,发送的时钟中断就会以合适的时间间隔进行进程的调度。

实际上计算机当中的时钟源为了能更快的完成时钟中断都是将时钟源直接集成到CPU当中,这样时钟源距可以以更加小的代价将对应的时钟中断发送给CPU,简单来说时钟源就是进行计数的,而时钟中断就是进行通知CPU。当CUP接收到对应的时钟中断之后就会将当前进行的进程进行切换的工作,将进程的寄存器当中的上下文数据保存到PCB当中,此时进行的工作就是“CPU保护现场”。

在早期的计算机当中其实是可以通过对应的时钟中断的快慢得到对应的CPU频率的,这里的CPU频率就是一般我进行电脑选配的的时候看到的CPU主频。

注:但是在当代的计算机当中实际上计算机当中的时钟中断和CPU的频率实际上已经解耦了,这两个之间无法在通过简单的换算进行转换。

时钟中断除了有以上的作用之外还可以让计算机当中能时刻的存储当前的时间戳,这样计算机就能在离线的情况依然能知道当前的运行时间是什么样的。

因为在CPU执行进程的时候是不会记录具体运行的时间的,而是通过时钟源当中的计数器来获取目前的时间,时钟源每发送一次中断对应的计数器就会进行调整,而每次的时钟中断的发送间隔又是固定的,那么这时计算机就可以得到对应的具体时间了。

2.2 软中断

以上我们了解到了计算机当中的硬件是可以想CPU发送硬件中断的,那么接下来是否存在软件条件触发中断的情况呢?
实际上是存在的,除了硬件可以发送中断之外,还有软件条件也是可以引发中断的。在此软中断就是由软件触发的中断。软中断的形式有一下几种

1.程序员显示触发
2.异常
3.陷阱

以上第一种程序员显示触发就是使用对应的INT n指令来实现,Linux当中的就是通过INT 0x80实现。

在CPU当中检测到异常的时候就会抛出异常,在此该方式也属于“软中断”。

除此之外以上的陷阱就是当使用到SYSCALL系统调用等能让CPU陷入内核。

软中断执行的具体流程就如下图所示:

首先CPU会接收到对应的异常之后会通过系统当中的中断向量表来得到软中断要执行的系统调用,没有系统调用本质上就是函数指针数组当中的一个元素。因此实际上我们平时在调用系统调用的时候本质就是触发软中断,就是调用中断向量表当中的指定元素当中的元素,系统调用号就是数组下标。

之前在信号产生当中的学习我们就知道了野指针或者除零等软件的异常是可以产生信号的,但是那时候我们不知道出现异常之后CPU是如何知道当前的进程出现了异常,但是现在了解了中断当中软中断的概念以及运行的原理之后,就可以知道实际上当以上的CPU当中的异常(本质上就是中断)产生之后内核就会进行处理这个异常,并将其转化为对应的信号投递给用户进程。

其实可以这么比喻:

  • 中断/异常 = 警察发现你闯红灯(硬件发现错误)。

  • 信号 = 警察给你开罚单(内核把错误传达给用户进程)。

实际上无论是缺页中断、内存碎片处理、除零野指针错误、其实这些问题,全部都会被转换成为CPU内部的软中断,然后走中断处理例程,完成所有处理。有的是进行申请内存,填充页表,进行映射的。有的是用来处理内存碎片的,有的是用来给目标进行发送信号,杀掉进程等等。

综我们就可以总结出操作系统就是躺在中断处理例程上的代码块。

3.理解用户态和内核态

以上我们在中断当中提到了陷入内核的概念,那么这时我们就要思考这里的内核和用户实际上是什么关系的呢?而用户态和内核态又是什么样的关系呢?

其实之前我们了解到了虚拟内存到物理内存之间的映射表示图实际上只是真正情况下的一部分,之前了解的本质上都是用户态

通过以上的图示就可以看出实际上虚拟内存是被分为两个部分的,分别是[0,3GB]的用户区和[3,4GB]的内核区,用户区就是我们之前学习的虚拟地址空间,这段空间是使用对应的页表与物理内存进行映射的,在此就不进行讲解了,但是以上的内核区就需要来讲解看看了。

操作系统当中的进程可能会同时存在多个的,我们知道每个进程都会又自己的虚拟地址空间,实际上在虚拟地址空间当中的内核区与中断向量表进行映射的页表是所有进程共用的。本质上这样设计就是为了让进程在任何的时候都能找到操作系统。

而之前提到的陷入内核实际上就是将当前的进程从用户区跳转到内核区,但是在这里要注意的是用户是无法直接访问[3,4GB]中的内核区的,这是因为如果用户可以随意地访问那么不就会出现让用户更改一些核心的数据吗?所以实际上能访问内核区的只有操作系统。在内核区和用户区之间进行跳转的时候操作系统是会进行身份的切换的,无论是用户还是OS在用户区当中就是属于用户态;在内核区当中就是属于内核态。

但是这时又有问题了,那就是使用什么来标识当前用户或者是OS是处于用户态还是内核态的呢?

实际上在操作系统当中会存在一个CS段寄存器内的数据来标识,其中CPL为0的时候表示内核,为3的时候表示用户。当进程在调用对应的系统调用的时候就会将程序从用户区跳转到内核区,之后就再中断向量表当中找到指定下标的系统调用。整个的过程是和之前再动态库的加载之后,调用动态库当中的函数类似的,本质上都是在虚拟地址空间上进行跳转。

4. 可重新入函数

以上的insert函数当中使用在函数调用的过程当中如果先程序发送了信号,那么如果将该信号的处理方法进行自定义之后,将信号的处理自定义为再次调用insert函数,那么这时候不就会出现调用insert的同时又调用了insert函数。

main函数调用insert函数向⼀个链表head中插入节点node1,插入操作分为两步,刚做完第⼀步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同⼀个链表head中插入节点node2,插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续往下执行,先前做第⼀步之后被打断,现在继续做完第⼆步。结果是,main函数和sighandler先后 向链表中插⼊两个节点,⽽最后只有⼀个节点真正插入链表中了。

以上就展示了可重入函数是什么样的,那么在操作系统当中具体函数是不是可重入函数是如何进行区分的呢?

实际上只要函数满足一下其中之一的条件就是不可重入函数:
• 调用了malloc或free,因为malloc也是用全局链表来管理堆的。

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

5. volatile

首先我们先来看以下的代码:
 

#include<stdio.h>
#include<signal.h>


int flag=0;

void handler(int args)
{
    printf("flag 0 to 1!\n");
    flag=1;

}

int main()
{

    signal(2,handler);
    while(!flag);
    printf("process exit!\n");



    return 0;
}

makefile:

test:test.cc
	gcc -o $@ $^  -O1
.PHONY:clean
clean:
	rm -f test

在以上的代码当中我们是将二号进行自定义捕捉捕捉之后,那么运行编译之后的程序就可以得到当从键盘当中输入Ctrl+c 之后接下来程序就会因为flag的改变而使得while循环结束使得程序结束。

实际上我们实现的代码是可以让编译器进行优化的,优化有以下的级别:
 

-O0(默认)
    不做优化,编译速度快,便于调试。

-O1
    基本优化,比如删除无用代码、简单循环优化。

-O2
    在 -O1 的基础上,做更多优化,包括:
    更激进的寄存器分配
    内联函数展开
    循环展开与合并
    公共子表达式消除
    死代码消除
    指令调度(提高流水线利用率)
    更少的内存访问(变量缓存到寄存器)

那么如果让编译器进行进行-O1级别的优化以上的代码又会变成什么样的呢?

将makefile修改为以下的形式:

test:test.cc
	gcc -o $@ $^  -O1
.PHONY:clean
clean:
	rm -f test

运行程序之后发送信号就会发现发送2号信号之后无法将进程杀掉了,那么这时候出现这样的原因是什么呢?

实际上当编译器进行优化的时候当出现要从内存当中一直拿一个指定的变量的值时候,这时编译器为了提高效率那么就会会将该变量的值保存到指定的寄存器当中,之后再要获取该变量的值就直接从寄存器中获取,而不再从内存当中读取。这种方式确实能提高代码执行的效率,但是这时也就会存在对应的问题,就例如以上的代码当中在程序执行起来之后会将flag当中的值保存到寄存器当中,但是这时flag的值还是0,即使当用户向进程发送出2号信号之后flag的值虽然改变了,但是寄存器当中的flag值依旧是0,那么这就会出现while循环在得到信号之后依旧在执行的现象。

那么在以上编译器进行优化的时候如何禁止编译器将变量优化进寄存器,每次访问都要从内存中取值,在此提供了volatile来实现

在 C/C++ 里,volatile 的作用主要是 告诉编译器不要对这个变量的访问做优化

将以上代码当中的flag加上volatile之后运行程序:
 

#include<stdio.h>
#include<signal.h>


volatile int  flag=0;

void handler(int args)
{
    printf("flag 0 to 1!\n");
    flag=1;

}

int main()
{

    signal(2,handler);
    while(!flag);
    printf("process exit!\n");



    return 0;
}

这时就会发现程序能被2号信号杀掉了。

注:volatile 不是原子性的,也 不能保证多线程同步,但是目前我们还没了解线程相关的概念,这时还是无法理解原子性是什么,这些等到接下来学习完线程之后就能理解了。

6. 信号补充知识

6.1 sigaction

在之前我们已经了解了进行信号自定义捕捉的函数signal,那么接下来载来学习一个相比signal功能更加强大的函数sigaction

#include <signal.h>

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

参数:
signum:要处理的信号(如 SIGINT、SIGTERM)。
act:新的信号处理动作。
oldact:如果非空,则保存旧的动作。

struct sigaction {
    void     (*sa_handler)(int);       // 简单的信号处理函数
    void     (*sa_sigaction)(int, siginfo_t *, void *); // 复杂处理函数
    sigset_t sa_mask;   // 信号屏蔽字,处理函数执行期间要屏蔽的信号集合
    int      sa_flags;  // 行为控制标志
};

sa_handler:指定一个处理函数(比如 handler(int signo))。
sa_sigaction:如果设置了 SA_SIGINFO 标志,就用这个函数,能拿到更多信息(比如信号来源、附加数据)。
sa_mask:在处理这个信号时,额外屏蔽哪些信号。
sa_flags:常见的有:
    SA_RESTART:被信号中断的系统调用会自动重启。
    SA_SIGINFO:启用 sa_sigaction 方式。

通过以上就可以看出sigaction函数的相比signal的使用会较为复杂,在此该函数的第一个参数就是要进行自定义捕捉的信号,第二个参数是是一个结构体的指针,实际上就是将要执行的信号捕捉方法传给该函数,在该结构体当中sa_handler就和之前handler函数一样,除此之外还可以选择使用sa_sigaction,相比原来的sa_handler能发送信号附带的信息。

其实在sigaction当中相比signal效果最明显的是结构体当中的sa_mask,该变量是是信号屏蔽字,作用就是当对应的信号在进行处理的时候就将sa_mask当中的信号都加入到Block表当中,让该信号在执行的时候这些信号会保持阻塞的状态直到信号处理结束的时候,会将block修改为处理该信号之前的样式。

例如以下的使用示例:

#include<iostream>
#include<signal.h>
#include<sys/types.h>
#include<unistd.h>



void PrintPeding(sigset_t s)
{
    std::cout<<"当前进程pid"<<getpid()<<",进程peding表:";
    for(int i=31;i>=1;i--)
    {
        if(sigismember(&s,i))
        {
            std::cout<<1;
        }
        else{
            std::cout<<0;
        }
    }
    std::cout<<std::endl;
}


void handler(int args)
{
    printf("收到2号信号!\n");
    while(1)
    {
        sigset_t s;
        sigpending(&s);
        PrintPeding(s);
        sleep(1);
    }

}



int main()
{
    struct sigaction act;
    sigemptyset(&act.sa_mask);
    act.sa_handler=handler;
    sigaddset(&act.sa_mask,SIGTSTP);
    sigaction(SIGINT,&act,nullptr);

    while(1);

    return 0;

}

以上的代码当中就是sigaction将2号信号进行自定义的捕捉,并且在2号信号在进行处理的时候让20号信号阻塞等待,并且让进程接收到2号信号之后让进程循环的打印当前进程的peding位图表,那么接下来再该进程发送20号信号也就是使用键盘使用Ctrl+z 发送,接下来当得到对应的信号之后观察peding位图是否会修改,且观察当前的进程是否会被杀掉来检测20号信号是否被递达。

运行程序效果如下所示:

通过以上的效果就可以看出确实在接受到对应的20号信号之后会将Peding位图修改,但是不会将该进程“杀掉”。

6.2 SIGCHLD信号

之前在学习进程相关的概念的时候我们就已经了解了创建子子进程之后,若子进程先退出那么子进程就会变为僵尸进程,只有父进程进行进程等待之后将子进程进行回收才不会造成内存泄漏,并且我们在了进程等待的时候还知道了进程等待的方式有阻塞等待和非阻塞等待。

父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的⽅式)。采用第⼀种方式,父进程阻塞了就不 能处理自己的工作了;采⽤第⼆种方式,父进程在处理自己的工作的同时还要记得时不时地轮询⼀ 下,程序实现复杂。

实际上在子进程退出的时候是会发送SIGCHLD信号的,那么这时就可以对该信号进行自定义捕捉,在对应的捕捉函数当中实现对子进程的等待,这样就可以让父进程不再需要阻塞的等待而转为非阻塞的等待

例如以下的示例:

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include<sys/wait.h>
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;
}

在以上的代码当中就使用fork创建出对应的子进程之后,接下来父进程继续的执行,子进程在运行3秒之后停止了,接下来子进程就会向父进程发送对应的SIGCHLD信号,由于我们对该信号进行了自定义的捕捉,那么接下来就可以在对应的捕捉方法handler当中执行对子进程的等待,那么这样就可以实现父进程非阻塞式的将子进程从僵尸进程状态释放。

但是在Linux当中实际上还提供了使用sigaction将SIGCHLD处理动作修改为SIG_IGN,那么这时子进程就不会再在退出的时候将对应的退出信息保存到其task_struct当中并且不会将子进程退出之后进入到僵尸状态,这时父进程就不需要在进程子进程的进程等待。

例如以下示例:


#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/wait.h>

int main()
{

    struct sigaction act;
    sigemptyset(&act.sa_mask);
    act.sa_handler = SIG_IGN;
    sigaction(SIGINT, &act, nullptr);
    pid_t cid;
    if ((cid = fork()) == 0)
    { // child
        printf("child : running!\n");
        sleep(3);
        printf("child : exit!\n");
        exit(1);
    }
    else
    {
        printf("parent : running!\n");
        sleep(3);
        printf("parent : exit!\n");
        exit(1);
    }

    return 0;
}

以上代码当中就使用sigaction将SIGCHLD的处理动作修改为SIG_IGN,那么这时机不再需要再对子进程进行进程等待。
 

以上就是本篇的所有内容,感谢你的观看


网站公告

今日签到

点亮在社区的每一天
去签到