Bootstrap

深入理解linux内核

一、内存寻址

1.1 8086

1.1.1 运行模式

保护模式和实模式是 x86 架构中的两种工作模式,它们主要用于管理和控制处理器对内存的访问和操作。

  1. 实模式(Real Mode):
    实模式是 x86 处理器的最初工作模式,也是兼容性最强的模式。在实模式下,处理器可以访问整个 1MB 的物理内存空间地址,寻址方式简单直接,但缺乏保护和多任务支持,没有虚拟地址的概念,因此程序容易出现内存访问越界和冲突。实模式下只有一个段寄存器 CS,存储正在执行的代码段的基地址,DS、ES、SS 寄存器存储数据段的基地址。
  2. 保护模式(Protected Mode):
    保护模式是 x86 处理器引入的新的工作模式,提供了更加复杂和灵活的内存管理和保护机制。在保护模式下,处理器可以访问超过 1MB 的物理内存空间,使用 32 位虚拟地址和分段+分页的方式来寻址。保护模式下引入了特权级别(Ring 0-3)的概念,不同特权级别对应不同的访问权限和能力,使得操作系统能够实现对内存和资源的严格保护和隔离。保护模式下的内存管理更加灵活,可以实现虚拟内存、内存保护和多任务支持等高级特性。保护模式下具有多个段寄存器,包括 CS、DS、ES、FS、GS 和 SS 寄存器,每个段寄存器都存储一个段描述符,存放在全局描述符表(Global Descriptor Table,GDT)或局部描述符表(Local Descriptor Table,LDT)中,从而实现对内存段的访问保护和隔离。

逻辑地址是段内偏移量,线性地址由段基址+段内偏移组成
段寄存器
cs:代码段寄存器
ss:栈段寄存器
ds:数据段寄存器,指向包含静态数据或者全局数据段
当前特权级(CPL),从0~3,0最高为内核态,3最低为用户态,在cs寄存器中被指定。当当前特权级改变,对应的段寄存器必须更新成对应的状态

1.2 高速缓存

参考网址https://zhuanlan.zhihu.com/p/593016449
请添加图片描述
高速缓存以行的形式存储连续几十个字节的信息。当缓存命中时,读和写操作不用,对读而言只需把信息送到cpu寄存器中就行。对写由于要修改数据,存在回写(只更新高速缓存,在收到要刷新高速缓存的命令后才会更新到ram)和通写(同时更新ram和高速缓存)。每个cpu都有自己的高速缓存,因此一个cpu修改了高速缓存时必须检查同样的数据是否在其他cpu的高速缓存中,这种检查由硬件处理,无需内核关心。多级高速缓存的一致性也由硬件实现的,linux忽略这些细节并假定只有一个单独的高速缓存。注:TLB也是通过高速缓存来实现的,为了加快找页表的速度。
多级高速缓存寻址方式如下:

  1. 硬件高速缓存:多核cpu中,每个CPU都有自己的L1 cache及L2 cache,其中L1 cache分了L1I(Level 1 Instruction)、L1D(Level 1 Data)两种。不同CPU共享L3 cache,L3 cache位于cluster内。Cluster通过BUS与DDR、EMMC、SSD等建立连接,这样CPU就可以访问到EMMC、SSD中的数据。在程序执行时,会先将程序及数据,从SSD或者EMMC加载到内存中,然后将内存中的数据,加载到共享的 L3 Cache 中,再加载到每个核心独有的 L2 Cache,最后才进入到最快的 L1 Cache,之后才会被 CPU 读取。
  2. CPU在访问DDR的时候用的地址是虚拟地址(Virtual Address,VA),经过MMU将VA映射成物理地址(Physucal Address,PA),然后使用物理地址查询高速缓存,这种高速缓存称为物理高速缓存。
  3. CPU使用虚拟地址寻址高速缓存,这种高速缓存称为虚拟高速缓存。CPU在寻址时,先把虚拟地址发送到高速缓存,若在高速缓存里找到所需数据,就不再访问TLB和DDR。
  4. L1采用了分离缓存的方式,分了L1I和L1D,主要原因如下:
  • 原因一:避免取指令单元和取数据单元竞争访问缓存: 在CPU中,取指令和取数据指令是由两个不同的单元完成的,也就是说在流水线控制中取指和访存是分开的。如果使用统一缓存,当CPU使用超前控制或流水线控制(并行执行)的控制方式时,会存在取指令操作和取数据操作,同时争用同一个缓存的情况,这会降低CPU运行效率。
  • 原因二:内存中数据和指令是相对聚集的,分离缓存能提高命中率: 在现代计算机系统中,内存中的指令和数据并不是随机分布的,而是相对聚集地分开存储的。因此,CPU Cache中也采用分离缓存的策略,这更符合DDR内存中数据的组织形式,从而提高cache命中率。
  1. 针对高速缓存的优化
    一个数据结构中最常用的字段放在该数据结构内的低偏移部分,以便它们能够处于高速缓存的同一行中:这是一种内存布局优化策略,它建议将一个数据结构中最频繁使用的字段放在数据结构的起始部分或低偏移部分。这样做的目的是为了确保这些常用字段可以被更快地访问,因为在内存中,距离数据结构起始部分较近的数据更容易被高速缓存缓存起来。当CPU访问数据时,它通常会以块(称为高速缓存行)为单位加载数据到高速缓存中。如果常用字段在低偏移部分,它们更有可能与其他数据一起缓存,提高了访问速度。
    当为一大组数据结构分配空间时,内存试图把它们都放在内存中,以便所有高速缓存行按同一方式使用:这是为了确保这些数据结构可以尽可能地处于相邻的内存位置。当数据结构彼此相邻时,它们通常会处于同一高速缓存行中,这意味着CPU在访问它们时可以更有效地利用高速缓存。

1.3 linux寻址

