Linux内存分段和分页管理

1.x86 内存架构和Linux的分段管理
x86 内存架构
在 x86 架构中,内存被划分成 3 种类型的地址:
·         逻辑地址 (logical address) 是存储位置的地址,它可能直接对应于一个物理位置,也可能不直接对应于一个物理位置。逻辑地址通常在请求控制器中的信息时使用。
·         线性地址 (linear address) (或称为平面地址空间)是从 0 开始进行寻址的内存。之后的每个字节都可顺序使用下一数字来引用(0、1、2、3 等),直到内存末尾为止。这就是大部分非 Intel CPU 的寻址方式。Intel® 架构使用了分段的地址空间,其中内存被划分成 64KB 的段,有一个段寄存器总是指向当前正在寻址的段的基址。这种架构中的 32 位模式被视为平面地址空间,不过它也使用了段。
·         物理地址 (physical address) 是使用物理地址总线中的位表示的地址。物理地址可能与逻辑地址不同,内存管理单元可以将逻辑地址转换成物理地址。
CPU 使用两种单元将逻辑地址转换成物理地址。第一种称为分段单元 (segmented unit),另外一种称为分页单元 (paging unit)。
 
图 2. 转换地址空间使用的两种单元
段由两个元素构成:
·         基址 (base address) 包含某个物理内存位置的地址
·         长度值 (length value) 指定该段的长度
每个段都是一个 16 位的字段,称为段标识符 (segment identifier) 或段选择器 (segment selector)。x86 硬件包括几个可编程的寄存器,称为段寄存器 (segment register),段选择器保存于其中。这些寄存器为cs(代码段)、ds(数据段)和ss(堆栈段)。每个段标识符都代表一个使用 64 位(8 个字节)的段描述符 (segment descriptor) 表示的段。这些段描述符可以存储在一个 GDT(全局描述符表,global descriptor table)中,也可以存储在一个 LDT(本地描述符表,local descriptor table)中。每次将段选择器加载到段寄存器中时,对应的段描述符都会从内存加载到相匹配的不可编程 CPU 寄存器中。每个段描述符长 8 个字节,表示内存中的一个段。这些都存储到 LDT 或 GDT 中。段描述符条目中包含一个指针和一个 20 位的值(Limit 字段),前者指向由 Base 字段表示的相关段中的第一个字节,后者表示内存中段的大小。
段选择器包含以下内容:
·         一个 13 位的索引,用来标识 GDT 或 LDT 中包含的对应段描述符条目
·         TI (Table Indicator) 标志指定段描述符是在 GDT 中还是在 LDT 中,如果该值是 0,段描述符就在 GDT 中;如果该值是 1,段描述符就在 LDT 中。
·         RPL (request privilege level) 定义了在将对应的段选择器加载到段寄存器中时 CPU 的当前特权级别。
由于一个段描述符的大小是 8 个字节,因此它在 GDT 或 LDT 中的相对地址可以这样计算:段选择器的高 13 位乘以 8。例如,如果 GDT 存储在地址 0x00020000 处,而段选择器的 Index 域是 2,那么对应的段描述符的地址就等于 (2*8) + 0x00020000。GDT 中可以存储的段描述符的总数等于 (2^13 - 1),即 8191。
图 3. 从逻辑地址获得线性地址


