原始博客地址:
http://blog.csdn.net/qq_26626709/article/details/52742470
一、概述
1.虚拟地址空间
内存是通过指针寻址的,因而CPU的字长决定了CPU所能管理的地址空间的大小,该地址空间就被称为虚拟地址空间,因此32位CPU的虚拟地址空间大小为4G,这和实际的物理内存数量无关。
Linux内核将虚拟地址空间分成了两部分:
- 一部分是用户进程可用的,这部分地址是地址空间的低地址部分,从0到TASK_SIZE,称为用户空间
- 一部分是由内核保留使用的,这部分地址是地址空间的高地址部分,从KERNELBASE到结束,称为内核空间
与之相关的一些宏:
- KERNELBASE:内核虚拟地址空间的起始地址,一般和PAGE_OFFSET相同,但是也可能不同
- PAGE_OFFSET:内核虚拟地址空间中低端内存的起始地址
- PHYSICAL_START:内核物理地址的起始地址
- MEMORY_START:内核低端内存的物理起始地址
用户进程可用的部分在进程切换时会发生改变,但是由内核保留使用的部分在进程切换时是不变的。在32位系统上,两部分的典型划分比为3:1(该比例可修改),即4G虚拟地址空间中的3G是用户进程可访问的,而另外1G是保留给内核使用的,在这种划分下用户进程可用的虚拟地址空间是0x00000000-0xbfffffff,内核的虚拟地址空间是0xc0000000-0xffffffff。
不同的进程使用不同的用户空间可以使得不同进程的用户空间部分相互隔离,从而保护进程的用户空间部分。
内核空间的保护是通过CPU的特权等级实现的,所有现代CPU都提供了多个特权等级,每个特权等级可以获得的权限是不同的,当CPU处在某个权限等级时就只能执行符合这个等级的权限限制的操作。Linux使用了两个权限等级,分别对应于内核权限和用户权限,并且给属于内核的内存空间添加了权限限制,使得只有处于内核权限等级时CPU才能访问这些内存区域,这就将内核空间也保护了起来。
2.物理地址到虚拟地址的映射
可用的物理内存会被映射到内核虚拟地址空间中。在32位系统中,内核会将一部分物理内存直接映射到内核的虚拟地址空间中,如果访问内存时所使用的虚拟地址与内核虚拟地址起始值的偏移量不超过该部分内存的大小,则该虚拟地址会被直接关联到物理页帧;否则就必须借助”高端内存“来访问,因此也可以看出之所以使用“高端内存”是因为CPU可寻址的虚拟地址可能小于实际的物理内存,因而不得不借助其它机制(“高端内存”)来访问所有的内存。在IA-32系统上,这部分空间大小为896M。
64位系统不使用高端内存,这是因为64位的系统理论上可寻址的地址空间远大于实际的物理内存(至少现在是如此),因而就不必借助“高端内存”了。而对于用户进程来说,由于它的所有内存访问都通过页表进行,不会直接进行,因而对用户进程来说也不存在高端内存之说。
高端内存由32位架构的内核使用,在32位架构的内核中,要使用高端内存必须首先使用kmap将高端内存映射进内核的虚拟地址空间。
3.内存类型
从硬件角度来说存在两种不同类型的机器,分别用不同的方式来管理内存。
- UMA(uniform memory access):一致内存访问机器,它将可用内存以连续的方式组织起来。SMP系统中,每个CPU都可以以同样的速度访问内存。
- NUMA(non-uniform memory access):非一致内存访问机器总是多处理器机器。系统的各个CPU都有本地内存,可以支持快速访问。系统中的所有处理器都通过总线连接起来,进而可以访问其它CPU的本地内存,但是不如访问本地内存快。
lnux中如果要支持NUMA系统,则需要打开CONFIG_NUMA选项。
二、内存组织
linux内核对一致和不一致的内存访问系统使用了同样的数据结构,因此对于不同的内存布局,内存的管理算法几乎没有区别。对于UMA系统,将其看作只有一个NUMA节点的NUMA系统,即将其看成NUMA的特例。这样就将简化了内存管理的其它部分,其它部分都可以认为它们是在处理NUMA系统。
1.基本概念和相关数据结构
linux引入了一个概念称为node,一个node对应一个内存bank,对于UMA系统,只有一个node。其对应的数据结构为“struct pglist_data”。
对于NUMA系统来讲, 整个系统的内存由一个名为node_data 的struct pglist_data(page_data_t) 指针数组来管理。NUMA系统的内存划分如图所示:
每个node又被分成多个zone,每个zone对应一片内存区域。内核引入了枚举常量 zone_type 来描述zone的类型:
[cpp] view plain copy
- <mmzone.h>
- enum zone_type {
- #ifdef CONFIG_ZONE_DMA
- ZONE_DMA,
- #endif
- #ifdef CONFIG_ZONE_DMA32
- ZONE_DMA32,
- #endif
- ZONE_NORMAL,
- #ifdef CONFIG_HIGHMEM
- ZONE_HIGHMEM,
- #endif
- ZONE_MOVABLE,
- MAX_NR_ZONES
- };
它们之间的用途是不一样的:
- ZONE_DMA:可用作DMA的内存区域。该类型的内存区域在物理内存的低端,主要是ISA设备只能用低端的地址做DMA操作。
- ZONE_NORMAL:直接被内核直接映射到自己的虚拟地址空间的地址。
- ZONE_HIGHMEM:不能被直接映射到内核的虚拟地址空间的地址。
- ZONE_MOVABLE:伪zone,在防止物理内存碎片机制中使用
- MAX_NR_ZONES:结束标记
很显然根据内核配置项的不同,zone的类型是有变化的。每个zone都和一个数组关联在一起,该数组用于组织管理属于该zone的物理内存页。
zone用数据结构struct zone来表示。
所有的node都被保存在一个链表中。在使用时,内核总是尝试从与进程所运行的CPU所关联的NUMA节点申请内存。这是就要用到备用列表,每个节点都通过struct zonelist提供了备用列表,该列表包含了其它节点,可用于代替本节点进行内存分配,其顺序代表了分配的优先级,越靠前优先级越高。
2.阈值计算
当系统中可用内存很少的时候,内核线程kswapd被唤醒,开始回收释放page。pages_min, pages_low and pages_high这些参数影响着回收行为。
每个zone有三个阈值标准:pages_min, pages_low and pages_high,帮助确定zone中内存分配使用的压力状态。kswapd和这3个参数的互动关系如下图:
在最新的内核中这三个变量变成了watermark数组的成员,分别对应于WMARK_MIN,WMARK_LOW和WMARK_HIGH。
内核在计算这几个值之前会首先计算一个关键参数min_free_kbytes,它是为关键性分配保留的内存空间的最小值。该关键参数有一个约束:不能小于128k,不能大于64M。其计算公式:
[cpp] view plain copy
- lowmem_kbytes = nr_free_buffer_pages() * (PAGE_SIZE >> 10);
- min_free_kbytes = int_sqrt(lowmem_kbytes * 16);
阈值的计算由init_per_zone_pages_min( 最新内核中是init_per_zone_wmark_min)完成。该函数还会完成每个zone的lowmem_reserve的计算,该数组用于为不能失败的关键分配预留的内存页。这几个阈值的含义:
- page_min:如果空闲页数目小于该值,则该zone非常缺页,页面回收压力很大。
- page_low: 如果空闲页数目小于该值,kswapd线程将被唤醒,并开始释放回收页面。
- page_high: 如果空闲页面的值大于该值,则该zone的状态很完美, kswapd线程将重新休眠。
3.Zone等待队列表
当对一个page做I/O操作的时候,page需要被锁住,以防止不正确的数据被访问。做法是:
- 进程在访问page前,调用wait_on_page*函数,使进程加入一个等待队列(如果没有其它进程正在访问该页,就直接获得访问权限,否则加入等待队列)。
- 当当前访问page的进程完成自己的访问动作后,会调用unlock_page唤醒在该页上wait的进程,因而进程即可获得对页的访问权。
每个page都可以有一个等待队列,但是太多的分离的等待队列使得花费太多的内存访问周期。也可以让一个zone中的所有page都使用同一个队列,但是这就意味着,当一个page unlock的时候,访问这个zone里内存page的所有休眠的进程将都被唤醒,这样就会出现惊群效应(thundering herd)。
内核的解决方法是将所有的队列放在struct zone数据结构中,并通过哈希表zone->wait_table来管理zone中的等待队列。哈希表的方法可能会造成一些进程不必要的唤醒,但是这个是小概率事件是可以容忍的。
等待队列的哈希表的分配和建立在free_area_init_core()函数中(最终是在zone_wait_table_init()函数中)进行。
4.冷热页
zone中的pageset用于实现冷热分配器。热页指的是已经加载到CPU高速缓存的页,这种页的访问速度比在主存中的快。冷页就是不在高速缓存中的页。SMP系统中每个CPU都有一个或多个高速缓存,各个CPU的管理必须是独立的(即便在NUMA中每个CPU也都可以访问所有的内存页,因而其高速缓存也可能缓存所有的内存页)。
每个CPU都有一个struct per_cpu_pages结构,其定义如下:
[cpp] view plain copy
- struct per_cpu_pages {
- int count; /* number of pages in the list */
- int high; /* high watermark, emptying needed */
- int batch; /* chunk size for buddy add/remove */
- /* Lists of pages, one per migrate type stored on the pcp-lists */
- struct list_head lists[MIGRATE_PCPTYPES];
- };
- count:列表中页的数目
- high:一个阈值,如果列表中页数目超过该值,则表示列表中页太多了;没有下限的阈值,如果列表中没有成员,则重新填充。
- batch:如果可能,每次操作多个页,batch是每次操作页数目的参考值。
- lists:页列表。
在这些列表中,热页放在列表头部,冷页放在尾部。
5.页(Page)
1.页概念
内核使用struct page作为基本单位来管理物理内存,在内核看来,所有的RAM都被划分成了固定长度的页帧。每一个页帧包含了一个页,也就是说一个页帧的长度和一个页的长度相同。页帧是主存的一部分,是一个存储区域。页和页帧的区别在于,页是抽象的数据结构,可以存放在任意地方,而页帧是真实的存储区域。
struct page包含了跟踪一个物理页帧当前被用于什么的有信息。比如页面计数,标志等等。
2.映射页面到zone
内核使用struct page的flags中的字段来保存页所属于的zone以及node。这是通过set_page_zone和set_page_node,这两个函数由函数set_page_links调用。
三、页表
1.页表机制
CPU管理虚拟地址,因而物理地址需要映射到虚拟地址才能给CPU使用。用于将虚拟地址空间映射到物理地址空间的数据结构称为页表。
在使用4k大小页的情况下,4k地址空间需要2的20次方个页表项。即便每个页表项大小为4字节也需要4M内存,而每个进程都需要有自己的页表,这就成了一个极大的内存开销。而且在大多数情况下,虚拟地址空间的大部分区域都是没有被使用的,因而没必要为虚拟地址空间中的每个页都分配管理结构,因而实际中采用的是如下方案:
- 使用多级页表,每个线性地址被看为形如“页目录表+页目录表+...+页目录表+页表+页内偏移”的形式,每个比特组按照其含义被用于在相应的表中查找数据,最终找到页表。
- 进程的页表只包含了它所使用的地址空间。进程不使用的地址空间不需要加入进程的页表。
- 只有在进程实际需要一个页表时才会给该页分配RAM,而不是在一开始就为进程的所有页都分配空间。
页表中包含了关于该页的信息,例如是否存在于主存中,是否是“脏”的,访问所需权限等级,读写标志,cache策略等等。内核的页表保存在全局变量swapper_pg_dir中,应用进程的页表保存在task_struct->mm->pgd中,在应用进程切换时,会切换进程的页表(schedule-->__schedule-->context_switch-->switch_mm-->switch_mmu_context-->local_flush_tlb_mm)。
linux中采用了4级分页模型。如下:
PGD | PUD | PMD | PTE | OFFSET |
虽然采用了4级模型,但是:
- 对于32位且未使能物理地址扩展的系统,使用二级页表。Linux的做法是让页上级目录表和页中间目录表所包含的比特数目为0,让页全局目录表的比特数目包含除了页表和偏移量之外的所有比特,从而取消这两级目录。同时为了让代码可以同时运行在32比特环境和64比特环境,linux保留了这两级目录在指针序列中的位置,做法是将这两级目录所包含的表项数设置为1(这里需要注意的是即便只有一个比特,也可以表示两个项,因此需要此设置)。
- 对于32且使能了物理地址扩展的系统,使用三级页表。
- 对于64位系统,取决于硬件对线性地址位的划分。
在linux中,每个进程都有自己的页全局目录表(PGD),以及自己的页表集。当发生进程切换时,linux会完成页表的切换。
使用该方案后,每个虚拟地址都划分为相应的比特分组,其中PGD用于索引每个进程所专有的页全局表,以找到PUD,PUD用于索引进程的页上级目录表,以找到PMD依次类推直到找到PTE。PTE即页表数组,该表的表项包含了指向页帧的指针以及页的访问控制相关的信息,比如权限,是否在主存中,是否包含“脏”数据等等,OFFSET用做表内偏移。
使用该机制后,虚拟地址空间中不存在的内存区域对应的PUD,PMD,PTE将不被创建,这就节省了地址空间。
但是使用该机制后每次寻址都需要多次查表,才能找到对应的物理地址,因而降低了速递,CPU使用高速缓存和TLB来加速寻址过程。在访问内存时,如果虚拟地址对应的TLB存在,也就是TLB 命中了,则直接访问,否则就要使用相关的页表项更新TLB(此时可能需要创建新的页表项)然后再继续进行访问。下图是一个CPU的虚拟地址到实地址的转换过程:
当被访问的地址不存在对应的TLB表项时,就会产生TLB中断。在TLB中断中,会:
- 首先查找访问地址对应的页表,如果找不到对应的页表,就会生成相应的页表项(powerpc通过调用读写异常的处理函数完成该过程)。
- 使用PTE的内容更新TLB。
在TLB的内容更新完后,仍可能产生读写异常(也就是通常说的page fault),因为页表项虽然存在,但是其内容可能是非法的(比如页表并不在内存中),。
2.x86架构中的页
1.地址空间
当使用x86时,必须区分以下三种不同的地址:
- 逻辑地址:机器语言指令仍用这种地址指定一个操作数的地址或一条指令的地址。这种寻址方式在Intel的分段结构中表现得尤为具体,它使得MS-DOS或Windows程序员把程序分为若干段。每个逻辑地址都由一个段和偏移量组成。
- 线性地址:线性地址是一个32位的无符号整数,可以表达高达2的32次方(4GB)的地址。通常用16进制表示线性地址,其取值范围为0x00000000~0xffffffff。
- 物理地址:也就是内存单元的实际地址,用于芯片级内存单元寻址。物理地址也由32位无符号整数表示。
X86中的MMU包含两个部件,一个是分段部件,一个是分页部件,分段部件(段机制)把一个逻辑地址转换为线性地址;接着,分页部件(分页机制)把一个线性地址转换为物理地址。转化过程如图所示:
2.分段
1)分段机制
在x86段机制中,逻辑地址由两部分组成,即段部分(选择符)及偏移部分。
段是形成逻辑地址到线性地址转换的基础。如果我们把段看成一个对象的话,那么对它的描述如下:
- 段的基地址(Base Address):在线性地址空间中段的起始地址。
- 段的界限(Limit):表示在逻辑地址中,段内可以使用的最大偏移量。
- 段的属性(Attribute): 表示段的特性。例如,该段是否可被读出或写入,或者该段是否作为一个程序来执行,以及段的特权级等等。
段的界限定义逻辑地址空间中段的大小。段内在偏移量从0到limit范围内的逻辑地址,对应于从Base到Base+Limit范围内的线性地址。在一个段内,偏移量大于段界限的逻辑地址将没有意义,使用这样的逻辑地址,系统将产生异常。另外,如果要对一个段进行访问,系统会根据段的属性检查访问者是否具有访问权限,如果没有,则产生异常。例如,在80386中,如果要在只读段中进行写入,80386将根据该段的属性检测到这是一种违规操作,则产生异常。
下图表示一个段如何从逻辑地址空间,重新定位到线性地址空间。图的左侧表示逻辑地址空间,定义了A,B及C三个段,段容量分别为LimitA、LimitB及LimitC。图中虚线把逻辑地址空间中的段A、B及C与线性地址空间区域连接起来表示了这种转换。
段的基地址、界限及保护属性存储在段的描述符表中,在虚拟—线性地址转换过程中要对描述符进行访问。段描述符又存储在存储器的段描述符表中,该描述符表是段描述符的一个数组。简单的说段描述符表里存储了段描述符,而段描述符又包含了硬件进行逻辑地址到线性地址转换所需的所有信息。
每个段描述符都定义了线性地址空间中的一段地址,它的属性以及它和逻辑地址空间之间的映射关系,实际上是如何从逻辑地址空间映射到线性地址空间。
2)linux中的段
各种段描述符都存放于段描述符表中,要么在GDT中,要么在LDT中。
描述符表(即段表)定义了386系统的所有段的情况。所有的描述符表本身都占据一个字节为8的倍数的存储器空间,空间大小在8个字节(至少含一个描述符)到64K字节(至多含8K)个描述符之间。
- 全局描述符表(GDT):全局描述符表GDT(Global Descriptor Table),包含着系统中所有任务都共用的那些段的描述符。
- 局部描述符表(LDT):局部描述符表LDT(local Descriptor Table),包含了与一个给定任务有关的描述符,每个任务各自有一个的LDT。有了LDT,就可以使给定任务的代码、数据与别的任务相隔离。
每一个任务的局部描述符表LDT本身也用一个描述符来表示,称为LDT描述符,它包含了有关局部描述符表的信息,被放在全局描述符表GDT中。
但是linux很少使用分段机制,这是因为,分段和分页都能用于将物理地址划分为小的地址片段的功能,因而它们是相互冗余的。分段可以为不同的进程分配不同的线性地址空间,而分页可以将相同的线性地址空间映射到不同的物理地址空间。linux采用了分页机制,原因是:
- 如果所有的进程都使用相同的线性地址空间,内存管理更简单
- 很多其他架构的CPU对分段的支持很有限
在linux中,所有运行在用户模式的进程都使用相同的指令和数据段,因此这两个段也被成为用户数据段和用户指令段。类似的,内核使用自己的内核数据段和内核数据段。这几个段分别用宏_ _USER_CS,_ _USER_DS,_ _KERNEL_CS, and_ _KERNEL_DS定义。这些段都从0开始,并且大小都相同,因而linux中,线性地址和逻辑地址是相同的,而且内核和用户进程都可以使用相同的逻辑地址,逻辑地址也就是虚拟地址,这就和其它架构统一起来了。
单处理器系统只有一个GDT,而多处理器系统中每个CPU都有一个GDT,GDT存放在cpu_gdt_table中GDT包含了用户数据段,用户指令段,内核数据段内核指令段以及一些其他段的信息。
绝大多数的linux用户程序并不使用LDT,内核定义了一个缺省的LDT给大多数进程共享。它存放于default_ldt中。如果应用程序需要创建自己的局部描述附表,可以通过modify_ldt系统调用来实现。使用该系统调用创建的LDT需要自己的段。应用程序也可以通过modify_ldt来创建自己的段。
四、内存管理初始化
1.初始化流程
内存初始化关键是page_data_t数据结构以及其下级数据结构(zone,page)的初始化。
宏NODE_DATA用于获取指定节点对应的page_data_t,在多节点系统中,节点数据结构为struct pglist_data *node_data[];该宏获取对应节点所对应的数据结构,如果是单节点系统,节点的数据结构为struct pglist_data contig_page_data;该宏直接返回它。
1.初始化代码流程
系统启动代码中与内存管理相关的初始化代码如图:
其功能分别为:
- setup_arch:架构相关的初始化,其中包括了内存管理中与架构相关部分的初始化。boot分配器在这个时候被初始化。
- setup_per_cpu_areas:SMP中,该函数初始化源代码中静态定义的每CPU变量,该类变量对系统中每一个CPU都一个副本。此类变量保存在内核二进制影响的一个独立的段中。
- build_all_zonelists:建立节点和zone的数据结构
- mem_init:初始化内存分配器
- setup_per_cpu_pageset:遍历系统中所有的zone,对于每一个zone为所有的CPU分配pageset(冷热页缓存)并进行初始化,在这个函数被调用之前,只有boot pagesets可用。
2.节点和zone的初始化
build_all_zonelists会遍历系统中所有的节点,并为每个节点的内存域生成数据结构。它最终会使用节点数据结构调用build_zonelists,该函数会在该节点和系统中其它节点的内存之间建立一种距离关系,距离表达的是从其它节点分配的代价,因而距离越大,分配代价也越大;之后的内存分配会依据这种距离进行,优先选择本地的,如果本地的不可用,则按照距离从近到远来分配,直到成功或者所有的都失败。
在一个节点的内存域中:
- 高端内存被看做是最廉价的,因为内核不依赖于高端内存,它被耗尽不会对系统有不良影响
- DMA看做是最昂贵的,因为它有特殊用途,它用于和外设交互数据
- 普通内存介于两者之间,因为内核有些部分是依赖于普通内存的,所以它耗尽对系统会有影响
当分配内存时,假设指定的内存区域的昂贵程度为A,则分配过程为:
- 首先尝试从本节点分配,并且是按照昂贵程度递增的顺序从A开始尝试,直到最昂贵的区域
- 如果从本节点分配失败,则按照距离关系依次检查其它几点,在检查每个节点时,仍是按照昂贵程度递增的顺序从A开始尝试,直到最昂贵的区域
2.特定于体系结构的设置
1.内核在内存中的布局
在启动装载器将内核复制到内存,并且初始化代码的汇编部分执行完后,内存布局如图所示:
这是一种默认布局,也存在一些例外:
- PHYSICAL_START可用于配置修改内核在内存中的位置。
- 内核可以被编译为可重定位二进制程序,此时由启动装载器决定内核的位置。
默认情况下,内核安装在RAM中从物理地址0x00100000开始的地方。也就是第2M开始的那个。没有安装在第1M地址空间开始的地方的原因:
- 页帧0由BIOS使用,存在上电自检(POST)期间检查到的系统硬件配置。
- 物理地址从0x000a0000到0x000fffff的范围通常保留给BIOS程序使用
- 第一个MB内的其它页帧可能由特定计算机模型保留
从_edata到_end之间的初始化数据部分所占用的内存在初始化完成后有些是不再需要的,可以回收利用,可以控制哪些部分可以回收,哪些部分不能回收。
内核占用的内存分为几段,其边界保存在变量中,可以通过System.map查看相关的信息,在系统启动后也可以通过/proc/iomem查看相关的信息。
2.初始化步骤
在start_kernel,在其中会调用setup_arch来进行架构相关的初始化。setup_arch会完成启动分配器的初始化以及各个内存域的初始化(paging_init)。paging_init最终会调用free_area_init_node这是个架构无关的函数,它会完成节点以及zone的数据结构的初始化。
3.分页机制初始化
Linux内核将虚拟地址空间分成了两部分:用户空间和内核空间。用户进程可用的部分在进程切换时会发生改变,但是由内核保留使用的部分在进程切换时是不变的。在32位系统上,两部分的典型划分比为3:1(该比例可修改),即4G虚拟地址空间中的3G是用户进程可访问的,而另外1G是保留给内核使用的。
32位系统中,内核地址空间又被分为几部分,其图示如下:
3.1 直接映射
其中第一部分用于将一部分物理内存直接映射到内核的虚拟地址空间中,如果访问内存时所使用的虚拟地址与内核虚拟地址起始值的偏移量不超过该部分内存的大小,则该虚拟地址会被直接关联到物理页帧;否则就必须借助”高端内存“来访问,在IA-32系统上,这部分空间大小为896M。
对于直接映射部分的内存,内核提供了两个宏:
- __pa(vaddr):用于返回与虚拟地址vaddr相对应的物理地址。
- __va(paddr):用于返回和物理地址paddr相对应的虚拟地址。
剩余部分被内核用作其它用途:
- 虚拟地址中连续,但是物理地址不连续的内存区域可以从VMALLO区域分配。该机制通常用于用户进程,内核自己会尽量尝试使用连续的物理地址。当然,当直接映射部分不能满足需求时,内核也会使用该区域。在ppc32中ioremap就使用了该区域。
- 持久映射区域用于将高端内存中的非持久页映射到内核中。
- 固定映射用于与物理地址空间中的固定页关联的虚拟地址页,但是物理地址页即页帧可以自由选择。
内存的各个区域边界由图中所示的常数定义。high_memory定义了直接映射区域的边界。
系统中定义了与页相关的一些常量:
- num_physpages:最高可用页帧的页帧号
- totalram_pages:可用页帧的总数目
- min_low_pfn:RAM中在内核映像之后的第一个可用的页帧号
- max_pfn:最后一个可用的页帧号
- max_low_pfn:被内核直接映射的最后一个页帧的页帧号(低端内存中)
- totalhigh_pages:没有被内核直接映射的页帧的总数(高端内存中)
在直接映射的内存区域和用于vmalloc的内存区域之间有一个大小为VMALLOC_OFFSET的缺口,它用于对内核进行地址保护,防止内核进行越界访问(越过了直接映射区域)。
3.2 vmalloc区
vmalloc区域的起始位置取决于high_memory和VMALLOC_OFFSET。而其结束位置则取决于是否启用了高端内存支持。如果没有启用高端内存支持,就不需要持久映射区域,因为所有内存都可以直接映射。
3.3 持久映射区
持久映射页则开始于PKMAP_BASE,其大小由LAST_PKMAP表示有多少个页。
3.4 固定映射区
固定映射开始于FIXADDR_START结束于FIXADDR_END。这部分区域指向物理内存的随机位置。在该映射中,虚拟地址和物理地址之间的关联是可以自由定义的,但是定义后就不能更改。该区域一直延伸到虚拟地址空间的顶端。
固定映射的优势在于编译时,对该类地址的处理类似于常数,内核一旦启动即为它分配了物理地址。对此类地址的引用比普通指针要快。在上下文切换期间,内核不会将对应于固定地址映射的TLB刷新出去,因此对这类地址的访问总是通过高速缓存。
对于每一个固定地址,都必须创建一个常数并添加到称为fixed_addresses的枚举列表里。内核提供了virt_to_fix和fix_to_virt用于虚拟地址和固定地址常数之间的转换。
set_fixmap用于建立固定地址常量和物理页之间的对应关系。
3.5 冷热页
free_area_init_node最终会调到zone_pcp_init,它会为该zone计算一个batch值。而setup_per_cpu_pageset则会完成冷热缓存的初始化。
3. 启动过程中的内存管理
bootmem分配器用于内核在启动过程中分配和内存。这是一个很简单的最先适配的分配器。它使用位图来管理页面,比特1表示页忙,0表示空闲。需要分配内存时就扫描位图,直到找到第一个能够满足需求的内存区域。
1. 数据结构
内核为每个节点都分配了一个struct bootmem_data结构的实例用来管理该node的内存。
2. 初始化
在不同的架构下初始化的代码不尽相同,但是都是在paging_int中被调用。
3. 分配器接口
alloc_bootmem*用于分配内存free_bootmem*用于释放内存
4. 停用bootmem分配器
当slab系统完成初始化,能够承担内存分配工作时,需要停掉该分配器,这是通过free_all_bootmem(UMA系统)或free_all_bootmem_node(NUMA系统)来完成的
5. 释放初始化数据
内核提供了两个属性__init用于标记初始化函数,__initdata用于标记初始化数据,这意味着这个函数/数据在初始化完成后其内存就不需了,可以进行回收利用。
4. 内核页表的初始化
以powerpc为例,内核页表的初始化由MMU_init来完成,它在start_kernel之前被调用:
MMU_init->mapin_ram->__mapin_ram_chunk->map_page,
map_page的代码如下:
[cpp] view plain copy
- int map_page(unsigned long va, phys_addr_t pa, int flags)
- {
- pmd_t *pd;
- pte_t *pg;
- int err = -ENOMEM;
- /* Use upper 10 bits of VA to index the first level map */
- pd = pmd_offset(pud_offset(pgd_offset_k(va), va), va);
- /* Use middle 10 bits of VA to index the second-level map */
- pg = pte_alloc_kernel(pd, va);
- if (pg != 0) {
- err = 0;
- /* The PTE should never be already set nor present in the
- * hash table
- */
- BUG_ON((pte_val(*pg) & (_PAGE_PRESENT | _PAGE_HASHPTE)) &&
- flags);
- set_pte_at(&init_mm, va, pg, pfn_pte(pa >> PAGE_SHIFT,
- __pgprot(flags)));
- }
- return err;
- }
再看下init_mm的相关定义:
[cpp] view plain copy
- struct mm_struct init_mm = {
- .mm_rb = RB_ROOT,
- .pgd = swapper_pg_dir,
- .mm_users = ATOMIC_INIT(2),
- .mm_count = ATOMIC_INIT(1),
- .mmap_sem = __RWSEM_INITIALIZER(init_mm.mmap_sem),
- .page_table_lock = __SPIN_LOCK_UNLOCKED(init_mm.page_table_lock),
- .mmlist = LIST_HEAD_INIT(init_mm.mmlist),
- INIT_MM_CONTEXT(init_mm)
- };
因此可见,kernel的页表是保存在swapper_pg_dir中的。它是init_task的active_mm:
[cpp] view plain copy
- #define INIT_TASK(tsk) \
- { \
- .state = 0, \
- .stack = &init_thread_info, \
- .usage = ATOMIC_INIT(2), \
- .flags = PF_KTHREAD, \
- .prio = MAX_PRIO-20, \
- .static_prio = MAX_PRIO-20, \
- .normal_prio = MAX_PRIO-20, \
- .policy = SCHED_NORMAL, \
- .cpus_allowed = CPU_MASK_ALL, \
- .nr_cpus_allowed= NR_CPUS, \
- .mm = NULL, \
- .active_mm = &init_mm, \
[cpp] view plain copy
- truct task_struct init_task = INIT_TASK(init_task);
init_task是内核代码开始位置被执行的:
[cpp] view plain copy
- /*
- * This is where the main kernel code starts.
- */
- start_here:
- /* ptr to current */
- lis r2,[email protected]
- ori r2,r2,[email protected]
start_here在start_kernel之前被执行。在start_kernel里rest_init会启动kernel_init来启动一个init进程,init_task并不是init进程,init_task是内核启动主代码所在的上下文,该进程最后停在了cpu_idle中(start_kernel->rest_init->cpu_idle),好吧,它的真面目出来了,它就是创世界的进程,并且最后变成了无所事事的idle了。