文章目錄
- 1、引出棧空間問題
- 2、解決問題
- 2.1、RAM空間
- 2.2、RAM空間具體分布
- 2.3、關于棧空間的使用
- 2.4、棧溢出
- 2.5、變量的消亡
- 2.6、回到關鍵字static
- 2.7、合法性的判斷
1、引出棧空間問題
從static
關鍵字引出該部分內容。
為什么能從static
引出來?
在使用該關鍵字的時候:
我們需要知道什么時候使用該關鍵字?
什么使用關鍵字?
并且我們知道函數執行的時候是在棧空間,但是我們的static
修飾的關鍵字變量是在data段或者bss段。
還有就是我們的程序是從FLASH里面燒寫的,那就是意味著所有的變量以及函數都是先出現在FLASH里面也就是ROM空間。
以上這些疑問接下來通過按鍵開源項目例程主意分析。
2、解決問題
2.1、RAM空間
我們知道RAM里面有棧空間、堆空間、bss、data段。
-
?棧空間(Stack)??:存儲函數調用的局部變量、參數、返回地址等,由系統自動管理,從高地址向下生長。
-
?堆空間(Heap)??:用于動態內存分配(如
malloc
),由程序員手動管理,從低地址向上生長。 -
?.bss 段?:存儲未初始化的全局變量和靜態變量,程序啟動時由系統自動清零。
-
?.data 段?:存儲已初始化的全局變量和靜態變量,程序啟動時從 Flash 復制初始值到 RAM。
但是需要聲明的是在裸機開發中一般不使用堆空間,
并且函數的執行都是在==棧空間==,那說到這里還記不記得有一個棧頂空間,對的,這個棧頂空間就是給一個上限,因此棧空間的特殊性,是從上到下的,也就是高字節到低字節分配,
- 棧是一種線性數據結構,僅允許在棧頂(Top)?進行插入(入棧)和刪除(出棧)操作。類似一摞盤子,最后放上的盤子最先被取走。
這是因為在_main函數到mainARM內核還有一段代碼需要執行,因此留出來的是這一段空間,然后才是我們自己寫的main函數棧頂地址,就這后面的棧頂空間就可以循環利用了。
我們首先需要知道棧頂地址是怎么得到的?
2.2、RAM空間具體分布
這是整個RAM的空間:
棧是RAM頂部的最后一個區域,符合典型設計。這句話至關重要。
Exec Addr Load Addr Size Type Attr Idx E Section Name Object0x20000000 COMPRESSED 0x00000024 Data RW 39 .data main.o0x20000024 COMPRESSED 0x00000040 Data RW 110 .data modbus_app.o0x20000064 COMPRESSED 0x000000b5 Data RW 183 .data mb.o0x20000119 COMPRESSED 0x00000003 PAD0x2000011c COMPRESSED 0x0000000c Data RW 267 .data mbrtu.o0x20000128 COMPRESSED 0x00000008 Data RW 372 .data modbus_slave.o0x20000130 COMPRESSED 0x00000024 Data RW 581 .data key_drv.o0x20000154 COMPRESSED 0x00000024 Data RW 618 .data led_drv.o0x20000178 COMPRESSED 0x00000008 Data RW 668 .data ntc_drv.o0x20000180 COMPRESSED 0x00000006 Data RW 810 .data rh_drv.o0x20000186 COMPRESSED 0x00000002 PAD0x20000188 COMPRESSED 0x0000000c Data RW 964 .data systick.o0x20000194 COMPRESSED 0x0000001c Data RW 1010 .data usb2com_drv.o0x200001b0 COMPRESSED 0x00000002 Data RW 1133 .data portevent.o0x200001b2 COMPRESSED 0x00000002 PAD0x200001b4 COMPRESSED 0x00000018 Data RW 1168 .data portserial.o0x200001cc COMPRESSED 0x00000004 Data RW 3445 .data mc_w.l(stderr.o)0x200001d0 COMPRESSED 0x00000004 Data RW 3734 .data mc_w.l(stdout.o)0x200001d4 - 0x00000100 Zero RW 265 .bss mbrtu.o0x200002d4 COMPRESSED 0x00000004 PAD0x200002d8 - 0x00000030 Zero RW 580 .bss key_drv.o0x20000308 - 0x00000014 Zero RW 666 .bss ntc_drv.o0x2000031c COMPRESSED 0x00000004 PAD0x20000320 - 0x00000400 Zero RW 3383 STACK startup_gd32f30x_hd.o
通過工程的map
文件可以看出在棧空間確定之前,首先確定的是data、bss
數據占用的RAM空間,最后確定出棧空間的最低地址是多少。通過代碼可以看出是0x20000320
,大小是0x00000400
,其中棧的大小是可以自己設定的。那么兩者相加就是0x20000320 + 0x00000400 = 0x20000720
。
pxMBFrameCBByteReceived 0x2000007c Data 4 mb.o(.data)pxMBFrameCBTransmitterEmpty 0x20000080 Data 4 mb.o(.data)pxMBPortCBTimerExpired 0x20000084 Data 4 mb.o(.data)pxMBFrameCBReceiveFSMCur 0x20000088 Data 4 mb.o(.data)pxMBFrameCBTransmitFSMCur 0x2000008c Data 4 mb.o(.data)__stderr 0x200001cc Data 4 stderr.o(.data)__stdout 0x200001d0 Data 4 stdout.o(.data)ucRTUBuf 0x200001d4 Data 256 mbrtu.o(.bss)__initial_sp 0x20000720 Data 0 startup_gd32f30x_hd.o(STACK)
從最后一行代碼也可以看出該工程的棧頂地址是0x20000720
。
并且也符合圖片中的順序。
我們現在是在裸機層面考慮,所以先不考慮堆空間。
2.3、關于棧空間的使用
?ARM Cortex-M啟動流程與棧初始化?,在芯片上電或復位后,硬件自動執行以下步驟:
初始化主堆棧指針(MSP)??:從向量表的第一個表項(地址0x00000000或0x08000000)加載MSP初始值,指向棧頂(高地址)。也就是我們常說的這一步:
參考鏈接ARM單片機啟動流程(一)(詳細解析)-CSDN博客
讀取了棧頂地址以后,接著就是進入Rest_Handler
復位函數地址,然后從這里開始執行程序,這里需要說明但是SP
指向的地方。
首先,?棧頂(SP)已指向預設的棧空間頂端?(例如0x20000428),但尚未為任何函數分配棧幀。棧頂地址本身并不存儲_ _main
函數的入口地址,而是由硬件直接設置SP寄存器的值。
接著需要引入一個:棧幀概念:
?棧幀的創建?:
當Reset_Handler調用__main
時,會在棧上為__main
創建棧幀,保存返回地址(LR)和寄存器上下文。 ?棧頂(SP)此時指向__main
棧幀的頂部?(低地址);
需要注意的是__main
的低地址也就是main
函數的高地址。
也就是下述這個例子。
__main
函數:
- 將已初始化的全局變量(.data段)從Flash復制到RAM。 這個地方就解決了我們所疑惑的代碼燒寫到ROM里面,但是那些全局變量什么的又會到RAM里面。
- 清零未初始化的全局變量(.bss段)。
- 初始化C運行時環境(堆、棧、庫函數)。
- 最終調用用戶main()函數。
用戶main()及其調用的子函數共享同一棧空間,通過SP的移動動態分配/釋放棧幀,實現內存高效利用。
也就是說在整個RAM空間(不考慮堆空間),能循環利用的地方也就是棧空間,更具體來講就是main下面的。
因為在最下面是data、bss段,往上就是堆空間,接著就是我們的棧空間了。而棧空間又分為最上面的棧頂空間是用來存__main
這個棧幀空間的,接下來就是main
以及可以循環利用的棧空間,全靠SP移動高效復用內存。
?特性? | 通用系統(如 Linux) | 嵌入式系統(無 OS) |
---|---|---|
?**main() 行為**? | 單次執行后退出 | 無限循環,永不退出 |
?**__main 棧幀生命周期**? | main() 返回后立即釋放 | 永久保留(因 main() 不退出) |
?棧溢出風險? | 遞歸過深導致 | 循環內局部變量過大或遞歸未限制 |
?退出處理? | 調用 atexit() 、析構全局對象 | 進入 halt 或復位 |
-
__main
棧幀是“永久居民”?**?:因main()
永不返回,它作為程序生命周期的基石始終存在棧底。 -
?子棧幀是“流動工人”??:在
main()
的循環中動態輪轉,通過 SP 移動高效復用內存。 -
?循環缺失 = 系統失控?:嵌入式環境中,
main()
退出即程序終結,棧幀管理失去意義。
2.4、棧溢出
在裸機嵌入式系統中,棧溢出可能覆蓋bss段和data段,尤其是當棧與靜態存儲區相鄰且無保護時。
先不考慮堆空間。
-
棧(Stack)??:從高地址向低地址增長(向下增長)。
-
?堆(Heap)??:從低地址向高地址增長(向上增長)。
-
?靜態存儲區?:
- ?data段?:存放已初始化的全局變量和靜態變量。
- ?bss段?:存放未初始化的全局變量和靜態變量(程序啟動時清零)。
-
bss段優先被覆蓋?:
- bss段通常緊鄰堆區,位于棧的下方(低地址方向)。
- 若棧溢出量較大,?最先覆蓋的是bss段?(因其位置更靠近棧底)。
- ?案例?:
在Jflash下載算法中,棧溢出導致.bss
段變量被覆蓋,引發Flash寫入錯誤(如數據被篡改)。
-
?data段可能被覆蓋?:
- 若bss段被完全覆蓋且溢出持續,棧會進一步向下覆蓋data段。
- data段存儲已初始化變量,覆蓋可能導致程序邏輯錯誤或數據損壞?(如配置參數丟失)。
若內存布局中堆區較大或存在保護間隙(Guard Region)?,棧溢出可能僅覆蓋堆區,未觸及bss/data段。
某些鏈接腳本(Linker Script)會隔離棧與其他段,例如在棧底預留保護區。
通常bss段最先被覆蓋,其次是data段(因位置更接近棧底)
并且可以通過鏈接腳本隔離、哨兵檢測、MPU保護或靜態分析,可有效預防覆蓋風險。
?哨兵檢測(Sentinel Detection)?? 是一種通過監控特定內存值來識別棧溢出的軟件方法。其核心原理是在棧空間邊界預設一個特殊標記值(哨兵值),通過定期檢查該值是否被篡改來判斷是否發生溢出。
設置哨兵值?
在棧空間的頂部或底部?(根據棧增長方向)預留一個位置,寫入特定的哨兵值(如 0xDEADBEEF
)。棧通常從高地址向低地址增長(如ARM Cortex-M),哨兵值需放置在棧頂(低地址邊界)。
#define STACK_SENTINEL_VALUE 0xDEADBEEF
volatile uint32_t stack_sentinel __attribute__((section(".stack"))) = STACK_SENTINEL_VALUE;
定期檢查哨兵值?
在系統空閑任務、定時器中斷或關鍵任務周期中調用檢測函數,驗證哨兵值是否被覆蓋:
void check_stack_overflow(void) {if (stack_sentinel != STACK_SENTINEL_VALUE) {// 棧溢出處理handle_overflow_error();}
}
并且哨兵檢測具有滯后性、漏檢風險等局限性
- 只能在溢出發生后檢測,無法預防溢出,結合棧著色(Stack Coloring)技術,填充全棧空間并計算高水位線,提前預警。
- 若溢出未覆蓋哨兵值(如局部變量過大但未觸及邊界),可能漏檢。可在函數入口處增加棧指針范圍檢查。
2.5、變量的消亡
- ?靜態存儲區?:
- ?data段?:存放已初始化的全局變量和靜態變量。
- ?bss段?:存放未初始化的全局變量和靜態變量(程序啟動時清零)。
需要說明的是:bss和data不會釋放的,會一直占用。
局部變量在函數棧幀(Stack Frame)?? 中分配空間。當函數被調用時,編譯器會移動棧指針(如 sub rsp, N
指令),為所有局部變量一次性分配內存。
函數返回時,通過指令(如 add rsp, N
或 mov rsp, rbp
)將棧指針移回函數調用前的位置,?整個棧幀的內存被標記為“可復用”?,局部變量的存儲空間隨之釋放
- 釋放操作是高效的指針移動,而非數據擦除(內存中可能殘留原值)。
- 若后續函數調用覆蓋該棧幀,殘留數據會被新數據替換。
局部變量的生命周期與其所屬函數的棧空間緊密綁定,其存儲空間確實隨著函數棧幀的銷毀而被釋放。
棧空間釋放后,局部變量的地址立即失效,但數據可能暫時殘留。訪問這些地址會導致未定義行為?(如野指針操作)。
這里也就解釋了前面學習指針內容中,為什么我們要對指針指向明確的地址,就是防止野指針發生,因為有時候可能恰好就會指向我們剛好釋放的棧幀空間,那不就導致數據錯誤了。 產生程序崩潰(段錯誤)、數據污染(覆蓋其他變量)。
是不是這里又豁然開朗了,簡直是太妙了!!!!!!!!!!
2.6、回到關鍵字static
使用了static就說明這個變量不會隨著函數棧幀的內存被標記為“可復用”而消失。
我們使用static
關鍵字主要有兩個方面
1、控制作用域和封裝
- 限制作用域?:
static
將變量作用域限定在當前文件內,其他文件無法通過extern
訪問這些變量。這避免了全局變量的“污染”,防止其他模塊意外修改按鍵狀態。 - ?封裝性?:按鍵操作邏輯(如掃描、消抖)通常集中在同一文件中。
static
變量使所有相關操作內聚,符合“高內聚、低耦合”的設計原則。
2、?模塊化設計與協作開發 - 避免命名沖突?:全局變量可能被多人協作時的其他文件同名變量覆蓋,而
static
變量僅在當前文件有效,徹底消除沖突風險。 - ?簡化調試與維護?:開發者只需關注當前文件內的邏輯,無需追蹤全局變量的跨文件調用鏈,降低認知負擔。
3、內存與生命周期管理 - 生命周期相同,但更安全?:
static
變量與全局變量均存儲在靜態數據區,生命周期均為整個程序運行期。但static
通過作用域限制,提供了自動的內存隔離,避免全局變量的無約束訪問。 - ?初始化保障?:
static
變量默認初始化為0
(如未顯式初始化),與全局變量一致,但僅在首次加載時初始化一次。
?特性? | static Button btn1, btn2; | 全局變量 Button btn1, btn2; |
---|---|---|
?作用域? | 僅當前文件 | 整個程序(所有文件) |
?跨文件訪問? | 不可訪問 | 可通過 extern 訪問 |
?命名沖突風險? | 幾乎為零 | 高(需靠命名約定管理) |
?內存位置? | 靜態數據區(與全局變量相同) | 靜態數據區 |
?初始化? | 默認 0 ,僅初始化一次 | 默認 0 ,程序啟動時初始化 |
?適用場景? | 模塊內共享數據,無需外部暴露 | 需跨模塊共享的全局數據 |
因此在這里我們使用static關鍵字。
2.7、合法性的判斷
編程思想的嚴謹性在這里需要體現。
即使 static
變量地址有效,若函數通過參數接收外部指針(如 button_init(&btn1, ...)
),仍需檢查該參數是否為空:
因此初始化的時候首先要進行檢測的就是判斷地址的合法性。
void button_init(Button* handle, ...) {if (!handle) return; // 必須檢查,避免外部誤傳 NULL
}
文章源碼獲取方式:
如果您對本文的源碼感興趣,歡迎在評論區留下您的郵箱地址。我會在空閑時間整理相關代碼,并通過郵件發送給您。由于個人時間有限,發送可能會有一定延遲,請您耐心等待。同時,建議您在評論時注明具體的需求或問題,以便我更好地為您提供針對性的幫助。
【版權聲明】
本文為博主原創文章,遵循 CC 4.0 BY-SA 版權協議。這意味著您可以自由地共享(復制、分發)和改編(修改、轉換)本文內容,但必須遵守以下條件:
署名:您必須注明原作者(即本文博主)的姓名,并提供指向原文的鏈接。
相同方式共享:如果您基于本文創作了新的內容,必須使用相同的 CC 4.0 BY-SA 協議進行發布。
感謝您的理解與支持!如果您有任何疑問或需要進一步協助,請隨時在評論區留言。