【问题标题】:How to "blend" two values without overflow?如何“混合”两个值而不会溢出?
【发布时间】:2019-02-09 02:50:58
【问题描述】:

考虑以下函数:

// Return a blended value of x and y:
//   blend(100, 200, 1, 1) -> 150
//   blend(100, 200, 2, 1) -> 133
uint8_t blend(uint8_t x, uint8_t y, uint8_t parts_x, uint8_t parts_y) {
  uint32_t big_parts_x = parts_x;
  uint32_t big_parts_y = parts_y;
  return (uint8_t) ((big_parts_x * x + big_parts_y * y) /
                    (big_parts_x + big_parts_y));
}

有没有办法让接近 到适当的返回值,而不需要任何大于uint8_t 的分配?您可以通过执行两个除法轻松地将其分解(减少舍入)为两个 uint16_t 的加法。你可以只用uint8_t吗?

【问题讨论】:

  • 所有操作数在计算完成之前都会被提升为int,如果int至少有4个字节,你的计算就不会溢出,所以我不用担心它。
  • 不幸的是,我的目标架构只有 8 位的操作,因此问题。我应该将其表述为“不需要对 uint8_t 以外的类型进行任何操作”。
  • “对我的目标架构进行 8 位操作”并不将 C 代码限制为仅 8 位整数数学。经典示例:第一台 PC 使用 8 位处理器,缺少 16 位或更宽的数学运算。

标签: c algorithm integer-overflow


【解决方案1】:

符合标准的 C 实现保证执行至少 16 位的算术运算。

C standard 的第 6.3.1.1p2 节指出:

可以在表达式中使用intunsigned int 时可以使用以下内容:

  • 具有整数类型(intunsigned int 除外)的对象或表达式,其整数转换等级小于 或等于intunsigned int的等级。
  • 类型的位域 _Boolintsigned int,或unsigned int

如果int 可以表示原始类型的所有值(如 受宽度限制,对于位域),值为 转换为 int ;否则,它将转换为 unsigned int 。这些称为整数促销。所有其他 整数提升不会改变类型。

第 E.1 节还规定 int 必须能够支持至少 -32767 到 32767 范围内的值,而 unsigned int 必须至少支持 0 到 65535 范围内的值。

由于uint8_t的等级低于int,所以当它是大多数运算符的主题时,前者总是会被提升为后者,包括+-*和@987654342 @。

鉴于此,您可以通过以下轻微修改安全地计算该值:

uint8_t blend(uint8_t x, uint8_t y, uint8_t parts_x, uint8_t parts_y) {
    return ((1u*parts_x*x) / (parts_x + parts_y)) + ((1u*parts_y*y) / (parts_x + parts_y));
}

表达式 parts_x*xparts_y*y 的最大值为 65025。这对于 16 位 int 而不是 16 位 unsigned int 来说太大了,所以每个都乘以 1u 来强制根据第 6.3.1.8 节中指定的通常的算术转换,要转换为 unsigned int 的值:

整数提升在两个操作数上执行。然后 以下规则适用于提升的操作数:

  • 如果两个操作数的类型相同,则无需进一步转换。
  • 否则,如果两个操作数都具有有符号整数类型或都具有无符号整数类型,则具有较小类型的操作数 整数转换等级转换为操作数的类型 排名更高。
  • 否则,如果具有无符号整数类型的操作数的等级大于或等于该类型的等级 其他操作数,则带符号整数类型的操作数为 转换为无符号整数的操作数类型 输入。

另请注意,我们将每个部分分别除以总和。如果我们在除法之前先将两个部分相加,则分子可能会超过 65535。通过先进行除法,这会使每个子表达式回到 uint8_t 的范围内。然后我们可以添加两个部分,它们将再次在uint8_t 的范围内。

因此,上面的表达式可以保证在符合 C 标准的编译器上返回正确的准确答案。

【讨论】:

    【解决方案2】:

    以下内容将在没有任何额外分配的情况下合并。
    即使int/unsigned 是 16 位也可以工作。

    return (uint8_t) ((1u*parts_x*x + 1u*parts_y*y) / (0u + parts_x + parts_y));
    

    【讨论】:

    • @Brendan 代码不使用int,而是使用unsigned。 OP 特别指的是“需要任何分配”。这里没有这样的中间对象。任何数学运算都会将操作数提升到unsigned/int,就像这里的unsigned一样。因此,除非 OP 不想使用任何操作数、+,-,*,/,%,&,^,|, ||,&&, [] 等,否则转换为 unsigned/int 必须是答案的一部分。
    • 语义。您的代码使用unsigned int,它绝对大于 8 位。我会假设(为了让这个问题完全有意义)代码是为某种 8 位微控制器编译的,除了 8 位整数之外不支持任何东西。
    • @Brendan 不是语义。 C 指定添加(减去,添加,...) 2 uint8_t,如在 x+ y 中使用至少 16 位数学(作为通常整数提升)。在 C 中,代码无法仅使用 8 位数学运算来执行 x+y - 总和至少为 16 位。处理器的本机位宽无关紧要 C 已托管在许多 8 位处理器上。即使是图灵机(1 位处理器)也可以进行至少 16 位宽的 C 加法。
    • C 指定所有代码的行为都“好像”遵循了规则。这意味着当添加 2 个 uint8_t 变量时,生成的代码可以忽略规则并进行 8 位加法(这恰好表现为“好像”变量被提升了)。
    • @chux 你能提供一个关于使用至少 16 位数学添加 uint8_t 的参考吗?我不怀疑你,但我不熟悉在哪里寻找它。
    【解决方案3】:

    有没有一种方法可以接近适当的返回值而不需要任何大于 uint8_t 的分配?

    理论上是的:

    uint8_t blend(uint8_t x, uint8_t y, uint8_t parts_x, uint8_t parts_y) {
      return lookup_table[x][y][parts_x][parts_y];
    }
    

    实际上,查找表需要 4 GiB 的 RAM,所以这可能不是一个好主意。

    除此之外,这取决于您所说的“接近”(“可接受的最坏情况错误”可以有多大)以及有效值的范围(尤其是对于parts_xparts_y)。

    例如(如果 parts_xparts_y 的范围仅为 1 到 15):

    uint8_t blend(uint8_t x, uint8_t y, uint8_t parts_x, uint8_t parts_y) {
      uint8_t scaleX = (parts_x << 4) / (parts_x + parts_y);
      uint8_t scaleY = (parts_y << 4) / (parts_x + parts_y);
    
      return (x >> 4) * scaleX + (y >> 4) * scaleY;
    }
    

    当然在这种情况下“关闭”意味着:

    • 混合(100、200、1、1)= 6*8 + 12*8 = 144(不是 150)
    • 混合(100、200、2、1)= 6*10 + 12*5 = 120(不是 133)

    请注意,(通常)乘法是“扩展”的。我的意思是,如果a 有 M 位范围,b 有 N 位范围,那么 a*b 将有 M+N 位范围。换句话说(使用全范围)以避免溢出uint8_t * uint8_t = uint16_t。除法明显差很多(比如要避免精度损失,1/3需要无限位),有些精度损失是无法避免的,结果中的位数决定了精度损失多少,8位精度“不多” .

    另外请注意,我上面展示的简单示例可以通过为这些情况添加额外代码来改进某些情况。例如:

    uint8_t blend(uint8_t x, uint8_t y, uint8_t parts_x, uint8_t parts_y) {
      if(parts_x < parts_y) {
         return blend(y, x, parts_y, parts_x);
      }
      // parts_x <= parts_y now
    
      if(parts_x == parts_y*2) {
          return 2*(x/3) + y/3;
      } else if(parts_x == parts_y*3) {
          return 3*(x/4) + y/4;
      } else if(parts_x == parts_y*4) {
          return 4*(x/5) + y/5;
      } else if(parts_x == parts_y*5) {
          return 5*(x/6) + y/6;
      } else if( (x > 16) && (y > 16) ){
          uint8_t scaleX = (parts_x << 4) / (parts_x + parts_y);
          uint8_t scaleY = (parts_y << 4) / (parts_x + parts_y);
    
          return (x * scaleX + y * scaleY) >> 4;
      } else {
          uint8_t scaleX = (parts_x << 4) / (parts_x + parts_y);
          uint8_t scaleY = (parts_y << 4) / (parts_x + parts_y);
    
          return (x >> 4) * scaleX + (y >> 4) * scaleY;
      }
    }
    

    当然,使用比uint8_t 更大的东西要容易得多,也快得多,所以...

    【讨论】:

    • (parts_x &lt;&lt; 4) / (parts_x + parts_y) 仍然使用至少 16 位数学作为加法的一部分。编译器不知道 parts_x, parts_y 的范围是 1...15,并将添加 parts_x + parts_y,因为它们是 int(16、32 位数学)。移位和除法也是如此。代码可以使用 (uint8_t)(parts_x &lt;&lt; 4) / (uint8_t)(parts_x + parts_y) 之类的东西来通知编译器中间项是 8 位的,然后编译器可能会发出 8 位代码。
    • @chux:是的,你是对的(编译器需要分析所有调用者以确定输入参数的真实范围)。
    猜你喜欢
    • 1970-01-01
    • 2021-11-23
    • 2018-06-05
    • 1970-01-01
    • 1970-01-01
    • 2015-01-01
    • 2023-04-07
    • 1970-01-01
    相关资源
    最近更新 更多