Linux 中的段控制单元
在 Linux 中,所有的段寄存器都指向相同的段地址范围 —— 换言之,每个段寄存器都使用相同的线性地址。这使 Linux 所用的段描述符数量受限,从而可将所有描述符都保存在 GDT 之中。这种模型有两个优点:
·         当所有的进程都使用相同的段寄存器值时(当它们共享相同的线性地址空间时),内存管理更为简单。
·         在大部分架构上都可以实现可移植性。某些 RISC 处理器也可通过这种受限的方式支持分段。
Linux 使用以下段描述符:
·         内核代码段
·         内核数据段
·         用户代码段
·         用户数据段
·         TSS 段
·         默认 LDT 段
GDT 中的内核代码段 (kernel code segment)描述符中的值如下:
·         Base = 0x00000000
·         Limit = 0xffffffff (2^32 -1) = 4GB
·         G(粒度标志)= 1,表示段的大小是以页为单位表示的
·         S = 1,表示普通代码或数据段
·         Type = 0xa,表示可以读取或执行的代码段
·         DPL 值 = 0,表示内核模式
与这个段相关的线性地址是 4 GB,S = 1 和 type = 0xa 表示代码段。选择器在cs寄存器中。Linux 中用来访问这个段选择器的宏是_KERNEL_CS。
内核数据段 (kernel data segment)描述符的值与内核代码段的值类似,惟一不同的就是 Type 字段值为 2。这表示此段为数据段,选择器存储在ds寄存器中。Linux 中用来访问这个段选择器的宏是_KERNEL_DS。
用户代码段 (user code segment)由处于用户模式中的所有进程共享。存储在 GDT 中的对应段描述符的值如下:
·         Base = 0x00000000
·         Limit = 0xffffffff
·         G = 1
·         S = 1
·         Type = 0xa,表示可以读取和执行的代码段
·         DPL = 3,表示用户模式
在 Linux 中,我们可以通过_USER_CS宏来访问此段选择器。
在用户数据段 (user data segment)描述符中,惟一不同的字段就是 Type,它被设置为 2,表示将此数据段定义为可读取和写入。Linux 中用来访问此段选择器的宏是_USER_DS。
除了这些段描述符之外,GDT 还包含了另外两个用于每个创建的进程的段描述符 —— TSS 和 LDT 段。
每个 TSS 段 (TSS segment)描述符都代表一个不同的进程。TSS 中保存了每个 CPU 的硬件上下文信息,它有助于有效地切换上下文。例如,在U->K模式的切换中,x86 CPU 就是从 TSS 中获取内核模式堆栈的地址。
每个进程都有自己在 GDT 中存储的对应进程的 TSS 描述符。这些描述符的值如下:
·         Base = &tss (对应进程描述符的 TSS 字段的地址;例如 &tss_struct)这是在 Linux 内核的 schedule.h 文件中定义的
·         Limit = 0xeb (TSS 段的大小是 236 字节)
·         Type = 9 或 11
·         DPL = 0。用户模式不能访问 TSS。G 标志被清除
所有进程共享默认 LDT 段。默认情况下,其中会包含一个空的段描述符。这个默认 LDT 段描述符存储在 GDT 中。Linux 所生成的 LDT 的大小是 24 个字节。默认有 3 个条目:
    UP系统中只有一个GDT表,而在SMP系统中每个CPU有一个GDT表。所有GDT存放在cpu_gdt_table[]数组中,段的大小和指针存放在cpu_gdt_descr[]数组中。Linux的GDT布局如下图所示。它包含18个段描述符和14个Null、保留、未使用的段描述符。包括任务状态段TSS、用户和内核代码数据段、所有进程共享的局部描述段、高级电源管理使用的数据段APMBIOS data、即插即用设备代码数据段PNPBIOS、三个线程局部存储段TLS、第一个为null的段用于处理段描述符异常。
图4 Linux Global Descriptor Table
 
Linux启动时GDT段表的初始化
全局描述表GDT表的初始化分两个阶段:
第一个阶段在setup中完成,此处是为系统进入保护模式做准备,把内核代码段和数据段的两个段描述符初始化放在GDT表中,这只是一个并不完整的临时GDT表。
第二个阶段在arch/i386/kernel/head.S 文件中的startup_32()函数里,在这里加载head.s 文件中已经初始化的cpu_gdt_table描述表,该表有32项。
 
2.Linux的三级分页管理
X86中的分页管理
x86 架构中指定分页的字段,这些字段有助于在 Linux 中实现分页功能。分页单元进入作为分段单元输出结果的线性字段,然后进一步将其划分成以下 3 个字段:
Directory以 10 MSB 表示(Most Significant Bit,也就是二进制数字中值最大的位的位置 —— MSB 有时称为最左位)。
Table以中间的 10 位表示。
Offset以 12 LSB 表示。(Least Significant Bit,也就是二进制整数中给定单元值的位的位置,即确定这个数字是奇数还是偶数。LSB 有时称为最右位。这与数字权重最轻的数字类似,它是最右边位置处的数字。)
Intel的分页机制
图5 x86分页机制
 
