3、核间中断与通信:IPI机制、核间消息传递、共享内存通信、自旋锁与原子操作

多核系统里,核与核之间怎么打招呼?怎么传数据?怎么抢资源?

这些问题,说白了就是核间通信要解决的事。我刚开始接触多核时,总觉得每个核各跑各的,互不干扰多好。但现实很骨感——任务要分配、数据要共享、中断要处理,核之间不说话,系统就转不起来。

这一章,我们就来聊聊RT-Thread在多核场景下,怎么搞定这些通信问题。

3.1 IPI机制:核间中断

IPI,全称Inter-Processor Interrupt。名字挺长,意思很简单:一个核给另一个核发个中断信号。

你想想看,CPU0想让CPU1执行某个紧急任务,总不能轮询吧?效率太低了。IPI就是干这个的——直接打断对方,让它停下来处理你的事。

3.1.1 IPI的硬件基础

不同的处理器架构,IPI的实现方式不一样。ARM Cortex-A系列用的是GIC(Generic Interrupt Controller),x86用的是APIC(Advanced Programmable Interrupt Controller)。

我在项目中遇到过一个问题:某款ARM芯片的GIC配置不对,导致IPI发出去后,目标核收不到。查了两天才发现是中断号映射错了。嗯,这里要注意——IPI的中断号通常是软件可配的,别跟其他外设中断冲突。

3.1.2 RT-Thread中的IPI实现

RT-Thread对IPI做了封装,上层接口很简单。核心函数就几个:

// 发送IPI到指定核
void rt_smp_ipi_send(int cpu_id, uint32_t ipi_id);

// IPI中断处理函数注册
void rt_smp_ipi_register(int ipi_id, rt_ipi_handler_t handler);

// IPI中断服务例程
void rt_smp_ipi_isr(int vector, void *param);

我个人习惯把IPI分成几类用途:

  • IPI_RESCHED:触发调度,让目标核重新调度任务
  • IPI_CALL_FUNC:让目标核执行某个函数
  • IPI_CPU_STOP:让目标核停下来(比如系统panic时)

关键点:IPI处理函数里不能做太多事。它是在中断上下文执行的,耗时操作会阻塞其他中断。我一般只在IPI处理里设置标志位,真正的处理放到线程里去做。

3.2 核间消息传递

IPI只是打个招呼,真正要传数据,还得靠消息传递机制。

RT-Thread里,核间消息传递用的是mailboxmessage queue。但要注意,这些IPC对象默认是单核安全的。多核环境下,需要加锁保护。

3.2.1 基于共享内存的消息队列

我常用的做法是:在共享内存里划出一块区域,做成环形缓冲区。一个核往里写,另一个核往外读。

struct rt_smp_mqueue {
    rt_uint8_t *pool;           // 数据缓冲区
    rt_uint32_t msg_size;       // 每条消息大小
    rt_uint32_t max_msgs;       // 最大消息数
    volatile rt_uint32_t head;  // 写指针
    volatile rt_uint32_t tail;  // 读指针
    rt_spinlock_t lock;         // 自旋锁保护
};

为什么用环形缓冲区?因为它不需要动态内存分配,性能稳定。我在一个工业控制项目里用过这个方案,两个核之间每100微秒交换一次数据,跑了几个月没出问题。

小技巧:head和tail用volatile修饰,防止编译器优化掉读写操作。但volatile不能保证原子性,所以还得配合自旋锁或原子操作。

3.2.2 消息传递的同步方式

消息发过去了,对方怎么知道?有两种方式:

  • 轮询:接收核定期检查消息队列。简单,但浪费CPU。
  • 中断通知:发送完消息后,发一个IPI通知对方。高效,但实现复杂一点。

我建议:如果消息频率高(比如每毫秒几十次),用轮询;如果消息频率低但要求实时响应,用中断通知。

3.3 共享内存通信

共享内存,说白了就是多个核都能访问同一块物理内存。这是最直接的通信方式,但也是最容易出问题的。

3.3.1 缓存一致性问题

