【问题标题】:Efficient conversion from Indeterminate Value to Unspecified Value从不确定值到未指定值的有效转换
【发布时间】:2024-01-11 18:00:02
【问题描述】:

在 C 中有时需要从部分写入的数组中读取可能写入的项,这样:

  1. 如果项目已被写入,则读取将产生实际写入的值,并且

  2. 如果项目尚未写入,读取会将未指定的位模式转换为适当类型的值,而不会产生副作用。

在各种算法中,从头开始寻找解决方案的成本很高,但验证提出的解决方案却很便宜。如果一个数组包含所有已找到的情况的解决方案,以及其他情况的任意位模式,则读取数组,测试它是否包含有效的解决方案,并且仅当数组中的解决方案无效时才慢慢计算解决方案,可能是一个有用的优化。

如果尝试读取类型为uint32_t 的非写入数组元素可以保证始终产生适当类型的值,那么这种方法将是简单直接的有效方法。即使该要求仅适用于unsigned char,它也可能仍然可行。不幸的是,编译器有时会表现得好像读取了一个不确定的值,即使是unsigned char 类型,也可能会产生与该类型的值不一致的东西。此外,缺陷报告中的讨论表明,涉及 Indeterminate 值的操作会产生 Indeterminate 结果,因此即使给定 unsigned char x, *p=&x; unsigned y=*p & 255; unsigned z=(y < 256); 之类的东西,z 也有可能接收到值 0

据我所知,函数:

unsigned char solidify(unsigned char *p)
{
  unsigned char result = 0;
  unsigned char mask = 1;
  do
  {
    if (*p & mask) result |= mask;
    mask += (unsigned)mask; // Cast only needed for capricious type ranges
  } while(mask);
  return result;
}

将保证在任何时候都可以生成类型为unsigned char 的值,只要标识的存储可以被访问为该类型,即使它碰巧持有不确定值。然而,这种方法似乎相当缓慢和笨拙,因为获得所需效果所需的机器代码通常应该等同于返回x

是否有更好的方法可以保证标准始终产生unsigned char 范围内的值,即使源值是不确定的?

附录

固化值的能力是必要的,尤其是在使用部分写入的数组和结构执行 I/O 时,在没有人关心从未设置的部分输出哪些位的情况下。无论标准是否要求fwrite 可用于部分写入的结构或数组,我认为可以以这种方式使用的 I/O 例程(为未设置的部分写入任意值)是比那些在这种情况下可能会跳槽的质量更高。

我关心的主要是防范不太可能用于危险组合的优化,但随着编译器变得越来越“聪明”,这种优化仍然可能发生。

类似的问题:

unsigned char solidify_alt(unsigned char *p)
{ return *p; }

是编译器可能会将一个可能很麻烦但孤立地容忍的优化与一个孤立地很好但与第一个结合起来致命的优化结合起来:

  1. 如果函数被传递,unsigned char 的地址已被优化为例如一个 32 位寄存器,类似上面的函数可能会盲目地返回该寄存器的内容,而不会将其裁剪到 0-255 的范围内。要求调用者手动剪辑这些函数的结果会很烦人,但如果这是唯一的问题,那么它是可以生存的。可惜……

  2. 1234563与 0-255 范围之外的值无关的东西。

一些 I/O 设备可能要求希望写入八位字节的代码对 I/O 寄存器执行 16 位或 32 位存储,并且可能需要 8 位包含要写入的数据,而其他位保持某种模式。如果任何其他位设置错误,它们可能会出现严重故障。考虑代码:

void send_byte(unsigned char *p, unsigned int n)
{
  while(n--)
    OUTPUT_REG = solidify_alt(*p++) | 0x0200;
}
void send_string4(char *st)
{
  unsigned char buff[5]; // Leave space for zero after 4-byte string
  strcpy((char*)buff, st);
  send_bytes(buff, 4);
}

具有 send_string4("Ok"); 的缩进语义应该发送一个“O”、一个“k”、一个零字节和一个 0-255 的任意值。由于代码使用solidify_alt 而不是solidify,编译器可以合法地将其转换为:

void send_string4(char *st)
{
  unsigned buff0, buff1, buff2, buff3;
  buff0 = st[0]; if (!buff0) goto STRING_DONE;
  buff1 = st[1]; if (!buff1) goto STRING_DONE;
  buff2 = st[2]; if (!buff2) goto STRING_DONE;
  buff3 = st[3];
 STRING_DONE:
  OUTPUT_REG = buff0 | 0x0200;
  OUTPUT_REG = buff1 | 0x0200;
  OUTPUT_REG = buff2 | 0x0200;
  OUTPUT_REG = buff3 | 0x0200;
}

效果是 OUTPUT_REG 可能会接收位设置在正确范围之外的值。即使输出表达式更改为((unsigned char)solidify_alt(*p++) | 0x0200) & 0x02FF),编译器仍然可以简化它以生成上面给出的代码。

标准的作者没有要求编译器生成的自动变量初始化,因为在这种初始化在语义上是不必要的情况下,这会使代码变慢。我不认为他们打算让程序员在所有位模式都同样可接受的情况下必须手动初始化自动变量。

