在我们使用键盘的时候,操作系统要怎么知道键盘上有数据了呢?硬件中断!
硬件中断过程如图所示:
按照图中所示,外设直接与CPU进行交互,但是之前对于冯诺依曼体系架构的学习可知,外设要和CPU交互必须要通过内存,那么怎么做到的呢?
有两个颜色的信号交互线路,分别是控制信号和数据信号。之前的描述中主要是对于数据的拷贝线路进行的学习,实际上还有一个控制信号的线路,只要进行传输控制信号即可,无需拷贝数据,所以外设可以和CPU进行交互。主要是传输信息!
外设和中央处理器之间如何连接通信呢?
上图为CPU。会有很多针脚,针脚与主板相连,外设也会和主板连接。CPU的针脚有相当一部分是用来与外设交互的。
再了解一下寄存器的概念:
当我们向磁盘发送数据,想在磁盘进行存储,假如指令in 100(扇区编号) XXXX(数据)
。
磁盘要怎么往扇区存储呢?
实际上磁盘有磁盘控制器,里面会有各个不同功能的寄存器!
拓展一下,外设都有自己的控制器!
继续理解硬件中断的过程图示:
这一套过程可以得到:
内核代码:
// 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);
// 下面将 int 17-47 的陷阱门先均设置为 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 中断信号请求。
}
for (;;) {
asm("hlt"); // 让 CPU 进入低功耗等待状态,直到下一个中断到来
}
或者:
for (;;) {
pause(); // 类似 hlt,也会在硬件中断到来前挂起
}
- **目的**<font style="color:rgb(31,35,41);">:减少功耗,不浪费 CPU 周期在“忙等”上。</font>
void main(void) /* 这里确实是 void,并没有错。 */
{
/* 在 startup 程序(head.s)中就是这样假设的。 */
// 进入无限循环,等待调度器决定是否需要执行其他任务
for (;;)
pause(); // 'pause()' 等待一个信号的到来(例如,任务切换或者硬件中断)
/*
注意!! 对于任何其它的任务,'pause()' 将意味着我们必须等待收到一个信号才会返回就绪运行态。
然而,任务0(task0)是唯一的例外情况(参见 'schedule()')。因为任务0在任何空闲时间都会被激活(当没有其它任务在运行时)。
对于任务0,'pause()' 仅意味着我们返回来查看是否有其它任务可以运行,如果没有的话,我们就回到这里,继续循环执行 'pause()'。
*/
} // end main
当没有任何外部中断(时钟、I/O、系统调用陷阱等)时,CPU 就一直跑到图最右侧的 <font style="color:rgb(31,35,41);">for(;;) pause()</font>
,等待下一个“蓝色箭头”打来的时钟中断。
时钟源以恒定频率发中断(比如 1 ns 一次),这个就是CPU的主频。每个进程被分配一个固定的“滴答数”作时间片(比如 10 个滴答 = 10 ns),每次中断既减少当前进程的剩余时间片,也把全局计数器
total
(jiffies)加一,这样既能实现进程的公平调度,又能即使离线也可以精确地统计系统运行时间。
set_intr_gate
将定时器中断(IRQ0)与处理函数(timer_interrupt
)关联。这相当于告诉内核,当硬件定时器发出中断信号时,应该跳转到哪个函数进行处理。timer_interrupt
入口,CPU 会保存现场,允许处理函数执行。此时的中断处理并不直接切换到其他任务,而是先通过汇编指令跳转到 C 语言的 do_timer
函数。void do_timer(void) {
/* 更新全局时钟节拍 */
total++; // jiffies++,记录自开机以来的中断总次数
// 让“脱机”(OS 未运行)时的时间也能被累计
/* 对当前进程的时间片计数 */
if (--current->counter > 0)
return; // 进程的时间片还没用完,直接退出中断
/* 时间片耗尽,进行进程调度 */
current->counter = DEFAULT_TIMESLICE; // 重置下次时间片(图中用 struct task_struct.count)
schedule(); // 进行真正的上下文切换
}
- 在 `do_timer` 函数中,内核会检查当前进程的剩余时间片(`current->counter`)。如果该进程的时间片还没用完,就直接返回继续执行当前进程。
- 如果时间片已经耗尽,内核会为进程重置时间片,并调用 `schedule()` 来触发进程调度。通过调度器选择下一个准备好的进程执行。
<font style="color:rgb(31,35,41);">schedule()</font>
会遍历就绪队列,挑选优先级最高或公平调度的下一个进程。 <font style="color:rgb(31,35,41);">switch_to()</font>
保存当前进程的寄存器/栈指针,恢复下一个进程的上下文。 <font style="color:rgb(31,35,41);">iret</font>
,回到新进程的用户态或内核态继续运行。<font style="color:rgb(31,35,41);">int 0x80</font>
/<font style="color:rgb(31,35,41);">syscall</font>
、页错误、非法指令等) <font style="color:rgb(31,35,41);">raise_softirq()</font>
、<font style="color:rgb(31,35,41);">tasklet</font>
、<font style="color:rgb(31,35,41);">workqueue</font>
等)<font style="color:rgb(31,35,41);">syscall</font>
指令,CPU 会做一次“软中断”,转到内核的系统调用入口。 <font style="color:rgb(31,35,41);">iret</font>
或 <font style="color:rgb(31,35,41);">sysret</font>
返回到原来被抢占或陷入的进程。操作系统“自己”并没有一个独立的“调度者”,而是被外部与内部的“事件”驱动——每来了一个中断或陷阱,就把控制权交给内核。
这样操作系统就可以在硬件时钟的推动下进行自动调度了~
想象一下,你正在编写一个简单的程序,比如读取一个文件并打印其内容。这个看似简单的操作,背后却隐藏着操作系统内核的复杂工作。你的程序运行在用户空间 (User Space),一个相对受限的环境;而文件系统、硬件设备等核心资源则由内核空间 (Kernel Space) 掌控,拥有最高权限。那么,用户程序如何安全、可控地请求内核来完成这些特权操作呢?答案就是通过系统调用 (System Call),而系统调用的实现,很大程度上就依赖于我们今天要讲的软中断。
在深入软中断之前,我们先快速回顾一下什么是“中断”。你可以把中断想象成一种信号,它会打断 CPU 当前正在执行的任务,要求 CPU 立即关注并处理一个更紧急或特殊的事件。
中断主要分为两类:
上文已经讲解了关于硬件中断的相关知识点,我们今天要聚焦的就是第二种——软中断。
CPU 设计了对应的汇编指令 (
int
或者syscall
), 可以让 CPU 内部触发中断逻辑。
没错,软中断的核心就是 CPU 提供了一些特殊的指令,允许正在运行的程序主动“中断”自己,将控制权交给预先定义好的处理程序(通常是操作系统内核的一部分)。
INT n
指令就是用来触发软中断的,其中 n
是一个中断号(0-255)。Linux 早期广泛使用 INT 0x80
作为系统调用的入口。SYSENTER
(配合 SYSEXIT
) 和 SYSCALL
(配合 SYSRET
)。无论使用哪种指令,效果都是类似的:暂停当前的用户程序,切换到更高权限的内核模式,并跳转到指定的中断处理程序。
软中断最广为人知的应用场景就是实现系统调用。让我们跟随一次典型的系统调用(比如 read
文件),来一场“从用户空间到内核再返回”的深度游:
第一站:用户空间 - 请求发起
read(fd, buffer, count);
。read()
函数并非直接操作硬件。它是一个封装层。它的主要工作是: read
操作对应的系统调用号(这是一个事先约定好的数字,例如,在 x86-64 Linux 中,read
的号是 0)。/* 系统调用函数指针表,用于系统调用中断处理程序 (int 0x80) 作为跳转表 */
static fn_ptr sys_call_table[] = {
/* 0 - 9 */
sys_setup, /* 0 */
sys_exit, /* 1 */
sys_fork, /* 2 */
sys_read, /* 3 */
sys_write, /* 4 */
sys_open, /* 5 */
sys_close, /* 6 */
sys_waitpid, /* 7 */
sys_creat, /* 8 */
sys_link, /* 9 */
/* 10 - 19 */
sys_unlink, /* 10 */
sys_execve, /* 11 */
sys_chdir, /* 12 */
sys_time, /* 13 */
sys_mknod, /* 14 */
sys_chmod, /* 15 */
sys_chown, /* 16 */
sys_break, /* 17 */
sys_stat, /* 18 */
sys_lseek, /* 19 */
/* 20 - 29 */
sys_getpid, /* 20 */
sys_mount, /* 21 */
sys_umount, /* 22 */
sys_setuid, /* 23 */
sys_getuid, /* 24 */
sys_stime, /* 25 */
sys_ptrace, /* 26 */
sys_alarm, /* 27 */
sys_fstat, /* 28 */
sys_pause, /* 29 */
/* 30 - 39 */
sys_utime, /* 30 */
sys_stty, /* 31 */
sys_gtty, /* 32 */
sys_access, /* 33 */
sys_nice, /* 34 */
sys_ftime, /* 35 */
sys_sync, /* 36 */
sys_kill, /* 37 */
sys_rename, /* 38 */
sys_mkdir, /* 39 */
/* 40 - 49 */
sys_rmdir, /* 40 */
sys_dup, /* 41 */
sys_pipe, /* 42 */
sys_times, /* 43 */
sys_prof, /* 44 */
sys_brk, /* 45 */
sys_setgid, /* 46 */
sys_getgid, /* 47 */
sys_signal, /* 48 */
sys_geteuid, /* 49 */
/* 50 - 59 */
sys_getegid, /* 50 */
sys_acct, /* 51 */
sys_phys, /* 52 */
sys_lock, /* 53 */
sys_ioctl, /* 54 */
sys_fcntl, /* 55 */
sys_mpx, /* 56 */
sys_setpgid, /* 57 */
sys_ulimit, /* 58 */
sys_uname, /* 59 */
/* 60 - 69 */
sys_umask, /* 60 */
sys_chroot, /* 61 */
sys_ustat, /* 62 */
sys_dup2, /* 63 */
sys_getppid, /* 64 */
sys_getpgrp, /* 65 */
sys_setsid, /* 66 */
sys_sigaction,/* 67 */
sys_sgetmask, /* 68 */
sys_ssetmask, /* 69 */
/* 70 - 77 */
sys_setreuid, /* 70 */
sys_setregid /* 71 */
/* 如果有更多 syscall,请在此继续添加并更新区间注释 */
};
- 将这个系统调用号放入指定的寄存器(通常是 `EAX` 或 `RAX`)。
- 将函数的参数(文件描述符 `fd`、缓冲区 `buffer` 的地址、要读取的字节数 `count`)按照 **ABI (Application Binary Interface)** 的约定,放入其他指定的寄存器(如 `RDI`, `RSI`, `RDX` 等)或压入堆栈。
- 执行触发软中断的指令,比如 `syscall`。
第二站:模式切换 - “陷阱”之门
系统调用的过程,其实就是先使用
int 0x80
、syscall
陷入 (Trap) 内核,本质就是触发软中断…
syscall
(或 INT 0x80
) 指令时,奇妙的事情发生了: syscall
指令(或 INT 0x80
的中断号 0x80)作为索引,去查询一个特殊的数据结构——中断描述符表 (IDT - Interrupt Descriptor Table)。IDT 中存储了每个中断号对应的处理程序的入口地址和所需权限等信息。(你可以想象图示中,有一条从用户态执行 int 0x80/syscall
指令,穿过用户态/内核态边界,指向 IDT,再由 IDT 指向内核中特定处理代码的路径。)
第三站:内核空间 - 请求处理
_system_call
)接管控制权。它的任务是: RAX
(或 EAX
) 寄存器中读取之前 C 库放入的系统调用号。sys_call_table
) 中查找。操作系统不会提供任何系统调用接口,只提供系统调用号。• 系统调用号的本质:数组下标!
• fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read, ... };
• call [_sys_call_table + eax * 4]
(或 call sys_call_table[,%rax,8]
在 64 位)
- 上文已经提到过。
这个表里存放的是指向具体内核实现函数(如 sys_read
, sys_write
, sys_open
等)的指针。
sys_call_table
中找到的函数指针,也就是执行 sys_read
。int 0x80
或者syscall
陷入内核,在系统调用表找到对应的系统调用函数执行。sys_read
函数会执行真正的文件读取逻辑,这可能涉及到: buffer
地址。第四站:返回用户空间 - 功成身退
sys_read
执行完毕,将返回值(比如成功读取的字节数)放入 RAX
(或 EAX
) 寄存器。sysret
(对应 syscall
) 或 iret
(对应 INT
)。read
) 中,就在 syscall
指令之后。RAX
取出内核返回的结果,并将其作为 read()
函数的返回值,返回给你的应用程序。至此,一次完整的系统调用结束。整个过程虽然复杂,但通过软中断机制,实现了用户空间到内核空间的安全、受控的“穿越”。
INT 0x80
/syscall
?因为 Linux 的 GNU C 标准库,给我们把⼏乎所有的系统调⽤全部封装了。
正如课件所说,我们平时编程依赖的标准库(如 Linux 下的 glibc,Windows 下的 ntdll.dll 或 kernel32.dll)为我们隐藏了这些底层细节。库函数就像是“系统调用代理人”,负责处理参数传递、触发软中断、获取结果等所有繁琐步骤。这使得应用程序员可以专注于业务逻辑,而无需关心底层的中断和模式切换。
软中断的范畴并不局限于程序员主动发起的系统调用。CPU 在执行指令时,如果遇到无法处理的错误或需要特殊处理的情况,也会触发内部中断。这些通常被称为异常 (Exceptions)。
缺页中断?内存碎片处理?除零野指针错误?这些问题,全部都会被转换成为CPU内部的软中断… CPU内部的软中断,比如除零/野指针等,我们叫做 异常 (Exception)… CPU内部的软中断,比如
int 0x80
或者syscall
,我们叫做 陷阱 (Trap)。
这个区分很有用。我们可以进一步细化一下:
INT 0x80
/syscall
就是典型的陷阱。调试断点指令 (INT 3
) 也是陷阱。执行完陷阱处理程序后,通常会返回到陷阱指令的下一条指令继续执行。无论是陷阱、故障还是中止,它们都使用与系统调用类似的机制:CPU 检测到事件 -> 保存状态 -> 查 IDT -> 跳转到内核处理程序。操作系统在初始化时(如课件中的 trap_init
函数)会为这些预定义的异常编号设置好相应的处理函数入口。
// 示例:设置异常处理入口 (概念性)
set_trap_gate(0, ÷_error_handler); // 除零错误
set_trap_gate(3, &breakpoint_handler); // 断点陷阱 (INT 3)
set_trap_gate(6, &invalid_opcode_handler); // 无效指令
set_trap_gate(13, &general_protection_fault_handler); // 通用保护错误
set_trap_gate(14, &page_fault_handler); // 缺页故障
操作系统就是躺在中断处理例程上的代码块!
这句话精辟地指出了中断(包括硬件中断和软中断)对于操作系统的核心意义。操作系统的大部分代码,无论是设备驱动、文件系统、内存管理还是进程调度,很多时候都是在响应某个中断事件。软中断提供了:
一点补充: 在 Linux 内核中,还有一个叫做 “softirq” 的机制,它与我们这里讨论的 CPU 级软中断(INT
/syscall
/异常)是不同的概念。Linux 的 softirq 主要用于将硬件中断处理中耗时较长的部分“延迟”到底半部(bottom half)异步执行,以尽快释放硬件中断上下文。这是一个内核内部的优化技术,不要与 CPU 指令触发的软中断混淆。
软中断就像是操作系统这座大厦中隐藏的楼梯和电梯,连接着用户空间和内核空间,也连接着正常的程序执行与异常处理。理解了软中断,你就能更深刻地把握程序是如何与操作系统交互,以及操作系统是如何应对各种内部事件的。希望这次的深入探讨,能让你对这个“看不见”却至关重要的机制有更清晰的认识!