操作系统:用户态和内核态&中断

因为操作系统的资源是有限的,如果访问资源的操作过多,必然会消耗过多的资源,如果不对这些操作加以区分,很可能造成资源访问的冲突。为了减少资源的访问和使用冲突,操作系统对不同的操作赋予不同的执行等级,就是所谓的特权的概念。简单来说就是有多大能力做多大的事,与系统相关的一些特别关键的操作必须由最高特权的程序来完成。Intel x86架构的CPU提供了0~3四个等级,数字越小,权限越高;Linux操作系统主要采用0和3两个权限等级,分别对应内核态用户态

1. Unix/Linux的体系架构

Linux体系架构

从宏观上看,Linux操作系统的体系架构分为用户态和内核态(或者用户空间和内核空间)。内核从本质上看是一种控制计算机硬件资源、提供上层应用程序运行环境的软件。用户态即上层应用程序的活动空间,应用程序的执行必须依托于内核提供的资源,包括CPU资源、存储资源、I/O资源等。为了使上层应用能够访问到这些资源,内核必须为上层应用提供访问的接口:系统调用。

系统调用是操作系统的最小功能单位,这些系统调用根据不同的应用场景可以进行裁剪和扩展,现在各种版本的Unix实现都提供了不同数量的系统调用,如Linux的不同版本提供了240-260个系统调用,FreeBSD大约提供了320个(reference:UNIX环境高级编程)。我们可以把系统调用看成一种不能再化简的操作(类似于原子操作,但概念不同),我们可以将系统调用看做是汉字的一个笔画,而一个汉字就代表着一个上层应用。有时候,我们要实现一个上层应用,需要调用很多的系统调用,如果从实现者(程序员)的角度来看,这势必会加重程序员的负担,良好的程序设计方法是:注重上层业务逻辑操作,尽可能屏蔽底层复杂的实现细节。「库函数」正是为了将程序员从复杂的底层实现细节中解脱出来而提出的一种有效方法。它实现对系统调用的封装,将简单的业务逻辑接口呈现给用户,方便用户调用,从这个角度看,库函数像是组成汉字的“偏旁”。这样一种组成方式极大地增强了程序设计的灵活性,对于简单的操作,我们可以直接调用系统调用来访问资源,而对于复杂操作,我们借助于库函数来实现。显然库函数依据不同的标准可以有不同的实现版本,如ISO C标准库、POSIX标准库等。

Shell是一种特殊的应用程序,俗称命令行,本质上是一个命令解释器,它下通系统调用,上通各种应用,通常充当胶水的角色,来连接各个小功能程序,让不同程序能够以一种清晰的接口系统工作,从而增强各个程序的功能。同时,Shell是可编程的,它可以执行符合Shell语法的文本,这样的文本成为Shell脚本,通常短短的几行Shell脚本就可以实现一个非常强大的功能,原因是这些Shell语句通常对系统调用做了一层封装。为了方便用户与系统交互,一般来说,一个Shell对应着一个终端(终端是一个硬件设备,呈现给用户的是一个图形化窗口)。我们可以通过这个窗口输入或输出文本,这个文本直接传递给Shell进行分析解释,然后执行。

2. 用户态和内核态

2.1 用户态和内核态是什么?

在介绍用户态和内核态之前,我们先来了解下CPU指令集。指令集是CPU实现软件控制硬件的媒介,具体来说每条汇编语言都对应一条CPU指令,而很多条CPU指令在一起组成的集合就是CPU指令集。由于CPU指令集是可以直接操作硬件的,为避免指令操作不规范造成计算机崩溃,CPU指令集是具有权限分级的。硬件设备商为CPU指令集设置权限提供了硬件级别的支持,不同CPU指令集对硬件的操作权限是不同的,以Intel CPU为例,Intel把CPU指令集操作的权限由高到低分为4级:

  • ring 0
  • ring 1
  • ring 2
  • ring 3

