第八章 内存分配
本章主讲设备驱动程序中使用内存的一些其他方法。等第十五章再讨论关于分段分页等描述内存管理的内部细节。
8.1 kmalloc 函数内幕
kmalloc 函数功能强大,不对内存空间清零(这意味着我们要将申请到的内存显式的清零后再用),且分配的区域在物理内存中连续 (这是kmalloc 与 vmalloc 最本质的区别,kmalloc 与 vmalloc一样返回的内存地址都是虚拟地址,由MMU内存管理单元处理才能转化为物理内存地址)。函数原型:
c
#include <linux/slab.h>
void *kmalloc(size_t size, int flags);
// 与之匹配的释放函数
void kfree(const void *objp);
-
size: 分配块的大小。 -
flags:GFP_KERNEL:最常用,可以睡眠。(持有自旋锁或中断处理中千万不要用)GFP_ATOMIC:不能睡眠(用于中断、软中断等原子上下文)。GFP_USER:用来为用户空间页来分配内存; 它可能睡眠.__GFP_NOFAIL:分配失败反复申请,千万不要用。现在的最佳实践是:分配失败就返回错误码,让上层逻辑处理。GFP_NOFS和GFP_NOIO用于防止递归死锁。__GFP_DMA:已经不再用于DMA操作。改用DMA Mapping API。
-
内存浪费问题:
kmalloc最适合小对象(通常小于 128KB,具体取决于架构)。对于大内存,优先使用vmalloc(虚拟连续,物理不连续)或者kvmalloc(自动在kmalloc失败时回退到vmalloc)。
8.1.1 内存区
没看懂。
8.1.2. size 参数
这段内容主要揭示了内核内存分配与用户空间 malloc 在底层机制上的巨大差异。并提醒开发者使用kmalloc 函数时如何合理的执行size的大小。
看到一篇23年博客说:为了减少内核的维护负担,提高API的改进,Linux内核的开发者决定废弃SLAB分配器,并推荐用户使用SLUB分配器,参考链接:Linux 的 SLAB 分配器已正式弃用?- 知乎,这篇博客参考链接:Linux 内核中有哪些比较牛逼的设计? - Lion 莱恩呀的回答 - 知乎有对SLAB原理 的入门级别解析。另外还有大神对Linux的知识点做了系统梳理,包括3.2.5 SLAB/SLUB/SLOB。使用kmalloc不注意偶尔还可能造成内存泄露,就可能需要用到kernel内存泄漏检测:参考链接:linux kernel内存泄漏检测工具之slub debug。
驱动开发者需知:
- 不要使用用你申请内存得到的多余的部分 :如果你申请 10 字节,内核可能会给你 32 或 64 字节(取决于架构和缓存行大小)。不要利用这部分多出来的空间存储额外数据,这是未定义行为。
- 警惕小对象浪费 :如果你有大量极小的数据结构(比如几个字节),直接用
kmalloc会造成巨大的内存浪费(内部碎片)。(对于大量同类型的小对象,驱动开发者应该学习使用 自定义 SLAB 缓存) kmalloc有上限,通常不能指望分配大于 128KB 的内存。(如果你需要 > 128KB 的内存,且不需要物理连续(例如用于视频缓冲、大文件读写),请使用vmalloc(虚拟地址连续,物理不连续)。)- 如果你需要 > 128KB 且必须物理连续:需要使用 CMA 或预留内存,普通的
kmalloc无法满足。
8.2 后备高速缓存 - Cache
8.2.1 后备高速缓存-高性能驱动必备
Linux内核提供了后备缓存机制,用来创建一组相同大小对象的内存池。应用在反复分配许多相同大小内存的场景。如 USB 和 SCSI 驱动等。Linux内核的缓存管理者为------slab分配器。(目标:学会 kmem_cache_create、kmem_cache_alloc、kmem_cache_free。这是编写高性能驱动(尤其是涉及大量数据吞吐的驱动,如网卡、摄像头)的必备技能。)
!IMPORTANT
有什么区别?
kmalloc是"临时向系统要一块内存"
kmem_cache是"为某一种对象长期准备一个对象池" ,它是给 "写到一定复杂度的内核子系统(如 VFS、网络、块设备、驱动)"准备的,通常比kmalloc更稳定、预测性更好,调适能力更强。✅ 如果你 只偶尔用、结构简单、生命周期短 →
kmalloc✅ 如果你 反复创建/销毁同一种结构体、生命周期可预期 →
kmem_cache
c
#include <linux/slab.h>
# 创建一个新的,可以驻留任意数目,全部同样大小(由size决定)的后备缓存对象
# kmem_cache_create() 函数在文件实现中如下:
#define kmem_cache_create(__name, __object_size, __args, ...) \
_Generic((__args), \
struct kmem_cache_args *: __kmem_cache_create_args, \
void *: __kmem_cache_default_args, \
default: __kmem_cache_create)(__name, __object_size, __args, __VA_ARGS__)
可见kmem_cache_create有过几次更新,其最新的用法为__kmem_cache_create_args,如下:
c
struct kmem_cache *__kmem_cache_create_args(const char *name,
unsigned int object_size,
struct kmem_cache_args *args,
slab_flags_t flags);
name:保存一些信息object_size:区域的大小(所有内存区域都相同,由这设置)flags:控制如何完成分配,可设为:SLAB_NO_REAP SLAB_HWCACHE_ALIGN SLAB_CACHE_DMA等。
一旦使用 kmem_cache_create 创建了专门给某个对象的高速缓存后,可以通过kmem_cache_alloc等从中管理内存对象啦。如下所示:
c
# 分配内存对象,flags 同 kmem_cache_create
void *kmem_cache_alloc(struct kmem_cache *cachep, gfp_t flags);
# 释放内存对象
void kmem_cache_free(struct kmem_cache *s, void *objp);
# 释放高速缓存(如果由对象未归,则返回状态失败)
void kmem_cache_destroy(struct kmem_cache *s);
可以通过/proc/slabinfo节点方便的获取高速缓存的使用统计。
8.2.1. 一个基于 Slab 缓存的 scull: scullc
文中给了个后备缓存使用示例,用的不多没细研究。大概流程如下:
c
scullc_cache = kmem_cache_create(...); /* 创建高速缓存 */
kmem_cache_alloc(scullc_cache, HFP_KERNEL); /* 分配内存量子 */
......
kmem_cache_free(scullc_cache, HFP_KERNEL); /* 分配内存量子 */
kmem_cache_destroy(scullc_cache); /* 释放高速缓存 */
与之前的scull相比,scullc的优势有:①在cache分配速度快,②内存池中内存块大小一样排列紧密,内存碎片少。
8.2.2 内存池-用于不允许失败的情况
Linux内核有些地方内存分配不允许失败,为了确保这种情况下必须成功分配,内核开发者开发建立了一种称为内存池(或者mempool)的抽象,其实就是某种形式的后备高速缓存,它始终保持空闲以便在紧急情况下使用(平时它借用后备缓存或 kmalloc 分配内存;只有当系统内存耗尽、普通分配失败时,它才会把藏起来的那一小撮内存拿出来救急。)。(mempool 通常用于内核核心子系统 :它更多见于内核的 VM(内存管理)子系统 、块设备层(Block Layer) 或 文件系统 内部。)
内存池类型为mempool_t(在</linux/mempool.h>中定义),可以使用mempool_creat来建立内存池对象:
c
#include <linux/mempool.h>
mempool_t *mempool_create(
int min_nr,
mempool_alloc_t *alloc_fn,
mempool_free_t *free_fn,
void *pool_data);
介绍一下mempool_create的四个参数:
-
min_nr为内存池应始终保持的已分配对象的最少数目。 -
alloc_fn与free_fn管理对象的实际分配和释放,这两个函数定义规范如下所示:ctypedef void * (mempool_alloc_t)(gfp_t gfp_mask, void *pool_data); typedef void (mempool_free_t)(void *element, void *pool_data); -
pool_data被传入alloc_fn与free_fn。
如果有必要,用户是可以按照上面alloc_fn与free_fn的规范来自行构造特定的函数处理内存的分配和释放的,但是我们通常都让 slab 分配器为我们做了这个任务。你去看内核</linux/mempool.h>文件,其中有两个函数mempool_alloc_slab和mempool_free_slab已经符合了对象分配和释放定义规范,且提供了mempool_create的包装mempool_create_slab_pool,并利用</linux/slab.h>文件提供的kmem_cache_create,kmem_cache_destroy等来创建和销毁专门分配"固定大小内核对象"的 slab cache,提高性能、减少碎片。因此构造内存池的代码通常如下:
c
// 如下代理我参考自drivers/dma/dmaengine.c文件,说上说的没错
cache = kmem_cache_create(.....)
pool = mempool_create_slab_pool(MY_POOL_MININUM, cache);
// 然后就可以使用如下函数分配和释放对象了
void *mempool_alloc(mempool_t *pool, int gfp_mask);
void mempool_free(void *element, mempool_t *pool);
在使用mempool_free释放对象时,如果预先分配的对象数码小于最低数据,则会将该对象保留在内存池中,否则将对象返回给系统。还有一些函数要知道:
c
int mempool_resize(mempool_t *pool, int new_min_nr) # 重新调整内存池预分配对象的大小
void mempool_destroy(mempool_t *pool); # 如果不再需要则将内存池返回给系统,与 mempool_create 相配
注意:在mempool_destroy销毁前,必须将所有已分配对象返回到内存池,非欧洲导致内核oops。
提醒:mempool很容易浪费大量内存,尽量避免在驱动程序代码中使用。
8.3. 分配必须连续的大内存
8.3.1 伙伴系统-了解概念
推荐阅读:
核心思想: 解决物理内存碎片化的算法。有关伙伴系统和当前状态的信息可以在/proc/buddyinfo中获取
基本单位: 内存被划分为"页框"(Page Frame,通常 4KB)。
分组管理: 系统将空闲的物理页框按大小分组,每组包含 2的n次方 个连续页框。这个 n 被称为阶。
- 阶 0: 1 个页框 (4KB)
- 阶 1: 2 个连续页框 (8KB)
- 阶 2: 4 个连续页框 (16KB)
- ...
- 阶 10: 1024 个连续页框 (4MB)
分配逻辑:
- 当你请求一个阶 n 的内存时,系统先看对应的链表里有没有。
- 如果没有,它会去更高阶( n+1 )的链表里找一个大的块,把它掰成两半(分裂),一半给你,另一半放回 n 阶链表。
释放逻辑:
- 当你释放内存时,系统会检查它的"伙伴"(地址相邻且大小相同的块)是不是也空闲。
- 如果是,就把两个合并成一个更大的块(合并),放入更高阶的链表。
驱动开发者的痛点:
- 外部碎片: 即使总空闲内存很大,但如果都是零散的小块(阶 0),你就申请不到大块连续物理内存(高阶)。这就是为什么
kmalloc申请大内存容易失败。
8.3.2 kvmalloc - 搞懂
**核心思想:**智能分配。是大内存分配首选。
- 调用
kvmalloc(size)时工作原理:- 尝试 1: 先尝试用
kmalloc分配(物理连续,速度快)。 - 尝试 2: 如果
kmalloc因为找不到连续物理内存而失败(通常是因为 size 较大或碎片多),它会自动回退,改用vmalloc分配(虚拟连续,物理不连续)。
- 尝试 1: 先尝试用
- 优势:
- 在小内存时享受
kmalloc的高速缓存优势;在大内存时享受vmalloc的高成功率。
- 在小内存时享受
- API 家族:
kvmalloc(size, flags):基础函数。kvzalloc(size, flags):分配并清零(最常用,防止信息泄露)。kvfree(ptr):必须 用这个来释放!它能自动识别指针是来自kmalloc区域还是vmalloc区域,并调用正确的释放函数。
8.3.3 vmalloc - 理解原理不推荐用
核心思想: 用"CPU 算力"换"内存空间",得到的地址物理不连续。分配速度慢,分配效率低。
原理图解 :vmalloc 修改页表,把连续的"门牌号"分别指向那些散落的"房间"。代码看到是连续的 100-105,但实际硬件访问时,通过 MMU 翻译,跳到了物理上不相邻的地方。
返回值:发生错误时返回0,成功时返回一个指针(该指针指向一个线性的,大小最少为size的线性内存区域)。
与之匹配的释放函数:vfree
建议 :之所以介绍它是因为vmalloc是是Linux内存分配机制的基础。但大多是情况不鼓励使用,因为其获得的内存使用效率不高,不能在原子上下文使用。且在某些体系架构上,vmalloc的地址空间总量相对较小。
8.3.4 alloc_pages - 了解
核心思想:绕过"中间商"(SLAB/SLUB),直接向"房东"(伙伴系统)租房。
它是谁: 它是页分配器的直接接口。
返回什么: struct page *。
- 注意:它返回 的不是你直接能用的内存地址(指针),而是一个描述这块内存的"身份证"结构体。
- 你需要通过
page_to_virt(page)把它转换成虚拟地址才能读写数据。
为什么要用它?
- 控制力: 当你需要精确控制物理内存的属性(比如设置内存为不可缓存、加密属性)时。
- 大内存: 当你需要分配大于 128KB 且必须物理连续 的内存,而
kmalloc已经无能为力时。 - 特殊用途: 比如你要把这块内存映射到用户空间(mmap),或者做复杂的 IOMMU 映射。
NUMA 感知: 它有 alloc_pages_node 变体,允许你指定在哪个 CPU 节点的内存上分配,这对高性能计算很重要。
8.3.5 get_free_page - 忘掉
它返回的是 unsigned long (虚拟地址),虽然用起来方便,但它掩盖了底层的 struct page 细节。只有在阅读非常古老的驱动代码(10 年前)时,你可能会看到它的身影。现在的内核源码里,它通常只是 alloc_pages 的一个简单封装(Wrapper)。
8.4 per-CPU 的变量
结合这篇文章记录学习:linux同步机制 - percpu 的使用 - 黄导的文章 - 知乎。
光看书我完全没明白per-CPU存在的必要性,但是我找到的这篇博客就讲解的非常细致。我仅在理解的基础上总结:
8.4.1 为什么需要per-CPU?
随着硬件的发展,程序执行效率很大的一个优化是引入了高速缓存,也就是Cache,但是缓存的命中与一致性一直是SMP架构中一个很棘手的问题。
在SMP架构中高速缓存怎么工作的呢?通常每个CPU都有自己的高速缓存,通常 L1_Cache 是CPU独占的,每个CPU都有自己的一份,L2_Cache通常是所有CPU共享,当CPU载入一个全局数据时,会逐级检查高速缓存,如果缓存没命中,就从内存中载入,并加入到各级Cache中,当CPU下次需要读取这个值时,直接读取Cache将会获得非常快的速度,比直接读取内存高出几个数量级。
对于读取操作,Cache的存在带来了很大的性能提升,但是对于写入操作不然,写入也是写入到Cache然后同步到内存。一个CPU对全局变量的修改会造成其他CPU对该全局数据的缓存全部失效。需要全部更新,这带来了性能上的损失。
最好的军事战略就是不战而屈人之兵,最好的同步机制就是压根不需要同步。因为不管用哪种同步机制都有性能开销。percpu 就是这样一种同步机制:percpu为每个CPU生成了一份供该CPU独有的数据备份,每个数据备份占用独立的内存,每个CPU只能修改属于自己的那份。这样避免了多CPU产生的数据同步问题。
光独立了,怎同步的呢?想想就不对劲呐,进程在CPU0上操作percpu变量,在某时刻被调度到CPU1执行又操作了该CPU上的percpu变量,拿两个CPU上的percpu变量就不同了,这不有问题吗???
实际上,percpu只能在特殊的条件下用:当确定在系统的CPU上的数据在逻辑上是独立的时候。正是因为这种场景下,CPU之间的percpu变量压根儿不需要同步。percpu的优化思路是这样的:把全局变量count变成:
count[CPU0] + count[CPU1] + ... + count[CPUn]
这样每个 CPU 写自己的内存(无锁),没有 cache 抖动,性能高,代价是读取时需要累加,读取值是"近似实时"的。
比较经典的应用场景为计数器的使用:在网络接收程序中,接收数据包的程序可能先后运行在不同的 CPU 上,采用 percpu 变量进行计数,最后将所有 CPU 上的 percpu 副本相加就可以得到所接收数据包的总和,在这里,每个 CPU 上的 percpu 计数器并没有产生逻辑上的联系。文件:net/core/dev.c:
c
// net/core/dev.c
# 结构
struct pcpu_sw_netstats {
u64 rx_packets;
u64 tx_packets;
};
# 定义:
struct pcpu_sw_netstats __percpu *tstats;
# 更新
this_cpu_inc(dev->tstats->rx_packets);
# 汇总
for_each_possible_cpu(i) {
stats->rx_packets += per_cpu_ptr(dev->tstats, i)->rx_packets;
}
8.4.2 percpu 的存储
Linux 内核image中存在各种不同的 section,也经常看到__attribute__关键字修饰的section:
c
__attribute__(section(".section"),...)
__attribute__是gcc中的关键字,通常作为修饰符,使用方式类似于const,static,为符号添加属性。
__attribute__(section())的作用是将所修饰的对象放在编译生成二进制文件的指定section中,最常见的section有:.data/.data/.bss,在程序链接的阶段会确定每个section最终加载地址。
对于普通变量,变量的加载地址就是程序中使用的该变量的地址,可以用取地址符获得,但对于percpu变量而言,该 percpu 的加载地址是不允许访问的。咋搞的呢?在内核启动阶段,对于n核SMP架构系统,内核将为每个CPU开辟一段内存,然后将该percpu变量复制n份分别放到每个CPU独有的内存中,同时记录下原始变量与CPU独有内存的偏移值,然后每个 CPU 对应的 percpu 变量地址为 (&var + offset),当然真实情况要比这个复杂,将在后文中讲解。
8.4.3 percpu 变量的使用
驱动工程师最关心的还是怎么使用它,秉承 linux 内核的一贯设计风格,percpu 变量的接口非常易用,总共包含两个部分:初始化 + 读写操作。(如果是动态申请方式的初始化,需要释放。)
8.4.3.1 定义
定义分两种:静态和动态,静态定义:
c
// type 是变量类型,name 是变量名。
DEFINE_PER_CPU(type, name)
动态定义:
c
type __percpu *ptr alloc_percpu(type);
动态分配一个 percpu 变量时,返回percpu变量的地址,但返回的地址并非可直接使用的变量地址,跟静态定义一样,真正被使用的数据被分成了 n 份分别保存在每个CPU独占的地址空间中,在访问percpu变量时就是对这些个副本的访问。
8.4.3.2 读写
对于静态变量的读写:
c
DEFINE_PER_CPU(int, val); //var 是定义时使用的变量名
...
int *pint = &get_cpu_var(val) //获取当前执行这段代码的 CPU 所对应的 percpu 变量
*pint++;
put_cpu_var(val) //当前操作的结束
事实上,put_cpu_var 并非字面上理解的:将变量放回内存,事实上它仅仅是使能了在 get_cpu_var 函数中关闭的内核抢占。 所以,put_cpu_var 和 get_cpu_var 是成对出现的,因为这段时间内核抢占处于关闭状态,他们之间的代码不宜过长。
!NOTE
那为何调用
get_cpu_var第一步是禁止内核抢占呢?想想这样一个场景,进程 A 在 CPU0 上执行,读取了 percpu 变量到寄存器中,这时候进程被高优先级进程抢占,继续执行的时候可能被转移到 CPU1 上执行,这时候在 CPU1 执行的代码操作的仍旧是 CPU0 上的 percpu 变量,这显然是错误的。
对于动态变量的读写:
c
int *pint = alloc_percpu(int);
...
int *p = per_cpu_ptr(pint,raw_smp_processor_id());
(*p)++;
动态变量读写接口为per_cpu_ptr(ptr, cpu)。该接口跟静态变量的接口不同,该接口允许指定CPU,不再只能获取当前CPU的值,第一个参数是动态申请时返回的指针。raw_smp_processor_id() 函数返回当前 CPU num,这个示例仅为操作当前 CPU 的 percpu 变量。但这个接口并不需要禁止内核抢占,因为不管进程被切换到哪个 CPU 上执行,per_cpu_ptr所操作的都是第二个参数提供的 CPU。
8.4.3.3 遍历
尽管 percpu 变量的原则是 CPU 只操作自己所独占的 percpu 变量,但是软件上并没有做任何限制,每个CPU都可以通过接口或特定手段获取其他 percpu 变量的值并对其进行操作。内核提供的遍历接口:
c
for_each_cpu(cpu, mask)
per-cpu-operation....
- 第一个参数
cpu是一个临时变量,用于遍历时赋值。第二个参数mask是CPU位图(掩码),类型为struct cpumask。
简单起见用这个:
c
for_each_possible_cpu(i)
per-cpu-operation....