用户态与内核态

一、CPU 指令集权限

1.1 了解 CPU 指令集

  • 指令集是 CPU 实现软件指挥硬件执行工作的媒介,具体来说每一条汇编语句都对应了一条 CPU 指令
  • 多个 CPU 指令集合在一起,组成了一个、甚至多个的集合,每个指令的集合叫CPU 指令集】。随着时间的推移,CPU 指令越来越多,从最早的几十条,到现在的几千条。
  • Inter、AMD CPU 都是复杂指令集 CPU,支持的 CPU 指令多达几千个,每一个指令都指代一种数学运算,比如加减乘除4种数学计算在 CPU 中就是 4条 CPU 指令,以此类推。
  • CPU 指令集发展到现在有很多,典型的 Intel CPU 支持:EM64T, MMX, SSE, SSE2 ,SSE3,SSE4A, SSE4.1, SSE4.2, AVX, AVX2, AVX-512, VMX 等指令集。这些指令集中,每个 CPU 指令都有唯一的、不重复的指令编号,CPU 硬件中的 控制单元 可以解析、识别程序想要执行的 CPU 指令的编号,然后指挥相关硬件执行相关操作,所以 汇编语言 被视为是最底层的编程语言。
  • 一般认为:CPU 硬件直接支持的数学计算公式执行效率最高。就是因为 CPU 指令集中包含这个数学公式,数学计算只需要一条指令就可以执行完毕。反之就需要通过多条 CPU 指令以组合的方式完成数学计算,需要执行多条 CPU 指令,在空间和时间复杂度上都会复杂 N 倍。所以请大家理解 CPU 执行效率高低的来源,当然指令集只是制约 CPU 执行效率高低的其中一个因素,但是该因素绝对是重量级的、起决定性的因素

1.2 CPU 指令权限分级

CPU 指令也是有权限分级的 --> 大家试想,CPU 指令是可以直接操作硬件的,要是因为指令操作的不规范,造成的错误将会影响整个计算机系统,操作系统内核、及其其他所有正在运行的程序,都会因为你操作失误而受到不可挽回的错误,那么你只有重启整个计算机才行。

而对于 硬件的操作 是非常复杂的,参数众多,出问题的几率相当大,必须极其谨慎的进行操作,个人开发者在这方面也是不被信任的。所以 操作系统内核 直接屏蔽了个人开发者对于硬件操作的可能,这方面 系统内核 对 硬件操作 进行了封装处理,对外提供标准函数库,操作更简单、更安全。比如 我们要打开一个文件,C标准函数库中对应的是fopen(),其内部封装的是内核中的系统函数 open() 。

以 Inter CPU 为例,Inter 把 CPU 指令操作的权限划为4级:ring 0 、ring 1 、ring 2 、ring 3 。其中 ring 0 权限最高,可以使用所有 CPU 指令,ring 3 权限最低,仅能使用常规 CPU 指令,这个级别的权限不能使用访问硬件资源的指令,比如 IO 读写、网卡访问、申请内存都不行,都没有权限。

Linux 系统内核采用了 ring 0 和 ring 3 这2个权限:

  • ring 0 被叫做 内核态,完全在 操作系统内核 中运行,由专门的 内核线程 在 CPU 中执行其任务 ;
  • ring 3 被叫做 用户态,在 应用程序 中运行,由 用户线程 在 CPU 中执行其任务 。

二、用户态与内核态

2.1 虚拟地址空间(进程私有)

2.1.1 虚拟地址空间的概念及引入目的

在多任务操作系统中,每个进程都运行在属于自己的虚拟内存中,这块空间被称为 Virtual Address Space(虚拟地址空间)。

虚拟地址空间是进程私有的。在操作系统中,每个进程都有自己的虚拟地址空间,这个地址空间对于其他进程是不可见的。这意味着一个进程的内存操作不会影响到其他进程的内存空间,从而确保了进程之间的内存隔离

虚拟地址空间为每个进程提供了一种抽象的内存模型,进程看到的内存是一个连续的地址空间(虚拟地址空间),而实际上这个地址空间可能被映射到物理内存的不同部分,或者部分存储在磁盘上的交换空间中。操作系统通过页表(page,4K)或其他机制管理这种映射,以实现虚拟内存。

