虚拟内存的重要优势是让每个进程拥有专属虚拟地址空间,可经操作系统映射到物理内存。
内核空间与用户空间分配差异 :内核空间分配不受 CPU 运行进程影响,能快速满足,具全局性(vmaalloc() 除外,因其易引发页表同步问题)。进程通过页表项指针指向只读全局全零页面在其线性地址空间预留空间,写操作触发缺页中断,系统分配、初始化新全零页面并标记可写,新页面外观与原全零页面一致。
用户空间特性:用户空间非固定不变,每次上下文切换(除 4.3 节提到的 TLB 切换)后内容可能变化。内核需捕获用户空间异常并定位错误,相关内容在 5.5 节讨论。
章节内容规划:先探讨线性地址空间构成及各部分用途,再讨论进程相关结构及其配置、初始化与释放。接着介绍进程地址空间私有区域创建及相关函数,涉及异常处理、缺页中断等。最后阐述内核与用户空间间数据正确拷贝方式。
4.1 线性地址空间
从用户视角看,地址空间是平坦的线性地址空间,但内核视角下不同。地址空间分两部分:随上下文切换改变的用户空间部分,以及保持不变的内核空间部分,二者分界由PAGE_OFFSET
决定,在x86中其值为0xC0000000
,这意味着有3GB空间供用户使用,内核可映射剩余1GB空间。
系统为载入内核,需保留从PAGE_OFFSET
开始的8MB空间(两个PGD定位的内存大小),内核映象在内核页表初始化时放入此8MB空间,之后是供UMA体系结构使用的mem_map
数组 。mem_map
数组通常位于16MB位置,为避免使用ZONE_DMA
,其在不同体系结构中各部分位置分散。
PAGE_OFFSET
到VMALLOC_START - VMALLOC_OFFSET
是物理内存映射区域,大小由可用RAM决定,通过页表项映射物理内存到PAGE_OFFSET
开始的虚拟地址,为防止边界错误,在物理内存映射和vmalloc
地址空间间有大小为VMALLOC_OFFSET
的空隙 ,如32MB的x86系统中,VMALLOC_START
等于PAGE_OFFSET + 0x2000000 + 0x00800000
。
在内小内存系统,vmalloc
可在连续虚拟地址空间表示非连续内存分配,减去2个页面空隙后供vmalloc
使用;大内存系统中,vmalloc
区域更大,减去2个页面空隙后还引入2个区域,第1个是从PKMAP_BASE
到PKMAP_END
供kmap()
,kmap()
用于将高端内存页面映射到低端内存;第2个是从FIXADDR_START
至FIXADDR_TOP
的固定虚拟地址映射区域,供编译时需知道虚拟地址的子系统使用,如高级可编程中断控制器(APIC) ,在x86中静态地址为0xFFFFE000
,大小通过编译时的__FIXADDR_SIZE
计算。
vmalloc
、kmap
及固定映射区域限制了ZONE_NORMAL
大小,运行中的内核需这些函数,在地址空间顶端至少保留VMALLOC_RESERVE
大小区域,x86中为128MB ,这使得ZONE_NORMAL
通常只有896MB ,vmalloc
区域由线性地址空间上端1GB减去保留的128MB所得。
🌍 思考:线性地址空间和虚拟地址空间有什么关系?
从本质上讲,线性地址空间和进程的虚拟地址空间是一种映射关系中的不同视角,它们共同体现了现代操作系统中虚拟内存机制的两个核心角色:统一抽象和实际执行。
视角 | 线性地址空间 | 进程虚拟地址空间 |
所属层次 | 操作系统内核层 | 用户进程视角 |
结构特点 | 是连续的一段虚拟地址范围,通常是所有进程共享的逻辑视图,分为用户区和内核区 | 是每个进程独立的一组地址映射,用户认为是从0开始的扁平空间 |
管理单位 | 内核通过页表映射线性地址到物理地址 | 用户通过系统调用访问虚拟地址,最终由页表转换为物理地址 |
地址有效性 | 对于CPU来说,是访问内存的第一层翻译结果 | 对于进程来说,是访问内存的抽象接口 |
✅举个例子说明:
- 每个用户进程都认为自己的地址从
0x00000000
开始,一直到0xBFFFFFFF
(假设3GB),这是它的虚拟地址空间。 - 当进程执行某条访问地址
0x08048000
的指令时,CPU通过段机制或现代CPU的扁平模型直接得到线性地址0x08048000
。 - 然后通过页表将这个线性地址转换为对应的物理地址。
💡 线性地址是 CPU 统一访问的中间形式,虚拟地址则是进程对内存的抽象认知。
📌 PAGE_OFFSET 的意义
在 x86 架构中,内核定义 PAGE_OFFSET = 0xC0000000
,这意味着:
- 虚拟地址
0x00000000
到0xBFFFFFFF
:每个进程私有,称为用户空间。 - 虚拟地址
0xC0000000
到0xFFFFFFFF
:系统统一分配给内核空间,所有进程共享。
这段内核空间也属于线性地址空间的一部分,由内核统一管理,用于映射物理内存、vmalloc分配、IO映射等用途。
虚拟地址空间是操作系统给每个进程看的抽象世界;
线性地址空间是 CPU 实际执行指令时理解的逻辑世界;
二者的转换通过段表(如果使用段机制)+ 页表共同完成。
🧠 再思考:
这套机制的核心价值在于:
- 隔离性:每个进程拥有独立的虚拟空间。
- 共享性:通过映射同一物理页实现进程间共享或内核访问。
- 灵活性:内核可通过线性地址映射物理资源,不暴露底层细节。
需要我再配一张结构图说明关系吗?
4.2 地址空间的管理
管理结构
- 进程可用地址空间由
mm_struct
管理。每个进程地址空间包含多个按页面对齐的内存区域,这些区域不重叠,代表地址集合,包含用于保护或其他目的且相互关联的页面。 - 内存区域由
struct vm_area_struct
管理。一个区域可表示malloc()
使用的进程堆、内存映射文件(如共享库)、mmap()
分配的匿名内存区域等。区域中的页面可能未分配、已分配或常驻内存且可被交换出去。 - 若区域是文件映射,其
vm_file
字段会被设置,通过查看vm_file→f_dentry→d_inode→i_mapping
,可获取该区域代表的地址空间内容,此地址空间包含与文件系统相关的特定信息,用于磁盘上基于页面的操作。
相关系统调用(表4.1 内存区域相关的系统调用 )
fork()
:创建具有新地址空间的进程,页面标记为写时复制(COW),页面中断前由两个进程共享,出错时为进程复制COW页面,有时也被称为使一个COW
页面失效。
clone()
:允许创建共享上下文的新进程(Linux中线程实现方式)。若未设置CLONE_VM
位,clone()
将创建新地址空间,作用和fork()
一样 。
mmap()
:在进程线性地址空间中创建一个新区域。
mremap()
:重映射一个内存区域或改变其大小。若虚拟地址空间对映射不可用,移走该区域,除非该移动操作被调用者所禁止。 mummap()
:销毁部分或所有的区域。若已解除映射的区域位于已存在区域中间,该存在区域就分裂成两个单独的区域。
shmat()
:关联共享内存段到进程地址空间。
shmdt()
:从地址空间移除共享内存段。
execve()
:载入一个新的可执行文件,替换当前的地址空间。
exit()
:销毁一个地址空间和所有的区域。
🌍 思考:这些系统调用和内存区域有什么关系?
这些系统调用与内存区域(vm_area_struct
)之间的关系可以概括为:
在 Linux 中,一个进程的虚拟地址空间被组织为一组不重叠的内存区域(vm_area_struct
结构体管理),这些区域由 mm_struct
统一管理。
系统调用提供了创建、修改、映射或销毁这些内存区域的机制,因此它们实质上是对地址空间数据结构的动态管理操作。
🔄 系统调用和内存区域的关系:
系统调用 | 作用 | 与 |
| 创建新进程 | 拷贝 |
| 创建线程或新进程 | 若设置 |
| 创建新的内存区域 | 分配新的 |
| 调整区域大小 | 扩展/移动原 |
| 销毁区域 | 删除一个或多个 |
| 映射共享内存段 | 创建一个新的 |
| 解除共享内存映射 | 删除对应 |
| 加载新程序 | 销毁当前所有 |
| 退出进程 | 销毁整个 |
🗂️ 数据结构字符图(mm_struct
和 vm_area_struct
的关系)
进程 task_struct|--> mm (指向 mm_struct)||--> mmap ------------------> [vm_area_struct] (代码段)| start: 0x08040000| end: 0x08050000|--> [vm_area_struct] (数据段)| start: 0x08050000| end: 0x08060000||--> [vm_area_struct] (堆 heap,由 malloc() 等分配)| start: 0x08060000| end: 0x08100000||--> [vm_area_struct] (mmap 文件映射,如共享库)| start: 0x40000000| end: 0x40100000||--> [vm_area_struct] (栈 stack)start: 0xbfffe000end: 0xc0000000其他字段:||--> pgd -----> 页表|--> map_count: 区域数量|--> total_vm: 总虚拟内存页数|--> flags: 内存策略|--> mm_rb: 所有 vm_area_struct 的红黑树(用于快速查找)
说明:mmap
是一个链表头,连接所有 vm_area_struct
;同时它们也存在于 mm_rb
这棵红黑树中用于快速定位虚拟地址所在的区域。
✅ 总结
系统调用是用户态操作虚拟地址空间中内存区域(vm_area_struct)的接口,每个调用对应着创建、修改、销毁或映射某些区域。而 mm_struct
是这些区域的统一容器,维护它们的组织结构(链表 + 红黑树)。
4.3 进程地址空间描述符
概述
进程地址空间由mm_struct
结构描述,一个进程只有一个mm_struct
结构,且在进程用户空间中由多个线程共享,线程通过是否指向同一个mm_struct
判定。内核线程一般不需要mm_struct
,因为它们不会发生缺页中断或访问用户空间,仅vmalloc
空间的缺页中断是例外,且task_struct→mm
字段总为NULL
。某些任务如引导空闲任务,mm_struct
永不设置。
延迟TLB技术
因TLB刷新开销大,在像PPC这样的体系结构中,未访问用户空间的进程进行TLB刷新无意义,Linux采用“延迟TLB”技术避免。通过借用前个任务的mm_struct
,放入task_struct→active_mm
,避免调用switch_mm()
刷新TLB 。进入延迟TLB时,SMP上调用enter_lazy_tlb()
防止mm_struct
被SMP处理器共享,UP机器上是个空操作;进程退出且等待被父进程回收时,调用start_lazy_tlb()
函数。
mm_struct
结构及字段含义
定义在<linux/sched.h>
,结构包含众多字段:
struct mm_struct {struct vm_area_struct *mmap;rb_root_t mm_rb;struct vm_area_struct *mmap_cache;pgd_t * pgd;atomic_t mm_users;atomic_t mm_count;int map_count;struct rw_semaphore mmap_sem;spinlock_t page_table_lock;struct list_head mmlist;unsigned long start_code, end_code, start_data, end_data;unsigned long start_brk, brk, start_stack;unsigned long arg_start, arg_end, env_start, env_end;unsigned long rss, total_vm, locked_vm;unsigned long def_flags;unsigned long cpu_vm_mask;unsigned long swap_address;unsigned dumpable:1;mm_context_t context;
};
- **字段含义**:- `mmap` :地址空间中所有VMA(虚拟内存区域)的链表首部。- `mm_rb` :VMA排列在链表中且存于红黑树以加快查找,该字段表示树的根部。- `mmap_cache` :最后一次通过`find_vma()`找到的VMA存放处。- `pgd` :全局目录表的起始地址。- `mm_users` :访问用户空间部分的用户计数值。- `mm_count` :匿名用户计数值,数值1代表真实用户。- `map_count` :正被使用的vma数量。- `mmap_sem` :读写保护锁,通过`down_read()`获取信号量,写操作需`down_write()` ,更新VMA链表后获取`page_table_lock`锁。- `page_table_lock` :保护`mm_struct`中大部分字段,防止驻留集大小(RSS)计数和VMA被修改。- `mmlist` :所有`mm_struct`结构通过它链接在一起。- `start_code`等 :代码段、数据段、堆、栈、命令行参数、环境变量区域的起始和结束地址。- `rss` :驻留集大小,进程常驻内存页面数,全局零页面不包括在内。- `total_vm` :进程中所有vma区域的内存空间总和。- `locked_vm` :内存中被锁住的常驻页面数。- `def_flags` :只有`VM_LOCKED`一种可能值,指定默认情况下映射是否上锁。- `cpu_vm_mask` :代表SMP系统中所有CPU的掩码值,用于判定内部处理器中断(IPI)时CPU的TLB刷新操作。 - `swap_address` :换出整个进程时,记录最后一次被换出的地址。- `dumpable` :由`prctl()`设置,跟踪进程时有用。- `context` :跟体系结构相关的MMU上下文。
初始化与分配:
mm_init()
:初始化mm
结构,设置字段初始值,分配PGD,初始化自旋锁等。allocate_mm()
:从slab分配器中分配一个mm_struct
。mm_alloc()
:从slab中分配mm_struct
,并调用mm_init()
初始化。
复制与销毁
copy_mm()
:为新任务复制所需mm_struct
的完美副本,fork过程中用到。exit_mmap()
:遍历mm_struct
结构,解除所有与其关联的VMA映射。free_mm()
:返回mm
结构给slab分配器。
分配、初始化与销毁描述符
分配描述符:Allocate_mm()
是预处理宏,从slab分配器分配mm_struct
;mm_alloc()
从slab分配后调用mm_init()
初始化。
初始化描述符:系统中第一个mm_struct
通过init_mm()
初始化,后续mm_struct
以其为模板,通过copy_mm()
复制创建,copy_mm()
调用init_mm()
初始化与具体进程相关字段。第一个mm_struct
编译时通过宏INIT_MM()
静态设置:
#define INIT_MM(name) \
{ \.mm_rb = RB_ROOT, \.pgd = swapper_pg_dir, \.mm_users = ATOMIC_INIT(2), \.mm_count = ATOMIC_INIT(1), \.mmap_sem = __RWSEM_INITIALIZER(name.mmap_sem), \.page_table_lock = SPIN_LOCK_UNLOCKED, \.mmlist = LIST_HEAD_INIT(name.mmlist), \
}
销毁描述符:新用户通过atomic_inc(&mm→mm_users)增加使用计数,mmput()减少计数。mm_users变为0时,通过exit_mmap()释放所有映射区域和页表;mm_count通过mmdrop()减1 ,变为0时,mm_struct被释放。
🌍 思考:线程通过是否指向同一个mm_struct
判定?这句话是否精准?
在 Linux 中,如果多个线程共享地址空间,它们的 task_struct
中的 mm
字段会指向同一个 mm_struct
,这表示它们确实是线程(共享虚拟地址空间)。但不精准的地方在于:
判断线程不能仅靠 mm_struct 是否相同,因为:
- 内核线程:
mm
字段是NULL
,但它们也是线程。 - 创建进程时(如 clone)可以控制是否共享 mm_struct,这时
CLONE_VM
标志才是决定是否共享地址空间(是否共享 mm_struct)的关键。
更准确的判断线程方式是:
- 判断是否为同一个“线程组”的成员(即共享
tgid
); - 或者是否是通过
clone(CLONE_VM | CLONE_THREAD)
创建的; - 更底层,可以通过
mm_struct
(地址空间)+signal_struct
(信号处理)+tgid
(线程组 ID)共同判断是否是“线程”关系。
精准说法应为:
线程通常共享同一个 mm_struct
,以实现共享虚拟地址空间,但判断是否为线程应基于是否在同一线程组(共享 tgid),以及是否共享 mm_struct、files_struct、signal_struct 等资源,由 clone
系统调用的参数决定。
示例:Linux 中线程 vs 进程的区别
| 属| 属性 | 进程(fork) | 线程(clone with CLONE_VM | CLONE_THREAD) |
|--------------|--------------|-----------------------------|
| mm_struct | 不共享 | 共享 |
| files_struct | 不共享 | 可共享 |
| signal_struct | 不共享 | 共享 |
| tgid | 不同 | 相同 |
| 地址空间 | 不同 | 相同 |
4.4 内存区域
区域表示与特性
进程地址空间一般只用部分分离区域,由 vm_area_struct
表示,区域间不交叉,代表相同属性和用途的地址集合,如只读共享库所在区域。进程已映射区域可在 /proc/PID/maps
查看(PID 为进程号) 。
vm_struct
结构声明
<linux/mm.h>
中声明,包含众多字段:
基本地址相关:vm_start
(起始地址)、vm_end
(结束地址) 。
链接与查找相关:vm_next
(按地址空间次序链接 VMA 链表)、vm_rb
(用于红黑树存储 VMA 加速查找) 。
保护与属性相关:vm_page_prot
(PTE 保护标志位)、vm_flags
(保护和属性标志位,如 VM_READ
可读取、VM_WRITE
可写入等,具体见内存区域标志位表 ) 。
共享与操作相关:vm_next_share
(链接文件映射的 VMA 共享区域)、vm_pprev_share
(辅助指针)、vm_ops
(指向磁盘同步操作函数指针 ) 。
文件映射相关:vm_file
(指向被映射文件指针)、vm_pgoff
(被映射文件里对齐页面偏移) 。
其他:vm_raend
(预读窗口结束地址)、vm_private_data
(设备驱动私有数据存储)。
区域管理结构优势
所有区域按地址排序由 vm_next
链接成链表,查找空闲区间遍历链表即可。缺页中断时搜索指定区域,链表操作频繁,所以引入红黑树,平均搜索时间为 O(logN) ,红黑树节点地址左小右大,能快速定位区域。
4.4.1 内存区域的操作
- 操作函数及结构声明
VMA 提供open()
、close()
和nopage()
三个操作函数,通过vm_operations_struct
结构体的vma->vm_ops
提供这些操作。该结构体在<linux/mm.h>
中声明:
struct vm_operations_struct {void (*open)(struct vm_area_struct * area);void (*close)(struct vm_area_struct * area);struct page * (*nopage)(struct vm_area_struct * area,unsigned long address,int unused);
};
- 函数调用时机及作用
-
open()
和close()
:创建或删除区域时系统调用,如 system V 和 system v 的共享区域打开或关闭时,会执行额外操作,像 system V 中open()
回调函数会递增共享段 VMA 数量。nopage()
:在发生缺页中断时,do_no_page()
会调用此回调函数。它负责定位页面在高速缓存中的位置,或分配新页面并填充请求的数据,然后返回该页面 。
- 通用文件映射操作
多数被映射文件会用到generic_file_vm_ops
的vm_operations_struct
,它只注册了名为filemap_nopage()
的nopage()
函数,在mm/filemap.c
中声明:
static struct vm_operations_struct generic_file_vm_ops = {.nopage = filemap_nopage,
};
🌍 思考:VMA 链表是什么?有什么用?
🌍 思考:VMA 链表是什么?有什么用?
VMA 链表(Virtual Memory Area list)是指每个进程的地址空间中,用于管理虚拟内存区域(VMA)的链表结构,每个节点是一个 struct vm_area_struct
,描述一段具有相同属性的连续虚拟地址范围。
📌 什么是 VMA?
VMA(虚拟内存区域)代表一段具有相同属性的虚拟地址区间,比如:
- 一段程序代码(可执行、只读)
- 堆区(malloc分配的内存)
- 栈区(函数调用栈)
- mmap 映射的文件或匿名内存
- 共享内存(shm)
一个进程的虚拟地址空间,通常由多个这样的区域拼接构成。
📌 VMA 链表是什么?
在 Linux 中,每个进程的地址空间由 mm_struct
管理,其中有一个成员:
struct vm_area_struct *mmap;
指向该进程的VMA 链表头部。每个 vm_area_struct
结构体中又有一个vm_next
指针,用于将多个 VMA 串联起来形成链表。此外还有一个红黑树 mm->mm_rb
来加速地址查找(比如页错误处理时用)。
VMA 链表的作用:
用途 | 描述 |
管理内存区域 | 每个 VMA 表示一段具有统一访问权限和用途的虚拟地址区间 |
页错误处理 | 当访问某地址页错误时,内核遍历(或红黑树查找)VMA 链表以确认访问是否合法,并分配物理页 |
权限控制 | 每个 VMA 有访问权限标志(如只读、可执行、不可写),内核依此判断是否允许读写执行 |
内存映射文件支持 | VMA 可映射到文件,系统据此在访问时加载磁盘文件内容 |
内存回收 | 在 |
+--------------------+
mm_struct -> | mmap (VMA 链表头) |------++--------------------+ |↓ ↓+----------------------+ +-----------------------+| vm_area_struct #1 |-->| vm_area_struct #2 |---> ...| start = 0x08048000 | | start = 0x40000000 || end = 0x08050000 | | end = 0x40001000 || flags = r-xp | | flags = rw-p || file = /bin/bash | | anonymous mmap |+----------------------+ +-----------------------+
总结:VMA 链表是 Linux 内核用来描述和管理进程虚拟内存布局的核心数据结构。每个 VMA 是一个虚拟内存段,VMA 链表是这些段的有序集合。内核通过它来实现权限管理、内存分配、页错误处理、文件映射等核心功能。
4.4.2 有后援文件/设备的内存区域
address_space
结构声明及字段
在有后援文件的区域中,vm_file
引出address_space
结构,其在 <linux/fs.h>
中声明,包含与文件系统相关信息:
struct address_space {struct list_head clean_pages; // 不需后援存储器同步的干净页面链表struct list_head dirty_pages; // 需要后援存储器同步的脏页面链表struct list_head locked_pages; // 在内存中被锁住的页面链表unsigned long nrpages; // 地址空间中正在被使用且常驻内存的页面数struct address_space_operations *a_ops; // 操作函数集struct inode *host; // 文件的索引节点struct vm_area_struct *i_mmap; // 使用 address_space 的私有映射链表struct vm_area_struct *i_mmap_shared; // 该地址空间中共享映射的 VMA 链表spinlock_t i_shared_lock; // 保护此结构的自旋锁int gfp_mask; // 调用 __alloc_pages() 所要用到的掩码
};
address_space_operations
结构声明及字段
内存管理器需定期将信息写回磁盘,通过 a_ops
结构(address_space_operations
类型)调用相关函数,在 <linux/fs.h>
中声明:
struct address_space_operations {int (*writepage)(struct page *); // 把一个页面写到磁盘,写操作由具体文件系统代码完成int (*readpage)(struct file *, struct page *); // 从磁盘读一个页面int (*sync_page)(struct page *); // 同步一个脏页面到磁盘int (*prepare_write)(struct file *, struct page *, unsigned, unsigned); // 准备写操作,保证文件系统日志最新等int (*commit_write)(struct file *, struct page *, unsigned, unsigned); // 提交写操作,将数据提交给磁盘int (*bmap)(struct address_space *, long); // 映射磁盘块,与具体文件系统有关int (*flushpage)(struct page *, unsigned long); // 释放页面之前处理等待该页面的 I/O 操作int (*releasepage)(struct page *, int); // 释放页面之前刷新相关缓冲区int (*direct_IO)(int, struct inode *, struct kiobuf *, unsigned long, int); // 对索引节点执行直接 I/O 时使用int (*direct_fileIO)(struct file *, struct kiobuf *, unsigned long, int); // 对 struct file 进行直接 I/Ovoid (*removepage)(struct page *); // 页面从页面高速缓存中移除时使用
};
4.4.3 创建内存区域
- 系统调用流程
在 x86 中,通过系统调用mmap()
为进程创建新内存区域 。mmap()
会调用sysy_mmap2()
,sysy_mmap2()
进一步调用do_mmap2()
,这三个函数使用相同参数 。do_mmap2()
负责获取do_mmap_pgoff()
所需参数,do_mmap_pgoff()
是在体系结构中创建新区域的主要函数。 do_mmap2()
操作
-
- 首先清空
flags
参数中的MAP_DENYWRITE
和MAP_EXECUTABLE
位,因为 Linux 用不到这两个标志位。 - 若映射文件,
do_mmap2()
通过文件描述符查找对应的struct file
,并在调用do_mmap_pgoff()
前获得mmap_struct→mmap
信号量。
- 首先清空
do_mmap_pgoff()
操作
-
- 合法性检查:检查文件和设备被映射时,相应文件系统和设备的操作函数是否有效;检查映射大小是否与页面对齐,确保不在内核空间创建映射,且映射大小不超过
pgoff
范围和进程映射区域上限。 - 地址空间查找:若系统提供
get_unmapped_area()
函数(基于文件系统和设备 ),则调用它,否则使用arch_get_unmapped_area()
函数找出内存映射所需的空闲线性地址空间。 - 标志位处理:获得 VM 标志位,并根据文件存取权限对其进行检查。
- 区域修正与分配:若映射处有旧区域,系统会修正以便新映射使用;从分配器里分配一个
vm_area_struct
,并填充其各个字段 。 - 链接与调用:把新的 VMA 链接到链表中;调用与文件系统或设备相关的
mmap()
函数 。 - 返回:更新数据并返回。
- 合法性检查:检查文件和设备被映射时,相应文件系统和设备的操作函数是否有效;检查映射大小是否与页面对齐,确保不在内核空间创建映射,且映射大小不超过
4.4.4 查找已映射内存区域
在处理缺页中断等场景下,常需查找给定地址所属的虚拟内存区域(VMA),相关操作由以下函数完成:
find_vma()
函数原型:struct vm_area_struct *find_vma(struct mm_struct * mm, unsigned long addr)
功能:查找涉及给定地址的 VMA 。若该区域不存在,将返回离请求地址最近的 VMA 。执行时先检查mmap_cache
所代表的 VMA(mmap_cache
是上一次调用find_vma()
返回的结果 ),若不是目标 VMA ,则遍历mm_rb
存放的红黑树。若给定地址不包含在任何一个 VMA 中,函数返回离给定地址最近的 VMA ,调用此函数后需检查返回的 VMA 是否包含给定的地址。find_vma_prev()
函数原型:struct vm_area_struct *find_vma_prev(struct mm_struct * mm, unsigned long addr, struct vm_area_struct **pprev)
功能:和find_vma()
类似,但返回指向目标 VMA 的前一个 VMA 的指针。该函数一般在判断两个 VMA 能否合并以及删除一个内存区域时使用 ,常需调用find_vma_prepare()
。find_vma_prepare()
函数原型:struct vm_area_struct *find_vma_prepare(struct mm_struct * mm, unsigned long addr, struct vm_area_struct **pprev, rb_node_t **rb_link, rb_node_t **rb_parent)
功能:和find_vma()
类似,且还会在链表中查找运行中的 VMA,如同红黑树节点插入操作一样 。find_vma_intersection()
函数原型:struct vm_area_struct *find_vma_intersection(struct mm_struct * mm, unsigned long start_addr, unsigned long end_addr)
功能:返回与给定地址范围相交的 VMA 。主要在扩展一个内存区域且调用do_brk()
过程中使用,用于确保扩展的区域不会与原有的区域相交 。vma_merge()
函数原型:int vma_merge(struct mm_struct * mm, struct vm_area_struct * prev, rb_node_t *rb_parent, unsigned long addr, unsigned long end, unsigned long vm_flags)
功能:尝试扩展一个补胎的 VMA 以覆盖新的地址范围。若 VMA 不能向前拓展,会检查接下来的 VMA 是否可向后拓展以覆盖该地址范围,区域可能在无文件/设备映射且许可匹配时合并掉 。get_unmapped_area()
函数原型:unsigned long get_unmapped_area(struct file * file, unsigned long addr, unsigned long len, unsigned long pgoff, unsigned long flags)
功能:返回足以覆盖所请求内存大小的空闲内存区域地址,主要在创建新的 VMA 时使用 。insert_vm_area()
函数原型:void insert_vm_area(struct mm_struct *, struct vm_area_struct *)
功能:将一个新的 VMA 插入到线性地址空间中 。
4.4.5 查找空闲内存区域
映射内存时,需先获取足够大的空闲区域,get_unmapped_area()
函数用于此目的,其参数及作用如下:
struct file
:表示映射的文件或设备。pgoff
:表示偏移量。address
:表示请求区域的起始地址。length
:表示请求区域的长度 。flags
:表示此区域的保护标志位。
若映射设备(如视频卡),需先调用 f_op→get_unmapped_area()
,因为设备或文件有额外操作要求,通用代码无法满足,比如映射地址须对齐到特殊虚拟地址。若无特殊要求,系统调用体系结构相关函数 arch_get_unmapped_area()
,若体系结构未提供,则调用 mm/mmap.c
中通用版本函数。其调用关系为:get_unmapped_area
→ arch_get_unmapped_area
→ find_vma
。
4.4.6 插入内存区域
插入新区域主要函数是 insert_vm_struct()
,调用流程如下:
- 先调用
find_vma_prepare()
找到新区域在两个 VMA 间的位置及在红黑树中的正确节点。 - 然后调用
vma_link()
将新区域链接到 VMA 链表。
在 Linux 中,insert_vm_struct()
不增加 mm_struct
里 map_count
值 ,与之功能相似但常用的 __insert_vm_struct
会增加该值。
链接函数有 vma_link()
和 __vma_link()
。vma_link()
用于无加锁情况,调用 __insert_vm_struct
前需确保所需锁已获取;若是文件映射,还需锁住文件,然后 __vma_link()
将 VMA 添加到相关链表。
__vma_link()
分三步:
- 第一步:
__vma_link_list()
,把新区域插入单向 VMA 线性链表,若前驱为NULL
,则成为红黑树根节点。 - 第二步:
__vma_link_rb()
,把新区域插入 VMA 红黑树。 - 第三步:
__vma_link_file()
,处理文件共享映射,通过vm_pprev_share
和vm_next_share
把 VMA 插入 VMA 链表。
4.4.7 合并邻接区域
- 目的与函数:Linux 用
merge_segments()
或等价的vma_merge()
合并相邻内存区域,以减少 VMA 数量,因大量映射操作(如sys_mprotect()
)会创建众多映射,合并开销大,映射多耗时久。 - 应用场景:
vma_merge()
在sys_mmap()
映射匿名区域(可合并 )及do_brk()
给区域扩展并合并新分配区域时使用。条件是文件和权限匹配,且在无文件和设备映射、两区域权限相同下,区域可扩展。其他场景如sys_mprotect()
权限一致时判断是否合并,move_vma()
中可能将相似区域移到一起。
4.4.8 重映射和移动内存区域
- 扩展与收缩:系统调用
mremap()
扩展或收缩现有区域,由sys_mremap()
实现。扩展时可能移动区域,未指定MREMAP_FIXED
标志位时可移动。 - 移动操作:
do_mremap()
先调用get_unmapped_area()
找容纳扩展映射的空闲区域,再用move_vma()
移动旧 VMA 。move_vma()
检查新区域能否与相邻 VMA 合并,不能则创建新 VMA 并分配 PTE,再用move_page_tables()
复制旧映射页表项,最后用zap_page_range()
处理旧映射页面(交换或删除 )。
4.4.9 对内存区域上锁
- 系统调用:Linux 通过
mlock()
(由sys_lock()
实现 )锁住给定地址范围内存,sys_lock()
为地址范围创建 VMA 并设VM_LOCKED
标志位,再调用make_pages_present()
确保页在内存。mlockall()
(由sys_mlockall()
实现 )功能类似但影响进程所有 VMA ,二者依赖do_mlock()
处理查找受影响 VMA 等工作。 - 限制条件:VMA 页对齐,待锁定地址范围也需页对齐(向上取整 );不能超系统管理员设置的
RLIMIT_MLOCK
进程限制;每个进程每次只能锁住一半物理内存。
4.4.10 对区域解锁
- 解锁函数:系统调用
munlock()
和munlockall()
分别用于对内存区域解锁,由sys_munlock()
和sys_munlockall()
实现 。 - 实现原理:相较于上锁函数,解锁函数简单,依赖
do_mmap()
来修整区域,无需过多检查 。
4.4.11 上锁后修整区域
影响及修正函数:上锁或解锁时,VMA 会在 4 个方面受影响,需 mlock_fixup()
修正 。
- 当影响到所有 VMA 时,调用
mlock_fixup_all()
进行修正。 - 被锁住区域地址起始处,由
mlock_fixup_start()
处理,可能需分配新 VMA 映射新区域。 - 被锁住区域地址结束处,由
mlock_fixup_end()
处理。 mlock_fixup_middle()
处理映射区域中间部分,可能需分配 2 个新 VMA 。
注意事项:创建上锁的 VMA 时不合并,解锁后也不能合并。已上锁区域的进程没必要再次锁住同一区域,因为合并和分裂区域会消耗处理器计算资源 。
4.4.12 删除内存区域
删除函数及步骤:do_munmap()
负责删除内存区域,操作分三部分。
- 第一部分是修整红黑树,为保证红黑树有序,要删除的 VMA 先添加到
free
链表,再用rb_erase()
从红黑树中移除,若区域后续有变动,会以新地址重新添加到系统。 - 第二部分是释放和对应区域相关的页面和页表项,通过遍历
free
指向的 VMA 链表,调用remove_shared_vm_struct()
共享映射,部分删除时用unmap_fixup
处理,还会调用zap_page_range()
删掉相关页面 。 - 第三部分是如果生成了空洞就修整区域,最后调用
free_pgtables()
释放相关页表项,若页表项使用比例低则不释放,因释放代价高且可能再次被使用。
4.4.13 删除所有的内存区域
进程退出处理:进程退出时,exit_mmap()
函数负责删除与 mm_struct
相关联的所有 VMA 。在遍历 VMA 链表前刷新 CPU 高速缓存,依次删除每个 VMA 并释放相关页面,之后刷新 TLB 并删除页表项 。
4.5 异常处理
异常关注重点:在虚拟内存(VM)中,重点关注因页面中断产生的异常,而非除数为零等错误 。
错误引用情况
- 情况一:进程通过系统调用向内核传递无效指针,内核需安全处理,一般检查地址是否低于
PAGE_OFFSET
. - 情况二:内核使用
copy_from_user()
或copy_to_user()
读写用户空间数据时可能引发异常. - 异常表机制:编译时,链接器在代码段的
__ex_table
创建异常表,从__start_ex_table
开始,到__stop_ex_table
结束 。表中每个表项类型为exception_table_entry
,由可执行点和修整子程序组成。产生异常且缺页中断处理程序无法处理时,会调用search_exception_table()
检查是否为引起中断的指令提供了修整子程序,若系统支持,还会搜索每个模块的异常表。若在异常表中找到对应异常地址,将返回修整子程序位置并执行 。
4.6 缺页中断
问题背景与技术:进程线性地址空间页面不必常驻内存,Linux 采用请求调页技术解决非常驻页面问题。从后援存储器调页时,swapin_readahead()
会预取页面,若运气不佳,刚要用到的页面可能仅一次机会靠近交换区,因此 Linux 采用适合应用程序的预约式换页策略 。
缺页中断类型
- 主缺页中断:费时从磁盘读取数据时产生。
- 次缺页中断:也称轻微缺页中断,通过物理页面分配器分配页面帧等简单操作可解决 。Linux 通过
task_struct→maj_flt
和task_struct→min_flt
统计数目。
缺页中断原因及动作
异常 | 类型 | 动作 |
线性区有效但页面没分配 | 次要 | 通过物理页面分配器分配一个页面帧 |
线性区无效但是可扩展,如堆栈 | 次要 | 扩展线性区并分配一页 |
页面被交换但在交换高速缓存中 | 次要 | 从交换高速缓存中删除并分配给进程 |
页面被交换至后援存储器 | 主要 | 通过 PTE 中的信息查找页面并从磁盘读到内存中 |
写只读页面 | 次要 | 如果是 COW 页,就复制一页,标志为可写的并分配给进程,如果是写异常,就发送 SIGSEGV 信号 |
线性区无效或者没有访问权限 | 错误 | 给进程发送 SIGSEGV 信号 |
异常发生在内核地址空间 | 次要 | 如果异常发生在 |
异常发生在内核模态用户空间 | 错误 | 如果发生异常,表明内核不能从用户空间正确地复制数据,这是非常严重的内核 Bug |
4. 处理函数:每种体系结构注册处理缺页中断函数(如 |
4.6.1 处理缺页中断
当异常处理程序确定是有效内存区域内的有效缺页中断时,会调用 handle_mm_fault()
函数(与体系结构无关 ),其处理流程如下:
- 页表项检查与处理:
-
- 调用
pte_present()
检查页表项(PTE)标志位,确定页面是否在内存中,再调用pte_none()
检查 PTE 是否已分配。 - 若 PTE 未分配(
pte_none()
返回true
),调用do_no_page()
处理请求页面的分配;若页面已交换到磁盘,调用do_swap_page()
处理请求换页。特殊情况(在 12.4 节讨论 )下,换出页面属于虚拟文件时,由do_mmap_pgoff()
处理 。
- 调用
- 写页面判断与处理:检查 PTE 是否写保护,若是,调用
do_wp_page()
处理写时复制(COW)页面。写时复制页面指多个进程共享一页(常为父子进程 ),直到某进程写操作时才为其分配并复制单独页面,可通过页面所在区域 VMA 标志位可写但相应 PTE 不可写来识别 。若不是写时复制页面,检查其标志是否为脏。 - 页面读取状态检查:确定页面是否已读取,某些无 3 级页表的体系结构中,建立 PTE 并标志为新即可 。若请求的页表项不存在,会先分配页表项,再调用
handle_pte_fault()
。
4.6.2 请求页面分配
在进程首次访问页面时,需分配页面,通常由 do_no_page()
函数填充数据。若父 VMA 的 vm_ops
提供 nopage()
函数,就调用它填充数据,这对内存映射设备(如视频卡 )很关键。
- 处理匿名页面
-
- 若
vm_area_struct→vm_ops
字段未填充或无nopage()
函数,调用do_anonymous_page()
处理匿名访问。 - 第一次读:匿名页面无数据,系统一般用全零页
empty_zero_page
映射 PTE 且写保护,进程写时会引发缺页中断,mem_init()
负责将全局零页面归零。 - 第一次写:调用
alloc_page()
分配由clear_user_highpage()
用零填充的空闲页。分配成功,mm_struct
中 Resident Set Size(RSS)字段递增,部分体系结构中,页面插入进程空间时调用flush_page_to_ram()
保证高速缓存一致性,页面插入 LRU 链表,最后更新进程页表反映新映射。
- 若
- 处理文件/设备映射页
若页面被文件或设备映射,VMA 中vm_operation_struct
提供nopage()
函数。文件映射时,filemap_nopage()
分配页面并从磁盘读取相应数据;虚文件映射时,使用shmem_nopage()
。设备驱动程序提供各自nopage()
函数。 - 返回页面处理
返回页面时,先检查分配是否成功,失败则返回错误。接着检查提前 COW 失效是否发生(向页面写且受管 VMA 无VM_SHARED
标志时发生,指分配新页面并在减少nopage()
返回页面引用计数前交叉复制数据 )。利用pte_none()
检查确保使用的 PTE 不在页表中,SMP 环境中,若两个异常几乎同时发生且自旋锁未完全被异常获得,需立即检查。无竞争条件时,给 PTE 赋值,更新统计数据,调用体系结构相关钩子函数保证高速缓存一致性 。
4.6.3 请求换页
- 换页函数及原理:
do_swap_page()
函数负责将已交换至后援存储器的页面读入内存 。通过页表项(PTE)信息查找交换出去的页面,因页面可能被多个进程共享,一般先放至交换高速缓存,不能立即交换出去 。 - 反向映射技术(RMAP):2.5.x 后期版本和定制的 2.4.x 补丁引入 RMAP ,通过它,页面所映射的 PTE 组成链表,方便反向查找进程页表,避免查找所有进程页表浪费时间 。
- 交换高速缓存及处理:发生缺页中断时,若页面在交换高速缓存中,只需简单增加页面计数,放入进程页表并统计缺页中断发生次数 。
- 磁盘页面读取:若页面仅存于磁盘,
Linux
调用swapin_readahead()
读取该页面及其后续若干页面,读取页面数量由mm/swap.c
中page_cluster
变量决定 。内存小于 16MB 的机器,该值初始化为 2 或 3 ,除碰到坏或空的交换表项,通常读取数量是page_cluster
。
4.6.4 写时复制(COW)页
- 技术背景:以往创建进程时,将父进程地址空间完全复制给子进程耗时,因大量内容可能需从后援存储器交换入内存。为避免开销,Linux 采用写时复制(COW)技术。
- 实现原理:创建进程时,将父子进程的页表项(PTE)设为只读,进程写操作时引发缺页中断。Linux 识别 COW 页(PTE 写保护但 VMA 区域可写 ),通过
do_wp_page()
函数将复制页赋值给写进程,必要时为页面保留新交换插槽。采用此技术,创建进程时只需复制页表项。
4.7 复制到用户空间/从用户空间复制
安全问题与处理机制:直接访问进程地址空间内存不安全,因难以快速检测页面是否常驻内存。在 x86 中,地址无效时 MMU 抛出缺页中断,由缺页中断处理子程序捕获处理。__copy_user
捕获无效地址异常,调用 search_exception_table()
确定修整代码位置 。
函数 | 功能 |
| 从用户地址复制 |
| 从内核地址复制 |
| 复制数据到用户空间的匿名页面或 COW 页面,可通过内核虚拟地址避免 D - cache 别名问题 |
| 清零用户空间页面 |
| 从用户空间复制一个整型值到内核空间 |
| 从内核空间复制一个整型值到用户空间 |
| 从用户空间复制最长字节末尾 NULL 终结字符串到内核空间 |
| 返回包含终结符 NULL 在内的用户空间字符串长度,最大值为 |
| 检查用户空间内存块是否合法,不合法返回 0 |
|
这些函数主要用于Linux内核中处理用户空间和内核空间之间的数据交换,以及确保这些操作的安全性和正确性。它们在内核开发中非常重要,尤其是在驱动程序或需要访问用户空间数据的场景中。
copy_from_user
的实现与异常处理
copy_from_user
的实现展示了Linux内核如何安全地处理用户空间数据访问:
实现机制:
-
- 根据复制数据量是否为编译时常量,选择调用
__constant_copy_from_user
(固定大小)或__generic_copy_from_user
(动态大小)。 - 底层依赖
__copy_user_zeroing
(定义在<asm-i386/uaccess.h>
中),包含三部分:
- 根据复制数据量是否为编译时常量,选择调用
-
-
- 汇编计算与复制:计算实际复制字节数,触发缺页中断(若页面不在内存)或执行数据复制。
- 修整代码:处理异常情况(如无效地址),将未复制部分填充为0。
- 异常表(__ex_table):将复制指令映射到修整代码,记录在内核异常处理表中。
-
异常处理:
-
- 若访问无效地址,触发页面错误(
do_page_fault
)。 - 内核调用
search_exception_table
查找异常指令指针(EIP),跳转到修整代码。 - 修整代码将剩余字节置0,调整寄存器状态,返回未复制的字节数。
- 若访问无效地址,触发页面错误(
安全性:通过MMU(内存管理单元)和异常表机制,内核能安全访问用户空间,处理页面错误或非法地址访问。
这些函数是Linux内核与用户空间交互的核心工具:
安全数据传输:在用户空间和内核空间之间安全地传递数据,防止非法访问或越界。
内存管理:处理页面级操作(如清零、复制页面),支持内存分配和虚拟化。
异常处理:通过异常表和修整代码,确保内核在访问无效用户空间地址时不会崩溃。
驱动开发:设备驱动程序常使用这些函数读取用户输入或返回处理结果。
系统调用:系统调用实现中广泛使用这些函数来传递参数和结果。
注意事项:这些函数运行在内核态,需要确保用户空间地址的合法性(通过access_ok
)。它们通过异常处理机制(如__ex_table
)确保内核稳定性,防止用户空间的错误地址导致内核崩溃。开发者在使用这些函数时需仔细检查返回值(如copy_from_user
返回的未复制字节数),以处理潜在的错误。
🌍 思考:用户空间和内核空间交换是指虚拟地址空间的 内核地址空间的数据 和 用户地址空间的数据 互相拷贝吗?
用户空间和内核空间之间的数据交换确实是指虚拟地址空间中内核地址空间的数据和用户地址空间的数据互相拷贝的过程。
用户空间与内核空间的虚拟地址空间
现代操作系统(如Linux)中,每个进程都有自己的虚拟地址空间:
- 用户空间:进程运行用户态代码(如应用程序)的地址范围,通常是虚拟地址的低地址部分(例如在32位系统中为0到3GB)。
- 内核空间:操作系统内核运行的地址范围,通常是高地址部分(例如在32位系统中为3GB到4GB),所有进程共享同一内核空间。
用户空间和内核空间的隔离通过硬件内存保护机制(如MMU,内存管理单元)实现,用户态代码无法直接访问内核空间的地址。
2. 数据交换的本质
- 用户空间到内核空间:当用户程序通过系统调用(如
read
、write
)或设备驱动与内核交互时,需要将用户空间的数据(存储在用户态虚拟地址中)复制到内核空间。例如,用户程序传递一个缓冲区给内核,内核需要读取这些数据。 - 内核空间到用户空间:内核处理完成后,可能需要将结果(如读取的文件内容、设备状态)复制到用户空间的缓冲区,供用户程序使用。
- 这种数据交换是通过虚拟地址进行的,内核和用户空间的虚拟地址指向不同的物理内存区域,复制过程由内核提供的函数(如
copy_from_user
、copy_to_user
)完成。
3. 为什么需要复制?
- 安全性和隔离:用户空间和内核空间的隔离确保用户程序无法直接访问或修改内核数据,防止恶意代码破坏系统。直接访问对方地址空间会导致安全漏洞。
- 地址转换:用户空间和内核空间的虚拟地址不同,指向的物理内存也不同。复制过程通过MMU将数据从一个虚拟地址空间的物理内存移动到另一个。
- 异常处理:用户提供的地址可能无效(例如未映射的页面或越界访问)。复制函数(如
copy_from_user
)包含异常处理机制,确保内核在访问用户空间地址时不会崩溃。
4. 复制的实现
函数如copy_from_user
和copy_to_user
负责在用户空间和内核空间之间复制数据:
-
copy_from_user
:从用户空间的虚拟地址读取数据,复制到内核分配的内存(内核空间的虚拟地址)。copy_to_user
:将内核空间的数据复制到用户空间的虚拟地址。
这些函数会:
-
- 检查用户空间地址的有效性(通过
access_ok
)。 - 处理页面错误(通过异常表和修整代码)。
- 完成实际的数据复制(通常通过汇编优化,确保高效)。
- 检查用户空间地址的有效性(通过
- 例如,
copy_from_user(to, from, n)
会将用户空间地址from
的n
字节数据复制到内核空间地址to
,并返回未复制的字节数(若为0表示成功)。
具体例子
- 用户空间到内核空间:用户程序调用
write
系统调用,传递一个缓冲区(用户空间虚拟地址)。内核通过copy_from_user
将缓冲区内容复制到内核内存,然后写入设备(如磁盘)。 - 内核空间到用户空间:用户程序调用
read
系统调用,内核从设备读取数据到内核缓冲区,然后通过copy_to_user
将数据复制到用户程序提供的缓冲区(用户空间虚拟地址)。
关键点
虚拟地址的角色:用户空间和内核空间的虚拟地址是分开的,复制过程本质上是将数据从一个虚拟地址空间的物理内存映射移动到另一个虚拟地址空间的物理内存映射。
内核的控制:所有复制操作都在内核态执行,内核通过MMU和页表管理地址转换和访问权限。
安全性:复制函数确保即使用户提供无效地址,内核也能安全处理(通过触发缺页中断或返回错误)。
1