上一讲我们把字符串函数从头到尾捋了一遍,不知道你有没有注意到一个规律:strcpy、strcat、strcmp 这些函数,全都是靠 '\0' 来判断终点的。遇到 '\0' 就停,没遇到就一直跑——这个行为在操作"纯文本"时很自然,但一旦你面对的是二进制数据,问题就来了。

你想拷贝一个整型数组,数组里可能有个元素恰好是 0x00000000——对 memcpy 来说这只是"一个值为 0 的 int",但对 strcpy 来说这就是一个 '\0',它会在那里停住,丢掉后面的数据。你想把一个包含 '\0' 字节的二进制数据包拼接起来,strcat 直接罢工。你需要的是一组不关心内容语义、只认"字节数"的函数——这就是内存操作函数登场的原因。

内存函数和字符串函数最本质的区别就一句话:字符串函数靠 '\0' 判断终点,内存函数靠 num(字节数)判断终点。这个区别虽然小,但影响深远——因为内存函数不关心数据类型,它们操作的对象是 void *,也就是"万能指针"。这意味着你可以用同一套函数来拷贝整型数组、结构体、浮点数、序列化后的网络数据包……只要是连续的内存块,统统适用。

先搞懂 void*:万能指针的两条铁律

在深入具体函数之前,先把 void * 这个特殊类型说清楚。void * 可以指向任何类型的数据,但有两个限制:

  1. 不能直接解引用(*ptr 是非法的,因为编译器不知道要读几个字节)。
  2. 不能做算术运算(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 说明两个区域完全不重叠(目标整个在源后面),任何方向都安全。除此之外的情况(目标在源之后且重叠),必须从后向前。

这里有两个代码细节值得说道说道:

  1. 两个指针比较 dst <= src 在标准里其实是"未定义"的严格说法——只有当两个指针指向同一个数组(或同一块分配的内存)时才允许比较。但在重叠拷贝的场景下,dest 和 src 本来就在同一块内存区域里,所以这种比较在实际的 memcpy/memmove 实现中被广泛使用,实践中是安全的。
  2. 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 的区别(重要对比)

维度strcmpmemcmp
终止条件遇到 '\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 语言实现动态数组的标准配方。

内存函数的易错点速查

把本讲的坑集中列一遍,写代码前过一眼:

  1. 第三参数永远是字节数:memcpy(d, s, n) 的 n 是字节,拷贝 int 数组记得 n * sizeof(int)。
  2. 重叠必须用 memmove:memcpy 遇到重叠是未定义行为,即使"碰巧能跑"也不可靠。
  3. memset 按字节设置:只能可靠置 0(和置 -1 的巧合),别的值用循环。
  4. memcmp 比较结构体有 padding 陷阱:先 memset 清零或逐成员比较。
  5. void* 不能解引用和算术:先转成 char * 或具体类型指针。
  6. 拷贝字符串时 memcpy 不会补 '\0':记得手动补。
  7. NULL 检查:memcpy/memmove/memset 都不检查参数为 NULL,传 NULL 就是未定义行为,调用方要自己保证。

总结

回到最开头那个问题:把内存函数和字符串函数放在一起看,才能建立起"字节级操作 vs 字符级操作"的双重视角。strcpy 关心 '\0',memcpy 不关心;strcpy 不需要长度参数(它自己会找到 '\0'),memcpy 必须有长度参数(它不知道什么时候停)。这是两套函数最根本的分野,也是你在写 C 代码时最核心的两个"工具箱"。

实际项目中有太多场景离不开内存函数:序列化/反序列化一个结构体到字节缓冲区、实现内存池时用 memset 初始化分配的内存块、在环形缓冲区中用 memmove 处理跨边界的数据搬运、用 memcmp 比较协议包的魔数来识别数据类型……搞懂这四个函数,你就真正触碰到了 C 语言的底层灵魂——一切数据,追根溯源,都是字节序列。

思考题

  1. 为什么 memcpy 的标准签名是 void *memcpy(void *dest, const void *src, size_t n) 而不是 char *?参数用 const void * 和 void * 分别想表达什么?
  2. memcpy(a, b, sizeof(a)) 和 memcpy(a, b, sizeof(b)) 在什么情况下结果不同?(提示:a、b 类型不同时)
  3. 自己推导:char s[16] = "hello"; memmove(s + 2, s, 5); 之后 s 的内容是什么?memcpy(s + 2, s, 5) 呢?
  4. memset(p, 255, n) 对 int 数组的效果是什么?为什么和 memset(p, -1, n) 一样?
  5. 为什么标准规定 memcpy 重叠是"未定义行为"而不是直接禁止编译?这对实现有什么好处?
  6. 实现 my_memset(void *p, int c, size_t n),并用它验证 memset(p, 0, n) 对 double 数组的安全置零。
  7. 想一想:如果内存函数接收的不是字节数而是"元素个数 + 元素大小"两个参数(像 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) 这一相对可靠的惯用法来降低出错率。