寫 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 滿了但你沒檢查回傳值,資料就默默丟掉了,你還不知道。 - 更慘的是,如果某個硬體異常導致
RXNEflag 怎麼讀都不會清掉,恭喜你,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 它。