C 中的一个空循环。编译器是否生成了很多不必要的代码,或者我错过了什么?

An empty cycle in C. Does the compiler generate lots of unnecessary code or did I miss something?

我是 AVR 汇编语言的新手,因此决定查看用 C 编写的一个愚蠢的延迟函数的代码,看看一个带有长算术的空循环需要多长时间。

延时函数如下:

void delay(uint32_t cycles) {
    for (volatile uint32_t i = 0; i < cycles; i++) {}
}

我用objdump反汇编了它,我认为得到了一些奇怪的结果(见评论中的四个问题):

00000080 <delay>:
void delay (uint32_t cycles) {                  
; `cycles` is stored in r22..r25
  80:   cf 93           push    r28
  82:   df 93           push    r29
; First one: why does the compiler rcall the next position relative to the following
; two instructions? Some stack management?
  84:   00 d0           rcall   .+0             ; 0x86 <delay+0x6>
  86:   00 d0           rcall   .+0             ; 0x88 <delay+0x8>
  88:   cd b7           in      r28, 0x3d       ; 61
  8a:   de b7           in      r29, 0x3e       ; 62
  8c:   ab 01           movw    r20, r22
  8e:   bc 01           movw    r22, r24
; Now `cycles` is in r20..r23
    for (volatile uint32_t i = 0; i < cycles; i++) {}
; r1 was earlier initialized with zero by `eor r1, r1`
; `i` is in r24..r27
  90:   19 82           std     Y+1, r1 ; 0x01
  92:   1a 82           std     Y+2, r1 ; 0x02
  94:   1b 82           std     Y+3, r1 ; 0x03
  96:   1c 82           std     Y+4, r1 ; 0x04
  98:   89 81           ldd     r24, Y+1        ; 0x01
  9a:   9a 81           ldd     r25, Y+2        ; 0x02
  9c:   ab 81           ldd     r26, Y+3        ; 0x03
  9e:   bc 81           ldd     r27, Y+4        ; 0x04
  a0:   84 17           cp      r24, r20
  a2:   95 07           cpc     r25, r21
  a4:   a6 07           cpc     r26, r22
  a6:   b7 07           cpc     r27, r23
  a8:   a0 f4           brcc    .+40            ; 0xd2 <delay+0x52>, to location A
; location B:
; Third (yes, before the second) one: why does it load the registers each time after
; comparing the counter with the limit if `cp`, `cpc` do not change the registers?
  aa:   89 81           ldd     r24, Y+1        ; 0x01
  ac:   9a 81           ldd     r25, Y+2        ; 0x02
  ae:   ab 81           ldd     r26, Y+3        ; 0x03
  b0:   bc 81           ldd     r27, Y+4        ; 0x04
  b2:   01 96           adiw    r24, 0x01       ; 1
  b4:   a1 1d           adc     r26, r1
  b6:   b1 1d           adc     r27, r1
; Second one: why does it store and load the same registers with unchanged values?
; If it needs to store the registers, why does it load anyway? Does `std` change the
; source registers?
  b8:   89 83           std     Y+1, r24        ; 0x01
  ba:   9a 83           std     Y+2, r25        ; 0x02
  bc:   ab 83           std     Y+3, r26        ; 0x03
  be:   bc 83           std     Y+4, r27        ; 0x04
  c0:   89 81           ldd     r24, Y+1        ; 0x01
  c2:   9a 81           ldd     r25, Y+2        ; 0x02
  c4:   ab 81           ldd     r26, Y+3        ; 0x03
  c6:   bc 81           ldd     r27, Y+4        ; 0x04
  c8:   84 17           cp      r24, r20
  ca:   95 07           cpc     r25, r21
  cc:   a6 07           cpc     r26, r22
  ce:   b7 07           cpc     r27, r23
  d0:   60 f3           brcs    .-40            ; 0xaa <delay+0x2a>, to location B
}
; Location A:
; Finally, fourth one: so, under my first question it issued an `rcall` twice and now 
; just pops the return addresses to nowhere? Now the `rcall`s are double-strange
  d2:   0f 90           pop     r0
  d4:   0f 90           pop     r0
  d6:   0f 90           pop     r0
  d8:   0f 90           pop     r0
  da:   df 91           pop     r29
  dc:   cf 91           pop     r28
  de:   08 95           ret

毕竟,为什么它需要所有这些操作?

UPD

完整代码:

#include <avr/io.h>

void delay (uint32_t cycles)
{
    for (volatile uint32_t i = 0; i < cycles; i++) {}
}

