在我之前的文章里,讲过了整数的二进制表示形式有三种,即原码、反码、补码三种形式以及他们的相互转换方式,具体可见链接: C语言初阶操作符学习笔记,本篇文章将更加深入的讲解数据类型以及他们是如何存储的。
根据数据在内存中的存储方式,我们可以将数据分为两大家族:整形家族和浮点型家族。
整形家族包括:char,short,int,long,long long(C99)。由于字符在内存里存储的是他们的ASCII码值,所以将char类型归结到整型家族里;long long 类型是在C99标准中加入的。这五种整形又被细分为:
char:
char / signed char / unsigned char
short(short int):
signed short / unsigned short (short等价于signed short)
int:
signed int / unsigned int (int等价于signed int)
long(long int):
signed long / unsigned long (long等价于signed long)
long long(long long int):
signed long long / unsigned long long (long long等价于long long short)
在我之前的文章里除了char未介绍,其他整型数据类型的大小以及取值范围,我已经介绍过了,链接放在这里(部分数据类型的大小以及转换说明符的用法),现在我重点说一下char类型。
char是和signed char等价还是unsigned char等价,这在C标准里是未定义的,等价于哪一个取决于编译器,本篇文章使用的是VS2022编译器,所以char=signed char。
类型 | 大小(字节) | 数值范围 |
---|---|---|
char | 1 | -2^7 - 2^7-1 |
浮点型家族包括两类:float 类型和 double 类型。
类型 | 大小(字节) |
---|---|
float | 4 |
double | 8 |
关于这两种类型的精度,取值范围等特性在头文件 float.h中可以找到。
除了char类型变量只占一个字节的内存大小,其他数据类型都至少占据2个以上的字节大小,又因为在内存里以字节为单位存储数据,那么存储的时候接涉先后顺序,举个栗子:
#include
int main()
{
int a = 10;
printf("%d", a);
return 0;
}
代码如上,整型变量a存储整数10于其中,即:变量a向内存申请了4个字节大小的内存空间,用来存储00000000000000000000000000001010(10的二进制补码),二进制每八位代表一个字节,总共32位,共4个字节,那么这四个字节在内存里如何存储呢,哪个字节在前,哪个字节在后呢?这就引出的大端字节序和小端字节序存储方式。
00000000000000000000000000001010转化为16进制为:(二进制4个数字相当于16进制的一个数字)0x00 00 00 0a,这里两个数字就代表一个字节,最右边的0a为低位,最左边的00为高位,通过调试观察内存窗口,可以发现:
每个地址对应一个内存单元即一个字节,所以我们只用看第一行。内存里是将00 00 00 0a倒着存储的,这种存储方式叫做小端字节序;如果内存里是将00 00 00 0a顺着存储的,我们称之为大端字节序。
由此引出小端字节序和大端字节序的定义:
小端字节序:
当一个数据的低位字节内容存储在内存中的低地址,高位字节内容存储在内存中的高地址,这种存储方式叫做小端字节序。
大端字节序:
当一个数据的低位字节内容存储在内存中的高地址,高位字节内容存储在内存中的低地址,这种存储方式叫做大端字节序。
这两种字节序是数据在内存里的两种存储方式,具体是哪一种取决于编译器,通过代码也是很容易测出当前机器是哪种存储方式的,代码如下:
#include
int main()
{
int a = 1;
char* pa = (char*)&a;
if (*pa)
printf("小端");
else
printf("大端");
return 0;
}
了解完整形数据在内存里的存储,浮点型数据在内存里又是如何存储的呢?
我们知道浮点型数据分为两种,一种是float类型,也叫单精度浮点型;还有一种是double类型,也叫双精度浮点型,他们所占据的内存空间前面也提到分别是4字节和8字节,而整形int在内存里也是占据4字节的内存空间,那么他们的写入和读出的方式是相同的吗?下面我就通过代码来说明一下。
思考以下代码:
#include
int main()
{
int a = 5;
float* pa = (float*) &a;
printf("%d\n", a);
printf("%f\n", *pa);
*pa = 5.5;
printf("%d\n", a);
printf("%f\n", *pa);
return 0;
}
这个代码运行起来会是什么结果呢?
通过运行代码,可以得到以下结果:
从结果来看,浮点型数据和整形数据在内存里的存入和读出方式是不同的,那么浮点型数据是怎么存储的呢?
以float类型举例,假如有以下代码:
int main()
{
float a = 5.5f;
return 0;
}
5.5写成二进制是这样写的,分为两步:
(1)整数部分:101
(2)小数部分:.1
所以二进制表示为101.1,而在内存里并不是就将101.1存储进去的,而是先把101.1转化为类似(-1)^S * M * 2^E 形式,然后将S,M,E存贮到内存里。
符号S决定了存储的数据是正数还是负数,0代表正数,1代表负数;
有效数字M代表有效数字,其数值范围为:1≤M<2;
指数E为是2的指数,讲道理是有正有负的,但是IEEE 754(电气和电子工程协会)规定在内存里E为无符号数,即非负数,那么在存储E时,对于float类型的数据来说需要加上中间数127,对于double类型的数据来说需要加上中间数1023,然后存储到内存里。
我们知道float类型占据4个字节大小的内存空间,double类型占据8个字节大小的内存空间,也就是说float有32个bit位,double有64个bit位,在内存里是这样分配给S,M,E的:
(1)对float类型:(2)对double类型:
S就存0或1,E存储指数,由于M都是1≤M<2,所以存储时只需存储小数点后的数字。前文的例子5.5,二进制为:101.1=(-1)^0 * 1.011 * 2^2,所以内存里存储的就是:0 10000001 01100000000000000000000,16进制表示为:
0x40b00000,调试代码观察内存:
我使用的是VS2022编译器,该编译器是小端存储,所以需要倒着读,显然和我们分析的一致。
讲完存储,接着就到了读取阶段。前文提到在存储M时,会省略小数点前的数字1,存储E时会加中间数,所以读取需要考虑的情况就比较复杂,可以分为以下三种情况:
(1) 当E在内存里不全为1或者0时,读取就是存储的逆向,即读取到E区的值减127或者1023得指数E,读取到M区的值加上整数1为有效数字M;
(2) 当E在内存里全为0时,浮点数的指数E等于1-127,即-126为真实值;有效数字M此时不再加1,而是还原成0.xxxx的小数,这样做是为了表示±0,以及接近于0的很小的数字;
(3) 当E在内存里全为1时,这是如果有效数字M全为0,则表示±无穷大(正负号取决于S)。
讲完浮点数的存储与读取,考虑以下代码:
#include
int main()
{
int n = 9;
float *a =(float*) & n;
printf("n=%d\n", n);
printf("*a=%f\n", *a);
*a = 9.0f;
printf("n=%d\n", n);
printf("*a=%f\n", *a);
return 0;
}
会是怎样的输出呢?
我们来分析一下,首先9时整数,转为二进制表示:
00000000000000000000000000001001(正数原码/反码/补码相同)
16进制表示为:0x00000009
所以第一行打印n=9;
由于是将n的地址经过强制类型转换存储到a中,所以从a的视角看他读取到的就是浮点型数据,按章上文讲的浮点型读取方式,S=0,由于E在内存里为全0,所以E=-126,M=0.000000000000000000001001,转化为十进制可以看到是一个很小的数字,所以第二行打印为0.000000;
由于语句*a=9.0f,所以是将9.0存储在a指向的空间里,9.0写为(-1)^S * M * 2 ^ E形式为:(-1)^0 * 1.001 * 2^(3),S=0,M=1.001,E=3,内存里应该是:
01000001000100000000000000000000,十六进制为0x41100000,那么从n的视角看,内存里存放的是整形数据,所以将按整数的读取方式读取数字,所以第三行打印为1091567616;
第四行打印就为9.000000。
通过分析对比整形和浮点型数据在内存里的存储和读取规则,增加我们的内功,进而为我们在以后不论是看代码或者写代码遇到bug时,解决问题的角度就多了一个,久而久之我们也就理解的越来越深刻。