计算机体系结构基础(第3版)
上QQ阅读APP看书,第一时间看更新

4.2 六种常见的上下文切换场景

CPU运行指令的过程中,根据应用或者操作系统的需要,经常会改变指令的执行流,同时根据需要在不同的上下文之间切换。本节讲述指令系统如何实现函数调用、中断与异常、系统调用、进程、线程以及虚拟机等上下文切换场景。

4.2.1 函数调用

函数调用是用户主动发起的指令流和上下文改变。普通的转移指令只改变指令流不改变上下文,函数调用则通过ABI约定实现了一定的上下文变化。函数调用通常伴随着栈帧的变化,此外部分寄存器也会发生变化。根据ABI的约定,像$s0~$s8这样约定由被调用者保存(Callee Save)的寄存器在函数调用前后保持不变,而通用暂存器、参数寄存器等则不保证维持调用前的值。

不同指令系统实现函数调用的方式有所不同。LoongArch采用比较典型的RISC做法,硬件仅仅提供一个机制(bl或者jirl指令),用于在改变指令流的同时保存一个返回地址到通用寄存器,其余的都由软件来约定和实现。X86指令系统中则有比较复杂的硬件支持,其函数调用指令call指令有多种形式,硬件可以执行权限检查、保存返回地址到栈上、修改CS和IP寄存器、设置标志位等处理逻辑,但是参数的传递方式还是由软件约定。Sparc指令系统则为了减少函数调用时寄存器准备的开销,引入了体系结构可见的寄存器窗口机制。它的通用寄存器包括8个全局寄存器和2~32个窗口,每个窗口包括16个寄存器。任意时刻,指令可以访问8个全局寄存器、8个输入寄存器、8个局部寄存器、8个输出寄存器,其中前两个由当前窗口提供,输出寄存器由相邻窗口的输入寄存器提供。Sparc提供专门的save和restore指令来移动窗口,调用函数执行save指令,让当前函数的输出寄存器变成被调用函数的输入寄存器,消除了多数情况下准备调用参数的过程,函数返回时则执行restore指令恢复原窗口。这个技术看起来非常巧妙,然而它会给寄存器重命名等现代流水线技术带来很大的实现困难,现在常常被人们当作指令系统过度优化的反面案例。

4.2.2 异常和中断

上一章已经介绍了异常和中断的概念及其常规处理流程。通常异常和中断的处理对用户程序来说是透明的,相关软硬件需要保证处理前后原来执行中的代码看到的CPU状态保持一致。这意味着开始异常和中断处理程序之前需要保存所有可能被破坏的、原上下文可见的CPU状态,并在处理完返回原执行流之前恢复。需要保存的上下文包括异常处理代码的执行可能改变的寄存器(如Linux内核自身不用浮点部件,因此只需要处理通用整数寄存器而无须处理浮点寄存器)、发生异常的地址、处理器状态寄存器、中断屏蔽位等现场信息以及特定异常的相关信息(如触发存储访问异常的地址)。异常和中断的处理代码通常在内核态执行,如果它们触发前处理器处于用户态,硬件会自动切换到内核态。这种情况下通常栈指针也会被重新设置为指向内核态代码所使用的栈,以便隔离不同特权等级代码的运行信息。

对于非特别高频的异常或者中断,操作系统往往会统一简化处理,直接保存所有可能被内核修改的上下文状态,然后调用相应的处理函数,最后再恢复所有状态。因为大部分情况下处理函数的逻辑比较复杂,所以算起开销比例来这么做的代价也可以接受。例如,3A5000处理器的Linux内核中,所有中断都采用统一的入口处理代码,它的主要工作就是保存所有的通用整数寄存器和异常现场信息,除此之外只有少量指令用于切换中断栈、调用实际中断处理函数等代码。入口处理的指令总共只有几十条,而一个有实际用处的中断处理过程一般至少有数百条指令,其中还包括一些延迟比较长的IO访问。例如,看上去很简单的键盘中断处理,在把输入作为一个事件报告到Linux内核的输入子系统之前,就已经走过了如图4.15所示那么多的函数。

图4.15 键盘输入的中断处理部分路径

except_vec_vi是Linux/LoongArch内核的向量中断入口处理代码,之后它会用USB键盘对应的中断号为参数调用do_IRQ函数,do_IRQ再经过一系列中断框架处理后调用usb的中断处理函数usb_hcd_irq,读入相应的键码,最后用input_event报告给输入子系统,输入子系统再负责把输入事件传递给适当的应用程序。感兴趣的读者可以阅读Linux内核相关代码以更深入地理解这个过程,在此不再展开。

