C语言的信息表示

前言

C语言是现在最重要、最流行的编程语言之一,相对于其余程序设计语言,C语言更偏向底层结构化设计,提供对硬件更精准的控制,但也要求使用者更关注指针、位等字节级编程,本文以C语言为载体,研究字节级信息的存储和相关特性,讨论字符串、整数、浮点数的编码方式和相关隐患。不正之处,敬请指教html

1、位+上下文

众所周知,现代计算机存储和处理的信息以二进制信号表示,即0和1。计算机内都是以0、1bit串存储信息,区分不一样信息的惟一方法是根据解析0、1bit串的上下文。编程

首先,咱们来思考这么个问题:给定一个串0110 0001,表明什么含义呢?数组

在没有任何上下文的时候,咱们直观的猜想这是个数字,将二进制转换为十六进制为0x61,转换为十进制则为97。 若进一步假定计算机在读取串0110 0001的上下文是解析ASCII码,则串0110 0001被计算机解释为ASCII码中的字符a。但若设定计算机在读取串0110 0001的上下文是unsigned char掩码,则0110 0001不表明任何含义,假如计算机在读取该串的上下文是引用一个字长为8bit的指针,则0110 0001就表明着地址97markdown

单独的bit串并无意义,信息是经过位+上下文承载的,相同的位串在不一样上下文被解释成不一样的信息。app

2、表示字符串

C语言中的字符串被编码为以null结尾的字符数组,字符串的每一个字符一般以ASCII字符码进行编码。编程语言

//"123"存储为:0x31 0x32 0x33 0x00
//二进制表示:0011 0001 0011 0010 0011 0011 0000 0000
char *str1 = "123";
//"abc"存储为:0x61 0x62 0x63 0x00
//二进制表示:0110 0001 0110 0010 0110 0011 0000 0000
char *str2 = "abc";
复制代码

字符串的存储方式是不受字节顺序和字长影响的,所以字符串的可移植性是最好的。oop

3、表示整数

C语言数字编码体系是真实世界数字体系的近似表示,在某系特性上存在必定的差别。字体

打个比方,C语言用char类型变量存储[-128,127]共256个整数,在使用以下表达式时:编码

/*例1*/
char x = -128;
char y = -x;//真实世界y应该是128,可是在C语言中,y仍是-128!

/*例2*/
char x = 100,y=30;
char sum = x+y;//真实世界y应该是130,可是在C语言中,y-126复制代码

若对C语言数字编码体系不了解,很容易致使程序出现极其隐秘的bug。下面咱们着重讨论C语言是如何表示整数、浮点数。加密

(一)表示无符号

1. 概述

C语言针对整数提供两种编码方式:无符号编码(unsigned)、有符号编码(signed)。

首先,咱们来看一下十进制的正整数表示方式,思考咱们如何解释123数字。应该为 123 = 3 1 0 0 + 2 1 0 1 + 1 1 0 2 123=3*10^0+2*10^1+1*10^2

类比到无符号8bit的二进制0110 0001,有:

01100001 = 1 2 0 + 1 2 5 + 1 2 6 = 97 0110 0001=1*2^0 + 1*2^5 + 1*2^6=97

思考若用16bit空间存储97,bit串应该是?

考虑 w w 位的bit串,公式化为(公式1):

[ x w 1 , x w 2 , , x 1 , x 0 ] = x w 1 2 w 1 + x w 2 2 w 2 + + x 1 2 1 + x 0 2 0 = i = 0 w 1 x i 2 i [x_{w-1},x_{w-2},\cdots,x_{1},x_{0}] = x_{w-1}*2^{w-1}+x_{w-2}*2^{w-2}+\cdots+x_1*2^1+x_0*2^0=\sum_{i=0}^{w-1} x_i2^i

其中, x w x_{w} 为第 w w 位的bit取值(注意是位,非字节)

基本C数据类型中,一般以1字节、2字节、4字节、8字节做为存储大小,则对应 w w 取值为八、1六、3二、64

2. 取值范围

一个颇有趣的现象,假设用8bit存储无符号,最大取值为 X M a x 8 = 2 8 1 = 255 XMax_8=2^8-1=255 ,思考为啥不是 2 8 2^8 而是还要减1呢?

其实在给定的8bit中,二进制表示范围为0000 0000~1111 1111,那最大的就是全部存储空间的bit取值为1,即1111 1111。该值为1 0000 0000-1,按公式1很容易知道1 0000 00002^8

聪明的你应该猜得出,给定长度为 w w 的bit串存储无符号整数,最大值都是奇数,为 X M a x w = 2 w 1 XMax_w=2^w-1

无符号的最小值即所有bit取值为0,所以不管bit串多少位, X M i n w = 0 XMin_w=0

C数据类型 最小值 最大值
unsigned char(8bit) 0 255
unsigned short(16bit) 0 65535
unsigned int(32bit) 0 4294967295

其余无符号的数据类型均可以经过存储空间的bit数肯定取值范围。

(二)表示有符号

1. 概述

相比于无符号数的直观,有符号数显得更加晦涩。由于有符号数引入了补码、减法、非对称性。

1.1 原码

首先,仍是咱们看一下十进制的负整数,好比-123,咱们就在123前面加个-号即表示负整数,咱们是否也可让计算机用-0110 0001二进制形式表示-97

别忘了,咱们计算机只能存储0、1,它不能存储-号,所以简单粗暴的作法是不行的,必须找一种编码方式,让二进制能够表示负整数。

应该能够很容易知道,那我就用0,1bit串的最高位做为符号位(这种编码称为原码),若最高位为0表明正数,最高位为1表明负数。来看看这种编码方式:

97 = 11100001 = 1 ( 1 2 0 + 1 2 5 + 1 2 6 ) -97= 1110 0001 = -1*(1*2^0 + 1*2^5 + 1*2^6)

这种编码形式很直观,但人们很快发现原码并不适合计算机编码。

为何呢?由于计算机CPU没有人那般智能,为简化硬件设计的复杂度,CPU只有加法器,没有减法器!这意味着CPU将全部的减法都转化为加法进行运算。咱们尝试在CPU角度利用原码编码方式上计算2-1:\

  1. 将2编码为 0000 0010
  2. 因为只能作加法,咱们将2-1视为2+(-1),则-1编码为 1000 0001
  3. 运算2+(-1)结果为:1000 0011,即-3!很明显错误了。

所以,采用原码做为有符号数的编码不利于CPU简化运算和硬件设计。

1.2 反码
第二种编码方式反码被提出了,这种编码方式将无符号编码中最高位的权重从 2 w 1 2^{w-1} 转换为 2 w 1 1 -(2^{w-1}-1) ,在char类型中, w w 取值为8,则 2 w 1 1 = 127 -(2^{w-1}-1) = -127 。 好比:

10000001 = 1 ( 2 w 1 1 ) + 1 2 0 = 126 1000 0001=1*(-(2^{w-1}-1)) + 1*2^0 = -126

这种编码方式有个特色:正负数之间在位级上正好取反

1 = 00000001 1 = 00000001
1 = 11111110 -1 = 11111110

同时,反码正好能弥补原码在CPU中运算的不足,仍旧以2-1举例:

  1. 将2编码为 0000 0010
  2. 因为只能作加法,咱们将2-1视为2+(-1),将1编码为0000 0001,则取反后-1编码为 1111 1110
  3. 运算2+(-1)结果为:0000 0010+ 1111 1110 =1 0000 0000
  4. 根据反码计算规则,若是有进位出现,则要把它送回到最低位去相加(循环进位),最后结果为0000 0001,即为1

经过反码的运算能够得出,CPU在只有加法器的条件下,经过配合简单的位级取反和移位操做,就能快速的计算出最终减法结果。这种编码规则能适应底层硬件设计,但你应该很快发现反码取值有一个很奇怪的特性,对数字0有两种编码方式,即:

00000000 = 0 00000000 = 0
11111111 = 1 ( 2 w 1 1 ) + 1 2 6 + + 1 2 0 = 127 + 127 = 0 11111111 = 1*(-(2^{w-1}-1)) + 1*2^6 + \cdots + 1*2^0 = -127 + 127 = 0

也就是说反码将编码00000000解释为+0,将编码11111111解释为-0。这种解释方式和真实事件存在差别,毕竟咱们真实世界不会将0区分正负。

为了改进该缺点,现代计算机都采用一种称为补码的编码形式,经过将负数空间再减1,使得编码1111 1111~1000 0000-0~-127降低到-1~-128

现代计算机基本都是使用补码做为有符号整数的编码方式

1.2 补码

补码编码方式将最高位的权重视为 2 w 1 -2^{w-1} ,注意:反码的最高位权重为 2 w 1 1 -(2^{w-1}-1)

借鉴无符号数的公式1,容易推出 w w 位有符号数的编码定位(公式2):

[ x w 1 , x w 2 , , x 1 , x 0 ] = x w 1 2 w 1 + i = 0 w 2 x i 2 i [x_{w-1},x_{w-2},\cdots,x_{1},x_{0}] =-x_{w-1}2^{w-1} + \sum_{i=0}^{w-2} x_i2^i

注意:补码编码方式也有个特色,正负数之间在位级上正好取反加1,好比1-1的编码以下:

11111111 = 1 2 7 + 1 2 6 + + 1 2 1 + 1 2 0 = 128 + 127 = 1 1111 1111 = -1*2^7+1*2^6+\cdots+1*2^1+1*2^0 = -128 + 127 = -1
00000001 = 1 2 0 = 1 0000 0001 = 1*2^0 = 1
1 = 11111111 = 取反( 00000001 + 1 -1 = 1111 1111 = 取反(0000 0001)+1

再来看看2-1的运算:

  1. 将2编码为 0000 0010
  2. 因为只能作加法,咱们将2-1视为2+(-1),将1编码为0000 0001,则取反加1后-1编码为 1111 1111
  3. 运算2+(-1)结果为:0000 0010+ 1111 1111 =1 0000 0001
  4. 根据补码计算规则,计算结果对 2 w 2^{w} 取模,最后结果为0000 0001,即为1

2. 无符号和有符号数的转换

本文有讲述过信息=位+上下文,那对于C语言而言,有符号和无符号数在存储上并没有区别,有的仅仅是对同一位串的不用解析。

公式1对比公式2:

i = 0 w 1 x i 2 i = x w 1 2 w 1 + i = 0 w 2 x i 2 i (公式 1 \sum_{i=0}^{w-1} x_i2^i=x_{w-1}2^{w-1} + \sum_{i=0}^{w-2} x_i2^i (公式1)
x w 1 2 w 1 + i = 0 w 2 x i 2 i (公式 2 -x_{w-1}2^{w-1} + \sum_{i=0}^{w-2} x_i2^i(公式2)

能够从公式中看出,若无符号数转有符号数:

有符号数值 = 无符号数值 2 x w 1 2 w 1 = 无符号数值 x w 1 2 w 有符号数值=无符号数值-2*x_{w-1}2^{w-1}=无符号数值-x_{w-1}2^{w}

若无符号数转有符号数,则:

无符号数值 = 有符号数值 + 2 x w 1 2 w 1 = 有符号数值 + x w 1 2 w 无符号数值=有符号数值+2*x_{w-1}2^{w-1}=有符号数值+x_{w-1}2^{w}

好比在强制类型转换中:

/*无符号转有符号*/
unsigned char x = 255;
/*最高位权重从128转变为-128(变化了-256),即255-256=-1*/
char y = (char)x; //y=-1;
复制代码
/*有符号转无符号*/
char x = -128;
/*最高位权重从-128转变为128(变化了+256),即-128+256=128*/
unsigned char y = (unsigned char)x;//x=128
复制代码

有兴趣的同窗能够将8bit无符号的100转换为有符号数,将8bit有符号的100转换为无符号。

3. 取值范围

一个有趣的事实,使用补码表示有符号数,则会出现不对称性。到底是什么不对称呢?

咱们对比有符号数的正数和负数二进制串表示范围:

正数:0000 0001 ~ 0111 1111

负数:1000 0000 ~ 1111 1111

发现了吗?不看最高位的状况下,正数表示范围从000 0001开始,而负数从000 0000开始。表明负数的范围要比正数表示的范围大1,这就是正负数取值范围的不对称性。

因此8bit有符号的二进制取值范围为:

正数:0000 0001 ~ 0111 1111 => 1 ~ 127

负数:1000 0000 ~ 1111 1111 => -128 ~ -1

0:0000 0000

综上,8bit有符号取值范围为-128 ~ 127,在此就不罗列二、四、8字节有符号数的取值范围了,原理都同样,请小伙伴自行推导。

本节的最后,提个小小的思考题:

char x = -128;
//y的取值是?
char y = -x;
复制代码

4、表示浮点数

相比于整数的编码方式,浮点数的编码更加精细,计算机对浮点数的编码一般使用IEEE754标准。
考虑十进制小数的定点表示法,10的负指数次幂表示小数位,例如表示 12.3 4 10 12.34_{10} :

12.34 = 1 1 0 1 + 2 1 0 0 + 3 1 0 1 + 4 1 0 2 12.34 = 1*10^1 + 2*10^0 + 3*10^{-1} + 4*10^{-2}

对应的,能够对二进制使用相同的定点表示法,例如用二进制表示 1. 5 10 1.5_{10} :

1. 5 10 = 1 + 1 2 = 1 2 0 + 1 2 1 = [ 1.1 ] 2 1.5_{10} = 1 + \frac{1}{2}=1*2^0 + 1*2^{-1} = [1.1]_{2}

这种表示方法很直观,可是在表示很是大的数或很是靠近0的数须要至关多的存储空间,好比表示数字 2 100 2^{100} 2 100 2^{-100} ,须要使用至少101个bit的内存空间。
所以,相似十进制表示法,引入科学计数法,经过 x 2 y x*2^y 形式表示一个数。在这种表示法中,只须要精确的存储 x x y y 就能表示一个数,哪怕这个数很是大或者很是小。好比表示 2 100 2^{100} ,只需在存储空间中存储 x = 1 x=1 , y = 100 y=100 便可。

正式的,IEEE754标准经过形如 ( 1 ) s M 2 E (-1)^s*M*2^E 表示一个数,其中:

  • s:符号位,表示数值的正负数,参考上述整数的原码表示法
  • M:尾数位,表示一个二进制小数,即上述中的 y y ,经过frac字段计算得出
  • E:阶码位,表示对二进制小数加权,即上述中的 x x ,经过exp字段计算得出

32位浮点数表示:符号位1位,exp字段8位,frac字段23位 32位浮点数表示 64位浮点数表示:符号位1位,exp字段11位,frac字段52位 64位浮点数表示

IEEE754标准经过形如 ( 1 ) s M 2 E (-1)^s*M*2^E 表示一个数,所以想要解析或者编码浮点数,必须计算出s,M,E三个值。根据存储中的exp不一样取值,对浮点数区分不一样的编码状况,每种编码状况对应不一样的计算方式:

image.png

只要肯定下exp字段和frac字段总位数,则肯定浮点数能够表示多少个不一样数字。但能够经过调节exp字段和frac字段占用的位数,肯定浮点数的表示范围和密集度。当exp字段拥有更多位数时,浮点数的表示范围更宽,当frac字段拥有更多位数时,浮点数表示更加密集

(一)规格化值

当exp的值不为全0或全1状况下,浮点数为规范化值。对于kexp,nfrac,计算方式以下:

  • 计算E:
  1. 引入偏置值B:
B = 2 k 1 1 B = 2^{k-1}-1

32位浮点数B值为127,64位浮点数B值为1023

  1. 经过exp减去偏置值B表示有符号的E
E = e x p B E = exp - B

好比在32位浮点数中,内存中存储的exp为128,则 E = e x p B = 128 127 = 1 E=exp-B=128-127=1

注意此处用了偏置形式表示有符号E,而不用补码形式表示,主要缘由在于偏置形式表示能够直接经过二进制位比较不一样浮点数的大小,而不用作任何补码计算。

经过计算,能够得出32位浮点数取值范围为-126~127,64位浮点数取值范围为-1022~1023

  • 计算M:
  1. frac字段视为小数位,例如frac取值为1101,则视为小数位后为0.1101
  2. 经过小数位frac字段加1计算M
M = f r a c + 1 M = frac + 1

好比frac取值为1101,则M取值为1.1101

该方式能够得到一个额外精度位,由于不管frac取值为什么值,均将第一位视为1

(二)非规格化的值

当exp的值为全0状况下,浮点数为非规范化值,这种编码表示0或很是接近0的数值。对于kexp,nfrac,计算方式以下:

  • 计算E:
  1. 引入偏置值B:
与规范值同样: B = 2 k 1 1 与规范值同样:B = 2^{k-1}-1

32位浮点数B值为-126,64位浮点数B值为-1022

  1. 经过1减去偏置值B表示有符号的E
E = 1 B E = 1 - B

好比在32位浮点数中,内存中存储的exp为0,则 E = 1 B = 1 127 = 126 E=1-B=1-127=-126

 注意此处经过B计算E的方法为: E = 1 B E = 1 - B ,目的是和规格化表示相衔接,由于规格化表示最靠近0的取值时,也是 1 B 1-B

  • 计算M:
  1. frac字段视为小数位,例如frac取值为1101,则视为小数位后为0.1101
  2. 直接经过小数位frac字段得出M
M = f r a c M = frac

好比frac取值为1101,则M取值为0.1101

在规格化表示中, M = f r a c + 1 M = frac+1 ,这种表示方式不管frac取值为什么值,均将第一位视为1,也就表明没法使M为0(注意frac取为无符号表示,不能取值为负数),所以,为了使得M取值为0,在非规格化中,直接经过小数位frac字段得出M M = f r a c M = frac

因为精巧的设计非规格化的EM计算方式,使得非规格化和规格化表示能够平滑过渡,即最大的非规格化数仅比规格化数小一点

(三)特定值

最后是当exp取值为全1,则当frac取值为全0时,表示无穷大,当frac取值不为全0时,表示NaN,表明不是个数。

(四)溢出

浮点数经过 x 2 y x*2^y 形式表示一个数值,但不是任何数值均可以在有限空间中经过肯定的 x 2 y x*2^y 形式表示,所以经过IEEE 754编码方式表示浮点数,避免不了存在溢出状况。分两种状况讨论:

  • x x ,即上述讨论中的M没法精确表示数值:

好比十进制的0.1,转化为二进制为0.00011001100[1100],括号内的值为无限循环数值,没法经过有限存储空间表示,发生溢出,致使浮点数只能近似表示。

- 当 y y ,即上述讨论的E没法表示过大或太小的数值:

好比十进制的 3.4 1 0 39 3.4*10^{39} ,没法经过32位浮点数表示出来。

总结

本文主要围绕计算机信息的表示,讨论了字符串、无符号整数、有符号整数以及浮点数在字节级别的编码形式。

  1. 字符串的每一个字符一般使用ASCII编码进行存储,具备良好的移植性
  2. 无符号整数使用无符号编码,编码形式简洁直观,不一样的存储空间大小决定无符号不一样的整数取值范围
  3. 有符号整数使用补码存储,该编码形式解决了+0-0问题,同时也知足了硬件加法器的设计要求
  4. 浮点数使用IEEE 754标准进行编码,即采用 x 2 y x*2^y 形式表示一个数字,只须要存储 x x y y 便可表示浮点数,下降极大数或靠近0的数值对存储空间的要求,但也使得存储形式更加复杂。

参考文章:
1.深刻理解计算机系统
2.计算机为何要用补码?