上一讲我们把字符串函数从头到尾捋了一遍,不知道你有没有注意到一个规律:strcpy、strcat、strcmp 这些函数,全都是靠 '\0' 来判断终点的。遇到 '\0' 就停,没遇到就一直跑——这个行为在操作"纯文本"时很自然,但一旦你面对的是二进制数据,问题就来了。
你想拷贝一个整型数组,数组里可能有个元素恰好是 0x00000000——对 memcpy 来说这只是"一个值为 0 的 int",但对 strcpy 来说这就是一个 '\0',它会在那里停住,丢掉后面的数据。你想把一个包含 '\0' 字节的二进制数据包拼接起来,strcat 直接罢工。你需要的是一组不关心内容语义、只认"字节数"的函数——这就是内存操作函数登场的原因。
内存函数和字符串函数最本质的区别就一句话:字符串函数靠 '\0' 判断终点,内存函数靠 num(字节数)判断终点。这个区别虽然小,但影响深远——因为内存函数不关心数据类型,它们操作的对象是 void *,也就是"万能指针"。这意味着你可以用同一套函数来拷贝整型数组、结构体、浮点数、序列化后的网络数据包……只要是连续的内存块,统统适用。
先搞懂 void*:万能指针的两条铁律
在深入具体函数之前,先把 void * 这个特殊类型说清楚。void * 可以指向任何类型的数据,但有两个限制:
- 不能直接解引用(
*ptr是非法的,因为编译器不知道要读几个字节)。 - 不能做算术运算(
ptr + 1是非法的,因为编译器不知道步长是多少)。
那怎么用呢?先强转成具体类型的指针——比如 (char *)ptr——然后再操作。所有内存函数在内部实现时,都是通过 char * 指针来逐字节搬运数据的,因为 char 类型正好是 1 个字节,操作粒度和内存地址完美匹配。
#include <stdio.h>
int main()
{
int x = 0x12345678;
void *p = &x; /* void* 可以指向任何类型 */
/* printf("%d\n", *p); 非法!不能解引用 void* */
/* printf("%d\n", *(p+1)); 非法!不能对 void* 做算术 */
/* 正确姿势:先转成具体类型的指针再操作 */
int *ip = (int *)p;
char *cp = (char *)p;
printf("按 int 读取: 0x%X\n", *ip); /* 0x12345678 */
printf("按 char 读取: 0x%X\n", (unsigned char)*cp); /* 首字节 */
return 0;
}还有一个细节:void * 是唯一一种"任何类型指针都可以隐式转换到它、它也可以隐式转换回任何类型指针"的类型。在 C 语言里 int *p = malloc(100); 不用强制转换也能编译(C++ 不行,必须强转)。不过为了代码清晰,很多教程还是建议显式转换。
内存重叠:理解 memcpy 和 memmove 的钥匙
还有一个关键概念需要先讲清楚——内存重叠。当你把一段数据从位置 A 拷贝到位置 B,如果 A 和 B 的内存区域有交集,就发生了重叠。比如你想把数组 arr[0..4] 的内容拷贝到 arr[2..6],源和目标共用 arr[2..4] 这一段。如果拷贝方向不当——从前向后拷贝——你在拷贝 arr[2] 的时候,原始值已经被你刚才拷贝的 arr[0] 覆盖掉了。这就是为什么 memcpy 和 memmove 需要分开存在——它们对重叠的处理策略完全不同。
用一张图来理解重叠方向:
情况A:目标在源之前(dest < src)
源: [a][b][c][d][e]
目标: [a][b][c][d][e]
从前向后拷贝 → 安全(源的低地址内容先被读走)
情况B:目标在源之后且重叠(dest > src 且重叠)
源: [a][b][c][d][e]
目标: [a][b][c][d][e]
从前向后拷贝 → 危险!dest 的 a 位置会覆盖还没读到的 c
从后向前拷贝 → 安全(先读走高地址的 e,再依次向前)
这就是 memmove 的方向判断逻辑。而 memcpy 的标准规定是不处理重叠——重叠时行为未定义。
memcpy
void * memcpy ( void * destination, const void * source, size_t num );memcpy 是最纯粹的内存拷贝函数:从 source 地址开始,逐字节拷贝 num 个字节到 destination。不关心 '\0',不关心数据类型,只做一件事——搬字节。它和后面的 memmove、memset 一样,都返回目标内存的起始地址(即 destination 本身),方便链式调用,但这个返回值平时常常被忽略。来看一个最简单的例子:
#include <stdio.h>
#include <string.h>
int main()
{
int arr1[] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
int arr2[10] = { 0 };
/* 从 arr1 拷贝 20 个字节到 arr2(即 5 个 int,每个 4 字节) */
memcpy(arr2, arr1, 20);
int i;
for (i = 0; i < 10; i++)
{
printf("%d ", arr2[i]);
}
/* 输出: 1 2 3 4 5 0 0 0 0 0 */
printf("\n");
return 0;
}注意那个 20——是 20 个字节,不是 20 个元素。这是内存函数使用时最容易犯的错:第三个参数始终是字节数,而不是元素个数。如果你写成 memcpy(arr2, arr1, 5),那只能拷贝 5 个字节,连两个完整的 int 都不到。正确的写法是 5 * sizeof(int) 或者直接 sizeof(arr1)(前提是 arr1 是数组而非指针)。
这里再强调一次数组和指针的区别:在函数参数里,int arr[] 会退化成 int *arr,所以如果你在函数内部写 sizeof(arr),得到的是指针大小(4 或 8),不是数组大小!要拷贝整个数组,要么传入长度参数,要么用 sizeof 在数组声明的那个作用域里算好再传进去。
memcpy 的模拟实现
下面是 memcpy 的模拟实现,逻辑非常直白——逐字节从前向后拷贝:
#include <stdio.h>
#include <assert.h>
/* memcpy 的简单模拟:
不考虑重叠,只实现从前向后的逐字节拷贝 */
void *my_memcpy(void *dst, const void *src, size_t count)
{
void *ret = dst; /* 保存目标起始地址 */
assert(dst != NULL);
assert(src != NULL);
/* 逐字节拷贝:将 src 的每个字节写到 dst 对应位置 */
while (count--)
{
*(char *)dst = *(char *)src; /* 转成 char* 来操作单字节 */
dst = (char *)dst + 1; /* 目标指针前进 1 字节 */
src = (char *)src + 1; /* 源指针前进 1 字节 */
}
return ret;
}
int main()
{
int src[5] = {10, 20, 30, 40, 50};
int dst[5] = {0};
my_memcpy(dst, src, sizeof(src));
for (int i = 0; i < 5; i++)
printf("%d ", dst[i]); /* 输出: 10 20 30 40 50 */
printf("\n");
return 0;
}用模拟实现演示 memcpy 在重叠时如何出错
很多人记不住"memcpy 不能用于重叠"到底是什么意思,那就亲眼看看。我们把 my_memcpy 用于重叠场景,观察它如何把数据搞坏:
#include <stdio.h>
void *my_memcpy(void *dst, const void *src, size_t count)
{
void *ret = dst;
while (count--)
{
*(char *)dst = *(char *)src;
dst = (char *)dst + 1;
src = (char *)src + 1;
}
return ret;
}
int main()
{
/* 目标在源之后且重叠:把 arr[0..3] 拷到 arr[2..5] */
int arr[] = {1, 2, 3, 4, 5, 6, 7, 8};
my_memcpy(arr + 2, arr, 4 * sizeof(int));
printf("memcpy 重叠拷贝结果: ");
for (int i = 0; i < 8; i++)
printf("%d ", arr[i]);
printf("\n");
/* 输出: 1 2 1 2 1 2 7 8 (本应是 1 2 1 2 3 4 7 8) */
/* 原因:从前向后拷贝,arr[2] 被 arr[0] 覆盖后,
再拷贝 arr[2] 时取到的已经是覆盖后的值——数据"复制传染"了 */
return 0;
}看到那个 1 2 1 2 1 2 了吗?正确的重叠拷贝应该是 1 2 1 2 3 4,但 memcpy 把已经覆盖过的数据又"复制"了一份。这就是为什么 C 标准明确规定:memcpy 遇到重叠行为未定义——标准实现可能用 SIMD 一次拷 32 字节,顺序和你的直觉不一样,结果完全不可预测。而 memmove 就是专门为重叠场景设计的。
顺便说一句,标准库的 memcpy 实现通常不会真的逐字节循环——编译器会针对目标平台进行高度优化。对于大块内存,现代实现可能使用 SIMD 指令(如 SSE、AVX)一次搬运 16 或 32 个字节。所以你也别轻易尝试"手写 memcpy 来提升性能"——标准库的版本经过了数十年的优化,几乎总是比你写的快。
另外,当你用 memcpy 而不是 strcpy 来拷贝字符串时,记得手动补 '\0':
char dest[20];
memcpy(dest, "hello", 5);
dest[5] = '\0'; /* 必须手动加!strcpy 会帮你加,memcpy 不会 */memmove
void * memmove ( void * destination, const void * source, size_t num );memmove 和 memcpy 的功能几乎一样,唯一的区别是它保证在重叠情况下也能正确工作。它是怎么做到的?核心在于判断拷贝方向:
- 如果目标地址在源地址之前(或完全不重叠),就从前向后拷贝——和 memcpy 一样。
- 如果目标地址在源地址之后且存在重叠,就从后向前拷贝——这样源数据在被覆盖前就已经被拷贝走了。
你可以想象搬家具的场景:如果新位置在旧位置的"前面",你从最前面的家具开始搬;如果新位置在旧位置的"后面",你从最后面的家具开始搬。这样无论如何都不会出现"还没搬就被别人占了位置"的情况。
#include <stdio.h>
#include <string.h>
int main()
{
int arr[] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
/* 把 arr[0..4] 的内容拷贝到 arr[2..6](重叠) */
/* arr 原始: [1][2][3][4][5][6][7][8][9][10] */
/* 期望结果: [1][2][1][2][3][4][5][8][9][10] */
memmove(arr + 2, arr, 20);
int i;
for (i = 0; i < 10; i++)
{
printf("%d ", arr[i]);
}
/* 输出: 1 2 1 2 3 4 5 8 9 10 */
printf("\n");
return 0;
}memmove 的模拟实现——方向判断是精髓
#include <stdio.h>
#include <assert.h>
#include <string.h>
void *my_memmove(void *dst, const void *src, size_t count)
{
void *ret = dst;
/* 情况A:目标在源前面(或无重叠)→ 从前向后拷贝 */
if (dst <= src || (char *)dst >= (char *)src + count)
{
while (count--)
{
*(char *)dst = *(char *)src;
dst = (char *)dst + 1;
src = (char *)src + 1;
}
}
/* 情况B:目标在源后面且重叠 → 从后向前拷贝 */
else
{
/* 将指针移到各自区域的最后一个字节 */
dst = (char *)dst + count - 1;
src = (char *)src + count - 1;
while (count--)
{
*(char *)dst = *(char *)src;
dst = (char *)dst - 1; /* 从后向前移动 */
src = (char *)src - 1;
}
}
return ret;
}
int main()
{
/* 测试不重叠的情况 */
char buf1[20] = "hello world";
my_memmove(buf1 + 6, buf1, 5);
printf("不重叠: %s\n", buf1); /* "hello hello" */
/* 测试重叠的情况 */
char buf2[20] = "abcdefghij";
my_memmove(buf2 + 2, buf2, 5);
/* 期望: buf2 变成 "ababcdehij" */
printf("重叠: %s\n", buf2);
return 0;
}注意方向判断的条件:dst <= src 说明目标在源前面(或重叠但目标低地址),此时从前向后安全——因为目标要覆盖的区域在源区域的"前面",源区域末尾的数据不会被目标的开头覆盖掉;(char *)dst >= (char *)src + count 说明两个区域完全不重叠(目标整个在源后面),任何方向都安全。除此之外的情况(目标在源之后且重叠),必须从后向前。
这里有两个代码细节值得说道说道:
- 两个指针比较
dst <= src在标准里其实是"未定义"的严格说法——只有当两个指针指向同一个数组(或同一块分配的内存)时才允许比较。但在重叠拷贝的场景下,dest 和 src 本来就在同一块内存区域里,所以这种比较在实际的 memcpy/memmove 实现中被广泛使用,实践中是安全的。 while (count--)先用后减:第一次进入时判断count非 0,执行循环体,然后 count 减 1。当 count 从 0 开始时,条件为假,循环一次都不执行。注意和while (--count)的区别——那是"先减后判断",行为完全不同。
经典应用:用 memmove 实现数组元素删除
在实际项目中,memmove 最常见的应用场景之一是实现数组元素的删除——比如把数组中从 index+1 开始的元素整体前移一位:
#include <stdio.h>
#include <string.h>
/* 将数组中从 index 开始的元素整体前移一位(覆盖 index 处的元素) */
void remove_element(int *arr, int size, int index)
{
if (index < 0 || index >= size - 1) return;
/* arr+index+1 是源,arr+index 是目标,两者有重叠 */
/* 目标在源前面,memmove 从前向后拷贝,正确! */
int elements_to_move = size - index - 1;
memmove(arr + index, arr + index + 1,
elements_to_move * sizeof(int));
}
int main()
{
int arr[] = {10, 20, 30, 40, 50};
int size = 5;
printf("删除前: ");
for (int i = 0; i < size; i++)
printf("%d ", arr[i]);
printf("\n");
/* 删除索引为 2 的元素(值为 30) */
remove_element(arr, size, 2);
printf("删除后: ");
for (int i = 0; i < size; i++)
printf("%d ", arr[i]);
/* 输出: 10 20 40 50 50(最后一个 50 是残留,逻辑上已被"移出"数组) */
printf("\n");
return 0;
}这个场景为什么必须用 memmove 而不是 memcpy?因为 arr+index(目标)在 arr+index+1(源)之前且两个区域相邻重叠——用 memcpy 是未定义行为,即使多数编译器碰巧工作。这里用 memmove 是唯一正确的选择。
另一个经典应用:循环左移数组
#include <stdio.h>
#include <string.h>
/* 将数组循环左移 k 个位置(k 在 [0, size) 内) */
void rotate_left(int *arr, int size, int k)
{
/* 分两段:先用临时数组存前 k 个元素 */
int temp[100]; /* 简化:假设 k 不会超过 100 */
int i;
for (i = 0; i < k; i++)
temp[i] = arr[i];
/* 把后面 size-k 个元素前移(重叠,必须用 memmove) */
memmove(arr, arr + k, (size - k) * sizeof(int));
/* 把临时数组里的元素放回末尾 */
for (i = 0; i < k; i++)
arr[size - k + i] = temp[i];
}
int main()
{
int arr[] = {1, 2, 3, 4, 5, 6, 7};
rotate_left(arr, 7, 3);
printf("循环左移 3 位: ");
for (int i = 0; i < 7; i++)
printf("%d ", arr[i]);
printf("\n"); /* 输出: 4 5 6 7 1 2 3 */
return 0;
}memset
void * memset ( void * ptr, int value, size_t num );memset 把 ptr 指向的前 num 个字节,每个字节都设置为 value(实际上只使用 value 的低 8 位)。最常见的用途是把一块内存初始化为 0:
#include <stdio.h>
#include <string.h>
int main()
{
/* 用 'x' 填充字符串的前 6 个字符 */
char str[] = "hello world";
memset(str, 'x', 6);
printf("填充字符串: %s\n", str); /* 输出: xxxxxxworld */
/* 将整型数组全部置零(最常见的用法) */
int arr[10];
memset(arr, 0, sizeof(arr)); /* 每个字节都设为 0 */
for (int i = 0; i < 10; i++)
printf("%d ", arr[i]); /* 全是 0 */
printf("\n");
return 0;
}但这里有个巨大的陷阱——memset 是按字节设置的,而不是按元素。很多初学者会写出这样的代码:
int arr[10];
memset(arr, 1, sizeof(arr)); /* 期望:每个元素都是 1;实际:每个元素是 0x01010101 */因为每个字节都被设成了 1,一个 4 字节的 int 就变成了 0x01010101 = 16843009,而不是你期望的 1。结论很简单:memset 只能可靠地用于置零。当所有字节都是 0 时,无论你用 int、float、指针还是任何类型来解读,结果都是 0。但如果想设置成其他值——用循环逐个赋值。
#include <stdio.h>
#include <string.h>
int main()
{
int arr1[5];
int arr2[5];
/* 错误做法:想把每个 int 设成 1 */
memset(arr1, 1, sizeof(arr1));
/* arr1[0] 实际上是 0x01010101 = 16843009,不是 1! */
/* 正确做法:用循环逐个赋值 */
for (int i = 0; i < 5; i++)
{
arr2[i] = 1;
}
printf("memset(1) 的结果: %d (0x%08X)\n", arr1[0], arr1[0]);
/* 输出: 16843009 (0x01010101) */
printf("循环赋值的结果: %d (0x%08X)\n", arr2[0], arr2[0]);
/* 输出: 1 (0x00000001) */
return 0;
}有一个例外:memset(arr, -1, sizeof(arr)) 可以把每个 int 设为 -1。这是因为 -1 的补码是全 1(0xFF),每个字节都是 0xFF,拼起来正好是 0xFFFFFFFF = -1。但这是靠巧合,不是通用规则。
思考题:memset(p, 0, n) 对 double 数组置零安全吗?对结构体数组置零安全吗?答案是都安全——因为 IEEE 754 中 0.0 的位模式是全 0,NULL 指针的位模式在绝大多数平台也是全 0(标准并不保证,但实践中如此),而置零后结构体所有成员都是 0。所以"清零"是 memset 唯一可靠的非字符用途。
memcmp
int memcmp ( const void * ptr1, const void * ptr2, size_t num );逐字节比较 ptr1 和 ptr2 的前 num 个字节。返回值规则和 strcmp 一样:ptr1 > ptr2 返回正数,相等返回 0,小于返回负数。但它不会在遇到 '\0' 时停止——会老老实实地比较完 num 个字节。
有一个细节值得留意:比较是按无符号字节(unsigned char)进行的。也就是说,即使某段数据里含有符号位为 1 的字节(值大于 127),它们也会被当作 0~255 的正数来比较大小,而不是当作负数。这和 strcmp 按 unsigned char 逐字节比较的规则是一致的。
#include <stdio.h>
#include <string.h>
int main()
{
/* 大小写不同的两串数据 */
char buffer1[] = "DWgaOtP12df0";
char buffer2[] = "DWGAOTP12DF0";
int n = memcmp(buffer1, buffer2, sizeof(buffer1));
if (n > 0)
printf("'%s' 大于 '%s'\n", buffer1, buffer2);
else if (n < 0)
printf("'%s' 小于 '%s'\n", buffer1, buffer2);
else
printf("'%s' 等于 '%s'\n", buffer1, buffer2);
/* 输出: 'DWgaOtP12df0' 大于 'DWGAOTP12DF0'
因为小写字母 'g'(103) > 大写字母 'G'(71) */
return 0;
}memcmp 与 strcmp 的区别(重要对比)
| 维度 | strcmp | memcmp |
|---|---|---|
| 终止条件 | 遇到 '\0' 或发现差异 | 只认 num,不理会 '\0' |
| 比较范围 | 从开头到第一个不同字符或 '\0' | 固定前 num 个字节 |
| 常用于 | 字符串比较 | 二进制数据、结构体比较 |
遇到 '\0' | 停止 | 继续('\0' 只是普通字节 0) |
| 参数 | const char * | const void * |
一个典型场景:比较两个二进制协议包的前 4 个字节(魔数),判断数据格式。
#include <stdio.h>
#include <string.h>
#define MAGIC_NUM 0x4D534346 /* "MSCF" 的 ASCII 值 */
int main()
{
unsigned char packet[16] = {0x4D, 0x53, 0x43, 0x46, 0x00, 0x01};
/* 只比较前 4 个字节是否为魔数 */
if (memcmp(packet, &MAGIC_NUM, 4) == 0)
{
printf("是合法文件头\n");
}
else
{
printf("不是合法文件头\n");
}
/* 注意:这里依赖小端序;严谨写法应逐字节比较或用 ntohl 转换 */
return 0;
}memcmp 比较结构体的陷阱
memcmp 的一个常见用途是比较结构体——比如判断两个学生记录是否完全一致:
#include <stdio.h>
#include <string.h>
typedef struct
{
int id;
char name[20];
double score;
} Student;
int main()
{
Student s1 = {1, "Alice", 95.5};
Student s2 = {1, "Alice", 95.5};
Student s3 = {2, "Bob", 88.0};
/* 用 memcmp 比较两个结构体是否完全相同 */
if (memcmp(&s1, &s2, sizeof(Student)) == 0)
{
printf("s1 和 s2 完全相同\n");
}
if (memcmp(&s1, &s3, sizeof(Student)) != 0)
{
printf("s1 和 s3 不同\n");
}
/* 注意:如果结构体有填充字节(padding),填充字节的值是不确定的,
可能导致两个"逻辑相等"的结构体被 memcmp 认为不同。
生产代码中应谨慎使用 memcmp 比较结构体 */
return 0;
}注意那个警告——结构体因为内存对齐,成员之间可能有填充字节(padding),而这些填充字节的值是不确定的。两个逻辑上完全相同的结构体,可能因为填充字节的差异而被 memcmp 判定为不同。如果你要比较结构体,最安全的做法是逐个成员比较,或者在结构体初始化前先用 memset 清零(把填充字节也变成确定的 0)。
/* 安全比较结构体的两种方式 */
#include <stdio.h>
#include <string.h>
typedef struct
{
int id;
char name[20];
} Person;
/* 方式一:逐个成员比较(最稳妥) */
int person_equal_member(const Person *a, const Person *b)
{
return a->id == b->id && strcmp(a->name, b->name) == 0;
}
/* 方式二:先清零再填充(让填充字节确定,才能安全用 memcmp) */
Person make_person(int id, const char *name)
{
Person p;
memset(&p, 0, sizeof(p)); /* 先清零,padding 也变成 0 */
p.id = id;
strcpy(p.name, name);
return p;
}
int main()
{
Person p1 = make_person(1, "Alice");
Person p2 = make_person(1, "Alice");
if (memcmp(&p1, &p2, sizeof(Person)) == 0)
printf("清零后 memcmp 相等\n");
if (person_equal_member(&p1, &p2))
printf("逐成员比较相等\n");
return 0;
}综合实战:用四个内存函数实现一个动态数组
最后,用一个综合示例来看这四个函数如何在实际项目中协同工作——实现一个简单的动态数组(类似 C++ 的 std::vector):
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct
{
int *data;
size_t size;
size_t capacity;
} DynamicArray;
/* 初始化动态数组 */
void da_init(DynamicArray *da, size_t initial_capacity)
{
da->data = (int *)malloc(initial_capacity * sizeof(int));
da->size = 0;
da->capacity = initial_capacity;
}
/* 扩容(容量翻倍) */
void da_resize(DynamicArray *da)
{
size_t new_capacity = da->capacity * 2;
int *new_data = (int *)malloc(new_capacity * sizeof(int));
/* 用 memcpy 把旧数据完整拷贝到新空间 */
memcpy(new_data, da->data, da->size * sizeof(int));
/* 用 memset 将新分配的多余空间清零 */
memset(new_data + da->size, 0,
(new_capacity - da->size) * sizeof(int));
free(da->data);
da->data = new_data;
da->capacity = new_capacity;
}
/* 追加元素 */
void da_push(DynamicArray *da, int value)
{
if (da->size >= da->capacity)
da_resize(da);
da->data[da->size++] = value;
}
/* 在指定位置插入(memmove 处理重叠后移) */
void da_insert(DynamicArray *da, size_t index, int value)
{
if (index > da->size) return; /* 越界保护 */
if (da->size >= da->capacity)
da_resize(da);
/* 把 index 起的元素整体后移一位(重叠,用 memmove) */
memmove(da->data + index + 1, da->data + index,
(da->size - index) * sizeof(int));
da->data[index] = value;
da->size++;
}
void da_free(DynamicArray *da)
{
free(da->data);
da->data = NULL;
da->size = da->capacity = 0;
}
int main()
{
DynamicArray arr;
da_init(&arr, 2); /* 初始容量 2 */
for (int i = 1; i <= 10; i++)
da_push(&arr, i * 10);
/* 在位置 3 插入 999 */
da_insert(&arr, 3, 999);
printf("动态数组内容: ");
for (size_t i = 0; i < arr.size; i++)
printf("%d ", arr.data[i]);
printf("\n容量: %zu, 大小: %zu\n", arr.capacity, arr.size);
da_free(&arr);
return 0;
}这个综合示例里,memcpy 负责"迁移数据",memset 负责"清零新空间",memmove 负责"重叠后移插入"——三个内存函数各司其职,加上 malloc/free,就是 C 语言实现动态数组的标准配方。
内存函数的易错点速查
把本讲的坑集中列一遍,写代码前过一眼:
- 第三参数永远是字节数:
memcpy(d, s, n)的 n 是字节,拷贝int数组记得n * sizeof(int)。 - 重叠必须用 memmove:memcpy 遇到重叠是未定义行为,即使"碰巧能跑"也不可靠。
- memset 按字节设置:只能可靠置 0(和置 -1 的巧合),别的值用循环。
- memcmp 比较结构体有 padding 陷阱:先 memset 清零或逐成员比较。
- void* 不能解引用和算术:先转成
char *或具体类型指针。 - 拷贝字符串时 memcpy 不会补 '\0':记得手动补。
- NULL 检查:memcpy/memmove/memset 都不检查参数为 NULL,传 NULL 就是未定义行为,调用方要自己保证。
总结
回到最开头那个问题:把内存函数和字符串函数放在一起看,才能建立起"字节级操作 vs 字符级操作"的双重视角。strcpy 关心 '\0',memcpy 不关心;strcpy 不需要长度参数(它自己会找到 '\0'),memcpy 必须有长度参数(它不知道什么时候停)。这是两套函数最根本的分野,也是你在写 C 代码时最核心的两个"工具箱"。
实际项目中有太多场景离不开内存函数:序列化/反序列化一个结构体到字节缓冲区、实现内存池时用 memset 初始化分配的内存块、在环形缓冲区中用 memmove 处理跨边界的数据搬运、用 memcmp 比较协议包的魔数来识别数据类型……搞懂这四个函数,你就真正触碰到了 C 语言的底层灵魂——一切数据,追根溯源,都是字节序列。
思考题
- 为什么 memcpy 的标准签名是
void *memcpy(void *dest, const void *src, size_t n)而不是char *?参数用const void *和void *分别想表达什么? memcpy(a, b, sizeof(a))和memcpy(a, b, sizeof(b))在什么情况下结果不同?(提示:a、b 类型不同时)- 自己推导:
char s[16] = "hello"; memmove(s + 2, s, 5);之后 s 的内容是什么?memcpy(s + 2, s, 5)呢? memset(p, 255, n)对int数组的效果是什么?为什么和memset(p, -1, n)一样?- 为什么标准规定 memcpy 重叠是"未定义行为"而不是直接禁止编译?这对实现有什么好处?
- 实现
my_memset(void *p, int c, size_t n),并用它验证memset(p, 0, n)对 double 数组的安全置零。 - 想一想:如果内存函数接收的不是字节数而是"元素个数 + 元素大小"两个参数(像 calloc 那样),会不会更不容易出错?为什么标准没有这样设计?
思考题参考答案
1. 为什么 memcpy 的标准签名是 void * 而不是 char *?参数用 const void * 和 void * 分别想表达什么?
因为 memcpy 是"一视同仁地搬字节",它不该关心、也不想知道目标是 int 数组、struct 还是别的什么。如果签名是 char *,语义上就暗示"这函数跟字符有关",会让使用者误以为它只适合字符串——这显然违背内存函数"不关心类型、只认字节数"的设计初衷。void * 是"万能指针",任何类型的地址都能隐式转换过来,所以 memcpy 才能通用于任意数据块。
参数的位置表达了 const 的语义:const void *source 表示源是只读的(memcpy 承诺不改写源),void *destination 表示目标是可写的(memcpy 会改写它)。这正是 const 修饰指针的作用——向调用者声明"我动谁、我保证不动谁",也让编译器帮忙拦截你写错方向的调用。
2. memcpy(a, b, sizeof(a)) 和 memcpy(a, b, sizeof(b)) 在什么情况下结果不同?
只要 a 和 b 是不同的数组类型(占用字节数不同),两者拷贝的字节数就不同。例如 int a[10]; double b[10];:sizeof(a) 是 40(10 个 int),sizeof(b) 是 80(10 个 double)。memcpy(a, b, sizeof(a)) 只拷 40 字节——但 b 是 double 数组,前 40 字节只够 5 个 double,且把 double 的字节原样塞进 int 数组还涉及大小端/表示差异,结果在语义上往往是错误的(虽然拷贝本身合法)。
更危险的场景是方向搞反 + 越界:memcpy(a, b, sizeof(b)) 会把 80 字节写进只有 40 字节的 a,直接缓冲区溢出。所以这里的教训是:memcpy 的字节数必须根据需要实际想要搬运的内容来确定,通常用目标元素的 sizeof(类型) 乘上元素个数,而不是无脑 sizeof(a)。两个式子只有在 a、b 为同类型数组(sizeof 相等)时才可能一致。
3. 自己推导:char s[16] = "hello"; memmove(s + 2, s, 5); 之后 s 的内容是什么?memcpy(s + 2, s, 5) 呢?
初始 s 为:'h' 'e' 'l' 'l' 'o' '\0' 0 0 ...
memmove(s + 2, s, 5):目标(s+2)在源(s)后面且重叠,走"从后向前"分支:
- 取末尾字节依次前搬:
s[6]=s[5]、s[5]=s[4]、s[4]=s[3]、s[3]=s[2]、s[2]=s[1]。
得到:'h' 'e' 'e' 'l' 'l' 'o' '\0',即字符串 "hehello"。因为每个源字节在被覆盖前都已读走,结果正确。
memcpy(s + 2, s, 5):它只机械地"从前向后"搬:
s[2]=s[0]('h')→s[3]=s[1]('e')→s[4]=s[2](此时已是 'h',不是原来的 'l'!)→s[5]=s[3](已是 'e')→s[6]=s[4](已是 'h')。
得到:'h' 'e' 'h' 'e' 'h' 'h'('h' 之前 s[2..3] 被覆盖为 'h','e',连 s[6] 原终止符都被覆盖成 'h')。显示时 s[7] 恰好是初始化的 0,所以看到的是 "hehehh",属于数据损坏(标准上 memcpy 遇重叠本就是未定义行为)。可见同一代码换个方向就出问题——这也正是必须用 memmove 的原因。
4. memset(p, 255, n) 对 int 数组的效果是什么?为什么和 memset(p, -1, n) 一样?
memset 只取 value 的低 8 位,255 即 0xFF,所以把目标每个字节都设为 0xFF。一个 4 字节的 int 变成 0xFFFFFFFF,它的补码值恰好就是 -1。同理 memset(p, -1, n):-1 的补码就是全 1,低 8 位也是 0xFF,效果完全相同。所以两者都对 int 数组产生"每个元素 = -1"的效果。这也再次印证正文结论:memset 除了置 0 和这个"全 1 ≈ -1"的巧合外,不能可靠地设置成任意所需值。
5. 为什么标准规定 memcpy 重叠是"未定义行为"而不是直接禁止编译?这对实现有什么好处?
因为"是否重叠"是运行时才能知道的事情,编译器无法靠静态检查去判断,更不可能在编译期报错拦截。标准若要求 memcpy 遇到重叠必须报错,那它就必须在每次调用时加运行时检测和方向判断——那它就和 memmove 没有区别了。把 memcpy 定义为"不保证重叠正确",等于放开一个性能钩子:实现可以完全不做重叠检查,任意选择从前向后搬、用 SIMD 一次搬 16/32 字节、甚至并行化,都不用担心破坏任何合法用法。这样 memcpy 就能成为最快的内存拷贝原语,而"需要重叠安全"的用户改用 memmove 去付出那一点点方向判断的开销。这正是 C 语言"信任程序员、把最大性能留给不需要安全性的正确调用"的一贯哲学。
6. 实现 my_memset,并用它验证 memset(p, 0, n) 对 double 数组的安全置零。
#include <stdio.h>
#include <string.h>
#include <math.h>
/* 模拟实现 memset:把 p 起的前 n 个字节都设为 (unsigned char)c */
void *my_memset(void *p, int c, size_t n)
{
unsigned char *byte = (unsigned char *)p; /* 按字节操作 */
unsigned char value = (unsigned char)c; /* 只取低 8 位 */
void *ret = p;
while (n--)
{
*byte++ = value;
}
return ret;
}
int main()
{
/* 用 my_memset 把 double 数组置零 */
double d[4];
my_memset(d, 0, sizeof(d));
int all_zero = 1;
for (int i = 0; i < 4; i++)
if (d[i] != 0.0)
all_zero = 0;
printf("double 清零后均为 0.0: %s\n", all_zero ? "是" : "否");
/* 对比库函数 memset 结果一致 */
double e[4];
memset(e, 0, sizeof(e));
printf("与 memset 结果一致: %d\n", memcmp(d, e, sizeof(d)) == 0);
return 0;
}之所以安全,是因为 IEEE 754 中 0.0 的二进制表示(符号位 0、阶码与尾数全 0)就是全 0 字节,所以按字节清零后读取到的就是标准的 0.0。同理,把结构体按字节清零后,每个成员(int=0、float=0.0、指针=NULL、数组元素=0)在字节层面都是 0,逻辑上都等价于"零值"。
7. 如果内存函数接收"元素个数 + 元素大小"两个参数(像 calloc 那样),会不会更不容易出错?为什么标准没有这样设计?
从"防错"角度看,memcpy(d, s, 5, sizeof(int))(元素个数×元素大小)确实更不易把字节数和元素个数搞混——这正是 calloc(nmemb, size) 选择两个参数的考虑。但标准仍把内存函数设计成单一字节数,原因在于:
- 字节数是最通用的抽象。memcpy/memmove 的本质是"连续若干字节的整体搬运",调用方可能是结构体、协议包碎片、磁盘扇区,它们内部根本没有"元素"的概念。强行要求每次传入元素个数+大小反而束缚了场景。
- 性能与简洁。对纯内存块常用"一次拷贝已知大小的整块",直接给一个字节数最简单,不需要拆成两个参数再相乘,也省去乘法开销和对元素类型造出来的约束。
sizeof(type) × n已经足够表达"元素语义"。惯用法memcpy(d, s, n * sizeof(T))已经能把意图写清楚,编译器甚至会优化成移位/乘法,几乎没有额外负担。而 calloc 用两个参数是为了配合它"分配并清零 nmemb 个元素"的分配语义,和"搬数据"的 memcpy 用途不同,设计先行者不必强求一致。
所以这是"防呆便利"和"底层抽象通用性与性能"之间的权衡——标准把通用性优先,把"尺寸换算"的责任交给调用方,用 n * sizeof(T) 这一相对可靠的惯用法来降低出错率。
还没有评论 — 第一条由你来留。