回溯

引言

在计算机系统中,数据的存储是非常基础但极其重要的一部分。理解数据在内存中的存储机制不仅有助于我们编写更高效的代码,还可以帮助我们理解一些计算机运行中的底层细节。这篇博客将为大家详细讲解整数和浮点数是如何存储在内存中的,并且会解释大端字节序与小端字节序的区别,最后介绍内存对齐的重要性及其实现方式。

1. 整数在内存中的存储

整数在内存中的存储主要有三种二进制表示方法:原码反码补码。在深入理解这三种表示方法之前,我们首先要了解,计算机中的整数是以二进制形式存储的。

1.1 原码、反码和补码
  • 原码:原码是将数值直接按照正负数的形式翻译成二进制。最左边的位(最高位)用于表示符号,0表示正数,1表示负数,其他位用于表示数值。例如,+5 的原码是 00000101,而 -5 的原码则是 10000101
  • 反码:反码是对原码的一种变形形式,它的符号位保持不变,其他位按位取反。例如,-5 的反码是 11111010
  • 补码:补码是在反码的基础上加 1 而得到的。例如,-5 的补码是 11111011。在计算机系统中,数据一律用补码来表示和存储,这样做的好处是可以将符号位和数值位统一处理,同时加法和减法也可以统一处理。
1.2 为什么使用补码?

在计算机系统中,使用补码来表示整数有几个显著的优势:

  1. 统一处理符号位和数值位:补码的表示方式可以将符号位和数值部分一起进行运算,这简化了计算机的硬件设计。
  2. 加法和减法的统一性:在补码的表示方式下,加法和减法可以通过相同的硬件电路实现,CPU 只需要一个加法器。
1.3 示例代码:整数的存储

下面的 C 代码展示了正负整数在内存中的存储方式。

代码语言:javascript代码运行次数:0运行复制
#include <stdio.h>

void print_binary(int num)
{
    for (int i = 31; i >= 0; i--)
    {
        printf("%d", (num >> i) & 1);
        if (i % 8 == 0) printf(" ");
    }
    printf("\n");
}

int main()
{
    int positive = 5;
    int negative = -5;

    printf("正数 5 的补码形式:\n");
    print_binary(positive);
    
    printf("负数 -5 的补码形式:\n");
    print_binary(negative);

    return 0;
}

运行该代码可以看到 5-5 在内存中的二进制表示,其中负数的补码形式通过对正数按位取反加 1 来得到。

2. 大小端字节序和字节序判断

当数据在内存中存储时,尤其是超过一个字节的数据(如 int 型或 long 型),存储的顺序变得非常重要,这就涉及到 大端字节序(Big-endian)小端字节序(Little-endian) 的概念。

2.1 什么是大端和小端?
  • 大端模式(Big-endian):数据的高位字节内容保存在内存的低地址处,而数据的低位字节内容保存在内存的高地址处。简单来说,就是先存储“大的部分”。
  • 小端模式(Little-endian):数据的低位字节内容保存在内存的低地址处,高位字节内容保存在内存的高地址处,简单来说,就是先存储“小的部分”。

举个例子,假设有一个 16 位的数值 0x1122,在大端模式下,它会被存储为:

代码语言:javascript代码运行次数:0运行复制
地址 0x0010: 0x11
地址 0x0011: 0x22

而在小端模式下,则会被存储为:

代码语言:javascript代码运行次数:0运行复制
地址 0x0010: 0x22
地址 0x0011: 0x11
2.2 为什么会有大端和小端之分?

大小端模式的产生主要与处理器的设计有关。在 X86 结构中,我们普遍采用小端模式,而在一些特殊的嵌入式系统中则使用大端模式。此外,很多 ARM 处理器可以由硬件来选择是大端还是小端模式。

大小端的存在并没有孰优孰劣,更多是与硬件架构的历史和习惯有关。在实际编程中,判断字节序有助于编写跨平台兼容的代码。

2.3 字节序的判断小程序

以下代码可以用来判断当前机器的字节序:

代码语言:javascript代码运行次数:0运行复制
#include <stdio.h>

int check_sys()
{
    int i = 1;
    return (*(char *)&i);
}

int main()
{
    int ret = check_sys();
    if(ret == 1)
    {
        printf("小端字节序\n");
    }
    else
    {
        printf("大端字节序\n");
    }
    return 0;
}

在上面的代码中,我们通过将一个整型变量 i 的地址转换为字符指针,并检查其第一个字节的值来判断机器的字节序。如果第一个字节是 1,则说明是小端模式,否则是大端模式。

2.4 示例代码:大小端存储的区别

以下代码展示了大小端存储模式在内存中的差异:

代码语言:javascript代码运行次数:0运行复制
#include <stdio.h>

void print_bytes(int num)
{
    unsigned char *ptr = (unsigned char *)&num;
    for (int i = 0; i < sizeof(int); i++)
    {
        printf("字节 %d: 0x%02x\n", i, ptr[i]);
    }
}

int main()
{
    int num = 0x11223344;
    printf("整数 0x11223344 在内存中的存储情况:\n");
    print_bytes(num);
    return 0;
}

在小端系统上,输出结果为:

代码语言:javascript代码运行次数:0运行复制
字节 0: 0x44
字节 1: 0x33
字节 2: 0x22
字节 3: 0x11

而在大端系统上,输出结果则为:

代码语言:javascript代码运行次数:0运行复制
字节 0: 0x11
字节 1: 0x22
字节 2: 0x33
字节 3: 0x44
3. 浮点数在内存中的存储

浮点数的存储较整数要复杂得多,因为它们需要同时存储符号位、指数和有效数字部分。在计算机中,浮点数通常采用 IEEE 754 标准来表示。

3.1 浮点数的表示方法

根据 IEEE 754 标准,任意一个二进制浮点数 V 可以表示为:

  • S:符号位,当 S=0 时,V 为正数;当 S=1 时,V 为负数。
  • M:有效数字,通常是大于等于 1 小于 2 的小数。
  • E:指数部分。
3.2 浮点数的存储结构

浮点数按照 IEEE 754 标准存储时,32 位的浮点数(即单精度浮点数)和 64 位的浮点数(即双精度浮点数)有不同的结构:

  • 32 位浮点数(单精度)
    • 符号位 S:1 位
    • 指数 E:8 位
    • 有效数字 M:23 位
  • 64 位浮点数(双精度)
    • 符号位 S:1 位
    • 指数 E:11 位
    • 有效数字 M:52 位
3.3 浮点数的编码示例

例如,考虑一个十进制数 -5.75,我们想将其编码为 32 位浮点数:

  1. 符号位 S:由于数是负数,符号位 S 为 1
  2. 转换为二进制5.75 的二进制形式是 101.11
  3. 标准化形式:将 101.11 写成 1.0111 × 2^2
  4. 指数 E:指数部分为 2,为了适应偏移量表示,单精度浮点数的偏移量是 127,所以 E = 2 + 127 = 129,即 10000001
  5. 有效数字 M:有效数字部分为 0111,后面补 0,直到总共占 23 位。

最终,-5.75 的二进制表示为:

代码语言:javascript代码运行次数:0运行复制
1 | 10000001 | 01110000000000000000000
3.4 示例代码:浮点数的存储

以下代码展示了浮点数在内存中的存储:

代码语言:javascript代码运行次数:0运行复制
#include <stdio.h>

void print_float_bits(float num)
{
    unsigned char *ptr = (unsigned char *)&num;
    for (int i = 0; i < sizeof(float); i++)
    {
        printf("字节 %d: 0x%02x\n", i, ptr[i]);
    }
}

int main()
{
    float num = 5.75;
    printf("浮点数 5.75 在内存中的存储情况:\n");
    print_float_bits(num);
    return 0;
}

