当前位置: 首页 > article >正文

Linux信号的处理

目录

一、信号处理概述:为什么需要“信号”?

二、用户空间与内核空间:进程的“双重人格”

三、内核态与用户态:权限的“安全锁”

四、信号捕捉的内核级实现:层层“安检”

五、sigaction函数:精细控制信号行为

1. 函数原型

2. 关键结构体

3. 示例代码:动态修改信号处理

六、可重入函数

1. 问题场景

2. 问题分析

3. 原因解释

4. 不可重入函数与可重入函数

5. 如何避免重入问题

七、volatile

1. 问题引入

2. 未使用volatile的情况

3. 使用volatile解决问题

4. volatile的作用总结


一、信号处理概述:为什么需要“信号”?

        想象你在办公室工作时,突然有人敲门提醒你快递到了。这里的“敲门”就像操作系统发给进程的信号。信号是操作系统通知进程某个事件发生的机制,例如:

  • Ctrl+C 发送 SIGINT 信号终止进程

  • 程序崩溃时内核发送 SIGSEGV 信号

  • 用户自定义信号处理逻辑(如保存日志)

        但进程不会立即处理信号,而是在“合适的时候”——比如从内核态切换回用户态时。这背后隐藏着操作系统的核心设计逻辑。


二、用户空间与内核空间:进程的“双重人格”

每个进程的地址空间分为两部分:

用户空间内核空间
存储进程私有代码和数据存储操作系统全局代码和数据
通过用户级页表映射物理内存通过内核级页表映射物理内存
每个进程看到的内容不同所有进程看到的内容相同

// 示例:用户空间的变量
int user_data = 100; 

// 内核空间的代码(进程无权直接访问)
void kernel_code() 
{
    // 管理硬件资源 
}

关键点

  • 用户态代码无法直接访问内核空间(权限不足)

  • 执行系统调用(如printf)时,进程会陷入内核,切换到内核态


三、内核态与用户态:权限的“安全锁”

用户态内核态
权限等级低(普通用户代码)高(操作系统代码)
操作限制无法直接访问硬件可执行任何指令
触发场景执行普通代码系统调用、中断、异常

状态切换示例

printf("Hello");  // 用户态 -> 内核态(执行write系统调用) -> 用户态

具体步骤

1、用户态:调用printf

  • printf是C标准库函数,负责格式化字符串(如将"Hello"转换为字符流)。
  • 若输出到终端(如屏幕),最终会调用**系统调用write**将数据写入文件描述符(如标准输出stdout)。

2、触发系统调用write

系统调用是用户程序请求操作系统服务的唯一入口。

write的函数签名:

ssize_t write(int fd, const void *buf, size_t count);

其中fd=1表示标准输出,buf指向数据缓冲区,count为数据长度。

3、从用户态陷入内核态

  • CPU执行特殊的陷入指令(如syscallint 0x80),触发软中断。
  • 硬件自动切换特权级:用户态(ring 3)→ 内核态(ring 0)。
  • 跳转到内核中预定义的系统调用处理函数(如sys_write)。

4、内核态:执行sys_write

  • 操作系统验证参数合法性(如fd是否有效)。
  • 将用户空间的数据("Hello")从缓冲区复制到内核空间(防止用户篡改)。
  • 调用设备驱动,将数据发送到终端(如控制台、SSH会话)。
  • 记录返回结果(成功写入的字节数或错误码)。

5、返回用户态

  • 内核恢复用户程序的寄存器状态和堆栈。
  • CPU特权级切换回用户态(ring 3)。
  • 用户程序继续执行printf之后的代码。

🌴 为什么需要切换特权态?

  • 用户态的限制
    用户程序无法直接访问硬件(如磁盘、网卡)或修改关键数据结构(如进程表)。
    例:若允许用户程序直接写磁盘,恶意程序可能覆盖系统文件。

  • 内核态的权限
    操作系统代码拥有最高权限,可安全管理硬件和资源。
    通过系统调用“代理”用户程序的请求,确保所有操作受控。


