为什么要有用户态和内核态

内核态和用户态的区别,为什么要做内核态和用户态的区别,内核态有什么好处吗?有什么用户态做不了的工作吗?

内核态(Kernel Mode) 用户态(User Mode)
最高权限(Ring 0) 低权限(Ring 3)
可访问所有内存、硬件(如磁盘、网卡) 只能访问自己的内存,硬件操作需通过系统调用(隔离)
运行操作系统内核代码(如进程调度、驱动);执行所有CPU指令(包括特权指令)
内核崩溃 → 整个系统宕机(蓝屏/死机) 程序崩溃仅影响自身,系统仍运行
处理中断、内存管理、设备驱动
上述表格还是倾向于描述现象,操作系统这样设计主要还是考量以下几点:
  1. 安全性与稳定性:通过权限分级和内存隔离,保护核心系统不受应用程序错误或恶意行为的侵害。
  2. 资源统一管理:统一管理和调度所有硬件资源,为上层应用提供简洁、统一的接口(系统调用),并防止资源使用冲突。
  3. 抽象与简化:为应用程序抽象了硬件的复杂性,开发者无需关心底层硬件细节。
    再补充一点这样设计的问题:用户态和内核态的切换性能开销,由此也产生了0拷贝技术、旁路技术等。

再补充一点这样设计的问题:用户态和内核态的切换性能开销,由此也产生了0拷贝技术、旁路技术等。

当发生用户态和内核态之间的切换的时候,运行栈的信息发生了变化,对应的CPU中的寄存器信息也要发生变换。但是用户线程完成系统调用的时候,还是要切换回用户态,继续执行代码的。所以要将发生系统调用之前的用户栈的信息保存起来,也就是将寄存器中的数据保存到线程所属的某块内存区域。这就涉及到了数据的拷贝,同时用户态切换到内核态还需要安全验证等操作。所以用户态和内核态之间的切换是十分耗费资源的。更详细地来说:

CPU中有一个标志字段,标志着线程的运行状态。用户态和内核态对应着不同的值,用户态为3,内核态为0.

每个线程都对应着一个用户栈和内核栈,分别用来执行用户方法和内核方法。

用户方法就是普通的操作。

内核方法就是访问磁盘、内存分配、网卡、声卡等敏感操作。

当用户尝试调用内核方法的时候,就会发生用户态切换到内核态的转变。

切换流程: 1、每个线程都对应这一个TCB,TCB中有一个TSS字段,存储着线程对应的内核栈的地址,也就是内核栈的栈顶指针。

2、因为从用户态切换到内核态时,首先用户态可以直接读写寄存器,用户态操作CPU,将寄存器的状态保存到对应的内存中,然后调用对应的系统函数,传入对应的用户栈的PC地址和寄存器信息,方便后续内核方法调用完毕后,恢复用户方法执行的现场。

3、将CPU的字段改为内核态,将内核段对应的代码地址写入到PC寄存器中,然后开始执行内核方法,相应的方法栈帧时保存在内核栈中。

4、当内核方法执行完毕后,会将CPU的字段改为用户态,然后利用之前写入的信息来恢复用户栈的执行。

用户态切换到内核态的几种场景面试也会拷打。


mmap工作原理是什么

依据标志位不同,用途不同:

标志组合 拿到的页类型 典型用途
MAP_ANONYMOUS(或 MAP_ANON 匿名页 与文件无关,全零,swap-backed malloc 大对象、栈、堆
不带 MAP_ANONYMOUSfd≥0 文件页 对应打开的文件块,缺页时读盘 共享库、文件映射、数据库缓存

1. malloc的流程

malloc(size)__libc_malloc()线程局部缓存 → _int_malloc() → arena分配 或 sysmalloc()向内核要内存 → brk小区域 / mmap 大区域

arenaglibc 为每个线程(或主线程)准备的一整块“私有堆管理区”,和go协程的内存分配很像,各种大小的内存由链表组织。

使用 glibc 库里封装的 malloc 函数进行虚拟内存申请时,当申请的内存大于 128K 的时候,malloc 就会调用 mmap 采用私有匿名映射的方式来申请堆内存。因为它是私有的,所以申请到的内存是进程独占的,多进程之间不能共享。

这里需要特别强调一下 mmap 私有匿名映射申请到的只是虚拟内存,内核只是在进程虚拟内存空间中划分一段虚拟内存区域 VMA 出来,并将 VMA 该初始化的属性初始化好,mmap 系统调用就结束了。这里和物理内存还没有发生任何关系。

当进程开始访问这段虚拟内存区域时,发现这段虚拟内存区域背后没有任何物理内存与其关联,体现在内核中就是这段虚拟内存地址在页表中的 PTE 项是空的。这时 MMU 就会触发缺页异常(page fault),这里的缺页指的就是缺少物理内存页,随后进程就会切换到内核态,在内核缺页中断处理程序中,为这段虚拟内存区域分配对应大小的物理内存页,随后将物理内存页中的内容全部初始化为 0 ,最后在页表中建立虚拟内存与物理内存的映射关系,缺页异常处理结束

当缺页处理程序返回时,CPU 会重新启动引起本次缺页异常的访存指令,这时 MMU 就可以正常翻译出物理内存地址了。


2. calloc导致的缺页

callocmalloc唯一差异:除了返回一块内存,它还会把整块按字节清零bzero)。
详细来说:glibccalloc 会调用 mmap(…, MAP_ANONYMOUS, …) 向内核索要一块全零匿名映射。 并不立即分配物理页,而是把整段区域都指向一个全局只读零页(zero page)。如果申请的区域很大,会立即逐页建立映射并清零,因此运行时不会再缺页; calloc 更慢、可以看见RSS 立刻涨了

malloc的流程

malloc(size)__libc_malloc()线程局部缓存 → _int_malloc() → arena分配 或 sysmalloc()向内核要内存 → brk小区域 / mmap 大区域

arena 是 glibc 为每个线程(或主线程)准备的一整块“私有堆管理区”,和go协程的内存分配很像,各种大小的内存由链表组织。


3. page cache的文件映射

当进程打开一个文件的时候,内核会为其创建一个 struct file 结构来描述被打开的文件,并在进程文件描述符列表 fd_array 数组中找到一个空闲位置分配给它,数组中对应的下标,就是我们在用户空间用到的文件描述符
每一个磁盘上的文件在内核中都会有一个唯一的 struct inode 结构,inode 结构和进程是没有关系的,一个文件在内核中只对应一个 inodeinode 结构用于描述文件的元信息,比如,文件的权限,文件中包含多少个磁盘块,每个磁盘块位于磁盘中的什么位置等等。
如果我们通过 mmap 映射的是磁盘上的一个文件,那么就需要通过参数 fd 来指定要映射文件的描述符(file descriptor),通过参数 offset 来指定文件映射区域在文件中偏移。

调用 mmap 进行内存文件映射的时候,内核首先会在进程的虚拟内存空间中创建一个新的虚拟内存区域 VMA 用于映射文件,通过 vm_area_struct->vm_file 将映射文件的 struct flle 结构与虚拟内存映射关联起来。根据 vm_file->f_inode 我们可以关联到映射文件的 struct inode,近而关联到映射文件在磁盘中的磁盘块 i_block这个就是 mmap 内存文件映射最本质的东西

这只是磁盘中的文件和虚拟内存的桥梁,物理内存由page cache负责。page cache 在内核中是使用基树 radix_tree 结构来表示的,文件页是挂在 radix_tree 的叶子结点上,radix_tree 中的 root 节点和 node 节点是文件页(叶子节点)的索引节点。

当进程读取磁盘块的内容到内存之后,磁盘块中的数据被 DMA 拷贝到了物理内存页中,这个物理内存页就是前面提到的文件页。根据程序的时间局部性原理,内核会将已经访问过的磁盘块缓存在文件页中。

一个文件包含多个磁盘块,当它们被读取到内存之后,一个文件也就对应了多个文件页。每一个文件在内核中都会有一个唯一的 page cache 与之对应,用于缓存文件中的数据。page cache 是和文件相关的,它和进程是没有关系的,多个进程可以打开同一个文件,每个进程中都有有一个 struct file 结构来描述这个文件,但是一个文件在内核中只会对应一个 page cache。

