第四章 核间通信机制(一):共享内存原理、内存属性单元(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是消费者:

  1. 初始化阶段:两个核各自配置MPU,Core0读写权限,Core1只读权限。
  2. 数据准备:Core0把共享内存区域配置为不可缓存,或者手动刷Cache。
  3. 写数据:Core0往共享内存写数据,写完设置标志位。
  4. 通知对方:Core0通过中断或轮询方式通知Core1数据就绪。
  5. 读数据:Core1读取共享内存,先检查标志位,再读数据。
  6. 确认完成: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一致性是性能关键。下一讲我们会深入讨论更高级的核间通信机制——消息队列和硬件信号量。到时候见。