内存管理(3): 虚拟内存管理
3 虚拟内存管理
虚拟内存管理主要涉及3个方面: 虚拟空间分配; 物理页帧分配; 建立页表, 把两者关联起来.
这3者不一定要同时运作, 例如:
可以只分配虚拟地址, 暂不分配物理页帧和建立页表. 只有当CPU访问虚拟地址, 发现它没有关联到物理页帧时, 才会通过缺页处理(page fault handling)机制分配物理页帧并建立页表.
这种情况常见于用户空间, 在分配虚拟地址时, 我们一般会通过存储映射机制把这段虚拟地址空间与某个后备存储器关联起来, 后备存储器主要负责读取磁盘文件中的数据. 关于存储映射机制的细节后文会专门介绍.
也可以分配虚拟地址空间(但不是绝对的), 并分配物理页帧页建立页表, 这种情况常见于内核空间, 例如vmalloc.
3.1 页表
页表用于建立用户进程的虚拟地址空间和系统物理内存(内存、页帧)之间的关联。
每个进程都是自己独立的页表,页表向每个进程提供一致的虚拟地址空间。应用程序看到的地址空间是一个连续的内存区。该表也将虚拟内存页映射到物理内存,因而支持共享内存的实现(几个进程同时共享的内存),还可以在不额外增加物理内存的情况下,将页换出到块设备来增加有效的可用内存空间。
内核内存管理总是假定使用四级页表,而不管底层处理器是否如此。页表管理分为两个部分,第一部分依赖于体系结构,第二部分是体系结构无关的。有趣的是,所有数据结构和操作数据结构的几乎所有函数都是定义在特定于体系结构的文件中。
在以后几节里描述的数据结构和函数,通常基于体系结构相关的文件中提供的接口。定义可以在头文件arch/”arch”/include/asm/page.h 和arch/”arch”/include/asm/pgtable.h 中找到。
3.1.1 主要数据结构
在C语言中,void * 数据类型用于定义可能指向内存中任何字节位置的指针。该类型所需的比特位数目依不同体系结构而不同。所有常见的处理器(包括Linux支持的所有处理器)都使用32位或64位。
内核源代码假定void * 和unsigned long 类型所需的比特位数相同,因此它们之间可以进行强制转换而不损失信息。该假定的形式表示为sizeof(void *) == sizeof(unsigned long) ,在Linux支持的所有体系结构上都是正确的。
内存管理更喜欢使用unsigned long 类型的变量,而不是void 指针,因为前者更易于处理和操作。技术上,它们都是有效的。
虚拟地址的分解
根据四级页表结构的需要,虚拟内存地址分为5部分(4个表项用于选择页,1个索引表示页内位置)。
各个体系结构不仅地址字长度不同,而且地址字拆分的方式也不同。因此内核定义了宏,用于将地址分解为各个分量。
下图说明了如何用比特位移来定义地址字各分量的位置。BITS_PER_LONG 定义用于unsigned long 变量的比特位数目,因而也适用于指向虚拟地址空间的通用指针。