文件页缺页异常时物理内存和虚拟内存映射的过程和匿名页有所不同:①当任意一个进程,比如上图中的进程 1 开始访问这段映射的虚拟内存时,CPU 会把虚拟内存地址送到 MMU 中进行地址翻译,因为 mmap 只是为进程分配了虚拟内存,并没有分配物理内存,所以这段映射的虚拟内存在页表中是没有页表项 PTE 的。②随后 MMU 就会触发缺页异常(page fault),进程切换到内核态,在内核缺页中断处理程序中会发现引起缺页的这段 VMA 是私有文件映射的,所以内核会首先通过 vm_area_struct->vm_pgoff 在文件 page cache 中查找是否有缓存相应的文件页(映射的磁盘块对应的文件页)。③如果文件页不在 page cache 中,内核则会在物理内存中分配一个内存页,然后将新分配的内存页加入到 page cache 中,并增加页引用计数。④随后会激活块设备驱动从磁盘中读取映射的文件内容,然后将读取到的内容填充新分配的内存页。⑤在缺页中断处理程序的最后一步,内核会为映射的这段虚拟内存在页表中创建 PTE,然后将虚拟内存与 page cache 中的文件页通过 PTE 关联起来,缺页处理就结束了,但是由于我们指定的私有文件映射,所以 PTE 中文件页的权限是只读的。

进程 2 和进程 1 一样,都是采用 mmap 私有文件映射的方式映射到了同一个文件中,虽然现在已经有了物理内存了(通过进程 1 的缺页产生),但是目前还和进程 2 没有关系。当进程 2 访问这段映射虚拟内存时,同样会产生缺页中断,随后进程 2 切换到内核态,进行缺页处理,这里和进程 1 不同的是,此时被映射的文件内容已经加载到 page cache 中了,进程 2 只需要创建 PTE ,并将 page cache 中的文件页与进程 2 映射的这段虚拟内存通过 PTE 关联起来就可以了。同样,因为采用私有文件映射的原因,进程 2 的 PTE 也是只读的。

但是由于采用的是私有文件映射的方式,各个进程页表中对应 PTE 却是只读的,当进程对这段虚拟内存进行写入的时候,MMU 会发现 PTE 是只读的,所以会产生一个写保护类型的缺页中断,写入进程,比如是进程 1,此时又会陷入到内核态,在写保护缺页处理中,内核会重新申请一个内存页,然后将 page cache 中的内容拷贝到这个新的内存页中(COW),进程 1 页表中对应的 PTE 会重新关联到这个新的内存页上,此时 PTE 的权限变为可写。但是进程 1 对这个内存页的任何修改均不会回写到磁盘文件上,这也体现了私有文件映射的特点,**进程对映射文件的修改,其他进程是看不到的,并且修改不会同步回磁盘文件中。可以利用 mmap 私有文件映射这个特点来加载二进制可执行文件的 .text , .data section 到进程虚拟内存空间中的代码段和数据段、动态链接库中。

对于共享文件映射来说,多进程读写都是共享的,由于多进程直接读写的是 page cache ,所以多进程对共享映射区的任何修改,最终都会通过内核回写线程 pdflush 刷新到磁盘文件中。多进程对各自虚拟内存映射区 VMA 的写入操作,内核会根据自己的脏页回写策略将修改内容回写到磁盘文件中。

(共享匿名页映射也比较难,因为文件页共享可以通过fd+offset找到page cache,但匿名共享的物理页面只能借助 tmpfs vfs来实现,而且由于虚拟空间隔离的原因,共享匿名映射只适用于父子进程之间的通讯)
大页内存也不允许被 swap,只能支持匿名映射的方式来使用 HugePagemmap 对文件进行大页映射基于hugetlbfs 文件系统。


4. 使用Page Cache 的拷贝

