【问题标题】:Is there a way, in tests, to defend against "correct" results coming out of undefined behavior?在测试中,有没有办法防止来自未定义行为的“正确”结果?
【发布时间】:2021-10-14 03:25:41
【问题描述】:

前言

I know what UB is,所以我不是在问如何避免它,而是是否有办法让单元测试更能抵抗它,即使它是一种概率方法,这只会使 UB 更有可能变得明显而不是默默地成功通过测试。

问题

假设我想为一个函数编写一个测试,但我做错了,像这样:

#include <gtest/gtest.h>
#include <vector>

int main()
{
    std::vector<int> v{0};
    for (auto i = 0; i != 100; ++i) {
      v.push_back(3);     // push a 3
      v.pop_back();       // ops, popping the value I just pushed
      EXPECT_EQ(v[1], 3); // UB
    }
}

在我的机器上,它始终通过;也许程序太简单了,没有理由将 3 真正从它所在的内存区域中抹去pop_back

因此测试显然不可靠。

有什么方法可以防止这种意外成功的测试,即使是在统计基础上(“在EXPECT_EQ 之前调用这样的函数会降低 UB 刺痛你的机会”)?


上面的代码只是一个例子(我不愿意测试STL);我知道std::vector&lt;T&gt;::at 是一个绑定安全的std::vector&lt;T&gt;::operator[],但这是一种首先防止未定义行为的方法,而我正在徘徊如何防御它。

例如,通过在v.pop_back(); 之后添加*(&amp;v[0] + 1) = 10; 来利用UB 本身,将使测试的不正确性显而易见,至少在我的机器上是这样。

所以我在想一个工具/库/任何东西,比如说,将v 不持有的内存设置为每个可执行行之后的随机值。

【问题讨论】:

  • 不,未定义的行为可能完全符合您的(毫无根据的)期望:)
  • 使用v.at(1) 看看你会得到什么。
  • 是的。如果您想范围检查您的访问权限,请使用at。如果您不想在超出范围时抛出异常,那么您需要自己进行范围检查。
  • 您不能真正针对 UB 进行完全单元测试。它们可能会帮助您捕获一些实例,但不能证明其正确性。但这对于一般的测试来说是正确的。目标是减少缺陷未被发现的可能性。
  • 未定义行为的本质是——如果你不走运的话——它可能看起来像你(或你的单元测试)期望的那样工作。如果你幸运的话,它会崩溃。但它也可以将您的浏览器历史记录通过电子邮件发送给您的祖母,然后格式化您的硬盘。

标签: c++ unit-testing googletest undefined-behavior


【解决方案1】:

Clang with Adress Sanitizer (https://clang.llvm.org/docs/AddressSanitizer.html) 捕获此错误:

$ clang++ -Wall -std=c++11 -o test test.cpp
$ ./test # program runs without errors

$ clang++ -fsanitize=address -Wall -std=c++11 -o test test.cpp
$ ./test
=================================================================
==94146==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x6020000000f4 at pc 0x00010ebcbf54 bp 0x7ffee10362d0 sp 0x7ffee10362c8
READ of size 4 at 0x6020000000f4 thread T0
    #0 0x10ebcbf53 in main+0x393 (test:x86_64+0x100002f53)
    #1 0x7fff204c3f3c in start+0x0 (libdyld.dylib:x86_64+0x15f3c)

0x6020000000f4 is located 4 bytes inside of 8-byte region [0x6020000000f0,0x6020000000f8)
allocated by thread T0 here:
    #0 0x10ec38c9d in wrap__Znwm+0x7d (libclang_rt.asan_osx_dynamic.dylib:x86_64h+0x54c9d)
    #1 0x10ebcdb38 in std::__1::__libcpp_allocate(unsigned long, unsigned long)+0x18 (test:x86_64+0x100004b38)
    #2 0x10ebcdaa9 in std::__1::allocator<int>::allocate(unsigned long)+0x49 (test:x86_64+0x100004aa9)
    #3 0x10ebcd4cc in std::__1::allocator_traits<std::__1::allocator<int> >::allocate(std::__1::allocator<int>&, unsigned long)+0x1c (test:x86_64+0x1000044cc)
    #4 0x10ebcfbc0 in std::__1::__split_buffer<int, std::__1::allocator<int>&>::__split_buffer(unsigned long, unsigned long, std::__1::allocator<int>&)+0x180 (test:x86_64+0x100006bc0)
    #5 0x10ebcf68c in std::__1::__split_buffer<int, std::__1::allocator<int>&>::__split_buffer(unsigned long, unsigned long, std::__1::allocator<int>&)+0x2c (test:x86_64+0x10000668c)
    #6 0x10ebceec4 in void std::__1::vector<int, std::__1::allocator<int> >::__push_back_slow_path<int>(int&&)+0x154 (test:x86_64+0x100005ec4)
    #7 0x10ebcc480 in std::__1::vector<int, std::__1::allocator<int> >::push_back(int&&)+0xd0 (test:x86_64+0x100003480)
    #8 0x10ebcbedd in main+0x31d (test:x86_64+0x100002edd)
    #9 0x7fff204c3f3c in start+0x0 (libdyld.dylib:x86_64+0x15f3c)

SUMMARY: AddressSanitizer: heap-buffer-overflow (test:x86_64+0x100002f53) in main+0x393
Shadow bytes around the buggy address:
  0x1c03ffffffc0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x1c03ffffffd0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x1c03ffffffe0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x1c03fffffff0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x1c0400000000: fa fa fd fd fa fa 00 00 fa fa 00 06 fa fa 00 fa
=>0x1c0400000010: fa fa 00 00 fa fa 00 06 fa fa fd fa fa fa[04]fa
  0x1c0400000020: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x1c0400000030: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x1c0400000040: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x1c0400000050: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x1c0400000060: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
Shadow byte legend (one shadow byte represents 8 application bytes):
  Addressable:           00
  Partially addressable: 01 02 03 04 05 06 07
  Heap left redzone:       fa
  Freed heap region:       fd
  Stack left redzone:      f1
  Stack mid redzone:       f2
  Stack right redzone:     f3
  Stack after return:      f5
  Stack use after scope:   f8
  Global redzone:          f9
  Global init order:       f6
  Poisoned by user:        f7
  Container overflow:      fc
  Array cookie:            ac
  Intra object redzone:    bb
  ASan internal:           fe
  Left alloca redzone:     ca
  Right alloca redzone:    cb
  Shadow gap:              cc
==94146==ABORTING
[1]    94146 abort      ./test
    /tmp 

【讨论】:

  • 考虑到问题的范围,值得指出的是,clang 还有一个更通用的未定义行为清理器:clang.llvm.org/docs/UndefinedBehaviorSanitizer.html
  • 另外,地址清理程序实际上不会在我的系统上捕获此错误,除非我增加索引。这大概是因为取消引用的内存仍然是向量容量的一部分,因为我的 stdib 在 pop_back() 之后没有执行隐式 shrink_to_fit()
  • 哦,我什至不知道这个。谢谢@弗兰克!然而,在我的系统上,-fsanitize=undefined 没有捕捉到这个特定的错误。我在 macOS 上使用 clang 12.0.5。
【解决方案2】:

不幸的是,检查无效的内存访问还不够好,因为不需要pop_back() 来放弃内存。

v[1]总是由于从已删除对象中读取而未定义的行为,但这是从 c++ 抽象机的角度仅在编译期间存在的微妙之处。一旦代码被编译成二进制,只要分配内存并正确对齐,就没有“问题”。因此,您不一定会通过系统级运行时检查来捕获此类 UB。

虽然这通常不是 UB 的灵丹妙药,但您可以定义一些预处理器宏来启用标准库中的额外验证。

stdlib macro
libstdc++ _GLIBCXX_DEBUG
libc++ _LIBCPP_DEBUG
MSVC automatic for Debug builds, but partial :(

因此,将-D_GLIBCXX_DEBUG -D_LIBCPP_DEBUG 添加到编译器标志将可靠地捕获 OP 的错误,至少在使用 gcc/clang 时是这样。

【讨论】:

  • AFAIK,默认情况下,MSVC 在调试版本中为 STL 提供调试访问保护。
  • @JanHošek 它有它们用于迭代器,但不适用于std::vector&lt;&gt;::operator[] 之类的东西,这是 OP 所特别关心的。 (我仍然在答案中添加了这个细节,谢谢指出)
【解决方案3】:

您可以通过将测试套件与各种其他方法相结合来制作更多的测试套件,只需使用不同的编译选项编译和运行测试代码即可。对于您展示的具体示例,有 clang 和 gcc 支持的地址清理程序。但是,还有更多的消毒剂可以在运行时检测其他类型的问题。 (valgrind 工具套件也可能有用。)

并非所有消毒剂都可以组合使用,因此您可能需要使用不同的设置多次编译和运行代码。然而,这也是可取的,因为还有更多方法可以编译代码以查找更多错误:

  • 使用不同的优化级别:使用更高的优化级别,编译器会更深入地分析代码并执行转换,以消除或更改具有未定义行为的代码部分,从而使测试可以观察到这一点。
  • 启用和不启用断言 - 两种情况都是相关的:启用断言后,您可能会发现其他问题,禁用断言后,您可能会发现由于断言表达式中的副作用而导致的问题。
  • 为使用过的库(如 C++ STL,其中库可以确定某个迭代器失效后是否使用它)提供特殊的调试标志

上述所有优点都得益于使用精心设计的测试套件运行,该套件具有良好的代码覆盖率和有趣的场景(例如边界情况),因为所有这些方法都依赖于在有问题的代码片段上实际执行并且通常还涉及执行期间使用的数据。

当然,为了完整起见,这些动态方法应与其他质量保证技术(如审查、静态代码分析工具等)结合使用。

【讨论】:

    猜你喜欢
    • 2014-01-24
    • 2022-01-17
    • 2011-12-10
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2020-05-01
    • 2017-06-03
    • 1970-01-01
    相关资源
    最近更新 更多