寫 C 寫了好幾年,一開始我對記憶體佈局的理解就是「區域變數在 stack、malloc 在 heap」這樣而已。直到有一次在嵌入式專案裡,MCU 的 RAM 只有 64KB,程式莫名其妙跑到一半就掛了,才被迫把這些東西搞清楚。
這篇就是把當時搞懂的東西整理下來,順便附上一些我驗證用的小實驗。
先看全貌:一個程式在記憶體裡長什麼樣
當你編譯一個 C 程式、載入記憶體執行時,作業系統(或在嵌入式環境下是 linker script)會把它切成幾個區段。從低位址到高位址,大概長這樣:

在 Linux 上,實際的佈局會更複雜一點(還有 memory mapped region、vDSO 之類的),但核心概念就是這幾塊。嵌入式環境更單純,linker script 裡面寫得清清楚楚每一段放哪。
每個區段到底裝什麼
.text — 你的程式碼
編譯後的機器指令就放在這裡。這段通常是唯讀的,改了會 segfault。
在嵌入式系統上,.text 通常直接放在 Flash 裡面,CPU 從 Flash 讀指令執行(XIP, Execute in Place)。有些比較高階的做法會在開機時把 .text 從 Flash 搬到 RAM 來跑,速度會快一些。
.rodata — 唯讀資料
const 修飾的全域變數、字串常量("hello world")都在這裡。
const int MAX_RETRY = 5; // .rodata
const char *msg = "boot ok\n"; // 指標本身在 .data,字串內容在 .rodata
一個容易搞混的地方:const char *msg 裡面,msg 這個指標變數本身是可寫的(放在 .data),但它指向的字串 "boot ok\n" 是唯讀的(在 .rodata)。
.data — 有初始值的全域/靜態變數
int retry_count = 3; // .data
static int log_level = 2; // .data
這段的特點是:它在編譯時就知道值是多少了,所以這些初始值會被塞進執行檔裡面。也就是說,你如果宣告一個很大的已初始化陣列,你的執行檔也會跟著變大。
在嵌入式環境下,.data 的初始值存在 Flash,開機時由 startup code 搬到 RAM。這就是為什麼你在 linker script 裡面常常看到 _sidata、_sdata、_edata 這些 symbol —— 它們標記了「從 Flash 哪裡搬」和「搬到 RAM 哪裡」。
.bss — 沒有初始值(或初始化為 0)的全域/靜態變數
int error_count; // .bss
static char rx_buffer[1024]; // .bss
int zero_init = 0; // 通常也放 .bss(編譯器優化)
BSS 這個名字的由來是 "Block Started by Symbol",很古老的術語了,知道就好。
重點是:.bss 不佔執行檔空間。執行檔裡只記錄「.bss 要多大」,載入時由 OS 或 startup code 把這段記憶體全部清零。所以你宣告一個 static char buffer[65536] 不會讓你的 bin 檔膨脹 64KB,但它會吃掉 64KB 的 RAM。
在嵌入式開發裡,這是非常重要的觀念。我看過有人把大 buffer 宣告成 static char buf[4096] = {0};,以為加了 = {0} 會「比較安全」。結果其實編譯器通常還是把它放 .bss(因為初始值全是零),但如果你碰到比較保守的編譯器設定,它可能真的把它丟進 .data,你的 bin 檔就無端肥了 4KB。
Heap — 動態記憶體
malloc()、calloc()、realloc() 配出來的空間。方向是從低位址往高位址長。
char *buf = malloc(256); // 在 heap 上配 256 bytes
// 用完記得 free,不然就是 memory leak
free(buf);
在 Linux 上,glibc 的 malloc 底層用 brk()/sbrk()(小配置)和 mmap()(大配置,通常 > 128KB),實作細節蠻複雜的。
在嵌入式環境,heap 就比較敏感了。很多嚴格的嵌入式專案(特別是 safety-critical 的)根本禁用動態記憶體配置,因為 fragmentation 和不確定性。FreeRTOS 提供好幾種 heap 實作(heap_1 到 heap_5),各有不同的取捨,這又是另一個話題了。
Stack — 函式呼叫的命脈
區域變數、函式參數、return address 都在 stack 上。方向是從高位址往低位址長(大部分架構是這樣)。
void process_packet(uint8_t *data, int len) {
char temp[512]; // 在 stack 上,函式結束就自動回收
// ...
}
Stack 的大小是有限制的。Linux 上預設通常是 8MB(ulimit -s 可以查),嵌入式環境下可能就幾 KB。Stack overflow 是很常見的 crash 原因,而且通常不太好抓,因為它會把相鄰的記憶體內容搞壞,症狀可能延遲出現。
來實際看看
寫個小程式驗證一下各個變數分別被放到哪裡:
#include <stdio.h>
#include <stdlib.h>
// .data
int global_init = 42;
static int static_init = 100;
// .bss
int global_uninit;
static char bss_buffer[1024];
// .rodata
const int CONST_VAL = 999;
void show_layout(void) {
int local_var = 7; // stack
char *heap_ptr = malloc(64); // heap
printf("=== 記憶體佈局觀察 ===\n\n");
printf(".text (程式碼) : %p\n", (void *)show_layout);
printf(".rodata (唯讀) : %p\n", (void *)&CONST_VAL);
printf(".data (已初始化) : %p\n", (void *)&global_init);
printf(".data (static) : %p\n", (void *)&static_init);
printf(".bss (未初始化) : %p\n", (void *)&global_uninit);
printf(".bss (buffer) : %p\n", (void *)bss_buffer);
printf("Heap : %p\n", (void *)heap_ptr);
printf("Stack : %p\n", (void *)&local_var);
free(heap_ptr);
}
int main(void) {
show_layout();
return 0;
}
在我的 x86-64 Linux 上跑出來大概會是這樣(ASLR 關掉的話位址會比較好觀察):
.text (程式碼) : 0x401136
.rodata (唯讀) : 0x402004
.data (已初始化) : 0x404030
.data (static) : 0x404034
.bss (未初始化) : 0x404040
.bss (buffer) : 0x404060
Heap : 0x1a3a2a0
Stack : 0x7ffd4b2c3e4c
位址從低到高的順序:.text → .rodata → .data → .bss → heap → ... → stack。跟前面畫的圖對得上。
你也可以用 size 指令看編譯出來的各段大小:
$ gcc -o mem_layout mem_layout.c
$ size mem_layout
text data bss dec hex filename
1758 600 1056 3414 d56 mem_layout
bss 那個 1056 bytes 就是那個 1024 bytes 的 buffer 加上其他未初始化變數。注意 bss 這 1056 bytes 不會佔到 binary 的檔案大小。
嵌入式環境的記憶體管理:真的會痛
在 Linux 上,你通常不太需要擔心這些細節,因為虛擬記憶體幫你搞定了大部分問題。但在嵌入式環境,每一個 byte 都是實實在在的 RAM,沒有虛擬記憶體幫你擋。
我之前做一個專案,MCU 只有 64KB RAM。程式跑著跑著就掛,查了半天發現是 stack 跟 heap 撞在一起了。原因是有人在 ISR(中斷服務函式)裡宣告了一個蠻大的區域變數陣列,而 ISR 用的是獨立的 stack 或跟 main 共用(看設定),這一下就把 stack 推爆了。
後來的處理方式:
// ❌ 不要在 ISR 裡這樣做
void UART_IRQHandler(void) {
char parse_buf[512]; // 在 ISR 裡配 512 bytes,太傷了
// ...
}
// ✅ 用 static 或全域變數
static char parse_buf[512]; // 放 .bss,不佔 stack
void UART_IRQHandler(void) {
// 使用 parse_buf
// ...
}
還有一個常見的問題是算記憶體用量。linker 會告訴你 .data + .bss 佔了多少 RAM,但 stack 和 heap 的用量是動態的,linker 算不到。你得自己估算 worst case,或者用工具去量(比如 FreeRTOS 有 uxTaskGetStackHighWaterMark() 可以看每個 task 的 stack 最高水位)。
幾個常被問到的問題
Q: 全域變數跟 static 區域變數有什麼差別?從記憶體的角度看。
從記憶體佈局來看,它們放在同樣的地方(.data 或 .bss)。差別只在 scope(可見範圍),這是 compiler 層面的事,不是記憶體層面的。
Q: Stack 到底要配多大?
看你的程式怎麼用。遞迴深度、區域變數大小、ISR 巢狀層數都會影響。嵌入式環境下我通常會先用工具量一下 worst case,再多留 20-30% 的餘量。沒有萬用的數字。
Q: 為什麼 heap 和 stack 要從兩端往中間長?
這是經典的設計。因為編譯時不知道 heap 和 stack 各自會用多少,讓它們共享中間的空間可以最大化利用率。但代價是,如果某一邊長太快,它們就會撞在一起,然後什麼事都可能發生。
延伸閱讀
如果你想更深入,可以看看這些:
objdump -h your_binary:看每個 section 的詳細資訊readelf -S your_binary:更詳細的 section header/proc/<pid>/maps:看一個 process 實際的記憶體映射- linker script(.ld 檔案):嵌入式環境下,這個檔案決定了一切
寫嵌入式的話,真心建議花一個下午好好讀一次你專案的 linker script。一開始會覺得語法很奇怪,但讀懂之後,很多以前搞不懂的問題會突然通透。我自己就是某次被逼著去讀了 STM32 的 linker script,才真正搞懂 startup code 在做什麼。
✅ 這篇的知識點
- 程式載入記憶體後,由低到高依序是 .text → .rodata → .data → .bss → heap → ... → stack
- .text 放編譯後的機器指令,通常唯讀;嵌入式環境下常直接從 Flash 執行(XIP)
- .rodata 放
const全域變數和字串常量;const char *msg的指標本身在 .data,字串內容在 .rodata - .data 放已初始化的全域/靜態變數,初始值會佔進執行檔大小
- .bss 放未初始化(或初始化為 0)的全域/靜態變數,不佔執行檔空間,載入時歸零
static char buf[4096] = {0}通常會被優化放進 .bss,但不保證——別多此一舉- Heap 由
malloc配置,從低位址往高位址長;嵌入式 safety-critical 專案常禁用動態配置 - Stack 放區域變數、函式參數、return address,從高位址往低位址長
- Heap 跟 Stack 從兩端共享中間空間,撞在一起就出事
- 全域變數跟
static區域變數在記憶體裡放同一個地方(.data 或 .bss),差別只在 scope - ISR 裡不要宣告大的區域變數陣列,改用
static或全域變數放 .bss,避免爆 stack - Linker 算得到 .data + .bss 的 RAM 用量,但 stack 和 heap 是動態的,要自己估或用工具量
size指令可以快速看 binary 的 text / data / bss 各段大小objdump -h、readelf -S、/proc/<pid>/maps是觀察記憶體佈局的實用工具- 嵌入式環境下,linker script 裡的
_sidata、_sdata、_edata控制 .data 從 Flash 搬到 RAM 的過程