万字长文揭秘 ARM 32 内核是如何启动的!




译者 | 弯月,责编 | 郑丽媛
图 | CSDN 下载自视觉中国
出品 | CSDN(ID:CSDNnews)

以下为译文:

不久前,我曾撰文介绍了内核的解压缩,结果那篇文章(http://people.kernel.org/linusw/how-the-arm32-linux-kernel-decompresses)收到了大量的评论和留言,这有点出乎意料。我估计可能是因为那篇文章跟火爆七十年代末的《莱昂氏UNIX源代码分析第六版》同样的原因吧。操作系统开发人员喜欢阅读深度扩展的代码注释,而那篇文章就是这种类型。

我所说的“ARM32”的正式名称为Aarch32,在ARM架构中,从ARMv4到ARMv7这几个版本实现了该架构。

本文我将讨论在经过解压缩和引导、加载到物理内存后,内核如何自我引导,直到在虚拟内存中执行由C编写的通用内核代码的过程。


一切的开端


在经过解压、增强,并收到了设备树块(DTB)之后,程序计数器(pc)被置于符号stext()的物理地址(即文本段的开始处),从而调用ARM32内核。这段代码可以参考arch/arm/kernel/head.S。

宏 __HEAD 会将这里的代码放到一个名为 .head.text 的连接器节中。查看ARM架构的连接器文件(arch/arm/kernel/vmlinux.lds.S)就会发现,这一操作表明了该节中的目标代码为首先被执行的代码。

此处的物理地址被平均分割为16MB加上额外的32KB的 TEXT_OFFSET(其原因稍后会详细解释),所以stext()的地址大致为0x10008000(即本例中使用的地址)。

head.S 包含一小段密集的异常处理,用于各种旧的ARM..,因此很难读懂。ATAGs和设备树引导的标准问世大致与 head.S 的建立处于同一时间,所以多年来这段代码变得越来越复杂。

为了理解下面的内容,你需要对页式虚拟内存有基本的了解。如果觉得..上的介绍太粗略,可以参考Hennesy & Patterson的《计算机体系结构:量化研究方法》一书。本文还假设你懂得一些基本的ARM汇编语言,以及Linux内核的基础知识。


ARM的虚拟内存分割


首先介绍一下内核在虚拟内存中的何处执行。内核的RAM基址在 PAGE_OFFSET 符号中定义,其位置可以配置。从 PAGE_OFFSET 的名字中可以看出,它是内核RAM第一页的虚拟内存偏移量。

你可以从四种内存分割方法中选择一种,这让我想起了快餐店。这一点目前在 arch/arm/Kconfig 中定义如下:

config PAGE_OFFSET
hex
default PHYS_OFFSET if !MMU
default 0x40000000 if VMSPLIT_1G
default 0x80000000 if VMSPLIT_2G
default 0xB0000000 if VMSPLIT_3G_OPT
default 0xC0000000

首先注意到,如果没有MMU(例如在运行ARM Cortex-R类设备,或旧的ARM7芯片时),就会在物理内存和虚拟内存之间建立1:1的映射。页表的作用仅仅是填充缓存,地址不会被重写。这种情况下,PAGE_OFFSET通常会位于地址 0x00000000。不带虚拟内存的Linux内核被称为“uClinux”,曾经是Linux内核的一个分支,多年后才被吸纳成为主线内核的一部分。

不使用虚拟内存,在Linux甚至任何POSIX类系统中都是另类。后文我们假设引导都会使用虚拟内存。

PAGE_OFFSET 虚拟内存分割符号会在上述地址处建立虚拟内存空间,供存放内核使用。所以内核会将所有代码、状态和数据结构(包括虚拟内存到物理内存的转译表)保存在下面的虚拟内存地址之一:

● 0x40000000-0xFFFFFFFF

● 0x80000000-0xFFFFFFFF

● 0xB0000000-0xFFFFFFFF

● 0xC0000000-0xFFFFFFFF

在这四者中,最后一个 0xC0000000-0xFFFFFFFF 是目前最常见的,这样内核就有1GB的地址空间可以使用。

内核下方的内存用于用户空间的代码,地址范围为0x00000000-PAGE_OFFSET-1(通常地址位于0x00000000-0xBFFFFFFF,共3GB)。Unix习惯提供超额内存,即操作系统乐观地给程序提供的虚拟内存空间,其大小通常会超过可用的物理内存大小。每个新生成的用户空间进程都以为自己有3GB的内存可用!这种超额提供从上世纪七十年代就成了Unix的特点。

为什么有四种分割方式?

答案很简单:ARM在嵌入式系统中有大量应用,这些系统可能更重视用户空间(如通常的平板电脑、手机甚至台式电脑),也可能更重视内核空间(如路由器)。绝大多数系统都重视用户空间,或者内存很少,所以怎样分割其实关系不大(不论怎样分割,内存都会很拥挤),所以最常见的分割就是将 PAGE_OFFSET 设置为 0xC0000000。


图:内核空间和用户空间之间最常见的虚拟内存分割位于0xC0000000。关于本图需要注意的一点:这里说一块内存在“上面”的意思是上图中更靠下的位置,即沿着箭头朝着高位地址的方向。我知道有人会认为这不符合逻辑,更倾向于将上图颠倒过来,将0xFFFFFFFF画在上面,不过这是我的个人习惯,也是绝大多数硬件手册中的惯例。

有可能系统有很多内存,且更重视内核空间,例如带有很多内存(如4GB RAM)的路由器或NAS。此时你可能希望内核能够将一些内存作为页面缓存和网络缓存使用,提高最常见的操作的速度,所以你会希望分割出更多的内核内存,比如在极端情况下可以将 PAGE_OFFSET 设置为 0x40000000。

这个虚拟内存映射永远存在,即使内核在执行用户空间代码时也是。一直保持内核映射,可以非常快地进行户空间到内核空间的上下文切换,这样当用户空间进程需要调用内核时,不需要进行任何页表替换。只需要启动一个软件陷阱,切换到监督模式(supervisor mode),执行内核代码即可,虚拟内存的配置不需要变化。

在不同的用户空间之间执行上下文切换也更快:只需要用一段预先定义好的物理RAM块替换页表的低端部分(通常会替换内核映射,因为它很简单)即可。这段预先定义好的物理RAM是线性映射的,甚至被存储在一个特殊的地方:页表缓存(translation lookaside buffer)。页表缓存位于芯片上,是“非常快的转译表”,所以能更快地进入内核空间。这些地址永远存在,永远是线性映射的,而且永远不会产生页面错误。


在哪里执行?


我们继续看 arch/arm/kernel/head.S 处的符号 stext()。

下一步就是处理在未知内存地址处运行的问题。内核可以加载到任何地方(只要是合理的偶数地址即可)并执行,所以我们要处理这一点。注意内核代码不是位置无关的,内核经过编译和连接后,必须在特定的地址执行。但我们还不知道这个地址。

内核首先要检查一些特殊特性,如虚拟化扩展、LPAE(大型物理地址扩展),然后进行以下操作:

 adr        r3, 2f
ldmia r3, {r4, r8}
sub r4, r3, r4 @ (PHYS_OFFSET - PAGE_OFFSET)
add r8, r8, r4 @ PHYS_OFFSET
(...)
2: .long .
.long PAGE_OFFSET

.long . 在连接时赋值为标签 2: 处的地址,所以 . 会解析为标签 2: 实际被连接到的地址,连接器认为该地址会被定位到内存中。该地址将位于内核指定的某块虚拟内存中,即通常位于 0xC0000000 上方的某个地方。

之后就是编译好的常量PAGE_OFFSET,我们已经知道它的值大概为 0xC0000000。

我们将 2: 在编译时生成的地址加载到 r4 中,将常量 PAGE_OFFSET 加载到 r8 中。然后从中减去 2: 的真实地址。之后利用相对指令从 r4 中得到 2: 的真实地址并保存到 r3,再从 r3 中减去 r4。记住ARM汇编的参数顺序就像计算器一样,sub ra, rb, rc 相当于 ra = rb - rc。

这样在 r4 中得到的结果就是内核在编译时得到的运行地址和实际的运行地址之间的偏移量。所以这里的注释 @ (PHYS_OFFSET - PAGE_OFFSET) 表明我们获得了该偏移量。如果内核符号 2: 在编译时的执行地址是虚拟内存中的 0xC0001234,但实际上在 0x10001234处执行,那么 r4 的值就是 0x10001234 - 0xC0001234 = 0x50000000。这个值的实际含义是“-0xB0000000”,因为这里的算术是可交换的:0xC0001234 + 0x50000000 = 0x10001234。证明完毕。

下面,将这个偏移量加到编译时确定的 PAGE_OFFSET 上。我们已知后者类似于 0xC0000000。使用循环算术,如果内核执行的实际地址还是 0x10001234,我们将得到 0xC0000000 + 0x50000000 = 0x10000000 并保存在 r8 中,这就是内核执行时的基址的物理地址。所以注释写的是 @PHYS_OFFSET 。r8中保存的这个值就是我们要使用的值。

旧的ARM内核中有一个叫做 PLAT_PHYS_OFFSET 的符号,其中包含的正是这个偏移量(如0x10000000),不过是在编译时确定的。现在已经不这样做了,而是在执行时动态确定。如果你的操作系统比Linux简单,那很可能会发现,开发人员通常会做出类似于“物理偏移量是常量”的假设进行简化。Linux发展到今天这种做法,是因为它需要在各种内存布局中引导同一个内核。

图:本文示例中的物理内存到虚拟内存的映射。

关于 PHYS_OFFSET 有一些规则:它需要满足一些基本的对齐要求。在确定第一个解压后的代码中的第一个物理内存块的位置时,我们执行 PHYS = pc & 0xF8000000,意思是物理RAM必须从偶数的128MB边界上开始。例如从 0x00000000 开始就很好。

这段代码考虑了XIP(execute in place,原地执行)的一些特殊情况,例如内核直接从ROM中执行,不过这里不再讨论,因为这种情况更罕见,甚至比不使用虚拟内存的情况还罕见。

还有另一点需要注意。如果你尝试过加载一个解压后的内核并引导,就会发现它对于加载位置非常挑剔——必须放在类似于 0x00008000 或 0x10008000 (假设你的 TEXT_OFFSET 为 0x8000)之类的地址上。而使用压缩后的内核就没有这个问题,因为解压缩程序会将内核解压到合适的位置上(大多数情况下为 0x00008000),所以这个问题解决了。这就是人们常常觉得压缩后的内核“更好用”的原因。


给物理地址打补丁,转换成虚拟地址(P2V)


现在我们有了虚拟内存和物理内存之间的偏移量。接下来就会遇到第一个Kconfig符号:CONFIG_ARM_PATCH_PHYS_VIRT。

建立这个符号的原因是,开发人员需要让内核在不重新编译的情况下,在不同内存配置的系统中引导。内核可能被编译成在特定的虚拟地址(如 0xC0000000 )处执行,但实际可能被加载到 0x10000000 (如本文中的例子),也有可能是 0x40000000 或其他地址。

当然,内核中的绝大多数符号不需要担心这一点,因为它们都在虚拟内存中执行,对于它们而言,它们永远在 0xC0000000 处执行。但我们写的不是用户空间的程序,所以事情没那么简单。我们必须知道执行所处位置的物理地址,因为我们就是内核,意思就是我们需要在页表中建立物理地址到虚拟地址的映射,还需要经常更新这些页表。

而且,由于我们并不知道实际运行所处的物理地址,所以没办法依赖诸如编译时常量等技巧。这些技巧等于作弊,而且会造成非常难以维护的代码。

内核有两个函数可以在物理地址和虚拟地址之间进行转换: __virt_to_phys() 和 __phys_to_virt() (仅限于内核内存使用的地址)。在内存空间中,这个转换是线性的(每个方向上只需使用一个偏移量),所以简单的加减法就可以实现。因此得名“P2V运行时补丁”。该方法由Nicolas Pitre、Eric Miao和Russell King于2011年发明,2013年Santosh Shilimkar将其扩展并应用到了LPAE系统上,特别是TI Keystone SoC上。

这里的重点是,如果对于一个物理地址 PHY 和一个内核虚拟地址 VIRT (两者的概念参见上一幅插图),以下关系成立的话:

PHY = VIRT – PAGE_OFFSET + PHYS_OFFSET

  VIRT = PHY – PHYS_OFFSET + PAGE_OFFSET

那么根据算术定律可知,下述关系依然成立:

PHY = VIRT + (PHYS_OFFSET – PAGE_OFFSET)

VIRT = PHY – (PHYS_OFFSET – PAGE_OFFSET)

所以,只需给虚拟地址加上一个常量就可以得到物理地址,给物理地址减去一个常量就可以得到虚拟地址。所以最初的代码大概如下所示:

static inline unsigned long __virt_to_phys(unsigned long x)
{
unsigned long t;
__pv_stub(x, t, "add");
return t;
}

static inline unsigned long __phys_to_virt(unsigned long x)
{
unsigned long t;
__pv_stub(x, t, "sub");
return t;
}

__pv_stub() 包含一个汇编宏,用于执行加法或减法。从那以后,LPAE开始支持多于32位的地址,因此这段代码变得更为复杂,但基本原理是不变的。

每当在内核中调用 __virt_to_phys() 或 __phys_to_virt() 时,它们会被替换成一段内联汇编代码(位于 arch/arm/include/asm/memory.h),然后连接器就会将节切换到一个名为 .pv_table 的节上,然后在该节中添加一个指针,指向刚刚添加的汇编指令的位置。这就是说,.pv_table 接会扩展成一个指针的表格,指向所有这些内联汇编代码所在的位置。

在引导过程中,我们会遍历整个表格,取出每一个指针,检查指针所指位置的指令,然后利用物理和虚拟内存之间的偏移量对这些指令打补丁。


图:每个利用汇编宏将物理地址转换为虚拟地址的地方,都在引导过程的前期进行打补丁。

为什么要进行如此复杂的操作,而不是简单地将偏移量保存到一个变量中呢?这是为了提高效率,因为这些路径会被反复执行。更新页表以及从物理内存到虚拟内核内存的交叉引用的调用,其性能极其关键。所有访问内核虚拟内存的用例,不论是设备块层还是网络层的操作,甚至是用户空间到内核空间的转译,理论上任何流经内核的数据都会在某个时间点调用这些函数。所以它们必须非常非常快。

这个解决方案并不简单,实际上是非常复杂的解决方案,但效率非常高!


遍历补丁表


在实际打补丁时,我们会利用前面插图中求出的偏移量给所有的位置打补丁。这是通过调用符号 __fixup_pv_table 实现的,此时就需要用到 r8 中保存的偏移量了:从一个名为 __pv_table 读入五个符号至 r3 ~ r7 中,这五个符号都需要直接引用物理内存地址,接下来用上面说过的方法来增强它们(这就是为何这个表前面有个 .long):

__fixup_pv_table:
adrr0, 1f
ldmiar0, {r3-r7}
mvnip, #0
subsr3, r0, r3@ PHYS_OFFSET - PAGE_OFFSET
addr4, r4, r3@ adjust table start address
addr5, r5, r3@ adjust table end address
addr6, r6, r3@ adjust __pv_phys_pfn_offset address
addr7, r7, r3@ adjust __pv_offset address
movr0, r8, lsr #PAGE_SHIFT@ convert to PFN
strr0, [r6]@ save computed PHYS_OFFSET to __pv_phys_pfn_offset
(...)
b__fixup_a_pv_table

1: .long.
.long__pv_table_begin
.long__pv_table_end
2: .long__pv_phys_pfn_offset
.long__pv_offset

这段代码使用第一个值(加载到了 r3 中)计算物理内存的偏移量,然后将其加到其他寄存器上,这样 r4 ~ r7 都直接指向各个标签的物理内存地址。所以 r4 指向保存了 __pv_table_begin 的物理内存地址,r5 指向 __pv_table_end,r6 指向 __pv_phys_pfn_offset,r7 指向 __pv_offfset。如果是C语言,这些地址都将是 u32 * ,即指向32位整数。

__pv_phys_pfn_offset 特别重要,它的含义是给物理地址打补丁成虚拟地址时需要的偏移量,所以我们首先通过 mov r0, r8, lsr #PAGE_SHIFT,利用前面计算出的 r8 (内核内存相对于0的偏移量,本例中为 0x10000000)对其执行右移操作,然后利用 str r0, [r6] 将结果写入实际保存该变量的位置。这个值在内核启动的前期不会用到,但后面的虚拟内存管理需要用到它。

接下来调用 __fixup_a_pv_table,它会遍历从 r4 到 r5 的每个地址(这个表格中保存的指针指向了需要打补丁的指令),然后利用一个自定义的二进制补丁程序依次打补丁,该程序可以将ARM或THUMB2的指令(指令类型在编译时确定)转换成带有一个立即偏移量的指令,该立即偏移量表示的是物理地址到虚拟地址的偏移量。这段代码非常复杂,包含了许多奇怪的操作,用来处理大头字节序。

注意每次内核加载模块的时候也会经历这个步骤,因此该步骤必须知道新的模块是否需要在物理地址和虚拟地址之间进行转换!所以,所有模块的ELF文件必须包含一个相同类型的 .pv_table 节,而且每次加载模块的时候都会调用这个汇编循环。


设置初始页表


开始在虚拟内存中执行之前,我们必须设置一个MMU转译表,将物理内存映射到虚拟内存。这个表通常称为“页表”,尽管初始的映射使用的是节,而不是页。ARM架构要求页表必须放在物理内存中的偶数16KB边界上。而页表的尺寸也永远是16KB,所以这个要求很合理。

初始页表的位置由一个名为 swapper_pg_dir 的符号定义,意为“交换页目录”,是内核对于初始页表的称呼。后来,该页表被一个更详细的页表替换(或者说交换,因此得名“交换”页目录)。

“页表”这个名字有点误导,因为其中的初始映射在ARM的术语中其实叫做“节”(section),而不是页。但页表这个词还是被模糊地用来指代“在引导时负责将物理地址转译成虚拟地址的那个东西”。

符号 swapper_pg_dir 定义为 KERNEL_RAM_VADDR - PG_DIR_SIZE 。我们来仔细看看。

如你所料,KERNEL_RAM_VADDR 正是内核在虚拟内存中的位置。它就是内核在编译期间被连接到的地址。

KERNEL_RAM_VADDR 的定义为 (PAGE_OFFSET + TEXT_OFFSET)。PAGE_OFFSET 可以是前面讨论过的Kconfig符号的四个位置之一,通常是 0xC0000000。TEXT_OFFSET通常为 0x8000,所以 KERNEL_RAM_VADDR 通常是 0xC0008000,但在某些虚拟内存分割方式或极端的 TEXT_OFFSET 设置下,它也可能是其他的值。TEXT_OFFSET 来自 arch/arm/Makefile中的 textof-y,通常是 0x8000,但在某些高通..上可能是 0x00208000,在某些博通..上可能是 0x00108000,所以 KERNEL_RAM_VADDR 可能是 0xC0208000 等。

我们可以确定的是,在连接内核时,这个地址传递给了连接器。检查ARM架构的连接器文件(arch/arm/kernel/vmlinux.lds.S)就可以看到,连接器确实指示了将其放在 . = PAGE_OFFSET + TEXT_OFFSET。内核被编译成在 KERNEL_RAM_VADDR 指定的地址执行,即使是我们现在分析的最早期的代码,也是按照位置无关的方式编写的。

TEXT_OFFSET是一个很小的区域,通常为32KB,在物理空间和虚拟空间中都位于内核RAM上方。图中内核在物理RAM中的位置0x10000000仅仅是一个例子,实际上可以是任意偶数的16MB边界。

注意内核上方的一小片空隙,最常见的位置是 0xC0000000-0xC0007FFF (TEXT_OFFSET最常见的32KB大小)。虽然它属于内核空间内存的一部分,但内核不会被放入这片内存中。它包含的是初始页表,也可能是引导程序提供的ATAGs和一些临时区域,如果内核位于 0x00000000 的情况下,这片区域还可以用于中断向量。这里的初始页表当然是供内核使用 的,但后来就被放弃了。

注意,我们给物理和虚拟内存都加上了同样的 TEXT_OFFSET 区域。这一步其实在解压缩代码的时候就完成了,解压缩的过程会把内核的第一个字节放到 PHYS_OFFSET +TEXT_OFFSET 处,这样内核位置在物理内存和虚拟内存的之间的线性差异可以一直使用32位字的几个高位比特表示,例如比特24~比特31(8比特),因此只需使用立即数算术运算,在指令中直接加入偏移量即可。从此时开始,内核RAM就必须位于可被16MB(0x01000000)整除的地址上了。

通过给 PAGE_OFFSET 加上 TEXT_OFFSET 的方法,我们在虚拟内存中找到了 swapper_pg_dir 的物理位置,然后后退 PG_DIR_SIZE。通常结果是 0xC0000000 + 0x8000 - 0x4000,所以初始页表将位于虚拟内存中的 0xC0004000处,相应的物理内存中的偏移量(用本例的 PHYS_OFFSET 0x10000000来计算的话)为 0x10004000。

在我们的例子中,swapper_pg_dir符号位于16KB处(0x4000字节),在物理内存中位于.text段之前。在使用传统的ARM MMU时,PHYS_OFFSET为0x10000000,TEXT_OFFSET为0x8000,所以swapper_pg_dir符号位于 0x10004000。

如果使用LAPE,那么页表 PG_DIR_SIZE 为 0x5000,这样就会得到 0xC0003000 的虚拟地址和 0x10003000 的物理地址。汇编宏 pgtbl 会为我们计算该地址:我们用 r8 中计算的物理地址和 TEXT_OFFSET,减去 PG_DIR_SIZE,就得到了初始转译表的物理地址。

初始转译表的构建方式很相似:首先用零填充页表,然后构建初始页表。这些操作位于符号 __create_page_tables 处。


ARM32页表格式


ARM32上的页表布局由两到三层组成。在ARM文档中,两层页表称为“短格式”,而三层页表称为“长格式”。长格式是LPAE(大型物理地址扩展)的一个特性,顾名思义,它用来处理大型物理内存,最多可处理40位物理地址。

这些转译表能够以1MB的节(在LPAE中为2MB,不过暂时先不考虑)或16kB的页为单位对内存进行转译。初始页表使用节,所以初始页表中的内存转译仅限于1MB的节上。

这样可以简化初始映射的操作:节可以直接编码到页表的第一层。这样,一般情况下就不需要处理两层结构的复杂性,可以直接把机器当做只有一层1MB节表来处理。

但是如果在LPAE上运行,那就要处理额外的一层,所以这里要处理最初的两层而不是一层。这就是为何此处会有LPAE代码的原因。它的任务就是在2MB节转译表中插入一个64位的指针。

在传统MMU中,我们仅将MMU指向第一层页表(其中只有1MB的节),而对于LPAE,我们需要一个中间层来访问2MB的节。这些项称为节描述符。


Linux页表的术语


从这里开始,代码中开始包含一些三个字母的缩写。很难说在讨论初始页表时这些缩写是否有意义,但开发人员习惯使用这些缩写。

● PGD:page global directory,全局页目录。这个词指最上面的转译表,整个MMU遍历该表来解析节和页的起始点。在我们的例子中,它指的是物理内存中0x10004000的位置。在ARM32的世界中,我们还会将它写入特殊的 CP15 转译表寄存器(有时候叫做 TTBR0),来告诉MMU在何处寻找转译。如果使用LPAE,它的值就是 0x10003000,留出 0x1000 的空间和一个指向下一层(称为PMD)的64位指针。

● P4D:page 4th level directory(第四层页目录),PUD:page upper directory(上方页目录)是Linux VMM(虚拟内存管理器)中的概念,在ARM中没有使用,因为它用于处理四层或五层转译表,而我们仅使用了两层或三层。

● PMD:page middle directory(中层页目录),是第三层转译的名字,仅在LPAE中使用。这就是为何要给LPAE初始页表保留额外的 0x1000 字节的原因。对于传统的ARM MMU(非LPAE)而言,PMD和PGD是一样的。这就是为什么代码对于传统MMU和LPAE都引用了 PMD_ORDER。因为在Linux VMM的术语中,它被认为是“PTE:s正上方的表格的格式”,由于我们没有使用PTEs,而是使用了节映射,所以“PMD节表”就是映射的最终产物。

● PET:s,页表项,它将RAM从物理内存映射到虚拟内存。初始引导转译表不会使用它,而是使用节。

再次注意:对于传统ARM来说,MMU、PGD和PMD是同一个东西。对于LPAE而言,它们是两个不同的东西。有时候这被称为将PMD“折叠”到PGD中。由于我们也可以说“折叠”P4D和PUD,因此这些术语越发令人迷惑了。

如果你用过虚拟内存,你肯定会反复遇到上述术语。对于我们构建初始“页”的目的而言,完全不需要关心这些术语。我们要构建的只是一个由1MB大小的节组成的列表,负责将虚拟内存映射到物理内存。此时我们甚至都不会处理页,所以“页”这个术语都令人迷惑。


转译表的二进制格式


在后续讨论中,我们只考虑传统ARM中的MMU。

从物理地址 0x10004000 到 0x10007FFF,共有 0x1000 (4096)个节描述符,每个描述符为32位(4字节)。它们该如何使用呢?

在打开MMU之后,程序计数器和所有的CPU访问都在虚拟地址上进行,所以转译会如下工作:在访问总线之前,将一个虚拟地址转译成物理地址。

由虚拟地址决定物理地址的方法如下:

利用虚拟地址的31-20比特作为索引,查找转译表中的某个32位节描述符。

● 负责转译虚拟内存中的地址 0x00000000-0x000FFFFF (第一个1MB)的索引位于 0x10004000-0x10004003处,是转译表的第一个四字节,称为索引 0 。

● 负责转译虚拟地址 0x00100000-0x0001FFFF 的是位于 0x10004004-0x10004007 处的索引1,所以索引编号乘以4就是描述符的字节地址。

● ……

● 虚拟地址 0xFFF00000-0xFFFFFFFF 由位于 0x10003FFC-0x10003FFF 处的索引 0xFFF 转译。

真聪明。0x4000 (16KB)的内存正好能够跨越32位,即4GB的内存空间。所以使用这个MMU表,我们可以将任意1MB的虚拟内存块映射到1MB的物理内存块上。这并不是巧合。

这就是说,如果内核的虚拟基址为 0xC0000000,那么表的索引将是 0xC0000000 >> 20 = 0xC00,由于索引需要乘以4,所以实际的字节索引为 0xC00 * 4 = 0x3000,所以在物理地址 0x10004000 + 0x3000 = 0x10007000 的地方就能找到内核空间内存的第一个 1MB 的节描述符。

我们使用的32位 1MB节描述的格式大致如下:

MMU 会查看比特0和比特1,其值“10”表示这是一个节映射。然后我们给其他比特设置一些默认值。除此之外,我们真正关心的只有将比特31-20设置为正确的物理地址,而且我们还有一个可用的节描述符。这就是代码的内容。

对于LPAE而言,情况有点不一样:我们使用64位的节描述符(8字节),但同时,节的大小是2MB而不是1MB,所以最终转译表的大小正好是0x4000字节。


MMU启用代码中的全等映射


首先我们要围绕符号 __turn_mmu_on 创建一个全等映射,表示这段代码将在一段物理地址和虚拟地址1:1映射过的内存上执行。如果代码位于 0x10009012,那么代码的虚拟地址也是 0x10009012。如果查看这段代码就会发现,它被放在一个单独的名为 .idmap.text 的节中。创建单独的节的意思是,这个节会连接到一个单独的物理页上,这个页中没有任何其他东西,所以映射的 1MB 完全供这段代码使用(甚至可能有两个 1MB,如果正好跨越节的边界的话),所以全等映射是专门为这段代码准备的。

仔细考虑一下就会发现这样做很巧妙,即使跨越 2MB 也是如此:如果像本例一样,将内核加载到 0x10000000,那么代码就会位于比如 0x10000120 处,还有一个位于同样地址的全等映射。这不会干扰到位于 0xC0000000 处的内核,或者在极端的内核内存分割的情况下,也不会干扰到位于 0x40000000 处的内核。如果有人想把物理内存的起始点放在比如 0xE0000000 处,就会造成大问题。我们希望不会发生这种情况。


映射其余部分


接下来我们创建主要部分的物理到虚拟内存的映射,从物理内存的 PHYS_OFFSET 处(在前面“在哪里执行”一节中介绍过的 r8 中保存的值)和虚拟内存中的 PAGE_OFFSET (编译时的常量)开始,接下来每次移动一页,直到到达虚拟内存中的 _end 符号。该符号位于内核目标代码中的 .bss 节的末尾。

在引导的前期,初始页表 swapper_pg_dir 和1:1映射过的仅包含一页的节 __turn_mmu_on,以及物理到虚拟内存的映射。在本例中,我们没有使用LPAE,所以初始页表为 PHYS_OFFSET 中的 -0x4000,内存的末尾为 0xFFFFFFFF。

BSS指的是二进制内核在内存中的最后一节,C编译器会在此处设置所有运行时变量的位置。该节的地址已经定义好,但没有二进制数据:其内存包含未定义的内容(即在映射时其中包含的任何内容)。

这段汇编循环值得好好学习一下,才能理解其中的映射代码如何工作:

  ldr    r7, [r10, #PROCINFO_MM_MMUFLAGS] @ mm_mmuflags
(...)
add r0, r4, #PAGE_OFFSET >> (SECTION_SHIFT - PMD_ORDER)
ldr r6, =(_end - 1)
orr r3, r8, r7
add r6, r4, r6, lsr #(SECTION_SHIFT - PMD_ORDER)
1: str r3, [r0], #1 << PMD_ORDER
add r3, r3, #1 << SECTION_SHIFT
cmp r0, r6
bls 1b

我们来逐步看看。我们假设本例使用的是非LPAE的传统ARM的MMU(你可以认为同样的分析对于LPAE也成立):

add    r0, r4, #PAGE_OFFSET >> (SECTION_SHIFT - PMD_ORDER

r4 包含页表(PGD或PMD)的物理地址,我们将在那里设置节。(SECTION_SHIFT - PMD_ORDER) 会解析成 (20 - 2) = 18,所以执行 PAGE_OFFSET 0xC0000000 >> 18 = 0x3000,正好是转译表中 0xC0000000 的绝对索引,跟我们前面看到的一样。这也正常,因为索引是4字节的。所以每当我们看到 (SECTION_SHIFT - PMD_ORDER) 就知道它的意思是“转化成该虚拟地址在转译表中的绝对索引”,在本例中其值为 0x10003000。

所以第一条语句会在 r0 中生成内核空间内存的第一个32位节描述符的物理地址。

ldr    r6, =(_end - 1)

r6 显然被设置成内核空间内存的最末尾。

  ldr    r7, [r10, #PROCINFO_MM_MMUFLAGS] @ mm_mmuflags
(...)
orr r3, r8, r7

r8 包含 PHYS_OFFSET,本例中为 0x10000000 (我们依赖于比特19-0均为零),然后将其与 r7 进行 OR 操作,后者表示MMU的标志,每个CPU有不同的定义,位于 arch/arm/mm/proc-*.S 中。每个文件都包含一个特殊的节,名为 .proc.info.init,位于索引 PROCINFO_MM_MMUFLAGS (其值大致是 0x08 这样)处是 OR 的右值,这样就可以得到我们所用的CPU对应的节描述符。这个结构体本身的名称为 struct proc_info_list,可以在 arch/arm/include/asm/procinfo.h 中找到。由于汇编无法真正处理C结构体,所以需要使用一些索引技巧才能得到这个魔术数字。

所以,节描述符的物理地址位于比特31-20,r7 中的值会设置更多的比特(如最低两比特),所以MMU就能正确处理节描述符。

add    r6, r4, r6, lsr #(SECTION_SHIFT - PMD_ORDER) 

这一行会为我们映射的内存的最后一MB构建节描述符的绝对物理索引地址。我们不会在 r7 上执行操作,只是用它作为循环的比较,而不会真正写入转译表,所以不需要。

现在 r0 是我们设置好的第一个节描述符的物理地址,r6是我们将要设置的最后一个节描述符的物理地址。接下来进入循环:

1:  str    r3, [r0], #1 << PMD_ORDER
add r3, r3, #1 << SECTION_SHIFT
cmp r0, r6
bls 1b

这段代码将第一个节描述符写入MMU表中的 r0 指向的地址,在本例中从 0x10003000处开始。然后在循环末尾给 r0 中的地址增加 ( 1<< PMD_ORDER),本例中为4。然后给描述符的物理地址部分(比特20及以上)增加1MB,即 (1 << SECTION_SHIFT),并检查是否到达最后一个描述符,否则返回 1: 处继续循环。

这样就能建立整个内核(包括所有段和 .bss 的1MB块)的虚拟内存到物理内存的映射。


最后的映射


接下来要映射一些其他的东西,具体来说是映射引导参数,可以是ATAG,或者是添加的设备树块(DTB)。ATAG通常是内存的第一页(位于 PHYS_OFFSET + 0x0100),而现代DTB通常位于内核的下方。你会发现,将DTB放在内核上方太远的地方是不明智的,因为它可能会越界并回到低地址上。

如果进行调试,那么还需要将串口映射到物理和虚拟内存中指定的地址上。这样才能在虚拟内存中执行时进行调试。

这里再次出现了“原地执行”的特例:如果在ROM中执行,那就需要将内核从某个特殊地址处进行映射,而不是从编译时确定的内存地址处进行。


跳转到虚拟内存


现在几乎到了整个 stext 过程的末尾,开始执行内核了。

首先调用“procinit”函数,这个函数对于每个CPU类型都不一样。这是一段由C和汇编写成的底层CPU管理代码,位于arch/arm/mm/proc-*.S 中。例如,大多数 v7 CPU的初始化代码都在 proc-v7.S 中,而ARM920的初始化代码在 proc-arm920.S中。稍后这些代码会有用,但通常“procinit”的调用都是空的,只有XScale的函数才有实际操作,用于处理引导程序初始状态中的bug。

procinit函数通过传统的 ret lr 返回,意味着连接寄存器(lr)中的值会赋给程序计数器(pc)。

在进入procinit函数之前, 我们将 lr 设置为标签 1: 的物理地址,从而会相对分支到符号 __enable_mmu。我们还给 r13 赋值为 __mmap_switched 的地址,该符号的地址是编译时确定的,是在MMU启用之后的下一个执行指针处的非相对虚拟地址。我们已经接近相对代码构建的末尾了。

接下来跳转到 __enable_mmu。r4 包含初始页表的地址。我们利用一条特殊的 CP15 指令将物理内存中的页表指针加载到 MMU 中:

mcr    p15, 0, r4, c2, c0, 0

到目前为止还没发生任何事情。页表地址设置到了MMU中,但还没有在物理地址和虚拟地址之间进行转译。接下来跳转到 __turn_mmu_on。这里会发生许多神奇的事情。__turn_mmu_on 被编译到 .idmap.text 节中,意味着它的物理地址和虚拟地址是一样的。接下来启用MMU:

 mcr    p15, 0, r0, c1, c0, 0        @ write control reg
mrc p15, 0, r3, c0, c0, 0 @ read id reg

现在MMU启用了。下一条指令(正好是清除指令缓存的指令)将从虚拟内存中执行。最初不会注意到任何东西,但实际上是在虚拟内存中执行的。在跳转到 r13中的地址以执行返回时,我们会进入该函数的虚拟内存地址中的 __mmap_switched,它位于 PAGE_OFFSET(通常为 0xC0nnnnnn)下方的某处。现在可以进行绝对寻址了,内核会按照预期的方式执行。

图:从物理内存执行切换到虚拟内存执行时,程序计数器上发生的跳转。

现在已经成功地启动了初始页表,终于可以在C编译器认为内核应该在的位置上执行内核了。


重点


__mmap_switched 位于文件 arch/arm/kernel/head-common.S 中,会执行一些特殊的事情。

首先是一条异常语句,又是因为原地执行(XIP):尽管内核的 .text 段可以继续在ROM中执行,但无法在 .data 段中保存任何变量。所以首先需要通过将该段复制到RAM中,或者使用某些代码将其解压到RAM中的方式(比较节省芯片)来设置。

接下来将 .bss 段清零,因为Linux内核需要静态变量的初始值为零。其他的C运行时可能不需要这样做,但在运行Linux内核时,你可以可靠地认为在进入函数时静态变量的值为零。

现在机器已经切换到虚拟内存,完全可以执行C运行时环境了。我们还给所有的交叉引用打了物理内存到虚拟内存的补丁。现在一切就绪。

接下来我们将处理器ID、机器类型和ATAG或DTB指针保存下来,然后分支到符号 start_kernel()。这个符号会解析成绝对地址,它是 init/main.c 靠下的地方定义的一个C函数。它是完全通用的,任何Linux架构都会调用该函数,所以我们已经到达了C编写的通用内核代码处。

我们来看看现在在哪里。我使用了工具链中的objdump工具来反汇编内核,然后用管道输出至less命令:

arm-linux-gnueabihf-objdump -D vmlinux |less

在less中使用 /start_kernel 命令搜索 start_kernel,然后跳转到第二次出现的位置:

c088c9d8 <start_kernel>:
c088c9d8: e92d4ff0 push {r4, r5, r6, r7, r8, r9, sl, fp, lr}
c088c9dc: e59f53e8 ldr r5, [pc, #1000] ; c088cdcc
c088c9e0: e59f03e8 ldr r0, [pc, #1000] ; c088cdd0
c088c9e4: e5953000 ldr r3, [r5]
c088c9e8: e24dd024 sub sp, sp, #36 ; 0x24
c088c9ec: e58d301c str r3, [sp, #28]
c088c9f0: ebde25e8 bl c0016198 <set_task_stack_end_magic>

非常好!我们在执行 0xC088C9D8 处的C代码,现在可以随便反汇编和调试内核了。每当遇到随机崩溃转储的情况,我通常会使用同样的方法,配合使用objdump和less来反汇编内核,并搜索崩溃处的符号,来查找可能出现的问题。

内核开发人员常用的另一个技巧是启用底层内核调试,并在start_kernel()处放置一条print语句,这样就能知道执行到了该点。我个人的做法如下(只需在 start_kernel() 中插入这些行):

#if defined(CONFIG_ARM) && defined(CONFIG_DEBUG_LL)
{
extern void printascii(char *);
printascii("start_kernel\n");
}
#endif

可见,要想让类似于此的底层调试print正常工作,需要启用 CONFIG_DEBUG_LL,然后就能在内核的标志“Linux...”打印之前看到一个标志。

Linux的内核开发人员应该都很熟悉该文件和该函数了,所以闲暇时间就可以阅读该文件中的代码。这些代码就是Linux启动的通用代码。

通用代码总是短暂的,因为一会儿就要调用setup_arch(),又要回到arch/arm中了。我们可以确定的是,初始转译表会被一个更详细的转译表替换。目前还没有用户空间的虚拟内存到物理内存的映射。不过这是另外一个话题了。

原文:http://people.kernel.org/linusw/how-the-arm32-kernel-starts

 本文为 CSDN 翻译,转载请注明来源出处。

更多精彩推荐

头秃,在线求名字:网易使用昵称交流,再也没有“哥,姐,总”

☞高科技公司的 CEO 要写代码吗?

☞文件系统:隐匿在 Linux 背后的机制

☞CPU有个禁区,内核权限也无法进入!

☞5年5亿美金,华为昇腾如何构建全行业AI生态?

将..用作结算网络中蕴含的经济学知识

点分享
点点赞
点在看

标签:
CSDNnews
CSDN 微信号:CSDNnews 扫描二维码关注公众号
优质自媒体

小编推荐

  1. 1 重出江湖!安海人的骄傲!

  2. 2 excel怎么复制表格格式(excel复制表格格式变了)

    大家好,小娟今天来为大家解答excel怎么复制表格格式以下问题,excel复制表格格式变了很多人还不知道,现在让我们一起来看看吧!1、方法一、复制

  3. 3 路遥的作品(路遥的作品集)

    大家好,小丽今天来为大家解答路遥的作品以下问题,路遥的作品集很多人还不知道,现在让我们一起来看看吧!1、路遥的作品有:《平凡的世界》

  4. 4 摇曳的意思是什么(烛光摇曳的意思是什么)

    大家好,小伟今天来为大家解答摇曳的意思是什么以下问题,烛光摇曳的意思是什么很多人还不知道,现在让我们一起来看看吧!1、摇曳的意思是摇

  5. 5 跟男朋友聊天的话题搞笑(跟男朋友的聊天话题)

    大家好,小乐今天来为大家解答跟男朋友聊天的话题搞笑以下问题,跟男朋友的聊天话题很多人还不知道,现在让我们一起来看看吧!1、4. 我希望每

  6. 6 哪种射线引起的外照射可以不予考虑(射线外照射危害大小排序)

    大家好,小伟今天来为大家解答哪种射线引起的外照射可以不予考虑以下问题,射线外照射危害大小排序很多人还不知道,现在让我们一起来看看吧

  7. 7 鬼六神算(鬼六神算系列图)

    大家好,小乐今天来为大家解答鬼六神算以下问题,鬼六神算系列图很多人还不知道,现在让我们一起来看看吧!1、天宇系列加鬼六神算来源于中国

  8. 8 吉林大学珠海校区地址(吉林大学珠海学院详细地址查询)

    大家好,小美今天来为大家解答吉林大学珠海校区地址以下问题,吉林大学珠海学院详细地址查询很多人还不知道,现在让我们一起来看看吧!1、吉

Copyright 2024 优质自媒体,让大家了解更多图文资讯!