SPI驱动开发实战:轮询、中断与DMA模式详解与性能优化
1. 项目概述从IIC到SPI嵌入式总线驱动的实战视角在嵌入式开发领域IIC和SPI是两种绕不开的串行通信总线。很多工程师朋友在论坛里讨论IIC驱动时常常会提到它的时序复杂、协议严格需要处理起始位、停止位、应答位等一系列信号。相比之下SPI总线就显得“直来直去”得多。我最近在调试一块基于S3C2410的旧板子上面挂载了MCP2510 CAN控制器和ADS7846触摸屏芯片两者都通过SPI通信。在重构驱动代码的过程中我重新梳理了SPI驱动的几种实现模式特别是轮询、中断和DMA这三种方式在实际项目中的取舍与实现细节。这篇文章我就结合这些实战经验把SPI驱动的核心脉络、代码骨架以及那些容易踩坑的地方掰开揉碎了讲清楚。无论你是刚接触嵌入式驱动的新手还是想优化现有通信性能的老手希望这些从实际项目中总结出的思路和代码片段能给你带来一些直接的参考价值。2. SPI驱动核心思路与三种模式解析2.1 SPI协议的本质为什么说它比IIC简单很多资料会从四根线SCLK, MOSI, MISO, CS讲起但理解SPI简单性的关键在于它的协议层或者说它几乎没有“协议”。IIC是一个多主多从、基于地址寻址、有严格起始/停止信号和应答机制的“智能”总线。而SPI更像一个受时钟严格同步的“移位寄存器”通道。主设备产生时钟通过MOSI线移出数据同时从设备通过MISO线移入数据收发是同步完成的。它没有寻址概念片选信号决定与哪个从设备通信没有复杂的握手信号通信速率理论上只受限于时钟频率和器件性能。这种简单性直接反映在驱动代码上。对于一个最基本的轮询模式SPI发送函数其核心可能就是向发送数据寄存器写入一个值。例如在S3C2410上发送一个字节数据的函数可能精简到只有一两行void spi_tx_byte(uint8_t data) { while (!(rSPSTA0 0x01)); // 等待发送缓冲区空轮询状态位 rSPTDAT0 data; // 写入数据启动发送 }接收也同样直接读取接收数据寄存器即可。这种“寄存器读写即通信”的特性是SPI驱动代码量通常远小于IIC的根本原因。但简单不代表没有讲究时钟极性、相位、数据位序这些配置如果与从设备不匹配通信就会完全失败这是第一个需要注意的地方。2.2 三种驱动模式的选择逻辑与适用场景SPI驱动通常有三种实现模式轮询、中断和DMA。选择哪一种不取决于哪种技术更“高级”而完全取决于具体的应用场景和性能需求。轮询模式是最简单、最可靠的模式。CPU不断查询SPI控制器的状态寄存器检查数据是否发送完毕或是否接收到新数据。它的优点是代码简单直观没有上下文切换开销在低速、间歇性通信的场景下非常合适。例如读取一个温度传感器每秒只需读取几次数据用轮询模式完全足够且稳定性极高。缺点也明显CPU被长时间占用在等待数据传输期间无法执行其他任务严重浪费计算资源在高频或大数据量通信时会导致系统响应迟缓。中断模式引入了异步通知机制。CPU启动SPI传输后就可以去处理其他任务当传输完成时SPI控制器产生一个中断CPU再跳转到中断服务程序处理数据。这种方式解放了CPU提高了系统整体的并发处理能力。它适用于数据交换频率中等、且系统有其他任务需要及时响应的场景。例如一个通过SPI接收用户输入事件的设备使用中断可以确保主程序不被阻塞及时响应用户操作。但中断模式会增加代码复杂度需要处理中断上下文的资源竞争问题并且中断本身的响应和处理也有一定开销。DMA模式是性能最高的模式。DMA控制器可以在不需要CPU介入的情况下直接在SPI数据寄存器和系统内存之间搬运数据。CPU只需要配置好DMA的源地址、目标地址和数据长度然后启动传输即可。传输完成后DMA控制器会通过中断通知CPU。这种模式将CPU从繁琐的数据搬运工作中彻底解放出来特别适合高速、持续、大批量的数据传输场景。例如通过SPI接口读取高分辨率ADC的连续采样数据或者向SPI接口的显示屏发送大量帧缓冲数据DMA模式几乎是唯一的选择否则CPU负载会不堪重负。注意模式选择不是非此即彼。一个成熟的驱动框架可能会根据传输的数据量动态选择模式。例如传输单个字节命令用轮询传输几十个字节的数据用中断传输几KB的块数据则用DMA。这需要在驱动设计之初就考虑好。3. 轮询模式SPI驱动的实现与细节3.1 基础轮询驱动的代码骨架让我们从一个最基础的、用于读取ADS7846触摸屏芯片的轮询模式SPI驱动片段开始。虽然原始代码片段看起来是一系列函数调用但我们需要理解其背后的完整上下文。首先SPI控制器需要初始化。这包括配置时钟极性、相位、数据位宽、主从模式、波特率等。以S3C2410为例初始化函数可能如下void spi_init(void) { // 1. 配置GPIO引脚为SPI功能SCLK, MOSI, MISO, CSn rGPGCON (rGPGCON ~(0xFF 12)) | (0x55 12); // 假设SPI0在GPG6~GPG9 // 2. 配置SPI控制寄存器SPCON0 // 主模式使能SCK格式ACPOL0 CPHA0轮询模式 rSPCON0 (06) | (15) | (14) | (03) | (02) | (0x0); // 3. 配置SPI波特率预分频器 rSPPRE0 0x20; // 根据PCLK和所需波特率计算得出例如PCLK50MHz 0x20对应约781Kbps // 4. 清空状态寄存器 rSPSTA0 0x0; }初始化完成后数据的收发函数是核心。一个完整的“发送-接收”事务通常需要片选控制。以下是结合了片选操作的收发函数uint8_t spi_transfer_byte(uint8_t tx_data) { uint8_t rx_data; // 等待发送缓冲区为空TX ready while (!(rSPSTA0 0x01)); // 写入数据启动传输 rSPTDAT0 tx_data; // 等待接收缓冲区满RX ready while (!(rSPSTA0 0x02)); // 读取接收到的数据 rx_data rSPRDAT0; return rx_data; } // 读取ADS7846 X坐标的示例函数 uint16_t ads7846_read_x(void) { uint16_t value; uint8_t x_upper, x_lower; // 拉低片选选中ADS7846 ADS7846_CS_LOW(); // 发送控制字并读取数据注意SPI是全双工发送的同时也在接收 spi_transfer_byte(0xD0); // 启动 通道选择 12位模式 差分输入 x_upper spi_transfer_byte(0x00); // 发送哑元数据读取高8位 x_lower spi_transfer_byte(0x00); // 发送哑元数据读取低4位实际数据在返回字节的高4位 // 拉高片选结束传输 ADS7846_CS_HIGH(); // 组合数据ADS7846返回12位数据先高8位后低4位在第二个字节的高4位 value ((uint16_t)x_upper 4) | (x_lower 4); return value; }3.2 轮询模式下的时序与器件协议分离这是理解SPI驱动的一个关键点。原始材料中提到的“SPI收、发数据就是一句话而已”指的是SPI总线控制器层面的操作。而像0xD0、0x00这样的具体命令序列以及数据组合方式例如高8位、低4位完全是所连接的从设备如ADS7846、MCP2510的通信协议要求。SPI控制器驱动负责提供spi_transfer_byte()这样的底层函数确保在正确的时钟沿下发送和接收一个字节。它不关心发送的内容是什么。设备驱动如ads7846_read_x()它了解ADS7846芯片的数据手册。它知道要发送什么命令字节来启动X坐标转换知道需要连续进行几次传输才能凑齐一个完整的12位数据也知道如何将收到的几个字节拼接成最终的有效数据。在编写驱动时一定要在头脑中清晰地划分这两层。这样当你更换不同的SPI从设备时只需要重写设备驱动层而底层的SPI控制器驱动可以复用。这种分层思想是嵌入式驱动模块化设计的基础。实操心得调试SPI通信时逻辑分析仪是必不可少的工具。不要只盯着代码看要用逻辑分析仪抓取SCLK、MOSI、MISO、CS四根线上的实际波形。首先确认时钟极性、相位是否正确然后对照从设备的数据手册逐位核对发送的命令和接收的数据是否匹配。很多时候问题就出在数据位序MSB/LSB搞反了或者片选信号的建立/保持时间不满足要求。4. 中断模式SPI驱动的设计与性能考量4.1 中断驱动的基本框架当数据传输频率提高或者系统无法忍受轮询带来的CPU空耗时就需要引入中断。中断驱动的核心思想是“异步通知”。驱动框架会比轮询模式复杂通常涉及以下几个部分初始化与中断申请在驱动初始化时申请SPI传输完成中断。static int spi_driver_init(void) { // ... 初始化SPI控制器硬件 ... // 申请中断中断处理函数为spi_interrupt_handler if (request_irq(IRQ_SPI0, spi_interrupt_handler, IRQF_SHARED, spi_driver, spi_dev)) { printk(KERN_ERR Failed to request SPI interrupt\n); return -EBUSY; } // ... 其他初始化 ... }数据传输流程应用层调用read/write时驱动启动传输然后让出CPU。static ssize_t spi_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { struct spi_device *dev filp-private_data; dev-rx_buffer kmalloc(count, GFP_KERNEL); dev-rx_count count; dev-rx_done 0; // 1. 配置SPI为接收模式使能中断 spi_configure_for_rx(); spi_enable_interrupt(); // 2. 启动SPI接收例如通过写入一个哑元数据来产生时钟 spi_start_transfer(); // 3. 等待中断唤醒。如果使用非阻塞I/O这里应返回-EAGAIN wait_event_interruptible(dev-read_waitq, dev-rx_done); // 4. 中断处理程序完成后将数据拷贝到用户空间 if (copy_to_user(buf, dev-rx_buffer, count)) { kfree(dev-rx_buffer); return -EFAULT; } kfree(dev-rx_buffer); return count; }中断服务程序处理传输完成事件唤醒等待的进程。static irqreturn_t spi_interrupt_handler(int irq, void *dev_id) { struct spi_device *dev (struct spi_device *)dev_id; uint8_t data; // 1. 读取状态寄存器确认是接收完成中断 if (rSPSTA0 RX_READY_BIT) { data rSPRDAT0; // 读取数据 if (dev-rx_index dev-rx_count) { dev-rx_buffer[dev-rx_index] data; if (dev-rx_index dev-rx_count) { dev-rx_done 1; wake_up_interruptible(dev-read_waitq); // 唤醒read进程 } else { // 如果还没收完可能需要启动下一次传输例如再写入一个哑元数据 spi_trigger_next_byte(); } } } // 2. 清除中断标志位非常重要 rSPSTA0 CLEAR_INTERRUPT_BIT; return IRQ_HANDLED; }4.2 提升中断驱动性能Tasklet与工作队列原始材料中提到“用tasklet方式在中断处理函数中释放CPU”。这是一个重要的Linux内核编程优化点。中断处理程序ISR要求执行速度尽可能快因为它会屏蔽其他同级或低级中断。如果中断处理中有大量耗时的操作比如复杂的数据处理会严重影响系统实时性。解决方案是将耗时操作推迟到中断上下文之外执行。Linux内核提供了几种机制Tasklet一种软中断运行在中断下半部。它保证同一个tasklet不会在多个CPU上并发执行适合处理轻量级、可延迟的任务。static void spi_tasklet_func(unsigned long data) { // 这里进行复杂的数据处理 process_received_data(...); } DECLARE_TASKLET(spi_tasklet, spi_tasklet_func, 0); // 在中断处理程序中 static irqreturn_t spi_interrupt_handler(...) { // ... 读取数据到缓冲区 ... tasklet_schedule(spi_tasklet); // 调度tasklet // ... 清除中断 ... return IRQ_HANDLED; }工作队列将工作推送到一个内核线程中执行可以睡眠适合处理更复杂、可能阻塞的操作。static void spi_work_func(struct work_struct *work) { // 可以睡眠可以执行更复杂的操作 msleep(10); process_data_and_notify_user(...); } DECLARE_WORK(spi_work, spi_work_func); // 在中断处理程序中 static irqreturn_t spi_interrupt_handler(...) { schedule_work(spi_work); return IRQ_HANDLED; }对于触摸屏驱动如ADS7846原始材料提到它结合了tasklet和timer。这是因为触摸屏的采样需要防抖和去噪。中断可能因屏幕轻微抖动而频繁触发。一个常见的做法是在中断中或通过tasklet读取原始坐标然后启动一个定时器。如果在定时器到期前没有新的中断意味着触摸已稳定再上报最终的坐标值给上层应用。这种“中断定时器”的组合有效平衡了响应速度和数据稳定性。5. DMA模式SPI驱动的高效实现5.1 DMA原理与SPI结合的配置要点DMA是直接内存访问的缩写其核心目标是让数据在外设和内存之间直接流动无需CPU参与每一次搬运。对于SPI这种流式数据接口DMA模式能极大提升吞吐量并降低CPU占用。配置SPI的DMA传输需要理解几个关键角色和它们的协作关系SPI控制器作为数据的生产者发送时或消费者接收时。DMA控制器作为数据的搬运工。内存缓冲区数据搬运的源或目的地。以S3C2410接收数据为例配置流程如下代码基于原始材料中的寄存器操作并加以注释void spi_dma_receive_setup(char *buffer, int length) { // 1. 配置SPI控制器为DMA接收模式 // SPCON0: [6:5]01 (DMA模式), [4]1 (使能SCK), [3:2]00 (主模式), [1:0]00 (格式A) rSPCON0 (16) | (05) | (14) | (03) | (02) | (0x0); // 2. 配置DMA通道1假设SPI0使用DMA CH1 // a. 设置源地址SPI接收数据寄存器地址 (0x59000014) rDISRC1 0x59000014; // b. 设置源控制LOC1 (源在外设总线), INC0 (地址不递增因为总是读同一个寄存器) rDISRCC1 (11) | (00); // c. 设置目标地址用户提供的缓冲区地址 rDIDST1 (uint32_t)buffer; // d. 设置目标控制LOC0 (目标在系统总线/SDRAM), INC1 (地址递增因为要连续存放) rDIDSTC1 (01) | (10); // e. 设置DMA控制寄存器 (DCON1) - 这是最复杂的部分 // DMD_HS1 (握手模式), SYNC1 (同步到HCLK), INT1 (传输完成产生中断) // TSZ0 (单位传输), SERVMODE0 (单服务模式) // HWSRCSEL011 (选择SPI作为DMA请求源), SWHW_SEL1 (硬件触发) // RELOAD0 (不重载,传输一次), DSZ00 (字节传输), TClength (传输数量) rDCON1 (131)|(130)|(129)|(028)|(027)|(0x324)|(123)|(022)|(0x020)|(length); // 3. 启动DMA通道 // DMASKTRIG1: STOP0, ON_OFF1 (开启DMA), SW_TRIG1 (软件触发启动) rDMASKTRIG1 (02) | (11) | (10); // 4. 使能SPI控制器开始产生时钟和数据请求 rSPCON0 | (14); // 确保SCK使能 // 通常还需要向SPI发送寄存器写入数据或哑元数据来启动时钟具体取决于硬件 rSPTDAT0 0xFF; // 发送哑元数据启动SPI时钟从而触发DMA请求 }5.2 Blackfin处理器SPI DMA驱动实例分析原始材料中提供了Blackfin处理器上一个更完整的SPI DMA驱动框架。我们重点分析其read函数和中断处理函数的协作这比裸寄存器操作更具参考价值。static ssize_t spi_read(struct file *filp, char *buf, size_t count, loff_t *pos) { spi_device_t *pdev filp-private_data; unsigned short regdata; pdev-done 0; pdev-tmode RECEIVE; // 关键步骤1: 无效化数据缓存确保DMA写入内存后CPU能读到最新数据 // 对于有数据缓存(Cache)的CPU这是必须的。DMA直接写内存会绕过Cache导致CPU读到旧数据。 blackfin_dcache_invalidate_range(buf, buf count*2); // 关键步骤2: 配置SPI为DMA接收模式 get_spi_reg(SPI_CTL, ®data); set_spi_reg(SPI_CTL, regdata | BIT_CTL_TIMOD_DMA_RX); // 关键步骤3: 配置DMA通道 pdev-dma_config | (WNR | RESTART | DI_EN); // 设置方向为外设到内存使能DMA中断等 set_dma_config(CH_SPI, pdev-dma_config); set_dma_start_addr(CH_SPI, buf); // 目标地址直接是用户空间缓冲区需已映射到内核 set_dma_x_count(CH_SPI, count); // 设置传输数量 set_dma_x_modify(CH_SPI, 2); // 地址增量2表示16位数据假设字宽16bit __builtin_bfin_ssync(); // 同步指令确保配置生效 enable_dma(CH_SPI); // 使能DMA通道 // 关键步骤4: 使能SPI控制器开始产生时钟 get_spi_reg(SPI_CTL, ®data); set_spi_reg(SPI_CTL, regdata | BIT_CTL_ENABLE); // 关键步骤5: 等待传输完成 if (pdev-nonblock) { return -EAGAIN; // 非阻塞模式直接返回 } else { // 阻塞模式睡眠等待中断唤醒 wait_event_interruptible(*(pdev-rx_avail), pdev-done); return count; // 被唤醒后说明数据已在buf中直接返回 } }DMA传输完成的中断处理函数是驱动协调的关键static irqreturn_t spidma_irq(int irq, void *dev_id, struct pt_regs *regs) { spi_device_t *pdev (spi_device_t*)dev_id; unsigned short regdata; clear_dma_irqstat(CH_SPI); // 清除DMA中断标志 pdev-done 1; // 设置完成标志 // 可选发送异步通知信号给应用程序 if (pdev-fasync) { kill_fasync((pdev-fasync), SIGIO, POLL_IN); } // 唤醒在read函数中睡眠的进程 wake_up_interruptible(pdev-rx_avail); // 等待SPI传输真正结束确保FIFO为空 if (pdev-tmode TRANSMIT) { while (*pSPI_STAT TXS); // 等待发送移位寄存器空 } else { while (*pSPI_STAT RXS); // 等待接收移位寄存器空 } // 传输完成禁用SPI根据实际需求也可保持使能 get_spi_reg(SPI_CTL, ®data); set_spi_reg(SPI_CTL, regdata ~BIT_CTL_ENABLE); return IRQ_HANDLED; }这个流程清晰地展示了DMA模式下驱动如何实现“零拷贝”的高效数据传输用户空间的缓冲区地址直接传递给DMA控制器数据搬运完全由硬件完成CPU仅在开始和结束时介入配置与通知。wait_event_interruptible和wake_up_interruptible这对机制完美地实现了阻塞式I/O的同步。注意事项DMA操作涉及物理地址。在Linux内核中用户空间缓冲区地址是虚拟地址不能直接给DMA使用。通常需要使用dma_map_single()等函数将虚拟地址映射为总线地址物理地址。上述Blackfin示例中buf能直接使用可能是因为其驱动在open或ioctl中已经通过mmap将用户缓冲区映射到了内核的DMA可用区域或者Blackfin平台有特殊的地址映射机制。在通用Linux驱动中这一步必不可少且是DMA驱动最容易出错的地方之一。6. 常见问题排查与驱动调试实战技巧6.1 基础通信失败排查清单当SPI通信无法建立时可以按照以下顺序排查电源与硬件连接确认从设备已上电电压在额定范围内。用万用表测量SCLK、MOSI、MISO、CSn线路是否连通有无对地/电源短路。检查上拉电阻。SPI通常不需要上拉但有些开漏输出的MISO可能需要。时钟与片选信号首要检查使用示波器或逻辑分析仪观察SCLK和CSn波形。SCLK是否有时钟输出频率是否正确极性CPOL和相位CPHA是否与从设备要求一致这是最常见的错误来源。CPOL0表示时钟空闲时为低电平CPOL1则为高电平。CPHA0表示数据在时钟的第一个边沿采样CPHA1则表示在第二个边沿采样。CSn片选信号是否在传输开始时有效拉低在传输结束后无效拉高片选信号的建立和保持时间是否满足从设备要求数据线信号MOSI主设备发送的数据是否正确对照数据手册的命令字用逻辑分析仪解码看每一位是否匹配。MISO从设备是否有数据返回如果MISO一直为高阻态或固定电平检查从设备是否被正确选中CSn是否处于正确的模式如ADS7846需要先发送控制字启动转换。软件配置SPI控制器是否已正确使能时钟门控是否打开数据位宽8位/16位是否配置正确波特率是否设置过高超过了从设备或PCB走线的能力尝试降低波特率。驱动代码中的延时是否足够有些从设备在命令之间需要一定的tACQ采集时间或转换时间。6.2 高级问题DMA传输中的数据一致性与中断风暴问题一DMA传输的数据错误或丢失现象使用DMA模式接收数据数据缓冲区中的内容随机错误或者后半部分数据丢失。可能原因与排查缓存一致性问题这是多核CPU或带有数据缓存CPU的典型问题。DMA直接读写物理内存而CPU操作的是缓存中的数据副本。如果CPU在DMA写入前读取过该内存区域数据会缓存在Cache中DMA写入后CPU再次读取到的仍是Cache里的旧数据。解决方案在DMA传输开始前调用dma_map_single()或类似函数如Blackfin的blackfin_dcache_invalidate_range。在传输完成后调用dma_unmap_single()。这些API会处理缓存无效化或写回操作。内存对齐确保DMA缓冲区地址符合DMA控制器对齐要求通常是4字节或8字节对齐。使用kmalloc或dma_alloc_coherent分配DMA缓冲区。DMA缓冲区大小检查DMA传输计数设置是否正确是否超过了分配的缓冲区大小。问题二中断风暴或系统卡死现象启用SPI中断或DMA中断后系统频繁进入中断甚至卡死。可能原因与排查中断标志未清除这是最常见的原因。在中断处理函数中必须读取并清除SPI状态寄存器或DMA中断寄存器中对应的中断标志位。如果忘记清除硬件会持续认为中断未处理从而不断产生中断请求。中断共享问题如果SPI中断号是共享的在request_irq时要使用IRQF_SHARED标志并且在中断处理函数开始时要判断是否是自己设备产生的中断通过读取状态寄存器如果不是应返回IRQ_NONE。中断处理函数耗时过长中断处理中进行了复杂的运算或可能阻塞的操作。务必遵循“快进快出”原则将耗时任务交给tasklet或工作队列。6.3 驱动性能优化与测试建议模式混合使用实现一个灵活的驱动根据传输数据量自动选择模式。例如定义一个阈值如64字节小于该值用轮询或中断单字节传输大于该值用DMA传输。这需要对驱动框架进行良好的抽象。使用SPI消息框架对于Linux内核推荐使用内核提供的spi_message和spi_transfer结构体来编写驱动。它能更好地处理复杂的传输序列如先发命令再收数据中间改变时钟参数并兼容各种SPI控制器驱动。struct spi_transfer t { .tx_buf command, .rx_buf response, .len 4, .delay_usecs 10, // 命令间延时 }; struct spi_message m; spi_message_init(m); spi_message_add_tail(t, m); spi_sync(spi_device, m);压力测试与稳定性验证长时间大数据量传输运行DMA连续传输数小时检查是否有内存泄漏dma_alloc_coherent/dma_free_coherent配对使用、数据错误或系统负载异常。并发访问测试模拟多个线程或进程同时读写SPI设备测试驱动的并发控制如信号量semaphore或互斥锁mutex是否正确。边界条件测试传输长度为0、1、缓冲区边界值等特殊情况确保驱动行为正确不会发生数组越界。调试SPI驱动尤其是涉及DMA和中断时逻辑分析仪配合适当的触发条件如片选下降沿是定位问题的利器。同时善用内核的printk在不同阶段初始化、打开、读写、中断输出调试信息并结合dmesg查看内核日志是软件调试的基本功。记住耐心和系统性的排查方法是解决任何嵌入式驱动问题的关键。