ARM 启动流程

本文逐指令分析 Cortex-A 裸机系统的核心初始化序列,深入拆解从处理器上电复位、建立异常向量表、配置 Cache 和 MMU,直至移交控制权给 Linux 内核的完整技术路径。

1. 引言:从复位到 main()

在 x86 PC 上,BIOS/UEFI 固件已经为你完成了硬件初始化和设备枚举。但在 ARM 嵌入式系统中——特别是”裸机”(bare-metal)环境——你什么都没有。处理器上电后处于一种近乎原始的默认状态:

  • R0-R14 的值不可预测(reset values are unpredictable)
  • Caches 和 MMU 全部关闭——每次内存访问都直接经过总线到达物理存储
  • CPSR.I 和 CPSR.F 置位——IRQ 和 FIQ 被屏蔽
  • 所有异常模式(FIQ、IRQ、ABT、SVC、UND)的 banked SP 寄存器都是随机值——不能用栈

裸机系统在调用 main() 之前必须完成的八个初始化任务:

The reset handler code must do some, or all of the following:

  • In a multi-core system, enable non-primary cores to sleep.
  • Initialize exception vectors.
  • Initialize the memory system, including the MMU.
  • Initialize core mode stacks and registers.
  • Initialize any critical I/O devices.
  • Perform any necessary initialization of NEON or VFP.
  • Enable interrupts.
  • Change core mode or state.
  • Handle any set-up required for the Secure world.
  • Call the main() application.

这八个步骤的执行顺序至关重要——每一步都为后续步骤建立依赖。让我们(Examples 13.1–13.5)逐一分析每一步的汇编实现。

下图展示了从复位到 main() 的完整裸机启动流程:

1


2. 第一步:建立异常向量表

The first consideration is placement of the exception vector table. You must make sure that it contains a valid set of instructions that branch to the appropriate handlers.

复位向量是异常向量表的第一个条目(偏移 0x00),处理器复位后 PC 会直接跳到这里。

When the core has been reset, it will commence execution at the location of the reset vector in the exception vector table (at either address 0x00000000 or 0xFFFF0000).

Example 13.1 给出了向量表的典型布局:

1
2
3
4
5
6
7
8
9
10
11
; 异常向量表 — 位于 0x00000000 或 0xFFFF0000
; 每个条目占 4 字节,包含一条 B 跳转指令

B Reset_Handler ; 0x00: 复位向量 → 跳转到初始化代码
B Undefined_Handler ; 0x04: 未定义指令
B SWI_Handler ; 0x08: 软件中断 (SVC)
B Prefetch_Handler ; 0x0C: 预取异常
B Data_Handler ; 0x10: 数据异常
NOP ; 0x14: 保留(Hyp Trap Entry 占位)
B IRQ_Handler ; 0x18: IRQ 中断
; FIQ_Handler 直接放在向量表之后(0x1C),因为 FIQ 在最末位,可以省去跳转

从汇编中可以看到几个设计细节:

  1. 每个条目只有一条 B 指令——4 字节刚好填满向量表的一个槽位。这确保向量表的 8 个条目总大小为 32 字节。
  2. 从 FIQ handler 开始不再需要跳转指令——因为 FIQ 位于向量表末尾(0x1C),handler 代码可以直接从 0x1C 开始放置,省去一次跳转延迟。这是 FIQ 设计为最高优先级且放在末位的原因之一:节省跳转周期,降低中断响应延迟。
  3. Reset handler 要做的事情最多——它包含的是一条到实际初始化代码的远跳转,而不是原地处理。

2.1 ROM 重映射与位置无关代码

ARM 处理器上电后,会固定从地址 0x00000000 开始取指,因此启动阶段必须让 ROM 映射到该地址,CPU 才能执行 Boot ROM 中的初始化代码。

但 ROM 是只读的,而操作系统通常希望异常向量表能够在运行时修改(例如安装新的异常处理程序或接管中断)。因此,在完成 RAM 初始化后,Boot ROM 会先将异常向量表复制到 RAM,再通过 Memory Remap 将 RAM 映射到地址 0x00000000

整个过程如下:

1
2
3
4
5
6
7
8
9
10
11
上电时:
0x00000000 ───► ROM
0x80000000 ───► RAM

Boot ROM 初始化 RAM
复制异常向量表到 RAM

修改 Memory Remap

0x00000000 ───► RAM
0x80000000 ───► RAM(仍然有效)

这里需要注意的是,RAM 并不是从 0x80000000 搬到了 0x00000000,而是多了一个地址别名(Alias)。之后 CPU 访问 0x00000000 时,实际上访问的是原来的那块 RAM。