Linux的三级分页模型
虽然 Linux 中的分页与普通的分页类似,但是 x86 架构引入了一种32位和64位通用的三级页表机制,包括:
页全局目录 (Page Global Directory),即 pgd,是多级页表的抽象最高层。每一级的页表都处理不同大小的内存 —— 这个全局目录可以处理 4 MB 的区域。每项都指向一个更小目录的低级表,因此 pgd 就是一个页表目录。当代码遍历这个结构时(有些驱动程序就要这样做),就称为是在“遍历”页表。
页中间目录 (Page Middle Directory),即 pmd,是页表的中间层。在 x86 架构上,pmd 在硬件中并不存在,但是在内核代码中它是与 pgd 合并在一起的。
页表条目 (Page Table Entry),即 pte,是页表的最低层,它直接处理页(参看PAGE_SIZE),该值包含某页的物理地址,还包含了说明该条目是否有效及相关页是否在物理内存中的位。
图6 Linux三级页表机制
为了支持大内存区域,Linux采用了这种三级分页机制。在不需要为大内存区域时,即可将 pmd 定义成“1”,返回两级分页机制。
分页级别是在编译时进行优化的,我们可以通过启用或禁用中间目录来启用两级和三级分页(使用相同的代码)。Intel 32 位处理器使用的是 pmd 分页,而 64 位处理器使用的是 pgd 分页。
每个进程都有自己的页目录和页表。为了引用一个包含实际用户数据的页框,操作系统(在 x86 架构上)首先将 pgd 加载到cr3寄存器中。Linux 将cr3寄存器的内容存储到 TSS 段中。此后只要在 CPU 上执行新进程,就从 TSS 段中将另外一个值加载到cr3寄存器中。从而使分页单元引用一组正确的页表。
pgd 表中的每一条目都指向一个页框,其中中包含了一组 pmd 条目;pdm 表中的每个条目又指向一个页框,其中包含一组 pte 条目;pde 表中的每个条目再指向一个页框,其中包含的是用户数据。如果正在查找的页已转出,那么就会在 pte 表中存储一个交换条目,(在缺页的情况下)以定位将哪个页框重新加载到内存中。
Linux 为内核代码和数据结构预留了几个页框。这些页永远不会被转出到磁盘上。从 0x0 到 0xc0000000 (PAGE_OFFSET)的线性地址可由用户代码和内核代码进行引用。从PAGE_OFFSET到 0xffffffff 的线性地址只能由内核代码进行访问。
这意味着在 4 GB 的内存空间中,只有 3 GB 可以用于用户应用程序。
 
Linux分页的启动
Linux 进程使用的分页机制包括两个阶段:
在启动时,系统为 8 MB 的物理内存设置页表。
然后,第二个阶段完成对其余所有物理地址的映射。
在启动阶段,startup_32()调用负责对分页机制进行初始化。这是在 arch/i386/kernel/head.S 文件中实现的。这 8 MB 的映射发生在PAGE_OFFSET之上的地址中。这种初始化是通过一个静态定义的编译时数组 (swapper_pg_dir) 开始的。在编译时它被放到一个特定的地址(0x00101000)。
这种操作为在代码中静态定义的两个页 —— pg0和pg1 —— 建立页表。这些页框的大小默认为 4 KB,除非我们设置了页大小扩展位(有关 PSE 的更多内容,请参阅 扩展分页 一节)。这个全局数组所指向的数据地址存储在cr3寄存器中,我认为这是为 Linux 进程设置分页单元的第一阶段。其余的页项是在第二阶段中完成的。
第二阶段由方法调用paging_init()来完成。
在 32 位的 x86 架构上,RAM 映射到PAGE_OFFSET和由 4GB 上限 (0xFFFFFFFF) 表示的地址之间。这意味着大约有 1 GB 的 RAM 可以在 Linux 启动时进行映射,这种操作是默认进行的。然而,如果有人设置了HIGHMEM_CONFIG,那么就可以将超过 1 GB 的内存映射到内核上 —— 切记这是一种临时的安排。可以通过调用kmap()实现。 

