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

606 阅读20分钟

前言(2021.11.14) 从今天(2021.11.14)开始将记录读书笔记,这本黑皮书是2021年的1024程序员节打折买的,同样也是leader推荐我去补基础知识的入门书。

最近确实导师催得比较紧,本专业的论文,项目等都在催,再加上强制去实验室,在那边同学们天天大声聊天,没有氛围学习,也可能是为自己偷懒找的借口,等等诸多原因,导致20天才看了正文34页,比较愧疚,感觉浪费了时间, 前天和去实习的科班同学聊天,也建议我多花时间补基础,说字节那边就算是前端,貌似会写点linux的shell脚本这些是非常基础的技能。

所以总结了一下,在毕业入职前,基础知识需要把这黑皮书看完,最好学一下C语言,然后学习linux,入职前两个月,前端需要再捡起来,学习vue,ts,还有RN,个人对electron还挺感兴趣,不知道有时间没有,任务有点重。

以下只是记录个人的理解,并不是对书的通篇讲解,以及一些灵感然后就联想之前的知识的记录

信息的表示和处理

引言部分

引言部分主要是通过两个例子,引入 “信息是如何存储的” 话题

书中22页下方的第一个例子,得到结论:在不知道计算机内部如何进行计算的机制的前提下,测试了常用的结合律、交换律等,虽然答案是错误的,但是各个例子证明了数学规律是没有错误的。

引出答案错误的原因是因为结果太大溢出了

第二个例子: (3.14+1e20)-1e20=0.0 与 3.14+(1e20-1e20)=3.14,跟上方的例子里,相违背,因为上个例子是整数类型的结合律,这个是浮点数。

原因是浮点数只能表示一个近似的数值范围,整数却可以精确

所以上述的例子,第一个0.0是因为3.14相对于1e20来说,太小了,可以忽略,即:

(3.14+1e20)-1e20=0.0 ===> 1e20-1e20=0.0

3.14+(1e20-1e20)=3.14 ===> 3.14+0=3.14

2.1 信息存储

2.1.1 进制转换

首先是进制间的转换,C语言中,以0x开头(不区分大小写,0X也可以)的数字常量,被认为是十六进制,比如之前学的堆栈啥的,一个引用类型的数据a(一个对象),栈里的变量是a,a存储的就是一个十六进制的内存地址,比如0x2F03CD,指向的就是堆里地址为0x2F03CD的位置,当时只是知道这个是地址,现在知道了这个是十六进制的表示法。

因为计算机是01的二进制,0x对计算机本身没有影响,所以个人感觉0x的目的是为了人读数的时候方便识别此为十六进制,仅仅是个剥离出计算机内部的一个代号而已,比如在写帖子的时候,我写0xF3AD,读者知道这是代表十六进制,或者在写代码的时候写0x0xF3AD,计算机会知道我写的是十六进制,不过计算机内部依然是二进制。

image.png

本节介绍了二进制,十进制,十六进制之间相互转换的方法

比如十六进制直接分开,每个字符对应表中的二进制,即是十六进制转二进制的办法;同理,二进制的从右向左依次4个为一组,差位补0,直接转换也能转到十六进制。

2.1.2 字长

字长(word size)就是指针数据的标称大小,通俗来讲,就是常说的32位,64位的电脑中的位。比如32位的机器,虚拟地址范围就是0 ~ 2^32 -1 ,即是2^32 = 256个字节,所以虽然字长是word size,字节是byte,但是从中文的翻译上,可以联系起来记忆,字长就是关系到字节的个数的一个指标。

联系基础常识,进行的思考如下(在看到后面的一个字节长度的时候才想起来的,先记录在这里)

因为如下原因(层层递进)

  • 32位字长的机器,即最多访问2^32个字节长度;
  • 1字节是8比特:1 byte = 8 bit
  • 比特是英文 binary digit的缩写。比特是表示信息的最小单位,是二进制数的一位包含的信息或2个选项中特别指定1个的需要信息量。(百度解释)
  • 所以bit只能表示 0 或者 1 , 则 1bit 表示的范围是 0 ~ 1
  • 那么1byte可以表示的是 0000 0000 到 1111 1111 (4个一组好转为十六进制),正好是0 ~ 255
  • 如果用十六进制来表示的话,1byte表示的范围就是0x00 ~ 0xFF

所以在后面的章节里出现的32位的int类型占4个字节,比如12345用大端法表示为0x00003039,我到这里才想明白,为什么要多出4个0(当时觉得4个字节的话3039不就4个数了吗,难道不是4个字节吗,还要前面4个0干嘛),因为0x00 00 30 39,两个字符就代表1byte的数据,所以30 39两个字节就已经显示完了12345的全部数据,但是int数据对象要用4个字节表示,所以前面补上两个字节的00 00。

知道了这个,就能更加理解后面寻址与字节顺序那一节了。

更新(2021.11.16)

2.1.3 寻址和字节顺序

这一节讲了如何存储字节,比如在上一章知道了32位与64位的机器里,各种数据类型占几个字节,

摘抄书中表格如下:

数据类型字节数
有符号无符号32位64位
[signed] charunsigned char11
shortunsigned short22
intunsigned44
longunsigned long48
int32_tuint32_t44
int64_tuint64_t88

可见int在32位中占据4个字节,在前方所提到的点击跳转,一个字节用十六进制表示的话应该是两个字符,比如0x00,所以int类型的变量,应该有八个字符表示,比如变量x为int类型,那么其值可能为0x01234567,其内存地址表达式假设为0x100,0x100表达的是一个地址为0x100的字节,则x实际上占据了从0x100开始的,0x100,0x101,0x102,0x103一共四个字节,但是只用第一个开始字节的地址即可表示。

其中四个地址各存一个字节的数据,整合起来就是一个int类型的变量x的所有数据,存储类型分为大端法与小端法,大端法就是顺序存储,小端法则是倒序。

x:0x01234567

大端法
地址0x1000x1010x1020x103
数据01234567
小端法
地址0x1000x1010x1020x103
数据67452301

初遇c语言

在这章节里面第一次碰到了c语言的代码,经过一段猜测与查资料,知道了printf与js里的console.log不一样,printf里面的第一个参数就直接是双引号的,引号里面可以是字符串,也可以是指示,比如 "%" 开头的就代表要以什么格式打印下一个参数,如 "%d"是输出一个十进制整数, "%f" 是输出一个浮点数,在这里面不能没有指示,就直接输出后面的参数,

比如当时我想直接输出len,我直接写了 printf(len),结果一直报错,我还以为是指针的问题,最后加了"%d",变成printf("%d",len) 才能运行。

#include <stdio.h>

typedef unsigned char* byte_pointer;

void show_bytes(byte_pointer start, size_t len) {
    size_t i;
    //要加一个 "%d" 来处理len,不然该指针不能直接打印,会报错
    printf("%d",len);
    printf("\n");
        for (i = 0; i < len; i++) {
        /* code */
        printf("%.2x", start[i]);
        //printf(len);
    }
    printf("\n");
}

void show_int(int x) {
    show_bytes((byte_pointer)&x, sizeof(int));
    //show_bytes((byte_pointer)&x, 6);
}

void test_show_bytes(int val) {
    int ival = val;
    show_int(ival);
}

void main() {
    /// <summary>
    /// 如果参数是 数值12345,那么就是十进制的放进去,就会对应的进行输出十六进制,因为"%.2x"
    /// 但是如果参数是 字符串"12345" ,那么就是由1 2 3 4 5 五个单个的char进去,一个char占一个字节,
    /// 相当于这个循环就是挨着把1 2 3 4 5走一遍,就不再是12345(十进制)了
    /// </summary>
    test_show_bytes(12345);
    show_bytes("12345", 6);
    //show_int("12345");
}

2.1.4 表示字符串

如下:字符串嘛,就是char咯,一个char占1个字节,挨着来就行,但是最后会以一个不可见的null字符结尾,常见的是对齐ASCII码,

    /// <summary>
    /// 如果参数是 数值12345,那么就是十进制的放进去,就会对应的进行输出十六进制,因为"%.2x"
    /// 但是如果参数是 字符串"12345" ,那么就是由1 2 3 4 5 五个单个的char进去,一个char占一个字节,
    /// 相当于这个循环就是挨着把1 2 3 4 5走一遍,就不再是12345(十进制)了
    /// </summary>

比如书中例子 "12345" , 各单个字符ASCII码对应的分别如下

image.png

image.png

所以经过书中show_bytes的代码之后,得到的是 31 32 33 34 35 00

2.1.5 表示代码

不同机器,指令代码不同,二进制代码不兼容,并没有细讲,第三章会细讲

2.1.6 布尔代数

这节没什么多说的,就是非、与、或、异或等逻辑运算,把高进制的,比如十六进制,转成二进制再进行每一位对齐的运算后,再转回十六进制,就是结果。

值得说的是练习题2.9让我知道了,RGB三种光是怎么通过布尔运算得到补色的。

2.1.7 位级运算

就是上一节说的,转成二进制后,对齐每一位进行布尔运算。

