ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

STM32软件SPI驱动1.8寸TFT屏:ST7735R实战与优化

2026/9/20 6:51:55 拓冰建站 浏览量
STM32软件SPI驱动1.8寸TFT屏:ST7735R实战与优化 1. 为什么我最终选了软件SPI来驱动这块1.8寸屏1.1 一块小屏幕背后的取舍逻辑1.8寸TFT-LCD在嵌入式圈子里算是老熟人了分辨率通常是128×160驱动芯片以ST7735R和ST7735S为主通信接口支持SPI和8位并口两种模式。并口速度快但占引脚多SPI引脚少但速度相对受限。对于STM32这类引脚资源紧张、又经常要同时挂多个外设的场景SPI几乎是默认选择。那为什么不用硬件SPI这是很多人第一次接触这个项目时的疑问。硬件SPI当然快配置好之后一个字节的数据由硬件自动移位CPU只需要往数据寄存器里写就行。但问题在于STM32的硬件SPI外设数量有限一般也就两到三个而且往往已经被Flash、无线模块、传感器占用了。更麻烦的是硬件SPI的引脚是固定的不能随便映射到任意GPIO上PCB布线的时候经常被这个约束卡住。软件SPI也就是用普通GPIO模拟SPI时序最大的好处就是引脚随便选。你只要找四个空闲的GPIO随便哪个端口都行甚至可以不连续。对于那种PCB已经画好了、发现硬件SPI引脚走不通的情况软件SPI就是救命稻草。代价是速度慢一些但对于128×160这种小屏幕刷一屏也就几十毫秒的事日常显示完全够用。我这次的项目是一个基于STM32F103C8T6的小型数据采集终端需要显示实时曲线和几个参数值。板子上硬件SPI1给了无线模块SPI2的引脚又被其他功能占了最后只能走软件SPI。实测下来用72MHz主频、GPIO翻转速度调到50MHz刷一屏纯色大概30ms左右刷一张全屏图片大概80ms对于1秒刷新一次的界面来说绰绰有余。1.2 ST7735R的脾气你得先摸清楚ST7735R这颗驱动芯片有几个地方特别容易踩坑我一开始就吃了亏。第一个是初始化序列网上流传的版本五花八门有的能用有的不能用原因是ST7735R和ST7735S的初始化参数有细微差别而且不同厂家做的模块内部伽马校正和电源参数可能不一样。我建议直接找卖家要初始化代码或者用常见的几套参数逐个试。第二个是显示窗口设置。ST7735R的显存是132×162的但实际屏幕只有128×160所以设置行列地址的时候需要加偏移。有的模块偏移是(0,0)有的是(2,1)有的是(0,1)这个不搞定就会出现画面偏移或者边缘花屏。我手上这块模块的偏移是(0,0)但另一块同尺寸的模块偏移是(2,1)所以这个参数一定要实测。第三个是颜色格式。ST7735R支持RGB565和RGB666两种常用的是RGB565一个像素两个字节。注意发送的时候是高字节先发也就是先发RGB565的高8位再发低8位。这个顺序搞反了颜色就会完全不对比如红色变成蓝色。1.3 软件SPI的时序到底怎么模拟SPI有四种模式由CPOL和CPHA决定。ST7735R用的是模式0也就是CPOL0、CPHA0空闲时时钟为低电平数据在时钟上升沿采样。软件模拟的时候你需要手动控制时钟线的高低变化在正确的时间点把数据放到数据线上。具体来说发送一个字节的流程是这样的先拉低片选然后循环8次每次先把最高位放到数据线然后拉高时钟再拉低时钟数据在时钟上升沿被ST7735R采样。8次之后一个字节发完片选可以拉高也可以保持低电平继续发下一个字节。实际写代码的时候为了速度通常会把片选拉低后连续发送多个字节最后再拉高。这里有个细节数据建立时间。你在拉高时钟之前数据线必须已经稳定了。STM32的GPIO翻转速度很快如果你写完数据线立刻拉高时钟可能数据还没稳定就被采样了。稳妥的做法是在数据线设置之后插入一两个空操作或者微小的延时。我实测在72MHz下不加延时也能工作但为了可靠性还是加了一个__NOP()。2. 硬件连接与GPIO配置的实操细节2.1 引脚分配与接线表先把手上的模块引脚理清楚。1.8寸TFT模块通常有8个引脚VCC、GND、CS、RESET、DC、SDA、SCL、BLK。其中SDA是数据线对应SPI的MOSISCL是时钟线对应SPI的SCK。注意这个模块没有MISO因为屏幕只接收数据不需要回读。模块引脚功能说明STM32连接备注VCC电源正3.3V不要接5V除非模块带电平转换GND电源地GND共地CS片选PA4低电平有效RESET复位PA5低电平复位DC数据/命令选择PA60命令1数据SDA数据线PA7SPI MOSISCL时钟线PA3SPI SCKBLK背光3.3V或PWM接3.3V常亮接PWM可调亮度我选PA3、PA4、PA5、PA6、PA7这五个引脚是因为它们在同一端口上操作起来方便而且避开了硬件SPI1的默认引脚PA5-PA7虽然这里用了PA5-PA7但配置成普通GPIO不会和硬件SPI冲突。如果你板子上有别的空闲引脚随便换就行软件SPI的好处就在这。注意BLK背光引脚如果直接接3.3V屏幕会一直最亮。如果想调亮度可以接到STM32的PWM输出引脚上用定时器输出PWM波控制背光亮度。但要注意背光LED的电流有些模块背光电流能到20mA以上最好加个限流电阻或者用三极管驱动。2.2 GPIO初始化代码与速度优化GPIO配置成推挽输出模式速度选50MHz。这里有个关键点输出速度要选高。STM32的GPIO输出速度决定了引脚电平翻转的斜率速度越高翻转越快。软件SPI的性能直接取决于GPIO翻转速度所以一定要选50MHz。void LCD_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_3 | GPIO_Pin_4 | GPIO_Pin_5 | GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_SetBits(GPIOA, GPIO_Pin_3 | GPIO_Pin_4 | GPIO_Pin_5 | GPIO_Pin_6 | GPIO_Pin_7); }初始化之后把所有引脚拉高让CS、SCL处于空闲状态。然后定义几个宏来操作引脚这样代码看起来干净编译器也能优化。#define LCD_CS_LOW() GPIO_ResetBits(GPIOA, GPIO_Pin_4) #define LCD_CS_HIGH() GPIO_SetBits(GPIOA, GPIO_Pin_4) #define LCD_DC_LOW() GPIO_ResetBits(GPIOA, GPIO_Pin_6) #define LCD_DC_HIGH() GPIO_SetBits(GPIOA, GPIO_Pin_6) #define LCD_SCL_LOW() GPIO_ResetBits(GPIOA, GPIO_Pin_3) #define LCD_SCL_HIGH() GPIO_SetBits(GPIOA, GPIO_Pin_3) #define LCD_SDA_LOW() GPIO_ResetBits(GPIOA, GPIO_Pin_7) #define LCD_SDA_HIGH() GPIO_SetBits(GPIOA, GPIO_Pin_7) #define LCD_RES_LOW() GPIO_ResetBits(GPIOA, GPIO_Pin_5) #define LCD_RES_HIGH() GPIO_SetBits(GPIOA, GPIO_Pin_5)用宏而不是函数是因为函数调用有开销在SPI这种高频操作里函数调用的压栈出栈会明显拖慢速度。宏在编译时直接展开成GPIO操作效率最高。2.3 软件SPI字节发送函数这是整个驱动最核心的函数每发一个字节都要调用它。我写了两版一版是通用的一版是优化过的。通用版void LCD_SPI_WriteByte(uint8_t data) { uint8_t i; for(i 0; i 8; i) { LCD_SCL_LOW(); if(data 0x80) LCD_SDA_HIGH(); else LCD_SDA_LOW(); data 1; LCD_SCL_HIGH(); } LCD_SCL_LOW(); }这个版本逻辑清晰但速度一般。优化版把循环展开减少循环开销void LCD_SPI_WriteByte(uint8_t data) { LCD_SCL_LOW(); if(data 0x80) LCD_SDA_HIGH(); else LCD_SDA_LOW(); LCD_SCL_HIGH(); if(data 0x40) LCD_SDA_HIGH(); else LCD_SDA_LOW(); LCD_SCL_HIGH(); // ... 重复8次 LCD_SCL_LOW(); }实测优化版比通用版快大概30%。如果你对刷新速度有要求可以用优化版。但要注意循环展开后代码体积会变大如果Flash紧张就得不偿失。实操心得在SCL拉高之前数据线必须已经稳定。STM32的GPIO操作是即时的但如果你在SDA设置之后立刻拉高SCL可能因为流水线的原因导致时序紧张。稳妥的做法是在SDA设置和SCL拉高之间插入一个__NOP()。我实测在72MHz下不加也能工作但加了更稳尤其是温度变化或者电压波动的时候。3. ST7735R初始化序列的逐条拆解3.1 复位与延时的重要性ST7735R上电之后必须复位否则内部寄存器状态不确定初始化可能失败。复位有两种方式硬件复位和软件复位。硬件复位就是拉低RESET引脚至少10ms再拉高然后等待120ms让芯片内部稳定。软件复位是发送0x01命令但硬件复位更可靠。void LCD_Reset(void) { LCD_RES_HIGH(); Delay_ms(10); LCD_RES_LOW(); Delay_ms(20); LCD_RES_HIGH(); Delay_ms(120); }这里的延时不能省。我试过把120ms改成10ms结果屏幕偶尔能亮偶尔不亮后来老老实实等120ms就再也没出过问题。ST7735R的数据手册里写的是复位后需要等待5ms才能发命令但实际用下来120ms最稳。3.2 关键命令逐条解析初始化序列看起来一大堆命令其实核心的就那么几条。我挑几个最重要的说。0x11 Sleep Out退出睡眠模式。发完这条命令后必须延时120ms因为芯片内部需要时间稳定振荡器。这个延时很多人会忽略结果就是屏幕花屏或者不亮。0x3A COLMOD设置颜色格式。参数0x05表示RGB5650x06表示RGB666。我们用RGB565所以发0x05。0x36 MADCTL设置扫描方向。这个命令控制屏幕的显示方向参数不同可以实现横屏、竖屏、镜像等效果。常用的参数有0x00竖屏、0x60横屏、0xC0竖屏翻转、0xA0横屏翻转。我一般用0x00需要横屏的时候改成0x60。0x21 Display Inversion On开启反显。有些模块不开启反显颜色会不对这个取决于模块厂家。我手上这块必须开另一块不用开所以这个命令要试。0x29 Display On开启显示。发完这条屏幕才会亮。完整的初始化序列我整理成表格方便对照命令参数说明延时0x01无软件复位120ms0x11无退出睡眠120ms0xB10x01,0x2C,0x2D帧率设置无0xB20x01,0x2C,0x2D帧率设置无0xB30x01,0x2C,0x2D,0x01,0x2C,0x2D帧率设置无0xB40x07显示反转控制无0xC00xA2,0x02,0x84电源控制1无0xC10xC5电源控制2无0xC20x0A,0x00电源控制3无0xC30x8A,0x2A电源控制4无0xC40x8A,0xEE电源控制5无0xC50x0EVCOM控制无0x360x00扫描方向无0x3A0x05颜色格式RGB565无0xE00x02,0x1C,0x07,0x12,0x37,0x32,0x29,0x2D,0x29,0x25,0x2B,0x39,0x00,0x01,0x03,0x10伽马校正正无0xE10x03,0x1D,0x07,0x06,0x2E,0x2C,0x29,0x2D,0x2E,0x2E,0x37,0x3F,0x00,0x00,0x02,0x10伽马校正负无0x11无退出睡眠120ms0x29无开启显示无伽马校正参数不同模块差异很大如果颜色看起来发白或者发暗可以调整这两组参数。我一般先用上面这组不行再换。3.3 设置显示窗口与内存写入ST7735R的显存操作需要先设置窗口也就是告诉芯片接下来要写入的区域是哪里。设置窗口用0x2A列地址和0x2B行地址两条命令每条命令后面跟两个参数起始地址和结束地址都是16位高字节先发。void LCD_SetWindow(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { LCD_WriteCmd(0x2A); LCD_WriteData(x0 8); LCD_WriteData(x0 0xFF); LCD_WriteData(x1 8); LCD_WriteData(x1 0xFF); LCD_WriteCmd(0x2B); LCD_WriteData(y0 8); LCD_WriteData(y0 0xFF); LCD_WriteData(y1 8); LCD_WriteData(y1 0xFF); LCD_WriteCmd(0x2C); // 开始写显存 }注意这里的x0、y0、x1、y1是屏幕坐标但ST7735R内部显存有偏移。如果你的模块偏移是(2,1)那么实际发送的地址要加上偏移。我一般把偏移做成宏方便修改#define LCD_X_OFFSET 0 #define LCD_Y_OFFSET 0 LCD_SetWindow(x LCD_X_OFFSET, y LCD_Y_OFFSET, x w - 1 LCD_X_OFFSET, y h - 1 LCD_Y_OFFSET);设置完窗口后连续发送像素数据就会自动填充这个区域每发两个字节RGB565就填一个像素填满一行自动换到下一行。4. 刷屏速度优化与常见问题排查4.1 从30ms到15ms的优化过程一开始我的刷屏函数是逐个像素发送每个像素调用两次LCD_SPI_WriteByte刷一屏128×160需要发40960个字节每个字节8次GPIO操作总共32万次GPIO翻转。实测刷一屏纯色大概30ms。后来我做了两个优化。第一个是把片选拉低后连续发送整屏数据中间不拉高片选。因为每次拉高再拉低片选都有开销连续发送省去了这部分。第二个优化是把数据先存到一个缓冲区然后一次性发送。但STM32F103C8T6只有20KB RAM128×160×240KB存不下整屏。所以我改成按行缓冲一行128像素需要256字节开一个256字节的数组填满一行发一行。void LCD_FillColor(uint16_t color) { uint8_t buf[256]; uint16_t i; for(i 0; i 128; i) { buf[i*2] color 8; buf[i*21] color 0xFF; } LCD_SetWindow(0, 0, 127, 159); LCD_CS_LOW(); LCD_DC_HIGH(); for(i 0; i 160; i) { LCD_SPI_WriteBytes(buf, 256); } LCD_CS_HIGH(); }这样优化后刷一屏纯色降到15ms左右。如果你用DMA配合软件SPI还能更快但那就复杂了对于这个项目没必要。4.2 常见问题速查表现象可能原因排查方法屏幕完全不亮背光没接、电源不对、复位失败先测背光引脚电压再测RESET引脚波形屏幕亮但白屏初始化序列没发完、延时不够检查0x11和0x29命令后的延时花屏初始化参数不对、SPI时序太快降低SPI速度换一套初始化参数颜色不对RGB顺序反了、反显没开调整0x36参数试0x21命令画面偏移显存偏移没设对修改LCD_X_OFFSET和LCD_Y_OFFSET部分区域不刷新窗口设置错误检查0x2A和0x2B的起止地址刷屏有撕裂感刷新速度慢、没开垂直同步提高SPI速度或分块刷新4.3 几个我踩过的坑第一个坑是CS片选的处理。我一开始每次发字节都拉低再拉高CS结果屏幕反应特别慢。后来改成整个刷屏过程CS一直拉低只在最后拉高速度立刻上来了。SPI的CS本来就是用来选中设备的连续传输时不需要每次都切换。第二个坑是DC引脚的电平切换时机。DC决定发的是命令还是数据必须在发第一个字节之前设置好。我试过在发字节中间切换DC结果命令和数据混在一起屏幕完全不响应。正确的做法是发命令前拉低DC发完命令拉高DC再发数据。第三个坑是延时函数不准。我用的是简单的for循环延时结果优化等级不同延时时长差很多。后来改用SysTick定时器做延时精度就稳定了。如果你用HAL库直接用HAL_Delay就行。第四个坑是电源干扰。屏幕背光电流比较大如果和STM32共用一路3.3V背光一亮电压就被拉低导致STM32复位。解决办法是背光单独供电或者加一个大电容滤波。我在VCC和GND之间并了一个100uF的电解电容问题就解决了。独家技巧如果你手头没有逻辑分析仪可以用一个简单的方法判断SPI时序对不对。把SCL接到一个LED上串个电阻刷屏的时候LED会微微闪烁说明时钟在动。再把SDA也接一个LED如果两个LED亮度不一样说明数据在变化。虽然不能看具体时序但至少能判断有没有信号。5. 显示功能封装与项目集成5.1 字符与图片显示的实现刷屏只是基础实际项目里还要显示字符和图片。字符显示我用的是取模软件生成的点阵数据常用的是8×16和16×16两种字体。8×16字体一个字符占16个字节每个字节表示一行的8个像素。显示的时候逐行扫描遇到1就画一个像素遇到0就画背景色。void LCD_ShowChar(uint16_t x, uint16_t y, char ch, uint16_t fc, uint16_t bc) { uint8_t i, j; uint8_t temp; ch ch - ; LCD_SetWindow(x, y, x 7, y 15); LCD_CS_LOW(); LCD_DC_HIGH(); for(i 0; i 16; i) { temp asc2_1608[(uint8_t)ch][i]; for(j 0; j 8; j) { if(temp 0x80) LCD_SPI_WriteWord(fc); else LCD_SPI_WriteWord(bc); temp 1; } } LCD_CS_HIGH(); }图片显示类似只是数据量更大。我一般把图片转成C数组用Image2Lcd这个软件设置输出灰度选16位真彩色扫描方式选水平扫描输出格式选C数组。生成的数组直接放到代码里显示的时候按顺序发送就行。5.2 与FreeRTOS的配合如果项目里跑了FreeRTOS刷屏操作最好放在一个独立的任务里优先级设低一点。因为刷屏是耗时操作如果放在高优先级任务里会阻塞其他任务。我一般创建一个LCD任务优先级设为1最低通过队列接收要显示的数据然后慢慢刷。void LCD_Task(void *pvParameters) { LCD_Data_t data; while(1) { if(xQueueReceive(lcd_queue, data, portMAX_DELAY) pdTRUE) { LCD_ShowString(data.x, data.y, data.str, data.fc, data.bc); } } }这样其他任务只管往队列里丢数据LCD任务负责慢慢刷互不干扰。注意刷屏的时候不要调用vTaskDelay因为刷屏本身就要占用CPU调延时反而会拖慢速度。5.3 低功耗场景的处理如果是电池供电的项目屏幕背光是大头。我的做法是不用的时候把背光关掉屏幕进入睡眠模式发0x10命令需要的时候再唤醒。唤醒后不需要重新初始化直接发0x11退出睡眠再发0x29开启显示就行。void LCD_Sleep(void) { LCD_WriteCmd(0x10); // 进入睡眠 LCD_BLK_LOW(); // 关背光 } void LCD_Wakeup(void) { LCD_WriteCmd(0x11); // 退出睡眠 Delay_ms(120); LCD_WriteCmd(0x29); // 开启显示 LCD_BLK_HIGH(); // 开背光 }实测这样处理屏幕待机电流从20mA降到不到1mA对于电池项目来说很关键。6. 关于软件SPI的一些个人体会软件SPI这东西说简单也简单就是GPIO翻转说难也难时序不对就是不出画面。我前后调了大概三天才完全跑通大部分时间都花在初始化序列和偏移参数上。如果你刚开始搞建议先用卖家提供的例程跑通再一点点改成自己的代码。不要一上来就自己写初始化序列那样很容易卡住。另外软件SPI的速度确实不如硬件SPI但对于1.8寸这种小屏幕完全够用。如果你要驱动更大的屏幕比如2.4寸或者3.5寸建议还是用硬件SPI或者FSMC。软件SPI刷大屏会明显卡顿体验不好。最后分享一个调试技巧如果屏幕不亮先别急着改代码用万用表测一下背光引脚有没有电压再测RESET引脚有没有复位波形。很多时候问题出在硬件上代码是好的。我遇到过好几次都是杜邦线接触不良换了线就好了。