上一篇I2C讲了SHT30传感器,这篇讲SPI。
SPI是嵌入式里速率最高的通信协议之一——刷屏、读写Flash、连SD卡全靠它。
这篇从SPI协议原理讲到W25Q64 Flash驱动实战,附4种工作模式和DMA加速技巧。
一、SPI vs I2C:什么时候用SPI?
| 对比项 | I2C | SPI |
|---|---|---|
| 线数 | 2线(SCL+SDA) | 4线(SCLK+MOSI+MISO+CS) |
| 速率 | 最高3.4MHz | 通常几十MHz,最高可达100MHz+ |
| 全双工 | ❌ 半双工 | ✅ 全双工 |
| 多从设备 | 靠地址区分 | 靠CS片选区分 |
| 应答 | 有ACK | 无ACK |
| 典型器件 | 传感器(SHT30/BH1750) | Flash、LCD屏、SD卡 |
选型原则:
- 低速传感器(<1MHz)→ I2C(省引脚)
- 高速设备(Flash/屏幕)→SPI(速度快)
我做LVGL项目时,480×480屏幕刷一帧要460KB数据,I2C完全扛不住,必须用SPI。W25Q64存字库和图片资源,也是SPI。
二、SPI的4根线
主设备(MCU) 从设备(Flash/屏幕) ┌──┐ ┌──┐ │ │ SCLK ──────────────►│ │ 时钟线(主→从) │ │ MOSI ──────────────►│ │ 主出从入(主→从) │ │ MISO ◄──────────────│ │ 主入从出(从→主) │ │ CS ──────────────►│ │ 片选(低有效) └──┘ └──┘| 信号 | 全称 | 方向 | 作用 |
|---|---|---|---|
| SCLK | Serial Clock | 主→从 | 时钟线,主设备产生 |
| MOSI | Master Out Slave In | 主→从 | 主设备发数据 |
| MISO | Master In Slave Out | 从→主 | 主设备收数据 |
| CS | Chip Select | 主→从 | 片选,低电平选中从设备 |
关键:SPI是全双工的——MOSI和MISO同时工作,发一个字节的同时也能收一个字节。这是SPI比I2C快的核心原因。
三、SPI通信时序
3.1 基本时序
SCLK: ‾\_/‾\_/‾\_/‾\_/‾\_/‾\_/‾\_/‾\_ 1 2 3 4 5 6 7 8 (8个时钟=1字节) MOSI: ─D7─D6─D5─D4─D3─D2─D1─D0─► MISO: ◄─D7─D6─D5─D4─D3─D2─D1─D0─ CS: ___/‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\___ 选中 取消选中- 主设备拉低CS,选中从设备
- 主设备产生SCLK时钟
- 每个时钟周期,MOSI上发1bit,同时MISO上收1bit
- 8个时钟周期 = 传输1字节(收发同时完成)
- 传输完成,主设备拉高CS
3.2 SPI的4种工作模式(CPOL + CPHA)
这是SPI最难理解的部分,面试常考。
CPOL(Clock Polarity,时钟极性):
- CPOL=0:空闲时SCLK为低电平
- CPOL=1:空闲时SCLK为高电平
CPHA(Clock Phase,时钟相位):
- CPHA=0:第1个时钟边沿采样(前沿采样)
- CPHA=1:第2个时钟边沿采样(后沿采样)
组合出4种模式:
| 模式 | CPOL | CPHA | 空闲电平 | 采样边沿 |
|---|---|---|---|---|
| 0 | 0 | 0 | 低 | 上升沿采样 |
| 1 | 0 | 1 | 低 | 下降沿采样 |
| 2 | 1 | 0 | 高 | 下降沿采样 |
| 3 | 1 | 1 | 高 | 上升沿采样 |
怎么选:查从设备的Datasheet!比如W25Q64支持Mode 0和Mode 3,触摸屏控制芯片GT911用Mode 0。配错了通信必然乱码。
记忆口诀:CPOL看"空闲时SCLK高还是低",CPHA看"第几个边沿采样"。
四、STM32 SPI配置
以SPI1(PA5-SCK, PA6-MISO, PA7-MOSI)连接W25Q64为例。
4.1 HAL库版(CubeMX)
CubeMX配置:
SPI1 → Mode: Full-Duplex Master(全双工主模式) Parameter Settings: Frame Format: Motorola ← Motorola格式是SPI的标准帧格式(另一种TI格式极少使用),默认选Motorola即可 Data Size: 8 Bit First Bit: MSB First Prescaler: 4 → 72MHz/4 = 18MHz (W25Q64最大支持104MHz) Clock Polarity (CPOL): Low Clock Phase (CPHA): 1 Edge → 即 SPI Mode 0 CS引脚:用GPIO输出(PA4),手动控制代码:
/* 收发一个字节(全双工) */uint8_tspi_rw(uint8_ttx_data){uint8_trx_data;HAL_SPI_TransmitReceive(&hspi1,&tx_data,&rx_data,1,100);returnrx_data;}/* CS控制 */#defineCS_LOW()HAL_GPIO_WritePin(GPIOA,GPIO_PIN_4,GPIO_PIN_RESET)#defineCS_HIGH()HAL_GPIO_WritePin(GPIOA,GPIO_PIN_4,GPIO_PIN_SET)/* 读取W25Q64 ID */uint16_tw25q64_read_id(void){uint16_tid;CS_LOW();spi_rw(0x9F);/* 读ID命令 */id=spi_rw(0xFF)<<8;/* 制造商ID(高字节) */id|=spi_rw(0xFF);/* 设备ID(低字节) */CS_HIGH();returnid;/* W25Q64返回 0xEF16 */}4.2 寄存器版
voidspi1_init(void){RCC->APB2ENR|=(1<<12);/* SPI1时钟 */RCC->APB2ENR|=(1<<2);/* GPIOA时钟 *//* PA5(SCK) PA7(MOSI) 复用推挽, PA6(MISO) 浮空输入 *//* CRL寄存器每4位控制一个引脚,PA5在bit20-23, PA6在bit24-27, PA7在bit28-31 *//* 先清零这3个引脚的配置位 */GPIOA->CRL&=~(0xFFF<<20);/* 0xB3 = 二进制10110011 * PA5(SCK) = 0xB(1011) = 复用推挽输出50MHz * PA6(MISO) = 0x4(0100) = 浮空输入(注意这里0xB3的中间4位是0x4) * PA7(MOSI) = 0xB(1011) = 复用推挽输出50MHz * 三个引脚的配置值合并写入CRL的对应位置 */GPIOA->CRL|=(0xB3<<20);SPI1->CR1=0;SPI1->CR1|=(0<<0);/* CPHA=0 */SPI1->CR1|=(0<<1);/* CPOL=0 → Mode 0 */SPI1->CR1|=(1<<2);/* 主模式 */SPI1->CR1|=(0<<11);/* 8位数据 */SPI1->CR1|=(1<<9);/* 软件管理CS */SPI1->CR1|=(3<<3);/* 波特率÷16 = 4.5MHz */SPI1->CR1|=(1<<6);/* 使能SPI */}uint8_tspi_rw(uint8_tdata){while(!(SPI1->SR&(1<<1)));/* 等TXE发送缓冲空 */SPI1->DR=data;while(!(SPI1->SR&(1<<0)));/* 等RXNE接收缓冲非空 */returnSPI1->DR;}五、W25Q64 Flash驱动实战
W25Q64是64Mbit(即8MB)的SPI Flash,常用于存字库、图片、配置数据。
MB和Mbit的区别:1 Byte = 8 bit,所以64Mbit = 64÷8 = 8MB。Flash芯片通常用Mbit标注容量(因为芯片内部按bit存储),但我们编程时用Byte(字节)计算。W25Q64 = 64Mbit = 8MB = 8388608字节。
5.1 常用命令
| 命令 | 代码 | 功能 |
|---|---|---|
| 读ID | 0x9F | 读取制造商和设备ID |
| 读数据 | 0x03 | 从指定地址读数据 |
| 页写 | 0x02 | 向指定地址写数据(每页256字节) |
| 擦除扇区 | 0x20 | 擦除一个4KB扇区 |
| 读状态 | 0x05 | 读取状态寄存器(判断忙/闲) |
| 写使能 | 0x06 | 每次写/擦前必须发 |
| 等待空闲 | - | 读状态寄存器bit0,0=空闲 |
5.2 读数据
voidw25q64_read(uint32_taddr,uint8_t*buf,uint16_tlen){CS_LOW();spi_rw(0x03);/* 读数据命令 */spi_rw((addr>>16)&0xFF);/* 地址高字节 */spi_rw((addr>>8)&0xFF);/* 地址中字节 */spi_rw(addr&0xFF);/* 地址低字节 */for(uint16_ti=0;i<len;i++){buf[i]=spi_rw(0xFF);/* 发0xFF读数据 */}CS_HIGH();}5.3 写数据(需先擦除)
/* 等待Flash空闲 */voidw25q64_wait_busy(void){CS_LOW();spi_rw(0x05);/* 读状态 */while(spi_rw(0xFF)&0x01);/* bit0=1表示忙 */CS_HIGH();}/* 写使能 */voidw25q64_write_enable(void){CS_LOW();spi_rw(0x06);CS_HIGH();}/* 页写(最多256字节) */voidw25q64_page_write(uint32_taddr,uint8_t*buf,uint16_tlen){if(len>256)len=256;/* 每页最大256字节 */w25q64_write_enable();CS_LOW();spi_rw(0x02);/* 页写命令 */spi_rw((addr>>16)&0xFF);spi_rw((addr>>8)&0xFF);spi_rw(addr&0xFF);for(uint16_ti=0;i<len;i++){spi_rw(buf[i]);}CS_HIGH();w25q64_wait_busy();/* 等写完成 */}5.4 擦除扇区
voidw25q64_erase_sector(uint32_taddr){w25q64_write_enable();CS_LOW();spi_rw(0x20);/* 扇区擦除命令 */spi_rw((addr>>16)&0xFF);spi_rw((addr>>8)&0xFF);spi_rw(addr&0xFF);CS_HIGH();w25q64_wait_busy();/* 擦除较慢,约几百ms */}Flash特性:Flash的存储单元默认状态是1,写入操作只能把1变成0,不能把0变成1。要把0变回1,必须通过擦除操作(整片变回1)。所以写之前必须先擦除。擦除最小单位是4KB扇区。
六、SPI + DMA加速
刷屏时用CPU轮询SPI太慢,用DMA能提速3-4倍。
6.1 DMA发送
/* DMA方式发送大量数据 */HAL_SPI_Transmit_DMA(&hspi1,frame_buf,460800);/* 发送完成回调 */voidHAL_SPI_TxCpltCallback(SPI_HandleTypeDef*hspi){CS_HIGH();/* 拉高CS */lv_disp_flush_ready(&disp_drv);/* 通知LVGL */}6.2 性能对比
| 方式 | 460KB耗时 | CPU占用 | 帧率 |
|---|---|---|---|
| CPU轮询 | ~30ms | 100% | 33fps |
| DMA | ~8ms | <5% | 120fps |
LVGL项目从30fps到60fps,关键就是SPI换成了DMA方式。
七、多从设备怎么接?
SPI支持一主多从,每个从设备用独立CS:
MCU ──SCLK──────────┬── Flash SCLK └── LCD SCLK ──MOSI──────────┬── Flash MOSI └── LCD MOSI ──MISO──────────┬── Flash MISO └── LCD MISO ──CS1 ────────── Flash CS ──CS2 ────────── LCD CS通信时只拉低目标设备的CS,其他设备CS保持高。这样SCLK/MOSI/MISO可以共享,节省引脚。
/* 选中Flash通信 */FLASH_CS_LOW();spi_rw(0x9F);FLASH_CS_HIGH();/* 选中LCD通信 */LCD_CS_LOW();spi_rw(lcd_cmd);LCD_CS_HIGH();注意:同一时刻只能有一个从设备CS为低,否则数据会冲突。
八、常见踩坑
坑1:通信全是0xFF或0x00
原因:
- CPOL/CPHA配错——查Datasheet确认模式
- CS没拉低——开始通信前必须
CS_LOW() - MISO/MOSI接反——交叉连接,主MOSI接从MOSI(不是MISO!)
注意:SPI的MOSI接MOSI,MISO接MISO,同名相接(不像UART交叉)。新手常搞混。
坑2:Flash写不进去
原因:
- 没发写使能命令(0x06)——每次写/擦前必须发
- 没擦除就写——Flash只能1写0,写前必须擦除
- 跨页写——每页256字节,跨页写会回卷覆盖前面数据
解决:严格按"写使能→写数据→等忙"流程,跨页时分段写。
坑3:读到的数据错位
原因:SPI的TransmitReceive是全双工的,发的同时也在收。如果只发不收,接收缓冲区会溢出,导致下次读数据错位。
解决:用HAL_SPI_TransmitReceive而非HAL_SPI_Transmit,即使不想收数据也要读DR寄存器清空。
/* 错误:只发不收 */HAL_SPI_Transmit(&hspi1,&data,1,100);/* 正确:收发同时(即使不要收的数据) */HAL_SPI_TransmitReceive(&hspi1,&data,&dummy,1,100);坑4:DMA发送中途数据错乱
原因:DMA发送期间,源数据被其他代码修改了。
解决:DMA发送期间不要修改源缓冲区;或用双缓冲,DMA发A时CPU填B。
九、总结
| 要点 | 内容 |
|---|---|
| SPI四线 | SCLK/MOSI/MISO/CS,全双工 |
| 4种模式 | CPOL×CPHA组合,查Datasheet确定 |
| W25Q64 | 读(0x03)/写(0x02)/擦(0x20)/写使能(0x06) |
| Flash特性 | 写前必须擦除,每页256字节 |
| DMA加速 | 刷屏460KB从30ms→8ms |
| 多从设备 | 共享三线,独立CS片选 |
一句话总结:SPI的核心是4线全双工 + CPOL/CPHA选模式,Flash驱动记住"写前必擦除、写前必使能、写后等空闲"三原则。
下一篇预告:《FreeRTOS入门第一课:从裸机到多任务的思维转变》
如果这篇文章对你有帮助,点赞 + 收藏 + 关注,这是我持续更新的动力!
有问题欢迎评论区交流,我会逐条回复。作者:嵌入式阿蔡