目录
1.引言
2.memcpy 函数
3.memmove 函数
4.memset 函数
5.memcmp 函数
6.整数在内存中的存储
7.大小端字节序及字节序判断
8.浮点数在内存中的存储
9.结语
1.引言
上篇博客我们讲解了一些用于字符数组的字符串函数,那我们知道,C语言数组除了字符数组外,还有整型数组等其他类型的数组,那如果要对这些类型的数组进行拷贝或比较等操作,我们肯定是不能使用字符串函数的,此时就要使用本篇博客所讲的内存函数了。当然,本篇博客最后还会补充一些与数据存储相关的知识。
2.memcpy 函数
首先就是 memcpy 函数,memcpy 函数是用于内存拷贝的函数,他会从源空间 p2 的首地址开始,将 n 个字节长度的内容拷贝到目标空间 p1 里面,然后返回目标空间的首地址。至于返回值类型与参数类型为什么是 void*,这是因为不同类型的数组大小不同,使用 void* 指针接收就能实现范式编程,提高函数的通用性。不过接下来的四个内存函数虽然都是对内存空间进行操作,但在使用前都要包含头文件 string.h。

接着我们来看 memcpy 函数的具体使用。如下图,我们创建两个数组,然后利用 memcpy 函数将 arr1 数组的内容拷贝到 arr2 数组中。但由于 memcpy 函数是依据字节进行拷贝的,所以我们传入的 20 实际上是 20 字节。而一个 int 类型数组的元素大小为 4 字节,所以这里就只会拷贝 arr1 数组的前五个元素到 arr2 数组中,最后结果也证明了这一点。


值得注意的是,虽然我们前面批注说 memcpy 函数只能拷贝不重叠的内存,且C语言标准也确实规定了 memcpy 函数无法对同一个数组的重叠内容进行拷贝操作,但当我们在VS上测试时却发现结果并没有问题。这并不是因为我们批注写错了或是C语言规则改动了,而是VS编译器扩展了 memcpy 函数的作用,使其也可以正常完成重叠内存的拷贝操作。

![]()
在清楚了 memcpy 函数的作用与语法后,我们也可以尝试模拟一下 memcpy 函数的运行逻辑。根据前面所学知识我们知道 memcpy 函数是依据字节进行拷贝操作的,而 char 类型的大小就是1字节,所以我们在对 void* 指针做强制类型转换时就可将其转为 char* 指针,然后再进行拷贝与偏移操作。具体代码如下。

![]()
当我们使用自己模拟的 Memcpy 函数对重叠内存进行拷贝操作时,就会发生下图结果那样的错误,这是因为在逐字节拷贝内容时,先将 arr 地址处的 1 拷贝到了 arr+4 地址处,那 arr+4 地址处的值在一开始就被改为了 1,那当我们拷贝到 arr+8 地址处时,拷贝过来的就是 arr+4 地址处已被修改后的1,而不是最开始的 3。所以使用 memcpy 函数是无法处理重叠内存的拷贝操作的。

![]()
3.memmove 函数
既然 memcpy 函数无法处理重叠内存的拷贝操作,那有没有其他函数可以实现?其实是有的,memmove 函数可以认为是 memcpy 函数的优化版本,利用 memmove 函数就能对重叠内存进行拷贝操作。那 memmove 函数的作用与语法是和 memcpy 函数一样的,我们就不再重复了。

![]()
从上图结果来看,memmove 函数确实解决了重叠内存的拷贝问题,那 memmove 函数是不是只能用于重叠内存的拷贝?其实也不是,我们使用 memmove 函数同样可以完成不重叠内存的拷贝,所以在之后的学习中,我们尽量使用 memmove 函数来完成内存的拷贝操作。

![]()
那清楚了 memcpy 函数与 memmove 函数的区别之后,我们就要思考一下,怎样改写我们自己所写的自定义函数代码,就能实现对 memmove 函数的功能模拟?从之前的分析中,我们发现问题就在于我们使用顺序拷贝时,可能会覆盖后续拷贝所需的原始内容,那我们能否颠倒拷贝顺序使用倒序拷贝?对于先前的问题自然是可以的,但这样就带来了新问题,我们使用倒序拷贝时,同样可能会覆盖后续拷贝所需的原始内容,就如第二组图片的示例那样。那导致问题产生的根本原因就是 p1 与 p2 在发生内存重叠时,两者的前后位置不同,所以我们需要判断 p1 与 p2 所指向地址的大小关系,然后选择合适的顺序来完成拷贝操作。当 p1<p2 时,使用倒序拷贝就有可能发生头部覆盖,此时我们要使用顺序拷贝;而当 p1>p2 时,使用顺序拷贝就有可能发生尾部覆盖,此时我们要使用倒序拷贝。那理清楚 memmove 函数的运行逻辑后,我们就能正确修改原本的代码了,具体代码如下。

![]()

![]()
从上述的测试结果来看,无论 p1 与 p2 的内存重叠情况如何,我们都能正确的完成重叠内存的拷贝操作。那如果是不重叠内存的拷贝操作,这个函数还能正确完成吗?经过下图样例的测试,我们发现也是可以完成的。至此,我们就模拟出了 memmove 函数的运行逻辑。

![]()
4.memset 函数
然后就是 memset 函数,memset 函数是用于内存初始化的函数,他会从目标空间 p 的首地址开始,将 n 个字节长度的内容全部设置为给定的值 v,然后返回目标空间的首地址。值得注意的是,memset 函数的参数 v,虽然在传入时是 int 类型,但 memset 函数也是按字节进行设置的,所以 v 值在实际使用时会被转换为 unsigned char 类型。

接着我们来看 memset 函数的具体使用。如下图,我们创建一个数组 arr,然后利用 memset 函数进行数组的初始化,虽然我们传入值 1,但最后打印时并不会输出 10 个值为 1 的数组元素,就是因为 memset 函数是以字节为单位进行初始化的,每个字节都被设置为 1,那数组元素实际上是被设置成了 0x01010101,也就是我们所看到的那个极大的整数。

![]()
我们通过调试,在内存窗口可以更清晰的观察到 memset 函数对内存空间进行初始化后的结果。

所以 memset 函数更适合用来重置一片内存空间。就比如我们之前的扫雷游戏,在一局游戏结束后,我们可以利用 memset 函数将整个二维数组置 0 后,再重新植入雷点。包括以后如果要对一个数组进行多次操作时,每次操作前我们都可以先使用 memset 函数将数组置 0,这样就可以避免先前的旧数据对之后的操作结果产生影响。

![]()
我们同样可以书写代码模拟一下 memset 函数的运行逻辑,由于整个过程并不复杂,我们只需要利用循环将对应地址的值都修改为所给的值 v,然后不断向后偏移直到操作完 n 个字节即可。这里就不过多分析了,具体代码如下。

![]()
5.memcmp 函数
然后就是 memcmp 函数,memcmp 函数是用于比较内存大小的函数,他会从 p1 地址开始与 p2 地址依次比较两个地址所存的值的大小关系,最多比较 n 个字节,如果比较途中发现两片内存空间存在不相同的内容就会提前返回比较结果。memcmp 函数的比较结果有三种,当返回值 ==0 时说明 p1 与 p2 相同;当返回值 >0 时说明 p1>p2;当返回值 <0 时说明 p1<p2。

接着我们来看 memcmp 函数的具体使用。如下图,我们创建两个数组并初始化不同的数据,然后利用 memcmp 函数对两个数组进行比较。第一行代码中传入比较字节数 8,表示最多比较前8个字节的内容是否相同,如有不同则提前返回比较结果,那我们知道这两个数组的前两个元素是一样的,所以最后结果为 0。第二行代码中传入比较字节数 13,虽然从数组元素上来看,257与1必然是不同的,但 memcmp 函数是按字节进行比较的,1在内存中的存储形式为 01000000,可以看到是倒序存放的,至于原因后面会讲,而257在内存中的存储形式为 01010000,所以只比较到第13个字节时,两个数组的内容还是一样的,最后结果也为 0。但当我们再往后比较一个字节的内容,此时两个数组就有大小之分了,可以看到 arr1 的第14个字节存的是 01,而 arr2 的第14个字节存的是 00,此时就会返回一个 >0 的数,表示 arr1>arr2。

![]()

那关于 memcmp 函数的模拟,由于 memcmp 函数的运行逻辑与 strncmp 函数相似,只是少了字符 '\\0' 的约束,我们这里就不模拟了,感兴趣的小萌新可以自己去尝试一下。
6.整数在内存中的存储
关于整数在内存中的存储,我们前面其实已经讲过了。整数有三种表达形式,分别是原码、补码、反码,而整数在内存中所存的就是他的二进制补码。且整数是分有符号整数和无符号整数的,对于有符号整数而言,最高位表示符号位,其余位表示数值位;对于无符号整数而言,所有位都是数值位。那关于整数存储的知识我们就这样简单的回顾一下,接下来来看一些与整数存储相关的代码。
第一个代码,从最后结果可以看出,在VS编译器里 char 类型默认是有符号 char。那至于有符号 char 与无符号 char 为什么最后的输出结果不同,我们前面也是讲过的,当我们把一个 int 类型的数据赋给一个 char 类型的变量时一定会发生截断,最后打印输出时又会发生整型提升,那有符号 char 在整型提升时填充的是符号位,而无符号 char 在整型提升时填充的永远是 0。关于截断与整型提升的知识我们在操作符篇的末尾讲过,忘记的小萌新可以再回过去复习一下。

![]()
第二个代码也是同理,当我们把一个 int 类型的数据赋给一个 char 类型的变量时一定会发生截断,最后打印输出时又会发生整型提升。而 %u 格式是以十进制形式打印无符号整数,就会将最高位的符号位也一并视作数值位,所以最后结果就是一个极大的整数。

![]()
第三个代码依旧是关于 char 类型的,我们知道 char 类型的取值范围是 -128 到 127,关于这个范围是怎么来的我们同样在操作符篇讲过,这里就不重新推导了。那么 -129 实际在存入 char 类型的数组时会因为截断而变成 127,由此推导我们可知 256 在存入数组时就会变为 0,而 strlen 函数统计的是 '\\0' 字符前的字符个数,而 '\\0' 字符的 ASCII 值为 0,所以最后结果就是 255。

![]()
第四个代码则是与无符号相关,下列代码正常情况下只会执行 6 次,但无符号 int 类型的整数不可能出现负数,当 i==0 时,i– 就会变成一个极大的整数而不是我们认为的 -1,所以这个程序的终止条件 i>=0 永远成立,当我们尝试运行这个程序时,就会发生死循环。

7.大小端字节序及字节序判断
刚刚在使用 memcmp 函数对两片内存空间的内容进行比较时,我们发现数据在内存中竟然是倒序存放的,就和下图变量 a 的值存放形式一样。那其实这就涉及到内存存储数据的模式了,接下来就给大家深入讲解一下大小端的概念以及如何判断当下内存的存储模式。


其实除开 char 类型以外的其他多字节类型,比如 int,float,double 等,这些类型数据在内存中进行存储时都会到涉及到存储的顺序问题。因为我们知道可以通过指针访问地址,然后通过指针的解引用操作获取到对应地址的值。但考虑到数据的存放与获取,我们主要有两种存储方式,也就是大端存储与小端存储。其中大端存储就是将数据的低位字节内容存到内存的高地址处,将数据的高位字节内容存到内存的低地址处,从直观上来看就是顺序存储数据。而小端存储则是反过来,将数据的低位字节内容存到内存的低地址处,将数据的高位字节内容存到内存的高地址处,从直观上来看就是倒序存储数据。

那要测试当下内存的存储方式是大端存储还是小端存储也很容易,因为两种存储方式的区别就在于低地址处存储的是高位字节内容还是低位字节内容。那下图代码就是先指向 x 变量的低地址处,但由于 int 类型数据有 4 字节大小,所以先强制类型转换为 char* 指针类型,再通过解引用操作判断低地址处存的是低位字节内容还是高位字节内容,就能得知当下内存的存储方式。从最后结果来看,VS编译器在 x86 环境下的内存存储方式为小端存储。

![]()
然后我们来看下图代码,该代码需要在VS的 x86 环境下运行。第一行代码很容易理解,一开始 &arr 获取整个数组的地址,然后向后偏移一个单位得到 arr[5] 的地址,最后 p1[-1] 等同于先向前跨过一个 int 类型的长度然后解引用,最后结果就是 4。第二行代码就要用到刚刚所学的存储知识了,具体演示可以看下面的草图,当我们将 arr 指针强制类型转换为 int 类型之后,+1 就是做整数加法,相当于地址只向后跨过一个字节的长度,此时再对 p2 解引用就得到 0x02000000,但十六进制打印输出时会省略 0x 与前导 0。

![]()

8.浮点数在内存中的存储
在讲解浮点数的存储前,我们先来看一个代码。从最后结果可以推断出整数存储与浮点数存储是不同的,当我们以浮点数形式读取整数或以整数形式读取浮点数时就会出错。


那浮点数的存储其实是有专门规定的,具体的规定可以看下图文字。

同时这套规定与浮点数在内存中的存储形式有关,以 float 为例,S 代表符号位占据一个比特位大小,E 代表指数位占据一个字节大小,M 代表数值位占据余下的所有位数。

除此之外,对于浮点数存储时的 M 与 E 还有一些特殊规定,具体内容也都在下图中。




关于 float 类型数据的取值范围则可以看下图,我们可以看到是有规范化区间与非规范化区间之分的,那规范化区间就是常规情况而非规范化区间就是 E 全为 0 的情况。

在了解了浮点数的存储形式后,我们就可以回过来解释一开始的代码了,大概的过程已经在下图以批注形式呈现了。我们可以看到,当我们以浮点数形式读取整数时会直接按照浮点数的存储逻辑转换,此时获取的就是一个接近于 0 的极小浮点数,受限于 float 类型的输出精度,最后结果就成了 0.000000。而当我们以整数形式读取浮点数时也会直接按照整数的存储逻辑转换,此时获取的就是一个极大的整数。

接下来我们来看一个代码,这个代码凭我们的认知应该要输出 ==,但最终结果却是 !=。那是哪里出了问题?

![]()
当我们通过调试进入监视窗口就可以看到,01,0.2,0.3 这三个数都没有做到精确保存,这是因为在使用二进制表示一些小数时也有可能出现无限循环的情况,就像我们计算1除以3的结果一样,但无论是 float 类型还是 double 类型,都是有最多数位限制的,所以我们看到的就是一个与目标数字十分接近的数字。

但这样明显不符合我们对 0.1+0.2==0.3 这个数学等式的认知,所以在进行与浮点数相关的运算时我们往往会宏定义一个所能接受的误差区间,那只要最后的误差不超过这个区间,我们就认为等式成立。而要计算两者差的绝对值就要用到专门的 fabs 函数,这个函数就是用来计算浮点数的绝对值的,在使用前需要包含头文件 math.h。所以我们要将代码改为下图那样,此时再去测试,最后结果就是 == 了。

![]()
9.结语
本篇博客到这里就结束了,希望能够帮到各位刚接触C语言的小萌新们,下篇博客将继续讲解C语言的相关知识。


