1. 项目概述:从像素到图形的跨越
在嵌入式显示和人机交互领域,TFT-LCD屏是绝对的主流。很多朋友拿到一块TFT屏,跑通了厂家提供的Demo,显示了几张图片和文字后,下一步最自然的需求就是:我能不能在上面画点东西?比如画一条线、画个方框、画个圆?这个需求看似简单,背后却串联起了从底层驱动到上层图形算法的完整知识链。它不仅仅是调用一个drawLine()函数那么简单,而是理解如何在最基础的像素操作之上,构建出更高级的图形原语。
这个项目,就是深入探讨如何在TFT-LCD上,不依赖复杂的图形库,从零实现直线、矩形和圆形的绘制。这不仅是嵌入式GUI开发的基石,也是理解计算机图形学中光栅化算法最直观的入门实践。无论是为你的智能家居设备设计一个简洁的仪表盘,还是在工业HMI上绘制一个动态的波形图框,亦或是为小游戏绘制基本的图形元素,这套技能都至关重要。你会发现,掌握了这些,你就拥有了在屏幕上“创造”形状的基本能力。
2. 核心原理与算法选型
在开始写代码之前,我们必须搞清楚一个根本问题:屏幕是由离散的像素点组成的矩阵,而我们想要绘制的直线、圆是连续的数学概念。如何用这些离散的像素点,最“像”地表示出连续的图形?这就是光栅化的核心任务。不同的算法决定了绘制效率、准确度和视觉效果。
2.1 直线绘制:Bresenham算法的统治地位
提到画直线,绝大多数从业者都会第一时间想到Bresenham算法。它为什么如此经典?因为它完美避开了浮点运算和乘法,仅用整数加减法和比较,就能确定一条直线上哪些像素应该被点亮,效率极高,特别适合单片机等资源受限的环境。
它的核心思想是“误差累积”。假设我们要从点(x0, y0)画到点(x1, y1)。我们每次在x方向上前进一个像素(步进),需要决策y方向是保持不变(y)还是也增加一个像素(y+1)。Bresenham算法通过一个误差项e来记录当前点与理想直线在垂直方向上的偏差。根据e的符号,决定y的走向,并更新e的值。
这里以斜率在0到1之间的直线为例(即dx > dy > 0):
- 计算差值
dx = x1 - x0,dy = y1 - y0。 - 初始化误差项
e = 2*dy - dx。 - 循环从x0到x1,每次绘制点(x, y)。
- 如果
e >= 0,则y++,并且e = e - 2*dx。 - 无论是否增加y,都执行
x++,并且e = e + 2*dy。
这个算法的精妙之处在于,整个循环中只有整数加法和比较。对于其他象限的直线,只需通过交换x、y角色和改变步进方向(+1或-1)即可适配。
注意:很多初学者会尝试用直线方程
y = kx + b直接计算每个x对应的y,然后四舍五入。这在PC上没问题,但在嵌入式系统中,浮点运算慢,且四舍五入会导致不均匀的像素分布。Bresenham是更专业的选择。
2.2 矩形绘制:最直接的填充策略
矩形绘制在概念上最简单,因为它本质上就是四条直线的组合。但实现上的考量点在于效率和功能。
一个空心矩形,就是分别绘制四条边。但这里有一个细节:如果简单地调用四次画线函数来画矩形的四条边,在四个角上,像素会被重复绘制(两次)。虽然视觉上可能看不出,但在一些对绘制操作非常敏感(如OLED屏)或需要精确计数的场景下,需要避免。更高效的方式是直接操作帧缓冲区或显存,水平方向连续画点,垂直方向连续画点。
对于实心矩形(填充矩形),核心是行填充。确定矩形的左上角(x1, y1)和右下角(x2, y2)后,我们只需要一个双层循环:外层循环遍历每一行y(从y1到y2),内层循环在这一行上从x1到x2画点。这是最高效的填充方式,因为内存访问是连续的,特别适合使用memset或DMA进行加速。
2.3 圆形绘制:中点圆算法
画圆也有一个与Bresenham直线算法齐名的经典算法:中点圆算法(Midpoint Circle Algorithm)。它同样是仅使用整数运算,通过决策参数来确定下一个像素点。
算法基于圆的八分对称性:只要计算出八分之一圆弧上的点,就能通过对称性得到整个圆。我们以圆心在原点(0,0),画第一象限从(0, R)到(R/sqrt(2), R/sqrt(2))的八分弧为例。
- 初始化:
x = 0,y = R, 决策参数d = 1 - R。 - 循环,当
x <= y时: a. 绘制8个对称点:(x, y),(-x, y),(x, -y),(-x, -y),(y, x),(-y, x),(y, -x),(-y, -x)。 b. 如果d < 0,选择下一个像素为(x+1, y),并更新d = d + 2*x + 3。 c. 否则(d >= 0),选择下一个像素为(x+1, y-1),并更新d = d + 2*(x - y) + 5。 d.x++。
这个算法的d就是当前点(x+1, y-0.5)到圆心的距离与半径R的误差。通过判断这个中点在圆内还是圆外,来决定y是否减少。
实操心得:中点圆算法画出的空心圆非常完美。但如果你需要画一个实心圆(填充圆),千万不要用多个同心圆去填充,那效率极低。正确做法是:在画每一个对称点对时,例如画
(x, y)和(-x, y)时,将这两点之间的水平线段填充。这样,对于每一对(x,y),你填充的是从-x到x,高度为y和-y的两条水平线。用这种方式,一次八分弧的计算就能完成整个实心圆的填充。
3. 硬件驱动与软件框架准备
算法是大脑,驱动是手脚。在将算法付诸实现前,必须为你的TFT-LCD搭建好稳定的驱动层。
3.1 底层像素操作函数的封装
无论你的屏幕驱动是SPI、8080并行接口还是RGB接口,最终都需要抽象出一个最基础的函数:void DrawPixel(uint16_t x, uint16_t y, uint16_t color)。这个函数是所有高级图形功能的基石。它的内部实现通常包括:
- 设置光标地址(设置
x,y坐标的寄存器)。 - 发送颜色数据(16位RGB565或24位RGB888)。
为了提高连续绘制的效率,你还需要实现一个void FillArea(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2, uint16_t color)函数,用于快速填充一个矩形区域。优秀的驱动会在此函数中使用块写入(Block Write)命令,或者配置DMA进行数据传输,这比单点绘制快几个数量级。
// 一个基于SPI的TFT驱动像素点绘制示例(伪代码) void TFT_DrawPixel(uint16_t x, uint16_t y, uint16_t color) { // 1. 设置地址窗口(通常是一个矩形,但这里我们只设一个点) TFT_SetAddrWindow(x, y, x, y); // 2. 发送颜色数据 TFT_WriteData(color >> 8); // 发送高字节 TFT_WriteData(color & 0xFF); // 发送低字节 } // 快速填充矩形区域 void TFT_FillRect(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2, uint16_t color) { uint32_t pixelCount = (x2 - x1 + 1) * (y2 - y1 + 1); TFT_SetAddrWindow(x1, y1, x2, y2); TFT_WriteColor(color, pixelCount); // 此函数应优化为连续发送 }3.2 坐标系统与颜色管理
在编写图形函数前,定义清晰的坐标系统。通常,屏幕左上角为原点(0,0),x轴向右增长,y轴向下增长。确保你的所有绘图函数都遵循这一约定,并在函数入口进行边界检查(Clipping)。如果传入的坐标超出了屏幕范围,应该被忽略或截断到有效边界,防止访问非法内存导致程序崩溃。
颜色格式也需要统一。最常用的是RGB565(16位:红色5位,绿色6位,蓝色5位),它在色彩表现和内存占用间取得了良好平衡。你的颜色参数和帧缓冲区(如果有)都应使用此格式。
3.3 软件架构设计
建议采用分层设计:
- 底层驱动层(Hardware Abstraction Layer, HAL):直接操作硬件寄存器,提供
DrawPixel、FillRect、WriteCommand等原始接口。 - 图形原语层(Graphics Primitives):实现本文核心的直线、矩形、圆形算法。这一层只调用底层的
DrawPixel或FillRect。 - 应用层:调用图形原语层,组合出更复杂的界面。
这样的分层便于移植。当你更换另一款TFT屏时,通常只需重写底层驱动层,上层的图形算法和应用代码几乎不用改动。
4. 核心图形算法的代码实现与优化
有了坚实的底层,现在我们可以用C语言来实现第二部分讨论的算法。这里我将提供经过实战检验的、可直接使用的代码。
4.1 直线绘制函数实现
以下是支持任意方向直线的Bresenham算法实现,并加入了基本的边界检查。
/** * @brief 在TFT上绘制一条直线 (Bresenham算法) * @param x0, y0: 起点坐标 * @param x1, y1: 终点坐标 * @param color: 线条颜色 (RGB565) * @retval 无 */ void TFT_DrawLine(int x0, int y0, int x1, int y1, uint16_t color) { int dx = abs(x1 - x0); int dy = abs(y1 - y0); int sx = (x0 < x1) ? 1 : -1; // x方向步进 int sy = (y0 < y1) ? 1 : -1; // y方向步进 int err = dx - dy; int err2; while (1) { // 绘制当前点,可在此处加入屏幕边界判断 if (x0 >= 0 && x0 < TFT_WIDTH && y0 >= 0 && y0 < TFT_HEIGHT) { TFT_DrawPixel(x0, y0, color); } if (x0 == x1 && y0 == y1) break; // 到达终点 err2 = 2 * err; if (err2 > -dy) { err -= dy; x0 += sx; } if (err2 < dx) { err += dx; y0 += sy; } } }优化技巧:对于水平线(dy==0)或垂直线(dx==0),可以特殊处理,直接调用FillRect函数来绘制,这比逐点绘制快得多。
4.2 矩形绘制函数实现
空心矩形和实心矩形的实现。
/** * @brief 绘制空心矩形 * @param x1, y1: 矩形左上角坐标 * @param x2, y2: 矩形右下角坐标 * @param color: 边框颜色 * @retval 无 */ void TFT_DrawRect(int x1, int y1, int x2, int y2, uint16_t color) { TFT_DrawLine(x1, y1, x2, y1, color); // 上边 TFT_DrawLine(x2, y1, x2, y2, color); // 右边 TFT_DrawLine(x2, y2, x1, y2, color); // 下边 TFT_DrawLine(x1, y2, x1, y1, color); // 左边 } /** * @brief 绘制实心矩形(填充) * @param x1, y1: 矩形左上角坐标 * @param x2, y2: 矩形右下角坐标 * @param color: 填充颜色 * @retval 无 */ void TFT_FillRect(int x1, int y1, int x2, int y2, uint16_t color) { // 交换坐标,确保x1<=x2, y1<=y2 if (x1 > x2) { int t = x1; x1 = x2; x2 = t; } if (y1 > y2) { int t = y1; y1 = y2; y2 = t; } // 边界裁剪 if (x1 < 0) x1 = 0; if (y1 < 0) y1 = 0; if (x2 >= TFT_WIDTH) x2 = TFT_WIDTH - 1; if (y2 >= TFT_HEIGHT) y2 = TFT_HEIGHT - 1; // 调用底层优化过的区域填充函数 TFT_FillArea(x1, y1, x2, y2, color); }注意:
TFT_FillArea应该是你底层驱动中高度优化的函数,它通过设置一次地址窗口,然后连续发送颜色数据来完成填充,效率远高于用DrawPixel画双层循环。
4.3 圆形绘制函数实现
基于中点圆算法,同时实现空心圆和实心圆。
/** * @brief 绘制空心圆 * @param x0, y0: 圆心坐标 * @param r: 半径 * @param color: 颜色 * @retval 无 */ void TFT_DrawCircle(int x0, int y0, int r, uint16_t color) { int x = 0; int y = r; int d = 1 - r; // 初始决策参数 while (x <= y) { // 利用八分对称性绘制8个点 TFT_DrawPixel(x0 + x, y0 + y, color); TFT_DrawPixel(x0 - x, y0 + y, color); TFT_DrawPixel(x0 + x, y0 - y, color); TFT_DrawPixel(x0 - x, y0 - y, color); TFT_DrawPixel(x0 + y, y0 + x, color); TFT_DrawPixel(x0 - y, y0 + x, color); TFT_DrawPixel(x0 + y, y0 - x, color); TFT_DrawPixel(x0 - y, y0 - x, color); if (d < 0) { d += 2 * x + 3; } else { d += 2 * (x - y) + 5; y--; } x++; } } /** * @brief 绘制实心圆(填充圆) * @param x0, y0: 圆心坐标 * @param r: 半径 * @param color: 颜色 * @retval 无 */ void TFT_FillCircle(int x0, int y0, int r, uint16_t color) { int x = 0; int y = r; int d = 1 - r; while (x <= y) { // 填充水平线,利用对称性一次填充四条线 // 从 (x0 - x) 到 (x0 + x) 在 y0+y 和 y0-y 行 TFT_DrawLine(x0 - x, y0 + y, x0 + x, y0 + y, color); TFT_DrawLine(x0 - x, y0 - y, x0 + x, y0 - y, color); // 从 (x0 - y) 到 (x0 + y) 在 y0+x 和 y0-x 行 TFT_DrawLine(x0 - y, y0 + x, x0 + y, y0 + x, color); TFT_DrawLine(x0 - y, y0 - x, x0 + y, y0 - x, color); if (d < 0) { d += 2 * x + 3; } else { d += 2 * (x - y) + 5; y--; } x++; } }重要优化:注意TFT_FillCircle函数内部调用的是TFT_DrawLine。对于实心圆填充,这会产生大量短水平线。如果你的底层TFT_DrawLine对水平线有特殊优化(比如直接调用FillRect),那么效率会很高。否则,可以考虑在FillCircle内部直接计算水平线的起点终点,然后调用FillRect来填充每一行,这样内存访问模式更连续。
5. 高级话题:抗锯齿与性能优化
当这些基本图形能正确绘制后,我们会追求更极致的视觉效果和运行效率。
5.1 简单抗锯齿(边缘平滑)
基本的Bresenham和中点算法画出的图形边缘会有明显的“锯齿”。在嵌入式系统上实现全屏抗锯齿(如MSAA)不现实,但我们可以实现一种轻量级的Wu氏抗锯齿(Xiaolin Wu‘s algorithm)思想来改善直线和圆的显示效果。
其核心不是只点亮一个像素,而是根据像素被理想线段覆盖的面积比例,来设置不同的亮度(颜色深浅)。对于只能显示纯色的TFT屏,我们可以通过抖动(Dithering)来模拟。例如,画一条浅灰色的线,我们可以交替点亮白色和黑色像素,从远处看就像灰色。对于直线,可以在误差项e接近0时,点亮相邻的两个像素,其中一个用主色,另一个用混合色(或背景色)。
实现真正的Wu氏直线算法需要处理颜色混合,计算量稍大。一个折中的方案是:在画点时,根据误差项的大小,有概率地在相邻位置多画一个半透明的点。这能在不显著增加负担的情况下,让边缘看起来更柔和。
5.2 性能瓶颈分析与优化策略
在嵌入式设备上绘制图形,性能是关键。主要瓶颈在两个方面:CPU计算时间和总线传输时间。
计算优化:
- 查表法:对于固定大小的圆或常用角度的直线,可以预先计算好像素点坐标并存储在Flash中,绘制时直接查表复制,用空间换时间。
- 定点数运算:如果必须使用三角函数等复杂计算(如画圆弧、多边形),尽量使用查找表或定点数数学库,避免浮点运算。
- 汇编优化:对最内层循环(如画点判断)用汇编重写,有时能带来惊喜。
传输优化:
- 帧缓冲区(Frame Buffer):如果MCU RAM足够,开辟一块和屏幕分辨率一样大的缓冲区(Frame Buffer)。所有绘图操作都先修改这个缓冲区,修改完成后,一次性将整个缓冲区通过DMA传输到屏幕。这能极大减少总线竞争和命令开销,实现流畅的动画。这是性能提升最有效的手段。
- DMA传输:充分利用DMA将颜色数据从内存搬运到显示接口(如SPI、FSMC),解放CPU。
- 批量写入:确保你的底层
FillArea或WriteColor函数使用的是屏幕驱动的“内存连续写入”模式,设置一次地址后,连续发送像素数据,而不是每画一个点都设置一次地址。
5.3 应用实例:一个简单的动态仪表盘
让我们把这些函数组合起来,创建一个简单的动态仪表盘,包含表盘(圆)、指针(直线)和刻度(短直线/矩形)。
void DrawSpeedometer(int centerX, int centerY, int radius, int speed) { uint16_t bgColor = COLOR_BLACK; uint16_t faceColor = COLOR_DARK_GRAY; uint16_t scaleColor = COLOR_WHITE; uint16_t needleColor = COLOR_RED; uint16_t textColor = COLOR_GREEN; // 1. 绘制表盘背景(实心圆) TFT_FillCircle(centerX, centerY, radius, faceColor); // 2. 绘制表盘外圈(空心圆) TFT_DrawCircle(centerX, centerY, radius, scaleColor); TFT_DrawCircle(centerX, centerY, radius-1, scaleColor); // 画双圈更有质感 // 3. 绘制刻度(每30度一个主刻度,每6度一个副刻度) for (int angle = 0; angle < 240; angle += 6) { // 假设表盘240度 float rad = angle * 3.14159 / 180.0; int cosVal = (int)(cos(rad) * 1000); int sinVal = (int)(sin(rad) * 1000); int x1 = centerX + (radius - 10) * cosVal / 1000; int y1 = centerY + (radius - 10) * sinVal / 1000; int x2, y2; if (angle % 30 == 0) { // 主刻度 x2 = centerX + (radius - 20) * cosVal / 1000; y2 = centerY + (radius - 20) * sinVal / 1000; TFT_DrawLine(x1, y1, x2, y2, scaleColor); // 这里可以添加刻度数字,需要用到字符显示函数 } else { // 副刻度 x2 = centerX + (radius - 15) * cosVal / 1000; y2 = centerY + (radius - 15) * sinVal / 1000; TFT_DrawLine(x1, y1, x2, y2, scaleColor); } } // 4. 绘制指针(根据速度计算角度) // 假设速度范围0-240,对应角度0-240度 int needleAngle = speed; // speed在0-240之间 float rad = needleAngle * 3.14159 / 180.0; int needleLength = radius - 25; int xEnd = centerX + needleLength * cos(rad); int yEnd = centerY + needleLength * sin(rad); TFT_DrawLine(centerX, centerY, xEnd, yEnd, needleColor); // 5. 绘制中心点(一个小实心圆) TFT_FillCircle(centerX, centerY, 5, COLOR_BLACK); TFT_DrawCircle(centerX, centerY, 5, needleColor); // 6. 显示速度数字(需要实现数字显示函数,此处略) // DrawNumber(centerX - 20, centerY + radius/2, speed, textColor); }这个例子展示了如何将基本的画圆、画线函数组合成一个有意义的图形界面。注意,频繁使用sin/cos计算会影响性能,在实际产品中,刻度位置应预先计算好存入数组。
6. 常见问题与调试技巧实录
即使算法和代码都正确,在实际硬件调试中还是会遇到各种问题。以下是我踩过的一些坑和解决方法。
6.1 图形显示错位或扭曲
- 症状:画的线不是直的,圆变成了椭圆,或者图形位置不对。
- 排查:
- 坐标系统确认:首先检查你的
DrawPixel函数是否正确理解了屏幕的坐标系统。有些屏幕驱动芯片(如ILI9341)的起始行列可以设置,确认你的初始化代码没有设置奇怪的扫描方向或镜像。 - 数据类型溢出:在Bresenham算法中,
dx、dy、err等变量可能很大。确保使用足够宽的数据类型(如int32_t),防止在画长直线或大圆时发生溢出,导致计算错误。 - 边界检查干扰:检查你的边界检查逻辑。如果裁剪算法有误,可能会错误地丢弃或修改像素点。
- 坐标系统确认:首先检查你的
6.2 绘制速度慢,屏幕闪烁
- 症状:画一个复杂的图形需要很长时间,或者能看到图形一部分一部分地绘制出来(闪烁)。
- 排查与解决:
- 最可能的原因:单点绘制:如果你的高级图形函数是基于
DrawPixel实现的,并且DrawPixel内部每次都要设置地址窗口再发数据,那么速度会慢得无法接受。解决方案:必须实现并利用FillArea或块写入函数。对于实心矩形和实心圆填充,一定要用块写入。 - 没有使用DMA:如果总线传输(如SPI)占用大量CPU时间,会导致系统卡顿。启用DMA传输颜色数据可以彻底解放CPU。
- 无帧缓冲区:直接操作屏幕(Direct Mode)必然导致闪烁,因为用户能看到绘制过程。终极解决方案是使用帧缓冲区(Frame Buffer)。所有绘制操作在后台缓冲区完成,然后一次性交换到前台显示(通常通过DMA)。这是实现流畅图形界面的标准做法。
- 最可能的原因:单点绘制:如果你的高级图形函数是基于
6.3 颜色显示不正确
- 症状:设置的颜色和实际显示的颜色对不上,比如红色显示为蓝色。
- 排查:
- 颜色格式:确认你的颜色值格式(RGB565)和屏幕驱动芯片期待的格式是否一致。是
高位在前(MSB First)还是低位在前(LSB First)?是RGB还是BGR?通常需要在初始化时配置屏幕的像素格式寄存器(如ILI9341的0x3A命令)。 - 字节序(Endianness):当你使用
uint16_t表示颜色,并通过SPI发送时,是先发高字节还是低字节?这需要和屏幕驱动芯片的预期匹配。常见的做法是:SPI_SendData(color >> 8); SPI_SendData(color & 0xFF);。
- 颜色格式:确认你的颜色值格式(RGB565)和屏幕驱动芯片期待的格式是否一致。是
6.4 实心圆填充中间有空洞
- 症状:用
FillCircle函数画的实心圆,在靠近圆心的部分出现未填充的透明点或线。 - 原因与解决:这通常是由于在
while (x <= y)循环中,当x和y很接近时,填充的四条水平线有重叠部分。如果DrawLine函数画的是包括起点和终点的线,且没有做“去重”处理,重叠部分被绘制了两次,但通常这不会导致空洞。空洞更可能源于边界计算错误。请检查计算水平线起点终点的公式:x0 - x,x0 + x,x0 - y,x0 + y。确保当x和y相等时,逻辑依然正确。一个稳健的方法是,在填充水平线时,确保起点坐标不大于终点坐标,即调用DrawLine前做一次判断和交换。
6.5 抗锯齿效果不理想或性能下降严重
- 症状:实现了抗锯齿后,边缘有改善但出现杂点,或者绘制速度急剧下降。
- 建议:在资源紧张的嵌入式设备上,抗锯齿是奢侈品。如果必须做,建议:
- 仅对关键图形:只对最重要的、静止的图形(如大型文字、图标轮廓)应用抗锯齿。
- 降低精度:使用4级或2级灰度抖动代替256级Alpha混合。
- 预处理:对于静态界面,可以在PC上预先渲染好带抗锯齿的位图,然后作为图片资源下载到设备中显示,这比实时计算要高效得多。
调试图形显示问题,一个非常有效的方法是编写一个简单的测试程序:在屏幕不同位置画不同颜色的像素、水平线、垂直线、对角线、矩形和圆。观察哪些图形出了问题,就能快速定位是算法问题、坐标问题还是底层驱动问题。从一个像素开始,确保它绝对正确,然后再扩展到线,再到面,这是最稳妥的调试路径。