Logo
Section 1 / 6

重新理解内存

通过重新解读数据类型,深入了解内存的内部工作机制。

导入

重新理解内存

通过重新解读数据类型,深入了解内存的内部工作机制。

引言

我们现在已经知道,内存是一串线性的比特或字节。

此外,我们还了解以下几点:

  • 当我们在栈上创建一个变量,例如 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 = 222
  • 10101101 = 173
  • 10111110 = 190
  • 11101111 = 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(字节)。

指针与函数

挑战:实现 memcmp 函数