【深入Linux内核架构笔记】第三章 内存管理——内存域、页、页表

525 阅读7分钟

3.1 概述

  • 虚拟地址空间:每个进程各自有自己的逻辑地址空间。在IA-32系统上,进程寻址范围是0 ~ 4GB。地址空间被典型划分为3GB的用户空间和1GB的内核空间

image-20220831062604088.png

  • 管理物理内存方式
    • UMA(一致内存访问, uniform memory access):将可用内存以连续方式组织起来。SMP系统每个处理器访问个内存区都一样快(结构对称)
    • NUMA(非一致内存访问, non-uniform memory access):仅用于多处理器计算机,各个CPU有各自的本地内存。CPU可以访问其它CPU的内存,但是比访问本地内存慢(结构非对称)

【注】书中重点考虑UMA系统的平坦内存模型FLATMEM,不考虑CONFIG_NUMA

image-20220831062648255.png

# numactl -H
available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 28 29 30 31 32 33 34 35 36 37 38 39 40 41
node 0 size: 79946 MB
node 0 free: 14940 MB
node 1 cpus: 14 15 16 17 18 19 20 21 22 23 24 25 26 27 42 43 44 45 46 47 48 49 50 51 52 53 54 55
node 1 size: 80597 MB
node 1 free: 55633 MB
node distances:
node   0   1
  0:  10  21
  1:  21  10

3.2 (N)UMA模型中的内存组织

3.2.1 术语定义

  • 结点:内核将物理内存划分为结点,每个结点关联到系统的一个处理器。内核中有pg_data_t的实例。
    • NUMA系统:各个内存结点保存在一个单链表中,供内核遍历。内核按照结点距离远近,优先遍历当前运行CPU相关联的结点
      • 每个结点都提供了一个备用列表struct zonelist,列表项越靠后越不适合分配
    • UMA系统:NUMA系统的特例,只有一个结点(图3-3的内存结点减少为一个),其余不变

image-20220912120110013.png

  • 内存域:IA-32系统上,虚拟地址空间的3G ~ 4G属于内核空间,其中3G ~ 3G+896M直接映射物理地址的0~896M
    • Linux把每个内存结点的物理内存划分为3个管理区zone_type
      • ZONE_DMA:0~16M,专门供I/O设备的DMA使用
      • ZONE_NORMAL:16~896M,内核可以直接使用该区域的物理页面
      • ZONE_HIGHMEM:896~1024M,属于高端内存,由于线性地址空间不足物理页面需要动态映射,所以不能由内核直接访问(64位系统地址空间充足,取消此区域)
    • 各个内存域关联一个数组,组织物理内存页(即页帧)。对每个页帧,内核分配一个struct page实例及所需的管理数据
enum zone_type {
#ifdef CONFIG_ZONE_DMA
    ZONE_DMA,                   //DMA设备使用,32位计算机该区域为16MB
#endif
#ifdef CONFIG_ZONE_DMA32
    ZONE_DMA32,                 //32位计算机该区域为空,64位系统上两种DMA才有区别
#endif
    ZONE_NORMAL,                //普通内存域,所有体系上都存在,
#ifdef CONFIG_HIGHMEM
    ZONE_HIGHMEM,               //高端内存
#endif
    ZONE_MOVABLE,               //伪内存域,防止物理内存碎片的机制用到,见3.5.2节
    MAX_NR_ZONES
};

3.2.2 数据结构

1. 结点管理pg_data_t定义了内存结点的基本元素,包括:内存域、页帧、结点编号、交换守护进程等

typedef struct pglist_data {
    struct zone node_zones[MAX_NR_ZONES];          //结点中各内存域的数据结构
    struct zonelist node_zonelists[MAX_ZONELISTS]; //备用列表,当前结点没有可用空间时,在备用结点分配内存
    int nr_zones;                                  //内存域数目
#ifdef CONFIG_FLAT_NODE_MEM_MAP
    struct page *node_mem_map;                     //指向结点的所有物理内存页(页帧)
#endif
    struct bootmem_data *bdata;                    //用于内存管理子系统初始化的自举,自举内存分配器数据结构的实例
......
    unsigned long node_start_pfn;                  //NUMA结点第一个页帧的逻辑编号,编号全局唯一。UMA中总是0
    unsigned long node_present_pages;              //结点中页帧总数
    unsigned long node_spanned_pages;              //结点含多少个页帧(中间可能有空洞,空洞不对应真正的页帧)
    int node_id;                                   //全局结点ID,从0开始编号
    wait_queue_head_t kswapd_wait;                 //交换守护进程的等待队列
    struct task_struct *kswapd;                    //交换守护进程的task_struct
    int kswapd_max_order;                          //用于页交换子系统的实现,定义需要释放区域的长度
} pg_data_t;
  • 结点状态管理:内核维护位图,记录各个结点的状态信息:结点是否有普通内存域、是否有高端内存域等等
    • /sys/devices/system/node/可以看到节点的详细信息
    • 使用node_set_state()node_clear_state()设置或清除比特位
enum node_states {
    N_POSSIBLE,            /* 结点在某个时刻可能变为online */
    N_ONLINE,            /* 结点是online的 */
    N_NORMAL_MEMORY,    /* 节点有普通内存域,没有高端内存域 */
#ifdef CONFIG_HIGHMEM
    N_HIGH_MEMORY,        /* 节点有普通或高端内存域 */
#else
    N_HIGH_MEMORY = N_NORMAL_MEMORY,
#endif
    N_CPU,                /* 节点有一个或多个CPU */
    NR_NODE_STATES
};

2. 内存域:内核使用struct zone描述内存域

  • ZONE_PADDING分隔为几个部分:页分配器访问的字段、页面收回扫描程序访问的字段、很少使用或大多数情况只读的字段

【注】这个结构体访问非常频繁。为了提高执行的性能,用两个自旋锁zone->lockzone->lru_lock对齐到不同cache line中

struct zone {
    //影响页交换的行为:内存不足则换到硬盘上
    //空闲页多于pages_high,内存域状况理想
    //空闲页低于pages_low,页换出到硬盘;
    //空闲页低于pages_min,页回收压力大,即内存域急需空闲页。
    unsigned long        pages_min, pages_low, pages_high;
    //为各个内存域预留若干页,用于无论如何不能失败的关键性内存分配
    unsigned long        lowmem_reserve[MAX_NR_ZONES];
    //用于实现每个CPU的热页列表和冷页列表
    struct per_cpu_pageset    pageset[NR_CPUS];
    spinlock_t        lock;                         //自旋锁
    struct free_area    free_area[MAX_ORDER];     //用于实现伙伴系统,3.5.5节

    ZONE_PADDING(_pad1_)
    spinlock_t        lru_lock;    
    struct list_head    active_list;       //活动页的集合
    struct list_head    inactive_list;     //不活动页的集合
    unsigned long        nr_scan_active;    //指定在回收内存时,需要扫描的活动页的数目
    unsigned long        nr_scan_inactive;  //指定在回收内存时,需要扫描的不活动页的数目
    unsigned long        pages_scanned;       //上次换出一页以来,有多少页未能成功扫描
    //内存域当前状态,允许使用以下标志
    //ZONE_ALL_UNRECLAIMABLE:内存试图页面回收,但是所有的页都已经“钉”住而无法回收(如mlock系统调用)
    //ZONE_RECLAIM_LOCKED:防止多个CPU并发回收
    //ZONE_OOM_LOCKED:进程OOM消耗大量内存,内核杀死该进程,此时要防止多个CPU同时回收内存
    unsigned long        flags;
    //维护有关该内存域的统计信息,见17.7.1节。用zone_page_state读取信息
    atomic_long_t        vm_stat[NR_VM_ZONE_STAT_ITEMS];
    //存储了上一次扫描操作扫描该内存域的优先级
    int prev_priority;

    ZONE_PADDING(_pad2_)
    //以下3个字段实现了一个等待队列,等待某一页变为可用的进程使用
    wait_queue_head_t    * wait_table;
    unsigned long        wait_table_hash_nr_entries;
    unsigned long        wait_table_bits;
    //建立内存域和父结点的关联
    struct pglist_data    *zone_pgdat;
    /* zone_start_pfn == zone_start_paddr >> PAGE_SHIFT */
    unsigned long        zone_start_pfn; //内存域第一个页帧的索引
    unsigned long        spanned_pages;    //较少使用,内存域中页的总数,包括空洞
    unsigned long        present_pages;    //较少使用,实际可用的页的数目,不包括空洞,通常与spanned_pages相同
    const char        *name;              //较少使用,保存内存域的惯用名称:Normal、DMA、HighMem
} ____cacheline_internodealigned_in_smp;

3. 内存阈值(watermark,也称水位)的初始化

  • 在内核启动或内存热插拔时,会重新计算内存阈值
  • 内存阈值:衡量内存的使用情况,有一个专门的内核线程kswapd0定期回收内存。系统内存越大,则阈值越大
    • 空闲页多于pages_high,内存域状况理想
    • 空闲页低于pages_low,页需要换出到硬盘
    • 空闲页低于pages_min,页回收压力大,即内存域急需空闲页
  • zone->pages_minzone->pages_lowzone->pages_highsetup_per_zone_pages_min()初始化,pages_min可以通过/proc/sys/vm/min_free_kbytes 内核选项设置(>=128KB,<=64MB)。
  • 初始的典型值计算方式:
    • pages_min=4lowmem_kbytespages\_min = 4\sqrt{lowmem\_kbytes},其中lowmem_kbytes可以认为是系统内存大小
    • pages_low=pages_min5/4pages\_low = pages\_min * 5 / 4
    • pages_high=pages_min3/2pages\_high = pages\_min * 3 / 2
  • zone->lowmem_reservekernel在分配内存时,可能会涉及到多个zone,优先尝试从自己的zone分配,如果失败就会尝试低地址的zone。每个zone需要自己预留内存
    • lowmem_reserve由setup_per_zone_lowmem_reserve()初始化,具体算法是:
      • 预留内存=zone管理的页帧数/比率预留内存 = zone管理的页帧数 / 比率
      • 比率由内核参数/proc/sys/vm/lowmem_reserve_ratio指定,默认:DMA是256,NORMAL是256,HIGHMEM是32 (在Linux 2.6.24上面是256 32 32)
      • ratio=256表示有1/256的页帧被保护;如果ratio=1表示有100%的页帧被保护
# 4.14内核版本的预留内存分布
$ cat /proc/sys/vm/lowmem_reserve_ratio
256     256     32
  • 我们可以通过/proc/zoneinfo查看具体的内存域信息
# 1GB内存32位虚拟机的内存域分布(2.6.24内核版本)
# cat /proc/zoneinfo
Node 0, zone      DMA
  pages free     2380
        min      17
        low      21
        high     25
        scanned  0 (a: 0 i: 0)
        spanned  4096
        present  4064
        protection: (0, 873, 1000, 1000)
......
Node 0, zone   Normal
  pages free     220882
        min      936
        low      1170
        high     1404
        scanned  0 (a: 0 i: 0)
        spanned  225280
        present  223520
        protection: (0, 0, 1015, 1015)
......
Node 0, zone  HighMem
  pages free     32520
        min      32
        low      66
        high     100
        scanned  0 (a: 0 i: 0)
        spanned  32766
        present  32511
        protection: (0, 0, 0, 0)
......

# 系统总预留的物理内存(KB) ~= (dma.min + dma32.min + normal.min) * 4
# cat /proc/sys/vm/min_free_kbytes
3816

【说明】

  • 物理内存分三个zone:DMA、NORMAL、HIGHMEM
    • zone[DMA]管理4096个页帧,也就是16MB内存
    • zone[NORMAL]管理225280个页帧,也就是约880MB内存
    • zone[HIGHMEM]管理32766个页帧,也就是约128MB内存
    • 总计1024MB内存
  • spanned:表示内存域的总页帧数,包括空洞
  • present:表示内存域的可用页帧数,不包括空洞
  • managed:内存域中被伙伴系统管理的页帧数
  • protection:每个区域保留的不能被用户空间分配的页面数目
  • 预留内存计算 (在内核2.6.24源码是使用present,而内核4.14使用managed)
    • protection[dma, normal] = (normal.present) / 256 = 873
    • protection[dma, high] = (normal.present + high.present) / 256 = 1000
    • 也就是说,如果normal内存不够,想在dma中尝试申请page时,此时dma.free > dma.high + protection[dma,normal],所以可以分配;否则不能分配(代码参考:3.5.5节zone_watermark_ok()

【注】内核参数具体介绍可以看这里:www.kernel.org/doc/Documen…

4. 冷热页

  • 热页:页加入到高速缓存;冷页:不在高速缓存中 (在2.6.25版本将冷热页合并为一个)
  • 冷热页分配器(hot-n-cold allocator):根据阈值,控制页什么时候被加载进高速缓存中
struct zone 
    //用于实现每个CPU的热页列表和冷页列表
    struct per_cpu_pageset    pageset[NR_CPUS];
};

struct per_cpu_pageset {
    struct per_cpu_pages pcp[2];    /* 0: hot.  1: cold */
......
} ____cacheline_aligned_in_smp;

struct per_cpu_pages {
    int count;        /* number of pages in the list */
    int high;        /* high watermark, emptying needed */
    int batch;        /* chunk size for buddy add/remove */
    struct list_head list;    /* the list of pages */
};

image-20220922081605642.png

5. 页帧

  • 页帧:内存分配的最小单位,IA-32系统标准页长度为4KB。每个页会创建一个struct page实例
    • 注意实例中结构体尽可能小,利用union
struct page {
        unsigned long flags;    /* 用于描述页的属性,原子标志,有些情况下会异步更新 */
        atomic_t _count;        /* 引用计数,内核中引用该页的次数。其值为0可以删除 */
        union {
            atomic_t _mapcount; /* 页表中有多少项指向该页
                                 * 用于表示页是否已经映射,还用于限制逆向映射搜索。
                                 */
            unsigned int inuse; /* 用于SLUB分配器:对象的数目 */
    };
    union {
        struct {
            unsigned long private; /* 由映射私有,不透明数据:
                                    * 如果设置了PagePrivate,通常用于buffer_heads;
                                    * 如果设置了PageSwapCache,则用于swp_entry_t;
                                    * 如果设置了PG_buddy,则用于表示伙伴系统中的阶。
                                    */
            struct address_space *mapping; /* mapping指定了页帧所在的地址空间。index是页帧映射内部的偏移量
                                            * 如果最低位为0,则指向inode
                                            * address_space,或为NULL。
                                            * 如果页映射为匿名内存,最低位置位,
                                            * 而且该指针指向anon_vma对象:
                                            * 参见下文的PAGE_MAPPING_ANON。
                                            */
        };
        ...
        struct kmem_cache *slab; /* 用于SLUB分配器:指向slab的指针 */
        struct page *first_page; /* 用于复合页的尾页,指向首页 */
    };
    union {
        pgoff_t index; /* 在映射内的偏移量 */
        void *freelist; /* SLUB: freelist req. slab lock */
    };
    struct list_head lru; /* 换出页列表,例如由zone->lru_lock保护的active_list!*/
    
    #if defined(WANT_PAGE_VIRTUAL)
        void *virtual; /* 内核虚拟地址(如果没有映射则为NULL,即高端内存) */
    #endif /* WANT_PAGE_VIRTUAL */
};
  • 重要的页标志page->flags。由一些原子操作设置相应比特位
    • PG_locked:页是否锁定
    • PG_error:该页的IO操作期间发生错误
    • PG_referencedPG_active:控制页的活跃次数,用于页交换
    • PG_uptodate:页的数据已经从块设备读取,期间没有出错
    • PG_dirty:页内容发生改变
    • PG_lru:用于实现页面回收和切换
    • PG_highmem:页在高端内存中,无法持久映射到内核内存中
    • PG_private:page的private成员非空时设置
    • PG_writeback:页内容正在向块设备回写
    • PG_slab:页是slab分配器的一部分
    • PG_swapcache:页处于交换缓存。此时private包含一个类型为swap_entry_t的项
    • PG_reclaim:内核决定回收某个特定的页后,设置PG_reclaim标志通知
    • PG_buddy:页空闲且包含在伙伴系统的列表中
    • PG_compound:页属于一个更大的复合页,复合页由多个毗连的普通页组成

3.3 页表

3.3.1 页表回顾(第一章)

  • :虚拟地址空间和物理内存被内核划分为很多等长部分。页一般专指虚拟地址空间的页
  • 页帧:物理内存页常称作页帧
  • 页表:虚拟地址空间映射到物理地址空间的数据结构。为节省内存通常分解为多级页表

image.png

3.3.2 数据结构和操作

1. 内存地址的分解

image-20220930081345790.png

  • 虚拟内存地址分为5个部分:PGD(全局页目录)、PUD(上层页目录)、PMD(中间页目录)、PTE(页表)、Offset(偏移) 。PGD寻址512个PUD,以此类推。
    • X86 32位(二级页表):PGD=31~22、PTE=21~12、Offset=11~0
    • X86 32位PAE模式(三级页表):PGD=31~30、PMD=29~21、PTE=20~12、Offset=11~0
    • X86 64位(四级页表):PGD=47~39、PUD=38~30、PMD=29~21、PTE=20~12、Offset=11~0

image-20221002071308100.png

2. 页表的格式

  • 以AMD64体系结构为例,根据intel手册,页表项的结构如下:

image-20221002071946912.png

  • 因此,在Linux内核中,页表项定义为一个64 bit的struct
    • 注:定义为struct目的是只允许相关辅助函数处理
typedef struct { unsigned long pte; } pte_t;
typedef struct { unsigned long pmd; } pmd_t;
typedef struct { unsigned long pud; } pud_t;
typedef struct { unsigned long pgd; } pgd_t;
  • 页表项的位信息示例:
#define _PAGE_PRESENT    0x001    //页是否存在于内存中,即是否被交换到磁盘上了
#define _PAGE_RW    0x002        //允许读写或禁止读写
#define _PAGE_USER    0x004        //允许用户空间代码访问该页
#define _PAGE_PWT    0x008        //Page-level write-through
#define _PAGE_PCD    0x010        //Page-level cache disable
#define _PAGE_ACCESSED    0x020    //CPU每次访问页时,会设置该位
#define _PAGE_DIRTY    0x040        //页的内容是否被修改过
#define _PAGE_FILE    0x040         //页表项属于非线性映射
#define _PAGE_PSE    0x080         /* 2MB page */
#define _PAGE_PROTNONE    0x080     /* If not present */
#define _PAGE_GLOBAL    0x100     /* Global TLB entry */
  • pte_t为例,内核定义了一系列辅助函数操作页表项,用于比特位的置位:
//地址对齐到页边界(地址舍入到下一页的起始处,一定是页大小的倍数)
#define PAGE_ALIGN(addr)    (((addr)+PAGE_SIZE-1) & PAGE_MASK)

