内存函数与数据存储

内存函数与数据存储 ------ 从 memcpy 翻车到整型提升,一次讲透

1. 一句话总结

内存操作函数(memcpy / memmove / memset)的本质是按字节搬运,理解这一点就能解释为什么 memset 给 int 数组赋值会翻车,以及为什么 memmove 要用"前前后后"的策略处理重叠内存;而数据存储部分的核心是:一切数据在内存中都是二进制,解读方式(有无符号、大小端、整型提升)决定了最终的输出结果。


2. 知识地图

复制代码
内存函数与数据存储
├── 内存操作函数(<string.h>)
│   ├── memcpy  ------ 内存拷贝(不处理重叠)
│   │     └── 实现:逐字节拷贝,src → dest 从前向后
│   ├── memmove ------ 内存拷贝(处理重叠)
│   │     └── 实现:"前前后后"策略,根据 dest 和 src 的相对位置
│   │           选择拷贝方向
│   ├── memset  ------ 内存初始化(按字节设置)
│   │     └── 陷阱:给 int 数组赋非 0 值会出错(原因见下文)
│   └── memcmp  ------ 内存比较(逐字节比较)
│
└── 数据存储规则
    ├── 大小端字节序
    │     ├── 小端:低位存低地址(x86 架构,常见)
    │     └── 大端:高位存低地址(网络字节序)
    ├── signed / unsigned char 的区别
    │     └── char 是否有符号取决于编译器(VS 上是有符号的)
    └── 整型提升(Integer Promotion)
          ├── 触发条件:表达式计算中小于 int 的类型
          ├── 有符号:高位补符号位
          └── 无符号:高位补 0

关系梳理:

  • memcpy 和 memmove 是"兄弟函数",功能一样但 memmove 更安全(能处理重叠)。
  • memset 之所以给 int 赋非 0 值出错,根本原因在于它按字节操作,这和"数据存储"中整型多字节表示直接相关。
  • 理解整型提升必须先理解 signed/unsigned char 的存储差异,而要理解存储差异又必须先知道补码规则。
  • 大小端字节序是理解"数据在内存中长什么样"的基础,和 memcpy 的逐字节拷贝有直接联系------拷贝时你能看到字节的排列顺序。

3. 核心概念深度解析

3.1 memmove 的"前前后后"策略 ------ 内存重叠的优雅解法

问题场景: 在同一块内存中做拷贝,比如把 arr[0..4] 拷贝到 arr[2..6],源和目标有重叠区域。

复制代码
内存布局(假设 arr 从地址 0x100 开始,每个 int 占 4 字节):

arr[0]  arr[1]  arr[2]  arr[3]  arr[4]  arr[5]  arr[6]
 1       2       3       4       5       6       7
 |_______________|
       src (20字节)

                 |_______________|
                       dest (从 arr[2] 开始)

          重叠区域 ↓
         arr[2] arr[3] arr[4]

错误做法(memcpy 式从前向后拷贝):

复制代码
Step 1: dest[0] = src[0]  →  arr[2] = 1   结果: [1, 2, 1, 4, 5, 6, 7]
Step 2: dest[1] = src[1]  →  arr[3] = 2   结果: [1, 2, 1, 2, 5, 6, 7]
Step 3: dest[2] = src[2]  →  arr[4] = ?   此时 src[2] 已经被 Step 1 覆盖成 1 了!
                                           本该拷贝 3,结果拷贝了 1

memmove 的策略 ------ 判断方向:

复制代码
情况1:dest < src(目标在源前面)
  ┌──── src ────┐
  ┌──── dest ────┐
  从前往后拷贝,安全 ✓

情况2:dest > src(目标在源后面,本次案例)
  ┌──── src ────┐
        ┌──── dest ────┐
  从后往前拷贝,安全 ✓

ASCII 图解"从后往前"为什么安全:

复制代码
原始数组: [1, 2, 3, 4, 5, 6, 7, 8]
                ↑src[0..4]  要把 1,2,3,4,5 拷到 arr[2..6]

从后往前拷贝(拷贝 5 个 int = 20 字节):

Step 1: dest[4] = src[4]  →  arr[6] = 5   [1, 2, 3, 4, 5, 6, 5, 8]
Step 2: dest[3] = src[3]  →  arr[5] = 4   [1, 2, 3, 4, 5, 4, 5, 8]
Step 3: dest[2] = src[2]  →  arr[4] = 3   [1, 2, 3, 4, 3, 4, 5, 8]
Step 4: dest[1] = src[1]  →  arr[3] = 2   [1, 2, 3, 2, 3, 4, 5, 8]
Step 5: dest[0] = src[0]  →  arr[2] = 1   [1, 2, 1, 2, 3, 4, 5, 8]

最终结果: [1, 2, 1, 2, 3, 4, 5, 8]  ✓ 正确!

关键:先拷贝后面的元素,这些元素还没被覆盖,所以源数据始终完好。

核心代码逻辑:

c 复制代码
if ((char *)dest < (char *)src) {
    // dest 在 src 前面 → 从前往后
    while (num--) {
        *(char *)dest = *(char *)src;
        dest = (char *)dest + 1;
        src  = (char *)src  + 1;
    }
} else {
    // dest 在 src 后面 → 从后往前
    while (num--) {
        *((char *)dest + num) = *((char *)src + num);
    }
}

我的理解:*((char *)dest + num) 这种写法从后往前拷贝,利用了 num-- 后置递减的特性,每次循环 num 已经减 1,所以下标从 num-1 慢慢降到 0,天然实现倒序遍历。


3.2 大小端字节序 ------ 同一个数字在内存中的两种"长相"

一句话定义: 大小端描述的是多字节数据在内存中的字节排列顺序

复制代码
假设 int n = 0x12345678(十六进制),共 4 个字节:

内存地址:  低地址 ←──────────────→ 高地址
          0x100    0x101    0x102    0x103

大端模式(Big Endian)------ 高位存低地址:
          0x12     0x34     0x56     0x78
          高位字节 ──────────────→ 低位字节
          "人的阅读顺序" = 内存存储顺序

小端模式(Little Endian)------ 低位存低地址:
          0x78     0x56     0x34     0x12
          低位字节 ──────────────→ 高位字节
          "人的阅读顺序" 与 内存存储顺序 相反

为什么关注这个?

  • 网络传输统一用大端(网络字节序),x86 机器用小端,所以 htonl / ntohl 这类转换函数很重要。
  • memcpy 逐字节拷贝时,你能"看到"字节序。
  • 理解指针强转:*(char*)&n 取的是低地址那个字节,在小端机器上就是最低位字节。

判断大小端的两种方法:

c 复制代码
// 方法1:指针强转法
int check_sys() {
    int n = 1;                // 0x00000001
    return *(char*)&n;        // 取低地址那个字节
    // 小端:低地址存 0x01 → 返回 1
    // 大端:低地址存 0x00 → 返回 0
}

// 方法2:联合体法(更优雅)
int check_sys() {
    union {
        int i;
        char x;
    } s;
    s.i = 1;       // 0x00000001
    return s.x;    // i 和 x 共享低地址起始位置
    // 小端:x 读到 0x01 → 返回 1
    // 大端:x 读到 0x00 → 返回 0
}

我的理解:联合体法和指针强转法本质一样,都是"从同一个起始地址只读第一个字节"。联合体的写法更安全,因为它不涉及指针类型转换,语义更清晰------"我用两种视角看同一块内存"。


3.3 整型提升 ------ 小于 int 的类型参与运算时的"隐形转换"

触发条件:char / short 等小于 int 的类型参与表达式计算(如 printf 的参数传递、算术运算)时,编译器会先自动把它们提升为 int

