《深入理解计算机系统》学习笔记:信息的表示和处理

概述

整数的表示虽然只能编码一个相对较小的数值范围,但是这种表示是精确的
浮点数虽然可以编码一个较大的数值范围,但是这种表示只是近似的

C语言的演变:

  1. 1972年,最早的C语言是贝尔实验室的Dennis Ritchie开发出来的,目的是和Uinx操作系统一起使用(Unix也是贝尔实验室开发的,类Unix的Linux是芬兰赫尔辛基大学大学二年级的学生Linus Torvalds开发的)。
  2. 1989年,美国国家标准学会下的一个工作组推出了ANSI C标准(有时候也称为C89),对最初的贝尔实验室的C语言做了重大修改。
  3. 1990年,国际标准化组织接替了对C语言标准化任务,推出了一个几乎和ANSI C一样的版本,称为“ISO C90”。
  4. 1999年,国际标准化组织对C语言做了更新,推出了“ ISO C99”,引入了一些新的数据类型。
  5. 2011年,国际标准化组织对C语言做了更新,推出了“ISO C11”。
C版本 GCC命令行选项
GNU 89 无,-std=gnu89
ANSI,ISO C90 -ansi,-std=c89
ISO C99 -std=c99
ISO C11 -std=c11

命令行例子:
linux> gcc -std=c11 prog.c

一、信息存储

大多数计算机使用8位的块,或者字节(byte),作为最小的可寻址的内存单位,而不是访问内存中单独的位。

C语言中的一个指针的值(无论它指向一个整数、一个结构或是某个其他程序对象)都是某个存储块的第一个字节的虚拟地址。

指针有两个方面:值和类型。它的值表示某个对象的位置,而它的类型表示那个位置上所存储对象的类型(比如整数或者浮点数)。

1.1、十六进制表示法

十六进制转换位二进制:通过展开每个十六进制数字,将它转换为二进制格式。
二进制转换为十六进制:从右边往左,每4位一组来转换为十六进制,如果位总数不是4的倍数,最左边的一组可以少于4位,前面用0补足。

1.2、子数据大小

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

大多数64位机器也可以运行为32位机器编写的程序,这是一种向后兼容。因此,举例来说,当程序prog.c用如下伪指令编译:

linux> gcc -m32 prog.c

该程序就可以在32位或64位机器上正确运行。另一方面,若程序用下述伪指令编译:

linux> gcc -m64 prog.c

那就只能在64位机器上运行。因此,我们将程序称为“32位程序”或“64位程序”时,区别在于该程序是如何编译的,而不是其运行的机器类型。

C标准保证的字节数典型的字节数之间的关系。所谓保证的取值范围就是这些数据类型必须至少具有这样的取值范围。

保证的字节数和典型的字节数一样或者小一些,特别地,除了固定大小的数据类型是例外,我们看到它们只要求正数和负数的取值范围是对称的

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

1.3、寻址和字节顺序

在几乎所有的机器上,多字节对象都被存储为连续的字节序列,对象的地址为所使用字节中最小的地址

小端法(little endian):多字节对象的高位存储在内存的高地址,低位存储在内存的低地址。
大端法(big endian):多字节对象的高位存储在内存的低地址,低位存储在内存的高地址。

许多比较新的微处理器是双端法(bi-endian),也就是说可以把它们配置成作为大端或者小端的机器运行。然而,实际情况是:一旦选择了特定操作系统,那么字节顺序也就固定下来了。比如,用于许多移动电话的ARM微处理器,其硬件可以按小端或大端两种模式操作,但是这些芯片上最常见的两种操作系统——Android (来自Google) 和iOS (来自Apple) —— 却只能运行于小端模式。

为了便于不同字节顺序的终端进行网络通信,将网络字节序统一为大端字节序进行通信。

选择何种字节顺序没有技术上的理由。

1.4、表示字符串

C语言中字符串被编码为一个以 null (其值为0) 字符结尾的字符数组。

基本编码,称为 Unicode 的 “ 统一字符集 ” ,使用32位来表示字符。这好像要求文本串中每个字符要占用4个字节。不过,可以有一些替代编码,常见的字符只需要1个或2个字节,而不太常用的字符需要多一些的字节数。

1.5、表示代码

当我们在示例机器上编译时,生成机器代码,对于不同的机器类型(我想是不是不同的编译器???)使用不同的且不兼容的指令和编码方式。

用一台电脑,安装不同的系统,同一个编译器,那么同一个C程序会编译生成不同的机器代码???按理说硬件和汇编指令确定的,不确定的是C代码生成汇编指令的规则。

即使是完全一样的进程,运行在不同的操作系统上也会有不同的编码规则。

1.6、布尔代数简介

通过将逻辑值 TRUE ( 真 ) 和 FALSE ( 假 ) 编码为二进制1和0,能够设计出一种代数。

布尔运算符 ~ 对应于逻辑运算 NOT
布尔运算符 & 对应于逻辑运算 AND
布尔运算符 | 对应于逻辑运算 OR
布尔运算符 ^ 对应于逻辑运算 EXCLUSIVE-OR

将多个布尔运算扩展到位向量运算,所谓 位向量 就是固定长度位 w 、由 0 和 1 组成的串。

布尔运算 & 对 | 的分配律:a & ( b | c ) = (a & b) | (a & c)
布尔运算 | 对 & 的分配律:a | ( b & c ) = (a | b) & (a | c)
a ^ a = 0,当我们重新排列组合顺序,这个属性也仍然成立。(a ^ b) ^ a = b。

1.7、C语言中的位级运算(位运算)

C语言的一个很有用的特性就是它支持按位布尔运算 。事实上,我们在布尔运算中使用的那些符号就是C语言所使用的:| 就是 OR(或),& 就是 AND(与),~ 就是 NOT (取反),而 ^ 就是 EXCLUSIVE-OR(异或)。

用异或运算符实现不借助第三个变量完成两个变量交换值,这种交换方式并没有性能上的优势,它仅仅是一个智力游戏。

1.8、C语言中的逻辑运算

C语言还提供了一组逻辑运算符 || 、&& 和 !,分别对应于命题逻辑中大的 OR 、AND 和 NOT 运算。

逻辑运算很容易和位级运算相混淆,但是它们的功能是完全不同的。

逻辑运算认为所有非零的参数都表示 TRUE,而参数0表示FALSE。它们返回 1 或者 0 ,分别表示结果为 TRUE 或者为 FALSE。

逻辑运算符 && 和 || 又称为短路运算符,即如果对第一个参数求值就能确定表达式的结果,那么逻辑运算符就不会对第二个参数求值。

1.9、C语言中的移位运算

左移运算:x 向左移动 k 位,丢弃最高的 k 位,并在右端补 k 个 0。

右移运算:一般而言,机器支持两种形式的右移——逻辑右移和算术右移。
逻辑右移:逻辑右移一次,在左端补一个0;
算术右移:算术右移一次,在左端补一个最高有效位的值。

那右移时怎么确定是逻辑右移还是算术右移???
C语言标准并没有明确定义对于有符号数应该使用哪种类型的右移,实际上,几乎所有的编译器 / 机器组合都对有符号数使用算术右移,且许多程序员也都假设机器会使用这种右移。另一方面,对于无符号数,右移必须是逻辑的

移动 k 位,这里 k 很大???
对于一个由 w 位组成的数据类型,如果要移动 k ≥ w 位会得到什么结果呢?实际上位移量就是通过计算k mod w。例如,当 w = 32 时,k 等于 36,实际上移动 4 位。

二、整数表示

2.1、整型数据类型

用位来编码整数的两种不同的方式:一种只能表示非负数(无符号),而另一种能够表示负数、零和正数(有符号)。

研究扩展或者收缩一个已编码整数以适应不同长度表示的效果。

C 和 C++ 都支持有符号(默认)和无符号数。Java 只支持有符号数。

2.2、无符号数的编码

[ 0101 ] = 0·23 + 1·22 + 0·21 + 1·20 = 5
[ 1011 ] = 1·23 + 0·22 + 1·21 + 1·20 = 11

2.3、补码编码

C语言标准并没有要求用补码形式来表示有符号整数,但是几乎所有的机器都是这么做的。

最常见的有符号数的计算机表示方式就是补码(two’s - complement)形式。

方式一:
最高有效位也称为符号位,它的“权重”为-2w-1
[ 0101 ] = -0·23 + 1·22 + 0·21 + 1·20 = 5
[ 1011 ] = -1·23 + 0·22 + 1·21 + 1·20 = -5

方式二:

正数的二进制原码、反码、补码均相同,为原码的形式。
负数的原码为符号位为1,其余为负数绝对值的原码;
负数的反码为符号位保持不变,其余各位取反;
负数的补码为负数的反码加1。

