引言
隨著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的反操作。