[C 的那些眉角]Stack Overflow — 嵌入式的堆疊管理

一聽到 Stack Overflow 這個名字,
大部分工程師第一個想到的是那個問答網站。

但在嵌入式開發,Stack Overflow 是一個真實會發生的災難,
而且症狀往往讓你完全摸不著頭緒。

程式跑著跑著突然 reset,
某個全域變數的值莫名其妙被改掉,
函式回傳之後跳到奇怪的位址,
或是程式直接進入 HardFault Handler,
然後你盯著 register dump 發愁。

這些症狀背後,很多時候都是同一個原因:
Stack 被寫爆了。


Stack 是什麼,為什麼會 Overflow

Stack 是一塊固定大小的記憶體區域,
用來存放:

  • 函式的區域變數(Local Variables)
  • 函式呼叫的返回位址(Return Address)
  • 函式參數(某些 ABI 下)
  • 暫存器的保存值(Context Save)

每次呼叫函式,Stack 就往下長(大部分架構是往低位址方向)。
函式返回,Stack 再縮回去。

file

如果 Stack 用完了還繼續往下寫,
就會覆蓋到 Stack 底端以外的記憶體,
那裡可能是 heap、全域變數、或是其他 task 的 Stack。

這就是 Stack Overflow


嵌入式的 Stack 有多小

在 PC 上,OS 通常給每個執行緒幾 MB 的 Stack,
而且用完了還可以自動擴展(mmap 新的頁面)。

嵌入式完全不一樣:

平台 典型 Stack 大小
Arduino Uno(ATmega328P) 2KB(整個 SRAM)
STM32F103 自己配置,通常 1-4KB
ESP32 FreeRTOS task 預設 2-4KB
Raspberry Pi(Linux) 8MB(跟 PC 差不多)

幾 KB 的 Stack,一個不小心就滿了。


哪些情況容易爆 Stack

情況一:區域變數太大

void process_frame(void) {
    uint8_t frame_buf[4096]; // ❌ 4KB 的區域變數,直接把 Stack 幹掉一半
    // ...
}

大 buffer 放在 Stack 上是最常見的原因。
解法是改成靜態或全域:

static uint8_t s_frame_buf[4096]; // ✅ 放在 BSS,不佔 Stack

void process_frame(void) {
    // 使用 s_frame_buf
}

但要注意,static 變數不是 thread-safe 的,
如果多個 task 會同時呼叫這個函式,要加鎖或改用其他方式。


情況二:遞迴呼叫太深

// ❌ 遞迴解析 JSON,深度不可控
void parse_json(const char *json, int depth) {
    char local_buf[256]; // 每層遞迴都吃 256 bytes
    // ...
    parse_json(nested_json, depth + 1);
}

每一層遞迴都會在 Stack 上分配新的區域變數,
如果 JSON 巢狀很深,Stack 很快就滿了。

嵌入式盡量避免遞迴,
或是明確限制遞迴深度並加上檢查:

#define MAX_PARSE_DEPTH 8

void parse_json(const char *json, int depth) {
    if (depth > MAX_PARSE_DEPTH) {
        LOG_ERROR("JSON too deeply nested");
        return;
    }
    // ...
}

情況三:函式呼叫鏈太長

main()
  → init_system()
    → init_network()
      → wifi_connect()
        → tcp_handshake()
          → tls_negotiate()
            → crypto_verify()
              → sha256_compute()

每一層函式都有自己的區域變數和返回位址,
呼叫鏈越長,Stack 用得越多。

這個比較難直接解決,
但可以透過測量(後面會說)來確認 Stack 用量是否安全。


情況四:ISR 的 Stack

在沒有 RTOS 的裸機系統,
中斷發生時,CPU 會把當前的 context 壓到 Stack 上,
然後跳去執行 ISR。

如果 ISR 本身也有區域變數,
這些都疊加在同一個 Stack 上。

如果 main 的 Stack 本來就快滿了,
這時候來一個中斷,就可能爆掉。


情況五:FreeRTOS 每個 Task 有自己的 Stack

// ❌ Stack 給太小
xTaskCreate(sensor_task, "Sensor", 128, NULL, 1, NULL);
// ^^^
// 128 words = 512 bytes(ARM 32-bit)

FreeRTOS 的 Stack 大小單位是 word(ARM 上是 4 bytes),
128 words = 512 bytes,
如果 task 裡有稍微複雜的邏輯,很容易不夠。


怎麼知道 Stack 用了多少

方法一:Stack Painting(塗色法)

這是嵌入式最常用的方法。

原理是:在程式啟動時,把整個 Stack 填滿一個特殊的值(通常是 0xDEADBEEF0xA5),
程式跑一段時間後,檢查還有多少 Stack 沒有被覆蓋。

// 在 startup code 裡,初始化 Stack 之前
extern uint32_t _stack_start; // linker script 定義的 Stack 底端
extern uint32_t _stack_end; // linker script 定義的 Stack 頂端

void stack_paint(void) {
    uint32_t *p = &_stack_start;
    while (p < &_stack_end) {
        *p++ = 0xDEADBEEF;
    }
}

