4. 多核驱动开发基础:多核编程模型、共享内存、原子操作、自旋锁、信号量

多核开发,说白了就是让几个脑子同时干活。听起来简单,做起来坑不少。我刚开始接触多核时,总觉得把代码拆成几份跑就完事了,结果调试到怀疑人生。今天咱们就把这些基础概念掰开揉碎,看看在 Renode 里怎么玩转多核驱动。

4.1 多核编程模型:AMP 与 SMP

多核编程主要有两种模型,你得先搞清楚自己在用哪一种。

模型 特点 典型场景
AMP(非对称多处理) 每个核跑独立 OS 或裸机程序,各自为政 一个核跑 RTOS,另一个核跑 Linux
SMP(对称多处理) 所有核共享同一个 OS,共同调度任务 多核 Linux、FreeRTOS SMP 模式

我个人习惯在 Renode 里先用 AMP 模型做原型验证。为什么?因为每个核独立调试,出问题容易定位。你想想看,如果两个核共享内存还共享中断,一旦崩了,你根本不知道是谁先动的手。

我的经验:在 Renode 中启动 AMP 模型时,记得给每个核分配独立的入口地址。我曾经犯过一个低级错误——两个核都从 0x0 开始跑,结果它们互相踩代码段,折腾了一下午才发现。

4.2 共享内存:多核通信的基石

多核之间怎么传数据?最直接的方式就是共享内存。两个核都能读写同一块物理地址,数据就传过去了。

但这里有个关键问题:缓存一致性。我记得有一次调试,核 A 写了一个标志位,核 B 死活读不到新值。查了半天,原来是核 A 的数据还在自己的 L1 Cache 里,根本没刷回主存。

避坑指南:共享内存区域一定要配置为非缓存(Non-cacheable)或使用内存屏障指令。在 ARM 上,你可以用 DMB(数据内存屏障)来保证顺序。
// 共享内存示例:两个核通过标志位通信
volatile uint32_t *flag = (uint32_t *)0x20001000;

// 核 A:写数据后置标志
*flag = 1;
__asm volatile("dmb" ::: "memory");  // 内存屏障,确保写操作完成

// 核 B:等待标志
while (*flag != 1) {
    __asm volatile("wfe");  // 等待事件,省电
}
__asm volatile("dmb" ::: "memory");  // 读之前也要屏障

4.3 原子操作:不可分割的指令

共享内存有个天然问题:两个核同时读写同一个变量,数据就乱了。比如核 A 在读,核 B 在写,你读到的可能是半新半旧的值。

原子操作就是解决这个问题的。它保证一条指令执行期间,不会被其他核打断。在 ARM Cortex-M 系列上,常用的原子指令是 LDREX 和 STREX。

// 原子加 1 操作(ARM 汇编风格)
atomic_add:
    ldrex   r1, [r0]      // 独占加载
    add     r1, r1, #1    // 加 1
    strex   r2, r1, [r0]  // 独占存储,成功则 r2=0
    cmp     r2, #0
    bne     atomic_add    // 失败则重试
    bx      lr

嗯,这里要注意:STREX 可能失败。为什么?因为另一个核也在同时操作同一个地址。失败就重试,直到成功为止。这就是所谓的 LL/SC(Load-Link/Store-Conditional)机制。

关键点:原子操作是自旋锁和信号量的底层基础。没有原子操作,上层同步机制全是空中楼阁。

4.4 自旋锁:忙等的代价

自旋锁是最简单的同步机制。一个核想访问共享资源,先尝试获取锁。如果锁被别的核占着,它就原地打转(自旋),直到锁释放。

// 简单的自旋锁实现
typedef struct {
    volatile uint32_t lock;
} spinlock_t;

void spin_lock(spinlock_t *s) {
    while (__atomic_test_and_set(&s->lock, __ATOMIC_ACQUIRE)) {
        // 自旋等待
        __asm volatile("nop");
    }
}

void spin_unlock(spinlock_t *s) {
    __atomic_clear(&s->lock, __ATOMIC_RELEASE);
}
我曾经踩过的坑:在中断服务程序里用自旋锁。如果持有锁的核被中断打断,而中断服务程序又去抢同一个锁——死锁了!记住:中断上下文慎用自旋锁。

自旋锁的优点是实现简单,缺点是浪费 CPU。如果锁被占用的时间很短,自旋还能接受。但如果锁被占很久,其他核就在那空转,白白耗电。这时候就该信号量出场了。

4.5 信号量:让等待的核去睡觉

信号量比自旋锁更智能。如果资源被占,请求的核可以阻塞自己,让出 CPU 给其他任务。等资源释放了,再被唤醒。

特性 自旋锁 信号量
等待方式 忙等(消耗 CPU) 阻塞(让出 CPU)
适用场景 临界区极短 临界区较长或可能阻塞
中断中使用 危险(可能死锁) 不允许(会阻塞)
实现复杂度 低 高(需要调度器支持)
// 信号量伪代码(基于 FreeRTOS 风格)
SemaphoreHandle_t sem = xSemaphoreCreateBinary();

// 任务 A:等待资源
void task_A(void *param) {
    if (xSemaphoreTake(sem, portMAX_DELAY) == pdTRUE) {
        // 访问共享资源
        // ...
        xSemaphoreGive(sem);  // 释放
    }
}

// 任务 B:释放资源
void task_B(void *param) {
    // 准备数据
    // ...
    xSemaphoreGive(sem);  // 通知任务 A
}

在 Renode 里调试信号量时,我建议你打开 中断跟踪 功能。因为信号量的阻塞和唤醒依赖中断机制,看中断触发的时间点,能帮你快速定位调度问题。

4.6 本章知识结构图

下面这张图把多核编程的核心概念串起来了。你可以看到,原子操作在最底层,自旋锁和信号量建立在它之上,而共享内存是它们共同的操作对象。

多核驱动开发基础结构图 多核驱动开发基础结构图 多核编程模型 AMP(非对称多处理) SMP(对称多处理) 共享内存(通信基石) 原子操作(LL/SC 指令) 自旋锁(忙等) 信号量(阻塞等待) 说明:原子操作是底层基础,自旋锁和信号量建立在它之上,共享内存是它们的操作对象

4.7 在 Renode 中验证多核同步

理论说完了,咱们在 Renode 里跑一下。假设你有一个双核 Cortex-M4 的仿真平台,想验证自旋锁是否正常工作。

  1. 启动 Renode 并加载平台:machine LoadPlatform @platforms/cpus/cortex-m4-dual.repl
  2. 加载两个核的固件:sysbus.cpu0 LoadELF @path/to/core0.elf 和 sysbus.cpu1 LoadELF @path/to/core1.elf
  3. 设置日志级别:logLevel -1 sysbus.cpu0 打开 CPU0 的详细日志
  4. 运行并观察:start 然后看两个核是否在自旋锁处正确同步
调试技巧:在 Renode 中,你可以用 sysbus.cpu0 ExecutionMode SingleStep 让一个核单步执行,另一个核全速跑。这样能清晰看到锁的获取和释放过程。

我曾经用这个方法抓到一个隐蔽的 bug:核 A 释放锁后,核 B 要过好几个指令周期才能看到锁的变化。原因是缓存没刷。加上 DMB 指令后,问题立刻消失。你看,理论知识和实际调试是分不开的。