linux将所有进程使用相同的段寄存器值,紧紧使用4个主要的段:用户代码段、用户数据段、内核代码段、内核数据段,并且4个主要段的首地址都是0x00000000 ,段选择符由对应的宏来定义。因此对linux来说,线性地址和逻辑地址是同一个。
MMU通过分段单元的硬件电路将逻辑地址转换成线性地址,分页单元的硬件电路将线性地址转为物理地址(页表会指定虚拟地址和物理地址之间的关系),线性地址和逻辑地址都属于虚拟地址。虚拟地址可能是连续的,但是物理地址不一定连续,这是通过分配一组非连续的物理地址页框来实现逻辑上的连续。
页指的是虚拟地址中的一页,和物理地址页框一一对应,对应关系放在页表中。 虚拟地址偏移A,页面大小L(一般L为4KB),页表首地址为B,那么页内偏移地址为A%L,页表号为A/L+B,物理地址为 查询到的(A/L+B)中的页框地址+页内偏移地址A%L。
CPU对内存的一次访问动作需要访问两次物理内存才能达到目的,第一次,拿到框的起始地址,第二次,访问最终物理地址。CPU的效率变成了50%。为了提高CPU对内存的访问效率,在CPU第一次访问内存之前,加了一个快速缓冲区寄存器,它里面存放了近期访问过的页表项。当CPU发起一次访问时,先到TLB中查询是否存在对应的页表项,如果有就直接返回了。整个过程只需要访问一次内存。

二、进程

2.1 current

linux把进程描述符放在动态内存中,并为每个进程分配了8k的连续页框分别存放进程描述符相关结构thread_info和进程堆栈,通过计算公式current = (void *)(esp_reg & ~(THREAD_SIZE - 1));其中THREAD_SIZE是内核线程栈的大小,这个大小通常为8kb或4kb
请添加图片描述
内核维护了140个优先级队列,通过list_head的出队入队实现dequeue_task和enqueue_task

2.2 等待队列

等待队列结构体中的flags字段用来表明是否是互斥进程。互斥进程(flags为1)在唤醒时只会唤醒链表中的一个,而非互斥进程则会唤醒所有

  • 内核提供了sleep_on、interruptible_sleep_on、sleep_on_timeout等接口,本质是通过修改 task->state。实现中会采用循环多重判定,即进入wait_event判定次,被唤醒后再判定一次。主要实现简码如下
wait_event(){
	init_wait_entry();//初始化等待队列实体并加入等待队列的链表中
	for (;;) {
		prepare_to_wait();
		if (condition)
			break;
		schedule();
	}
	finish_wait(); // 从等待队列链表中删除
}
  • 等待队列在唤醒时提供了wake_up等很多接口。互斥进程在等待队列的末尾,非互斥进程在等待队列的开头,但是互斥进程和非互斥进程在同一个等待队列中是非常罕见的

2.3 进程资源限制

进程的资源限制存放在current->signal->rlim字段中,数据结构如下。内核定义了rlimit数组,针对进程地址空间最大数、内存信息转储文件最大数、进程使用cpu的最长时间等16个方面都做出了设定。普通用户可以通过getrlimit和setrlimit等系统调用修改rlim_cur字段并最大达到rlim_max。超级用户可以修改rlim_max字段。子进程将会继承父进程的rlimit限制。

struct rlimit {
	unsigned long rlim_cur; 	// 当前的资源限制值
	unsigned long rlim_max;		// 最大的资源限制值
}

2.4 进程切换

由两步组成

  1. 切换页全局目录以安装一个新的地址空间
  2. 切换硬件上下文:所有进程的地址空间是独立的,但是共享cpu的寄存器,因此进程切换需要首先切换硬件上下文,即保存切换prev的硬件上下文,用切进的next的硬件上下文替换掉prev在cpu寄存器中的地址。早期的硬件上下文切换通过硬件来实现,linux2.6后通过软件实现。硬件上下文保存在task_struct的thread_struc的thread字段中。这些操作在宏switch_to中完成,通过内联汇编来实现。
    主要耗时应该在 切换页表全局目录、切换内核态堆栈、切换硬件上下文(进程恢复前,必须装入寄存器的数据统称为硬件上下文)、刷新TLB、系统调度器的代码执行

2.5 创建进程(讲的太细了,待看)

为了避免创建子线程时拷贝大量数据影响性能,linux采用写时复制,即父子进程读取相同的物理页,只有当一方需要写一个页时才会拷贝这个页的内容并分配给正在写的进程。
do_fork

  1. 查找位图,分配新的pid
  2. 复制进程描述符

copy_process

  1. 获取进程描述符(task_struct)
  2. 分配内存存放thread_info和内核栈.

一个进程需要哪些数据结构,或者说在创建一个进程时需要给这个进程分配哪些数据结构

2.6 销毁进程(讲的也太细,看的比较粗略)

两个函数do_group_exit(针对进程组)和do_exit(针对单个进程)
do_group_exit相当于给进程组中的其他每个进程发送一个SIGKILL信号,自身调用do_exit
do_exit:

  1. 将进程描述符flags设置为PF_EXITING表示进程正在被删除
  2. 从动态定时器队列中删除进程描述符
  3. 调用exit_mm、exit_sem、exit_files、exit_fs、exit_namespace、exit_thread,分离出分页、信号量、文件系统、打开文件描述符、命名空间以及I/O权限位图相关的数据结构,如果这些数据未被共享,将会被删除
  4. 调用schedule以运行新的进程

三、中断和异常

3.1 概念

中断到达时,cpu必须停止它当前正在做的事情,在内核态堆栈保存程序计数器的当前值,并把与中断类型相关的一个地址放进程序计数器。有点类似硬件上下文的切换,但是由于中断上下文很少(无需切换页表等),因此中断处理程序比进程“轻”很多。
由于中断处理程序运行时可能在产生另一个中断,因此中断处理程序必须要求是可嵌套的。这就要求中断处理程序永不阻塞,即中断处理程序运行期间不能发生进程切换。嵌套的内核控制路径恢复执行时所需要的所有数据都放在内核态的堆栈中,这个栈属于当前进程。

3.2 IRQ

32条irq由中断控制器来控制,中断的过程如下

  1. 监视irq线,检查产生的信号。如有多个信号,选择编号小的
  2. 将接收到的信号转换成对应的向量
  3. 将向量存放在中断控制器的一个I/O端口,从而允许CPU通过数据总线读取这个向量
  4. 把引发信号发送到处理器的INTR引脚,即产生一个中断
  5. 等待知道CPU通过将这个型号写进中断控制器的I/O端口来确认它
  6. 清INTR线,返回第一步
    可以有选择的激活或禁止单条irp线,被禁止的中断会等到irq线被激活后重新被发送到cpu

3.3 异常

linux为20种异常提供了对应的处理函数,并发送对应的信号

3.4 中断描述符表的初始化

linux把三种类类型(终端门、陷阱门、系统门)的中断描述符的特权优先级(dpl)设置为0,防止用户进程模拟非法的中断和异常。

3.5 软中断和tasklet

