计算机系统要素,从零开始构建现代计算机(nand2tetris)
如果完成了本书所有的项目 你将会获得以下成就
- 构建出一台计算机(在模拟器上运行)
- 实现一门语言和相应的语言标准库
- 实现一个简单的编译器
而且,这本书的门槛非常低,只要你能熟练运用一门编程语言即可。本课程综合了数字电路,计算机组成原理,计算机体系架构,操作系统,编译原理,数据结构等的主要内容,搭建了计算机平台的构建的框架,并未深入细节,如果需要了解细节,可由本书作为主线,逐步完善的知识体系。
QQ交流群(含资料):289682057
课程连接
项目地址Github
8个类构成的操作系统
在本项目中,操作系统主要由先八个类完成。只完成了些主要的工作,也可是认为是最原始的工作,想系统安全,多线程之类的并没有实现。
Math
数学计算的底层实现一定要保证效率是最高的,否则在逐层构建上层应用的时候会导致越来越低效。因此这里实现的数学计算主要考虑到了效率问题,其中主要的函数就是:multiply()
,divide()
,sqrt()
。
init()
:该函数主要就是初始化一个数组,该数组的长度为16,每个位置上的元素为2的索引次幂。abs(int x)
:计算绝对值,很简单。multiply(int x, int y)
:乘法计算最简单的实现方式就是累加,如果是累加的话,那么计算的效率会随着输入的数的大小线性增长,因此这里采用了一种更高效的方式,这种方式与这些数学操作的硬件实现基本上是一样的。通俗来讲,就是使得计算的复杂度依赖于输入的数的位数,而不是他的大小。下面是伪代码:
关于算法实现的几点提示:众所周知,乘法的开销毕竟还是高于加法的。但是在乘数非常大的情况下,还是使用乘法更高效。因此,在这里我们可以把multiply(x,y): // Where x, y >=0 sum=0 shiftedX = x for j=0...(n-1) do if (j-th bit of y)=1 then sum = sum + shiftedX shiftedX = shiftedX*2
shiftedX*2
改写为shiftedX + shiftedX
。同样的,在除法算法的实现中,像这种比较简单的乘法,还是建议使用加法来实现。
由于该平台下数据表示是使用的2进制补码的形式,因此该乘法算法也适用于负数的情况。divide(int x, int y)
:同样的,前面使用简单的方法实现乘法的时候可以累加,这里在实现减法的时候可以累减,直到减不动为止,然后剩下的就是余数。然而这种方法低效了,它的复杂度随着输入数据的大小而线性增长。因此类比于乘法,这里也有一种高效的算法:
不过,这里的算法只适用于正数的情况,下面是一个扩展的算法,它可以适用于负数的情况:divide(x, y): // Integer part of x/y, where x>=0 and y>0 if y>x return 0 q = divide(x, 2*y) if (x-2*q*y)<y return 2*q else return 2*q+1
divide(x, y): // Integer part of x/y, where x>=0 and y>0 if y>x return 0 q = divide(x, 2*y) if (x-2*q*y)<y absResult = 2*q else absResult = 2*q+1 a = 16-th bit of x b = 16-th bit of y c = a xor b if c == 0 return absResult else return -absResult
sqrt(int x)
:
使用其反函数来进行计算,采用二叉查找的方法。sqrt(x): // 计算y=sqrt(x)的整数部分。策略: // 通过在0...2^(n/2)-1范围内执行二叉搜索,来确定 // 一个满足条件y^2 <= x <(y+1)^2(0<=x<=2*)的y y = 0 for j = n/2 -1...0 do if [(y+2^(j))^2 <= x] then y=y+2^(j) return y
max(int a, int b)
:简单的if判断min(int a, int b)
:简单的if判断
Memory
内存管理是非常重要的部分,也是比较复杂的部分。在内存管理部分主要有下面的四个函数,它们的名字与操作系统中的名字是一致的。
实际上,高级语言是很难直接操作底层内存的,而在Jack中,却提供了这样的权限。好处是你可以方便的管理内存,坏处是,你可能会将内存中的数据破坏掉,甚至导致电脑关机。由于这样的权限,peek和poke函数实现起来就非常的简单。具体来说,Jack允许使用一个数组来抽象整个内存,可以将该数组认为是内存的代理,我们只需要通过索引,就可以拿到指定位置的值,也就是以索引位地址的内存单元的值。
而内存的分配和回收重点在于理解实现的算法。使用链表将堆中空闲的内存段记录下来,如果需要分配内存的话,那么就搜索该内存段,搜索的策略可以是best-fit或first-fit。一般来说best-fit是好的选择。在这里比较困难的地方在于对堆的建模和搜索策略的实现。如果需要回收内存,那么只需要将回收的内存段追加到链表的末尾就可以。所以,这里并没有提供内存碎片整理的功能,那么就可能存在这样的情况,虽然堆空间是足够的,但是却找不到合适的内存,无法分配。这是因为碎片太多,没有合适大小的内存块。
-
init()
:初始化主要是完成对一些必须变量的初始化。这些变量都是实现链表操作的辅助变量。heapBase
:堆的基址2048freeList
:可用堆空间的首地址,也是链表的第一个结点的首地址。这个链表是个特殊的链表,它的第一个结点不是空结点。将它初始化为heapBase。memory[freeList]
:memory实际上是对这个内存的代理。通过索引可以定位指定的内存单元。前面通过freeList给堆空间的首地址起了个别名,但freeList实际上还是个地址。因此,这里通过freeList索引到该结点的第一个单元。实际上每个结点的第一个单元是长度,第二个单元是指针。所以在一开始的时候链表长为堆长,要将它初始化为14336。listEnd
:链表尾结点。主要是用来帮助堆空间的回收的,使用这个指针可以避免对链表逐个遍历,从而减小了开销。freeList[1]
:这里实际上表示freeList+1,也就是结点的第二个单元,它是指向链表下一个结点的指针。在开始的时候,我们只有一个结点,因此将它置为null。
-
peek(int address)
:获取address单元上的值。在这里,本质上就是索引元素。 -
poke(int address, int value)
:将address单元的值设置为value。在这里,本质上是修改指定位置元素的值。 -
alloc(int size)
:划分内存区域。划分内存区域就会使用到一些算法,这里采用了一些优化方法,虽然看起来比较复杂,但相对而言还是很简单的。- 只有一个结点。判断是不是只有一个结点。如果只有一个结点,那么我们不需要去搜索,只需做一些简单的操作。
- 搜索。已经确定有多个结点,那么就会进行搜索。搜索采用的是Best-Fit方法。除了基本的搜索的实现,还加了一些判断。如果当前的块已经是最合适的了,那么我们就直接结束搜索,避免了不必要的开销。另外,也可以定义合适的度(比如我们需要5个内存单元,但是目前遇到了一个7个单元的块,那么我们通过设置一些阈值,就可以认为当前的块是最合适的,从而划分出来,这样会在一定程度上提高效率)。
- 分配。当然,在前面做了效率的优化,在后面的处理也就要分情况。如果当前的情况是根据合适阈值得到的,也就是说,整个内存块都可以划分出去,那么我们需要修改块的长度,然后修改链表的指针。如果并不是在合适阈值内,那么我们就需要切割内存块,需要找到分配块的首地址,设置分配块的长度,修改结点的空闲单元的长度。
- 返回分配块。实际上是返回首地址。
-
deAlloc(Array o)
:释放内存区域。释放内存区域(精确来讲是堆内存块)。释放堆内存块,我们主要做一下动作:1. 得到该内存块的首地址。2. 使listEnd
指向该内存块。 3. 将该内存块的第二个单元(也就是指向下一个结点的单元)设置为null,并且更新listEnd
(令listEnd
等于第二个单元即可)。
这里给出了一种分配内存空间的实现方案:
/**
* bestAdd:分配块的首地址
* curSize:当前最合适的块的大小
* p:后指针
* q:前指针
* end:是否满足合适阈值(这里的合适阈值为size+1,也就是正好合适)
*/
var int bestAdd, curSize, p, q;
var boolean end;
// 初始化前后指针。从头开始遍历链表
let p = freeList;
let q = memory[p[1]];
// 记录当前最合适的块的大小,初始值为第一个结点的长度
let curSize = memory[freeList];
// 如果链表只有一个结点,那么我们只需要做下面的分配
if(p[1]=null){
let bestAdd = p+memory[p]-size;
let memory[freeList] = memory[freeList]-size-1;
let bestAdd[-1] = size+1;
return bestAdd;
}
// 如果有多个结点就进行搜索
let bestAdd = -1;
let end = false;
/** Search */
while(~(q[0] = null) & ~end){
/**
* Note the condition "(memory[p] > size)". In fact the best case is memory[p] = size+1.
* The first unit of the segment is used to memory the length.
*/
if((memory[q] > size) & ~(memory[q] > curSize)){
let curSize = memory[q];
let bestAdd = q+1;
}
// 这里是进行阈值的判断
/** Sometimes this can reduce some unnecessary loop. */
if(memory[q] = (size+1)){
let end = true;
}
// 如果是因为阈值而结束的话,那么不需要在移动指针了,画图即可明白。
if(~end){
let p = p[1];
let q = q[1];
}
}
/** Don't find the segment.*/
if(bestAdd = -1){
// 当前没有找到合适的内存块,直接返回不合适的地址,使之报错
return bestAdd;
}
// 因为阈值而结束的话,只需要最这里的操作,实际上就是删除链表结点
if(end){
let bestAdd[-1]=size+1;
let p[1]=q[1];
// 因为搜索到最后而结束的情况,需要做下面的切割内存块的操作。
}else{
let bestAdd[-1] = size+1;
let bestAdd = q+memory[q]-size;
let memory[q] = q-size-1;
}
// 返回
return bestAdd;
Screen
实际上也不是很复杂,核心的函数在于drawLine()
。其他的都很简单,注意需要初始化内存映像的基址,设置颜色。另外实现函数时注意参考资料,尤其有在diff那里做了优化。
init()
:初始化颜色以及屏幕内存映像的基址。clearScreen()
:清理屏幕,很简单,循环将屏幕内存映像内的所有单元清零即可。setColor(boolean b)
:设置颜色,很简单。drawPixel(int x, int y)
:画像素,也很简单。有点复杂的地方在于计算当前像素所在内存映像单元的地址,另外计算出地址后还得计算它在那个比特位上。比特位可以通过对16取模来实现,也可以通过与15进行与运算,然后依据Math
模块的twoToThe
来得到比特位的掩码。然后就是给指定的位设置值,也很简单。drawLine(int x1, int y1, int x2, int y2)
:整个类中最复杂的函数。虽然复杂,但是逻辑有很多相通之处。通过9个分支判断,来实现不同方向的画线。实现了分支之后,接着进行逻辑的修改,需要修改的就是是否需要对dx,dy取绝对值。其实,最复杂的是斜着的四个方向,而水平或竖直划线实际上还是很简单的。drawRectangle(int x1, int y1, int x2, int y2)
:有的drawLine()
,完成这个函数只需要循环即可,很简单了。drawCircle(int x, int y, int r)
:这里也没有复杂的逻辑了,具体的计算公式资料上都给出了。也非常简单。
因此在这里最复杂,最核心的就是drawLine()
,在它上面花费点时间还是值得的。下面是关于drawLine()
的核心代码:(为了压缩空间,我删掉了空行)
if((dx>0) & (dy>0)){
while(~(a>dx)&~(b>dy)){
do Screen.drawPixel(x1+a,y1+b);
if(diff<0) {let a=a+1; let diff=diff+dy;}
else {let b=b+1; let diff=diff-dx;}
}
return;
}
if((dx=0) & (dy>0)){
while(~(b>dy)){
do Screen.drawPixel(x1,y1+b);
let b=b+1;
}
return;
}
if((dx=0) & (dy<0)){
while(~(b>Math.abs(dy))){
do Screen.drawPixel(x2,y2+b);
let b=b+1;
}
return;
}
if((dx<0) & (dy=0)){
while(~(a>Math.abs(dx))){
do Screen.drawPixel(x2+a,y2);
let a=a+1;
}
return;
}
if((dx>0) & (dy=0)){
while(~(a>dx)){
do Screen.drawPixel(x1+a,y1);
let a=a+1;
}
return;
}
if((dx>0) & (dy<0)){
while(~(a>dx) & ~(b>Math.abs(dy))){
do Screen.drawPixel(x1+a,y1-b);
if(diff<0) {let a=a+1; let diff=diff+Math.abs(dy);}
else {let b=b+1; let diff=diff-dx;}
}
return;
}
if((dx<0) & (dy>0)){
while(~(a>Math.abs(dx)) & ~(b>dy)){
do Screen.drawPixel(x1-a,y1+b);
if(diff<0) {let a=a+1; let diff=diff+dy;}
else {let b=b+1; let diff=diff-Math.abs(dx);}
}
return;
}
if((dx<0) & (dy<0)){
while(~(a>Math.abs(dx)) & ~(b>Math.abs(dy))){
do Screen.drawPixel(x1-a,y1-b);
if(diff<0) {let a=a+1; let diff=diff+Math.abs(dy);}
else {let b=b+1; let diff=diff-Math.abs(dx);}
}
return;
}
if((dx=0)&(dy=0)){
do Screen.drawPixel(x1,y1);
return;
}
Output
Keyboard
相对来看是比较简单的类,其中最复杂的也就是readLine()
函数。尽管它是这个类中最复杂的函数,相对来看,它的逻辑也没有非常的复杂。
init()
:完成对键盘内存映像基址的初始化。keyPressed()
:返回当然键盘输入的值,直接返回内存映像单元内的值即可。readChar()
:读取一个字符。当当前没有输入的时候,阻塞,可以使用一个死循环来实现。如果有输入的话,那么就记录输入的值,然后判断键盘按下后是否有松开,如果没有松开就阻塞,因此这里并不能进行连续的输入。松开后,将输入的值返回。readLine(String message)
:读取一行的操作,是这个类中最复杂的函数。依赖于readChar()
实现。首先我们需要将提示信息打印出去。然后开辟一个字符串缓冲区,用于存储输入的字符。接着进入循环,循环结束的条件是这一行输入完毕,也就是读取到换行符。循环体是判断是否将当前输入的字符拼接到缓冲区中。判断的条件是backspace
,如果当前输入了backspace
程序不仅不拼接字符,还要将字符串最后一个字符丢掉。readInt(String message)
:依赖于readLine()
来实现。比较简单,通过从键盘读取一行,然后将读取的字符串转成数字。
String
字符串类,虽然该类包含了许多方法, 但大部分都很简单,复杂的方法两三个。同样的,由于这门课程提供了实现复杂方法的算法,所以你只需要将算法翻译过来即可。这样相对来看,复杂度又降低了。由于是利用字符数组来实现的字符串,因此需要定义一些成员变量,来方便数组的操作。这里定义了三个成员变量:len
数组的长度,maxLen
数组的最大长度,chars
字符数组。
new(int maxLength)
:构造函数。用于创建一个字符串(本质上是创建一个字符数组)。因此这里需要完成长度,最大长度的初始化,字符数组的内存空间分配(调用数组提供的方法即可)。dispose()
:销毁字符串。调用Memory
类提供的函数进行内存空间的释放即可。length()
:得到字符串的长度。如果我们没有定义长度成员变量,那么就需要遍历数组,这样就会有开销。由于定义了长度成员变量,仅返回一个变量即可。charAt(int j)
:得到指定位置出的字符。利用数组索引很容易实现。setCharAt(int j, char c)
:设置指定位置处的字符。利用数组索引很容易实现。appendChar(char c)
:在字符串末尾拼接一个字符。利用数组所以很容易实现。注意主要判断长度是否已经等于最大长度,如果已经等于最大长度,那么就不能拼接。直接放回当前的字符串。eraseLastChar()
:去掉末尾的字符。注意需要判断长度是否已经等于零。intValue()
:将字符串转换成数值,是该类中比较复杂的函数,不过已经提供了核心的算法。首先我们需要判断该字符串表示的数值是正数还是负数,通过第一个字符即可判断。判断正负之后,就可以进行字符和数字的转换了,主要是利用ASCII码。最后返回的时候注意返回的数值有正有负。setInt(int val)
:将数值转换成字符串。是该类中最复杂的函数。利用递归的方法也很容易实现。基本的思想就是我们逐次除10,知道得到的数值位于0-9之间,那么我们就可以利用ASCII码进行拼接。此外需要注意的就是数值的正负。setIntAss(int number)
:是上一个函数的辅助函数,该函数主要是完成递归算法,递归的拼接字符。newLine()
:返回换行的ASCII。backSpace()
:返回撤销的ASCII。doubleQuote()
:返回双引号的ASCII。
Array
new(int size)
:创建数组,直接调用内存类的alloc函数即可。dispose()
:销毁数组对象。直接调用内存类的deAlloc函数即可。
Sys
init()
:完成类的初始化,由于Array类不需要进行初始化,因此只有7个初始化动作。halt()
:暂停,写个死循环即可。wait(int duration)
:等待指定的毫秒数,重点在于找到你的计算机一毫秒可以计数多少,可以使用高级语言进行测试一下。然后写两个循环,外层循环计数毫秒数,内层循环通过计数实现一毫秒的时延。error(int errorCode)
:打印错误信息,比较简单。