Appearance
内存函数与数据存储
预计学习时长:约 3 小时(仅 L1 主线,建议分两次完成)· 运行环境:VS2026 / gcc / clang 均可
🎯 学习目标
学完本讲,你将能够:
- 区分
memcpy与memmove,说清"内存重叠为什么必须用 memmove" - 用
memset/memcmp按字节操作内存,并解释memset(arr, 1, ...)的坑 - 模拟实现四个内存函数
- 说清整数在内存中存的是补码,以及为什么用补码
- 解释大小端字节序,独立写出判断本机字节序的程序
- 用 IEEE 754 规则解释"同一个内存,按 int 和按 float 读出来天差地别"
- 拿下大小端 / 补码 / 无符号循环这一整组经典笔试题
🔗 先修知识
- 第 3 讲:数据类型和变量:
char取值范围、有符号无符号 - 第 4 讲:运算符详解:原码/反码/补码、位运算
- 第 10 讲:字符串函数:
strcmp与memcmp对照
先修自检(三问)
-1的 8 位补码是什么?unsigned char的取值范围是多少?strcmp比的是什么?sizeof(int)通常是多少?
第一部分:内存函数
字符串函数处理"以 '\0' 结尾的文本"。但内存里还有大量不以 '\0' 结尾的数据——int 数组、结构体、任意二进制块。操作它们的工具就是四个内存函数,都在 <string.h> 里。它们的共同特点:参数是 void *(不关心类型),长度以字节为单位。
一、memcpy——内存拷贝
🧩 函数原型(示意片段) · 需 #include <string.h>
c
void *memcpy(void *destination, const void *source, size_t num);- 功能:从
source起,向后复制num个字节到destination - 不管数据是什么类型,纯粹按字节搬运
- 返回值:目标空间起始地址
- ⚠️ 如果 source 和 destination 有重叠,结果未定义(🔴 UB)——重叠时要用下一节的
memmove
📄 memcpy_demo.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
#include <string.h>
int main()
{
int arr1[] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
int arr2[10] = { 0 };
memcpy(arr2, arr1, 20); // 拷 20 字节 = 5 个 int
for (int i = 0; i < 10; i++)
printf("%d ", arr2[i]);
printf("\n");
return 0;
}text
1 2 3 4 5 0 0 0 0 0只拷了 20 字节(前 5 个 int),后 5 个保持 0。
📘 提高(L2):my_memcpy 模拟实现
c
#include <assert.h>
#include <stddef.h>
void *my_memcpy(void *dst, const void *src, size_t count)
{
void *ret = dst;
assert(dst && src);
while (count--)
{
*(char *)dst = *(char *)src; // 按字节拷,每次 1 字节
dst = (char *)dst + 1;
src = (char *)src + 1;
}
return ret;
}为什么转成 char * 再 +1?因为 void * 不能直接 +1(不知道一步多大),而 char 恰好 1 字节,转成 char * 后每次精确移动 1 个字节,正好实现"按字节搬运"。
二、memmove——支持重叠的内存拷贝
🧩 函数原型(示意片段) · 需 #include <string.h>
c
void *memmove(void *destination, const void *source, size_t num);和 memcpy 唯一的差别:源和目标可以重叠,它保证拷贝结果正确。
📄 memmove_demo.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
#include <string.h>
int main()
{
int arr1[] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
memmove(arr1 + 2, arr1, 20); // 把开头 5 个 int 拷到从下标 2 开始(重叠!)
for (int i = 0; i < 10; i++)
printf("%d ", arr1[i]);
printf("\n");
return 0;
}text
1 2 1 2 3 4 5 8 9 10源 [arr1, arr1+20) 和目标 [arr1+2, arr1+22) 明显重叠。memmove 通过选择拷贝方向来保证正确。
📘 提高(L2):my_memmove 模拟实现——方向选择是关键
c
#include <stddef.h>
void *my_memmove(void *dst, const void *src, size_t count)
{
void *ret = dst;
if (dst <= src || (char *)dst >= (char *)src + count)
{
// 没有重叠,或目标在源前面:从左往右拷
while (count--)
{
*(char *)dst = *(char *)src;
dst = (char *)dst + 1;
src = (char *)src + 1;
}
}
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;
}为什么重叠时要从右往左:正着拷会先把源的前几个字节覆盖掉,后面再读源就读到脏数据。倒着拷先处理高地址,被覆盖的区域总是已经读过的,就不会污染还没读的源数据。
三、memset——内存设置
🧩 函数原型(示意片段) · 需 #include <string.h>
c
void *memset(void *ptr, int value, size_t num);把从 ptr 开始的 num 个字节,每个都设成 value(value 会被转成 unsigned char,即只用它的低 8 位)。
📄 memset_demo.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
#include <string.h>
int main()
{
char str[] = "hello world";
memset(str, 'x', 6); // 前 6 个字节设为 'x'
printf("%s\n", str);
return 0;
}text
xxxxxxworld⚠️ memset 只按字节工作
memset(arr, 1, sizeof(arr)) 想把 int 数组每个元素设成 1?错了!它把每个字节都设成 1,一个 int 的 4 个字节变成 0x01010101,即十进制 16843009。只有设 0(或 -1 这种全字节相同的值)才对,因为 0 的每个字节都是 0。
四、memcmp——内存比较
🧩 函数原型(示意片段) · 需 #include <string.h>
c
int memcmp(const void *ptr1, const void *ptr2, size_t num);比较两块内存前 num 个字节,逐个按 unsigned char 比大小,返回值同样是"正 / 0 / 负"三态。
📄 memcmp_demo.c · ✅ 完整程序(可直接复制编译)
c
#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);
return 0;
}第 3 个字节 g(103) vs G(71),buffer1 更大,输出"小于"分支的反面——buffer1 大于 buffer2。
memcmp vs strcmp:
| memcmp | strcmp | |
|---|---|---|
| 比较单位 | 任意内存,num 字节 | 字符串,遇 '\0' 停 |
| 适用 | int 数组、结构体、二进制 | 只用于以 '\0' 结尾的字符串 |
第二部分:数据在内存中怎么存
五、整数:存的是补码
整数有原码、反码、补码三种二进制表示(第 4 讲讲过)。回顾规则:
- 正数:原码 = 反码 = 补码
- 负数:原码符号位不变、其余取反得反码,反码 +1 得补码
- 符号位:0 表正、1 表负,占最高 1 位
关键点:整数放进内存时,存的是补码。 为什么?
- 用补码,符号位和数值位能统一处理;
- CPU 只有加法器,有了补码,减法和加法能统一(
a - b=a + (-b的补码)); - 补码与原码互相转换的运算过程相同,不需要额外硬件。
六、大小端字节序
6.1 概念
看一段代码在调试器里的内存:
c
int a = 0x11223344;你会惊讶地发现 0x11223344 是倒着按字节存放的。超过 1 个字节的数据,就有一个"字节摆放顺序"的问题,这就是大小端:
| 模式 | 定义 | 口诀 |
|---|---|---|
| 大端 | 数据的低位字节放高地址,高位字节放低地址 | 高字节 → 低地址 |
| 小端 | 数据的低位字节放低地址,高位字节放高地址 | 低字节 → 低地址 |
0x11223344 中,11 是最高字节、44 是最低字节:
text
地址: 低 ──────────────► 高
大端存: 11 22 33 44
小端存: 44 33 22 11常用 X86 / ARM 多为小端,KEIL C51 为大端。
6.2 判断本机字节序
思路:放一个值为 1 的 int,用 char 去看它的最低地址字节是 1 还是 0。
📄 check_endian.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
int check_sys(void)
{
int i = 1;
return *(char *)&i; // 取 i 首地址处的第 1 个字节
}
int main()
{
// 返回 1 → 低地址存的是最低位 → 小端;返回 0 → 大端
printf("%s\n", check_sys() == 1 ? "小端" : "大端");
return 0;
}另一种用联合体(第 14 学会正式讲,这里先感受):
🧩 示意片段 · 用到 union,第 14 讲才正式学
c
int check_sys(void)
{
union {
int i;
char c;
} un;
un.i = 1;
return un.c; // c 和 i 共享内存,读第 1 个字节
}七、浮点数:IEEE 754
先看一段"诡异"的代码:
📄 float_puzzle.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
int main()
{
int n = 9;
float *pFloat = (float *)&n; // 让 pFloat 指向 n 这块内存
printf("n的值为:%d\n", n);
printf("*pFloat的值为:%f\n", *pFloat);
*pFloat = 9.0; // 通过 float 视角写 9.0
printf("n的值为:%d\n", n);
printf("*pFloat的值为:%f\n", *pFloat);
return 0;
}同一块内存,整数和浮点数读出来天差地别——输出是 9 / 0.000000 / 1091567616 / 9.000000。原因:整数和浮点数用完全不同的规则解释这 32 个比特。
7.1 IEEE 754 规定
任意二进制浮点数 V 表示为:
text
V = (-1)^S × M × 2^ES符号位:0 正 1 负M有效数字,规定1 ≤ M < 2E指数位
内存布局:
| 类型 | 总位数 | S | E | M |
|---|---|---|---|---|
float | 32 | 1 | 8 | 23 |
double | 64 | 1 | 11 | 52 |
两条存储技巧:
- M 隐藏首位的 1:M 一定是
1.xxxx,那个整数 1 不存,只存小数部分,白赚 1 位精度(23 位能表示 24 位有效数字)。 - E 加中间数再存:E 是无符号数,但真实指数可正可负,于是存的时候加一个偏移——8 位 E 加 127,11 位 E 加 1023。例如真实 E=10,8 位存储时存
10+127=137。
7.2 E 取出的三种情况
| E(存储值) | 含义 |
|---|---|
| 不全 0 不全 1 | 常规:真实 E = 存储值 − 127,M 前补回首位 1 |
| 全 0 | 特殊:真实 E = 1 − 127,M 不补 首位 1,用于表示 ±0 和极小的数 |
| 全 1 | 特殊:M 全 0 表示 ±无穷大(符号看 S) |
7.3 回到谜题
- 9 当浮点数读:9 的整数补码
...00001001,按浮点拆分后 E 全为 0,落入"E 全 0"特殊情况,算出来是一个极接近 0 的正数(约2^-146),%f打印就是0.000000。 - 9.0 当整数读:9.0 =
1.001 × 2^3,S=0、M=001(后补0)、E=3+127=130=10000010,拼成 32 位0 10000010 001...,再按整数补码解读,正好是1091567616。
⚠️ 顺带提醒:
(float *)&n这种"跨类型读写同一内存"在严格意义上触碰了类型双关 / 对象生命周期规则,编译器优化下可能出问题(C11 §6.5p7 的有效类型)。本讲只用它来演示存储差异;工程里安全做法是用union或memcpy做类型双关。
八、经典笔试题精讲(大小端 + 补码 + 无符号)
下面 5 组都是高频面试题,先自己想,再看解析。它们都是片段(缺 include 或 main),补全后才能编译。
① 三种 char 打印
🧩 示意片段 · 需补 #include <stdio.h> 和 main 才能编译
c
char a = -1; signed char b = -1; unsigned char c = -1;
printf("%d %d %d", a, b, c); // -1 -1 255c 是 unsigned char,-1 的补码 11111111 被当无符号读就是 255;%d 打印时它整型提升为 int 255。(本机实测输出确为 -1 -1 255。)
② char -128 用 %u 打印(本讲练习题,见下方交互版)
③ strlen 的坑
🧩 示意片段 · 需补 #include 和 main 才能编译
c
char a[1000];
for (i = 0; i < 1000; i++) a[i] = -1 - i;
printf("%d", strlen(a)); // 255a[i] = -1-i,当 i=255 时值为 -256,赋给 char 截断成 0(-256 & 0xFF = 0),于是 a[255]=='\0',strlen 数到它就是 255。(完整程序见练习 8,本机实测输出 255。)
④ 无符号循环永真
🧩 示意片段 · 故意展示死循环,不要直接运行
c
unsigned char i = 0;
for (i = 0; i <= 255; i++) printf("hello\n"); // 死循环!unsigned char 最大 255,i<=255 恒成立,i 从 255 再 +1 又回到 0,永远出不去。同理 unsigned int i; for(i=9;i>=0;i--) 也死循环——无符号数永远 >=0。
⑤ 指针 + 大小端(X86 小端)
🧩 示意片段 · 需补 #include 和 main;且只能在 32 位环境运行
c
int a[4] = { 1, 2, 3, 4 };
int *ptr1 = (int *)(&a + 1);
int *ptr2 = (int *)((int)a + 1);
printf("%x, %x", ptr1[-1], *ptr2); // 4, 2000000&a+1 跳过整个数组,ptr1[-1] 回退一个 int = a[3] = 4;(int)a+1 让地址 +1 字节,小端下从这个位置读 int,得到 0x02000000,%x 打印为 2000000。
⚠️ 这题只在 32 位环境成立。
(int)a把指针塞进 int——32 位下指针正好 4 字节没问题;但64 位下指针是 8 字节,(int)a会截断高 4 位,ptr2变成非法地址,一解引用就崩溃(本机 64 位 gcc 实测崩溃,退出码0xC0000409)。所以这道经典题默认是 32 位老环境;在 64 位下要用uintptr_t而非int才能安全地"地址 +1 字节"。理解考点即可,不必在现代 64 位机器上强行运行它。
⚠️ 常见坑与报错(本讲汇总)
| 你看到的现象 | 原因 | 修法 |
|---|---|---|
memcpy 拷重叠内存结果乱 | 重叠时 memcpy 行为未定义 | 重叠一律用 memmove |
memset(arr,1,...) 后 int 不是 1 | memset 按字节设,得到 0x01010101 | 只有设 0 安全;设别的值用循环 |
unsigned 循环 i>=0 停不下来 | 无符号恒 ≥0 | 条件改成 i>0 或用有符号变量 |
char a=128; 值不是 128 | 有符号 char 存不下 128,溢出成 -128 | 存 128 用 unsigned char |
| 整数和浮点数读同一内存结果怪异 | 两者解释规则不同(IEEE 754) | 别跨类型读写;要看得用 memcpy/union |
strlen 结果比预期小 | 数组里提前出现了 '\0' | 检查是否有元素被算成 0 |
VS 报 printf(str) 相关警告 | 把变量当格式串,可能触发格式串漏洞 | 写成 printf("%s", str) |
🛠️ 动手练习
规则:先独立做,卡住了再依次展开提示,不要一上来就看答案。
📊 本讲网页练习进度0 / 5(0%)
进度自动保存在本浏览器;编程题不计数,请在编辑器中完成。
练习 1 · ⭐ 基础 · 读输出
知识点:memset 按字节 + char 无符号
题干:写出输出:
📄 ex1.c · ✅ 完整程序
c
#include <stdio.h>
#include <string.h>
int main()
{
char a = -1;
unsigned char b = -1;
printf("%d %d\n", a, b);
return 0;
}请写出两个数字(空格分隔):
空①
练习 2 · ⭐ 基础 · 选择判断
知识点:内存函数特性
题干:下列说法正确的是?(多选)
练习 3 · ⭐ 基础 · 读输出
知识点:char 溢出 + %u
题干:写出输出:
📄 ex3.c · ✅ 完整程序
c
#include <stdio.h>
int main()
{
char a = -128;
printf("%u\n", a);
return 0;
}请写出程序打印的数字:
空①
练习 4 · ⭐ 基础 · 找错改错
知识点:无符号循环死循环
题干:下面循环停不下来,请填出修正后的循环条件:
🧩 待改错片段
c
unsigned int i;
for (i = 9; i >= 0; i--)
printf("%u\n", i);请把判断条件 i >= 0 改成正确的写法(只填条件部分):
空①
练习 5 · ⭐ 基础 · 程序填空
知识点:大小端判断
题干:阅读判断字节序的函数,回答两个空:
🧩 示意片段
c
int check_sys(void)
{
int i = 1;
return *(char *)&i; // 取 i 首地址处的第 1 个字节
}*(char *)&i 读到的是 i 的最低地址那 1 个字节。若返回 1,说明低字节存在低地址,是 ____ 端;若返回 0,则是 ____ 端:
空①空②
练习 6 · ⭐⭐ 提高 · 写小程序
知识点:memmove 处理重叠
🛠 动手编程题 · 请在 VS2026(或你的编辑器)中完成
有一个 char str[] = "abcde",用 memmove 把字符串整体左移两位(结果应是 "cde")。
✅ 过关标准:程序打印 cde。
网页内无法练习写代码,亲手敲、亲手编译才能真正学会。下方按顺序展开 思路 → 步骤 → 答案。
💡 提示 1(思路)
把 str+2 开始的 3 个字符(cde)拷回 str 开头,源和目标重叠,正好用 memmove。
🧭 提示 2(步骤)
memmove(str, str + 2, 3);把 "cde" 拷到开头- 拷完 str 变成 "cdede",把后面多余的清掉
str[3] = '\0';截断printf("%s", str);
🔑 参考答案与解释
c
#include <stdio.h>
#include <string.h>
int main()
{
char str[] = "abcde";
memmove(str, str + 2, 3); // 把 "cde" 移到开头,源目标重叠
str[3] = '\0'; // 截断,去掉残留的 "de"
printf("%s\n", str); // cde
return 0;
}为什么这里 memmove 是必须的:源 str+2 和目标 str 有重叠,用 memcpy 属于未定义行为,可能拷出错误结果。memmove 会自动选择安全的拷贝方向。
练习 7 · ⭐⭐ 提高 · 写小程序
知识点:memcpy 拷结构体数组
🛠 动手编程题 · 请在 VS2026(或你的编辑器)中完成
定义 int src[8] = {1,2,3,4,5,6,7,8};,用 memcpy 把 src 的后 4 个元素(5,6,7,8)拷到另一个 int dst[4] 里,再打印 dst。
✅ 过关标准:打印 5 6 7 8。
网页内无法练习写代码,亲手敲、亲手编译才能真正学会。下方按顺序展开 思路 → 步骤 → 答案。
💡 提示 1(思路)
后 4 个元素的起始地址是 src + 4,字节数是 4 * sizeof(int)。
🧭 提示 2(步骤)
int dst[4] = {0};memcpy(dst, src + 4, 4 * sizeof(int));- 遍历打印 dst
🔑 参考答案与解释
c
#include <stdio.h>
#include <string.h>
int main()
{
int src[8] = { 1, 2, 3, 4, 5, 6, 7, 8 };
int dst[4] = { 0 };
memcpy(dst, src + 4, 4 * sizeof(int)); // 从第 5 个元素起拷 16 字节
for (int i = 0; i < 4; i++)
printf("%d ", dst[i]);
printf("\n");
return 0;
}要点:memcpy 的第三个参数是字节数,拷 int 数组一定要 元素个数 × sizeof(int),写成 4 只会拷 4 字节 = 1 个 int。src + 4 是指针运算,指向下标 4 的元素。
练习 8 · ⭐⭐⭐ 挑战 · 读输出
知识点:strlen 与 char 截断
🛠 动手编程题 · 请在 VS2026(或你的编辑器)中完成
预测下面程序的输出,然后上机验证:
c
#include <stdio.h>
#include <string.h>
int main()
{
char a[1000];
int i;
for (i = 0; i < 1000; i++)
a[i] = -1 - i;
printf("%d\n", (int)strlen(a));
return 0;
}✅ 过关标准:答对并解释为什么是 255(提示:什么时候 a[i] 会变成 '\0'?)。
网页内无法练习写代码,亲手敲、亲手编译才能真正学会。下方按顺序展开 思路 → 步骤 → 答案。
💡 提示 1(思路)
a[i] = -1 - i 赋给 char 会截断成低 8 位。什么时候低 8 位正好是 0?
🧭 提示 2(步骤)
strlen从 a[0] 数到第一个'\0'a[i] == 0意味着-1 - i的低 8 位为 0-1 - i ≡ 0 (mod 256)→i = 255- 所以 a[0..254] 非 0,a[255] = 0,strlen = 255
🔑 参考答案与解释
输出 255。a[i] = -1 - i 存进 char 只保留低 8 位。当 i = 255 时 -1-255 = -256,-256 的低 8 位是 00000000,即 a[255] == '\0'。而 i=0~254 时结果分别是 -1~-255,低 8 位都不是 0。strlen 数到第一个 '\0' 停下,正好 255 个字符。
验证:把这段拷进 VS/gcc 运行,输出确为 255。这类题考的就是"char 截断 + strlen 遇 \0 停"两个知识点的组合。
📝 小结与自测
本讲主线:内存函数(memcpy/memmove/memset/memcmp)按字节操作任意内存,重叠用 memmove、memset 只认字节;整数在内存里存补码(为了统一加减法);多字节数据有大小端之分(低位字节放低地址是小端,X86 常用);浮点数按 IEEE 754 存成 S+E+M,M 隐藏首位 1、E 加偏移 127/1023,所以同一块内存按 int 和 float 读出来完全不同。
自测四问:
memcpy和memmove什么时候必须用后者?memset(arr, 1, sizeof(arr))里 arr 是 int 数组,每个元素变成多少?为什么?- 小端机器上
int a = 0x11223344;最低地址那字节是什么? - 为什么
unsigned int i; for(i=9;i>=0;i--)是死循环?
🔬 选学拓展(L3)
以下内容超出主线要求
适合学有余力的同学,不影响后续学习。
L3-1:手写一个通用 swap(void* 的威力)
内存函数用 void * 处理任意类型,这个思想可以自己写一个通用交换函数:
📄 generic_swap.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
#include <string.h>
void swap(void *a, void *b, size_t size)
{
char tmp[64]; // 假设元素不超过 64 字节
memcpy(tmp, a, size);
memcpy(a, b, size);
memcpy(b, tmp, size);
}
int main()
{
int x = 3, y = 8;
swap(&x, &y, sizeof(int));
printf("%d %d\n", x, y); // 8 3
double p = 1.5, q = 2.5;
swap(&p, &q, sizeof(double));
printf("%.1f %.1f\n", p, q); // 2.5 1.5
return 0;
}同一个 swap 能交换 int、double、甚至结构体——因为它只搬字节、不问类型。这正是 qsort 能排序任意数组的原理(第 13 讲)。
L3-2:为什么 0.1 + 0.2 != 0.3
浮点数按 M × 2^E 存,只能精确表示分母是 2 的幂的小数。0.1(即 1/10)的二进制是无限循环的 0.0001100110011...,存进 23 位尾数必然截断,产生微小误差:
📄 float_err.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
int main()
{
double a = 0.1 + 0.2;
printf("%.20f\n", a); // 0.30000000000000004441
printf("%d\n", a == 0.3); // 0
return 0;
}所以永远不要用 == 直接比较两个浮点数,应改成"差的绝对值小于一个极小值":fabs(a - b) < 1e-9。这是所有语言共有的坑,不是 C 的 bug。
L3-3:用联合体窥探一个 float 的比特
📄 float_bits.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
int main()
{
union {
float f;
unsigned int u;
} data;
data.f = 9.0f;
printf("0x%08X\n", data.u); // 0x41100000
return 0;
}9.0f = 1.001 × 2^3,S=0、E=3+127=130=0x82、M=001,拼起来正好 0x41100000,和本讲谜题里"9.0 当整数读 = 1091567616"完全对得上(0x41100000 = 1091567616)。union 让两种类型共享同一块内存,是观察 IEEE 754 布局的好工具(正式讲解在第 14 讲)。