3. Zephyr 内核初始化:多核启动流程、主核与从核的初始化顺序

好,咱们接着聊多核。上一章我们把多核架构的硬件基础讲清楚了,这一章咱们聚焦在软件层面——Zephyr 到底是怎么把多个核“唤醒”并组织起来的。

说实话,多核启动这块,我第一次看源码的时候也绕了好一阵。你想想看,一个核跑起来了,另一个核还睡在那里,怎么让它醒?醒了之后它该干嘛?谁先谁后?这些问题,Zephyr 都有一套清晰的流程。

3.1 主核与从核:谁说了算?

在 Zephyr 里,系统上电后,只有一个核会主动执行完整的初始化流程,这个核叫 主核(Primary Core),通常是 CPU 0。其他核一开始都处于休眠或等待状态,它们叫 从核(Secondary Core)

为什么会这样设计?

说白了,就是为了避免多个核同时抢资源、抢外设、抢内存。你想想看,如果两个核同时去初始化串口,那不乱套了?所以 Zephyr 的做法是:主核先把家底儿都收拾好,再挨个儿把从核叫醒

关键点:主核负责全局初始化,从核只做局部初始化。主核初始化完成后,才会释放从核的启动信号。

3.2 主核的启动流程

主核的启动,大致可以分为三个阶段。我习惯把它们叫做:“光杆司令期”、“招兵买马期”、“分派任务期”

3.2.1 第一阶段:汇编级初始化

这个阶段,C 语言还没跑起来。主核在复位向量处开始执行,做几件最基础的事:

  • 设置栈指针(SP)
  • 清零 BSS 段
  • 设置异常向量表
  • 初始化 MMU/MPU(如果硬件支持)
  • 跳转到 C 语言的入口函数 z_cstart()

嗯,这里要注意:从核在这个阶段是完全不参与的。它们要么在 WFI(Wait For Interrupt)状态,要么在自旋等待一个特定的内存地址被写入。

3.2.2 第二阶段:内核基础设施初始化

进入 z_cstart() 后,主核开始搭建内核的“骨架”。这个阶段做的事情非常多,我挑几个关键的说说:

  1. 初始化中断控制器——没有中断,多核通信就无从谈起。
  2. 初始化调度器——虽然现在只有一个核在跑,但调度器的数据结构得先准备好。
  3. 初始化内存管理——包括堆、内存池、 slab 等。
  4. 初始化静态线程和对象——所有在编译时定义的线程、信号量、队列等,都会被注册到内核中。

我在项目中遇到过一个问题:主核在初始化某个外设驱动时,因为依赖了一个还没初始化的内核对象,导致系统直接挂掉。排查了半天才发现是初始化顺序的问题。所以,Zephyr 的初始化顺序是严格规定的,千万别乱改

3.2.3 第三阶段:启动从核

主核把家底儿都收拾好了,接下来就该叫醒从核了。这一步,Zephyr 通过 arch_start_cpu() 函数来实现。

// 伪代码示意
void arch_start_cpu(int cpu_id, k_thread_stack_t *stack, int stack_size,
                    void (*fn)(void *), void *arg)
{
    // 1. 设置从核的启动栈
    // 2. 设置从核的入口函数(通常是 z_secondary_cpu_init)
    // 3. 发送 IPI(核间中断)或写特定内存地址
    // 4. 等待从核确认启动完成
}

不同的架构,启动从核的方式不一样:

架构 启动方式 说明
ARM Cortex-A PSCI(Power State Coordination Interface) 通过 SMC 指令调用固件接口
RISC-V CLINT(Core Local Interruptor) 写特定内存地址触发启动
x86 IPI + SIPI 发送两次中断序列
Xtensa 自定义机制 取决于 SoC 实现

我的经验:如果你在移植 Zephyr 到新平台,从核启动这块往往是坑最多的地方。我曾经在一个 RISC-V 平台上折腾了整整两天,最后发现是 CLINT 的地址映射配错了。建议你先用调试器确认从核是否真的收到了启动信号。

3.3 从核的初始化顺序

从核被唤醒后,它不会像主核那样从头开始初始化。它走的是另一条路——轻量级初始化

3.3.1 从核入口:z_secondary_cpu_init()

