Linux -- 信号【下】
目录
一、操作系统是怎么运行的
1、硬件中断
1.1 概念
1.2 原理
1.3 中断向量表
1.4 CPU中断响应全流程
2、时钟中断
2.1 概念
2.2 Linux 0.11 时钟中断机制详解
2.3 时间片与调度机制
3、死循环
3.1 Linux 0.11 的主函数结构
3.2 pause() 的内部机制
4、软中断
4.1 概念
4.2 系统调用表
4.3 系统调用原理
4.4 缺页中断?内存碎片处理?除零野指针错误?
4.5 中断、陷阱、异常、终止
二、深入理解用户态、内核态
1、内核页表
2、特权级
3、用户态到内核态切换底层机制
三、可重入函数
四、volatile
五、SIGCHLD信号
一、操作系统是怎么运行的
# 我们先引入一些前置知识,如针脚、中断、中断控制器、中断向量表:
1、硬件中断
1.1 概念
# 硬件中断是外部设备(如键盘、磁盘、网卡)向 CPU 发出的信号,表示需要处理某个事件或请求服务。这是一种异步事件,可以在任何时候发生,打断 CPU 当前正在执行的任务,其核心目的是避免 CPU 轮询外设造成的资源浪费。
-
中断请求(IRQ):硬件设备发出的中断信号
-
中断向量:唯一标识中断类型的编号
-
中断处理程序:对应每个中断向量的处理函数
-
中断控制器:管理和优先级排序多个中断请求的硬件
1.2 原理
# 触发原理:当设备完成操作(如磁盘读取结束)或状态变化(如按键按下),通过 中断控制器(如8259A) 向 CPU 发送中断请求(IRQ)。
# 硬件协作流程:
📌 关键设计: CPU仅在指令边界检查中断,确保指令原子性。
//Linux内核0.11源码
void trap_init(void)
{int i;set_trap_gate(0,÷_error);// 设置除操作出错的中断向量值。以下雷同。set_trap_gate(1,&debug);set_trap_gate(2,&nmi);set_system_gate(3,&int3); /* int3-5 can be called from all */set_system_gate(4,&overflow);set_system_gate(5,&bounds);set_trap_gate(6,&invalid_op);set_trap_gate(7,&device_not_available);set_trap_gate(8,&double_fault);set_trap_gate(9,&coprocessor_segment_overrun);set_trap_gate(10,&invalid_TSS);set_trap_gate(11,&segment_not_present);set_trap_gate(12,&stack_segment);set_trap_gate(13,&general_protection);set_trap_gate(14,&page_fault);set_trap_gate(15,&reserved);set_trap_gate(16,&coprocessor_error);// 下⾯将int17-48 的陷阱⻔先均设置为reserved,以后每个硬件初始化时会重新设置⾃⼰的陷阱⻔。for (i=17;i<48;i++)set_trap_gate(i,&reserved);set_trap_gate(45,&irq13);// 设置协处理器的陷阱⻔。outb_p(inb_p(0x21)&0xfb,0x21);// 允许主8259A 芯⽚的IRQ2 中断请求。outb(inb_p(0xA1)&0xdf,0xA1);// 允许从8259A 芯⽚的IRQ13 中断请求。set_trap_gate(39,¶llel_interrupt);// 设置并⾏⼝的陷阱⻔。void rs_init (void){set_intr_gate (0x24, rs1_interrupt); // 设置串⾏⼝1 的中断⻔向量(硬件IRQ4信号)。set_intr_gate (0x23, rs2_interrupt); // 设置串⾏⼝2 的中断⻔向量(硬件IRQ3信号)。init (tty_table[1].read_q.data); // 初始化串⾏⼝1(.data 是端⼝号)。init (tty_table[2].read_q.data); // 初始化串⾏⼝2。outb (inb_p (0x21) & 0xE7, 0x21); // 允许主8259A 芯⽚的IRQ3,IRQ4 中断信号请求。}
}
1.3 中断向量表
# 中断向量表(IDT) 是操作系统启动时加载到内存的数据结构,实现中断号到处理程序的映射。
# 核心组成:
组件 | 作用 | Linux 0.11示例 |
---|---|---|
中断门(Interrupt Gate) | 处理外部硬件中断,自动禁用中断响应 | set_intr_gate(0x24, rs1_interrupt) (串口中断) |
陷阱门(Trap Gate) | 处理内部异常(如除零错误),允许嵌套中断 | set_trap_gate(14, &page_fault) (缺页异常) |
中断屏蔽寄存器 | 控制中断使能状态 | outb(inb_p(0x21) & \~0x01, 0x21) (开启时钟中断) |
1.4 CPU中断响应全流程
2、时钟中断
2.1 概念
# 当没有中断时,操作系统在做什么?什么都不做,一直在暂停,等待中断到来。
# 操作系统自己被谁指挥,被谁推动执行?被时钟中断指挥和推动执行。操作系统不是一个主动的"管理者",而是一个被动的"响应者"。它通过时钟中断这个规律性的心跳来获得执行机会,从而进行调度、管理和维护工作。
# 有没有可以定期触发中断的设备?有,这就是时钟源 / 系统定时器 / 时钟芯片。计算机中有专门的硬件定时器(如8253/8254 PIT或HPET)集成在 CPU 上,它们能够以固定的频率产生中断信号,这就是时钟中断的来源。
# 中断向量表上注册有一个 schedule 方法,配合时钟源发的中断,CPU 读取中断号来索引中断向量表,就会执行进程调度的方法,因此操作系统就在硬件时钟中断的驱动下完成调度,所以操作系统就是基于中断来运行的软件。
# 所以操作系统的本质就是一个死循环,当没有外部设备就绪时,就一直暂停,由时钟源定期发送中断进而完成调度。
2.2 Linux 0.11 时钟中断机制详解
初始化过程:
// 在sched_init()中设置时钟中断
void sched_init(void) {// ...set_intr_gate(0x20, &timer_interrupt); // 设置时钟中断处理程序outb(inb_p(0x21) & ~0x01, 0x21); // 允许时钟中断(IRQ0)// ...
}
时钟中断处理程序:
// 汇编代码:timer_interrupt
_timer_interrupt:push %dspush %eaxmovl $0x10, %eaxmov %ax, %dsmovb $0x20, %aloutb %al, $0x20 # 向8259A发送EOI(中断结束)信号movl $0, %eaxincl %eaxmovl %eax, jiffies # 更新系统时钟滴答计数pushl $0x10 # 参数:CPL(当前特权级)call _do_timer # 调用C函数处理定时任务popl %eaxpopl %eaxpop %dsiret
do_timer 函数:核心处理逻辑
// kernel/sched.c
void do_timer(long cpl) {extern int beepcount;extern void sysbeepstop(void);// 更新系统时间if (beepcount)if (!--beepcount)sysbeepstop();// 更新当前进程时间片if (--current->counter > 0)return;// 时间片用完,需要重新调度current->counter = 0;schedule(); // 调用调度程序
}
调度函数 schedule:
// kernel/sched.c
void schedule(void) {int i, next, c;struct task_struct **p;// 寻找就绪状态且counter值最大的进程while (1) {c = -1;next = 0;i = NR_TASKS;p = &task[NR_TASKS];while (--i) {if (!*--p)continue;if ((*p)->state == TASK_RUNNING && (*p)->counter > c)c = (*p)->counter, next = i;}if (c) break; // 找到了可运行的进程// 所有进程的时间片都已用完,重新分配时间片for (p = &LAST_TASK; p > &FIRST_TASK; --p)if (*p)(*p)->counter = ((*p)->counter >> 1) + (*p)->priority;}// 切换到选中的进程switch_to(next);
}
2.3 时间片与调度机制
# 时间片(Time Quantum)
-
每个进程被分配一个执行时间单位(时间片)
-
在Linux 0.11中,时间片大小与进程的
counter
值相关 -
时钟中断每次发生时,当前进程的
counter
减1 -
当
counter
减到0时,进程被剥夺CPU使用权
# 优先级与时间片分配
// 重新计算时间片的公式
(*p)->counter = ((*p)->counter >> 1) + (*p)->priority;
这个公式确保:
-
进程的剩余时间片会继承一半到下一个周期
-
加上固定的优先级值,保证每个周期都有基本的时间片
-
优先级高的进程获得更多CPU时间
3、死循环
# 如果是这样,操作系统不就可以躺平了吗?对,操作系统自己不做任何事情,需要什么功能,就向中断向量表里面添加方法即可。操作系统的本质:就是一个死循环!
3.1 Linux 0.11 的主函数结构
void main(void) {// 初始化阶段:设置整个系统的基础设施mem_init(); // 内存管理初始化trap_init(); // 中断向量表初始化blk_dev_init(); // 块设备初始化sched_init(); // 调度器初始化// ... 其他初始化工作// 创建init进程(用户空间的第一个进程)if (!fork()) {init();}// 主循环:操作系统的"休息"状态for (;;) {pause(); // 等待中断发生}
}
这个死循环的真正含义
-
不是忙等待:
pause()
系统调用会让 CPU 进入低功耗状态 -
中断驱动:只有在中断发生时,CPU才会跳出暂停状态
-
事件响应:操作系统作为中断处理程序的"调度中心"
注意:对于任何其它的任务,pause()
将意味着我们必须等待收到⼀个信号才会返回就绪运⾏态,但任务0(task0)是唯⼀的意外情况( 参⻅schedule() ),因为任务 0 在任何空闲时间⾥都会被激活(当没有其它任务在运⾏时),因此对于任务0,pause()
仅意味着我们返回来查看是否有其它任务可以运⾏,如果没有的话我们就回到这⾥,⼀直循环执⾏pause()
。
3.2 pause() 的内部机制
// pause() 的简化实现
int pause(void) {// 将当前进程状态设为可中断睡眠current->state = TASK_INTERRUPTIBLE;// 调用调度器,选择其他进程运行schedule();// 当信号到达时,从这里恢复执行return -EINTR;
}
为什么使用 pause() 而不是空循环?
方式 | CPU使用率 | 功耗 | 响应速度 |
---|---|---|---|
空循环(while(1); ) | 100% | 高 | 即时 |
pause() | 接近0% | 低 | 依赖中断响应时间 |
4、软中断
4.1 概念
# 述外部硬件中断,需要硬件设备通过特定信号线(如IRQ线)触发。例如,当键盘按键被按下时,键盘控制器会通过中断请求线向CPU发送电信号,CPU检测到后会暂停当前任务处理中断。
# 有没有可能,因为软件原因,也触发上面的逻辑?有!这被称为"软件中断"或"陷阱中断"。这种中断不是由外部设备产生,而是由CPU执行特定指令主动触发。常见的场景包括:
- 除零错误等异常情况
- 调试断点
- 系统调用
4.2 系统调用表
# 为了让操作系统支持进行系统调用,CPU 厂商设计了专门的汇编指令,可以让 CPU 内部触发中断逻辑:
- x86 架构使用 int 指令(如int 0x80)
- x86_64 架构使用 syscall / sysenter 指令
- ARM 架构使用 SWI / SVC 指令 这些指令会让 CPU 内部产生中断逻辑,切换到内核模式。
我们都知道 CPU 只有一个,那在进行系统调用的时候,是如何进入操作系统进而完成系统调用的呢?
- 其实操作系统上所有的系统调用都是被写在一张系统调用表的函数指针数组里面的,每个系统调用都有一个唯一的下标,这个下标叫做系统调用号,是在内核中的。
- 当用户调用 syscall ,就会执行中断向量表中的 0x80 方法来获取系统调用号,再通过系统调用号来调用系统调用方法
注意:我们现在得矫正一个观点,即操作系统并不会用户提供任何系统调用,只会提供系统调用号!而我们之前学习的所有系统调用,如 open、fork...都是在 glibc 里面封装好的。
# 用户层怎么把系统调用号给操作系统? - 通过寄存器(比如 EAX )
4.3 系统调用原理
1. 系统调用的发起(以 open
、vfork
为例)
- 在用户层(如 glibc 库中),先将系统调用名称(如
open
、vfork
)通过宏SYS_ify
转换为系统调用号(例如SYS_ify(open)
会展开为__NR_open
,这个调用号由操作系统内核提供,而非 glibc)。 - 把系统调用号存入寄存器(如
eax
寄存器),再通过syscall
指令(或int 0x80
软中断指令),触发从用户态到内核态的切换,发起系统调用。
2. 内核态的处理流程
- 当
syscall
或int 0x80
触发软中断后,CPU 会执行内核中的中断处理程序(如system_call
方法)。 system_call
会从寄存器中拿到系统调用号,然后到系统调用表(sys_call_table
)中,以 “调用号 × 4”(因为 32 位系统中指针占 4 字节)为下标,找到对应的内核函数并调用,从而完成系统调用的核心逻辑(比如open
对应的内核打开文件操作)。
4.4 缺页中断?内存碎片处理?除零野指针错误?
# 缺页中断、内存碎片处理、除零错误、野指针访问等系统级问题,在硬件层面都会被转换为CPU内部的软中断信号。这些中断信号会触发预先注册的中断处理例程(Interrupt Service Routine),由操作系统内核完成相应的处理逻辑。
# 所有异常统一走中断处理路径,实现事件驱动架构(操作系统本质是"躺在中断处理例程上的代码块"),有的是进行申请内存、填充页表、进行映射的,有的是用来处理内存碎片的,有的是用来给目标进程发送信号的...
4.5 中断、陷阱、异常、终止
类型 | 触发源 | 同步 / 异步 | 可恢复性 | 典型用途 | 处理后是否返回原程序 |
---|---|---|---|---|---|
中断(Interrupt) | 外部硬件(如网卡、键盘) | 异步 | 无(处理外部事件) | 设备交互、进程调度 | 是(返回被中断任务) |
陷阱(Trap) | 主动触发(如系统调用指令) | 同步 | 必然恢复 | 系统调用、调试断点 | 是(返回下一条指令) |
异常(Exception) | 内部错误(如除零、缺页) | 同步 | 部分可恢复 | 错误处理(如缺页加载、信号通知) | 可能(如缺页重试) |
中止(Abort) | 致命错误(如硬件故障、内核崩溃) | 同步 / 异步 | 不可恢复 | 致命错误处理(日志、重启) | 否(终止或重启) |
# 中断门 vs 系统门:
类型 | 特权级切换 | 典型应用 | 注册函数 |
---|---|---|---|
陷阱门(Trap) | 不自动关中断 | 除零/缺页等异常 | set_trap_gate() |
系统门(System) | 允许用户态触发 | 系统调用(int 0x80) | set_system_gate() |
⚠️ 关键区别:陷阱门处理期间不屏蔽中断,允许更高优先级中断抢占
二、深入理解用户态、内核态
1、内核页表
- 空间分配:虚拟地址空间被划分为0 ~ 3 GB的用户空间和3 ~ 4
GB
的内核空间。- 用户空间(0 ~ 3 GB):进程以用户态运行时,只能访问此空间内的代码、数据(如未初始化数据、初始化数据、正代码等),受权限限制,无法直接访问内核空间。
- 内核空间(3 ~ 4 GB):只有进程进入内核态(如通过系统调用、中断等),才能访问该空间,内核态下可操作系统核心代码、数据及硬件资源。
- 地址转换:
- 用户页表:用户空间的虚拟地址通过用户页表,映射到物理内存中进程对应的实际地址,实现用户空间内的地址转换。
- 内核页表:内核空间的虚拟地址通过内核页表,映射到物理内存中操作系统内核加载的位置,支撑内核态下的地址转换。
用户页表有多份,有多少个进程就有多少份,而内核页表只有一份,被所有进程共享。因此操作系统⽆论怎么切换进程,都能找到同⼀个操作系统!换句话说,操作系统系统调⽤⽅法的执⾏, 是在进程的地址空间中执⾏的。
2、特权级
# CPU 通过 cs 段寄存器的最后两个比特位来区分用户态和内核态,这两个比特位也叫做当前特权级(CPL),动态标记运行环境权限,x86 架构采用四级特权环(Ring 0-3):
- 用户态(Ring 3):CPL=3,仅能访问用户空间(0x00000000 - 0xBFFFFFFF)
- 内核态(Ring 0):CPL=0,可访问全部内存(包括内核空间 0xC0000000 - 0xFFFFFFFF)
# 当我们正在访问用户区的代码时,CPL为3,当代码想要访问内核时,CPU 就要寻址,寻址发现当前是用户态,所以 CPU 就直接走异常中断,终止进程。
# 所以int 0x80 或 syscall 指令集的工作就是让cs 段寄存器指向操作系统的代码区,把 CPL 由3设为0,此时就称作陷入内核。
3、用户态到内核态切换底层机制
# 用户态→内核态通过三类事件触发:
- 软中断指令:
int 0x80
(传统)或syscall
(现代)显式请求 - 硬件自动校验:CPU 比较 CPL(当前特权级)、DPL(目标段描述符特权级)、RPL(请求特权级),仅当 CPL ≤ DPL 时允许切换
# 安全检查机制:
# 从用户态到内核态的安全切换:
用户程序调用系统调用 → 执行int 0x80/syscall↓
CPU自动进行特权级检查(CPL vs DPL)↓
如果检查失败 → 触发#GP异常 → 杀死进程↓
如果检查通过 → 切换栈指针到内核栈↓
保存用户态寄存器状态↓
根据系统调用号查找系统调用表↓
执行对应的内核函数(进行参数验证)↓
完成操作后返回用户态
三、可重入函数
# 我们看一个场景:
# 像上例这,insert 函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入。insert 函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。
# 如果一个函数符合以下条件之一则是不可重入的:
- 调用了
malloc
或new
,因为malloc
也是全局链表来管理堆的。- 调用了标准
I/O
库函数。标准I/O
库函数的很多实现都以不可重入的方式使用全局的数据结构。
# 如果一个函数符合以下条件则是可重入的:
- 只有自己的临时变量
四、volatile
# volatile
其实是C语言的一个关键字,该关键字的作用是保持内存的可见性。
# 比如如下这段代码:
#include <iostream>
#include <signal.h>
#include <cstring>
#include <unistd.h>using namespace std;int flag = 0;
void handler(int signum)
{cout << "cat a signal: " << signum << endl;flag = 1;
}int main()
{signal(2, handler);while(!flag);cout << "process quit normal" << endl;return 0;
}
# 如果正常运行程序会陷入死循环,但是如果发送 2 号信号,flag
被修改,程序就会正常结束,结果也应我们所料。
# 在优化条件下,由于 main 函数与 handler 函数分属两个不同的执行流。而 while 循环处于 main 函数中,此时编译器进行检测,若发现 main 函数中不存在对 flag 值进行修改的操作,那么 flag 变量就可能会被编译器直接优化到 CPU 内的寄存器中。后续在收到信号时调用 handler 方法对 flag 进行修改,修改的是内存中 flag 的值,并未修改寄存器中的 flag 值。而 CPU 一直使用的是寄存器中的 flag ,因此就可能陷入死循环中。
需要注意的是,g++
编译器默认不进行优化,可带选项 -O0、-O1、-O2、-O3
进行这四种优化等级。
# 此时我们发现,程序陷入死循环当中了,这是因为编译器的优化,导致寄存器覆盖了进程看到变量的真实情况,导致内存不可见了。
# 为了解决这种问题,可以用 volatile
修饰 flag
变量,告诉编译器不要对该变量进行优化,每次访问都去内存加载该变量的值到 CPU ,保存进程对该变量的可见性,保证内存的可见性。
五、SIGCHLD信号
# 我们之前讲过用 wait 和 waitpid 函数来管理子进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。
# 采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。
# 其实,子进程在终止时会给父进程发 SIGCHLD 信号,该信号的默认处理动作是忽略,父进程可以自定义 SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用 waitpid清理子进程。
- SIGCHLD 属于普通信号,记录该信号的 pending 位只有一个,如果在同一时刻有多个子进程同时退出,那么在 handler 函数当中实际上只清理了一个子进程,因此在使用 waitpid 函数释放子进程资源时需要使用循环不断进行清理。
- 使用 waitpid 函数时,需要设置WNOHANG选项,即非阻塞式 轮询等待,否则当所有子进程都已经清理完毕时,由于 while 循环,会再次调用 waitpid 函数,此时就会在这里一直阻塞住。
# 下面 我们来看一段代码:
#include<iostream>
#include<cstdlib>
#include<sys/wait.h>
#include<unistd.h>void WaitAll(int num)
{while(true){// pid_t n = waitpid(-1, nullptr, 0); // waitpid默认是阻塞的pid_t n = waitpid(-1, nullptr, WNOHANG);if(n == 0){break;}else if(n < 0){std::cout << "waitpid error" << std::endl;break;}}std::cout << "father get a signal: " << num << std::endl;
}int main()
{// 父进程signal(SIGCHLD, WaitAll);pid_t id = fork(); // 如果我们有10个子进程,6个退出了,4个没退?if(id == 0){std::cout << "I am a child, exit" << std::endl;sleep(3);exit(3);}// waitpid(id, nullptr, 0);while(true){std::cout << "I am a father, exit" << std::endl;sleep(1);}return 0;
}
# 事实上,由于 UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用 signal 或 sigaction 函数将 SIGCHLD 信号的处理动作设置为 SIG_IGN,这样 fork 出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。此方法对于 Linux 可用,但不保证在其他 UNIX 系统上都可用。
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
int main()
{signal(SIGCHLD, SIG_IGN);if (fork() == 0){//childprintf("child is running, child dead: %d\n", getpid());sleep(3);exit(1);}//fatherwhile (1);return 0;
}
# 注意:上文说 SIGCHLD 信号的默认处理动作是 IGN ,那为什么在这还要手动设置为 SIG_IGN 呢?
# 这是因为 SIGCHLD 的默认处理动作是 SIG_DFL ,只不过 SIG_DFL 刚好是 IGN , 这两者并不相等。