SPI驱动开发实战:从轮询到DMA的嵌入式通信架构解析
2026/6/7 15:18:33 网站建设 项目流程

1. 项目概述:从IIC到SPI,嵌入式总线驱动的实战视角

在嵌入式开发领域,IIC和SPI是两种最经典、最常用的串行通信总线。很多工程师在入门时,都会先被IIC那套复杂的起始、停止、应答信号时序搞得头大,网上关于IIC驱动调试的“血泪史”也一抓一大把。相比之下,SPI总线在硬件层面要“友好”得多——它没有复杂的握手协议,通信本质上就是主从设备之间同步地交换数据移位寄存器里的内容。正因如此,很多资料会告诉你“SPI驱动比IIC简单得多”。这句话没错,但只说对了一半。简单,意味着底层的数据收发操作确实直观;但要把SPI驱动做得稳定、高效,并能适配各种复杂的应用场景(比如非阻塞读取、DMA传输、多设备管理),里面的门道一点也不少。今天,我就结合自己多年在MCU和嵌入式Linux平台上的踩坑经验,来一次SPI驱动的深度“阅读理解”,不仅告诉你寄存器怎么读写,更要讲清楚在不同模式下(轮询、中断、DMA)该如何设计驱动架构,以及那些数据手册里不会写的实战细节。

2. SPI驱动核心:理解协议、控制器与设备的三层关系

在动手写代码之前,我们必须先理清一个关键概念:SPI驱动开发,实际上是在处理三个不同层面的时序和要求。混淆它们,是很多驱动工作不稳定的根源。

2.1 SPI协议本身:几乎没有时序约束

这是SPI“简单”说法的来源。SPI协议标准本身只定义了四根线:

  • SCLK: 串行时钟,由主机产生。
  • MOSI: 主机输出,从机输入。
  • MISO: 主机输入,从机输出。
  • CS/SS: 片选信号,由主机控制,低电平有效。

通信过程就是主机拉低某个从机的片选,然后产生时钟,同时在MOSI上输出数据位,并在MISO上采样输入数据位。整个过程是全双工的,即收、发同时进行。协议本身没有规定数据帧之间的间隔、片选建立保持时间、时钟极性和相位之外的任何时序。因此,从纯协议角度看,SPI控制器驱动要做的就是:配置好时钟极性和相位,然后向发送数据寄存器写数据,同时从接收数据寄存器读数据。正如原始资料中提到的S3C2410的例子,轮询模式下,一次收发可能就只是一条写SPTDAT0寄存器和一条读SPRDAT0寄存器的语句。

2.2 SPI控制器硬件:模式与性能的关键

虽然协议简单,但具体到如S3C2410、Blackfin、STM32等芯片内部的SPI控制器硬件,它们提供了不同的功能模块,直接决定了驱动的实现方式和性能上限。我们需要关注以下几点:

  1. 工作模式: 控制器支持主机模式还是从机模式?大部分嵌入式CPU的SPI控制器都支持主机模式。
  2. 时钟与数据宽度: 最大支持多高的SCLK频率?数据宽度是8位还是16位?这决定了通信速率。
  3. FIFO深度: 控制器内部是否有发送和接收FIFO?有多深?FIFO能有效平滑数据流,减少CPU中断频率或DMA请求频率,对提升性能至关重要。例如,Blackfin的SPI控制器就自带一个4字(Word)的FIFO。
  4. 中断与DMA支持: 控制器是否支持在发送FIFO空、接收FIFO满、传输完成等事件时产生中断?是否集成了DMA请求通道,可以与系统DMA控制器联动?这是实现高效、低CPU占用驱动的硬件基础。

原始资料中对比了S3C2410和Blackfin的DMA支持差异,非常典型:

  • S3C2410: 采用“集中式DMA控制器”,有4个独立的DMA通道,SPI、UART等外设可以(通过配置)使用这些通道。你需要手动设置DMA的源地址、目标地址、传输长度等参数,相当于“借用”一个通用的DMA通道给SPI用。
  • Blackfin: 采用“专用DMA通道”,SPI外设直接绑定了特定的DMA通道(如CH_SPI)。设置时更简洁,很多时候只需要关注SPI本身的数据缓冲区地址。

