3. GPIO模拟SPI时序:时钟极性(CPOL)与相位(CPHA)配置、数据收发函数实现
好,咱们进入第三章。这一章要聊的是SPI时序的软件模拟。说实话,很多初学者一看到时序图就头大,觉得这东西玄乎。其实没那么复杂——你把它想成两个人约定好怎么说话,就通了。
我刚开始做LCD驱动那会儿,也踩过不少坑。有一次屏幕死活不亮,查了两天,最后发现是时钟相位配反了。嗯,从那以后我对CPOL和CPHA就特别敏感。
3.1 SPI的四种模式:CPOL和CPHA到底在说啥?
SPI通信,说白了就是主设备发时钟,从设备跟着时钟走。但这里有个关键问题:时钟空闲时是高还是低?数据是在时钟上升沿采样还是下降沿采样?
这两个参数,就是CPOL和CPHA。
- CPOL(Clock Polarity):时钟极性。决定SCLK空闲时的电平。
- CPOL = 0:空闲时低电平
- CPOL = 1:空闲时高电平
- CPHA(Clock Phase):时钟相位。决定数据在哪个边沿采样。
- CPHA = 0:第一个边沿采样(上升沿或下降沿,取决于CPOL)
- CPHA = 1:第二个边沿采样
组合起来就是四种模式。我习惯用一张表来记:
| 模式 | CPOL | CPHA | 空闲时钟电平 | 数据采样边沿 |
|---|---|---|---|---|
| 模式0 | 0 | 0 | 低电平 | 上升沿(第一个边沿) |
| 模式1 | 0 | 1 | 低电平 | 下降沿(第二个边沿) |
| 模式2 | 1 | 0 | 高电平 | 下降沿(第一个边沿) |
| 模式3 | 1 | 1 | 高电平 | 上升沿(第二个边沿) |
你想想看,LCD驱动芯片最常用的是模式0和模式3。为什么?因为这两种模式下,数据都是在上升沿采样,下降沿变化。很多MCU的硬件SPI也默认支持这两种模式。
核心要点:CPOL决定空闲电平,CPHA决定采样时机。两者配合,就定死了通信协议。
3.2 用GPIO模拟SPI:从零开始搭时序
硬件SPI虽然快,但有时候引脚不够用,或者需要接多个从设备。这时候就得用GPIO模拟。我个人习惯把模拟SPI封装成几个基础函数,这样上层代码写起来就清爽多了。
先看一个典型的GPIO模拟SPI的初始化:
/* SPI引脚宏定义 */
#define SPI_SCL_PIN GPIO_PIN_5 /* PB5 - 时钟线 */
#define SPI_SDA_PIN GPIO_PIN_7 /* PB7 - 数据线 */
#define SPI_CS_PIN GPIO_PIN_6 /* PB6 - 片选线 */
#define SPI_GPIO_PORT GPIOB
/* 引脚操作宏 */
#define SPI_SCL_H() HAL_GPIO_WritePin(SPI_GPIO_PORT, SPI_SCL_PIN, GPIO_PIN_SET)
#define SPI_SCL_L() HAL_GPIO_WritePin(SPI_GPIO_PORT, SPI_SCL_PIN, GPIO_PIN_RESET)
#define SPI_SDA_H() HAL_GPIO_WritePin(SPI_GPIO_PORT, SPI_SDA_PIN, GPIO_PIN_SET)
#define SPI_SDA_L() HAL_GPIO_WritePin(SPI_GPIO_PORT, SPI_SDA_PIN, GPIO_PIN_RESET)
#define SPI_CS_H() HAL_GPIO_WritePin(SPI_GPIO_PORT, SPI_CS_PIN, GPIO_PIN_SET)
#define SPI_CS_L() HAL_GPIO_WritePin(SPI_GPIO_PORT, SPI_CS_PIN, GPIO_PIN_RESET)
#define SPI_SDA_READ() HAL_GPIO_ReadPin(SPI_GPIO_PORT, SPI_SDA_PIN)
这里我用宏定义把引脚操作封装起来。为什么用宏?因为函数调用有开销,模拟SPI对时序要求高,能省则省。
小技巧:如果你的MCU速度够快,可以用函数。但像STM32F103这种72MHz的芯片,用宏能省下几个时钟周期,对高频通信有帮助。
3.3 数据收发函数:模式0的实现
咱们以最常用的模式0为例。模式0的特点是:空闲低电平,上升沿采样。
先画个时序图,帮大家理解:
看到没?时钟空闲是低电平,每个上升沿采样一位数据。这就是模式0的精髓。
接下来是发送一个字节的函数:
/**
* @brief SPI发送一个字节(模式0)
* @param data: 要发送的8位数据
* @retval 无
*/
void SPI_WriteByte(uint8_t data)
{
uint8_t i;
for(i = 0; i < 8; i++)
{
/* 先拉低时钟,准备发送数据 */
SPI_SCL_L();
/* 根据当前位设置数据线 */
if(data & 0x80)
SPI_SDA_H();
else
SPI_SDA_L();
/* 数据准备好后,拉高时钟,上升沿采样 */
SPI_SCL_H();
/* 延时一小段时间,保证从设备能正确采样 */
delay_us(1);
/* 左移,准备下一位 */
data <<= 1;
}
/* 发送完毕,拉低时钟 */
SPI_SCL_L();
}
这个函数的核心逻辑就三步:
- 拉低时钟——告诉从设备「我要发数据了」
- 设置数据线——把当前位放到MOSI上
- 拉高时钟——上升沿,从设备采样
重复8次,一个字节就发出去了。
注意:这里的delay_us(1)很关键。我见过有人不加延时,结果高速MCU发数据太快,从设备跟不上。具体延时多久,要看你的从设备数据手册。一般1-5微秒就够用。
3.4 读取数据:双向通信的实现
有些LCD支持读操作,比如读寄存器状态、读显存数据。这时候就需要双向通信。发送命令用MOSI,读取数据用MISO。
读取一个字节的函数:
/**
* @brief SPI读取一个字节(模式0)
* @param 无
* @retval 读取到的8位数据
*/
uint8_t SPI_ReadByte(void)
{
uint8_t i;
uint8_t data = 0;
for(i = 0; i < 8; i++)
{
/* 拉低时钟,准备接收 */
SPI_SCL_L();
/* 延时,等待从设备准备好数据 */
delay_us(1);
/* 拉高时钟,上升沿采样 */
SPI_SCL_H();
/* 读取MISO引脚电平 */
data <<= 1;
if(SPI_SDA_READ())
data |= 0x01;
/* 延时,等待下一个周期 */
delay_us(1);
}
/* 读取完毕,拉低时钟 */
SPI_SCL_L();
return data;
}
这里有个细节:先拉低时钟,再拉高时钟,在上升沿读取数据。顺序不能乱。我刚开始写的时候,把读取放在了拉低时钟之后,结果读到的数据全是错的。
3.5 组合操作:命令+数据的完整流程
实际驱动LCD时,通常先发命令,再发数据。比如初始化时:
/* 发送命令 */
SPI_CS_L(); // 拉低片选,选中设备
SPI_WriteByte(0x36); // 发送命令:设置扫描方向
SPI_CS_H(); // 拉高片选,结束命令
/* 发送数据 */
SPI_CS_L(); // 再次拉低片选
SPI_WriteByte(0x48); // 发送数据:具体配置值
SPI_CS_H(); // 拉高片选,结束传输
有些LCD支持连续写模式,就是片选一直拉低,连续发多个字节。这样可以提高效率:
/* 连续写多个字节 */
SPI_CS_L();
SPI_WriteByte(0x2C); // 命令:写显存
for(uint16_t i = 0; i < 320*240; i++)
{
SPI_WriteByte(0xFF); // 连续写入像素数据
}
SPI_CS_H();
这种连续写的方式,刷屏速度能快不少。我做过测试,用GPIO模拟SPI,在72MHz的STM32上,刷一个320x240的屏幕,大概需要30ms。如果用硬件SPI,能压到10ms以内。但GPIO模拟的好处是灵活,引脚随便选。
3.6 避坑指南:我踩过的那些坑
做模拟SPI这几年,我总结了几条经验:
- 时钟极性别搞反:我曾经把模式0和模式3搞混,屏幕显示全是乱的。后来用示波器一看,时钟波形反了。从那以后,我每次写驱动前都会先看数据手册的时序图。
- 延时别太短也别太长:太短从设备跟不上,太长影响刷屏速度。我一般先用1微秒,如果不行再调。
- 片选操作要规范:每次传输前拉低片选,传输完拉高。有些芯片对片选时序很敏感,拉低后要等一会儿再发时钟。
- 数据线方向要确认:读操作时,MOSI要切换成输入模式。我习惯在读写函数里动态切换GPIO方向。
个人习惯:我会在代码里加一个调试模式,用宏控制是否打印SPI波形信息。这样调试时能看到每个字节的发送情况,定位问题快很多。
好了,这一章的内容就到这儿。GPIO模拟SPI的核心就是理解CPOL和CPHA,然后按照时序图一步步操作。代码写多了,你会发现这东西其实挺机械的——就是拉高拉低、读读写写。但正是这些基础操作,构成了LCD驱动的基石。