先看一段代码,猜它打印什么:

c

#include <stdio.h>
#include <string.h>

int main(void) {
    char buf[16] = "abcdefghij";
    memcpy(buf + 2, buf, 5);
    printf("%.10s\n", buf);
    return 0;
}

memcpy(buf + 2, buf, 5) 把 buf[0..4] 拷到 buf[2..6],源和目标区域在 [2, 4] 上重叠。按 C 标准,这是未定义行为(C11 7.24.2.1:“If copying takes place between objects that overlap, the behavior is undefined”)。

在 x86-64 的 glibc 上,实测经常输出 ababcdehij——看起来像“正向拷贝”的结果。但换成 ARM、或者换成另一个 libc、或者换个编译优化级别,可能是 abababahij(反向拷贝的结果),也可能是别的。这不是编译器在“选择”行为,是它被允许假设源和目标不重叠,从而做任何优化。

memmove 才是有定义的那个:

c

char buf[16] = "abcdefghij";
memmove(buf + 2, buf, 5);
// buf 现在一定是 "ababcdefhij"

“向前拷贝”还是“向后拷贝”

重叠有两种方向。dst > src 且区间有交叠时(像上面那样),如果从低地址往高地址逐字节拷,会把源数据覆盖掉。所以这种情形必须从高地址往低地址拷。

dst < src 且区间交叠时,反过来,必须从低往高拷。

dst 和 src 不重叠时,两个方向都行,实现可以自由选择。

一个可移植的 memmove 骨架:

c

#include <stddef.h>

void *my_memmove(void *dst, const void *src, size_t n) {
    unsigned char *d = dst;
    const unsigned char *s = src;
    if (d == s || n == 0) return dst;
    if (d < s) {
        for (size_t i = 0; i < n; i++) d[i] = s[i];
    } else {
        for (size_t i = n; i-- > 0; ) d[i] = s[i];
    }
    return dst;
}

d < s 这个比较在标准 C 里对不相关对象是未定义行为——指针比较只对同一数组内定义良好。严格按标准,应该比较 uintptr_t 转换后的值,或者用 std::less 那套等价思路。实际 libc 实现里普遍直接比较指针,因为主流的平铺地址空间下这个行为是稳定的。但如果你在写需要严格可移植的代码,这一点得留意。这是“事实与判断”的分界:标准说不允许,实现说没问题,你得决定按哪个走。

手写的“优化版”为什么经常更慢

网上常见的一段 memcpy 优化写法:前缀对齐、按机器字长批量拷贝、尾部处理。

c

#include <stdint.h>
#include <stddef.h>

void *fast_memcpy(void *dst, const void *src, size_t n) {
    unsigned char *d = dst;
    const unsigned char *s = src;

    /* 前缀:逐字节到对齐边界 */
    while (n && ((uintptr_t)d & (sizeof(uintptr_t) - 1))) {
        *d++ = *s++;
        n--;
    }
    /* 主体:按字长 */
    uintptr_t *dw = (uintptr_t *)d;
    const uintptr_t *sw = (const uintptr_t *)s;
    while (n >= sizeof(uintptr_t)) {
        *dw++ = *sw++;
        n -= sizeof(uintptr_t);
    }
    /* 尾部 */
    d = (unsigned char *)dw;
    s = (const unsigned char *)sw;
    while (n--) *d++ = *s++;
    return dst;
}

看起来比逐字节快。但在 x86-64 上,从 GCC 12 / Clang 15 开始,这种手写版本对小于 128 字节的拷贝,经常比直接 memcpy 调用还慢。原因不是代码写得不好,是 memcpy 已经被编译器当作内建函数处理了。GCC 在 -O2 以上会对已知大小的 memcpy 直接内联成几条 mov 指令,调用库函数的那层间接开销完全消失。而手写的 fast_memcpy 是一个普通函数,编译器不会对它做同样的内联展开,反而多了一次函数调用。

更大的问题是运行时 CPU 分派。glibc 的 memcpy 在启动时会根据 CPU 特性选择实现——有 AVX-512 用 AVX-512,只有 SSE2 用 SSE2,甚至在小尺寸下走专门的短拷贝路径。手写版本用的是固定宽度的 uintptr_t,一个周期搬 8 字节,而 AVX2 一次 32 字节、AVX-512 一次 64 字节。对中等大小的拷贝,差距是数量级的。

那手写优化什么时候值得?两个条件同时满足时:目标平台上没有这么完善的 memcpy(比如某些嵌入式环境或裸机),且拷贝尺寸固定、编译器无法内联。除此之外,手写版本的收益基本是负的。

我这里只给判断,不给具体加速比:不同 CPU、不同编译器版本、不同优化级别下差异很大。如果要写进生产代码,必须在目标平台上实测,而不是相信某种“通用”优化写法。

对齐与未对齐访问

前缀对齐那一步,动机是某些架构(老的 ARM、SPARC、部分 MIPS)对未对齐的多字节访存会触发异常。x86-64 不会,但性能上未对齐访问跨缓存行时会更慢,前缀对齐能减少这种边界。

然而从 C11 开始,标准库提供了 memcpy 本身作为“未对齐访问”的可移植方式。如果你想写一段“拷贝 8 字节到未对齐地址”,正确写法是用 memcpy(&dst, &src, 8),而不是 *(uint64_t *)dst = *(uint64_t *)src。后者在严格对齐的平台上会 fault,前者编译器会识别并对齐处理。

c

/* 不推荐:依赖未对齐访问 */
*(uint64_t *)d = *(const uint64_t *)s;

/* 推荐:语义正确,编译器会优化 */
uint64_t tmp;
memcpy(&tmp, s, sizeof tmp);
memcpy(d, &tmp, sizeof tmp);

所以手写 fast_memcpy 里用 uintptr_t * 强转那一行,本身就是不严格的。如果要对齐,应该先对齐到 _Alignof(uintptr_t),或者干脆依赖库函数。这一段是“写法看起来更专业、但实际把可移植性搞坏了”的典型。

一个值得关心的语义差异:restrict

memcpy 的声明在 C99 之后是:

c

void *memcpy(void *restrict dst, const void *restrict src, size_t n);

restrict 告诉编译器:dst 和 src 指向的对象在函数执行期间不通过其他指针访问。这让编译器可以放心地用 SIMD 并行搬数据,不必考虑“前面写过的字节后面又要读”的依赖。这也正是重叠调用未定义行为的根源。

memmove 没有 restrict,所以实现里必须自己判断方向。

一个可移植的接口约定:如果调用方保证不重叠,传 memcpy,让编译器内联和向量化;如果重叠,传 memmove。如果拿不准,用 memmove——它的代价通常只是多一次 dst < src 的比较,在已经内联的路径上可以忽略。

验证

验证重叠语义,不要看“输出对不对”,要看 memmove 和 memcpy 在重叠调用下的差异:同一段输入,用 memmove 得到确定结果;用 memcpy,在同一平台上反复跑可能得到不同结果(取决于优化级别和缓冲区对齐),换平台可能直接崩溃。ASan 的 -fsanitize=address 不直接检查 memcpy 重叠,但配合 _FORTIFY_SOURCE=3 的 glibc 在部分情况下会报 memcpy-param-overlap。

验证手写优化是否值得,写一个 benchmark,覆盖 4、16、64、256、4096 字节六档拷贝大小,每档跑一百万次,对比手写版和直接 memcpy。如果手写版在大多数档位上不显著更快,就不值得引入。别用“最优档位更快”来论证,要看多数场景。

最后一句话,是我个人的判断:C 里大部分“手写优化”的收益,要么已经被编译器吸收,要么只在特定平台成立。memcpy 是这个规律的典型样本——手写它作为练习可以,作为生产代码里的替换项,得有非常具体的测量支撑。

Logo

更多推荐