排查:Bresenham算法邊界條件與嵌入式修復(fù)實踐)
屏幕剛亮起來系統(tǒng)就卡死。這是我第一次遇到 OLED 一調(diào)用畫線函數(shù)就整機假死的情況。一塊 128x64 分辨率的 OLED 顯示模塊接在常見的 Cortex-M 系列 MCU 上I2C 接口底層的畫點函數(shù)單獨調(diào)沒有任何問題顯示字符數(shù)字也正常但只要調(diào)用了畫線功能肉眼可見的就是程序掛在原地不動了。剛開始沒有人往算法頭上想I2C 時序不對、復(fù)位引腳沖突、供電毛刺、中斷嵌套卡死這些才是優(yōu)先懷疑對象。邏輯分析儀抓了半天波形也沒看出異常最后接上調(diào)試器單步執(zhí)行才逮住真兇Bresenham 畫線函數(shù)內(nèi)部藏了一個死循環(huán)。這篇文章把這顆雷完整拆開從現(xiàn)象定位、算法原理、死循環(huán)觸發(fā)條件到最終的修復(fù)代碼和驗證方案全部講清楚。適合正在調(diào) OLED 圖形庫的朋友、寫過畫線函數(shù)但只測過正向直線的同學(xué)以及所有不想再被 while 循環(huán)卡到頭皮發(fā)麻的嵌入式開發(fā)者。1. 問題現(xiàn)場一次畫線引發(fā)的整機“假死”1.1 現(xiàn)象描述與初查路徑故障的現(xiàn)象很規(guī)律程序啟動后 OLED 正常初始化顯示開機頁、清屏、畫字符都沒問題但只要執(zhí)行到“畫線段”的調(diào)用整機立刻失去響應(yīng)。因為當(dāng)時測試程序沒有開看門狗所以 MCU 不是復(fù)位重啟而是硬生生掛死在某個地方。外部表現(xiàn)就是屏幕停在那不動串口調(diào)試助手沒有任何新輸出按鍵也都沒反應(yīng)。初查階段我把懷疑點按概率排序分了幾路。第一路是 I2C 通信擔(dān)心畫線過程中頻繁寫像素把總線搞掛于是單獨在畫線循環(huán)里加延時問題依舊存在。第二路是屏幕驅(qū)動芯片的顯示 RAM 尋址方式有些屏在連續(xù)寫數(shù)據(jù)時要先設(shè)置頁地址和列地址如果越界寫到了不存在的頁地址驅(qū)動芯片可能返回異常 ACK 甚至拖死總線但實測單獨寫像素函數(shù)循環(huán)幾千次都沒問題。第三路才是偶然間想到的——拿調(diào)試器暫停程序看 PC 指針停在哪里結(jié)果發(fā)現(xiàn)主循環(huán)被卡在畫線函數(shù)里一個 while 結(jié)構(gòu)上怎么都繞不出來。這里想先多說一句遇到“一運行就死機”的問題不要一上來就重新焊接、換屏、換電機而是花五分鐘用調(diào)試器暫停一下看程序到底停在哪個地址。硬件問題會分散在各種中斷或者外設(shè)初始化流程里軟件死循環(huán)則通常指向某幾個文件里的特定行號區(qū)別非常明顯。1.2 三條直觀懷疑怎么被排除的我用一張小表記錄當(dāng)時的排查過程很多問題排查到最后其實依賴這種“排除法可復(fù)現(xiàn)”的組合。懷疑方向排查方式結(jié)論I2C 總線異常邏輯分析儀抓取畫線前后的總線波形檢查 ACK/NACK波形正常無總線鎖死OLED 驅(qū)動尋址越界單獨連續(xù)調(diào)用畫點函數(shù)寫入邊界坐標(biāo)單點寫入正常未觸發(fā)異常系統(tǒng)時鐘/中斷配置不當(dāng)關(guān)閉所有中斷后單獨調(diào)用畫線函數(shù)仍然死機排除中斷因素畫線算法內(nèi)部死循環(huán)調(diào)試器暫停后查看 PC 指針與調(diào)用棧定位到 Bresenham 函數(shù)的 while 循環(huán)排除了前三項之后問題范圍已經(jīng)縮得很小要么是畫線算法本身在某種參數(shù)下死循環(huán)要么是算法里疊加了超出屏幕范圍的坐標(biāo)寫入把內(nèi)存寫壞。接下來要做的事情就是老老實實把 Bresenham 算法連同它的邊界條件一起重新讀一遍。2. Bresenham 算法的底牌它為什么快又為什么脆2.1 從直線方程到整數(shù)決策一個具體推演要在 OLED 上畫一條直線最直接的做法是從起點到終點按 x 方向逐點前進每個 x 用直線方程算一個浮點 y。y y1 k * (x - x1)其中 k 是斜率。這個思路本身沒錯但在沒有硬件浮點單元的 MCU 上每畫一個點都要做浮點乘法一幀畫面幾百條線就是幾千次浮點運算幀率直接被拖垮。Bresenham 的核心思想是把浮點斜率換成整數(shù)誤差項每次只做加法、減法和比較因此特別適合嵌入式場景。我以一個具體直線為例從 (1,1) 到 (6,4)。這時候 dx 5dy 3。經(jīng)典寫法會維護一個決策參數(shù) p初始值 pInit 2 * dy - dx 2 * 3 - 5 1。步數(shù)當(dāng)前坐標(biāo)當(dāng)前 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 都是正數(shù)而且曲線的整體走向是第一象限、x 方向為主導(dǎo)。2.2 一個“能跑但經(jīng)不起考驗”的參考實現(xiàn)網(wǎng)上有很多簡化版畫線函數(shù)大多數(shù)長這樣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; } } }這個函數(shù)只在“x2 大于 x1、y2 大于 y1、dx 大于 dy”的條件下正確。也就是說它只能畫從左下到右上、且比較平緩的直線。第一象限正斜率的一部分線段湊合能畫其余七個方向基本全錯某些方向直接死循環(huán)。真正通用的畫線函數(shù)必須同時處理坐標(biāo)的四種符號組合和斜率大于 1 兩種情況這正好是很多 OLED 圖形庫移植時翻車最集中的地方。2.3 脆弱點逐個點名方向、符號、主循環(huán)軸對照上面這段代碼可以找出三個致命設(shè)計缺陷。第一沒有處理 dx、dy 的符號。當(dāng) x2 x1 時dx 是負(fù)數(shù)pInit 的計算結(jié)果就完全偏離了正常范圍。比如從 (100,20) 畫到 (10,80)dx 10 - 100 -90dy 80 - 20 60p 2 * 60 - (-90) 210這個參數(shù)已經(jīng)失去決策意義。第二循環(huán)體里 x 永遠(yuǎn)做自增。如果終點在 x 的負(fù)方向x 每次加 1 只會離目標(biāo)越來越遠(yuǎn)。配合 while (x ! x2) 這種寫法x 從 100 出發(fā)目標(biāo)是 10x 朝著 101、102、103 一路跑下去條件永遠(yuǎn)不成立這就是死循環(huán)的直接來源。如果循環(huán)條件是 while (x x2)反向時條件一開始就是假循環(huán)體根本不執(zhí)行屏幕畫不出線倒是不會死機。第三主循環(huán)軸固定為 x 軸。當(dāng) dy 大于 dx 時直線更“陡”每一列 x 對應(yīng)多行 y。如果仍然以 x 為主循環(huán)軸相鄰兩個點之間 y 方向會跳變畫出來的線會出現(xiàn)明顯斷點俗稱“虛線感”。正確的做法是讓循環(huán)軸自動選擇 dx 和 dy 中較大的一方。還有一個很多人忽視的隱藏問題決策參數(shù) p、dx、dy 的類型選擇。如果用了 unsigned int 或者 uint16_tdx x2 - x1 在反向時會發(fā)生無符號回繞得到一個巨大正數(shù)后面所有判斷全部錯亂。這個問題單獨拎出來說因為它在單步調(diào)試時非常迷惑人。3. 死循環(huán)的完整推演從(100,20)到(10,80)3.1 逐行跟蹤循環(huán)變量的變化我用一個具體調(diào)用把死循環(huán)的過程完整推演一遍。假設(shè)代碼是while (x ! x2) { OLED_DrawPixel(x, y, 1); x; if (p 0) { p 2 * dy; } else { p 2 * dy - 2 * dx; y; } }入?yún)⑹?x1 100y1 20x2 10y2 80。按錯誤實現(xiàn)計算dx 10 - 100 -90dy 80 - 20 60pInit 2 * 60 - (-90) 210x 100y 20。進入循環(huán)第一次迭代畫點 (100,20)然后 x 變成 101。因為 p 是正數(shù)走 else 分支p 更新為 210 120 - 2 * (-90) 210 120 180 510y 變成 21。第二次迭代畫點 (101,21)x 變成 102p 繼續(xù)增大。觀察這個趨勢就明白了x 每次加 1而終點 x2 是 10兩者之差從 90 變成 91、92、93……永遠(yuǎn)不可能相等。y 倒是朝終點方向走但循環(huán)的退出條件只檢查 x 不檢查 y所以算法根本不知道 y 已經(jīng)到位。程序就這樣在 while 里無限畫點看似瘋狂工作實則原地空轉(zhuǎn)。如果換成 while (x x2) 這種條件反向畫線時 x 100 大于 10循環(huán)體一次都不執(zhí)行函數(shù)直接返回。這倒是不會死機但調(diào)用方期待看到一條從 (100,20) 到 (10,80) 的線實際什么都沒畫出來。兩種寫法都不對只是后果不同。3.2 另一種隱蔽炸彈無符號整型下溢死循環(huán)還有一種非常隱蔽的觸發(fā)方式和算法結(jié)構(gòu)無關(guān)純粹是 C 語言類型轉(zhuǎn)換在搗亂。假設(shè)把 dx、dy 聲明成 uint16_t那么執(zhí)行 uint16_t dx x2 - x1 時如果 x2 - x1 是負(fù)數(shù)結(jié)果會變成一個巨大的無符號數(shù)。舉例來說x1 10x2 100正常應(yīng)該是正 90。反過來 x1 100x2 10得到 -90按 uint16_t 存儲就是 65536 - 90 65446。后面的 pInit 2 * dy - dx 會基于這個 65446 參與計算得到的決策參數(shù)同樣是天文數(shù)字。p 的正負(fù)判斷因此完全失去意義循環(huán)內(nèi)部的 x、y 增長方向和終點完全脫節(jié)。單步調(diào)試時你會看到變量值在 60000 上下跳動第一反應(yīng)往往以為是數(shù)據(jù)長度不夠很少意識到它其實代表一個負(fù)數(shù)。這種問題在“偶爾死機、不是必然死機”的故障里更常見。因為只有當(dāng)坐標(biāo)差值恰好為負(fù)時才會觸發(fā)而測試者可能只在圖上畫了幾個固定方向的線段恰好避開了反向場景。等后面加菜單、畫波形、畫任意方向箭頭時死機就隨機冒出來了。3.3 順帶排查 OLED 畫點坐標(biāo)越界定位到死循環(huán)之后我順手做了一次代碼走查又發(fā)現(xiàn)一個藏在畫點函數(shù)里的隱患。OLED 顯示模塊的坐標(biāo)范圍有限128x64 意味著 x 是 0 到 127y 是 0 到 63。錯誤版的畫線函數(shù)在反向場景下會把 x 推到 128、129、130 甚至更大直接調(diào)用底層畫點函數(shù)。如果底層畫點函數(shù)沒有做邊界裁剪這些越界坐標(biāo)會一路寫進顯示 RAM 之外的內(nèi)存區(qū)域。更麻煩的是很多 OLED 驅(qū)動庫會把顯示緩沖區(qū)定義成數(shù)組數(shù)組越界寫入可能覆蓋相鄰的全局變量包括循環(huán)變量、狀態(tài)標(biāo)志甚至函數(shù)返回地址。一旦循環(huán)變量被污染即使你修復(fù)了畫線算法本身程序也可能在別的地方出現(xiàn)莫名字段錯誤或死機。我的建議是底層畫點函數(shù)永遠(yuǎn)保留邊界判斷最差也要做坐標(biāo)裁剪。這個習(xí)慣能讓上層圖形函數(shù)在調(diào)試期少背負(fù)一類“幽靈 bug”。就算性能緊張畫點函數(shù)里兩個比較指令的成本也遠(yuǎn)低于一次意外內(nèi)存覆蓋造成的排查時間損失。4. 修復(fù)與驗證一套能覆蓋全象限的畫線函數(shù)4.1 修復(fù)后的完整代碼正確的 Bresenham 畫線函數(shù)有很多種等價寫法我推薦下面這個版本它同時處理了方向符號、斜率大于 1、主循環(huán)軸自動切換三個問題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; } } }幾個關(guān)鍵點說明一下。dx 和 dy 都取絕對值這樣后續(xù)判斷不用關(guān)心坐標(biāo)差值的正負(fù)。sx 和 sy 記錄真實的前進方向取 1 或 -1。err 初始值是 dx - dy它像一根“蹺蹺板”哪個方向落后就朝哪個方向補一步。e2 是臨時變量用來同時判斷 x 和 y 兩個方向是否都需要步進。這個實現(xiàn)沒有顯式交換 x、y 軸但算法本身天然支持斜率大于 1 的情況當(dāng) dy 大于 dx 時err 初始值為負(fù)e2 dx 的條件會更頻繁成立y 方向自然變成主導(dǎo)方向。這也是它比一開始那個樸素版全面得多的原因。循環(huán)為什么用 while (1) 配合 break而不是 while (x1 ! x2 || y1 ! y2)因為每輪迭代后 x1 和 y1 可能同時變化也可能只變化其中一個用 break 在“當(dāng)前點剛好是終點”時立即退出邏輯更直觀也不容易出現(xiàn)跨越終點的情況。坐標(biāo)值不超過屏幕范圍時最多執(zhí)行與長軸像素數(shù)相同次數(shù)的迭代性能完全夠用。4.2 六個方向八組用例的驗證矩陣修復(fù)完代碼后我沒有直接燒錄到屏幕上而是先在 PC 上做了一次軟件模擬。做法很簡單把 OLED_DrawPixel 替換成一個向控制臺打印坐標(biāo)點的函數(shù)然后用一組覆蓋全象限的用例跑一遍。編號測試直線覆蓋方向預(yù)期結(jié)果1(0,0) - (127,63)左下到右上dx dy完整連續(xù)直線2(127,63) - (0,0)右上到左下反向完整連續(xù)直線3(0,63) - (127,0)左上到右下反向斜率完整連續(xù)直線4(127,0) - (0,63)右下到左上反向斜率完整連續(xù)直線5(0,30) - (127,30)水平線每個 x 對應(yīng)唯一 y6(64,0) - (64,63)垂直線每個 y 對應(yīng)唯一 x7(0,0) - (63,63)45 度對角線無斷點、無重復(fù)點8(10,10) - (120,12)接近水平的小斜率線點和真實直線偏差不超過 1第一輪模擬就跑出了兩條輕微變形的斜線原因是模擬程序里畫點坐標(biāo)我用了 x、y 直接打印沒有做坐標(biāo)換行對齊以為是算法問題調(diào)整打印格式后所有用例全部通過。這也提醒了我算法驗證階段一定要把可視化和算法本身剝離開別把顯示層的問題怪到算法頭上。4.3 上板驗證時想加的兩個安全措施模擬測試通過之后我又在板子上補了兩道保險。第一道是給畫線循環(huán)加一個看門狗喂狗機制。如果有意外情況導(dǎo)致函數(shù)長時間不返回至少系統(tǒng)能觸發(fā)復(fù)位而不是像第一次那樣徹底假死。第二道是在畫線函數(shù)入口和出口各放一條串口日志記錄入?yún)⒆鴺?biāo)和循環(huán)結(jié)束后的實際坐標(biāo)。這樣跑自動化測試時如果某條隨機線段讓函數(shù)“陷進去”日志文件里能看到最后一次正常調(diào)用的坐標(biāo)組合復(fù)現(xiàn)概率會高很多。我還順手寫了一段隨機線段測試代碼每輪隨機生成 1000 組坐標(biāo)坐標(biāo)范圍限制在 0 到 127、0 到 63 之間依次調(diào)用畫線函數(shù)并在每次調(diào)用后檢查一個全局計數(shù)器是否增長。這個計數(shù)器放在主循環(huán)里一旦畫線卡住串口調(diào)試助手會立刻發(fā)現(xiàn)計數(shù)不再增加。連續(xù)跑了幾萬條隨機線段沒有再出現(xiàn)死循環(huán)。5. 把死循環(huán)按死在搖籃里避坑清單與排查手段5.1 嵌入式循環(huán)里最常見的七種死法畫線函數(shù)的死循環(huán)只是眾多死法之一。這些年調(diào)試過不少“程序跑飛”的問題總結(jié)出七種最常見的循環(huán)結(jié)構(gòu)誤用逐一列出來供排查時對號入座。死法典型代碼特征觸發(fā)條件循環(huán)變量方向與判定條件相反while (x ! x2) 但 x反向遍歷時無符號數(shù)參與減法產(chǎn)生回繞uint16_t dx x2 - x1x2 x1 時for 循環(huán)體內(nèi)修改循環(huán)變量循環(huán)體內(nèi)調(diào)用函數(shù)間接改 i函數(shù)副作用浮點數(shù)判等while (f ! 0.0f)浮點截斷導(dǎo)致恒不等中斷與主循環(huán)共享變量無保護主循環(huán)等待標(biāo)志位清 0中斷修改標(biāo)志時機不確定越界寫壞相鄰循環(huán)變量數(shù)組寫越界覆蓋 while 條件特定輸入數(shù)據(jù)時優(yōu)化器對循環(huán)變量過度優(yōu)化volatile 缺失看似無限循環(huán)實際被優(yōu)化掉大多數(shù)情況下只要把循環(huán)條件改成可收斂的等價形式或者明確變量的符號屬性問題都能直接消除。比如 while (x ! x2) 改成 while (x x2) 或者引入方向符號之后用 while (x ! x2) 才安全。5.2 定位死循環(huán)的三板斧條件斷點、串口日志、看門狗真遇到死循環(huán)我的排查順序有講究。首選是調(diào)試器的條件斷點在循環(huán)入口處設(shè)置斷點條件寫成 x 200這樣變量接近終點時斷點才觸發(fā)不用單步幾千次。斷點觸發(fā)后查看 x、y、err 三個變量的變化趨勢比盲猜高效太多。如果硬件調(diào)試器不方便就用串口日志。在循環(huán)內(nèi)部每 100 次迭代打印一次當(dāng)前坐標(biāo)和 err 值跑幾秒鐘之后看日志規(guī)律是收斂還是發(fā)散一目了然。等到定位完問題再把日志刪掉或者變成條件編譯別影響最終性能??撮T狗是我建議常開的調(diào)試伴侶。不是靠它解決問題而是靠它把“整機死機”變成“反復(fù)復(fù)位”配合復(fù)位原因寄存器可以統(tǒng)計死機頻率。有時候問題只在特定路徑出現(xiàn)沒有看門狗你只能手動按復(fù)位鍵次數(shù)多了很容易煩躁有看門狗至少能自動跑測試。5.3 一個值得養(yǎng)成的習(xí)慣先畫坐標(biāo)紙再碼代碼最后分享一個我后來一直保持的習(xí)慣。任何圖形算法要寫到 OLED 或 LCD 之前先拿一張方格紙把坐標(biāo)系畫出來左上角是 (0,0)x 正方向朝右y 正方向朝下。然后把你要支持的八個方向直線全部標(biāo)一遍再對照代碼判斷每個方向下 dx、dy、sx、sy、err 各自是什么值。這一步看起來花了幾分鐘實際上能擋掉九成以上的邊界 bug。那次 OLED 畫線死機事件之后我再也沒有在圖形函數(shù)的邊界條件上翻過車。后來寫菜單圖標(biāo)、畫波形、做簡易指針表盤都是先把方向、符號、邊界條件寫在注釋里再開始填代碼。調(diào)試器暫停的那一瞬間我看到程序卡在畫線函數(shù)的 while 循環(huán)里心里其實松了一口氣。因為問題找到就好辦最怕的是查了兩天找不到原因?,F(xiàn)在回頭想這個坑本質(zhì)上不是算法太深奧而是寫代碼時只考慮了最常見的正向場景把所有其他方向都當(dāng)成了“用戶不會用到”的情況。嵌入式開發(fā)里哪有那么多“不會用到”屏幕一旋轉(zhuǎn)、UI 一換風(fēng)格、坐標(biāo)一反向隱藏的邊角條件立刻集體出動。好在 Bresenham 算法本身并不復(fù)雜理解它如何用誤差項在 x、y 兩個方向之間做取舍這個坑就永遠(yuǎn)不再是坑。