第四章 ARINC 429 软件驱动开发:Linux 下 SPI 驱动框架、中断处理、DMA 传输、环形缓冲区设计

好,咱们进入正题。这一章要聊的是 ARINC 429 的软件驱动开发,具体是在 Linux 环境下,怎么把 SPI 驱动、中断处理、DMA 传输和环形缓冲区这几个东西串起来。说实话,这部分内容在航电系统里属于「硬骨头」,但啃下来之后,你会发现很多底层逻辑都是相通的。

4.1 Linux SPI 驱动框架:从设备树到数据传输

先说说 SPI 驱动框架。Linux 下的 SPI 驱动,说白了就是一套标准化的「对话协议」。你不需要从头造轮子,内核已经帮你搭好了骨架。

我个人习惯,第一步是看设备树(Device Tree)。设备树里会描述 SPI 控制器的寄存器地址、中断号、时钟频率,还有挂在上面的从设备。比如 ARINC 429 的收发器芯片,通常就是 SPI 从设备。

设备树节点示例:

arinc429: arinc429@0 {
    compatible = "mycompany,arinc429-spi";
    reg = <0>;
    spi-max-frequency = <10000000>;
    interrupt-parent = <&gpio>;
    interrupts = <17 2>;  // GPIO17, 下降沿触发
};

嗯,这里要注意:spi-max-frequency 别设太高。我在项目中遇到过,某次把频率设到 20MHz,结果数据老是丢包,查了半天发现是信号完整性出了问题。后来降到 10MHz,稳如老狗。

驱动代码里,核心是 spi_driver 结构体。你需要实现 proberemove 函数。在 probe 里,我会做三件事:

  • 分配并初始化私有数据结构(比如环形缓冲区指针)
  • 注册中断处理函数
  • 创建字符设备或 misc 设备,供用户空间访问

数据传输用 spi_messagespi_transfer。你可以把多个 transfer 组成一个 message,内核会帮你按顺序执行。举个例子,读 ARINC 429 数据时,先发一个字节的命令,再收四个字节的数据,这就是两个 transfer。

我的小技巧:spi_async 替代 spi_sync。同步方式会阻塞当前线程,而异步方式配合完成回调,效率高得多。尤其是在航电系统里,实时性要求高,阻塞是大忌。

4.2 中断处理:别让 CPU 空转

中断处理是驱动开发的灵魂。ARINC 429 芯片通常会在收到一帧数据后拉高中断引脚,或者在发送完成时产生中断。

我曾经犯过一个错误:在中断处理函数里做太多事情。比如直接调用 spi_sync 去读数据。结果呢?中断上下文不允许睡眠,而 spi_sync 会触发调度,系统直接崩溃。教训深刻啊。

正确的做法是:

  1. 中断处理函数(上半部)只做最紧急的事:比如读取状态寄存器,清除中断标志,然后唤醒一个工作队列或 tasklet。
  2. 下半部(比如 work_structtasklet)里再发起 SPI 传输,读取数据。

避坑指南: 我曾经在 tasklet 里用了 msleep,结果系统直接死锁。tasklet 是在软中断上下文执行的,不能睡眠。要用 mdelay 或者干脆用工作队列。

代码结构大致是这样:

static irqreturn_t arinc429_irq_handler(int irq, void *dev_id)
{
    struct arinc429_dev *dev = dev_id;
    // 清除中断标志(通过 GPIO 或 SPI 命令)
    // 调度工作队列
    schedule_work(&dev->work);
    return IRQ_HANDLED;
}

static void arinc429_work_handler(struct work_struct *work)
{
    struct arinc429_dev *dev = container_of(work, struct arinc429_dev, work);
    // 发起 SPI 传输,读取数据
    // 将数据放入环形缓冲区
}

4.3 DMA 传输:让数据自己跑起来

SPI 传输如果每次都用 CPU 搬运数据,那 CPU 就啥也别干了。DMA 就是干这个的——让数据直接在 SPI 控制器和内存之间流动,CPU 只需要在传输完成时收个通知。

