使用 gcc 检测数组扁平化技巧

Detecting array flattening trick with gcc

一些代码像这样展平多维数组:

int array[10][10];
int* flattened_array = (int*)array;
for (int i = 0; i < 10*10; ++i)
   flattened_array[i] = 42;

据我所知,这是未定义的行为。

我正在尝试使用 gcc 消毒剂来检测此类情况,但是,-fsanitize=address-fsanitize=undefined 都不起作用。

是否有我遗漏的消毒剂选项,或者在 运行 时间检测它的不同方法?或者也许我弄错了,代码是合法的?

编辑:消毒程序将此访问检测为错误:

array[0][11] = 42;

但没有检测到:

int* first_element = array[0];
first_element[11] = 42;

此外,clang 检测到第一次访问静态,并发出警告

warning: array index 11 is past the end of the array (which contains 10 elements) [-Warray-bounds]

编辑:如果将声明中的 int 替换为 char,则上述内容 不会 发生变化。

编辑:UB 有两个潜在来源。

  1. 通过不兼容类型 (int) 的左值访问对象(int[10] 类型)。
  2. 使用 int* 类型的指针和 >=10 索引进行越界访问,其中基础数组的大小为 10(而不是 100)。

消毒剂似乎没有检测到第一种违规行为。这是否属于违规行为存在争议。毕竟,在同一地址还有一个 int 类型的对象。

至于第二个潜在的 UB,UB 消毒器确实检测到此类访问,但前提是它是直接通过二维数组本身完成的,而不是通过指向其第一个元素的另一个变量完成的,如上所示。我认为这两种访问在合法性上应该没有区别。它们要么都是合法的(然后 ubsan 有误报),要么都是非法的(然后 ubsan 有误报)。

编辑:附录 J2 说 array[0][11] 应该是 UB,尽管它只是提供信息。

从语言律师的角度来看,这通常被视为无效代码,因为整数数组的大小仅为 10,并且代码确实访问了 声明的数组大小 。然而它曾经是一个常见的习语,我知道没有编译器会不接受它。仍然使用我所知道的所有真实世界的编译器,生成的程序将具有预期的行为。

在第二次(实际上更多)阅读 C11 标准草案 (n1570) 后,该标准的意图仍然不清楚。 6.2.5 类型§ 20 说:

An array type describes a contiguously allocated nonempty set of objects with a particular member object type, called the element type.

清楚地表明数组包含连续分配的对象。但是恕我直言,不清楚一组连续分配的对象是否是一个数组。

如果您回答否,那么显示的代码确实会通过访问最后一个元素之后的数组来调用 UB

但如果你回答是,那么一组 10 个连续的 10 个连续整数的集合给出 100 个连续的整数,可以看作是一个 100 个整数的数组。那么显示的代码就是合法的。

后一种接受似乎在现实世界中很常见,因为它与动态数组分配一致:您为多个对象分配了足够的内存,并且可以访问它,就好像它已被声明为数组一样 -并且分配函数确保没有对齐问题。

我目前的结论是:

  • 代码是否漂亮干净:当然不是,我会在生产代码中避免使用它
  • 是否会调用UB:我真的不知道,我个人的看法可能不会

让我们看看编辑中添加的代码:

array[0][11] = 42;

编译器知道数组声明int[10][10]。所以它知道两个索引必须小于 10,它可以发出警告。

int* first_element = array[0];
first_element[11] = 42;

first_element 被声明为一个指针。静态地,编译器必须假定它可以指向一个未知大小的数组内部,因此在特定上下文之外,发出警告要困难得多。当然,对于人类程序员来说,两种方式显然应该被视为相同,但由于不需要编译器对越界数组发出任何诊断,因此检测它们的工作量降至最低,并且仅检测到微不足道的情况.

此外,当编译器在通用平台上对指针算法进行内部编码时,它只是计算一个内存地址,即原始地址和一个字节偏移量。所以它可以发出与以下相同的代码:

char *addr = (char *) first_element;  // (1)
addr += 11 * sizeof(int);             // (2)
*((int *) addr) = 42;                 // (3)

(1) 是合法的,因为指向任何对象的指针(这里是一个 int)可以转换为指向 char 的指针,它需要指向对象表示的第一个字节

(2)这里的技巧是(char *) first_element(char *) array是一样的 因为10*10数组的第一个字节第一个第一行第一个整数的字节,一个字节只能有一个地址。由于array的大小是10 * 10 * sizeof(int)11 * sizeof(int)是其中的有效偏移量。

(3) 出于同样的原因,(char *) &array[1][1] addr 因为数组中的元素是连续的,所以它们的字节表示也是连续的。由于 2 种类型之间的来回转换是合法的,并且需要返回原始指针,(int *) addr (int*) ((char*) &array[1][1])。这意味着取消引用 (int *) addr 是合法的,并且与 array[1][1] = 42.

具有相同的效果

这并不意味着first_element[11]不涉及UB。 array[0] 声明的大小为 10。它只是解释了为什么所有已知的编译器都接受它(除了不想破坏遗留代码之外)。

多维数组要求连续分配(C使用row-major)。并且数组的元素之间不能有任何填充 - 尽管标准中没有明确说明,这可以通过数组定义推断为“contiguously allocated nonempty set of objects" and the definition of sizeof operator.

所以“扁平化”应该是合法的。


回复。 accessing array[0][11]: 虽然Annex J2直接给出了例子,但是具体违反规范的是什么并不明显。尽管如此,仍然有可能使 char*:

的中间转换合法
*((int*)((char*)array + 11 * sizeof(int))) = 42;

(显然不建议写这样的代码;)

净化器不是特别擅长捕获 out-of-bounds 访问,除非所讨论的数组是一个完整的对象。

例如,在这种情况下,他们不会捕获 out-of-bounds 访问:

struct {
   int inner[10];
   char tail[sizeof(int)];
} outer;

int* p = outer.inner;
p[10] = 42;

这显然是非法的。但他们确实可以访问 p[11].

数组扁平化与这种访问在本质上并没有什么不同。编译器生成的代码,以及消毒剂对其进行检测的方式,应该非常相似。因此,这些工具可以检测到数组扁平化的希望很小。

这里的问题是,标准将两个操作描述为等价的,其中一个应该明确定义,而另一个标准明确表示没有定义。

解决这个问题的最干净的方法,这似乎与 clang 和 gcc 已经做的一致,也就是说将 [] 运算符应用于数组左值或 non-l 值 不会导致它衰减,而是直接查找一个元素,如果数组操作数是左值则产生一个左值,否则产生一个non-l值。

[] 与数组一起使用作为不同的运算符将清除语义中的许多极端情况,包括访问函数返回的结构中的数组,register-qualified 数组,位域数组等。它还可以明确 inner-array-subscript 限制的含义。给定 foo[x][y],编译器将有权假设 y 将在内部数组的范围内,但给定 *(foo[x]+y),它无权做出这样的假设。