前言

汇编是个干黑客的好东西,可惜我已经没时间好好学了😭,本文以8086典中典cpu,MASM和王爽经典教材进行学习。

环境配置

古老的是通过dosbox,然后从网上找到古老的并非自带的MASM的工具,然后挂载,通过外部编辑器来写代码,dosbox只负责编译和运行。(但是dosbox明明是用来玩dos游戏的)

现在也可以使用vscode配置对应的环境,但是我还不会。TODO

引入

8086是16位处理器,一共只有14个寄存器虽然还是记不住,但是比64位的那么多寄存器好记多了。

字(word)代表着处理器一次性处理数据的能力,在16位中 1个字=2个字节(Byte)=16位(Bit), 而在常见的64位中 1个字 = 8个字节 = 64位

通用寄存器

8086提供四个16位通用寄存器:AX、BX、CX 和 DX。每个寄存器可拆分为两个8位部分:高字节(H)和低字节(L),例如 AX = AH + AL(其中+表示拼接)。 8086采用小端(little-endian)字节序:当将16位值存入内存时,低字节(如 AL)存储在较低的内存地址,高字节(如 AH)存储在相邻的较高地址。 但是说是通用其实还是有一定特定的用处的(约定俗成那种)只不过是还可以随便用,详见英文名字。

  • AX = AH + AL :Accumulator
  • BX = BH + BL:Base
  • CX = CH + CL:Count
  • DX = DH + DL:Data

简单的指令

1
2
3
4
5
mov ax, 18
mov ah, 18
add ax, 8
mov ax, bx
add ax, bx

在汇编中,预留关键词不区分大小写,但是用户自定义的是区分的。

例题:四条指令 计算2的4次方

1
2
3
4
mov ax, 2 ; ax = 2
add ax, ax ; ax = 4
add ax, ax ; ax = 8
add ax, ax ; ax = 16

寻址

不巧8086的地址总线是20位,cpu却只有16位,那么怎么办呢。当我们学这个的时候有一种先射箭后画靶的感觉,当初设计师肯定是先设计的寻址再让地址总线是20位。而20位支持1MiB的内存大小,当年还是很🐮的。 至于为什么不直接把地址总线设置成32位,那鬼才知道他脑袋一热怎么想的。

以上是废话,我们只需要知道在8086中,地址 = 段地址 x 16 + 偏移地址,段地址 x 16 又称之为基础地址。(注意,如果逆向过植物大战僵尸的修改阳光,肯定会遇到过基址这个东西,但是其实并不是一个东西,现代的基址是用来内存保护的。)

寄存器(寻址)

使用CS和IP,作为上面提到的段地址和偏移地址。即 地址 = CS x 16 + IP。(由于IP是16位,所以我们可知一个地址有很多种表示方式)

至于怎么修改这个这两个的值呢,不巧还真不能用mov,而是使用jmp CS:IP来修改,想要只修改IP,那就jmp 地址,至于只修改CS正常人类好像是没这个需求。

小于等于64KiB的一组代码可以视作一个代码段,如123B0H~123B9H,可以视作段地址位123BH的一个代码段,我们通过jmp 123BH:0就是可以实现开始执行这一堆,注意如果一个指令执行完了,IP会自动跳转到下一条指令的位置(相当于IP ← IP + 当前指令长度),但是指令既可以是2个字节,也可以是三个3字节

寄存器(内存访问)

对于两个连续的内存字节,我们既可以视作两个独立的字节,也可以视作一个字,但是要注意由于是小端,所以如果内存是12H 34H,对应的字其实是3412H

想要内存访问,也是需要通过16位的CPU来访问20位的地址总线,不过此时我们用DS寄存器来表示段地址,再加[偏移],来表示。

1
2
3
4
mov bx, 0100H     ; 将0100H加载到BX寄存器
mov ds, bx        ; 将BX的值赋给数据段寄存器DS
mov ax, [10H]     ; 从DS:10H地址读取字数据到AX
mov [0H], cx      ; 将CX寄存器的值写入到DS:00H地址

注意:ds不能直接用数字赋值,必须通过寄存器来间接赋值,就是这么设计的。

类似于之前的代码段,我们称从ds开始这64KiB位为数据段

补充:

MASM的逆天设计,导致上面的mov ax, [10H]其实是错误的,在debug中该语句时按照预期执行的,但是在MASM中实际上它是ax = 10H。(早期简单汇编器(如 Intel ASM)中,[100h] 本就是 “带括号的立即数” 写法(括号仅为视觉区分,无内存含义),MASM 作为商用汇编器,必须兼容这种行业惯例,否则开发者迁移成本极高。???)

如果想要实现这个功能

  1. 使用寄存器中转
  2. 显式加上段前缀
1
2
3
4
5
6
; 假设ds已经赋值
mov bx, 1234H      
mov ax, [bx]      ; 等价于 ds:[1234H],取DS:1234H处的字值

mov ax, ds:[0]    ; 明确指定从DS段偏移0处取字,赋值给ax
mov ax, cs:[0]    ; 从CS段偏移0处取字

内存访问补充(段前缀)

在指令前,你可以使用以下四个段前缀之一来覆盖默认的段寄存器:

  1. CS: (代码段前缀)
  2. DS: (数据段前缀)
  3. ES: (附加段前缀)
  4. SS: (堆栈段前缀)

我们先暂时了解:在 8086/8085 实模式下,内存地址 0:0200h0:02FFh(对应的物理地址是 00200h002FFh)这段 256 字节的空间,通常被 DOS 和其他合法程序刻意避开,主要是因为它是 BIOS 数据区 的重要组成部分,用于存储系统关键的运行时数据。

