未定义的行为和真相未定义

在C和C ++语言中,术语“不确定行为”表示一种情况,即从字面上“什么都不会发生”。 从历史上看,以前的C编译器(及其上的体系结构)以不兼容的方式运行的情况归因于不确定的行为,并且开发标准的委员会以其无限的智慧决定不对此做出任何决定(即不给予优先选择)一些竞争的实现)。 不确定行为也被称为可能的情况,在这种情况下,标准(通常如此详尽)没有规定任何特定行为。 这个术语具有第三种含义,在我们这个时代越来越重要:不确定的行为-这是优化的机会。 C和C ++的开发人员喜欢优化。 他们坚持要求编译器尽一切努力来加速代码。

本文最初在密码服务网站上发布。 该翻译经作者Thomas Pornin许可出版。

这是一个经典的例子:

void foo(double *src, int *dst) { int i; for (i = 0; i < 4; i ++) { dst[i] = (int)src[i]; } } 

我们将在用于Linux的64位x86平台上编译此GCC代码(我使用的是最新版本的Ubuntu 18.04,版本GCC-7.3.0)。 我们打开全面优化,然后查看汇编程序列表,为此我们使用键“ -W -Wall -O9 -S ”(参数“ -O9 ”设置了GCC优化的最大级别,实际上在某些情况下等效于“ -O3 ”)。 GCC定义和更高级别)。 我们得到以下结果:

  .file "zap.c" .text .p2align 4,,15 .globl foo .type foo, @function foo: .LFB0: .cfi_startproc movupd (%rdi), %xmm0 movupd 16(%rdi), %xmm1 cvttpd2dq %xmm0, %xmm0 cvttpd2dq %xmm1, %xmm1 punpcklqdq %xmm1, %xmm0 movups %xmm0, (%rsi) ret .cfi_endproc .LFE0: .size foo, .-foo .ident "GCC: (Ubuntu 7.3.0-27ubuntu1~18.04) 7.3.0" .section .note.GNU-stack,"",@progbits 

头两个movupd指令的每条指令将两个 精度值都移至128位SSE2寄存器( 精度型的大小为64位,因此SSE2寄存器可以存储两个 精度值)。 换句话说,首先读取四个初始值,然后才将它们转换为intcvttpd2dq操作)。 punpcklqdq操作将接收到的四个32位整数移入一个SSE2寄存器(%xmm0 ),然后将其内容写入RAM( movups )。 现在最主要的是:我们的C程序正式要求对内存的访问按以下顺序进行:

  • src [0]读取第一个double值。
  • 将类型int的第一个值写入dst [0]
  • src [1]读取第二个double值。
  • 将类型为int的第二个值写入dst [1]
  • src [2]中读取第三个double值。
  • 将类型为int的第三个值写入dst [2]
  • src [3]读取第四个double值。
  • 将类型int的第四个值写入dst [3]

但是,所有这些要求仅在C标准定义的抽象机的上下文中才有意义。 实际计算机上的过程可能会有所不同。 编译器可以自由地重新安排或修改操作,只要它们的结果不与抽象机的语义相矛盾(所谓的as-if规则是“好像”)。 在我们的示例中,操作顺序只是不同的:

  • src [0]读取第一个double值。
  • src [1]读取第二个double值。
  • src [2]中读取第三个double值。
  • src [3]读取第四个double值。
  • 将类型int的第一个值写入dst [0]
  • 将类型为int的第二个值写入dst [1]
  • 将类型为int的第三个值写入dst [2]
  • 将类型int的第四个值写入dst [3]

这是C语言:所有内存内容最终都是字节(即,值类型为unsigned char的插槽,但实际上是八位组),并且允许任何任意指针操作。 特别是,当调用srcdst指针时(该情况称为“别名”),可用于访问内存的重叠部分。 因此,如果先写入字节然后再次读取,则读取和写入顺序可能很重要。 为了使程序的实际行为与C标准定义的摘要相对应,编译器将不得不在读取和写入操作之间交替,从而在每次迭代时提供完整的内存访问周期。 生成的代码将更大,但运行起来会慢得多。 对于C开发人员来说,这将是一个悲伤。

