程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> C++ >> C++入門知識 >> C語言未定義行為一覽

C語言未定義行為一覽

編輯:C++入門知識

幾周前,我的一位同事帶著一個編程問題來到我桌前。最近我們一直在互相考問C語言的知識,所以我微笑著鼓起勇氣面對無疑即將到來的地獄。

他在白板上寫了幾行代碼,並問這個程序會輸出什麼?

  1. #include <stdio.h> 
  2.   
  3. int main(){ 
  4.     int i = 0; 
  5.     int a[] = {10,20,30}; 
  6.   
  7.     int r = 1 * a[i++] + 2 * a[i++] + 3 * a[i++]; 
  8.     printf("%d\n", r); 
  9.     return 0; 

看上去相當簡單明了。我解釋了操作符的優先順序——後綴操作比乘法先計算、乘法比加法先計算,並且乘法和加法的結合性都是從左到右,於是我抓出運算符號並開始寫出算式。

  1. int r = 1 * a[i++] + 2 * a[i++] + 3 * a[i++]; 
  2. //    =    a[0]    + 2 * a[1]  + 3 * a[2]; 
  3. //    =     10     +     40    +    90; 
  4. //    = 140 

我自鳴得意地寫下答案後,我的同事回應了一個簡單的“不”。我想了幾分鐘後,還是被難住了。我不太記得後綴操作符的結合順序了。此外,我知道那個順 序甚至 不會改變這裡的值計算的順序,因為結合規則只會應用於同級的操作符之間。但我想到了應該根據後綴操作符都從右到左求值的規則,嘗試算一遍這條算式。看上去 相當簡單明了。

  1. int r = 1 * a[i++] + 2 * a[i++] + 3 * a[i++]; 
  2. //    =    a[2]    + 2 * a[1]  + 3 * a[0]; 
  3. //    =     30     +     40    +    30; 
  4. //    = 100 

我的同事再一次回答說,答案仍是錯的。這時候我只好認輸了,問他答案是什麼。這段短小的樣例代碼原來是從他寫過的更大的代碼段裡刪減出來的。為了驗 證他的問題,他編譯並且運行了那個更大的代碼樣例,但是驚奇地發現那段代碼沒有按照他預想的運行。他刪減了不需要的步驟後得到了上面的樣例代碼,用gcc 4.7.3編譯了這段樣例代碼,結果輸出了令人吃驚的結果:“60”。

這時我被迷住了。我記得,C語言裡,函數參數的計算求值順序是未定義的,所以我們以為後綴操作符只是遵照某個隨機的、而非從左至右的順序,計算的。 我們仍然確信後綴比加法和乘法擁有更高的操作優先級,所以很快證明我們自己,不存在我們可以計算i++的順序,使得這三個數組元素一起加起來、乘起來得到 60。

現在我已對此入迷了。我的第一個想法是,查看這段代碼的反匯編代碼,然後嘗試查出它實際上發生了什麼。我用調試符號debugging symbols)編譯了這段樣例代碼,用了objdump後很快得到了帶注釋的x86_64反匯編代碼。

  1. Disassembly of section .text: 
  2.   
  3. 0000000000000000 <main>: 
  4. #include <stdio.h> 
  5.   
  6. int main(){ 
  7.    0:   55                      push   %rbp 
  8.    1:   48 89 e5                mov    %rsp,%rbp 
  9.    4:   48 83 ec 20             sub    $0x20,%rsp 
  10.     int i = 0; 
  11.    8:   c7 45 e8 00 00 00 00    movl   $0x0,-0x18(%rbp) 
  12.     int a[] = {10,20,30}; 
  13.    f:   c7 45 f0 0a 00 00 00    movl   $0xa,-0x10(%rbp) 
  14.   16:   c7 45 f4 14 00 00 00    movl   $0x14,-0xc(%rbp) 
  15.   1d:   c7 45 f8 1e 00 00 00    movl   $0x1e,-0x8(%rbp) 
  16.     int r = 1 * a[i++] + 2 * a[i++] + 3 * a[i++]; 
  17.   24:   8b 45 e8                mov    -0x18(%rbp),%eax 
  18.   27:   48 98                   cltq  
  19.   29:   8b 54 85 f0             mov    -0x10(%rbp,%rax,4),%edx 
  20.   2d:   8b 45 e8                mov    -0x18(%rbp),%eax 
  21.   30:   48 98                   cltq  
  22.   32:   8b 44 85 f0             mov    -0x10(%rbp,%rax,4),%eax 
  23.   36:   01 c0                   add    %eax,%eax 
  24.   38:   8d 0c 02                lea    (%rdx,%rax,1),%ecx 
  25.   3b:   8b 45 e8                mov    -0x18(%rbp),%eax 
  26.   3e:   48 98                   cltq  
  27.   40:   8b 54 85 f0             mov    -0x10(%rbp,%rax,4),%edx 
  28.   44:   89 d0                   mov    %edx,%eax 
  29.   46:   01 c0                   add    %eax,%eax 
  30.   48:   01 d0                   add    %edx,%eax 
  31.   4a:   01 c8                   add    %ecx,%eax 
  32.   4c:   89 45 ec                mov    %eax,-0x14(%rbp) 
  33.   4f:   83 45 e8 01             addl   $0x1,-0x18(%rbp) 
  34.   53:   83 45 e8 01             addl   $0x1,-0x18(%rbp) 
  35.   57:   83 45 e8 01             addl   $0x1,-0x18(%rbp) 
  36.     printf("%d\n", r); 
  37.   5b:   8b 45 ec                mov    -0x14(%rbp),%eax 
  38.   5e:   89 c6                   mov    %eax,%esi 
  39.   60:   bf 00 00 00 00          mov    $0x0,%edi 
  40.   65:   b8 00 00 00 00          mov    $0x0,%eax 
  41.   6a:   e8 00 00 00 00          callq  6f <main+0x6f> 
  42.     return 0; 
  43.   6f:   b8 00 00 00 00          mov    $0x0,%eax 
  44.   74:   c9                      leaveq 
  45.   75:   c3                      retq 

最先和最後的幾個指令只建立了堆棧結構,初始化變量的值,調用printf函數,還從main函數返回。所以我們實際上只需要關心從0×24到0×57之間的指令。那是令人關注的行為發生的地方。讓我們每次查看幾個指令。

  1. 24:   8b 45 e8                mov    -0x18(%rbp),%eax 
  2. 27:   48 98                   cltq  
  3. 29:   8b 54 85 f0             mov    -0x10(%rbp,%rax,4),%edx 

最先的三個指令與我們預期的一致。首先,它把i(0)的值加載到eax寄存器,帶符號擴展到64位,然後加載a[0]到edx寄存器。這裡的乘以1的運算1*)顯然被編譯器優化後去除了,但是一切看起來都正常。接下來的幾個指令開始時也大致相同。

  1. 2d:   8b 45 e8                mov    -0x18(%rbp),%eax 
  2. 30:   48 98                   cltq  
  3. 32:   8b 44 85 f0             mov    -0x10(%rbp,%rax,4),%eax 
  4. 36:   01 c0                   add    %eax,%eax 
  5. 38:   8d 0c 02                lea    (%rdx,%rax,1),%ecx 

第一個mov指令把i的值仍然是0)加載進eax寄存器,帶符號擴展到64位,然後加載a[0]進eax寄存器。有意思的事情發生了——我們再次 期待 i++在這三條指令之前已經運行過了,但也許最後兩條指令會用某種匯編的魔法來得到預期的結果(2*a[1])。這兩條指令把eax寄存器的值自加了一 次,實際上執行了2*a[0]的操作,然後把結果加到前面的計算結果上,並存進ecx寄存器。此時指令已經求得了a[0] + 2 * a[0]的值。事情開始看起來有一些奇怪了,然而再一次,也許某個編譯器魔法在發生。

  1. 3b:   8b 45 e8                mov    -0x18(%rbp),%eax 
  2. 3e:   48 98                   cltq  
  3. 40:   8b 54 85 f0             mov    -0x10(%rbp,%rax,4),%edx 
  4. 44:   89 d0                   mov    %edx,%eax 

接下來這些指令開始看上去相當熟悉。他們加載i的值仍然是0),帶符號擴展至64位,加載a[0]到edx寄存器,然後拷貝edx裡的值到eax。嗯,好吧,讓我們在多看一些:

  1. 46:   01 c0                   add    %eax,%eax 
  2. 48:   01 d0                   add    %edx,%eax 
  3. 4a:   01 c8                   add    %ecx,%eax 
  4. 4c:   89 45 ec                mov    %eax,-0x14(%rbp) 

在這裡把a[0]自加了3次,再加上之前的計算結果,然後存入到變量“r”。現在不可思議的事情——我們的變量r現在包含了a[0] + 2 * a[0] + 3 * a[0]。足夠肯定的是,那就是程序的輸出:“60”。但是那些後綴操作符上發生了什麼?他們都在最後:

  1. 4f:   83 45 e8 01             addl   $0x1,-0x18(%rbp) 
  2. 53:   83 45 e8 01             addl   $0x1,-0x18(%rbp) 
  3. 57:   83 45 e8 01             addl   $0x1,-0x18(%rbp) 

看上去我們編譯版本的代碼完全錯了!為什麼後綴操作符被扔到最底下、所有任務已經完成之後?隨著我對現實的信仰減少,我決定直接去找本源。不,不是編譯器的源代碼——那只是實現——我抓起了C11語言規范。

這個問題處在後綴操作符的細節。在我們的案例中,我們在單個表達式裡對數組下標執行了三次後綴自增。當計算後綴操作符時,它返回變量的初始值。把新 的值再分配回變量是一個副作用。結果是,那個副作用只被定義為只被付諸於各順序點之間。參照標准的5.1.2.3章節,那裡定義了順序點的細節。但在我們 的例子中,我們的表達式展示了未定義行為。它完全取決於編譯器對於 什麼時候 給變量分配新值的副作用會執行 相對於表達式的其他部分。

最終,我倆都學到了一點新的C語言知識。眾所周知,最好的應用是避免構造復雜的前綴後綴表達式,這就是一個關於為什麼要這樣的極好例子。

譯文鏈接:http://blog.jobbole.com/53211/

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved