深入理解linux内核——内存寻址

时间:2021-12-26 06:06:31

1.      在80x86微处理器上要区分三种地址:

a. 逻辑地址logical address:在机器语言指令中用来制定一个操作数或一条指令的地址,由段segment和偏移量offest/displacement组成。

b. 线性地址linear address/virtual address:32位无符号整数。

c. 物理地址physical address:32位或36位无符号整数,芯片级内存单元。

2.      内存控制单元MMU负责转换几种地址:

逻辑——分段单元segmentation unit——线性——分页单元paging init——物理

3.      有一个叫内存仲裁器memory arbiter的硬件电路负责保证RAM芯片上的读写串行执行,准许cpu访问空闲的RAM,延迟访问繁忙的RAM。

4.      逻辑地址的段标识符16位,称为段选择符segment selector,偏移量32位。

5.      段寄存器存放段选择符,有6个,cs(代码段)、ss(栈段)、ds(数据段)、es、fs、gs。注意cs中有两位用来表示cpu的当前特权Current Privilege Level,CPL,0代表最高3代表最低。linux只有0和3,分别叫内核态和用户态。

6.      段在kernel里用8字节的段描述符Segment Descriptor表示,存放在全局描述符表Global Descriptor Table,GDT或局部描述符表LocalDescriptor Table,LDT中。它们分别在gdtr、ldtr控制寄存器里。

7.      通常GDT只定义一个,每个进程除了GDT的段还要附加创建自己的段,于是就有了自己的LDT。

8.      段描述符包含Base、G、Limit、S、Type、DPL(Descriptor Privilege Level)、P、D或B、AVL(被linux忽略)。

9.      linux中广泛采用的几种段描述符:代码段描述符、数据段描述符、任务状态段描述符(TSSD,存放TSS,见上一篇进程部分)。

10.  快速访问段描述符机制:附加一个非编程寄存器(程序员不能控制),当段选择符装入段寄存器时,它对应的段描述符就装入它对应的非编程寄存器,那么只要段寄存器的内容不改变,针对哪个短的逻辑地址转换就可以不经GDT/LDT,直接读这个寄存器就好。

11.  80x86微处理器中的分段鼓励程序员把程序分化成子程序或者全局与局部数据区,但linux更爱用分页的方式,基于两个原因:

a.      当所有进程使用相同的段寄存器,它们共享同一组线性地址,内存管理变的简单。

b.      分页有利于移植到其他平台。

ps. 分段给每一个进程分配不同的线性地址空间,分页把同一线性地址空间映射到不同的物理空间。

12.  所有用户态进程使用相同的两个段来对指令、数据寻址,分别叫做用户代码段、用户数据段。内核态的也一样,叫做内核代码段,数据代码段。

13.  以上四个段的段选择符,用宏__USER_CS,__USER_DS,__KERNEL_CS,__KERNEL_DS分别定义;如果我们要对内核代码段寻址,就把__KERNEL_CS装入cs段寄存器即可。

14.  所有这四个段描述符,Base都是0x00000000,Limit都是0xfffff(32bit),由此可知两点:

a.      用户态或内核态下可以使用相同的逻辑地址;

b.      既然所有段都从0开始,那么linux下逻辑地址与线性地址一致。

15.  单处理器系统只有一个GDT,多cpu会有多个GDT。它们都存放在cpu_gdt_table数组中,GDT的地址和大小放在cpu_gdt_descr数组中。

16.  每个GDT有十八个段描述符,以及14个空或保留项。这十四个是为了使经常一起访问的描述符能够处于同一个32字节的硬件高速缓存中。那使用中的18个段描述符包括:

a.      Tip13中提到的四个段

b.      每个处理器一个tss(任务状态段,进程部分有介绍)。

c.      1个缺省局部描述符,default_ldt。

d.      3个局部线程存储(Tread-LocalStorage,TLS)段,系统调用set_thread_are()和get_thread_area()为执行中的进程创建和撤销TLS。

e.      其他:3个高级电源管理(AMP)相关的段;5个支持即插即用(PnP)功能的BIOS服务程序相关的段;1个处理双重错误异常的TSS段(双重错误即处理一个异常时引发了另一个异常)。

以上一共十八个。

17.  linux内核定义一个缺省的ldt让大多数进程共享,放在default_ldt数组中

18.  如果进程有创建自己的ldt的需求,就使用modify_ldt()系统调用。(比如Wine就需要自己的ldt)当cpu执行这种有自己ldt的进程时,该cpu的gdt副本中的ldt表现就被修改了。

19.  分页单元paging unit负责把线性地址转换成物理地址。

20.  线性地址->物理地址的关键是把所请求的访问类型与线性地址的访问权限比较,如果内存访问无效,那么会产生缺页异常。

21.  所谓的页page就是以固定长度为单位的一组线性地址。根据习惯,页可以指一组地址,也可以指这组地址中的数据。

22.  页内部连续的线性地址被映射到连续的物理地址中。

23.  页和页框:分页单元把所有RAM分成固定长度的页框page frame/物理页。每一个页框可以放一个页。页是一个数据块,可以放在任何页框或磁盘中。

24.  把线性地址映射到物理地址的数据结构称为页表page table。页表在主存中。

25.  常规分页:32位的线性地址有三个域:目录directory,高10位;页表table中间10位;偏移量offset低12位。

26.  线性地址的转换分两步,分别基于页目录表page directory和页表page table。

27.  使用这种二级模式的目的在于减少每个进程页表所需的RAM数量。因为使用一级页表的话,就需要2^20个表项来表示每个进程的页表。

28.  扩展分页extended paging机制允许页框大小从4kb扩展到4mb。

29.  常规分页举例——假设内核给一个进程分配了0x20000000~0x2003ffff的线性空间。这两个地址,高10位都是00 1000 0000(128),指向directory字段的第129项,所以页目录的第129目录项上必须有分配给该进程的页表的物理地址,页目录的其余1023项都置0(如果没有给这个进程分配其它线性地址的话);中间10位,从0到63,所以页表的1-64项有一期,其余960项置0。

现在假设要取0x20021406的数据,步骤:

a.      找到页目录第129项,取页表地址。

b.      0x20021406的中间10位是0x21(33)对应页表的第34项,这一项指向包含所需页的页框。

c.      偏移量是0x406,读出页框中偏移0x406的数据。

30.  如果页表0x21表项不在主存中(present标志为0),就会产生缺页异常。另外,不管任何时候,进程想访问0x20000000~0x2003ffff之外的地址时,都会产生缺页异常(因为其他页表项都是0,present标志自然也是0)。

31.  64位系统分页——两级分页不适合64位系统。因为一个页4kb,4 * 2^10 = 2^12的地址范围,那么offset字段是12位,那么table和directory就站了52位,太大了。所以64位系统采用额外的分页级别,不同系统不一样。

32.  linux才有一种同时适用32bit和64bit系统的普通分页模型。从2.6.11开始,linux支持4级分页,它们是:页全局目录pageglobal directory,页上级目录page upper directory,页中间目录page middle directory,页表page table。各自站的位数取决于具体的计算机体系结构。

33.  在没有启动物理地址扩展的32位系统中,页上级目录和页中间目录被全部置0,但其在指针序列中的位置仍然保留,这样,同样的代码可以兼容32位64位系统,且32位系统中实际上只保留了两级页表。

34.  每一个进程有它自己的页全局目录和自己的页表集。当进程切换时linux把cr3寄存器的内容保存在前一个执行进程的描述符中,然后把下一个要执行进程的描述符的值装入cr3仅存器中,因此当新进程开始在cpu上执行时,分页单元指向一组正确的页表。

35.  关于线性地址的宏:

PAGE_SHIFT //指定offset字段的位数。80x86是12。
PMD_SHIFT //指定线性地址的Offset字段和Table字段的总位数。不采用PAE时,是22.
PUD_SHIFT
PGDIR_SHUIFT //确定页上级目录项能映射的区域大小的对数、确定页全局目录项能映射的区域大小的对数。
PTRS_PER_PTE
PTRS_PER_PMD
PTRS_PER_PUD
PTRS_PER_PGD //用于计算页表、页中间目录、页上级目录、页全局目录表中表项的个数。

36.  页表处理相关的宏:

pte_t
pmd_t
pud_t
pgd_t //页表项、页中间目录项、页上级目录项、页全局目录项的格式。没有PAE时是32位类型,有PAE时是64位类型。
pgprot_t //与一个单独表项相关的保护标识。
__pte
__pmd
__pud
__pgd
__pgprot /*把无符号转化成相应类型。对应的pte_val、pmd_val、pud_val、pgd_val、pgprot_val做相反的转换。*/
pte_none
pmd_none
pgd_none
pgd_none //如果对应的项是0,则返回1;否则为1.
pte_clear
pmd_clear
pgd_clear
pgd_clear /*清除相应页表的一个表项,由此禁止进程使用这个线性地址。ptep_get_and_clear()可以清楚一个页表项并返回前一个值*/
set_pte
set_pmd
set_pud
set_pgd /*向一个页表项中写入指定的值*/
pte_same(a, b) /*a b两个页表项指向同一个页并且指定相同的访问优先级,返回1*/
pmd_bad /*检查页中间目录项,如果指向一个不能用的页表(不在主存、只允许读、accessed或dirty位被清除),返回1。pud_bad和pgd_bad总是返回0,没有pte_bad。因为页表项引用不可访问的页是合法的*/

37.  读页标志的函数:

38.  设置页标志的函数:

39.  对页表项操作的宏:

40.  页分配函数:

41.  内核保留这两种页框:在不可用的物理地址范围内的页框、含有内核代码和已经初始化的数据结构的页框。这种保留页框中的页决不能被动态分配或交换到磁盘上。

42.  一般来说linux内核安装在RAM中从物理地址0x0010 0000开始的地方。不从第一个MB就开始是因为:

a.      页框0由BIOS使用。

b.      物理地址0x000a 0000~0x000f ffff通常留给BIOS例程。

c.      第一个MB内的其他页框可能由特定的计算机模型保留。

43.  在计算机启动早期,内核询问BIOS了解物理内存大小,随后内核执行machine_specific_memory_setup()函数,建立物理地址映射。

44.  setup_memory()函数在machine_specific_memory_setup()函数执行后被调用,它分析物理区域并初始化一些变量来描述内核的物理内存布局,变量如下:

45.  进程的线性地址空间分两部分,0~0xbfff ffff无论内核态还是用户态进程都可以寻址,0xc000 0000~0xffff ffff只有内核态才能寻址。所以一般内核态、用户态进程的空间以0xc0000000分界,但某些情况下内核为了检索或者存放数据,必须访问用户态空间。

46.      内核页表:内核维持着一组自己使用的页表,驻留在所谓的主内核也全局目录master kernel Page Global Directory中。

47.      内核初始化自己的页表分两个阶段:

a.      首先内核传建一个有限的地址空间,包含内核的代码段和数据段、初始页表、用于存放动态数据结构的一共128KB的空间。这个空间只是满足初始化。

b.      然后内核利用剩余的RAM并适当地建立分页表。

48.      临时内核页表:临时页全局目录是在内核编译过程中静态地初始化的,而临时页表是由startup_32()汇编语言函数初始化的。临时页全局目录放在swapper_pg_dir变量中,临时页表在pg0变量处开始存放,紧接在内核未初始化的数据段后面。

49.      我们假设内核所需要的段、临时页表和47a提到的128KB的内存能容纳于RAM的前8MB中,/*为了映射RAM前8MB空间,需要两个页表。*/分页的第一阶段目标就是允许在实模式和保护模式下都能很容易的对着8MB寻址。即内核要把0 ~ 0x007f ffff和0xc0000 0000 ~ 0xc07f ffff两段地址映射到0 ~ 0x007fffff的地址空间。