这种机制不仅提供了内存保护,防止一个进程访问或修改另一个进程的数据,还允许操作系统更有效地管理物理内存,提高资源的利用率和系统的稳定性。

内存分页:为了节约内存,提高使用效率,操作系统会将内存拆成一个个的小块来使用,在 Linux 中,这每一小块叫做 page(页) ,大小一般为4k。

如上图,两块虚拟内存通过page table(页表) 将自己映射到物理内存上,进程只能看到虚拟内存(当然它自己是不知道内存是虚拟的),进程只能"运行"在虚拟地址空间,只会操作属于自己的虚拟内存,因此进程之间不会相互影响。

2.1.2 虚拟地址空间的大小及分配

操作系统需要为每一个进程分配属于自己的虚拟内存,那这个虚拟内存要分配多大呢?

在没有虚拟地址空间之前,是根据进程的需要按需分配物理内存的。但有了虚拟地址空间,分配策略可以变一下,先把虚拟地址空间分配的大些,但不马上建立与物理内存的映射,而是用到的时候,用多少建立多少

这样物理内存的大小虽然不变,但是内存分配的灵活性大大的提高了,进程也不用担心地址会跟别的进程冲突。

在 32 位操作系统中,操作系统会为每个进程分配最大为 4G(2 的 32 次方)的虚拟地址空间。

2.2 用户空间与内核空间

操作系统虽然为每个进程都分配了虚拟地址空间,但 虚拟地址空间中并不是所有的区域都可以为进程所用

操作系统将虚拟地址空间 分为用户空间内核空间,对于 32 位的操作系统,它的寻址空间(虚拟地址空间,或叫线性地址空间)为 4G(2的32次方)。也就是说一个进程的最大地址空间为 4G。在 Linux 的虚拟地址空间中,用户空间和内核空间的大小比例为 3:1,而在 window 中则为 2:2。

  • 用户空间 :0~3G(从虚拟地址 0x00000000 到 0xBFFFFFFF),由各个进程使用。
  • 内核空间 :3~4G(从虚拟地址 0xC0000000 到 0xFFFFFFFF),由内核使用,所有进程共享。

接下来我们从内核空间和用户空间的角度看一看整个 Linux 系统的结构。它大体可以分为三个部分,从下往上依次为:硬件 -> 内核空间 -> 用户空间。如下图所示:

2.2.1 为什么会有内核空间

为了系统的安全,现代的操作系统一般都强制用户进程不能直接操作内核的,所有的系统调用都要交给内核完成。

但是内核也要运行在内存中,为了防止用户进程干扰,操作系统为内核单独划分了一块内存区域,这块区域就是内核空间,内核运行在内核空间中。

2.2.2 内核空间与用户空间的映射

在 Linux 中,系统启动时,就需要将内核加载到物理内存的内核空间上运行。

但对于进程,物理内存对它是不可见的,但它又需要使用内核来完成各种系统调用,而内核实际又在物理内存上。怎么解决这个矛盾?

Linux 想了一个办法,将进程的虚拟地址空间中的内核空间映射到物理内存中的内核空间上,内核就“搬到”虚拟内存中了。而在进程看了,自己的内存中就有了内核了,就可以通过内核进行各种系统调用了

在 Linux 中,内核空间是持续的,并且所有进程的虚拟地址空间中的内核空间都映射到同样的物理内存的内核空间

如上图 进程a 和 进程b 的内核空间都映射到了同一块物理内存区域,而用户空间的地址,则被映射到了不同的物理内存区域。

2.3 4G虚拟地址空间解析

上图展示了整个进程4G虚拟地址空间的分布,分为两部分。在用户空间内,对应了内存分布的五个段:数据段、代码段、BSS段、堆、栈。

2.3.1 虚拟地址空间分配及其与物理内存对应图

这个图示内核用户空间的划分,图中最重要的就是高端内存的映射。其中 kmallocvmalloc 函数 申请的空间对应着不同的区域,同时有不同的含义。

伙伴算法

