关于数组外部指针算术的C标准

时间:2019-05-29 12:10:13

标签: c pointers math standards undefined-behavior

我读了很多有关指针算术和未定义行为的内容(linklinklinklinklink)。总是得出相同的结论:指针算术仅在数组类型上以及在array [0]和array [array_size + 1]之间(在C语言标准中,最后一个元素是有效的)才得到很好的定义。

我的问题是:这是否意味着当编译器看到与任何数组都不相关的指针算术(未定义的行为)时,它可以发出所需的内容(甚至什么也没有)?还是更高级别的“未定义行为”,意味着您可以访问未映射的内存,垃圾数据等,并且不能保证地址的有效性?

在此示例中:

char test[10];
char * ptr = &test[0];
printf("test[-1] : %d", *(ptr-1))

通过“未定义的行为”,仅仅是不能完全保证该值(可能是垃圾,未映射的内存等),但是我们仍然可以肯定地说,我们正在访问与数组8字节相邻的内存地址开始之前?还是以一种“未定义行为”的方式,使编译器根本无法发出此代码?

另一个简单的用例:您要计算一个函数的内存大小。一个简单的实现可能是下面的代码,其中假定函数以相同的顺序在二进制文件中输出,并且是连续的并且之间没有任何填充。

#include <stdint.h>
#include <stdio.h>

void func1()
{}

void func2()
{}

int main()
{
  uint8_t * ptr1 = (uint8_t*) &func1;
  uint8_t * ptr2 = (uint8_t*) &func2;

  printf("Func 1 size : %ld", ptr2-ptr1);

  return 0;
}

由于ptr1ptr2不是数组的一部分,因此被视为未定义的行为。同样,这是否意味着编译器无法发出这些代码?还是“未定义的行为”意味着减法是没有意义的,取决于系统(函数在内存中不连续,带有填充等),但仍会按预期发生?是否存在定义明确的方法来计算两个不相关的指针之间的减法?

4 个答案:

答案 0 :(得分:5)

C标准未定义未定义行为的未定义程度。如果未定义,则总是下注。

此外,现代编译器还弄乱了这种指针出处,在这种情况下,编译器甚至会监视是否正确导出了可能有效的指针,如果不是,则可以调整程序行为。

如果您希望数学指针运算没有UB的可能性, 您可以在进行数学运算之前尝试将指针投射到uintptr_t


例如:

#include <stdio.h>
int main()
{
    char a,b;
    printf("&a=%p\n", &a);
    printf("&b=%p\n", &b);
    printf("&a+1=%p\n", &a+1);
    printf("&b+1=%p\n", &b+1);
    printf("%d\n", &a+1==&b || &b+1==&a);
}
在我的计算机上,用gcc -O2编译的

结果为:

&a=0x7ffee4e36cae
&b=0x7ffee4e36caf
&a+1=0x7ffee4e36caf
&b+1=0x7ffee4e36cb0
0

&a+1&b的数字地址相同,但由于地址是从不同的对象派生的,因此被视为与&b不相等。

(此gcc优化有些争议。它没有跨越函数调用/转换单元的边界,clang并没有这样做,并且没有必要,因为6.5.9p6允许偶然的指针相等。请参见{ {3}}到此dbush Keith Thompson's了解更多信息。)

答案 1 :(得分:1)

C标准必须说出未定义的行为,仅仅是因为诸如内存映射之类的事情超出了该标准的范围。

这不仅适用于数组索引是唯一允许的指针算术形式,还不适用于C的“有效类型”概念,可以将其描述为编译器的内部列表,其中列出了实际存储在任何位置的类型给定它知道的地址。而且,访问编译器不知道的内存部分本质上也是未定义的行为。

如果查看普通的嵌入式系统,则经常需要访问没有数组的地址,并且据编译器所知,根本没有对象(内存映射的寄存器等)。因此,所有此类嵌入式C编译器均保证此类代码的行为可预测,即使此类保证是“非标准扩展”。实际上,这意味着指针会简化为代表物理地址的整数。

最佳实践是编写安全的代码。例如,如果要编写一个转储闪存页面内容的程序,则希望逐字节地对其进行迭代(以将结果放在某个串行总线上)。使用普通的嵌入式系统编译器,可以安全地将volatile const uint8_t*设置到闪存页的第一个字节,然后进行遍历,而不管碰巧存储在此处的变量和类型如何。但是从C的角度来看,这是未定义的行为。

我们可以通过将所有要分配在该页面中的变量放在一个巨大的struct foo { ... } bar;中来满足C和现实世界的需求。我们允许使用指向uint8_t之类的字符类型的指针来逐字节地进行迭代。 (C17 6.3.2.3/7)。

因此,规避未定义行为的工作不一定那么麻烦。通常会使用结构,联合,将指针转换为整数等方法来解决。

答案 2 :(得分:0)

C标准委员会认为没有必要禁止编译器表现出愚蠢的行为,从而使它们不适用于许多目的。确实,根据已发布的《基本原理》,委员会认识到,实施的行为可能是合规但无用的,但他认为,寻求提供使用标准编写的语言的高质量实施的人们应该避免如此愚蠢。考虑一下程序:

void byte_copy(unsigned char *dest, unsigned char *src, int len)
{
  while(len--) *dest++ = *src++;
}
unsigned char src[10][10], dest[100];
void test(int mode)
{
  if (mode == 0)
    byte_copy(dest, src[0], 11);
  else
    byte_copy(dest, (unsigned char*)src, 100);
}

如果test为零,则实现可能会在mode上陷印,这可能是有用的,因为程序员可能打算从src的第一行复制元素,该标准的作者可能不想禁止这样做。另一方面,如果不能使用mode != 0情况下的代码来按字节复制所有类型的对象(包括多维数组),该语言将被严重破坏,委员会很可能认识到。尽管如此,在这两种情况下传递的指针之间没有任何区别。

只有当人们认为通过允许实现以使它们变得无用的方式来破坏语言时,才有必要进行这种区分。由于该标准的作者已经说过,他们认识到它允许实现无用地表现,但是不相信这种可能性会破坏语言,因此这表明他们可能不会认为未能将所有必要构造的行为定义为一种错误。如果他们希望编写标准描述的语言的质量实现能够支持这样的构造,无论如何还是有缺陷的。

关于是否可以依靠人们寻求编写该标准所描述的语言的高质量实现来避免这种愚蠢的问题,如果不了解人们维护某些编译器的动机,可能很难回答这个问题。

答案 3 :(得分:0)

实际上,要证明任意指针算术“与任何数组都不相关”是非常困难的(可能类似于Halting问题?不确定),因为可以通过全局变量将指针“偷偷地”分配给指针,查看地图文件以找到指针的实际地址并对其进行修改等。

该标准的意思是,编译器可能会根据生成的代码(即通常的指针算术)来执行“预期的事情”,但是不能保证所得的指针指向任何有效的指针。因此,行为是“未定义的”。特别是,如果您在数组之前和之后声明一个变量,并且即使指针在数组之前或之后甚至到达一个元素,也不能保证您将触及这些变量或实际上是任何有效的内存。在具有内存保护的系统上,它甚至可能崩溃。实际行为取决于运行代码的系统。