工控智汇

工控智汇

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

admin 3 65

译者|弯月,责编|郑丽媛

出品|CSDN(ID:CSDNnews)

以下为译文:

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

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

一切的开端

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

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

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

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

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

ARM的虚拟内存分割

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

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

configPAGE_OFFSET
hex
defaultPHYS_OFFSETif!MMU
default0x40000000ifVMSPLIT_1G
default0x80000000ifVMSPLIT_2G
default0xB0000000ifVMSPLIT_3G_OPT
default0xC0000000

首先注意到,如果没有MMU(例如在运行ARMCortex-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画在上面,不过这是我的个人习惯,也是绝大多数硬件手册中的惯例。

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

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

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

在哪里执行?

我们继续看arch/arm/kernel/处的符号stext。

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

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

adrr3,2f
ldmiar3,{r4,r8}
subr4,r3,r4@(PHYS_OFFSET-PAGE_OFFSET)
addr8,r8,r4@PHYS_OFFSET
()
2:.long.
.longPAGE_OFFSET

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

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

我们将2:在编译时生成的地址加载到r4中,将常量PAGE_OFFSET加载到r8中。然后从中减去2:的真实地址。之后利用相对指令从r4中得到2:的真实地址并保存到r3,再从r3中减去r4。记住ARM汇编的参数顺序就像计算器一样,subra,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(executeinplace,原地执行)的一些特殊情况,例如内核直接从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运行时补丁”。该方法由NicolasPitre、EricMiao和RussellKing于2011年发明,2013年SantoshShilimkar将其扩展并应用到了LPAE系统上,特别是TIKeystoneSoC上。

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

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

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

staticinlineunsignedlong__virt_to_phys(unsignedlongx)
{
unsignedlongt;
__pv_stub(x,t,"add");
returnt;
}

staticinlineunsignedlong__phys_to_virt(unsignedlongx)
{
unsignedlongt;
__pv_stub(x,t,"sub");
returnt;
}

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

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

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

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

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

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

遍历补丁表

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

__fixup_pv_table:
adrr0,1f
ldmiar0,{r3-r7}
mvnip,PAGE_SHIFT@converttoPFN
strr0,[r6]@savecomputedPHYS_OFFSETto__pv_phys_pfn_offset
()
b__fixup_a_pv_table

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

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

__pv_phys_pfn_offset特别重要,它的含义是给物理地址打补丁成虚拟地址时需要的偏移量,所以我们首先通过movr0,r8,lsrPROCINFO_MM_MMUFLAGS]@mm_mmuflags
()
addr0,r4,(SECTION_SHIFT-PMD_ORDER)
1:strr3,[r0],1SECTION_SHIFT
cmpr0,r6
bls1b

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

addr0,r4,PROCINFO_MM_MMUFLAGS]@mm_mmuflags
()
orrr3,r8,r7

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

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

addr6,r4,r6,lsr1PMD_ORDER
addr3,r3,1000];c088cdcc
c088c9e0:e59f03e8ldrr0,[pc,36;0x24
c088c9ec:e58d301cstrr3,[sp,ifdefined(CONFIG_ARM)defined(CONFIG_DEBUG_LL)
{
externvoidprintascii(char*);
printascii("start_kernel\n");
}
#if

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

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

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

原文:

点分享