C语言的信息表示

706 阅读16分钟

前言

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

一、位+上下文

众所周知,现代计算机存储和处理的信息以二进制信号表示,即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就代表着地址97

单独的bit串并没有意义,信息是通过位+上下文承载的,相同的位串在不同上下文被解释成不同的信息。

二、表示字符串

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";

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

三、表示整数

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=3100+2101+1102123=3*10^0+2*10^1+1*10^2

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

01100001=120+125+126=970110 0001=1*2^0 + 1*2^5 + 1*2^6=97

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

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

[xw1,xw2,,x1,x0]=xw12w1+xw22w2++x121+x020=i=0w1xi2i[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

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

基本C数据类型中,通常以1字节、2字节、4字节、8字节作为存储大小,则对应ww取值为8、16、32、64

2. 取值范围

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

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

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

无符号的最小值即全部bit取值为0,因此无论bit串多少位,XMinw=0XMin_w=0

C数据类型最小值最大值
unsigned char(8bit)0255
unsigned short(16bit)065535
unsigned int(32bit)04294967295

其他无符号的数据类型都可以通过存储空间的bit数确定取值范围。

(二)表示有符号

1. 概述

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

1.1 原码

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

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

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

97=11100001=1(120+125+126)-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 反码
第二种编码方式反码被提出了,这种编码方式将无符号编码中最高位的权重从2w12^{w-1}转换为2w11-(2^{w-1}-1),在char类型中,ww取值为8,则2w11=127-(2^{w-1}-1) = -127。 比如:

10000001=1(2w11)+120=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=000000000 = 0
11111111=1(2w11)+126++120=127+127=011111111 = 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 补码

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

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

[xw1,xw2,,x1,x0]=xw12w1+i=0w2xi2i[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=127+126++121+120=128+127=11111 1111 = -1*2^7+1*2^6+\cdots+1*2^1+1*2^0 = -128 + 127 = -1
00000001=120=10000 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. 根据补码计算规则,计算结果对2w2^{w}取模,最后结果为0000 0001,即为1

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

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

公式1对比公式2:

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

可以从公式中看出,若无符号数转有符号数:

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

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

无符号数值=有符号数值+2xw12w1=有符号数值+xw12w无符号数值=有符号数值+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,在此就不罗列2、4、8字节有符号数的取值范围了,原理都一样,请小伙伴自行推导。

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

char x = -128;
//y的取值是?
char y = -x;

四、表示浮点数

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

12.34=1101+2100+3101+410212.34 = 1*10^1 + 2*10^0 + 3*10^{-1} + 4*10^{-2}

对应的,可以对二进制使用相同的定点表示法,例如用二进制表示1.5101.5_{10}:

1.510=1+12=120+121=[1.1]21.5_{10} = 1 + \frac{1}{2}=1*2^0 + 1*2^{-1} = [1.1]_{2}

这种表示方法很直观,但是在表示非常大的数或非常靠近0的数需要相当多的存储空间,比如表示数字21002^{100}21002^{-100},需要使用至少101个bit的内存空间。
因此,类似十进制表示法,引入科学计数法,通过x2yx*2^y形式表示一个数。在这种表示法中,只需要精确的存储xxyy就能表示一个数,哪怕这个数非常大或者非常小。比如表示21002^{100},只需在存储空间中存储x=1x=1,y=100y=100即可。

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

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

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

IEEE754标准通过形如(1)sM2E(-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=2k11B = 2^{k-1}-1

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

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

比如在32位浮点数中,内存中存储的exp为128,则E=expB=128127=1E=exp-B=128-127=1

注意此处用了偏置形式表示有符号E,而不用补码形式表示,主要原因在于偏置形式表示可以直接通过二进制位比较不同浮点数的大小,而不用做任何补码计算。

通过计算,可以得出32位浮点数取值范围为-126~127,64位浮点数取值范围为-1022~1023

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

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

该方式可以获得一个额外精度位,因为无论frac取值为何值,均将第一位视为1

(二)非规格化的值

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

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

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

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

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

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

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

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

在规格化表示中,M=frac+1M = frac+1,这种表示方式无论frac取值为何值,均将第一位视为1,也就代表无法使M为0(注意frac取为无符号表示,不能取值为负数),因此,为了使得M取值为0,在非规格化中,直接通过小数位frac字段得出MM=fracM = frac

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

(三)特定值

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

(四)溢出

浮点数通过x2yx*2^y形式表示一个数值,但不是任何数值都可以在有限空间中通过确定的x2yx*2^y形式表示,因此通过IEEE 754编码方式表示浮点数,避免不了存在溢出情况。分两种情况讨论:

  • xx,即上述讨论中的M无法精确表示数值: 比如十进制的0.1,转化为二进制为0.00011001100[1100],括号内的值为无限循环数值,无法通过有限存储空间表示,发生溢出,导致浮点数只能近似表示。

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

比如十进制的3.410393.4*10^{39},无法通过32位浮点数表示出来。

总结

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

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

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