Linux 内核启动分析-BugMan-ChinaUnix博客
通过《Linux应用程序elf描述》,我们了解到一个应用程序编译后,最终会按照指定方式进行链接,而我们通过ld --verbose可以查看对应应用的默认链接方式。那么对于Linux内核呢?毫无疑问, Linux内核也是按照指定格式进行链接的,只是Linux的链接方式是由arch/arm64/kernel/vmlinux.lds.S指定的(gcc可以在链接的时候指定自定义链接脚本-T)。本章基于Linux内核Linux-4.19.73来作为例子说明的。
首先,我们看看vxlinux.lds.S为何物,如下图:
上图是一个删减了很多其他暂时不关心段的vmlinux.ld.s脚本文件,vmlinux.ld.s脚本文件的语法,这里不作出介绍, 关注的可以去查看相关文档。通过vmlinux.ld.s我们可以看到,bin文件的入口被设置为ENTRY(_text), 因此,_text即为入口函数地址,那么_text又在那里呢?那么接着看这个脚本文件,我们发现在灰色框里面有一个_text = .; 这说明_text的地址就是.head.text的首地址。那么哪些数据被链接到.head.text段了呢?通过搜索发现
点击(此处)折叠或打开
- #define HEAD_TEXT KEEP(*(.head.text))
- #define __HEAD .section ".head.text","ax"
- $ grep __HEAD arch/arm64/ -r
- include/linux/init.h:#define __HEAD .section ".head.text","ax"
- $grep __HEAD arch/arm64/ -r
- arch/arm64/kernel/head.S: __HEAD
因此, 目前只有kernel/head.S有代码被放置在了.head.text段,我们进一步看看arch/arm64/kernel/head.S文件,如下:
点击(此处)折叠或打开
- #define __PHYS_OFFSET (KERNEL_START - TEXT_OFFSET) // 内核物理地址起始位置
- __HEAD
- _head:
- b stext // branch to kernel start, magic
- .long 0 // reserved
- le64sym _kernel_offset_le // Image load offset from start of RAM, little-endian
- le64sym _kernel_size_le // Effective size of kernel image, little-endian
- le64sym _kernel_flags_le // Informative flags, little-endian
- .quad 0 // reserved
- .quad 0 // reserved
- .quad 0 // reserved
- .ascii "ARM\x64" // Magic number
- .long 0 // reserved
- __INIT
- ENTRY(stext)
- bl preserve_boot_args
- bl el2_setup // Drop to EL1, w0=cpu_boot_mode
- adrp x23, __PHYS_OFFSET // 物理地址偏移
- and x23, x23, MIN_KIMG_ALIGN - 1 // KASLR offset, defaults to 0,一种内核安全机制,通过物理地址起始位置计算出偏移大小,偏移大小保存在X23寄存器
- bl set_cpu_boot_mode_flag
- bl __create_page_tables
- bl __cpu_setup // initialise processor
- b __primary_switch
- ENDPROC(stext)
- $ grep __INIT include/ -r
- include/linux/init.h:#define __INIT .section ".init.text","ax"
- $
从上面代码可以看出,只有_head被放在了.head.text段,而下面的stext是放在.init.text段的。因此,当前版本的Linux kernel的入口函数就是_head函数, 而_head函数就只有一条跳转指令:b stext;因此内核启动后, 最终去stext函数运行。而stext主要调用了几个函数,他们的作用如下:
1、preserve_boot_args: 将uboot传入的参数 保存到bootargs[4] 全局变量里面。
2、el2_setup :判断启动的模式是el2还是el1并进行相关级别的系统配置(armv8中el2是hypervisor模式,el1是标准的内核模式,具体的参考手册), 然后返回启动模式
3、set_cpu_boot_mode_flag: 将启动模式保存到全局变量
4、__create_page_tables: 创建内存映射表,一共两张,一张存放在swapper_pg_dir(线性映射),一张存放在idmap_pg_dir(一对一映射)。
5、__cpu_setup : 初始化处理器相关的代码,配置访问权限,内存地址划分等。
6、__primary_switch :开启MMU, 准备0号进程和内核栈,然后跳转到start_kernel运行
首先,我们说说preserve_boot_args函数, 它的实现如下:
点击(此处)折叠或打开
- preserve_boot_args:
- mov x21, x0 // 默认x0是uboot传入的第一个参数,通常是fdt的基地址,这里给x21寄存器保存
- adr_l x0, boot_args //adr指令读取boot_args变量的当前地址,而不是链接地址(因为此时还没没有创建映射表,链接地址占时还不能用),boot_args是一个全局变量,默认地址是链接地址。
- stp x21, x1, [x0] // 将uboot传入的第一个参数和第二个参数保存到boot_args的[0],[1]里面,表示地址和大小
- stp x2, x3, [x0, #16] // 将uboot传入的第三个核第四个参数保存到boot_args的[2],[3]变量里面
- dmb sy // 数据存储器栅栏,具体作用参考汇编手册
- mov x1, #0x20 // boot_args有四个变量,每个变量8字节大小,因此 x1存入boot_args的长度(x0是boot_args的地址),然后调用_inval_dcache_area无效这段地址的缓存
- b __inval_dcache_area // 无效x0和x1指定区域的缓存
- ENDPROC(preserve_boot_args)
其次是el2_setup 函数, 它的实现如下:
点击(此处)折叠或打开
- ENTRY(el2_setup)
- msr SPsel, #1 // 设置SP的使用方式,是各用各的 还是共用一个,这里设置的是各用各的(armv8的栈使用)
- mrs x0, CurrentEL // 读取当前的EL模式
- cmp x0, #CurrentEL_EL2 // 判断当前的模式是不是el2,是 就跳转到el2的处理代码
- b.eq 1f
- mov_q x0, (SCTLR_EL1_RES1 | ENDIAN_SET_EL1) // 配置el1模式
- msr sctlr_el1, x0
- mov w0, #BOOT_CPU_MODE_EL1 // 返回值设置成 el1模式启动,注:w0是32位寄存器,通常x0/w0作为函数返回值使用
- isb
- ret
- // 下面是el2,即hypervisor模式的处理代码,这里不介绍,在hypervisor会有介绍
- 1: mov_q x0, (SCTLR_EL2_RES1 | ENDIAN_SET_EL2)
- msr sctlr_el2, x0
- .............
- eret
- ENDPROC(el2_setup)
然后set_cpu_boot_mode_flag函数用于保存启动模式,该函数实现如下:
点击(此处)折叠或打开
- set_cpu_boot_mode_flag:
- adr_l x1, __boot_cpu_mode //将_boot_cpu_mode的物理地址读取到x1寄存器
- cmp w0, #BOOT_CPU_MODE_EL2 // w0是el2_setup返回的值,即模式
- b.ne 1f
- add x1, x1, #4
- 1: str w0, [x1] // This CPU has booted in EL1,将模式保存到_boot_cpu_mode变量
- dmb sy
- dc ivac, x1 // Invalidate potentially stale cache line
- ret
- ENDPROC(set_cpu_boot_mode_flag)
对于__create_page_tables,则主要是创建内存映射表(这里只是简单的映射,只把内核代码段映射进来,用于开启MMU),后期还会做出二次映射。在映射函数中,有两种映射,一个是直接映射(即va=pa, 用于处理开启mmu那一瞬间不会出现异常),一个是线性映射(va = pa + offset)。具体函数如下:
点击(此处)折叠或打开
- __create_page_tables:
- mov x28, lr
- // 无效 idmap_pg_dir和swpper_pg_end直接的数据缓存
- adrp x0, idmap_pg_dir
- adrp x1, swapper_pg_end
- sub x1, x1, x0
- bl __inval_dcache_area
- // 清楚idmap和swapper映射表里的脏数据
- adrp x0, idmap_pg_dir
- adrp x1, swapper_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
- // mmu也属性标记
- mov x7, SWAPPER_MM_MMUFLAGS
- //创建直接映射 idmap,从idmap_text_start到idmap_text_end
- adrp x0, idmap_pg_dir
- adrp x3, __idmap_text_start // __pa(__idmap_text_start)
- adrp x5, __idmap_text_end
- clz x5, x5
- cmp x5, TCR_T0SZ(VA_BITS) // default T0SZ small enough?
- b.ge 1f // .. then skip VA range extension
- adr_l x6, idmap_t0sz
- str x5, [x6]
- dmb sy
- dc ivac, x6 // Invalidate potentially stale cache line
- mov x4, #1 << (PHYS_MASK_SHIFT - PGDIR_SHIFT)
- // VA_BITS = 48bit
- str_l x4, idmap_ptrs_per_pgd, x5
- ldr_l x4, idmap_ptrs_per_pgd
- mov x5, x3 // __pa(__idmap_text_start)
- adr_l x6, __idmap_text_end // __pa(__idmap_text_end)
- // map_memory用于映射, 具体怎么写映射表, 参考armv8体系结构
- map_memory x0, x1, x3, x6, x7, x3, x4, x10, x11, x12, x13, x14
- // 线性映射内核代码段, 存放到swapper_pg_dir, 从_text段开始到_end之间的数据
- adrp x0, swapper_pg_dir
- mov_q x5, KIMAGE_VADDR + TEXT_OFFSET // 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)
- // map_memory用于映射, 具体怎么写映射表, 参考armv8体系结构
- map_memory x0, x1, x5, x6, x7, x3, x4, x10, x11, x12, x13, x14
- // 无效映射表对应的缓存
- adrp x0, idmap_pg_dir
- adrp x1, swapper_pg_end
- sub x1, x1, x0
- dmb sy
- bl __inval_dcache_area
- ret x28
- ENDPROC(__create_page_tables)
注:__idmap_text_start到__idmap_text_end的数据,其实就是启用mmu前后,需调用的那几个函数(因为CPU有加速指令处理的关系, 有些指令是乱序执行,防止开启mmu后,因为地址空间切换,导致的代码混乱的问题),因为有一段是va=pa因此, 之后即使还有code在用老的物理地址,也是不会出问题的。
__cpu_setup主要设置一些访问属性和内存划分等 ,具体函数如下:
点击(此处)折叠或打开
- ENTRY(__cpu_setup)
- tlbi vmalle1 // 无效TLB
- dsb nsh
- mov x0, #3 << 20
- msr cpacr_el1, x0 // 使能FP/ASIMD
- mov x0, #1 << 12
- msr mdscr_el1, x0 // 允许EL0访问DCC
- isb
- reset_pmuserenr_el0 x0 // 设置EL0禁止PMU访问
- /*
- * LPAE内存属性:
- *
- * n = AttrIndx[2:0]
- * n MAIR
- * DEVICE_nGnRnE 000 00000000
- * DEVICE_nGnRE 001 00000100
- * DEVICE_GRE 010 00001100
- * NORMAL_NC 011 01000100
- * NORMAL 100 11111111
- * NORMAL_WT 101 10111011
- */
- ldr x5, =MAIR(0x00, MT_DEVICE_nGnRnE) | \
- MAIR(0x04, MT_DEVICE_nGnRE) | \
- MAIR(0x0c, MT_DEVICE_GRE) | \
- MAIR(0x44, MT_NORMAL_NC) | \
- MAIR(0xff, MT_NORMAL) | \
- MAIR(0xbb, MT_NORMAL_WT)
- msr mair_el1, x5
- mov_q x0, SCTLR_EL1_SET
- /*
- * 设置 TCR and TTBR. 用户和内核采用512GB (39-bit) 地址
- */
- ldr x10, =TCR_TxSZ(VA_BITS) | TCR_CACHE_FLAGS | TCR_SMP_FLAGS | \
- TCR_TG_FLAGS | TCR_KASLR_FLAGS | TCR_ASID16 | \
- TCR_TBI0 | TCR_A1
- tcr_set_idmap_t0sz x10, x9
- /*
- * Set the IPS bits in TCR_EL1.
- */
- tcr_compute_pa_size x10, #TCR_IPS_SHIFT, x5, x6
- msr tcr_el1, x10
- ret
- ENDPROC(__cpu_setup)
最后__primary_switch准备好0号进程栈,然后切换到start_kernel运行,具体代码实现如下:
点击(此处)折叠或打开
- __primary_switch:
- #ifdef CONFIG_RANDOMIZE_BASE
- mov x19, x0 // preserve new SCTLR_EL1 value
- mrs x20, sctlr_el1 // preserve old SCTLR_EL1 value
- #endif
- bl __enable_mmu // 开启mmu ,就是只是配置一些MMU寄存器
- #ifdef CONFIG_RELOCATABLE
- ...... // 这里省略掉 内核代码重定位代码,这个主要用于gdb调试驱动
- #endif
- ldr x8, =__primary_switched // 将内核的物理地址起始地址作为参数1,调用_primary_switched函数
- adrp x0, __PHYS_OFFSET
- br x8
- ENDPROC(__primary_switch)
- union thread_union {
- unsigned long stack[THREAD_SIZE/sizeof(long)];
- } init_thread_union;
- __primary_switched:
- adrp x4, init_thread_union // 读取0号进程的thread_union地址
- add sp, x4, #THREAD_SIZE // 将init_thread_union +THREAD_SIZE作为内核线程的栈顶地址
- adr_l x5, init_task // 读取0号进程的task_struct结构
- msr sp_el0, x5 // 在内核空间中,将当前task_sturct给sp_el0保存
- adr_l x8, vectors // 设置中断向量表,vector在中断章节说明
- msr vbar_el1, x8 // 系统寄存器vector table address
- isb
- str_l x21, __fdt_pointer, x5 // X21存放的是fdt指针, 这里将fdt保存到__fdt_pointer
- // 下面是保存虚拟地址和物理地址之差到kimg_voffset变量
- ldr_l x4, kimage_vaddr // 获取到内核虚拟起始地址
- sub x4, x4, x0 // x0是传参传入的 内核物理起始地址
- str_l x4, kimage_voffset, x5
- // 将内核BSS段 请0
- 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
- #ifdef CONFIG_KASAN
- bl kasan_early_init // 一种内存调试手段初始化
- #endif
- mov x30, #0 // x30是Lr寄存器, 这里赋值成NULL,不需要返回,返回即异常
- b start_kernel // 跳转到start_kernel运行
- ENDPROC(__primary_switched)
最后Linux内核进入C代码空间,start_kernel。
注: PAGE_OFFSET是内核虚拟地址起始地址, PAGE_SHIFT是页大小位数, TEXT_OFFSET是内核代码起始位置到内核起始地址的偏移。
注:在32位CPU中, 内核通常会保留开始的32k(0x8000)地址,前16k(0x4000)保存bootargs参数,后16k用于保存pgd,因此可以看到内核的代码地址基本都是0x8000开始,如0xC0008000.
注:vectors向量表位于:"arch/arm64/kernel/entry.S"文件中,实现如下:
点击(此处)折叠或打开
- ENTRY(vectors)
- kernel_ventry 1, sync_invalid // Synchronous EL1t
- kernel_ventry 1, irq_invalid // IRQ EL1t
- kernel_ventry 1, fiq_invalid // FIQ EL1t
- kernel_ventry 1, error_invalid // Error EL1t
- kernel_ventry 1, sync // Synchronous EL1h
- kernel_ventry 1, irq // IRQ EL1h
- kernel_ventry 1, fiq_invalid // FIQ EL1h
- kernel_ventry 1, error // Error EL1h
- kernel_ventry 0, sync // Synchronous 64-bit EL0
- kernel_ventry 0, irq // IRQ 64-bit EL0
- kernel_ventry 0, fiq_invalid // FIQ 64-bit EL0
- kernel_ventry 0, error // Error 64-bit EL0
- #ifdef CONFIG_COMPAT
- kernel_ventry 0, sync_compat, 32 // Synchronous 32-bit EL0
- kernel_ventry 0, irq_compat, 32 // IRQ 32-bit EL0
- kernel_ventry 0, fiq_invalid_compat, 32 // FIQ 32-bit EL0
- kernel_ventry 0, error_compat, 32 // Error 32-bit EL0
- #else
- kernel_ventry 0, sync_invalid, 32 // Synchronous 32-bit EL0
- kernel_ventry 0, irq_invalid, 32 // IRQ 32-bit EL0
- kernel_ventry 0, fiq_invalid, 32 // FIQ 32-bit EL0
- kernel_ventry 0, error_invalid, 32 // Error 32-bit EL0
- #endif
- END(vectors)
注:armv8中,每个异常的 向量地址不再是4字节,而是0x80字节,可以放更多的代码在向量表里面。