MultiPWMs 系列大一統:ESP8266 軟體 PWM 函式庫十篇全紀錄與應用教訓
這是 MultiPWMs 系列(以 MultiTimers 為底層、用 4 個硬體計時器交錯逼近出多路軟體 PWM 的函式庫)從主篇 ver.0.1 到最終版 ver.0.9,加上一篇應用文(直流馬達驅動板),共 10 篇文章讀完後的技術總結。系列源起於前導實驗文〈範例-製作 PWM/使用 MultiTimers ver.0.3〉,正式主篇於 2020-12-03 至 2021-04-03 橫跨約 4 個月完成 9 次改版。與 MultiTimers 系列一樣,這裡把逐版核對出的問題編號追蹤到底:有的從第一版活到最後一版都沒人碰過,有的被扎實修好,有的甚至在正式發表的應用文中已被實際觸發過。
問題清單總表
| 編號 | 問題 | 最終狀態 |
|---|---|---|
| #1 | 繼承 MultiTimers ForceHaltForSync() 的 m/n 未初始化就使用 | 未修正,全系列 9 版沿用到底 |
| #2 | 建構期 wellAdd()/node_add() 動態新增 PWM 無同步保護 | 已修正(ver.0.6 起包入全域暫停) |
| #5 | 記憶體配置失敗時物件狀態不一致(uid 先於 owner 賦值) | 已修正(ver.0.2) |
| #6 | setDC() 的 0%/100% 特例:忽略 is_inverted、比較基準單位錯誤 | 已修正(ver.0.2) |
| #7a | Sync() 的 same_level_at_head 參數是 no-op | 已修正(ver.0.4,重新設計為 setStallLevel()) |
| #7b | 同步後未即時搬動節點所屬的物理串列 | 已修正(ver.0.5,改為立即 node_pwm_move_to) |
| #7c | 相位搬移後 counter 重算算式邊界誤判(作者自白的相位飄移之謎) | 已修正(ver.0.6) |
| #8 | 解構子未把節點從鏈結串列摘除(use-after-free) | 已修正(ver.0.3) |
| #9 | isr 尚未開始跑之前就先 sync,結果不正確 | 未修正,官方文件化 workaround 定案(ver.0.8 起) |
| #10 | detachGpio() 宣告 bool 但缺少 return | 已修正(ver.0.9),但修正前已被應用文(id=2304)實際呼叫觸發 |
#1:從未被碰過的繼承缺陷
MultiPWMs 的 configTimer() 一開始就呼叫 timers[1].ForceHaltForSync(timers[0], 48) 等三次,把四個底層 cHwTimer 依序偏移 48us 疊出等效 50us 精度的虛擬時基。ForceHaltForSync() 內部那個「unsigned m, n; 只在迴圈內條件性賦值、找不到就是未初始化值當陣列索引用」的 bug,在姊妹篇〈MultiTimers 系列大一統〉裡已經證實六版、含最終版都沒修。這裡的重點是:MultiPWMs 系列全程 9 個版本,這行呼叫逐字沿用到底,沒有一版試圖繞開或修補——因為根因在更底層的函式庫,MultiPWMs 這一層没有單獨處理的空間,是名副其實「上游沒修、下游全部一起揹」的案例。好消息是 MultiTimers ver.0.5 草稿已經把這個修正做完(三行等級:m/n 初始化為 0 並事後檢查),等硬體驗證平台就緒、正式發布後,MultiPWMs 若跟進升級底層依賴,這個問題就會一併解決,不需要在 MultiPWMs 這一層另外動手。
#7 系列:一個同步問題,三次改版才真正解決
Sync() 相關的瑕疵是這個系列裡故事線最完整的一條,橫跨 ver.0.2 到 ver.0.6 共五個版本才收尾:
- ver.0.2 新增
Sync()就帶著兩個問題:參數same_level_at_head套用邏輯整行被註解掉(作者自己標「hard to impl」),以及同步後只更新邏輯欄位、沒有把節點實際搬到新相位對應的物理串列——後者是結構性問題,作者在 ver.0.3 文前就自己察覺「sync function 比較有不確定性」。 - ver.0.4 用重新設計解決 #7a:拿掉
same_level_at_head參數,改用setStallLevel()強制雙方先收斂到同一個已知電位才計算 offset,程式碼註解裡明確寫下設計推理——這不是隨手補一行,是想清楚背後語意模糊之後的重新設計。 - ver.0.5 修正 #7b:
Sync()/SyncEnd()算完新相位後立即呼叫node_pwm_move_to(),不再等節點下一次自然轉態才被動搬移,且這個搬移動作包在全域 ISR 暫停視窗內,不會跟中斷競爭。 - ver.0.5 同時暴露出 #7c:物理搬移變成「立即生效」之後,原本被「延遲搬移+下一次自然轉態」意外掩蓋掉的 counter 相位補償算式邊界誤判,馬上就在示波器上現形——作者本人在 ver.0.5 文前寫下這系列最沒把握的一句話:「若我說沒問題是示波器的問題,先打自己兩耳光」。
- ver.0.6 修正 #7c,且修法比原先推測更精確:真正的根因不是「a/b/c 三個相對位置變數的比較式寫錯」,而是相位搬移之後,依賴該相位的計數值(
reload_high/reload_low)忘了連動重新展開。改成先取出目前生效值、依新舊相位是否跨越邊界決定是否遞減、展開回原始計數尺度再重新拆解——這個修法本身在邏輯上是自洽的,讀完程式碼可以直接驗證正確,不需要再靠窮舉測試佐證。
這條故事線值得記一筆的地方,是它示範了「修好一個問題可能先讓另一個問題現形」的典型模式——#7b 修好之前,#7c 這個更深層的邊界誤判其實一直都在,只是被延遲搬移的緩衝效果順便沖掉了觀察得到的症狀。作者花了整整一個版本(ver.0.5)陷在「不確定是不是自己儀器有問題」的困惑裡,根源其實是自己前一版的另一個修正動作,讓原本沉默的 bug 第一次有機會被看見。
#9:作者自己也没抓到根因、最終選擇文件化的限制
ver.0.7 新增「isr 尚未開始跑之前就先 sync」這個用法,作者自曝結果不正確,原文用詞是「想破頭也想不到原因」。靜態核對 configPwm()/SyncStart()/SyncEnd() 找不出明顯邏輯漏洞,懷疑跟 wellAdd() 把新物件掛入佇列時的初始位置、與 SyncEnd() 用「當前 isr 位置」校正 counter 那段邊界判斷之間的交互有關——但這類推論需要動態追蹤 ISR 時序才能證實,非靜態讀碼所能斷定。到了 ver.0.8,作者不再嘗試除錯,改為在 known bugs 明確記錄:「sync 前先 Resume() 過即無虞」,並在 ver.0.9 把這條 workaround 連同另外兩條相關限制,寫成一段完整、可直接複製貼上的範例程式。這是系列裡「已知限制優於未知風險」的一次務實選擇:與其繼續追一個抓不到根因的時序 bug,不如把安全的使用方式講清楚、附上範例,讓使用者不會踩到雷。
#10:一個從理論缺陷變成已發表範例程式的靜態缺陷
ver.0.8 新增的 detachGpio():
bool detachGpio(){setGpio(MULTIPWMS_GPIO_PARKING);};
宣告回傳 bool,函式本體卻沒有 return,跌出結尾屬未定義行為——這是系列裡少數不需要動態追蹤、單靠讀程式碼就能百分之百確認的缺陷。ver.0.8 發表當下範例程式並未呼叫它,本可視為「暫未觸發的理論風險」;但後續核對應用文(id=2304,直流馬達驅動板)才發現,loop_cmultipwms() 的四個狀態分支(mstart/mforward/mreward/mstop)其實各呼叫了一次 LEDB.detachGpio();——也就是說,這個缺陷在 ver.0.9 真正修正(補上 return)之前,已經存在於一篇公開發表、宣稱可正常運作的應用文範例程式裡,只是呼叫端沒有讀回傳值,加上編譯器行為恰好沒有暴露出可觀察的異常,才沒有被回報成 bug。ver.0.9 的修法很乾淨:bool detachGpio(int cur_level=-1){return setGpio(MULTIPWMS_GPIO_PARKING, cur_level);};,順便讓 setGpio() 擴充了可指定目前/新腳位準位的參數,兩個 changelog 項目一起解決。
應用文的真實教訓:程式邏輯沒問題,硬體特性才是元兇
id=2304 這篇應用文(車用排氣管閥門直流馬達驅動,H 橋+level shifter+ADC 回饋判斷負載)的價值不在於程式碼本身的缺陷,而在於作者事後(2021-02-01)補充自陳「本案例是失敗的」:ESP8266 上電瞬間,多顆 GPIO(含 GPIO15,即使外接 pull-low 電阻)仍有約 5us 機率短暫拉高,實際燒毀了一張驅動板。最終解法不是修程式,而是實測找出唯二「上電保證不會拉高」的 GPIO4/GPIO5,把 PWM 輸出改接到這兩隻腳位迴避。這個教訓也回頭反映在函式庫本身——ver.0.9 把預設的 parking gpio 從 GPIO0 改成 GPIO3(UART RX0),正是把這篇應用文用一張燒毀的驅動板換來的教訓,吸收進函式庫的預設安全值。程式邏輯層面的 code review 再仔細,也擋不住這種上電瞬間的硬體特性風險——這是這篇應用文對整個系列最重要的補充。
結語
9 個版本、4 個月,MultiPWMs 系列比 MultiTimers 更明顯地呈現出「修一個問題、現形另一個問題」的疊代真實樣貌(#7b→#7c 那條線最典型),也留下一個從頭到尾沒人碰過、繼承自底層函式庫的地雷(#1),以及一個從「理論上未觸發」升級成「已被公開範例實際呼叫」的靜態缺陷(#10)——後者提醒:「範例程式沒有回報異常」不等於「範例程式沒有 bug」,只是缺陷剛好沒有暴露出可觀察的症狀。而 #9 的收尾方式(放棄追根因、改為文件化 workaround)與應用文的燒板教訓,共同說明了一件事:不是所有問題都值得、或都能夠靠程式碼修正解決,有時候誠實記錄限制、給出可複製貼上的安全用法,比死磕一個抓不到根因的時序 bug 更務實。
Claude-code 全系列 code review/2026-09-08,依 Ken 指示於系列全數讀完後撰寫的技術總結(大一統文章)。本文分析全部基於逐字閱讀原始碼與 changelog 進行靜態核對,除少數作者本人已用示波器/實機驗證過的項目外(如 #7c 的修正邏輯已可從程式碼自洽性直接驗證),#7b/#9 等涉及 ISR 時序的部分未經動態追蹤或實機重現,屬未實機驗證的推論性結論,僅供參考。