一种物理内存分配和回收的方法,物理内存所有空闲页都记录在BUDDY链表中。首选,系统建立一个链表,链表中的每个元素代表一类大小的物理内存,分别为2的0次方、1次方、2次方,个页大小,对应4K、8K、16K的内存,每一类大小的内存又有一个链表,表示目前可以分配的物理内存。例如现在仅存需要分配8K的物理内存,系统首先从8K那个链表中查询有无可分配的内存,若有直接分配;否则查找16K大小的链表,若有,首先将16K一分为二,将其中一个分配给进程,另一个插入8K的链表中,若无,继续查找32K,若有,首先把32K一分为二,其中一个16K大小的内存插入16K链表中,然后另一个16K继续一分为二,将其中一个插入8K的链表中,另一个分配给进程…以此类推。当内存释放时,查看相邻内存有无空闲,若存在两个联系的8K的空闲内存,直接合并成一个16K的内存,插入16K链表中。(伙伴算法用于物理内存分配方案)

SLAB算法

是一种对伙伴算的一种补充,对于用户进程的内存分配,伙伴算法已经够好了,但对于内核进程,还需要存在一类很小的数据(字节大小,比如进程描述符、虚拟内存描述符等),若每次给几个字节的数据分配一个4KB的页,实在太浪费,于是就有了SLBA算法,SLAB算法其实就是把一个页用力劈成一小块一小块,然后再分配。

2.3.2 物理内存分配图

2.4 用户态与内核态

2.4.1 用户态、内核态是什么

  • 当进程运行在内核空间时就处于内核态,而进程运行在用户空间时则处于用户态。
  • 在内核态(ring 0)下,进程运行在内核地址空间中,此时 CPU 可以执行任何指令。运行的代码也不受任何的限制,可以自由地访问任何有效地址,也可以直接进行端口的访问。
  • 在用户态(ring 3)下,进程运行在用户地址空间中,被执行的代码要受到 CPU 的诸多检查,它们只能访问映射其地址空间的页表项中规定的在用户态下可访问页面的虚拟地址,且只能对任务状态段(TSS)中 I/O 许可位图(I/O Permission Bitmap)中规定的可访问端口进行直接访问。

对于以前的 DOS 操作系统来说,是没有内核空间、用户空间以及内核态、用户态这些概念的。可以认为所有的代码都是运行在内核态的,因而用户编写的应用程序代码可以很容易的让操作系统崩溃掉。

对于 Linux 来说,通过区分内核空间和用户空间的设计,隔离了操作系统代码(操作系统的代码要比应用程序的代码健壮很多)与应用程序代码。即便是单个应用程序出现错误也不会影响到操作系统的稳定性,这样其它的程序还可以正常的运行(Linux 可是个多任务系统啊!)。

所以,区分内核空间和用户空间本质上是要提高操作系统的稳定性及可用性

用户运行一个程序,该程序创建的进程开始时运行自己的代码,处于 Ring3 用户态。如果要执行文件操作、网络数据发送等操作必须通过write、send等系统调用,这些系统调用会调用内核的代码,进程会切换到 Ring0 内核态,然后进入3G-4G中的内核地址空间去执行内核代码来完成相应的操作。内核态的进程执行完后又会切换回到Ring3 用户态。这样,用户态的程序就不能随意操作内核地址空间,具有一定的安全保护作用。

2.4.2 用户态、内核态在资源上的区别

CPU 指令权限的高低,一样会反映在对资源的操作上,区别就是高 CPU 指令权限可以操作更多、更核心的资源,典型的就是内存和硬件资源。

虽然从概念上说:用户态、内核态 只是 CPU 指令权限的区别,但是在 程序、系统内核设计上,必然会有相对应的运行机制来支持的,这体现在2个显著的区别上:

  1. 用户态的代码必须由 用户线程 去执行、内核态的代码必须由 内核线程 去执行 ;
  2. 用户态、内核态 或者说 用户线程、内核线程 可以使用的资源是不同的,尤体现在内存资源上。Linux 内核对每一个进程都会分配 4G 虚拟内存空间地址 :
    1. 用户态: --> 只能操作 0-3G 的内存地址 ;
    2. 内核态: --> 0-4G 的内存地址都可以操作,尤其是对 3-4G 的高位地址必须由内核态去操作,因为所有进程的 3-4G 的高位地址使用的都是同一块、专门留给 系统内核 使用的 1G 物理内存。
  3. 所有对 硬件资源、系统内核数据 的访问都必须由内核态去执行

