如果你调过带图形界面的 OLED 屏幕,大概率遇到过这种诡异现象:点灯、清屏、显示字符都正常,代码一走到某条画线函数,屏幕要么黑掉、要么卡住,然后过一会看门狗把你拉起来复位。串口最后一条日志永远停在同一行,后面就没了。我最初怀疑过电源纹波、怀疑过 I2C 时序、怀疑过显存越界,最后发现凶手是一个怎么看都不该出事的 Bresenham 画线函数——准确地说,是它内部一个隐藏很深的死循环。这篇文章就把这个坑完整拆开:现象怎么复现、根因是什么、排查链路怎么走、最终用什么代码稳定跑通,适合正在移植图形库或者自己写 OLED 绘图驱动的朋友参考。
1. 现象:OLED一运行画线函数就死机,不是电源问题
1.1 死机现场的两种典型表现
我手里的屏幕是一块 128x64 的常见 OLED,控制器是某款 SPI/I2C 屏,驱动代码和显存操作都验证过。开机后清屏正常,显示一个 8x16 字符正常,但一旦调用真正的斜线绘制函数,系统就"死"了。
这里说的"死",有两种表现。第一种是彻底卡死:屏幕停留在最后写入的显存画面,主循环里的 LED 心跳不再翻转,按按键没有任何反应,看门狗如果开着,过一会儿会触发复位。第二种是无限复位:屏幕反复亮灭,串口打印反复从头开始,程序根本没有机会进入正常的业务逻辑。一开始我还以为是电源带不动 OLED,换了电容、换了杜邦线,故障依旧。
1.2 必现问题优先怀疑代码路径
真正让我把怀疑从硬件转向软件的,是复现的确定性。同一个画线函数,只要是同一个参数组合,永远卡住;换一个方向却完全正常。比如OLED_DrawLine(0, 0, 100, 0)可以正常画一条水平线,但OLED_DrawLine(100, 0, 0, 0)就卡死。OLED_DrawLine(50, 10, 50, 40)垂直线正常,倒过来从 40 到 10 就卡死。这说明问题不在 I2C 时序,也不在供电,而在代码里某条确定性的路径。
我还做了最小复现:把项目砍到只剩屏幕初始化、清屏、一条画线函数和一个专用串口打印。结果照样死机。能稳定复现的必现 bug,往往是逻辑错误而不是环境问题,这一点帮我节省了大量时间。
1.3 看门狗会让现象变得更迷惑
如果你没有接调试器,只看外设表现,很容易被看门狗带偏。CPU 卡死在画线函数的 while 循环里,看门狗超时后强制复位。复位后如果初始化代码马上又调用同一函数,就会再次卡死,形成"上电-卡死-复位-再卡死"的循环。从外部看就是屏幕一直在闪,像极了电源不稳或者软件整体崩溃。这时候如果你只抓复位原因,未必能看到真正的 PC 指针停在哪,最好的做法是先把看门狗关掉,或者把画线调用从初始化流程里拿出来,单独测试。
2. Bresenham 画线的边界条件:为什么这个函数最容易藏死循环
2.1 标准 Bresenham 的终止逻辑
Bresenham 画线算法之所以在单片机里流行,是因为它用整数加减代替了浮点斜率计算。它维护一个误差项err,每一步用e2 = 2 * err判断要不要在另一个轴方向前进一个像素,直到 x 和 y 同时到达终点。
标准版本的终止条件非常干净:while (1)循环里,先画当前点,再检查if (x0 == x1 && y0 == y1) break;。因为有这个精确的终点判断,理论上它最多走|dx| + |dy|步就一定结束,不可能死循环。标准实现长这样:
void OLED_DrawLine(int16_t x0, int16_t y0, int16_t x1, int16_t y1, uint8_t color) { int16_t dx = x1 - x0; int16_t dy = y1 - y0; int16_t sx = dx > 0 ? 1 : (dx < 0 ? -1 : 0); int16_t sy = dy > 0 ? 1 : (dy < 0 ? -1 : 0); if (dx < 0) dx = -dx; if (dy < 0) dy = -dy; int16_t err = dx - dy; while (1) { OLED_DrawPixel(x0, y0, color); if (x0 == x1 && y0 == y1) break; int16_t e2 = 2 * err; if (e2 > -dy) { err -= dy; x0 += sx; } if (e2 < dx) { err += dx; y0 += sy; } } }这段代码能正确处理水平线、垂直线、反向线、单点,因为它把方向和误差分开处理了。sx和sy负责方向,dx和dy取绝对值,err负责决定哪个方向要动。
2.2 边界条件一多,移植时就容易埋雷
画线函数表面上只画一条线,实际上要覆盖八种方向组合(四个象限乘两个旋转方向),外加水平线、垂直线、单点这些退化情况。很多人移植时会觉得标准实现"太啰嗦",于是自作聪明地精简:把int16_t改成uint8_t省内存;把while (1) + break改成while (x0 != x1 || y0 != y1);或者把err从int16_t缩成int8_t。这些优化单看都有道理,组合在一起就是死循环的温床。
我整理过一张边界情况表,大家可以对照自己的代码看一眼:
| 边界情况 | 标准版正确行为 | 常见移植简化写法 | 潜在后果 |
|---|---|---|---|
| 反向水平线 x0 > x1 | sx = -1,x 逐格逼近 x1 | uint8_t dx = x1 - x0 | 差值回绕,方向判断反转 |
| 垂直线 x0 == x1 | sx = 0,只动 y | while (x0 != x1) 做终止判断 | 循环直接退出或少画点 |
| dy == 0 时 sy 的取值 | sy 不影响 y 更新 | sy = (dy > 0) ? 1 : -1 | 与误差溢出叠加后 y 跑飞 |
| err 类型过窄 | e2 = 2 * err 不溢出 | int8_t err | e2 符号翻转,坐标乱跳 |
| 单点 x0==x1, y0==y1 | 画一个点后 break | while 循环里先判断再画点 | 单点画不出或永不退出 |
2.3 "能编译能下载"不等于逻辑正确
嵌入式开发里有一个特别坑的错觉:程序能编译、能下载、能跑起来,就说明代码没问题。可对于 Bresenham 这种算法,类型宽度和终止条件只要差一点点,运行结果就从"正常"变成"死机"。我在排查时经常看到类似的代码,每个变量单独看都合理,合起来就出问题。比如有人为了省 RAM 把坐标参数定义成uint8_t,理由是 OLED 最大只有 128x64,坐标完全够用。这个理由在显示层面成立,但在算法内部,uint8_t的中间计算会回绕,一旦出现反向差值,方向判断就彻底反了。
3. 隐藏死循环的核心根因:无符号回绕、零步进与误差溢出
3.1 根因一:无符号坐标与差值回绕,方向判断反转
我遇到的第一个典型根因,是把坐标参数和中间变量全部定义成无符号类型。OLED 的坐标域确实是非负的,很多驱动代码也习惯用uint8_t x0, y0。问题出在计算差值时:
uint8_t dx = x1 - x0; uint8_t dy = y1 - y0;当x1小于x0时,比如x0 = 80、x1 = 10,10 - 80在uint8_t里不是 -70,而是 186。后面的方向判断如果写成sx = (dx > 0) ? 1 : -1,就会得到sx = 1。本来要往左画,结果往右画了。往右画到屏幕边缘后继续越界,写进显存之外的内存,轻则花屏,重则踩掉栈或者覆盖图形库的指针变量,表现就是"一运行就死机"。
这种问题有时候不是直接死循环,而是越界写内存导致间接崩溃。但它的隐蔽性在于:正向画线完全正常,反向画线立刻出问题,和我在第 1 章复现的现象完全吻合。
3.2 根因二:步进量 sx/sy 算成 0,坐标原地踏步
第二个根因更直接,就是循环里的步进量被算成了 0。我在精简代码的时候自己踩过一次:想省一点判断,把方向变量写成这样:
int16_t sx = (dx > 0) ? 1 : 0; // 反向时 sx = 0 int16_t sy = (dy > 0) ? 1 : 0;标准 Bresenham 里,dx == 0时sx本来就应该是 0,这没问题。但反向直线dx < 0时,sx也变成了 0。如果终止条件用的是while (x0 != x1),那么 x0 永远停在原处,内部除非有额外的退出条件,否则就是标准死循环。垂直线、反向线,只要有一个方向的步进量为 0,而终点判断又依赖这个方向的坐标,就会原地空转。
类似的问题还会发生在把 -1 直接赋值给无符号变量时。比如uint8_t sx = -1;实际得到的是 255,如果在后续判断里再用if (sx == -1),永远不成立。代码看起来能编译,但语义完全变了。
3.3 根因三:误差项类型过窄,e2 溢出跨过终点
第三个根因最隐蔽,也是我这次排查花时间最多的。有人为了省内存,把误差项err声明成了int8_t。在 128x64 的屏幕上,dx最大 127,dy最大 63,表面上看int8_t好像够用。但 Bresenham 里有个关键操作:e2 = 2 * err。当err = 100时,e2 = 200,已经超过int8_t的最大值 127,于是e2变成了负数。
举个例子:画一条从(0, 10)到(100, 10)的水平线,如果方向变量写成sy = (dy > 0) ? 1 : -1,那么当dy == 0时sy = -1。正常算法里,水平线不应该触发 y 方向更新,因为e2 < dx这个条件不会成立。但如果err = 100,e2 = 2 * err = 200溢出成 -56,-56 < 100成立,于是y0 += sy被执行,y 坐标开始往下跑;err继续累加、继续溢出,y 越跑越远,甚至回绕。此时 x 到了终点也没用,因为终点条件是 x 和 y 同时到达,y 永远到不了正确位置,于是死循环。
这个根因和"方向变量写错"叠加在一起,才真正导致死机。单独看,误差溢出可能只是画错点,但配合错误的 sy,就会让循环永远找不到终点。
3.4 三类根因的对比
| 根因 | 典型代码 | 直接后果 | 致命程度 |
|---|---|---|---|
| 无符号差值回绕 | uint8_t dx = x1 - x0 | 方向反转、越界写显存 | 高 |
| 步进量退化为 0 | sx = (dx > 0) ? 1 : 0 | 坐标原地踏步,永不达终点 | 高 |
| 误差项类型过窄 | int8_t err; e2 = 2 * err | e2 符号翻转,越过终点 | 非常高 |
实际项目里这三种根因还经常混在一起:坐标类型是无符号,误差类型又缩窄,方向变量再精简一下,基本就是一套完美的死循环套餐。这也是为什么很多人在网上搜"OLED 画线死机"会看到各种不同答案,因为每个人踩到的组合点不一样,但底层都是同一个问题:Bresenham 的终止条件被破坏了。
4. 完整排查链路:从卡死现场到命中根因
4.1 第一步:串口加心跳,确定死点
排查死机问题,第一步不是看算法,而是先确认 CPU 到底死在哪一行。我的做法是在主循环里放一个 GPIO 翻转,用示波器看波形。正常运行时是连续方波,卡死时就停在某个电平。
然后我把画线函数前后各放一行串口打印,输出[ENTER]和[EXIT]。测试结果只有[ENTER],没有[EXIT],说明程序是在画线函数内部卡住或崩溃的。这一步排除了屏幕驱动线程、中断、显示刷新等其他模块的干扰。
4.2 第二步:枚举方向组合,缩小范围
死点定位到画线函数后,我用一个简单的测试循环,依次调用四种方向、水平线、垂直线和单点:
// 方向矩阵测试 OLED_DrawLine(10, 10, 100, 50); // 右下 OLED_DrawLine(100, 50, 10, 10); // 左上,反向 OLED_DrawLine(100, 10, 10, 50); // 左下 OLED_DrawLine(10, 50, 100, 10); // 右上 OLED_DrawLine(10, 30, 100, 30); // 水平 OLED_DrawLine(60, 10, 60, 50); // 垂直 OLED_DrawLine(20, 20, 20, 20); // 单点测试结果非常清晰:正向方向的线基本能画出来,三个反向方向的线全部卡死,垂直反向也会卡死。这时候我已经把问题锁定到方向处理上,接下来只需要看方向变量是怎么算的。
4.3 第三步:在循环内部加计数器探针
为了确认是不是真的死循环,我在画线函数里加了一个volatile uint32_t计数器,每次循环累加一次:
volatile uint32_t g_step_count = 0; void OLED_DrawLine(/* ... */) { g_step_count = 0; while (1) { OLED_DrawPixel(x0, y0, color); g_step_count++; if (x0 == x1 && y0 == y1) break; /* 步进计算 */ } }正常画一条最长对角线,步数最多 190 左右。但我测试反向水平线时,用调试器读g_step_count,发现它已经涨到十几万还没有退出,基本可以断定是死循环而不是慢。这一步非常关键,它把"屏幕卡住"从现象层面坐实为"某个循环没有终止条件"。
4.4 第四步:打印坐标和误差,命中根因
接下来就是最费时间的一步:我在循环里临时用调试串口把x0、y0、err、e2打出来,观察它们的轨迹。以反向水平线为例,正常情况应该是 x 逐渐逼近终点,y 固定不动。但我看到的却是 y 在不断变化,而且变化方向还是反的;err和e2呈现出明显的溢出特征,数值在正负之间跳来跳去,完全不符合标准 Bresenham 的收敛过程。
顺着这条轨迹往下查,我才发现代码里三处改动是连环犯案:坐标是uint8_t、方向变量是±1且没有处理 0 的情况、误差项是int8_t。单独修任何一处都能缓解,但只有把三个问题一起改掉,画线才算真正稳定。
4.5 排查过程的小结
这轮排查最有价值的收获是:不要对着代码干瞪眼。死循环问题一定要用探针确认循环次数,用变量轨迹确认收敛方向。只看"屏幕卡住"这个现象,你会忍不住去查电源、查屏幕、查 I2C,而调试器的计数器和坐标打印会在五分钟内告诉你算法内部到底发生了什么。
5. 修复后的画线函数:带防御的可靠实现与验证结果
5.1 推荐实现:统一符号类型,循环内精确判终
修复思路其实很简单:让 Bresenham 回到标准版,但额外增加防御。我的最终实现如下:
void OLED_DrawLine(int16_t x0, int16_t y0, int16_t x1, int16_t y1, uint8_t color) { int16_t dx = x1 - x0; int16_t dy = y1 - y0; int16_t sx = dx > 0 ? 1 : (dx < 0 ? -1 : 0); int16_t sy = dy > 0 ? 1 : (dy < 0 ? -1 : 0); if (dx < 0) dx = -dx; if (dy < 0) dy = -dy; int16_t err = dx - dy; uint32_t guard = (uint32_t)(OLED_W + OLED_H) * 4 + 16; while (1) { OLED_DrawPixel(x0, y0, color); if (x0 == x1 && y0 == y1) break; int16_t e2 = 2 * err; if (e2 > -dy) { err -= dy; x0 += sx; } if (e2 < dx) { err += dx; y0 += sy; } // 防御:异常情况下强制退出,避免死机 if (--guard == 0) break; } }几个关键点说明一下。坐标参数统一使用int16_t,不要用uint8_t,这是为了中间计算不丢失符号。dx、dy、err、e2全部使用int16_t,对于 128x64 的屏幕来说完全够用,如果以后换大屏,可以把这些变量改成int32_t,逻辑不用动。guard是根据理论最大步数再留余量,即使算法出 bug,也不会卡死系统。
5.2 防御性保护的三层含义
有人可能会问:既然 Bresenham 本身理论上是有限的,为什么还要加guard?因为现实世界里有太多不可控因素:坐标可能被人为传入非法值,dx + dy可能因为之前的计算错误而远大于预期,甚至函数可能被别处的内存破坏波及。guard相当于给算法上了一份保险,它不会掩盖真正的问题,但能保证最坏情况下程序还能继续跑,方便后续排查。
另外我还建议在写像素前做坐标合法检查,防止越界:
static void OLED_DrawPixelChecked(int16_t x, int16_t y, uint8_t color) { if (x >= 0 && x < OLED_W && y >= 0 && y < OLED_H) { OLED_DrawPixel(x, y, color); } }这样一个简单的钳制,可以避免因为坐标越界而写坏显存之外的内容。注意,这个检查只能作为防御,不能替代方向判断修复。如果方向逻辑是错的,钳制之后画出来的线依然是错的,只是不再死机而已。
5.3 回归测试与验证结果
修复后我把原来的方向矩阵测试全部重跑了一遍,结果如下:
| 测试项 | 修复前 | 修复后 |
|---|---|---|
| 右下方向 | 正常 | 正常 |
| 左上方向(反向) | 卡死 | 正常 |
| 左下方向 | 卡死 | 正常 |
| 右上方向 | 卡死 | 正常 |
| 水平线 | 部分卡死 | 正常 |
| 垂直线 | 部分卡死 | 正常 |
| 单点 | 表现异常 | 正常 |
| 连续全屏随机线 1000 条 | 几分钟内必死 | 稳定跑完 |
我还做了压力测试:在一个循环里交替画各种角度、各种长短的线条,同时刷新屏幕,跑了半小时没有出现黑屏和复位。这说明问题确实根除了。
5.4 移植到其他图形库时要注意什么
如果你不是自己写画线函数,而是移植第三方 OLED 图形库,同样要注意三个地方:第一,确认库的接口参数类型是不是有符号整数;第二,找到画线函数内部是否支持反向坐标,很多精简版库只处理x0 <= x1的情况,需要你在调用前交换坐标;第三,看内部终止条件是while (1) + break还是while (x0 != x1),后者在步进量为 0 的退化场景下极其危险。移植完成后,强烈建议先跑一遍方向矩阵测试,再上业务代码。
6. 从画线到全局:嵌入式绘图代码的同类隐患
6.1 画圆、填充矩形、字库取模都一样
这次排查画线函数踩的坑,后来我在画圆、填充矩形、字库取模里都见到过影子。画圆的典型实现是while (x >= y),一旦 xy 的更新逻辑写反,或者坐标类型用了无符号,终止条件就可能永远不满足。填充矩形如果入口参数不做大小交换,(100, 10)到(10, 50)这样的反向矩形会直接画不出来,有些实现还会在反向时死循环。字库取模更不用说,取模偏移一旦越界,直接读超出字库数组,行为完全不可预测。
这些绘图函数共享同一个特点:循环次数依赖坐标差值的收敛,而坐标差值很容易被类型和符号搞坏。所以我后来给自己定了一条规矩:所有绘图函数的坐标参数统一用int16_t,所有循环内部都带guard计数器,所有写显存前都做坐标范围检查。
6.2 排查建议与代码审查清单
如果你现在也被 OLED 死机问题困扰,可以按下面这个清单自查一遍:
- 画线函数的坐标参数是
uint8_t还是int16_t?有没有可能反向差值回绕? sx、sy方向变量是否可能被算成 0 或相反数?err、e2、dx、dy的类型是否足够容纳两倍误差?- 终止条件是精确的
(x0 == x1 && y0 == y1)吗? - 循环内部有没有防护计数器?
- 写显存前有没有坐标合法性校验?
把这六条过一遍,大部分"一跑画线就死机"的问题都能找到答案。
这次排查给我最大的一个体会是:在嵌入式这种资源紧张的环境里,类型宽度和边界条件不是"优化细节",而是正确性的组成部分。uint8_t省下的那点 RAM,可能一次死循环就让你烧掉一整天的排查时间。现在我再写绘图函数,第一件事就是先把所有坐标变量声明成int16_t,循环里永远保留 guard,写显存前永远检查范围。这些习惯多花不了几行代码,但能帮你避开大部分"看起来不可能"的诡异死机。