内存函数与数据存储 ------ 从 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 char是255。数据本身没有意义,解读方式赋予它意义。
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 标准明确规定:如果 src 和 dest 指向的内存区域有重叠,memcpy 的行为是未定义的 (undefined behavior)。这不是 bug,而是设计取舍------memcpy 假定两块内存是独立的,从而可以用更激进的方式优化(比如 SIMD 指令批量拷贝)。如果你不确定是否重叠,请用 memmove。
为什么 memcpy / memmove 的参数是 void *?
泛型指针 void * 是 C 语言实现"泛型"的经典手段。它表示"指向任意类型的指针",可以接收任何指针类型而不需要强转。但代价是 void * 不能解引用,也不能做指针算术运算,所以函数内部必须强转为 char * 来逐字节操作。
为什么 memset 按字节设置?
这是 C 标准库的设计哲学:内存操作函数统一以字节 为最小单位,保证通用性。memset 不在乎你传的是 int 数组还是 char 数组还是结构体,它只认字节。这种设计简单通用,但需要使用者自己理解多字节类型的存储方式。
5.2 个人理解类
我的理解:
memcpy和memmove的关系,可以类比strcpy和strncpy。前者是"快但不安全"的版本,后者是"安全但稍慢"的版本。工程中,拿不准就用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 char 或 unsigned 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、大小端、整型提升就都不再是孤立的零散知识点,而是一套关于"数据如何在计算机中表示和流动"的完整认知体系。