《深入理解计算机系统》读书笔记 —— 第二章 信息的表示和处理

本章主要研究了计算机中无符号数,补码,浮点数的编码方式,通过研究数字的实际编码方式,我们能够了解计算机中不同类型的数据可表示的值的范围,不同算术运算的属性,可以知道计算机是如何处理数据溢出的。了解计算机的编码方式,对于我们写出可以跨越不同机器,不同操作系统和编译器组合的代码具有重要的帮助。

@

信息存储

为什么会有二进制?二进制有什么含义和优势?

  对于有10个手指的人类来说,使用十进制表示法是很自然的事情,但是当构造存储和处理信息的机器时,二进制值工作得更好。二值信号能够很容易地被表示、存储和传输。例如,可以表示为穿孔卡片上有洞或无洞、导线上的高电压或低电压,或者顺时针或逆时针的磁场。对二值信号进行存储和执行计算的电子电路非常简单和可靠,制造商能够在一个单独的硅片上集成数百万甚至数十亿个这样的电路。孤立地讲,单个的位不是非常有用。然而,当把位组合在一起,再加上某种解释,即赋予不同的可能位模式以含意,我们就能够表示任何有限集合的元素。比如,使用一个二进制数字系统,我们能够用位组来编码非负数。通过使用标准的字符码我们能够对文档中的字母和符号进行编码

计算机的三种编码方式

  无符号:无符号(unsigned)编码基于传统的二进制表示法,表示大于或者等于零的数字。

  补码:补码(two’ s-complement)编码是表示有符号整数的最常见的方式,有符号整数就是可以为正或者为负的数字。

  浮点数:浮点数( floating-point)编码是表示实数的科学记数法的以2为基数的版本。

整数&浮点数

  在计算机中,整数的运算符合运算符的交换律和结合律,溢出的结果会表示为负数。整数的编码范围比较小,但是其结果表示是精确的。

  浮点数的运算是不可结合的,并且其溢出会产生特殊的值——正无穷。浮点数的编码范围大,但是其结果表示是近似的。

  造成上述不同的原因主要是因为计算机对于整数和浮点数的编码格式不同。

虚拟内存&虚拟地址空间

  大多数计算机使用8位的块,或者字节(byte),作为最小的可寻址的内存单位,而不是访问内存中单独的位。机器级程序将内存视为一个非常大的字节数组,称为虚拟内存( virtual memory)。内存的每个字节都由一个唯一的数字来标识,称为它的地址(address),所有可能地址的集合就称为虚拟地址空间( virtual address space)。

  指针是由数据类型和指针值构成的,它的值表示某个对象的位置,而它的类型表示那个位置上所存储对象的类型(比如整数或者浮点数)。C语言中任何一个类型的指针值对应的都是一个虚拟地址。C语言编译器可以根据不同类型的指针值生成不同的机器码来访问存储在指针所指向位置处的值。但是它生成的实际机器级程序并不包含关于数据类型的信息

二进制&十进制&十六进制

二进制转十六进制(分组转换)

  四位二进制可以表示一位十六进制。二进制和十六进制的互相转换方法如下表所示。这里就不展开讲解了。

十六进制 1 7 3 A 4 C
二进制 0001 0111 0011 1010 0100 1100
十进制转十六进制

Gamma公式展示 \(\Gamma(n) = (n-1)!\quad\forall
n\in\mathbb N\)
是通过 Euler integral

  设x为2的非负整数n次幂时,也就是\(x = {2^n}\)。我们可以很容易地将x写成十六进制形式,只要记住x的二进制表示就是1后面跟n个0(比如
\(1024 = {2^{10}}\),二进制为10000000000)。十六进制数字0代表4个二进制0。所以,当n表示成i+4j的形式,其中0≤i≤3,我们可以把x写成开头的十六进制数字为1(i=0)、2(i=1)、4(i=2)或者8(i=3),后面跟随着j个十六进制的0。比如,\(2048 = {2^{11}}\),我们有n=11=3+4*2,从而得到十六进制表示为0x800。下面再看几个例子。

n \({2^{n}}\)(十进制) \({2^{n}}\)(十六进制)
9 512 0x200
19(3+4*4) 524288 0x80000
14(2+4*2) 16384 0x4000
16(0+4*4) 65536 0x10000
17(1+4*4) 131072 0x20000
5(1+4*1) 32 0x20
7(3+4*1) 128 0x80

  十进制转十六进制还可以使用另一种方法:辗转相除法。反过来,十六进制转十进制可以用相应的16的幂乘以每个十六进制数字。

虚拟地址的范围

  每台计算机都有一个字长( word size),指明指针数据的标称大小( nominal size)。因为虚拟地址是以这样的一个字来编码的,所以字长决定的最重要的系统参数就是虚拟地址空间的最大大小。也就是说,对于一个字长为w位的机器而言,虚拟地址的范围为0~\({2^{w}}\)-1 。程序最多访问\({2^{w}}\)个字节。

16位字长机器的地址范围:0~65535(FFFF)

