3. 片内通信机制(一):共享内存(Shared Memory)原理、缓存一致性(Cache Coherency)问题

各位同学,今天我们来聊聊多核异构架构里最基础、也最让人头疼的一个话题——共享内存。

说白了,共享内存就是让多个处理器核心(不管是同构的A核,还是异构的R核、M核)都能访问同一块物理内存区域。听起来很简单对吧?但实际做起来,坑多得很。我当年第一次做异构AMP(非对称多处理)项目时,就因为这个机制吃了大亏。

3.1 共享内存的基本原理

共享内存的核心思想就一句话:一块物理地址空间,多个主设备可见。

在异构系统里,通常我们会把DDR或者OCRAM(片上RAM)划分出一块区域,专门用于核间数据交换。每个核通过各自的总线接口,映射到同一段物理地址。

关键点:共享内存本身不提供任何同步机制。它只是一块“公共黑板”,谁都能写,谁都能读。数据一致性、访问互斥,全靠软件自己管。

我习惯把共享内存的通信模型分成三个角色:

  • 生产者(Producer):往共享区写数据的核
  • 消费者(Consumer):从共享区读数据的核
  • 协调者(Coordinator):负责同步通知的机制(通常是中断或信号量)

举个例子,一个典型的异构通信流程是这样的:

  1. A核往共享内存地址0x3000_0000写入一帧数据
  2. A核写完后,通过门铃中断(Doorbell Interrupt)通知R核
  3. R核收到中断,从0x3000_0000读取数据
  4. R核处理完,写回状态标志,再发中断告诉A核

嗯,这里要注意——步骤2和3之间,有一个巨大的陷阱。我们马上讲到。

3.2 缓存一致性(Cache Coherency)问题

为什么会出问题?因为现代处理器都有多级缓存(L1/L2 Cache)。每个核看到的数据,可能不是内存里的真实数据,而是自己缓存里的副本。

你想想看:A核把数据写到了自己的L1 Cache里,还没来得及写回DDR。这时候R核去读共享内存,读到的还是旧数据。这就是经典的缓存一致性问题。

我曾经踩过的坑:在一个车载控制器的项目中,A核(Cortex-A72)和R核(Cortex-R5F)通过共享内存交换控制指令。A核更新了油门开度值,R核读到的却是上一次的值,导致响应延迟了整整3个周期。查了两天才发现,是A核的Cache没有刷写。

缓存不一致的典型场景有三种:

场景 描述 后果
写后读不一致 Core0写Cache,Core1读内存 Core1读到脏数据
读后写不一致 Core0读Cache,Core1写内存 Core0持有过期副本
伪共享(False Sharing) 两个核操作同一Cache Line的不同字节 频繁Cache Miss,性能暴跌

3.3 硬件解决方案:Snoop协议与MESI

对于同构多核(比如4个A72核),硬件会帮你维护缓存一致性。最常见的就是MESI协议(Modified, Exclusive, Shared, Invalid)。

MESI协议给每个Cache Line打了四个状态标签:

  • M(Modified):数据已修改,只在本Cache有效,内存已过时
  • E(Exclusive):数据干净,只在本Cache,内存一致
  • S(Shared):数据干净,多个Cache都有副本
  • I(Invalid):数据无效,需要从内存或其他Cache重新获取

当Core0要写一个地址时,Snoop控制器会广播给所有其他核:“这个地址我要独占啦,你们手里的副本赶紧作废!”其他核收到后,把对应的Cache Line标记为I状态。这就是所谓的Snoop监听机制。

我个人习惯:在同构集群里,尽量信任硬件一致性协议。但如果你发现性能异常,先检查是不是伪共享(False Sharing)——两个核频繁操作同一个Cache Line的不同字段。解决办法很简单:把结构体按Cache Line对齐,或者加padding填充。

3.4 异构系统的特殊挑战

问题来了:异构系统里,A核和R核之间,通常没有硬件Snoop机制。

为什么?因为Snoop协议需要所有核心挂在同一个一致性总线上(比如ACE总线)。而R核(Cortex-R系列)和M核(Cortex-M系列)往往走的是AHB或AXI-lite总线,根本不支持Snoop。

所以,异构共享内存的缓存一致性,必须靠软件手动维护。

具体做法分两步:

  1. 写操作后刷Cache:生产者调用 clean_flush 指令,把脏数据写回内存
  2. 读操作前无效化Cache:消费者调用 invalidate 指令,丢弃本地缓存副本,强制从内存重新加载

代码示例(ARMv8架构,A核侧):

