本文又是一篇理论性较强的、介绍基本编程概念的文章。

在一些论坛上,经常会看到有初学者提这样的问题:“如何把一个16进制的数转换成10进制的?”在编程中,如果能正确理解一些基本概念的话,就会意识到:这是个伪问题。数就是数,它是一个值,不论用几进制表示,它都表示同一个量。在计算机中,把数划分为两种基本类型,一种是整数,一种是浮点数,前者是最常用的。

目前为止,我们还不需要关心它们是如何在计算机中记录和表示的,只须知道,有几种基本的数据类型,能够用来表示一定范围内的数。拿整数举例:在CPU与基本数据类型基础一文中我已经介绍过,x86-32 CPU有8、16、32位三种基本宽度,64位运算也比较简单因而也支持,它们又分别分为带符号/无符号两种。这里的“位”是用二进制来表示的计位,一个8位的二进制能够表示2^8即256个不同的数,因而无符号的8位整数的范围是0~255,有符号的是-128~127(为什么是这两个数后面会讲)。现在有一个数字——100(10进制),用16进制表示它是$64,用2进制表示它是01100100,尽管看起来它们不一样,但它们表示同一个量,在计算机里它们的样子都是相同的。在计算机中记录这个数的时候,只有一个“值”而已,并不存在“进制”的问题,或额外记录一个“进制”的信息。事实上,只有当你要把这个数表达出来给人看时,“进制”才有意义。同一个量,可以用10进制来表示为“100”,用16进制表示为“64”,或用2进制表示为“1100100”。但在显示在屏幕上之前,首先要把这个整数变成字符串,然后才能显示在屏幕上。
总结来说,这个问题是:在我们看一个数字的时候,实际上有两个信息,一个是数的值,另一个是数的进制;但当我们把这个数告诉计算机的时候,计算机只保留了值的信息,而“进制”与数的运算完全没关系,所以被忽略掉了。

也许你要说,计算机中的数是2进制的,你看and、or、xor、not这几种运算,不都是用二进制算的么?为了了消除“计算机中数都是2进制”的错误观念,能够更加清晰地理解有关“数”本身的概念,现在要更抽象一些。运算本身也是与进制无关的,就好比平时我们用十进制表示进行4则运算时,实质上是做了很多规则约定的。最典型的就是九九表了,一共规定了45种运算规则,再上加乘法交换律、加法的运算法则,我们才能用10进制的计数方式进行乘法运算。同样,我们也可以用10进制进行and等运算,如果是8位的整数(0~255),首先需要定义交换律,接着定义256*256/2种运算规则;用16进制的话,除交换律外,还要定义16*16/2种运算法则。之所以在讲解时用2进制的表示方式来讲,是因为这种形式下需要定义的运算规则最少,只要定义2*2/2种运算规则就够了(事实上常常给出的是一张2*2的表,连交换律都免了);同时,不同进制表达方式之间的转化,尤其是2/8/10/16进制之间的相互转换能力,是不言自明的、程序员默认已经掌握的能力。

如果你以前搞不清数、进制之类问题的话,相信现在应该有一个比较清楚的概念了吧。如果您说以前就不会进行不同进制之间的转化……那我没辙了,“满n进1”这么简单的规则都搞不清的话,相信我,您不适合写程序。


接下来要介绍的是CPU的Endianness。XXX-endian一般被翻译成“XXX端”,可以自己搜一下Endian这个词,网上会告诉你它的来源。在计算机中,最小的数据单元是字节(Byte),也就是CPU在处理数据时,最少从内存读入一个字节。那么在8位宽的整数时非常简单,只要读入一字节就可以了。但是,假如现在有一个32位的整数:$01020304,那么它在内存中是怎么储存的呢?如果你以前从来没考虑过这个问题,那么你可能会自然而然的认为,它在内存中的排列方式是$01,$02,$03,$04——也就是高位在“前”,低位在“后”——这是一种很自然的思维方式,与我们日常书写的习惯相同。我们知道,在计算机中,地址是从低到高排列的(比如数组),这也是很自然的一种思维方式——比如一个字符串'abcd',它在内存排列为:'a','b','c','d'。两种都是很自然的思维方式,但是现在问题来了,有人提出:在定义地址的时候,低位在“前”;而32位整数$01020304的低位是$04,它在“前”才对,这样两者都是低位在“前”。嘿嘿,可能现在你就糊涂了吧,两种说法都挺有道理的,但是哪个对呢?别急,两种方法都“对”,实际中都有应用。地址由低到高,按照$01,$02,$03,$04排列的是Big-Endian,而按照$04,$03,$02,$01排列的是Little-Endian——甚至还有一些更复杂的排列方式,统称为Middle-Endian。“大端”/“小端”这种翻译并不常见,习惯上用BE/LE这种缩写比较多一些。