32位字长机器的地址范围:0~4294967296(FFFFFFFF,4GB)

64位字长机器的地址范围:0~18446744073709551616(1999999999999998,16EB)

32位编译指令:gcc -m32 main.c

64位编译指令:gcc -m64 main.c

C语言基本数据类型的典型大小(字节为单位)

有符号 无符号 32位 64位
[signed] char unsigned char 1 1
short unsigned short 2 2
int unsigned int 4 4
long unsigned long 4 8
int32_t uint32_t 4 4
int64_t uint64_t 8 8
char* 4 8
float 4 4
double 8 8

  注意:基本C数据类型的典型大小分配的字节数是由编译器如何编译所决定的,并不是由机器位数而决定的。本表给出的是32位和64位程序的典型值。

  为了避免由于依赖“典型”大小和不同编译器设置带来的奇怪行为,ISOC99引入了类数据类型,其数据大小是固定的,不随编译器和机器设置而变化。其中就有数据类型int32_t和int64_t,它们分别为4个字节和8个字节。使用确定大小的整数类型是程序员准确控制数据表示的最佳途径。

  对关键字的顺序以及包括还是省略可选关键字来说,C语言允许存在多种形式。比如,下面所有的声明都是一个意思:

unsigned long 
unsigned long int 
long unsigned 
long unsigned int

大端&小端

  大端:是指数据的高字节保存在内存的低地址中,而数据的低字节保存在内存的高地址中,这样的存储模式有点儿类似于把数据当作字符串顺序处理:地址由小向大增加,而数据从高位往低位放。

  小端:是指数据的高字节保存在内存的高地址中,而数据的低字节保存在内存的低地址中,这种存储模式将地址的高低和数据位权有效地结合起来,高地址部分权值高,低地址部分权值低,和我们的逻辑方法一致。

  举个例子,假设变量x的类型为int,位于地址0x100处,它的十六进制值为0x01234567。地址范围0x100~0x103的字节顺序依赖于机器的类型。

大端法

地址 0x100 0x101 0x102 0x103
数据 01 23 45 67

小端法

地址 0x100 0x101 0x102 0x103
数据 67 45 23 01

注意,在字0x01234567中,高位字节的十六进制值为0x01,而低位字节值为0x67。

记忆方式:

大端==高尾端,即尾端(67)放在高地址(0x103)。

小端==低尾端,即尾端(67)放在低地址(0x100)。

扩展:大小端有什么意义?

1.不同设备的数据传输

  A设备为小端模式,B设备为大端模式。当通过网络将A设备的数据传输到B设备时,就会出现问题。(B设备如何转换A设备的数据将在后面章节讲解)

2.阅读反汇编代码

  假设Intel x86-64(x86都属于小端)生成某段程序的反汇编码如下:

4004d3:01 05 43 0b 20 00              add   %eax,0x200b43(%rip)

  这条指令是把一个字长的数据加到一个值上,该值的存储地址由0x200b43加上当前程序计数器的值得到,当前程序计数器的值即为下一条将要执行指令的地址。

  我们习惯的阅读顺序为最低位在左边,最高位在右边,0x00200b43。而小端模式生成的反汇编码最低位在右边,最高位在左边,01 05 43 0b 20 00.和我们的阅读顺序正好相反。

3.编写符合各种系统的通用程序

/*打印程序对象的字节表示。这段代码使用强制类型转换来规避类型系统。很容易定义针对其他数据类型的类似函数*/
#include <stdio.h>
typedef unsigned char* byte_pointer;
/*传递给 show_bytes一个指向它们参数x的指针&x,且这个指针被强制类型转换为“unsigned char*”。这种强制类型转换告诉编译器,程序应该把这个指针看成指向一个字节序列,而不是指向一个原始数据类型的对象。*/
void show_bytes(byte_pointer start, size_t len){
    size-t i;
    for (i=0;i<len;i++)
        printf("%.2x",start [i]);
    printf("\n");
}
void show_int (int x){
show_bytes ((byte_pointer)&x,sizeof(int));
}
void show_float (float x){
    show_bytes ((byte_pointer)&x,sizeof(float));
}
void show_pointer (void* x){
    show_bytes ((byte_pointer)&x,sizeof(void* x));
}
void test_show_bytes (int val){
    int ival = val;
    float fval =(float)ival;
    int *pval = &ival;
    show_int(ival);
    show_float(fval);
    show_pointer(pval);
}

  以上代码打印示例数据对象的字节表示如下表:

机器 类型 字节(十六进制)
Linux32 12345 int 39 30 00 00
Windows 12345 int 39 30 00 00
Linux64 12345 int 39 30 00 00
Linux32 12345.0 float 00 e4 40 46
Windows 12345.0 float 00 e4 40 46
Linux64 12345.0 float 00 e4 40 46
Linux32 &ival int * e4 f9 ff bf
Windows &ival int * b4 cc 22 00
Linux64 &ival int * b8 11 e5 ff ff 7f 00 00

  注:Linux64为x86-64处理器。

  除了字节顺序以外,int和 float的结果是一样的。指针值与机器类型相关。参数12345的十六进制表示为0x00003039。对于int类型的数据,除了字节顺序以外,我们在所有机器上都得到相同的结果。此外,指针值却是完全不同的。不同的机器/操作系统配置使用不同的存储分配规则。( Linux32、 Windows机器使用4字节地址,而 Linux64使用8字节地址)

  可以观察到,尽管浮点型和整型数据都是对数值12345编码,但是它们有截然不同的字节模式:整型为0x00003039,而浮点数为0x4640E400。一般而言,这两种格式使用不同的编码方法。

位运算符&逻辑运算符

  位运算符:& | ~ ^。逻辑运算符:&& || !。特别要 ~ 和!的区别,看下面的例子。

表达式 结果
!0x41 0x00
!!0x41 0x01
!0x00 0x01
~0x41 0x3E
~0x00 0xff

  “!”逻辑非运算符,逻辑操作符一般将其操作数视为条件表达式,返回结果为Bool类型:“!true”表示条件为真(true)。“!false ”表示条件为假(false)。

  ”~”位运算符,代表位的取反,对于整形变量,对每一个二进制位进行取反,0变1,1变0。

^为异或运算符,有一个重要的性质:a ^ a = 0,a ^ 0= a。即任何数和其自身异或结果为0,和0异或结果仍为原来的数。利用这个性质,我们可以找出数组中只出现一次/两次/三次等的数字。如何找呢?

例1:假设给定一个数组 arr,除了某个元素只出现一次以外,其余每个元素均出现两次。找出那个只出现了一次的元素。

思路:其余元素出现了都是两次,因此,将数组内的所有元素依次异或,最后的结果即为只出现一次的元素。比如,arr = [0,0,1,1,8,8,12],0 ^ 0 ^ 1 ^ 1^ 8^ 8^ 12 = 12。 感兴趣的可以自己编程试下。

例2:给定一个整数数组 arr,其中恰好有两个元素只出现一次,其余所有元素均出现两次。 找出只出现一次的那两个元素。

思路:首先可以通过异或获得两个出现一次的数字的异或值,该异或值中的为1的bit位肯定是来自这两个数字之中的一个。然后可以随便选一个为1的bit位,按照这个bit位,将所有该位为1的数字分为一组,所有该位为0的数字分为一组,这样就成了查找两个子数组中只出现了一次的数字。

例3:假设给定一个数组 arr,除了某个元素只出现一次以外,其余每个元素均出现了三次。找出那个只出现了一次的元素。

思路:可以自己考虑下。

  逻辑运算符&& 和||还有一个短路求值的性质。具体如下。

  如果对第一个参数求值就能确定表达式的结果,那么逻辑运算符就不会对第二个参数求值。常用的例子如下

int main()
{
    int a=3,b=3;
    (a=0)&&(b=5);
    printf("a=%d,b=%d\n",a,b); // a = 0 ,b = 3
    (a=1)||(b=5);
    printf("a=%d,b=%d",a,b); // a = 1 ,b = 3
} 

  a=0为假所以没有对B进行操作

  a=1为真,所以没有对b进行操作

逻辑左移和算术左移

  逻辑左移(SHL)和算数左移(SAL),规则相同,右边统一添0

  逻辑右移(SHR),左边统一添0

  算数右移(SAR),左边添加的数和符号有关 (正数补0,负数补1)

  比如一个有符号位的8位二进制数11001101,逻辑右移不管符号位,如果移一位就变成01100110。算术右移要管符号位,右移一位变成11100110。

  e.g:1010101010,其中[]位是添加的数字

  逻辑左移一位:010101010[0]

  算数左移一位:010101010[0]

  逻辑右移一位:[0]101010101

  算数右移一位:[1]101010101

移位符号(<<, >>, >>>)

  <<,有符号左移位,将运算数的二进制整体左移指定位数,低位用0补齐。

  >>,有符号右移位,将运算数的二进制整体右移指定位数,正数高位用0补齐,负数高位用1补齐(保持负数符号不变)。

扩展:当移动位数大于实际位数时该怎么办?

  对于一个由w位组成的数据类型,如果要移动k≥w位会得到什么结果呢?例如,计算下面的表达式会得到什么结果,假设数据类型int为w=32。

int lval=OxFEDCBA98 << 32;
int aval=0xFEDCBA98 >> 36;
unsigned uval = OxFEDCBA98u >>40;

   C语言标准很小心地规避了说明在这种情况下该如何做。在许多机器上,当移动一个w位的值时,移位指令只考虑位移量的低\([{\log _2}w]\)位,因此实际上位移量就是通过计算k mod w得到的。例如,当w=32时,上面三个移位运算分别是移动0、4和8位,得到结果:

lval OxFEDCBA98
aval OxFFEDCBA9
uvaL OXOOFEDCBA

  不过这种行为对于C程序来说是没有保证的,所以应该保持位移量小于待移位值的位数。