50.      内核的做法是把swapper_pg_dir清零,但把第0项和第0x300项的地址字段置为pg0的物理地址,第1项和第0x301项的地址字段置为紧随pg0后的页框的物理地址。

51.      最终内核页表:由内核页表所提供的最终映射必须把从0xc000 0000开始的线性地址转化为从0开始的物理地址。

52.      宏__pa用于把从PAGE_OFFSET开始的线性地址转换成相应的物理地址。__va做相反的转化。

53.      主内核全局目录也保存在sapper_pg_dir变量中,由paging_inti()函数初始化,该函数:

a.      调用paging_init()建立页表;

b.      把swapper_pg_dir的物理地址写入cr3控制寄存器中;

c.      *支持PAE的话讲cr4控制器的PAE标识置位;

d.      *调用__flush_tlb_all()使TLB的所有项无效。

54.  pagetable_init()执行的操作依赖于现有RAM容量和CPU模型,最简单的情况是计算机有小于896MB的RAM(线性地址的最高128MB留给集中映射了,剩下要映射的RAM就是1GB – 128MB = 896MB),32位物理地址足以对所有可用的RAM进行寻址。

55.  swapper_pg_dir页全局目录由如下等价的循环重新初始化:

pgd = swapper_pg_dir + pgd_index(PAGE_OFFSET);/*0x300*/
phys_addr = 0x00000000;
while(phys_addr < (max_low_pfn * PAGE_SIZE))
{
pmd = one_md_table_init(pgd);
set_pmd(pmd, __pmd(phys_addr | pgprot_val(__pgprot(0x1e3))));
/*0x1e3 == Present, Accessed, Dirty, Read/Write, Page Size, Global*/
phys_addr += PTRS_PER_PTE * PAGE_SIZE; /*0x400000*/
}

56.  当RAM大小在896-4096MB之间,并不把RAM全部映射到内核地址空间。Linux初始化阶段先把一个具有896MB的RAM窗口映射到内核线性地址空间。如果有程序要对RAM其余部分寻址,就需要通过改变某些页表项的值来把其他线性地址的间隔映射到所需的RAM。

57.  当RAM大于4096MB,需要PAE的支持。

58.  固定映射的线性地址fix-mapped linear address基本上是一种类似于0xffffc000这样的常量线性地址,它对应的物理地址不必等于直接减去0xc000000,而是可以以任意方式建立。

59.      因此每个固定映射的线性地址都映射一个物理内存的页框。内核使用固定映射的线性地址来代替指针变量,因为这些指针变量的值从不改变。

60.      固定映射的线性地址比指针变量更有效,因为:

a.      间接引用一个指针变量比间接引用一个立即常量地址要多一次内存访问;

b.      间接引用一个指针变量的时候有必要进行检查,而常量线性地址没必要检查。

61.      固定映射的线性地址用fixed_address枚举体中的整型索引表示:

enum fixed_addresses{
FIX_HOLE,
FIX_VSYSCALL,
FIX_APIC_BASE,
FIX_IO_APIC_BASE_0,
[...]
__end_of_fixed_addresses
};

62.      每个固定映射的线性地址都放在线性地址的第四个GB末端。fix_to_virt()函数计算给定索引开始的常量线性地址:

inline unsigned long fix_to_virt(const unsigned int idx)
{
if(idx >= __end_of_fixed_addresses)
/*if语句如果不成立,会在编译阶段去掉*/
__this_fixmap_does_not_exist();
/*如果if语句成立了,此处会发生链接错误,因为该函数别处没定义过*/
return (0xfffff000UL – (idx << PAGE_SHIFT));
}

63      为了把一个物理地址与固定映射的线性地址关联起来,内核使用set_fixmap(idx, phys)和set_fixmap_nocache(idx, phys)宏。这两个函数都把fix_to_virt(idx)线性地址对应的一个页表项初始化为物理地址phys。但第二个函数会额外把页表项的PCD置位以禁用硬件高速缓存。

64.      用clear_fixmap(idx)可以撤销固定映射线性地址idx和物理地址之间的连接。