linux内核启动引导过程(一) - 汇编引导部分
目标
分析Linux内核启动引导过程。整体过程涉及内核引导的MMU初始化、中断初始化、内存管理初始化、printk打印初始化、设备驱动初始化相关内容。学习和交流。
本篇关注于汇编引导部分。
代码分析环境:
- arm64架构
- linux-5.10
分为以下几部分
- 总体
- Linux内核引导前置过程概述
- Linux内核引导全流程和汇编引导概述
- [流程1] 链接脚本vmlinux.lds.S入口
- [流程2] 页表映射(MMU)开启前过程
- [流程2.1] 保存启动参数:dtb表入口(preserve_boot_args)
- [流程2.2] EL2特权级设置(el2_setup)
- [流程2.3] 创建初始化页表 (__create_page_tables)
- [流程2.4] 主cpu初始化 (__cpu_setup)
- [流程2.5] 启用MMU(__enable_mmu)
- [流程3] 页表映射(MMU)开启后过程
- [流程3.1] 重新迁移内核镜像__relocate_kernel
- [流程3.2] 准备进入start_kernel引导__primary_switched
一 总体
Linux内核引导前置过程概述
Linux 引导是一个相当冗长的过程,本文所要描述的只是其中的内核引导部分,属于这个过程的后半段,在此前还有一段,即所谓固件引导部分。
基于x86的PC结构,该前置过程通常称为bios引导,即广为熟知的bios。现代模式通常称为UEFI bios(在游戏电脑中,主板厂家通常非常重视这一部分开发,呈现出来的设置界面较为绚丽)。
arm64嵌入下,不使用UEFI bios,但都基于一个固件。不同架构厂家,不同芯片厂家,通常有不同实现。
以rk3588为例,Linux内核引导的前置过程大体包括:
上电/复位
↓
1. Mask ROM (芯片内部固化代码)
↓
2. TPL (DDR初始化) → SPL (加载U-Boot本体)
↓
3. ATF/BL31 (ARM Trusted Firmware, EL3安全世界)
↓
4. OP-TEE (可选, 安全OS)
↓
5. U-Boot Proper (主引导加载程序, EL2或EL1)
↓
6. Linux Kernel (通过 booti/bootm 跳转)
由以上,Linux内核实际上是引导的最后一步。它的前置步骤还有很多,但最为熟悉的一部分是UBOOT(第5步),该部分类比于台式机bios。
本文只讨论最后一步,内核部分的引导;
Linux内核引导全流程概述
内核引导全流程,包括
- 汇编引导部分
- start_kernel引导.
从start_kernel开始,就进入了c程序引导过程。
- 二 [流程1] 汇编引导部分
[流程1.1] 链接脚本vmlinux.lds.S入口
内核引导起始于汇编,入口是链接脚本 @arch/arm64/kernel/vmlinux.lds.S
// ... ...
OUTPUT_ARCH(aarch64)
ENTRY(_text)
jiffies = jiffies_64;
// ... ...
SECTIONS
{
DISCARDS
/DISCARD/ : {
*(.interp .dynamic)
*(.dynsym .dynstr .hash .gnu.hash)
}
. = KIMAGE_VADDR;
.head.text : {
_text = .;
HEAD_TEXT
}
.text : { /* Real text segment */
_stext = .; /* Text and read-only data */
IRQENTRY_TEXT
SOFTIRQENTRY_TEXT
ENTRY_TEXT
TEXT_TEXT
SCHED_TEXT
CPUIDLE_TEXT
LOCK_TEXT
KPROBES_TEXT
HYPERVISOR_TEXT
IDMAP_TEXT
HIBERNATE_TEXT
TRAMP_TEXT
*(.fixup)
*(.gnu.warning)
. = ALIGN(16);
*(.got) /* Global offset table */
}
.got.plt : { *(.got.plt) }
. = ALIGN(SEGMENT_ALIGN);
_etext = .; /* End of text section */
/* everything from this point to __init_begin will be marked RO NX */
RO_DATA(PAGE_SIZE)
// ... ...
链接脚本的ENTRY入口指向_text符号,该符号位置是HEAD_TEXT,定义为
#define HEAD_TEXT KEEP(*(.head.text))
即指向.head.text段。该段在head.S。
实际入口是primary_entry @arch/arm64/kernel/head.S。从这里开始,进入正式的引导流程。
linux引导代码都属于头代码段 .head.text
[流程2] 页表映射(MMU)开启前过程
总体有以下流程
primary_entry @arch/arm64/kernel/head.S
└─ preserve_boot_args
└─ el2_setup
└─ set_cpu_boot_mode_flag
└─ __create_page_tables
└─ __cpu_setup
└─ __primary_switch
__primary_switch @arch/arm64/kernel/head.S
└─ __enable_mmu @同上
└─ __primary_switched
└─ └─ start_kernel @init/main.c
总体路径有
primary_entry → __primary_switch → __primary_switched → start_kernel
[流程2.1] 保存启动参数:dtb表入口(preserve_boot_args)
该过程是进入内核引导的第一个操作。
preserve_boot_args @arch/arm64/kernel/head.S
SYM_CODE_START_LOCAL(preserve_boot_args)
mov x21, x0 // x21=FDT
adr_l x0, boot_args // record the contents of
stp x21, x1, [x0] // x0 .. x3 at kernel entry
stp x2, x3, [x0, #16]
dmb sy // needed before dc ivac with
// MMU off
mov x1, #0x20 // 4 x 8 bytes
b __inval_dcache_area // tail call
SYM_CODE_END(preserve_boot_args)
该过程将uboot传入的设备树地址(由x0),存入boot_args变量。(因为x0是临时变量,在后续引导中会被使用)。同时传入的还有x1、x2、x3(保留,不实际用)。boot_args变量定义如下所示
@arch/arm64/kernel/setup.c
u64 __cacheline_aligned boot_args[4];
dmb sy用于数据保序,只有当boot_args变量存入后,才做后续的无效数据缓存的操作。
__inval_dcache_area宏函数传入的参数是x0: 待无效数据内存区的基地址和x1:长度。
__inval_dcache_area@arch/arm64/mm/cache.S定义如下所示
SYM_FUNC_START_PI(__inval_dcache_area)
/* FALLTHROUGH */
add x1, x1, x0
dcache_line_size x2, x3
sub x3, x2, #1
tst x1, x3 // end cache line aligned?
bic x1, x1, x3
b.eq 1f
dc civac, x1 // clean & invalidate D / U line
1: tst x0, x3 // start cache line aligned?
bic x0, x0, x3
b.eq 2f
dc civac, x0 // clean & invalidate D / U line
b 3f
2: dc ivac, x0 // invalidate D / U line
3: add x0, x0, x2
cmp x0, x1
b.lo 2b
dsb sy
ret
SYM_FUNC_END_PI(__inval_dcache_area)
__inval_dcache_area的主要逻辑是
- 检查数据区终止地址,和首地址是否是缓存行对齐
- 若不对齐,需要执行“清脏+无效”操作,到PoC缓存一致性点;
- 若对齐,只执行“无效”操作,到到PoC缓存一致性点。
采用缓存清理操作,且到PoC点的原因是:
- uboot阶段可能开启了dcache,使得boot_args区域会有缓存残留;是一种保护性的操作。万一,
- 多核共享使用数据结构区,所以必须清理到PoC点。
el2_setup @arch/arm64/kernel/head.S:
SYM_FUNC_END(el2_setup)
// ... ...
mov_q x0, INIT_SCTLR_EL1_MMU_OFF
msr sctlr_el1, x0
/* Coprocessor traps. */
mov x0, #0x33ff
msr cptr_el2, x0 // Disable copro. traps to EL2
/* SVE register access */
mrs x1, id_aa64pfr0_el1
ubfx x1, x1, #ID_AA64PFR0_SVE_SHIFT, #4
cbz x1, 7f
bic x0, x0, #CPTR_EL2_TZ // Also disable SVE traps
msr cptr_el2, x0 // Disable copro. traps to EL2
isb
mov x1, #ZCR_ELx_LEN_MASK // SVE: Enable full vector
msr_s SYS_ZCR_EL2, x1 // length for EL1.
/* Hypervisor stub */
7: adr_l x0, __hyp_stub_vectors
msr vbar_el2, x0
isb
mov x0, #INIT_PSTATE_EL1
msr spsr_el2, x0
msr elr_el2, lr
mov w0, #BOOT_CPU_MODE_EL2
eret
SYM_FUNC_END(el2_setup)
该过程关键在于以INIT_PSTATE_EL1重写spsr_el2寄存器, 里面包含了返回目标的特权级,然后eret中断返回后即进入EL1。
同时因为是从EL2降级,设置引导CPU模式为EL2(w0寄存器保存)。
[流程2.3] 创建初始化页表 (__create_page_tables)
设置初始内核页表的目的是为了启用MMU(现代处理器不可能在不开启MMU的情况下工作,因为cache缓存等都依赖于MMU)。
这里初始化的内核页表包括两张:
- 标识映射(identity mapping),实际为恒等映射。
- 内核代码段镜像映射。建立第一阶段的内核代码段映射,实际为临时映射。之所以称之为第一阶段,因为在后续引导过程中会进行第二阶段最终映射,本次的临时映射会被替代,最终页表就绪。
idmap是一个特殊映射的代码段区间, .idmap.text表示,该映射的意义是建立虚拟地址和物理地址1:1的映射,目的是使能完成MMU启用前后的地址转换。注意该段同时属于.text代码段。
__create_page_tables(@arch/arm64/kernel/head.S),关键代码路径如下
SYM_FUNC_START_LOCAL(__create_page_tables)
mov x28, lr
adrp x0, init_pg_dir
adrp x1, init_pg_end
sub x1, x1, x0
bl __inval_dcache_area
adrp x0, init_pg_dir
adrp x1, init_pg_end
sub x1, x1, x0
1: stp xzr, xzr, [x0], #16
stp xzr, xzr, [x0], #16
stp xzr, xzr, [x0], #16
stp xzr, xzr, [x0], #16
subs x1, x1, #64
b.ne 1b
mov x7, SWAPPER_MM_MMUFLAGS
// ... ...
mov x4, #1 << (PHYS_MASK_SHIFT - PGDIR_SHIFT)
str_l x4, idmap_ptrs_per_pgd, x5
1:
ldr_l x4, idmap_ptrs_per_pgd
mov x5, x3 // __pa(__idmap_text_start)
adr_l x6, __idmap_text_end // __pa(__idmap_text_end)
// --> 位置1
map_memory x0, x1, x3, x6, x7, x3, x4, x10, x11, x12, x13, x14
adrp x0, init_pg_dir
mov_q x5, KIMAGE_VADDR // compile time __va(_text)
add x5, x5, x23 // add KASLR displacement
mov x4, PTRS_PER_PGD
adrp x6, _end // runtime __pa(_end)
adrp x3, _text // runtime __pa(_text)
sub x6, x6, x3 // _end - _text
add x6, x6, x5 // runtime __va(_end)
// --> 位置2
map_memory x0, x1, x5, x6, x7, x3, x4, x10, x11, x12, x13, x14
dmb sy
// ...
以上主要操作为两次调用map_memory。map_memory是技巧性很强的汇编实现宏函数,用于一次调用完成针对一组目标虚拟地址的页表项添加。其接口简要说明如下。
.macro map_memory, 1:tbl, 2:rtbl, 3:vstart, 4:vend, 5:flags, 6:phys, 7:pgds, 8:istart, 9:iend, tmp, count, sv
主要参数
- 参数1和2: 第一阶段页目录表(init_pg_dir)地址(物理地址)
- 参数3和4: 待映射的虚拟地址的起始和结束
- 参数5: 页表标志;
- 参数6: 映射目标物理地址
在代码位置1的恒等映射,参数3:x3的赋值是通过adrp x3, __idmap_text_start,因此x3是__idmap_text_start的物理地址,同时目标物理地址也是x3。说明是1:1的映射。__idmap_text_start在链接脚本的定义如下
#define IDMAP_TEXT \
. = ALIGN(SZ_4K); \
__idmap_text_start = .; \
*(.idmap.text) \
__idmap_text_end = .;
.idmap.text 代码段的定义包括以下
- __enable_mmu
- __relocate_kernel
- __primary_switch
而在代码位置2, 参数3待映射虚拟地址起始直接指向KIMAGE_VADDR,该值是在镜像链接阶段就确认好的虚拟地址(通常为0xffff800010000000)。整个代码断的长度为链接符号_end - _text。
在对页表操作后,通过dmb sy做内存保序,保证本核的状态一致性。
在4级页表配置下,初始页表的级数为3级。以上map_memory宏,就是建立页表的底层操作。其具体实现较复杂。可参见相关代码。
[流程1.2.2] __cpu_setup
在__cpu_setup中主要完成在开启MMU之前的cpu的初始化操作。
__cpu_setup@arch/arm64/mm/proc.S
.pushsection ".idmap.text", "awx"
SYM_FUNC_START(__cpu_setup)
tlbi vmalle1 // Invalidate local TLB
dsb nsh
mov x1, #3 << 20
msr cpacr_el1, x1 // Enable FP/ASIMD
mov x1, #1 << 12 // Reset mdscr_el1 and disable
msr mdscr_el1, x1 // access to the DCC from EL0
isb // Unmask debug exceptions now,
enable_dbg // since this is per-cpu
reset_pmuserenr_el0 x1 // Disable PMU access from EL0
reset_amuserenr_el0 x1 // Disable AMU access from EL0
mov_q x5, MAIR_EL1_SET
// ... ...
msr mair_el1, x5
mov_q x10, TCR_TxSZ(VA_BITS) | TCR_CACHE_FLAGS | TCR_SMP_FLAGS | \
TCR_TG_FLAGS | TCR_KASLR_FLAGS | TCR_ASID16 | \
TCR_TBI0 | TCR_A1 | TCR_KASAN_FLAGS
ldr_l x9, idmap_t0sz
#endif
tcr_set_t0sz x10, x9
// ... ...
msr tcr_el1, x10
mov_q x0, INIT_SCTLR_EL1_MMU_ON
ret // return to head.S
__cpu_setup主要完成:
- 通过tlbi指令,清理可能残留的TLB缓存
- 通过cpacr_el1寄存器开启FP浮点
- 写入mair_el1寄存器初值
- 写入tcr_el1寄存器初值
注意,在开启MMU之前需要无效掉所有的TLB缓存,这是本函数第一时间做的工作。
[流程2.5] 启用MMU(__enable_mmu)
该操作是整个Linux内核汇编引导阶段的关键。
__enable_mmu @arch/arm64/kernel/head.S
adrp x1, init_pg_dir
bl __enable_mmu
SYM_FUNC_START(__enable_mmu)
mrs x2, ID_AA64MMFR0_EL1
ubfx x2, x2, #ID_AA64MMFR0_TGRAN_SHIFT, 4
cmp x2, #ID_AA64MMFR0_TGRAN_SUPPORTED
b.ne __no_granule_support
update_early_cpu_boot_status 0, x2, x3
adrp x2, idmap_pg_dir
phys_to_ttbr x1, x1
phys_to_ttbr x2, x2
// --> 位置1
msr ttbr0_el1, x2 // load TTBR0
offset_ttbr1 x1, x3
msr ttbr1_el1, x1 // load TTBR1
isb
msr sctlr_el1, x0
isb
// --> 位置2
ic iallu
dsb nsh
isb
ret
SYM_FUNC_END(__enable_mmu)
上述过程的主要逻辑是为idmap页表和第一阶段内核页表,分别启用页表转换。
- 对于idmap,取idmap_pg_dir物理地址存入x2。为idmap段是1:1映射,虚拟地址等同于物理地址,高位为0,因此写入ttbr0_el1寄存器(该寄存器通常被认为是一个用户态寄存器,但实际是只要虚拟地址高位为0硬件自动利用该寄存器);
- 对于内核页表,取init_pg_dir物理地址存入x1。因为内核代码段虚拟地址在镜像构建时预先设定为KIMAGE_VADDR(例如0xffff800010000000),高位为1,因此写入ttbr1_el1寄存器。
上述过程完成后,执行指令刷新和清高速缓存操作。在ret之后,即__enable_mmu函数完成之后,MMU页表转换正式启用。
注意代码位置2的作用是,在开启MMU页映射管理后的第一时间,无效指令缓存到PoU点,实际是L1指令缓存。因为初始状态指令缓存应清空。同时,因为指令是本地局部性的,无效到PoU点即可。
至此。MMU页表映射已开启。因为该区域已经被idmap代码段覆盖,提供恒等映射,因此ret可以直接跳转。之后,虚拟地址空间启用。
在此小结一下,开启MMU操作之前的必要cache操作:
- preserve_boot_args的对boot_args数据区缓存无效到PoC。
- __cpu_setup中清理本地TLB缓存。
- __enable_mmu之后第一时间对指令缓存无效到PoU。
[流程3] 页表映射(MMU)开启后过程
在未配置 CONFIG_RANDOMIZE_BASE下,主要包含以下流程
__primary_switch [arch/arm64/kernel/head.S]
└─ __relocate_kernel
└─ __primary_switched
└─ start_kernel [init/main.c]
[流程3.1] 重新迁移内核镜像__relocate_kernel
__relocate_kernel该过程应是在start_kernel之前的一个重要过程。
(但是暂未理解,后续待学习研究)
[流程3.2] 准备进入start_kernel引导__primary_switched
该过程是汇编引导的最后一个部分。
__primary_switched @arch/arm64/kernel/head.S。以下是其主要局部代码
SYM_FUNC_START_LOCAL(__primary_switched)
// 位置1
adrp x4, init_thread_union
add sp, x4, #THREAD_SIZE
adr_l x5, init_task
msr sp_el0, x5 // Save thread_info
// 位置2
adr_l x8, vectors // load VBAR_EL1 with virtual
msr vbar_el1, x8 // vector table address
isb
stp xzr, x30, [sp, #-16]!
mov x29, sp
str_l x21, __fdt_pointer, x5 // Save FDT pointer
// 位置3
ldr_l x4, kimage_vaddr // Save the offset between
sub x4, x4, x0 // the kernel virtual and
str_l x4, kimage_voffset, x5 // physical mappings
// Clear BSS
// 位置4:
adr_l x0, __bss_start
mov x1, xzr
adr_l x2, __bss_stop
sub x2, x2, x0
bl __pi_memset
dsb ishst // Make zero page visible to PTW
// 位置5:
add sp, sp, #16
mov x29, #0
mov x30, #0
b start_kernel
SYM_FUNC_END(__primary_switched)
先明确一点,进入__primary_switched函数,pc指针已经切换到内核虚拟地址了,不再是idmap恒等映射。
上述包括几个工作:
- 位置1: 设置内核栈和首进程的内核栈指针。
- 内核栈基地址init_thread_union + 栈大小THREAD_SIZE,即为栈顶。写入sp寄存器(sp_el1);
- init_task的内核栈指针,即thread_info的基地址写入sp_el0寄存器。
- 位置2: 设置中断向量表vbar_el1到实际地址(虚拟地址)。这一步为后续中断控制器GIC初始化做准备。
- 位置3: 这一步主要是计算写入kimage_voffset变量,该变量表示内核镜像虚拟地址和物理地址的固定偏移,用于提供内核地址的非线性转换(__kimg_to_phys)。
- 位置4: 清理bss段。bss段是未初始化变量的段,由内核在引导时,统一清零。
- 位置5: 恢复内核sp指针到栈顶,进入start_kernel流程。
至此,汇编引导部分全部完成。

浙公网安备 33010602011771号