緩衝區溢位(Buffer Overflow)是 C 語言最惡名昭彰的問題之一。
它不只是會讓程式 crash,
在某些情況下,它是駭客攻擊的入口。
歷史上很多嚴重的安全漏洞,
根源都是一個沒有做邊界檢查的陣列存取。
但在嵌入式開發,我更常遇到的不是安全問題,
而是「程式跑著跑著,某個全域變數的值莫名其妙被改掉」,
或是「UART 收到一個比預期長的封包,程式就 crash 了」。
追到最後,都是同一件事:
有人寫到了陣列邊界以外的地方。
最簡單的例子
uint8_t buf[8];
for (int i = 0; i <= 8; i++) { // ❌ 應該是 i < 8,不是 i <= 8
buf[i] = 0;
}
buf 有 8 個元素,索引是 0 到 7。
i <= 8 讓迴圈多跑一次,
buf[8] 寫到了陣列邊界之外。
這個錯誤叫做 off-by-one,
是邊界問題裡最常見的一種。
C 語言不會幫你檢查,
編譯器通常也不會警告,
程式就這樣默默寫到別人的記憶體。
為什麼 C 不幫你檢查
因為 C 的陣列本質上就是一個指標,
buf[i] 等同於 *(buf + i),
C 不知道也不在乎 i 是否在合法範圍內。
uint8_t buf[8];
uint8_t other[8];
// 在記憶體裡,buf 和 other 可能是相鄰的
// buf[8] 可能就是 other[0]
buf[8] = 0xFF; // 你以為在寫 buf,其實在改 other
這就是為什麼緩衝區溢位的症狀這麼難追:
問題發生在 A,症狀出現在 B。
常見的溢位情境
情境一:字串操作沒有限制長度
char name[32];
// ❌ gets() 完全不檢查長度,已經從 C11 標準移除
gets(name);
// ❌ strcpy 不檢查目標緩衝區大小
strcpy(name, user_input);
// ❌ sprintf 不檢查目標緩衝區大小
sprintf(name, "Hello, %s!", user_input);
這三個函式在嵌入式和網路程式裡是重災區,
因為輸入的長度往往來自外部,不可控。
情境二:UART / 網路封包接收
#define RX_BUF_SIZE 64
uint8_t rx_buf[RX_BUF_SIZE];
int rx_len = 0;
void uart_rx_callback(uint8_t byte) {
// ❌ 沒有檢查 rx_len 是否超過緩衝區大小
rx_buf[rx_len++] = byte;
}
如果對方傳來的資料超過 64 bytes,
rx_buf 就溢位了。
在正常測試環境下,封包長度都是合法的,
一切看起來正常。
但只要對方傳來一個異常的封包,
程式就壞了。
情境三:索引來自外部資料
const char *error_messages[] = {
"No error", // 0
"Timeout", // 1
"CRC error", // 2
"Invalid packet", // 3
};
void print_error(uint8_t error_code) {
// ❌ error_code 如果大於 3,就越界了
printf("Error: %s\n", error_messages[error_code]);
}
error_code 來自外部(封包、使用者輸入、感測器),
不能假設它一定在合法範圍內。
情境四:memcpy / memset 的長度錯誤
uint8_t src[64];
uint8_t dst[32];
// ❌ 把 src 的大小用在 dst 上
memcpy(dst, src, sizeof(src)); // 寫了 64 bytes 到只有 32 bytes 的空間
sizeof(src) 是 64,但 dst 只有 32 bytes,
這樣 memcpy 就溢位了。
這個錯誤很常發生在複製貼上程式碼的時候,
改了 src 的大小,忘記改 memcpy 的長度。
正確的寫法
字串操作用有長度限制的版本
char name[32];
// ✅ strncpy:限制最多複製 n-1 個字元
strncpy(name, user_input, sizeof(name) - 1);
name[sizeof(name) - 1] = '\0'; // 確保結尾有 null terminator
// ✅ snprintf:限制輸出長度
snprintf(name, sizeof(name), "Hello, %s!", user_input);
// snprintf 會自動加上 null terminator,不需要手動加
// ✅ strlcpy(BSD / 部分平台有,更安全)
strlcpy(name, user_input, sizeof(name));
注意 strncpy 的一個坑:
如果來源字串長度 >= n,它不會自動加上 null terminator,
所以要手動補上 name[sizeof(name) - 1] = '\0'。
snprintf 沒有這個問題,我更常用它。
UART 接收加邊界檢查
#define RX_BUF_SIZE 64
uint8_t rx_buf[RX_BUF_SIZE];
int rx_len = 0;
void uart_rx_callback(uint8_t byte) {
if (rx_len >= RX_BUF_SIZE) {
// ✅ 緩衝區滿了,丟棄或處理錯誤
LOG_WARN("RX buffer overflow, discarding byte");
return;
}
rx_buf[rx_len++] = byte;
}
索引來自外部時先驗證
#define ERROR_MSG_COUNT 4
const char *error_messages[ERROR_MSG_COUNT] = {
"No error",
"Timeout",
"CRC error",
"Invalid packet",
};
void print_error(uint8_t error_code) {
if (error_code >= ERROR_MSG_COUNT) {
// ✅ 超出範圍,處理錯誤
LOG_WARN("Unknown error code: %u", error_code);
return;
}
printf("Error: %s\n", error_messages[error_code]);
}
memcpy 用目標大小,不是來源大小
uint8_t src[64];
uint8_t dst[32];
// ✅ 用 dst 的大小,或是明確指定要複製的長度
size_t copy_len = sizeof(dst); // 複製 32 bytes
memcpy(dst, src, copy_len);
// 或是用巨集確保不超過目標大小
#define SAFE_MEMCPY(dst, src, len) \
memcpy((dst), (src), MIN((len), sizeof(dst)))
用 sizeof 而不是寫死數字
這個習慣可以避免很多問題:
// ❌ 寫死數字,改了陣列大小之後容易忘記改這裡
uint8_t buf[64];
memset(buf, 0, 64);
// ✅ 用 sizeof,陣列大小改了這裡自動跟著變
uint8_t buf[64];
memset(buf, 0, sizeof(buf));
// ❌ 迴圈上限寫死
for (int i = 0; i < 64; i++) { ... }
// ✅ 用 sizeof 或 ARRAY_SIZE 巨集
#define ARRAY_SIZE(arr) (sizeof(arr) / sizeof((arr)[0]))
for (int i = 0; i < ARRAY_SIZE(buf); i++) { ... }
ARRAY_SIZE 這個巨集在 Linux kernel 和很多嵌入式專案裡都看得到,
非常好用。
用工具幫你找
AddressSanitizer(ASan)
gcc -fsanitize=address -g your_code.c -o your_program
./your_program
ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000018
WRITE of size 1 at 0x602000000018 thread T0
#0 0x401234 in uart_rx_callback demo.c:12
ASan 可以抓到 heap 上的越界存取,
但 stack 上的越界有時候抓不到(取決於記憶體佈局)。
-fsanitize=bounds(GCC)
專門用來抓陣列越界:
gcc -fsanitize=bounds -fsanitize=undefined -g your_code.c -o your_program
Valgrind
valgrind --tool=memcheck ./your_program
可以抓到 heap 上的越界讀寫,
搭配上一篇說的 --track-origins=yes 更有效。
嵌入式的額外防禦:Canary
在嵌入式,如果沒辦法用 ASan,
可以在緩衝區末端放一個「金絲雀值」(Canary),
定期檢查它有沒有被覆蓋:
#define CANARY_VALUE 0xDEADBEEF
typedef struct {
uint8_t data[64];
uint32_t canary; // 放在緩衝區末端
} SafeBuffer;
void safe_buffer_init(SafeBuffer *buf) {
memset(buf->data, 0, sizeof(buf->data));
buf->canary = CANARY_VALUE;
}
bool safe_buffer_check(const SafeBuffer *buf) {
if (buf->canary != CANARY_VALUE) {
LOG_ERROR("Buffer canary corrupted! Overflow detected.");
return false;
}
return true;
}
這個方法不能阻止溢位,
但可以讓你早點發現溢位發生了,
而不是等到程式出現奇怪行為才來找。
說實話
邊界檢查這件事,
說起來很簡單,做起來很容易忘。
特別是在趕進度的時候,
「這個 buffer 夠大了啦,不會溢位」,
然後就沒有加檢查。
我踩過最印象深刻的一次,
是一個 MQTT 封包解析的函式,
topic 長度來自封包內容,
但我沒有驗證長度是否超過 topic buffer 的大小。
正常的 broker 傳來的 topic 都很短,
測試完全正常。
後來有個測試人員用工具發了一個異常的封包,
topic 長度填了 0xFFFF,
程式直接 crash。
從那之後,我對「來自外部的長度值」特別敏感,
任何從封包、使用者輸入、感測器讀來的長度,
在用之前一定先驗證。
這篇的 Checklist
- [ ] 迴圈上限用
< size,不是<= size(off-by-one) - [ ] 字串操作用
snprintf/strncpy,不用sprintf/strcpy - [ ]
strncpy之後有手動補 null terminator - [ ]
memcpy/memset的長度用目標大小,不是來源大小 - [ ] 來自外部的索引值在使用前有做範圍檢查
- [ ] 來自外部的長度值在使用前有做上限檢查
- [ ] 陣列大小用
sizeof或ARRAY_SIZE,不寫死數字 - [ ] 有用 ASan 或 Valgrind 跑過測試
- [ ] 嵌入式關鍵緩衝區有考慮加 Canary