随着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。
在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)
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)
在经过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 index9999: 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个参数,分别如下意思。
我们还是依然翻译成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)
如何使用上述三个接口创建映射关系呢?其实很简单,首先我们需要先调用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_startffff200008fbc000 T kimage_vaddrffff200008fbc008 T el2_setupffff200008fbc054 t set_hcrffff200008fbc118 t install_el2_stubffff200008fbc16c t set_cpu_boot_mode_flagffff200008fbc190 T secondary_holding_penffff200008fbc1b4 t penffff200008fbc1c8 T secondary_entryffff200008fbc1d4 t secondary_startupffff200008fbc1e4 t __secondary_switchedffff200008fbc218 T __enable_mmuffff200008fbc26c t __no_granule_supportffff200008fbc290 t __primary_switchffff200008fbc2b0 T cpu_resumeffff200008fbc2d0 T cpu_do_resumeffff200008fbc340 T idmap_cpu_replace_ttbr1ffff200008fbc370 T __cpu_setupffff200008fbc3f0 t crvalffff200008fbc408 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 */
经过以上初始化,页表就算是初始化完成。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区域。
通过上面的介绍,你应该有所了解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
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 */ }}
memstart_addr的值定下来之后,虚拟地址和物理地址以memstart_addr为偏差创建线性映射区域。在map_mem函数中完成。phys_to_virt宏的实现就不用介绍了,就是virt_to_phys的反操作。