①十进制转二进制(-5)
原码:1101
反码:1010
补码:1011(内存中保存的)

②二进制转十进制(-5)
补码:1011(内存中保存的)
反码:1100
补码:1101
左边第一位为符号位,即为负数,101为5,则结果为-5。

2.4、有符号数和无符号数之间的转换

在相同数据大小的情况下,有符号数和无符号数之间的转换,结果就是保持位值不变,只是改变了解释这些位的方式

原理:补码转换为无符号数(当 w = 16 时)
当x < 0,则结果为x+2w,例如 T2U(-12345) = -12345 + 216 = 53191

当x ≥ 0,则结果为x,例如 T2U(12345) = 12345

原理:无符号数转换为补码(当 w = 8 时)
当 u ≤ 有符号数最大值,则结果为 u,例如 U2T(10) = 10

当x ≥ 有符号数最大值,则结果为 u - 2w,例如 U2T(128) = 128 - 256 = -128

2.5、C语言中的有符号数和无符号数

C语言允许无符号数和有符号数之间的转换,虽然C标准没有精确规定应该如何进行这种转换,但大多数系统遵循的原则是底层的位保持不变

当执行一个运算时,如果它的一个运算数是有符号的而另一个是无符号的,那么C语言会隐式地将有符号参数强制类型转换为无符号数,并假设这两个数都是非负数的,来执行这个运算。这个方法对于标准的算术来说并无多大差异对于<和>这样的关系运算符来说,它会导致非直观的结果

例如:-1 < 0U,判断结果是否正确?
解:由于0后面加上U,所以0为无符号数,那么需要将-1准换为无符号数,按照大部分计算机采用的方式,则-1变成无符号数就是-1+232,远大于0,所以-1 < 0U 是错误的。

例如:-1 < 0,判断结果是否正确?
解:编译器会将 -1 和 0 都按有符号数来处理,所以是正确的。

判断 (-2147483647 - 1U) < -2147483647是否正确?
解:-2147483647 - 1U = (-2147483647+2^32) - 1 = 2147483648
-2147483647 转换为 2147483649
所以为正确的

个人想法: 右移运算分为逻辑右移和算术右移,相似的大于运算符可以分为补码大于无符号大于(小于一样的)。

2.6、扩展一个数字的位表示

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

要将一个补码数字转换为一个更大的数据类型,可以执行一个符号扩展(sign extension)在表示中添加最高有效位的值

short 转换成 unsigned int,是先改变大小,还是先该改变符号???
答案:先改变大小,再改变符号

#include <stdio.h>

int main(void)
{
    
    
	short sx = -12345;  //0xcfc7
	unsigned short tmp = (unsigned short)sx;
	unsigned int value = (unsigned int)tmp;
	
	printf("%u\n", value);
	value = (unsigned int)sx;
	printf("%u\n", value);
	
	return 0;
}

/*
运行结果为:
53191
4294954951
*/

情况一:先符号后大小
①有符号short转换为无符号short
tmp = 0xcfc7
①无符号short转换为无符号int
因为是无符号数,只是简单的在开头加 0
value = 0x0000cfc7 = 53191

情况二:先大小后符号
①有符号short扩展位数
因为是有符号数,且最高有效位为1,所以
tmp = 0xffffcfc7
①有符号int转换为无符号int
value = 0xffffcfc7 = 4294954951

2.7、截断数字

将32位的 int 截断为 16位的short int,取其低16位。

截断 k 位,相当于取 2k 的模。

三、整数运算

3.1、无符号加法

无符号数加法(假设数据位数为 w 位):
当 x+y < 2w ,则结果为x+y ;(正常)
当 2w < x+y < 2w+1,则结果为 x+y-2w ;(溢出)

当执行C程序时,不会将溢出作为错误而发信号。

检测无符号数加法中的溢出:
s = x + y,当且仅当 s < x 或 s < y 时,发生了溢出。

每个元素x有一个加法逆元y,使得 x + y = 0

无符号数求反(求y)这个不同于~取反运算符):
当 x = 0,y = x = 0
当 x > 0,y = 2w - x (x + y = 2w,正好会溢出,所以 x + y = 0)

3.2、补码加法

补码加法(假设数据位数为 w 位):
当 2w-1 ≤ x+y ,则结果为x + y - 2w; (正溢出,进位到符号位)
当 -2w-1 < x+y < 2w-1,则结果为 x+y (正常)
当 x+y < -2w-1,则结果为 x+y + 2w(负溢出,符号位溢出)

