在上一篇笔记ARMv8 MMU的基础知识中,我们已经了解了ARMv8中MMU
的基础知识。这篇笔记将重点关注Linux内核的启动阶段,看看MMU
是如何开启,以及临时页表是如何建立起来的。
上电后发生了什么
在介绍Linux内核的启动流程前,我们简单来看看硬件上电后进行的一系列操作。
当硬件上电后,硬件会进行一些初始化,同时ARM Core会跳入RESET Vector
进行一系列的初始化。之后BootLoader
会将Linux内核从外部存储器(SD卡,FLash ROM等)载入物理内存(一般是载入到特定的地址),然后Linux内核就可以开始执行了(从head.S
处开始执行,在开启MMU
前都用物理地址进行寻址)。
下图是一个内核镜像文件的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
的。在开启MMU
前,CPU是直接在代码段的物理地址进行取指执行的。因为heads.S
开始的代码都是PIC
的,因此运行没有问题(此时PC
还在0x00XX
区域)。当开启MMU
后,heads.S
就开始执行非PIC
的代码。因为代码段在编译链接时已经有了自己的虚拟地址,通过非PIC
代码,CPU会跳转到相应的虚拟地址(此时PC
跳转到0xFFXX
区域)进行取指执行。到这里之后,即使是PIC
代码也不会有问题,因为PC
已经在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的高位不同。根据最后几位偏移不能计算出正确的跳转地址。