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() 后,主核开始搭建内核的“骨架”。这个阶段做的事情非常多,我挑几个关键的说说:
- 初始化中断控制器——没有中断,多核通信就无从谈起。
- 初始化调度器——虽然现在只有一个核在跑,但调度器的数据结构得先准备好。
- 初始化内存管理——包括堆、内存池、 slab 等。
- 初始化静态线程和对象——所有在编译时定义的线程、信号量、队列等,都会被注册到内核中。
我在项目中遇到过一个问题:主核在初始化某个外设驱动时,因为依赖了一个还没初始化的内核对象,导致系统直接挂掉。排查了半天才发现是初始化顺序的问题。所以,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()。这个函数做的事情比主核少得多:
- 设置本地栈——每个从核有自己的栈,不能跟主核共用。
- 初始化本地中断控制器——比如 GIC 的 CPU 接口。
- 初始化本地定时器——每个核都需要自己的 tick 源。
- 初始化调度器本地数据——比如当前线程指针、就绪队列等。
- 标记自己“就绪”——告诉主核:我准备好了。
- 进入空闲循环或第一个线程——开始参与调度。
说白了,从核只初始化跟自己相关的部分,全局的东西它不管。这也是 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 调度器——它是怎么决定哪个线程跑在哪个核上的。