检测补码加法中的溢出:
当且仅当 x > 0,y > 0,但 x + y ≤ 0 时,则发生了正溢出;
当且仅当 x < 0,y < 0,但 x + y ≥ 0 时,则发生了负溢出;

每个元素x有一个加法逆元y,使得 x + y = 0

补码的非(求y): y = -x
当 x = 补码的最小值,则结果为补码的最小值;
当 x > 补码的最小值,则结果为-x;
其实补码最小值的负数还是其本身,所以总的来说,y = -x。

在C语言中,对于任意整数值x,计算表达式 -x 和 ~x + 1得到的结果完全时一样的。(P66)

3.3、无符号乘法

范围在(0,2w - 1)内的两个整数相乘,结果可能需要 2w 来表示,不过,C语言中的无符号乘法被定义为产生 w 位的值,就是 2w 位的整数乘积的低 w 位表示的值,乘积结果截断保留 w 位

值 = (x · y) mod 2w

3.4、补码乘法

范围在(-2w-1 - 1,2w-1 - 1)内的两个整数相乘,结果可能需要2w来表示,C语言中有符号乘法是通过将 2w 位的乘积截断为 w 位来实现的

值 = 无符号转换为补码[ (x · y) mod 2w ]

模式 x y x·y 截断的x·y
无符号 5 ( [101] ) 3 ( [011] ) 15 ( [001111] ) 7 ( [111] )
补码 -3 ( [101] ) 3 ( [011] ) -9 ( [110111] ) -1 ( [111] )

