为什么要有用户态和内核态
内核态和用户态的区别,为什么要做内核态和用户态的区别,内核态有什么好处吗?有什么用户态做不了的工作吗?
| 内核态(Kernel Mode) | 用户态(User Mode) |
|---|---|
| 最高权限(Ring 0) | 低权限(Ring 3) |
| 可访问所有内存、硬件(如磁盘、网卡) | 只能访问自己的内存,硬件操作需通过系统调用(隔离) |
| 运行操作系统内核代码(如进程调度、驱动);执行所有CPU指令(包括特权指令) | |
| 内核崩溃 → 整个系统宕机(蓝屏/死机) | 程序崩溃仅影响自身,系统仍运行 |
| 处理中断、内存管理、设备驱动 | |
| 上述表格还是倾向于描述现象,操作系统这样设计主要还是考量以下几点: |
- 安全性与稳定性:通过权限分级和内存隔离,保护核心系统不受应用程序错误或恶意行为的侵害。
- 资源统一管理:统一管理和调度所有硬件资源,为上层应用提供简洁、统一的接口(系统调用),并防止资源使用冲突。
- 抽象与简化:为应用程序抽象了硬件的复杂性,开发者无需关心底层硬件细节。
再补充一点这样设计的问题:用户态和内核态的切换性能开销,由此也产生了0拷贝技术、旁路技术等。
再补充一点这样设计的问题:用户态和内核态的切换性能开销,由此也产生了0拷贝技术、旁路技术等。
当发生用户态和内核态之间的切换的时候,运行栈的信息发生了变化,对应的CPU中的寄存器信息也要发生变换。但是用户线程完成系统调用的时候,还是要切换回用户态,继续执行代码的。所以要将发生系统调用之前的用户栈的信息保存起来,也就是将寄存器中的数据保存到线程所属的某块内存区域。这就涉及到了数据的拷贝,同时用户态切换到内核态还需要安全验证等操作。所以用户态和内核态之间的切换是十分耗费资源的。更详细地来说:
切换流程: 1、每个线程都对应这一个TCB,TCB中有一个TSS字段,存储着线程对应的内核栈的地址,也就是内核栈的栈顶指针。
2、因为从用户态切换到内核态时,首先用户态可以直接读写寄存器,用户态操作CPU,将寄存器的状态保存到对应的内存中,然后调用对应的系统函数,传入对应的用户栈的PC地址和寄存器信息,方便后续内核方法调用完毕后,恢复用户方法执行的现场。
3、将CPU的字段改为内核态,将内核段对应的代码地址写入到PC寄存器中,然后开始执行内核方法,相应的方法栈帧时保存在内核栈中。
4、当内核方法执行完毕后,会将CPU的字段改为用户态,然后利用之前写入的信息来恢复用户栈的执行。

用户态切换到内核态的几种场景面试也会拷打。
mmap工作原理是什么
依据标志位不同,用途不同:
| 标志组合 | 拿到的页类型 | 典型用途 |
|---|---|---|
MAP_ANONYMOUS(或 MAP_ANON) |
匿名页 与文件无关,全零,swap-backed | malloc 大对象、栈、堆 |
不带 MAP_ANONYMOUS 且 fd≥0 |
文件页 对应打开的文件块,缺页时读盘 | 共享库、文件映射、数据库缓存 |
1. malloc的流程
malloc(size) → __libc_malloc()线程局部缓存 → _int_malloc() → arena分配 或 sysmalloc()向内核要内存 → brk小区域 / mmap 大区域
arena 是 glibc 为每个线程(或主线程)准备的一整块“私有堆管理区”,和go协程的内存分配很像,各种大小的内存由链表组织。
使用 glibc 库里封装的 malloc 函数进行虚拟内存申请时,当申请的内存大于 128K 的时候,malloc 就会调用 mmap 采用私有匿名映射的方式来申请堆内存。因为它是私有的,所以申请到的内存是进程独占的,多进程之间不能共享。
这里需要特别强调一下 mmap 私有匿名映射申请到的只是虚拟内存,内核只是在进程虚拟内存空间中划分一段虚拟内存区域 VMA 出来,并将 VMA 该初始化的属性初始化好,mmap 系统调用就结束了。这里和物理内存还没有发生任何关系。
当进程开始访问这段虚拟内存区域时,发现这段虚拟内存区域背后没有任何物理内存与其关联,体现在内核中就是这段虚拟内存地址在页表中的 PTE 项是空的。这时 MMU 就会触发缺页异常(page fault),这里的缺页指的就是缺少物理内存页,随后进程就会切换到内核态,在内核缺页中断处理程序中,为这段虚拟内存区域分配对应大小的物理内存页,随后将物理内存页中的内容全部初始化为 0 ,最后在页表中建立虚拟内存与物理内存的映射关系,缺页异常处理结束。
当缺页处理程序返回时,CPU 会重新启动引起本次缺页异常的访存指令,这时 MMU 就可以正常翻译出物理内存地址了。
2. calloc导致的缺页
calloc 与 malloc 的唯一差异:除了返回一块内存,它还会把整块按字节清零(bzero)。
详细来说:glibc 的 calloc 会调用 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 结构和进程是没有关系的,一个文件在内核中只对应一个 inode,inode 结构用于描述文件的元信息,比如,文件的权限,文件中包含多少个磁盘块,每个磁盘块位于磁盘中的什么位置等等。
如果我们通过 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,只能支持匿名映射的方式来使用HugePage,mmap对文件进行大页映射基于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方式):
- 数据库(如InnoDB)自己已经有一套精心设计的缓存池(Buffer Pool)来缓存数据页。现在操作系统又加了一层 Page Cache,意味着同一份数据在内存中存了两份,这对于宝贵的内存资源是巨大的浪费。
- 当内核突然决定大规模刷写 Page Cache 时,会与数据库的IO操作产生资源竞争,导致数据库响应延迟激增。
- 虽然
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)可以缓存页表项的物理地址(即页表页的物理帧号),也可以缓存 虚拟数据页面对应的物理页帧号。