迴圈邊界從 0 還是 1 開始?踩過幾次坑之後我的判斷邏輯
寫韌體寫了快二十年,for 迴圈大概打過幾萬次了,但「從 0 開始還是從 1 開始」這件事,我到現在還是會停下來想一秒。
不是不會寫,是因為這個東西錯起來特別陰。編譯不會報錯、跑起來大部分時候也正常,偏偏就在某個 buffer 邊界、某個陣列尾巴,悄悄多讀一個 byte 或少寫一格,然後在三天後的某個壓力測試裡爆給你看。
這篇就聊聊我自己的判斷邏輯,以及那些讓我學乖的坑。
預設答案:從 0 開始
C 的陣列從 arr[0] 開始,這是寫死的事實。所以絕大多數時候,迴圈就該長這樣:
for (int i = 0; i < n; i++) {
process(arr[i]);
}
i < n 而不是 i <= n。這組「0 起頭、< 收尾」是黃金組合,跑 n 次、剛好碰到 arr[0] 到 arr[n-1],一個都不漏、一個都不超。
我的習慣是:沒有特殊理由,就用這個。不要自作聰明。
一不小心就 off-by-one
問題通常出在你想「從 1 開始數比較直覺」的時候。比如你想印「第幾個」:
// ❌ 想印第 1 到第 n 個,結果讀爆
for (int i = 1; i <= n; i++) {
printf("第 %d 個: %d\n", i, arr[i]); // arr[n] 越界!
}
i 跑到 n 的時候,arr[n] 已經是陣列外面了。在 PC 上你可能只是讀到垃圾值,在 MCU 上,那塊記憶體說不定是別的變數,甚至是某個 peripheral 的 register。我就遇過讀到隔壁 buffer、害一個 UART 設定被汙染的狀況,查了整個下午。
正解是把「顯示用的編號」跟「存取用的索引」分開:
// ✅ 索引從 0,顯示時 +1
for (int i = 0; i < n; i++) {
printf("第 %d 個: %d\n", i + 1, arr[i]);
}
索引永遠服務記憶體,顯示是給人看的,兩件事不要混在同一個變數裡。
什麼時候真的會從 1 開始
也不是說 1 就一定錯。我自己會從 1 開始的場景大概這幾種:
第一種,演算法本身的定義就是 1-based。 比如某些數學遞推、或是你在翻論文 paper 上的 pseudo code,人家寫 for i = 1 to n,你硬要改成 0-based 反而容易在轉換時出錯。這時候我寧可開一個 arr[n+1]、刻意放棄 arr[0],讓索引對齊論文,debug 的時候腦袋比較不會打結。
第二種,你要比較「相鄰兩個元素」。 像算差分、找跳變點:
// 從 1 開始,才能安全地碰 arr[i-1]
for (int i = 1; i < n; i++) {
diff[i - 1] = arr[i] - arr[i - 1];
}
這裡從 1 開始是有道理的——i = 0 的時候 arr[i-1] 就是 arr[-1],直接踩雷。從 1 起跳剛好避開。
真正讓我學乖的那次
最慘的一次不是陣列,是處理一個環形 buffer(ring buffer)的讀寫指標。
那時候我寫了個 DMA 收資料的 ISR,讀指標跟寫指標都從 0 開始,看起來很合理。問題是我在判斷「滿了沒」的條件上,把 head == tail 跟「空」還是「滿」搞混,加上一個 <= 寫成 <,結果在 buffer 剛好繞一圈、邊界對齊的瞬間,少收了一個 byte。
平常完全正常,只有在資料量大到剛好填滿一圈的時候才會出事。這種 bug 最難查,因為它跟「邊界」綁死,你不刻意去戳那個邊界,它一輩子不出現。
從那次之後,我養成一個習慣:寫完迴圈,一定手動跑一遍邊界值。 i = 0 碰到哪裡?最後一圈 i 是多少?那一格存在嗎?用嘴巴唸過一遍,比跑十次正常測試還有用。
我現在的判斷流程
講了這麼多,其實濃縮成幾句話:
- 預設從
0、用<,沒理由就別動它 - 想「數第幾個」是顯示邏輯,跟索引分開,別讓 1-based 的直覺污染陣列存取
- 要碰
arr[i-1]就從1開始,天經地義 - 對齊論文 / 既有定義時,可以刻意 1-based,但要在註解寫清楚為什麼
- 寫完一定手算邊界,第一格跟最後一格,各別存不存在
說到底,0 還是 1 不是品味問題,是「你有沒有想清楚這個迴圈到底要碰哪些記憶體」的問題。想清楚了,從幾開始都不會錯;沒想清楚,從 0 開始照樣 off-by-one。
這個我大概還會繼續踩,只是希望下次能在 code review 就抓到,而不是壓力測試的第三天。
💬 你有沒有那種「從 0 還是從 1」害你 debug 半天的經驗?或是你習慣怎麼檢查邊界?留言聊聊,我蠻好奇大家各自的土法煉鋼。