Mach 虚拟内存
在内核管理最重要的资源中,出了CPU本身,就是内存了。Mach 和所有内核一样,代码中有很大一部分都在负责高效地管理内存(virtual memory,VM)。
虚拟内存架构
虚拟内存的抽象是Mach 中提供的最重要的机制,虚拟内存的抽象是通过内存对象(memory object)和分页器(pager)的形式提供的。和调度以及Mach 原语原语,我们这里要面对的是一个抽象层,这里抽象层提供了供上层使用的底层原语。在XNU 中,这个“上层”就是BSD层。
Mach 虚拟内存的实现非常全面而且通用。这部分由两个层次构成:一层是和硬件相关的部分,另一层构建在这一层之上,是和硬件无关的公共层。OS X 和 iOS 使用的几乎一样的底层机制,硬件无关层(以及之上的BSD 层中的机制)都是一样的,只有架构相关部分的代码改为适合ARM 虚拟内存的语义。
虚拟内存全貌
Mach 的 虚拟内存子系统可以说是和其要管理的虚拟内存一样复杂和充满了各种细节。然后从高层次看,可以看到两个层次:一个是虚拟内存的层次,一个是物理内存的层次。
虚拟内存层
虚拟内存这一层完全以一种机器无关的方式来管理虚拟内存。这一层通过几种关键的抽象表示虚拟内存:vm_map:表示任务地址空间内的一个或多个虚拟内存区域。每一个区域都是有一个独立的条目vm_map_entry 表示。这些条目又一个双向链表vm_map_links维护。
vm_map_entry:这是关键的数据结构,尽管只有在包含这个结构的映射的上下文中才会访问到这个结构。每一个vm_map_entry 都表示了虚拟内存中一块连续的区域(region)。每一个这样的区域都可以通过指定的访问保护权限进行保护(和虚拟内存页面采用同样的权限)。任务之间可以共享区域。vm_map_entry 通常指向一个vm_object,但是也可以指向一个嵌套的vm_map,即子映射(submap)。
vm_object:用于将vm_map_entry 和实际支撑的内存关联起来。这个数据结构包含一个vm_page 的链表,还包含一个用于访问正确分页器的Mach 端口(称为memory_object),通过这个分页器进行页面的获取或清理操作。
vm_page:vm_page 真正表示了vn_object 或部分vm_object(由vm_object中的偏移量表示)。vm_page 可以有多种状态:驻留内存、交换出、加密、干净和脏等。
Mach 允许使用多个分页器。事实上,默认就存在3~4个分页器。Mach 的分页器以外部实体的形式存在:是专业的任务,有点类似于其他系统上的内核交换(kernel-swapping)线程。Mach 的设计允许分页器和内核任务隔离开,设置允许用户态任务作为分页器。类似地,底层的后备存储也可以驻留在磁盘交换文件中(通过OS X 中的 default_pager 处理),可以映射到一个文件(由vnode_pager处理),可以是一个设备(由device_pager 处理)。注意:在Mach 中,每一个分页器处理的都是属于这个分页器的页面的请求,但是这些请求必须通过pageout 守护程序发出。这些守护程序(实际上就是内核线程)维护内核的页面表,并且判定哪些页面需要被清除出去。因此,这些守护程序维护的分页策略和分页器实现的分页操作是分开的。物理内存层:物理内存的页面处理的是虚拟内存到物理内存的映射,因为虚拟内存中的内容最终总要存储在某个地方。这一层面只有一个抽象,那就是pmap,不过这个抽象非常重要,因为提供了机器无关的接口。这个接口隐藏了底层平台的细节,底层的细节需要在处理器层次进行分页操作,其中要处理的对象包括硬件页表项(page table entry,PTE)、翻译查找表(translation lookaside buffer,TLB)等。
虚拟内存概述
每一个Mach 任务都要自己的虚拟内存空间,任务的struct task 中的 map 字段保存的就是这个虚拟内存空间。
vm_page_entry 中最关键的元素是vm_map_object,这是一个联合体,既可以包含另一个vm_map(作为子映射),也可以包含一个vm_object_t(由于这是一个联合体,所以具体的内容需要用布尔字段is_sub_map 来判断)。vm_object 是一个巨大的数据结构,其中包含了处理底层虚拟内存所需要的所有数据。vm_object的数据结构中的大部分字段都是用位表示的标志。这些字段表示了底层的内存状态(联动、物理连续和持久化等状态)和一些计数器(引用计数、驻留计数和联动计数等)。不过有3个字段需要特别注意:
- memq:vm_page 对象的链表,每一项都表示一个驻留内存的虚拟内存页面。尽管一个对象可以表示一个单独的页面,但是多数情况下一个对象可以包含多个页面,所以每一个页面关联到一个对象时都会有一个偏移值
- page:memory_object 对象,这是指向分页器的Mach 端口。分页器将未驻留内存的页面关联到后备存储,后备存储可以是内存映射的文件、设备和交换文件,后备存储保存了没有驻留内存的页面。换句话说,分页器(可以有多个)负责将数据从后备存储移入内存以及将数据从内存移出到后备存储。分页器对于虚拟内存子系统来说极为重要
- internal:vm_page 中众多标志位之一,如果这个位为真,那么表示这个对象是由内核内部使用的。这个标志位的值决定了对象中的页面会进入哪一个pageout队列
物理内存管理
尽管内核和用户空间一样,基本上只在虚拟地址空间内操作,但是虚拟内存最终还是要翻译为物理地址的。机器的RAM 实际上是虚拟内存中开的窗口,允许程序访问虚拟内存是有限的,而且通常是不连续的区域,这些区域的上线就是机器上安装的内存。而虚拟内存中其他部分则要么延迟分配,要么共享,要么被交换到外部存储中,外部存储通常是磁盘。
然而虚拟内存和具体的底层架构相关。尽管虚拟内存和物理内存的概念在所有架构上本周都是一样的,但是具体的实现细节则各有千秋。XNU 构建与Mach 的物理内存抽象层之上,这个的抽象层成为pmap。pmap 从设计上对物理内存提供了一个统一的接口,屏蔽了架构相关的区别。这对于XNU来说非常有用,因为XNU支持的物理内存的架构包括以前的PowerPC,现在主要是Intel,然后在iOS 中还支持ARM。
pmap 的 API
Mach 的pmap 层逻辑上由一下两个子层构成:
- 机器无关层:提供了一组基本上和及其无关的API。只要求及其支持基本的虚拟内存分页的概念。VM层只考虑pamp_t 并传递这个类型的数据即可,pmap_t 是一个指向struct pmap 是指针,实际上是一个void 指针
- 机器相关层:将pmap绑定到一个具体的实现,处理底层敬爱个的各种细节
MachZone
Mach(以及XNU)Zone的概念相当于Linux的内存缓存(memory cache)和Windows 的Pool。Zone 是一种内存区域,用于快速分配和是否频繁使用的固定大小的对象。Zone的API是内核内部使用的,在用户态不能访问。Mach中Zone的使用非常广泛。
Mach Zone 的结构
所有的zone 内存实际上都是在调用zinit( )时预先分配好的(zinit( )通过底层内存分配器kernel_memory_allocate( )分配内存)zalloc( )实际上是对REMOVE_FROM_ZONE 宏的封装,作用是返回zone的空闲列表中的下一个元素(如果zone已满,则调用kernel_memory_allocate( )分配这个zone在定义的alloc_size字节)。zfree( ) 使用的是相反功能的宏 ADD_TO_ZONE。这两个函数都会执行合理数量的参数检查,不过这些检查帮助不大:过去zone分配相关的bug已经导致了数据可以被黑客利用的内存损坏。zalloc( ) 最重要的客户是内核中的kalloc( ),这个函数从kalloc.*系列zone中分配内存。BSD的mcache机制也会从自己的zone中分配内存。BSD内核zone也是如此,BSD内核zone直接构建与Mach的zone之上。
引导期间的zone 设置
内核引导时,vm_mem_bootstrap( )通过两个调用设置zone:
- zone_bootstrap:设置主zone(“zones”),所有其他的zone数据都保存在这里面
- zone_init:初始化zone子系统的锁和页面(使用zone_page_init( ))
zone 垃圾回收
如果系统内存不足,zone可能会进行垃圾回收。垃圾回收是通过consider_zone_gc( ) 函数进行的,这个函数被 cm_pageout_garbage_collect 线程调用。consider_zone_gc( ) 可能会在以下某种情况中调用zone垃圾回收(zone_gc):
- zfree( ) 已经释放了zone中一个超过一个页面大小的元素,而且系统的vm_pool低
- 距离上一次zone_gc 运行已经有一段时间了,这个时间由zone_gc_tie_throttle 指定的
- 系统在休眠,而且调用了hibernate_flush_memory( )
垃圾回收是一个两趟的过程,首先系统先扫描所有的zone(跳过标记为不可回收的zone),检查这些zone的空闲列表,判断哪些对象是可以回收的。在第二趟中,将这些对象转换为页面:和非空闲对象共享一个页面的对象不能被释放,只有页面全部空闲的对象才能被释放。最后,当判定好了可以释放的页面之后,通过kmem_free( )释放。
zone 调试
zone是可以通过以下几种发那个是进行调试的:
- 编译时配置CONFIG_ZLEAKS:配置完CONFIG_ZLEAKS后,绘制每个struct zone 中多分配一些数据用于检查内存泄露。
- 开关zone元素检查:通过-zc引导参数
- 开关zone污染:通过-zp引导参数
- 在每一个任务中保存zone信息:通过-zinfop引导参数
- 指定zone日志引导参数:通过zlog参数指定要记录日志的zone的准确名字,通过zrecs指定日志中要保存的记录数目(最多不超过8000)
内核内存分配器
当内核代码真的需要分配内存时,特别是在自己的vm_map(即kernel_map)中分配内存时,就需要实际的分配函数了,内核的分配函数负责分配虚拟内存,并且做好后备物理内存页面的映射。下图是XNU中丰富的分配层次架构:
kernel_memory_allocate( )
所有的内核分配(除了连续物理内存的分配)的路径最终都会到达一个函数,那就是kernel_memory_allocate( )。这个函数执行实际的内存分配,同时对vm_map和pmap进行操作。
实际的物理存在分配是通过查看两个空闲列表中的一个进行的:一个列表是每一个处理器自有的空闲列表,另一个列表是低内存空闲列表。后面这张情况比较罕见,只有要求非常特殊的物理内存区域(小于16MB的内存区域)时才需要。vm_page_grablo( ) 函数调用 cpm_allocate( ),cmp_allocate( )函数直接从空闲列表中窃取页面,从而分配连续的物理内存。
kmem_alloc( ) 系列函数
Mach 中最常用的内存分配器就是kmem_alloc( ) 系列函数提供的分配器,都是对kernel_memory_allocate( )的封装
kmem_akkic系列函数都采用了同样的原型,接受三个参数,分别是map、一个地址指针的输入输出参数以及一个表示大小的参数。这些参数传入的map参数基本上都是kernel_map vm_map,除非要求的是可分页的内存。
还有一些是构建于kernel_memory_allocate( )的kmem_alloc_*函数。这些函数包括:
- kmem_alloc_contig( ):用于分配连续的物理内存(通过cmp_allocte( ))实现
- **kmem_alloc_pageable( )( 通过cm_map_enter( ) 实现):分配非联动的内存,非联动的内存可以在没有任何警告的情况下都会被交换出去
- kmem_alloc_pages( ):可以用于在已有对象中分配新的页,这个函数是对vm_page_alloc( )的封装(vm_page_alloc( )本本身是对kernel_memory_allocate( )中调用的vm_page_grab( )/vm_page_insert( )的封装)
kmem_alloc( )开销非常大,主要是因为需要后备物理页面的支持:底层调用的kernel_memory_allocate( ) 可能会永久阻塞。更多情况下,使用的是更快的alloc( )(这个分配器是基于更搞笑的zone机制实现的)
kalloc
一旦Mach中的zone都初始化之后,就可以用于快速的内核内部内寸分配了,这些内存分配是由 kalloc_( )系列函数完成的。这些函数从功能上等同于用户态的 maclloc( ) 。
kalloc函数是XNU中使用最为广泛的内存分配器,有很多函数封装了kalloc,其中包括:
- IOKit 的 IOMalloc:直接封装了kalloc( ),还调用了IOStatisticsAlloc 宏,用于记录内存分配
- Libkern的kern_os_malloc:直接封装了kalloc( ),会在分配的内存块之前追加上这个内存的大小。new 操作符就是对这个函数的封装。
- BSD的_MALLOC:用于BSD层的各种分配,也会在分配的内存块之前追加上这个内存块的大小
OSMalloc
Mach 还提供了另一组内存分配函数:OSMalloc。OSMalloc 中的关键概念就是标签,标签是一个透明的类型,必须首先分配。调用者持有了标签之后,就可以将这个标签传入任何一个OSMalloc的函数,那么OSMalloc 通过kmem_alloc_pageable 分配内存。否则,通过kalloc( )从联动内存中分配内存。标签本身保存在一个标签的链表中,每一个标签都有一个引用计数。分配内存会增加这个标签的引用计数。
Mach 分页器
进程的内存需求早晚会超过可用的RAM,系统必须有一种方法能够将不活动的页面备份起来并且从RAM中删除,腾出更多的RAM给活动的页面使用,至少暂时能够满足活动页面的需求。在其他操作系统中,这个工作专门是由专门的内核线程完成的。在Mach 中,这些专门的任务称为分页器(pager),分页器可以是内核线程,设置建议是外部的用户态(甚至远程)服务程序。
Mach分页器是一个内存管理器,负责将虚拟内存备份到某个特定类型的后备存储中。当内存容量不足,内存页面需要被交换出内存是,后备存储保存内存页面的内容:当换出的内存页面需要被使用时,将内存的页面恢复到RAM中。只有“脏”页面才需要进行上述的换出和换入,因为“脏”页面是在内存中修改过的页面,要从RAM中剔除时必须保存到磁盘中防止数据丢失。
要注意的是,这里提到的分页器仅仅实现了各自负责的内存对象的分页操作,这些分页器不会控制系统的分页策略。分页策略是有vm_pageout 守护线程负责的。
分页器的类型
iOS 和 OS X 中XNU 包含的分页器种类都是一样的。下表是XNU中的内存分页器的多种类型:
内存分页器 | 用途 |
---|---|
Default 分页器(默认) | 匿名内存 |
VNode 分页器 | 内存映射的文件 |
Device 分页器 | 设置后援的I/O |
Swapfile 分页器 | 处理特定的swapfile 映射的尝试,防止通过内存映射读取交换文件的数据 |
Apple-protected 分页器 | 苹果特有的扩展:对内存(二进制文件所在的内存)加密提供支持 |
Freezer(仅用于iOS) | iOS 特有的扩展,支持“冷冻”进程 |
分页策略管理
Pageout 守护程序
pageout 守护程序其实不是一个真的守护程序,而是一个线程。而且不是一般的线程:当kernel_bootstarp_thread( ) 完成内核初始化工作并且没有其他事情可做时,就调用vm_pageout( ) 成为了pageour 守护程序, vm_pageout( ) 永远不返回。这个线程管理页面交换的策略,判断哪些页面需要写回到其后备存储。
vm_pageout线程
vm_pageout( ) 函数讲kernel_bootstrap_thread 线程转变为pageout 守护程序,这个函数实际上重新设置了这个线程。设置完成后,调用vm_pageout_continute( ),这个函数周期性地唤醒并执行vm_page_scan( ),维护4个页面表(称为页面队列)。系统中的每一个vm_page 都通过pageq字段绑定这4个队列中的一个:vm_page_queue_active:最近活跃且驻留在内存中的页面
vm_page_queue_inactive:最近不活跃的页面,因此这些页面是页面换出的备选页面。根据这些页面的使用情况,可能会被换出,也可能会被重新激活
vm_page_queue_free:空闲页面表。这些页面曾经是非活跃的页面,但是被清理出去了(页面换出)
vm_page_queue_speculative:这些页面是通过预读策略投机映射的页面,这些页面是不活跃的,但是很可能很快会变为活跃页面
vm_pageout iothread线程
内部和外部iothread 线程各自检查一个vm_pageout_queue_t,这两个vm_page_queue_t 都是由vm_pageout( )初始化的。vm_pageout_queue_internal 专门用于内部的VM对象(即那些内核创建的VM对象,又默认分页器维护,internel 标准设置为true),vm_pageout_queue_external 用于其他所有的VM对象
这两个线程都使用了同一个线程函数vm_pageout_iothread_continue( ) 只不过操作的是不同的队列。这个函数(严格地说是一个续体)遍历自己的队列,出队队列中的每一个页面,获得其对应的分页器(通过vm_object 引用),然后调用器分页器的memory_object_data_return( ) 函数。这种方式可以使得pageout 线程和实际的分页操作实现解耦,分页器操作是由分页器单独负责的。垃圾回收线程
垃圾回收线程(vm_pageout_garbage_collect( ))偶尔会被vm_pageout_scan( ) 通过其续体唤醒。垃圾回收机制线程处理4个方面的垃圾回收工作:srack_collect( ):内核栈中的页面
consider_machine_collect( ):回收机器相关的页面
consider_buffer_cache_collect( ):如果确定定义了这个函数则调用这个函数。调用这通过vm_set_buffer_cleanup_callout( ) 定义这个函数。BSD 层在bufinit( ) 函数中注册了buffer_cache_gc( ) 函数
consider_zone_gc( ):zone 相关的垃圾回收
处理页错误
vm_pageout( ) 守护程序处理的只是交换的一个方向,从物理内存换出到后备存储。而另外一个方向是页面换入,则是发生在页面错误的时候处理的。这个逻辑非常复杂,简化为一下步骤:
- 如果陷阱的原因是页错误,那么机器级别的线程处理程序调用vm_fault( )
- vm_fault( ) 函数调用 vm_pageout_fault( )处理实际发生错误的页面,并且从后备存储中将这个页面返回
- PMAP_ENTER( ) 将页面插入任务的pmap
页错误有很多种,上述只是其中一种,其他类型的也错误还包括:
- 非法访问:访问应该没有映射到进程地址空间(即任务的vm_map)的地址。解引用应该野指针时通常会发生这种错误。发生这种错误时进程会收到SIGSEGV信号
- 页面保护错误:访问应该映射的地址,但是页面的保护掩码拒绝请求的访问
- 写时复制(copy-on-write):页面可以被标记可读,因此如果任务试图写入页面时,会捕捉到这个错误,在重新尝试写入操作之前可以将这个页面复制出来