1. 信号捕捉流程
操作系统会在合适的时候处理信号,那这个合适的时候是什么时候呢?进程从内核态返回到用户态的时候。
假如用户程序注册了 SIGQUIT
信号的处理函数 sighandler。
当程序正在执行 main
函数时,如果发生中断、异常或系统调用,程序会进入 内核态(如图 1 所示)。
在内核完成中断或异常的处理后,准备返回用户态的 main
函数执行。但在这个过程中,内核会先检查当前进程是否有待递达的信号(如图 2 所示)。如果 SIGQUIT
信号已递达,内核会调用 do_signal()
进行信号处理(如图 3 所示)。
如果 SIGQUIT
信号的处理方式是用户自定义,那么内核不会恢复 main
的上下文,而是直接让程序跳转执行 sighandler
处理信号(如果信号处理函数正常返回,则会将pending表中的1置为0再返回应用层)。此时,sighandler
和 main
之间并没有调用和被调用的关系,它们是两个独立的控制流程,并且使用不同的堆栈空间(如图 3 所示)。
当 sighandler
处理完成后,程序不会直接返回 main
,而是会执行一个特殊的 sigreturn
系统调用,使程序再次进入内核态(如图 4 所示)。
内核在 sys_sigreturn()
处理完成后,如果没有新的信号需要递达,就会恢复 main
函数的上下文,让程序从被中断的位置继续执行(如图 5 所示)。
2. 操作系统是如何运行的
2.1 硬件中断
硬件中断(Hardware Interrupt) 是操作系统运行的核心机制之一,它允许 CPU 在执行用户程序的过程中,被外部设备或特定事件打断,转而执行内核提供的中断处理程序。
1. 什么是硬件中断?
硬件中断是指 CPU 在执行指令时,外部设备(如键盘、网卡、硬盘)或内部组件(如定时器)向 CPU 发送信号,请求操作系统的处理。
当 CPU 收到中断信号时,会暂停当前正在执行的任务,切换到内核态,执行对应的中断处理程序。
2. 硬件中断的来源
外部设备中断:键盘输入、鼠标点击、硬盘 I/O 完成、网络数据到达等。
定时器中断:操作系统定期使用定时器中断(如时钟中断)进行任务调度和时间管理。
处理器异常(Faults):如除零错误、缺页异常、非法指令等,也属于中断的一种,但由 CPU 内部产生。
3. 硬件中断的执行流程
(1)设备触发中断信号
例如,当用户按下键盘,键盘控制器会向 CPU 发送中断请求(IRQ,Interrupt Request)。
(2)CPU 响应中断
当前正在运行的用户程序暂停,CPU 保存当前执行状态(如寄存器内容、程序计数器),切换到内核态,然后跳转到中断处理程序(ISR,Interrupt Service Routine)。
(3)执行中断处理程序
内核根据中断类型,执行相应的处理逻辑。例如,处理键盘输入、读取磁盘数据、调度进程等。
(4)中断处理完成,恢复执行
操作系统处理完中断后,使用 iret
指令(x86 架构)或 ERET
指令(ARM 架构)恢复 CPU 的执行状态,切换回用户态,继续执行被中断的程序。
4. OS如何知道键盘上有数据:通过中断机制和中断向量表
中断向量表是操作系统的一部分,启动就加载到内存中了。
通过外部硬件中断,操作系统就不需要对外设进行任何周期性的检测或者轮询。
(1)用户按下按键
当你按下键盘上的一个键(例如字母 "A"),键盘的电路会检测到按键事件,并将其转换为一个 扫描码(Scan Code)。
(2)键盘控制器发送中断信号
计算机的键盘与 CPU 之间通常通过 键盘控制器(Keyboard Controller, 例如 8042 芯片,这是一个中断控制器) 进行通信。
该控制器检测到新的按键输入后,会通过 中断请求线(IRQ 1,键盘的默认 IRQ 号) 向 CPU 发送 中断请求(Interrupt Request, IRQ)。
(3)CPU 响应键盘中断
CPU 立即暂停当前执行的任务,保存寄存器状态,并跳转到中断向量表(Interrupt Vector Table, IVT)中查找对应的中断处理程序。
对于键盘中断,通常是 IRQ 1(中断号 0x21),它指向键盘中断处理程序(ISR, Interrupt Service Routine)。
总结
硬件中断是用户态与内核态切换的重要触发机制之一。当外部设备或 CPU 内部事件需要操作系统的处理时,会触发中断,导致 CPU 进入内核态,执行中断处理程序。处理完成后,CPU 再次返回用户态,继续执行原来的任务。硬件中断使得计算机系统能够高效地管理设备和任务调度,从而实现多任务并发和实时响应。
操作系统就是在硬件时钟中断的调度下进行调度的,操作系统就是基于中断进行工作的软件。
2.2 时钟源与进程调度
操作系统通过定时器中断(Timer Interrupt)来进行进程调度,决定哪个进程可以使用 CPU。定时器中断由时钟源提供,在一定时间间隔触发,触发后执行中断服务程序(ISR, Interrupt Service Routine),然后调用**调度器(Scheduler)**进行进程切换。
(1)进程调度的工作流程
-
CPU 运行某个进程
-
定时器触发中断(例如每 10 毫秒一次)
-
CPU 进入内核态,执行定时器中断的 ISR
-
操作系统判断进程的时间片是否用完
-
时间片未用完:继续执行当前进程。
-
时间片用完:调用调度器选择新进程。
-
-
执行进程切换(Context Switch)
-
恢复新进程的上下文,返回用户态
-
CPU 继续执行新进程
(2) 时间片(Time Slice)
时间片是指CPU 分配给进程的最大连续运行时间,单位通常是毫秒级(如 10ms、50ms)。
时间片的作用
-
控制进程的公平性:确保所有进程都能获得 CPU 运行时间。
-
提高系统响应速度:避免某个进程长时间占用 CPU,导致其他任务等待。
-
实现抢占式调度:当时间片用完时,操作系统强制切换进程。
时间片与主频的关系
假设 CPU 运行在 3 GHz,即 3×10⁹ 次时钟周期/秒:
-
时间片 = 10ms
-
总时钟周期数 = 3×10⁹ × 10×10⁻³ = 3×10⁷(3000万次时钟周期)
-
CPU 在这 10ms 内可以执行 3000万条指令(假设 IPC = 1)。
(3) 结合示例分析
时钟源、主频、中断、时间片的协同工作
假设 Linux 运行一个任务,并采用 时间片轮转调度(Round Robin):
-
CPU 运行进程 A,时钟源提供 1ms 级定时信号。
-
10ms 后,时钟中断触发,CPU 进入内核态,执行进程调度。
-
操作系统检查 A 的时间片:
-
若未用完,则继续执行。
-
若用完,则调度 进程 B 运行,切换 CPU 上下文。
-
-
进程 B 开始执行,重复上述过程。
➡ 结论:
-
时钟源提供中断信号,触发 CPU 进行进程调度。
-
主频影响 CPU 处理指令的速度,从而影响时间片内可执行的任务量。
-
中断服务负责处理定时器中断,调用调度器决定哪个进程运行。
-
时间片决定每个进程能运行多长时间,从而实现多任务并发。
2.3 软件中断
1. 什么是软件中断?
软件中断是由指令触发的中断,而不是由外部设备(如键盘、时钟)引发的硬件中断。它允许程序主动向操作系统请求服务,例如系统调用(syscall),从而完成文件操作、进程管理、内存管理等任务。
2. 软件中断 vs. 硬件中断
特性 | 软件中断 | 硬件中断 |
---|---|---|
触发方式 | 由指令(如 int 指令)触发 | 由外部设备或定时器触发 |
控制权 | 由程序主动请求 | 由外部事件异步触发 |
作用 | 实现系统调用、调试等 | 处理 I/O 事件、时钟中断等 |
示例 | int 0x80 (Linux 系统调用) | 键盘中断(IRQ 1)、时钟中断(IRQ 0) |
3. 软件中断的实现方式
软件中断通常有以下几种实现方式:
int
指令(x86 架构)syscall
指令(现代 Linux)- 异常机制(如除零异常、缺页异常)
trap
指令(RISC 体系结构)
4. 软件中断在系统调用中的作用
在 x86 架构的早期 Linux 版本(如 Linux 2.4 及更早),系统调用通常通过 int 0x80
指令来触发软件中断,使 CPU 切换到内核态并执行系统调用。例如:
mov eax, 1 ; SYS_exit
mov ebx, 0 ; exit status = 0
int 0x80 ; 触发系统调用
其中,eax
寄存器存放系统调用号(SYS_exit
),ebx
传递参数,int 0x80
触发软件中断,进入内核处理。
2. 软件中断 vs. syscall
指令
随着 CPU 发展,为了提高性能,现代 x86-64 处理器引入了 syscall
指令,它比 int 0x80
更快,因为:
- syscall
指令直接切换到内核态,而 int 0x80
需要查找中断向量表,额外的开销更大。
- syscall
使用 rax
作为系统调用号,rdi
、rsi
、rdx
等寄存器传递参数,避免了 int 0x80
依赖栈的开销。
CPU内部的软中断,比如int 0x80或者syscall,我们叫做 陷阱
2.4 系统调用表
操作系统提供系统调用号以及系统调用的具体实现,而 glibc 等标准库对这些系统调用进行封装,提供更易用的接口。
1. 用户调用系统调用:
系统调用由操作系统内核提供,但不会以普通函数的形式直接暴露给用户程序。相反,操作系统通过 系统调用号 标识不同的系统调用。在用户空间,程序通常通过库函数(如 glibc)间接调用系统调用,这些库函数会生成相应的系统调用号并传递给操作系统,从而触发对应的系统调用。
系统调用的实际处理过程:
-
系统调用号传递: 用户空间的程序(通过
glibc
)会调用一些库函数(例如write()
),这些库函数会在内部使用syscall
或类似机制,将系统调用号和参数传递给内核。 -
内核接收请求: 内核接收到系统调用号后,会通过系统调用号查询系统调用表,找到对应的内核处理函数。举个例子,
write()
系统调用号会对应到内核中的sys_write
函数。 -
系统调用表: 在 Linux 内核中,系统调用表是一个内核维护的函数指针表,其中每个条目对应一个系统调用的处理函数。例如,
sys_write
函数用于处理write()
系统调用。 -
系统调用函数实现:
-
内核中维护的
sys_write
函数实现了如何将数据写入文件描述符。 -
这个函数会通过文件系统和设备驱动程序将数据写入磁盘或其他设备。
-
总结:
- 操作系统内核提供系统调用号和系统调用的处理机制,处理系统调用请求。
- glibc等用户库封装了对系统调用的调用,提供了易用的接口给用户程序。
- 内核通过系统调用号在系统调用表中找到对应的内核函数,并执行相应操作。
综上所述,操作系统就是躺在中断处理例程上的代码块!
3. 用户态与内核态
3.1 基本概念
系统调用的过程也是在进程地址空间中进行的,所有的函数调用都是地址空间之间的跳转
操作系统的运行分为用户态(User Mode)和内核态(Kernel Mode):
- 用户态:普通应用程序在用户态运行,受限于权限,不能直接访问硬件资源。
- 内核态:操作系统内核在内核态运行,拥有最高权限,可以直接访问硬件资源,如内存、CPU、设备等。
- CPL(Current Privilege Level)用于指示当前 CPU 运行的权限级别,其中 0 代表内核态,3 代表用户态。
- 进程运行时,通常在用户态执行普通代码,而当需要访问系统资源(如文件、网络、设备)时,需要通过系统调用(syscall)切换到内核态执行。
3.2 进程地址空间
- 进程的地址空间划分为 [0,3]GB 作为用户区,[3,4]GB 作为内核区。
- 每个进程都拥有各自独立的用户区,但内核区只有一份,并被所有进程共享。
这意味着无论进程如何调度,我们总能找到操作系统。
关键问题:用户和内核都在同一个地址空间上,用户是否可以直接访问内核?
- 如果用户进程能随意访问[3,4GB]的内核地址,就会导致系统崩溃或安全问题。
- 操作系统通过权限控制,限制用户进程访问内核地址。
- 只有系统调用才能让用户进程间接访问内核功能。
总结:
用户态:只能访问自己的 [0,3GB]。
内核态:拥有最高权限,可以访问 [3,4GB]。
OS 通过 CS 寄存器(Code Segment Register)来标识当前运行态(CPL 0: 内核态, CPL 3: 用户态)。
3.3 系统调用的触发
软件中断(陷阱):用户程序无法直接执行内核代码,而是通过软件中断(如 int 0x80
或 syscall
指令)请求内核服务。
调用流程:
1. 用户态程序调用库函数(如 printf()),该函数最终会调用底层的 write()。
2. write() 函数内部使用 syscall 触发软件中断,让 CPU 切换到内核态。
3. 操作系统内核查找对应的系统调用处理函数,执行系统级操作(如写入文件)。
4. 执行完毕后,内核返回到用户态,继续执行用户程序。
重要机制:
进程在用户态时,不能访问内核空间,以防止非法操作和安全漏洞。
进程只能通过 syscall 进入内核态,然后返回用户态。
3.4 段描述符表(GDT)和段权限
在x86 保护模式下,段描述符表(GDT/LDT)中的 RPL(请求特权级,Requested Privilege Level)和 DPL(描述符特权级,Descriptor Privilege Level)与系统的特权级机制紧密相关。它们用于控制进程或任务对不同内存段的访问权限,以增强安全性。
1. CPL(Current Privilege Level)
- CPL 是 当前运行的代码的特权级,存储在 CS 段寄存器的低 2 位。
- 通常,内核代码运行在 CPL = 0,用户代码运行在 CPL = 3。
2. DPL(Descriptor Privilege Level)
- DPL 是 段描述符(如代码段、数据段、栈段)中的特权级字段,占 2 位(0~3)。
- 其值决定了访问该段所需的最低特权级。
- 只有 CPL(当前特权级,Current Privilege Level)≤ DPL 的进程才能访问该段(数据段)。
- 代码段的访问规则更复杂,受 CPL == DPL 或 DPL < CPL(调用门)约束。
3. RPL(Requested Privilege Level)
- RPL 是 段选择子(Segment Selector)中的 2 位字段(最低 2 位)。
- RPL 代表请求访问某个段时的优先级,通常用于 软件层级上的额外访问限制。
- 访问时,CPU 取 最低特权级 max(CPL, RPL) 来与 DPL 进行权限判断。
4. 权限检查规则
当 CPU 访问某个段时,遵循以下规则:
(1) 数据段访问:max(CPL, RPL) ≤ DPL,否则拒绝访问。
(2) 代码段访问:
CPL == DPL:可直接跳转(如同级调用)。
CPL > DPL:需要通过调用门(call gate)访问。
CPL < DPL:不允许访问(低权限代码不能直接跳到高权限代码)。
5. 示例
假设:
CPL = 3(用户模式),段描述符 DPL = 0(内核段),RPL = 3(用户态请求)。
则:
由于 max(3,3) = 3,但 DPL = 0,不满足 max(CPL, RPL) ≤ DPL,访问被拒绝。
如果 DPL = 3(用户模式段),则 max(3,3) ≤ 3,允许访问。
总结
- 用户态程序不能直接访问内核资源,必须通过系统调用切换到内核态。
- 软件中断(syscall, int 0x80)是用户态到内核态切换的主要方式。
- 进程的地址空间是隔离的,但内核地址空间是所有进程共享的。
- GDT 段描述符表用于定义不同的权限级别,确保用户进程不能非法访问内核数据。
4. sigaction
`sigaction` 是 Linux/Unix 系统中用于管理信号处理的系统调用和结构体,它提供了比传统 `signal()` 函数更灵活、更可靠的信号处理机制。
3.1 函数原型
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
- 参数:
- `signum`:信号编号(如 `SIGINT`, `SIGTERM`)。
- `act`:新的信号处理配置(若为 `NULL` 则不修改)。
- `oldact`:保存旧的信号配置(若为 `NULL` 则不保存)。
- 返回值:成功返回 `0`,失败返回 `-1`。
3.2 `struct sigaction` 结构体
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()` 的简单处理函数。
- `sa_sigaction`:支持附加信息的复杂处理函数(需 `SA_SIGINFO` 标志)。
- `sa_mask`:在执行信号处理函数时,自动阻塞的信号集。
- `sa_flags`:控制信号行为的标志(如 `SA_RESTART` 自动重启被中断的系统调用)。
3.3 关键特性
(1)信号阻塞(`sa_mask`)
在执行信号处理函数时,自动阻塞 `sa_mask` 中指定的信号,防止重入:
sigaddset(&sa.sa_mask, SIGQUIT); // 处理 SIGINT 时阻塞 SIGQUIT
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <cstdlib>
void handler(int signum)
{
std::cout << "捕捉到信号:" << signum << std::endl;
while(true)
{
sigset_t pending;
sigpending(&pending);
for(int i = 31; i >= 1; i--)
{
if(sigismember(&pending, i)) std::cout << "1";
else std::cout << "0";
}
std::cout << std::endl;
sleep(1);
}
exit(0);
}
int main()
{
struct sigaction act, oact;
act.sa_handler = handler;
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, 3);
sigaddset(&act.sa_mask, 4);
act.sa_flags = 0;
sigaction(SIGINT, &act, &oact); //对二号信号进行捕捉
while(true)
{
std::cout << "hello, world: " << getpid() << std::endl;
sleep(1);
}
return 0;
}
- `SA_RESTART`:自动重启被信号中断的系统调用(如 `read()`)。
- `SA_NODEFER`:不阻塞当前信号类型(默认阻塞)。
- `SA_RESETHAND`:信号处理后恢复为默认行为(类似 `signal()`)。
5. 可重入函数
可重入函数(Reentrant Function) 是指在多个执行线程(如多线程或信号处理程序)可以同时安全调用的函数。它不会因为共享状态而引发数据竞争或未定义行为。
(1) 可重入函数的特点
-
不使用全局或静态变量,只能使用局部变量(栈上的变量)。
-
不调用非可重入函数,不能调用可能修改全局状态的函数,例如
malloc
、printf
、rand
。 -
不使用不可重入的系统调用,不能使用
fork()
之后只在子进程执行malloc()
,因为malloc()
可能会导致死锁。 -
不依赖共享资源,不能访问文件描述符、全局变量、共享内存等。
(2)常见的不可重入函数
涉及全局状态的函数:
rand() / srand()(使用全局种子)
getenv()(返回指向全局缓冲区的指针)
strtok()(内部使用静态变量)
ctime() / asctime()(返回指向静态存储区的指针)
动态内存管理:
malloc() / free()(使用全局堆)
标准 I/O:
printf() / scanf() / sprintf()(使用全局缓冲区)
(3)可重入函数示例
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
void handler(int signum) {
write(STDOUT_FILENO, "Signal received\n", 16); // 使用 write 而不是 printf
_exit(0);
}
int main() {
struct sigaction act;
act.sa_handler = handler;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
sigaction(SIGINT, &act, nullptr);
while (true) {
std::cout << "Running..." << std::endl;
sleep(1);
}
return 0;
}
为什么 write()
可重入,而 printf()
不可重入?
write()
是一个低级系统调用,直接操作文件描述符。
printf()
需要管理标准 I/O 缓冲区,可能导致竞态条件。
6. SIGCHLD信号
SIGCHLD
信号是 Linux 进程间通信(IPC)中的一个重要信号,用于通知父进程其子进程的状态发生了变化,例如子进程终止、暂停或继续运行。它的主要作用是让父进程能够及时回收子进程的资源,避免僵尸进程的产生。
1. SIGCHLD
信号的特点
- 由子进程的状态变化(终止、暂停、恢复)触发,发送给其父进程。
- 默认情况下,SIGCHLD 信号会被忽略,但可以通过 signal() 或 sigaction() 进行捕捉。
- SIGCHLD 信号不会导致父进程退出,因此可以安全地用于子进程管理。
2. SIGCHLD
信号的典型应用
避免僵尸进程:父进程可以在信号处理函数中调用 waitpid()
以回收子进程资源。
并发服务器:服务器程序可以利用 SIGCHLD
处理子进程结束事件,管理并发连接。
3. 使用 SIGCHLD
处理子进程退出的示例
int main()
{
signal(SIGCHLD, SIG_IGN);
for(int i = 0; i < 10; i++)
{
pid_t id = fork();
if(id == 0)
{
sleep(3);
std::cout << "i am a child, exit" << std::endl;
exit(3);
}
}
while(true)
{
sleep(1);
std::cout << "i am father, exit" << std::endl;
}
return 0;
}