MultiTimers 系列大一統:ESP8266 軟體計時器函式庫六版全紀錄與修正建議
這是 MultiTimers 系列(ESP8266 HW Timer1 硬體計時器多工函式庫)從主篇到最終版 ver.0.4 共七篇文章讀完後的技術總結。系列源起於前導文〈Heap operation〉的二元堆積實作,主篇提出用單一硬體 timer1(FRC1)搭配 min-heap 多工出多個軟體計時器的設計,並在文中親自點出「this func might fault, if so, mem-fault or wdt-reset」——作者對風險是有自覺的。以下逐一追蹤主篇分析出的 6 個 C/C++ 層級問題,在 ver.0.1 到 ver.0.4(2020-11-10 至 2021-04-03,橫跨約 5 個月)六次改版中各自的命運:有的被扎實修正、有的原地踏步、有的甚至先修好又退回原狀。
原始六個問題(主篇 id=2012 分析)
- #1 Sync() 的 m、n 未保證賦值就使用——
unsigned m, n, z;只在迴圈內條件性賦值,若目標計時器當下不在 heap 陣列中,m/n 會是未初始化值就被拿去當陣列索引。 - #2 直接覆寫 heap 節點、不重新排序——破壞 min-heap 的堆積性質,之後 main_isr() 取堆頂可能不再是真正時間最近的計時器。
- #3 main_isr()(中斷內容)與 Sync()(主程式內容)對共用陣列缺乏互斥——SYNC_WAITING 預設關閉,且範例主程式的 Sync() 呼叫本身是註解掉的,代表發文當下這支 API 沒有實機測試過。
- #4 arm() 同樣操作共用陣列,但完全沒有中斷保護的自覺——作者連提醒都沒寫。
- #5 char loc 用 -1 表示未初始化,依賴 char 是有號型別——C/C++ 標準未規定 char 一定有號,可移植性地雷。
- #6 「可支援 2^9-1 顆計時器」的宣稱與 ID_MASK_BIT=4(上限 15)的實作不一致——且作者自己註明「筆者未親試」。
六版逐一核對總表
| 版本 | #1 m/n 未賦值 | #2 heap 覆寫不重排 | #3 main_isr/Sync 互斥 | #4 arm() 保護 | #5 char loc | #6 計時器數量宣稱 |
|---|---|---|---|---|---|---|
| ver.0.1(id=2012 主篇) | — | — | SYNC_WAITING 關閉、Sync() 未實測 | — | 有號 char | 宣稱與實作不一致 |
| ver.0.2(id=2112) | 未修 | 未修 | 未變 | 未變 | 未變 | 矛盾原封保留 |
| ver.0.3(id=2127 Lib 化) | 未修 | 未修 | 未變 | 未變 | 未變 | 設計收斂:TIMER_NUM 15→6, ID_MASK_BIT 4→3,並補上取捨說明 |
| ver.0.4(id=2134 ver.0.1) | 未修,新函式 ForceHaltForSync() 複製了同一個 bug | 未修 | ForceHaltForSync() 首次用 emergent_shutdown/safe_resume 做真正硬體互斥 | 未變 | 未變 | 未變(沿用 6) |
| ver.0.5(id=2141 ver.0.2) | 未修——引言聲稱「修正 sync bugs」但實際只涵蓋下一欄 | 未修 | Sync() 新增時機安全窗+重試(機率性防護,非根治) | 已修:arm() 用 emergent_shutdown/safe_resume 包住 | 未變 | 未變 |
| ver.0.6(id=2158 ver.0.3) | 未修 | 2026-09-06 更正:呼叫確實到位,但函式本身邏輯只對「新值變大」方向正確,見下方新增段落 | 未變 | 沿用已修 | 作者自陳已知情,承諾 ver.0.4 修 | 未變 |
| ver.0.4 最終版(id=2455) | 仍未修——確認到最終發行版仍在 | 2026-09-06 更正:其實仍未真正修好,見下方新增段落 | 沿用(Sync 機率性/ForceHaltForSync 硬體互斥並存) | 沿用已修 | 已修:loc 型別改為 int | 迴歸:TIMER_NUM 改回 15、ID_MASK_BIT 改回 4,註解改成「recommended」建議值而非強制 |
仍未解決:#1 m/n 未初始化,一路帶到最終版
這是整個系列六次改版裡,唯一一個從主篇到最終版完全沒有被觸碰過的問題,甚至在 ver.0.4(id=2134)新增 ForceHaltForSync() 時被複製貼上了一份。最終版(id=2455)Esp8266HwSwTimers.h 的實際內容:
bool Sync(const cHwTimer &ref, unsigned offset_delay_us){
if (loc>0 && ref.loc>0){
unsigned i, j, k;
k=US_TO_CNT(offset_delay_us)+1;
j=10;
do {
if ((i=timer_obj.current[0]) && (OP_HEAP_TOP_CNT>INTRUDE_TIME_CNT)){
unsigned m, n, z; // <-- 未初始化
while (i){
z=OP_GET_ID(timer_obj.current[i]);
if (z==ref.loc) m=i; // 只有找到才賦值
else if (z==loc) n=i; // 只有找到才賦值
--i;
}
if ((z=OP_GET_CNT(timer_obj.current[m])+k)<=UP_BOUND_CNT){ // m 可能未賦值
min_heap_deterioration(OP_MERGE_CNTID(z, loc), n, timer_obj.current); // n 可能未賦值
return true;
}
...
}
...
} while (--j);
}
return false;
};
觸發條件並不刁鑽:只要 ref(要同步的目標)或呼叫者自己 this 當下剛好不在 heap 陣列中——例如該計時器已經 Stop()、或恰好在 main_isr() 這一輪被服務掉並移出陣列——迴圈跑完 m 或 n 就會是未初始化的堆疊值,緊接著 timer_obj.current[m](讀)與 current[n]=...(寫)就是用未定義值當陣列索引存取,寫入位置完全不可預期。ForceHaltForSync() 因為額外用 emergent_shutdown()/safe_resume() 暫停了硬體計數器,至少排除了「與 main_isr 同時存取」這個競爭來源,但陣列查找本身邏輯沒變,一樣可能撲空。
修法並不複雜——把 m、n 初始化為 0(heap 陣列索引從 1 起算,0 保證不是合法位置),迴圈結束後檢查兩者是否都被真正設定過即可:
bool Sync(const cHwTimer &ref, unsigned offset_delay_us){
if (loc>0 && ref.loc>0){
unsigned i, j, k;
k=US_TO_CNT(offset_delay_us)+1;
j=10;
do {
if ((i=timer_obj.current[0]) && (OP_HEAP_TOP_CNT>INTRUDE_TIME_CNT)){
unsigned m=0, n=0, z;
while (i){
z=OP_GET_ID(timer_obj.current[i]);
if (z==ref.loc) m=i;
else if (z==loc) n=i;
--i;
}
if (m && n && (z=OP_GET_CNT(timer_obj.current[m])+k)<=UP_BOUND_CNT){
min_heap_deterioration(OP_MERGE_CNTID(z, loc), n, timer_obj.current);
return true;
}
return false; // 找不到 ref 或 this,視同同步失敗,而非未定義行為
}
delayMicroseconds(20);
} while (--j);
}
return false;
};
ForceHaltForSync() 同一個模式,補上一樣的初始化與 m && n 檢查即可,篇幅不再重複。這是三行等級的修補,六次改版卻始終沒被觸及——很可能是因為它不會在一般測試場景下發作(大部分呼叫當下兩個計時器都還活著),是那種「機率性、難以在手動測試中復現」的 bug,恰恰是最需要靠程式碼審查而非跑分測試抓出來的一類。
2026-09-06 更正:#2 heap 覆寫不重排,其實只修對了一半
上面表格與後段「真正被修好的部分」原本把 #2 判定為 ver.0.3(id=2158)新增 min_heap_deterioration() 後就已解決,沿用到最終版。但那次的核對只停在「有沒有呼叫重排函式」這個層級,沒有驗證函式本身邏輯正不正確。這次把 min_heap_insertion/min_heap_removal/min_heap_deterioration 三支函式逐字抽出寫成跟硬體無關的純 C,寫了隨機化壓力測試(每次隨機挑 insert/removal/deterioration 其中一種操作,每步之後都驗證 min-heap 性質與元素集合是否正確),結果跑不到 20 次就抓到 heap 性質被破壞:
操作前 t = [_, 1331861(id5), 1553037(id13), 1537044(id4)]
DETERIORATION target_id=13, idx_target=2, new_key=474461(比原值 1553037 小很多)
操作後 t = [_, 1331861(id5), 474461(id13), 1537044(id4)]
→ 父節點 t[1]=1331861 大於子節點 t[2]=474461,違反 min-heap 性質
根因:原本的寫法固定「先從 idx_target 往下沉」,這只對「新值變大」的情況正確——這正好對應主篇原文明講的 MultiTimers 案例(「若比原來大(MultiTimers)」),所以在當初的人工測試下從未露餡。但函式結尾往上比對父節點那個迴圈被 j>=idx_target 這個條件卡死,導致新值即使比 idx_target 更上層的祖先都還小,也永遠沒有機會往上冒泡超過 idx_target 自己的位置。換句話說,這支函式其實只處理了「deterioration(惡化/變慢)」這一個方向;遇到「新值反而比較小」的情況就會出包。而在 Sync()/ForceHaltForSync() 的實際用法裡,新值究竟比原值大或小,取決於 ref 跟自己原本在佇列裡的相對位置,兩種方向在正常運作中都真的會發生——這不是一個測不到的邊角案例,是會被實際觸發的 bug。
修法改成教科書寫法:先比較新值跟舊值,新值比較小就只往上浮(不受 idx_target 上限限制)、新值比較大(或相等)就只往下沉,兩個方向各自獨立處理,不再混在一起猜測方向。修好後用 6 組不同亂數種子、每組 200 萬次隨機操作(合計 1200 萬次),heap 性質與元素集合全部驗證通過。這個修正版已經寫進 MultiTimers ver.0.5 草稿(post id 5829,狀態 draft),目前只做過純軟體邏輯驗證,尚待硬體測試平台就緒後實機驗證才會轉為正式發布。
真正被修好的部分,也值得記一筆
- #2 heap 覆寫不重排:ver.0.3(id=2158)新增
min_heap_deterioration(),用正規的 sift-down 找空位+sift-up 回填取代了原本的直接賦值——heap 不變量在同步操作後能正確維持,並沿用到最終版。2026-09-06 更正:呼叫確實取代了直接賦值,但該函式本身邏輯有方向性缺陷,見下方新增段落。 - #4 arm() 缺乏中斷保護:ver.0.4(id=2134)引入
emergent_shutdown()/safe_resume()(清 FRC1_ENABLE_TIMER bit 暫停硬體計數、操作完再恢復),ver.0.5(id=2141)進一步把arm()本身也包進這個保護,是系列中第一個把「主程式碼修改共用陣列」與「isr 觸發」徹底互斥的正確做法,之後沒有再退化。 - #5 char loc:最終版 ver.0.4(id=2455)改成
int loc,changelog 明寫原因是「IRAM_ATTR 資料不可用 bitfield」,順手解決了有號性的可移植性疑慮。
特別值得推廣的手法是 ForceHaltForSync() 的 emergent_shutdown()/safe_resume() 互斥策略——先讀目前 FRC1 計數器值、暫停計數,操作完成後寫回並恢復——這是本系列裡唯一一次真正做到「讓 main_isr 在關鍵區段內物理上不可能被觸發」,而不是靠 SYNC_WAITING 那種 spin-wait flag(只在 isr 最開頭檢查一次,isr 已經開始執行到一半就沒有保護)。可惜這個手法從未被套用回 Sync() 本身——Sync() 走的是「時機安全窗+重試」(等 OP_HEAP_TOP_CNT>INTRUDE_TIME_CNT 才動手,失敗重試最多 10 次),是機率性防護,理論上窗口邊緣仍可能被中斷打斷。系列全程 Sync() 與 ForceHaltForSync() 兩種不同、成熟度不一的保護策略並存到最後一版,沒有統一。
一個迴歸:TIMER_NUM 與 ID_MASK_BIT
ver.0.3(id=2127)曾經把預設值從 TIMER_NUM=15/ID_MASK_BIT=4 收斂成 TIMER_NUM=6/ID_MASK_BIT=3,並在註解說明了取捨脈絡:硬體計數暫存器 23-bit,扣掉留給倒數值的部份最多可留 9 bit 給 id(理論上限 511 顆),作者選擇犧牲上限、只留 3 bit 換取每顆計時器有更寬裕的計數位元。這本來是個乾淨的收斂。但最終版 ver.0.4(id=2455)又改回了 TIMER_NUM=15/ID_MASK_BIT=4,只是把註解從肯定句改成「recommended max 6」「recommended 0x07」的建議語氣——原本主篇點出的「文中宣稱可支援到 2^9-1 顆、但實作只留 4 bit」這個宣稱與實作不一致的問題,等於又原封不動地回來了,只是多了一行「建議別這樣做」的警語。
最終版新增:Timeout() 與 micros() wraparound 的正確示範
ver.0.4 的 changelog 第四點「add Timeout function」附帶了一段作者親自寫的、相當紮實的 unsigned 溢位處理教學註解,並在新函式裡正確實作:
// since micros() will wrap around counting from 0 after about 70 minutes,
// wrong results could occur by applying it.
// the rule: for unsigned(a)-unsigned(b), degrade it to int(c)=(int(a)-int(b));
// c represents the directional distance by a-to-b, valid as long as the real
// distance between a and b is <= 0x1:0000:0000/2-1.
// so the function finally becomes: if (int(a)-int(b)>=0) timeout;
if (int(micros())-int(ms*1000+store_start_time_us)>=0){ // this is ok
...
}
// note: the naive "if (micros()>=(ms*1000+store_start_time_us))" is NOT ok,
// will abnormal after power-on 70 minutes.
這是這個系列裡少數「先寫教學再寫程式碼」的段落,把 unsigned 溢位比較的原理講得比多數教科書清楚。值得留意的是這個修法只套用在新的 Timeout() 這支輔助函式上——查了一下 main_isr() 核心排程邏輯,實際上是靠硬體 FRC1 計數暫存器(US_TO_CNT/CNT_TO_US 這組巨集)而不是 micros() 在算時間,所以本來就不受這個問題影響,不是遺漏,是這段說明和修法精準地只用在真正需要的地方。
結語
六次改版、五個月,把系列當一份完整的 code review 讀下來,這支函式庫的演進路徑其實相當寫實地呈現了業餘專案疊代的真實樣貌:heap 不變量(#2)與中斷互斥(#4)這兩個「一旦出錯就是 mem-fault/wdt-reset」的硬傷,各自在系列中段被扎實修好——2026-09-06 更正:中斷互斥(#4)確實在系列中段被扎實修好,但heap 不變量(#2)當時只驗證了呼叫關係、沒驗證邏輯本身,實際上藏了一個方向性 bug,直到系列結束後的隨機化壓力測試才被抓到;型別可移植性(#5)在最終版順手解決;但機率性、難以在手動測試中復現的 bug(#1 m/n 未初始化)從頭到尾沒被抓到,即使程式碼被複製貼上到新函式時也一起被複製了過去;而一個原本已經收斂乾淨的設計取捨(#6)在最終版又被放寬回去,只留了一句建議注解。這對任何維護中斷驅動、共享資料結構程式碼的人都是個提醒:能被測試跑出來的 bug 會被修,測不出來的 bug 不會自己消失,需要靠程式碼審查、或是像 #1 這樣三行等級的防禦性寫法(初始化+事後檢查)來擋,而不能只靠「跑起來沒事」當作驗收標準。
Claude-code 全系列 code review/2026-09-05,依 Ken 指示於系列全數讀完後撰寫的技術總結(大一統文章),限於 C/C++ domain 分析,未對 ISR 進出 4us 開銷做任何嘗試壓縮。
2026-09-12 補充:ver.0.5 真實硬體壓力測試結果
在 esp8266-local 的 ttyUSB0(可自由燒錄的測試板)上,寫了一支專用壓力測試草稿(multipwm_sync_stress_test.ino),在 loop() 內持續反覆對 4 個 channel 呼叫 cMultiPwms::SyncStart()/SyncNext()/SyncEnd()——這組呼叫底層正是本文修正的 cHwTimer::Sync()/ForceHaltForSync(),函式庫用的是本文 ver.0.5 修正版(含上面 #1 m/n 初始化修正、#2 heap 方向性 bug 修正)搭配 MultiPWMs ver.0.9。
截至本次更新,累計已跑滿 12,251,197 次 Sync 循環(約 24 小時 39 分鐘連續運行),每輪皆檢查三個函式的回傳值並記錄 ESP.getFreeHeap():
STRESS_PROGRESS iter=12251197 syncFail=0 freeHeap=52304 millis=86365654
syncFail 全程為 0,freeHeap 穩定維持在 52304 bytes 沒有下降(無記憶體洩漏跡象),期間無 WDT reset、無當機、無需人工介入重新燒錄。這代表 ver.0.5 修正版在真實硬體上經得起遠超過原訂「百萬次量級」門檻(超過 12 倍)的長時間重複觸發,沒有引發任何可觀察到的異常。
需要誠實補充一點限制:這個測試驗證的是「修正版在真實硬體長時間重複使用下穩定、不當機、不洩漏記憶體」,但不直接證明上面 2026-09-06 更正段落提到的 min_heap_deterioration() 方向性 bug(新值變小 vs. 變大兩種情況)在這次測試中兩個方向都確實被觸發到——Sync()/ForceHaltForSync() 呼叫時新舊值的相對大小,取決於當下四個 channel 各自的計時器狀態,這次測試並未特別設計成強制涵蓋兩種方向。該 bug「兩個方向都會出錯、改成教科書寫法後兩個方向都驗證正確」這件事,已經由前面提到的純軟體隨機化壓力測試(6 組亂數種子、合計 1200 萬次操作,逐步驗證 min-heap 性質與元素集合)獨立證實過;這次真實硬體測試補上的是純軟體測試無法涵蓋的部分——真正的中斷時序、真正的硬體暫存器操作,長時間下不會讓系統崩潰或洩漏資源。兩者合起來,ver.0.5 修正版至此可以正式視為邏輯層與硬體層都已完成驗證。
Claude-code 補充/2026-09-12,依 Ken 指示於真實硬體上進行大量重複觸發壓力測試後的驗證結果補充。