
1. 项目概述为什么选择硬件SPI驱动3线LCD在嵌入式显示项目中驱动一块SPI接口的LCD屏是再常见不过的需求。很多开发者尤其是刚接触STM32的朋友可能会图省事直接用GPIO模拟SPI时序也就是我们常说的“软件SPI”。这种方法在初期验证时确实方便引脚可以随意定义代码也直观。但一旦项目对刷新速度、CPU占用率或者系统实时性有要求软件SPI的短板就暴露无遗了——它太“吃”CPU了主控需要不断地翻转IO口来模拟时钟和数据整个通信期间CPU几乎被独占干不了别的活。这时候硬件SPI的优势就体现出来了。STM32内部集成了专用的SPI外设它就像一个“通信协处理器”你只需要配置好参数、填好要发送的数据它就能在后台自动、高速地完成整个通信过程期间CPU可以被解放出来去处理其他任务或者直接进入低功耗模式。对于驱动LCD这种需要频繁刷新、传输数据量不小的场景硬件SPI带来的性能提升和系统优化是决定性的。而我们这次要聊的“3线SPI-LCD”是一种在成本敏感型产品中非常流行的方案。它相比标准的4线SPISCK, MOSI, MISO, CS或者更复杂的8080/6800并行接口节省了一根数据线。通常这省掉的一根是MISO主设备输入从设备输出因为对于大多数只显示不触摸的LCD屏我们只需要向屏写命令和数据不需要从屏读回状态。所以3线SPI一般就只包含时钟线SCK、数据线SDA或MOSI、以及片选线CS。有些屏甚至通过将命令/数据选择线DC/RS的功能整合到数据协议里进一步省掉DC线但核心的通信骨架还是依靠SCK和SDA这两根线。用硬件SPI来驱动这种屏就是要充分发挥硬件自动化的优势在有限的IO资源下实现稳定、高效的刷屏。2. 核心思路与硬件连接设计2.1 3线SPI模式解析它和标准SPI有何不同首先要明确一点从STM32的SPI外设角度看它并不直接区分“4线”或“3线”。SPI外设硬件上固定有SCK、MOSI、MISO、NSS这四根线。我们所说的“3线SPI驱动LCD”本质上是在硬件SPI的基础上进行一种“变通”使用。核心变通点在于MISO线的处理。对于纯输出的LCD屏我们根本不需要接收数据因此MISO引脚可以完全空置不连接。STM32的SPI外设在仅发送模式下MISO引脚的状态是无关紧要的。所以硬件连接上我们只需要连接三根线SCK (Serial Clock): SPI时钟线由STM32主设备产生。MOSI (Master Output, Slave Input): 主设备输出数据线也就是我们的SDA线STM32通过它向LCD发送命令和数据。CS (Chip Select) / NSS: 片选线。虽然STM32 SPI硬件支持NSS硬件管理但在驱动外设时我们更常用一个普通的GPIO来模拟CS功能因为控制更灵活。所以这根线通常也由软件控制的GPIO充当。那么LCD屏常见的另一根重要控制线——DCData/Command 或称RS线去哪了这就是3线SPI屏的另一个关键。有些3线屏确实还有独立的DC线那它就需要占用一个额外的GPIO。但更多为了极致省IO的方案采用了“9位数据帧”协议。即在传输一字节8位数据前先发送一个额外的位来表示当前字节是命令还是数据。这个“第9位”通常是通过在8位数据前加一个时钟周期来实现的。然而STM32的标准SPI硬件通常只支持8位或16位数据帧。因此要实现这种“9位”协议要么用软件模拟最开始的那一位失去了部分硬件加速意义要么选择另一种更常见的3线协议利用数据线在传输过程中的特定时序来区分命令/数据例如在片选有效后第一个字节固定为命令后续字节在无特殊时序下均为数据。具体需要严格遵循你所使用的LCD控制器如ILI9341, ST7735等的3线SPI模式时序图。所以我们的硬件连接方案通常是STM32 SPIx_SCK - LCD SCKSTM32 SPIx_MOSI - LCD SDA (或SDI)STM32 GPIOx - LCD CS (软件控制)STM32 GPIOy - LCD DC (如果屏有独立DC线)LCD RESET引脚连接到一个GPIO进行硬件复位控制可选但推荐。背光控制引脚连接到另一个GPIO或PWM输出。注意务必查阅你手中LCD模组的具体数据手册。确认它支持的3线SPI协议具体格式。是“带独立DC线的3线SPI”还是“通过协议内嵌命令/数据标识的3线SPI”。这决定了你代码中控制DC线的方式或者是否需要特殊的字节发送顺序。2.2 STM32硬件SPI外设选型与配置要点STM32系列通常有多个SPI外设SPI1, SPI2, SPI3...。选择哪一个主要考虑三点引脚分布查看芯片数据手册的引脚复用功能表选择那些与你的LCD连接方便、且不与板上其他关键功能冲突的SPI外设引脚。时钟频率不同SPI外设挂载在不同的总线APB1, APB2上其时钟源频率不同。APB2上的SPI1通常可以获得更高的时钟频率从而得到更快的刷屏速度。例如在STM32F1系列中APB2总线频率最高72MHz而APB1最高36MHz。DMA支持如果你计划使用DMA来进一步解放CPU实现“后台刷图”需要确认该SPI外设支持DMA请求并规划好对应的DMA通道。配置硬件SPI时以下几个参数必须与LCD控制器要求严格匹配时钟极性(CPOL)与时钟相位(CPHA)即SPI的四种模式。这决定了时钟空闲时的电平CPOL和数据在时钟的哪个边沿被采样CPHA。必须按照LCD数据手册的要求设置。常用模式是CPOL0, CPHA0模式0或CPOL1, CPHA1模式3。数据帧格式选择8位数据帧SPI_DATASIZE_8BIT。虽然有些16位色数据发送可以拆成两个8位但帧格式本身设为8位最通用。比特序即MSB最高位先行还是LSB最低位先行。绝大多数SPI设备包括LCD控制器都是MSB先行。NSS片选管理设置为“软件管理”SPI_NSS_SOFT。这样我们可以自由控制一个GPIO作为CS片选信号。波特率预分频器这是决定SCK时钟频率的关键。SCK频率 SPI外设输入时钟 / 预分频系数。初始调试时建议选择一个较低的频率如分频至1MHz以下确保通信稳定。后续再逐步提高测试屏能稳定工作的最高速率。过高的速率可能导致显示错乱或通信失败。工作模式配置为全双工主模式即使不用MISO或者只发送主模式。有些库函数可能只支持全双工配置但只读发送缓冲区。3. 软件驱动层设计与实现3.1 底层硬件抽象层HAL函数封装直接调用HAL库的HAL_SPI_Transmit()函数在循环中发送每一个像素数据是可行的但效率不高因为每个字节传输都伴随着函数调用开销和轮询等待。更好的做法是进行一层轻量封装并积极利用DMA。首先我们封装最基本的写命令和写数据函数。这里以有独立DC引脚为例// 假设已定义好句柄SPI_HandleTypeDef hspi1; // 以及GPIO引脚LCD_CS_Pin, LCD_DC_Pin, LCD_RST_Pin void LCD_WriteCommand(uint8_t cmd) { LCD_CS_LOW(); // 拉低片选选中设备 LCD_DC_LOW(); // DC置低表示发送的是命令 HAL_SPI_Transmit(hspi1, cmd, 1, HAL_MAX_DELAY); // 对于无独立DC的屏这里可能需要发送一个带命令标识的9位数据帧 LCD_CS_HIGH(); // 释放片选 } void LCD_WriteData(uint8_t dat) { LCD_CS_LOW(); LCD_DC_HIGH(); // DC置高表示发送的是数据 HAL_SPI_Transmit(hspi1, dat, 1, HAL_MAX_DELAY); LCD_CS_HIGH(); } void LCD_WriteData_Bulk(uint8_t *pData, uint32_t len) { LCD_CS_LOW(); LCD_DC_HIGH(); HAL_SPI_Transmit(hspi1, pData, len, HAL_MAX_DELAY); // 阻塞式发送一段数据 LCD_CS_HIGH(); }对于无独立DC线的屏LCD_WriteCommand和LCD_WriteData可能需要合并为一个函数并在发送真正数据字节前先发送一个特定的命令头字节或者采用其他协议规定的方式。3.2 DMA传输优化实现“无阻塞”刷屏当需要填充整个屏幕区域例如320x240像素每个像素2字节共153.6KB时使用HAL_SPI_Transmit进行阻塞传输会导致CPU长时间被挂起。此时DMA直接存储器访问就是救星。配置步骤初始化DMA为SPI的TX流配置一个DMA通道方向为存储器到外设数据宽度为字节或半字如果一次发16位模式为普通模式非循环并启用传输完成中断。封装DMA发送函数uint8_t dma_tx_busy 0; // DMA状态标志 void LCD_WriteData_DMA(uint8_t *pData, uint32_t len) { while(dma_tx_busy); // 等待上一次DMA传输完成 dma_tx_busy 1; LCD_CS_LOW(); LCD_DC_HIGH(); HAL_SPI_Transmit_DMA(hspi1, pData, len); // 函数立即返回DMA在后台搬运数据 }实现DMA传输完成中断回调在SPI发送完成的DMA回调函数如HAL_SPI_TxCpltCallback中拉高CS片选并将dma_tx_busy标志清零。void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if(hspi-Instance SPI1) { LCD_CS_HIGH(); dma_tx_busy 0; } }使用DMA的优势调用LCD_WriteData_DMA启动传输后CPU几乎立刻被释放可以继续执行其他任务如处理按键、更新逻辑、准备下一帧数据。只有当整个数据块发送完毕产生中断时CPU才需要花极短的时间处理一下收尾工作。这对于需要保持高响应性的实时系统至关重要。实操心得DMA传输时要确保发送数据缓冲区pData指向的内存在DMA传输期间是稳定有效的。不能使用局部变量函数退出即失效或即将被修改的内存。通常使用全局数组或从堆heap动态分配但需注意内存碎片。对于显存可以定义一个全局的帧缓冲区frame buffer。3.3 关键时序与延迟处理LCD控制器对某些命令的执行需要时间例如软复位RESET、退出睡眠模式、设置显示开等。在发送这类命令后必须插入足够的延迟。错误的做法LCD_WriteCommand(0x01); // 软复位命令 // 没有延迟立刻发送下一条命令这很可能导致后续命令被忽略屏初始化失败。正确的做法查阅数据手册找到关键命令表看是否有标注“最小延迟时间”。例如软复位后可能需要等待5ms退出睡眠模式后等待120ms。使用HAL_Delay()在初始化序列中使用HAL_Delay()进行毫秒级延迟是简单可靠的。LCD_WriteCommand(0x01); // 软复位 HAL_Delay(5);更优的延迟策略对于需要等待屏内部操作完成的情况如完成一次全屏填充更好的方法是查询屏的“忙”状态如果SPI支持读。但很多3线SPI屏为了省线砍掉了状态读取功能。此时一个经验性的、略大于手册要求值的延迟是最实用的。也可以使用硬件定时器实现非阻塞的精确延迟。另一个重要时序是CS片选信号。在两次独立的命令/数据传输之间CS需要有一个从高到低再变高的过程。我们的封装函数已经处理了这一点。但在连续发送大量数据如一帧图像时应在整个数据块传输开始时拉低CS传输结束后再拉高而不是每发送几个字节就切换一次CS这样可以显著提高有效数据吞吐率。4. 初始化序列与图形绘制实战4.1 屏显控制器初始化代码剖析每个LCD模组都有一个初始化序列Init Sequence这是一系列按照特定顺序发送的命令和参数用于配置控制器内部的寄存器如驱动方向、颜色格式、伽马校正、电源电压等。这个序列通常由屏厂商提供有时在配套的示例代码里。一个典型的初始化序列片段如下以ILI9341为例需根据实际屏型号调整void LCD_Init(void) { // 硬件复位 LCD_RST_LOW(); HAL_Delay(10); LCD_RST_HIGH(); HAL_Delay(120); // 等待复位稳定 // 发送初始化命令序列 LCD_WriteCommand(0xCF); LCD_WriteData(0x00); LCD_WriteData(0xC1); LCD_WriteData(0X30); // ... 更多配置命令和数据 LCD_WriteCommand(0x36); // 内存访问控制MADCTL LCD_WriteData(0x48); // 设置扫描方向如BGR顺序行地址顺序反转等 // ... LCD_WriteCommand(0x29); // 打开显示 HAL_Delay(100); }关键点顺序至关重要必须严格按照数据手册或示例代码的顺序发送。参数个性化序列中的某些参数特别是颜色格式RGB565 vs RGB888、扫描方向横屏/竖屏、镜像相关的命令如MADCTL需要根据你的应用需求调整。调整不当会导致颜色错误或显示方向不对。延迟不可省序列中自带的HAL_Delay必须保留。4.2 设置显示窗口与像素填充要在屏幕上特定位置画一个点或填充一个矩形区域需要先设置“窗口”Window即告诉LCD控制器接下来要写入的数据对应屏幕上的哪个矩形区域。设置窗口函数void LCD_SetWindow(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2) { // 设置列地址X方向 LCD_WriteCommand(0x2A); // 列地址设置命令ILI9341是0x2A LCD_WriteData(x1 8); // 起始列高8位 LCD_WriteData(x1 0xFF); // 起始列低8位 LCD_WriteData(x2 8); // 结束列高8位 LCD_WriteData(x2 0xFF); // 结束列低8位 // 设置行地址Y方向 LCD_WriteCommand(0x2B); // 行地址设置命令ILI9341是0x2B LCD_WriteData(y1 8); LCD_WriteData(y1 0xFF); LCD_WriteData(y2 8); LCD_WriteData(y2 0xFF); // 发送写内存命令准备接收像素数据 LCD_WriteCommand(0x2C); // 写GRAM命令ILI9341是0x2C }设置好窗口后接下来连续发送的像素数据就会按照从左到右、从上到下的顺序填充到这个矩形区域。每个像素的颜色数据格式取决于初始化时的设置常用的是16位RGB565格式2字节。像素填充示例阻塞式void LCD_FillRect(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2, uint16_t color) { uint32_t total_pixels (x2 - x1 1) * (y2 - y1 1); LCD_SetWindow(x1, y1, x2, y2); LCD_CS_LOW(); LCD_DC_HIGH(); // 进入连续数据写入模式 for(uint32_t i0; itotal_pixels; i) { uint8_t color_hi color 8; uint8_t color_lo color 0xFF; // 注意有些屏要求先发高字节有些要求先发低字节需根据屏手册调整顺序 HAL_SPI_Transmit(hspi1, color_hi, 1, HAL_MAX_DELAY); HAL_SPI_Transmit(hspi1, color_lo, 1, HAL_MAX_DELAY); } LCD_CS_HIGH(); }优化方向上面的for循环效率很低。我们可以预先在内存中构造一个颜色数据缓冲区然后使用HAL_SPI_Transmit一次性发送或者更优地使用HAL_SPI_Transmit_DMA进行DMA传输。4.3 使用帧缓冲区Frame Buffer实现高级图形对于动态图形、UI界面频繁地计算并直接写入屏幕会非常慢且复杂。引入帧缓冲区是标准做法。帧缓冲区原理在STM32的内部RAM或外部RAM如果容量不够中开辟一块大小与屏幕分辨率匹配的内存区域。例如对于320x240的16位色屏需要3202402 153600字节150KB的缓冲区。所有的绘图操作画点、线、矩形、显示图片、文字都先在这个内存缓冲区中进行。当一帧画面准备好后调用一个LCD_Refresh()或LCD_Update()函数将整个帧缓冲区的数据通过SPI DMA一次性搬运到LCD的GRAM中。优势避免闪烁直接写屏时复杂的画面更新过程会部分呈现在屏幕上造成闪烁。而帧缓冲一次性更新画面是瞬间切换的。简化绘图逻辑绘图函数只需操作内存速度极快无需考虑SPI通信时序和等待。支持双缓冲可以开辟两个缓冲区一个用于后台绘制下一帧一个用于前台显示当前帧。绘制完成后交换指针实现极其流畅的动画效果。实现刷新函数DMA方式uint16_t frame_buffer[LCD_HEIGHT][LCD_WIDTH]; // 二维数组作为帧缓冲 void LCD_Refresh(void) { // 设置窗口为全屏 LCD_SetWindow(0, 0, LCD_WIDTH-1, LCD_HEIGHT-1); // 使用DMA将整个帧缓冲区发送到LCD LCD_WriteData_DMA((uint8_t*)frame_buffer, LCD_WIDTH * LCD_HEIGHT * sizeof(uint16_t)); }在main函数的主循环或定时器中断中定期调用LCD_Refresh()即可更新屏幕。绘图函数则直接修改frame_buffer数组中的值。5. 调试技巧与常见问题排查驱动3线SPI LCD的过程很少一帆风顺以下是几个最常见的坑和排查方法。5.1 上电无任何显示检查电源和背光这是最容易被忽视的。用万用表测量LCD模组的VCC和GND引脚电压是否正确通常是3.3V或5V。确认背光引脚LED LED-是否已正确供电或受控。可以尝试给背光一个固定高电平看是否亮起。检查复位时序确保复位引脚如果有的时序符合要求。一般是先拉低至少10ms然后拉高并等待100ms以上再开始发送初始化命令。检查SPI波形使用逻辑分析仪或示波器抓取SCK、SDA(MOSI)、CS三根线的波形。这是最直接的调试手段。看CS是否在每个命令/数据包前后有正确的跳变看DC如果是独立DC线发送命令时是否为低电平发送数据时是否为高电平看SCK和数据时钟频率是否在预期范围内数据线在时钟边沿是否稳定数据内容是否与代码发送的一致特别是初始化序列的第一个字节降低SPI速率将SPI波特率预分频调到最大即速率最慢排除因时序过快导致屏无法响应的问题。5.2 显示花屏、错位、颜色异常扫描方向设置错误这是导致显示错位、镜像、颜色通道错乱的最常见原因。重点检查初始化序列中“内存访问控制”MADCTL如ILI9341的0x36命令的参数。这个字节的每一个bit控制着屏幕的镜像、旋转、颜色顺序BGR/RGB。参考数据手册尝试不同的参数值直到显示方向正确。MADCTL Bit功能常见值效果MY行地址顺序1上下翻转MX列地址顺序1左右翻转MV行列交换1横竖屏切换ML垂直刷新顺序一般不常用RGB颜色顺序1BGR, 0RGBMH水平刷新顺序一般不常用颜色格式不匹配确认初始化序列中设置的颜色格式如RGB565与你发送的像素数据格式是否一致。例如如果你配置为18位色RGB666却发送16位数据颜色必然错误。像素数据字节序问题发送16位颜色数据时是先发高8位还是低8位不同的屏控制器要求可能相反。如果颜色完全不对比如红色显示成绿色可以尝试交换两个字节的发送顺序。显存窗口设置错误在局部刷新时LCD_SetWindow函数的参数计算错误可能导致数据写到了屏幕其他区域造成局部花屏。仔细检查坐标计算逻辑。5.3 SPI通信不稳定偶尔丢数据电源噪声数字电路对电源敏感。确保给STM32和LCD模组的电源是干净、稳定的。可以在电源引脚附近增加一个10uF电解电容并联一个0.1uF陶瓷电容进行滤波。信号完整性如果连接线较长超过10cm或者工作在较高频率10MHz需要考虑信号完整性问题。SCK和MOSI线上可以串联一个22Ω到100Ω的小电阻有助于减少过冲和振铃。中断干扰高优先级的中断特别是SysTick定时器中断可能会打断SPI的字节传输过程导致时序出现微小毛刺。如果使用不带FIFO的SPI且以很高频率运行尝试在关键的数据块传输期间临时关闭全局中断。__disable_irq(); // 发送关键数据块... __enable_irq();注意此操作需谨慎会严重影响系统实时性仅作为调试手段。根本解决方法是使用DMA。DMA传输竞争如果使用了DMA确保在DMA传输完成中断回调中拉高CS之前不会启动新的DMA传输。通过dma_tx_busy这类标志位进行严格的状态管理。5.4 性能瓶颈分析与优化当你觉得刷屏速度不够快时可以按以下层次排查和优化测量极限SPI时钟逐步提高SPI的波特率分频系数测试屏幕能稳定工作的最高SCK频率。这是提升速度最直接有效的方法。检查SPI时钟源确认你使用的SPI外设挂载在高速总线如APB2上并且该总线时钟已被正确配置到芯片允许的最高频率。从阻塞传输切换到DMA这是释放CPU的关键一步对比效果立竿见影。优化数据组织避免频繁地设置小窗口。一次性设置全屏窗口然后发送整帧数据效率远高于分多个小区域多次设置窗口并发送。减少函数调用开销对于最内层的像素发送循环使用寄存器直接操作SPI数据寄存器(DR)进行“轮询式”发送比调用HAL_SPI_Transmit函数更快。但会牺牲代码可读性和可移植性。void SPI_WriteByte_Fast(uint8_t data) { while(!(__HAL_SPI_GET_FLAG(hspi1, SPI_FLAG_TXE))); // 等待发送缓冲区空 *((__IO uint8_t *)hspi1.Instance-DR) data; // 写入数据寄存器 }考虑硬件连接如果SPI引脚连接到了有复用功能的IO上确认没有其他慢速外设如JTAG限制了IO速度。驱动一块3线SPI LCD从调通到优化是一个典型的嵌入式开发过程理解协议、匹配硬件、编写软件、调试问题、最后追求性能。整个过程下来你对STM32的SPI外设、GPIO操作、DMA乃至系统时序的理解都会深一个层次。我最开始用软件SPI刷一个矩形都要几十毫秒后来换成硬件SPI加DMA全屏刷新时间缩短到了十几毫秒以内整个UI的流畅度完全不一样了。所以在资源允许的情况下尽量拥抱硬件外设和DMA它们才是嵌入式系统性能的基石。