有很多人会说,数据类型不是在高级语言编程中都会讲的吗,C,C++,C#,JAVA等都讲的很清楚,为什么还要提出来说呢?其实我想说,在几乎所有的高级语言编程书籍中,都将数据类型的讲解与底层割裂开来,对数据的大小端只字不提(C++之父写的书讲到了大小端),对浮点数的编码也没有讲清楚,什么是网络字节序和字节对齐更没有讲解,哪些数据类型需要考虑网络字节序,哪些不需要,有符号数和无符号数到底有什么区别?其实,所有定义的数据类型,都是基于底层而来,所以有必要从底层来分析理解数据类型,以下结合Intel文档,再结合一些例子来加深理解。
数据在计算机中的表示为补码,但是对于多个字节来表示的整数(注意是整数,不是字节也不是浮点数,也不是字符串,字节是最小数据单位当然没有字节序说法,字符串是多个字节组成的序列也不用考虑字节序,浮点数有专门的浮点编码规范也不用考虑字节序),由于历史原因,没有做统一,有两种表示方法,即大端和小端。讲大小端的文章很多,其实我认为一句就能记住。
little理解为低位数,把endian理解为低地址,合起来就是低位数位于内存的低地址端。例如:我们写一段代码来测试一下就明白:
int data = 0x12345678;
unsigned char* p = (unsigned char*)&data;
unsigned char data0 = p[0];
unsigned char data3 = p[3];
这里我们将整数的表示按字节来读取,这里12是高位,78位是低位,我们取地址看一下:
p的地址是0x0019f1d8,78是低位,地址为0x0019f1d8
p+3存储的地址是0x0019f1db,12的地址是0x0019f1db
这里可以看出,低位数78在内存的起始地址0x0019f1d8比高位数12在内存的起始地址0x0019f1db小,即低位数在低地址端。
同样,我们将Big理解为高位数,endian理解为低地址,合起来就是高位数位于内存的低地址端,和Little-endian相反。
由于有以上两种内存表示整数的顺序方法,所以,在网络通信的过程中,约定使用大端方式,即网络字节序就是大端字节序。
基本的数据类型有字节,字,双字,四字,八字。一个字节由8个位组成。一个字两个字节,双字四字节,四字八字节,八字十六字节。IA-32架构指令子集在这些基础数据类型上的操作无须额外的操作类型。如下图所示:
下图所示为数据在内存中的布局(little-endian)
关于字节的对齐,在上一篇文章中已经详细讲解了,这里不再赘述。
数值数据类型分为整数和浮点数,其中整数又分为有符号整数无符号整数,浮点数编码的最高位就表示符号,所以,浮点数都是有符号,没有无符号这一说,不要和整数混淆。
整数分为有符号数的字节,字,双字,四字,八字和无符号数的字节,字,双字,四字和八字。高级语言中的整型都分别与之对应。这里要正确理解有符号无符号的真正含义,有符号数的最高位表示符号,0表示+,1表示-,而无符号数的最高位不表示符号,而是作为数的一部分,也就是我们默认为这种数不会出现负。数据在计算机中统一表示为补码,不管你是正还是负,他的值都是确定的。那么这个数该表示正还是负呢,这个选择权就交给程序员去做,你认为这个数应该有正有负,那你就声明为有符号数,这个时候编译器就将补码表示的数解析为有符号的表示,如果你认为这个数不可能出现负值,你就将它声明为无符号数,这个时候编译器就将补码表示的数解析为无符号的表示。
下面通过示例来展示有符号和无符号数:
我们在内存里面有0xFFFFFFF这个值,如果我们解析为无符号数,它就表示0xFFFFFFFF这个数,而如果我们解析为有符号数,它就表示-1.
内存里面是这个值:0x0019F1D8 ff ff ff ff
unsigned int data = 0xFFFFFFFF;//在VC++中用进六进制表示时显示的是补码
这个时候data显示为4294967295(16进程为0xFFFFFFFF),即正数的补码就是它自己。
int data = 0xFFFFFFFF;
这个时候data就显示为-1,因为-1的补码是0xFFFFFFFF,内存里面仍然是0xFFFFFFFF。
浮点数据类型采用IEEE标准754二进制浮点数标准,分为半精度浮点数(16位表示),单精度浮点数(32位),双精度浮点数(64位),双精度扩展(80位)。注意,浮点数都是有符号,没有无符号这一说,由于有自身编码标准,不考虑字节序。高级语言中的浮点与之一样。
指针是一个内存位置的地址。
两者结构如下图:
在64位模式下,近指针就是指64位,超过64位又有不同,分为3种模式:
如果操作数是32位,则其组在为16位段描述符加16位偏移;
如果操作数是32位,则其组成为16位段描述符加32位偏移;
如果操作数是64位,则期组成为16位段描述符加64位偏移。
如下图:
一个位域数据类型是一个连续的位序列。可以开始于内存的任意字节的任意位的位置,可包括多达32个位。
字符串数据类型是由位,字节,字或者双字组成的连续序列。位字符串可以开始于任意字节的任意位的位置,可以包括多达2^32-1个位。字节字符串可以包括字节,字,双字,可包括多达2^32-1个字节(4G bytes)。所以,字符串不考虑字节序,它是连续字节序列。
Intel 64和IA-32对于SIMD的操作定义了64位和128位的packed数据类型。这些数据类型由基础数据类型和基础类型解析器组成。
64位的packed数据类型在Intel的MMX技术中引入到IA-32架构,在MMX寄存器上操作。由packed字节,packed字,packed双字组成。当在这些packed数据类型上执行数值操作的时候,这些类型将被解析为连续的字节,字,双字。如下图所示:
128位的packed数据类型在Intel的SSE扩展技术中引入到IA-32架构,用于SSE2,SSE3,SSSE3。主要用于128位的XMM寄存器和内存的操作。由基础的packed字节,packed字,packed双字,packed四字组成。当在MMX寄存器的这些基础数据类型上完成SIMD操作的时候,这些数据类型将被解释为packed或者标量的单精度或双精度的浮点值,或者解释为packed字节,packed字,packed双字,packed四字的整数值。
如下图所示:
BCD指二进制编码的十进制数,由4个位的值从0-9的值组成的整数,也就是用4个位来表示1个十进制数。IA-32定义了在一个或多个通用寄存器上的操作,或者一个或多个X87浮点寄存器上的操作。如下图所示:
当在位于通用寄存器上操作BCD整数的时候,BCD的值可以被解压(每字节一个BCD的数字),或者压缩(一个字节两个BCD数字),未解压的BCD数是字节的低四位,高四位在加和减过程中可以是任意值,但是在乘法和除法过程中,必须是0。压缩的两个BCD整数,允许合并成一个字节。在这儿,高半字节的数字比低半字节的数字更重要。
当在位于X87浮点处理单元的数据寄存上操作BCD整数的时候,BCD值被压缩成一个80位的格式称为十进制整数,在这种格式中,前面9个字节存储了18位BCD数字,每字节两个BCD数字。最低有效数字包含在字节0中,最高有效数字包含在字节9中。字节10的最高有效位为符号位(0表示正,1表示负,0位到6位不必考虑),负的十进制整数不是以二进制补码存储,它们与正整数区分的仅符号位。可以按这种方式编码的十进制数的范围是10^18 + 1到10^18 – 1。十进制整数仅存在于内存当中,当一个十进制整数载入一个F87浮点处理单元数据寄存器中,它将自动转抽象为double扩展精度浮点格式,每个整数都确切的对应一个double扩展精度浮点数。压缩的十进制整数编码如下图:
实数系统由负的无穷天到正的无穷大组成(-∞,+∞),因为任何计算机的寄存器的大小和数量都是有限的,仅有实数的一个字集(浮点数)可以用在计算中。IA-32系统支持实数表示的近似值。实数表示的精度与表示范围由IEEE标准754浮点格式决定。
为了增强实数计算的带度和效率,计算机和微处理器表示浮点数的典型方法是采用二进制浮点格式。以这种格式,一个浮点数由3部分组成:一个符号,一个有效位数(significand),以及一个指数。符号是一个二进制位,表示这个数是正还是负,0表示正,1表示负。有效位数又由两个部分组成:一个1比特的二进制整数(又称为J比特)和一个二进制的分数。整数位通常不出现,是一个隐含值。指数部分是一个二进制整数来乘以一个2为底的幂。
下图展示了十进制表示的实数178.125以IEEE-754标准表示的浮点数的存储方式:
实数和浮点数表示如下图:
4.8.2.1 规格化数字(Normalized Numbers)
在大部分情况下,浮点数被编码成规格化形式。也即除了0,有效位数由1和跟在后面的分数组成:
1.fff…ff
对于小于1的数,前导0被去掉了。(每取消一个前导0,指数减少1)
以规格化格式表的数字最大化了最高有效数字的数量,这些最高有效数字可以适配给定的宽度的最高有效位数。总之,一个格范化数字由一个表示1到2之间实数的有效位数和一个指定了数字二进制点的指数构成。
4.8.2.2 偏指数(Biased Exponent)
在IA-32架构中,浮点数被编码成偏置的形式。也就是说,向指数加入一个常量使得偏置指数永远是正数。偏置常量的值取决于用浮点数表示指数时可获得的位数。选择用偏置指数使得最小的规格化数字可以互换也不会发生溢出。
各种实数和特殊值可以编码成IEEE-754标准的浮点格式。这些实数和特殊值通常分为以下几类:
有符号0(Signed zeros) 非规格化有限数(Denormalized finite numbers)规格化有限数(Normalized finite numbers)有符号无限数(Signed infinities)NaN类型数(NaNs)(Not a Number)无限数(Indefinite numbers)下图展示了如何编码来表示数字和非数字来适配实数的连续性。这里展示的是IEEE的单精度浮点数的编码格式。S表示符号位,E表示偏指数,Sig表示有效位数,指数以10进制数表示。整数位是有效位数,尽管在单精度浮点数中它是默认的。
0可以表示成+0或-0,取决于符号位。两个的编码在数值上都是相等的。0结果取决于所执行的操作和采用的舍入模式(rounding mode)。符号0已经应用于协助区间运算(interval arithmetic)。符号0可能表示下溢发生的方向,也可能表示往复无穷大的符号。
非0,有限数分为两类:规格化和非规格化。规格化有限数组成了所有非零的有限值,这些值可以编码成0到+∞之间的规格化实数。当浮点数无限的接近0的时候,规范化数字格式不在用于表示数字。这是因为指数范围并非足够大,可以补偿换转二进制点到恰好的位置来取消前导0.
当偏置指数是0的时候,更小的数字只能通过有效位数的整位(或许其它的前导位)来表示。在这个范围内的数字称为非规格化数字(denormalized numbers)。带有前导0的非规格化数字允许表示更小的数。尽管如此,这种表示可能会导致数据精度的丢失(前导0减少了最高有效位的位数)。
当执行规格化浮点计算的时候,IA-32架构处理器在规格化数字上操作会输出规格化数字作为结果。非规格化数表示一个下溢的条件。
一个非规格化数是通过一个称为平缓下溢(gradual underflow)的技术来计算的。下图以单精度浮点数来展示非规格化数的平缓下溢处理过程:
最极端的情况是,非规格化数的所有最高有效位都被前导0切换到最右侧从而创建了一个0结果。Intel 64和IA-32处理器以下面的方式来处理非规格化数:
尽量避免通过非规格化数来创建非规格化数。提供浮点下溢异常来允许程序员检测非规格化数的创建。提供一个浮点非规格化操作异常来允许过程或程序检测非规划化数作为源操作数参与计算的情况。两个无限大-∞和+∞,分别表示实数的负无穷大和正的无穷大,这可以用浮点数来表示。”无限”总是可能用一个有效位数1.00...00 (整数位可能是默认的)和一个允许指定格式的最大偏置指数来表示。无穷的符号是可以考察的,比较也是可能的。无限从防射意义(affine sense)上来讲总是可以被解释:-∞总是比任何有限的数都小,+∞总是比任何有限的数都大。在无效上的算法总是精确的,例外的情况只有使用一个无限的源操作数来组成一个非法的操作数。非规格化数也可能表示一个下溢条件,这两个无效的数可能表示一个下溢条件的结果。这个时候,这个非规格化数的计算结果有一个比所选择的结果所能表示的最大偏置指数还大的偏置指数。
非数字不是实数行的一部分,非数字在浮点编码空间上中位于实数行的末端之上,这个空间包括带有最大偏置指数的的任何值和一个非0分数(非数字忽略符号位)。
IA-32定义两种非0:安静的NaNs(quit NaNs,QNaNs)和信号NaNs(signaling NaNs,SNaNs)。QNaNs是一个带有最高有效位分数位设置的NaN。SNaNs是一个带有最高有效位分数位清除的NaN。QNaNs允许参与大部分运 算操作而不会发送异常信号,而当SNaNs出现在算术运行中的时候会发出一个浮点非法运算异常信号。SNaNs典型的用法是作为阱井(trap)或者是调用(invoke)异常处理。它们必须被插入到软件中,也就是说,处理器从来不会将NaNs作为一个浮点操作的结果。
当在一个QNaNs和或SNaNs上执行浮点操作的时候,操作的结果要么是一个传递给目标操作数的QNaN,要么是作为一个浮点操作的异常产生,这取决于以下规则:
如果源操作数之是一个SNaNs并且这个浮点的非法操作异常被标识,然后一个浮点非法操作异常就会产生,目标操作数不会存储任何结果;如果源操作数之一是QNaNs并且这个浮点非法操作被标识,这个操作会产生一个非法操作异常,同样目标操作数不会存储任何结果。(这儿有两个异常,非法操作异常(#I,Invalid Operation Exception),非法运算操作异常(#IA,Invalid Arithmetic Operation Exception))。如果其中一个源操作数是或两个都是NaNs且浮点非法操作异常被标识,这个结果就如下图所示。SNaNs转化成QNaNs是通过设置SNaNs的最高有效位的分数位为1来实现的。如果两个源操作数都不是NaNs而又产生了浮点非法操作异常,结果就是常见的一个QNaNs浮点无限数。下图展示处理NaNs的规则:
除了上面讲的规则这外,NaNs按QNaNs和SNaNs编码,软件可以自由的使用有效位数中的位来实现任何其它目的。QNaNs和SNaNs两者都可编码来运行和存储数据,例如诊断信息。通过标识非法操作异常,程序员可以采用SNaNs来向异常处理设置阱井。这种通用方法和大量可得的NaNs值为经验丰富的程序员提供了处理各种特殊情况的工具。例如,编码器可以使用SNaNs来表示未初始化的实数数组元素。编码器可以使用SNaNs来预初始化有效位数包含下标(相对位置)的数组元素。而后,如果程序试图访问未初始化的元素,它可以使用编译器放置的NaNs值。如果非法操作异常被标识,就会发生一个中断,系统会调用异常处理器。异常处理器可以判断程序访问了那个元素,因为异常处理器指针的操作数地址字段指针指向了NaN,而这个NaN包含访问元素的下标。
QNaNs通常用于加速调试。在程序的早期测试阶段,程序可能有各种错误。每当调用异常处理器的时候,异常处理信息都会写入到诊断信息并保存在内存中。在保存了诊断信息以后,异常处理器将提供一个QNaN来作为错误的指令的结果,且这个QNaN会指向与之关联的诊断信息的内存区域。然后程序继续运行,为每个错误都创建一个不同的QNaN。至程序结束,这些NaNs结果就可以作为那些错误发生时存储的诊断信息来访问。这样,程序通过一次运行测试就可以诊断并修正多个错误。
在那些将计算结果作为更深入计算的嵌入应用中,一个未检测到的QNaN会使得所有接下来的结果都无效。这样的应用应该定期的检测QNaNs值,如果发现QNaNs应该提供可修复的机制。
对于浮点数编码(单精度,双精度,双精度扩展),保留了一个唯一的值(QNaNs)来表示一个专门的QNaN浮点无限数。X87浮点处理单元和SSE/SS2/SS3/SSE4.1/AVX扩展对于一些未标识浮点异常的值返回对应的浮点无限数。
4.8.3.8 半精度浮点操作
半精度浮点数不会被处理器直接用在算术操作中。VCVTPH2PS, VCVTPS2PH这两个指定用于将半精度浮点数转换为单精度浮点数。
在执行浮点操作的时候,只要可能,处理器就会产生一个无限精度的目标浮点数格式的结果(单精度,双精度,扩展精度)。然而,IEEE-754 标准浮点编码只能表示实数的一个很小的子集,常常是这个无限精度的结果不是目标操作数编码格式的准确结果。例如,下面的值(a)有一个24位的分数部分,这个分数的最低有效位不能被单精度浮点数准确的编码(单精度仅有23位浮点精度)。
(a) 1.0001 0000 1000 0011 1001 0111E2 101
要舍入结果(a),处理器首先选择2个值无限接接近所表示分数的b和c满足括号中的表过式(b<a<c)
(b) 1.0001 0000 1000 0011 1001 011E2 101
(c) 1.0001 0000 1000 0011 1001 100E2 101
然后,处理器按照选定的舍入模式将结果设置为b或c。
舍入在结果上引入了一个错误,那就是舍入作为结果的最后位置(浮点数值的最低有效位的这个位置)不足一个单位。
IEEE-754标准定义了4种舍入模式,如下图所示:
舍入到最近的值,向上舍入,向下舍入,以及向零舍入。默认的舍入模式是向最近的舍入(Intel 64 和IA-32架构)。这种模式得出了真实值的最为准确且统计上无偏估值的近似值,适用大多数应用场景。
向上舍入和向下舍入术语上称为直接舍入(direct rounding)。可用于区间运算(interval arithmetic)。区间运算用于多步计算后得出最终结果的上界和下界,而这些计算的中间结果是受舍入的影响的。向零舍入(有时称为”去除”模式),常用于在X87浮点处理单元上执行整数运算。
舍入后得到的结果称为非精确结果,当处理器生成一个非精确结果的时候,它会设置浮点(非精确数)精度标识(PE),这种模式对比较操作,产生精确结果的操作,或者产生NaN结果的操作都没有影响。
在Intel 64和IA-32架构中,舍入是通过一个两位的舍入控制域来控制的。RC域是在两个不同的位置中实现的:
X87 浮点处理单元控制寄存器(10和11位)。MXCSR害存器(13位和14位)。虽然两者完成相同的功能,但是这个舍入是在处理器的不同环境中完成的。在X87浮点处理单元中的浮点控制寄存器中的RC域控制着X87浮点处理单元的指令完成计算后的舍入。而MXCSR寄存器中的RC域控制着用SSE/SSE2指令完成SIMD浮点运算的舍入。
以下SSE/SSE2指令在将浮点值转换成非精确整数值的时候会自动的截断结果:
CVTTPD2DQ, CVTTPS2DQ, CVTTPD2PI, CVTTPS2PI, CVTTSD2SI, CVTTSS2SI.
这里的截断意味着向零舍入。