SPI通信协议详解与STM32驱动OLED实战指南
2026/8/4 14:57:30 网站建设 项目流程

在嵌入式开发中,与传感器、存储芯片、显示屏等外设通信是家常便饭。面对UART、I2C、SPI这几种常见的通信协议,很多开发者,尤其是初学者,常常会困惑:它们有什么区别?我的项目该选哪个?特别是SPI,虽然速度最快,但其相对复杂的时序和配置也让不少人望而却步。本文将为你彻底厘清SPI通信协议的核心原理,并通过一个完整的实战案例——使用STM32的硬件SPI驱动OLED屏幕,带你从零到一掌握SPI的应用。无论你是刚接触单片机的新手,还是想巩固通信基础的中级开发者,都能从本文获得可直接复用的代码和清晰的配置思路。

1. 背景与核心概念:为什么需要SPI?

在深入细节之前,我们先从宏观上理解SPI的定位。微控制器(MCU)或处理器需要与外部芯片“对话”,这种对话需要遵循双方都能理解的规则,这就是通信协议。

1.1 常见串行通信协议对比

为了理解SPI的独特价值,我们将其与UART和I2C进行简单对比:

特性UARTI2CSPI
通信方式全双工(通常指异步)半双工全双工
信号线数量2根 (TX, RX) + GND2根 (SDA, SCL) + GND3根或4根(MOSI, MISO, SCK, CS) + GND
拓扑结构点对点多主多从(总线式)一主多从(片选式)
有无时钟线无(异步)有(同步)有(同步)
最高速度较低(通常<10Mbps)中速(标准模式100kbps,快速模式400kbps)高速(可达几十甚至上百Mbps)
硬件复杂度简单中等相对复杂
软件复杂度简单中等(需处理地址、应答)简单(直读直写)
典型应用调试打印、蓝牙模块传感器(如温湿度)、EEPROMFlash存储(如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可接固定电平,但通常不推荐):

  1. SCK (Serial Clock) - 串行时钟线

    • 由主设备产生并输出给所有从设备。
    • 时钟的边沿决定了数据何时被采样(读取)和何时被改变(输出)。
    • 这是SPI同步的核心
  2. MOSI (Master Out Slave In) - 主出从入线

    • 主设备通过此线向从设备发送数据。
  3. MISO (Master In Slave Out) - 主入从出线

    • 从设备通过此线向主设备发送数据。
    • 注意:当从设备未被选中时,必须将MISO置为高阻态,以避免总线冲突。
  4. 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 0Mode 3表示:

模式CPOLCPHA空闲时钟数据采样时刻数据改变时刻常见应用
Mode 000低电平第一个边沿(上升沿)第二个边沿(下降沿)最常用,如W25Qxx Flash
Mode 101低电平第二个边沿(下降沿)第一个边沿(上升沿)
Mode 210高电平第一个边沿(下降沿)第二个边沿(上升沿)
Mode 311高电平第二个边沿(上升沿)第一个边沿(下降沿)如SD卡(初始化时)

如何记忆和配置?关键原则:主从设备的工作模式必须完全一致!通常,从设备(如传感器、Flash芯片)的数据手册会明确规定其支持的SPI模式。我们在配置主设备(MCU)的SPI时,必须严格按照从设备的要求来设置CPOL和CPHA。

2.3 SPI通信过程详解

让我们以最常用的Mode 0 (CPOL=0, CPHA=0)为例,拆解一次完整的8位数据通信过程:

  1. 空闲状态:SCK为低电平,CS为高电平(未选中)。
  2. 启动通信:主设备将目标从设备的CS线拉低
  3. 数据移位与采样(以8位数据0xAA (1010 1010b)为例)
    • 在CS拉低后,主从双方立即将各自要发送的第一位数据(最高位MSB)放到MOSI和MISO线上。
    • 第一个SCK上升沿:主设备产生一个上升沿。此时,从设备采样主设备MOSI线上的数据位(即0xAA的最高位1),同时主设备采样从设备MISO线上的数据位。
    • 第一个SCK下降沿:主从双方根据要发送的下一位数据,改变各自输出线上的电平。
    • 重复此过程8次,依次传输完所有8位数据。注意:数据通常从最高位(MSB)开始传输,但有些设备也支持最低位(LSB)优先,需查阅手册。
  4. 结束通信: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信号说明
PA5D0 (CLK)SCKSPI1时钟线
PA7D1 (MOSI)MOSISPI1主出从入线
PA4CS (片选)CS自定义GPIO,低电平选中
PA2DC (数据/命令)DC自定义GPIO,区分数据/命令
3.3VVCC-电源正极
GNDGND-电源地
PA1RES (复位)RESET复位引脚,可选,本文用软件延时替代

重要连接说明

  1. MISO线:OLED屏作为纯输出设备(只接收MCU发来的显示数据,不返回数据),因此其D1引脚是单向的(对于OLED是DIN)。在标准SPI定义中,这是主设备的MOSI。我们不需要连接MISO线。
  2. DC引脚:这是一个SPI协议之外的、SSD1306特有的控制引脚。DC=0表示接下来发送的是命令(如设置对比度、扫描方式),DC=1表示接下来发送的是要显示的RAM数据。我们需要用一个普通的GPIO来控制它。
  3. CS引脚:虽然理论上可以一直拉低(因为只有一个从设备),但规范的做法还是独立控制,便于后续扩展和降低功耗。
  4. RES引脚:硬件复位,上电时需要拉低再拉高以初始化芯片。可以用GPIO控制,也可以直接接VCC(模块内部可能有上拉),本文为了简化,在代码中用上电后软件延时替代硬件复位。