每个指针末端的几个比特位,用于指定所选页帧内部的位置。比特位的具体数目由PAGE_SHIFT指定。
PTE代表页表
PMD代表中间页目录
PUD代表上层页目录
PGD则是全局页目录
在各级页目录/页表中所能存储的指针数目,也可以通过宏定义确定。PTRS_PER_PGD 指定了全局页目录中项的数目,PTRS_PER_PMD 对应于中间页目录,PTRS_PER_PUD 对应于上层页目录中项的数目,PTRS_PER_PTE 则是页表中项的数目。
有的CPU的MMU只支持2级页表,两级页表的体系结构会将PTRS_PER_PMD 和PTRS_PER_PUD 定义为1。这使得内核的剩余部分感觉该体系结构也提供了四级页转换结构,尽管实际上只有两级页表:中间层页目录和上层页目录实际上被消去了,因为其中只有一项。由于只有极少数系统使用四级页表,内核使用头文件include/asm-generic/pgtable-nopud.h 来提供模拟上层页目录所需的所有声明。头文件include/asm-generic/pgtable-nopmd.h 用于在只有二级地址转换的系统上模拟中间层页表。
n比特位长的地址字可寻址的地址区域长度为2n字节。内核定义了额外的宏变量保存计算得到的值,以避免多次重复计算。相关的宏定义如下:
#define PAGE_SIZE (1UL << PAGE_SHIFT)
#define PUD_SIZE (1UL << PUD_SHIFT)
#define PMD_SIZE (1UL << PMD_SHIFT)
#define PGDIR_SIZE (1UL << PGDIR_SHIFT)
值2n在二进制中很容易通过从位置0左移n位计算而得到。内核在许多地方使用了这种技巧。
ARM 32位体系架构虚拟地址分解
前面介绍了虚拟地址分化的一些背景知识, 让我们来看看ARM 32位体系架构中是如何划分虚拟地址的.
虚拟地址的划分定义在头文件: arch/arm/include/asm/pgtable.h
......
#include <asm-generic/pgtable-nopud.h>
......
#ifdef CONFIG_ARM_LPAE
#include <asm/pgtable-3level.h>
#else
#include <asm/pgtable-2level.h>
#endif
include了pgtable-nopud.h, 说明没有PUD. 在pgtable-nopud.h中, 会将做如下两个定义:
#define PUD_SHIFTPGDIR_SHIFT
#define PTRS_PER_PUD1
这样一来, 就达到了消除PUD层的目的, 而且对上层代码是透明的, 上层代码任然可以使用4级页表结构。
如果没有定义CONFIG_ARM_LPAE, 则会使用2级页表结构. 看了几个ARM开发板的内核, 都没有定义这个开关, 所以我们着重看看2级页表.
头文件: arch/arm/include/asm/pgtable-2level.h
首先看看PGD的定义:
#define PGDIR_SHIFT 21
#define PTRS_PER_PGD 2048
#define PGDIR_SIZE (1UL << PGDIR_SHIFT)
#define PGDIR_MASK (~(PGDIR_SIZE-1))
全局页目录的SHIFT是21, 所以全局也目录总共有211=2048个表项. 也就是如果我们用一个数组来存储全局页目录, 那这个数组总共有2048个元素, 如果每个元素占4个字节, 那全局也目录总共就会占用8KB的物理内存, 大小还是可以接受的!
在来看看PMD的定义:
#define PMD_SHIFT 21
#define PTRS_PER_PMD 1
#define PMD_SIZE (1UL << PMD_SHIFT)
#define PMD_MASK (~(PMD_SIZE-1))
PMD_SHIFT与PGDIR_SHIFT一页, PMD的表项只有1个, 所以PMD被消除了
消除了PUD和PMD, 就只剩下PGD和PTE了, 所以称为2级页表, 接下来看看PTE
#define PTRS_PER_PTE 512
PTRS_PER_PTE代表每个页表有512个表项, 如果用数组表示页表的话, 那数组元素的个数就是512. 数组的每个元素存储的就是物理内存的地址, 32位系统上一个元素会占用4个字节.
这个值是怎么来的? PTRS_PER_PTE = 2((PMD_SHIFT+1) – PAGE_SHIFT), PAGE_SHIFT被定义为12(下文马上介绍), 所以PTRS_PER_PTE = 210 = 512.
最后, 我们再来看看PAGE_SHIFT的定义, PAGE_SHIFT代表系统中一个页面的大小, 不管是物理地址空间还是虚拟地址空间, 都会按照这个大小划分为一个个页.
头文件: arch/arm/include/asm/page.h
/* PAGE_SHIFT determines the page size */
#define PAGE_SHIFT 12
#define PAGE_SIZE (_AC(1,UL) << PAGE_SHIFT)
#define PAGE_MASK (~((1 << PAGE_SHIFT) - 1))
每个页面的大小是212 = 4KB.
综述, ARM 32位系统中, 把地址空间划分为4KB大小的页面, 并使用2级页表结构(PGD+PTE)来管理所有的虚拟地址空间的页面. 全局页目录PGD总共有2048项, 每个页表PTE有512项.
页表数据结构和相关函数
上述定义已经确立了各个页目录/页表的数目,但没有定义其结构。所谓结构就是指描述页目录/页表中每个元素的数据结构。对于32位系统, 每个元素都用一个u32的整数表示; 对于64位系统, 每个元素都用一个u64的整数表示.
内核提供了4个数据结构(定义在page.h中)来表示页目录/页表的结构。
pgd_t 用于全局页目录项。
pud_t 用于上层页目录项。
pmd_t 用于中间页目录项。
pte_t 用于直接页表项。
对于ARM系统架构, 上述几个数据结构定义如下:
//arch/arm/include/asm/pgtable-2level-types.h
typedef u32 pteval_t;
typedef u32 pmdval_t;
/*
* These are used to make use of C type-checking..
*/
typedef struct { pteval_t pte; } pte_t;
typedef struct { pmdval_t pmd; } pmd_t;
typedef struct { pmdval_t pgd[2]; } pgd_t;
typedef struct { pteval_t pgprot; } pgprot_t;
使用struct 而不是基本类型,以确保表项的内容只能由相关的辅助函数处理,而决不能直接访问。表项也可以由几个基本类型变量构成。在这种情况下,内核就必须使用struct 了。(例如,在IA-32处理器使用PAE模式时,将pte_t 定义为typedef struct { unsigned long pte_low, pte_high;} 。32个比特位显然不够寻址全部的内存,因为该模式可以管理多于4 GiB的内存。换句话说,可用的内存数量可以大于处理器的地址空间。但由于指针仍然只有32个比特位宽,必须为用户空间应用程序选择扩大的内存空间的一个适当子集,使每个进程仍然只能看到4 GiB地址空间。)
虚拟地址分为几个部分,用作各个页表的索引,这是我们熟悉的方案。根据使用的体系结构字长不同,各个单独的部分长度小于32或64个比特位。从给出的内核源代码片段可以看出,内核(以及处理器)使用32或64位类型来表示表项(不管页表的级数)。这意味着并非表项的所有比特位都存储了有用的数据,即下一级表的基地址。多余的比特位用于保存额外的信息。
下表给出了用于分析页目录/页表的标准函数, 根据不同的体系结构, 一些函数可能实现为宏而另一些则实现为内联函数. 在下文中我对二者不作区分.
|
Comment |
|
|
pgd_val pud_val pmd_val pte_val pgprot_val |
将pte_t 等类型的变量转换为unsigned long 整数 |
|
__pgd __pud __pmd __pte __pgprot |
pgd_val 等函数的逆:将unsigned long 整数转换为pgd_t 等类型的变量 |
|
pgd_index(virtual_addr) pud_index(virtual_addr) pmd_index(virtual_addr) pte_index(virtual_addr) |
给定虚拟地址, 从虚拟地址中提出出相关表项的索引. 例如: #define pgd_index(addr)((addr) >> PGDIR_SHIFT) 假设PGD[]是全局页目录数组, 那么PGD[pgd_index(addr)]就能得到下一级页表的起始地址. |
|
pgd_present pud_present pmd_present pte_present |
检查对应项的_PRESENT 位是否设置。如果该项对应的页目录或页表在内存中,则会置位 |
|
pgd_none pud_none pmd_none pte_none |
对xxx_present 函数的值逻辑取反。如果返回true ,则对应的页目录或页表不在内存中 |
|
pgd_clear pud_clear pmd_clear pte_clear |
删除传递的表项。通常是将其设置为零 |
|
pgd_bad pud_bad pmd_bad |
检查中间层页表、上层页表、全局页表的项是否无效。如果函数从外部接收输入参数,则无法假定参数是有效的。为保证安全性,可以调用这些函数进行检查 |
|
pud_page pmd_page pte_page |
pud_page返回一个物理地址, 该物理地址指向pud页目录所在的位置 pmd_page也返回一个物理地址, 指向pte页表所在的位置 pte_page则会返回物理页帧的地址 |
|
PAGE_ALIGN |
是另一个每种体系结构都必须定义的标准宏(通常在page.h 中)。它需要一个地址作为参数,并将该地址“舍入”到下一页的起始处。如果页大小是4 096,该宏总是返回其倍数。PAGE_ALIGN(6000)=8192 = 2×4 096, PAGE_ALIGN(0x84590860)=0x84591000 = 542 097 ×4 096。 由于CPU高速缓存也是按页缓存物理页面的,为了更好的利用处理器高速缓存资源,将地址对齐到页边界是很重要的。 |
pte flags
最后一级页表中的项(pte)不仅包含了指向页的内存位置的指针,还在上述的多余比特位包含了与页有关的附加信息。尽管这些数据是特定于CPU的,它们至少提供了有关页访问控制的一些信息。下列位在Linux内核支持的大多数CPU中都可以找到。
_PAGE_PRESENT指定了虚拟内存页是否存在于内存中。页不见得总是在内存中,页可能换出到交换区。
如果页不在内存中,那么页表项的结构通常会有所不同,因为不需要描述页在内存中的位置。相反,需要信息来标识并找到换出的页。
CPU每次访问页时,会自动设置_PAGE_ACCESSED 。内核会定期检查该比特位,以确认页使用的活跃程度(不经常使用的页,比较适合于换出)。在读或写访问之后会设置该比特位。
_PAGE_DIRTY 表示该页是否是“脏的”,即页的内容是否已经修改过。
_PAGE_FILE 的数值与_PAGE_DIRTY 相同,但用于不同的上下文,即页不在内存中的时候。显然,不存在的页不可能是脏的,因此可以重新解释该比特位。如果没有设置,则该项指向一个换出页的位置。如果该项属于非线性文件映射,则需要设置_PAGE_FILE ,且将在《非线性映射》一节讨论。
如果设置了_PAGE_USER ,则允许用户空间代码访问该页。否则只有内核才能访问(或CPU处于系统状态的时候)。
_PAGE_READ 、_PAGE_WRITE 和_PAGE_EXECUTE 指定了普通的用户进程是否允许读取、写入、执行该页中的机器代码。
内核内存中的页必须防止用户进程写入。
但即使属于用户进程的页,也无法保证可以写入,这可能是有意如此,也可能是无意偶合。例如,其中可能包含了不能修改的可执行代码。
对于访问权限粒度不那么细的体系结构而言,如果没有进一步的准则可区分读写访问权限,则会定义_PAGE_RW 常数,用于同时允许或禁止读写访问。
IA-32和AMD64提供了_PAGE_BIT_NX ,用于将页标记为不可执行的(在IA-32系统上,只有启用了可寻址64 GiB内存的页面地址扩展(page address extension,PAE)功能时,才能使用该保护位。例如,它可以防止执行栈页上的代码。否则,恶意代码可能通过缓冲区溢出手段在栈上执行代码,导致程序的安全漏洞。NX位无法防止缓冲器溢出,但可以抑制其效果,因为进程会拒绝执行恶意代码。当然,如果体系结构本身对内存页提供了良好的访问授权设置,也可以实现同样的效果,某些处理器就是这样(令人遗憾的是,这些处理器不怎么常见)。
每种体系结构都必须提供两个东西,使得内存管理子系统能够修改pte_t 项中额外的比特位,即保存额外的比特位的__pgprot数据类型,以及修改这些比特位的pte_modify 函数。上述的flags可用于选择适当的比特位。
内核还定义了各种函数,用于查询和设置内存页与体系结构相关的状态。某些处理器可能缺少对一些给定特性的硬件支持,因此并非所有的处理器都定义了所有这些函数。
pte_present 检查页表项指向的页是否存在于内存中。例如,该函数可以用于检测一页是否已经换出。
pte_dirty 检查与页表项相关的页是否是脏的,即其内容在上次内核检查之后是否已经修改过。要注意,只有在pte_present 确认了该页可用的情况下,才能调用该函数。
pte_write 检查内核是否可以写入到页。
pte_file 用于非线性映射,通过操作页表提供了文件内容的一种不同视图(详见《非线性映射》)。该函数检查页表项是否属于这样的一个映射。
只有在pte_present 返回false 时,才能调用pte_file ,即与该页表项相关的页不在内存中。
由于内核的通用代码对pte_file 的依赖,在某个体系结构并不支持非线性映射的情况下也需要定义该函数。在这种情况下,该函数总是返回0。
下表综述了所有用于操作PTE项的函数,这些函数经常3个1组,分别用于设置、删除、查询某个特定的属性。
|
Comment |
|
|
pte_present |
页在内存中吗 |
|
pte_read pte_rdprotect pte_mkread |
从用户空间可以读取该页吗 清除该页的读权限 设置读权限 |
|
pte_write pte_wrprotect pte_mkwrite |
可以写入到该页吗 清除该页的写权限 设置写权限 |
|
pte_exec pte_exprotect pte_mkexec |
该页中的数据可以作为二进制代码执行吗 清除执行该页中二进制数据的权限 允许执行页的内容 |
|
pte_dirty pte_mkclean pte_mkdirty |
页是脏的吗?其内容是否修改过 “清除”页,通常是指清除_PAGE_DIRTY 位 将页标记为脏 |
|
pte_file |
该页表项属于非线性映射吗 |
|
pte_young pte_mkold pte_mkyoung |
访问位(通常是_ PAGE_ACCESS )设置了吗 清除访问位 设置访问位,在大多数体系结构上是_PAGE_ACCESSED |
不是所有的CPU硬件上都支持上述操作,例如IA-32处理器只支持两种控制方式,分别允许读和写。在这种情况下,体系结构相关的代码会试图尽力模仿所需的语义。
3.1.2 API
下面列出了用于创建新也目录/页表的所有函数,所有体系结构都必须实现表中的函数,以便内存管理代码创建和销毁页表。
|
Functions |
Comment |
|
mk_pte |
创建一个页表项。必须将page 实例和所需的页访问权限作为参数传递 |
|
pte_page |
获得页表项描述的页对应的page 实例地址 |
|
pgd_alloc pud_alloc pmd_alloc pte_alloc |
分配并初始化可容纳一个完整页目录或者页表的内存(不只是一个表项),每个页目录或者页表需要多大内存空间可以根据前文的知识计算出来 |
|
pud_free pmd_free pte_free |
释放页目录或页表占据的内存 |
|
set_pgd set_pud set_pmd set_pte |
设置页表中某项的值 |
3.2 内核虚拟地址空间
3.2.1 地址空间的划分
第1章提到,在IA-32系统上内核通常将总的4 GiB可用虚拟地址空间按3 : 1的比例划分。低端3 GiB用于用户状态应用程序(每个应用程序独享3GB的虚拟地址空间),而高端的1 GiB则专用于内核。(3 : 1并不是绝对的,有的系统采用了别的划分方式,但除了有特殊目的,一般都是3 : 1划分)
这样划分主要的动机如下:
在用户应用程序的执行切换到核心态时(这总是会发生,例如在使用系统调用或发生周期性的时钟中断时),内核必须装载在一个可靠的环境中。因此有必要将地址空间的一部分分配给内核专用。
物理内存页则映射到内核地址空间的起始处,以便内核直接访问,而无需复杂的页表操作。
虽然用于用户层进程的虚拟地址部分随进程切换而改变,但是内核部分总是相同的。
按3∶1的比例划分地址空间,只是约略反映了内核中的情况,内核地址空间自身又分为各个段。细节如下图所示:

这张图在前文曾多次被引用,注意该图只是标明了虚拟地址空间的各个区域的用途,这与物理内存的分配无关。
地址空间的第一段用于将系统的物理内存页映射到内核的虚拟地址空间中。由于内核地址空间从偏移量0xC0000000 开始,即经常提到的3 GiB,每个虚拟地址x都对应于物理地址x —0xC0000000,因此这是一个简单的线性平移。
直接映射区域从0xC0000000 到high_memory 地址,high_memory 准确的数值稍后讨论。如果物理内存的总容量超过high_memory,则内核无法直接映射所有的物理内存,这种情况下,内核引入了高端内存的概念: 0xC0000000– highmem属于normal区; highmem – 4GB属于highmem区。
对于normal区,内核移植的每个体系结构都必须提供两个宏,用于物理和虚拟地址之间的转换(最终这是一个平台相关的任务)
__pa(vaddr) 返回与虚拟地址vaddr 相关的物理地址
__va(paddr) 则计算出对应于物理地址paddr 的虚拟地址
两个函数都用void 指针和unsigned long 操作,因为这两个数据类型对表示内存地址是同样适用的。
切记,这些函数不适用于处理虚拟地址空间中的任意地址,只能用于其中的一致映射部分!
地址空间的第二段: highmem – 4GB. 如上图所示, 该部分有3个用途
vmalloc区: 虚拟内存中连续、但物理内存中不连续的内存区,可以在vmalloc区域分配。该机制通常用于用户过程,内核自身会试图尽力避免非连续的物理地址。内核通常会成功,因为大部分大的内存块都在启动时分配给内核,那时内存的碎片尚不严重。但在已经运行了很长时间的系统上,在内核需要物理内存时,就可能出现可用空间不连续的情况。此类情况,主要出现在动态加载模块时。
VMALLOC_START 和VMALLOC_END 定义了vmalloc区域的开始和结束. 这两个值没有直接定义为常数:
VMALLOC_START取决于在直接映射物理内存时,使用了多少虚拟地址空间内存(因此也依赖于上文的high_memory 变量)。内核还考虑到下述事实,即两个区域之间有至少为VMALLOC_OFFSET 的一个缺口,而且vmalloc区域从可被VMALLOC_OFFSET 整除的地址开始。
VMALLOC_END取决于是否启用了高端内存支持。如果没有启用,那么就不需要持久映射区域,此时VMALLOC_END = (FIXADDR_START - 2 * PAGE_SIZE); 否则VMALLOC_END (PKMAP_BASE - 2 * PAGE_SIZE). 总是会留下两页, 作为vmalloc区域与这两个区域之间的分割.
PKMAP区: 持久映射用于将高端内存域中的非持久页映射到内核中.
FIXADDR区: 固定映射是与物理地址空间中的固定页关联的虚拟地址空间项.
在ARM体系架构中, VMALLOC_END的地址被定义为常数, high_memory的值与VMALLOC区域的大小有关. (细节见2.3.3 《确定低端/高端内存的分界值》).
而持久映射区在ARM体系架构中被放到了PAGE_OFFSET前面, 固定映射区放置于VMALLOC_END之后. 下文简单讨论KMAP & FIXADDR区.
3.2.2 ARM 64内核虚拟地址空间
最近接触ARM64的芯片, 简单看了下内核代码, 在这里把已经知晓的内核虚拟地址空间划分列出来, 可能不准确, 后续更新. (add @ 2017-3-19)
//arch/arm64/include/asm/memory.h
/*
* PAGE_OFFSET - the virtual address of the start of the linear map (top
* (VA_BITS - 1))
* KIMAGE_VADDR - the virtual address of the start of the kernel image
* VA_BITS - the maximum number of bits for virtual addresses.
* VA_START - the first kernel virtual address.
* TASK_SIZE - the maximum size of a user space task.
* TASK_UNMAPPED_BASE - the lower boundary of the mmap VM area.
*/
#define VA_BITS (CONFIG_ARM64_VA_BITS)
#define VA_START (UL(0xffffffffffffffff) << VA_BITS)
#define PAGE_OFFSET (UL(0xffffffffffffffff) << (VA_BITS - 1))
#define KIMAGE_VADDR (MODULES_END)
#define MODULES_END (MODULES_VADDR + MODULES_VSIZE)
#define MODULES_VADDR (VA_START + KASAN_SHADOW_SIZE)
#define MODULES_VSIZE (SZ_128M)
#define VMEMMAP_START (PAGE_OFFSET - VMEMMAP_SIZE)
#define PCI_IO_END (VMEMMAP_START - SZ_2M)
#define PCI_IO_START (PCI_IO_END - PCI_IO_SIZE)
#define FIXADDR_TOP (PCI_IO_START - SZ_2M)
#define TASK_SIZE_64 (UL(1) << VA_BITS)
//arch/arm64/include/asm/pgtable.h
#define VMALLOC_START (MODULES_END)
#define VMALLOC_END (PAGE_OFFSET - PUD_SIZE - VMEMMAP_SIZE - SZ_64K)
#define vmemmap ((struct page *)VMEMMAP_START - (memstart_addr >> PAGE_SHIFT))
进程虚拟地址空间: 0 - TASK_SIZE_64
内核虚拟地址空间: VA_START - 0xffffffffffffffff
- VA_START – (VA_START + KASAN_SHADOW_SIZE) : KASAN shadow memory虚拟地址空间
- MODULES_VADDR - MODULES_END : 内核模块虚拟地址空间. 大小是MODULES_VSIZE(SZ_128M).
注意#define MODULES_VADDR(VA_START + KASAN_SHADOW_SIZE)
- VMALLOC_START - VMALLOC_END : the virtual address of the start of the kernel image, 也就是vmalloc区域.
注意#define VMALLOC_END(MODULES_END)
#define VMALLOC_END(PAGE_OFFSET - PUD_SIZE - VMEMMAP_SIZE - SZ_64K)
- VMEMMAP_START - PAGE_OFFSET : 这段区域的大小是VMEMMAP_SIZE, 它并不是紧接着VMALLOC_END开始的, 从VMALLOC_END到VMEMMAP_START之间还有一些别的区域(现在还不知道有哪些, 以后再补充).
要理解这段区域的作用, 要稍微费点事. 内核的内存有3种模型: FLAT memory model, Discontiguous Memory Model, Sparse Memory Model. 为简化理解, 本文默认是以FLAT memory model为背景.不过随着内核发展, 慢慢的转向Sparse memory model. 关于这3种模式, 这篇文章写的很不错, 可以参考一下: http://www.wowotech.net/memory_management/memory_model.html(链接内容已copy至《1.5 Linux 内核中的三种memory model》).
在FLAT模型中, mem_map[]数组用来存储每一个struct page结构, 通过这个数组, 可以完成物理地址和struct page的相互转换(pfn_to_page/page_to_pfn).
在Sparse模型中, 所有的struct page存储在VMEMMAP_START定义的地址空间内, 与之对应的pfn_to_page和page_to_pfn定义如下:

Vmemmap类似于数组的启动地址, pfn就是index. vmemmap的定义如下:

毫无疑问,我们需要在虚拟地址空间中分配一段地址来安放struct page数组(该数组包含了所有物理内存跨度空间page),也就是VMEMMAP_START的定义
- PAGE_OFFSET : the virtual address of the start of the linear map (线性映射的起始虚拟地址), 也是内核代码被链接的起始地址.
3.2.3 建立内核页表(paging_init)
paging_init 负责建立内核逻辑地址空间的页表,也就是把物理内存的normal区域一一映射到内核虚拟地址空间的normal区域。
页表建立完毕之后,内核后期调用伙伴系统接口从ZONE_NORMAL域获取物理页帧时,就不需要更改页表了(例如alloc_pages或者kmalloc)。与之相反的是,如果从ZONE_HIGHMEM域获取物理页帧时,则每次都需要更改页表项(例如vmalloc)。
内核整个虚拟地址空间的全局页目录存储在swapper_pg_dir变量中, 当内核被uboot装载进内存时, 内核汇编代码会先运行。整个汇编代码可以分为两部分: 第一部分是地址无关的, 第二部分是地址相关的(关联到0xc0000000这个地址). 第一部分的代码会初始化swapper_pg_dir变量, 然后开启MMU, 然后跳转到第二部分. 此时的全局页目录只映射了物理内存的大概1M字节的空间.
整个逻辑地址空间的页表是在paging_init阶段建立的, 所以paging_init的主要功能就是填充swapper_pg_dir的其余部分(当然也包括与之对应的PUD PMD PTE).
代码的大致流程如下:
setup_arch -> paging_init -> map_lowmem.
map_lowmem (arch/arm/mm/mmu.c):
for_each_memblock(memory, reg) : 针对memblock中的每一个block
if (start >= arm_lowmem_limit) break; : 如果此物理内存的起始地址属于高端内存域, 则结束循环. 看来memblock中的blocks都已经排过序了
否则, 根据不同的情况, 设置一些参数(主要是指明此块内存是RWX还是RW), 然后调用create_mapping
create_mapping (arch/arm/mm/mmu.c):
做一些参数检查
pgd = pgd_offset_k(addr); : 根据虚拟地址, 从全局页目录swapper_pg_dir中取出对应的表项
针对从addr 到addr_length的所有物理内存, 调用alloc_init_pud创建pud页目录
end = addr + length;
do{
unsignedlong next = pgd_addr_end(addr, end);
alloc_init_pud(pgd, addr, next, phys, type);
phys += next - addr;
addr = next;
}while(pgd++, addr != end);
alloc_init_pud (arch/arm/mm/mmu.c):
由于ARM体系架构没有PUD, 因此这里并没有实际的alloc动作, 而是直接调用了alloc_init_pmd
alloc_init_pmd (arch/arm/mm/mmu.c):
由于ARM体系架构没有PMD, 因此这里并没有实际的alloc动作, 而是直接调用了alloc_init_pte
alloc_init_pte (arch/arm/mm/mmu.c):
early_pte_alloc : 该函数首先为pte页表分配一块内存, 并把内存地址设置到上一级页目录对应的表项
do { set_pte_ext ... } while (xxx) : 然后针对每一个物理内存, 初始化页表的每一个表项, 这里同时会设置页表表项的低位, 用于存储保护. 关于页表低位每个bit的含义, 参见《3.1.1 pte flags》
3.2.4 PKMAP & FIXADDR
PKMAP
不同于X86体系架构, ARM体系架构把PKMAP区域放置于PAGE_OFFSET之前了. 定义如下:
// arch/arm/include/asm/highmem.h
#define PKMAP_BASE (PAGE_OFFSET - PMD_SIZE)
#define LAST_PKMAP PTRS_PER_PTE
#define LAST_PKMAP_MASK (LAST_PKMAP - 1)
// arch/arm/include/asm/pgtable-2level.h
#define PMD_SHIFT 21
#define PMD_SIZE (1UL << PMD_SHIFT)
从上述代码可以看出PKMAP区域的起始位置为PAGE_OFFSET – 2M, 大小为一个页表中表项的数目, ARM系统中此值为512. 看样子这段区域只能映射512个地址了.
另外, 如果PAGE_OFFSET是0xc0000000的话, 那么意味着每个应用程序可用的虚拟地址不是0 – 3GB, 否则会跟这里的PKMAP区域冲突了. 事实上确实如此, ARM体系架构中, TASK_SIZE的定义为:
// arch/arm/include/asm/memory.h
#define TASK_SIZE (UL(CONFIG_PAGE_OFFSET) - UL(SZ_16M))
FIXADDR
FIXADDR区域的起始和结束位置定义如下:
// arch/arm/include/asm/fixmap.h
#define FIXADDR_START 0xffc00000UL
#define FIXADDR_END 0xfff00000UL
#define FIXADDR_TOP (FIXADDR_END - PAGE_SIZE)
注意, VMALLOC_END 也被定义为固定值0xff800000UL, 因此VMALLOC_END和FIXADDR_START之间有4M的分割区域.
初始化
PKMAP区域和FIXADD区域的初始化也是在paging_init中完成的, 流程如下:
setup_arch -> paging_init -> kmap_init
// arch/arm/mm/mmu.c
staticvoid __init kmap_init(void)
{
#ifdef CONFIG_HIGHMEM
pkmap_page_table = early_pte_alloc(pmd_off_k(PKMAP_BASE),
PKMAP_BASE, _PAGE_KERNEL_TABLE);
#endif
early_pte_alloc(pmd_off_k(FIXADDR_START), FIXADDR_START,
_PAGE_KERNEL_TABLE);
}
注意只有使能了CONFIG_HIGHMEM才存在PKMAP区域.
关于PKMAP和FIXADDR的API细节, 相关数据结构, 工作逻辑等, 这里不细说了, 详见《深入Linux内核架构3.5.8》
3.2.5 vmalloc
根据上文的讲述,我们知道物理上连续的映射对内核是最好的,但并不总能成功地使用。在分配一大块内存时,可能竭尽全力也无法找到连续的内存块。在用户空间中这不是问题,因为普通进程使用处理器的分页机制,将不连续的物理页面映射到连续的虚拟地址空间,当然这会降低速度并占用TLB。
在内核中也可以使用同样的技术。内核虚拟地址空间中有一个VMALLOC区域,用于建立连续映射。VMALLOC区域的细分如下图所示:

VMALLOC区域被划分为多个子区域, 子区域之间通过一个内存页分隔. 不同vmalloc 子区域之间的分隔也是为防止不正确的内存访问操作。这种情况只会因为内核故障而出现,应该通过系统错误信息报告,而不是允许内核其他部分的数据被暗中修改。因为分隔是在虚拟地址空间中建立的,不会浪费宝贵的物理内存页。
工作原理
还记得第3章开篇介绍的虚拟内存管理的3个方面吗: 虚拟空间分配; 物理页帧分配; 建立页表.
vmalloc系统就是遵循这个原则.
虚拟空间分配: vmalloc系统的虚拟地址空间开始于VMALLOC_START, 结束于VMALLOC_END. 每当用户调用API申请内存时, vmalloc系统就会在虚拟地址空间范围内创建一个子区域, 一个子区域用一个struct vm_struct结构体来表示
物理页帧分配: 虚拟空间分配好之后, vmalloc系统就会调用伙伴系统的API, 向伙伴系统申请物理页帧, 由于vmalloc不要求页帧物理上连续, 因此向伙伴系统申请页帧时是一页一页申请的
建立页表: 最后一个步骤就是建立页表, 将物理页帧和虚拟地址映射起来.
数据结构
vm_struct
vmalloc区域的每一个子区域用一个struct vm_struct来描述, 所有的子区域都会通过链表串起来, 这样内核就能知道vmalloc区域中哪些虚拟地址已经被使用了, 哪些是空闲的.
注意, 用户进程虚拟地址空间也会被划分为一个个子区域, 内核代码用struct vm_area_struct来抽象每一个子区域, 尽管名称和目的都是类似的, 但不能混淆这两个结构.
头文件: include/linux/vmalloc.h
|
struct vm_struct |
Comment |
|
struct vm_struct*next; |
指向下一个子区域, 通过这个指针把各个子区域都串起来 |
|
void*addr |
定义了分配的子区域在虚拟地址空间中的起始地址 |
|
unsigned longsize |
size 表示该子区域的长度 |
|
|
flags 存储了与该子区域关联的标志集合,它只用于指定子区域的类型:
include/linux/vmalloc.h #define VM_IOREMAP0x00000001/* ioremap() and friends */ #define VM_ALLOC0x00000002/* vmalloc() */ #define VM_MAP0x00000004/* vmap()ed pages */ #define VM_USERMAP0x00000008/* suitable for remap_vmalloc_range */ #define VM_VPAGES0x00000010/* buffer for pages was vmalloc'ed */ #define VM_UNINITIALIZED0x00000020/* vm_struct is not fully initialized */ #define VM_NO_GUARD0x00000040 /* don't add guard page */ #define VM_KASAN0x00000080 /* has allocated kasan shadow memory */ |
|
struct page**pages |
pages 是一个指针, 指向page 指针的数组. 每个数组成员都表示一个映射到虚拟地址空间中的物理内存页的page 实例 |
|
unsigned intnr_pages |
指定pages 中数组项的数目, 即涉及的物理内存页数目 |
|
phys_addr_tphys_addr |
仅当用ioremap 映射了由物理地址描述的物理内存区域时才需要。 ioremap用于把某个给定的物理地址映射到vmalloc的某个子区域, 供内核访问. 最常见的情况就是把外设的寄存器地址映射到内核虚拟地址空间中. 这种情况下, 不会调用伙伴系统分配物理页帧, 而会使用给定的物理地址, 该地址保存在phys_addr 中 |
|
const void*caller |
一般指向__builtin_return_address(0)函数, 不知道干啥用的 |
API
|
vmalloc API |
Comment |
|
void *vmalloc(unsigned long size) |
该函数只需要一个参数, 用于指定所需内存区的长度, 与伙伴系统中讨论的函数不同, 其长度单位不是页而是字节, 这在用户空间程序设计中是很普遍的.
使用vmalloc 的最著名的实例是内核对模块的实现。因为模块可能在任何时候加载,如果模块数据比较多,那么无法保证有足够的连续内存可用,特别是在系统已经运行了比较长时间的情况下。如果能够用小块内存拼接出足够的内存,那么使用vmalloc 可以规避该问题。
内核中还有大约400处地方调用了vmalloc ,特别是在设备和声音驱动程序中。
因为用于vmalloc 的内存页不要求是连续的,因此使用ZONE_HIGHMEM 内存域的页要优于其他内存域。这使得内核可以节省更宝贵的较低端内存域,而又不会带来额外的坏处。
该函数返回的是一个虚拟地址, 内核代码可以直接访问. |
|
void *vzalloc(unsigned long size) |
用0填充分配的物理页帧, 很明显该函数只是向伙伴系统申请页帧时使用了__GPF_ZERO标志 |
|
除了vmalloc 之外, 还有其他方法可以创建虚拟连续映射. 这些都基于下文讨论的__vmalloc_node 函数或使用非常类似的机制 |
|
|
vmalloc_32 |
工作方式与vmalloc 相同,但会确保所使用的物理内存总是可以用普通32位指针寻址。如果某种体系结构的寻址能力超出基于字长计算的范围,那么这种保证就很重要。例如,在启用了PAE的IA-32系统上,就是如此。 |
|
vmap |
使用一个page 数组作为起点,来创建虚拟连续内存区。与vmalloc 相比,该函数所用的物理内存位置不是隐式分配的,而需要先行分配好,作为参数传递。此类映射可通过vm_map实例中的VM_MAP 标志辨别。 |
|
ioremap |
不同于上述的所有映射方法,ioremap 是一个特定于处理器的函数,必须在所有体系结构上实现(它的头文件不是vmalloc.h)。 它可以将取自物理地址空间、由系统总线用于I/O操作的一个内存块,映射到内核的地址空间中。 该函数在设备驱动程序中使用很多,可将用于与外设通信的地址区域暴露给内核。 |
|
void vfree(const void *addr) |
通过虚拟地址, 释放子区域, 同时也会把物理页帧返回给伙伴系统, 并删除页表中的相关项。
vfree 用于释放vmalloc 和vmalloc_32 分配的区域。 |
|
vunmap |
用于释放由vmap 或ioremap 创建的映射。 |
|
vmalloc_to_page |
Converts a kernel virtual address obtained from vmalloc to its correspondingstruct page pointer |
关键代码分析
vmalloc的实现代码在mm/vmalloc.c中, 主要流程如下:
void *vmalloc(unsigned long size)
__vmalloc_node_flags(size, NUMA_NO_NODE, GFP_KERNEL | __GFP_HIGHMEM)
__vmalloc_node
__vmalloc_node_range
__get_vm_area_node : 创建子区域
__vmalloc_area_node: 向伙伴系统申请页帧, 并建立页表
创建vm_area (__get_vm_area_node)
在创建一个新的虚拟内存区之前,必须找到一个适当的位置。vm_area 实例组成的一个链表,管理着vmalloc 区域中已经建立的各个子区域。定义在mm/vmalloc.c 的全局变量vmlist 是表头。
// mm/vmalloc.c
staticstruct vm_struct *vmlist __initdata;
内核在mm/vmalloc.c中提供了辅助函数get_vm_area 。它充当__get_vm_area_node 函数的前端。根据子区域的长度信息,该函数试图在虚拟的vmalloc 空间中找到一个适当的位置。
由于各个vmalloc 子区域之间需要插入1页(警戒页)作为安全隙,内核首先适当提高需要分配的内存长度。
// mm/vmalloc.c
staticstruct vm_struct *__get_vm_area_node(unsignedlong size,
unsignedlong align,unsignedlong flags,unsignedlong start,
unsignedlong end,int node, gfp_t gfp_mask,constvoid*caller)
{
......
if(!(flags & VM_NO_GUARD))
size += PAGE_SIZE;
......
va = alloc_vmap_area(size, align, start, end, node, gfp_mask);
setup_vmalloc_vm(area, va, flags, caller);
}
size设置正确之后, 调用alloc_vmap_area创建子区域, 最后调用setup_vmalloc_vm初始化vm_area各个成员变量的值. setup_vmalloc_vm没什么好看的, 我们主要关注一下alloc_vmap_area函数.
alloc_vmap_area
// mm/vmalloc.c
staticstruct vmap_area *alloc_vmap_area(unsignedlong size,
unsignedlong align,
unsignedlong vstart,unsignedlong vend,
int node, gfp_t gfp_mask)
{
start 和end 参数分别由调用者设置为VMALLOC_START 和VMALLOC_END 。
该函数首先会循环遍历vmlist 的所有表元素,直至找到一个适当的项。
// mm/vmalloc.c
for(p =&vmlist;(tmp =*p)!=NULL;p =&tmp->next){
if((unsignedlong)tmp->addr < addr){
if((unsignedlong)tmp->addr + tmp->size >= addr)
addr = ALIGN(tmp->size +
(unsignedlong)tmp->addr, align);
continue;
}
if((size + addr)< addr)
goto out;
if(size + addr <=(unsignedlong)tmp->addr)
goto found;
addr = ALIGN(tmp->size +(unsignedlong)tmp->addr, align);
if(addr > end -size)
goto out;
}
如果size+addr 不大于当前检查区域的起始地址(保存在tmp->addr ),那么内核就找到了一个合适的位置。接下来用适当的值初始化新的链表元素,并添加到vmlist 链表。
如果没有找到适当的内存区,则返回NULL 指针表示失败。
remove_vm_area 函数将一个现存的子区域从vmalloc 地址空间删除。
申请页帧(__vmalloc_area_node)
当vmalloc调用__get_vm_area_node成功分配了一个虚拟地址空间之后, 就会调用__vmalloc_area_node向伙伴系统申请物理页面.
这里不给出完整的代码了,其中包含了无趣的安全检查。我们比较感兴趣的是物理内存区域的分配(忽略没有足够物理内存页可用的可能性)。
// mm/vmalloc.c
staticvoid*__vmalloc_area_node(struct vm_struct *area, gfp_t gfp_mask,
pgprot_t prot,int node)
{
......
for(i =0; i < area->nr_pages; i++){
struct page *page;
if(node == NUMA_NO_NODE)
page =alloc_page(alloc_mask);
else
page =alloc_pages_node(node, alloc_mask, order);
if(unlikely(!page)){
/* Successfully allocated i pages, free them in __vunmap() */
area->nr_pages = i;
goto fail;
}
area->pages[i]= page;
if(gfpflags_allow_blocking(gfp_mask))
cond_resched();
}
if(map_vm_area(area, prot, pages))
goto fail;
return area->addr;
......
}
如果显式指定了分配页帧的结点,则内核调用alloc_pages_node 。否则,使用alloc_page 从当前结点分配页帧。
分配的页从相关结点的伙伴系统移除。在调用时,vmalloc 将gfp_mask 设置为GFP_KERNEL | __GFP_HIGHMEM ,内核通过该参数指示内存管理子系统尽可能从ZONE_HIGHMEM 内存域分配页帧。理由已经在上文给出:低端内存域的页帧更为宝贵,因此不应该浪费到vmalloc 的分配中,在此使用高端内存域的页帧完全可以满足要求。
从伙伴系统分配内存时,是逐页分配,而不是一次分配一大块。这是vmalloc 的一个关键方面。如果可以确信能够分配连续内存区,那么就没有必要使用vmalloc 。毕竟该函数的所有目的就在于分配大的内存块,尽管因为内存碎片的缘故,内存块中的页帧可能不是连续的。将分配单位拆分得尽可能小(换句话说,以页为单位),可以确保在物理内存有严重碎片的情况下,vmalloc 仍然可以工作。
建立页表(map_vm_area)
__vmalloc_area_node的最后会调用map_vm_area 将分散的物理内存页连续映射到虚拟的vmalloc 区域。
该函数遍历分配的物理内存页,在各级页目录/页表中分配并初始化所需的目录项/表项。
有些体系结构在修改页表后需要刷出CPU高速缓存。因此内核调用了flush_cache_vmap ,其定义是特定于体系结构的。取决于不同的CPU类型,其中可能包括用于刷出高速缓存的底层汇编语句。
释放空间(__vunmap)
vfree和vunmap用于释放空间, 《API》一节介绍过它们.
这两个函数最终都会调用__vunmap:
// mm/vmalloc.c
staticvoid __vunmap(constvoid*addr,int deallocate_pages)
addr 表示要释放的区域的起始地址,deallocate_pages 指定了是否将与该区域相关的物理内存页返回给伙伴系统。vfree 将后一个参数设置为1,而vunmap 设置为0,因为在这种情况下只删除映射,而不将相关的物理内存页返回给伙伴系统。
下图给出了__vunmap 的代码流程:

不必明确给出需要释放的区域长度,长度可以从vmlist 中的信息导出。因此__vunmap 的第一个任务是在__remove_vm_area (由remove_vm_area 在完成锁定之后调用)中扫描该链表,以找到相关项。
unmap_vm_area 使用找到的vm_area 实例,从页表删除不再需要的项。与分配内存时类似,该函数需要操作各级页表,但这一次需要删除涉及的项。它还会更新CPU高速缓存。
如果__vunmap 的参数deallocate_pages 设置为1(在vfree 中),内核会遍历area->pages 的所有元素,即指向所涉及的物理内存页的page 实例的指针。然后对每一项调用__free_page ,将页释放到伙伴系统。
最后,必须释放用于管理该内存区的内核数据结构。
3.3 用户进程虚拟地址空间
3.3.1 进程地址空间的布局
各个进程的虚拟地址空间起始于地址0,延伸到TASK_SIZE - 1 ,其上是内核地址空间。在IA-32系统上地址空间的范围可达2 32 = 4 GiB,总的地址空间通常按3:1比例划分,我们在下文中将关注该划分。内核分配了1 GiB,而各个用户空间进程可用的部分为3 GiB。其他的划分比例也是可能的,但正如前文的讨论,只能在非常特定的配置和某些工作负荷下才有用。
与系统完整性相关的非常重要的一方面是,用户程序只能访问整个地址空间的下半部分,不能访问内核部分。如果没有预先达成“协议”,用户进程也不可能操作另一个进程的地址空间,因为后者的地址空间对前者不可见。
无论当前哪个用户进程处于活动状态,虚拟地址空间内核部分的内容总是同样的。取决于具体的硬件,这可能是通过操作各用户进程的页表,使得虚拟地址空间的上半部看上去总是相同的。也可能是指示处理器为内核提供一个独立的地址空间,映射在各个用户地址空间之上。
虚拟地址空间中包含了若干区域。其分布方式是特定于体系结构的,但所有方法都有下列共同成分。
当前运行代码的二进制代码。该代码通常称之为text,所处的虚拟内存区域称之为text段。
文字常量区, 或者叫做只读数据段。
静态存储区, 包括R/W data, BSS data. 全局变量和static 变量都存放在这里。
动态产生数据(malloc)的堆。
用于保存局部变量和实现函数/过程调用的栈。
环境变量和命令行参数的段。
将文件内容映射到虚拟地址空间中的内存映射区。这个区域与vmalloc区域具有相同的属性,这个区域也会被划分为多个子区域,每个子区域用一个struct vm_area_struct来描述。每次创建一个新的内存映射时,都会在内存映射区创建一个子区域。
内存映射区可以映射很多东西, 例如程序使用的动态库的代码. 后文会专门介绍内存映射.

内存映射区域的两种布局方式
下图给出了一个粗略的布局图, 其中省去了某些段, 使用这张图是为了解释内核中为什么会有2种地址空间布局:

text段如何映射到虚拟地址空间中由ELF标准确定。每个体系结构都指定了一个特定的起始地址:IA-32系统起始于0x08048000 ,在text段的起始地址与最低的可用地址之间有大约128 MiB的间距,用于捕获NULL 指针。其他体系结构也有类似的缺口:UltraSparc计算机使用0x100000000 作为text段的起始点,而AMD64使用0x0000000000400000 。
堆紧接着text段开始,向上增长。
栈起始于STACK_TOP ,如果进程设置了PF_RANDOMIZE ,则起始点会减少一个小的随机量。设置PF_RANDOMIZE的主要目的是处于安全的考虑,例如,使得攻击因缓冲区溢出导致的安全漏洞更加困难。如果攻击者无法依靠固定地址找到栈,那么想要构建恶意代码,通过缓冲器溢出获得栈内存区域的访问权,而后恶意操纵栈的内容,将会困难得多。
每个体系结构都必须定义STACK_TOP ,大多数都设置为TASK_SIZE ,即用户地址空间中最高的可用地址。进程的参数列表和环境变量都是栈的初始数据。
用于内存映射的区域起始于mm_struct->mmap_base ,通常设置为TASK_UNMAPPED_BASE ,每个体系结构都需要定义。几乎所有的情况下,其值都是TASK_SIZE/3 。要注意,如果使用内核的默认配置,则mmap区域的起始点不是随机的。
如果计算机提供了巨大的虚拟地址空间,那么使用上述的地址空间布局会工作得非常好。但在32位计算机上可能会出现问题。考虑IA-32的情况:虚拟地址空间从0到0xC0000000 ,每个用户进程有3 GiB可用。TASK_UNMAPPED_BASE 起始于TASK_SIZE/3 ,即1 GiB处。糟糕的是,这意味着堆只有1 GiB空间可供使用,继续增长则会进入到mmap区域,这显然不是我们想要的。
为了解决这个问题,在内核版本2.6.7开发期间引入一个新的虚拟地址空间布局(经典布局仍然可以使用)。新的布局如下图所示:

其想法在于使用固定值限制栈的最大长度。由于栈是有界的,因此安置内存映射的区域可以在栈末端的下方立即开始。与经典方法相反,内存映射区域现在是自顶向下扩展。由于堆仍然位于虚拟地址空间中较低的区域并向上增长,因此mmap区域和堆可以相对扩展,直至耗尽虚拟地址空间中剩余的区域。为确保栈与mmap区域不发生冲突,两者之间设置了一个安全隙。
特定于体系架构的设置
各个体系结构可以通过几个配置选项影响虚拟地址空间的布局:
如果体系结构想要在不同地址空间布局之间作出选择,则需要设置HAVE_ARCH_PICK_MMAP_LAYOUT ,并提供arch_pick_mmap_layout 函数。
在创建新的内存映射时,除非用户指定了具体的地址,否则内核需要找到一个适当的位置。如果体系结构自身想要选择合适的位置,则必须设置预处理器符号HAVE_ARCH_UNMAPPED_AREA ,并相应地定义arch_get_unmapped_area 函数。
在寻找新的内存映射低端内存位置时,通常从较低的内存位置开始,逐渐向较高的内存地址搜索。内核提供了默认的函数arch_get_unmapped_area用于搜索,但如果某个体系结构想要提供专门的实现,则需要设置预处理器符号HAVE_ARCH_GET_UNMAPPED_AREA 。
通常,栈自顶向下增长。具有不同处理方式的体系结构需要设置配置选项CONFIG_STACK_GROWSUP 。
3.3.2 数据结构(mm_struct)
系统中的各个进程都具有一个struct mm_struct 的实例,可以通过task_struct 访问。这个实例保存了进程的内存管理信息,这个结构体非常庞大, 我们目前只看看这个结构体中与地址空间布局相关的部分:
头文件: include/linux/mm_types.h
|
struct mm_struct |
Comment |
|
...... |
|
|
unsigned long start_code, end_code, start_data, end_data; |
可执行代码占用的虚拟地址空间区域,其开始和结束分别通过start_code 和end_code 标记。
类似地,start_data 和end_data 标记了包含已初始化数据的区域。
请注意,在ELF二进制文件映射到地址空间中之后,这些区域的长度不再改变。 |
|
unsigned long start_brk, brk, start_stack; |
堆的起始地址保存在start_brk ,brk 表示堆区域当前的结束地址。尽管堆的起始地址在进程生命周期中是不变的,但堆的长度会发生变化,因而brk 的值也会变。
start_stack表示栈的当前位置 |
|
unsigned long arg_start, arg_end, env_start, env_end; |
参数列表和环境变量的位置分别由arg_start 和arg_end 、env_start 和env_end 描述。两个区域都位于栈中最高的区域。 |
|
unsigned long mmap_base ulong (*get_unmapped_area)(......) struct vm_area_struct *mmap |
这3个元素都与内存映射区域相关, 将会在《管理子区域的红黑树和链表》中详细介绍 |
|
unsigned long task_size |
顾名思义, 存储了对应进程的地址空间长度. 该值通常是TASK_SIZE, ARM体系架构中, TASK_SIZE定义为( memory.h : #define TASK_SIZE (UL(CONFIG_PAGE_OFFSET) - UL(SZ_16M)) )
但如果在64位计算机上执行32位二进制代码,则task_size 描述了该二进制代码实际可见的地址空间长度 |
3.3.3 建立布局
在使用load_elf_binary 装载一个ELF二进制文件时,将创建进程的上述地址空间布局。而exec 系统调用刚好使用了该函数。加载ELF文件涉及大量纷繁复杂的技术细节,与我们的主旨关系不大,因此下图给出的代码流程图主要关注建立虚拟内存区域所需的各个步骤。

如果全局变量randomize_va_space 设置为1,则启用地址空间随机化机制。通常情况下都是启用的,但在Transmeta CPU上会停用,因为该设置会降低此类计算机的速度。此外,用户可以通过/proc/sys/kernel/randomize_va_space 停用该特性。
选择布局的工作由arch_pick_mmap_layout 完成。如果对应的体系结构没有提供一个具体的函数,则使用内核的默认例程,按上述布局建立地址空间。但我们更感兴趣的是,IA-32如何在经典布局和新的布局之间选择:

如果用户通过/proc/sys/kernel/legacy_va_layout 给出明确的指示,或者要执行为不同的UNIX变体编译、需要旧的布局的二进制文件。或者栈可以无限增长(最重要的一点),则系统会选择旧的布局,因为很难确定栈的下界,亦即mmap区域的上界。
在经典的配置下,mmap区域的起始点是TASK_UNMAPPED_BASE ,其值为0x4000000 ,而标准函数arch_get_unmapped_area (其名称虽然带有arch ,但该函数不一定是特定于体系结构的,内核也提供了一个标准实现)用于自下而上地创建新的映射。
在使用新布局时,内存映射自顶向下增长。标准函数arch_get_unmapped_area_topdown (我不会详细描述)负责该工作。更有趣的问题是如何选择内存映射的基地址:

可以根据栈的最大长度,来计算栈最低的可能位置,用作mmap区域的起始点。但内核会确保栈至少跨越128 MiB的空间。另外,如果指定的栈界限非常巨大,那么内核会保证至少有一小部分地址空间不被栈占据。
如果要求使用地址空间随机化机制,上述位置会减去一个随机的偏移量,最大为1 MiB。另外,内核会确保该区域对齐到页帧,这是体系结构的要求。
我们回到load_elf_binary 。该函数最后需要在适当的位置创建栈:
<fs/binfmt_elf.c>
staticint load_elf_binary(struct linux_binprm *bprm,struct pt_regs *regs)
{
...
retval = setup_arg_pages(bprm, randomize_stack_top(STACK_TOP),
executable_stack);
...
}
标准函数setup_arg_pages 即用于该目的。因为该函数只是技术性的,我不会详细讨论。该函数需要栈顶的位置作为参数。栈顶由特定于体系结构的常数STACK_TOP 给出,而后调用randomize_stack_top ,确保在启用地址空间随机化的情况下,对该地址进行随机偏移。
3.3.4 内存映射
内存映射的原理
Linux下一切皆文件, 很多动态库(.so), 文本文件(.txt, ...), 还有很多其它数据都以文件的形式存放在磁盘上. 用户进程要想访问这些数据, 必须把这些数据拷贝到物理内存上, 然后把物理内存映射到用户进程的虚拟地址空间, 之后用户进程才可访问这些数据.
如前文《进程地址空间的布局》所述, 在进程的虚拟地址空间中, 专门划分了一块区域, 用于上述映射, 这块区域就叫内存映射区.
不过实际的映射过程与上述步骤有些区别, 我们并不会先把文件的内容都拷贝到物理内存然后再做映射, 这样是不合理的: 假如有一个文件有1G的大小, 如果全部拷贝到物理内存就会占用1G的内存大小, 很多时候物理内存根本就没有这么大; 而且在某个时刻, 我们只需要访问这个文件的一小部分内容, 没有必要把整个文件都拷贝到物理内存, 这是对物理内存的浪费!
因此, 实际的处理过程是先建立内存映射区和文件之间的映射(此时并不会拷贝任何数据到物理内存), 然后当程序访问到某个虚拟地址, 发现通过页表找不到对应的物理内存, 而且这个地址所在的区域已经被映射到某个文件, 此时系统才会向伙伴系统申请内存, 并拷贝需要的数据到内存(注意伙伴系统一次会分配一个PAGE_SIZE大小的内存, 因此每次拷贝动作至少也会拷贝PAGE_SIZE大小的内容). 负责申请物理页表并拷贝数据的是内核的”缺页异常处理程序”.
文件也有自己的地址空间, 所谓内存映射其实就是建立文件地址空间到内存映射区的映射. 注意一次映射不需要把整个文件的地址空间都映射过去, 这样会浪费内存映射区的虚拟地址空间, 我们可以只映射文件中我们需要的那一部分.
映射建立之后, 还必须解决一个问题: 当缺页异常产生之后, 如何读取文件中的内容? 文件数据在硬盘上的存储通常并不是连续的,而且不同的文件读取的方式可能也不一样. 内核利用address_space 数据结构来抽象读取文件内容的方法, 不管是什么形式的文件, 在建立内存映射时, 都必须填充address_space中相关的接口, 以便后面读取文件中的内容.
另外, 尽管上述讨论是针对内存映射区, 但是对于进程地址空间的其它区域也同样适用, 例如进程的代码存储在磁盘上, 我们把代码文件映射到进程的代码区, 而且只有在执行某一部分代码时才把相应的内容拷贝进内存.
最后, 我们用一副图示来说明一下”缺页异常”的处理过程:

进程试图访问用户地址空间中的一个内存地址,但使用页表无法确定物理地址(物理内存中没有关联页)。
处理器接下来触发一个缺页异常,发送到内核。
内核首先会检查”异常地址”是否合法(例如该地址是否属于某个内存映射区), 然后找到适当的后备存储器(address_space)。
分配物理内存页,并从后备存储器读取所需数据填充。
借助于页表将物理内存页并入到用户进程的地址空间,应用程序恢复执行。
这些操作对用户进程是透明的。换句话说,进程不会注意到页是实际在物理内存中,还是需要通过”缺页异常”加载。
相关数据结构
子区域(vm_area_struct)
内存映射区会被划分为多个子区域, 每个子区域表示为vm_area_struct 的一个实例,其定义(简化形式)如下:
头文件: include/linux/mm_types.h
|
struct vm_area_struct |
Comment |
|
struct mm_struct *vm_mm; |
所属的进程的地址空间 |
|
unsigned long vm_start; |
该子区域在进程虚拟地址空间中的起始位置 |
|
unsigned long vm_end; |
该子区域在进程虚拟地址空间中的结束位置 |
|
struct vm_area_struct *vm_next, *vm_prev; |
用于链接所有的子区域, 链表中的子区域会按地址递增的形式排好序 |
|
struct rb_node vm_rb; |
mm_struct会用红黑树管理下属的所有子区域, vm_rb相当于红黑树中的一个节点, 用于把本子区域集成到红黑树中 |
|
unsigned long rb_subtree_gap |
Largest free memory gap in bytes to the left of this VMA. Either between this VMA and vma->vm_prev, or between one of theVMAs below us in the VMA rbtree and its ->vm_prev. This helpsget_unmapped_area find a free area of the right size |
|
pgprot_t vm_page_prot |
Access permissions of this VMA pgprot_t的定义在《3.1.1 pte flags》中讨论过 |
|
unsigned long vm_flags |
描述该区域的一组标志:
头文件: include/linux/mm.h VM_READ 、VM_WRITE 、VM_EXEC 、VM_SHARED 分别指定了页的内容是否可以读、写、执行,或者由几个进程共享 VM_MAYREAD 、VM_MAYWRITE 、VM_MAYEXEC 、VM_MAYSHARE 用于确定是否可以设置对应的VM_*标志。这是mprotect 系统调用所需要的。 VM_GROWSDOWN 和VM_GROWSUP 表示一个区域是否可以向下或向上扩展。 对于堆区域, 由于堆自下而上增长, 因此设置为VM_GROWSUP 对于栈区域, 由于栈自顶向下增长, 故设置为VM_GROWSDOWN 对于内存映射区, 则根据实际情况设置 如果区域很可能从头到尾顺序读取,则设置VM_SEQ_READ VM_RAND_READ 指定了读取可能是随机的 这两个标志用于”提示”内存管理子系统和块设备层,以优化其性能 如果设置了VM_DONTCOPY ,则相关的区域在fork 系统调用执行时不复制 VM_DONTEXPAND 禁止区域通过mremap 系统调用扩展 如果区域是基于某些体系结构支持的巨型页,则设置VM_HUGETLB 标志 VM_ACCOUNT 指定区域是否被归入overcommit特性的计算中。这些特性以多种方式限制内存分配 |
|
struct { struct rb_node rb; unsigned long rb_subtree_last; } shared; |
通过进程的mm_struct结构体, 我们可以很容易的找到属于该进程的所有子区域, 每个子区域都可能映射到某个文件, 因而也能很方便的知道一个进程映射了哪些文件
但是, 一个文件可能被映射到多个进程, 这种映射称之为”共享映射”, 例如C的标准库就可能被多个进程映射. 所以内核有时候需要知道一个文件或者文件中的某个区域被映射到了哪些进程.
shared就是用来解决这个问题的, shared.rb是红黑树的一个节点, 用于把该区域与映射的文件关联起来, 一个文件也用红黑树来管理与它关联的所有子区域. 后文《文件的地址空间》中将会讨论更多细节 |
|
struct list_head anon_vma_chain struct anon_vma *anon_vma |
暂时不明白其细节 |
|
const struct vm_operations_struct *vm_ops |
vm_ops 是一个指针, 指向许多方法的集合, 这些方法用于在该区域上执行各种标准操作 后文《vm_operations_struct》将会讨论这个结构体的更多细节 |
|
unsigned long vm_pgoff |
指定了文件映射的偏移量,该值用于只映射了文件部分内容时(如果映射了整个文件, 则偏移量为0) 偏移量的单位不是字节, 而是页(即PAGE_SIZE ). 这是合理的, 因为内核只支持以整页为单位的映射, 更小的值没有意义. |
|
struct file * vm_file |
在Linux系统中, 文件被打开一次, 就会产生与之相对的一个file实例. vm_file指向file 实例,描述了一个被映射的文件。 如果映射的对象不是文件,则为NULL 指针 |
|
struct file *vm_prfile |
暂时不明白其细节 |
|
void * vm_private_data |
vm_private_data 可用于存储私有数据,不由通用内存管理例程操作。内核只确保在创建新区域时该成员初始化为NULL 指针。当前,只有少数声音和视频驱动程序使用了该选项。 |
|
struct vm_userfaultfd_ctx vm_userfaultfd_ctx |
暂时不明白其细节 |
vm_operations_struct
头文件: include/linux/mm.h
|
struct vm_operations_struct |
Comment |
|
void (*open)(struct vm_area_struct * area) void (*close)(struct vm_area_struct * area) |
在创建和删除区域时, 分别调用open 和close . 这两个接口通常不使用, 设置为NULL 指针. |
|
int (*fault)(struct vm_area_struct *vma, struct vm_fault *vmf) |
fault 是非常重要的。如果地址空间中的某个虚拟内存页不在物理内存中,自动触发的缺页异常处理程序会调用该函数。 该函数将对应的数据读取到一个映射在用户地址空间的物理内存页中。 |
|
...... |
还有一些其他函数, 暂不一一介绍 |
管理子区域的红黑树和链表
我们知道struct mm_struct 很重要, 按前文的讨论, 该结构提供了进程在内存中布局的所有必要信息.
另外, 它还包括下列成员, 用于管理用户进程的内存映射区域中所有的子区域:
头文件: include/linux/mm_types.h
|
struct mm_struct |
Comment |
|
...... |
|
|
struct vm_area_struct *mmap |
mmap指向内存映射区的一个子区域, 每个子区域自身都有vm_next & vm_prev 指向相邻的子区域. 这样, 通过mmap, 我们就能遍历属于该进程的所有子区域.
注意所有的子区域会按起始地址以递增次序被归入链表中 |
|
struct rb_root mm_rb |
红黑树的跟节点, 属于该进程的每个子区域都通过struct rb_node vm_rb关联到这个根节点上.
红黑树是一种自平衡二叉查找树, 非常高效. 如果只通过链表管理子区域, 在有大量子区域时, 会导致查找某个特定子区域的效率很低, 因为这可能要扫描整个链表. |
|
unsigned long mmap_base |
表示虚拟地址空间中用于内存映射的起始地址 |
|
unsigned long (*get_unmapped_area) ( ....) |
调用get_unmapped_area 可以在内存映射区域中为新映射找到适当的位置 |
最后我们用一张图来解释一下上述管理结构, 注意图的表示只是象征性的, 没有反映真实布局的复杂性.

文件的地址空间(address_space)
每个打开文件(和每个块设备,因为这些也可以通过设备文件进行内存映射)都表示为structfile 的一个实例, 该结构包含了一个指向文件地址空间struct address_space 的指针.
// include/linux/fs.h
struct file {
...
struct address_space *f_mapping;
...
}
每个文件都有自己的地址空间, 用struct address_space表示. 一个文件可能被映射到多个进程中, 因此我们需要在address_space中记录本文件到底被映射到了哪些进程, 以便内核在有需要的时候获取该信息.
address_space结构体的细节如下, 我们暂时只关于与映射相关的部分:
头文件: include/linux/fs.h
|
struct address_space |
Comment |
|
...... |
|
|
struct inode*host; |
struct inode代表文件本身, 与struct file不同, 每个文件不管被打开多少次, 都只存在一个inode实例. |
|
struct rb_rooti_mmap |
红黑树的跟节点, 用于挂接所有的private and shared映射. vm_area_struct->shared.rb用于把vm_area_struct挂接到这个跟节点上.
通过i_mmap, 我们就能找到所有与该文件相关的vm_area_struct; 而通过vm_area_struct->mm_struct, 我们就能找到对应的进程. 因此通过i_mmap, 我们就能找到所有映射了该文件的进程 |
|
const struct address_space_operations *a_ops |
该数据结构提供了获取文件内容的通用方法.
|
address_space_operations
头文件: include/linux/fs.h
|
struct address_space_operations |
Comment |
|
int (*writepage)(struct page *page, struct writeback_control *wbc) |
writepage 将一页的内容从物理内存写回到块设备上对应的位置, 以便永久地保存更改的内容 |
|
int (*writepages)(struct address_space *, struct writeback_control *) |
一次写多个pages |
|
int (*readpage)(struct file *, struct page *) |
readpage 从潜在的块设备读取一页到物理内存中 |
|
int (*readpages)(struct file *filp, struct address_space *mapping,struct list_head *pages, unsigned nr_pages) |
一次读多个pages |
|
...... |
|
内存映射区与文件地址空间建立关联
vm_operations_struct 和address_space 之间的联系如何建立?这里不存在将一个结构的实例分配到另一个结构的静态连接。这两个结构使用内核为vm_operations_struct 提供的标准实现连接起来,几乎所有的文件系统都使用了这种方式。

filemap_fault 的实现使用了address_space_operations的readpage等方法.
这样, 当发生缺页异常时, 处理程序会找到触发异常的虚拟地址对应的vm_area_struct, 继而找到vm_operations_struct, 继而调用它的.fault函数, 继而调用readpage等方法.
内存映射区相关操作
在介绍API之前, 我们先介绍一下与内存映射区相关的操作, 因为API里面介绍的函数会引用这里的操作函数.
内存映射区其实就是进程的一段虚拟地址空间, 对该区域常用API有两个:
创建映射: 在内存映射区新建一个子区域.
删除映射: 删除之前创建的子区域
在上述两个API的基础上衍生出另外几个操作:
区域查询: 通过给定的虚拟地址, 找到该地址所属的子区域
区域检查: 在创建子区域之前, 我们需要在内存映射区找到合适的位置.
区域合并: 如果一个新区域紧接着现存区域前后直接添加, 或者在两个区域中间插入一个新区域, 并且涉及的所有区域的访问权限相同,而且是从同一后备存储器映射的连续数据。此时内核将涉及的区域合并为一个.
区域拆分: 如果删除现有某个区域头部/尾部的一部分, 则必须据此截断现存的区域. 另外如果删除现存某个区域中间的一部分, 则必须把现存区域拆分为2个新的区域.
下图展示了区域合并与区域删除的示例:

下面, 我们分别介绍与这些操作相关的代码.
区域查询(find_vma)
给定某个虚拟地址,find_vma 可以查找用户地址空间中结束地址在给定地址之后的第一个区域,即满足addr < vm_area_struct->vm_end 条件的第一个区域。该函数的参数不仅包括虚拟地址(addr),还包括一个指向mm_struct 实例的指针,后者指定了扫描哪个进程的地址空间。
实现文件: mm/mmap.c
struct vm_area_struct *find_vma(struct mm_struct *mm,unsignedlong addr)
{
struct rb_node *rb_node;
struct vm_area_struct *vma;
/* Check the cache first. */
vma = vmacache_find(mm, addr);
if(likely(vma))
return vma;
rb_node = mm->mm_rb.rb_node;
while(rb_node){
struct vm_area_struct *tmp;
tmp = rb_entry(rb_node,struct vm_area_struct, vm_rb);
if(tmp->vm_end > addr){
vma = tmp;
if(tmp->vm_start <= addr)
break;
rb_node = rb_node->rb_left;
}else
rb_node = rb_node->rb_right;
}
if(vma)
vmacache_update(addr, vma);
return vma;
}
EXPORT_SYMBOL(find_vma);
内核首先通过vmacache_find检查上次处理的区域中是否包含所需的地址. 很多情况下, 我们都有可能连续几次操作同一个区域, 因此系统把上次操作的区域缓存起来, 加快处理速度.
否则必须逐步搜索红黑树。rb_node 是用于表示树中各个结点的数据结构。rb_entry 用于从结点取出“有用数据”(在这里是vm_area_struct 实例)
搜索红黑树的逻辑为:
如果当前区域结束地址大于目标地址,则从左子结点开始
如果当前区域的结束地址小于等于目标地址,则从右子结点开始
如果某个区域结束地址大于目标地址而起始地址小于目标地址,内核就找到了一个适当的结点,可以退出while 循环,返回指向vm_area_struct 实例的指针
如果树根结点的子结点为NULL 指针, 则内核很容易判断何时结束搜索并返回NULL 指针作为错误信息
如果找到适当的区域, 则调用vmacache_update缓存找的区域, 因为下一次find_vma 调用搜索同一个区域中邻近地址的可能性很高
区域合并(vm_merge)
在新区域被加到进程的地址空间时,内核会检查它是否可以与一个或多个现存区域合并。vm_merge 在可能的情况下,将一个新区域与周边区域合并。它需要很多参数。
实现文件: mm/mmap.c
struct vm_area_struct *vma_merge(struct mm_struct *mm,
struct vm_area_struct *prev,unsignedlong addr,
unsignedlong end,unsignedlong vm_flags,
struct anon_vma *anon_vma,struct file *file,
pgoff_t pgoff,struct mempolicy *policy,
struct vm_userfaultfd_ctx vm_userfaultfd_ctx)
{
pgoff_t pglen =(end - addr)>> PAGE_SHIFT;
struct vm_area_struct *area,*next;
......
returnNULL;
}
mm 是相关进程的地址空间实例,而prev 是紧接着新区域之前的区域
addr 、end 和vm_flags 分别是新区域的开始地址、结束地址、标志
如果该区域属于一个文件映射,则file 是一个指向表示该文件的file 实例的指针
pgoff 指定了映射在文件数据内的偏移量
policy 参数只在NUMA系统上需要,我不会进一步讨论它
实现的技术细节非常简单。首先检查确定前一个区域的结束地址是否对应于新区域的起始地址。
倘若如此,内核接下来必须检查两个区域,确认二者的标志和映射的文件相同,文件映射内部的偏移量符合连续区域的要求,如果两个文件映射在地址空间中连续,但在文件中不连续,亦无法合并。
通过can_vma_merge_after 辅助函数完成检查。将区域与前一个区域合并的工作看起来如下所示:
// mm/mmap.c : vma_merge
if(prev &&prev->vm_end == addr&&
mpol_equal(vma_policy(prev), policy)&&
can_vma_merge_after(prev, vm_flags,
anon_vma, file, pgoff,
vm_userfaultfd_ctx)){
...
如果可以,内核接下来检查后一个区域是否可以合并:
// mm/mmap.c : vma_merge
/*
* OK, it can. Can we now merge in the successor as well?
*/
if(next && end == next->vm_start &&
mpol_equal(policy, vma_policy(next))&&
can_vma_merge_before(next, vm_flags,
anon_vma, file,
pgoff+pglen,
vm_userfaultfd_ctx)&&
is_mergeable_anon_vma(prev->anon_vma,
next->anon_vma,NULL)){
/* cases 1, 6 */
err =vma_adjust(prev, prev->vm_start,
next->vm_end, prev->vm_pgoff,NULL);
}else /* cases 2, 5, 7 */
err = vma_adjust(prev, prev->vm_start,
end, prev->vm_pgoff,NULL);
与前一例相比,第一个差别是使用can_vma_merge_before 来检查两个区域是否可以合并,替代了can_vma_merge_after 。
如果前一个和后一个区域都可以与当前区域合并,还必须确认前一个和后一个区域的匿名映射可以合并,然后才能创建包含这3个区域的一个单一区域。
在两种情况下,都调用了辅助函数vma_adjust 执行最后的合并。它会适当地修改涉及的所有数据结构,包括address_space-> i_mmap和vm_area_struct 实例,还包括释放不再需要的结构实例。
区域检查(get_unmapped_area)
在创建新的内存映射子区域之前, 内核必须确认虚拟地址空间中有足够的空闲空间, 可用于给定长度的区域. 该工作分配给get_unmapped_area 辅助函数完成.
// mm/mmap.c
unsignedlong
get_unmapped_area(struct file *file,unsignedlong addr,unsignedlong len,
unsignedlong pgoff,unsignedlong flags)
{
unsignedlong(*get_area)(struct file *,unsignedlong,
unsignedlong,unsignedlong,unsignedlong);
......
get_area = current->mm->get_unmapped_area;
if(file && file->f_op->get_unmapped_area)
get_area = file->f_op->get_unmapped_area;
addr = get_area(file, addr, len, pgoff, flags);
......
}
函数参数都是自明的, 不多解释.
如果struct file自己提供了get_unmapped_area, 则会优先使用文件提供的函数.
否则, 会使用当前进程提供的get_unmapped_area函数.
mm-> get_unmapped_area是在何时被初始化的呢? 回想《3.3.1 进程地址空间的布局: 特定于体系架构的设置》中描述的内容, 可以得到如下结论:
如果特定于体系架构的代码实现了get_unmapped_area, 则使用该实现.
如果体系架构没有, 则使用内核默认的实现:
如果内存映射区域的布局是从低向高增长, 则使用arch_get_unmapped_area(mm/mmap.c)函数
如果内存映射区域的布局是从高向低增长, 则使用arch_get_unmapped_area_topdown (mm/mmap.c)函数
在这里, 我们详细看看大多数系统上采用的标准函数arch_get_unmapped_area 。
// mm/mmap.c
#ifndef HAVE_ARCH_UNMAPPED_AREA
unsignedlong
arch_get_unmapped_area(struct file *filp,unsignedlong addr,
unsignedlong len,unsignedlong pgoff,unsignedlong flags)
{
struct mm_struct *mm = current->mm;
struct vm_area_struct *vma;
struct vm_unmapped_area_info info;
if(len > TASK_SIZE - mmap_min_addr)
return-ENOMEM;
if(flags & MAP_FIXED)
return addr;
if(addr){
addr =PAGE_ALIGN(addr);
vma =find_vma(mm, addr);
if(TASK_SIZE - len >= addr && addr >= mmap_min_addr &&
(!vma || addr + len <= vma->vm_start))
return addr;
}
info.flags =0;
info.length = len;
info.low_limit = mm->mmap_base;
info.high_limit = TASK_SIZE;
info.align_mask =0;
return vm_unmapped_area(&info);
}
#endif
首先必须检查是否设置了MAP_FIXED 标志, 该标志表示映射将在固定地址创建. 倘若如此, 直接返回该地址
调用find_vma函数, 如果找到了一个结束地址大于addr的区域, 则vma不为NULL, 此时进一步检查此区域的起始地址是否也大于addr + len, 如果成立, 证明找到了一个空洞, 可以在这里创建长度为len的子区域, 并且不会骚扰到现存的这个vma.
如果find_vma没有找到合适的子区域, 即vma == NULL, 这说明现存的所有子区域的结束地址都小于addr, add处在边缘区, 后面都是荒漠, 因此可以在此处创建一个长度为len的子区域.
如果还不行, 则会调用vm_unmapped_area. 注意, 调用vm_unmapped_area时, 并没有把addr参数传进去, 这证明vm_unmapped_area的返回值与addr没有任何关系.
换句话说, vm_unmapped_area的目的是想方设法的从内存映射区找到一块长度为len的空洞, 如果找到了, 就返回这个空洞的起始地址, 这个地址可能与用户要求的地址addr不一样.
vm_unmapped_area的实现细节好像很复杂, 我们就不细看了
区域拆分(split_vma)
split_vma是一个辅助函数, 它会直接调用__split_vma实现功能.
功能实现的逻辑很简单, 只是对熟悉的数据结构进行标准操作, 它会为新拆分出来的区域分配一个新的vm_area_struct 实例, 用原区域的数据填充它, 并校准边界. 新建的区域会插入到进程的红黑树中.
代码就不具体分析了, 我们只是简单贴一下__split_vma的申明:
// mm/mmap.c
staticint __split_vma(struct mm_struct *mm,struct vm_area_struct *vma,
unsignedlong addr,int new_below)
APIs
我们已经熟悉了内存映射相关的原理, 数据结构和相关操作, 在本节中, 我们将进一步讨论建立内存映射的相关API. 用户空间的代码可以利用这些API进行内存映射.我们着重关注在建立映射时内核和应用程序之间的交互.
创建映射(mmap , do_mmap)
就我们所知, C标准库提供了mmap 函数建立映射. 其定义如下:
#include <sys/mman.h>
void*mmap(void*start,size_t length,int prot,int flags,int fd,off_t offset);
参数解释如下:
start : 指定期望映射到哪个虚拟地址, 通常设为NULL, 代表让系统自动选定地址,映射成功后返回该地址. 另外即使start不为NULL, 返回值也可能与start指向的地址不同, 原因详见《3.3.4 get_unmapped_area函数》
length : 代表映射的长度, 它意味着在内存映射区需要一个长度为length的子区域, 也意味着将文件中多大的部分映射到内存映射区
prot : 对应vm_area_struct->vm_page_prot, 代表新建的内存映射子区域的访问权限
PROT_EXEC : 子区域可被执行
PROT_READ : 子区域可被读取
PROT_WRITE : 子区域可被写入
PROT_NONE : 子区域不能存取
...
flags : 影响映射区域的各种特性:
在创建一个映射时, MAP_SHARED / MAP_PRIVATE必须二选一, 也就是说要么创建共享映射, 要么创建私有映射.
MAP_SHARED :创建一个共享映射, 所谓共享是指如果多个进程映射了同一个文件中的同一段区域, 则只会在物理内存中存在一份拷贝, 任何一个进程写操作其它进程都能看见, 而且写操作修改最终会写回到磁盘的原始文件中. 这样做的一个好处是节省物理内存. 示例如下图:

MAP_PRIVATE : 创建一个私有映射, 对映射区域的写入操作会产生一个私有的”写入时复制”(copy on write, 即在写入时复制物理内存的一份拷贝),进程之间的写入操作相互不可见, 对此区域作的任何修改都不会写回到磁盘的原始文件中. 示例图如下:

MAP_FIXED : 如果参数start所指的地址无法成功建立映射时, 则放弃映射, 不对自动寻找可用地址. 通常不鼓励用此标志.
MAP_ANONYMOUS : 建立匿名映射. 此时会忽略参数fd, 不涉及文件, 而且映射区域无法和其他进程共享. 这种映射和文件映射的区别是, 当发生缺页异常时, 文件映射会从文件中读取内存并填充物理页面; 而匿名映射是用0填充物理页面.
MAP_LOCKED : 将映射区域锁定住, 这表示该区域不会被置换(swap)
...
fd : 要映射到内存中的文件描述符. 如果使用匿名内存映射时, 即flags中设置了MAP_ANONYMOUS, fd设为-1.
有些系统不支持匿名内存映射, 则可以使用fopen打开/dev/zero文件, 然后对该文件进行映射,可以同样达到匿名内存映射的效果.
fd 可以指向普通文件, 也可以指向字符设备或者块设备等.
如果fd指向的是某个字符设备, 例如LCD驱动程序提供的设备节点, 针对这种fd使用mmap必须要求相应的驱动实现了.mmap函数. 与普通文件映射不同的是, 针对这种fd的映射在创建映射时就会分配物理内存并建立页表(由驱动程序在.mmap中完成, 而不是由系统的缺页异常程序来处理)
offset : 指定了文件映射的偏移量, 该值用于只映射了文件部分内容时(如果映射了整个文件, 则偏移量为0). offset必须是PAGE_SIZE的整数倍.
用户层的mmap函数最终会调用内核提供的系统调用来完成实际的功能. 在ARM体系架构中, 这个系统调用函数是sys_mmap_pgoff.
// mm/mmap.c
SYSCALL_DEFINE6(mmap_pgoff,unsignedlong, addr,unsignedlong, len,
unsignedlong, prot,unsignedlong, flags,
unsignedlong, fd,unsignedlong, pgoff)
sys_mmap_pgoff -> vm_mmap_pgoff -> do_mmap_pgoff -> do_mmap
do_mmap完成实际的动作, 该函数也定义在mm/mmap.c中:
// mm/mmap.c
/*
* The caller must hold down_write(¤t->mm->mmap_sem).
*/
unsignedlong do_mmap(struct file *file,unsignedlong addr,
unsignedlong len,unsignedlong prot,
unsignedlong flags, vm_flags_t vm_flags,
unsignedlong pgoff,unsignedlong*populate)
其代码流程大致如下:
do_mmap
get_unmapped_area
计算标志
mmap_region
find_vma_links & do_munmap
accountable_mapping
vma_merge
创建新的vm_area_struct实例
kmem_cache_zalloc
if (file != NULL) file->f_op->mmap
else shmem_zero_setup
vma_link
返回映射的起始地址
如果设置了VM_LOCKED 标志, 则会导致mm_populate被调用
do_mmap曾经是内核中最长的函数之一。它现在已经分成两个部分,但仍然相当长。一个部分需要彻底检查用户应用程序传递的参数,第二个部分需要考虑大量特殊情况和微妙之处。由于后一部分对于从一般意义上理解所涉及的机制没什么价值,我们只考察具有代表性的标准情况:用MAP_SHARED 映射普通文件。另外,为避免使描述过于冗长,代码流程图也进行了相应删减。
首先, 调用前文介绍get_unmapped_area函数, 在虚拟地址空间中找到一个适当的地址用于创建子区域. 我们知道, 应用程序可以对映射指定固定地址、建议一个地址或由内核选择地址.
然后, 计算标志, 代码如下:
// mm/mmap.c : do_mmap
vm_flags |= calc_vm_prot_bits(prot)| calc_vm_flag_bits(flags)|
mm->def_flags | VM_MAYREAD | VM_MAYWRITE | VM_MAYEXEC;
calc_vm_prot_bits 和calc_vm_flag_bits 将用户空间传递过来的标志和访问权限常数合并到一个共同的标志集中, 在后续的操作中比较易于处理( MAP_ 和PROT_ 标志转换为前缀VM_ 的标志)
有趣的是,内核在从当前运行进程的mm_struct实例获得def_flags 之后,又将其包含到标志集中。def_flags 的值为0 或VM_LOCK 。前者不会改变结果标志集,而VM_LOCK 意味着随后映射的页无法换出。为设置def_flags 的值,进程必须发出mlockall系统调用,使用上述机制防止所有未来的映射被换出,即使在创建时没有显式指定VM_LOCK 标志,也是如此。
在检查过参数并设置好所有需要的标志之后, 剩余的工作委托给mmap_region.
当通过get_unmapped_area获得合适的起始地址后, mmap_region首先调用find_vma_links检查[addr , add + length]是否已经被包含在某个现存的映射区? 如果是, 则调用do_munmap(mm, addr, len)从现存的映射区中删除[addr , add + length]这段区域, 注意这时可能涉及到区域拆分. do_munmap会在后文详细介绍.
然后mmap_region调用accountable_mapping检查内存限制, 如果没有设置MAP_NORESERVE 标志或内核参数sysctl_overcommit_memory设置为OVERCOMMIT_NEVER (即,不允许过量使用), 则调用security_vm_enough_memory_mm. 该函数选择是否分配操作所需的内存. 如果它选择不分配, 则系统调用结束, 返回-ENOMEM .
sysctl_overcommit_memory 可以借助于/proc/sys/vm/overcommit_memory 设置。当前有3个过量使用选项。1允许应用程序分配与所要数量同样多的内存,即使超出系统地址空间所允许的限制。
0意味着应用启发式过量使用,可用页的数目是通过计算页缓存、交换区和未使用页帧的总数而得到,且允许分配少量页的请求。
2表示严格模式,称之为严格过量使用,其中允许分配的页数如下计算:
allowed = (totalram_pages -hugetlb) * sysctl_overcommit_ratio / 100;
allowed += total_swap_pages;
这里sysctl_overcommit_ratio 是一个可配置的内核参数,通常设置为50。如果已使用页的总数超出所
述计算结果,则内核拒绝继续分配内存。
允许一个应用程序分配超出理论处理限制的页数,有什么意义么?科学计算应用有时需要这种特性。一些应用程序趋向于分配大量的内存,实际上并不需要使用,但从应用程序作者的看法来说,多分配一些总是好的,以防万一嘛!如果内存的确从不使用,那么不会分配物理页帧,也没有问题。显然这种程序设计风格是糟糕的惯例,但遗憾的是,这不是评估软件价值的标准。在计算机科学以外的科学界,编写清洁的代码通常不会带来奖励。相关的领域中,通常只关注程序在给定的配置下能够正常工作,而使得程序未来仍然可用或可移植的工作,看起来不能提供眼前可见的好处,因此通常被认为是没有价值的。
接下来mmap_region调用vma_merge, 看看需要新创建的子区域能否与某个现存的区域合并. 如果可以, 那就只用调整这个现存区域的vm_area_struct的相关字段, 不需要创建新的vm_area_struct实例.
如果不能合并, 则kmem_cache_zalloc分配一个新的vm_area_struct实例, 并执行如下的初始化动作:
初始化vm_area_struct的各个字段
如果是普通的文件映射(即file != NULL), 则调用file->f_op->mmap, 大多数文件系统将f_op->mmap赋值为generic_file_mmap.
generic_file_mmap所做的主要工作就是将新建的vm_area_struct实例的vm_ops 成员设置为generic_file_vm_ops, 《3.3.4 内存映射区与文件地址空间建立关联》一节中介绍过generic_file_vm_ops, 其关键要素就是filemap_fault.
filemap_fault的主要目的是读取文件中的数据, 这里不再细说
如果file == NULL, 则说明是一个匿名映射, 此时会调用shmem_zero_setup, 将file指向/dev/zero
最后, 调用vma_link, 把新建的vm_area_struct实例添加到mm_struct的红黑树中
新实例创建成功后, 返回此子区域的起始虚拟地址.
在do_mmap的最后, 如果设置了vm_flags & VM_LOCKED不为0, 则会将populate赋值为len

vm_mmap_pgoff会检查do_mmap是否设置了populate, 如果设置了, 则会调用mm_populate
// mm/util.c
unsignedlong vm_mmap_pgoff(struct file *file,unsignedlong addr,
unsignedlong len,unsignedlong prot,
unsignedlong flag,unsignedlong pgoff)
{
...
ret = do_mmap_pgoff(file, addr, len, prot, flag, pgoff,
&populate);//do_mmap_pgoff直接调用do_mmap
if(populate)
mm_populate(ret, populate);
...
}
上述这段逻辑的目的是: 如果设置了VM_LOCKED,不管是通过系统调用的标志参数显式传递进来,还是通过mlockall 机制隐式设置, 内核都会调用mm_populate依次扫描映射中各页, 对每一页触发缺页异常以便读入其数据. 当然, 这意味着失去了延迟读取带来的性能提高, 但内核可以确保在映射建立后所涉及的页总是在物理内存中. 毕竟VM_LOCKED 标志用来防止从内存换出页, 因此这些页必须先读进来.
删除映射(munmap , do_munmap)
用户代码如果想删除mmap建立的映射, 比如调用munmap, 其定义如下:
#include <sys/mman.h>
int munmap(void* addr,size_t len );
它需要两个参数: 解除映射区域的起始地址和长度. 接下来对已删除区的访问将导致段错误.
用户层的munmap函数最终会调用内核提供的系统调用来完成实际的功能. 在ARM体系架构中, 这个系统调用函数是sys_munmap.
// mm/mmap.c
SYSCALL_DEFINE2(munmap,unsignedlong, addr,size_t, len)
{
profile_munmap(addr);
return vm_munmap(addr, len);
}
sys_munmap会调用vm_munmap, 后者直接调用do_munmap.
do_munmap完成实际的动作, 该函数也定义在mm/mmap.c中:
// mm/mmap.c
int do_munmap(struct mm_struct *mm,unsignedlong start,size_t len)
其代码流程大致如下:
do_munmap
find_vma
需要拆分吗? ---> __split_vma
再次find_vma
还有必要再次拆分吗? ---> __split_vma
detach_vmas_to_be_unmapped
unmap_region
remove_vma_list
首先, 调用find_vma, 找到与给定的地址相对应的vm_area_struct实例.
如果(实例的起始地址vma_area_struct->start)大于(给定的起始地址start), 则必须进行区域拆分, 把区域拆分为[vma_area_struct->start , start - 1] 和[start , vma_area_struct->end]这两个区域.
拆分后再次调用find_vma, 寻找与给定地址相对应的vma_area_struct实例, 此时找到的实例的起始地址一定与给定的地址相等. 这时在检查(实例的结束地址vma_area_struct->end) 是否大于(给定的起始地址start + len), 如果是, 则需要再次拆分, 把区域拆分为[vma_area_struct->start , start + end -1] 和[start + end , vma_area_struct->end]这两个区域.
经过上述拆分动作后, 我们一定能找到一个vma_area_struct实例, 其起始地址为start, 结束地址为start + len. 然后针对该实例调用detach_vmas_to_be_unmapped.
detach_vmas_to_be_unmapped会把实例从进程的红黑树和线性链表中删除.
最后还有两个步骤. 首先调用unmap_region 从页表删除与映射相关的所有项. 完成后, 内核还必须确保将相关的项从TLB移除或使之无效.
其次, 用remove_vma_list 释放vm_area_struct 实例占用的空间, 完成从内核中删除映射的工作.
非线性映射
按照上文的描述,普通的映射将文件中一个连续的部分映射到虚拟内存中一个同样连续的部分。如果需要将文件的不同部分以不同顺序映射到虚拟内存的连续区域中,通常必须使用几个映射,从消映射耗的资源来看,代价比较昂贵(特别是需要分配的vm_area_struct 数量)。实现同样效果简单的方法是使用非线性映射,该特性在内核版本2.5开发期间引入。内核提供了一个独立的系统调用(remap_file_pages),专门用于该目的。
不过在4.1的内核中, 该特性已经被取消了, 原因是没有多少人会用到这个特性, 而且这个特性会让虚拟内存管理的代码变得有点混乱, 并会占用PTE的低位. 所以最新的内核代码已经取消了该特性, 详细的原因介绍请见< Documentation/vm/remap_file_pages.txt>
如果读者依然对非线性映射感兴趣, 请阅读《深入Linux内核架构: 4.7.3 非线性映射》
.mmap的一些思考[@ 2018.12.24]
用户空间调用mmap函数后, 内核最终要完成的事情有三件:
1) 在进程的虚拟地址空间找到一块区域(struct vm_area_struct)
2) 从物理内存上分配一块内存
3) 修改页表, 建立物理内存到虚拟地址空间的映射
其中事情1是内核帮我们完成的(参考《创建映射》), 当内核找到空间区域后, 会调用底层驱动实现的mmap函数(file->f_op->mmap), 并把代表这块区域的struct vm_area_struct作为参数传递给mmap.
底层驱动的mmap函数有多种方式, 这里列几种见过的方式:
a) 一种是在mmap函数里面实现vm_operations_struct里面定义的函数(主要是fault函数), 然后把vm_operations_struct赋值给vm_area_struct. 当用户空间访问对应的虚拟地址时, 内核会触发缺页异常, 最终会调用这里的vm_operations_struct.fault. 在fault函数中, 我们可以分配物理内存并修改页表建立映射.目前对文件的mmap操作用的是这种方式(generic_file_vm_ops).
b) 第二种是在mmap里面分配物理内容并建立映射, 这样就不需要实现vm_operations_struct了, 因为映射已经建立好了, 不会产生缺页异常. 目前一些涉及到DMA访问的硬件驱动都是这样做的, (代码示例没找到..).
c) 第三种方式是在mmap函数被调用前, 在驱动的其它地方先分配物理内存(例如在驱动初始化时, 或者随后的某个时候), 然后在mmap函数里面只负责修改页表建立映射.目前很多地方都是这样做的, 例如sound里面的snd_pcm_lib_default_mmap, 或者LCD里面的fb_mmap.
3.3.5 堆的管理
堆是进程中用于动态分配变量和数据的内存区域,堆的管理对应用程序员不是直接可见的。因为它依赖标准库提供的各个辅助函数(其中最重要的是malloc )来分配任意长度的内存区。malloc 和内核之间的经典接口是brk 系统调用,负责扩展/收缩堆。
堆是一个连续的内存区域,在扩展时自下至上增长。前文提到的mm_struct 结构,包含了堆在虚拟地址空间中的起始和当前结束地址(start_brk 和brk )。
brk系统调用的定义如下:
// mm/mmap.c
SYSCALL_DEFINE1(brk,unsignedlong, brk)
brk 系统调用只需要一个参数,用于指定堆在虚拟地址空间中新的结束地址(如果堆将要收缩,则小于当前值)。
代码流程大致如下:

brk 机制不是一个独立的内核概念,而是基于匿名映射实现,以减少内部的开销。因此前几节讨论的许多用于管理内存映射的函数,都可以在实现sys_brk 时重用。
在检查过用作brk 值的新地址未超出堆的限制之后,sys_brk 第一个重要操作是将请求的地址按页长度对齐。
// mm/mmap.c : SYSCALL_DEFINE1(brk, unsigned long, brk)
newbrk = PAGE_ALIGN(brk);
oldbrk = PAGE_ALIGN(mm->brk);
该代码确保brk 的新值(原值也同样)是系统页长度的倍数. 换句话说, 一页是用brk 能分配的最小内存区块. 因此在用户空间需要另一个分配器函数, 将页拆分为更小的区域. 这是就是C标准库中malloc的主要任务.
在需要收缩堆时将调用do_munmap, 我们在《删除映射》一节已经熟悉了该函数.
// mm/mmap.c : SYSCALL_DEFINE1(brk, unsigned long, brk)
/* Always allow shrinking brk. */
if(brk <= mm->brk){
if(!do_munmap(mm, newbrk, oldbrk-newbrk))
goto set_brk;
goto out;
}
如果堆将要扩大, 内核首先必须检查新的长度是否超出进程的最大堆长度限制. find_vma_intersection 就是用来干这个事的, 它会检查扩大的堆是否与进程中现存的映射重叠. 倘若如此, 则什么也不做, 立即返回.
// mm/mmap.c : SYSCALL_DEFINE1(brk, unsigned long, brk)
/* Check against existing mmap mappings. */
if(find_vma_intersection(mm, oldbrk, newbrk+PAGE_SIZE))
goto out;
检查过后, 将扩大堆的实际工作委托给do_brk.函数总是返回mm->brk 的新值, 无论与原值相比是增大、缩小、还是不变。
// mm/mmap.c : SYSCALL_DEFINE1(brk, unsigned long, brk)
/* Ok, looks good - let it rip. */
if(do_brk(oldbrk, newbrk-oldbrk)!= oldbrk)
goto out;
我们不需要单独讨论do_brk, 因为实质上它是do_mmap的简化版本, 没什么新东西. 与后者类似, 它在用户地址空间中创建了一个匿名映射, 但省去了一些安全检查和用于提高代码性能的对特殊情况的处理.
如果一切顺利, 则会进入到set_brk.
// mm/mmap.c : SYSCALL_DEFINE1(brk, unsigned long, brk)
set_brk:
mm->brk = brk;
populate = newbrk > oldbrk &&(mm->def_flags & VM_LOCKED)!=0;
up_write(&mm->mmap_sem);
if(populate)
mm_populate(oldbrk, newbrk - oldbrk);
return brk;
注意, set_brk最后调用了mm_populate, 我们在《创建映射》一节中介绍过该函数, 该函数会依次扫描映射中各页, 对每一页触发缺页异常以便读入其数据.这说明当sys_brk成功返回时, 内核已经向伙伴系统申请了内存并将内存页面初始化为0了, 页表也会在此时创建.
3.4 缺页异常的处理
在实际需要某个虚拟内存区域的数据之前,虚拟和物理内存之间的关联不会建立。如果进程访问的虚拟地址空间部分尚未与页帧关联,处理器自动地引发一个缺页异常,内核必须处理此异常。这是内存管理中最重要、最复杂的方面之一,因为必须考虑到无数的细节。
下图概述了缺页处理的整个流程, 后文我们会依次介绍各部分的细节:

首先, 硬件会触发缺页异常, 然后会跳转到中断向量表, 然后中断向量表最终会调用到do_page_fault
do_page_fault大致分为两大部分:
首先会检查mm_struct是否为NULL, 如果是, 则证明异常可能是内核中断触发的; 或者如果!user_mode(regs)为真, 证明此时处于内核态. 此时会调用__do_kernel_fault
如果是用户态, 即mm_struct != NULL, 则会调用__do_page_fault, 后者会进一步调用体系架构无关的代码, 处理缺页异常. 这里会根据不同的情况, 最终调用do_fault/do_anonymous_page/do_swap_page 3者之一.
如果do_page_fault成功的话, 则系统已经分配好物理内存, 而且也往内存里面写入了初始数据并建立了页表. 此时系统会重新执行触发缺页异常的指令.
如果do_page_fault失败的话, 则会调用__do_user_fault, 主要目的是向用户进程发送SIGSEGV等信号, 从而结束用户进程. 这块逻辑很简单, 后文就不分析这个函数了.
3.4.1 中断处理流程
本小节不会关注Linux内核中断处理的原理(会开专门一篇文章来说中断的原理), 这里只大致列一下ARM系统架构下缺页异常中断的流程.
首先, 硬件触发Prefetch Abort, 然后硬件就会把PC指向中断向量表的vector_pabt.
ARM架构中断向量表的定义如下:
//arch/arm/kernel/entry-armv.S
__vectors_start:
W(b) vector_rst
W(b) vector_und
W(ldr) pc, __vectors_start +0x1000
W(b) vector_pabt
W(b) vector_dabt
W(b) vector_addrexcptn
W(b) vector_irq
W(b) vector_fiq
vector_pabt的实现也在同一个汇编文件中, 由宏” vector_stub pabt”定义的, 最终会调用__pabt_usr -> pabt_helper -> CPU_PABORT_HANDLER
CPU_PABORT_HANDLER的如下:
//arch/arm/include/asm/glue-pf.h
......
#ifdef CONFIG_CPU_PABRT_V7
# ifdef CPU_PABORT_HANDLER
# define MULTI_PABORT 1
# else
# define CPU_PABORT_HANDLER v7_pabort
# endif
#endif
......
根据不同的架构版本, 选择不同的实现, 如果CPU是ARMv7架构的, 则对应v7_pabort.
v7_pabort的实现是arch/arm/mm/pabort-v7.S, 它会直接调用do_PrefetchAbort. 后者的实现如下:
//arch/arm/mm/fault.c
asmlinkage void __exception
do_PrefetchAbort(unsignedlong addr,unsignedint ifsr,struct pt_regs *regs)
{
conststruct fsr_info *inf = ifsr_info + fsr_fs(ifsr);
struct siginfo info;
if(!inf->fn(addr, ifsr | FSR_LNX_PF, regs))
return;
......
}
ifsr_info其实是个数组, 先通过ifsr从这个数组中取出对应的数组项, 然后调用inf->fn. 那这个fn到底是谁呢?
来看下面这段实现:
//arch/arm/mm/fault.c
struct fsr_info {
int (*fn)(unsignedlong addr,unsignedint fsr,struct pt_regs *regs);
int sig;
int code;
constchar*name;
};
/* FSR definition */
#ifdef CONFIG_ARM_LPAE
#include "fsr-3level.c"
#else
#include "fsr-2level.c"
#endif
//arch/arm/mm/fsr-2level.c
staticstruct fsr_info ifsr_info[]={
{ do_bad, SIGBUS, 0, "unknown 0" },
...
{ do_page_fault,SIGSEGV, SEGV_MAPERR,"page translation fault" },
...
}
fault.c中定义了一个结构体fsr_info, 而在fsr-2level.c中又定义并初始化了这个结构体的一个数组ifsr_info.看见了吧, 其中有一个元素对应的函数是do_page_fault.
至于为什么就能调用到do_page_fault对应的数组项, 我也没细看, 这里就不多说了, 反正知道中断处理最终调用了do_page_fault就行.
3.4.2 do_page_fault
do_page_fault的实现如下, 为了精简, 我们省去了很多部分, 只留下了骨干:
//arch/arm/mm/fault.c
staticint __kprobes
do_page_fault(unsignedlong addr,unsignedint fsr,struct pt_regs *regs)
{
...
/*
* If we're in an interrupt or have no user
* context, we must not take the fault..
*/
if(in_atomic()||!mm)
gotono_context;
...
/*
* As per x86, we may deadlock here. However, since the kernel only
* validly references user space from well defined areas of the code,
* we can bug out early if this is from code which shouldn't.
*/
if(!down_read_trylock(&mm->mmap_sem)){
if(!user_mode(regs)&&!search_exception_tables(regs->ARM_pc))
gotono_context;
...
}else{
...
}
fault = __do_page_fault(mm, addr, fsr, flags, tsk);
...
/*
* Handle the "normal" case first - VM_FAULT_MAJOR / VM_FAULT_MINOR
*/
if(likely(!(fault &(VM_FAULT_ERROR | VM_FAULT_BADMAP | VM_FAULT_BADACCESS))))
return 0;
/*
* If we are in kernel mode at this point, we
* have no context to handle this fault with.
*/
if(!user_mode(regs))
gotono_context;
...
if(fault & VM_FAULT_SIGBUS){
/*
* We had some memory, but were unable to
* successfully fix up this page fault.
*/
sig = SIGBUS;
code = BUS_ADRERR;
}else{
/*
* Something tried to access memory that
* isn't in our memory map..
*/
sig = SIGSEGV;
code = fault == VM_FAULT_BADACCESS ?
SEGV_ACCERR : SEGV_MAPERR;
}
__do_user_fault(tsk, addr, fsr, sig, code, regs);
return0;
no_context:
__do_kernel_fault(mm, addr, fsr, regs);
return0;
}
首先, 注意3处goto no_context语句, 总结起来就是如果in_atomic() || mm_struct == NULL || !user_mode(regs)则会进入__do_kernel_fault.
其次, __do_page_fault负责用户进程缺页处理, 注意其返回值fault: 如果fault没有错误, 则return 0, 代表处理成功了; 否则会根据fault的具体值, 调用__do_user_fault, 向用户进程发送SIGBUS/SIGSEGV信号.
3.4.3 内核缺页异常处理(__do_kernel_fault)
根据上一节的介绍, 下列几种情况可能导致内核缺页异常:
如果在原子上下文中访问了异常地址, 此时in_atomic() == true.
如果在中断上下文或者内核线程中访问了异常地址, 此时mm_struct == NULL.
Note, 关于内核线程, 请参考《深入Linux内核架构1.3.3节》
如果用户进程调用了内核提供的系统调用接口, 但是进程却传递了不正确的地址. 这种情况下首先会尝试由__do_page_fault处理, 如果不成功, 则会调用__do_kernel_fault.
__do_kernel_fault代码看上去比较简单:
//arch/arm/mm/fault.c
staticvoid
__do_kernel_fault(struct mm_struct *mm,unsignedlong addr,unsignedint fsr,
struct pt_regs *regs)
{
/*
* Are we prepared to handle this kernel fault?
*/
if(fixup_exception(regs))
return;
/*
* No handler, we'll have to terminate things with extreme prejudice.
*/
bust_spinlocks(1);
pr_alert("Unable to handle kernel %s at virtual address %08lx\n",
(addr < PAGE_SIZE)?"NULL pointer dereference":
"paging request", addr);
show_pte(mm, addr);
die("Oops", regs, fsr);
bust_spinlocks(0);
do_exit(SIGKILL);
}
首先会尝试调用fixup_exception处理异常, 这是最后的机会! 我们暂时不打算深入fixup_exception机制, 有兴趣的可以自行阅读代码.
如果最后的机会都失败了, 那么内核就会打印我们经常看到的Oops信息了...
3.4.4 用户进程缺页异常处理(__do_page_fault)
__do_page_fault首先会通过异常地址找到对应的映射区, 如果映射区不存在, 说明这是个无效地址, 会返回VM_FAULT_BADMAP错误. 然后会检查映射区的访问权限(READ/WRITE/EXEC), 如果权限不对, 说明是个非法访问, 此时会返回VM_FAULT_BADACCESS错误.
上述检查通过后, 就会调用handle_mm_fault进行处理.
handle_mm_fault不依赖于底层体系结构, 而是在内存管理的框架下、独立于系统而实现的. 该函数确认在各级页目录中, 通向对应于异常地址的页表项的各个页目录项都存在, 然后调用handle_pte_fault 函数分析缺页异常的原因.
handle_pte_fault的主要逻辑如下:
//mm/memory.c
staticint handle_pte_fault(struct mm_struct *mm,
struct vm_area_struct *vma,unsignedlong address,
pte_t *pte, pmd_t *pmd,unsignedint flags)
{
pte_t entry;
spinlock_t *ptl;
entry =*pte;
barrier();
if(!pte_present(entry)){
if(pte_none(entry)){
if(vma->vm_ops){
if(likely(vma->vm_ops->fault))
return do_fault(mm, vma, address, pte,
pmd, flags, entry);
}
return do_anonymous_page(mm, vma, address,
pte, pmd, flags);
}
return do_swap_page(mm, vma, address,
pte, pmd, flags, entry);
}
if(flags & FAULT_FLAG_WRITE){
if(!pte_write(entry))
return do_wp_page(mm, vma, address,
pte, pmd, ptl, entry);
entry = pte_mkdirty(entry);
}
...
}
参数pte 是指向相关页表项(pte_t )的指针.
如果页不在物理内存中,即!pte_present(entry) , 则必须区分下面3种情况:
如果没有对应的页表项( pte_none == true), 说明此时只建立了内存映射区和文件之间的映射, 还没分配物理内存和创建页表.
如果vma->ops->fault不为空, 则调用do_fault处理
否则调用do_anonymous_page处理
如果对应的页表项存在(pte_none == true), 但是对应的页面不在物理内存, 则意味着该页已经换出, 因为需要调用do_swap_page从系统的某个交换区将页表换入.
如果页存在物理内存, 那if (!pte_present(entry))就不成立了, 上述几个do_xxx操作都不会执行. 这种情况的原因是该映射区对页授予了写权限, 但是页表指示无法写入此页, 因此触发了异常.
这种情况由do_wp_page负责处理, do_wp_page 负责创建该页的副本, 并修改进程页表的对应表项, 使之指向新的物理页面, 然后使能页表项中对于新物理页的写权限. 该机制称为写时复制(copy on write, 简称COW).
COW机制最常见的场合就是在进程创建子进程时(fork), 子进程所需的物理页并不是立即复制的, 而是将父进程的物理页映射到子进程的地址空间中作为“只读”副本, 以免在复制信息时花费太多时间. 在子进程实际发生写访问之前, 都不会为进程创建页的独立副本.
另外, 创建内存映射时, 如果是采用的私有映射, 则也会采用COW机制, 详见3.3.4 《创建映射》.
do_fault
按照上一节的分析, 进入到do_fault说明vma->ops->fault不为空, 更进一步来说, 就是映射一定与某个文件挂钩.
回想《3.3.4 创建映射》中的细节: 如果是普通文件的映射, 不管是共享还是私有映射, 都会与普通文件file挂钩; 如果是匿名&共享映射, 则会与/dev/zero文件挂钩; 但如果是匿名&私有映射, 则不会与任何文件挂钩, 这种情况将由do_anonymous_page处理, 不在本节描述之列.
在来看看do_fault的细节:
//mm/memory.c
staticint do_fault(struct mm_struct *mm,struct vm_area_struct *vma,
unsignedlong address, pte_t *page_table, pmd_t *pmd,
unsignedint flags, pte_t orig_pte)
{
pgoff_t pgoff =(((address & PAGE_MASK)
- vma->vm_start)>> PAGE_SHIFT)+ vma->vm_pgoff;
pte_unmap(page_table);
if(!(flags & FAULT_FLAG_WRITE))
returndo_read_fault(mm, vma, address, pmd, pgoff, flags,
orig_pte);
if(!(vma->vm_flags & VM_SHARED))
returndo_cow_fault(mm, vma, address, pmd, pgoff, flags,
orig_pte);
returndo_shared_fault(mm, vma, address, pmd, pgoff, flags, orig_pte);
}
do_fault会分情况调用3个不同的处理函数, 这3个函数的共同之处是都会调用__do_fault和do_set_pte.__do_fault会进一步调用vma->vm_ops->fault, fault会负责调用伙伴系统API分配物理页帧, 并调用address_space_operations提供的readpage接口, 将文件内容读取到物理页帧. do_set_pte则会调用mk_pte创建页表项, 并将页表项指向物理页帧的地址.
3个函数的不同之处是:
do_read_fault : 如果!(flags & FAULT_FLAG_WRITE) == true, 说明这是一个只读映射, 此时do_read_fault只是简单的调用了__do_fault和do_set_pet, 没有做其它额外动作
do_cow_fault : 如果!(vma->vm_flags & VM_SHARED) == true, 说明这是一个私有映射, 而且!(flags & FAULT_FLAG_WRITE) == false (否则就会调用do_read_fault了), 说明这个映射会有写访问. 这种情况下, do_cow_fault调用__do_fault之后, 会申请一个新的物理页面, 并将数据拷贝到这个新的物理页面, 然后调用do_set_pte, 将页表指向这个新创建的物理页面.
注意, 该函数名称虽然有cow, 但是跟我们前文描述的do_wp_page的处理情况不一样: do_cow_fault处理的!pte_present(entry) == true的写时复制, 而do_wp_page处理的是!pte_present(entry) == false的写时复制
do_shared_fault : 如果是共享映射, 并且映射会有写访问, 则会调用do_shared_fault. 这种映射意味着对于物理页面的写操作会最终写回到磁盘文件上. 因此do_shared_fault除了调用__do_fault和do_set_pte之外, 还会调用vma->vm_ops->page_mkwrite检查磁盘文件是否可以写入, 另外还会调用set_page_dirty把页面标记为脏, 这样当物理页面被释放时, 就会把内容写回到磁盘文件.
do_anonymous_page
如果是匿名&私有映射, 则不会与任何文件挂钩, 这种情况将由do_anonymous_page处理.
下面是该函数的实现, 我们省略了大部分内容, 只留下了梗概:
//mm/memory.c
staticint do_anonymous_page(struct mm_struct *mm,struct vm_area_struct *vma,
unsignedlong address, pte_t *page_table, pmd_t *pmd,
unsignedint flags)
{
...
page = alloc_zeroed_user_highpage_movable(vma, address);
...
entry = mk_pte(page, vma->vm_page_prot);
if(vma->vm_flags & VM_WRITE)
entry = pte_mkwrite(pte_mkdirty(entry));
...
}
alloc_zeroed_user_highpage_movable负责向伙伴系统申请物理页面并清零页面. mk_pte负责创建页表项并指向物理页面. 如果这是一个可写的匿名映射, 则会将页表项的相应标志设置为可写.
do_swap_page
这个函数的很多细节与页面交换/回收机制相关, 这里就不细述了, 有兴趣可以阅读《深入Linux内核架构第18章》
do_wp_page
下图给出了do_wp_page的大致流程(注意实际代码的顺序可能与图示的不一样, 不过没关系, 主体逻辑都是一样的):