//读取页表项
#define pte_val(x)    ((x).pte)
//设置页表项
#define __pte(x) ((pte_t) { (x) } )
//从内存指针和页表项获得下一级页表的地址
#define pte_index(address) (((address) >> PAGE_SHIFT) & (PTRS_PER_PTE - 1))
//删除页表项,通常置0
#define pte_clear(mm,addr,xp)    do { set_pte_at(mm, addr, xp, __pte(0)); } while (0)
//检查的页是否在内存中
#define pte_present(x)    (pte_val(x) & (_PAGE_PRESENT | _PAGE_PROTNONE))

static inline void set_pte(pte_t *dst, pte_t val) {
    pte_val(*dst) = pte_val(val);
}
//检查的页是否设置了DIRTY、ACCESSED、RW位
static inline int pte_dirty(pte_t pte)        { return pte_val(pte) & _PAGE_DIRTY; }
static inline int pte_young(pte_t pte)        { return pte_val(pte) & _PAGE_ACCESSED; }
static inline int pte_write(pte_t pte)        { return pte_val(pte) & _PAGE_RW; }
//为页设置DIRTY、ACCESSED、RW位
static inline pte_t pte_mkdirty(pte_t pte)    { set_pte(&pte, __pte(pte_val(pte) | _PAGE_DIRTY)); return pte; }
static inline pte_t pte_mkyoung(pte_t pte)    { set_pte(&pte, __pte(pte_val(pte) | _PAGE_ACCESSED)); return pte; }
static inline pte_t pte_mkwrite(pte_t pte)    { set_pte(&pte, __pte(pte_val(pte) | _PAGE_RW)); return pte; }
  • 物理地址和虚拟地址的转换
    • 内核源码中定义了一系列的转换函数
    • 命名规范:page(页)、pfn(页帧)、virt(虚拟地址)、phys(物理地址)
    • 转换规则
      • virt=physPHYS_OFFSET+PAGE_OFFSETvirt = phys - PHYS\_OFFSET + PAGE\_OFFSET
      • pfn=phys/4096pfn = phys / 4096
      • page=mem_map+(pfnPHYS_PFN_OFFSET)page = mem\_map + (pfn - PHYS\_PFN\_OFFSET)
//内核空间(非高端内存域)的虚拟地址到物理地址的线性映射。例如0xC0100000映射到0x00100000物理地址
#define __pa(x)            ((unsigned long)(x)-PAGE_OFFSET)
#define __va(x)            ((void *)((unsigned long)(x)+PAGE_OFFSET))

//物理地址和虚拟地址互转
#define __virt_to_phys(x) (((phys_addr_t)(x) - PAGE_OFFSET + PHYS_OFFSET))
#define __phys_to_virt(x) ((unsignedlong)((x) - PHYS_OFFSET + PAGE_OFFSET))

//页帧到物理页的映射。mem_map是page结构体数组基址,加上物理页帧距离起始页帧的偏移即当前对应的虚拟页地址
#define pfn_to_page(pfn) (mem_map + ((pfn) - PHYS_PFN_OFFSET))
#define page_to_pfn(page) ((unsigned long)((page) - mem_map) + PHYS_PFN_OFFSET)

//内核虚拟地址和页帧互转
#define virt_to_pfn(kaddr)    (__pa(kaddr) >> PAGE_SHIFT)
#define pfn_to_virt(pfn)    __va((pfn) << PAGE_SHIFT)

//内核虚拟地址和页互转
#define virt_to_page(kaddr)    pfn_to_page(__pa(kaddr) >> PAGE_SHIFT)

//页帧、物理地址互转
#define PFN_ALIGN(x)    (((unsigned long)(x) + (PAGE_SIZE - 1)) & PAGE_MASK)
#define PFN_UP(x)    (((x) + PAGE_SIZE-1) >> PAGE_SHIFT)
#define PFN_DOWN(x)    ((x) >> PAGE_SHIFT)
#define PFN_PHYS(x)    ((x) << PAGE_SHIFT)