在C/ c++中无符号左移前的掩码太偏执了

Is masking before unsigned left shift in C/C++ too paranoid?

本文关键字:掩码 偏执 左移 c++ 无符号      更新时间:2023-10-16

这个问题的动机是我在C/c++中实现加密算法(例如SHA-1),编写可移植的平台无关代码,并彻底避免未定义行为。

假设一个标准化的加密算法要求你实现这个:

b = (a << 31) & 0xFFFFFFFF

,其中ab为32位无符号整数。请注意,在结果中,我们丢弃了最低有效位32位以上的所有位。


作为第一个朴素的近似,我们可以假设int在大多数平台上是32位宽,所以我们会写:

unsigned int a = (...);
unsigned int b = a << 31;

我们知道这个代码不会在任何地方工作,因为int在某些系统上是16位宽,在其他系统上是64位宽,甚至可能是36位宽。但是使用stdint.h,我们可以用uint32_t类型来改进这段代码:

uint32_t a = (...);
uint32_t b = a << 31;

我们做完了,对吧?这就是我多年来的想法. ...不完全是。假设在某个平台上,我们有:

// stdint.h
typedef unsigned short uint32_t;

在C/c++中执行算术运算的规则是,如果类型(如short)比int窄,则如果所有值都可以匹配,则将其扩展为int,否则将扩展为unsigned int

假设编译器定义short为32位(有符号),int为48位(有符号)。然后是这几行代码:

uint32_t a = (...);
uint32_t b = a << 31;

将有效地表示:

unsigned short a = (...);
unsigned short b = (unsigned short)((int)a << 31);

注意a被提升为int,因为所有ushort(即uint32)都适合int(即int48)。

但是现在我们有一个问题:将非零位左移到有符号整数类型的符号位是未定义的行为。出现这个问题是因为我们的uint32被提升到int48,而不是提升到uint48(左移是可以的)。


以下是我的问题:

  1. 我的推理是正确的吗?这在理论上是一个合理的问题吗?

  2. 这个问题是否可以安全忽略,因为在每个平台上下一个整数类型是宽度的两倍?

  3. 通过像这样预屏蔽输入来正确防御这种病理情况是一个好主意吗?: b = (a & 1) << 31;。(这在每个平台上都是正确的。但这可能会使速度关键型加密算法比必要时慢。

澄清/编辑:

  • 我会接受C或c++或两者的答案。我想知道至少一种语言的答案

  • 预屏蔽逻辑可能会影响位旋转。例如,GCC将b = (a << 31) | (a >> 1);编译成汇编语言中的32位旋转指令。但是,如果我们预先屏蔽左移,则有可能新的逻辑不会转换为位旋转,这意味着现在执行4个操作而不是1个。

说到C方面的问题,

  1. 我的推理正确吗?这在理论上是一个合理的问题吗?
这是一个我以前没有考虑过的问题,但是我同意你的分析。C语言根据提升的左操作数的类型定义了<<操作符的行为,当该操作数的原始类型为uint32_t时,整数提升导致(有符号的)int,这是可以想象的。我不期望在任何现代机器上看到这一点,但我完全赞成按照实际标准编程,而不是我个人的期望。
  • 这个问题是否可以安全忽略,因为在每个平台上,下一个整数类型是宽度的两倍?
  • C不需要整数类型之间的这种关系,尽管它在实践中普遍存在。然而,如果你决定只依赖标准——也就是说,如果你煞费苦心地编写严格符合标准的代码——那么你就不能依赖这样的关系。

  • 通过像这样预先屏蔽输入来正确防御这种病理情况是一个好主意吗?: b = (a &1) & lt; & lt;31;。(这在每个平台上都是正确的。但是这个可以使速度关键型加密算法比必要时慢。)
  • 类型unsigned long保证至少有32个值位,并且在整型提升下不会被提升为任何其他类型。在许多通用平台上,它与uint32_t具有完全相同的表示,甚至可能是相同的类型。因此,我倾向于这样写表达式:

    uint32_t a = (...);
    uint32_t b = (unsigned long) a << 31;
    

    或者如果只需要将a作为计算b的中间值,那么首先将其声明为unsigned long

    Q1:在移位之前屏蔽确实可以防止OP所关注的未定义行为。

    Q2:"……因为在每个平台上,下一个整数类型是宽度的两倍?"- - ->没有。"下一个"整数类型可以小于2x甚至相同的大小。

    以下是为所有具有uint32_t的兼容C编译器定义的。

    uint32_t a; 
    uint32_t b = (a & 1) << 31;
    

    Q3: uint32_t a; uint32_t b = (a & 1) << 31;不期望产生执行掩码的代码——在可执行文件中不需要——只在源代码中需要。如果确实发生了掩码,如果速度是一个问题,请使用更好的编译器。

    根据建议,最好强调这些移位的无符号性。

    uint32_t b = (a & 1U) << 31;
    

    @John Bollinger回答得好,详细说明了如何处理OP的具体问题。

    一般问题是如何形成一个至少有n位的数字,具有一定的符号,而不受意外整数提升的影响- OP困境的核心。下面的代码通过调用一个不改变值的unsigned操作来实现这一点——除了类型问题之外,实际上没有操作。该产品将至少 unsigneduint32_t的宽度。铸造,一般来说,可能会缩小类型。除非确定不会发生缩窄,否则需要避免铸型。优化编译器不会创建不必要的代码。
    uint32_t a;
    uint32_t b = (a + 0u) << 31;
    uint32_t b = (a*1u) << 31;
    

    从这个关于uint32 * uint32算法中可能的UB的问题中得到线索,下面的简单方法应该在C和c++中工作:

    uint32_t a = (...);
    uint32_t b = (uint32_t)((a + 0u) << 31);
    

    整型常数0u类型为unsigned int。这将促进将a + 0u添加到uint32_tunsigned int,以较宽的一个为准。由于该类型的等级为int或更高,因此不再进行提升操作,并且可以对左操作数uint32_tunsigned int进行移位操作。

    最后的转换回uint32_t只会抑制关于窄化转换的潜在警告(比如int是64位)。

    一个像样的C编译器应该能够看到添加零是一个无操作,这比看到预掩码在无符号移位后没有效果要少得多。

    为了避免不必要的提升,您可以使用greater类型和一些类型定义,如

    using my_uint_at_least32 = std::conditional_t<(sizeof(std::uint32_t) < sizeof(unsigned)),
                                                  unsigned,
                                                  std::uint32_t>;
    

    对于这段代码:

    uint32_t a = (...);
    uint32_t b = a << 31;
    

    a提升为无符号类型而不是有符号类型,使用:

    uint32_t b = a << 31u;
    

    如果<<操作符的两边都是unsigned类型,那么6.3.1.8 (C标准草案n1570)中的这一行适用:

    否则,如果两个操作数都是有符号整数类型,或者两个操作数都是无符号整数类型,则将整数转换排名较低的操作数转换为排名较高的操作数的类型。


    你所描述的问题是由于你使用了31而不是signed int type,所以6.3.1.8

    中的另一行

    否则,如果有符号整型操作数的类型可以表示无符号整型操作数类型的所有值,则将无符号整型操作数转换为有符号整型操作数的类型。

    强制将a提升为有符号类型


    更新:

    这个答案不正确,因为6.3.1.1(2)(强调我的):

    如果int可以表示原始类型的所有值(受限制)(对于位域),该值被转换为int;否则,将转换为unsigned int类型。这些被称为所有其他类型都不受整数的影响。促销 .

    和脚注58:

    58)整数提升只适用于:作为常规算术转换的一部分,特定参数表达式,单元+、-和~操作符的操作数,以及移位操作符的两个操作数,由它们各自的子句指定。

    由于只发生整数提升而不发生普通的算术转换,使用31u不能保证a如上所述转换为unsigned int