在上一篇笔记ARMv8 MMU的基础知识中,我们已经了解了ARMv8中MMU的基础知识。这篇笔记将重点关注Linux内核的启动阶段,看看MMU是如何开启,以及临时页表是如何建立起来的。
上电后发生了什么
在介绍Linux内核的启动流程前,我们简单来看看硬件上电后进行的一系列操作。
当硬件上电后,硬件会进行一些初始化,同时ARM Core会跳入RESET Vector
进行一系列的初始化。之后BootLoader
会将Linux内核从外部存储器(SD卡,FLash ROM等)载入物理内存(一般是载入到特定的地址),然后Linux内核就可以开始执行了(从head.S
处开始执行)。
下图是一个内核镜像文件的VA->PA
映射,该图来自 How the ARM32 kernel starts(这是一个32bit
系统的映射图,64bit
的也类似),我们可以看出几点:
- 内核镜像文件在物理内存中存放于低地址位,而其对应的虚拟地址是高地址位;
PHYS_OFFSET
和PAGE_OFFSET
分别用于物理地址和虚拟地址;- 其中有部分
VA -> PA
是一一对应的,这部分用于保证MMU
开启前后,指令的读取不会出错。
Linux内核的入口程序
从这里开始,我们就要和汇编语言打交道了。说实话,我对汇编只能说是勉强能看懂,因此接下来的内容不需要深入理解。关于MMU
的开启的部分可以从大的原理上思考下为什么要这样做,至于源码,简单看看即可。
当BootLoader
完成它的任务后,Linux内核就开始接管接下来的工作。这部分工作从 head.S 这个文件开始。
1 | // ./arch/arm64/kernel/head.S |
这里主要看看和MMU
相关的部分,因此我们直接跳到__create_page_tables()
这个函数。
页表的创建
在Linux的启动阶段,内核创建了多个页表,包括idmap_pg_dir
, swapper_pg_dir
和init_pg_dir
。其中第一个用于确保开启MMU
前后取指地址保持不变,第三个用于在启动阶段映射Linux内核,第二个在之后的paging_init()
中会取代init_pg_dir
。在MMU
开启前,系统是直接使用物理地址进行取指,而开启MMU
后,由于有了VA->PA
的映射,PA
就可能不是之前的PA
了,因此需要做一个VA = PA
的映射来保证内核的正常工作。
1 | // ./arch/arm64/kernel/vmlinux.lds.S |
__create_page_tables Part 1
这一步主要是做一些准备工作,比如获取相关页表的物理地址、清空页表等。
1 | // ./arch/arm64/kernel/head.S |
(1) 使用adrp
指令获取idmap_pg_dir
物理地址(这里使用PIC技术)并存入x0
;
(2) 中清空这几个页表所在的dcache
- 在MMU
开启前,dcache
是不会被使用的(内存不是cacheable类型)这么做是为了避免不必要的问题;
(3) 将这几个页表置零(表示这些页表项都是无效的);
(4) 获取idmap_pg_dir
和__idmap_text_start
,分别存入x0
和x3
;
__create_page_tables Part 2
接下来,我们就要开始初始化PGD, PUD, PMD和PTE了,
1 | // ./arch/arm64/kernel/head.S |
这里我就不深究细节了,简单来说就是
(1) 将__idmap_text_start
到__idmap_text_end
的区域一一对应地映射到虚拟内存空间。这样,即使MMU
开启了,ARM Core也能正常地取指。注意,MMU
开启的部分代码一定在__idmap_text_start
到__idmap_text_end
之间。
(2) 将整个内核映射到虚拟空间。注意,这部分包括__idmap_text_start
到__idmap_text_end
的区间,也就是说这部分其实映射了两遍。
具体的映射形式可以参考最开始的那幅图,注意其中有部分映射了两遍。
开启MMU
MMU
是在__primary_switch
中通过调用__enable_mmu
开启的。在开启MMU前,我们还需要对ARM的各种寄存器进行设置,这里我就不详细记录了。下面直接看__primary_switch
和__enable_mmu
这两个函数:
1 | // ./arch/arm64/kernel/head.S |
(1) 参考注释
(2) idmap_pg_dir
之所以放入ttbr0_el1
,是因为当MMU
开启后,PC使用的地址还和之前一样,都是0x00XX
开头的地址。而这些地址都会去查看ttbr0_el1
对应的页表,因此需要将 idmap_pg_dir
放入
至此,汇编代码部分基本结束,之后就会调用start_kernel()
函数,进入C语言的世界了。
实验系统页表布局
在整个Linux内核的学习过程中,我使用了《奔跑吧内核第二版》的实验平台,在该实验平台上,其页表布局如下:
1 | // 4级页表+4K的页面,VA_BITS = 48 |
一个疑问
之前写完这篇笔记后,有一个问题一直没有想明白,
在开启
MMU
之前,CPU是在0x00XX
区域进行取指;而在开启MMU
后,CPU是在0xFFXX
区域进行取指。CPU到底是怎么做到这种切换的呢?MMU
怎么会改变CPU发出的地址?它应该只能改变CPU发出地址的映射地址吧?
这个疑问困扰了我很多天,翻阅了大量的资料后,我终于明白是怎么回事了。
这里要牵涉PIC(Position Independent Code)
和非PIC
汇编代码,在heads.S
中,绝大多数代码都是PIC
的,只有开启MMU后的一些代码是非PIC
的。就是这些非PIC
的代码,将PC
的值从0x00XX
切换到了0xFFXX
,并且之后将一直运行在0xFFXX
区域。
比如说,br
就是一个非PIC
的指令,它最终在MMU
开启后,将CPU的寻址空间切换到了0xFFXX
区域(之前都在PC+/- 一定范围内寻指,并且PC也是在0x00XX
区域)。关于PIC
相关内容,请参考 linux内核链接脚本vmlinux.lds分析(十一)。
bl
和adrp
都是PIC
指令, br
是非PIC
指令。 adrp
还比较特殊,它可以找到当前指令+/- 4GB
空间的地址(因此可以处理64位的地址),并且其地址是4KB
对齐的(也就是说低12位都是0)。head.S
中页表使用的地址都满足这个要求,因此可以用adrp
来进行相对地址跳转。
PIC指令
这里简单说说PIC
指令是如何工作的,比如bl disable_watchdog
这个指令,它其实是获取disable_watchdog
地址后,取其最后几位偏移。因为bl disable_watchdog
指令和disable_watchdog
在同一个区域,因此通过最后这几位偏移就可以和当前pc
算出它们的距离,通过这个距离,pc
就会跳转到正确的地方。上个例子看看,
1 | // 0x5fe000d8 - 0x5fe0008c = 0x4C |
从上面例子可以看出,如果要bl
到一个很远的地方,那么CPU就会跳转到一个错误的地方,因为跳转地址的高位和PC的高位不同。根据最后几位偏移不能计算出正确的跳转地址。