arm32架构,32位arm

译者| 三日月,编辑|

头图|CSDN下载自视觉中国

制作| CSDN(ID:CSDNnews)

译文如下。

前段时间,我写了一篇介绍内核解压的文章。结果,那篇文章(https://people.kernel.org/linusw/how-the-arm32-linux-kernel-decompresses)收到了很多评论和消息。有点令人惊讶。也许这篇文章与20 世纪70 年代末流行的《莱昂氏UNIX源代码分析第六版》 出于同样的原因。操作系统开发人员喜欢阅读深入而广泛的代码注释,本文就是这样做的。

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

本文介绍了在解压和引导用C 语言编写的通用内核代码、将其加载到物理内存中并在虚拟内存中运行之后,内核如何进行自我引导。

解包、扩展、接收设备树块(DTB)并调用ARM32后,所有内容都放置在符号stext的物理地址(即文本段的开头)。核。这段代码可以在arch/arm/kernel/head.S 中找到。

宏__HEAD 将代码放置在名为.head.text 的连接器部分中。如果查看ARM架构连接器文件(arch/arm/kernel/vmlinux.lds.S),可以看到该操作表明将首先执行本节中的目标代码。

这里的物理地址被均匀地划分为16MB加上32KB的TEXT_OFFSET(稍后会详细说明原因),因此stext的地址大约为0x10008000(本例中使用的地址)。

head.S 很难阅读,因为它包含各种旧ARM 平台上使用的密集异常处理的小部分。 ATAG 和设备树启动标准大约在创建head.S 的同时引入,因此多年来该代码变得越来越复杂。

要理解以下内容,您必须了解分页虚拟内存的基础知识。如果您发现Wikipedia 的介绍过于笼统,请查看Hennesy Patterson 的书《计算机体系结构:量化研究方法》。本文还假设您具备基本ARM 汇编语言和Linux 内核的基本知识。

ARM的虚拟内存分区首先介绍了内核在虚拟内存中运行的位置。内核的RAM基地址由PAGE_OFFSET符号定义,其位置是可配置的。正如名称PAGE_OFFSET 所示,这是内核RAM 第一页的虚拟内存偏移量。

您可以选择四种内存分区方法之一,让人想起快餐店。目前在arch/arm/Kconfig 中定义为:

config PAGE_OFFSET hex 默认PHYS_OFFSET !对于MMU 默认0x40000000 对于VMSPLIT_1G 默认0x80000000 对于VMSPLIT_2G 默认0xB0000000 对于VMSPLIT_3G_OPT 默认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 可用内存。自20 世纪70 年代以来,这种过度配置一直是Unix 的一个特性。

为什么有四种分割方法?

答案很简单。 ARM在嵌入式系统中有众多的应用,而这些系统可能更关注用户空间(比如常见的平板电脑、手机,甚至台式电脑)。或者在内核空间中(例如作为路由器)。大多数系统要么重视用户空间,要么内存很少,因此如何分区并不重要(无论如何分区,内存都会变得非常拥挤)。因此,最常见的分区是将PAGE_OFFSET设置为0xC0000000。图:最常见的划分在内核空间和用户空间之间的虚拟内存位于0xC0000000。这张图有一点需要注意。当我们说内存“在顶部”时,我们指的是上图中的底部位置,沿着指向更高地址的箭头。我确信这是不合逻辑的,有些人更喜欢颠倒上面的图并在上面画上0xFFFFFFFF,但这是我个人的做法,大多数硬件手册也是习惯。

您的系统可能拥有大量内存,例如具有大量内存(例如4GB RAM)的路由器或NAS,并且可能更关注内核空间。目前,为了加速最常见的操作,内核可能需要使用一些内存作为页面缓存和网络缓存,因此在极端情况下,例如,可以保留内核内存。将PAGE_OFFSET 设置为0x40000000。

即使内核正在执行用户空间代码,该虚拟内存映射也永远存在。始终维护内核映射可以实现从用户空间到内核空间的非常快速的上下文切换,从而在用户空间进程需要调用内核时无需进行页表替换。只需启动软件陷阱,切换到管理模式,然后运行内核代码,而无需更改虚拟内存配置。

在不同用户空间之间执行上下文切换也更快。只需用预定义的物理RAM 块替换页表的底部(通常替换内核映射,因为它很简单)。这个预定义的物理RAM 是线性映射的,并且还存储在一个特殊位置,即页表缓存(转换后备缓冲区)。页表缓存位于片上,是一个“非常快的转换表”,因此它可以更快地进入内核空间。这些地址始终存在,始终线性映射,并且从不产生页面错误。

应该在哪里执行呢?让我们继续查看arch/arm/kernel/head.S 中的符号文本。

下一步是处理未知内存地址处的执行。内核可以在任何地方加载和执行(只要它是一个相当均匀的地址),所以你必须处理这个问题。请注意,内核代码是与位置无关的,因此一旦内核被编译和链接,它必须在特定地址执行。不过,我还不知道地址。

内核首先检查虚拟化扩展、LPAE(大型物理地址扩展)等特殊功能,然后执行以下操作:

adr r3, 2f ldmia r3, {r4, r8} sub r4, r3, r4 @ (PHYS_OFFSET – PAGE_OFFSET) 添加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=pc0xF8000000。这意味着您的物理RAM 必须从128MB 边界开始。例如,从0x00000000 开始就可以。

这段代码考虑到了XIP(就地执行)的一些特殊情况,比如直接从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)

