复制具有 "memcpy" 技术上未定义行为的二维数组吗?

Is copying 2D arrays with "memcpy" technically undefined behaviour?

的评论中出现了一个有趣的讨论:现在,尽管那里的语言是 C,但讨论已经转向 C++ 标准规定,在使用 std::memcpy.

等函数访问多维数组的元素时,什么构成未定义的行为

首先,这是该问题的代码,已转换为 C++ 并尽可能使用 const

#include <iostream>
#include <cstring>

void print(const int arr[][3], int n)
{
    for (int r = 0; r < 3; ++r) {
        for (int c = 0; c < n; ++c) {
            std::cout << arr[r][c] << " ";
        }
        std::cout << std::endl;
    }
}

int main()
{
    const int arr[3][3] = { {1, 2, 3}, {4, 5, 6}, {7, 8, 9} };
    int arr_copy[3][3];
    print(arr, 3);
    std::memcpy(arr_copy, arr, sizeof arr);
    print(arr_copy, 3);
    return 0;
}

问题出在对 std::memcpy 的调用中:arr 参数将产生(通过衰减)指向第一个 int[3] 子数组的指针,因此,根据讨论(由 Ted Lyngmo 领导),当 memcpy 函数访问该子数组的第三个元素以外的数据时,存在 正式 未定义的行为(同样适用到达目的地,arr_copy).

然而,辩论的另一方(mediocrevegetable1 和我同意)使用的理由是每个二维数组 根据定义 ,占据连续内存,并且由于 memcpy 的参数只是指向这些位置的 void* 指针(第三个 size 参数有效),因此这里不可能有 UB。

以下是与辩论最相关的一些评论的摘要,以防对原始问题进行任何“清理”(加粗以强调我的):

I don't think there's any out-of-bounds here. Just like memcpy works for an array of ints, it works for an array of int [3]s, and both should be contiguous (but I'm not 100% sure). – mediocrevegetable1

The out of bounds access happens when you copy the first byte from arr[0][3]. I've never seen it actually fail, but, in C++, it has UB. – Ted Lyngmo

But the memcpy function/call doesn't do any array indexing - it's just given two void* pointers and copies memory from one to the other. – Adrian Mole

I can't say for sure if that matters in C. In C++ it doesn't. You get a pointer to the first int[3] and any access out of its range has UB. I haven't found any exception to that in the C++ standard. – Ted Lyngmo

I don't think the arr[0][3] thing applies. By that logic, I think copying the second int of an int array through memcpy would be UB as well. int [3] is simply the type of arr's elements, and the bounds of arr as a whole in bytes should be sizeof (int [3]) * 3. I'm probably missing something though :/ – mediocrevegetable1

是否有任何 C++ 语言律师可以解决这个问题——最好是从 C++ 标准中适当引用(一个或多个)?

此外,C 标准中的相关引用可能会有帮助——尤其是当两种语言标准不同时——所以我在这个问题中加入了 C 标签。

std::memcpy(arr_copy, arr, sizeof arr);(您的示例)定义明确。

另一方面,

std::memcpy(arr_copy, arr[0], sizeof arr); 会导致未定义的行为(至少在 C++ 中;对 C 不完全确定)。


多维数组是数组的一维数组。据我所知,与真正的一维数组(即具有非数组类型元素的数组)相比,它们没有得到太多(如果有的话)特殊处理。

考虑一个一维数组的例子:

int a[3] = {1,2,3}, b[3];
std::memcpy(b, a, sizeof(int) * 3);

这显然是合法的。1

注意 memcpy 接收指向数组第一个元素的指针,并且可以访问其他元素。

元素类型不影响此示例的有效性。如果使用二维数组,元素类型变为 int[N] 而不是 int,但有效性不受影响。

现在,考虑一个不同的例子:

int a[2][2] = {{1,2},{3,4}}, b[4];
std::memcpy(b, a[0], sizeof(int) * 4);
//             ^~~~

这个导致UB2,因为memcpy被赋予了指向a[0]第一个元素的指针,它只能访问a[0]的元素a[0] (a[0][i]),而不是 a[j][i].

但是,如果你想听听我的意见,这是一种“温和”的 UB,在实践中可能不会造成问题(但是,一如既往,应尽可能避免 UB)。



1 C++标准没有解释memcpy,而是引用了C标准。 C 标准使用了一些草率的措辞:

C11 (N1570) [7.24.2.1]/2

The memcpy function copies n characters from the object pointed to by s2 into the object pointed to by s1.

指向数组第一个(或任何)元素的指针仅指向该元素,而不指向整个数组,即使整个数组可通过该指针到达。因此,如果从字面上解释,@LanguageLawyer 似乎是正确的:如果您给 memcpy 一个指向数组元素的指针,您只能复制该单个元素,而不能复制连续的元素。

这种解释与常识相矛盾,很可能不是故意的。

例如考虑 [basic.types.general]/2 中的示例,它使用指向第一个元素的指针将 memcpy 应用于数组:(即使示例是非规范的)

constexpr std::size_t N = sizeof(T);
char buf[N];
T obj;
std::memcpy(buf, &obj, N);
std::memcpy(&obj, buf, N);

2 这是没有实际意义的,因为上面描述的 memcpy 的措辞有问题。

我对 C 不太确定,但对于 C++,有强烈的暗示表明这是 UB。

首先,考虑一个使用 std::copy_n 的类似示例,尝试执行按元素复制而不是按字节复制:

#include <algorithm>

consteval void foo()
{
    int a[2][2] = {{1,2},{3,4}}, b[2][2] = {{1,2},{3,4}};
    std::copy_n(a[0], 4, b[0]);
}

int main() {foo();} 

运行 函数在编译时捕获大多数形式的 UB(它使代码格式不正确),实际上编译此代码段会给出:

error: call to consteval function 'foo' is not a constant expression
note: cannot refer to element 4 of array of 2 elements in a constant expression

memcpy的情况不太确定,因为它执行的是逐字节复制。整个主题似乎是 vague and underspecified.

考虑 std::launder 的措辞:

[ptr.launder]/4

A byte of storage b is reachable through a pointer value that points to an object Y if there is an object Z, pointer-interconvertible with Y, such that b is within the storage occupied by Z, or the immediately-enclosing array object if Z is an array element.

换句话说,给定一个指向数组元素的指针,该数组的所有元素都可以通过该指针到达(非递归,即通过 &a[0][0] 只有 a[0][i] 可以到达)。

形式上,这个定义只是用来描述std::launder(它不能扩展给它的指针的可达区域的事实)。但暗示似乎是这个定义总结了标准其他部分描述的可达性规则([static.cast]/13, notice that reinterpret_cast is defined through the same wording; also [basic.compound]/4)。

尚不完全清楚上述规则是否适用于 memcpy,但它们应该适用。因为否则,程序员将能够忽略使用库函数的可达性,这将使可达性的概念几乎毫无用处。

我目前的观点是,当 int[3][3] 作为参数传递给函数时,它会衰减为指向该数组中第一个元素的指针。第一个元素是一个 int[3],另外两个 int[3] 在范围内——就像当你将一个 1D int[3] 传递给一个函数时,你会得到一个指向第一个 [=14] 的指针=] 和其他两个 int 在范围内,因此 memcpy 是安全的。


原回答:

这个答案是基于我很久以前阅读的一些错误假设。我会留下答案和评论,也许可以防止其他人陷入同样的​​思维陷阱。

传递给函数的内容衰减为指向第一个元素的指针,在这种情况下,两个 int(*)[3]s。

C draft附件J (资料性)可移植性问题 J.2 未定义的行为:

An array subscript is out of range, even if an object is apparently accessible with the given subscript (as in the lvalue expression a[1][7] given the declaration int a[4][5]) (6.5.6).

memcpy(arr_copy, arr, sizeof arr); 得到两个 int(*)[3] 并且将访问超出范围的两个,因此,UB。

C++ 标准说 ([cstring.syn]/1):

The contents and meaning of the header <cstring> are the same as the C standard library header <string.h>.

C11 7.24.2.1 The memcpy function 说:

Synopsis

1

         #include <string.h>
         void *memcpy(void * restrict s1,
              const void * restrict s2,
              size_t n);

Description

2 The memcpy function copies n characters from the object pointed to by s2 into the object pointed to by s1

鉴于此描述,人们可能想知道如果 n 大于 s1/s2 指向的对象的大小会怎样。 “常识”表明,从 int 对象复制超过 sizeof(int) 个字节应该是没有意义的。

的确,7.24.1 String function conventions p.1 说:

The header <string.h> declares one type and several functions, and defines one macro useful for manipulating arrays of character type and other objects treated as arrays of character type. … Various methods are used for determining the lengths of the arrays, but in all cases a char * or void * argument points to the initial (lowest addressed) character of the array. If an array is accessed beyond the end of an object, the behavior is undefined.

因此,当传递指向数组第一个元素的指针时,它是 memcpy p.2 中的“对象”,并且试图复制比该对象更多的字节是 UB。

Is copying 2D arrays with "memcpy" technically undefined behaviour?

(n.b., 根据 https://port70.net/~nsz/c/c11/n1570.html 的 C11 标准草案,这仅涵盖 C)

不是,是不是

TLDR 摘要:

6.7.6.3 Function declarators (including prototypes), paragraph 7 defines decay of arrays to pointers in function calls. BUT that decay is done under the auspices of 6.9.1 Function definitions, paragraph 7, which states "... in either case, the type of each parameter is adjusted as described in 6.7.6.3 为参数类型列表; 结果类型应该是一个完整的对象类型。"

直接反驳了数组作为函数参数传递时数组衰减产生的指针不指向整个数组的概念。

详细解答

第一个数组是“完整对象”。

为什么数组必须是“完整对象”

(如果有人能在标准中找到将数组定义为“完整对象”的语句,则此答案的整个部分都是多余的。)

虽然在(草案)C11 标准中没有明确定义(至少在我能找到的任何地方都没有),但数组在多个语句中是隐式的“完整对象”,例如数组被显式定义的语句从“完整对象”类别中删除:

6.5.2.2 Function calls, paragraph 1:

The expression that denotes the called function shall have type pointer to function returning void or returning a complete object type other than an array type.

6.7.2.1 Structure and union specifiers does not explicitly allow array members of structures and unions other than "flexible array members" in paragraph 18:

As a special case, the last element of a structure with more than one named member may have an incomplete array type; this is called a flexible array member. ...

6.7.2.1 结构和联合说明符的唯一段落是paragraph 9:

A member of a structure or union may have any complete object type other than a variably modified type.

这是(草案)C11 标准中唯一允许在结构和联合中包含数组的语句。

数组初始化由6.7.9 Initialization, paragraph 3覆盖:

The type of the entity to be initialized shall be an array of unknown size or a complete object type that is not a variable length array type.

这仅涵盖通过“完整对象”类别确定的已知大小的数组。

函数 return 值的数组已被 6.9.1 Function definitions, paragraph 3 明确从“完整对象”类别中删除:

The return type of a function shall be void or a complete object type other than array type.

所以,我们已经确定数组是“完整的对象”。

函数的参数是“完整的对象类型”

根据 6.9.1 Function definitions, Semantics, paragraph 7:

the type of each parameter is adjusted as described in 6.7.6.3 for a parameter type list; the resulting type shall be a complete object type.

为什么“完整的对象”很重要

6.5.2.1 Array subscripting, paragraph 1 状态:

One of the expressions shall have type ''pointer to complete object type'', the other expression shall have integer type, and the result has type ''type''.

并且根据 6.9.1p7array 作为“完整对象类型”传递,这意味着可以取消引用指针以访问 整个数组。

Q.E.D.

问题是关于C++的;我只能回答 C。在 C 中,这是定义明确的行为。我将引用 2020 年 12 月 11 日的 C2x 标准草案,该草案位于 http://www.open-std.org/jtc1/sc22/wg14/www/docs/n2596.pdf; 所有重点都与原文相同。

问题是我们是否可以将 memcpy 应用于 int[3][3]int[3][3] 是数组的数组,而 memcpy 对字节起作用。所以我们需要知道标准对数组作为字节的表示是怎么说的。

我们从数组开始。第 6.2.5 节,“类型”,第 22 段,定义了数组类型:

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

因此,int[3][3] 是连续分配的三个 int[3] 对象的非空集合。其中每一个都是连续分配的非空集合,包含三个 int 个对象。

我们先问一下int个对象。每个人都希望单个 int 中的 memcpy 能够正常工作。为了解标准要求,我们查看第 6.2.6.1 节“一般”第 2 段:

Except for bit-fields, objects are composed of contiguous sequences of one or more bytes, the number, order, and encoding of which are either explicitly specified or implementation-defined.

所以 int 是一个或多个字节的连续序列。因此我们的 int[3][3] 是三个连续序列的三个连续序列的连续序列 sizeof(int) 字节;标准 要求 它是 9 × sizeof(int) 个连续字节。

该标准还对这些字节与数组索引的关系提出了要求。第 6.5.2.1 节“数组下标”第 2 段说:

A postfix expression followed by an expression in square brackets [] is a subscripted designation of an element of an array object. The definition of the subscript operator [] is that E1[E2] is identical to (*((E1)+(E2))).

所以 arr[1] == *((arr)+(1)) 是第二个 int[3]arr[1][2] == *((*((arr)+(1)))+(2)) 是它的第三个元素,这必须是 arr 开始后的第六个 int .第 3 段明确说明了这一点:

Successive subscript operators designate an element of a multidimensional array object. If E is an n-dimensional array (n ≥ 2) with dimensions i × j × ··· × k, then E (used as other than an lvalue) is converted to a pointer to an (n − 1)-dimensional array with dimensions j × ··· × k. If the unary * operator is applied to this pointer explicitly, or implicitly as a result of subscripting, the result is the referenced (n − 1)-dimensional array, which itself is converted into a pointer if used as other than an lvalue. It follows from this that arrays are stored in row-major order (last subscript varies fastest).

尽管如此,您仍然无法访问 arr[0][4]。正如 Ted Lyngmo 的回答说明,附录 J.2 特别指出:

An array subscript is out of range, even if an object is apparently accessible with the given subscript (as in the lvalue expression a[1][7] given the declaration int a[4][5]) (6.5.6).

但由于 memcpy 实际上是关于字节的,所以没关系。它的源和目标不是多维数组,而是 void *。 7.24.2.1,“memcpy 函数”解释说:

The memcpy function copies n characters from the object pointed to by s2 into the object pointed to by s1.

根据 3.7 节,一个“字符”可以有三种含义。相关的似乎是“单字节字符”(3.7.1),因此 memcpy 复制 n 字节。因此 memcpy(arr_copy, arr, sizeof(arr)) 必须正确地将 arr 复制到 arr_copy

虽然仔细想想,memcpy 并没有说它复制了 n 个连续的字节。我想它可以复制相同的字节 n 次。或者选择 n 个随机字节。这将使调试...变得有趣。

定义明确,即使你使用memcpy(arr_cpy, arr, size)而不是
memcpy(&arr_cpy, &arr, size)(他们一直在争论 ),原因由@HolyBlackCat 和其他人解释。

标准的 intended 含义很明确,任何与此相反的语言都是标准中的缺陷,编译器开发人员不会用它来掩盖事实从不将 int* 转换为 int (*)[N] 的 memcpy(包括一维数组)的无数正常使用中,尤其是因为 ISO C++ 不允许可变长度数组。

关于编译器开发人员如何选择将标准解释为让 memcpy 从指向的整个外部对象(数组的数组)读取的实验证据 - 通过 void* arg,即使 void* 是作为指向第一个元素(即第一个 array-of-int)的指针获得的:

如果您传递的尺寸过大,您会收到警告,对于 GCC,警告甚至会准确说明它看到的对象和尺寸 memcpyed:

#include <cstring>

int dst[2][2];
void foo(){
    int arr[2][2] = {{1,1},{1,1}};
    std::memcpy(dst, arr, sizeof(arr));  // compiles cleanly
}

void size_too_large(){
    int arr[2][2] = {{1,1},{1,1}};
    std::memcpy(dst, arr, sizeof(arr)+4);
}

使用 &dst, &src 对警告或缺少警告没有影响。
Godbolt compiler explorer 用于 GCC 和 clang -O2 -Wall -Wextra -pedantic -fsanitize=undefined,以及 MSVC -Wall.

GCC 对 size_too_large() 的警告是:

warning: 'void* memcpy(void*, const void*, size_t)' forming offset [16, 19] is  \
  out of the bounds [0, 16] of object 'dst' with type 'int [2][2]' [-Warray-bounds]
   11 |     std::memcpy(dst, arr, sizeof(arr)+4);
      |     ~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~
<source>:3:5: note: 'dst' declared here
    3 | int dst[2][2];

clang 没有拼出对象类型,但仍显示大小:

<source>:11:5: warning: 'memcpy' will always overflow; destination buffer has size 16, but size argument is 20 [-Wfortify-source]
    std::memcpy(dst, arr, sizeof(arr)+4);
    ^

所以在实际编译器中它显然是安全的,这是我们已经知道的事实。 两者都将目标 arg 视为整个 16 字节 int [2][2] 对象。

但是,GCC 和 clang 可能不如 ISO C++ 标准严格。即使将 dst[0] 作为目标(衰减到 int* 而不是 int (*)[2]),它们仍然将目标大小报告为 16 字节,类型为 int [2][2].

HolyBlackCat 的回答指出,以这种方式调用 memcpy 实际上只会给它 2 元素子数组,而不是整个 2D 数组,但编译器不会试图阻止您或警告您使用指向访问更大对象的任何部分的第一个元素。

正如我所说,测试真正的编译器只能告诉我们,这是目前在它们上定义明确的;关于他们将来可能做什么的争论需要其他推理(基于没有人想要破坏 memcpy 的正常使用,以及标准的预期含义。)


ISO 标准的确切措辞:可以说 一个缺陷

唯一的问题是,关于标准的措辞在解释哪个对象与语言相关的方式方面存在缺陷的论点是否有任何价值超越对象的末尾,是否仅限于数组到指针“衰减”之后的单个指向对象,以便将 arg 传递给 memcpy。 (是的,这将是标准中的一个缺陷;人们普遍认为您不需要也不应该将 &arr 与 memcpy 的数组类型一起使用,或者基本上永远是 AFAIK。)

对我来说,这听起来像是对标准的误解,但我可能有偏见,因为我当然希望将其解读为我们都知道在实践中是正确的。我仍然认为明确定义是对标准中措辞的 a 有效解释,但其他解释也可能有效。 (即它是否是 UB 可能是模棱两可的,这将是一个缺陷。)

指向数组第一个元素的 void* 可以转换回 int (*)[2] 以访问整个数组对象。这不是 memcpy 使用它的方式,但它表明指针并没有失去其作为指向整个 N 维数组的指针的地位。我认为标准的作者假设了这个推理,即 this void* 可以被认为是指向整个对象的指针,而不仅仅是第一个元素。

但是,对于 memcpy 的工作方式确实存在特殊的语言,正式阅读可能会争辩说这不会让您依赖关于内存工作方式的正常 C 假设。

但是标准允许的 UB 解释并不是任何人想要它工作的方式或认为它应该的方式。并且它适用于一维数组,因此这种解释与使用 memcpy 的标准示例相冲突,这些示例是众所周知的/普遍认为有效的。因此,任何认为标准中的措辞与此不完全匹配的论点都是在措辞中存在缺陷的论点,而不是我们需要更改代码并避免这种情况。

编译器开发人员也没有动机尝试声明此 UB,因为这里几乎没有什么优化(与有符号溢出、基于类型的别名或假设没有 NULL deref 不同)。

假设运行时变量大小必须最多只影响被强制转换为 void* 的指针类型的整个第一个元素的编译器不允许在实际代码中进行太多优化。后面的代码很少只在第一个之后严格访问元素,这会让编译器通过旨在编写它的 memcpy 进行常量传播或类似的事情。

(正如我所说,每个人都知道这不是标准 的意图,这与关于签名溢出是 UB 的明确声明不同。)

恕我直言,HolyBlackCat 在最基本的原则上是完全错误的。我的 C17 标准草案在 7.24.1 中说:“对于本子条款中的所有函数 [包含 memcpy],每个字符都应被解释为具有 unsigned char 类型。” C 标准并没有真正对这些微不足道的函数进行任何类型考虑:memcpy 复制内存。就语义而言,它被视为一系列无符号字符。因此,以下第一个 C 原则适用:

只要在某个地址上有一个初始化的对象,你就可以通过字符指针访问它。

为了强调和清楚起见,让我们重复一遍:

任何初始化的对象都可以通过字符指针访问。

如果您知道某个对象位于特定地址 0x42,例如因为您的计算机硬件将鼠标的 x 坐标映射到那里,您可以将其转换为字符指针并读取它。如果坐标是 16 位值,您也可以读取下一个字节。

没有人关心你怎么知道有一个整数:如果有一个,你可以读它。 (Peter Cordes 指出,由于可能的分段内存架构,无法保证您可以通过来自不相关对象的指针算法到达有效地址(或至少到达预期地址)。但这不是示例情况:整个数组是一个对象,必须驻留在单个段中。)

现在我们有了3个3个整数的数组,我们知道9个整数在内存中是连续放置的;那是语言要求。那里的整个内存都是属于单个对象的整数,我们可以通过 char 指针手动对其进行迭代,或者我们可以将其草皮到 memcpy。我们是否使用 arrarr[0] 或通过来自其他变量的堆栈偏移量获取地址 [<- 不保证正确,正如 Peter Cordes 提醒我的那样] 或一些只要地址正确,其他魔术或简单地进行有根据的猜测是完全无关紧要的,这一点毫无疑问。

memcpy 的指示使用将由任何编译器有意义地处理,其作者不滥用标准作为将有用的构造视为“损坏”的借口。唯一应该关心标准是否真正无矛盾地定义它的人是滥用标准的编译器作者,或者那些试图保护自己免受滥用标准的编译器作者的人。如果 C 或 C++ 标准旨在避免滥用,那么可能值得担心它是否 100% 明确指定所有情况 memcpy 应该有效。然而,两者的编写都依赖于编译器编写者认识到,如果标准同时指定某些构造如何工作,但将一组重叠的构造描述为调用未定义行为,则编译器应该真诚地努力以有用的方式处理代码实用。

考虑两个函数:

char arr[4][4][4];

int test1(int i, unsigned mode)
{
  arr[1][0][0] = 1;
  memcpy(arr[0][i], arr[2][0], mode & 4);
  return arr[1][0][0];
}

int test2(int i, unsigned mode)
{
  arr[1][0][0] = 1;
  memcpy(arr[0]+i, arr[2], mode & 4);
  return arr[1][0][0];
}

根据程序员的意图,以下任何一种解释可能最有用:

  1. 以在 memcpy.

    之后重新加载 arr[1][0][0] 的值的方式处理这两个函数
  2. 以 returns 1 无条件地处理这两个函数,而不考虑 memcpy 是否覆盖它。

  3. 以无条件 returns1 的方式处理第一个函数,但以重新加载 arr[1][0][0] 的方式处理第二个函数,基础是标准定义了在数组 lvalues/glvalues 上使用索引运算符在数组衰减后跟指针索引方面,程序员的语法选择通常基于数组类型 lvalue/glvalue 是否实际被使用 as 一个数组,或用作获取指向第一个元素的指针的方法,然后用作进一步地址计算的基础。

如果编译器试图在 imode 为 4 的情况下有意义地处理代码,那么代码的行为方式就不会真正含糊不清。只有一种行为是有意义的。唯一含糊不清的是,适应这种情况的好处是否值得这样做的执行成本;适应行为始终是“安全”的选择。编写标准说当 n 为 4 时 test1 应该为 i==0..3 定义行为,当 n 为零时 i==0..4 应该定义行为,这将是尴尬的,但是 test2 应该为 i==0..15 定义行为,而不考虑 n,但对于大多数目的,语义、兼容性和优化的最佳结合将通过以这种方式处理代码来实现。