☰
OLED画线死机排查:Bresenham算法u8变量回绕陷阱
2026/10/13 1:05:09 网站建设 项目流程

OLED一运行画线就死机,这个问题我印象实在太深了。上个月调一块0.96寸、SSD1306驱动的OLED小屏,显示字符、刷新BMP图片都好好的,结果一跑Bresenham画线函数,屏幕指针画到一半就定住不动,主循环里按键扫描、传感器读取全部停摆。按复位能恢复,但一跑画线又死,非常有规律。反复排查后定位到画线函数里一个极其隐蔽的类型问题:把所有坐标、增量甚至是符号标志都声明成u8,直接导致无符号回绕、循环退出条件永远不满足。这次排障前前后后折腾了几天,中间还踩了"死机原因"的几个同类雷区,干脆写成一篇完整复盘,希望对同样在单片机上折腾图形显示的开发者有帮助。

1. 问题现场:OLED一跑画线就死屏

1.1 故障复现的固定步骤

这块屏用I2C接口挂在单片机的普通IO上,软件模拟I2C,之前所有基础功能都验证过:用OLED_ShowString显示字符串、OLED_ShowBMP显示位图、OLED_Clear清屏,统统没问题。死机是从我加了一个画线Demo开始的。Demo内容很简单,就是把屏幕对角线来回地画,画完清屏再画,循环轮播。烧进去之后屏幕能正常亮起,但大约一秒钟之后,屏幕上的线就停在半路,不再刷新。

按复位键,又重新开始,依然在同样的位置停下来。这个"固定位置"其实很关键,说明问题不是随机的时序毛刺,而是逻辑路径上有一个必然触发的缺陷。我用示波器钩了一下I2C的SCL线,发现死机前SCL上还有连续的时钟脉冲,然后突然就没有任何跳变了,SCL停在低电平或高电平一动不动。这说明代码在某个循环里空转,没在等待I2C事务,因为软件模拟I2C一旦进入传输就不会空转,SCL必然有时钟。

于是基本可以锁定:主程序卡在了一个没有I/O操作的纯计算死循环里,或者卡在某个不出I2C脉冲的等待循环。最值得怀疑的自然是画线函数本身。

1.2 死机点的快速定位

我习惯用最简单的方式先圈定范围:在画线函数前后各放一个GPIO翻转。画线前把LED拉高,画完拉低。烧录后观察LED状态,它一直保持高电平,说明死机就发生在画线函数的执行过程中。再进一步,在画线函数的循环体里每隔十次迭代翻转一次另一个测试脚,如果测试脚不动了,说明死循环就在这个循环内部。

实测发现,测试脚翻转了几十次后停止,非常稳定。所以我判断是循环内部某次迭代后,坐标值发生了异常变化,导致循环退出条件永远无法满足。现在问题清晰了:找出哪一次迭代、哪一步坐标更新出了幺蛾子。

1.3 为什么单步调试时反而不容易复现

这里有个值得说的现象:用调试器单步执行时,画线居然能正常跑完整个循环,不会死机;一旦全速运行,必死。最初被这个现象误导了很久,以为是时序或中断问题。后来想明白了,单步执行时,每一行代码之间插入了调试器的停驻时间,肉眼会观察到变量回绕的瞬间,却容易把它当作"正常值"放过去。另外,软件I2C的IO时序是循环子节拍控制的,单步调试会干扰时序,导致某些分支不被走到;全速时时序正常,分支稳定走到,问题才稳定暴露。说到底,单步不是"不会复现",而是"复现路径被调试器搅乱了"。

2. Bresenham 画线算法凭什么能画线

2.1 算法原理一句话讲透

在单片机上画直线,最直观的想法是从起点到终点,按斜率计算每个x对应的y,然后打点。这需要浮点除法,每次还要取整,既慢又不讨好。Bresenham算法的核心,是把"斜率"转化为一个整数误差项err,每走一步,根据err的符号决定是沿着主轴方向移动,还是同时在两个方向各移动一格。整个过程只用整数加减法,就能得到一条和理想直线贴合得很好的像素路径。

