是否未定义行为仅在多个平台上部署时才会出现问题

Is undefined behavior only an issue if you are deploying on several platforms?

本文关键字:部署 问题 平台 未定义 是否      更新时间:2023-10-16

大多数关于未定义行为 (UB)的讨论都是关于如何有一些平台可以做到这一点,或者一些编译器可以做到这一点。

如果你只对一个平台和一个编译器(相同的版本)感兴趣,并且你知道你将使用它们很多年呢?

除了代码没有任何变化,并且UB不是实现定义的。

一旦UB为那个架构和编译器表现出来并且你已经测试过了,你能不能假设从那时起编译器对UB做了什么,它会每次都这样做?

注意:我知道未定义的行为是非常非常糟糕的,但是当我指出有人在这种情况下写的代码中的UB时,他们问这个,我没有什么比这更好的了,如果你必须升级或移植,所有的UB将非常昂贵来修复。

似乎有不同的行为类别:

  1. Defined -这是根据
  2. 标准记录的行为
  3. Supported -这是要支持的行为文档实现定义的
  4. Extensions -这是一个文档化的添加,支持低级别像popcount、分支提示这样的位操作就属于这一类
  5. Constant -虽然没有记录,但这些行为将可能在给定的平台上是一致的,比如端序,sizeof int虽然不可移植,但可能不会更改
  6. Reasonable -通常是安全的,通常是遗留的,从使用指针低位位作为临时空间
  7. Dangerous -读取未初始化或未分配的内存,返回一个临时变量,在非pod类
  8. 上使用memcopy

似乎Constant可能在一个平台的补丁版本中是不变的。ReasonableDangerous之间的界限似乎越来越多的行为向Dangerous移动,因为编译器在优化方面变得更加积极

操作系统更改,无害的系统更改(不同的硬件版本!),或编译器更改都可能导致先前"正常工作"的UB无法工作。

但情况比这更糟。

有时候,对一个不相关的编译单元的更改,或者对同一编译单元中相距很远的代码的更改,可能会导致先前"工作"的UB不工作;例如,两个定义不同但签名相同的内联函数或方法。一个在连接过程中被无声地丢弃;而完全无害的代码改变可以改变哪一个被丢弃。

当你在不同的上下文中使用它时,在一个上下文中工作的代码可能突然停止在相同的编译器,操作系统和硬件中工作。违反强混叠就是一个例子;编译后的代码在A点调用时可能有效,但在内联时(可能在链接时),代码可能会改变含义。

你的代码,如果是一个更大项目的一部分,可以有条件地调用一些第三方代码(比如,一个在文件打开对话框中预览图像类型的shell扩展)来改变一些标志的状态(浮点精度,区域设置,整数溢出标志,除零行为等)。你的代码,以前工作得很好,现在表现出完全不同的行为。

其次,许多未定义行为本质上是不确定的。在指针被释放后访问它的内容(甚至写它)可能是安全的99/100,但是1/100的页面被交换了,或者在你到达它之前写了别的东西。现在你有内存损坏。它通过了你所有的测试,但你对可能出现的问题缺乏完全的了解。

通过使用未定义行为,你必须完全理解c++标准,你的编译器在这种情况下能做的每一件事,以及运行时环境能做出的每一种反应。每次构建程序时,都必须审计生成的程序集,而不是整个程序的c++源代码。您还将每个阅读或修改代码的人提交到该知识级别。

有时候还是值得的。

最快可能的委托使用UB和关于调用约定的知识,成为一个真正快速的非拥有std::function类型。

impossible Fast delegate竞争。它在某些情况下更快,在其他情况下更慢,并且符合c++标准。

为了提高性能,使用UB可能是值得的。从这样的UB攻击中获得性能(速度或内存使用)之外的东西是很少的。

我看到的另一个例子是,当我们必须用一个蹩脚的C API注册一个回调时,它只接受一个函数指针。我们将创建一个函数(编译时没有优化),将其复制到另一个页面,修改该函数中的指针常量,然后将该页面标记为可执行页,允许我们秘密地将指针与函数指针一起传递给回调函数。

另一种实现是有一些固定大小的函数集(10?100年?1000年?100万?)所有这些都在全局数组中查找std::function并调用它。这将限制我们每次安装多少这样的回调函数,但实际上已经足够了。

没有,这是不安全的。首先,您必须修复中的所有,而不仅仅是编译器版本。我没有特别的例子,但我猜不同的(升级的)操作系统,甚至升级的处理器可能会改变UB结果。

此外,即使有不同的数据输入到您的程序可以改变UB的行为。例如,超出边界的数组访问(至少在没有优化的情况下)通常依赖于数组之后的内存。UPD:请参阅Yakk的精彩回答,以了解更多关于此问题的讨论。

更大的问题是优化和其他编译器标志。UB可能会以不同的方式表现出来,这取决于优化标志,很难想象有人总是使用相同的优化标志(至少你会在调试和发布时使用不同的标志)。

UPD:刚刚注意到您从未提到修复编译器版本,您只提到修复编译器本身。然后一切都更加不安全:新的编译器版本肯定会改变UB的行为。从这个系列的博客文章:

重要的和可怕的事情是,几乎任何基于未定义行为的优化可以开始被触发在将来的任何时候有bug的代码。内联,循环展开,内存推广和其他优化将会越来越好它们存在的重要原因是暴露次要的像上面那样的优化

这基本上是一个关于特定c++实现的问题。"我是否可以假设,在UVW的情况下,($CXX)将继续在XYZ平台上以同样的方式处理标准未定义的特定行为?"

我认为你要么应该说清楚你正在使用的编译器和平台,然后查阅他们的文档,看看他们是否做出任何保证,否则这个问题基本上是无法回答的。

未定义行为的关键在于c++标准没有规定会发生什么,所以如果你想从标准中寻找某种"ok"的保证,你是找不到的。如果你问的是"整个社区"是否认为它安全,那主要是基于意见。

一旦UB为那个架构和编译器表现出来并且你已经测试过了,你能不能假设从那时起编译器对UB做了什么,它会每次都这样做?

只有当编译器制造商保证你可以这样做,否则,不,这是一厢情愿的想法。


让我试着用一种稍微不同的方式再回答一次。

我们都知道,在正常的软件工程和工程中,程序员/工程师被教导按照标准做事,编译器编写者/零件制造商生产符合标准的零件/工具,最后你生产的东西"在标准的假设下,我的工程工作表明这个产品可以工作",然后你测试它并发布它。

假设你有一个疯狂的jimbo叔叔,有一天,他拿出所有的工具和一堆2 * 4的东西,工作了几个星期,在你的后院做了一个临时的过山车。然后你运行它,它肯定不会崩溃。你甚至运行了十次,它也不会崩溃。jimbo不是工程师,所以这不是按照标准做的。但如果它在十次之后没有崩溃,那就意味着它是安全的,你就可以开始向公众收费了,对吧?

在很大程度上,什么是安全的,什么是不安全的是一个社会学问题。但如果你想把它变成一个简单的问题:"我什么时候可以合理地假设没有人会因为我收取入场费而受到伤害,当我真的不能对产品做出任何假设时",我就会这样做。假设我估计,如果我开始向公众收费,我将运行X年,在此期间,可能会有10万人乘坐它。如果这基本上是一个有偏见的抛硬币,那么我希望看到的是,"这个设备已经用崩溃假人运行了一百万次,它从未崩溃或显示出损坏的迹象。"那么我就有理由相信,如果我开始向公众收费,任何人受伤的几率就会很低,即使没有严格的工程标准。这只是基于统计学和力学的一般知识。

关于你的问题,我想说,如果你发布的代码带有未定义的行为,没有人,无论是标准,编译器制造商,还是其他任何人都不会支持,这基本上是"疯狂的jimbo大叔"工程,只有当你根据统计和计算机的一般知识做大量的测试来验证它满足你的需求时,它才是"好"的。

您所指的更可能是实现定义的而不是未定义的行为。前者是当标准没有告诉你会发生什么,但如果你使用相同的编译器和相同的平台,它应该是一样的。举个例子,假设int长度为4字节。UB是更严重的问题。这里的标准什么也没说。对于给定的编译器和平台,它可能有效,但也可能仅在某些情况下有效。

一个使用未初始化值的例子。如果您在if中使用未初始化的bool,您可能会得到true或false,并且它可能总是您想要的,但是代码将以几种令人惊讶的方式中断。

