前言(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,计算机会知道我写的是十六进制,不过计算机内部依然是二进制。
本节介绍了二进制,十进制,十六进制之间相互转换的方法
比如十六进制直接分开,每个字符对应表中的二进制,即是十六进制转二进制的办法;同理,二进制的从右向左依次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] char | unsigned char | 1 | 1 |
| short | unsigned short | 2 | 2 |
| int | unsigned | 4 | 4 |
| long | unsigned long | 4 | 8 |
| int32_t | uint32_t | 4 | 4 |
| int64_t | uint64_t | 8 | 8 |
可见int在32位中占据4个字节,在前方所提到的点击跳转,一个字节用十六进制表示的话应该是两个字符,比如0x00,所以int类型的变量,应该有八个字符表示,比如变量x为int类型,那么其值可能为0x01234567,其内存地址表达式假设为0x100,0x100表达的是一个地址为0x100的字节,则x实际上占据了从0x100开始的,0x100,0x101,0x102,0x103一共四个字节,但是只用第一个开始字节的地址即可表示。
其中四个地址各存一个字节的数据,整合起来就是一个int类型的变量x的所有数据,存储类型分为大端法与小端法,大端法就是顺序存储,小端法则是倒序。
x:0x01234567
| 大端法 | ||||
|---|---|---|---|---|
| 地址 | 0x100 | 0x101 | 0x102 | 0x103 |
| 数据 | 01 | 23 | 45 | 67 |
| 小端法 | ||||
| 地址 | 0x100 | 0x101 | 0x102 | 0x103 |
| 数据 | 67 | 45 | 23 | 01 |
初遇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码对应的分别如下
所以经过书中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 逻辑运算
逻辑运算只用来判断true和false,即是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,好在加了括号,不然就错了。
去掉括号就不行了
更新(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序列对应的映射关系函数变化,所以
fun1和fun2中的强制转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;
}
如上述书中里的那个代码里数组的长度length是unsigned,但是循环的i是int为signed,在进行比较的时候运行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 ,为什么做的答案对不上,暂时想不明白
题目:
中文版答案:
本人纠结的点如下:
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 (第二天想通了,写在后面)。
图中**表示幂运算,
破案了 ,为什么A问答案对不上,因为中文版印刷错了。。。挺无语的
英文原版如下
所以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问答案来进行比较,之前书中的答案都是错的,我就说咋比较都出不来。。。