援引《C++ Primer(Fifth Edition)》4.1.3節(jié):
Order of operand evaluation is independent of precedence and associativity. In an
expression such as f() + g() * h() + j():
? Precedence guarantees that the results of g() and h() are multiplied.
? Associativity guarantees that the result of f() is added to the product of g() and h() and that the result of that addition is added to the value of j().
? There are no guarantees as to the order in which these functions are called.If f, g, h, and j are independent functions that do not affect the state of the same
objects or perform IO, then the order in which the functions are called is irrelevant. If
any of these functions do affect the same object, then the expression is in error and
has undefined behavior.
大意如下:
操作數(shù)的求職順序與運(yùn)算符的優(yōu)先級(jí)和結(jié)合律無(wú)關(guān)钱磅。在一個(gè)形如f() + g() * h() + j()的表達(dá)式中:
- 優(yōu)先級(jí)保證g()的返回值與h()的返回值相乘
- 結(jié)合律保證f()的返回值與g() * h()的結(jié)果相加可柿,并將結(jié)果與j()的返回值相加
- 但是沒(méi)有任何保證可以確定這些函數(shù)調(diào)用的順序
如果f,g,h,j既沒(méi)有共同關(guān)聯(lián)參數(shù)的(do affect the same object)盹沈,也不是輸入輸出系統(tǒng)(IO)函數(shù)哺哼,那么函數(shù)調(diào)用的順序彼此互不影響。如果其中的任何函數(shù)都引用了相同的對(duì)象,那么這個(gè)表達(dá)式就是錯(cuò)誤的。它會(huì)產(chǎn)生未定義的行為岖瑰。
我們看回題干:
printf("%c%c%c\n",*p++,*p++,*p++);
這里面的突出問(wèn)題在于
- 表達(dá)式狀態(tài)共享:
子表達(dá)式共享狀態(tài)p變量
導(dǎo)致一方的求值運(yùn)算會(huì)受另一方結(jié)果影響。
- 運(yùn)算對(duì)象求值順序不明:
C++中只有'&&', '||', ',', '?:' 這四個(gè)運(yùn)算符明確了其所屬運(yùn)算對(duì)象的求值順序砂代。
函數(shù)調(diào)用也是一種運(yùn)算符
而實(shí)參壓棧順序完全依賴于編譯器實(shí)現(xiàn)蹋订,三個(gè)*p++求值順序不明。
那么結(jié)合第一個(gè)問(wèn)題刻伊,假如從左向右壓棧結(jié)果就是123
如果換個(gè)編譯器可能順序又不同了
所有選項(xiàng)可能都能有幸成為正確答案
所以露戒,這種表達(dá)式是錯(cuò)誤的,會(huì)產(chǎn)生未定義的行為捶箱。
許多朋友都表達(dá)了一個(gè)廣泛存在的意識(shí)形態(tài):
函數(shù)的參數(shù)從右向左壓入調(diào)用棧
那么參數(shù)表達(dá)式的執(zhí)行順序自然是從右至左
這個(gè)說(shuō)法由來(lái)已久
并且有種“情不知所起 一往而深”的味道
大家(包括我)都是從各種語(yǔ)言書(shū)籍中看到的這種說(shuō)法
不得不說(shuō)這種意識(shí)形態(tài)荼毒甚廣
以至于我在幾篇回答中引用了《C++ Primer(Fifth Edition)》
仍然有不少胖友將信將疑
所以 私以為需要在此開(kāi)宗明義
徹徹底底地論述這個(gè)問(wèn)題的實(shí)質(zhì)
那么就從不同編譯器的調(diào)用約定(calling convention)說(shuō)起吧
讓我們先厘清什么是調(diào)用約定
以下摘自Wikipedia
In computer science, a calling convention is an implementation-level (low-level) scheme for how subroutines receive parameters from their caller and how they return a result. Differences in various implementations include where parameters, return values, return addresses and scope links are placed, and how the tasks of preparing for a function call and restoring the environment afterward are divided between the caller and the callee.
簡(jiǎn)單翻譯如下
在計(jì)算機(jī)科學(xué)領(lǐng)域智什,調(diào)用約定是一種編譯器級(jí)別的方案。這個(gè)方案規(guī)定了函數(shù)如何從它的調(diào)用方獲取實(shí)參以及如何返回函數(shù)結(jié)果丁屎。對(duì)于不同的編譯器而言荠锭,他們的調(diào)用約定的不同之處包括參數(shù)、返回值晨川、返回地址证九、域指針等的存儲(chǔ)位置,如何發(fā)起函數(shù)調(diào)用和調(diào)用結(jié)束后如何恢復(fù)共虑,以及調(diào)用方和被調(diào)方的任務(wù)如何劃分等愧怜。
大家困惑的參數(shù)壓棧順序也是調(diào)用約定的范疇,即上文所言“參數(shù)看蚜、返回值叫搁、返回地址赔桌、域指針等的存儲(chǔ)位置”與“如何發(fā)起函數(shù)調(diào)用和調(diào)用結(jié)束后如何恢復(fù)”
既然調(diào)用約定是編譯器級(jí)別的方案供炎,那么不同編譯器應(yīng)該就有不同的實(shí)現(xiàn)渴逻。
在此我把我研究過(guò)的常見(jiàn)的幾種編譯器的調(diào)用約定分別說(shuō)一下
編譯器一般都按照芯片的指令集進(jìn)行劃分
i386:
這種指令集對(duì)應(yīng)的是大家以前常用的32位Intel芯片
i386出生的時(shí)候寄存器還很少,不夠用
所以這廝在函數(shù)調(diào)用中的參數(shù)傳遞全靠壓棧
我們以如下函數(shù)調(diào)用為例
int bar(int i0, int i1, int i2, int i3, int i4, int i5, int i6, int i7, int i8, int i9) {return i0+i1+i2+i3+i4+i5+i6+i7+i8+i9;}
這個(gè)函數(shù)有10個(gè)參數(shù)
i386會(huì)從右至左將實(shí)參逐個(gè)壓入ESP
也就是它的棧幀
匯編表現(xiàn)為
push i9
push i8
...
push i1
push i0
call _bar
大家看的所謂語(yǔ)言書(shū)籍的作者當(dāng)年基本都是i386的使用者
這就是大家看到“壓棧順序從右至左”這一說(shuō)法的原因
X86_64:
原來(lái)壓棧方式的調(diào)用約定限制了函數(shù)調(diào)用的速度
因?yàn)閴簵S玫氖莾?nèi)存
這個(gè)時(shí)候世界飛速發(fā)展 摩爾定律潛移默化
芯片很快進(jìn)入了64位時(shí)代
寄存器的數(shù)量大大增加
X86_64開(kāi)始動(dòng)用部分寄存器來(lái)完成參數(shù)傳遞的工作
其中RDI, RSI, RDX, RCX, R8D, R9D寄存器分別用于
正序存儲(chǔ)第1至第6個(gè)實(shí)參
剩下的更多參數(shù)就采用老辦法
逆序壓入棧幀
還是以上文的函數(shù)例子
X86_64的匯編表現(xiàn)為
movq i0, %rdi
movq i1, %rsi
movq i2, %rdx
movq i3, %rcx
movq i4, %r8d
movq i5, %r9d
push i9
push i8
push i7
push i6
callq _bar
由此可見(jiàn)
到了64位
函數(shù)調(diào)用就不再是i386那樣式兒的一概從右至左壓棧了
而是當(dāng)參數(shù)少于6個(gè)時(shí)音诫,直接從左至右使用寄存器
參數(shù)太多時(shí)才會(huì)動(dòng)用堆棧
事實(shí)上這種取舍是非常合理的
因?yàn)榫幋a規(guī)范一般都會(huì)要求大家設(shè)計(jì)函數(shù)調(diào)用不要超過(guò)4個(gè)形參
大家平時(shí)使用的普通函數(shù)大都沒(méi)有或者只有一個(gè)形參
ARM:
ARM指令集的芯片大量用于移動(dòng)終端
大家的手機(jī)芯片大部分都是ARM架構(gòu)的
這里不帶數(shù)字的ARM默認(rèn)是32位的指令集
與Intel的區(qū)別在于ARM已經(jīng)使用寄存器來(lái)完成參數(shù)傳遞了
策略是前4個(gè)參數(shù)使用R0, R1, R2, R3來(lái)傳遞
多出的參數(shù)用堆棧
ARM64:
ARM64和X86_64很像
它使用了X0-X5存儲(chǔ)前6個(gè)參數(shù)惨奕,其他用堆棧
雖然不同指令集的編譯器所使用的匯編語(yǔ)言和寄存器名稱有些許出入
相信大家能夠理解
以上講解正是為了闡明一個(gè)道理
不同編譯器的調(diào)用約定是不同的
一定要樹(shù)立這個(gè)意識(shí)形態(tài)
盡信書(shū)不如無(wú)書(shū)
我以X86_64這個(gè)目前最流行的臺(tái)式計(jì)算機(jī)芯片指令集來(lái)舉例
看看題目中的語(yǔ)句會(huì)被編譯器翻譯成什么樣的匯編代碼
pushq %rbp
movq %rsp, %rbp
# 第一個(gè)參數(shù)是格式化字符串,即下面的傳給rdi寄存器
leaq L_.str(%rip), %rdi
# 以下分別是三次*p++竭钝,分別傳給rsi, rdx, rcx寄存器 movl $49, %esi
movl $50, %edx
movl $51, %ecx
xorl %eax, %eax
callq _printf
xorl %eax, %eax
popq %rbp
retq
.section __TEXT,__cstring,cstring_literals
L_.str: ## @.str
.asciz "%c%c%c\n"
由匯編代碼可知梨撞,我們?cè)赬86_64上根本不會(huì)用到調(diào)用棧
因?yàn)閰?shù)數(shù)量尚未超過(guò)6個(gè)
那就不會(huì)有所謂從右向左求值的說(shuō)法
接下來(lái)我們要深入探討調(diào)用約定的問(wèn)題
考察如下代碼
int foo0() {printf("%s\n", __PRETTY_FUNCTION__); return 0;}
int foo1() {printf("%s\n", __PRETTY_FUNCTION__); return 1;}
int foo2() {printf("%s\n", __PRETTY_FUNCTION__); return 2;}
int foo3() {printf("%s\n", __PRETTY_FUNCTION__); return 3;}
int foo4() {printf("%s\n", __PRETTY_FUNCTION__); return 4;}
int foo5() {printf("%s\n", __PRETTY_FUNCTION__); return 5;}
int foo6() {printf("%s\n", __PRETTY_FUNCTION__); return 6;}
int foo7() {printf("%s\n", __PRETTY_FUNCTION__); return 7;}
int foo8() {printf("%s\n", __PRETTY_FUNCTION__); return 8;}
int foo9() {printf("%s\n", __PRETTY_FUNCTION__); return 9;}
int main() {
printf("%d%d%d%d%d%d%d%d%d%d\n", foo0(), foo1(), foo2(), foo3(), foo4(), foo5(), foo6(), foo7(), foo8(), foo9());
}
大家不妨思考下10個(gè)fooX()函數(shù)最終的執(zhí)行順序如何?
以下是打印出來(lái)的實(shí)驗(yàn)結(jié)果
int foo0()
int foo1()
int foo2()
int foo3()
int foo4()
int foo5()
int foo6()
int foo7()
int foo8()
int foo9()
0123456789
也許這會(huì)出乎一些胖友的意料
畢竟上文描述X86_64先存寄存器再壓棧
那么是否foo6 - foo9應(yīng)該倒序執(zhí)行香罐?
讓我們看看匯編代碼
# 所有參數(shù)表達(dá)式中的函數(shù)調(diào)用已經(jīng)提前完成
leaq L___PRETTY_FUNCTION__._Z4foo0v(%rip), %rdi
callq _puts
leaq L___PRETTY_FUNCTION__._Z4foo1v(%rip), %rdi
callq _puts
leaq L___PRETTY_FUNCTION__._Z4foo2v(%rip), %rdi
callq _puts
leaq L___PRETTY_FUNCTION__._Z4foo3v(%rip), %rdi
callq _puts
leaq L___PRETTY_FUNCTION__._Z4foo4v(%rip), %rdi
callq _puts
leaq L___PRETTY_FUNCTION__._Z4foo5v(%rip), %rdi
callq _puts
leaq L___PRETTY_FUNCTION__._Z4foo6v(%rip), %rdi
callq _puts
leaq L___PRETTY_FUNCTION__._Z4foo7v(%rip), %rdi
callq _puts
leaq L___PRETTY_FUNCTION__._Z4foo8v(%rip), %rdi
callq _puts
leaq L___PRETTY_FUNCTION__._Z4foo9v(%rip), %rdi
callq _puts
subq $8, %rsp
# 編譯器將參數(shù)表達(dá)式的返回值分別傳入相應(yīng)的寄存器或棧幀
leaq L_.str.1(%rip), %rdi
movl $0, %esi
movl $1, %edx
movl $2, %ecx
movl $3, %r8d
movl $4, %r9d
movl $0, %eax
pushq $9
pushq $8
pushq $7
pushq $6
pushq $5
callq _printf
addq $48, %rsp
xorl %eax, %eax
popq %rbp
retq
.section __TEXT,__cstring,cstring_literals
L___PRETTY_FUNCTION__._Z4foo0v: ## @__PRETTY_FUNCTION__._Z4foo0v
.asciz "int foo0()"
L___PRETTY_FUNCTION__._Z4foo1v: ## @__PRETTY_FUNCTION__._Z4foo1v
.asciz "int foo1()"
L___PRETTY_FUNCTION__._Z4foo2v: ## @__PRETTY_FUNCTION__._Z4foo2v
.asciz "int foo2()"
L___PRETTY_FUNCTION__._Z4foo3v: ## @__PRETTY_FUNCTION__._Z4foo3v
.asciz "int foo3()"
L___PRETTY_FUNCTION__._Z4foo4v: ## @__PRETTY_FUNCTION__._Z4foo4v
.asciz "int foo4()"
L___PRETTY_FUNCTION__._Z4foo5v: ## @__PRETTY_FUNCTION__._Z4foo5v
.asciz "int foo5()"
L___PRETTY_FUNCTION__._Z4foo6v: ## @__PRETTY_FUNCTION__._Z4foo6v
.asciz "int foo6()"
L___PRETTY_FUNCTION__._Z4foo7v: ## @__PRETTY_FUNCTION__._Z4foo7v
.asciz "int foo7()"
L___PRETTY_FUNCTION__._Z4foo8v: ## @__PRETTY_FUNCTION__._Z4foo8v
.asciz "int foo8()"
L___PRETTY_FUNCTION__._Z4foo9v: ## @__PRETTY_FUNCTION__._Z4foo9v
.asciz "int foo9()"
L_.str.1: ## @.str.1
.asciz "%d%d%d%d%d%d%d%d%d%d\n"
代碼雖長(zhǎng) 但是邏輯還是比較清晰的
說(shuō)明了一個(gè)道理
參數(shù)中的函數(shù)調(diào)用完全是提前完成的
編譯器可以按照自有的順序來(lái)執(zhí)行
這里X86_64就是按照從左至右的順序執(zhí)行的
并沒(méi)有受 “函數(shù)結(jié)果是存儲(chǔ)于寄存器還是堆椢圆ǎ” 這個(gè)問(wèn)題的影響
也不需要等到傳入各自的寄存器或棧幀前 再忙不迭地執(zhí)行參數(shù)表達(dá)式
綜合上述
我以X86_64做了一些示例
這些例子給大家透露的提示就是
對(duì)于調(diào)用約定 每個(gè)編譯器都可能有其內(nèi)部實(shí)現(xiàn)
一些老舊書(shū)籍所言的函數(shù)調(diào)用參數(shù)傳遞執(zhí)行順序
很可能并未考慮所有編譯器的情況
即使考慮了不同編譯器的情況
然而卻忽略了
參數(shù)中表達(dá)式的執(zhí)行順序其實(shí)與傳參順序無(wú)關(guān) 這個(gè)事實(shí)
愿諸君能夠以嚴(yán)謹(jǐn)?shù)膽B(tài)度 找規(guī)范做實(shí)驗(yàn)去探究和求證所遇到的問(wèn)題