裸机驱动框架搭建: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的每一“位”传输拆成四个步骤:
- 设置MOSI电平(根据当前发送位是0还是1)
- 拉高SCK(上升沿,从机采样)
- 读取MISO(如果需要接收数据)
- 拉低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 用示波器校准延时
代码写完了,怎么知道延时准不准?用示波器看!
校准步骤:
- 写一个测试函数,让某个GPIO先拉高,然后调用
delay_us(10),再拉低 - 用示波器探头测这个GPIO的波形
- 测量高电平的宽度,看是不是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 本章知识体系
下面这张图把本章的核心逻辑串起来了:
4.6 调试阶段的几个实用技巧
最后分享几个我在调试SPI驱动时常用的方法:
| 场景 | 方法 | 说明 |
|---|---|---|
| 确认SCK频率 | 示波器测SCK引脚 | 看方波周期是否等于 2 × (延时时间 × 8) |
| 确认MOSI数据 | 示波器看MOSI波形 | 发一个已知字节(如0x55),看波形是否交替变化 |
| 确认CS时序 | 双通道看CS和SCK | CS拉低后SCK才开始,CS拉高前SCK已结束 |
| 确认DC电平 | 示波器看DC和SCK | 写命令时DC为低,写数据时DC为高 |
| 延时校准 | 测GPIO翻转宽度 | 拉高→延时→拉低,测高电平宽度 |
一个小技巧:调试的时候,先发一个固定的测试数据(比如0xAA),在示波器上确认每一位的时序都正确。然后再发实际数据。这样能快速定位问题是出在时序上还是数据上。