幾周前,我的一位同事帶著一個程式設計問題來到我桌前。最近我們一直在互相考問C語言的知識,所以我微笑著鼓起勇氣面對無疑即將到來的地獄。
他在白板上寫了幾行程式碼,並問這個程式會輸出什麼?
1 2 3 4 5 6 7 8 9 10 |
#include <stdio.h> int main(){ int i = 0; int a[] = {10,20,30}; int r = 1 * a[i++] + 2 * a[i++] + 3 * a[i++]; printf("%d\n", r); return 0; } |
看上去相當簡單明瞭。我解釋了操作符的優先順序——字尾操作比乘法先計算、乘法比加法先計算,並且乘法和加法的結合性都是從左到右,於是我抓出運算子號並開始寫出算式。
1 2 3 4 |
int r = 1 * a[i++] + 2 * a[i++] + 3 * a[i++]; // = a[0] + 2 * a[1] + 3 * a[2]; // = 10 + 40 + 90; // = 140 |
我自鳴得意地寫下答案後,我的同事回應了一個簡單的“不”。我想了幾分鐘後,還是被難住了。我不太記得字尾操作符的結合順序了。此外,我知道那個順序甚至不會改變這裡的值計算的順序,因為結合規則只會應用於同級的操作符之間。但我想到了應該根據字尾操作符都從右到左求值的規則,嘗試算一遍這條算式。看上去相當簡單明瞭。
1 2 3 4 |
int r = 1 * a[i++] + 2 * a[i++] + 3 * a[i++]; // = a[2] + 2 * a[1] + 3 * a[0]; // = 30 + 40 + 30; // = 100 |
我的同事再一次回答說,答案仍是錯的。這時候我只好認輸了,問他答案是什麼。這段短小的樣例程式碼原來是從他寫過的更大的程式碼段裡刪減出來的。為了驗證他的問題,他編譯並且執行了那個更大的程式碼樣例,但是驚奇地發現那段程式碼沒有按照他預想的執行。他刪減了不需要的步驟後得到了上面的樣例程式碼,用gcc 4.7.3編譯了這段樣例程式碼,結果輸出了令人吃驚的結果:“60”。
這時我被迷住了。我記得,C語言裡,函式引數的計算求值順序是未定義的,所以我們以為字尾操作符只是遵照某個隨機的、而非從左至右的順序,計算的。我們仍然確信字尾比加法和乘法擁有更高的操作優先順序,所以很快證明我們自己,不存在我們可以計算i++的順序,使得這三個陣列元素一起加起來、乘起來得到60。
現在我已對此入迷了。我的第一個想法是,檢視這段程式碼的反彙編程式碼,然後嘗試查出它實際上發生了什麼。我用除錯符號(debugging symbols)編譯了這段樣例程式碼,用了objdump後很快得到了帶註釋的x86_64反彙編程式碼。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
Disassembly of section .text: 0000000000000000 <main>: #include <stdio.h> int main(){ 0: 55 push %rbp 1: 48 89 e5 mov %rsp,%rbp 4: 48 83 ec 20 sub $0x20,%rsp int i = 0; 8: c7 45 e8 00 00 00 00 movl $0x0,-0x18(%rbp) int a[] = {10,20,30}; f: c7 45 f0 0a 00 00 00 movl $0xa,-0x10(%rbp) 16: c7 45 f4 14 00 00 00 movl $0x14,-0xc(%rbp) 1d: c7 45 f8 1e 00 00 00 movl $0x1e,-0x8(%rbp) int r = 1 * a[i++] + 2 * a[i++] + 3 * a[i++]; 24: 8b 45 e8 mov -0x18(%rbp),%eax 27: 48 98 cltq 29: 8b 54 85 f0 mov -0x10(%rbp,%rax,4),%edx 2d: 8b 45 e8 mov -0x18(%rbp),%eax 30: 48 98 cltq 32: 8b 44 85 f0 mov -0x10(%rbp,%rax,4),%eax 36: 01 c0 add %eax,%eax 38: 8d 0c 02 lea (%rdx,%rax,1),%ecx 3b: 8b 45 e8 mov -0x18(%rbp),%eax 3e: 48 98 cltq 40: 8b 54 85 f0 mov -0x10(%rbp,%rax,4),%edx 44: 89 d0 mov %edx,%eax 46: 01 c0 add %eax,%eax 48: 01 d0 add %edx,%eax 4a: 01 c8 add %ecx,%eax 4c: 89 45 ec mov %eax,-0x14(%rbp) 4f: 83 45 e8 01 addl $0x1,-0x18(%rbp) 53: 83 45 e8 01 addl $0x1,-0x18(%rbp) 57: 83 45 e8 01 addl $0x1,-0x18(%rbp) printf("%d\n", r); 5b: 8b 45 ec mov -0x14(%rbp),%eax 5e: 89 c6 mov %eax,%esi 60: bf 00 00 00 00 mov $0x0,%edi 65: b8 00 00 00 00 mov $0x0,%eax 6a: e8 00 00 00 00 callq 6f <main+0x6f> return 0; 6f: b8 00 00 00 00 mov $0x0,%eax } 74: c9 leaveq 75: c3 retq |
最先和最後的幾個指令只建立了堆疊結構,初始化變數的值,呼叫printf函式,還從main函式返回。所以我們實際上只需要關心從0x24到0x57之間的指令。那是令人關注的行為發生的地方。讓我們每次檢視幾個指令。
1 2 3 |
24: 8b 45 e8 mov -0x18(%rbp),%eax 27: 48 98 cltq 29: 8b 54 85 f0 mov -0x10(%rbp,%rax,4),%edx |
最先的三個指令與我們預期的一致。首先,它把i(0)的值載入到eax暫存器,帶符號擴充套件到64位,然後載入a[0]到edx暫存器。這裡的乘以1的運算(1*)顯然被編譯器優化後去除了,但是一切看起來都正常。接下來的幾個指令開始時也大致相同。
1 2 3 4 5 |
2d: 8b 45 e8 mov -0x18(%rbp),%eax 30: 48 98 cltq 32: 8b 44 85 f0 mov -0x10(%rbp,%rax,4),%eax 36: 01 c0 add %eax,%eax 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 2 3 4 |
3b: 8b 45 e8 mov -0x18(%rbp),%eax 3e: 48 98 cltq 40: 8b 54 85 f0 mov -0x10(%rbp,%rax,4),%edx 44: 89 d0 mov %edx,%eax |
接下來這些指令開始看上去相當熟悉。他們載入i的值(仍然是0),帶符號擴充套件至64位,載入a[0]到edx暫存器,然後拷貝edx裡的值到eax。嗯,好吧,讓我們在多看一些:
1 2 3 4 |
46: 01 c0 add %eax,%eax 48: 01 d0 add %edx,%eax 4a: 01 c8 add %ecx,%eax 4c: 89 45 ec mov %eax,-0x14(%rbp) |
在這裡把a[0]自加了3次,再加上之前的計算結果,然後存入到變數“r”。現在不可思議的事情——我們的變數r現在包含了a[0] + 2 * a[0] + 3 * a[0]。足夠肯定的是,那就是程式的輸出:“60”。但是那些字尾操作符上發生了什麼?他們都在最後:
1 2 3 |
4f: 83 45 e8 01 addl $0x1,-0x18(%rbp) 53: 83 45 e8 01 addl $0x1,-0x18(%rbp) 57: 83 45 e8 01 addl $0x1,-0x18(%rbp) |
看上去我們編譯版本的程式碼完全錯了!為什麼字尾操作符被扔到最底下、所有任務已經完成之後?隨著我對現實的信仰減少,我決定直接去找本源。不,不是編譯器的原始碼——那只是實現——我抓起了C11語言規範。
這個問題處在字尾操作符的細節。在我們的案例中,我們在單個表示式裡對陣列下標執行了三次字尾自增。當計算字尾操作符時,它返回變數的初始值。把新的值再分配回變數是一個副作用。結果是,那個副作用只被定義為只被付諸於各順序點之間。參照標準的5.1.2.3章節,那裡定義了順序點的細節。但在我們的例子中,我們的表示式展示了未定義行為。它完全取決於編譯器對於 什麼時候 給變數分配新值的副作用會執行 相對於表示式的其他部分。
最終,我倆都學到了一點新的C語言知識。眾所周知,最好的應用是避免構造複雜的字首字尾表示式,這就是一個關於為什麼要這樣的極好例子。