手里攥着一块1.8寸的TFT-LCD小板,插上STM32,点亮它那一刻的成就感,很多人第一次做嵌入式显示项目时都体验过。但紧接着的问题就来了:屏幕亮了却全白、颜色红蓝颠倒、刷一屏要好几秒、方向怎么调都不对。我做过的基于STM32的小屏项目里,1.8寸TFT-LCD(驱动芯片多为ST7735S,128x160分辨率)出现频率极高,从数字温湿度计、小型仪表盘到智能台灯的状态面板,几乎都是它在撑场面。而这个尺寸的屏,用软件SPI驱动是最常见、也最容易上手的一种方式——不挑引脚、不占专用外设、任何一块STM32都能跑,代价是要自己抠时序、自己算偏移。这篇就把从接线、GPIO操作、软件SPI通信代码,到初始化序列、刷屏优化、问题排查的完整链路摊开讲一遍,配合可直接抄的代码,新手能跑通,做过几块屏的人也能从里面挑出几条以前没注意到的细节。
1. 先弄明白这块屏和这套方案到底是怎么回事
1.1 1.8寸TFT-LCD模块的常见形态与驱动芯片
市面上说"1.8寸TFT",绝大多数指的是对角线1.8英寸、物理分辨率128x160(竖屏)或160x128(横屏视角)的小尺寸彩屏模块。模块本身不只是一块玻璃,背面贴着一颗驱动IC,最常见的是ST7735S,也有ST7735R、ST7735B,以及少部分用ST7789的加长版。这些芯片的显存都是RGB565格式,也就是每个像素用16位表示:高5位红、中间6位绿、低5位蓝。绿色多一位不是设计者偏心,而是人眼对绿色亮度最敏感,多一位能明显改善渐变的观感,这是显示领域的通用做法。
模块正面是屏,背面是一小块PCB,上面引出8个左右的排针。你如果买过会发现,不同批次的板子丝印不一样,有的写SDA/SCL,有的写MOSI/SCK,还有的写SDA/SCLK/A0/RES/CS/VCC/GND/BLK,但用途是一一对应的。购买前一定要确认驱动IC型号和分辨率,因为ST7735和ST7789的初始化序列完全不同,用错序列的表现就是白屏或者花屏,而且你还很难一眼看出问题在哪。
模块上还会带一颗3.3V的LDO稳压和电平转换,有些板子还带SD卡座。带SD卡的版本会多出几个引脚用于SPI共享,初学阶段可以直接不管它,先把屏点亮再说。
1.2 为什么这个项目优先选软件SPI而不是硬件SPI
STM32的硬件SPI外设很好用,配置好之后一个字节只需要几次寄存器写入,硬件自动完成时钟翻转,速度轻松上到十几MHz。那为什么还有大量项目坚持用软件SPI?
第一个原因是引脚自由度。硬件SPI的SCK、MOSI必须落在指定的复用引脚上,比如STM32F103的SPI1只能在PA5/PA7,SPI2只能在PB13/PB15。而你手上的板子可能已经把这些引脚分给了别的外设——比如PA5/PA6/PA7被JTAG占了,或者被串口、定时器占用。软件SPI想用哪个GPIO就用哪个,挪一下宏定义就行,PCB已经打好的情况下这点特别关键。
第二个原因是便于观察和调试。软件SPI的时序是你自己写出来的,逻辑分析仪抓波形的时候一眼就能对上;遇到屏幕不亮,检查SCL有没有翻转、SDA有没有数据,比排查硬件SPI的时钟相位极性配置要直观得多。
第三个原因是片选控制的灵活性。硬件SPI有NSS引脚,但很多时候要配合软件片选使用;一主多从的场景下,软件片选配合软件SPI反而更好控制时序边界。对于只有一块屏、刷新率要求不高的项目,软件SPI完全够用。
代价也很明确:软件SPI靠CPU循环翻转电平,一个字节要循环8次,每次好几个GPIO操作,全屏刷新时CPU基本被占满。所以软件SPI适合小面积刷新、低频更新的场景,如果你要做动画或者视频流,还是老老实实上硬件SPI加DMA。
1.3 开始动手前需要准备的东西
硬件方面,需要一块STM32最小系统板(F103C8T6这种最常见,也最便宜)、一块1.8寸TFT模块、若干杜邦线、一个ST-Link或者串口下载工具。如果手里有逻辑分析仪会事半功倍,没有的话也能靠现象排查,只是慢一些。
软件方面,开发环境用Keil MDK或者STM32CubeIDE都行。用标准库的话,重点是GPIO初始化和延时函数;用HAL库的话,思路一样,只是把寄存器操作换成HAL_GPIO_WritePin。我下面给的代码以标准库风格为主,因为它的GPIO操作最贴近寄存器,改到HAL库也只需要替换几个函数名。
再准备一份ST7735S的初始化参数表。网上流传的版本很多,参数略有差异,但大体一致。我会在第四节给出一份经过实测可用的版本,并解释每个参数块是干什么的,这样万一你的屏表现不同,你也能自己微调。
2. 接线与引脚规划,想清楚再插线
2.1 八个引脚各自负责什么
先把引脚含义理一遍,别看它简单,接错一根就白屏:
| 引脚丝印 | 全称 | 作用 | 接法 |
|---|---|---|---|
| VCC | 电源 | 逻辑供电 | 接3.3V,不要接5V |
| GND | 地 | 公共地 | 接GND |
| CS | Chip Select | 片选,低电平有效 | 接任意GPIO,或直接拉低 |
| RES / RST | Reset | 硬复位,低电平复位 | 接任意GPIO |
| DC / A0 / RS | Data/Command | 区分命令与数据 | 接任意GPIO,必须可控 |
| SDA / MOSI | 数据线 | 主机输出 | 接任意GPIO |
| SCL / SCK | 时钟线 | 主机输出 | 接任意GPIO |
| BLK / LED | 背光 | 控制背光开关 | 接GPIO或直接接3.3V |
DC这根线是TFT屏的关键,它不是数据线,而是一个标志位:拉低表示接下来发的字节是命令,拉高表示是数据。很多新手把它接到固定的高电平或者地上,结果屏要么不初始化,要么初始化命令全被当成像素数据写进去,自然什么都不显示。RES、CS、DC、SDA、SCL这五根线必须接到可控GPIO上。
2.2 供电和背光,别想当然
供电这块有两条硬规矩。第一,逻辑供电用3.3V。虽然有些模块标注支持5V,但内部LDO和电平转换的质量参差不齐,STM32的IO是3.3V电平,用5V供电时如果模块没有做好电平隔离,长期跑会有风险。第二,背光如果是直接引出LED正极的模块,必须加限流电阻或者用PWM控制,直接怼3.3V有可能烧背光。带LDO和恒流驱动的模块一般已经把限流做在板子上了,接上去就行。
背光控制有两种常见做法。一种是把BLK接3.3V,屏幕常亮,最简单。另一种是把BLK接到一个有PWM输出的GPIO上,通过调节占空比做亮度调节。做智能台灯、夜间仪表这类项目时,后者体验会好很多。注意PWM频率建议在1kHz以上,低于200Hz人眼会明显感到频闪。
电源去耦也别省。模块工作瞬间电流会有波动,建议在VCC和GND之间并一个0.1uF的瓷片电容,位置尽量靠近模块排针。这个细节在面包板上调试时特别有用,能避免一些莫名其妙的随机花屏。
2.3 引脚分配要避开的几个大坑
分配GPIO的时候,有几类引脚要主动避开:
- 调试口:STM32F103的PA13/PA14是SWD,PA15/PB3/PB4默认是JTAG的JTDI/JTDO/JTRST。如果你不打算禁用JTAG,就别把这些脚拿来做普通IO,否则下次下载程序会直接连不上芯片。要用的话,得先执行JTAG复用功能重映射,或者干脆在CubeMX里把调试模式设成SWD-only。
- 晶振脚:PC14/PC15、PD0/PD1这类接外部晶振的引脚,用了会导致时钟起不来。
- BOOT脚:BOOT0、BOOT1影响启动模式,不要动。
- 模拟外设脚:如果项目里还要用ADC、DAC,尽量别把屏的引脚放在同一组相邻通道上,虽然不冲突,但布线时会很难受。
一个经过验证的分配方案是这样的,用STM32F103C8T6,屏挂在GPIOB上:
#define LCD_SCL_PIN GPIO_Pin_13 #define LCD_SDA_PIN GPIO_Pin_15 #define LCD_RES_PIN GPIO_Pin_12 #define LCD_DC_PIN GPIO_Pin_11 #define LCD_CS_PIN GPIO_Pin_10 #define LCD_BLK_PIN GPIO_Pin_9 #define LCD_PORT GPIOB注意PB13/PB15正好是SPI2的SCK和MOSI,这个分配的好处是:以后如果嫌软件SPI慢,改硬件SPI只需要改初始化和写字节函数,接线一根不用动。这是我在做项目时习惯留的后路,值得参考。
提示:杜邦线不要拉太长。软件SPI的时钟频率虽然不高,但线长了之后信号边沿变缓,加上没有阻抗匹配,容易出现偶发数据错误。20cm以内最稳妥。
3. 软件SPI的时序,一次讲透
3.1 模式0的时序拆解
SPI有四种模式,由CPOL(时钟极性)和CPHA(时钟相位)组合而成。ST7735S支持模式0和模式3,日常都用模式0:空闲时SCK为低,数据在SCK的上升沿被从机采样,主机在下降沿更新数据。
用生活化的说法解释:SCL是"敲门的节奏",SDA是"说出去的话"。模式0的意思是,你先在SCL为低的时候把要说的话准备好放在SDA上,然后SCL拉高——"敲一下门",屏幕就在这一瞬间把你刚才说的话记下来;敲完之后SCL拉低,你再换下一句话。整个字节8位,就敲门8次。
对应的代码动作顺序是:
- SCL拉低
- 根据当前要发的位,设置SDA为高或低
- SCL拉高(屏幕在此刻采样SDA)
- 数据左移一位,准备下一位
- 重复8次
很多人写反了顺序,先拉高SCL再准备数据,结果屏幕采样到的是上一位的电平,显示出来数据整体错位一位,现象是图像内容对但位置和颜色都乱。这个错误非常隐蔽,因为屏幕不会完全不亮,只是内容不对,容易往"初始化参数错了"的方向排查。
3.2 GPIO初始化与位操作封装
软件SPI对速度敏感,所以位操作要尽量短。用标准库的GPIO_WriteBit会经过函数调用,开销不小;直接操作BSRR和BRR寄存器是最快的,因为STM32的GPIO置位/复位寄存器是原子操作,一条指令搞定,不需要读-改-写。
#define LCD_SCL_H() (LCD_PORT->BSRR = LCD_SCL_PIN) #define LCD_SCL_L() (LCD_PORT->BRR = LCD_SCL_PIN) #define LCD_SDA_H() (LCD_PORT->BSRR = LCD_SDA_PIN) #define LCD_SDA_L() (LCD_PORT->BRR = LCD_SDA_PIN) #define LCD_RES_H() (LCD_PORT->BSRR = LCD_RES_PIN) #define LCD_RES_L() (LCD_PORT->BRR = LCD_RES_PIN) #define LCD_DC_H() (LCD_PORT->BSRR = LCD_DC_PIN) #define LCD_DC_L() (LCD_PORT->BRR = LCD_DC_PIN) #define LCD_CS_H() (LCD_PORT->BSRR = LCD_CS_PIN) #define LCD_CS_L() (LCD_PORT->BRR = LCD_CS_PIN)GPIO初始化时,这几根全部配置成推挽输出、50MHz速度、不上拉不下拉。推挽输出比开漏输出更合适,因为开漏需要外部上拉电阻才能拉高,上升沿会变缓,时序余量变小。
void LCD_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin = LCD_SCL_PIN | LCD_SDA_PIN | LCD_RES_PIN | LCD_DC_PIN | LCD_CS_PIN | LCD_BLK_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(LCD_PORT, &GPIO_InitStructure); LCD_CS_H(); LCD_SCL_H(); // 空闲态按模式0应该是低,后面写字节时会拉低 LCD_BLK_H(); // 开背光 }有个细节值得强调:初始化时先把CS拉高、DC拉高,让总线处于确定状态,避免上电瞬间的随机电平被屏误认为是命令。这个习惯在多个外设共享总线时尤其重要。
3.3 写一个字节的完整实现
void LCD_Writ_Bus(uint8_t dat) { uint8_t i; for (i = 0; i < 8; i++) { LCD_SCL_L(); // 时钟拉低,准备数据 if (dat & 0x80) LCD_SDA_H(); else LCD_SDA_L(); dat <<= 1; // 先移一位,为下次准备 LCD_SCL_H(); // 上升沿,从机采样 } LCD_SCL_L(); // 收尾拉低,保持空闲低电平 }这段代码每次循环做两次GPIO写操作加上一次判断和移位,在72MHz的F103上跑下来,一个字节大概2到3微秒,折算成时钟频率约3到4MHz。这个速度对于128x160的全屏刷新来说,一帧要写40960个字节,大概0.1秒左右,翻页、切菜单完全够用,做简单动画也能接受。
如果要再快一点,可以把8次循环展开,用宏或者直接写8遍,省掉循环计数和跳转。也可以对dat做位判断时用查表法,但实测下来循环展开收益更明显。还有一个技巧是把SDA的设置写成条件赋值:
LCD_SDA_H(); if (!(dat & 0x80)) LCD_SDA_L();因为拉高通常比拉低少一条指令分支,先拉高再按需拉低,平均能省一点点时间。这些优化在几百字节的场景看不出来,全屏刷新时能差出几毫秒。
3.4 命令和数据的分层封装
有了写字节的底层函数,往上就能封出命令和数据的接口:
void LCD_WR_REG(uint8_t reg) { LCD_DC_L(); // 命令标志 LCD_CS_L(); LCD_Writ_Bus(reg); LCD_CS_H(); } void LCD_WR_DATA8(uint8_t dat) { LCD_DC_H(); // 数据标志 LCD_CS_L(); LCD_Writ_Bus(dat); LCD_CS_H(); }这里每个字节都翻转一次CS,是"软件片选"的典型做法。它的好处是每次传输都有清晰的边界,缺点是CS翻转本身也要花时间。全屏写像素时如果还这样一个个字节翻CS,开销会很大。所以刷像素时要用下面这种批量写法,把CS拉低一次,连续写完整屏数据再拉高:
void LCD_WR_DATA_Bulk(uint8_t *buf, uint32_t len) { uint32_t i; LCD_DC_H(); LCD_CS_L(); for (i = 0; i < len; i++) { LCD_Writ_Bus(buf[i]); } LCD_CS_H(); }注意:CS在连续写数据的整个过程中必须保持低电平,中间不能松开。ST7735S在CS拉高时会把当前传输视为结束,再拉低时重新开始解析,如果你在每个数据字节之间翻了CS,屏幕会把每个字节当成新的起始字节处理,结果就是数据错乱。
4. ST7735S初始化序列,逐条读懂而不是照抄
4.1 复位时序不能省
初始化之前必须先做一次硬复位,即使芯片内部有软复位指令,硬复位也建议保留,因为它能把一些上电时的异常状态彻底清掉。
LCD_RES_L(); delay_ms(20); LCD_RES_H(); delay_ms(120);复位低电平至少保持10微秒,我一般给20毫秒,宽裕一些。拉高之后要等120毫秒再发指令,因为ST7735S内部有上电稳定流程,太早发命令会被忽略。这个延时不加,现象就是偶发白屏——有时候亮有时候不亮,重启几次又能好,很容易误判成接触不良。
4.2 帧率、电源与Gamma参数
下面这份序列我实测在大多数红板1.8寸ST7735S上可用:
LCD_WR_REG(0x01); // 软复位 delay_ms(120); LCD_WR_REG(0x11); // 退出睡眠 delay_ms(120); LCD_WR_REG(0xB1); // 帧率控制,正常模式 LCD_WR_DATA8(0x01); LCD_WR_DATA8(0x2C); LCD_WR_DATA8(0x2D); LCD_WR_REG(0xB2); // 空闲模式 LCD_WR_DATA8(0x01); LCD_WR_DATA8(0x2C); LCD_WR_DATA8(0x2D); LCD_WR_REG(0xB3); // 局部模式 LCD_WR_DATA8(0x01); LCD_WR_DATA8(0x2C); LCD_WR_DATA8(0x2D); LCD_WR_DATA8(0x01); LCD_WR_DATA8(0x2C); LCD_WR_DATA8(0x2D); LCD_WR_REG(0xB4); // 显示反转控制 LCD_WR_DATA8(0x07); LCD_WR_REG(0xC0); // 电源控制1 LCD_WR_DATA8(0xA2); LCD_WR_DATA8(0x02); LCD_WR_DATA8(0x84); LCD_WR_REG(0xC1); // 电源控制2 LCD_WR_DATA8(0xC5); LCD_WR_REG(0xC2); // 电源控制3 LCD_WR_DATA8(0x0A); LCD_WR_DATA8(0x00); LCD_WR_REG(0xC3); // 电源控制4 LCD_WR_DATA8(0x8A); LCD_WR_DATA8(0x2A); LCD_WR_REG(0xC4); // 电源控制5 LCD_WR_DATA8(0x8A); LCD_WR_DATA8(0xEE); LCD_WR_REG(0xC5); // VCOM控制 LCD_WR_DATA8(0x0E);0xB1到0xB3这三组是帧率分频,值越大帧率越低,闪烁越明显但功耗越低。0xC0到0xC4这组是电源升压相关的,直接决定屏幕亮度和对比度。如果屏幕整体发暗,可以试试微调0xC0的第一个参数;如果偏色,多半是Gamma的问题。
Gamma校正这块分正负两组:
LCD_WR_REG(0xE0); // 正极性Gamma LCD_WR_DATA8(0x02); LCD_WR_DATA8(0x1C); LCD_WR_DATA8(0x07); LCD_WR_DATA8(0x12); LCD_WR_DATA8(0x37); LCD_WR_DATA8(0x32); LCD_WR_DATA8(0x29); LCD_WR_DATA8(0x2D); LCD_WR_DATA8(0x29); LCD_WR_DATA8(0x25); LCD_WR_DATA8(0x2B); LCD_WR_DATA8(0x39); LCD_WR_DATA8(0x00); LCD_WR_DATA8(0x01); LCD_WR_DATA8(0x03); LCD_WR_DATA8(0x10); LCD_WR_REG(0xE1); // 负极性Gamma LCD_WR_DATA8(0x03); LCD_WR_DATA8(0x1D); LCD_WR_DATA8(0x07); LCD_WR_DATA8(0x06); LCD_WR_DATA8(0x2E); LCD_WR_DATA8(0x2C); LCD_WR_DATA8(0x29); LCD_WR_DATA8(0x2D); LCD_WR_DATA8(0x2E); LCD_WR_DATA8(0x2E); LCD_WR_DATA8(0x37); LCD_WR_DATA8(0x3F); LCD_WR_DATA8(0x00); LCD_WR_DATA8(0x00); LCD_WR_DATA8(0x02); LCD_WR_DATA8(0x10);Gamma参数的作用是修正液晶在不同灰阶下的亮度响应曲线。不同厂商的面板Gamma特性不一样,所以你会看到网上版本繁多。我建议先用一份通用参数跑通,然后用一张256级灰阶图观察过渡是否平滑,有明显断层或者色块的话再换别的版本试。
最后是收尾:
LCD_WR_REG(0x36); LCD_WR_DATA8(0xC8); // 扫描方向 LCD_WR_REG(0x3A); LCD_WR_DATA8(0x05); // 16位色,RGB565 LCD_WR_REG(0x21); // 打开反显 LCD_WR_REG(0x13); // 正常显示模式 delay_ms(10); LCD_WR_REG(0x29); // 打开显示 delay_ms(100);0x21这条反显指令容易被忽略。部分1.8寸模块的面板出厂就是反显特性,不加这条会显示成"照片底片"效果——白变黑、红变青。如果你加了0x21反而颜色不对,说明你的模块不需要反显,直接去掉即可。
4.3 0x36扫描方向寄存器与横竖屏切换
0x36(MADCTL)是解决"方向不对"的核心。它每一位控制一个维度的翻转:
| 位 | 名称 | 作用 |
|---|---|---|
| D7 | MY | 行地址顺序,1为从下到上 |
| D6 | MX | 列地址顺序,1为从右到左 |
| D5 | MV | 行列交换,1为横屏 |
| D4 | ML | 垂直刷新顺序 |
| D3 | RGB | 1为RGB顺序,0为BGR |
| D2 | MH | 水平刷新顺序 |
0xC8这个值按二进制拆开是1100 1000:MY=1、MX=1、RGB=1。MX和MY同时为1相当于把画面旋转180度。配合0x2A/0x2B的开窗设置,就得到常见的竖屏显示效果。
如果要做横屏(160x128),把MV置1、MX和MY清零,也就是0x68,然后在代码里把宽高宏对调:
#define LCD_W 160 #define LCD_H 128如果颜色出现红蓝互换,把D3的RGB位改成0,即BGR模式,值变成0xC0。这个调整比在代码里交换颜色字节要干净得多。
4.4 开窗与连续写显存
ST7735S没有暴露完整的显存地址空间,写像素之前必须先用命令设置一个矩形区域,之后写入的数据会从这个区域的左上角开始,按行自动填充,填满一行自动换下一行,填满整个区域后指针回到起点。
void LCD_SetWindow(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { LCD_WR_REG(0x2A); // 列地址设置 LCD_WR_DATA8(x0 >> 8); LCD_WR_DATA8(x0 & 0xFF); LCD_WR_DATA8(x1 >> 8); LCD_WR_DATA8(x1 & 0xFF); LCD_WR_REG(0x2B); // 行地址设置 LCD_WR_DATA8(y0 >> 8); LCD_WR_DATA8(y0 & 0xFF); LCD_WR_DATA8(y1 >> 8); LCD_WR_DATA8(y1 & 0xFF); LCD_WR_REG(0x2C); // 开始写显存 }注意列地址对应的是X,行地址对应的是Y,两个不要写反。写反的现象是画点函数画出来的是转置图形,比如画横线出来成了竖线。
4.5 偏移量问题,1.8寸屏最容易踩的坑
很多人第一次用1.8寸屏时会发现:图像整体往左上角偏了,边缘有花边或者黑边,这就是offset(偏移)问题。原因是ST7735S的显存控制器设计支持最大132x162的显存,而1.8寸面板实际只有128x160,多出来的部分在扫描时会被省略,输出窗口需要往回偏一段才能对齐。
常见的偏移组合有这么几种:
| 模块类型 | X偏移 | Y偏移 |
|---|---|---|
| 1.8寸128x160 竖屏 | 0 | 0 |
| 1.8寸128x160 部分批次 | 0 | 1 |
| 1.44寸128x128 | 2 | 1 |
| 0.96寸80x160 | 26 | 1 |
1.8寸的多数模块偏移是0,但确实有批次需要Y偏移1。判断方法很简单:全屏填充一个纯色(比如蓝色),如果最上面一行是黑线,说明Y偏移少了1;如果图形整体错半个位置,检查X偏移。调整时只需要在开窗函数里加:
#define X_OFFSET 0 #define Y_OFFSET 0 // 开窗时写成 LCD_SetWindow(x0 + X_OFFSET, y0 + Y_OFFSET, x1 + X_OFFSET, y1 + Y_OFFSET);这个偏移不要凭猜,用纯色填充法一分钟就能确定。我见过有人花两天时间怀疑SPI时序,最后发现只是Y偏移差1。
5. 显示层实现,从画点到显示图片
5.1 画点与填充矩形
有了开窗函数,画点就很简单:
void LCD_DrawPoint(uint16_t x, uint16_t y, uint16_t color) { LCD_SetWindow(x, y, x, y); LCD_DC_H(); LCD_CS_L(); LCD_Writ_Bus(color >> 8); LCD_Writ_Bus(color & 0xFF); LCD_CS_H(); }注意16位颜色是高字节先发。如果先发低字节,红色会显示成蓝色,绿色会显示成另一种绿色,整体色调偏得莫名其妙。这个字节序问题和MADCTL的RGB位是两个独立的坑,都调对了才能得到正确的颜色。
填充矩形比逐点写快得多,因为只开一次窗,后面的数据连续写入:
void LCD_Fill(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, uint16_t color) { uint32_t i, n; n = (uint32_t)(x1 - x0 + 1) * (y1 - y0 + 1); LCD_SetWindow(x0, y0, x1, y1); LCD_DC_H(); LCD_CS_L(); for (i = 0; i < n; i++) { LCD_Writ_Bus(color >> 8); LCD_Writ_Bus(color & 0xFF); } LCD_CS_H(); }全屏清屏就是LCD_Fill(0, 0, LCD_W - 1, LCD_H - 1, color)。在我的F103+软件SPI方案下,清一次屏大约120到150毫秒,肉眼能感觉到一下刷过去,但可以接受。如果嫌慢,可以把颜色高低字节做判断后各写一次,或者用前面说的循环展开技巧。
画线、画圆、画矩形边框都可以在画点的基础上实现。Bresenham画线算法是标准做法,核心思想是用整数累加误差代替浮点运算,在STM32这种没有FPU的芯片上很有必要。画线代码这里不展开,网上资料很多,重点是把画点函数的性能做上去,上层图形函数的效率就跟着上来了。
5.2 字符取模与显示
显示字符要解决两件事:字库怎么存,以及怎么快速刷到屏上。
字库最常用的方式是取模软件(比如PCtoLCD2002)生成点阵数组。以12x6的ASCII字符为例,每个字符12行、每行1个字节,共12字节;用"逐行式、顺向、高位在前"的取模方式生成的数组,写入顺序正好和屏幕扫描顺序一致。
const uint8_t ascii_12x6[][12] = { {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00}, // 空格 // ... 其他字符 }; void LCD_ShowChar(uint16_t x, uint16_t y, char ch, uint16_t fc, uint16_t bc) { uint8_t i, j, byte; uint16_t idx = (uint16_t)(ch - ' '); LCD_SetWindow(x, y, x + 5, y + 11); LCD_DC_H(); LCD_CS_L(); for (i = 0; i < 12; i++) { byte = ascii_12x6[idx][i]; for (j = 0; j < 6; j++) { if (byte & (0x80 >> j)) { LCD_Writ_Bus(fc >> 8); LCD_Writ_Bus(fc & 0xFF); } else { LCD_Writ_Bus(bc >> 8); LCD_Writ_Bus(bc & 0xFF); } } } LCD_CS_H(); }这段代码写12x6的一个字符要循环72次像素,每次两个字节,也就是144次写字节调用,大致0.4毫秒。显示一行20个字符需要8毫秒左右,做成每秒刷新几次的温湿度计完全没问题。
中文字库比较占空间,常用的16x16宋体字库大约有200KB,F103C8T6的64KB Flash放不下全套,通常只取项目需要的几十个汉字。用Flash换字库是最省事的做法,但要注意字库数组加const放到Flash段,否则会吃掉宝贵的RAM。
5.3 图片显示与RGB565数据组织
显示图片需要先把图片转成RGB565数组。工具有Image2Lcd、PCtoLCD等,输出格式选"16位真彩、RGB565、高位在前、不含图像头数据"。转换出来的数组用const修饰,编译时自动进Flash。
extern const uint8_t gImage_logo[]; void LCD_ShowPicture(uint16_t x, uint16_t y, uint16_t w, uint16_t h, const uint8_t *pic) { uint32_t i, n; n = (uint32_t)w * h * 2; LCD_SetWindow(x, y, x + w - 1, y + h - 1); LCD_DC_H(); LCD_CS_L(); for (i = 0; i < n; i++) { LCD_Writ_Bus(pic[i]); } LCD_CS_H(); }128x160的全屏图片数据是40960字节,接近40KB。F103C8T6只有64KB Flash,放一张全屏图就占了三分之二,所以实际项目里图片通常是局部的小图标,比如24x24的状态图标,一张才1KB出头。
如果图片多,可以外挂SPI Flash,把图片数据存在外部芯片里,显示时边读边刷。这时候总线占用就成了瓶颈,软件SPI同时驱动屏幕和Flash会互相拖慢,需要仔细安排时序。
5.4 局部刷新与双缓冲
软件SPI最大的短板是全屏刷新慢,所以优化方向不是"刷得更快",而是"刷得更少"。
局部刷新的思路是:只重绘发生变化的区域。比如显示温度值,只在温度变化时才更新数字所在的那个小矩形,其他区域不动。这样一次刷新只有几百个像素,几毫秒就完成,视觉上完全看不出来。
如果需要更平滑的效果,可以做双缓冲:在RAM里开两块显存,一块显示、一块绘制,绘制完成后整体拷贝过去。但对128x160的屏来说,一块显存就要40KB,F103C8T6的20KB RAM根本放不下,所以这个方案在小容量MCU上不现实。更实用的折中方案是"脏矩形"标记,记录本帧哪些区域需要更新,只对这些区域重绘。
另一个技巧是把界面做成静态底图加动态数据。开机时先把背景、边框、标签这些不变的内容画一遍,之后只更新数值区域。这种做法在数字钟、仪表盘类项目里非常常见,效果也最好。
6. 软件SPI提速,能榨出来的性能其实不少
6.1 直接寄存器操作与位带
前面用的是BSRR/BRR寄存器操作,已经比库函数快很多。如果想再进一步,可以启用Cortex-M3的位带功能。位带把外设区的每一位映射到别名区,操作别名区的一个32位字就等于操作某一位,可以省掉移位和屏蔽。
#define BITBAND(addr, bitnum) ((0x42000000 + ((addr - 0x40000000) << 5) + (bitnum << 2))) #define MEM_ADDR(addr) *(volatile uint32_t *)(addr) #define BIT_ADDR(addr, bit) MEM_ADDR(BITBAND(addr, bit))不过实际测下来,BSRR/BRR方案和位带方案的差距很小,因为BSRR本身已经是一条指令完成置位或复位。位带的优势更多体现在需要读-改-写单个位的场景,比如判断某个GPIO状态。所以SPI部分不用特意上位带。
6.2 编译器优化等级要调对
Keil默认的优化等级可能是-O0,这个级别下代码基本不做优化,变量都放栈上,函数调用不开内联。把优化等级调到-O2,软件SPI的吞吐能提升30%到50%,这个提升幅度比手动改代码明显得多。
调整位置在Options for Target -> C/C++ -> Optimization,选择Level 2。如果调试时发现某些变量被优化掉看不到了,可以对那几个变量加volatile,而不是把整个工程降回-O0。
还有一个细节:把LCD_Writ_Bus这类小函数声明为static,编译器更容易做内联;把delay_us之类的短延时函数写成内联或者用NOP循环,也能省下函数调用开销。
6.3 减少CS/DC翻转次数
前面提过,每个字节都翻CS的做法在批量写数据时开销很大。除了CS,DC的翻转也有成本。刷一屏像素时DC只需要设置一次,之后一直保持高电平,这一点在写批量函数时已经做到了。
还有一个容易忽略的点:整个刷屏过程中,SCL的空闲电平处理。我前面的代码在LCD_Writ_Bus末尾拉低了SCL,下一次调用开头又会拉低一次,有一点点重复。如果把这句去掉,用一个专门的函数负责连续调用,也能省下可观的指令数。
实测对比:优化前的全屏清屏约180毫秒,调整优化等级加循环展开加减少多余翻转之后,降到90毫秒左右,提升一倍。这个量级的变化,做简单界面动画时是能感觉出来的。
7. 常见问题与排查实录
7.1 白屏、黑屏、花屏
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 完全白屏,背光正常 | 初始化序列没执行、RES没拉高 | 示波器看RES引脚,确认复位电平正确 |
| 全黑但背光亮 | 没发0x29开显示指令,或睡眠未退出 | 检查0x11和0x29是否都发了 |
| 花屏、雪花点 | SPI时序错位、数据线接触不良 | 用逻辑分析仪抓SCL/SDA波形 |
| 偶发白屏,重启能好 | 复位延时不够 | 把delay_ms(120)加大到200ms试试 |
| 只有背光没有图像 | DC引脚没接或接死 | 换一个GPIO控制DC |
白屏是最常见的。排查顺序建议先确认供电和背光,再确认复位时序,再确认初始化参数,最后才是时序本身。很多人一开始就怀疑SPI时序,结果费半天劲发现是RES没接。
花屏最常见的原因是数据错位一位,往往是写字节函数里的顺序写反了,或者循环里移位的位置不对。用逻辑分析仪抓一段数据,对照发送的字节值看波形,一眼就能定位。
7.2 颜色、方向、偏移三类问题
颜色红蓝颠倒——检查MADCTL的RGB位,改成BGR;或者检查写入字节的先后,确认是先发高字节。
方向不对——检查MADCTL的MV位,切换横竖屏时同时要把宽高宏对调,否则会出现画面只显示一部分的现象。
偏移错位——用纯色填充法确定X/Y偏移,注意偏移量是加在开窗坐标上的,画点函数不需要额外处理,因为它调用的就是开窗。
这三类问题都属于"配置层"问题,不涉及时序,一旦理解了MADCTL的位定义和偏移原理,解决起来都很快。
7.3 卡顿、闪烁与撕裂
卡顿通常是全屏刷新太频繁导致的。如果项目需要每秒刷新20次以上,软件SPI就很难支撑。办法是降低刷新频率,或者改局部刷新。
闪烁可能来自帧率设置。0xB1里的参数如果设置得过低,屏幕会明显闪。把参数调回0x01/0x2C/0x2D这组一般能解决。
撕裂现象是屏幕在刷新的过程中你看到了半新半旧的画面,这是没有垂直同步导致的。软件SPI没法做到精确的帧同步,缓解办法是把界面更新拆成小块,让每次写入的数据量都小于一帧的传输时间,视觉上就不容易察觉。
7.4 与其他外设的冲突
软件SPI占用CPU,如果项目里还有串口接收、定时器中断这类实时性要求高的任务,刷屏期间可能出现丢数据。解决办法是把刷屏操作拆成多次小块调用,放在主循环里分批执行,避免一次占用CPU太长时间。
如果同一组GPIO上还挂了其他SPI设备(比如SPI Flash、无线模块),一定要保证同一时刻只有一个设备的CS为低。这个靠代码里的片选管理来保证,建议给每个设备封装独立的读写函数,函数内部完成CS拉低、传输、CS拉高,不给外部留出错的机会。
GPIOReset也是常见坑。如果在刷屏过程中调用了某个库函数,而那个库函数配置了同组GPIO的模式,把输出改成了输入,屏幕就会立刻停止响应。调试时如果发现刷屏到一半突然不动了,优先怀疑这个。
8. 我踩过的几个坑和长期总结
第一个坑是Gamma参数和反显指令。我早期用一份网上抄来的初始化序列,颜色完全正常但整体像蒙了一层灰,白颜色显示成浅灰,对比度极低。后来发现那份序列里漏了0x21反显指令。加回去之后画面立刻通透了。这件事让我养成一个习惯:拿到一份陌生屏的初始化序列,先逐条看有没有0x21和0x13,这两条缺失的概率相当高。
第二个坑是延时函数。早期我用了一个基于SysTick的delay_ms,但刷屏期间SysTick中断被频繁打断,实测下来延时会偏长,导致初始化时间被拉长,屏要过一两秒才亮。后来改用简单的循环延时专门给屏的复位用,问题就没了。如果你的项目里也遇到"屏幕显示慢半拍",可以往这个方向查。
第三个坑是软件SPI和按键扫描抢资源。按键消抖需要延时,刷屏也占CPU,两者放在主循环里就互相拖。我的做法是把按键扫描放在定时器中断里,刷屏放主循环,两者互不阻塞。这个结构后来成了我做所有小屏项目的基本框架。
第四个坑是杜邦线。调试阶段为了方便,我用30cm的杜邦线连接,结果大约每刷十几次就会出现一次颜色错乱,重新初始化又能好。换了20cm以内的线之后再也没出现过。软件SPI的时钟频率不高,但边沿不够陡的时候,从机对时序的容限其实并不大。
关于后续扩展,如果项目要做动画或者视频,最直接的升级路径是把软件SPI换成硬件SPI加DMA。这个改造其实不大,硬件连接的改动是零——因为前面引脚分配时就特意留在了SPI2的复用脚上。只需要把LCD_Writ_Bus替换成SPI_I2S_SendData,配合DMA把整块缓冲区自动推出去,CPU占用能降到几乎为零,刷新率也能提升到几十帧。这个升级我做过几次,改完代码不到一百行,效果提升非常明显,强烈建议在项目立项时就把这个后路留着。
最后说个细节,如果你的屏和别的外设共享电源,注意上电顺序。屏的LDO在电源上升沿较缓的时候可能进入未定义状态,表现为开机偶尔不亮。在这种场景下,在初始化函数开头加一个200毫秒的延时再开始复位,能规避掉大部分偶发问题。这个延时代价很小,但能把现场返修率降下来不少。