地址结构
页表概念
在操作系统中,PGD(Page Global Directory)、PUD(Page Upper Directory)、PMD(Page Middle Directory)和 PTE(Page Table Entry)是页表的不同级别,用于管理虚拟地址到物理地址的映射关系。它们之间的区别和联系如下:
- PGD(Page Global Directory):
- PGD是页表的最高级别,用于将虚拟地址映射到PUD或者PMD。
- 在x86架构中,PGD通常是一个指向PUD的指针数组。
- PGD的索引对应于虚拟地址的高位,用于定位到对应的PUD或者PMD。
- PUD(Page Upper Directory):
- PUD是页表的第二级别,用于将虚拟地址映射到PMD。
- 在一些架构中,PUD可能不存在,直接使用PGD和PMD进行映射。
- PUD的索引对应于虚拟地址的中间位,用于定位到对应的PMD。
- PMD(Page Middle Directory):
- PMD是页表的第三级别,用于将虚拟地址映射到PTE。
- PMD的索引对应于虚拟地址的中间位,用于定位到对应的PTE。
- PTE(Page Table Entry):
- PTE是页表的最低级别,用于将虚拟地址映射到物理地址。
- PTE包含了虚拟地址和物理地址的映射关系,以及一些控制位(如读写权限、缓存控制等)。
联系和区别:
- 这些不同级别的页表项之间存在着层级关系,通过多级页表的方式可以有效地管理大量的虚拟地址空间。
- PGD、PUD、PMD和PTE在不同的级别上负责不同粒度的地址映射,通过这种层级结构可以实现更灵活和高效的地址映射。
- 在具体实现中,不同的架构可能会有不同的页表结构和层级,但是基本的思想是类似的,即通过多级页表来管理虚拟地址到物理地址的映射关系。
内核页表在内核中其实就是一段内存,存放在主内核页全局目录init_mm.pgd(swapper_pg_dir)中,硬件并不直接使用
虚拟地址空间0~3G用于应用层
虚拟地址空间3~4G用于内核层
内核又将3~4G的虚拟地址空间,划分为如下几个部分:
896MB又可以细分为ZONE_DMA和ZONE_NORMAL区域。
低端内存(ZONE_DMA):3G-3G+16M 用于DMA __pa线性映射
ZONE_DMA只在使能了CONFIG_ZONE_DMA情况下才有,不过在arch/arm64/Kconfig中,发现这个默认是使能的。如果外设无法通过DMA访问到ZONE_NORMAL的内存时,此时就需要专门划分出ZONE_DMA 和 ZONE_DMA32内存管理区域,供外设通过DMA访问,现在保留这个更多是出于兼容性的考虑。比如:某些工业标准体系结构(ISA)中的设备需要用到它,这类设备比较古老,无法直接访问整个内存 , 需要使用 DMA 直接内存访问区域,这类设备只能访问**内存的低**16M
普通内存(ZONE_NORMAL):3G+16M-3G+896M __pa线性映射 (若物理内存<896M,则分界点就在3G+实际内存)
高端内存(ZONE_HIGHMEM):3G+896-4G 采用动态的分配方式
ZONE_DMA+ZONE_NORMAL属于直接映射区:虚拟地址=3G+物理地址 或 物理地址=虚拟地址-3G,从该区域分配内存不会触发页表操作来建立映射关系。
ZONE_HIGHMEM属于动态映射区:128M虚拟地址空间可以动态映射到(X-896)M(其中X位物理内存大小)的物理内存,从该区域分配内存需要更新页表来建立映射关系,vmalloc就是从该区域申请内存,所以分配速度较慢。
直接映射区的作用是为了保证能够申请到物理地址上连续的内存区域,因为动态映射区,会产生内存碎片,导致系统启动一段时间后,想要成功申请到大量的连续的物理内存,非常困难,但是动态映射区带来了很高的灵活性(比如动态建立映射,缺页时才去加载物理页)。
注意:
在64位系统中,并不需要高端内存。因为ARM64的linux采用4级页表,支持的最大物理内存为64TB,对于虚拟地址空间的划分,ARM64地址总线位宽最多支持48位(4级页表下,3级页表是39位),系统一般将0x0000,0000,0000,0000 -- 0x0000,ffff,ffff,ffff 这256T地址用于用户空间;而0xffff,000,0000,0000以上的256T为内核空间地址。256TB显然远大于当前我们系统中的物理内存空间64TB,因此所有的物理地址都可以直接映射到内核中,不需要高端内存的特殊映射
-
kmalloc: 只能在低端内存区域分配(基于ZONE_NORMAL),最大32个PAGE,共128K,kzalloc/kcalloc都是其变种
-
vmalloc: 只能在高端内存区域分配(基于ZONE_HIGHMEM)
-
alloc_page: 可以在高端内存区域分配,也可以在低端内存区域分配,最大4M(2^(MAX_ORDER-1)个PAGE)
-
__get_free_page: 只能在低端内存区域分配,get_zeroed_page是其变种,基于alloc_page实现
-
ioremap是将已知的一段物理内存映射到虚拟地址空间,物理内存可以是片内控制器的寄存器起始地址,也可以是显卡外设上的显存,甚至是通过内核启动参数"mem="预留的对内核内存管理器不可见的一段物理内存。
前 面我们解释了高端内存的由来。 Linux将内核地址空间划分为三部分ZONE_DMA、ZONE_NORMAL和ZONE_HIGHMEM,高端内存HIGH_MEM地址空间范围为 0xF8000000 ~ 0xFFFFFFFF(896MB~1024MB)。那么如内核是如何借助128MB高端内存地址空间是如何实现访问可以所有物理内存?
当内核想访问高于896MB物理地址内存时,从0xF8000000 ~ 0xFFFFFFFF地址空间范围内找一段相应大小空闲的逻辑地址空间,借用一会。借用这段逻辑地址空间,建立映射到想访问的那段物理内存(即填充内核PTE页面表),临时用一会,用完后归还。这样别人也可以借用这段地址空间访问其他物理内存,实现了使用有限的地址空间,访问所有所有物理内存。
例 如内核想访问2G开始的一段大小为1MB的物理内存,即物理地址范围为0×80000000 ~ 0x800FFFFF。访问之前先找到一段1MB大小的空闲地址空间,假设找到的空闲地址空间为0xF8700000 ~ 0xF87FFFFF,用这1MB的逻辑地址空间映射到物理地址空间0×80000000 ~ 0x800FFFFF的内存。
对应高端内存的3部分,高端内存映射有三种方式:
映射到"内核动态映射空间"(noncontiguous memory allocation)
这种方式很简单,因为通过 vmalloc() ,在"内核动态映射空间"申请内存的时候,就可能从高端内存获得页面(参看 vmalloc 的实现),因此说高端内存有可能映射到"内核动态映射空间"中。
持久内核映射(permanent kernel mapping)
如果是通过 alloc_page() 获得了高端内存对应的 page,如何给它找个线性空间?内核专门为此留出一块线性空间,从 PKMAP_BASE 到 FIXADDR_START ,用于映射高端内存。在 2.6内核上,这个地址范围是 4G-8M 到 4G-4M 之间。这个空间起叫"内核永久映射空间"或者"永久内核映射空间"。这个空间和其它空间使用同样的页目录表,通常情况下,这个空间是 4M 大小,因此仅仅需要一个页表即可,内核通过来 pkmap_page_table 寻找这个页表。通过 kmap(),可以把一个 page 映射到这个空间来。由于这个空间是 4M 大小,最多能同时映射 1024 个 page。因此,对于不使用的的 page,及应该时从这个空间释放掉(也就是解除映射关系),通过 kunmap() ,可以把一个 page 对应的线性地址从这个空间释放出来。
临时映射(temporary kernel mapping)
内核在 FIXADDR_START 到 FIXADDR_TOP 之间保留了一些线性空间用于特殊需求。这个空间称为"固定映射空间"在这个空间中,有一部分用于高端内存的临时映射。
这块空间具有如下特点:(1)每个 CPU 占用一块空间(2)在每个 CPU 占用的那块空间中,又分为多个小空间,每个小空间大小是 1 个 page,每个小空间用于一个目的,这些目的定义在 kmap_types.h 中的 km_type 中。
当要进行一次临时映射的时候,需要指定映射的目的,根据映射目的,可以找到对应的小空间,然后把这个空间的地址作为映射地址。这意味着一次临时映射会导致以前的映射被覆盖。通过 kmap_atomic() 可实现临时映射。
分配内存
kmalloc函数原型
c
#include <linux/slab.h>
void *kmalloc(size_t size, int flags);
/*
size:要分配的块的大小
falgs:标志位,多种方式控制kmalloc行为
GFP_ATOMIC:用于在中断处理例程或其他运行于进程上下文之外的代码中分配内存,不会休眠
GFP_KERNEL:内核内存的通常分配方法, 可能引起休眠
GFP_USER:用于为用户空间页分配内存,可能会休眠
GFP_HIGHUSER:类似于GFP_USER,不过如果有高端内存的话就从那里分配。
GFP_NOTIO:禁止任何I/O初始化
GFP_NOFS:分配不允许执行文件系统调用
__GFP_DMA:标志请求分配发生在可进行DMA的内存区段中
__GFP_HIGHMEM:这个标志表明要分配的内存可位于高端内存
__GFP_COLD:内存分配器会试图返回"缓存热"页面,即可在处理器缓存中找到的页面
__GFP_NOWARN:很少使用,可以避免内核在无法满足分配请求时产生警告
__GFP_HIGH:高标志标记了一个高优先级的请求,它允许为紧急状况而消耗由内核保留的最后一些页面
__GFP_REPEAT
__GFP_NOFAIL
__GFP_NORETRY:上面3个是分配内存遇到困难时应该采取什么行为。"再试一次"、"不返回失败,努力满足"、"请求内存不可获取就立即返回
*/
后备高速缓存
设备驱动常常反复的分配和使用内存块,可以增加特殊的内存池。内核确实实现了这种形式的内存池,通常称为后备高速缓存。管理有时称为"slab分配器"
相关函数和类型在<linux/slab.h>中声明。告诉缓存具有kmem_cache_t类型,可通过kmem_cache_create创建:
c
kmem_cache_t *kmem_cache_create(const char *name, size_t size,
size_t offset, unsigned long flags,
void (*constructor)(void *, kmem_cache_t *, unsigned long falgs),
void (*destructor)(void *, kmem_cache_t *, unsigned long flags));
/*
size:参数指定区域的大小
name:高速缓存相关联,保管一些信息以便追踪问题
offset:参数是页面中第一个对象的偏移量,用来确保对已分配的对象进行某种特殊的对齐,常用的就是0
flags:控制如何完成分配,是一个位掩码
SLAB_NO_REAP:保护高速缓存在系统寻找内存的时候不会被减少
SLAB_HWCACHE_ALIGN:要求所有数据对象跟高速缓存对齐
SLAB_CACHE_DMA:要求每个数据对象都从可用于DMA的内存区段中分配
constructor和destructor参数是可选的函数,前者用于初始化新分配的对象,而后者用于"清除"对象
kmem_cache_create
*/
一旦某个对象的高速缓存被创建,就可以调用kmem_cache_alloc从中分配内存对象:
c
void *kmem_cache_alloc(kmem_cache_t *cache, int falgs);
/*
cache:是先前创建的高速缓存
falgs:传递给kmalloc相同,并且当需要分配更多内存来满足kmem_cache_alloc时,高速缓存还会利用这个参数
*/
释放一个内存对象时使用kmem_cache_free:
c
void kmem_cache_free(kmem_cache_t *cache, const void *obj);
如果驱动程序代码中和高速缓存有关的部分已经处理完了,这时驱动程序应该释放它的高速缓存
c
int kmem_cache_destroy(kmem_cache_t *cache);
//这个释放只有在已将从缓存中分配的所有对象都归还后才能成功。所以需要检查返回状态如果失败,则发生了内存泄漏
scull中的slab例子:
c
/* 声明一个高速缓存指针,它将用于所有设备 */
kmem_cache_t *scullc_cache;
/* slab高速缓存的创建代码如下所示 */
/* scullc_init:为我们的量子创建一个高速缓存 */
scullc_cache = kmem_cache_create("scullc", scullc_quantu,
0, SLAB_HWCACHE_ALIGN, NULL, NULL); /* 没有ctor/dtor */
if(!scullc_cache) {
scullc_cleanup();
return -ENOMEM;
}
/* 下面是分配内存量子的代码 */
/* 使用内存高速缓存来分配一个量子 */
if(!dptr->data[s_pos]) {
dptr->data[s_pos] = kmem_cache_alloc(scullc_cache, GFP_KERNEL);
if(!dptr->data[s_pos])
goto nomem;
memset(dptr->data[s_pos], 0, scullc_quantum);
}
/* 下面的代码将释放内存 */
for(i=0;i<qset;i++)
if(dptr->data[i])
kmem_cache_free(scullc_cache, dptr->data[i]);
/* 最后,在模块卸载期间,我们必须将高速缓存返回给系统 */
/* scullc_cleanup:释放量子使用的高速缓存 */
if(scullc_cache)
kmem_cache_destroy(scullc_cache);
内存池
内核中有些地方的内存分配是不允许失败的。为了确保这种情况的成功分配,建立了内存池(mempool)的抽象。
内存池对象的类型为mempool_t(在<linux/mempool.h>中定义),可使用mempool_create来建立内存池对象
c
mempool_t *mempool_create(int min_nr,
mempool_alloc_t *alloc_fn,
mempool_free_t *free_fn,
void *pool_data);
/*
min_nr:内存池应该始终保持的已分配对象最少数目
alloc_fn和free_fn:分配和释放函数
pool_data:被传入alloc_fn和free_fn的参数
*/
alloc_fn和free_fn函数原型如下:
c
typedef void *(mempool_alloc_t)(int gfp_mask, void *pool_data);
typedef void (mempool_free_t)(void *element, void *pool_data);
//mempool_create最后一个参数,pool_data被传入alloc_fn和free_fn
构造内存池的代码通常如下所示
c
cache = kmem_cache_create( ... );
void *mempool_alloc_slab(gfp_t gfp_mask, void *pool_data)
{
struct kmem_cache *mem = pool_data;
return kmem_cache_alloc(mem, gfp_mask);
}
void *mempool_free_slab(void *element, void * pool_data)
{
struct kmem_cache *mem = pool_data;
kmem_cache_free(mem, element);
}
pool = mempool_create(MY_POOL_MINIMUM,
mempool_alloc_slab, mempool_free_slab,
cache);
建立内存池之后,可如下所示分配和释放对象:
c
void *mempool_alloc(mempool_t *pool, int gfp_mask);
void mempool_free(void *element, mempool_t *pool);
//创建mempool时,就会多次调用分配函数为预先分配的对象创建内存池。
可以利用下面的函数来调整mempool大小:
c
int mempool_resize(mempool_t *pool, int new_min_nr, int gfp_mask);
//将内存池的大小调整为至少有new_min_nr个预分配对象
如果不在需要内存池,可以使用下面的函数将其返回给系统:
c
void mempool_destory(mempool_t *pool);
//销毁mempool之前,必须将所有已分配的对象返回到内存池汇中,否则会导致内核oops
get_free_page相关函数
- get_zeroed_page(unsigned int flags); 返回指向新页面的指针并将页面清零
- __get_free_page(unsigned int flags); 类似于get_zeroed_page,但不清零页面
- __get_free_pages(unsigned int flags, unisgned int order); 分配若干页面,并返回指向该内存区域第一个字节的指针,但不清零页面
c
void free_page(unsigned long addr);
void free_pages(unsigned long addr, unsigned long order);
//如果试图释放和先前分配数目不等的页面,内存映射关系就会被破坏,随后系统就会出错
scullp分配内蕴数量是一个或数个整页:
c
/* 下面分配单个量子 */
if(!dptr->data[s_pos]) {
dptr->data[s_pos] =
(void *)__get_free_pages(GFP_KERNEL, dptr->order);
if(!dptr->data[s_pos])
goto nomem;
memset(dptr->data[s_pos], 0, PAGE_SIZE << dptr->order);
}
/* 这段代码释放整个量子集 */
for(i=0;i<qset;i++)
if(dptr->data[i])
free_pages((unsigned long)(dptr->data[i]),
dptr->order);
alloc_page接口用在需要使用高端内存的地方
c
struct page *alloc_pages_node(int nid, unsigned int flags, unsigned int order);
//函数具有变种,多数情况下使用这两个宏
struct page *alloc_pages(unsigned int flags, unsigned int order);
struct page *alloc_page(unsigned int flags);
//为了释放通过上述途径分配的页面,应使用下面的函数:
void __free_page(struct page *page);
void __free_pages(struct page *page, unsigned int order);
void free_hot_page(struct page *page);
void free_cold_page(struct page *page);
vmalloc及其辅助函数
vmalloc分配虚拟地址空间的连续区域。尽管这段区域在物理上可能是不连续的,内核确认为他们地址上是连续的。
发生错误时返回0,成功时返回一个指针,该指针指向一个线性的,大小最少为size的线性内存区域。
c
#include <linux/vmalloc.h>
void *vmalloc(unsigned long size);
void vfree(void *addr);
void *ioremap(unsigned long offset, unsigned long size);
void ioremap(void *addr);
首先,VMALLOC区 是由两个结构体维护的,即:struct vmap_area和 struct vm_struct。其中vmap_area是专门用来管理 VMALLOC区域的虚拟空间。即一个vmap_area结构代表一块有效的虚拟空间, 当调用vmalloc函数或者ioremap时:
-
首先通过kmalloc创建一个 vmap_area结构体。
-
遍历vmap_area_root红黑树,找到VMALLOC区中的一个虚拟地址addr,这个addr与树中所有vmap_area结构体所管理的地址范围都没有重合。
-
之后将addr赋值到新创建的vmap_area结构体中,并插入到 vmap_area_root的红黑树中。
以上,新创建的 vmap_area结构体管理着addr地址以及size范围,即虚拟地址已经申请好了,那又如何映射到物理地址呢?
这里就需要 struct vm_struct结构体来管理了,vm_struct记录着虚拟地址与物理地址之间的关系。
我们紧接着上面的流程:
-
vmap_area结构被赋值好后,会把其中的虚拟地址信息赋值到新创建的vm_struct中,之后
-
对于ioremap函数,知道了虚拟地址addr,知道了物理地址:寄存器地址(传入的参数),直接调用映射函数完成对mmu的编程,实现addr与寄存器地址之间的映射关系
-
对于vmalloc函数,知道了虚拟地址addr后,从伙伴系统中申请size(vmalloc函数的参数)大小的物理地址空间,之后调用映射函数完成虚拟地址与物理页之间的映射关系。
总结:
-
vmalloc与ioremap在驱动中才能用,因为申请的虚拟内存是在内核的地址范围内,是从VMALLOC区域申请的。
-
ioremap从VMALLOC区申请到虚拟地址后,直接映射(因为物理地址已知): virtual addr <--> regsiters address。
-
vmalloc从VMALLOC区申请到虚拟地址后,需要申请size大小的物理地址空间(vmalloc参数指定),然后在映射: virtual addr <--> 物理地址。