场景:应用程序调用 write(fd, buffer, size)。这个需求一般发生在要写到设备里去的时候
过程:用户态 -> 内核态(第一次切换)内核代码将数据从用户空间(buffer 指向的内存,比如InnoDB Buffer Pool的一部分)拷贝到内核空间的Page Cache中。(write写到page cache中都需要切换到内核态)只要数据成功进入了 Page Cache,write() 系统调用就返回成功。这非常快,因为它只是一次内存拷贝。操作系统会在后台(由 pdflush 等内核线程负责)异步地将 Page Cache 中的“脏页”(被修改过的页面)写入到物理硬盘中。

InnoDB现在的写入方式(O_DIRECT方式):

  1. 数据库(如InnoDB)自己已经有一套精心设计的缓存池(Buffer Pool)来缓存数据页。现在操作系统又加了一层 Page Cache,意味着同一份数据在内存中存了两份,这对于宝贵的内存资源是巨大的浪费。
  2. 当内核突然决定大规模刷写 Page Cache 时,会与数据库的IO操作产生资源竞争,导致数据库响应延迟激增。
  3. 虽然 write() 返回了成功,但数据可能还在 Page Cache 里,如果此时服务器断电,这部分数据就会丢失。数据库为了保证事务的持久性(Durability),必须在关键操作(如事务提交)后调用 fsync() 来强制将 Page Cache 的数据刷盘,但这又引入了一次额外的、可能很耗时的数据拷贝(从Buffer Pool到Page Cache)。
    O_DIRECT 标志告诉内核:“跳过Page Cache”。当 write() 被调用时,内核会尝试直接将数据从应用程序的内存缓冲区(InnoDB Buffer Pool)写入到磁盘
  • 你现在有一个自己设计的buffer pool,文件内容远远大于这个buffer pool大小,你用mmap做管理和你自己定制(比如划分若干块)有哪些利弊,比发生缺页前自己去调度过来之类的。(io延迟;OOM;存储相关的访问优化策略,什么时候调度,什么时候淘汰。)
  • 和page cache表现有什么不一样?(page cache主要由内核控制写回)
  • mmap相关的一些物理页做了变更,现在希望这些变更及时生效,希望及时触发写回msync,什么样的系统调用做到将文件脏页写入到磁盘?(默认使用 msync(MS_SYNC) 用于关键数据,msync(MS_ASYNC) 用于批量数据,fdatasync() 用于文件级一致性保证。)

用户态获取较大的内核态数据

用户态如何获取较大的内核态数据,比如一个大的buffer数据?

当用户程序需要从内核获取大数据(如驱动采样数据、网络包、trace 缓冲区等),必须通过系统调用内存映射等机制进行。

  • 持续流式传输(日志、采样数据):环形缓冲 + read()
  • 大块数据:mmap() 让用户进程把一段虚拟地址区间映射到某些物理页(或设备 MMIO 区域)。如果内核为某个设备或模块准备了一块物理内存(或一系列 page 结构),驱动在 mmap 回调中把这些 page 的 物理帧号(PFN) 映射到用户的 vm_area_struct,用户就可以直接读写这些物理页——零拷贝

这里mmap的映射call back一下前面的:如果是共享匿名映射要借助VFS来实现。


什么是虚拟内存,作用是啥

  • 每一段分配出去的地址空间都是用VMA表示的,vm_area_struct是由有双向链表的红黑树组织的。用户申请内存的时候只是申请到了vm_area_struct,然后会触发缺页异常来分配内存;最后这个函数调用是涉及到了伙伴系统的页面分配这里的分配是栈空间。
  • 堆内存的分配是开发语言运行时通过new、malloc等函数从堆中去分配的,依赖mmap(也是分配虚拟的vm_area_struct)和brk(听起来像是池化,但是实际是vm_area_struct有个brk和brk_end指针移动来做的)来实现的;

