一、前言
在 Linux设备驱动 中,内存使用 是一个逃不掉的话题。Linux内核 的内存管理庞大且复杂,要想理解透彻需要花费不少的心思和时间,本文将简单的对 Linux设备驱动 中涉及到的部分 内存原理及使用 做一个简单的探讨。
二、正文
2.1 mm_struct
Linux内核 使用 内存描述符mm_struct 来描述进程的 用户虚拟地址空间,其主要成员如下:
struct mm_struct {
struct vm_area_struct *mmap; /* list of VMAs */
int map_count; /* number of VMAs */
struct rb_root mm_rb;
unsigned long mmap_base; /* base of mmap area */
unsigned long task_size;
pgd_t * pgd;
atomic_t mm_users;
atomic_t mm_count;
unsigned long start_code, end_code;
unsigned long start_data, end_data;
unsigned long start_brk, brk,
unsigned long start_stack;
unsigned long arg_start, arg_end, env_start, env_end;
mm_context_t context;
......
}
成员 | 说明 |
---|---|
mmap | 虚拟内存区域(VMA)链表 |
mm_rb | 虚拟内存区域(VMA)红黑树 |
map_count | 虚拟内存区域(VMA) 的 数量 |
task_size | 用户虚拟地址空间 的 长度 |
pgd | 指向 页全局目录(第一级页表) |
mm_users | 共享该 用户虚拟地址空间 的进程数量(即 线程组 包含的进程数量) |
mm_count | 内存描述符 的引用计数 |
mmap_base | 内存映射区域 的 起始地址 |
start_code | end_code | 代码段 的 起始地址 | 结束地址 |
start_data | end_data | 数据段 的 起始地址 | 结束地址 |
start_brk | brk | 堆 的 起始地址 | 结束地址 |
start_stack | 栈 的 起始地址 |
start_brk | brk | 堆 的 起始地址 | 结束地址 |
arg_start | arg_end | 参数字符串 的 起始地址 | 结束地址 |
env_start | env_end | 环境变量 的 起始地址 | 结束地址 |
context | 内存管理上下文(与处理器架构相关) |
在 进程描述符 中通常有 内存描述符(struct mm_struct) 成员来描述 进程内存
struct task_struct {
......
struct mm_struct *mm;
struct mm_struct *active_mm;
......
};
成员 | 说明 |
---|---|
mm | 指向 用户空间进程的内存描述符,内核线程 没有 用户虚拟地址空间,其 mm 为 空 |
active_mm | 一般情况下,active_mm 和 mm指向同一个 内存描述符 内核线程 的 active_mm 成员在没有运行时为 空,运行时时指向 借用进程的内存描述符 |
网络上有一张图片可以形象的描述 mm_struct ,如下:(出处来自文末的参考链接)
进程描述符 的 mm 和 active_mm 的区别:
如果当 用户进程 在运行时 发生 系统调用,程序会从 用户态 进入 内核态。内核态中会执行 内核线程。上面说到 内核线程 的 mm_struct 为 空,即 没有虚拟内存地址空间。 内核线程 不需要访问 用户进程地址空间,但是需要 页表 等数据信息来访问 内核空间。由于 所有用户进程的内核页表都是一样的 ,所以 内核线程 从 用户进程 那里 借来 一个 mm_strcut,以让 内核线程 能够访问 内核地址空间。 内核线程借来的mm_struct 即存放在 active_mm 中。而如果是 不要外部事件就自行运转的内核线程,其 active_mm 指向 上一个用户进程的mm_struct。-
内存描述符 的 mm_users 和 mm_count 的区别:
- mm_users 表示 正在引用地址空间 的 用户进程数目,比如 父进程 克隆出 子进程时,如果共享一个 mm_struct,此时 mm_users 即会 +1。
- mm_count 表示 正在引用地址空间 的 内核线程数目,比如 用户进程 进入 内核态 后有可能会执行 内核线程,此时 内核线程 会 借用 用户线程 的 mm_strcut,此时 mm_count 即会 +1。
2.2 内存映射
2.2.1 内核物理地址空间映射
CPU 通过 外围设备控制寄存器 来访问外设,寄存器 一般分为 控制寄存器、状态寄存器 和 数据寄存器。一般情况下的 寄存器 都是连续编址的。
由于 驱动程序 是通过 虚拟地址 来访问外设寄存器,所以需要通过内核接口实现 寄存器物理地址到虚拟地址 的映射,以让驱动程序访问 外设控制器
2.2.2 用户空间内存映射
内存映射 可以根据 数据源 为以下 2类:
- 文件映射:把 文件的某个区间 映射到进程的 虚拟地址空间,数据源为 文件。该情况下 物理页 称为 文件页
- 匿名映射:把 物理内存 映射到进程的 虚拟地址空间,无数据源。该情况下 物理页 称为 匿名页
如果根据 是否对其他进程可见 或 是否传递到底层文件 可以分为以下 2类:
- 共享映射:修改数据时,映射相同区域 的其他进程可以看见。如果是 文件映射,修改会同步到 文件 上
- 私有映射:第一次修改数据时会从 数据源 上复制副本,然后修改副本。其他进程不可见,修改不会同步到 文件
在进程的 虚拟地址空间 中的 代码段 和 数据段 是 私有的文件映射,按照笔者的理解就是 数据来源 为程序,但是修改不会同步到 程序。未初始化的数据段、堆和栈 是 私有的匿名映射,按照笔者的理解就是 没有数据来源 (因为不需要从程序上读取任何东西,符合这些 segment 的特性),且修改不会同步到 程序。
2.2.2 内存映射基本原理
PS:本节仅介绍基本原理,不对实现细节进行讲解,有兴趣的读者请自行阅读源码。
内存映射 一般分为 3个阶段:
- 进程启动映射,在 虚拟地址空间中为创建 虚拟映射区域(VMA)。
1.1. 用户进程 调用 mmap库函数。
1.2. 在进程的 虚拟地址空间 中,寻找一段 满足要求的、空闲的、连续的虚拟地址。
1.3. 为该段 虚拟地址 分配一个 VMA结构 并进行 初始化。
1.4. 将 VMA结构 插入进程的 mm_struct的链表或树中 。 - 内核空间执行 文件操作mmap,创建 物理地址 和 用户虚拟地址 的映射关系。
2.1. 找到文件的 文件结构体(struct file)。
2.2. 找到文件的 文件操作集file_operations,并执行其中的 mmap函数。
2.3. mmap函数 通过 inode结构体 定位到文件在 磁盘 上的 物理地址。
2.4. 通过 remap_pfn_range函数 建立 页表,即建立了 文件地址 和 虚拟映射区域(VMA) 的映射关系。注意,此时 VMA 并没有分配到实际的 物理内存 。 -
用户进程 访问 映射空间 并引发 缺页异常,实现 文件内容 到 物理内存 的拷贝
3.1. CPU 通过 MMU 的 translation table walking 机制引发 缺页异常
3.2. 内核 发起 请求调页过程
3.3. 调页过程 先在 交换缓存空间(swap cache) 中寻找 需要访问的内存页,如果 没有 则调用nopage函数 分配 物理页 并把内容读取到 物理页 中。
3.4. 用户进程 对 映射内存 进行 读写操作。如果 写操作 修改了内容,则一定时间后系统会自动回写 内存中的数据 到对应 磁盘地址。该过程有一定的 时间延迟,可以调用 msync 来 强制同步。
2.2.2 内存映射接口
1、用户接口
内存映射 有以下常用的 系统调用:
- mmap:用于 创建 内存映射,接口为 void *mmap(void *addr, size_t length, int prot, int flags,int fd, off_t offset)
1.1. 作用:
- 创建 匿名映射,把 物理内存 映射到进程的 虚拟地址空间
- 创建 文件映射,直接通过 访问内存 来访问文件。从而避免系统调用的切换,提高读写效率
- 创建 两个进程 对同一个文件的 共享内存映射
1.2. 重要参数:
- prot:即 保护位,如下:
宏 | 含义 |
---|---|
PROT_EXEC | 可执行 |
PROT_READ | 可读 |
PROT_WRITE | 可写 |
PROT_NONE | 不可访问 |
- flags:映射标志,如下:
宏 | 含义 |
---|---|
MAP_SHARED | 共享映射 |
MAP_PRIVATE | 私有映射 |
MAP_ANONYMOUS | 匿名映射 |
MAP_FIXED | 固有映射,指定映射地址 必须 为参数 addr |
MAP_HUGETLB | 使用 巨型页 |
MAP_LOCKED | 把页 锁 在内存中 |
MAP_NORESERVE | 不预留 物理内存 |
MAP_POPULATE | 分配并填充 页表,文件映射 使用该标志会导致 执行预读 |
MAP_NONBLOCK | 不阻塞,不执行 预读,只为已存在于内存中的页面建立页表入口 |
PS:书中说到MAP_NONBLOCK需要和MAP_POPULATE一起使用才有意义,但这2者是矛盾的。目前笔者还不甚了解,日后再补上
- mremap:用于 扩大或缩小 已经存在的内存映射
- mumap:用于 删除 内存映射
- mprotect:用于设置 虚拟内存区域 的 访问权限
PS:2 - 4接口的参数与 mmap 相似,这里不在赘述
2、内核接口
- ioremap:把 寄存器物理地址 映射到 内核虚拟地址空间
- iounmap:取消 地址映射
- remap_pfn_range:把 物理内存 映射到进程的 虚拟地址空间,即创建 页表项。实现 进程 和 内核 共享内存。接口如下:
int remap_pfn_range(struct vm_area_struct *, unsigned long addr, unsigned long pfn, unsigned long size, pgprot_t)
- io_remap_pfn_range:把 寄存器物理地址 映射到 用户虚拟地址空间,一般情况下与 remap_pfn_range。都是将 内核 可以访问的 地址 映射到 用户空间。接口如下:
static inline int io_remap_pfn_range(struct vm_area_struct *vma,
unsigned long vaddr,
unsigned long pfn,
unsigned long size,
pgprot_t prot)
驱动实现 mmap函数 时,一般需要调用 remap_pfn_range 或 io_remap_pfn_range,这 2 个函数都有 VMA结构体。
内核的mmap操作函数 接口如下:
int (*mmap) (struct file *, struct vm_area_struct *)
可以看到也带有 VMA结构体,这是操作系统内部通过查找获取到的有效 VMA区域,那么 内核映射接口 通过对该 VMA区域 进行映射即可。
VMA结构体 的部分代码如下:
struct vm_area_struct {
unsigned long vm_start;//虚拟地址区域起始地址
unsigned long vm_end;//虚拟地址区域结束地址,一般区级为[start, end)
struct vm_area_struct *vm_next, *vm_prev;//VMA结构体链表,按起始地址排序。用于组织一个进程的VMA结构体
struct rb_node vm_rb;//VMA结构体红黑树。用于组织一个进程的VMA结构体
struct mm_struct *vm_mm;//指向内存描述符
pgprot_t vm_page_prot;//保护位
struct list_head anon_vma_chain; //匿名VMA链表,用于组合本进程和父进程的所有匿名VMA
struct anon_vma *anon_vma; //该结构体用于组织匿名页被映射到的所有虚拟地址空间
const struct vm_operations_struct *vm_ops;//VMA操作集
unsigned long vm_pgoff;//文件偏移,在文件映射时有效
struct file * vm_file;//指向文件,在文件映射时有效
void * vm_private_data;
} __randomize_layout;
内核驱动在实现 mmap操作函数 时,需要对 VMA结构体 进行映射,该结构体中有一个 vm_ops成员,该成员是一些对 虚拟内存区域 操作的函数,其中一些重要的回调函数含义如下:
- open:在 创建虚拟内存区域 时调用该方法,通常不用,可设置为空
- close:在 删除虚拟内存区域 时调用该方法,通常不用,可设置为空
- mremap:在应用层调用 mremap系统调用 时会执行该回调
- fault:当执行 缺页异常中断 时会执行该回调。由于有可能访问的虚拟地址还没映射到物理页,所以会产生 缺页异常。
- huge_fault:与 fault方法 类似,在该函数的使用对象是 透明巨型页的文件映射
PS:在文末附有笔者的代码例程和注释
2.3 物理内存组织形式
在以前的 单CPU 架构中,对于内存的访问形式是比较直接的,直接通过地址跟数据总线即可访问内存,如下图所示:
但在 多处理器 架构种,对于内存的访问则复杂得多,其架构也有所不同。目前 多处理器内存访问架构 有以下两种:
-
非一致内存访问:即 Non-Uniform Memory Access(NUMA)。NUMA 将内存划分为多个 内存节点,每个 处理器 都有一个 直连 的 内存节点,也称为 本地内存节点,其余节点称为 远程内存节点。内存访问时间 取决于 处理器 和 内存节点 的距离。一般访问 本地内存节点 比 远程内存节点 要快。
一般 NUMA架构 用于 中高端服务器,其架构图如下所示:
-
对称对处理器:即 Symmetric Multi-Processor(SMP)。SMP 的 所有 处理器 共享内存子系统以及总线。每个 处理器 访问内存花费的时间是相同的,访问内存 时每个 处理器 地位都 平等。这种架构使得 每个处理器 能够共享 内存和其他资源,即意味着 处理器 在 内存 中的每个数据都只能 保持或共享 唯一的一个数值,即具有 一致性。所以 SMP 一般也称为 一致性访问,即 Uniform Memory Access(UMA)。SMP 的缺点就是当 处理器 足够多的时候,总线速度 会达到 饱和,此后就无法通过 增加处理器 来 提高性能。
SMP 架构如如下所示:
在 NUMA架构 下,内存管理子系统 在软件使用三级结构来描述 NUMA,即:
-
节点(node):需要分 2 种情况来看
- NUMA架构 的 内存节点 根据 处理器 和 内存 的 距离 来划分
- 在 不具备连续内存 的 UMA架构 中,根据物理地址是否连续来划分,每块 连续的物理地址内存 是一个 内存节点
-
区域(zone):即常见的 内存区域划分,一般分为:
- DMA区域
- 线性映射区域
- 高端内存区域
- 可移动区域:一般用于 反碎片技术
- 页(page):即 物理内存页
2.4 页分配器
Linux内核 使用了一种算法来实现 物理页分配器,用于负责管理 物理内存的分配和释放。其算法称为 伙伴算法,特点是 简单高效。所以也被称为 伙伴分配器。
2.4.1 页分配器算法原理
连续的物理页 称为 页块(page block), 个 连续页 称为 n阶页块,阶数 即 order。满足以下条件的 2个n阶页块 称为 伙伴(buddy)
- 两个页块是 相邻 的,即物理地址是 连续 的
- 页块的 第一页的物理页号 必须是 的 整数倍
- 如果合并成 (n+1)阶页块,那么 第一页的物理页号 必须是 的整数倍
即内核将 物理内存 分为 页 来管理,每个 物理页 都有 页号。以 单页 来举例,0号页 和 1号页 是伙伴,但 1号页 和 2号页 不是伙伴,因为 1号页 和 2号页 合并后 第一页的物理页号(即1) 不是 2的整数倍
伙伴分配器 分配和释放物理页 的数量单位是 阶,其过程如下:
- 查看是否有 空闲的n阶页块。如果有则直接分配,如果没有继续执行
- 查看是否存在 空闲的(n+1)阶页块。如果有,把 (n+1)阶页块 分裂为两个 n阶页块,一个插入空闲 n阶页块链表,另一个分配出去。如果没有则继续执行。
- 查看是否存在 空闲的(n+2)阶页块。如果有,把 (n+2)阶页块 分裂为两个 n+1阶页块 ,一个插入 空闲(n+1)阶页块链表,另一个分裂为2个 n阶页块,一个插入空闲 n阶页块链表,另一个分配出去。如果没有,则继续查看是否存在 更高阶的空白内存块 继续执行。
释放 n阶页块 时,需要查看它的 伙伴 是否 空闲,如果伙伴 不空闲,那么把 n阶页块 插入 空闲的n阶页块链表。如果 伙伴 是 空闲 的,那么合并为 (n+1)阶页块。
2.4.2 分配页
2.4.2.1 分配页接口
- alloc_pages(gfp_mask, order):用于请求分配一个 阶数为order 的页,返回一个 page实例。
- alloc_page(gfp_mask):用于请求分配一个 阶数为0 的页(即只分配 一页),返回一个 page实例。
- __get_free_pages(gfp_mask, order):对函数 alloc_pages 进行封装,只从 低端内存区域(线性映射区) 分配页,并且返回 虚拟地址。
- __get_free_page(gfp_mask):是函数 __get_free_pages 在 阶数为0 情况下的简化形式,只分配 一页。
- __get_zeroed_page(gfp_mask):是函数 __get_free_page 在 gfp_mask 设置为 __GFP_ZERO 且 阶数为0 的简化形式,只分配 一页 并 初始化为0。
2.4.2.2 分配标志位
分配页块时都带有 分配标志位,该标志位类型众多繁杂。虽然有些标志位并不常用,但为了能够更加全面的认识,笔者还是全部罗列出来。可以将其分为以下 5类:
-
区域修饰符:指定分配页的 区域 类型
- __GFP_DMA:从 DMA区域 分配页
- __GFP_HIGHMEM:从 高端内存区域 分配页
- __GFP_DMA32:从 DMA32区域 分配页
- __GFP_MOVABLE:从 可移动区域 分配页
-
页移动性和位置提示:指定页的 迁移类型 和 内存节点
- __GFP_MOVABLE:申请 可移动页
- __GFP_RECLAIMABLE:申请 可回收页
- __GFP_WRITE:指明调用者的 写物理页 意图。尽量把该类型的页分布到本地节点的 所有区域,避免所有 脏页 在一个 内存区域
- __GFP_HARDWALL:实施 cpuset内存分配策略(有兴趣的读者自行了解)
- __GFP_ACCOUNT:把分配的也记录在 内核内存控制组(有兴趣的读者自行了解)
- __GFP_THISNODE:强制从 本地节点 分配页
-
水线修饰符:
- __GFP_HIGH:指明调用者为 高优先级,系统必须通过请求
- __GFP_ATOMIC:指明调用者为 高优先级,不能回收页或者进入睡眠
- __GFP_MEMALLOC:允许访问所有内存
- __GFP_NOMEMALLOC:进制访问 紧急保留内存
-
回收修饰符:
- __GFP_IO:允许 读写存储设备
- __GFP_FS:允许向下调用到 底层文件系统。当 文件系统 申请页时,如果内存 严重不足,直接回收页,把 脏页 会写到 存储设备。为了避免调用 文件系统函数 可能会导致的 死锁,文件系统申请页的时候应该 清除 该标志位。
- __GFP_DIRECT_RECLAIM:调用者可以 直接回收页
- __GFP_KSWAPD_RECLAIM:当 空闲页数 达到 低水线 时,调用者想要唤醒 页回收线程kswapd(即 异步回收页)
- __GFP_RECLAIM:允许 直接回收页 和 异步回收页
- __GFP_RETRY_MAYFAI:允许重试,直到多次以后放弃,分配可能 失败
- __GFP_NOFAIL:必须无限次重试,因为调用者 不能处理分配失败
- __GFP_NORETRY:不要重试,当 直接回收页 和 内存碎片整理 不能使得分配成功的时候,应该放弃。
-
行动修饰符:
- __GFP_COLD:调用不期望分配的页很快被使用,尽可能分配 缓存冷页(即 数据在不处理器缓存中)
- __GFP_NOWARN:如果分配失败,不要打印告警信息
- __GFP_COMP:把分配的页组成 复合页(compound page)
- __GFP_ZERO:把 页 使用 0 进行初始化
2.4.2.3 分配标志位组合
以上是比较繁琐复杂的 分配标志,但往往 分配标志位 都是组合使用。常用的组合也已经在 内核 中定义好可以直接使用,如下所示:
PS:有关于宏的组合请有兴趣的读者到内核中去查看即可
- GFP_ATOMIC:原子分配 内核使用的页,不能睡眠。调用者为 高优先级,允许 异步回收页。
- GFP_KERNEL:分配 内核 使用的页,可能睡眠。从 低端内存区域 分配,允许 直接回收页 和 异步回收页,允许 读写存储设备,允许 调用到底层文件系统。
- GFP_NOWAIT:分配 内核 使用的页,不用等待。允许 异步回收页,不允许 直接回收页,不允许 读写存储设备,不允许 调用到底层文件系统。
- GFP_NOIO:不允许 读写存储设备,允许 直接回收页 和 异步回收页。
- GFP_NOFS:不允许 调用到底层文件系统,允许 读写存储设备,允许 直接回收页 和 异步回收页。
- GFP_USER:分配 用户空间 使用的页,内核或硬件 也可以直接访问,从 普通区域(线性映射区) 分配分配,允许 调用到底层文件系统,允许 读写存储设备,允许 直接回收页 和 异步回收页,允许 实施cpuset内存分配策略。
- GFP_HIGHUSER:分配 用户空间 使用的页,内核或硬件 不直接访问,从 高端区域 分配分配,物理页使用过程中 不可以移动。
- GFP_HIGHUSER_MOVABLE:分配 用户空间 使用的页,内核或硬件 不直接访问,从 高端区域 分配分配,物理页可以通过 页回收 或 页迁移技术移动。
- GFP_TRANSHUGE_LIGHT:分配 用户空间 使用的 巨型页,把分配的页块组成 复合页,禁止使用 紧急保留内存,禁止 打印告警信息,不允许 直接回收页 和 异步回收页。
- GFP_TRANSHUGE:和 GFP_TRANSHUGE_LIGHT 类似,不同之处在于允许 直接回收页
2.4.3 释放页
- __free_pages(page, order):参数 page 为 页实例,参数 order 为 阶数
- free_pages(addr, order):参数 addr 为 内核起始虚拟地址,参数 order 为 阶数
一般释放时是把 页的计数 减 1,直到为 0 时才释放页。如果页的阶数为 0,则将其作为 缓存热页 存储到某个区域中,不返回给 伙伴分配器,如果阶数 大于0 则释放
2.5 块分配器
页分配器 只能提高最小 4Kb 的内存页,但实际场景中往往会使用到 小于4Kb 的内存,这样会遭殃严重的内存浪费。为了解决 小块内存 的分配问题,Linux内核 提供 块分配器 。块分配器 使用 页分配器 申请出 物理页,然后对 物理页 进行切分,再将切分后的内存分配出去,减少内存浪费。
块分配器 为每种 对象类型 创建一个 内存缓存,每个 内存缓存 又分为多个 内存块(slab),一个 大块 由 一个或多个连续的物理页 组成。而每个大块有被切分为多个 对象。可以看出 块分配器 使用的是 面向对象 的思想,基于 对象类型 管理内存。其结构如下图所示:
块分配器 有几种实现方式,分别为:
- SLAB:适用于 大量物理内存 的机器
- SLUB:与 SLAB 相同,在 SLAB 的基础上进行优化,其内存开销比较小
- SLOB:适用于 小内存 的嵌入式机器
2.5.1 块分配器接口
虽然 块分配器 具有多种实现,但对外提供了统一接口,只是底层实现不同。
2.5.1.1 通用内存缓存接口
- kmalloc:用于 分配内存。块分配器找到一个合适的 通用内存缓存(即对象长度刚好大于或等于请求的内存长度),从中 分配对象 并返回 对象地址。
- krealloc:用于 重新 分配内存,根据新的长度为对象分配新的内存。
- kfree:释放内存
通用内存缓存接口 需要找到一个 对象长度刚好大于或等于请求的内存长度 的 通用缓存。如果请求的内存长度和申请到的内存缓存对象的长度相差太远会浪费较大的内存
2.5.1.2 专用内存缓存接口
为了解决 通用内存缓存的问题,可以使用 专用内存缓存。专用内存缓存 可以指定 对象长度,有效解决内存浪费的问题。其接口如下:
- keme_cache_create:创建内存缓存
- kmem_cache_destroy:销毁内存缓存
- kmem_cache_alloc:从内存缓存中分配对象
- kmem_cache_free:释放对象,将其放回内存缓存中
2.5.2 SLAB分配器
本小节将简单地介绍 SLAB分配器 的原理,有助理解内核的内存管理。
SLAB 的数据结构如图所示:
图中需要关注的数据结构有 3个:
- struct kmem_cache:即 内存缓存,所有操作都在该结构体上
- struct kmem_cache_node:即 内存缓存节点,每一个 内存节点 都对应一个该结构体
- struct page:即 page示例,一个 page实例 对应一个 物理页
2.5.2.1 struct kmem_cache
其结构体代码如下:
struct array_cache {
/* 数组entry存放的可用对象数量 */
unsigned int avail;
/* 数组entry的大小 */
unsigned int limit;
/* 对象地址数组,用于存放释放对象的指针 */
void *entry[];
};
struct kmem_cache {
/*
指向本地CPU高速缓存的指针数组。每个CPU在其中都有对应的结构体。
当有对象释放时,优先放入本地CPU高速缓存中
*/
struct array_cache __percpu *cpu_cache;
/* 每个SLAB拥有的对象数量 */
unsigned int num;
/* slab的阶数,代表slab拥有即2^gfporder个物理页 */
unsigned int gfporder;
/* 对象的原始长度 */
int object_size;
/*
每个对象都带有填充信息,以让块分配器可以进行某些操作
size = 填充信息长度 + object_size
*/
int size;
/* slab节点链表,每个内存节点都对应一个链表节点 */
struct kmem_cache_node *node[MAX_NUMNODES];
};
- 将 刚释放的对象 存放在 cpu_cache 可以有效的 减少链表操作和锁操作,并提高分配速度。
-
size 和 object_size 的关系如下图所示:
- 修改幻数1:长度 8字节,如果该值被修改说明对象被改写。该字段打开调试宏时才有
- 修改幻数2:与 修改幻数1 相同。该字段打开调试宏时才有
-
最后调用者地址:长度 8字节,用于确定对象被谁改写。该字段打开调试宏时才有
PS:关于这3个调试字段,有兴趣的读者自行查阅资料
2.5.2.2 struct kmem_cache_node
重要成员代码如下:
/* kmem_cache_node链表的节点,本质上就是一个page实例,即物理页 */
struct page {
/* 本质是一个数组,该指针指向第一个对象的地址 */
void *s_mem;
/* 本质是一个数组,其元素存放的是空闲对象在s_mem数组中的下标 */
void *freelist;
/*
active具有2层含义:
1. 记录该slab分配出去的对象数量
2. 是一个数组下标,指向freelist数组中的第一个空闲对象下标。该下标所在的元素是s_mem中对象的下表你
*/
unsigned int active;
/* 链表节点,用于串起多个slab */
struct list_head lru;
/* 指向所属的内存缓存 */
struct kmem_cache *slab_cache;
};
struct kmem_cache_node {
/* 只分配了部分对象的slab(page)链表 */
struct list_head slabs_partial;
/* 所有对象都已经分配出去的slab(page)链表 */
struct list_head slabs_full;
/* 所有对象都空闲的slab(page)链表 */
struct list_head slabs_free;
};
这里可能读者对于 freelist 和 s_mem 的关系有些许疑惑,笔者简单地做以下说明:
-
空闲阶段:此时没有分配任何对象,所以可以看到 s_mem 中的所有对象为 空闲, active 也为 0,表示没有对象分配出去。且 active 指向 freelist 的第一个元素,而 freelist 被 active 所指向的元素的值为 0,表明此时 s_mem 中下标为 freelistactive 的对象是 空闲的
-
分配阶段:此时分配出 1个对象,可以看到 s_mem 中的 对象0 已经被分配出去了。且 active 的值为 1,指向了 freelist 的元素也改变了
-
连续分配:经过多次使用,s_mem 已经分配出 3 个对象了,其中 active 的值为 3。指向了 freelist 的元素值为 3,s_mem[freelist[active]] 的对象是空闲的。以此类推
-
释放阶段:使用完了以后释放 对象0,可以看到 freelist[active] 的值为 0,那么 s_mem[freelist[active]] 的对象已经被释放且状态为 空闲
从代码中可以看出 freelist 仅仅只是一个 指针,那么就需要为这个指针 开辟内存空间。而关于存放该内存空间的地方也有一定的讲究,这里笔者简单地说明一下,请有兴趣的读者自行查阅资料。
- 在 s_mem 中分配出一个 对象 存放 freelist数组,即使用内部空间
- 重新在 s_mem 之外的地方开辟一个 数组 来存放 freelist数组,即使用外部空间
SLAB分配 会定时 回收对象和空闲slab,其实现方法是在 每个处理器 上向 全局工作队列 添加 延迟工作项,其处理函数为 cache_reap
一般来讲,SLAB 释放对象后,对象优先放在 CPU_cache。当 CPU_cache 满了以后再将 空闲对象 批量释放到 空闲SLAB。而 空闲SLAB 达到一定数量后再将 物理页 释放到 页分配器。
2.5.3 SLUB分配器
前面提到 SLAB分配器 释放 对象 时会优先回到 CPU_cache,这将造成一个问题。设想一下,如果 SLAB 迎来了一个 分配高峰期,则会从 页分配器 申请许多 SLAB(物理页)。之后这些 物理内存 将优先放回 CPU_cache 和 三个SLAB链表 中,而不是回到 页分配器,从而造成内存浪费。
为了解决该问题,内核提供了 SLUB分配器,其数据结构图下图所示:
相比于 SLAB分配器,SLUB分配器 有以下改进:
- SLUB分配器 的数据结构开销小
- SLUB分配器 仅保留了 部分空闲链表(partial_slab)
- SLUB分配器 不进行 着色(用于加快内存访问,有兴趣读者请自行查阅资料)
与 SLAB分配器 一样,重要的数据结构还是:
- kmem_cache
- kmem_cache_node
- page
2.5.3.1 kmem_cache
SLUB分配器 部分代码如下
struct kmem_cache_cpu {
/* 指向当前使用slab的空闲对线链表 */
void **freelist;
/* 指向当前使用的slab(page实例) */
struct page *page;
/* 指向当前每处理器部分空闲slab链表 */
struct page *partial;
};
struct kmem_cache {
/* 处理器缓存 */
struct kmem_cache_cpu __percpu *cpu_slab;
/* 对象长度,带填充信息 */
int size;
/* 对象原始长度 */
int object_size;
/*
低16位为最优对象数量
高16位为最优阶数
*/
struct kmem_cache_order_objects oo;
/*
低16位为最小对象数量
高16位为最小阶数
当设备长时间运行后,内存碎片化,分配连续的物理页很难成功。如果分配最优阶数的slab失败,则分配最小阶数slab
*/
struct kmem_cache_order_objects min;
/* 内存节点 */
struct kmem_cache_node *node[MAX_NUMNODES];
};
与 SLAB分配器 同理,对象所具备的信息不只是对象本身,还附带有其他填充信息。SLUB分配器 的一种(SLUB分配器有多种对象布局,本文按照下面这种来讲述)填充信息如下图所示:
除了对象之外的其余 填充信息 均要打开 调试宏 才具备,其功能与 SLAB分配器 类似,需要注意的是 SLUB对象 将 最后调用者地址 修改为 分配者地址 和 释放者地址。
SLAB分配器 的 CPU_cache(每处理器缓存) 是以 对象 为单位,而 SLUB分配器 的 CPU_slab(每处理器缓存) 则以 SLAB 为单位
2.5.3.2 kmem_cache_node
SLUB分配器 的 kmem_cache_node 结构复用了 SLAB分配器, 其部分重要成员如下:
struct kmem_cache_node {
/* 空闲slab的数量 */
unsigned long nr_partial;
/* 空闲slab链表,链表节点为slab,即page实例 */
struct list_head partial;
};
2.5.3.3 page
SLUB分配器 也复用了 struct page 来作为 链表节点,其部分成员如下:
struct page {
/* 一般设置为 PG_SLAB << 1 来表示该页属于 SLUB分配器 */
unsigned long flags;
/* 空闲对象链表 */
void *freelist;
struct {
/* 已分配对象的数量 */
unsigned inuse:16;
/* 对象数量 */
unsigned objects:15;
/* 表示当前page实例是否被冻结在cpu_slab中 */
unsigned frozen:1;
};
/* 链表节点 */
struct list_head lru;
/* 指向所属的kmem_cache */
struct kmem_cache *slab_cache;
}
SLAB分配器 的 空闲链表是一个数组,而 SLUB分配器 的 空闲链表 则是一个货真价实的链表。对象 中的 空闲指针 指向了 下一个对象的地址,从而将所有 对象 串起来。空闲链表 的几种状态如下:
-
空闲状态:
-
分配状态:修改 freelist指针的指向,如下图
释放时将对象继续插入回 freelist链表。
2.5.3.4 CPU_slab
SLUB分配器 的 CPU_slab 结构图如下所示:
struct kmem_cache_cpu {
/* 指向当前使用slab的空闲对线链表 */
void **freelist;
/* 指向当前使用的slab(page实例) */
struct page *page;
/* 指向当前每处理器部分空闲slab链表 */
struct page *partial;
};
struct kmem_cache {
struct kmem_cache_cpu *cpu_slab;
/* 处理器缓存 */
int CPU_partial
};
一些重要数据结构的说明如下:
- CPU_slab 中的 page成员 指向了当前正在使用的 slab。而 partial 指向了 等待使用的部分空闲slab,并且串成链表。
- partial链表 的 第一个slab 中的 pages 表明了该链表中 slab的数量,pobjects 表明链表中 空闲对象的数量。(请注意链表后面的slab并没有使用这2个成员)
- 内存缓存kmem_cache 中的 cpu_partial 决定了 partial链表 中 空闲对象的数量
上面简单讲了数据结构之间的关系,下面我们看看 分配对象 和 释放对象 时如何操作 cpu_slab。
-
分配对象:
- 从 当前处理器 的 cpu_slab 分配,如果 当前使用的slab 有 空闲对象,那么分配一个对象。
- 如果 当前处理器的cpu_slab链表 没有 空闲对象,那么取 partial链表 中的 第一个slab 作为当前使用的slab,并继续分配对象
-
释放对象:
- 如果 对象 所属的 slab 在此之前没有 空闲对象,并且 没有冻结 在 cpu_slab 中,那么把该 slab 存放到 partial链表 中
- 如果发现 partial链表 中的 空闲对象总数 超过了 cpu_partial,那么把 partial链表 中的 所有slab 归还到 内存节点 的 部分空闲slab链表 中。
可以发现,释放对象 时会检查 slab 的 空闲对象数量,并据此进行操作。这样做的好处是:优先使用空闲对象少的slab,并且从中分配对象,减少内存浪费
2.6 不连续内存分配
当设备长时间裕兴后,内存会碎片化,很难找到 连续的物理也。如果在这种情况下仍需要分配出 长度超过一页的内存块,可以使用 不连续分配器。使用 不连续分配器 分配 内存页 在 虚拟地址 上是 连续的,而在 物理地址 上 不连续。
而且 不连续分配器 是优先从 高端区域 分配虚拟地址,减少了 常规线性区域 的使用。
其实现原理是使用 MMU 建立 *物理地址 到 虚拟地址 的映射,并使用方法确保 不连续的物理地址 按照固定的映射关系映射到 连续的虚拟地址。
PS:不连续内存分配器的映射单位为物理页,小于物理页尺寸的内存无法映射到连续的虚拟地址上,只能将多个不连续的物理页映射到连续的虚拟地址上
其接口如下:
vmalloc:分配 不连续的物理页 并将 物理页地址 映射到 连续的虚拟地址
vfree:使用内存
-
vmap(pages, count, flags, prot):把已经分配的 不连续页 映射到 连续的虚地址,参数如下
- pages:page实例 的指针数组
- count:指针数组 的大小
- flags:标志位
- prot:保护位
vunmap:释放映射的 虚拟地址
kvmalloc:优先使用 kmalloc 分配内存,如果失败再使用 vmalloc
kvfree:释放 kvmalloc 分配的内存
那么以上就是本文的所有内容,只是简单地对内核的内存管理进行整理,梳理出框架脉络。本文内容仅占Linux内存管理的冰山一角,有兴趣的读者请自行查阅其他资料补齐知识框架
三、附录
3.1 例程代码
/* 内核驱动 */
#include <linux/module.h>
#include <linux/init.h>
#include <linux/fs.h>
#include <linux/device.h>
#include <linux/slab.h>
#include <linux/cdev.h>
#include <linux/err.h>
#include <linux/mm_types.h>
#include <asm/uaccess.h>
#include <linux/io.h>
#include <linux/platform_device.h>
#include <linux/kern_levels.h>
#include <linux/ioport.h>
#include <linux/of_address.h>
#include <linux/of_irq.h>
#include <linux/interrupt.h>
#include <linux/device.h>
#include <linux/uaccess.h>
#define DEVICE_MM_SIZE 4096
struct test_device
{
dev_t n_dev;
struct cdev cdev;
struct class *dev_class;
struct device *dev;
u8* share_buffer;
u32 buffer_size;
};
static int open(struct inode* inode, struct file* filp)
{
printk("device open, filp = %p, f_owner.pid = %p\n", filp, filp->f_owner.pid);
filp->private_data = (void*)container_of(inode->i_cdev, struct test_device, cdev);
return 0;
}
static ssize_t read(struct file* filp, char __user * buf, size_t size, loff_t* ppos)
{
int ret = 0;
struct test_device* test_device = (struct test_device*)filp->private_data;
if(size > test_device->buffer_size)
{
printk("size(%d) is out of range(%d)\n", size, test_device->buffer_size);
return -1;
}
ret = copy_to_user(buf, test_device->share_buffer, size);
if(-1 == ret)
{
printk("read failed\n");
return -1;
}
return size;
}
static ssize_t write(struct file* filp, const char __user * buf, size_t size, loff_t* ppos)
{
int ret = 0;
struct test_device* test_device = (struct test_device*)filp->private_data;
if(size > test_device->buffer_size)
{
printk("size(%d) is out of range(%d)\n", size, test_device->buffer_size);
return -1;
}
ret = copy_from_user(test_device->share_buffer, buf, size);
if(-1 == ret)
{
printk("write failed\n");
return -1;
}
return size;
}
static int mmap(struct file* filp, struct vm_area_struct* vma)
{
struct test_device* test_device = (struct test_device*)filp->private_data;
vma->vm_flags |= VM_IO;
if(remap_pfn_range(vma,
vma->vm_start,
virt_to_phys(test_device->share_buffer) >> PAGE_SHIFT,
vma->vm_end - vma->vm_start,
vma->vm_page_prot))
{
return -EAGAIN;
}
return 0;
}
static struct file_operations test_fops =
{
.owner = THIS_MODULE,
.open = open,
.read = read,
.write = write,
.mmap = mmap,
};
static int probe(struct platform_device *pdev)
{
int ret = 0;
struct test_device* test_device = NULL;
/* 1. 为设备数据分配空间 */
test_device = devm_kzalloc(&pdev->dev, sizeof(struct test_device), GFP_KERNEL);
if(!test_device)
{
printk("can't create test_device\n");
goto ALLOC_FAIL;
}
platform_set_drvdata(pdev, test_device);
/* 2.1 分配设备号 */
int base_minor = 0;
int n_num = 1;
ret = alloc_chrdev_region(&test_device->n_dev, base_minor, n_num, "test_device");
if(0 != ret)
{
printk(KERN_INFO"can't alloc a chrdev number\n");
goto REGION_FAIL;
}
printk("major = %d, minor = %d\n", MAJOR(test_device->n_dev), MINOR(test_device->n_dev));
/* 2.2 初始化字符设备 */
int dev_count = 1;
cdev_init(&test_device->cdev, &test_fops);
test_device->cdev.owner = THIS_MODULE;
ret = cdev_add(&test_device->cdev, test_device->n_dev, dev_count);
if(0 != ret)
{
printk(KERN_INFO"add a cdev error\n");
goto CDEV_FAILE;
}
/* 2.3 创建设备,发出uevent事件 ,在/sys/class/目录下创建设备类别目录dev_class */
test_device->dev_class = class_create(THIS_MODULE, "dev_class");
if(IS_ERR(test_device->dev_class))
{
printk(KERN_INFO"create a class error\n");
goto CDEV_FAILE;
}
/* 2.4 在/dev/目录和/sys/class/gpio_class目录下分别创建设备文件gpio_dev */
test_device->dev = device_create(test_device->dev_class, NULL, test_device->n_dev, NULL, "test_dev");
if(IS_ERR(test_device->dev))
{
printk(KERN_INFO"create a device error\n");
goto CLASS_FAILE;
}
test_device->buffer_size = DEVICE_MM_SIZE;
#if 1
/*
请注意,要进行mmap映射的内存地址必须是4k对齐,不然应用层使用mmap映射后的地址可能是不对的。
笔者理解是:因为remap_pfn_range调用会对地址进行检查,将地址映射到4k对齐的虚拟地址。
举个例子,假如该内存地址是不对齐的,比如为0x0000fb10,那么有可能会导致地址映射到0x0000fb00,这样导致应用层映射到的地址是从0x0000fb00开始
*/
test_device->share_buffer = kmalloc(test_device->buffer_size, GFP_KERNEL);
#else
/*
使用devm_kzalloc会导致出来的地址并不是4k对齐,从而导致应用层会发生映射错误
笔者一开始使用的是devm_kzalloc分配,分配出来的地址为0xc3b96010,从而导致应用层读取错误,需要加上16的便宜才能访问到正确的地址
修改为kmalloc,分配出来的地址是0xb6f9a000,该地址是4k对齐,所以应用层映射后的地址是正确的。
*/
test_device->share_buffer = devm_kzalloc(&pdev->dev, test_device->buffer_size, GFP_KERNEL);
#endif
if(!test_device->share_buffer)
{
printk("can't create share buffer\n");
goto ALLOC_BUFFER_FAIL;
}
return 0;
ALLOC_BUFFER_FAIL:
device_destroy(test_device->dev_class, test_device->n_dev);
CLASS_FAILE:
class_destroy(test_device->dev_class);
CDEV_FAILE:
unregister_chrdev_region(test_device->n_dev, n_num);
REGION_FAIL:
devm_kfree(&pdev->dev, test_device);
ALLOC_FAIL:
return -1;
}
static int remove(struct platform_device *pdev)
{
struct test_device* test_device = (struct test_device*)platform_get_drvdata(pdev);
int n_num = 1;
#if 1
kfree(test_device->share_buffer);
#else
devm_kfree(&pdev->dev, test_device->share_buffer);
#endif
device_destroy(test_device->dev_class, test_device->n_dev);
class_destroy(test_device->dev_class);
cdev_del(&test_device->cdev);
unregister_chrdev_region(test_device->n_dev, n_num);
devm_kfree(&pdev->dev, test_device);
return 0;
}
static const struct of_device_id of_dev_match[] = {
{ .compatible = "device_node", .data = NULL},
{},
};
static struct platform_driver dev_driver = {
.probe = probe,
.remove = remove,
.driver = {
.name = "dev_driver",
.of_match_table = of_dev_match,
},
};
module_platform_driver(dev_driver);
MODULE_LICENSE("GPL");
/* 应用代码 */
#include <sys/mman.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#include <strings.h>
#define MM_SIZE 4096
int main(int argc, char* argv[])
{
int dev_fd = 0;
char* buf_addr = NULL;
char buffer[MM_SIZE] = {0};
dev_fd = open("/dev/test_dev", O_RDWR);//必须设置为O_RDWR,不然无法使用MAP_SHARED映射标志
if(-1 == dev_fd)
{
printf("open device error\n");
return -1;
}
buf_addr = mmap(NULL, MM_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, dev_fd, 0) + 16;
strcpy(buf_addr, "read_device_mmap");
bzero(buffer, MM_SIZE);
int ret = read(dev_fd, buffer, MM_SIZE);
printf("read buffer: %s\n", buffer);
bzero(buffer, MM_SIZE);
write(dev_fd, buffer, MM_SIZE);
write(dev_fd, "write_device_mmap", strlen("write_device_mmap"));
printf("write buffer: %s\n", buf_addr);
munmap(buf_addr, MM_SIZE);
}
3.2 参考链接
mm_struct
认真分析mmap:是什么 为什么 怎么用
linux内核线程的问题
CPU与内存互联的架构演变
Linux系统调用--mmap/munmap函数详解
linux用户态和kernel之间共享内存
io_remap_pfn_range
linux内存管理 之 内存节点和内存分区
linux内存源码分析 - SLAB分配器概述
Linux内存管理 (5)slab分配器
Linux slab 分配器剖析
slab为什么要进行着色处理
slab着色区的作用
SLUB和SLAB的区别
Linux驱动mmap内存映射