四、信号捕捉的内核级实现:层层“安检”

        在计算机系统里,程序运行时可能会遇到一些特殊情况,比如用户按下某些按键或者系统出现了问题,这时候就需要程序能够及时做出反应。这种反应机制在Linux系统中是通过“信号”来实现的。信号就像是一个信使,负责把发生的事件告诉程序。

        现在,假设一个程序正在运行它的主函数(main函数),就好比一个人正在按照计划做一件大事。突然,某个特定的事件发生了,比如用户按下了一个特殊的按键组合(这会触发SIGQUIT信号)。这时候,系统会暂时中断这个人的工作,切换到一个专门处理这种情况的模式,也就是“内核态”,由操作系统来处理这个事件。

        操作系统在处理完这个事件后,准备回到原来的程序继续工作之前,会检查有没有需要特别处理的信号。如果发现有SIGQUIT信号,而且这个程序之前已经告诉过操作系统,当这个信号出现时要按照它自己定义的方式来处理(也就是注册了一个信号处理函数sighandler),那么操作系统就会安排一个特殊的操作。

        这个操作就是:不是直接回到原来的主函数继续做之前的事情,而是先去执行那个专门定义的处理函数sighandler。这就好比在你做一件大事的时候,突然有紧急情况需要你先去处理一下,处理完了再回来继续做原来的事。

        需要注意的是,这个处理函数sighandler和原来的主函数(main函数)是两个完全独立的任务,它们就像两条平行的路,没有直接的调用关系。sighandler有自己的工作空间(不同的堆栈空间)来完成它的任务。

        当处理函数sighandler完成自己的任务后,它会触发一个特殊的指令(sigreturn系统调用),再次回到操作系统那里。操作系统会检查是否还有其他的紧急情况需要处理。如果没有,就会回到原来的主函数,恢复之前的状态,继续完成未做完的事情。

当进程从内核态返回用户态时,会检查未决信号集(pending)

  1. 检查信号状态

    • 若信号未被阻塞(block),且处理动作为默认忽略
      → 立即处理(如终止进程)并清除pending标志

    • 若处理动作为自定义
      → 先返回用户态执行处理函数,再通过sigreturn回到内核

  2. 执行自定义处理函数的关键步骤

    • 内核不信任用户代码:必须返回用户态执行处理函数

    • 处理函数与主流程独立(不同堆栈,无调用关系)


五、sigaction函数:精细控制信号行为

1. 函数原型

#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oldact);

参数说明:

  • signo :指定信号的编号(如SIGINT)。

  • act :新的处理动作

  • oldact :保存旧的处理动作

2. 关键结构体

结构体 sigaction 的定义如下:

struct sigaction 
{
    void     (*sa_handler)(int);          // 信号处理函数
    sigset_t   sa_mask;                   // 额外屏蔽的信号
    int        sa_flags;                  // 控制选项(通常设为0)
    // 其他字段(如sa_sigaction)暂不讨论
};

3. 示例代码:动态修改信号处理

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

struct sigaction act, oact;

// 自定义信号处理函数
void handler(int signo)
{
    printf("捕获信号: %d\n", signo);
    // 恢复为默认处理方式(仅第一次捕获时自定义)
    sigaction(SIGINT, &oact, NULL);
}

int main()
{
    memset(&act, 0, sizeof(act));
    memset(&oact, 0, sizeof(oact));

    act.sa_handler = handler;  // 设置自定义处理函数
    act.sa_flags = 0;          // 无特殊标志
    sigemptyset(&act.sa_mask); // 不额外屏蔽其他信号

    // 注册SIGINT信号(Ctrl+C触发)
    sigaction(SIGINT, &act, &oact);

    while (1)
    {
        printf("程序运行中...\n");
        sleep(1);
    }
    return 0;
}

