2、共享内存基础:共享内存模型、内存映射与地址空间、原子操作与内存屏障

好,咱们正式开始聊核间通信。说实话,共享内存是这里面最基础、也是最容易出问题的一块。我见过不少团队,上来就搞复杂的消息队列,结果底层共享内存的坑一个没躲过去。我个人习惯,先把共享内存的底裤扒干净,再谈别的。

2.1 共享内存模型:到底“共享”了什么?

共享内存,说白了就是让两个或多个处理器核,能直接读写同一块物理内存。你想想看,核A写了一个变量,核B马上就能读到。听起来很简单对吧?但这里有个关键问题——你读到的,真的是核A写进去的那个值吗?

我在项目中遇到过,两个核通过共享内存交换数据,核A写了一个标志位,核B轮询这个标志位。结果核B等了半天没反应,最后发现是缓存一致性问题。嗯,这里要注意,共享内存模型不仅仅是“内存”,还涉及缓存、写缓冲、甚至编译器的重排。

常见的共享内存模型有三种:

  • 强一致性模型:所有核看到的内存访问顺序完全一致。听起来完美,但性能代价很高。x86 架构就偏向强一致性。
  • 弱一致性模型:只有通过特定的同步指令(比如内存屏障),才能保证顺序。ARM 和 PowerPC 都是典型的弱一致性。
  • 释放一致性模型:更精细的控制,把同步操作分为“获取”和“释放”。你写数据前做一个释放操作,别人读数据前做一个获取操作。

核心观点:不要假设你的硬件是强一致的。除非你明确知道自己在做什么,否则一定要用同步原语。

2.2 内存映射与地址空间:虚拟地址怎么找到物理地址?

每个核都有自己的 MMU(内存管理单元),它把虚拟地址翻译成物理地址。但共享内存要求多个核访问同一块物理内存。怎么做?

有两种常见做法:

  1. 直接映射到相同的物理地址:每个核的页表里,都有一项指向同一个物理页框。这是最直接的方式。
  2. 通过 IO 内存映射:有些硬件提供了共享的 SRAM 或 DDR 区域,直接映射到固定的物理地址。

我建议你在设计初期就把共享内存的物理地址固定下来。别用动态分配,否则调试起来你会想哭的。我曾经在一个项目中,两个核各自用 malloc 分配共享内存,结果物理地址对不上,数据全乱套了。

这里有个典型的映射示例:

/* 假设共享内存物理地址为 0x40000000,大小 1MB */
#define SHARED_MEM_BASE  0x40000000
#define SHARED_MEM_SIZE  (1 * 1024 * 1024)

/* 在核0上映射 */
void *map_shared_memory_core0(void) {
    return mmap(NULL, SHARED_MEM_SIZE, 
                PROT_READ | PROT_WRITE, 
                MAP_SHARED | MAP_FIXED, 
                fd, SHARED_MEM_BASE);
}

/* 在核1上映射到相同的物理地址 */
void *map_shared_memory_core1(void) {
    return mmap(NULL, SHARED_MEM_SIZE, 
                PROT_READ | PROT_WRITE, 
                MAP_SHARED | MAP_FIXED, 
                fd, SHARED_MEM_BASE);
}

小技巧:用 MAP_FIXED 时要小心,它会覆盖已有的映射。我一般先检查一下目标地址是否已被占用。

2.3 原子操作:为什么不能直接赋值?

你可能会想,共享一个 int 变量,直接 flag = 1 不就行了?嗯,没那么简单。

一个简单的赋值操作,在底层可能是多条指令:读取、修改、写回。如果两个核同时执行,就会发生竞态。比如核A读到了0,核B也读到了0,然后各自加1,结果两个核都写回1,但期望值是2。

原子操作就是保证“读-改-写”这三步一气呵成,中间不会被其他核打断。常见的原子操作有:

  • 原子加/减atomic_add(&counter, 1)
  • 原子比较并交换(CAS)atomic_cmpxchg(&ptr, old, new)
  • 原子测试并设置atomic_test_and_set_bit(&flags, 3)

在 C11 标准里,有 stdatomic.h 可以用。我个人习惯用这个,可移植性好。比如:

#include <stdatomic.h>

atomic_int shared_counter = ATOMIC_VAR_INIT(0);

void increment_counter(void) {
    atomic_fetch_add(&shared_counter, 1);
}

int read_counter(void) {
    return atomic_load(&shared_counter);
}

警告:别用 volatile 代替原子操作。volatile 只告诉编译器不要优化,但无法保证硬件级别的原子性。我见过有人用 volatile 做自旋锁,结果在多核上跑得稀里哗啦。

2.4 内存屏障:看不见的“栅栏”

好,现在你用了原子操作,数据不会错乱了。但性能呢?你想想看,现代 CPU 为了提速,会乱序执行指令。核A先写 data,再写 flag。但 CPU 可能先把 flag 写出去,data 还在写缓冲里排队。核B看到 flag 变了,去读 data,结果读到的是旧值。

这就是内存屏障要解决的问题。它像一道栅栏,强制前面的内存操作必须先完成,后面的操作才能开始。

内存屏障分几种:

类型 作用 典型场景
读屏障 (rmb) 保证屏障前的读操作先完成 读数据前,先确认标志位
写屏障 (wmb) 保证屏障前的写操作先完成 写数据后,再写标志位
全屏障 (mb) 读写都保证顺序 通用同步点

在 Linux 内核里,有 smp_mb()smp_wmb()smp_rmb() 这些宏。它们只在多核场景下生效,单核时会被优化掉。比如:

/* 核A:生产者 */
data = 42;
smp_wmb();          /* 保证 data 写完,再写 flag */
flag = 1;

/* 核B:消费者 */
while (flag != 1) {
    cpu_relax();    /* 自旋等待 */
}
smp_rmb();          /* 保证 flag 读完后,再读 data */
value = data;

避坑指南:我曾经在一个 ARM 平台上,忘了加写屏障,结果数据总是对不上。查了两天,最后发现是写缓冲搞的鬼。加上 dmb 指令后,问题立刻消失。嗯,从那以后我再也不敢省屏障了。

2.5 实战建议:从简单开始

如果你刚开始做多核共享内存,我建议你从最简单的模式开始:

  • 一个生产者,一个消费者:用原子变量做标志位,配合内存屏障。
  • 固定大小的环形缓冲区:用两个原子变量分别表示头和尾。
  • 避免复杂的锁:自旋锁虽然简单,但容易死锁。能用无锁就用无锁。

等你把基础打牢了,再考虑多生产者、多消费者、甚至无锁队列。别一上来就搞高大上的东西,多核调试的坑,你踩过一次就知道了。

最后,记住一句话:共享内存不共享,缓存一致不一致。你写的代码,和 CPU 实际执行的,可能是两码事。用原子操作和内存屏障,就是告诉 CPU:“别耍小聪明,按我说的来。”