編譯器和出棧壓棧寄存器對printf參數輸出的不同影響

鏈接:https://www.nowcoder.com/questionTerminal/c64aadd25ceb4789bfd404819704855d
來源:牛客網
 

題目的寫法事實上是有嚴重安全隱患的

 

援引《C++ Primer(Fifth Edition)》4.1.3節:

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.

大意如下:
操作數的求職順序與運算符的優先級和結合律無關。在一個形如f() + g() * h() + j()的表達式中:

  • 優先級保證g()的返回值與h()的返回值相乘
  • 結合律保證f()的返回值與g() * h()的結果相加,並將結果與j()的返回值相加
  • 但是沒有任何保證可以確定這些函數調用的順序

如果f,g,h,j既沒有共同關聯參數的(do affect the same object),也不是輸入輸出系統(IO)函數,那麼函數調用的順序彼此互不影響。如果其中的任何函數都引用了相同的對象,那麼這個表達式就是錯誤的。它會產生未定義的行爲。


 

我們看回題幹:

1

printf("%c%c%c\n",*p++,*p++,*p++);

這裏面的突出問題在於

 

1. 表達式狀態共享:

子表達式共享狀態p變量

導致一方的求值運算會受另一方結果影響。

 

2. 運算對象求值順序不明:

C++中只有'&&', '||', ',', '?:' 這四個運算符明確了其所屬運算對象的求值順序。

函數調用也是一種運算符

而實參壓棧順序完全依賴於編譯器實現,三個*p++求值順序不明。

那麼結合第一個問題,假如從左向右壓棧結果就是123

如果換個編譯器可能順序又不同了

所有選項可能都能有幸成爲正確答案

 

所以,這種表達式是錯誤的,會產生未定義的行爲。

這種題目真的不該再出。

 

----

 

2017.10.24 更

今天上來瞅一眼評論

看到GondorFu的回覆

想起其實在自己其他回答中也有不少類似的評論

這些都表達了一個廣泛存在的意識形態:

函數的參數從右向左壓入調用棧

那麼參數表達式的執行順序自然是從右至左

這個說法由來已久

並且有種“情不知所起 一往而深”的味道

大家(包括我)都是從各種語言書籍中看到的這種說法

不得不說這種意識形態荼毒甚廣

以至於我在幾篇回答中引用了《C++ Primer(Fifth Edition)》

仍然有不少胖友將信將疑

所以 私以爲需要在此開宗明義

徹徹底底地論述這個問題的實質

 

那麼就從不同編譯器的調用約定(calling convention)說起吧

讓我們先釐清什麼是調用約定

以下摘自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.

 

簡單翻譯如下

在計算機科學領域,調用約定是一種編譯器級別的方案。這個方案規定了函數如何從它的調用方獲取實參以及如何返回函數結果。對於不同的編譯器而言,他們的調用約定的不同之處包括參數、返回值、返回地址、域指針等的存儲位置,如何發起函數調用和調用結束後如何恢復,以及調用方和被調方的任務如何劃分等。

 

大家困惑的參數壓棧順序也是調用約定的範疇,即上文所言“參數、返回值、返回地址、域指針等的存儲位置”與“如何發起函數調用和調用結束後如何恢復”

既然調用約定是編譯器級別的方案,那麼不同編譯器應該就有不同的實現。

在此我把我研究過的常見的幾種編譯器的調用約定分別說一下

編譯器一般都按照芯片的指令集進行劃分

 

i386:

這種指令集對應的是大家以前常用的32位Intel芯片

i386出生的時候寄存器還很少,不夠用

所以這廝在函數調用中的參數傳遞全靠壓棧

我們以如下函數調用爲例

1

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;}

這個函數有10個參數

i386會從右至左將實參逐個壓入ESP

也就是它的棧幀

彙編表現爲

1

2

3

4

5

6

push i9

push i8

...

push i1

push i0

call _bar

大家看的所謂語言書籍的作者當年基本都是i386的使用者

這就是大家看到“壓棧順序從右至左”這一說法的原因

 

X86_64:

原來壓棧方式的調用約定限制了函數調用的速度

因爲壓棧用的是內存

這個時候世界飛速發展 摩爾定律潛移默化

芯片很快進入了64位時代

寄存器的數量大大增加

X86_64開始動用部分寄存器來完成參數傳遞的工作

其中RDI, RSI, RDX, RCX, R8D, R9D寄存器分別用於

正序存儲第1至第6個實參

剩下的更多參數就採用老辦法

逆序壓入棧幀

還是以上文的函數例子

X86_64的彙編表現爲

1

2

3

4

5

6

7

8

9

10

11

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

由此可見

到了64位

函數調用就不再是i386那樣式兒的一概從右至左壓棧了

而是當參數少於6個時,直接從左至右使用寄存器

參數太多時纔會動用堆棧

事實上這種取捨是非常合理的

因爲編碼規範一般都會要求大家設計函數調用不要超過4個形參

大家平時使用的普通函數大都沒有或者只有一個形參

 

ARM:

ARM指令集的芯片大量用於移動終端

大家的手機芯片大部分都是ARM架構的

這裏不帶數字的ARM默認是32位的指令集

與Intel的區別在於ARM已經使用寄存器來完成參數傳遞了

策略是前4個參數使用R0, R1, R2, R3來傳遞

多出的參數用堆棧

 

ARM64:

ARM64和X86_64很像

它使用了X0-X5存儲前6個參數,其他用堆棧

 

雖然不同指令集的編譯器所使用的彙編語言和寄存器名稱有些許出入

相信大家能夠理解

以上講解正是爲了闡明一個道理

不同編譯器的調用約定是不同的

一定要樹立這個意識形態

盡信書不如無書

 

我以X86_64這個目前最流行的臺式計算機芯片指令集來舉例

看看題目中的語句會被編譯器翻譯成什麼樣的彙編代碼

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

    pushq    %rbp

    movq    %rsp, %rbp

# 第一個參數是格式化字符串,即下面的傳給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"

由彙編代碼可知,我們在X86_64上根本不會用到調用棧

因爲參數數量尚未超過6個

那就不會有所謂從右向左求值的說法

 

接下來我們要深入探討調用約定的問題

考察如下代碼

1

2

3

4

5

6

7

8

9

10

11

12

13

14

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個fooX()函數最終的執行順序如何?

 

以下是打印出來的實驗結果

int foo0()

int foo1()

int foo2()

int foo3()

int foo4()

int foo5()

int foo6()

int foo7()

int foo8()

int foo9()

0123456789

 

也許這會出乎一些胖友的意料

畢竟上文描述X86_64先存寄存器再壓棧

那麼是否foo6 - foo9應該倒序執行?

讓我們看看彙編代碼

# 所有參數表達式中的函數調用已經提前完成
    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
# 編譯器將參數表達式的返回值分別傳入相應的寄存器或棧幀
    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"

代碼雖長 但是邏輯還是比較清晰的

說明了一個道理

參數中的函數調用完全是提前完成的

編譯器可以按照自有的順序來執行

這裏X86_64就是按照從左至右的順序執行的

並沒有受 “函數結果是存儲於寄存器還是堆棧” 這個問題的影響

也不需要等到傳入各自的寄存器或棧幀前 再忙不迭地執行參數表達式

 

綜合上述

我以X86_64做了一些示例

這些例子給大家透露的提示就是

對於調用約定 每個編譯器都可能有其內部實現

一些老舊書籍所言的函數調用參數傳遞執行順序

很可能並未考慮所有編譯器的情況

即使考慮了不同編譯器的情況

然而卻忽略了

參數中表達式的執行順序其實與傳參順序無關 這個事實

願諸君能夠以嚴謹的態度 找規範做實驗去探究和求證所遇到的問題

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章