1. 项目概述从汇编到内核的惊险一跃如果你曾经好奇过当你按下电源键一块搭载了ARM64芯片的开发板或服务器是如何从一片死寂的物理内存中一步步唤醒最终运行起我们熟悉的Linux操作系统的那么stext段就是你探索这个神秘旅程的绝佳起点。这不仅仅是内核启动的第一行代码更是一段在极度受限的“裸机”环境下为C语言世界铺平道路的精密汇编舞蹈。今天我们就以Linux 4.14内核中arch/arm64/kernel/head.S这个文件为蓝本深入剖析stext段的每一处细节。理解它你不仅能明白内核如何“站起来”更能洞悉计算机系统从硬件加电到软件统治的底层逻辑这对于从事嵌入式开发、系统底层优化乃至安全研究都至关重要。简单来说stext段是内核镜像中一段纯汇编代码它的使命是在start_kernel这个第一个C函数被调用之前完成所有必要的、只能用汇编语言处理的硬件初始化工作。你可以把它想象成一场盛大音乐会开始前舞台经理在黑暗中所做的一切检查电源、架设灯光、调试音响、为乐手摆放好乐谱架和座椅。stext就是这位舞台经理它确保当指挥start_kernel举起指挥棒时整个系统硬件已经处于一个已知、可控且准备好的状态。2. 核心思路与启动阶段全景解析在深入代码之前我们必须建立对ARM64 Linux启动流程的宏观认知。这个过程是分层递进的每一层都为下一层准备好运行环境。2.1 启动链条从Bootloader到内核一个完整的启动序列通常如下ROM Code / BL1芯片上电后首先执行固化在ROM中的代码初始化最基础的硬件如CPU、时钟并加载下一阶段引导程序如U-Boot的SPL。Bootloader (如 U-Boot)这是我们在嵌入式开发中最常打交道的部分。它负责初始化更复杂的硬件如DDR内存、存储设备将压缩或未压缩的内核镜像Image、设备树文件DTB以及可选的初始RAM磁盘initramfs加载到内存的指定地址然后准备好必要的启动参数最终跳转到内核入口点——也就是stext的起始地址。内核stext段 (汇编阶段)这是本文的核心。它接收一个“半生不熟”的环境CPU可能处于非预期的异常级别内存管理单元MMU关闭意味着使用的是物理地址没有栈C语言运行环境为零。它的任务就是扭转这一切。内核start_kernel(C语言阶段)当stext完成其使命后便跳转到start_kernel。从这里开始内核进入熟悉的C语言世界进行全面的子系统初始化如调度器、内存管理、中断系统等最终挂载根文件系统启动第一个用户空间进程init。stext的核心挑战在于它必须在“不知道自己在哪里运行”虚拟地址未启用的情况下为“知道自己在哪里运行”做好准备。这涉及到异常级别切换、页表构建、MMU开启等一系列精巧操作。2.2 ARMv8异常级别EL与启动模式选择ARMv8架构定义了四个异常级别Exception Level, EL类似于CPU的“特权模式环”EL0: 用户态User用于运行普通应用程序。EL1: 内核态OS KernelLinux内核通常运行于此级别。EL2: 虚拟机监控程序Hypervisor为虚拟化提供支持。EL3: 安全监控程序Secure Monitor负责安全世界与非安全世界的切换。Bootloader如U-Boot可以将内核加载到EL2或EL1启动。el2_setup这个函数就是用来处理这个差异的。它的逻辑非常关键如果当前在EL3需要进行一些安全世界相关的设置然后通常会降级到EL2或EL1。如果当前在EL2这是一个常见场景特别是支持虚拟化的平台。内核会利用EL2阶段做一些只有Hypervisor权限才能做的初始化例如初始化虚拟化相关的系统寄存器VTCR_EL2,VTTBR_EL2等。完成这些后内核会主动执行一条ERET指令异常返回Exception Return到EL1。这就是原文中提到的“退回EL1”。在EL1启动内核是标准做法因为EL2的资源如某些寄存器是留给Hypervisor使用的内核自身运行在EL1。如果当前在EL1那最简单直接跳过EL2的初始化部分。注意ERET指令是异常级别切换的关键。它不仅仅是一个跳转还会从ELR_ELx异常链接寄存器恢复程序计数器PC并从SPSR_ELx保存的程序状态寄存器恢复处理器状态包括异常级别。el2_setup中会精心设置这两个寄存器以确保能准确地“降级”到EL1。3.head.S关键函数逐行精解现在让我们打开linux-4.14/arch/arm64/kernel/head.S沿着代码执行流看看这位“舞台经理”具体做了什么。3.1preserve_boot_args: 参数的接力与保存这是stext中第一个重要的子函数。preserve_boot_args: mov x21, x0 // 将FDT的物理地址存入x21保存 adr_l x0, boot_args // 将boot_args数组的物理地址加载到x0 stp x21, x1, [x0] // 保存x21(FDT地址), x1到[x0]和[x0#8] stp x2, x3, [x0, #16] // 保存x2, x3到[x0#16]和[x0#24] ret作用保存Bootloader通过寄存器传递过来的参数。根据ARM64的调用规范AAPCS64Bootloader会通过x0-x3寄存器传递最多4个参数给内核。参数解析x0: 通常存放设备树二进制文件Flattened Device Tree, FDT在内存中的物理地址。这是内核了解硬件配置的核心数据结构。x1-x3: 历史上用于传递其他信息如ATAGS系统类型、起始地址等在现代基于设备树的系统中这些通常为0但协议上仍保留。操作细节mov x21, x0第一时间将FDT地址从x0转移到x21寄存器暂存。因为后续x0会被用作通用寄存器这个操作防止了关键参数的丢失。adr_l x0, boot_argsadr_l是一个宏用于获取符号boot_args的地址。这个符号在.bss段定义了一个u64数组。注意此时MMU关闭这个地址是物理地址。stp指令将寄存器对存储到内存。这里将x21(FDT),x1,x2,x3依次存放到boot_args数组开始的连续内存中。为什么这么做这些参数是内核后续初始化特别是解析设备树所必需的。将它们保存到内核数据段boot_args的一个固定位置使得C代码在启动后期可以方便地通过extern u64 boot_args[];来访问而不需要依赖易失的寄存器。3.2el2_setup: 异常级别的探戈此函数较长逻辑分支多我们提炼其核心伪代码逻辑// 伪代码示意逻辑 void el2_setup() { uint64_t current_el get_current_el(); // 读取CurrentEL寄存器 if (current_el EL3) { // 配置SCR_EL3等安全相关寄存器 // 设置返回地址和状态准备降级到EL2或EL1 return_to_lower_el(EL2_OR_EL1); } if (current_el EL2) { // 1. 配置Hypervisor配置寄存器(HCR_EL2) // 禁用EL1的某些 trapping为EL1运行内核做准备。 // 2. 配置虚拟化相关寄存器(VTCR_EL2, VTTBR_EL2) // 即使内核自身不用虚拟化也需要将其设置为一个安全、确定的状态。 // 3. 配置定时器、中断路由(CNTHCTL_EL2, CNTVOFF_EL2) // 确保虚拟计时器对EL1可用且中断能正确路由。 // 4. 设置返回地址(ELR_EL2)为标签1:处的地址即函数返回后的下一条指令。 // 5. 设置返回状态(SPSR_EL2)明确指定返回到EL1并启用中断(DAIF掩码)。 // 6. 执行 ERET // 关键从这里开始CPU异常级别从EL2切换到EL1。 } // 标签 1: 无论是从EL2返回还是原本就在EL1CPU最终都会执行到这里且处于EL1。 // 设置一些EL1下的基础系统控制寄存器(SCTLR_EL1, CPACR_EL1等)。 // 返回。 }核心动作——ERET这是从EL2降级到EL1的魔法指令。在执行ERET前代码必须正确设置ELR_EL2指向EL1的代码地址和SPSR_EL2描述EL1的处理器状态。ERET执行后CPU从ELR_EL2取指并将SPSR_EL2写入PSTATE从而完成级别切换。3.3set_cpu_boot_mode_flag: 记录启动的“出身”这个函数相对简单。set_cpu_boot_mode_flag: adr_l x1, __boot_cpu_mode // 加载__boot_cpu_mode的地址 cmp x0, #BOOT_CPU_MODE_EL2 // 比较x0来自el2_setup的返回值与EL2模式标志 b.ne 1f // 如果不是EL2跳转到标签1 mov x2, #BOOT_CPU_MODE_EL2 // 是EL2则将EL2标志值存入x2 b 2f // 跳转到标签2进行存储 1: mov x2, #BOOT_CPU_MODE_EL1 // 标签1将EL1标志值存入x2 2: str x2, [x1] // 标签2将x2的值存入__boot_cpu_mode ret作用将CPU最终的启动模式EL1或EL2记录在一个全局变量__boot_cpu_mode中。为什么需要记录内核的其他部分例如电源管理、CPU热插拔、虚拟化驱动可能需要知道系统最初是否在EL2启动过以决定某些硬件资源的初始化和访问方式。这是一个一次性的、对全局状态的记录。3.4__create_page_tables: 构建虚拟内存的基石这是stext中最复杂、最核心的函数之一。在开启MMU之前我们必须先为CPU准备好页表Page Tables。ARM64 Linux通常使用4级页表PGD - PUD - PMD - PTE支持48位虚拟地址空间。目标创建两组初始的、恒等映射Identity Mapping的页表。恒等映射将一块物理内存区域映射到与之相同地址的虚拟内存。例如物理地址0x80000000映射到虚拟地址0x80000000。这在MMU刚开启时至关重要因为当前执行的代码stext自身还位于物理地址上必须确保开启MMU的瞬间下一条指令的“虚拟地址”仍然指向正确的物理内存否则CPU会立即跑飞。内核映像映射将内核代码、数据所在的物理内存区域映射到内核的虚拟地址空间通常是0xffffffc0_00000000附近的高位地址。这是内核正常运行所需的最终映射。关键操作步骤确定页表基址通常选择一块未被使用的内存区域例如紧挨着内核映像末尾的位置来存放页表。清零页表内存使用stp指令循环将页表区域清零确保所有表项初始化为无效。创建恒等映射计算当前运行代码所在区域的物理地址范围。逐级填充页表项Descriptor。对于初始的大块内存可能会使用大页如2MB或1GB的块描述符来减少页表层级和大小。设置描述符的权限位通常是可执行、可读、特权模式EL1可访问。创建内核映像映射计算内核的物理起始地址_text和结束地址_end。将其映射到内核虚拟地址空间的相应位置如PAGE_OFFSET _text。设置TTBR0_EL1和TTBR1_EL1TTBR0_EL1用于用户空间低地址空间的页表基址寄存器。在启动初期可能先将其设置为一个无效或空的页表。TTBR1_EL1用于内核空间高地址空间的页表基址寄存器。这里被设置为刚刚创建好的、包含内核映射的页表物理基址。这是关键一步它告诉MMU内核的虚拟地址翻译规则。实操心得调试早期页表错误极其困难因为此时没有任何串口或日志输出。一个常用的技巧是使用“LED闪烁”或“测量特定引脚电平”的“土办法”来定位问题。更高级的方法是依赖JTAG调试器单步跟踪汇编指令并检查TTBR1_EL1等寄存器的值是否正确以及内存中页表内容是否符合预期。3.5__cpu_setup: 配置MMU与缓存这个函数为开启MMU做最后的、与CPU微架构相关的精细配置。它主要配置的是TCR_EL1转换控制寄存器和MAIR_EL1内存属性间接寄存器。TCR_EL1配置T0SZ/T1SZ定义用户空间TTBR0和内核空间TTBR1的虚拟地址空间大小。例如T1SZ 25表示内核空间有2^(64-25)的地址范围。TG0/TG1设置页粒度Granule如4KB, 16KB, 64KB。Linux通常使用4KB。IPS设置中间物理地址Intermediate Physical Address大小如40位或44位取决于系统支持的最大物理内存。SH/ORGN/IRGN设置内存区域的共享性Shareability和缓存策略Outer/Inner Cacheability。这是性能关键SHINNER_SHAREABLE表示该内存区域在所有CPU核心间共享且需要维护缓存一致性。ORGN/IRGNWRITE-BACK模式。表示读写都使用缓存写操作先更新缓存再异步写回内存。这是最常用的高性能模式。MAIR_EL1配置这是一个索引寄存器。它为不同的内存类型如普通内存、设备内存定义属性模板。例如索引0通常对应MT_NORMAL普通内存属性为WRITE-BACK缓存策略。索引1通常对应MT_DEVICE_nGnRnE强序设备内存属性为无缓存、无聚合、无早期写确认。访问外设寄存器必须使用这种类型以保证操作的顺序性和原子性。__cpu_setup函数根据预定义的配置可能因页粒度不同而有差异将计算好的值写入这些系统寄存器为MMU的激活铺平最后一段路。3.6__primary_switch与__primary_switched: 临门一脚与环境切换这是启动汇编阶段的最后两步。__primary_switch:__primary_switch: bl __enable_mmu // 分支跳转到 __enable_mmu 函数开启MMU ldr x8, __primary_switched // 将 __primary_switched 的**虚拟地址**加载到 x8 br x8 // 绝对跳转到 x8 指向的地址bl __enable_mmu调用函数执行MSR SCTLR_EL1, xzr和ISB等指令真正将SCTLR_EL1寄存器中的M位MMU enable bit置1。从此CPU发出的地址都是虚拟地址需要经过MMU翻译。ldr x8, __primary_switched这是一个关键点。操作符在这里获取的是符号__primary_switched的链接时地址虚拟地址。因为MMU已经开启后续所有寻址都必须使用虚拟地址。br x8跳转到__primary_switched函数的虚拟地址执行。CPU从此完全运行在内核的虚拟地址空间中。__primary_switched: 这个函数开始进行C语言运行环境所需的最后准备。__primary_switched: // 1. 初始化 init 进程的内存信息 (sp_el0) adr_l x4, init_thread_union add sp, x4, #THREAD_SIZE msr sp_el0, x4 // 2. 设置异常向量表 (vbar_el1) adr_l x5, vectors msr vbar_el1, x5 // 3. 保存FDT物理地址到全局变量 adr_l x8, __fdt_pointer str x21, [x8] // x21 来自 preserve_boot_args保存了FDT物理地址 // 4. 清零BSS段 adr_l x0, __bss_start adr_l x1, __bss_stop sub x1, x1, x0 bl __pi_memset // 调用一个位置无关的memset函数 // 5. 保存处理器ID和启动信息 str x22, [x8, #8] // 保存处理器ID str x24, [x8, #16] // 保存其他启动信息 // 6. 最终跳转到C语言世界 b start_kernel初始化栈指针spC函数调用依赖栈。这里将栈指针指向init_thread_union内核第一个线程即init进程的内核栈的顶端。sp_el0的设置为用户空间线程的task_struct查找提供便利。设置异常向量表将vbar_el1指向vectors这样当发生异常如中断、缺页时CPU就知道跳转到哪里去执行对应的处理程序。保存FDT指针将之前保存在x21中的FDT物理地址存放到一个名为__fdt_pointer的全局变量中供后续setup_arch等C函数解析设备树使用。清零BSS段这是C语言标准的要求。BSS段存放未初始化的全局变量和静态变量在程序加载时其内容应是未定义的。将其清零确保这些变量具有确定的初始值0。__pi_memset是一个特意编写的、位置无关的汇编函数因为在清零BSS时一些运行环境可能还未完全就绪。跳转到start_kernel执行一条简单的分支指令b start_kernel。至此汇编引导阶段全部结束CPU正式进入由C语言编写的、庞大而复杂的内核初始化主函数。stext段的使命圆满完成。4. 常见问题与深度调试技巧在移植内核或调试早期启动问题时stext段是最容易出问题的地方之一。以下是一些典型问题及排查思路。4.1 问题一内核在开启MMU后瞬间“死机”或跑飞现象串口没有任何输出或者输出乱码后停止系统毫无反应。根本原因几乎可以肯定是页表映射错误。在__primary_switch中执行br x8跳转后CPU使用虚拟地址x8取指如果这个虚拟地址没有正确映射到当前代码所在的物理地址CPU就会取到错误指令或触发异常而异常向量表可能还未正确设置导致死锁。排查步骤检查恒等映射确认__create_page_tables中为当前运行代码区域从stext到__primary_switched创建的恒等映射是否正确。确保物理地址到相同虚拟地址的映射存在且权限足够可执行。检查内核映射确认内核虚拟地址PAGE_OFFSET _text到内核物理地址_text的映射是否正确。检查TTBR1_EL1使用调试器在__enable_mmu执行前检查TTBR1_EL1寄存器的值是否指向了你创建的页表的物理基址。检查页表内容用调试器查看页表内存区域逐级解析描述符看其指向的下级表或物理地址是否正确。特别注意大页描述符的格式。4.2 问题二数据访问错误或权限异常现象在清零BSS段或访问某个全局变量时触发数据异常。可能原因设备内存错误映射如果代码尝试访问一个外设寄存器区域但对应的页表项内存属性被错误地设置为MT_NORMAL可缓存而不是MT_DEVICE不可缓存、强序会导致访问行为不符合设备预期引发异常。权限错误页表项中的APAccess Permission位设置错误。例如内核代码段应设置为只读、可执行数据段应设置为可读可写、不可执行。如果数据段被错误设置为不可写就会触发写权限异常。排查仔细核对__create_page_tables中为不同内存区域设置的描述符属性特别是MAIR_EL1的索引与页表项中AttrIndx字段的对应关系。4.3 问题三从el2_setup返回后状态异常现象系统在el2_setup后的某个点行为异常比如访问某些系统寄存器出错。可能原因ERET指令执行前ELR_EL2或SPSR_EL2设置错误导致CPU没有正确降级到EL1或者降级后的处理器状态如中断掩码不符合预期。排查在ERET指令前设置断点检查ELR_EL2是否指向正确的EL1指令地址通常是el2_setup函数内的一个标签检查SPSR_EL2的值确保其M[3:0]字段指定了EL1h模式使用SP_EL1并且DAIF位正确设置了中断掩码。4.4 高级调试手段JTAG/SWD调试器这是最强大的工具。可以单步执行汇编指令查看/修改所有通用寄存器和系统寄存器查看任意物理内存内容。可以设置复杂的硬件断点如在__enable_mmu处。串口早期打印在内核printk系统工作之前可以尝试在关键位置插入非常简单的汇编代码通过操作某个GPIO引脚翻转电平用示波器观察从而判断代码执行流。或者如果串口控制器非常简单如PL011且其物理地址已知可以尝试在汇编阶段直接向其数据寄存器写入字符进行输出需确保内存映射正确。对比法与模拟器使用QEMU等模拟器运行相同内核模拟器的调试功能如-d in_asm,cpu可以输出详细的执行轨迹和寄存器状态与真实硬件行为进行对比能快速定位差异点。利用-O0编译与反汇编在调试时将arch/arm64/kernel/head.S的编译优化关闭-O0并生成详细的反汇编文件vmlinux的反汇编确保你单步调试的代码与源码完全对应避免因优化导致的理解偏差。理解stext不仅仅是读懂一段汇编代码更是理解操作系统如何与硬件共舞的第一章。它充满了对硬件的直接操控和对极端环境的谨慎假设。每一次成功的启动都是这段精密代码与硬件完美配合的结果。当你下次看到Linux内核成功启动的日志时不妨回想一下在这一切开始之前stext那段沉默的汇编代码所完成的从物理世界到虚拟世界的伟大奠基。