文章目录
-
- 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, ÷_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 核心知识点
-
sigaction函数
- 替代signal的POSIX标准函数
- sa_mask在handler执行期间阻塞信号
-
操作系统运行原理 ★★★
- 硬件中断:外部设备触发,CPU响应
- 时钟中断:OS调度的心脏,推动进程切换
- 系统调用:软中断,用户主动陷入内核
- CPU异常:除0、缺页等,CPU内部触发
- 中断向量表:统一处理所有中断源
-
内核态与用户态
- Ring 0~3权限级别
- 虚拟地址空间:3GB用户 + 1GB内核
- 切换流程:保存上下文、提权、切换栈、执行内核代码、检查信号、恢复上下文、降权
- TSS:存储内核栈地址
-
信号处理完整流程
- 系统调用返回前检查信号
- do_signal修改用户栈
- IRET返回到handler(用户态)
- handler执行完毕调用sigreturn
- 恢复原来的上下文
-
可重入函数与volatile
- 可重入:只使用局部变量,不调用不安全函数
- volatile:防止编译器优化
9.2 最重要的理解
📌 OS不是独立运行的程序,而是"躺"在中断处理例程上!
bash
OS的本质:
- main函数最后是for(;;) pause()
- 等待中断唤醒
- 中断处理程序就是OS的代码
- 时钟中断推动调度
- 系统调用提供服务
- 信号在系统调用返回前检查
整个计算机系统的运行:
CPU执行用户程序 → 中断 → 执行OS代码 → 返回用户程序 → ...
💬 总结:本篇是整个系列的灵魂,深入剖析了操作系统的运行机制。从硬件中断到软中断,从时钟中断到系统调用,从用户态到内核态,完整地展现了信号机制背后的操作系统原理。理解这些内容,不仅能掌握信号机制,更能对整个计算机系统有深刻的认识!
👍 点赞、收藏与分享:如果这篇文章对你有帮助,请点赞、收藏并分享!三篇系列完结,感谢阅读!