内存的概念在Linux系统中相对复杂,有常规内存、高端内存、虚拟地址、逻辑地址、总线地址、物理地址、IO内存、设备内存、预留内存等概念。本章将系统讲解内存和IO访问编程
在X86处理器中存在着IO空间的概念,IO空间是相对于内存空间而言的,它通过特定指令in和out来访问。
端口号标志了外设的寄存器地址。
目前,大部分的嵌入式微控制器(如ARM、PowerPC等)中并不提供IO空间,而仅存在内存空间。内存空间可以直接通过地址、指针来访问,程序及在程序运行中使用的变量和其他数据都存在与内存空间中。
内存地址可以直接由C语言指针操作:e.g:
unsigned char*p = (unsigned char *) 0xF000FF00;
*p = 11;
以上程序的意思是在绝对地址0xF000 + 0xFF00(186处理器使用16位短地址和16位偏移地址)中写入11
而在ARM、PowerPC等未采用段地址的处理器中,p指向的内存空间就是0xF000FF00,而*p = 11,就是在该地址写入11
内存空间是必须的,IO空间是可选的
高性能处理器一般会提供一个内存管理单元(MMU),该单元辅助操作系统进行内存管理,提供虚拟地址和物理地址的映射、内存访问权限保护和Cache缓存控制等硬件支持。操作系统内核借助MMU可以让用户感觉到程序好像可以使用非常大的内存空间,从而使得编程人员在写程序时不用考虑计算机中物理内存的实际容量。
为了理解基本的MMU操作原理,先明晰几个概念:
1.TLB:即转换旁路缓存(Translation Lookaside Buffer):即转换旁路缓存,TLB是MMU的核心部件,它缓存少量的虚拟地址与物理地址的转换关系,是转换表的Cache,因此也经常被称为快表。
2.TTW: 即转换表漫游(Translation Table walk),当TLB中没有缓存对应的地址转换关系的时候,需要通过对内存中转换表(多级页表)的访问来获得虚拟地址与物理地址的对应关系。TTW成功后,结果应该写入TLB中
3.要么TLB成功,要么TTW后把信息写入TLB,然后查找转换关系成功,反正最后都是会TLB成功的。除非该虚拟地址与物理地址的对应关系即不在TLB中也不在内存中的转换表中。
4.ARM中TLB条目中的控制信息用于控制对对应地址的访问权限以及Cache的操作地址。
上述的MMU机制针对的虽然是ARM处理器,但是PowerPC、MIPS等其他处理器也有类似的操作
MMU有虚拟地址和物理地址转换、内存访问权限保护等功能,这将使得Linux操作系统能单独为系统每个用户进程分配独立的内存空间并保证用户空间不能访问内核空间的地址,为操作系统的内存管理模块提供硬件基础。
对于包含MMU的处理器而言,Linux系统提供了复杂的存储管理系统,使得进程所能访问的内存达到4GB。
在Linux系统中,进程的4GB内存空间被分为两个部分–用户空间和内核空间。用户空间的地址一般分布为0-3GB,这样剩下3GB~4GB为内核空间。用户进程通常是访问不到内核空间的地址的,只有当系统调用(陷入到内核态时)才可以访问到内核空间
每个进程的用户空间都是完全独立、互不相干的,用户进程各自有不同的页表。而内核空间是有内核负责映射,它不会跟着进程改变,是固定的。内核空间的虚拟地址到物理地址映射是被所有进程共享的,内核的虚拟空间独立于其他进程。
Linux中1GB的内核地址空间,由3GB~4GB,又被划分为物理内存映射区、虚拟内存分配区、高端页面映射区、专用页面映射区和系统保留映射区。
当系统物理内存超过4GB时,必须使用CPU的扩展分页模式所提供的64位页目录项才能存取到4GB以上的物理内存,这需要CPU的支持。
直接映射的896MB物理内存其实又分为两个区域,在低于16MB的区域,ISA设备可以做DMA,所以该区域是DMA区域。16M~896M之间的为常规区域,高于896MB的称为高端内存区域。(x86)
32位ARM Linux的内核空间地址映射与x86不一样,地址由低到高分别是向量表、vmalloc、DMA+常规区域内存映射、高端内存映射区、内核模块。
DMA、常规、高端内存这3个区域都是采用buddy算法进行管理,把空闲的页面以2的n次方为单位进行管理。Buddy算法最主要的优点是避免了外部碎片,任何时候区域里的空闲空间都可以以2的n次方进行拆分或者合并。
cat /proc/buddyinfo 会显示每个区域里面2的n次方的空闲页面分布情况。如下
Node 0, zone DMA 8 5 2 7 8 3 0 0
Node 0, zone Normal 2002 1252 524 187
上述结果表示:
高端内存区域为0,DMA区域有一页空闲的内存有8个,连续2页空闲的内存有5,4页的有2个
常规内存区域里面有一页空闲的内存有2002个,连续两页空闲的有1252个,。。。。
在DMA和常规内存区域里面,有一种简单的方式换算虚拟地址和物理地址。
virt_to_phys()和phys_to_virt()
在用户空间中动态申请内存的函数为malloc(),释放函数是free(),Linux内核总是采用按需调页,因此当malloc返回的时候,虽然都是成功返回,但是内核并没有真正的给这个进程内存。如果去读这个申请的内存,全部都是0,这个页面的映射是只读的。只有当写到某个页面的时候,内核才在页错误之后把这个页面返回进程。
Linux内核空间申请内存主要包括kmalloc()、__get_free_pages()和vmalloc等。kmalloc和__get_free_pages()(以及类似的函数)申请的内存位于DMA和常规区域的映射区,而且在物理上也是连续的,它们与真实的物理地址只有一个固定的偏移,因此存在较简单的转换关系。而vmalloc在虚拟内存空间给出一块连续的内存区,实际上这块连续的虚拟地址在物理上不一定是连续的,而且物理地址与虚拟地址之间也没有简单的换算关系。
1. kmalloc
void *kmalloc(size_t size, int flags);
size:要分配的块的大小
flags:分配标志,用于控制kmalloc的行为,最常见的是GFP_KERNEL,其含义是在内核空间的进程中申请内存。
kmalloc的底层依赖于__get_free_pages()来实现,分配标志的GFP也是该函数的缩写。用GFP_KERNEL来申请内存的时候,若暂时不能满足,则进程会睡眠等待页,就是引起阻塞,所以不能再中断上下文或者持有自旋锁的时候使用GFP_KERNEL标志来申请内存。
由于在中断处理函数、tasklet、内核定时器等非进程上下文中不能阻塞,所以此时应该使用GFP_ATOMIC标志来申请内存。当使用GFP_ATOMIC标志申请内存的时候,若不存在空闲页,则不等待直接返回。
常用的就是这两个标志位,kfree是相应的内存释放函数。
2.__get_free_pages
__get_free_pages系列函数/宏本质上是Linux内核最底层用于获取空闲内存的方法,因此底层的buddy算法以2的n次方页为单位管理空闲内存,所以最底层的内存申请总是以2的n次方为单位的
3.vmalloc
vmalloc一般用于较大内存的分配,vfree是对应的内存释放函数,vmalloc在申请内存的时候,会进行内存的映射,改变页表项。它的虚拟地址和实际物理地址并不是一个简单的线性映射关系
4.slab和内存池
一方面,完全使用页为单元申请和释放内存容易导致浪费;另一方面,在操作系统的运作过程中,经常会涉及大量对象的重复生成、使用和释放内存问题。如果我们能用合适的方法使得对象在前后两次被使用时分配在同一块内存或者同一类内存空间且保留了基本的数据结构,就可以提高效率。于是slab算法应运而生,kmalloc就是使用slab的机制实现的。
slab建立在buddy算法之上,它从buddy算法中拿到2的n次方页面后再次进行二次管理,这一点和用户空间的c库很像。slab申请的内存以及基于slab的kmalloc申请的内存地址,与实际物理内存地址之间存在一个简单的线性偏移
slab在最底层仍然依赖__get_free_pages(),slab在底层每次申请1页或者多页,之后再分隔这些页为更小的单元进行管理。
Linux内核还包含了内存池的支持,内存池技术也是一种经典的用于分配大量小对象的后背缓存技术
1.创建内存池
mempoll_t *mempool_create(int min_nr, mempoll_alloc_t *alloc_fn, mempoll_free_t *free_fn, void *poll_data);
该函数用来创建一个内存池,min_nr参数是需要预分配对象的数目,alloc_fn和free_fn是内存池提供的对象分配和回收函数指针,分别如下
typedef void *(mempoll_alloc_t)(int gfp_mask, void *poll_data);
typedef void (mempoll_free_t)(void *element, void *poll_data);
2.分配和回收内存池
void *mempoll_alloc(mempoll_t *poll, int gfp_msak);
void mempoll_free(void *element, mempoll_t *poll);
mempoll_alloc用来分配对象,如果内存池分配器无法提供内存,那么就可以用预分配的内存池
3.回收内存池
void mempoll_destory(mempoll_t *poll);
mempoll_create创建的内存池需要由mempoll_destory回收。
设备通常会提供一组寄存器来控制设备、读写设备和获取设备状态,即控制寄存器、数据寄存器、状态寄存器。这些寄存器可能位于IO空间中,也可能位于内存空间中。当位于IO空间(大部分处理器并没有这个东西)中的时候,通常被称为IO端口;当位于内存空间的时候,被称为IO内存
IO内存
在内核中访问IO内存(通常是芯片内部的各个I2C、SPI、USB等控制器的寄存器或者外部内存总线上的设备)之前,需首先使用ioremap函数将设备所处的物理地址映射虚拟地址上。ioremap的原型如下:
void *ioremap(unsigned long offset, unsigned long size);
ioremap与vmalloc类似,也需要建立新的页表,但是它并不进行vmalloc中所执行的内存分配行为。ioremap返回一个特殊的虚拟地址,该地址可用来存取特定的物理地址范围,这个虚拟地址位于vmalloc映射区域。通过ioremap()获得的虚拟地址应该被iounmap()函数释放,其原型如下:
void iounmap(void *addr);
ioremap有个变体是devm_ioremap,类似于其他以devm_开头的函数,通过devm_ioremap进行的映射通常是不需要在驱动退出和出错处理的时候进行iounmap()。
在设备的物理地址一般都是寄存器被映射到虚拟地址之后,尽管可以直接通过指针访问这些地址,但是Linux内核推荐使用标准的API来完成设备内存映射的虚拟地址的读写。
IO端口申请
Linux内核提供了一组函数以申请和释放IO端口,表明该驱动要访问这片区域。
struct resource *request_region(unsigned long first, unsigned long n, const char *name);
这个函数向内核申请n个端口,这些端口从first开始,name参数为设备的名称。如果申请失败返回NULL
void release_region(unsigned long start, unsigned long n);
2.IO内存申请
同样的,Linux内核也提供了一组函数申请和释放IO内存的范围。此处的“申请”表明驱动要访问这块区域,不会做任何内存映射的动作
struct resource *request_mem_region(unsigned long start, unsigned long len, char* name)
void release_men_region(unsigned long start, unsigned long len);
request_region和request_mem_region都有对应的devm版本,不需要释放的版本
设备驱动访问IO端口和IO内存的步骤如下:
IO端口访问的途径:直接使用IO端口操作函数,在设备打开或者驱动模块被加载时申请IO端口区域,之后使用inb()/outb()等进行端口访问,最后在设备关闭或者驱动被卸载的时候释放IO端口区域。
IO内存的访问步骤:首先调用request_mem_region申请资源,接着将寄存器地址通过ioremap映射到内核空间虚拟地址,之后就可以通过Linux设备访问编程接口来访问这些设备的寄存器。访问完成后,调用iounmap释放申请的虚拟地址,调用release_mem_region申请IO内存资源。
1.内存映射与VMA
一般情况下,用户空间不可能也不应该直接访问设备的。但是设备驱动程序实现了mmap()函数,使得用户空间能直接访问设备的物理地址。实际上,mmap()函数实现了这样一个映射过程:它将用户空间的一段内存与设备内存关联,当用户访问用户空间这段地址范围时,实际上会转化为对设备的访问。
这种能力对于显示适配器一类的设备非常有意义,如果用户空间可直接通过内存映射访问显存的话,屏幕帧的各点像素将不再需要一个从用户空间到内核空间的复制的过程。
mmap()必须以PAGE_SIZE为单位进行映射,实际上,内存只能以页为单位进行映射,若要映射非PAGE_SIZE整数倍的地址范围,要先进行页对齐,强行以PAGE_SIZE的倍数大小进行映射。
从file_operation文件操作结构体可以看出,驱动中mmap()函数的原型如下:
int(*mmap)(struct file*, struct vm_area_struct*);
驱动中的mmap()函数将在用户进行mmap系统调用时,最终被调用到。系统调用的mmap函数原型如下:
cadrr_t mmap(cadrr_t addr, size_t len, int prot, int flags, int fd, off_t offset);
参数fd为文件描述符,一般由open返回,fd也可以返回-1,此时需制定flags参数中的MAP_ANON,表明进行的是匿名映射。
len是映射到调用用户空间的字节数,它从被映射文件开头offset个字节开始算起,offset参数一般设为0,表示从文件头开始映射。
port参数制定访问权限,可取如下几个值得或:可读、可写、可执行、不可访问
参数addr指定文件应被映射到用户空间的起始地址,一般被指定为NULL,这样,选择起始地址的任务将由内核完成,而参数的返回值就是映射到用户空间的地址。其类型caddr_t实际上就是void*
当用户调用mmap的时候,内核会进行如下处理。
1.在进程的虚拟空间查找一块VMA(虚拟内存区域)
2.将这块VMA进行映射
3.如果设备驱动程序或者文件系统的file_operations定义了mmap操作,则调用它
4.将这个VMA插入进程的VMA链表中
file_operations中mmap函数的第一个参数就是步骤1中找到的VMA
由mmap系统调用映射的内存可有munmap接触映射,函数原型如下:
int munmap(caddr_t addr, size_t len);
驱动程序中mmap的实现机制是建立页表,并填充VMA结构体中vm_operations_struct指针。VMA就是vm_area_struct,用于描述一个虚拟的内存区域,VMA结构体的定义如下:
struct vm_area_struct {
unsigned long vm_start;
unsigned long vm_end;
...
const struct vm_operations_struct *vm_ops;
...
}
VMA结构体描述的虚地址介于vm_start和vm_end之间,其vm_ops成员指向这个VMA的操作集。针对VMA的操作都被包含在vm_operations_struct结构体重,vm_operations_struct结构体的定义如下:
struct vm_operations_struct {
void (*open) (struct vm_area_struct *area);
void (*close) (struct vm_area_struct *area);
....
}
整个vm_operations_struct 结构体的实体会在file_operations的mmap成员函数里被复制给相应的vma->vm_ops,而上述的open函数也通常会在mmap函数里调用,close函数也会在用户调用munmap的时候被调用到。vm_operations_struct的操作范例:
static int xxx_mmap(struct file*filp, struct vm_area_struct *vma)
{
if(remap_pfn_range(vma, vma->start, vma->pgoff, vma->vm_end - vma->vm_start, vma->vm_page_prot)) //建立页表
return -EAGAIN;
vma->vm_ops = &xxx_remap_vm_ops;
xxx_vma_open(vma);
return 0;
}
static void xxx_vma_open(struct vm_area_struct *vma) //vma打开函数
{
...
}
static void xxx_vma_close(struct vm_area_struct *vma) //vma关闭函数
{
...
}
static struct vm_operations_struct xxx_remap_vm_ops ={
.open = xxx_vma_open,
.close = xxx_vma_close,
...
}
调用remap_pfn_range()创建页表项,以VMA结构体的成员(VMA的数据成员是内核根据用户自己的请求填充的)作为remap_pfn_range()的参数,映射的虚拟地址范围是vma->vm_start至vma->end。
remap_pfn_range()函数的原型如下:
int remap_pfn_range(struct vm_area_struct *vma, unsigned long addr, unsigned long pfn, unsigned long size, pgprot_t prot)
其中addr参数表示内存映射开始处的虚拟地址。remap_pfn_range函数为addr~addr+size的虚拟地址构造页表。
pfn是虚拟地址应该映射到的物理地址的页帧,实际上就是物理地址右移PAGE_SHIFT位,若PAGE_SIZE位4KB (==2的12次方),所以PAGE_SHIFT为12。
prot是新页所要求的保护属性
在驱动程序中,我们能使用remap_pfn_range()映射内存中的保留页、设备IO、framebuffer、camera等内存。在remap_pfn_range()上又可以进一步封装出io_remap_pfn_range、vm_iomap_memory等API
VMA的fault函数通常可以为设备提供更加灵活的内存映射途径。当访问页不在内存里,即发生缺页异常时,fault会被内核自动调用,而fault的具体行为可以自定义。这是因为当发生缺页异常时,系统会经过如下的处理过程:
1. 找到缺页的虚拟地址所在的VMA
2. 如果必要,分配中间页目录表和页表。
3. 如果页表项对应的物理页面不存在,则调用这个VMA的fault方法,它返回物理页面的页描述符
4. 讲物理的页面地址填充到页表中
fault的典型范例:
static int xxx_fault(struct vm_area_struct *vma, struct vm_fault *vmf)
{
unsigned long paddr;
unsigned long pfn;
pgoff_t index = vmf->pgoff;
struct vma_data *vdata = vma->vm_private_data;
...
pfn = paddr >> PAGE_SHIFT;
vm_insert_pfn(vma, (unsigned long)vmf->virtual_address, pfn);
return VM_FAULT_NOPAGE;
}