STM32 HAL库驱动1.8寸TFT-LCD:硬件SPI与软件模拟实战指南
1. 项目概述当STM32遇见1.8寸TFT-LCD玩过单片机的朋友都知道点亮一块屏幕是件很有成就感的事。这次要聊的就是用STM32的HAL库通过硬件SPI或者软件模拟SPI来驱动一块1.8寸的TFT-LCD彩屏。这类屏幕通常指的是ST7735S或ILI9163这类控制器驱动的128x160或128x128分辨率的小屏成本低、接口简单在智能手表、便携式仪表、小型人机交互界面上应用非常广泛。为什么选择SPI对于这种分辨率不高、刷新率要求不苛刻的屏幕SPI接口是性价比最高的选择。它只需要3-4根数据线MOSI, SCK, CS, DC有时再加一个RST相比并口的8位或16位总线极大地节省了宝贵的单片机IO资源。硬件SPI能解放CPU让数据传输在后台自动进行而软件SPI则提供了极致的引脚分配灵活性当硬件SPI引脚被其他功能占用时它就是救星。HAL库的封装让这两种方式的切换和代码编写变得相对统一和便捷。这篇文章我会从一个实际项目开发者的角度带你从零开始完成引脚连接、HAL库配置、驱动函数编写、图形基础功能实现再到性能优化和问题排查。无论你是刚接触STM32和HAL库的新手还是想寻找一个稳定可靠驱动方案的开发者这里都有你需要的“干货”。我们会深入SPI通信的时序细节理解TFT屏幕的初始化序列并分享那些数据手册上不会写的调试技巧和避坑指南。2. 核心硬件与通信协议解析2.1 认识我们的“画布”1.8寸TFT-LCD屏幕市面上常见的1.8寸TFT模块其核心驱动芯片大多是ST7735S也有部分使用ILI9163、ILI9341裁剪了分辨率。以ST7735S为例它内部集成了132x162的显存GRAM但我们通常只使用其中心的128x160区域。屏幕每个像素的颜色由16位数据RGB565格式表示即红色占5位绿色占6位蓝色占5位。屏幕模块的引脚通常包括VCC电源正极3.3V或5V需根据模块逻辑电平决定。GND电源地。CS片选信号低电平有效。用于选中当前SPI总线上的这个设备。RST复位信号低电平复位。用于硬件复位屏幕控制器。DC有时标为A0或RS数据/命令选择线。这是SPI驱动TFT屏幕最关键的一根线。当DC为低电平时SPI总线发送的是命令Command当DC为高电平时发送的是数据Data。SCKSPI时钟线。MOSISPI主机输出从机输入线用于STM32向屏幕发送数据。MISOSPI主机输入从机输出线。特别注意绝大多数TFT屏幕模块在SPI模式下是只写的不需要读取屏幕数据因此MISO引脚通常可以悬空不接。这简化了连线。BL背光控制高电平点亮。注意务必查阅你手中屏幕模块的具体资料表确认其驱动芯片型号、供电电压接5V可能烧毁3.3V逻辑的模块、以及引脚定义。这是所有工作的基础。2.2 SPI通信协议在TFT驱动中的角色SPI是一种全双工、同步、串行的通信协议。在TFT驱动场景下我们几乎只使用其“主机发送”功能。关键时序理解对于ST7735S这类屏幕SPI模式通常工作在模式0CPOL0 CPHA0或模式3CPOL1 CPHA1。这意味着在时钟线SCK的空闲状态为低电平模式0或高电平模式3数据在SCK的第一个边沿上升沿或下降沿被采样。绝大多数模块默认支持模式0。配置错误会导致屏幕无任何反应。DC引脚的核心作用这是区别于其他普通SPI设备如Flash、传感器的关键。屏幕控制器需要区分你发送的是一个“设置指令”还是一包“显示数据”。发送命令拉低DC引脚 - 通过SPI发送一个字节的命令码如0x2A 设置列地址。发送数据拉高DC引脚 - 通过SPI发送命令所需的参数或像素数据如随后的4个字节的列起始和结束地址。所有的屏幕操作如设置显示区域、写入像素颜色都遵循“先命令后数据”的序列。屏幕的初始化本质上就是发送一长串预先定义好的命令和数据序列。2.3 硬件SPI vs 软件模拟SPI如何抉择硬件SPI优点速度极快占用CPU资源少DMA加持下几乎零占用时序精准由硬件保证。缺点引脚固定如STM32F1的SPI1 MOSI/SCK固定在PA7/PA5可能与其他外设冲突配置稍复杂。适用场景对刷屏速度有要求需要显示动画或频繁刷新局部区域CPU需要处理其他复杂任务。软件模拟SPIBit-Banging优点引脚可以任意指定到任何GPIO灵活性无敌代码简单直观易于理解和调试。缺点速度慢占用大量CPU时间进行IO翻转和延时时序容易受中断干扰。适用场景硬件SPI引脚被占用项目初期快速验证对刷新速度要求极低如静态图片显示。我的经验选择在资源允许的情况下优先使用硬件SPI。其性能优势是决定性的。只有当引脚确实冲突且速度要求不高时才考虑软件SPI。HAL库的架构使得两者的上层驱动函数如写命令、写数据可以抽象成统一的接口底层实现不同而已这为我们后续的驱动设计提供了便利。3. 工程环境搭建与HAL库配置3.1 STM32CubeMX项目创建与硬件SPI配置假设我们使用STM32F103C8T6蓝色药丸核心板和硬件SPI1。系统核心在SYS中将Debug改为Serial Wire如果要用ST-Link调试。时钟配置RCCHSE选择Crystal/Ceramic Resonator然后在Clock Configuration选项卡里将系统时钟SYSCLK配置到最高72MHz对于F1。SPI1配置找到SPI1 将其模式设置为Full-Duplex Master。参数设置Prescaler 时钟分频。这是决定SPI速度的关键。SPI时钟 APB2总线时钟 / 分频系数。APB2时钟为72MHz。对于屏幕初期调试可以设大一点如64分频约1.125MHz稳定后再提高。最终可以尝试8分频9MHz或4分频18MHz具体需看屏幕模块支持的最高SCK频率。Data Size8-bit。屏幕命令和数据都以字节为单位。First BitMSB First。绝大多数设备都是高位在前。Clock PolarityLow。对应模式0。Clock Phase1 Edge。对应模式0。此时CubeMX会自动分配PA5为SPI1_SCKPA7为SPI1_MOSI。我们不需要MISO。GPIO配置为CS、DC、RST引脚分配三个普通的GPIO输出口例如PB0DC、PB1RST、PB2CS。将它们初始输出电平设置为高对于CS和RST或低根据情况。将背光控制BL引脚也配置为一个GPIO输出如PB10 初始化为高电平点亮背光。生成代码在Project Manager中设置好工程名、路径、IDEKeil MDK或STM32CubeIDE然后生成代码。3.2 软件模拟SPI的GPIO初始化如果选择软件SPI则无需在CubeMX中配置SPI外设。只需配置用于模拟SCK、MOSI、CS、DC、RST、BL的GPIO引脚为输出模式即可。例如PB12-SCKPB13-MOSIPB14-CSPB15-DCPA8-RSTPA9-BL在生成的代码中我们需要手动实现SCK和MOSI的翻转时序。3.3 底层驱动函数抽象与实现为了代码的通用性和可移植性我们抽象出几个最基本的操作函数无论硬件还是软件SPI都实现它们。1. 引脚控制宏定义在头文件中// 假设引脚定义如下硬件SPI情况 #define TFT_CS_PIN GPIO_PIN_2 #define TFT_CS_PORT GPIOB #define TFT_DC_PIN GPIO_PIN_0 #define TFT_DC_PORT GPIOB #define TFT_RST_PIN GPIO_PIN_1 #define TFT_RST_PORT GPIOB #define TFT_BL_PIN GPIO_PIN_10 #define TFT_BL_PORT GPIOB // 控制宏方便操作 #define TFT_CS_LOW HAL_GPIO_WritePin(TFT_CS_PORT, TFT_CS_PIN, GPIO_PIN_RESET) #define TFT_CS_HIGH HAL_GPIO_WritePin(TFT_CS_PORT, TFT_CS_PIN, GPIO_PIN_SET) #define TFT_DC_LOW HAL_GPIO_WritePin(TFT_DC_PORT, TFT_DC_PIN, GPIO_PIN_RESET) #define TFT_DC_HIGH HAL_GPIO_WritePin(TFT_DC_PORT, TFT_DC_PIN, GPIO_PIN_SET) #define TFT_RST_LOW HAL_GPIO_WritePin(TFT_RST_PORT, TFT_RST_PIN, GPIO_PIN_RESET) #define TFT_RST_HIGH HAL_GPIO_WritePin(TFT_RST_PORT, TFT_RST_PIN, GPIO_PIN_SET) #define TFT_BL_ON HAL_GPIO_WritePin(TFT_BL_PORT, TFT_BL_PIN, GPIO_PIN_SET) #define TFT_BL_OFF HAL_GPIO_WritePin(TFT_BL_PORT, TFT_BL_PIN, GPIO_PIN_RESET)2. 核心通信函数硬件SPI版本// 发送一个字节8位数据 static void TFT_WriteByte(uint8_t data) { HAL_SPI_Transmit(hspi1, data, 1, HAL_MAX_DELAY); // hspi1是CubeMX生成的SPI句柄 } // 发送一个命令 void TFT_WriteCommand(uint8_t cmd) { TFT_DC_LOW; // 命令模式 TFT_CS_LOW; TFT_WriteByte(cmd); TFT_CS_HIGH; } // 发送一个数据字节 void TFT_WriteData(uint8_t data) { TFT_DC_HIGH; // 数据模式 TFT_CS_LOW; TFT_WriteByte(data); TFT_CS_HIGH; }软件SPI版本// 模拟SCK和MOSI引脚定义 #define SOFT_SCK_PIN GPIO_PIN_12 #define SOFT_SCK_PORT GPIOB #define SOFT_MOSI_PIN GPIO_PIN_13 #define SOFT_MOSI_PORT GPIOB static void SOFT_SPI_WriteByte(uint8_t data) { for(uint8_t i 0; i 8; i) { HAL_GPIO_WritePin(SOFT_SCK_PORT, SOFT_SCK_PIN, GPIO_PIN_RESET); // SCK拉低 // 根据模式0在SCK上升沿采样数据因此我们先设置数据位 if(data 0x80) { HAL_GPIO_WritePin(SOFT_MOSI_PORT, SOFT_MOSI_PIN, GPIO_PIN_SET); } else { HAL_GPIO_WritePin(SOFT_MOSI_PORT, SOFT_MOSI_PIN, GPIO_PIN_RESET); } // 插入微小延时确保数据稳定具体时长需根据主频调整 __NOP(); __NOP(); __NOP(); HAL_GPIO_WritePin(SOFT_SCK_PORT, SOFT_SCK_PIN, GPIO_PIN_SET); // SCK拉高产生上升沿 __NOP(); __NOP(); // 保持高电平一段时间 data 1; // 移位准备下一位 } // 循环结束后SCK处于高电平。根据模式0CPOL0空闲时应为低电平 HAL_GPIO_WritePin(SOFT_SCK_PORT, SOFT_SCK_PIN, GPIO_PIN_RESET); } // TFT_WriteCommand和TFT_WriteData函数与硬件版相同只需将内部的TFT_WriteByte替换为SOFT_SPI_WriteByte。实操心得软件SPI的延时__NOP()数量需要根据你的CPU主频进行测试调整。太快了屏幕可能无法识别太慢了刷屏速度感人。一个简单的测试方法是先设置较多的__NOP()确保通信成功然后逐步减少直到出现通信错误再稍微加回一些作为安全余量。4. TFT屏幕驱动实现详解4.1 屏幕初始化序列唤醒沉睡的“画布”初始化序列是一系列按照特定顺序发送的命令和数据用于配置屏幕控制器的各种内部寄存器如驱动方向、颜色模式、电源控制等。不同厂家、不同批次的ST7735S模块其初始化序列可能略有差异。最好的来源是屏幕卖家提供的例程或数据手册附录。一个典型的ST7735S初始化流程如下通过调用TFT_WriteCommand和TFT_WriteData实现硬件复位拉低RST引脚至少10ms然后拉高并延时120ms以上等待屏幕内部复位完成。void TFT_Reset(void) { TFT_RST_LOW; HAL_Delay(20); // 延时20ms远大于10ms要求 TFT_RST_HIGH; HAL_Delay(120); // 等待复位稳定 }退出睡眠模式发送命令0x11(SLPOUT)。设置颜色格式发送命令0x3A(COLMOD) 后面跟数据0x05或0x06。0x05代表16位RGB565这是我们最常用的格式。设置显示方向旋转发送命令0x36(MADCTL) 后面的参数决定了显示方向和颜色顺序。例如0xA0或0xC0等需要根据你的屏幕物理安装方向来调整。设置列和行地址范围通常初始化时会设置全屏范围命令为0x2A(CASET) 和0x2B(RASET) 后面各跟4个字节的起始、结束地址。打开显示发送命令0x29(DISPON)。清屏并点亮背光。关键点很多初始化序列在关键命令后需要插入HAL_Delay 例如SLPOUT之后需要等待120msDISPON之前可能需要等待。忽略这些延时是导致初始化失败最常见的原因之一。建议将卖家提供的完整初始化序列代码封装成一个TFT_Init()函数。4.2 设置绘图窗口与像素写入要在屏幕上某个区域绘图必须先设置“窗口”告诉屏幕控制器接下来接收的像素数据要填充到GRAM的哪个矩形区域。void TFT_SetWindow(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { // 设置列地址范围 TFT_WriteCommand(0x2A); // CASET TFT_WriteData(x0 8); // 起始列高8位 TFT_WriteData(x0 0xFF); // 起始列低8位 TFT_WriteData(x1 8); // 结束列高8位 TFT_WriteData(x1 0xFF); // 结束列低8位 // 设置行地址范围 TFT_WriteCommand(0x2B); // RASET TFT_WriteData(y0 8); // 起始行高8位 TFT_WriteData(y0 0xFF); // 起始行低8位 TFT_WriteData(y1 8); // 结束行高8位 TFT_WriteData(y1 0xFF); // 结束行低8位 // 发送写GRAM命令接下来发送的数据将自动填充到设置的窗口 TFT_WriteCommand(0x2C); // RAMWR }设置好窗口后调用TFT_WriteData发送的像素数据就会从窗口的左上角开始从左到右、从上到下依次填充。每个像素需要两个字节16位。画点函数这是最基础的图形函数。void TFT_DrawPixel(uint16_t x, uint16_t y, uint16_t color) { if(x TFT_WIDTH || y TFT_HEIGHT) return; // 边界检查 TFT_SetWindow(x, y, x, y); // 窗口设置为单个像素点 TFT_WriteData(color 8); // 发送颜色高字节 TFT_WriteData(color 0xFF); // 发送颜色低字节 }注意TFT_DrawPixel函数每次画点都重新设置窗口效率极低仅适用于画散点。画连续图形线、矩形、填充时应该先设置好整个区域的窗口然后连续发送像素数据。4.3 基础图形功能实现基于画点和窗口设置我们可以构建更高级的函数。1. 填充矩形void TFT_FillRect(uint16_t x0, uint16_t y0, uint16_t w, uint16_t h, uint16_t color) { uint32_t pixelCount (uint32_t)w * h; if(pixelCount 0) return; TFT_SetWindow(x0, y0, x0 w - 1, y0 h - 1); // 优化连续发送同一颜色数据 TFT_DC_HIGH; TFT_CS_LOW; for(uint32_t i 0; i pixelCount; i) { TFT_WriteByte(color 8); TFT_WriteByte(color 0xFF); // 可以在此处加入DMA传输优化见后续章节 } TFT_CS_HIGH; }2. 清屏TFT_FillRect(0, 0, TFT_WIDTH, TFT_HEIGHT, BACKGROUND_COLOR);3. 画线、画矩形框、画圆这些属于算法层面可以使用Bresenham等经典算法实现核心依然是调用TFT_DrawPixel或更优化的区域连续写入。网上有大量开源代码可供参考集成。4.4 显示图片与中英文字符显示图片将图片如BMP、JPG通过工具如Img2Lcd LCD Image Converter转换为C语言数组格式为RGB565。图片数组的大小应为宽度 * 高度 * 2字节。使用TFT_SetWindow设置图片显示区域。将图片数组数据通过TFT_WriteData或更高效的方式如DMA连续发送出去。显示字符字库取模使用PCtoLCD2002等软件选择字体和大小如12x12 16x16生成点阵字库数组。每个字符对应一个数组描述其点阵信息。显示函数根据字符编码如ASCII或GBK找到对应的点阵数组然后遍历点阵遇到“1”则画前景色点“0”则画背景色点或跳过。中文字符原理相同但字符点阵更大通常16x16或以上编码需使用GBK/Unicode并需要包含更庞大的字库数组。为了节省单片机Flash通常只提取项目中用到的字符。实操心得将字库放在SPI Flash或SD卡中运行时加载到RAM或直接通过FSMC等接口读取是显示大量字符的常用方法但这超出了基础SPI驱动的范畴。对于简单界面将少量常用字符的点阵直接编译进代码是最直接可靠的。5. 性能优化与高级技巧5.1 使用DMA提升硬件SPI刷屏效率直接使用HAL_SPI_Transmit发送大量像素数据如整屏填充是阻塞式的CPU需要等待每个字节发送完成。DMA直接存储器访问可以将数据从内存搬运到SPI数据寄存器的工作交给硬件解放CPU。配置步骤CubeMX在SPI1的DMA Settings选项卡添加一个SPI1_TX的DMA请求。选择通道如DMA1 Channel3模式设为Normal单次传输优先级High内存数据宽度Byte与SPI的8位匹配。在代码中使用HAL_SPI_Transmit_DMA函数。uint16_t fillColor 0xF800; // 红色 uint8_t colorBuffer[320]; // 缓冲区160个像素 * 2字节 320字节 // 将颜色值填充到缓冲区 for(int i 0; i 320; i2) { colorBuffer[i] fillColor 8; colorBuffer[i1] fillColor 0xFF; } TFT_SetWindow(0, 0, 127, 159); // 设置全屏窗口 TFT_DC_HIGH(); TFT_CS_LOW(); HAL_SPI_Transmit_DMA(hspi1, colorBuffer, 320); // 启动DMA传输 // 此时CPU可以去做其他事情 // 需要等待DMA传输完成可以查询标志位或使用回调函数。注意DMA传输是非阻塞的你需要管理好传输完成的时机如通过HAL_SPI_TxCpltCallback回调函数才能进行下一次操作如拉高CS。对于整屏刷新可能需要分多次DMA传输因为一次传输的数据量受限于DMA缓冲区大小和SPI的FIFO。5.2 双帧缓冲与局部刷新对于动态显示频繁全屏刷新即使使用DMA也可能造成闪屏。更高级的策略是使用“双帧缓冲”在单片机RAM中开辟两块与屏幕GRAM大小一致的缓冲区Buffer A和Buffer B每个像素占2字节。所有的绘图操作画点、画线、渲染字符都只在当前“后台缓冲区”中进行修改的是内存中的数据。当一帧图像准备好后通过一次快速的DMA传输将整个后台缓冲区的内容复制到屏幕GRAM。交换前后台缓冲区指针。这种方法将耗时的绘图计算与屏幕更新解耦能提供最流畅的动画效果。但它的代价是消耗大量RAM对于128x160 RGB565 需要约40KB * 2 80KB这对于RAM有限的STM32F1系列如C8T6只有20KB是不现实的。因此更实用的优化是局部刷新只更新屏幕上发生变化的那部分区域而不是整个屏幕。这需要应用程序层来跟踪脏区域。5.3 软件SPI的速度极限与优化软件SPI的瓶颈在于CPU循环和GPIO操作。一些优化思路使用寄存器直接操作GPIO代替HAL_GPIO_WritePin使用GPIOx-BSRR或ODR寄存器进行位操作速度更快。#define SOFT_SCK_HIGH (SOFT_SCK_PORT-BSRR SOFT_SCK_PIN) #define SOFT_SCK_LOW (SOFT_SCK_PORT-BRR SOFT_SCK_PIN) #define SOFT_MOSI_HIGH (SOFT_MOSI_PORT-BSRR SOFT_MOSI_PIN) #define SOFT_MOSI_LOW (SOFT_MOSI_PORT-BRR SOFT_MOSI_PIN)循环展开将8次循环展开成顺序执行的8组语句消除循环判断开销。减少延时在保证屏幕能稳定接收的前提下尽可能减少__NOP()的数量。使用汇编或内联汇编对于时间要求极其苛刻的场合可以用汇编编写最核心的位操作循环。但无论如何优化软件SPI的速度都难以与硬件SPI匹敌更不用说加上DMA了。因此优化软件SPI更多是一种“救急”或“学习”行为。6. 常见问题排查与调试心得驱动屏幕的过程就是与各种奇怪现象斗争的过程。下面是一些典型问题及排查思路。现象可能原因排查步骤与解决方案屏幕完全无显示背光亮1. 电源问题。2. 复位或初始化序列错误。3. SPI通信根本未建立。1. 检查VCC/GND电压确认屏幕逻辑电压3.3V/5V。2. 用逻辑分析仪或示波器抓取RST、CS、DC、SCK、MOSI波形。首先看RST是否有正确的低脉冲然后看发送初始化命令时CS和DC是否有对应变化SCK是否有时钟输出MOSI是否有数据。3. 检查SPI模式CPOL/CPHA是否与屏幕要求一致。模式0和模式3是常见问题点。4. 大幅降低SPI波特率如分频到256再试。屏幕全白、全黑或显示彩色条纹初始化序列部分成功但某些关键参数如颜色模式、显示方向、电源配置设置错误。1. 核对初始化序列特别是COLMOD颜色格式、MADCTL显示方向命令的参数。2. 检查CASET/RASET设置的范围是否超出了屏幕实际物理分辨率。3. 尝试使用卖家提供的“绝对可靠”的初始化代码。显示错位、镜像或颜色错误MADCTL命令参数设置错误导致扫描方向、RGB顺序不对。仔细查阅ST7735S数据手册中关于MADCTL寄存器的说明调整MY、MX、MV、RGB等位的值进行测试。通常有0x00, 0xA0, 0xC0, 0x60等几种常见组合。画点位置不正确1. 坐标系理解错误屏幕原点位置。2. 窗口设置函数TFT_SetWindow有bug。3. 屏幕物理偏移。1. 编写测试代码分别在(0,0), (maxX,0), (0,maxY), (maxX,maxY)画点观察位置。2. 有些屏幕控制器GRAM大小大于可视区域需要设置偏移量XSTART, YSTART。在初始化序列中查找0x36命令或相关的偏移设置命令。使用DMA时显示乱码或错位1. DMA传输未完成就进行了其他操作如拉高CS。2. 内存缓冲区数据格式错误。3. DMA传输大小或内存地址对齐问题。1. 确保在DMA传输完成回调函数中再进行后续操作如结束传输、操作其他外设。2. 检查颜色缓冲区数据是否是正确的RGB565高低字节顺序。3. 确保DMA传输的数据长度是偶数因为每个像素2字节。4. 尝试关闭Cache如果单片机有的话或使用非缓存内存区域。软件SPI显示不稳定时好时坏时序不满足屏幕要求受中断干扰。1. 增加软件SPI位之间的延时__NOP()。2. 在软件SPI的关键传输函数前后关闭全局中断__disable_irq()和开启__enable_irq()避免被高优先级中断打断时序。调试必备工具逻辑分析仪几十块钱的8通道逻辑分析仪是调试SPI、I2C等数字通信的利器。可以清晰看到CS、DC、SCK、MOSI上的每一个波形对照数据手册分析是否完全符合时序要求。万用表检查电源和连接。ST-Link IDE调试器单步执行查看变量设置断点观察程序流。我的几点深刻教训初始化延时是玄学数据手册上要求SLPOUT后延迟120ms我试过有些模块延迟50ms也能工作有些模块延迟150ms才稳定。宁多勿少在初始化函数里多加点HAL_Delay成功后再尝试缩减。DC线是关键中的关键我曾因为一根接触不良的杜邦线导致DC信号时高时低屏幕显示乱七八糟的图案排查了半天。确保所有连接牢固。硬件SPI的NSSCS管理STM32的硬件SPI的NSS引脚有硬件管理模式和软件管理模式。强烈建议使用软件控制GPIO来作为CS这样更灵活避免硬件自动控制带来的时序问题。这就是为什么我们在CubeMX中不配置SPI的NSS引脚而是自己用普通GPIO控制的原因。颜色字节序RGB565在内存中的存储顺序是高字节在前R[4:0]G[5:3]还是低字节在前G[2:0]B[4:0]这需要和取模软件、屏幕驱动芯片保持一致。常见的错误是红蓝颜色反了。