STM32硬件SPI驱动三线SPI-LCD:协议解析与DMA优化实践
1. 从三线SPI-LCD的“非主流”说起
如果你玩过STM32驱动LCD,大概率用过8080并口或者标准的4线SPI。前者速度快但占引脚多,后者协议简单但也要4根线(SCK, MOSI, MISO, CS)。但最近在一些追求极致成本或特定封装的小尺寸LCD模块上,一种“3线SPI”的驱动方式开始流行起来。它只使用三根线:时钟线(SCK)、数据线(SDA)和片选线(CS),就完成了所有数据和命令的传输。乍一看,这似乎违背了SPI全双工的基本设定——没有独立的MISO线,数据怎么读?实际上,这类LCD模块在设计上就是只写的,我们只需要向它发送命令和数据,无需读取其状态(或者通过延时等待其内部操作完成)。这种“3线SPI”本质上是SPI协议的一个子集,或者说是一种“半双工”的变体,数据在SCK的同一个边沿进行发送,模块内部通过识别特定的命令/数据包头来区分传输内容。
那么,用STM32的硬件SPI来驱动它,是不是大材小用?恰恰相反,这是性能和代码简洁性的绝佳平衡点。用软件模拟IO口翻转(Bit-Banging)固然可以,但在高分辨率或高刷新率下,CPU会被大量占用在产生精确时序上。硬件SPI则把时序生成和移位输出的工作交给了DMA和SPI外设,CPU得以解放出来处理更复杂的图形逻辑。核心的挑战在于,标准的硬件SPI是4线全双工,如何让它适配3线半双工的单数据线模式?答案就在于对数据线(通常是MOSI)的灵活控制和对通信时序的精确理解。这篇文章,我就结合自己最近在STM32G0系列上驱动一块240x240 IPS屏的实际经历,拆解如何用硬件SPI高效、稳定地驱动3线SPI-LCD,并分享几个从数据手册里挖出来的关键配置和避坑点。
2. 三线SPI-LCD的通信协议深度拆解
在动手写代码之前,我们必须吃透这块屏的通信规则。不同于标准SPI设备,3线SPI-LCD的协议是“自定义”的,但万变不离其宗,核心是如何用一根数据线,分时复用传输命令(Command)和数据(Data)。
我手头这块屏的控制器是ST7789V(市面上ILI9341、ILI9488等也有3线模式),其3线SPI模式的数据手册里明确规定了帧格式。每一次传输都以一个8位或9位的“命令/数据标识位”开始。最常见的是9位模式:如果第一位(MSB)是0,则后续的8位被解释为一个命令字节(Command);如果第一位是1,则后续的8位被解释为一个数据字节(Data)。这意味着,我们需要在发送每个字节之前,先发送一个额外的标识位。
那么,用硬件SPI如何发送9位数据?STM32的SPI通常支持8位或16位数据帧。一个巧妙的做法是使用16位数据帧。我们把要发送的8位真实数据(命令或数据)放在低8位(Bit[7:0]),而将标识位放在最高位(比如Bit8)。例如,要发送命令0x2A,我们构造一个16位的数据:0x002A(标识位0,命令0x2A)。要发送数据0x00,则构造为0x0100(标识位1,数据0x00)。SPI外设会按照16位的长度,从高位(Bit15)到低位(Bit0)依次移出。这样,标识位最先被发出,屏幕控制器在接收到第一个时钟边沿的数据(即标识位)时,就能立刻判断后续8位的性质。
这里有一个关键细节:SPI的时钟极性和相位(CPOL/CPHA)。这必须严格按照屏幕数据手册的要求来设置。我的这块ST7789V在3线模式下,要求空闲时SCK为低电平(CPOL=0),在第一个时钟边沿(上升沿)采样数据(CPHA=0)。这对应SPI模式0。如果设置错误,屏幕将无法识别任何数据。在初始化SPI时,除了设置模式,还要注意数据顺序(MSB First)和波特率。初期调试建议将波特率设低一些(如1-2Mbps),待通信稳定后再逐步提高。
注意:有些屏幕可能使用8位帧+DCX引脚的模式,即用一根额外的DCX引脚来指示当前发送的是命令还是数据。这实质上是4线(SCK, SDA, CS, DCX),不属于本文讨论的纯3线范畴。确认你的屏幕是真正的“3线”,即只有SCK、SDA、CS三根信号线。
3. STM32硬件SPI的驱动层实现
理解了协议,我们就可以着手搭建驱动层了。驱动层的核心任务有两个:一是初始化SPI外设和GPIO,二是实现基本的发送命令和发送数据的函数。
3.1 硬件连接与初始化
假设我们使用STM32的SPI1,引脚分配如下:
- SCK (SPI1_SCK): PA5
- SDA (SPI1_MOSI): PA7 (注意,我们只使用MOSI引脚,MISO引脚PA6可以悬空或配置为其他功能)
- CS (Chip Select): PA4 (使用一个普通的GPIO口模拟片选)
在MX_SPI1_Init函数中,我们需要进行如下关键配置(以HAL库为例):
hspi1.Instance = SPI1; hspi1.Init.Mode = SPI_MODE_MASTER; // 主机模式 hspi1.Init.Direction = SPI_DIRECTION_2LINES; // 虽然只用一根线输出,但外设通常仍配置为双线模式,只关注发送。 hspi1.Init.DataSize = SPI_DATASIZE_16BIT; // 关键!使用16位数据帧 hspi1.Init.CLKPolarity = SPI_POLARITY_LOW; // CPOL = 0 hspi1.Init.CLKPhase = SPI_PHASE_1EDGE; // CPHA = 0 (第一个边沿采样) hspi1.Init.NSS = SPI_NSS_SOFT; // 软件控制NSS(片选),我们用自己的GPIO hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_8; // 初始低速,APB2时钟为64MHz时,SPI时钟为8MHz hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB; // 高位先发 hspi1.Init.TIMode = SPI_TIMODE_DISABLE; hspi1.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE; hspi1.Init.CRCPolynomial = 10; if (HAL_SPI_Init(&hspi1) != HAL_OK) { Error_Handler(); }GPIO的初始化需要将PA5、PA7配置为复用推挽输出(AF_PP),并将PA4配置为普通的推挽输出(GPIO_MODE_OUTPUT_PP),初始状态置高(不选中)。
3.2 核心发送函数的编写
这是驱动层的灵魂。我们需要编写两个函数:LCD_WriteCommand和LCD_WriteData。
// 发送一个命令 void LCD_WriteCommand(uint8_t cmd) { LCD_CS_LOW(); // 拉低片选,开始传输 uint16_t tx_data = (0 << 8) | cmd; // 标识位为0,命令放在低8位 HAL_SPI_Transmit(&hspi1, (uint8_t*)&tx_data, 1, HAL_MAX_DELAY); LCD_CS_HIGH(); // 拉高片选,结束传输 } // 发送一个数据 void LCD_WriteData(uint8_t dat) { LCD_CS_LOW(); uint16_t tx_data = (1 << 8) | dat; // 标识位为1,数据放在低8位 HAL_SPI_Transmit(&hspi1, (uint8_t*)&tx_data, 1, HAL_MAX_DELAY); LCD_CS_HIGH(); }看起来很简单,对吗?但这里隐藏着一个效率陷阱。HAL_SPI_Transmit是阻塞式的,每发送一个16位数据,都要经历函数调用、片选控制、等待发送完成的过程。如果我们要发送一个像素的颜色数据(通常是16位RGB565,即2个字节),就需要调用两次LCD_WriteData, overhead非常大。对于刷屏这种需要连续发送海量数据的操作,这是不可接受的。
因此,我们必须实现一个连续发送数据的函数。思路是:将多个数据打包,并预先在内存中构造好带有标识位的16位数据缓冲区,然后使用SPI的DMA或连续发送模式一次性送出。
// 发送多个数据(用于初始化配置或填充颜色) void LCD_WriteDataBuffer(uint8_t *pData, uint32_t len) { if(len == 0) return; LCD_CS_LOW(); // 动态分配或使用静态缓冲区。注意:此方法在len很大时可能效率不高,因为需要先构造缓冲区。 // 更优的方案是结合DMA,并利用SPI的16位模式直接发送原始数据,标识位通过硬件或软件方式在另一个GPIO上控制(但这需要额外的线)。 // 对于纯3线,我们采用软件构造缓冲区的方式。 uint16_t *tx_buffer = (uint16_t*)pvPortMalloc(len * sizeof(uint16_t)); // 假设使用FreeRTOS的内存管理 if(tx_buffer == NULL) return; for(uint32_t i = 0; i < len; i++) { tx_buffer[i] = (1 << 8) | pData[i]; // 为每个数据字节加上标识位 } HAL_SPI_Transmit(&hspi1, (uint8_t*)tx_buffer, len, HAL_MAX_DELAY); vPortFree(tx_buffer); LCD_CS_HIGH(); }这个函数解决了连续发送的问题,但引入了动态内存分配和内存拷贝,对于实时性要求高的刷屏操作依然不是最佳选择。终极优化方案是使用DMA,并精心设计数据结构来避免频繁的缓冲区构造。这引出了我们下一章要讨论的核心性能优化。
4. 性能跃升:DMA驱动与刷屏策略优化
当我们需要更新整个屏幕时,例如绘制一幅图像,数据量是巨大的(240x240x2 = 115200字节)。如果按照上述方式一个字节一个字节地发送,效率极低。硬件SPI搭配DMA正是为此而生。
4.1 DMA驱动的设计思路
使用DMA的目标是让SPI外设自动从内存中读取数据并发送,CPU在此期间可以处理其他任务。对于3线SPI,DMA传输的难点依然在于那个“标识位”。我们不能直接把图像数据的数组丢给DMA,因为每个字节前都需要加上标识位‘1’。
一个高效的策略是利用内存到内存的DMA(DMA2D,如果芯片支持)或CPU预先准备一个格式化的缓冲区。但对于刷屏这种重复性工作,我们可以采用一个“投机取巧”但非常有效的方法:将屏幕的GRAM(显存)窗口设置为一次写入多个数据后自动递增地址的模式。这样,我们只需要发送一次“写GRAM”命令,然后就可以通过DMA连续发送大量的像素数据(每个像素16位),而每个16位像素数据的高8位和低8位,我们都将其标识位设置为‘1’。
具体操作如下:
- 发送命令
0x2C(写GRAM命令)。 - 随后发送的所有数据都会被屏幕解释为连续的像素数据。
- 我们构造一个大的
uint16_t数组,数组中的每个元素都是一个完整的16位数据,其高8位是我们需要的像素数据的高字节(加上标识位1),低8位是像素数据的低字节(加上标识位1)。但等等,这不对。一个16位像素(如0xF800)需要拆成两个8位数据(0xF8和0x00)发送。因此,我们的DMA源缓冲区应该是一个uint16_t数组,每个uint16_t代表一个“带标识位的8位数据”。例如,像素0xF800对应两个uint16_t:0x01F8和0x0100。
这意味着,一幅115200字节的原始图像,我们需要一个230400字节的DMA发送缓冲区。这消耗了大量RAM。为了节省内存,我们可以使用DMA的循环模式(Circular Mode)或双缓冲区模式(Double Buffer Mode),配合一个较小的缓冲区进行“流水线”式发送。但更常见的做法是,如果SPI时钟足够快,我们可以在DMA传输完成中断中,快速填充下一块数据到缓冲区,实现“乒乓操作”。
4.2 实战代码:DMA刷屏函数
假设我们使用SPI1的TX DMA(Stream5, Channel3)。首先初始化DMA。
// 初始化DMA __HAL_RCC_DMA1_CLK_ENABLE(); hdma_spi1_tx.Instance = DMA1_Stream5; hdma_spi1_tx.Init.Request = DMA_REQUEST_SPI1_TX; hdma_spi1_tx.Init.Direction = DMA_MEMORY_TO_PERIPH; hdma_spi1_tx.Init.PeriphInc = DMA_PINC_DISABLE; hdma_spi1_tx.Init.MemInc = DMA_MINC_ENABLE; hdma_spi1_tx.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD; // 外设(SPI DR)是16位 hdma_spi1_tx.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD; // 内存也是16位对齐 hdma_spi1_tx.Init.Mode = DMA_NORMAL; // 普通模式,传输一次 hdma_spi1_tx.Init.Priority = DMA_PRIORITY_HIGH; if (HAL_DMA_Init(&hdma_spi1_tx) != HAL_OK) { Error_Handler(); } __HAL_LINKDMA(&hspi1, hdmatx, hdma_spi1_tx); HAL_NVIC_SetPriority(DMA1_Stream5_IRQn, 0, 0); HAL_NVIC_EnableIRQ(DMA1_Stream5_IRQn);然后实现一个刷矩形区域的函数。为了简化,我们假设一次刷整个屏幕,并使用一个全局的格式化缓冲区g_dma_buffer。
#define LCD_WIDTH 240 #define LCD_HEIGHT 240 #define DMA_BUFFER_SIZE (LCD_WIDTH * 2) // 一次传输一行的数据量(240像素 * 2字节/像素 * 2倍(因为每个字节变16位)?) // 注意:这里需要仔细计算。240像素 * 16位/像素 = 3840位 = 480字节。 // 但每个字节需要扩展为16位(带标识位),所以DMA需要传输的数据量是480 * 2 = 960字节。 // 我们的DMA缓冲区元素是uint16_t,所以元素个数是 960 / 2 = 480个。 // 因此,DMA_BUFFER_SIZE 应定义为 480。 static uint16_t g_dma_buffer[LCD_WIDTH * 2]; // 实际大小是 480 void LCD_FillColor_DMA(uint16_t color) { // 1. 设置写GRAM区域为全屏 LCD_SetAddress(0, 0, LCD_WIDTH-1, LCD_HEIGHT-1); // 2. 发送写GRAM命令 0x2C LCD_WriteCommand(0x2C); // 3. 准备DMA数据:将颜色值拆成高8位和低8位,并加上标识位。 uint8_t color_h = (color >> 8) & 0xFF; uint8_t color_l = color & 0xFF; for(int i = 0; i < LCD_WIDTH; i++) { // 每个像素需要两个16位数据 g_dma_buffer[i*2] = (1 << 8) | color_h; // 高字节 g_dma_buffer[i*2+1] = (1 << 8) | color_l; // 低字节 } // 4. 拉低片选,启动DMA传输。注意:这里需要传输整个屏幕的数据,但我们的缓冲区只存了一行。 // 我们需要循环传输,每次传一行,直到整个屏幕完成。这里为了示例,只演示一行的传输。 LCD_CS_LOW(); HAL_SPI_Transmit_DMA(&hspi1, (uint8_t*)g_dma_buffer, LCD_WIDTH * 2); // 传输数量是元素个数 // 在实际应用中,需要在DMA传输完成中断中,切换行数据并启动下一次传输,直到所有行发送完毕。 // 传输完成后,在中断回调函数中拉高CS。 } // DMA传输完成中断回调函数 void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if(hspi->Instance == SPI1.Instance) { LCD_CS_HIGH(); // 传输完成,拉高片选 // 可以在这里设置标志位,通知主循环一帧或一行数据发送完成。 } }这个例子展示了单行填充的DMA操作。完整的全屏填充或图像显示,需要管理多行数据的循环和缓冲区切换,逻辑会更复杂,但原理相通。核心思想是将CPU从繁重的字节搬运和标识位添加工作中解放出来,通过预先格式化的缓冲区和DMA实现高速、不占CPU的数据流。
5. 初始化序列与关键参数配置
屏幕在上电后需要一段初始化序列(Initialization Sequence)才能正常工作。这个序列由一系列特定的命令和参数组成,用于设置屏幕的扫描方向、颜色格式、伽马校正、电源控制等。初始化序列通常由屏幕厂商提供,可以在数据手册或配套的示例代码中找到。
对于ST7789V的3线SPI模式,初始化序列可能如下(命令和参数值需以实际数据手册为准):
void LCD_Init(void) { // 硬件复位(如果RESET引脚连接了) LCD_RST_LOW(); HAL_Delay(100); LCD_RST_HIGH(); HAL_Delay(120); // 等待复位完成 // 软件复位 LCD_WriteCommand(0x01); HAL_Delay(150); // 退出睡眠模式 LCD_WriteCommand(0x11); HAL_Delay(255); // 颜色接口格式设置:16位/pixel (RGB565) LCD_WriteCommand(0x3A); LCD_WriteData(0x55); // 0x55 代表16位,0x66代表18位 // 内存访问控制(MADCTL):设置扫描方向、颜色顺序等 LCD_WriteCommand(0x36); // 参数:MY MX MV RGB MH - 具体位定义参考数据手册 // 例如 0x00 为正常方向,0xC0 为旋转180度 LCD_WriteData(0x00); // 设置列地址(X方向) LCD_WriteCommand(0x2A); LCD_WriteData(0x00); LCD_WriteData(0x00); // 起始列高8位,低8位 LCD_WriteData(0x00); LCD_WriteData(0xEF); // 结束列 (239=0xEF) // 设置行地址(Y方向) LCD_WriteCommand(0x2B); LCD_WriteData(0x00); LCD_WriteData(0x00); // 起始行 LCD_WriteData(0x00); LCD_WriteData(0xEF); // 结束行 // 打开显示 LCD_WriteCommand(0x29); HAL_Delay(100); }关键参数解析与避坑:
- 颜色格式(0x3A命令):务必设置为与你的像素数据格式一致。RGB565对应
0x55,RGB666对应0x66。设置错误会导致颜色完全错乱。 - 内存访问控制(0x36命令):这是最容易出问题的地方。它控制着屏幕的显示方向(横屏/竖屏)、RGB子像素顺序(BGR还是RGB)、以及GRAM的刷新顺序。如果你发现图像是镜像的、颜色通道反了(红蓝对调),或者刷新方向不对,首先检查这个寄存器的值。需要根据你的屏幕物理安装方向和控制器特性来调整。
- 窗口设置(0x2A, 0x2B命令):在每次填充颜色或绘制图像前,通常需要先设置窗口,告诉屏幕接下来要写入的GRAM区域。设置不正确会导致图像显示位置偏移或只有部分区域被更新。
- 延时(Delay):初始化序列中的延时至关重要。一些命令(如退出睡眠、软件复位)执行后,屏幕内部控制需要一定时间稳定。延时不足可能导致后续命令被忽略,屏幕无法点亮。数据手册会给出最小延时要求,适当增加一些余量是稳妥的做法。
6. 调试技巧与常见问题排查
驱动一块新的LCD屏幕,很少能一次成功。以下是我总结的排查流程和常见问题:
问题一:屏幕一片空白(背光亮但无显示)
- 检查电源和背光:确认VCC、GND连接正确,背光控制引脚(如果有)已使能。
- 检查复位时序:确保硬件复位或软件复位信号满足时序要求(低电平保持时间足够)。
- 检查SPI时钟和模式:用逻辑分析仪或示波器抓取SCK和SDA波形。确认SCK频率是否在屏幕支持范围内(初期建议低于5MHz)。确认CPOL和CPHA设置是否正确。确认数据是在正确的时钟边沿变化和采样。
- 检查数据内容:确认发送的初始化序列完全正确,特别是关键的命令和参数。可以尝试只发送最简单的序列(如复位、退出睡眠、打开显示),看屏幕是否有反应(比如出现全白或全黑)。
- 检查片选CS:确保在数据传输期间CS为低电平,传输间隔为高电平。有些屏幕对CS的下降沿和上升沿非常敏感。
问题二:屏幕有显示但花屏、错位或颜色异常
- 颜色格式错误:检查
0x3A命令的参数。如果是RGB565但发送了RGB666的数据,颜色会错乱。 - 扫描方向错误:调整
0x36(MADCTL)命令的参数。尝试不同的MY、MX、MV组合,观察图像方向变化。 - 数据位顺序错误:确认SPI设置为MSB First。有些屏幕可能要求LSB First,但这不常见。
- 窗口设置错误:在绘制前,确认设置的列地址和行地址范围与实际要绘制的区域匹配。如果设置了一个很小的窗口,却发送了大量数据,多余的数据会被丢弃或导致未定义行为。
- 像素数据格式错误:确认你发送的像素数据格式。RGB565是16位,通常排列为
R[4:0] G[5:0] B[4:0]。如果你从图像数组直接取数据,要确保数组的排列顺序(可能是RGB或BGR)与屏幕期望的一致。
问题三:使用DMA时图像撕裂或不完整
- DMA缓冲区溢出或传输未完成:确保DMA缓冲区足够大,且DMA传输完成中断被正确触发。在启动下一次DMA传输前,必须等待上一次传输完成(检查标志位或使用回调函数)。
- SPI波特率过高:过高的SPI速度可能导致屏幕控制器来不及处理数据,尤其是在长线连接或有干扰的情况下。尝试降低波特率。
- 内存对齐问题:如果DMA源缓冲区地址或长度不符合对齐要求(例如要求字对齐但给了非对齐地址),可能导致传输错误。确保缓冲区地址和长度符合DMA数据宽度(半字、字)的对齐要求。
- CPU与DMA访问冲突:如果DMA正在从缓冲区读取数据,同时CPU也在写入缓冲区,会导致数据不一致。使用双缓冲区或确保在DMA传输期间CPU不修改源数据区。
调试利器:逻辑分析仪一个几十块钱的逻辑分析仪(配合PulseView或Saleae Logic软件)是调试SPI通信的必备工具。它可以直观地显示SCK、SDA、CS线上的波形,并解析出SPI数据。你可以清晰地看到发送的每一个16位数据帧,验证标识位、命令和数据字节是否正确。这是定位通信问题最快最直接的方法。
7. 进阶优化与扩展思考
当基础驱动稳定后,我们可以考虑进一步的优化和功能扩展。
1. 提升刷屏帧率:
- 提高SPI时钟:在屏幕允许的范围内,尽可能提高SPI的波特率。注意STM32的APB总线时钟和SPI分频器的限制。
- 优化DMA传输:使用DMA双缓冲区或循环模式,实现传输和数据处理的重叠(ping-pong buffer),减少等待时间。
- 减少通信开销:对于连续的区域填充,尽量使用“设置窗口+连续写GRAM”的模式,避免频繁发送设置命令。对于局部更新,只更新变化的区域。
- 使用硬件加速:如果STM32型号支持DMA2D(图形加速器),可以先用DMA2D在内存中完成图形合成(如图层混合、颜色格式转换),再将最终帧缓冲区通过SPI DMA发送出去,能极大减轻CPU负担。
2. 实现图形库集成:有了稳定的底层LCD_WriteCommand、LCD_WriteData、LCD_FillColor、LCD_DrawPixel等函数,就可以轻松移植轻量级的图形库,如u8g2、LVGL、emWin等。你需要为这些库实现其要求的“显示驱动回调函数”,这些函数内部调用你的底层LCD驱动函数。这能快速实现文本显示、控件、动画等高级功能。
3. 省电策略:对于电池供电设备,LCD是耗电大户。合理利用屏幕的睡眠(Sleep)、待机(Standby)和部分显示(Partial Display)命令,可以在不需要全屏刷新时降低功耗。例如,在只显示静态时间时,可以让屏幕进入部分显示模式,只刷新时间区域。
4. 应对干扰与长线驱动:如果SPI线缆较长(超过10cm),信号完整性可能变差。可以采取以下措施:
- 在SCK和SDA线上串联一个小电阻(如22-100欧姆),减少振铃。
- 在接收端(屏幕端)的SCK和SDA对地并联一个几十皮法的小电容,滤除高频噪声。
- 降低SPI通信速率。
- 使用屏蔽线缆或双绞线。
驱动3线SPI-LCD,从理解协议到实现稳定高效的DMA驱动,是一个典型的嵌入式系统软硬件协同设计过程。它考验的是对通信协议底层的理解、对微控制器外设的熟练运用,以及调试和解决问题的耐心。当你看到第一抹色彩正确地出现在屏幕上时,那种成就感是纯粹的。希望这篇基于实战的拆解,能帮你少走弯路,更快地点亮你的屏幕世界。
