RainSec 10月23日 23:40
Linux内核对象分配释放机制研究
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

本文深入探讨了Linux内核对象的创建、分配与释放机制,重点介绍了SLUB分配器的工作原理。文章详细阐述了自旋锁和读写自旋锁在内核并发控制中的应用,以及GFP标志符在内存分配策略中的作用。通过分析SLUB分配器的布局、slab cache与内核对象的关系,以及per-cpu和per-node slab list的管理,揭示了内核对象分配的复杂流程。同时,文章也对内核对象释放过程中的不同处理方式进行了说明,特别是在开启KASAN等调试配置下的行为差异,旨在帮助开发者更好地理解内核内存管理和进行调试分析。

🔑 **内核并发控制与内存分配基础**: 文章首先介绍了Linux内核中用于保护共享资源的自旋锁和读写自旋锁,强调了它们在多线程环境下的重要性,并解释了不同类型自旋锁的使用场景和潜在死锁风险。接着,文章阐述了Linux内核提供的多种内存分配API(如kmalloc, vmalloc, alloc_pages)及其GFP标志符(GFP_KERNEL, GFP_NOWAIT, GFP_ATOMIC, GFP_USER)的含义与使用策略,为理解内核内存分配奠定了基础。

📦 **SLUB分配器核心机制**: SLUB作为Linux内核主流的内存分配器,其工作原理被详细解析。文章介绍了SLUB的内存布局,包括RED zone、Object content、READ zone、Metadata和Padding等区域的作用,并重点阐述了slab cache、slab对象以及kmem_cache结构体之间的层级关系。特别是CPU相关的kmem_cache_cpu结构体和内存节点相关的kmem_cache_node结构体,以及free object的管理方式(如free pointer FP)都得到了清晰的说明。

🔄 **内核对象分配与释放流程**: 文章详细描述了内核对象从分配到释放的完整生命周期。分配流程包括从per-cpu active slab、per-cpu partial slab list、per-node partial slab list等不同层级获取可用对象的策略,以及在不同情况下的性能考量和锁机制的介入。释放流程则解释了对象如何被重新插入到freelist,以及根据内核配置(如KASAN)和对象状态(full, partial, empty)对slab状态和链表进行相应更新的操作,揭示了内存管理的高效与复杂性。

原创 时钟 2024-04-19 10:07 北京

内核对象分配释放机制和调试方法的研究

Linux kernel memory Debug(一)初识内核对象的创建和释放

最近因为一些事情研究复现了几个Linux kernel的洞,感觉自己对内核的对象分配这块的了解并不深入,于是打算出一个系列对于内核对象分配释放机制和调试方法等进行研究,基本属于跟着大佬们学习,多多指教。

前置知识

自旋锁- spin locks

static DEFINE_SPINLOCK(xxx_lock);
      unsigned long flags;
      spin_lock_irqsave(&xxx_lock, flags);
      ... critical section here ..
      spin_unlock_irqrestore(&xxx_lock, flags);

  在内核中,上面的代码片段使用了最基本的自旋锁,它会禁用本地中断,而且它是全局锁,因此会保证被锁保护的区域只存在一个线程。自旋锁在无论在单核CPU或者多核CPU下都可以很好的工作。除了上述类型的使用方式,内核中还存在对于共享内存等的读写自旋锁,它允许多个内存读操作同时进行,但是如果是写操作那么就必须进行内存锁定。

rwlock_t xxx_lock = __RW_LOCK_UNLOCKED(xxx_lock);
     unsigned long flags;
     read_lock_irqsave(&xxx_lock, flags);
     .. critical section that only reads the info ...
     read_unlock_irqrestore(&xxx_lock, flags);
     write_lock_irqsave(&xxx_lock, flags);
     .. read and write exclusive access to the info ...
     write_unlock_irqrestore(&xxx_lock, flags);

  对于一些复杂数据结构链表的访问就可能用到上述操作。

  虽然自旋锁非常安全,但是因为它需要关闭中断操作因此它的性能开销是非常巨大的,不过也存在更轻量化版本的spin lock:

spin_lock(&lock);
...
spin_unlock(&lock);

这种类型的spin lock同样保证对于保护区域的单独访问,不过这种一般用于没用中断操作调用的情况,比如被保护区域只被进程上下文操作,一旦出现下面的情况:

spin_lock(&lock);
...
        <- interrupt comes in:
                spin_lock(&lock);

就会造成死锁,这种情况下一个中断处理程序想要访问一个被保护的数据区域就会造成死锁。如果是多个CPU,中断发生在另一个CPU,这样是可以的。这种情况不能发生在相同的CPU上,因为数据已经被锁定。CPU运行的程序为中断处理程序,因此数据无法被访问,锁也无法被释放。

内核堆分配策略

  Linux 提供了多种用于内存分配的 API。你可以使用 kmalloc 或 kmem_cache_alloc 等系列函数分配小块内存,使用 vmalloc 及其衍生函数分配大的虚拟连续内存区域,或者直接从页面分配器请求页面使用 alloc_pages。还可以使用更专门的分配器,例如 cma_alloc 或 zs_malloc。同时Linux kernel提供GFP标志符来控制内核堆分配行为,如下:

kzalloc(<size>, GFP_KERNEL);

下面简单列举内核中GFP标识的使用,这有助于理解Linux kenrel堆的使用策略。

#include <linux/slab.h>
voidmy_custom_allocation(size_t size) {
    void* ptr = kmalloc(size, GFP_KERNEL | GFP_NOWARN);
    if (!ptr)
        pr_info("Memory allocation failed, but no warning is printed.\n");
    else
        pr_info("Memory allocation succeeded.\n");
    return ptr;
}
void my_custom_free(void* ptr) {
    kfree(ptr);
}
// 示例用法
int my_module_init(void) {
    void* allocated_memory = my_custom_allocation(1024);
    // 使用分配的内存...
    my_custom_free(allocated_memory);
    return 0;
}

  这些是开发者在开发内核模块等时需要注意的标识,同样对我们理解内核代码进行漏洞挖掘也有一定的帮助,在整体范围上我们还需要知道,对于Linux kernel 内存分配来讲主要存在三个不同的分配器,当然也有其它分配器,不过这三个较为主流:

    1. Page allocator

    2. Vmalloc allocator

    3. Slab allocator

其中page allocator很好理解,用来进行物理页的分配,大小一般为4kb,vmalloc allocator则主要是对虚拟内存连续块进行分配管理,但是因为在内核里面很多分配请求其实所需的内存大小都不超过4kb,因此简单的使用page allocator会导致资源浪费,因此就有了slab allocator,它通过对不同类型的内核对象进行缓存来增加空间利用率,但是针对不同的环境和系统也存在不同的slab实现,因此Linux kernel一般存在三种不同的slab,分别命名为SLABSLUBSLOB,其中SLUB是最广泛使用的版本。

SLUB分配器

  SLUB分配器作为对象分配器,其分配大小跟内核对象大小有直接的关联,下面是它的简单布局:

  其中Object content是内核对象,其它部分根据是否开启内核的各种配置来决定其是否出现,比如Kasan区域和内核Kasan配置是否开启紧密相关。SLUB存在很多slab cache,在内核里面一般由kmem_cache对象来表示,kmem_cache对象里面包含了用来管理slab cache的所有信息,其中有一个成员为cpu_slab,该成员作为一个指针指向kmem_cache_cpu结构体,kmem_cache_cpu结构体中的内容为和CPU相关的slab cache信息,对于每一个slab都有一个slab object表示,但是如果内核版本低于5.17的话,如果一个页处于一个slab中,在页对象中存在一个匿名的union来保存slab的信息,kmem_cache中的kmem_cache_node成员则表示可以用于slab分配的内存节点。同时对于free object的管理,内核和用户态glibc也存在类似的地方,在对象中存在free pointer (FP),该指针一般位于内核对象的起始位置,不过具体位置根据内核版本和不同对象之间存在差别。不过从上图可以看出有些时候内核对象只是分配出来的堆块的一部分,简单解释一下不同区域的作用。

接下来聊一聊slab cache和内核对象之间的关系。slab cache中包含一个或多个slab,同时每个slab都包含一个或者多个物理页,这些页中包含指定大小的内核对象。内核对象和slab都是以链表的形式组织起来。

同时对于slab cache而言,它存在一个per-cpu active slab,一个per-cpu partial slab list和一个per-node partial slab list,在per-cpu partial slab中的slab通过slab.next成员进行连接,在per-node partial slab lists中的slab则通过slab.slab_list成员进行连接,这一块最好还是通过源码了解一下,不然可能有点乱。至于per-cpu active slab很容易理解就是现在被用于分配内核对象的slab,当active slab的内核对象被分配完成的时候另一个slab开始分配内核对象,同时该slab转变为active slab,同时在每一个slab cache中都包含一个per-cpu freelist,freelist中包含很多active slab中的内核对象,同时在slab结构体中也存在一个freelist,因此active slab中的内核对象可能随时处于两个列表中的一个,两个列表的区别在于per-cpu freelist是一个无锁列表,从无锁列表中分配内核对象,或者释放内核对象到无锁列表都不需要进行任何的中断和抢占操作,但是不是所有的slab和内核对象操作都可以通过无锁方式来实现,比如操作slab中的freelist,或者操作slabs list。slab的freelist可以参考下图:

关于SLUB allocator中的锁一般有以下几种:

通过上面的信息我们可以意识到,一个slab存在三种不同的状态:

    1. empty

    2. full

    3. partial