// 之後可以查詢 Stack 的高水位線
size_t stack_get_unused(void) {
    uint32_t *p = &_stack_start;
    size_t unused = 0;
    while (*p == 0xDEADBEEF) {
        unused += sizeof(uint32_t);
        p++;
    }
    return unused;
}

定期呼叫 stack_get_unused()
如果剩餘空間越來越少,要注意了。


方法二:FreeRTOS 內建的 High Water Mark

FreeRTOS 有內建 Stack 監控:

// 取得 task 的 Stack 剩餘最小值(歷史最低點)
UBaseType_t remaining = uxTaskGetStackHighWaterMark(NULL);
// NULL 代表查詢目前 task
// 回傳值單位是 word

LOG_INFO("Task stack remaining: %u words (%u bytes)",
         remaining, remaining * sizeof(StackType_t));

這個值是歷史最低點
代表這個 task 從啟動到現在,Stack 最少剩下多少。

如果這個值很小(比如小於 20 words),
就要考慮增加 Stack 大小了。

可以在 monitor task 裡定期印出所有 task 的狀態:

void stack_monitor_task(void *pvParameters) {
    for (;;) {
        LOG_INFO("=== Stack Monitor ===");
        LOG_INFO("sensor_task: %u words",
                 uxTaskGetStackHighWaterMark(sensor_task_handle));
        LOG_INFO("network_task: %u words",
                 uxTaskGetStackHighWaterMark(network_task_handle));
        LOG_INFO("mqtt_task: %u words",
                 uxTaskGetStackHighWaterMark(mqtt_task_handle));
        vTaskDelay(pdMS_TO_TICKS(30000)); // 每 30 秒檢查一次
    }
}

方法三:讓 Stack Overflow 早點被發現

與其等 Stack Overflow 產生奇怪症狀,
不如讓它發生的時候立刻被抓到。

FreeRTOS Stack Overflow Hook:

// FreeRTOSConfig.h
#define configCHECK_FOR_STACK_OVERFLOW 2
// 1 = 只檢查 SP 是否超界
// 2 = 同時檢查 Stack 末端的 pattern 是否被覆蓋(更可靠)

// 實作 hook 函式
void vApplicationStackOverflowHook(TaskHandle_t xTask,
                                    char *pcTaskName) {
    // 這裡不能用正常的 log,因為 Stack 可能已經壞了
    // 最保險的做法是直接 reset 或停在這裡讓 debugger 抓
    __disable_irq();
    while (1) {
        // 停在這裡,讓 debugger 看到是哪個 task 出問題
    }
}

Cortex-M 的 MPU(Memory Protection Unit):

如果 MCU 有 MPU,
可以在 Stack 底端設置一個不可寫的保護區域,
Stack 一超界就立刻觸發 MemManage Fault,
比讓它默默覆蓋其他記憶體好多了。


一個真實的案例

之前有個專案,MCU 是 STM32,
程式跑幾個小時之後會莫名 reset。

GDB 抓到 reset 是因為 HardFault,
但 HardFault 的位址每次都不一樣,
完全找不到規律。

後來加了 Stack Painting,
發現 Stack 剩餘空間只剩 48 bytes,
幾乎是零。

追查原因,發現是某個函式在處理特定封包格式時,
會走到一條比較深的呼叫鏈,
加上那個函式有一個 512 bytes 的區域 buffer,
這條路徑的 Stack 用量比正常情況多了將近 800 bytes。

平常跑的封包不會觸發那條路徑,
所以測試都過了。
客戶的環境剛好有那種封包,跑幾個小時才出現。

解法:

  1. 把那個 512 bytes 的 buffer 改成 static
  2. Stack 大小從 2KB 增加到 4KB
  3. 加上 Stack 監控,定期印出剩餘空間

之後就再也沒有出現那個問題了。


說實話

Stack 問題在嵌入式是一個「不出事不知道,出事很難找」的類型。

我現在的習慣是,新專案一開始就把 Stack Painting 和監控加進去,
不要等出問題才來找。

Stack 大小的設定也不要太省,
多給個 50% 的餘裕,
幾百 bytes 的記憶體通常不是瓶頸,
但可以救你很多 debug 時間。

還有,每次加新功能之後,
都要看一下 Stack 的高水位線有沒有明顯變化,
養成習慣,就不會等到出貨之後才發現問題。


這篇的 Checklist

  • [ ] 大型 buffer 沒有放在 Stack 上(改用 static 或全域)
  • [ ] 遞迴呼叫有限制最大深度
  • [ ] FreeRTOS task 的 Stack 大小有根據實際用量設定,不是隨便猜
  • [ ] 有加 Stack Painting 或 FreeRTOS High Water Mark 監控
  • [ ] FreeRTOS 有開 configCHECK_FOR_STACK_OVERFLOW
  • [ ] 有實作 vApplicationStackOverflowHook
  • [ ] 加新功能後有重新確認 Stack 用量
分類: 程式相關,標籤: , , , , , , , , 。這篇內容的永久連結

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *