3. 片内通信机制(一):共享内存(Shared Memory)原理、缓存一致性(Cache Coherency)问题
各位同学,今天我们来聊聊多核异构架构里最基础、也最让人头疼的一个话题——共享内存。
说白了,共享内存就是让多个处理器核心(不管是同构的A核,还是异构的R核、M核)都能访问同一块物理内存区域。听起来很简单对吧?但实际做起来,坑多得很。我当年第一次做异构AMP(非对称多处理)项目时,就因为这个机制吃了大亏。
3.1 共享内存的基本原理
共享内存的核心思想就一句话:一块物理地址空间,多个主设备可见。
在异构系统里,通常我们会把DDR或者OCRAM(片上RAM)划分出一块区域,专门用于核间数据交换。每个核通过各自的总线接口,映射到同一段物理地址。
关键点:共享内存本身不提供任何同步机制。它只是一块“公共黑板”,谁都能写,谁都能读。数据一致性、访问互斥,全靠软件自己管。
我习惯把共享内存的通信模型分成三个角色:
- 生产者(Producer):往共享区写数据的核
- 消费者(Consumer):从共享区读数据的核
- 协调者(Coordinator):负责同步通知的机制(通常是中断或信号量)
举个例子,一个典型的异构通信流程是这样的:
- A核往共享内存地址0x3000_0000写入一帧数据
- A核写完后,通过门铃中断(Doorbell Interrupt)通知R核
- R核收到中断,从0x3000_0000读取数据
- 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。
所以,异构共享内存的缓存一致性,必须靠软件手动维护。
具体做法分两步:
- 写操作后刷Cache:生产者调用
clean_flush指令,把脏数据写回内存 - 读操作前无效化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。记住三个动作——写后刷、读前无效、加内存屏障。做到这三点,你的共享内存通信基本就稳了。