百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 优雅编程 > 正文

ARM Linux 的启动过程,这一切的开始 | Linux内核

sinye56 2025-01-17 12:46 7 浏览 0 评论

大家好,我是老吴。

今天继续跟大家分享 linus 的文章,

文章有点长,都是 linus 的锅。

我的翻译策略是这样:

不会一字一句翻译,会改变表达方式,

会简化代码分析,必要的地方会使用英文术语。

水平有限,建议搭配原文阅读。


本文将讨论 ARM Linux 内核在自解压后,如何在物理内存中执行自引导,直到能够在虚拟内存中执行用 C 编写的通用内核代码。这里默认你是了解一点 ARM 汇编语言和 Linux 内核基础知识的。

这一切的开始

ARM Linux 内核在自解压并处理完设备树的更新后,会将程序计数器 pc 设置为 stext() 的物理地址,这里是内核的代码段。这段代码可以在 arch/arm/kernel/head.S 中找到。

arch/arm/kernel/head.S

/*
 * Kernel startup entry point.
 * ---------------------------
 *
 * This is normally called from the decompressor code.  The requirements
 * are: MMU = off, D-cache = off, I-cache = dont care, r0 = 0,
 * r1 = machine nr, r2 = atags or dtb pointer.
 *
    [...]
	__HEAD
ENTRY(stext)
    [...]

__HEAD 是定义在链接脚本里的一个宏:section:".head.text"。

通过查看 ARM 体系结构的链接脚本 arch/arm/kernel/vmlinux.lds.S,可以知道这个宏会将目标代码放置在内核最开始的位置。

这个位置对应的物理地址为:16MB 的倍数 + TEXT_OFFSET (32KB)。例如,你可能会在 0x10008000 之类的地址处找到 stext(),后面的示例会基于这个假设的地址进行分析。

head.S 包含了一些针对不同的旧 ARM 平台的特殊处理代码,这使得我们很难从抓住程序的主干。ATAG 和设备树的标准是后来才出现的,所以这些特殊代码多年来变得越来越复杂。

要理解后续的内容,你需要对分页虚拟内存 (paged virtual memory) 有基本的了解。如果维基百科过于简洁,请参阅 Hennesy & Patterson 的书:Computer Architecture: A Quantitative Approach。这里默认你是了解一点 ARM 汇编语言和 Linux 内核基础知识的。

虚拟内存的划分

首先,让我们先弄清楚内核是在虚拟内存中哪个地址开始执行的。内核的虚拟内存基地址 (kernel RAM base) 由 PAGE_OFFSET 决定,你可以对其进行配置。从名字上理解 PAGE_OFFSET:first page of kernel RAM 在虚拟内存中的偏移位置。

你可以从 4 种内存划分方案中选择其中 1 个,这让我想起了快餐店的餐牌。目前在 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,即 virtual memory split symbol,在其上方的地址处创建一个虚拟内存空间,供内核驻留。内核将其所有代码、状态和数据结构 (包括虚拟到物理内存转换表,即 page table) 都保存在这一区域的虚拟内存中:

0x40000000-0xFFFFFFFF

0x80000000-0xFFFFFFFF

0xB0000000-0xFFFFFFFF

0xC0000000-0xFFFFFFFF

这 4 种不同大小的内核空间里,0xC0000000-0xFFFFFFFF 是迄今为止最常见的。这种方式下,内核有 1GB 的地址空间可供使用。

内核下方的虚拟内存空间,从 0x00000000-PAGE_OFFSET-1,即通常地址 0x00000000-0xBFFFFFFF (3 GB) 用于用户空间代码。这意味着您可以乐观地为程序提供比可用物理内存更多的虚拟内存空间,这种做法被称为 overcommit。每次启动一个新的用户空间进程时,它都认为它有 3 GB 的内存可以使用!overcommit 一直是 Unix 系统自 1970 年代诞生以来的一个特征。

嵌入式物联网需要学的东西真的非常多,千万不要学错了路线和内容,导致工资要不上去!

无偿分享大家一个资料包,差不多150多G。里面学习内容、面经、项目都比较新也比较全!某鱼上买估计至少要好几十。

点击这里找小助理0元领取:加微信领取资料




为什么有四种不同的划分方式?

答案很明显:ARM 大量用于嵌入式系统,这些系统可以是用户空间密集型 (例如普通平板电脑或手机,甚至台式计算机) 或内核空间密集型 (例如路由器)。大多数系统都是用户空间密集型,或物理内存太小以至于拆分并不重要,因此最常见方式是 PAGE_OFFSET = 0xC0000000。


关于这些插图的注意事项:当我说内存“高于”某物时,我的意思是图片中的较低位置,沿着箭头,朝向更高的地址。我知道有些人认为这是不合逻辑的,并将数字倒置,顶部为 0xFFFFFFFF,但这是我个人的偏好,也是大多数硬件手册中使用的约定。

当你有足够大的内存和并且应用场景是内核密集型,例如大容量的内存 (例如 4GB 内存) 路由器或 NAS 的话,如果你希望内核能够将其中一些内存用于 page cache 和 network cache 以提升系统的性能,可以选择更大的内核空间,例如在极端情况下:PAGE_OFFSET = 0x40000000。

内核空间的映射会一直存在,即便是内核正在执行用户空间代码时也是如此。这个想法是这样的,通过保持内核空间永久映射,从用户空间到内核空间的上下文切换会变得非常快:当用户空间进程想要向内核询问某些东西时,不需要替换任何页表。只需发出一个软中断 (software trap) 来切换到特权模式 (supervisor mode) 并执行内核代码,无需改动虚拟内存相关的设置。

不同用户空间的进程之间的上下文切换也变得更快:你只需要替换页表的较低部分。内核空间的映射通常很简单,它映射的是预先确定的物理内存块并且是线性映射,甚至存储在一个特殊的地方:translation lookaside buffer,从而能更快地进入内核空间。内核空间的地址总是存在的,并且总是线性映射,永远不会产生 page fault。

目前我们是在哪里运行?

我们继续查看 arch/arm/kernel/head.S 里的 stext()。

下一步是处理我们目前正在内存的某个未知位置运行的事实。内核可以被加载到任何地址(只要它是一个合理的偶数地址)并直接执行,所以现在我们需要处理它。由于内核代码不是位置无关的,它在编译后被链接器链接到某个地址处执行,而我们还不知道是哪个地址。

内核首先检查一些特殊功能,如虚拟化扩展和 LPAE(大型物理地址扩展),然后做了下面这件事:

arch/arm/kernel/head.S

    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 . 是在链接的时候就分配给 lable 2 的地址,也就是说我们可以通过 label 2 获得其链接地址,这个地址属于内核空间,一般在 0xC0000000 之上的某个位置。

之后是常量 PAGE_OFFSET,它大概率是 0xC0000000。

其余的几行汇编代码是在通过 lable 2 的运行地址和链接地址相减的方式来推算出物理内存的起始偏移(PHYS_OFFSET),将其保存在 r8 中,假设其值为0x10000000。

旧的 ARM 内核有一个名为 PLAT_PHYS_OFFSET 的符号,它包含这个偏移量,这是在编译时时指定的。我们现在不再这样做了,正如我们前面看到的那样,动态地计算出来。如果您使用的操作系统不如 Linux 那么成熟,您会发现开发人员通常就会在编译时指定,使事情变得简单些:物理内存的起始偏移量是一个常数。Linux 发展成现在这样,是因为我们需要在各种内存布局上处理单个内核映像的启动。

物理内存到虚拟内存映射。

一些关于 PHYS_OFFSET 的规定:它需要遵守一些基本的对齐要求。当我们要确定第一个物理内存块的位置时,是通过执行 PHYS = pc & 0xF8000000 来确定的,这意味着物理内存必须是 128 MB 对齐。例如,如果它从 0x00000000 开始,那就太好了。

当内核是以 XIP “execute in place” 的方式执行时,就需要有有一些特殊的考虑,但我们把这种情况放在一边,这是另一个奇怪的地方,甚至比不使用虚拟内存更不常见。

请注意另一件事:你可能尝试加载未压缩的内核并启动它,然后发现内核对放置它的位置特别挑剔。此时,你最好将其加载到 0x00008000 或 0x10008000 之类的物理地址(假设你的 TEXT_OFFSET 是 0x8000 )。如果你使用压缩内核,则可以避免此问题,因为解压缩器会将内核解压缩到合适的位置(通常为 0x00008000)并为你解决此问题。这正是人们觉得压缩内核正常工作是一种常态的另一个原因。

给 P2V 打补丁 (Patching Physical to Virtual)

现在我们有了运行时应处于的虚拟内存地址和实际执行时的物理内存地址之间的偏移量 (PHYS_OFFSET - PAGE_OFFSET),接下来我们第一个要处理的东西就是 CONFIG_ARM_PATCH_PHYS_VIRT。

创建此符号是因为内核开发者想实现这样的功能:无需重新编译,也能让同一个内核在不同内存配置的系统上启动。内核被编译成在某个虚拟地址处执行,例如 0xC0000000,但是仍然可以被加载到物理内存 0x10000000 处,或者在 0x40000000 处,或其他某个地址处去执行。

内核中的大多数符号是不需要我们额外关心的:它们运行时的地址就是其链接时的虚拟地址上,即 0xC0000000 之后的那些地址。但是现在我们不是在编写用户空间的程序,事情没那么容易。我们必须知道我们正在执行的物理内存,因为我们是内核,这意味着我们需要在页表中设置物理到虚拟的映射,并定期更新这些页表。

内核不知道自己将在物理内存中的哪个位置运行,而我们也不能依赖任何廉价的技巧,例如编译时常量,这是作弊,那会创建难以维护的充满幻数的代码。

为了在物理地址和虚拟地址之间转换,内核有两个函数:__virt_to_phys() 和 __phys_to_virt() 用于互相转换内核地址 (不会用于非内核地址)。

这种转换在内存空间中是线性的,可以通过简单的加法或减法来实现。这就是我们正要做的事情,我们给它起了个名字叫 “P2V runtime patching”。该方案由 Nicolas Pitre、Eric Miao 和 Russell King 在 2011 年发明,2013 年 Santosh Shilimkar 将该方案扩展到适用于 LPAE 系统,特别是 TI Keystone SoC。

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 的 section,然后向该 section 里添加一个条目,条目的内容是一个指针,它指向前面提到的汇编代码。这意味着 .pv_table section 其实就是一个表,里面的每一个条目都是一个指针,每个指针都指向内核调用了 __virt_to_phys() 或 __phys_to_virt() 处的汇编代码。

在启动过程中,我们将遍历这个表,取出每个指针,检查它指向的每条指令,然后用 (PHYS_OFFSET - PAGE_OFFSET) 去给这些指令打补丁。

在早期启动过程中,每个调用了执行物理内存到虚拟内存的转换汇编宏的地方都需要打补丁

相关的代码:

__fixup_pv_table:
	adr	r0, 1f
	ldmia	r0, {r3-r7}
	mvn	ip, #0
	subs	r3, r0, r3	@ PHYS_OFFSET - PAGE_OFFSET
	add	r4, r4, r3	@ adjust table start address
	add	r5, r5, r3	@ adjust table end address
	add	r6, r6, r3	@ adjust __pv_phys_pfn_offset address
	add	r7, r7, r3	@ adjust __pv_offset address
	mov	r0, r8, lsr #PAGE_SHIFT	@ convert to PFN
	str	r0, [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

核心内容就是,先计算出 pv_table 的起始地址和结束地址,然后遍历该表,对每一个条目都调用 __fixup_a_pv_table,给该条目所指向的汇编代码打补丁。

为什么我们进行这么复杂的操作,而不仅仅是将偏移量存储在变量中?

这是出于效率原因:它位于内核的热数据路径上。更新页表和交叉引用物理到虚拟内核内存的操作对性能的要求是及其苛刻的,所有使用内核虚拟内存的场景,无论是 block layer 或 network layer 的操作,还是用户到内核空间的转换,原则上任何通过内核的数据会在某个时间点调用这些函数。所以,他们必须很快。

上面的做法不能称为简单的解决方案,事实上,它是一个非常复杂的解决方案。但是它能正常工作,并且非常高效!

好啦,今天就分享这么多吧。

感谢阅读,下篇文章见。

文章链接:

https://mp.weixin.qq.com/s/C0xbOHqwoKZXy8b6IJyAYA

转载自:老吴嵌入式

文章链接:ARM Linux 的启动过程,这一切的开始 | Linux内核

相关推荐

程序员:JDK的安装与配置(完整版)_jdk的安装方法

对于Java程序员来说,jdk是必不陌生的一个词。但怎么安装配置jdk,对新手来说确实头疼的一件事情。我这里以jdk10为例,详细的说明讲解了jdk的安装和配置,如果有不明白的小伙伴可以评论区留言哦下...

Linux中安装jdk并配置环境变量_linux jdk安装教程及环境变量配置

一、通过连接工具登录到Linux(我这里使用的Centos7.6版本)服务器连接工具有很多我就不一一介绍了今天使用比较常用的XShell工具登录成功如下:二、上传jdk安装包到Linux服务器jdk...

麒麟系统安装JAVA JDK教程_麒麟系统配置jdk

检查检查系统是否自带java在麒麟系统桌面空白处,右键“在终端打开”,打开shell对话框输入:java–version查看是否自带java及版本如图所示,系统自带OpenJDK,要先卸载自带JDK...

学习笔记-Linux JDK - 安装&配置

前提条件#检查是否存在JDKrpm-qa|grepjava#删除现存JDKyum-yremovejava*安装OracleJDK不分系统#进入安装文件目...

Linux新手入门系列:Linux下jdk安装配置

本系列文章是把作者刚接触和学习Linux时候的实操记录分享出来,内容主要包括Linux入门的一些理论概念知识、Web程序、mysql数据库的简单安装部署,希望能够帮到一些初学者,少走一些弯路。注意:L...

测试员必备:Linux下安装JDK 1.8你必须知道的那些事

1.简介在Oracle收购Sun后,Java的一系列产品就被整合到Oracle官网中,打开官网乍眼一看也不知道去哪里下载,还得一个一个的摸索尝试,而且网上大多数都是一些Oracle收购Sun前,或者就...

Linux 下安装JDK17_linux 安装jdk1.8 yum

一、安装环境操作系统:JDK版本:17二、安装步骤第一步:下载安装包下载Linux环境下的jdk1.8,请去官网(https://www.oracle.com/java/technologies/do...

在Ubuntu系统中安装JDK 17并配置环境变量教程

在Ubuntu系统上安装JDK17并配置环境变量是Java开发环境搭建的重要步骤。JDK17是Oracle提供的长期支持版本,广泛用于开发Java应用程序。以下是详细的步骤,帮助你在Ubuntu系...

如何在 Linux 上安装 Java_linux安装java的步骤

在桌面上拥抱Java应用程序,然后在所有桌面上运行它们。--SethKenlon(作者)无论你运行的是哪种操作系统,通常都有几种安装应用程序的方法。有时你可能会在应用程序商店中找到一个应用程序...

Windows和Linux环境下的JDK安装教程

JavaDevelopmentKit(简称JDK),是Java开发的核心工具包,提供了Java应用程序的编译、运行和开发所需的各类工具和类库。它包括了JRE(JavaRuntimeEnviro...

linux安装jdk_linux安装jdk软连接

JDK是啥就不用多介绍了哈,外行的人也不会进来看我的博文。依然记得读大学那会,第一次实验课就是在机房安装jdk,编写HelloWorld程序。时光飞逝啊,一下过了十多年了,挣了不少钱,买了跑车,娶了富...

linux安装jdk,全局配置,不同用户不同jdk

jdk1.8安装包链接:https://pan.baidu.com/s/14qBrh6ZpLK04QS8ogCepwg提取码:09zs上传文件解压tar-zxvfjdk-8u152-linux-...

运维大神教你在linux下安装jdk8_linux安装jdk1.7

1.到官网下载适合自己机器的版本。楼主下载的是jdk-8u66-linux-i586.tar.gzhttp://www.oracle.com/technetwork/java/javase/downl...

window和linux安装JDK1.8_linux 安装jdk1.8.tar

Windows安装JDK1.8的步骤:步骤1:下载JDK打开浏览器,找到JDK下载页面https://d.injdk.cn/download/oraclejdk/8在页面中找到并点击“下载...

最全的linux下安装JavaJDK的教程(图文详解)不会安装你来打我?

默认已经有了linux服务器,且有root账号首先检查一下是否已经安装过java的jdk任意位置输入命令:whichjava像我这个已经安装过了,就会提示在哪个位置,你的肯定是找不到。一般我们在...

取消回复欢迎 发表评论: