[C 的那些眉角]記憶體佈局 — Stack、Heap、BSS、Data

寫 C 寫了好幾年,一開始我對記憶體佈局的理解就是「區域變數在 stack、malloc 在 heap」這樣而已。直到有一次在嵌入式專案裡,MCU 的 RAM 只有 64KB,程式莫名其妙跑到一半就掛了,才被迫把這些東西搞清楚。

這篇就是把當時搞懂的東西整理下來,順便附上一些我驗證用的小實驗。

先看全貌:一個程式在記憶體裡長什麼樣

當你編譯一個 C 程式、載入記憶體執行時,作業系統(或在嵌入式環境下是 linker script)會把它切成幾個區段。從低位址到高位址,大概長這樣:

file

在 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 -hreadelf -S/proc/<pid>/maps 是觀察記憶體佈局的實用工具
  • 嵌入式環境下,linker script 裡的 _sidata_sdata_edata 控制 .data 從 Flash 搬到 RAM 的過程

發佈留言