如何在没有中间副本的情况下在标准 C 中实现 memmove?

Posted

技术标签:

【中文标题】如何在没有中间副本的情况下在标准 C 中实现 memmove?【英文标题】:How to implement memmove in standard C without an intermediate copy? 【发布时间】:2011-04-30 17:09:00 【问题描述】:

来自我系统上的手册页:

void *memmove(void *dst, const void *src, size_t len);

描述 memmove() 函数将 len 个字节从字符串 src 复制到字符串 dst。两个字符串可能重叠;复制始终以非破坏性方式完成 方式。

来自 C99 标准:

6.5.8.5 比较两个指针时,结果取决于 地址中的相对位置 指向的对象的空间。如果 两个指向对象或不完整的指针 类型都指向同一个对象, 或两者都指向最后一个 同一数组对象的元素, 他们比较平等。如果对象 指向的是相同的成员 聚合对象,指向的指针 稍后声明的结构成员 比较大于指针 之前宣布的成员 结构体和指向数组的指针 具有较大下标值的元素 比较大于指针 相同数组的元素具有较低的 下标值。所有指向的指针 同一个联合对象的成员 比较相等。如果表达式P 指向数组的一个元素 对象和表达式 Q 指向 同一数组的最后一个元素 对象,指针表达式Q+1 比较大于P。在所有 其他情况下,行为是 未定义

重点是我的。

参数dstsrc 可以转换为指向char 的指针以缓解严格的别名问题,但是是否可以比较两个可能指向不同块内的指针,从而进行复制如果它们指向同一个块,按正确的顺序?

显而易见的解决方案是if (src < dst),但如果srcdst 指向不同的块,则这是未定义的。 “未定义”意味着您甚至不应该假设条件返回 0 或 1(这在标准词汇中被称为“未指定”)。

另一种选择是if ((uintptr_t)src < (uintptr_t)dst),它至少是未指定的,但我不确定标准是否保证在定义src < dst 时,它等同于(uintptr_t)src < (uintptr_t)dst)。指针比较是从指针算术定义的。例如,当我阅读关于加法的第 6.5.6 节时,在我看来,指针算术可能与uintptr_t 算术相反,也就是说,当p 是@ 类型时,兼容的编译器可能会有987654338@:

((uintptr_t)p)+1==((uintptr_t)(p-1)

这只是一个例子。一般来说,将指针转换为整数时似乎很少保证。

这是一个纯粹的学术问题,因为memmove 是与编译器一起提供的。在实践中,编译器作者可以简单地将未定义的指针比较提升为未指定的行为,或者使用相关的编译指示来强制他们的编译器正确编译他们的memmove。例如,this implementation 有这个 sn-p:

if ((uintptr_t)dst < (uintptr_t)src) 
            /*
             * As author/maintainer of libc, take advantage of the
             * fact that we know memcpy copies forwards.
             */
            return memcpy(dst, src, len);
    

如果memmove 确实不能在标准 C 中有效实现,我仍然想用这个例子来证明标准在未定义的行为上走得太远了。例如,在回答 @987654322 时没有人打勾@。

【问题讨论】:

6.5.8.5 似乎措辞非常糟糕,无论是哪种方式。它以一个简单的陈述开始:“他的结果取决于所指向的对象在地址空间中的相对位置。”然后,如果其中一项规定性规则与地址空间中的相对位置规则冲突,那么它会执行所有规定性的规定,而根本不清楚会发生什么。它是否试图规定结构应该如何布局,或者指针应该如何比较? +1 表示解释清楚且有趣的问题。 这不仅是学术性的,支持重叠区域的 blitter 也有这个问题,例如我的补丁 winehq.org/pipermail/wine-patches/2008-March/051766.html和通过指针比较选择的起始行)。 【参考方案1】:

要使两个内存区域有效且重叠,我相信您需要处于 6.5.8.5 定义的情况之一。也就是数组的两个区域,union,struct等。

