可变修改类型的兼容性及其安全影响

10

我对C99的可变修饰类型系统产生了浓厚的兴趣。这个问题是受到这篇文章的启发。

查看这个问题的代码,我发现了一些有趣的东西。考虑下面的代码:

int myFunc(int, int, int, int[][100]);

int myFunc(int a, int b, int c, int d[][200]) {
    /* Some code here... */
}

这段代码明显不能编译(也确实没有编译)。然而,下面这段代码:

int myFunc(int, int, int, int[][100]);

int myFunc(int a, int b, int c, int d[][c]) {
    /* Some code here... */
}

在gcc编译时,甚至没有警告。

这似乎意味着可变修改的数组类型与任何非可变修改的数组类型都兼容!

但这还不是全部。你会期望可变修改的类型至少会关心使用哪个变量来设置其大小。但它似乎并没有这样做!

int myFunc(int, int b, int, int[][b]);

int myFunc(int a, int b, int c, int d[][c]) {
    return 0;
}

同时编译没有任何错误。

所以,我的问题是:这种行为是否正确标准化了?

另外,如果一个可变修改的数组类型确实与具有相同维度的任何数组兼容,这是否意味着存在恶意安全问题?例如,请考虑以下代码:

int myFunc(int a, int b, int c, int d[][c]) {
    printf("%d\n", sizeof(*d) / sizeof((*d)[0]));
    return 0;
}

int main(){
    int arr[10] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
    myFunc(0, 0, 100, &arr);

    return 0;
}

编译并输出100,没有错误或警告,什么都没有。在我看来,这意味着轻松越界写入数组,即使您通过sizeof严格检查数组的大小,不进行任何转换,甚至打开所有警告!还是我漏掉了些什么?


如果你还没有尝试过,在gcc编译行中添加-std=c99 -pedantic-errors,看看是否有任何不同。 - jschultz410
@jschultz410:好主意,但是不行——这完全没有任何区别=( - user3079266
有很多情况下编译器无法静态推断出c的值(例如,c是从stdin输入的)。因此,对于这种函数定义的参数,通常无法进行任何有意义的静态类型检查。如果这样做,似乎编译器会说:“好吧,只要d的类型是int的双重索引数组,我就允许你传递任何你想要的内容。祝你好运!” - jschultz410
在这样一个函数中,不同的调用会发生什么情况?如果使用不同的c值来推进d,它会正确地动态确定在内存中应该前进多远吗? - jschultz410
@jschultz410:我不确定我理解你的意思... 你能举个例子吗? - user3079266
2个回答

4
C99,第6.7.5.2节似乎是给出相关规则的地方。特别是,
第6行:
对于两个数组类型要兼容,两者必须拥有兼容的元素类型,并且如果两个大小说明符都存在并且是整数常量表达式,则这两个大小说明符必须具有相同的常量值。如果两个数组类型在需要它们兼容的上下文中使用,则当这两个大小说明符评估为不等值时将会导致未定义行为。
先前的答案,现已删除,也提到了第6行。评论认为第二句话受第一句话末尾条件的限制,但这似乎是一个不太可能的解读。该部分的示例3可能会澄清(摘录):
int c[n][n][6][m];
int (*r)[n][n][n+1];
r=c;   // compatible, but defined behavior only if
       // n == 6 and m == n+1

这似乎与问题中的示例相似:两种数组类型,一个具有常量维度,另一个具有相应的可变维度,并且需要兼容。当在运行时变量维数与编译时常量维数不同时,行为是未定义的(根据示例3中的注释和6.7.5.2/6的一种合理解读)。而且未定义的行为难道不是您期望的吗?否则为什么要提出这个问题?
假设我们可以同意当出现这种不匹配时行为是未定义的,我观察到编译器通常不需要识别未定义或可能未定义的行为,也不需要发出任何诊断,即使它们识别了这样的行为。我希望在这种情况下编译器能够警告可能未定义的行为,但它必须成功编译代码,因为它在语法上是正确的并满足所有适用的约束条件。请注意,一个能够警告此类使用的编译器可能不会默认这样做。

谢谢,这是一个很好的答案!我认为你是对的,我对这个规则的理解可能是错误的。我可能太习惯于类型不兼容导致 UB 是 Number One... 这使得 C 标准没有问题,但 gcc 不行 =) 无论如何,抱怨它不发出警告可能不会有太大作用... - user3079266
如果由我决定,我会明确声明虚拟机类型与所有其他内容不兼容,以确保安全。在我看来这是有道理的。 - user3079266
1
@Mints,虚拟机类型至少需要与自身兼容,否则它们将无法使用。即使虚拟机类型具有完全相同的声明符,当变量维度在运行时不同时,仍可能出现未定义的行为。考虑到这个问题,让VM类型自动与其他所有类型不兼容有什么好处呢?这又是C语言提供给程序员强大工具的另一个例子,借助这些工具,程序员可以造成强大的破坏。C语言不适合新手。(这里并不是在暗示什么。) - John Bollinger

-1
#include <stdio.h>

void foo(int c, char d[][c])
{
  fprintf(stdout, "c = %d; d = %p; d + 1 = %p\n", c, d, d + 1);
}

int main()
{
  char x[2][4];
  char y[3][16];
  char (*z)[4] = y;  /* Warning: incompatible types */

  foo(4, x);
  foo(16, y);
  foo(16, x);        /* We are lying about x. What can / should the compiler / code do? */
  foo(4, y);         /* We are lying about y. What can / should the compiler / code do? */

  return 0;
}

输出:

c = 4; d = 0x7fff5b295b70; d + 1 = 0x7fff5b295b74
c = 16; d = 0x7fff5b295b40; d + 1 = 0x7fff5b295b50
c = 16; d = 0x7fff5b295b70; d + 1 = 0x7fff5b295b80
c = 4; d = 0x7fff5b295b40; d + 1 = 0x7fff5b295b44

所以,foo()函数会根据c动态计算d需要前进的距离,正如您的代码所示。

然而,编译器通常无法静态确定何时/如何调用foo()函数。如果这样做,那么编译器会说:“好吧,我允许你传递任何类型的d,只要它是一个双重索引的字符数组。指针d上的操作将由c决定。祝你好运!”

也就是说,是的,编译器通常无法对这些参数进行静态类型检查,因此标准几乎肯定不会强制要求编译器捕获所有可能静态确定类型不兼容的情况。


是的,因为sizeof(*d)是使用c计算的,就像我在问题中展示的那样 =) - user3079266
然而,编译器通常无法确定您何时/如何正确调用foo()。这正是问题所在。如果这完全破坏了静态类型检查,为什么我可以调用foo呢?此外,可以使用动态类型检查。 VLA实现必须将其大小作为某种形式的元数据保留,以进行动态的sizeof调用,那么为什么不说,如果此大小与其作为参数传递的参数类型的大小不匹配,则会导致segfault或go UB? - user3079266
2
@Mints97:C语言不需要动态类型检查。如果大小不匹配,则其行为是未定义的;正如你所说,“go UB”。未定义的行为并不意味着程序会崩溃,而是指行为是未定义的。这通常包括程序似乎“工作”了。 - Keith Thompson

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接