虚拟内存
Android 现在都是采用ARM64的架构了,采用64位的cpu总线,虽然说不至于全部64位地址都使用,不过也不像32位那样紧紧巴巴过日子了。不过很多linux的介绍文章,还是只介绍了32位地址空间的内存管理,虚拟内存还是3:1或者2:2(32位地址空间最大内存是4GB),这就太out了。
64位地址空间中,
[0xFFFF_0000_0000_0000,0xFFFF_FFFF_FFFF_FFFF] 是内核态地址
....
中间部分都是不规范的,如今使用中间的地址,手机是会挂掉的
....
[0x0000_0000_0000_0000,0x0000_FFFF_FFFF_FFFF] 是用户态地址
**CONFIG_COMPAT = y **打开的话,说明还支持32位进程
**CONFIG_ARM64_VA_BITS = 39 **
因为48位地址的总内存可以达到256TB,太夸张了,所以一般都是打开这个配置,使用39位地址,512GB就行了。内核态空间和用户态空间都是512G
内核起始地址就是最高位减去 2的39次方 +1
#define VA_BITS (CONFIG_ARM64_VA_BITS)
#define VA_START (UL(0xffffffffffffffff) - (UL(1) << VA_BITS) + 1)
用户态空间中的排列一般都是固定的,这个就是分段,然后再把这些放在不连续的内存块中,把内存块分页存储。
- 顶部是栈(stack),存放局部变量和实现函数调用,自上而下增长。
- 栈和堆中间是文件区间映射到虚拟地址空间的内存映射区域。
- 堆(heap),动态分配和释放的内存,自下而上增长。
- BSS,未初始化的数据段,存放进程未初始化的static 以及 gloal 变量, 默认初始化时全部为0.
- Data,数据段
- Text(ELF)代码段
用户程序使用了一段内存,首先会在虚拟内存上面找到一段空的内存,然后将用户程序使用的内存映射到这段内存上,然后虚拟内存再将这段内存映射到物理内存上。
第一次映射需要段表,第二次映射需要页表。
Linux系统采用延迟分配物理内存的策略,用户态进程每次分配内存时分配的都是虚拟内存,表示一段地址空间已经分配出来供进程使用;当进程第一次访问虚拟地址时,才会发现虚拟地址没有对应的物理内存,系统默认会触发缺页异常,从内核物理内存管理系统中分配物理页,建立页表中把虚拟地址映射到物理地址。
问题一:页表越来越大,内存浪费
方案:多级页表,建立页表索引,不使用的页表不加载到内存中
问题二:多级页表级数过多,造成访问次数增多,执行指令的速度比访问速度快得多
方案:CPU和内存间加个快表,TSL
一个虚拟内存地址区域表示该段内存已经分配出去,但是并不保证该地址空间已经映射物理内存,也不保证相应的物理页在内存中。例如分配2MB的内存后,自始至终没有访问过这片内存,所以这2MB的内存只是占用了虚拟地址空间,没有使用相应大小的物理内存。当访问一个未经映射的虚拟地址时,就会产生一个“Page Fault”事件(通常叫做缺页异常),当前进程会被缺页异常打断而进入异常处理函数,在处理函数中,会从伙伴系统中分配一个page,与相应的虚拟地址建立映射,这个映射关系需要通过页表来管理;同时页表也需要单独分配内存来保存,所以在计算一个进程使用的物理内存时,也要算上页表的内存。
Pagefault,是CPU提供的功能。两种情况会出现Pagefault,一是,CPU通过虚拟地址没有查到对应的物理地址。二是,MMU没有访问物理地址的权限。
分DMA Zone的原因,是DMA引擎的缺陷。DMA引擎 可以直接访问内存空间的地址,但不一定能够访问到所有的内存,访问内存时会存在一定的限制。
当CPU 和DMA同时访问内存时,硬件上会有仲裁器,选择优先级高的去访问内存。
内核初始化后会将物理内存线性映射,这样通过物理地址和虚拟地址的偏移就可以获得页表物理地址对应的虚拟地址
分配内存的系统调用
在Linux操作系统标准libc库中,malloc函数的实现中会根据分配内存的size来决定使用哪个分配函数, 当size小于等于128KB,调用brk分配, 当size大于128KB时,调用mmap分配内存。
- brk系统调用
brk是传统分配/释放堆内存的系统调用, 堆内存是由低地址向高地址方向增长;
分配内存时,将数据段(.data)的最高地址指针_edata往高地址扩展;
释放内存时,把_edata向低地址收缩。
可以看出brk系统调用管理的始终是一片连续的虚拟地址空间,而且起始地址一经设定就默认不变,只是高地址按需变化。
- mmap系统调用
mmap系统调用是在进程堆和栈中间(称为Memory Mapping Segment)找一块空闲的虚拟内存,mmap可以进行匿名映射和文件映射,文件映射即把磁盘存储设备上面的文件映射的内存中,然后访问内存就是访问文件,文件映射的物理页是可以通过kswapd或者direct reclaim回收的;匿名映射即没有映射任何文件。
由于brk系统调用分配内存存在内存碎片化线性,例如先分配100MB的内存,然后再分配4KB内存,再把100MB内存释放掉,此时由于4KB内存还没有释放,_edata就不能收缩,导致100MB内存不能及时操作系统;反之先分配4KB,在分配100MB,则存在内存碎片化的问题。另外由于_edata上面是mmap区域,_edata与最近的mmap内存很接近,则会导致brk系统调用极容易分配失败,即使memory mmap区域还有大量可用内存。Brk分配管理的实际上就是一块匿名映射的内存,所以实际上可以通过mmap匿名映射来满足malloc的内存分配。
这两种方式分配的都是虚拟内存,没有分配物理内存。在第一次访问已分配的虚拟地址空间的时候,发生缺页中断,操作系统负责分配物理内存,然后建立虚拟内存和物理内存之间的映射关系。虚拟地址的分配也是内核态管理的,不过用户进程可以访问用户态的地址空间。
分配器
如果进程每次分配内存都通过brk和mmap系统调用分配的话,存在两个致命的问题:
碎片化的问题,从内核分配虚拟内存都是按照page(默认是4KB)对齐来分配的,如果进程分配8byte,实际从内核分配的内存是4096byte,这样就存在4088byte的浪费;同时进程的内存分配需求存在随机性,如果不同大小的内存交替分配,当部分内存释放后,整个内存空间严重碎片化,导致最后分配大片内存时高概率会失败。
性能问题,系统调用从用户态陷入到内核态都是通过中断来实现的,在进程从内核态返回到用户态时,任务有可能被调度出cpu;另外,对于多线程的进程,所有的线程共享同一个mm,如果多个线程同时分配内存,则在内核空间存在竞争关系,所有的线程分配请求都要排队处理;如果频繁系统调用分配内存,分配内存的效率会降低。
分配器的出现就是为了解决上述问题,例如我们熟悉的libc库,调用malloc的时候并不是每次都会通过系统调用从内核分配内存的,而是分配器相当于在malloc和系统调用之间插入一层中间件。分配器首先通过系统调用从内核批发大块内存,然后切成不同大小的内存片缓存起来,例如8/16/24/32/64byte等,当调用malloc的时候,直接从cache的空闲小内存片分配;同时为了解决性能问题,分配器对每个线程或者每个cpu预留单独的cache,每个线程从自己的cache中分配,可以减少线程之间的锁竞争。
现在业界主流的分配器有ptmalloc、tcmalloc、jemalloc、scudo等。在Android系统中,为例提高兼容性和性能,malloc函数的实现,默认都是通过mmap系统调用分配内存,不再使用brk系统调用(部分三方APP自带SDK可能会用brk)。Android现在用的分配器是jemalloc或者scudo,安卓R上AOSP默认采用scudo,不过性能会有跌落,都切换回jemalloc了。
举例
某64位进程A,在用户态堆区malloc段堆内存,首先是调用glib.c,尝试从jemalloc(管理虚拟内存,防止碎片化,分配判定,小块内存分配)中分配,如果有,就分配到该区域,不过还没有物理内存,只有访问这块虚拟内存的时候,会产生pagefault异常,进入内核态,内核态再去调用alloc_pages或者get_free_page分配物理页面,再通过页表映射。完成映射后,会再次访问这块虚拟内存,这个时候没有异常产生,可以访问了!进程只能访问已经分配的虚拟地址空间,分配还是依赖操作系统取分配,哪怕是虚拟地址空间!
32位进程在ARM64上
0-3G是虚拟地址,3-4G是内核地址,894M线性映射,仍然存在高端内存去映射其他物理地址。虚拟地址空间到物理空间的转换建立在页表上,不同的用户页表不一样。内核部分页表相同,所有程序都能访问内核空间。
DDR
Dual Data Rate SDRAM 双倍速率同步同态随机存储器
DDR的初始化一般在BIOS或者BootLoader中完成,BIOS或者BootLoader把DDR的大小传递给Linux内核,因此从Linux内核角度看,DDR就是一段物理空间内存
32位应用在ARM64上
ARM公司宣称64位的ARMv8是兼容32位的ARM应用的,所有的32位应用都可以不经修改就在ARMv8上运行。那32位应用的虚拟地址在64位内核上是怎么分布的呢?事实上,64位内核上的所有进程都是一个64位进程。要运行32位的应用程序, Linux内核仍然从64位init进程创建一个进程, 但将用户地址空间限制为4GB。通过这种方式, 我们可以让64位Linux内核同时支持32位和64位应用程序。
要注意的是, 32位应用程序仍然对应128TB的内核虚拟地址空间, 并且不与内核共享自己的4GB虚拟地址空间, 此时用户应用程序具有完整的4GB虚拟地址。而32位内核上的32位应用程序只有3GB真正意义上的虚拟地址空间。
virt_to_phys宏的作用是将内核虚拟地址转换成物理地址(针对线性映射区域)
页表遍历过程
页表遍历过程
下面以arm64处理器架构多级页表遍历作为结束(使用4级页表,页大小为4K):
Linux内核中 可以将页表扩展到5级,分别是页全局目录(Page Global Directory, PGD), 页4级目录(Page 4th Directory, P4D), 页上级目录(Page Upper Directory, PUD),页中间目录(Page Middle Directory, PMD),直接页表(Page Table, PT),而支持arm64的linux使用4级页表结构分别是 pgd, pud, pmd, pt ,arm64手册中将他们分别叫做L0,L1,L2,L3级转换表,所以一下使用L0-L3表示各级页表。
tlb miss时,mmu会进行多级页表遍历遍历过程如下:
1.mmu根据虚拟地址的最高位判断使用哪个页表基地址寄存器作为起点:当最高位为0时,使用ttbr0_el1作为起点(访问的是用户空间地址);当最高位为1时,使用ttbr1_el1作为起点(访问的是内核空间地址) mmu从相应的页表基地址寄存器中获得L0转换表基地址。
2.找到L0级转换表,然后从虚拟地址中获得L0索引,通过L0索引找到相应的表项(arm64中称为L0表描述符,内核中叫做PGD表项),从表项中获得L1转换表基地址。
3.找到L1级转换表,然后从虚拟地址中获得L1索引,通过L1索引找到相应的表项(arm64中称为L1表描述符,内核中叫做PUD表项),从表项中获得L2转换表基地址。
4.找到L2级转换表,然后从虚拟地址中获得L2索引,通过L2索引找到相应的表项(arm64中称为L2表描述符,内核中叫做PUD表项),从表项中获得L3转换表基地址。
5.找到L3级转换表,然后从虚拟地址中获得L3索引,通过L3索引找到页表项(arm64中称为页描述符,内核中叫做页表项)。
6.从页表项中取出物理页帧号然后加上物理地址偏移(VA[11,0])获得最终的物理地址。