每个核都有自己的L1 cache。核A改了内存里的数据,核B从cache里读到的可能是旧值。这就是缓存一致性问题。

ARM架构用MESI协议(或它的变种)来保证一致性。但软件层面也要注意:

// 写共享数据后,确保写回内存
__asm volatile("dsb st" ::: "memory");

// 读共享数据前,确保缓存失效
__asm volatile("dsb ld" ::: "memory");

我曾经踩过一个坑:两个核共享一个标志位,核A写完后没做内存屏障,核B一直读到旧值,导致任务重复执行。从那以后,我写共享内存代码时,第一件事就是检查有没有加屏障。

3.3.2 共享内存的分配

RT-Thread里,可以用rt_smp_shared_malloc来分配共享内存。这块内存会被映射到所有核的地址空间里。

void *shared_buf = rt_smp_shared_malloc(1024);
if (shared_buf == RT_NULL) {
    // 分配失败处理
}

注意:共享内存不能太大。我一般控制在几KB以内。太大的共享内存会影响cache性能,因为每次访问都要跨核同步。

3.4 自旋锁与原子操作

多核环境下,保护临界区是头等大事。自旋锁和原子操作,就是最常用的两种手段。

3.4.1 自旋锁

自旋锁的原理很简单:锁被占用时,申请锁的线程就在原地循环等待,直到锁释放。

rt_spinlock_t lock = RT_SPINLOCK_INIT;

void critical_section(void) {
    rt_spin_lock(&lock);
    // 临界区代码
    rt_spin_unlock(&lock);
}

RT-Thread的自旋锁实现里,有一个关键点:关中断。在单核系统里,关中断就能保护临界区。但在多核系统里,关中断只能阻止当前核被中断,其他核照样能访问共享资源。所以自旋锁要同时做两件事:关中断 + 原子地获取锁。

// RT-Thread自旋锁的底层实现(简化版)
void rt_spin_lock(rt_spinlock_t *lock) {
    rt_hw_interrupt_disable();  // 关中断
    while (atomic_test_and_set(&lock->lock, 1)) {
        // 自旋等待
    }
}

经验之谈:自旋锁适合保护很短的临界区。如果临界区代码超过几十条指令,建议用信号量或互斥量。否则其他核会白白浪费CPU时间。

3.4.2 原子操作

原子操作是硬件级别的操作,要么全部执行完,要么完全不执行。RT-Thread封装了常见的原子操作:

// 原子加1
rt_atomic_add(&counter, 1);

// 原子比较并交换
rt_atomic_cmpxchg(&flag, old_val, new_val);

// 原子设置位
rt_atomic_set_bit(®, 3);

原子操作比自旋锁更轻量。我一般在以下场景优先用原子操作:

  • 计数器增减(比如引用计数)
  • 标志位设置/清除
  • 无锁队列的head/tail指针更新

3.4.3 自旋锁 vs 原子操作

特性 自旋锁 原子操作
保护范围 一段代码 单个变量
性能开销 较高(可能自旋等待) 较低(硬件保证)
适用场景 复杂临界区 简单变量操作
死锁风险 有(需注意锁顺序)

我个人习惯是:能原子操作解决的,绝不用自旋锁。只有需要保护多行代码时,才考虑自旋锁。

3.5 避坑指南

讲了这么多,最后分享几个我踩过的坑:

  1. 死锁:两个核互相等待对方释放锁。我曾经在调试时遇到过,CPU0持有锁A等锁B,CPU1持有锁B等锁A,两个核都卡死了。解决办法是:所有核按相同顺序申请锁。
  2. 优先级反转:低优先级任务持有锁,高优先级任务等锁,中优先级任务抢占了低优先级任务。RT-Thread的自旋锁不支持优先级继承,所以临界区要尽量短。
  3. 缓存伪共享:两个核频繁修改相邻的内存地址,导致cache line来回失效。解决办法是:把频繁访问的变量按cache line对齐(通常是64字节)。

最后说一句:核间通信没有银弹。IPI快但传不了多少数据,共享内存灵活但要小心同步,自旋锁简单但别滥用。根据你的实际场景,选择合适的组合才是正道。