软中断的分配是静态的,可以并发的运行在多个cpu上,是可重入的,因此需要自旋锁的保护。
tasklet的分配是动态的,虽然在软中断之上实现的,但是不可重入的。同类型的tasklet只能串行执行,不可在多个cpu上运行同一种tasklet,因此不需要锁的保护,可以在多个cpu上运行不同的tasklet。
可延迟函数有4种操作:初始化、激活、屏蔽、执行。在哪个cpu上激活就会在这个cpu上执行。

3.5.1 软中断

linux使用了6个软中断优先级从0到5,优先级下标低的优先执行。
软中断的主要数据结构为指向action指针和指向软中断函数需要的通用数据结构的data指针、32为的preempt_count字段、每个CPU存放在irq_cpustat_t中的32为掩码。
preempt_count字段主要用于跟踪内核抢占和内核控制路径的嵌套,存放在thread_info中。宏in_interrupt 在单内核栈中只需要检查这个字段,在多内核栈中还需要检查本地cpu的irq_ctx联合体thread_info描述符的preempt_count字段。preempt_count字段的编码表示三个不同的计数器和一个标志:

  • 0~7 抢占计数器,表示禁用本地CPU内核抢占的次数,值为0表示允许抢占
  • 8~15 软中断计数器,表示可延迟函数被禁用
  • 16~ 27 硬中断计数器,表示在本地CPU上中断处理程序的嵌套数
  • 28 PREEMPT_ACTIVE标志

内核线程ksoftirqd详解(每个cpu一个)

for(;;) {
	set_current_state(TASK_INTERRUPRIBLE);
	schedule();
	while(local_softirq_pending()) {	// 加载本地cpu软中断位掩码,检测是否有挂起的软中断需要处理
		preempt_disable();	// 禁用本地抢占
		do_softirq();
		preempt_enable();
		cond_resched();
	}
}

3.5.2 tasklet

字段 next(指向链表中下一个描述符的指针)、state(tasklet的状态)、count(锁计数器)、func(指向tasklet函数的指针)、data(一个无符号长整数)。state含有TASKLET_STATE_SCHED和TASKLET_STATE_RUN(正在被执行)标志。因为同一个tasklet同一时间只能运行在一个cpu上,因此在多系统上,tasklet在运行前需要检查TASKLET_STATE_RUN标志,判定同类型tasklet是否在其他cpu上运行。

3.5.3 工作队列

通过链表将要运行的任务链起来,工作线程一旦被唤醒就开始执行对应的函数,可以阻塞或睡眠。
内核提供了event的预定义工作队列,来避免为了运行一个函数而创建整个工作者线程的大开销。

四、内核同步

内核抢占时机:用户态进程陷入系统调用且内核抢占未被禁止,或者等到时钟中断

4.1 每CPU变量

将变量声明为数组,每个CPU对应数组的一个元素,需要确保不同CPU上的数据是逻辑独立的。在使用中需要禁用本地抢占,防止获得本地副本的地址后因抢占被转移到另一CPU上运行。

4.2 原子操作

  • 进行零次或一次对齐内存访问的汇编指令是原子的
  • 在读操作后、写操作前没有其他处理器占用内存总线
  • 操作码前缀是lock字节的,会锁定内存总线,避免其他处理器访问此内存单元

4.3 优化和内存屏障

  1. 优化屏障:
    优化屏障的作用是确保编译器不会改变代码的执行顺序或者省略指定位置的代码,以便满足程序员的预期。用于编译器优化过程,防止编译器优化重新安排汇编语言以使寄存器以最优方式使用。
  2. 内存屏障:
    内存屏障是一种用于控制内存访问顺序和一致性的机制,在多线程编程中经常会用到。内存屏障的作用是确保内存操作的顺序和可见性,防止出现数据竞争和内存相关的问题。内存屏障是在硬件或者操作系统层面起作用的,它可以通过特定的机器指令或者操作系统提供的接口来实现。

4.4 自旋锁

4.4.1 spin_lock宏

在自旋锁忙等待期间内核抢占是有效的。非rt内核会在拿到自旋锁后禁止抢占的,而rt内核在拿到锁后运行抢占和睡眠的。在拿自旋锁前trylock函数中会通过先禁止抢占,再尝试获取锁,获取到了return1,如果没获取到就打开抢占并return 0

int _raw_spin_trylock(spin_lock* lock){
	preempt_disable();
	if (do_raw_trylock(lock)) {
		lock_acquire();
		return 1;
	}
	preempt_enable();
	return 0;
}

4.4.2 读写锁

在实现上比spic_lock多个计数器

// 会根据count的值判定是否有进程占用锁,为0x1000000表示没有进程占用锁,0x00ffffff表示有一个读者,0x0000000表示有一个写者

// 读者
int _raw_read_trylock(spin_lock* lock){
	preempt_disable();
	atomic* count = lock->lock;
	atomic_dec(count); 
	if (atomic_read(count) >= 0) {
		return 1;
	}
	atomic_inc(count);
	preempt_enable();
	return 0;
}
// 写者
int _raw_write_trylock(spin_lock* lock){
	preempt_disable();
	atomic* count = lock->lock;
	if (atomic_sub_and_test(0x1000000, count)) {
		return 1;
	}
	atomic_add(0x1000000, count);
	preempt_enable();
	return 0;
}

4.4.3 顺序锁

类似读写锁,为写者赋予了更高的优先级,结构体中包含了自旋锁和顺序计数器。

  • 优点是读者无需禁用抢占也无需持有自旋锁,每个读者都必须在读数前后读顺序计数器以保证在读的过程中无写者操作。缺点是读者有时需要多次重复读写相同数据
  • 写者之间竞争自旋锁,并且修改后递增顺序计数器
  • 使用顺序锁需保证不存在读者访问或间接引用某个指针变量的数据,而写者修改这个指针地址的情况,也不存在读者的临界区有副作用(即多个读者的读操作和单个读者的读操作有不同结果)

4.5 RCU

  • 适用于多读者少写者的情况,允许多个读者和写者并发进行。
  • 只保护被动态分配并通过指针引用的数据结构,这点和顺序锁相反,顺序锁不保护这种行为
  • 不使用锁,但在使用过程中是禁止抢占和睡眠的。
  • 读者只需读。写者要更新数据结构时,需要先生成整个数据结构的副本,再修改副本,修改完毕需要将原数据结构的指针指向修改后的副本。需要内存屏障来保证只有在数据结构被修改后,更新的指针才对其他cpu可见。
  • 写者更新完指针后必须等到cpu所有读者都执行完rcu_read_unlock才可以释放旧副本。

4.6 信号量

通过等待队列实现可睡眠的锁,运行n个进程并发访问资源,n可设置,当n=1时同mutex。读写信号量相当于把读写锁中的spin_lock变为信号量

4.7 补充原语

功能上和信号量一致,两者之间的区别在于如何使用等待队列中的自旋锁。信号量中的自旋锁用于确保在添加到等待队列链表中的并发(即不同线程调用down函数的并发),而补充原语中的自旋锁用于up和down之间的并发(即线程唤醒某个不存在的信号量)

4.8 禁止中断和禁止可延迟函数

分别调用接口local_irq_disable和local_bh_disable

4.9 数据结构的保护

  • 异常处理:使用信号量
  • 中断:单核只需禁止本地中断,多核需要禁止本地中断+自旋锁
  • 中断下半部:单核不用保护,因为软中断不会被另一个软中断打断。多核中软中断或者多个tasklet之间需要自旋锁保护,单个tasklet之间不需要保护

五、定时测量

5.1 时钟

实时时钟(Renl Time Clock, RTC):所有PC包含的实时时钟,独立于cpu和其他所有芯片。只被linux用于获取时间和日期,在irq8上发出周期性中断。Linux 提供了 rtc 驱动程序来与硬件RTC进行通信,并通过 /dev/rtc 设备文件来访问它。RTC可以提供秒级别的时间精度,并且不受系统时钟的影响。

时间戳计数器(Time Stamp Counter,TSC,定时器对象timer_tsc)是一种由处理器提供的高精度时钟计数器,用于测量指令执行的时间间隔。每个CPU核心都有自己的TSC,它是一个递增的64位计数器,以时钟周期为单位计数。TSC通常是在处理器初始化时启动的,并且以固定的频率递增,通常与处理器的时钟频率相对应。TSC通常用于性能分析、基准测试和时间测量等应用,因为它提供了高分辨率和低开销的时间测量方案。与其他时钟(如系统时钟)相比,TSC不受系统调整或变化的影响,因此可以提供更为精确的时间测量结果。由于TSC是每个CPU核心独立递增的计数器,因此在多核系统中,不同核心上的TSC计数器可能是不同步的。因此,在进行时间测量时,需要考虑到这一点,可以使用CPU绑定或者其他同步措施来确保在同一核心上执行测量操作。

可编程间隔定时器(PIT,定时器对象timer_pit),用于内核产生固定的时钟中断,频率为Hz数,即tick。

高精度定时器(HPET,定时器对象timer_hpet),主要包含8个32位或64位独立计数器,由自己的时钟信号驱动,最低分辨率为100ns

ACPI电源管理定时器(定时器对象timer_pmtmr), 拥有大约为3.58MHz的固定频率,内核通过I/O端口来访问此计数器。如果系统中不存在HPET,那么ACPI的精确度最高

5.2 计时体系和数据结构

timer_opts, 该数据结构包含5个字段,name(标识定时器源的一个字符串)、mark_offset(记录上一个节拍的准确时间,由时钟中断处理程序调用)、get_offset(返回自上一个节拍开始经过的时间)、monotonic_clock(返回自内核初始化开始所经过的纳秒数)、delay(等待指定数目的循环)。linux内核通过mark_offset和get_offset两个字段得到比周期节拍更高的时钟精确度。Linux在初始化时会按照以下顺序选择可用的时钟源:HPET、ACPI、TSC、PIT。

jiffies, 记录自系统启动以来产生的节拍总数,是一个32位变量,通过顺序锁将两个32计数器合成64位计数器防止溢出

xtime, timespec类型结构体,每个节拍更新一次,记录自1970年1月1日后经过的秒数

5.3 软定时器和延迟函数

定时器分为由用户态创建的间隔定时器和由内核态创建的动态定时器。定时器的定时值由当前的jiffies加上设定的节拍构成,内核在软中断中比较定时值和此刻jiffies的大小决定是否触发定时器。由于在软中断中进行比较,无法确保时间的准确性。

动态定时器, 存放在timer_list的数据结构中,在第一个执行add_timer或mod_timer的cpu上运行

struct timer_list
{
	struct list_head entry;	 // 存放的链表
	unsigned long expires;	// 定时器到期的时间
	spinlock_t lock;
	unsigned long magic;
	void (*function)(unsigned long);  // 回调函数
	unsigned long data;  // 回调函数的参数
	tvec_base_t *base;
}

内核维护了一个tvec_bases的每CPU变量,依据expires的值将动态定时器放入不同的链表中

struct tvec_base_t
{
	spinlock_t losk;
	unsigned long timer_jiffies; // 需要检查的动态定时器的最早到期时间
	struct timer_list* running_timer;
	tvec_root_t tv1; //由256个list_head组成的数组,包含在接下来到来的255个节拍内要到期的动态定时器
	tvec_t tv2;	// 包含2^14-1节拍内到期的动态定时器
	tvec_t tv3;	// 包含2^20-1节拍内到期的动态定时器
	tvec_t tv4;	// 包含2^26-1节拍内到期的动态定时器
	tvec_t tv5;	// 包含2^32-1节拍内到期的动态定时器
}

nanosleep,使用了动态定时器,通常动态定时器有着很大的设置开销和一个相当大的最小等待时间(1ms,或者说是1节拍)
udelay或ndelay, 如没有硬件支持(HPET或TSC),就采用循环次数,即1个节拍内循环多少次,通过循环对应的次数实现相对精确的微妙/纳秒等待。

5.4 相关系统调用

gettimeofday,返回UTC后走过的秒数和前1秒走过的微妙数。settimeofday则会修改系统时间,但不会修改rtc寄存器中的值,不能对时钟频率等参数进行调整。

adjtimex,可以调整系统时间的各种参数,同时也能够控制时钟频率、时钟精度等,多用于时间同步中。

setitimer和alarm,激活定时器发送信号到进程,有三种策略:ITIMER_REAL(真正过去的时间,进程接受SIGALRM信号,采用的是动态定时器)、ITIMER_VIRTUAL(进程在用户态下花费的时间,接收SIGVTALRM信号)、ITIMER_PROF(进程即在用户态又在内核态下所花费的时间,接受SICPROF信号)

linux使用动态定时器来实现posix定时器

六 进程调度

6.1 调度优先级

