Linux 内核开发之内存管理

本文详细介绍了Linux内核的内存管理,包括地址类型(如用户虚拟地址、物理地址、内核逻辑地址、内核虚拟地址)、内存管理单元MMU、区的划分、页的获取与释放、gfp_mask的使用、kmalloc和vmalloc的异同、slab缓存机制以及高端内存的映射等核心概念和操作。通过对这些内容的探讨,读者可以深入理解Linux内核如何高效管理内存。

一. 地址类型

    1. 用户虚拟地址(Virtual Memory Address)

    用户空间程序所能看到的常规地址,32位或者64位。每个进程都有自己的虚拟地址空间,空间分为若干区域/段,/proc/pid/maps 可查看,通过 VMA 管理。

    进程的内存映射至少包含如下区域:

    a. text 段,即程序的可执行代码段;

    b. data 段,包含初始化数据、非初始化数据和堆栈

    c. 与每个活动的内存映射对应的区域,即 mmap 的区域

    2. 物理地址

    在处理器和系统内存之间使用,也是 32 位或者 64 位的,以页为单位进行管理。

    3. 内核逻辑地址

    内核逻辑地址组成了内核的常规地址空间,内核使用逻辑地址来引用物理内存中的页。该地址映射了部分或者全部的内存,并经常被视为物理地址。在大多数体系结构上,逻辑地址和与其关联的物理地址的区别,仅仅是它们之间存在一个固定的偏移量。逻辑地址使用硬件内建的指针大小,因此在安装了大量内存的 32 位系统中,它无法寻址全部的物理内存。逻辑地址通常保存在 unsigned long 或者 void * 这样类型的变量中。kmalloc 返回的内存就是内核逻辑地址。

    我的理解,就像用户空间无法直接访问物理内存,内核也一样,需经过一层映射,换成官方说法:内核无法直接操作没有映射到内核地址空间的内存。不同的是,用户空间的映射是非线性的,而内核空间的逻辑地址则是线性的。所以,kmalloc 返回的不是直接的物理地址,而是内核逻辑地址。

    内核逻辑地址与物理地址的相互转换:

    __pa():返回某个逻辑地址对应的物理地址    physics address

    __va():将物理地址逆向映射到逻辑地址,但只对低端内存有效    virtual address,其实应该是 logic address

        #define __pa(x) ((unsigned long)(x) & 0x7fffffff)

        #define __va(x) ((void *)((unsigned long)(x) | 0x80000000))

    从这个定义来看,内核逻辑地址在物理地址的基础上增加了一个偏移,所以,内核逻辑地址的低位变成了保留位,难道是给 DMA 使用的?

    4. 内核虚拟地址

    与前者的相同之处在于,都将内核空间的地址映射到物理地址上。区别是,内核虚拟地址与物理地址的映射不必是线性的和一对一的,而是类似于用户虚拟地址的映射方式。所有的逻辑地址都是内核虚拟地址,但是许多内核虚拟地址不是逻辑地址。例如 vmalloc 分配的内存都是虚拟地址,kmap 也返回一个虚拟地址。虚拟地址通常保存在指针变量中。


    


二. 内存管理单元(MMU: Memory Mamagement Unit)

    以 物理页 作为内存管理的基本单元。页的大小随体系结构而不同,大多数 32位 的体系结构支持 4KB 的页,而 64位 的体系结构一般会支持 8KB 的页。常量 PAGE_SIZE 给出了在任何指定体系结构下的页大小。

    仔细观察内存地址,无论是虚拟的还是物理的,他们都被分为页号和一个页内的偏移量(for example?)。例如,如果使用每页 4096 字节,那么最后的 12 位是偏移量,而剩余的高位则指定了页号。如果忽略了地址偏移量,并将除去偏移量的剩余位移到右端(即右移 12 位),该结果称为页帧数。移动位以在页帧数和地址间进行转换是一个常用操作,宏 PAGE_SHIFT 将告诉程序员,必须移动多少位才能完成转换。

    struct page 结构表示系统中的物理页,每一个物理页都会分配一个该结构。该结构包含以下一些重要的成员:

        atomic_t count;    对该页的访问计数,当为 0 时,该页将返回给空闲链表

        void *virtual;         如果页面被映射,则指向页的内核虚拟地址,如果未被映射则为 NULL。低端内存页总是被映射,而高端内存通常不被映射。并不是在所有的体系结构中都有该成员,只有在页的内核虚拟地址不容易被计算时,它才被编译,如果要访问该成员,正确的方式是使用 page_address 宏。

        unsigned long flags;    描述页状态。其中,PG_locked 表示内存中的页已经被锁住,而 PG_reserved 表示禁止内存管理系统访问该页。

    内核维护了一个或者多个 page 结构数组,用来跟踪系统中的物理内存。在一些系统中,有一个单独的数组称之为 mem_map。在另外一些系统中,情况将会复杂很多。非一致性内存访问(NUMA)系统和有大量不连续物理内存的系统会有多个内存映射数组,因此从可移植性考虑,代码不要直接访问那些数组。


三. 区

    由于硬件的限制,内核并不能对所有的物理页一视同仁,例如:

    1. 一些硬件只能用特定的内存地址来执行 DMA,对于这些设备,在分配 DMA 缓冲区时,应该使用 GFP_DMA 标志调用 kmalloc 或 __get_free_pages 从 DMA 区间分配内存

    2. 一些体系结构上,其内存的物理寻址范围比虚拟寻址范围大得多,这样,就有一些内存不能永久的映射到内核空间上。

    所以内核把页划分为不同的区。

        ZONE_DMA

        ZONE_NORMAL      存在于内核空间上的逻辑地址内存

        ZONE_HIGHMEM    不存在逻辑地址的内存

    在 i386 系统中,虽然在内核配置的时候能够改变低端内存和高端内存的界限,但是通常将该界限设置为小于1 GB。

    

四. 获得与释放页

        struct page *alloc_pages(unsigned int gfp_mask, unsigned int order)

    分配 2 的 order 次方个连续的物理页,返回 page 指针。

        void *page_address(struct page *page)

    把给定的页转换成逻辑地址(前面提到是虚拟地址,但因为所有的逻辑地址都是虚拟地址,所以前述说法没错,但真实情况是逻辑地址。又因为高端内存不存在逻辑地址,所以,不建议用该函数作转换)。

    需要注意的是,对于高端内存来说,只有当内存页被映射后该地址才存在。因此,推荐使用后面提到的 kmap 而非该函数,因为 kmap 返回的是虚拟地址。

        unsigned long __get_free_pages(unsigned int gfp_mask, unsigned int order)

    该函数与 alloc_pages 作用相同,区别是返回连续的逻辑地址

    如果要分配 16KB 的空间,计算 order 参数是个问题,所幸 Linux 提供了 get_order 函数用于转换:

        #include <asm/page.h>

        int order = get_order(16*1024);

        buf = __get_free_pages(GFP_KERNEL, order);

    以上函数都是返回多个页,如果只需要用到一页,可以:

        struct page *alloc_page(unsigned int gfp_mask)

        unsigned long __get_free_page(unsigned int gfp_mask)

    如果需要在申请的时候初始化,可以:

        unsigned long get_zeroed_page(unsigned int gfp_mask)

    该函数与 _get_free_page 作用相同,只是分配好的页都填充成了 0。

    释放页:

        void __free_pages(struct page *page, unsigned int order)

        void free_pages(unsigned long addr, unsigned int order)

        void free_page(unsigned long addr)

    此外还有:

        struct page *virt_to_page(void *kaddr)

    该宏负责将内核逻辑地址转换为相应的 page 结构指针。由于它需要一个逻辑地址,因此它不能操作 vmalloc 生成的地址以及高端内存。

        struct page *pfn_to_page(int pfn),实现为:

        #define pfn_to_page(pfn) (mem_map + ((pfn) - PHYS_PFN_OFFSET))

    pfn: page frame number,页帧号。针对给定的页帧号,返回 page 结构指针。如果需要的话,在将页帧号传递给 pfn_to_page 前,使用 pfn_valid 检查页帧号的合法性。

        int pfn page_to_pfn(struct page *page),实现为:

        #define page_to_pfn(page) ((unsigned long)((page) - mem_map) + PHYS_PFN_OFFSET)


五. gfp_mask

    GFP: get free pages,即使是 kmalloc 和 vmalloc,最终都是通过调用 __get_free_pages 来实现实际的分配,这就是 GFP_ 前缀的由来

    可以分为三类:行为修饰符、区修饰符及类型标志。

    行为修饰符表示内核应当如何分配所需的内存:

        __GFP_WAIT

        __GFP_HIGH

        __GFP_IO

        __GFP_FS

        __GFP_COLD

        __GFP_NOWARN

        __GFP_REPEAT

        __GFP_NOFAIL

        __GFP_NORETRY

        __GFP_NO_GROW

        __GFP_COMP

    区修饰符:

        __GFP_DMA

        __GFP_HIGHMEM

    类型标志:

        GFP_ATOMIC            高优先级,不会睡眠,用于中断处理程序、下半部、持有自旋锁及其它不能睡眠的地方

        GFP_NOIO                  可能阻塞,但不会启动磁盘 IO

        GFP_NOFS                 可能阻塞,也可能启动磁盘 IO,但不会启动文件系统操作

        GFP_KERNEL            常规的分配方式,可能会阻塞

        GFP_USER

        GFP_HIGHUSER

        GFP_DMA


