前 言
本章的内容是围绕Linux物理页分配算法中的伙伴算法来讲解的,但是,直接引入这个算法未免有点过于牵强,因此我们先来分析一下什么时候开始进行物理页面的分配:(本文中的源码均来自内核版本5.15)
Linux采用的是“请求调页”机制,请求调页是一种动态内存分配技术,它把页面的分配推迟到不能再推迟为止,也就是一直推迟到进程要访问的页不在物理内存时为止,由此引起一个缺页异常,缺页异常处理函数接下来才开始真正进行分配物理内存空间的工作,而我们今天要说的就是在缺页异常处理函数中调用的真正分配物理内存空间的函数get_free_pages( )的具体实现以及它所用的伙伴算法。
一、准备工作
在讲解分配算法之前,我们需要明白CPU访问的地址并不是物理内存中的实地址,而是虚拟地址空间中的虚拟地址。所以对于内存页面的管理,通常是先分配虚存空间,然后才根据需要为此虚存空间分配真实的物理内存页并建立映射。
1、页描述符
那么内核是如何表示系统中的每个物理页呢?内核是通过一个结构 struct page页描述符来表示的,通过查看源码,我们找到了它的定义:(目录为:/include/linux/mm_types.h)
struct page {
// 用于页面的原子标记,可能会异步更新
unsigned long flags;
// 下面的union包括了多种可能的页面用途。对于不同的页面类型,内核会使用这个union的不同部分。
union {
// 用于页面缓存和匿名页面
struct {
// lru列表,用于页面替换算法
struct list_head lru;
// 与页面关联的地址空间对象的指针
struct address_space *mapping;
// 在映射中的偏移量
pgoff_t index;
// 私有数据字段。对于不同的页面类型,它的意义也不同
unsigned long private;
};
// 用于网络堆栈的页面池
struct {
// 一个魔法值,用于确保只回收由page_pool分配的页面
unsigned long pp_magic;
struct page_pool *pp;
unsigned long _pp_mapping_pad;
unsigned long dma_addr;
// 一些DMA相关的信息
union {
unsigned long dma_addr_upper;
atomic_long_t pp_frag_count;
};
};
// 用于slab, slob 和 slub分配器的页面
struct {
union {
struct list_head slab_list;
struct {
struct page *next;
#ifdef CONFIG_64BIT
int pages;
int pobjects;
#else
short int pages;
short int pobjects;
#endif
};
};
// 指向kmem_cache的指针,用于slub/slab分配
struct kmem_cache *slab_cache;
// freelist的头指针
void *freelist;
union {
void *s_mem;
unsigned long counters;
struct {
unsigned inuse:16;
unsigned objects:15;
unsigned frozen:1;
};
};
};
// 复合页面的尾页面
struct {
unsigned long compound_head;
unsigned char compound_dtor;
unsigned char compound_order;
atomic_t compound_mapcount;
unsigned int compound_nr;
};
// 第二个复合页面的尾页面
struct {
unsigned long _compound_pad_1;
atomic_t hpage_pinned_refcount;
struct list_head deferred_list;
};
// 用于页表页面
struct {
unsigned long _pt_pad_1;
pgtable_t pmd_huge_pte;
unsigned long _pt_pad_2;
union {
struct mm_struct *pt_mm;
atomic_t pt_frag_refcount;
};
#if ALLOC_SPLIT_PTLOCKS
spinlock_t *ptl;
#else
spinlock_t ptl;
#endif
};
// 用于ZONE_DEVICE页面
struct {
struct dev_pagemap *pgmap;
void *zone_device_data;
};
// RCU释放的页面可以使用这个字段
struct rcu_head rcu_head;
};
union {
atomic_t _mapcount;
unsigned int page_type;
unsigned int active;
int units;
};
// 页面的引用计数
atomic_t _refcount;
#ifdef CONFIG_MEMCG
unsigned long memcg_data;
#endif
#if defined(WANT_PAGE_VIRTUAL)
// 如果页面被映射到内核虚拟地址空间,则此字段包含其虚拟地址
void *virtual;
#endif
#ifdef LAST_CPUPID_NOT_IN_PAGE_FLAGS
int _last_cpupid;
#endif
} _struct_page_alignment;
可以看出真的蛮复杂的,但是不要怕,由于我们现在的目的是去分析伙伴算法,故其他的内容我们先忽略,我来调一下重点字段来讲:
flags:用来存放页的状态,包括页是不是脏的、是不是被锁定在内存中的。
_refcount:存放页面被引用的次数。
virtual:存放该物理页被映射到的虚拟地址。
list_head lru:指向最近最久未使用(LRU)链表中的相应结点,这个链表用于页面的回收。
目前,我们知道了Linux是如何对物理页进行描述了,可是接下来考虑一个问题,物理内存分页后,会产生很多page结构,那这么多的页面Linux是如何进行管理的呢?很简单,系统在初始化时建立一个page类型的数组,这个数组描述了系统中的全部物理页面。
二.伙伴算法----理论讲解
进程要分配一个大块的连续页面,就可以采用伙伴(Buddy)算法来进行分配。伙伴算法的描述如下:
伙伴算法是用于分配和回收固定大小的连续内存块的方法。其核心思想是将可用的物理内存划分为大小为2的整数次幂的块。而伙伴的意思是:大小相同、物理地址连续的两个页块被称为伙伴。伙伴系统采用一个free_area数组,来记录空闲的物理页:
举例:
- 当请求一个大小为
2
的内存块时(也就是请求两个页面),系统会找数组下标为1的位置(因为2的1次方为2),然后为请求者分配这样一个2大小的块,并修改链表。- 如果下标为1的位置找不到,就顺序往后找,直到找到为止,然后分配给请求者,再将多余的块挂在数组对应的位置。
- 当释放一个块时,系统会检查与该块相邻的“伙伴”是否也是空闲的。如果是,那么这两个“伙伴”会被合并成一个更大的块。
结合Linux的具体实现,我们来看一下free_area 数组:
//free_area数组的最大值为11
#define MAX_ORDER 11
//定义了链表
struct free_area {
struct list_head free_list[MIGRATE_TYPES];
unsigned long nr_free;
};
//创建了一个free_area类型的数组free_area,也就是上图中的数组
struct zone {
......
struct free_area free_area[MAX_ORDER];
......
}
这个算法在理论上比较好理解,核心就是在维护这个free_area数组,我就不详细赘述了,接下来我们来看一下Linux内核5.15具体是怎么实现的。
三.伙伴算法----源码分析
1.__get_free_pages():(/mm/page_alloc.c)
unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order)
{
struct page *page;
page = alloc_pages(gfp_mask & ~__GFP_HIGHMEM, order);
if (!page)
return 0;
return (unsigned long) page_address(page);
}
发现分配物理页面的工作交给了alloc_pages()函数,那我们继续跟进。
2.alloc_pages()(/mm/mempolicy.c)
// 定义alloc_pages函数,它接受两个参数:一个是内存分配标志(gfp_t)和一个是页的数量(2的order次方)。
struct page *alloc_pages(gfp_t gfp, unsigned order)
{
// 初始化一个指向默认内存策略的指针
struct mempolicy *pol = &default_policy;
// 声明一个指向页结构的指针
struct page *page;
// 检查当前是否在中断上下文中,以及是否为特定节点分配页面
// 如果不在中断中并且没有为特定节点请求页面,则获取当前任务的内存策略
if (!in_interrupt() && !(gfp & __GFP_THISNODE))
pol = get_task_policy(current);
// 检查内存策略的模式
// 如果是交叉模式(INTERLEAVE),则在多个节点上交替分配页面
if (pol->mode == MPOL_INTERLEAVE)
page = alloc_page_interleave(gfp, order, interleave_nodes(pol));
// 如果是多首选节点模式,根据指定的多首选节点策略分配页面
else if (pol->mode == MPOL_PREFERRED_MANY)
page = alloc_pages_preferred_many(gfp, order,
numa_node_id(), pol);
// 对于其他策略,使用默认的__alloc_pages方法,并传入相关的节点和nodemask信息
else
page = __alloc_pages(gfp, order,
policy_node(gfp, pol, numa_node_id()),
policy_nodemask(gfp, pol));
// 返回所分配的页面的指针
return page;
}
假设我们的策略不是交叉模式或者多首选节点模式,则进入到__alloc_pages()函数继续分析。
3.__alloc_pages():
// 定义__alloc_pages函数,该函数根据给定的参数尝试从系统中分配一组连续的页面
struct page *__alloc_pages(gfp_t gfp, unsigned int order, int preferred_nid,
nodemask_t *nodemask)
{
// 声明一个指向页结构的指针
struct page *page;
// 设置默认的分配标志为ALLOC_WMARK_LOW
unsigned int alloc_flags = ALLOC_WMARK_LOW;
// 用于记录实际用于分配的gfp_t标志
gfp_t alloc_gfp;
// 初始化一个分配上下文结构
struct alloc_context ac = { };
// 检查请求的order是否超出了系统允许的最大值
// MAX_ORDER是系统中允许的最大页面数量的常量
if (unlikely(order >= MAX_ORDER)) {
// 如果请求的order超出范围,并且没有设置__GFP_NOWARN标志,发出警告
WARN_ON_ONCE(!(gfp & __GFP_NOWARN));
// 返回NULL表示分配失败
return NULL;
}
// 使用gfp_allowed_mask掩码来清除不允许的gfp标志
gfp &= gfp_allowed_mask;
// 获取当前任务的gfp上下文,主要考虑到GFP_NOFS、GFP_NOIO等标志的继承
gfp = current_gfp_context(gfp);
alloc_gfp = gfp;
// 准备页面分配:设置相关的上下文、节点和其他参数
if (!prepare_alloc_pages(gfp, order, preferred_nid, nodemask, &ac,
&alloc_gfp, &alloc_flags))
return NULL;
// 禁止首次分配尝试回退到可能会造成内存碎片化的类型,直到考虑了所有本地区域
alloc_flags |= alloc_flags_nofragment(ac.preferred_zoneref->zone, gfp);
// 进行第一次分配尝试
page = get_page_from_freelist(alloc_gfp, order, alloc_flags, &ac);
if (likely(page))
goto out;
// 重置alloc_gfp为原始的gfp标志
alloc_gfp = gfp;
ac.spread_dirty_pages = false;
// 恢复原始的节点掩码,特别是如果它可能在快速路径尝试中被替换
ac.nodemask = nodemask;
// 使用慢路径进行页面分配
page = __alloc_pages_slowpath(alloc_gfp, order, &ac);
out:
// 如果启用了内存控制组的内核内存计费,并且分配成功,则尝试对页面进行计费
if (memcg_kmem_enabled() && (gfp & __GFP_ACCOUNT) && page &&
unlikely(__memcg_kmem_charge_page(page, gfp, order) != 0)) {
// 如果计费失败,则释放页面并设置page为NULL
__free_pages(page, order);
page = NULL;
}
// 使用trace宏记录页面分配事件
trace_mm_page_alloc(page, order, alloc_gfp, ac.migratetype);
// 返回分配的页面,或者在分配失败时返回NULL
return page;
}
注意这行代码:page = get_page_from_freelist(alloc_gfp, order, alloc_flags, &ac),这行代码就是开始从free_area中开始分配物理页了。
4. get_page_from_freelist():
get_page_from_freelist(gfp_t gfp_mask, unsigned int order, int alloc_flags,
const struct alloc_context *ac)
{
.......
// 为当前的zone尝试分配页面
try_this_zone:
// 从优先区域中的rmqueue中请求页面
page = rmqueue(ac->preferred_zoneref->zone, zone, order,
gfp_mask, alloc_flags, ac->migratetype);
// 检查是否成功获得了一个页面
if (page) {
// 准备新分配的页面
prep_new_page(page, order, gfp_mask, alloc_flags);
// 如果这是一个高阶原子分配,则检查是否应该为将来保留页面块
if (unlikely(order && (alloc_flags & ALLOC_HARDER)))
reserve_highatomic_pageblock(page, zone, order);
// 返回新分配的页面
return page;
} else {
#ifdef CONFIG_DEFERRED_STRUCT_PAGE_INIT
// 如果有延迟初始化的页面,则尝试再次为该区域分配页面
if (static_branch_unlikely(&deferred_pages)) {
if (_deferred_grow_zone(zone, order))
goto try_this_zone;
}
#endif
}
// 如果已经尝试了所有区域而没有找到可用的页面,并且是在避免碎片化的模式下
// 则重置并再次尝试
if (no_fallback) {
alloc_flags &= ~ALLOC_NOFRAGMENT;
goto retry;
}
// 如果无法分配页面,则返回NULL
return NULL;
}
.......
}
终于,我们分析到了具体的分配页面函数rmqueue(),顾名思义,这个方法就是伙伴算法的核心,接下来看一下这个函数的实现。
5.rmqueue():
// 从给定的zone中移除页面的函数定义
struct page *rmqueue(struct zone *preferred_zone,
struct zone *zone, unsigned int order,
gfp_t gfp_flags, unsigned int alloc_flags,
int migratetype)
{
unsigned long flags; // 用于保存中断状态
struct page *page; // 用于返回找到的页面
// 检查是否允许从per-CPU列表中分配页面
if (likely(pcp_allowed_order(order))) {
// CMA (连续内存分配)的处理逻辑
// 如果没有启用CMA,或者分配标志允许CMA,或者迁移类型不是MIGRATE_MOVABLE
// 则从per-CPU列表中移除页面
if (!IS_ENABLED(CONFIG_CMA) || alloc_flags & ALLOC_CMA ||
migratetype != MIGRATE_MOVABLE) {
page = rmqueue_pcplist(preferred_zone, zone, order,
gfp_flags, migratetype, alloc_flags);
goto out;
}
}
// WARN_ON_ONCE是一个调试帮助,用于在条件为真时发出警告
WARN_ON_ONCE((gfp_flags & __GFP_NOFAIL) && (order > 1));
spin_lock_irqsave(&zone->lock, flags); // 获得zone的自旋锁并保存中断状态
do {
page = NULL;
// 对于高阶的原子性分配,尝试从MIGRATE_HIGHATOMIC迁移类型中分配
if (order > 0 && alloc_flags & ALLOC_HARDER) {
page = __rmqueue_smallest(zone, order, MIGRATE_HIGHATOMIC);
if (page)
trace_mm_page_alloc_zone_locked(page, order, migratetype);
}
// 如果没有找到页面,尝试从其他迁移类型中分配
if (!page)
page = __rmqueue(zone, order, migratetype, alloc_flags);
} while (page && check_new_pages(page, order));
if (!page)
goto failed;
// 调整zone的free页面计数器
__mod_zone_freepage_state(zone, -(1 << order),
get_pcppage_migratetype(page));
spin_unlock_irqrestore(&zone->lock, flags); // 释放自旋锁并恢复中断状态
// 记录页面分配事件
__count_zid_vm_events(PGALLOC, page_zonenum(page), 1 << order);
// 更新统计信息
zone_statistics(preferred_zone, zone, 1);
out:
// 如果zone的水位标记被提升,则清除它并唤醒kswapd来释放更多的页面
if (test_bit(ZONE_BOOSTED_WATERMARK, &zone->flags)) {
clear_bit(ZONE_BOOSTED_WATERMARK, &zone->flags);
wakeup_kswapd(zone, 0, 0, zone_idx(zone));
}
// 调试帮助,确保返回的页面在给定的zone中
VM_BUG_ON_PAGE(page && bad_range(zone, page), page);
return page;
failed:
spin_unlock_irqrestore(&zone->lock, flags); // 如果分配失败,释放自旋锁并恢复中断状态
return NULL;
}
__rmqueue_smallest(zone, order, MIGRATE_HIGHATOMIC)方法开始分配物理页面。
6.__rmqueue_smallest():
// 从给定的zone的空闲列表中获取最小可用的页面的函数定义
static __always_inline
struct page *__rmqueue_smallest(struct zone *zone, unsigned int order,
int migratetype)
{
unsigned int current_order; // 用于循环遍历不同的页面大小
struct free_area *area; // 指向特定order的空闲区域
struct page *page; // 从空闲区域中找到的页面
// 从指定的order开始,遍历所有更大的order,直到达到最大的order
for (current_order = order; current_order < MAX_ORDER; ++current_order) {
area = &(zone->free_area[current_order]); // 获取当前order的空闲区域
page = get_page_from_free_area(area, migratetype); // 从该区域获取一个页面
if (!page) // 如果没有页面,继续下一个order
continue;
// 如果找到了页面,从空闲列表中删除它
del_page_from_free_list(page, zone, current_order);
// 如果需要的,将页面分解为更小的页面
expand(zone, page, order, current_order, migratetype);
// 设置页面的迁移类型
set_pcppage_migratetype(page, migratetype);
return page; // 返回找到的页面
}
return NULL; // 如果没有找到合适的页面,返回NULL
}
接下来我们就来具体查看一下 del_page_from_free_list()函数的实现:
// 从给定的zone的空闲列表中删除一个指定的页面的函数定义
static inline void del_page_from_free_list(struct page *page, struct zone *zone,
unsigned int order)
{
// 如果页面被报告过,清除其报告状态并更新报告页面计数
if (page_reported(page))
__ClearPageReported(page);
// 从空闲链表中删除页面
list_del(&page->lru);
// 清除页面的Buddy属性,表示它不再是空闲页面的一部分
__ClearPageBuddy(page);
// 将页面的private字段设置为0
set_page_private(page, 0);
// 减少当前order的空闲页面计数
zone->free_area[order].nr_free--;
}
简单来说,这个函数执行以下操作:
- 如果页面之前被报告过,它会清除页面的报告状态。
- 它从空闲列表(使用双向链表)中删除该页面。
- 它清除页面的Buddy属性,表示这个页面不再是空闲页面的一部分。
- 设置页面的private字段为0。
- 更新当前order的空闲页面计数,将其减少1。
通过以上的分析,我们分析了在__rmqueue_smallest()函数中,先以当前的order值从free_area数组中进行查找,若找到满足要求的空闲页,则分配,若没有找到,则执行++current_order,继续寻找下一个数组值。这个流程符合我们分析的伙伴算法原理。通过这篇文章,我想大家也会分析如何释放内存页面了,这里就不再分析了。
四、心得体会
通过对伙伴算法的源码分析,我发现对于内核的学习,真的不能只停留在理论上,我们需要结合Linux的源码进行学习,虽然这个过程对于初学者很困难,但是我觉得这个过程真的对于自己能力的提升很有帮助,在初期分析源码的过程中,我们千万不能过于追求细节,要捉住主线,先把我们的需求分析出来,再去学习其他的一些相关源码。
对于本次分析的过程,我发现自己还是有很多地方分析的不到位,对于许多函数或者判断条件很迷惑,内核中的一些标志位的设计,锁相关的知识等还是不太理解,那么接下来我会继续去结合源码去学习这些问题。