Linux内核启动探秘:从stext入口到start_kernel的底层之旅
2026/5/17 1:45:05 网站建设 项目流程

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 Protocol),Bootloader在跳转到内核前,必须满足一系列状态条件,这些条件构成了stext执行的基础上下文。

2.1 Bootloader的“临终嘱托”

Bootloader(以U-Boot为例)在内核启动前,需要完成硬件最底层的初始化,并将机器置于一个已知的、确定的状态。这个状态协议是内核与Bootloader之间的契约,stext的代码正是基于这份契约的“信任”而编写的。核心要求包括:

  1. CPU模式:必须处于SVC(超级用户)模式,并且IRQ和FIQ中断必须被禁用。这是因为内核启动初期需要独占CPU,进行关键的系统初始化,任何中断都可能破坏其脆弱的初始状态。
  2. MMU与缓存:MMU(内存管理单元)、数据缓存(D-cache)和指令缓存(I-cache)必须被禁用。内核需要从物理地址的视角来设置自己的页表,开启缓存必须在MMU开启之后才有意义。
  3. 寄存器r0-r2:这三个寄存器承载了Bootloader传递给内核的关键信息。
    • r0:通常为0。在历史版本或某些特定机器ID传递的约定中可能有用,现代通用引导协议下通常为0。
    • r1:机器类型ID(Machine Type ID)。这是一个由内核源码arch/arm/tools/mach-types文件定义的唯一数字,用于告诉内核它正在哪种硬件平台上运行。例如,树莓派1代B型对应的ID是3138。内核根据这个ID来调用对应的平台初始化代码。
    • r2:ATAGS或DTB的物理地址指针。这是Bootloader向内核传递参数的核心机制。早期使用ATAGS(A tagged list),一种结构化的参数列表;现在主流使用设备树Blob(DTB),它是一个描述硬件拓扑结构的数据文件。内核通过解析这个地址的内容,获知内存大小、命令行参数(cmdline)、初始化RAM磁盘(initrd)位置等信息。
  4. 内存:内核镜像必须被加载到正确的物理内存地址(通常是0x8000,如树莓派)。内核代码期望从它被链接的地址开始执行,这个链接地址在编译时确定。

注意:这些状态是stext能够正确执行的先决条件。如果你在移植内核或修改Bootloader时遇到启动失败,首先应该检查这些交接条件是否被满足。可以使用U-Boot的md(内存显示)命令查看r1、r2寄存器的值,或者通过JTAG调试器查看CPU状态。

2.2 内核镜像的“自我认知”:vmlinux与Image

我们常说的“编译内核”,最终会生成一个名为vmlinux的ELF格式文件。但Bootloader通常无法直接加载ELF。因此,会通过objcopy工具将其转换为纯二进制格式的Image文件。zImageuImage则是经过压缩的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.Sarch/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传来的机器ID(r1)是否在内核支持的列表中,并检查参数指针(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)开始的若干节(Section,1MB大小)内存, * 映射到相同的虚拟地址上。同时,也会将这段内存映射到内核虚拟地址空间的 * 高端地址(如0xC0000000,即PAGE_OFFSET)处。这是为后续跳转到高端地址运行做准备。 */ /* 填充页表项(Descriptor),设置权限(可读、可执行,可能不可写)和域(Domain) */ ... mov pc, lr @ 返回 /* 设置控制寄存器,为开启MMU做准备 */ ldr r13, =__mmap_switched @ 将__mmap_switched的地址加载到r13(sp) 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阶段是重灾区。

  • 排查思路
    1. 检查Bootloader传参:使用U-Boot的printenv命令查看bootargs,确保bootmbootz命令正确加载了内核和DTB,并使用md命令确认内核加载地址的内容是否正确(通常能看到内核魔数)。最关键的是检查r1(机器ID)和r2(DTB地址)。可以在U-Boot中设置一个简单的汇编循环,在跳转前将这些寄存器的值打印到串口,或者通过JTAG调试器直接查看。
    2. 验证机器ID:确认你编译内核时使用的配置(make ARCH=arm multi_v7_defconfig等)是否包含了目标板的支持。检查arch/arm/tools/mach-types文件中,你的板子对应的ID是否与Bootloader传递的一致。不一致是导致__lookup_machine_type失败的最常见原因。
    3. 检查DTB兼容性:如果使用设备树,确保使用的DTB文件与内核版本匹配,并且包含了正确的compatible属性。一个不匹配或错误的DTB会导致内核在早期初始化时崩溃。
    4. 初始页表映射错误:如果内核在开启MMU的瞬间死掉,很可能是初始页表建立有误。这通常与内存地址计算错误有关。可以尝试在__create_page_tables函数中,在写页表项之前和之后,通过串口打印出关键地址和页表内容进行比对。注意:此时串口驱动尚未初始化,需要使用最底层的、基于物理地址的串口打印函数(例如printascii,如果平台支持)。
    5. 关闭所有优化,添加调试:在内核配置中关闭CONFIG_ARM_PATCH_PHYS_VIRT(动态修补物理/虚拟地址转换)等高级选项,使用最基础的配置。在stext的关键位置插入汇编宏BUG()asm(“mov r0, r0”)(作为断点标记),配合JTAG单步调试,是定位问题的终极手段。

4.2 问题二:内核启动早期打印乱码或打印后停止

  • 可能原因
    1. 串口初始化时机:内核的早期打印(printk)依赖于CONFIG_DEBUG_LL(Low-Level Debugging)和CONFIG_EARLY_PRINTK。这些功能需要在内核启动极早期、甚至是在stext中初始化串口。如果平台相关的DEBUG_LL初始化代码有误(如错误的时钟、波特率、引脚复用配置),就会导致乱码。需要仔细核对硬件手册和内核中对应平台的调试LL代码(arch/arm/include/debug/目录下)。
    2. 栈设置错误:如果栈指针(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到ARMv8

stext的代码并非一成不变,它随着ARM架构的发展而演进。

  • ARMv5/v6 (ARM926/11):这是经典的head.S流程,上述分析主要基于此。需要手动创建精细的页表。
  • ARMv7:引入了CP15协处理器的新操作,并且内核支持了多种CPU核心的通用化初始化(通过__v7_setup)。同时,CONFIG_ARM_PATCH_PHYS_VIRT特性允许内核在运行时动态计算物理-虚拟地址偏移,提高了内核镜像在不同内存地址加载的灵活性。
  • ARMv8 (AArch64):发生了根本性变化。64位ARM内核的入口点不再是stext,而是head.S中的_textprimary_entry。启动流程被重写,更加模块化。它使用一组名为“启动协议”(Boot Protocol)的固定寄存器(x0-x3)来传递信息(如DTB地址),并且早期代码大量使用C语言编写,汇编部分主要负责最底层的CPU模式切换和异常向量设置。分析AArch64的启动,需要阅读arch/arm64/kernel/head.S,其逻辑与32位ARM有显著区别。

理解这些差异,有助于你在为不同平台的内核进行调试或移植时,快速找到正确的代码入口和分析路径。stext及其所代表的早期启动流程,是连接硬件冷启动与软件繁荣世界的唯一桥梁,它的稳定与可靠,是整个系统基石中的基石。每一次对其深入的分析,都让我们对“系统如何从无到有”这个根本问题,多一分敬畏与洞察。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询