int main(void)
{
    DDRD |= 1 << DDD2 | 1 << DDD3 | 1 << DDD4 | 1 << DDD5;
    PORTD |= 1 << PORTD2 | 1 << PORTD4;
    while (1) 
    {
        const uint32_t d = 1000000;
        delay(d);
        PORTD ^= 1 << PORTD2 | 1 << PORTD3;
        delay(d);
        PORTD ^= 1 << PORTD4 | 1 << PORTD5;
        delay(d);
        PORTD ^= 1 << PORTD3 | 1 << PORTD2;
        delay(d);
        PORTD ^= 1 << PORTD5 | 1 << PORTD4;
    }
}

编译器:gcc version 5.4.0 (AVR_8_bit_GNU_Toolchain_3.6.0_1734)

构建命令:

avr-gcc.exe  -x c -funsigned-char -funsigned-bitfields -DDEBUG  -I%inc_folder%  -O1 -ffunction-sections -fdata-sections -fpack-struct -fshort-enums -g2 -Wall -mmcu=atmega328p -B %atmega328p_folder% -c -std=gnu99 -MD -MP %sources, object files, etc%

回复关于延时功能的注意事项:

是的,我完全理解延迟函数的这种方法可能存在的问题,即时间不可预测和优化周期的风险。这只是一个自学例子,看看空循环编译成什么

不确定您使用的是什么编译器,但 Atmel Studio 下的 GCC 为其本机 delay 函数提供了以下内容。首先是我的C代码:

#define F_CPU 20000000
#include <util/delay.h>

int main(void)
{
    while (1) 
    {
        _delay_us(100);
    }
}

反汇编代码的结果部分:

    __builtin_avr_delay_cycles(__ticks_dc);
  f6:   83 ef           ldi r24, 0xF3   ; 243
  f8:   91 e0           ldi r25, 0x01   ; 1
  fa:   01 97           sbiw    r24, 0x01   ; 1
  fc:   f1 f7           brne    .-4         ; 0xfa <main+0x4>
  fe:   00 c0           rjmp    .+0         ; 0x100 <main+0xa>
 100:   00 00           nop
 102:   f9 cf           rjmp    .-14        ; 0xf6 <main>

这里我只延迟了 100 微秒,但如果我将它更改为 100 毫秒,我仍然不会像你的代码那么长:

    __builtin_avr_delay_cycles(__ticks_dc);
  f6:   2f e7           ldi r18, 0x7F   ; 127
  f8:   8a e1           ldi r24, 0x1A   ; 26
  fa:   96 e0           ldi r25, 0x06   ; 6
  fc:   21 50           subi    r18, 0x01   ; 1
  fe:   80 40           sbci    r24, 0x00   ; 0
 100:   90 40           sbci    r25, 0x00   ; 0
 102:   e1 f7           brne    .-8         ; 0xfc <main+0x6>
 104:   00 c0           rjmp    .+0         ; 0x106 <main+0x10>
 106:   00 00           nop
 108:   f6 cf           rjmp    .-20        ; 0xf6 <main>

结论:不确定为什么你的代码这么长,但如果你想要更紧凑的代码,并且你的编译器有一个内置的实现,请使用编译器的实现作为你如何执行这些延迟的模型。

首先,请注意使用这样一个繁忙的循环来编写延迟是不好的,因为时间将取决于您的编译器如何运行的细节。对于AVR平台,使用avr-libc和GCC提供的内置延时函数,详见JLH的回答。

两次 rcall 和四次 pops

通常,函数顶部的 rcall +0 指令是使函数运行次数加倍的简便方法。但是在这种情况下,我们可以看到 return 地址没有被 return 编辑,它们实际上是在函数末尾用四个 pop 指令从堆栈中删除的.

所以在函数的开头,编译器将四个字节添加到堆栈,在函数的结尾从堆栈中删除四个字节。这就是编译器为您的变量 i 分配存储空间的方式。由于 i 是一个局部变量,它通常存储在堆栈中。编译器优化可能允许将变量存储在寄存器中,但我认为 volatile 变量不允许这样的优化。这回答了你的第一个和第四个问题。

额外加载和存储

您将变量 i 标记为 volatile,这告诉编译器它不能对存储 i 的内存做出任何假设。每次您的代码读取或写入i,编译器必须对保存 i 的 RAM 位置生成真正的读取或写入;不允许进行您认为会进行的优化。这回答了你的第二个和第三个问题。

volatile关键字对于芯片上的特殊功能寄存器很有用,对于在主循环和中断之间共享的变量也很有用。