同样有收获的是练习题,2.10,2.11以及2.13等,做完训练了一下如何通过位级运算得到想要的结果,以及结合bis运算和bic运算来得到位级运算的结果。有点像底层如何实现异或运算等逻辑的原理了

2.1.8 逻辑运算

逻辑运算只用来判断truefalse,即是0和1。

最重要的一点就是逻辑运算是双符号,比如 && || ,与之对应的位级运算是单符号, & |

然后如果逻辑运算的前一个表达式能确定结果,就不会看第二个表达式了,但是位级运算都要看。 这一点在以前写代码的时候并没有注意过,当时并没有纠结到底用 && 还是 & ,现在知道了两者的具体区别,以后应当注意

后续更新:有时候发现不知道什么时候该用 && ,什么时候用 & ,总结了一下,如下

  • 位级运算 & 常用于二进制里按位与运算;逻辑运算 && 常用于 if 等条件语句里的判断,更常用;
  • 如果条件语句的判断里,单个条件是只有一个变量那种情况。比如 a && 1/a ,那么这种情况下就是判断a是否为0,如果a=0,那么&&运算下,前一个条件已经能确定该表达式结果为0了,就不会看后面1/a,并且后面的分母还是0,如果真的进行判断还会报错,所以使用逻辑运算的时候,先后顺序很重要。
  • 如果是其他的条件判断,比如 a > 1 && a < 10 ,就是确定这个条件的正确与否了,不再是看a是不是等于0,并且同样遵守前一个表达式能确定结果,就不看后一个表达式的准则,比如a=0,那么直接就不用看 a < 10 这个条件了。

2.1.9 移位运算

先转成二进制,左移,后面补0;右移分为逻辑右移和算术右移,逻辑右移前面补0,算术右移前面补1;

大多数的机器,对于有符号数的右移都是算术右移,无符号数右移是逻辑右移(有符号数,第一位1代表负数,0代表正数)

旁注中记录的重要的两点:

  • 1、移动k位,大于了总位数w,就计算 k % w 的值,比如 k = 36 , w = 32 ,那么就移动4位。
  • 2、加减法的优先级高于移位运算,这让我想起来之前在刷算法题的时候,用>>1来当除以2,就需要特别注意先后优先级了

我特地去翻了一下leetcode,704二分查找,当时就是用的>>1,好在加了括号,不然就错了。

image.png

去掉括号就不行了

image.png

更新(2021.11.21)

2.2 整数表示

2.2.1 整数数据类型

没啥特别的,就是列个表把各数据类型能表示的范围列出来;

比如char,之前了解到可以表示1个字节的数据,那么就是十六进制的两位,即 0X_ _ , 二进制就是 _ _ _ _ ,二进制范围就是 0000 0000 到 1111 1111;

unsigned能表示的数据范围就是 0 ~ 2^8-1 = 255

signed能表示的数据范围就是 -2^7 ~ 0 ~ (2^7-1) ==> -128 ~ 127

可以发现因为0的关系,无符号数和有符号数的最大值都是2的n次方减1,有符号数因为可以表示负数,所以分出一半来表示负数,0占据了正数那一半里面的一个位置。

2.2.2 无符号数编码

B2U ( Binary to Unsigned ) , 就是二进制转无符号数十进制的编码

2.2.3 补码编码

