Linux Device Drivers-第八章 内存分配

第八章 内存分配

本章主讲设备驱动程序中使用内存的一些其他方法。等第十五章再讨论关于分段分页等描述内存管理的内部细节。

8.1 kmalloc 函数内幕

kmalloc 函数功能强大,不对内存空间清零(这意味着我们要将申请到的内存显式的清零后再用),且分配的区域在物理内存中连续 (这是kmallocvmalloc 最本质的区别,kmallocvmalloc一样返回的内存地址都是虚拟地址,由MMU内存管理单元处理才能转化为物理内存地址)。函数原型:

c 复制代码
#include <linux/slab.h>
void *kmalloc(size_t size, int flags);
// 与之匹配的释放函数
void kfree(const void *objp);
  • size: 分配块的大小。

  • flags:

    1. GFP_KERNEL:最常用,可以睡眠。(持有自旋锁或中断处理中千万不要用)
    2. GFP_ATOMIC :不能睡眠(用于中断、软中断等原子上下文)。
    3. GFP_USER:用来为用户空间页来分配内存; 它可能睡眠.
    4. __GFP_NOFAIL:分配失败反复申请,千万不要用。现在的最佳实践是:分配失败就返回错误码,让上层逻辑处理。
    5. GFP_NOFSGFP_NOIO 用于防止递归死锁。
    6. __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

驱动开发者需知

  1. 不要使用用你申请内存得到的多余的部分 :如果你申请 10 字节,内核可能会给你 32 或 64 字节(取决于架构和缓存行大小)。不要利用这部分多出来的空间存储额外数据,这是未定义行为。
  2. 警惕小对象浪费 :如果你有大量极小的数据结构(比如几个字节),直接用 kmalloc 会造成巨大的内存浪费(内部碎片)。(对于大量同类型的小对象,驱动开发者应该学习使用 自定义 SLAB 缓存
  3. kmalloc 有上限,通常不能指望分配大于 128KB 的内存。(如果你需要 > 128KB 的内存,且不需要物理连续(例如用于视频缓冲、大文件读写),请使用 vmalloc(虚拟地址连续,物理不连续)。)
  4. 如果你需要 > 128KB 且必须物理连续:需要使用 CMA 或预留内存,普通的 kmalloc 无法满足。

8.2 后备高速缓存 - Cache

8.2.1 后备高速缓存-高性能驱动必备

Linux内核提供了后备缓存机制,用来创建一组相同大小对象的内存池。应用在反复分配许多相同大小内存的场景。如 USB 和 SCSI 驱动等。Linux内核的缓存管理者为------slab分配器。(目标:学会 kmem_cache_createkmem_cache_allockmem_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_fnfree_fn管理对象的实际分配和释放,这两个函数定义规范如下所示:

    c 复制代码
    typedef void * (mempool_alloc_t)(gfp_t gfp_mask, void *pool_data);
    typedef void (mempool_free_t)(void *element, void *pool_data);
  • pool_data被传入alloc_fnfree_fn

如果有必要,用户是可以按照上面alloc_fnfree_fn的规范来自行构造特定的函数处理内存的分配和释放的,但是我们通常都让 slab 分配器为我们做了这个任务。你去看内核</linux/mempool.h>文件,其中有两个函数mempool_alloc_slabmempool_free_slab已经符合了对象分配和释放定义规范,且提供了mempool_create的包装mempool_create_slab_pool,并利用</linux/slab.h>文件提供的kmem_cache_createkmem_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 分配(虚拟连续,物理不连续)。
  • 优势:
    • 在小内存时享受 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中的关键字,通常作为修饰符,使用方式类似于conststatic,为符号添加属性。

__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_varget_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....
相关推荐
AlfredZhao14 小时前
vi 删除指定范围的行,不用再反复按 dd
linux·vi
用户97183563346620 小时前
银河麒麟 KY10 申威(SW64) 安装 nginx-1.16.1-2.p01.ky10.sw_64.rpm 详细步骤
linux
猪脚踏浪21 小时前
linux 拷贝文件或目录到指定的位置
linux
摇滚侠2 天前
Linux CentOS7 rpm 安装 MySQL 5.7
linux·运维·mysql
bush42 天前
嵌入式linux学习记录十四、术语
linux·嵌入式
载数而行5202 天前
Linux 11 动态监控指令top
linux
不会C语言的男孩2 天前
Linux 系统编程 · 第 8 章:进程基础
linux·c语言
古城小栈2 天前
Unix 与 Linux 异同小叙
linux·服务器·unix
凡人叶枫2 天前
Effective C++ 条款42:了解 typename 的双重意义
java·linux·服务器·c++
2601_961875242 天前
决战申论100题2026|最新|范文
linux·容器·centos·debian·ssh·fabric·vagrant