2.4.3 用户态到内核态的切换

当在系统中执行一个程序时,大部分时间是运行在用户态下的,在其需要操作系统帮助完成一些用户态自己没有特权和能力完成的操作时就会切换到内核态。这种切换其内在表现形式是:代码会从应用程序所在的 用户线程 切换到 内核中的 内核线程 去执行

2.5.3.1 用户态切换到内核态的3种方式

这三种方式是系统在运行时由用户态切换到内核态的最主要方式,其中系统调用可以认为是用户进程主动发起的,异常和外围设备中断则是被动的。从触发方式上看,切换方式都不一样,但从最终实际完成由用户态到内核态的切换操作来看,步骤都是一样的,都相当于执行了一个中断响应的过程系统调用实际上最终是中断机制实现的,而异常和中断的处理机制基本一致。

1. 系统调用

这是用户态进程主动要求切换到内核态的一种方式。用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作。例如 fork() 就是执行了一个创建新进程的系统调用。系统调用的机制和新是使用了操作系统为用户特别开放的一个中断来实现,如Linux的int 80h中断。

下面的操作都需要系统调用到 内核态 中去执行:

  • 进程操作、获取进程信息
  • 文件操作
  • 硬件设备操作
  • 系统内核信息查询、硬件信息查询
  • 通信,进程通信、malloc 申请内存,pipe 开辟管道都是

man syscalls 指令可以看到所有的 Linux 系统内核调用方法

2. 异常

当cpu在执行运行在用户态下的程序时,发生了一些没有预知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关进程中,也就是切换到了内核态,如缺页异常。

3. 外围设备的中断

当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令而转到与中断信号对应的处理程序去执行,如果前面执行的指令时用户态下的程序,那么转换的过程自然就会是 由用户态到内核态的切换。如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后边的操作等。

三、用户线程与内核线程

3.1 什么是进程

进程:系统调度分配资源的最小或基本单位

  • 资源分配:操作系统通过进程来管理内存、CPU、I/O等资源。
  • 任务调度:操作系统根据进程的状态和优先级,决定哪个进程应该获得CPU时间。

系统内核中 进程 就是一段记录专有资源和状态的 task_struct 结构体,就是一个数据结构或者理解为一个存储资源信息的对象。其存储的信息主要包括:

  • 标识符:与进程相关的唯一标识符。
  • 状态:描述进程的状态(新建、就绪、运行、阻塞、终止、睡眠、挂起、僵尸、等待)。
  • 优先级:多个进程执行的先后顺序。
  • 程序计数器:与进程页表相关的计数器。
  • 内存指针:程序代码和进程相关数据的指针。
  • 上下文数据:进程执行时处理器的寄存器中的数据。
  • I/O状态信息:包括显示的I/O请求,分配给进程的I/O设备和进程使用的文件列表等。
  • 记账信息:包括处理器的时间总和,记账进程号等。

这个 task_struct 结构体有个专门的名字:PCB --> PROCESS control block,也叫进程控制块

PCB 数据保存在 进程4G内存虚拟地址中的内核态中,也就是 3-4G 内存这一段内,显然用户态时是无法访问的,想要访问就必须从用户态切换到内核态。

进程在内核中就是这么个东西,就是一个叫 PCB 的 task_struct 结构体,系统内核中有一个 PCB TABLE 用来存储、调度进程

3.2 什么是线程

线程:进程的执行单元,是CPU调度和分派的基本单位(竞争 CPU 资源的基本单位)

线程在内核中同 进程 一样,也是一个 task_struct 结构体,线程在 new 出来之后就是把 进程的 PCB 复制一份给自己,然后加上 PCB 没有的 PC程序计数器,SP堆栈,State状态,寄存器 这些信息。线程的 task_struct 结构体 叫做:TCB --> thread control block

