为什么在C和C++模式下,gcc的右移代码不同?

10

当ARM gcc 9.2.1使用命令行选项-O3 -xc++ -mcpu=cortex-m0 [以C ++编译]并使用以下代码:

unsigned short adjust(unsigned short *p)
{
    unsigned short temp = *p;
    temp -= temp>>15;
    return temp;
}

它生成合理的机器码:

    ldrh    r0, [r0]
    lsrs    r3, r0, #15
    subs    r0, r0, r3
    uxth    r0, r0
    bx      lr

等同于:

unsigned short adjust(unsigned short *p)
{
    unsigned r0,r3;
    r0 = *p;
    r3 = temp >> 15;
    r0 -= r3;
    r0 &= 0xFFFFu;   // Returning an unsigned short requires...
    return r0;       //  computing a 32-bit unsigned value 0-65535.
}

很合理。在这种情况下,最后的“uxtw”实际上可以省略,但对于那些无法证明此类优化的安全性的编译器来说,更好的选择是谨慎一些,以避免返回0-65535范围外的值,这可能会完全破坏下游代码。

然而,在使用-O3 -xc -mcpu=cortex-m0 [除了编译为C而不是C ++之外,选项相同]时,代码将发生变化:

    ldrh    r3, [r0]
    movs    r2, #0
    ldrsh   r0, [r0, r2]
    asrs    r0, r0, #15
    adds    r0, r0, r3
    uxth    r0, r0
    bx      lr

unsigned short adjust(unsigned short *p)
{
    unsigned r0,r2,r3;
    r3 = *p;
    r2 = 0;
    r0 = ((unsigned short*)p)[r2];
    r0 = ((int)r0) >> 15;  // Effectively computes -((*p)>>15) with redundant load
    r0 += r3
    r0 &= 0xFFFFu;     // Returning an unsigned short requires...
    return temp;       //  computing a 32-bit unsigned value 0-65535.
}

我知道在C和C++中,左移操作的定义边界不同,但我认为右移操作是相同的。在C和C++中,是否有一些关于右移操作方式的不同之处会导致编译器使用不同的代码来处理它们?在早期版本中,在C模式下生成的代码会稍微好一些(不过现在已经更新到9.2.1版本了):

    ldrh    r3, [r0]
    sxth    r0, r3
    asrs    r0, r0, #15
    adds    r0, r0, r3
    uxth    r0, r0
    bx      lr

等同于:

unsigned short adjust(unsigned short *p)
{
    unsigned r0,r3;
    r3 = *p;
    r0 = (short)r3;
    r0 = ((int)r0) >> 15; // Effectively computes -(temp>>15)
    r0 += r3
    r0 &= 0xFFFFu;     // Returning an unsigned short requires...
    return temp;       //  computing a 32-bit unsigned value 0-65535.
}

相比于9.2.1版本不那么糟糕,但仍比直接翻译代码要长。在使用9.2.1时,将参数声明为unsigned short volatile *p可以消除冗余的p加载,但我很好奇为什么gcc 9.2.1需要一个volatile限定符来帮助它避免冗余加载,或者为什么这种奇怪的“优化”只发生在C模式下而不是C ++模式下。我还有些好奇,为什么gcc甚至考虑添加((short)temp) >> 15而不是减去temp >> 15。是否有某个优化阶段会认为这是有意义的呢?


我觉得很奇怪的是C代码添加了移位后的值,而不是像源代码意图那样减去它。 - Mark Ransom
@supercat:不是很确定为什么,但如果你将“temp”更改为“unsigned int”,编译器会生成相同的“合理机器代码”。 - clyne
@clyne:当然,有其他编写函数的方式可以产生更好的机器代码,但是这里要求编译器做的并不难。如果一种“优化”使函数比代码的直接翻译更大更慢,请不要尝试应用它。 - supercat
@supercat:在编译器浏览器中查看此代码(https://godbolt.org/z/FeNER7),使用Tree/RTL Viewer,可以看出在C++下,temp被提升为一个int进行右移,而在C语言下,temp只被提升为一个signed short。也许C编译版本无法像这样优化得那么好,因为这可能是C和C++之间整数提升/转换的差异引起的? - clyne
1
你可以尝试一些-fopt-info选项,以获取更深入的了解优化器正在做什么,而不是猜测。 - chris
显示剩余8条评论
1个回答

3

这种差异似乎是由于GCC的C和C++编译模式在积分提升temp方面存在差异。

在Compiler Explorer上使用“Tree/RTL Viewer”可以观察到,当代码被编译为C++时,GCC将temp提升为一个int进行右移操作。然而,在编译为C时,temp只被提升为一个signed short (查看godbolt):

使用-xc++选项的GCC树:

{
  short unsigned int temp = *p;

  # DEBUG BEGIN STMT;
    short unsigned int temp = *p;
  # DEBUG BEGIN STMT;
  <<cleanup_point <<< Unknown tree: expr_stmt
  (void) (temp = temp - (short unsigned int) ((int) temp >> 15)) >>>>>;
  # DEBUG BEGIN STMT;
  return <retval> = temp;
}

使用-xc

{
  short unsigned int temp = *p;

  # DEBUG BEGIN STMT;
    short unsigned int temp = *p;
  # DEBUG BEGIN STMT;
  temp = (short unsigned int) ((signed short) temp >> 15) + temp;
  # DEBUG BEGIN STMT;
  return temp;
}

当将temp左移少于15个比特时,强制转换为signed short仅在显式进行时才会发生;当移位小于15位时,强制转换会消失,并且代码将编译成与-xc++产生的“合理”指令相匹配。如果使用unsigned char并向左移7个比特,则也会出现意外行为。

有趣的是,armv7-a clang不会产生相同的行为;-xc-xc++都会产生“合理”的结果:

    ldrh    r0, [r0]
    sxth    r0, r0
    lsrs    r1, r0, #15
    adds    r0, r1, r0
    uxth    r0, r0
    bx      lr

更新:看起来这种“优化”要么是由于字面值15,要么是由于使用减法(或一元的-)与右移:

  • 将字面值15放在一个unsigned short变量中会导致-xc-xc++都生成合理的指令。
  • temp>>15替换为temp/(1<<15)也会导致两个选项生成合理的指令。
  • 将移位改为temp>>(-65521)会导致两个选项生成更长的算术移位版本,-xc++还会在移位内对temp进行有符号短整数类型强制转换。
  • 将负数移开移位操作(temp = -temp + temp>>15; return -temp;)会导致两个选项生成合理的指令。

请参见这些Godbolt 实例。我同意 @supercat 的观点,这可能只是as-if 规则的奇怪情况。我从中得出的结论是要么避免使用非常量的无符号减法,要么根据这篇关于 int 提升的 SO 帖子,不要试图将算术强制转换为小于int的存储类型。


这是GCC优化器的一个bug吗?我在C标准中没有看到允许将类型转换为(signed short)。请注意,当15被显式转换为无符号短整型时,同样的问题似乎也会发生。 - Jérôme Richard
@JérômeRichard:这个替换是将(temp>>15)替换为-((signed short)(temp >> 15)) [注意负号]。在C语言中,根据as-if规则,这种替换是允许的,而在C++中可能也是如此,但需要考虑运算符重载的情况,这可能会使得应用相同逻辑更加困难。 - supercat
我刚刚发现另一个有趣的事情:即使在-O0模式下,gcc 10.1的x86-64版本无论是在C模式还是C++模式下,都会将该操作转换为算术右移位值的减法,但是先前的版本9.3仅在C模式下进行更改。这仍然让我感到困惑的是,在编译的这一点上为什么要应用这种转换,因为它会降低性能,而且很可能不值得实施。 - supercat
也许有一些平台可以将数字的最高位变为0或1比进行右移更快,但这种转换应作为特定于平台的优化的一部分应用。 - supercat

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接