【Linux】进程信号(4)_信号捕捉_内核态与用户态


文章目录


一、如何理解内核态和用户态

  1. 虚拟地址空间总体可以分为两个部分,在 32 位 Linux 经典布局下是3GB 用户空间 + 1GB 内核空间。进程执行用户代码、访问用户空间时处于用户态;进入中断 / 异常 / 系统调用、执行内核代码、访问内核空间时切换为内核态,内核态拥有更高的硬件执行权限。

  2. 每个进程拥有独立的页表,这张页表逻辑上可看作两部分:高地址的内核页表区域与低地址的用户页表区域。内核页表区域统一映射操作系统内核的代码、数据及物理内存空间;用户页表区域则映射该进程自身的代码、数据、栈、动态库等用户态资源。

  3. 所有进程共享物理内存中同一份操作系统内核及其内核数据,这份内核通过固定的虚拟地址布局,映射到每一个进程虚拟地址空间的内核空间部分,因此所有进程看到的内核虚拟地址与物理内存的映射关系完全一致。而每个进程都拥有一套独立的页表,其中用户空间的映射各自不同,内核空间的映射则完全相同。所以无论进程如何切换,新进程都能直接找到操作系统,因为物理内存上始终只有一份内核,只是被所有进程的虚拟地址空间共同映射。

  4. 进程如何判断自身处于内核态还是用户态?核心取决于 x86 架构下的当前特权级(CPL,Current Privilege Level):CPL 值为0(二进制 00)时表示内核态(最高特权级),值为3(二进制 11)时表示用户态(最低特权级)

  5. 进程所有的函数调用都在自身的虚拟地址空间内完成,无非是根据调用目标的权限与类型,在虚拟地址空间的不同区域(用户空间 / 内核空间)完成跳转;其中用户态函数调用依托进程独立的用户页表与用户栈,在用户空间内完成;内核态函数调用(如系统调用对应的内核服务)则切换至内核栈、依托所有进程共享的内核页表区域完成跳转。

二、sigaction接口


  1. sigaction 接口的功能与 signal 类似,都可以用于捕捉信号、修改信号的处理方式,但 sigaction 功能更强大、行为更稳定。它的第一个参数是要修改处理方式的信号编号,第二个参数需要传入一个 struct sigaction 结构体变量(作为输入参数,用于设置新的信号处理动作),这个结构体成员如图所示,我们重点关注它的成员变量 sa_mask 即可该结构体的第一个成员变量用于指定信号的处理方式,而 sa_mask 表示在执行当前信号处理函数期间,需要额外阻塞的信号集合。函数的第三个参数是输出型参数,用于保存该信号原先的处理方式,可传入 NULL 表示不关心原有配置。
  1. 默认 OS 在执行信号对应的 handler 处理方法时,会先将 pending 表中该信号对应的比特位由 1 置 0,同时将 block 表中该信号对应的比特位设为 1(阻塞当前信号);等 handler 处理完毕后,再把 block 表中该信号对应的比特位恢复为 0。这么做是为了防止在 OS 执行当前 handler 的过程中,进程再次收到同一个信号,导致 handler 函数被重复、递归调用
  2. 而这个 sa_mask 成员变量的作用,就是在执行当前信号的 handler 处理方法时,除了系统自动屏蔽当前信号之外,额外还需要屏蔽哪些其他信号
  3. 下面通过代码进行试验:
cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>

void handler(int signo)
{
    std::cout << "捕捉一个信号:" << signo << std::endl;
    sigset_t pending;
    while(true)
    {
        sigpending(&pending);
        for (int i = 31; i > 0; i--)
        {
            if (sigismember(&pending, i))
                std::cout << 1;
            else
                std::cout << 0;
        }
        sleep(1);
        std::cout << std::endl;
    }
}
int main()
{
    struct sigaction act, oact;
    act.sa_handler = handler;
    sigemptyset(&(act.sa_mask));
    sigaddset(&(act.sa_mask), 3);
    sigaddset(&(act.sa_mask), 4);
    sigaddset(&(act.sa_mask), 5);

    act.sa_flags = 0;
    act.sa_restorer = nullptr;

    sigaction(SIGINT, &act, &oact);

    while (1)
    {
        std::cout << "进程正在运行..." << std::endl;\
        sleep(1);
    }

    return 0;
}

三、可重入函数

  1. 看上图,我们在执行链表节点 node1 插入操作时,如果插入函数还没有执行完毕,进程又收到信号并跳转去执行信号处理函数,再次调用同一个插入函数插入 node2,就会导致函数被重入调用。由于两次操作共用同一片全局资源或指针,这样就会破坏数据结构,最终导致 node2 节点无法被正常访问,从而造成内存泄漏

  2. 像这种不允许在执行过程中被重复调用、一旦重入就会导致数据错乱或逻辑异常的函数,叫做不可重入函数,例如图中的节点插入函数;而可以安全地被重复调用、不会出现数据异常的函数,叫做可重入函数

  3. 如果一个函数符合下列条件之一就是不可重入的:

  1. 若函数内部调用了 malloc 或 free,则该函数为不可重入函数,因为 malloc 底层采用全局链表管理堆内存空间,重入调用会破坏链表结构。
  2. 若函数调用了标准 I/O 库函数,该函数也属于不可重入函数,因为标准 I/O 库的底层实现,大多会以不可重入的方式使用全局缓冲区等共享数据结构。

四、volatile关键字

cpp 复制代码
#include <stdio.h>
#include <signal.h>

int flag = 0;
//volatile int flag = 0;
void change(int signo)
{
    flag = 1;
    printf("change flag : 0 -> 1\n");
}
int main()
{
    signal(2, change);

    while(!flag);
    printf("进程正常退出\n");
    return 0;
}
  1. 上面这段代码如果编译器不开启优化,进程收到 2 号信号 (SIGINT) 时,信号处理函数会将全局变量 flag 从 0 修改为 1,主函数的 while 循环会检测到变量变化并退出,进程正常结束;但如果编译器开启优化,编译器会将 flag 变量缓存到 CPU 寄存器中,循环只会读取寄存器的值。虽然信号处理函数修改了物理内存中的 flag,但 CPU 始终读取寄存器中未改变的旧值,最终导致循环无限执行,进程无法退出
  2. 如果在 flag 变量定义的时候前面加上 volatile,就可以保持内存的可见性,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须要在真实的物理内存里面进行

今天的分享就到此结束啦,如果对读者朋友们有所帮助的话,可否留下宝贵的三连呢~~
让我们共同努力, 一起走下去!

相关推荐
大树882 小时前
金刚石散热越强,管路越先见顶
大数据·运维·服务器·人工智能·ai
摇滚侠2 小时前
Linux CentOS7 rpm 安装 MySQL 5.7
linux·运维·mysql
霸道流氓气质2 小时前
领域驱动设计(DDD)在 Spring Boot 微服务中的实践指南
运维·spring boot·微服务
bush42 小时前
嵌入式linux学习记录十四、术语
linux·嵌入式
载数而行5203 小时前
Linux 11 动态监控指令top
linux
小宇宙Zz3 小时前
Maven依赖冲突
java·服务器·maven
Inhand陈工3 小时前
基于台达PLC与映翰通IG502的智慧水产养殖精准投喂与远程运维解决方案
运维·人工智能·物联网·阿里云·信息与通信
酣大智4 小时前
ARP代理--工作原理
运维·网络·arp·arp代理
不会C语言的男孩4 小时前
Linux 系统编程 · 第 8 章:进程基础
linux·c语言
shushangyun_4 小时前
2026年快消品B2B系统推荐:支持终端门店订货、促销政策自动化的工具?
java·运维·网络·数据库·人工智能·spring·自动化