其他情况未定义的原因是因为两个不同的对象甚至可能不在同一种内存中,具有相同类型的指针。在 PC 架构上,地址通常只是虚拟内存中的 32 位地址,但 C 支持各种奇怪的架构,而内存完全不是这样。

C 保留未定义的原因是为了在不需要定义情况时为编译器编写者留出余地。阅读 6.5.8.5 的方法是仔细描述 C 想要支持的架构,其中指针比较没有意义,除非它在同一个对象内。

此外,编译器提供 memmove 和 memcpy 的原因是,它们有时是使用专门的指令以针对目标 CPU 的优化汇编编写的。它们并不意味着能够以相同的效率在 C 中实现。

【讨论】:

是的,但是memmove的规范并没有说在不同的块内用指针调用它是无效的。如果它无效,规范应该说明,以便程序员在这种情况下可以使用memcpy @Pascal- 这样做不应该是无效的。您的 memmove 实现应该足够聪明,可以检测到这一点并运行 memcpy。程序员不必担心调用哪个函数。 仅在 C 语言中无法检测到这一点,但是是的,它需要在两种情况下都有效。 memcpy 和 memmove 不需要只用 C 就可以实现——事实上,完美的版本可能无法实现,这也是标准希望编译器编写者提供它的更多原因。 @Lou Franco:+1 标准头文件和标准库需要实现函数和宏来执行许多在便携式 C 中无法完成的事情。正如您所注意到的,这是一个很大的标准宏和库首先存在的部分原因。如果有一些其他有效且可移植的方法来复制可能重叠也可能不重叠的内存块,那么 memmove 将是多余的。在不使用 stdarg.h 的情况下,人们不会期望以一种适用于所有平台的方式提供 stdarg.h 或 setjmp.h 的功能;为什么 memmove() 应该有所不同?【参考方案2】:

我认为您是对的,在标准 C 中无法有效地实现 memmove

我认为,测试区域是否重叠的唯一真正可移植的方法是这样的:

for (size_t l = 0; l < len; ++l) 
    if (src + l == dst) || (src + l == dst + len - 1) 
      // they overlap, so now we can use comparison,
      // and copy forwards or backwards as appropriate.
      ...
      return dst;
    

// No overlap, doesn't matter which direction we copy
return memcpy(dst, src, len);

您不能在可移植代码中有效地实现memcpymemmove 所有那个,因为无论您做什么,特定于平台的实现都可能让您大吃一惊。但是便携式memcpy 至少看起来是合理的。

C++ 引入了std::less 的指针特化,它被定义为适用于任何两个相同类型的指针。理论上它可能比&lt; 慢,但显然在非分段架构上它不是。

C 没有这样的东西,所以从某种意义上说,C++ 标准同意你的观点,即 C 没有足够的定义行为。但是,C++ 需要它来处理std::map 等等。在不知道实现的情况下,您想要实现 std::map(或类似的东西)的可能性要比在不知道实现的情况下想要实现 memmove(或类似的东西)的可能性大得多。

【讨论】:

哦,漂亮(比较只有有效指针的相等性)。 我希望“美丽”是指“我的眼睛!护目镜什么都不做!” ;-) 不,我的意思是“漂亮”,因为据我所知,伪代码在技术上是对问题的回答,而不是讨论 C 标准为何如此. 请注意,相同的代码在 C++ 中可能正确,其中将一个过去指针与另一个相等指针进行比较不必返回“正确“ 回答。但毕竟你只声称 C。 (然而,AFAIK 在实践中编译器优化这些过去的指针在 C 和 C++ 中的方式相同,所以这仍然是一个问题。) 是的,你是对的。第二行末尾应该是dst + len - 1【参考方案3】:

