4、SPI控制器驱动:寄存器详解与实战配置
大家好,我是你们的老朋友。今天我们来聊聊SPI控制器驱动里最核心的部分——寄存器配置、DMA传输、中断处理,还有那个让很多人头疼的时钟极性与相位。
说实话,我早年刚接触SPI驱动时,也被时钟极性搞晕过。有一次调试Flash读写,数据死活不对,折腾了两天才发现是CPOL和CPHA配反了。嗯,从那以后,我养成了一个习惯:先看从设备的数据手册,再配主控寄存器。
4.1 SPI控制器寄存器详解
先说说SPI控制器的寄存器。不同厂家的MCU,寄存器布局略有差异,但核心寄存器就那么几个。我以最常见的STM32为例,带你过一遍。
| 寄存器名称 | 功能描述 | 关键位域 |
|---|---|---|
| SPI_CR1 | 控制寄存器1 | CPOL, CPHA, MSTR, SPE, BR[2:0] |
| SPI_CR2 | 控制寄存器2 | TXEIE, RXNEIE, ERRIE, FRF, SSOE |
| SPI_SR | 状态寄存器 | BSY, TXE, RXNE, MODF, OVR |
| SPI_DR | 数据寄存器 | 16位数据,读写共用 |
| SPI_CRCPR | CRC多项式寄存器 | 用于CRC校验 |
我个人习惯,初始化SPI时,先配CR1,再配CR2。CR1决定了通信的底层参数,比如波特率、时钟极性。CR2则控制中断和DMA使能。
核心要点:SPI_DR寄存器是读写共用的。你往里面写数据,就是发送;你读它,就是接收。这个设计很巧妙,但新手容易搞混——发送和接收是同时进行的。
4.2 时钟极性与相位配置实战
时钟极性和相位,说白了就是决定数据在时钟的哪个边沿被采样。CPOL决定时钟空闲时的电平,CPHA决定数据采样发生在第一个边沿还是第二个边沿。
我画了一张图,帮你理清这四种模式:
你想想看,NOR Flash最常用的是哪种模式?我翻过Winbond、Macronix、GD等多家厂商的数据手册,绝大多数Flash默认支持模式0(CPOL=0, CPHA=0)和模式3(CPOL=1, CPHA=1)。
实战技巧:配置时钟极性时,先看Flash数据手册里的时序图。如果图上显示SCK空闲为低,数据在第一个边沿采样,那就是模式0。如果SCK空闲为高,数据在第一个边沿采样,那就是模式2。别猜,看手册最靠谱。
4.3 DMA传输配置
SPI用轮询方式读写,CPU会被占得死死的。尤其是读写Flash时,一页256字节,如果每字节都等中断,效率太低了。这时候DMA就派上用场了。
DMA传输的核心配置,我总结为三步:
- 配置DMA通道:选择DMA通道,设置源地址、目的地址、传输方向
- 配置传输参数:数据宽度(8位或16位)、传输模式(普通或循环)、优先级
- 使能DMA请求:在SPI_CR2中使能TX DMA或RX DMA
我曾经在一个项目里,用DMA传输SPI数据,结果发现数据错位。排查了半天,原来是DMA的地址递增没配对。发送时源地址递增,目的地址固定;接收时源地址固定,目的地址递增。这个细节很容易忽略。
// DMA发送配置示例(伪代码)
void SPI_DMA_Transmit(uint8_t *data, uint16_t len) {
// 1. 配置DMA通道
DMA_InitTypeDef dma_init;
dma_init.PeriphAddr = (uint32_t)&SPI1->DR; // 外设地址固定
dma_init.MemAddr = (uint32_t)data; // 内存地址递增
dma_init.Direction = DMA_MEM_TO_PERIPH; // 内存到外设
dma_init.BufferSize = len;
dma_init.PeriphInc = DMA_PINC_DISABLE; // 外设地址不递增
dma_init.MemInc = DMA_MINC_ENABLE; // 内存地址递增
dma_init.PeriphDataWidth = DMA_PDATAWIDTH_8BIT;
dma_init.MemDataWidth = DMA_MDATAWIDTH_8BIT;
dma_init.Mode = DMA_NORMAL; // 普通模式
dma_init.Priority = DMA_PRIORITY_HIGH;
DMA_Init(DMA1_Channel3, &dma_init);
// 2. 使能DMA通道
DMA_Cmd(DMA1_Channel3, ENABLE);
// 3. 使能SPI的DMA发送请求
SPI_I2S_DMACmd(SPI1, SPI_I2S_DMAReq_Tx, ENABLE);
}
注意:DMA传输完成后,一定要检查SPI的BSY位是否清零。DMA只负责把数据搬到DR寄存器,但SPI硬件还在发送最后一个字节。如果此时关闭SPI或切换CS片选,最后一个字节可能发不出去。我吃过这个亏,后来在DMA传输完成中断里加了个等待BSY清零的循环。
4.4 中断处理机制
SPI中断主要有三个:发送缓冲区空(TXE)、接收缓冲区非空(RXNE)、错误中断(ERR)。
实际开发中,我通常这样分配:
- 轮询方式:适合单字节读写,比如读Flash ID
- 中断方式:适合少量数据,比如读写状态寄存器
- DMA方式:适合大量数据,比如读写Flash页
中断处理函数里,核心逻辑就是判断中断标志位,然后处理数据。我写过一个简洁的中断处理框架:
void SPI1_IRQHandler(void) {
// 检查接收中断
if(SPI_I2S_GetITStatus(SPI1, SPI_I2S_IT_RXNE) == SET) {
uint8_t rx_data = SPI_I2S_ReceiveData(SPI1);
// 将数据存入接收缓冲区
if(rx_buf_index < RX_BUF_SIZE) {
rx_buf[rx_buf_index++] = rx_data;
}
SPI_I2S_ClearITPendingBit(SPI1, SPI_I2S_IT_RXNE);
}
// 检查发送中断
if(SPI_I2S_GetITStatus(SPI1, SPI_I2S_IT_TXE) == SET) {
if(tx_buf_index < tx_len) {
SPI_I2S_SendData(SPI1, tx_buf[tx_buf_index++]);
} else {
// 发送完成,关闭发送中断
SPI_I2S_ITConfig(SPI1, SPI_I2S_IT_TXE, DISABLE);
}
}
// 检查错误中断
if(SPI_I2S_GetITStatus(SPI1, SPI_I2S_IT_ERR) == SET) {
// 处理溢出或模式错误
uint8_t tmp = SPI_I2S_ReceiveData(SPI1); // 读DR清错误标志
SPI_I2S_ClearITPendingBit(SPI1, SPI_I2S_IT_ERR);
}
}
为什么会这样设计?因为SPI的发送和接收是同步的。你发一个字节,同时也会收到一个字节。所以中断处理时,发送和接收要配对处理。我见过有人只开了发送中断,结果接收缓冲区溢出,数据全丢了。
避坑指南:我曾经在中断里调用了printf打印调试信息,结果导致中断嵌套,系统直接死机。记住,中断服务函数里不要做耗时操作,尤其是打印和延时。如果非要调试,用GPIO翻转波形看时序。
4.5 实战配置流程
好了,理论讲完了,我们来个完整的配置流程。这是我个人总结的SPI初始化步骤:
- 使能时钟:GPIO时钟、SPI时钟、DMA时钟(如果用DMA)
- 配置GPIO:SCK、MOSI、MISO、CS,设置为复用功能推挽输出/输入
- 配置SPI参数:波特率、CPOL、CPHA、数据格式(MSB/LSB)、数据宽度
- 配置中断:设置优先级,使能NVIC
- 配置DMA:设置通道、地址、传输长度(如果用DMA)
- 使能SPI:设置SPE位,开始通信
嗯,这里要注意,使能SPI之前,一定要确保所有配置都已完成。我曾经在使能SPI后又修改了CR1寄存器,结果SPI直接罢工了。后来查手册才发现,SPE使能后,CR1的某些位是不能修改的。
最后说一句,SPI驱动看似简单,但细节很多。尤其是时钟极性和相位,配错了数据全乱。我的建议是:先用逻辑分析仪抓波形,确认时序正确,再写上层驱动。这样能省去很多调试时间。
个人习惯:我每次移植SPI驱动到新平台,都会先写一个回环测试——把MOSI和MISO短接,发一串数据,看收回来的是不是一样的。如果回环测试通过,说明SPI基本配置没问题。然后再接Flash,进行读写验证。
公众号:蓝海资料掘金营,微信deep3321