Linux内存管理学习笔记——内存寻址

最近开始想稍微深入一点地学习Linux内核,主要参考内容是《深入理解Linux内核》和《深入理解Linux内核架构》以及源码,经验有限,只能分析出有限的内容,看完这遍以后再更深入学习吧。

1,内存地址

逻辑地址:包含在机器语言中用来指定一个操作数或一条指令的地址。

线性地址:一个32位无符号数,用于直接映射物理地址

物理地址:片上引脚寻址级别的地址

2,逻辑地址->线性地址

2.1 段选择符与段寄存器

逻辑地址:段选择符(16位)+段内偏移(32位)

 

index:在GDT或LDT中段描述符的位置

TI:段描述符在GDT中(TI=0),段描述符在LDT中(TI=1)

RPL: 请求者特权级,当段选择符装入cs寄存器中,指示CPU的当前特权级

为了方便的找到段选择符,处理器提供六个段寄存器存放段选择符。

cs ss ds es fs gs

其中     cs:代码段寄存器,指向包含程序指令的段

            ss:栈段寄存器,指向包含当前程序栈的段

            ds:数据段寄存器,指向包含静态数或者全局数据段

cs寄存器的RPL字段表示CPU的当前特权级(CPL),内核态0,用户态3

 

2.2 段描述符 

每个段由一个8字节的段描述符表示,它描述了段的特征。

全局描述符表(GDT)和局部描述符表(LDT)存放段描述符。

通常只定义一个GDT,而每个进程除了存放在GDT中的段之外,如果还需要创建附加的段,就可以有自己的LDT。

GDT在主存中的位置和大小存放在gdtr控制寄存器中,当前正在被使用的LDT地址和大小放在ldtr寄存器中。、

为了加快主存与CPU的数据交换,引入高速缓存,架在主存与CPU中间,每把一个段选择符放进段选择器,同时会把相应的段描述符放进这个寄存器,加快数据交换

 

 

Base:  段基地址

G :      粒度标志:清0,段大小以字节为单位,否则以4096字节为单位。

Limit: 段长度

S:       系统标志,0:系统段,存储诸如LDT之类关键数据结构

DPL:  描述符特权级,用于存取这个段都要求的特权级。

2.3逻辑地址的转换

 

 

3,Linux中的实现

3.1Linux中的分段

Linux以非常有限的方式使用分段。

四个主要的linux段的段描述符

 

 

相应的段选择符由宏__USER_CS,__USER_DS,__KERNEL_CS,__KERNEL_DS分别定义。

为了对内核代码段寻址,只需要将__KERNEL_CS宏产生的值装入cs寄存器中即可。

 

所有段的段基地址都为0x00000000,地址空间都是从0~232-1。

可就是,Linux下逻辑地址与线性地址是一致的,逻辑地址的偏移量字段与相应线性地址的值总是一一对应的。

 

3.2Linux GDT

一个CPU对应一个GDT,所有的GDT都存放在cpu_gdt_table数组中。

 

每个GDT包含18个段描述符和14个空的,未使用的或保留的项。

3个局部线程存储段(TLS):线程私有数据

4个用户态和内核态下的代码段,数据段。

TSS段:任务状态段,每个CPU一个。所有的任务状态段都存放在init_tss数组中。

             G标志清0,Limit为0xeb,也就是段长为236bytes。DPL为0,不允许用户态访问。

              进程切换,进程上下文切换时,这个段用于保存CPU寄存器的内容。

LDT段:一般指向包含缺省LDT表的段。(大多数用户态下程序都不使用LDT,所以定义一个缺省的LDT供大多数进程共享)

             modify_ldt()系统调用允许进程创建自己的局部描述符表(例如Wine程序),此时LDT段相应的被修改。

double fault TSS:处理双重错误异常的特殊的TSS段??

3个与高级电源管理有关的段

5个与支持PnP功能的BIOS服务有关的段。

 

4,线性地址->物理地址

 

 控制寄存器cr3中存放正在使用的页目录的物理地址。

页目录项和页表项具有相同的结构

Present标志:置1,所指页或页表在主存中;为0,不在主存中。

                   当访问一个地址时,页目录项或页表项的Present标志为0。

               分页单元将该线性地址存放在寄存器cr2中,产生14号异常:缺页异常。

Field              :  20位,   页目录项中的Field指向包含一个页表的页框。

                                      页表项中的Field指向包含一页数据的页框。

PCD/PWT标志: 硬件高速缓存有关

Access标志   :  每当分页单元对相应页框进行寻址时,设置这个标志。

Dirty标志      :  只应用于页表项中,每当对一个页框写操作时就设置。

Read/Write  :   页或页表的存取权限

User/Supervisor:  访问页或页表所需的特权级

Page Size  :  只应用于页目录项。设置为1,则页目录项指向2M或4M的内存。(hugepage)

Global标志: 只应用于页表项,用于防止常用页(全局页)从TLB中刷新出去。(当cr4寄存器的PGE(页全局启用)标志置位时,这个标志才有效)

 

 对于2M页面,启动时传递内核参数

