linux kernel 内存管理-页表、TLB

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了linux kernel 内存管理-页表、TLB相关的知识,希望对你有一定的参考价值。

参考技术A

页表用来把虚拟页映射到物理页,并且存放页的保护位(即访问权限)。
在Linux4.11版本以前,Linux内核把页表分为4级:
页全局目录表(PGD)、页上层目录(PUD)、页中间目录(PMD)、直接页表(PT)
4.11版本把页表扩展到5级,在页全局目录和页上层目录之间增加了 页四级目录(P4D)
各处处理器架构可以选择使用5级,4级,3级或者2级页表,同一种处理器在页长度不同的情况可能选择不同的页表级数。可以使用配置宏CONFIG_PGTABLE_LEVELS配置页表的级数,一般使用默认值。
如果选择4级页表,那么使用PGD,PUD,PMD,PT;如果使用3级页表,那么使用PGD,PMD,PT;如果选择2级页表,那么使用PGD和PT。 如果不使用页中间目录 ,那么内核模拟页中间目录,调用函数pmd_offset 根据页上层目录表项和虚拟地址获取页中间目录表项时 直接把页上层目录表项指针强制转换成页中间目录表项

每个进程有独立的页表,进程的mm_struct实例的成员pgd指向页全局目录,前面四级页表的表项存放下一级页表的起始地址,直接页表的页表项存放页帧号(PFN)
内核也有一个页表, 0号内核线程的进程描述符init_task的成员active_mm指向内存描述符init_mm,内存描述符init_mm的成员pgd指向内核的页全局目录swapper_pg_dir

ARM64处理器把页表称为转换表,最多4级。ARM64处理器支持三种页长度:4KB,16KB,64KB。页长度和虚拟地址的宽度决定了转换表的级数,在虚拟地址的宽度为48位的条件下,页长度和转换表级数的关系如下所示:

ARM64处理器把表项称为描述符,使用64位的长描述符格式。描述符的0bit指示描述符是不是有效的:0表示无效,1表示有效。第1位指定描述符类型。
在块描述符和页描述符中,内存属性被拆分为一个高属性和一个低属性块。

处理器的MMU负责把虚拟地址转换成物理地址,为了改进虚拟地址到物理地址的转换速度,避免每次转换都需要查询内存中的页表,处理器厂商在管理单元里加了称为TLB的高速缓存,TLB直译为转换后备缓冲区,意译为页表缓存。
页表缓存用来缓存最近使用过的页表项, 有些处理器使用两级页表缓存 第一级TLB分为指令TLB和数据TLB,好处是取指令和取数据可以并行;第二级TLB是统一TLB,即指令和数据共用的TLB

不同处理器架构的TLB表项的格式不同。ARM64处理器的每条TLB表项不仅包含虚拟地址和物理地址,也包含属性:内存类型、缓存策略、访问权限、地址空间标识符(ASID)和虚拟机标识符(VMID)。 地址空间标识符区分不同进程的页表项 虚拟机标识符区分不同虚拟机的页表项

如果内核修改了可能缓存在TLB里面的页表项,那么内核必须负责使旧的TLB表项失效,内核定义了每种处理器架构必须实现的函数。

当TLB没有命中的时候,ARM64处理器的MMU自动遍历内存中的页表,把页表项复制到TLB,不需要软件把页表项写到TLB,所以ARM64架构没有提供写TLB的指令。

为了减少在进程切换时清空页表缓存的需要,ARM64处理器的页表缓存使用非全局位区分内核和进程的页表项(nG位为0表示内核的页表项), 使用地址空间标识符(ASID)区分不同进程的页表项
ARM64处理器的ASID长度是由具体实现定义的,可以选择8位或者16位。寄存器TTBR0_EL1或者TTBR1_EL1都可以用来存放当前进程的ASID,通常使用寄存器TCR_EL1的A1位决定使用哪个寄存器存放当前进程的ASID,通常使用寄存器 TTBR0_EL1 。寄存器TTBR0_EL1的位[63:48]或者[63:56]存放当前进程的ASID,位[47:1]存放当前进程的页全局目录的物理地址。
在SMP系统中,ARM64架构要求ASID在处理器的所有核是唯一的。假设ASID为8位,ASID只有256个值,其中0是保留值,可分配的ASID范围1~255,进程的数量可能超过255,两个进程的ASID可能相同,内核引入ASID版本号解决这个问题。
(1)每个进程有一个64位的软件ASID, 低8位存放硬件ASID,高56位存放ASID版本号
(2) 64位全局变量asid_generation的高56位保存全局ASID版本号
(3) 当进程被调度时,比较进程的ASID版本号和全局版本号 。如果版本号相同,那么直接使用上次分配的ASID,否则需要给进程重新分配硬件ASID。
存在空闲ASID,那么选择一个分配给进程。不存在空闲ASID时,把全局ASID版本号加1,重新从1开始分配硬件ASID,即硬件ASID从255回绕到1。因为刚分配的硬件ASID可能和某个进程的ASID相同,只是ASID版本号不同,页表缓存可能包含了这个进程的页表项,所以必须把所有处理器的页表缓存清空。
引入ASID版本号的好处是:避免每次进程切换都需要清空页表缓存,只需要在硬件ASID回环时把处理器的页表缓存清空

虚拟机里面运行的客户操作系统的虚拟地址转物理地址分两个阶段:
(1) 把虚拟地址转换成中间物理地址,由客户操作系统的内核控制 ,和非虚拟化的转换过程相同。
(2) 把中间物理地址转换成物理地址,由虚拟机监控器控制 ,虚拟机监控器为每个虚拟机维护一个转换表,分配一个虚拟机标识符,寄存器 VTTBR_EL2 存放当前虚拟机的阶段2转换表的物理地址。
每个虚拟机有独立的ASID空间 ,页表缓存使用 虚拟机标识符 区分不同虚拟机的转换表项,避免每次虚拟机切换都要清空页表缓存,在虚拟机标识符回绕时把处理器的页表缓存清空。

arm-linux内存管理学习笔记-页表前戏

start_kernel之前的汇编代码建立了内核临时页表,完成了内核区域的静态线性映射,保证内核可以在舒适的虚拟地址空间(运行地址和链接地址一致)运行。进入start_kernel之后就要准备建立完整的页表映射,这部分工作是在paging_init中完成。
不过在建立完整页表映射之前还需要进行一些准备工作,本文来分析下。
为了简化整个代码流程,便于分析,我的设备内核配置为不使用高端内存,不配置CONFIG_HIGHMEM。bootargs中传给内核的mem=256.
内核版本号:3.4.55
paging_init是在start_kernel的setup_arch中调用,这里按照先后顺序对setup_arch跟页表相关的各个函数功能做个介绍,重点函数paging_init进行详细分析。
setup_processor:调用lookup_processor_type,跟head.S中__lookup_processor_type一样,获取存储在.proc.info.init段中与cpu id一致的proc_info_list结构体,该结构体中存储着处理器的一些特性。打印出cpu的一些相关信息(如版本号 cache属性等)。

setup_machine_tags:对uboot传递来的tags进行解析,获取mem cmdline等信息,具体过程可以参考我分析kernel传参的博文,链接:http://blog.csdn.net/skyflying2012/article/details/35787971

parse_early_param:对boot_command_line进行早期的解析,具体解析原理可以参考我分析kernel参数解析的博文,链接:http://blog.csdn.net/skyflying2012/article/details/41142801
与页表相关的是对mem的解析,相应的解析函数如下。


    static int usermem __initdata = 0;
    unsigned long size;
    phys_addr_t start;
    char *endp;

    /*
     * If the user specifies memory size, we
     * blow away any automatically generated
     * size.
     */
    if (usermem == 0) 
        usermem = 1;
        meminfo.nr_banks = 0;
    

    start = PHYS_OFFSET;
    size  = memparse(p, &endp);
    if (*endp == '@')
        start = memparse(endp + 1, NULL);

    arm_add_memory(start, size);

    return 0;

early_param("mem", early_mem);

我的设备内存起始物理地址是0x80000000,即PHYS_OFFSET = 0x80000000,cmdline中mem=256m,
early_mem最终调用arm_add_memory将0x80000000起始的256MB内存添加到meminfo的membank数组中。meminfo中记录着系统有多少块连续内存,用membank数组记录,这里我们仅使用1个membank表示0x80000000起始的256MB内存空间。