The ROM can be aliased to the address of the exception vector. The ROM then writes to some memory-remap peripheral that maps RAM into address 0 and the real exception vector table is copied into RAM. This means the part of the boot code that handles remapping must be position-independent, as only PC-relative addressing can be used.

这里还有一个关键约束:执行重映射(Remap)的那段 Boot Code 必须是位置无关代码(Position-Independent Code,PIC)。

原因在于,重映射前后,地址 0x00000000 的含义已经发生变化:

1
2
3
4
5
6
7
重映射前:

0x00000000 ───► ROM

重映射后:

0x00000000 ───► RAM

如果此时代码仍然依赖固定地址(例如通过绝对地址访问代码或常量),重映射后就可能访问到错误的位置。因此,Boot ROM 在执行 Remap 附近的这几条指令时,只能使用 PC 相对寻址,例如:

1
2
B   next
ADR r0, label

而不能依赖固定地址的寻址方式。


3. 第二步:为各模式初始化栈指针

向量表就绪后,下一个关键任务是让代码能用栈——这是使用 BL(Branch and Link)指令进行子程序调用的前提。Example 13.2 给出了一个典型的栈初始化序列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
; Example 13.2: 初始化 FIQ 和 IRQ 模式的栈指针
; 假设 stack_base 指向一块预分配的栈内存区域(向低地址增长)

LDR R0, stack_base ; R0 = 栈内存区域的基地址

; --- 设置 FIQ 模式栈 ---
MSR CPSR_c, #Mode_FIQ:OR:I_Bit:OR:F_Bit ; 切换到 FIQ 模式,同时禁用中断
MOV SP, R0 ; FIQ 模式的 SP ← stack_base
SUB R0, R0, #FIQ_Stack_Size ; 为 IRQ 模式预留栈空间(R0 向下移动)

; --- 设置 IRQ 模式栈 ---
MSR CPSR_c, #Mode_IRQ:OR:I_Bit:OR:F_Bit ; 切换到 IRQ 模式,中断仍禁用
MOV SP, R0 ; IRQ 模式的 SP ← 减去 FIQ 栈大小后的地址
; ... 类似地初始化 ABT、SVC、UND、SYS 和 User 模式的栈

逐行解析核心机制:

  • **MSR CPSR_c, #mode**:将 CPSR 的低 8 位(_c field)设置为目标模式编码。这是 ARM 中切换处理器模式的唯一方式——你不能直接写 CPSR 的模式域,必须通过 MSR 指令。
  • MOV SP, R0 的效果取决于当前模式——因为 SP (R13) 是 banked 寄存器。在 FIQ 模式下写 SP,实际写入的是 R13_fiq;切换到 IRQ 模式后再写 SP,写入的是 R13_irq
  • 栈向下生长SUB R0, R0, #size 每次上移栈基址,为下一个模式预留空间。这是 ARM AAPCS 调用规范的标准约定(full descending stack)。

之所以在初始化栈的同时禁用中断(I_Bit:OR:F_Bit),是因为此时异常向量表中的各 handler 还未就绪——如果在这期间发生 FIQ/IRQ,处理器会跳到一个尚未初始化的 B 指令,导致不可预期的行为。待 §4-§5 完成所有初始化后才会在 §1 的清单最后一步启用中断。


4. 第三步:关闭并清空 Caches 和 MMU

现在栈可用,可以调用子程序完成更复杂的初始化——第一节是”清旧”,把处理器从复位残留状态变为可控的干净状态。Example 13.3 展示了完整的 Cache/MMU/Branch Predictor 初始化流程:

4.1 关闭 MMU 和 Caches

1
2
3
4
5
6
7
8
9
10
; --- 关闭 MMU ---
MRC p15, 0, r1, c1, c0, 0 ; 读取 SCTLR (System Control Register)
BIC r1, r1, #0x1 ; 清除 bit 0 (M bit —— MMU enable)
MCR p15, 0, r1, c1, c0, 0 ; 写回 SCTLR → MMU 禁用

; --- 关闭 L1 Caches ---
MRC p15, 0, r1, c1, c0, 0 ; 再次读取 SCTLR
BIC r1, r1, #(0x1 << 12) ; 清除 bit 12 (I bit —— Instruction Cache)
BIC r1, r1, #(0x1 << 2) ; 清除 bit 2 (C bit —— Data Cache)
MCR p15, 0, r1, c1, c0, 0 ; 写回 SCTLR → I-Cache 和 D-Cache 禁用

