多核处理器基础:SMP与AMP架构区别、缓存一致性、内存屏障、原子操作
好,咱们开始聊多核。说实话,很多做嵌入式开发的朋友,一开始接触多核时都会有点懵。我当年第一次拿到双核PPC的板子,心里也在打鼓——两个核怎么协同?会不会打架?数据会不会乱?
这一节,我把多核驱动开发最核心的几个概念掰开揉碎了讲。你搞懂了这些,后面写驱动心里就有底了。
SMP与AMP:两种多核架构,两种思维模式
先说说两种最常见的多核架构。你想想看,一个芯片上有好几个CPU核,它们怎么分工?
SMP(对称多处理),说白了就是所有核地位平等。每个核都能访问所有内存、所有外设。操作系统把任务随便往哪个核上一丢,谁闲着谁干活。QNX的默认配置就是SMP模式,这也是最常用的。
AMP(非对称多处理),每个核各管一摊。核0跑QNX负责控制,核1跑裸机程序做高速数据采集,核2跑Linux做网络协议栈。各干各的,互不干扰。
我在项目中遇到过这样一个场景:一个工业相机项目,需要同时做图像处理和实时控制。用SMP的话,图像处理任务可能会把控制任务的实时性拖垮。后来改成AMP方案,核0专门跑QNX的实时控制,核1跑裸机做图像处理流水线,问题就解决了。
核心区别一句话总结:
- SMP:一个操作系统管所有核,任务动态分配
- AMP:每个核跑独立系统或裸机,静态分工
那QNX怎么选?我建议你这样做:
- 如果所有任务对实时性要求差不多,用SMP,开发简单
- 如果某个任务需要独占一个核(比如高速数据采集),用AMP
- 也可以混合——QNX支持绑定进程到指定核,实现"软AMP"效果
缓存一致性:多核编程的第一道坎
嗯,这里要注意。很多新手写多核驱动,第一个坑就踩在缓存一致性上。
每个CPU核都有自己的L1/L2缓存。核0从内存地址0x1000读了个数据,缓存起来了。核1也去读同一个地址,它读的是自己缓存里的旧数据——如果核0刚改了那个值,核1根本不知道。
为什么会这样?因为缓存一致性协议(比如MESI协议)只在硬件层面保证某些情况下的同步,但软件层面的操作顺序它管不了。
我记得有一次调试一个双核通信的驱动,两个核通过共享内存传数据。核0写了一个标志位,核1死活读不到。查了两天,最后发现是缓存没刷。加上一条内存屏障指令,问题立刻解决。
避坑指南:
我曾经见过一个团队,在AMP架构下两个核共享一个环形缓冲区。核0写数据,核1读数据。跑起来偶尔丢包,查了三个月。最后发现是核0写完数据后,数据还在写缓冲(store buffer)里没刷到内存,核1就读了。加个内存屏障,问题消失。
QNX里怎么处理?我一般这样做:
- 共享内存区域用
mmap映射时加上MAP_SHARED标志 - 关键数据用
volatile修饰,防止编译器优化掉读写操作 - 必要时手动调用缓存刷新函数,比如
cache_flush()
内存屏障:给乱序执行的CPU立规矩
现代CPU为了性能,会乱序执行指令。你代码里写的是:
data = 100; // 写数据
flag = 1; // 写标志位
但CPU实际执行可能是先写flag再写data。另一个核看到flag=1就去读data,结果data还是旧值。
内存屏障就是用来阻止这种乱序的。说白了,它告诉CPU:在这条指令之前的所有内存操作,必须在这条指令之后的操作之前完成。
QNX提供了几种内存屏障宏:
| 宏名称 | 作用 |
|---|---|
mb() |
完整内存屏障,所有读写操作都不能越过 |
rmb() |
读内存屏障,只保证读操作的顺序 |
wmb() |
写内存屏障,只保证写操作的顺序 |
我个人习惯是:能不用就不用,因为内存屏障会影响性能。但该用的时候一定不能省。判断标准很简单——如果两个核通过共享内存通信,中间没有锁或其他同步机制,那就必须加屏障。
小技巧:
在QNX里,如果你用 pthread_mutex_lock() 或者QNX的 SyncType 同步原语,它们内部已经包含了内存屏障。你不需要额外再加。只有自己手写共享内存通信时,才需要显式使用屏障。
原子操作:不被打断的读写
原子操作,说白了就是"要么全部做完,要么什么都不做"。一个原子操作在执行过程中,不会被其他核或中断打断。
你想想看,两个核同时对一个变量做 counter++。这个操作在汇编层面其实是三步:读、加、写。核0读到counter=5,还没写回去,核1也读到counter=5。两个核都加1,结果counter变成6,而不是7。这就是经典的"读-改-写"问题。
原子操作就是解决这个问题的。它保证读、改、写三步一气呵成,中间不会被插队。
QNX里常用的原子操作:
#include <atomic.h>
int old_val;
old_val = atomic_add(&counter, 1); // 原子加1
old_val = atomic_set(&flag, 1); // 原子赋值
int cur = atomic_load(&counter); // 原子读取
我在项目中遇到过这样一个场景:一个多核网络驱动,每个核都在更新同一个统计计数器(收包数量)。一开始用普通变量加锁,性能很差。后来改成原子操作,性能提升30%以上。因为原子操作在硬件层面就完成了同步,不需要操作系统介入。
什么时候用原子操作,什么时候用锁?
- 简单变量(计数器、标志位)的读写:用原子操作,轻量高效
- 复杂数据结构(链表、队列)的修改:用锁,原子操作搞不定
- 性能敏感路径:优先考虑原子操作
嗯,最后说一句。原子操作虽然快,但不是万能的。如果你需要同时修改多个变量,原子操作就帮不上忙了。这时候还是得上锁。我见过有人试图用多个原子操作拼出一个"原子序列",结果该出问题还是出问题——因为多个原子操作之间,其他核还是能插进来。
好了,多核基础就聊到这儿。下一节我们讲具体怎么在QNX里写多核驱动,包括中断亲和性设置、核间通信、以及调试技巧。到时候我会拿一个真实的网卡驱动例子来拆解,你看了就知道这些概念在实际代码里是怎么用的。