另一个例子是对空指针解引用。虽然在所有情况下都可能导致段错误,但标准并不要求程序在每次运行时都产生相同的结果。 总之,如果你正在做的事情是实现定义的,那么你是安全的,如果你只开发一个平台,你测试了它的工作。如果您正在做的事情是未定义行为,那么您可能在任何情况下都不安全。它可能是有效的,但没有什么能保证它。

换个角度想想。

未定义的行为总是不好的,不应该使用,因为你永远不知道你会得到什么。

但是,您可以使用

来缓和它

行为可以由语言规范以外的各方定义

因此,你永远不应该依赖于UB,但是你可以找到替代的源代码,这些源代码声明在你的环境中,某个行为是编译器的DEFINED行为。

关于快速委托类,Yakk给出了很好的例子。在这些情况下,根据规范,作者明确地声称他们正在从事未定义的行为。然而,他们然后去解释一个商业原因,为什么行为比这更好地定义。例如,他们声明成员函数指针的内存布局不太可能在Visual Studio中改变,因为不兼容会带来巨大的业务成本,这让微软感到厌恶。因此,他们声明该行为是"事实上定义的行为"。在pthreads的典型linux实现(由gcc编译)中也可以看到类似的行为。在某些情况下,他们会对允许编译器在多线程场景中调用哪些优化做出假设。这些假设在源代码的注释中清楚地说明了。这种"事实上定义的行为"是怎样的?pthreads和gcc是紧密联系在一起的。在gcc中添加破坏pthreads的优化是不可接受的,所以没有人会这样做。

然而,你不能做同样的假设。您可能会说"pthreads做到了,所以我也应该能够做到。"然后,有人进行了优化,并更新了gcc以使用它(可能使用__sync调用而不是依赖volatile)。现在pthreads继续运行…但是你的代码不再这样了。

也考虑MySQL的情况(或者是Postgre?),他们发现了一个缓冲区溢出错误。实际上,代码中已经捕获了溢出,但它使用了未定义的行为,因此最新的gcc开始优化整个检出。

因此,总而言之,寻找定义行为的替代来源,而不是在未定义时使用它。找到为什么知道1.0/0.0等于NaN的原因,而不是导致出现浮点陷阱,这是完全合理的。但是,在首先证明它对你和你的编译器来说是一个有效的行为定义之前,千万不要使用这个假设。

请记住,我们会时不时地升级编译器。

从历史上看,C编译器通常倾向于以某种可预测的方式工作,即使标准不要求这样做。例如,在大多数平台上,空指针和指向死对象的指针之间的比较只会报告它们不相等(如果代码希望安全地断言指针为空并在非空情况下捕获则很有用)。标准没有要求编译器做这些事情,但是从历史上看,编译器可以很容易地做这些事情。

不幸的是,一些编译器作者认为,如果在指针有效非空的情况下无法进行这种比较,编译器应该省略断言代码。更糟糕的是,如果它还可以确定某些输入将导致代码被无效的非空指针到达,它应该假设永远不会接收到这样的输入,并省略处理此类输入的所有代码。

希望这样的编译器行为将成为一个短暂的时尚。据推测,它是由"优化"代码的愿望驱动的,但对于大多数应用程序来说,健壮性比速度更重要,并且让编译器混乱的代码可以限制错误输入或差事程序行为造成的损害,这是一场灾难。

然而,在此之前,在使用编译器仔细阅读文档时必须非常小心,因为不能保证编译器编写者不会认为支持有用的行为(例如能够安全地检查两个任意对象是否重叠)比利用一切机会消除标准不要求执行的代码更重要,尽管这些行为得到了广泛支持,但不是标准强制要求的(例如能够安全地检查两个任意对象是否重叠)。

未定义的行为可以被环境温度等因素改变,这会导致旋转硬盘延迟改变,这会导致线程调度改变,这反过来又会改变正在评估的随机垃圾的内容。

简而言之,除非编译器或操作系统指定该行为(因为语言标准没有),否则不安全。

任何类型的未定义行为都有一个基本问题:它是由消毒器和优化器诊断的。编译器可以无声地改变从一个版本到另一个版本的行为(例如,通过扩展它的库),突然你的程序中就会出现一些无法追踪的错误。这应该避免。

有一些未定义的行为是由你的特定实现"定义"的。左移的负位数可以由您的机器定义,并且在那里使用它是安全的,因为文档特性的破坏性更改很少发生。另一个常见的例子是严格别名: GCC可以通过-fno-strict-aliasing禁用此限制。

虽然我同意回答说即使您不针对多个平台也不安全,但每个规则都有例外。

我想举两个例子,我相信允许未定义/实现定义的行为是正确的选择。

  1. 单发程序。它不是一个程序,打算由任何人使用,但它是一个小而快速编写的程序,创建计算或生成一些现在。在这种情况下,"快速而肮脏"的解决方案可能是正确的选择,例如,如果我知道系统的端序,并且我不想麻烦编写与其他端序一起工作的代码。例如,我只需要它来执行一个数学证明,以知道我是否能够在我的其他面向用户的程序中使用特定的公式。

  2. 非常小的嵌入式设备。最便宜的微控制器的内存只有几百字节。如果你开发了一个带有闪烁led的小玩具或音乐明信片等,每一分钱都很重要,因为它将以百万计的数量生产,每单位的利润非常低。处理器和代码都不会改变,如果您必须为下一代产品使用不同的处理器,那么您可能无论如何都必须重写代码。在这种情况下,未定义行为的一个很好的例子是,微控制器在上电时保证每个内存位置的值为零(或255)。在这种情况下,您可以跳过变量的初始化。如果你的微控制器只有256字节的内存,这可能会导致程序适合内存和代码不适合内存的差异。

任何不同意第2点的人,请想象一下如果你把这样的事情告诉你的老板会发生什么:

"我知道硬件只要0.40美元,我们计划以0.50美元的价格出售。然而,我为它编写的40行代码的程序只适用于这种非常特定类型的处理器,所以如果在遥远的将来我们改变到一个不同的处理器,代码将无法使用,我将不得不扔掉它并编写一个新的。一个适用于所有类型处理器的符合标准的程序将不适合我们0.40美元的处理器。因此,我要求使用一个0.60美元的处理器,因为我拒绝编写一个不可移植的程序。

"不改变的软件不被使用。"

如果你对指针做了一些不寻常的事情,可能有一种方法可以使用强制类型转换来定义你想要的。由于它们的性质,它们将而不是是"编译器第一次对UB所做的任何事情"。例如,当您引用由未初始化指针指向的内存时,您将获得一个随机地址,该地址每次运行程序时都是不同的。

未定义的行为通常意味着你正在做一些棘手的事情,你最好用另一种方式来完成任务。例如,这是undefined:

printf("%d %d", ++i, ++i);

很难知道这里的意图是什么,应该重新考虑。

在不破坏代码的情况下更改代码需要阅读和理解当前代码。依赖于未定义的行为会损害可读性:如果我不能查找它,我怎么知道代码是做什么的?

虽然程序的可移植性可能不是问题,但程序员的可移植性可能是问题。如果您需要雇人来维护程序,您将希望能够简单地寻找具有<应用程序领域>'经验的'<语言&nbsp;x>开发人员,而不是必须找到具有<应用程序领域>经验的'<语言&nbsp;x>开发人员,了解(或愿意学习)版本x.y的所有未定义行为特性。z在平台上foo当与bar组合使用时,而bazfurbleblawup'.

除了代码没有任何变化,并且UB不是实现定义的。

更改代码足以触发优化器针对未定义行为的不同行为,因此可能工作的代码很容易由于看似微小的更改而暴露更多优化机会而中断。例如,允许一个函数被内联,这在每个C程序员应该知道的未定义行为#2/3中有很好的介绍,其中说:

虽然这是一个有意设计的简单示例,但这种情况在内联中经常发生:内联函数通常会暴露许多二次优化机会。这意味着,如果优化器决定内联一个函数,就会启动各种局部优化,从而改变代码的行为。根据标准,这是完全有效的,并且在实践中对性能很重要。

编译器供应商对未定义行为的优化变得非常积极,升级可以暴露以前未开发的代码:

需要意识到的重要而可怕的事情是,几乎任何基于未定义行为的优化都可能在未来的任何时候开始被有bug的代码触发。内联、循环展开、内存提升和其他优化将会越来越好,它们存在的一个重要原因是暴露了像上面那样的二次优化。