学时对汇编语言一知半解,直到最近看到一个 JS 实现的模拟器 assembler-simulator,才豁然开朗。相关参考材料如下:
于是有了这篇实战分析版的汇编入门教程。
在运行和调试模拟器的示例代码前,下面一些概念需要温习一下:
JMP
跳转或者CALL
子程序通过模拟器学习的好处是可以屏蔽不同硬件平台的实现细节,以最简单的方式洞察其原理。
原作者开源的 js在线汇编模拟器 可以通过 demo 直接查看汇编指令的执行以及内存的变化过程,负责汇编过程的代码src/assembler/asm.js
只有六百多行,比较有学习价值。该模拟器特性如下:
下面以如下汇编代码为例,按照 CPU 的执行循序逐句进行解析:
; Simple example
; Writes Hello World to the output
JMP start
hello: DB "Hello World!" ; Variable
DB 0 ; String terminator
start:
MOV C, hello ; Point to var
MOV D, 232 ; Point to output
CALL print
HLT ; Stop execution
print: ; print(C:*from, D:*to)
PUSH A
PUSH B
MOV B, 0
.loop:
MOV A, [C] ; Get char from var
MOV [D], A ; Write to output
INC C
INC D
CMP B, [C] ; Check if end
JNZ .loop ; jump if not
POP B
POP A
RET
; Simple example
(无机器码)以 ;
开头的是注释,不会出现在汇编后的机器码中。
模拟器的实现 src/assembler/asm.js:190
:
if (match[1] !== undefined || match[2] !== undefined) {} else {
var line = lines[i].trim();
if (line !== "" && line.slice(0, 1) !== ";") {
throw "Syntax error";
}
}
模拟器是按照正则的方式对汇编代码进行逐行汇编,对于正则无法解析的行会进一步判断是否是空行或者;
开头,这两种情况是直接跳过不做任何处理的。
JMP start
(机器码 1F 0F
)指令指针跳转到 start
标签的地址。
JMP
是跳转指令,只有这个指令能改变CPU执行指令的顺序。JMP
有两种指令类型:
JMP_REGADDRESS
跳转到寄存器地址 (操作码 1E
)JMP_ADDRESS
跳转到内存地址 (操作码 1F
)start
是一个标签,在汇编的过程中会生成一个符号表(对应源码的labels
变量),记录各个标签的内存地址:Name | Address | Value(10进制) |
---|---|---|
.loop | 1F | 03 |
hello | 02 | 48 (‘H’) |
18 | 32 (‘2’) | |
start | 0F | 06 |
在正则扫描完代码后,会根据生成的符号表将符号替换成具体的地址,例如 start
替换成 0F
。
模拟器的实现 src/assembler/asm.js:622
:
for (i = 0, l = code.length; i < l; i++) {
if (!angular.isNumber(code[i])) {
if (code[i] in labels) {
code[i] = labels[code[i]];
}
}
}
hello: DB "Hello World!"
(机器码 48 65 6C 6C 6F 20 57 6F 72 6C 64 21
)定义一个字符串常量,用标签 hello
标记其内存起始地址。
if (p1.type === "number")
code.push(p1.value);
else if (p1.type === "numbers")
for (var j = 0, k = p1.value.length; j < k; j++) {
code.push(p1.value[j]);
}
DB 0
(机器码00
)定义字符串结束标记,即写入00
的一个字节。
MOV C, hello
(机器码 06 02 02
)将 hello
标签的内存地址写入到寄存器C中。
MOV
是变量写入的指令,唯一可以改变内存的指令,一共有8种类型MOV_REG_TO_REG
从寄存器写入到寄存器MOV_ADDRESS_TO_REG
将内存地址的值拷贝到寄存器MOV_REGADDRESS_TO_REG
?MOV_REG_TO_ADDRESS
将寄存器值写入内存地址MOV_REG_TO_REGADDRESS
?MOV_NUMBER_TO_REG
将数值写入到寄存器MOV_NUMBER_TO_ADDRESS
将数值写入到内存地址MOV_NUMBER_TO_REGADDRESS
?
if (p1.type === "register" && p2.type === "register")
opCode = opcodes.MOV_REG_TO_REG;
else if (p1.type === "register" && p2.type === "address")
opCode = opcodes.MOV_ADDRESS_TO_REG;
else if (p1.type === "register" && p2.type === "regaddress")
opCode = opcodes.MOV_REGADDRESS_TO_REG;
else if (p1.type === "address" && p2.type === "register")
opCode = opcodes.MOV_REG_TO_ADDRESS;
else if (p1.type === "regaddress" && p2.type === "register")
opCode = opcodes.MOV_REG_TO_REGADDRESS;
else if (p1.type === "register" && p2.type === "number")
opCode = opcodes.MOV_NUMBER_TO_REG;
else if (p1.type === "address" && p2.type === "number")
opCode = opcodes.MOV_NUMBER_TO_ADDRESS;
else if (p1.type === "regaddress" && p2.type === "number")
opCode = opcodes.MOV_NUMBER_TO_REGADDRESS;
因为有寄存器、寄存器地址、内存地址、常量等概念,所以做内存操作的时候要区分各种不同情况。
MOV D, 232
(机器码06 03 E8
)在寄存器D中写入数值232
CALL print
(机器码38 18
)调用子方法,即执行print
标签所在内存地址的指令。和JMP
指令不同的是,子方法执行完后还会继续执行下一条指令。
CALL
有两种类型CALL_REGADDRESS
执行寄存器保存值的内存地址的指令CALL_ADDRESS
执行内存地址的指令HLT
(机器码00
)停止处理器的运行。
PUSH A
(机器码32 00
)将寄存器A的值入栈,同时会使栈指针SP
减一。
PUSH
有四种类型PUSH_REG
PUSH_REGADDRESS
PUSH_ADDRESS
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
有四种类型CMP_REG_WITH_REG
比较两个寄存器的值CMP_REGADDRESS_WITH_REG
比较寄存器的值对应的内存地址的值
和寄存器值的大小CMP_ADDRESS_WITH_REG
比较内存地址的值和寄存器值的大小CMP_NUMBER_WITH_REG
比较数字和寄存器的值的大小比较的结果是设置标志寄存器Z
和C
self.zero = false;
self.carry = false;
if (value >= 256) {
self.carry = true;
value = value % 256;
} else if (value === 0) {
self.zero = true;
} else if (value < 0) {
self.carry = true;
value = 256 - (-value) % 256;
}
return value;
JNZ .loop
(机器码 27 1F
)如果零位寄存器不是零,就跳转到.loop
内存地址。
number = memory.load(++self.ip);
if (!self.zero) {
jump(number);
} else {
self.ip++;
}
POP B
(机器码36 01
)将寄存器B的值从栈中恢复
RET
(机器码39
)返回子程序(回调CALL
指令之后)
上面的指令解析过程可能有些枯燥,原始的几百行 JS 代码会更有趣一点。通过本篇的温习,相信能解决不少学时的疑问:
其实复杂性的来源是 CPU 运算速度太快,只有寄存器的速度能够匹配(但是其成本十分昂贵,容量太小),于是只能把程序转换成指令先保存到内存中,然后逐条取出执行(以及后续衍生出的多级存储结构),也正式因为存储介质的不同,又引入了各种寻址操作。
挖个坑,后续看看有没有编译器的项目,可以重温一下编译原理^o^
Copyright © 2015-2022 BY-NC-ND 4.0