STM32软件SPI驱动ST7735S TFT-LCD刷屏实战
2026/9/17 20:14:03 网站建设 项目流程

手头这块1.8寸TFT-LCD用的是ST7735S控制器,分辨率128x160,SPI接口。我原本打算用STM32的硬件SPI直接点,结果发现SPI1的引脚已经被板子上的其他外设占了,重新飞线又嫌麻烦。于是干脆用软件SPI来驱动——也就是拿几个普通GPIO模拟SCK和SDA,把屏幕先跑起来再说。实测下来,软件SPI虽然刷全屏慢,但胜在引脚随便选、接线直观、调试时能单步看波形,特别适合前期验证和低速界面。这篇文章就把我从接线、写时序、初始化、画点、填色到字符显示这一整套过程拆开讲清楚,顺带把白屏、花屏、偏移、颜色反了这些坑一次性说透。只要你手里有一块STM32和一块1.8寸TFT,照着做就能点亮。

1. 为什么一块1.8寸TFT-LCD值得用软件SPI去点

1.1 模块到手先认清:ST7735S与128x160的引脚定义

市面上常见的1.8寸TFT模块,大多数是红色PCB,背面带一个SD卡座,正面屏幕分辨率128x160,驱动芯片是ST7735S。它和1.44寸、1.8寸、2.0寸的SPI屏基本是一个家族,区别主要在分辨率、偏移量和初始化参数。模块引出的引脚通常有8到10个,不同厂家丝印略有差别,但功能一致。下面这张表是我手头这块的典型定义,你可以对照自己的模块确认。

模块引脚功能接STM32说明
VCC电源正3.3V不要接5V,除非模块带电平转换
GND电源地GND必须和STM32共地
CS片选任意GPIO低电平有效
RESET/RST复位任意GPIO低电平复位,也可接复位电路
DC/A0/RS命令数据选择任意GPIO低=命令,高=数据
SDA/MOSI数据线任意GPIO软件SPI时就是普通输出脚
SCK/SCL时钟线任意GPIO软件SPI时就是普通输出脚
LED/BLK背光3.3V或PWM串联限流电阻,或直接3.3V

这里最容易忽略的是DC引脚。很多人第一次点屏,把SDA和SCK接对了,CS也拉低了,但屏幕就是不亮或者花屏,最后发现DC没接。DC是ST7735S区分“命令”和“数据”的唯一信号:DC为低时,SPI上传输的字节被当成命令;DC为高时,被当成数据。没有它,屏幕根本不知道你发的是初始化命令还是像素数据。

另外,背光LED通常已经内置限流电阻,直接接3.3V就能亮。如果模块上没有电阻,需要自己串一个几十欧到一百欧的电阻,否则可能烧背光。想调亮度,可以用STM32的定时器输出PWM到BLK脚,频率建议1kHz以上,避免肉眼看到闪烁。

1.2 软件SPI和硬件SPI的取舍:我为什么先选软件SPI

软件SPI,说白了就是用GPIO手动翻转电平来模拟SPI时序。硬件SPI则是STM32内部的SPI外设,配置好之后自动产生时钟、自动移位数据。两者没有绝对的好坏,关键看当前阶段要解决什么问题。我选软件SPI,最直接的原因是硬件SPI的引脚被占了,而软件SPI可以把SCK、SDA、CS、DC、RST全都安排到空闲GPIO上,甚至可以在PCB上随便飞线。

对比维度软件SPI硬件SPI
引脚选择任意GPIO固定复用引脚
最高速度受CPU主频和代码效率限制可达十几到几十MHz
CPU占用高,刷屏时CPU一直在翻转低,可配合DMA
调试难度低,可单步、可示波器看波形需要配置外设,出错时排查项多
冲突风险几乎不占外设可能与其他SPI设备冲突
适合场景验证、低速界面、局部刷新刷图、动画、高频刷新

我通常的做法是:前期用软件SPI把屏幕点亮,确认初始化序列、偏移量、颜色格式都对了;等界面布局固定、需要提高刷新率时,再把底层写字节函数换成硬件SPI发送。这样调试阶段不会因为SPI外设配置分心,后期优化也有明确目标。软件SPI的代码结构也很适合理解SPI时序,你能亲眼看到每一个SCK上升沿和SDA数据位的关系。

1.3 接线表与电源注意事项

以STM32F103C8T6为例,我选了PA2到PA5这组引脚,避开JTAG占用的PA13、PA14、PA15和PB3、PB4。接线如下:

TFT模块STM32说明
VCC3.3V模块和MCU同电源
GNDGND共地
CSPA4软件片选
RESETPA2复位
DCPA3命令/数据
SDAPA7软件SPI数据
SCKPA5软件SPI时钟
LED3.3V背光常亮

电源部分要特别注意。1.8寸TFT在背光全亮时电流可能达到几十毫安,如果和STM32共用LDO,要确认LDO余量。屏幕的VCC和MCU的3.3V最好来自同一路,避免上电时序不一致导致初始化失败。GND必须共地,软件SPI没有差分信号,地线不共,电平判断就会乱。如果模块背面有SD卡座,暂时不用可以不接,但SD卡的引脚不要短路到其他信号。

还有一点,STM32的GPIO输出高电平是3.3V,TFT模块的逻辑电平也是3.3V,直连没问题。如果你用的是5V单片机,比如某些51开发板,必须做电平转换,否则可能损坏屏幕。软件SPI的SCK频率在前期建议先降到很低,比如每个位延时几个微秒,等屏幕稳定出图后再逐步提高。

2. ST7735S的SPI时序与软件模拟的关键细节

2.1 SPI模式0的四个边沿:数据到底在哪个沿被采样

SPI有四种模式,由CPOL和CPHA决定。ST7735S通常使用模式0,也就是CPOL=0、CPHA=0。CPOL=0表示时钟空闲时为低电平;CPHA=0表示在第一个时钟边沿采样数据,也就是上升沿采样。这意味着SCK从低变高的一瞬间,SDA上的电平必须已经稳定。所以软件SPI的写字节顺序应该是:先准备好SDA数据,再把SCK拉高,然后拉低SCK,准备下一位。

如果你把顺序写反了,先拉高SCK再改SDA,屏幕采到的就是上一位的数据,结果就是花屏或者完全无显示。很多网上的软件SPI代码看起来五花八门,但核心都是围绕这个边沿关系。模式0的时序可以记成一句话:数据在上升沿被读走,在下降沿改变。我们手动模拟时,就是在SCK低电平期间改SDA,然后拉高SCK,再拉低。

还有一点,数据是高位先行,MSB first。一个字节从bit7开始发,最后发bit0。所以循环里通常用“dat & 0x80”判断当前位,然后左移一位。

2.2 命令与数据的区分:DC引脚的电平规则

ST7735S没有独立的命令通道,所有命令和数据都走同一根SDA线,靠DC引脚区分。DC为低,表示接下来发送的是命令字节;DC为高,表示发送的是数据字节。比如初始化时发送0x11表示退出睡眠,这是命令,DC必须拉低。发送颜色值0xF800,这是数据,DC必须拉高。

在实际代码里,我一般封装两个函数:LCD_WriteCmd和LCD_WriteData。前者拉低DC,后者拉高DC。写命令时通常一个字节就够了,写数据时可能连续写很多字节。为了效率,连续写数据时不要在每一字节之间反复切换DC和CS,而是先拉低CS、拉高DC,然后连续调用写字节函数,最后拉高CS。

DC引脚的电平切换要在CS有效期间完成,并且要保证在SCK上升沿之前稳定。软件SPI下这很容易控制,因为一切都在你的代码顺序里。硬件SPI时DC通常还是用GPIO控制,所以同样要注意在发送前切换好。

2.3 软件片选:CS什么时候拉低、什么时候拉高

CS是片选,低电平有效。软件SPI下CS也是普通GPIO,由代码控制。最简单的写法是每发送一个字节就拉低CS,发送完再拉高。这样逻辑清晰,但速度慢,因为每次都要操作CS引脚。对于初始化阶段,完全没问题;对于刷屏,建议把CS在整个连续传输期间保持低电平,只在开始和结束时切换。

ST7735S允许CS在命令和数据之间保持低电平,只要DC正确。所以优化后的流程是:拉低CS,发送命令字节,切换DC,连续发送数据字节,最后拉高CS。这样省掉大量CS翻转。注意,如果你用硬件SPI,CS可以由硬件NSS自动控制,也可以继续用GPIO软件控制,后者更灵活,尤其当SPI总线上挂多个设备时。

还有一个小细节:上电后、初始化前,先把CS拉高,让屏幕处于未选中状态,避免上电瞬间的毛刺被当成命令。RST和DC也建议初始化为高电平或低电平的确定状态,不要悬空。

2.4 时序参数:SCK周期、建立时间与保持时间

ST7735S的SPI时钟最高可以到几十MHz,具体看数据手册。软件SPI下,SCK周期由代码执行时间决定。STM32F103主频72MHz,直接操作寄存器时,一个SCK周期大概几个机器周期,实际频率可能在几MHz到十几MHz之间。前期调试建议主动加延时,把SCK降到1MHz以下,等屏幕稳定后再去掉延时。

建立时间和保持时间在软件SPI里其实就是代码顺序。先改SDA,再拉高SCK,这中间自然有指令执行时间作为建立时间。拉高SCK后,再拉低SCK,然后改SDA,这中间也有保持时间。只要不在同一个时钟周期里同时改SDA和SCK,一般都能满足。如果你用HAL_GPIO_WritePin,函数调用开销大,反而“天然”满足了低速时序,但也拖慢了刷屏速度。

我的经验是:先用带延时的版本点亮,确认初始化序列和偏移都对;然后用示波器或者逻辑分析仪看一下SCK频率,再决定是否去掉延时。没有仪器也没关系,逐步减少延时,直到屏幕出现不稳定,再退回去一点,就是当前接线下的安全速度。

3. 从GPIO到写字节:软件SPI底层代码逐行拆解

3.1 GPIO初始化:推挽输出、高速、默认电平

软件SPI的GPIO必须配置为推挽输出,速度等级选High或Very High,具体看STM32型号。推挽输出才能同时输出强高和强低,开漏输出需要外部上拉,不适合高速SPI。初始化时把所有相关引脚设为输出,默认电平:SCK低、SDA高、CS高、DC高、RST高。下面是HAL库的初始化示例。

static void LCD_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitStruct.Pin = LCD_SCK_PIN | LCD_SDA_PIN | LCD_DC_PIN | LCD_CS_PIN | LCD_RST_PIN; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); LCD_SCK_L(); LCD_SDA_H(); LCD_CS_H(); LCD_DC_H(); LCD_RST_H(); }

这里把SCK、SDA、DC、CS、RST都放在GPIOA,方便用BSRR和BRR操作。如果你分散在不同端口,宏定义里写清楚端口即可。速度等级选High,是因为软件SPI翻转频繁,低速档位可能让波形边沿变缓,影响高速下的可靠性。

3.2 宏定义与寄存器操作:比HAL_GPIO_WritePin快在哪里

HAL_GPIO_WritePin是一个函数,内部有参数检查、端口判断、BSRR写入等操作,调用一次可能几十个时钟周期。软件SPI每个字节要翻转SCK八次,每次还要设置SDA,如果用HAL函数,光函数调用开销就非常可观。直接操作BSRR和BRR寄存器,一条语句就能完成置位或复位,速度提升非常明显。

#define LCD_SCK_PORT GPIOA #define LCD_SCK_PIN GPIO_PIN_5 #define LCD_SDA_PORT GPIOA #define LCD_SDA_PIN GPIO_PIN_7 #define LCD_DC_PORT GPIOA #define LCD_DC_PIN GPIO_PIN_3 #define LCD_CS_PORT GPIOA #define LCD_CS_PIN GPIO_PIN_4 #define LCD_RST_PORT GPIOA #define LCD_RST_PIN GPIO_PIN_2 #define LCD_SCK_H() (LCD_SCK_PORT->BSRR = LCD_SCK_PIN) #define LCD_SCK_L() (LCD_SCK_PORT->BRR = LCD_SCK_PIN) #define LCD_SDA_H() (LCD_SDA_PORT->BSRR = LCD_SDA_PIN) #define LCD_SDA_L() (LCD_SDA_PORT->BRR = LCD_SDA_PIN) #define LCD_DC_H() (LCD_DC_PORT->BSRR = LCD_DC_PIN) #define LCD_DC_L() (LCD_DC_PORT->BRR = LCD_DC_PIN) #define LCD_CS_H() (LCD_CS_PORT->BSRR = LCD_CS_PIN) #define LCD_CS_L() (LCD_CS_PORT->BRR = LCD_CS_PIN) #define LCD_RST_H() (LCD_RST_PORT->BSRR = LCD_RST_PIN) #define LCD_RST_L() (LCD_RST_PORT->BRR = LCD_RST_PIN)

BSRR是置位寄存器,低16位写1置位,高16位写1复位。BRR是复位寄存器,写1复位。使用宏比函数调用少了一层栈操作和参数传递。如果你用的是标准库,也可以用GPIO_SetBits和GPIO_ResetBits,但同样不如直接操作寄存器快。注意宏里的端口和引脚要一一对应,别写错端口。

3.3 写字节函数的两种写法与实测差异

第一种写法是面向初学者的,按位判断、拉高拉低,逻辑最清楚:

static void LCD_WriteByte_Slow(uint8_t dat) { for (uint8_t i = 0; i < 8; i++) { if (dat & 0x80) LCD_SDA_H(); else LCD_SDA_L(); LCD_SCK_H(); dat <<= 1; LCD_SCK_L(); } }

第二种是去掉分支,用位运算和条件赋值,或者直接用查表,速度更快:

static inline void LCD_WriteByte(uint8_t dat) { for (uint8_t i = 0; i < 8; i++) { if (dat & 0x80) LCD_SDA_H(); else LCD_SDA_L(); LCD_SCK_H(); dat <<= 1; LCD_SCK_L(); } }

其实两者差别不大,关键是把函数声明为inline或者直接写在头文件里,让编译器优化。实测在STM32F103 @72MHz下,直接寄存器版本一个字节大约1微秒左右,HAL_GPIO_WritePin版本可能超过10微秒。全屏40960字节,前者约40毫秒理论值,实际加上循环和窗口设置,可能100到200毫秒;后者可能超过1秒。这个差距在刷图片时非常明显。

3.4 写命令、写数据、写16位数据的封装

在写字节的基础上,封装三个常用函数:

void LCD_WriteCmd(uint8_t cmd) { LCD_DC_L(); LCD_CS_L(); LCD_WriteByte(cmd); LCD_CS_H(); } void LCD_WriteData(uint8_t dat) { LCD_DC_H(); LCD_CS_L(); LCD_WriteByte(dat); LCD_CS_H(); } void LCD_WriteData16(uint16_t dat) { LCD_WriteData(dat >> 8); LCD_WriteData(dat & 0xFF); }

这是简单版本,每个字节都切换CS。初始化阶段完全够用。刷屏时我会用优化版本:手动拉低CS,设置DC,然后连续调用LCD_WriteByte,最后拉高CS。注意LCD_WriteByte本身不操作CS和DC,只翻转SCK和SDA。这样职责分离,优化时更灵活。

3.5 复位与延时:上电时序不能省

ST7735S对上电复位有要求。RST拉低至少10毫秒,然后拉高,再等待120毫秒,让内部稳压和振荡器稳定。之后发送软件复位命令0x01,再等120毫秒。很多白屏问题就是因为复位时间不够,或者上电后立刻发初始化命令。

void LCD_Reset(void) { LCD_RST_H(); HAL_Delay(10); LCD_RST_L(); HAL_Delay(20); LCD_RST_H(); HAL_Delay(120); }

这个顺序是:先确保RST为高,再拉低20毫秒,再拉高并等待120毫秒。不同模块可能略有差异,但宁可多等,不要少等。HAL_Delay在系统时钟配置正确的前提下才准确,如果你还没调通SysTick,先用简单的循环延时也可以,但时间要重新估算。

4. ST7735S初始化序列:从上电到出图的完整命令链

4.1 初始化命令表:每条命令解决什么问题

ST7735S的初始化看起来是一长串命令,其实每条都有明确目的。下面这张表列出了常见命令及其作用,理解了这些,你就不用盲目抄代码。

命令名称作用
0x01SWRESET软件复位
0x11SLPOUT退出睡眠模式
0xB1FRMCTR1正常模式帧率控制
0xB2FRMCTR2空闲模式帧率控制
0xB3FRMCTR3部分模式帧率控制
0xB4INVCTR反显控制
0xC0PWCTR1电源控制1
0xC1PWCTR2电源控制2
0xC2PWCTR3电源控制3
0xC3PWCTR4电源控制4
0xC4PWCTR5电源控制5
0xC5VMCTR1VCOM控制
0x20INVOFF关闭反显
0x21INVON开启反显
0x36MADCTL扫描方向控制
0x3ACOLMOD像素格式,0x05为16位
0xE0GMCTRP1正极性伽马校正
0xE1GMCTRN1负极性伽马校正
0x13NORON正常显示模式
0x29DISPON开启显示

初始化的大致顺序是:复位、退出睡眠、设置帧率、设置电源、设置VCOM、设置扫描方向、设置像素格式、设置伽马、开启显示。不同厂家的模块在电源参数和伽马上略有差异,但核心命令一致。如果屏幕能亮但颜色不对,优先检查COLMOD和MADCTL;如果完全白屏,先查复位和SLPOUT。

4.2 常见红板1.8寸的初始化代码

下面是我手头这块红板1.8寸128x160能稳定出图的初始化代码。注意,不同批次可能有个别参数差异,但大多数ST7735S模块可以直接用。

void LCD_Init(void) { LCD_Reset(); LCD_WriteCmd(0x01); // SWRESET HAL_Delay(120); LCD_WriteCmd(0x11); // SLPOUT HAL_Delay(120); LCD_WriteCmd(0xB1); // FRMCTR1 LCD_WriteData(0x01); LCD_WriteData(0x2C); LCD_WriteData(0x2D); LCD_WriteCmd(0xB2); // FRMCTR2 LCD_WriteData(0x01); LCD_WriteData(0x2C); LCD_WriteData(0x2D); LCD_WriteCmd(0xB3); // FRMCTR3 LCD_WriteData(0x01); LCD_WriteData(0x2C); LCD_WriteData(0x2D); LCD_WriteData(0x01); LCD_WriteData(0x2C); LCD_WriteData(0x2D); LCD_WriteCmd(0xB4); // INVCTR LCD_WriteData(0x07); LCD_WriteCmd(0xC0); // PWCTR1 LCD_WriteData(0xA2); LCD_WriteData(0x02); LCD_WriteData(0x84); LCD_WriteCmd(0xC1); // PWCTR2 LCD_WriteData(0xC5); LCD_WriteCmd(0xC2); // PWCTR3 LCD_WriteData(0x0A); LCD_WriteData(0x00); LCD_WriteCmd(0xC3); // PWCTR4 LCD_WriteData(0x8A); LCD_WriteData(0x2A); LCD_WriteCmd(0xC4); // PWCTR5 LCD_WriteData(0x8A); LCD_WriteData(0xEE); LCD_WriteCmd(0xC5); // VMCTR1 LCD_WriteData(0x0E); LCD_WriteCmd(0x20); // INVOFF LCD_WriteCmd(0x36); // MADCTL LCD_WriteData(0xC8); LCD_WriteCmd(0x3A); // COLMOD LCD_WriteData(0x05); LCD_WriteCmd(0xE0); // GMCTRP1 LCD_WriteData(0x02); LCD_WriteData(0x1C); LCD_WriteData(0x07); LCD_WriteData(0x12); LCD_WriteData(0x37); LCD_WriteData(0x32); LCD_WriteData(0x29); LCD_WriteData(0x2D); LCD_WriteData(0x29); LCD_WriteData(0x25); LCD_WriteData(0x2B); LCD_WriteData(0x39); LCD_WriteData(0x00); LCD_WriteData(0x01); LCD_WriteData(0x03); LCD_WriteData(0x10); LCD_WriteCmd(0xE1); // GMCTRN1 LCD_WriteData(0x03); LCD_WriteData(0x1D); LCD_WriteData(0x07); LCD_WriteData(0x06); LCD_WriteData(0x2E); LCD_WriteData(0x2C); LCD_WriteData(0x29); LCD_WriteData(0x2D); LCD_WriteData(0x2E); LCD_WriteData(0x2E); LCD_WriteData(0x37); LCD_WriteData(0x3F); LCD_WriteData(0x00); LCD_WriteData(0x00); LCD_WriteData(0x02); LCD_WriteData(0x10); LCD_WriteCmd(0x13); // NORON HAL_Delay(10); LCD_WriteCmd(0x29); // DISPON HAL_Delay(100); }

这段代码里的MADCTL值0xC8对应某种旋转方向,如果你的屏幕方向不对,改这个值即可。COLMOD的0x05表示16位RGB565,这是最常用的像素格式。

4.3 偏移量xoffset/yoffset:为什么有人要加2和1

ST7735S的显存是132x162,但1.8寸屏幕只显示128x160,所以存在行列偏移。不同厂家把屏幕贴在玻璃上的位置不同,偏移量也不同。常见的1.8寸128x160红板,偏移量可能是xoffset=0、yoffset=0;但有些模块需要xoffset=0、yoffset=0,而1.44寸128x128的模块可能需要xoffset=2、yoffset=1,或者xoffset=0、yoffset=32。

如果你发现图像右边或下边有黑边,或者图像整体错位,就是偏移量不对。解决方法是写一个测试程序:先全屏填红,再在四个角画不同颜色的方块,然后调整xoffset和yoffset,直到方块正好出现在屏幕边缘。这个调试过程比反复改初始化命令高效得多。

#define LCD_X_OFFSET 0 #define LCD_Y_OFFSET 0 void LCD_SetWindow(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { LCD_WriteCmd(0x2A); // 列地址 LCD_WriteData((x0 + LCD_X_OFFSET) >> 8); LCD_WriteData((x0 + LCD_X_OFFSET) & 0xFF); LCD_WriteData((x1 + LCD_X_OFFSET) >> 8); LCD_WriteData((x1 + LCD_X_OFFSET) & 0xFF); LCD_WriteCmd(0x2B); // 行地址 LCD_WriteData((y0 + LCD_Y_OFFSET) >> 8); LCD_WriteData((y0 + LCD_Y_OFFSET) & 0xFF); LCD_WriteData((y1 + LCD_Y_OFFSET) >> 8); LCD_WriteData((y1 + LCD_Y_OFFSET) & 0xFF); LCD_WriteCmd(0x2C); // 开始写显存 }

注意运算符优先级,加偏移后再移位,括号不能少。

4.4 MADCTL与显示方向:旋转90度、180度怎么设

MADCTL命令0x36控制显存的扫描方向,包括行地址顺序、列地址顺序、行列交换、垂直刷新顺序、RGB/BGR顺序等。常用值如下:

方向说明
0x00正常MY=0, MX=0, MV=0, BGR=0
0x08正常+BGR颜色顺序反过来
0xC0旋转180MX=1, MY=1
0xC8旋转180+BGR很多红板默认
0x60旋转90MX=1, MV=1
0x68旋转90+BGR横屏常用
0xA0旋转270MY=1, MV=1
0xA8旋转270+BGR另一种横屏

如果你要横屏显示,通常设置0x68或0xA8;竖屏显示用0xC8或0x08。设置完MADCTL后,LCD_SetWindow里的x和y范围也要跟着变。比如128x160的屏幕,横屏后逻辑分辨率变成160x128,画点函数的坐标上限也要调整。

4.5 颜色格式COLMOD与RGB565的字节顺序

COLMOD命令0x3A设置像素格式,0x05表示16位每像素,也就是RGB565。一个像素占两个字节,高字节在前,低字节在后。比如红色0xF800,先发0xF8,再发0x00;绿色0x07E0,先发0x07,再发0xE0;蓝色0x001F,先发0x00,再发0x1F。

#define COLOR_RED 0xF800 #define COLOR_GREEN 0x07E0 #define COLOR_BLUE 0x001F #define COLOR_WHITE 0xFFFF #define COLOR_BLACK 0x0000 #define COLOR_YELLOW 0xFFE0 #define COLOR_CYAN 0x07FF #define COLOR_MAGENTA 0xF81F

如果显示的颜色和预期是反的,比如红色显示成蓝色,通常是RGB和BGR顺序反了。改MADCTL里的BGR位,或者交换发送的两个字节顺序。注意,交换字节顺序会改变所有颜色,不如改BGR位来得干净。

5. 画点、填充与字符:把驱动变成能用的显示层

5.1 设置窗口命令0x2A/0x2B/0x2C

ST7735S画图不是直接指定坐标,而是先设置一个矩形窗口,然后连续写入数据,数据会自动填充窗口内的像素。0x2A设置列地址范围,0x2B设置行地址范围,0x2C开始写显存。窗口设置好后,每写入两个字节,列地址自动增加,到边界后换行。这个机制让填充矩形非常高效。

void LCD_DrawPoint(uint16_t x, uint16_t y, uint16_t color) { LCD_SetWindow(x, y, x, y); LCD_WriteData(color >> 8); LCD_WriteData(color & 0xFF); }

画点是最基本的操作,但逐个画点速度很慢。如果画一条线,最好先设置整条线的窗口,然后连续发送颜色数据。比如画水平线:

void LCD_DrawHLine(uint16_t x, uint16_t y, uint16_t len, uint16_t color) { LCD_SetWindow(x, y, x + len - 1, y); LCD_CS_L(); LCD_DC_H(); while (len--) { LCD_WriteByte(color >> 8); LCD_WriteByte(color & 0xFF); } LCD_CS_H(); }

这样比循环调用LCD_DrawPoint快很多,因为窗口只设置一次,CS只切换两次。

5.2 画点与填充矩形:连续写时的CS优化

填充矩形是界面开发中最常用的操作,比如清屏、画背景、画按钮。优化思路和画线一样:设置一次窗口,然后连续写数据。

void LCD_Fill(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, uint16_t color) { uint32_t size = (uint32_t)(x1 - x0 + 1) * (y1 - y0 + 1); LCD_SetWindow(x0, y0, x1, y1); LCD_CS_L(); LCD_DC_H(); while (size--) { LCD_WriteByte(color >> 8); LCD_WriteByte(color & 0xFF); } LCD_CS_H(); }

注意LCD_SetWindow最后发送了0x2C命令,此时CS被拉高。然后我们重新拉低CS,设置DC为高,开始连续写数据。这样避免了每写一个像素都切换CS和DC。实测这个优化对填充速度提升很明显,尤其是全屏清屏。

清屏可以调用LCD_Fill(0, 0, 127, 159, COLOR_BLACK)。如果你用了横屏,坐标范围要相应调整。

5.3 ASCII与汉字取模:PCtoLCD2002的参数怎么选

显示字符需要字库。ASCII字符常用8x16或6x12点阵,汉字常用16x16点阵。取模软件我一般用PCtoLCD2002,设置如下:

  • 点阵格式:阴码
  • 取模方式:逐列式
  • 取模走向:顺向(高位在前)
  • 输出数制:十六进制
  • 自定义格式:C51格式

这样生成的数组,每个字节代表一列8个像素,高位在上。显示时按列读取,然后逐位画点。比如8x16的ASCII字符,每个字符16个字节,每字节一列。显示函数逻辑如下:

void LCD_ShowChar(uint16_t x, uint16_t y, char ch, uint16_t color, uint16_t bg) { uint8_t i, j, temp; for (i = 0; i < 16; i++) { temp = ascii_8x16[ch - ' '][i]; for (j = 0; j < 8; j++) { if (temp & (0x80 >> j)) LCD_DrawPoint(x + j, y + i, color); else LCD_DrawPoint(x + j, y + i, bg); } } }

这个版本逐点画,速度慢,适合少量字符。如果要显示大量文字,建议先设置字符矩形窗口,然后按行组合字节,连续写入。汉字16x16的取模类似,每个字32字节,每行两个字节。

5.4 显示字符串与变量的常用封装

显示字符串就是循环调用LCD_ShowChar,注意字符宽度和换行。显示变量可以先格式化到缓冲区,再调用显示字符串。

void LCD_ShowString(uint16_t x, uint16_t y, const char *str, uint16_t color, uint16_t bg) { while (*str) { LCD_ShowChar(x, y, *str, color, bg); x += 8; if (x + 8 > 128) { x = 0; y += 16; } str++; } }

显示数字:

void LCD_ShowNum(uint16_t x, uint16_t y, int32_t num, uint16_t color, uint16_t bg) { char buf[12]; sprintf(buf, "%ld", (long)num); LCD_ShowString(x, y, buf, color, bg); }

sprintf会占用一些Flash和栈空间,资源紧张的芯片可以用自己写的整数转字符串函数。显示浮点数时,先转成整数和小数两部分,避免sprintf的浮点支持带来额外体积。

5.5 图片显示:Image2Lcd的导出设置

显示图片需要先把图片转成点阵数组。常用工具是Image2Lcd,设置如下:

  • 输出灰度:16位真彩色
  • 最大宽度和高度:128和160
  • 扫描方式:水平扫描
  • 字节顺序:高位在前
  • 包含图像头:不包含

导出的数组每两个字节一个像素,高字节在前,和RGB565一致。显示函数:

void LCD_ShowPicture(uint16_t x, uint16_t y, uint16_t w, uint16_t h, const uint8_t *pic) { LCD_SetWindow(x, y, x + w - 1, y + h - 1); LCD_CS_L(); LCD_DC_H(); for (uint32_t i = 0; i < (uint32_t)w * h; i++) { LCD_WriteByte(pic[2 * i]); LCD_WriteByte(pic[2 * i + 1]); } LCD_CS_H(); }

一张128x160的图片,数组大小是40960字节。如果放在STM32F103C8T6的Flash里,会占用不少空间,建议压缩或者放在外部Flash。显示时如果软件SPI速度慢,可以看到图像从上到下逐行刷出来,这是正常现象。

6. 刷屏速度实测:软件SPI的性能边界与提速办法

6.1 全屏刷新到底要多久:按字节估算

全屏128x160等于20480个像素,每个像素16位,共40960字节。每个字节8个SCK周期,共327680个SCK周期。如果软件SPI每个SCK周期耗时0.1微秒,那么总时间约32.8毫秒;如果每个周期0.5微秒,总时间约164毫秒。实际代码中还有循环开销、窗口设置、CS切换,所以软件SPI全屏刷新通常在100到300毫秒之间。

这个速度对于静态界面够了,但刷动画、滚动文字就会明显卡顿。我实测过HAL_GPIO_WritePin版本,全屏清屏大约1.2秒,慢到怀疑人生。换成直接操作BSRR/BRR后,降到200毫秒左右。再改成硬件SPI 18MHz,全屏约20毫秒。所以软件SPI的定位很明确:验证和低速显示可以,高速刷图必须换硬件SPI。

6.2 直接操作BSRR/BRR:省掉函数调用

提速的第一招就是前面说的,用宏直接操作BSRR和BRR。第二招是减少循环内的分支。比如把判断当前位是高还是低,改成查表或者用位运算技巧。第三招是把LCD_WriteByte声明为inline,让编译器把它内联到调用处,省掉函数调用和返回。

static inline void LCD_WriteByte(uint8_t dat) { for (uint8_t i = 0; i < 8; i++) { if (dat & 0x80) LCD_SDA_H(); else LCD_SDA_L(); LCD_SCK_H(); dat <<= 1; LCD_SCK_L(); } }

如果编译器支持,可以加__attribute__((always_inline))或者用宏完全展开。但完全展开会增大代码体积,需要权衡。我的经验是,先把功能跑通,再根据实际需要优化,不要一上来就写得很晦涩。

6.3 局部刷新与脏矩形:不必要不全屏刷

界面开发中,真正需要刷新的区域往往很小。比如只更新一个温度数值,没必要全屏重绘。可以记录哪些区域发生了变化,只对这些区域调用LCD_SetWindow和写数据,这就是脏矩形思想。软件SPI下,局部刷新能把刷新时间从几百毫秒降到几毫秒,体验提升巨大。

实现上,可以给每个UI元素记录上一次的位置和内容,变化时只重绘该元素。或者简单一点,把屏幕分成几个固定区域,比如标题栏、数据区、按钮区,哪个区域变了就重绘哪个区域。这样代码不复杂,但效果很明显。

6.4 什么时候必须换硬件SPI或DMA

如果你需要显示动态波形、视频、流畅动画,或者界面刷新率要求超过10帧每秒,软件SPI基本不够用。这时候应该换硬件SPI,最好再配上DMA。硬件SPI的引脚是固定的,需要提前规划。STM32F103的SPI1在PA5、PA6、PA7,SPI2在PB13、PB14、PB15。如果这些引脚被占用,可以考虑重映射或者换芯片型号。

换硬件SPI后,DC和CS仍然可以用GPIO控制,只有SCK和SDA交给SPI外设。发送函数改成HAL_SPI_Transmit,一次发送一个字节或一串字节。配合DMA,可以在后台刷屏,CPU去处理其他任务。这个迁移过程不难,但要注意SPI模式、数据宽度和字节顺序,下一章会详细说。

7. 白屏、花屏、偏移、颜色反了:实机排查链路

7.1 白屏与背光:先确认屏有没有亮

白屏是最常见的问题,但原因可能完全不同。第一步先看背光有没有亮。如果背光不亮,屏幕一片黑,不是白屏。背光不亮查LED/BLK引脚电压、限流电阻、供电。如果背光亮但屏幕全白,说明屏幕没有收到有效初始化命令,或者复位失败。

排查顺序:先测VCC是否3.3V,再测RST在上电后是否有低电平脉冲,再测CS、DC、SCK、SDA是否有波形。如果SCK完全没有波形,说明代码没跑到发送函数,或者GPIO配置错了。如果有波形但仍然白屏,重点查初始化序列里的SLPOUT和DISPON有没有发,延时够不够。

7.2 花屏与初始化失败:从复位和SPI速率查起

花屏通常是数据错位或者初始化不完整。先降低软件SPI速度,加一些延时,看是否稳定。如果降速后正常,说明之前SCK太快,超出了模块的时序要求。如果降速后仍然花屏,检查接线是否松动,尤其是SDA和SCK有没有接反。软件SPI下接反不会烧,但会花屏。

另一个常见原因是初始化序列不完整,比如缺少电源控制命令或者伽马校正。不同厂家的ST7735S模块对这些参数敏感程度不同,最好用厂家提供的初始化代码。如果找不到,可以多试几个版本的初始化序列,找到能稳定显示的那个。

7.3 图像偏移与显示不全:窗口偏移与分辨率

图像偏移表现为左边或上边有黑边,或者图像显示不完整。这是窗口偏移量不对。前面说过,调整LCD_X_OFFSET和LCD_Y_OFFSET,用全屏填充和边角方块来定位。注意,如果MADCTL设置了旋转,偏移方向也会变,需要重新调整。

还有一种情况是显示不全,比如只显示了前128行中的一部分。检查LCD_SetWindow里的y1是否超过159,以及MADCTL的MV位是否把行列交换了。横屏时逻辑分辨率是160x128,如果代码里还按128x160设置窗口,就会出错。

7.4 颜色颠倒与反色:BGR位和INVON

颜色颠倒通常有两种:一种是RGB顺序反了,红色变蓝色,绿色不变;另一种是整体反色,黑变白。前者改MADCTL的BGR位,或者交换发送字节顺序;后者改INVON/INVOFF命令。注意,反色和颜色顺序是两回事,不要混在一起调。

如果只有部分颜色不对,比如红色正常但蓝色偏紫,可能是伽马校正参数不匹配。可以先注释掉伽马校正命令,用默认值显示,看颜色是否正常。如果正常,再逐步加回伽马参数。

7.5 常见问题速查表

现象优先检查解决方法
白屏背光、复位、电源测BLK电压、RST时序、3.3V供电
花屏接线、SPI速率降低SCK速度、检查SDA/SCK是否接反
偏移窗口偏移调整xoffset/yoffset
颜色反BGR/INV改MADCTL的BGR位或INVON/INVOFF
方向不对MADCTL改MX/MY/MV组合
显示条纹电源、刷新加滤波电容、降低刷新率
汉字乱码取模设置逐列式、顺向、高位在前
刷屏慢软件SPI硬件SPI、DMA、局部刷新
上电偶尔不亮复位时序增加复位低电平时间和上电延时

8. 从软件SPI迁移到硬件SPI:引脚、配置与代码改动

8.1 硬件SPI的引脚复用与时钟配置

以STM32F103的SPI1为例,SCK在PA5,MISO在PA6,MOSI在PA7。TFT只用MOSI,MISO不用,可以不配置。配置步骤:开启GPIOA和SPI1时钟,GPIOA5和PA7设为复用推挽输出,SPI1设为主机模式、8位数据、CPOL=0、CPHA=0、MSB first、分频2或4。分频2对应36MHz,分频4对应18MHz,先放慢一点,稳定后再提速。

void LCD_SPI_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; SPI_HandleTypeDef hspi1; __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_SPI1_CLK_ENABLE(); GPIO_InitStruct.Pin = GPIO_PIN_5 | GPIO_PIN_7; GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); hspi1.Instance = SPI1; hspi1.Init.Mode = SPI_MODE_MASTER; hspi1.Init.Direction = SPI_DIRECTION_1LINE; hspi1.Init.DataSize = SPI_DATASIZE_8BIT; hspi1.Init.CLKPolarity = SPI_POLARITY_LOW; hspi1.Init.CLKPhase = SPI_PHASE_1EDGE; hspi1.Init.NSS = SPI_NSS_SOFT; hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_4; hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB; hspi1.Init.TIMode = SPI_TIMODE_DISABLE; hspi1.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE; HAL_SPI_Init(&hspi1); __HAL_SPI_ENABLE(&hspi1); }

实际项目中我会把hspi1设为全局变量,方便其他地方调用。注意SPI_DIRECTION_1LINE表示只用MOSI,节省一根线。

8.2 软件片选与硬件片选的区别

硬件SPI的NSS可以配置为软件NSS,也就是由代码控制CS引脚,也可以是硬件NSS,由SPI外设自动控制。我建议用软件NSS,因为TFT的CS需要在命令和数据之间保持低电平,硬件NSS在每字节后可能自动拉高,反而不方便。软件NSS就是把CS当普通GPIO,和软件SPI时一样控制。

DC仍然用GPIO控制,和软件SPI一样。发送命令时拉低DC,调用HAL_SPI_Transmit发送一个字节;发送数据时拉高DC,调用HAL_SPI_Transmit发送数据。CS在连续传输期间保持低电平。

8.3 迁移时最容易忽略的SPI模式与数据宽度

最容易忽略的是SPI模式和软件SPI不一致。软件SPI用的是模式0,硬件SPI也必须配置成CPOL=0、CPHA=0,否则数据错位。还有一个坑是数据宽度,STM32 SPI可以配成8位或16位。如果配成16位,发送一个16位数据会自动产生16个时钟,适合刷像素;但初始化命令是8位,需要切换数据宽度,比较麻烦。我建议统一用8位,自己拆分高低字节,代码简单,兼容性好。

另外,硬件SPI发送函数是阻塞的,HAL_SPI_Transmit会等到发送完成。如果刷大图,CPU会被占用。要真正释放CPU,需要配合DMA。DMA配置稍微复杂,但效果明显,尤其是刷图片和动画。

8.4 迁移后的性能提升与实测对比

我做过一个简单测试,同样填充全屏红色,软件SPI直接寄存器版本约200毫秒,硬件SPI 18MHz约20毫秒,提升接近10倍。如果开DMA,CPU占用几乎为零,刷屏期间还能处理按键和串口。对于需要实时显示波形或者刷新率要求高的场景,硬件SPI是必须的。

不过,硬件SPI也有代价:引脚固定,可能与其他外设冲突;配置项多,调试时如果不出图,排查起来比软件SPI麻烦。所以我的习惯是保留软件SPI的底层函数作为备用,在硬件SPI调不通时,切回软件SPI验证屏幕本身没问题,这样能快速定位是硬件SPI配置问题还是屏幕问题。

最后再分享一个小技巧:如果你在软件SPI和硬件SPI之间切换,可以把写命令、写数据、设置窗口这些上层函数保持不变,只替换底层的写字节实现。这样上层显示代码完全不用改,迁移成本很低。我平时会把底层SPI操作单独放在一个文件里,用宏选择软件或硬件实现,调试和量产都很方便。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询