我们常说的 Linux 严格来说指代的是 Linux Kernel,泛指使用或裁剪标准 Linux Kernel 并在此基础之上实现各种应用程序解决方案的操作系统发行版本(e.g. RHEL、SUSE 和 Ubuntu)。一个完整的 Linux 操作系统体系架构通常由下列几个核心层级组成:
Linux Kernel 本质上看是一种软件,实现了进程管理器、内存管理器、文件系统、设备驱动以及网络管理组件来负责对接、管理计算机硬件平台,并通过系统调用(System Calls)为上层应用程序暴露硬件资源以提供程序运行环境。
以系统调用为边界将 Linux 操作系统的体系架构分为用户态和内核态(包括系统调用)。
操作系统的用户态和内核态实际上对应了 CPU 指令集中的非特权指令和特权指令的执行状态,CPU 划分了不同的执行级别来执行具有相应特权的指令,例如:Intel x86 CPU 具有四种不同的执行级别 [RING0, RING1, RING2, RING3],Linux 操作系统只使用了其中的 RING0 和 RING3 分别表示内核态与用户态。处于 RING3 状态的用户态代码不能直接访问处于 RING0 的内核态代码的地址空间(包括代码和数据)。
我们知道有些 CPU 特权指令的操作实际是比较危险的,比如:写入系统配置文件、杀掉其他用户的进程或重启系统。所以在操作系统的设计中,为了保障操作系统的稳定性,尤其是在多用户环境中的可靠性,操作系统根据 CPU 的指令类型来抽象并实现了用户态和内核态两种代码运行模式,两种运行模式之间的切换也成为模式切换。用户态的代码被限制了可以执行的操作以及可以访问的资源范围,而内核态的代码则可以执行任何操作并且没有资源使用上的限制。
所以,**为什么要划分核心态和用户态?**简单来说:
Linux 进程拥有 4GB 内存地址空间,其中 3-4G 部分是内核态的地址空间,存放了整个内核的代码,所有内核模块以及内核所维护的数据。随便多说一句,这就是所谓的操作系统副本,无论是 SMP 还是 NUMA 实现的都是「单操作系统与数据库系统副本」,而 MPP 海量并行处理体系结构实现的是多操作系统与数据库系统副本,但 MPP 一般只常见于大型机。运行在 RING3 的用户程序代码可以通过系统调用主动访问 RING0 的内核代码来实现从用户态带内核态的切换。当进程陷入内核态时,被执行的内核代码会直接使用进程的内核栈资源。
例如:用户运行一个程序,该程序创建的进程开始运行在用户态,如果程序要执行诸如文件操作,网络数据发送操作等内核态操作的话,就必须通过系统调用中的 Write,Send 等功能单元完成,根本是通过调用内核代码完成的。此时,运行该进程的处理器会从 RING3 切换到 RING0 级别,然后进入 3-4GB 内核地址空间中完成内核代码的执行。执行完成后,处理器再从 RING0 切换回 RING3,进程也回到用户态。
用户态的应用程序可以通过三种方式来访问内核态的资源:
系统调用是操作系统的最小功能单位,具有原子性,这些系统调用根据不同的应用场景可以进行扩展和裁剪,现在各种版本的 Unix 实现都提供了不同数量的系统调用,如 Linux 的不同版本提供了 240-260 个系统调用,FreeBSD 大约提供了 320 个(reference:UNIX 环境高级编程)。
库函数正是为了将程序员从复杂的细节中解脱出来而提出的一种有效方法。它实现对系统调用的封装,将简单的业务逻辑接口呈现给用户,方便用户调用。显然,这样的库函数依据不同的标准也可以有不同的实现版本,如 ISO C 标准库,POSIX 标准库等。
Shell 是一个特殊的应用程序,俗称命令行,本质上是一个命令解释器,它下通系统调用,上通各种应用,通常充当着一种“胶水”的角色,来连接各个小功能程序,让不同程序能够以一个清晰的接口协同工作,从而增强各个程序的功能。
同时,Shell 是可编程的,它可以执行符合 Shell 语法的文本,这样的文本称为 Shell 脚本,通常短短的几行 Shell 脚本就可以实现一个非常大的功能,原因就是这些 Shell 语句通常都对系统调用做了一层封装。为了方便用户和系统交互,一般的,一个 Shell 对应一个终端,终端是一个硬件设备,呈现给用户的是一个图形化窗口。我们可以通过这个窗口输入或者输出文本。这个文本直接传递给 Shell 进行分析解释,然后执行。
因为操作系统的资源是有限的,如果访问资源的操作过多,必然会消耗过多的资源,而且如果不对这些操作加以区分,很可能造成资源访问的冲突。
所以,为了减少有限资源的访问和使用冲突,Unix/Linux 的设计哲学之一就是:对不同的操作赋予不同的执行等级,就是所谓特权的概念。简单说就是有多大能力做多大的事,与系统相关的一些特别关键的操作必须由最高特权的程序来完成。Intel 的 X86 架构的 CPU 提供了 0 到 3 四个特权级,数字越小,特权越高。
Linux 操作系统中主要采用了 0 和 3 两个特权级,分别对应的就是内核态和用户态。运行于用户态的进程可以执行的操作和访问的资源都会受到极大的限制,而运行在内核态的进程则可以执行任何操作并且在资源的使用上没有限制。
很多程序开始时运行于用户态,但在执行的过程中,一些操作需要在内核权限下才能执行,这就涉及到一个从用户态切换到内核态的过程。比如C函数库中的内存分配函数 malloc(),它具体是使用 sbrk() 系统调用来分配内存,当malloc() 调用 sbrk() 的时候就涉及一次从用户态到内核态的切换,类似的函数还有 printf(),调用的是 wirte() 系统调用来输出字符串,等等。
用户程序除了通过系统调用主动触发模式切换之外,还可能会被动的进行。总的来说模式切换有两种触发手段:
(软中断)系统调用:这时用户态进程要传递很多变量或参数值给内核,内核态运行时也要保存用户进程的一些寄存器值和变量等等。所谓的「进程上下文」,可以看作是用户进程传递给内核的这些参数以及内核要保存的那一整套的变量和寄存器值以及运行环境等。
(硬中断)外围设备中断:硬件可以通过触发中断信号令内核调用中断处理程序从而进入内核空间。这个过程中,硬件的一些变量和参数也要传递给内核,内核通过这些参数进行中断处理。所谓的「中断上下文」,就是硬件传递过来的这些参数和内核需要保存的当前被中断执行的进程环境。
由此可见,处理器总处于以下状态中的一种:
发生从用户态到内核态的切换,一般存在以下三种情况:
注意:系统调用的本质其实也是中断,相对于外围设备的硬中断,这种中断称为软中断,这是操作系统为用户特别开放的一种中断,如 Linux int 80h 中断。所以,从触发方式和效果上来看,这三种切换方式是完全一样的,都相当于是执行了一个中断响应的过程。但是从触发的对象来看,系统调用是进程主动请求切换的,而异常和硬中断则是被动的。
内核空间总是驻留在内存中,它是为操作系统的内核保留的。应用程序是不允许直接在该区域进行读写或直接调用内核代码定义的函数的。
上图左侧区域为内核进程对应的虚拟内存,按访问权限可以分为进程私有和进程共享两块区域:
每个普通的用户进程都有一个单独的用户空间,处于用户态的进程不能访问内核空间中的数据,也不能直接调用内核函数的 ,因此要进行系统调用的时候,就要将进程切换到内核态才行。
用户空间包括以下几个内存区域: