最近把一块1.69寸的TFT LCD接到了F407ZET6上,主控是ST7789。这块屏在淘宝上很常见,价格也不高,但真正把它点亮、调稳、刷出流畅画面,还是花了我不少时间。其实驱动代码本身不长,真正折磨人的是初始化序列的时序配合、显示窗口的坐标设置,以及各种“看起来代码没问题但屏幕就是不正常”的玄学现象。这篇就基于我实际做的STM32F407ZET6驱动1.69寸ST7789屏幕的过程,把引脚规划、SPI时序、初始化命令、绘图底层、DMA优化和排查经验完整写一遍,给准备自己写LCD驱动的人做个参考。
如果你正在纠结“到底要不要学自己写驱动”,我的建议是:非常值得。底层驱动写一次,后面换屏、改分辨率、做UI都心里有底,不会被别人的库牵着走。这篇文章的内容不需要你有多强的硬件基础,只要会STM32的基本GPIO和SPI操作,跟着思路走就能复现。
1. 为什么1.69寸这颗屏值得自己写驱动
1.1 硬件参数与接口初印象
1.69寸TFT屏,分辨率常见是240x280,驱动IC是ST7789V,接口有SPI、RGB、MCU并口等几种版本。市面上买到的大多数是四线SPI接口模块,引脚不算多,只有VCC、GND、CS、DC(也叫A0)、RST、BLK、MOSI、SCLK这几个。相比老款的ST7735屏幕,ST7789支持更大的分辨率、更高的像素格式,颜色深度为RGB565时显示效果很细腻,用来做小仪表盘、温湿度计、迷你游戏机都非常合适。
我手里这块屏幕是240x280的,宽高比接近1:1.17,实际上这也就意味着它并不是标准的240x320全尺寸。ST7789V这颗芯片原生最大支持240x320,但1.69寸屏往往只用到240x280甚至240x240的区域,厂商在屏的玻璃上做了裁切,剩下的区域要么物理不存在,要么被遮蔽。这个特点直接影响了后面的显示窗口初始化,后面会展开说。
1.2 现成驱动库的适配问题
很多人拿到屏幕第一反应是找现成的驱动代码。网上的图形库源码确实很多,但这类代码通常绑定某个开发板或特定引脚,直接拿来用的时候经常要面对“原作者的引脚和你的板子对不上”“底层是软件模拟SPI还是硬件SPI没写清楚”“不同屏幕版本初始化参数不同”这些麻烦。
更重要的是,现成库往往把初始化序列封装成了一个函数,内部每个命令的作用、为什么需要延时、为什么MADCTL要设置为某个值,全都被隐藏起来了。一旦屏幕显示方向不对、颜色发紫、图像偏移,你根本不知道从哪下手。自己写驱动,并不是为了显得多专业,而是为了在出问题的时候有清晰的排查方向。尤其当你换了不同批次、不同分辨率的屏幕,只需修改显示窗口参数和扫描方向,其他代码完全不动,这个灵活性是现成库很难给的。
1.3 先搞懂数据是怎么跑到屏幕上的
在写代码之前,必须理解整个数据链路。MCU通过SPI接口把数据发给屏幕驱动IC,驱动IC再控制玻璃面板上的像素点。SPI接口中,SCLK提供时钟,MOSI负责传输数据,CS做片选,RST负责复位,DC线则是区分“当前传输的是命令还是数据”。
DC线的作用可以这样理解:MCU和数据手册之间需要一种“这是通知还是包裹”的区分方式。当DC拉低时,屏幕把这个字节当作命令来解释,比如设置颜色格式、设置扫描方向;当DC拉高时,屏幕把后续数据当作真正的像素数据或命令参数。这个机制很简单,但在实际调试中,DC线的时序配合非常关键,很多花屏问题都出在DC翻转和SCLK边沿的相对位置上。
2. 硬件设计:GPIO规划与接线细节
2.1 引脚分配与选型理由
F407ZET6的资源非常充裕,GPIO多、外设多,驱动一块SPI屏绰绰有余。我选用了SPI1作为通信接口,原因是SPI1挂在APB2总线上,最大时钟可以达到42MHz,而SPI2和SPI3挂在APB1上,最大只有21MHz。后续如果想提升刷新率,SPI1的上限明显更有优势。
我实际使用的引脚分配如下:
| 信号 | 引脚 | 说明 |
|---|---|---|
| SCLK | PA5 | SPI1_SCK,复用功能AF5 |
| MOSI | PA7 | SPI1_MOSI,复用功能AF5 |
| CS | PB6 | 任意GPIO即可,软件控制 |
| DC | PA0 | 任意GPIO,命令/数据选择 |
| RST | PE1 | 任意GPIO,复位控制 |
| BLK | PE0 | 任意GPIO,背光控制 |
| VCC | 3.3V | 屏幕模块电源 |
| GND | GND | 共地必须可靠 |
这里有个小建议:如果板子上的SPI1引脚被其他外设占用,用GPIO模拟SPI也能驱动这块屏,只是速度会明显下降,高频刷图会卡顿。硬件SPI是首选方案,务必别为了省事把所有信号线都用杜邦线飞得很长。
2.2 供电与背光设计
屏幕模块大多内置了电平转换和稳压电路,但VCC依然需要用干净的3.3V供电。我实测屏幕全亮时电流大约在30mA到60mA之间,单个稳压芯片完全带得动。需要注意的是,背光引脚BLK不能悬空,悬空时屏幕可能不亮或者亮度不稳定。把BLK接到高电平是最简单的做法,如果要做亮度调节,可以将BLK接在PWM输出引脚上,通过调节占空比控制背光电流。
2.3 接线中的隐患
第一个坑是杜邦线过长。SCLK频率高了之后,过长的杜邦线会引入信号反射和串扰,屏幕上就会出现随机雪花点或边缘闪烁。我一开始用20cm长的杜邦线连接,42MHz下刷图偶尔会有杂点,后来把线缩短到10cm以内,并让SCLK和MOSI尽量分开走线,问题就消失了。第二个坑是模块丝印的DC和D0容易看错,尤其是一些小厂模块,丝印模糊时最好拿万用表对照原理图逐一确认。第三个坑是共地不可靠,电源地和信号地必须连到同一块板子的GND上,否则SPI电平参考点不一致,逻辑分析仪里看波形没问题,但实际就是不稳定。
3. 时序与初始化序列:从波形到命令
3.1 SPI模式与DC线配合
ST7789的SPI接口通常工作在模式0,也就是CPOL=0、CPHA=0:空闲时SCLK为低电平,数据在SCLK上升沿被采样,下降沿切换。发送命令时DC拉低,发送数据时DC拉高。一个容易忽视的点是,DC的状态必须在SCLK上升沿到来之前就已经稳定,不能和MOSI同时翻转。
如果你用逻辑分析仪抓波形,会发现有些花屏问题并不是命令错误,而是DC翻转比SCLK晚了那么一两百纳秒。低速SPI下这种问题不明显,频率高到几十兆的时候就会被放大。所以我在代码里发送命令和数据时,都是先写GPIO的BSRR寄存器设置DC状态,再等待一小段时间,最后才操作SPI数据寄存器。这个顺序看着不起眼,但对稳定性帮助很大。
3.2 可用的最小初始化序列
ST7789的完整初始化序列很长,数据手册里会给出Power_Setting、Gamma_Setting等一大堆寄存器配置。实际上在大多数场景下,我们只需要一个最小初始化序列就能点亮屏幕,其余寄存器保持默认值即可。
我使用的初始化代码如下:
static void ST7789_Init(void) { // 硬件复位 GPIO_ResetBits(GPIOE, GPIO_Pin_1); delay_ms(50); GPIO_SetBits(GPIOE, GPIO_Pin_1); delay_ms(120); // 软件复位 ST7789_WriteCmd(0x01); delay_ms(120); // 退出睡眠模式 ST7789_WriteCmd(0x11); delay_ms(200); // 16位像素格式 RGB565 ST7789_WriteCmd(0x3A); ST7789_WriteData(0x55); // 内存数据访问控制,调整扫描方向 ST7789_WriteCmd(0x36); ST7789_WriteData(0x00); // 是否反白,不同批次屏幕不同 ST7789_WriteCmd(0x20); // 如果显示颜色异常,可改为0x21 delay_ms(50); // 设置显示区域为240x280 ST7789_WriteCmd(0x2A); ST7789_WriteData(0x00); ST7789_WriteData(0x00); ST7789_WriteData(0x00); ST7789_WriteData(0xEF); // X方向结束 239 ST7789_WriteCmd(0x2B); ST7789_WriteData(0x00); ST7789_WriteData(0x00); ST7789_WriteData(0x01); ST7789_WriteData(0x17); // Y方向结束 279 // 打开显示 ST7789_WriteCmd(0x29); delay_ms(20); }这个序列只用了7个命令。SWRESET让屏幕复位到默认状态,SLPOUT唤醒屏幕电源,COLMOD设置RGB565格式,MADCTL控制扫描方向,CASET和RASET划定显示窗口,最后DISPON打开显示。每一步后面的延时都不能省略,尤其是SLPOUT之后,屏幕内部电源稳定需要时间,如果立刻发后续命令,命令很容易被丢弃,表现为白屏或显示异常。
3.3 初始化后花屏最常见的几个根因
第一个根因是延时不足。很多例程把延时全部缩到最小,导致屏幕上电时序没走完就发命令。我实测SWRESET之后至少等120ms,SLPOUT之后至少等200ms,才能保证后续命令被正确接收。第二个根因是像素格式不匹配。屏幕被设置为18位色,但MCU发的是16位数据,画面颜色会明显发灰、发紫。COLMOD必须设置成0x55。第三个根因是MADCTL扫描方向不对。这个值决定了X和Y的增长方向以及RGB的排列顺序,设置不对时画面会镜像、旋转或者出现斜向条纹。第四个根因是反白模式。有些屏默认白字黑底,有些屏默认黑字白底,如果发现颜色像“底片”一样反转,把0x20和0x21互换一下即可。
4. 绘图核心:显示窗口、画点与坐标计算
4.1 全帧缓冲的算账
拿到屏幕第一反应是开一块全帧缓冲,把所有像素都放在内存里,再整体刷新到屏幕上。然而240x280分辨率、RGB565格式,每个像素占用2字节,全帧大小为2402802=134400字节,约131KB。F407ZET6虽然有多达192KB的SRAM,但实际项目里还要放通信缓存、任务栈、运动参数等,全部占用并不现实。而且一些编译器默认堆栈配置还吃掉了不少内存,强行开全帧缓冲很容易导致编译能过但运行异常。
我的做法是不开全帧缓冲,改用行缓冲加局部刷新的方案。屏幕一次只刷新一小块区域,比如40行或者60行,这样内存占用只有几KB,刷新效果却几乎看不出差别。这是实际项目中更务实的选择,也避免了“内存足够”的错觉。
4.2 设置窗口与画点
ST7789的绘图核心是“窗口+RAMWR”的组合。先通过CASET设置列范围,RASET设置行范围,然后连续发送数据,屏幕上对应窗口内的像素就会被依次填充。窗口越小,发送的数据越少,刷新速度越快。
画一个像素点的实现逻辑如下:
void ST7789_DrawPixel(uint16_t x, uint16_t y, uint16_t color) { if (x >= 240 || y >= 280) return; ST7789_SetWindow(x, y, x + 1, y + 1); ST7789_WriteCmd(0x2C); ST7789_WriteData(color >> 8); ST7789_WriteData(color & 0xFF); }注意这里把颜色拆成了高字节和低字节,因为RGB565颜色是16位的,而SPI每个数据单元是8位。发送顺序必须是高字节在前,否则颜色通道会互换,红色变成蓝色,蓝色变成绿色。这一点在小端模式下尤其容易踩坑,因为STM32的内存中uint16_t的低字节在低地址,不能直接把指针强转成uint8_t发送。
4.3 画线、矩形填充与取模
有了画点函数,直线和矩形就很简单了。画直线可以用Bresenham算法,避免浮点运算,速度也快。画实心矩形时,一条一条扫描线地填充即可。不过要注意,逐点调用画点函数效率很低,因为每个点都要重新设置窗口,白白发送6个额外的窗口命令字节。更快的做法是一次性把整个矩形的窗口设置好,然后连续发送像素数据。
显示文字或图片时,通常用取模软件将位图转换成C数组。取模方向必须与显示窗口的方向一致,否则字符看起来是颠倒的。我自己习惯用“逐行取模、高字节在前”的方式,配合行缓冲一次性发送整行数据,显示16x16中文字符时速度非常理想。
5. 提速:从阻塞SPI到DMA局部刷新
5.1 阻塞SPI的实测瓶颈
刚开始我直接用阻塞方式发送数据,SPI时钟设为21MHz,每发送一个字节需要等待TXE标志置位。看起来频率不低,但实际测下来,刷一屏还是要上百毫秒。原因有两层:第一层是SPI底层本身需要一个字节一个字节地搬运,每发一个字节都有等待;第二层是绘图函数里夹杂了大量函数调用、窗口设置、边界判断,白白消耗了CPU时间。如果只是显示静态画面这些可以忽略,但要做动画或者菜单滑动,体验就会很差。
5.2 用DMA把CPU解放出来
DMA能在外设和内存之间直接搬运数据,不经过CPU干预。对刷新屏幕来说,我们只需要把一块内存中的数据连续搬到SPI的数据寄存器,SPI外设就会自动把这些数据发出去。
F407ZET6的SPI1_TX对应DMA2硬件请求,我使用的是DMA2_Stream3,外设通道选择Channel_3。配置思路如下:将外设地址设为SPI1的数据寄存器地址,内存地址设为待发送的缓冲区首地址,内存地址增量使能,外设地址不增量,方向为内存到外设,数据宽度均为半字(16位),缓冲区大小为待发送的数据量。搬运完成后在DMA传输完成中断里关闭DMA,同时关闭SPI的TX DMA请求。
这里有一个容易踩的坑:使能DMA之前,必须先把SPI_I2S_DMACmd配置为ENABLE,否则DMA搬运的数据不会到达SPI外设。DMA触发完成后,也需要先把SPI的DMA请求关闭,再关闭DMA,顺序反了可能会出现最后几个字节发送不完整的情况。
5.3 更实用的优化路线
DMA并不是唯一手段,甚至很多时候单纯上DMA提升有限,因为瓶颈在数据的来源和窗口命令上。我更推荐的组合是:局部刷新加DMA批量搬运。也就是只更新变化的区域,每次设置一次窗口,然后把一整块缓冲的数据通过DMA发出去。放在具体场景里就是:显示数字变化时,只有数字周围几十个像素需要刷新,没必要整个屏幕跟着重绘。
另一个优化思路是分块缓冲加双缓冲。比如把屏幕分成6个40行的区块,每个区块的缓冲大小是240402=19200字节,约18.75KB,内存完全能承受。先在一个区块内存里绘制好所有像素,再一次性DMA发送给屏幕。这样既避免了全帧缓冲的巨额内存开销,又比逐行发送少了很多次窗口命令设置,实测刷新速度可以提升3倍以上。
6. 调试中踩过的坑与最终排查清单
6.1 白屏和乱码的波形检查
遇到白屏时,我第一个动作是打开逻辑分析仪抓SCLK、MOSI、DC三根线的波形。重点看两个地方:一是初始化阶段有没有命令被遗漏,比如SWRESET之后是不是立刻发了SLPOUT,如果是,需要加大延时;二是DC线在命令和数据之间的切换是否和SCLK边沿对齐。有一个典型的假象是,SPI波形看起来没有问题,但MISO上没有任何返回数据。ST7789的MISO在某些模块上是悬空不接的,这种情况只要确认发送端波形正常即可,不用怀疑硬件。
6.2 偏移量与屏幕版本问题
1.69寸屏最常见的显示异常是“画面偏左偏上”或者“底部有一条黑边”。这是因为ST7789芯片原生支持240x320,但屏幕只露出240x280。如果不重新设定RASET的结束坐标,屏幕就会尝试访问不存在的像素区域。
我遇到过一批屏幕,显示区域起始坐标不是(0,0),而是需要从第0行偏移到第40行。处理方法是在初始化CASET/RASET时直接把起始坐标和结束坐标一起设置好,比如将Y方向从40到319调整为0到279。如果显示内容上下颠倒了,则修改MADCTL的BIT7和BIT6来翻转扫描方向。不同批次屏幕的差异是真实存在的,同一型号的屏,换了一家供应商,初始化参数可能完全不同。调试时先用最简单的纯色填充测试,比如整屏填红色,然后依次填绿、蓝,能快速确认RGB通道是否正常。
6.3 排查清单表格
| 现象 | 先查什么 | 再查什么 |
|---|---|---|
| 白屏 | 复位引脚是否拉高、延时是否足够 | 逻辑分析仪看初始化命令是否完整 |
| 颜色发紫/发灰 | COLMOD是否设为0x55 | 是否误设成18位色模式 |
| 颜色像底片 | 反白命令0x20/0x21是否反了 | MADCTL中的RGB顺序 |
| 画面偏移、有黑边 | CASET/RASET范围是否匹配实际像素 | 屏幕批次起始坐标是否非0 |
| 画面镜像/颠倒 | MADCTL扫描方向设置 | 检查BIT7、BIT6、BIT3 |
| 随机雪花点 | SCLK频率是否过高 | 杜邦线长度和地线连接 |
6.4 经验清单
最后写几条自己总结的经验。初始化命令发完后,要下意识地多等一段时间,宁可多等也不能少等,大部分白屏都是延时不够引起的。在调试阶段,先用纯色填充验证硬件通路,再用渐变或网格图案验证坐标和方向,不要一上来就刷图片,否则问题来源会非常难定位。SPI频率从低速开始,稳定再慢慢提高,不要一开始就追求42MHz。高频下的信号完整性问题比驱动代码更难查,而且经常是“偶发故障”,肉眼很难复现。
另外,如果用的是带触摸的1.69寸屏,触摸IC和显示IC往往是分开的,触摸部分一般走I2C或者单独的四线SPI,别把触摸引脚搞混到显示驱动里面。我一开始就把触摸的INT引脚当成了RST引脚,结果屏幕一直无法启动,查了很久才发现接线错误。
最后再分享一点实际体会。我一开始把SPI频率拉到42MHz,以为越快越好,结果屏幕偶尔会出现随机杂色点。后来把SCLK和MOSI的杜邦线剪短、重新焊线,再把频率降到21MHz,问题就消失了。这件事让我明白一个道理:很多时候驱动代码本身没有错,反而是物理链路的裕量不够,降低了频率、缩短了连接距离,看似“退步”,实际稳定性反而更好。如果你也在调这块屏卡了很久,不妨先把频率降下来试试。