[C 的那些眉角]指標與陣列的關係 — 從根本搞清楚

寫 C 寫了好幾年,我一直以為自己搞懂指標跟陣列的關係了。

直到有一次,我在一個跨檔案的專案裡,某個 .c 檔宣告了 char buf[256],另一個 .c 檔用 extern char *buf 去引用它。編譯過了,連結也過了,一跑就 segfault。

我盯著螢幕看了十分鐘,心裡想:「這兩個不是一樣的東西嗎?」

不是。這篇就是要把這件事從根本講清楚。

🧠 陣列不是指標,指標不是陣列

先把結論放前面:陣列和指標是兩種完全不同的東西,只是在某些場景下行為很像,像到讓人誤以為它們是同一個東西。

int arr[5] = {10, 20, 30, 40, 50};
int *ptr = arr;

這兩行之後,arr[2]ptr[2] 的值一樣,都是 30。用起來感覺完全一樣對吧?但底層完全不同:

  • arr 是一塊連續的記憶體空間,佔 5 * sizeof(int) 個 byte,它本身就是那塊記憶體
  • ptr 是一個變數,裡面存的是一個位址,佔 sizeof(int *) 個 byte(通常 4 或 8 bytes)

一個是「那塊地」,一個是「寫著地址的紙條」。你拿著紙條可以找到那塊地,但紙條本身不是那塊地。

🔄 Array Decay — 那個讓人搞混的元凶

為什麼大家會搞混?因為 C 語言有一個規則叫做 array decay(陣列退化):在大多數 expression 裡,陣列名稱會自動轉換成指向第一個元素的指標。

int arr[5] = {10, 20, 30, 40, 50};
int *ptr = arr;  // arr 在這裡 decay 成 &arr[0]

這個轉換是隱式的、自動的、無聲無息的。所以你寫 ptr = arr 的時候,感覺好像在說「指標就是陣列」,但其實是 compiler 偷偷幫你轉了。

但有三個地方不會 decay:

sizeof(arr)     // 回傳整個陣列的大小,不是指標的大小
&arr            // 取得「整個陣列」的位址,型別是 int (*)[5]
// 以及 arr 作為 string literal 初始化 char[] 的時候

這三個例外超級重要。特別是 sizeof,這是區分陣列跟指標最直接的方法。

📏 sizeof 是照妖鏡

這個是我覺得最能說明兩者差異的地方:

int arr[5];
int *ptr = arr;

printf("sizeof(arr) = %zu\n", sizeof(arr));  // 20(假設 int 是 4 bytes)
printf("sizeof(ptr) = %zu\n", sizeof(ptr));  // 8(64-bit 系統)

sizeof(arr) 回傳的是整個陣列佔的空間,sizeof(ptr) 回傳的是一個指標變數的大小。這就是鐵證——它們根本不是同一種東西。

這也是為什麼系列裡另一篇 sizeof 的陷阱 會專門討論「把陣列傳進函式之後 sizeof 就不對了」的問題。因為一旦 decay 發生,陣列的大小資訊就永遠丟失了。

🚨 函式參數裡的陣列:全是假象

這是最多人踩坑的地方。你寫:

void process(int data[10]) {
    printf("sizeof = %zu\n", sizeof(data));  // 8,不是 40!
}

那個 int data[10] 長得像陣列參數,但 compiler 實際上把它當成 int *data。方括號裡的 10?compiler 完全無視。你寫 int data[10] 跟寫 int data[999] 跟寫 int *data,對 compiler 來說一模一樣。

這不是 bug,是 C 語言的設計。C standard 規定:函式參數裡的陣列宣告會被「調整」(adjusted)為指標。

所以如果你需要在函式裡知道陣列的長度,必須另外傳:

void process(int *data, size_t len) {
    for (size_t i = 0; i < len; i++) {
        // ...
    }
}

// 呼叫的時候
int buf[10];
process(buf, sizeof(buf) / sizeof(buf[0]));

我自己會用一個 macro 來避免每次都手寫 sizeof 除法:

#define ARRAY_SIZE(arr) (sizeof(arr) / sizeof((arr)[0]))

// 用法
process(buf, ARRAY_SIZE(buf));

不過要小心,這個 macro 如果對指標用,不會報錯但會給你一個無意義的值。Linux kernel 有一個更安全的版本 ARRAY_SIZE 會在 compile time 檢查參數是不是真的陣列,但那個實作比較複雜,這裡先不展開。

💣 跨檔案宣告的大坑

回到開頭的故事。為什麼 char buf[256]extern char *buf 搭配會爆?

假設在 file_a.c 裡:

// file_a.c
char buf[256] = "hello";

然後在 file_b.c 裡:

// file_b.c
extern char *buf;  // ❌ 錯誤!

void foo(void) {
    printf("%s\n", buf);  // 💥 segfault 或亂碼
}

問題在哪?

buffile_a.c 裡是一個 256 byte 的陣列,起始位址假設是 0x1000,前幾個 byte 的內容是 'h', 'e', 'l', 'l', 'o', '\0'...

file_b.cextern char *buf 告訴 compiler:「buf 是一個指標變數」。所以 compiler 會去 0x1000 讀取一個指標大小的值(8 bytes),把 'h', 'e', 'l', 'l', 'o', '\0', '\0', '\0' 這 8 個 byte 解讀成一個記憶體位址。

然後它就跑去那個莫名其妙的位址讀資料了。當然爆。

正確的做法:

// file_b.c
extern char buf[];  // ✅ 宣告為不完整陣列型別
// 或
extern char buf[256];  // ✅ 大小要跟定義一致

這個 bug 特別陰險的地方是:編譯不會報錯,連結不會報錯,只有跑起來才會炸。而且因為讀到的那個「假位址」有時候碰巧指向合法記憶體,所以不一定每次都 crash,可能只是偶爾出現亂碼。在嵌入式系統上 debug 這種問題會讓人懷疑人生。

我那次花了半天才找到原因,之後養成習慣:陣列的 extern 宣告統一放在 header 檔,只寫一次,然後所有 .c 檔都 include 同一個 header。這樣至少 compiler 能幫你抓型別不一致。

🧮 指標算術 vs 陣列索引

arr[i]*(arr + i) 在 C 裡面是完全等價的。不是「行為類似」,是根據 C standard 的定義,arr[i] 就等於 *(arr + i)

這也是為什麼你可以寫出這種奇怪但合法的 code:

int arr[5] = {10, 20, 30, 40, 50};
printf("%d\n", 2[arr]);  // 輸出 30 😂

因為 2[arr] 被解讀成 *(2 + arr),而加法有交換律,所以跟 *(arr + 2) 一樣。當然,拜託不要在 production code 裡這樣寫。

但指標算術有一個常見的誤解:ptr + 1 不是位址加 1,而是位址加 sizeof(*ptr)

int arr[5] = {10, 20, 30, 40, 50};
int *ptr = arr;

printf("ptr   = %p\n", (void *)ptr);      // 假設 0x1000
printf("ptr+1 = %p\n", (void *)(ptr + 1)); // 0x1004,不是 0x1001

Compiler 會根據指標指向的型別自動計算偏移量。這在處理 uint8_t buffer 跟 uint32_t buffer 的時候差別很大,搞混的話就會讀到錯誤的位置。

📦 多維陣列與指標的對應

二維陣列的指標關係又更繞了一層:

int matrix[3][4];

matrix 是一個「3 個元素的陣列,每個元素是 4 個 int 的陣列」。當它 decay 的時候,不會變成 int *,而是 int (*)[4]——指向「4 個 int 的陣列」的指標。

int matrix[3][4];
int (*row_ptr)[4] = matrix;  // ✅ 正確
int *flat_ptr = matrix;       // ❌ 型別不匹配
int *flat_ptr = matrix[0];    // ✅ 第一列 decay 成 int*

這在嵌入式裡蠻常碰到的。比如你有一個 sensor 資料表,每個 sensor 有固定數量的 channel:

#define NUM_SENSORS  4
#define NUM_CHANNELS 8

uint16_t sensor_data[NUM_SENSORS][NUM_CHANNELS];

// 要傳某一個 sensor 的所有 channel 給函式
void process_sensor(uint16_t *channels, size_t num_ch);

// 呼叫的時候
process_sensor(sensor_data[2], NUM_CHANNELS);  // 傳第 3 個 sensor 的資料

sensor_data[2] 本身是一個 uint16_t[8] 的陣列,decay 之後變成 uint16_t *,型別剛好對得上。但如果你搞錯維度的對應關係,讀到的就是別的 sensor 的資料了。

🔑 &arr 跟 arr 值一樣,但型別不同

最後一個容易搞混的:

int arr[5];

printf("%p\n", (void *)arr);    // 假設 0x1000
printf("%p\n", (void *)&arr);   // 也是 0x1000

值一樣,但型別完全不同:

  • arr(decay 之後)的型別是 int *,指向第一個元素
  • &arr 的型別是 int (*)[5],指向整個陣列

差別在指標算術上:

printf("%p\n", (void *)(arr + 1));   // 0x1004,跳 4 bytes(一個 int)
printf("%p\n", (void *)(&arr + 1));  // 0x1014,跳 20 bytes(整個陣列)

這在實務上比較少直接用到,但如果你在讀別人的 code 或分析 compiler 錯誤訊息的時候,知道這個區別會讓你少走很多冤枉路。

整理一下

把幾個最常搞混的情境列出來:

int arr[5];
int *ptr = arr;

// 這些是一樣的
arr[2]    ≡  *(arr + 2)  ≡  ptr[2]  ≡  *(ptr + 2)

// 這些不一樣
sizeof(arr)    → 20    // 整個陣列的大小
sizeof(ptr)    → 8     // 指標變數的大小

// 這個會出事
extern int *arr;  // ❌ 如果原始定義是 int arr[5]
extern int arr[]; // ✅ 正確的跨檔案宣告

// 函式參數裡
void foo(int arr[5])  ≡  void foo(int *arr)  // compiler 不在乎方括號裡的數字

寫 C 這麼多年,我覺得指標跟陣列的關係就是那種「你以為懂了,但隔幾個月碰到一個奇怪的 case 又開始懷疑自己」的東西。特別是在嵌入式開發裡,pointer arithmetic 跟 memory layout 關係密切,搞錯一個 offset 可能不是 crash,而是靜靜地把隔壁的變數覆蓋掉,讓你 debug 到天荒地老。

把 decay 的規則記住,把 sizeof 當照妖鏡用,extern 宣告統一放 header——做到這三件事,至少可以避開八成的坑。

✅ 這篇的知識點

  • 陣列是一塊記憶體,指標是存放位址的變數——一個是「地」,一個是「紙條」
  • Array decay:陣列名稱在大多數 expression 裡會隱式轉換成指向首元素的指標
  • sizeof& 運算子、字串初始化是 decay 的三個例外
  • sizeof(arr) 回傳整個陣列大小,sizeof(ptr) 回傳指標大小——這是照妖鏡
  • 函式參數裡的 int arr[10] 會被 compiler 調整為 int *arr,方括號裡的數字沒有意義
  • 需要在函式裡知道陣列長度,就額外傳一個 size_t len 參數
  • ARRAY_SIZE(arr) macro 可以簡化 sizeof(arr)/sizeof(arr[0]),但對指標無效
  • 跨檔案引用陣列要用 extern int arr[],不能用 extern int *arr——型別不同,會 segfault
  • extern 宣告統一放 header 檔,讓 compiler 幫你抓型別不一致
  • ptr + 1 跳的是 sizeof(*ptr) 個 byte,不是 1 個 byte
  • 二維陣列 decay 成的是「指向一維陣列的指標」(如 int (*)[4]),不是 int *
  • arr&arr 值相同但型別不同,指標算術的步進大小完全不一樣

發佈留言