3.Linux的内存结构和管理
物理内存区域
Linux 内核按照 3:1 的比率来划分虚拟内存:3 GB 的虚拟内存用于用户空间,1 GB 的内存用于内核空间。内核代码及其数据结构都必须位于这 1 GB 的地址空间中,但是对于此地址空间而言,更大的消费者是物理地址的虚拟映射。
为了迎合大量用户的需要,支持更多内存、提高性能,建立一种独立于架构的内存描述方法,Linux 内存模型将内存划分成分配给每个 CPU 的空间。每个空间都称为一个节点;每个节点都被划分成一些区域。区域(表示内存中的范围)可以进一步划分为以下类型:
ZONE_DMA(0-16 MB):包含 ISA/PCI 设备需要的低端物理内存区域中的内存范围。
ZONE_NORMAL(16-896 MB):由内核直接映射到高端范围的物理内存的内存范围。所有的内核操作都只能使用这个内存区域来进行,因此这是对性能至关重要的区域。
ZONE_HIGHMEM(896 MB 以及更高的内存):系统中内核不能映像到的其他可用内存。
节点的概念在内核中是使用struct pglist_data结构来实现的。区域是使用struct zone_struct结构来描述的。物理页框是使用struct Page结构来表示的,所有这些Struct都保存在全局结构数组struct mem_map中,这个数组存储在NORMAL_ZONE的开头。节点、区域和页框之间的基本关系如下图所示。
图 7. 节点、区域和页框之间的关系
 
对于4 GB 的内存可以通过使用kmap()将ZONE_HIGHMEM映射到ZONE_NORMAL来进行访问。
物理内存区域的管理是通过一个区域分配器(zone allocator)实现的。它负责将内存划分为很多区域;它可以将每个区域作为一个分配单元使用。每个特定的分配请求都利用了一组区域,内核可以从这些位置按照从高到低的顺序来进行分配。
例如:
对于某个用户页面的请求可以首先从“普通”区域中来满足(ZONE_NORMAL);
如果失败,就从ZONE_HIGHMEM开始尝试;
如果这也失败了,就从ZONE_DMA开始尝试。
这种分配的区域列表依次包括ZONE_NORMAL、ZONE_HIGHMEM和ZONE_DMA区域。另一方面,对于 DMA 页的请求可能只能从 DMA 区域中得到满足,因此这种请求的区域列表就只包含 DMA 区域。
 
Linux物理内存的管理
在物理页面管理上实现了基于区的伙伴系统(zone based buddy system)。对不同区的内存使用单独的伙伴系统(buddy system)管理,而且独立地监控空闲页。相应接口alloc_pages(gfp_mask, order),_ _get_free_pages(gfp_mask, order)等。
单单分配页面的分配器肯定是不能满足要求的。内核中大量使用各种数据结构,大小从几个字节到几十上百k不等,都取整到2的幂次个页面那是完全不现实的。Linux提供了Cache的Slab分配算法,提供大小为2,4,8,16,...,131056字节的内存对象管理。对象的要素有大小、结构、构造和析构函数。每个Cache有若干Slab组成,Slab以2的整数次幂为单位向分区申请页面。每个Slab被划分成若干个size大小的对象,每个对象之间可能需要Cache对齐。为了防止对象频繁的分配释放,Slab并不物理上释放已经分配不使用的对象,当下次再申请对象时就不需要经过初始化直接把对象分配使用。Cache中Slab都满时,Slab像分区申请一个Slab大小需要的页面,并进行Slab和对象的初始化划分。
图8 Slab的对象结构
 
Cache用kmem_cache_t结构表示,主要包括array_cache[]每个CPU的本地对象Cache,slabs_full,slabs_partial,slabs_free三个Slab双向链表,gfporder每个Slab所需页面的次幂,colour和colour_off表示Slab Cache对齐参数及一些状态参数。
Slab描述符用slab结构表示,list指向Slab所在链表,colouroff表示对象在Slab中的偏移,s_mem表示第一个对象的地址,inuse表示当前使用的对象数,free表示第一个空闲的对象索引。每个Slab描述符后面放着一个kmem_bufctl_t数组,用来描述Slab中的空闲对象。
Slab的管理结构既可以放在每个Slab页面上也可以集中放在其他位置,这取解决对象大小等因素。Slab结构如下图所示:
图9 Slab对象结构
管理区的初始化
管理区的初始化在函数start_kernel()-> setup_arch()->zone_sizes_init()—>…—> free_area_init_node()中进行。该函数在setup_memory()建立引导内存分配器和paging_init()建立内核页表后调用。传递参数有管理区结点标志符nid,初始化的pg_data_t,管理区大小zones_size,第一个管理区的起始物理地址node_start_pfn等。函数原型:
void __meminit free_area_init_node(int nid, struct pglist_data *pgdat,
unsigned long *zones_size, unsigned long node_start_pfn, unsigned long *zholes_size)
alloc_node_mem_map()用于为节点分配mem_map数组,free_area_init_core()用于向每个zone_t填充相关信息,标记所有页保留,标记所有内存队列为空,清空内存位图;并初始化区的mem_map。
mem_map的初始化:在NUMA系统中全局mem_map被处理成一个起始于PAGE_OFFSET的虚拟数组,全局mem_map从未被明确的申明国,取而代之被处理成起始于PAGE_OFFSET的虚拟数组。局部映射地址存储在pg_data_t—>node_mem_map中,也存在于虚拟mem_map中。对于节点中的每个管理区,虚拟mem_map中表示管理区的地址存储在zone—>zone_mem_map中。余下的节点都把mem_map作为真实的数组,因为只有有效的管理区会被节点所使用。
 
4.Linux的内存初始化
引导内存分配器
由于硬件配置的多样性,在编译时静态初始化所有的内核存储结构是不现实的。物理页面分配器是如何分配内存完成自身的初始化的呢?Linux是通过引导内存分配器boot memory allocator来完成的,该机制基于大部分分配器的原理,用位图代替空闲链表结构表示存储空间,位图中某位置1表示该页面已被分配,否则表示未被占有。该机制通过记录上一次分配页面帧号及结束时的偏移量实现小于一页的内存分配。该分配器也是基于NUMA上的节点分配的。
初始化引导内存分配器
每种体系结构都提供了setup_arch()函数,用于获取初始化引导内存分配器时所必须的参数信息。setup_arch()中调用setup_memory()初始化内存。setup_memory()中首先找出低端内存的PFN起点和终点和高端内存PFN的起点和终点,然后调用setup_bootmem_allocator()初始化引导内存分配器。
setup_bootmem_allocator()处理流程如下:
1)        调用init_bootmem()->init_bootmem_core()用contig_page_data及低端内存起点和终点初始化对应的bootmem_data_t结构,并插入pgdata_list节点列表中。
2)        调用register_bootmem_low_pages()函数通过检测e820映射图,并在每一个可用页面上调用free_bootmem()函数,将其位设为1。
3)        依次调用reserve_bootmem()分别为bootmem保存实际位图所需的页面预留空间、预留BIOS使用的第一个物理页面0、为EBDA区预留4K区、为AMD 768MPX芯片预留1个页面。
4)        如果配置了CONFIG_SMP则为trampoline跳转预留4K空间;
5)        如果加入了睡眠机制,则调用acpi_reserve_bootmem()为之保留内存;
6)        调用find_smp_config()读取SMP配置信息并为之保留内存;
7)        如果配置了CONFIG_BLK_DEV_INITRD或者CONFIG_KEXEC则为它们保留内存;
内存分配和释放
引导内存的分配接口有:alloc_bootmem_node(pgdat, x)、alloc_bootmem_pages_node(pgdat, x)、alloc_bootmem_low_pages_node(pgdat, x),最终它们都调用一个核心函数__alloc_bootmem_core (pg_data_t *pgdat, unsigned long size, unsigned long align, unsigned long goal)。该函数主要处理流程:
1)        函数开始处保证所有参数正确;
2)        以goal参数为基础计算开始扫描的起始地址;
3)        检查本次分配是否可以使用上次分配的页面以节省内存;
4)        在位图中标记已分配为1,并将页中内容清0;
    内存释放函数只有一个free_bootmem_node(),它调用free_bootmem_core().该函数比较简单,对于受释放影响的每个完整页面的相应位设为0,如果原来就是0则调用BUG()提示重复释放错误。对于释放函数只有完整的页面才可释放,不能部分释放一个页面。