注意,顺便说一句,在处理短数组时,初始化所有值会很便宜,而且通常是个好主意,而当使用大数组时,编译器不太可能强加上述“优化”。但是,在数组足够大以至于成本很重要的情况下省略初始化将使程序的正确操作依赖于“希望”。

【问题讨论】:

  • 出于好奇,有什么例子说明什么时候需要这样做?
  • 我觉得这需要一个非常极端的情况才能值得在开始填写解决方案之前将数组归零。话虽如此,该标准使这一点变得多么困难和容易出错仍然令人非常沮丧。
  • 您认为solidify()unsigned char solidify_alt(unsigned char *p) { unsigned char x = *p; return x; } 相比有什么优势?
  • 委员会对DR 451 的回复说“[...] 对不确定值执行的任何操作都将具有不确定值。”因此,委员会的观点是,没有办法“确定”一个不确定的值,甚至是问题中提出的值。
  • 没有任何实际价值的问题。在谈论 IO 的 POD 时更是如此。最后一个例子试图变得聪明,并表明不确定和未初始化是两个不同的东西。然而,类型转换神奇地切换到甚至在第一次使用buff 之前,而不是在分配OUTPUT_REG 时在算术运算期间进行提升。作者很困惑为什么编译器会跟踪未初始化的变量来进行优化。高质量的代码完全不需要solidify,因为它首先不会调用未定义的行为。

标签: c undefined-behavior c99 c11


【解决方案1】:

这不是答案,而是扩展评论。

直接的解决方案是让编译器提供一个内置的,例如assume_initialized(variable [, variable ... ]*),它不会生成机器代码,而只是让编译器将指定变量(标量或数组)的内容视为已定义但未知

例如,使用另一个编译单元中定义的虚拟函数可以达到类似的效果

void define_memory(void *ptr, size_t bytes)
{
    /* Nothing! */
}

并调用它(例如define_memory(some_array, sizeof some_array)),以阻止编译器将数组中的值视为不确定;这是可行的,因为在编译时,编译器无法确定这些值是否未指定,因此必须将它们视为已指定(已定义但未知)。

不幸的是,这会导致严重的性能损失。即使函数体为空,调用本身也会对性能产生影响。然而,更糟糕的是对代码生成的影响:因为数组是在单独的编译单元中访问的,所以数据实际上必须以数组形式驻留在内存中,因此通常会产生额外的内存访问,并且限制了编译器的优化机会.特别是,即使是一个小数组也必须存在,并且不能是隐式的或完全驻留在机器寄存器中。

我已经尝试了一些体系结构 (x86-64) 和编译器 (GCC) 特定的解决方法(使用扩展的内联汇编来欺骗编译器,使其相信值已定义但未知(未指定,而不是不确定),无需生成实际的机器代码——因为这不需要任何机器代码,只需对编译器处理数组/变量的方式进行小幅调整——但成功率几乎为零。

现在,我写这篇评论的根本原因。

多年前,在处理数值计算代码并将性能与 Fortran 95 中的类似实现进行比较时,我发现缺少 memrepeat(ptr, first, bytes) 函数:相对于 memmove() 对应于 memcpy(),这将重复 first 字节 ptrptr+first 直到 ptr+bytes-1。与memmove() 一样,它适用于数据的存储表示,因此即使ptrptr+first 包含陷阱表示,也不会真正触发陷阱。

主要用例是使用浮点数据(一维、多维或具有浮点成员的结构)初始化数组,方法是初始化第一个结构或一组值,然后简单地重复存储模式整个阵列。这是数值计算中非常常见的模式。

例如,使用

    double nums[7] = { 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0 };
    memrepeat(nums, 2 * sizeof nums[0], sizeof nums);

产量

    double nums[7] = { 7.0, 6.0, 7.0, 6.0, 7.0, 6.0, 7.0 };

(如果将其定义为例如memsetall(data, size, count),则编译器可能会更好地优化操作,其中size是重复存储单元的大小,count是存储单元的总数(所以count-1 单元实际上被复制了)。特别是,这允许使用非临时存储进行复制的简单实现,从初始存储单元读取。另一方面,memsetall() 只能复制完整的存储单元,不像 @987654341 @,所以memsetall(nums, 2 * sizeof nums[0], 3); 将保持nums[] 中的第7 个元素不变——即,在上面的示例中,它会产生{ 7.0, 6.0, 7.0, 6.0, 7.0, 6.0, 1.0 }。)

虽然您可以轻松实现memrepeat()memsetall(),甚至针对特定的架构和编译器对其进行优化,但很难编写出可移植的优化版本。

特别是,使用memcpy()(或memmove())的基于循环的实现在由例如编译时产生非常低效的代码。 GCC,因为编译器无法将函数调用模式合并为单个操作。

大多数编译器通常将memcpy()memmove() 内联到内部的、针对目标和用例优化的版本,并且为这样的memrepeat() 和/或memsetall() 函数这样做可以使其可移植。在 x86-64 上的 Linux 中,GCC 内联已知大小的调用,但将函数调用保留在只有在运行时才知道大小的地方。

我确实尝试将它推向上游,在各种邮件列表上进行了一些私人和一些公开讨论。回应很热情,但很明确:没有办法将这些功能包含在编译器中,除非它首先由某人标准化,或者你激起了核心开发人员的兴趣,以至于他们想自己尝试一下。

因为 C 标准委员会只关心满足其企业赞助商的商业利益,所以将类似的东西标准化为 ISO C 的可能性为零。(如果有,我们真的应该推动 POSIX 的基本功能,例如getline()regexiconv 将被包括在首先;它们将对我们可以教授新 C 程序员的代码产生更大的积极影响。)

这些都没有激起核心 GCC 开发人员的兴趣,所以在那时,我失去了尝试将其推向上游的兴趣。

如果我的经验是典型的 - 并且与一些人讨论它看起来确实如此 - OP 和其他担心这些事情的人将更好地利用他们的时间来寻找编译器/架构特定的解决方法,而不是指出出标准的不足:标准已经失传,那些人不在乎。

最好把你的时间和精力花在你可以实际完成的事情上,而不必与风车作斗争。

【讨论】:

  • 从实际的角度来看,可能需要一组编译器供应商正式承认“常识 C”,并鼓励程序员以该方言为目标并指定他们的代码不应期望研究作者认为“聪明”意味着“不愚蠢”的编译器。在涉及安全或安保的任何情况下,都应强烈反对使用不承诺支持保证的编译器。语言“C,在实际使用时,未定义行为在底层平台上以‘自然’方式表现”是好的和有用的。子集...
  • ...“独立 C” 仅限于委员会规定的行为非常薄弱,因为 C89 和 C99 的作者认为他们可以依靠编译器编写者来使用常识。顺便说一句,我想知道那些将 C 带入疯狂小镇的人是否真的喜欢 C,或者他们是否试图摧毁它?例如,我认为 Apple 是“clang”和 Swift 语言背后的主要推动力。
  • @supercat:到达那里(说服供应商)是......好吧,不会费心去尝试实现这一点。另一方面,LKML 中有很多例子,其中参与 GCC 和 Linux 内核开发的开发人员在 GCC 中有固定的白痴,这些白痴在技术上是标准允许的。我想说贿赂/支付 GCC 开发人员来实现其中的一些将是一条更容易实现的途径——尤其是因为不会违反道德规范,因为我们正在谈论添加标准未定义的有用内容。不幸的是,我破产了。
  • @supercat:对于厂商来说,PathScale 基本没了; Portland Group 做 HPC 的事情,似乎不太关心标准的人。 Intel、GCC、clang 的人不太关心 C 库方面(并且乐于将 C++ 特性合并到 C 中);微软只对保持其 C 和 C++ 源代码的不可移植性感兴趣,以防止开发人员生成可移植代码,从而将它们与自己的围墙花园(EEE,还记得吗?)联系起来。总结:是的,他们希望 C 与 C++ 合并。
  • 您的memrepeat 正是memmove 所做的。 memmove不知道指针目标类型,所以无法触发陷阱……
【解决方案2】:

我认为这很清楚。 C11 3.19.2

不确定值
未指定的值或陷阱 表示

期间。除了以上两种情况外,不能是别的。

因此,unsigned z=(y < 256) 之类的代码永远 不会返回 0,因为您的示例中的 x 不能保存大于 255 的值。根据字符类型的表示,6.2.6,一个unsigned char 不允许包含填充位或陷阱表示。

其他类型,在非常奇特的系统上,理论上可以保持超出其范围、填充位和陷阱表示的值。

在极有可能使用二进制补码的实际系统上,不存在陷阱表示。所以不确定的值只能是未指定的。未指定,不是未定义!有一个神话说“读取不确定的值总是未定义的行为”。除了陷阱表示和其他一些特殊情况,这不是真的,see this。这只是未指定的行为

未指定的行为并不意味着编译器可以进行破坏并做出奇怪的假设,就像它遇到未定义的行为时那样。它必须假设变量值在范围内。编译器无法假设的是,读取之间的值是相同的 - 这已由某些 DR 解决。

【讨论】:

  • 给定short test(unsigned mode) { register short x; if (!mode) x=someVolatile; return x; },对于像 ARM 这样的平台,最直接的非“优化”代码将为 x 选择一个寄存器,有条件地加载一个带有符号扩展的 volatile 值,然后返回该寄存器的内容(如果未设置,则返回其中发生的任何 32 位值)。我不认为任何版本的标准的作者打算禁止这种行为[我不认为将前几个register-qualified 变量分配给寄存器会被认为是“优化”]。
  • 使用register short y=test(1234567); if (y >= 0 && y < 32768) foo[y]++; 之类的代码真的很棘手。我认为任何版本的标准都不是为了禁止 test 返回任意 32 位值,也不是让 y 接收这样的值,也不是让编译器盲目地假设任何短(包括 @ 987654330@) 总是比较小于 32768。说使用未初始化的自动变量是 UB 比尝试识别使用一个变量的所有允许后果要简单得多。