其中,ring 0权限最高,可以使用所有的CPU指令集ring 3权限最低,仅能使用常规的CPU指令集,不能使用操作硬件资源的CPU指令集,比如I/O读写、网卡访问、内存申请都不行,Linux系统仅采用ring 0ring 3两个权限等级。

  • 内核态:执行内核空间的代码,具有ring 0保护级别,有对硬件的所有操作权限,可以执行所有CPU指令集,访问任意地址的内存,在内核模式下的任何异常都是灾难性的,将会导致整台机器停机。
  • 用户态:用户模式下,具有ring 3保护级别,代码没有对硬件的直接控制权限,也不能直接访问地址的内存,程序通过系统调用接口(System Call APIs)来达到操作硬件和访问内存的目的,在这种保护模式下,即使程序发生崩溃也是可以恢复的,电脑上的大部分程序都是在用户模式下运行的。

2.2 用户态和内核态的切换

很多程序开始时都是运行在用户态的,但在执行过程中,一些操作需要内核权限下才能执行,这就涉及到用户态切换到内核态的过程。例如C函数库中的内存分配函数malloc(),它具体是使用sbrk()来分配内存的,当malloc()调用sbrk()的时候就涉及到一次从用户态到内核态的切换,类似的函数还有printf(),调用的是write()系统调用来输出字符串,等等。

从用户态到内核态的切换需要哪些开销呢?主要如下:

  • 保存用户态现场(寄存器、上下文、用户栈等)
  • 复制用户参数,用户栈切到内核栈,进入内核态
  • 额外检查(因为内核代码对用户不信任)
  • 执行内核代码
  • 复制结果到用户态,切换回用户态
  • 恢复用户态现场(寄存器、上下文、用户栈等)

实际上操作系统进行用户态和内核态的切换过程远比上述复杂,我们大致可以发现一次切换经历了「用户态 -> 内核态 -> 用户态」。

哪些情况会导致用户态到内核态的切换:

  • 系统调用:用户态进程主动切换为内核态的方式,用户态进程通过系统调用向操作系统申请资源完成工作。例如fork()就是创建新进程的系统调用,系统调用机制的核心是使用了操作系统为用户特别开放的一个中断来实现的,如Linux的int 80h中断,也可以称之为软中断
  • 异常:当CPU在执行用户态的进程时,发生了一些没有预知的异常,此时当前进程会切换到处理此异常的内核相关进程中,也就发生了从用户态到内核态的切换,如缺页异常。
  • 外围设备的中断:当CPU在执行用户态的进程时,外围设备完成用户的请求后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的命令,转到中断信号对应的处理程序去执行,也就是切换到了内核态。如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后边的操作等。这种外围设备的中断也可以称之为硬中断

注意:从触发方式和效果上看,这三种切换方式是完全一样的,都相当于执行了一个中断响应的过程。但是从触发的对象来看,系统调用是进程主动请求切换的,而异常和硬中断则是被动的。

3. 中断

在计算机中,中断是操作系统响应硬件设备请求的一种机制,操作系统收到硬件的中断请求,会打断正在执行的进程,然后调用内核的中断处理程序来响应请求。中断是一种异步的事件处理机制,可以提供系统的发处理能力。

从本质上来讲,中断是一种电信号,当设备有某种事件发生时,就会产生中断,通过总线把电信号发送给中断控制器。如果中断的线是激活的,中断控制器就把电信号发送给处理器的某个特定引脚,于是处理器立马停止正在做的事情,跳转到中断处理程序的入口,进行中断处理。