所以,在虚拟地址上加上一个常量就得到了物理地址,用物理地址减去这个常量就得到了虚拟地址。所以你的第一个代码可能如下所示:

静态内联unsigned long __virt_to_phys(unsigned long x){unsigned long t; __pv_stub(x, t, \\\’add\\\’); 静态内联unsigned long __phys_to_virt(unsigned long x){unsigned long t , 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 读取5 个符号到r3-r7 中并直接引用这5 个符号。按照上述方式编写它们(这就是为什么此表前面有一个.long 的原因)。

__fixup_pv_table:adrr0, 1fldmiar0, {r3-r7}mvnip, #0subsr3, r0, r3@ PHYS_OFFSET – PAGE_OFFSETaddr4, r4, r3@ 表起始地址addr5, r5, r3@ 调整表结束地址addr6, r6, r3@ 调整__pv_phys_pfn_offset addressaddr7 , r 7, r3@ 调整__pv_offset 地址movr0, r8, lsr #PAGE_SHIFT@ 转换为PFNstrr0, [r6]@ 计算PHYS_OFFSET __pv_phys_pfn_offset(.)b__fixup_a_pv_table1:long__pv_table_begin .long__pv_table_end2: 保存为.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的每个地址(该表中存储的指针指向需要修补的指令)并使用自定义二进制修补程序Apply依次修补它们。这对应于ARM 或THUMB2。指令(在编译时确定的指令类型)被翻译成带有立即偏移量的指令,该立即数偏移量表示从物理地址到虚拟地址的偏移量。这段代码非常复杂,包含很多奇怪的处理大字节序的操作。

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

设置初始页表在开始在虚拟内存中运行之前,必须设置MMU 转换表以将物理内存映射到虚拟内存。尽管此表通常称为“页表”,但初始映射使用节而不是页。 ARM 架构还要求页表在物理内存中的16KB 边界上对齐。这个要求非常合理,因为页表大小始终为16KB。

初始页表的位置由符号swapper_pg_dir定义。这代表“交换页目录”,是初始页表的内核名称。随后,该页表被替换(或交换)为更详细的页表,因此被称为“交换”页目录。

“页表”这个名字有点误导。这是因为初始映射在ARM 术语中实际上称为“节”,而不是页。然而,术语“页表”仍然被广泛地用来指代负责在引导时将物理地址转换为虚拟地址的东西。

符号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,但在某些Qualcomm 平台上可能为0x00208000,在某些Broadcom 平台上可能为0x00108000,因此KERNEL_RAM_VADDR 可能为0xC0208000。

我们确实知道的是,当连接到内核时,该地址会传递给连接器。如果您查看ARM 架构连接器文件(arch/arm/kernel/vmlinux.lds.S),您会发现它告诉连接器放置在=PAGE_OFFSET + TEXT_OFFSET 处。内核被编译为在KERNEL_RAM_VADDR 指定的地址上运行,甚至我们当前正在分析的最古老的代码也是以位置无关的方式编写的。

TEXT_OFFSET 是物理和虚拟空间中位于内核RAM 上方的一个小区域(通常为32KB)。图中物理RAM0x10000000 处的内核位置只是一个示例;它实际上可能位于16MB 边界上。

注意内核上的小间隙。最常见的位置是0xC0000000-0xC0007FFF(TEXT_OFFSET 最常见的32KB 大小)。这是内核空间内存的一部分,但是内核并不放在这块内存中。这包括初始页表、可能由引导加载程序提供的ATAG 以及一些临时空间。如果内核位于0x00000000,则该区域也可用于中断向量。当然,这里的第一个页表曾被内核使用过,但后来被废弃了。

请注意,我向物理内存和虚拟内存添加了相同的TEXT_OFFSET 区域。当您解压代码时,这一步实际上已经完成。解压缩过程将内核的第一个字节放置在PHYS_OFFSET + TEXT_OFFSET 处,因此物理内存和虚拟内存中的内核位置之间的线性差异始终可以有多个可用的高位。由于它表示为32 位字,例如位24 到位31(8 位),因此您可以简单地使用立即算术将偏移量直接添加到指令中。从此时起,内核RAM 必须位于可被16MB (0x01000000) 整除的地址。

查找虚拟内存中swapper_pg_dir 的物理位置,并通过将TEXT_OFFSET 添加到PAGE_OFFSET 来减少PG_DIR_SIZE。典型的结果是0xC0000000 +0x8000 -0x4000,因此第一个页表位于虚拟内存中的0xC0004000 处,物理内存中相应的偏移量(本例中使用PHYS_OFFSET0x10000000 计算)为0x10004000。

在此示例中,swapper_pg_dir 符号位于物理内存中.text 段之前的16 KB(0x4000 字节)处。使用传统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(Large Physical Address Extensions)的一个特性,顾名思义,它是用来处理大物理内存的,最多可以处理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_ORDERr4 包含页表(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, r7r8 包含 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 :c088c9d8: e92d4ff0 push {r4, r5, r6, r7, r8, r9, sl, fp, lr}c088c9dc: e59f53e8 ldr r5, [pc, #1000] ; c088cdccc088c9e0: e59f03e8 ldr r0, [pc, #1000] ; c088cdd0c088c9e4: e5953000 ldr r3, [r5]c088c9e8: e24dd024 sub sp, sp, #36 ; 0x24c088c9ec: e58d301c str r3, [sp, #28]c088c9f0: ebde25e8 bl c0016198 非常好!我们在执行 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中了。我们可以确定的是,初始转译表会被一个更详细的转译表替换。目前还没有用户空间的虚拟内存到物理内存的映射。不过这是另外一个话题了。
原文:https://people.kernel.org/linusw/how-the-arm32-kernel-starts本文为 CSDN 翻译,转载请注明来源出处。
点分享

本文和图片来自网络,不代表火豚游戏立场,如若侵权请联系我们删除:https://www.huotun.com/game/664114.html

(0)
上一篇 2024年6月3日
下一篇 2024年6月3日

相关推荐

  • qq怎么创小号玩和平精英?

    qq怎么创小号玩和平精英? 1. 打开和平精英登录界面,点击【qq登录】。 2. 来到授权页面,点击【切换帐号】。 3. 在弹出来的页面,点击【添加帐号】。 4. 输入qq和密码登录即可。 和平精英怎么创小号? 步骤/方式1 首先打开和平精英。 步骤/方式2 如下图所示,点击下方的设置图标。 步骤/方式3 在设置页面,点击退出按钮。 步骤/方式4 退出登录后…

    游戏快讯 1小时前
  • 和平精英怎么跳伞最快?

    和平精英怎么跳伞最快? 工具/原料 手机联网 和平精英 方法/步骤 1、在飞机起飞前,标点好要降落的地方。如果想要去离航线比较远的地方,在道路上降落,找车过去。标点的作用就是指引我们跳伞的方向,随缘降落的玩家可以随意。 2、跳伞落地快要考虑两个方面的问题,一个是调整角度问题,另一个是在适合的时候开伞。因为跳伞时候距离目的地的距离是不一样的,可以分为垂直斜飞、…

    游戏快讯 3小时前
  • 和平精英机动兵是哪个模式?

    和平精英机动兵是哪个模式? 和平精英机动兵是娱乐模式。 1、首先要进入和平精英游戏主页,点击左上角的【切换模式】按钮,点击以后会进入和平精英模式界面; 2、然后在和平精英模式的选择界面中找到机动兵模式,机动兵模式就是和平精英游戏中的机甲模式,选择此模式以后就可以匹配对局; 和平精英机动兵各个技能? 以下是《和平精英》游戏中机动兵的各个技能: 1. 冲刺:机动…

    游戏快讯 4小时前
  • 和平精英如何穿墙标点?

    和平精英如何穿墙标点? 在设置中开启顶枪提示,就能穿墙标点了 和平精英能穿墙吗? 和平精英是一款网络游戏,游戏中的角色不能穿墙。墙壁、障碍物等都是可以阻挡角色移动的,这是游戏的设计规则。如果您在游戏中遇到了穿墙的情况,那么可能是游戏中存在的一些BUG或外挂程序导致的,建议不要使用这些程序,以免被封号或影响游戏的公平性。 和平精英家园怎么穿墙? 和平精英家园穿…

    游戏快讯 6小时前
  • 怎么录和平精英视频? 和平精英怎么录视频?

    怎么录和平精英视频? 1、先连接上手机屏幕,然后打开游戏。 2、打开之后可以看到在的游戏下方有一个录屏的选项。 3、点击结束之后可以看到左下角会弹出一个文件查看的选项,点击就可以查看到录制的视频了。 4、如果没有显示文件查看的话点击菜单设置。 5、点击设置之后依次找到菜单上的保存路径。 6、在保存路径的下面找到文件夹选项,点击即可查看到视频了。 和平精英怎么…

    游戏快讯 7小时前