4. 多核驱动开发基础:多核编程模型、共享内存、原子操作、自旋锁、信号量
多核开发,说白了就是让几个脑子同时干活。听起来简单,做起来坑不少。我刚开始接触多核时,总觉得把代码拆成几份跑就完事了,结果调试到怀疑人生。今天咱们就把这些基础概念掰开揉碎,看看在 Renode 里怎么玩转多核驱动。
4.1 多核编程模型:AMP 与 SMP
多核编程主要有两种模型,你得先搞清楚自己在用哪一种。
| 模型 | 特点 | 典型场景 |
|---|---|---|
| AMP(非对称多处理) | 每个核跑独立 OS 或裸机程序,各自为政 | 一个核跑 RTOS,另一个核跑 Linux |
| SMP(对称多处理) | 所有核共享同一个 OS,共同调度任务 | 多核 Linux、FreeRTOS SMP 模式 |
我个人习惯在 Renode 里先用 AMP 模型做原型验证。为什么?因为每个核独立调试,出问题容易定位。你想想看,如果两个核共享内存还共享中断,一旦崩了,你根本不知道是谁先动的手。
4.2 共享内存:多核通信的基石
多核之间怎么传数据?最直接的方式就是共享内存。两个核都能读写同一块物理地址,数据就传过去了。
但这里有个关键问题:缓存一致性。我记得有一次调试,核 A 写了一个标志位,核 B 死活读不到新值。查了半天,原来是核 A 的数据还在自己的 L1 Cache 里,根本没刷回主存。
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 本章知识结构图
下面这张图把多核编程的核心概念串起来了。你可以看到,原子操作在最底层,自旋锁和信号量建立在它之上,而共享内存是它们共同的操作对象。
4.7 在 Renode 中验证多核同步
理论说完了,咱们在 Renode 里跑一下。假设你有一个双核 Cortex-M4 的仿真平台,想验证自旋锁是否正常工作。
- 启动 Renode 并加载平台:
machine LoadPlatform @platforms/cpus/cortex-m4-dual.repl - 加载两个核的固件:
sysbus.cpu0 LoadELF @path/to/core0.elf和sysbus.cpu1 LoadELF @path/to/core1.elf - 设置日志级别:
logLevel -1 sysbus.cpu0打开 CPU0 的详细日志 - 运行并观察:
start然后看两个核是否在自旋锁处正确同步
sysbus.cpu0 ExecutionMode SingleStep 让一个核单步执行,另一个核全速跑。这样能清晰看到锁的获取和释放过程。
我曾经用这个方法抓到一个隐蔽的 bug:核 A 释放锁后,核 B 要过好几个指令周期才能看到锁的变化。原因是缓存没刷。加上 DMB 指令后,问题立刻消失。你看,理论知识和实际调试是分不开的。