2.3 外设器件时序:驱动复杂度的真正来源

这才是SPI驱动编写中最需要花功夫的地方。SPI协议没要求,但具体的外设芯片(如Flash、ADC、传感器、CAN控制器)有自己的命令集和时序要求。例如:

  • 命令-响应式: 如读取Flash芯片ID。主机先发送一个字节的命令(如0x9F),然后连续接收几个字节的响应。这要求驱动能灵活组合发送和接收。
  • 特定延时要求: 如某些ADC芯片,在片选有效后,需要等待一个t_SETUP时间才能发送第一个命令字节;两次读操作之间可能需要一个t_CONV转换时间。这些时间如果很短,可以用空操作指令nop来忙等;如果较长(几毫秒以上),就必须在驱动中释放CPU,用内核定时器或工作队列来调度。
  • 非标准数据帧: 有些器件的数据帧可能不是8的整数倍,或者需要CPOL/CPHA的特定组合。

原始资料中提到的ADS7846触摸屏控制器就是一个绝佳案例。它的驱动复杂,并非因为SPI通信本身,而是因为触摸屏的应用特性:需要周期性采样,但用户可能长时间不触摸;读取坐标需要发送一系列命令字节并接收多个数据字节;为了不阻塞上层GUI,读取操作必须设计成非阻塞(noblock)或异步方式。于是,驱动里就引入了tasklet、定时器、等待队列等机制,这些都属于Linux内核驱动框架的范畴,与SPI底层寄存器操作是解耦的。

核心心得: 写SPI驱动时,一定要把这三层分开思考。先确保SPI控制器本身的读写函数(spi_transfer_one)是正确的,它能根据给定的时钟参数和字节流完成一次基本的全双工传输。然后,再基于这个基础函数,去实现具体外设芯片要求的命令序列和时序逻辑。这样结构清晰,也便于调试和复用。

3. 三种传输模式详解:从轮询到DMA的演进之路

理解了上述三层关系,我们再来看SPI控制器支持的三种典型数据传输模式:轮询、中断和DMA。它们代表了性能、CPU占用和代码复杂度之间的不同权衡。

3.1 轮询模式:简单粗暴的起点

轮询模式是理解和调试SPI通信的起点。其核心逻辑就是“忙等”:CPU不断读取SPI控制器的状态寄存器,检查发送缓冲区是否为空(可写入新数据),或者接收缓冲区是否满(有数据可读)。

典型代码流程(以发送一字节,接收一字节为例):

// 1. 等待发送缓冲区为空 while (!(read_spi_status() & TX_BUFFER_EMPTY_FLAG)) { cpu_relax(); // 避免过度消耗CPU,对于某些架构是空操作或提示编译器优化 } // 2. 写入要发送的数据到数据寄存器 write_spi_data(tx_data); // 3. 等待接收缓冲区满(数据已移入) while (!(read_spi_status() & RX_BUFFER_FULL_FLAG)) { cpu_relax(); } // 4. 从数据寄存器读取接收到的数据 rx_data = read_spi_data();

优点:

  • 代码极其简单,逻辑直白,非常适合初期调试和验证硬件连接。
  • 不涉及中断上下文、并发等复杂的内核编程概念。

致命缺点:

  • CPU占用率100%:在等待期间,CPU什么也干不了,严重浪费系统资源。
  • 实时性差:如果SPI时钟很慢,或者需要等待外设响应,CPU会被长时间挂起,无法响应其他更紧急的事件。
  • 无法用于非阻塞操作:用户空间的read/write调用会被一直阻塞,直到整个传输完成。

因此,轮询模式仅适用于以下场景:系统非常简单(没有其他任务);SPI通信是偶尔发生的、短小的操作;或者是在系统初始化阶段进行一些简单的器件配置。

3.2 中断模式:解放CPU的必由之路

中断模式是生产级驱动的基础。其核心思想是:CPU启动传输后就去处理其他任务,当SPI控制器完成一个数据单元的传输(如发送完、接收到数据)时,通过中断来通知CPU进行处理。

驱动设计要点:

  1. 中断处理函数: 必须快速、简洁。通常只做最必要的工作:读取数据到缓冲区、更新缓冲区指针、唤醒等待数据的进程。复杂的数据处理应放到tasklet、工作队列或内核线程中。
  2. 缓冲区管理: 驱动需要维护一个或多个内核缓冲区(通常是环形缓冲区kfifo)来暂存收发数据。中断处理函数向里填数据或从里取数据。
  3. 用户接口: 提供给用户空间read/write的系统调用。这些调用可能将用户进程睡眠(阻塞模式),直到所需数据准备好或发送缓冲区有空闲,然后通过wait_event_interruptiblewake_up_interruptible这类机制与中断处理函数同步。

原始资料中提到的interruptible_sleep_on/wake_up_interruptible是较老的内核API,现代驱动更推荐使用wait_queue_head_t配合wait_event_interruptiblewake_up

一个简化的中断驱动read流程:

static ssize_t spi_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { struct spi_device *dev = filp->private_data; int ret = 0; // 1. 检查当前是否有足够数据可读,如果没有且是非阻塞模式,直接返回-EAGAIN if (filp->f_flags & O_NONBLOCK) { if (kfifo_is_empty(&dev->rx_fifo)) return -EAGAIN; } // 2. 如果是阻塞模式,且数据不够,则睡眠等待 while (kfifo_len(&dev->rx_fifo) < count) { if (wait_event_interruptible(dev->rx_waitq, !kfifo_is_empty(&dev->rx_fifo))) { // 被信号中断 return -ERESTARTSYS; } } // 3. 从内核fifo拷贝数据到用户空间 ret = kfifo_to_user(&dev->rx_fifo, buf, count, &count); // 4. 如果fifo空了,可以停止SPI接收中断(如果之前是连续接收模式) if (kfifo_is_empty(&dev->rx_fifo)) { disable_spi_rx_interrupt(dev); } return ret ? ret : count; }

中断处理函数spi_irq_handler

static irqreturn_t spi_irq_handler(int irq, void *dev_id) { struct spi_device *dev = dev_id; u8 rx_data; // 1. 判断中断类型,清除中断标志 if (spi_status & RX_FULL_FLAG) { rx_data = read_spi_data(); // 2. 将数据放入缓冲区 if (!kfifo_put(&dev->rx_fifo, rx_data)) { // 缓冲区满,数据丢失,可以记录错误 dev->stats.rx_over_errors++; } else { // 3. 缓冲区有数据了,唤醒可能正在睡眠的读进程 wake_up_interruptible(&dev->rx_waitq); // 4. 可能还需要通知异步IO(fasync) if (dev->async_queue) kill_fasync(&dev->async_queue, SIGIO, POLL_IN); } } // ... 处理发送完成中断 return IRQ_HANDLED; }

中断模式平衡了性能和复杂度,是大多数中等速率SPI通信(几百KHz到几MHz)的首选。

3.3 DMA模式:追求极致性能的利器

当SPI通信数据量很大(如读写大容量SPI Flash)、速率很高,或者系统对CPU实时性要求极高时,中断模式仍然会带来可观的CPU开销(每次中断都有上下文切换的成本)。此时,DMA模式就成为必然选择。

DMA模式下,数据的搬运(从内存到SPI发送寄存器,或从SPI接收寄存器到内存)完全由DMA控制器硬件完成,无需CPU介入。CPU只需要在传输开始前配置好DMA,在传输结束后处理一个完成中断即可。

DMA驱动实现的关键步骤:

  1. 申请DMA通道: 使用request_dmadma_request_channel(新内核API)申请一个可用的DMA通道。
  2. 配置DMA: 设置源地址、目标地址、传输方向、数据宽度、地址递增模式、传输总量等。这里要特别注意缓存一致性问题。如果数据缓冲区在CPU缓存中,DMA控制器访问的物理内存可能不是最新数据。必须使用dma_map_single等API来获取总线上一致的地址,或者在驱动中手动处理缓存(如原始资料中的blackfin_dcache_invalidate_range)。
  3. 配置SPI控制器为DMA模式: 告诉SPI控制器,它的数据来源和去向是DMA,而不是CPU直接读写数据寄存器。例如,设置BIT_CTL_TIMOD_DMA_RX(DMA接收模式)。
  4. 启动传输: 先使能DMA通道,再使能SPI控制器。顺序很重要,防止SPI已经开始工作而DMA还没准备好。
  5. 等待完成: 和中断模式类似,用户进程调用read会睡眠在等待队列上。当DMA传输完成,会触发DMA完成中断(或SPI传输完成中断)。
  6. 中断处理: 在中断处理函数中,确认传输完成,更新状态,唤醒等待的进程,并禁用SPI和DMA。
  7. 释放资源: 传输结束后,释放DMA映射,必要时释放DMA通道。

原始资料中Blackfin的spi_read函数清晰地展示了这个过程:配置SPI为DMA接收模式 -> 配置DMA(目标地址是用户缓冲区buf) -> 使能DMA -> 使能SPI -> 进程睡眠等待 -> DMA完成中断唤醒进程。

重要避坑点:DMA与缓存一致性这是DMA驱动最容易出错的地方。假设用户空间传递了一个缓冲区地址buf给驱动的read函数。这个地址是虚拟地址,对应的物理页面可能已经被CPU缓存。如果你直接把这个地址对应的物理地址交给DMA,DMA会直接从内存(而不是缓存)读写数据。结果就是:CPU看到的是缓存里的旧数据,DMA写入的新数据CPU看不到;或者CPU写入缓存的新数据,DMA读不到。解决方法就是使用内核提供的DMA API:dma_map_singledma_unmap_single。对于Blackfin这种有独立指令/数据缓存和DMA的架构,有时需要手动刷新缓存,如代码中的blackfin_dcache_invalidate_range

4. 实战:构建一个健壮的Linux SPI设备驱动框架

理解了原理和模式,我们来看如何在一个现代Linux内核中,构建一个完整的SPI设备驱动。这里我们以连接一个SPI接口的加速度计传感器为例。

4.1 驱动框架与数据结构

首先,我们定义描述我们设备的核心数据结构:

struct my_spi_device { struct spi_device *spi; // 内核SPI核心层提供的设备对象 struct mutex lock; // 互斥锁,防止并发访问 struct completion data_ready; // 完成量,用于同步 u8 *rx_buffer; u8 *tx_buffer; dma_addr_t dma_rx_addr; dma_addr_t dma_tx_addr; bool use_dma; struct fasync_struct *async_queue; // 用于异步通知 };

4.2 初始化与探测

在驱动的probe函数中,我们需要:

  1. 分配设备结构体。
  2. 初始化互斥锁、完成量等。
  3. 从设备树或平台数据中获取SPI配置(如模式、最大频率)。
  4. 与SPI核心建立连接:spi_setup(spi)
  5. 根据硬件能力决定是否使用DMA,并预先分配DMA缓冲区。
  6. 初始化传感器硬件(通过SPI发送初始化命令序列)。
  7. 注册字符设备或inputiio等子系统设备,创建sysfs节点。
static int my_spi_probe(struct spi_device *spi) { struct my_spi_device *dev; int ret; // 1. 分配设备结构 dev = devm_kzalloc(&spi->dev, sizeof(*dev), GFP_KERNEL); // 2. 初始化 mutex_init(&dev->lock); init_completion(&dev->data_ready); dev->spi = spi; // 3. 设置SPI模式 spi->mode = SPI_MODE_0; spi->max_speed_hz = 10000000; // 10MHz ret = spi_setup(spi); // 4. 尝试申请DMA if (dma_set_mask_and_coherent(&spi->dev, DMA_BIT_MASK(32))) { dev_info(&spi->dev, "DMA not supported, using PIO mode.\n"); dev->use_dma = false; } else { // 分配一致性DMA缓冲区 dev->tx_buffer = dma_alloc_coherent(&spi->dev, BUF_SIZE, &dev->dma_tx_addr, GFP_KERNEL); // ... 类似分配rx_buffer dev->use_dma = (dev->tx_buffer && dev->rx_buffer); } // 5. 初始化硬件 ret = my_spi_init_hardware(dev); // 6. 注册设备到内核相应框架(例如IIO) ret = iio_device_register(...); // 7. 将驱动数据保存到spi_device spi_set_drvdata(spi, dev); return 0; }

4.3 实现文件操作与SPI传输

这是驱动的核心,我们需要实现read,write,ioctl,poll等系统调用接口。关键在于实现一个通用的、支持可配置传输模式的spi_transfer函数。

static int my_spi_transfer(struct my_spi_device *dev, const u8 *tx_buf, u8 *rx_buf, size_t len) { struct spi_transfer t = { .tx_buf = tx_buf, .rx_buf = rx_buf, .len = len, .delay_usecs = dev->delay_us, // 某些器件需要的帧间延迟 }; struct spi_message m; int status; spi_message_init(&m); spi_message_add_tail(&t, &m); if (dev->use_dma && len > DMA_THRESHOLD) { // 对于大块数据,使用DMA映射 t.tx_dma = dma_map_single(&dev->spi->dev, (void*)tx_buf, len, DMA_TO_DEVICE); t.rx_dma = dma_map_single(&dev->spi->dev, rx_buf, len, DMA_FROM_DEVICE); t.is_dma_mapped = 1; // 告诉SPI核心我们已经做了DMA映射 } mutex_lock(&dev->lock); status = spi_sync(dev->spi, &m); // 同步传输,会阻塞直到完成 mutex_unlock(&dev->lock); if (dev->use_dma && len > DMA_THRESHOLD) { dma_unmap_single(...); } return status; }

spi_sync是Linux SPI子系统提供的核心API,它封装了底层控制器驱动的传输操作。控制器驱动(如spi-s3c64xx.c,spi-bfin5xx.c)会根据自己的能力,以轮询、中断或DMA方式去完成这次spi_transfer。作为设备驱动开发者,我们通常不需要关心底层具体用了哪种模式,除非有特殊的性能调优需求。

4.4 处理外设特定时序与命令

对于我们的加速度计,读取数据可能需要一个特定的命令序列。例如,先发送一个“读寄存器”的命令字节(如0x80 | reg_addr),然后接收数据。这个逻辑应该封装在设备驱动内部。

static int my_spi_read_reg(struct my_spi_device *dev, u8 reg, u8 *val) { u8 tx_buf[2] = {0x80 | reg, 0}; // 命令 + 哑元字节用于接收 u8 rx_buf[2]; int ret; ret = my_spi_transfer(dev, tx_buf, rx_buf, 2); if (ret == 0) { *val = rx_buf[1]; // 第二个字节是读回的数据 } return ret; }

对于有严格时序要求的,比如发送命令后需要等待t_CONV(转换时间),可以在spi_transferdelay_usecs字段设置,或者传输完成后主动udelaymsleep。对于更长的等待,应该使用内核定时器或工作队列,避免长时间阻塞内核。

5. 调试技巧与常见问题排查实录

即使理解了所有原理,调试SPI驱动仍然可能遇到各种奇怪的问题。下面是我总结的一些实战排查技巧。

5.1 基础检查清单

  1. 电气连接: 用万用表检查VCC、GND。用示波器或逻辑分析仪看SCLK、MOSI、MISO、CS波形。确保电压电平正确,没有短路。
  2. 片选信号: 这是最容易被忽略的。确认片选信号在传输期间是持续有效的低电平。有些控制器驱动在每传输一个spi_transfer后会自动拉高片选,如果外设要求一次通信中CS持续有效,就需要将多个spi_transfer放入一个spi_message中,并设置.cs_change = 0
  3. 时钟极性与相位: 这是SPI的“模式”。用逻辑分析仪抓取波形,对照外设数据手册的时序图,看时钟空闲电平(CPOL)和数据采样边沿(CPHA)是否正确。这是导致“能写不能读”或数据错位的常见原因。
  4. 时钟频率: 先从低速开始(如100KHz),确保通信稳定,再逐步提高。过高的频率可能导致信号完整性问题。

5.2 软件调试手段

  1. 内核日志: 在驱动中关键位置添加pr_debugdev_dbg。通过dynamic_debug机制可以动态打开/关闭这些调试信息。
  2. Sysfs调试接口: 为驱动创建一些调试属性,例如可以直接读写寄存器的文件。这在/sys/kernel/debug下通过debugfs实现非常方便。
  3. SPI Loopback测试: 将控制器的MOSI和MISO短接,然后发送一个已知的数据模式并读回。如果读回的数据与发送的一致,说明SPI控制器底层驱动基本正常。这是一个隔离硬件问题的好方法。
  4. 用户空间测试工具: 使用spidev_test(内核源码tools/spi目录下)或自己编写小程序,直接操作/dev/spidevX.Y设备节点,发送特定数据序列,验证通信。

5.3 典型问题与解决方案

问题现象可能原因排查思路与解决方案
写入成功,读取全为0或0xFF1. MISO线连接错误或外设未驱动。
2. 时钟相位(CPHA)设置错误,采样边沿不对。
3. 外设需要特定的命令格式或延时。
1. 用示波器看MISO线上是否有波形。检查外设供电和使能。
2. 尝试切换SPI模式(0/1/2/3)。
3. 仔细阅读外设手册,确认命令序列,并在命令间增加微小延时(udelay)。
数据错位(如读到的数据总是左移或右移一位)1. 数据位序(MSB/LSB)设置错误。
2. 时钟极性错误,导致在错误的时钟边沿采样。
1. 检查SPI控制器和外设的位序要求,尝试设置spi_device.bits_per_wordspi_transfer.cs_change等属性。
2. 用逻辑分析仪精确对照时钟和数据边沿。
高频率下数据不稳定1. 信号完整性问题(过冲、振铃)。
2. PCB走线过长或阻抗不匹配。
3. 电源噪声。
1. 降低频率测试。在SCLK和MOSI/MISO线上串联小电阻(如22-100欧姆)。
2. 检查PCB layout,确保SPI走线尽量短,远离噪声源。
3. 在电源引脚增加去耦电容。
DMA传输数据错误1.缓存一致性问题(最常见)。
2. DMA缓冲区未按Cache行对齐。
3. DMA传输长度设置错误。
1.务必使用dma_alloc_coherent分配DMA缓冲区,或使用dma_map/unmap_singleAPI
2. 确保缓冲区地址和长度是Cache行大小的整数倍。
3. 核对DMA控制器的传输计数寄存器设置,确认是字节数还是传输次数。
中断驱动下系统偶尔卡死1. 中断处理函数耗时过长,或发生了阻塞。
2. 中断上下文中调用了可能睡眠的函数(如mutex_lock)。
3. 中断标志未正确清除,导致中断风暴。
1. 遵循“快进快出”原则,中断处理函数只做最紧急的事(读寄存器、推入缓冲区),复杂处理交给taskletworkqueue
2. 检查中断处理函数中的所有函数调用,确保它们不会睡眠。使用spin_lock代替mutex
3. 在读取数据后,立即清除外设和中断控制器中的中断标志位。

5.4 性能优化点

当驱动基本稳定后,可以考虑以下优化:

  • 调整DMA阈值: 并非所有传输都用DMA就好。DMA有启动开销(配置寄存器)。对于很小的数据包(比如几个字节),使用PIO(轮询/中断)可能更快。通过测试找到一个最优的DMA_THRESHOLD
  • 使用spi_message队列: 如果需要连续进行多个不相关的SPI传输,可以将它们加入一个spi_message队列,然后使用spi_async进行异步传输。这允许SPI控制器在硬件可能的情况下进行流水线操作。
  • 合理使用双缓冲(Ping-Pong Buffer): 在高速连续流数据采集场景下,可以准备两个DMA缓冲区。当DMA正在填充缓冲区A时,CPU可以处理已经满的缓冲区B。处理完后交换角色,实现无缝数据流。

最后,我想说的是,SPI驱动就像一把瑞士军刀,协议本身简单,但要在复杂的系统环境中用好它,需要你对硬件控制器、内核驱动框架、以及具体外设的特性都有深入的理解。从最简单的轮询开始,逐步迭代到中断和DMA,每一步都扎实地测试和调试,你就能构建出稳定高效的SPI通信链路。记住,示波器和逻辑分析仪是你最好的朋友,而清晰的分层设计和丰富的调试日志,则是你快速定位问题的利器。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询