【Linux】进程信号(三):信号捕捉与操作系统运行原理

文章目录

    • Linux进程信号(三):信号捕捉与操作系统运行原理
    • 一、信号捕捉流程概览
      • [1.1 回顾signal函数](#1.1 回顾signal函数)
      • [1.2 信号捕捉的完整流程](#1.2 信号捕捉的完整流程)
    • 二、sigaction函数
      • [2.1 函数原型](#2.1 函数原型)
      • [2.2 struct sigaction结构](#2.2 struct sigaction结构)
      • [2.3 代码示例](#2.3 代码示例)
    • [三、★★★ 操作系统是怎么运行的 ★★★](#三、★★★ 操作系统是怎么运行的 ★★★)
      • [3.1 硬件中断](#3.1 硬件中断)
        • [3.1.1 什么是硬件中断?](#3.1.1 什么是硬件中断?)
        • [3.1.2 中断向量表](#3.1.2 中断向量表)
        • [3.1.3 中断处理流程](#3.1.3 中断处理流程)
      • [3.2 时钟中断 - OS调度的心脏](#3.2 时钟中断 - OS调度的心脏)
        • [3.2.1 时钟中断是什么?](#3.2.1 时钟中断是什么?)
        • [3.2.2 时钟中断处理流程(Linux 0.11源码)](#3.2.2 时钟中断处理流程(Linux 0.11源码))
        • [3.2.3 关键理解](#3.2.3 关键理解)
      • [3.3 OS的"死循环"本质](#3.3 OS的"死循环"本质)
      • [3.4 系统调用 - 软中断](#3.4 系统调用 - 软中断)
        • [3.4.1 什么是系统调用?](#3.4.1 什么是系统调用?)
        • [3.4.2 系统调用表](#3.4.2 系统调用表)
        • [3.4.3 系统调用流程](#3.4.3 系统调用流程)
        • [3.4.4 为什么用户代码看不到int 0x80?](#3.4.4 为什么用户代码看不到int 0x80?)
      • [3.5 CPU内部异常 - 陷阱与异常](#3.5 CPU内部异常 - 陷阱与异常)
        • [3.5.1 常见CPU异常](#3.5.1 常见CPU异常)
        • [3.5.2 异常处理流程](#3.5.2 异常处理流程)
        • [3.5.3 中断、异常、陷阱的统一处理](#3.5.3 中断、异常、陷阱的统一处理)
    • 四、内核态与用户态
      • [4.1 权限级别 - Ring 0 ~ Ring 3](#4.1 权限级别 - Ring 0 ~ Ring 3)
      • [4.2 虚拟地址空间划分](#4.2 虚拟地址空间划分)
      • [4.3 用户态与内核态切换](#4.3 用户态与内核态切换)
        • [4.3.1 切换流程详解](#4.3.1 切换流程详解)
        • [4.3.2 TSS(任务状态段)](#4.3.2 TSS(任务状态段))
    • 五、信号处理的完整流程
      • [5.1 信号捕捉的内核实现](#5.1 信号捕捉的内核实现)
      • [5.2 do_signal函数详解](#5.2 do_signal函数详解)
    • 六、可重入函数
      • [6.1 什么是可重入函数?](#6.1 什么是可重入函数?)
      • [6.2 不可重入的条件](#6.2 不可重入的条件)
      • [6.3 可重入函数的要求](#6.3 可重入函数的要求)
    • 七、volatile关键字
      • [7.1 编译器优化问题](#7.1 编译器优化问题)
      • [7.2 volatile的作用](#7.2 volatile的作用)
    • 八、SIGCHLD信号
      • [8.1 SIGCHLD信号的产生](#8.1 SIGCHLD信号的产生)
      • [8.2 使用SIGCHLD清理僵尸进程](#8.2 使用SIGCHLD清理僵尸进程)
      • [8.3 使用SA_NOCLDWAIT自动清理](#8.3 使用SA_NOCLDWAIT自动清理)
    • 九、本篇总结
      • [9.1 核心知识点](#9.1 核心知识点)
      • [9.2 最重要的理解](#9.2 最重要的理解)

Linux进程信号(三):信号捕捉与操作系统运行原理

💬 重磅来袭 :经过前两篇的学习,我们已经掌握了信号的产生方式和保存机制。但最核心的问题还没有解答:信号处理函数是如何被调用的?操作系统究竟是如何运行的?为什么说信号是"软件模拟硬件中断"? 本篇将揭开操作系统运行的神秘面纱,从硬件中断、时钟中断、系统调用、到用户态与内核态的切换,带你深入理解计算机系统的本质。这是整个系列最精华、最烧脑、也最有价值的一篇!

👍 点赞、收藏与分享:本篇包含大量Linux内核源码、底层原理图示、权限级别剖析,理解这些内容将让你对操作系统有质的飞跃!如果对你有帮助,请点赞、收藏并分享!

🚀 循序渐进:本篇难度较大,建议反复阅读,结合图示理解。


一、信号捕捉流程概览

1.1 回顾signal函数

在第一篇中,我们使用signal函数设置信号处理函数:

cpp 复制代码
void handler(int signo)
{
    std::cout << "捕捉到信号: " << signo << std::endl;
}
    // ...
}

但signal函数有一些历史遗留问题,POSIX标准推荐使用sigaction函数

1.2 信号捕捉的完整流程

关键问题:handler函数在哪里执行?

bash 复制代码
答案:在用户态执行!

流程图:
用户态                内核态
──────                ──────
main()
  │
  ├─ 系统调用 ─────→ 内核代码
  │                    │
  │                    ├─ 检查信号
  │                    │   发现pending=1, block=0
  │                    │   需要递达信号
  │                    │
  │                    ├─ 准备调用handler
  │                    │   但handler是用户空间的函数!
  │                    │
  │    ←────────────── ├─ 返回用户态
  ├─ handler(signo)    │   (特殊的返回方式)
  │                    │
  │                    │
  ├─ sigreturn ──────→ ├─ 重新进入内核
  │                    │
  │    ←────────────── ├─ 正常返回用户态
  ├─ 继续main()        │

📌 核心理解:

bash 复制代码
1. handler函数和main函数都在用户态执行

2. handler函数是一个独立的控制流程
   - 不是main函数调用的
   - 是内核"强制"让进程执行的

3. handler执行完毕后,需要通过sigreturn系统调用返回内核
   然后内核再让进程继续执行main函数

4. 这就是信号处理的"异步性"

二、sigaction函数

2.1 函数原型

c 复制代码
#include <signal.h>

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

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

参数说明:

bash 复制代码
signum:要设置的信号编号

act:新的信号处理动作
     如果act不是NULL,则修改signum的处理动作

oldact:原来的信号处理动作
        如果oldact不是NULL,则通过它返回原来的处理动作
        
可以只读取不修改:act=NULL, oldact!=NULL
可以只修改不读取:act!=NULL, oldact=NULL

2.2 struct sigaction结构

c 复制代码
struct sigaction {
    void (*sa_handler)(int);           // 信号处理函数
    void (*sa_sigaction)(int, siginfo_t *, void *);  // 高级处理函数
    sigset_t sa_mask;                  // 信号屏蔽字
    int sa_flags;                      // 标志位
    void (*sa_restorer)(void);         // 废弃字段
};

字段详解:

字段 说明
sa_handler 信号处理函数,类似signal的handler参数
sa_sigaction 高级处理函数(本章不讨论)
sa_mask 在执行handler时要阻塞的信号集
sa_flags 标志位,常用SA_SIGINFO、SA_RESTART等
sa_restorer 已废弃,不要使用

📌 重要:sa_mask的作用

bash 复制代码
当某个信号的处理函数被调用时:
1. 该信号会自动加入进程的阻塞信号集
2. sa_mask中的信号也会被加入阻塞信号集
3. handler执行完毕后,自动恢复原来的阻塞信号集

目的:防止handler执行期间被相同或相关的信号中断

2.3 代码示例

示例1:基本使用

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

void handler(int signo)
{
    std::cout << "捕捉到信号: " << signo << std::endl;
}

int main()
{
    struct sigaction act, oldact;
    
    // 设置新的处理动作
    act.sa_handler = handler;
    sigemptyset(&act.sa_mask);      // 清空sa_mask
    act.sa_flags = 0;                // 不设置任何标志
    
    // 修改2号信号的处理动作
    sigaction(SIGINT, &act, &oldact);
    
    while(true)
    {
        std::cout << "running..." << std::endl;
        sleep(1);
    }
    return 0;
}

示例2:sa_mask的作用

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

void handler(int signo)
{
    std::cout << "\n开始处理信号: " << signo << std::endl;
    
    // 模拟耗时操作
    for(int i = 1; i <= 10; i++)
    {
        std::cout << "处理中... " << i << std::endl;
        sleep(1);
    }
    
    std::cout << "处理完毕: " << signo << "\n" << std::endl;
}

int main()
{
    struct sigaction act;
    
    act.sa_handler = handler;
    sigemptyset(&act.sa_mask);
    
    // 在处理2号信号时,阻塞3号信号
    sigaddset(&act.sa_mask, SIGQUIT);  // 添加3号到sa_mask
    
    act.sa_flags = 0;
    
    sigaction(SIGINT, &act, nullptr);
    sigaction(SIGQUIT, &act, nullptr);
    
    std::cout << "进程PID: " << getpid() << std::endl;
    
    while(true)
    {
        std::cout << "running..." << std::endl;
        sleep(1);
    }
    return 0;
}

测试:

bash 复制代码
$ ./sig
进程PID: 451234
running...
^C                              ← 按Ctrl+C,发送2号信号
开始处理信号: 2
处理中... 1
处理中... 2
^\                              ← 在处理期间按Ctrl+\,发送3号信号
处理中... 3                      ← 3号信号被阻塞,不会中断2号的处理
处理中... 4
处理中... 5
...
处理中... 10
处理完毕: 2

开始处理信号: 3                  ← 2号处理完后,3号立即递达
处理中... 1
...

三、★★★ 操作系统是怎么运行的 ★★★

💡 重要提示:接下来是本篇的灵魂内容!我们将深入探讨操作系统的运行机制,理解硬件中断、时钟中断、系统调用的本质。这部分内容难度较大,但理解它们将让你对操作系统有质的飞跃!

3.1 硬件中断

3.1.1 什么是硬件中断?
bash 复制代码
定义:
硬件中断是由硬件设备产生的异步事件通知机制

特点:
1. 异步性:随时可能发生
2. 硬件触发:由外部设备产生
3. CPU响应:打断当前执行流程

常见硬件中断:

bash 复制代码
1. 键盘中断
   - 用户按下键盘
   - 键盘控制器产生中断
   - CPU暂停当前工作,处理键盘输入

2. 网卡中断
   - 网卡收到数据包
   - 网卡产生中断
   - CPU读取网络数据

3. 硬盘中断
   - 硬盘完成读写操作
   - 硬盘控制器产生中断
   - CPU处理数据

4. 时钟中断(最重要!)
   - 定时器硬件定期产生中断
   - CPU执行调度程序
   - 实现进程切换
3.1.2 中断向量表

Linux 0.11 内核源码(kernel/traps.c):

c 复制代码
void trap_init(void)
{
    int i;
    
    // 设置中断向量表
    set_trap_gate(0, &divide_error);        // 0: 除0异常
    set_trap_gate(1, &debug);               // 1: 调试异常
    set_trap_gate(2, &nmi);                 // 2: 非屏蔽中断
    set_system_gate(3, &int3);              // 3: 断点
    set_system_gate(4, &overflow);          // 4: 溢出
    set_system_gate(5, &bounds);            // 5: 边界检查
    set_trap_gate(6, &invalid_op);          // 6: 无效操作码
    set_trap_gate(7, &device_not_available);// 7: 设备不可用
    set_trap_gate(8, &double_fault);        // 8: 双重故障
    set_trap_gate(9, &coprocessor_segment_overrun); // 9: 协处理器段溢出
    set_trap_gate(10, &invalid_TSS);        // 10: 无效TSS
    set_trap_gate(11, &segment_not_present);// 11: 段不存在
    set_trap_gate(12, &stack_segment);      // 12: 栈段错误
    set_trap_gate(13, &general_protection); // 13: 一般保护错误
    set_trap_gate(14, &page_fault);         // 14: 缺页异常
    set_trap_gate(15, &reserved);           // 15: 保留
    set_trap_gate(16, &coprocessor_error);  // 16: 协处理器错误
    
    // 其他中断向量...
    for (i = 17; i < 48; i++)
        set_trap_gate(i, &reserved);
}

中断向量表结构:

bash 复制代码
中断向量表(IDT: Interrupt Descriptor Table)
┌────────┬─────────────────────────────────┐
│ 中断号 │ 处理程序地址                    │
├────────┼─────────────────────────────────┤
│   0    │ divide_error                    │ ← 除0异常
│   1    │ debug                           │
│   2    │ nmi                             │
│   3    │ int3                            │ ← 断点调试
│  ...   │ ...                             │
│   14   │ page_fault                      │ ← 缺页中断
│  ...   │ ...                             │
│   32   │ timer_interrupt                 │ ← 时钟中断
│   33   │ keyboard_interrupt              │ ← 键盘中断
│  ...   │ ...                             │
│   128  │ system_call                     │ ← 系统调用
│  ...   │ ...                             │
└────────┴─────────────────────────────────┘
3.1.3 中断处理流程
bash 复制代码
步骤1:硬件设备产生中断信号
      (例如:键盘按下)
      │
      ↓
步骤2:中断控制器(8259A)接收中断
      并向CPU发送中断请求
      │
      ↓
步骤3:CPU检查IF标志位
      │
      ├─ IF=0(中断被禁止)→ 忽略
      │
      └─ IF=1(中断允许)→ 继续
      │
      ↓
步骤4:CPU暂停当前指令
      保存当前状态(CS, EIP, EFLAGS等)
      │
      ↓
步骤5:根据中断号查询中断向量表(IDT)
      获取中断处理程序地址
      │
      ↓
步骤6:跳转到中断处理程序执行
      (自动切换到内核态,ring 0)
      │
      ↓
步骤7:中断处理程序执行完毕
      执行IRET指令(中断返回)
      │
      ↓
步骤8:恢复之前保存的状态
      继续执行被中断的程序

3.2 时钟中断 - OS调度的心脏

3.2.1 时钟中断是什么?
bash 复制代码
时钟中断:
- 由硬件定时器(PIT: Programmable Interval Timer)产生
- Linux中默认频率:100Hz(每10ms触发一次)
- 现代Linux:1000Hz(每1ms触发一次)

作用:
1. 推动进程调度
2. 更新系统时间
3. 处理定时器(如alarm)
4. 统计CPU使用率
3.2.2 时钟中断处理流程(Linux 0.11源码)

kernel/sched.c:

c 复制代码
// 时钟中断处理函数
void do_timer(long cpl)
{
    // 1. 更新jiffies(系统运行的时钟滴答数)
    jiffies++;
    
    // 2. 减少当前进程的时间片
    if ((--current->counter) > 0)
        return;  // 时间片未用完,继续执行
    
    // 3. 时间片用完,设置need_resched标志
    current->counter = 0;
    if (!cpl)  // 如果在内核态,不能立即调度
        return;
    
    // 4. 触发调度
    schedule();
}

// 进程调度函数
void schedule(void)
{
    int i, next, c;
    struct task_struct **p;
    
    // 1. 检查alarm,检查信号
    for(p = &LAST_TASK; p > &FIRST_TASK; --p)
        if (*p) {
            if ((*p)->alarm && (*p)->alarm < jiffies) {
                (*p)->signal |= (1 << (SIGALRM - 1));
                (*p)->alarm = 0;
            }
            // ... 其他信号检查
        }
    
    // 2. 选择下一个要运行的进程
    while (1) {
        c = -1;
        next = 0;
        i = NR_TASKS;
        p = &task[NR_TASKS];
        
        // 找到counter最大的就绪进程
        while (--i) {
            if (!*--p)
                continue;
            if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
                c = (*p)->counter, next = i;
        }
        
        if (c) break;
        
        // 如果所有进程的counter都是0,重新分配时间片
        for(p = &LAST_TASK; p > &FIRST_TASK; --p)
            if (*p)
                (*p)->counter = ((*p)->counter >> 1) + (*p)->priority;
    }
    
    // 3. 切换到选中的进程
    switch_to(next);
}

完整流程图:

bash 复制代码
时钟硬件
  │
  ├─ 每10ms产生一次中断
  │
  ↓
CPU接收中断
  │
  ├─ 查询IDT,找到timer_interrupt
  │
  ↓
timer_interrupt
  │
  ├─ 保存现场
  ├─ 调用do_timer()
  │    │
  │    ├─ jiffies++
  │    ├─ current->counter--
  │    │
  │    └─ if (counter == 0)
  │         └─ schedule()  ← 进程调度
  │              │
  │              ├─ 检查alarm
  │              ├─ 检查信号
  │              ├─ 选择下一个进程
  │              └─ switch_to()  ← 进程切换
  │
  ├─ 恢复现场
  └─ IRET返回
3.2.3 关键理解

📌 OS不是主动运行的,而是被动唤醒的!

bash 复制代码
传统错误理解:
OS像一个管理员,主动地调度各个进程

正确理解:
OS本身不是一个独立运行的程序!
OS是"躺"在中断处理例程上的!

具体来说:
1. 平时CPU执行用户进程
2. 时钟中断到来 → 强制进入内核
3. 执行do_timer → schedule → switch_to
4. 切换到另一个进程
5. 继续执行用户代码
6. 等待下一次时钟中断...

OS的代码在什么时候执行?
- 时钟中断时
- 其他硬件中断时
- 系统调用时
- CPU异常时

OS就是这些中断/异常处理程序的集合!

3.3 OS的"死循环"本质

Linux 0.11的main函数(init/main.c):

c 复制代码
void main(void)
{
    // 1. 初始化内存
    mem_init(main_memory_start, memory_end);
    
    // 2. 初始化中断
    trap_init();
    
    // 3. 初始化块设备
    blk_dev_init();
    
    // 4. 初始化字符设备
    chr_dev_init();
    
    // 5. 初始化硬盘
    hd_init();
    
    // 6. 初始化软盘
    floppy_init();
    
    // 7. 开中断
    sti();
    
    // 8. 创建init进程
    move_to_user_mode();
    if (!fork()) {
        init();  // 子进程执行init
    }
    
    // 9. 0号进程(idle进程)永远循环
    for(;;)
        pause();  // 等待中断
}

📌 核心理解:

bash 复制代码
OS的main函数最后是一个死循环:
for(;;) pause();

pause()函数:
- 使CPU进入低功耗状态
- 等待中断到来

整个系统的运行:
1. OS初始化完毕,创建init进程
2. 0号进程进入for(;;) pause()死循环
3. 时钟中断到来 → 进入do_timer
4. do_timer调用schedule,切换到init进程
5. init进程运行...
6. 下一次时钟中断 → 又进入do_timer
7. 可能切换到其他进程
8. 如此循环往复...

结论:
OS"躺"在pause()上,等待中断唤醒!
OS的调度代码在中断处理例程中!

3.4 系统调用 - 软中断

3.4.1 什么是系统调用?
bash 复制代码
定义:
用户程序主动陷入内核,请求内核服务的机制

本质:
软件中断(软中断)

实现方式:
- Linux 32位:int 0x80指令
- Linux 64位:syscall指令
3.4.2 系统调用表

Linux 0.11 源码(include/unistd.h):

c 复制代码
// 系统调用号
#define __NR_setup      0   /* used only by init, to get system going */
#define __NR_exit       1
#define __NR_fork       2
#define __NR_read       3
#define __NR_write      4
#define __NR_open       5
#define __NR_close      6
#define __NR_waitpid    7
#define __NR_creat      8
#define __NR_link       9
#define __NR_unlink     10
#define __NR_execve     11
#define __NR_chdir      12
#define __NR_time       13
// ... 更多系统调用

kernel/system_call.s(部分):

asm 复制代码
! 系统调用入口
system_call:
    cmpl $nr_system_calls-1,%eax  ! 检查系统调用号是否合法
    ja bad_sys_call
    push %ds
    push %es
    push %fs
    pushl %edx
    pushl %ecx
    pushl %ebx
    movl $0x10,%edx    ! 设置内核数据段
    mov %dx,%ds
    mov %dx,%es
    movl $0x17,%edx    ! 设置用户数据段
    mov %dx,%fs
    call *sys_call_table(,%eax,4)  ! 调用对应的系统调用函数
    pushl %eax
    movl current,%eax
    cmpl $0,state(%eax)
    jne reschedule
    cmpl $0,counter(%eax)
    je reschedule
ret_from_sys_call:
    ! ... 返回用户态前检查信号 ★★★
    ! ... 这里就是信号递达的时机!
    movl current,%eax
    cmpl task,%eax
    je 3f
    cmpw $0x0f,CS(%esp)
    jne 3f
    cmpw $0x17,OLDSS(%esp)
    jne 3f
    movl signal(%eax),%ebx    ! 获取pending信号
    movl blocked(%eax),%ecx   ! 获取block信号
    notl %ecx
    andl %ebx,%ecx            ! pending & ~block
    bsfl %ecx,%ecx            ! 找到第一个需要递达的信号
    je 3f
    btrl %ecx,%ebx            ! 清除pending位
    movl %ebx,signal(%eax)
    incl %ecx
    pushl %ecx                ! 信号号
    call do_signal            ! ★ 调用信号处理 ★
    popl %eax
3:  popl %eax
    popl %ebx
    popl %ecx
    popl %edx
    pop %fs
    pop %es
    pop %ds
    iret  ! 返回用户态

系统调用表(sys_call_table):

c 复制代码
fn_ptr sys_call_table[] = {
    sys_setup,      // 0
    sys_exit,       // 1
    sys_fork,       // 2
    sys_read,       // 3
    sys_write,      // 4
    sys_open,       // 5
    sys_close,      // 6
    sys_waitpid,    // 7
    // ... 更多
};
3.4.3 系统调用流程
bash 复制代码
用户程序调用write():
┌─────────────────────────────────────────┐
│ 用户态                                  │
│                                         │
│ int main() {                            │
│     write(1, "hello", 5);  ← 调用glibc │
│ }                                       │
└─────────────────────────────────────────┘
              │
              ↓ glibc封装
┌─────────────────────────────────────────┐
│ write() 函数(glibc库函数)             │
│ {                                       │
│     movl $4, %eax      // 系统调用号4  │
│     movl $1, %ebx      // 参数1: fd    │
│     movl $buf, %ecx    // 参数2: buf   │
│     movl $5, %edx      // 参数3: count │
│     int $0x80          // 触发软中断   │
│     ret                                 │
│ }                                       │
└─────────────────────────────────────────┘
              │
              ↓ int 0x80(软中断)
┌─────────────────────────────────────────┐
│ 内核态                                  │
│                                         │
│ system_call:           ← IDT[128]      │
│     保存寄存器                          │
│     call *sys_call_table(,%eax,4)      │
│          │                              │
│          ↓ 数组索引sys_call_table[4]   │
│     sys_write:                          │
│         执行实际的写操作                │
│                                         │
│     检查信号 ★★★                        │
│     恢复寄存器                          │
│     iret               ← 返回用户态     │
└─────────────────────────────────────────┘
              │
              ↓
┌─────────────────────────────────────────┐
│ 用户态                                  │
│                                         │
│ int main() {                            │
│     write(1, "hello", 5);  ← 继续执行  │
│     ...                                 │
│ }                                       │
└─────────────────────────────────────────┘
3.4.4 为什么用户代码看不到int 0x80?

好的!我继续完成第三篇的剩余部分!


bash 复制代码
原因:glibc对系统调用进行了封装

write() in glibc  →  包装了int 0x80指令
read() in glibc   →  包装了int 0x80指令
open() in glibc   →  包装了int 0x80指令

用户只需要调用write(fd, buf, count)
不需要关心底层的int 0x80

封装的好处:
1. 屏蔽底层细节
2. 跨平台兼容(32位用int 0x80,64位用syscall)
3. 提供友好的函数接口

3.5 CPU内部异常 - 陷阱与异常

除了硬件中断和软中断(系统调用),CPU内部也会产生中断,统称为异常(Exception)陷阱(Trap)

3.5.1 常见CPU异常
bash 复制代码
1. 除零异常(Divide Error)
   - 中断号:0
   - 触发:执行除0指令
   - 处理:发送SIGFPE信号

2. 调试异常(Debug)
   - 中断号:1
   - 触发:单步调试
   - 处理:gdb调试

3. 缺页异常(Page Fault)
   - 中断号:14
   - 触发:访问未映射的虚拟地址
   - 处理:分配物理页或发送SIGSEGV

4. 一般保护错误(General Protection)
   - 中断号:13
   - 触发:非法内存访问、权限冲突
   - 处理:发送SIGSEGV或SIGBUS

5. 无效操作码(Invalid Opcode)
   - 中断号:6
   - 触发:执行非法指令
   - 处理:发送SIGILL
3.5.2 异常处理流程

以除零异常为例:

bash 复制代码
用户程序执行:
int a = 10;
a /= 0;  ← CPU执行DIV指令
  │
  ↓ CPU检测到除0
  │
CPU产生异常:
  ├─ 查询IDT[0] → divide_error
  ├─ 保存现场(CS, EIP, EFLAGS)
  └─ 跳转到divide_error
  │
  ↓
内核处理:
void divide_error(void)
{
    // 1. 打印错误信息
    printk("divide error\n");
    
    // 2. 向当前进程发送SIGFPE信号
    current->signal |= (1 << (SIGFPE - 1));
    
    // 3. 返回(但由于异常状态未清除,会循环触发)
}
  │
  ↓ IRET返回
  │
回到用户程序:
  ├─ 异常状态仍然存在
  └─ 继续执行DIV指令 → 再次触发异常
  │
  ↓ 下一次系统调用返回时检查信号
  │
信号递达:
  ├─ 执行handler或默认动作
  └─ 终止进程
3.5.3 中断、异常、陷阱的统一处理

中断向量表的统一性:

bash 复制代码
中断向量表(IDT)统一处理所有中断源:

┌──────────┬──────────────────┬──────────┐
│ 中断号   │ 来源             │ 类型     │
├──────────┼──────────────────┼──────────┤
│ 0        │ CPU(除0)       │ 异常     │
│ 1        │ CPU(调试)      │ 陷阱     │
│ 3        │ CPU(断点)      │ 陷阱     │
│ 13       │ CPU(保护错误)  │ 异常     │
│ 14       │ CPU(缺页)      │ 异常     │
│ ...      │ ...              │ ...      │
│ 32       │ 时钟硬件         │ 硬件中断 │
│ 33       │ 键盘硬件         │ 硬件中断 │
│ ...      │ ...              │ ...      │
│ 128      │ int 0x80(软件) │ 软中断   │
└──────────┴──────────────────┴──────────┘

无论什么来源,都通过IDT统一处理!

四、内核态与用户态

4.1 权限级别 - Ring 0 ~ Ring 3

x86 CPU的4个权限级别:

bash 复制代码
                Ring 0(内核态)
                ┌─────────────┐
                │   内核代码  │
                │   驱动程序  │
                └─────────────┘
                     ↑ 最高权限
                     │ 可以执行所有指令
                     │ 访问所有内存
                     │
             Ring 1, Ring 2(很少使用)
                     │
                     ↓
                Ring 3(用户态)
                ┌─────────────┐
                │  用户程序   │
                │  应用软件   │
                └─────────────┘
                     ↓ 最低权限
                     │ 受限的指令集
                     │ 受限的内存访问

CPL(Current Privilege Level):

bash 复制代码
CPL存储在CS寄存器的低2位

CS寄存器结构(16位):
┌──────────────┬──┬──┐
│ 段选择子     │TI│CPL│
└──────────────┴──┴──┘
                 ↑  ↑
                 │  └─ 当前权限级别(0-3)
                 └──── 表指示符

CPL = 0:内核态(Ring 0)
CPL = 3:用户态(Ring 3)

4.2 虚拟地址空间划分

Linux进程的虚拟地址空间(32位系统):

bash 复制代码
4GB ┌─────────────────────────────────┐
    │                                 │
    │      内核空间(1GB)            │ ← Ring 0可访问
    │      [3GB, 4GB)                 │   Ring 3不可访问
    │                                 │
3GB ├─────────────────────────────────┤ ← 分界线
    │                                 │
    │      用户空间(3GB)            │ ← Ring 0和Ring 3都可访问
    │      [0, 3GB)                   │
    │                                 │
    │  ┌─────────────────────┐        │
    │  │      栈(向下增长)  │        │
    │  └─────────────────────┘        │
    │           ↓                     │
    │                                 │
    │           ↑                     │
    │  ┌─────────────────────┐        │
    │  │      ─────────────────────┘        │
    │  ┌─────────────────────┐        │
    │  │      .data          │        │
    │  └─────────────────────┘        │
    │  ┌─────────────────────┐        │
    │  │      .text          │        │
    │  └─────────────────────┘        │
0   └─────────────────────────────────┘

📌 关键理解:

bash 复制代码
1. 每个进程都有独立的0-3GB用户空间
2. 所有进程共享同一个3-4GB内核空间
3. 用户态(CPL=3)只能访问0-3GB
4. 内核态(CPL=0)可以访问0-4GB全部空间

4.3 用户态与内核态切换

4.3.1 切换流程详解

从用户态切换到内核态:

bash 复制代码
步骤1:触发条件
   ├─ 系统调用(int 0x80 / syscall)
   ├─ 硬件中断
   └─ CPU异常

步骤2:保存用户态上下文
   CPU自动保存到内核栈:
   ├─ SS(用户栈段选择子)
   ├─ ESP(用户栈指针)
   ├─ EFLAGS(标志寄存器)
   ├─ CS(代码段选择子)
   └─ EIP(指令指针)

步骤3:提权
   ├─ 修改CPL:CS.CPL = 0
   └─ 进入Ring 0

步骤4:切换栈
   ├─ 从TSS中获取内核栈地址:
   │   SS0 = TSS.SS0
   │   ESP0 = TSS.ESP0
   └─ 切换到内核栈

步骤5:执行内核代码
   ├─ 执行中断/异常处理程序
   └─ 或执行系统调用函数

步骤6:检查信号 ★★★
   ├─ 遍历pending位图
   ├─ 对比block位图
   └─ 如果有信号需要递达 → 调用do_signal

步骤7:准备返回用户态

从内核态返回用户态:

bash 复制代码
步骤1:执行IRET指令

步骤2:恢复用户态上下文
   CPU自动从内核栈弹出:
   ├─ EIP
   ├─ CS
   ├─ EFLAGS
   ├─ ESP
   └─ SS

步骤3:降权
   ├─ 修改CPL:CS.CPL = 3
   └─ 进入Ring 3

步骤4:切换栈
   └─ 恢复用户栈(ESP, SS)

步骤5:继续执行用户代码
4.3.2 TSS(任务状态段)

TSS结构:

c 复制代码
struct tss_struct {
    unsigned short back_link, __blh;
    unsigned long esp0;          // Ring 0的栈指针 ★
    unsigned short ss0, __ss0h;  // Ring 0的栈段 ★
    unsigned long esp1;
    unsigned short ss1, __ss1h;
    unsigned long esp2;
    unsigned short ss2, __ss2h;
    unsigned long cr3;           // 页目录基址
    unsigned long eip;
    unsigned long eflags;
    unsigned long eax, ecx, edx, ebx;
    unsigned long esp;
    unsigned long ebp;
    unsigned long esi;
    unsigned long edi;
    unsigned short es, __esh;
    unsigned short cs, __csh;
    unsigned short ss, __ssh;
    unsigned short ds, __dsh;
    unsigned short fs, __fsh;
    unsigned short gs, __gsh;
    unsigned short ldt, __ldth;
    unsigned short trace, bitmap;
};

📌 TSS的作用:

bash 复制代码
1. 每个进程有一个TSS
2. 存储进程的内核栈地址(SS0, ESP0)
3. 用户态→内核态时,CPU从TSS中获取内核栈地址
4. 这样不同进程在内核态使用不同的内核栈,互不干扰

五、信号处理的完整流程

5.1 信号捕捉的内核实现

完整流程图:

bash 复制代码
用户态(main函数)
  │
  ├─ 执行用户代码
  │
  ├─ 发生系统调用(例如write)
  │
  ↓ int 0x80
────────────────────────────────────────
内核态
  │
  ├─ system_call入口
  │   ├─ 保存寄存器
  │   ├─ 调用sys_write
  │   └─ sys_write执行完毕
  │
  ├─ ret_from_sys_call ★关键★
  │   │
  │   ├─ 检查是否需要调度
  │   │
  │   ├─ 检查信号 ★★★
  │   │   movl signal(%eax), %ebx     ; 获取pending
  │   │   movl blocked(%eax), %ecx    ; 获取block
  │   │   notl %ecx                   ; ~block
  │   │   andl %ebx, %ecx             ; pending & ~block
  │   │   bsfl %ecx, %ecx             ; 找到第一个信号
  │   │   je 3f                       ; 没有信号,跳过
  │   │   btrl %ecx, %ebx             ; 清除pending位
  │   │   incl %ecx                   ; 信号号+1
  │   │   call do_signal ★★★
  │   │
  │   └─ IRET
────────────────────────────────────────
  │
  ↓ do_signal特殊处理
────────────────────────────────────────
用户态(handler函数)
  │
  ├─ 执行handler(signo)
  │
  ├─ handler执行完毕
  │
  ↓ 自动调用sigreturn(glibc插入的代码)
────────────────────────────────────────
内核态
  │
  ├─ sys_sigreturn
  │   └─ 恢复用户栈上的上下文
  │
  ↓ IRET
────────────────────────────────────────
用户态(main函数)
  │
  ├─ 继续执行write后面的代码
  └─ ...

5.2 do_signal函数详解

简化版do_signal实现(Linux 0.11):

c 复制代码
void do_signal(long signr, long eax, long ebx, long ecx, long edx,
               long fs, long es, long ds,
               long eip, long cs, long eflags,
               unsigned long *esp, long ss)
{
    unsigned long sa_handler;
    long old_eip = eip;
    struct sigaction *sa = current->sigaction + signr - 1;
    
    sa_handler = (unsigned long) sa->sa_handler;
    
    if (sa_handler == 1)  // SIG_IGN
        return;
    
    if (!sa_handler) {    // SIG_DFL
        // 执行默认动作(终止、core dump等)
        do_exit(1 << (signr - 1));
    }
    
    // 自定义处理:
    // 关键:修改用户栈,让返回时执行handler!
    
    // 1. 在用户栈上保存返回地址
    put_fs_long((long) sa->sa_restorer, esp);  // sigreturn地址
    put_fs_long(signr, esp + 1);                // 信号号作为参数
    
    // 2. 修改返回地址为handler
    put_fs_long(eip, esp + 2);                  // 保存原EIP
    put_fs_long(sa_handler, &eip);              // 修改EIP为handler
    
    // 3. 设置阻塞信号集
    current->blocked |= sa->sa_mask;
    
    // IRET返回时,会跳转到handler!
}

关键理解:

bash 复制代码
do_signal不是直接调用handler,而是:
1. 修改用户栈,压入返回地址(sa_restorer)和参数(signr)
2. 修改保存的EIP为handler地址
3. IRET返回时,CPU跳转到handler(用户态执行)
4. handler执行完毕,返回到sa_restorer
5. sa_restorer调用sigreturn系统调用
6. sigreturn恢复原来的上下文
7. 继续执行main函数

六、可重入函数

6.1 什么是可重入函数?

bash 复制代码
定义:
一个函数在被调用执行期间(还没返回),
由于某种原因(如信号处理)又被重复调用,
这种情况称为重入。

如果一个函数可以安全地被重入,
则称为可重入函数(Reentrant Function)。

不可重入的例子:

c 复制代码
// 全局链表
struct node {
    int data;
    struct node *next;
};

struct node *head = NULL;

void insert(int data)
{
    struct node *p = malloc(sizeof(struct node));
    p->data = data;
    
    // ★ 关键位置 ★
    p->next = head;
    head = p;  // 如果在这里被信号中断...
}

void handler(int signo)
{
    insert(100);  // 在信号处理函数中也调用insert
}

int main()
{
    signal(SIGINT, handler);
    
    insert(1);
    insert(2);
    insert(3);
    
    while(1) pause();
}

问题分析:

bash 复制代码
时刻t1: main调用insert(1)
        ├─ 执行到p->next = head;
        ├─ 还未执行head = p;
        │
时刻t2: 信号到来,进入handler
        ├─ handler调用insert(100)
        ├─ insert(100)完整执行:
        │   head现在指向新节点(data=100)
        ├─ handler返回
        │
时刻t3: 继续执行insert(1)
        ├─ 执行head = p;
        └─ 问题:覆盖了insert(100)的结果!
             链表中节点100丢失!

6.2 不可重入的条件

bash 复制代码
一个函数是不可重入的,通常因为:

1. 使用了全局变量或静态变量
   - 重入时修改同一份数据,产生竞态条件

2. 调用了malloc/free
   - 堆管理的内部数据结构可能被破坏

3. 调用了标准I/O库函数
   - printf, scanf等内部使用全局缓冲区

4. 调用了其他不可重入函数

6.3 可重入函数的要求

bash 复制代码
可重入函数必须满足:

1. 只使用局部变量或参数
2. 不调用malloc/free
3. 不调用标准I/O库函数
4. 只调用其他可重入函数

可重入版本的insert:

c 复制代码
void insert_reentrant(struct node **phead, int data)
{
    struct node p;  // 使用栈上的局部变量
    p.data = data;
    p.next = *phead;
    *phead = &p;  // 仍然不完美,但说明了思路
}

📌 信号处理函数中应该做什么?

bash 复制代码
1. 尽量简单,只设置标志位
2. 不要调用不可重入函数
3. 不要进行复杂操作

七、volatile关键字

7.1 编译器优化问题

c 复制代码
int flag = 0;

void handler(int signo)
{
    flag = 1;
}

int main()
{
    signal(SIGINT, handler);
    
    while (!flag) {
        // 等待信号
    }
    
    printf("收到信号,退出循环\n");
    return 0;
}

编译优化后的问题:

bash 复制代码
$ gcc -O2 test.c -o test
$ ./test
^C^C^C  ← 按Ctrl+C无效,程序不退出!

原因分析:

bash 复制代码
编译器优化(-O2):
1. 编译器看到while (!flag)循环
2. 发现flag在main函数中从未被修改
3. 优化为:
   if (!flag) {
       while (1);  // 死循环
   }
4. flag的值被缓存在寄存器中
5. handler修改的是内存中的flag
6. 寄存器中的值不变,循环永不退出

7.2 volatile的作用

c 复制代码
volatile int flag = 0;  // 添加volatile

void handler(int signo)
{
    flag = 1;
}

int main()
{
    signal(SIGINT, handler);
    
    while (!flag) {
        // 等待信号
    }
    
    printf("收到信号,退出循环\n");
    return 0;
}
bash 复制代码
$ gcc -O2 test.c -o test
$ ./test
^C收到信号,退出循环  ← 正常退出

📌 volatile的含义:

bash 复制代码
1. 告诉编译器:这个变量是"易变的"
2. 每次访问都必须从内存读取
3. 不要将其缓存在寄存器中
4. 不要对其进行优化

适用场景:
1. 信号处理函数修改的变量
2. 多线程共享的变量
3. 硬件寄存器映射的变量

八、SIGCHLD信号

8.1 SIGCHLD信号的产生

bash 复制代码
当子进程状态发生变化时,向父进程发送SIGCHLD信号:

1. 子进程终止
2. 子进程被停止(Ctrl+Z)
3. 子进程从停止状态恢复

默认处理:忽略

8.2 使用SIGCHLD清理僵尸进程

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>

void handler(int signo)
{
    std::cout << "收到SIGCHLD信号,清理僵尸进程" << std::endl;
    
    while (waitpid(-1, NULL, WNOHANG) > 0) {
        std::cout << "清理一个僵尸进程" << std::endl;
    }
}

int main()
{
    signal(SIGCHLD, handler);
    
    // 创建5个子进程
    for (int i = 0; i < 5; i++) {
        pid_t pid = fork();
        if (pid == 0) {
            // 子进程
            std::cout << "子进程 " << getpid() << " 退出" << std::endl;
            exit(0);
        }
    }
    
    // 父进程等待
    while (1) {
        std::cout << "父进程运行中..." << std::endl;
        sleep(1);
    }
    
    return 0;
}

8.3 使用SA_NOCLDWAIT自动清理

cpp 复制代码
int main()
{
    struct sigaction act;
    act.sa_handler = SIG_IGN;
    sigemptyset(&act.sa_mask);
    act.sa_flags = SA_NOCLDWAIT;  // 自动清理子进程
    
    sigaction(SIGCHLD, &act, NULL);
    
    // 创建子进程
    for (int i = 0; i < 5; i++) {
        pid_t pid = fork();
        if (pid == 0) {
            exit(0);  // 子进程自动被清理,不会成为僵尸
        }
    }
    
    while (1) pause();
    return 0;
}

九、本篇总结

9.1 核心知识点

  1. sigaction函数

    • 替代signal的POSIX标准函数
    • sa_mask在handler执行期间阻塞信号
  2. 操作系统运行原理 ★★★

    • 硬件中断:外部设备触发,CPU响应
    • 时钟中断:OS调度的心脏,推动进程切换
    • 系统调用:软中断,用户主动陷入内核
    • CPU异常:除0、缺页等,CPU内部触发
    • 中断向量表:统一处理所有中断源
  3. 内核态与用户态

    • Ring 0~3权限级别
    • 虚拟地址空间:3GB用户 + 1GB内核
    • 切换流程:保存上下文、提权、切换栈、执行内核代码、检查信号、恢复上下文、降权
    • TSS:存储内核栈地址
  4. 信号处理完整流程

    • 系统调用返回前检查信号
    • do_signal修改用户栈
    • IRET返回到handler(用户态)
    • handler执行完毕调用sigreturn
    • 恢复原来的上下文
  5. 可重入函数与volatile

    • 可重入:只使用局部变量,不调用不安全函数
    • volatile:防止编译器优化

9.2 最重要的理解

📌 OS不是独立运行的程序,而是"躺"在中断处理例程上!

bash 复制代码
OS的本质:
- main函数最后是for(;;) pause()
- 等待中断唤醒
- 中断处理程序就是OS的代码
- 时钟中断推动调度
- 系统调用提供服务
- 信号在系统调用返回前检查

整个计算机系统的运行:
CPU执行用户程序 → 中断 → 执行OS代码 → 返回用户程序 → ...

💬 总结:本篇是整个系列的灵魂,深入剖析了操作系统的运行机制。从硬件中断到软中断,从时钟中断到系统调用,从用户态到内核态,完整地展现了信号机制背后的操作系统原理。理解这些内容,不仅能掌握信号机制,更能对整个计算机系统有深刻的认识!

👍 点赞、收藏与分享:如果这篇文章对你有帮助,请点赞、收藏并分享!三篇系列完结,感谢阅读!

相关推荐
哈哈不让取名字2 小时前
分布式日志系统实现
开发语言·c++·算法
zl_dfq2 小时前
Linux 之 【进程间通信】(消息队列与信号量、Systrm VIPC在内核中数据结构设计)
linux
信创天地2 小时前
国产化数据库深度运维:性能调优与故障排查实战指南
运维·数据库·安全·elk·自动化·rabbitmq
知无不研2 小时前
实现一个整形栈
c语言·数据结构·c++·算法
木卫二号Coding2 小时前
Docker-构建自己的Web-Linux系统-镜像colinchang/ubuntu-desktop:22.04
linux·ubuntu·docker
维度攻城狮2 小时前
Ubuntu突然无法中文输入的问题解决办法
linux·运维·ubuntu
Coder个人博客2 小时前
Linux6.19-ARM64 mm Makefile子模块深入分析
linux·安全·车载系统·系统架构·系统安全·鸿蒙系统·安全架构
曾几何时`2 小时前
二分查找(十)1146. 快照数组 pair整理
java·服务器·前端
猫猫的小茶馆2 小时前
【Linux 驱动开发】五. 设备树
linux·arm开发·驱动开发·stm32·嵌入式硬件·mcu·硬件工程