4、通信协议详解(上):SPI协议时序、多从机片选机制、DMA传输优化

各位同学,今天我们来聊聊SPI。这个协议在嵌入式系统里太常见了,几乎每个项目都会用到。我最早接触SPI是在做一款智能家居网关的时候,当时要同时挂载一个LCD屏、一个SD卡和一个无线模块。嗯,那会儿踩了不少坑,今天就把这些经验分享给你们。

4.1 SPI协议时序——别被波形图吓到

SPI全称是Serial Peripheral Interface,说白了就是串行外设接口。它是个全双工的同步通信协议,什么意思?就是发送和接收可以同时进行,而且有专门的时钟线来同步。

SPI用四根线:

  • SCLK(串行时钟)——由主机产生
  • MOSI(主机输出,从机输入)
  • MISO(主机输入,从机输出)
  • CS/SS(片选,低电平有效)

时序上,大家最头疼的就是CPOL和CPHA这两个参数。我简单解释一下:

  • CPOL(时钟极性):决定空闲时时钟是高电平还是低电平
  • CPHA(时钟相位):决定数据是在时钟的上升沿还是下降沿采样

组合起来就是四种模式:

模式 CPOL CPHA 空闲时钟 数据采样沿
模式0 0 0 低电平 上升沿
模式1 0 1 低电平 下降沿
模式2 1 0 高电平 下降沿
模式3 1 1 高电平 上升沿

我的小技巧:大多数传感器和Flash芯片默认用模式0或模式3。如果你不确定,先看数据手册里的时序图,找到采样沿和空闲电平,再对照这个表。我曾经因为搞反了CPHA,调了整整一个下午的示波器……

4.2 多从机片选机制——别让从机打架

一个SPI总线上可以挂多个从机,但同一时刻只能有一个被选中。怎么做到的?靠片选信号。

每个从机独占一根CS线。主机要跟谁通信,就把谁的CS拉低,其他从机的CS保持高电平。从机看到自己的CS被拉低,就知道「轮到我了」。

这里有个常见的坑:CS信号必须在传输开始前拉低,传输结束后拉高。而且拉高后要留一点时间(至少半个时钟周期),让从机完成内部处理。

注意:有些从机芯片对CS的时序要求很严格。比如某些LCD驱动芯片,CS拉高后必须保持高电平至少100ns,否则下次通信可能失败。我遇到过一款温湿度传感器,CS拉高太快,导致它一直处于「忙」状态,读回来的数据全是0xFF。

多从机连接时,还有一点要注意:MISO线是共享的。所有从机的MISO都接到主机的同一根MISO引脚上。这就要求未被选中的从机必须把MISO置为高阻态,否则多个从机同时驱动MISO,会烧坏引脚。

怎么解决?

  • 大多数SPI从机芯片内部已经做了三态输出,CS为高时MISO自动高阻
  • 如果遇到没有三态输出的老芯片,可以在MISO线上加一个三态缓冲器

4.3 DMA传输优化——让CPU喘口气

SPI传输数据时,传统做法是CPU一个字节一个字节地读写数据寄存器。比如你要发送1024个字节,CPU就得循环1024次。每次循环都要检查状态位、写数据、等传输完成……CPU被占得死死的,啥也干不了。

DMA(直接存储器访问)就是来解决这个问题的。它可以在没有CPU参与的情况下,直接把数据从内存搬到SPI发送寄存器,或者从SPI接收寄存器搬到内存。

我举个例子,用STM32的HAL库配置SPI+DMA发送:

// 配置SPI句柄
SPI_HandleTypeDef hspi1;
hspi1.Instance = SPI1;
hspi1.Init.Mode = SPI_MODE_MASTER;
hspi1.Init.Direction = SPI_DIRECTION_2LINES;
hspi1.Init.DataSize = SPI_DATASIZE_8BIT;
hspi1.Init.CLKPolarity = SPI_POLARITY_LOW;
hspi1.Init.CLKPhase = SPI_PHASE_1EDGE;
hspi1.Init.NSS = SPI_NSS_SOFT;
hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_16;
hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB;
HAL_SPI_Init(&hspi1);

// 配置DMA
__HAL_RCC_DMA2_CLK_ENABLE();
hdma_tx.Instance = DMA2_Stream3;
hdma_tx.Init.Channel = DMA_CHANNEL_3;
hdma_tx.Init.Direction = DMA_MEMORY_TO_PERIPH;
hdma_tx.Init.PeriphInc = DMA_PINC_DISABLE;
hdma_tx.Init.MemInc = DMA_MINC_ENABLE;
hdma_tx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
hdma_tx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
hdma_tx.Init.Mode = DMA_NORMAL;
hdma_tx.Init.Priority = DMA_PRIORITY_HIGH;
HAL_DMA_Init(&hdma_tx);

// 将DMA句柄关联到SPI
__HAL_LINKDMA(&hspi1, hdmatx, hdma_tx);

// 启动DMA传输
uint8_t tx_buffer[1024] = {0};
HAL_SPI_Transmit_DMA(&hspi1, tx_buffer, 1024);

配置好之后,CPU只需要调用一次HAL_SPI_Transmit_DMA,然后就可以去干别的事了。DMA会在后台自动把1024个字节搬完,搬完后触发一个中断通知CPU。

性能对比:在72MHz的STM32F103上,用CPU轮询发送1024字节大约需要1.2ms,而用DMA只需要0.3ms左右,而且CPU占用率几乎为0。你想想看,如果你的系统要同时处理多个外设,DMA能省下多少CPU时间。

使用DMA时有几个注意事项:

  • 缓冲区必须保持有效:DMA传输期间,数据缓冲区不能被释放或修改。我建议用全局数组或者静态变量
  • 注意缓存一致性:在带Cache的MCU上(比如STM32H7),DMA和CPU可能看到不同的数据。需要做Cache刷新或使用非Cache内存区域
  • DMA中断优先级:如果系统中有多个DMA通道,要合理设置优先级。我曾经把SPI的DMA中断优先级设得太低,结果被定时器中断频繁打断,导致SPI传输超时

4.4 实战经验总结

最后,我把自己这些年用SPI的经验整理成几条原则:

  1. 先看数据手册,再看例程——很多问题都是因为没仔细看时序要求
  2. 片选信号一定要用GPIO控制——不要依赖硬件NSS,灵活性太差
  3. SPI时钟频率不要超过从机上限——有些从机标称20MHz,实际可能只能跑到10MHz,留点余量
  4. 长线传输时加缓冲器——超过10cm的SPI线,建议加74HC125之类的缓冲器,否则信号反射会让你怀疑人生
  5. DMA传输完成后记得关闭——有些MCU的DMA不会自动关闭,下次启动前要手动复位

好了,这一章的内容就到这里。下一章我们会继续深入SPI,讲讲多从机冲突的调试技巧和高速传输的PCB布局要点。有什么问题,欢迎在课程群里讨论。