1. Interruption

第一课 CPU Running Mode


1.1 Real Mode and Protected Mode

早期由于8086/8088 CPU最大只能寻址1MB且指令长度为16位。为了兼容性,x86系列的CPU在计算机启动后的最开始都以实模式(Real Mode)运行。实模式下只能使用低1MB的RAM,而且默认的指令长度为16bits。

在实模式下,地址线直接映射物理内存地址。处理器不执行任何形式的内存管理和保护,如BIOS阶段的指令,没有我们后面介绍的特权级别一说,也没有特权指令、非特权指令之分。在实模式下,处理器会执行BIOS阶段的指令,初始化硬件和系统设置。

之后,Bootloader接管计算机并在适当时刻将处理器从实模式转变为 保护模式(Protected Mode) 这一转变使得系统可以访问更大的内存空间和执行更复杂的指令,也可以使用例如虚拟内存管理和内存保护机制这样的高级功能。所有的现代操作系统都运行在保护模式。

1.2 User Mode and Supervisor Mode

为了实现系统的安全性和稳定性,现代OS和处理器定义了两种处理器执行权限模式:用户模式和内核模式。 这种设计使得操作系统可以更好地管理硬件资源,防止用户程序对系统的核心部分进行未经授权的访问。

1.2.1 User Mode

在用户模式下,程序只能执行有限的指令,不能直接对硬件和内核中的数据结构进行访问,这种指令被称为非特权指令(non-privileged instructions)。用户模式的限制防止用户程序对系统资源的滥用,保护了系统的安全和稳定。常见的非特权指令有:

  1. 算术和逻辑运算指令。
  2. 数据传输指令,不涉及受保护资源的读写。
  3. 简单的控制流指令(如条件跳转)
  4. 某些系统调用指令,这些通过中断或异常机制,安全地请求操作系统提供服务。

1.2.2 Supervisor Mode

也叫Kernel mode。在内核模式下,操作系统内核对所有的硬件和软件资源具有完全的访问权限。内核可以执行任何指令,管理任何设备和系统资源。只能在内核模式下运行的指令就是特权指令(privileged instructions),这些指令通常涉及对硬件资源的直接控制和管理。常见的特权指令有:

  1. 修改控制寄存器(如分页控制、内存管理)
  2. 更改处理器的运行模式(如从用户模式切换到内核模式)
  3. 直接访问I/O设备,
  4. 启用或禁用中断,
  5. 控制时间片、任务切换等。

1.3 Protection Ring

在保护模式下,内核和用户程序从此隔离。用户程序只能够执行一些特定的指令,称为非特权指令,而内核可以执行任何特权、非特权的指令。那系统(CPU)是如何知道谁是用户程序,谁是内核程序呢?

现代操作系统实现了4种不同的程序执行等级(CPU模式),称为 hierarchical protection domains,也叫 protection rings。实际上这四种不同的 rings 只使用了两种。其中,用户程序只能执行在 Ring 3,而内核程序执行在 Ring 0。CPU 通过 CS 寄存器的前两位来识别当前程序的特权级别,称为 CPL (Current Privilege Level) 位。

Pasted image 20250127044801.png
在段描述符、门描述符中还有 DPL(Descriptor Privilege Level)用来表示描述符特权级。比如在中断门描述符每一项都有一个 DPL,用于控制中断请求的特权级别;系统调用所对应的门描述符也有一个 DPL,用于控制系统调用的特权级别。只有当 CPL ≤ DPL 时,才能访问该描述符。

后面我们在学习进程6. Processing The Processes阶段的时候,我们会了解到每个进程都会有内核区(在32位系统下虚拟内存为3GB-4GB),在内核区中的代码段描述符和数据段描述符等的DPL就会被设置为0,即只有CPL为0时才能访问这些代码和数据等。

1.4 System Integrity

我们对用户模式和内核模式进行了简单的了解,我们知道用户程序都运行在用户模式下(Ring 3),权限受限。而操作系统核心组件(如内存管理、进程调度、文件系统等)运行在内核模式下(Ring 0),在用户态的用户程序是无法直接对系统的核心组件进行篡改的。

