memcpy 可以用于类型双关语吗?

Posted

技术标签:

【中文标题】memcpy 可以用于类型双关语吗?【英文标题】:Can memcpy be used for type punning? 【发布时间】:2016-11-30 19:18:06 【问题描述】:

这是来自 C11 标准的引用:

6.5 表达式 ...

6 用于访问其存储值的对象的有效类型是对象的声明类型(如果有)。如果通过具有非字符类型类型的左值将值存储到没有声明类型的对象中,则左值的类型将成为该访问的对象的有效类型以及不修改该类型的后续访问储值。如果使用memcpymemmove 将值复制到没有声明类型的对象中,或者复制为字符类型的数组,则该访问的修改对象的有效类型以及不修改的后续访问该值是从中复制该值的对象的有效类型(如果有的话)。对于没有声明类型的对象的所有其他访问,对象的有效类型只是用于访问的左值的类型。

7 对象的存储值只能由具有以下类型之一的左值表达式访问:

——与对象的有效类型兼容的类型, — 与对象的有效类型兼容的类型的限定版本, — 对应于对象有效类型的有符号或无符号类型, — 有符号或无符号类型,对应于对象有效类型的限定版本, — 在其成员中包含上述类型之一的聚合或联合类型(递归地,包括子聚合或包含联合的成员),或 — 一种字符类型。

这是否意味着memcpy 不能以这种方式用于类型双关:

double d = 1234.5678;
uint64_t bits;
memcpy(&bits, &d, sizeof bits);
printf("the representation of %g is %08"PRIX64"\n", d, bits);

为什么它不会给出与以下相同的输出:

union  double d; uint64_t i;  u;
u.d = 1234.5678;
printf("the representation of %g is %08"PRIX64"\n", d, u.i);

如果我使用字符类型的memcpy 版本会怎样:

void *my_memcpy(void *dst, const void *src, size_t n) 
    unsigned char *d = dst;
    const unsigned char *s = src;
    for (size_t i = 0; i < n; i++)  d[i] = s[i]; 
    return dst;


编辑: EOF 评论说 第 6 段中关于 memcpy() 的部分不适用于这种情况,因为 uint64_t bits 具有声明的类型。 我同意,但不幸的是,这无助于回答 memcpy 是否可以用于类型双关语的问题,它只是使第 6 段与评估上述示例的有效性无关。

这里是使用memcpy 进行类型双关的另一种尝试,我相信第 6 段会介绍:

double d = 1234.5678;
void *p = malloc(sizeof(double));
if (p != NULL) 
    uint64_t *pbits = memcpy(p, &d, sizeof(double));
    uint64_t bits = *pbits;
    printf("the representation of %g is %08"PRIX64"\n", d, bits);

假设sizeof(double) == sizeof(uint64_t),上述代码是否在第 6 段和第 7 段中定义了行为?


编辑:一些答案​​指出读取陷阱表示可能会导致未定义的行为。这不相关,因为 C 标准明确排除了这种可能性:

7.20.1.1 精确宽度整数类型

1 typedef 名称intN_t 指定宽度为 N、无填充位和二进制补码表示的有符号整数类型。因此,int8_t 表示这样一个宽度正好为 8 位的有符号整数类型。

2 typedef 名称uintN_t 指定宽度为 N 且无填充位的无符号整数类型。因此,uint24_t 表示这样一种无符号整数类型,其宽度正好为 24 位。

这些类型是可选的。但是,如果实现提供了宽度为 8、16、32 或 64 位的整数类型,没有填充位,并且(对于有符号类型)具有二进制补码表示,则它应定义相应的 typedef 名称。

类型 uint64_t 正好有 64 个值位并且没有填充位,因此不能有任何陷阱表示。

【问题讨论】:

我不是 C 标准方面的专家,但两者之间有一个重要的区别。 union 版本确保u 的起始地址满足两个字段的对齐限制。 memcpy 版本不保证bits 位于double 边界上。如果double 对齐比uint64_t 更严格,它可能会失败,例如当printf 尝试计算bitsdouble 字符串表示时出现总线错误。 @Gene:我没有看到任何对齐问题:memcpy 在非对齐块之间复制是明确安全的,传递给printf 的值是从它们的有效类型中读取的。 @RichardChambers:但是,当通过不同类型的左值访问时,该值被检索为左值的类型,而不是原始类型。 这正是定义类型双关语和上述代码的目的。您的回答也是如此:是的,memcpy 可用于类型双关语。 第 6 段中关于memcpy() 的部分不适用于这种情况,因为uint64_t bits 具有声明的类型 @EOF:好点。我编辑了问题。 【参考方案1】:

可能给出相同的结果,但编译器不需要保证。所以你根本不能依赖它。

【讨论】:

因此问题是:为什么不呢?第 6 段对我来说是模糊的。有人可以解释吗?如果memcpy() 实现为读写字节怎么办,第 7 段中的最后一项是否可以节省时间? @chqrlie:从历史上看,这种行为是 100% 可靠的,但主要的编译器编写者非常渴望将基准性能提高一两个百分点,以至于他们愿意放弃几十年的先例窗口(并迫使不编写基准的程序员编写效率较低的代码)。【参考方案2】:

我读到第 6 段说使用 memcpy() 函数将一系列字节从一个内存位置复制到另一个内存位置可以用于类型双关语,就像使用具有两种不同类型的 union 一样可以使用用于类型双关语。

第一次提到使用memcpy() 表示如果它复制指定数量的字节,并且当该变量(左值)用于存储字节时,这些字节将与源目标处的变量具有相同的类型.

换句话说,如果你有一个变量double d;,然后你给这个变量赋值(左值),那么存储在那个变量中的数据类型就是double。如果您随后使用memcpy() 函数将这些字节复制到另一个内存位置,例如变量uint64_t bits;,则这些复制字节的类型仍然是double

如果您随后通过目标变量(左值)访问复制的字节,在示例中为 uint64_t bits;,则该数据的类型被视为用于从该目标变量中检索数据字节的左值类型.所以字节被解释(不是转换而是解释)为目标变量类型而不是源变量的类型。

通过不同类型访问字节意味着这些字节现在被解释为新类型即使字节实际上并没有以任何方式改变

这也是union 的工作方式。 union 不进行任何类型的转换。您将字节存储到属于一种类型的union 成员中,然后通过不同的union 成员将相同的字节拉回。字节是相同的,但是字节的解释取决于用于访问内存区域的union 成员的类型。

我已经看到在较旧的 C 源代码中使用 memcpy() 函数,通过使用 struct 成员偏移量和 memcpy() 函数来复制 struct 变量的部分,来帮助将 struct 分成几部分到其他 struct 变量中。

因为memcpy() 中使用的源位置的类型是存储在那里的字节类型,使用union 进行双关可能会遇到的相同类型的问题也适用于使用@987654344 @这种方式如数据类型的Endianness。

要记住的是,无论是使用union 还是使用memcpy() 方法,复制的字节类型都是源变量的类型,然后当您以另一种类型访问数据时,无论是通过不同的union 的成员或通过memcpy() 的目标变量将字节解释为目标左值的类型。但是实际字节没有改变。

【讨论】:

我希望你的解释是正确的,但是如果第 6 段说memcpy 目标处的表示的有效类型是源的,第 7 段说使用目标类型访问它不是枚举的情况之一,因为左值表达式的类型uint64_t与存储值的对象的有效类型double不兼容,不是字符类型。【参考方案3】:

已更改--见下文

虽然我从未观察到编译器将非重叠源和目标的 memcpy 解释为执行任何不等同于将源的所有字节作为字符类型读取然后写入的所有字节的操作将目标作为字符类型(意味着如果目标没有声明的类型,则它将没有有效类型),标准的语言将允许钝编译器进行“优化”——在那些罕见的情况下编译器将能够识别和利用它们——与实际提高效率相比,更有可能破坏原本可以工作的代码(如果标准编写得更好,则定义明确)。

至于这是否意味着最好使用 memcpy 或手动字节复制循环,其目的被充分伪装成无法识别为“复制字符类型数组”,我不知道。我认为明智的做法是避开任何如此迟钝的人,以至于建议一个好的编译器应该在没有这种混淆的情况下生成虚假代码,但是由于过去几年被认为是迟钝的行为现在很流行,我不知道是否memcpy 将成为破坏编译器数十年来视为“定义明确”的代码的下一个受害者。

更新

从 6.2 开始的 GCC 有时会在它看到目标和源标识相同地址的情况下省略 memmove 操作,即使它们是不同类型的指针。如果作为源类型写入的存储稍后被读取为目标类型,则 gcc 将假定后者读取无法识别与先前写入相同的存储。 gcc 的这种行为是合理的,因为标准中的语言允许编译器通过memmove 复制有效类型。目前尚不清楚这是否是对memcpy 规则的有意解释,但是,考虑到 gcc 在标准明确不允许允许的某些情况下也会进行类似的优化,例如当一种类型的联合成员(例如 64 位 long)被复制到一个临时对象并从那里复制到具有相同表示形式的不同类型的成员(例如 64 位 long long)时。如果 gcc 发现目标位置与临时位置是逐位相同的,它将省略写入,因此不会注意到存储的有效类型已更改。