每个从核的入口函数都是 z_secondary_cpu_init()。这个函数做的事情比主核少得多:

  1. 设置本地栈——每个从核有自己的栈,不能跟主核共用。
  2. 初始化本地中断控制器——比如 GIC 的 CPU 接口。
  3. 初始化本地定时器——每个核都需要自己的 tick 源。
  4. 初始化调度器本地数据——比如当前线程指针、就绪队列等。
  5. 标记自己“就绪”——告诉主核:我准备好了。
  6. 进入空闲循环或第一个线程——开始参与调度。

说白了,从核只初始化跟自己相关的部分,全局的东西它不管。这也是 SMP 系统高效的关键——避免重复劳动

3.3.2 从核的同步点

从核在初始化过程中,有几个关键的同步点:

  • 启动同步:从核必须等待主核发出启动信号。
  • 初始化完成同步:从核完成本地初始化后,通知主核。
  • 调度就绪同步:所有从核都就绪后,主核才允许全局调度开始。

这些同步点,Zephyr 通常用 自旋锁 + 内存屏障 来实现。嗯,这里要特别提醒:千万不要在从核初始化完成之前,让主核开始调度任务到从核上。否则从核可能连栈都没准备好,就被塞了一个线程进来,结果可想而知。

避坑指南:我曾经在一个项目中,因为从核的本地定时器初始化顺序不对,导致从核的 tick 比主核慢了 10 倍。所有基于时间片的调度都乱套了。排查了三天才发现,是定时器的时钟源配置错了。所以,从核的本地定时器初始化,一定要确认时钟源和分频系数跟主核一致。

3.4 多核启动的完整时序图

为了让你更直观地理解整个过程,我画一个时序图(用文字描述):

主核 (CPU 0)                    从核 (CPU 1)                    从核 (CPU 2)
   |                               |                               |
   |-- 汇编初始化                   |                               |
   |-- z_cstart()                  |                               |
   |   |-- 内核基础设施             |                               |
   |   |-- 驱动初始化               |                               |
   |   |-- 创建 idle 线程          |                               |
   |   |-- arch_start_cpu(1) ------+-- 收到启动信号                |
   |   |-- arch_start_cpu(2) ------+-------------------------------+-- 收到启动信号
   |   |                           |-- z_secondary_cpu_init()      |-- z_secondary_cpu_init()
   |   |                           |   |-- 本地栈                  |   |-- 本地栈
   |   |                           |   |-- 本地中断                |   |-- 本地中断
   |   |                           |   |-- 本地定时器              |   |-- 本地定时器
   |   |                           |   |-- 标记就绪                |   |-- 标记就绪
   |   |                           |   |-- 进入调度                |   |-- 进入调度
   |   |-- 等待所有从核就绪         |                               |
   |   |-- 启动全局调度            |                               |
   |   |-- 进入 idle               |                               |
   |                               |                               |

你看,整个过程就像一场精心编排的交响乐。主核是指挥,从核是乐手。指挥先上台,把谱子摆好、乐器调好,然后一个手势,所有乐手同时开始演奏。

3.5 一些值得注意的细节

最后,我再补充几个实际开发中容易踩的坑:

  • 从核的栈大小:从核的启动栈不能太小。我见过有人只给了 256 字节,结果从核在初始化驱动时栈溢出,系统直接死机。建议至少 1KB。
  • 缓存一致性:主核和从核共享的数据结构(比如就绪队列),一定要用缓存一致性协议或者显式 flush。否则一个核改了数据,另一个核看到的还是旧值。
  • 中断亲和性:外设中断默认都发到主核。如果你想让某个中断由从核处理,记得在从核初始化完成后,重新配置中断控制器。
  • 调试输出:多核启动阶段,调试输出要小心。如果两个核同时往串口写数据,输出会乱掉。我习惯在从核初始化完成前,只用主核输出日志。

一个小技巧:在调试多核启动问题时,可以在每个核的入口处加一个 GPIO 翻转。用逻辑分析仪看 GPIO 波形,就能直观地看到每个核的启动时序。这比看日志靠谱多了。

好了,这一章的内容就到这里。多核启动是整个 SMP 系统的基础,把这块搞清楚了,后面的调度、同步、负载均衡才能顺利展开。下一章,我们来聊聊 Zephyr 的 SMP 调度器——它是怎么决定哪个线程跑在哪个核上的。