汇编基础
- 王爽汇编语言 - 挺好, 精简重要 - 4
环境配置
- 使用 VSCode+masm+dosbox
- 事实上 masm+dosbox 下载下来就可以在大部分 C/C++IDE 下使用汇编, 如 VS2022, 但是在 VSCode 可以将 masm 集成为一个插件
- 安装 MASM/TASM 插件后, 非 Win 系统需下载 dosbox, 之后就可以一键编译运行, 并支持调用 Debug
- 但学习 8086 的目的说到底是将 c/c++/rust 之类的编译型语言, 尤其是 "系统级语言" 的代码转换为汇编以更好的了解语言底层
- 因此使用 Compiler Explorer 在线网站将各种语言转换为各种汇编
基础知识
- 汇编语言有三类指令:
- 汇编指令, 对应机器码
- 伪指令, 编译器执行, 不对应机器码
- 其他符号, 如 +/- 等, 有编译器识别, 不对应机器码
- cpu 通过总线传递地址信息, 控制信息 (读写, 器件), 数据信息
- 这三种总线的宽度决定 cpu 单次传递信息的量
- 存储器分 RAM (随机存储器) 和 ROM (只读存储器)
- CPU 在操作它们的时候将其总的看作一个内存地址空间
寄存器
- CPU 中有运算器, 控制器, 寄存器, 内部总线
reg 指代寄存器, n 指代数值
mov reg, reg/n ; 相当于 reg=reg/n 称为传送指令
add reg, reg/n ; 相当于 reg+=reg/n
两个部分的位数必须一致, 超过 CPU 位数将被舍去
- 8086 依赖段地址与偏移地址实现 20 位寻址能力
- CS 寄存器: 代码段寄存器, 存放代码段的段地址;IP 寄存器: 指令指针寄存器, 存放当前指令的偏移地址, CS:IP 指向的地址即当前指令的地址
- CPU 仅将 CS:IP 指向的地址的内容看作指令, 执行过后, IP 自动加指令的长度, CPU 运作伊始, CS:IP 指向的地址为 FFFF0h
寄存器 (内存访问)
mov reg, [n] ; 相当于 reg=DS:[n]
mov [n], reg ; 相当于 DS:[n]=reg
mov ds, reg ; 相当于 DS=reg
; 以此类推, DS, 通用寄存器, 内存单元, 三者可互相 mov
; 但是不可将数值直接送入 DS
; sub 减法与 add 指令类似
; 要求左是存储器/寄存器, 右是存储器/寄存器或数值, 但不可是段寄存器
- DS 存储数据段的段地址
- SS 存储堆栈段的段地址 (mov 时同 DS), SP 存储偏移量
- 没人帮你防止堆栈超界
第一个程序
- 某个 command 将 cpu 的控制权交给程序后, 运行结束必须返回, 利用 mov 与 int 实现, 后文详叙
- 程序加载后 ds = 程序位置, 前 256 字节是 PSP (程序段前缀 进行 DOS 与程序通信)
[BX]
和 loop
指令
; [bx] 就是将 bx 的值作为偏移值
inc reg ; 相当于 reg++
name:.... ; 这里已经执行一次了
loop name ; 相当于 if(--cx) goto name, 注意这里跳回去了 cx 减一次
; s 编译后回变成标记指令的地址
- 汇编源程序中字面量不能以字母开头, 16 进制是 0aaaah 的形式
- 编译器认为
[n]
等效 n,ds:[n]
才正确, 这就是段前缀
包含多个段的程序
- 代码段开头存储在可执行文件的描述信息当中
- 定义段就正常定义就行, 完全是我们自己安排, 真自由, 真他妈的爽
- 定义的段名在程序当中引用会被认为是段的地址
更灵活的定位内存地址的方法
and reg, n; 按位与
or reg, n; 按位或
db 'aaaa'
db 61h, 61h, 61h, 61h
; db 定义字节型数据
; [bx+n] 内存单元的新表示方式, 也可以写成 n[bx]
- ASCII 值 大小写字母的区别是二进制第五位为 0/1
- 寄存器 si, di 往往作为偏移量
- 往往在内存当中使用栈段暂时保存寄存器的值
数据处理的两个基本问题
[command] word/byte ptr [bx], ... ; word/byte ptr 指定内存单元长度 (数据类型)
; [bx+n+si]->[bx].n[si]
div reg/内存单元 ; 这是除数, 被除数的位数是其两倍, 在 reg 存前 16 位, dx 不存 / 后 16 位, 结果与除数位数一致, al/reg 为商, ah 或 dx 为余数
dd 值, ... ; 定义双字型数据
db/dw/dd n dup (1, 2, 3, ) ; 定义 n 个重复 123->1, 2, 3, 1, 2, 3, 1, 2, 3
- 只有 bx, bp, si, di 四个寄存器可以做偏移量, 且 bx, bp 或者 si, di 互不能相加, bp 的默认段地址是 ss
- 数据读取的位置只能是内存, 寄存器或者端口 (后详叙)
- 指令中的数据叫立即数
- 有些指令默认长度, 如 push, pop
转移指令的原理
command reg, offset name ; 取得 name 的偏移地址 (IP)
nop ; 空指令
jmp short name ; 短转移 依赖在 IP 上加减 (补码表示)来实现
jmp near name ; 近转移 同理 上面 8 位, 这个 16 位
jmp far ptr name ;远转移 在机器码中是 IPCS 的顺序, 低地址->高地址
jmp reg ; 前已叙
jmp word ptr 16 位内存 ; IP=
jmp dword ptr 32 位内存 ; 按 IPCS 赋值
jcxz name ; 相当于 if(!cx) jmp short name
; 前面的 loop 实际上也是短转移
dec reg ; reg--
- 改变 CS:IP (段间转移)/IP (段内转移) 的指令叫转移指令
- 段内转移分短转移 / 近转移,
-128 ~~ 127 / -32768 ~~ 32767
- 亦分无条件转移 / 条件转移 / 循环 / 过程 / 中断
- 段内位移的意义是不会对程序段在内存中的偏移地址有严格的限制
CALL 和 RET 指令
ret ; pop IP
retf ; pop IP, pop CS
call name ; push IP, jmp near name
call far ptr name ; push CS, push IP, jmp far ptr name
call reg ; push IP, IP = reg(jmp reg)
call word ptr 内存 ; push IP, IP = 16 位内存 (jmp word ptr 内存)
call dword ptr 内存 ; push IP, I P= 内存 (jmp dword ptr 内存)
mul reg/内存 ;8 位另一个乘数在 al, 16 位则在 ax, 结果在 ax 或 ax+dx
- 高级语言的函数显然就是这么实现的, 在汇编当中参数较少情况下, 我们可以使用寄存器传递参数与结果, 但是我们都知道用栈来传递是不错的选择
- 显然在子程序的开始的时候, 我们要把它使用的寄存器的值保存起来, 以防止影响主程序的运行, 这个过程往往也是用栈实现的
- 小心除法的溢出, 当然我们可以用数学方法将一个会溢出的除法拆分成多个不会溢出的除法
标志寄存器
- 标志寄存器有下三种作用: 存储相关指令的执行结果; 为 CPU 执行相关指令提供行为依据; 用来控制 CPU 的相关工作方式, 它们存储的叫 PSW (程序状态字) 例如 flag
- flag 寄存器是按位起作用的, 16 位使用了 0 2 4 6 7 8 9 10 11
- 第六位, ZF, 记录相关指令执行后的结果是否为 0, 如果为 0, 那么它为 1, 反之为 0, 相关指令往往是运算指令, 包括逻辑与算术 (下同)
- 第二位, PF, 记录相关指令执行后的结果汉明重量是否为偶数, 如果为偶数, 那么为 1, 反之为 0
- 第七位, SF, 记录相关指令执行后的结果是否为负数, 如果为负数, 那么为 1, 反之为 0 (其实就是结果最高位)
- 第零位, CF, 记录进位与借位, 对无符号运算有效
- 第十一位, OF, 记录相关指令执行后的结果是否溢出, 对有符号运算有效
- inc, loop, 不会影响 CF, 将它们替换成 add 要小心
adc reg, reg ; 相当于 add reg, reg 的结果加 CF
; 实现两步相加, 使用一个 Add 进行低位加法, 再一个 ADC 进行高位加法
; 最后存在两个 16 位当中就能实现 32 位的加法运算
sbb reg ; 同理
cmp reg, reg ; 相当于 sub reg, reg 但不存在 reg 中, 影响以上 5 个位
; 可以反映两个寄存器中值的大小关系
; 根据比较结果进行转移是常见的, 根据无符号数和有符号数检查不同的位
; 无符号数检查 zf, cf
; je zf 等于
; jne !zf 不等于
; jb cf 小于
; jnb !cf 小于等于
; ja !cf&&!zf 大于
; jna cf||zf 大于等于
; 有符号数检查 sf, of, zf 不举例
movsb ; mov es:[di], byte ptr ds:[si], if(!df) si++, di++, else si--, di--
movsw ; mov es:[di], word ptr ds:[si], if(!df) si+=2, di+=2, else si-=2, di-=2
rep movsb ; name:movsb, loop name
cld/std ; df=0/df=1
pushf/popf ; push/pop flag
- 第十位, DF, 控制相关操作后 si/di 的增减
内中断
- 中断是 CPU 的一种异常处理机制, 放下手中的事直接去处理中断信息
- 中断类型码记录了中断信息来源, 大概有四种内中断, 0 除法错误, 1 单步执行, 4into, n int n
- 对终中断信息的处理是需要编写的程序, 但是 CPU 怎么找到这个程序的位置呢?
- 内存当中有一个中断向量表对应 256 个中断源每一个的处理程序入口
- 8086 的在内存从零开始的位置, 每个表项显然占两个字节 (CS:IP)
- 获取类型码之后, 要把标志寄存器和当前指令位置压入栈中, 以便在中断处理结束后恢复现场, 压入之后要将 flag 第八位和第九位分别是 TF 和 IF 立刻置 0, 防止内中断过程被干扰
iret; 以上过程的逆过程也就是中断程序的返回指令
- 王爽看那个向量表里有空位就把自己写的那个中断程序往那里塞太邪恶了啊, 注意这种程序不仅要保护代码段, 固定的数据段也得保护哦
- 编译器可以计算常数的加减乘除, 当然也包括
offset name
- 执行完事一条指令一旦 TF 为 0, 就是单步执行中断
- CPU 有的时候也不响应中断, 就比如说我要设置一下 SSSP 的位置, 不管你是啥都等等我, 因为你还不知道那个现场信息往哪保存呢
- 不同的 CPU, 这个现场信息的位置不一样, 有的是专门有系统堆栈, 但是 8086 就是压入目前程序堆栈, 所以程序必须要有堆栈, 且中断必须发生在设置完 sssp 之后
int 指令
- BIOS 和 DOS 提供一些中断例程, 开机后, CPU 初始化执行 BIOS 中的硬件检测和初始化程序将入口地址登记在中断向量表, 因为程序都固化在 ROM 当中, 然后调用 int 19H 进行操作系统的引导, 操作系统也会放一些中断例程
- 其提供的程序往往还包含多个子程序, 根据中断时候寄存器中保存的值而决定运行哪些程序
端口
- 在上面我们使用 CPU 读写寄存器与内存单元, 但事实上 CPU 还可以读写各种非存储器的芯片中的寄存器, 端口
- 端头地址和内存地址一样, 通过地址总线来传送, 8086 端口地址范围是 0~65535
in ax/al, nh ;从 n 号端口读, 严格 8 位用 AL, 16 位 AX
out nh, ax, al ;同理
; cmos arm 有两个端口, 70h/71h
; 分别存放地址和数据
; 使用方法就是将要读取的地址, 放入 70H , 然后从 71H 中读出
shl ax, 1; 相当于 ax<<1, 移除的最后位会写入 CF, 最低位补 0
shr ax, 1; 相当于 ax>>1, 移除的最后位会写入 CF, 最高位补 0
外中断
- 相关芯片将外设的输入对应的中断信息发给 CPU, CPU 执行完当前指令后检测到中断信息, 引发中断过程
- 可屏蔽中断是 CPU 可以不响应的外中断, 如果 IF 等于 1, CPU 执行完当前指令之后响应中断, 否则不响应, 这就是中断过程中将 IF 置零的原因
- 反之则是不可屏蔽中断, 8086 不可屏蔽中断的类型码固定为 2 (稀少)
- 8086cpu 键盘的输入, 按下键和松开键, 产生一个扫描码到达相应端口, 引发中断产生相对应的字符码或者是状态字节 (对应控制键和切换键写入内存当中相应单元) 字符码会送入 BIOS 键盘缓冲区, 在缓冲区当中可以存储 15 个键盘输入, 一个键盘输入用一个字存放, 高位字节存放扫描码, 低位字节存放字符码
直接定址表
seg name ; name 的段地址
a db 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
b dw 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ; 这么声明内存空间, 标号将带有长度信息
; b->word prt CS:8
- 所谓直接定值表, 就是将数据对应地址的偏移 (哈希)
使用 BIOS 进行键盘输入和磁盘读写
介绍了这两个功能的实现