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的特点是:空闲低电平,上升沿采样。

先画个时序图,帮大家理解:

SCLK MOSI ↑采样 ↑采样 ↑采样 ↑采样 ↑采样 ↑采样 ↑采样 ↑采样 D7 D6 D5 D4 D3 D2 D1 D0 SPI模式0时序图(CPOL=0, CPHA=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();
}

这个函数的核心逻辑就三步:

  1. 拉低时钟——告诉从设备「我要发数据了」
  2. 设置数据线——把当前位放到MOSI上
  3. 拉高时钟——上升沿,从设备采样

重复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驱动的基石。