[C 的那些眉角]迴圈陷阱—那些讓你 debug 到懷疑人生的邊界條件

寫 application 的迴圈出包,頂多程式 crash、重開就好。但在 firmware 的世界裡,一個迴圈的邊界條件沒處理好,輕則硬體不回應,重則系統直接掛死,debug 工具接上去什麼都看不到,只能對著 oscilloscope 發呆。

這篇整理的都是我這幾年踩過的坑,有些當下真的找很久,事後回頭看都覺得自己蠢。但這就是 firmware 的日常吧。


🔥 硬體暫存器輪詢:你以為它會回來,但它不會

這大概是 firmware 新手最容易中招的地方。輪詢一個硬體暫存器,等某個 bit 被設起來:

// ❌ 經典死法
while (!(REG_STATUS & STATUS_READY));

看起來很直覺對吧?但問題是——如果硬體永遠不 ready 呢?

SPI slave 沒接好、I2C bus 被拉住、外部晶片根本沒上電⋯⋯各種原因都可能讓這個 while 變成無限迴圈。整個系統就卡在這裡,watchdog 可能會把你救回來,但如果 watchdog 也還沒初始化呢?那就真的只能拔電了。

加上 timeout,這是鐵律

// ✅ 永遠記得加 timeout
#define POLL_TIMEOUT_US  10000  // 10ms,根據硬體 spec 調整

bool wait_for_ready(void)
{
    uint32_t elapsed = 0;

    while (!(REG_STATUS & STATUS_READY)) {
        delay_us(10);
        elapsed += 10;
        if (elapsed >= POLL_TIMEOUT_US) {
            log_error("STATUS_READY timeout");
            return false;
        }
    }
    return true;
}

幾個眉角:

  • timeout 值怎麼定? 去看硬體 datasheet。如果 datasheet 說 typical 回應時間是 1ms,我通常會給 5~10 倍的餘量。但不要無腦給超大值,不然出問題時使用者要等很久才會看到錯誤。
  • delay 的粒度:如果你在 bare-metal 環境,delay_us() 可能是用迴圈燒 CPU cycle 實現的。在 RTOS 上可以考慮用 vTaskDelay() 讓出 CPU。
  • timeout 之後怎麼辦? 這其實是更重要的問題。回傳錯誤碼讓上層決定?直接 reset 那個硬體?還是整個系統重啟?這取決於你的系統容錯設計,沒有標準答案。

⚡ 中斷裡的迴圈:你確定你想在 ISR 裡面轉?

我看過(好吧,我自己也寫過)在 ISR 裡面放迴圈去處理資料的 code:

// ❌ ISR 裡面搞這個,找死
void UART_IRQHandler(void)
{
    while (UART->SR & UART_SR_RXNE) {
        ring_buffer_put(&rx_buf, UART->DR);
    }
}

這段其實 在大多數情況下是可以運作的,因為 UART FIFO 深度有限,迴圈很快就會結束。但問題出在邊界情況:

  • 如果 baud rate 很高,資料一直灌進來,你的 ISR 就一直在裡面轉,其他中斷全部被擋住。
  • 如果 ring_buffer_put 的 buffer 滿了但你沒檢查回傳值,資料就默默丟掉了,你還不知道。
  • 更慘的是,如果某個硬體異常導致 RXNE flag 怎麼讀都不會清掉,恭喜你,ISR 就再也出不來了。

比較穩的做法

// ✅ 限制單次 ISR 處理量
void UART_IRQHandler(void)
{
    int count = 0;
    const int MAX_READ_PER_ISR = 16;  // FIFO 深度或合理上限

    while ((UART->SR & UART_SR_RXNE) && (count < MAX_READ_PER_ISR)) {
        uint8_t data = UART->DR;
        if (!ring_buffer_put(&rx_buf, data)) {
            // buffer 滿了,記錄一下,別假裝沒事
            rx_overflow_count++;
        }
        count++;
    }

    if (UART->SR & UART_SR_RXNE) {
        // 還有資料沒讀完,但先讓出 ISR
        // 下次中斷再處理,或設 flag 讓 main loop 來收
        SET_PENDING_IRQ(UART_IRQn);
    }
}

重點就是:ISR 裡面的迴圈一定要有上限。你不知道硬體會發生什麼事,但你可以控制軟體不要跟著一起瘋。


🕳️ Volatile 忘了加:最陰險的迴圈 bug

這個坑特別陰,因為 debug build 正常、release build 就掛

// ❌ flag 沒有加 volatile
uint8_t dma_done = 0;

void DMA_IRQHandler(void)
{
    dma_done = 1;
}

void wait_dma_complete(void)
{
    while (!dma_done);  // compiler 可能把這整個優化掉
    dma_done = 0;
}

-O0 編譯的時候沒問題,因為 compiler 每次都乖乖去讀 dma_done。但一開 -O2,compiler 看到 wait_dma_complete() 裡面沒人改 dma_done,就很聰明地把它優化成「既然不會變,那 while 不是恆真就是恆假」。

結果就是:你的程式碼邏輯完全正確,但 compiler 幫你「優化」成了一個永遠跳不出去的迴圈,或是直接跳過等待。GDB 斷點打上去看到的是優化前的行為,一拿掉斷點又壞了。我曾經花了大半天在 debug 一個 DMA transfer 的問題,最後發現就是少了一個 volatile

// ✅ 跟硬體或 ISR 共享的變數,一律 volatile
volatile uint8_t dma_done = 0;

