裸机驱动框架搭建:GPIO模拟SPI、发送函数封装、延时函数校准

好,咱们进入第四章。前面几章我们把硬件连接、示波器探头都搞定了,现在要真正开始写代码了。

这一章的核心就三件事:用GPIO模拟SPI时序、把发送逻辑封装成函数、把延时函数校准到微秒级。说白了,就是让MCU学会“说话”,而且说得准。

本章核心目标:搭建一个可移植、可调试的裸机SPI驱动框架,为后续LCD驱动和示波器抓波打下基础。

4.1 为什么不用硬件SPI?

你可能会问:“芯片明明有硬件SPI,为什么还要用GPIO模拟?”

嗯,这个问题我当年也纠结过。后来在一个项目里,硬件SPI的引脚被其他外设占用了,又不想改PCB,只好用GPIO模拟。结果发现,模拟SPI有几个硬件SPI比不了的好处:

  • 引脚任意:随便哪几个GPIO都能当SCK、MOSI、MISO、CS
  • 时序可调:想快就快,想慢就慢,调试阶段特别方便
  • 移植性强:换MCU平台,改几行GPIO宏定义就行
  • 便于抓波:你可以手动控制每一拍,示波器上看得一清二楚

当然,缺点也有——速度上限低,CPU占用高。但对于驱动一块320×240的LCD来说,绰绰有余。

4.2 GPIO模拟SPI的底层实现

SPI的时序其实不复杂。以模式0为例(CPOL=0, CPHA=0):

  • 空闲时SCK为低电平
  • 数据在SCK上升沿采样
  • 数据在SCK下降沿变化

我习惯把SPI的每一“位”传输拆成四个步骤:

  1. 设置MOSI电平(根据当前发送位是0还是1)
  2. 拉高SCK(上升沿,从机采样)
  3. 读取MISO(如果需要接收数据)
  4. 拉低SCK(下降沿,准备下一位)

来看代码:

/* SPI引脚宏定义 */
#define SPI_SCK_PIN      GPIO_PIN_5
#define SPI_MOSI_PIN     GPIO_PIN_7
#define SPI_MISO_PIN     GPIO_PIN_6
#define SPI_CS_PIN       GPIO_PIN_4
#define SPI_GPIO_PORT    GPIOB

/* 引脚操作宏 */
#define SPI_SCK_H()      HAL_GPIO_WritePin(SPI_GPIO_PORT, SPI_SCK_PIN, GPIO_PIN_SET)
#define SPI_SCK_L()      HAL_GPIO_WritePin(SPI_GPIO_PORT, SPI_SCK_PIN, GPIO_PIN_RESET)
#define SPI_MOSI_H()     HAL_GPIO_WritePin(SPI_GPIO_PORT, SPI_MOSI_PIN, GPIO_PIN_SET)
#define SPI_MOSI_L()     HAL_GPIO_WritePin(SPI_GPIO_PORT, SPI_MOSI_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_MISO_READ()  HAL_GPIO_ReadPin(SPI_GPIO_PORT, SPI_MISO_PIN)

/* 发送一个字节(MSB first) */
void SPI_WriteByte(uint8_t data)
{
    for (uint8_t i = 0; i < 8; i++)
    {
        /* 先设置MOSI */
        if (data & 0x80)
            SPI_MOSI_H();
        else
            SPI_MOSI_L();

        /* 拉高SCK,从机在上升沿采样 */
        SPI_SCK_H();
        delay_us(1);  /* 延时校准后再说 */

        /* 拉低SCK,准备下一位 */
        SPI_SCK_L();
        delay_us(1);

        data <<= 1;  /* 移到下一位 */
    }
}

我的习惯:在调试阶段,我会在SCK_H和SCK_L之间故意加一个稍大的延时,比如5μs。这样示波器上能看到清晰的“台阶”,方便确认时序是否正确。等调通了再逐步减小延时。

4.3 发送函数封装:从字节到命令

LCD驱动通常需要两种操作:写命令和写数据。区别在于DC引脚的电平:

  • DC=0:当前传输的是命令
  • DC=1:当前传输的是数据

我一般这样封装:

/* DC引脚控制 */
#define LCD_DC_PIN       GPIO_PIN_3
#define LCD_DC_PORT      GPIOB
#define LCD_DC_CMD()     HAL_GPIO_WritePin(LCD_DC_PORT, LCD_DC_PIN, GPIO_PIN_RESET)
#define LCD_DC_DATA()    HAL_GPIO_WritePin(LCD_DC_PORT, LCD_DC_PIN, GPIO_PIN_SET)

/* 写命令 */
void LCD_WriteCmd(uint8_t cmd)
{
    LCD_DC_CMD();        /* DC拉低,表示命令 */
    SPI_CS_L();          /* 拉低片选 */
    SPI_WriteByte(cmd);  /* 发送命令字节 */
    SPI_CS_H();          /* 拉高片选 */
}

/* 写数据 */
void LCD_WriteData(uint8_t data)
{
    LCD_DC_DATA();       /* DC拉高,表示数据 */
    SPI_CS_L();          /* 拉低片选 */
    SPI_WriteByte(data); /* 发送数据字节 */
    SPI_CS_H();          /* 拉高片选 */
}

你看,这样封装之后,上层代码就变得很干净:

LCD_WriteCmd(0x11);   // 退出睡眠模式
LCD_WriteCmd(0x36);   // 设置扫描方向
LCD_WriteData(0x00);  // 参数

注意:有些LCD模块在片选拉高之后需要一小段“释放时间”,才能进行下一次传输。我遇到过一块屏,连续快速发命令会丢字节,后来在CS_H后面加了2μs延时才解决。这个坑,你调试的时候要留意。

4.4 延时函数校准:别让时序“跑偏”

延时函数是模拟SPI的命脉。延时不准,时序就乱,LCD显示就会花屏。

我见过很多人直接用HAL_Delay(1)来做微秒级延时——这其实是个大坑。HAL_Delay是基于SysTick的,精度只有1ms,根本没法用在SPI位传输上。

正确的做法是:用定时器或者空循环做微秒级延时,然后用示波器校准。

4.4.1 实现一个微秒级延时函数

/* 使用DWT(Data Watchpoint and Trace)实现高精度延时 */
/* 需要先使能DWT:CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; */
/* 然后使能CYCCNT:DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; */

void delay_us(uint32_t us)
{
    uint32_t start = DWT->CYCCNT;
    uint32_t ticks = us * (SystemCoreClock / 1000000);  /* 计算需要的时钟周期数 */
    while ((DWT->CYCCNT - start) < ticks);
}

这个方法的原理很简单:DWT的CYCCNT寄存器每个时钟周期加1,我们算出目标微秒对应的时钟周期数,然后死等。

4.4.2 用示波器校准延时

代码写完了,怎么知道延时准不准?用示波器看!

校准步骤:

  1. 写一个测试函数,让某个GPIO先拉高,然后调用delay_us(10),再拉低
  2. 用示波器探头测这个GPIO的波形
  3. 测量高电平的宽度,看是不是10μs
/* 校准测试函数 */
void delay_calibration_test(void)
{
    while (1)
    {
        TEST_PIN_H();       /* 拉高 */
        delay_us(10);       /* 延时10μs */
        TEST_PIN_L();       /* 拉低 */
        delay_us(10);       /* 延时10μs */
    }
}

示波器上应该看到20μs周期的方波,高电平10μs,低电平10μs。如果不对,调整SystemCoreClock的值或者循环次数。

我的经验:有一次我换了芯片型号,忘了改SystemCoreClock,结果延时偏了30%。示波器上一看,高电平只有7μs。调完之后,LCD立马不花屏了。所以每次换平台,第一件事就是校准延时。

4.5 本章知识体系

下面这张图把本章的核心逻辑串起来了:

裸机SPI驱动框架 裸机SPI驱动框架 GPIO引脚宏定义 SCK / MOSI / MISO / CS / DC 引脚操作宏封装 SCK_H/L / MOSI_H/L / CS_H/L 微秒级延时函数 DWT_CYCCNT / 示波器校准 SPI_WriteByte(uint8_t data) 循环8次:设置MOSI → SCK上升沿 → 读取MISO → SCK下降沿 LCD_WriteCmd(uint8_t cmd) DC=0 → CS拉低 → 发字节 → CS拉高 LCD_WriteData(uint8_t data) DC=1 → CS拉低 → 发字节 → CS拉高 LCD初始化 / 画点 / 刷屏等上层函数 每一层只依赖下一层,上层不关心底层GPIO怎么操作,便于移植和调试

4.6 调试阶段的几个实用技巧

最后分享几个我在调试SPI驱动时常用的方法:

场景 方法 说明
确认SCK频率 示波器测SCK引脚 看方波周期是否等于 2 × (延时时间 × 8)
确认MOSI数据 示波器看MOSI波形 发一个已知字节(如0x55),看波形是否交替变化
确认CS时序 双通道看CS和SCK CS拉低后SCK才开始,CS拉高前SCK已结束
确认DC电平 示波器看DC和SCK 写命令时DC为低,写数据时DC为高
延时校准 测GPIO翻转宽度 拉高→延时→拉低,测高电平宽度

一个小技巧:调试的时候,先发一个固定的测试数据(比如0xAA),在示波器上确认每一位的时序都正确。然后再发实际数据。这样能快速定位问题是出在时序上还是数据上。