hugepages=1024 

对于1GB的页面:

default_hugepagesz=1G hugepagesz=1G hugepages=4

CPU所支持的hugepage大小可以由cpu标志得知

If PSE exists, 2M hugepages are supported; if pdpe1gb exists, 1G hugepages are supported.

 

For 2 MB pages, there is also the option of allocating hugepages after the system

has booted.

echo 1024 > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages

 

mkdir /mnt/huge

mount -t hugetlbfs nodev /mnt/huge

 

只要是在 /mnt/huge/ 目录下创建的文件,将其映射到内存中时都会使用 2MB 作为分页的基本单位。值得一提的是,hugetlbfs 中的文件是不支持读 / 写系统调用 ( 如read()write()等 ) 的,一般对它的访问都是以内存映射的形式进行的。

 

在实际应用中,为了使用大页面,还需要将应用程序与库libhugetlb链接在一起。libhugetlb库对malloc()/free()等常用的内存相关的库函数进行了重载,以使得应用程序的数据可以放置在采用大页面的内存区域中,以提高内存性能。 

 

 

4.2 加快线性地址转换

为了缩小CPU和RAM之间的速度不匹配,引入了硬件高速缓存。基于局部性原理。

cache line :高速缓存与内存间一次传输数据的长度。

PCD 当访问该页框中数据时,高速缓存功能被启用还是禁用。

PWT:当数据被写到页框时,采用通写策略还是回写策略。

Linux对所有页框都启用高速缓存,对写操作都采用回写策略。

 

TLB(transition lookaside buffers):线性地址第一次寻找时,CPU会在缓慢的RAM中查看页表,然后把结果存储到该CPU对应的TLB,但在之后再次引用这个线性地址时,会直接调用TLB中的内容,以加速寻址。多核系统无需为每个CPU同步TLB,因为不同CPU相同的线性地址可能指向不同的物理空间

 

5,Linux中的分页

 

在32位系统中,upper dir是删除了的,middle dir相当于硬件分页中的页目录,table相当于页表,global dir相当于PDPT。

事实上,分页机制完成了以下两个设计目标:

  1. 给不同的进程分配不同的物理空间,避免内存错误。
  2. 解耦了页框与页。前者是物理上实际的存储空间,在RAM中,而页只是一堆数据。分页机制允许一个页先存储在一个页框中,然后被取出,最后被放在另一个页框内。这是Linux中虚拟内存的实现基础。

下面讨论一下Linux中如何处理页表的具体实现(代码流程),每个部分分析一两个函数足以大概明白各种机制。

5.1:线性地址字段

PAGE_SHIFT:指定offset字段的位数

8 #define PAGE_SHIFT      13

PMD_SHIFT指定offset和table字段的总位数,分别在二级页表和三级页表定义了两次,和PAGE_SHIFT的方式一致。

还有PUD_SHIFT,PGDIR_SHIFT,PTRS_PER_PTE,PTRS_PER_PMD等字段描述不同目录项和表项数目。

5.2:页表处理

只讨论32位系统情况,64位情况通过pgprot_t保护

pte_t,pmd_t,pud_t,pgd_t分别描述页表项,页中间目录项,页上级目录和页全局目录项的格式:

 94 #ifdef CONFIG_64BIT_PHYS_ADDR
 95   #ifdef CONFIG_CPU_MIPS32
 96     typedef struct { unsigned long pte_low, pte_high; } pte_t;
 97     #define pte_val(x)    ((x).pte_low | ((unsigned long long)(x).pte_high << 32))
 98     #define __pte(x)      ({ pte_t __pte = {(x), ((unsigned long long)(x)) >> 32}; __pte; })
 99   #else
100      typedef struct { unsigned long long pte; } pte_t;
101      #define pte_val(x) ((x).pte)
102      #define __pte(x)   ((pte_t) { (x) } )
103   #endif
104 #else
105 typedef struct { unsigned long pte; } pte_t;
106 #define pte_val(x)      ((x).pte)
107 #define __pte(x)        ((pte_t) { (x) } )
108 #endif
109 typedef struct page *pgtable_t;

可以看到,如果不指定64位硬件,就默认按照32位硬件系统处理,即使定义为64位物理地址,如果CPU的MIPS的32位,也会另行定义。

内核也提供了宏可以读取/设置页标志,以pte_user()和pte_wrprotect()为例:

237 static inline int pte_user(pte_t pte)   { return pte_val(pte) & __PAGE_PROT_USER; }

内联函数的方法定义

120 static inline pte_t pte_wrprotect(pte_t pte)
121 {
122         pte_val(pte) &= ~(_PAGE_WRITE | _PAGE_SILENT_WRITE);
123         return pte;
124 }

其中_PAGE_SILENT_WRITE是为了看是32位系统还是64位系统。

5.3:页表操作

该部分很多内容与虚拟地址相关,暂不分析。整体来说,就是获得各个页表不同表项的地址,这里只大概看一下其中一个函数,pgd_alloc(mm)