静态优先级:100~139,用户可以更改进程的静态优先级,新进程总是继承父进程的静态优先级。静态优先级和进程的基本时间片如下,静态优先级<120,基本时间片为(140 - 静态优先级)* 20 (ms),静态优先级>=120,时间片为(140 - 静态优先级)* 5
动态优先级:max(100, min(静态优先级 - bonus + 5, 139)),bonus与进程的平均睡眠时间有关。设计出动态优先级是为了处理交互式进程和批处理进程,交互式进程cpu占用时间少,睡眠时间多,应该有较高的优先级。
实时优先级:1~99
活动进程:未用完时间片的进程(fifo没有时间片的概念)
过期进程:用完时间片的进程被禁止运行,直到所有活动进程都过期

6.2 数据结构

runquere 为每CPU变量,每个prio_array_t 结构包含了140双向链表的表头,对应了140个优先级。调度程序通过交换active和expired字段内容来将活动进程变为过期进程

struct runquere // 主要成员
{
	spinlock_t lock;	// 保护链表的自旋锁
	prio_array_t *active; // 指向活动进程链表的指针
	prio_array_t *expired; // 指向过期进程链表的指针
	prio_array_t[2] arrays; 	// 活动进程和过期进程的集合
}

进程描述符

struct thread_info
{
	unsigned long flags; // 存放TIF_NEED_RESCHED标志,如果必须调用调度程序,则设置该标志
	unsigned long cpu; // 所在可运行队列的CPU逻辑号
	unsigned long state; // 进程的当前状态
	int prio; // 动态优先级
	int static_prio; // 静态优先级
	unsigned long rt_prio; // 进程的实时优先级
	struct list_head run_list; // 指向进程所属运行队列链表中的下一个和前一个元素
	prio_array_t* array; //指向包含当前进程的运行队列的集合
	unsigned int time_slice; // 进程时间片中还剩余的时钟节拍数
}
	

6.3 调度函数

6.3.1 scheduler_tick

每个节拍到来调用此函数,主要用于更新time_slice计数器

  1. 根据TSC时钟更新本地CPU的timestamp_last_tick字段
  2. 如果当前进程是swapper进程,且就绪队列中有进程,就设置TIF_NEED_RESCHED,强迫重新调度。跳到5
  3. 检查current->array是否指向本地运行队列的活动链表,不是说明进程已经过期,应该被替换,设置TIF_NEED_RESCHED,强迫重新调度。跳到5
  4. 递减当前时间片计数器,检查时间片是否用完,不同调度策略在这一步行为不一样
    • fifo进程的time_slice无任何意义
    • rr,将会递减时间片,检查是否用完,用完就重新装填时间片,并设置TIF_NEED_RESCHED
    • 普通进程,递减时间片,如果用完,就将自身由active指向expired,重新装填时间,更新动态优先级
  5. 调用rebalance_tick函数确保CPU之间负载均衡

6.3.2 try_to_wake_up

将要唤醒的进程运行状态设置为RUNNING,更新进程的动态优先级、睡眠时间、时间戳、avtivated等字段,并放置到对应的CPU运行队列中

6.3.3 recalc_task_prio

主要用于更新进程的平均睡眠时间和动态优先级

6.3.4 schedule

调用方式:

  • 直接调用:当current进程因不能获得必须得资源而立刻被阻塞时,就直接调用schedule调度程序。
    1. 将current进程插入适当的等待队列
    2. 将current的状态改为TASK_INTERRUPTIBLE或TASK_UNINTERRUPTIBLE
    3. 调用schedule
    4. 检测资源是否可用,如不可用转到第二步,可用就从等待队列中删除current,并将状态设置为RUNNING
  • 延迟调用:将进程的TIF_NEED_RESCHED设置为1,等待其他函数调用schedule进行调度

schedule函数行为

  1. 禁用本地抢占,确保current进程不占用大内核锁
  2. 调用sched_clock读取TSC,计算current进程的run_time
  3. 禁用中断,如果current状态不是可运行态且没在内核态被抢占,就将current从运行队列中删除。如果current状态为TASK_INTERRUPTIBLE,且有信号,将会把current的运行状态设为RUNNING,且重新放回运行队列
  4. schedule将检查运行队列中剩余的可运行进程数。如果无可运行进程就调用idle_balance,从其他运行队列中迁移一些可运行进程到本地CPU中,检测可运行进程中是否至少有一个进程是活动的,如没有就交换运行队列的active和expired。如无可搬运的运行队列,就运行swapper进程。
  5. 当选中了next进程后,如果next和prev是不同进程,schedule函数要进行上下文切换工作。首先先加载next的thread_info,并把thread_info的第一部分字段内容装入硬件高速缓存以改善性能(装入硬件高速缓存这一过程是由硬件完成,因此和CPU进行后续的行为是同步的)
  6. 如果next是普通进程,上下文切换将会用next的地址空间替换prev的地址空间。在schedule函数中,prev为内核线程或普通进程有很大的区别。

6.4 多处理器运行队列的平衡

逻辑CPU:指操作系统看到的CPU处理单元。在支持超线程(Hyper-Threading)和多核(Multi-core)的处理器中,每个物理内核可以表现为多个逻辑CPU。
调度域:Linux内核中用于定义CPU之间调度关系的抽象层次。它由一组共享某些调度策略和属性的CPU组成。调度域用来实现负载均衡,以确保系统各部分的工作负载大致均匀,从而提高系统整体性能。
为了保证运行队列的平衡,Linux主要调用以下函数进行负载均衡

  • rebalance_tick
    每个时钟节拍,scheduler_tick就调用rebalance_tick来保持系统中运行队列的平衡,接受的参数为cpu下标、本地运行队列地址、标志位(SHCHED_IDLE或NOT_IDLE),标志位代表着CPU当前的current是否是swapper进程
    1. 访问运行队列描述符的nr_running和cpu_load字段,来确定运行队列中的进程数,并更新运行队列的平均工作量
    2. 从基本域到上层域开始循环,根据CPU当前的current进程是否为swapper进程,来决定调用load_rebalance函数的频率
  • load_rebalance
    函数作用是判定是否可以通过把最繁忙的组中的一些进程迁移到本地CPU来减轻不平衡的状况,如果是,就进行迁移。

七 内存管理

7.1 页框管理

页描述符字段

  • (atomic_t)_count:页的引用计数器。为-1则代表页框闲置。page_count返回_count+1的值,即该页的使用者
  • (unsigned long)flags:一组标志位,代表页当前的状态,主要状态有
    • PG_LOCKED:页被锁定
    • PG_referenced:刚刚访问过的页
    • PG_active :页活动链表中标志页面是否活跃,PG_active 和PG_referenced两个flag配合使用被linux内核用于活跃和非活跃的双链表管理
    • PG_slab:用于slab页框

