在 PIC16 上使用 seqLock 實作 Arduino 風格的 millis() 函數,以確保 ISR 到主程式的變數安全傳輸

本工程簡報展示如何使用順序鎖(seqLock)技術將毫秒計數值從中斷服務例程(ISR)傳送到 main() 函數。該技術允許我們在不禁用微控制器全域中斷的情況下安全地傳輸資料。此技術廣泛適用於所有微控制器,並將以 PIC16F13145 為例進行演示,如圖 1 所示。

本文是上一篇關於「毫秒回呼(millisecond callback)」的文章的續篇。請先閱讀前一篇文章,以便更好地理解本應用中如何使用 MPLAB 程式碼配置器(MCC)產生的回呼函數。

圖 1:PIC16F13145 Curiosity Nano 微控制器和按鈕安裝在試驗電路板上的圖片。

技術提示:原子操作是理解 seqLock 的基本前提。原子操作是指不能被分割的最小位寬,例如,不能在一個不間斷的步驟中完成。對於本文介紹的 PIC16 微控制器,原子操作的位元寬為 8-bit;而對於 ARM 等微控制器,原子操作的位元寬為 32-bit。

當在中斷服務例程(ISR)和 main() 函數之間傳輸資料時,如果變數的位元寬超過機器的原子操作位寬,就會出現邏輯衝突。例如,8-bit PIC 微控制器傳輸 32-bit 毫秒計數時就會出現這種邏輯衝突。為了緩解這個問題,我們可以停用全域中斷,或使用雙重鎖定機制,例如本文中描述的 seqLock。

什麼是順序鎖(sequential lock?)?

順序鎖(seqLock)是一種允許在兩個獨立的程式碼段之間進行非原子資料傳輸的機制。為了理解這個機制,我們首先回顧一下中斷服務例程(ISR)的工作原理。

為什麼要使用中斷服務例程 (ISR)?

在這個 PIC16 應用中,我們建立了一個回呼函數,它透過硬體定時器每毫秒觸發一次。

  • TMR0 直接由內部 32MHz 時脈驅動。

  • TMR0 具有 HFINTOSC 的精度特性。如有必要,可以透過切換到外部晶體振盪器來提高精度。

  • 回呼函數獨立於 main() 函數運作。換句話說,main() 函數中發生的事情不會影響回呼函數的時序。

  • 從 main() 函數切換到 ISR 會存在中斷延遲。但是,與 ISR 中維護的毫秒計數的整體時序相比,這種延遲可以忽略不計。

為什麼要把毫秒計數轉移到 main() 函數中?

首先,我們來定義一下前台進程和後台進程:

  • 前台程序:中斷服務例程 (ISR) 在前台運行,優先順序很高。用人類的反射神經系統來比喻,ISR 就像人的反射神經系統。它以極低的計算量快速完成任務。每個時脈週期都至關重要。

  • 後台程序main() 程式碼在背景執行。可以把它想像成大腦的高階處理過程。雖然時間控制也很重要,但後台運行的節奏要輕鬆得多。除非我們想盡可能地節省電池電量,否則幾百個溢出的時脈週期並不重要。

超級迴路的執行時間是可變的,但中斷服務程式 (ISR) 的執行時間是固定的。

回想一下,遍歷 main 函數的 while(1) 超級迴路所需的時間變化很大。根據程式碼類型和複雜程度的不同,時間範圍可能從幾微秒到幾秒鐘不等。在這種高度可變的系統中,幾乎不可能精確地追蹤時間。

然而,ISR 的執行時間卻非常可預測。它可以輕鬆精確地保持毫秒級的計時。

充分利用兩者的優勢

我們利用中斷服務例程 (ISR) 和超級迴路的特性,在 ISR 中維護時間,並在需要時傳遞給 main() 函數。我們這樣做的前提是,main() 函數的精確時間值允許最多延遲 1ms。例如,main() 函數中一個時脈週期的差異就可能導致毫秒計數是使用舊值還是新值。

為什麼不把所有操作都放在中斷服務程式 (ISR) 中執行呢?

這樣做當然可以,但這需要相當高的程式技巧。而且,只有當每個程式碼週期都在毫秒級的時間間隔內完成時,這種方法才有效。在這種協作環境中,稍有偏差,時間就會白白浪費。在我看來,這樣做得不償失。不如把 ISR 當作計時器,然後在 main() 函數中執行批次任務。

seqLock 是如何運作的?

seqLock 是一個包含兩個部分的過程,包括一個小型中斷服務程式回呼函數和一個從 main() 函數呼叫的讀取函數。

seqLock 的 ISR 部分

毫秒計數器在 My_1ms_Callback 中以三行程式碼實現,如清單 1 所示。volatile 變數 seq 在毫秒累加器遞增前後都會遞增。稍後我們將看到,這種兩步驟鎖定對於正確傳輸非原子資料至關重要。

第一條語句被指定為奇數,因此在加法運算後 seq 的值將變成奇數。seq++ 語句在 ISR 退出時將 seq 變數的值變成偶數。

static volatile uint8_t  seq = 0u;
static volatile uint32_t msAccum = 0u;
static void My_1ms_Callback(void){
   
    seq++;          // Begin write (odd)
    msAccum++;      // Increment the 1 ms accumulator
    seq++;          // End write (even)
}

清單 1:seqLock 的中斷服務程式 (ISR) 只需三行程式碼。

seqLock 函數部分

清單 2 展示了 seqLock 的讀取、檢查和重試部分。關鍵在於捕捉 seq 的前後值。然後,我們比較 s1 和 s2 的值,僅當 s1 == s2 時才繼續執行。

請注意,ISR 可以相對於 main() 函數的任何時間點執行。s1 和 s2 的原子傳輸使我們能夠檢測 ISR 更新是否發生在 v = msAccum 傳輸(PIC16 上的 4 次操作)的過程中。如果發生衝突,我們將重試。

在 main() 函數中使用 seqLock

完整的 PIC16 演示程序包含在清單 3 中。其結果是使用以下方式呼叫的類似 Arduino 的 millis 函數:

        uint32_t now = millis( );

請注意,I/O 引腳是使用 MPLAB 代碼配置器 (MCC) 進行配置的。另請注意,My_1ms_Callback(void) 是在 main() 中使用語句 TMR0_OverflowCallbackRegister(My_1ms_Callback); 註冊的。

uint32_t millis(void){
    uint8_t s1, s2;
    uint32_t v;
    do {   // read, copy, retry
        s1 = seq;
        v  = msAccum;
        s2 = seq;
    } while ((s1 != s2) || (s1 & 1u));   //  Retry if changed. 
    return v;
}

列表 2:millis() 函數具有嘗試/重試機制,可安全地從 ISR 傳輸非原子資料。

技術提示:ISR 和 main() 函數的時間並不同步。需要考慮兩種極端情況:

  • 短超級迴路:main() 函數在 ISR 讀取計數器之前會呼叫 millis() 函數數千次。

  • 長超級迴路:多個 ISR 回呼函數會在 main() 函數讀取計數器之前更新計數器。

無論哪種情況,ISR 都會維護毫秒計數器,並且在呼叫 millis() 函數時,main() 函數可以使用最新的計數器值。

#include "mcc_generated_files/system/system.h"

#define HEARTBEAT_MS   (500u)
static volatile uint16_t msTicks = 0u;

static volatile uint8_t  seq = 0u;
static volatile uint32_t msAccum = 0u;

/**
 * @brief 1 ms timer overflow callback (registered with MCC).
 * @details seqLock pattern: bump @c seq before and after updating @c msAccum
 *          so readers can detect mid-update without disabling interrupts.
 *          Also toggles LATB5 every @c HEARTBEAT_MS as a heartbeat.
 * @note Register with TMR0_OverflowCallbackRegister(My_1ms_Callback).
 *       Assumes LATB5 has been configured as an output by MCC.
 */
static void My_1ms_Callback(void){
   
    seq++;          // Begin write (odd)
    msAccum++;      // Increment the 1 ms accumulator
    seq++;          // End write (even)

    if (++msTicks >= HEARTBEAT_MS) {  // Heartbeat LED every 500 ms
        msTicks = 0u;
        LATBbits.LATB5 ^= 1;
    }

   
}

/**
 * @brief Atomic millisecond read via seqLock.
 * @details Read @c seq, then @c msAccum, then @c seq again.
 *          Retry if the version changed (s1 != s2) or if the start version
 *          was odd (writer in progress on architectures that allow nesting).
 *          On PIC16, nesting doesn’t occur, but we keep the odd check for portability.
 * @return Monotonic millisecond count (wraps after ~49.7 days).
 */
uint32_t millis(void){
    uint8_t s1, s2;
    uint32_t v;
    do {   // read, copy, retry
        s1 = seq;
        v  = msAccum;
        s2 = seq;
    } while ((s1 != s2) || (s1 & 1u));   //  Retry if changed. Keep (s1 & 1u) as a portability reminder if this code is ever transferred to a uC that allows ISR nesting.
    return v;
}



int main(void){
   
    SYSTEM_Initialize();
   
    TMR0_OverflowCallbackRegister(My_1ms_Callback);

    INTERRUPT_GlobalInterruptEnable();
    INTERRUPT_PeripheralInterruptEnable();
   
    uint32_t now = 0;
    uint32_t last = 0;
    LATBbits.LATB4 = 1; // Prime the pump

    while (1){
       
        now = millis( );
   
        if (last != now){
            last = now;
            LATBbits.LATB4 ^= 1;
        }
       
        // Add code here

    }  
}

// Doxygen comments generated using GPT-5o.

清單 3:用於 seqLock 技術的 PIC16 演示程式。

seqLock 程式演示

清單 3 中的程式碼從 PIC16F13145 輸出兩個實體訊號,包括:

  • ISR 產生的訊號:引腳 B5 上的 1Hz 輸出

  • main() 產生的訊號:腳位 B4 上的 500Hz 方波輸出

圖 2 顯示了使用 Digilent Analog Discovery 捕捉的訊號之間的關係。圖中可以看到,在 0ms 時刻訊號穩定重合,表示 millis() 的傳輸沒有出錯。雖然這並非最終證據,但在整個測試過程中,訊號關係保持穩定。

圖 2:透過比較 ISR 和 main() 函數中產生的波形來驗證 PIC16 seqLock 的有效性。

seqLock 技術的效率

清單 4 中的拆卸程式碼最能反映 seqLock ISR 的效率。實作 seqLock 的 ISR 部分總共只需要 11 行彙編程式碼。這對於精簡快速的 ISR 來說非常理想。

!    seq++;          // Begin write (odd)
0x9F: MOVLW 0x1
0xA0: MOVLB 0x0
0xA1: ADDWF seq, F

!    msAccum++;      // Increment the 1 ms accumulator
0xA2: MOVLW 0x1
0xA3: ADDWF msAccum, F
0xA4: MOVLW 0x0
0xA5: ADDWFC 0x29, F
0xA6: ADDWFC 0x2A, F
0xA7: ADDWFC 0x2B, F

!    seq++;          // End write (even)
0xA8: MOVLW 0x1
0xA9: ADDWF seq, F

清單 4:seqLock 中斷服務例程的拆卸程式碼。

完結前感想

seqLock 技術允許我們在不停用全域中斷的情況下,將非原子變數從中斷服務例程傳遞到 main() 函數。由此產生的中斷服務例程非常小。

現在我們應該回過頭來,將 seqLock 與雙鎖或 GIE 掩碼傳遞進行比較。哪種方法比較快?停用 GIE 位元會對程式碼中的其他中斷服務例程產生什麼影響?這些都是以後要討論的話題。

相關文章

如果您喜歡這篇文章,您可能也會覺得以下相關文章很有幫助: