使用“memcpy”复制二维数组在技术上是未定义的行为吗?

Adr*_*ica 70 c c++ multidimensional-array undefined-behavior language-lawyer

在对这个最近问题的评论中出现了一个有趣的讨论:现在,虽然那里的语言是C,但讨论已经转向C++标准所指定的内容,即使用 a 访问多维数组的元素时构成未定义行为的内容。功能类似于std::memcpy.

\n

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

\n
#include <iostream>\n#include <cstring>\n\nvoid print(const int arr[][3], int n)\n{\n    for (int r = 0; r < 3; ++r) {\n        for (int c = 0; c < n; ++c) {\n            std::cout << arr[r][c] << " ";\n        }\n        std::cout << std::endl;\n    }\n}\n\nint main()\n{\n    const int arr[3][3] = { {1, 2, 3}, {4, 5, 6}, {7, 8, 9} };\n    int arr_copy[3][3];\n    print(arr, 3);\n    std::memcpy(arr_copy, arr, sizeof arr);\n    print(arr_copy, 3);\n    return 0;\n}\n
Run Code Online (Sandbox Code Playgroud)\n

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

\n

然而,争论的另一方(mediocrevegetable1和我订阅的)使用的基本原理是,根据定义,每个 2D 数组将占用连续的内存,并且作为参数只是memcpy指向void*这些位置的指针(第三个,size论证有效),那么这里不可能有 UB。

\n

以下是与辩论最相关的一些评论的摘要,以防原始问题发生任何“清理”(粗体表示我的强调):

\n
\n

我不认为这里有任何出界。就像memcpy适用于 s 数组一样int,它也适用于 s 数组int [3],并且两者都应该是连续的(但我不能 100% 确定)。\xe2\x80\x93\nmmediacrevegetable1

\n
\n
\n

当您从 复制第一个字节时,就会发生越界访问arr[0][3]。我从未见过它真正失败,但是,在 C++ 中,它有 UB。\xe2\x80\x93 特德·林莫

\n
\n
\n

但该memcpy函数/调用不执行任何数组索引 - 它只是给出两个void*指针并将内存从一个指针复制到另一个指针。\xe2\x80\x93 阿德里安·摩尔

\n
\n
\n

我不能肯定地说这在 C 中是否重要。在 C++ 中则不然。你得到一个指向第一个的指针int[3],任何超出其范围的访问都有 UB。我在 C++ 标准中没有发现任何例外。\xe2\x80\x93 特德·林莫

\n
\n
\n

我认为这件事不arr[0][3]适用。按照这个逻辑,我认为复制数组int的第二个也是很UB的。只是\ 元素的类型,并且 的整体边界(以字节为单位)应该是。我可能错过了一些东西:/ \xe2\x80\x93 mediocrevegetable1intmemcpyint [3]arrarrsizeof (int [3]) * 3

\n
\n

是否有任何 C++ 语言律师可以最好通过 C++ 标准中的适当引用来解决 \xe2\x80\x93 问题?

\n

另外,C 标准的相关引用可能会有所帮助,特别是如果两种语言标准不同,因此我在这个问题中包含了 C 标签。

\n

Hol*_*Cat 31

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);
Run Code Online (Sandbox Code Playgroud)

这显然是合法的。1

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

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

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

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

这会导致 UB 2,因为由于memcpy给定了指向 的第一个元素的指针,因此它只能访问( )a[0]的元素,而不能访问。a[0]a[0][i]a[j][i]

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



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

C11 (N1570)[7.24.2.1]/2

memcpy函数将n字符从 指向的对象复制s2到 指向的对象中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);
Run Code Online (Sandbox Code Playgroud)

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();} 
Run Code Online (Sandbox Code Playgroud)

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

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

的情况memcpy不太确定,因为它执行按字节复制。整个主题似乎显得模糊且不明确

考虑以下措辞std::launder

[ptr.launder]/4

如果存在可与 进行指针互转换的对象 ,则可通过指向对象的b指针值访问存储字节,该对象位于 占用的存储空间内,或者如果是数组元素,则指向直接封闭的数组对象。YZYbZZ

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

形式上,这个定义仅用于描述std::launder(它无法扩展给定的指针的可达区域的事实)。但其含义似乎是,该定义总结了标准其他部分描述的可达性规则([static.cast]/13请注意,它reinterpret_cast通过相同的措辞定义的;也[basic.compound]/4)。

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

  • 抱歉,“me​​mcpy”的参数指向空洞,而不是您尝试传递的任何数组...... (6认同)
  • @LanguageLawyer `strcpy` 受到[相同限制](http://port70.net/~nsz/c/c11/n1570.html#7.24.1p1) 的约束,并且它总是接收 `const char *` 作为输入参数。按照你的逻辑,给它一个非空字符串总是会导致 UB,对吗? (5认同)
  • @LanguageLawyer 我们都知道,根据常识,你的第二个例子是可以的。即使它在技术上被证明是 UB,这也将是标准中的一个明显缺陷。试图避免这个所谓的“UB”是没有意义的。 (5认同)
  • 我不相信第二个表达式是所声称的未定义行为。`std::memcpy(arr_copy, arr[0], sizeof arr)` 不会访问对象 `arr` 边界之外的任何内容,因此它是安全的。`(void*)arr[0] == (void*)arr`。 (4认同)
  • 另外,由于 `&amp;arr`、`arr`、`arr[0]` 和 `&amp;arr[0][0]` 保证在逻辑上转换为相同的 void 指针,因此它们中的任何一个都可以与 memcpy 互换使用。该函数无法知道、不应该知道也不想知道从该地址开始存在的 n 个字节以上的信息。(我意识到这就是托比所说的。) (4认同)
  • _`std::memcpy(arr_copy, arr, sizeof arr);` (您的示例)定义良好。_ 但事实并非如此。 (3认同)
  • @LanguageLawyer 再次查看了规范,看来你[技术上是正确的](https://www.youtube.com/watch?v=hou0lU8WMgo),[再次](/sf/ 62329008/2752075)。我仍然认为我们应该在现实生活中忽略这个“UB”,它只是表明了该主题的标准措辞是多么不完整。 (3认同)
  • 如果是,那么:你说*“如果将指针传递给一维数组 - 它就是对象。如果传递给它的第一个元素 - 该对象就是它的第一个元素。”*。但如果你这样做 `char a[4], b[] = "xyz"; strcpy(a, b);`,第二个参数是指向单个字符的指针(指向整个数组的指针将是`&amp;b`),但显然该函数可以访问整个数组? (2认同)
  • `strcpy` 与 `memcpy` 有不同的要求。是的,`char a[4], b[] = "xyz"; strcpy(a, b);` 可以。这并不意味着 `char a[4], b[] = "xyz"; memcpy(a, b, 4);` 没问题。 (2认同)

Pet*_*des 18

它是明确定义的,即使您使用memcpy(arr_cpy, arr, size)而不是
memcpy(&arr_cpy, &arr, size)@LanguageLawyer 最终解释了他们一直在争论的内容),原因由@HolyBlackCat 和其他人解释。

标准的预期含义是明确的,任何与此相反的语言都是标准中的缺陷,而不是编译器开发人员将用来从无数正常使用 memcpy(包括一维数组)中拉出来的东西,这些使用t 转换int*int (*)[N],特别是因为 ISO C++ 不允许可变长度数组。

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

如果你传递的大小太大,你会收到警告,对于 GCC,警告甚至会准确地说明它看到的对象和大小memcpy

#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);
}
Run Code Online (Sandbox Code Playgroud)

在这里使用&dst, &src与警告或缺乏警告没有区别。适用于 GCC 和 clang以及 MSVC 的
Godbolt 编译器资源管理器-O2 -Wall -Wextra -pedantic -fsanitize=undefined-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];
Run Code Online (Sandbox Code Playgroud)

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);
    ^
Run Code Online (Sandbox Code Playgroud)

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

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

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

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


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

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

对我来说,这听起来像是对标准的误解,但我可能有偏见,因为我当然想把它读成我们都知道在实践中是正确的。我仍然认为对其进行明确定义是对标准中措辞的有效解释,但其他解释也可能是有效的(即是否是 UB 可能会含糊不清,这将是一个缺陷。)

指向数组第一个元素的Avoid*可以强制转换回 anint (*)[2]以访问整个数组对象。这不是 memcpy 使用它的方式,但它表明该指针尚未失去其作为指向整个 N 维数组的指针的状态。我认为该标准的作者假设这个推理,这void*可以被认为是指向整个对象的指针,而不仅仅是第一个元素。

然而,确实有特殊的语言来解释 memcpy 的工作原理,并且正式的阅读可能会认为这并不让您依赖于关于内存如何工作的正常 C 假设。

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

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

假设运行时变量大小最多只能影响被转换为的指针类型的整个第一个元素的编译器void*不允许在实际代码中进行太多优化。后面的代码很少只严格访问第一个元素之后的元素,这将使编译器可以在旨在写入它的 memcpy 之后进行常量传播或类似的操作。

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

  • @PeterCordes:可悲的是,那些提倡 UB 为编译器无意义提供无限许可的人并没有立即回应“是的,一个合格但垃圾的编译器可以做到这一点,但只有垃圾质量的编译器才会使用标准”以此为借口为程序员正在尝试做的事情制造不必要的障碍。” (3认同)
  • @PeterCordes:这有可能是标准作者期望编译器编写者在有或没有授权的情况下支持的众多操作之一,因此实际上并不是由标准定义的,但应该被有意义地处理*无论如何* ? (2认同)

Pet*_*ica 15

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

只要某个地址处有一个已初始化的对象,您就可以通过 char 指针访问它。

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

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

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

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

现在我们有 3 个 3 个 int 的数组,我们知道 9 个 int 连续放置在内存中;这是语言要求。整个内存充满了属于单个对象的 int,我们可以通过 char 指针手动迭代它,或者我们可以将它交给 memcpy。只要地址正确,我们是否使用arrarr[0] 通过其他变量的堆栈偏移量获取地址[<-不能保证正确,正如彼得·科德斯提醒我的那样]或其他一些魔法或只是进行有根据的猜测是完全无关的,并且这是毫无疑问的。

  • 有趣 - 而且是一个制作精良的案例。但这是您的 C17 标准草案。C++17 标准有什么规定? (2认同)
  • 请记住,C(和 C++)不采用平面内存模型。指针不必是像 0x42 这样的简单整数。分段内存模型上的假设 C++(*不*使用“远”指针扩展语言)可能具有 64k 的最大对象大小,并安排单个对象在使用“seg”访问时不跨越段边界:以有效方式派生的 off` 指针。(因为指向同一字节的指针可以有多种表示形式,但其中一些表示形式的“off”组件太接近于环绕而无法迭代对象的其余部分)。 (2认同)
  • 除其他外,该标准使用术语“未定义的行为”作为笼统地描述在某些情况下、在某些实现上可能以实际上无法以与顺序程序执行一致的方式描述的行为的行为。 。如果各种各样的操作都会违反约束,但编译器编写者没有任何理由不有效地处理一些常见的极端情况,那么标准通常会依赖编译器编写者运用一些常识。UB 的想法是无条件邀请胡言乱语...... (2认同)
  • ...是一项更新的创新,如果标准做出任何真正的努力来解决标准不同部分定义和“未定义”操作的地方,这可能没问题。 (2认同)

Ozo*_*zob 7

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

\n

问题是我们是否可以将 a 应用于memcpyan int[3][3]。Anint[3][3]是数组的数组,而memcpy适用于字节。因此,我们需要了解标准对数组字节表示的规定。

\n

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

\n
\n

数组类型描述了具有特定成员对象类型(称为元素类型)的连续分配的非空对象集。

\n
\n

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

\n

让我们首先询问int对象。每个人都希望有memcpy一个int能够发挥作用。要了解该标准是否有此要求,请查看第 6.2.6.1 节“概述”第 2 段:

\n
\n

除了位字段之外,对象由一个或多个字节的连续序列组成,其数量、顺序和编码要么是显式指定的,要么是实现定义的。

\n
\n

因此 anint是一个或多个字节的连续序列。因此,我们的int[3][3]序列是由三个连续字节序列组成的连续序列sizeof(int);标准要求它是 9 \xc3\x97sizeof(int)个连续字节。

\n

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

\n
\n

后缀表达式后跟方括号中的表达式[]是数组对象元素的下标指定。下标运算符的定义[]E1[E2](*((E1)+(E2)))

\n
\n

arr[1] == *((arr)+(1))第二个也是,int[3]第三arr[1][2] == *((*((arr)+(1)))+(2))个元素也是,这一定是int开始之后的第六个arr第六个。第 3 段明确说明了这一点:

\n
\n

连续的下标运算符指定多维数组对象的元素。如果E是一个n维数组 ( n \xe2\x89\xa5 2) ,维度为i \xc3\x97 j \xc3\x97 \xc2\xb7\xc2\xb7\xc2\xb7 \xc3\x97 k,则E(使用转换为指向维度为j \xc3\x97 \xc2\xb7\xc2\xb7\xc2\xb7 \xc3\x97 k 的( n \xe2\x88\x92 1) 维数组的指针。如果一元运算符显式应用于此指针,或由于下标而隐式应用于此指针,则结果是引用的 ( n* \xe2\x88\x92 1) 维数组,如果用作除左值。由此可见,数组按行优先顺序存储(最后一个下标变化最快)。

\n
\n

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

\n
\n

数组下标超出范围,即使对象显然可以使用给定的下标访问(如a[1][7]给定声明的左值表达式中int a[4][5])(6.5.6)。

\n
\n

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

\n
\n

memcpy函数将n字符从 指向的对象复制s2到 指向的对象中s1

\n
\n

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

\n

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

\n