幸运的是,这里出现了不确定的行为 。 标准C声明不能通过其类型与这些值的当前类型不对应的指针访问值。 简而言之,如果将值写入dst [0] (其中dst是 int指针),则无法通过src [1] (其中src指针)读取相应的字节,因为在这种情况下,我们将尝试访问值,现在是int类型,使用不兼容类型的指针。 在这种情况下,将发生未定义的行为。 这在标准ISO 9899:1999(“ C99”)第6.5节的第7段中进行了说明(在新版9899:2018或“ C17”中,措词未更改)。 此要求称为严格别名规则。 结果,允许C编译器根据以下假设进行操作:不会发生由于违反严格的别名规则而导致未定义行为的内存访问操作。 因此,编译器可以按任何顺序重新排列读取和写入操作,因为它们不应访问内存的重叠部分。 这就是代码优化的全部内容。

简而言之,未定义行为的含义是:编译器可以假定将没有未定义行为,并根据此假设生成代码。 对于严格的混叠规则-如果发生混叠,则不确定的行为允许进行重要的优化,否则将难以实现。 一般而言,编译器使用的代码生成过程中的每条指令都具有限制操作计划算法的依赖性:一条指令不能在其所依赖的指令之前或之后执行。 在我们的示例中,未定义的行为消除了dst []中的写入操作与src []的 “后续”读取操作之间的依赖关系:只有当访问内存时发生未定义的行为时,这种依赖关系才会存在。 同样,未定义行为的概念使编译器可以简单地删除不进入未定义行为状态而无法执行的代码。

当然,所有这些都是好的,但是编译器有时会将这种行为视为背叛。 您通常会听到这样的短语:“编译器使用不确定行为的概念作为破坏我的代码的借口。” 假设有人编写了一个将整数加起来并且担心溢出的程序,请记住比特币情况 。 他可以这样想:代表整数,处理器使用附加代码,这意味着如果发生溢出,则会发生,因为结果将被截断为类型的大小,即 32位 这意味着可以通过测试来预测和检查溢出的结果。

我们的条件开发人员将编写以下代码:

 #include <stdio.h> #include <stdlib.h> int add(int x, int y, int *z) { int r = x + y; if (x > 0 && y > 0 && r < x) { return 0; } if (x < 0 && y < 0 && r > x) { return 0; } *z = r; return 1; } int main(int argc, char *argv[]) { int x, y, z; if (argc != 3) { return EXIT_FAILURE; } x = atoi(argv[1]); y = atoi(argv[2]); if (add(x, y, &z)) { printf("%d\n", z); } else { printf("overflow!\n"); } return 0; } 

现在,让我们尝试使用GCC编译以下代码:

 $ gcc -W -Wall -O9 testadd.c $ ./a.out 17 42 59 $ ./a.out 2000000000 1500000000 overflow! 

好的,这似乎可行。 现在尝试另一个编译器,例如Clang(我的版本为6.0.0):

 $ clang -W -Wall -O3 testadd.c $ ./a.out 17 42 59 $ ./a.out 2000000000 1500000000 -794967296 

什么啊

事实证明,当使用带符号整数类型的运算导致结果无法用目标类型表示时,我们将输入未定义行为的范围。 但是编译器可能会认为它不会发生。 特别是,优化表达式x> 0 && y> 0 && r <x时 ,编译器得出结论,由于xy的值严格为正,因此第三次校验不能为真(两个值之和不能小于它们中的任何一个),您可以跳过整个操作。 换句话说,由于溢出是未定义的行为,因此从编译器的角度来看“不会发生”,并且可以删除所有依赖于此状态的指令。 检测未定义行为的机制已完全消失。

该标准从未规定过“带符号语义”(实际上在处理器操作中使用)用于带符号类型的计算的假设。 这是按传统发生的-即使在那些编译器不够聪明以优化代码(专注于一系列值)的时代。 您可以使用-fwrapv特殊标志强制Clang和GCC将包装语义应用于签名类型(在Microsoft Visual C中,可以使用-d2UndefIntOverflow-,如此处所述)。 但是,这种方法不可靠,当代码转移到另一个项目或另一个体系结构时,该标志可能会消失。

很少有人知道字符类型溢出涉及未定义的行为。 C99和C17标准的6.5节第5段对此进行了说明:

如果在评估表达式时发生异常(即,如果数学上未定义结果或超出给定类型的有效值范围),则该行为不确定。

但是,对于无符号类型,可以保证模块化语义。 6.2.5节第9段指出:

在使用无符号操作数进行的计算中,绝对不会发生溢出,因为无法用所得的无符号整数类型表示的结果将以比所得类型表示的最大值大一的数字取模。

带符号类型的操作中未定义行为的另一个示例是除法操作。 众所周知,被数学除以零的结果不是数学确定的,因此,根据标准,该操作需要不确定的行为。 如果在x86处理器上的idiv操作中除数为零,则抛出处理器异常。 像中断请求一样,处理器异常由操作系统处理。 在类似Unix的系统(例如Linux)上,由idiv操作触发的处理器异常被转换为SIGFPE信号,该信号被发送到进程,并以默认处理程序结尾(不要惊讶“ FPE”代表“浮点异常”(浮点运算),而idiv使用整数)。 但是还有另一种情况导致不确定的行为。 考虑以下代码:

 #include <stdio.h> #include <stdlib.h> int main(int argc, char *argv[]) { int x, y; if (argc != 3) { return EXIT_FAILURE; } x = atoi(argv[1]); y = atoi(argv[2]); printf("%d\n", x / y); return 0; }  : $ gcc -W -Wall -O testdiv.c $ ./a.out 42 17 2 $ ./a.out -2147483648 -1 zsh: floating point exception (core dumped) ./a.out -2147483648 -1 

事实是:在这台机器上(对于Linux,所有x86都是相同的), int类型表示的值范围是-2,147,483,648到+2,147,483,647。如果将-2,147,483,648除以-1,则应该得到+2,147,483,648但是此数字不在int值范围内。 因此,行为没有定义。 什么都可能发生。 在这种情况下,该过程将被强制终止。 在另一个系统上,尤其是在没有除法运算的小型处理器上,结果可能会有所不同。 在这样的体系结构中,除法是通过程序执行的-在通常由编译器提供的过程的帮助下进行,现在它可以以不确定的行为来完成自己喜欢的任何事情,因为这正是它的本质。

我注意到, SIGFPE可以在相同条件下借助模运算符( )获得。 实际上:在它下面是相同的idiv操作,该操作同时计算商和余数,因此会触发相同的处理器异常。 有趣的是,C99标准表示表达式INT_MIN%-1不会导致未定义的行为,因为结果在数学上定义为(零),并且唯一地属于目标类型的值的范围。 在C17版本中,更改了6.5.5节第6段的文本,现在还考虑了这种情况,这使该标准更接近于通用硬件平台上的实际情况。

有许多不明显的情况也会导致不确定的行为。 看一下这段代码:

 #include <stdio.h> #include <stdlib.h> unsigned short mul(unsigned short x, unsigned short y) { return x * y; } int main(int argc, char *argv[]) { int x, y; if (argc != 3) { return EXIT_FAILURE; } x = atoi(argv[1]); y = atoi(argv[2]); printf("%d\n", mul(x, y)); return 0; } 

您是否认为如果我们将45,000和50,000的因子传递给函数,就应该遵循C标准打印程序?

  • 18,048
  • 2,250,000,000
  • 上帝保佑女王!

正确答案...是的,以上所有! 您可能会这样争论:由于无符号short是无符号类型,因此它应该支持包装65 536模的语义,因为在x86处理器上,这种类型的大小通常恰好是16位(标准也允许更大的大小,但是实际上,这仍然是16位类型)。 由于数学上的乘积为2,250,000,000,因此将对65,536进行模数截断,得出18,048的答案,但是,以这种方式考虑,我们就忽略了整数类型的扩展。 根据C标准(第6.3.1.1节,第2节),如果操作数的类型严格小于int的大小,并且该类型的值可以用int类型表示,而不会丢失位(而我们只有这种情况:在我的x86上Linux的int大小为32位,并且可以显式存储0到65,535之间的值),然后将两个操作数都强制转换为int,并且已经对转换后的值执行了操作。 即:乘积被计算为int类型的值并且仅在从函数返回时才将其带回无符号short (也就是说,此时会发生以65 536为模的截断)。 问题在于,在数学上,逆变换之前的结果为220.5万,并且该值超出了有符号类型int的范围。 结果,我们得到未定义的行为。 此后,任何事情都可能发生,包括英国爱国主义的突然爆发。

但是,实际上,对于普通的编译器,结果是18,048,因为仍然没有优化可以利用此特定程序中的不确定行为(可能会想象出更多会导致麻烦的人为场景)。

