本文基于:linux-5.11
在基于arm64架构的linux内核中, 有两个 表示__pa(x)和__va(x)用于物理地址转换位虚拟地址 或者 虚拟地址转换为物理地址(实际上还有一个__pa_symbol(x))。
这两个表达式是如何进行虚/实地址转换的?这种转换关系是如何确立的?为什么这样转换?
本文就这些问题进行挖掘探究。
一、层层展开,还原__pa(x)全貌
表达式__pa(x)是一个宏,定义在arch/arm64/include/asm/memory.h文件中:
#define __pa(x) __virt_to_phys((unsigned long)(x))
上面的__virt_to_phys()在调试配置没有开 CONFIG_DEBUG_VIRTUAL=n 时也由宏定义在arch/arm64/include/asm/memory.h:
#define __virt_to_phys(x) __virt_to_phys_nodebug(x)
上面的 __virt_to_phys_nodebug 是一个宏,还是定义在同一个文件中:
#define __virt_to_phys_nodebug(x) ({ \ phys_addr_t __x = (phys_addr_t)(__tag_reset(x)); \ __is_lm_address(__x) ? __lm_to_phys(__x) : __kimg_to_phys(__x); \ })
这个就是arm64架构linux内核中__pa(x)的全貌。
其中,__tag_reset(x)是去掉虚拟地址中的tag(如果有tag的话),让虚拟地址还原为真正可用的虚拟地址,我们这里可以直接理解为没有tag的普通虚拟地址。
接着,第二条指令"__is_lm_address(__x) ? __lm_to_phys(__x) : __kimg_to_phys(__x)",判断虚拟地址__x是否是在线性区域,如果是则用__lm_to_phys(__x)将虚拟地址转换为物理地址;否则用__kimg_to_phys(__x)将虚拟地址转换为物理地址。
这里出现了三个表达式:__is_lm_address(),__lm_to_phys()以及__kimg_to_phys(),它们都是什么含义呢?接下来一一分析。
1.1 判断虚拟地址是否为线性地址
宏__is_lm_address(addr)用于判断虚拟地址addr是否在arm64的虚拟地址空间的线性地址区域,其实现如下:
/* * Check whether an arbitrary address is within the linear map, which * lives in the [PAGE_OFFSET, PAGE_END) interval at the bottom of the * kernel's TTBR1 address range. */ #define __is_lm_address(addr) (((u64)(addr) - PAGE_OFFSET) < (PAGE_END - PAGE_OFFSET))
这个宏判断虚拟地址addr是否处于[PAGE_OFFSET, PAGE_END)范围,如果是则说明addr是线性区域的虚拟地址。
在arm64架构中对于虚拟地址空间为48-bit (CONFIG_ARM64_VA_BITS=48是典型的有效虚拟地址配置,还有39-bit和52-bit可选) 的情况,
PAGE_OFFSET = (-(UL(1) << (48))) = 0xFFFF000000000000 PAGE_END = (-(UL(1) << (48-1))) = 0xFFFF800000000000
因而,arm64架构中,对于48-bit虚拟地址的情况下,内核虚拟地址空间中的线性区域为[ 0xFFFF000000000000, 0xFFFF800000000000),在这一区域中的虚拟地址即为线性地址。
1.2 线性地址虚转实__lm_to_phys()
我们来看线性区域地址转换为物理地址的情况,该宏定义在arch/arm64/include/asm/memory.h文件:
#define __lm_to_phys(addr) (((addr) - PAGE_OFFSET) + PHYS_OFFSET)
- addr :需要转换的虚拟地址
- PAGE_OFFSET:线性区域虚拟地址相对物理地址的偏移
- PHYS_OFFSET:系统中物理地址的起始地址。
这样一来貌似就比较清楚了。线性区的虚拟地址与物理地址之间是线性关系,二者相差PAGE_OFFSET - PHYS_OFFSET ;如果PHYS_OFFSET为0的话,实际上线性区域的虚拟地址与物理地址就相差PAGE_OFFSET。
但是我有一个疑问不知当讲不当讲:
- 这个线性映射关系是在什么时候确定的呢?
- 上面的两个宏 PAGE_OFFSET 和 PHYS_OFFSET 具体值又是多少呢?
1.2.1 线性映射关系的确定
线性映射关系的确定是在内核初始化期间在map_mem()函数中确定的,其定义在arch/arm64/mm/mmu.c中:
static void __init map_mem(pgd_t *pgdp) { /* [1] */ phys_addr_t kernel_start = __pa_symbol(_stext); phys_addr_t kernel_end = __pa_symbol(__init_begin); phys_addr_t start, end; int flags = 0; u64 i; if (rodata_full || crash_mem_map || debug_pagealloc_enabled()) flags = NO_BLOCK_MAPPINGS | NO_CONT_MAPPINGS; /* [2] */ memblock_mark_nomap(kernel_start, kernel_end - kernel_start); /* [3] */ /* map all the memory banks */ for_each_mem_range(i, &start, &end) { //遍历系统中所有memblock, 会skip掉MEMBLOCK_NOMAP的memblock,即kernel_start~kernel_end if (start >= end) break; __map_memblock(pgdp, start, end, PAGE_KERNEL_TAGGED, flags); //映射到线性区域 } /* [4] */ __map_memblock(pgdp, kernel_start, kernel_end, PAGE_KERNEL, NO_CONT_MAPPINGS); /* [5] */ memblock_clear_nomap(kernel_start, kernel_end - kernel_start); }
为了方便抓住主题讲解,上面的代码去掉源代码的注释部分。
- [1]kernel_start ~ kernel_end 表示内核镜像部分内存
- [2]将kernel_start~kernel_end所在的memblock标记为MEMBLOCK_NOMAP, 这样下面遍历系统memblock时可过滤掉kernel image部分
- [3]遍历系统中所有的memblock,并通过__map_memblock()建立线性区域的虚实映射(不包括MEMBLOCK_NOMAP标记部分)
- [4]再使用不同的属性和flag对内核镜像内存部分建立线性虚实映射
- [5]清除内核镜像内存memblock的MEMBLOCK_NOMAP标记
这里我们重点关注建立线性映射的函数[4]__map_memblock():
static void __init __map_memblock(pgd_t *pgdp, phys_addr_t start, phys_addr_t end, pgprot_t prot, int flags) { __create_pgd_mapping(pgdp, start, __phys_to_virt(start), end - start, prot, early_pgtable_alloc, flags); }
参数pgdp是页全局目录swapper_pg_dir,在内核初始化完成后,内核态问内存时MMU都使用swapper_pg_dir作为唯一全局页目录。
参数start和end分别是一个memblock的起、止物理地址。
上面的函数实际就是调用__create_pgd_mapping()函数在swapper_pg_dir页表中建立物理地址 [start, end] 到 [__phys_to_virt(start), __phys_to_virt(end)]的虚实映射。其中__phys_to_virt(x)是将物理地址转换为对应的虚拟地址:
#define __phys_to_virt(x) ((unsigned long)((x) - PHYS_OFFSET) | PAGE_OFFSET)
前面的[3]和[4]遍历内核中所有memblock感知到的物理内存,并为这些物理内存建立了虚实映射,映射到虚拟地址空间的线性区域。这个虚实映射的线性关系为:paddr = vaddr + ( PAGE_OFFSET - PHYS_OFFSET)。 这里的( PAGE_OFFSET - PHYS_OFFSET)就是虚地址与物理地址线性映射的偏移。
也就是说在arm64架构中,系统MMU完成初始化后就可以通过这个线性关系将一个线性区域的虚拟地址转换为物理地址(但是并非所有的虚拟地址对应着有效的物理地址),也可以在知道物理地址的情况下将物理地址转换为线性区域虚拟地址供CPU进行访问(理论上linux可探测到的物理内存都可以找到一个合法的线性区域虚拟地址)。
小结:系统中所有的物理内存都有线性映射的虚拟地址,这个虚实映射关系的建立是在系统启动阶段map_mem()确定的。
1.2.2 PAGE_OFFSET与PHYS_OFFSET的真面目
PAGE_OFFSET与PHYS_OFFSET的值是什么?为什么选二者作为线性映射区的参数?
- PAGE_OFFSET:Linux内核虚拟地址空间以及内核线性区域的起始地址。要了解它需要先了解一下arm64地址空间布局(参考:Memory Layout on AArch64 Linux)
Start End Size Use
-----------------------------------------------------------------------
0000000000000000 0000ffffffffffff 256TB user
ffff000000000000 ffff7fffffffffff 128TB kernel logical memory map
[ffff600000000000 ffff7fffffffffff] 32TB [kasan shadow region]
ffff800000000000 ffff800007ffffff 128MB bpf jit region
ffff800008000000 ffff80000fffffff 128MB modules
ffff800010000000 fffffbffefffffff 124TB vmalloc
fffffbfff0000000 fffffbfffdffffff 224MB fixed mappings (top down)
fffffbfffe000000 fffffbfffe7fffff 8MB [guard region]
fffffbfffe800000 fffffbffff7fffff 16MB PCI I/O space
fffffbffff800000 fffffbffffffffff 8MB [guard region]
fffffc0000000000 fffffdffffffffff 2TB vmemmap
fffffe0000000000 ffffffffffffffff 2TB [guard region]
上面是arm64 经典48-bit虚拟地址空间的布局,这个布局对于arm64 48-bit来说是固定的。前面在分析__is_lm_address(addr)宏时就已经提及48-bit的arm64系统中线性区域为[PAGE_OFFSET, PAGE_END) = [0xFFFF000000000000, 0xFFFF800000000000),即
上面内存布局中标红区域;PAGE_OFFSET 就是内核虚拟地址空间线性地址的起始位置。顺便提一下,PAGE_OFFSET以下的区间是user用户态地址区间,因而PAGE_OFFSET还是内核虚拟地址空间的起始位置。
一旦内核的虚拟地址长度(可以是39bit/48bit/52bit)确定,PAGE_OFFSET的值也就确定。
- PHYS_OFFSET:PHYS_OFFSET表示arm64架构中物理内存的物理起始地址(具体见1.3.2.2);不同的arm64机器/单板,物理内存总线的起始地址不尽相同,因而PHYS_OFFSET也就不同。
由于线性区域的虚拟地址与物理地址是线性映射关系,且线性区域的虚拟地址是固定的,即使各种arm64机器的物理地址分布会有不同。在这种情况下为了能够使的线性关系能够成立就需要虚拟<-->物理地址的偏移中加入PHYS_OFFSET这个变量来适应不同的物理内存(起始地址)情况,即(PAGE_OFFSET - PHYS_OFFSET)。
小结:PAGE_OFFSET既是arm64中内核虚拟地址空间的起始地址,也是内核虚拟地址空间线性区域的起始地址;而PHYS_OFFSET则是系统中内存的物理地址起始位置,二者通过(PAGE_OFFSET - PHYS_OFFSET)组合称为线性区域的线性偏移。
1.3 内核镜像虚拟地址to物理地址
由前面的分析可知,如果虚拟地址addr不在线性区域,则使用__kimg_to_phys(addr)宏来完成的虚拟地址到物理地址的转换,其作用是将kernel image虚拟地址转换为物理地址。什么是kernel image呢?典型的就是内核镜像中的数据段、代码段,你引用内核中定义的一个全局变量,一个函数,这些都是kernel image;其特点就是内核编译完成后这些符号的虚拟地址就已经确定。
这个转换关系更加简单:
#define __kimg_to_phys(addr) ((addr) - kimage_voffset)
也就是说,kernel image中虚拟地址与物理地址也是线性映射关系,它们之间的线性偏移为kimage_voffset。
在boot进入内核时会将内核镜像文件(也就是编译后生成的vmlinux或者zImage...)从存储空间拷贝到物理内存的某个位置,这样内核镜像中的代码段、数据段等等的物理内存地址就确定下来;接着在内核页表初始阶段内核会建立内存镜像物理内存的虚实映射映射,以便MMU开启后CPU可以通过虚拟地址进行访问。
1.3.1 kernel image虚实映射关系的确定
Linux中为kernel image物理内存建立页表映射关系是map_kernel(pgdp)完成的。
static void __init map_kernel(pgd_t *pgdp) { ...... /* * Only rodata will be remapped with different permissions later on, * all other segments are allowed to use contiguous mappings. */ map_kernel_segment(pgdp, _stext, _etext, text_prot, &vmlinux_text, 0, VM_NO_GUARD); map_kernel_segment(pgdp, __start_rodata, __inittext_begin, PAGE_KERNEL, &vmlinux_rodata, NO_CONT_MAPPINGS, VM_NO_GUARD); map_kernel_segment(pgdp, __inittext_begin, __inittext_end, text_prot, &vmlinux_inittext, 0, VM_NO_GUARD); map_kernel_segment(pgdp, __initdata_begin, __initdata_end, PAGE_KERNEL, &vmlinux_initdata, 0, VM_NO_GUARD); map_kernel_segment(pgdp, _data, _end, PAGE_KERNEL, &vmlinux_data, 0, 0); ...... }
上面只列出kernel image内存映射部分相关代码。其中map_kernel_segment()函数就是建立kernel image虚拟内存与物理内存映射的函数。根据kernel image中不同内存段的属性分别使用不同的pgprot和vm_flags参数来建立映射。
static void __init map_kernel_segment(pgd_t *pgdp, void *va_start, void *va_end, pgprot_t prot, struct vm_struct *vma, int flags, unsigned long vm_flags) { phys_addr_t pa_start = __pa_symbol(va_start); //[1] unsigned long size = va_end - va_start; ...... /* [2] */ __create_pgd_mapping(pgdp, pa_start, (unsigned long)va_start, size, prot, early_pgtable_alloc, flags); ..... }
- [1]宏__pa_symbol(addr)展开后为((addr) - kimage_voffset)。
pa = (va - kimage_voffset)计算出va_start对应的物理地址。通过计算公式可以知道处于kernel image内存物理地址与虚拟地址相差一个kimage_voffset线性偏移。
- [2]入参pgdp就是swap_pg_dir,即内核全局页表。有了虚拟地址、物理地址和页表,然后通过__create_pgd_mapping()为他们建立映射,这样内核就可以通过虚拟地址访问到对应的物理地址了。
这样kernel image中的各个symbol就建立了paddr = vaddr - kimage_voffset的映射关系,cpu就能够在欢快的通过虚拟地址访问到物理内存了。
1.3.2 kimage_voffset的真貌
听起来似乎很合理,但是仔细想想又觉得哪里不对。
首先,这些虚拟地址,也就是kernel image中的各个symbol的虚拟地址是什么呢?如确定的呢?
其次,kernel image内存虚拟地址与物理内存地址的偏移kimage_voffset是如何确定的呢?
1.3.2.1 kernel image的虚拟地址
首先看第一个问题,kernel image各个symbol的虚拟地址。
前面的map_kernel(pgd_t *pgdp)函数调用了5次map_kernel_segment()分别为kernel image中属性不同的各个段建立映射,这些函数调用中
map_kernel_segment(pgd_t *pgdp, void *va_start, void *va_end, pgprot_t prot, struct vm_struct *vma,int flags, unsigned long vm_flags)
的入参va_start、va_end分别是:
- _stext,_etext
- __start_rodata,__inittext_begin,
- __inittext_begin,__inittext_end,
- __initdata_begin,__initdata_end,
- _data,_end
这些入参分别都是kernel image中各个段起、止虚拟地址,它们的定义在arch/arm64/kernel/vmlinux.lds.S中,这些符号的虚拟地址在内核编译链接阶段就已经确定。其中_stext是kernel image代码段起始地址,其他段都依次放到后面。
由于代码段放在kernel image的低端,因而后面依次存放的段的地址都由_stext,即代码段的起始位置决定,我们来看看vmlinux.lds.S的定义:
. = KIMAGE_VADDR; .head.text : { _text = .; HEAD_TEXT } .text : ALIGN(SEGMENT_ALIGN) { /* Real text segment */ _stext = .; /* Text and read-only data */
从上面可以看出_stext的地址由KIMAGE_VADDR这个宏决定(二者之间相差.head.text的偏移),
#define KIMAGE_VADDR (MODULES_END)
KIMAGE_VADDR这个宏在arch/arm64/include/asm/memory.h文件定义,其值等于MODULES_END。从前面内存布局可以看出
- ffff800008000000 ffff80000fffffff 128MB modules
- ffff800010000000 fffffbffefffffff 124TB vmalloc
虚拟地址区间modules的末端,即MODULES_END,紧挨着vmalloc虚拟区间,因而kernel image的虚拟地址区间是放在vmalloc区间的;而kernel image中各个symbol虚拟地址和自身所处的段(代码段、BSS段、数据段等等)有关,kernel image各个段的地址在vmlinux.lds.S中又通过其相对于_stext的偏移而确定下来。
好了,这样第一个问题就有了答案:也就是kernel image中的各个symbol的虚拟地址由vmlinux.lds.S和宏KIMAGE_VADDR宏确定。
1.3.2.2 kimage_voffset的来源
第二个问题,我们来看看kimage_voffset的来龙去脉。
kimage_voffset是在boot刚刚进入linux kernel内核初期初始化的,初始化代码在arch/arm64/kernel/head.S中,如下所示:
///////[1] SYM_FUNC_START_LOCAL(__primary_switch) ....... adrp x1, init_pg_dir bl __enable_mmu ...... ldr x8, =__primary_switched adrp x0, __PHYS_OFFSET br x8 SYM_FUNC_END(__primary_switch) ///////[2] /* * The following fragment of code is executed with the MMU enabled. * * x0 = __PHYS_OFFSET */ SYM_FUNC_START_LOCAL(__primary_switched) ...... ldr_l x4, kimage_vaddr // Save the offset between sub x4, x4, x0 // the kernel virtual and str_l x4, kimage_voffset, x5 // physical mappings .....
【1】这段程序是准备好入参,然后跳转到__primary_switched执行。这里要特别说明
- ldr x8, =__primary_switched //这条指令是将__primary_switched虚拟地址放到x8寄存器
- adrp x0, __PHYS_OFFSET //这条指令是将"__PHYS_OFFSET"这个lable的物理地址取到x0寄存器,x0作为函数调用的第一个参数 (是怎么取到物理地址的呢?参考最后一章)
__PHYS_OFFSET这个又是什么呢?
//arch/arm64/kernel/head.S #define __PHYS_OFFSET KERNEL_START //arch/arm64/include/asm/memory.h #define KERNEL_START _text
从上面的宏展开我们知道,__PHYS_OFFSET实际上最终就是_text,它在kernel image的物理上的起始位置。因而kernel image由boot拷贝到内存后,镜像在物理内存的起始位置就是_text所在的位置。因而"adrp x0, __PHYS_OFFSET"这条指令就是将kernel image在内存中的物理起始地址放到x0。
- br x8 //然后跳转到__primary_switched这个虚拟地址处执行
【2】__primary_switched函数,入参x0保存的是kernel image在内存中起始物理地址。
- ldr_l x4, kimage_vaddr //kimage_vaddr的定义如下。这是一个内存变量,该变量存的值为_text这个符号的虚拟地址,也就是kernel image虚拟地址空间的起始地址。
SYM_DATA_START(kimage_vaddr)
.quad _text
ldr_l和后面的str_l都是arch/arm64/kernel/head.S文件中定义的宏:
.macro ldr_l, dst, sym, tmp= .ifb \tmp adrp \dst, \sym ldr \dst, [\dst, :lo12:\sym] .else adrp \tmp, \sym ldr \dst, [\tmp, :lo12:\sym] .endif .endm
ldr_l, dst, sym, tmp= :这个宏是将符号sym中存放的内容取到dst中;对于"ldr_l x4, kimage_vaddr",由于当前PC已经运行在虚拟地址空间,因而这条指令取的是kimage_vaddr中存放的内容(_text的虚拟地址)到x4寄存器。
- sub x4, x4, x0 //kernel image 虚拟地址 - 物理地址 = 线性偏移,放到x4寄存器
- str_l x4, kimage_voffset, x5 //将x4中的线性偏移存放kimage_voffset中。这个str_l宏的实现如下:先通过PC+相对地址的方式取得kimage_voffset的虚拟地址,然后在将x4寄存器的值存入kimage_voffset这个符号对应的地址中。
/* * @src: source register (32 or 64 bit wide) * @sym: name of the symbol * @tmp: mandatory 64-bit scratch register to calculate the address * while <src> needs to be preserved. */ .macro str_l, src, sym, tmp adrp \tmp, \sym str \src, [\tmp, :lo12:\sym] .endm
x4里面的内容就是前一条指令计算的kernel image中 (虚拟地址 - 物理地址)的结果,即kernel image的线性偏移。因此kimage_voffset中存放的也就是kernel image内存虚实转换的线性偏移。
1.3 小结:内核镜像vmlinux或者bzImage从存储空间拷贝的内存中后linux内核的PC指针暂时运行在物理地址空间;后面打开MMU后还有一小段代码运行的虚拟地址空间与物理地址空间是一样的,此时通过相对地址找到kernel image的物理地址,然后再用vmlinux.lds.S中kernel image的虚拟地址与之相减就得到 kernel image虚实转换的线性偏移kimage_voffset。
二、__va(x)
前面章节对arm64架构中内核的虚拟地址转换为物理地址进行了分析。通过上面的分析可以知道:linux内核中并非所有的虚拟地址都能够转换为有效的物理地址;只有线性区域和kernel image区域中有效的虚拟地址才能够转换为有效的物理地址。
知道了如何将虚拟地址转换为物理地址,我们在再看看物理地址转换为虚拟地址的情况,这个是有__va(x)来完成的,此宏定义在arch/arm64/include/asm/memory.h文件:
#define __va(x) ((void *)__phys_to_virt((phys_addr_t)(x)))
#define __phys_to_virt(x) ((unsigned long)((x) - PHYS_OFFSET) | PAGE_OFFSET)
与__pa(x)不同的是,这个宏仅仅只做线性区域的转换:即给定一个物理地址x, __va(x)返回该物理地址对应的线性区域的虚拟地址。
全文完。
-------------------------------------------------------------------------------------------
注解:
关于adrp x0, __PHYS_OFFSET是如何将"__PHYS_OFFSET"这个lable的物理地址取到x0中的?
SYM_FUNC_START_LOCAL(__primary_switch) ....... adrp x1, init_pg_dir bl __enable_mmu ...... ldr x8, =__primary_switched adrp x0, __PHYS_OFFSET br x8 SYM_FUNC_END(__primary_switch)
在上面这个函数中:
(1) 在执行bl __enable_mmu指令之前系统还没有打开MMU,cpu运行在物理地址空间中;
(2) 运行 bl __enable_mmu指令后内核开启了MMU,但是PC取得的指令仍然是与物理地址空间时一致;但是由于有idmap 映射页表的存在,且__primary_switch是在__idmap_text_start ~ __idmap_text_end之间,享受idmap页表的照顾,即使运行时PC取的时物理地址执行也没有关系。
(3) adrp x0, __PHYS_OFFSET 指令:
adrp指令取__PHYS_OFFSET这个lable与当前PC的相对地址所在的4K页(对齐)base地址到x0。 由于adrp取的地址是4K对齐,因而指令操作码中的寻址部分 21位 在实施时要左移12位,因而可以寻址 2^33大小的范围,即+/-4G大小的范围。这个是与adr指令不同的地方。
同时,由于当前PC是的虚拟地址空间与物理地址空间相同,因而adrp取PC相对__PHYS_OFFSET这个label地址实际取的是它的物理地址。
(4) ldr x8, =__primary_switched
br x8
这两条指令完成PC从物理地址空间到真正虚拟地址空间的转换。
__primary_switched是内核的symbol,其虚拟地址在内核编译链接过程中已经确定(对于linux-5.11内核,kernel image虚拟地址在vmalloc区间),
然后ldr x8, =__primary_switched 将__primary_switched这个符号的虚拟地址取到x8;
接着br x8指令执行,PC就跳转到真正的虚拟地址空间取执行了。