我们常用的x86 CPU是Little-Endian的,在内存中储存整数采用的方式是:地址由低到高,分别储存数据的低位到高位。除了这种理论上的对应,这种方式也有一些其它好处。例如,我们将一个32位的整数赋值给一个8位要整数:

var
	i32	: Integer;
	u8	: Byte;
begin
	i32	:= $01020304;
	u8	:= i32;
	Writeln(Format('%.2x', [u8]));
end;

这段代码输出的结果是$04。也就是说,当把一个整数由位数多的数据类型赋值给一个位数少的数据类型时,只保留位该数据中相应的低位,高位忽略掉。这是一种非常合理并且高效的处理方式,并且在后面将会看到,在处理负数是也是非常有效的。现在把代码变一下,看看不同Endian中将会输出怎样的结果:

var
	i32	: Integer;
	pu8	: PByte;
begin
	i32	:= $01020304;
	pu8	:= Addr(i32);
	Writeln(Format('%.2x', [pu8^]));
end;

这段代码很有意思,我们用一个指向8位整数的指针,指向了一个32位的整数。由于LE的数据是由位位到高位排列的,pu8指向的数据区是:$04,$03,$02,$01...,所以结果将输出$04;而BE的情况下,pu8指向:$01,$02,$03,$04...,结果是$01。所以,在x86平台中,pu8^与u8的结果是一致的,也就意味着:指向不同宽度的整数类型的指针可以指向同一个地址,得到的结果与用整数类型转换的结果一致。


需要注意的是,不仅仅是整数类型,Endian与浮点数类型也有关。与整数类型的运算单元不同,x86 CPU架构中,浮点数类型的处理单元是FPU(也称为x87)。浮点数的储存方式则是符合IEEE 754标准的,按照这个标准,无法不通过FPU直接利用指针在不同精度的浮点类型之间转换。关于浮点数的储存方式,本文只作简单的原理性的介绍;有兴趣的话,可以参考wikipedia关于IEEE 754的介绍(英文中文),去了解更详细的内容。

介绍浮点数类型也是为了说明一个在许多教材中、或是文章中语焉不详的内容:为什么浮点数无法精确表示一些实数,为什么运算后会存在误差,以及在哪些情况下是没有误差的。首先,浮点数由三个部分组成,能表示一定范围内的一个实数,包括0、无穷与NaN(Not a Number)。

浮点数要能表示正数与负数,所以需要占用一位(Bit)来记录符号,也就是IEEE 754浮点数类型的第一部分:符号位。从字面上理解,浮点数要有一个浮动的小数点,也就是指定该实数的指数——第二部分可以理解成主要起这个作用。要注意的是,在计算机中的“指数”的底数,并不是我们日常10进制所使用的10,而是2。换句话说,我们平时的0.125可以写成1.25*10^-1,而在计算机中,要把它变成2进制,也就是1*2^-3。第三部分则是小数位,用它加上1再乘以指数部分,得到的结果就是我们要表示的数。

那么接下来的问题是,浮点数类型能精确表示哪些数?先来考虑一个问题,二进制浮点数如何表示十进制中的0.1,也就是1*10-1?在浮点数中,指数位是2^N,所以0.1只能用2^E1+2^E2+...+2^En相加得出。这个问题可以先放一下,考虑一下2^N(N<0)都是哪些数:2^-1=0.5,2^-2=0.25,2^-3=0.125,2^-4=0.0625……因为10=2*5,所以10^N=2^N * 5^N,2^N=10^N / 5^N=10^N * 5^-N。所以,当N<0时,2^-x=5^x*10^-x,也就是说,二进制每多一位小数,十进制就会增加一位,并且新增的最右边的位是5。因此,十进制里的0.1无法用二进制的小数精确的表示,在二进制里,它是个无限小数。所以,对于浮点数准确性的结论就是:
只要换算为2进制后,是二进制的有理数,并且在数据精度范围之内,那么这个实数就是能用浮点数精确表示的。

有个这个结论之后,前面几个问题都很容易回答了。无法精确表示的实数是因为以下至少一个原因:转换成二进制小数后是个无限小数;数位太多,超过浮点数精度范围。两个有限小数做加法、减法、乘法,结果还是有限小数,这种情况下只要精度允许,结果是没有误差的。无限小数由于用浮点数表示后,只保留了有限的数位,因此产生了误差——这个误差一旦出现,就比较难消除掉了。所以,像a:=0.125,b:=0.0625,x=a*b*100这样的运算是不会产生误差的;但如果当中掺入了一个0.2,那么误差就产生了,并且很难消除。

在Delphi中,支持单精度(Single,32位)、双精度(Double,64位)与扩展精度(Extended,80位)三种基本IEEE 754浮点数类型。


接下来还是回到整数上,接下来的内容要讲一讲计算机中,有无符号、正负整数的差别都有哪些差别与特点。

