
一块1.8寸的彩色液晶屏驱动起来说难不难说简单也不简单。最近在调一个项目用的就是STM32F103C8T6这颗经典芯片搭配1.8寸TFT-LCD走的是软件SPI。这屏在某宝上几块钱一片买回来却发现资料乱得不行驱动代码东拼西凑。折腾了两天总算把这块屏彻底点亮并且稳定跑起来了。这篇文章把整个过程中的踩坑和心得整理出来给后面要做同样事情的朋友一个参考。先说清楚这篇文章能解决什么问题。如果你手里有一块ST7735S主控的1.8寸TFT屏想接到STM32上但用的是GPIO模拟的软件SPI而不是硬件SPI外设那么这篇文章就是为你准备的。你会了解到为什么在手机屏、开发板屏满天飞的今天软件SPI依然有它不可替代的价值以及如何从零开始把驱动代码写出来、跑通、优化最后还能稳定显示。整个过程用到的工具只有Keil、一个ST-Link和几根杜邦线。1. 项目概述与方案选型分析1.1 为什么选择软件SPI而不是硬件SPI这个问题很多人会问STM32F103自带SPI外设而且性能不差为什么非要自己用GPIO去模拟一个SPI出来答案有现实的考虑也有技术层面的原因。首先是引脚复用的限制。硬件SPI在STM32上通常固定映射到特定的引脚比如SPI1是PA5、PA6、PA7SPI2是PB13、PB14、PB15。如果你的板子布局比较紧张或者这几个引脚已经被LCD之外的其他外设占用了硬件SPI就没法直接用除非做重映射但重映射之后引脚位置可能还是不理想。其次是兼容性问题。市面上1.8寸TFT模块的来源五花八门引脚定义并不统一。有的是SPI接口有的实际上可以切换成3线或4线SPI还有的屏出厂是4线串口模式。如果你用软件SPI引脚完全可以自由定义哪个GPIO空闲就用哪个完全不受硬件SPI固定引脚的约束。这对于快速原型验证、飞线调板来说方便太多了。还有一个很重要的原因软件SPI对学习者来说是一种极好的基本功训练。当你手动拉高时钟线、拉低数据线、一位一位地把数据发出去的时候你对SPI时序的理解会深入到骨子里。之后再用硬件SPI配置寄存器的时候你会知道每一个位长的含义而不只是照着例程抄。对于刚开始学STM32的朋友这点价值无可替代。软件SPI的缺点也明显最大问题是速度慢。GPIO翻转一次需要几条指令一个字节8个bit每个bit都要翻转时钟整体速率比硬件SPI慢一个数量级别。但对于1.8寸这种小屏、显示静态界面或者低帧率刷新的应用场景软件SPI完全够用。实测下来刷新一帧128x160的图片大概需要几十毫秒显示文字和简单图形绰绰有余。1.2 屏幕主控芯片与硬件管脚规划这里要强调一下市面上的1.8寸TFT屏绝大多数用的是ST7735S这颗驱动芯片。也有少部分用ILI9163这两者的指令集高度相似但初始化序列有差异写代码前务必确认你手里的屏用的是哪颗芯片。买到屏之后先看背面的丝印有的会直接印ST7735S或者ILI9163有的没有丝印那就需要自己写代码的时候靠ID寄存器去探测或者直接拿例程试点亮了就知道是哪颗了。我用的这块屏丝印是ST7735S接口定义如下引脚功能VCC电源正极 3.3VGND电源地CS片选信号低电平有效RESET复位信号低电平有效DC数据/命令选择高电平为数据低电平为命令SDASPI数据输入SCLSPI时钟板载还自带一个SD卡槽占用额外的引脚不用管它初始化的时候不初始化SD卡就行。GPIO分配我用了这组引脚SCK - PA5SDA - PA7CS - PA3DC - PA2RESET - PA1选这几个引脚没有特别的玄学就是避开我板上已经被USART1占用的PA9/PA10以及I2C占用的PB6/PB7。软件SPI的好处在这里体现得淋漓尽致想怎么接就怎么接哪天想换引脚了改几行宏定义就行。2. ST7735S核心时序与初始化细节2.1 通信时序的底层原理拆解先来看ST7735S的SPI通信时序这是整个驱动的根基。SPI协议有好几种模式模式由CPOL时钟极性和CPHA时钟相位两个参数决定。ST7735S手册上写的是支持模式0和模式3但实际操作中绝大多数例程用的都是模式0时钟空闲时为低电平数据在时钟上升沿被采样。理解这个时序需要抓住一个核心发送方和接收方约定好在哪个边沿采样数据。对于模式0来说SCLK上升沿的时候主机把数据线的电平稳定住从机在这个时刻读走这个电平值一个bit就传输完成了。我在这里给一个通俗的类比两个人隔着窗户递东西SCK是敲窗户的手SDA是窗户上放物件的台面。每次敲一下窗户对方就看一眼台面上放的是0还是1。敲八次窗户就递过去一个字节。写软件SPI的时候就是模拟这个过程。把一根引脚拉高、另一根拉低本质上是在制造一个电平状态然后给从机一个时钟边沿让它来采样这个状态。很多人写软件SPI容易犯的错误是只顾着SDA的变化忽略了时钟边沿和数据的稳定关系导致时序混乱屏幕显示花屏或者完全不显示。ST7735S的数据手册里还有一个重要概念DC引脚的用法。DC为低电平时SCL上的数据被解释为命令DC为高电平时的数据被解释为命令的参数或者要写入显存的数据。这个逻辑很简单但如果你用Flash读写那种把DC当普通IO的思维去理解容易绕晕。简单记住一条先拉DC为低发命令再拉DC为高发该命令的参数。每一个命令和参数的组合构成了对屏幕的一次完整操作。2.2 初始化序列的背后逻辑网上能搜到很多版本的ST7735S初始化代码长度从几十行到一百多行不等内容大同小异。初看会一头雾水其实拆开来看初始化序列无非是以下几个步骤软件复位通过RESET引脚拉低再拉高让屏幕芯片恢复到上电初始状态。退出睡眠模式发送SLPOUT命令让屏幕从省电模式切换到正常模式。设置显示参数包括颜色格式通常设置为RGB565也就是16位色、显示方向、扫描顺序等。开启显示发送DISPON命令屏幕上开始有内容显示。清屏把整个显存填充为黑色为后续显示做准备。每一段初始化代码看起来不同的地方在于中间的延迟时间和参数值。延迟时间尤其重要。ST7735S的电源管理很细致从睡眠模式唤醒到可以接收下一步指令中间需要等待足够的时间比如典型值是120毫秒。很多人写初始化代码时没有加延时或者延时太短导致屏幕永远白屏其实就是因为芯片还没准备好接收到的指令全部丢弃了。还有一点需要特别提醒ST7735S有多个子型号比如ST7735S和ST7735R两者的初始化序列有一点微妙区别甚至同一个型号不同批次也可能有差异。所以当你发现初始化流程跟网上主流例程不一样的时候不要怀疑是自己写错了可以先尝试调整FRMCTR1、FRMCTR2、PWCTR1这些寄存器的值或者换一版初始化序列试试。2.3 颜色格式与显存组织方式1.8寸TFT屏的分辨率是128x160每个像素点的颜色用16位表示其中红色占5位、绿色占6位、蓝色占5位也就是RGB565格式。显存里每个像素占据2个字节所以一帧全屏数据是128x160x2字节约40KB。软件SPI发送数据的顺序是从左上角第一个像素开始逐行向右扫描直到右下角最后一个像素。每发送两个字节就表示一个像素的颜色。ST7735S内部有地址计数器每次写完一个像素自动加1所以只要在写数据前设置好写入窗口CASET和RASET命令之后连续发送数据屏幕就会自动填充。这里有一个常见的坑颜色字节的发送顺序。RGB565格式下高字节先发还是低字节先发ST7735S在被设置为RGB565颜色格式后期望的像素数据顺序是高字节在前、低字节在后。比如红色0xF800先发0xF8再发0x00。如果你发送的顺序反了显示出来的颜色就会明显偏色比如红色会变成蓝色。这个细节在调试的时候很容易被忽略因为屏幕不是不显示而是颜色不对你往往会去怀疑背光问题或者Gamma设置问题但其实只是字节序搞反了。3. 软件SPI的完整实现与优化策略3.1 底层字节发送函数的写法软件SPI的底层实现是整个驱动的地基。这里我给出一个最常用也是最稳定的写法#define LCD_SCK_GPIO_PORT GPIOA #define LCD_SCK_PIN GPIO_PIN_5 #define LCD_SDA_GPIO_PORT GPIOA #define LCD_SDA_PIN GPIO_PIN_7 #define LCD_CS_GPIO_PORT GPIOA #define LCD_CS_PIN GPIO_PIN_3 #define LCD_DC_GPIO_PORT GPIOA #define LCD_DC_PIN GPIO_PIN_2 #define LCD_RST_GPIO_PORT GPIOA #define LCD_RST_PIN GPIO_PIN_1 #define LCD_SCK_H() HAL_GPIO_WritePin(LCD_SCK_GPIO_PORT, LCD_SCK_PIN, GPIO_PIN_SET) #define LCD_SCK_L() HAL_GPIO_WritePin(LCD_SCK_GPIO_PORT, LCD_SCK_PIN, GPIO_PIN_RESET) #define LCD_SDA_H() HAL_GPIO_WritePin(LCD_SDA_GPIO_PORT, LCD_SDA_PIN, GPIO_PIN_SET) #define LCD_SDA_L() HAL_GPIO_WritePin(LCD_SDA_GPIO_PORT, LCD_SDA_PIN, GPIO_PIN_RESET) #define LCD_CS_L() HAL_GPIO_WritePin(LCD_CS_GPIO_PORT, LCD_CS_PIN, GPIO_PIN_RESET) #define LCD_CS_H() HAL_GPIO_WritePin(LCD_CS_GPIO_PORT, LCD_CS_PIN, GPIO_PIN_SET) #define LCD_DC_L() HAL_GPIO_WritePin(LCD_DC_GPIO_PORT, LCD_DC_PIN, GPIO_PIN_RESET) #define LCD_DC_H() HAL_GPIO_WritePin(LCD_DC_GPIO_PORT, LCD_DC_PIN, GPIO_PIN_SET) #define LCD_RST_L() HAL_GPIO_WritePin(LCD_RST_GPIO_PORT, LCD_RST_PIN, GPIO_PIN_RESET) #define LCD_RST_H() HAL_GPIO_WritePin(LCD_RST_GPIO_PORT, LCD_RST_PIN, GPIO_PIN_SET)底层发送一个字节的函数void LCD_SPI_WriteByte(uint8_t data) { for (uint8_t i 0; i 8; i) { if (data 0x80) { LCD_SDA_H(); } else { LCD_SDA_L(); } LCD_SCK_H(); data 1; LCD_SCK_L(); } }这段代码的逻辑就是前面提到的时序模拟。先根据当前发送的是1还是0设置数据线然后把时钟线拉高形成一个上升沿这时候从机采样数据再把时钟拉低准备发送下一位。从最高位开始发送一位一位向左移8次循环之后一个字节就发出去了。这里要谈一个细节为什么不在拉高时钟之后插入延时因为STM32F103的GPIO翻转速度本身有限从SCK拉高到拉低中间隔着几条指令的执行时间这个时间差对于几MHz的SPI从机来说已经足够了。实测在72MHz主频下这样写出来的SPI时钟频率大约在几MHz量级对于ST7735S完全没问题。如果你用的是更高主频的单片机比如STM32H7跑在400MHz那可能需要在SCK拉高之后插入几个空指令的延时否则时序太快从机跟不上。判断标准很简单用逻辑分析仪抓波形看如果SCK高电平脉宽过窄或者屏幕出现偶发花屏就适当加延时。3.2 CS片选与DC引脚的配合有了底层的字节发送函数接下来就是命令和数据的发送。ST7735S的操作模式是CS拉低表示选中设备DC为低表示发送的是命令DC为高表示发送的是数据。按照这个规则可以封装出两个高层函数void LCD_WriteCmd(uint8_t cmd) { LCD_CS_L(); LCD_DC_L(); LCD_SPI_WriteByte(cmd); LCD_CS_H(); } void LCD_WriteData(uint8_t data) { LCD_CS_L(); LCD_DC_H(); LCD_SPI_WriteByte(data); LCD_CS_H(); }这个封装非常简单但里面有一个可以优化的点如果每次都把CS拉高再拉低那么在连续发送大量像素数据的时候每一次片选切换都会消耗额外的GPIO操作时间。对于刷屏这种场景可以做一个只拉低一次CS的批量写数据函数把整行或者整块数据连续发完再释放CS。这个优化在软件SPI下效果很明显实测可以提升约20%的全屏刷新速度。还有一个需要注意的地方DC引脚的电平状态必须和CS拉低同时有效。理论上讲从机的行为只取决于CS有效期间DC和SCL的状态但为了保证时序稳健最好先在CS有效之前把DC设置到位然后再拉低CS发送数据。很多花屏问题的根源就在这里DC和SCL之间没有满足建立时间。虽然大多数屏对时序要求不严格但养成好的时序习惯能省掉很多排查问题的功夫。3.3 从点亮到稳定显示的踩坑记录点亮屏幕的过程我是分步骤进行的。第一步做的是最基本的硬件验证上电、看背光是否亮起。背光亮说明供电没问题但不代表屏能正常显示很多时候屏幕白屏无显示背光却是亮着的。第二步做的是发送最简单的命令写一个软件复位序列然后发SLPOUT命令延时120毫秒再发DISPON命令。如果屏幕在这个时候出现雪花点或者是杂乱的条纹说明SPI通信链路是通的只是初始化参数不对。这个时候我会在代码里加一个测试命令把屏幕设置成固定颜色看能否显示纯色画面。能显示纯色说明接下来的方向就是正确的如果还是白屏问题大概率出在初始化序列的延时不够或者时序极性错误。第三步才是完整初始化并做清屏操作。从白屏到有系统性的颜色渐变显示这一步走通了后面的所有改进才有了基础。在整个调试过程中我遇到过一个非常隐蔽的问题屏幕在初始化完成之后显示一段时间的正常画面然后突然出现花屏或者偏色复位之后又能恢复正常。排查了很久才发现是电源问题。1.8寸TFT的背光和ST7735S芯片的驱动电流虽然不大但如果使用普通的AMS1117-3.3线性稳压器输出并且输入电源本身纹波比较大在高负载情况下电压会跌落导致芯片工作不稳定。换成高质量的LDO并且在屏幕电源引脚旁边并联一个10uF的电解电容和一个100nF的陶瓷电容后问题彻底消失了。这里也提醒大家做液晶屏这种动态功耗变化较大的外设电源设计一定要留足余量滤波电容不能省。4. 实用封装与高级绘图函数实现4.1 硬件初始化与引脚复用的坑很多人在移植例程的时候第一步就卡在引脚配置上。ST7735S的驱动代码和普通的SPI外设初始化有一点不同这里没有调用HAL_SPI_Init也没有配置SPI的波特率、极性这些寄存器只需要把用到的GPIO全部配置为推挽输出即可。void LCD_GPIO_Init(void) { GPIO_InitTypeDef gpio_init_struct {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); gpio_init_struct.Pin LCD_SCK_PIN | LCD_SDA_PIN | LCD_CS_PIN | LCD_DC_PIN | LCD_RST_PIN; gpio_init_struct.Mode GPIO_MODE_OUTPUT_PP; gpio_init_struct.Pull GPIO_NOPULL; gpio_init_struct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, gpio_init_struct); LCD_RST_H(); LCD_CS_H(); LCD_DC_H(); LCD_SCK_L(); LCD_SDA_L(); }这里面有一个容易踩的坑如果你的项目中使用了J-Link或者ST-Link的SWD调试接口而SWDIO和SWCLK恰好分配在你用于LCD的GPIO上那么当你给LCD引脚输出初始化代码时调试器可能链接不上。因为SWD引脚在复位后默认是调试功能一旦被初始化为普通推挽输出调试器就断了。解决办法是用ST-LinkUtility或者FlyMCU等工具在连接状态下先把程序下载进去或者干脆把LCD引脚挪到别的位置避开PB3、PB4、PA13、PA14、PA15这些特殊引脚。我个人的习惯是直接避开省心。4.2 画点函数与坐标映射画点函数是所有绘图功能的基础它做的事情就两步设置写入窗口然后写入一个像素的颜色数据。void LCD_SetWindow(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { LCD_WriteCmd(0x2A); // CASET: 列地址设置 LCD_WriteData(x0 8); LCD_WriteData(x0 0xFF); LCD_WriteData(x1 8); LCD_WriteData(x1 0xFF); LCD_WriteCmd(0x2B); // RASET: 行地址设置 LCD_WriteData(y0 8); LCD_WriteData(y0 0xFF); LCD_WriteData(y1 8); LCD_WriteData(y1 0xFF); LCD_WriteCmd(0x2C); // RAMWR: 开始写显存 } void LCD_DrawPixel(uint16_t x, uint16_t y, uint16_t color) { LCD_SetWindow(x, y, x, y); LCD_WriteData(color 8); LCD_WriteData(color 0xFF); }注意这里颜色数据的处理传入的color是RGB565格式高字节先发送。很多例程在LCD_DrawPixel里直接对参数取反顺序容易搞混干脆在发送前手动拆分高字节和低字节逻辑清楚不易错。坐标映射还有一个很关键的坑屏幕的物理方向和内部的显存地址映射并不总是一一对应的。ST7735S通过MADCTL寄存器可以配置扫描方向默认情况下左上角是(0,0)但有的屏因为面板FPC走线的原因需要额外的坐标旋转才能让显示内容正确。这就是为什么同样的代码在A屏上正常在B屏上就旋转了90度或者镜像了。遇到这种情况不要慌调整MADCTL的bit5到bit7MY、MX、MV位就能旋转屏幕方向。下面是我常用的几种方向配置方向MADCTL值效果竖屏正常0x00左上角起始横屏0x60逆时针旋转90度竖屏翻转0xC0上下左右翻转180度4.3 字符显示与中文字库的简化方案显示字符本质上就是显示一张小位图。英文字符用8x16或者6x12的取模数据每个字符对应一个数组。我这里提供一个简单但非常实用的方法用PCtoLCD2002软件把需要的字符生成字模存成一个const数组然后通过画点函数按位扫描打印出来。void LCD_ShowChar(uint16_t x, uint16_t y, char ch, uint16_t color) { uint8_t i, j; const uint8_t *p (const uint8_t *)Font8x16[ch - ][0]; for (i 0; i 16; i) { for (j 0; j 8; j) { if (p[i] (0x80 j)) { LCD_DrawPixel(x j, y i, color); } } } }中文字库和英文字库的差异在于编码。英文字符ASCII码占用1个字节而GB2312编码的中文占2个字节。如果你直接在字符串里写字面量编译器会按UTF-8处理取得的是多字节数据直接拿这个数据做数组索引会得到错误的结果。这里有一个常用的简化方案把中文字模按16x16的点阵取模然后用两个字节的GB2312编码做索引建立一个查找表。实际开发中我更推荐直接把需要显示的汉字预先取好模做成一个数组集合用字符编码去查而不是在运行时去判断。这样代码量可控逻辑也简单。显示数字时需要先把整数转成字符串常用的方法是sprintf但sprintf比较耗资源。如果只是显示简单数字可以自己写一个轻量级的整数转字符串函数只处理十进制转换效率高出很多。这个在嵌入式开发里是很常见的优化思路。4.4 图片显示与缓存策略显示一张完整的图片需要把图片转换成C数组或者从外部Flash读取。如果只是测试可以先把图片用Image2Lcd工具转换成数组然后通过软件SPI刷进屏幕void LCD_ShowImage(uint16_t x, uint16_t y, uint16_t width, uint16_t height, const uint8_t *data) { uint32_t i; uint32_t pixel_count (uint32_t)width * height; LCD_SetWindow(x, y, x width - 1, y height - 1); LCD_CS_L(); LCD_DC_H(); for (i 0; i pixel_count * 2; i) { LCD_SPI_WriteByte(data[i]); } LCD_CS_H(); }这里有个值得注意的工程问题一张128x160的全屏图片RGB565格式需要约40KB的数据而STM32F103C8T6的Flash只有64KB加上代码本身存不了几张图片。如果只是演示可以存一两张小图。如果想做完整的图片相册就需要接外部Flash或者从SD卡读取。对于软件SPI来说更大的瓶颈是速度一张全屏图片的显示时间取决于SPI速率和IO翻转速度实测一般在几十毫秒到一百多毫秒之间。如果显示动态画面这个速度远远不够但显示静态图片、开机Logo、菜单背景完全够用。5. 常见问题排查与工程化建议5.1 屏幕显示异常的排查顺序屏幕显示异常是这类项目最常见的问题我给出一套自己总结的排查口诀先查硬件再查时序最后查初始化参数。先查硬件的意思是确认供电电压是否为3.3V、GND是否接好、背光亮不亮、RESET引脚有没有正确拉高。很多问题其实只是某根杜邦线松了接触不良导致花屏或者白屏。建议用万用表测一下引脚电平不要只靠眼睛看。然后是查时序用逻辑分析仪抓SCL和SDA的波形对比数据手册上的时序要求。特别关注SCK高电平脉宽是否足够CS的建立时间是否满足DC是否在CS有效之前就设置好。这些信号层面的问题用分析仪一眼就能看出来比自己猜测高效得多。最后才是查初始化参数确认颜色格式设置是否正确、MADCTL是否导致方向错误、Gamma曲线参数是否适配你的屏幕批次。初始化序列里面每一个寄存器值都有其存在的意义但同一个寄存器值在不同批次的屏幕上效果可能略有差异。多准备两套初始化序列做对比测试是一个很实用的排查手段。5.2 花屏与白屏现象的根因分析花屏是显示内容错乱、有杂色条纹白屏则是完全没有显示内容。这两个问题表面上相似但根因完全不同。花屏的常见原因是SPI速率过快、电源纹波过大、初始化未完成就开始写显存、地址窗口设置错误导致写入越界。其中速率过快导致的花屏是最典型的因为软件SPI如果延时太短SCK高电平脉宽小于从机的最小要求数据采样就会出现跳变。解决方法是增加微小延时或者在每次SCK拉高后插入几个NOP空指令。白屏的常见原因是背光正常但没有数据写入。排查思路是从最基本的命令开始发送SLPOUT命令后延时是否足够DISPON命令是否发送成功CASET和RASET设置是否正确数据是否为全零黑色或者全FF白色。很多白屏问题实际上是初始化序列太快导致的。我在第一次接触ST7735S时初始化代码里没有加延时屏幕就是白屏。后来在每一条关键命令之间加了延时问题立刻解决。ST7735S对上电时序的要求比较严苛特别是从睡眠到正常的切换延时一般不能少于120毫秒。5.3 代码架构设计与后续扩展建议软件SPI的驱动代码写完不要止步于能用还要考虑后续的维护和移植。我习惯把驱动代码分成三层硬件抽象层、协议层、应用层。硬件抽象层就是那些宏定义和GPIO操作函数。换平台的时候只需要修改这一层。协议层是命令发送、数据发送、初始化、设置窗口这些操作。应用层则是画点、画线、画圆、显示字符、显示图片这些功能。这样的分层结构在项目复杂度上升之后会非常受用。比如你以后换了块2.4寸屏主控变成ILI9341只需要改协议层的初始化函数和部分指令参数上层的绘图代码完全不用动。另外一个扩展建议是软件SPI调通之后如果想要提升刷新速度可以直接在同一套引脚上切换到硬件SPI只需要把GPIO配置成复用模式调用SPI外设的发送函数。因为软件SPI和硬件SPI在协议层是兼容的所以应用层的函数不需要修改只是底层发送字节的实现从GPIO模拟变成硬件外设操作。这个平滑升级路径就是软件SPI驱动最大的价值。最后再分享一个小技巧调试的时候可以在驱动文件里加一个调试开关比如用宏定义控制是否打印初始化过程中的关键信息。通过串口输出每个初始化步骤的完成状态这样当屏幕出现问题时你能很快定位到初始化卡在哪一步而不需要一遍遍地插拔电源看复位效果。这个习惯让我省了不少时间。整理完这些内容这块被很多人说“坑很多”的1.8寸小屏算是真正被我吃透了。回头来看所谓“坑”其实是藏在细节里的时序要求、电源稳定性和初始化参数的适配问题。软件的思路一旦清晰硬件上再多花点耐心去量波形、看电平问题总能定位到具体的某一行代码或某一根线上。希望这篇文章能帮你少走一些弯路。