ARM64 Kernel Image Mapping的變化
作者:smcdef 發佈於:2018-4-21 20:25 分類:內存管理
引言
隨着linux的代碼更新,閱讀linux-4.15代碼,從中發現很多與衆不同的地方。之所以與衆不同,就是因爲和我之前從網上博客或者書籍中看到的內容有所差異。當然了,並不是爲了表明書上或者博客的觀點是錯誤的。而是因爲linux代碼更新的太快,網上的博客和書籍跟不上linux的步伐而已。究竟是哪些發生了差異了?例如:kernel image映射區域從原來的linear mapping region(線性映射區域)搬移到VMALLOC區域。因此,我希望通過本篇文章揭曉這些差異。當然,我相信不久的將來這篇文章也將會成爲一段歷史。
注:文章代碼分析基於linux-4.15,架構基於aarch64(ARM64)。涉及頁表代碼分析部分,假設頁表映射層級是4,即配置CONFIG_ARM64_PGTABLE_LEVELS=4。地址寬度是48,即配置CONFIG_ARM64_VA_BITS=48。
kernel啓動頁表在哪裏
在ARM64架構上,彙編代碼初始化階段會創建兩次地址映射。第一次是爲了打開MMU操作的準備。因爲再打開MMU之前,當前代碼運行在物理地址之上,而打開MMU之後代碼運行在虛擬地址之上。爲了從物理地址(Physical Address,簡稱PA)轉換到虛擬地址(Virtual Address,簡稱VA)的平滑過渡,ARM推薦創建VA和PA相等的一段映射(例如:虛擬地址addr通過頁表查詢映射的物理地址也是addr)。這段映射在linux中稱爲identity mapping。第二次是kernel image映射。而這段映射在linux-4.15代碼上映射區域是VMALLOC區域。
kernel啓動開始首先就會打開MMU,但是打開MMU之前,我們需要填充頁表。也就是告訴MMU虛擬地址和物理地址的對應關係。系統啓動初期使用section mapping,因此需要3個頁面存儲頁表項。但是我們有identity mapping和kernel image mapping,因此總需要6個頁面。那麼這6個頁面內存是在哪裏分配的呢?可以從vmlinux.lds.S中找到答案。
- BSS_SECTION(0, 0, 0)
- . = ALIGN(PAGE_SIZE);
- idmap_pg_dir = .;
- . += IDMAP_DIR_SIZE;
- swapper_pg_dir = .;
- . += SWAPPER_DIR_SIZE;
從鏈接腳本中可以看到預留6個頁面存儲頁表項。緊跟在bss段後面。idmap_pg_dir是identity mapping使用的頁表。swapper_pg_dir是kernel image mapping初始階段使用的頁表。請注意,這裏的內存是一段連續內存。也就是說頁表(PGD/PUD/PMD)都是連在一起的,地址相差PAGE_SIZE(4k)。
如何填充頁表的頁表項
從鏈接腳本vmlinux.lds.S文件中可以找到kernel代碼起始代碼段是".head.text"段,因此kernel的代碼起始位置位於arch/arm64/kernel/head.S文件_head
標號。在head.S文件中有三個宏定義和創建地址映射相關。分別是:create_table_entry
、create_pgd_entry
和create_block_map
。
create_table_entry實現如下。
- /*
- * Macro to create a table entry to the next page.
- *
- * tbl: 頁表基地址
- * virt: 需要創建地址映射的虛擬地址
- * shift: #imm page table shift
- * ptrs: #imm pointers per table page
- *
- * Preserves: virt
- * Corrupts: tmp1, tmp2
- * Returns: tbl -> next level table page address
- */
- .macro create_table_entry, tbl, virt, shift, ptrs, tmp1, tmp2
- lsr \tmp1, \virt, #\shift
- and \tmp1, \tmp1, #\ptrs - 1 // table index
- add \tmp2, \tbl, #PAGE_SIZE
- orr \tmp2, \tmp2, #PMD_TYPE_TABLE // address of next table and entry type
- str \tmp2, [\tbl, \tmp1, lsl #3]
- add \tbl, \tbl, #PAGE_SIZE // next level table page
- .endm
這裏是彙編中的宏定義。彙編中宏定義是以.macro
開頭,以.endm
結尾。宏定義中以\x
來引用宏定義中的參數x
。該宏定義的作用是創建一個level的頁表項(PGD/PUD/PMD)。具體是哪個level是由virt、shift和ptrs參數決定。我總是喜歡幫你翻譯成C語言的形式。C語言如果不懂的話,我也沒辦法了。既然彙編你不熟悉,沒關係,下面幫你轉換成C語言的宏定義。
- #define PAGE_SIZE (1 << 12)
- #define PMD_TYPE_TABLE (3 << 0)
- #define create_table_entry(tbl, virt, shift, ptrs, tmp1, tmp2) do { \
- tmp1 = virt >> shift; /* 1 */ \
- tmp1 &= ptrs - 1; /* 1 */ \
- tmp2 = tbl + PAGE_SIZE; /* 2 */ \
- tmp2 |= PMD_TYPE_TABLE; /* 3 */ \
- *((long *)(tbl + (tmp1 << 3))) = tmp2; /* 4 */ \
- tbl += PAGE_SIZE; /* 5 */ \
- } while (0)
- 根據virt和ptrs參數計算該虛擬地址virt的頁表項在頁表中的index。例如計算virt地址在PGD也表中的indedx,可以傳遞shift = PGDIR_SHIFT,ptrs = PTRS_PER_PGD,tbl傳遞PGD頁表基地址。所以,宏定義是一個創建中間level的頁表項。
- 既然要填充當前level的頁表項就需要告知下一個level頁表的基地址,這裏就是計算下一個頁表的基地址。還記得上面說的idmap_pg_dir和swapper_pg_dir嗎?頁表(PGD/PUD/PMD)都是連在一起的,地址相差PAGE_SIZE。
- 告知MMU這是一箇中間level頁表並且是有效的。
- 頁表項的真正填充操作,tmp1 << 3是因爲ARM64的地址佔用8bytes。
- 更新tbl,也就只指向下一個level頁表的地址,可以方便再一次調用create_table_entry填充下一個level頁表項而不用自己更新tbl。
create_pgd_entry的實現如下。
- /*
- * Macro to populate the PGD (and possibily PUD) for the corresponding
- * block entry in the next level (tbl) for the given virtual address.
- *
- * Preserves: tbl, next, virt
- * Corrupts: tmp1, tmp2
- */
- .macro create_pgd_entry, tbl, virt, tmp1, tmp2
- create_table_entry \tbl, \virt, PGDIR_SHIFT, PTRS_PER_PGD, \tmp1, \tmp2
- create_table_entry \tbl, \virt, SWAPPER_TABLE_SHIFT, PTRS_PER_PTE, \tmp1, \tmp2
- .endm
create_pgd_entry可以用來填充PGD、PUD、PMD等中間level頁表對應頁表項。雖然名字是創建PGD的描述符,但是實際上是一級一級的創建頁表項,最終只留下最後一級頁表沒有填充頁表項。老規矩轉換成C語言分析。
- #define SWAPPER_TABLE_SHIFT PUD_SHIFT
- #define create_pgd_entry(tbl, virt, tmp1, tmp2) do { \
- create_table_entry(tbl, virt, PGDIR_SHIFT, PTRS_PER_PGD, tmp1, tmp2); /* 1 */ \
- create_table_entry(tbl, virt, SWAPPER_TABLE_SHIFT, PTRS_PER_PTE, tmp1, tmp2); /* 2 */ \
- } while (0)
- 這裏的tbl參數相當於PGD頁表地址,調用create_table_entry創建PGD頁表中virt地址對應的頁表項。
- 填充下一個level的頁表項。這裏是PUD頁表。由於使用了ARM64初期使用section mapping,因此PUD頁表就是最後一箇中間level的頁表,所以只剩下PMD頁表的頁表項沒有填充,virt地址對應的PMD頁表項最終會填充block descriptor。假設這裏使用4級頁表,那麼下面還會創建PMD頁表的頁表項,也就是只留下PTE頁表。所以,宏定義是創建所有中間level的頁表項,只留下最後一級頁表。
在經過create_pgd_entry宏的調用後,就填充好了從PGD開始的所有中間level的頁表的頁表項的填充操作。現在是不是只剩下PTE頁表的頁表項沒有填充呢?所以最後一個create_block_map就是完成這個操作的。
- /*
- * Macro to populate block entries in the page table for the start..end
- * virtual range (inclusive).
- *
- * Preserves: tbl, flags
- * Corrupts: phys, start, end, pstate
- */
- .macro create_block_map, tbl, flags, phys, start, end
- lsr \phys, \phys, #SWAPPER_BLOCK_SHIFT
- lsr \start, \start, #SWAPPER_BLOCK_SHIFT
- and \start, \start, #PTRS_PER_PTE - 1 // table index
- orr \phys, \flags, \phys, lsl #SWAPPER_BLOCK_SHIFT // table entry
- lsr \end, \end, #SWAPPER_BLOCK_SHIFT
- and \end, \end, #PTRS_PER_PTE - 1 // table end index
- 9999: str \phys, [\tbl, \start, lsl #3] // store the entry
- add \start, \start, #1 // next entry
- add \phys, \phys, #SWAPPER_BLOCK_SIZE // next block
- cmp \start, \end
- b.ls 9999b
- .endm
create_block_map宏的作用是創建虛擬地址(從start到end)區域映射到到phys物理地址。傳入5個參數,分別如下意思。
- tbl:頁表基地址
- flags:將要填充頁表項的flags
- phys:創建映射的物理地址
- start:創建映射的虛擬地址起始地址
- end:創建映射的虛擬地址結束地址
我們還是依然翻譯成C語言分析。
- #define SWAPPER_BLOCK_SHIFT PMD_SHIFT
- #define SWAPPER_BLOCK_SIZE (1 << PMD_SHIFT)
- #define create_block_map(tbl, flags, phys, start, end) do { \
- phys >>= SWAPPER_BLOCK_SHIFT; /* 1 */ \
- start >>= SWAPPER_BLOCK_SHIFT; /* 2 */ \
- start &= PTRS_PER_PTE - 1; /* 2 */ \
- phys = flags | (phys << SWAPPER_BLOCK_SHIFT);/* 3 */ \
- end >>= SWAPPER_BLOCK_SHIFT; /* 4 */ \
- end &= PTRS_PER_PTE - 1; /* 4 */ \
- \
- while (start != end) { /* 5 */ \
- *((long *)(tbl + (start << 3))) = phys; /* 6 */ \
- start++; /* 7 */ \
- phys += SWAPPER_BLOCK_SIZE; /* 8 */ \
- } \
- } while (0)
- 針對phys的低SWAPPER_BLOCK_SHIFT位進行清零,和第三步驟的phys << SWAPPER_BLOCK_SHIFT收尾呼應。相當於對齊(這裏的情況是2M對齊)。
- 計算起始地址start的頁目錄項的index。
- 構造描述符。
- 計算結束地址end的頁目錄項的index。
- 循環填充start到end的頁目錄項。
- 根據頁表基地址tbl和當前的start變量填充對應的頁表項。start << 3是因爲ARM64地址佔用8 bytes。
- 更新下一個頁表項。
- 更新下一個block的物理地址。
如何使用上述三個接口創建映射關係呢?其實很簡單,首先我們需要先調用create_pgd_entry宏填充PGD以及所有中間level的頁表項。最後的PMD頁表的填充可以調用create_block_map宏來完成操作。
如何創建頁表
在彙編代碼階段的head.S文件中,負責創建映射關係的函數是create_page_tables。create_page_tables函數負責identity mapping和kernel image mapping。前文提到identity mapping主要是打開MMU的過度階段,因此對於identity mapping不需要映射整個kernel,只需要映射操作MMU代碼相關的部分。如何區分這部分代碼呢?當然是利用linux中常用手段自定義代碼段。自定義的代碼段的名稱是".idmap.text"。除此之外,肯定還需要在鏈接腳本中聲明兩個標量,用來標記代碼段的開始和結束。可以從vmlinux.lds.S中找到答案。
- #define IDMAP_TEXT \
- . = ALIGN(SZ_4K); \
- VMLINUX_SYMBOL(__idmap_text_start) = .; \
- *(.idmap.text) \
- VMLINUX_SYMBOL(__idmap_text_end) = .;
從鏈接腳本中可以看出idmap_text_start和idmap_text_end分別是.idmap.text段的起始和結束地址。在創建identity mapping的時候會使用。另外我們同樣從鏈接腳本中得到_text和_end兩個變量,分別是kernel代碼鏈接的開始和結束地址。編譯器的鏈接地址實際上就是最後代碼期望運行的地址。在KASLR關閉的情況下就是kernel image需要映射的虛擬地址。當我們編譯kernel後,可以根據符號表System.map文件查看哪些函數被放在".idmap.text"段。當然你也可以看代碼,但是我覺得沒有這種方法簡單。
- ffff200008fbc000 T __idmap_text_start
- ffff200008fbc000 T kimage_vaddr
- ffff200008fbc008 T el2_setup
- ffff200008fbc054 t set_hcr
- ffff200008fbc118 t install_el2_stub
- ffff200008fbc16c t set_cpu_boot_mode_flag
- ffff200008fbc190 T secondary_holding_pen
- ffff200008fbc1b4 t pen
- ffff200008fbc1c8 T secondary_entry
- ffff200008fbc1d4 t secondary_startup
- ffff200008fbc1e4 t __secondary_switched
- ffff200008fbc218 T __enable_mmu
- ffff200008fbc26c t __no_granule_support
- ffff200008fbc290 t __primary_switch
- ffff200008fbc2b0 T cpu_resume
- ffff200008fbc2d0 T cpu_do_resume
- ffff200008fbc340 T idmap_cpu_replace_ttbr1
- ffff200008fbc370 T __cpu_setup
- ffff200008fbc3f0 t crval
- ffff200008fbc408 T __idmap_text_end
create_page_tables的彙編代碼比較簡單,就不轉換成C語言講解了。create_page_tables實現如下。
- __create_page_tables:
- mov x7, SWAPPER_MM_MMUFLAGS
- /*
- * Create the identity mapping.
- */
- adrp x0, idmap_pg_dir /* 1 */
- adrp x3, __idmap_text_start // __pa(__idmap_text_start) /* 2 */
- create_pgd_entry x0, x3, x5, x6 /* 3 */
- mov x5, x3 // __pa(__idmap_text_start) /* 4 */
- adr_l x6, __idmap_text_end // __pa(__idmap_text_end)
- create_block_map x0, x7, x3, x5, x6 /* 5 */
- /*
- * Map the kernel image.
- */
- adrp x0, swapper_pg_dir /* 6 */
- mov_q x5, KIMAGE_VADDR + TEXT_OFFSET // compile time __va(_text)
- add x5, x5, x23 // add KASLR displacement /* 7 */
- create_pgd_entry x0, x5, x3, x6 /* 8 */
- adrp x6, _end // runtime __pa(_end)
- adrp x3, _text // runtime __pa(_text)
- sub x6, x6, x3 // _end - _text
- add x6, x6, x5 // runtime __va(_end)
- create_block_map x0, x7, x3, x5, x6 /* 9 */
- x0寄存器PGD頁表基地址,這裏是idmap_pg_dir,是爲了創建identity mapping。
- adrp指令可以獲取__idmap_text_start符號的實際運行物理地址。
- 填充PGD及中間level頁表的頁表項。
- 因爲我們爲了創建虛擬地址和物理地址相等的映射,因此這裏的x5和x3值相等。
- 調用create_block_map創建identity mapping,注意這裏傳遞的參數物理地址(x3)和虛擬地址(x5)相等。
- 創建kernel image mapping,PGD頁表基地址是swapper_pg_dir。
- KASLR默認關閉的情況下,x23的值爲0。
- 填充PGD及中間level頁表的頁表項。
- 填充PMD頁表項。因爲採用的是section mapping,所以一個頁表項對應2M大小。注意彙編中的註釋,va()代表得到的事虛擬地址,pa()得到的是物理地址。
經過以上初始化,頁表就算是初始化完成。kernel映射區域從先行映射區域遷移到VMALLOC區域在哪裏體現呢?答案就是KIMAGE_VADDR宏定義。KIMAGE_VADDR是kernel的虛擬地址。其定義在arch/arm64/mm/memory.h文件。
- #define VA_BITS (CONFIG_ARM64_VA_BITS)
- #define VA_START (UL(0xffffffffffffffff) - (UL(1) << VA_BITS) + 1)
- #define PAGE_OFFSET (UL(0xffffffffffffffff) - (UL(1) << (VA_BITS - 1)) + 1)
- #define KIMAGE_VADDR (MODULES_END)
- #define VMALLOC_START (MODULES_END)
- #define VMALLOC_END (PAGE_OFFSET - PUD_SIZE - VMEMMAP_SIZE - SZ_64K)
- #define MODULES_END (MODULES_VADDR + MODULES_VSIZE)
- #define MODULES_VADDR (VA_START + KASAN_SHADOW_SIZE)
- #define MODULES_VSIZE (SZ_128M)
- #define VMEMMAP_START (PAGE_OFFSET - VMEMMAP_SIZE)
- #define PCI_IO_END (VMEMMAP_START - SZ_2M)
- #define PCI_IO_START (PCI_IO_END - PCI_IO_SIZE)
- #define FIXADDR_TOP (PCI_IO_START - SZ_2M)
- #define TASK_SIZE_64 (UL(1) << VA_BITS)
上面的宏定義顯得不夠直觀,畫張圖表示現階段kernel的地址空間分佈情況。可以看出KIMAGE_VADDR正好處在VMALLOC區域,因此kernnel的運行地址位於VMALLOC區域。
virt_to_phys和phys_to_virt怎麼辦
通過上面的介紹,你應該有所瞭解kernel image和linear mapping region不在一個區域。virt_to_phys宏的作用是將內核虛擬地址轉換成物理地址(針對線性映射區域)。在kernel image還在線性映射區域的時候,virt_to_phys宏可以將kernel代碼中的一個地址轉換成物理地址,因爲線性映射區域,物理地址和虛擬地址只有一個偏移。因此兩者很容易轉換。那麼現在kernel image和線性映射區域分開了,virt_to_phys宏又該如何實現呢?virt_to_phys宏實現如下。
- #define PHYS_OFFSET ({ VM_BUG_ON(memstart_addr & 1); memstart_addr; })
- #define __is_lm_address(addr) (!!((addr) & BIT(VA_BITS - 1)))
- #define __lm_to_phys(addr) (((addr) & ~PAGE_OFFSET) + PHYS_OFFSET)
- #define __kimg_to_phys(addr) ((addr) - kimage_voffset)
- #define __virt_to_phys_nodebug(x) ({ \
- phys_addr_t __x = (phys_addr_t)(x); \
- __is_lm_address(__x) ? __lm_to_phys(__x) : \
- __kimg_to_phys(__x); \
- #define __virt_to_phys(x) __virt_to_phys_nodebug(x)
- static inline phys_addr_t virt_to_phys(const volatile void *x)
- {
- return __virt_to_phys((unsigned long)(x));
- }
從__virt_to_phys_nodebug宏可以看出其中的奧祕。通過addr地址的(VA_BITS - 1)位是否爲1判斷addr是位於kernel image區域還是線性映射區域(因爲線性映射區域大小正好是kernel虛擬地址空間大小的一半)。針對線性映射區域,虛擬地址和物理地址的偏差是memstart_addr。針對kernel image區域,虛擬地址和物理地址的偏差是kimage_voffset。kimage_voffset和memstart_addr是如何計算的呢?先看看kimage_voffset的計算。
- #define KERNEL_START _text
- #define __PHYS_OFFSET (KERNEL_START - TEXT_OFFSET)
- ENTRY(kimage_vaddr)
- .quad _text - TEXT_OFFSET
- /*
- * The following fragment of code is executed with the MMU enabled.
- *
- * x0 = __PHYS_OFFSET
- */
- __primary_switched:
- ldr_l x4, kimage_vaddr // Save the offset between /* 2 */
- sub x4, x4, x0 // the kernel virtual and /* 3 */
- str_l x4, kimage_voffset, x5 // physical mappings /* 4 */
- b start_kernel
- __primary_switch:
- bl __enable_mmu
- ldr x8, =__primary_switched
- adrp x0, __PHYS_OFFSET /* 1 */
- br x8
- x0是_primary_switch函數中設置。x0寄存器通過adrp指令可以獲取運行時的地址。也就是實際運行的物理地址。你是不是好奇此時不是已經打開MMU了嘛!爲什麼adrp得到的運行地址就是物理地址呢?請往上翻看看_primary_switch函數是不是位於".idmap.text"段,那麼該段是identity mapping。因此獲取的運行地址雖然是虛擬地址,但是它和實際運行的物理地址相等。
- x4寄存器保存的是kernel image的運行的虛擬地址。你是不是又好奇這個地方爲什麼獲取的運行地址和物理地址不相等呢?其實是因爲__primary_switched函數映射在kernel image mapping區域。
- 計算虛擬地址和物理地址的偏移。
- 將偏移寫入kimage_voffset全局變量。
memstart_addr是kernel選取的物理基地址,memstart_addr在arm64_memblock_init函數中設置。arm64_memblock_init函數實現如下(截取部分代碼)。
- void __init arm64_memblock_init(void)
- {
- const s64 linear_region_size = -(s64)PAGE_OFFSET;
- /*
- * Ensure that the linear region takes up exactly half of the kernel
- * virtual address space. This way, we can distinguish a linear address
- * from a kernel/module/vmalloc address by testing a single bit.
- */
- BUILD_BUG_ON(linear_region_size != BIT(VA_BITS - 1)); /* 1 */
- /*
- * Select a suitable value for the base of physical memory.
- */
- memstart_addr = round_down(memblock_start_of_DRAM(), /* 2 */
- ARM64_MEMSTART_ALIGN);
- memblock_remove(max_t(u64, memstart_addr + linear_region_size, /* 3 */
- __pa_symbol(_end)), ULLONG_MAX);
- if (memstart_addr + linear_region_size < memblock_end_of_DRAM()) {
- /* ensure that memstart_addr remains sufficiently aligned */
- memstart_addr = round_up(memblock_end_of_DRAM() - linear_region_size,
- ARM64_MEMSTART_ALIGN); /* 4 */
- memblock_remove(0, memstart_addr); /* 5 */
- }
- }
- 從註釋以及代碼皆可以看出,PAGE_OFFSET是線性區域的開始虛擬地址。線性區域大小是整個kernel虛擬地址空間的一半。
- 選取一個合適的物理基地址,根據RAM的起始地址按照1G對齊。
- memstart_addr是選取的物理基地址。kernel虛擬地址空間一半大小作爲線性映射區域。因此最大支持的內存範圍是memstart_addr + linear_region_size。所以告訴memblock,超過這個區域的範圍都是非法的。
- 如果memstart_addr + linear_region_size的值小於RAM的結束地址,說明[memstart_addr, memstart_addr + linear_region_size]地址空間範圍的區域無法覆蓋整個RAM地址範圍。這時候就需要從RAM結束地址減去linear_region_size的值作爲memstart_addr。什麼時候會出現這種情況呢?當物理內存足夠大時,if語句就可能滿足條件了。
- 既然4滿足,自然這裏[0, memstart_addr]的地址空間需要從memblock中remove。
memstart_addr的值定下來之後,虛擬地址和物理地址以memstart_addr爲偏差創建線性映射區域。在map_mem函數中完成。phys_to_virt宏的實現就不用介紹了,就是virt_to_phys的反操作。
« 一次觸摸屏中斷調試引發的深入探究 | tty驅動分析»