【讨论】:

不确定这与问题有什么关系。有两个可用的内存复制功能,memcpy() 用于非重叠内存区域,memmov() 用于可能重叠的内存区域。我认为这是为了允许对不重叠的内存区域进行库优化,这对重叠的内存区域是危险的。 ***.com/questions/4415910/memcpy-vs-memmove @RichardChambers:如果 memcpy 或 memmove 的目标没有声明类型,则允许编译器将任何尝试使用与资源;如果编译器认为数据被复制为字符数组(无论这意味着什么),它也可以这样做。但是,如果数据被复制为一系列完全不相关的字符操作,则不允许编译器做出这样的推断,但标准并不清楚这些操作必须有多不相关。【参考方案4】:

有两种情况需要考虑:memcpy()进入一个具有声明类型的对象,以及memcpy()进入一个没有声明类型的对象。

第二种情况,

double d = 1234.5678;
void *p = malloc(sizeof(double));
assert(p);
uint64_t *pbits = memcpy(p, &d, sizeof(double));
uint64_t bits = *pbits;
printf("the representation of %g is %08"PRIX64"\n", d, bits);

行为确实是未定义的,因为p指向的对象的有效类型将变为double,并且尽管uint64_t类型的左值未定义,但访问有效类型为double的对象。

另一方面,

double d = 1234.5678;
uint64_t bits;
memcpy(&bits, &d, sizeof bits);
printf("the representation of %g is %08"PRIX64"\n", d, bits);

不是未定义。 C11草案标准n1570:

7.24.1 字符串函数约定 3 对于本条中的所有功能,每个字符都应被解释为具有类型 unsigned char (因此每个可能的对象表示都是 有效且具有不同的值)。

还有

6.5 表达式 7 对象的存储值只能由具有以下类型之一的左值表达式访问:88)

——与对象的有效类型兼容的类型, — 与对象的有效类型兼容的类型的限定版本, — 对应于对象有效类型的有符号或无符号类型, — 一种类型,它是有符号或无符号类型,对应于 对象的有效类型, — 包含上述类型之一的聚合或联合类型 在其成员之间(递归地包括子聚合或包含联合的成员),或 — 一种字符类型。

脚注 88)此列表的目的是指定对象可能或可能不会别名的情况。

所以memcpy() 本身是明确定义的。

由于uint64_t bits 有一个声明的类型,它保留了它的类型,即使它的对象表示是从double 复制的。

正如 chqrlie 所指出的,uint64_t 不能有陷阱表示,因此在memcpy() 之后访问bits不是未定义的,前提是sizeof(uint64_t) == sizeof(double)。但是,bits 将取决于实现(例如由于字节序)。

结论memcpy() 可以用于类型双关,前提是memcpy() 的目的地确实有声明的类型,即不是由[m/c/re]alloc() 或同等学历。

【讨论】:

memcpy 用于类型双关语是否关注源类型?如果我们知道它的有效类型,源是否可以没有声明的类型(m/c/re/alloced)? @SomeName 考虑到对对象进行 存储 的行为会导致它具有用于进一步(读取)访问的有效类型,即使它之前没有有效类型, 一个没有有效类型的对象复制的唯一方法是访问一个既没有初始化也没有写入的对象,这将使其值不确定并且读取它的行为未定义。 @EOF:编写的有效类型规则看起来很简单,因为它们不努力避免创建模棱两可和不可行的极端情况。此外,他们使用与编译器实际关心的事物无关的抽象模型。盲目允许别名使用会不必要地昂贵的情况通常是通过一种类型访问存储,通过与该类型没有明显关系的指针访问,然后使用 again 作为第一种类型访问与任何其他类型没有明显关系的指针。 @EOF:如果正在处理中间类型访问的编译器不知道使用其他类型的前后访问,它就没有任何理由关心如何这样访问可能影响了对象的“有效类型”。如果它确实知道此类访问,但在使用较早类型的最后一次访问和使用新类型的第一次访问之间将指针从较早类型转换为另一种类型,则将转换视为使用合并负载的障碍原始类型不太可能... ...阻碍有用的优化。当涉及memcpy、对结构和联合成员左值的直接操作以及在此类左值上使用地址的问题中的一个因素时,假装存储拥有有效类型最终成为一个比实际使用少得多的模型一个基于访问存储或派生指向它的指针的操作序列。【参考方案5】:

您提出了 3 种方式,它们都对 C 标准有不同的问题。

    标准库memcpy

    double d = 1234.5678;
    uint64_t bits;
    memcpy(&bits, &d, sizeof bits);
    printf("the representation of %g is %08"PRIX64"\n", d, bits);
    

    memcpy 部分是合法的(在您的实现sizeof(double) == sizeof(uint64_t) 中提供,根据标准保证):您通过 char 指针访问两个对象。

    printf 行不是。 bits 中的表示现在是双精度。它可能是uint64_t 的陷阱表示,如 6.2.6.1 General §5

    中所定义

    某些对象表示不需要表示对象类型的值。如果存储 对象的值具有这样的表示,并由左值表达式读取 没有字符类型,行为未定义。如果产生这样的表示 通过通过左值表达式修改对象的全部或任何部分的副作用 没有字符类型,行为未定义。这种表示称为 陷阱表示。

    而 6.2.6.2 整数类型明确表示

    对于 unsigned char 以外的无符号整数类型,对象的位 表示应分为两组:值位和填充位...任何填充位的值都是未指定的。53

    注释 53 说:

    填充位的某些组合可能会产生陷阱表示,

    如果您知道在您的实现中没有填充位(仍然从未见过......)每个表示都是有效值,print 行再次变为有效。但它只是依赖于实现,在一般情况下可能是未定义的行为

    联合

    union  double d; uint64_t i;  u;
    u.d = 1234.5678;
    printf("the representation of %g is %08"PRIX64"\n", d, u.i);
    

    联合的成员不共享公共子序列,并且您正在访问的成员不是最后写入的值。好的,常见的实现会给出预期的结果,但每个标准没有明确定义应该发生什么。 6.5.2.3 Structure and union members §3 中的脚注说,如果导致与前一个案例相同的问题:

    如果用于访问联合对象内容的成员与上次用于访问的成员不同 在对象中存储一个值,该值的对象表示的适当部分被重新解释 作为 6.2.6 中描述的新类型中的对象表示(有时称为“类型 punning")。这可能是一个陷阱表示。

    自定义memcpy

    您的实现只进行始终允许的字符访问。这与第一种情况完全相同:实现定义。

根据标准明确定义的唯一方法是将 double 的表示形式存储在正确大小的 char 数组中,然后显示 char 数组的字节值:

double d = 1234.5678;
unsigned char bits[sizeof(d)];
memcpy(&bits, &d, sizeof(bits));
printf("the representation of %g is ", d);
for(int i=0; i<sizeof(bits); i++) 
    printf("%02x", (unsigned int) bits[i]);

printf("\n");

只有当实现对char 使用 8 位时,结果才可用。但它是可见的,因为如果 bytes 之一的值大于 255,它将显示超过 8 个十六进制数字。


以上所有内容仅是有效的,因为bits 具有已声明的类型。请参阅@EOF's answer 以了解为什么分配对象会有所不同

【讨论】:

union 的情况在 C11 标准草案 n1570 中明确(但遗憾地非规范地)记录为实现定义 6.5.2.3 结构和联合成员 [脚注] 95 如果成员用于读取联合对象内容的成员与上次用于在对象中存储值的成员不同,该值的对象表示的适当部分被重新解释为新类型中的对象表示,如 6.2 中所述.6(有时称为“类型双关语”的过程)。这可能是一个陷阱表示。 这里缺少的一个部分是,如果复制到的对象是由[m/c/re]alloc() 分配的,则memcpy() 变体中的行为是明确的undefined。看看我的回答为什么会这样。 @EOF:我同意你的观点,但这是一个不同的问题,我认为它与 xalloc 松散相关。如果我将您的示例更改为void *p = malloc(sizeof(double)); uint64_t *pbits = p; *pbits = 0 // effective type is now uint64_t; memcpy(p, &amp;d, sizeof(double));,由于可能的陷阱表示,我只剩下实现定义的行为。一旦分配的变量接收到一个类型,它就会保留它直到它被释放。 没有。请记住 C11 草案标准 n1570:6.5 表达式 6 [...]如果使用 memcpy 或 memmove 将值复制到没有声明类型的对象中,或者复制为字符类型数组,则有效类型该访问的修改对象 以及不修改值的后续访问 是从中复制值的对象的有效类型,如果它有的话。 memcpy()确实修改了值,所以对象的有效类型再次改变 其实你的具体例子的相关部分是前面的一句话:如果一个值通过一个非字符类型的左值存储到一个没有声明类型的对象中,然后左值的类型成为该访问的对象的有效类型,并且对于不修改存储值的后续访问 [ ...] ,但重点是一样的。

以上是关于memcpy 可以用于类型双关语吗?的主要内容,如果未能解决你的问题,请参考以下文章

OWL 双关语可以帮助定义对象属性的数据属性吗?

memcpy的用法总结

转:memcpy的用法总结

转: memcpy的用法总结

什么是类型双关语,它的目的是什么?

使用带有联合的类型双关语的问题