为什么是2 * err而不是直接用err?这是Bresenham一个很聪明的设计。经典的误差判定是:设真实直线在x+1处的y理论值,和当前像素y值的差为e,如果e > 0.5,则y方向应该进一格。判断e > 0.5等价于判断2e > 1。为了全部用整数运算,把e乘以2dx,误差阈值就变成了整数,不需要除法也不需要小数。err保存的就是这个2dx倍的误差,所以每次判断时都用err2 = 2 * err,然后和-dy、dx比较。这样无论斜率如何,判断逻辑统一。

具体到代码:设dx为主轴方向上的距离,dy为另一个方向上的距离。初始误差err = dx - dy。每走一步,先取err的二倍值err2来判断:如果err2 > -dy,说明误差偏大,需要在主方向前进一格外,还要在另一方向也前进一格;如果err2 < dx,说明另一个方向也需要前进。这两条判断可以同时发生,所以算法天然支持45度以内的任意斜率,而且代码里不需要比较dx和dy谁大谁小,长短轴统一处理。

2.2 标准参考实现的几个前提

网上流传最广的"通式"写法一般是这样的:

void bresenham_line(int x0, int y0, int x1, int y1) { int dx = abs(x1 - x0); int dy = abs(y1 - y0); int sx = x0 < x1 ? 1 : -1; int sy = y0 < y1 ? 1 : -1; int err = dx - dy; while (1) { draw_pixel(x0, y0); if (x0 == x1 && y0 == y1) break; int err2 = 2 * err; if (err2 > -dy) { err -= dy; x0 += sx; } if (err2 < dx) { err += dx; y0 += sy; } } }

这段代码本身没有大问题,但它有三个隐含前提,很多人会忽略。

前提一:所有参与运算的变量都必须是带符号整型(int或int16_t),因为sx和sy可能为-1,err可能为负,dx/dy虽然非负但在比较时也会和负数打交道。一旦把类型换成uint8_t,表面上省了RAM或寄存器,实际上破坏了符号语义。

前提二:draw_pixel这个底层函数必须容忍传入坐标越界。如果画一条从屏幕外起点到屏幕内终点的线,或者起点终点都合法但路径经过屏幕边界,draw_pixel会收到越界坐标。OLED的坐标范围是有限的(比如128x64),越界读写显存可能破坏其他数组、栈,甚至触发硬件错误。

前提三:终点检查依赖精确相等。算法每一步只沿主轴或对角线方向移动一个像素,理论上必然能精确落到终点,但这个结论建立在每一步坐标计算都严格符合数学预期的基础上。只要中途任何一次增量发生回绕、溢出或方向反了,坐标序列就会偏离,可能永远摸不到终点,然后while(1)就再也跳不出来。

这三点放在一起看,就知道死循环不是Bresenham算法本身的问题,而是实现者在类型选择、边界处理上的疏忽,恰好踩中了算法的"信任边界"。

3. 隐藏死循环的四个真凶

3.1 真凶一:u8变量回绕,循环退出失效

我的代码在最开始声明了一堆uint8_t,为的是"省内存"。OLED画线函数传入的坐标参数本来也是uint8_t类型的。问题就出在step变量(即sx、sy)和坐标累加的过程中。

C语言里,uint8_t sx = -1,存进去的是255。如果后续执行x0 += sx,而x0也是uint8_t,那么实际运算等价于x0 = x0 - 1,只要x0大于0,结果看上去是对的。

真正的危机出现在x0等于0并且还需要继续往回走的那一步。0减1在uint8_t里变成255,坐标瞬间从屏幕最左端且合法的0,跳到255这个在屏幕上根本不存在的值。之后x0继续递减,而终点x1如果是一个0到127之间的小整数,这个递减序列会一路滑下去,再也不能稳定命中目标。更常见的情况是,有一个轴的坐标在某一步从0蹦到255后,另一个轴已经到达终点,算法继续推进,导致已经到终点的轴也被推离,循环永远退不出。

我当时复现死循环的输入是画一条从(0, 20)到(30, 0)的短斜线,这个例子很有代表性。dx=30、dy=20,理论上一共需要约30到40次迭代。错误实现中,x方向每次都移动,y方向大约每两步移动一格。于是x大约在30次迭代时到达30,但y还停留在5附近。循环终检要求x0==x1且y0==y1,此时不满足,继续执行。下一步x方向仍然满足移动条件,x0变成31,永远回不到30。而y方向继续递减,等到y0从1变到0后,如果算法还需要再降一步,0 + 255 = 255,y0直接飞走。x和y都在离终点越来越远的方向上死循环。