对于初学者来说,C 标准因在此类细节上存在问题而臭名昭著。部分问题是因为 C 在多个平台上使用,并且标准试图足够抽象以涵盖所有当前和未来的平台(可能使用一些我们从未见过的复杂的内存布局)。为了让编译器编写者为目标平台“做正确的事”,有许多未定义或特定于实现的行为。包括每个平台的详细信息是不切实际的(并且经常过时);相反,C 标准让编译器编写者记录在这些情况下会发生什么。 “未指定”行为仅表示 C 标准未指定发生的情况,不一定表示无法预测结果。如果您阅读目标平台和编译器的文档,结果通常仍然可以预测。

由于确定两个指针是否指向同一个块、内存段或地址空间取决于该平台的内存布局方式,因此规范没有定义做出该确定的方法。它假设编译器知道如何做出这个决定。您引用的规范部分说指针比较的结果取决于指针的“地址空间中的相对位置”。请注意,“地址空间”在这里是单数的。本节仅指同一地址空间中的指针;即直接可比较的指针。如果指针位于不同的地址空间,则结果不是 C 标准定义的,而是由目标平台的要求定义的。

memmove的情况下,实现者一般先判断地址是否可以直接比较。如果不是,则该功能的其余部分是特定于平台的。大多数时候,在不同的内存空间中足以确保区域不重叠并且函数变成memcpy。如果地址可以直接比较,那么它只是一个简单的字节复制过程,从第一个字节开始向前或从最后一个字节向后向后(无论哪个可以安全地复制数据而不会破坏任何内容)。

总而言之,C 标准在无法编写适用于任何目标平台的简单规则的地方故意未指定很多内容。然而,标准作者本可以更好地解释为什么有些东西没有定义并使用更具描述性的术语,例如“架构相关”。

【讨论】:

我理解为现有和未来实现定义标准的妥协。我的问题不是对 C 的抱怨(数以百万计的程序员不可能全错),但是必须在未定义但可接受的构造和普通的未定义构造之间做出自己的分类是每个低级 C 程序员的负担(和包在我身上!)。而且我可以想象,有时,您必须编写自己的类似 memmove 的函数,因为标准的 memmove 并不是您所需要的;您想用 C 编写它,并且希望它避免中间副本。 此外,C 标准将定义和记录的内容留给编译器编写者,但有些编译器是由不关心为标准提供额外保证的人编写的。我听说编译器编写者在访问union 字段而不是通过调用标准写入的最后一个字段时,会因为生成不正确的代码而受到惩罚。这是不允许的(gcc 将其从未定义提升为实现定义,作为编译器编写者通常应该记录的示例)。 @Pascal:该联合示例有潜在的充分理由 - 为了使通过联合强制转换工作,您可能必须对依赖于严格别名规则的优化进行例外处理。我不认为编译器编写者“应该”这样做(除了因为他们想让很多现有的不正确的代码工作,他们必须这样做):我认为程序员不应该使用成语。 @Steve Jessop 那是一个嵌入式编译器。它必须提供一种将某些位从一种类型转换为另一种类型的方法(同样,不需要中间副本),否则它无法用于其预期目的。 Gcc 选择在通过联合完成时允许这样做并通过指针强制转换不允许这样做至少为您提供了一种方法。 @Pascal:为什么它必须提供一种没有副本的方法?也就是说,我认为在某些情况下联合强制转换在 C99 中是有效的,假设您知道所涉及类型的表示。我永远记不起它们是什么。【参考方案4】:

如果 memmove 确实无法在标准 C 中有效实现,我仍然想用这个例子来证明标准在未定义行为方面做得太过分了

但这不是证据。绝对没有办法保证您可以在任意机器架构上比较两个任意指针。 C 标准甚至编译器都无法规定这种指针比较的行为。我可以想象一台具有分段架构的机器可能会根据段在 RAM 中的组织方式产生不同的结果,或者甚至可能在比较指向不同段的指针时选择抛出异常。这就是行为“未定义”的原因。在完全相同的机器上完全相同的程序可能会在每次运行时给出不同的结果。

memmove() 的“解决方案”通常使用两个指针的关系来选择是从头到尾复制还是从尾到头复制,只有在所有内存块都从同一地址分配时才有效空间。幸运的是,尽管在 16 位 x86 代码时代并非如此,但通常都是这种情况。