为了确保系统的安全,我们需要保证系统的完整性。系统完整性即系统在运行过程中保持其预期的正确性和一致性,防止未经授权的修改和破坏。由于用户态和内核态的分离,对系统核心的操作都在内核态中进行,这种特性保护了系统的完整性和稳定性。

比方来说,在用户程序下,如果出现程序错误,往往只会导致程序崩溃,而不会影响整个系统的稳定性。通过内核模式的错误处理机制,操作系统可以捕获和处理这些错误,防止它们扩散到系统的其他部分,维护系统的整体完整性。而且用户程序是无法直接篡改系统的核心部件的,这也维护了系统的完整性。

1.4.1 Separation of Policy and Mechanism

策略(policy) 是指操作系统如何决定何时以及如何执行某些操作,例如进程调度、内存管理、资源分配等。策略规定了系统行为的高层规则,而机制(mechanism) 则是实现这些规则的具体方法。

操作系统在内核模式下执行具体的机制(如进程调度、内存管理),而策略(如调度策略、资源分配策略)则由操作系统的高层组件或管理员来决定。这种分离有助于系统的灵活性和可维护性,同时确保系统的完整性。

第二课 System Calls


由于操作系统对底层硬件的保护和封装,用户程序是不能够直接操作计算机硬件的。如果用户程序想要使用系统资源怎么办呢?操作系统通过系统调用接口提供了一系列的机制,使得用户程序可以调用这些接口来请求操作系统执行特定的操作,操作系统完成后返回操作结果。

系统调用是操作系统提供给应用程序的一组接口,允许应用程序请求操作系统执行一些不能在用户模式下完成的操作。系统调用是应用程序与操作系统内核之间的桥梁。对于用户而言,使用系统调用和调用函数非常类似,唯一的不同就是系统调用工作在内核模式。

系统调用的作用就是提供了一种安全的方法,让用户空间的程序请求内核空间的服务。我们通常将执行文件操作、进程控制、网络通信等需要更高权限的操作进行封装,提供系统调用接口给用户程序去使用。

当应用程序需要执行一个系统调用时,它会通过特定的机制(如软中断)通知操作系统。操作系统接收到通知后,会从用户模式切换到内核模式,执行相应的内核函数。完成操作后,操作系统将结果返回给应用程序,并切换回用户模式。这就是系统调用的过程

系统调用有什么好处呢?它可以让操作系统在满足请求之前检查请求的正确性,防止不安全的操作。同时,应用程序开发者不需要了解硬件的低级编程细节。此外,相同的系统调用通常上在相同的操作系统上的接口是一样的,使程序具有一定的可移植性。

2.1 open() System Call

我们用打开文件open()函数举例,假设我们有以下 C 程序:

#include <stdio.h>
#include <fctrl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>

int main(){
/*用户态*/

	int fd = open("example.txt", O_RDWR, S_IRUSR | S_IWUSR);//Mode switching
	if(fd == -1){
		perror("fail to open file");
		return 1;
	}
	char buf[128];
	int size = read(fd, buf, sizeof(buf) - 1);//Mode switching
	if(size == -1){
		perror("fail to read");
		close(fd);//Mode switching
		return 1;
	}
	printf("Read form buf: %s", buf);//Mode switching
	close(fd);//Mode switching
	return 0;
}

虽然这段代码不长,但是发生了4次系统调用,状态转换了8次。

第三课 Tracing System Calls using strace


Question: Why printf(); works in the user mode?
Pasted image 20240913230021.png
Answerprintf();函数运行在用户态是因为 C standard library(libc) 运行在用户态,而 printf();是作为标准库的一部分。好了,现在的问题转为为什么标准库运行在用户态了,在我们调用 printf(); 的时候首先 printf(); 函数会根据函数里面的参数将打印的字符串序列排好序。之后字符串会被放到一个buffer中,然后调用系统调用write()将buffer传给内核处理。内核将buffer里面的内容交给特定的输出设备然后回到用户态,打印结束。

3.1 Trace printf in Terminal

假设我们有如下的代码,我们对其进行编译并使用strace命令进行系统调用跟踪,会发生什么?

#include<stdio.h>
int main(){
    printf("hello");
    return 0;
}

