4、多核启动流程:Bootloader设计、多核启动序列、核间同步原语

好,咱们今天聊聊多核启动。这个话题,说实话,是很多刚接触多核开发的朋友第一个头疼的地方。单核启动很简单,上电、跑Bootloader、跳App,完事。但多核呢?多个核心同时跑,谁先谁后?共享资源怎么初始化?搞不好就是死锁、数据错乱。我在项目里见过太多次了,启动阶段出的问题,排查起来特别费劲。

4.1 Bootloader设计:谁来做主?

多核系统的Bootloader,跟单核最大的区别在于——启动责任分配。你想想看,四个核同时从Reset向量开始跑,那不乱套了?

所以,业界通用的做法是:主核(通常是Core0)负责执行Bootloader的全部逻辑,从核(Core1/2/3)则被设计为等待状态。说白了,就是让一个核当老大,其他核先老实待着。

我个人习惯把Bootloader分成两个阶段:

  • 阶段一(硬件初始化):关闭所有中断、配置时钟、初始化RAM、设置堆栈指针。这一步所有核都会跑,但只有主核会继续往下走。
  • 阶段二(应用加载与跳转):校验App镜像、配置MPU、设置中断向量表。这一步只有主核执行。

核心要点:从核在阶段一结束后,必须进入一个自旋等待(Spin Wait)状态,直到主核发来“启动信号”。

我曾经在一个项目里犯过傻——从核在阶段一里也去初始化外设,结果跟主核抢总线,导致时钟配置出错。嗯,从那以后我学乖了,所有共享资源的初始化,必须由主核统一完成

4.2 多核启动序列:一步一步来

启动序列,说白了就是一张时间表。谁在什么时候干什么事,必须清清楚楚。我一般把它分成这么几步:

  1. 硬件复位:所有核同时从Reset向量开始执行。
  2. 核识别:每个核读取自己的核心ID寄存器(比如Infineon TC3xx的CORE_ID)。主核ID为0,其余为从核。
  3. 主核初始化:主核执行Bootloader,配置系统时钟、PLL、Flash等待周期、RAM初始化。
  4. 从核等待:从核执行一个简单的循环,不断检查一个共享内存中的标志位(比如 g_StartFlag)。
  5. 主核释放从核:主核完成初始化后,将 g_StartFlag 置为1,并发送一个核间中断(IPI)唤醒从核。
  6. 从核跳转:从核检测到标志位变化后,跳转到各自的应用程序入口地址。

这里有个细节要注意——从核的启动地址。每个核的App入口地址可能不同。比如Core0跑OS,Core1跑通信协议栈,Core2跑诊断。所以主核在释放从核前,需要把每个核的入口地址写到约定的共享内存位置。

小技巧:我建议在共享内存里放一个结构体,包含每个核的启动标志、入口地址、状态信息。这样调试起来一目了然。

代码示例(伪代码,基于AUTOSAR MCAL层):

/* 主核启动流程 */
void Bootloader_Main(void) {
    uint32 coreId = GetCoreId();
    
    if (coreId == 0) {
        /* 主核:初始化所有共享资源 */
        SystemClock_Init();
        Flash_Init();
        Ram_Init();
        
        /* 配置从核入口地址 */
        g_CoreStartInfo[1].EntryPoint = (uint32)App_Core1_Entry;
        g_CoreStartInfo[2].EntryPoint = (uint32)App_Core2_Entry;
        g_CoreStartInfo[3].EntryPoint = (uint32)App_Core3_Entry;
        
        /* 内存屏障,确保写入完成 */
        MemoryBarrier();
        
        /* 释放从核 */
        for (int i = 1; i < 4; i++) {
            g_CoreStartInfo[i].StartFlag = 1;
            SendIPI(i);  /* 发送核间中断 */
        }
        
        /* 主核跳转到App */
        JumpToApplication(APP_CORE0_ADDR);
    } else {
        /* 从核:等待主核释放 */
        while (g_CoreStartInfo[coreId].StartFlag == 0) {
            WaitForInterrupt();  /* 低功耗等待 */
        }
        JumpToApplication(g_CoreStartInfo[coreId].EntryPoint);
    }
}

4.3 核间同步原语:别抢,排队

启动完了,多核开始并行跑。但问题来了——多个核同时访问共享资源怎么办?比如一个核在写Flash,另一个核在读同一个地址,数据就乱套了。

这就需要核间同步原语。说白了,就是让多个核“商量着来”。常用的原语有四种:

原语 适用场景 性能开销 我踩过的坑
自旋锁(Spinlock) 短时间临界区保护 低(但忙等) 忘了关中断,导致死锁
信号量(Semaphore) 资源计数、任务同步 信号量初始值设错,从核永远拿不到
事件标志(Event) 一对多通知 事件标志被多个核同时清除
内存屏障(Memory Barrier) 保证内存访问顺序 极低 编译器优化把顺序搞乱了

我个人最常用的是自旋锁,因为它简单、直接。但要注意——自旋锁是忙等,如果临界区代码太长,会浪费CPU时间。所以我的原则是:临界区代码不超过10条指令,否则改用信号量

警告:使用自旋锁时,一定要关中断!否则一个核拿着锁被中断打断,另一个核来抢锁,直接死锁。我曾经因为这个bug调了三天,最后发现是中断服务函数里也用了同一个锁。

代码示例(基于ARM Cortex-R5,使用LDREX/STREX指令实现自旋锁):

/* 自旋锁实现 */
void SpinLock_Acquire(volatile uint32 *lock) {
    while (1) {
        /* 尝试原子性地将lock从0改为1 */
        uint32 result = __LDREXW(lock);
        if (result == 0) {
            if (__STREXW(1, lock) == 0) {
                __DMB();  /* 数据内存屏障,保证后续操作在锁之后 */
                return;
            }
        }
        /* 锁被占用,等待 */
        __WFE();  /* 等待事件,降低功耗 */
    }
}

void SpinLock_Release(volatile uint32 *lock) {
    __DMB();  /* 保证之前的操作在释放锁之前完成 */
    *lock = 0;
    __SEV();  /* 发送事件,唤醒等待的核 */
}

你可能会问:为什么用 __WFE() 而不是空转?嗯,这是功耗优化的考虑。在车载MCU上,功耗是有要求的。空转浪费电,用 __WFE() 可以让核心进入低功耗等待状态,直到其他核发来事件才唤醒。

4.4 避坑指南:启动阶段的常见问题

最后,我总结几个我在项目中实际遇到过的坑,希望能帮你少走弯路:

  • 共享内存未初始化:上电后RAM内容是随机的。如果从核在启动标志位还没被主核写入时就读取,可能读到0xFFFFFFFF,误以为可以跳转了。解决办法:主核先把所有启动标志位清零,再开始初始化。
  • 中断向量表冲突:每个核都有自己的中断向量表,但默认可能指向同一个地址。一定要在启动阶段为每个核重新设置VTOR寄存器。
  • Cache一致性问题:如果开启了Cache,主核写了一个共享变量,但从核读到的可能是Cache里的旧值。解决办法:在关键共享变量上使用 volatile 关键字,并手动执行Cache Clean/Invalidate操作。
  • 调试器干扰启动时序:这个我印象特别深。用调试器单步调试时,主核跑得慢,从核可能已经超时了。所以调试多核启动,我建议用硬件断点 + 日志输出,别依赖单步。

好了,多核启动这块就聊到这儿。说白了,核心思想就是一个核做主,其他核等待,用同步原语保证不乱。下一章咱们聊聊多核运行时怎么调度任务,那个更有意思。