整数表示

  约定一些术语如下所示

符号 类型 含义
\([B2{T_w}]\) 函数 二进制转补码
\([B2{U_w}]\) 函数 二进制转无符号数
\([U2{B_w}]\) 函数 无符号数转二进制
\([U2{T_w}]\) 函数 无符号转补码
\([T2{B_w}]\) 函数 补码转二进制
\([T2{U_w}]\) 函数 补码转无符号数
\(T{Min_w}\) 常数 最小补码值
\(T{Max_w}\) 常数 最大补码值
\(U{Max_w}\) 常数 最大无符号数
\(+ _w^t\) 操作 补码加法
\(+ _w^u\) 操作 无符号数加法
\(* _w^t\) 操作 补码乘法
\(* _w^u\) 操作 无符号数乘法
\(- _w^t\) 操作 补码取反
\(- _w^u\) 操作 无符号数取反

无符号数的编码

  无符号数编码的定义:

  对向量\(\vec x = [{x_{w – 1}},{x_{w – 2}}, \cdots ,{x_0}]\):\(B2{U_w}(\vec x) = \sum\limits_{i = 0}^{w – 1} {{x_i}{2^i}}\)

  其中,\(\vec x\)看作一个二进制表示的数,每个位\({x_i}\)取值为0或1。举个例子如下所示。

\(B2{U_4}([0001]) = 0*{2^3} + 0*{2^2} + 0*{2^1} + 1*{2^0} = 0 + 0 + 0 + 1 = 1\)

\(B2{U_4}([1111]) = 1*{2^3} + 1*{2^2} + 1*{2^1} + 1*{2^0} = 8 + 4 + 2 + 1 = 15\)

  无符号数能表示的最大值为:\(UMa{x_w} = \sum\limits_{i = 0}^{w – 1} {{2^w} – 1}\)

补码的编码

  补码编码的定义:

  对向量\(\vec x = [{x_{w – 1}},{x_{w – 2}}, \cdots ,{x_0}]\):\(B2{T_w}(\vec x) = – {x_{w – 1}}{2^{w – 1}} + \sum\limits_{i = 0}^{w – 2} {{x_i}{2^i}}\)

  最高有效位\({x_{w – 1}}\)也称为符号位,它的“权重”为$ – {2^{w – 1}}$,是无符号表示中权重的负数。符号位被设置为1时,表示值为负,而当设置为0时,值为非负。举个例子

\(B2{T_4}([0001]) = – 0*{2^3} + 0*{2^2} + 0*{2^1} + 1*{2^0} = 0 + 0 + 0 + 1 = 1\)

\(B2{T_4}([1111]) = – 1*{2^3} + 1*{2^2} + 1*{2^1} + 1*{2^0} = – 8 + 4 + 2 + 1 = – 1\)

  w位补码所能表示的范围:\(TMi{n_w} = – {2^{w – 1}}\),\(TMa{x_w} = {2^{w – 1}} – 1\)

关于补码需要注意的地方

  第一,补码的范围是不对称的\(\left| {TMin} \right| = \left| {TMax} \right| + 1\),也就是说,\(TMin\)没有与之对应的正数。之所以会有这样的不对称性,是因为一半的位模式(符号位设置为1的数)表示负数,而另一半(符号位设置为0的数)表示非负数。因为0是非负数,也就意味着能表示的整数比负数少一个。

  第二,最大的无符号数值刚好比补码的最大值的两倍大一点:\(UMa{x_w} = 2TMa{x_w} + 1\)补码表示中所有表示负数的位模式在无符号表示中都变成了正数

  注:补码并不是计算机表示负数的唯一方式,只是大家都采用了这种方式。计算机也可以用其他方式表示负数,比如原码和反码。有兴趣可以继续深入了解。

确定数据类型的大小

  在不同位长的系统中,int,double,long等占据的位数不同,其可表示的范围的大小也不一样,如何编写具有通用性的程序呢?ISO C99标准在文件stdint.h中引入了整数类型类。这个文件定义了一组数据类型,他们的声明形式位:intN_t,uintN_t(N取值一般为8,16,32,64)。比如,uint16_t在任何操作系统中都可以表述一个16位的无符号变量。int32_t表示32位有符号变量。

  同样的,这些数据类型的最大值和最小值由一组宏定义表示,比如INTN_MIN,INTN_MAX和UINTN_MAX。

  打印确定类型的内容时,需要使用宏。

  比如,打印int32_t,uint64_t,可以用如下方式:

printf("x=%"PRId32",y=%"PRIu64"\n",x,y);

  编译为64位程序时,宏PRId32展开成字符串“d”,宏PRIu64则展开成两个字符串“l”“u”。当C预处理器遇到仅用空格(或其他空白字符)分隔的一个字符串常量序列时,就把它们串联起来。因此,上面的 printf调用就变成了:printf("x=%d.y=%lu\n",x,y);

  使用宏能保证:不论代码是如何被编译的,都能生成正确的格式字符串。

无符号数和补码的相互转化

  补码转换为无符号数:

对于满足在这里插入图片描述

举例如下:

x \(T2{U_4}(x)\) 解释
-8 8 -8<0,-8+\({2^4}\)=16,\(T2{U_4}(-8)\)=16
-3 13 -3<0,-3+\({2^4}\)=13,\(T2{U_4}(-3)\)=16
-2 14 -2<0,-2+\({2^4}\)=14,\(T2{U_4}(-2)\)=16
-1 15 -1<0,-1+\({2^4}\)=15,\(T2{U_4}(-1)\)=16
0 0 \(T2{U_4}(0)\)=0
5 5 \(T2{U_4}(5)\)=5

  无符号数转补码:

  对于满足\(0 \le u \le UMa{x_w}\)的u有:在这里插入图片描述

  结合下面两张图理解下:

  从补码到无符号数的转换。函数T2U将负数转换为大的正数

image-20201023171812180

  从无符号数到补码的转换。函数U2T把大于\({2^{w – 1}} – 1\)的数字转换为负值

image-20201023172007748

有符号数与无符号数的转换

  前面提到过,补码并不是计算机表示负数的唯一方式,但是几乎所有的计算机都是使用补码来表示负数。因此无符号数转有符号数就是使用函数\(U2{T_w}\),而从有符号数转无符号数就是应用函数\(T2{U_w}\)

  注意:当执行一个运算时,如果它的一个运算数是有符号的而另一个是无符号的,那么C语言会隐式地将有符号参数强制类型转换为无符号数,并假设这两个数都是非负的,来执行这个运算。

  比如,假设数据类型int表示为32位补码,求表达式-1<0U的值。因为第二个运算数是无符号的,第一个运算数就会被隐式地转换为无符号数,因此表达式就等价于4294967295U<0U,所以表达式的值为0。

扩展数字

  要将一个无符号数转换为一个更大的数据类型,我们只要简单地在表示的开头添加0。这种运算被称为零扩展( zero extension)。

  比如,将16位的无符号数12(0xC),扩展为32位为0x0000000C。

  要将一个补码数字转换为一个更大的数据类型,可以执行一个符号扩展( sign exten sion),即扩展符号位。

  比如,将16位的有符号数-25(0x8019,1000000000011001),扩展为32位为0xffff8019。

截断数字

  无符号数截断公式为:\(B2{U_k}(x) = B2{U_w}(X)mod{2^k}\)等价于\(x’ = x\bmod {2^k}\),\(x’\)为其截断k位的结果。

  比如,将9从int转换为short,即需要截断16位,k=16。\(9\bmod {2^{16}} = 9\)。因此,9从int转换为short的结果为9。

  有符号数的截断公式为:\(B2{T_k}(x) = U2{T_k}(B2{U_w}(X)mod{2^k})\)等价于\(x’ = U2{T_k}(x{\kern 1pt} {\kern 1pt} {\kern 1pt} mod{2^k})\),\(x’\)为其截断k位的结果。

  比如,将53791从int转换为short,即需要截断16位,k=16。\(53791\bmod {2^{16}} = 53791\)\(U2{T_{16}}(53791) = 53791 – 65536 = – 12345\)。因此,53791从int转换为short的结果为-12345。

  无符号数截断的几个例子(将4位数值截断为3位)

原始值 截断值
0 0
2 2
9 1
11 3
15 7

  有符号数截断的几个例子(将4位数值截断为3位)

原始值 截断值
0 0
2 2
-7 1
-5 3
-1 -1

小结

  关于有符号数和无符号数的转换,数字的扩展与截断,经常发生于不同类型不同位长数字的转换,这些操作一般都是由计算机自动完成的,但是我们最好要知道计算机是如何完成转换的,这对于我们检查BUG是特别有用的。这些内容我们不一定要都记住,但是当发生错误时,我们是要知道从哪里检查。

整数运算

无符号数加法

  对满足在这里插入图片描述
,正常情况下,x+y的值保持不变,而溢出情况则是该和减去\({2^{\rm{w}}}\)的结果。

比如,考虑一个4位数字表示(最大值为15),x=9,y=12,和为21,超出了范围。那么x+y的结果为9+12-15=6。

补码加法

对满足
在这里插入图片描述

  当\({{2^{w – 1}} \le x + y}\),产生正溢出,当\({w + y < – {2^{w – 1}}}\),产生负溢出。当\({ – {2^{w – 1}} \le x + y < {2^{w – 1}}}\),正常。具体参考下图。

image-20201023201221606

  举例如下表所示(以4位补码加法为例)

x y x+y \(x + _4^ty\) 情况
-8[1000] -5[1011] -13[10011] 3[0011] 1
-8[1000] -8[1000] -16[10000] 0[0000] 1
-8[1000] 5[0101] -3[11101] -3[1101] 2
2[0010] 5[0101] 7[00111] 7[0111] 3
5[0101] 5[0101] 10[01010] -6[1010] 4

补码的非

  对满足\(TMi{n_w} \le x \le TMa{x_w}\)的x,其补码的非\(- _w^tx\)由下式给出

在这里插入图片描述

(吐槽下CSDN,使用typora写好latex公式,粘贴过来报错,原来CSDN的Markdown是用Katex渲染的。这不是增加工作量吗?)

  也就是说,对w位的补码加法来说,\({TMi{n_w}}\)是自己的加法的逆,而对其他任何数值x都有-x作为其加法的逆。

无符号数的乘法

  对满足\(0 \le x,y \le UMa{x_w}\)的x和y有:\(x*_w^uy = (x*y)mod{2^w}\)

补码的乘法

  对满足\(TMi{n_w} \le {\rm{x}}{\rm{y}} \le TM{\rm{a}}{{\rm{x}}_{\rm{w}}}\)的x和y有:\(x*_w^ty = U2{T_w}((x*y)mod{2^w})\)

举例,3位数字乘法的结果

模式 x y x * y 截断的x * y
无符号 4 [100] 5 [101] 20 [010100] 4 [100]
补码 -4 [100] -3 [101] 12 [001100] -4 [100]
无符号 2 [010] 7 [111] 14 [001110] 6 [110]
补码 2 [010] -1 [111] -2 [111110] -2 [110]
无符号 6 [110] 6 [110] 36 [100100] 4 [100]
补码 -2 [110] -2 [110] 4 [000100] -4 [100]

常数与符号数的乘法

  在大多数机器上,整数乘法指令相当慢,需要10个或者更多的时钟周期,然而其他整数运算(例如加法、减法、位级运算和移位)只需要1个时钟周期。因此,编译器使用了一项重要的优化,试着用移位和加法运算的组合来代替乘以常数因子的乘法。

  由于整数乘法比移位和加法的代价要大得多,许多C语言编译器试图以移位、加法和减法的组合来消除很多整数乘以常数的情况。例如,假设一个程序包含表达式x*14。利用\(14 = {2^3} + {2^2} + {2^1}\),编译器会将乘法重写为(x<<3)+(x<<2)+(x<1),将一个乘法替换为三个移位和两个加法。无论x是无符号的还是补码,甚至当乘法会导致溢出时,两个计算都会得到一样的结果。(根据整数运算的属性可以证明这一点。)更好的是,编译器还可以利用属性\(14 = {2^4} – 1\),将乘法重写为(x<<4)-(x<<1),这时只需要两个移位和一个减法。

  归纳以下,对于某个常数的K的表达式x * K生成代码。我们可以用下面两种不同形式中的一种来计算这些位对乘积的影响:

  形式A:\((x < < n) + (x < < (n – 1)) + \cdots + (x < < m)\)

  形式B:\((x < < (n + 1)) – (x < < m)\)

  对于嵌入式开发中,我们经常使用这种方式来操作寄存器了。在编程中,我们要习惯使用移位运算来代替乘法运算,可以大大提高代码的效率。

常数与符号数的除法

  在大多数机器上,整数除法要比整数乘法更慢—需要30个或者更多的时钟周期。除以2的幂也可以用移位运算来实现,只不过我们用的是右移,而不是左移。无符号和补码数分别使用逻辑移位算术移位来达到目的。

无符号数的除法

  对无符号运算使用移位是非常简单的,部分原因是由于无符号数的右移一定是逻辑右移。同时注意,移位总是舍入到零

  举例如下,以12340的16位表示逻辑右移k位的结果。左端移入的零以粗体表示。

k >>k(二进制) 十进制 \(12340/{2^k}\)
0 0011000000110100 12340 12340.0
1 0001100000011010 6170 6170.0
4 0000001100000011 771 771.25
8 0000000000110000 48 48.203125
补码的除法(向下舍入)

  对于除以2的幂的补码运算来说,情况要稍微复杂一些。首先,为了保证负数仍然为负,移位要执行的是算术右移

  对于x≥0,变量x的最高有效位为0,所以效果与逻辑右移是一样的。因此,对于非负数来说,算术右移k位与除以\({2^k}\)是一样的。

  举例如下所示,对-12340的16位表示进行算术右移k位。对于不需要舍入的情况(k=1),结果是\(x/{2^k}\)。当需要进行舍入时,移位导致结果向下舍入。例如,右移4位将会把-771.25向下舍入为-772。我们需要调整策略来处理负数x的除法。

k >>k(二进制) 十进制 \(-12340/{2^k}\)
0 1100111111001100 -12340 -12340.0
1 1110011111100110 -6170 -6170.0
4 1111110011111100 -772 -771.25
8 1111111111001111 -49 -48.203125
补码的除法(向上舍入)

  我们可以通过在移位之前“偏置( biasing)”这个值,来修正这种不合适的舍入。

  下表说明在执行算术右移之前加上一个适当的偏置量是如何导致结果正确舍入的。在第3列,我们给出了-12340加上偏量值之后的结果,低k位(那些会向右移出的位)以斜体表示。我们可以看到,低k位左边的位可能会加1,也可能不会加1。对于不需要舍入的情况(k=1),加上偏量只影响那些被移掉的位。对于需要舍入的情况,加上偏量导致较高的位加1,所以结果会向零舍入

k 偏量 -12340+偏量 >>k(二进制) 十进制 \(-12340/{2^k}\)
0 0 1100111111001100 1100111111001100 -12340 -12340.0
1 1 1100111111001101 1110011111100110 -6170 -6170.0
4 15 1100111111011011 1111110011111100 -771 -771.25
8 255 1101000011001011 1111111111001111 -48 -48.203125

总结

  现在我们看到,除以2的幂可以通过逻辑或者算术右移来实现。这也正是为什么大多数机器上提供这两种类型的右移。不幸的是,这种方法不能推广到除以任意常数。同乘法不同,我们不能用除以2的幂的除法来表示除以任意常数K的除法。

浮点数

二进制小数

  一种关于二进制的小数编码:\(b = \sum\limits_{i = – n}^m {{2^i} \times {b_i}}\)

image-20201024101019148

  二进制小数点向左移动一位相当于这个数被2除,二进制小数点向右移动一位相当于将数乘2。

IEEE浮点表示

IEEE浮点标准用\(V = {( – 1)^s} \times M \times {2^E}\)的形式来表示一个数

  • 符号(sign)s决定这数是负数(s=1)还是正数(s=0),而对于数值0的符号位解释作为特殊情况处理。

  • 尾数( significand)M是一个二进制小数,它的范围是$1 \sim 2 – \varepsilon $,或者是$0 \sim 1 – \varepsilon $。

  • 阶码( exponent)E的作用是对浮点数加权,这个权重是2的E次幂(可能是负数)。将浮点数的位表示划分为三个字段,分别对这些值进行编码:

  • 一个单独的符号位s直接编码符号s

  • k位的阶码字段\(\exp = {e_{k – 1}} \cdots {e_1}{e_0}\)编码阶码E。

  • n位小数字段\(frac = {f_{n – 1}} \cdots {f_1}{f_0}\)编码尾数M,但是编码出来的值也依赖于阶码字段的值是否等于0。

  C语言中的编码方式:

  单精度浮点格式(float) —— s、exp和frac字段分别为1位、k = 8位和n = 23位,得到一个32位表示。

   双精度浮点格式(double) —— s、exp和frac字段分别为1位、k = 11位和n = 52位,得到一个64位表示。

image-20201024102259994

  根据exp的值,被编码的值可以分成三种不同的情况:

image-20201024102336756

   情况1:规格化的值 —— exp的位模式:既不全为0(数值0),也不全为1(单精度数值为255,双精度数值为2047)。

   阶码的值:E = e – Bias(偏置编码法)

  e是无符号数,其位表示为 \({e_{k – 1}} \cdots {e_1}{e_0}\),单精度下取值范围为1~254.双精度下取值范围为1 ~ 2047。

   偏置值\(Bias = {2^{k – 1}} – 1\),单精度下是127,双精度下是1023。

  因此阶段码E的取值范围:单精度下是-126 ~ +127。双精度下是-1022 ~ 1024。

e的范围:1~254

Bias的值:127

E的范围:-126~127

   尾数的值:M=1+f(隐式编码法,因为有个隐含的1,所以无法表示0)

   其中\(0 \le f \le 1.0\),f的二进制表示为\(0.{f_{n – 1}} \cdots {f_1}{f_0}\),也就是二进制小数点在最高有效位的左边。

  因此添加了一个隐含的1,M的取值范围为\(1.0 \le M \le 2.0\)

为什么不在exp域中使用补码编码?为什么采用偏置编码的形式?

exp域如果为补码编码,比较两个浮点数既要比较补码的符号位,又要比较编码位。

而在exp域中采用偏置编码,我们只需要比较一次无符号数e的值就可以了。

举例:float f = 15213.0

\(\begin{array}{l}
{15213_{10}} = {11101101101101_2}\\
\quad {\kern 1pt} \;\;\, = {1.1101101101101_2} \times {2^{13}}
\end{array}\)

\(\begin{array}{l}
M = {1.1101101101101_2}\\
frac = {11011011011010000000000_2}
\end{array}\)

则:

\(\begin{array}{l}
E = 13\\
Bias = 127\\
\exp = 140 = {10001100_2}
\end{array}\)

image-20201028192446712

  情况2:非规格化的值 —— exp的位模式为全0。

  阶码的值:E = 1 – Bias。

  尾数的值:M = f(没有隐含的1,可以表示0)

  非规格化数有两个用途:

  表示数值0 —— 只要尾数M = 0。

   表示非常接近于0.0的数

  情况3:特殊值 —— exp的位模式为全1。

  当小数域全为0时,得到的值表示无穷,当s = 0时是\(+ \infty\),当s=1时是\(-\infty\)。当小数域为非零0,得到NaN(Not a Number)。

浮点数的运算规则

