【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,就可以保持内存的可见性,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须要在真实的物理内存里面进行

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

相关推荐
小王C语言2 小时前
【Ext系列文件系统】————磁盘、分盘/分区/分组、软硬连接
运维
supersolon2 小时前
PVE9安装32位爱快路由(ikuai)
linux·运维·网络
123过去2 小时前
mfterm使用教程
linux·网络·测试工具·安全
深圳市恒讯科技2 小时前
OpenClaw 2026安全指南
运维·服务器·安全
海兰2 小时前
使用 TypeScript 创建 Elasticsearch MCP 服务器
服务器·elasticsearch·typescript·mcp
学编程的小程2 小时前
我的极空间 NAS 进阶玩法:开启 SSH,解锁私有云服务器新体验
运维·服务器·ssh
123过去2 小时前
nfc-mfclassic使用教程
linux·网络·测试工具·安全
深念Y2 小时前
飞牛OS部署MCSM搭建MC服务器完整教程
运维·服务器·jdk·端口·nas·mc·飞牛os
JACK的服务器笔记2 小时前
《服务器测试百日学习计划——Day14:BMC基础与健康状态,为什么服务器排障不能只看OS》
运维·服务器·学习