本文基於: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就跳轉到真正的虛擬地址空間取執行了。