Pgd_alloc(mm)分配一个新的页全局目录,如果PAE被激活,还会分配三个对应的页中间目录。这个函数调用机制比较复杂,在此处不加细节说明的说一下流程和设计的数据结构。在之后的内存管理中会详细分析一次页表的所有重要数据结构和函数,先理顺整体思路。

调用开始于

15 static inline pgd_t *pgd_alloc (struct mm_struct *mm)
 16 {
 17         return (pgd_t *)get_zeroed_page(GFP_KERNEL);
 18 }

传入的mm在80x86体系中被忽略。然后通过get_zeroed_page()函数调用__get_free_pages()函数,这个是比较复杂的函数,返回一个32位地址,如前文所言,这个是不能用于表示高地址的页表的。

2750 unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order)
2751 {
2752         struct page *page;
2753 
2754         /*
2755          * __get_free_pages() returns a 32-bit address, which cannot represent
2756          * a highmem page
2757          */
2758         VM_BUG_ON((gfp_mask & __GFP_HIGHMEM) != 0);
2759 
2760         page = alloc_pages(gfp_mask, order);
2761         if (!page)
2762                 return 0;
2763         return (unsigned long) page_address(page);
2764 }

然后调用alloc_pages()函数实际调用到如下过程

2111 struct page *alloc_pages_current(gfp_t gfp, unsigned order)
2112 {
2113         struct mempolicy *pol = get_task_policy(current);
2114         struct page *page;
2115         unsigned int cpuset_mems_cookie;
2116 
2117         if (!pol || in_interrupt() || (gfp & __GFP_THISNODE))
2118                 pol = &default_policy;
2119 
2120 retry_cpuset:
2121         cpuset_mems_cookie = get_mems_allowed();
2122 
2123         /*
2124          * No reference counting needed for current->mempolicy
2125          * nor system default_policy
2126          */
2127         if (pol->mode == MPOL_INTERLEAVE)
2128                 page = alloc_page_interleave(gfp, order, interleave_nodes(pol));
2129         else
2130                 page = __alloc_pages_nodemask(gfp, order,
2131                                 policy_zonelist(gfp, pol, numa_node_id()),
2132                                 policy_nodemask(gfp, pol));
2133 
2134         if (unlikely(!put_mems_allowed(cpuset_mems_cookie) && !page))
2135                 goto retry_cpuset;
2136 
2137         return page;
2138 }

这个函数里首先是get_task_policy()分配内存节点,如果没有特殊的policy分配,会使用default的policy,然后分配内存,

96 static inline unsigned int get_mems_allowed(void)
 97 {
 98         return read_seqcount_begin(&current->mems_allowed_seq);
 99 }

然后根据参数判断是以interleaved方式还是alloc_pages_nodemask()的方式分配页,这里也是页分配的核心。

然后把成功分配的页返回即可,这就是流程。一开始,都是先分配一个zero_page

 

进程页表:

每一个进程都有它自己的页全局目录和页表集,当发生进程切换时,cr3的内容被保存在前一个执行进程的task_struct中,(task_struct->mm->pgd)

将下一个进程的pgd地址装入cr3寄存器。

内核提供了丰富的API可以查看页表的状态和值

进程的线性地址空间分为两部分

0-3G    用户态与内核态都可寻址

3G-4G 只有内核态才能寻址

进程的页全局目录的第一部分表项映射的线性地址小于3G

剩余的表项对于所有进程都是相同的,等于内核页表中的相应表项。

 

内核页表:

内核维持着一组自己使用的页表,驻留在主内核页全局目录中。在系统初始化时建立。

内核页表的初始化建立分为两个阶段:

第一阶段下,CPU处于实模式,分页功能尚未开启,内核创建一个有限的空间,把内核的代码段,数据段,初始页表和用于存放动态数据结构放进RAM,第二阶段,通过临时页全局目录的初始化和临时页表的初始化得到临时内核页表,其存放在swapper_pg_dir中。第一阶段,假设内核使用的段以及相应的数据结构能容纳与RAM的前8MB里,则必须讲用户模式的0x00000000到0x007fffff和内核的0xc0000000到0xc07fffff映射到RAM的前8MB的物理地址,即0x00000000到0x007fffff,然后把0xc0000000开始的线性地址转化为从0开始的物理地址,完成最终内核页表。

这就是大致流程,具体代码实现有待考究。

6,处理硬件高速缓存和TLB

硬件高速缓存的同步,由处理器自动完成。

TLB的同步,由内核完成,因为线性地址到物理地址的映射是否有效,由内核决定。

 

在一个处理器上运行的函数发送处理器间中断(????),给其他CPU,强制它们执行适当的函数刷新TLB。

 

 

 

进程切换一般都会引起TLB表项刷新,除了以下情况:

两个使用相同页表集的普通进程之间执行进程切换(线程,mm_struct)

普通进程与内核线程间执行进程切换(内核线程直接使用前一个进程的mm_struct)

 

为了避免多处理器系统上无用的TLB刷新,内核使用一种叫做lazy TLB模式的技术。

 

你可能感兴趣的:(Linux内存管理学习笔记——内存寻址)