TCB 数据保存在 进程4G内存虚拟地址中的用户态中,也就是 0-3G 内存这一段内。线程本质上就是多了一个任务列表,就是 栈帧 这个东西。

线程之所以能共享堆内存资源呢,就是在新建线程时把进程资源信息 PCB 复制了一份给 自己的 TCB 。

3.3 内核线程

线程分为:内核线程用户线程

内核线程的 TCB 和 进程 的 PCB 保存在一个位置,都是在 3-4G 的内核地址内存中。系统内核中调度的是 内核线程,不是用户线程。

内核线程一样有自己的栈帧,所有 ring0 的代码都得切换到 内核线程 来执行:

3.4 用户线程

系统内核操作、硬件操作、安全权限高的代码必须得在内核中执行,用户不能碰,用户自己的低权限代码在用户自己的线程中执行就好了,这样设计代码分层,扩展性能好,相互不影响。

Linux 中默认采用 1:1 线程模型,就是有一个 用户线程,就得在内核中启动一个对应的 内核线程,然后把这个 用户线程 绑定到 内核线程 上。当然还有其他的对应方式,这就得依靠第三方实现方案了。Linux 内核为对外接口统一设计了 Posix 规范,C 标准函数库 PThread 线程函数库(POSIX线程,一套线程相关的API标准)就是 1:1 线程模型。

需要说一下 JVM 采用 Linux 默认函数库,也就是 PThread 线程函数库 。java new 一个 Thread 时,是创建了 1个用户线程和内核线程的,然后把用户线程绑定到内线线程中,ring3 的代码在 用户线程中执行,ring0 的代码切换到 内核线程 中去执行,然后使用 内核线程 接受 系统内核的调度,内核线程抢到 CPU 时间片后,用户线程就会激活执行代码。

用户线程的 TCB 保存在 进程 0-3G 虚拟地址空间的堆内存中,对于 系统内核 来说是不可见的,所以线程的调度是由 内核线程 来承担、处理

3.5 用户线程和内核线程的切换

用户线程和内核线程的区别就是 TCB 位置的不同执行的代码权限级别不同,然后内核线程会接受操作系统内核调度单元的调度指令

用户线程和内核线程都有自己的栈帧,寄存器值、CPU 加载的缓存 等属于自己线程的数据,用户线程到内核线程的切换会涉及这些 线程上下文 的切换,性能损失就在这里。

一般来说像 IO 操作这些系统调用都是得在 内核线程 中去执行,需要进行频繁的 用户态到内核态再到用户态 的切换,所以会造成大量的性能损失。优化的重点是减少切换,所以 mmap(内存映射) 很重要。mmap 实现了不用借助 系统中断切换到 内核态 执行系统函数,直接往硬盘中读写数据,减少了用户态到内核态的切换,所以性能好,效率高。

mmap 是一种内存映射文件的方法,即将一个文件或者其他对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一映射关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必调用read,write等系统调用函数。相反,内核空间的这段区域的修改也直接反应用户空间,从而可以实现不同进程的文件共享。

通常使用mmap()的三种情况: 提高I/O效率、匿名内存映射、共享内存进程通信。

3.6 JVM 线程

系统内核创建和调度的一定是内核线程,用户程序中使用也必须是用户线程,这点从安全和内核隔离来说是必须的。通用操作系统设计中探讨了 内核线程和用户线程 的捆绑关系,有3种方案:1:N 、N:N 、1:1 。

Linux 内核默认使用了 1:1 模型,但是并不是说不可以更改的。Linux 内核为对外接口统一设计了 POSIX 标准,该标准定义了一套线程相关的API标准接口,即 PThread 线程函数库(POSIX线程),这个标准提供了一组线程相关的函数,用于创建、同步、管理和销毁线程。具体怎么实现看更上层的设计了,语言级别的线程就是在这个基础上设计的(如Java线程)。

JVM线程 采用的就是 Linux 默认函数库即 PThread。也就是通过 PThread 线程函数库 的接口,1:1创建用户线程和内核线程。

  • 21
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值