STM32硬件SPI驱动三线SPI-LCD:协议解析与DMA优化实践

发布时间:2026/7/30 12:17:43
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为低电平CPOL0在第一个时钟边沿上升沿采样数据CPHA0。这对应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): PA5SDA (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线SPIDMA传输的难点依然在于那个“标识位”。我们不能直接把图像数据的数组丢给DMA因为每个字节前都需要加上标识位‘1’。一个高效的策略是利用内存到内存的DMADMA2D如果芯片支持或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_t0x01F8和0x0100。这意味着一幅115200字节的原始图像我们需要一个230400字节的DMA发送缓冲区。这消耗了大量RAM。为了节省内存我们可以使用DMA的循环模式Circular Mode或双缓冲区模式Double Buffer Mode配合一个较小的缓冲区进行“流水线”式发送。但更常见的做法是如果SPI时钟足够快我们可以在DMA传输完成中断中快速填充下一块数据到缓冲区实现“乒乓操作”。4.2 实战代码DMA刷屏函数假设我们使用SPI1的TX DMAStream5, 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*21] (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); // 结束列 (2390xEF) // 设置行地址Y方向 LCD_WriteCommand(0x2B); LCD_WriteData(0x00); LCD_WriteData(0x00); // 起始行 LCD_WriteData(0x00); LCD_WriteData(0xEF); // 结束行 // 打开显示 LCD_WriteCommand(0x29); HAL_Delay(100); }关键参数解析与避坑颜色格式0x3A命令务必设置为与你的像素数据格式一致。RGB565对应0x55RGB666对应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的数据颜色会错乱。扫描方向错误调整0x36MADCTL命令的参数。尝试不同的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驱动是一个典型的嵌入式系统软硬件协同设计过程。它考验的是对通信协议底层的理解、对微控制器外设的熟练运用以及调试和解决问题的耐心。当你看到第一抹色彩正确地出现在屏幕上时那种成就感是纯粹的。希望这篇基于实战的拆解能帮你少走弯路更快地点亮你的屏幕世界。