即使不是这个具体坐标,所有"一轴提前到达,另一轴仍在推进"的路径都会让终点精确相等失效。只是标准算法中两轴恰好同时到达,所以大家没觉得这里是个坑。

3.2 真凶二:误差项用窄类型截断,算法逻辑全乱

Bresenham算法的灵魂是err和它的二倍值err2。err的取值范围近似在-max(|dx|,|dy|)到+max(|dx|,|dy|)之间,最大也就是几百。对于128x64的屏幕,用int16_t完全够。但有些实现为了省RAM把err也声明成uint8_t,这就是大问题了。

err2 = 2 * err,如果err超过127或者小于-128,结果直接被截断。例如err = 100,err2 = 200,存入uint8_t变成200;如果err2声明为int8_t还会溢出为-56。这一截断会让判断分支错乱,该移动时不移动,不该移动时乱移动。坐标轨迹不再是一条近似直线,而是在某个区域反复抖动,或者朝着完全错误的方向推进。终点自然永远无法到达。

哪怕屏幕很小,画一条几百像素长的线也只需要几十到几百步。可一旦分支错乱,步数就完全不可控了。我排查时用逻辑分析仪观察I2C引脚,发现画线过程中出现了大量超出屏幕范围的坐标,就是err截断导致的。

3.3 真凶三:坐标越界写坏显存甚至栈

OLED的驱动芯片一般内置GRAM,以SSD1306为例,显存总共1024字节,组织成8页、每页128列。向显存写入时,如果坐标越界,页地址、列地址的计算可能落在错误的RAM区域。

更隐蔽的是,如果你用的是"显存数组加缓冲区刷新"方案,比如定义一个局部数组uint8_t oled_buffer[1024],而draw_pixel里没有坐标校验,直接用y/8*128 + x计算下标,那么越界坐标会写到哪里?可能写出数组边界之外,踩坏相邻的局部变量、返回地址、甚至栈底。程序不一定当场死循环,可能表现为函数返回后跳飞,进入HardFault,或者整个系统行为莫名错乱。

在这个案例里,单步调试时曾看到画线中途x0变成255,而draw_pixel没有拦截,直接往缓冲区外写,把另一个缓冲区的前几个字节破坏掉了,后续主线逻辑一访问这些数据,整体就全乱了。

3.4 真凶四:终点"擦肩而过",精确相等永远不成立

前面三个真凶的根源各不相同,但它们最终都汇聚到同一个爆破点:Bresenham的循环退出依赖精确的x0 == x1 && y0 == y1。这个相等建立在"每个像素坐标严格按整数序列推进且不会跳变"的前提上。一旦中途坐标发生了回绕或截断,这个序列就不再单调且不再收敛,循环就永远等不到"相等"的那一刻。

有人把循环条件改成while (x0 != x1 || y0 != y1),看起来和if(相等)break等价,但改错了的话,比如用了&&,或循环末尾少画一个点,可能出现提前退出、终点像素缺失。虽然一般情况下不会死循环,但和真正的问题一样,都是对"精确相等"的错误信任。

这个真凶不总是单独出现,更多时候是和其他三个真凶叠加,排查时要当成一个现象来对待,而不是死磕某一个变量。

4. 定位死循环的实践记录

4.1 花式打印——用串口与LED把程序"照"出原形

死机问题不好直接看,我当时的调试工具组合是:串口打印、GPIO翻转、调试器单步,以及一台老逻辑分析仪。先说最有效的三板斧。

第一板斧,串口打印。这是在画线函数入口处打一行"LINE START"、出口处打"LINE END"。如果只看到START没有END,说明画线函数没返回。进一步,在while(1)循环体内打印x0、y0、err2这三个值,每100次迭代打一次,看最后一次打印时这三个值是什么。我测出来最后一次打印时,y0已经变成了0,下一步执行y0 += sy后立马变成255,之后y0就在254、253、255附近反复转,永远回不到0。这几乎就是把"无符号回绕"写在脸上了。

