ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SLUB分配器源码解析:从数据结构到内存分配释放全流程

SLUB分配器源码解析:从数据结构到内存分配释放全流程 内存管理这块我一直觉得核心分配器属于那种“天天用、天天忽略”的代码。每个进程里的 malloc、内核里的 kmalloc最终都会汇入同一条路。我之前排查一个内存碎片问题时对着 /proc/slabinfo 里的数字一头雾水一个对象 free 之后到底去了哪里、为什么有时候 cache 里残留很多 page完全说不清楚。后来花了两三个周末硬啃 Linux 5.15 LTS 的 mm/slub.c才把整条链路理顺。这篇 slub 源码解读会从数据结构、分配路径、释放路径、调试机制四个层面展开最后再给出一套我实测过的验证方法。它适合刚接触内核内存管理的朋友也适合已经写过内核模块、想深入了解 slab 分配器内部机制的人。我会尽量把代码背后的设计意图讲透而不是贴一大段源码然后让你自己猜。1. SLUB 是什么为什么值得花时间读它的源码1.1 从 SLAB 到 SLUB一次广为人知的内核瘦身Linux 2.6.23 之前默认的 slab 分配器是 SLAB。SLAB 设计得比较“重”每个 cache 上挂了一堆链表、还有着色coloring机制来减少缓存行冲突。这些特性听上去很美但实际维护成本很高而且在大规模多核机器上锁竞争非常明显。SLUB 的核心思路是砍掉所有“锦上添花”的复杂结构只保留最本质的东西。每个 cache 里就维护三类信息——per-CPU 的活动 slab、per-CPU 的 partial 列表、per-node 的 partial 和 full 列表。分配快路径在多数情况下只是从 per-CPU freelist 上取一个对象连锁都不用加。这就像把一家有很多部门的大公司精简成几个直接干活的小队流程短、责任清、调头快。读 SLUB 源码的收益不只是“看懂一个分配器”。你会发现 Linux 内核处理并发的方式很有意思不排斥锁但尽量把热路径上的锁拿掉实在躲不过就用原子操作和事务 ID 来兜底。这种思路在很多子系统里都能看到。1.2 源码导读该从哪几个文件下手阅读之前先把目标文件准备好。SLUB 的核心代码都集中在mm/slub.c数据结构定义在include/linux/slub_def.h对外接口声明在include/linux/slab.h。如果只想看特定函数建议用 elixir.bootlin.com 在线浏览支持函数和结构体交叉跳转比我当年用 ctags 手动跳要方便得多。我的建议是按这个顺序来读先读include/linux/slub_def.h里的三个结构体struct kmem_cache、struct kmem_cache_cpu、struct kmem_cache_node。然后在mm/slub.c里定位四个函数kmem_cache_alloc、kmem_cache_free、allocate_slab、__slab_free。读完分配释放主路径后再回头研究calculate_order、deactivate_slab、put_cpu_partial这些辅助函数。这套顺序能保证你第一遍就走通主线不会被宏开关和调试分支带偏。2. 核心数据结构先看懂 SLUB 的骨架2.1 kmem_cache一个固定尺寸对象的生产流水线struct kmem_cache代表“某种固定大小对象”的分配器实例。内核里的kmalloc-64、kmalloc-128这些 cache本质就是不同的kmem_cache实例。你可以把它理解成一条只生产单一规格产品的流水线。这个结构体字段很多但需要优先关注这几位字段作用说明cpu_slabper-CPU 核心数据结构每个 CPU 核都有自己的活动 slab 和 freelistsize含元数据的实际分配大小可能大于object_size因为调试功能要占用空间object_size用户请求的对象大小比如kmalloc-64就是 64 字节offsetfreelist 指针在对象中的偏移通常为 0调试模式下可能放到对象末尾oooptimal order 与对象数决定这个 slab 占几页、能放几个对象min/maxslab 页序的上下限避免单个 cache 占用过多连续内存node[]每个 NUMA 节点一个管理结构分配对象时会优先考虑本地节点一个容易被忽略的点size和object_size往往不一样。如果你开了CONFIG_SLUB_DEBUGredzone、poison、track 这些调试数据都要占空间所以对象实际占用的内存比用户请求的要大。读源码时如果看到“明明只请求 64 字节debug 后却显示 128 字节”别慌这只是元数据占据了对象旁边的位置。2.2 kmem_cache_cpu 与 kmem_cache_nodeper-CPU 与 per-node 的分工struct kmem_cache_cpu是 SLUB 性能的关键。它保存了当前 CPU 的 freelist、正在使用的 slab 页、以及 CPU 自己的 partial 列表。这里有个很巧妙的细节每个 CPU 在任意时刻最多只有一个“活动 slab”这个 slab 就挂在c-page。分配对象时只需要从c-freelist取取完了再把c-freelist指向下一个空闲对象。整个过程只在当前 CPU 上操作没有锁。struct kmem_cache_node则是每个 NUMA 节点一个主要负责跨 CPU 协调。它里面有一个list_lock保护 partial 链表。当某个 slab 在 CPU 间流转、或者需要回收给伙伴系统时才需要动这个锁。从我读代码的感受看per-CPU 与 per-node 的分工是 SLUB 的灵魂。它把锁竞争从“全局一个锁”降到了“只在慢路径才需要碰 node 锁”这也是为什么 SLUB 在大量线程并发分配释放时表现要比老 SLAB 好很多。2.3 struct page 的客串一个 slab 如何借用页框结构SLUB 里有一种“结构体复用”的写法容易让初读源码的人懵掉明明struct page是描述物理页的怎么又变成了 slab 的管理数据其实每个 slab 对应一个或几个连续物理页。SLUB 在把页移交给 slab 管理时会借用struct page里不再被页管理子系统使用的字段page-freelist这个 slab 自己的空闲对象链表头。page-slab_cache指向归属的kmem_cache。page-frozen表示这个 slab 是否被某个 CPU“锁定”。page-inuse当前已分配出去的对象数量。page-slab_list/page-next串入 partial 链表时使用。也就是说struct page在不同的生命周期扮演不同角色作为物理页时它是页管理单元被 SLUB 接管之后它就成了 slab 的管理头。新版本内核5.17已经把 slab 相关字段抽到了独立的struct slab里但 5.15 的代码里还是这种复用方式。读源码时沿着page-freelist和page-frozen去追状态变化能很快理解一个 slab 到底处于什么状态。3. 分配路径源码拆解从 kmem_cache_alloc 往下走3.1 快路径拿 per-CPU freelist几乎不花时间分配对象的入口是kmem_cache_alloc它先调用slab_alloc再进入slab_alloc_node。核心逻辑是这样的static __always_inline void *slab_alloc_node(struct kmem_cache *s, gfp_t gfpflags, int node, unsigned long addr) { struct kmem_cache_cpu *c; struct page *page; unsigned long tid; void *object; c raw_cpu_read(s-cpu_slab); tid c-tid; object c-freelist; page c-page; if (unlikely(!object || !page || (node ! NUMA_NO_NODE page_to_nid(page) ! node))) { object __slab_alloc(s, gfpflags, node, addr, c); } else { void *next_object get_freepointer_safe(s, object); if (unlikely(!this_cpu_cmpxchg_double( s-cpu_slab-freelist, s-cpu_slab-tid, object, tid, next_object, tid 1))) { object __slab_alloc(s, gfpflags, node, addr, c); } } return object; }注意这个代码里没有加锁也没有关中断。SLUB 依靠的是this_cpu_cmpxchg_double它一次性比较并交换freelist和tid两个值。如果中间有中断或抢占打断了这次分配freelist已经被别人改过cmpxchg 就会失败于是退回慢路径重新处理。tid在这里扮演的是“版本号”的角色。每次成功更新 freelisttid 都会加一。新来的线程只要发现 tid 对不上就知道这期间发生过并发修改。这种无锁快路径在单核、多核场景下都表现得很稳也是 SLUB 能成为默认分配器的重要原因。如果c-freelist本来就不为空分配一个对象只是“取下头结点并更新链表头”的操作开销接近两个原子指令。这也是为什么很多人说 SLUB 分配对象“几乎是免费的”。3.2 慢路径从 CPU partial、node partial 到 new_slab当 per-CPU freelist 为空或者请求的 NUMA 节点不是当前节点时分配会进入慢路径__slab_alloc。它的处理顺序很有意思是一层一层向上找资源先看当前 CPU 的partial链表上有没有可用的 slab。有的话直接把它拿过来作为新的活动 slab。CPU partial 没有就去当前 NUMA 节点的partial链表中找。node partial 也没有就只能调用new_slab从伙伴系统分配一整块新页作为新 slab。这个过程中get_partial_node会尝试一次性从 node partial 链表里取出合适的 slab 并填充到 CPU partial避免频繁访问 node 锁。你可以把它理解成超市补货货架CPU partial快空的时候不是一瓶一瓶去库房拿而是一整箱一整箱地搬减少来回次数。慢路径里还有一个容易忽略的地方它往往需要local_irq_save关中断。为什么要关因为一旦进入慢路径要访问 node 链表、要操作部分全局状态如果这时被中断打断中断处理程序里又触发了同一个 cache 的分配就可能出现重入问题。关闭当前 CPU 中断是一道安全网代价自然比快路径高一些。3.3 new_slab向伙伴系统要页再建立 freelistnew_slab最终会走到allocate_slab。这个函数的动作很直白根据s-oo里保存的 order 值调用alloc_pages从伙伴系统拿几页连续内存。拿到之后把内存切成s-oo里记录的objects个对象然后将这些空闲对象通过 freelist 串起来。初始化 freelist 的简化逻辑大致如下static inline void *setup_object(struct kmem_cache *s, struct page *page, void *object) { if (s-ctor) s-ctor(object); return object; } static void __init_slab_objects(struct kmem_cache *s, struct page *page, int freelist_idx) { void *p; int i; page-freelist NULL; for (i 0; i s-oo.objects; i) { p index_to_object(s, page, i); set_freepointer(s, p, page-freelist); page-freelist p; } }每个空闲对象的内存里会写入“下一个空闲对象的位置”这就构成了一个按地址倒序链接的单向链表。分配对象时只需要取表头并更新表头即可。注意这里 freelist 指针默认存放在对象开始的位置也就是s-offset一般为 0。如果开启了CONFIG_SLAB_FREELIST_RANDOM初始化时会把链表的次序打乱。这样做的目的不是性能而是安全如果不打乱攻击者可以预测同一 slab 内对象分配的先后顺序进而实施堆布局控制。至于为什么要对 freelist 指针本身做加密处理第五节再展开。4. 释放路径源码拆解free 对象是如何回“家”的4.1 快路径与 frozen 标志释放对象的入口是kmem_cache_free最终会进入do_slab_free或__slab_free。一个对象被释放时SLUB 要回答的核心问题是这个对象所在的 slab现在归谁管如果对象所在的 slab 恰好就是当前 CPU 的活动 slabc-page并且page-frozen为 1那么释放动作极其简单static __always_inline void do_slab_free(struct kmem_cache *s, struct page *page, void *head, void *tail, int cnt, unsigned long addr) { struct kmem_cache_cpu *c; if (likely(page c-page c-page-frozen)) { set_freepointer(s, head, c-freelist); c-freelist head; c-tid next_tid(c-tid); return; } __slab_free(s, page, head, tail, cnt, addr); }这里直接把对象链回当前 CPU 的 freelist更新一下 tid完事。不用碰 node 锁不用管 global 状态。我刚开始读这里的时候对frozen的理解费了不少劲。后来找到的直观类比是frozen1表示这个 slab 被某个 CPU “包养”了属于 CPU 的私有活动财产。此时发生在它身上的释放操作只需要在“包养者”自己的 freelist 上做插入不需要向 node 层汇报。frozen0则意味着这个 slab 已经不在活动状态可能在 partial 链表里也可能已经完全空闲等待回收这时释放操作要谨慎处理链表状态。4.2 慢路径回 partial 还是回伙伴系统如果 slab 不是 CPU 的活动 slab释放就会走到__slab_free。这个函数比快路径复杂不少但核心决策就三条分支slab 处于frozen1状态但不是当前 CPU 的 activity slab。这种情况一般发生在 NUMA 场景对象被 A CPU 分配却由 B CPU 释放。此时 slab 依然被某个 CPU 持有只需要把对象链到对应 CPU 的 freelist 上这里还有个细节SLUB 在实际实现中会尽量通过 freelist 或者 node 锁来保证一致性代码里会判断frozen后决定是否需要spin_lock(n-list_lock)。slab 在某个 partial 链表中且inuse没有降到 0。释放一个对象后它只是重新变成空闲对象slab 继续留在 partial。slab 从 partial 链表中被释放的最后一个对象inuse变为 0。这时 slab 变成了完全空闲页SLUB 会把它从 partial 链表摘下来然后看节点上已有多少空闲 slab如果还低于min_partial留作缓存如果已经足够就discard_slab回收到伙伴系统。min_partial这个参数非常关键。它表示每个节点至少要保留的 partial slab 数量。这样做是为了避免一种抖动对象大量释放后slab 立刻还给伙伴系统过了一会儿又需要它又从伙伴系统申请回来。频繁的 alloc/free 页会造成不必要的内存管理开销。4.3 CPU partial 与批量操作机制SLUB 里还有一个很容易忽视的优化CPU partial。老 SLAB 分配器里 partial 信息主要挂在节点上CPU 释放一个空闲 slab 后往往要立刻去抢 node 锁。SLUB 允许每个 CPU 先把空闲 slab 暂存在自己这里攒够一批再交给节点。具体来说put_cpu_partial会检查当前 CPU 的 partial 链表长度。如果长度没有超过s-cpu_partial的上限就把 slab 挂到 CPU partial一旦超出就把一部分合并到 node partial。这样做的收益很明显节点锁的抢占用频率大幅下降多核环境下 free 路径的伸缩性更好。deactivate_slab也是理解释放链路绕不开的函数。当一个 CPU 活动 slab 被“用完”所有对象都分配出去或者 CPU 决定换一个 slab 时deactivate_slab会决定这个老 slab 的去向如果还有空闲对象放 CPU partial 或 node partial如果全满放到 node 的 full 链表。分配慢路径在拿新 slab 时会优先从这些位置找尽量做到“物尽其用”。5. 源码中暗藏的调试与安全机制5.1 slub_debug红区、毒药与调用栈跟踪SLUB 源码里CONFIG_SLUB_DEBUG分支下的代码非常值得读一遍因为它是你排查内存越界和 UAF 的第一工具。启用方式很简单在内核启动参数里加slub_debugP,kmalloc-128这个参数的意思是对kmalloc-128这个 cache 开启 poison 检查。其实slub_debug支持多种标志常见的有标志功能用途FSanity checks每次分配释放时做基础检查ZRedzone在对象前后设置红区检测越界读写PPoison用特定字节填充对象检测释放后使用UTrack记录分配或释放的调用栈TTrace打开分配释放的 tracepointAFail alloc模拟分配失败测试错误处理路径我实际排查越界问题时最常用的组合是slub_debugFZPU。对象前后有红区一旦谁越界写了下次分配释放时就会报BUG并且打印出越界位置。对象释放后会填充成0x6bpoison如果还有代码在读旧对象很容易在调试器中看到一片0x6b6b6b6b。5.2 freelist hardening 与随机化freelist 指针为什么要加密SLUB 在安全上的两个特性要分开讲CONFIG_SLAB_FREELIST_HARDENED和CONFIG_SLAB_FREELIST_RANDOM。先说FREELIST_RANDOM。它把同一 slab 内对象的分配顺序打乱让攻击者无法预测下一个被分配的对象地址。如果没有这个随机化freelist 就是一个确定的降序链表攻击者可以通过连续分配多个对象来精确布置内核堆。再说FREELIST_HARDENED。它的核心是让 freelist 里保存的“下一个对象指针”不是明文地址而是经过异或混淆的值static inline void *freelist_ptr(const struct kmem_cache *s, void *ptr, unsigned long ptr_addr) { return (void *)((unsigned long)ptr ^ s-random ^ swab(ptr_addr)); }这样做的目的是即使攻击者通过 UAF 漏洞读到了 freelist 内容也无法直接得到有效地址反过来如果攻击者想通过改 freelist 指针来劫持分配改出来的也是被加密的密文无法通过解密检查。这是一套“双向防护”的设计。读到这里你可能会问这些安全特性为什么放在 SLUB 里因为 slab 分配器是最常用的对象来源堆漏洞利用几乎都绕不开它。内核在分配器层面做防护是最底层也最有效的一环。5.3 借助 sysfs 与内核参数验证你的理解读源码不落地很容易读完就忘。我习惯用/sys/kernel/slab/来直观验证代码行为。每个 kmem_cache 在 sysfs 下都有一个目录列出关键信息ls /sys/kernel/slab/kmalloc-128/ cat /sys/kernel/slab/kmalloc-128/objs_per_slab cat /sys/kernel/slab/kmalloc-128/order cat /sys/kernel/slab/kmalloc-128/cpu_partial这里order对应s-oo.orderobjs_per_slab对应s-oo.objects。如果你在调试模式下分配了一个对象再看/sys/kernel/slab/kmalloc-128/objects会发现对象计数发生变化。这比单纯读源码更有感觉。另外slub_min_order、slub_max_order、slub_min_objects、slub_nomerge这些内核参数也建议统一过一遍。它们会影响calculate_order的选择结果。我之前在一个需要大量小对象的服务上就通过调低slub_max_order来减少 per-slab 的对象数从而降低碎片化。这类参数都能在源码里找到对应位置看懂了再改心里才有底。6. 读 SLUB 源码时的排坑与验证技巧6.1 阅读时最容易绕晕的四个点读 SLUB 源码理解难点往往不在单个函数而在于几个反复出现的“小妖精”。我梳理出四个最常见的问题第一c-freelist和page-freelist分不清。c-freelist是当前 CPU 正在使用的活动 slab 的空闲对象头page-freelist则是一个 slab 自己的空闲对象头。大多数快路径操作的是c-freelist而page-freelist更多是在初始化、慢路径、状态切换时使用。第二frozen的含义理解不对。frozen1表示 slab 正被某个 CPU 当作活动 slab 使用此时它的释放操作不需要 node 锁frozen0表示 slab 处于 partial 或待回收状态涉及它的链表操作必须拿 node 锁保护。这个字段是理解整个释放路径的核心。第三一个kmem_cache里有很多 node但每个 CPU 只有一个活动 slab。很多人会问那 NUMA 怎么办答案是每个 CPU 的活动 slab 可以来自不同 node分配时会根据node参数选择。如果当前 CPU 的活动 slab 不在目标 node 上就会走慢路径甚至直接分配一个新的 slab。第四开启调试后size不等于object_size。如果看到kmalloc-128的对象在调试模式下size变成 160不要惊讶。redzone、poison、track 都要占用对象附近的字节这些额外空间算在size里但用户能用的是object_size。源码里s-offset可能因此被调整到对象末尾。6.2 用 ftrace 和 bpftrace 把源码和现实对应起来读源码最怕“觉得看懂了但遇到实际问题还是无从下手”。我的经验是读一个函数就找一个现场工具去验证它。最轻量的方式是 ftrace。在 root 权限下先挂上分配器函数然后跑一段压力场景echo kmem_cache_alloc /sys/kernel/debug/tracing/set_ftrace_filter echo function /sys/kernel/debug/tracing/current_tracer echo 1 /sys/kernel/debug/tracing/tracing_on # 运行你的负载 cat /sys/kernel/debug/tracing/trace | head -50这样你能看到kmem_cache_alloc被哪些进程频繁调用频率有多高。如果怀疑某个路径触发了慢路径可以进一步跟踪__slab_alloc、new_slab等函数。如果想做更灵活的统计用 bpftrace 也可以效果类似bpftrace -e kprobe:kmem_cache_alloc { [comm] count(); }这行命令会统计每个进程调用kmem_cache_alloc的次数。如果你在验证自己的内核模块看到计数明显变化说明你的模块确实走了这条分配路径。用这些手段配合源码看比单纯静态阅读要扎实得多。6.3 一次小的内核模块验证实验如果你想亲手确认一个 cache 的分配释放过程可以写个小模块。这不是什么高深操作却能让你直观看到 sysfs 里对象数量的涨跌。下面这个示例只做教学用途生产环境不要直接使用#include linux/module.h #include linux/slab.h #include linux/slab_def.h static struct kmem_cache *demo_cache; static void *demo_obj; static int __init demo_init(void) { demo_cache kmem_cache_create(demo_cache, 64, 8, 0, NULL); if (!demo_cache) return -ENOMEM; demo_obj kmem_cache_alloc(demo_cache, GFP_KERNEL); if (!demo_obj) { kmem_cache_destroy(demo_cache); return -ENOMEM; } pr_info(demo: allocated %px\n, demo_obj); return 0; } static void __exit demo_exit(void) { kmem_cache_free(demo_cache, demo_obj); kmem_cache_destroy(demo_cache); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE(GPL);编译加载后去/sys/kernel/slab/demo_cache/看objects和objs_per_slab。你会发现即使只分配一个 64 字节对象SLUB 也会整页分配一个包含很多对象的 slab空闲对象依然在 freelist 里排队。这正好印证了源码里new_slab和set_freepointer的行为。写完模块再回到mm/slub.c你会对很多函数有新的理解因为你已经亲手让它们执行过了。最后再分享一个实操经验。我啃完 SLUB 之后养成了一个“坏习惯”每次排查内存相关性能问题第一件事都是打开/proc/slabinfo和/sys/kernel/slab先看哪些 cache 的对象数异常、哪些 partial 链表过长。读源码最大的价值就是让你能真正看懂这些数字背后的状态变化。以后再遇到“为什么 cache 占用居高不下”“为什么分配变慢”这类问题你能准确说出它是在哪个环节卡住了而不是靠猜。
返回列表