计算机要素--第十二章 Hack操作系统

计算机系统要素,从零开始构建现代计算机(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:堆的基址2048
    • freeList:可用堆空间的首地址,也是链表的第一个结点的首地址。这个链表是个特殊的链表,它的第一个结点不是空结点。将它初始化为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):打印错误信息,比较简单。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章