第二板斧,GPIO翻转。串口打印有个坏处:输出本身耗时,可能掩盖时序类问题。所以我在循环体内部放了两个测试脚,一个每50次迭代翻转一次,另一个在到达认为的"终点"时翻转。死机时,测试脚A停走,测试脚B从未翻转,说明循环根本没有走到可以被判定为"终点"的位置。

第三板斧,逻辑分析仪。如果是I2C或SPI刷新型的死机,用逻辑分析仪看总线状态非常直观。协议分析仪可以解码出每次写像素的坐标,你能直接看到一连串远大于屏幕宽度的值,比看内存还快。我抓到的数据里反复出现x=255、y=247这种坐标,虽然它们被draw_pixel里的无校验代码直接放进了显存,但也侧面证实了坐标已经失控。

4.2 单步调试的教训与久等不来的"现场"

说到单步调试,我的体会是:这种类型的Bug,单步大概率帮倒忙。原因有三。

第一,单步会让时间尺度拉长,某些依赖I2C时序的中间态被破坏,循环走的分支和全速时不一致,于是问题不出现或延迟出现。

第二,当你单步到比较坐标的那一行,编译器可能已经优化了变量的存储位置,你看到的x0值未必和寄存器里的实时值同步,容易产生"现在还没到终点"的误判。

第三,死循环本身在单步模式下更容易迷惑人,你每按一次Step,循环推进一次,你以为"还能走下去",实际上程序已经在一个永远不会退出的循环里。除非你故意把步数设成几万次上限,才会意识到循环次数远超一条线应有的像素数。

正确的做法是:先用GPIO或串口缩小范围,确定是哪一层循环卡住,然后用断点或者条件断点直接停在那条可疑语句上,再看变量的真实值。我当时就是在全速运行时,用条件断点y0 == 255逮住了第一现场:y0真的变成了255。

4.3 一次静态审视就破案:u8问题的特征太明显

在全速断点抓到y0=255之后,再回到代码里静态分析,一切就明朗了。这类u8协变量导致死循环,有几个非常典型的特征:

  • 死机位置基本固定,且和某条特定的直线方向强相关。我复现时,从左上到右下画线不卡死,从右上到左下就卡死,因为前者坐标递增,后者坐标递减,递减更容易触发回绕。

  • 画垂线或水平线不卡死,斜线容易卡死。垂直水平线只有一个方向在变化,回绕次数少,而且dx或dy为0时很多分支被跳过,能走到终点的概率更高。

  • 变量名带u8、uint8_t、unsigned char的画线函数,直接是怀疑对象。尤其是sx、sy这种看似只取值1或-1的变量,声明成无符号就是定时炸弹。

我在代码里把sx、sy、dx、dy、err、err2全部改成int之后,同一个画线Demo立刻跑通,没再死机。这说明根因就是变量类型选择错误。

5. 修复实现与加固方案

5.1 修正后的Bresenham参考实现

我把自己的修复版贴出来,这个版本已经在128x64的OLED上稳定跑了大半年:

