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的经验整理成几条原则:
- 先看数据手册,再看例程——很多问题都是因为没仔细看时序要求
- 片选信号一定要用GPIO控制——不要依赖硬件NSS,灵活性太差
- SPI时钟频率不要超过从机上限——有些从机标称20MHz,实际可能只能跑到10MHz,留点余量
- 长线传输时加缓冲器——超过10cm的SPI线,建议加74HC125之类的缓冲器,否则信号反射会让你怀疑人生
- DMA传输完成后记得关闭——有些MCU的DMA不会自动关闭,下次启动前要手动复位
好了,这一章的内容就到这里。下一章我们会继续深入SPI,讲讲多从机冲突的调试技巧和高速传输的PCB布局要点。有什么问题,欢迎在课程群里讨论。