月份: 2026 年 9 月

MultiTimers 系列大一統:ESP8266 軟體計時器函式庫六版全紀錄與修正建議

No Comments

這是 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)未修已修:新增 min_heap_deterioration()(sift-down+sift-up),Sync/ForceHaltForSync 都改呼叫它未變沿用已修作者自陳已知情,承諾 ver.0.4 修未變
ver.0.4 最終版(id=2455)仍未修——確認到最終發行版仍在沿用已修沿用(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() 這一輪被服務掉並移出陣列——迴圈跑完 mn 就會是未初始化的堆疊值,緊接著 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,恰恰是最需要靠程式碼審查而非跑分測試抓出來的一類。

真正被修好的部分,也值得記一筆

  • #2 heap 覆寫不重排:ver.0.3(id=2158)新增 min_heap_deterioration(),用正規的 sift-down 找空位+sift-up 回填取代了原本的直接賦值,heap 不變量在同步操作後能正確維持,並沿用到最終版。
  • #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=15ID_MASK_BIT=4 收斂成 TIMER_NUM=6ID_MASK_BIT=3,並在註解說明了取捨脈絡:硬體計數暫存器 23-bit,扣掉留給倒數值的部份最多可留 9 bit 給 id(理論上限 511 顆),作者選擇犧牲上限、只留 3 bit 換取每顆計時器有更寬裕的計數位元。這本來是個乾淨的收斂。但最終版 ver.0.4(id=2455)又改回了 TIMER_NUM=15ID_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_CNTCNT_TO_US 這組巨集)而不是 micros() 在算時間,所以本來就不受這個問題影響,不是遺漏,是這段說明和修法精準地只用在真正需要的地方。

結語

六次改版、五個月,把系列當一份完整的 code review 讀下來,這支函式庫的演進路徑其實相當寫實地呈現了業餘專案疊代的真實樣貌:heap 不變量(#2)與中斷互斥(#4)這兩個「一旦出錯就是 mem-fault/wdt-reset」的硬傷,各自在系列中段被扎實修好;型別可移植性(#5)在最終版順手解決;但機率性、難以在手動測試中復現的 bug(#1 m/n 未初始化)從頭到尾沒被抓到,即使程式碼被複製貼上到新函式時也一起被複製了過去;而一個原本已經收斂乾淨的設計取捨(#6)在最終版又被放寬回去,只留了一句建議注解。這對任何維護中斷驅動、共享資料結構程式碼的人都是個提醒:能被測試跑出來的 bug 會被修,測不出來的 bug 不會自己消失,需要靠程式碼審查、或是像 #1 這樣三行等級的防禦性寫法(初始化+事後檢查)來擋,而不能只靠「跑起來沒事」當作驗收標準。

Claude-code 全系列 code review/2026-09-05,依 Ken 指示於系列全數讀完後撰寫的技術總結(大一統文章),限於 C/C++ domain 分析,未對 ISR 進出 4us 開銷做任何嘗試壓縮。

Categories: Arduino

Tags: ,

PHP Code Snippets Powered By : XYZScripts.com