Linux深入理解内存管理8(基于Linux6.6)---创建内核临时页表
一、概述
1.1、什么是内核临时页表?
内核临时页表是内核创建的用于处理内存映射和虚拟内存访问的临时数据结构。通常,内核需要操作一些内存区域或进行地址转换时,可能会使用临时页表进行映射的设置,直到这些映射不再需要。临时页表的作用主要体现在以下几个方面:
- 内核空间地址映射:当内核需要访问某些内存区域(如用户空间、外部设备或特殊的内存区域)时,它可能需要为这些区域建立临时的地址映射。
- 跨地址空间操作:内核的操作可能涉及不同地址空间的映射,特别是在上下文切换或进程切换时。临时页表可以用于在不同的地址空间之间切换。
- 避免频繁修改当前页表:内核临时页表可以避免修改当前的页表,从而避免影响正常的内存管理工作流程。
1.2、内核临时页表的用途和场景
内核在一些操作中需要访问或映射用户空间以外的内存区域,通常会使用临时页表。以下是几个典型的场景:
1.启动时的页表初始化
在内核启动时,内核会设置和配置临时页表。这些临时页表主要用于以下目的:
- 引导加载器阶段:内核引导时,系统会使用临时页表来建立一些内存映射,保证内核能够正常加载并执行。
- 内核代码加载:内核通常会从物理内存中加载到虚拟内存空间,在这个过程中可能需要通过临时页表进行必要的地址转换。
2. 执行页表切换与上下文切换
在上下文切换(进程切换)时,内核需要使用临时页表来保存当前进程的地址空间映射,并设置新的进程地址空间。例如,当进程 A 被切换出去,而进程 B 被切换进来时,内核会创建一个临时页表来切换到进程 B 的地址空间。
3. 用户空间与内核空间的交互
在用户空间和内核空间之间进行交互时(例如,系统调用、内存映射文件、设备映射等),内核可能会使用临时页表来映射用户空间中的数据到内核空间,或者将内核空间中的数据映射到用户空间。临时页表的作用是确保这种跨空间访问的高效性和安全性。
4. 内存映射文件(Memory-mapped I/O)
当内核执行 内存映射 I/O(如映射设备的内存)时,它可能需要创建临时的页表条目,以便将文件或设备的内容映射到内核虚拟地址空间。临时页表用于管理这些映射,确保内核能高效地访问硬件或文件数据。
1.3、如何创建内核临时页表
创建内核临时页表的过程涉及以下几个关键步骤:
1. 分配内存页
内核需要分配一块物理内存作为临时页表。内存页可以通过 内核内存分配机制 来获取。内核通常通过 kmalloc()
或 get_free_page()
等函数来分配这些内存。
2. 初始化页表结构
Linux 内核使用页表项(Page Table Entries, PTE)来描述每一个虚拟地址到物理地址的映射。在创建临时页表时,内核需要初始化一个新的页表结构,并将其连接到当前的页表层次结构中。具体来说,页表结构一般是树状的,每一层代表不同的地址段。
内核会初始化临时页表的页表项,使其指向正确的物理地址,并为其设置适当的访问权限(如只读、读写等)。
3. 设置页表映射
一旦内核分配并初始化了临时页表,接下来需要设置地址映射。通常,内核通过一些内存映射函数,如 remap_pfn_range()
或 map_page()
来创建这些映射。
这些函数会在临时页表中插入页表项,将虚拟地址映射到物理地址,或者将内核空间与用户空间或设备空间进行映射。
4. 切换到临时页表
在执行操作时,内核需要临时切换到这个临时页表,以便访问特定的内存区域。这通常是通过修改 CR3 寄存器(在 x86 架构下)来完成的,CR3 寄存器存储了当前页表的物理地址。切换到临时页表意味着 CPU 会使用新的页表来进行地址转换,直到操作完成。
5. 清理临时页表
临时页表只在需要时存在,操作完成后,内核需要释放这些临时结构,清理相应的内存。这通常是通过调用 free_page()
或类似的内存释放函数来实现。
1.4、内核临时页表与普通页表的区别
- 生命周期:普通页表通常是长期存在的,作为进程地址空间的一部分,而内核临时页表只在特定的短期操作中存在。
- 功能:普通页表主要用于管理用户进程的虚拟地址空间,而临时页表用于在特定操作(如上下文切换、内存映射等)中提供临时的地址映射。
- 作用范围:普通页表的作用范围通常是用户空间,而临时页表可以涉及到内核空间与用户空间的映射操作。
二、ARM映射机制
下图展示了ARM使用不同方式映射的地址查找过程:
描述符的分类为:
- Section: 20位,只支持一级页表,Linux中在建立临时页表的时候采用这种方式。
- Large pages:16位,64KB的页表大小,支持二级页表。
- Small pages:12位,页表大小为4Kb,Linux中在建立永久页表采用这种方式。
- SuperSection:24位,可选,主要是支持大物理地址扩展必须支持。
对于4K的转换过程跟X86的转换过程基本一样,只是对于页全局目录表变成了其他的寄存器,其转换过程如下:
- 根据TTBRCR寄存器和虚拟地址使用判断使用那个页表及地址寄存器(TTBR0或TTBR1),防止一级页表的基地址
- 处理器根据虚拟地址的bit[31:20]作为索引,在一级页表中查找页表项,一级页表一共又4096个页表项(4K个entry)
- 一级页表的表项中存放了二级页表的基地址,处理器根据虚拟地址bit[19:12]作为索引值,在二级页表中找到对应的表,二级页表一共256个表项
- 二级页表的页表项里面存放了4KB页的物理基地址,加上最后的偏移量bit[11:0],最终寻找到物理内存
在4KB的映射的一级页表和二级页表的表项其实跟x86基本类似,页包含了很多的其他:
一级页表项
二级页表项
三、内核启动主要概述
当U-boot启动后,通过r1,r2来将启动参数传递给内核,arm的启动(arch/arm/kernel/head.S)中kernel热人口地址对应stext,其主要做了以下几件事情
- 设置svc模式,关闭所有中断
- 获取CPU ID,提取相应的proc info
- 验证tags或者dtb
- 创建临时内核页表的页表项
- 配置r13寄存器,也就是设置打开MMU之后要跳转到的函数
- 使能MMU
- 跳转到start_kernel,也就是跳转到第二阶段
kernel里面的所有符号在链接时,都使用了虚拟地址值。在完成基本的初始化后,kernel代码将跳到第一个C语言函数start_kernl来 执行,这些虚拟地址必须能够对它所存放在真正内存位置,否则运行将为出错。为此,CPU必须开启MMU,但在开启MMU前,必须为虚拟地址到 物理地址的映射建立相应的面表。对应的各个宏的解释如下:
宏 | 默认值 | 定义 |
KERNEL_RAM_VADDR | 0xC0008000 | 内核在内存的虚拟地址 |
PACE_OFFSET | 0xC0000000 | 内核虚拟地址空间的起始地址 |
TEXT_OFFSET | 0xC0008000 | 内核起始位置相对于内存起始位置的偏移 |
PHYS_OFFSET | 0x80000000 | 物理内存的起始地址 |
四、内核临时页表项
内核通过__create_page_tables创建临时页表项,我们来看以一下其处理流程
__create_page_tables:
pgtbl r4, r8 @ page table address
首先就使用pgtbl,而这个是一个宏,定义如下:
.macro pgtbl, rd, phys
add \rd, \phys, #TEXT_OFFSET
sub \rd, \rd, #PG_DIR_SIZE
.endm
其实际上是这样
add r4, r8, #TEXT_OFFSET
sub r4, r4, #PG_DIR_SIZE
由r8为PHYS_OFFSET,那么r4的值为PHYS_OFFSET+TEXT_OFFSET-PG_DIR_SIZE=0x80000000+0x00008000-0x4000=0x80004000,将r4设置成页表的基地址,页表将4G的地址空间分成若干个1M的段,因此页表包含4096个页表项。每个页表项是4字节,那么页表就占用4096*4=16K的内存空间。之后就将这16K的页表项清0
mov r0, r4
mov r3, #0
add r6, r0, #PG_DIR_SIZE
1: str r3, [r0], #4
str r3, [r0], #4
str r3, [r0], #4
str r3, [r0], #4
teq r0, r6
bne 1b
由于IMX不支持CONFIG_ARM_LPAE((Large Physical Address Extensions)大型物理地址扩展,那么就直接运行下面的
ldr r7, [r10, #PROCINFO_MM_MMUFLAGS] @ mm_mmuflags
/*
* Create identity mapping to cater for __enable_mmu.
* This identity mapping will be removed by paging_init().
*/
adr r0, __turn_mmu_on_loc
ldmia r0, {r3, r5, r6}
sub r0, r0, r3 @ virt->phys offset
add r5, r5, r0 @ phys __turn_mmu_on
add r6, r6, r0 @ phys __turn_mmu_on_end
mov r5, r5, lsr #SECTION_SHIFT
mov r6, r6, lsr #SECTION_SHIFT
首先从proc_info_list结构体获取__cpu_mm_mmu_flags,该字段包含了存储空间访问权限等,并存储在r7中,然后取__turn_mmu_on_loc处的地址保存在r0,然后从这块内存中读取3个word到r3,r5,r6中,那么这3个word里面放的什么呢?
__turn_mmu_on_loc:
.long .
.long __turn_mmu_on
.long __turn_mmu_on_end
那么sub r0, r0, r3意义就很明确了,就是求出__turn_mmu_on_loc这个标号的物理地址和虚拟地址之间的偏移量。然后根据这个偏移量求出__turn_mmu_on的物理地址r5和__turn_mmu_on_end的物理地址r6,后面就是最关键的,mov r5, r5, lsr #SECTION_SHIFT通过r5的高12位,通过右移20位得到,最终得到kernel的section机制,r5存放起始地址的段序号,r6存放末地址的段序号。
1: orr r3, r7, r5, lsl #SECTION_SHIFT @ flags + kernel base
str r3, [r4, r5, lsl #PMD_ORDER] @ identity mapping
cmp r5, r6
addlo r5, r5, #1 @ next section
blo 1b
该过程是将r7(也就是段的flag)和r5左移20位,也就是段页表项的内容,然后将段页表项的值写到对应的段页表项中,段页表项的地址=段页表起始地址(r4)+段序号r5*段页表项的size,最后通过判断是否写到__turn_mmu_on_end地址,如果没有写入,继续写入下一段,该过程主要是完成__turn_mmu_on代码的映射
/*
* Map our RAM from the start to the end of the kernel .bss section.
*/
add r0, r4, #PAGE_OFFSET >> (SECTION_SHIFT - PMD_ORDER)---解析1
ldr r6, =(_end - 1)
orr r3, r8, r7 ---解析2
add r6, r4, r6, lsr #(SECTION_SHIFT - PMD_ORDER)---解析3
1: str r3, [r0], #1 << PMD_ORDER ---解析4
add r3, r3, #1 << SECTION_SHIFT
cmp r0, r6
bls 1b
- PAGE_OFFSET表示内核空间的偏移,这里是0xc0000000,也就是内核映射区的起始段的起始地址。将PAGE_OFFSET左移动(SECTION_SHIFT - PMD_ORDER)后得到该地址所在段的段页表项的地址偏移,最后将段页表项的地址偏移+临时内核页表地址得到0xc0000000所在段的段页表项的物理地址,并放到r0中,而r6中存放内核映射区的末尾地址。
- 将DDR起始物理地址(r8)或上MMU的表示(r7),得到0xc0000000所在段的段页表项内容,存放到r3中。
- 将内核映射区的末尾地址(r6)左移(SECTION_SHIFT - PMD_ORDER)后得到其所在段的段页表项的物理地址
- 将r3存入当前段页表项中([r0]),然后将r0加上4,得到下一个段页表项的地址,更新r3中的页表项值为下一个段的页表项值,也就是直接加上,判断是否已经到达内核映射区的末尾,如果不是就进入下一个循环。
从上面可以看出,这段主要是完成对kernel内核空间进行映射,我们可以通过内核的System.map文件可以看出内核的起始和结束地址为
c0008000 T _text
c10e8eec B _end
其相应在物理地址上的内存区域是0x80008000到0x810e8eec区域,因此就完成了创建物理区[0x80008000-0x810e8eec]到内核映射区[0xc0008000-0xc10e8eec]的内存映射。
接下来代码就完成了DTB的映射,其代码如下
mov r0, r2, lsr #SECTION_SHIFT---解析1
movs r0, r0, lsl #SECTION_SHIFT
subne r3, r0, r8
addne r3, r3, #PAGE_OFFSET
addne r3, r4, r3, lsr #(SECTION_SHIFT - PMD_ORDER)---解析2
orrne r6, r7, r0
strne r6, [r3], #1 << PMD_ORDER
addne r6, r6, #1 << SECTION_SHIFT
strne r6, [r3]
- 首先将dtb起始物理地址(r2)左移SECTION_SHIFT,存放在r0中,再将r0右移SECTION_SHIFT得到这个物理内存段的地址(和上一步简单理解就是把低20位清零),计算dtb物理内存段(r0)对应DRAM起始地址(r8)的偏移,存放在r3中,将偏移(r3)加上,内核空间起始地址PAGE_OFFSET,得到要映射到的虚拟地址
- 取要映射的虚拟地址的段的页表项的地址,存放在r3中,将物理内存段地址(r0)或上mmu标识(r7),得到对应页表项值,存放到r6中。将页表项值(r6)写入到页表项中([r3]),然后r3+4,获取到下一个页表项的地址,页表项值+0x100000,得到下一个页表项应该写入的页表项值,将页表项值(r6)写入到页表项中([r3])
总结,create_page_table完成了3种地址映射的页表空间:
- turn_mmu_on所映射的1M空间的屏映射:那么为什么要做映射呢?在执行开启MMU指令之前,CPU取指是在0x80008000附件,如果只做kernel_image的映射,开启MMU后,CPU所看到的地址就全变了,那么就可能无法执行。完成平映射后,就可以完美解决从0x8xxxxxxx到0xcxxxxxxx的过渡。
- kernel_image的线性映射:kernel编译链接的入口地址在0xc0008000,但其物理地址不等于链接的虚拟地址,需要将物理地址映射到对应的虚拟地址空间。
- atags(DTB)所在的1M空间的线性映射:当MMU开启后,内核只能访问虚拟地址空间,无法访问物理地址空间,所以就需要做相应的映射。