内存寻址
内存地址:
逻辑地址: 段+偏移量 组成
线性地址: 可用来表达4GB的地址 (也称虚拟地址)
物理地址: 用于内存芯片级内存单元寻址。他们与微处理器地址引脚发送到内存总线上的电信号相对应
内存控制单元(MMU) 通过一种称为分段单元的硬件店里把一个逻辑地址转换为线性地址,接着通过分页单元的硬件电路把线性地址转换成一个物理地址。
分段单元 分页单元
逻辑地址----------》线性地址---------------》物理地址
硬件中的分段:
从80286模式开始,Intel微处理器以两种不同的方式执行地址转换,这两种方式分别为 实模式 和 保护模式。
段选择符和段寄存器:
一个逻辑地址由两个部分组成:一个段标识符和一个指定段内相对地址的偏移符。
段标识符是一个16位长的字段,称为段选择符
段偏移量是一个32位常的字段。
为了快速方便地找到段选择符,处理器提供了段寄存器,段寄存器的唯一目的就是存放段选择符。这些寄存器称为CS,SS,DS,ES,FS,GS;
3个有专门的用途:
CS 代码段寄存器,指向包含程序指令的段。
SS 栈段寄存器,指向包含当前程序栈的段。
DS 数据段寄存器,指向包含静态数据或者全局数据段。
CS寄存器还有一个很重要的功能:它含有一个两位的字段,用以指明CPU的当前特权级。值为0代表最高优先级,而值为3表示最低优先级。Linux只用0级和3级,分别称为内核态和用户态。
段描述符:
每个段都有一个8字节的段描述符表示,它描述段的特征。段描述符放在全局描述符表(GDT)或局部描述符表中(LDT)。
GDT在主存中的地址和大小存放在gdtr控制寄存器中,当前正被使用的LDT地址和大小放在ldtr控制寄存器中。
快速访问段描述符:
逻辑地址由16位段选择符和32位偏移量组成,段寄存器仅仅存放段选择符。
每当一个段选择符被装入段寄存器时,相应的段描述符就由内存装入到对应的非编程CPU寄存器。
由于一个段描述符是8字节长,因此它在GDT或者LDT内的相对地址是由段选择符的最高13位的值乘以8得到的。
分段单元:
一个逻辑地址如何转换成相应的线性地址?
1、线检查段选择符的TI字段,以决定段描述符保存在哪一个描述符表中。TI字段指明描述符是在GDT中(在这种情况下,分段单元从gdtr寄存器中得到GDT的线性基地址)还是在激活的LDT中(在这种情况下,分段单元从ldtr寄存器中得到GDT的线性基地址)。
2、从段选择符的index字段计算段描述符的地址,index字段的值乘以8(一个段描述符的大小),这个结果再与gdtr或ldtr寄存器中的内容相加。
3、把逻辑地址的偏移量和段描述符base字段的值相加就得到了线性地址。
注意:有了与段寄存器相关的不可编程寄存器,只有当段寄存器的内容被改变时才需要执行前两个操作。
Linux中的分段:
分段可以给每一个进程分配不同的线性地址空间,而分页可以把同一线性地址空间空间映射到不同的物理空间。
与分段相比Linux更喜欢分页方式,因为:
1、当所有内存使用相同的段寄存器值时,内存管理变得更简单,也就是说他们能共享同样的一组线性地址。
2、Linux设计目标之一是可以把他一直到绝大多数留下的处理器平台上,然而,risc体系结构对分段的支持很有限。
运行在用户态的所有Linux进程都是用一对相同的段来对指令和数据寻址。这两个段就是所谓的用户代码段和用户数据段。类似的,运行在内核态的所有Linux进程都是用一堆相同的段对指令和数据寻址;他们分别叫做内核代码段和内核数据段。
相应的段选择符由宏__USER_CS、__USER_DS、__KERNEL_CS 、__KERNEL_DS分别定义。例如对内核代码段寻址,内核只需要把__KERNEL_CS宏产生的值装进CS段寄存器中即可。
注意,与段相关的线性地址从0开始,达到2的32此方-1的寻址限长。这就意味着在用户态或内核态下的所有进程都可以使用相同的逻辑地址。
所有段都从0x00000000开始,这可以得到另一个重要结论,那就是在Linux下逻辑地址与线性地址是一致的,即逻辑地址的偏移量字段的值与相应的线性地址的值总是一致的。
Linux GDT
在单处理器系统中只有一个GDT,而在多处理器系统中每个CPU对应一个GDT。所有的GDT都存放在cpu_gdt_table数组中,而所有GDT的地址和它们的大小(当初始化gdtr寄存器时使用)被存放在cpu_gdt_descr数组中。
每个GDT包含18个段描述符和14个空的,未使用的,或保留的项。插入为使用项的目的是为了使经常一起访问的描述符能够处理同一个32字节的硬件高速缓存行中。
每一个GDT中包含的18个段描述符指向下列的段:
用户态和内核态下的代码段和数据段共4个。
任务状态段(TSS),每个处理器有一个。
一个包括缺省局部描述表的段。
三个局部线程存储(TLS)段:这个机制允许多线程应用程序使用最多3个局部与线程的数据段。系统使用set_thread_area()和get_thread_area()分别为正在执行的进程创建和撤销一个TLS段
与高级电源管理(AMP)相关的3个段。
与支持即插即用(PnP)功能的BIOS服务程序相关的5个段.
被内核用来处理“双重错误”异常的特殊TSS段。
Linux LDT
大多数用户态下Linux程序不使用局部描述符表,这样内核就定义了一个缺省的LDT工大多数进程共享。缺省的局部描述表存放在default_ldt数组中。它包含5个项,但是内核仅仅有效的使用了两个项.
某些情况下,进程仍然需要创建自己的局部描述符。这对有些应用程序有用像wine那样的程序,他们面向段的微软Windows应用程序。modify_ldt()系统调用允许进程创建自己的局部描述符表。
硬件中的分页
分页单元把线性地址转换为物理地址。
其中的一个关键任务就是把所请求的访问类型与线性地址的访问权限相比较,如果这次内存访问是无效的,就产生一个缺页异常。
为了效率起见,线性地址被分成以固定长度的组,称为页(page)。页内部连续的线性地址被映射到连续的物理地址中。这样,内核可以指定一个页的物理地址和其存取权限。而不用指定页所包含的全部线性地址的存取权限。
分页单元把所有的RAM分成固定长度的页框(page frame)(有时叫做物理页)。每一个页框包含一个页(page),也就是说一个页框的长度就是一个页的长度。
把线性地址映射到物理地址的数据结构称为页表(page table)。页表存放在主存中,并在启用分页单元之前必须由内核对页表进行适当的初始化。
常规分页:
从80386起,Intel处理器的分页单元处理4KB的页。
32位的线性地址被分为3个域:
Directory(目录) 最高10位
Table(页表) 中间10位
Offset(偏移量) 最低10位
线性地址的转换分为两步完成,每一步都是基于一种转换表,第一种转换表称为页目录表(page directory),第二种转换表称为页表(page table)
使用这种二级模式的目的在于减少每一个进程页表所需RAM的数量。如果使用简单的一级页表,那将需要高达2的20次方个页表来表示每个进程的页表,即使一个进程并不使用哪个范围内的所有地址。
二级模式通过只为进程实际使用的那些虚拟内存区请求页表来减少内存容量。
每个活动进程必须有一个分配给它的页目录。不过,没必要马上为进程的所有页表都分配RAM。只有进程实际需要一个页表是才给该页表分配RAM会更为有效率。
线性地址内的Directory字段决定页目录中的目录项,而目录项指向适当的页表。地址的Table字段依次有决定页表中的表象,而页项含有页所有页框的物理地址。Offset字段决定页框内的相对位置。由于它是12位长,故每一页含有4096字节的数据。
扩展分页:
扩展分页允许页框大小为4MB而不是4KB。扩展粉笔用于把大段连续的线性地址转换成相应的物理地址,在这些情况下,内核可以不用中间页表进行地址转换,从而节省内存并保留TLB项。
正如前面所描述的,通过设置页目录的Page Size标志启动扩展分页功能。在这种情况下,分页单元把32位的线性地址分成两个字段:
directory 最高10位
Offset 其余22位
扩展分页和正常分页的页目录项基本相同,除了:
Page Size标志必须被设置。
20位物理地址字段只有最高10位是有意义的。这是用为每一个物理地址都是在以4MB为边界的地方开始,故这个地址的最低22位为0.
硬件保护方案:
分页单元和分段单元的保护方案不同。
64位操作系统的分页
所有64位处理器的硬件分页系统都使用了额外的分页级别。使用的级别数量取决于处理器的类型。
硬件高速缓存:
如今的微处理器时钟频率接近几GHz,而动态RAM(DRAM)芯片的存取时间是时钟周期的数百倍。这意味着,当从RAM中去操作数项RAM中存放结果这样的指令执行时,CPU可能等待很长时间。
为了缩小CPU和RAM之间的速度不匹配,引入了硬件高速缓存内存。硬件高速缓存基于著名的局部性原理,该原理即使用程序结构和也适用于数据结构。为此,80x86体系结构中引入了一个叫行(line)的新单位。行由几十个连续的字节组成,它们以脉冲突发模式在慢速DRAM和快速的用来实现高速缓存的片上静态RAM(SRAM)之间传递,用来实现高速缓存。
高速缓存单元插在分页单元和主内存之间。它包含一个硬件高速缓存内存(hardware cache memory)和一个高速缓存控制器(cache controller)。
高速缓存内存存放内存中真正的行。高速缓存控制器存放一个表项数组,每个表项对应高速缓存内存中的一个行。每个表项有一个标签(tag)和描述高速缓存行状态的几个标志(flag)。
Linux中的分页:
Linux采用了一种同时适用于32位和64位系统的普通分页模型。
直到2.6.10版本,Linux采用三级分页模型。从2.6.11版本开始,采用四级分页模型。
其中四级分页模型的4中页表分别被称为:
页全局目录(Page Global Directory)
页上级目录(Page Upper Directory)
页中间目录(Page Middle Directory)
页表(Page Table)
线性地址字段:
下列宏简化了页表处理:
PAGE_SHIFT:指定Offset字段的位数;
PAGE_SIZE:用于返回页的大小。
PAGE_MASK:产生的值为0xfffff000,用以屏蔽Offset字段的所有位。
PMD_SHIFT:指定线性地址的Offset字段和Table字段的总位数;换句话说模式页中间目录项可以映射的区域大小的对数。
PMD_SIZE:用于计算由页中间目录的一个单独表项所映射的区域大小,也就是一个页表的大小。
PMD_MASK:用于屏蔽Offset字段与Table字段的所有位。
PUD_SHIFT:确定页上级目录项能映射的区域大小的对数。
PUD_SIZE:用于计算页全局目录中的一个单独表项所能映射的区域大小。
PUD_MASK:用于屏蔽offset字段、Table字段、Middle Air字段和Upper Air字段的所有位。
PGDIR_SHIFT:确定页全局目录项能映射的区域大小的对数。
PGDIR_SIZE:计算页全局目录中一个单独表项所能映射的区域大小。
PGDIR_MASK:屏蔽offset、Table、Middle Air、Upper Air字段的所有位。
PTRS_PER_PTE,PTRS_PER_PMD,PTRS_PER_PUD,PTRS_PER_PGD:
用于计算页表、页中间目录、页上级目录和全局目录表中表项的个数。
当PAE被禁止时,他们产生的值分别为1024,1,1,0.当PAE被激活是,产生的值分别为512,512,1,4
页表处理:
pte_t,pmd_t,pud_t,pgd_t分别表示描述页表项、页中间目录项、页上级目录和页全局目录项的格式。当PAE被激活时,他们都是64位的数据类型,否则都是32位数据类型。
pgprot_t是另一个64位(PAE激活时)或32位(PAE禁止时)的数据类型,它表示与一个单独表项相关的保护标志。
五个类型转换宏(__pte,__pmd,__pud__pgd,__pgprot)把一个无符号整数转换成所需的类型。
另外五个类型转换宏(pte_val,pmd_val,pud_val,pgd_val,pgprot_val)执行相反的转换,即把上面提到的四种特殊的类型转换成一个无符号的整数。
内核还提供了许多宏和函数用于读或修改页表选项:
1)如果相应表项的值为0,那么红pte_none,pmd_none,pud_none,pgd_none产生的值为1,否则产生的值为0;
2)宏pte_clear,pmd_clear,pud_clear,pgd_clear清除相应页表的一个表项,由此禁止进程使用该页表项映射的线性地址。
ptep_get_and_clear()函数清除一个页表项并返返回前一个值。
3)set_ptr,set_pmd,set_pud,set_pgd向一个页表项中写入指定的值。
set_pte_atomic与set_pte的作用是相同的,但是当PAE被激活的时候同样能保证64位的值被原子的写入。
4)如果a、b两个页表项指向同一页并且指定相同的访问优先级,那么pte_same(a,b)返回1,否则返回0。
5)如果页中间目录项e指向一个大型页(2MB或4MB)那么pmd_large(e)返回1,否则返回0.
物理内存布局:
在初始化阶段,内核必须建立一个物理地址映射来指定哪些物理地址范围对内核可用而哪些不可用(或者因为它们映射硬件设备I/O的共享内存、或者因为相应的页框含有BIOS数据)。
内核将下列页框记为保留:
在不可用的物理地址范围内的页框。
含有内核代码和初始化的数据结构的页框。
进程页表
进程的线性地址空间分为两部分:
从0x00000000到0xbfffffff的线性地址,无论进程允许在用户态还是内核态都可以寻址。
从0xc0000000到0xffffffff的线性地址,只有内核态的进程才能寻址。
当进程运行在用户态时,它产生的线性地址小于0xc0000000;当进程运行在内核态时,它执行内核代码,所产生的地址大于等于0xc0000000。但是,在默写情况下,内核为了检索或存放数据必须访问用户态线性地址空间。
内核页表:
内核维持着一组自己使用的页表,驻留在所谓的主内核页全局目录中。系统初始化后,这组页表还从未被任何进程或者任何内核线程直接使用;更确切地说,主内核页全局目录的最高目录项部分作为参考模型,位系统中每个普通经常对应的页全局目录项提供参考模型。
内核如何初始化自己的页表?这个过程分为两个阶段。事实上,内核映像刚刚被装入内存后,CPU仍然运行在实模式,所以分页功能没有被启动。
第一阶段,内核创建一个有限的地址空间,包括内核的代码段和数据段、初始化页表和用于存放动态数据结构的共128KB大小的空间。这是最小限度的地址空间仅够将内核装入RAM和对其初始化核心数据结构。
第二阶段,内核充分利用剩余的RAM并适当地建立分页表。下面解释这个方案是如何实施的。
临时内核页表:
临时页全局目录是在内核编译过程中静态初始化的,而临时页表是由startup_32()汇编语言函数初始化的。在这个阶段PAE支持并未激活。
临时全局目录放在swapper_pg_dir变量中。临时页表在pg0变量处开始存放,紧接着内核未初始化的数据段后面。为了简单起见,我们假设内核使用的段、临时页表和128KB的内存范围能容纳于RAM前8MB空间里,为了映射RAM前8MB的空间,需要用到两个页表。
分页第一阶段的目标是允许在实模式下和保护模式下都容易地对着8MB寻址。因此,内核必须创建一个映射,把0x00000000到0x007fffff的线性地址和0x00800000到0xc07fffff的线性地址映射到从0x00000000到0x007fffff的物理地址。
换句话说买就是内核在初始化的第一阶段,可以通过与物理地址相同的线性地址或者通过从0xc0000000开始的8MB线性地址对RAM的钱8MB进行寻址。
当RAM小于896MB时的最终内核页表
由内核页表所提供的最终映射必须从0xc0000000开始的线性地址转换为从0开始的物理地址。
宏__pa用于把从PAGE_OFFSET开始的线性地址转换为相应的物理地址,而宏__va做相反的转换。
主内核页全局目录仍然保存在swapper_pg_dir变量中。它由paging_init()函数初始化。该函数进行如下操作:
1、调用pagetable_init()适当地建立页表项
2、把swapper_pg_dir的物理地址写入cr3控制寄存器中。
3、如果CPU支持PAE并且如果内核编译支持PAE,则将cr4控制寄存器的PAE标志置位
4、调用__flush_tlb_all()使TLB的所有项无效。
当RAM大小在896MB和4096MB之间时的最终内核页表
在这种情况下,并不把RAM全部映射到内核地址空间。Linux在初始化阶段可以做的最好的事是把一个具有896MB的RAM窗口映射到内核线性地址空间。如果需要对现有RAM其余部分寻址,那么就必须把某些其他的线性地址间隔映射到所需的RAM。这意味着修改某些页表项的值。
内核使用与前一种相同的代码来初始化页全局目录。
当RAM大于4096MB时的最终内核页表
现在让我们考虑RAM大于4GB计算机的内核页表初始化;更确切地说,我们处理一下发生的情况:
CPU模型支持物理地址扩展(PAE)
RAM容量大于4GB
内核以PAE支持来编译
尽管PAE支持36位物理地址,但是线性地址依然是32位地址。如前所述,Linux映射一个896MB的RAM窗口到内核线性地址空间;剩余的RAM留着不映射,并用动态重映射来处理。
以前一种情况的主要差异是使用三级分页模型。
固定映射的线性地址
我们看到内核线性地址第四个GB的初始部分映射系统的物理内存。但是至少128MB的线性地址总是留作他用,因此内核使用这些线性地址实现非连续内存分配和固定映射的线性地址。
固定映射线性地址基本上是一种类似于0xffffc000这样的常量线性地址,起对应的物理地址不比等于线性地址减去0xc0000000,而是可以以任意方式建立。
我们将在后面的章节看到,内核使用固定映射的线性地址来代替指针变量,应为这些指针变量的值从不改变。
每个固定映射的线性地址都存放在线性地址第四个GB的末端。
为了把一个物理地址与固定映射的线性地址关联起来,内核使用set_fixmap(idx,phys)和set_fixmap_nocache(idx,phys)宏。
这两个函数都把fix_to_virt(idx)线性地址对应一个页表项初始化为物理地址phys;
反过来,clear_fixmap(idx)用来撤销固定映射线性地址idx和物理地址之间的连接。
处理硬件高速缓存和TLB
硬件高速缓存和转换后院缓存器(TLB)在提高现代计算机体系结构的性能上扮演着非常重要的角色。
处理硬件高速缓存
为了使高速缓存的命中率达到最优化,内核在下列决策中考虑体系结构:
一个数据结构中最常使用的字段放在该数据结构内存低的低偏移部分,以便他们能够处于高速缓存的同一行中
当为一个大组数据结构分配空间时,内核试图把他们都存放在内存中,以便所有高速缓存行按同一方式使用。
处理TLB
处理器不能自动同步他们自己的TLB高速缓存,因为决定线性地址和物理地址之间映射何时不再有效的是内核,而不是硬件。
一般来说,任何进程切换都会暗示着更换活动页表级。相对于过期的页表,本地TLB表项必须刷新;这个过程在内核把新的页全局目录的地址写入cr3控制器时会自动完成。
不过内核在下列情况下将避免TLB被刷新:
1、当两个使用相同页表集的不同进程之间执行进程切换时
2、当一个普通进程和一个内核线程间执行进程切换时。
事实上,每个内核线程并不拥有自己的页表集;更确切地说,它使用一个普通进程的页表集。