对于empty slab来说,其可以被回收,底层的page则会被返回给page allocator,对于任何partial list上面的slab要么是部分空的要么是全空的,full slab不需要特别关注,因为假如full slab上面的一个内核对象被释放了,那么可以通过该内核对象找到该slab并将该slab放在合适的slab链表上。

同时slab依赖的page数量也是不固定的,可以是一个page也可以是多个page,page的数量取决于kmem_cache.oo成员(struct kmem_cache_order_objects oo;)该成员表示一个slab存在的内核对象数量。对于包含多个page的slab,一个融合页(compound page,which group of 2 or more physically contiguous pages)会被分配给它,在5.17之前版本的内核page结构体中存在slab_cache和freelist成员,融合页被slab_cache管理,并且在融合页中只有head page的freelist成员是有用的,对于尾部的页来说slab_cache和freelist成员不被用来识别slab_cache或者slab中的第一个free object,从5.17内核版本开始,slab_cache和freelist或者其它slab相关的成员被移动到单独的slab结构体中。不过无论slab存在于slab内核对象还是page内存对象,其freelist成员都指向该slab上第一个free的内核对象。

如何分配一个内核对象

  对此基本上对slab有了一个大概的了解,那么一个内核对象到底如何从一个slab中被分配出来呢?首先,内核对象一定是从active slab上分配的,并且该过程不需要使用锁或者关闭中断,在freelist(kmem_cache.cpu_slab->freelist)上面的第一个内核对象会被分配出去,然后跟它在链表上处于相邻位置的内核对象会成为该链表的第一个内核对象,假如无锁链表(kmem_cache.cpu_slab->freelist)中的内核对象被分配完,那么该链表会变成一个空链表NULL,此时如果当前CPU架构不支持cmpxchg指令或者此时slub_debug功能开启,这种无锁的快速分配方式会被禁用,此时存在一些其它的性能较差的分配方式但是不同的方式存在不同的性能消耗,下面对这些分配方式进行介绍。

  假如per-cpu freelist,也就是无锁freelist不包含free的内核对象,但是active slab中的freelist存在free的内核对象,那么就会从slab的freelist上面分配一个内核对象给请求者,同时该freelist的其它free内核对象会被传递给无锁freelist,然后slab的freelist会被置NULL,这个传递的过程会关闭中断并请求kmem_cache_cpu.lock锁,因此它有一定的性能开销,但是这已经算是除了从无锁freelist上直接分配的方式外最快的方式了。不过这种情况下必须关闭内核的抢占配置CONFIG_PREEMPT_RT,因为kmem_cache_cpu.lock是一个spin lock并且保留启用抢占,显示的禁用抢占可以提升稳定性。在前文中提到per-cpu freelist和active slab freelist可以指向同一个free的内核对象,因为当一个slab转变为active slab的时候它的slab freelist中的内核对象会被转发到per-cpu freelist,然后slab freelist就会变为NULL,此时内核对象的分配会从per-cpu freelist上进行,最终这些内核对象还是会被free掉,假如这些内核对象在free的时候是被分配时使用的CPU free掉,那么就会进入per-cpu freelist,那么如果是被其它CPU free掉,那么这些内核对象就会进入到slab freelist,因此有时会出现slab freelist存在内核对象但是per-cpu freelist为空的情况。

  还有一种情况是active slab中不存在可用的内核对象,但是per-cpu partial slab list存在一些slab,它们有可用的内核对象,这种情况下,per-cpu partial slab list中的第一个slab会成为active slab,它的freelist被转移到per-cpu freelist,然后给需求方提供内核对象,这个过程依然需要关闭中断/抢占并请求kmem_cache_cpu.lock,同时相比于上一种方式还存在额外的性能开销:将per-cpu partial slab list的第二个slab(假如有的话)放在链表的头部,以及对active slab的切换处理。

  那么假如per-cpu slab无论是active还是partial都不存在可用的内核对象,那么就会尝试从per-node partial slab list中分配slab,首先我们要了解slab是如何添加到per-node partial slab list链表中的。当一个full slab变成empty slab或者partial slab的时候,该slab就会被放到per-cpu partial slab list,那么假如per-cpu partial slab list功能不被支持或者它已经存放了最大数量的内核对象,那么该slab 就会被放到per-node partial slab list。因此在per-cpu slab无论是active还是partial都不存在可用的内核对象的时候,slub分配器会从local node partial slab list获取slab,但是如果如果在该节点上无法找到合适的slab,之后就会从per-node partial slab list上面的其它节点中获取slab,对一个node partial slab list进行遍历需要请求kmem_cache_node.list_lock,并且因为这是一个central lock,因此这种分配方式存在比上一种更高的性能消耗。在遍历中找到合适的slab并将其freelist的第一个内核对象分配出去,紧接着slab中剩下的内核对象会成为per-cpu active slab的一部分。在开启per-cpu partial slab list的情况下,slub分配器会继续操作,将per-node partial slab list中的slab移动到per-cpu partial slab list直到per-node partial slab list为空或者per-cpu partial slab list中的slab数量已经达到最大值,该最大值依赖于内核对象的大小,假如内核对象大小>=PAGE_SIZE,这个数量为6,对于内核对象大小小于256,这个数量为120。

  假如出现所有的slab都变成full slab,对于新的slab的分配则需要使用page allocator,这个新分配的slab会成为CPU当前的active slab,这种方式是性能消耗最严重的因为它需要从buddy allocator中获取新的物理页。