释放引导内存分配器
系统启动后,引导内存分配器就不在需要,内核提供mem_init()负责释放引导内存分配器并把其余的页面传人到普通的页面分配器中。
mem_init()的流程如下:
1)        调用ppro_with_ram_bug ()检查奔腾Pro版本中是否存在一个bug,该bug阻止高端内存的某些页被使用;
2)        调用free_all_bootmem()释放引导内存分配器,并把低端地址页面转移到伙伴分配器中管理;
3)        遍历所有内存计算保留内存的页面数;
4)        调用set_highmem_pages_init()并逐页初始化高端内存;
5)        计算用于初始化的代码和数据的代码段、数据段和内存大小并打印内存信息;
6)        打印内核虚拟内存布局并进行检测;
7)        如果配置了CONFIG_X86_PAE当CPU不支持,则使系统瘫痪;
8)        测试WP位是否可用;
9)        如果配置了CONFIG_SMP,调用zap_low_mappings()填充swapper_pg_dir的PGD用户空间部分的表项,将这些页面都映射到0,这是因为在后面SMP辅助处理器启动时它需要为进入保护模式进行地址映射;
free_all_bootmem()-> free_all_bootmem_core()执行以下操作:
1)        对于该节点上分配器可以识别的所有未分配的页面:
l          将它们结构页面上的PG_reserved标志清0;
l          将计数器置为1;
l          调用__free_pages()以使伙伴系统分配器能建立free空闲列表;
2)        释放位图使用的所有页面,并将之交给伙伴分配器。
这样,伙伴系统就控制了所有的低端内存页面。
对于高端内存,由set_highmem_pages_init()进行初始化,该函数对highstart_pfn和highend_pfn之间的页面分别调用add_one_highpage_init()该函数将PG_reserved标志清0,初始化计数器为1,调用__free_page()将自己释放到伙伴分配器中。
初始化页表
前面已经分析过在启动时startup_32函数为系统8 MB 的物理内存设置页表。在setup_arch()调用setup_memory()初始化引导内存分配器后,需要完成对其余所有物理地址的映射。这里是通过paging_init()完成的。处理流程为:
1)        调用pagetable_init()初始化对应于ZONE_DMA、ZONE_NORMAL的所有物理内存必须要的页表;它对从FIXADDR_START开始的高端内存调用permanent_kmaps_init()进行初始化;
2)        将初始化后的swapper_pg_dir页表载入CR3寄存器中,以供换页单元使用;
3)        如果配置了CONFIG_X86_PAE且CPU支持,则设置CR4寄存器中的相应位;
4)        调用kmap_init()初始化带有PAGE_KERNEL标志位的每个PTE;
 
5.Linux的进程地址空间
Linux管理系统中的进程用一个task_struct数据结构来表示,Linux地址空间如下所示,对用户空间来说,它只能访问0~3G空间的范围。
图10 内核地址空间
Linux进程地址空间有mm_struct管理,task_struct中的mm成员表示。mm_struct数据结构包含了已加载可执行映象的信息和指向进程页表的指针,它还包含了一个指向vm_area_struct链表的指针,每个指针代表进程内的一个虚拟内存区域。vm_area_struct表示的内存区域是一个页面对齐的、并且相互之间不会重叠,它可能是一个malloc使用的进程堆或者是一个内存映射文件,也可以是mmap()分配的匿名内存区域。如果该区域是一个文件的映像,则vm_file字段被设置,通过vm_file可以找到该区域代表的地址空间内容。
图11 进程地址空间的数据结构
 
mm_struct结构的初始化和释放
mm_init()初始化一个mm结构,allocate_mm()用于从slab分配器分配一个mm_struct结构。系统中第一个mm_struct通过init_mm()初始化,后继的子mm_struct都通过copy_mm()进行复制得到。第一个mm_struct结构在编译时静态配置。do_munmap()负载删除一个VMA区域,释放相关页面,并修复区域。当进程退出时,必须删除与mm_struct相关的所有VMA,由函数exit_mmap()负责操作。该函数首先刷新CPU高速缓存,依次删除每一个VMA并释放相关页面,然后刷新TLB和删除页表项。

本文来自CSDN博客,转载请标明出处:http://blog.csdn.net/cxylaf/archive/2007/05/26/1626519.aspx

你可能感兴趣的:(linux技术)