4. 实战:STM32硬件SPI驱动OLED显示

我们将工程分为以下几个步骤:初始化GPIO和SPI、编写OLED驱动底层函数、实现字符和字符串显示、最后在主函数中测试。

4.1 创建工程与配置SPI

首先,在Keil中创建一个基于标准外设库的STM32工程,并配置RCC(系统时钟)、GPIO和SPI1。

关键配置步骤(基于寄存器/SPL思路)

  1. 开启外设时钟:SPI1挂载在APB2总线上,GPIOA也挂载在APB2。
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_SPI1 | RCC_APB2Periph_AFIO, ENABLE);
  2. 配置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);
  3. 配置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); // 使能SPI1
    特别注意:标准外设库中SPI_CPHA_1Edge表示在第一个边沿采样,对应CPHA=0SPI_CPHA_2Edge对应 CPHA=1。这是容易混淆的地方,务必对照数据手册和库定义。

4.2 编写OLED驱动底层函数

我们创建oled.coled.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); #endif

oled.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 编译、下载与运行

  1. 将完整的字库数据font.h添加到工程。
  2. 编译工程,确保无错误。
  3. 通过ST-Link或USB转串口工具将程序下载到STM32。
  4. 给开发板和OLED模块上电。
  5. 如果连接正确,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用得更好、更稳?以下是一些进阶建议:

  1. 封装与抽象

    • 将SPI底层读写函数(如SPI1_SendByte)封装在独立的spi.c/h中,与具体的OLED驱动分离。
    • 在OLED驱动层,通过函数指针或回调机制调用SPI发送函数,提高代码可移植性。这样更换SPI接口(如换用SPI2或软件模拟SPI)只需修改底层。
  2. 使用DMA提升性能

    • 当需要刷新整屏或大量数据时,CPU轮询等待SPI发送完成会浪费大量资源。使用DMA可以将数据从内存自动搬运到SPI数据寄存器,发送完成后产生中断通知CPU。
    • 步骤:配置DMA通道(SPI1_TX通常用DMA1_Channel3),设置内存地址、外设地址、数据长度、传输模式,使能DMA和SPI的TX DMA请求。
  3. 软件模拟SPI(GPIO模拟时序)

    • 当硬件SPI引脚被占用,或需要驱动不标准时序的器件时,可以用任意GPIO模拟SPI时序。
    • 优点:引脚分配灵活。
    • 缺点:速度慢,占用CPU。
    • 实现关键:严格按照时序图,在CPHA=0时,先设置数据位,再产生时钟边沿;在CPHA=1时,先产生时钟边沿,再设置数据位。注意时钟空闲电平。
  4. 配置的健壮性

    • 时钟极性/相位:永远以从设备数据手册为准。在初始化函数中添加注释,写明模式。
    • 片选管理:通信前后严格操作CS引脚,即使只有一个设备。在通信间隔较长的低功耗场景,拉高CS可以降低从设备功耗。
    • 错误处理:检查SPI状态标志,如SPI_I2S_FLAG_OVR(溢出错误)、MODF(模式错误)等,并在调试时输出错误信息。
  5. 调试技巧

    • 逻辑分析仪是神器:一个几十元的逻辑分析仪配合软件(如Saleae Logic)可以直观看到SCK、MOSI、MISO、CS的波形,精确测量时序、分析数据,是排查SPI问题的终极手段。
    • 简化测试:先尝试发送固定的单字节(如0xAA),用逻辑分析仪抓取波形,对照时序图检查。再逐步增加复杂度。
    • 利用示波器:测量SCK频率是否与配置相符,检查信号质量(有无过冲、振铃)。
  6. 跨平台与兼容性

    • 如果你的代码需要兼容多种MCU或平台,可以将SPI操作抽象为统一的接口,如spi_transfer(),在不同平台下提供具体实现。
    • 注意不同厂商库函数对CPHA的定义可能不同,务必查看库文件中的注释或定义。

从理解SPI的四根线、四种模式,到完成一个OLED显示屏的驱动,我们走完了SPI应用的一个完整闭环。SPI的核心在于“同步”和“全双工”,关键点在于主从设备模式的匹配。通过本项目,你不仅掌握了STM32硬件SPI的配置流程,也学会了如何阅读外设芯片(SSD1306)的数据手册,并根据时序图编写驱动代码——这是嵌入式开发中最核心的能力之一。

下一步,你可以尝试:

  1. 驱动其他SPI设备:如Flash芯片W25Q64、温湿度传感器BME280、以太网控制器ENC28J60等,巩固协议理解。
  2. 实现SPI+DMA:优化大量数据传输时的性能。
  3. 探索更复杂的SPI变种:如支持Quad-SPI(QSPI)的Flash,它使用更多数据线进一步提升速度。
  4. 使用RTOS:在RTOS任务中管理SPI通信,实现非阻塞的显示刷新。

希望这篇长文能成为你SPI学习路上的坚实参考。在实际项目中遇到问题,多回顾时序图,善用逻辑分析仪,问题总能迎刃而解。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询