第四章 核间通信机制(一):共享内存原理、内存属性单元(MPU)配置、Cache一致性基础
各位同学,欢迎来到多核开发的第一个硬骨头——核间通信。说实话,很多工程师一听到「多核」就头大,其实核心问题就三个:怎么把数据传过去、怎么保证数据不出错、怎么让数据跑得快。今天这一讲,我们先啃下共享内存这块基石。
4.1 共享内存:最朴素的核间通信方式
共享内存的原理,说白了就是让两个核都能访问同一块物理内存。你想想看,Core0往地址0x20000000写了个数,Core1从同一个地址读出来,这不就通信了吗?
我在项目中遇到过这样一个场景:一个核负责采集传感器数据,另一个核负责做控制算法。数据量很大,用消息传递的方式根本扛不住。最后就是用共享内存,几十KB的数据瞬间就过去了。
核心要点:共享内存不是简单的「共用一块内存」,而是要解决三个问题——谁写谁读、什么时候写什么时候读、写完了怎么通知对方。
S32K3的共享内存通常放在SRAM中。我个人习惯把共享内存区域放在SRAM的高地址段,这样不容易和堆栈空间冲突。举个例子:
/* 共享内存区域定义 */
#define SHM_BASE_ADDR 0x20400000u
#define SHM_SIZE 0x1000u /* 4KB共享内存 */
/* 共享数据结构 */
typedef struct {
uint32_t flag; /* 数据就绪标志 */
uint32_t data[256]; /* 实际数据 */
uint32_t checksum; /* 校验和 */
} shm_data_t;
/* 注意:这个结构体必须放在共享内存区域 */
__attribute__((section(".shm_section"))) shm_data_t g_shm_data;
嗯,这里要注意。共享内存的结构体定义,一定要保证两个核看到的布局完全一致。我吃过这个亏——有一次Core0用packed结构体,Core1没用,结果数据全对不上。
4.2 内存属性单元(MPU)配置:给共享内存上把锁
共享内存不是「裸奔」的。你想想看,如果Core0在写数据的时候,Core1突然读走了半成品,那不就乱套了?这时候就需要MPU出场了。
MPU的作用,说白了就是给每个核设置「访问权限」。比如Core0可以读写共享内存,Core1只能读不能写。或者反过来。
我的经验:在S32K3上配置MPU,我建议遵循「最小权限原则」。哪个核需要写,就只给那个核写权限。其他核一律只读。这样可以避免很多诡异的bug。
S32K3的MPU配置其实不复杂,关键是要理解它的层级结构。每个核有自己的MPU,可以配置最多16个区域。配置代码大致长这样:
/* 配置Core0的MPU,允许读写共享内存 */
void MPU_Config_Core0(void)
{
/* 区域0:共享内存,全权限 */
MPU->RNR = 0u; /* 选择区域0 */
MPU->RBAR = SHM_BASE_ADDR | /* 基地址 */
(0x1u << 4u); /* 有效位 */
MPU->RASR = (0x3u << 24u) | /* 全权限:读+写 */
(0x1u << 18u) | /* 可执行 */
(0x1u << 17u) | /* 可共享 */
(0x1u << 16u) | /* 可缓存 */
(0x1Fu << 1u); /* 区域大小:4KB */
}
/* 配置Core1的MPU,只允许读共享内存 */
void MPU_Config_Core1(void)
{
MPU->RNR = 0u;
MPU->RBAR = SHM_BASE_ADDR | (0x1u << 4u);
MPU->RASR = (0x2u << 24u) | /* 只读权限 */
(0x1u << 18u) |
(0x1u << 17u) |
(0x1u << 16u) |
(0x1Fu << 1u);
}
警告:我曾经在项目中发现,MPU配置的顺序很重要。如果你先使能了MPU再配置区域,系统可能会立即触发异常。正确的做法是:先配置好所有区域,最后再使能MPU。
4.3 Cache一致性基础:为什么数据会「不新鲜」?
好,现在共享内存有了,MPU也配好了。但还有一个坑等着你——Cache一致性。
为什么会这样?因为现代处理器都有Cache。Core0写数据到共享内存,实际上可能先写到了自己的Cache里,还没刷到主存。Core1去读的时候,读到的还是旧数据。
我刚开始做多核开发时,就被这个问题折磨了整整一周。明明Core0说写完了,Core1读到的却是垃圾数据。后来才发现是Cache在捣鬼。
S32K3的Cache一致性处理,主要有三种方式:
| 方式 | 原理 | 适用场景 | 性能影响 |
|---|---|---|---|
| 软件刷Cache | 手动调用指令刷Cache | 小数据量、低频通信 | 中等 |
| 配置为非缓存区域 | MPU中设置该区域不可缓存 | 实时性要求高的场景 | 较低(直接访问主存) |
| 硬件一致性协议 | S32K3内部硬件维护一致性 | 大数据量、高频通信 | 高(硬件自动处理) |
我个人最常用的方式是第二种——把共享内存区域配置为不可缓存。虽然性能会受一点影响,但胜在简单可靠。代码实现就是在MPU配置中把缓存位清零:
/* 配置共享内存为不可缓存区域 */
MPU->RASR = (0x3u << 24u) | /* 全权限 */
(0x1u << 18u) | /* 可执行 */
(0x1u << 17u) | /* 可共享 */
(0x0u << 16u) | /* 不可缓存!关键点 */
(0x1Fu << 1u); /* 4KB */
避坑指南:如果你非要用Cache不可,记得在写数据后调用数据同步屏障指令(DSB)和指令同步屏障指令(ISB)。我曾经因为忘了加DSB,导致Core0写的数据在Core1那边延迟了整整3个时钟周期才看到。
4.4 实战经验:一个完整的共享内存通信流程
说了这么多理论,我们来走一遍实际流程。假设Core0是生产者,Core1是消费者:
- 初始化阶段:两个核各自配置MPU,Core0读写权限,Core1只读权限。
- 数据准备:Core0把共享内存区域配置为不可缓存,或者手动刷Cache。
- 写数据:Core0往共享内存写数据,写完设置标志位。
- 通知对方:Core0通过中断或轮询方式通知Core1数据就绪。
- 读数据:Core1读取共享内存,先检查标志位,再读数据。
- 确认完成:Core1读完数据后,清除标志位,通知Core0可以写下一批。
嗯,这里有个细节。标志位的操作一定要用原子操作。S32K3提供了LDREX/STREX指令来实现原子读写:
/* Core0:原子写标志位 */
__asm volatile(
"MOV r0, #1\n\t"
"LDREX r1, [%0]\n\t" /* 独占加载 */
"STREX r2, r0, [%0]\n\t" /* 独占存储 */
"CMP r2, #0\n\t"
"BNE retry\n\t" /* 如果失败,重试 */
:
: "r" (&g_shm_data.flag)
: "r0", "r1", "r2"
);
说实话,原子操作这块我建议直接用S32K3 SDK提供的API,比自己手写汇编要安全得多。SDK里封装好了各种原子操作函数,直接用就行。
好了,这一讲的内容就到这里。共享内存是核间通信的基础,MPU是保护机制,Cache一致性是性能关键。下一讲我们会深入讨论更高级的核间通信机制——消息队列和硬件信号量。到时候见。