C語(yǔ)言未定義行為一覽
幾周前,我的一位同事帶著一個(gè)編程問(wèn)題來(lái)到我桌前。最近我們一直在互相考問(wèn)C語(yǔ)言的知識(shí),所以我微笑著鼓起勇氣面對(duì)無(wú)疑即將到來(lái)的地獄。
他在白板上寫(xiě)了幾行代碼,并問(wèn)這個(gè)程序會(huì)輸出什么?
- #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;
- }
看上去相當(dāng)簡(jiǎn)單明了。我解釋了操作符的優(yōu)先順序——后綴操作比乘法先計(jì)算、乘法比加法先計(jì)算,并且乘法和加法的結(jié)合性都是從左到右,于是我抓出運(yùn)算符號(hào)并開(kāi)始寫(xiě)出算式。
- int r = 1 * a[i++] + 2 * a[i++] + 3 * a[i++];
- // = a[0] + 2 * a[1] + 3 * a[2];
- // = 10 + 40 + 90;
- // = 140
我自鳴得意地寫(xiě)下答案后,我的同事回應(yīng)了一個(gè)簡(jiǎn)單的“不”。我想了幾分鐘后,還是被難住了。我不太記得后綴操作符的結(jié)合順序了。此外,我知道那個(gè)順 序甚至 不會(huì)改變這里的值計(jì)算的順序,因?yàn)榻Y(jié)合規(guī)則只會(huì)應(yīng)用于同級(jí)的操作符之間。但我想到了應(yīng)該根據(jù)后綴操作符都從右到左求值的規(guī)則,嘗試算一遍這條算式。看上去 相當(dāng)簡(jiǎn)單明了。
- int r = 1 * a[i++] + 2 * a[i++] + 3 * a[i++];
- // = a[2] + 2 * a[1] + 3 * a[0];
- // = 30 + 40 + 30;
- // = 100
我的同事再一次回答說(shuō),答案仍是錯(cuò)的。這時(shí)候我只好認(rèn)輸了,問(wèn)他答案是什么。這段短小的樣例代碼原來(lái)是從他寫(xiě)過(guò)的更大的代碼段里刪減出來(lái)的。為了驗(yàn) 證他的問(wèn)題,他編譯并且運(yùn)行了那個(gè)更大的代碼樣例,但是驚奇地發(fā)現(xiàn)那段代碼沒(méi)有按照他預(yù)想的運(yùn)行。他刪減了不需要的步驟后得到了上面的樣例代碼,用gcc 4.7.3編譯了這段樣例代碼,結(jié)果輸出了令人吃驚的結(jié)果:“60”。
這時(shí)我被迷住了。我記得,C語(yǔ)言里,函數(shù)參數(shù)的計(jì)算求值順序是未定義的,所以我們以為后綴操作符只是遵照某個(gè)隨機(jī)的、而非從左至右的順序,計(jì)算的。 我們?nèi)匀淮_信后綴比加法和乘法擁有更高的操作優(yōu)先級(jí),所以很快證明我們自己,不存在我們可以計(jì)算i++的順序,使得這三個(gè)數(shù)組元素一起加起來(lái)、乘起來(lái)得到 60。
現(xiàn)在我已對(duì)此入迷了。我的***個(gè)想法是,查看這段代碼的反匯編代碼,然后嘗試查出它實(shí)際上發(fā)生了什么。我用調(diào)試符號(hào)(debugging symbols)編譯了這段樣例代碼,用了objdump后很快得到了帶注釋的x86_64反匯編代碼。
- 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
***和***的幾個(gè)指令只建立了堆棧結(jié)構(gòu),初始化變量的值,調(diào)用printf函數(shù),還從main函數(shù)返回。所以我們實(shí)際上只需要關(guān)心從0×24到0×57之間的指令。那是令人關(guān)注的行為發(fā)生的地方。讓我們每次查看幾個(gè)指令。
- 24: 8b 45 e8 mov -0x18(%rbp),%eax
- 27: 48 98 cltq
- 29: 8b 54 85 f0 mov -0x10(%rbp,%rax,4),%edx
***的三個(gè)指令與我們預(yù)期的一致。首先,它把i(0)的值加載到eax寄存器,帶符號(hào)擴(kuò)展到64位,然后加載a[0]到edx寄存器。這里的乘以1的運(yùn)算(1*)顯然被編譯器優(yōu)化后去除了,但是一切看起來(lái)都正常。接下來(lái)的幾個(gè)指令開(kāi)始時(shí)也大致相同。
- 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
***個(gè)mov指令把i的值(仍然是0)加載進(jìn)eax寄存器,帶符號(hào)擴(kuò)展到64位,然后加載a[0]進(jìn)eax寄存器。有意思的事情發(fā)生了——我們?cè)俅?期待 i++在這三條指令之前已經(jīng)運(yùn)行過(guò)了,但也許***兩條指令會(huì)用某種匯編的魔法來(lái)得到預(yù)期的結(jié)果(2*a[1])。這兩條指令把eax寄存器的值自加了一 次,實(shí)際上執(zhí)行了2*a[0]的操作,然后把結(jié)果加到前面的計(jì)算結(jié)果上,并存進(jìn)ecx寄存器。此時(shí)指令已經(jīng)求得了a[0] + 2 * a[0]的值。事情開(kāi)始看起來(lái)有一些奇怪了,然而再一次,也許某個(gè)編譯器魔法在發(fā)生。
- 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
接下來(lái)這些指令開(kāi)始看上去相當(dāng)熟悉。他們加載i的值(仍然是0),帶符號(hào)擴(kuò)展至64位,加載a[0]到edx寄存器,然后拷貝edx里的值到eax。嗯,好吧,讓我們?cè)诙嗫匆恍?/p>
- 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(bǔ)[0]自加了3次,再加上之前的計(jì)算結(jié)果,然后存入到變量“r”。現(xiàn)在不可思議的事情——我們的變量r現(xiàn)在包含了a[0] + 2 * a[0] + 3 * a[0]。足夠肯定的是,那就是程序的輸出:“60”。但是那些后綴操作符上發(fā)生了什么?他們都在***:
- 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)
看上去我們編譯版本的代碼完全錯(cuò)了!為什么后綴操作符被扔到***下、所有任務(wù)已經(jīng)完成之后?隨著我對(duì)現(xiàn)實(shí)的信仰減少,我決定直接去找本源。不,不是編譯器的源代碼——那只是實(shí)現(xiàn)——我抓起了C11語(yǔ)言規(guī)范。
這個(gè)問(wèn)題處在后綴操作符的細(xì)節(jié)。在我們的案例中,我們?cè)趩蝹€(gè)表達(dá)式里對(duì)數(shù)組下標(biāo)執(zhí)行了三次后綴自增。當(dāng)計(jì)算后綴操作符時(shí),它返回變量的初始值。把新 的值再分配回變量是一個(gè)副作用。結(jié)果是,那個(gè)副作用只被定義為只被付諸于各順序點(diǎn)之間。參照標(biāo)準(zhǔn)的5.1.2.3章節(jié),那里定義了順序點(diǎn)的細(xì)節(jié)。但在我們 的例子中,我們的表達(dá)式展示了未定義行為。它完全取決于編譯器對(duì)于 什么時(shí)候 給變量分配新值的副作用會(huì)執(zhí)行 相對(duì)于表達(dá)式的其他部分。
最終,我倆都學(xué)到了一點(diǎn)新的C語(yǔ)言知識(shí)。眾所周知,***的應(yīng)用是避免構(gòu)造復(fù)雜的前綴后綴表達(dá)式,這就是一個(gè)關(guān)于為什么要這樣的極好例子。
原文鏈接:http://blog.chris-cole.net/2013/11/30/a-glimpse-of-undefined-behavior-in-c/






















