SPI概述

SPI(Serial Peripheral Interface,串行外设接口)协议是一种全双工通信协议,SPI最主要的特点是:

  • 一主多从:即一个主设备(主机)同时和多个串行设备(从机)通信

  • 同步全双工:具有时钟线作为同步信号,主机和从机需要共地,如果从机没有独立供电还需要主机提供供电口;能够同时发送和接收数据;

  • 简单且高速:SPI的时序简单,因为线多端口多没有电平瓶颈(每新加一个从机主机就要增加一个SS端口进行片选,例如三个从机就要三个主机SS端口),主机输出配置成推挽输出输入(MISO)配置成浮空或者上拉输入电平驱动能力强;通信速率极高(几十Mbps);

  • 四根通信线,接口开销大SCK(Serial Clock)、MOSIMISOSS(Slave Select),其中MOSI、MISO其中的M代表主机Master,S代表从机Slave,分布代表主机发从机收,主机收从机发,MISO就接在从机的MISO上,MOSI就接在从机的MOSI上(一般命名上,从机作为没有主控的设备,MISO会命名成输出端,如DO、SDO等,MOSI会命名成DI、SDI等,片选则为CS);

SPI应用的外设主要有:TFT SPI屏幕、SD存储卡、2.4G无线通信芯片;

SPI通信的实现

SPI通信的核心实现是移位寄存器移位寄存器按照SCK时钟进行移位实现主机从机数据交换

SPI移位示意图 从图中来看,现在主机通过某个SS端输出低电平选中了某个从机,现在双方要实现一个字节数据的交换:主机通过波特率发生器产生SCK时钟,在时钟的上升沿主从机的移位寄存器向左移位,也即主机的1进入MOSI,从机的0进入MISO,在SCK时钟的下降沿移位寄存器MOSI、MISO进行电平采样,并且填充移位寄存器的最后一位,这样主机和从机的数据就实现了一个位的互换,如此循环八次,就实现了一个字节数据的交换。

而有时候我们希望只进行单工通信,只希望接收从机数据时,主机还是会在移动寄存器设置垃圾数据,例如0xFF或者0x00,交换从机的数据;而只希望发送主机数据时,那么主机端不要读取返回的数据即可,可见单工通信仍然采用双工的通信方式,某种程度上的确带来了资源的浪费。

另一个值得注意的是,SPI并没有规定SCK起始时必须是高电平还是低电平,也没有规定一定需要上升沿移位,下降沿采样,SPI给出了两个位进行配置:

  • CPOL(Clock Polarity,时钟极性):0代表初始化为低电平,1代表初始化为高电平

  • CPHA(Clock Phase,时钟相位):0代表第一个边沿先采样第二个边沿移入数据,1代表第一个边沿先移入数据第二个边沿采样;这里的边沿指的是跳变沿。

SPI移位示意图

因此结合起来一共有四个可能(CPOL|CPHA定义了Mode0-Mode3),不同的芯片采用的策略不同,具体要参考数据手册决定交换一个字节数据的写法。值得注意的是,如果CPHA=0,那么在SCK第一个边沿会移入数据,为了防止直接覆盖移位寄存器最后一位,移位寄存器首先会在SS的下降沿就移位一次(一般认为这使得移位时间提前了半个周期);

SPI通信时序

有了上面的认识,读写时序一笔带过即可。读写时序是基本一致的:

  1. 主机SSx输出低电平,选中某个从机;
  2. SCK产生时钟,按照模式0~模式3进行不同的移位、采用操作实现一个字节的数据交换,这个字节的指令定义了是读还是写设备,具体参照外设的数据手册;
  3. 如果单字节指令就完成数据传输,SS重新置回高电平,完成数据传输;
  4. 如果单字节指令后面还有指令(例如读写指令后面+地址+数据),SPI没有应答机制,也即SS不变化,继续按照SCK时钟传输指令,直到最后才将SS置回高电平;

为了防止冲突,当从机SS为高电平时,不会通过MISO向主机发数据,此时从机会输出高阻态。

SPI读写TFT触摸屏

纯手撕代码,从零写SPI协议,并且完成TFT触摸屏的stm32驱动模块;采用的是软件SPI的写法,因为多硬件控制、尤其是esp32这些少引脚设备,硬件SPI移植可能依赖库函数和引脚数量。

引脚配置

TFT SPI屏幕显示驱动ILI9341,触摸驱动Xpt2046。

stm32f103c8t6 TFT SPI屏幕
5V/3.3V VCC
GND GND
PB11 CS
PB12 RESET
PB10 DC
PB15 SDI(MOSI)
PB13 SCK
PB9 LED
PB14 SDO(MISO)

引脚初始化代码

相比于传统SPI接口,这里多出来了RESET、DC、LED三个接口。从ILI9341数据手册可以简单了解,RESET是低电平复位信号,复位在数字电路设计是很必要的信号,LED是背光信号,可以接3.3V常亮,也可以由引脚控制;DC是一个特殊信号,它是ILI9341区别命令和数据的一个信号,当它是高电平1时,代表读写的是数据参数,当它是低电平0时,代表读写的是命令参数,这在后面读写函数会用到,ILI9341的Command记录了LCD的设置命令。

因此这里除了MOSI配置成上拉输入,其他六个端口配置成推挽输出即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void TFT_GPIOInit(void){
GPIO_InitTypeDef GPIO_InitStructure;
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB ,ENABLE);
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9| GPIO_Pin_10| GPIO_Pin_11 \
| GPIO_Pin_12|GPIO_Pin_13|GPIO_Pin_15;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_Init(GPIOB, &GPIO_InitStructure);

GPIO_InitStructure.GPIO_Pin = GPIO_Pin_14;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStructure);
}

复位

复位是防止垃圾数据,统一时序电平,和FPGA类似,拉低RESET一会就好了,例程是100ms:

1
2
3
4
5
6
7
8
9
10
11
12
void LCD_RESET(void)
{
LCD_RST_CLR;
delay_ms(100);
LCD_RST_SET;
delay_ms(50);
}
其中:
#define GPIOx GPIOB
#define LCD_RST 12
#define LCD_RST_SET GPIOx->BSRR=1<<LCD_RST //置位PB12,LCD_RST=12
#define LCD_RST_CLR GPIOx->BRR=1<<LCD_RST //复位PB12
这里使用了寄存器操作,寄存器的操作带来的刷新帧率是28帧,库函数是14帧,因此提高了性能。

这里有必要了解一下寄存器知识,以下三个寄存器都能够对初始化的端口进行配置,使其输出低电平或者高电平;    

- BSRR寄存器(端口位设置清除寄存器):32位寄存器,高16位写1为低电平,低16位写1是高电平;写0,无动作;只写

- BRR寄存器(端口位清除寄存器):32位寄存器,仅低16位有效,写1为低电平,写0无动作;只写

- ODR寄存器:32位寄存器,仅低16位有效,写1为高电平,写0为低电平;可读可写寄存器;

其中ODR必须一次设置16位,因此对单一端口配置经常使用BSRR和BRR寄存器,上面使用1左移12位,分别代表将引脚PB12置高电平、低电平。

- IDR寄存器:低十六位有效,读取引脚电平(后面用到)

SPI写数据

软件SPI,ILI9341采用的是模式0(兼容模式3),因此初始化时,先移位向MOSI放数据,再产生SCK时钟的上升沿,再拉回下降沿,如此循环8次就完成一个字节发送:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
uint8_t SwapData(uint8_t SendData){
uint8_t i;
for(i=0;i<8;i++){ //一个字节
if(SendData&0x80) //取最高位
LCD_MOSI_SET; //为1 MOSI也发送1
else
LCD_MOSI_CLR; //否则发0
SendData<<=1; //移位
LCD_SCK_SET; //创造SCK上升沿
if(LCD_MISO_ReadStatus) //读取MISO
SendData|=0x01; //放入主机的最后一位
LCD_SCK_CLR; //拉低SCK
}
return SendData; //最后主机的字节就交换成从机字节,虽然TFT显示没有使用,但是对称性~
}

//其中宏定义:
#define LCD_SCK 13
#define LCD_MOSI 15
#define LCD_MISO 14
#define LCD_SCK_SET GPIOx->BSRR=1<<LCD_SCK //置位 PB13
#define LCD_SCK_CLR GPIOx->BRR=1<<LCD_SCK //复位 PB13
#define LCD_MOSI_SET GPIOx->BSRR=1<<LCD_MOSI //置位 PB15
#define LCD_MOSI_CLR GPIOx->BRR=1<<LCD_MOSI //复位 PB15
#define LCD_MISO_ReadStatus (GPIOx->IDR&(1<<LCD_MISO)) //读取PB14高低电平
SPI的发数据,实际上就是和从机交换数据,因此这里为了对称性,我们还是把从机的数据获取了过来,对于TFT显示这不是必须的,但是对SPI这个概念需要有。

SPI向TFT发送字节数据

这里字节数据分成8bits的命令(寄存器地址)还是8bits的数据,区别在于DC是拉低还是拉高:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void LCD_SendCMD(uint8_t cmdbyte){
LCD_CS_CLR; //拉低SS片选
LCD_DC_CLR; //发命令:拉低DC
SwapData(cmdbyte);
LCD_CS_SET; //拉高片选
}

void LCD_SendData(uint8_t databyte){
LCD_CS_CLR; //拉低SS片选
LCD_DC_SET; //发数据:拉高DC
SwapData(databyte);
LCD_CS_SET; //拉高片选
}

//宏解析:
#define LCD_CS 11
#define LCD_DC 10
#define LCD_CS_SET GPIOx->BSRR=1<<LCD_CS
#define LCD_CS_CLR GPIOx->BRR=1<<LCD_CS
#define LCD_DC_SET GPIOx->BSRR=1<<LCD_DC
#define LCD_DC_CLR GPIOx->BRR=1<<LCD_DC
再封装一些特殊用法,例如发送16位的数据,向8位寄存器地址发送8位的数据:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
void LCD_SendData_16bits(uint16_t databyte_16bits){
LCD_CS_CLR; //拉低SS片选
LCD_DC_SET; //发数据:拉高DC
SwapData(databyte_16bits);
databyte_16bits>>=8;
SwapData(databyte_16bits);
LCD_CS_SET; //拉高片选
}

void LCD_SendData_ToAddr(uint8_t LCD_RegAddr, uint8_t LCD_RegValue)
{
LCD_SendCMD(LCD_RegAddr);
LCD_SendData(LCD_RegValue);
}
至此,SPI的任务基本完成了,后面就是使用这些工具,向TFT发送各种初始化、设置命令(开抄,使其工作。

SPI读写SD卡

SPI读写SD卡在TFT显示图片