sanity_check_meminfo:对meminfo中所有的membank进行范围检查,不能覆盖最小的vmalloc区域,将lowmem_limit设置为最高membank的顶端,我的设备只有一个membank,因此lowmem_limit为0x90000000,这是内存物理地址。最后将high_memory设置为lowmem_limit的虚拟地址,lowmem是线性映射到0xc0000000,因此high_memory=0xd0000000。
static void * __initdata vmalloc_min = (void *)(VMALLOC_END - (240 << 20) - VMALLOC_OFFSET);
我的设备VMALLOC_END=0xfc000000,这是vmalloc区域的上限,VMALLOC_OFFSET=0x800000,是vmalloc区域与lowmem之间8MB的隔离带。这样计算vmalloc_min是0xec800000,这是一个最小(240MB)的vmalloc区域.

arm_memblock_init:全局结构体memblock用来记录内存中可用和保留的区域,memblock.memory代表可用区域,memblock.reserved代表保留区域。arm_memblock_init中首先将meminfo中记录的membank添加到可用区域中。将kernel的代码段 数据段以及ramdisk区域都添加到保留区域,再将一级页目录的16KB区域添加到保留区域。
最后如果在板级结构体machine_desc定义了reserve函数,则会调用该函数完成板级相关的一些内存区域的保留。
为了方便调试,可以在cmdline中加入memblock=debug,会将memblock中可用和保留的区域全部打印出来。
我的设备是将0x80000000起始的256MB区域添加到可用区域memblock.memory中。
以上完成了对meminfo memblock的初始化,需要注意,其中存储的都是物理地址。

接下来调用paging_init,其中与页表建立相关的函数如下。在arch/arm/mm/mmu.c中

 build_mem_type_table();
    prepare_page_table();
    map_lowmem();
    devicemaps_init(mdesc);
    kmap_init();

    top_pmd = pmd_off_k(0xffff0000);

其中map_lowmem devicemaps_init kmap_init完成了完整页表的建立,下篇文章再详细分析,这里先不说了。
build_mem_type_table:完成对kernel中mem_types数组的修复和补充。mem_types数组是kernel记录当前系统映射不同地址空间类型(普通内存 设备内存 IO空间等)的页表属性,其中页表属性还包括section-mapping的属性prot_sect,以及page-mapping的一级页目录属性prot_l1,二级页表属性prot_pte.
不过要注意的是,mem_types中prot_sect prot_l1都与硬件属性一一对应。但二级页表属性值prot_pte是linux定义的软件页表属性,而不是硬件页表属性,本文最后说明linux与arm适配问题会再详说。
在kernel下页表建立函数create_mapping中会根据选定的mem_types成员来设置当前映射所需的二级软件页表属性,最后会调用处理器相关的set_pte_ext,首先设置软件页表项,然后在根据软件页表项值配置硬件页表项。(linux在建立完整第二级页表时使2个硬件页表相连,并且在其后添加2个软件页表,保证这一页空间被充分利用,文末再细说)
build_mem_type_table中跟处理器特性对mem_types数组成员(各个类型的地址空间)的各个页表属性进行修改补充。软件开发人员一般不需要修改,除非处理器核做过修改。
接下来我们来分析下本文的关键函数prepare_page_table。在arch/arm/mm/mmu.c中

static inline void prepare_page_table(void)

    unsigned long addr;
    phys_addr_t end;

    /*
     * Clear out all the mappings below the kernel image.
     */
    for (addr = 0; addr < MODULES_VADDR; addr += PMD_SIZE)
        pmd_clear(pmd_off_k(addr));

#ifdef CONFIG_XIP_KERNEL
    /* The XIP kernel is mapped in the module area -- skip over it */
    addr = ((unsigned long)_etext + PMD_SIZE - 1) & PMD_MASK;
#endif
    for ( ; addr < PAGE_OFFSET; addr += PMD_SIZE)
        pmd_clear(pmd_off_k(addr));

    /*
     * Find the end of the first block of lowmem.
     */
    end = memblock.memory.regions[0].base + memblock.memory.regions[0].size;
    if (end >= lowmem_limit)
        end = lowmem_limit;

    /*
     * Clear out all the kernel space mappings, except for the first
     * memory bank, up to the vmalloc region.
     */
    for (addr = __phys_to_virt(end);
         addr < VMALLOC_START; addr += PMD_SIZE)
        pmd_clear(pmd_off_k(addr));