六. kmalloc & vmalloc

    kmalloc 可以获取以字节为单位的一块内核内存:

        void *kmalloc(size_t size, int flags)

    其分配的内存在物理上连续,在逻辑上自然也连续。

    释放用 kfree:

        void kfree(const void *ptr)

    vmalloc 的工作方式类似于 kmalloc,但是其分配的内存虚拟地址连续,而物理地址则无需连续,这也是用户空间分配函数的工作方式。

        void *vmalloc(unsigned long size)

    释放用 vfree。

        void free(void *addr)

    大多数情况下,只有硬件设备需要得到物理地址连续的内存。在很多体系结构上,硬件设备存在于内存管理单元之外,它根本不理解什么是虚拟地址。因此,硬件设备用到的任何内存区都必须是物理上连续的块。而仅供软件使用的内存块就可以使用只有虚拟地址连续的内存块。

    尽管仅仅在某些情况下才需要物理上连续的内存块,但是,很多内核代码的都用 kmalloc() 来获取内存,而不是 vmalloc(),这主要是处于性能的考虑。vmalloc() 函数为了把物理上不连续的页转换为虚拟地址空间上连续的页,必须专门建立页表项。糟糕的是,通过 vmalloc() 获得的页必须一个一个的进行映射,这就会导致比直接内存映射大得多的 TLB(Translation Lookaside Buffer,一种硬件缓冲区,很多体系结构用它来缓存虚拟地址到物理地址的映射关系) 抖动。因为这些原因,vmalloc() 仅在不得已时才会使用。

    kmalloc 和 vmalloc 都不能分配高端内存

    需要记住的是,kmalloc 能处理的最小的内存块是 32 或者 64,到底是哪个则取决于当前体系结构使用的页面大小,所以,即使用 kmalloc 分配 1 个字节,实际分配的内存大小肯定不会是 1 字节。而对于 kmalloc 分配的内存块大小,同样存在一个上限。这个限制随着体系结构的不同以及内核配置选项的不同而变化。如果希望代码具有完整的可移植性,则不应该分配超过 128KB 的内存。如果希望得到大于几千字节的内存,最好使用除 kmalloc 之外的内存分配方法。


七. slab 层

    为了便于数据的频繁分配和回收,常常会用到空闲链表。空闲链表相当于对象的高速缓存,以便快速存储频繁使用的对象类型。

    在内核中,空闲链表面临的主要问题之一是不能全局控制。当可用内存变得紧缺时,内核无法通知每个空闲链表,让其收缩缓存的大小以便释放出一些内存来。实际上,内核根本就不知道存在任何空闲链表。为了弥补这一缺陷,Linux 内核提供了 slab 层,也就是所谓的 slab 分配器。

    slab 分配器试图在几个基本原则之间寻求一种平衡:

    频繁使用的数据结构也会频繁分配和释放,因此应当缓存它们

    频繁分配和回收必然会导致内存碎片,难以找到大块连续的内存,为了避免这种情况,空闲链表的缓存会连续的存放,因为已释放的数据结构又会放回空闲链表,因此不会导致碎片

    回收的对象可以立即投入下一次分配,因此,对于频繁的分配和释放,空闲链表能够提高其性能

    slab 层把不同的对象划分为所谓 高速缓存组,其中每个高速缓存都存放不同类型的对象,每种对象类型对应一个高速缓存。例如,一个高速缓存用于存放进程描述符,而另一个高速缓存用于存放文件索引节点对象。

    然后,这些高速缓存又被划分为 slab。slab 由一个或多个物理上连续的页组成。每个高速缓存可以由多个 slab 组成。

    每个 slab 都包含一些对象成员,这里的对象是指被缓存的数据结构。

    高速缓存用 kmem_cache_t 结构表示。一个新的高速缓存通过以下函数创建:

        kmem_cache_t *kmem_cache_create(const char *name, size_t size, size_t align, unsigned long flags, void (* ctor)(void *, kmem_cache_t *, unsigned long), void (* dtor)(void *, kmem_cache_t *, unsigned long))

    第一个参数是字符串,存放高速缓存的名字

    第二个参数是高速缓存中每个元素的大小

    第三个参数是高速缓存中第一个对象的偏移,最常用的是 0

    第四个参数用于控制高速缓存的行为,包括:

        SLAB_NO_REAP

        SLAB_HWCACHE_ALIGN

        SLAB_CACHE_DMA

    等等

    最后两个参数 ctor 和 dtor 分别是高速缓存的构造和析构函数,只有在新的页追加到高速缓存时,构造函数才被调用,只有从高速缓存中删去页时,析构函数才被调用。我们不能认为分配一个对象后随之就会调用一次 ctor,类似的,dtor 函数也不是在一个对象释放之后就随之调用一次。实际上,Linux 内核的高速缓存不使用构造和析构函数,可以将这两个参数赋值为 NULL。

    释放高速缓存用:

        int kmem_cache_destroy(kmem_cache_t *cachep)

    从高速缓存中获取对象:

        void *kmem_cache_alloc(kmem_cache_t *cachep, int flags)

    释放一个对象,将其返回给原先的 slab:

        void kmem_cache_free(kmem_cache_t *cachep, void *objp)


