See You Again

从 JS 模拟器重学汇编

学时对汇编语言一知半解,直到最近看到一个 JS 实现的模拟器 assembler-simulator,才豁然开朗。相关参考材料如下:


于是有了这篇实战分析版的汇编入门教程。

一、计算机原理一些概念

在运行和调试模拟器的示例代码前,下面一些概念需要温习一下:

二、模拟器实现剖析

通过模拟器学习的好处是可以屏蔽不同硬件平台的实现细节,以最简单的方式洞察其原理。

原作者开源的 js在线汇编模拟器 可以通过 demo 直接查看汇编指令的执行以及内存的变化过程,负责汇编过程的代码src/assembler/asm.js只有六百多行,比较有学习价值。该模拟器特性如下:

下面以如下汇编代码为例,按照 CPU 的执行循序逐句进行解析:

  1. ; Simple example
  2. ; Writes Hello World to the output
  3. JMP start
  4. hello: DB "Hello World!" ; Variable
  5. DB 0 ; String terminator
  6. start:
  7. MOV C, hello ; Point to var
  8. MOV D, 232 ; Point to output
  9. CALL print
  10. HLT ; Stop execution
  11. print: ; print(C:*from, D:*to)
  12. PUSH A
  13. PUSH B
  14. MOV B, 0
  15. .loop:
  16. MOV A, [C] ; Get char from var
  17. MOV [D], A ; Write to output
  18. INC C
  19. INC D
  20. CMP B, [C] ; Check if end
  21. JNZ .loop ; jump if not
  22. POP B
  23. POP A
  24. RET

; Simple example(无机器码)

; 开头的是注释,不会出现在汇编后的机器码中。

模拟器的实现 src/assembler/asm.js:190

  1. if (match[1] !== undefined || match[2] !== undefined) {} else {
  2. var line = lines[i].trim();
  3. if (line !== "" && line.slice(0, 1) !== ";") {
  4. throw "Syntax error";
  5. }
  6. }

模拟器是按照正则的方式对汇编代码进行逐行汇编,对于正则无法解析的行会进一步判断是否是空行或者;开头,这两种情况是直接跳过不做任何处理的。

