在前面的三篇文章中,我们已经依次介绍了:

  • 信号的概念
  • 信号的产生
  • 信号的保存(block / pending / sigset_t)

到这里我们已经知道,信号从产生到递达之间,会被进程暂时保存起来,并不会立即被处理,而是在“合适的时候”处理。

那么这个“合适的时候”究竟是在什么时候?

先给出结论:这个“合适的时候”并不是由进程自己决定的,而是由内核决定的。

本文将带你从内核执行角度,理解信号处理到底发生在什么时候,以及内核是如何完成这一过程的。

1 信号捕捉

1.1 信号捕捉的概念

信号到达进程后,其处理方式主要有三种:

  • 默认处理(Default)
  • 忽略处理(Ignore)
  • 自定义处理(Catch)

其中,默认处理和忽略处理都是系统预定义行为,而自定义处理则是由用户通过注册信号处理函数来完成的。

在Linux中,当进程为某个信号设置了自定义处理函数时,这种行为称为信号捕捉(signal catching)

信号捕捉的本质是修改该信号的默认处理动作。

1.2 信号捕捉的流程

如果信号的处理动作是默认处理,那么执行完第 4 步的时候,会继续在内核态执行该信号的默认处理方法,然后恢复被打断的用户上下午,返回用户态继续执行被打断的位置。

如果信号的处理动作是忽略,那么执行完第 4 步的时候,会直接恢复被打断的用户上下午,返回用户态继续执行被打断的位置。

至此,我们能够理解 “合适的时候” 是指内核态返回用户态的时候

那么新的问题接踵而至,我们的进程是如何陷入内核态呢?

这个问题将在操作系统是怎么运行的小节中为你揭晓。

信号捕捉流程的简化理解

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

一张简略图描述信号捕捉的全流程

1.3 操作系统是怎么运行的

1.3.1 硬件中断

这张图描述的是:外设触发中断后,CPU 中断进程运行,进入内核处理中断,最终回到用户态继续运行的全过程

让我们逐层分析一下这张图

外设层(左边)

外设设备 + 中断控制器

外设在发生事件时,会通过中断控制器向 CPU 发送中断请求。

该过程发生在硬件层面:外设通过中断线或 MSI/MSI-X 机制向中断控制器发送中断请求。中断控制器对中断进行仲裁与编号,生成对应的中断号,并通知 CPU 进入中断响应流程。

例如:键盘按键、网卡接收数据、定时器到期等事件,都会触发相应的硬件中断。

内核层(右边)

中断控制器通知 CPU 之后,CPU 依次做三件事情。

  • 保存寄存器现场(图中 CPU寄存器)
  • 暂停用户态程序
  • 切换到内核态

CPU 不再继续执行用户态的程序,而是被强制打断进入内核处理流程。

CPU 切换到内核态之后,CPU 获取中断控制器的中断号,根据中断号在中断向量表中找对应的中断处理函数入口,执行中断处理函数,内核处理完事件后,恢复用户态程序上下午数据,切换到用户态继续执行。

补充:

中断向量表就是操作系统内置的,启动操作系统的时候就加载到内存了。

通过外部硬件中断,操作系统就不需要对外设进行任何周期性的检测或者轮询。

由外部设备触发,中断系统运行流程被称作硬件中断。

由此可以引出,信号实现思想:硬件中断。本质是用软件来模拟硬件中断的。

发中断 ---- 发信号

保存中断号 ---- 保存信号

处理中断 ---- 处理信号

思考:

当没有中断到来的时候,操作系统在做什么?

操作系统什么也没有做,一直处于暂停状态,等待中断的唤醒。

操作系统的本质就是基于中断进行运行的软件。

1.3.2 时钟中断

基于对硬件中断的认识,我们该如何理解时钟中断呢?让我们迈入本小节的内容。

在进程调度的章节中,我们知道操作系统是基于时间片来完成进程调度的。

那问题来了:

进程可以在操作系统的指挥下,被调度、被执行,而操作系统本身“并不会主动运行”,那么操作系统是如何“定期被唤醒”的呢?

外部设备确实可以触发硬件中断,但这类中断依赖外部事件驱动,例如键盘输入、磁盘IO等,它们的发生时间是不确定的。

那么问题进一步升级:

有没有一种不依赖外部设备、可以周期性触发的中断源?

在早期的计算机体系结构中,时钟中断是由独立的定时硬件外设实现的,例如可编程定时器(PIT)。

这种设备的特点是:

  • 由晶振提供稳定频率
  • 可以配置“每隔固定时间产生一次中断”
  • 通过中断控制器向 CPU 发送中断请求

也就是说:

时钟中断本质上也是一种“外设中断”,只是它是周期性主动触发的外设。

随着 CPU 体系的发展,现代处理器已经将定时器功能直接集成到 CPU 内部,例如:

  • Local APIC Timer(x86)
  • ARM Generic Timer