非一致性内存访问:在某些硬件体系中,不同CPU对同一块内存单元的访问需要的时间是不同的。因此系统的物理内存被划分为了几个节点,每个cpu会对应一个节点。在给定的单独节点中,给定CPU访问页面所需要的时间是相同的。每个节点都有一个类型为pg_data_t的描述符,主要字段如下

  • (int)nr_zones:节点中管理区的个数
  • (page*)node_mem_map:节点中页描述符的数组
  • (unsigned long)node_start_pfn:节点中第一个页框的下标
  • (unsigned long)node_spanned_pages:节点的大小

内存管理区:针对DMA只能对RAM的前16M寻址、大容量RAM的32位机中线性地址小CPU不能直接访问所有物理内存这两种限制(linux内核只有1G,无法直接访问高于1G的地址),linux把每个内存节点的物理内存划分为三个管理区

  • ZONE_DMA:包含低于16MB的内存页框
  • ZONE_NORMAL:包含高于16MB且低于896MB的内存页框
  • ZONE_HIGHMEM:包含从896MB开始高于896MB的内存页框

设计出三种内存区的优势在于:

  • 低端内存(低于896MB),内核可以直接映射一部分物理内存到虚拟空间地址。
  • 高端内存(高于896MB),由于32位机限制了内核可寻址的范围,内核通过临时映射来访问高端内存,突破内核直接映射的限制,扩展内存的使用范围。低端内存可以用于频繁访问和需要直接映射的内存需求,而高端内存可以用于缓存、文件系统缓冲等不需要频繁直接访问的场景。这种设计方式确保了用户空间和内核空间的隔离,防止用户态程序直接访问和修改内核态内存,提高了系统的安全性和稳定性。

7.1.1 页框的分配

内核调用内存分配函数时,必须指明请求页框所在的管理区。如果有足够的空闲,那么请求就会被满足,否则必须回收一些内存。针对原子分配,为了减少分配失败的可能,内核保留了一个页框池,只有在内存不足时才使用。保留内存的数量存放在min_free_kbytes变量中取决于包含在ZONE_DMA和ZONE_NORMAL内存管理区内的页框数目,可通过写入/proc/sys/vm/min_free_kbytes修改。管理区描述符的pages_min字段存储了管理区内保留页框的数目。
常用的页框分配函数:

  • alloc_pages(gfp_mask, order):用于获得2^order个连续页框,分配失败返回NULL
  • alloc_page(gfp_mask):用于获得一个单独页框的宏,等价于alloc_pages(gfp_mask, 0)
  • __get_free_pages(gfp_mask, order):返回的时第一个所分配页的线性地址

gfp_mask为一组如何寻找页框的标志,常用如下,一般使用内核提供的组合标志值:

  • __GFP_DMA:所请求的页框必须处于ZONE_DMA管理区
  • __GFP_HIGHMEM:所请求的页框必须处于ZONE_HIGHMEM管理区
  • __GFP_WAIT:允许内核阻塞当前正在等待分配的进程
  • __GFP_HIGH:运行访问保留的页框池
  • __GFP_REPEAT:反复分配直至成功

常用的释放页框函数:

  • free_pages(addr, order):释放2^order个连续页框,addr为第一个页框的线性地址
  • free_page(addr):用释放一个单独的页框

7.1.2 高端内存映射

高端映射只能通过alloc_pages/alloc_page来分配,返回的是页框的页描述符的线性地址(页框描述符在内核初始化时分配在)。高端映射相当于把超出内核能够直接访问的内存地址映射到内存能够访问的低于4g的内存地址。高端映射有三种:永久内核映射、临时内核映射、非连续内存分配

永久内核映射
内核使用了主内核页表中一个专门的页表来记录内核建立高端页框到内核地址空间的长期映射。内核使用了散列表来记录高端内存页框和永久内核映射的线性地址之间的联系。page_address用于获取页框对应的线性地址,如果在高端内存中或者未被映射就返回null。page_address函数接收一个页描述符指针page作为参数,有以下两种情况:

  • 页框不在高端内存中,计算页框下标将其转化为物理地址,再得到对应的线性地址。由代码__va((unsigned long)(page-mem_map)<<12)完成
  • 页框在高端内存中,该函数就到散列表中查找,如果找到页框,就返回它的线性地址

7.1.3 伙伴算法

参考文章:Linux系统内存管理之伙伴算法分析
目的:减少内存碎片
优点:分配的页框物理地址连续,提高访问内存的平均速度
缺点: 一次分配为2^n次方个页框,浪费部分内存;一段小块被使用会阻挡大块的合并,且合并块需要时间
数据结构:不同的管理区使用不同的伙伴系统,数据结构主要为

struct free_area {
	struct list_head free_list;
	unsigned long nr_free;
}
// ZONE 区数据结构如下
struct zone {
	struct free_area free_area[MAX_ORDER};
	......
}

实现:

  • 位图:Linux内核伙伴算法中用位图来记录内存块的状态,位图的某位对应于两个伙伴块,为1就表示其中一块忙,为0表示两块都闲或都在使用。系统每次分配和回收伙伴块时都要对它们的伙伴位跟1进行异或运算。位图的主要用途是在回收算法中指示是否可以和伙伴块合并,分配时只要搜索空闲链表就足够了。
  • 分配:将空闲页框分为11个块链表,每个块链表包含1、2、4、8、16、32、64、128、256、512、1024个连续的页框。比如,要分配4(2^2)页(16k)的内存空间,算法会先从free_area[2]中查看nr_free是否为空,如果有空闲块,就直接从中摘下并分配出去,如果没有空闲块,就顺着数组向上查找,从它的上一级free_area[3](每块32K)中分配,如果free_area[3]中有空闲块,则将其从链表中摘下,分成等大小的两部分,前四个页面作为一个块插入free_area[2],后4个页面分配出去,如果free_area[3]也没有空闲,则从更上一级申请空间,如果free_area[4]中有,就将这16(222*2)个页面等分成两份,前一半挂在free_area[3]的链表头部,后一半的8个页等分成两等分,前一半挂free_area[2]的链表中,后一半分配出去。依次递推,直到free_area[max_order],如果顶级都没有空间,那么就报告分配失败。
  • 合并:当内存释放请求到来时,算法会将该内存块标记为空闲。然后检查其伙伴(如果存在),如果伙伴也是空闲状态,则合并它们,形成一个更大的空闲内存块,递归直到不能再合并为止。

7.1.4 每CPU页框高速缓存

