#打卡不停更# Linux下内存空间分配、物理地址与虚拟地址映射 原创
一、Linux内核动态内存分配与释放
1.1 kmalloc函数
Kmalloc分配的是连续的物理地址空间。如果需要连续的物理页,可以使用此函数,这是内核中内存分配的常用方式,也是大多数情况下应该使用的内存分配方式。
传递给函数的最常用的标志是GTP_ATOMIC和GTP_KERNEL。前面的标志表示进行不睡眠的高优先级分配。在中断处理程序和其他不能睡眠的代码段中需要。后面的标志可以睡眠,在没有持自旋锁的进程上下文中使用。此函数返回内核逻辑地址。
头文件: #include <linux/slab.h>
1.1.1 申请空间
static void *kmalloc(size_t size, gfp_t flags)
参数:
size_t size :申请的空间大小
gfp_t flags:申请的标志(模式)
返回值:申请的空间首地址。如果为NULL,表示分配失败!
一般填写的模式:
GFP_ATOMIC:用来从中断处理和进程上下文之外的其他代码中分配内存,分配内存优先级高,不会阻塞
GFP_KERNEL:内核内存的正常分配方式,可能会阻塞。
1.1.2 释放内存空间
void kfree(const void *block)
参数:
void *block:将要释放空间的首地址
1.1.3 示例
1.1.3 示例
#include <linux/init.h>
#include <linux/module.h>
#include <linux/slab.h>
char *buff;
static int __init interrupt_init(void)
{
printk("init ok\n");
/*1.1 申请空间*/
buff=kmalloc(1024, GFP_KERNEL);
/*1.2 初始化空间*/
memset(buff,0x10,1024);
/*1.3 打印出空间的数据*/
int i;
for(i=0;i<1024;i++)
{
printk("0x%X \n",buff[i]);
}
return 0;
}
static void __exit interrupt_exit(void)
{
/*1.4 释放申请的空间*/
kfree(buff);
printk("exit ok\n");
}
module_init(interrupt_init); /*驱动入口*/
module_exit(interrupt_exit); /*驱动出口*/
MODULE_LICENSE("GPL");
1.2 vmalloc 函数
分配的空间是线性的,在物理地址上不连续!最多分配1GB的空间。
定义文件:\mm\vmalloc.c
头文件:#include <linux/vmalloc.h>
1.2.1 申请空间
void *vmalloc(unsigned long size)
参数:
unsigned long size :分配空间的大小
返回值:申请的空间首地址
1.2.2 释放空间
void vfree(const void *addr)
参数:
const void *addr:释放的空间首地址
1.2.3 示例
#include <linux/init.h>
#include <linux/module.h>
#include <linux/vmalloc.h>
#include <linux/slab.h>
char *buff=NULL;
static int __init interrupt_init(void)
{
printk("init ok\n");
/*1.1 申请空间*/
buff=vmalloc(1024);
if(buff==NULL)
{
printk("内存空间分配失败!\n\n");
}
/*1.2 初始化空间*/
memset(buff,0x10,1024);
/*1.3 打印出空间的数据*/
int i;
for(i=0;i<1024;i++)
{
printk("0x%X \n",buff[i]);
}
printk("buff=0x%x\n",buff); //buff=0xf0537000
return 0;
}
static void __exit interrupt_exit(void)
{
/*释放申请的空间*/
vfree(buff);
printk("exit ok\n");
}
module_init(interrupt_init); /*驱动入口*/
module_exit(interrupt_exit); /*驱动出口*/
MODULE_LICENSE("GPL");
1.3 区别总结
1. kmalloc和vmalloc是分配的是内核的内存,malloc分配的是用户的内存
2. kmalloc保证分配的内存在物理上是连续的,vmalloc保证的是在虚拟地址空间上的连续
3. kmalloc能分配的大小有限,vmalloc能分配的大小相对较大
4. 内存只有在要被DMA访问的时候才需要物理上连续
5. vmalloc比kmalloc要慢
二、 MMAP驱动实现
2.1 应用层mmap函数介绍
mmap函数用于将一个文件或者其它对象映射进内存,通过对这段内存的读取和修改,来实现对文件的读取和修改,而不需要再调用read,write等操作。
头文件:<sys/mman.h>
函数原型:
void* mmap(void* start,size_t length,int prot,int flags,int fd,off_t offset);
int munmap(void* start,size_t length);
映射函数
void *mmap(void *addr, size_t len, int prot, int flags, int fd, off_t offset);
(1) addr: 指定映射的起始地址,通常设为NULL,由系统指定。
(2) length:映射到内存的文件长度。
(3) prot:映射的保护方式,可以是:
PROT_EXEC:映射区可被执行
PROT_READ:映射区可被读取
PROT_WRITE:映射区可被写入
PROT_NONE:映射区不能存取
(4) Flags:映射区的特性,可以是:
MAP_SHARED:写入映射区的数据会复制回文件,且允许其他映射该文件的进程共享。
MAP_PRIVATE:对映射区的写入操作会产生一个映射区的复制(copy_on_write),对此区域所做的修改不会写回原文件。
(5) fd:由open返回的文件描述符,代表要映射的文件。
(6) offset:以文件开始处的偏移量,必须是分页大小的整数倍,通常为0,表示从文件头开始映射。
解除映射
int munmap(void *start, size_t length);
功能:取消参数start所指向的映射内存,参数length表示欲取消的内存大小。
返回值:解除成功返回0,否则返回-1
2.2 Linux内核的mmap接口
2.2.1 内核描述虚拟内存的结构体
Linux内核中使用结构体vm_area_struct来描述虚拟内存的区域,其中几个主要成员如下:
unsigned long vm_start 虚拟内存区域起始地址
unsigned long vm_end 虚拟内存区域结束地址
unsigned long vm_flags 该区域的标志
该结构体定义在<linux/mm_types.h>头文件中。
该结构体的vm_flags成员赋值的标志为:VM_IO和VM_RESERVED。
其中:VM_IO表示对设备IO空间的映射,M_RESERVED表示该内存区不能被换出,在设备驱动中虚拟页和物理页的关系应该是长期的,应该保留起来,不能随便被别的虚拟页换出(取消)。
2.2.2 mmap操作接口
在字符设备的文件操作集合(struct file_operations)中有mmap函数的接口。原型如下:
int (*mmap) (struct file *, struct vm_area_struct *);
其中第二个参数struct vm_area_struct *相当于内核找到的,可以拿来用的虚拟内存区间。mmap内部可以完成页表的建立。
2.2.3 实现mmap映射
映射一个设备是指把用户空间的一段地址关联到设备内存上,当程序读写这段用户空间的地址时,它实际上是在访问设备。这里需要做的两个操作:
1.找到可以用来关联的虚拟地址区间。
2.实现关联操作。
mmap设备操作实例如下:
static int tiny4412_mmap(struct file *myfile, struct vm_area_struct *vma)
{
vma->vm_flags |= VM_IO;//表示对设备IO空间的映射
vma->vm_flags |= VM_RESERVED;//标志该内存区不能被换出,在设备驱动中虚拟页和物理页的关系应该是长期的,应该保留起来,不能随便被别的虚拟页换出
if(remap_pfn_range(vma,//虚拟内存区域,即设备地址将要映射到这里
vma->vm_start,//虚拟空间的起始地址
virt_to_phys(buf)>>PAGE_SHIFT,//与物理内存对应的页帧号,物理地址右移12位
//说明: 向后面移动12位相当于除4096 ,为了得到页编号
vma->vm_end - vma->vm_start,//映射区域大小,一般是页大小的整数倍
vma->vm_page_prot))//保护属性,
{
return -EAGAIN;
}
printk("tiny4412_mmap\n");
return 0;
}
其中的buf就是在内核中申请的一段空间。使用kmalloc函数实现。
代码如下:
buf = (char *)kmalloc(MM_SIZE, GFP_KERNEL);
//内核申请内存只能按页申请,申请该内存以便后面把它当作虚拟设备
2.2.4 remap_pfn_range函数
remap_pfn_range函数用于一次建立所有页表。函数原型如下:
int remap_pfn_range(struct vm_area_struct *vma, unsigned long addr, unsigned long pfn, unsigned long size, pgprot_t prot);
其中vma是内核为我们找到的虚拟地址空间,addr要关联的是虚拟地址,pfn是要关联的物理地址,size是关联的长度是多少。
ioremap与phys_to_virt、virt_to_phys的区别:
ioremap是用来为IO内存建立映射的, 它为IO内存分配了虚拟地址,这样驱动程序才可以访问这块内存。
phys_to_virt只是计算出某个已知物理地址所对应的虚拟地址。将内核物理地址转化为虚拟地址。
virt_to_phys :将内核虚拟地址转化为物理地址。
三、 IO地址空间映射
3.1 ioremap函数
ioremap将一个IO地址空间映射到内核的虚拟地址空间上去,便于访问。
void * __ioremap(unsigned long phys_addr, unsigned long size, unsigned long flags)
void *ioremap(unsigned long phys_addr, unsigned long size)
参数:
phys_addr:要映射的起始的IO地址;
size:要映射的空间的大小;
flags:要映射的IO空间的和权限有关的标志;
phys_addr:是要映射的物理地址
size:是要映射的长度,单位是字节
头文件: #include <linux/io.h>
功能: 将一个IO地址空间映射到内核的虚拟地址空间上去,便于访问;
实现: 对要映射的IO地址空间进行判断,低PCI/ISA地址不需要重新映射,也不允许用户将IO地址空间映射到正在使用的RAM中,最后申请一个 vm_area_struct结构,调用remap_area_pages填写页表,若填写过程不成功则释放申请的vm_area_struct空间;
ioremap 依靠 __ioremap实现,它只是在__ioremap中以第三个参数为0调用来实现.
ioremap是内核提供的用来映射外设寄存器到主存的函数,我们要映射的地址已经从pci_dev中读了出来(上一步),这样就水到渠成的成功映射了而不会和其他地址有冲突。映射完了有什么效果呢,我举个例子,比如某个网卡有100 个寄存器,他们都是连在一块的,位置是固定的,假如每个寄存器占4个字节,那么一共400个字节的空间被映射到内存成功后,ioaddr就是这段地址的开头(注意ioaddr是虚拟地址,而mmio_start是物理地址,它是BIOS得到的,肯定是物理地址,而保护模式下CPU不认物理地址,只认虚拟地址),ioaddr+0就是第一个寄存器的地址,ioaddr+4就是第二个寄存器地址(每个寄存器占4个字节),以此类推,我们就能够在内存中访问到所有的寄存器进而操控他们了。
3.2 iounmap函数
void iounmap(void *addr)
取消ioremap映射的空间。
3.3 补充说明
1、ioremap 按照页大小进行映射,而且是整页 。
2、ioremap 允许对一个物理地址进行多次映射,而且分配的虚拟空间地址各不相同(多个虚拟地址对应于同一个物理地址)。而且,ioumap相互不影响。
3.4 示例
volatile unsigned int *GPD0CON;
volatile unsigned int *GPD0DAT;
GPD0CON=ioremap(0x114000A0,4); /*CON*/
GPD0DAT=ioremap(0x114000A4,4);
四、linux内核readl()和writel()函数
writel()往内存映射的I/O 上写入 32 位数据 (4字节)。
readl()从内存映射的I/O上读取32位的数据(4字节)。
writel函数:
void writel(unsigned char data , unsigned short addr)
参数说明
data:写入的数据
addr:I/O地址
readl函数:
unsigned char readl (unsigned int addr )
参数说明
addr : I/O 地址。
返回值 : 从I/O空间读取的数值。
示例:
static long beep_ioctl(struct file *file, unsigned int cmd, unsigned long argv)
{
u32 data;
switch(cmd)
{
case 1234:
data=readl(GPD0DAT);
data|=1<<0;
writel(data,GPD0DAT);
printk("开蜂鸣器\n");
//*GPD0DAT &=~(1 << 0); //关蜂鸣器
break;
case 5678:
data=readl(GPD0DAT);
data&=~(1<<0);
writel(data,GPD0DAT);
printk("关蜂鸣器\n");
//*GPD0DAT |=(1 << 0); //开蜂鸣器
break;
}
return 0;
五、MMU(内存管理单元)
MMU是中央处理器(CPU)中用来管理虚拟存储器、物理存储器的控制线路,同时也负责虚拟地址映射为物理地址,以及提供硬件机制的内存访问授权,多用户多进程操作系统。它一个与软件密切相关的硬件部件,也是理解Linux等操作系统内核机制的最大障碍之一。不搞清楚MMU原理会使编程思想停留在单片机与无OS的时代。
5.1 MMU历史概述
许多年以前,当人们还在使用DOS或是更古老的操作系统的时候,计算机的内存还非常小,一般都是以K为单位进行计算,相应的,当时的程序规模也不大,所以内存容量虽然小,但还是可以容纳当时的程序。但随着图形界面的兴起还有用户需求的不断增大,应用程序的规模也随之膨胀起来,终于一个难题在程序员的面前,那就是应用程序太大以至于内存容纳不下该程序,通常解决的办法是把程序分割成许多称为覆盖块(overlay)的片段。覆盖块0首先运行,结束时他将调用另一个覆盖块。虽然覆盖块的交换是由OS完成的,但是必须先由程序员把程序先进行分割,这是一个费时费力的工作,而且相当枯燥。人们必须找到更好的办法从根本上解决这个问题。不久人们找到了一个办法,这就是虚拟存储器(virtual memory)。虚拟存储器的基本思想是程序,数据,堆栈的总的大小可以超过物理存储器的大小,操作系统把当前使用的部分保留在内存中,而把其他未被使用的部分保存在磁盘上。
比如对一个16MB的程序和一个内存只有4MB的机器,操作系统通过选择,可以决定各个时刻将哪4M的内容保留在内存中,并在需要时在内存和磁盘间交换程序片段,这样就可以把这个16M的程序运行在一个只具有4M内存机器上了。而这个16M的程序在运行前不必由程序员进行分割。
5.2 相关概念介绍
——地址范围、虚拟地址映射为物理地址以及分页机制
任何时候,计算机上都存在一个程序能够产生的地址集合,我们称之为地址范围。这个范围的大小由CPU的位数决定,例如一个32位的CPU,它的地址范围是0~0xFFFFFFFF (4G),而对于一个64位的CPU,它的地址范围为0~0xFFFFFFFFFFFFFFFF (16E)这个范围就是我们的程序能够产生的地址范围,我们把这个地址范围称为虚拟地址空间,该空间中的某一个地址我们称之为虚拟地址。与虚拟地址空间和虚拟地址相对应的则是物理地址空间和物理地址,大多数时候我们的系统所具备的物理地址空间只是虚拟地址空间的一个子集。这里举一个最简单的例子直观地说明这两者,对于一台内存为256M的32bit x86主机来说,它的虚拟地址空间范围是0~0xFFFFFFFF(4G),而物理地址空间范围是0x00000000~0x0FFFFFFF(256M)。
在没有使用虚拟存储器的机器上,地址被直接送到内存总线上,使具有相同地址的物理存储器被读写;而在使用了虚拟存储器的情况下,虚拟地址不是被直接送到内存地址总线上,而是送到存储器管理单元MMU,把虚拟地址映射为物理地址。
大多数使用虚拟存储器的系统都使用一种称为分页(paging)机制。虚拟地址空间划分成称为页(page)的单位,而相应的物理地址空间也被进行划分,单位是页帧(frame).页和页帧的大小必须相同。
在这个例子中我们有一台可以生成32位地址的机器,它的虚拟地址范围从0~0xFFFFFFFF(4G),而这台机器只有256M的物理地址,因此他可以运行4G的程序,但该程序不能一次性调入内存运行。这台机器必须有一个达到可以存放4G程序的外部存储器(例如磁盘或是FLASH),以保证程序片段在需要时可以被调用。在这个例子中,页的大小为4K,页帧大小与页相同——这点是必须保证的,因为内存和外围存储器之间的传输总是以页为单位的。对应4G的虚拟地址和256M的物理存储器,他们分别包含了1M个页和64K个页帧。
寻址空间一般指的是CPU对于内存寻址的能力。通俗地说,就是能最多用到多少内存的一个问题。数据在存储器(RAM)中存放是有规律的 ,CPU在运算的时候需要把数据提取出来就需要知道数据在那里 ,这时候就需要挨家挨户的找,这就叫做寻址,但如果地址太多超出了CPU的能力范围,CPU就无法找到数据了。 CPU最大能查找多大范围的地址叫做寻址能力 ,CPU的寻址能力以字节为单位 ,如32位寻址的CPU可以寻址2的32次方大小的地址也就是4G,这也是为什么32位的CPU最大能搭配4G内存的原因 ,再多的话CPU就找不到了。
5.3 虚拟地址与物理地址介绍
1)虚拟地址/物理地址
如果处理器没有MMU,CPU内部执行单元产生的内存地址信号将直接通过地址总线发送到芯片引脚,被内存芯片接收,这就是物理地址(physical address),简称PA。英文physical代表物理的接触,所以PA就是与内存芯片physically connected的总线上的信号。
如果MMU存在且启用,CPU执行单元产生的地址信号在发送到内存芯片之前将被MMU截获,这个地址信号称为虚拟地址(virtual address),简称VA,MMU会负责把VA翻译成另一个地址,然后发到内存芯片地址引脚上,即VA映射成PA
软件上MMU对用户程序不可见,在启用MMU的平台上(没有MMU不必说,只有物理地址,不存在虚拟地址),用户C程序中变量和函数背后的数据/指令地址等都是虚拟地址,这些虚拟内存地址从CPU执行单元发出后,都会首先被MMU拦截并转换成物理地址,然后再发送给内存。也就是说用户程序运行*pA =100;"这条赋值语句时,假设debugger显示指针pA的值为0x30004000(虚拟地址),但此时通过硬件工具(如逻辑分析仪)侦测到的CPU与外存芯片间总线信号很可能是另外一个值,如0x8000(物理地址)。
当然对一般程序员来说,只要上述语句运行后debugger显示0x30004000位置处的内存值为100就行了,根本无需关心pA的物理地址是多少。但进行OS移植或驱动开发的系统程序员不同,他们必须清楚软件如何在幕后辅助硬件MMU完成地址转换。
暂不探讨这种复杂机制的历史原因,很多人学习MMU时,都迷失于对一些相关发散问题的无休止探究,我们暂时抽身出来,用一句话做阶段性交待,“所有计算机科学中的问题都能通过增加一个中间转换层来解决”。
2 ) 页/页帧/页表/页表项(PTE)
MMU是负责把虚拟地址映射为物理地址,但凡"映射"都要解决两个问题:映射的最小单位(粒度)和映射的规则。
MMU中VA到PA映射的最小单位称为页(Page),映射的最低粒度是单个虚拟页到物理页,页大小通常是4K,即一次最少要把4K大小的VA页块整体映射到4K的PA页块(从0开始4K对齐划分页块),页内偏移不变,如VA的一页0x30004000~0x30004fff被映射到PA的一页 0x00008000~0x00008fff,当CPU执行单元访问虚拟地址0x30004008,实际访问的物理地址是0x00008008(0x30004008和0x00008008分别位于虚实两套地址空间,互不相干,不存在重叠和冲突)。以页为最小单位,就是不能把VA中某一页划分成几小块分别映射到不同PA,也不能把VA中属于不同页的碎块映射到PA某一页的不同部分,必须页对页整体映射。
页帧(Page Frame)是指物理内存中的一页内存,MMU虚实地址映射就是寻找物理页帧的过程,对这个概念了解就可以了。
MMU软件配置的核心是页表(Page Table),它描述MMU的映射规则,即虚拟内存哪(几)个页映射到物理内存哪(几)个页帧。页表由一条条代表映射规则的记录组成,每一条称为一个页表条目(Page Table Entry,即PTE),整个页表保存在片外内存,MMU通过查找页表确定一个VA应该映射到什么PA,以及是否有权限映射。
但如果MMU每次地址转换都到位于外部内存的页表上查找PTE,转换速度就会大大降低,于是出现了TLB。
TLB (Translation Lookaside Buffers)即转换快表,又简称快表,可以理解为MMU内部专用的存放页表的cache(快速缓冲贮存区),保存着最近使用的PTE乃至全部页表。MMU接收到虚拟地址后,首先在TLB中查找,如果找到该VA对应的PTE就直接转换,找不到再去外存页表查找,并置换进TLB。TLB属于片上SRAM,访问速度快,通过TLB缓存PTE可以节省MMU访问外存页表的时间,从而加速虚实地址转换。TLB和CPU cache的工作原理一样,只是TLB专用于为MMU缓存页表。
3) MMU的内存保护功能
既然所有发往内存的地址信号都要经过MMU处理,那让它只单单做地址转换,岂不是浪费了这个特意安插的转换层,显然它有能力对虚地址访问做更多的限定(就像路由器转发网络包的同时还能过滤各种非法访问),比如内存保护。可以在PTE条目中预留出几个比特,用于设置访问权限的属性,如禁止访问、可读、可写和可执行等。设好后,CPU访问一个VA时,MMU找到页表中对应PTE,把指令的权限需求与该PTE中的限定条件做比对,若符合要求就把VA转换成PA,否则不允许访问,并产生异常。
4) 多级页表
虚拟地址由页号和页内偏移组成。前面说过MMU映射以页为最小单位,假设页大小为4K(212),那么无论页表怎样设置,虚拟地址后12比特与MMU映射后的物理地址后12比特总是相同,这不变的比特位就是页内偏移。为什么不变? 比如: 把搭积木想象成一种映射,不管怎么搭,也改变不了每块积木内部的原子排列。所谓以页为最小单位就是保持一部分不变作为最小粒度。
一个32bits虚拟地址,可以划分为220个内存页,如果都以页为单位和物理页帧随意映射,页表的空间占用就是220*sizeof(PTE)*进程数(每个进程都要有自己的页表),PTE一般占4字节,即每进程4M,这对空间占用和MMU查询速度都很不利。问题是实际应用中不需要每次都按最小粒度的页来映射,很多时候可以映射更大的内存块。因此最好采用变化的映射粒度,既灵活又可以减小页表空间。具体说可以把20bits的页号再划分为几部分
简单说每次MMU根据虚拟地址查询页表都是一级级进行,先根据PGD的值查询,如果查到PGD的匹配,但后续PMD和PTE没有,就以2(offset+pte+pmd)=1M为粒度进行映射,后20bits全部是块内偏移,与物理地址相同。
5) 操作系统和MMU
实际上MMU是为满足操作系统越来越复杂的内存管理而产生的。OS和MMU的关系简单说:
a. 系统初始化代码会在内存中生成页表,然后把页表地址设置给MMU对应寄存器,使MMU知道页表在物理内存中的什么位置,以便在需要时进行查找。之后通过专用指令启动MMU,以此为分界,之后程序中所有内存地址都变成虚地址,MMU硬件开始自动完成查表和虚实地址转换。
b. OS初始化后期,创建第一个用户进程,这个过程中也需要创建页表,把其地址赋给进程结构体中某指针成员变量。即每个进程都要有独立的页表。
c.用户创建新进程时,子进程拷贝一份父进程的页表,之后随着程序运行,页表内容逐渐更新变化
6) 总结
相关概念讲完,VA到PA的映射过程就一目了然:MMU得到VA后先在TLB内查找,若没找到匹配的PTE条目就到外部页表查询,并置换进TLB;根据PTE条目中对访问权限的限定检查该条VA指令是否符合,若不符合则不继续,并抛出exception异常;符合后根据VA的地址分段查询页表,保持offset(广义)不变,组合出物理地址,发送出去。
在这个过程中,软件的工作核心就是生成和配置页表。
ARM系列的MMU
ARM出品的CPU,MMU作为一个协处理器存在。根据不同的系列有不同搭配。需要查询DATASHEET才可知道是否有MMU。如果有的话,一定是编号为15的协处理器。可以提供32BIT共4G的地址空间。
ARM MMU提供的分页机制有1K/4K/64K 3种模式。ARM-Linux操作系统上分页使用的是4K模式。涉及的寄存器,全部位于协处理器15。
ARM cpu地址转换涉及三种地址:虚拟地址(VA,Virtual Address),变换后的虚拟地址(MVA,Modified Virtual Address),物理地址(PA,Physical Address)。没有启动MMU时,CPU核心、cache、MMU、外设等所有部件使用的都是物理地址。启动MMU后,CPU核心对外发出的是虚拟地址VA,VA被转换为MVA供cache、MMU使用,并再次被转换为PA,最后使用PA读取实际设备。