整数与浮点数的内存存储

前言

C语言中数据在内存中的存储规则是基础且核心的知识点,直接影响数据的读取、运算和跨平台兼容性。本文将从整数的原/反/补码存储、大小端字节序、浮点数的IEEE 754标准存储三个核心模块展开,搭配代码实现和经典练习题解析,把底层存储逻辑讲透,适合入门学习和查漏补缺。


一、整数在内存中的存储(原码/反码/补码)

1. 三种二进制表示规则

整数的二进制表示有原码、反码、补码三种形式,均由符号位(最高位,0表示正数,1表示负数)和数值位(其余位,存储数值的二进制)组成,且仅负数需要区分三种形式,正数的原/反/补码完全相同。

• 原码:直接将十进制数按正负翻译成二进制,符号位+数值位的原始形式。

• 反码:针对负数,原码的符号位不变,数值位按位取反(0变1,1变0)。

• 补码:针对负数,反码加1 即为补码,是整数在内存中的实际存储形式。

2. 核心示例(以int型-1为例,32位平台)

int型占4字节(32位),以-1为例看三种形式的转换:

• 原码:10000000 00000000 00000000 00000001

• 反码:11111111 11111111 11111111 11111110

• 补码:11111111 11111111 11111111 11111111

正数示例(int型1):原/反/补码均为 00000000 00000000 00000000 00000001。

3. 为什么内存中统一存储补码?

CPU硬件中仅设计了加法器,减法运算需转换为加法实现,补码的存在解决了符号位参与运算的问题,核心优势有3点:

  1. 符号位和数值位可以统一处理,无需额外硬件区分;

  2. 加法和减法可统一为加法运算(如a-b = a + (-b)的补码);

  3. 补码与原码的转换过程完全相同(补码求反加1即得原码),无需额外电路设计。

4. 注意点

• 无符号整数没有符号位,只有数值位,其原/反/补码均为自身的二进制形式;

• 不同数据类型的取值范围由补码规则决定(如char型8位,signed char范围-128~127,unsigned char范围0~255)。

二、大小端字节序(附代码实现判断)

多字节数据(如int、long、float)在内存中存储时,字节的排列顺序分为大端和小端,这是跨平台开发必须考虑的问题,单字节数据(char)无字节序问题。

1. 大小端定义

• 大端模式(Big Endian):高位字节存放在内存的低地址处,低位字节存放在内存的高地址处(符合人类的阅读习惯);

• 小端模式(Little Endian):低位字节存放在内存的低地址处,高位字节存放在内存的高地址处(计算机底层更常用)。

2. 直观示例(int型1,32位小端平台)

int型1的十六进制为0x00000001,占4字节,地址从低到高为0x100、0x101、0x102、0x103:

• 小端存储:01 00 00 00(低地址存低位字节);

• 大端存储:00 00 00 01(低地址存高位字节)。

3. 常见平台的字节序

• 小端模式:X86架构(日常使用的PC、笔记本)、多数ARM/DSP嵌入式平台;

• 大端模式:KEIL C51、部分ARM硬件(可配置)、网络传输协议(网络字节序为大端)。

4. 代码实现:判断当前平台的字节序

方法1:指针强制转换法(最常用)

核心思路:定义int型变量1,通过char指针仅读取其第一个字节,若为1则是小端,否则是大端(char为单字节指针,仅访问低地址)。

cpp 复制代码
#include <stdio.h>

// 字节序判断函数,返回1表示当前平台是小端模式,返回0表示大端模式
int check_sys()
{
    // 定义一个int类型变量i,并赋值为1
    // 在32位系统中,int占4字节,1的二进制表示为 00000000 00000000 00000000 00000001
    int i = 1;
    
    // &i 取变量i的地址,类型为int*
    // (char*)&i 强制将int*类型的地址转换为char*类型
    // char*是单字节指针,解引用时只会读取该地址对应的1个字节
    // 如果是小端模式,低地址存储的是低位字节,也就是00000001,解引用结果为1
    // 如果是大端模式,低地址存储的是高位字节,也就是00000000,解引用结果为0
    return *(char *)&i;
}

int main()
{
    // 调用字节序判断函数,获取返回值
    int ret = check_sys();
    
    // 根据返回值判断当前平台的字节序
    if (ret == 1)
    {
        // 返回1说明读取到的第一个字节是1,为小端模式
        printf("当前平台为小端模式\n");
    }
    else
    {
        // 返回0说明读取到的第一个字节是0,为大端模式
        printf("当前平台为大端模式\n");
    }
    
    return 0;
}

方法2:联合体法

核心思路:联合体的所有成员共用同一块内存空间,定义包含int和char的联合体,给int赋值1,读取char的值即可判断(char占1字节,访问低地址)。

cpp 复制代码
#include <stdio.h>

int check_sys()
{
    union Un
    {
        int i;  // 4字节
        char c; // 1字节
    } un;
    un.i = 1; // 给int赋值,低地址存储的字节由字节序决定
    return un.c; // 读取低地址的1个字节
}

int main()
{
    int ret = check_sys();
    ret == 1 ? printf("小端\n") : printf("大端\n");
    return 0;
}

三、经典练习题解析(整数存储+字节序)

练习1:signed/unsigned char的输出差异

cpp 复制代码
#include <stdio.h>
int main()
{
    char a = -1;
    signed char b = -1;
    unsigned char c = -1;
    printf("a = %d, b = %d, c = %d\n", a, b, c);
    return 0;
}

输出结果

a = -1, b = -1, c = 255

解析

  1. 变量初始化与内存存储
    char a = -1;
    signed char b = -1;
    unsigned char c = -1;

在绝大多数编译器中,char 类型默认等价于 signed char,占 1字节(8位)。

有符号 char(a 和 b)

• -1 是负数,在内存中以补码形式存储

• 原码:10000001(最高位 1 表示负,后 7 位是数值 1)

• 反码:11111110(原码符号位不变,其余位取反)

• 补码:11111111(反码 + 1)

• 所以 a 和 b 在内存中存储的二进制都是:11111111

无符号 char(c)

• unsigned char 没有符号位,所有 8 位都用来表示数值

• 当赋值 -1 时,会发生溢出处理:

  1. -1 的补码是 11111111

  2. 无符号类型直接将这个二进制解析为正数,11111111 对应的十进制是 255

• 所以 c 在内存中存储的二进制也是:11111111,但解释为 255

  1. printf 打印时的隐式转换

printf("a = %d, b = %d, c = %d\n", a, b, c);

%d 是按 32位有符号整数 打印,所以 8 位的 char 会先被提升为 32 位整数:

对于 a 和 b(有符号)

• 有符号数提升时会进行符号扩展:高位补符号位(即最高位 1)

• 8 位的 11111111 扩展为 32 位:11111111 11111111 11111111 11111111

• 这个 32 位补码对应的十进制是 -1

• 所以 a 和 b 打印结果都是 -1

对于 c(无符号)

• 无符号数提升时会进行零扩展:高位补 0

• 8 位的 11111111 扩展为 32 位:00000000 00000000 00000000 11111111

• 这个 32 位无符号数对应的十进制是 255

• 所以 c 打印结果是 255

  1. 最终输出

a = -1, b = -1, c = 255

练习2:char型的溢出与无符号打印

cpp 复制代码
#include <stdio.h>
int main()
{
    char a = -128;
    printf("%u\n", a); // %u表示以无符号十进制打印
    return 0;
}

输出结果

4294967168(32位平台)

解析(默认编译器中char等价于signed char,占1字节=8位,32位平台)

步骤1:变量a = -128的内存存储(8位signed char)

  1. signed char的取值范围:8位有符号char的补码规则下,取值范围是-128 ~ 127,-128是边界最小值,也是一个特殊补码值(无对应的原码和反码)。

  2. -128的8位补码:直接存储为10000000(这是补码规则的规定,用于唯一表示-128,无需经过"原码→反码→补码"的转换)。

◦ 注:8位有符号char的最大正数127补码是01111111,最小负数-128补码是10000000,刚好占满8位的所有组合。

  1. 最终变量a在内存中存储的二进制:10000000。

步骤2:printf传参时的整数提升(8位→32位)

C语言中,char/short类型在参与表达式运算(包括函数传参)时,会自动发生整数提升,提升为int型(32位),提升规则由是否有符号决定:

• 有符号类型(signed char):符号扩展(高位补符号位,即二进制最高位的值)

• 无符号类型(unsigned char):零扩展(高位补0)

针对本代码:

• a是signed char,存储的二进制10000000的符号位为1(最高位);

• 提升为32位int时,高位24位全部补1,最终32位二进制为:

11111111 11111111 11111111 10000000。

步骤3:%u格式的无符号十进制解析

printf的%u格式符的规则是:将传入的二进制数据按「32位无符号整数」解析为十进制,忽略原有符号属性。

对提升后的32位二进制11111111 11111111 11111111 10000000进行无符号解析:

• 无符号数的计算方式:按位计算2的幂次和

• 计算结果:2^{31} + 2^{30} + ... + 2^8 + 2^7 = 4294967168

步骤4:最终输出结果

控制台最终打印:4294967168

核心知识点总结

  1. 8位signed char的**-128是特殊补码**,直接存为10000000,无原/反码;

  2. char/short传参时必发生整数提升,有符号数按符号扩展补位,保证数值不变;

  3. %u会强制将二进制按无符号数解析,无论原变量是有符号还是无符号;

  4. 本代码的"负数打印出大数",本质是符号扩展后的32位补码,被无符号规则解读的结果,并非变量存储值改变。

延伸:若将char改为unsigned char会怎样?

如果定义unsigned char a = -128,则:

  1. -128会被无符号char按溢出取模处理,存储为128(二进制10000000);

  2. 传参提升时零扩展为32位00000000 00000000 00000000 10000000;

  3. %u打印结果为128。

练习3:无符号char的死循环问题

cpp 复制代码
#include <stdio.h>
int main()
{
    unsigned char i = 0;
    for (i = 0; i <= 255; i++)
    {
        printf("hello world\n");
    }
    return 0;
}

运行结果

死循环,无限打印hello world

解析

这段代码的核心是无符号char的取值范围特性 + 自增的循环溢出,直接导致循环条件永远成立,最终进入死循环,全程围绕无符号整数的溢出规则展开(unsigned char占1字节=8位,32位/64位平台均适用)。

步骤1:明确unsigned char的核心属性

unsigned char是无符号8位整数,没有符号位,8位全部用于表示数值,因此固定取值范围为 0 ~ 255,且无符号整数在C语言中遵循循环溢出规则:当数值超出取值范围时,会自动对最大值+1取模,重新回到取值范围内。

步骤2:拆解for循环的执行逻辑

循环格式:for(初始化; 循环条件; 自增),本代码中关键执行步骤如下:

  1. 初始化:i=0,符合i <= 255,进入第一次循环,打印hello world,执行i++,i变为1;

  2. 正常循环:i从1逐步自增到255,每一次都满足i <= 255,持续打印,直到i=255时,仍执行循环体并触发i++;

  3. 关键溢出:当i=255时,执行i++,数值变为256,超出unsigned char 0~255的范围,触发循环取模:256 % 256 = 0,因此i的值会从255直接变回0;

  4. 循环条件永远成立:i变回0后,再次满足i <= 255,重复上述过程,无限循环打印,永远无法退出。

步骤3:死循环的本质总结

无符号整数没有负数,且溢出后会循环归位,导致unsigned char i的取值永远在0~255之间,循环条件i <= 255恒为真,for循环失去终止条件,成为死循环。

延伸:如何修改让循环正常执行(打印256次后退出)

只需将循环条件改为严格小于,让i到255时触发终止,而非触发自增溢出:

cpp 复制代码
#include <stdio.h>
int main()
{
    unsigned char i = 0;
    // 条件改为i < 256 或 i <= 254,均能打印256次后退出
    for (i = 0; i < 256; i++) 
    {
        printf("hello world\n");
    }
    return 0;
}

(i < 256与unsigned char的取值范围匹配,是最直观的修改方式)
循环执行过程(刚好256次)

  1. 初始化:i = 0 → 满足i < 256 → 打印第1次,i++变为1;

  2. 中间循环:i 从1逐步自增到254 → 每次都满足条件,依次打印,共254次;

  3. 最后一次:i = 255 → 仍满足i < 256 → 打印第256次,执行i++;

  4. 触发终止:i = 255自增后变为256,超出unsigned char范围触发溢出,i 被循环置为0,但此时会先判断循环条件i < 256吗?

❌ 不会!for循环的执行顺序是:先执行循环体→再自增→再判断条件。

当i=255打印完成后,执行i++(溢出为0),接着判断i < 256,虽然条件仍成立,但这是第256次打印后的判断,循环不会再执行!

核心知识点提炼

  1. unsigned char占1字节,取值范围固定0~255,是无符号整数的基础特性;

  2. 无符号整数溢出会循环取模,有符号整数溢出属于未定义行为,二者规则不同;

  3. 使用无符号整数做循环变量时,避免用<= 最大值作为条件,否则会触发死循环。

练习4:数组指针偏移+小端字节序(X86平台必考题)

cpp 复制代码
#include <stdio.h>
// 环境:x86架构(小端)、32位平台、int占4字节
int main()
{
    int a[4] = { 1, 2, 3, 4 };
    int *ptr1 = (int *)(&a + 1);
    int *ptr2 = (int *)((int)a + 1);
    printf("%x, %x\n", ptr1[-1], *ptr2); // %x表示以十六进制打印
    return 0;
}

输出结果

4, 2000000

详细解析

步骤1:分析&a + 1的含义

• a是数组名,代表数组首元素地址,类型为int*;&a是数组的地址,类型为int(*)[4](数组指针,指向4个int的数组);

• 数组指针+1,会跳过整个数组的长度(4*4=16字节),因此&a + 1指向数组a末尾的下一个地址;

• ptr1是int*类型,ptr1[-1]等价于*(ptr1 - 1),指针向前偏移4字节,指向数组最后一个元素4,因此打印4。

步骤2:分析(int)a + 1的含义(核心+难点)

• (int)a:将数组首地址(指针类型)强制转为int型(数值),+1是地址数值的加1(字节级偏移,而非int*的4字节偏移);

• 小端平台下,数组a的首元素1的4字节存储为:01 00 00 00(低地址→高地址);

• (int)a + 1指向首元素的第二个字节,地址开始的4字节内容为:00 00 00 02(数组第二个元素2的存储为02 00 00 00,衔接首元素);

• 该4字节作为int型解析,十六进制为02000000,简化打印为2000000。

四、浮点数在内存中的存储(IEEE 754标准)

C语言中的浮点数(float、double)存储遵循IEEE 754国际标准,与整数的存储规则完全不同,即使是相同的数值,整数和浮点数的内存表示也毫无关联。

1. IEEE 754标准的核心格式

任意一个二进制浮点数V,都可以表示为科学计数法形式:

V = (-1)^S * M * 2^E

其中各部分含义:

• S(符号位):0表示正数,1表示负数,占1位;

• M(有效数字):1 ≤ M < 2,是二进制的有效数字,仅存储小数部分(首位的1省略,节省空间);

• E(指数位):表示指数的大小,为无符号整数,存储时需加上偏移量(消除负数,float偏移127,double偏移1023)。

2. float/double的存储结构(32位/64位)

