【C语言】C语言内存存储底层原理:整数补码、浮点数IEEE754与大小端(数据内存存储的深度原理与实践)
一、整数在内存中的存储
整数的二进制表示方法
在讲解操作符的时候,我们就讲过了下面的内容:
整数的 2 进制表示方法有三种,即原码、反码和补码。
有符号的整数,三种表示方法均有符号位和数值位两部分,符号位都是用 0 表示 “正”,用 1 表示 “负”,最高位的一位是被当做符号位,剩余的都是数值位。
- 正整数的原、反、补码都相同。
- 负整数的三种表示方法各不相同。
原码、反码、补码的定义
- 原码:直接将数值按照正负数的形式翻译成二进制得到的就是原码。
- 反码:将原码的符号位不变,其他位依次按位取反就可以得到反码。
- 补码:反码 + 1 就得到补码。
为什么整形数据在内存中存放的是补码?
对于整形来说:数据存放内存中其实存放的是二进制的补码。
在计算机系统中,数值一律用补码来表示和存储。
原因在于,使用补码,可以将符号位和数值域统一处理;
同时,加法和减法也可以统一处理(CPU 只有加法器)此外,补码与原码相互转换,其运算过程是相同的,不需要额外的硬件电路。
原码与补码之间的相互转化可以采取一个口诀:取反,+1
二、大小端字节序和字节序判断
当我们了解了整数在内存中存储后,我们调试看一个细节:
#include <stdio.h>
int main()
{int a = 0x11223344;return 0;
}
调试的时候,我们可以看到在a中的 0x11223344 这个数字是按照字节为单位,倒着存储的。这是为什么呢?
1、什么是大小端
大端和小端存储模式
其实超过一个字节的数据在内存中存储的时候,就有存储顺序的问题,按照不同的存储顺序,我们分为大端字节序存储和小端字节序存储,下面是具体的概念:
大端(存储)模式
是指数据的低位字节内容保存在内存的高地址处,而数据的高位字节内容,保存在内存的低地址处。
小端(存储)模式
是指数据的低位字节内容保存在内存的低地址处,而数据的高位字节内容,保存在内存的高地址处。
上述概念需要记住,方便分辨大小端。
内存单元的大小是一个字节
举个例子同时画一幅图来帮助大家理解一下大小端字节序:
我们熟知的数字123(一百二十三),1是百位、2是十位、3是个位,1是这个数的最高位,3是这个数的最低位;类比到内存中0x11223344,其中11就是我们所说的最高位,44便是最低位。
2、为什么有大小端
为什么会有大小端模式之分呢?
这是因为在计算机系统中,我们是以字节为单位的,每个地址单元都对应着一个字节,一个字节为 8 bit 位。但在 C 语言中,除了 8 bit 的
char
之外,还有 16 bit 的short
型、32 bit 的long
型(具体取决于编译器)。另外,对于位数大于 8 位的处理器(例如 16 位或 32 位的处理器),由于寄存器宽度大于一个字节,必然存在如何安排多个字节的问题,这就导致了大端存储模式和小端存储模式的产生。示例
以一个 16 bit 的
short
型变量x
为例,假设其在内存中的地址为0x0010
,值为0x1122
。其中,0x11
为高字节,0x22
为低字节。
- 大端模式:将高字节
0x11
存放在低地址0x0010
中,低字节0x22
存放在高地址0x0011
中。- 小端模式:将低字节
0x22
存放在低地址0x0010
中,高字节0x11
存放在高地址0x0011
中。我们常用的 X86 结构是小端模式,而 KEIL C51 则为大端模式。很多的 ARM、DSP 都为小端模式,有些 ARM 处理器还可以由硬件来选择是大端模式还是小端模式。
3、练习
练习一:请简述大端字节序和小端字节序的概念,设计一个小程序来判断当前机器的字节序。(10分)-- 百度笔试题
#include<stdio.h>
int main()
{int a = 1;if (*(char*)&a == 1)printf("小端\n");else printf("大端\n");return 0;
}
我们可以看出这段代码实际非常简单但是乍一看是不是也很难看出个所以然,下面请大家看图解:
大家也可以采取"邪修":只需记住给我们一个地址0x后面的俩个数是高位字节,他要是放在一个内存空间的起始位置(也就是低地址处)就是大端,低位字节放在低地址处就是小端(双低),当然这个只是帮助初学者快速记住。
练习二:判断下列代码的输出结果
#include <stdio.h>
int main()
{char a = -1;signed char b = -1;unsigned char c = -1;printf("a = %d, b = %d, c = %d", a, b, c);return 0;
}
处理这段代码的时候我们就需要回忆起之前学过的知识点:整型提升,不记得的同学们可以看我之前的博客:解锁C语言魔法符号!探秘操作符的奇妙世界_无符号的四位整数9乘以有符号的四位整数2为多少-CSDN博客https://blog.csdn.net/2501_91731683/article/details/147838049?spm=1001.2014.3001.5501
解析:
a:
char类型默认是signed char类型,同时将-1换成二进制表示
10000000 00000000 00000000 00000001 -1原码
取反,+1
11111111 11111111 11111111 11111111 -1补码
我们知道这是整型类型四个字节32个bit位存储的结果,但是我们这里是char类型
所以a中实际存储的是11111111,我们用%d来打印a便会发生整型提升
因为其是signed类型的便补符号位
11111111 11111111 11111111 11111111 补码
取反,+1
10000000 00000000 00000000 00000001 原码
于是a便打印出了-1b:
b与a的分析一致,故略
c:
还是先写出-1的原码
10000000 00000000 00000000 00000001 -1原码
取反,+1
11111111 11111111 11111111 11111111 -1补码
c中实际存储的是11111111,接下来会发生整型提升
因为其实unsigned类型的,于是补0
00000000 00000000 00000000 11111111 unsigned类型原码、反码、补码相同
计算这个数结果就是255,故c = 255
练习三:判断下列代码的输出结果
#include <stdio.h>
int main()
{char a = -128;printf("%u\n",a);return 0;
}
解析:
%u是以10进制的形式打印无符号的整数(内存中存储的是无符号整数的补码)
char默认是signed char
依旧先写出-128的原码
10000000 00000000 00000000 10000000 -128原码
取反,+1
11111111 11111111 11111111 10000000 -128补码
a中实际存储的是 10000000,接下来进行整型提升
11111111 11111111 11111111 10000000 补码我们这里是%u无符号整数的打印,原码与补码相同
很明显这是一个超级大的数字,我们知道这一点就好了
#include <stdio.h>
int main()
{char a = 128;printf("%u\n",a);return 0;
}
解析:
char默认是signed char
依旧先写出128的原码
00000000 00000000 00000000 10000000 128原码
取反,+1
11111111 11111111 11111111 10000000 128补码
a中实际存储的是 10000000,接下来进行整型提升
11111111 11111111 11111111 10000000 补码
因为其是%u来打印即无符号整数,所以原码与补码相同
很明显这是一个超级大的数字,我们知道这一点就好了
练习四:判断下列代码的输出结果
#include <stdio.h>
#include <string.h>
int main()
{char a[1000];int i;for(i = 0; i < 1000; i++){a[i] = -1 - i;}printf("%d", strlen(a));return 0;
}
#include <stdio.h>
#include <string.h>
int main()
{char a[1000];//默认是signed char取值范围是-128~127int i;for (i = 0; i < 1000; i++){a[i] = -1 - i;}//-1 -2 -3 -4 -5 ... -1000//\0的ASCLL码值是0,我们找到0即可前面-1~-128都正常//当他变成-129时再进行整型提升会被编译器解析成127,然后126...最后变为0就停止了//0后面还有数字,但是strlen不会再统计了printf("%d", strlen(a));//求字符串的长度,其实是统计\0之前的字符个数return 0;
}
解析:
变量分析
char a[1000];
- 定义了一个大小为 1000 的字符数组
a
。- 每个元素的类型是
char
,通常是有符号的(signed char
),取值范围是-128
到127
。for(i = 0; i < 1000; i++)
- 循环从
0
到999
,共执行 1000 次。- 每次循环中,
a[i]
被赋值为-1 - i
。a[i] = -1 - i;
- 计算
-1 - i
的值,并将其赋值给a[i]
。- 由于
char
是有符号的,当值超过范围时会发生溢出。printf("%d", strlen(a));
strlen
函数计算字符串的长度,直到遇到\0
(即值为0
的字符)。关键问题
strlen
的特性:
strlen
函数会一直遍历字符数组,直到遇到\0
(即值为0
的字符)。- 如果数组中没有
\0
,strlen
会继续访问数组后面的内存,导致未定义行为。- 数组
a
的内容:
- 数组
a
的每个元素被赋值为-1 - i
。- 当
i = 255
时,-1 - i = -256
,由于char
的范围是-128
到127
,-256
会溢出为0
(即\0
)。- 因此,数组
a
的第256
个元素(索引255
)的值为0
。输出结果
strlen(a)
会在遇到\0
时停止,因此返回255
。总结
- 这段代码的输出是
255
。- 关键原因是
strlen
函数在遇到\0
时停止,而数组a
的第256
个元素(索引255
)的值为0
。
练习五:判断下列代码的输出结果
#include <stdio.h>
unsigned char i = 0;
int main()
{for(i = 0; i <= 255; i++){printf("hello world\n");}return 0;
}
解析:
我们知道unsigned char类型的取值范围是0~255
而这里循环了256次即超出循环次数,会造成越界访问
最终导致的结果就是死循环打印hello world
#include <stdio.h>
int main()
{unsigned int i;for(i = 9; i >= 0; i--){printf("%u\n",i);}return 0;
}
解析:
经过前面的学习我们知道了%u是以十进制的形式打印无符号整数
unsigned int i就注定了i是>=0的,而这里在i=0之后会继续--
unsigned int
类型的变量,其取值范围是 非负整数(通常是0
到UINT_MAX
,比如 32 位系统中是0 ~ 4294967295
)。它的本质是没有符号位,所有二进制位都用来表示数值。当
i
是unsigned int
类型时,i >= 0
这个条件永远为真:
- 因为无符号整数的最小值就是
0
,不存在负数的情况。即使你写i--
让i
从0
继续 “减 1”,由于无符号整数的溢出回绕规则,0 - 1
会直接变成UINT_MAX
(比如 32 位下是4294967295
),循环条件i >= 0
依然成立。
练习六:判断下列代码的输出结果
#include <stdio.h>
//X86环境 ⼩端字节序
int main()
{int a[4] = { 1, 2, 3, 4 };int *ptr1 = (int *)(&a + 1);int *ptr2 = (int *)((int)a + 1);printf("%x, %x", ptr1[-1], *ptr2);return 0;
}
我们首先来画图分析一下:
我们还要知道%x:以十六进制形式打印数据
我们还要知道一点就是这里面有(int)a + 1这里的a是被强制类型转换为整型,a + 1只跳过1个字节,整型指针+1才是跳过四个字节,整数+1只跳过1个字节所以他的实际内存应该更加细致,如下图所示:
三、浮点数在内存中的存储
常见的浮点数:3.14159、1E10等,浮点数家族包括: float、double、long double 类型。浮点数表示的范围: float.h 中定义。
1、浮点数的存储
根据国际标准 IEEE(电气和电子工程协会)754,任意一个二进制浮点数
V
可以表示成下面的形式:
V=(−1)S×M×2E
- (−1)S 表示符号位,当 S=0,V 为正数;当 S=1,V 为负数
- M 表示有效数字,M 是大于等于 1,小于 2 的
- 2E 表示指数位
举例说明
十进制的
5.0
,写成二进制是101.0
,相当于 1.01×22 。
那么,按照上面 V 的格式,可以得出:
S=0(正数),M=1.01,E=2十进制的
-5.0
,写成二进制是-101.0
,相当于 −1.01×22 ;那么:
S=1(负数),M=1.01,E=2IEEE 754 存储规则
IEEE 754 规定:
- 对于 32 位的浮点数(
float
),最高的 1 位存储符号位 S,接着的 8 位存储指数 E,剩下的 23 位存储有效数字 M- 对于 64 位的浮点数(
double
),最高的 1 位存储符号位 S,接着的 11 位存储指数 E,剩下的 52 位存储有效数字 M正是因为整数和浮点数在内存中编码规则完全不同(整数用补码,浮点数用 IEEE 754 标准拆分符号、指数、尾数),所以即使内存二进制相同,解读结果也会天差地别。
浮点数存的过程
IEEE 754 对有效数字 M 和指数 E 的特别规定
IEEE 754 对有效数字 M 和指数 E,还有一些特别规定。
一、有效数字 M 的存储优化
前面说过,1≤M<2 ,也就是说,
M
可以写成1.xxxxxx
的形式(其中xxxxxx
表示小数部分 )。IEEE 754 规定:
在计算机内部保存M
时,默认这个数的第一位总是 1,因此可以被舍去,只保存后面的xxxxxx
部分。
- 举例:保存
1.01
时,只需要保存01
;读取时,再把第一位的1
加回去,还原为1.01
。- 目的:节省 1 位有效数字。以 32 位浮点数为例,留给
M
只有 23 位;舍去第一位的1
后,相当于可以保存 24 位有效数字(1 位隐含的1
+ 23 位存储的小数部分 )。二、指数 E 的复杂处理
指数
E
的情况更复杂,需要分步骤理解:1. E 的无符号存储与偏移值
首先,E 是一个无符号整数(unsigned int):
- 如果
E
占 8 位(如 32 位浮点数),取值范围是0~255
;- 如果
E
占 11 位(如 64 位浮点数),取值范围是0~2047
。但科学计数法中,
E
可以是负数(比如 0.5=1×2−1 ,此时E=-1
)。因此,IEEE 754 规定:
存入内存时,E
的真实值必须加上一个中间数:
- 对于 8 位的
E
,中间数是 127(E + 127
后再存储 );- 对于 11 位的
E
,中间数是 1023(E + 1023
后再存储 )。2. 举例:E 的存储与还原
以 32 位浮点数为例,假设科学计数法中
E=10
(比如 V=1.xx×210 ):
- 存储时,
E
需要加上中间数127
→10 + 127 = 137
;- 137 的二进制是
10001001
,这就是存入内存的E
值。读取时,再通过 减去中间数 还原真实的
E
→137 - 127 = 10
。三、浮点数的 “无法精确存储” 问题
这样的浮点数存储方式很巧妙,但需要注意:有些浮点数无法被精确保存。
- 举例:
1.2
这类数值,在二进制中是无限循环小数(类似十进制的1/3=0.333...
)。存入内存时,只能存储近似值,导致计算时出现微小误差。- 验证:可以在 VS 等编译器中调试观察,会发现
1.2
的存储和计算结果存在 “些许误差”。通过对
M
的 “隐含 1” 优化、E
的 “偏移存储”,IEEE 754 在有限的二进制位中,尽可能平衡了存储效率和数值范围;但受限于二进制编码方式,某些十进制小数仍无法被精确表示,这也是浮点数计算误差的根源。
浮点数取的过程
指数 E 从内存取出后的三种情况
指数
E
从内存中取出后,可再分成 三种情况 处理:一、常规情况(E 不全为 0 且不全为 1)
当
E
不全为0
或不全为1
时,按以下规则解析:
- 计算真实指数:指数
E
的计算值减去127
(32 位浮点数)或1023
(64 位浮点数),得到真实指数。- 还原有效数字 M:有效数字
M
前加上第一位的1
(因存储时舍去了隐含的1
)。举例(以 32 位浮点数
0.5
为例):
0.5
的二进制形式是0.1
,需调整为科学计数法形式:1.0×2−1(规定正数部分必须为1
,将小数点右移 1 位 )。- 真实指数
E = -1
,存储时需加上中间值127
→ −1+127=126(二进制01111110
)。- 有效数字
M = 1.0
,存储时舍去1
,保留0
,补齐 23 位为00000000000000000000000
。- 最终二进制表示:
0 01111110 00000000000000000000000
(符号位0
表示正数,指数位01111110
,尾数位00000000000000000000000
)。二、E 全为 0 的情况
当
E
全为0
时,解析规则不同:
- 真实指数:指数
E
的真实值为 1−127(32 位)或 1−1023(64 位)。- 还原有效数字 M:有效数字
M
不再加上第一位的1
,而是还原为0.xxxxxx
的小数形式。作用:表示**±0** 或接近 0 的很小的数字(下溢情况 )。
三、E 全为 1 的情况
当
E
全为1
时,需结合有效数字M
判断:
- 如果有效数字
M
全为0
:表示 ± 无穷大(正负由符号位S
决定 )。- 如果有效数字
M
不全为0
:表示 非数(NaN,Not a Number) ,用于表示无效运算结果(如0/0
、sqrt(-1)
等 )。总结
通过对指数
E
的三种情况区分(全 0、全 1、常规),IEEE 754 覆盖了:
- 常规数值的科学计数法表示(常规情况 );
- 0 和极小数的表示(E 全为 0 );
- 无穷大与非数的表示(E 全为 1 )。
这种设计让浮点数在有限的二进制位中,尽可能兼容了数值范围、精度和特殊值的需求,是计算机高效处理实数的基础。
2、题目及其解析
题目
#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;
}
解析
下面,让我们回到一开始的练习先看第1环节,为什么 9 还原成浮点数,就成了 0.000000 ?9以整型的形式存储在内存中,得到如下二进制序列:
0000 0000 0000 0000 0000 0000 0000 1001
首先,将 9 的二进制序列按照浮点数的形式拆分,得到第一位符号位s=0,后面8位的指数E=00000000, 最后23位的有效数字M=000 0000 0000 0000 0000 1001。 由于指数E全为0,所以符合E为全0的情况。因此,浮点数V就写成: V = -1⁰ × 0.00000000000000000001001 × 2⁻¹²⁶ = 1.001 × 2⁻¹⁴⁶ 显然,V是一个很小的接近于0的正数,所以用十进制小数表示就是0.000000。
再看第2环节,浮点数9.0,为什么整数打印是1091567616 首先,浮点数9.0等于二进制的1001.0,即换算成科学计数法是:1.001×2³ 所以:9.0 = (-1)⁰ * (1.001) * 2³, 那么,第一位的符号位S=0,有效数字M等于001后面再加20个0,凑满23位,指数E等于3+127=130,即10000010 所以,写成二进制形式,应该是S+E+M,即
0 10000010 001 0000 0000 0000 0000 0000
这个32位的二进制数,被当做整数来解析的时候,就是整数在内存中的补码,原码正是 1091567616。
四、模拟实现atoi
1、函数介绍
atoi - C++ Reference这是有关atoi的相关文件
这个函数的功能是将我们传入的字符串转换为一个整数
atoi
(ASCII to integer)是 C 语言标准库<stdlib.h>
提供的字符串转整数函数,用于将字符串形式的数字转换为int
类型整数。一、函数原型
c
运行
int atoi(const char *str);
- 参数
str
:指向待转换的字符串的指针(const
表示函数不会修改字符串内容)。- 返回值:转换后的整数(
int
类型);若无法转换,返回0
。二、转换规则
atoi
按以下步骤处理字符串:
跳过前导空白字符
自动忽略字符串开头的空格(' '
)、制表符('\t'
)、换行符('\n'
)等空白字符。处理符号位
- 若遇到
'+'
,表示正数(可省略,默认即为正数)。- 若遇到
'-'
,表示负数(仅允许出现一次)。转换数字字符
从符号位后的第一个字符开始,依次处理'0'~'9'
范围内的字符,直到遇到非数字字符时停止转换。无效输入处理
若字符串中无有效数字(如全是空白字符、以非数字开头),返回0
。三、示例代码
#include <stdio.h> #include <stdlib.h>int main() {printf("%d\n", atoi("123")); // 输出:123(基本数字转换)printf("%d\n", atoi(" -456")); // 输出:-456(含空格和负号)printf("%d\n", atoi("789abc")); // 输出:789(遇到非数字'c'停止)printf("%d\n", atoi("abc123")); // 输出:0(无有效前导数字)printf("%d\n", atoi("")); // 输出:0(空字符串)return 0; }
四、注意事项
溢出问题
若转换结果超出int
类型的取值范围(通常为-2147483648
~2147483647
),atoi
的行为是未定义的(可能返回错误值)。
- 替代方案:使用
strtol
函数,支持检测溢出并返回错误码。进制限制
atoi
仅支持十进制数字字符串,不处理八进制(0
开头)或十六进制(0x
开头)格式。总结
atoi
是处理字符串到整数转换的便捷工具,适合简单场景,但需注意输入合法性和数值范围。复杂场景下,建议使用更健壮的strtol
函数。
2、模拟实现
#include<stdio.h>
#include<assert.h>
#include<ctype.h>
#include<limits.h>//整数最大值
int my_atoi(const char* str)
{assert(str != NULL);//处理传进来的是空格的情况if (*str == '\0')return 0;//处理有空格的情况while (isspace(*str)){//isspace就是将你要判断的字符放进去//要是为真会返回一个int类型的数字,不是的话我们就++进入下一个数字str++;}//处理有+/-号的情况int flag = 1;//代表是个正数if (*str == '+'){flag = 1;str++;}else if (*str == '-'){flag = -1;//表示是负数str++;}//判断当前字符是数字字符还是非数字字符long long ret = 0;while (*str != '\0'){//判断是否为数字字符if (isdigit(*str)){//将"123"变为123ret = ret * 10 + flag * (*str - '0');//解决超出范围与小于范围的数if (ret > INT_MAX){return INT_MAX;}if (ret < INT_MIN){return INT_MIN;}}else{printf("不是合法转化");return 0;//123a456}str++;}if (*str == '\0'){printf("合法的转化");}return (int)ret;//将long long转换为int
}
int main()
{const char* str1 = " ";const char* str2 = " -123a4567";int ret1 = my_atoi(str1);int ret2 = my_atoi(str2);printf("%d", ret1);printf("%d", ret1);return 0;
}
五、练习
练习一
#include<stdio.h>
int main()
{unsigned char a = 200;unsigned char b = 100;unsigned char c = 0;c = a + b;printf("%d %d %d %d", a, b, a + b, c);
}
我们根据这幅图来分析,很明显a+b就是一个直接打印不需要其他的处理,当我们处理c时发现c的取值范围是0~255很明显超出了300,我们可以直接从这幅图来看,当c=255时再给他加别的数字,他会继续绕着这个球,从0重新开始绕,所以下面的打印结果就是44,而a与b都在范围内,所以打印结果经过整型提升等一系列操作后结果保持不变,再者用%d来打印a+b与c无关是可以直接打印结果的也就是300
练习二
在小端机器中下列代码的运行结果是什么
#include<stdio.h>
int main()
{int a = 0x11223344;char* pc = (char*)&a;*pc = 0;printf("%x\n", a);return 0;
}
我们依旧画图来分析一下:
我们将*pc置为0即将44改为00。
练习三
我们将a在内存中补齐实际是0x 00 00 12 34,b实际拿到的是第一个00