深入理解 Zephyr RTOS 启动流程:从上电复位到 main 函数的完整追踪(基于AI协助总结)
硬件平台: STM32H747I-DISCO | Zephyr 版本: v4.3.0-dev | 架构: ARM Cortex-M7 (ARMv7-M)
1. 为什么要理解启动流程
理解 Zephyr 的启动流程,意味着你能回答这些问题:
- CPU 上电后第一条指令从哪里开始执行?
0x08000000这个地址是谁决定的? - 为什么有些日志”看不到”——实际上是因为打印太早、串口驱动还没初始化
- 链接脚本里的
FLASH和RAM区域地址是怎么来的?DTS 的chosen节点如何影响链接结果? main()函数被调用之前,内核已经完成了哪些初始化?驱动的初始化顺序是谁定的?- 第一次上下文切换发生在哪个节点?假线程(dummy thread)存在的意义是什么?
本文从硬件复位开始,逐阶段追踪 Zephyr 的启动序列。
2. 全局鸟瞰:从上电到 main 的完整路径
1 | CPU 上电 |
3. 第一步:硬件复位 — STM32H7 如何找到第一行代码
3.1 启动地址机制:Option Bytes 与 BOOT 引脚
STM32H747 是双核芯片(Cortex-M7 + Cortex-M4),启动行为由 Option Bytes(选项字节)控制,而非单一的 BOOT 引脚。
Option Byte 不是普通寄存器:存在于 Flash 的特殊区域,掉电保持,硬件上电时自动读取(此时软件尚未运行)。可通过 STM32CubeProgrammer 修改。
三个关键配置:
① BCM7 / BCM4 — 控制哪个核启动
| BCM7 | BCM4 | 行为 |
|---|---|---|
| 1 | 1 | 出厂默认:两个核都启动 |
| 1 | 0 | 只有 M7 启动(M4 可被 M7 软件唤醒) |
| 0 | 1 | 只有 M4 启动 |
| 0 | 0 | 两个核都不自动启动 |
② BCM7_ADD0 / ADD1 — M7 的启动地址
| Option Byte | 出厂默认值 | 含义 |
|---|---|---|
| BCM7_ADD0 | 0x08000000 | Flash(BOOT=0 时使用) |
| BCM7_ADD1 | 0x00000000 | ITCM-RAM(BOOT=1 时使用) |
③ BOOT 引脚 — 芯片上的物理引脚,选择用 ADD0 还是 ADD1
1 | BOOT = 0(当前开发板设置,BOOT 脚接地)→ 使用 ADD0 = 0x08000000(从 Flash 启动) |
完整启动决策流程:
1 | 上电 |
3.2 上电时 VTOR 的状态
ARM Cortex-M 架构规定:复位后 VTOR = 0,CPU 理论上从 0x00000000 读取向量表。
STM32H7 的实现:不是简单地把 0x08000000 别名映射到 0x00000000,而是通过 BOOT 引脚 + Option Bytes 机制,硬件直接把 CPU 的初始取指地址指向 BCM7_ADD0 指定的地址(默认 0x08000000)。
1 | 上电时:硬件直接从 0x08000000 取 MSP 和 Reset_Handler(不经过 VTOR) |
3.3 向量表:Flash 开头的一张函数指针数组
向量表是 Flash 开头的一张函数指针数组,CPU 上电后第一条指令就从这里找:
1 | Flash: 0x0800_0000 |
关键理解:
- CPU 硬件自动从
0x0800_0000加载 MSP,从0x0800_0004加载 PC - STM32H7 通过
SCB->VTOR寄存器可以在运行时重定位向量表
3.4 向量表地址 0x08000000 的完整传递链
0x08000000 从 DTS 到运行时的 5 层传递:
1 | ① 设备树 DTS: |
关键机制:CMake 把 DTS 解析成 Python 对象(edt.pickle),Kconfig 求值时通过 Python 函数 dt_chosen_reg_addr_hex() 从中查找 zephyr,flash 指向的节点的 reg 属性,提取出 0x08000000。
3.5 链接脚本中向量表段的详细分析
源码位置:build/zephyr/linker.cmd
1 | __rom_region_start = (0x8000000 + 0x0); |
ALIGN 为什么不移动位置?
0x08000000 = 二进制末尾有 27 个零,天然满足所有对齐要求:
1 | . = ALIGN(4) → 需要 2 位对齐 ✓ 不动 |
ALIGN 是安全网——如果起始地址不是 0x08000000(比如从 SRAM 启动),对齐操作会自动调整。
3.6 向量表的实际内容
源码位置:zephyr/arch/arm/core/cortex_m/vector_table.S
STM32H7 M7 核是 ARMv7-M Mainline 架构,16 个系统异常全都有:
1 | 偏移 内容 |
3.7 静态向量表 vs 动态 ISR 表
链接脚本中有两种向量段,对应不同的中断处理方式:
| 段名 | 内容 | 可变性 |
|---|---|---|
.exc_vector_table |
系统异常(Reset, NMI, Fault…) | 编译时固定 |
.gnu.linkonce.irq_vector_table |
外设中断(IRQ 0~149) | 全部指向 _isr_wrapper |
动态分发机制:
flowchart LR
subgraph VT["向量表 (Flash, 固定)"]
direction TB
V0["IRQ 0"]
V1["IRQ 1"]
V2["IRQ 2"]
VN["..."]
V149["IRQ 149"]
end
subgraph ISR["ISR 软件表 (RAM, 运行时可改)"]
direction TB
S0["[0] spi1_isr"]
S1["[1] uart1_isr"]
S2["[2] gpio_isr"]
SN["..."]
end
W["_isr_wrapper()"]
C["IRQ_CONNECT() 驱动注册"]
VT --> W
W -- "查表 _irq_table[irq_num]" --> ISR
C -. "运行时填充" .-> ISR
动态分发的意义:驱动在运行时用 IRQ_CONNECT() 注册 ISR,而不是编译时写死。这样同一中断号可以接不同驱动,支持动态加载。
3.8 向量表的两级源码实现
vector_table.S 中只有 16 个系统异常,外设中断(IRQ 0~149)的定义在 zephyr/arch/common/isr_tables.c 中:
1 | // zephyr/arch/common/isr_tables.c:59-83 |
两级向量表的源码分工:
| 源文件 | 段名 | 内容 | 原因 |
|---|---|---|---|
vector_table.S |
.exc_vector_table |
16 个系统异常 | 每个异常有独立处理函数(hard_fault、svc、pendsv 等),必须逐个定义 |
isr_tables.c |
.gnu.linkonce.irq_vector_table |
全部 IRQ → _isr_wrapper |
本质是同值数组,用 C 的 range initializer [0 ...(N-1)] 一行填充 |
链接脚本将两个段拼在一起,形成完整的向量表:
1 | KEEP(*(.exc_vector_table)) ← 系统异常(vector_table.S) |
最终 ELF 中是一段连续的函数指针数组:16 个系统异常入口 + 150 个 _isr_wrapper。
4. 第二步:z_arm_reset() — 汇编级逐行深度解析
源码位置:zephyr/arch/arm/core/cortex_m/reset.S
这是 CPU 执行的第一段用户代码(纯汇编)。以下按 STM32H7(ARMv7-M Mainline)实际执行的路径,逐行详解每条汇编和涉及的系统寄存器。
4.1 完整执行时间线(STM32H7 实际路径)
在逐行分析前,先看 z_arm_reset() 的完整执行流程:
1 | z_arm_reset: |
4.2 涉及的 ARM Cortex-M 系统寄存器速查
| 寄存器 | 访问方式 | 位域 | 作用 |
|---|---|---|---|
| CONTROL | 特殊寄存器(MRS/MSR) | bit[2] FPCA: FP 上下文活跃 bit[1] SPSEL: 0=MSP, 1=PSP bit[0] nPRIV: 0=特权, 1=非特权 |
控制栈选择和特权级别 |
| MSP | 特殊寄存器(MRS/MSR) | 32bit | 主栈指针(复位后默认使用) |
| PSP | 特殊寄存器(MRS/MSR) | 32bit | 进程栈指针(线程使用) |
| BASEPRI | 特殊寄存器(MRS/MSR) | bit[7:0] | 优先级屏蔽阈值(≥此值的中断被屏蔽) |
| FAULTMASK | 特殊寄存器(MRS/MSR) | bit[0] | 异常屏蔽(=1 时屏蔽所有可编程异常) |
| MPU_CTRL | 0xE000ED94(存储器映射) | bit[0] ENABLE, bit[1] HFNMIENA, bit[2] PRIVDEFENA | MPU 总开关 |
4.3 逐行详解
阶段 1:重置 CONTROL 寄存器
1 | /* 第 93-97 行:重置 CONTROL 为默认值 */ |
CONTROL 寄存器详解(ARMv7-M,Cortex-M7 有效位域):
1 | CONTROL 寄存器(特殊寄存器,通过 MRS/MSR 访问): |
重置的原因:这段代码可能被 bootloader 链式加载(chain-loading),此时 CONTROL 可能不是默认值。重置确保从已知状态开始。
isb 是什么? Instruction Synchronization Barrier(指令同步屏障)。写 CONTROL 后必须跟 isb,否则 CPU 流水线可能还在用旧的栈指针。ARM 架构要求:修改 CONTROL 或栈指针后,必须用 isb 确保生效。
阶段 2:清零栈限制寄存器
1 | /* 第 98-103 行:清零 SPLIM(如果 CPU 支持) */ |
MSPLIM / PSPLIM 是什么? ARMv8-M 引入的栈溢出检测寄存器。如果 MSP < MSPLIM,触发 Stack Overflow 异常。启动阶段清零 = 暂不检测。
STM32H7 的 Cortex-M7 是 ARMv7-M 架构,没有 SPLIM 寄存器,所以这段代码实际不编译(被 #if defined(CONFIG_CPU_CORTEX_M_HAS_SPLIM) 排除)。ARMv8-M 架构的处理器(如 Cortex-M33/M55)才有此寄存器。
阶段 3:设置 MSP 到主栈
1 | /* 第 147-148 行:设置 MSP */ |
ldr r0, =xxx 是伪指令,把 32 位常量加载到 r0。z_main_stack 是链接脚本中定义的符号,CONFIG_MAIN_STACK_SIZE 通常为 1024 或 2048。
使用 MSP 的原因:此时 CONTROL.SPSEL=0,CPU 还在使用 MSP。后续代码需要 MSP 来执行 soc_reset_hook() 等函数调用(函数调用需要栈来保存返回地址)。
阶段 4:调用 SoC 复位钩子
1 | /* 第 162-164 行:调用 STM32H7 专用复位函数 */ |
bl = Branch with Link:跳转到 soc_reset_hook,同时把下一条指令的地址保存到 LR(R14),函数返回时 bx lr 跳回来。
soc_reset_hook() 做了什么? 以 STM32H7 为例,清零 TCM 内存(防 ECC 错误):
1 | // zephyr/soc/st/stm32/stm32h7x/soc_m7.c |
关键设计:地址和大小从 Device Tree 的 zephyr_itcm / zephyr_dtcm chosen 节点获取,同一 SoC 的不同变体(ITCM 大小不同)无需改代码。BUILD_ASSERT 在编译时验证对齐要求。volatile 防止编译器将清零循环优化掉。
STM32H7 需要清零 TCM 是因为其上电后内容未定义,访问会触发 ECC 错误 → HardFault(参考 ST AN5342 Rev.7 §3.1.1)。其他 SoC 可能在此执行 PLL 预配置、看门狗关闭等操作。
阶段 5:禁用 MPU
1 | /* 第 167-173 行:关闭 MPU */ |
MPU_CTRL 寄存器详解:
1 | MPU_CTRL (0xE000ED94): |
先禁用 MPU 的原因:后续要设置栈指针、拷贝数据,如果 MPU 配置了内存保护,可能会阻止这些操作。先禁用,等内核初始化完成后再配置并启用。
dsb 是什么? Data Synchronization Barrier(数据同步屏障)。确保 MPU_CTRL 写入到达硬件后再继续。
阶段 6:初始化架构硬件
1 | /* 第 176 行 */ |
源码位置:zephyr/arch/arm/core/cortex_m/scb.c(93-155 行)
这个函数把 CPU 内部所有硬件状态重置到已知值,防止前一个固件(bootloader)留下残余配置:
1 | void z_arm_init_arch_hw_at_boot(void) |
涉及的寄存器详解:
| 步骤 | 寄存器 | 访问方式 | 操作 | 目的 |
|---|---|---|---|---|
| 2 | FAULTMASK | 特殊寄存器(MRS/MSR) | 写 0 | 允许所有 Fault 触发 |
| 3 | MPU_RNR/RBAR/RASR | 0xE000ED98..(存储器映射) | 清除所有区域 | MPU 回到空白状态 |
| 4 | NVIC->ICER[n] | 0xE000E180..(存储器映射) | 写全 1 | 禁用所有外设中断 |
| 5 | NVIC->ICPR[n] | 0xE000E280..(存储器映射) | 写全 1 | 清除所有挂起中断 |
| 6 | SCB->CCR | 0xE000ED14(存储器映射) | 检查 DC 位 | 根据当前状态决定如何处理 Cache |
NVIC 寄存器特性:ICER(Interrupt Clear Enable)写 1 禁用对应中断,ICPR(Interrupt Clear Pending)写 1 清除挂起状态。这是 ARM 的”写 1 清除/设置”模式——读和写的含义不同。
阶段 7:禁用普通中断(保留 Fault)
1 | /* 第 182-184 行(ARMv7-M 路径) */ |
ARM Cortex-M 三级优先级体系:
| 层级 | 优先级 | 异常 | BASEPRI 能屏蔽? |
|---|---|---|---|
| 固定(不可屏蔽) | -3 | Reset | 永远不能 |
| 固定(不可屏蔽) | -2 | NMI(不可屏蔽中断) | 永远不能 |
| 固定(不可屏蔽) | -1 | HardFault | 永远不能 |
| 可编程 Fault | 0x00(默认) | MemManage / BusFault / UsageFault | 不屏蔽(< 0x10) |
| 可编程系统 | 0x00(默认) | SVCall | 不屏蔽(< 0x10) |
| 普通外设 | 0x10~0xF0 | UART, SPI, DMA, TIM… | 全部屏蔽 |
| 系统 | 0xFF | PendSV, SysTick | 全部屏蔽 |
BASEPRI 寄存器详解(特殊寄存器,通过 MRS/MSR 访问):
1 | 规则:屏蔽所有优先级 ≥ 写入值的中断 |
_EXC_IRQ_DEFAULT_PRIO 的推导:
1 | // DTS 定义: stm32h7.dtsi → NUM_IRQ_PRIO_BITS = 4(STM32H7 用 4 位优先级) |
优先级寄存器的位布局:STM32H7 只有 4 位有效优先级,放在 8 位寄存器的高 4 位:
1 | 硬件 8 位优先级寄存器: |
BASEPRI=0x10 后的效果:
1 | 能触发: Reset, NMI, HardFault(固定优先级,BASEPRI 管不了) |
阶段 8:切换到 PSP(进程栈指针)
1 | /* 第 213-226 行 */ |
1 | mrs r0, CONTROL /* 读取 CONTROL 当前值 */ |
CONTROL.SPSEL 的含义:
1 | CONTROL 寄存器: |
mrs / msr 而非 movs 的原因:CONTROL 是特殊寄存器,必须通过 mrs(读)和 msr(写)访问,不能用普通 mov 指令。
阶段 9:跳转到 C 语言
1 | /* 第 233 行 */ |
bl = Branch with Link,跳转并把返回地址存入 LR。但 z_prep_c() 声明为 FUNC_NORETURN,永远不会返回。
4.4 三个屏障指令的区别
| 指令 | 全称 | 作用 | 何时使用 |
|---|---|---|---|
isb |
Instruction Synchronization Barrier | 刷新流水线,确保后续指令看到之前的系统寄存器修改 | 修改 CONTROL、栈指针后 |
dsb |
Data Synchronization Barrier | 等待所有显式内存访问完成 | 修改 MPU 配置后 |
dmb |
Data Memory Barrier | 确保内存操作顺序 | 多核共享内存时 |
区分要点:isb 作用于 CPU 流水线,dsb 等待内存总线写入完成,dmb 保证内存操作顺序。
5. 第三步:z_prep_c() — C 运行时环境准备
源码位置:zephyr/arch/arm/core/cortex_m/prep_c.c
进入 C 语言世界前,需要建立 C 程序的基本假设:.bss 清零、.data 拷贝、中断控制器就绪。
1 | z_prep_c() |
5.1 relocate_vector_table() — 向量表重定位
1 | void __weak relocate_vector_table(void) |
__weak 的含义:弱符号——SoC 可以提供一个强符号版本来覆盖此函数。
VTOR 寄存器(0xE000ED08):
1 | bit[31:7] = 向量表基地址(0x08000000 对齐到 128 字节) |
5.2 z_arm_floating_point_init() — FPU 初始化
STM32H7 有双精度浮点单元(FPU),需要配置 CPACR 和 FPCCR 两个寄存器:
1 | static inline void z_arm_floating_point_init(void) |
延迟保存(Lazy Stacking)的意义:
Cortex-M7 FPU 有 S0-S31 共 32 个单精度寄存器和 1 个 FPSCR。但硬件异常入口只保存其中一半:
1 | 硬件自动保存: S0-S15 + FPSCR + reserved = 18 words (72 字节) |
5.3 arch_bss_zero() — .bss 段清零
源码位置:zephyr/arch/common/init.c(50-81 行)
1 | __boot_func void arch_bss_zero(void) |
__bss_start 和 __bss_end 是链接脚本提供的符号,标记 .bss 段的起止地址。
5.4 arch_data_copy() — .data 段拷贝
1 | void arch_data_copy(void) |
涉及的符号(全部由链接脚本定义):
| 符号 | 含义 |
|---|---|
__data_region_start |
RAM 中 .data 段的起始地址(目标) |
__data_region_load_start |
Flash 中 .data 段初始值的起始地址(源) |
__data_region_end |
RAM 中 .data 段的结束地址 |
1 | Flash RAM |
5.5 z_arm_interrupt_init() — NVIC 优先级初始化
1 | void z_arm_interrupt_init(void) |
效果:所有 150 个外设中断的默认优先级设为 0x10(第二高优先级)。驱动初始化时可通过 IRQ_CONNECT() 覆盖。
6. 第四步:z_cstart() — 内核核心初始化
源码位置:zephyr/kernel/init.c(538-610 行)
这是 Zephyr 内核真正的”启动管理器”,负责按优先级执行所有初始化。
1 | z_cstart() ← kernel/init.c:538 |
6.1 gcov_static_init() — 代码覆盖率初始化
1 | /* init.c:541 */ |
遍历 GCC 的 __init_array 段(存放全局构造函数指针),逐个调用。仅在启用 CONFIG_COVERAGE 时有实际代码,正常构建中是空内联函数,编译器直接优化掉,零开销。
6.2 z_sys_init_run_level() — 初始化等级执行引擎
这是 z_cstart 中被调用多次的核心函数,也是理解 Zephyr 驱动初始化秩序的关键。
6.2.1 init_entry 结构体与两条注册路径
1 | // zephyr/include/zephyr/init.h |
两个字段互斥:要么 init_fn != NULL(纯函数),要么 dev != NULL(设备驱动)。
路径一:设备驱动 — DEVICE_DT_DEFINE()
每个 DTS 中 status = "okay" 的设备节点,驱动用此宏注册:
1 | // 驱动源码示例:zephyr/drivers/interrupt_controller/intc_exti_stm32.c |
路径二:纯函数 — SYS_INIT()
不需要设备结构体的初始化,直接注册函数:
1 | // 驱动源码示例:zephyr/drivers/console/uart_console.c |
6.2.2 核心机制:编译时排序 + 运行时遍历
1 | 源码阶段 链接阶段 运行时 |
运行时只是简单的数组遍历,所有排序逻辑在编译/链接时已完成。
6.2.3 链接脚本排序原理
段名 .z_init_{LEVEL}_P_{PRIO}_SUB_{SUB}_ 中的数字决定了排序:
1 | .z_init_PRE_KERNEL_1_P_0_SUB_0_ ← RCC 时钟(优先级 0) |
链接脚本用 SORT 按段名字母顺序排列。因为段名中嵌入了等级和优先级数字,排序后的物理内存布局自然就是正确的执行顺序。
6.2.4 运行时执行
1 | static void z_sys_init_run_level(enum init_level level) |
运行时逻辑极简:根据等级找到对应的数组区间,逐个调用。区分设备驱动和纯函数两条路径。
6.2.5 STM32H7 的典型初始化序列
以 STM32H747I-DISCO 板为例,通过 nm build/zephyr/zephyr.elf | grep __init_ | sort 可以看到各等级的初始化内容:
EARLY:通常为空(极少需要如此早期的初始化)
PRE_KERNEL_1:基础硬件
| 设备 | 说明 |
|---|---|
| RCC 时钟控制器 | 所有外设的时钟源,必须最先初始化 |
| STM32 时钟配置 | 配置 PLL、系统时钟树 |
| 内存 slab | 内核内存管理基础设施 |
| Reset Controller | 硬件复位控制 |
| GPIOA/B/C/… | 引脚控制器(I2C/SPI/UART 等外设需要 GPIO) |
| EXTI 中断控制器 | 外部中断路由 |
| DMA | 直接内存访问 |
| EXTI GPIO 中断 | GPIO 到 EXTI 的中断映射 |
| UART 串口 | 调试串口 |
| 串口控制台 | printk 输出从此开始可用 |
PRE_KERNEL_2:系统时钟
| 设备 | 说明 |
|---|---|
| sys_clock_driver | 系统节拍定时器(Cortex-M SysTick 或 STM32 TIM) |
POST_KERNEL:外设驱动
| 设备 | 说明 |
|---|---|
| 日志后端 | 日志输出初始化 |
| 系统工作队列 | 内核异步工作队列 |
| SPI/I2C/USB 等总线驱动 | 具体外设取决于板级配置 |
| 磁盘/Flash | 存储设备 |
依赖关系示例:
1 | RCC 时钟 (prio 0) |
RCC 必须最先:所有外设寄存器需要时钟才能响应。UART 在控制台之前:控制台依赖 UART 驱动就绪。
日志”消失”的原因:PRE_KERNEL_1 阶段初期,串口控制台还没初始化,printk() 输出被丢弃。串口控制台初始化之后,printk() 才真正输出到串口。
6.3 arch_kernel_init() — ARM Cortex-M 架构初始化
1 | static ALWAYS_INLINE void arch_kernel_init(void) |
| 步骤 | 函数 | 做了什么 | 为什么 |
|---|---|---|---|
| 1 | z_arm_interrupt_stack_setup() |
MSP = interrupt_stack 栈顶 | 让中断处理使用专用栈(与线程栈分离) |
| 2 | z_arm_exc_setup() |
设置 PendSV=0xFF(最低优先级),SVC=0x00;设置 Fault 优先级(MemManage/BusFault/UsageFault=0);SHCSR 启用这三个 Fault | PendSV 用于上下文切换必须在最低优先级;Fault 必须能捕获启动阶段的错误 |
| 3 | z_arm_fault_init() |
CCR 寄存器:启用 DIV_0_TRP(除零陷阱);配置 UNALIGN_TRP(非对齐访问) | 让 CPU 捕获除零和非对齐等编程错误 |
| 4 | z_arm_cpu_idle_init() |
SCB->SCR = SCB_SCR_SEVONPEND_Msk(赋值,非 OR) |
中断挂起时产生事件,唤醒 WFE 等待的 CPU |
| 5 | z_arm_clear_faults() |
清除 CFSR、HFSR 等 Fault 状态寄存器(写 1 清除) | 防止启动阶段的残余 Fault 标志影响运行时 |
| 6-7 | MPU 初始化 | 配置 MPU 区域(Flash、RAM、外设等)(#ifdef CONFIG_ARM_MPU) |
内存保护 |
| 8 | soc_per_core_init_hook() |
SoC 每核初始化钩子(可选,默认空操作) | 多核场景下每个核的独立初始化 |
6.4 LOG_CORE_INIT() — 日志子系统核心初始化
初始化日志缓冲区、默认时间戳函数、运行时过滤级别、并遍历初始化所有后端(UART、RTT 等)。放在 arch_kernel_init() 之后(需要硬件计数器支持时间戳)、PRE_KERNEL_1 之前(驱动初始化过程中需要用 LOG_INF() 等宏)。
6.5 z_dummy_thread_init() — 假线程占位
内核中大量代码通过 _current(当前线程指针)获取上下文信息。此时调度器尚未就绪,没有真正的线程,但 _current 不能为 NULL(解引用会触发 HardFault)。假线程将 _current 指向一个标记为 _THREAD_DUMMY 的结构体,作为安全占位指针。
假线程从 z_cstart() 创建,到 switch_to_main_thread() 首次上下文切换后丢弃,永远不会被调度。详见第 7.3 节。
6.6 z_device_state_init() — 设备对象初始化
1 | static void z_device_state_init(void) |
遍历所有静态定义的 device 结构体(由驱动 DEVICE_DT_DEFINE() 宏创建),标记为已注册。在驱动实际初始化(PRE_KERNEL_1 阶段)之前完成,确保 device_is_ready() 等函数可用。
6.7 soc_early_init_hook() — STM32H7 SoC 早期初始化
源码位置:zephyr/soc/st/stm32/stm32h7x/soc_m7.c(150-171 行)
调用位置:z_cstart() → init.c:557(在 EARLY 等级之后)
1 | void soc_early_init_hook(void) |
时序约束:必须放在 EARLY 之后、PRE_KERNEL_1 之前。
1 | 时序设计: |
Cache 必须在 .data 拷贝和 .bss 清零之后才能启用,原因如下:
如果 D-Cache 在 .data 拷贝之前就启用:
1 | 1. .data 区域 RAM 中还是上电垃圾值 |
D-Cache 关闭时执行拷贝,CPU 写入直达 RAM(无 Cache 中间层)。完成后启用 D-Cache,Cache 从空开始,只缓存正确的数据。
6.8 board_early_init_hook() — 板级早期初始化
默认行为:空宏(do { } while (0)),需要板级代码定义 CONFIG_BOARD_EARLY_INIT_HOOK 并实现函数才会生效。典型用途:配置板级特定的电压调节器、时钟树、GPIO 引脚复用等。
与 soc_early_init_hook 的区别:
| soc_early_init_hook | board_early_init_hook | |
|---|---|---|
| 实现者 | SoC 系列(所有同 SoC 的板共用) | 具体板级(同一 SoC 的不同板可不同) |
| 位置 | soc/st/stm32/stm32h7x/soc_m7.c |
boards/st/stm32h747i_disco/ |
| 典型内容 | Cache、电源方案、Errata | 板级电源芯片、特殊 GPIO 配置 |
6.9 Stack Canary 初始化 — 栈溢出保护
1 | /* init.c:568-574 */ |
启用传递链:prj.conf: CONFIG_STACK_CANARIES=y → CMake → GCC -fstack-protector 编译选项。
机制:编译器在受保护函数的栈帧中插入”金丝雀”值(canary),函数返回前比较栈上副本与全局变量 __stack_chk_guard 中的原件。栈溢出会覆盖栈上副本,比较失败时触发 __stack_chk_fail() → 系统终止。
1 | 栈帧布局(受保护函数): |
为什么 <<= 8? 最低字节变为 \0。攻击者通过 strcpy() 溢出时,payload 中的 \0 会导致字符串截断,无法传递完整的伪造 canary 值来绕过检查。
随机数来源:z_early_rand_get() 优先使用硬件熵源(STM32H7 的 RNG 外设),回退到基于 CPU 周期计数器的伪随机数。
7. 第五步:准备多线程与首次上下文切换
7.1 prepare_multithreading() — 创建 main 线程和 idle 线程
源码位置:zephyr/kernel/init.c(442-473 行)
1 | static char *prepare_multithreading(void) |
执行步骤详解:
| 步骤 | 函数 | 做了什么 |
|---|---|---|
| 1 | z_sched_init() |
初始化调度器内部数据结构(就绪队列、优先级位图) |
| 2 | _kernel.ready_q.cache = &z_main_thread |
调度缓存预热:缓存不能为 NULL,且此时只有 main 线程,直接填入避免调度器空转 |
| 3 | z_setup_new_thread() |
创建 main 线程:分配栈空间,初始化线程结构体,入口设为 bg_thread_main,优先级为 CONFIG_MAIN_THREAD_PRIORITY,标记 K_ESSENTIAL(线程退出时系统 panic) |
| 4 | z_ready_thread() |
将 main 线程加入就绪队列 |
| 5 | z_init_cpu(0) |
初始化 CPU 0 数据结构:创建 idle 线程(init_idle_thread(0))、设置 CPU 中断栈、CPU ID 映射 |
idle 线程的创建藏在 z_init_cpu(0) → init_idle_thread(0) 中:
1 | static void init_idle_thread(int i) |
idle 线程优先级 K_IDLE_PRIO 是最低优先级,当没有其他线程就绪时运行,执行 WFI 等待中断。
7.2 switch_to_main_thread() — 首次上下文切换
源码位置:zephyr/kernel/init.c(476-490 行)
1 | static FUNC_NORETURN void switch_to_main_thread(char *stack_ptr) |
两条路径:
1 | switch_to_main_thread(stack_ptr) |
通用路径 z_swap_unlocked() 会保存假线程的状态再丢弃,浪费一次上下文保存。ARM Cortex-M 有专门的优化路径,完全绕过这个无意义操作。
7.2.1 arch_switch_to_main_thread() 完整流程
源码位置:arch/arm/core/cortex_m/thread.c(564-656 行)
1 | void arch_switch_to_main_thread(struct k_thread *main_thread, char *stack_ptr, |
7.2.2 逐步详解
**① z_arm_prepare_switch_to_main()**:清零 FPSCR(浮点状态控制寄存器),清除 CONTROL.FPCA 位。确保没有残留的浮点标志影响后续线程。
**② z_current_thread_set(main_thread)**:将 _current 从假线程切换到 main 线程。从此刻起,_current 指向 z_main_thread。
③ TLS 指针初始化:z_arm_tls_ptr = main_thread->tls。这是线程本地存储的关键一步。
TLS 知识点详解
什么是 TLS? 用 __thread 关键字修饰的变量,每个线程有独立的副本:
1 | static __thread int counter; // 每个线程有自己的 counter |
ARM Cortex-M 的困境:ARM Cortex-A 有专用寄存器 TPIDRURO 存放 TLS 基地址。Cortex-M 没有这个寄存器,Zephyr 用全局变量 z_arm_tls_ptr 替代。
1 | Cortex-A(硬件支持): Cortex-M(软件模拟): |
编译器遇到 __thread 变量时,调用 __aeabi_read_tp()(ARM EABI 标准函数)获取 TLS 基地址,再加偏移量访问变量。每个线程的 TLS 区域在线程栈顶分配。
为什么必须手动设置? 正常上下文切换由 PendSV 汇编自动更新 z_arm_tls_ptr。但 arch_switch_to_main_thread() 绕过了 PendSV 路径,如果不手动设置,z_arm_tls_ptr 为 0,main 线程中任何 __thread 变量访问都会解引用地址 0 → HardFault。
④ MPU 栈保护:为 main 线程配置 MPU 栈守卫区域。在线程栈底前方(低地址方向)放一个 guard 区域,设为特权只读。栈溢出时 PUSH 写入 guard → MPU 拦截 → MemManage 异常。
1 | 高地址 |
Guard 区域的 MPU 配置源码:
1 | // arch/arm/core/mpu/arm_core_mpu.c |
Guard 为什么是 64 字节?
Guard 的大小不是随意选择的,它要保证溢出被可靠检测。核心约束:
1 | Guard 大小 ≥ 单次最大 PUSH + 异常帧大小 |
用 32 字节 guard 和 64 字节 guard 的最坏情况对比(SP 刚好从栈底溢出)。
Cortex-M PUSH 指令的关键行为:PUSH 分两步——① 先减 SP(
SP = SP - 4×N)② 再从新 SP 写入内存。MPU fault 发生在第 ② 步写内存时,此时 SP 已经减完了,不会回滚。
1 | 【32 字节 Guard — 异常帧可能逃逸】 |
1 | 【64 字节 Guard — 异常帧仍在 guard 内】 |
64 字节 guard 的关键:SP 先减进入 guard 后,异常帧 push(32 字节)仍然落在 guard 范围内。Guard 大小 = 单次最大 PUSH(32B) + 异常帧(32B) = 64 字节,缺一不可。
MPU 栈保护的局限:只能防渐进式溢出(小 PUSH 一步步进入 guard),防不住跳跃式溢出——int buf[1000] 编译器生成 SUB SP, SP, #4000,只改 SP 寄存器不访问内存,MPU 完全无感,SP 直接跳过 guard。
| 保护方式 | 机制 | 防跳跃溢出 | STM32H7 |
|---|---|---|---|
| MPU guard | 检查内存访问地址 | 不能 | 支持 |
| PSPLIM 硬件寄存器 | 检查 SP 值 | 能 | 不支持(ARMv8-M) |
-fstack-clash-protection |
编译器插入探测 | 缓解 | Zephyr 未启用 |
⑤ PSPLIM(ARMv8-M):直接检查 SP 值,SP < PSPLIM 立即触发异常。STM32H7 的 Cortex-M7 是 ARMv7E-M,没有此寄存器,所以只能依赖 MPU guard。
7.2.3 寄存器状态追溯:从上电到内联汇编执行前
以下通过源码追溯验证了每个阶段 PSP/MSP/CONTROL/BASEPRI 的变化。
1 | 阶段 0:硬件复位 |
执行内联汇编前的最终状态:
1 | MSP = z_interrupt_stacks[0] 栈顶(异常 handler 用) |
纠正常见误解:所有内核初始化代码(z_prep_c、z_cstart、所有初始化函数)都运行在 PSP 上,不是 MSP。MSP 从 z_arm_interrupt_stack_setup() 后就留给异常/中断 handler 专用。
7.2.4 内联汇编逐条指令解析
1 | /* 第 1 步:保存 _main 到 callee-saved 寄存器 */ |
GCC 内联汇编占位符 %0 对应输入约束 "r"(_main)。保存到 r4 因为后面调用 arch_irq_unlock 会覆盖 r0-r3(caller-saved),而 r4-r11 是 callee-saved——被调用函数必须保存恢复,调用返回后值不变。_main 在函数调用之后还要用,必须放在 callee-saved 寄存器中。
1 | /* 第 2 步:切换活跃栈(中断栈 → main 线程栈) */ |
直接改变活跃栈指针! 由于 CONTROL.SPSEL = 1,PSP 就是当前活跃栈。msr PSP, stack_ptr 执行后,所有后续的 PUSH/CALL 立刻使用 main 线程的栈。必须在开中断之前完成——否则 PendSV 可能在 PSP 还指向中断栈时触发,异常帧压错位置。
1 | /* 第 3 步:准备开中断参数 */ |
blx r3 跳转并保存返回地址到 lr。但 arch_switch_to_main_thread 标记 FUNC_NORETURN,lr 的值毫无意义——注释说 “LR is lost which is fine”。r4 中的 bg_thread_main 地址在函数调用后仍然保留(callee-saved)。
1 | /* 第 6 步:准备 z_thread_entry 参数 */ |
bx(Branch and Exchange)与 blx 的关键区别:不保存返回地址到 lr。这是一次单向跳转:
- lr 已被
arch_irq_unlock调用覆盖,是垃圾值 arch_switch_to_main_thread标记FUNC_NORETURNz_thread_entry本身也从不返回
7.2.5 完整执行流程图
1 | 执行前: |
7.2.5.1 z_thread_entry — 线程的真正入口
main 线程的真正执行从 z_thread_entry 内部开始。它不是架构相关的汇编代码,而是一个通用的 C 函数(lib/os/thread_entry.c),所有架构、所有线程都经过它:
1 | // lib/os/thread_entry.c |
为什么需要这个包装层? 直接让 bx 跳转到 bg_thread_main 不行吗?不行,原因有三个:
线程安全终止:如果线程入口函数(如
main())返回,不能让执行流”掉出”到随机地址。z_thread_entry在entry()返回后调用k_thread_abort()清理线程资源。TLS 初始化:每个线程创建后需要初始化自己的 TLS 变量(如
z_tls_current)和线程独立的 stack canary 值。这必须在entry()调用之前完成。CFI 标记:
ARCH_CFI_UNDEFINED_RETURN_ADDRESS()告诉调试器”此函数没有有效的返回地址”,防止 backtrace 工具回溯到垃圾地址导致 HardFault。
调用链全景:
1 | arch_switch_to_main_thread() 内联汇编 |
7.2.6 栈使用时间线
1 | 硬件复位 |
7.2.7 为什么必须用内联汇编而不是 C 代码?
栈切换:
msr PSP, stack_ptr切换栈指针。C 函数的局部变量和返回地址在旧栈上,切换后旧栈虽仍在内存但语义上已”不属于当前线程”。汇编精确控制哪些值在寄存器中(r4 保存_main),不依赖栈。不返回:
bx r4没有”返回地址”(不是blx),旧栈上的内容被丢弃。C 函数必须有返回路径,无法表达”永不返回”的跳转。中断时序:
msr PSP改变活跃栈指针,必须在开中断之前完成。如果先开中断,PendSV 可能在 PSP 还指向中断栈时触发 → 异常帧压错位置 → 上下文混乱。汇编精确控制msr PSP在blx arch_irq_unlock之前。
7.3 假线程存在的意义
内核中大量代码通过 _current(当前线程指针)来获取上下文信息。无假线程时 _current 为 NULL,任何解引用都会触发 HardFault。
假线程的状态 _THREAD_DUMMY 确保它永远不会被调度,只是一个安全的占位指针。
内核中引用 _current 的典型场景:
| 场景 | 代码 | 无假线程会怎样 |
|---|---|---|
| 调度器加锁 | _current->base.sched_locked++ |
NULL 解引用 → HardFault |
| 中断处理 | z_get_next_ready_thread() 依赖当前线程上下文 |
优先级比较失败 |
| 内核对象权限(USERSPACE) | k_object_access_grant(obj, _current) |
授权给 NULL |
8. 第六步:bg_thread_main() — 应用初始化的最后一步
源码位置:zephyr/kernel/init.c(283-359 行)
bg_thread_main() 在首次上下文切换后运行于 main 线程中,完成剩余初始化并调用 main()。
1 | static void bg_thread_main(void *unused1, void *unused2, void *unused3) |
POST_KERNEL 阶段初始化的设备(STM32H747I-DISCO 典型序列):
| 设备 | 说明 |
|---|---|
| FMC/SDRAM 控制器 | 外部内存 |
| 日志后端 | 日志输出到串口/RTT |
| malloc 堆 | 动态内存分配 |
| SPI/QSPI Flash | 外部存储 |
| I2C | 总线驱动 |
| USB OTG | USB 设备/主机 |
| 磁盘/SDMMC | SD 卡/Flash 存储 |
| FAT 文件系统 | 文件系统挂载 |
**soc_late_init_hook() vs soc_early_init_hook()**:early 在 PRE_KERNEL_1 之前(Cache 启用、电源配置),late 在 POST_KERNEL 之后(此时所有驱动已就绪,可执行依赖驱动的 SoC 级操作)。
**boot_banner()**:打印启动信息(如 *** Booting Zephyr OS build xxx ***),出现在串口上的第一行内核输出。
**z_init_static_threads()**:初始化用户代码中通过 K_THREAD_DEFINE() 定义的静态线程。这些线程在 main() 之前就绪,main() 返回后调度器继续运行它们。
至此,main() 执行时,所有初始化等级均已完成。
9. 总结
回顾整个启动流程,从硬件复位到 main() 的数据与控制传递:
flowchart TD
subgraph 硬件["硬件"]
direction LR
A["CPU 复位"] --> B["向量表 0x08000000"] --> C["MSP + Reset_Handler"]
end
subgraph reset["reset.S"]
direction LR
D["z_arm_reset()"] --> E["soc_reset_hook()"] --> F["init_arch_hw()"] --> G["BASEPRI=0x10"] --> H["切换 PSP"]
end
subgraph prep["prep_c.c"]
direction LR
I["z_prep_c()"] --> J["VTOR"] --> K["清 .bss"] --> L["拷 .data"] --> M["NVIC 初始化"]
end
subgraph kernel["kernel/init.c"]
direction LR
N["z_cstart()"] --> O["Cache"] --> P["PRE_KERNEL_1"] --> Q["PRE_KERNEL_2"] --> R["canary"] --> S["多线程准备"] --> T["首次切换"]
end
subgraph app["应用"]
direction LR
U["z_thread_entry()"] --> V["bg_thread_main()"] --> W["POST_KERNEL"] --> X["main()"]
end
硬件 --> reset --> prep --> kernel --> app
style 硬件 fill:#e3f2fd,stroke:#1565c0,color:#000
style reset fill:#fff3e0,stroke:#ef6c00,color:#000
style prep fill:#e8f5e9,stroke:#2e7d32,color:#000
style kernel fill:#fce4ec,stroke:#c62828,color:#000
style app fill:#e1f5fe,stroke:#0277bd,color:#000
五个关键要点:
硬件向量表地址由 Option Bytes + BOOT 引脚决定:BCM7_ADD0 指定默认启动地址
0x08000000,这个值通过 DTS 的chosen→ Kconfig 的dt_chosen_reg_addr_hex()→ 链接脚本的#include宏,最终成为链接脚本中的__rom_region_start。reset.S 是一段精密的汇编初始化序列:CONTROL 重置 → MSP 设置 → TCM 清零 → MPU 禁用 → NVIC/Cache 初始化 → BASEPRI 屏蔽普通中断 → PSP 切换。每一步都有明确的硬件目的。
Cache 必须在 .data/.bss 初始化之后才能启用:D-Cache 关闭时写操作直达 RAM(最简单可预测)。启用 Cache 后再初始化内存,会导致 Cache 和 RAM 内容不一致。
初始化顺序由链接脚本的段名决定,而非代码顺序:
.z_init_{LEVEL}_P_{PRIO}_格式的段名在链接时按字母排序,保证编译产物中的初始化数组天然有序。首次上下文切换前需要假线程:
_current在内核中广泛使用,解引用 NULL 会触发 HardFault。假线程是一个安全的占位指针,确保在内核初始化完成前所有代码都能安全访问_current。
参考源码(基于 Zephyr 主线):
zephyr/arch/arm/core/cortex_m/reset.S— 复位向量汇编zephyr/arch/arm/core/cortex_m/prep_c.c— C 运行时准备zephyr/kernel/init.c— 内核初始化入口zephyr/arch/arm/core/cortex_m/scb.c— 架构硬件初始化zephyr/soc/st/stm32/stm32h7x/soc_m7.c— STM32H7 SoC 钩子zephyr/arch/arm/core/cortex_m/thread.c— 上下文切换实现