在嵌入式开发中,与传感器、存储芯片、显示屏等外设通信是家常便饭。面对UART、I2C、SPI这几种常见的通信协议,很多开发者,尤其是初学者,常常会困惑:它们有什么区别?我的项目该选哪个?特别是SPI,虽然速度最快,但其相对复杂的时序和配置也让不少人望而却步。本文将为你彻底厘清SPI通信协议的核心原理,并通过一个完整的实战案例——使用STM32的硬件SPI驱动OLED屏幕,带你从零到一掌握SPI的应用。无论你是刚接触单片机的新手,还是想巩固通信基础的中级开发者,都能从本文获得可直接复用的代码和清晰的配置思路。
1. 背景与核心概念:为什么需要SPI?
在深入细节之前,我们先从宏观上理解SPI的定位。微控制器(MCU)或处理器需要与外部芯片“对话”,这种对话需要遵循双方都能理解的规则,这就是通信协议。
1.1 常见串行通信协议对比
为了理解SPI的独特价值,我们将其与UART和I2C进行简单对比:
| 特性 | UART | I2C | SPI |
|---|---|---|---|
| 通信方式 | 全双工(通常指异步) | 半双工 | 全双工 |
| 信号线数量 | 2根 (TX, RX) + GND | 2根 (SDA, SCL) + GND | 3根或4根(MOSI, MISO, SCK, CS) + GND |
| 拓扑结构 | 点对点 | 多主多从(总线式) | 一主多从(片选式) |
| 有无时钟线 | 无(异步) | 有(同步) | 有(同步) |
| 最高速度 | 较低(通常<10Mbps) | 中速(标准模式100kbps,快速模式400kbps) | 高速(可达几十甚至上百Mbps) |
| 硬件复杂度 | 简单 | 中等 | 相对复杂 |
| 软件复杂度 | 简单 | 中等(需处理地址、应答) | 简单(直读直写) |
| 典型应用 | 调试打印、蓝牙模块 | 传感器(如温湿度)、EEPROM | Flash存储(如W25Q64)、显示屏(如OLED)、ADC/DAC芯片 |
核心区别提炼:
- UART:简单、通用,适合远距离或与PC通信,但速度慢,需要双方预先约定相同的波特率(时钟)。
- I2C:节省引脚,支持多设备,通过地址寻址,有应答机制,可靠性较好,但速度受限,协议稍复杂。
- SPI:速度之王,协议简单(几乎无格式),但占用引脚多,通信距离短,适合板内高速数据交换。
所以,当你的项目需要高速、实时地传输大量数据(例如刷新显示屏、读写大容量Flash)时,SPI通常是首选。
1.2 SPI是什么?
SPI,全称Serial Peripheral Interface,即串行外设接口。它是一种高速、全双工、同步的通信总线协议。
- 高速:时钟频率可以很高,数据传输快。
- 全双工:主设备和从设备可以同时发送和接收数据。
- 同步:通信双方由一根时钟线(SCK)同步节奏,保证数据采样准确。
- 总线:但它并非严格意义上的“共享总线”,其本质是点对点的,通过片选信号(CS)管理多个从设备。
SPI协议由摩托罗拉公司提出,因其简单高效,已成为一种事实上的工业标准,被几乎所有现代MCU和众多外设芯片所支持。
2. SPI通信协议原理深度拆解
理解了“为什么用SPI”,接下来我们深入其内部,看它是如何工作的。这是正确配置和使用SPI的基石。
2.1 SPI的通信角色与信号线
一个典型的SPI系统包含一个主设备(Master)和一个或多个从设备(Slave)。 通信至少需要4根线(对于单个从设备,有时CS可接固定电平,但通常不推荐):
SCK (Serial Clock) - 串行时钟线
- 由主设备产生并输出给所有从设备。
- 时钟的边沿决定了数据何时被采样(读取)和何时被改变(输出)。
- 这是SPI同步的核心。
MOSI (Master Out Slave In) - 主出从入线
- 主设备通过此线向从设备发送数据。
MISO (Master In Slave Out) - 主入从出线
- 从设备通过此线向主设备发送数据。
- 注意:当从设备未被选中时,必须将MISO置为高阻态,以避免总线冲突。
CS/SS (Chip Select / Slave Select) - 片选线
- 由主设备控制,每个从设备独立拥有一根CS线。
- 低电平有效:当主设备将某个从设备的CS线拉低时,表示选中该从设备,与之进行通信。其他CS为高的从设备忽略总线上的信号。
- 这是实现“一主多从”的关键。
一主多从连接示意图(逻辑上):
主设备 (MCU) | |--- SCK -------------------------------------- 所有从设备 SCK |--- MOSI -------------------------------------- 所有从设备 MOSI |--- MISO -------------------------------------- 所有从设备 MISO |--- CS1 --------(拉低)----> 从设备1 CS |--- CS2 --------(拉高)----> 从设备2 CS |--- CS3 --------(拉高)----> 从设备3 CS注意:实际布线时,SCK、MOSI、MISO是并联的,只有CS是独立的。
2.2 SPI的四种工作模式与时序
这是SPI最核心也最容易出错的地方。SPI模式由时钟极性(CPOL)和时钟相位(CPHA)两个参数组合而成,决定了时钟空闲状态和数据的采样时刻。
时钟极性 CPOL (Clock Polarity):
CPOL = 0: 时钟空闲状态为低电平。CPOL = 1: 时钟空闲状态为高电平。
时钟相位 CPHA (Clock Phase):
CPHA = 0: 数据在时钟的第一个边沿(即SCK从空闲状态跳变到相反状态的边沿)被采样,在下一个边沿改变。CPHA = 1: 数据在时钟的第二个边沿(即SCK跳变回空闲状态的边沿)被采样,在第一个边沿改变。
由此组合出四种模式,通常用Mode 0到Mode 3表示:
| 模式 | CPOL | CPHA | 空闲时钟 | 数据采样时刻 | 数据改变时刻 | 常见应用 |
|---|---|---|---|---|---|---|
| Mode 0 | 0 | 0 | 低电平 | 第一个边沿(上升沿) | 第二个边沿(下降沿) | 最常用,如W25Qxx Flash |
| Mode 1 | 0 | 1 | 低电平 | 第二个边沿(下降沿) | 第一个边沿(上升沿) | |
| Mode 2 | 1 | 0 | 高电平 | 第一个边沿(下降沿) | 第二个边沿(上升沿) | |
| Mode 3 | 1 | 1 | 高电平 | 第二个边沿(上升沿) | 第一个边沿(下降沿) | 如SD卡(初始化时) |
如何记忆和配置?关键原则:主从设备的工作模式必须完全一致!通常,从设备(如传感器、Flash芯片)的数据手册会明确规定其支持的SPI模式。我们在配置主设备(MCU)的SPI时,必须严格按照从设备的要求来设置CPOL和CPHA。
2.3 SPI通信过程详解
让我们以最常用的Mode 0 (CPOL=0, CPHA=0)为例,拆解一次完整的8位数据通信过程:
- 空闲状态:SCK为低电平,CS为高电平(未选中)。
- 启动通信:主设备将目标从设备的CS线拉低。
- 数据移位与采样(以8位数据0xAA (1010 1010b)为例):
- 在CS拉低后,主从双方立即将各自要发送的第一位数据(最高位MSB)放到MOSI和MISO线上。
- 第一个SCK上升沿:主设备产生一个上升沿。此时,从设备采样主设备MOSI线上的数据位(即0xAA的最高位
1),同时主设备采样从设备MISO线上的数据位。 - 第一个SCK下降沿:主从双方根据要发送的下一位数据,改变各自输出线上的电平。
- 重复此过程8次,依次传输完所有8位数据。注意:数据通常从最高位(MSB)开始传输,但有些设备也支持最低位(LSB)优先,需查阅手册。
- 结束通信:8个时钟周期后,主设备将CS线拉高。
全双工的秘密:在上述过程中,主设备通过MOSI发送0xAA的同时,也从MISO线接收到了从设备发来的一个字节数据。一次SPI读写操作,总是同时完成发送和接收。即使你只想发送命令(写入),也会收到一个“无关”的字节;同样,只想读取数据时,也必须先发送一个“哑元”(Dummy)字节(通常是0xFF或0x00)来产生时钟信号。
3. 环境准备与硬件连接
理论需要实践来巩固。我们将以STM32F103C8T6(蓝桥杯/正点原子最小系统板常见型号)作为主设备,驱动一款0.96寸OLED显示屏(SSD1306驱动芯片,4线SPI接口)作为从设备。
3.1 硬件清单与版本说明
- 主控MCU:STM32F103C8T6 (ARM Cortex-M3内核)
- 开发环境:
- IDE: Keil uVision5 MDK 或 STM32CubeIDE
- 开发库:标准外设库(Standard Peripheral Library)或 HAL库均可,本文示例基于易于理解的SPL。原理相通,HAL库调用API不同但逻辑一致。
- OLED模块:0.96寸 I2C/SPI 双接口OLED,本文使用SPI接口。驱动芯片为SSD1306。
- 连接线:杜邦线若干。
版本兼容性说明:SPI协议是硬件标准,不同库或IDE只是封装了底层寄存器操作。只要理解原理,代码可以很容易地移植到HAL库、LL库或其他STM32型号,乃至其他品牌的MCU(如ESP32、GD32)上。
3.2 硬件电路连接
根据SSD1306数据手册和模块原理图,我们需要连接4根线(模块的I2C接口需要断开跳线帽,确保选择SPI模式)。
| STM32F103引脚 | OLED模块引脚 | SPI信号 | 说明 |
|---|---|---|---|
| PA5 | D0 (CLK) | SCK | SPI1时钟线 |
| PA7 | D1 (MOSI) | MOSI | SPI1主出从入线 |
| PA4 | CS (片选) | CS | 自定义GPIO,低电平选中 |
| PA2 | DC (数据/命令) | DC | 自定义GPIO,区分数据/命令 |
| 3.3V | VCC | - | 电源正极 |
| GND | GND | - | 电源地 |
| PA1 | RES (复位) | RESET | 复位引脚,可选,本文用软件延时替代 |
重要连接说明:
- MISO线:OLED屏作为纯输出设备(只接收MCU发来的显示数据,不返回数据),因此其
D1引脚是单向的(对于OLED是DIN)。在标准SPI定义中,这是主设备的MOSI。我们不需要连接MISO线。 - DC引脚:这是一个SPI协议之外的、SSD1306特有的控制引脚。
DC=0表示接下来发送的是命令(如设置对比度、扫描方式),DC=1表示接下来发送的是要显示的RAM数据。我们需要用一个普通的GPIO来控制它。 - CS引脚:虽然理论上可以一直拉低(因为只有一个从设备),但规范的做法还是独立控制,便于后续扩展和降低功耗。
- RES引脚:硬件复位,上电时需要拉低再拉高以初始化芯片。可以用GPIO控制,也可以直接接VCC(模块内部可能有上拉),本文为了简化,在代码中用上电后软件延时替代硬件复位。
4. 实战:STM32硬件SPI驱动OLED显示
我们将工程分为以下几个步骤:初始化GPIO和SPI、编写OLED驱动底层函数、实现字符和字符串显示、最后在主函数中测试。
4.1 创建工程与配置SPI
首先,在Keil中创建一个基于标准外设库的STM32工程,并配置RCC(系统时钟)、GPIO和SPI1。
关键配置步骤(基于寄存器/SPL思路):
- 开启外设时钟:SPI1挂载在APB2总线上,GPIOA也挂载在APB2。
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_SPI1 | RCC_APB2Periph_AFIO, ENABLE); - 配置GPIO引脚模式:
- PA5 (SCK), PA7 (MOSI): 复用推挽输出(AF_PP),高速。
- PA4 (CS), PA2 (DC): 推挽输出(Out_PP),低速。
// 初始化结构体 GPIO_InitTypeDef GPIO_InitStructure; // 配置SCK和MOSI为复用推挽输出 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // 配置CS和DC为普通推挽输出 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4 | GPIO_Pin_2; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_2MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // 初始状态:CS高(未选中),DC高(默认为数据,具体操作时再改) GPIO_SetBits(GPIOA, GPIO_Pin_4 | GPIO_Pin_2); - 配置SPI1工作模式:
- 根据SSD1306数据手册,其SPI模式为Mode 0(CPOL=0, CPHA=0)。
- 配置为主设备、全双工、8位数据格式、MSB先行。
- 设置时钟分频,决定SCK频率。STM32F103的APB2时钟为72MHz,我们分频到18MHz (
SPI_BaudRatePrescaler_4) 或更低(如9MHz_8),以确保稳定性。
特别注意:标准外设库中SPI_InitTypeDef SPI_InitStructure; SPI_InitStructure.SPI_Direction = SPI_Direction_2Lines_FullDuplex; // 双线全双工 SPI_InitStructure.SPI_Mode = SPI_Mode_Master; // 主模式 SPI_InitStructure.SPI_DataSize = SPI_DataSize_8b; // 8位数据 SPI_InitStructure.SPI_CPOL = SPI_CPOL_Low; // CPOL=0 SPI_InitStructure.SPI_CPHA = SPI_CPHA_1Edge; // CPHA=0 (注意库中1Edge对应CPHA=0) SPI_InitStructure.SPI_NSS = SPI_NSS_Soft; // 软件控制NSS(CS),我们用自己的GPIO SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_4; // 18MHz SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB; // MSB先行 SPI_InitStructure.SPI_CRCPolynomial = 7; // CRC默认值,本例不用CRC SPI_Init(SPI1, &SPI_InitStructure); SPI_Cmd(SPI1, ENABLE); // 使能SPI1SPI_CPHA_1Edge表示在第一个边沿采样,对应CPHA=0。SPI_CPHA_2Edge对应 CPHA=1。这是容易混淆的地方,务必对照数据手册和库定义。
4.2 编写OLED驱动底层函数
我们创建oled.c和oled.h文件。
oled.h头文件定义
#ifndef __OLED_H #define __OLED_H #include "stm32f10x.h" // 引脚定义(根据实际连接修改) #define OLED_CS_PIN GPIO_Pin_4 #define OLED_DC_PIN GPIO_Pin_2 #define OLED_PORT GPIOA #define OLED_CS_LOW() GPIO_ResetBits(OLED_PORT, OLED_CS_PIN) #define OLED_CS_HIGH() GPIO_SetBits(OLED_PORT, OLED_CS_PIN) #define OLED_DC_LOW() GPIO_ResetBits(OLED_PORT, OLED_DC_PIN) // 命令 #define OLED_DC_HIGH() GPIO_SetBits(OLED_PORT, OLED_DC_PIN) // 数据 // 函数声明 void OLED_Init(void); void OLED_WR_Byte(uint8_t dat, uint8_t cmd); void OLED_Set_Pos(uint8_t x, uint8_t y); void OLED_Clear(void); void OLED_ShowChar(uint8_t x, uint8_t y, char chr); void OLED_ShowString(uint8_t x, uint8_t y, char *str); #endifoled.c核心函数实现
#include "oled.h" #include "spi.h" // 确保包含SPI头文件 #include "font.h" // 字库数据头文件,需自行准备或生成 // 延时函数(简易实现,实际项目建议用SysTick) static void OLED_Delay(uint32_t count) { for(uint32_t i=0; i<count; i++); } // SPI发送一个字节 static void SPI1_SendByte(uint8_t byte) { while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET); // 等待发送缓冲区空 SPI_I2S_SendData(SPI1, byte); // 写入数据,启动发送 while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) == RESET); // 等待接收完成 SPI_I2S_ReceiveData(SPI1); // 读取接收到的数据(丢弃) } // 向OLED写入一个字节(数据或命令) // cmd=0: 命令; cmd=1: 数据 void OLED_WR_Byte(uint8_t dat, uint8_t cmd) { OLED_CS_LOW(); // 选中OLED if(cmd) OLED_DC_HIGH(); // 写数据 else OLED_DC_LOW(); // 写命令 SPI1_SendByte(dat); // 通过SPI发送 OLED_DC_HIGH(); // 操作完成后恢复DC为高(可选) OLED_CS_HIGH(); // 取消选中 } // OLED初始化序列 void OLED_Init(void) { // 1. 硬件复位(如果接了RESET引脚) // GPIO_ResetBits(OLED_RES_PORT, OLED_RES_PIN); // OLED_Delay(100000); // GPIO_SetBits(OLED_RES_PORT, OLED_RES_PIN); // OLED_Delay(100000); // 本文用延时替代 OLED_Delay(200000); // 2. 发送初始化命令序列 OLED_WR_Byte(0xAE, 0); // 关闭显示 OLED_WR_Byte(0xD5, 0); // 设置显示时钟分频比/振荡器频率 OLED_WR_Byte(0x80, 0); OLED_WR_Byte(0xA8, 0); // 设置多路复用率 OLED_WR_Byte(0x3F, 0); // 对应128*64分辨率 OLED_WR_Byte(0xD3, 0); // 设置显示偏移 OLED_WR_Byte(0x00, 0); OLED_WR_Byte(0x40, 0); // 设置显示起始行 OLED_WR_Byte(0x8D, 0); // 电荷泵设置 OLED_WR_Byte(0x14, 0); // 开启电荷泵 OLED_WR_Byte(0x20, 0); // 设置内存地址模式 OLED_WR_Byte(0x00, 0); // 水平地址模式 OLED_WR_Byte(0xA1, 0); // 段重映射设置 (0xA1 列127映射到SEG0) OLED_WR_Byte(0xC8, 0); // 扫描方向设置 (0xC8 从COM[N-1]到COM0) OLED_WR_Byte(0xDA, 0); // 设置COM引脚硬件配置 OLED_WR_Byte(0x12, 0); OLED_WR_Byte(0x81, 0); // 设置对比度控制 OLED_WR_Byte(0xCF, 0); OLED_WR_Byte(0xD9, 0); // 设置预充电周期 OLED_WR_Byte(0xF1, 0); OLED_WR_Byte(0xDB, 0); // 设置VCOMH电压倍率 OLED_WR_Byte(0x40, 0); OLED_WR_Byte(0xA4, 0); // 设置整个显示开启/关闭 OLED_WR_Byte(0xA6, 0); // 设置正常/反色显示 OLED_WR_Byte(0xAF, 0); // 开启显示 OLED_Clear(); // 清屏 } // 设置显示位置 (x: 0~127, y: 0~7 对应8页) void OLED_Set_Pos(uint8_t x, uint8_t y) { OLED_WR_Byte(0xb0 + y, 0); // 设置页地址 OLED_WR_Byte(((x & 0xf0) >> 4) | 0x10, 0); // 设置列地址高4位 OLED_WR_Byte(x & 0x0f, 0); // 设置列地址低4位 } // 清屏函数 void OLED_Clear(void) { uint8_t i, n; for(i=0; i<8; i++) { OLED_Set_Pos(0, i); for(n=0; n<128; n++) { OLED_WR_Byte(0x00, 1); // 写入数据0,即熄灭所有像素点 } } }4.3 实现字符与字符串显示
显示字符需要字库。我们使用一个8x16像素的ASCII字库(font.h),它包含从空格(0x20)到波浪线(0x7E)的字符点阵数据。
font.h字库示例(部分)
#ifndef __FONT_H #define __FONT_H // ASCII 8x16 字体,索引为 ASCII-32 const unsigned char ASCII_8x16[95][16] = { // 空格 ' ' (0x20) {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00}, // '!' (0x21) {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x5F,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00}, // ... 其他字符数据 // 'A' (0x41) {0x00,0x00,0x00,0x00,0x00,0x00,0x7C,0x12,0x11,0x12,0x7C,0x00,0x00,0x00,0x00,0x00}, // ... 更多字符 }; #endif在oled.c中添加显示函数
// 在指定位置显示一个字符 (8x16) void OLED_ShowChar(uint8_t x, uint8_t y, char chr) { uint8_t c = chr - ' '; // 得到字符在字库中的索引 if(x > 120) { x=0; y++; } // 自动换行处理 OLED_Set_Pos(x, y); for(uint8_t i=0; i<8; i++) { OLED_WR_Byte(ASCII_8x16[c][i], 1); // 写入字符上半部分 } OLED_Set_Pos(x, y+1); for(uint8_t i=8; i<16; i++) { OLED_WR_Byte(ASCII_8x16[c][i], 1); // 写入字符下半部分 } } // 在指定位置显示字符串 void OLED_ShowString(uint8_t x, uint8_t y, char *str) { while(*str != '\0') { OLED_ShowChar(x, y, *str); x += 8; // 字符宽度为8像素 if(x > 120) { // 换行判断 x = 0; y += 2; // 字符高度占2页(16像素) } str++; } }4.4 主函数测试
最后,在main.c中初始化并调用显示函数。
#include "stm32f10x.h" #include "oled.h" int main(void) { // 系统时钟初始化(通常由SystemInit()完成,需确保正确) // 初始化GPIO和SPI(已在OLED_Init内部或之前调用) OLED_Init(); // 显示测试内容 OLED_ShowString(0, 0, "Hello, CSDN!"); OLED_ShowString(0, 2, "SPI Test OK!"); OLED_ShowString(0, 4, "Speed: 18MHz"); while(1) { // 可以在此添加动态显示效果,如滚动、数值更新等 // ... } } // 简单的延时函数,用于主循环(建议替换为SysTick) void Delay(uint32_t nCount) { for(; nCount != 0; nCount--); }4.5 编译、下载与运行
- 将完整的字库数据
font.h添加到工程。 - 编译工程,确保无错误。
- 通过ST-Link或USB转串口工具将程序下载到STM32。
- 给开发板和OLED模块上电。
- 如果连接正确,OLED屏幕将显示“Hello, CSDN!”、“SPI Test OK!”和“Speed: 18MHz”三行文字。
5. 常见问题与排查思路
在实际使用SPI时,你可能会遇到以下问题:
| 问题现象 | 可能原因 | 排查思路与解决方案 |
|---|---|---|
| 屏幕全白、全黑或乱码 | 1. 电源或GND未接好。 2. SPI模式(CPOL/CPHA)设置错误。 3. 初始化命令序列错误或顺序不对。 4. DC引脚控制逻辑错误(命令/数据混淆)。 | 1. 检查VCC和GND连接,确保电压为3.3V。 2.重点检查:确认主从设备SPI模式一致。用逻辑分析仪抓取SCK、MOSI、CS、DC波形,对照SSD1306时序图检查。 3. 核对初始化命令,特别是电荷泵命令 0x8D, 0x14必须发送,否则屏幕无显示。4. 在发送命令前拉低DC,发送数据前拉高DC。 |
| SPI发送函数卡死在等待标志位 | 1. SPI外设时钟未开启。 2. GPIO引脚模式配置错误(SCK、MOSI应为复用推挽)。 3. SPI本身未使能( SPI_Cmd)。4. 从设备故障或未正确连接,导致MISO线状态异常(如果用了MISO)。 | 1. 检查RCC_APB2PeriphClockCmd是否已开启SPI和GPIO时钟。2. 使用 GPIO_ReadInputDataBit读取引脚状态,或用万用表测量SCK是否有波形。3. 确保 SPI_Cmd(SPI1, ENABLE)已执行。4. 如果项目只用发送(如本例),可以暂时不连接MISO线。 |
| 显示内容错位或重叠 | 1. 设置显示起始行、页地址、列地址的命令错误。 2. 字库数据与取模方式不匹配(如取模顺序MSB/LSB错误)。 3. OLED_Set_Pos函数逻辑错误。 | 1. 检查OLED_Set_Pos函数中的命令(0xB0, 0x10, 0x00)是否正确。2. 使用PCtoLCD2002等取模软件时,注意选择“逐列式、顺向(高位在前)、C51格式”等选项,并与代码中的写入顺序匹配。 3. 可以尝试先画一个全屏填充的测试图案(如交替0xFF和0x00)来验证坐标系统。 |
| 通信速度慢 | 1. SPI时钟分频设置过大。 2. 使用 while循环等待标志位,在高速时可能成为瓶颈。3. 每次发送都频繁操作CS/DC的GPIO。 | 1. 在保证稳定的前提下,减小SPI_BaudRatePrescaler的值(如从_256改为_8)。2. 考虑使用DMA进行SPI数据传输,解放CPU。 3. 对于连续发送,可以在片选有效期间发送完所有数据再拉高CS,减少GPIO操作开销。 |
| 多从设备干扰 | 1. 未使用的从设备CS引脚浮空。 2. 多个从设备MISO线直接并联冲突。 | 1. 将所有未选中从设备的CS引脚通过电阻上拉到高电平,或由MCU输出高电平。 2. 确保每个从设备在不被选中时,其MISO引脚处于高阻态。有些芯片需要配置,好的设计应在硬件上为每个从设备分配独立的MISO线,或使用SPI开关芯片。 |
6. 最佳实践与工程建议
掌握了基础驱动后,如何将SPI用得更好、更稳?以下是一些进阶建议:
封装与抽象:
- 将SPI底层读写函数(如
SPI1_SendByte)封装在独立的spi.c/h中,与具体的OLED驱动分离。 - 在OLED驱动层,通过函数指针或回调机制调用SPI发送函数,提高代码可移植性。这样更换SPI接口(如换用SPI2或软件模拟SPI)只需修改底层。
- 将SPI底层读写函数(如
使用DMA提升性能:
- 当需要刷新整屏或大量数据时,CPU轮询等待SPI发送完成会浪费大量资源。使用DMA可以将数据从内存自动搬运到SPI数据寄存器,发送完成后产生中断通知CPU。
- 步骤:配置DMA通道(SPI1_TX通常用DMA1_Channel3),设置内存地址、外设地址、数据长度、传输模式,使能DMA和SPI的TX DMA请求。
软件模拟SPI(GPIO模拟时序):
- 当硬件SPI引脚被占用,或需要驱动不标准时序的器件时,可以用任意GPIO模拟SPI时序。
- 优点:引脚分配灵活。
- 缺点:速度慢,占用CPU。
- 实现关键:严格按照时序图,在
CPHA=0时,先设置数据位,再产生时钟边沿;在CPHA=1时,先产生时钟边沿,再设置数据位。注意时钟空闲电平。
配置的健壮性:
- 时钟极性/相位:永远以从设备数据手册为准。在初始化函数中添加注释,写明模式。
- 片选管理:通信前后严格操作CS引脚,即使只有一个设备。在通信间隔较长的低功耗场景,拉高CS可以降低从设备功耗。
- 错误处理:检查SPI状态标志,如
SPI_I2S_FLAG_OVR(溢出错误)、MODF(模式错误)等,并在调试时输出错误信息。
调试技巧:
- 逻辑分析仪是神器:一个几十元的逻辑分析仪配合软件(如Saleae Logic)可以直观看到SCK、MOSI、MISO、CS的波形,精确测量时序、分析数据,是排查SPI问题的终极手段。
- 简化测试:先尝试发送固定的单字节(如0xAA),用逻辑分析仪抓取波形,对照时序图检查。再逐步增加复杂度。
- 利用示波器:测量SCK频率是否与配置相符,检查信号质量(有无过冲、振铃)。
跨平台与兼容性:
- 如果你的代码需要兼容多种MCU或平台,可以将SPI操作抽象为统一的接口,如
spi_transfer(),在不同平台下提供具体实现。 - 注意不同厂商库函数对CPHA的定义可能不同,务必查看库文件中的注释或定义。
- 如果你的代码需要兼容多种MCU或平台,可以将SPI操作抽象为统一的接口,如
从理解SPI的四根线、四种模式,到完成一个OLED显示屏的驱动,我们走完了SPI应用的一个完整闭环。SPI的核心在于“同步”和“全双工”,关键点在于主从设备模式的匹配。通过本项目,你不仅掌握了STM32硬件SPI的配置流程,也学会了如何阅读外设芯片(SSD1306)的数据手册,并根据时序图编写驱动代码——这是嵌入式开发中最核心的能力之一。
下一步,你可以尝试:
- 驱动其他SPI设备:如Flash芯片W25Q64、温湿度传感器BME280、以太网控制器ENC28J60等,巩固协议理解。
- 实现SPI+DMA:优化大量数据传输时的性能。
- 探索更复杂的SPI变种:如支持Quad-SPI(QSPI)的Flash,它使用更多数据线进一步提升速度。
- 使用RTOS:在RTOS任务中管理SPI通信,实现非阻塞的显示刷新。
希望这篇长文能成为你SPI学习路上的坚实参考。在实际项目中遇到问题,多回顾时序图,善用逻辑分析仪,问题总能迎刃而解。