目录
一、模拟SPI核心原理(模式0)
二、前期准备
1. 硬件材料
2. 软件环境
三、核心实现:代码编写与解析
1. 通用引脚定义(主机与从机一致)
2. 主机代码实现
3. 从机代码实现
四、硬件连接步骤
五、测试与调试
1. 程序上传
2. 查看通信结果
六、常见问题与解决方案
1. 时序同步错位(核心问题)
2. 引脚选择不当
3. 缺少共地或接触不良
4. 从机进入死循环
在嵌入式开发中,SPI(串行外设接口)是常用的同步通信协议,广泛用于传感器、存储芯片、显示屏等外设的通信。ESP32自带硬件SPI外设,但在某些场景下(如硬件SPI引脚被占用、需要自定义通信引脚),模拟SPI(软件SPI)就成为了刚需。本文将详细讲解在Arduino环境下,如何实现ESP32模拟SPI的主机与从机通信,包含完整代码、硬件连接、时序分析及常见问题解决。
一、模拟SPI核心原理(模式0)
SPI通信有4种模式,由时钟极性(CPOL)和时钟相位(CPHA)定义,本文选择最常用的模式0(CPOL=0,CPHA=0),其核心时序规则如下:
时钟空闲状态:SCLK为低电平(CPOL=0);
数据采样时机:SCLK的上升沿(第一个时钟沿)采样数据;
数据更新时机:SCLK的下降沿(第二个时钟沿)更新数据;
通信逻辑:主机产生SCLK时钟和CS片选信号,通过MOSI发送数据,从机同步通过MISO返回数据(全双工通信)。
模拟SPI的本质是通过软件控制GPIO口的电平变化,模拟上述时序,无需依赖硬件SPI外设,灵活性更高,但通信速度低于硬件SPI。
二、前期准备
1. 硬件材料
ESP32开发板 2块(1块作为主机,1块作为从机);
杜邦线 若干(至少4根,用于连接SCLK、MOSI、MISO、CS);
USB数据线 2根(用于给开发板供电和上传程序);
面包板 1块(可选,用于规范接线,避免接触不良)。
2. 软件环境
Arduino IDE(已安装ESP32开发板支持包,若未安装,可参考官方指南:https://docs.espressif.com/projects/arduino-esp32/en/latest/installing.html)。
三、核心实现:代码编写与解析
本次实现“主机发送固定数据0x54,从机接收后返回固定数据0x77”的全双工通信,引脚定义优先选择ESP32通用GPIO(避免使用SDIO复用引脚,减少电平干扰)。
1. 通用引脚定义(主机与从机一致)
选择4个通用GPIO口,对应关系如下(可根据实际需求调整,但需保证主机与从机一一对应):
#define SPI_SCLK 9 // 串行时钟引脚 #define SPI_MOSI 10 // 主机输出从机输入引脚 #define SPI_MISO 11 // 主机输入从机输出引脚 #define SPI_CS 12 // 片选引脚(低有效)2. 主机代码实现
主机核心功能:初始化GPIO口、产生SCLK时钟和CS信号、通过MOSI发送数据、通过MISO接收从机数据。
/* * ESP32 模拟 SPI 主机(模式0:CPOL=0,CPHA=0) */ #define SPI_SCLK 9 // 串行时钟引脚 #define SPI_MOSI 10 // 主机输出从机输入引脚 #define SPI_MISO 11 // 主机输入从机输出引脚 #define SPI_CS 12 // 片选引脚(低有效) #define CPOL 0 // 时钟极性:空闲低 #define CPHA 0 // 时钟相位:上升沿采样 void spi_soft_init(); uint8_t spi_soft_transfer(uint8_t tx_data); void setup() { Serial.begin(115200); spi_soft_init(); Serial.println("SPI主机初始化完成!"); } void loop() { uint8_t send_data = 0x01; // 测试数据 uint8_t recv_data = spi_soft_transfer(send_data); Serial.printf("主机发送:0x%02X,主机接收(从机返回):0x%02X\n", send_data, recv_data); delay(1000); } // 主机SPI初始化 void spi_soft_init() { pinMode(SPI_SCLK, OUTPUT); pinMode(SPI_MOSI, OUTPUT); pinMode(SPI_MISO, INPUT); pinMode(SPI_CS, OUTPUT); digitalWrite(SPI_CS, HIGH); digitalWrite(SPI_SCLK, CPOL); digitalWrite(SPI_MOSI, LOW); } // 主机全双工传输一个字节 uint8_t spi_soft_transfer(uint8_t tx_data) { uint8_t rx_data = 0; digitalWrite(SPI_CS, LOW); // 选中从机 // 增加短暂延时,确保CS拉低后从机有时间响应 delayMicroseconds(1); for (int i = 7; i >= 0; i--) { // 【关键】先输出MOSI数据,等待电平稳定(提前于SCLK上升沿) digitalWrite(SPI_MOSI, (tx_data >> i) & 0x01); delayMicroseconds(1); // 电平稳定延时 // 模式0:上升沿采样,先拉SCLK高 digitalWrite(SPI_SCLK, HIGH); delayMicroseconds(1); // 等待从机响应并稳定MISO电平 // 【关键】延时后再采样MISO,避免时序错位 rx_data |= (digitalRead(SPI_MISO) << i); // 拉低SCLK,恢复空闲电平 digitalWrite(SPI_SCLK, LOW); delayMicroseconds(1); // 可选:增加延时降低时钟速度 } digitalWrite(SPI_CS, HIGH); // 取消选中从机 return rx_data; }3. 从机代码实现
从机核心功能:初始化GPIO口、持续监听CS信号(等待主机选中)、同步主机SCLK时钟、采样MOSI数据(接收主机数据)、通过MISO返回数据。
/* * ESP32 模拟 SPI 从机(模式0:CPOL=0,CPHA=0) * 引脚需与主机一一对应:SCLK/MOSI/CS 为输入,MISO 为输出 */ #define SPI_SCLK 9 // 主机产生的时钟,从机作为输入 #define SPI_MOSI 10 // 主机输出的数据,从机作为输入 #define SPI_MISO 11 // 从机输出的数据,主机作为输入 #define SPI_CS 12 // 主机的片选,从机作为输入 #define CPOL 0 #define CPHA 0 uint8_t slave_recv_data = 0; // 从机接收的主机数据 uint8_t slave_send_data = 0x02; // 从机要返回给主机的数据 void spi_slave_init(); void spi_slave_listen(); // 从机监听主机的通信请求 void setup() { Serial.begin(115200); spi_slave_init(); Serial.println("SPI从机初始化完成!"); } void loop() { spi_slave_listen(); // 从机持续监听主机的通信 } // 从机SPI初始化 void spi_slave_init() { pinMode(SPI_SCLK, INPUT); pinMode(SPI_MOSI, INPUT); pinMode(SPI_MISO, OUTPUT); pinMode(SPI_CS, INPUT); digitalWrite(SPI_MISO, LOW); // 初始化MISO电平 } // 从机核心:监听主机的CS和SCLK,完成数据接收和发送 void spi_slave_listen() { // 等待主机拉低CS(选中当前从机) if (digitalRead(SPI_CS) != LOW) { slave_recv_data = 0; digitalWrite(SPI_MISO, LOW); // 空闲时MISO拉低 return; } uint8_t recv_data = 0; uint8_t send_data = slave_send_data; // 从机要发送的预存数据 // 逐位处理主机的时钟和数据 for (int i = 7; i >= 0; i--) { // 【关键】在等待SCLK上升沿前,先输出当前位到MISO(提前准备数据) digitalWrite(SPI_MISO, (send_data >> i) & 0x01); delayMicroseconds(1); // 电平稳定延时 // 等待主机产生SCLK上升沿(模式0:上升沿采样) while (digitalRead(SPI_SCLK) != HIGH) { if (digitalRead(SPI_CS) == HIGH) { // 防止CS意外拉高导致死循环 return; } } // 上升沿:从机采样MOSI(接收主机的数据) recv_data |= (digitalRead(SPI_MOSI) << i); delayMicroseconds(1); // 可选:稳定延时 // 等待主机拉低SCLK(恢复空闲电平) while (digitalRead(SPI_SCLK) != LOW) { if (digitalRead(SPI_CS) == HIGH) { // 防止CS意外拉高导致死循环 return; } } } // 通信完成:更新从机的接收数据,并打印调试 slave_recv_data = recv_data; Serial.printf("从机接收主机数据:0x%02X,从机发送给主机数据:0x%02X\n", slave_recv_data, send_data); // 可选:从机发送数据自增,用于测试连续通信 // slave_send_data++; }四、硬件连接步骤
按照以下对应关系连接主机和从机的GPIO口,确保接线牢固(面包板连接需注意杜邦线接触良好):
ESP32主机 | ESP32从机 | 信号说明 |
|---|---|---|
GPIO18(SCLK) | GPIO9(SCLK) | 时钟信号(主机→从机) |
GPIO23(MOSI) | GPIO10(MOSI) | 主机发送→从机接收 |
GPIO19(MISO) | GPIO11(MISO) | 从机发送→主机接收 |
GPIO5(CS) | GPIO12(CS) | 片选信号(主机→从机) |
GND | GND | 共地(必须连接,否则电平不稳定) |
注意:主机和从机必须共地,否则会因电平参考点不一致导致通信失败!
五、测试与调试
1. 程序上传
将主机代码上传到其中一块ESP32开发板;
将从机代码上传到另一块ESP32开发板;
上传完成后,给两块开发板通电。
2. 查看通信结果
打开Arduino IDE的“串口监视器”,分别选择两块开发板的串口(波特率设为115200),正常通信时应显示以下日志:
主机日志:
从机日志:
六、常见问题与解决方案
在实际测试中,最常见的问题是“从机能接收主机数据,但主机接收从机数据错误(如0xBB、0xFF)”,以下是核心问题及解决方法:
1. 时序同步错位(核心问题)
问题原因:主机在SCLK上升沿瞬间采样MISO,但从机未及时输出数据(从机在上升沿后才更新MISO电平),导致主机采样到旧电平。
解决方案:从机需在SCLK上升沿前输出MISO数据,主机采样前增加短暂延时(如delayMicroseconds(1)),确保电平稳定。本文提供的代码已优化此问题。
2. 引脚选择不当
问题原因:ESP32的GPIO9、10、11、12等属于SDIO复用引脚,若用于模拟SPI,可能因硬件复用导致电平异常。
解决方案:优先选择通用GPIO(如18、19、23、5、4等),避免使用SDIO、UART等复用引脚。
3. 缺少共地或接触不良
问题原因:主机与从机未共地,电平参考点不一致;面包板或杜邦线接触不良,导致信号丢失。
解决方案:确保主机和从机的GND相连;检查杜邦线是否插紧,必要时更换杜邦线或面包板。
4. 从机进入死循环
问题原因:从机的while循环(等待SCLK沿)中,若CS意外拉高,会一直循环无法退出。
解决方案:在从机的while循环中增加CS状态判断,若CS拉高则直接返回(本文代码已添加此保护)。