这里一个重要的问题是为什么要先关闭再清空。原因很简单——如果 Caches 仍在工作状态,你对 cache invalidate 操作的执行效果是不可预测的:硬件可能还在从 Cache 中取指或写回脏数据。必须在 Cache 静止状态(disabled)下执行清除操作。

4.2 清空指令缓存

1
2
3
4
; --- 清空 L1 I-Cache ---
MOV r1, #0
MCR p15, 0, r1, c7, c5, 0 ; ICIALLUIS: 对所有统一内部 Shareability 域的
; L1 I-Cache 执行全局清除

CP15 c7, c5, 0ICIALLUIS 操作——对指令缓存的全局清除。指令缓存没有”脏数据”的概念(指令不会被回写),所以只需要 invalidate 即可。

4.3 Set/Way 循环清空数据缓存

数据缓存可能含有脏数据(被修改但尚未写回内存),必须逐行处理。但如何遍历 Cache 的每一行?答案是通过 Set/Way 寻址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
; --- 清空 L1 D-Cache (Set/Way 循环) ---
; Step 1: 读取 Cache Size ID 寄存器,提取 Number of Sets
MRC p15, 1, r0, c0, c0, 0 ; 读取 CCSIDR (Cache Size ID Register)
LDR r3, #0x1ff
AND r0, r3, r0, LSR #13 ; r0 = (CCSIDR >> 13) & 0x1FF = Number of Sets - 1
MOV r1, #0 ; r1 = Way 计数器,从 Way 0 开始

way_loop: ; 外层循环:遍历 Ways
MOV r3, #0 ; r3 = Set 计数器,从 Set 0 开始

set_loop: ; 内层循环:遍历 Sets
MOV r2, r1, LSL #30 ; r2[31:30] = Way 编号
ORR r2, r3, LSL #5 ; r2[4+log2(line size):5] = Set 编号
MCR p15, 0, r2, c7, c6, 2 ; DCCISW: 按 Set/Way 清除并无效化该 Cache 行
ADD r3, r3, #1 ; Set++
CMP r0, r3 ; 是否已遍历所有 Set?
BGT set_loop ; 未完成 → 继续内层循环

ADD r1, r1, #1 ; Way++
CMP r1, #4 ; 是否已遍历所有 Way(4-way 组相联)?
BNE way_loop ; 未完成 → 继续外层循环

Set/Way 寻址格式解析

DCCISW(Data Cache Clean and Invalidate by Set/Way)操作的寄存器 R2 编码如下:

1
2
3
R2 [31:30] = Way     (组内行号,在 4-way 组相联 Cache 中取值 0-3)
R2 [4+log2(line size):5] = Set (组号)
R2 [1:0] = Level (00 = L1 Cache)

实例:计算一个具体 (Way=2, Set=128) 的 DCCISW 操作数

1
2
3
4
5
6
7
8
9
假设 L1 D-Cache = 4-way 组相联,每 Way 256 个 Set,Line Size = 8 words (32 bytes)
→ log2(line size) = 5 (32 = 2^5)

Way 2: 2 << 30 = 0x8000_0000 (Set Way=2 in R2[31:30])
Set 128: 128 << 5 = 0x0000_1000 (Set Set=128 in R2[9:5])
Level: 0 << 0 = 0x0000_0000 (L1 Data Cache = level 0)

最终 R2 = 0x8000_0000 | 0x0000_1000 = 0x8000_1000
→ MCR p15, 0, r2, c7, c6, 2 ; DCCISW: Clean+Invalidate Way=2, Set=128

这段代码是整章中最复杂的汇编序列。外层 way_loop 遍历 Cache 的每个 Way(本例假设 4-way 组相联),内层 set_loop 遍历每个 Way 中的所有 Set。对于每个 (Way, Set) 组合,DCCISW 操作对该 Cache 行执行 Clean + Invalidate:先将脏数据写回主存,再清除该行的 Valid 标记。

为什么不直接用全局清除指令? 因为 DCCISW 是 ARM 中唯一能保证对所有数据执行写回的正确方式。全局清除操作 DCIALL(c7 c6 0)虽然更简单,但在某些实现中可能遗漏某些状态的 Cache 行。

4.4 清空 TLB 和启用分支预测

1
2
3
4
5
6
7
8
; --- 清空 TLB ---
MCR p15, 0, r1, c8, c7, 0 ; TLBIALL: 清除所有 TLB 条目

; --- 启用分支预测 ---
MOV r1, #0
MRC p15, 0, r1, c1, c0, 0 ; 读取 SCTLR
ORR r1, r1, #(0x1 << 11) ; 设置 bit 11 (Z bit —— 分支预测使能)
MCR p15, 0, r1, c1, c0, 0 ; 写回 SCTLR

The branch target predictor hardware might not have to be explicitly invalidated, but it must be enabled by boot code. Branch prediction can safely be enabled at this point; this will improve performance.

分支预测器不需要显式清除(它在复位后自动处于干净状态),但必须在 boot code 中显式启用。ARM 将 SCTLR 中的 Z 位(bit 11)默认设为 0,即使硬件支持分支预测,不置位也不会工作。


5. 第四步:创建一级页表

清除完成后,处理器处于一个”干净但无用”的状态——没有 MMU 翻译,所有内存访问都是物理寻址。接下来要建立最简单的页表并启用 MMU。Example 13.4 给出了完整的页表初始化和 MMU 启用代码。

5.1 利用 D-side Prefetch 优化

1
2
3
4
5
6
; --- 启用 D-side Prefetch ---
MRC p15, 0, r1, c1, c0, 1 ; 读取 ACTLR (Auxiliary Control Register)
ORR r1, r1, #(0x1 << 2) ; 设置 D-side prefetch 使能位
MCR p15, 0, r1, c1, c0, 1 ; 写回 ACTLR
DSB ; 数据同步屏障:确保之前的 Cache 维护操作完成
ISB ; 指令同步屏障:冲刷流水线

D-side Prefetch(Data-side Prefetch)是一种硬件预取机制。当处理器检测到程序具有连续访问数据的趋势时,会提前将后续的 Cache Line 从内存加载到 Data Cache 中,从而减少 Cache Miss 带来的访问延迟。例如,在顺序遍历数组、执行 memcpymemset 等操作时,D-side Prefetch 可以提高内存访问效率。

DSB(Data Synchronization Barrier)和 ISB(Instruction Synchronization Barrier)是 Boot Code 中常见的两条屏障指令。它们的作用分别是:

  • DSB:等待之前发出的系统维护操作(如 Cache、TLB、Branch Predictor 的维护操作)全部完成,再继续执行后续指令。
  • ISB:刷新处理器流水线,使后续指令能够按照更新后的处理器配置重新取指执行。

在 Boot 阶段,修改处理器控制寄存器(例如 ACTLRSCTLR)或完成 Cache、TLB 等维护操作后,通常都会紧跟 DSBISB。这样可以确保前面的配置已经真正生效,并保证后续代码在新的处理器状态下运行,避免由于流水线或维护操作尚未完成而导致的不可预期行为。

5.2 构造 1MB Section 的 Flat Mapping 页表

1
2
3
4
5
6
7
8
9
10
11
12
; --- 初始化 L1 页表 ---
; 目标:建立 VA=PA 的 flat mapping,1MB sections,所有页面 Full Access
; 描述符模板:非地址部分
LDR r0, =2_00000000000000000000110111100010 ; PTE 默认属性
LDR r1, ttb_address ; r1 = 页表基地址(16KB 对齐)
LDR r3, =4095 ; 循环计数器(4096 个条目)

