C#算术运算正负溢出的区别

Distinction between positive and negative overflow in C# arithmetic operation

我正在 C#/.NET 中的检查范围内对整数执行算术运算,以便在发生溢出时捕获。我想以一种简短、智能、简单的方式找出溢出是正数还是负数,而不需要根据操作数或操作进行大量特殊情况和检查。

checked
{
    try
    {
        // The code below is an example operation that will throw an
        // overflow exception, that I expect to be a positive overflow.
        // In my real code, all arithmetic operations are included in this
        // code block and can result in both positive and negative overflows.
        int foo = int.MaxValue;
        int bar = 1;
        foo += bar;
    }
    catch (OverflowException)
    {
        // I have found out that a overflow occurred,
        // but was it positive or negative?
    }
}

可以吗?我在异常本身中没有找到可用于查找的信息。

TL;DR:

I want to find out if the overflow was positive or negative, in a short, smart, simple way, without a lot of special cases and checks depending on the operands or operation.

你不能:C# 不公开该信息,因为:

  • CPU今天不容易检测溢出方向。
  • 虽然 可以完成 ,但需要必要的步骤来检查 CPU 的状态 post-moterm 将破坏现代超标量处理器的性能。
  • 另一种方法是在执行任何算术之前执行 safety-checks,但这也会破坏性能。
  • 而这只是针对 x86/64 的。 .NET CLR 现在支持的可能有十几种截然不同的 CPU ISA,并且必须处理它们自己的所有 overflow/carry/sign 特性,以确保 C# 程序的行为都相同且正确地符合您提议的“ checked-with-overflow-direction”是不可行的。
  • 知道算术溢出的方向没有多大价值。重要的是系统检测到发生了溢出,这意味着您的代码中存在错误,您需要 go-fix: 并且因为您需要重现该问题作为正常调试实践的一部分,这意味着您将能够详细跟踪执行并捕获每个值和状态,这是您纠正导致溢出的任何潜在问题所需的全部 - 同时了解溢出方向的次要细节在正常 运行 时间执行有助于...如何?
    • (这是一个反问:我不认为它有任何显着帮助,甚至可能 red-herring 只会浪费你的时间)

更长的答案:

问题一:CPUs不关心溢出的方向

一些背景知识:pretty-much 今天每个微处理器都有这些 special-function-registers (aka CPU flags, aka status registers which are often similar to these 4 in ARM:

当然,CS-theoretical ALU(执行算术的位)的基本设计是整数运算是相同的,无论它们是否'有符号或无符号,正或负(例如,减法是负操作数的加法),并且标志 本身 不会自动发出错误信号(例如,无符号的溢出标志被忽略算术,而 carry-flag 实际上在有符号算术中不如无符号算术重要。

(这个 post 不会解释它们代表什么或它们如何工作,因为我假设 you,我的博学 reader, is already familiar with the basic fundamentals of computer integer arithmetic)

现在,您可能假设在 C#/.NET 程序的 checked 块中,本机机器代码将检查这些 CPU 在 each-and-every 算术运算之后进行标记,以查看紧接的前一个运算是否具有 signed-overflow 或意外的 bit-carry - 如果是,则将 call/jump 中的信息传递给 CLR创建并抛出 OverflowException.

的内部函数

...并且在某种程度上就是这样,除了有用的信息实际上很少可以从CPU。原因如下:

  • 在 x86/x64 上的 C# checked 块中,CLR 的 JIT 在每个可能溢出的算术指令之后插入一条 x86/x64 jo [CORINFO_HELP_OVERFLOW] 指令。
    • You can see it in this Godbolt example.
    • CORINFO_HELP_OVERFLOWthe native function JIT_Overflow 的地址,它(最终)调用 RealCOMPlusThrowWorker 来抛出 OverflowException
      • 注意jo指令只能告诉我们设置了溢出标志:它不会暴露或揭示任何其他 CPU 标志的状态,也不是指令操作数的符号,因此 不能使用 jo 指令 来判断溢出是否(到使用您的术语)“负溢出”或“正溢出”。

      • 因此,如果程序需要的信息不仅仅是“it overflowed, Jim”,它将需要使用 CPU 指令,save/copy CPU 的其余部分] 标志状态进入内存,如果这些标志不足以确定溢出的 方向 那么 JIT 编译器还必须保留所有算术操作数的副本 in-memory在某处,in-practice 意味着显着增加堆栈 space 或浪费 CPU 寄存器保存旧值,在算术运算成功之前你不想删除这些旧值。

      • ...不幸的是,用于将 CPU 标志复制到内存或其他寄存器的 CPU 指令往往会破坏整体系统性能:

        • 考虑一下现代 CPU 设计的绝对复杂性lar,推测和 out-of-order 执行,以及其他整洁的小发明:modern CPUs work best when programs follow a predictable "happy path" 没有使用太多笨拙的指令 mess-around 和 CPU 的内部状态。因此,将程序更改为更具内省性不仅会损害您自己程序的性能,还会损害整个计算机系统。哎呀

          • This comment from Rust contributor Tom-Phinney summarizes the situation well:

            Instruction-level access to a "carry bit", so that the value can be used as an input to a subsequent instruction, was trivial to implement in the early days of computing when each instruction was completed before the next instruction was begun.

            For a modern, out-of-order, superscalar processor implementation that cost/benefit is reversed; the cost in gates and/or instruction-cycle slowdown of the "carry-bit feature" far, far outweighs any possible benefit. That is why RISC-V, which is a state-of-the-art computer architecture whose expected implementations span the range from embedded processors of 10k gate complexity (e.g., RV32EC) to superscalar processors with 100x more gates, does not materialize an instruction-stream-synchronous carry bit.

