ARTICLE DETAIL

建站实战干货

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

ST7735屏幕双圆盘仪表显示:嵌入式图形算法与性能优化实践

2026/8/19 1:25:58 拓冰建站 浏览量
ST7735屏幕双圆盘仪表显示:嵌入式图形算法与性能优化实践 1. 项目概述在ST7735屏幕上实现双圆盘仪表显示最近在做一个嵌入式显示项目需要在一块小巧的ST7735驱动的TFT屏幕上同时显示两个实时更新的圆盘仪表。这个需求在很多监控类设备上很常见比如同时显示当前的温度和压力、电池的电压和电流或者电机转速和负载。ST7735是一款非常经典的1.8英寸彩色LCD驱动芯片成本低、接口简单在Arduino、STM32等开源硬件项目中应用极广。但它的分辨率通常只有128x160或128x128要在这么小的屏幕上清晰地绘制两个带刻度和指针的圆盘并且保证刷新流畅对图形算法和驱动优化是个不小的挑战。我折腾了好一阵子从最基础的画点画线开始到最终实现平滑抗锯齿的指针动画踩了不少坑也总结出一套比较高效的方案。这个方案的核心不仅仅是“画出来”更是如何在资源受限的单片机比如常见的STM32F103甚至Arduino UNO上让仪表看起来专业、响应迅速。如果你也在为类似的项目头疼希望这篇从原理到代码、从优化到避坑的完整记录能帮到你。2. 核心思路与架构设计2.1 为什么选择ST7735与圆盘仪表ST7735屏幕价格亲民SPI接口占用MCU引脚少且有成熟的驱动库如Adafruit_ST7735、TFT_eSPI等支持生态完善。圆盘仪表是一种极其有效的信息可视化方式它比单纯的数字显示更能直观地反映数值的相对位置如“快满了”、“过半了”、“危险低位”符合人的直觉认知。在工业HMI、车载设备、智能家居中控等场景下双仪表并列显示可以提供一目了然的对比信息。然而挑战在于分辨率限制128x160的屏幕扣除边距每个仪表的有效绘制区域可能只有60x60像素左右。在这个区域内要绘制出带刻度的圆环、数字标签和一根清晰的指针对绘图精度要求很高。刷新效率仪表数据通常是实时变化的。如果每次更新都重绘整个屏幕全屏刷新会非常缓慢且导致严重的闪烁。必须采用局部刷新技术。内存与计算资源单片机RAM有限无法缓存整张图片。所有绘图指令画圆、画线、填充都需要实时计算并且要高效。我的设计思路是“静态背景一次绘制动态指针局部更新”。将仪表的静态部分表盘、刻度、固定文字作为背景只在初始化时绘制一次。动态的指针则通过计算其新旧位置只擦除旧指针并绘制新指针极大减少每帧的绘图量。2.2 系统架构与模块划分为了实现清晰的双仪表显示我将整个软件架构分为以下几个层次硬件驱动层负责最底层的ST7735屏幕初始化、SPI通信、发送像素数据。这部分通常由成熟的库完成我们需要关注的是其提供的画图APIdrawPixel,drawLine,drawCircle,fillCircle等的性能。图形基元层在驱动库API之上封装更适用于仪表绘制的函数。例如绘制带角度的指针一条从圆心出发的线段、绘制圆弧刻度、在指定角度位置绘制标签文字。这一层需要处理坐标计算和角度转换。仪表对象层这是核心。定义一个Gauge结构体或类包含仪表的属性圆心坐标 (x, y)半径 (radius)量程范围 (minValue, maxValue)当前值 (currentValue)当前指针角度 (currentAngle)背景色、前景色、指针颜色是否需要重绘背景的标志位。 该层提供Gauge_Init()、Gauge_DrawBackground()、Gauge_UpdateValue()等方法。应用逻辑层创建两个Gauge实例设置各自的参数如一个量程0-100一个量程0-5.0。在主循环中从传感器或通信接口读取数据调用Gauge_UpdateValue()更新每个仪表并管理整体的刷新逻辑。通过这种分层代码结构清晰易于维护和移植到不同的平台或屏幕驱动库上。3. 核心图形算法与实现细节3.1 仪表盘背景的绘制仪表盘背景是静态的但绘制它需要一些几何计算。我们假设仪表是一个270度的圆弧从-135度到135度留出底部空间而不是一个完整的圆。绘制刻度线这是最繁琐的部分。主要刻度比如每10个单位一个和次要刻度比如每2个单位一个需要分别绘制。思路是在表盘圆周上根据量程和刻度间隔计算出每个刻度点对应的角度然后计算该角度下圆周内外的两个点坐标连接这两个点就是刻度线。// 伪代码示例绘制刻度线 void drawScaleTick(int centerX, int centerY, int radius, float angle, int tickLength, int color) { float rad angle * PI / 180.0; // 角度转弧度 // 计算刻度线外端点 int x1 centerX (radius - tickLength) * cos(rad); int y1 centerY (radius - tickLength) * sin(rad); // 注意屏幕Y轴向下 // 计算刻度线内端点 int x2 centerX radius * cos(rad); int y2 centerY radius * sin(rad); tft.drawLine(x1, y1, x2, y2, color); // 调用驱动库画线 }你需要遍历从起始角度到结束角度的所有刻度位置调用这个函数。对于主要刻度可以画长一点的线并在其末端绘制数字标签。绘制标签文字时需要将文字中心点定位在刻度线外端点的延长线上这涉及到更精细的坐标微调通常需要根据字体大小进行偏移。绘制表盘圆弧使用驱动库的drawArc或drawCircle函数如果库支持圆弧。如果不支持可以用多个短线段来近似模拟一个圆弧。注意在资源紧张的MCU上频繁调用sin和cos函数计算坐标是性能瓶颈。一个优化技巧是预先计算好所有刻度线的端点坐标并存入数组。因为背景是静态的我们可以在初始化时花点时间计算并存储这些坐标以后绘制背景时直接使用存储的坐标画线避免了运行时的浮点三角函数计算。对于只有几十个刻度的表盘这个数组很小但带来的性能提升是显著的。3.2 指针的绘制与动画更新指针的动态更新是整个项目的核心难点目标是无闪烁、平滑。指针绘制指针通常是一条从圆心指向某个角度的粗线段。我们可以用drawLine函数但为了美观可以绘制成三角形或菱形。一个简单有效的方法是画一条有宽度的线从圆心出发沿着指针方向画一条主线然后在主线两侧各画一条短平行线最后填充这个区域。在低分辨率下一条2-3像素宽的线就足够了。关键算法局部更新Partial Update全屏刷新tft.fillScreen(BLACK)再重画所有内容会导致严重闪烁绝对不可取。正确的做法是记录旧指针位置在Gauge对象中保存lastAngle。计算新指针位置根据新数值newValue按比例换算到角度范围得到newAngle。angle startAngle (value - minValue) * (endAngle - startAngle) / (maxValue - minValue)擦除旧指针在旧指针角度上用背景色即表盘的颜色而不是黑色重新绘制一次指针。这相当于“擦除”。绘制新指针在新指针角度上用指针颜色绘制指针。更新记录将lastAngle更新为newAngle。这里有一个巨大的坑如果指针比较粗或者表盘背景不是纯色比如有渐变色或网格简单的用背景色重画旧指针可能会擦不干净或者把背景图案也破坏了。因此更稳健的方案是采用双缓冲区的思想但只在指针区域方案A精确擦除将指针覆盖的矩形区域内的原始背景像素保存下来。更新时先把这个保存的像素块写回屏幕恢复背景再计算并绘制新指针同时保存新指针区域的背景。这对内存要求较高。方案B重绘局部背景如果指针移动范围不大可以计算新旧指针区域所覆盖的最大扇形区然后只重绘这个扇形区域内的背景刻度、数字等最后画上新指针。这需要你的Gauge_DrawBackground()函数支持指定角度范围绘制。虽然比重绘整个背景快但比方案A计算复杂。对于ST7735和大多数单片机项目我推荐一个折中且高效的方案将指针设计得细一些1-2像素宽并且确保指针扫过的区域背景是纯色。这样用背景色画线“擦除”旧指针的效果就很好实现简单性能极高。3.3 双仪表的布局与协同刷新在128x160的屏幕上布局两个仪表通常采用左右并排。每个仪表的圆心和半径需要精心计算留出足够的边距和中间间隔防止视觉拥挤。// 示例布局计算 #define SCREEN_WIDTH 128 #define SCREEN_HEIGHT 160 #define GAUGE_RADIUS 30 #define MARGIN 10 Gauge gaugeLeft, gaugeRight; gaugeLeft.x MARGIN GAUGE_RADIUS; gaugeLeft.y SCREEN_HEIGHT / 2; // 垂直居中 gaugeLeft.radius GAUGE_RADIUS; gaugeRight.x SCREEN_WIDTH - MARGIN - GAUGE_RADIUS; gaugeRight.y SCREEN_HEIGHT / 2; gaugeRight.radius GAUGE_RADIUS;协同刷新当两个仪表的数据源更新频率不同时比如一个温度每秒更新一次一个转速每100毫秒更新一次我们需要分别管理它们的刷新。最好的方式是在每个仪表的Update函数内部判断数值是否发生变化只有变化超过一定阈值时才触发重绘指针的操作。这样可以避免不必要的屏幕操作节省CPU和SPI带宽。在主循环中逻辑非常简单void loop() { float temp readTemperature(); // 读取传感器 float rpm readRPM(); gaugeLeft.updateValue(temp); gaugeRight.updateValue(rpm); delay(50); // 控制整体刷新率例如20Hz }4. 性能优化与抗锯齿技巧在单片机上做图形性能永远是第一考量。以下是我实测有效的优化手段使用整数运算尽量避免浮点数。角度可以用整数例如将0-360度放大100倍用0-36000的整数表示。坐标计算时使用定点数数学。很多图形库内部都使用整数运算。预先计算与查表如前所述将正弦、余弦值预先计算好存入数组查表法是图形编程的经典优化。对于仪表你可以预先计算好0-360度或你的角度范围每隔1度或0.5度的sin和cos值存储为整数。精简通信ST7735的SPI通信速度是瓶颈。确保使用硬件SPI并设置到最高允许速率。此外驱动库中的startWrite()和endWrite()函数非常重要它们将多次SPI写操作包裹在一个事务中减少了通信开销能显著提升连续画图的速度。使用DMA如果MCU支持对于STM32等高级MCU可以使用DMA来搬运显存数据到SPI外设从而在传输数据时解放CPU去做其他计算。不过很多轻量级驱动库需要修改才能支持DMA。抗锯齿在低分辨率屏幕上画斜线或圆弧锯齿感阶梯状很明显。虽然单片机难以实现真正的抗锯齿但我们可以用一些技巧来改善Wu氏抗锯齿画线一种经典的算法通过调整线条路径上相邻像素的亮度来平滑边缘。但计算量稍大需要灰度支持ST7735是16位色可以模拟。简单模糊对于指针可以不用纯色而用从中心向边缘渐变的颜色例如中心用深红色边缘用浅红色可以在视觉上减弱锯齿感。但这需要绘制更复杂的指针形状。分辨率妥协在很小的屏幕上过于追求平滑可能得不偿失。有时接受轻微的锯齿但换来更高的帧率和响应速度是更实用的选择。确保指针移动时是连贯的比绝对平滑更重要。5. 常见问题与调试心得在实际开发中我遇到了不少问题这里记录下最典型的几个及其解决方案。5.1 指针刷新闪烁或残留现象指针移动时后面有拖影或者旧指针擦不干净。排查检查“擦除”旧指针时使用的颜色是否正确。一定要用指针所在区域的背景像素颜色而不是默认的黑色。如果你的表盘是蓝色的就用蓝色画线去擦。检查刷新顺序。必须是“擦旧” - “画新”。如果顺序反了会出现一瞬间两个指针。增加延时观察。在擦旧和画新之间加一个很小的延时如delay(5)如果闪烁模式变化说明是屏幕刷新率的问题。这可能是因为屏幕的刷新速度跟不上MCU的发送速度。解决方案是确保在startWrite()和endWrite()之间完成一次完整的局部更新操作让屏幕一次性接收所有更改指令。5.2 屏幕显示错乱或花屏现象仪表显示一段时间后屏幕部分区域出现乱码或颜色错误。排查内存溢出这是最常见的原因。检查所有缓冲区、数组是否越界。特别是如果你用了任何像素缓存数组。SPI干扰确保SPI的时钟线SCK、数据线MOSI上连接了上拉电阻并且走线远离高频噪声源。过长或凌乱的杜邦线很容易引入干扰。电源问题ST7735屏幕工作时需要一定的电流峰值可能超过100mA。确保你的开发板或电源模块能提供稳定、充足的5V或3.3V电源。电源不稳会导致驱动芯片复位或通信错误。建议在屏幕的VCC和GND之间并联一个100uF的电解电容。5.3 仪表响应速度慢现象数据更新后指针要明显滞后一段时间才移动。排查计算瓶颈使用性能分析工具或简单用GPIO翻转测时间定位是哪个函数耗时最长。通常是三角函数计算、浮点除法或复杂的循环。通信瓶颈尝试提高SPI时钟频率。确认是否使用了startWrite()/endWrite()。不必要的全屏操作检查代码中是否有隐藏的fillScreen()或setAddrWindow()调用它们会导致整个帧缓冲区被重置极其耗时。5.4 在不同驱动库间移植问题现象代码在Adafruit库上运行正常换到TFT_eSPI库就显示不对。排查坐标系统差异不同库的坐标原点0,0可能不同有的在左上角有的在左下角drawPixel和drawLine的参数顺序也可能不同。仔细阅读新库的文档。颜色格式ST7735通常使用RGB565格式16位色。但不同库定义颜色的宏可能不同如ST7735_BLACKvsTFT_BLACK或者颜色字节顺序高位/低位有区别。确保你传入的颜色值是正确的格式。初始化序列屏幕的初始化命令序列可能因屏幕厂商不同而有细微差别。确保你使用的驱动库和屏幕型号匹配或者根据屏幕数据手册调整初始化代码。最后分享一个调试小技巧在开发初期不要急于画两个完整的仪表。可以先画一个静态的圆和一根能随变量移动的指针把这个基本功能调通。然后加上刻度最后再处理双仪表布局和刷新逻辑。分步推进遇到问题时范围更小更容易定位。当看到两个指针随着传感器数据流畅地在屏幕上转动时那种成就感绝对是驱动我们这些硬件开发者继续折腾下去的最大动力。