void OLED_Line(int x0, int y0, int x1, int y1, uint8_t color) { // 边界裁剪:超出屏幕范围的坐标直接拒绝 if (x0 < 0 || x0 >= OLED_WIDTH || y0 < 0 || y0 >= OLED_HEIGHT || x1 < 0 || x1 >= OLED_WIDTH || y1 < 0 || y1 >= OLED_HEIGHT) { return; } int dx = abs(x1 - x0); int dy = abs(y1 - y0); int sx = x0 < x1 ? 1 : -1; int sy = y0 < y1 ? 1 : -1; int err = dx - dy; while (1) { OLED_DrawPixel(x0, y0, color); if (x0 == x1 && y0 == y1) break; int err2 = 2 * err; if (err2 > -dy) { err -= dy; x0 += sx; } if (err2 < dx) { err += dx; y0 += sy; } } }

改动要点:

  • 入参全部用int,函数内部不再出现uint8_t坐标计算。

  • sx和sy是int,0减1得到-1,x0 += sx得到正确的新坐标,不会发生回绕。

  • err、err2是int,对于128x64分辨率,即便画满屏对角线,绝对值也不会超过200,int完全覆盖,不存在截断。

  • 入口加了边界校验,拒绝所有起点或终点在屏幕外的线。这一步看着多余,其实是把"信任边界"明确化:算法只负责合法区域内的直线,非法输入直接不画。

你可能担心:如果以后要画一条起点在屏幕外、终点在屏幕内的线怎么办?那需要做真正的线段裁剪,用Cohen-Sutherland或Liang-Barsky算法把线段先切到屏幕范围内再画。屏幕只有128x64,裁剪成本很低,后面我会给一个简化方案。

5.2 底层DrawPixel必须做边界保护

很多人只改上层画线,忽略底层绘点函数。我建议OLED_DrawPixel内部无论如何都加一重越界保护,哪怕上层已经校验过:

void OLED_DrawPixel(int x, int y, uint8_t color) { if (x < 0 || x >= OLED_WIDTH || y < 0 || y >= OLED_HEIGHT) return; uint8_t page = y / 8; uint8_t bit = 1 << (y % 8); if (color == 1) oled_buffer[page * OLED_WIDTH + x] |= bit; else oled_buffer[page * OLED_WIDTH + x] &= ~bit; }

这一层保护的意义是双重的。第一,防止任何上层越界坐标写入显存数组;第二,防止数组下标的负值变成巨大正数,写穿栈区。嵌入式开发里,数组越界不一定会像PC那样报段错误,它可能只是悄悄改掉旁边某个全局变量的值,让你的程序在几个小时后的某个随机时刻崩溃,排查难度翻倍。

顺便说一下oled_buffer的组织。SSD1306的GRAM是8页,每页128字节。显存数组的顺序是:页0的128个字节(对应像素行0到7),然后是页1的128字节。所以第y行、第x列的像素,对应的数组下标就是(y/8)*128 + x。这个映射关系在写DrawPixel时最容易出错,尤其容易把页内偏移和列地址搞混。我第一次写DrawPixel,就忘记把y/8乘以宽度,导致画两条竖线,屏幕下半屏全是乱点,排查了很久才意识到是页地址算错了。

5.3 裁剪方案的简化实现

如果确实需要画起点在屏幕外的线,最简单的做法是在进入Bresenham之前,把线段用参数化形式裁剪到屏幕范围。设线段从P0(x0,y0)到P1(x1,y1),参数t从0到1,则直线上任意点为:

x(t) = x0 + t * (x1 - x0) y(t) = y0 + t * (y1 - y0)

把x限制在[0, OLED_WIDTH-1]、y限制在[0, OLED_HEIGHT-1],求出t的合法区间[tmin, tmax],然后用这个区间修正起点和终点。因为屏幕矩形是轴对齐的,裁剪每个轴只需要一次浮点除法,共四次比较,开销可以忽略。如果不想用浮点,也可以做整数形式的Cohen-Sutherland编码裁剪,代码也不复杂。考虑到OLED应用一般不会频繁画超长线,参数化裁剪更直观。

我自己用的方案,其实是在业务层保证传入画线的坐标都在屏幕范围内。因为画线菜单、图形表盘的线条都是由逻辑计算出来的,如果出现越界,那说明业务逻辑本身有Bug,与其在底层兜底,不如让上层直接暴露问题。底层的边界保护只负责"不写坏内存",不负责"画出好看的线"。

5.4 刷新策略:Buffer先行,一帧一刷

差点忘了一个重要配套:画线不能画一个点刷一次屏。SSD1306这类OLED,如果每个像素都用I2C写,那画一条对角线要发起上百次I2C事务,一次I2C事务包括起始位、设备地址、控制字节、数据字节,期间还有页地址设置、列地址设置,传输效率极低。我实测过,在400kHz软件I2C下,直接逐点写OLED,画一条128像素的对角线大约要15到20毫秒,屏幕同时还会闪烁。

正确做法是先在内存的显存数组oled_buffer上把所有点画好,全部画完后,再把整个buffer一次性写入OLED的GRAM。写入过程一般是:设置列地址0到127、页地址0到7,然后连续发送1024字节数据。400kHz下,全屏刷新大约需要25毫秒。如果业务可以接受30fps左右的刷新率,就用这个方案;如果条件允许,就用SPI接口的OLED,速率能上到10MHz以上,全屏刷新降到1毫秒量级,但那又是另一个话题了。

6. 避坑清单与个人心得

6.1 一眼识别同类隐患

复盘这个Bug,我总结了几个"特征码",以后看到代码里有以下模式,就要高度警惕:

  • 函数参数或局部变量使用了uint8_t、uint16_t来承载坐标、误差、方向标志。

  • 循环退出依赖x0 == x1 && y0 == y1这种精确相等判断,而变量可能是无符号。

  • 画线、画圆、画矩形等图形函数里,出现了i += step而step可能为负的写法。

  • 底层DrawPixel函数没有参数合法性检查,或者检查用了>=128但没有检查负数。

这类问题在编译时不会有任何警告,除非开启-Wsign-conversion之类的选项。我后来把工程的编译选项加上了-Wall -Wextra -Wconversion -Wsign-conversion,立刻就能在编译阶段挑出一大批无符号和有符号混合比较的警告,那感觉真是相见恨晚。

还有一个审查小技巧:把所有图形函数的参数先问一遍"这个值能不能为负?""如果为负会发生什么?""无符号时会不会回绕?"。这三个问题过一遍,很多隐患根本走不到运行期。

6.2 防御性编程的四个习惯

第一,坐标和几何计算统一用int,不要为了省那一两个字节的RAM去用u8。单片机RAM即便只有几KB,图形库用到的变量也就几十字节,这点开销根本无所谓,省下来的时间和崩溃风险才是真正的成本。

第二,凡是循环里可能出现"永远跑不到终点"的地方,都加一个最大迭代次数保护。比如Bresenham循环里,可以用for (int i = 0; i <= dx + dy; i++)作为循环骨架,这样即使坐标计算出了差错,循环也会在最多dx+dy+1次后退出,不会让整机死掉。虽然治标不治本,但至少能让故障恢复,给生产环境留一条活路。

第三,开启看门狗,并且把看门狗喂狗放在主循环,而不是放在某个函数内部。这样即使画线函数内部死循环,看门狗超时后系统复位,至少不会让设备永远"装死"。这也是为什么嵌入式设备在野外长期运行时,这类隐蔽Bug往往表现为"偶尔重启"而不是"永久卡死"的原因,看门狗兜了底。

第四,模块边界做参数校验。我的建议是"上层信任、底层不信",底层不管什么情况都拒绝越界写入。这个习惯救过我好几次。

6.3 故障速查表

现象根因对策
画线到固定位置后主流程卡死u8坐标回绕或溢出,循环退出失效坐标、误差、方向全部用int;底层加越界保护
单步调试能过、全速必死调试器干扰时序或掩盖长循环用GPIO翻转和逻辑分析仪先缩小范围
画线后屏幕花点、乱码越界写显存,缓冲区和栈遭破坏DrawPixel内部校验坐标范围
画水平垂直线正常、斜线死机斜线两轴同时推进,回绕概率高重点审查斜线分支的符号与类型

6.4 后续还能怎么扩展

Bresenham这类代价极低的整数算法,不只是画直线。稍加改造就能画圆、画椭圆,甚至画贝塞尔曲线的像素近似:

  • 画圆:可以用中点圆算法,同样只用整数加减法和比较。

  • 画椭圆:在画圆的基础上,把误差项按椭圆长短轴比例缩放。

  • 抗锯齿:如果想要更平滑的线条,可以用Wu's Algorithm,它把线段的端点像素按覆盖率混合灰度,代价是更多计算量。但对于OLED这种单色屏,抗锯齿意义不大。

我现在的显示模块已经把Bresenham线、矩形框、圆框、填充矩形、心电图这些都通过整数算法实现了,整套图形代码在无操作系统的小单片机上运行很流畅。后续如果再遇到类似的"一跑某功能就死机"的问题,我会第一时间先检查数据类型和循环退出条件,再考虑是不是算法本身的问题。

最后说一点个人感受。这次排查花了不少时间,很大一部分原因是过于相信"教科书代码"。Bresenham算法是1965年提出的,无数人用过,我潜意识里觉得它不可能出问题,所以在排查时总往硬件时序、OLED驱动上找原因,而忽略了变量类型这个最基础的环境。后来想想,算法没错,代码也没错,错的是把它塞进一个和它预期不符的类型系统里。花在单步调试上的几个小时,远不如一开始就打开全部编译警告、认真审视数据类型来得划算。如果你也遇到类似的死机问题,不妨先看一眼画线函数的变量声明,说不定答案就在那里。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询