该函数是在建立完整页表前对一级页目录(swapper_pg_dir)进行清空,便于建立页表时对空页目录项进行判断然后分配。不过并不是所有页目录项都清空,我们知道虚拟地址的高12bit是16K一级页目录的索引ndex,prepare_page_table中对于第一块membank(lowmem部分)虚拟地址空间映射的页目录项跳过,不进行清空操作。
以我的设备为例,第一块也是唯一一块membank是0x80000000起始的256MB。lowmem_limit也是0x90000000。这块空间是线性映射,由0x80000000映射到0xc0000000。因此在4KB个页目录项中,第0xc00到第0xd00这256个页目录项,prepare_page_table不进行清空。
根据上一篇内核临时页表建立的分析,head.S中建立的临时页表,主要完成了3个地址空间的映射。
(1)turn_mmu_on所在1M空间的平映射
(2)kernel image的线性映射
(3)atags所在1M空间的线性映射
我计算过我的设备中,临时页表中kernel image线性映射了大约12MB空间,也就是第0xc00之后的12个页目录项。prepare_page_table中跳过0xc00开始的256个,临时页表建立的kernel image线性映射没有破坏,kernel的运行环境得到了保证。
不过为了保证mmu使能后的平滑跳转而建立的1MB平映射(vaddr从0x80000000到0x8100000),在prepare_page_table中就被清空了。
prepare_page_table整个流程如下。
1 对虚拟地址0到MODULES_VADDR(0xc0000000以下8MB或16MB的地址)的一级页目录项进行清空
2 对MODULES_VADDR到PAGE_OFFSET(0xc0000000)的一级页目录项进行清空
3 对lowmem顶端到VMALLOC_START的一级页目录项进行清空
我们来分析下具体的清空过程,以流程1为例。
从0到MODULES_VADDR,对每2MB空间,调用pmd_clear(pmd_off_k(addr))。
pmd_off_k定义如下。在arch/arm/mm/mm.h中

static inline pmd_t *pmd_off_k(unsigned long virt)

    return pmd_offset(pud_offset(pgd_offset_k(virt), virt), virt);

arm-linux仅使用2级页表,pud_offset
pmd_offset不做任何操作,直接返回参数,直接来看pgd_offset_k,如下。

extern pgd_t swapper_pg_dir[PTRS_PER_PGD];

/* to find an entry in a page-table-directory */
#define pgd_index(addr)     ((addr) >> PGDIR_SHIFT)

#define pgd_offset(mm, addr)    ((mm)->pgd + pgd_index(addr))

/* to find an entry in a kernel page-table-directory */
#define pgd_offset_k(addr)  pgd_offset(&init_mm, addr)

PGDIR_SHIFT定义为21,pgd_offset_k获取到了一级页目录项的虚拟地址,但是需要注意的是,该地址是8字节对齐。再来看pmd_clear,如下。在./arch/arm/include/asm/pgtable-2level.h

#define pmd_clear(pmdp)         \\
    do                 \\
        pmdp[0] = __pmd(0); \\
        pmdp[1] = __pmd(0); \\
        clean_pmd_entry(pmdp);  \\
     while (0)

将连续的2个页目录项清空,并且清空相应的TLB缓存。
综上,prepare_page_table清空具体流程是,对指定地址空间以2MB为一次操作单元,每次操作连续清空对应的2个一级页目录。
这就奇怪了,按照之前我们介绍的arm硬件页表机制,一级页目录共4096个,每个映射1MB空间,不应该一次操作1MB吗,为啥要以2MB为单位?

理解内核的这种做法,文章最后,就要来说明下linux页表机制与arm页表机制的适配问题了,主要有2点。
1 页表深度的适配
2 软件页表和硬件页表配合

