8、设备驱动:SMP安全的驱动编写、DMA一致性、设备树与多核绑定

好,我们进入第八章。这一章讲的是设备驱动在多核环境下的那些“坑”和“招”。说实话,驱动开发在单核时代还算单纯,但一上SMP,很多老经验就不灵了。我当年第一次把单核驱动移植到双核上,跑起来直接死机,查了三天才发现是全局变量没加保护。嗯,咱们今天就把它聊透。

8.1 SMP安全的驱动编写:别让两个核同时碰一个寄存器

先问一个问题:两个CPU核同时写一个设备寄存器,会发生什么?

答案很残酷——数据错乱,设备状态不可预测。这就是驱动必须考虑SMP安全的原因。

我个人习惯把驱动中的共享资源分成三类:

  • 全局变量:比如设备状态标志、缓冲区指针
  • 硬件寄存器:多个核可能同时访问的MMIO区域
  • DMA描述符链:多个核提交DMA请求时可能冲突

针对这些,RT-Thread提供了几种保护手段:

保护手段 适用场景 性能开销
自旋锁(spinlock) 中断上下文、短临界区
互斥量(mutex) 线程上下文、可能睡眠
原子操作 简单计数器、标志位 极低
RCU(读-拷贝-更新) 读多写少的场景 读无锁

举个例子,一个简单的UART驱动,发送缓冲区索引需要保护:

/* 不安全的写法 */
static int tx_index = 0;
void uart_send(char c) {
    buffer[tx_index++] = c;  // 两个核同时执行,tx_index会乱
}

/* SMP安全的写法 */
static int tx_index = 0;
static struct rt_spinlock tx_lock;
void uart_send(char c) {
    rt_spin_lock(&tx_lock);
    buffer[tx_index++] = c;
    rt_spin_unlock(&tx_lock);
}

这里有个细节:自旋锁在单核上其实是个空操作,但多核下必须加。我建议你从一开始就加上,别等出问题再补。

注意:中断处理函数里绝对不能使用互斥量!因为互斥量可能导致睡眠,而中断上下文不允许睡眠。这时候只能用自旋锁或者关中断的方式。

8.2 DMA一致性:缓存和内存的“同步战争”

DMA一致性,说白了就是CPU的缓存和DMA引擎看到的内存数据不一样。为什么会这样?

CPU有L1/L2缓存,写数据时可能先写到缓存里,还没刷回主存。DMA引擎直接读写主存,它看不到缓存里的最新数据。这就出问题了。

我在项目中遇到过这样一个案例:网卡驱动用DMA接收数据包,CPU从缓冲区读数据时总是读到旧数据。查了半天,发现是DMA已经把数据写到了主存,但CPU的缓存里还是老版本。解决办法就是做缓存一致性操作。

RT-Thread中处理DMA一致性的常用方法:

  1. 使用一致性DMA缓冲区:分配时指定为非缓存区域,CPU和DMA都直接操作主存。
  2. 手动缓存刷新:在DMA传输前后调用缓存维护函数。
  3. 硬件自动一致性:有些SoC支持硬件缓存一致性协议(如CCI-400),驱动无需额外操作。

代码示例——分配一致性DMA缓冲区:

#include <rtdevice.h>

/* 分配一个DMA安全的缓冲区 */
void *dma_buf = rt_dma_alloc(1024, RT_DMA_CONSISTENT);
if (!dma_buf) {
    rt_kprintf("DMA buffer allocation failed\n");
    return -ENOMEM;
}

/* 使用完毕后释放 */
rt_dma_free(dma_buf);

如果你用的是手动刷新方式,记住这个原则:

  • DMA写入数据前:CPU要调用缓存无效化(invalidate),确保读到的是DMA写的最新数据。
  • DMA读取数据前:CPU要调用缓存清理(clean),把缓存中的数据刷回主存。
小技巧:我习惯在驱动初始化时就把DMA缓冲区固定下来,用rt_dma_alloc分配一致性内存。这样省去了每次传输都做缓存同步的麻烦,虽然性能上有一点点损失,但代码简单可靠。

8.3 设备树与多核绑定:告诉系统“这个设备归哪个核管”

设备树(Device Tree)在嵌入式Linux里用得很多,RT-Thread也支持。它的作用就是描述硬件拓扑,告诉内核“有哪些设备、挂在哪里、中断号是多少”。

在多核环境下,设备树还能做一件事:指定某个设备的中断由哪个CPU核处理。这叫中断亲和性(IRQ affinity)。

举个例子,一个千兆网卡的中断如果总是被CPU0处理,那CPU0的负载会很高,其他核却闲着。我们可以通过设备树把中断分散到不同核上:

/* 设备树片段:将网卡中断绑定到CPU1 */
ethernet@40000000 {
    compatible = "vendor,eth";
    reg = <0x40000000 0x1000>;
    interrupts = <33 2>;
    interrupt-affinity = <1>;  /* 绑定到CPU1 */
};

在RT-Thread中,解析设备树并设置中断亲和性的流程大致如下:

  1. 解析设备树节点,获取interrupt-affinity属性。
  2. 调用rt_hw_interrupt_set_affinity()函数,将中断路由到指定CPU。
  3. 在中断处理函数中,检查当前运行的CPU核是否与绑定一致。

我曾经调试过一个音频驱动,播放时总是有杂音。后来发现是中断在两个核之间来回跳,导致DMA缓冲区被交替访问。用设备树把中断固定到一个核上,问题就解决了。

核心原则:设备树的多核绑定不是必须的,但当你遇到性能瓶颈或数据竞争时,它是一个非常有效的工具。我个人建议:对性能敏感的设备(网卡、存储控制器)做中断绑定,对低频设备(GPIO、I2C)保持默认。

8.4 避坑指南:我踩过的几个雷

最后分享几个实战中容易忽略的点:

  • DMA描述符的缓存问题:描述符链本身也可能被缓存,记得在提交DMA前刷新描述符内存。
  • 中断嵌套:高优先级中断可能打断低优先级中断,如果它们共享同一个设备寄存器,必须用自旋锁保护。
  • 设备树中的中断号:不同SoC的中断号编码方式不同,有的从0开始,有的从32开始。一定要看芯片手册确认。
  • 一致性内存的分配时机:不要在中断处理函数里分配DMA缓冲区,因为分配过程可能阻塞。应该在初始化时预分配好。

嗯,这一章的内容就到这里。驱动开发是个细致活,SMP环境下更是如此。记住一句话:能加锁的地方别省,能固定绑定的别乱跑。下一章我们会聊调度器在多核下的实现,那才是真正的重头戏。