八. 在栈上分配内存

    内核栈大小固定,一般为 1 页或者 2 页,逐渐倾向于前者。在任何情况下,无限制的递归和 alloca() 是不允许的。

    中断栈:为每个进程提供一个用于中断处理程序的栈,这样一来,中断处理程序不用再和被中断进程共享一个内核栈。

    内核没有在管理内核栈上做足工作,因此,当栈溢出时,势必引发严重的问题。所以,动态分配是一种明智的选择。


九. 高端内存的映射

    根据定义,在高端内存中的页不能永久地映射到内核地址空间上,因此,通过 alloc_pages() 函数以 __GFP_HIGHMEM 获得的页不可能有逻辑地址

    1. 永久映射

    要映射一个给定的 page 结构到内核地址空间,可以使用:

        void *kmap(struct page *page)

    这个函数在高端内存或者低端内存上都能用。如果 page 结构对应的是低端内存中的一页,函数只会单纯地返回该页的逻辑地址(也是虚拟地址)。如果页位于高端内存,则会建立一个永久映射,再返回地址。这个函数可以睡眠,因此 kmap() 只能用在进程上下文中。此外,kmap 调用维护了一个计数器,因此如果两个或者多个函数对同一页调用 kmap,操作也是正常的。

    因为允许永久映射的数量是有限的,当不再需要高端内存时,应该解除映射,可以:

        void kunmap(struct page *page)

    2. 临时映射

    当必须创建一个映射而当前的上下文又不能睡眠时,内核提供了临时映射,也即所谓的原子映射。有一组保留的映射,它们可以存放新创建的临时映射。内核可以原子地把高端内存中的一个页映射到保留的映射中。因此,临时映射可以用在不能睡眠的地方,比如中断处理程序中。

        void kmap_atomic(struct page *page, enum km_type type)

    取消映射:

        void kunmap_atomic(void *kvaddr, enum km_type type)


十. mmap

    1. 虚拟内存区

    VMA 是用于管理进程地址空间中不同区域的内核数据结构。一个 VMA 表示在进程的虚拟内存中的一个同类区域:拥有相同权限标志位和被同样对象备份的一个连续的虚拟内存地址范围。进程的内存映射至少包含下面这些区域:

        a. 程序的可执行代码区域,通常称为 text;

        b. 多个数据区,其中包含初始化数据、非初始化数据(BSS)以及程序堆栈

        c. 与每个活动的内存映射对应的区域

    查看 /proc/pid/maps 文件就能了解进程的内存区域。每行的表示形式如下:

        start-end perm offset major:minor inode image

    其中,start 和 end 表示该内存区域的起始处和结束处的虚拟地址。offset 表示内存区域在映射文件中的起始位置。inode 表示被映射的文件的索引节点号。

    加入你的理解:可执行程序分为数据段、代码段,这是早就知道的。但是更深入一步,不止这些。且这些所谓的段,是通过 VMA 管理的。

    2. vm_area_struct 结构

    当用户空间进程调用 mmap,将设备内存映射到它的地址空间时,系统通过创建一个表示该映射的新 VMA 作为响应。支持 mmap 的驱动程序(当然要实现 mmap 方法)需要帮助进程完成 VMA 的初始化。

    3. 内存映射处理

    在系统中的每个进程,都拥有一个 struct mm_struct 结构,其中包含了虚拟内存区域链表、页表以及其它大量内存管理信息,还包含一个信号灯(mmap_sem)和一个自旋锁(page_table_lock)。在 task 结构中能够找到该结构的指针;在少数情况下当驱动程序需要访问它时,常用的办法是使用 current->mm。请注意,多个进程可以共享内存管理结构,Linux 就是用这种方法实现线程的。

    4. mmap

    映射一个设备意味着将用户空间的一段内存与设备内存关联起来,无论何时当程序在分配的地址范围内读写时,实际上访问的就是设备。对于驱动程序而言,内存映射可以提供给用户程序直接访问设备内存的能力。

    并不是所有的设备都能进行 mmap 抽象,比如像串口和其它面向流的设备就不能。此外,另一个限制是,必须以 PAGE_SIZE 为单位进行映射。

    3. remap_pfn_range


十一. CPU 接口



评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值