// 生产者(A核):写共享数据后刷Cache
void producer_write(volatile uint32_t *addr, uint32_t val) {
    *addr = val;                    // 写数据
    __asm__ volatile("dsb sy");     // 数据同步屏障,确保写完成
    __asm__ volatile("dc cvac, %0" :: "r"(addr)); // Clean & Flush Cache Line
    __asm__ volatile("dsb sy");     // 再次同步,确保刷写完成
    // 然后发中断通知R核
}

// 消费者(R核):读共享数据前无效化Cache
uint32_t consumer_read(volatile uint32_t *addr) {
    __asm__ volatile("dsb sy");     // 等待所有操作完成
    __asm__ volatile("dc ivac, %0" :: "r"(addr)); // Invalidate Cache Line
    __asm__ volatile("dsb sy");     // 同步
    return *addr;                   // 从内存重新读取
}

注意:不同架构的Cache维护指令不一样。ARM用DC指令,RISC-V用FENCE.I,x86用CLFLUSH。移植代码时一定要查对应架构的手册。我曾经见过一个团队把ARM的刷Cache代码直接搬到RISC-V上,结果数据全乱了。

3.5 共享内存的同步机制

光有Cache维护还不够。你还需要解决互斥访问的问题——两个核同时写共享内存怎么办?

常用的同步原语有:

  • 原子操作(Atomic Operation):ARM的LDREX/STREX,RISC-V的LR/SC
  • 自旋锁(Spinlock):基于原子操作实现的忙等锁
  • 信号量(Semaphore):通常配合中断实现阻塞等待

我个人建议:在异构共享内存中,优先使用无锁(Lock-Free)设计。因为自旋锁在异构系统里容易出问题——一个核死锁了,另一个核永远等不到锁释放。

无锁设计的典型做法是环形缓冲区(Ring Buffer):

// 简单的无锁环形缓冲区(单生产者-单消费者)
struct ring_buffer {
    volatile uint32_t head;  // 生产者写入位置
    volatile uint32_t tail;  // 消费者读取位置
    uint32_t data[BUF_SIZE];
};

// 生产者写入
int rb_push(struct ring_buffer *rb, uint32_t val) {
    uint32_t next = (rb->head + 1) % BUF_SIZE;
    if (next == rb->tail) return -1; // 缓冲区满
    rb->data[rb->head] = val;
    __asm__ volatile("dmb"); // 数据内存屏障,保证写顺序
    rb->head = next;
    return 0;
}

// 消费者读取
int rb_pop(struct ring_buffer *rb, uint32_t *val) {
    if (rb->tail == rb->head) return -1; // 缓冲区空
    *val = rb->data[rb->tail];
    __asm__ volatile("dmb");
    rb->tail = (rb->tail + 1) % BUF_SIZE;
    return 0;
}

避坑指南:无锁环形缓冲区只适用于单生产者-单消费者场景。多生产者或多消费者时,必须用原子操作来保护head/tail指针。我曾经在项目中强行把单生产者改成双生产者,结果head指针被两个核同时修改,数据全乱了。

3.6 共享内存的性能考量

最后聊聊性能。共享内存虽然快(纳秒级延迟),但用不好反而会拖慢系统。

几个关键指标:

指标 典型值(DDR) 典型值(OCRAM) 说明
访问延迟 50-100ns 5-20ns OCRAM快得多,但容量小
Cache刷写开销 100-500ns 50-200ns 取决于Cache Line数量和总线负载
中断通知延迟 1-10μs 1-10μs 主要开销在中断响应

我的经验是:尽量把共享数据放在OCRAM里。虽然容量小(通常只有几百KB到几MB),但延迟低、没有DDR刷新带来的抖动。对于控制类数据(比如油门值、转向角),OCRAM完全够用。

另外,减少Cache刷写的频率。不要每写一个字节就刷一次Cache。攒够一个Cache Line(64字节)再刷,效率高得多。

核心总结:共享内存是异构通信的基石,但缓存一致性是绕不开的坎。同构靠硬件Snoop,异构靠软件刷Cache。记住三个动作——写后刷、读前无效、加内存屏障。做到这三点,你的共享内存通信基本就稳了。

共享内存与缓存一致性架构 Core0 (A核) L1 Cache (M/E/S/I) Core1 (R核) L1 Cache (I状态) 共享内存 (DDR/OCRAM) 物理地址: 0x3000_0000 Core0写后刷Cache (DC CVAC + DSB) Core1读前无效化 (DC IVAC + DSB) 异构系统无硬件Snoop,需软件手动维护缓存一致性