使用 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 有两个潜在来源。
- 通过不兼容类型 (
int
) 的左值访问对象(int[10]
类型)。
- 使用
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)
,它无权做出这样的假设。
一些代码像这样展平多维数组:
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 有两个潜在来源。
- 通过不兼容类型 (
int
) 的左值访问对象(int[10]
类型)。 - 使用
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)
,它无权做出这样的假设。