对于发生频率很高的异常或者中断,我们希望它的处理效率尽量高。从异常和中断处理的各个环节都可以设法降低开销。例如,可以通过专用入口或者向量中断技术来降低确定异常来源和切换指令流的开销。此外,不同的指令系统用不同的方法来降低上下文保存恢复的开销。例如TLB管理,上一章中我们介绍了LoongArch中TLB重填的做法:设置专门的异常入口,利用便签寄存器来快速获得可用的通用寄存器,以及提供两个专门的指令(lddir和ldpte)来进一步加速从内存页表装入TLB表项的过程。X86指令系统选择完全用硬件来处理,成功的情况不会发出异常。MIPS指令系统则采用预留两个通用寄存器的办法。TLB重填异常处理只用这两个寄存器,因此没有额外的保存恢复代价(但所有的应用程序都牺牲了两个宝贵的通用寄存器)。

4.2.3 系统调用

系统调用是操作系统内核为用户态程序实现的子程序。系统调用的上下文切换场景和函数调用比较类似,和普通调用相比主要多了特权等级的切换。Linux操作系统中的部分系统调用如表4.4所示。一些系统调用(如gettimeofday系统调用)只返回一些内核知道但用户程序不知道的信息。系统调用要满足安全性和兼容性两方面的要求。安全性方面,在面对错误甚至恶意的应用时,内核应该是健壮的,应能保证自身的安全;兼容性方面,操作系统内核应该能够运行已有的应用程序,这也要求系统调用应该是兼容的,轻易移除一个系统调用是无法接受的。

表4.4 Linux/LoongArch操作系统的部分系统调用

Linux内核中,每个系统调用都被分配了一个整数编号,称为调用号。调用号的定义与具体指令系统相关,X86和MIPS对同一函数的调用号可能不同。Linux/LoongArch系统的调用号定义可以从内核源码include/uapi/asm-generic/unistd.h获得。

因为涉及特权等级的切换,系统调用通常被当作一种用户发起的特殊异常来处理。例如在LoongArch指令系统中,执行SYSCALL指令会触发系统调用异常。异常处理程序通过调用号查表找到内核中相应的实现函数。与所有异常一样,系统调用在返回时使用ERTN指令来同时完成跳转用户地址和返回用户态的操作。

类似于一般的函数调用,系统调用也需要进行参数的传递。应该尽可能使用寄存器进行传递,这可以避免在核心态空间和用户态空间之间进行不必要的内容复制。在LoongArch指令系统中,系统调用的参数传递有以下约定:

1)调用号存放在$a7寄存器中。

2)至多7个参数通过$a0~$a6寄存器进行传递。

3)返回值存放在$a0/$a1寄存器。

4)系统调用保存$s0~$s8寄存器的内容,不保证保持参数寄存器和暂存寄存器的内容。

为了保障安全性,内核必须对用户程序传入的数组索引、指针和缓冲区长度等可能带来安全风险的参数进行检查。从用户空间复制数据时,应用程序提供的指针可能是无效的,直接在内核使用可能导致内核崩溃。因此,Linux内核使用专用函数copy_to_user()和copy_from_user()来完成与用户空间相关的复制操作。它们为相应的访存操作提供了专门的异常处理代码,避免内核因为用户传入的非法值而发生崩溃。

图4.16展示了一个汇编语言编写的write系统调用的例子。用gcc编译运行,它会在屏幕上输出“Hello World!”字符串。当然,通常情况下应用程序不用这样使用系统调用,系统函数库会提供包装好的系统调用函数以及更高层的功能接口。比如,glibc库函数write包装了write系统调用,C程序直接用write(1,“Hello World!\\n”,14)或者用更高层的功能函数printf(“Hello World\\n”)就可以实现同样的功能。

图4.16 调用write系统调用输出字符串

4.2.4 进程

为了支持多道程序并发执行,操作系统引入了进程的概念。进程是程序在特定数据集合上的执行实例,一般由程序、数据集合和进程控制块三部分组成。进程控制块包括很多信息,它记录每个进程运行过程中虚拟内存地址、打开文件、锁和信号等资源的情况。操作系统通过分时复用、虚拟内存等技术让每个进程都觉得自己拥有一个独立的CPU和独立的内存地址空间。切换进程时需要切换进程上下文。进程上下文包括进程控制块记录的各种信息。

进程的上下文切换主要由软件来完成。发生切换的时机主要有两种,一是进程主动调用某些系统调用时因出现无法继续运行的情况(如等待IO完成或者获得锁)而触发切换,二是进程分配到的时间片用完了或者有更高优先级的就绪进程要抢占CPU导致的切换。切换工作的实质是实现对CPU硬件资源的分时复用。操作系统把当前进程的运行上下文信息保存到内存中,再把选中的下一个进程的上下文信息装载到CPU中。特定时刻只能由一个进程使用的处理器状态信息,包括通用寄存器、eflags等用户态的专有寄存器以及当前程序计数器(PC)、处理器模式和状态、页表基址(例如X86指令系统的CR3寄存器和LoongArch的PGD寄存器)等控制信息,都需要被保存起来,以便下次运行时恢复到同样的状态。如果一些不支持共享的硬件状态信息在内存里有最新备份,切换时可以采用直接丢弃的方法。例如,有些指令系统的TLB不能区分不同进程的页表项(早期的X86指令系统就是如此),那么在进程切换时需要把已有的表项设为无效,避免被新的进程错误使用。而可以共享的硬件状态信息(如Cache等),以及用内存保存的上下文信息(如页表等),则不需要处理。由于篇幅限制,这里不展开讨论具体的进程切换细节,感兴趣的读者可以通过阅读Linux内核源代码或者相关操作系统书籍来进一步了解。

不同的硬件支持可能导致不同的效率。TLB是否可以区分来自不同进程的页表项就是一个例子。不能区分时,每次切换进程的时候必须使所有的硬件TLB表项无效,每次进程开始运行时都需要重新从内存获取页表项。而LoongArch等指令系统的TLB则支持用某种进程标记(LoongArch中是ASID)来区分不同进程的页表项,可以避免这种开销。随着指令系统的发展,需要切换的信息也在增加,引发了一些新的硬件支持需求。例如,除了常规的整数和浮点通用寄存器,很多现代处理器增加了数十个位宽很大(X86 AVX扩展可达512位)的向量寄存器。由于无条件保存所有寄存器的代价比较大,操作系统常常会采用某种按需保存的优化,比如不为没有用到向量的进程保存向量状态。但这需要指令系统提供一定的支持。在MIPS和LoongArch指令系统中,浮点和向量部件都可以通过控制寄存器来关闭,在关闭部件后使用相关指令会触发异常,这样操作系统就能有效地实现按需加载。

历史上也有些指令系统曾尝试为进程切换提供更多硬件支持。例如,X86指令系统提供了专门的TS(Task State)段和硬件自动保存进程上下文的机制,适当设置之后进程切换可以由硬件完成。但由于硬件机制不够灵活而且性能收益不明显,包括Linux和Windows在内的多数操作系统都没有使用这个机制。

4.2.5 线程

线程是程序代码的一个执行路径。一个进程可以包含多个线程,这些线程之间共享内存空间和打开文件等资源,但逻辑上拥有独立的寄存器状态和栈。现代系统的线程一般也支持线程私有存储区(Thread Local Storage,简称TLS)。例如,GCC编译器支持用__thread int number;这样的语句来定义一个线程私有的全局变量,不同线程看到的number地址是不一样的。

线程可以由操作系统内核管理,也可以由用户态的线程库管理,或者两者混合。线程的实现方式对切换开销有很大的影响。例如,Linux系统中最常用的线程库NPTL(Native POSIX Thread Library)采用内核和用户1:1的线程模型,每个用户级线程对应一个内核线程。除了不切换地址空间,线程的切换和进程的大部分流程一致,都需要进入和退出核心态,经历至少两次用户态和核心态上下文的切换。因此,对一些简单测试来说,Linux中进程和线程切换的速度差异可能不太明显。而Go语言提供的goroutines可以被看作一种用户级实现的轻量级线程,它的切换不需要通过内核,一些测试表明,其切换开销可比NPTL小一半以上。当然,进程和线程切换不仅仅有执行切换代码的直接开销,还有因为TLB、Cache等资源竞争导致的间接开销,在数据集比较大的时候,进程和线程的实际切换代价差异也可能较大。

同样,适当的硬件支持也有助于提升线程切换效率。例如,LoongArch的ABI将一个通用寄存器用作专门的$tp寄存器,用来高效访问TLS空间。切换线程时只需要将$tp指向新线程的TLS,访问TLS的变量时用$tp和相应的偏移就能实现访问每个线程一份的变量。相比之下,Linux/MIPS系统则依赖系统调用set_thread_area来设置当前线程的TLS指针,将它保存到内核的线程数据结构中;用户程序用rdhwr指令来读取当前的线程指针,这个指令会产生一个异常来陷入内核读取TLS指针。相比之下,这样的实现效率会低很多。

