手写 memcpy:从字节对齐到移位拼接,这才是底层优化的艺术
很多人觉得 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 宽度),逐字节拷贝完全浪费了总线带宽。真正的优化核心就两件事:
- 对齐:让读写地址落在 4/8/16 字节边界上,触发宽总线传输
- 批量:一次拷 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&8、n&4、n&2、n&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&16、n&8、n&4、n&2、n&1 逐字节处理剩余数据。这里没有用 u32 拷贝,因为剩余数据一定不足 16 字节且可能跨 u32 边界,逐字节最安全。
四、性能对比(定性)
| 实现方式 | 对齐拷贝 | 未对齐处理 | 适用场景 |
|---|---|---|---|
| 逐字节 | ❌ | ❌ | n < 16 |
| 朴素 u32 循环 | ✅ | ❌ 崩溃 | 两端都对齐 |
| 本实现 | ✅ 16字节/次 | ✅ 移位拼接 | 任意情况 |
| Glibc memcpy | ✅ SIMD | ✅ ERMS | 大块数据 |
本实现的优势:代码短、无依赖、无 SIMD intrinsic、跨平台。在嵌入式和内核场景非常实用。
五、一句话总结
这段 memcpy 的核心思想:先对齐,再用两个 u32 滑动窗口做移位拼接,一套逻辑通吃所有未对齐场景。 看懂了它,你就看懂了底层内存拷贝的本质。
觉得有用的话点个赞,后续可以拆解 memmove(处理重叠区域)和 memset 的优化实现。
更多推荐

所有评论(0)