首先还是考虑一个比较简单的情况,假如我们有两个8位整数并进行加法运算:$61+$62 = $C3;再加上一个8位的整数:$C3+$63 = $126。以上各步的运算结果也都放在一个8位整数当中,那么,$126这个数字已经超过了8位整数能记录的范围,结果会是什么呢?答案是$26,原因很简单,计算机只保留能表示的位数,其它的位放不下就只能丢掉了。那么,这个8位的整数类型有没有符号会不会影响运算结果呢?现在可以动手试一下,观察一下结果:

var
	u8	: Byte;
	s8	: Shortint;
begin
	u8	:= $61;
	u8	:= u8 + $62;
	Writeln(Format('unsigned byte(+$62):'#9'%d', [u8]));
	u8	:= u8 + $63;
	Writeln(Format('unsigned byte(+$63):'#9'%d', [u8]));

	s8	:= $61;
	s8	:= s8 + $62;
	Writeln(Format('signed byte(+$62):'#9'%d', [s8]));
	s8	:= s8 + $63;
	Writeln(Format('signed byte(+$63):'#9'%d', [s8]));
	Readln;
end

十六进制的$C3和$26用十进制表示分别是195和38($60是96)。会不会因为当s8的值为$C3时,结果输出-61而感到很意外,为什么两个正数相加会得到负数呢?一个8位的整数类型一共有8个bit,如果要表示正/负的话,至少需要用1个。现在我们有一个8位整数$00,它减1之后的结果是$FF(会从前面“借”一位,由于只有8位宽所以显示不出来),因为0-1=-1,所以计算机直接用$FF来表示。观察一下$FF与$01,-$01=$FF能用非常基本的位运算得出:-N=not(N-1)。由于有一个not运算,所以当一个正整数不断增大时,最晚改变的位就是最高位——所以当一个整数有符号时,最高位用来表示符号。一个8位的有符号整数,它的最高位不为0的数中,最大的是$7F,求负之后:-$7F=not($7F-1)=$81;而如果只有最高位是1的数是$80,现在我们对它求负的话:-$80=not($80-1)=$80。而$80比$81还要小1,所以8位有符号整数的值的范围区间就是:$80..$7F,即-128~127。

下面再来考虑有符号与无符号整数在进行计算时的差别。首先,加法与减法运算是没有差别的,只不过由于表达的方式不同,计算的结果也不同。现在再来看整数乘法与除法,假如现在有一个8位的整数:有符号时为-64,将它除以8的话结果应该是-8($F8);无符号的时候-64为256-64=192,192除以8结果是24($18)。对比这两个结果,似乎看不出什么相似之处来,但是别忘了,整除8有一个特点,回忆一下我在CPU与基本数据类型基础中说的:“对于 无符号整数 和 非负有符号整数 来说,右移n位则相当于整除2的n次方”。观察一下$C0(-64或192)、$F8、$18用2进制表示时有什么特点:

$C0:1100,0000 -> 右移3位
$F8:1111,1000
$18:0001,1000

不知道发现了其中的特点了没:$F8与$18只是在最高3位不同,3我们进行右移的操作倍数也刚好是3位。这也正是计算机运算的奇妙之处,如果在右移的同时,高位补上1而不是0的话(shr运算符的特点是补0),$C0右移之后仍然还是负数,并且也得出了正确的结果。事实上,CPU的乘法与除法指令分为有符号和无符号两个版本,编译器会根据变量的类型进行正确的选择。像在x86架构上,shr运算符也对应着一个有符号版本的右移指令,该指令的汇编助记符是sar(注意:Delphi中没有sar的运算符),它的作用是在右移的时候,会根据最高位的值来进行补位——因为$C0的最高位是1,所以右移的同时也会补上1。使用C类语言的程序员也要注意,尽管像C语言标准规定,“>>”运算符在处理有符号整数时,行为是由编译器实现决定的,但在x86平台上,都是采用带符号位的方式进行处理;而Pascal编译器的行为则不同,它对有符号与无符号的整数都是进行无符号位的右移。在将C类语言的代码向Pascal语言翻译时,右移运算符(>>)是需要格外注意的
同样,假如我们有一个8位整数$C0,将它赋值给一个32位的有符号整数,编译也会根据该8位整数的符号类型,选择不同版本的指令:对于无符号的,高于8的位清0;有符号的则会根据第8位的值,把高于8的位都设为0或1。也就是说,Integer(ShortInt($C0))会变成$ffffffC0,而Integer(Byte($C0))则变为$000000C0。由位数较多向位数较少位数的整数类型转换,如果两个都是有符号的类型,由于比有效数字的最高位更高的位都是1,所以该值只要在较少位数的整数允许的数值范围之内(如由Integer(-100)向ShortInt转换),该特性并未改变,转换后的值不变。

怎么样,很神奇吧?感兴趣的话,自己多动手进行一些实验吧。更详细的内容,如果你有机会学习x86汇编的话,会有更加深刻的理解的。