跳至主要內容
科技 科普

你的 printf 為什麼會害整臺機器當掉:C 標準庫的隱形狀態,與多工系統裡的搶座位問題

從 Keil 啟動反匯編裡的 __user_libspace() 出發,說明 C 標準庫的內部狀態在多任務與中斷下為何會出問題,以及兩種主流庫的保護方式差異。

刊登:(台北時間) 閱讀約 6 分鐘

這篇文章回顧的是一篇於 2026 年 10 月 6 日發表在掘金、作者署名 oku 的單晶片底層技術長文。文章從一個很小的線索出發:在 Keil 工程的啟動反組譯裡,作者沿著 __rt_entry 的執行流程,看到一次對 __user_libspace() 的呼叫,函式回傳一個 RAM 位址,後面跟著一段與 96 位元組有關的堆疊計算。這塊神祕空間,就是 ARM C 標準庫的 libspace。

對多數寫嵌入式的人來說,printf 和 malloc 就像家裡的水龍頭,打開就有水,很少人會去想水管埋在哪。這篇原文做的事情,就是把水管挖出來給你看,而且是在最容易出事的地方挖:FreeRTOS 多任務環境。

庫函式其實有「私人物品」

直覺上,函式像一臺自動販賣機,投幣、按鈕、掉出結果,過程不留痕跡。但標準庫裡很多函式並非如此,它們需要跨呼叫保留狀態。

原文列了幾個例子。printf 要透過 stdout 輸出,得記住串流的緩衝區配置和目前位置;malloc 要維護記憶體區塊的分配狀態,才有辦法查找、拆分、合併空閒塊;errno 是函式回報錯誤的地方,例如 strtol 轉換結果超出範圍時會設成 ERANGE;strtok 得記住上次切到哪裡,下次呼叫才能接著切;rand 有自己的偽隨機序列狀態;localtime 甚至直接回傳一個指向內部結果物件的指標,下次呼叫同一族函式就可能把上次的結果覆蓋掉。

用個貼近日常的比喻:這些函式不是自動販賣機,比較像共用廚房裡的廚師。strtok 切菜切到一半被叫走,回來得記得刀放在哪;malloc 是管家,手上有整棟房子的房間分配表。這些「私人物品」平常放在一個固定置物櫃裡,也就是 libspace。ARM C 標準庫預設提供一塊 96 位元組、零初始化的區域存放這些狀態,而且在 C 庫初始化期間,這塊區域還會被暫時當成棧使用,這解釋了為什麼會在啟動階段的堆疊建立程式碼附近看到它。

問題來了:置物櫃只有一個,廚師卻來了好幾個。

單人房住進了室友

單任務、無中斷的程式裡,一個櫃子夠用,因為任何時刻只有一條執行流程在使用它。但引入 FreeRTOS 之後,任務 A 切到一半被排程器換掉,任務 B 接手,打開同一個櫃子,把 A 的東西掃到地上放自己的。這在術語上叫狀態覆蓋。

原文做了一組實驗驗證這件事:透過追蹤 errno 的讀寫路徑,確認狀態區是怎麼被存取的,然後在多任務環境下實際重現狀態互相覆蓋的情況。解法是把櫃子改成每人一格:重新實作 __user_perthread_libspace(),讓每個任務都有自己獨立的私有狀態區,實驗證實覆蓋問題就此消失。

但 errno 這類「每人一份」的狀態好處理,malloc 這類「本來就該共用」的資源才是難題。記憶體堆只有一個,分配表只能有一份,不能各任務各記各的帳,否則兩個任務會把同一塊記憶體分配給不同人。共用的東西要保護,靠的是鎖。

原文的第二組實驗更驚心動魄:在多任務下並發地 malloc/free,直接讓 STM32F407 觸發 HardFault。作者接著從異常棧、故障指令,加上 CFSR、BFAR 暫存器的數值,定位出問題源頭是堆的管理資料被破壞;再透過加上鎖的對照實驗,確認鎖確實能擋住這種損壞。這套從當機現場回推根因的流程,本身就很值得嵌入式工程師參考:多數人遇到 HardFault 的反應是重開機碰運氣,而這裡示範的是把暫存器當驗屍報告來讀。

中斷是那個不敲門的室友

鎖上了鎖,故事還沒結束。中斷服務常式(ISR)是搶座位遊戲裡最兇的玩家:它隨時可以插隊,而且不受排程器管。

這裡有兩種死法。第一種是狀態覆蓋的變體:中斷打斷了某個庫函式執行到一半,ISR 裡又呼叫了同一個庫函式,兩者共用同一份狀態,資料就壞了。第二種更陰險,是等鎖死鎖:任務 A 拿著堆的鎖做到一半,中斷插進來,ISR 也想 malloc,於是坐下等鎖。但 A 要等 ISR 結束才能繼續跑、才能還鎖,而 ISR 在等 A 還鎖。兩邊互相等,系統整個凍住。原文也討論了另一種保護手法,也就是在關鍵區段裡屏蔽中斷,但指出這種做法有其局限。

一個看似繞圈的直覺可以幫忙理解:在 ISR 裡用 printf 除錯很方便,但那正是把「不敲門的室友」直接送進共用廚房。很多團隊的編碼規範禁止在中斷裡呼叫標準庫函式,理由就在這裡,與其事後驗屍,不如一開始就不讓兇手進門。

這種「先想清楚誰會同時碰這份資料」的思維,其實不限於嵌入式。伺服器端工程師面試常見的快取與資料庫並發題,考的也是同一件事,我們先前寫過的先查快取再查資料庫的並發思維課討論的雙重檢查與分段鎖,和嵌入式裡的鎖與臨界區,是同一套腦子在兩個世界的投影。

兩家圖書館,兩套管理方式

原文以 ARM C Library 為主、Newlib 4.6.0 作對照,比較兩者在狀態管理和鎖介面適配上的差異。這部分的實務意義在於:換工具鏈不只是換編譯器選項。ARM C 與 Newlib 對「哪些狀態放哪裡」「鎖怎麼掛」「多工支援怎麼開」各有自己的設計,移植工程時如果沿用舊假設,很可能在某個並發場景下才爆開。原文也明確列出實驗環境:Keil MDK 5.38.0、ARM Compiler V6.19、C99、未啟用 MicroLIB、FreeRTOS 10.4.6、heap_4.c,這些前提條件本身就是結論適用範圍的一部分。

一般開發者可以帶走什麼

這篇原文的價值不在於給出「照做就安全」的清單,而在於把一個平常被當黑箱的東西打開:標準庫函式有狀態,狀態有歸屬,歸屬和並發模型必須對得上。幾個可以直接用到工作上的判斷:第一,在 RTOS 環境裡,預設的單一 libspace 是隱患,確認你的庫與 RTOS 之間的重入支援是否正確接上;第二,堆這類本來就共用的資源,確認鎖機制存在且真正生效,並發 malloc 導致的 HardFault 往往看起來像隨機當機,最難抓;第三,中斷服務常式裡原則上不要碰標準庫,能延後到任務層級處理就延後。

至於要動手重現或深入研究原文實驗細節的讀者,完整內容在原始文章裡,含反組譯流程、追蹤方法與兩組實驗的對照設計,值得花三十七分鐘細讀。

主題

#armclibrary與嵌入式安全性#freertos與並發#stm32開發