问题 2:异质性

  • .NET CLR 表面上是可移植的:.NET 必须 运行 在每个 Windows 支持的平台上,以及微软 [=260= 突发奇想的其他平台] 和 D-levels:今天 运行 在 x86/x64,different varieties of ARM (including Apple Silicon), and in the past Itanicum, while the XNA build ran on the Xbox 360's PowerPC chip, and the Compact Framework supported SH-3/SH-4, MIPS, and I'm sure dozens others. Oh, and don't forget how Silverlight had its own edition of the CLR, which ultimately became the basis for .NET Core and now .NET 5 - which replaced .NET Framework 4.x - and Silverlight also ran on PowerPC back in 2007

  • 或者以列表形式,官方 .NET CLR 实现支持的所有 ISA 的 off-the-top-of-my-head 列表...我可以想到:

    • x86/x64
    • ARM / ARM-Thumb
    • SH-3(紧凑型框架)
    • SH-4(紧凑型框架)
    • MIPS(紧凑型框架)
    • PowerPC(PPC 上的 Silverlight 1.0 Mac,Xbox 360 上的 XNA)
    • 安腾 IA-64
  • 这是一个不错的选择 - 我肯定还有其他的我已经忘记了,更不用说 Mono 支持的所有平台了。

  • 所有这些 processors/ISAs 有什么共同点?好吧,他们都有自己不同的处理整数溢出的方法 - 有时非常不同

    • 例如,某些 ISA(如 MIPS)在溢出时引发硬件异常(如 divide-by-zero)而不是设置标志。
    • 虽然 .NET 已经相当可移植,可移植性的鼻祖可能是古老的 C 编程语言:如果有 ISA,那么肯定有人为它编写了 C 编译器.对于 C 从 1970 年代初期到今天(2022 年)的所有生命和历史,它 从未支持检查算术 (它是 UB),因为 doing-so 会做很多工作对于 systems-programming 中并不真正需要的东西,它倾向于使用大量有意的未经检查的溢出和按位运算。
      • ...尽管 C23 (for release in 2023) does (finally) add checked arithmetic to the standard library。虽然只用了 50 年...
      • ...虽然 C 编译器当然总是可以自由添加扩展以支持检查算术,但它从来不是 可移植 C 语言的一部分。
      • 对于需要它的 C 程序员,他们在每次操作之前 had to resort to gnarly (and performance-killing) workarounds involving validating every operand 和 aborting-early 而不是执行计算然后再次检查 CPU 标志,因为溢出的一致性为零处理 C 支持的无数 CPUs/archs。
      • ...因此,如果在所有主要参与者和国际标准组织支持的所有编程语言中,C 在算术溢出方面遇到这么多麻烦,那么我们真的不能指望微软能够处理这种复杂程度 -哎呀,我必须说我们真的很幸运,考虑到 .NET 的祖先 Java、didn't support checked arithmetic until Java 8, and only for 2 operations, which also doesn't reveal the direction of overflow either.
      • ,我们甚至完全支持 checked 算法