为了提高本地CPU对内存申请和释放的效率,减少页框分配释放的CPU之间的竞争,内存管理区为每个CPU提供了热高速缓存和冷高速缓存。
热高速缓存:针对内核或进程刚分配到页框就立刻向页框写的场景,新分配的热高速缓存页框会更新cpu的硬件缓存
冷高速缓存:针对由DMA申请分配的页框,无需经过CPU操作
数据结构:主要数据结构是存放在内存管理区描述符的pageset字段中由一个per_cpu_pageset数组数据结构。per_cpu_pageset包含两个per_cpu_pages描述符,分别代表热高速缓存和冷高速缓存。

struct per_cpu_pages {
	int count;  // 高速缓存中页框的个数
	int low;	// 下界,表示高速缓存需要补充
	int high;	// 上界,表示高速缓存用尽
	int batch;	// 在高速缓存中将要添加或被删去的页框数
	struct list_head list;	// 高速缓存的页框描述符链表

页框个数低于low,内核通过从伙伴系统中分配batch个单一页框来补充对应的高速缓存。高于high,则会释放batch个页框到伙伴系统中。

7.1.5 管理区分配器

7.1.5.1 分配

分配时应满足的目标:

  • 保护保留的页框
  • 内存不足时允许阻塞当前线程,并触发页框回收算法,一旦有页框被释放,就再次尝试分配
  • 如果可能,尽量保存ZONE_DMA内存区。一般如果时ZONE_NORMAL或ZONE_HIGHMEM页框的请求,不太会分配ZONE_DMA内存区中的页框

分配函数__alloc_pages,对于每个内存管理区,该函数都将空闲页框的个数与一个阈值作比较。当空闲内存不足时,每个内存区都会被检查多次且每次的阈值都会比上一次检查更低,每次检查的阈值值由zone_watermark_ok确定。buffered_rmqueue在指定的内存管理区中分配页框。__alloc_pages实现如下:

for (i = 0; (z = zonelist->zones[i]) != NULL; i++) {
	if (zone_watermark_ok(z, order, ...)) {
		page = buffered_rmqueue(z, order, gfp_mask);
		if (page)
			return page;
	}
}
7.1.5.2 释放

释放函数__free_pages执行步骤如下:

  1. 检查第一个页框是否属于动态内存(PG_reserved标志被清0),如果不是,就终止
  2. 减少page->_count使用计数器的值,如果大于或等于0.终止
  3. 如果释放页框数量的对数order等于0, 那么该函数就调用free_hot_page来释放页框给每CPU热高速缓存
  4. 如果order大于0, 就将页框加入本地链表,并调用free_pages_bulk函数来释放到适当的内存管理区伙伴系统中。

7.2 内存区管理

为了解决远小于一个页框大小的内存分配情况,解决内存的内部碎片问题

7.2.1 slab分配器

slab的主要作用

  • slab分配器分配内存以字节为单位,基于伙伴分配器的大内存进一步细分成小内存分配
  • 维护常用对象的缓存,例如内核中对task_struct 等常用数据结构维护一个slab的高速缓存
  • 提高CPU硬件缓存的利用率

slab的缺点:

  • 缓存队列管理复杂
  • 管理数据存储开销大
  • 对numa支持复杂

slab描述符:用于描述和管理整个slab内存区域,记录了该slab的状态、使用情况、内存块的分配情况等关键信息,字段如下

类型名称说明
struct list_headlistslab描述符的三个双向循环链表中的一个
unsigned longcolouroffslab中第一个对象的偏移
void*s_memslab中第一个对象的地址
unsigned intinuse当前正在使用的(非空闲)slab中的对象个数
unsigned intfreeslab中下一个空闲对象的下标,如果没有则为BUFCTL_END

slab主要维护在高速缓存描述符的kmem_cache_node 这一数据结构中,共维护了3种状态的slab,满的、部分满的和空闲的,当有分配内存需求时优先从部分满的slab种分配。如果不存在部分满的和空闲的slab,将会申请新的页并添加到空闲的slab链表中

struct kmem_cache_node {
    spinlock_t list_lock;
    struct list_head slabs_partial;    /* partial list first, better asm code */
    struct list_head slabs_full;	
    struct list_head slabs_free;
    unsigned long total_slabs;    /* 所有 slab list 的长度 */
    unsigned long free_slabs;    /* 仅 free slab list 的长度*/
    unsigned long free_objects;
    ...
};

7.2.2 slab内存区的创建和释放

  1. 从高速缓存中分配slab:
    • 分配slab的时机:收到分配新对象的请求且高速缓存中不包含任何空闲对象(在一个新创建的高速缓存中,由于缓存中还没有slab,所以也就没有可用的空闲内存块来存储对象)。
    • slab分配器主要调用cache_grow给高速缓存分配一个新的slab,分配的过程如下:
      1. cache_grow调用kmem_getpages从分区页框中获取一组页框
      2. cache_grow调用alloc_slabmgmt来获得一个新的slab描述符,如果对象不超过 1/8 个物理内存页框的大小,那么这些 slab 管理结构直接存放在 slab 的内部,位于分配给 slab 的第一个物理内存页框的起始位置;否则的话,存放在 slab 外部,位于由 kmalloc 分配的通用对象缓冲区中。
      3. cache_grow将高速缓存描述符和slab描述符地址分别赋给lru字段的next和prev子字段,因此当 给定一个页框,内核能够确定是否被slab分配器所使用,并就迅速得到响应高速缓存和slab描述符地址
      4. cache_grow调用cache_init_objs将构造方法(如果有的话)应用到新slab所包含的对象上
      5. 调用list_add_tail将新的slab描述符添加到全空slab链表的末端,并更新空闲对象计数器
  2. 从高速缓存中释放slab:
    • 释放的条件:slab高速缓存中有太多的空闲对象或者当定时器函数确定某个slab完全未被使用时
    • slab分配器调用slab_destroy函数来撤销一个slab,并释放页框到相应的页区页框分配器,释放过程如下:
      1. 检查对应slab是否有析构函数,有就调用析构
      2. 调用kmem_freepages释放页框,如果slab描述符存放在slab外部,就释放slab描述符

7.2.3 slab管理的对象

对象描述符,存放在一个数组中,位于slab描述符后,因此存放的位置有外部对象描述符和内部对象描述符。对象描述符是一个无符号整形,只有在对象空闲时才有意义,表示下一个空闲对象在slab中的下标,类似于在slab空闲对象的简单链表。第一个对象由数组中的第一个对象描述符描述,最后一个对象描述符用常规值BUFCTL_END描述。
slab对象缓存对齐,目的是为了对缓存友好,确保小对象不会横跨两个高速缓存行。
1. 缓存首地址对齐:分配内存给 SLAB 高速缓存中的对象时,确保内存分配的起始地址是缓存行大小的整数倍
2. 缓存大小对齐:确保 SLAB 高速缓存中每个对象的大小是缓存行大小的整数倍。

7.2.4 slab着色

目的:在多处理器系统中,多个处理器可能会竞争同一个 Cache Line,导致缓存行的争用(cache contention)。Cache Coloring 可以将不同的 Slab 映射到不同的 Cache Line,减少竞争,提高性能。

7.2.5 slab对象的分配和释放

slab对象的分配:
调用kmem_cache_alloc函数,函数首先试图从本地高速缓存中获取一个空闲对象,当本地高速缓存中没有空闲对象时,就调用cache_alloc_refill函数重新填充本地高速缓存并获得一个空闲对象。过程如下