运行效果

  1. 第一次按下Ctrl+C → 打印“捕获信号: 2”

  2. 再次按下Ctrl+C → 进程终止(已恢复默认行为)


六、可重入函数

1. 问题场景

        假设我们有一个简单的链表结构,定义了两个节点 node1 和 node2,以及一个头指针 head。在 main 函数中,我们调用 insert 函数将 node1 插入到链表中。insert 函数的实现分为两步:首先将新节点的 next 指针指向当前头节点,然后更新头指针为新节点。

node_t node1, node2, *head;

void insert(node_t *p) {
    p->next = head; // 第一步:将新节点的 next 指针指向当前头节点
    head = p;       // 第二步:更新头指针为新节点
}

int main() {
    // ... 其他代码 ...
    insert(&node1); // 在 main 函数中插入 node1
    // ... 其他代码 ...
}

        在插入 node1 的过程中,假设刚执行完第一步(p->next = head),此时发生了硬件中断,导致进程切换到内核态。在内核态处理完中断后,检查到有信号待处理,于是切换到信号处理函数 sighandler。sighandler 同样调用 insert 函数,试图将 node2 插入到同一个链表中。

void sighandler(int signo) {
    // ... 其他代码 ...
    insert(&node2); // 在信号处理函数中插入 node2
    // ... 其他代码 ...
}

        当 sighandler 完成插入 node2 的操作并返回内核态后,再次回到用户态,继续执行 main 函数中被中断的 insert 函数的第二步(head = p)。

2. 问题分析

        理想情况下,我们希望 main 函数和 sighandler 分别将 node1 和 node2 插入到链表中,最终链表包含两个节点。然而,实际情况却并非如此。

  1. main 函数插入 node1 的第一步 :将 node1 的 next 指针指向当前头节点(初始时 head 为 NULL),此时 node1->next = NULL。

  2. 中断发生,切换到内核态 :main 函数的 insert 操作被中断,此时 head 还未更新为 node1。

  3. sighandler 插入 node2 :在信号处理函数中,执行 insert(&node2)。此时 head 仍为 NULL,所以 node2->next = NULL,然后 head 被更新为 node2。

  4. 返回 main 函数继续执行 :执行 insert 函数的第二步,将 head 更新为 node1。

        最终,链表的头指针 head 指向 node1,而 node1 的 next 指针为 NULL。node2 被插入后又被覆盖,实际上没有真正加入链表。

3. 原因解释

        这个问题的根源在于 insert 函数被不同的控制流程(main 函数和 sighandler)调用,且在第一次调用还未完成时就再次进入该函数。这种现象称为“重入”(Reentrant)。insert 函数访问了一个全局链表 head,由于全局变量在多个控制流程之间共享,导致数据不一致。

4. 不可重入函数与可重入函数

  1. 不可重入函数 :如果一个函数在被调用过程中,其内部操作依赖于全局变量或共享资源,并且在函数执行过程中这些资源可能被其他调用者修改,那么这个函数就是不可重入的。像上面的 insert 函数,因为它操作了全局链表 head,所以在重入情况下容易出错。

  2. 可重入函数 :如果一个函数只访问自己的局部变量或参数,不依赖于全局变量或共享资源,那么它就是可重入的。可重入函数在不同控制流程中被调用时,不会相互干扰。

5. 如何避免重入问题

  1. 避免使用全局变量 :尽量使用局部变量,或者通过参数传递必要的数据。

  2. 使用互斥机制 :在多线程或信号处理场景中,使用互斥锁(如 mutex)来保护共享资源的访问。

  3. 设计可重入函数 :确保函数只依赖于参数和局部变量,不依赖于外部环境。


七、volatile

        在C语言中,volatile 是一个经常被提及但又容易被误解的关键字。今天,我们通过一个具体的信号处理例子,来深入理解 volatile 的作用。

1. 问题引入

考虑以下代码:

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

int flag = 0;

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

