Linux内核启动探秘:从stext入口到start_kernel的底层之旅

发布时间:2026/5/17 1:45:15

Linux内核启动探秘:从stext入口到start_kernel的底层之旅 1. 项目概述从按下电源到内核启动的第一行代码当你在树莓派上启动一个定制系统或者在服务器上调试一个内核启动失败的问题时有没有想过从CPU上电复位到屏幕上出现第一个内核日志这中间到底发生了什么对于绝大多数开发者来说内核的main函数start_kernel是故事的开端但真相是在main之前内核已经默默运行了相当长的一段“暗黑”旅程。这段旅程的起点就是stext——内核镜像中那个看似不起眼却承载着从“裸机”到“操作系统”惊险一跃的入口段。stext并不是一个函数名而是一个链接器脚本中定义的符号它标记了内核代码段.text的起始地址。在ARM体系结构尤其是ARMv7及更早版本的Linux内核中stext更是被直接用作整个内核的入口点。当Bootloader如U-Boot完成最基本的硬件初始化将CPU模式切换到SVC管理模式关闭中断并最终跳转到内核镜像的加载地址时CPU执行的第一条指令就位于stext标签处。分析stext就是分析内核如何在一片混沌的硬件环境中为自己搭建起一个能够运行高级C语言代码的“舞台”。这个过程涉及处理器模式切换、内存管理单元MMU的开启、初始页表的建立、C运行环境的准备等一系列底层操作任何一个环节出错系统都将陷入沉寂。理解stext不仅是深入内核启动流程的必修课更是进行嵌入式系统移植、内核异常调试如启动时卡死的必备技能。2. 内核启动前置知识Bootloader与内核的交接在深入stext的代码之前我们必须清楚它并非在真空中运行。它的执行依赖于Bootloader搭建的“临时舞台”。根据ARM Linux的启动协议Boot ProtocolBootloader在跳转到内核前必须满足一系列状态条件这些条件构成了stext执行的基础上下文。2.1 Bootloader的“临终嘱托”Bootloader以U-Boot为例在内核启动前需要完成硬件最底层的初始化并将机器置于一个已知的、确定的状态。这个状态协议是内核与Bootloader之间的契约stext的代码正是基于这份契约的“信任”而编写的。核心要求包括CPU模式必须处于SVC超级用户模式并且IRQ和FIQ中断必须被禁用。这是因为内核启动初期需要独占CPU进行关键的系统初始化任何中断都可能破坏其脆弱的初始状态。MMU与缓存MMU内存管理单元、数据缓存D-cache和指令缓存I-cache必须被禁用。内核需要从物理地址的视角来设置自己的页表开启缓存必须在MMU开启之后才有意义。寄存器r0-r2这三个寄存器承载了Bootloader传递给内核的关键信息。r0通常为0。在历史版本或某些特定机器ID传递的约定中可能有用现代通用引导协议下通常为0。r1机器类型IDMachine Type ID。这是一个由内核源码arch/arm/tools/mach-types文件定义的唯一数字用于告诉内核它正在哪种硬件平台上运行。例如树莓派1代B型对应的ID是3138。内核根据这个ID来调用对应的平台初始化代码。r2ATAGS或DTB的物理地址指针。这是Bootloader向内核传递参数的核心机制。早期使用ATAGSA tagged list一种结构化的参数列表现在主流使用设备树BlobDTB它是一个描述硬件拓扑结构的数据文件。内核通过解析这个地址的内容获知内存大小、命令行参数cmdline、初始化RAM磁盘initrd位置等信息。内存内核镜像必须被加载到正确的物理内存地址通常是0x8000如树莓派。内核代码期望从它被链接的地址开始执行这个链接地址在编译时确定。注意这些状态是stext能够正确执行的先决条件。如果你在移植内核或修改Bootloader时遇到启动失败首先应该检查这些交接条件是否被满足。可以使用U-Boot的md内存显示命令查看r1、r2寄存器的值或者通过JTAG调试器查看CPU状态。2.2 内核镜像的“自我认知”vmlinux与Image我们常说的“编译内核”最终会生成一个名为vmlinux的ELF格式文件。但Bootloader通常无法直接加载ELF。因此会通过objcopy工具将其转换为纯二进制格式的Image文件。zImage或uImage则是经过压缩的Image在头部添加了解压代码。stext的地址就记录在vmlinux这个ELF文件的入口地址Entry Point Address中。你可以通过readelf -h vmlinux命令查看。在链接器脚本如arch/arm/kernel/vmlinux.lds.S中.text段的起始位置被设置为stext这保证了所有内核代码都从它之后开始排列。3. stext代码逐行解析从汇编到C的桥梁现在让我们打开arch/arm/kernel/head.S或arch/arm/kernel/head-common.S不同版本和架构文件可能略有不同直面stext的汇编代码。我们将以ARMv7为例分解其关键步骤。请注意以下代码是经过简化和注释的示意性代码旨在说明流程。3.1 第一步安全验证与基础设置ENTRY(stext) /* 确保CPU处于SVC模式且中断关闭 */ safe_svcmode_maskall r9 /* 获取处理器ID并存放到r9寄存器 */ mrc p15, 0, r9, c0, c0 读取主ID寄存器MIDR /* 检查处理器是否支持此内核版本早期的一些校验可能会在这里进行 */ /* 查找机器类型信息。 * r1寄存器存放了Bootloader传来的机器ID。 * 内核会遍历一个预定义的机器描述结构体数组__arch_info_begin 到 __arch_info_end * 与r1进行匹配。如果找不到系统可能无法启动。 */ bl __lookup_machine_type r5 machinfo 指针 movs r8, r5 无效机器类型 beq __error_a 是则跳转到错误处理 /* 检查ATAGS/DTB指针r2的合法性 */ bl __vet_atags核心作用这几步是“信任但要验证”。内核不盲目相信Bootloader它首先确认CPU处于正确模式然后验证Bootloader传来的机器IDr1是否在内核支持的列表中并检查参数指针r2是否是一个合理的地址。如果机器ID不匹配通常会打印错误信息并挂起系统。这一步失败是嵌入式移植中最常见的问题之一表现为内核启动后立即停止。3.2 第二步创建初始页表并开启MMU这是stext中最复杂、最核心的部分。在MMU开启前CPU访问的是物理地址。内核需要建立一张临时的映射表使得开启MMU后CPU看到的虚拟地址能够正确地映射到物理地址上。这个过程称为“恒等映射”identity mapping即虚拟地址等于物理地址通常只映射内核代码所在的那一小段内存区域比如最初的几MB或十几MB。/* 准备开启MMU */ bl __create_page_tables __create_page_tables: /* 1. 清空页表所在内存区域通常是0x4000或0x8000之后的一块内存 */ /* 2. 创建恒等映射 * 将内核起始物理地址如0x8000开始的若干节Section1MB大小内存 * 映射到相同的虚拟地址上。同时也会将这段内存映射到内核虚拟地址空间的 * 高端地址如0xC0000000即PAGE_OFFSET处。这是为后续跳转到高端地址运行做准备。 */ /* 填充页表项Descriptor设置权限可读、可执行可能不可写和域Domain */ ... mov pc, lr 返回 /* 设置控制寄存器为开启MMU做准备 */ ldr r13, __mmap_switched 将__mmap_switched的地址加载到r13sp adr lr, BSYM(1f) 设置返回地址 ldr r12, [r10, #PROCINFO_INITFUNC] 获取处理器特定初始化函数 add r12, r12, r10 ret r12 跳转到处理器特定初始化如__v7_setup 1: /* 处理器特定初始化返回后正式开启MMU */ mov r0, #0 mcr p15, 0, r0, c7, c10, 4 数据同步屏障DSB mcr p15, 0, r0, c8, c7, 0 使无效整个指令和数据TLB mrc p15, 0, r0, c1, c0, 0 读取控制寄存器SCTLR orr r0, r0, #CR_M 设置M位开启MMU mcr p15, 0, r0, c1, c0, 0 写回控制寄存器 /* 指令同步屏障ISB确保MMU开启立即生效 */ isb关键点解析恒等映射的必要性开启MMU的指令本身需要从内存中取指执行。如果开启MMU后当前执行指令的虚拟地址没有映射到正确的物理地址CPU会立即取指错误系统崩溃。恒等映射保证了“开启MMU”这条指令及其后续几条指令的平滑过渡。高端映射Linux内核通常运行在虚拟地址空间的高端如3GB以上。初始页表同时建立了低端恒等映射和高端映射使得内核可以在开启MMU后通过一条跳转指令从低端地址“跳”到高端地址继续执行从而进入内核的标准虚拟地址空间。处理器特定初始化__v7_setup在开启MMU前会调用一个与CPU核心架构如Cortex-A7, A53相关的函数来配置缓存、分支预测、以及其他协处理器设置。这些设置对性能至关重要。3.3 第三步跳转到虚拟地址并设置C环境MMU开启后内核世界从“物理视图”切换到了“虚拟视图”。接下来需要完成向C语言世界的最后过渡。__mmap_switched: /* 1. 复制数据段将编译时存储在代码段后面的初始化数据.data段复制到其运行时地址。 * 2. 清零BSS段将未初始化的全局变量区域.bss段全部置零。 * 这两步是C语言程序能够正确运行的基础。 */ adr r3, __mmap_switched_data ldmia r3!, {r4, r5, r6, r7} cmp r4, r5 复制数据段如果需要 ... mov r2, #0 cmp r5, r6 清零.bss段 ... /* 设置栈指针SP * 这是至关重要的一步。栈是C函数调用、局部变量存放的基础。 * 通常初始栈被设置在内核地址空间的一个安全区域例如 init_thread_union THREAD_START_SP。 */ ldr sp, [r7, #4] 获取初始栈指针 /* 保存机器描述信息r1、ATAGS/DTB指针r2等将其存入指定变量如__atags_pointer或寄存器备用 */ /* 清零帧指针FP满足APCS调用规范 */ mov fp, #0 /* 最后跳转到C语言写的内核启动函数 */ b start_kernel里程碑意义当执行到b start_kernel时内核已经完成了从“裸机汇编”到“拥有虚拟内存和完整C运行环境”的操作系统的蜕变。start_kernel函数是Linux内核初始化代码用C语言编写的总入口从这里开始内核将进行中断初始化、调度器初始化、内存管理系统初始化等一系列复杂的操作最终挂载根文件系统启动第一个用户空间进程init。4. 常见问题与调试技巧实录分析stext不仅是为了理解原理更是为了实战排错。以下是我在多年内核移植和调试中遇到的典型问题及解决方法。4.1 问题一内核启动后无任何输出直接停止挂起这是最令人头疼的问题。可能的原因非常多但stext阶段是重灾区。排查思路检查Bootloader传参使用U-Boot的printenv命令查看bootargs确保bootm或bootz命令正确加载了内核和DTB并使用md命令确认内核加载地址的内容是否正确通常能看到内核魔数。最关键的是检查r1机器ID和r2DTB地址。可以在U-Boot中设置一个简单的汇编循环在跳转前将这些寄存器的值打印到串口或者通过JTAG调试器直接查看。验证机器ID确认你编译内核时使用的配置make ARCHarm multi_v7_defconfig等是否包含了目标板的支持。检查arch/arm/tools/mach-types文件中你的板子对应的ID是否与Bootloader传递的一致。不一致是导致__lookup_machine_type失败的最常见原因。检查DTB兼容性如果使用设备树确保使用的DTB文件与内核版本匹配并且包含了正确的compatible属性。一个不匹配或错误的DTB会导致内核在早期初始化时崩溃。初始页表映射错误如果内核在开启MMU的瞬间死掉很可能是初始页表建立有误。这通常与内存地址计算错误有关。可以尝试在__create_page_tables函数中在写页表项之前和之后通过串口打印出关键地址和页表内容进行比对。注意此时串口驱动尚未初始化需要使用最底层的、基于物理地址的串口打印函数例如printascii如果平台支持。关闭所有优化添加调试在内核配置中关闭CONFIG_ARM_PATCH_PHYS_VIRT动态修补物理/虚拟地址转换等高级选项使用最基础的配置。在stext的关键位置插入汇编宏BUG()或asm(“mov r0, r0”)作为断点标记配合JTAG单步调试是定位问题的终极手段。4.2 问题二内核启动早期打印乱码或打印后停止可能原因串口初始化时机内核的早期打印printk依赖于CONFIG_DEBUG_LLLow-Level Debugging和CONFIG_EARLY_PRINTK。这些功能需要在内核启动极早期、甚至是在stext中初始化串口。如果平台相关的DEBUG_LL初始化代码有误如错误的时钟、波特率、引脚复用配置就会导致乱码。需要仔细核对硬件手册和内核中对应平台的调试LL代码arch/arm/include/debug/目录下。栈设置错误如果栈指针SP设置到了错误的内存区域如未初始化的内存或只读区域那么一旦start_kernel开始执行C代码进行函数调用时就会立即崩溃。检查__mmap_switched_data中栈指针的赋值是否正确指向了有效的、可读写的内存地址通常是init_thread_union区域的末尾。4.3 调试技巧没有JTAG怎么办对于广大嵌入式爱好者专业JTAG调试器可能不易获得。以下是一些“穷人的调试法”LED闪烁法在stext的不同阶段通过GPIO控制一个LED的亮灭。例如在__lookup_machine_type通过后闪烁1次在__create_page_tables完成后闪烁2次在开启MMU前闪烁3次。通过观察LED的闪烁模式可以大致判断内核死在了哪个阶段。内存标记法在已知的、Bootloader不会使用的物理内存地址例如0x1000处写入特定的魔数如0xDEADBEEF。在stext的每个关键步骤后修改这个魔数。最后通过Bootloader如U-Boot的md命令查看该地址的值就能知道内核最后执行到了哪里。利用未定义指令故意在代码中插入一条处理器不支持的指令如.word 0xe7f000f0。当内核执行到这里时会触发“未定义指令”异常。内核的异常向量表如果已设置可能会跳转到某个处理函数你可以在该函数里实现简单的串口打印输出程序计数器PC的值。这能帮你精确定位崩溃点。5. 不同ARM架构的演变从ARMv5到ARMv8stext的代码并非一成不变它随着ARM架构的发展而演进。ARMv5/v6 (ARM926/11)这是经典的head.S流程上述分析主要基于此。需要手动创建精细的页表。ARMv7引入了CP15协处理器的新操作并且内核支持了多种CPU核心的通用化初始化通过__v7_setup。同时CONFIG_ARM_PATCH_PHYS_VIRT特性允许内核在运行时动态计算物理-虚拟地址偏移提高了内核镜像在不同内存地址加载的灵活性。ARMv8 (AArch64)发生了根本性变化。64位ARM内核的入口点不再是stext而是head.S中的_text或primary_entry。启动流程被重写更加模块化。它使用一组名为“启动协议”Boot Protocol的固定寄存器x0-x3来传递信息如DTB地址并且早期代码大量使用C语言编写汇编部分主要负责最底层的CPU模式切换和异常向量设置。分析AArch64的启动需要阅读arch/arm64/kernel/head.S其逻辑与32位ARM有显著区别。理解这些差异有助于你在为不同平台的内核进行调试或移植时快速找到正确的代码入口和分析路径。stext及其所代表的早期启动流程是连接硬件冷启动与软件繁荣世界的唯一桥梁它的稳定与可靠是整个系统基石中的基石。每一次对其深入的分析都让我们对“系统如何从无到有”这个根本问题多一分敬畏与洞察。

相关新闻