計算機要素--第十二章 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):打印錯誤信息,比較簡單。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章