提升规则:

复制代码
有符号类型(signed char / signed short):
  高位补符号位(符号扩展)
  例:char a = -1;
      内存中(截断后):11111111
      符号位 = 1
      提升后:11111111 11111111 11111111 11111111  ← 高位全补 1

无符号类型(unsigned char / unsigned short):
  高位补 0(零扩展)
  例:unsigned char c = -1;
      内存中(截断后):11111111
      提升后:00000000 00000000 00000000 11111111  ← 高位全补 0

完整追踪 char a = -1 的存储和打印过程:

复制代码
第1步:写出原码
  -1 的原码:10000000 00000000 00000000 00000001

第2步:转补码(计算机用补码存储)
  反码:     11111111 11111111 11111111 11111110
  补码:     11111111 11111111 11111111 11111111

第3步:截断(char 只有 8 位)
  内存中:   11111111

第4步:整型提升(打印时,%d 要求 int,触发提升)
  有符号 char,符号位 = 1
  提升后:   11111111 11111111 11111111 11111111  ← 符号扩展

第5步:转回原码打印(%d 打印有符号十进制)
  补码 → 原码:10000000 00000000 00000000 00000001
  结果:-1

对比 unsigned char c = -1:

复制代码
第1~3步同上,截断后:11111111

第4步:整型提升
  无符号 char,高位补 0
  提升后:   00000000 00000000 00000000 11111111  ← 零扩展

第5步:打印(unsigned 类型原反补相同)
  原码就是:00000000 00000000 00000000 11111111
  结果:255

ASCII 图解整个过程:

复制代码
  char a = -1
  ┌─────────────────────────────────────────────────────┐
  │ 原码  10000000 00000000 00000000 00000001           │
  │ 补码  11111111 11111111 11111111 11111111           │
  │                         │                           │
  │                   截断(取低8位)                      │
  │                         ↓                           │
  │ 内存   [11111111] ← char 型变量 a 占 1 字节           │
  │                         │                           │
  │              整型提升(%d 打印,需要 int)              │
  │              有符号 → 高位补符号位 1                    │
  │                         ↓                           │
  │ 提升   11111111 11111111 11111111 11111111(补码)    │
  │                         │                           │
  │                  转原码供 printf 输出                  │
  │                         ↓                           │
  │ 输出  -1                                            │
  └─────────────────────────────────────────────────────┘

  unsigned char c = -1
  ┌─────────────────────────────────────────────────────┐
  │ 内存   [11111111] ← 同样存 11111111                  │
  │                         │                           │
  │              整型提升(无符号 → 高位补 0)              │
  │                         ↓                           │
  │ 提升   00000000 00000000 00000000 11111111(原码=补码)│
  │                         │                           │
  │ 输出   255                                          │
  └─────────────────────────────────────────────────────┘

关键认知:同样的二进制 11111111,解释成 signed char-1,解释成 unsigned char255数据本身没有意义,解读方式赋予它意义。


4. 代码实战

4.1 my_memmove ------ 完整实现

c 复制代码
#include <stdio.h>
#include <assert.h>

/**
 * 模拟实现 memmove ------ 安全的内存拷贝(处理重叠)
 * @param dest  目标内存起始地址
 * @param src   源内存起始地址
 * @param num   要拷贝的字节数
 * @return      目标内存起始地址
 */
void *my_memmove(void *dest, const void *src, size_t num)
{
    void *ret = dest;
    assert(dest && src);

    if ((char *)dest < (char *)src)
    {
        // 情况1:dest 在 src 前面 → 从前往后拷贝
        while (num--)
        {
            *(char *)dest = *(char *)src;
            src  = (char *)src  + 1;
            dest = (char *)dest + 1;
        }
    }
    else
    {
        // 情况2:dest 在 src 后面(或相等)→ 从后往前拷贝
        while (num--)
        {
            *((char *)dest + num) = *((char *)src + num);
        }
    }
    return ret;
}

int main()
{
    int arr[] = {1, 2, 3, 4, 5, 6, 7, 8};
    int sz = sizeof(arr) / sizeof(arr[0]);

    my_memmove(arr + 2, arr, 20);  // 把前5个元素拷贝到从第3个开始的位置

    for (int i = 0; i < sz; i++)
    {
        printf("%d ", arr[i]);
    }
    // 输出:1 2 1 2 3 4 5 8
    return 0;
}

4.2 my_memcpy ------ 基础版本(不处理重叠)

c 复制代码
/**
 * 模拟实现 memcpy ------ 基础内存拷贝(不处理重叠)
 * 注意:当 src 和 dest 指向同一块内存且有重叠时,结果不可预期
 */
void *my_memcpy(void *dest, const void *src, size_t num)
{
    void *ret = dest;
    assert(dest && src);

    while (num--)
    {
        *(char *)dest = *(char *)src;   // 逐字节拷贝
        dest = (char *)dest + 1;        // 泛型指针不能直接 ++,
        src  = (char *)src  + 1;        // 必须强转为 char* 再前进
    }
    return ret;
}

4.3 memset 的正确打开方式 vs 错误用法

c 复制代码
#include <stdio.h>
#include <string.h>

int main()
{
    // ✅ 正确用法:操作 char 数组(或任何单字节类型)
    char arr[] = "hello world";
    memset(arr + 2, 'y', 7);
    printf("%s\n", arr);  // 输出:hey yyyyyy
    // 从 arr[2] 开始,连续 7 个字节都设为 'y'

    // ❌ 错误用法:给 int 数组赋非 0 值
    int arr2[5] = {1, 2, 3, 4, 5};
    memset(arr2, 2, 20);  // 20 字节 = 5 个 int
    for (int i = 0; i < 5; i++)
    {
        printf("%d ", arr2[i]);
    }
    // 输出:33686018 33686018 33686018 33686018 33686018
    // 而不是期望的 2 2 2 2 2!

    return 0;
}

为什么 int 数组赋值会出错? 看字节级分析:

复制代码
memset(arr2, 2, 20) 做的事情:
  把 arr2 的前 20 个字节,每个字节都设为 0x02

内存布局(小端,每个 int 4 字节):
  arr2[0]: 0x02 0x02 0x02 0x02  → 解释为 int = 0x02020202 = 33686018
  arr2[1]: 0x02 0x02 0x02 0x02  → 同上
  ...

想要的是每个 int = 2,即内存中应该是:
  arr2[0]: 0x02 0x00 0x00 0x00  ← 这才表示 int 值 2

例外:memset(arr, 0, sizeof(arr)) 将 int 数组清零是安全的,因为 0x00000000 每个字节都是 0。

4.4 判断大小端

c 复制代码
#include <stdio.h>

// 方法1:指针强转法
int check_sys_v1()
{
    int n = 1;
    return *(char *)&n;  // 取低地址字节
}

// 方法2:联合体法
int check_sys_v2()
{
    union {
        int i;
        char x;
    } s;
    s.i = 1;
    return s.x;
}

int main()
{
    int ret = check_sys_v1();
    if (ret == 1)
        printf("小端\n");
    else
        printf("大端\n");

    return 0;
}

4.5 signed / unsigned char 整型提升对比

c 复制代码
#include <stdio.h>

int main()
{
    char a = -1;           // 取决于编译器,VS 上等价于 signed char
    signed char b = -1;    // 明确有符号
    unsigned char c = -1;  // 明确无符号

    printf("%d %d %d\n", a, b, c);
    // 输出:-1 -1 255

    return 0;
}

4.6 char 类型范围与循环陷阱

c 复制代码
#include <stdio.h>
#include <string.h>

int main()
{
    char a[1000];
    int i;

    for (i = 0; i < 1000; i++)
    {
        a[i] = -1 - i;
    }
    // i=0:  -1
    // i=1:  -2
    // ...
    // i=127: -128
    // i=128: 127   ← char 范围是 -128~127,-129 溢出变成 127!
    // i=129: 126
    // ...
    // i=254: 1
    // i=255: 0     ← 这里 a[255] = 0,即 '\0'
    // i=256: -1
    // ... 循环往复

    printf("%zd\n", strlen(a));  // 输出:255
    // strlen 遇到 '\0'(ASCII 码 0)就停止计数

    return 0;
}

char 值的"轮回"图:

复制代码
-1 → -2 → -3 → ... → -128 → 127 → 126 → ... → 1 → 0 → -1 → ...
                         ↑ 溢出翻转点

踩坑:signed char 的范围是 -128 到 127,总共 256 个值。当算术结果超出这个范围时,会发生回绕(wrap-around),而不是报错。这一点在写循环边界条件时尤其要小心。


5. 注释提炼

5.1 知识注解类 ------ 扩展科普

memcpy 为什么不处理内存重叠?

C 标准明确规定:如果 srcdest 指向的内存区域有重叠,memcpy 的行为是未定义的 (undefined behavior)。这不是 bug,而是设计取舍------memcpy 假定两块内存是独立的,从而可以用更激进的方式优化(比如 SIMD 指令批量拷贝)。如果你不确定是否重叠,请用 memmove

为什么 memcpy / memmove 的参数是 void *

泛型指针 void * 是 C 语言实现"泛型"的经典手段。它表示"指向任意类型的指针",可以接收任何指针类型而不需要强转。但代价是 void * 不能解引用,也不能做指针算术运算,所以函数内部必须强转为 char * 来逐字节操作。

为什么 memset 按字节设置?

这是 C 标准库的设计哲学:内存操作函数统一以字节 为最小单位,保证通用性。memset 不在乎你传的是 int 数组还是 char 数组还是结构体,它只认字节。这种设计简单通用,但需要使用者自己理解多字节类型的存储方式。

5.2 个人理解类

我的理解:memcpymemmove 的关系,可以类比 strcpystrncpy。前者是"快但不安全"的版本,后者是"安全但稍慢"的版本。工程中,拿不准就用 memmove,性能差异微乎其微。
我的理解:void * 指针就像快递员------他只负责把包裹从 A 点送到 B 点,不关心包裹里装的是什么。char * 的强转则是把包裹拆成"每 1 字节一个小件",这样快递员才知道每次搬多少。
我的理解:整型提升被很多人忽视,但它其实是 C 语言隐式转换体系的基石。理解它之后,char c = 128; printf("%d", c); 输出 -128 这种"反直觉"结果就变得可解释了。

5.3 踩坑感悟类

⚠️ 踩坑 1:memset 给 int 数组赋非 0 值

c 复制代码
int arr[5];
memset(arr, 1, sizeof(arr));  // 以为每个元素是 1,实际是 0x01010101 = 16843009

教训: memset 只能安全地用于:

  • 清零:memset(arr, 0, sizeof(arr))(无论什么类型)
  • 赋值为 0xFF(即 -1):memset(arr, 0xFF, sizeof(arr))(每个字节都是 0xFF,int 解释为 -1)
  • 操作 char 类型数组

⚠️ 踩坑 2:memcpy 在重叠内存上翻车

c 复制代码
int arr[] = {1, 2, 3, 4, 5};
memcpy(arr + 2, arr, 12);  // 未定义行为!实际结果不可预测

教训: 只要源和目标在同一块内存中且 dst > src,就用 memmove。宁可多打几个字,也不要赌编译器行为。

⚠️ 踩坑 3:char 是有符号还是无符号取决于编译器

c 复制代码
char a = 0xFF;  // 在 VS 上 a = -1,在 ARM 编译器上可能是 255

教训: 需要明确符号时,永远用 signed charunsigned char,不要裸写 char。跨平台代码中这一点尤其重要。

5.4 对比总结类

函数 用途 处理重叠 操作单位 返回值
memcpy 内存拷贝 不支持 字节 dest 起始地址
memmove 内存拷贝 支持 字节 dest 起始地址
memset 内存初始化 不适用 字节 dest 起始地址
memcmp 内存比较 不适用 字节 差值(<0, 0, >0
类型 存储 -1 后的值 整型提升规则 提升后(32位) printf("%d")
signed char 11111111 高位补符号位 0xFFFFFFFF -1
unsigned char 11111111 高位补 0 0x000000FF 255
字节序 低地址存什么 0x12345678 在内存中 常见场景
小端 低位字节 78 56 34 12 x86/x64 PC
大端 高位字节 12 34 56 78 网络传输、部分嵌入式

6. 代码修正

修正 1:check_sys 判断逻辑错误

修改前:

c 复制代码
int ret = check_sys(n);
if (ret == 1)
    printf("小端\n");
else
    printf("小端\n");  // ← BUG:两个分支都打印"小端"

修改后:

c 复制代码
int ret = check_sys_v1();
if (ret == 1)
    printf("小端\n");
else
    printf("大端\n");  // ← 修正:第二个分支打印"大端"

原因: 显然是复制粘贴导致的笔误,但这类错误在测试时很容易被忽略------因为 x86 机器上永远走 if 分支,else 分支根本不会被执行到。写这种判断逻辑时,建议两个分支的输出字符串差异要足够明显,便于一眼发现问题。

修正 2:被注释的 check_sys() 与调用处参数不匹配

原代码:

c 复制代码
// 定义了一个无参的 check_sys()(联合体版本,被注释掉了)
// int check_sys()
// {
//     union S { ... } s;
//     ...
// }

// 但又定义了一个有参的 check_sys(int n)(指针版本)
// int check_sys(int n)
// {
//     return *(char*)&n;
// }

// main 中调用:
int n = 1;
int ret = check_sys(n);  // 调用的是有参版本

说明: 联合体版本不需要参数(内部直接 s.i = 1),指针版本需要参数。原代码两个版本混在一起,应该统一使用一个版本。推荐使用联合体版本,因为它不依赖外部传参,更加内聚。


7. 小结

内存函数的本质是"按字节搬运",数据存储的本质是"同一段二进制可以有不同的解读方式"------把这两条线串起来,memcpy、memmove、memset、大小端、整型提升就都不再是孤立的零散知识点,而是一套关于"数据如何在计算机中表示和流动"的完整认知体系。

相关推荐
AAA代码批发商2 小时前
Days37 Linux C 网络编程:UDP、TCP 与 HTTP 协议实战详解
linux·c语言·网络
是隼人2 小时前
buuctf-pwn ciscn_2019_n_5题解(学习过程持续 更新)
c语言·学习·安全·pwn入门·ctf入门
水饺编程5 小时前
第5章,[Win32 章节] :练习程序,BigFontPic
c语言·c++·windows·visual studio
dtq04246 小时前
数据结构 - 线性表 - 双向链表
c语言·数据结构·学习
不正经学生10 小时前
C语言预处理详解:编译器真正动手之前的那些事
c语言·开发语言·算法·面试·bug
门思科技10 小时前
LoRaWAN 设备类别:Class A、B、C 对比与选型指南
c语言·开发语言·php
xiaobobo333014 小时前
c语言中for循环条件中定义临时栈变量的作用域
c语言·for循环条件定义栈变量·大括号作用域·独立栈变量
惜离殇14 小时前
从零开始的敲代码生活--Linux应用软件(文件操作基础1)
linux·c语言·文件操作·标准io
潘潘的嵌入式日记16 小时前
I²C 从机接收总被覆盖?双缓冲要在 STOP 时交接
c语言·开发语言·单片机