1. 一个让人抓狂的现场OLED点亮就死机如果你玩过单片机驱动 OLED 屏大概率遇到过这种场景代码编译通过烧录成功屏幕刚亮起来显示第一帧画面整个系统就卡死了。没有 HardFault 打印没有看门狗复位调试器暂停后发现程序停在一个看起来人畜无害的while循环里出不来。我第一次遇到这个问题是在一个基于SSD1306驱动芯片的 0.96 寸 OLED 模块上。主控是常见的 Cortex-M 内核单片机用I2C接口通信。当时写了一个简单的图形测试程序画几条直线、几个矩形结果屏幕只显示了半条线就再也不动了。用调试器挂上去一看PC 指针停在OLED_DrawLine函数内部的一个循环里循环变量是int类型但循环条件永远无法满足。这个问题的根源就藏在Bresenham 画线算法的实现细节里。Bresenham 算法本身是一个非常优雅的整数运算直线光栅化算法效率高、不需要浮点运算在嵌入式图形库中被广泛使用。但正因为它的循环控制逻辑依赖多个变量的增减配合一旦某个边界条件处理不当就会产生死循环。而 OLED 驱动通常是在主循环中调用绘图函数一旦死循环整个系统自然就死机了。这篇文章会从实际案例出发把 Bresenham 画线函数的死循环成因、排查方法、修复方案和防御性编程技巧全部拆开讲清楚。不管你是刚接触嵌入式图形编程的新手还是已经写过几版显示驱动的老手都能从中找到可以直接复用的经验。文章会涉及算法原理、代码实现、参数计算、调试手段和常见坑点内容偏实操代码以 C 语言为主适配常见的嵌入式开发环境。2. Bresenham 画线算法到底在算什么2.1 从画一条直线说起在像素屏幕上画直线本质上是要决定从起点到终点哪些像素点应该被点亮才能让这条线看起来足够直。数学上的直线是连续的但屏幕上的像素是离散的。所以我们需要一个光栅化过程把连续直线映射到离散像素网格上。最朴素的想法是用浮点运算每走一个 x 步长计算对应的 y 值然后四舍五入取整。但嵌入式环境里浮点运算慢而且不同斜率需要分别处理代码会变得很臃肿。Bresenham 算法的精妙之处在于它用一个误差累积变量来替代浮点运算全程只用整数加减法和比较速度极快。核心思想是这样的假设我们要从(x0, y0)画到(x1, y1)且x1 x0、斜率在 0 到 1 之间。我们沿着 x 方向每次走一步y 方向是否也走一步取决于当前误差累积值是否超过了一个阈值。这个误差累积值就是 Bresenham 算法里的决策变量。2.2 标准 Bresenham 算法的循环结构一个典型的 Bresenham 画线函数长这样void OLED_DrawLine(int x0, int y0, int x1, int y1) { int dx x1 - x0; int dy y1 - y0; int sx (dx 0) ? 1 : -1; int sy (dy 0) ? 1 : -1; dx (dx 0) ? dx : -dx; dy (dy 0) ? dy : -dy; int err dx - dy; while (1) { OLED_SetPixel(x0, y0); if (x0 x1 y0 y1) break; int e2 2 * err; if (e2 -dy) { err - dy; x0 sx; } if (e2 dx) { err dx; y0 sy; } } }这段代码看起来逻辑清晰循环退出条件是x0 x1 y0 y1。理论上每次循环都会让x0或y0向终点靠近最终必然到达终点。但问题恰恰出在这个理论上。2.3 死循环是怎么发生的死循环的产生通常有几种典型情况我逐一拆解。第一种坐标越界导致终点永远无法到达。假设 OLED 屏幕分辨率是 128x64但传入的终点坐标是(200, 30)。在OLED_SetPixel内部通常会有边界检查超出范围的像素直接忽略。但 Bresenham 循环本身并不知道屏幕边界它会继续按照算法逻辑推进坐标。如果OLED_SetPixel没有对坐标做钳位而循环条件又依赖坐标精确等于终点那么当坐标因为某种原因跳过了终点时循环就永远不会退出。第二种整数溢出导致决策变量异常。如果dx或dy的值非常大比如坐标范围在int边缘2 * err或err dx可能产生溢出。溢出后的值变成负数或异常值导致x0和y0的增减方向错乱坐标可能在终点附近来回震荡永远无法同时满足x0 x1 y0 y1。第三种起点和终点相同但循环条件写法有误。有些实现把循环条件写成while (x0 ! x1 || y0 ! y1)然后在循环体内先画点再移动。如果起点等于终点循环体一次都不执行这倒不会死循环。但如果循环体内部有continue或条件分支导致某次迭代没有移动坐标就会出问题。第四种符号处理错误导致方向反了。比如sx和sy的计算依赖dx和dy的原始符号但如果在计算sx之前就把dx取绝对值了那么sx永远为 1当终点在起点左侧时x0会一直向右走离终点越来越远。我在实际项目中遇到的是第一种和第四种的组合传入的坐标没有做边界检查同时sx的计算顺序写错了。结果就是屏幕显示到边缘后坐标继续往外走循环永远不退出。3. 把死循环揪出来排查思路与实操记录3.1 用调试器定位卡死位置当你怀疑程序卡在某个循环里时第一步是用调试器暂停程序查看 PC 指针位置。以常见的 Cortex-M 调试环境为例在 IDE 中点击暂停按钮调用栈会显示当前执行的函数。如果停在OLED_DrawLine或类似的绘图函数里基本可以确认是画线循环出了问题。接下来要观察循环变量的值。把x0、y0、x1、y1、dx、dy、err这几个变量全部加入监视窗口。如果发现x0和y0在某个范围之外持续变化或者err的值异常大或异常小就能快速定位问题类型。我当时的调试记录是这样的x0从 0 开始递增到 127 之后继续变成 128、129……而x1是 200。y0在 30 附近小幅震荡。err的值在正负之间反复横跳。这就很明确了x0在向x1靠近但因为屏幕边界限制OLED_SetPixel在x0 127后不再实际画点而循环本身没有边界退出机制所以它会一直走到x0 200。但如果x1本身也超出了int的合理范围或者err溢出导致x0跳过 200那就永远出不来了。3.2 用串口打印做轻量级追踪不是所有场景都能方便地挂调试器比如产品已经组装好或者调试器资源紧张。这时候可以用串口打印关键变量。在循环体内加一行打印输出x0、y0、err的值然后观察输出序列。但要注意串口打印本身会严重影响时序可能导致原本偶尔出现的死循环变得更容易复现也可能掩盖某些时序相关的问题。所以串口打印适合做初步定位确认问题后再用调试器精确定位。一个实用的技巧是不要每次循环都打印而是加一个计数器每 100 次或 1000 次循环打印一次。这样既能观察趋势又不会因为打印太多而拖慢系统。3.3 静态分析人工审查循环退出条件很多时候死循环的 bug 在代码审查阶段就能发现。重点检查以下几点循环退出条件是否依赖多个变量同时满足如果是是否存在某个变量永远无法达到目标值的情况循环体内是否所有分支都会导致至少一个循环控制变量向退出条件靠近是否存在整数溢出风险特别是2 * err这种乘法操作。坐标变量的类型是否足够容纳所有可能的输入范围我后来养成了一个习惯每写一个while循环都会在注释里写清楚这个循环为什么一定会退出。如果写不出令人信服的理由就说明循环条件设计有问题。3.4 常见问题速查表问题现象可能原因排查方法修复方向屏幕显示部分线条后卡死坐标越界循环无法到达终点监视坐标变量是否超出屏幕范围增加边界检查或钳位小屏幕正常大屏幕卡死整数溢出检查dx、dy、err的值范围使用更大类型或限制输入范围特定斜率卡死符号处理错误检查sx、sy计算顺序先保存原始差值再取绝对值起点等于终点时卡死循环条件写法错误检查循环退出判断增加起点终点相同的特殊处理偶尔卡死复位后正常内存越界破坏变量检查数组边界和栈使用增加内存保护或优化变量布局4. 修复方案让画线函数既快又稳4.1 方案一增加边界钳位与循环保护最直接的修复方式是在函数入口对坐标做钳位确保所有坐标都在屏幕范围内。同时给循环加一个最大迭代次数保护即使算法逻辑出问题也不会无限循环。#define OLED_WIDTH 128 #define OLED_HEIGHT 64 #define MAX_ITER (OLED_WIDTH OLED_HEIGHT 10) void OLED_DrawLine_Safe(int x0, int y0, int x1, int y1) { /* 边界钳位 */ if (x0 0) x0 0; if (x0 OLED_WIDTH) x0 OLED_WIDTH - 1; if (y0 0) y0 0; if (y0 OLED_HEIGHT) y0 OLED_HEIGHT - 1; if (x1 0) x1 0; if (x1 OLED_WIDTH) x1 OLED_WIDTH - 1; if (y1 0) y1 0; if (y1 OLED_HEIGHT) y1 OLED_HEIGHT - 1; int dx (x1 x0) ? (x1 - x0) : (x0 - x1); int dy (y1 y0) ? (y1 - y0) : (y0 - y1); int sx (x0 x1) ? 1 : -1; int sy (y0 y1) ? 1 : -1; int err dx - dy; int iter 0; while (1) { OLED_SetPixel(x0, y0); if (x0 x1 y0 y1) break; if (iter MAX_ITER) break; /* 保护性退出 */ int e2 2 * err; if (e2 -dy) { err - dy; x0 sx; } if (e2 dx) { err dx; y0 sy; } } }这个方案的好处是改动小、兼容性好适合在现有项目上快速修复。缺点是钳位会改变线条的实际显示效果如果传入的坐标本来就超出屏幕钳位后线条会被裁剪到屏幕边缘而不是按比例缩放。对于大多数 UI 场景来说这种裁剪行为是可以接受的。4.2 方案二改用整数迭代步数控制循环更稳健的做法是不依赖坐标相等来判断循环结束而是预先计算需要迭代的步数用步数计数器控制循环。这样即使坐标因为某种原因出现偏差循环也一定会终止。void OLED_DrawLine_Iter(int x0, int y0, int x1, int y1) { int dx (x1 x0) ? (x1 - x0) : (x0 - x1); int dy (y1 y0) ? (y1 - y0) : (y0 - y1); int sx (x0 x1) ? 1 : -1; int sy (y0 y1) ? 1 : -1; int err dx - dy; int steps (dx dy) ? dx : dy; /* 总步数 */ for (int i 0; i steps; i) { OLED_SetPixel(x0, y0); int e2 2 * err; if (e2 -dy) { err - dy; x0 sx; } if (e2 dx) { err dx; y0 sy; } } }这个版本用for循环和steps计数器替代了while(1)循环次数在进入循环前就已经确定不存在死循环的可能。steps取dx和dy中的较大值这是因为 Bresenham 算法在长轴方向上每次迭代至少走一步短轴方向可能走也可能不走所以总迭代次数等于长轴方向的步数。4.3 方案三使用定点数或更大整数类型防溢出如果坐标范围可能很大比如在某些虚拟画布或缩放场景中dx和dy可能达到几万甚至几十万。这时候2 * err很容易溢出 16 位整数甚至 32 位整数。解决方案是使用int64_t或者把误差变量改为定点数表示。#include stdint.h void OLED_DrawLine_Wide(int32_t x0, int32_t y0, int32_t x1, int32_t y1) { int32_t dx (x1 x0) ? (x1 - x0) : (x0 - x1); int32_t dy (y1 y0) ? (y1 - y0) : (y0 - y1); int32_t sx (x0 x1) ? 1 : -1; int32_t sy (y0 y1) ? 1 : -1; int64_t err (int64_t)dx - (int64_t)dy; int32_t steps (dx dy) ? dx : dy; for (int32_t i 0; i steps; i) { OLED_SetPixel((int)x0, (int)y0); int64_t e2 2 * err; if (e2 -(int64_t)dy) { err - dy; x0 sx; } if (e2 (int64_t)dx) { err dx; y0 sy; } } }这里把误差变量提升到 64 位中间计算也用 64 位避免溢出。代价是运算速度略慢但在大多数嵌入式场景中画线的频率不会高到成为瓶颈。如果确实对性能敏感可以在函数入口判断坐标范围小范围时走 32 位快速路径大范围时走 64 位安全路径。4.4 三种方案对比与选型建议方案改动量安全性性能影响适用场景边界钳位迭代保护小高极小现有项目快速修复步数计数器控制中高无新项目首选64位误差变量中最高略慢大坐标范围场景我个人在实际项目中更倾向于第二种方案也就是用步数计数器控制循环。原因很简单它从结构上消除了死循环的可能性而不是靠打补丁来兜底。而且代码可读性更好后来接手的人一眼就能看懂循环会执行多少次。5. 更深一层为什么这类 bug 总是反复出现5.1 嵌入式图形库的常见设计缺陷很多嵌入式图形库为了追求极致的代码体积和运行速度会省略边界检查和溢出保护。这在资源极度受限的单片机上是可以理解的但代价就是稳定性下降。Bresenham 画线函数的死循环只是其中一个典型代表类似的还有填充算法、圆弧绘制、多边形裁剪等。另一个常见问题是坐标类型不统一。有些函数用uint8_t存坐标有些用int有些用int16_t。当这些函数互相调用时隐式类型转换可能产生意想不到的结果。比如uint8_t的 200 转换成int是 200但如果某个中间变量是int8_t200 就变成了 -56坐标直接跑到屏幕外面去了。5.2 防御性编程在嵌入式图形中的落地防御性编程不是要把每个函数都写成铜墙铁壁而是要在关键路径上设置合理的保护。对于画线函数来说我认为至少要做到以下几点入口处对坐标做范围检查超出屏幕的坐标要么钳位要么直接返回。循环必须有确定的终止条件优先使用for循环和步数计数器。误差变量使用足够宽的类型避免中间计算溢出。如果性能允许增加迭代次数上限作为最后一道防线。这些措施增加的代码量很小通常不超过 20 行但能避免绝大多数死循环问题。我在后来的项目中把这套做法固化成了模板新写的每个绘图函数都套用这个结构再也没有出现过类似的卡死问题。5.3 测试用例设计让 bug 无处可藏光靠代码审查还不够需要设计针对性的测试用例。以下是我常用的测试坐标组合起点和终点相同(0,0)到(0,0)水平线(0,0)到(127,0)垂直线(0,0)到(0,63)对角线(0,0)到(127,63)反方向对角线(127,63)到(0,0)超出屏幕边界(-10,-10)到(200,100)极端斜率(0,0)到(1,63)和(0,0)到(127,1)把这些用例跑一遍基本能覆盖 Bresenham 算法的所有边界情况。如果某个用例导致卡死或显示异常就能快速定位到具体的逻辑分支。6. 实操心得与避坑清单6.1 我踩过的三个坑第一个坑以为int一定够用。在一个项目中坐标范围设计为 0 到 320我觉得int肯定没问题。但后来增加了画布缩放功能坐标被放大了 10 倍dx达到 32002 * err在某些情况下超过了 32767如果编译器把int当作 16 位处理就会溢出。后来统一改用int32_t才解决。第二个坑忽略了OLED_SetPixel的副作用。有些 OLED 驱动库的SetPixel函数内部会做页地址切换如果连续调用时坐标变化跨度太大可能触发 I2C 通信超时。虽然这不是死循环但表现和死机很像。排查时要把绘图逻辑和通信逻辑分开看。第三个坑在中断里调用画线函数。有一次我把画线函数放在定时器中断里结果主循环里的 I2C 通信和中断里的 I2C 通信冲突导致总线锁死。后来改成中断里只设置标志位实际绘图在主循环中完成。这个教训是绘图函数通常不是可重入的不要在中断和主循环中同时调用。6.2 避坑清单提示以下清单建议在每次编写或修改绘图函数时逐条核对。坐标类型是否统一入口参数和内部变量是否使用了足够宽的类型是否处理了起点等于终点的特殊情况循环退出条件是否只依赖一个确定的计数器误差变量的中间计算是否会溢出2 * err的最大值是多少是否对输入坐标做了范围检查或钳位绘图函数是否可能在中断中被调用是否存在重入风险是否用边界坐标测试用例验证过6.3 一个实用的调试小技巧如果你怀疑某个绘图函数导致死机但又不能确定是哪一个可以用二分法排查先注释掉一半的绘图调用看是否还死机如果正常说明问题在被注释掉的那一半里然后继续二分直到定位到具体函数。这个方法看起来笨但在代码量大、调用关系复杂的时候非常有效。另外可以在每个绘图函数入口和出口各翻转一个 GPIO 引脚用示波器或逻辑分析仪观察波形。如果某个引脚翻转后一直没有再次翻转说明程序卡在了对应的函数里。这个方法不需要调试器适合现场排查。7. 从画线函数延伸出去的思考Bresenham 画线函数的死循环问题本质上是一个循环不变式设计的问题。任何依赖多个变量协同变化来控制循环退出的代码都存在类似的隐患。比如链表的遍历、状态机的跳转、协议解析的等待循环如果退出条件设计不严谨都可能出现永远等不到的情况。我在后来的开发中形成了一个习惯对于任何可能长时间运行的循环都会问自己三个问题。第一这个循环在最坏情况下会执行多少次第二有没有可能某个变量永远达不到退出条件第三如果循环不退出系统会表现成什么样我能不能快速发现这三个问题看起来简单但能逼着我把循环的边界条件想清楚。很多死循环 bug 不是技术难题而是思维盲区。写代码的时候多花两分钟想清楚循环怎么退出调试的时候就能少花两小时。OLED 显示驱动本身并不复杂但它是嵌入式系统中少有的直接面向用户的模块。屏幕不亮、花屏、卡死这些问题用户一眼就能看到所以对稳定性的要求反而比很多后台模块更高。把画线函数写稳只是第一步。后面还有填充、圆弧、字体渲染、图片解码等一系列功能等着你去踩坑。但只要掌握了排查死循环的思路和防御性编程的方法这些问题都会变得有迹可循。