JMP start(机器码 1F 0F

指令指针跳转到 start 标签的地址。

JMP是跳转指令,只有这个指令能改变CPU执行指令的顺序。

JMP 有两种指令类型:

  1. JMP_REGADDRESS 跳转到寄存器地址 (操作码 1E
  2. JMP_ADDRESS 跳转到内存地址 (操作码 1F

start是一个标签,在汇编的过程中会生成一个符号表(对应源码的labels变量),记录各个标签的内存地址:

Name Address Value(10进制)
.loop 1F 03
hello 02 48 (‘H’)
print 18 32 (‘2’)
start 0F 06

在正则扫描完代码后,会根据生成的符号表将符号替换成具体的地址,例如 start 替换成 0F
模拟器的实现 src/assembler/asm.js:622

  1. for (i = 0, l = code.length; i < l; i++) {
  2. if (!angular.isNumber(code[i])) {
  3. if (code[i] in labels) {
  4. code[i] = labels[code[i]];
  5. }
  6. }
  7. }

hello: DB "Hello World!"(机器码 48 65 6C 6C 6F 20 57 6F 72 6C 64 21

定义一个字符串常量,用标签 hello 标记其内存起始地址。

  1. if (p1.type === "number")
  2. code.push(p1.value);
  3. else if (p1.type === "numbers")
  4. for (var j = 0, k = p1.value.length; j < k; j++) {
  5. code.push(p1.value[j]);
  6. }

DB 0(机器码00

定义字符串结束标记,即写入00的一个字节。

MOV C, hello(机器码 06 02 02

hello 标签的内存地址写入到寄存器C中。

MOV 是变量写入的指令,唯一可以改变内存的指令,一共有8种类型

  1. MOV_REG_TO_REG 从寄存器写入到寄存器
  2. MOV_ADDRESS_TO_REG 将内存地址的值拷贝到寄存器
  3. MOV_REGADDRESS_TO_REG
  4. MOV_REG_TO_ADDRESS 将寄存器值写入内存地址
  5. MOV_REG_TO_REGADDRESS
  6. MOV_NUMBER_TO_REG 将数值写入到寄存器
  7. MOV_NUMBER_TO_ADDRESS 将数值写入到内存地址
  8. MOV_NUMBER_TO_REGADDRESS
  1. if (p1.type === "register" && p2.type === "register")
  2. opCode = opcodes.MOV_REG_TO_REG;
  3. else if (p1.type === "register" && p2.type === "address")
  4. opCode = opcodes.MOV_ADDRESS_TO_REG;
  5. else if (p1.type === "register" && p2.type === "regaddress")
  6. opCode = opcodes.MOV_REGADDRESS_TO_REG;
  7. else if (p1.type === "address" && p2.type === "register")
  8. opCode = opcodes.MOV_REG_TO_ADDRESS;
  9. else if (p1.type === "regaddress" && p2.type === "register")
  10. opCode = opcodes.MOV_REG_TO_REGADDRESS;
  11. else if (p1.type === "register" && p2.type === "number")
  12. opCode = opcodes.MOV_NUMBER_TO_REG;
  13. else if (p1.type === "address" && p2.type === "number")
  14. opCode = opcodes.MOV_NUMBER_TO_ADDRESS;
  15. else if (p1.type === "regaddress" && p2.type === "number")
  16. opCode = opcodes.MOV_NUMBER_TO_REGADDRESS;

因为有寄存器、寄存器地址、内存地址、常量等概念,所以做内存操作的时候要区分各种不同情况。

MOV D, 232(机器码06 03 E8

在寄存器D中写入数值232

CALL print(机器码38 18

调用子方法,即执行print标签所在内存地址的指令。和JMP指令不同的是,子方法执行完后还会继续执行下一条指令。

CALL有两种类型

  1. CALL_REGADDRESS 执行寄存器保存值的内存地址的指令
  2. CALL_ADDRESS 执行内存地址的指令

HLT(机器码00

停止处理器的运行。

PUSH A(机器码32 00

将寄存器A的值入栈,同时会使栈指针SP减一。

PUSH有四种类型

  1. PUSH_REG
  2. PUSH_REGADDRESS
  3. PUSH_ADDRESS
  4. PUSH_NUMBER

PUSH B(机器码32 01

MOV B, 0(机器码06 01 00

MOV A, [C](机器码03 00 02

将寄存器C的值对应的内存地址的值写入到寄存器A中

MOV [D], A(机器码05 03 00

将寄存器A的值写入到寄存器D的值对应的内存地址里

INC C(机器码12 02

寄存器C的值加一

CMP B, [C](机器码15 01 02

比较寄存器C的值对应的内存地址的值寄存器B的值大小

CMP有四种类型

  1. CMP_REG_WITH_REG 比较两个寄存器的值
  2. CMP_REGADDRESS_WITH_REG 比较寄存器的值对应的内存地址的值和寄存器值的大小
  3. CMP_ADDRESS_WITH_REG 比较内存地址的值和寄存器值的大小
  4. CMP_NUMBER_WITH_REG 比较数字和寄存器的值的大小

比较的结果是设置标志寄存器ZC

  1. self.zero = false;
  2. self.carry = false;
  3. if (value >= 256) {
  4. self.carry = true;
  5. value = value % 256;
  6. } else if (value === 0) {
  7. self.zero = true;
  8. } else if (value < 0) {
  9. self.carry = true;
  10. value = 256 - (-value) % 256;
  11. }
  12. return value;

JNZ .loop(机器码 27 1F

如果零位寄存器不是零,就跳转到.loop内存地址。

  1. number = memory.load(++self.ip);
  2. if (!self.zero) {
  3. jump(number);
  4. } else {
  5. self.ip++;
  6. }

POP B(机器码36 01

将寄存器B的值从栈中恢复

RET(机器码39

返回子程序(回调CALL指令之后)

小结

上面的指令解析过程可能有些枯燥,原始的几百行 JS 代码会更有趣一点。通过本篇的温习,相信能解决不少学时的疑问:

其实复杂性的来源是 CPU 运算速度太快,只有寄存器的速度能够匹配(但是其成本十分昂贵,容量太小),于是只能把程序转换成指令先保存到内存中,然后逐条取出执行(以及后续衍生出的多级存储结构),也正式因为存储介质的不同,又引入了各种寻址操作。

挖个坑,后续看看有没有编译器的项目,可以重温一下编译原理^o^

2019-12-10 喜欢

Copyright © 2015-2022 BY-NC-ND 4.0

回到顶部 ↑