|--------|-----|-----------|-----------|-----------|-------|
| 数据类型 | 总位数 | 符号位S(第一位) | 指数位E(中间位) | 有效位M(最后位) | 指数偏移量 |
| float | 32位 | 1位 | 8位 | 23位 | 127 |
| double | 64位 | 1位 | 11位 | 52位 | 1023 |

3. 指数位E的三种取值规则

E是无符号整数,但其实际表示的指数可以是负数,通过偏移量和不同取值范围区分三种场景,核心规则:

• E不全为0且不全为1(正常情况):真实指数 = 存储的E - 偏移量;有效数字M = 1.xxx...(xxx为内存中存储的23/52位小数);

• E全为0(表示0或极小值):真实指数 = 1 - 偏移量;有效数字M = 0.xxx...(省略的首位1变为0,避免无法表示0);

• E全为1(表示无穷大或非数值):若M全为0,表示±无穷大;若M不全为0,表示非数值(NaN,Not a Number)。

4. 经典练习题:整数与浮点数的内存解析

cpp 复制代码
#include <stdio.h>
int main()
{
    int n = 9;
    float *pFloat = (float *)&n;
    printf("n的值为: %d\n", n);
    printf("*pFloat的值为: %f\n", *pFloat);
    
    *pFloat = 9.0;
    printf("n的值为: %d\n", n);
    printf("*pFloat的值为: %f\n", *pFloat);
    return 0;
}

输出结果

cpp 复制代码
n的值为: 9
*pFloat的值为: 0.000000
n的值为: 1091567616
*pFloat的值为: 9.000000

分阶段详细解析

这道题的核心是强制类型转换仅改变指针的解读方式,不改变内存中二进制数据的存储形式,int和float的二进制编码规则完全不同(int是原/反/补码,float是IEEE754单精度浮点标准),因此互相解读会得到完全不同的数值,下面逐行拆解代码+底层原理。

先明确基础前提

  1. 假设运行环境为32位系统(int和float均占4字节,这是该题的默认前提,64位系统结果一致)。

  2. 变量n的内存地址:&n是int类型,强制转为float后,pFloat仅表示以float的规则解读n的4字节内存。

  3. IEEE754单精度浮点格式(4字节):符号位(1位) + 指数位(8位) + 尾数位(23位),数值计算规则:(-1)^S \times (1+尾数) \times 2^{(指数-127)}。

  4. int的32位存储:正数直接存补码(与原码一致)。

逐段代码解析

  1. 初始化与指针赋值
    int n = 9;
    float *pFloat = (float *)&n;

• 变量n是int类型,值为9,32位二进制补码:00000000 00000000 00000000 00001001(4字节,低地址到高地址)。

• &n取n的内存地址,类型为int*,通过(float*)强制转为float*后赋值给pFloat。

✅ 关键:内存中存储的二进制数据不变,只是pFloat认为这段内存是float类型的编码。

  1. 第一次打印:n和*pFloat
    printf("n的值为: %d\n", n); // 输出:n的值为: 9
    printf("*pFloat的值为: %f\n", *pFloat); // 输出:*pFloat的值为: 0.000000

• 打印n:以int规则解读内存,直接得到9,无歧义。

• 打印*pFloat:以IEEE754单精度浮点规则解读n的二进制00000000 00000000 00000000 00001001:

◦ 符号位S=0(正数);

◦ 指数位8位=00000000(指数值=0,偏移后为0-127=-127);

◦ 尾数位23位=00000000000000000001001(极小的尾数)。

计算结果:1 * (1+极小值) * 2^{-127},是远小于10^-38的极小浮点数,%f默认保留6位小数,直接显示为0.000000。

  1. 浮点数赋值:*pFloat = 9.0
    *pFloat = 9.0;

• 给*pFloat赋值9.0,本质是以float的IEEE754规则,将9.0的二进制编码写入pFloat指向的内存(即n的内存地址)。

• 先计算9.0的32位IEEE754编码:

9.0的十进制转二进制:9.0 = 1001.0 = 1.001 * 2^3,符合浮点标准1.尾数 × 2^指数。

