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内核引导全流程概述

内核引导全流程,包括

  1. 汇编引导部分
  2. 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点。
## \[流程2.2] EL2特权级设置(el2_setup) el2_setup主要完成判断内核是否开启VHE模式。若开启,内核应引导至EL2,如板卡uboot默认引导就是EL2,不需要降级。否则执行特权级降级。 从EL2降级至EL1的局部代码

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)。
这里初始化的内核页表包括两张:

  1. 标识映射(identity mapping),实际为恒等映射。
  2. 内核代码段镜像映射。建立第一阶段的内核代码段映射,实际为临时映射。之所以称之为第一阶段,因为在后续引导过程中会进行第二阶段最终映射,本次的临时映射会被替代,最终页表就绪。

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操作:

  1. preserve_boot_args的对boot_args数据区缓存无效到PoC。
  2. __cpu_setup中清理本地TLB缓存。
  3. __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流程。

至此,汇编引导部分全部完成。

posted @ 2026-09-02 23:44  happysc  阅读(13)  评论(0)    收藏  举报