寄存器(栈)

在栈中,数据的操作单元是字,同样的小端存储。通过SS:SP来确定哪个地址是栈顶,注意在8086中是没有栈底这个概念的,处理器不会进行是否越界的判断,你需要自己保证SP不会超出SS段边界。

需要记住并理解的一件事情是8086中栈是从高地址到低地址的,所以初始化的时候SP应当为FFFEH(因为这个段最大是FFFFH,但是由于一次是移动两个字节,所以说要凑成偶数地址从而字对齐)(注意dosbox中经测试,sp在自动初始化的时候为0)

1
2
3
4
5
6
7
pop ax
mov ax, ss:[sp]
add sp, 2

push bx
sub sp, 2
mov ss:[sp], bx

上面是等价写法,助于我们理解其实际操作过程,和ds不一样,ss和sp是可以直接赋值的。(备注mov ax, [sp] 在汇编中默认是 DS:[SP],这是常见陷阱。必须显式写 ss:[sp] 或依赖 pop/push 自动用 SS。)

然后需要注意的一点,当你的SP为0000H的时候,你再次压入数据,会变成FFFEH,但是这不是循环栈或者地址回绕,这只是单纯的硬件无符号加减法导致的溢出,导致的环形相接。

程序

很不幸,学汇编的时候并不能很轻松的写出第一个hello,world。

但是我们要先背诵记住这个框架

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
DATA segment
    msg db 'Hello, World!', 0Dh, 0Ah, '$'
DATA ends

STACK SEGMENT PARA STACK 'STACK'
    db 1024 dup(?)
    STACK_TOP label word
STACK ENDS


CODE segment
    assume cs:CODE, ds:DATA
START:
    mov ax, DATA
    mov ds, ax

    mov ax, STACK
    mov ss, ax
    mov sp, offset STACK_TOP


    mov dx, offset msg
    mov ah, 09h
    int 21h
    mov ah, 4Ch
    int 21h
CODE ends
end START

或者使用伪指令.STACK [大小]来定义栈大小

1
.STACK 100h

在 8086 + MASM 的 EXE 程序中,使用 .STACK 时不需要手动初始化 SS 和 SP

伪指令

汇编语言中的指令分为伪指令和汇编指令,伪指令的作用和Java中的注解类似,并非是直接用来编译的,但是编译器可以根据其做出一些处理,而汇编指令则被编译成机器码。

对于不同的编译器伪指令也是不同的,我们只学习MASM的。

1
2
3
XXX segment

XXX ends

段是用来存储数据、代码或者当成栈的。一个汇编语言至少有一个代码段。

  1. 结束

end写所有的最后,表示程序的结束,注意和ends的区分

  1. assume

关联,将自己定义的段和系统的寄存器关联

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
; 假设数据段 DATA 由 DS 指向
ASSUME DS:DATA

; 假设代码段 CODE 由 CS 指向(通常可省略,CS 默认指向当前代码段)
ASSUME CS:CODE

; 假设堆栈段 STACK 由 SS 指向
ASSUME SS:STACK

; 如果不再使用某段寄存器与段的关联,可设为 NOTHING
ASSUME DS:NOTHING

在8086中,MASM 汇编器需要明确知道自定义的变量或者段应该用那哪个寄存器访问他,没有智能推断

返回

就像C语言中的return 0;一样(古老的标准中也是需要显式返回的,虽然现在可以不写),汇编需要对程序进行显式返回。

1
2
mov ax, 4c00H
int 21H

这个先记下来,可以先知道这是调用中断,至于中断如果接触过单片机的话会有所了解。

LOOP循环

其格式为

1
2
3
4
5
mov cx, 循环次数
标号: 指令
		 指令
		 ...
		 loop 标号

当走到loop这一行时,先执行cx–,然后再判断cx是否为0,如果不为0,再跳转到标号。用起来像C语言当中的goto一样。

我们以计算3*16为例

1
2
3
4
5
6
; 目标:计算 3 × 16 = 48,结果存放在 AX 中
mov cx, 16   ; 1. 设置循环次数:CX = 16(需要加16次3)
mov ax, 0    ; 2. 初始化累加器:AX = 0(乘法本质是累加)
chao:        ; 循环标号(类似C语言的goto标记)
add ax, 3    ; 3. 核心操作:AX = AX + 3(每次加3)
loop chao    ; 4. LOOP指令:CX-- → 判断CX是否为0 → 非0则跳回chao

跳转

在汇编中,我们相当于使用goto来实现一切的if语句种种,没想到啊,判断语句比循环语句还复杂。