write_pte:
ORR r2, r0, r3, LSL #20 ; r2 = 描述符模板 | (section_index << 20)
STR r2, [r1, r3, LSL #2] ; 写入 PTE[section_index]
SUBS r3, r3, #1 ; 计数器递减
BNE write_pte ; 循环直到所有 4096 个条目写完

这 5 行汇编是 boot code 中最精巧的构造。逐行分解:

  1. **r0 是 PTE 描述符的”属性模板”**。十六进制 0x00000DE2(二进制 2_00000000000000000000110111100010)的分解为:

    • Bits [1:0] = 10 — Section 描述符类型标识
    • Bits [3:2] = 00 — C = 0, B = 0(与 TEX 联用表示内存类型)
    • Bit [4] = 1 — 实现定义位
    • Bits [8:5] = 1111 — Domain = 15
    • Bit [9] = 0 — SBZP(应为零或保留)
    • Bits [11:10] = 11 — AP[1:0] = 11
    • Bits [14:12] = 000 — TEX[2:0] = 000
    • Bits [19:12] = 00000000 — 配合 TEX、C、B 位,整体属性为 Strongly Ordered 内存类型
  2. ORR r2, r0, r3, LSL #20 将 section 的物理基地址嵌入描述符:

    • r3 循环计数器从 4095 递减到 0
    • 对于 1MB section,物理基地址位段为 PA[31:20] → 对应 r3 << 20
    • 例如当 r3 = 0r2 = 0x0000_0DE2,即 Section 0 映射 PA 0x0000_0000
    • 例如当 r3 = 1r2 = 0x0010_0DE2,即 Section 1 映射 PA 0x0010_0000
  3. STR r2, [r1, r3, LSL #2] 将描述符写入页表:

    • 基地址 r1 + r3 × 4(每个描述符 4 字节)→ 完整的 [基址 + 偏移] 寻址
    • 最终效果:ttb_address[0] 保存 Section 0 的描述符(映射 PA 0x00000000),ttb_address[4095] 保存 Section 4095 的描述符(映射 PA 0xFFF00000)。

完成循环后,整个 4GB 虚拟地址空间被 4096 个 1MB section 覆盖,每个 section 的 VA = PA(flat mapping),内存属性为 Strongly Ordered(最保守的缺省值)。

5.3 修正第一个 Section(0x0000_0000–0x000F_FFFF)的属性

将 0-1MB 区域(通常包含代码段)从 Strongly Ordered 改为 Cacheable:

1
2
3
4
5
6
7
; 为第一个 entry(section 0)设置 Cacheable 属性
BIC r0, r0, #2_1100 ; 清除 C 和 B bits
ORR r0, r0, #2_0100 ; 设置: inner write-back, write-allocate
BIC r0, r0, #2_111000000000000 ; 清除 TEX bits
ORR r0, r0, #2_101000000000000 ; 设置 TEX = 101 (write-back, write-allocate)
ORR r0, r0, #2_1000000000000000 ; 设置 S bit (Shareable, 多核系统需要)
STR r0, [r1] ; 写回 section 0 的描述符

两种内存属性并存的理由:

  • 代码段区域 → Write-back, Write-allocate, Cacheable:最大化指令取指和数据访问性能
  • 其余区域(包括外设 MMIO 区域)→ Strongly Ordered:外设寄存器访问不能乱序、不能缓存、不能合并

5.4 启用 MMU

1
2
3
4
5
6
7
8
9
10
11
12
13
14
; --- 设置 TTBR0 和 TTBCR ---
MOV r1, #0x0
MCR p15, 0, r1, c2, c0, 2 ; TTBCR = 0 (整个地址空间使用 TTBR0)
LDR r1, ttb_address
MCR p15, 0, r1, c2, c0, 0 ; TTBR0 = ttb_address (L1 页表基地址)

; --- 设置所有 Domain 为 Client ---
LDR r1, =0x55555555 ; 每个 Domain 2 bits → 01 = Client
MCR p15, 0, r1, c3, c0, 0 ; DACR (Domain Access Control Register)

; --- 启用 MMU ---
MRC p15, 0, r1, c1, c0, 0 ; 读取 SCTLR
ORR r1, r1, #0x1 ; 设置 bit 0 (M bit)
MCR p15, 0, r1, c1, c0, 0 ; 写回 SCTLR → MMU 正式启用!

MCR 写回 SCTLR 的那一瞬间是一个分水岭——在此之后,所有内存访问都要经过 MMU 翻译。因为使用的是 VA=PA 的 flat mapping,程序行为在此刻看起来没有变化,但从硬件层面,MMU 已经在工作了(TLB 开始在访问中填充)。

两个重要细节:

  • 页表基地址 ttb_address 必须是 16KB 对齐(L1 页表 = 4096 条目 × 4 字节 = 16KB)
  • DACR = 0x55555555:16 个 Domain 都被配置为 Client(01)——即 Domain 访问权限由页表描述符中的 AP bits 决定,Domain 本身不禁止访问

6. 第五步:多核识别与主核独占初始化

完成了所有单核通用初始化后,多核系统面临一个特殊问题——所有核心同时从同一个复位向量开始执行。如果每个核心都独立去做内存初始化、页表创建,就会发生灾难性的资源竞争。

Example 13.5 给出了标准的多核识别方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
; --- 读取核心 ID,仅让 Core 0 执行初始化 ---
MRC p15, 0, r1, c0, c0, 5 ; 读取 MPIDR (Multiprocessor Affinity Register)
AND r1, r1, #0x3 ; 提取 [1:0] —— CPU ID 位段
CMP r1, #0
BEQ initialize ; Core 0 → 执行初始化

wait_loop:
; 非 Core 0:进入 WFI 低功耗等待
; ... (WFI / WFE 指令)
; 在 SMP OS 启动后才被主核通过 IPI 唤醒

initialize:
; @ Core 0 的初始化代码继续...
; 此时只有 Core 0 在执行,因此可以安全完成系统级初始化(如建立共享页表、配置 MMU、初始化 Cache 等),不会与其他核心发生竞争。

核心机制分析:

  1. **MRC p15, 0, r1, c0, c0, 5**:读取 MPIDR(Multi-Processor ID Register)。ARMv7-A 规范中,MPIDR 的低 8 位 [7:0] 编码了 Affinity Level 0(即核心在 Cluster 内的编号)。AND r1, r1, #0x3 提取仅最低 2 位(在 4 核 Cluster 中够用,一般 SCLTR 最多 4 核)。

  2. 非主核进入 WFI 状态:WFI(Wait For Interrupt)让核心进入低功耗待机。主核后续会通过 IPI(Inter-Processor Interrupt)——即 SGIs(Software Generated Interrupts)via GIC——唤醒这些从核,并让它们跳到 SMP OS 的启动代码。

  3. Core 0 可以安全地创建供所有核心共用的 L1 页表——因为此时只有 Core 0 在运行。

The other cores might be woken after core 0 has created a simple set of L1 translation table entries, as these could be used by all cores in the system.

这段代码应该放在 boot 流程的最开始——“this code typically is run before any other initialization step”——因为你要在单核完成所有初始化后再并行化。


7. 从裸机到 Linux:Bootloader 与内核启动流程

裸机的硬件初始化完成后,控制权需要移交给操作系统。一个典型的 Linux 启动总流程:

1
2
3
4
5
6
上电 → ROM Boot Code → Bootloader (U-Boot) → Linux Kernel → User Space
│ │ │ │
│ │ │ └── start_kernel() → /sbin/init
│ │ └── 加载 zImage,设 ATAGs/DTB,跳转
│ └── 初始化 DRAM 控制器,拷贝自身到 RAM
└── 执行复位向量中的 Reset_Handler

2

Linux 启动路径分为五个阶段,每两个阶段之间发生一次控制权交接:

  1. ROM Boot(平台固件):完成最基础的 DRAM 和时钟初始化后,将控制权移交给 Bootloader。

  2. Bootloader(U-Boot):加载压缩后的内核镜像 zImage 到 RAM 并设定入口参数,然后直接 B 跳转到 arch/arm/boot/compressed/head.S

  3. 内核解压:在启用 MMU+Cache 的条件下完成 LZMA/gzip 解压,最后 Clean+Invalidate Caches 并关 MMU,再跳转到内核主体 arch/arm/kernel/head.S

  4. 架构特定初始化:CPU/机器类型检测、创建页表、配置 GIC 和定时器,最后调用 start_kernel() 进入架构无关世界。

  5. User Space:内核完成全部初始化后,创建 PID 1 进程 /sbin/init,系统正式进入用户态。

图中下半部分的物理内存布局展示了 Bootloader 为内核准备的参数区(ATAGs/DTB)、压缩镜像和解压后内核本体在 RAM 中的位置关系。

7.1 Bootloader (U-Boot) 的职责

Bootloader 必须完成五项任务:

  • 初始化内存系统和外设。
  • 将内核镜像加载到内存中的合适位置(必要时也加载初始 RAM 磁盘)。
  • 生成传递给内核的启动参数(包括机器类型)。
  • 为内核设置控制台(视频或串口)。
  • 进入内核。

内核镜像的加载位置:

内核代码是位置无关的,可以放在内存中任意位置。惯例是将其放在物理 RAM 基地址偏移 0x8000 处,这为放在偏移 0x100 处的参数块留出了空间(参数块用于页表等目的)。

典型的内存布局如下:

1
2
3
4
Physical RAM Base (例: 0x8000_0000)
├─ 0x8000_0000 + 0x100: ATAG / DTB 参数块
├─ 0x8000_0000 + 0x8000: zImage 内核镜像(压缩)
└─ 0x8000_0000 + 某地址: initrd / initramfs(初始 RAM 磁盘)

7.2 内核参数传递:ATAGs 与 Device Tree

两种参数传递机制:

ATAGs(ARM Tagged List):传统方式——在物理 RAM 中放置一个标签链表,R2 寄存器持有该链表的地址。

1
2
3
4
5
6
7
8
9
10
11
// ATAG 标签结构
struct tag {
u32 size; // 该 tag 的大小(以 32-bit words 计)
u32 tag; // tag 类型标识
// ... tag 特定的数据 ...
};

// 常见 ATAG 类型
ATAG_MEM // 物理内存映射信息
ATAG_INITRD2 // initrd 镜像位置
ATAG_RAMDISK // RAM 磁盘配置

每个 ATAG 都是一个变长结构体:size 表示本 tag 的总 word 数,tag 字段标识该 tag 的类型。Bootloader 在内存中构建一个以 ATAG_CORE 开头、ATAG_NONE 结尾的标签链表,R2 指向链表首元素。

例如,一个描述 128MB 内存 + 串口控制台的 ATAG 链布局如下:

1
2
3
4
0x8000_0100: ATAG_CORE    → [size=5, tag=0x54410001, ...]
ATAG_MEM → [size=4, tag=0x54410002, start=0x80000000, len=128MB]
ATAG_CMDLINE → [size=8, tag=0x54410009, cmdline="console=ttyAMA0 debug"]
ATAG_NONE → [size=0, tag=0x00000000] ← 链表终止符

内核启动后遍历此链表,提取物理内存布局、initrd 位置、内核命令行等信息。

**Flattened Device Tree (FDT)**:现代方式——一个描述硬件配置的树形数据结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// DTS 示例
/ {
model = "arm,versatilepb";
compatible = "arm,versatilepb";
#address-cells = <1>;
#size-cells = <1>;

memory {
name = "memory";
device_type = "memory";
reg = <0x0 0x08000000>; // 起始地址 0x0, 大小 128MB
};

chosen {
bootargs = "console=ttyAMA0 debug"; // 内核命令行参数
};
};

Device Tree 相较于 ATAGs 的优势很明显:ATAGs 只是一个扁平的标签列表,无法描述设备的层次结构;FDT 是树形的,天然适合描述 SoC 中相互关联的外设拓扑。这也是为什么从 ARM Linux 3.x 开始,FDT 逐渐取代了 ATAGs。

DTS 源文件经 DTC(Device Tree Compiler)编译后生成 DTB(Device Tree Blob)——这才是 Bootloader 加载到 RAM 中并通过 R2 传给内核的实际二进制格式。DTB 在内存中的布局包含四个区域:

1
2
3
4
5
6
7
8
9
10
11
+-------------------+
| FDT Header | u32 magic (=0xD00DFEED), totalsize, version, 各 block 偏移量等
+-------------------+
| Memory Reservation | 多组 (address, size) 描述保留内存区域,以全零项结束
+-------------------+
| Structure Block | FDT_BEGIN_NODE → 属性(值) → FDT_END_NODE → ... → FDT_END
| | 所有字符串以整数偏移引用,节点和属性以 token 分隔
+-------------------+
| Strings Block | 属性名("model", "compatible", "reg" 等)统一存放于此,
| | Structure 区通过偏移量引用,避免重复存储
+-------------------+

Header 中的 magic = 0xD00DFEED 标识这是一个有效 FDT。totalsize 给出整个 blob 的字节数,Bootloader 据此分配空间。树形逻辑(父子节点、属性键值)全部编码在 Structure Block 中,而属性名字符串集中存放在末尾的 Strings Block,通过整数偏移引用——这正是”Flattened”的含义:一棵树被展平为连续的二进制块,而非内存中的指针链表。

7.3 内核入口约定

Bootloader 跳转到内核前必须满足精确的硬件状态:内核执行必须在核心处于固定状态时开始。Bootloader 通过直接分支到内核镜像的第一条指令(arch/arm/boot/compressed/head.S 中的 start 标签)来调用内核镜像。MMU 和数据 Cache 必须关闭。核心必须处于 Supervisor 模式,CPSR 的 I 和 F 位必须置位(IRQ 和 FIQ 禁用)。R0 必须为 0,R1 为 MACH_TYPE 值,R2 为参数标签链表的地址。

入口约定汇总:

条件 原因
模式 SVC (Supervisor) 内核需要 PL1 特权级来操作 MMU 和异常向量表
MMU 关闭 内核自带页表,将在自身初始化中重新建立
D-Cache 关闭 避免 bootloader 残留的脏数据污染内核
I/F bits 置位 (IRQ/FIQ 禁用) 中断子系统尚未初始化
R0 0 架构保留
R1 MACH_TYPE ARM Linux 机器类型号(DTB 方式下可为 ~0)
R2 ATAGs/FDT 地址 参数块物理地址

7.4 内核解压

内核启动的第一步是解压自身。Bootloader 传入的参数被保存下来,接着启用 Cache 和 MMU。在调用 arch/arm/boot/compressed/misc.c 中的 decompress_kernel() 之前,会检查解压后的镜像是否会覆盖压缩镜像本身。解压完成后清理并作废 Cache,然后再次关闭 MMU,最后分支跳转到内核启动入口点 arch/arm/kernel/head.S

简化流程:

1
2
3
4
5
6
zImage (head.S) → 保存 R0/R1/R2
→ 启用 MMU + Caches(用于加速解压)
→ decompress_kernel() — 将内核主体解压到最终位置
→ Clean + Invalidate Caches(将解压后的内核代码写回主存)
→ 关闭 MMU(内核主体会重新开启)
→ B 跳转到 arch/arm/kernel/head.S (真正的内核入口)

注意解压阶段使用了自己的 MMU 映射和 Cache 策略——这是为了加速解压算法(LZMA/gzip),解压完成后必须将 Caches 写回并关闭,因为内核主体期望在 MMU 关闭的干净状态下启动。

7.5 平台特定初始化

跳转到内核入口 arch/arm/kernel/head.S 后,内核还完全不知道自己在什么硬件上运行。在调用 start_kernel() 之前,架构相关代码必须完成以下准备:

  1. __lookup_processor_type() — 读取 CP15 寄存器,检测当前运行在哪种 ARM 核心上,返回处理器类型编码
  2. __lookup_machine_type() — 遍历 .arch.info.init 段中的机器描述结构体,匹配 ATAGs 或 DTB 中的 MACH_TYPE
  3. 建立内核初始页表——为内核代码段创建 VA=PA flat mapping,然后启用 MMU,设置 CP15 控制寄存器
  4. 将 data segment 拷贝到 RAM——内核镜像中已初始化的全局变量(.data 段)需要从加载地址拷贝到运行地址
  5. 调用 start_kernel()——此后的代码不再依赖任何特定硬件架构

7.6 start_kernel()

start_kernel() 是内核中第一个架构无关的函数。从此处开始,所有后续初始化使用 Linux 统一的跨平台框架:

  1. local_irq_disable() / lock_kernel() — 禁止 IRQ 和 FIQ 中断
  2. 初始化 tick control、内存系统、架构特定子系统,处理 bootloader 传入的命令行参数
  3. 设置内核栈,初始化 Linux 调度器
  4. 划分内存区域并分配页面
  5. 建立中断和异常向量表及处理函数,配置 GIC
  6. 初始化系统定时器 → 启用 IRQ;额外的内存系统初始化;BogoMIPS 校准核心时钟速度
  7. 设置内核内部组件:文件系统、init 进程、内核线程 daemon
  8. 解锁内核(启用 FIQ),启动调度器
  9. do_basic_setup() — 驱动初始化、sysctl、工作队列、网络栈

Linux 虚拟内存视图如下:

3


8. 关键要点总结

  1. 复位后 R0-R14 不可预测——第一条指令必须来自异常向量表(0x000000000xFFFF0000)的复位向量槽位
  2. ROM→RAM 重映射要求初始化代码使用位置无关代码(PC 相对寻址),因为地址 0x00000000 的物理含义在重映射瞬间改变
  3. 栈指针必须显式初始化:通过 MSR 切换到每个异常模式,再用 MOV SP, Rx 为每个 banked SP 赋值;中断在初始化完成前保持禁用状态
  4. Cache 的 Set/Way 遍历是清空 L1 D-Cache 的工业标准方法——外层遍历 Ways(组相联度)、内层遍历 Sets,对每个 (Way, Set) 执行 DCCISW
  5. VM=PA flat mapping 页表用 4096 个 1MB section 描述符覆盖 4GB VA 空间——第一个 section 特殊处理为 Cacheable(代码段),其余保持 Strongly Ordered(覆盖 MMIO 外设区域)
  6. TTBR0 和 TTBCR 的设置 + DACR (Domain Client) + SCTLR M bit 置位 = MMU 正式生效——这四步必须按顺序执行
  7. MPIDR 读取是所有多核系统 boot code 的第一道指令:Core 0 执行初始化 → 其他核心进入 WFI → 被 IPI 唤醒后加入 SMP
  8. Linux 内核入口约定非常精确:SVC 模式、MMU 关闭、Caches 关闭、中断禁用、R0=0、R1=MACH_TYPE、R2=ATAGs/FDT 地址
  9. 内核解压阶段有自己独立的 MMU 映射(用于加速解压),解压完成后必须 Clean+Invalidate Caches 并关闭 MMU,再跳转到真正的内核入口
  10. ATAGs 和 FDT 是两代参数传递机制——ATAGs 扁平列表 → FDT 树形结构,后者能完整描述 SoC 的硬件拓扑