◦ 符号位S=0;

◦ 指数位=3+127=130 → 二进制10000010;

◦ 尾数位=001(后面补20个0)→ 00100000000000000000000。

因此9.0的32位float二进制编码为:01000001 00010000 00000000 00000000。

✅ 关键:这段二进制会直接覆盖原来n的内存数据,此时n的4字节内存已被替换为上述编码。

  1. 第二次打印:n和*pFloat
    printf("n的值为: %d\n", n); // 输出一个很大的整数(如1091567616)
    printf("*pFloat的值为: %f\n", *pFloat); // 输出:*pFloat的值为: 9.000000

• 打印*pFloat:以float规则解读内存中的9.0编码,直接得到9.000000,无歧义。

• 打印n:以int的32位补码规则解读9.0的float二进制编码01000001 00010000 00000000 00000000:

将二进制转为十进制整数:01000001000100000000000000000000_2 = 1091567616_{10}。

✅ 不同编译器输出一致,因为IEEE754和int补码是通用标准。

最终运行结果(32位系统)
n的值为: 9
*pFloat的值为: 0.000000
n的值为: 1091567616
*pFloat的值为: 9.000000

核心结论:

  1. 强制类型转换不改变内存数据:仅改变编译器对内存数据的解读规则(int/float)。

  2. int和float的编码体系完全独立:相同的二进制,以int和float解读会得到完全不同的数值。

  3. 指针的类型决定解读方式:int*指针指向的内存按int解读,float*按float解读,即使指向同一块内存。

  4. 赋值会覆盖内存数据:给*pFloat赋值时,会按目标类型(float)的编码规则写入二进制,直接覆盖原内存。

  5. 整数和浮点数的内存存储规则完全独立,即使指向同一块内存,以不同类型解析会得到完全不同的结果,本质是编译器对内存二进制的解释方式不同。

扩展:64位系统的影响

64位系统中,int仍为4字节,float仍为4字节(double为8字节),因此该代码的运行结果和32位系统完全一致;若将float改为double,指针强制转为double*,则因内存大小不匹配(int4字节 vs double8字节),会出现内存越界,结果未定义。

五、总结

  1. 整数在内存中统一存储补码,负数需通过原码→反码→补码转换,正数三码合一,无符号整数无符号位;

  2. 多字节数据的存储有大小端之分,X86平台为小端,网络传输为大端,可通过指针法或联合体法判断;

  3. 浮点数遵循IEEE 754标准,按S+E+M的结构存储,E需加偏移量,M省略首位1,与整数的存储规则无关联;

  4. 数据的输出结果不仅取决于内存中的二进制,还与打印格式符和数据类型的符号性相关(如%u会将有符号数解析为无符号数)。


掌握数据的内存存储规则,能帮助我们理解代码的底层运行逻辑,解决数据运算、跨平台移植、指针偏移等实际问题,是C语言进阶的重要基础。

相关推荐
lxl13078 小时前
学习C++(5)运算符重载+赋值运算符重载
学习
zhuqiyua8 小时前
第一次课程家庭作业
c++
只是懒得想了8 小时前
C++实现密码破解工具:从MD5暴力破解到现代哈希安全实践
c++·算法·安全·哈希算法
ruxshui8 小时前
个人笔记: 星环Inceptor/hive普通分区表与范围分区表核心技术总结
hive·hadoop·笔记
慾玄8 小时前
渗透笔记总结
笔记
m0_736919108 小时前
模板编译期图算法
开发语言·c++·算法
玖釉-8 小时前
深入浅出:渲染管线中的抗锯齿技术全景解析
c++·windows·图形渲染
【心态好不摆烂】8 小时前
C++入门基础:从 “这是啥?” 到 “好像有点懂了”
开发语言·c++
dyyx1118 小时前
基于C++的操作系统开发
开发语言·c++·算法
AutumnorLiuu8 小时前
C++并发编程学习(一)——线程基础
开发语言·c++·学习