SPI总线是我们常用的串行设备接口,一般情况下我们都会适应硬件SPI接口,但有些时候当硬件端口不足时,我们也希望可以使用软件来模拟SPI硬件接口,特别是要求不是很高的时候。在这一篇中我们将来讨论如何使用GPIO和软件来模拟SPI通讯接口。
1、功能概述
SPI即串行外设接口,是一种同步串行通讯接口,用于微处理器及控制器和外围扩展芯片之间的串行连接,现已发展成为一种工业标准。
1.1、物理层
SPI总线在物理层通常使用3条总线及1条片选线,3条总线分别为 SCK、 MOSI、 MISO,片选线为NSS,它们的作用介绍如下:
NSS( Slave Select),从设备选择信号线,常称为片选信号线。在SPI协议中没有设备地址,所以需要使用NSS信号线来寻址,当主机要选择从设备时,把该从设备的NSS信号线设置为低电平,该从设备即被选中,即片选有效,接着主机开始与被选中的从设备进行SPI通讯。所以SPI通讯以NSS线置低电平为开始信号,以NSS线被拉高作为结束信号。
SCK (Serial Clock),时钟信号线,用于通讯数据同步。它由通讯主机产生,决定了通讯的速率,不同的设备支持的最高时钟频率不一样,如 STM32 的 SPI 时钟频率最大为fpclk/2,两个设备之间通讯时,通讯速率受限于低速设备。
MOSI (Master Output, Slave Input),主设备输出/从设备输入引脚。主机的数据从这条信号线输出,从机由这条信号线读入主机发送的数据,即这条线上数据的方向为主机到从机。
MISO(Master Input,, Slave Output),主设备输入/从设备输出引脚。主机从这条信号线读入数据,从机的数据由这条信号线输出到主机,即在这条线上数据的方向为从机到主机。
对于使用SPI总线来进行通讯的设备,一台主机可以与多台从机进行通讯,时钟与数据总线为公用,片选线每台从机都是独立的,具体连接方式如下图所示:
也就是说,有多个SPI从设备与SPI主机通讯时,设备的时钟线和总线数据线 SCK、MOSI及MISO同时并联到相同的SPI总线上,即无论有多少个从设备,都共同只使用这 3 条总线。而每个从设备都拥有独立的一条NSS信号线,即有多少个从设备,就有多少条片选信号线。
1.2、协议层
我们已经简述了SPI总线的物理连接方式,接下来我们再来了解一下具体的通讯协议。在了解协议前,我们需要清楚两个概念,即时钟的极性和时钟的相位。
所谓时钟极性,通常称作CPOL,即是指在空闲状态下,时钟所处的电平状态。如果SCLK在数据发送之前和之后的空闲状态是高电平,那么就是CPOL=1;如果空闲状态SCLK是低电平,那么就是 CPOL=0。
而时钟的相位,通常称作CPHA,就是指数据采样是在时钟脉冲的第1个跳变沿还是在第2个跳变沿。如果在SCK的第1个跳变沿进行数据采样,则CPHA=0;如果在SCK的第2个跳变沿采样,则CPHA=1。
在通讯协议中,根据CPOL和CPHA的取值不同存在4种不同的配置方式,不同的配置方式对应不同的通讯模式。
(1)当CPOL=0,CPHA=0时,空闲状态时钟SCK的电平要保持在低电平,而数据的采样时刻在时钟脉冲的奇数跳变边沿,其时序图如下:
(2)当CPOL=0,CPHA=1时,空闲状态时钟SCK的电平要保持在低电平,而数据的采样时刻在时钟脉冲的偶数跳变边沿,其时序图如下:
(3)当CPOL=1,CPHA=0时,空闲状态时钟SCK的电平要保持在高电平,而数据的采样时刻在时钟脉冲的奇数跳变边沿,其时序图如下:
(4)当CPOL=1,CPHA=1时,空闲状态时钟SCK的电平要保持在高电平,而数据的采样时刻在时钟脉冲的偶数跳变边沿,其时序图如下:
根据这时钟情况,将SPI总线的工作模式分为4种,如下图所示:
而只有主机与从机拥有相同的工作模式时,主从机之间才可以正常通讯。在实际应用中,“模式 0”与“模式 3”是比较常见的工作模式,但在我们的驱动设计中应该兼顾这4种模式,以具备更广泛的适应性。
2、驱动设计与实现
我们已经简要的描述了SPI通讯总线的物理连接和通讯协议,接下来我们将根据其协议的特性设计并实现基于GPIO模拟的SPI总线驱动。
2.1、对象定义
我们依然使用基于对象的思想来实现基于GPIO模拟的SPI总线驱动。既然是基于对象,那么在使用一个对象之前我们需要先获得这个对象。所以我们必须先定义一个基于GPIO模拟的SPI总线的对象。
2.1.1、对象的抽象
我们要得到基于GPIO模拟的SPI总线的对象,需要先分析其基本特性。一般来说,一个对象至少包含属性与操作两方面的特性。接下来我们就来从这两个方面思考一下基于GPIO模拟的SPI总线的对象。
我们首先来考虑对象的属性,作为属性肯定是用于标识或记录对象特征的东西。我们在前面已经了解到SPI总线的一些特点和独特设定,那么这些特点和设定是否能成为对象的属性呢?
我们考虑到作为同步总线,SPI总线的控制需要时钟,这关系到SPI总线的通讯速率,这一通讯速率不仅配置SPI总线的工作方式也标识当前的工作状态,所以我们将工作速率作为模拟SPI总线对象的一个属性。在前面我们讨论过SPI总线的工作模式,工作模式由CPOL和CPHA决定,所以在初始化总线时就会确定工作模式,所以我们需要记录CPOL和CPHA,我们将CPOL和CPHA也作为对象的属性。
接下来我们考虑GPIO模拟SPI总线的操作问题。我们将那些对象要实现的,并且依赖于具体的平台的行为实现定义为对象的操作。对于GPIO模拟SPI总线来说,我们需要通过总线发送数据和接收数据,而接收和发送的实现都依赖于具体的软硬件平台,所以我们将发送数据和接收数据定义为对象的操作。SPI总线作为同步总线需要时钟,而时钟操作也是依赖于具体的软硬件平台来实现,所以我们将始终的控制也作为对象的操作。
根据上述我们对GPIO模拟SPI总线的分析,我们可以定义GPIO模拟SPI总线对象类型如下:
/*定义GPIO模拟SPI接口对象*/
typedef struct SimuSPIObject{uint16_t CPOL:1;uint16_t CPHA:1;uint16_t period:14; //确定速度为大于0K小于等于400K的整数,默认为100Kvoid (*SetSCKPin)(SimuSPIPinValueType op); //设置SCL引脚void (*SetMOSIPin)(SimuSPIPinValueType op); //设置SDA引脚uint8_t (*ReadMISOPin)(void); //读取SDA引脚位void (*Delayus)(volatile uint32_t period); //速度延时函数
}SimuSPIObjectType;
2.1.2、对象初始化
我们知道,在使用一个对象之前需要先对其进行初始化,所以这里我们来考虑GPIO模拟SPI对象的初始化函数。一般来说,初始化函数需要处理几个方面的问题。一是检查输入参数是否合理;二是为对象的属性赋初值;三是对对象做必要的初始化配置。据此我们设计GPIO模拟SPI对象的初始化函数如下:
/* GPIO模拟SPI通讯初始化 */
void SimuSPIInitialization(SimuSPIObjectType *simuSPIInstance,//初始化的模拟SPI对象uint32_t speed, //时钟频率SimuSPICPOLType CPOL, //时钟极性SimuSPICPHAType CPHA, //时钟频率SimuSPISetSCKPin setSCK, //SCK时钟操作函数指针SimuSPISetMOSIPin setMOSI, //MOSI操作函数指针SimuSPIReadMISOPin getMISO, //MISO操作函数指针SimuSPIDelayus delayus //微秒延时操作函数指针)
{if((simuSPIInstance==NULL)||(setSCK==NULL)||(setMOSI==NULL)||(getMISO==NULL)||(delayus==NULL)){return;}simuSPIInstance->SetSCKPin=setSCK;simuSPIInstance->SetMOSIPin=setMOSI;simuSPIInstance->ReadMISOPin=getMISO;simuSPIInstance->Delayus=delayus;/*初始化速度,默认100K*/if((speed>0)&&(speed<=500)){simuSPIInstance->period=500/speed;}else{simuSPIInstance->period=5;}simuSPIInstance->CPOL=CPOL;simuSPIInstance->CPHA=CPHA;/*拉高总线,使处于空闲状态*/if(simuSPIInstance->CPOL==SimuSPI_POLARITY_LOW){simuSPIInstance->SetSCKPin(SimuSPI_Reset);}else{simuSPIInstance->SetSCKPin(SimuSPI_Set);}
}
2.2、对象操作
我们已经定义了对象类型,也实现了对象的初始化函数,但我们还没有实现对象的具体操作,所以接下来我们就来实现对象的具体操作。
2.2.1、数据的发送
在我们使用SPI来实现数据通讯时,免不了要发送数据,所以在我们使用GPIO模拟SPI端口时就需要解决数据发送的问题。这里我们考虑使用模拟SPI发送一个字节的问题,因为发送多个字节无非是多重复几次。根据前面分析的在不同模式下的时序图我们可以编写GPIO模拟SPI发送一个字节的函数如下:
/* 通过模拟SPI发送一个字节 */
static void SendByteBySimuSPI(SimuSPIObjectType *simuSPIInstance,uint8_t byte)
{
// uint8_t length[2]={8,16};if(simuSPIInstance->CPOL==SimuSPI_POLARITY_LOW){/*拉低SCL引脚准备数据传输*/simuSPIInstance->SetSCKPin(SimuSPI_Reset);if(simuSPIInstance->CPHA==SimuSPI_PHASE_1EDGE) //模式0{for(uint8_t count = 0; count < 8; count++){if(byte & 0x80) //每次发送最高位{simuSPIInstance->SetMOSIPin(SimuSPI_Set);}else{simuSPIInstance->SetMOSIPin(SimuSPI_Reset);}byte <<= 1; //发送一位后,左移一位simuSPIInstance->Delayus(simuSPIInstance->period);simuSPIInstance->SetSCKPin(SimuSPI_Set);simuSPIInstance->Delayus(simuSPIInstance->period);simuSPIInstance->SetSCKPin(SimuSPI_Reset);}}else //模式1{for(uint8_t count = 0; count < 8; count++){if(byte & 0x80) //每次发送最高位{simuSPIInstance->SetMOSIPin(SimuSPI_Set);}else{simuSPIInstance->SetMOSIPin(SimuSPI_Reset);}byte <<= 1; //发送一位后,左移一位simuSPIInstance->SetSCKPin(SimuSPI_Set);simuSPIInstance->Delayus(simuSPIInstance->period);simuSPIInstance->SetSCKPin(SimuSPI_Reset);simuSPIInstance->Delayus(simuSPIInstance->period);}}}else{/*拉低SCL引脚准备数据传输*/simuSPIInstance->SetSCKPin(SimuSPI_Set);if(simuSPIInstance->CPHA==SimuSPI_PHASE_1EDGE) //模式2{for(uint8_t count = 0; count < 8; count++){if(byte & 0x80) //每次发送最高位{simuSPIInstance->SetMOSIPin(SimuSPI_Set);}else{simuSPIInstance->SetMOSIPin(SimuSPI_Reset);}byte <<= 1; //发送一位后,左移一位simuSPIInstance->Delayus(simuSPIInstance->period);simuSPIInstance->SetSCKPin(SimuSPI_Reset);simuSPIInstance->Delayus(simuSPIInstance->period);simuSPIInstance->SetSCKPin(SimuSPI_Set);}}else //模式3{for(uint8_t count = 0; count < 8; count++){if(byte & 0x80) //每次发送最高位{simuSPIInstance->SetMOSIPin(SimuSPI_Set);}else{simuSPIInstance->SetMOSIPin(SimuSPI_Reset);}byte <<= 1; //发送一位后,左移一位simuSPIInstance->Delayus(simuSPIInstance->period);simuSPIInstance->SetSCKPin(SimuSPI_Reset);simuSPIInstance->Delayus(simuSPIInstance->period);simuSPIInstance->SetSCKPin(SimuSPI_Set);}}}
}
2.2.2、数据的接收
对于SPI端口来说不光需要发送数据,也需要从对方接收数据,同样的我们再次考虑接收一个字节的情况。同样我们根据前面对不同模式下,接收数据的时序要求可以编写接收一个字节的函数如下:
/* 通过模拟SPI接收一个字节 */
static uint8_t RecieveByteBySimuSPI(SimuSPIObjectType *simuSPIInstance)
{uint8_t receive = 0;if(simuSPIInstance->CPOL==SimuSPI_POLARITY_LOW){/*拉低SCL引脚准备数据传输*/simuSPIInstance->SetSCKPin(SimuSPI_Reset);if(simuSPIInstance->CPHA==SimuSPI_PHASE_1EDGE) //模式0{for(uint8_t count = 0; count < 8; count++ ){simuSPIInstance->SetSCKPin(SimuSPI_Set);simuSPIInstance->Delayus(simuSPIInstance->period);receive <<= 1;if(simuSPIInstance->ReadMISOPin()){receive++;}simuSPIInstance->SetSCKPin(SimuSPI_Reset);simuSPIInstance->Delayus(simuSPIInstance->period);}}else //模式1{simuSPIInstance->SetSCKPin(SimuSPI_Set);simuSPIInstance->Delayus(simuSPIInstance->period);for(uint8_t count = 0; count < 8; count++ ){simuSPIInstance->SetSCKPin(SimuSPI_Reset);simuSPIInstance->Delayus(simuSPIInstance->period);receive <<= 1;if(simuSPIInstance->ReadMISOPin()){receive++;}simuSPIInstance->SetSCKPin(SimuSPI_Set);simuSPIInstance->Delayus(simuSPIInstance->period);}simuSPIInstance->SetSCKPin(SimuSPI_Reset);}}else{/*拉低SCL引脚准备数据传输*/simuSPIInstance->SetSCKPin(SimuSPI_Set);if(simuSPIInstance->CPHA==SimuSPI_PHASE_1EDGE) //模式2{for(uint8_t count = 0; count < 8; count++ ){simuSPIInstance->SetSCKPin(SimuSPI_Reset);simuSPIInstance->Delayus(simuSPIInstance->period);receive <<= 1;if(simuSPIInstance->ReadMISOPin()){receive++;}simuSPIInstance->SetSCKPin(SimuSPI_Set);simuSPIInstance->Delayus(simuSPIInstance->period);}}else //模式3{simuSPIInstance->SetSCKPin(SimuSPI_Reset);simuSPIInstance->Delayus(simuSPIInstance->period);for(uint8_t count = 0; count < 8; count++ ){simuSPIInstance->SetSCKPin(SimuSPI_Set);simuSPIInstance->Delayus(simuSPIInstance->period);receive <<= 1;if(simuSPIInstance->ReadMISOPin()){receive++;}simuSPIInstance->SetSCKPin(SimuSPI_Reset);simuSPIInstance->Delayus(simuSPIInstance->period);}simuSPIInstance->SetSCKPin(SimuSPI_Set);}}return receive;
}
3、驱动的使用
我们已经设计并实现了GPIO模拟SPI总线的驱动程序,接下来我们将基于这一驱动设计一个简单的用例,以验证驱动程序的正确性。
3.1、声明并初始化对象
我们是基于对象来实现GPIO模拟SPI驱动的,所以在开始之前我们需要声明一个模拟SPI对象如下:
SimuSPIObjectType simuSPI;
声明了这一对象之后,我们还需要对这一变量进行初始化才能使用。前面我们已经实现了对象变量的初始化函数,使用这一函数就可方便的初始化对象变量,该函数有多个输入数:
SimuSPIObjectType *simuSPIInstance,//初始化的模拟SPI对象
uint32_t speed, //时钟频率
SimuSPICPOLType CPOL, //时钟极性
SimuSPICPHAType CPHA, //时钟相位
SimuSPIDataSizeType dataSize,//数据长度
SimuSPISetSCKPin setSCK, //SCK时钟操作函数指针
SimuSPISetMOSIPin setMOSI, //MOSI操作函数指针
SimuSPIReadMISOPin getMISO, //MISO操作函数指针
SimuSPIDelayus delayus //微秒延时操作函数指针
在这些参数中simuSPIInstance为我们想要初始化的对象变量的指针。时钟极性、时钟相位以及数据长度都是枚举量,我们根据实际的使用要求选择输入即可。时钟频率为我们希望的时钟速度,最大500K。而余下的几个参数则都是回调函数的指针。而这几个函数则是我们在应用程序中需要实现的,它们的原型如下:
//设置SCL引脚
typedef void (*SimuSPISetSCKPin)(SimuSPIPinValueType op);
//设置SDA引脚
typedef void (*SimuSPISetMOSIPin)(SimuSPIPinValueType op);
//读取SDA引脚位
typedef uint8_t (*SimuSPIReadMISOPin)(void);
//速度延时函数
typedef void (*SimuSPIDelayus)(volatile uint32_t period);
这些函数的实现与具体的应用平台有关,我们在STM32F407上基于HAL库实现的这些函数如下:
//设置SCL引脚
void SPISCKOperation(SimuSPIPinValueType op)
{GPIO_PinState PinState=(GPIO_PinState)op;HAL_GPIO_WritePin(GPIOSPI, SPISCK, PinState);
}//设置SDA引脚
void SPIMOSIOperation(SimuSPIPinValueType op)
{GPIO_PinState PinState=(GPIO_PinState)op;HAL_GPIO_WritePin(GPIOSPI, SPIMOSI, PinState);
}//读取SDA引脚位
uint8_t SPIMISORead(void)
{if(HAL_GPIO_ReadPin(GPIOSPI, SPIMISO)){return 1;}return 0;
}
而延时操作函数则采用我们系统中通用的Delayus。有了这些参数后我们合一调用初始化函数对对象变量进行初始化如下:
/* GPIO模拟SPI通讯初始化 */SimuSPIInitialization(&simuSPI,//初始化的模拟SPI对象500, //时钟频率SimuSPI_POLARITY_LOW, //时钟极性SimuSPI_PHASE_1EDGE, //时钟频率SimuSPI_DataSize_8Bit,//数据长度SPISCKOperation, //SCK时钟操作函数指针SPIMOSIOperation, //MOSI操作函数指针SPIMISORead, //MISO操作函数指针Delayus //微秒延时操作函数指针);
3.2、基于对象进行操作
初始化这个对象变量后,我们就可以基于它操作这一对象了。我们基于驱动实现一个简单的读写数据的操作如下:
/* 使用模拟SPI读写数据*/
void SimuSPIDataExchange(void)
{uint8_t wDatas[3];uint8_t rDatas[3];/* 通过模拟SPI向从站写数据 */WriteDataBySimuSPI(&simuSPI,wDatas,3,1000);HAL_Delay(10);/* 通过模拟SPI自从站读数据 */ReadDataBySimuSPI(&simuSPI,rDatas, 3,1000);HAL_Delay(10);/* 通过模拟SPI实现对从站先写数据紧接读数据组合操作 */WriteReadDataBySimuSPI(&simuSPI, wDatas,3,rDatas, 3,1000);HAL_Delay(10);/* 通过模拟SPI实现对从站同时写和读数据组合操作*/WriteWhileReadDataBySimuSPI(&simuSPI, wDatas,rDatas,3,1000);}
我们分别测试了读取数据、下发数据、同时写和读数据以及写完后再读等几种情况的测试,效果还是比较理想的。
4、应用总结
在这一篇中,我们设计并实现了基于GPIO模拟的SPI接口驱动程序。并在此基础上设计了一个简单的测试应用。我们通过GPIO模拟的SPI接口向SPI接口的Flash中写数据、读数据、同时读写和先写后读试验都没有问题。
在使用驱动程序时需要注意,由于是使用GPIO模拟的SPI端口,其速度是受到限制的,目前最快能够支持到500K,再快就不能支持了。所以这个驱动程序只能应用于通讯速度小于500K的设备。