4.2.6 虚拟机

线程把一份CPU计算资源虚拟成多份独立的CPU计算资源,进程把CPU和物理内存的组合虚拟成多份独立的虚拟CPU和虚拟内存组合。更进一步,我们可以把一台物理计算机虚拟成多台含CPU、内存和各种外设的虚拟计算机。虚拟机可以更好地隔离不同的服务运行环境,更充分地利用越来越丰富的物理机资源,更方便地迁移和管理,因此得到了广泛的应用,成为云计算的基础技术。

虚拟机的运行上下文包括CPU、内存和外设的状态。在虚拟机内部会发生函数调用、中断和异常、线程和进程等各种内部的上下文切换,它们的处理和物理机的相应场景类似。但在虚拟机无法独立处理的情况下会退出虚拟机运行状态,借助宿主机的虚拟化管理软件来完成任务。虚拟机和宿主机之间的切换需要保存和恢复所有可能被修改的虚拟机相关状态信息。例如对于CPU的状态信息,之前几种场景需要保存恢复的主要是用户可访问的寄存器,而虚拟机切换时可能还需要保存各种特权态资源,包括众多控制寄存器。如果系统支持在一台物理计算机上虚拟化出多个虚拟机,在物理资源少于虚拟机个数的时候,只能通过保存和恢复相关资源来维持每个虚拟机都独占资源的效果。

虚拟机可以完全由软件实现。例如,开源的QEMU虚拟机软件能够虚拟出各种架构的CPU和众多设备,如在一台龙芯电脑上虚拟出一台X86 PC设备并运行Windows操作系统。在宿主机指令系统和被模拟的客户机指令系统不同时,QEMU采用二进制翻译技术把客户机应用动态翻译成等价功能的宿主机指令。不过,这种情况下QEMU虚拟的客户机运行速度比较低,一般不到宿主机的10%。

在客户机和宿主机指令系统相同时,已经有一些成熟的技术可通过适当的硬件支持来大大提升虚拟化效率。龙芯和大部分现代的高性能处理器都支持虚拟机扩展,在处理运行模式、系统态资源、内存虚拟化和IO虚拟化等方面提供硬件支持,使得虚拟机可以实现和物理机相似的性能。例如,关于处理器运行模式,LoongArch引入一个客户机模式(Guest Mode)和一个主机模式(Host Mode)以区分当前CPU是在运行客户机还是宿主机。这两个模式和特权等级模式PV0~3是正交的,也就是说客户机模式和主机模式下都有PV0~3四个特权等级。关于系统态资源,如果只有一套,那么在客户机和主机模式之间切换时就得通过保存恢复这些资源来复用。为了提高效率,硬件上可以复制相关资源,让客户机模式和主机模式使用专属的特权态资源(如控制寄存器)。在内存虚拟化方面,通过硬件支持的两级地址翻译技术可以有效地提升客户操作系统的地址翻译效率。可将支持二级地址翻译的硬件看作有两个TLB,一个保存客户机模式下的虚实地址映射关系,另一个保存主机模式下的虚实地址映射关系。客户机模式下,一个客户机虚拟地址首先通过前一个TLB查出客户机物理地址(它是由主机模式的虚拟内存模拟的,实际上是主机模式的虚拟地址),然后CPU会自动用后一个TLB进行下一级的地址翻译并找出真正的主机物理地址。在IO虚拟化方面,通过IOMMU(Input-Output Memory Management Unit) 普通MMU为CPU提供物理内存的虚拟化,IOMMU则为外设提供物理内存的虚拟化,让外设访问内存时可通过虚实地址转换。、支持虚拟化的中断分派等硬件可以有效提升虚拟化效率。适当的硬件支持有助于降低上下文切换需要保存恢复的内容,有助于在客户机模式的程序和真实硬件之间建立直接通道,从而提升虚拟化性能。

[1] 普通MMU为CPU提供物理内存的虚拟化,IOMMU则为外设提供物理内存的虚拟化,让外设访问内存时可通过虚实地址转换。

4.2.7 六种上下文切换场景的对比

表4.5对以上六种上下文切换的场景进行了对比总结。函数调用和系统调用是用户主动发起的,因此可以通过ABI约定来避免不必要的保存恢复。其他几种场景通常都要达到对应用程序透明的效果,因此切换后可能被修改的状态都应该被保存和恢复。

表4.5 六种上下文切换场景