计算机怎么表示有符号数和浮点数
有符号数就是我们说的带有正负号的数;浮点数即是小数,区别于定点数。
虽然我们日常编程中经常和他们打交道,但很难说我们对它理解的有多好,计算机内部是怎么表示的负数、浮点数呢? 尤其浮点数,我们非常熟悉但又恐惧。熟悉是因为我们开发工作中比如和钱打交道时,会涉及到浮点数运算;说到恐惧,是我们经常会遇到的精度问题.. 比如一个计算结果我们预期得到的4.0,怎么就莫名其妙的变成了3.999999。 一般我们会说计算机用的二进制表示,表示小数可能会产生误差。
实际上浮点数在计算机知识范畴里的确也是比较难理解的一块,很多有经验的程序员也不一定对它理解的好。
说到有符号或者浮点数之前,还要有很多工作要做。先概要说明信息是如何在计算机中存储的以及周边知识点。
信息存储:
每台计算机都有字长(word size),字长决定了虚拟地址空间的最大大小。对于一个字长为w位的机器而言,虚拟地址范围为0~2^w -1,程序最多可访问2^w个字节。内存寻址的基本单位是字节。
比如32位机器,虚拟地址空间即是0-2^32 -1,最大可以访问4GB(2^32 Byte)。
大多数64位机器也能运行给32位机器编译的程序,这是向后兼容。我们所说的32位程序还是64位程序,指的是该程序是如何编译的,而不是说它运行的机器类型。
有符号的整数,表示负数、整数、0,无符号的就是非负数。
多字节对象通常都是存储为连续的字节序列。一个int类型占用4个字节(比如首地址是0x100),那么占用内存情况就是0x100,0x101,0x102,0x103四个字节。
大端序、小端序列之前专门写过,再简单啰嗦下:大端序是高有效位在内存低位,小端序反之。
表示字符串,由字符数组和结尾符号组成。每个字符需要一种编码。参考ASCII、Unicode。
一段相同的代码在不同机器编译环境下,得到的机器代码是不同的(拿windows编译后的二进制程序,没法直接跑在一个linux系统上)。从机器角度看,程序仅仅是字节序列。
布尔代数
逻辑 | 符号 | 备注 |
---|---|---|
与 | & | and |
或 | \ | or |
非 | ~ | not |
异或 | ^ | exclusive or(比如在加法器中加法位的运算逻辑) |
位向量
布尔运算扩展到位向量的运算。位向量:一个固定长度w,由0和1组成的数组。
比如长度w=4的两个位向量:
A向量 | B向量 | A^B 异或操作 |
---|---|---|
[0110] | [1100] | [1010] |
位向量一个重要的应用比较广的就是用来表示有限集合。1表示该位有这个数字。
举个列子,w = 8的两个位向量:
A向量 / 集合 | B向量 / 集合 | C= A&B / 交集 | C= A 并B / 并集 |
---|---|---|---|
[0110 1001] | [0101 0101] | [0100 0001] | [0111 1101] |
{0,3,5,6} | {0,2,4,6} | {0, 6} | {0, 2, 3, 4, 5, 6} |
比如IO多路复用中,select的文件描述符集合里,最大1024,用的就是位向量。
移位运算
前提:x由w位组成
左移:x向左移动k位,丢弃高k位,并在右端补k个0。移位量在0~ w-1
右移:比较复杂,区分有符号和无符号,有逻辑右移和算术右移。【需要着重再看 算数右移和逻辑右移】todo
整数表示
编码整数两种方式,一种是无符号的非负整数,一种是带符号的负数、正数、零。
无符号整数的编码
用位向量表示无符号整数。二进制转成无符号的编码:
B2U( [1011] ) = 1*2^3 + 1*2^1 + 1*2^0 = 11
显然,位向量是w位的,最小值是0,最大值是2^w -1。 二进制位向量到无符号整数的关系是一一映射。每个介于0~ 2^w -1的非负整数都能由w位的位向量唯一表示,反之一样。
有符号整数编码 补码编码
对于负整数的表示,最常见的编码方式就是补码(two’s-complement)。补码中,字的最高有效位是负权(negative weight)。负权的含义有两个,一个是负号,一个是权重,注意它不是单单的一个负号,而是有权重的,理解这个很重要。 最高位是1表示负数。
B2T表达式中第一项即是负权的含义。举个例子很好理解。
B2T( [1011] ) = -1*2^3 + 1*2^1 + 1*2^0 = -5
B2T( [0011] ) = -0*2^3 + 1*2^1 + 1*2^0 = 3
补码表示的最大最小值(字长w位): 最大(当然是正数,最高有效位是0)值,除了最高有效位其余都置为0,即是 2^(w-1) -1。
最小值(从公式看很容易得出)最高有效位是1,其它都是0,即是 -2^(w-1)。 比如w=4:
最大 | 最小 |
---|---|
0111 | 1000 |
2^3-1 = 7 | -2^3 = -8 |
从数学上可以证明,补码的编码也是一一映射,互相唯一表示。这也是为什么补码编码中负数比整数(绝对值)大1。
这样理解补码似乎很刻板,换一个角度,我们从求和的角度看补码。 假设使用1个字节来表示一个自然数,高位是符号位。
我们来看一下,如果不用补码来表示负数,比如高有效位只是一个符号位。那么计算一下 1+(-1)
1 的二进制 | -1 的二进制 | 相加的二进制 | 相加的十进制 |
---|---|---|---|
0000 0001 | 1000 0001 | 1000 0010 | -2 |
相加结果解释成了-2,这样表示负数在计算加法时会带来这种问题。 同时这种表示法,在对0的处理上也会出现+0和-0的区别。
下面仍然拿1来举例,现在有-1,自然1和-1相加是0。我们知道如果对1求反的话,两者相加是全1。
对全1再加一个1,就会溢出,因为只能用一个字节表示,结果就是0。 因此-1就是1的反码再加1,即全1:1111 1111 。其他整数也是一样,反码+1就是补码。
1 的二进制 | 反码表示 | -1 二进制补码表示 | 相加的二进制 | 相加的十进制 |
---|---|---|---|---|
0000 0001 | 1111 1110 | 1111 1111 | 1 0000 0000(0) | 0 |
可以看到补码表示负数贴合了加法计算。 通过这个例子再理解补码的公式就明白多了。
实际上,计算机表示这些有符号数通常正是采用补码。
定点数
在介绍浮点数之前,我们先来说一下与之相对的定点数。定点数就是说小数点在这个小数里是固定位置的,比如后边的小数位固定就是两个。 这里使用BCD编码说明下定点数。
BCD(Binary-Coded Decimal)
用4位二进制数来表示1位十进制数中的0~9这10个数码,是一种二进制的数字编码形式。(当然二进制表示最少要用4位)
十进制数字 | BCD Code |
---|---|
0 | 0000 |
1 | 0001 |
2 | 0010 |
3 | 0011 |
4 | 0100 |
5 | 0101 |
6 | 0110 |
7 | 0111 |
8 | 1000 |
9 | 1001 |
BCD编码来表示,相当于1个字节(8位)表示两个十进制数。这样,为了表示符号位,需要多用掉4位或者8位。 例如表示一个小数 123.45 需要用3个字节,-1234.56 需要用4个字节
十进制小数 | BCD Code | 备注 |
---|---|---|
123.45 | 0000 0001 0010 0011 0100 0101 | 3个字节表示。 0000 高4位半个字节符号位 表示正数。小数点后固定保留两个数 |
-1234.56 | 0000 0001 0001 0010 0011 0100 0101 0110 | 4个字节表示。0000 0001 高8位整个字节符号位 表示负数。同样小数点后固定保留两个数 |
这种定点数表示法,如用来表示一个很小的小数到很大的数范围,就需要用更多字节来存储。比如为了表示0.00000000012 ~ 123456789000,需要用到12个字节表示一个数。
浮点数
科学记数法
学过数学我们知道,可以用科学记数法来表示一个大数或者很小的数,这样能省去写很多0。
十进制数 | 科学记数法表达 |
---|---|
490,000,000,000 | 4.9*10^11 |
0.00000000012 | 1.2*10^(-10) |
这里有几个概念,4.9 和 1.2 我们称做尾数或者有效数,11 和 -10 称为指数。 一般规定有效数的范围是1~10,这种写法属于规范化式。
类比十进制
十进制小数拆解:
123.45 = 1*10^2 + 2*10^1 + 3*10^0 + 4*10^(-1) + 5*10^(-2)
同样道理的二进制小数拆解:
101.11 = 1*2^2 + 0*2^1 + 1*2^0 + 1*2^(-1) + 1*2^(-2)
类比十进制我们用科学记数法来表示二进制小数。
101.11 = 1.0111 * 2^2
十进制中规范化式有效数的范围是1~10,同样二进制也是1~10(二进制的2),也就规定只能是1. 开头。
IEEE标准
在计算机发展之初,各家厂商都是自搞一套浮点数处理,不同厂商不同标准,这也带来了各种各样的问题。需要一个共同标准的约束,目前绝大部分计算机在处理浮点数都是遵循IEEE 754-1985标准,该标准于1985年制定,由数学家William Kahan领导制定,为表彰William Kahan在浮点数标准做出的杰出贡献,他在1989年获得了图灵奖。
IEEE浮点数标准制定了两种基本格式:4个字节表示的单精度格式,和8个字节表示的双精度格式。
把浮点数表示按照科学记数法拆分成3个部分。符号位、指数位、有效位。
单精度
单精度4个字节表示,拆分成如下:
s=1 个符号位 | e=8 个指数位 | f=23 个有效位 |
---|
有效位: 前边说过规范式表达,有效位固定以 1.xxx
开头,这样计算机就可以默认它存在,而不需要再多余存储它,转而可以让这一位多存储一个有效位。这也就是为什么说23个有效位是24位的精度。
指数位:指数位这块刚开始接触可能不好理解,它还要减去一个偏移量Bias。
计算机存储的指数位表示的是正数,8位指数能表示的范围即是0~255,减去一个偏移量是编码后的值。
Bias = 2^(e-1)-1
对于单精度格式来说e=8,这个偏移量是127。
注意:如果上下文只说指数,那它只代表e: 0~255的范围。编码后的指数是: e-Bias
指数是0和255这两个特殊值时,是有专门的含义。如果是1~254,可以用这样的数学式子描述表达的浮点数值。
数学表达式: (-1)^s* 1.f * 2^(e-127)
s符号位,1表示负数,0表示正数。数学式子 (-1)^s
正好表达了这个意思。
指数是0或者255的特殊case:
e = 0 且 f = 0 | e = 0 且 f !=0 | e = 255 且 f = 0 | e = 255 且 f != 0 |
---|---|---|---|
这个就是浮点数下的0,32位都是0。如果s 符号位是1,那就是-0了,-0的含义是无限接近于0的小于0的数。 | 这个是合法的,但不是规范化的。可以用数学式子表达:(注意 0.f ) (-1)^s* 0.f * 2^(-127) |
表示无穷大(s = 0)和 无穷小(s=1) | 解释成不是一个数。NaN (not a number) |
表达的范围
通过以上常规和不常规,我们能得到单精度浮点数表达的范围。
最小正数 | 最大正数 |
---|---|
1.(23个0)*2^(-126) | 1.(23个1)*2^127 |
转成十进制,近似为 1.175410^(-38) 和 3.402810^38 。
表达的精度
切记,精度不等同于范围,完全是两个概念。 有效位体现的是精度。 23位的有效位长度,能表达的我们平常理解的十进制是多大呢?
我们熟记的式子: 2^10 = 1024
,可以理解成 10个长度的二进制能表达3个数量级长度的十进制。
那么24位二进制,差不多就是7个数量级的十进制。
2^24 = 16777216
,表示精度是 1/16777216 。说白了就是单精度在表达16777216和16777217上,计算机内部存储是一样的。 具体来看下:
# 16777216 在计算机内部存储为:
1.00000000000000000000000 * 2^24
# 如果给这个单精度数,再往上加一个最小单位,即是最低的23位0变成1。
1.00000000000000000000001 * 2^24
拆解后:
1*2^24 + 1*2^(-23)*2^24 = 16777216 + 2 = 16777218
#include <stdio.h>
int main(){
float a = 16777217;
printf("%.2f\n", a);// 16777216.00
}
显然,16777216 ~ 16777217之间的浮点数在单精度下计算机内部存储都一样。这就是它能表示的精度。而16777218 也是能正好表达出来的,再次说精度和范围是两个概念。
指数不同,表达精度也不同。再举个例子:
2^18 = 262144
2^(-5) = 1/32 = 0.03(约)
# 262144.00 在计算机内部存储为:
1.00000000000000000000000 * 2^18
# 如果给这个单精度数,再往上加一个最小单位,即是最低的23位0变成1。
1.00000000000000000000001 * 2^18
拆解后:
1*2^18 + 1*2^(-23)*2^18 = 262144 + 1/32 = 262144.03
#include <stdio.h>
int main(){
float a = 262144.01;
float b = 1;
printf("%.2f\n", a);// 262144.00
printf("%.2f\n", b);
printf("%.2f\n", a+b);// 262145.00
}
262144.01 在单精度情况下和 262144.00在计算机内部存储是一样的。
这就是为什么我们在浮点数运算的编程中,经常遇到丢精度,不符合预期的情况发生。
双精度
单精度格式的精度不高,还有双精度来帮忙。双精度是用8个字节表示一个数,更多的有效位也有更高的精度。 64位拆分成如下:
s=1 符号位 | e=11 指数位 | f=52 有效位 |
---|
偏移量 Bias = 2^(e-1) -1 = 1023
。
数学表达式: (-1)^s* 1.f * 2^(e-1023)
规则和单精度一样,特殊case,e=0和2047。
最小正数 | 最大正数 |
---|---|
1.(52个0)*2^(-1022) | 1.(52个1)*2^1023 |
转成十进制,近似为 2.22510^(-308) 和 1.79710^308。
双精度在精度上比单精度要高,但是毕竟也是有限的有效位,避免不了不同的数的内部存储一样的问题。
OK~ 就到这里吧。
参考
- 《编码-隐藏在计算机背后的语言》
- 《CSAPP》
这里墙裂安利 《编码-隐藏在计算机背后的语言》这本书。写的非常精彩,循循善诱,介绍了计算机的本质,CPU是怎么运行的。看过之后,让你对计算机底层的东西不再恐惧。