整数和浮点数相乘

  规则:\(x{ + _f}y = Round(x + y)\),\(x{ \times _f}y = Round(x \times y)\),其中\(Round(x \times y)\)要遵循下表的舍入规则。

1.4 1.6 1.5 2.5 -1.5
向0舍入 1 1 1 2 -1
向负无穷舍入 1 1 1 2 -2
向正无穷舍入 2 2 2 3 -1
偶数舍入(四舍五入) 1 2 2 2 -2
两个浮点数相乘

  两个浮点数相乘规则:\(({( – 1)^{s1}} \times M1 \times {2^{E1}}) \times ({( – 1)^{s2}} \times M2 \times {2^{E2}}) = {( – 1)^S} \times M \times {2^E}\)

  S:s1^s2

  M:M1+M2

  E:E1+E2

两个浮点数相加

  浮点数相加规则:\({( – 1)^{s1}} \times M1 \times {2^{E1}} + {( – 1)^{s2}} \times M2 \times {2^{E2}} = {( – 1)^S} \times M \times {2^E}\)

  S和M的值为两个浮点数小数点对齐后相加的结果。

  E:E1 (假设E1>E2)
image-20201029112315391

浮点数的偶数舍入

  例如有效数字超出规定数位的多余数字是1001,它大于超出规定最低位的一半(即0.5),故最低位进1。如果多余数字是0111,它小于最低位的一半,则舍掉多余数字(截断尾数、截尾)即可。对于多余数字是1000、正好是最低位一半的特殊情况,最低位为0则舍掉多余位,最低位为1则进位1、使得最低位仍为0(偶数)。

  注意这里说明的数位都是指二进制数。

举例:要求保留小数点后3位。

对于1.0011001,舍入处理后为1.010(去掉多余的4位,加0.001)
对于1.0010111,舍入处理后为1.001(去掉多余的4位)
对于1.0011000,舍入处理后为1.010(去掉多余的4位,加0.001,使得最低位为0)

对于1.1001001,舍入处理后为1.101(去掉多余的4位,加0.001)
对于1.1000111,舍入处理后为1.100(去掉多余的4位)
对于1.1001000,舍入处理后为1.100(去掉多余的4位,不加,因为最低位已经为0)

对于1.01011,舍入处理后为1.011(去掉多余的2位,加0.001)
对于1.01001,舍入处理后为1.010(去掉多余的2位)
对于1.01010,舍入处理后为1.010(去掉多余的2位,不加)

注意

  浮点数的运算不支持结合律。

举例:(1e10+3.14)-1e10=0,3.14+(1e10-1e10)=3.14。因为舍入的原因,第一个表达式会丢失3.14。

举例:(1e20 * 1e20)1e-20 求值为正无穷,而1e20 * (1e201e-20) = 1e20。

C语言中的浮点数

在C语言中,当在int、float和 double格式之间进行强制类型转换时,程序改变数值和位模式的原则如下(假设int是32位的)

  • 从int转换成 float,数字不会溢出,但是可能被舍入。
  • 从int或float转换成 double,因为double有更大的范围(也就是可表示值的范围),也有更高的精度(也就是有效位数),所以能够保留精确的数值。
  • 从 double转换成float,因为范围要小一些,所以值可能溢出成\(+ \infty\)\(- \infty\)。另外,由于精确度较小,它还可能被舍入从float或者 double转换成int,值将会向零舍入。例如,1.999将被转换成1,而-1.999将被转换成-1。进一步来说,值可能会溢出。C语言标准没有对这种情况指定固定的结果。一个从浮点数到整数的转换,如果不能为该浮点数找到一个合理的整数近似值,就会产生这样一个值。因此,表达式(int)+1e10会得到-21483648,即从一个正值变成了一个负值。

举例:int x = …; float f = ….;double d =… ;

表达式 对/错 备注
x == (int)(float)x float 没有足够的位表示int,转换会造成精度丢失
x == (int)(double)x
f ==(float)(double)f
d == (double)(float)d float->double精度不够
f == -(-f)
2/3 == 2/3.0
d<0.0 ==> ((d*2)<0.0)
d>f ==> -f >-d
d*d >=0.0
(d+f)-d == f 没有结合律

总结

  本章中需要掌握的内容主要有:无符号数,补码,有符号数的编码方式,可表示的范围大小,相互转换的规则,运算规则。浮点数的编码方式了解即可,这部分有点难以理解,如果后面有用到的话再回来细看,但是对于C语言中其他数据类型到浮点数的转换规则是要掌握的。

  养成习惯,先赞后看!如果觉得写的不错,欢迎关注,点赞,转发,一键三连谢谢!

如遇到排版错乱的问题,可以通过以下链接访问我的CSDN。

CSDN:CSDN搜索“嵌入式与Linux那些事”

欢迎欢迎关注我的公众号:嵌入式与Linux那些事,领取秋招笔试面试大礼包(华为小米等大厂面经,嵌入式知识点总结,笔试题目,简历模版等)和2000G学习资料。