很多人觉得 memcpy 就是个 for 循环逐字节拷贝。但 Glibc 里的 memcpy 有上百行,这段代码用不到 150 行实现了对齐拷贝 + 未对齐移位拼接,是底层优化的经典范本。


一、为什么不能直接逐字节拷贝?

先看最朴素的实现:

void *memcpy(void *dest, const void *src, size_t n) {
    unsigned char *d = dest;
    const unsigned char *s = src;
    for (; n; n--) *d++ = *s++;
    return dest;
}

能用吗?能。快吗?慢得离谱

现代 CPU 一次能读写 8~64 字节(取决于 SIMD 宽度),逐字节拷贝完全浪费了总线带宽。真正的优化核心就两件事:

  1. 对齐:让读写地址落在 4/8/16 字节边界上,触发宽总线传输
  2. 批量:一次拷 16 字节而不是 1 字节

但问题来了——如果源地址或目标地址没对齐呢?


二、核心难点:未对齐地址怎么办?

假设 dst = 0x1001(偏移 1),src = 0x2000(对齐)。

你不能直接 *(uint32_t*)dst = *(uint32_t*)src,因为:

  • dst 未对齐,某些架构会触发总线错误(如旧 ARM)
  • 即使不崩溃,也可能需要两次总线访问,反而更慢

解决方案:先对齐,再批量拷贝,最后处理尾巴。

这段代码的精髓就在于——它用两个 u32 变量滑动拼接,优雅地解决了未对齐问题。


三、源码逐段拆解

3.1 第一步:先把源地址对齐到 4 字节

for (; (uintptr_t)s % 4 && n; n--) *d++ = *s++;

不管三七二十一,先用逐字节拷贝把 src 推到 4 字节边界。最多拷 3 个字节。


3.2 分支判断:目标地址是否对齐?

if ((uintptr_t)d % 4 == 0) {
    // 分支A:dst 已对齐 → 直接 u32 拷贝
} else {
    // 分支B:dst 未对齐 → 移位拼接
}

3.3 分支A:dst 对齐——最简单也最快

for (; n>=16; s+=16, d+=16, n-=16) {
    *(u32 *)(d+0)  = *(u32 *)(s+0);
    *(u32 *)(d+4)  = *(u32 *)(s+4);
    *(u32 *)(d+8)  = *(u32 *)(s+8);
    *(u32 *)(d+12) = *(u32 *)(s+12);
}

每次拷 16 字节(4 个 u32),循环展开,零移位,零开销。

然后用 n&8n&4n&2n&1 处理剩余字节。


3.4 分支B:dst 未对齐——整段代码的灵魂

这是最值得细读的部分。根据 dst % 4 的值分三种情况:

Case 1:dst % 4 == 1(偏移 1 字节)
case 1:
    w = *(u32 *)s;          // 读第1个 u32:字节 [0,1,2,3]
    *d++ = *s++;            // 拷1字节,d 移到偏移2
    *d++ = *s++;            // 拷1字节,d 移到偏移3
    *d++ = *s++;            // 拷1字节,d 现在对齐了!
    n -= 3;
    
    for (; n>=17; s+=16, d+=16, n-=16) {
        x = *(u32 *)(s+1);  // 读下一个 u32,但从 offset+1 开始
        *(u32 *)(d+0) = (w LS 24) | (x RS 8);   // 拼接!
        w = *(u32 *)(s+5);
        *(u32 *)(d+4) = (x LS 24) | (w RS 8);
        // ... 以此类推
    }

用一张图理解

源数据(小端):
s:  [a0 a1 a2 a3] [b0 b1 b2 b3] [c0 c1 c2 c3] ...
       ↑w             ↑x             ↑w(下一轮)

目标地址偏移1,需要这样拼:
d:  [a1 a2 a3 b0] [b1 b2 b3 c0] [c1 c2 c3 d0] ...
       ←(w>>24)|(x<<8)→
  • w = [a0 a1 a2 a3]x = [b0 b1 b2 b3]
  • w LS 24(小端下 LS=>>):取出 a1 a2 a3,高位补0 → [00 a1 a2 a3]
  • x RS 8(小端下 RS=<<):取出 b0 放低位 → [b0 00 00 00]
  • 或起来:[b0 a1 a2 a3] ✅
Case 2:dst % 4 == 2(偏移 2 字节)
case 2:
    w = *(u32 *)s;
    *d++ = *s++; *d++ = *s++;   // 拷2字节对齐
    n -= 2;
    for (; n>=18; ...) {
        x = *(u32 *)(s+2);
        *(u32 *)(d+0) = (w LS 16) | (x RS 16);  // 移位16位拼接
        // ...
    }

拼接逻辑:[a2 a3 b0 b1] = (w>>16) | (x<<16)

Case 3:dst % 4 == 3(偏移 3 字节)
case 3:
    w = *(u32 *)s;
    *d++ = *s++;   // 拷1字节对齐
    n -= 1;
    for (; n>=19; ...) {
        x = *(u32 *)(s+3);
        *(u32 *)(d+0) = (w LS 8) | (x RS 24);   // 移位8位拼接
        // ...
    }

拼接逻辑:[a3 b0 b1 b2] = (w>>8) | (x<<24)


3.5 大小端适配——一行宏搞定

#if __BYTE_ORDER == __LITTLE_ENDIAN
#define LS >>    // Left Shift in memory = 右移(小端低地址存低位)
#define RS <<    // Right Shift in memory = 左移
#else
#define LS <<
#define RS >>
#endif

这个宏定义非常巧妙:

架构内存低地址LS 含义RS 含义
小端低位字节右移取出高位字节左移取出低位字节
大端高位字节左移取出高位字节右移取出低位字节

同一套拼接逻辑 (w LS k) | (x RS (32-k)),两边都适用。


3.6 __may_alias__——编译器别乱优化

typedef uint32_t __attribute__((__may_alias__)) u32;

告诉 GCC:这个 u32 指针可能和其他类型指针指向同一块内存,别做严格别名假设。否则编译器可能把 *(u32*)p 的读写优化掉,导致严重 bug。


3.7 尾部处理

switch 出来后,用 n&16n&8n&4n&2n&1 逐字节处理剩余数据。这里没有用 u32 拷贝,因为剩余数据一定不足 16 字节且可能跨 u32 边界,逐字节最安全。


四、性能对比(定性)

实现方式对齐拷贝未对齐处理适用场景
逐字节n < 16
朴素 u32 循环❌ 崩溃两端都对齐
本实现✅ 16字节/次✅ 移位拼接任意情况
Glibc memcpy✅ SIMD✅ ERMS大块数据

本实现的优势:代码短、无依赖、无 SIMD intrinsic、跨平台。在嵌入式和内核场景非常实用。


五、一句话总结

这段 memcpy 的核心思想:先对齐,再用两个 u32 滑动窗口做移位拼接,一套逻辑通吃所有未对齐场景。 看懂了它,你就看懂了底层内存拷贝的本质。


觉得有用的话点个赞,后续可以拆解 memmove(处理重叠区域)和 memset 的优化实现。

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