3.1 硬中断

  1. 由与系统相连的外设(比如网卡、硬盘)自动产生的。主要是用来通知操作系统系统外设状态的变化。比如当网卡收到数据包的时候,就会发出一个中断。我们通常所说的中断指的是硬中断(hardirq)。
  2. 硬中断是外部设备对CPU的中断。
  3. 硬中断是由硬件产生的,比如,像磁盘,网卡,键盘,时钟等。每个设备或设备集都有它自己的IRQ(中断请求)。基于IRQ,CPU可以将相应的请求分发到对应的硬件驱动上(注:硬件驱动通常是内核中的一个子程序,而不是一个独立的进程)。
  4. 处理中断的驱动是需要运行在CPU上的,因此,当中断产生的时候,CPU会中断当前正在运行的任务,来处理中断。在有多核心的系统上,一个中断通常只能中断一颗CPU(也有一种特殊的情况,就是在大型主机上是有硬件通道的,它可以在没有主CPU的支持下,可以同时处理多个中断)。
  5. 硬中断可以直接中断CPU。它会引起内核中相关的代码被触发。对于那些需要花费一些时间去处理的进程,中断代码本身也可以被其他的硬中断中断。
  6. 对于时钟中断,内核调度代码会将当前正在运行的进程挂起,从而让其他的进程来运行。它的存在是为了让调度代码(或称为调度器)可以调度多任务。

3.2 软中断

中断请求的处理程序应该要短且快,这样才能减少对正常进程运行调度地影响,而且中断处理程序可能会暂时关闭中断,这时如果中断处理程序执行时间过长,可能在还未执行完中断处理程序前,会丢失当前其他设备的中断请求。 Linux 系统为了解决中断处理程序执行过长和中断丢失的问题,将中断过程分成了两个阶段,分别是「上半部和下半部分」

  • 上半部用来快速处理中断,一般会暂时关闭中断请求,主要负责处理跟硬件紧密相关或者时间敏感的事情。
  • 下半部用来延迟处理上半部未完成的工作,一般以「内核线程」的方式运行。

以网卡接收网络包的事件为例 ,网卡收到网络包后,会通过硬件中断通知内核有新的数据到了,于是内核就会调用对应的中断处理程序来响应该事件,这个事件的处理也是会分成上半部和下半部。

上部分要做到快速处理,所以只要把网卡的数据读到内存中,然后更新一下硬件寄存器的状态,比如把状态更新为表示数据已经读到内存中的状态值。接着,内核会触发一个软中断,把一些处理比较耗时且复杂的事情,交给「软中断处理程序」去做,也就是中断的下半部,其主要是需要从内存中找到网络数据,再按照网络协议栈,对网络数据进行逐层解析和处理,最后把数据送给应用程序。

所以,中断处理程序的上部分和下半部可以理解为:

  • 上半部直接处理硬件请求,也就是硬中断,主要是负责耗时短的工作,特点是快速执行;
  • 下半部是由内核触发,也就说软中断,主要是负责上半部未完成的工作,通常都是耗时比较长的事情,特点是延迟执行;

还有一个区别,硬中断(上半部)是会打断 CPU 正在执行的任务,然后立即执行中断处理程序,而软中断(下半部)是以内核线程的方式执行,并且每一个 CPU 都对应一个软中断内核线程,名字通常为「ksoftirqd/CPU 编号」,比如 0 号 CPU 对应的软中断内核线程的名字是 ksoftirqd/0

不过,软中断不只是包括硬件设备中断处理程序的下半部,一些内核自定义事件也属于软中断,比如内核调度等、RCU 锁(内核里常用的一种锁)等。

Linux 中的软中断包括网络收发、定时、调度、RCU 锁等各种类型,可以通过查看 /proc/softirqs 来观察软中断的累计中断次数情况,如果要实时查看中断次数的变化率,可以使用 watch -d cat /proc/softirqs 命令。

查看软中断:

3.3 硬中断和软中断的区别

  1. 硬中断是由外部事件引起的因此具有随机性和突发性;软中断是执行中断指令产生的,无面外部施加中断请求信号,因此中断的发生不是随机的而是由程序安排好的。
  2. 硬中断的中断响应周期,CPU需要发中断回合信号(NMI不需要);软中断的中断响应周期,CPU不需发中断回合信号。
  3. 硬中断的中断号是由中断控制器提供的(NMI硬中断中断号系统指定为02H);软中断的中断号由指令直接给出,无需使用中断控制器。
  4. 硬中断是可屏蔽的(NMI硬中断不可屏蔽);软中断不可屏蔽。