--- tags: NCKU Linux Kernel Internals, C語言 --- # C 語言: 未定義行為 [你所不知道的C語言: 未定義行為篇](https://hackmd.io/@sysprog/c-undefined-behavior?type=view) ## 甚麼是 Undefined behavior? * [Undefined behavior](https://en.wikipedia.org/wiki/Undefined_behavior): 語言規範書(例如 C 語言的 [ISO/IEC 9899](http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1548.pdf?fbclid=IwAR2k5K3ZPS4CzQ9AbVS96QD-5N2UQyE23Ui4ic270JX5Df3pEXJkg1cBDHA)) 並未明確規範某種程式碼的執行會產生的結果。 * [Unspecified behavior](https://en.wikipedia.org/wiki/Unspecified_behavior): 語言規範中雖沒有規定結果和應實現的行為,但可以根據平台或者編譯器的文檔得知其行為(例如[ABI](https://en.wikipedia.org/wiki/Application_binary_interface))。 語言上,未定義行為提供編譯器最佳化與靜態分析的空間,同時這也代表程式的撰寫者需為自己的行為負責。 ## 案例整理 參考 [Undefined Behavior and Compiler Optimizations](https://www.slideshare.net/linaroorg/bkk16503-undefined-behavior-and-compiler-optimizations-why-your-program-stopped-working-with-a-newer-compiler) 中的幾個範例。 :::warning :warning: 需注意畢竟是 undefined behavior,所以也不是所有範例的狀況都可以重現的 XD 需視編譯器的版本等因素而定。 ::: ### Case `1` 首先可以看到以下程式,在 gcc-4.8 編譯後的程式碼會是一個無窮迴圈。 ```c= int d[16]; int SATD (void) { int satd = 0, dd, k; for (dd = d[k = 0]; k < 16; dd = d[++k]) { satd += (dd < 0 ? -dd : dd); } return satd; } ``` * 在 C 語言中並沒有真正的陣列,因此 `d[++k]` 等價於 `(*((d) + (++k)))` * 因此 `d[k]` 中 k 超出原本陣列宣告的範圍是合法的,但是會產生 undefined bahavior * 基於程式撰寫者要為自己的程式負責的假設,編譯器會認為 k >= 16 是不合理的陣列操作,結果把程式最佳化成了一個無窮迴圈! ### Case `2` 以另一個程式為例 ```c= #include <stdio.h> int foo (int a) { if (a + 100 > a) printf("%d GT %d\n", a + 100, a); else printf("%d LT %d\n", a + 100, a); return 0; } int main () { foo(100); foo(0x7fffffff); return 0; } ``` 考慮到 32 bits 的 int,針對上述程式,如果我們用 > gcc -O0 *.c 來編譯,得到的結果會是 ``` 200 GT 100 -2147483549 LT 2147483647 ``` 乍看之下好像沒甚麼問題,但是如果如果進行最佳化 > gcc -O2 *.c 則會得到 ``` 200 GT 100 -2147483549 GT 2147483647 ``` ::: danger 我用 gcc 9.3.0 測試的時候不管有沒有優化都是得到 `-2147483549 GT 2147483647` ::: 原因是,在不考慮 overflow 的情形下,`a + 100` 理應始終大於 `a`,因此編譯器會直接把第5、6 行最佳化掉。 :::info 可以加入 -fno-strict-overflow 和 -fwrapv 避免最佳化把該兩行移除。 ::: ### Case `3` 需注意程式中踩到未定義行為可不是只是執行結果錯誤而已,甚至可能會發生安全性的疑慮!如 [GCC and pointer overflows](https://lwn.net/Articles/278137/) 中提及的案例。 ```c= char buffer[BUFLEN]; char *buffer_end = buffer + BUFLEN; /* ... */ unsigned int len; if (buffer + len >= buffer_end) die_a_gory_death("len is out of range\n"); ``` 上述的程式確保了 `buffer + len` 不會存取超出 `buffer_end` 的範圍。但 "細心" 的程式撰寫者可能會想到: 如果惡意的攻擊把 `len` 設很大,有可能產生 overflow,從而可能存取 address 小於 `buffer` 的範圍,於是把判斷條件改成。 ```c= if (buffer + len >= buffer_end || buffer + len < buffer) loud_screaming_panic("len is out of range\n"); ``` 看起來世界好像變得和平了?很不幸的,由於編譯會認為 `buffer + len < buffer` 是恆成立的 statement,而把判斷式優化為: ```c= if (buffer + len >= buffer_end) die_a_gory_death("len is out of range\n"); ``` 文章中提到,最好的方法應該是,避免檢查 pointer 的範圍,而是直接檢查 `len` 是否在合法的範圍下,類似下面的程式。 ```c= if (len >= BUFLEN) launch_photon_torpedoes("buffer overflow attempt thwarted\n"); ``` ### Case `4` ```c= #include <stdio.h> int foo(int x, int y) { x >>= (sizeof(int) << y); return x; } int main () { printf("%d\n", foo(1000, 3)); return 0; } ``` 考慮上述程式,如果我們用 > gcc -O0 *.c 來編譯,得到的結果會是 `1000`。 但是如果如果進行最佳化 > gcc -O2 *.c 則會得到 `0`。 由於 `sizeof(int) = 4`,而 `4 << 3 = 32`。如果預期 `>>` 是從右邊移出去出的位元會從左邊補進來的話,那得到的就是原始的 `x`。然而對於最佳化來說,右移超過 int 的長度(32 位元) 就等於是在做歸零,因此 `foo` 會直接被最佳化成 return 0。 :::info 可以使用 -fsanitize=undefined 來得到錯誤訊息 ::: :::danger 使用 gcc 9.3.0 測試時,加入 -fsanitize=undefined 雖然會得到報錯訊息,但 -O2 的結果仍會得到 0 ::: ### Case `5` 這是個有趣的案例。可以在以下程式看到,或許是因為程式撰寫者不小心寫錯程式,當 k = 0 時,事實上是會發生有問題的操作的。 ```c= #include <stdio.h> int testdiv(int i, int k) { if (!k) return 1/k; return 1; } int main() { int i = testdiv(1, 0); printf("%d\n",i); return i; } ``` 如果用 > gcc -O0 *.c 來編譯,那麼就會得到 Floating point exception,這是很合理的狀況。但假如我們加入最佳化 > gcc -O2 *.c 結果,竟然會平安無事的印出 `1`!這是因為編譯器預設 k 為除數,而判定 k 不該等於 0,因此最佳時便將 3、4 行直接移除,testdiv 變成只會 return 1。