3. QNX体系结构入门
共享内存,说白了就是让多个进程(或者多个核)能直接读写同一块物理内存。这是多核通信里吞吐量最大的方式,没有之一。我在做车载雷达数据融合项目时,几个传感器处理核就是靠共享内存把点云数据甩给融合核的——延迟只有几百纳秒,消息队列根本做不到。
但共享内存也有它的脾气。你想想看,两个核同时写一个变量,会发生什么?数据错乱、读到脏数据、甚至系统崩溃。嗯,这一章我们就来把这些坑一个个填平。
3.1 shm_open + mmap:创建与映射共享内存
在QNX里,创建共享内存的套路很固定:先用 shm_open 搞一个共享内存对象,再用 mmap 把它映射到进程的地址空间。我习惯把这两步叫做「开窗」和「进门」——开窗就是申请一块名字标识的内存,进门就是让进程能看到它。
3.1.1 创建共享内存对象
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>
#define SHM_NAME "/my_shared_mem"
#define SHM_SIZE 4096
int main() {
int fd = shm_open(SHM_NAME, O_CREAT | O_RDWR, 0666);
if (fd == -1) {
perror("shm_open failed");
return -1;
}
// 设置大小
if (ftruncate(fd, SHM_SIZE) == -1) {
perror("ftruncate failed");
return -1;
}
// 映射到进程地址空间
void *addr = mmap(NULL, SHM_SIZE,
PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
if (addr == MAP_FAILED) {
perror("mmap failed");
return -1;
}
close(fd); // 映射后可以关闭文件描述符
// 现在 addr 就是共享内存的起始地址
return 0;
}
/ 开头,这是POSIX规范。我曾经见过同事用 "my_shm" 不带斜杠,结果在QNX 7.0上死活打不开——嗯,踩过这个坑你就记住了。
3.1.2 多进程映射同一块内存
两个进程要通信,只需要各自调用 shm_open + mmap,用同一个名字。内核会保证它们映射到同一块物理页。我一般把共享内存的布局设计成一个结构体,里面放数据区和同步标志:
typedef struct {
volatile uint32_t flag; // 数据就绪标志
volatile uint32_t sequence; // 序列号,防ABA问题
char data[4084]; // 实际数据
} SharedData;
flag 和 data,如果没有额外保护,你会看到数据「半新半旧」——这就是缓存一致性问题。
3.2 多核缓存一致性问题
为什么会有缓存一致性问题?我画个图你就明白了。
Core 0 把变量 X 从 0 改成 1,但 Core 1 的 L1 Cache 里还存着旧的 X=0。如果 Core 1 直接读自己的缓存,它拿到的就是脏数据。这就是缓存一致性问题。
QNX 的硬件(ARM Cortex-A 系列)通常用 MESI 协议来维护一致性。但 MESI 不是万能的——它只能保证单次读写的原子性,不能保证多个变量之间的顺序关系。举个例子:
- Core 0 写 data[0..1023]
- Core 0 写 flag = 1
- Core 1 读 flag == 1
- Core 1 读 data[0..1023]
你希望 Core 1 看到的是完整的新数据。但硬件可能把步骤 2 的写操作重排到步骤 1 之前——Core 1 看到 flag=1 时,data 可能还是旧的。这就是内存乱序执行带来的问题。
3.3 内存屏障(Memory Barrier)的应用
内存屏障,说白了就是给CPU下的一道命令:「在这之前的内存操作,必须在这之前完成;在这之后的操作,必须等屏障执行完再开始。」
QNX 提供了几种内存屏障宏,我列个表给你看:
| 宏名称 | 作用 | 典型场景 |
|---|---|---|
MEMORY_BARRIER() |
全屏障:所有读写操作都不能跨越 | 生产者-消费者同步 |
READ_MEMORY_BARRIER() |
读屏障:后续读操作不能提前 | 读共享标志位 |
WRITE_MEMORY_BARRIER() |
写屏障:后续写操作不能提前 | 写数据后再写标志位 |
__sync_synchronize() |
GCC内置全屏障 | 跨平台代码 |
3.3.1 写屏障:保证数据先于标志位可见
回到刚才的例子。Core 0 写数据时,必须保证 data 写完,才能写 flag。用写屏障实现:
// Core 0 - 生产者
shared->data[0] = sensor_value_1;
shared->data[1] = sensor_value_2;
// ... 写更多数据 ...
WRITE_MEMORY_BARRIER(); // 保证上面所有写操作完成
shared->flag = 1; // 然后才通知消费者
3.3.2 读屏障:保证读标志位之后才读数据
// Core 1 - 消费者
while (shared->flag == 0) {
// 等待,或者用暂停指令
asm volatile("yield");
}
READ_MEMORY_BARRIER(); // 保证 flag 读完之后才读 data
int val1 = shared->data[0];
int val2 = shared->data[1];
3.3.3 全屏障:最保险但最慢
如果你不确定该用读屏障还是写屏障,直接用 MEMORY_BARRIER() 最保险。但代价是性能——全屏障会刷写 store buffer 和 invalidate queue,延迟大约 50-100 纳秒。在高速数据流里,这个开销不可忽视。
3.4 实战:无锁环形缓冲区
共享内存 + 内存屏障的经典应用就是无锁环形缓冲区(Lock-Free Ring Buffer)。我直接给你看代码骨架:
typedef struct {
volatile uint32_t head; // 写位置
volatile uint32_t tail; // 读位置
char buffer[RING_SIZE][ITEM_SIZE];
} RingBuffer;
// 生产者(Core 0)
int ring_push(RingBuffer *rb, const char *data) {
uint32_t h = rb->head;
uint32_t t = rb->tail;
if ((h + 1) % RING_SIZE == t) {
return -1; // 满
}
memcpy(rb->buffer[h], data, ITEM_SIZE);
WRITE_MEMORY_BARRIER(); // 保证数据写完
rb->head = (h + 1) % RING_SIZE;
return 0;
}
// 消费者(Core 1)
int ring_pop(RingBuffer *rb, char *data) {
uint32_t h = rb->head;
uint32_t t = rb->tail;
if (h == t) {
return -1; // 空
}
READ_MEMORY_BARRIER(); // 保证 head 读正确
memcpy(data, rb->buffer[t], ITEM_SIZE);
WRITE_MEMORY_BARRIER(); // 保证数据读完
rb->tail = (t + 1) % RING_SIZE;
return 0;
}
head 和 tail 的读写是原子的(32位对齐)。在QNX上,只要变量是自然对齐的 int32,读写就是原子的。但如果你用 uint64_t 做索引,在32位系统上就不是原子的了——需要额外加锁或使用CAS。
3.5 总结:共享内存的黄金法则
- shm_open + mmap 是创建共享内存的标准流程,记得用
ftruncate设置大小。 - 缓存一致性 硬件帮你搞定单地址的原子性,但多地址的顺序要靠你自己。
- 内存屏障 必须成对使用:写屏障在生产者,读屏障在消费者。
- 无锁环形缓冲区 是共享内存最实用的模式,性能高、延迟低。