文章目录
概括
本篇文章是我在学习小林coding的Linux虚拟内存管理后的一些总结与感悟。因为本节内容很难,涉及到的内容很广(至少我是这么觉得的,反复看了几遍并拷打AI才勉强学懂)。因此我基于自己的一些理解讲解相关内容,希望能够帮助到学习完后同样感到困惑的同学^ - ^。
为什么要使用虚拟内存地址访问内存?
我们知道在程序执行过程中使用的变量等数据都是存储在内存中的。为了在执行过程中得到这些数据就要得到其物理内存地址(也就是数据真正存储的地方),那既然物理内存地址可以直接定位数据的具体存储位置了,为什么程序中还要使用虚拟内存地址来访问呢?原因很简单,程序员在编写程序时更注意的是代码的具体逻辑和实现,而并不关心这些数据具体是存储在哪里的(因为这样太麻烦了!假设以下你每次申明变量都要指明其存储位置,还要注意不要和别的变量冲突,还要注意不要和别的线程冲突…真是想到就烦)。为了避免上述的情况,我们便希望有人能帮我们处理这些繁琐的内存处理细节,于是便出现了虚拟地址。通过虚拟地址每个进程都拥有了自己独立的内存空间,在这个独属于他的内存空间里简直想干嘛就干嘛。(因为虚拟内存提供了内存地址空间的隔离,使得每个进程都认为自己在独享内存空间)但实际上每个进程的虚拟内存最终还是会指向物理内存,这个从虚拟内存到物理内存的映射过程内核会帮我们处理。
这里我们还需要提及以下局部性原理。通过上文的描述我们会发现每个进程现在都拥有自己的虚拟地址空间,它可以在其中随意操作最终这些虚拟地址都会映射到物理地址上。但当进程太多不会导致物理地址分配太复杂吗?其实这个问题我们不必担心,程序的局部性原理会帮助我们:
1. 时间局部性(Temporal Locality)
定义:如果一个数据或指令被访问了一次,那么在不久的将来它很可能会被再次访问。
原因:程序中存在大量的循环结构(如for/while循环)、递归函数调用,以及重复使用的变量(如缓存频繁访问的全局变量)。
2. 空间局部性(Spatial Locality)
定义:如果一个数据被访问了一次,那么与它在内存地址上相邻的数据很可能在不久后被访问。
原因:程序通常按顺序访问连续存储的数据(如数组遍历),且数据结构(如结构体)中的成员在内存中连续存放,访问一个成员后可能接着访问下一个成员。
为何少量物理内存足够?
局部性原理保证:程序在任一时刻仅活跃访问少量页面(时间局部性),且这些页面的相邻数据已被预加载(空间局部性)。
虚拟内存的 “换入换出”:通过硬盘作为后备存储,物理内存仅需保存当前活跃的页面,非活跃页面可被暂时置换到硬盘,从而用有限的物理内存支持远大于其容量的程序运行。
于是现在我们知道了:引入虚拟内存后对内存的操作就会十分简单。对物理内存的操作以及从虚拟内存到物理内存的映射只需要交给内核处理就好了。
这里使用小林大佬的图来直观描述:
虚拟内存空间长啥样?
我们先看图(小林大佬的),在对每一个区域讲解:
一、程序代码段(Text Segment)
地址范围:低地址端(通常从0x08048000开始(并不是从0开始,下面会讲具体原因),64 位系统更高)。
特点:
只读:存储编译后的程序机器指令(如 C 语言的main函数、自定义函数等),运行时不允许修改,防止程序代码被意外破坏。
可共享:多个运行同一程序的进程(如多个终端打开同一个文本编辑器)可共享同一代码段,节省内存。
作用:执行程序的逻辑指令,是进程启动后首先加载的部分。
二、已初始化数据段(Data Segment)
地址范围:紧接代码段之后,分为初始化只读数据和初始化可写数据。
子区域及作用:
只读数据区(RO Data)
存储程序中声明为常量的数据(如 C 语言中的const变量、字符串字面量"hello world")。
只读,防止程序误修改常量值。
已初始化可写数据区(RW Data)
存储程序中已初始化的全局变量和静态变量(如 C 语言中int global_var = 10;、static int static_var = 5;)。
可读写,程序运行时可修改其值,数据会持久化保存(如进程退出前未释放的全局变量值会被销毁)。
三、未初始化数据段(BSS Segment)
地址范围:位于已初始化数据段之后。
特点:
存储程序中未初始化的全局变量和静态变量(如 C 语言中int global_var;、static int static_var;)。
不占用磁盘空间:内核在进程启动时自动将该区域清零,因此磁盘上的可执行文件仅记录 BSS 段的大小,无需存储具体数据。
作用:为未初始化的变量提供内存空间,避免未初始化值导致的程序错误。
四、堆(Heap)
地址范围:位于 BSS 段之后,向上(高地址)增长。
特点:
动态内存分配区域:由程序运行时通过malloc/new等函数动态申请内存,使用完毕后需通过free/delete手动释放。
非连续空间:堆的内存分配可能产生碎片(频繁申请 / 释放小块内存导致可用空间不连续)。
作用:用于存储运行时动态生成的数据(如动态数组、链表节点、对象实例等),灵活适应程序对内存的动态需求。
五、文件映射区(Memory-Mapped Segment)
地址范围:位于堆和栈之间(64 位系统中通常在较高地址区域)。
功能:
通过mmap系统调用将文件内容或设备数据直接映射到虚拟内存中,实现内存与文件的高效交互。
常见用途包括:
加载动态链接库(如.so文件,Linux)或共享库(如.dll文件,Windows)到内存。
实现进程间通信(IPC)的共享内存(多个进程映射同一文件区域,共享数据)。
访问磁盘文件(如大文件读取时无需逐字节读取,直接操作内存映射区域)。
特点:
按需加载:映射的文件内容不会立即全部加载到物理内存,而是在访问时通过缺页中断动态加载。
写时复制(Copy-on-Write, COW):多个进程映射同一文件时,初始共享物理内存,仅当某进程修改数据时才复制独立副本,节省内存。
六、栈(Stack)
地址范围:位于用户态空间的高地址端,向下(低地址)增长。
特点:
自动管理:由编译器和操作系统自动分配 / 释放内存,用于存储函数调用的上下文信息。(递归函数调用)
存储内容:
函数的参数、局部变量(如 C 语言中int x = 5;)。
返回地址(函数执行完毕后返回的位置)和栈帧指针(用于维护函数调用栈的结构)。
固定大小限制:每个进程的栈大小有限(Linux 默认通常为 8MB-10MB),递归过深或局部变量过大可能导致栈溢出(Stack Overflow)。
作用:支持函数调用的嵌套执行,确保程序按正确顺序执行指令,并管理临时数据的生命周期。
区域 | 增长方向 | 内存管理方式 | 数据生命周期 | 典型用途 |
---|---|---|---|---|
代码段 | 固定 | 静态加载 | 进程运行全程 | 存储程序指令 |
数据段(含 BSS) | 固定 | 静态分配 | 进程运行全程 | 存储全局 / 静态变量 |
堆 | 向上 | 动态分配(手动释放) | 按需申请 / 释放 | 动态数据结构(如链表、对象) |
文件映射区 | 固定或动态 | 系统调用(mmap) | 随映射文件生命周期 | 加载共享库、文件 I/O、进程间通信 |
栈 | 向下 | 自动分配 / 释放 | 函数调用周期内 | 函数参数、局部变量、调用上下文 |
32位机器上虚拟内存空间分布
了解了虚拟内存空间分布的大致规划后,让我们来看下其在32位机器(计算机的硬件架构(如 CPU、总线等)以32 位(4 字节)为基础处理单位)上的真实分布情况,还是先看图:
这里我们先来看下代码段下的保留区:这是一段不可访问区域,可以用来防止程序询问一些未初始化的空指针(NULL)这样就可以避免非法内存访问导致的程序崩溃。
再来看堆,堆中的箭头方向说明了其地址增长方向,因为其可增长因此内核使用使用 start_brk 标识堆的起始位置,brk 标识堆当前的结束位置(其实就是用指针做记录),上方的待分配区域用来扩展其使用。同理栈堆也是一样:使用start_stack标识栈的起始位置,在RSP寄存器中记录栈顶指针,在RBP寄存器中记录栈基地址,并在下方一段待分配区域用来扩展其使用。最后在其上方就是内核空间,这段地址对于内核来说是可见的,不过不能访问。
64位机器上虚拟内存空间分布
我们还是先直接看图:
可以发现大致分布和32位的还是相同的,不同在于其内存空间大很多(毕竟64位机器的处理单位太大了,寻址空间大了巨巨巨多),由于其寻址空间太大,因此一般只使用了低48位来描述虚拟内存空间,剩下未被使用的就形成了上图所示的空洞;其次在代码段和数据段间还多了一个不可访问的保护段,它可以用来防止读写数据段时越界读到代码段。
进程虚拟内存空间的管理
现在我们知道了虚拟内存空间的结构了,那在此基础上内核是怎么管理这些区域的呢:其使用mm_struct结构体来管理虚拟内存空间。这个结构体又属于进程描述符task_struct结构。下面让我们先看下这个结构体是如何被创建的:
用户调用 fork() → 内核执行 sys_fork() → do_fork() → copy_process() → dup_mm()
下面来看下每个函数的具体功能:
函数 | 核心功能 |
---|---|
fork() | 用户空间触发系统调用 |
sys_fork() | 内核系统调用入口,传递参数到 do_fork() |
do_fork() | 分配 PID,调用 copy_process() ,唤醒新进程 |
copy_process() | 复制父进程的 task_struct 、文件描述符、信号处理等,调用 dup_mm() |
dup_mm() | 复制页表(写时复制)和 VMA,初始化内存统计信息 |
简单来说就是当用户调用fork创建进程时mm_struct结构会随着进程描述符task_struct的创建而创建:在copy_process这个函数中创建了子进程的task_struct并将父进程的资源拷贝到其中,这其中有一个copy_mm函数用来拷贝父进程的mm_struct并创建子进程的mm_struct,最后在dup_mm函数中将虚拟内存空间及页表拷贝进子进程的mm_struct中并赋值给子进程的task_struct。(这是使用fork创建进程,如果使用clone或vfork创建就不是将信息拷贝,而是直接赋值,这样的效果是让父子进程指向同一个虚拟内存空间实现共享)
知道mm_sturct结构体的来源后,让我们来看下是怎么通过它来管理虚拟内存空间的:
这张图里的不同属性就是用来划分虚拟内存空间中的不同区域的,从total_vm属性往下的则是用来表示虚拟内存空间中的内存使用情况的
字段名 | 作用 |
---|---|
task_size | 表示任务虚拟内存空间的大小,用于界定进程可使用的虚拟内存范围 |
start_code | 代码段(存放可执行程序指令)的起始虚拟地址 |
end_code | 代码段的结束虚拟地址 |
start_data | 数据段(存放已初始化全局变量和静态变量)的起始虚拟地址 |
end_data | 数据段的结束虚拟地址 |
start_brk | 堆(用于动态内存分配,如malloc 分配内存)的起始虚拟地址 |
brk | 堆的当前结束虚拟地址,可通过brk() 或sbrk() 系统调用动态调整,从而改变堆的大小 |
start_stack | 栈(存放函数局部变量、函数调用栈信息等)的起始虚拟地址,在用户空间中通常从高地址向低地址增长 |
arg_start | 命令行参数(如main 函数中argv 数组内容)存放区域的起始虚拟地址 |
arg_end | 命令行参数存放区域的结束虚拟地址 |
env_start | 环境变量(如main 函数中envp 数组内容)存放区域的起始虚拟地址 |
env_end | 环境变量存放区域的结束虚拟地址 |
mmap_base | 内存映射区域(通过mmap 系统调用分配的区域,常用于映射共享库、文件等)的起始基址 |
total_vm | 进程虚拟内存中映射的总页数,用于统计进程虚拟内存使用量 |
locked_vm | 被锁定在物理内存中的页数,即设置了PG_mlocked 标志的页面,这些页面不会被换出到磁盘,常用于对实时性要求高或关键数据的内存保护 |
pinned_vm | 引用计数永久增加的页面数量,意味着这些页面在内存中被固定,不能被轻易回收或移动,内核在一些特定操作(如I/O相关操作)中可能会使用 |
data_vm | 用于标识具有可写(VM_WRITE )属性,且不属于共享(~VM_SHARED )和栈(~VM_STACK )类型的虚拟内存页面数量,主要涉及进程的数据段相关内存统计 |
exec_vm | 用于标识具有可执行(VM_EXEC )属性,且不具有可写(~VM_WRITE )和栈(~VM_STACK )属性的虚拟内存页面数量,主要涉及代码段相关内存统计 |
stack_vm | 用于标识属于栈(VM_STACK )类型的虚拟内存页面数量,统计栈区域占用的内存情况 |
其中的task_size字段是用来区分虚拟地址空间中的内核态和用户态的
比如在上图中task_size的值就是0xC000 000。
现在我们知道不同区域是怎么在mm_struct里划分的,让我们继续来看看这些区域是怎么被内核管理的:
VMA
之前我们提到了不同区域是怎么被划分的,现在我们来看看这些区域是怎么在内核中被描述的:
通过上图我们不难发现:每个vm_area_struct结构就对应了唯一的虚拟内存区域VMA,并通过vm_start指向起始区域(包括vm_start本身)、vm_end指向结束区域(不包括vm_end)
权限控制
我们知道不管是物理地址还是虚拟地址都会被划分成一段一段的页,其是内存管理的最小单位。虚拟内存中的页需要通过页表映射到物理地址中的页去。为了在页这个层面管理访问控制权限,便有了vm_page_prot 这一字段。而在整个虚拟内存区域的访问权限则是由vm_flags 实现。这里我们通过表格大致了解一下:
常见vm_flags字段 | 作用 |
---|---|
VM_READ | 表示该虚拟内存区域具有可读权限,进程可以从该区域读取数据 |
VM_WRITE | 表示该虚拟内存区域具有可写权限,进程可以向该区域写入数据 |
VM_EXEC | 表示该虚拟内存区域具有可执行权限,进程可以在该区域执行指令 |
VM_SHARED | 表示该虚拟内存区域是共享的,多个进程可以共享该区域的内容,对该区域的修改会在所有共享进程中体现 |
VM_PRIVATE | 表示该虚拟内存区域是私有的,进程对该区域的修改不会影响其他进程,常用于写时复制(Copy - on - Write,COW)机制 |
VM_STACK | 标识该虚拟内存区域是栈空间,用于存放函数调用栈、局部变量等 |
VM_DENYWRITE | 该区域不允许写入,通常用于只读映射的文件区域等,防止意外写入 |
VM_GROWSDOWN | 表示该虚拟内存区域可以向下增长,例如栈区域就是向下增长的,符合这种特性 |
VM_GROWSUP | 表示该虚拟内存区域可以向上增长,典型的如堆区域是向上增长的 |
总之我们只需要知道权限控制也分为在页的层面和在整体的层面
映射关系
我们知道虚拟内存空间中的地址最终都会映射到物理内存空间中去,那这样的映射关系在内核中是如何表示的呢:这里我们从anon_vma、vm_file、vm_pgoff 这三个虚拟内存区域(VMA,struct vm_area_struct)的关键属性,来具体讲解。
(匿名映射指映射到物理内存上、文件映射指映射到文件内容上)
一、anon_vma:匿名映射的反向映射机制
- 作用
管理匿名内存(如堆、栈、mmap(MAP_ANONYMOUS) 分配的内存)的反向映射(物理页 → 虚拟地址)。
支持写时复制(COW)和内存回收(如交换到磁盘)。 - 数据结构
struct anon_vma:每个匿名页面的物理页框通过 anon_vma 链接收藏所有映射到它的 VMA。
struct anon_vma_chain:连接 vm_area_struct 和 anon_vma,形成双向链表。 - 映射流程
虚拟地址 → VMA → anon_vma_chain → anon_vma → 物理页框(struct page)
当多个进程共享同一匿名页面(如 fork() 后的父子进程),它们的 VMA 通过 anon_vma 指向同一物理页框。
写操作触发 COW 时,内核通过 anon_vma 找到所有相关 VMA,复制物理页并更新映射。
二、vm_file:文件映射的关联文件
- 作用
指向文件映射(如 mmap() 映射的文件)的 struct file,建立虚拟内存与磁盘文件的关联。
用于实现内存映射 I/O(如共享库、文件缓存)。 - 数据结构
struct file:表示打开的文件,包含 f_op(文件操作函数集)和 f_mapping(地址空间对象)。
struct address_space:管理文件的页缓存,通过 i_mmap 字段关联所有映射到该文件的 VMA。 - 映射流程
虚拟地址 → VMA → vm_file → address_space → 页缓存(struct page) → 磁盘文件
读取文件时,若页面不在内存中,内核通过 vm_file 和 vm_pgoff 从磁盘加载数据到页缓存。
修改文件时,先更新页缓存,再通过 writeback 机制同步到磁盘。
三、vm_pgoff:文件映射的偏移量
- 作用
表示虚拟内存区域在映射文件中的起始偏移量(以页为单位)。
用于计算虚拟地址与文件内容的对应关系。 - 计算公式
文件偏移量(字节) = vm_pgoff × PAGE_SIZE
例如,若 vm_pgoff = 10 且页大小为 4KB,则映射从文件的第 40KB 处开始。
3. 映射流程
当访问虚拟地址 addr 时,内核通过以下公式计算对应的文件偏移:
文件偏移 = (addr - VMA->vm_start) + (vm_pgoff × PAGE_SIZE)
总之这三个属性是用于区分和管理不同类型的虚拟内存到物理内存的映射关系
针对虚拟内存区域的相关操作
为了实现对虚拟内存区域的操作就要用到vm_ops 。它是一个指向 vm_operations_struct 结构体的指针。vm_operations_struct 结构体中封装了一系列与虚拟内存区域(VMA)操作相关的函数指针,用于定义针对虚拟内存区域的各种操作行为(简单来说可以理解为一个函数指针的集合)。
下面我们来简单了解下其中常见的函数指针:
open 函数指针:在创建虚拟内存区域时调用,用于执行一些初始化或准备工作。例如,当通过 mmap 系统调用映射一个文件到虚拟内存时,对应的 open 函数可能会对相关的文件资源、映射参数等进行检查和初始化设置 。
close 函数指针:在删除虚拟内存区域时被调用,负责清理与该虚拟内存区域相关的资源。比如释放一些临时分配的内存、关闭与文件映射相关的文件描述符等。
fault 函数指针:当虚拟内存区域发生缺页异常(即访问的虚拟地址对应的物理页面不在内存中)时调用。对于文件映射的虚拟内存区域,该函数会负责从对应的文件中读取数据到物理内存页;对于匿名映射区域,可能会分配新的物理内存页并进行初始化等操作 。
page_mkwrite 函数指针:通知相关虚拟内存区域马上要变成可写状态。在写时复制(COW)机制中,当某个进程尝试对共享的只读页面进行写操作时,会先调用此函数,然后再进行页面复制等操作 。
mremap 函数指针:当使用系统调用 mremap 函数移动虚拟内存区域时调用。它负责调整虚拟内存区域的地址范围、更新相关的映射关系等 。
unmap 函数指针:在解除虚拟内存区域的映射时调用,比如当通过 munmap 系统调用解除 mmap 映射时,该函数会处理相关的清理工作,包括更新页表、释放资源等 。
sync 函数指针:用于将虚拟内存区域中已修改的数据同步回磁盘(如果该区域映射了文件),保证内存中的数据和磁盘上的数据一致 。
虚拟内存区域的组织连接
现在我们终于知道虚拟内存区域在内核中的表示、权限控制、相关操作、映射机制了。现在就让我们来看下每个虚拟内存区域VMA是怎么串联的。为了方便高效遍历内存区域和高效查找特定内存区域,VMA间同时存在两种不同的组织形式:双向链表、红黑树。
为了直观感受下面我直接就小林大佬的图来讲解:
不难发现每个VMA通过next指向下一个VMA,并由prev指向上一个VMA(这两个指针都是定义在VMA结构体中的)这个双向链表的头指针存储在mm_struct中的mmap中。每个VMA又通过vm_mm指针指向专属的虚拟内存空间mm_struct。至于红黑数的实现则是每个VMA通过vm_rb将自己连接入红黑数,同样红黑数的根节点也是存在于mm_struct中的mm_rb中
小结
相信大家第一遍读完上面的内容都会感觉晕乎乎的,很多个词放一起突然就不知道啥是啥了。但我们只需要简单了解VMA中每个功能是怎么基于其中定义的字段实现的就行。VMA就像我们自己自定义的类,里面通过不同的字段指定了这个类不同的特性。每个VMA通过指针串联在一起构成了完整的虚拟内存区域。而这个虚拟内存空间则是由mm_struct来管理的。最后我在结合每个字段总结一下,希望能够让同学们都能看懂:
VMA(Virtual Memory Area)是 Linux 内核中对虚拟内存区域的抽象,其设计思想类似于面向对象编程中的“类”:
-
属性封装
- VMA 通过
vm_flags
(权限)、vm_file
(映射文件)、vm_ops
(操作函数集)等字段定义特性。 - 不同类型的 VMA(如代码段、堆、共享库)通过这些字段实现差异化行为。
- VMA 通过
-
动态组织
- 多个 VMA 通过双向链表(按地址排序)和红黑树(按
vm_start
排序)串联。 mm_struct
作为“管理器”,通过mmap
(链表头)和mm_rb
(树根)统一管理所有 VMA。
- 多个 VMA 通过双向链表(按地址排序)和红黑树(按
-
核心机制
- 权限控制:VMA 层通过
vm_flags
定义默认权限,页表层通过 PTE 实现细粒度控制。 - 映射机制:支持文件映射(数据来自磁盘)和匿名映射(数据由内存分配)。
- 动态操作:支持合并、分裂、创建、删除等操作,适应程序运行时的内存需求变化。
- 权限控制:VMA 层通过
-
性能优化
- 红黑树实现 O(log n) 时间复杂度的地址查找。
mmap_cache
缓存最近访问的 VMA,加速常见场景的查询。
-
内核协作
- 与页表、反向映射(rmap)、内存回收等机制共同构成完整的内存管理系统。
- 与文件系统、进程调度等模块交互,支持跨组件的内存操作。
内核虚拟内存空间
通过上面的学习我们了解到进程的虚拟内存空间,现在让我们进一步了解内核虚拟内存空间。学习之前我们要先清楚一个概念:之前在学习进程内存空间是我们提到过,对于每一个进程来说是感觉不到其他进程的存在的。进程虚拟内存空间都是独立的。
比如在这张图里面,因为进程虚拟内存空间是独享的,所以即使三个进程都访问同样的虚拟内存地址0×354但是最终会映射到不同的物理内存地址。但是内核虚拟内存空间却是共享的,也就是说不同进程进到内核态后看到的虚拟内存空间全部是一样的。在上面的例子来说就是三个进程进入内核态后在访问0×354这个地址时得到的结果都是一样的。
(这样进程虚拟空间隔离、内核虚拟空间共享的设计是因为进程间需要隔离保证安全与稳定,而内核资源需要复用以提升系统调用效率)
了解到上述的内容后让我们还是从内核虚拟内存空间的布局开始看起:
32位内核虚拟内存空间布局
这里先使用小林大佬的图帮助大家整体了解:
下面我们针对每个区域具体讲解:
直接映射区
大家不难发现图中的DMA映射区和NORMAL映射区是直接指向物理内存中的一段区域的(无需通过页表映射)。这样直接指向的区域自然就叫做直接映射区。那为啥又要分成DMA区和NORMAL区呢?
硬件限制:DMA 设备对物理地址的要求(个人感觉是主要原因)
DMA(直接内存访问)设备:如网卡、磁盘控制器等,可直接读写内存而无需 CPU 干预。但早期 DMA 控制器存在物理地址限制,只能访问低地址内存(如 x86 架构中,某些 DMA 设备只能访问低于 16MB 的物理地址)。
DMA 区(ZONE_DMA):将物理地址低于 16MB 的内存单独划分为 DMA 区,确保 DMA 设备能直接访问该区域,避免因地址越界导致数据传输错误。
NORMAL 区(ZONE_NORMAL):用于常规内存分配,不受 DMA 设备地址限制,可利用更高的物理地址空间(16MB~896MB)。
2. 内存碎片管理
分离 DMA 内存:将 DMA 设备专用的低地址内存与常规内存分离,可减少常规内存分配对 DMA 区的碎片化影响。若不分离,频繁的内存分配 / 释放可能导致 DMA 区出现碎片,使大块连续内存无法分配给 DMA 设备,影响设备性能。
我的理解就是这样分区是为了方便DMA设备,因为他们虽然可以直接读写内存但是由于物理地址限制又只能独写低地址内存,那干脆直接把内核虚拟空间的低地址分配给这些设备方便直接映射。
接下来让我们来看下这俩区域都存放些什么:
ZONE_DMA 区域(低于 16MB 的内存页框 )
作用:主要用于支持具有直接内存访问(DMA)功能的设备。这些设备能够在不经过 CPU 干预的情况下,直接访问内存进行数据传输 ,像网卡、硬盘控制器等设备在进行数据读写操作时,可利用此区域与内存交互。比如网卡接收网络数据时,可通过 DMA 方式直接将数据写入该区域对应的物理内存中,然后通知 CPU 处理 ,提高数据传输效率,减少 CPU 占用时间。
存放内容:包含设备传输的数据以及相关的控制信息等 。例如网卡接收的网络数据包、磁盘控制器传输的磁盘数据块等 。同时,还可能有一些用于 DMA 操作的描述符,记录着数据传输的源地址、目的地址、传输长度等信息 ,以便设备和内核准确进行数据传输操作。
ZONE_NORMAL 区域(16MB - 896MB 的内存页框 )
作用:是内核管理物理内存的主要区域,用于存放内核自身运行所需的代码和数据,以及为普通的内存分配请求提供空间 。内核在执行各种任务(如进程调度、文件系统操作等 )时,会频繁使用该区域的内存资源。
存放内容
内核代码:如进程调度算法代码、系统调用处理代码等,这些代码是内核实现各种功能的基础,被加载到该区域以便 CPU 执行。
内核数据结构:像进程描述符(task_struct ),记录着进程的各种属性(如进程状态、优先级、打开文件列表等 );还有内存管理相关的数据结构,如页表、伙伴系统的管理结构等 ,用于管理物理内存的分配与回收。
缓存数据:例如文件系统的页缓存,用于缓存从磁盘读取的文件数据 。当进程需要读取文件时,先查看页缓存中是否有相应数据,若有则直接读取,避免频繁的磁盘 I/O 操作,提高文件访问效率。此外,还有目录项缓存等,用于加速文件目录的查找等操作。
动态分配内存:当内核模块或进程在内核态申请内存时,若申请的内存处于该区域范围,就会从这里分配。比如内核模块加载时,为模块的代码和数据分配内存空间 ;或者在一些临时操作中,为临时变量、缓冲区等分配内存 。
接下来我们可以看到在物理内存中直接映射区上部有一段高端内存区域,在32位机器下现在已经有896M用来直接映射,于是高端内存还剩:4096-896 = 3200M。但此时和他对应的内核虚拟内存空间只剩 1G(进程虚拟内存空间占3G) - 896M = 128M。很明显这么一大段物理内存没法完全和内核虚拟内存空间映射。于是需要实现动态映射。即先把正在使用的映射过去,然后解除映射,接着在映射其他部分。了解原理后让我们接着来看内核虚拟内存空间剩下的128M怎么布局
vmalloc动态映射区
首先来看下他的作用:
满足大块内存需求:随着系统运行,物理内存会碎片化,buddy 系统难以提供大的连续物理内存块。vmalloc 可将不连续的物理内存映射到内核中一段连续的虚拟地址空间,满足内核模块等对大块内存的需求,比如模块加载时所需的较大内存空间 。
访问高端物理内存:在 32 位系统中,直接映射区只能映射到 896MB 的物理内存,超过此范围的高端内存(Highmem)无法直接映射。vmalloc 是内核使用高端物理内存的主要方式之一,能将高端内存映射到内核虚拟地址空间,使内核可以访问这部分内存 。
灵活内存分配:kmalloc 分配的内存物理上连续,受限于物理内存连续情况及大小(一般不能超过 128KB )。而 vmalloc 分配的内存虚拟地址连续,物理地址无需连续,对物理内存的要求更灵活,可分配更大内存空间,适用于对物理内存连续性要求不高,但需要大内存的场景 。
核心就是在这个区域可以分配连续的虚拟内存(物理内存不连续)如下图:
因此也容易想到其存放内容:就是当内核模块需要较大内存空间且对物理内存连续性不高时就会使用其分配存储数据。
永久映射区
还是一样先看他的作用:
稳定访问高端内存:在 32 位 Linux 系统中,内核线性地址空间有限,无法直接映射全部物理内存。永久映射区用于将大于 896M 的高端物理内存映射到内核虚拟地址空间 。与临时映射不同,在调用 kunmap () 解除映射前,这种映射关系一直存在,为内核提供稳定的高端内存访问方式,适用于内核需长时间、稳定访问高端内存的场景 。
避免频繁映射操作:相比临时映射每次使用都要重新建立和解除映射,永久映射区一旦建立映射,可在一段时间内持续使用,减少因频繁建立和撤销映射带来的开销,提高内存访问效率 。比如内核中一些常驻模块,需长期访问高端内存数据,使用永久映射可避免重复映射操作消耗资源 。
因此也很容易想到其存放内容:内核中的常驻模块,如维护映射关系的页表项等
固定映射区
首先我们要知道啥是固定映射。与前面的直接映射和永久映射不同,这里的固定映射指的是虚拟地址固定、物理地址可变。下面让我们来看下他的作用:
提供精确地址映射:在 Linux 内核中,固定映射区(Fixed Mapping Region)提供了一种将物理地址映射到特定虚拟地址的机制 。这些映射关系在内核编译时或启动阶段就被预先定义好,每个虚拟地址对应特定的物理地址或功能,确保内核能通过固定的虚拟地址快速访问关键资源,无需在运行时动态计算或查找映射关系 。
支持时间敏感操作:由于映射关系固定且预先确定,内核在执行时间敏感的操作(如中断处理、系统初始化等 )时,可通过固定映射地址快速访问硬件寄存器或关键数据结构,减少地址转换带来的延迟,提高系统响应速度和性能 。
因此我们也可以推测其存放数据:
硬件寄存器映射:将硬件设备的寄存器映射到固定虚拟地址,使内核能直接通过这些地址控制硬件 。例如,在中断处理过程中,内核需要快速访问中断控制器的寄存器来读取中断状态、清除中断标志等操作,这些寄存器就会被映射到固定映射区的特定地址 ,以便内核迅速响应中断事件。
内核关键数据结构:存放一些内核关键数据结构,如用于获取当前 CPU 信息的地址 ,内核可通过固定映射区的特定地址快速获取当前 CPU 的编号、状态等信息,这在多 CPU 系统中对任务调度、资源分配等操作非常重要 。
临时映射区
还是先来看下他的作用:
短期高效访问高端内存:在 32 位 Linux 系统中,内核线性地址空间有限,高端内存(ZONE_HIGHMEM)无法被永久映射。临时映射区(也称为固定映射临时槽)提供了一种机制,允许内核在需要时临时将高端内存页映射到内核地址空间,使用完毕后立即释放映射,实现对高端内存的短期、高效访问 。
避免地址空间占用:与永久映射区不同,临时映射区的映射关系是短暂的,仅在需要时创建,使用后立即解除。这种方式避免了长期占用内核虚拟地址空间,使有限的地址资源能被更灵活地利用 。例如,当内核需要访问高端内存中的某个数据页时,可通过临时映射区快速建立映射,读取数据后立即释放映射,让该地址槽可被其他操作复用 。
支持关键内核操作:在某些内核操作(如中断处理、文件系统操作等 )中,需要快速访问高端内存中的数据,但操作时间短暂。临时映射区通过提供固定的映射槽(如 KMAP_DUP_MMU_LOCKED 等 ),使内核能在这些场景下高效地完成数据访问,而无需复杂的映射管理过程 。
也就是说该区主要的存放内容就是从高端内存处临时映射过来的数据页。
最后来看下在32位机器上的整体布局:
还是用的小林大佬的图(最底下还有保留区,截图截不全了)
64位机器内核虚拟内存空间布局
之前在32位机器上由于内核虚拟内存空间太小了,因此我们需要划分很多区域显得十分复杂。但到了64位机器上就不同了,空间大到都需要空洞了!因此整个分配就显得简单许多(也用不到高端内存那种动态映射了,直接全部映射过来都行哪里需要考虑这些?数值怪的魅力啊)
这里还是使用小林大佬的图来表示:
64位机器上大致和32位机器的分布还是相同的,不过64位的虚拟内存映射区就不用分的那么精细,内存太大直接用来映射就好了。
总结
本文是我在学完小林的深入理解虚拟内存管理后总结而成,希望可以帮助大家简要理解相关内容,如有问题也请大家指出改正!