8086 的条件跳转指令(Jcc依赖 CPU 标志寄存器 FLAGS 中的 6 个关键标志位

标志位 名称 含义
CF Carry Flag 进位/借位(无符号数运算)
ZF Zero Flag 结果为零
SF Sign Flag 结果符号位(最高位),负数则 SF=1
OF Overflow Flag 有符号数溢出
PF Parity Flag 结果低8位中 1 的个数为偶数则 PF=1
AF Auxiliary Flag 辅助进位(BCD 运算用)

跳转表格如下

类型 指令 条件 适用场景
无符号大于 JA CF=0, ZF=0 A > B (unsigned)
无符号小于 JB CF=1 A < B (unsigned)
有符号大于 JG (SF XOR OF)=0, ZF=0 A > B (signed)
有符号小于 JL (SF XOR OF)=1 A < B (signed)
等于 JE/JZ ZF=1 A == B
不等于 JNE/JNZ ZF=0 A != B
为负 JS SF=1 A < 0
为正 JNS SF=0 A >= 0
进位 JC CF=1 加法进位/减法借位
溢出 JO OF=1 有符号数溢出
等于0 JCXZ CX=0 循环前检查计数器是否为0,或字符串/数组边界处理

程序框架如下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
MOV AL, 7   
MOV BL, 5 

CMP AL, BL       ; 核心:AL - BL,仅更新 FLAGS,不改变 AL/BL
                 ; 根据结果,FLAGS 变化如下:
                 ;   AL > BL → ZF=0, (SF XOR OF)=0 → JG/JA 成立
                 ;   AL = BL → ZF=1               → JE 成立
                 ;   AL < BL → ZF=0, (SF XOR OF)=1 → JL/JB 成立

JE  equal        ; if (AL == BL)
JG  greater      ; if (AL >  BL)  ← 有符号
JL  less         ; if (AL <  BL)

**注意,汇编中的数字存储本身不分有无符号,只是在读取和运算的时候按照规则看待的,有符号的按照补码的方式存储。**这有助于我们理解溢出

标记位。

函数

指令 作用
PROC 定义一个过程(子程序),类似高级语言的函数/方法
CALL 调用一个过程(近调用或远调用)
RET 从过程返回到调用点(必须与 CALL 成对)

形如

1
2
3
4
函数名 PROC [距离属性]
; 函数实现
	RET
函数名 ENDP

距离属性:默认为NEAR段内调用,也可为FAR段见调用

调用 CALL 函数名

  • NEAR CALLPUSH IP

  • FAR CALLPUSH CSPUSH IP

返回RET

  1. 从栈顶弹出返回地址到 IP
    • NEAR RETPOP IP
    • FAR RETPOP IPPOP CS
  2. 继续执行 CALL 之后的指令

代码示例:

无参数,段内调用
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
DATA SEGMENT
    MSG1 DB 'Calling sub1...', 0DH, 0AH, '$'
    MSG2 DB 'Inside sub1!', 0DH, 0AH, '$'
DATA ENDS

CODE SEGMENT
    assume DS:DATA

; 子程序定义:NEAR(默认)
SUB1 PROC
    PUSH DX      ; 保护调用者寄存器(良好习惯)
    PUSH AX

    MOV DX, OFFSET MSG2
    MOV AH, 09h
    INT 21h

    POP AX
    POP DX
    RET          ; NEAR 返回
SUB1 ENDP

MAIN PROC
    MOV AX, DATA
    MOV DS, AX

    ; 显示调用前消息
    MOV DX, OFFSET MSG1
    MOV AH, 09h
    INT 21h

    ; 调用子程序
    CALL SUB1

    ; 程序结束
    MOV AH, 4Ch
    INT 21h
MAIN ENDP


CODE ENDS
END MAIN

从这个程序要学到以下内容:

  1. END后面标签指明了程序开始的地方,若无标签则从代码段第一行开始执行
  2. 保护调用者寄存器,在子程序用到的寄存器需要备份一下,防止在子程序中被更改,进而有可能对主程序造成影响(子程序中 修改了哪些寄存器,就保护哪些
寄存器传参

这种比较简便和方便,但是传参数目收到限制

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
; 计算 AX + BX → 返回结果在 AX
ADD_TWO PROC
ADD AX, BX
RET
ADD_TWO ENDP

; 调用:
MOV AX, 5
MOV BX, 7
CALL ADD_TWO   ; AX = 12
栈传参

我们要回忆一下,压栈的时候SP是减去对应的参数大小的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
; 调用前压栈(右→左)
    PUSH 10
    PUSH 20
    CALL ADD_STACK
    ADD SP, 4   ; 清理栈(调用者清理,C 规约)

ADD_STACK PROC
    PUSH BP
    MOV BP, SP
    MOV AX, [BP+4]   ; 第1个参数(20)
    ADD AX, [BP+6]   ; 第2个参数(10)
    ; 结果在 AX
    POP BP
    RET
ADD_STACK ENDP

也可以 RET 4为:stdcall / Pascal / Windows API → 被调用者清理

当然MASM有相应的语法糖

但是要标好语言约定格式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
DATA segment
DATA ends

CODE segment
    assume cs:CODE, ds:DATA

ADD_TWO PROC C a:word, b:word
    mov ax, b
    add ax, a
    ret
ADD_TWO ENDP

START:
    mov ax, DATA
    mov ds, ax

    mov ax, 10
    push ax
    mov ax, 20
    push ax
    call ADD_TWO
    add sp, 4      ; clean up the stack

    ; result is now in ax
    mov ax, 4C00h
    int 21h

CODE ends
end START

或者采用STDCALL,连ret几都自动帮你算好(就是proc后面的参数个数),缺点就是无法实现变长参数,但是都已经用参数了其实也无所谓。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
DATA segment
DATA ends

CODE segment
    assume cs:CODE, ds:DATA

ADD_TWO PROC stdcall a:word, b:word
    mov ax, b
    add ax, a
    ret
ADD_TWO ENDP

START:
    mov ax, DATA
    mov ds, ax

    mov ax, 10
    push ax
    mov ax, 20
    push ax
    call ADD_TWO

    ; result is now in ax
    mov ax, 4C00h
    int 21h

CODE ends
end START

需要注意一下参数的顺序

1
2
3
4
5
6
; C/STDCALL调用:func(a, b, c) → 压栈顺序:c, b, a
PUSH 3      ; 参数 c
PUSH 2      ; 参数 b
PUSH 1      ; 参数 a
CALL FUNC
ADD SP, 6   ; 调用者清栈(C 规约)

最后要注意访问值的时候的规则

寻址模式 默认段寄存器 示例
[BX], [SI], [DI] DS mov ax, [bx]DS:BX
[BP], [BP+偏移] SS mov ax, [bp+6]SS:BP+6
直接寻址 DS mov ax, [1234h]DS:1234h

对于如ADD_TWO PROC stdcall a:word, b:word的参数,其实是相当于bp这一种,所以想要修改外部的值的时候,记住通过中间寄存器来修改,否则修改的是栈上的值。

远调用

简单了解一下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
STACK_SEG SEGMENT
    DW 128 DUP(?)          ; 256 字节堆栈(128 words)
STACK_TOP LABEL WORD       ; 堆栈顶指针(初始化用)
STACK_SEG ENDS

DATA_SEG SEGMENT PARA 'DATA'
MSG_BEFORE  DB 'Calling FAR subroutine...$'
MSG_IN_SUB  DB 13,10,'>> Inside FAR subroutine!$'
MSG_AFTER   DB 13,10,'Returned from FAR call.$'
DATA_SEG ENDS

CODE_MAIN SEGMENT PARA 'CODE'
    ASSUME CS:CODE_MAIN, DS:DATA_SEG, SS:STACK_SEG

START:
    ; 初始化 DS 和 SS:SP
    MOV AX, DATA_SEG
    MOV DS, AX

    MOV AX, STACK_SEG
    MOV SS, AX
    MOV SP, OFFSET STACK_TOP   ; SP 指向栈顶(空栈时为最高地址)

    ; 显示调用前消息
    MOV DX, OFFSET MSG_BEFORE
    MOV AH, 09h
    INT 21h

    ; ★★★ 远调用子程序(跨段)★★★
    CALL FAR_SUB               ; 此处汇编成:CALL FAR PTR FAR_SUB

    ; 显示调用后消息
    MOV DX, OFFSET MSG_AFTER
    MOV AH, 09h
    INT 21h

    ; 正常退出
    MOV AH, 4Ch
    INT 21h

CODE_MAIN ENDS

; ────────────────────────────────────────
; ★ 子程序代码段(独立段!CS 将切换至此)★
; ────────────────────────────────────────
CODE_SUB SEGMENT PARA 'CODE'
    ASSUME CS:CODE_SUB, DS:DATA_SEG, SS:STACK_SEG

; ★ 必须声明为 FAR 过程!
FAR_SUB PROC FAR
    ; 标准 FAR 过程入口:保存 BP,建立栈帧
    PUSH BP
    MOV BP, SP             ; BP = 当前堆栈帧基址

    ; 保存调用者寄存器(调用约定要求)
    PUSH AX
    PUSH DX

    ; 显示进入子程序消息
    MOV DX, OFFSET MSG_IN_SUB
    MOV AH, 09h
    INT 21h

    ; 恢复寄存器(逆序)
    POP DX
    POP AX
    POP BP

    ; ★ RET FAR:弹出 IP + CS,返回主程序 ★
    RET                    ; 在 FAR PROC 中自动汇编为 RETF (CBh)
FAR_SUB ENDP

CODE_SUB ENDS

; ★ 指定程序入口点
END START

更多的运算

mul

MUL是无符号乘法,它的行为和结果存放位置依赖于操作数的大小(8 位或 16 位)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
MUL BL   ; AL * BL → AX
;16 位存放在 AX
;AL 存低 8 位
;AH 存高 8 位

MUL BX   ; AX * BX → DX:AX

;32 位,存放在 DX:AX
;AX 存低 16 位
;DX 存高 16 位

MUL 会根据结果设置 CF(进位标志)和 OF(溢出标志)

  • 如果结果 超出目标寄存器的大小 → CF = 1,OF = 1
  • 如果结果可以完全放入目标寄存器 → CF = 0,OF = 0

div

DIV无符号除法指令

除数是显式提供的操作数,被除数隐含在 AX 或 DX:AX,取决于操作数大小。

1
2
3
4
5
6
7
8
9
mov ax, 50h   ; 被除数 80
mov bl, 8     ; 除数 8
div bl        ; AL = 10, AH = 0


mov dx, 0
mov ax, 1000h   ; 被除数 4096
mov bx, 100h    ; 除数 256
div bx          ; AX = 16, DX = 0
操作数大小 被除数 余数
8 位 AX (16 位) AL AH
16 位 DX:AX (32 位) AX DX

中断

中断(Interrupt) 是一种软硬件机制,用于响应事件(如 I/O 操作、定时器、异常等)。我们在 DOS 或 BIOS 编程中,会用 INT n 来调用系统提供的服务。

DOS系统调用中断

INT 21H

注意是AH,看的是高8位

功能类别 AX 功能号 / 说明 说明/用途
字符输出 AH = 02h 输出字符到标准输出(DL = 字符)
字符输入 AH = 01h 从标准输入读取字符(回显到屏幕)读到AL里面
直接输入 AH = 08h 从标准输入读取字符(不回显)
输出字符串 AH = 09h 输出以 $ 结尾的字符串,DX = 地址
读磁盘文件 AH = 3Fh DX = 缓冲区, BX = 文件句柄, CX = 字节数
写磁盘文件 AH = 40h DX = 缓冲区, BX = 文件句柄, CX = 字节数
打开文件 AH = 3Dh DX = 文件名, AL = 0(只读)、1(写)
关闭文件 AH = 3Eh BX = 文件句柄
创建文件 AH = 3Ch DX = 文件名, CX = 属性
程序退出 AH = 4Ch AL = 返回码, 退出程序

常用到的换行0Ah回车0Dh

还有一个是常用但是比较复杂的,从键盘输入字符串AH = 0Ah

需要先准备一个 输入缓冲区,代码示例如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
DATA SEGMENT
buffer DB 100        ; 最大输入长度 100
       DB ?         ; DOS 返回实际长度
       DB 100 DUP(?) ; 字符存储区
DATA ENDS

CODE SEGMENT
ASSUME CS:CODE, DS:DATA
START:
    MOV AX, DATA
    MOV DS, AX

    ; 调用 DOS 功能 0Ah 输入字符串
    MOV AH, 0Ah
    LEA DX, buffer   ; DX = 缓冲区地址
    INT 21h

    ; 处理字符串结尾,添加 '$'
    LEA BX, buffer
    MOV AL, [BX+1]   ; 获取实际输入长度
    MOV AH, 0
    MOV SI, AX
    MOV BYTE PTR [BX+SI+2], '$' ; 在字符串末尾添加 '$'

    ; 输出换行 (可选,为了美观)
    MOV DL, 0Ah
    MOV AH, 02h
    INT 21h

    ; 输出输入的字符串
    MOV AH, 09h
    LEA DX, buffer + 2 ; DX 指向实际字符开始处
    INT 21h

    ; 退出程序
    MOV AH, 4Ch
    INT 21h
CODE ENDS
END START

BIOS中断

用于 硬件底层操作,如显示、键盘、磁盘操作。略

INT 10H - 视频服务

AH 功能号 功能说明
00h 设置显示模式
01h 设置光标属性
02h 设置光标位置
06h 向屏幕滚动
0Eh TTY 输出字符(带滚动)

INT 16H - 键盘服务

AH 功能号 功能说明
00h 等待键盘按键,返回 ASCII + 扫描码
01h 检测按键(非阻塞)
02h 获取键盘状态

IINT 13H - 磁盘服务

AH 功能号 功能说明
00h 重置磁盘
02h 读扇区
03h 写扇区
08h 获取磁盘参数

寄存器一览

一、通用寄存器(8 个 16 位寄存器)

通用寄存器用于临时存储数据和地址,支持 16 位整体操作或拆分为两个 8 位寄存器独立操作,是编程中最常用的寄存器组。

  1. 数据寄存器(4 个)

    • AX (Accumulator):累加器,多用于算术运算(加减乘除)、数据传输,是汇编指令的默认操作数寄存器。

      • 拆分:AH(高 8 位)、AL(低 8 位)
    • BX (Base):基址寄存器,常与段寄存器配合存放内存地址(基址寻址)。

      • 拆分:BH(高 8 位)、BL(低 8 位)
    • CX (Count):计数寄存器,用于循环(LOOP 指令)、字符串操作的计数器。

      • 拆分:CH(高 8 位)、CL(低 8 位)
    • DX (Data):数据寄存器,用于存放乘法运算的高位结果、除法运算的余数,或扩展地址总线宽度。

      • 拆分:DH(高 8 位)、DL(低 8 位)
  2. 指针与变址寄存器(4 个)这类寄存器不能拆分为 8 位,专门用于内存寻址,存放偏移地址。

    • SP (Stack Pointer):栈指针寄存器,指向栈顶的偏移地址,配合 SS 段寄存器使用,用于栈操作(PUSH/POP)。
    • BP (Base Pointer):基址指针寄存器,指向栈底或栈内某位置的偏移地址,配合 SS 段寄存器访问栈内数据。
    • SI (Source Index):源变址寄存器,字符串操作中存放源数据的偏移地址,配合 DS 段寄存器使用。
    • DI (Destination Index):目的变址寄存器,字符串操作中存放目的数据的偏移地址,配合 ES 段寄存器使用。

二、段寄存器(4 个 16 位寄存器)

8086 是 16 位处理器、20 位地址总线,通过段地址 + 偏移地址的方式生成 20 位物理地址(物理地址 = 段地址 × 16 + 偏移地址)。段寄存器专门存放段的起始地址(高 16 位)。

  • CS (Code Segment):代码段寄存器,存放程序指令所在段的段地址,配合 IP 寄存器定位下一条要执行的指令。
  • DS (Data Segment):数据段寄存器,存放程序数据所在段的段地址,默认用于访问内存数据。
  • SS (Stack Segment):栈段寄存器,存放栈空间所在段的段地址,配合 SP/BP 寄存器访问栈数据。
  • ES (Extra Segment):附加段寄存器,额外的数据段寄存器,常用于字符串操作的目的段地址。

三、控制寄存器(2 个 16 位寄存器)

控制寄存器用于控制 CPU 的运行状态和指令执行顺序,程序员只能间接修改,不能直接赋值。

  1. IP (Instruction Pointer):指令指针寄存器,存放当前代码段中下一条要执行指令的偏移地址,与CS配合确定指令的物理地址。

    • CPU 执行完一条指令后,会自动修改 IP 的值,指向下一条指令,程序员无法通过 MOV 指令直接修改。
  2. FLAGS

    :标志寄存器(也叫程序状态字寄存器 PSW),存放 CPU 执行指令后的状态标志和控制标志,共 16 位,仅 9 位有效(6 个状态标志 + 3 个控制标志)。

    • 状态标志(反映运算结果特征):

      • CF(进位标志):无符号数运算进位 / 借位时置 1。
      • PF(奇偶标志):运算结果低 8 位中 1 的个数为偶数时置 1。
      • AF(辅助进位标志):低 4 位向高 4 位进位 / 借位时置 1(用于 BCD 码运算)。
      • ZF(零标志):运算结果为 0 时置 1。
      • SF(符号标志):运算结果最高位为 1 时置 1(对应有符号数的负数)。
      • OF(溢出标志):有符号数运算溢出时置 1。
    • 控制标志(控制 CPU 运行方式):

      • TF(陷阱标志):置 1 时 CPU 进入单步执行模式(调试用)。
      • IF(中断允许标志):置 1 时允许 CPU 响应可屏蔽中断,置 0 时禁止。
      • DF(方向标志):字符串操作的方向控制,置 0 时正向(地址递增),置 1 时反向(地址递减)。

栈补充

栈和字符串结合起来就容易晕头转向。

首先要搞清楚OFFSET的用途,它的核心作用是:在汇编时获取某个标号(label)、变量或过程(procedure)在它所在段内的偏移地址(offset address),即段内偏移量(以字节为单位)。

而与其功能类似的是LEA(Load Effective Address): LEA reg16, mem 目标操作数 reg16:只能是 16 位通用寄存器:AX, BX, CX, DX, SI, DI, BP, SP 源操作数 mem:必须是内存寻址表达式(即含 [...] 的地址表达式),如:

  • [BX]
  • [SI+4]
  • [BX+SI+10h]
  • [BP+DI-2]
  • [DI]

注:可是明明有lea后面跟着标号的写法,但这其实只是MASM将label隐含成[label]了,对于lea的[],这个并非是取内存的值,只是对于框里面的表达式进行运算来求值而已。

然后我们就考虑如何处理栈必须是word为单位,而字符串确实用byte存储的问题。解决方式就是尽量不用,或者用八位的凑合用一下。

debug

MASM 的 debug 指令用于在 DOS 下调试可执行文件。常用指令如下:

  • d:显示内存内容(dump),如 d 100
  • u:反汇编(unassemble),如 u 100
  • e:编辑内存(enter),如 e 100
  • g:运行程序(go),如 g
  • t:单步执行(trace),如 t
  • r:显示/修改寄存器(register),如 r
  • q:退出 debug(quit)。

对于下断点:

在 DOS debug 里这样做:

  1. 载入程序

  2. 反汇编找到该指令偏移(例:u 100,记下 lea ax,[buffer+2] 前的偏移,如 0123)。

  3. 设断点并运行

到达断点后用 r 看寄存器,t 单步继续

或者可以在那行前插入软件断点 int 3,重新运行,使用g即可在此处停下。

停下后你就会发现确实是停下了,但也动不了了,此时需要R IP → 输入 当前IP+1;2. G

MASM 6.11邪教

.IF.WHILE.REPEATMASM(Microsoft Macro Assembler) 从 **v6.0+**开始支持的伪代码

1
2
3
4
5
6
7
.IF condition
    ; 代码块 A
[.ELSEIF condition2
    ; 代码块 B]
[.ELSE
    ; 代码块 C]
.ENDIF
1
2
3
.WHILE condition
    ; 循环体(可含 .BREAK / .CONTINUE)
.ENDW
1
2
3
4
5
.REPEAT
    ; 循环体(可含 .BREAK/.CONTINUE)
.UNTIL condition       ; 条件为真时退出
; 或
.UNTILCXZ              ; 等价于 .UNTIL cx == 0(常用于 string 操作)
  1. 大小写不敏感.if, .If, .IF 均可(但推荐大写保持风格统一)。
  2. 运算符空格eax==0 必须写成 eax == 0 (MASM 6.11 要求空格)。
  3. 寄存器 vs 内存:条件中尽量用寄存器,内存操作(如 [var] == 5)可能效率低或需 mov 中转。
  4. 嵌套支持:可任意嵌套(.IF 里套 .WHILE 等),但注意 .ENDIF/.ENDW 配对。
  5. 现代替代
    • x64 MASM(ml64.exe) 中,这些指令仍支持(Visual Studio 自带)。
    • 但纯汇编项目更倾向手写 cmp/jcc 以精确控制性能。
  6. 坑爹:默认是无符号的比较,想要有符号还是老老实实的cmp吧

八股取士补充

寻址

  1. 立即寻址 mov ax, 3064h

  2. 寄存器寻址 mov ax, bx

  3. 直接寻址 mov ax, [1234h] 注意:在 MASM 中,mov ax, [1234h] 默认被解释为 mov ax, 1234h(立即数寻址)

  4. 寄存器间接寻址 mov ax, [bx]

  5. 寄存器相对寻址 mov ax, count[SI] 等价于mov ax, [count + SI] count为提前定义好的符号地址

    1
    2
    
    .data
    count   dw  1111h, 2222h, 3333h, 4444h 
    
  6. 基址变址寻址方式 mov ax, [bx][di] 等价于 mov ax, [BX + DI]

  7. 相对基址变址寻址方式 相当于二维数组 mov ax, MASK[bx][si]等价于mov ax, MASK[bx+si] 等价于 mov ax, [MASK + bx + si]

然后是8086不支持的比例变址寻址方式,就是允许在寻址的时候进行乘法运算

1
2
3
mov eax, count[esi * 4]
mov ecx, [eax][edx * 8]
mov eax, table[ebp][edi*4]

跳转

近跳转只改 IP

1
2
3
4
5
JMP NEAR PTR PROGIA ; 16位
JMP SHORT QUESR ; 8位

JMP BX
JMP TABLE[BX]

段间跳转 = 修改 CS(代码段)IP(指令指针)

1
2
JMP FAR PTR NEXTROUTINY
JMP DWORD PTR[INTERS+BX]

NEXTROUTINE在同模块内,形如

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
.code
start:
    jmp far ptr NEXTROUTINE   ; 段间跳转

; 假设 NEXTROUTINE 在另一个段
NEW_CODE_SEG segment
    assume cs:NEW_CODE_SEG
NEXTROUTINE:
    mov ax, 1234h
    ; ...
NEW_CODE_SEG ends

INTERS 必须是一个内存变量(标号),指向一个 FAR 指针表(每个元素 4 字节:IP + CS)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
.data
; 定义一个远指针(CS:IP)
INTERS  dd  ROUTINE1      ; dd = define double word (4 bytes)
        dd  ROUTINE2

.code
    mov bx, 0             ; 选第 1 个指针(偏移 0)
    jmp dword ptr [INTERS + bx]   ; 跳转到 ROUTINE1

    mov bx, 4             ; 第 2 个指针(偏移 4)
    jmp dword ptr [INTERS + bx]   ; 跳转到 ROUTINE2
    
OTHER_SEG segment
    assume cs:OTHER_SEG
ROUTINE1 proc far         ; 必须是 FAR 过程!
    ; ...
    retf                  ; 段间返回用 RETF(非 RET)
ROUTINE1 endp
OTHER_SEG ends

冷门指令

冷门且无用

8086有都没有的

movsx 符号扩展的数据传送。将小尺寸有符号整数扩展为大尺寸(高位用符号位填充:正数补 0,负数补 1)。

movzx零扩展的数据传送。将小尺寸无符号整数扩展为大尺寸(高位补 0)。

1
2
3
4
5
6
7
8
9
mov al, 0xFE      ; AL = 1111 1110₂ = -2(有符号)
movsx ax, al      ; AX = 1111 1111 1111 1110₂ = 0xFFFE = -2
movsx eax, al     ; EAX = 0xFFFFFFFE = -2
movsx rax, al     ; RAX = 0xFFFFFFFFFFFFFFFE = -2(64 位)

mov al, 0xFE      ; AL = 254(无符号)
movzx ax, al      ; AX = 0x00FE = 254
movzx eax, al     ; EAX = 0x000000FE = 254
movzx rax, al     ; RAX = 0x00000000000000FE = 254

没人用过的

XCHG ax, bx交换

考试有用的

累加器专用传送指令

1
2
3
4
5
IN AL, PORT(Btye)
IN AX, PORT(Word)

OUT PORT(Byte), AL
OUT PORT(Byte), AX

8086中I/O端口与CPU通信使用IN和OUT指令,端口的范围为0~FFFFH,当端口号小于256时(0~255),可以使用立即数

eg对于读取时间

1
2
3
4
5
6
7
时间在CMOS RAM 中的地址偏移量分别是:
秒 → 地址 00H
分 → 地址 01H
时 → 地址 04H
日 → 地址 07H
月 → 地址 08H
年 → 地址 09H
1
2
3
4
MOV AL, 04H        ; 要读取的地址是 04H(小时)
OUT 70H, AL        ; 写入地址到索引端口
IN  AL, 71H        ; 从数据端口读取数据
; AL 中现在包含 BCD 编码的小时值,比如 0x14 表示 14 点

XALT:AL 作为索引,从一张字节表中取出对应字节,放回 AL

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
.MODEL SMALL
.STACK 100h

.DATA
    hex_table DB '0123456789ABCDEF'   ; 16字节查表
    prompt    DB 'Input hex digit (0-F): $'
    result    DB ' -> $'

.CODE
MAIN PROC
    MOV AX, @DATA
    MOV DS, AX          ; 初始化 DS

    ; 显示提示
    MOV DX, OFFSET prompt
    MOV AH, 09h
    INT 21h

    ; 读取一个字符(假设用户输入 0~F)
    MOV AH, 01h         ; DOS 读字符(回显)
    INT 21h             ; AL = 输入字符(如 'A' = 41h)

    ; 转换为索引:'0'~'9'→0~9, 'A'~'F'→10~15
    CMP AL, '9'
    JBE is_digit
    SUB AL, 'A' - 10    ; 'A'→10, 'B'→11...
    JMP done_convert
is_digit:
    SUB AL, '0'         ; '0'→0, '1'→1...

done_convert:
    ; ★ 关键:设置 BX 指向表,AL 为索引,执行 XLAT
    MOV BX, OFFSET hex_table
    XLAT                ; AL = [BX + AL]

    ; 显示转换结果
    PUSH AX             ; 保存 AL(结果字符)
    MOV DX, OFFSET result
    MOV AH, 09h
    INT 21h
    POP AX

    MOV DL, AL          ; 显示查表得到的字符
    MOV AH, 02h         ; DOS 输出字符
    INT 21h

    ; 退出
    MOV AH, 4Ch
    INT 21h
MAIN ENDP

END MAIN

地址传送指令

LEA

LEA reg16, memory_operand

⚠️ reg16 只能是 16 位通用寄存器(AX/BX/CX/DX/SI/DI/BP/SP) ⚠️ memory_operand 必须是内存操作数(不能是立即数或寄存器)

LDS、LES、LFS、LGS、LSS

8086 只支持 LDSLES


标志寄存器

LAHF: AH <- 标志低字节送到AH SAHF: AH -> 标志 PUSHF: 标志进栈 POPF: 标志出栈


CBW: 字节转为字 AL → AX 符号扩展 CWD: 字转为双字 AX → DX:AX 符号扩展 可视作 负数 扩展全为1,正数扩展全为0,这样维持了补码形式的大小不变和符号的正负 注意上述操作对象只能是AL和AX,这是无参数的指令


加法带进位、减法带借位

ADC dst, src : dst <- dst + src + cf

SBB dest, src: dst -< dst - src - cf

这个是用来进行32位加减法的

1
2
3
4
5
; 输入:A = DX:AX,  B = CX:BX
; 输出:A = DX:AX ← A + B
;       CF = 最终进位(33 位结果的第 33 位)
    ADD  AX, BX      ; 低 16 位相加,CF = 低字进位
    ADC  DX, CX      ; 高 16 位 + 进位
1
2
3
4
5
; 输入:A = DX:AX,  B = CX:BX
; 输出:A = DX:AX ← A - B
;       CF = 1 表示 A < B(无符号),即借位发生
    SUB  AX, BX      ; 低 16 位相减,CF = 1 当且仅当 AX < BX(需借位)
    SBB  DX, CX      ; 高 16 位 - 借位

乘除法符号

imul bx AX * BX → DX:AX idiv bx DX:AX(32 位)/ bx -> 商AX 余数 DX

1
2
mov ax,被乘数      ;16 位
imul 乘数          ;16 位 → 结果在 DX:AX
1
2
3
add ax,低常数
adc dx,高常数    ;带进位加高 16 位
idiv 除数        ;最终商在 AX,余数在 DX

必考之加减乘除 $$ \text{Result} = \left\lfloor \frac{A \times B + 2026}{C} \right\rfloor + D \times E $$

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
mov ax, A
mov bx, B
imul bx ; dx:ax = ax * bx = A * B
add ax, 2026
adc dx, 0
mov bx, C
idiv bx ; ax ... dx 
push ax
mov ax, D
mov cx, E
imul cx ; dx:ax = D * E
mov bx, ax
mov cx, dx
pop ax
cwd
add ax, bx
adc dx, cx

最终 Result 存放在:DX:AX (32 位有符号整数)

中断

中断 = 外部/内部事件“打断”CPU当前工作,让它去处理更紧急的事。

中断分为可屏蔽中断和非屏蔽中断。

CPU通过IF位(Interrupt Flag,中断允许标志位)

  • IF = 1 → 允许响应可屏蔽中断
  • IF = 0 → 屏蔽(忽略)可屏蔽中断
1
2
STI ; IF = 1
CLI ; IF = 0

所以 IF位中断屏蔽寄存器用来实现两级控制是否允许中断。

非屏蔽中断(NMI) = 不能被忽略的超级紧急事件NMI 。无视IF位,CPU一定会响应。

中断屏蔽寄存器的IO端口是21H,通过八位来对应控制八个外部设备的是否允许中断,1表示禁止,0表示允许

7 6 5 4 3 2 1 0
打印机 软盘 硬盘 串行通信口1 串行通信口2 保留 键盘 定时器

编写中断程序时,需要在主程序配置好中断屏蔽寄存器和IF并设置中断向量,所以需要先知道什么是中断向量表

每个中断都有一个编号,叫 中断号(Interrupt Vector Number) 中断向量表(IVT) 就是一个数组,有 256 个中断向量。当触发中断时可以高效地跳转到对应的中断程序地址。

1
2
3
4
5
6
7
8
9
; 设置中断向量
ah = 25h
al = 中断类型号
ds : dx = 用于赋值中断向量

; 读取中断向量
ah = 35h
al = 中断类型号
es : bx = 返回的中断向量

以覆写定时中断为例(假设已经有一个countdown proc)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
; 首先备份原先的中断地址
mov ah, 35h ; 35h 表示读取中断
mov al, 1ch
int 21h
push es
push bx
; 然后修改成自己的中断程序
push ds ; 备份ds
mov ax, seg countdown
mov ds, ax ; 段地址
mov dx, offset countdown ; 偏移地址
mov ah, 25h ; 25h 表示设置中断
mov al, 1ch ; 
int 21h
pop ds ; 恢复ds
; 确保中断启用
cli
mov al, 11111110B
out 21H, al
sti

注意中断的函数返回是iret


通过1CH中断进行定时操作

串子

方向标志 DF(Direction Flag)

  • DF = 0正向(递增) —— 字符串操作时,SI/DI 自动 +1(字节)或 +2(字)
  • DF = 1反向(递减) —— SI/DI 自动 –1(字节)或 –2(字)
1
2
3
CLD          ; 设置 DF=0,SI/DI 递增(最常用)
; 或
STD          ; 设置 DF=1,SI/DI 递减(如从高地址向低地址复制)

8086 提供 5 条隐式操作寄存器的字符串指令:

指令 操作 源地址 目标地址 说明
MOVSB / MOVSW 移动字节/字 [DS:SI][ES:DI] 自动更新 SI/DI 内存复制(需 DS→ES)
CMPSB / CMPSW 比较字节/字 (SI- ) [DS:SI] vs [ES:DI] 更新 SI/DI,影响 FLAGS(ZF, CF 等) 可用于 repe CMPSB 实现 memcmp
SCASB / SCASW 扫描(搜索)字节/字 AL/AX vs [ES:DI] 更新 DI,影响 FLAGS 常用于 repne SCASB 搜索字符(如 strlen)
STOSB / STOSW 存储字节/字 AL/AX[ES:DI] 更新 DI 常用于 rep STOSB 填充内存(如 memset)
LODSB / LODSW 加载字节/字 [DS:SI]AL/AX 更新 SI 较少与 REP 连用(因无目标存储)

这些是重复前缀,作用于带 CX 计数的字符串指令,相当于“硬件循环”。

前缀 全称 条件 常用于
REP Repeat CX ≠ 0 无条件重复(MOVSB, STOSB
REPE / REPZ Repeat while Equal / Zero CX ≠ 0 ZF = 1 CMPSB, SCASB(如比较相等时继续)
REPNE / REPNZ Repeat while Not Equal / Not Zero CX ≠ 0 ZF = 0 SCASB, CMPSB(如搜索不等时继续)