我们发现当我们执行程序的时候,我们调用了非常多的系统调用,但是在最后面有这么一行:write(1, "hello", 5hello) = 5。表示printf函数实际上是使用write系统调用来向屏幕上打印 "hello" 字符串的,返回值为5,即打印了5个字符。

du@DVM:~/Desktop/DSA$ strace ./test 
execve("./test", ["./test"], 0x7ffdd5170160 /* 55 vars */) = 0
brk(NULL)                               = 0x570c55c3b000
arch_prctl(0x3001 /* ARCH_??? */, 0x7ffc25af5710) = -1 EINVAL (Invalid argument)
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7cdffcdbc000
access("/etc/ld.so.preload", R_OK)      = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
newfstatat(3, "", {st_mode=S_IFREG|0644, st_size=59619, ...}, AT_EMPTY_PATH) = 0
mmap(NULL, 59619, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7cdffcdad000
close(3)                                = 0
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0P\237\2\0\0\0\0\0"..., 832) = 832
pread64(3, "\6\0\0\0\4\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0"..., 784, 64) = 784
pread64(3, "\4\0\0\0 \0\0\0\5\0\0\0GNU\0\2\0\0\300\4\0\0\0\3\0\0\0\0\0\0\0"..., 48, 848) = 48
pread64(3, "\4\0\0\0\24\0\0\0\3\0\0\0GNU\0I\17\357\204\3$\f\221\2039x\324\224\323\236S"..., 68, 896) = 68
newfstatat(3, "", {st_mode=S_IFREG|0755, st_size=2220400, ...}, AT_EMPTY_PATH) = 0
pread64(3, "\6\0\0\0\4\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0@\0\0\0\0\0\0\0"..., 784, 64) = 784
mmap(NULL, 2264656, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7cdffca00000
mprotect(0x7cdffca28000, 2023424, PROT_NONE) = 0
mmap(0x7cdffca28000, 1658880, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x28000) = 0x7cdffca28000
mmap(0x7cdffcbbd000, 360448, PROT_READ, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1bd000) = 0x7cdffcbbd000
mmap(0x7cdffcc16000, 24576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x215000) = 0x7cdffcc16000
mmap(0x7cdffcc1c000, 52816, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x7cdffcc1c000
close(3)                                = 0
mmap(NULL, 12288, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7cdffcdaa000
arch_prctl(ARCH_SET_FS, 0x7cdffcdaa740) = 0
set_tid_address(0x7cdffcdaaa10)         = 71252
set_robust_list(0x7cdffcdaaa20, 24)     = 0
rseq(0x7cdffcdab0e0, 0x20, 0, 0x53053053) = 0
mprotect(0x7cdffcc16000, 16384, PROT_READ) = 0
mprotect(0x570c55b39000, 4096, PROT_READ) = 0
mprotect(0x7cdffcdf6000, 8192, PROT_READ) = 0
prlimit64(0, RLIMIT_STACK, NULL, {rlim_cur=8192*1024, rlim_max=RLIM64_INFINITY}) = 0
munmap(0x7cdffcdad000, 59619)           = 0
newfstatat(1, "", {st_mode=S_IFCHR|0620, st_rdev=makedev(0x88, 0), ...}, AT_EMPTY_PATH) = 0
getrandom("\x81\x42\x61\xea\x7a\xbb\x01\x45", 8, GRND_NONBLOCK) = 8
brk(NULL)                               = 0x570c55c3b000
brk(0x570c55c5c000)                     = 0x570c55c5c000
write(1, "hello", 5hello)                    = 5
exit_group(0)                           = ?
+++ exited with 0 +++

我们对上面的系统调用重新看一遍,为什么只是打印一个字符串确需要如此多的系统调用?这是因为操作系统需要处理许多底层任务来支持程序的运行,我们执行printf之前的加载并执行程序都需要操作系统的介入。

第四课 System Call Mechanism(trap)*


4.1 System Call Interface

4.1.1 Syscall

系统调用是一种特殊类型的软件中断,通过系统调用,用户空间的程序可以请求操作系统的服务。系统调用是现代操作系统的基础功能,它为用户程序可以安全地使用内核模式下才能执行的文件操作、进程控制、通讯等操作。

系统调用有多种的实现方式:

  • 传统x86:在传统x86使用 int 0x80 的软件中断方式,其中 0x80 就是系统调用的中断向量号。
  • 现代x86:在现代的x86系统上,我们常用 syscall 指令来使用系统调用。 syscall 并不使用传统的中断向量表,而直接通过特定的 MSR(模型特定寄存器) 跳转到内核系统调用实现的入口点。

4.1.2 Syscall Table

当使用传统x86系统上的系统调用时,每个系统调用都会有唯一一个标识符,也就是系统调用号。系统调用号用于区分不同的系统调用。用户程序通常将系统调用号放在一个特定的寄存器(比如 eax 寄存器)中来指示希望执行哪个系统调用。随后内核通过查看这个寄存器来决定执行具体的ISR。

当使用现代64位的x86系统时,保存系统调用号和系统调用入口关系的数据结构就是系统调用表(syscall table)

4.1.3 Function Wrapper

openat这样的函数实际上是系统调用号的包装器。这些包装函数简化了系统调用的使用,允许程序员通过高级和更易使用的接口方式与操作系统交互。

4.2 Different Architecture, Different System Calls

我们用 open("filename", MODE) 库函数为例,我们在用户代码中被调用时只传入两个参数:文件名和打开文件的模式。在该函数执行时会触发模式切换并调用openat系统调用,不同的CPU架构实现方法不太一样,下面我们对比3种架构。

我们会看到,虽然实现的细节不同,但是它们系统调用指令和传递参数的方式都是类似的。

4.2.1 x86-32

x86-32属于传统的x86系统,使用 int 0x80 来调用 Linux 内核。如下:

section .data
	filename db 'example.txt', 0	; 要打开的文件名,末尾有一个null字符
	filemode dw 0x0002		; O_RDONLY模式
section .text
	mov eax, 5				; open系统调用的编号是5
	mov ebx, filename		; 第一个参数
	mov ecx, filemode		; 第二个参数
	xor edx, edx			; 第三个参数
	int 0x80				; 执行系统调用
4.2.2 x86-64

到了x86-64位系统上,用系统调用表(syscall table) 来保存记录系统调用号和系统调用入口的关系。我们使用 syscall 来执行系统调用。我们忽略一些细节,相关调用实现如下:

section .data
	filename db 'example.txt', 0	; 要打开的文件名,末尾有一个null字符
section .text
	mov rax, 257			; openat的系统调用的编号
	mov rdi, -100			; AT_FDCWD,当前 工作目录
	mov rsi, filename		; 第一个参数
	mov rdx, O_RDONLY		; 第二个参数
	xor r10, r10			; 第三个参数
	syscall					; 执行系统调用
4.2.3 arm64
.section .data
filename:
	.ascii "example.txt\0"
.text
	mov x0, -100
	ldr x1, =filename
	mov	x2, 0
	mov x3, 0
	mov x8, 56
	svc 0

第五课 Syscall Do It Yourself


第六课 Interrupts


6 .1 Preview

Interrupts are interruption to CPU.

系统调用是一种特殊的中断。所有中断的处理流程和系统调用的处理流程有很多相似的地方。现代计算机是中断驱动的,中断机制的存在使得计算机(OS)能够及时响应外部事件并作出反应,极大地提升了系统的效率和响应性。

如果没有中断机制,CPU将不得不采用轮询(Polling)方式逐个地检查每个设备的状态以确定其是否需要服务。这是一种主动响应机制,相比于被动响应的中断机制,轮询浪费了很大一部分计算资源。

6.2 Interrupts

中断(Interrupts) 是一个由硬件或软件发出的信号,当某个过程或事件需要立即处理时会使用中断来通知处理器。中断的目的是让处理器注意到更高优先级的任务并打断当前正在进行的指令流保存当前的状态,然后转而去执行特定的中断处理程序,最后再返回原指令流中继续执行,这就是 中断机制

6.3 Interrupt Classified

6.3.1 Events Classified(ref: Y4NGY操作系统课程)

根据中断信号的产生源可将中断分为硬件中断和软件中断两大类:

  • Hardware Interrupt

    1. 外部中断(External Interrupt):来自CPU外部硬件或I/O设备的中断。

    2. 内部中断(Internal Interrupt):CPU内部自已产生的中断,也称为异常(exception)

      • trap:程序执行时故意产生的中断 (syscall),是一种自愿中断
      • fault:执行指令引起的异常事件,是可恢复的错误而触发的中断 (page fault)
      • abort:硬故障事件,是不可恢复的严重错误下触发的中断
  • Software Interrupt

    • 通常由程序通过调用中断指令(如int xxx)主动发出的中断。(int 0x80)
6.3.2 Events Classified(ref: Art of Assembly Language Programming)
  • Hardware Interrupts :

    • 或直接称为中断,是由硬件设备唤起的中断类型。
    • 硬件中断是异步的,任何时刻都可能发生。
  • Traps :

    • 有时也称作软件中断(Software Interrupts)。
    • 由用户程序唤起,用来请求操作系统的帮助。
  • Exceptions :

    • 处理器内部自动生成,由于某些非法指令的执行。
    • Faults : 可恢复的错误。(page fault)
    • Aborts : 通常是不可恢复的错误。(divide by 0 exception)

6.4 Interrupt Controller

6.4.1 Interrupts in Legacy CPU
6.4.2 Advanced Programmable Interrupt Controller(APIC)
6.4.2.1 LAPIC
6.4.2.2 I/OAPIC

6.5 Interrupt Vectors

操作系统是如何区分处理各种中断信号的。每种中断信号都被赋予一个特定的编号,称为中断向量号。这个向量号是一个整数,它起到了关键的角色,让系统能够识别并对应到具体的中断处理程序(也叫“中断服务例程”)。

中断向量号的分配方式取决于中断的类型:

  • 外部中断,如来自硬件设备的中断,其向量号通常是由设备驱动在运行时动态申请的。这确保了不同设备能够根据当前系统状态获得适当的中断处理。
  • 异常,如程序错误或非法操作,其向量号则是由CPU的架构标准所固定规定。这样做的目的是为了确保异常能够以一致的方式被处理,无论系统在什么状态下。
  • 软件中断,通常是由操作系统内核中的程序触发的,其向量号是由内核预设。软件中断允许操作系统内部组件或运行在用户模式下的程序请求内核提供服务。

在32位的x86架构中,有256个中断号,从0到255,也就是int指令后面的数字最大是255。中断向量号的划分如下:

  • 预定义的中断(0-31)0-19号是由Intel定义的用于处理各种标准异常的中断号,比如除零错误(0),通用保护故障(13),页故障(14)等。20-31号保留给 Intel 未来使用。
  • 用户自定义的中断(32-255)32-47号通常被用于外部硬件中断,这是基于IBM PC架构的8259可编程中断控制器的标准设置。这个范围被称为主片和从片的IRQs(中断请求)。48-255号可以由操作系统自定义使用,通常用于实现软件中断、系统调用(128号中断向量)等。

我们在前面看到过,在下x86架构下的系统调用编号是0x80。我们可以说,系统调用是软件中断的特定形式,而软件中断又是中断的子集。

6.6 IVT and IDT

中断向量表(Interrupt Vector Table,IVT) 存储了每个中断向量号与其对应的 中断服务例程(Interrupt Service Routine, ISR) 的地址。当系统检测到一个中断信号时,它会通过这个信号的向量号在IVT中查找相应的服务例程地址,然后跳转到该地址执行中断处理程序。

在计算机的实模式中,像键盘输入和屏幕显示等BIOS服务都是通过预设的中断方式实现的,它使用了中断向量表来查询BIOS中断号对应的ISR地址。

当计算机转入保护模式, 中断描述符表(Interrupt Descriptor Table, IDT) 取代了IVT,成为新的中断管理核心。IDT 相较于 IVT,有以下优点(不完整):

  • 安全性: IDT不仅包含ISR的地址,还包含了必要的权限和状态信息,如特权级(DPL)。这允许操作系统设计者实施更精细的控制,例如防止用户模式代码直接触发某些特权中断。
  • 灵活性 : IDT支持在运行时动态修改,它们可能根据运行时的需要动态地添加、修改或移除中断处理程序。
  • 性能:IDT结构为现代操作系统提供了执行中断处理的更高效方式。例如,通过中断门触发的中断处理可以自动关闭中断,这避免了在处理一个中断时被其他中断干扰的问题,增强了处理的效率和系统的稳定性。

6.7 ISR

中断服务例程(Interrupt Service Routine, ISR) 是响应硬件或软件中断信号的一段特定程序代码。当某个事件(如输入/输出操作、时钟信号、硬件故障或其他外部事件)触发中断时,处理器会暂停当前正在执行的任务,转而执行与该中断关联的ISR。
Pasted image 20240423213215.png

第七课 Interrupt Context


当系统在执行指令流的过程中,在完成第 i 条指令后,如果突然接收到一个中断信号或主动发起系统调用,此时系统需要暂停当前的任务去响应这个中断/调用请求。为了确保中断处理完毕后,程序能够无缝地返回到被中断的位置继续执行后续指令,系统必须在执行中断处理程序之前,保存必要的一些信息,即现场信息

7.1 Context Saving

以x86-32位架构为例。arch/x86/kernel/entry_32.S

现场信息是指CPU中的一些寄存器的值,通过保存恢复这些寄存器的数值,程序就能够回到被中断位置继续执行指令。这其中有几个核心寄存器:

  • SS:栈段寄存器,指向当前栈所在的栈段地址。
  • ESP:栈指针寄存器,指向栈顶。
  • EFLAGS:包含了影响程序执行的多种状态标志,如零标志、符号标志、溢出标志等。
  • CS:包含当前代码段的选择子(Selector),即指定了代码段在内存中的位置。
  • EIP:即PC寄存器,指向将要执行的下一条指令。

那这些现场信息存放在哪里呢?只要程序暂停执行并在未来需要恢复,那么程序就需要把现场信息先存放到一个位置,这个位置就是内核栈。这些现场信息会被 push 到内核栈中。

7.1.1 User Mode Interruption

一旦程序在用户态被中断,上面提到的五个最重要的寄存器由硬件 CPU 自动完成(这个操作是当即完成的),无需操作系统或中断处理程序进行任何干预。而进程的中断上下文不仅仅只有这五个寄存器。操作系统负责保存一些额外的现场信息内容(执行 ISR 时保存)。如:

  1. 通用寄存器(EAX, EBX, ECX, EDX, ESI, EDI, EBP)
  2. 段寄存器选择子(DS, ES, ES, GS

如果有错误发生,硬件还会压入错误码。待操作系统处理。

此外,在 CPU 执行 ISR 时,还牵扯到 CPL 的变化。即在中断发生时 CPU 特权级别 CPL 是"user mode", CPU 需要负责将 CPL 切换至 0,即"kernel mode",才能接着执行 ISR。(中断门描述符检查 DPL ≥ CPL )

7.1.2 Kernel Mode Interruption

如果程序在内核态中断,那么将不再需要保存 SS 寄存器和 ESP 寄存器。因为中断时指向的就是内核栈。另外的,内核态下的段寄存器(如DS、ES)一般也不需要保存。因为它们已经指向内核数据段。

7.1.x Kernel Stack Ownership

每个线程都有自己的内核栈。当线程被中断时,CPU 就会转换到其内核栈将线程的上下文进行压栈保存。一般而言,内核栈大小为 8KB/16KB 。

7.2 Context Recovering

以x86-32架构为例。

在 iret 指令执行前,中断服务例程(ISR)需手动恢复操作系统保存的寄存器。恢复顺序需与保存顺序严格相反(后进先出,LIFO)。

ISR的指令逻辑流程:

  1. 保存通用寄存器
  2. 执行中断处理代码
  3. 恢复通用寄存器
  4. 使用iret返回(弹出硬件保存寄存器)

7.2.1 Return to User Mode

中断服务例程的执行完成后,系统需要通过执行 iret 指令来从 ISR 返回到原来的程序执行流。iret 指令的作用不仅是精确和有序的,它还负责恢复之前由 CPU 自动保存到栈中的处理器状态,并实现特权级的适当切换。

如果返回到用户态(CPL 0->3),那么执行 iret 时寄存器会依次弹出 EIP、CS、EFLAGS、用户态 ESP、用户态 SS。然后恢复 CS 中 CPL 的特权级别为 3,CPU 自动换回用户态。

7.2.2 Return to Kernel Mode

仅仅弹出 EIP、CS、EFLAGS。而且由于特权级别不变,不切换栈指针。

7.3 PSW(16 bits) and EFLAGS(32 bits)/RFLAGS(64 bits)

第Q课 信号、软件中断和软中断


信号将在IPC阶段中介绍。

学习Linux的信号机制(signaling mechanism)时,一时间将我的思绪拉回了中断。信号是什么?软中断是什么?软件中断又是什么?它们有何相同点?又有哪些是不同的?

1. 信号(Signaling)

The signaling mechanism in the Linux kernel allows running applications to asynchronously notify the system when a new event occurs. Because of its nature, this signaling mechanism is generally known as software interrupts.

信号在Linux操作系统中非常重要。信号是进程间通信的一种方式。对于内核而言,信号就是一个事件,操作系统内核将中断的处理结果以信号的方式传递给进程。随后进程根据处理的结果做出相应的动作。

1.1 信号的来源

我们常常用信号作为进程间通信IPC异常处理的一种手段。虽然操作系统内核通常是大多数信号的来源,但下面我们仍列举一些其他的信号来源:

  • 进程执行时产生的异常,如段错误(segmentation faults)
  • 硬件产生的信号,比如定时器产生的信号
  • 其他进程使用kill系统调用产生的信号

1.2 信号的产生

我们下面简单介绍硬件中断和异常是如何产生信号的。之后小节我们用例子说明kill系统调用是怎么产生信号并终止特定进程的。

1.2.1 硬件产生的信号

信号的本质就是软件层次对中断的模拟,是一种异步通信机制。每个信号都对应着不同的功能。举个例子(按下Ctrl + C):

  1. 硬件中断
    • 键盘控制器检测到 Ctrl+C 按键组合,并发送硬件中断信号给 CPU。
    • CPU暂停当前正在执行的用户空间代码,保存现场并切换到内核态。
  2. 中断处理
    • 进入内核态:CPU开始执行与键盘中断相关的中断服务例程(ISR)。
    • 执行ISR:内核中的键盘ISR处理这个中断事件,识别出这是一个Ctrl+C按键事件。
  3. 生成信号
    • 生成SIGINT信号:内核生成一个SIGINT信号。(用于终止进程)
    • 给进程发送信号:内核将SIGINT信号发送到目标进程,并将信号记录在PCB的信号位图上
  4. 信号处理
    • 检测信号:当进程恢复执行时,内核会检查该进程PCB的信号队列(信号位图)。
    • 处理信号:根据进程的信号处理设置,内核会执行相应的操作。默认SIGINT会终止进程。
    • 调用信号处理函数:如果进程有自定义的信号处理函数,内核会调用该函数来处理信号。
1.2.2 异常产生的信号

假如我们有一个signal.c文件如下:

#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
void divideByZeroHandler(int signum) {
    printf("Divide by zero exception occurred!\n");
    exit(1);
}
int main() {
    if (signal(SIGFPE, divideByZeroHandler) == SIG_ERR){
	    perror("Cannot register the handler");
	    return 1;
    }
    int dividend = 10;
    int divisor = 0;
    int result;
    printf("Attempting to divide %d by %d...\n", dividend, divisor);
    result = dividend / divisor;
    printf("Result: %d\n", result);
    return 0;
}

我们知道,当除数为0时,结果会是一个异常。当这个异常发生时,进程就会中断。这时操作系统内核会把 SIGFPE(浮点异常) 发给进程。又由于我们设置了信号的服务程序signal(SIGFPE, divideByZeroHandler);,所以当进程收到信号,他就会打印输出信息并退出程序 error(1)。

1.3 信号的传递和处理

1.3.1 信号的传递

信号由操作系统负责传递给指定进程,传递时机是:

  • 从内核态返回用户态运行时:内核会检查待处理信号集合,如果有未阻塞的待处理信号操作系统会在返回用户态前递送信号,调用相应的信号处理函数。
  • 被调度运行时:在进程真正开始运行用户代码之前,递送未阻塞的信号。
  • 就绪态时:信号会被添加到进程的待处理信号集合中,但不会立即递送。
  • 等待态时:如果进程正在阻塞于某个系统调用(如read、sleep等),信号的发生可能会导致系统调用被中断,提前返回用户态处理信号。
1.3.2 信号的处理

进程收到信号后的处理方式有:

  • 默认处理:操作系统定义的默认行为(终止、忽略等)
  • 忽略信号:进程选择不处理某些信号(部分信号不可忽略)自定义处理:进程注册信号处理函数,定义自己的处理方式。
    • 定义信号处理函数
    • 注册信号处理函数

1.4 信号的类型

我们可以通过下面的命令来查看Linux中的所有类型的信号:

kill -l

Linux系统中有62种信号,分为两类:非实时信号(1-31)和实时信号(34-64)。非实时信号包括常见的SIGINT(中断信号)、SIGTERM(终止信号)等,而实时信号用于更高精度的事件处理。

2. 软件中断(Software Interrupt)

从上述信号的了解中,我们能够感受到,中断可以是信号的产生源。我们已经学过中断了,我们可以将中断定义为一种CPU操作系统内核交流方式。中断一旦发生,CPU 暂停当前任务并触发内核中的中断服务程序(ISR)。

2.1 信号 vs. 中断

  • 处理程序(Handler):信号处理程序在用户空间代码中执行,而中断服务程序在内核空间中执行。信号处理程序用于处理进程接收到的特定信号,而中断服务程序用于处理硬件中断。

  • 屏蔽(Mask):信号屏蔽和中断屏蔽分别用于暂时阻止进程接收信号和处理硬件中断,以保护关键代码段的原子性执行

2.2 软件中断和信号

系统调用(syscall)是一种软件中断,我们可以用系统调用来给特定的进程发送信号。如:

  • kill 系统调用:用于向指定进程发送信号,可以通过进程ID(kill(PID))来指定目标进程。
  • raise 系统调用:用于向自身进程发送信号,相当于调用 kill(getpid(), sig)
  • alarm 系统调用:设置一个定时器,当定时器到期时,会向进程发送 SIGALRM 信号。
  • sigqueue 系统调用:用于向指定进程发送信号,并可以附带一个值。
2.2.1 kill系统调用

提供kill系统调用,我们可以给特定的进程发送SIGINT信号来终止某个进程。如下:

#include <sys/types.h>
#include <signal.h> // An abstraction to raw syscall
#include <stdio.h>
#include <unistd.h>

int main() {
    pid_t pid = fork(); 
	
    if (pid == 0) {
        for (int i = 0; i < 5; ++i) {
            printf("this is child process\n");
            sleep(1); 
        }
    } else if (pid > 0) {
        printf("this is parent process\n");
        sleep(2); 
        printf("terminating child process now!\n");
        kill(pid, SIGINT);
    } else {
        perror("fork");
    }
	
    return 0;
}

在这个例子中,父进程先是使用 fork() 创建一个子进程。子进程每秒打印一次 “this is child process”。父进程等待2秒后,使用 kill(pid, SIGINT) 向子进程发送 SIGINT 信号,终止子进程。我们还可以自己编写信号处理函数来处理输出我们想要的结果。

du@DVM:~/Desktop/CppCode$ ./kill 
this is child process
this is parent process
this is child process
this is child process
terminating child process now!

3. SoftIRQ

中断的来源很多,softirq的种类也不少。内核的限制是不能超过32个,目前实际用到的有10个。包括高优先级tasklet、定时器、网络收发、块设备、普通tasklet、高精度定时器和RCU等。softIRQ主要用于处理高频率、低延迟的任务,如网络包处理和定时器等。

其中两个用来实现tasklet(HI_SOFTIRQ和TASKLET_SOFTIRQ),两个用于网络的发送和接收操作(NET_TX_SOFTIRQ和NET_RX_SOFTIRQ),一个用于调度器(SCHED_SOFTIRQ),实现SMP系统上周期性的负载均衡。在启用高分辨率定时器时,还需要一个HRTIMER_SOFTIRQ。

为了有效地管理不同的softirq中断源,Linux采用的是一个名为softirq_vec[] 的数组,数组的大小由NR_SOFTIRQS 表示,这是在编译时就确定了的,不能在系统运行过程中动态添加。每个数组元素代表一种softirq的种类,而数组里存放的内容则是其各自对应的执行函数。