延伸一下,如果你用 RTOS,更好的做法是用 semaphore 或 event group,而不是自己搞一個 flag 去輪詢:

// ✅ RTOS 環境下更推薦的做法
SemaphoreHandle_t dma_sem;

void DMA_IRQHandler(void)
{
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    xSemaphoreGiveFromISR(dma_sem, &xHigherPriorityTaskWoken);
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

void wait_dma_complete(void)
{
    if (xSemaphoreTake(dma_sem, pdMS_TO_TICKS(1000)) == pdFALSE) {
        log_error("DMA timeout");
        // 處理 timeout...
    }
}

又有 timeout 保護,又不用擔心 volatile 的問題,還能讓 CPU 在等待的時候去做別的事。


🔄 Retry 迴圈:重試幾次才合理?

跟外部裝置通訊的時候,retry 機制很常見。但我看過不少 retry 寫得很隨便的 code:

// ❌ 重試但沒有任何退避策略
bool send_command(uint8_t cmd)
{
    for (int i = 0; i < 3; i++) {
        if (i2c_write(SLAVE_ADDR, &cmd, 1) == OK) {
            return true;
        }
    }
    return false;
}

這樣的問題是:如果第一次失敗是因為 bus 忙碌或 slave 還沒準備好,連續立刻重試三次也不會成功。你只是用很快的速度失敗了三次而已。

// ✅ 加上退避延遲,給對方喘息空間
bool send_command(uint8_t cmd)
{
    const int MAX_RETRIES = 3;
    const uint32_t BASE_DELAY_MS = 5;

    for (int i = 0; i < MAX_RETRIES; i++) {
        if (i > 0) {
            // 簡單的指數退避:5ms, 10ms, 20ms...
            delay_ms(BASE_DELAY_MS << (i - 1));

            // 有些情況下需要先 reset bus
            i2c_bus_recovery();
        }

        if (i2c_write(SLAVE_ADDR, &cmd, 1) == OK) {
            return true;
        }

        log_warn("i2c_write failed, retry %d/%d", i + 1, MAX_RETRIES);
    }

    log_error("i2c_write failed after %d retries", MAX_RETRIES);
    return false;
}

另一個容易忽略的:retry 次數在不同場景下差很多

I2C 通訊重試 3 次很合理,但如果是 WiFi 連線重試,3 次根本不夠。我在做 IoT 模組的時候,WiFi 重連機制大概是這樣設計的:短期快速重試(1 秒間隔重試 5 次)→ 中期慢速重試(30 秒間隔重試 10 次)→ 長期守候模式(5 分鐘間隔持續重試)。不同階段的退避策略完全不同。


🧮 整數溢位:計時器的陷阱

這個也是經典。用一個 32-bit 計時器做 timeout 判斷:

// ❌ 溢位的時候會壞掉
uint32_t start = get_tick_ms();
while (get_tick_ms() - start < timeout_ms) {
    // 等待...
}

等等,這段其實 在大多數 C compiler 上是正確的。因為 uint32_t 的減法在溢位時會自然 wrap around,數學上結果還是對的(前提是 timeout 不超過 2^32 ms ≈ 49 天)。

但如果有人手賤改成 signed:

// ❌ 改成 signed 就炸了
int32_t start = (int32_t)get_tick_ms();
while ((int32_t)get_tick_ms() - start < (int32_t)timeout_ms) {
    // 溢位時變成 undefined behavior
}

或是這樣比較:

// ❌ 直接比較而不是算差值
uint32_t deadline = get_tick_ms() + timeout_ms;
while (get_tick_ms() < deadline) {
    // deadline 溢位後,這個條件可能立刻為 false
}

我的習慣是把 timeout 判斷封裝起來,避免每次都要想溢位問題:

// ✅ 封裝一次,到處安心用
static inline bool is_timeout(uint32_t start, uint32_t timeout_ms)
{
    return (get_tick_ms() - start) >= timeout_ms;
}

// 使用方式
uint32_t t0 = get_tick_ms();
while (!is_timeout(t0, 500)) {
    if (check_condition()) {
        break;
    }
}

簡單,但管用。


🎯 我的迴圈 checklist

寫了這麼多年 firmware,每次寫迴圈前我會在腦中快速跑一遍這些問題:

  • 有沒有 timeout? 任何等待硬體的迴圈,沒有 timeout 就是定時炸彈。
  • 在 ISR 裡面嗎? 是的話,迴圈有上限嗎?能不能改用 flag + main loop 處理?
  • 共享變數有 volatile 嗎? 如果一個變數在 ISR 和 main loop 之間共享,volatile 不能少。
  • retry 有退避嗎? 立刻重試通常沒用,給對方一點時間。
  • timeout 後的處理邏輯寫了嗎? 很多人 timeout 加了,但 timeout 之後只是 return false,上層完全不知道發生什麼事。
  • 整數運算會溢位嗎? 特別是計時相關的,用 unsigned 差值比較。

老實說,這些東西每一條都是用 debug 時間換來的。有些 bug 在開發階段怎麼測都測不出來,上了產線跑個幾天才冒出來,然後你就得面對那種「99.9% 時間都正常,偶爾掛一次」的噩夢。

Firmware 的迴圈跟一般程式的迴圈最大的差別就在這裡——你的對手不只是邏輯錯誤,還有硬體的不確定性。永遠假設硬體 出錯,然後確保你的軟體能 handle 它。

發佈留言