我们考察的是一个略微简化的版本,其中省去了与交换缓存潜在的冲突,以及一些边边角角的情况。因为这些都使问题复杂化,而无助于揭示机制自身的本质。
内核首先调用vm_normal_page ,通过页表项找到页的struct page 实例,本质上这个函数基于pte_pfn 和pfn_to_page ,这两者是所有体系结构都必须定义的。前者查找与页表项相关的页号,而后者确定与页号相关的page 实例。这是可行的,因为写时复制机制只对内存中实际存在的页调用(否则,首先需要通过缺页异常机制自动加载)。
在用page_cache_get 获取页之后,接下来anon_vma_prepare 准备好逆向映射机制的数据结构,以接受一个新的匿名区域。
由于异常的来源是需要将一个充满有用数据的页复制到新页,因此内核调用alloc_page_vma 分配一个新页。cow_user_page 接下来将异常页的数据复制到新页,进程随后可以对新页进行写操作。
然后使用page_remove_rmap ,删除到原来的只读页的逆向映射。
新页添加到页表,此时也必须更新CPU的高速缓存。
最后,使用lru_cache_add_active 将新分配的页放置到LRU缓存的活动列表上,并通过page_add_anon_rmap 将其插入到逆向映射数据结构。此后,用户空间进程可以向页写入数据。
额, 上文是完全抄的《深入Linux内核架构》, 没时间仔细理解了, 先放这里吧, 后面有需要再来审查.
3.5 在内核和用户空间之间复制数据
内核经常需要从用户空间向内核空间复制数据。例如,在系统调用中通过指针间接地传递冗长的数据结构时。反过来,也有从内核空间向用户空间写数据的需求。
有两个原因,使得不能只是传递并反引用指针。首先,用户空间程序不能访问内核地址;其次,无法保证用户空间中指针指向的虚拟内存页确实与物理内存页关联。因此内核需要提供几个标准函数,以处理内核空间和用户空间之间的数据交换,并考虑到这些特殊情况。下面列出了这些API.
3.5.1 APIs
头文件: include/linux/uaccess.h
|
Function |
Comment |
|
copy_from_user(to, from, n)
__copy_from_user |
从from (用户空间)到to (内核空间)复制一个长度为n 字节的字符串. 返回值代表还剩多少字节的数据需要拷贝. 可能会休眠. |
|
copy_to_user(to, from, n)
__copy_to_user |
从from (内核空间)到to (用户空间)复制一个长度为n 字节的字符串. 返回值代表还剩多少字节的数据需要拷贝. 可能会休眠. |
|
|
|
|
get_user(type *to, type* ptr) __get_user |
从ptr 读取一个简单类型变量(char ,long,…),写入to 。根据指针的类型,内核自动判断需要传输的字节数(1、2、4、8) |
|
put_user(type *from, type *to) __put_user |
将一个简单值从from (内核空间)复制到to (用户空间)。相应的值根据指针类型自动判断
put/get_user专门用于处理小数据, 适用于ioctl中, 细节见《字符设备驱动5.4 – ioctl arg》 |
|
|
|
|
strncopy_from_user(to, from, n) __strncopy_from_user |
将0结尾字符串(最长为n 个字符)从from(用户空间)复制到to (内核空间) |
|
|
|
|
clear_user(to, n) __clear_user |
用0 填充to (用户空间)之后的n 个字节 |
|
strlen_user(s) __strlen_user |
获取用户空间中的一个0 结尾字符串的长度(包括结束字符) |
|
strnlen_user(s, n) __strnlen_user |
获取一个0 结尾字符串的长度, 但搜索限制为不超过n 个字符 |
根据表的内容, 大多数函数都有两个版本. 在没有下划线前缀的版本中, 还会调用access_user, 对用户空间地址进行检查. 所执行的检查依体系结构而不同. 例如, 一种平台的检查可能是确认指针确实指向用户空间中的位置. 而另一种可能在内存中找不到页时, 调用handle_mm_fault 以确保数据已经读入内存, 可供处理. 所有函数都应用了上述用于检测和校正缺页异常的修正机制.
这些函数主要是用汇编语言实现的. 由于调用非常频繁, 对性能要求极高. 另外, 还必须使用GNU C用于嵌入汇编的复杂构造和代码中的链接指令, 将异常代码也集成进来. 我不打算详细讨论各个函数的实现.
在内核版本2.5开发期间, 编译过程增加了一个检查工具. 该工具分析源代码, 检查用户空间的指针是否能直接反引用. 源自用户空间的指针必须用关键字__user 标记, 以便工具分辨所需检查的指针. 一个特定的例子是chroot 系统调用, 它需要一个文件名作为参数. 内核中还有许多地方包含了带有类似标记、来自用户空间的参数.
// fs/open.c
asmlinkage long sys_chroot(constchar__user* filename){
...
}

浙公网安备 33010602011771号