4. 设备驱动中的内存申请与释放
内存碎片问题,是每个嵌入式Linux开发者都绕不开的坎。伙伴系统按页分配,但驱动里动不动就要分配几十字节的结构体——你想想看,一个 struct sk_buff 才两百多字节,用伙伴系统去分,浪费太严重了。
slab 分配器就是来解决这个问题的。它专门管理小对象,避免内碎片,还带缓存加速。Linux 内核里其实有三套实现:slab、slub、slob。我最早接触的是 slab,后来项目里换 slub,再后来在资源受限的 MCU 上用过 slob。今天我把三者的区别和对象缓存机制一次讲透。
4.1 为什么需要 slab 分配器?
伙伴系统(Buddy System)以页为最小单位。驱动里频繁创建销毁的对象,比如 struct inode、struct dentry,大小往往只有几十到几百字节。直接按页分配,内部碎片率可能超过 90%。
更关键的是,这些对象经常被重复使用。每次分配都清零、每次释放都归还,开销太大。slab 分配器做了两件事:
- 对象缓存:释放的对象不真正归还,而是留在缓存池里,下次分配直接复用。
- 着色(Coloring):通过偏移调整,让不同对象落在不同的 cache line 上,减少伪共享。
我在一个网络驱动项目里遇到过性能抖动,排查下来就是 slab 着色没用好,多个 CPU 核抢同一个 cache line。调整后吞吐量提升了 12%。
4.2 三种分配器概览
Linux 内核从 2.6 开始引入 slab,后来为了适应不同场景,又加入了 slub 和 slob。三者的核心思想一致,但实现策略差异很大。
| 特性 | slab | slub | slob |
|---|---|---|---|
| 设计目标 | 通用、功能完整 | 简化、减少元数据 | 极小内存占用 |
| 适用场景 | 大型服务器、桌面 | 嵌入式、通用 Linux | 极度受限的 MCU |
| 元数据开销 | 高(每个 slab 有描述符) | 低(复用对象头) | 极低(链表管理) |
| 调试能力 | 强(红区、毒化) | 中等 | 弱 |
| NUMA 支持 | 完善 | 完善 | 无 |
| 当前内核默认 | 可选(已非主流) | 默认(大多数架构) | 仅用于 NOMMU |
核心结论:如果你的嵌入式设备有 MMU、内存大于 64MB,直接用 slub。slab 太臃肿,slob 功能太弱。只有像 Cortex-M 系列无 MMU 的芯片,才考虑 slob。
4.3 slab 分配器:经典但复杂
slab 是始祖。它把内存分成多个 slab(一个 slab 包含若干连续页),每个 slab 里再切分成等大的对象。每个缓存(cache)维护三个链表:
- slabs_full:所有对象都被分配
- slabs_partial:部分对象空闲
- slabs_free:所有对象空闲
分配时优先从 slabs_partial 取,没有则从 slabs_free 拿,再没有就新分配 slab。
slab 的着色机制很有意思。每个 slab 起始地址会加上一个偏移量,让不同 slab 的对象落在不同的 cache line 上。我记得在调试一个多核数据平面时,发现两个核频繁操作同一个 slab 里的对象,性能下降明显。打开着色后,冲突大幅减少。
但 slab 的缺点也很明显:每个 slab 需要一个独立的描述符(kmem_slab),管理开销大。在内存紧张的系统里,光描述符就能吃掉好几页。
4.4 slub 分配器:精简高效
slub 是 slab 的简化版。它去掉了 slab 描述符,把元数据直接塞进对象本身。每个 slab 的第一个空闲对象里存着下一个空闲对象的指针,形成一个隐式链表。
分配流程更直接:
- 从 CPU 本地缓存(per-CPU freelist)取对象
- 本地缓存空了,从 slab 的 freelist 批量搬一批过来
- slab 空了,从伙伴系统申请新页
释放时,对象直接放回 CPU 本地缓存,不需要加锁。只有本地缓存满了,才回收到 slab 层面。
个人习惯:我编译内核时,只要不是 NOMMU 平台,一律选 slub。它比 slab 少 30% 的元数据开销,分配路径也更短。在 ARM Cortex-A 系列上,slub 的分配延迟比 slab 低 15-20%。
slub 还有一个好处:/proc/slabinfo 的输出更清晰。你可以直接看到每个缓存的当前对象数、最大对象数、 slab 页数。调试时非常方便。
4.5 slob 分配器:为极限而生
slob 是三个里最轻量的。它不维护 slab 结构,而是用链表把空闲块串起来。分配时走 first-fit 算法,释放时合并相邻空闲块。
它的代码量只有几百行,整个分配器不到 2KB。但代价是性能差——分配大对象时可能遍历整个链表。而且没有 per-CPU 缓存,多核竞争严重。
我曾经在一个只有 4MB RAM 的 MCU 上跑 Linux,被迫用 slob。驱动里频繁分配 64 字节的小结构体,结果分配延迟不稳定,有时会卡几十微秒。后来我改用静态池 + slob 混合方案,才把抖动压下去。
避坑指南:千万不要在 SMP 系统上用 slob。我曾经在一个双核 Cortex-A7 上试过,spinlock 竞争导致分配路径耗时暴增 5 倍。slob 只适合单核、小内存的 NOMMU 设备。
4.6 对象缓存机制深度解析
三种分配器都实现了对象缓存,但方式不同。核心思路是一样的:
- 热缓存:每个 CPU 维护一个本地对象池,分配释放无锁操作
- 冷缓存:slab 层面的空闲对象,需要加锁访问
- 批量转移:本地缓存空了,从 slab 批量取一批;满了,批量还一批
slab 和 slub 都支持 per-CPU 缓存,但 slub 的实现更简洁。slub 的 per-CPU 缓存就是一个单向链表,每个节点存一个对象指针。分配时弹栈,释放时压栈,O(1) 复杂度。
slab 的 per-CPU 缓存更复杂,它维护了一个数组(array_cache),支持批量操作,但代码量也大得多。
下面这张图展示了 slub 的对象缓存流程:
4.7 如何选择?实战建议
说了这么多,到底怎么选?我根据项目经验给几条硬标准:
- 有 MMU,内存 ≥ 64MB:无脑选 slub。默认配置就行,不需要额外调优。
- 有 MMU,内存 8MB ~ 64MB:还是 slub,但可以调小
slub_min_objects和slub_max_order,减少 slab 页大小。 - 无 MMU,内存 < 8MB:只能用 slob。但要做好心理准备,频繁分配的场景需要自己加对象池。
- 调试阶段:临时切到 slab,打开
CONFIG_SLAB_DEBUG,利用红区检测和毒化功能抓内存越界。
调试技巧:我习惯在驱动初始化时调用 kmem_cache_create() 创建专用缓存,而不是直接用 kmalloc。这样可以在 /proc/slabinfo 里单独看到这个驱动的内存使用情况,定位泄漏非常方便。
4.8 内核配置与切换
三种分配器通过内核配置选项选择:
# 选择 slub(大多数架构默认)
CONFIG_SLUB=y
CONFIG_SLAB=n
CONFIG_SLOB=n
# 选择 slab
CONFIG_SLAB=y
CONFIG_SLUB=n
CONFIG_SLOB=n
# 选择 slob(NOMMU 默认)
CONFIG_SLOB=y
CONFIG_SLAB=n
CONFIG_SLUB=n
切换后重新编译内核即可。运行时可以通过 /proc/slabinfo 观察分配器状态。slub 还额外提供了 /sys/kernel/slab/ 接口,可以动态调整每个缓存的参数。
嗯,到这里 slab 分配器的内容就差不多了。三种分配器各有定位,slub 是当前的主流选择。理解它们的差异,能帮你在不同硬件平台上做出最合适的内存管理决策。