C 编译器如何在内存方面处理 post-增量 (i++)?

How does C compiler handle post-increment (i++), memory wise?

我一直在向朋友解释 i++++i 的细节。我告诉他 没有优化 for 循环中的 i++ 本质上意味着制作一个不用于任何用途的 i 副本。因为 i++ 可以用这个伪代码来描述:

tmp = i;
i = i + 1;
return tmp;

好吧,我注意到我真的不知道一件事:我们 tmp 的内存分配在哪里?它会增加整个 procedure/function 所需的内存大小吗? (也就是说,它在堆栈上吗?)

我想是的,但是如何测试呢? 当且仅当它重要时 我们正在谈论 C99 标准和 GCC 编译器。但我更喜欢更广泛的答案,以便对此事有一些看法。

从语义上讲,它是一个自动变量,它在内存中的位置是留给编译器的。 实际上,这取决于您是在谈论内置运算符还是用户定义的运算符、return 类型和您的 ABI。 例如,对于整数类型的内置增量,在 X64 CPU 上,整数的 return 值被放入寄存器中。所以变量不占用任何 space 并且根本不占用

对于原始类型,前缀与后缀仅影响增量步骤的顺序。比较 printf("%u", i++);printf("%u", ++i);。前者的伪汇编可能类似于(假设 i 已经在寄存器中):

load "%u" to arg1register
move i to arg2register
call printf
increment i  // Could occur before call to printf if arg2register separate from i

而对于后者,它只是重新排序增量步骤:

load "%u" to arg1register
increment i // Could occur at any point before this and after last use of i
move i to arg2register
call printf

for循环递增步骤中,即使关闭优化i++++i也是一样的,因为"result"未被使用;它不需要加载、移动等,所以它只是 increment i.

如果它不是基本类型,那么它会调用适当的 ++ 重载,并且由后缀 ++ 重载来制作副本(通常存储在堆栈中,像任何其他变量一样)在原地递增并返回副本之前。即使在 for 循环增量步骤中,它也不能保证后缀操作等同于前缀,因此除非进行极端的编译器优化(这可能不是标准合法的),对于非原始类型 i++ 将必须调用后缀运算符,不必要地创建和销毁临时文件。

这是低效的,这就是为什么在 C++ 中一直坚持使用前缀递增被认为是好的做法;如果您的代码从使用 int 更改为 mpz_class,您不希望在每次增量时都创建和销毁 mpz_class。由于前缀对基元是无害的,much 对用户定义的类型更好,所以只使用前缀,除非这样做会更丑陋(即使那样,只有当它是基元时)。

不需要额外的内存。使用 ++i 或 i++ i 的值存在于内存中,它被加载到处理器中,放入寄存器中, cpu 指令递增它(加法或递增指令取决于处理器),结果最终在一个寄存器中,然后结果被保存到我来自的内存中的相同位置。

++i 和 i++ 之间的区别实际上只是处理器指令的序列。 i++ 将确保在值递增之前使用初始值首先发生 i 的值用于其他任何事情。

如果该值未在其他地方使用,则生成的机器代码几乎没有区别。

您假设编译器总是为 ++ii++ 没有优化 产生不同的结果是错误的。下面看看 pre and post increment on godbolt, in gcc 6.2, no optimization:

C 代码

int pre() {
  int i = 0;
  ++i;
}

int post() {
  int i= 0;
  i++;
}

程序集 (x86-64)

pre():
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], 0
        add     DWORD PTR [rbp-4], 1
        nop
        pop     rbp
        ret

post():
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], 0
        add     DWORD PTR [rbp-4], 1
        nop
        pop     rbp
        ret

请注意,i++++i 的编译代码逐字节相同。两者都只是将 1 添加到堆栈上为 i 保留的内存位置。没有创建或不需要临时文件。

您可能会抱怨我实际上并没有使用递增表达式的值,所以让我们 look at something 实际使用该值:

C 代码

int pre(int i) {
  return ++i;
}

int post(int i) {
  return i++;
}

程序集 (x86-64)

pre(int):
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], edi
        add     DWORD PTR [rbp-4], 1
        mov     eax, DWORD PTR [rbp-4]
        pop     rbp
        ret

post(int):
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], edi
        mov     eax, DWORD PTR [rbp-4]
        lea     edx, [rax+1]
        mov     DWORD PTR [rbp-4], edx
        pop     rbp
        ret

在这里,装配是不同的。预自增版本使用内存RMW(read-modify-write)指令自增变量,而post自增版本通过edx单独自增变量。虽然查看未优化的代码总是徒劳的,但我很确定 post-increment 版本在这里 faster,因为依赖链更小,因为关键路径中没有 RMW 指令和后续存储转发停顿。

要注意的是,即使在这里也没有在内存中分配 "temporary space" - 只有程序集发生变化,并且寄存器(eax 这里)免费用作在 post 增量之前 i 的值。

当然,您不应该真正将 任何内容 读入未优化的代码中。它不会在实践中使用,您无法通过研究它真正了解任何结构的效率,因为优化后的代码在不同的习语中会有很大差异。

所以最后,让我们看看a realistic example我们使用优化的地方,both增量表达式和底层变量的值都被实际使用了(所以两者都不是值被优化掉了)。这里我们在每个函数中对 i 进行 int & 引用,以便修改传入的值。我们使用 -O2,尽管我尝试的所有其他优化级别都会产生相同的结果,除了 -Os:

C 代码

int pre(int& i) {
  return ++i;
}

int post(int& i) {
  return i++;
}

程序集 (x86-64)

pre(int&):
        mov     eax, DWORD PTR [rdi]
        add     eax, 1
        mov     DWORD PTR [rdi], eax
        ret

post(int&):
        mov     eax, DWORD PTR [rdi]
        lea     edx, [rax+1]
        mov     DWORD PTR [rdi], edx
        ret 

两种功能的成本几乎完全相同。它们具有相同数量的指令,并且在现代英特尔硬件上以相同的成本产生相同数量的 uops(4 个融合域)。这些函数占用的指令字节数完全相同1.

post-增量的不同之处在于它使用 lea 指令将其结果放入 edx,因此 eax 保持不变,因为 return 值。预增量版本只是对所有内容使用 eaxedx 的使用在这里没有直接开销,因为它在 x86-64 中是一个临时寄存器,所以不需要保存它的值。在更复杂的代码中,使用另一个寄存器 可能会 增加寄存器压力,尽管这种可能性很小,因为生命周期非常短并且有更多重新排序的机会。

post-increment 版本实际上对 return 值有一个更小的依赖链——假设调用者使用 eax 中的 return 值,它将需要1 个额外的周期使其准备就绪(因为 add eax, 1 是依赖链的一部分)。这实际上是预增量定义中固有的:在某种程度上,预增量较慢,因为值的增量和后续使用必须连续发生,而在post-increment case 它们可以并行发生,因为值的使用不依赖于增量操作。当然,这种影响非常小——通常不会超过一个周期。使用预增量的经典建议可能仍然适用,因为对于对象,它可以产生 big 差异。对于原语,没那么多。


1有趣的是,预增量版本可以用 inc eax 而不是 add eax, 1 实现,这在现代硬件上可能和保存一个字节。这可能不是因为由于部分标志停顿而避免 incdec 的大部分过时建议。事实上,使用 -Os(针对大小进行优化)gcc 确实 在此处使用 inc