  1. 查找合适的Slab Cache:当系统需要一个特定类型的对象时,首先在对应的Slab Cache中查找。
  2. 选择Slab:在找到的Slab Cache中,系统会寻找状态为部分满(Partial)或空(Empty)的Slab。优先选择部分满的Slab,以提高内存利用率。
  3. 分配对象:从选定的Slab中分配一个空闲对象。如果选择的是空Slab,系统会先初始化该Slab,然后分配对象。
  4. 更新Slab状态:分配对象后,更新Slab的状态。如果所有对象都被分配,Slab状态变为满

slab对象的释放:

  1. 确定对象所属的Slab:释放对象时,系统首先确定该对象属于哪个Slab。
  2. 释放对象:将对象标记为未使用,返回到Slab的空闲对象池中。
  3. 更新Slab状态:如果释放对象前Slab是满的,则释放后状态变为部分满(Partial)。如果释放后Slab中所有对象都是空闲的,则状态变为空(Empty)。
  4. Slab的回收:如果一个Slab长时间处于空状态,系统可能会决定回收该Slab,释放内存给操作系统。

7.3 非连续内存

slab和页框分配器分配的都是连续的内存区,非连续的内存区大小必须是4096倍数。
非连续内存区vm_struct描述符

类型名称说明
void*addr内存区第一个内存单元的线性地址
unsingned longflags非连续内存区映射的内存的类型
struct vm_struct *next指向下一个vm_struct结构的指针

通过next指针形成了一个简易的链表,链表的第一个元素在vmlist变量中。非连续内存区描述符是连续的,通过kmalloc申请。

八 进程地址空间

进程地址空间指的是进程所有的线性区的合集,或者说线性地址空间。线性区指的是如下的一个个分开的区域。
进程的虚拟内存分配

8.1 内存描述符

数据结构mm_struct存放在进程描述符的mm字段

struct mm_struct
{
    struct vm_area_struct *mmap;
    rb_root_t mm_rb;
   ...
    atomic_t mm_users;//代表正在使用该地址的线程数目,当该值为0时mm_count也变为0;
    atomic_t mm_count;
 
    struct list_head mmlist;
    unsigned long start_code;//代码段的首地址
    unsigned long end_code;//代码段的结束地址
    unsigned long start_data;//数据段
    unsigned long end_data;
    unsigned long start_brk;//堆
    unsigned long brk;
    unsigned long start_stack;//进程栈的首地址,进程栈通常是通过堆栈帧(stack frame)的方式来组织的,每次函数调用都会在栈上分配一部分空间,函数返回时再释放。
    //由于栈的大小在运行时动态变化,因此不需要在 struct mm_struct 中专门存储进程栈的结束地址。
    
   ...
};

8.2 线性区

进程将所有的线性区按内存地址升序排列组成链表,没两个线性区中由未用的内存地址隔开。进程描述符的mmap字段指向链表中的第一个线性区,其中单个线性区字段vm_area_struct如下:

struct vm_area_struct
{
	struct mm_struct* vm_mm; // 指向线性区所在的内存描述符
	unsigned long vm_start; // 线性区内的第一个线性地址
	unsigned long vm_end; // 线性区之后的第一个线性地址
	struct vm_area_struct* vm_next; // 进程线性区链表的下一个线性区
}

线性区处理主要通过遍历红黑树,删除或添加节点。
查找给定地址的最邻近区: find_vma()
查找与给定地址重叠的线性区: find_vma_intersection
查找一个空闲的地址空间: get_unmapped_area
向链表中添加一个线性区: insert_vm_struct

8.3 缺页异常

虚拟地址需要映射到物理页。缺页是指访问虚拟地址时候,对应的物理地址还没有映射到虚拟地址空间。
正常情况下的引起缺页异常原因:

  • 文件通常被分为多个虚拟页(或块),而不是一次性全部加载到内存中。当应用程序访问文件的某个部分时,操作系统会将相应的虚拟页加载到内存中,如果该页尚未在内存中,这种加载过程就称为缺页
  • 由于内存紧张,出现了换页操作,将部分页置换到了磁盘上。当再次访问这些磁盘上的页时,引发缺页
  • 写时拷贝可能会引起缺页异常
  • 请求掉页
  • 在栈的生长方向往低地址方向生长时,可能会出现缺页异常的地址不在任何vma闭区间内,而是 vma->vm_start > address,此时需要向下扩展栈所属的vma的起始地址

缺页异常处理函数do_page_fault行为如下
do_page_fault函数

  1. 请求掉页
    推迟页框的分配直至被第一次访问时。进程开始运行时并不会访问地址空间内的所有地址,临时用不到的页框可以由其他进程来使用。请求掉页能够提高系统的空闲页框平均数,为这一有点付出的代价时系统额外的开销,由请求掉页引发的缺页异常必须由内核来处理,这将浪费cpu的时钟周期。但是局部性原理保证了一旦进程开始在一组页上运行,在接下来相当长的一段时间内会一直停留在这些页上而不去访问其他的页。因此这种缺页异常可以被认为是一种稀有事件。
    请求掉页的三种情况,可以根据页表项进行判定:
    • 页未被进程访问,且不在磁盘
    • 页被进程访问过,但是被置换到磁盘
    • 页属于非线性磁盘文件的映射
  2. 写时拷贝
    父进程和子进程共享页框,只要页框被共享,就不能被修改。一旦父进程或子进程试图写一个共享的页框,就产生一个异常,内核将会复制这个页到新的页框并标记为可写。原来的页依旧是收到保护的,当其他进程试图写入时,内核会检查这个写进程是否是页框的唯一主,是的话就把页框标记为可写。页框的_count变量表明被引用数。
;