【讨论】:

我认为这证明了即使该语言保留了对已知位于同一块中的指针的快速比较,它也必须提供一个只要指针属于同一类型就可以工作的比较. Steve Jessop 的回答说服我改用 C++ :) @Pascal Cuoq:为什么限制所有指向相同类型对象的指针来自相同的地址空间?它适用于现代平面地址空间架构,但在一般情况下,甚至可能在逻辑上都不可行。 “它还必须提供一个比较”——嗯,memmove 真正需要的是一种测试内存区域是否重叠的方法。如果一个指向“红色”段,另一个指向“蓝色”段,那么您知道它们没有重叠,这足以让 memmove 可以委托给 memcpy。您不需要在“红色”和“蓝色”之间存在顺序关系(尽管一个实现大概可以选择一个,也许是任意的)。 @Steve:我认为大多数人的抱怨是执行没有强制执行这样的命令,有些人希望强制执行。当然,这将对此类实现的指针比较造成巨大的性能影响。另一方面,我的 抱怨是 C 仍然允许遗留的东西,比如非平面内存。谢天谢地,POSIX 增加了一些理智。 @R.. 同意你的观点,但非平面内存模型并不像我们这些编写现代通用计算机的人所想的那样遗留。【参考方案5】:

这是另一个想法,但我不知道它是否正确。为了避免史蒂夫回答中的O(len) 循环,可以将它放在#ifdef UINTPTR_MAX#else 子句中,并使用cast-to-uintptr_t 实现。假设 unsigned char *uintptr_t 的转换在偏移量对指针有效时添加整数偏移量,这使得指针比较明确。

我不确定这个交换性是否由标准定义,但它是有道理的,因为即使只有指针的低位是实际数字地址并且高位是某种黑盒,它也可以工作.

【讨论】:

如果指针转换后的整数值的高位是地址,低位是黑盒,则不一定有效。想象一个假设的系统,在该系统上,汇编代码中最小的可寻址内存单元是 4 位。然后我认为转换为uintptr_t 得到(uintptr_t)(ptr+1) == ((uintptr_t)ptr) + 2 是有效的,而且当然是相当自然的。这里底部的“黑匣子”只是一个 0 位,可能会导致测试“它们是否重叠?”的假阴性。 @Steve Jessop:在进行整数转换之前将偏移量添加到 (char*) 至少可以解决一些指针不是线性的系统上的问题(实际示例:在 80x86 系统上,使用“巨大”模式时的指针(或显式声明为“巨大”的指针)存储为 32 位,并使用前 16 位和后 4 位访问 1MB 的寻址空间)。我不知道可移植代码可以产生两个指针的实现,其中整数和指针比较会产生不同的结果(但不是 UB),但是...... ...如果存在这样的系统,我不会感到惊讶,尤其是在嵌入式微控制器领域。使用“大”模型(或显式声明为“远”的指针)在 80x86 系统上生成指针当然是可能的,当转换为整数时,这些指针看起来不会重叠,并且由于各种原因,这样的指针可能很有用,但是转换这样的指向“巨大”然后指向整数的指针将产生一致的比较(当然,这种类型转换是不可移植的,但产生它们的必要性也是如此)。

以上是关于如何在没有中间副本的情况下在标准 C 中实现 memmove?的主要内容,如果未能解决你的问题,请参考以下文章

如何在没有选择按钮的情况下在 GridView 中实现全行选择?

如何在没有外部库的情况下在 React 中实现 Google Maps JS API?

如何在没有正则表达式的情况下在 C++ 中实现有效的全字字符串替换?

如何在没有像 Quartz Clustering 这样的集中式解决方案的情况下在分布式环境中实现 cron 作业/计划作业? [复制]

我可以在没有 POST 的情况下在 python 中实现 Web 用户身份验证系统吗?

是否可以在没有动态多态性的情况下在 C++ 中实现状态设计模式?