虽然完整的乘积的位级表示可能会不同(15 ( [001111] 不等于 -9 ( [110111]),但是截断后乘积的位级表示是相同的(7 ( [111] ) 等于 -1 ( [111] ))。

3.5、乘以常数

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

原理:与2的幂相乘的无符号乘法
C变量 x 和 k 有无符号数值 x 和 k,且 0≤k<w ,则C表达式 x<<k 产生数值x * 2k

原理:与2的幂相乘的补码乘法
C变量 x 和 k 有有码值 x 和无符号数值 k,且 0≤k<w ,则C表达式 x<<k 产生数值x * 2k

原理:乘以任意常数(比如 14)
一个程序包含表达式 x * 14,利用 14 = 23 + 22 + 21,编译器会将乘法重写为> (x<<3) + (x<<2) + (x<<1),将一个乘法替换为三个位移和两个加法。
更好的是,编译器还利用利用属性14 = 24 - 21,将乘法重写为 (x<<4) - (x<<1),这时只需要两个位移和一个减法。

3.6、除以2的幂

在大多数机器上,整数除法要比整数乘法更慢——需要30个或者更多的时钟周期。

除以2的幂也可以用移位运算来实现,无符号和补码数分别用逻辑右移和算术右移

原理:除以2的幂的无符号除数,向下舍入
C变量 x 和 k 有无符号值 x 和 k,且 0 ≤ k<w,则C表达式 x>> k产生数值x/2k向下舍入。

原理:除以2的幂的补码除数,向下舍入
C变量 x 和 k 分别有有补码值 x 和无符号数值 k,且 0 ≤ k<w,则当执行算术移位时,C表达式 x>> k产生数值x/2k向下舍入。

原理:除以2的幂的补码除数,向上舍入
C变量 x 和 k 分别有有补码值 x 和无符号数值 k,且 0 ≤ k<w,则当执行算术移位时,C表达式 (x+ (1 << k) - 1) >> k 产生数值x/2k向上舍入。

除号的正负取舍和一般的算数一样,符号相同为正,相异为负;求余符号的正负取舍和被除数符号相同。

四、浮点数

4.1 - 4.2、二进制小数与IEEE浮点表示

参考:浮点数存储规则、进制转换、有效位与范围、精度损失与数值比较

IEEE( 电气和电子工程师协会 ):读做 “eye-triple-ee” 是一个包括电子和计算机技术的专业团队。它出版刊物,举办会议,并且建立委员会来定义标准,内容涉及从电力传输到软件工程。另一个IEEE标准的例子是无线网络的802.11标准。

计算机表示浮点型数值,其实是一个离散的一个概念,以float为例子,表示范围为 -3.4*238~3.4*238,用32位表示在这范围内的若干个点,并且这些点不是均匀分布的,越靠近原点处它们越稠密

4.3、数字示例

假定的8位浮点格式的示例,其中有 4 位阶码和 3 小数位,偏置量是24-1-1=7。
在这里插入图片描述
注:表中为非负值示例。

从表中可知,
1、非规格化数集中在原点0附近;
2、最大非规格化数和最小规格化数之间转变平滑。

4.4、舍入

舍入(rounding):表示方法限制了浮点数的范围和精度,所以浮点运算只能近似地表示实数运算。舍入运算的任务,一个关键问题实在两个可能的值的中间确定舍入方向。

IEEE浮点格式定义了四种不同的舍入方式,如下表所示。向偶数舍入(round-to-even),也被称为向最接近的值舍入(round-to-nearest),是默认的方式。

向偶数舍入方式采用的方法是:它将数字向上或者向下舍入,使得结果的最低有效数字是偶数。因此,这种方法将1.5和2.5都舍入成2。

方式 1.40 1.60 1.50 2.50 -1.50
向偶数舍入 1 2 2 2 -2
向零舍入 1 1 1 2 -1
向下舍入 1 1 1 2 -2
向上舍入 2 2 2 3 -1

向偶数舍入初看上去好像是个相当随意的目标——有什么理由偏向取偶数呢?为什么不始终把位于两个可表示的值中间的值都向上舍入呢?
使用这种方法的一个问题就是很容易假想到这样一个场景:这种方法舍入一组数值,会在计算这些值的平均数中引入统计偏差。我们采用这种方式舍入得到的一组数的平均值将比这些数本身的平均值略高一些。相反,如果我们总是把两个可表示值中间的数字向下舍入,那么舍入后的一组数的平均值将比这些数本身的平均值略低一些,向偶数舍入在大多数现实情况中避免了这种统计偏差。在50%的时间里,它将向上舍入,而在50%的时间里,它将向下舍入。

向偶数舍入法能够运用在二进制小数上。将最低有效位的值0认为是偶数,值1认为是奇数。一般来说,只有对形如XX···X.YY···Y100···的二进制位模式的数,这种舍入方式才有效,其中X和Y表示任意位值,最右边的Y是要被舍入的位置。只有这种位模式表示在两个可能的结果正中间的值。

示例:
10.00011 = 10.00 (向下舍入)
10.00110 = 10.01 (向上舍入)
10.11100 = 11.00 (向上舍入)
10.10100 = 10.10 (向下舍入)

4.5、浮点运算

当参数有一个特殊值(如-0、-∞或 NaN)时,IEEE标准定义了一些使之更合理的规则。例如,定义1/-0 将产生-∞,而定义1/+0 会产生+∞。

由于可能发生溢出,或者由于舍入而失去精度,浮点数不具有结合性,例如,单精度情况下,表达式(1e20*1e20)*1e-20求值为+∞,而1e20*(1e20*1e-20)将得出1e20。

浮点乘法在加法上不具有分配性, 例如,单精度情况下,表达式1e20*(1e20-1e20)求值为0.0,而1e20*1e20-1e20*1e20会得出NaN。

浮点数加法和浮点乘法满足单调性,无符号或补码没有这些单调性。

4.6、C语言中的浮点数

C语言标准不要求机器使用IEEE浮点,所以没有标准的方法来改变舍入方式或者得到诸如-0、+∞、-∞或者NaN之类的特殊值。大多数系统提供include(".h")文件和读取这些特征的过程库,但是细节随系统不同而不同。例如,当程序文件中出现下列句子时,GNU编译器GCC会定义程序常数INFINITY(表示+∞)和NAN(表示NaN):
#define _GNU_SOURCE 1
#include <math.h>

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

  • 从int 转换成 float,数字不会溢出,但是可能被舍入。
  • 从int 或float 转换成double,因为 double 有更大的范围(也就是可表示值的范围),也有更高的精度(也就是有效位数),所以能够保留精确的数值。
  • 从 double 转换成 float ,因为范围要小一些,所以值可能溢出或成 +∞ 或 -∞。另外由于精度较小,它还可能被舍入。
  • 从 float 或者 double 转换成 int,值将会向零舍入。

五、参考资料

[1]: Randal E. Bryant, David R. O’Hallaron. 深入理解计算机系统[M]. 机械工业出版社, 2017.

猜你喜欢

转载自blog.csdn.net/weixin_42258222/article/details/109083562