此时:

  • 不再依赖外部芯片
  • 时钟源直接由 CPU 内部寄存器和计数器实现
  • 到达时间后直接在 CPU 内部触发中断信号

因此可以理解为:

时钟中断已经从“外设驱动的中断”,演化为“CPU 内部自带的周期性中断源”

时钟中断本质上仍然属于硬件中断,只不过它从“外部定时器设备”演化为“CPU内部集成的定时中断机制”。

正是依赖时钟中断,操作系统才能周期性获得执行机会,从而实现时间片轮转调度机制。

1.3.3 死循环

操作系统的本质就是一个死循环,基于中断运行的软件,当中断到来,根据中断向量表查询中断处理方法进行执行。

如果需要添加什么功能,只需要向中断向量表中添加对应中断号的方法即可。

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

1.3.4 软中断

上面我们所讲的中断都是基于硬件设备触发的中断。

有没有可能由于软件原因,也能触发上面的逻辑? 肯定有

操作系统的设计者,为了让操作系统支持进行系统调用,CPU也设计了对应的汇编指令(int 或者 syscall),可以让CPU内部触发中断逻辑。

问题:

用户层怎么把系统调用号给操作系统的?

用户态并不会直接“调用内核函数”,而是将系统调用号和参数放入 CPU 规定的寄存器中,然后通过 syscall 指令陷入内核态。CPU 进入内核后,从寄存器中读取系统调用号,并通过系统调用表 sys_call_table 找到对应的内核处理函数执行。

操作系统怎么把返回值给用户?

内核在执行完系统调用函数后,把返回值放入特定寄存器,然后通过 sysret / iretq 返回用户态,用户程序从寄存器中读取返回值。

系统调用的过程,其实就是先int 0x80、syscall陷入内核,本质就是触发软中断,CPU就会自动执
行系统调用的处理方法,而这个方法会根据系统调用号,自动查表,执行对应的方法。

可是为什么我们用的系统调用,从来没有见过什么int 0x80 或者syscall 呢?都是直接调用上层的函数的啊?
那是因为Linux的gnu C标准库,给我们把几乎所有的系统调用全部封装了。​

系统调用号的本质:数组下标

1.3.5 如何理解内核态和用户态

在前面的内容中,我们不断提到“进入内核态”“返回用户态”“在内核中处理信号”等概念。

但这些说法如果没有一个统一的理解框架,很容易变成“机械记忆”。

因此我们有必要先从本质上理解:

用户态与内核态到底是什么?它们的区别是什么?操作系统为什么要这样设计?

对于用户态和内核态,很多人容易误解:

用户态 = 用户程序  内核态 = 内核程序

这样理解是不对的,它们的本质是:

CPU 的两种权限级别

在x86 架构中:

  • 用户态:CPL = 3
  • 内核态:CPL = 0

用户态做什么?

用户态运行的是:

  • 普通应用程序
  • 例如 shell / vim / your program

特点:

  • 不能访问硬件
  • 不能访问内核数据结构
  • 不能直接执行特权指令

👉 例如不能:

  • 直接读磁盘
  • 直接操作网卡
  • 直接修改进程调度

用户态的本质是只能使用虚拟地址空间上的[0, 3GB]的虚拟地址,不能使用[3GB, 4GB]的虚拟地址。

内核态做什么?

内核态运行的是:

  • 操作系统内核代码

负责:

  • 进程调度
  • 内存管理
  • 文件系统
  • 网络协议栈
  • 信号处理

👉 本质一句话:

内核态 = 系统资源的唯一管理者

内核态的本质是运行的是虚拟地址空间上的[0, 4GB]的虚拟地址。

为什么必须分层?

如果没有用户态/内核态:

  • 任意程序可以直接操作硬件
  • 任意程序可以修改内存
  • 系统完全不可控

所以操作系统通过 CPU 提供的特权级机制实现:

隔离 + 保护 + 统一管理

总结

用户态与内核态的本质区别,并不是“代码运行在哪”,而是 CPU 是否具备访问系统资源的权限。

操作系统通过特权级隔离,实现了“用户程序不能直接控制硬件,但可以通过内核间接使用系统资源”的结构。

而信号机制、系统调用、中断机制,本质上都是在这套权限体系下完成的不同入口路径。

在前面的内容中,我们为了说明“用户态为什么会进入内核态”,引入了硬件中断、异常、时钟中断以及系统调用等机制,并从操作系统的运行方式角度进行了分析。

这些内容虽然看似分散,但实际上都在回答同一个问题:

进程在什么时机从用户态进入内核态?

然而在信号机制中,这些内容只是“背景条件”,而不是信号本身。

1.4 sigaction

#include <signal.h>

int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
struct sigaction {
    void     (*sa_handler)(int);
    void     (*sa_sigaction)(int, siginfo_t *, void *);
    sigset_t   sa_mask;
    int        sa_flags;
    void     (*sa_restorer)(void);
};