1 页表深度的适配
arm硬件页表机制之前详细分析过,这里不再细说了,可以看我这系列的第一篇文章。
linux内核为了适配各种各样的处理器MMU,特别是64位处理器。2.6.11之前使用三级页表,之后开始使用四级页表。
linux内核定义的标准是这样,最高级pgd为页目录表,它找到每个进程mm_struct结构的pgd成员,用它定位到下一级pud,第二级pud定位到下一级pud,第三级pud定位到pte,pte是页表,它就能定位到哪个页了。最后虚拟地址的最后12位定位的是该页的偏移量。大体流程如下:

virt addr —> pgd —> pud —> pmd —> pte —> phy addr

首先需要理解,内存多级页表,是给mmu来解析的,因此针对不同的处理器mmu页表机制,linux需要在配置多级页表时进行调整。
为了能够符合arm mmu的页表解析机制(2级页表),arm-linux实现时砍掉了中间的pud pmd。如上面分析pmd_off_k时pud_offset pmd_offset中不做任何操作,直接返回参数。大体流程如下。

virt addr —> pgd —> pte —> phy addr

这样就保证了linux配置页表时虽然表面上看是4级页表,但是内存中建立却是一个2级页表。arm-mmu可以正确的解析页表,进行地址翻译。

2 软件页表和硬件页表配合
arm硬件页表机制中,每个一级页目录项对应的二级页表空间都是独立分配的,虽然连续2个一级页目录项所映射2MB地址空间是连续的虚拟地址空间,但是用来存储二级页表的空间之间是没有什么关系的。
不过arm-linux内核为了实现高效的内存管理,做了一个很巧妙的安排。
我们知道1个一级页目录项对应的二级页表是256x4 = 1024字节。arm-linux将2个连续的一级页目录项对应的2个二级页表分配在一起。而且还在这2个二级硬件页表之下在建立2个对应的二级软件页表,一共是4KB,正好占用1页空间。如下。

*    pgd             pte
 * |        |
 * +--------+
 * |        |       +------------+ +0
 * +- - - - +       | Linux pt 0 |
 * |        |       +------------+ +1024
 * +--------+ +0    | Linux pt 1 |
 * |        |-----> +------------+ +2048
 * +- - - - + +4    |  h/w pt 0  |
 * |        |-----> +------------+ +3072
 * +--------+ +8    |  h/w pt 1  |
 * |        |       +------------+ +4096

对于这样的安排在./arch/arm/include/asm/pgtable-2level.h有详细的英文解释。
我们告诉linux内核arm一级页目录项是8 bytes,一共是2048个。配置页目录项时,一次分配4KB空间,高2KB空间每1KB的物理基地址依次写入8 bytes一级页目录项的2个word中。
我的理解,arm-linux如此安排的原因有二。
(1)减少空间浪费,一个页目录项仅对应1024字节的页表,这样页表初始化时,按页分配的空间仅能使用1KB,其余3KB空间浪费,如上安排,可以完全利用这4KB页。
(2)linux软件二级页表属性位定义与arm硬件二级页表不一致(软件二级页表位定义在arch/arm/include/asm/pgtable-2level.h,硬件二级页表位定义在arch/arm/include/asm/pgtable-2level-hwdef.h,其中有详细说明),有些属性arm硬件页表没有,如dirty,accessed,young等,因此使用软件页表进行模拟兼容。arm-mmu读取硬件2级页表进行地址翻译,而第二级的软件页表仅仅留给linux来配置和读取。最终配置二级页表的set_pte_ext也会设置完软件页表后,在根据软件页表属性值来配置硬件页表。

所以我们在arm-linux内核中看到各种相关宏定义都表示,linux看到的arm一级页目录项有2048个,每个页目录项8
bytes,二级页表项是512个。不过arm-mmu的硬件机制还是4096个一级页目录项,每个页表有256个页表项。
两种机制是靠2个相邻页目录项的页表存储空间连续来平滑过渡的。

到这里,完整页表建立的准备工作做完了,主要是建立meminfo
memblock,并清空页目录项,但是保留了lowmem所在的线性映射页目录项,保障kernel正常运行。下篇文章就来分析下具体页表建立的过程!

以上是关于linux kernel 内存管理-页表、TLB的主要内容,如果未能解决你的问题,请参考以下文章

Linux内存从0到1学习笔记(四,TLB)

linux TLB表

linux 内存恒等映射

linux 内存恒等映射

linux内核源码分析之页表缓存

Linux分页机制之概述--Linux内存管理