4. 驱动适配核心概念
SPI接口的显示屏,在嵌入式GUI里太常见了。小到智能手表,大到工业控制面板,很多都用它。说实话,我最早接触SPI屏的时候,觉得不就是发几个字节嘛,能有多难?结果第一次点亮屏幕,花了我整整两天——不是协议不对,是初始化序列里一个延时参数没给够。
好,咱们今天就把这块彻底讲透。从SPI协议最核心的点开始,到写命令/写数据的封装,再到DMA传输优化,一条线串下来。
4.1 SPI协议快速回顾
SPI是同步、全双工的串行通信。主设备产生时钟,同时收发数据。对于显示屏驱动,我们通常只关心主设备发往从设备的数据方向,也就是MOSI线上的内容。
SPI有四种模式,由时钟极性(CPOL)和时钟相位(CPHA)决定。我见过不少新手在这上面栽跟头——屏不亮,查了半天,结果是模式配错了。
| 模式 | CPOL | CPHA | 数据采样边沿 | 常见屏类型 |
|---|---|---|---|---|
| 模式0 | 0 | 0 | 上升沿采样 | ILI9341, ST7789 |
| 模式1 | 0 | 1 | 下降沿采样 | 较少见 |
| 模式2 | 1 | 0 | 上升沿采样 | 部分OLED |
| 模式3 | 1 | 1 | 下降沿采样 | 部分OLED |
我个人习惯,拿到一块新屏,第一件事就是看数据手册里的时序图。手册里通常会画出来,照着配就行。别猜,猜容易出事。
4.2 初始化序列——点亮屏幕的第一步
初始化序列,说白了就是给屏发一串配置命令,让它进入正常工作状态。每个屏的初始化序列都不一样,但套路是通用的。
典型的初始化流程是这样的:
- 硬件复位:拉低RST引脚至少10ms,再拉高。确保屏内部状态机复位。
- 退出睡眠模式:发SLPOUT命令。之后要等120ms以上,让屏内部DC-DC稳定。
- 设置像素格式:比如COLMOD命令,设置16位色或18位色。
- 设置显示方向:MADCTL命令,控制横竖屏、BGR顺序等。
- 设置显示窗口:CASET和RASET,定义可写入的像素区域。
- 开启显示:DISPON命令。
嗯,这里要注意。不同厂商的屏,初始化序列长度差别很大。有的屏只需要十几条命令,有的需要四五十条。我曾经遇到过一块屏,手册上给了60多条初始化命令,少发一条,颜色就偏得离谱。
4.3 写命令/写数据函数封装
SPI屏驱动里,最核心的两个函数就是写命令和写数据。它们的区别在于DC引脚的电平——命令时DC拉低,数据时DC拉高。
我习惯这样封装:
// 底层SPI发送一个字节
static void spi_write_byte(uint8_t data)
{
// 等待发送缓冲区空
while (!(SPI->SR & SPI_SR_TXE));
// 发送数据
SPI->DR = data;
// 等待接收完成(全双工必须等)
while (!(SPI->SR & SPI_SR_RXNE));
// 读DR清标志
(void)SPI->DR;
}
// 写命令
void lcd_write_cmd(uint8_t cmd)
{
LCD_DC_LOW(); // DC拉低,表示命令
LCD_CS_LOW(); // 片选拉低
spi_write_byte(cmd);
LCD_CS_HIGH(); // 片选拉高
LCD_DC_HIGH(); // DC恢复高电平
}
// 写数据
void lcd_write_data(uint8_t data)
{
LCD_DC_HIGH(); // DC拉高,表示数据
LCD_CS_LOW();
spi_write_byte(data);
LCD_CS_HIGH();
}
// 批量写数据(用于填充显存)
void lcd_write_data_bulk(uint8_t *buf, uint32_t len)
{
LCD_DC_HIGH();
LCD_CS_LOW();
for (uint32_t i = 0; i < len; i++) {
spi_write_byte(buf[i]);
}
LCD_CS_HIGH();
}
你看,逻辑很简单。但实际项目中,批量写数据这个函数往往是性能瓶颈。因为每发一个字节都要轮询等待,CPU被死死卡住。这时候就需要DMA出场了。
4.4 DMA传输优化——让CPU喘口气
为什么需要DMA?你想想看,一块320x240的16位色屏,一帧数据是320×240×2 = 153600字节。如果SPI时钟20MHz,纯轮询发送一帧大约需要7.68ms。这7.68ms里CPU什么都干不了,只能在那等。
用DMA就不一样了。CPU把数据地址和长度配好,启动DMA,然后就可以去干别的事。DMA传输完成后触发中断,通知CPU。
DMA传输的典型流程:
- 配置DMA通道:源地址(内存)、目的地址(SPI数据寄存器)、传输长度。
- 设置SPI为DMA请求模式:使能SPI的TX DMA请求。
- 启动DMA传输。
- 等待DMA传输完成中断。
- 在中断里关闭DMA,处理后续。
代码实现大致如下:
// 使用DMA发送数据(非阻塞)
void lcd_write_data_dma(uint8_t *buf, uint32_t len)
{
// 等待上一次DMA传输完成
while (dma_busy);
LCD_DC_HIGH();
LCD_CS_LOW();
// 配置DMA
DMA->CCR = 0; // 复位配置
DMA->CPAR = (uint32_t)&(SPI->DR); // 外设地址
DMA->CMAR = (uint32_t)buf; // 内存地址
DMA->CNDTR = len; // 传输长度
DMA->CCR = DMA_CCR_MINC | // 内存地址递增
DMA_CCR_DIR | // 内存到外设
DMA_CCR_TCIE; // 传输完成中断使能
dma_busy = 1;
DMA->CCR |= DMA_CCR_EN; // 使能DMA
// 使能SPI的TX DMA请求
SPI->CR2 |= SPI_CR2_TXDMAEN;
}
// DMA传输完成中断
void DMA_IRQHandler(void)
{
if (DMA->ISR & DMA_ISR_TCIF) {
DMA->IFCR = DMA_IFCR_CTCIF; // 清除标志
DMA->CCR &= ~DMA_CCR_EN; // 关闭DMA
SPI->CR2 &= ~SPI_CR2_TXDMAEN; // 关闭SPI DMA请求
LCD_CS_HIGH();
dma_busy = 0;
}
}
4.5 整体驱动架构
把上面这些串起来,一个完整的SPI屏驱动架构就出来了。我画了张图,帮你理清关系。
这个架构的好处是分层清晰。如果以后要换MCU,只需要改传输层和硬件层,上面的接口层基本不用动。我在多个项目里复用这套结构,移植效率很高。
4.6 性能对比:轮询 vs DMA
最后,咱们用数据说话。我实际测过一块320x240的屏,SPI时钟20MHz,刷一帧全屏数据:
| 传输方式 | CPU占用率 | 传输时间 | 适用场景 |
|---|---|---|---|
| 轮询发送 | 100%(全程等待) | 约7.7ms | 简单测试、低帧率应用 |
| DMA发送 | 约5%(仅启动和中断) | 约7.7ms | 高帧率、多任务系统 |
| DMA+双缓冲 | 约3%(几乎不占用) | 约7.7ms(无额外开销) | 动画、视频播放 |
你看,传输时间几乎一样,但CPU占用率天差地别。DMA方式下,CPU可以在传输期间处理其他任务——比如触摸扫描、通信协议、GUI计算。这就是优化的价值。