Linux 内核提供了 dmaengine 框架。你需要先申请一个 DMA 通道,然后配置源地址、目的地址、传输长度。对于 SPI 接收,源地址是 SPI 的 RX FIFO 地址,目的地址是内存中的环形缓冲区。

我记得第一次调 DMA 时,数据死活不对。后来发现是 DMA 的地址没有做 cache 一致性处理。ARM 架构下,DMA 和 CPU 看到的 cache 可能不一致。解决方案是用 dma_map_singledma_alloc_coherent 分配内存。

关键代码片段:

struct dma_async_tx_descriptor *txd;
dma_cookie_t cookie;

txd = dmaengine_prep_slave_sg(chan, &sgt, 1, DMA_DEV_TO_MEM,
                               DMA_PREP_INTERRUPT | DMA_CTRL_ACK);
txd->callback = dma_callback;
txd->callback_param = dev;
cookie = dmaengine_submit(txd);
dma_async_issue_pending(chan);

DMA 传输完成后,会调用回调函数。在回调里,你只需要更新环形缓冲区的写指针,然后唤醒等待数据的进程。CPU 几乎零负担。

4.4 环形缓冲区:无锁设计的艺术

环形缓冲区(Ring Buffer)是解决生产者和消费者问题的经典方案。在驱动里,中断下半部是生产者,把数据写进缓冲区;用户空间的 read 调用是消费者,从缓冲区读数据。

为什么用环形缓冲区?因为可以做到无锁。只要保证一个写指针和一个读指针,并且写指针只由生产者更新,读指针只由消费者更新,就不需要互斥锁。这在实时系统中至关重要。

我常用的结构体是这样的:

struct arinc429_ringbuf {
    uint32_t *buf;      // 数据缓冲区
    int size;           // 缓冲区大小(必须是2的幂)
    int write_idx;      // 写指针
    int read_idx;       // 读指针
};

注意 size 必须是 2 的幂。这样可以用位运算代替取模:idx & (size - 1)。位运算比取模快一个数量级,在中断上下文里尤其重要。

写数据时:

int ringbuf_write(struct arinc429_ringbuf *rb, uint32_t data)
{
    int next = (rb->write_idx + 1) & (rb->size - 1);
    if (next == rb->read_idx) {
        return -ENOSPC;  // 缓冲区满了
    }
    rb->buf[rb->write_idx] = data;
    smp_wmb();  // 写屏障,确保数据写入后再更新指针
    rb->write_idx = next;
    return 0;
}

读数据时类似,只是方向相反。这里用 smp_wmb() 是为了防止编译器或 CPU 重排序。我在项目中遇到过,不加屏障时,消费者读到了旧数据,因为写指针先更新了,但数据还没写到内存。加了屏障后,问题解决。

我的经验: 环形缓冲区的大小要根据数据速率来定。ARINC 429 每帧 32 位,速率有 12.5kbps 和 100kbps 两种。如果缓冲区太小,高负载时会丢数据;太大又浪费内存。我一般设为 256 或 512 个条目,够用。

4.5 整合:一个完整的驱动流程

把上面几个模块串起来,整个驱动的工作流程是这样的:

  1. 设备树解析,注册 SPI 设备
  2. probe 函数初始化:分配环形缓冲区、注册中断、申请 DMA 通道
  3. ARINC 429 芯片收到数据,产生中断
  4. 中断上半部清除标志,调度工作队列
  5. 工作队列发起 DMA 传输,数据从 SPI FIFO 直接搬到环形缓冲区
  6. DMA 完成回调,更新写指针,唤醒用户空间进程
  7. 用户空间调用 read,从环形缓冲区读取数据

你看,整个过程中,CPU 只在中断上半部和 DMA 回调里做了极少量工作,大部分时间都在处理其他任务。这才是嵌入式系统该有的样子。

嗯,这一章的内容就到这里。下一章我们会聊 ARINC 429 数据解析和校验,包括奇偶校验、SDI、SSM 这些字段的处理。到时候再细说。