函数调用约定

援引《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)函数,那么函数调用的顺序彼此互不影响。如果其中的任何函数都引用了相同的对象,那么这个表达式就是错误的。它会产生未定义的行为。


我们看回题干:

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

这里面的突出问题在于

  1. 表达式状态共享:

子表达式共享状态p变量

导致一方的求值运算会受另一方结果影响。

  1. 运算对象求值顺序不明:

C++中只有'&&', '||', ',', '?:' 这四个运算符明确了其所属运算对象的求值顺序。

函数调用也是一种运算符

而实参压栈顺序完全依赖于编译器实现,三个*p++求值顺序不明。

那么结合第一个问题,假如从左向右压栈结果就是123

如果换个编译器可能顺序又不同了

所有选项可能都能有幸成为正确答案

所以,这种表达式是错误的,会产生未定义的行为。

许多朋友都表达了一个广泛存在的意识形态:

函数的参数从右向左压入调用栈

那么参数表达式的执行顺序自然是从右至左

这个说法由来已久

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

大家(包括我)都是从各种语言书籍中看到的这种说法

不得不说这种意识形态荼毒甚广

以至于我在几篇回答中引用了《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出生的时候寄存器还很少,不够用

所以这厮在函数调用中的参数传递全靠压栈

我们以如下函数调用为例

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

也就是它的栈帧

汇编表现为

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的汇编表现为

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这个目前最流行的台式计算机芯片指令集来举例

看看题目中的语句会被编译器翻译成什么样的汇编代码

    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个

那就不会有所谓从右向左求值的说法

接下来我们要深入探讨调用约定的问题

考察如下代码

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做了一些示例

这些例子给大家透露的提示就是

对于调用约定 每个编译器都可能有其内部实现

一些老旧书籍所言的函数调用参数传递执行顺序

很可能并未考虑所有编译器的情况

即使考虑了不同编译器的情况

然而却忽略了

参数中表达式的执行顺序其实与传参顺序无关 这个事实

愿诸君能够以严谨的态度 找规范做实验去探究和求证所遇到的问题

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269

推荐阅读更多精彩内容

  • 原文地址:C语言函数调用栈(一)C语言函数调用栈(二) 0 引言 程序的执行过程可看作连续的函数调用。当一个函数执...
    小猪啊呜阅读 4,505评论 1 19
  • 关于 C/C++ 函数调用约定,大多数时候并不会影响程序逻辑,但遇到跨语言编程时,了解一下还是有好处的。 VC 中...
    王守伟阅读 2,112评论 0 2
  • 函数调用约定 在C语言中,假设我们有这样的一个函数: int function(int a,int b) 调用时只...
    罗蓁蓁阅读 582评论 0 4
  • 我们都是 我们都是没人要的 野孩子 在草地、雪山 自由生长 我们都是“坏”孩子 我们无人取代 你属于孤独 我即是寂...
    散夏无泪阅读 143评论 0 2
  • 我无奈的想放弃尊严的抵抗,给麦子打电话的时候,那个前凸后翘的前台却扭着个屁股朝我走来。 想当年,我刚进公司的时候,...
    你有一条未读信息阅读 395评论 0 0