X86基础

Intel 8086/8088 CPU 基础。

基本结构

8086和8088的体系结构、指令系统、指令编码格式和寻址方式完全相同,软件互相兼容。

  • 8086和8088的寄存器、寻址和执行都是16位的。
  • 8088的数据总线只有8位,因此在一个总线周期只能访问一个8位字节。
  • 8086的数据总线有16位。

8088内部有20根地址线,可访问内存为1MB。

  • 可随机访问的内存(RAM,640KB,地址空间范围为00000H ~ 9FFFFH)。
  • 只读的BIOS(ROM)
  • 设备接口空间(ROM/RAM)

寄存器

14个16位寄存器。

8个通用寄存器

  • 数据寄存器:AX, BX, CX, DX
  • 指针及变址寄存器:SP, BP, SI, DI

数据寄存器

可将AX, BX, CX, DX拆分为AH, AL, BH, BL, CH, CL, DH, DL8个8位寄存器。

虽然被称为通用寄存器,但有约定的用途。

  • AX:累加器(accumulator),用于存放操作数和运算结果(如乘除法指令),在输入输出指令中用于传送数据,系统调用功能号(AH)。
  • BX:基址寄存器(base register),间接寻址时存放基址。
  • CX:计数寄存器(count register),用于循环计数,移位指令使用CL位计数器。
  • DX: 一般用于通用寄存器,也用于存放I/O端口的输入输出地址,在乘除法指令中用于AX的高位拓展(乘积高位、被除数高位、余数)。

指针及变址寄存器

指针P(pointer),变址I(index)。

  • SP:堆栈指针(stack pointer),指向堆栈顶部的偏移地址。
  • BP:基址指针(base pointer),用于堆栈段的基址。
  • SI:源变址寄存器(source index),一般用于存放源数据的偏移地址。
  • DI:目的变址寄存器(destination index),一般用于存放目的数据的偏移地址。

4个段寄存器

段(segment)即内存中的一段区域,段寄存器存放段的起始地址/基址。根据8086/8088的地址空间,理论上段基址需要20位,但实际上只取高16位。

  • CS:代码段寄存器(code segment),存放代码段的基址。
  • DS:数据段寄存器(data segment),存放数据段的基址,若有多个数据段,需要相应修改DS寄存器。
  • SS:堆栈段寄存器(stack segment),存放堆栈段的基址。
  • ES:附加段寄存器(extra segment),用于存放附加数据段的基址,同时使用多个数据段时,可用ES寄存器。

1个指令指针寄存器

IP:指令指针寄存器(instruction pointer),存放(下一条)指令相对于代码段的偏移地址。不能直接修改。

1个标志寄存器

9个标志位,用于存放运算结果的状态信息。被组合为1个程序状态字寄存器PSW(Program Status Word)。

  • CF:进位标志(carry flag),用于存放最高位的进位,位移指令中存放被移出的位。
  • PF:奇偶标志(parity flag),奇偶校验。
  • AF:辅助进位标志(auxiliary carry flag),低4位产生进位时置1,用于BCD运算。
  • ZF:零标志(zero flag),运算结果为0时置1。
  • SF:符号标志(sign flag),运算结果为负数时置1。
  • TF:陷阱标志(trap flag),当置1时,执行一条指令后进入单步调试模式。
  • IF:中断标志(interrupt flag),当置1时,允许CPU响应外部中断。
  • DF:方向标志(direction flag),用于控制字符串操作指令的方向,置1时,地址递减。
  • OF:溢出标志(overflow flag),运算结果超出寄存器范围时置1。

DF、IF、TF是控制标志,需要专门的指令来设置;其他是状态标志。

内存

内存单元

8086/8088内存基本单位为字节,每个字节有唯一地址。

8086/8088字长为16位,任何两个相邻单元的字节都可以组成一个字。采用小端模式,低地址存放低位字节。

8086/8088也支持双字(32位)操作,且在双字内,也是小端模式。

内存地址

8086/8088的段地址和段内地址(偏移地址)都用16位表示,段地址负责高16位,段内地址(偏移地址)负责低16位,两者有12位重叠。

8086/8088中与内存单元一一对应的地址称为物理地址(physical address),物理地址是8086/8088访问内存的实际地址。段地址:偏移地址的组合称为逻辑地址(logical address),这与操作系统中的逻辑地址概念不同。

物理地址 = 段地址 * 10H + 偏移地址

在实际编程中,把16字节称为1节(paragraph),段地的实际大小往往是按节对齐的。

堆栈

我一向觉得这里“堆栈”的命名有问题,因为“堆”和“栈”是两个不同的数据结构。这里的堆栈单指“栈”。

堆栈段的基址固定,堆栈指针SP指向堆栈顶部的偏移地址,堆栈是向低地址方向增长的。如一开始SS:SP=2400H:100H,运行一段时间后变为SS:SP=2400H:80H。

  • 压栈:PUSH、PUSHF、CALL、INT(中断)等。
  • 出栈:POP、POPF、RET、IRET(中断返回)等。

压栈和出栈的都以字为单位,即16位,SP的值加减2。

指令和寻址

指令格式

; 1
OP  DST, SRC
; 2
OP  SRC
; 3
OP
  • OP:操作码,指令的功能。
  • DST:目的操作数,指令的结果存放的位置。
  • SRC:源操作数,指令的操作对象。

与精简指令集(RISC)不同,复杂指令集(CISC)的指令格式中,一条指令的长度不固定。8086/8088的指令长度为1~7字节。

操作码

操作码OP一般占用一个字节,的基本格式为:

op      d/s + w
  • op:操作码,1字节。
  • d: 两个操作数,操作数为目的操作数(1)还是源操作数(0)。
  • s: 操作数为立即数时是否有符号扩展,有符号(1)或无符号(0)。
  • w: 操作数的字长,字长为16位(1)或8位(0)。

8086/8088规定指令的两个操作数必须有一个是寄存器,另一个可以是寄存器、立即数、内存单元。

有时操作码会超过一个字节。

指令寻址方式

用1个字节表示操作数的寻址方式。基本格式为:

    7   6   5   4   3   2   1   0
+---+---+---+---+---+---+---+---+
|  mod  |    reg    |    r/m    |
  • mod:另一个操作数的寻址方式:
    • 00:常量字节寻址。
    • 01:带1个相对量字节寻址。寻址方式后面跟着1字节相对量。
    • 10:带2个相对量字节寻址。寻址方式后面跟着2字节相对量。
    • 11:寄存器寻址。
  • reg:寄存器编码,与w位结合:
    • w=0:AL, CL, DL, BL, AH, CH, DH, BH
    • w=1:AX, CX, DX, BX, SP, BP, SI, DI
  • r/m:另一个操作数的寄存器编码或内存单元编码,与mod位结合:
    • mod=11:寄存器寻址,r为寄存器编码。
    • mod!=11:
      • r=000:BX+SI
      • r=001:BX+DI
      • r=010:BP+SI
      • r=011:BP+DI
      • r=100:SI
      • r=101:DI
      • r=110:D16(mod=00), BP(mod=01/10)
      • r=111:BX

段超越

如果不适用隐含的段寄存器,显式指定段寄存器,会导致段超越。需要再置入一个字节的前缀。

    7   6   5   4   3   2   1   0
+---+---+---+---+---+---+---+---+
|001|       |  seg  |    110    |

seg:

  • 00:ES
  • 01:CS
  • 10:SS
  • 11:DS

寻址方式

各指令所需的操作数来自:

  • 寄存器
  • 立即数
  • 内存单元

立即寻址

立即数直接作为操作数,包含在指令中。需要与另一个操作数的位宽一致。

寄存器寻址

操作数是寄存器。必须遵循指令要求(如MOV的数据通路)。

寄存器寻址的操作数可能隐含,比如STD设置DF=1。

直接寻址

操作数在内存中,直接给出内存单元地址。如:

MOV     AX, [1234H] ; 相对于DS段

偏移地址制定了相对于段基址的偏移量,CPU通过地址总线访问内存。

下面的代码也是直接寻址:

x       DW      1234H
y       DW      5678H

MOV     AX, x
MOV     AX, x+1

寄存器间接寻址

操作数是内存单元,内存单元地址存放在寄存器中。如:

MOV     AX, [BX]    ; 相对于DS段
MOV     BH, [BP]    ; 相对于SS段
MOV     CX, [SI]    ; 相对于DS段

上述代码隐含了段寄存器,也可以显式指定:

MOV     AX, DS:[BX]
MOV     BH, SS:[BP]
MOV     CX, DS:[SI]

一般不会经常段超越。

下面指令是错误的:

MOV     AX, [DX]    ; DX不能用于间接寻址
MOV     AX, [*L/*H] ; 偏移值为16位,不能用于间接寻址  

寄存器相对寻址

操作数是内存单元,内存单元地址是一个寄存器加上一个相对量。如:

MOV     AX, [SI+10H] ; 相对于DS段
MOV     AX, 10H[SI]  ; 等价于上面

有一种比较特殊的情况:

MOV     AX, xxx[DI+yyy]

其本质是:

MOV     AX, [DI+(xxx+yyy)]

基址变址寻址

操作数是内存单元,内存单元地址是一个基址寄存器加上一个变址寄存器。如:

MOV     AX, [BX+SI]  ; 相对于DS段
MOV     AX, [BX][SI] ; 等价于上面
MOV     AX, DS:[BP+DI]  

下面的也是合法的:

MOV     AX, [BX+SI+10H]

需要注意的是,基址变址寻址的基址寄存器只能是BX或BP,变址寄存器只能是SI或DI。

转移地址

段内/段间 直接/间接 寻址。

段内直接寻址

汇编为相对于当前IP的偏移量,属于相对寻址。

  • 短跳转:8位偏移量,SHORT操作符。
  • 近跳转:16位偏移量,NEAR PTR操作符。

对于条件转移,只能是8位偏移量,忽略SHORT操作符,如果JMP指令省略了操作符,则使用16位偏移量。

段内间接寻址

跳转的有效地址存放在寄存器或内存单元中。存储单元位于数据段中,可以用任何一个寻址方式。

MOV     AX, OFFSET label    ; AX, BX, CX, DX, SI, DI,BP均可,一般不用SP
CALL    AX

需要注意的是,操作数是内存地址和标签的区别:

CALL    label ; 直接寻址
CALL    DATA  ; 间接寻址

更直观比如:

CALL    1234H   ; 直接寻址
CALL    [1234H] ; 间接寻址

段间直接寻址

指定段地址和偏移地址。

CALL    FAR PTR proc1   ; proc1是由FAR定义的过程

段间间接寻址

间接寻址时地址放入内存单元,且为双字。

JMP     DWORD PTR [1234H]

转移后,高位字为CS,低位字为IP。

指令系统

  • 数据传送指令
  • 算术指令
  • 逻辑指令
  • 串处理指令
  • 控制转移指令
  • 处理机控制指令

数据通路

MOV指令的数据通路:

  • 通用寄存器 -> 通用寄存器
  • 通用寄存器 -> 段寄存器
  • 通用寄存器 -> 内存
  • 段寄存器 -> 通用寄存器
  • 段寄存器 -> 内存
  • 内存 -> 通用寄存器
  • 内存 -> 段寄存器
  • 立即数 -> 通用寄存器
  • 立即数 -> 内存

注意内存不能直接到内存,段寄存器不能直接到段寄存器,立即数不能直接到段寄存器。

X86汇编

不区分大小写。

指令

[标号:] 指令助记符 指令操作数 [;注释]
  • 标号:相对于当前段的偏移量。
  • 指令助记符:指令的名称,如MOV、ADD、JMP等。
  • 指令操作数:指令的操作数,可以是寄存器、内存单元、立即数等,也可能没有操作数。
  • 注释:分号后的内容,不影响程序执行。

伪指令

[符号名] 伪指令 操作数 [;注释]
  • 符号名:无冒号,段名、过程名、变量名、常量名等。
  • 伪指令:不是真正的指令,是编译器的指令,用于定义数据、段、过程等。
  • 操作数:伪指令的操作数。

指令和伪指令中出现的常数,当以A-F开头时,必须在前面加0,以避免被认为是变量名或常量名。

段定义

段名 SEGMENT [对齐类型][组合类型][类别名]
    ...
段名 ENDS
  • 段名:段的名称。
  • 对齐类型:
    • BYTE:字节对齐。
    • WORD:字对齐。
    • PARA:节对齐,16字节。
    • PAGE:页对齐,256字节。
  • 组合类型:
    • NONE:缺省,本段独立。
    • PUBLIC:公共段,合并为一个段,基址为其中最早的段基址。
    • COMMON:共享段,与其它具有相同段名的段共享段基址,段长度为其中最大的段长度。
    • STACK:堆栈段,说明为堆栈段,会将所有同名的具有堆栈属性的段合并为一个堆栈段,并将SS初始化为堆栈段的基址,将SP初始化为堆栈段的长度。
    • MEMORY: 内存段,表示应该定位在所有其它连接在一起的段之前。
    • AT 表达式:直接指定段的物理地址,节对齐。
  • 类别名:段的类别名,用单/双引号括起来,链接程序会将类别名相同的段放在连续的内存区域,但仍是独立的段,优先级低于组合类型。

通过ASSUME语句,将段寄存器与段名关联。

过程定义

过程名 PROC [NEAR/FAR]
    ...
    RET
过程名 ENDP
  • 过程名:过程的名称,CALL的操作数。
  • NEAR/FAR:过程的类型,缺省为NEAR:
    • NEAR:近过程,段内过程,CALL后压栈下一条指令的偏移量。RET仍为RET。
    • FAR:远过程,段间过程,CALL后压栈段基址(一般是CS)和下一条指令的偏移量。RET汇编为RETF。

数据定义

常量由EQU伪指令定义。

常量名 EQU 常量表达式

当前位置由$符号表示,可以带入常量表达式。

DUP(operand,…,operand)为重复定义。

?表示不予置值。

用DB、DW、DD定义字节、字、双字。

[符号名] DB/DW/DD 操作数[, 操作数, ...]

数值回送

数值回送操作符有TYPE、LENGTH、SIZE、OFFSET和SEG共5种。

  • TYPE:TYPE 变量名/标号,返回变量的数据类型的值:
    • BYTE:1,DB定义的变量。
    • WORD:2,DW定义的变量。
    • DWORD:4,DD定义的变量。
    • NEAR:-1,段内标号或过程。
    • FAR:-2,段间标号或过程。
  • LENGTH:LENGTH 变量名,返回变量的长度。
  • SIZE:SIZE 变量名,返回变量的字节数,SIZE = LENGTH * TYPE。

实际上,SIZE和LENGTH没什么用,因为DUP伪指令会干扰其计算。实际应用时将两个标号相减即可得到实际的字节长度。

  • OFFSET:OFFSET 变量名/标号,返回变量的偏移地址。
  • SEG:SEG 变量名/标号,返回变量的段地址。

属性操作

属性操作符有PTR、段操作符、SHORT、THIS、HIGH、LOW共6种。

  • PTR:类型 PTR 地址表达式,临时修改地址表达式的类型。
  • 段操作符:CS:/DS:/ES:/SS: 地址表达式,用于段超越。
  • THIS: THIS 类型名,为一个地址指定另一个类型。
  • SHORT:SHORT 地址表达式,将地址表达式转换为短地址。
  • HIGH:HIGH 变量名,返回变量的高位字节。
  • LOW:LOW 变量名,返回变量的低位字节。

其它伪指令

  • ORG: 为下一条指令或数据指定一个偏移地址。
  • END: 程序/模块结束,指定一个标号作为程序的入口。
  • MACRO/ENDM: 定义宏,ENDM不需要宏名。

程序的调试与DEBUG

  • d [段名:][偏移量] [长度]:显示指定地址的内存内容,默认DS。(dump)
  • e [段名:][偏移量] [内容表]:修改指定地址的内存内容。(edit)
  • r [寄存器]:显示或修改寄存器内容。(register)
  • f:填写内存,批量修改内存内容,默认DS。(fill)
  • t: 单步跟踪。(trace)
  • p:单步执行,不会进入CALL指令。(proceed)
  • g [地址]:执行到指定地址,默认CS。(go)
  • u [段名:][偏移量] [长度]:反汇编,默认CS。(unassemble)
  • a [段名:][偏移量]:汇编,默认CS。(assemble)
  • q:退出DEBUG。

控制转移

无条件转移

即JMP指令。

条件转移

无符号:

  • JA:大于。
  • JAE:大于等于。有时汇编为JNB。
  • JB:小于。
  • JBE:小于等于。
  • JE:等于。
  • JNE:不等于。

有符号:

  • JG:大于。
  • JL:小于。

其它:

  • JC:进位。
  • JZ:零。

循环

LOOP:CX-1,不为0则转移。 JCXZ:CX为0则转移。

过程调用

CALL:将下一条指令的偏移地址压栈,转移。 RET/RETF:从栈中弹出地址,转移。

中断

INT n:调用中断向量表中的第n个中断处理程序。

跳表

在C语言中,很多初学者不能理解switch-case中需要break的原因,抑或是认为if-else和switch-case是等价的。实际上,switch-case模仿的是跳表或者跳转表的实现。switch-case判断的变量理论上是在判断一个偏移量,以下是一个简单的跳转表:

TABLE   DW      CASE1, CASE2, CASE3, CASE4, CASE5

        MOV     BX, VAR
        JMP     TABLE[BX]        
CASE1:
        ...
        JMP     BREAK
CASE2:
        ...
        JMP     BREAK
CASE3:
        ...
        JMP     BREAK
CASE4:
        ...
        JMP     BREAK
CASE5:
        ...
        JMP     BREAK
BREAK:

串处理

串处理操作隐含的是相对于DS和ES的SI和DI的间接寻址。它典型的寻址过程是:

DS:[SI] -> ES:[DI]

取串:

  • LODSB:把DS:[SI]字节取到AL,SI的值加减1。
  • LODSW:把DS:[SI]字取到AX,SI的值加减2。

存串:

  • STOSB:把AL存到ES:[DI],DI的值加减1。
  • STOSW:把AX存到ES:[DI],DI的值加减2。

串传送:

  • MOVSB:把DS:[SI]字节传送到ES:[DI],SI和DI的值加减1。
  • MOVSW:把DS:[SI]字传送到ES:[DI],SI和DI的值加减2。 此处是特例,其不满足MOV指令的数据通路,而且两个操作数都是内存单元。

串比较:

  • CMPSB:比较DS:[SI]和ES:[DI],SI和DI的值加减1。
  • CMPSW:比较DS:[SI]和ES:[DI],SI和DI的值加减2。 一般会和条件转移指令结合。

串扫描:

  • SCASB:扫描ES:[DI]的字节,判断是否为AL,DI的值加减1。
  • SCASW:扫描ES:[DI]的字,判断是否为AX,DI的值加减2。 扫描方法是用AL/AX减去ES:[DI],会影响标志位。

串重复:

  • REP:重复执行串操作,例如REP MOVSB,直到CX为0。
  • REPE:重复执行串操作,直到CX为0或ZF为0。
  • REPNE:重复执行串操作,直到CX为0或ZF为1。

修改方向

CLD:使DF=0,串操作从低地址向高地址。 STD:使DF=1,串操作从高地址向低地址。

注意传操作会改变一些寄存器的值,或者需要手动修改一些寄存器的值,因此调用时注意压栈。

调用和组织

子程序

过程(子程序)通过伪指令定义:

过程名 PROC [NEAR/FAR]
    ...
过程名 ENDP

NEAR和FAR对应的RET和RETF等相关对应不再赘述。

参数传递

  • 寄存器
  • 约定的内存变量
  • 堆栈

保存环境信息

需要防止子程序破坏调用者的环境信息。一方面减少修改段寄存器等,另一方面用堆栈保存子程序中修改的寄存器的原始值。

保存寄存器的值有两种方式,一种是在子程序中被动保存,另一种是调用者主动保存。在书上一般使用前者,大概是因为可以降低程序的耦合。在C语言中,有__stdcall__cdecl等调用约定,与此相关。

堆栈平衡

堆栈平衡是指子程序在返回时,堆栈的状态与调用时一致。否则无法返回到正确的地址。

堆栈传参

堆栈传参是指将参数压栈,然后子程序从堆栈中取出参数。这种方式的优点是参数个数不受限制,缺点是压栈和出栈的操作有性能开销。

一般的操作是在子程序内用BP寄存器保存刚刚进入子程序时的SP值,然后用BP寄存器相对寻址的方式从内存中取出参数。

返回时需要在RET指令后加上参数的字节数,以便平衡堆栈正确返回。这种情况会使得PUSH和POP的个数不相等。

PNAME   PROC    NEAR
        PUSH    BP
        MOV     BP, SP

        ...     ; 环境保存

        MOV     AX, [BP+4] ; 第一个参数,4是BP和IP的偏移量,如果是FAR则是6
        MOV     BX, [BP+6] ; 第二个参数
        ...

        ...     ; 子程序操作
        
        ...     ; 环境恢复
        POP     BP
        RET     n          ; n是参数的字节数
PNAME   ENDP

与riscv(规范使用堆栈传递参数)等不同,X86并没有一个严格的参数调用规范,但堆栈传递会比较通用和稳定。

子程序需要引入额外开销,而宏是在编译时展开的,因此效率更高。宏的定义和调用如下:

宏名 MACRO [参数1, 参数2, ...]  ; 形参
    ...
    ENDM

宏名 [参数1, 参数2, ...]   ; 实参

宏的参数可以是寄存器、内存单元、立即数等,也可以是字符串。宏的展开是简单的文本替换,不利于调试。

结构

结构是一种数据类型,可以包含多个数据成员。结构的定义和使用如下:

; 定义结构
结构名  STRUC
        DB/DW/DD  数据成员1, 数据成员2, ...
        ...
结构名  ENDS

; 使用结构
变量名  结构名  <字段值表>

; 访问结构成员
变量名.数据成员1

结构本身不占用内存空间,只是提供了一种新的数据类型或者说新的数据组织方式。

事实上,结构提供的组织方式并不一定要用于声明的结构变量,通过数组.数据成员的方式也可以应用结构的组织,结构本质上是内存区域.偏移量

中断

BIOS中断

在内存较高地址区域内的ROM种固化了BIOS系统。

中断号小于10H的为硬中断,由IO硬件事件触发,一般不由程序调用(虽然可以调用);大于10H的为软中断,由系统软件和用户程序调用。

中断号 功能 中断号 功能
00H 除法溢出 10H 显示器
01H 单步中断 11H 设备检验
02H 不可屏蔽中断 12H 内存大小检查
03H 断点中断 13H 磁盘
04H 溢出中断 14H 异步通信
05H 打印屏幕中断 15H IO系统扩充
08H 时钟中断 16H 键盘
09H 键盘中断 17H 打印机
0BH UART1中断 18H 驻留BIOS
0CH UAR0中断 19H 引导
0DH 硬盘中断 1AH 时钟
0EH 软盘中断 1BH 键盘Break
0FH 并行打印机中断 1CH 定时器

DOS中断

DOS中断是BIOS中断的扩展,提供了更多的功能。DOS借用了BIOS中软中断的功能带哦用,大部分为用户提供的调用都位于21H中断中。

中断号 功能 中断号 功能
20H 退出程序 26H 绝对磁盘写
21H DOS功能 27H 终止并驻留内存
22H 结束地址 28H-3EH DOS内部使用
23H ctrl-break 2FH 补充
24H 严重错误 30H-3FH 保留
25H 绝对磁盘读    

常用DOS功能中断

使用21H中断,功能号放在AH寄存器中。有些功能需要传递出口参数和入口参数。

AH=

  • 01H: 键盘输入字符,返回AL。
  • 02H: 显示字符,DL为字符ASCII码。
  • 09H: 显示字符串,DS:DX指向字符串。
  • 0AH: 输入字符串,DS:DX指向缓冲区。
  • 4CH: 退出程序,返回AL为退出码。