完整讲解与避坑指南)
STM32驱动1.8寸TFT-LCD软件SPI完整讲解这个标题我太熟了。做嵌入式这几年屏幕驱动几乎是每个项目绕不开的环节而1.8寸TFT又是其中最经典、性价比最高的一块小屏。ST7735S主控、128x160分辨率、软件SPI接线这套组合我在四五个项目里用过从最初的翻车到后来一次点亮踩过的坑确实值得整理出来。这篇文章主要写给三类人刚学完GPIO和定时器、想搞块屏幕做显示的新手手里正好有1.8寸屏但不想动硬件SPI引脚、想省事的开发者以及项目里SPI外设被其他设备占用、只能用普通IO口模拟时序的老哥。我会把从硬件连接到驱动代码的完整链路拆开讲重点放在软件SPI的实现细节和ST7735S的初始化时序上尽量让你看完就能直接上手。1. 为什么选软件SPI而不是硬件SPI很多人拿到屏幕第一反应是开硬件SPI这当然没问题但在实际项目里软件SPI反而更灵活。我最近做的一个温湿度采集项目STM32的SPI1被SD卡占了SPI2引脚又被串口复用最后只能腾出三个普通IO口来驱动屏幕软件SPI几乎是唯一选择。软件SPI的核心逻辑就是“用GPIO模拟时序”把SCLK、MOSI、CS、DC、RST这些引脚都配置成推挽输出然后在代码里按照SPI协议手动拉高拉低电平。它和硬件SPI最大的区别在于不占用芯片的SPI外设资源引脚可以任意映射布线压力小频率完全由代码控制想多快就多快当然太快会出错互相干扰少同一套代码可以无缝移植到任何单片机平台只要支持GPIO操作缺点也很明显CPU占用高刷新速度远不如硬件SPI这里我说句实话1.8寸TFT这种分辨率和刷新率要求不高的屏幕软件SPI完全够用。实测下来SCLK频率拉到2MHz左右刷一帧全屏纯色大约需要40多毫秒显示静态界面、数字波形、简单动画都感受不到明显卡顿。如果要做视频级别的刷屏那才需要考虑硬件SPI加上DMA。引脚选择上有个小技巧优先选择同一组GPIO、且靠近的引脚比如PA5、PA6、PA7这种连续的这样代码配置方便布线也干净。我试过跨越两组GPIO口代码里多写几个RCC时钟使能不算什么但查错的时候容易头晕。2. 硬件连接与屏幕选型要点1.8寸TFT屏模块虽然牌子很多但主控基本都是ST7735S或者ST7735R两者指令集几乎完全兼容驱动代码可以通用。屏幕模块一般引出8个引脚VCC、GND、CS、RESET、A0也叫DC、SDAMOSI、SCK、LED背光。我习惯的接线方案是这样的以STM32F103C8T6为例屏幕引脚功能说明接STM32引脚VCC电源正极 3.3V3.3VGND电源地GNDCS片选低电平有效PA4RESET复位低电平有效PA3A0/DC数据/命令选择PA2SDA/MOSI数据线PA1SCK时钟线PA0LED背光控制3.3V或PWM引脚注意这块屏幕的VCC最好接3.3V不要接5V。虽然模块上一般有稳压电路但长时间5V供电容易发热而且ST7735S的逻辑电平是3.3V和STM32的IO电平正好匹配。如果用的是5V单片机比如Arduino UNO就需要电平转换或者串电阻分压。背光LED引脚别直接悬空不然屏幕亮度会很暗甚至不亮。固定接3.3V是常亮方案如果想实现自动熄灭可以接到任意一个PWM引脚上通过调整占空比控制亮度。我习惯用定时器的PWM输出接背光这样晚上调试的时候还能调暗一点省眼。复位引脚也要重点说。如果直接把RST接到STM32的复位脚上屏幕会比MCU晚一点上电有时会因为时序问题导致初始化失败。更稳妥的做法是接到一个普通GPIO上在初始化代码里手动拉低再拉高实现软件复位。我早期图省事直接接3.3V结果偶尔会出现花屏后来改成软件复位问题就消失了。3. ST7735S初始化时序与关键寄存器配置ST7735S是一颗比较“讲究”的屏幕驱动IC上电之后不能直接写像素数据必须先发送一串初始化命令。这些命令的作用是关闭睡眠模式、设置显示方向、调整Gamma曲线、开启显示等。我把它类比成电脑开机后的BIOS自检不做这步屏幕就处于不稳定的状态。完整初始化流程大致分为四个阶段软件复位拉低RST引脚至少10ms然后拉高退出睡眠模式发送SLPOUT指令延时120ms以上参数配置设置帧率、Gamma、显示窗口等开启显示发送DISPON指令进入正常显示状态这里有个关键点ST7735S的延时要求非常严格。比如退出睡眠模式后规格书上写的是“需要等待120ms”如果延时不够后面的命令可能不生效表现为屏幕白屏或者显示异常。我实测下来延时时长宁多勿少特别是第一次上电初始化延时都翻倍处理稳定性会好很多。如果只是想快速点亮屏幕网上流传的“简化初始化序列”就够了不用逐条理解每个寄存器的含义。但如果屏幕颜色偏色、对比度不对那就要检查Gamma校正相关的寄存器设置了。不同批次屏幕对Gamma参数有轻微差异我调过的一块屏红色偏淡后来把Gamma曲线相关寄存器微调了两个值颜色就正了。另外**显示方向寄存器MADCTL**也很关键。它决定像素扫描方向也就是屏幕的正反横竖。这个寄存器是0x36设置值不同对应四种方向设置值显示效果0x00竖屏正常方向0x60横屏顺时针旋转90度0xC0横屏逆时针旋转90度0xA0竖屏反向显示我一般用0x00竖屏因为128x160的分辨率竖着看最自然。如果做的是仪表盘类界面横屏显示更合适改一个寄存器值就行不需要动坐标换算逻辑。4. 软件SPI核心代码实现与逐行解读接下来进入正题。软件SPI的代码其实不复杂核心就是三个函数SPI_WriteByte、LCD_WriteReg和LCD_WriteData。我贴上自己常用的实现这段代码在STM32F1和F4上都验证过移植的时候只需要改引脚宏定义。// 引脚宏定义根据自己的接线修改 #define LCD_CS_H GPIO_SetBits(GPIOA, GPIO_Pin_4) #define LCD_CS_L GPIO_ResetBits(GPIOA, GPIO_Pin_4) #define LCD_RST_H GPIO_SetBits(GPIOA, GPIO_Pin_3) #define LCD_RST_L GPIO_ResetBits(GPIOA, GPIO_Pin_3) #define LCD_DC_H GPIO_SetBits(GPIOA, GPIO_Pin_2) #define LCD_DC_L GPIO_ResetBits(GPIOA, GPIO_Pin_2) #define LCD_SDA_H GPIO_SetBits(GPIOA, GPIO_Pin_1) #define LCD_SDA_L GPIO_ResetBits(GPIOA, GPIO_Pin_1) #define LCD_SCL_H GPIO_SetBits(GPIOA, GPIO_Pin_0) #define LCD_SCL_L GPIO_ResetBits(GPIOA, GPIO_Pin_0) // 软件SPI发送一个字节MSB先行 void SPI_WriteByte(uint8_t data) { for (uint8_t i 0; i 8; i) { // 先拉低时钟确保数据建立后再拉高避免毛刺 LCD_SCL_L; // 根据当前位设置数据线电平 if (data 0x80) LCD_SDA_H; else LCD_SDA_L; // 拉高时钟数据被采样 LCD_SCL_H; // 移到下一位 data 1; } } // 写命令DC0 void LCD_WriteReg(uint8_t reg) { LCD_DC_L; LCD_CS_L; SPI_WriteByte(reg); LCD_CS_H; } // 写数据DC1 void LCD_WriteData(uint8_t data) { LCD_DC_H; LCD_CS_L; SPI_WriteByte(data); LCD_CS_H; } // 写一串数据用于设置窗口、填充颜色等 void LCD_WriteDataBuffer(uint8_t *buf, uint16_t len) { LCD_DC_H; LCD_CS_L; for (uint16_t i 0; i len; i) { SPI_WriteByte(buf[i]); } LCD_CS_H; }这个实现里有几个细节值得展开讲。为什么MSB先行ST7735S的数据格式是高位在前MSB First所以从最高位bit7开始逐位发送。如果你改成LSB先行屏幕显示的字符就会变成镜像排查起来很奇怪。为什么每个bit先拉低SCLK再拉高这是模拟SPI时序的标准写法目的是保证数据线在时钟上升沿之前稳定。可以理解为先把数据放到桌子上再按快门拍照这样拍到的内容才是准的。如果顺序反了或者不加延时高速运行时很容易采到过渡状态的毛刺。CS引脚要不要每次都拉高拉低我见过有些人省事CS固定拉低不操作。这在单设备场景确实能用但如果总线上还挂了其他SPI设备CS不控制就会出现数据冲突。所以我习惯在每次传输前后都操作CS虽然多几条语句但兼容性最好。关于延时上面的代码没有加任何空循环延时这是因为在72MHz主频下几条GPIO操作指令的时间间隔已经足够满足ST7735S对SPI时序的最低要求。实测2MHz左右的SCLK频率没有任何问题。如果你移植到主频很低的MCU比如8MHz的AVR可能需要在时钟拉高后加几个nop或者空语句否则屏幕会显示花屏或雪花点。4.1 画点函数全部显示功能的地基有了上面的基础函数接下来就可以实现最核心的画点操作了。ST7735S的显存更新逻辑是先设置显示窗口Column和Page范围然后连续写入颜色数据颜色数据会自动填充到窗口内的每个像素点。// 设置显示窗口 void LCD_SetWindow(uint8_t x1, uint8_t y1, uint8_t x2, uint8_t y2) { LCD_WriteReg(0x2A); // 列地址设置 LCD_WriteData(0x00); LCD_WriteData(x1 2); // ST7735S有2像素偏移补偿 LCD_WriteData(0x00); LCD_WriteData(x2 2); LCD_WriteReg(0x2B); // 行地址设置 LCD_WriteData(0x00); LCD_WriteData(y1 1); // 行方向偏移一般为1 LCD_WriteData(0x00); LCD_WriteData(y2 1); LCD_WriteReg(0x2C); // 开始写显存 }这里有个新手容易忽略的偏移问题ST7735S的驱动IC虽然分辨率是128x160但它的显存实际上比可视区域多了一圈无效像素不同厂家的屏幕偏移量还不一样。上面代码里的2和1是对应常见1.8寸模块的参数。如果你的屏幕显示内容整体偏左、偏上调这两个偏移值就能纠偏这是我调试时挫败感最强的一个地方一度以为屏坏了后来才发现是偏移值的问题。// 画一个像素点 void LCD_DrawPixel(uint8_t x, uint8_t y, uint16_t color) { LCD_SetWindow(x, y, x, y); LCD_WriteData(color 8); // 高字节 LCD_WriteData(color 0xFF); // 低字节 }颜色值是RGB565格式16位高5位红色、中间6位绿色、低5位蓝色。所以需要拆成两个字节发送先高后低。这里如果顺序搞反显示效果就是红蓝互换一种很奇怪的偏色。4.2 全屏填充和字符显示画点有了填充就简单了循环调用就行。但直接循环画点效率太差全屏160x128 20480个点每个点都调一次SetWindow耗时很长。更好的做法是设置一次窗口然后连续写入所有像素数据// 全屏填充指定颜色 void LCD_Fill(uint16_t color) { uint8_t hi color 8; uint8_t lo color 0xFF; LCD_SetWindow(0, 0, 127, 159); LCD_DC_H; LCD_CS_L; for (uint32_t i 0; i 128 * 160; i) { SPI_WriteByte(hi); SPI_WriteByte(lo); } LCD_CS_H; }这样一帧全屏填充只需要设置一次窗口大大减少了命令交互次数。实测下来的耗时比逐点画快了一个数量级。同理字符显示也是这个思路提前在代码里存好字模数组取模时把每个像素点转成颜色字发送。给新手的建议先把画点和填充调通屏幕上能看到颜色了再进行字符、图形、图片的显示。不要一上来就想着显示图片显示链路太长出了问题很难定位。5. 完整初始化序列代码初始化这部分我直接给出可用的序列。这段代码在多个项目里跑过兼容性好基本不会踩坑void LCD_Init(void) { // 引脚初始化GPIOA的0-4号引脚推挽输出50MHz GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3 | GPIO_Pin_4; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); // 硬件复位 LCD_RST_L; delay_ms(50); LCD_RST_H; delay_ms(50); // 软件复位ST7735S专用命令 LCD_WriteReg(0x01); // SWRESET delay_ms(150); // 退出睡眠模式 LCD_WriteReg(0x11); // SLPOUT delay_ms(150); // 帧率设置 LCD_WriteReg(0xB1); LCD_WriteData(0x01); LCD_WriteData(0x2C); LCD_WriteData(0x2D); LCD_WriteReg(0xB2); LCD_WriteData(0x01); LCD_WriteData(0x2C); LCD_WriteData(0x2D); LCD_WriteReg(0xB3); LCD_WriteData(0x01); LCD_WriteData(0x2C); LCD_WriteData(0x2D); LCD_WriteData(0x01); LCD_WriteData(0x2C); LCD_WriteData(0x2D); // 电源控制 LCD_WriteReg(0xC0); LCD_WriteData(0xA2); LCD_WriteData(0x02); LCD_WriteData(0x84); LCD_WriteReg(0xC1); LCD_WriteData(0xC5); LCD_WriteReg(0xC2); LCD_WriteData(0x0A); LCD_WriteData(0x00); LCD_WriteReg(0xC3); LCD_WriteData(0x8A); LCD_WriteData(0x2A); LCD_WriteReg(0xC4); LCD_WriteData(0x8A); LCD_WriteData(0xEE); // 显示方向竖屏正常 LCD_WriteReg(0x36); LCD_WriteData(0x00); // 颜色格式16位RGB565 LCD_WriteReg(0x3A); LCD_WriteData(0x05); // Gamma校正 LCD_WriteReg(0xE0); 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_WriteReg(0xE1); 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_WriteReg(0x29); // DISPON delay_ms(100); // 全屏填充白色验证初始化是否成功 LCD_Fill(0xFFFF); }这段代码里最需要注意的延时点SWRESET之后必须延时150msSLPOUT之后也必须延时150ms。有些精简代码把延时缩到50ms甚至不延时短时间能用但多次上电会出现偶发初始化失败表现为白屏。我做产品测试时反复上电几百次延时不足的代码能复现出大约2%的失败率加足延时后失败率降为零。那组Gamma参数E0和E1指令是参考正常屏幕的值不用太纠结每个数字的含义它对显示效果的影响主要是颜色过渡自然度和对比度。如果你发现屏幕颜色发灰或者暗部细节丢失可以对比着调整这几组数据但大多数情况下默认值就很好了。6. 常用显示函数与字符字模实现初始化完成只是第一步真正要用起来至少需要字符显示功能。一个比较简单的方案是使用取模软件生成字模数组把每个字符的像素信息存成8x16的二维数组。我这里用的是ASCII码表范围是0x20空格到0x7E波浪号总共95个字符每个字符占用16个字节每行8个像素点从上到下16行。// 在指定位置显示一个ASCII字符 // x: 列起始位置0-127 // y: 行起始位置0-159 // c: 要显示的字符 // color: 字体颜色 // bgcolor: 背景颜色 void LCD_ShowChar(uint8_t x, uint8_t y, char c, uint16_t color, uint16_t bgcolor) { uint8_t i, j; uint8_t temp; // 取字符对应的字模数据 // 字模数组定义方式const uint8_t asc2_1608[95][16] for (i 0; i 16; i) { temp asc2_1608[c - 0x20][i]; for (j 0; j 8; j) { if (temp 0x80) LCD_DrawPixel(x j, y i, color); else LCD_DrawPixel(x j, y i, bgcolor); temp 1; } } }这个函数看起来简单但有个性能问题每个字符要调用16x8 128次LCD_DrawPixel而每次DrawPixel都要设置显示窗口开销很大。显示一行16个字符就需要2048次窗口设置速度会比较慢。更高效的方案是行缓冲先通过窗口设置设定好当前字符的显示区域宽8像素高16像素然后一行一行地解析字模数据把当前行的8个像素点判断好颜色后一次性写入。这样每个字符只需要调用一次SetWindow速度能快5到8倍。对于我们在软件SPI下追求速度的场景这个优化非常有意义。// 高效版本先设窗口再逐行填充 void LCD_ShowChar_Fast(uint8_t x, uint8_t y, char c, uint16_t color, uint16_t bgcolor) { uint8_t i, j; uint8_t temp; uint8_t hi, lo; // 设置窗口宽8像素高16像素 LCD_SetWindow(x, y, x 7, y 15); LCD_DC_H; LCD_CS_L; for (i 0; i 16; i) { temp asc2_1608[c - 0x20][i]; for (j 0; j 8; j) { if (temp 0x80) { hi color 8; lo color; } else { hi bgcolor 8; lo bgcolor; } SPI_WriteByte(hi); SPI_WriteByte(lo); temp 1; } } LCD_CS_H; }这里需要注意LCD_SetWindow函数内部已经做了LCD_WriteReg(0x2C)也就是进入了写显存模式之后连续发送的数据会按窗口顺序填充像素。所以在SetWindow之后DC引脚要保持高电平不再发送命令直到本窗口写满。做了这个优化之后显示满屏字符的耗时大概从几百毫秒降到了几十毫秒体感上流畅了很多。6.1 中文字符显示怎么办STM32F103的Flash只有64KB塞全字库不现实所以中文显示一般用两种方案取模软件局部字库把用到的汉字提前取模生成16x16或者24x24的点阵数组编译进代码。优点是零外部依赖、速度快缺点是加新字需要重新取模。外部Flash字库把完整字库文件比如通行的UCDOS字库烧录到外部SPI Flash里通过文件系统或者偏移量读取。优点是字全缺点是驱动复杂需要额外的Flash芯片。我个人的建议是如果项目只是显示“温度”“湿度”“电压”这几个固定词局部字库方案完全够用。做毕业设计或者小产品局部字库省事省钱没必要上外部Flash。取模软件我用的是PCtoLCD2002设置成“逐行式”取模、阴码、字节正序导出后直接贴进代码就行。7. 常见问题与排查技巧实录驱动屏幕翻车的概率其实很高我把这几年遇到过的问题整理成一份速查表基本覆盖了绝大部分排查场景。问题现象可能原因排查方向与解决方案白屏初始化序列未执行成功检查RST引脚是否正常拉低拉高SLPOUT后延时是否足够初始化代码是否完整执行白屏背光未点亮测量LED引脚电压确认是否接3.3V或PWM输出花屏/雪花SCL频率太高数据建立时间不足在SPI_WriteByte的循环里加几个空延时降低模拟时钟频率花屏/雪花数据线接触不良用万用表测通断重点检查杜邦线连接实在不行直接焊接颜色错乱RGB565字节顺序反了Swap write data的高低位顺序把color 8和color 0xFF调换显示内容偏移窗口偏移值错误调整LCD_SetWindow函数里的2和1偏移补偿值显示为镜像发送位序错误确认SPI_WriteByte是高位先行MSB First不是低位先行屏幕亮度不均匀供电不足用万用表量VCC引脚电压3.3V供电电流不够时换独立稳压偶发白屏/重启后不显示复位时序不稳使用软件复位RST接GPIO控制初始化前保证至少50ms低电平字符有拖影背景色没填充检查显示字符函数是否同时写入bgcolor而不是只写前景色这里面我踩得最深的是花屏问题。有一次项目联调屏幕偶尔出现雪花点一开始怀疑是屏幕质量问题换了一块新的还是这样。后来用示波器抓SCLK和SDA的波形发现SCLK高电平只有几十纳秒数据线在采样点还没完全稳定就拉高了时钟。解决方式就是在SCLK拉高之后加了两个空延时指令问题立刻消失。所以说软件SPI虽然简单但波形质量还是需要关注一下尤其是主频高的MCU上。另一个容易忽略的是杜邦线的长度和接触质量。软件SPI信号本身抗干扰能力一般杜邦线超过10厘米就容易出问题。我做过一个测试同一套代码10厘米杜邦线正常显示30厘米杜邦线就开始偶尔花屏。所以项目定型后尽量用PCB走线或者短排线别指望杜邦线一劳永逸。8. 扩展思路从单色填充到简单GUI基础驱动打通之后往上扩展的空间很大。我这里列几个我实际做过、效果不错的方向画图和曲线基于画点函数可以快速实现波形显示。比如ADC采集电压值映射到屏幕Y轴边采集边画点就是一台简易示波器。这类应用对刷新率要求不高软件SPI够用。菜单界面用显示字符函数配合按键输入做一个循环菜单的框架。菜单项高亮反色显示按键切换选中项逻辑清晰代码量也不大。我做的智能台灯项目就是这种结构屏幕显示亮度和色温按键调节。图片显示先用取模软件把图片转换成C数组然后用LCD_Fill类似的方式批量写显存。128x160的图片用RGB565格式需要40KB的数组空间超出了STM32F103的RAM所以一般用const存到Flash或者用RGB444格式压缩数据量。我试过把一张彩色Logo图存到Flash里做开机画面效果挺不错的。叠加显示在实时刷新区域先画背景色再画前景图形。比如一个动态数值区域刷新前用背景色填充矩形再写新数字这样就不会出现数字叠加残影的问题。这些功能本质上都是在画点函数之上封装建议自己动手封装一套简单的GUI库不需要用完整版的u8g2或者LVGL那些库对内存和Flash的要求更高1.8寸小屏用它们反而杀鸡用牛刀。9. 写在最后的几个实操心得按照我个人经验1.8寸TFT初始化显示这件事只要把时序和延时把握好一次成功的概率很高。我最后再分享几个小细节取模方向一定要和代码匹配。如果字模取的是列行式代码却按逐行式解析显示出来的字就是横七竖八乱码。这个错误很难一眼看出我排查过一次折腾了老半天最后重新取模才解决。初始化命令里有些参数需要根据屏幕批次微调。不同厂家出的1.8寸屏模块虽然都是ST7735S芯片但像素偏移值、Gamma参数可能存在微小的差别。如果第一片屏调通了换了一批屏发现颜色偏色优先对比这两处的寄存器配置。工程里建议加一个屏幕自检函数开机时在屏幕四个角和中心各画一个指定颜色的方块用来快速判断屏幕是否正常工作。这个函数对产线测试和维护都很有用我后面的项目基本都保留了这个功能。软件SPI的极限速度不要硬顶。我之前试过把SCLK频率拉得很高结果只能稳定运行一两分钟之后就开始花屏。既然选择了软件SPI就接受它在速度上的妥协稳定永远比快重要。这篇文章基本把我从一个“点亮屏幕”到“稳产品化”过程中积累的经验都整理出来了。代码部分可以直接抄遇到问题就对照速查表排查。做屏幕驱动这件事看起来简单真正踩一遍坑才会发现每个细节都是有原因的。