V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
• 请不要在回答技术问题时复制粘贴 AI 生成的内容
amiwrong123
V2EX  ›  程序员

同样是 return 一个中间变量,为什么另一种代码的反汇编分配了两次空间?

  •  
  •   amiwrong123 · 2019-07-14 17:07:20 +08:00 · 3136 次点击
    这是一个创建于 1994 天前的主题,其中的信息可能已经有所发展或是发生改变。

    总所周知,函数体返回值那里没有&,就会返回一个中间变量。

    class sale {
    public:
    	int i = 1;
    };
    
    sale add(const sale& lift, const sale& right) {
    	sale sum = lift;
    	sum.i += right.i;
    	return sum;
    }
    
    int main()
    {
    	sale one;
    	sale two;
    	const sale& global = add(one, two);
    }
    

    假设有如上代码,进行反汇编后,汇编如下:

    	const sale& global = add(one, two);
    00D51A32 8D 45 E8             lea         eax,[two]  
    00D51A35 50                   push        eax  
    00D51A36 8D 4D F4             lea         ecx,[one]  
    00D51A39 51                   push        ecx  
    00D51A3A E8 40 F9 FF FF       call        add (0D5137Fh)  
    00D51A3F 83 C4 08             add         esp,8  #调用完毕后,清除栈空间
    00D51A42 89 85 04 FF FF FF    mov         dword ptr [ebp-0FCh],eax  #为中间变量分配空间
    00D51A48 8B 95 04 FF FF FF    mov         edx,dword ptr [ebp-0FCh]  
    00D51A4E 89 55 D0             mov         dword ptr [ebp-30h],edx  #把中间变量复制给新的变量
    00D51A51 8D 45 D0             lea         eax,[ebp-30h]  #导入新变量的地址
    00D51A54 89 45 DC             mov         dword ptr [global],eax  #把这个地址给 global,因为引用本质是指针
    

    上面写的注释不一定对,但为什么这里要分配两次空间呢? 如果我换一个简单的程序:

    int re() {
    	return 5;
    }
    
    int main()
    {
    	//int a = 1;
    	const int& b = re();
    }
    

    他的汇编就和我想象中一样了,只分配一次空间:

    	const int& b = re();
    00B019B2 E8 0E FA FF FF       call        re (0B013C5h)  
    00B019B7 89 45 E8             mov         dword ptr [ebp-18h],eax  #分配空间
    00B019BA 8D 45 E8             lea         eax,[ebp-18h]  #把地址导入 eax
    00B019BD 89 45 F4             mov         dword ptr [b],eax #把 eax 赋值给 b,因为 b 是引用,相当于指针
    
    36 条回复    2019-07-15 20:25:05 +08:00
    lhx2008
        1
    lhx2008  
       2019-07-14 17:15:02 +08:00
    我猜,关掉编译器优化,反汇编之后结果就一样了
    ipwx
        2
    ipwx  
       2019-07-14 17:23:04 +08:00
    首先,楼主你两段代码都是不合法的。

    对于不合法的代码,C++ 编译器没有义务给你吐出合理的结果。
    stephen9357
        3
    stephen9357  
       2019-07-14 17:23:24 +08:00
    debug 编译的代码,编译器怎么顺手怎么来,别当真。甚至用 IDA 看系统自带的 release 动态库时,也遇到过类似情况,编译器毕竟不能保证每一行代码编译后都是最优的,能看明白就可以了。
    amiwrong123
        4
    amiwrong123  
    OP
       2019-07-14 17:36:22 +08:00
    @lhx2008
    用得是 vs2017,找了找,在当前项目的什么设置里面,找到了“优化”,里面有什么内联函数拓展、启动内部函数什么的,但基本都是关着的。

    @ipwx
    const int& b = re();原来这种用法是不合法的吗?有点没懂啊,我知道如果返回局部变量的引用,这种情况是不合法的,虽然编译器只是报个 warning。


    @stephen9357
    原来是这样的啊。确实大概能看明白,比如函数体返回值那里有没有&(返回的是不是引用),会体现到汇编上去。虽然分配了两次空间,但可能就是编译器没优化好呗。
    thedrwu
        5
    thedrwu  
       2019-07-14 17:36:33 +08:00
    首先,不能这样写。你让变量活在哪里?

    至于楼主的问题, 对向返回的是一个地址. 最后到 edx 里的是地址的地址,即 %ebp-30h 这个数字。中间变量没毛病。
    至于第二段, 不是地址的地址, 而只有一层地址。

    没仔细看,仅供参考
    akira
        6
    akira  
       2019-07-14 17:47:02 +08:00
    第一个是类吧 类应该是需要 2 个指针来表达的
    hoyixi
        7
    hoyixi  
       2019-07-14 17:49:56 +08:00
    'lift' and right
    amiwrong123
        8
    amiwrong123  
    OP
       2019-07-14 17:57:34 +08:00
    @thedrwu
    好吧,首先是不是,引用绑定到返回的中间变量,这种写法就是错的吗

    然后,我又改了一下,改成 sale global = add(one, two);汇编就变成了:
    00B019D2 8D 45 E8 lea eax,[two]
    00B019D5 50 push eax
    00B019D6 8D 4D F4 lea ecx,[one]
    00B019D9 51 push ecx
    00B019DA E8 F0 F9 FF FF call add (0B013CFh)
    00B019DF 83 C4 08 add esp,8
    00B019E2 89 85 10 FF FF FF mov dword ptr [ebp-0F0h],eax
    00B019E8 8B 95 10 FF FF FF mov edx,dword ptr [ebp-0F0h]
    00B019EE 89 55 DC mov dword ptr [global],edx
    好像跟是不是对象没关系,只用一个地址就好了。
    哎,我是不是有点钻牛角尖了,但是又有点好奇。

    @akira
    你看上面的汇编,好像跟是不是对象没关系啊。

    @hoyixi
    哈哈哈,一时手滑啦
    aliwalker
        9
    aliwalker  
       2019-07-14 18:15:24 +08:00
    我用 clang 编译了一下第一段,发现没有写把 add 返回值写两次内存的操作...

    100000f5a: 48 8d 7d f8 leaq -8(%rbp), %rdi # &one
    100000f5e: 48 8d 75 f0 leaq -16(%rbp), %rsi # &two
    100000f62: e8 a9 ff ff ff callq -87 <__Z3addRK4saleS1_> # call add
    100000f67: 31 c9 xorl %ecx, %ecx # 清零
    100000f69: 89 45 e0 movl %eax, -32(%rbp) # 返回值存到临时变量
    100000f6c: 48 8d 75 e0 leaq -32(%rbp), %rsi # 指针
    100000f70: 48 89 75 e8 movq %rsi, -24(%rbp) # 指针值存到 global
    100000f74: 89 c8 movl %ecx, %eax # 返回值为 0
    100000f76: 48 83 c4 20 addq $32, %rsp
    100000f7a: 5d popq %rbp
    100000f7b: c3 retq

    用 const 引用返回值是可以的,这个临时变量在 call site 的 frame 上是有分配空间的。如果改成 sale &global = add(one, two);就不行了:initial value of reference to non-const must be an lvalue。

    第二段结果是一样的,只是生成的是 x64 机器码。
    aliwalker
        10
    aliwalker  
       2019-07-14 18:21:56 +08:00
    补充一下,从第二段反汇编出来的内容可以看到为什么不是 const 引用不行:

    _main:
    100000f90: 55 pushq %rbp
    100000f91: 48 89 e5 movq %rsp, %rbp
    100000f94: 48 83 ec 10 subq $16, %rsp
    100000f98: e8 e3 ff ff ff callq -29 <__Z2rev>
    100000f9d: 31 c9 xorl %ecx, %ecx
    100000f9f: 89 45 f4 movl %eax, -12(%rbp)
    100000fa2: 48 8d 55 f4 leaq -12(%rbp), %rdx
    100000fa6: 48 89 55 f8 movq %rdx, -8(%rbp)
    100000faa: 89 c8 movl %ecx, %eax
    100000fac: 48 83 c4 10 addq $16, %rsp
    100000fb0: 5d popq %rbp
    100000fb1: c3 retq

    返回的 int 是 4bytes,写在-12(%rbp)上,但是指针 b 的位置-8(%rbp)其实和这个返回的 temp 值重合。
    zjsxwc
        11
    zjsxwc  
       2019-07-14 18:33:46 +08:00 via Android
    难道没人和我一样奇怪
    sum 在栈里的内容在函数结束时会不会被释放吗
    ipwx
        12
    ipwx  
       2019-07-14 18:43:05 +08:00
    @amiwrong123 不合法的理由,见 5L 和 11L 的疑惑。
    ipwx
        13
    ipwx  
       2019-07-14 18:45:31 +08:00
    另外我大概理解楼主为什么要写不合法代码的理由了,是想研究 C++ 返回值地址的问题嘛?

    但是 C++ 编译器会根据返回值的赋值进行代码优化的。

    比如:

    class A { ... };

    A someFunction() { A a; return a; }

    A target = someFunction();

    在深度优化的时候不会发生拷贝,直接在返回值 target 上调用构造函数。
    zjsxwc
        14
    zjsxwc  
       2019-07-14 18:46:42 +08:00 via Android
    In C++, unlike in C#, struct makes few differences with class. A struct is a class whose default visibility is public. Whether the allocation is performed on the stack or in the heap depends on the way you allocate your instance

    class A;

    void f()
    {
    A a;//stack allocated
    A *a1 = new A();// heap
    }
    zjsxwc
        15
    zjsxwc  
       2019-07-14 18:48:55 +08:00 via Android
    @zjsxwc 楼主的 sum 内存会被释放
    aliwalker
        16
    aliwalker  
       2019-07-14 18:50:15 +08:00 via Android
    @ipwx yep. Return value optimization. 是 copy elision 的一种
    lcdtyph
        17
    lcdtyph  
       2019-07-14 20:27:46 +08:00   ❤️ 1
    @ipwx #12
    @thedrwu #5

    两个都是合法的,临时变量在绑定到常引用之后生命周期会被延长,参见 https://en.cppreference.com/w/cpp/language/lifetime

    在 c++11 右值出现之前都是这样做的。
    amiwrong123
        18
    amiwrong123  
    OP
       2019-07-14 20:50:15 +08:00
    @aliwalker
    既然你用 clang 编译没有出现两次写内存,那可能我的编译器的问题吧。
    然后你的第二段,我看了,它把返回值 int 存在了-12,-11,-10,-9 这四个字节里,然后把地址存在了-8,-7,...,-1,没有什么重合啊感觉。所以没理解,“为什么不是 const 引用不行”。
    amiwrong123
        19
    amiwrong123  
    OP
       2019-07-14 20:57:03 +08:00
    @ipwx
    是啊,你说的差不多。主要把,我是想看看 return 到底是怎么 return 的,有几次内存拷贝,这样。
    还有最后你说的这个深度优化,意思懂啦,相当于直接 A target;
    amiwrong123
        20
    amiwrong123  
    OP
       2019-07-14 20:59:17 +08:00
    @zjsxwc
    你们都说,内存会被释放掉。那么我打印 global 的值的时候,肯定就是非法值呗。
    class sale {
    public:
    int i = 1;
    };

    sale add(const sale& lift, const sale& right) {
    sale sum = lift;
    sum.i += right.i;
    return sum;
    }

    int main()
    {
    sale one;
    sale two;
    const sale& global = add(one, two);
    cout << global.i;
    }
    执行这个代码,我发现还是能打印出来 2 啊,也不是什么非法值。
    nethard
        21
    nethard  
       2019-07-14 21:20:09 +08:00 via iPhone
    哈哈,楼主要是这么喜欢这样写,返回一个栈上的指针,可以去写 go 玩玩。
    lcdtyph
        22
    lcdtyph  
       2019-07-14 21:25:17 +08:00
    @amiwrong123 #18
    clang 有个参数 -fno-elide-constructors 可以让编译器不做 rvo,这样在你第一种代码里一定会分配两次内存。默认是会做 rvo 的。
    visual studio 的编译器不知道有没有类似的选项……
    ispinfx
        23
    ispinfx  
       2019-07-14 21:27:50 +08:00 via iPhone
    好奇那么多说不合法的…这不是常引用最常见的用法吗
    lcdtyph
        24
    lcdtyph  
       2019-07-14 21:32:57 +08:00
    还有实际上 lz 的情况在 c++17 中一定不会产生临时对象,这是 c++17 新的 guaranteed copy elision 特性来保证的。
    amiwrong123
        25
    amiwrong123  
    OP
       2019-07-14 21:50:42 +08:00
    @lcdtyph
    看到啦,在 Temporary object lifetime 的 There are two exceptions from that:,我这个属于 const lvalue reference。

    你说的-fno-elide-constructors,这又是我的知识盲区了。。。看了看博客,意思就是会省略一些中间变量的创建,因为两次拷贝和一次拷贝效果是一样的。但为啥你说,我第一种代码里一定会分配两次内存,不是默认是会优化的吗? vs2017 里面好像没有这个选项。。

    guaranteed copy elision,又是一个知识点,我拿小本本记上。
    amiwrong123
        26
    amiwrong123  
    OP
       2019-07-14 22:00:39 +08:00
    @nethard
    也不是喜欢这么写,只是在测试想把各种情况测试一遍,带不带 const,带不带&,这样


    @ispinfx
    开始这个用法对不对还真拿不准,然后就很疑惑。不过看了 17 楼的文档里写了这种用法。
    lcdtyph
        27
    lcdtyph  
       2019-07-14 22:02:31 +08:00 via iPhone
    @amiwrong123
    这个选项是强制在可以省略临时对象的情况下调用拷贝构造,所以打开这个选项就相当于没了返回值优化,就会造成一次额外的拷贝
    ispinfx
        28
    ispinfx  
       2019-07-14 22:36:11 +08:00 via iPhone
    @amiwrong123 这个我记得以前上谭 cpp primer 里就有说的吧,虽然我已不写 cpp10 年了。。
    karia
        29
    karia  
       2019-07-14 22:39:29 +08:00 via Android
    《程序员的自我修养》内存那一章(11 章?忘了)讲到过这个问题

    return 栈上大型结构的时候,caller 的 stack frame 里会分配 2 个临时空间,一个隐式的用来保存返回值,返回之后再 memcpy 给你的显式声明的 global

    另外提醒楼主...乐于尝试是不错,但是不要钻牛角尖了...孔夫子曰过思而不学则殆...多看书,或许很多问题别人已经研究过了
    lrxiao
        30
    lrxiao  
       2019-07-15 07:39:35 +08:00
    在 debug 模式看汇编 yy 分配空间... 也可能只是中间变量没优化而已..
    你需要的是写个 destructor 然后看调用次数..
    lrxiao
        31
    lrxiao  
       2019-07-15 07:45:49 +08:00
    这两个都是 copy elision/rvo 后的结果了 并没有额外的 constructor/destructor 介入
    14m3
        32
    14m3  
       2019-07-15 09:25:58 +08:00
    1. 楼主,以后可以在 https://godbolt.org/(一个网页版的交互式编译器)上面来测试,可以选择不同的编译器,开启不同的编译选项,也能很方便看到汇编代码
    2. 前面 @lcdtyph 已经说过了,const lvalue reference 会延长临时对象的生命周期,所以问题中的代码是合法的
    3. 其实讨论这个代码,是需要确定讨论的环境的,是 C++11/14,还是 C++17。因为 C++17 标准中添加了 Guaranteed Copy Elision,重新定义了 value category 中 prvalues 的语义
    4. 我自己测试了一下 https://godbolt.org/z/NRKI-R,在 C++17 标准下,不管 main 函数中是 const sale& global = add(one, two); 还是 const sale global = add(one, two); 问题中的代码都是调用了三次构造函数,前两次构造函数是 sale one; 和 sale two;,第三次构造函数是拷贝构造。在其他标准下,楼主可以自己测试.
    amiwrong123
        33
    amiwrong123  
    OP
       2019-07-15 13:50:13 +08:00
    @lcdtyph
    好吧,懂啦,看来这个编译器选项也挺重要啊。

    @ispinfx
    我就正在 primer 呢,但看得断断续续,我是因为看到书中某页的一句话“返回的是 sum 的副本”,但没细说,然后就开始想这个问题。
    amiwrong123
        34
    amiwrong123  
    OP
       2019-07-15 14:08:34 +08:00
    @karia
    这本书买了,还没看。的确很多问题别人已经研究过了,所以一般有问题我都是先看网上博客啥的

    @lrxiao
    你说,写个 destructor 然后看调用次数,这个挺好使啊。我在 32 楼给的链接里看到了调用析构的次数了。
    你意思这两段代码都是已经被优化了的呗
    amiwrong123
        35
    amiwrong123  
    OP
       2019-07-15 14:12:02 +08:00
    @14m3
    哇,你这个链接是个神器,回头好好研究下。确实,这个代码怎么优化的跟环境有关系啊。你这个代码而且写得很清楚,加了构造和析构的打印后,思路瞬间清晰了。
    14m3
        36
    14m3  
       2019-07-15 20:25:05 +08:00
    @amiwrong123 嗯嗯,互相学习 :)
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2727 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 26ms · UTC 08:54 · PVG 16:54 · LAX 00:54 · JFK 03:54
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.