Nand2Tetris 计算机系统要素 软件篇
Table of Contents
这个部分内容涉及到汇编器,编译器,操作系统。有几个地方因为以前没有接触过,所以实现起来比较有意思。
1. 汇编器符号解析
汇编器代码比较简单直接,基本上都是一条汇编指令对应一条机器指令,可能稍微有点难度的就是符合解析。
因为汇编代码里面的符号是不用提前声明的,所以符合解析需要使用额外的一遍遍历,找到符号对应的地址。
Hack符号有两类:1. 地址符号 2. 变量符号。在Hack实现里面,地址符号通过 `(label)` 方式声明,如果不是地址符号就是变量符号。 但是如果我们链接的时候,某个地址符号找不到的话,那么就会跳转到变量符号,出现执行错误。如何发现这类错误呢?
一个办法是,通常地址符号后面跟着 JMP 指令。所以可以假设,如果JMP指令之前的指令不是地址符号的话,那么很可能就是找不到符号。 这个问题在 homework 09 的时候,如果没有把 OS 代码放进来的话,那么在产生 asm 文件的时候就会出现问题。
def build_var_symtable(self): offset = 16 for (i,inst) in enumerate(self.insts): if isinstance(inst, AInst): # absolute address or label. if isinstance(inst.addr, int) or \ inst.addr in self.symtable: pass else: # 如果下面一条是跳转指令的话,那么说明链接错误 if (i+1) < len(self.insts): inst2 = self.insts[i+1] if isinstance(inst2, CInst) and inst2.jump is not None: print('[WARNING] jump inst follows non-address label. {} -> {}'.format( inst, inst2)) if self.debug: print('var {} -> {}'.format(inst.addr, offset)) self.symtable[inst.addr] = offset offset += 1
不过这种检测方式,很大程度上取决编译器生成的代码。如果编译器把函数调用产生的 boilerplate 代码变换一下,那么这种方式就不管用了。 当然编译器也可以在输出上做些文章,对于地址符号可以增加部分输出,告诉汇编器“这必须是一个地址符号”。
在编译器里面添加特殊标识符,声明这是一个函数符号 `//# :fn`.
text = f""" // push stack size to R13 @{stack_size} D=A @R13 M=D // push jump label to R14 //# :fn @{name} D=A @R14 M=D // push return label to D @{return_label} D=A {call_push_text} ({return_label}) """ if inst.must_be_fn(): print('[WARNING] require address label {}'.format(inst))
然后在编译 homework 09 的时候,如果不把OS代码添加进来的话,就会出现下面这样的错误
./asm.py: ./Average/Average.asm -> ./Average/Average.hack [WARNING] require address label @Sys.init [WARNING] require address label @String.new [WARNING] require address label @String.appendChar [WARNING] require address label @Keyboard.readInt [WARNING] require address label @Array.new [WARNING] require address label @Output.printString [WARNING] require address label @Math.divide [WARNING] require address label @Output.printInt
2. 编译器压缩代码大小-优化运算操作
一条bin-op虚拟机指令可能对应到好几条机器指令。比如下面 `add` 指令,大约14条机器指令。
// L55: add @SP AM=M-1 D=M @R13 M=D @SP AM=M-1 D=M @R13 D=D+M @SP AM=M+1 A=A-1 M=D
而类似 `gt`, `lt` 这样的指令,则对应更多的机器指令,因为里面还涉及跳转。
// L296: gt @SP AM=M-1 D=M @R13 M=D @SP AM=M-1 D=M @R13 D=D-M @Math.sqrt$lbl0.true D;JGT D=0 @Math.sqrt$lbl0.ok 0;JMP (Math.sqrt$lbl0.true) D=-1 (Math.sqrt$lbl0.ok) @SP AM=M+1 A=A-1 M=D
如果直接翻译代码的话,09/Square产生的汇编代码没有办法装载进入ROM,所以我们需要对代码进行压缩。
观察这些op代码的话,都是模板代码,没有涉及太多参数。所以我们可以将这些模板代码做成类似函数的形式来调用, 差别就是不用保存太多的上下文,只需要记录一下返回地址就行。我们将返回地址存储在R14里面,然后跳转到具体op代码上。
if ctx.compact_size: return_label = ctx.gen_label() jump_label = 'ARITH_OP_{}'.format(op.upper()) text = f""" @{return_label} D=A @R14 M=D @{jump_label} 0;JMP ({return_label}) """ return text_to_codes(text)
生成的代码如下,模板代码只需要生成一份,而原来 `add` 代码则只需要6条指令。而且基本上其他运算操作符也就是6条指令。
// L98: add @Main.main$lbl42 D=A @R14 M=D @ARITH_OP_ADD 0;JMP (Main.main$lbl42) // ============================== // ADD模板代码 (ARITH_OP_ADD) @SP AM=M-1 D=M @R13 M=D @SP AM=M-1 D=M @R13 D=D+M @SP AM=M+1 A=A-1 M=D @R14 A=M 0;JMP
3. 编译器压缩代码大小-优化函数调用
优化函数调用代码和上面非常类似,也是将模板代码抽取出来。
从函数调用返回代码非常简单,因为不涉及任何参数,只需要直接跳转到对应代码就好。
// L27: return @RETURN_POP_CODE 0;JMP // ============================== // 从函数调用返回模板代码 (RETURN_POP_CODE) @LCL D=M @5 A=D-A D=M @R14 M=D @SP AM=M-1 D=M @ARG A=M M=D @ARG D=M+1 @SP M=D @LCL D=M @R13 AM=D-1 D=M @THAT M=D @R13 AM=M-1 D=M @THIS M=D @R13 AM=M-1 D=M @ARG M=D @R13 AM=M-1 D=M @LCL M=D @R14 A=M 0;JMP
触发函数调用则稍微麻烦一些,因为涉及到部分参数。但是好在参数不是特别多,我们可以
- 堆栈大小stack_size存放在R13
- 函数符号func_label存放在R14
- 返回地址return_label存放在D中,这是因为我们后续第一步就是将D压入堆栈,生存期很短。
// L10: call Math.init 0 @5 D=A @R13 M=D //# :fn @Math.init D=A @R14 M=D @Sys.init$lbl4.ret D=A @CALL_PUSH_CODE 0;JMP (Sys.init$lbl4.ret) // ============================== // 触发函数调用模板代码 (CALL_PUSH_CODE) @SP AM=M+1 A=A-1 M=D @LCL D=M @SP AM=M+1 A=A-1 M=D @ARG D=M @SP AM=M+1 A=A-1 M=D @THIS D=M @SP AM=M+1 A=A-1 M=D @THAT D=M @SP AM=M+1 A=A-1 M=D @SP D=M @LCL M=D @SP D=M @R13 D=D-M @ARG M=D @R14 A=M 0;JMP @R15 A=M 0;JMP
4. 数学库的乘法,除法和开方实现
这些实现方法都是基于二进制来完成的,很容易用计算机的简单指令实现。
乘法通过shift和add来实现:
function void init() { let tt = Array.new(16); tt[0] = 1; tt[1] = 2; tt[2] = 4; tt[3] = 8; tt[4] = 16; tt[5] = 32; tt[6] = 64; tt[7] = 128; tt[8] = 256; tt[9] = 512; tt[10] = 1024; tt[11] = 2048; tt[12] = 4096; tt[13] = 8192; tt[14] = 16384; tt[15] = 32768; } funcetion int multiply(int x, int y) { int sum, shift, i; let sum = 0; let shift = x; let i = 0; while (i < 16) { if (bit(x, i)) { sum = sum + shift; } i = i + 1; shift = shift + shift; } }
除法实现类似于10进制的长除法,只不过除数使用二进制向上试探。其中 `_div2` 这种实现可以减少一次乘法调用,但是需要多一个存储空间。
def _div1(x, y): if x < y: return 0 q = _div1(x, 2 * y) q2 = q * 2 qy2 = q2 * y if (x - qy2) < y: pass else: q2 += 1 return q2 def _div2(x, y): if x < y: return 0, 0 q, qy2 = _div2(x, 2 * y) q2 = q * 2 # qy2 = q * 2 * y = q2 * y if (x - qy2) < y: pass else: q2 += 1 # 如果这里q2 += 1的话,那qy2需要+y qy2 += y return q2, qy2 def div(x, y): q1 = _div1(x, y) q2, _ = _div2(x, y) assert q1 == q2 return q2
开方实现则是通过二分法来实现的
def sqrt(x): v = 0 for i in reversed(range(32)): t = v + (1 << i) if (t * t) <= x: v = t return v
5. 在屏幕上打印字符
Hack计算机的屏幕尺寸是 256(行) * 512(列)。因为Hack是16bit计算机,所以对应到物理内存上, 类似于二维数组 `int16 mm[256][32]`, 然后屏幕映射内存地址从16384开始。
字符可以表示成为位图格式,每个位图的大小是11 x 8,这样来看字符屏幕尺寸则对应为 (256 / 11) x (512 / 8) = 23 x 64. 这样每个字符都可以表示成为11个int value,比如下面A字符可以对应成为(12,30,51,51,63,51,51,51,51,0,0).
展示字符也不是特别麻烦的事情。比较奇怪的是,col=0按照我的理解,应该对应的是16 bits的high 8 bits,但是如果按照这个思路, 相邻字符都会颠倒过来。
/** Displays the given character at the cursor location, * and advances the cursor one column forward. */ function void printCharNow(char ch) { var int i,r,c,hl,v,oldv,rc; var Array map; let r = row * 352; let c = col / 2; let hl = (col - (c + c)); let i = 0; let rc = r + c; let map = Output.getMap(ch); // strange! // col=1 w.r.t to high bits. // col=0 w.r.t to low bits. if (hl = 1) { // high bit while (i < 11) { let v = map[i]; let oldv = screen[rc]; let screen[rc] = Output.lshift8(v) | (oldv & 255); let i = i + 1; let rc = rc + 32; } } else { while (i < 11) { let v = map[i]; let oldv = screen[rc]; let screen[rc] = (oldv & (-256)) | v; let i = i + 1; let rc = rc + 32; } } return; }
6. 优化绘制矩形-连续内存绘制
Hack计算机的屏幕尺寸是 256(行) * 512(列)。因为Hack是16bit计算机,所以对应到物理内存上, 类似于二维数组 `int16 mm[256][32]`, 然后屏幕映射内存地址从16384开始。
一个比较简单直接的办法就是,按照每个pixel填充
/** Draws the (x,y) pixel, using the current color. */ function void drawPixel(int x, int y) { var int c, sft, off; let c = Screen.div16(x); // let sft = x - Screen.mul16(c); let sft = (x & 15); let off = Screen.mul32(y) + c; if (color) { let screen[off] = (screen[off] | tt[sft]); } else { let screen[off] = (screen[off] & (~ tt[sft])); } return; } /** Draws a filled rectangle whose top left corner is (x1, y1) * and bottom right corner is (x2,y2), using the current color. */ function void drawRectangle2(int x1, int y1, int x2, int y2) { var int x,y; let y = y1; while(~(y > y2)) { let x = x1; while(~(x > x2)) { do Screen.drawPixel(x, y); let x = x + 1; } let y = y + 1; } return; }
但是如果x1,x2如果是和16对齐的话,那么完全可以直接将内存设置成为0或者是-1, 而对于非对齐的则还是退化成为按照pixel来绘制。 而对于非对齐部分,我们还可以直接去填充值,进一步做优化。
function void drawPixelsIn16Bits(int x1, int x2, int y) { var int off, c, sft, x; let c = Screen.div16(x1); let off = Screen.mul32(y) + c; let sft = x1 & 15; // let sft = x1 - Screen.mul16(c); let x = x1; if (color) { while(~(x > x2)) { let screen[off] = (screen[off] | tt[sft]); let sft = sft + 1; let x = x + 1; } } else { while(~(x > x2)) { let screen[off] = (screen[off] & (~ tt[sft])); let sft = sft + 1; let x = x + 1; } } return; } function void drawRectangle(int x1, int y1, int x2, int y2) { var int x,y,fill,c,off,off2,sft; var int c1, c2, x1e, x2e; let c1 = Screen.div16((x1 + 15)); let c2 = Screen.div16(x2); let x1e = Screen.mul16(c1); let x2e = Screen.mul16(c2); if (c1 > c2) { let y = y1; while (~(y > y2)) { do Screen.drawPixelsIn16Bits(x1, x2, y); let y = y + 1; } return; } let fill = 0; if (color) { let fill = ~fill; } let y = y1; let off = Screen.mul32(y) + c1; while (~(y > y2)) { // edge // c1 (x1 to x1e-1) low bits // c2 (x2e to x2) high bits do Screen.drawPixelsIn16Bits(x1, x1e-1, y); do Screen.drawPixelsIn16Bits(x2e, x2, y); // block. let off2 = off; let c = c1; while(c < c2) { let screen[off2] = fill; let c = c + 1; let off2 = off2 + 1; } let off = off + 32; let y = y + 1; } return; }
7. 优化绘制矩形-优化乘除法
在绘制矩形上,有4个数学运算是最频繁的
- mul16. x * 16
- mul32. x * 32
- div16. x / 16
- mod16. x % 16
其中mul16, mul32可以通过多次叠加来完成,mod16可以通过 (x & 15) 来完成,而div16则可以通过移位来实现。
// tt[i] = (1 << i). function int div16(int x) { var int sum, i; while (i < 12) { if (x & tt[i+4]) { let sum = sum + tt[i]; } let i = i + 1; } }
然后我们还可以将循环展开来进一步减少开销。做到这一步,09/Pong的刷新速度就可以接受了。
function int div16(int x) { var int sum, i; // while (i < 12) { // if (x & tt[i+4]) { // let sum = sum + tt[i]; // } // let i = i + 1; // } if (x & tt[4]) { let sum = sum + tt[0]; } if (x & tt[5]) { let sum = sum + tt[1]; } if (x & tt[6]) { let sum = sum + tt[2]; } if (x & tt[7]) { let sum = sum + tt[3]; } if (x & tt[8]) { let sum = sum + tt[4]; } if (x & tt[9]) { let sum = sum + tt[5]; } if (x & tt[10]) { let sum = sum + tt[6]; } if (x & tt[11]) { let sum = sum + tt[7]; } if (x & tt[12]) { let sum = sum + tt[8]; } if (x & tt[13]) { let sum = sum + tt[9]; } if (x & tt[14]) { let sum = sum + tt[10]; } if (x & tt[15]) { let sum = sum + tt[11]; } return sum; }