运行该代码,可以看到浮点数 5.75 在内存中的表示形式。浮点数的存储涉及到符号位、指数和有效数字的组合,因此其内存表示比整数更复杂。

3.5 特殊情况:0、无穷和 NaN

  • 零的表示:当符号位为 01,指数部分和有效数字部分全为 0 时,表示 +0-0
  • 无穷大和负无穷大:当指数部分全为 1,有效数字部分全为 0 时,表示正无穷(+∞)或负无穷(-∞)。
  • NaN(Not a Number):当指数部分全为 1,有效数字部分不全为 0 时,表示 NaN,用于表示未定义的结果(例如 0/0√-1)。
4. 内存对齐

内存对齐是指数据在内存中的存放方式,需要遵循特定的对齐边界规则。内存对齐的目的是为了提高 CPU 访问数据的效率,因为大多数处理器在对齐边界上访问数据时效率更高。

4.1 为什么需要内存对齐?

内存对齐的主要原因有以下几点:

  1. 性能:现代 CPU 在读取内存数据时,如果数据地址是对齐的,读取速度会更快。对于非对齐的数据,CPU 可能需要执行多次内存访问,导致性能下降。
  2. 硬件限制:某些架构的 CPU 只能从特定的对齐地址读取数据,否则会产生硬件异常。
4.2 内存对齐的规则

内存对齐通常遵循以下规则:

  • 数据类型的对齐边界等于数据类型的大小。例如,int 类型通常是 4 个字节,因此它必须位于 4 的倍数的地址上。
  • 结构体的总大小也应该是其最大成员对齐边界的整数倍,这样可以确保结构体数组中的每个元素都能正确对齐。
4.3 示例代码:内存对齐

以下代码展示了结构体在内存中的对齐情况:

代码语言:javascript代码运行次数:0运行复制
#include <stdio.h>

struct Example
{
    char a;
    int b;
    short c;
};

int main()
{
    struct Example ex;
    printf("结构体 Example 的大小: %lu\n", sizeof(ex));
    return 0;
}

在大多数编译器中,结构体 Example 的大小可能是 12 字节,而不是简单的所有成员大小之和(1 + 4 + 2 = 7 字节)。这是因为编译器会插入填充字节来确保每个成员的对齐。

  • char a 后面会有 3 个填充字节,使得 int b 可以位于 4 字节对齐的地址。
  • short c 也会被对齐到 2 字节的边界上。
4.4 使用 #pragma pack 指令

在一些情况下,我们希望取消编译器的默认对齐方式,可以使用 #pragma pack 指令来更改对齐规则。例如:

代码语言:javascript代码运行次数:0运行复制
#include <stdio.h>

#pragma pack(1)
struct PackedExample
{
    char a;
    int b;
    short c;
};
#pragma pack()

int main()
{
    struct PackedExample ex;
    printf("结构体 PackedExample 的大小: %lu\n", sizeof(ex));
    return 0;
}

使用 #pragma pack(1) 后,结构体的大小将变为 7 字节,因为编译器不再插入填充字节。但是,这样做可能会导致性能下降,因为读取未对齐的数据需要更多的 CPU 周期。

5. 结论

数据在内存中的存储是理解计算机系统的基础之一。

整数的存储涉及到原码、反码和补码的概念,而大小端字节序则影响了多字节数据的存储顺序。

浮点数的存储更为复杂,需要考虑符号位、指数和有效数字的表示。

内存对齐则是为了提高 CPU 访问数据的效率,通过对齐边界来优化内存访问性能。通过对这些内容的深入理解,我们可以更好地编写高效且可靠的程序,并理解程序在底层是如何运行的。

Now,以上便是本期回溯C语言的全部内容啦,希望对大家有所帮助。同时也欢迎大家在评论区与我交流,共同进步!

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。 原始发表:2024-11-20,如有侵权请联系 cloudcommunity@tencent 删除数据硬件存储二进制内存