屏幕刚亮起来,系统就卡死。这是我第一次遇到 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 = 5,dy = 3。经典写法会维护一个决策参数 p,初始值 pInit = 2 * dy - dx = 2 * 3 - 5 = 1。
| 步数 | 当前坐标 | 当前 p | 判断与动作 | 更新后的 p |
|---|---|---|---|---|
| 0 | (1,1) | 1 | 起点直接画点 | 1 |
| 1 | (2,2) | p >= 0,x 和 y 都加 1 | p += 2dy - 2dx = 1 + 6 - 10 = -3 | |
| 2 | (3,2) | -3 | p < 0,只 x 加 1 | p += 2dy = -3 + 6 = 3 |
| 3 | (4,3) | 3 | p >= 0,x 和 y 都加 1 | p += 6 - 10 = -1 |
| 4 | (5,3) | -1 | p < 0,只 x 加 1 | p += 6 = 5 |
| 5 | (6,4) | 5 | p >= 0,x 和 y 都加 1 | p += 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 = -90,dy = 80 - 20 = 60,p = 2 * 60 - (-90) = 210,这个参数已经失去决策意义。
第二,循环体里 x 永远做自增。如果终点在 x 的负方向,x 每次加 1 只会离目标越来越远。配合 while (x != x2) 这种写法,x 从 100 出发,目标是 10,x++ 朝着 101、102、103 一路跑下去,条件永远不成立,这就是死循环的直接来源。如果循环条件是 while (x <= x2),反向时条件一开始就是假,循环体根本不执行,屏幕画不出线,倒是不会死机。
第三,主循环轴固定为 x 轴。当 dy 大于 dx 时,直线更“陡”,每一列 x 对应多行 y。如果仍然以 x 为主循环轴,相邻两个点之间 y 方向会跳变,画出来的线会出现明显断点,俗称“虚线感”。正确的做法是让循环轴自动选择 dx 和 dy 中较大的一方。
还有一个很多人忽视的隐藏问题:决策参数 p、dx、dy 的类型选择。如果用了 unsigned int 或者 uint16_t,dx = 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 = 100,y1 = 20,x2 = 10,y2 = 80。按错误实现计算:dx = 10 - 100 = -90,dy = 80 - 20 = 60,pInit = 2 * 60 - (-90) = 210,x = 100,y = 20。
进入循环,第一次迭代画点 (100,20),然后 x 变成 101。因为 p 是正数,走 else 分支,p 更新为 210 + 120 - 2 * (-90) = 210 + 120 + 180 = 510,y 变成 21。第二次迭代画点 (101,21),x 变成 102,p 继续增大。
观察这个趋势就明白了: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 = 10,x2 = 100,正常应该是正 90。反过来 x1 = 100,x2 = 10,得到 -90,按 uint16_t 存储就是 65536 - 90 = 65446。
后面的 pInit = 2 * dy - dx 会基于这个 65446 参与计算,得到的决策参数同样是天文数字。p 的正负判断因此完全失去意义,循环内部的 x、y 增长方向和终点完全脱节。单步调试时你会看到变量值在 60000 上下跳动,第一反应往往以为是数据长度不够,很少意识到它其实代表一个负数。
这种问题在“偶尔死机、不是必然死机”的故障里更常见。因为只有当坐标差值恰好为负时才会触发,而测试者可能只在图上画了几个固定方向的线段,恰好避开了反向场景。等后面加菜单、画波形、画任意方向箭头时,死机就随机冒出来了。
3.3 顺带排查 OLED 画点坐标越界
定位到死循环之后,我顺手做了一次代码走查,又发现一个藏在画点函数里的隐患。OLED 显示模块的坐标范围有限,128x64 意味着 x 是 0 到 127,y 是 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 对应唯一 y |
| 6 | (64,0) -> (64,63) | 垂直线 | 每个 y 对应唯一 x |
| 7 | (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 - x1 | x2 < 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 两个方向之间做取舍,这个坑就永远不再是坑。