B2T ( Binary to Two's-complement ) , 就是二进制转有符号数的十进制编码,最高位代表符号位,1负0正;

例:

  • B2U ( [1011] ) = 1 * 2^3 + 0 * 2^2 + 1 * 2^1 + 1 * 2^0 = 11
  • B2T ( [1011] ) = -1 * 2^3 + 0 * 2^2 + 1 * 2^1 + 1 * 2^0 = -5

这两个编码的章节让我想到了大一的时候学的原码、反码、补码,我在想这应该是一个东西,但是书中暂时没有看见原码、反码,以及之间的关系转换,所以就去查了一下之间的联系,整理如下:

原码、反码、补码 与本章的关系

原码、反码、补码是有符号数的三种表示方法,其中补码是最常用的一种;

  • 原码 :符号位 + 数值位;
  • 反码 :在真值( 十进制值 )为负数的情况下,在原码的基础上,符号位不变,数值为全取反;
  • 补码 :在真值为负数的情况下,在反码的基础上+1,符号位参与运算,进位则丢弃;

例1:

  • 真值(十进制)为 -5 ,假设 1 个字节表示,因为是有符号数,所以负数首位是1,5的二进制是101,所以二进制为 1101
  • 原码 : 1101
  • 反码 : 1010
  • 补码 : 1011
  • B2T ( [1011] ) = -1 * 2^3 + 0 * 2^2 + 1 * 2^1 + 1 * 2^0 = -5

例2:

  • 真值(十进制)为 -15 ,假设 2 个字节表示,因为是有符号数,所以负数首位是1,15的二进制是1111,所以二进制为 1000 1111
  • 原码 : 1000 1111
  • 反码 : 1111 0000
  • 补码 : 1111 0001
  • B2T ( [1111 0001] ) = -1 * 2^7 + 1 * 2^6 + 1 * 2^5 + 1 * 2^4 + 1 = -15

2.2.4 有符号数与无符号数之间的转换

这章介绍了 T2U 和 U2T 的转换公式,具体就是互相加起来为该数据类型的最大表示值:2^w,w为位。

2.2.5 C语言中的有符号数与无符号数

要创建一个无符号数要加后缀 u 或 U ,比如0x1232u , 类似的还有浮点数f,双精度浮点d,long型数值l

讲了一个运算中同时存在有符号数和无符号数,那么有符号数会被隐式强制转换为无符号数,所以会导致一些问题,比如比较大小的时候出现意想不到的结果。

-1 < 0 => true

-1 < 0u => false

2.2.6 扩展一个数

无符号的扩展,就是加0

有符号的扩展,根据最高位,换句话说就是负数就全加1,正数全加0;

数据类型的转换,优先级要高于有符号与无符号之间的转换

对于练习题2.23,一开始纠结0x87654321转成int是多少,后来想通了,不管他unsigned转signed是多少,对应的改变只是十进制的真值改变,其底层的二进制01序列是不变的,改变的是01序列对应的映射关系函数变化,所以 fun1fun2 中的强制转int,其实对低位来说没有影响,因为最后答案也是用十六进制表示,这道题要注意的是转int后需要判断是否是大于0的,要进行算术右移

2.2.7 截断一个数

无符号的截断,就是取模,也是二进制或者十六进制的直接斩断看剩余就行;

有符号的截断,先将十进制转成补码,将该补码看做无符号数进行截断,截断之后再把它看做补码转回十进制

2.2.8 有符号数与无符号数的建议

float sum_elements(float a[] , unsigned length) {
    int i;
    float result = 0;
    for (i = 0; i <= length -1 ; i++){
        result += a[i];
    }
    return result;
}

如上述书中里的那个代码里数组的长度lengthunsigned,但是循环的iintsigned,在进行比较的时候运行i <= length -1 ,如果length为 0,因为粗心会认为0 - 1 = -1,然而这是无符号数,0 - 1 确实 = -1,但是要转成十六进制后再转回unsigned,即-1 = 0xFF FF FF FF = 4294967295 ,那么这个循环就会一直进行下去,然后就会访问到数组的非法元素,而且这里面也会对i进行隐式的强制转换为unsigned,只不过,i一直都是大于0 的,所以在这里不是因为这个原因出现问题。

更新(2021.11.24)

2.3 整数运算

2.3.1 无符号加法与减法

加法

就是正数相加,不考虑符号,会有超出上限的情况,即是溢出,此时对其二进制编码进行截位处理即可,对应的十进制是取模运算,模的是数据类型最大值,比如4位就是2^4=16。

  • 检验溢出:因为溢出会取模,所以结果比原先式子中的任一值要小,即可认为溢出。

减法

减法的运算,就是加上一个“相反数”,这里,书里在这里提到了 “群” 的概念,群的概念如下:

群:给一个集合中的元素定义一种运算“乘法”(这个“乘法”不是数字运算的乘法,而只是借用了这个名字,因此加上了引号),如果这个集合中的元素和这个“乘法”满足:

  • <1> 封闭性:集合中任两个元素相“乘”的结果在这个集合之内;
  • <2> 结合律:这个“乘法”满足(ab)c=a(bc);
  • <3> 单位元:集合中存在某个元素e,对于任意集合中的其它元素a有ea=ae=a,e被称为单位元;
  • <4> 逆元:对于集合中任意元素a,一定存在集合中的另外一个元素 a^−1,使得 a ∗ a^−1 = a^−1 ∗ a = e,a与 a^−1互为逆元。

所以在无符号数里, x + (-x)= 0 , x 的逆元就是 -x , 单位元就是 0 ;

2.3.2 补码加法

两数相加,有可能溢出,在未截断之前,可能需要用 w + 1 来准确表示,这也是P64也表格里,第三行为什么相加后的结果要用5位来表示(这句话目前是个人根据书中写的推断的)

因为补码有正负,所以区分两种溢出,正溢出和负溢出,具体在书中有写。

  • 检验溢出:做完2.30和2.31后认识更清晰,根据x,y都小于0(大于0)要用结果是否大于(小于)0 来判断,不能用2.31的形式来判断,因为就算sum的结果是截断了的,在比较sum-x与y的时候,里面的运算也还是二进制里低位的运算,二进制低位的排列都没变,改变的一直是转十进制的映射方式

2.3.3 补码的非(减法)

跟无符号的减法差不多,都是通过加一个 -x , 但是其对应关系不同,要稍微注意一下。

2.3.4 无符号乘法

直接截断就行,取模运算。

2.3.5 补码乘法

同样是直接截断,不过编码方式变了。即十进制真值相乘后的结果真值,进行转二进制后,截断到该数据结构位数,然后进行补码编码即可。

2.3.6 乘以常数

乘以2的幂,位级运算上即是左移一位 : >> 1 ,除以2 这是右移一位 : << 1 ;

那么a * 14可以写成 a * (2^3 + 2^2 + 2^1) ,继续可得 a << 3 + a << 2 + a << 1;

同样a * 14还可以写成 a * (2^4 - 2^1 ) ,即 a << 4 - a << 1

2.3.7 除以2的幂

对除以2的幂来说同样可以用移位操作来实现,不过除法操作可能存在小数,此章节讨论的是整数,所以运算会舍入取整。但是移位操作要注意算术右移还是逻辑右移

无符号除以2的幂,向下取整;

补码除以2的幂,向0取整,即正数向下,负数向上,但是在实际的运算中依然会出现问题;所以需要先加上一个偏置量(biasing)再移位来修正,偏置具体取值为 2^k - 1

疑问:书中P74提到,不能用除以2的幂的除法来表示除以任意常数K的除法,为什么?答案如下,这个答案是自己推测的,不一定正确,但应该大差不错。

答案:以 2.3.6 中的例子来说明

  • 乘以任意常数可以进行2的幂的组合来移位,如:a * 14 写成 a * (2^4 - 2^1 )

  • 但是除法不行,因为除法没有结合律和分配率, a / 14 可以写成 a / (2^4 - 2^1 ) ,却不可以写成 ( a / 2^4 ) - ( a / 2^1 ) 。比如 8/14 ≠ 8/16 - 8/2

  • 因此触发不能以该方法表示除以任意常数K的除法,那么用什么方法呢

找了一下,这篇帖子有提到:链接,貌似要用到汇编的知识?这目前还没学过,如下:

//         变量除以非2的幂 公式:
// 
//         被除数 a
//         除数 b
//         商q
//         余数 r
// 
//         a = qb + r
//         a/b = q + r/b
// 
//         a/b = a* (1/b) = a* (2^n) /b * (1/2^n) = a* 2^n /b >> n
//         设m = (2^n)/b
//         b = (2^n)/m
//         a/b = a * m >> n
// 
//         m已知, 出现的除法反汇编实现中
//         被除数a已知, 和m做乘积的那个数就是a
//         n要根据除法反汇编中移位次数算出来, 也是已知的
//         */
// 

更新(2021.11.25)

2.4 浮点数

2.4.1 二进制小数

大于1的以2为权重,2的n次幂形式,小于1的,以2的 -n 次幂形式,最后相加即可

练习题 2.46 ,为什么做的答案对不上,暂时想不明白

题目: image.png

中文版答案:

image.png

本人纠结的点如下:

image.png

A问: 按照二进制的加减法则来算,我认为答案就是我写的那样

个人认为答案为: 0.000 0000 0000 0000 0000 0000 1100[1100]...

书中写的答案是: 0.000 0000 0000 0000 0000 0000 001100[1100]...

可以明显看到书中的正确答案比我多了俩0,我是实在想不通哪来的,暂时认为它是对的吧,留着问问别人。

B问: 如果把x转成10进制,然后0.1再减x确实是答案的 9.54 ✖ 10^-8 ,下图用三个算式算了一下,答案都差不多,但是还是不知道书中得到的 2^-20 ✖ 0.1 (第二天想通了,写在后面)。

图中**表示幂运算,

image.png

image.png

破案了 ,为什么A问答案对不上,因为中文版印刷错了。。。挺无语的

英文原版如下 image.png

所以A问正确答案就是我想的那样。。。

书中写的答案是: 0.000 0000 0000 0000 0000 0000 001100[1100]... (错误

原版正确答案为: 0.000 0000 0000 0000 0000 0000 1100[1100]...

B问答案 2^-20 ✖ 0.1 的来源,个人认为如下:答案原话是“把这个表示与1/10的二进制表示进行比较得到 2^-20 ✖ 0.1 ”

那么意思应该是A问答案来进行比较,之前书中的答案都是错的,我就说咋比较都出不来。。。

image.png