功能:更改进程对指定信号的处理动作,使其不调用默认处理,而是调用用户自己写的处理方法

比 signal() 更标志、更强大。

参数说明:

signum:信号编号

act:新的处理方式

sa_handler:信号处理函数

sa_mask:在执行 handler 时,阻塞某些信号 -> 防止在进行信号处理时,由于中断陷入内核,执行其他信号处理方法。

这里不再介绍另外三个参数。

oldact:输出型参数,保存之前的信号处理方式

返回值:成功返回 0, 失败返回 -1

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

基本用法:

struct sigaction sa;
sa.sa_handler = handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;

sigaction(SIGINT, &sa, NULL);

2 可重入函数与不可重入函数

样例:

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

像上例这样, insert 函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入, insert 函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入函数

可重入函数的特点

一个函数如果满足下面条件,通常就是可重入的:

1. 不使用全局/静态变量

所有状态都来自参数或栈上局部变量

 2. 不修改共享资源

比如不写文件、不操作全局结构

 3. 不调用不可重入函数

 4. 只使用局部变量(栈空间)

对于上面的样例很不合理,但对于可重入函数与不可重入函数,我们在线程中依旧会见到。

先给出结论:可重入函数一定线程安全 不可重入函数可能线程不安全,需要用锁的方式进行保护

3 volatile

3.1 volatile 是什么?

volatile 是 C/C++ 中一个非常容易被误解,但在操作系统/嵌入式/信号处理中很重要的关键字

volatile 作用:告诉编译器,这个变量可能在“程序之外”被改变,不要优化它的访问

3.2 为什么需要 volatile

编译器为了优化,会做一件事情:经过编译器检测,把某些变量优化到寄存器中,程序对该变量的访问不是从物理内存中取数据,而是在寄存器中取数据,如果物理内存和寄存器中数据不一致,就会导致程序出现问题。

样例:

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

int flag = 1;

void handler(int signum)
{
    flag = 0;
}

int main()
{

    signal(SIGINT, handler);

    while(flag)
    {
        printf("##########\n");
        sleep(1);
    }

    return 0;
}
[lt@iZ2zebg189x52z3hbfc0t4Z code]$ gcc sig.c -o sig 
[lt@iZ2zebg189x52z3hbfc0t4Z code]$ ./sig
##########
##########
##########
^C[lt@iZ2zebg189x52z3hbfc0t4Z code]$
[lt@iZ2zebg189x52z3hbfc0t4Z code]$ g++ test.cpp -o test -O1
[lt@iZ2zebg189x52z3hbfc0t4Z code]$ ./test 
########
########
########
########
^C########
########

优化情况下,按下 Ctrl + C ,2号信号被捕捉,执行自定义动作,修改 flag=0 ,但是 while 条
件依旧满足,进程继续运行!

原因:while循环检查的 flag ,并不是物理内存中最新的 flag,而是 CPU 寄存器存放的过期 flag。

解决办法:volatile int flag = 1;

意思:每次使用 flag,都必须从内存中重新获取。

3.3 volatile 不能解决的问题

不能保证原子性

不能解决并发竞争

不能替代锁

不能保证指令顺序

本质是因为 volatile 只是禁止编译器优化,不是解决并发问题。

4 SIGCHLD 信号

在进程控制章节中,我们讲过用 wait 和 waitpid 函数回收子进程,其中父进程可以阻塞等待子进程结束,也可以非阻塞轮询查看是否有子进程结束等待回收。采用第一种方式,父进程阻塞等待就不能处理自己的工作了,采用第二种方式,父进程在处理自己的工作的同时还要时不时地轮询一下,程序实现相当复杂。

其实子进程在终止时会给父进程发 SIGCHLD 信号,该信号的默认处理动作是忽略,父进程可以自定义 SIGCHLD 信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用 wait 清理子进程即可。

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

void handler(int signo)
{
    while (waitpid(-1, nullptr, WNOHANG) > 0)
    {
        // 回收所有已退出子进程
    }
}

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

    if (fork() == 0)
    {
        sleep(2);
        _exit(0);
    }

    while (1)
    {
        sleep(1);
    }
}

事实上,由于 UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用 sigaction 将SIGCHLD 的处理动作置为 SIG_IGN ,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。

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

int main()
{
    // 关键点:忽略 SIGCHLD
    struct sigaction sa;
    sa.sa_handler = SIG_IGN;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;

    sigaction(SIGCHLD, &sa, nullptr);

    pid_t pid = fork();

    if (pid == 0)
    {
        // 子进程
        std::cout << "child running: " << getpid() << std::endl;
        sleep(1);
        std::cout << "child exit\n";
        _exit(0);
    }
    else if (pid > 0)
    {
        std::cout << "parent: " << getpid() << std::endl;

        // 父进程不 wait,直接睡很久
        sleep(10);

        std::cout << "parent exit\n";
    }

    return 0;
}

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