int main() {
    signal(2, handler);
    while (!flag);
    printf("process quit normal\n");
    return 0;
}

        该程序的功能是:在接收到 SIGINT 信号(如用户按下 Ctrl+C)时,执行自定义信号处理函数 handler,将全局变量 flag 设置为 1,从而退出 while 循环,程序正常结束。

2. 未使用volatile的情况

        在未使用 volatile 修饰 flag 的情况下,编译器可能会对代码进行优化。例如,当使用 -O2(大写字母O) 优化选项编译时,编译器可能会认为 flag 的值在 while 循环中不会被改变(因为从代码的静态分析来看,没有明显的修改操作),于是将 flag 的值缓存到 CPU 寄存器中,而不是每次都从内存中读取。

        这就会导致一个问题:当信号处理函数 handler 修改了 flag 的值时,while 循环中的条件判断仍然使用寄存器中的旧值,无法及时检测到 flag 的变化,程序无法正常退出。这种现象被称为“数据不一致性”或“内存可见性”问题。

3. 使用volatile解决问题

为了解决上述问题,我们需要使用 volatile 关键字修饰 flag 变量:

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

volatile int flag = 0;

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

int main() {
    signal(2, handler);
    while (!flag);
    printf("process quit normal\n");
    return 0;
}

   volatile 告诉编译器,该变量的值可能会被程序之外的其他因素(如信号处理函数、硬件中断等)改变,因此编译器在优化时不会假设该变量的值不变。每次访问 volatile 修饰的变量时,编译器都会生成代码从内存中重新读取该变量的值,而不是使用寄存器中的缓存值。

        这样,在信号处理函数修改了 flag 的值后,while 循环中的条件判断能够及时检测到变化,程序可以正常退出。

4. volatile的作用总结

volatile 的主要作用是保持内存的可见性,确保程序能够正确地读取和写入变量的最新值。在以下场景中,使用 volatile 是必要的:

  1. 信号处理 :当变量可能被信号处理函数修改时,需要使用 volatile 修饰,以确保主程序能够及时检测到变量的变化。

  2. 多线程编程 :在多线程环境中,当变量可能被其他线程修改时,volatile 可以防止编译器优化导致的内存可见性问题。不过,需要注意的是,volatile 并不能完全替代互斥锁等同步机制,因为它不能保证操作的原子性。

  3. 硬件寄存器访问 :当程序需要直接访问硬件寄存器时,这些寄存器的值可能会被硬件异步修改,因此需要使用 volatile 修饰相关的指针或变量。


http://www.kler.cn/a/593162.html

相关文章:

  • 【蓝桥杯速成】| 7.01背包练习生
  • 第5章:Docker镜像管理实战:构建、推送与版本控制
  • 【人工智能-前端OpenWebUI】--图片显示
  • 详细介绍GetDlgItem()
  • 器材借用管理系统详细设计基于Spring Boot-SSM
  • 汽车电子硬件架构 --- 车用电子芯片与处理器架构的技术演进及核心价值
  • 深入解析 DAI 与 SAI:Linux 音频驱动中的核心概念
  • 【设计模式有哪些】
  • Linux | gcc编译篇
  • 互功率谱 cpsd
  • Mac - Cursor 配置 + GPT 4.0/4.5/o1/o3/Deepseek Api 使用
  • U-ViT:基于Vision Transformer的扩散模型骨干网络核心解析
  • linux 下消息队列
  • Leetcode Hot 100 39.组合总和(回溯)
  • 目标检测20年(一)
  • SAP-ABAP:AP屏幕增强技术手册-详解
  • C语言自定义类型【结构体】详解,【结构体内存怎么计算】 详解 【热门考点】:结构体内存对齐
  • OpenCV旋转估计(1)用于估计图像间仿射变换关系的类cv::detail::AffineBasedEstimator
  • 基于FPGA的DDS连续FFT 仿真验证
  • 力扣算法Hot100——75. 颜色分类