重新理解内存
通过重新解读数据类型,深入了解内存的内部工作机制。
引言
我们现在已经知道,内存是一串线性的比特或字节。
此外,我们还了解以下几点:
- 当我们在栈上创建一个变量,例如
int x时,会分配一块 4 字节的内存空间(因为int的大小是 4 字节)。我们会通过减去 4 来修改栈指针esp寄存器(即压栈操作)。 - 当我们在堆上创建一个变量时,使用
malloc或calloc。这些函数只需要知道要分配的内存块的大小即可。
从上述信息可以推断出:无论我们以何种方式分配数据,最关键的是我们为特定数据类型需要多少字节。
内存本身并不具备数据类型的概念。它只是一串 0 和 1。内存并不关心这些 0 和 1 是来自一个 int 还是一个 double。我们根据代码中使用的数据类型来决定如何解释这些数据。
例如:
1malloc(sizeof(int)); 2malloc(4 * sizeof(char));
这两个 malloc 调用都会分配一块 4 字节的内存。由我们决定是否将这 4 字节解释为一个整数,还是解释为包含四个字符的数组。
以下两种解释都是合法的:
1int* p1 = malloc(sizeof(int)); 2char* p2 = malloc(4 * sizeof(char));
我们甚至可以混合使用:
1char* p1 = malloc(sizeof(int)); 2int* p2 = malloc(4 * sizeof(char));
当然,这样做会让人困惑,在实际编程中不应这样写。但重要的是,这种做法在技术上是可行的。无论是 sizeof(int) 还是 4 * sizeof(char),它们都返回 4 字节的内存块,我们可以选择将其解释为整数或四个字符的数组。
请参考下面的内存示意图:
我们可以利用内存本身不关心数据类型这一特性,实现巧妙的内存操作或内存重新解释。
char* 的强大之处
由于 char 的大小是 1 字节,我们可以使用 char* 按字节逐个遍历任意其他数据类型。
请注意,这种重新解释在 C 语言中是明确定义的,不会导致未定义行为。以下是标准中的说明:
记住:当一个指向对象的指针被转换为指向字符类型的指针时,结果指针指向该对象的最低地址字节。对结果指针进行连续递增,直到对象大小为止,将得到指向对象其余字节的指针。
我们可以尝试使用 char 指针逐个访问整数的各个字节。我们创建一个整数,并将其值设为 0xDEADBEEF(第 5 行)。这是一个十六进制数值,其二进制表示为 11011110101011011011111011101111。
然后,我们将 x 的指针强制转换为 unsigned char*,从而获得一个指向整数内存块的 unsigned char 指针(第 7 行)。
注意:我们使用
unsigned char而不是char,是为了避免符号问题。否则在打印时可能会得到负值。这个负值实际上是正数(预期值)的补码形式。补码是计算机存储负数的方式。简而言之,如果不明确表示我们处理的是无符号值(只能为正),系统可能错误地将我们的正数解释为负数。
接着,我们可以使用 for 循环访问 ptr[0]、ptr[1]、ptr[2] 和 ptr[3](第 8 行)。由于 ptr 的类型是 unsigned char,每次递增都会使地址增加 1(即 unsigned char 的大小)。如果使用 int 指针,每次递增会增加 4 字节,就无法访问单个字节了。
让我们运行代码并查看输出结果:
1#include <stdio.h> 2 3int main() 4{ 5 int x = 0xDEADBEEF; 6 7 unsigned char* ptr = (unsigned char*)&x; 8 for(int i = 0; i < sizeof(int); i++) 9 { 10 printf("byte %d value %u\n", i, ptr[i]); 11 } 12 return 0; 13}
输出结果:
1byte 0 value 239 2byte 1 value 190 3byte 2 value 173 4byte 3 value 222
回忆一下,0xDEADBEEF 的二进制形式是 11011110101011011011111011101111。这 32 位被划分为 4 个字节。现在我们来分开它们:
- 第一个字节包含前 8 位:
11011110 - 第二个字节包含接下来的 8 位:
10101101 - 第三个字节是:
10111110 - 最后一个字节是:
11101111
现在,我们使用计算器将这些二进制值转换为十进制:
11011110= 22210101101= 17310111110= 19011101111= 239
我们可以看到代码输出是正确的。唯一的区别是我们得到的顺序是相反的。这一点目前无关紧要,但我们很快就会明白原因。
数字 0xDEADBEEF 在内存中正是通过组合上述 4 个字节构建而成的。
总结一下,我们使用了一个大小为 1 字节的 unsigned char 指针,遍历了一个整数的 4 个字节。我们之所以能做到这一点,是因为我们知道,当递增 char 指针时,地址每次增加 1。
实现 memcpy
回想一下,memcpy 是 C 标准库中的一个函数,用于将内存从一个块复制到另一个块。
其原型如下:
1void* memcpy(void* destination, const void* source, size_t num);
它必须适用于所有数据类型,因此参数必须定义为 void*。
现在我们来编写自己的 memcpy 实现。由于 char 的大小是 1 字节,任何其他数据类型的大小都是 1 的倍数。
因此,无论 destination 和 source 的类型是什么,我们都可以像上面处理整数那样,逐字节遍历它们的内存。实现方法如下:
- 将两个指针都转换为
char*(第 6 和第 7 行)。 - 然后从
sourcePtr复制num个字节到destPtr(第 11 行)。
只要用户正确传入字节数,该函数就能正常工作。例如,对于一个包含五个元素的整数数组,我们必须传入 5 * sizeof(int),即数组的总字节数。如果只传入 5,函数将尝试复制 5 字节,这意味着复制第一个数组元素(4 字节)和第二个数组元素的 1 字节。显然这是不正确的。
1#include <stdio.h> 2#include <string.h> 3 4void* my_memcpy(void* destination, const void* source, size_t num) 5{ 6 char* destPtr = (char*)destination; 7 const char* sourcePtr = (char*)source; 8 9 for(int i = 0; i < num; i++) 10 { 11 *(destPtr + i) = *(sourcePtr + i); 12 } 13 14 return destination; 15} 16 17int main() 18{ 19 // 复制一个整数 20 int x = 125; 21 int xCopy; 22 my_memcpy(&xCopy, &x, sizeof(int)); 23 printf("xCopy = %d\n", xCopy); 24 25 // 复制一个整数数组 26 int arr[5] = {0, 1, 2, 3, 4}; 27 int arrCopy[5]; 28 my_memcpy(arrCopy, arr, sizeof(int) * 5); 29 30 for(int i = 0; i < 5; i++) 31 { 32 printf("arrCopy[%d] = %d\n", i, arrCopy[i]); 33 } 34 return 0; 35}
代码输出如下:
1xCopy = 125 2arrCopy[0] = 0 3arrCopy[1] = 1 4arrCopy[2] = 2 5arrCopy[3] = 3 6arrCopy[4] = 4
我们首先复制了一个整数,可以看到 xCopy 的值确实是 125,正确无误。接着复制了一个包含五个整数的数组,输出结果也完全正确。
在整数的情况下,memcpy 代码会遍历 4 个字节,并将它们复制到目标缓冲区。在数组的情况下,代码会遍历 20 个字节并复制到目标缓冲区。由此可见,memcpy 并不关心内存的结构。内存可能是单个变量,也可能是多个元素的数组,但 memcpy 都不在乎。它只是将内存视为一串字节。换句话说,我们把内存重新解释为一系列 char(字节)。