作用:

  • 扩展可用内存空间,物理内存不足的补充:当物理内存(RAM)不足时,虚拟内存利用磁盘空间(如SSD/HDD)作为扩展,允许程序使用比实际物理内存更大的地址空间。
  • 进程间隔离:每个进程拥有独立的虚拟地址空间,防止其他进程非法访问,增强系统安全性。
  • 简化内存管理,统一地址空间:程序员无需关心物理内存的实际分配,只需在连续的虚拟地址空间中开发程序。
  • 节约内存:多个进程可以共享同一份动态库的代码段(如libc.so
  • 避免内存碎片化:虚拟地址连续,物理地址离散。程序看到的虚拟地址是连续的,而实际物理内存可以是分散的块,减少碎片影响。

地址转换是怎么实现的

CPU中的MMU配合TLB将虚拟地址转化为物理地址。虚拟地址到物理地址的转换是通过 内存管理单元(MMU)页表(Page Table) 协作完成的,核心目标是 隔离进程内存、提高安全性并支持虚拟内存

虚拟地址通常分为两部分:

  • 页号(Virtual Page Number, VPN):高位 bits,用于索引页表。
  • 页内偏移(Offset):低位 bits(12 bits for 4KB 页),直接复制到物理地址。

查询页表:页表也是防止内存中的页,缓存的在TLB

  • MMU 用 VPN 查找页表,找到对应的 物理页帧号(PFN)
  • 页表条目(PTE)还包含权限位(如可读/可写/可执行)

当页表中不存在有效映射时,MMU 触发缺页异常


TLB是怎么映射的

偏移量、虚拟页。存储的是 虚拟页号(VPN)到物理页帧号(PFN)的映射关系,但根据场景不同,缓存的内容可能涉及:

  • 数据页的映射(最常见):缓存虚拟数据页 → 物理页帧的转换。
  • 页表页的映射(嵌套转换):缓存页表自身的物理地址(用于多级页表遍历)。

TLB(Translation Lookaside Buffer)可以缓存页表项的物理地址(即页表页的物理帧号),也可以缓存 虚拟数据页面对应的物理页帧号。


典型缺页场景

几个大的缺页场景有了解过吗?

场景 触发点 缺页类型 解释
1. 冷启动 进程刚 exec(),代码段/数据段第一次被访问 Major 磁盘→内存,把 ELF 文件读进来
2. 换入换出 内存紧张,kswapd 把匿名页换到 swap;进程再次访问 Major 把 swap 区里的页面重新读回内存
3. 写时复制 fork() 后父子共享只读页,任一写触发 COW Minor 页已在内存,只重映射并复制一份
4. 延迟分配 首次 malloc/brk 得到虚拟地址,真正写时 Minor 比如共享库页:其他进程已加载到内存,当前进程访问
5. 文件映射 mmap() 文件后首次读写对应页 Major 文件系统→页缓存,把磁盘块搬进内存
6. 巨型页回落 申请 HugePage 失败,内核退而求其次用 4 KB 页 Minor 大页拆小页,页表重填,数据仍在内存

free 命令中的buffer和cache什么情况下用到

使用free命令可以查看系统的内存使用情况。

free -h

-h选项用于以人类可读的方式显示内存大小,例如使用GB、MB等单位。

命令的输出会包含以下列:

  1. total:系统内存的总量。
  2. used:已使用的内存量。
  3. free:空闲的内存量。
  4. shared:用于共享内存的内存量。
  5. buffers:用于缓冲区的内存量。
  6. cached:用于缓存的内存量。
  7. available:可用的内存量。

available 是系统目前可供使用的最大内存,free是指直接可用的内存。他们的差值来源于buff/cache(虽然是已分配给缓存和缓冲区的)系统有需要的话可以拿出一部分内存给进程用。

下面是一个示例输出:

              total        used        free      shared  buff/cache   available
Mem:           7.7G        3.2G        1.5G        500M        3.0G        3.8G
Swap:          2.0G        1.2G        819M

在这个示例中,系统的总内存为7.7GB,已使用的内存为3.2GB,空闲的内存为1.5GB。同时,有500MB的内存用于共享,3.0GB的内存用于缓存,还有3.8GB的内存可用。

“cache”部分表示操作系统所使用的文件系统缓存,也就是磁盘上的文件数据的副本,以提高读取文件的性能。这个缓存会根据系统的需求动态地调整大小。通常,较大的cache值表示系统正在积极地使用内存来缓存文件数据,以提高读写文件的速度。buffer cache,主要用于存放块设备的缓冲数据。


内存回收

Linux内核为每个内存区域(zone)定义了三个水位(单位是页面数):

  • High (high watermark):理想空闲内存量,异步回收的目标水位。(free_pages(伙伴系统的链表) < highkswapd启动。)
  • Low (low watermark):直接回收的触发阈值,低于此值会同步回收。
  • Min (min watermark):极端内存压力下的阈值,可能触发OOM Killer。

1. 直接内存回收(Direct Reclaim)

系统发现空闲内存低于最低水位(min watermark),且无法通过其他途径(如缓存释放)获得足够内存,内核会同步阻塞当前进程,立即执行内存回收,直到释放足够内存供分配。

回收目标:包括页面缓存(Page Cache)、slab缓存、匿名页(可能触发交换SWAP)。

2. 异步内存回收(Background Reclaim)

由内核线程kswapd在后台定期检查内存水位,若空闲内存低于高水位(high watermark)但高于最低水位(min)kswapd异步回收内存,提前释放页面。

回收策略:优先回收干净页面(如未修改的Page Cache),减少I/O压力。

OOM Killer 的选择并非随机,而是基于一个评分算法。计算 oom_score 的核心思想是:在内存紧张的情况下,杀死一个进程能释放大量内存,同时造成的系统损失(代价)又最小。(父进程、用户级进程、运行时间很长的进程例如数据库服务、守护进程的分数更高)


内存碎片有哪些

类型 定义 产生原因 影响 常见场景 解决办法
外部碎片 空闲区不连续 多次分配与释放 无法分配大块连续空间 堆分配、OS内核 压缩、分页、伙伴系统
内部碎片 已分配的内存块中,未被实际使用的部分 固定块、对齐 内存浪费 malloc/slab分配器 减小粒度、分级分配
页碎片 页内部碎片:一个页未被完全利用。 页表碎片:页表项分散或页帧映射不连续。 页管理机制 性能下降 虚拟内存、数据库 大页、NUMA优化
文件碎片 文件块不连续 文件频繁修改 I/O变慢 文件系统 磁盘整理、碎片整理

内存分配

1. 伙伴系统

伙伴系统一次能够分配的最大连续内存块为 $ 2^{10} $ 个页面,即 1024 个页面,4MB。
THP 的分配对应于 order 为 9 的内存,也从伙伴系统分配。

2. 缺页异常

Minor Fault(次要缺页)

  • 当虚拟页没有映射到进程的页表上,但它实际上已经在物理内存中(比如共享页或已经在内存缓存中)时发生。
  • 处理这类缺页异常不需要从磁盘读取数据,只需要更新页表即可。

Major Fault(主要缺页)

  • 当虚拟页不在物理内存中,需要从 磁盘(swap 或文件) 读取到内存中才能继续执行时发生。

触发条件

  • CPU 尝试访问虚拟地址对应的页,但 页表中没有有效映射
  • 并且该页 不在物理内存中(或者被换出到 swap 区)。
  • 系统捕获到缺页异常 → 进入内核处理。

处理过程

  • 处理器将当前执行状态保存到内核栈。
  • 跳转到操作系统的缺页异常处理程序(page fault handler)。
  • 检查缺页地址:是否属于有效的虚拟内存区域(进程已经分配了虚拟内存的地址范围,或者操作系统可以为其分配(如堆或栈可按需扩展)),如果访问非法地址 → Segmentation fault
  • OS 查找空闲物理页:如果内存充足,直接分配(选择内存区域(zone)+伙伴系统free area)。如果内存不足 → 触发 页替换算法(如 LRU、Clock),选择某页换出到 swap 区释放空间。
  • 写入页内容:文件页从可执行文件或共享库文件中读取到内存;匿名页如果第一次访问,通常初始化为全零页;如果之前被换出,则从swap 区读取回内存。major fault 的核心开销,涉及 磁盘 I/O,通常耗时几毫秒。minor fault也会到内核态,但是物理内存已经有想要的页面了,直接改页表就好了。
  • 更新页表:将新分配的物理页与虚拟地址映射到页表中,刷新CPU TLB
  • 返回用户态:恢复 CPU 状态,重新执行引发缺页的指令。

文章作者: 易百分
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 易百分 !
  目录