深刻理解指针与字节序

1,939 阅读7分钟

前言

越是基础的知识,越是能表现出其独特的魅力,从多个角度思考问题,体会其中的设计哲理。

指针的3种含义

对于再熟悉不过指针来说,依旧可以深入探究一下其含义。

指针一般有3种含义:

  1. 指明数据的位置,体现在指针的值表示一个地址,这也是我们最容易理解的含义,即指针就是变量的地址,这个地址也就是进程的虚拟地址。

  2. 表示数据类型的大小,虽然指针本身的值(地址长度)一般是一样大小的,在64位系统中使用8个字节表示地址,但是不同类型的指针所指向的内存的数据类型是不一样的。比如int指针表示4个字节为一组数据,体现在当使用指针的加减法时,会自动跳过4个字节,这也是指针加减法的特殊之处。

  3. 表示数据被如何解释,虽然float指针和int指针,所指向的内存的数据类型都是4个字节,但是他们的解释完全不同,就体现在对指针解引用时,同样是4个字节,解析出来的数据是不一样的。

void指针它只有第一层含义,不能表示数据如何解释,以及也不能表示数据的大小,只是指明了数据的位置。

字节序

在计算机中,存储是按字节来为单位来寻址和存储的,比如一个int类型数据,需要4个字节来存储,这种多字节数据一般都是被存储为连续的字节序列

比如一个4字节的int类型变量a,其存储的起始字节地址为0x80901100,则剩下的3个字节地址就分别为0x809011010x809011020x80901103,这时问题就来了,a的最低有效位可以存储在低地址,也可以存储在高地址中,这就引出了大端序和小端序

大端序和小端序

比如一个int类型的变量为0x12345678其中0x12为最高有效位字节,0x78为最低有效位字节,如果最高有效位存储在前面,则这种存储规则就是大端序,大端序就是我们日常的书写顺序

假如低有效位存储在前面,这种存储方式就是小端序,我们日常使用的x86架构就是小端序

0x12345678采用大端序存储方式的形式:

0123
0x120x340x560x78

采用小端序存储的形式:

0123
0x780x560x340x12

这里我们一定要切记,我们日常使用的芯片架构采用的是小端序。

深入思考

这里我们必须明确一点,在进程的虚拟地址空间中,一般情况下,堆的地址是逐渐增大的,而栈的地址是逐渐减小的,所以关于字节序可以以下面的定义来看。

小端序指的是低位字节(低有效位)存储在低地址,大端序指的是高位字节存储在低地址。

那么问题就来了,比如有一个int类型的变量x只为0x12345678,按小端序来看,它保存在堆中时,指向这个变量的地址是第一个字节,假如地址为0x100,那么0x100保存的就是780x101保存的是56,这是非常容易理解的。

假如该变量是保存在栈中的,栈的增长方向是高地址到低地址,所以当该int类型变量在栈中时,会自动分配4个字节,地址从高到底,然后该变量的地址就是那个低地址

这样在低地址中,按照小端序来看,就应该保存78,这样做有什么好处呢?

一来字节序和存储的地方无关,二来方便使用指针进行加减和取值,对于一些需要把二进制的数据按照特定格式进行转换的实际场景中非常有用。

指针和字节序实践

记住前面所说的,指针的3层含义,其中不同类型的指针所对应的指针加减操作,以及解引用是不一样的,我们来看个例子:

#include <iostream>
#include <stdio.h>
#include <stdlib.h>

void print_bytes(void* ptr, size_t size) {
    unsigned char* byte_ptr = (unsigned char*)ptr;
    for (int i = 0; i < size; i++) {
        printf("%p: %02X\n", &byte_ptr[i], byte_ptr[i]);
    }
}

int main()
{
    // 栈变量
    int stack_var1 = 0x12345678;
    int stack_var2 = 0xAABBCCDD;
    printf("栈变量1地址: %p\n", &stack_var1);
    printf("栈变量2地址: %p\n", &stack_var2);
    printf("栈变量1的字节序:\n");
    print_bytes(&stack_var1, sizeof(stack_var1));

    // 堆变量
    int* heap_var1 = (int*)malloc(sizeof(int));
    int* heap_var2 = (int*)malloc(sizeof(int));
    *heap_var1 = 0x12345678;
    *heap_var2 = 0xAABBCCDD;
    printf("堆变量1地址: %p\n", heap_var1);
    printf("堆变量2地址: %p\n", heap_var2);
    printf("堆变量1的字节序:\n");
    print_bytes(heap_var1, sizeof(*heap_var1));

    free(heap_var1);
    free(heap_var2);
}


栈变量1地址: 000000E0903DF804
栈变量2地址: 000000E0903DF824
栈变量1的字节序:
000000E0903DF804: 78
000000E0903DF805: 56
000000E0903DF806: 34
000000E0903DF807: 12
堆变量1地址: 000002A511407CB0
堆变量2地址: 000002A511407D70
堆变量1的字节序:
000002A511407CB0: 78
000002A511407CB1: 56
000002A511407CB2: 34
000002A511407CB3: 12

我们来逐一分析:

  1. 传递给print_bytes函数参数的类型是int*,说明在内存中使用4个字节来表示一个int值,然后将该指针强转为char*类型,就说明接下来按字节为单位,进行解引用。

这时我们应该知道,不论是int类型指针还是char类型指针,都是地址,只是在解引用时会取不同长度的字节来进行解析。这里也就可以解释为什么变量是int类型,我们却可以使用char类型指针来指向它。

  1. 按照上面的打印信息,我们可以简单画出内存情况:
高地址
+---------------------+
下一个堆变量
...
000002A511407CB3: 12
000002A511407CB2: 34
000002A511407CB1: 56
000002A511407CB0: 78 <- 堆变量1的地址
...
|         堆区(Heap)       
| 地址向**高地址**增长 ↑ |
+---------------------+
|       未使用区域         |  
+---------------------+
|       栈区(Stack)        
| 地址向**低地址**增长 ↓ |
...
000000E0903DF807: 12
000000E0903DF806: 34
000000E0903DF805: 56
000000E0903DF804: 78 <- 栈变量1的地址
...
下一个栈变量
  
+---------------------+
| 全局/静态变量区(.data) |  
+---------------------+
| 只读代码段(.text)     |  
低地址

从上面打印可以看出,在小端序的情况下,低有效位的字节永远保存在低地址上,也正因为这个,我们不论在堆上的变量还是栈上的变量,使用char类型指针进行++时,总能得到正确结果。

这也能看出栈的内存分配的特殊之处,比如这里必须先分配4个字节,然后取出最后一个分配的字节作为该变量的地址,和堆的分配不是那么直观。

总结

凡事多问几个为什么,总会有收获。