摘要:字符串函数遇到 '\0' 就停,只能处理文本;结构体、整数数组和二进制缓冲区里随时可能出现零字节,按字符串方式复制会把数据截断。本文讲 memcpy、memmove、memset、memcmp 和 memchr 这组按字节操作的内存函数,重点说明长度参数为什么以字节计、memcpy 为什么不允许重叠、memmove 怎样根据相对位置选择复制方向,最后用动态数组的删除与插入把 memmove 落到代码里。

1. 字符串函数停在 ‘\0’

strcpy 从源地址开始逐字节复制,遇到 '\0' 就停。对文本没有问题,对二进制数据就不成立:整数、浮点数、结构体里都可能有值为 0 的字节。

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

int main(void)
{
    int n = 10000;
    char text[sizeof n];

    for (size_t i = 0; i < sizeof text; i++)
        text[i] = '#';

    strcpy(text, (char *)&n);

    printf("sizeof(int) = %zu\n", sizeof n);
    printf("strlen(text) = %zu\n", strlen(text));
    for (size_t i = 0; i < sizeof n; i++)
        printf("%02X ", (unsigned char)text[i]);
    printf("\n");

    return 0;
}

输出:

sizeof(int) = 4
strlen(text) = 2
10 27 00 23

在小端机器上,10000 的 4 个字节是 10 27 00 00。strcpy 复制到第 3 个字节 00 就停了,只写了 10 27 00 三个字节,第 4 个字节仍是初始化时的 '#',十六进制是 23。strlen 也只数出 2。

如果换成 0x01020304 这种 4 个字节都不为 0 的数,strlen 会继续往后读到数组外,属于未定义行为。这段输出还依赖 int 占 4 字节和小端字节序;大端机器上第一个字节就是 00,strlen 会返回 0。

内存函数换了一套接口:参数是 void *,长度是字节数,函数内部按字节处理,不关心数据是什么,也不看 '\0'。

函数作用长度参数
memcpy复制一块内存字节数
memmove复制一块内存,允许重叠字节数
memset按字节填充一块内存字节数
memcmp按字节比较两块内存字节数
memchr在内存块里查找一个字节字节数

长度一律写 n * sizeof(元素类型) 或 sizeof 数组,不要写元素个数。sizeof 的结果天然是字节数,和这些函数单位一致。

2. memcpy:按字节复制

void *memcpy(void *destination, const void *source, size_t num);

从 source 复制 num 个字节到 destination,返回目标起始地址。要求两块内存不重叠。

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

int main(void)
{
    int src[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    int dst[10] = {0};

    memcpy(dst, src, 5 * sizeof(int));
    for (int i = 0; i < 10; i++)
        printf("%d ", dst[i]);
    printf("\n");

    return 0;
}

输出:

1 2 3 4 5 0 0 0 0 0

只复制了 5 个 int,共 20 字节,后 5 个元素保持原值。写成 memcpy(dst, src, 20) 在本机也能跑,但数字 20 依赖 int 占 4 字节,换类型就错了。

自己实现时,void * 不能直接解引用,也不能做指针算术,要先转成 char * 或 unsigned char *,每次前进 1 个字节:

#include <assert.h>
#include <stddef.h>
#include <stdio.h>

void *my_memcpy(void *dst, const void *src, size_t count)
{
    void *ret = dst;
    unsigned char *d = (unsigned char *)dst;
    const unsigned char *s = (const unsigned char *)src;

    assert(dst != NULL);
    assert(src != NULL);
    while (count-- > 0)
        *d++ = *s++;

    return ret;
}

int main(void)
{
    int src[5] = {1, 2, 3, 4, 5};
    int dst[5] = {0};

    my_memcpy(dst, src, sizeof src);
    for (int i = 0; i < 5; i++)
        printf("%d ", dst[i]);
    printf("\n");

    return 0;
}

输出:

1 2 3 4 5

先把 dst 存进 ret,因为循环里 dst 会一路后移,最后要靠 ret 原样返回起始地址。while (count-- > 0) 在 count 为 0 时一次都不执行,不会多复制一个字节。

memcpy 不允许源和目标重叠。原因看下面这段:

#include <assert.h>
#include <stddef.h>
#include <stdio.h>

void *my_memcpy(void *dst, const void *src, size_t count)
{
    void *ret = dst;
    unsigned char *d = (unsigned char *)dst;
    const unsigned char *s = (const unsigned char *)src;

    while (count-- > 0)
        *d++ = *s++;

    return ret;
}

int main(void)
{
    int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

    my_memcpy(arr + 2, arr, 5 * sizeof(int));
    for (int i = 0; i < 10; i++)
        printf("%d ", arr[i]);
    printf("\n");

    return 0;
}

输出:

1 2 1 2 1 2 1 8 9 10

正确结果应该是 1 2 1 2 3 4 5 8 9 10。目标从下标 2 开始,写指针一开始就落在源数据后面;从前往后复制时,写指针追上了还没读走的源字节,把 3、4、5 提前覆盖成了 1、2。标准规定 memcpy 遇到重叠时结果未定义,不同实现可能给出不同结果。

1

3. memmove:重叠时换方向

void *memmove(void *destination, const void *source, size_t num);

功能和 memcpy 一样,区别是允许源和目标重叠。做法是先判断相对位置,再决定复制方向:

  • 目标在源之前(dst < src):从前往后复制,写指针始终落后于读指针。
  • 目标在源之后(dst > src):从后往前复制,写指针始终领先于读指针。
  • 两块完全不重叠:两个方向都可以。
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>

void *my_memmove(void *dst, const void *src, size_t count)
{
    void *ret = dst;
    unsigned char *d = (unsigned char *)dst;
    const unsigned char *s = (const unsigned char *)src;

    if ((uintptr_t)d < (uintptr_t)s)
    {
        while (count-- > 0)
            *d++ = *s++;
    }
    else if ((uintptr_t)d > (uintptr_t)s)
    {
        d += count;
        s += count;
        while (count-- > 0)
            *--d = *--s;
    }

    return ret;
}

int main(void)
{
    int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

    my_memmove(arr + 2, arr, 5 * sizeof(int));
    for (int i = 0; i < 10; i++)
        printf("%d ", arr[i]);
    printf("\n");

    return 0;
}

输出:

1 2 1 2 3 4 5 8 9 10

目标在源之后,所以从后往前复制:先搬 5 到下标 6,再搬 4 到下标 5,直到 1 落到下标 2。每一步写的都是已经读走的位置,不会破坏还没读的源数据。

反过来,目标在源之前时从前往后复制:

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

void *my_memmove(void *dst, const void *src, size_t count)
{
    void *ret = dst;
    unsigned char *d = (unsigned char *)dst;
    const unsigned char *s = (const unsigned char *)src;

    if ((uintptr_t)d < (uintptr_t)s)
    {
        while (count-- > 0)
            *d++ = *s++;
    }
    else if ((uintptr_t)d > (uintptr_t)s)
    {
        d += count;
        s += count;
        while (count-- > 0)
            *--d = *--s;
    }

    return ret;
}

int main(void)
{
    int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

    my_memmove(arr, arr + 2, 5 * sizeof(int));
    for (int i = 0; i < 10; i++)
        printf("%d ", arr[i]);
    printf("\n");

    return 0;
}

输出:

3 4 5 6 7 6 7 8 9 10

前 5 个 int 被下标 2 到 6 的 3 4 5 6 7 覆盖,后面的元素保持原样。

严格按标准,指向不同对象的指针做 <、> 比较是未定义行为。上面的实现先把地址转成 uintptr_t 再比较,避开这个边界;工程里直接用标准库的 memmove 即可。

memcpy 和 memmove 怎么选:

情况用哪个原因
能确定两块内存不重叠memcpy契约更简单,编译器可以更大胆地优化
存在重叠可能memmove先判方向,结果一定正确
拿不准memmove多一次分支判断,换正确性

两者的时间复杂度都是 O ( n ) O(n) O(n),额外空间 O ( 1 ) O(1) O(1)。

4. memset:按字节填充

void *memset(void *ptr, int value, size_t num);

把 ptr 开始的 num 个字节都设成 value。value 先被转换成 unsigned char,也就是只取低 8 位,再逐字节写入。

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

int main(void)
{
    char str[] = "hello world";

    memset(str, 'x', 6);
    printf("%s\n", str);
    return 0;
}

输出:

xxxxxxworld

前 6 个字节被改成 'x',第 7 个字节原本是空格,保留下来。

对字符数组按字节填充没有问题,对 int 数组就要小心:

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

int main(void)
{
    int arr[4] = {0};

    memset(arr, 1, sizeof arr);

    printf("%d\n", arr[0]);
    printf("0x%08X\n", (unsigned)arr[0]);
    return 0;
}

输出:

16843009
0x01010101

memset 把每个字节都写成 0x01,4 个字节拼起来就是 0x01010101,十进制 16843009,不是 1。想给 int 数组赋 1,只能用循环逐个赋值。

1

安全的用法只有两类:

  • memset(arr, 0, sizeof arr):全 0 位模式在主流实现下就是数值 0。
  • memset(arr, -1, sizeof arr):每个字节都是 0xFF,在补码表示下每个 int 是 -1。这个结果依赖补码,C 标准没有强制。

value 超出 unsigned char 范围时按模 256 截断,例如 memset(buf, 263, 4) 每个字节都是 7。这类写法可读性差,不如直接写 7。

memset 常用来清零结构体或数组,时间复杂度 O ( n ) O(n) O(n)。

5. memcmp 与 memchr

int memcmp(const void *ptr1, const void *ptr2, size_t num);

从两个地址开始比较 num 个字节,按 unsigned char 逐字节比,遇到第一对不同字节就返回。返回值只保证符号:大于 0、等于 0、小于 0。

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

int main(void)
{
    char a[] = "DWgaOtP12df0";
    char b[] = "DWGAOTP12DF0";
    int r = memcmp(a, b, sizeof(a));

    if (r > 0)
        printf("%s 大于 %s\n", a, b);
    else if (r < 0)
        printf("%s 小于 %s\n", a, b);
    else
        printf("%s 等于 %s\n", a, b);

    return 0;
}

输出:

DWgaOtP12df0 大于 DWGAOTP12DF0

第 3 个字节分出大小:'g' 是 0x67,'G' 是 0x47,后面不再比较。具体返回值可能是 1,也可能是两个字节的差 32,调用方只能看符号。

memcmp 和 strcmp 的区别:

对比项memcmpstrcmp
停止条件比满 num 个字节遇到 '\0'
能否处理含零字节的数据能不能
比较长度由调用方指定由字符串结尾决定
返回值只看符号只看符号

比较整数数组时,memcmp 看到的是内存里的字节序。小端机器上先碰到低位字节,返回值反映字节序列的大小,不一定等于整数数值的大小。结构体也不要整体 memcmp:成员之间的填充字节内容不确定,两个成员完全相同的结构体可能比出不相等。

memchr 在内存块里找第一个等于指定值的字节:

void *memchr(const void *ptr, int ch, size_t num);
#include <stdio.h>
#include <string.h>

int main(void)
{
    const char text[] = "hello world";
    const char *p = memchr(text, 'o', sizeof text);

    if (p != NULL)
        printf("下标 %td\n", p - text);
    else
        printf("没找到\n");

    return 0;
}

输出:

下标 4

找到返回指向该字节的指针,找不到返回 NULL。查找范围由 num 决定,不受 '\0' 限制,所以也能在二进制数据里找。

五个函数的开销:

函数时间额外空间
memcpy O ( n ) O(n) O(n) O ( 1 ) O(1) O(1)
memmove O ( n ) O(n) O(n) O ( 1 ) O(1) O(1)
memset O ( n ) O(n) O(n) O ( 1 ) O(1) O(1)
memcmp O ( n ) O(n) O(n) O ( 1 ) O(1) O(1)
memchr O ( n ) O(n) O(n) O ( 1 ) O(1) O(1)

n 是字节数。memcmp 和 memchr 找到第一处不同或目标字节就停,最好情况小于 n,最坏仍是 O ( n ) O(n) O(n)。

6. 实践:动态数组的删除与插入

顺序表删除一个元素,要把后面的元素整体前移;插入一个元素,要把插入点之后的元素整体后移。两块区间重叠,正好用 memmove。

下面这个动态数组保存 int,支持尾部追加、按下标删除和按下标插入。容量不够时 realloc 翻倍。

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

typedef struct
{
    int *data;
    size_t size;
    size_t cap;
} IntVec;

static void vec_init(IntVec *v, size_t cap)
{
    v->data = (int *)malloc(cap * sizeof(int));
    if (v->data == NULL)
    {
        perror("malloc");
        exit(1);
    }
    v->size = 0;
    v->cap = cap;
}

static void vec_free(IntVec *v)
{
    free(v->data);
    v->data = NULL;
    v->size = 0;
    v->cap = 0;
}

static void vec_push(IntVec *v, int x)
{
    if (v->size == v->cap)
    {
        size_t new_cap = v->cap * 2;
        int *p = (int *)realloc(v->data, new_cap * sizeof(int));
        if (p == NULL)
        {
            perror("realloc");
            free(v->data);
            exit(1);
        }
        v->data = p;
        v->cap = new_cap;
    }
    v->data[v->size++] = x;
}

static int vec_remove(IntVec *v, size_t idx)
{
    assert(v != NULL);
    assert(idx < v->size);

    int old = v->data[idx];
    if (idx + 1 < v->size)
        memmove(v->data + idx, v->data + idx + 1,
                (v->size - idx - 1) * sizeof(int));
    v->size--;
    return old;
}

static void vec_insert(IntVec *v, size_t idx, int x)
{
    assert(v != NULL);
    assert(idx <= v->size);

    vec_push(v, 0);   // 先扩容,并占出一个位置
    if (idx < v->size - 1)
        memmove(v->data + idx + 1, v->data + idx,
                (v->size - 1 - idx) * sizeof(int));
    v->data[idx] = x;
}

static void vec_print(const IntVec *v)
{
    printf("[");
    for (size_t i = 0; i < v->size; i++)
        printf("%s%d", i ? ", " : "", v->data[i]);
    printf("]\n");
}

int main(void)
{
    IntVec v;

    vec_init(&v, 4);
    for (int i = 1; i <= 5; i++)
        vec_push(&v, i);
    printf("初始:");
    vec_print(&v);

    int old = vec_remove(&v, 1);
    printf("删除下标 1(值 %d):", old);
    vec_print(&v);

    vec_insert(&v, 1, 9);
    printf("在下标 1 插入 9:");
    vec_print(&v);
    printf("size = %zu, cap = %zu\n", v.size, v.cap);

    vec_free(&v);
    return 0;
}

输出:

初始:[1, 2, 3, 4, 5]
删除下标 1(值 2):[1, 3, 4, 5]
在下标 1 插入 9:[1, 9, 3, 4, 5]
size = 5, cap = 8

删除时,memmove 把下标 idx + 1 到末尾的 size - idx - 1 个元素搬到 idx,源区间在目标区间之后,函数自动选择从前往后复制。插入时先把新元素追加到末尾腾出容量,再把 idx 到原末尾的元素后移一位,源区间在目标区间之前,函数自动选择从后往前复制。

两个接口的复杂度:

操作时间说明
vec_push均摊 O ( 1 ) O(1) O(1)扩容时复制全部元素,摊到每次追加
vec_remove O ( n ) O(n) O(n)后面的元素整体前移
vec_insert O ( n ) O(n) O(n)插入点之后的元素整体后移

memmove 本身是 O ( n ) O(n) O(n),这里没有额外空间。插入和删除慢,是顺序存储的固有代价;需要频繁在中间增删时,应换链表。

7. 小结

  • 字符串函数遇到 '\0' 就停,二进制数据不能用;内存函数按字节处理,长度参数一律是字节数。
  • memcpy 要求源和目标不重叠,重叠时结果未定义;重叠复制必须用 memmove。
  • memmove 先看相对位置:目标在源之前从前往后复制,目标在源之后从后往前复制。
  • memset 按字节填充,给 int 设 1 会得到 0x01010101;只有全 0 和补码下的全 0xFF 可以放心用。
  • memcmp 比固定字节数,不受 '\0' 影响,返回值和 strcmp 一样只看符号;结构体的填充字节会让整体比较不可靠。
  • memchr 在指定字节范围内查找,返回首次出现的地址或 NULL。
  • 五个函数的复杂度都是 O ( n ) O(n) O(n);memcpy 和 memmove 额外空间 O ( 1 ) O(1) O(1)。
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