最后,现在是C ++中的另一个示例:

 #include <stdio.h> #include <stdlib.h> #include <string.h> #include <array> int main(int argc, char *argv[]) { std::array<char, 16> tmp; int i; if (argc < 2) { return EXIT_FAILURE; } memset(tmp.data(), 0, 16); if (strlen(argv[1]) < 16) { strcpy(tmp.data(), argv[1]); } for (i = 0; i < 17; i ++) { printf(" %02x", tmp[i]); } printf("\n"); } 

对您来说,这不是典型的“糟糕的strcpy() !”。 实际上,仅当源字符串(包括终端零)的大小足够小时,才执行strcpy()函数。 此外,数组的元素被显式初始化为零,因此数组中的所有字节都具有给定值,而不管是将大字符串还是小字符串传递给该函数。 同时,循环的末尾是不正确的:它读取的字节数比应读取的多。

运行代码:

 $ g++ -W -Wall -O9 testvec.c $ ./a.out foo 66 6f 6f 00 00 00 00 00 00 00 00 00 00 00 00 00 10 58 ffffffca ff ffffac ffffffc0 55 00 00 00 ffffff80 71 34 ffffff99 07 ffffffba ff ffffea ffffffd0 ffffffe5 44 ffffff83 fffffffd 7f 00 00 00 00 00 00 00 00 00 00 10 58 ffffffca ffffffac ffffffc0 55 00 00 ffffff97 7b 12 1b ffffffa1 7f 00 00 02 00 00 00 00 00 00 00 ffffffd8 ffffffe5 44 ffffff83 fffffffd 7f 00 00 00 ffffff80 00 00 02 00 00 00 60 56 (...) 62 64 3d 30 30 zsh: segmentation fault (core dumped) ./a.out foo ++? 

您可以天真地反对:嗯,它读取了数组边界之外的一个额外字节; 但这并不那么令人恐惧,因为在堆栈上该字节仍然存在,它已映射到内存,因此这里唯一的问题是具有未知值的额外的第十七个元素。 该循环仍将精确打印17个整数(以十六进制格式),并且在结束时不会有任何抱怨。

但是编译器对此有自己的见解。 他很清楚第十七读引起了不定行为。 根据他的逻辑,任何随后的指令都处于混乱状态:不需要在不确定的行为之后完全存在某些东西(正式地,甚至先前的指令也可能受到攻击,因为不确定的行为也沿相反的方向起作用)。 在我们的例子中,编译器将简单地忽略循环中的条件检查,并且它将永远旋转,或者直到它开始在分配给堆栈的内存之外读取数据之后, SIGSEGV信号才会起作用。

这很有趣,但是如果GCC在启动时没有进行积极的优化设置,则会发出警告:

 $ g++ -W -Wall -O1 testvec.c testvec.c: In function 'int main(int, char**)': testvec.c:20:15: warning: iteration 16 invokes undefined behavior [-Waggressive-loop-optimizations] printf(" %02x", tmp[i]); ~~~~~~^~~~~~~~~~~~~~~~~ testvec.c:19:19: note: within this loop for (i = 0; i < 17; i ++) { ~~^~~~ 

-O9,此警告以某种方式消失。 也许事实是,在高度优化的情况下,编译器会更积极地实施循环的部署。 这可能是(但不准确的)这是GCC错误(在失去警告的意义上;因此,在任何情况下GCC的行为都不会与标准相抵触,因为在这种情况下它不需要发出“诊断”)。

结论:如果您使用C或C ++编写代码,请格外小心,避免出现导致未定义行为的情况,即使看起来“没关系”也是如此。

无符号整数类型是算术计算的好帮手,因为它们是有保证的模块化语义(但是您仍然会遇到与整数类型扩展有关的问题)。 另一个选择-由于某种原因不受欢迎-根本不使用C和C ++编写。 由于多种原因,该解决方案并不总是合适的。 但是如果您可以选择使用哪种语言编写程序,即 当您仅在支持Go,Rust,Java或其他语言的平台上启动新项目时,拒绝使用C作为“默认语言”可能更有利可图。 选择工具(包括编程语言)始终是一种折衷方案。 C的陷阱,特别是在带符号类型的操作中行为不确定,会导致代码进一步维护的额外成本,而这些成本通常被低估了。

Source: https://habr.com/ru/post/zh-CN439502/


All Articles