最近在折腾一个STM32的小工具界面,屏幕用的是最常见的128x64单色点阵屏。功能一步步做下来,最卡我的居然不是菜单逻辑,也不是按键扫描,而是在这块屏幕上画一条直线。这件事听起来简单,真做起来就会发现一堆细节:坐标怎么算、显存怎么排、字节里哪一位对应哪个像素、边界怎么裁。干脆把整个思路和调试过程整理成一篇东西,给同样在LCD点阵屏上做过图形绘制的朋友做个参考。
这篇文章不会只给你一段现成的Bresenham代码就完事,而是从点阵屏的显存结构讲起,把“画线”拆成几个核心问题:像素点如何落到显存、直线算法为什么必须用整数、怎么用STM32把内存里的帧缓冲推到屏幕上,以及我实际调试中踩过的那些坑。无论你是刚学会用HAL库点亮一块屏,还是已经能驱动屏但不知道下一步怎么画图形,这里的内容应该都能用得上。
1. 先把“画一条线”拆成三件事
1.1 点阵屏的底层逻辑:字节和像素的关系
很多人第一次接触点阵屏时,第一反应是把屏幕当成一个二维坐标数组,像数学坐标系那样,给个点就点个像素。这种理解大方向没错,但落到底层硬件上就不一样了。屏幕控制器拿到的数据不是一个个独立的像素点,而是一串串字节。每个字节有8个bit,每个bit对应屏幕上的一个点。
以常见的128x64单色屏为例,它的显存通常被分成8个page(页),每页有128个字节,每字节承载8个纵向排列的像素点。也就是说,屏幕x方向是0到127,y方向是0到63,但y方向被分成了8组,第0到7行是一页,第8到15行是下一页,依此类推。想点亮某个像素点,你得先算出这个点属于第几页,再算出它在这一页里落在了第几个字节,最后还要算出它在这个字节里对应哪一位。
这种布局刚开始很别扭,但它是历史沿革下来的通用结构,也是为了节省控制器的引脚和数据处理时间。如果还是觉得抽象,可以想象一列8盏竖排的灯,每8盏灯被编成一个“小组”,屏幕上一共64行,所以有8个小组,每个小组对应一页。我们要画的任何图形,最后做的事情都是去修改这些小组里的某个灯。
1.2 画线问题本质上是坐标离散化
既然屏幕是一个个像素格子组成的,画线就不可能画出数学意义上“无限细”的直线。线的本质是:在给定起点和终点之后,找到一条连续的像素路径,让这条路径在人眼看来尽量接近理想直线。这个寻找像素点序列的过程,在图形学里叫光栅化。
你会遇到的问题很典型:从(0,0)到(127,63),斜率算出来是0.496,x每走1,y应该走0.496格。但屏幕不能点亮半个像素,你只能在y=0附近把相邻的两个像素都点亮,让整条线看起来平滑。所以,“画线”这个任务的真正核心,不是单纯地重复调用一个“画点函数”,而是想清楚怎么用离散坐标逼近连续直线,并且让算法足够快、足够省资源。
一个新手常犯的错误,是直接用一个for循环,每x走一步就把y取整一次,然后画点。这个做法在斜率比较平缓的时候看着还行,一旦斜率变大,比如接近90度,线就会断成一片孤立的点,因为x每前进一像素,y可能要跳好几格,你只改了y取整后的那个点,中间漏掉的像素导致整条线像一个虚线。
1.3 为什么不能直接“调库”一走了之
嵌入式开发里其实有很多现成的图形库,U8g2、LVGL这些都能直接画线。那为什么还要自己写一个画线函数?我个人的看法是,你如果只是要出个效果,用库完全可以,但很多场景下,库的尺寸、内存占用、底层适配会带来额外负担。一个只有几十行代码的直线算法,自己写并不复杂,而且能精确控制每一像素的写入方式,对后续做动画、做局部刷新非常有帮助。
更重要的是,自己写过一遍,你才理解那些图形库底层到底做了什么。以后遇到屏幕显示异常、花屏、线条有残影,你能直接从显存操作上定位问题。否则你只能对着库函数发愣,不知道它底层改的是哪一位。所以我的建议是,就算你最终要用库,也值得自己先实现一次。
2. 三种直线算法怎么选:只有一种适合STM32
2.1 直接斜率法为什么跑不动
最简单的画线思路是这样的:算出斜率k,然后从起点到终点,x每加1,y取整后画一个点。这个数学上是对的,但工程实现上问题不少。首先,它会用到浮点数。STM32的很多型号没有FPU(浮点运算单元),每次浮点乘法和加法,编译器都会生成一堆软浮点库的代码,开销比整数运算大很多。一次画线调用几十上百次浮点计算,对实时交互来说很吃亏。
然后是误差累积问题。y的值是小数,每一步都要四舍五入,这个舍入误差会一直积累,越到后面偏差越大。尤其在斜率不是整数的长线上,画出来的线会明显偏离理想位置,出现“波浪感”。另外还要单独处理斜率大于1的情况,否则线会稀疏。这些分支多了之后,代码既不简洁也容易出bug。所以这种算法顶多用来验证一下画点函数能不能跑通,不适合作为最终方案。
2.2 DDA算法的思路与问题
DDA(Digital Differential Analyzer)是很多人从计算机图形学教材里学到的入门算法。它的核心思想是:不管起点和终点在哪个方向,始终沿着步长较大的那一个轴前进,步长小一些的轴按比例增加。
举个例子,从(0,0)到(127,63),x方向步长是127,y方向步长是63,所以算法会沿着x方向每次加1,同时把y累加一个增量0.496,然后取整画点。这样线就不容易断了。但问题依然存在:只要“增量”不是整数,它就需要浮点运算,或者用定点数模拟浮点。定点数虽然比浮点快,但代码复杂度上来了,而且DDA本质上的误差控制不如后面要说的Bresenham算法干净。
DDA也有它的优势,就是特别好理解,调试的时候一眼就能看懂它在干什么。如果你是第一次写画线函数,可以用DDA先跑通流程,再考虑优化。
2.3 Bresenham才是这个场景的正解
Bresenham直线算法的价值在于,它只用整数加减法和比较,就能完成光栅化,而且每一步都是最优像素选择,误差控制得很好。它是目前嵌入式单色屏上画直线的事实标准,几乎所有轻量级图形库底层都用它。
这个算法的核心思想是维护一个“误差项”。想象你沿着x方向一格一格走,理论上的y值是一个小数,你每走一格,就有一个“当前y坐标与理论y坐标之间的差距”。这个差距超过0.5,就往y方向进一格,同时把误差减掉1;没超过,就继续沿原来的方向走。Bresenham用两倍的误差项来消除0.5带来的浮点比较,完全变成整数运算。
我画一条从左上角到右下角的斜线时,通常会先看一眼两端点坐标差,dx和dy。算法每次走一步,都会根据误差项的符号决定是只走x方向,还是x和y同时走。在这套逻辑下,六种斜率情况(水平、垂直、正斜率、负斜率、陡峭、平缓)都被统一的误差公式涵盖,不需要一堆if else分支去单独处理每种情况。这也是它能在8位MCU上跑得飞快的核心原因。
2.4 对称遍历与方向处理:不能只用正斜率
实际写代码时,有个细节容易忽略:屏幕坐标不是数学坐标。屏幕的y轴默认向下增长,起点可能在任何位置,终点也可能在起点的左上方、右下方、右上等等。如果你写的算法只假设“x0小于x1,y0小于y1”,那么斜率为负的线、从右向左画的线,都会出问题。
解决方法是引入方向步长。比较起点和终点的坐标,如果终点x比起点大,x方向步长就是+1,否则是-1。y方向同理。这样做的好处是,算法统一处理8个卦限的直线,不用关心具体斜率正负。我早期写第一版画线函数时就漏了这个,画出来的线只有第一卦限是对的,其他方向要么镜像,要么干脆没输出。后来才意识到,坐标间的方向和步长必须显式算出来。
下面是我用的整数Bresenham实现,它同时支持从任意方向画线:
void draw_line(int x0, int y0, int x1, int y1, uint8_t on) { int dx = abs(x1 - x0); int dy = abs(y1 - y0); int sx = (x1 >= x0) ? 1 : -1; int sy = (y1 >= y0) ? 1 : -1; int err = dx - dy; while (1) { set_pixel((uint8_t)x0, (uint8_t)y0, on); if (x0 == x1 && y0 == y1) break; int e2 = err * 2; if (e2 > -dy) { err -= dy; x0 += sx; } if (e2 < dx) { err += dx; y0 += sy; } } }代码里的err就是误差项,e2将误差放大两倍,用来判断下一步是沿x走、沿y走,还是斜着走。这套逻辑直接覆盖所有方向,也不必单独优化水平线和垂直线,虽然水平垂直场景它也能正确画,但性能上有优化空间。
三种算法的对比,整理成了下面的表:
| 算法 | 运算类型 | 误差控制 | 代码量 | 适合场景 |
|---|---|---|---|---|
| 直接斜率法 | 浮点运算 | 差,会累积 | 最少 | 验证画点函数,不建议实际使用 |
| DDA | 浮点或定点 | 中等 | 较少 | 学习理解,快速原型 |
| Bresenham | 纯整数加减比较 | 最好 | 适中 | STM32等嵌入式环境,推荐 |
注意:即使选Bresenham,也建议在调用它之前,对水平线、垂直线做特判,用专门的更快路径。这样平时画菜单边框时能省下大量循环时间。
3. 显存适配:从像素点到LCD控制器的最后一百米
3.1 先做一个正确的set_pixel
画线算法最终都要落到set_pixel这种最底层的函数上。如果这个函数写错了,再高明的直线算法也画不出正确效果。set_pixel做的事情只有两件:算坐标落在显存哪个字节的哪个位,然后把这一位置0或置1。
以128x64单色屏、纵向8位一页的布局为例,y坐标的高3位是页号,低3位是页内的位号;x坐标直接用作字节偏移。实现如下:
static uint8_t fb[8][128]; void set_pixel(uint8_t x, uint8_t y, uint8_t on) { if (x >= 128 || y >= 64) return; uint8_t page = y >> 3; uint8_t bit = y & 0x07; if (on) { fb[page][x] |= (uint8_t)(1 << bit); } else { fb[page][x] &= (uint8_t)~(1 << bit); } }这里最关键的位运算是:1 << bit。bit是0到7,对应显示内容从低位到高位。如果你发现画出的点不知道为什么乱跳,先检查一下数据手册里控制器对字节位和屏幕行之间的对应关系。有的屏第0bit对应第0行,有的屏是反的,甚至有的屏列扫描方向也相反。这种映射不一致,是花屏和图像上下左右颠倒的最常见原因。
还有一点值得注意:set_pixel里必须带边界检查。虽然直线算法的坐标理论上会限制在屏幕范围内,但一旦你后续做裁剪、滚动、动画,总有坐标越界的情况。没有边界检查的set_pixel,会让数组越界写,轻则图形错乱,重则把程序栈冲掉。
3.2 水平线和垂直线的专项优化
画直线时,最常画的是水平线和垂直线,比如菜单边框、表格线。Bresenham对这两类线也适用,但效率不高,因为没必要一格一格地走。水平线的特点是y固定,变的只是x。因为每一列都是一个独立字节,所以水平线本质上是在若干字节里点亮同一位。垂直线的特点则是x固定,y连续变化,y方向连续8个点会落在同一个字节内,这就给了我们一次性操作8位的空间。
垂直线的优化手法很值得说。假设y0小于y1,起点在同一页内部时,我们可以生成一个连续的位掩码,直接与该字节做按位或运算,而不是循环8次set_pixel。示例:
static uint8_t gen_mask(uint8_t startBit, uint8_t endBit) { uint8_t mask = 0; for (uint8_t b = startBit; b <= endBit; b++) { mask |= (uint8_t)(1 << b); } return mask; } void draw_vline(uint8_t x, uint8_t y0, uint8_t y1, uint8_t on) { if (x >= 128) return; if (y0 > y1) { uint8_t t = y0; y0 = y1; y1 = t; } if (y1 >= 64) y1 = 63; uint8_t startPage = y0 >> 3; uint8_t endPage = y1 >> 3; uint8_t startBit = y0 & 0x07; uint8_t endBit = y1 & 0x07; if (startPage == endPage) { uint8_t mask = gen_mask(startBit, endBit); if (on) fb[startPage][x] |= mask; else fb[startPage][x] &= (uint8_t)~mask; } else { for (uint8_t p = startPage + 1; p < endPage; p++) { if (on) fb[p][x] = 0xFF; else fb[p][x] = 0x00; } fb[startPage][x] = on ? (uint8_t)(fb[startPage][x] | gen_mask(startBit, 7)) : (uint8_t)(fb[startPage][x] & ~gen_mask(startBit, 7)); fb[endPage][x] = on ? (uint8_t)(fb[endPage][x] | gen_mask(0, endBit)) : (uint8_t)(fb[endPage][x] & ~gen_mask(0, endBit)); } }中间完整页直接写成0xFF或0x00,整页8个bit全亮或全灭,比逐点循环高效太多。水平线虽然没有这么高效的“整字节”写法,但可以先通过掩码操作优化一下两端,中间仍然是逐字节处理。实测中,画一条垂直长线用优化版比逐点调用set_pixel快好几倍,对刷新率敏感的应用很重要。
3.3 从帧缓冲到屏幕驱动
有了显存数组fb,还需要把整个帧缓冲推送到屏幕控制器上。这个操作一般叫flush。STM32的HAL库提供了现成的底层接口,例如I2C或SPI发送。大多数128x64单色屏的控制器,都会按页接收数据。也就是说,你需要先把第0页的128字节发过去,再发第1页,直到第7页。
推送函数通常长这样:
void lcd_flush(void) { for (uint8_t page = 0; page < 8; page++) { // 设置控制器当前页和当前列地址 lcd_set_page_address(page); lcd_set_column_address(0); // 然后发送fb[page][0..127]这一整页数据 HAL_SPI_Transmit(&hspi1, fb[page], 128, 10); } }这里如果调用频繁,可以只刷新变化区域。比如你的画面只有某一块矩形区域变了,就可以只把对应页和列的fb数据推过去。局部刷新对嵌入式设备来说很重要,因为SPI/I2C的传输速度有限,全屏刷新128x64单色屏虽然只有1024字节,但每帧都全刷,对实时性要求高的动画来说也是浪费。
3.4 调用层级全貌与内存开销
把整个调用链理清楚,你会发现代码分层很自然:应用层调用draw_line,draw_line内部循环调用set_pixel或现成的水平线垂直线函数,set_pixel直接修改fb数组,最后统一调用lcd_flush把fb推给屏幕。
这个设计最大的好处是,画图时和硬件刷新解耦。你可以先画很多条线、很多个矩形,把结果攒在fb里,最后一次性刷新,避免每条线都占用一次SPI传输。fb本身的内存开销也不大,8x128字节,正好1KB,加上代码和栈,在绝大多数STM32型号上都能轻松容纳。
如果追求更低的内存,可以把fb去掉,直接用控制器内置的显存做读改写。但很多屏幕控制器的读时序并不友好,而且从控制器读回数据再改位,速度会明显下降。所以我个人更倾向于1KB的帧缓冲方案,稳定的同时逻辑也简单。
4. 完整实操:STM32上从零实现并跑起来
4.1 工程搭建与初始化步骤
先说明一下基础环境。我用的是某个常见的STM32评估板,屏幕是一个128x64单色点阵屏,通过SPI接口连接。工程用CubeMX生成,配置了SPI1为主模式,波特率大约8MHz,屏幕的复位引脚用普通GPIO控制,数据/命令选择引脚也是GPIO。
初始化屏幕控制器的套路大致是一样的:先拉低复位引脚,延时几十毫秒,再拉高复位。然后发送一连串初始化命令,设置显示开启、列地址模式、页地址模式等。这些命令和屏幕型号强相关,但我发现很多单色屏的初始化命令都大同小异,重要的是最后要把显示开启位设为1,否则屏是黑的。
很多新人在这一步容易卡住,因为屏幕型号五花八门,网上代码在别的板子上能显示,自己这边就是没反应。我的排查习惯是,先只点亮一个像素点试试,不要一上来就跑画线。如果连一个点都点不亮,后面所有调试都无从谈起。这个“先画点再画线”的顺序,能帮你把问题快速隔离到屏幕驱动还是绘图算法。
4.2 最小可用的画线Demo
下面放一个完整的demo主循环,我会让一条线在屏幕上连续变换角度,这样能直观看到画线效果是否正常。为了不在demo里引入三角函数和浮点库,我把固定端放在屏幕中心点,另一端在屏幕四个边缘循环移动。
#include "main.h" extern SPI_HandleTypeDef hspi1; static uint8_t fb[8][128]; void set_pixel(uint8_t x, uint8_t y, uint8_t on); void draw_line(int x0, int y0, int x1, int y1, uint8_t on); void lcd_flush(void); void demo_animate(void) { int edge = 0; while (1) { // 每2秒切换一次线条另一端的位置 int x1, y1; switch (edge) { case 0: x1 = 127; y1 = 0; break; case 1: x1 = 127; y1 = 63; break; case 2: x1 = 0; y1 = 63; break; case 3: x1 = 0; y1 = 0; break; default: x1 = 64; y1 = 32; break; } edge = (edge + 1) % 5; // 清空帧缓冲,注意不是清屏,是清内存 memset(fb, 0, sizeof(fb)); // 从屏幕中心向边缘画线 draw_line(64, 32, x1, y1, 1); // 一次性推送到屏幕 lcd_flush(); HAL_Delay(500); } }这个demo虽然简单,但能快速验证几个事情:屏幕清屏是否干净、中心点到四个角的线是否方向正确、线有没有断裂。如果这些线的效果都对,那画线函数基本就稳了,后面再做任意两点之间画线也就不会有太大问题。
4.3 从波形显示到仪表盘:实际应用中的边界裁剪
画线函数在真实项目里最常见的应用,一个是画波形曲线,一个是画仪表盘的指针。波形曲线的场景下,数据点不断进来,x坐标会超过屏幕右边界,所以一般会把最新数据映射到屏幕右侧,旧数据整体左移,然后重新画整条曲线。这种刷新策略下,裁剪很关键。
裁剪有两种做法。一种是在set_pixel里做边界判断,超出屏幕直接返回。这种实现简单,但效率低,因为整条线从起点到终点仍然会迭代很多个像素点,哪怕它们全都不可见。另一种是画线前做线段裁剪,也就是算出这条线与屏幕边界的交点,只保留屏幕内的那段线段,再调用画线函数。经典的Liang-Barsky算法可以处理这个问题,但代码量会多一些。
我的建议是:如果你的屏幕尺寸不大,比如128x64,一次画线的迭代次数最多也就一两百次,用set_pixel里的边界检查完全够用。如果你的屏幕分辨率很高,或者画线频率非常高,再上线段裁剪。不要在低分辨率小屏上过早优化,嵌入式开发最重要的是在“够用”和“复杂”之间找到平衡。
4.4 性能分析与刷新策略
很多人关心STM32画一条线到底要多久。以Bresenham算法为例,画一条从(0,0)到(127,63)的线,大概会迭代128次,每次迭代只有几次整数加减法和比较,在几十MHz主频的STM32上,这个时间可以忽略不计。真正浪费时间的是把1KB的帧缓冲推送到屏幕控制器的过程。
SPI时钟8MHz的情况下,传输1024字节大约需要1毫秒以上。如果用的是I2C,速度更慢,可能会到几毫秒甚至十几毫秒。所以性能优化的重点不是画线算法,而是刷新。凡是能用局部刷新解决的,就不要全屏刷新。我做过一个动态波形的界面,每次只刷新波形所在的两三页,帧率提升了差不多三四倍。
注意:如果你的画面里同时有很多线、很多矩形,先把它们全画到fb里,最后统一flush,比画一条线flush一次要高效得多。我见过有人把flush写进了set_pixel里,结果画一条线调了一百多次flush,屏幕闪烁到完全没法看。flush一定是画图完成之后才做的事情。
5. 现场排查:花屏、断线、残影的实战记录
5.1 屏幕全黑或者全亮,一个点都画不出来
这通常分两种情况。第一种是初始化没成功,屏幕控制器没进入工作状态。排查时先确认复位引脚时序是否正确,数据命令引脚是否拉对,初始化命令是否完整,最后有没有开显示的命令。第二种是底层传输有问题,比如SPI时序、引脚复用配置不对。
验证底层传输最直接的方法,就是往fb里塞一个固定图案,比如把第0页全部写成0xFF,然后flush。如果屏幕第一行出现了8像素高的实心带,说明底层链路是通的,问题在后续的像素映射或算法。如果第一行没有任何反应,那就回到初始化时序和接线上去查。这个固定图案排查法,我几乎每一次调新屏都会用。
5.2 线条出现明显断点,像一串断续的虚线
如果你用自己写的直接斜率法,出现这种问题很常见,因为斜率大于1时没有处理y方向跳跃。但如果你用的是Bresenham,线还是断的,那就要怀疑坐标类型出问题了。最常见的是把坐标定义成uint8_t,然后算法里出现了减法负数的情况。比如计算dx = x1 - x0时,如果x1小于x0,结果会变成一个很大很大的正数,而不是负数。
排查方法是把坐标全部改成int类型,再检查算法里的方向步长是否正确。我早期踩过这个坑,所有坐标都用了unsigned char,结果从右向左画线时,线直接跑到屏幕外面去了。后来把坐标统一成int,断线问题立刻消失。
5.3 画面上下颠倒或者左右镜像
这几乎可以肯定是显存映射和屏幕控制器的扫描方向不一致。常见的解决方案是修改set_pixel里的映射关系,把page的转换、bit的对应关系改成和屏幕控制器一致。还可以尝试在初始化命令里修改扫描方向,但不同控制器方向控制位不一样,不如在set_pixel里做适配来得通用。
我在自己的驱动里保留了几个宏定义,专门用来切换上下翻转和左右翻转,因为同一个控制器在不同批次屏幕上有时候会反。这种“坐标转换层”的思路,也可以延伸到不同屏幕尺寸的适配,后续换屏时只改映射函数就行。
5.4 花屏或者刷新后残留旧图案
出现花屏时,先检查flush函数的页面顺序和列地址是否设置正确。很多控制器在页地址模式下,写完一页后列地址会自动回到0,但有些控制器不会,需要手动设置。如果这一环出错,你看到的内容就会是错位的,好像所有像素整体偏移了若干字节。
画完图形后残留旧图案,一般是没有清屏。如果你的应用逻辑里使用了局部刷新,一定要确保局部区域内的所有旧像素都被覆盖。一个偷懒但有用的办法是,在局部刷新前先画一个和背景色相同的填充矩形,把整个区域清掉,再画新内容,这样虽然多了一点点绘制时间,但逻辑非常简单可靠。
我整理了一个常见问题速查表,方便你对照排查:
| 症状 | 优先排查项 | 常见原因 |
|---|---|---|
| 屏幕没有任何反应 | 初始化命令、SPI/I2C时序 | 没开显示、引脚配置错误 |
| 整行或整列不出 | 页地址/列地址设置 | flush顺序或地址重置逻辑错误 |
| 线条断断续续 | 坐标类型、方向步长 | 使用了无符号类型、算法只处理单方向 |
| 上下颠倒/左右镜像 | set_pixel映射、扫描方向 | 控制器扫描方向与内存模型不一致 |
| 刷新后残留图案 | 局部刷新区域逻辑 | 新图形没有覆盖旧像素 |
| 画面闪烁严重 | flush调用频率 | 画一条线flush一次,应统一帧缓冲再刷新 |
5.5 调了三天没搞定?那就退到最小复现
如果你已经把代码翻来覆去查了很多遍,还是找不到问题,我的建议是别在完整工程里硬调。新建一个最小的工程,只保留屏幕驱动、set_pixel和一行画线代码,然后依序测试:先画一个水平线,再画一个垂直线,最后画一个对角线。这样能把问题范围一步步缩到最小。
这个方法看起来笨,实际上非常有效。我在调试某个图形效果时,发现对角线画出来总是歪的,折腾了很久,最后用最小工程把焦点从完整功能里剥离出来,才发现是屏幕初始化时列地址模式配置错误,和画线算法本身一点关系都没有。很多看似复杂的显示异常,根源往往只是某个初始化寄存器没写对。如果你的功能代码已经很多,先把图形部分单独拿出来验证,能省下大量时间。
最后再分享一个小技巧:每一条画线函数写完后,别急着在真实屏上看效果,先在电脑上用一个模拟的128x64数组跑一遍,把结果用字符打印出来。我用一根很短的脚本模拟过几百种起点终点组合,轻松发现了好几处肉眼看不出来的边界问题。屏幕上的视觉效果会欺骗你的判断,但字符数组里的点阵不会。