如何释放一个内核对象

  通过对内核对象分配的了解可以知道,要释放的内核对象的主要来源为:

在释放内核对象的过程中,内核触发的操作也和内核的配置紧密相关,假如内核开启了GENERIC KASAN,那么被释放的内核对象并不会转变为可用的状态而是放在了一个隔离列表里面,这样的话,其实slab的状态并没有因为释放内核对象而发生明显的改变,比如,一个full slab在释放一个内核对象之后并不会转变成一个partial slab,同时假如只存在一个内核对象的partial slab在释放的时候该slab也不会转变为empty slab,需要做的只是更新内核对象对应的shadow bytes,并将其放入隔离列表里面,只有当隔离列表里面的内核对象被处理的时候才会考虑slab状态改变的影响。

  那么假如不开启GENERIC KASAN内核配置的话,一个内核对象被释放的时候会被插入到freelist的头部,对于per-cpu active slab而言存在两个freelist,一个是kmem_cache_cpu->freelist无锁freelist,一个是slab结构体中的常规freelist,因此假如被释放的内核对象是属于active slab,并且释放该内核对象的CPU也属于该active slab,那么该内核对象就会被放入无锁freelist,那么假如被释放的内核对象属于active slab,但是释放该内核对象的CPU不属于该active slab,那么该内核对象就会被放入slab对应的常规freelist,这些操作在CPU支持cmpxchng指令并且没有开启slab debug的时候都可以无锁进行。假如被释放的内核对象属于full slab,那么释放内核对象之后该full slab也会转变为partial slab,该slab也会被放入到per-cpu partial slab list或者per-node partial slab list,这个新的partial slab被放入per-cpu partial slab list,而且不需要进行其它操作,那么整个操作只需要触发kmem_cache_cpu.lock,但是如果这个新的partial slab在放入per-cpu partial slab list的过程中发现per-cpu partial slab list已经满了,并且这些slab都没有被锁定等操作,那么这些slab会被移动到per-node partial slab list,因为移动到per-node partial slab list的过程中需要请求kmem_cache_node.list_lock锁,这会增加性能开销。

  假如被释放的内核对象不属于active slab而是partial slab,那么假如该slab在释放内核对象之后仍然属于partial slab,那么不会出现额外操作,slab依然保存在partial slab list上,这种情况在per-cpu partial slab list和per-node partial slab list中都适用,但是如果在释放内核对象之后该slab转变为empty slab,那么接下来的操作就取决于slab cache’s min_partial,假如partial slab list中的slab数量在这个限制之内,那么该empty slab依然会存在于这个链表上,但是如果超出该限制,那么该empty slab会被释放并从partial slab list中释放,这个处理方式同时适用于per-cpu partial slab list和per-node partial slab list。

  上图对应了内核对象释放过程中的几个过程,其中红色代表对于per-node partial slab list的操作,相关操作需要请求kmem_cache_node.list_lock,橙色代表对于per-cpu partial slab list的操作,相关操作需要请求kmem_cache_cpu.lock,红色和橙色都代表的速度较慢,性能消耗较大的内核对象释放方式,其中kmem_cache_node.list_lock的性能损耗要高于kmem_cache_cpu.lock。同时,释放一个内核对象和释放多个内核对象在插入freelist上存在区别,假如释放一个内核对象,那么只需要将该内核对象放到对应的freelist的头部,如果是释放多个内核对象,一个detached freelist会被创建并将其组合到对应freelist的头部。

     本文简单介绍了内核对象的申请和释放原理,后续文章将结合实际代码进行调试分析。

参考链接

https://blogs.oracle.com/linux/post/linux-slub-allocator-internals-and-debugging-1

https://docs.kernel.org/locking/spinlocks.html


阅读原文

跳转微信打开

Fish AI Reader

Fish AI Reader

AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

FishAI

FishAI

鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

联系邮箱 441953276@qq.com

相关标签

Linux内核 内存管理 SLUB分配器 内核对象 自旋锁 并发控制 调试 Linux Kernel Memory Management SLUB Allocator Kernel Objects Spinlocks Concurrency Control Debugging
相关文章