2026/10/18 12:31:22

OLED画线死循环排查:Bresenham算法边界条件与嵌入式修复实践

OLED画线死循环排查:Bresenham算法边界条件与嵌入式修复实践 屏幕刚亮起来系统就卡死。这是我第一次遇到 OLED 一调用画线函数就整机假死的情况。一块 128x64 分辨率的 OLED 显示模块接在常见的 Cortex-M 系列 MCU 上I2C 接口底层的画点函数单独调没有任何问题显示字符数字也正常但只要调用了画线功能肉眼可见的就是程序挂在原地不动了。刚开始没有人往算法头上想I2C 时序不对、复位引脚冲突、供电毛刺、中断嵌套卡死这些才是优先怀疑对象。逻辑分析仪抓了半天波形也没看出异常最后接上调试器单步执行才逮住真凶Bresenham 画线函数内部藏了一个死循环。这篇文章把这颗雷完整拆开从现象定位、算法原理、死循环触发条件到最终的修复代码和验证方案全部讲清楚。适合正在调 OLED 图形库的朋友、写过画线函数但只测过正向直线的同学以及所有不想再被 while 循环卡到头皮发麻的嵌入式开发者。1. 问题现场一次画线引发的整机“假死”1.1 现象描述与初查路径故障的现象很规律程序启动后 OLED 正常初始化显示开机页、清屏、画字符都没问题但只要执行到“画线段”的调用整机立刻失去响应。因为当时测试程序没有开看门狗所以 MCU 不是复位重启而是硬生生挂死在某个地方。外部表现就是屏幕停在那不动串口调试助手没有任何新输出按键也都没反应。初查阶段我把怀疑点按概率排序分了几路。第一路是 I2C 通信担心画线过程中频繁写像素把总线搞挂于是单独在画线循环里加延时问题依旧存在。第二路是屏幕驱动芯片的显示 RAM 寻址方式有些屏在连续写数据时要先设置页地址和列地址如果越界写到了不存在的页地址驱动芯片可能返回异常 ACK 甚至拖死总线但实测单独写像素函数循环几千次都没问题。第三路才是偶然间想到的——拿调试器暂停程序看 PC 指针停在哪里结果发现主循环被卡在画线函数里一个 while 结构上怎么都绕不出来。这里想先多说一句遇到“一运行就死机”的问题不要一上来就重新焊接、换屏、换电机而是花五分钟用调试器暂停一下看程序到底停在哪个地址。硬件问题会分散在各种中断或者外设初始化流程里软件死循环则通常指向某几个文件里的特定行号区别非常明显。1.2 三条直观怀疑怎么被排除的我用一张小表记录当时的排查过程很多问题排查到最后其实依赖这种“排除法可复现”的组合。怀疑方向排查方式结论I2C 总线异常逻辑分析仪抓取画线前后的总线波形检查 ACK/NACK波形正常无总线锁死OLED 驱动寻址越界单独连续调用画点函数写入边界坐标单点写入正常未触发异常系统时钟/中断配置不当关闭所有中断后单独调用画线函数仍然死机排除中断因素画线算法内部死循环调试器暂停后查看 PC 指针与调用栈定位到 Bresenham 函数的 while 循环排除了前三项之后问题范围已经缩得很小要么是画线算法本身在某种参数下死循环要么是算法里叠加了超出屏幕范围的坐标写入把内存写坏。接下来要做的事情就是老老实实把 Bresenham 算法连同它的边界条件一起重新读一遍。2. Bresenham 算法的底牌它为什么快又为什么脆2.1 从直线方程到整数决策一个具体推演要在 OLED 上画一条直线最直接的做法是从起点到终点按 x 方向逐点前进每个 x 用直线方程算一个浮点 y。y y1 k * (x - x1)其中 k 是斜率。这个思路本身没错但在没有硬件浮点单元的 MCU 上每画一个点都要做浮点乘法一帧画面几百条线就是几千次浮点运算帧率直接被拖垮。Bresenham 的核心思想是把浮点斜率换成整数误差项每次只做加法、减法和比较因此特别适合嵌入式场景。我以一个具体直线为例从 (1,1) 到 (6,4)。这时候 dx 5dy 3。经典写法会维护一个决策参数 p初始值 pInit 2 * dy - dx 2 * 3 - 5 1。步数当前坐标当前 p判断与动作更新后的 p0(1,1)1起点直接画点11(2,2)p 0x 和 y 都加 1p 2dy - 2dx 1 6 - 10 -32(3,2)-3p 0只 x 加 1p 2dy -3 6 33(4,3)3p 0x 和 y 都加 1p 6 - 10 -14(5,3)-1p 0只 x 加 1p 6 55(6,4)5p 0x 和 y 都加 1p 6 - 10 1最终输出点序列是 (1,1)、(2,2)、(3,2)、(4,3)、(5,3)、(6,4)和真实直线吻合。这个推演过程看起来毫无波澜但它隐藏了一个前提dx 和 dy 都是正数而且曲线的整体走向是第一象限、x 方向为主导。2.2 一个“能跑但经不起考验”的参考实现网上有很多简化版画线函数大多数长这样void Line_Basic(int x1, int y1, int x2, int y2) { int dx x2 - x1; int dy y2 - y1; int p 2 * dy - dx; int x x1; int y y1; while (x x2) { OLED_DrawPixel(x, y, 1); x; if (p 0) { p 2 * dy; } else { p 2 * dy - 2 * dx; y; } } }这个函数只在“x2 大于 x1、y2 大于 y1、dx 大于 dy”的条件下正确。也就是说它只能画从左下到右上、且比较平缓的直线。第一象限正斜率的一部分线段凑合能画其余七个方向基本全错某些方向直接死循环。真正通用的画线函数必须同时处理坐标的四种符号组合和斜率大于 1 两种情况这正好是很多 OLED 图形库移植时翻车最集中的地方。2.3 脆弱点逐个点名方向、符号、主循环轴对照上面这段代码可以找出三个致命设计缺陷。第一没有处理 dx、dy 的符号。当 x2 x1 时dx 是负数pInit 的计算结果就完全偏离了正常范围。比如从 (100,20) 画到 (10,80)dx 10 - 100 -90dy 80 - 20 60p 2 * 60 - (-90) 210这个参数已经失去决策意义。第二循环体里 x 永远做自增。如果终点在 x 的负方向x 每次加 1 只会离目标越来越远。配合 while (x ! x2) 这种写法x 从 100 出发目标是 10x 朝着 101、102、103 一路跑下去条件永远不成立这就是死循环的直接来源。如果循环条件是 while (x x2)反向时条件一开始就是假循环体根本不执行屏幕画不出线倒是不会死机。第三主循环轴固定为 x 轴。当 dy 大于 dx 时直线更“陡”每一列 x 对应多行 y。如果仍然以 x 为主循环轴相邻两个点之间 y 方向会跳变画出来的线会出现明显断点俗称“虚线感”。正确的做法是让循环轴自动选择 dx 和 dy 中较大的一方。还有一个很多人忽视的隐藏问题决策参数 p、dx、dy 的类型选择。如果用了 unsigned int 或者 uint16_tdx x2 - x1 在反向时会发生无符号回绕得到一个巨大正数后面所有判断全部错乱。这个问题单独拎出来说因为它在单步调试时非常迷惑人。3. 死循环的完整推演从(100,20)到(10,80)3.1 逐行跟踪循环变量的变化我用一个具体调用把死循环的过程完整推演一遍。假设代码是while (x ! x2) { OLED_DrawPixel(x, y, 1); x; if (p 0) { p 2 * dy; } else { p 2 * dy - 2 * dx; y; } }入参是 x1 100y1 20x2 10y2 80。按错误实现计算dx 10 - 100 -90dy 80 - 20 60pInit 2 * 60 - (-90) 210x 100y 20。进入循环第一次迭代画点 (100,20)然后 x 变成 101。因为 p 是正数走 else 分支p 更新为 210 120 - 2 * (-90) 210 120 180 510y 变成 21。第二次迭代画点 (101,21)x 变成 102p 继续增大。观察这个趋势就明白了x 每次加 1而终点 x2 是 10两者之差从 90 变成 91、92、93……永远不可能相等。y 倒是朝终点方向走但循环的退出条件只检查 x 不检查 y所以算法根本不知道 y 已经到位。程序就这样在 while 里无限画点看似疯狂工作实则原地空转。如果换成 while (x x2) 这种条件反向画线时 x 100 大于 10循环体一次都不执行函数直接返回。这倒是不会死机但调用方期待看到一条从 (100,20) 到 (10,80) 的线实际什么都没画出来。两种写法都不对只是后果不同。3.2 另一种隐蔽炸弹无符号整型下溢死循环还有一种非常隐蔽的触发方式和算法结构无关纯粹是 C 语言类型转换在捣乱。假设把 dx、dy 声明成 uint16_t那么执行 uint16_t dx x2 - x1 时如果 x2 - x1 是负数结果会变成一个巨大的无符号数。举例来说x1 10x2 100正常应该是正 90。反过来 x1 100x2 10得到 -90按 uint16_t 存储就是 65536 - 90 65446。后面的 pInit 2 * dy - dx 会基于这个 65446 参与计算得到的决策参数同样是天文数字。p 的正负判断因此完全失去意义循环内部的 x、y 增长方向和终点完全脱节。单步调试时你会看到变量值在 60000 上下跳动第一反应往往以为是数据长度不够很少意识到它其实代表一个负数。这种问题在“偶尔死机、不是必然死机”的故障里更常见。因为只有当坐标差值恰好为负时才会触发而测试者可能只在图上画了几个固定方向的线段恰好避开了反向场景。等后面加菜单、画波形、画任意方向箭头时死机就随机冒出来了。3.3 顺带排查 OLED 画点坐标越界定位到死循环之后我顺手做了一次代码走查又发现一个藏在画点函数里的隐患。OLED 显示模块的坐标范围有限128x64 意味着 x 是 0 到 127y 是 0 到 63。错误版的画线函数在反向场景下会把 x 推到 128、129、130 甚至更大直接调用底层画点函数。如果底层画点函数没有做边界裁剪这些越界坐标会一路写进显示 RAM 之外的内存区域。更麻烦的是很多 OLED 驱动库会把显示缓冲区定义成数组数组越界写入可能覆盖相邻的全局变量包括循环变量、状态标志甚至函数返回地址。一旦循环变量被污染即使你修复了画线算法本身程序也可能在别的地方出现莫名字段错误或死机。我的建议是底层画点函数永远保留边界判断最差也要做坐标裁剪。这个习惯能让上层图形函数在调试期少背负一类“幽灵 bug”。就算性能紧张画点函数里两个比较指令的成本也远低于一次意外内存覆盖造成的排查时间损失。4. 修复与验证一套能覆盖全象限的画线函数4.1 修复后的完整代码正确的 Bresenham 画线函数有很多种等价写法我推荐下面这个版本它同时处理了方向符号、斜率大于 1、主循环轴自动切换三个问题void OLED_DrawLine(int x1, int y1, int x2, int y2) { int dx (x2 x1) ? (x2 - x1) : (x1 - x2); int dy (y2 y1) ? (y2 - y1) : (y1 - y2); int sx (x2 x1) ? 1 : -1; int sy (y2 y1) ? 1 : -1; int err dx - dy; int e2; while (1) { OLED_DrawPixel(x1, y1, 1); if (x1 x2 y1 y2) { break; } e2 2 * err; if (e2 -dy) { err - dy; x1 sx; } if (e2 dx) { err dx; y1 sy; } } }几个关键点说明一下。dx 和 dy 都取绝对值这样后续判断不用关心坐标差值的正负。sx 和 sy 记录真实的前进方向取 1 或 -1。err 初始值是 dx - dy它像一根“跷跷板”哪个方向落后就朝哪个方向补一步。e2 是临时变量用来同时判断 x 和 y 两个方向是否都需要步进。这个实现没有显式交换 x、y 轴但算法本身天然支持斜率大于 1 的情况当 dy 大于 dx 时err 初始值为负e2 dx 的条件会更频繁成立y 方向自然变成主导方向。这也是它比一开始那个朴素版全面得多的原因。循环为什么用 while (1) 配合 break而不是 while (x1 ! x2 || y1 ! y2)因为每轮迭代后 x1 和 y1 可能同时变化也可能只变化其中一个用 break 在“当前点刚好是终点”时立即退出逻辑更直观也不容易出现跨越终点的情况。坐标值不超过屏幕范围时最多执行与长轴像素数相同次数的迭代性能完全够用。4.2 六个方向八组用例的验证矩阵修复完代码后我没有直接烧录到屏幕上而是先在 PC 上做了一次软件模拟。做法很简单把 OLED_DrawPixel 替换成一个向控制台打印坐标点的函数然后用一组覆盖全象限的用例跑一遍。编号测试直线覆盖方向预期结果1(0,0) - (127,63)左下到右上dx dy完整连续直线2(127,63) - (0,0)右上到左下反向完整连续直线3(0,63) - (127,0)左上到右下反向斜率完整连续直线4(127,0) - (0,63)右下到左上反向斜率完整连续直线5(0,30) - (127,30)水平线每个 x 对应唯一 y6(64,0) - (64,63)垂直线每个 y 对应唯一 x7(0,0) - (63,63)45 度对角线无断点、无重复点8(10,10) - (120,12)接近水平的小斜率线点和真实直线偏差不超过 1第一轮模拟就跑出了两条轻微变形的斜线原因是模拟程序里画点坐标我用了 x、y 直接打印没有做坐标换行对齐以为是算法问题调整打印格式后所有用例全部通过。这也提醒了我算法验证阶段一定要把可视化和算法本身剥离开别把显示层的问题怪到算法头上。4.3 上板验证时想加的两个安全措施模拟测试通过之后我又在板子上补了两道保险。第一道是给画线循环加一个看门狗喂狗机制。如果有意外情况导致函数长时间不返回至少系统能触发复位而不是像第一次那样彻底假死。第二道是在画线函数入口和出口各放一条串口日志记录入参坐标和循环结束后的实际坐标。这样跑自动化测试时如果某条随机线段让函数“陷进去”日志文件里能看到最后一次正常调用的坐标组合复现概率会高很多。我还顺手写了一段随机线段测试代码每轮随机生成 1000 组坐标坐标范围限制在 0 到 127、0 到 63 之间依次调用画线函数并在每次调用后检查一个全局计数器是否增长。这个计数器放在主循环里一旦画线卡住串口调试助手会立刻发现计数不再增加。连续跑了几万条随机线段没有再出现死循环。5. 把死循环按死在摇篮里避坑清单与排查手段5.1 嵌入式循环里最常见的七种死法画线函数的死循环只是众多死法之一。这些年调试过不少“程序跑飞”的问题总结出七种最常见的循环结构误用逐一列出来供排查时对号入座。死法典型代码特征触发条件循环变量方向与判定条件相反while (x ! x2) 但 x反向遍历时无符号数参与减法产生回绕uint16_t dx x2 - x1x2 x1 时for 循环体内修改循环变量循环体内调用函数间接改 i函数副作用浮点数判等while (f ! 0.0f)浮点截断导致恒不等中断与主循环共享变量无保护主循环等待标志位清 0中断修改标志时机不确定越界写坏相邻循环变量数组写越界覆盖 while 条件特定输入数据时优化器对循环变量过度优化volatile 缺失看似无限循环实际被优化掉大多数情况下只要把循环条件改成可收敛的等价形式或者明确变量的符号属性问题都能直接消除。比如 while (x ! x2) 改成 while (x x2) 或者引入方向符号之后用 while (x ! x2) 才安全。5.2 定位死循环的三板斧条件断点、串口日志、看门狗真遇到死循环我的排查顺序有讲究。首选是调试器的条件断点在循环入口处设置断点条件写成 x 200这样变量接近终点时断点才触发不用单步几千次。断点触发后查看 x、y、err 三个变量的变化趋势比盲猜高效太多。如果硬件调试器不方便就用串口日志。在循环内部每 100 次迭代打印一次当前坐标和 err 值跑几秒钟之后看日志规律是收敛还是发散一目了然。等到定位完问题再把日志删掉或者变成条件编译别影响最终性能。看门狗是我建议常开的调试伴侣。不是靠它解决问题而是靠它把“整机死机”变成“反复复位”配合复位原因寄存器可以统计死机频率。有时候问题只在特定路径出现没有看门狗你只能手动按复位键次数多了很容易烦躁有看门狗至少能自动跑测试。5.3 一个值得养成的习惯先画坐标纸再码代码最后分享一个我后来一直保持的习惯。任何图形算法要写到 OLED 或 LCD 之前先拿一张方格纸把坐标系画出来左上角是 (0,0)x 正方向朝右y 正方向朝下。然后把你要支持的八个方向直线全部标一遍再对照代码判断每个方向下 dx、dy、sx、sy、err 各自是什么值。这一步看起来花了几分钟实际上能挡掉九成以上的边界 bug。那次 OLED 画线死机事件之后我再也没有在图形函数的边界条件上翻过车。后来写菜单图标、画波形、做简易指针表盘都是先把方向、符号、边界条件写在注释里再开始填代码。调试器暂停的那一瞬间我看到程序卡在画线函数的 while 循环里心里其实松了一口气。因为问题找到就好办最怕的是查了两天找不到原因。现在回头想这个坑本质上不是算法太深奥而是写代码时只考虑了最常见的正向场景把所有其他方向都当成了“用户不会用到”的情况。嵌入式开发里哪有那么多“不会用到”屏幕一旋转、UI 一换风格、坐标一反向隐藏的边角条件立刻集体出动。好在 Bresenham 算法本身并不复杂理解它如何用误差项在 x、y 两个方向之间做取舍这个坑就永远不再是坑。