Part 2. 数据访问
!!! warning "注意"
不知怎么回事,这一页的[heti插件](https://github.com/TonyCrane/mkdocs-heti-plugin)渲染爆炸,排版弄得一团乱,可能给读者带来不良的观看体验,请见谅。
~~(也许之后我会改改这个bug)~~
寄存器
- 8086一共有14个寄存器(均为16位宽度),分别为:
ax、bx、cx、dx、sp、bp、si、di、cs、ds、es、ss、ip、fl - 80386除了段寄存器仍为16位,其余寄存器均扩展至32位,这14个寄存器分别为:
eax、ebx、ecx、edx、esp、ebp、esi、edi、cs、ds、es、ss、eip、efl- 如果在8086汇编程序中使用32位的寄存器,需要在程序开头插入一行
.386;并且在数据段开头(即data segment)处补上use16(即改写为data segment use16),以保证偏移地址仍然是16位的
- 如果在8086汇编程序中使用32位的寄存器,需要在程序开头插入一行
按照寄存器的用途,可将寄存器分为以下几类:
通用寄存器
作用:算术、逻辑、移位运算
寄存器:
ax、bx、cx、dx(80386中寄存器名称前多个e)- 其中
ax的低8位和高8位可以分别用寄存器al和ah表示。下图展示了eax、ax、ah、al的关系:
  - 单独使用
al运算时,溢出的高位并不会被保存在ah中,因为此时al和ah被视为两个独立的寄存器 bx、cx、dx与ax同理。
- 其中
段地址寄存器
- 作用:表示段地址
- 寄存器:
cs:代码段寄存器,存放代码段的段地址- 不能用
mov指令赋值,只能用jmp、call、retf、int、iret等指令间接改变其值 - CPU执行指令的流程为:
- CPU从
cs:ip指向的内存单元读取指令,该指令会进入指令缓冲器 ip = ip + length_of_instruction,即指向下一条指令- 执行指令,跳到步骤a,重复这个过程
- CPU从
- 8086CPU通电或复位后,
cp = ffffh, ip = 0000h
- 不能用
ds:数据段寄存器,存放数据段的段地址es:附加段寄存器ss:堆栈段寄存器,存放堆栈段的段地址后三者可以用
mov指令赋值,但源操作数不能是常数,只能是寄存器(而且只能在ax、bx、cx、dx、sp、bp、si、di中选)或变量(必须是单字宽度(2字节)的)
偏移地址寄存器
- 作用:表示偏移地址
- 寄存器:
ip- 与
cs搭配使用,cs:ip指向当前将要执行的指令 - 该寄存器不能直接出现在任何指令中,但可以通过
jmp reg跳转指令等控制转移指令来修改ip的内容
- 与
sp- 与
ss搭配使用,ss:sp指向堆栈顶端 - 不能置于
[]内用于间接寻址
- 与
bx(通用寄存器)、bp、si、di- 能放在
[]内用于间接寻址 - 还可以参与算术、逻辑、移位运算
- 能放在
???+ info "寄存器的初始化"
DOS把可执行程序加载到内存后,即将控制权交给可执行程序前,会对以下寄存器初始化:
- `cs`:代码段的首地址
- `ip`:首条指令的偏移地址
- `ss`:堆栈段的段地址
- `sp`:堆栈段的长度
- `ds`:[PSP](3.md#运行)段地址
- `es`:PSP段地址
标志寄存器
- 作用:存储标志位,这些信息通常被称为程序状态字(PSW)。
- 寄存器:
fl(不能直接出现在指令中)。它里面的位分为3类- 状态标志:反映当前指令的执行情况,包括:
cf、zf、sf、of、pf、af - 控制标志:控制CPU,包括:
df、if、tf - 保留位(下图用 X 表示):除了第1位为1,其余保留位恒为0
- 状态标志:反映当前指令的执行情况,包括:
!!! info "注"
几乎每个标志位都有对应的指令,其中以`j`开头的指令都是**条件跳转指令**,它们将对应标志位的信息作为跳转的条件。[Part 4](4.md#条件跳转指令)列出了常用的条件跳转指令表格。
各类标志如下(前6种标志称为状态标志,后3种标志称为控制标志):
- (第0位)进位标志(carry flag) cf:
- 该标志仅对无符号数有意义
- 两数相加(不包括
inc指令)产生进位,cf = 1 - 两数相减(不包括
dec指令)产生借位,cf = 1 - 两数相乘的乘积宽度超过被乘数宽度,cf = 1
- 移位指令最后移出的那1位保存在cf中
- 相关指令:
jc、jnc、clc(令cf = 0)、stc(令cf = 1)、cmc(对cf取反)、adc等
- (第6位)零标志(zero flag) zf:
- 运算结果为0时,zf = 1;运算结果不为0时,zf = 0
- 相关指令:
jz、jnz、je、jne
- (第7位)符号标志(sign flag) sf:
- 该标志仅对符号数有意义
- 表示运算结果的最高位,当运算结果为正时sf = 0,为负时sf = 1
- 相关指令:
js、jns
- (第11位)溢出标志(overflow flag) of:
- 用于检测符号数的溢出情况(对应无符号数的进位和借位),包括:
- 两个正数相加变负数时,of = 1
- 两个负数相加变正数时,of = 1
- 两数相乘的乘积宽度超过被乘数宽度时,of = 1
- 当仅移动1位且移位前的最高位
移位后的最高位时,of = 1
- 相关指令:
jo、jno
- 用于检测符号数的溢出情况(对应无符号数的进位和借位),包括:
- (第2位)奇偶校验标志(parity flag) pf:
- 当运算结果低8位中
1的个数为偶数时,pf = 1,否则pf = 0(偶校验) - 相关指令:
jp、jnp、jpe、jpo
- 当运算结果低8位中
- (第4位)辅助进位标志(auxiliary flag) af:
- 若执行加法时第3位向第4位产生进位(或者理解为低4位向高4位进位)时,af = 1
- 若执行减法时第3位向第4位产生借位(或者理解为低4位向高4位借位)时,af = 1
- 跟BCD码调整指令有关
(第10位)方向标志(direction flag) df:
- 作用:控制字符串操作指令的运行方向,具体而言控制的是si和di的增减
- df = 0:字符串操作指令按正向(从低到高)运行,即每次操作后si、di递增
- df = 1:字符串操作指令按反向(从高到低)运行,即每次操作后si、di递减
- 相关指令:
cld(使df = 0)、std(使df = 1)、movsb、movsw
- 作用:控制字符串操作指令的运行方向,具体而言控制的是si和di的增减
(第9位)中断标志(interrupt flag) if:
- 作用:控制硬件中断
- if = 0:禁止硬件中断
- if = 1:允许硬件中断
- 相关指令:
cli(使if = 0)、sti(使if = 1)
- 作用:控制硬件中断
(第8位)陷阱标志(trap flag) tf:
- 作用:设置CPU的运行模式,与调试相关
- tf = 0:常规模式,连续执行指令
- tf = 1:单步模式,每执行一条指令后都会跟随执行
int 01h中断指令,用于调试
- 相关指令:
pushf、popf
asm; 令 tf = 1 pushf ; 将fl压入堆栈中 pop ax ; 从堆栈中弹出`fl`的值并保存到ax中 or ax, 100h ; 把ax的第8位置1 push ax popf ; 从堆栈中弹出ax的值并保存到fl中,此时tf = 1 ; 令 tf = 0 pushf pop ax and ax, 0FEFFh ; 把ax的第8位清零 push ax popf ; 从堆栈中弹出ax的值并保存到fl中,此时tf = 0- 作用:设置CPU的运行模式,与调试相关
内存
小端规则
小端规则(little-endian):当CPU写入或读取宽度大于8位的数据时,会按照“低位在先高位在后”的顺序存储或获取数据。换句话说,存储在寄存器内的数据的位顺序和我们看到的位顺序是相反的。
??? example "例子"
假如内存中有以下数据:
```asm
data segment
a dw 1234h
b dw 5678h
c dd 12345678h
data ends
```
实际的内存空间为:
<div style="text-align: center">
<img src="images/3.png" width="50%">
</div>
可以看到,白色高亮部分表示的是`a`, `b`, `c`的值,它们的值分别为`3412`, `7856` 和 `78563412`
物理地址与逻辑地址
DOS系统运行在CPU的实模式 (real mode) 下,可访问的地址范围为[00000h, 0FFFFFh],即最多只能访问 1MB 内存空间。
物理地址(physical address):用单个数值表示,每个内存单元都有一个唯一的物理地址
- CPU通过地址总线传给存储器的必须是一个内存的物理地址
逻辑地址(logical address):物理地址的间接表示,形式为:段地址:偏移地址
- 原因:由于 8086CPU 的每个寄存器只有 16 位宽度,而CPU的地址总线宽度为 20 位,因此无法直接传递物理地址,而是通过逻辑地址来访问内存的
- 段地址和偏移地址是两个 16 位的地址,它们合在一起形成一个 20 位的物理地址,转换关系式为:
- 一个物理地址可以表示成多种逻辑地址,换句话说,不同的段地址和偏移地址可以形成同一个物理地址,例如:12398h = 1234:0058 = 1235:0048 = 1236:0038 = 1230:0098。
偏移地址(offset address):段内某个变量或标号与行首之间的距离
- 偏移地址 = 物理地址 - 段首地址( = 段地址 * 10h)
- 用
offset 变量名或标号名表示变量或标号的偏移地址 - 可以用常数表示
段地址(segment address):20位段首地址的高16位
- 用
seg 变量名或标号名或段名(在assume伪指令中与段寄存器有联系)表示变量或标号的段地址 - 不能用常数表示,只能用段寄存器表示
- 段地址的1相当于偏移地址的10h
- 用
段(segment):一块内存,包含若干内存单元,实际上内存内部并没有分段,而是由 CPU 划分的
- 成为段的要求:
- 20 位地址的低 4 位必须为 0,换句话说,段首地址的十六进制形式下的偏移地址的个位必须为0(16的倍数)
- 段的容量不大于 10000h 字节(偏移地址的最大变化范围),即64KB
- 段的种类:
- 数据段
- 代码段
- 堆栈段
- 附加段
- 成为段的要求:
??? info "补充:32位系统的逻辑地址(仅做了解)"
- 偏移地址扩展为32位
- 段地址保持16位,但是段首地址不再是从在16进制下的段地址后面填0得到,而是通过查表(称为全局描述符表,简称gdt表)得到段首地址
- gdt表其实是一个数组,该数组的首地址存放在gdtr寄存器内,数组中每个元素的宽度均为8字节
- gdt表首地址 + 段地址得到数组元素地址,然后从该数组元素的第2、3、4、7个字节逆向排列得到段首地址
- 段首地址 + 偏移地址 = 物理地址
- 数组元素的其余4个字节看作32位,其中20位用于表示段的长度(单位为字节或页(4KB)),剩余的12位中有一部分位用来表示段的ring级别(0、1、2、3共4级)及权限(读R、写W、执行X)
- 系统代码是ring0,而用户代码是ring3,只有用户代码的ring级别小于对应段描述的ring级别时才能访问该段
寻址方式
假定:
seg_reg:段寄存器- 段覆盖:通过在操作数前面加一个段前缀
seg_reg:来强制改变操作数的段地址
- 段覆盖:通过在操作数前面加一个段前缀
var:变量名 / 数组名idata:常数 / 立即数(整数)reg1、reg2:寄存器1、寄存器2
80x86CPU提供以下几种寻址方式:
- 直接寻址(方括号内只有立即数):一般形式为
seg_reg:var[idata]或seg_reg:[var+idata] - 间接寻址(方括号内还有寄存器):一般形式为
var[reg1+reg2+idata]或[var+reg1+reg2+idata]其中寄存器1和寄存器2至少存在1个
寄存器只能在
bx、bp、si、di四种寄存器内选如果在
[...]内只使用寄存器bp,且缺省段地址,则默认使用ss中的段地址(堆栈段);其他情况下默认使用ds中的段地址(数据段)如果出现两个寄存器相加的情况,其中一个寄存器必须从
bx、bp中选,另一个必须从si、di中选变体(这里忽略
var):- 表示一维数组:
idata[si],idata[bi] - 表示二维数组:
[bx][idata],[bx][si],idata[bx][si] - 表示结构体:
[bx].idata,[bx].idata[si](bx定位整个结构体,idata定位结构体的某个字段,si定位结构体数组字段的某个元素)
- 表示一维数组:
80386的一般形式为:
seg_reg:[reg1+reg2*N+idata],其中N是集合 {1, 2, 4, 8} 内的一个元素,寄存器1与寄存器2只能在eax、ebx、ecx、edx、esp、ebp、esi、edi八种寄存器内选,但可以重名
相关的操作符:
offset var/label:获取变量或标号的偏移地址
具体到变量的引用:
- 单个变量/数组首元素的引用:
var或[var] - 第
i个数组元素的引用:a[i * n]或[a + i * n],其中a是元素宽度为n字节的数组(与C语言略有不同) - 在数据段中
var或offset var都可以作为伪指令dw的操作数,表示var的偏移地址(近指针)var还可以作为伪指令dd的操作数,表示该变量的偏移地址的远指针
- 在代码段中,只能用
offset var引用该变量的偏移地址,用seg var或数据段名引用该变量的段地址
位置计数器(location counter):一个用于记录当前段内变量或标号的偏移地址
- 在段定义开始时,编译器会自动把位置计数器清零
- 每编译完一条指令或伪指令语句时,编译器会把该语句的宽度(即对应机器码的字节数)加到位置计数器中
- 一种特殊的操作数
$,它表示当前位置计数器的值,可以用它来计算数组的长度
变量定义
一般情况下变量在数据段中被定义,根据变量的定义位置来决定段地址和偏移地址
变量的命名规则:
- 可用字符有:大小写字母、数字、符号
@、$、?、_ - 不得以数字开头
$和?不能单独作为名称- 名称长度不超过 31 个字符
- 在缺省情况下,变量名及标号名不区分大小写
- 相同名称不得重复定义
- 不能与 80x86 指令、伪指令、汇编指示指令名相同
- 标号名的命名规则同上
- 可用字符有:大小写字母、数字、符号
要用到的操作符:
db:定义字节大小的变量dw:定义字大小的变量dd:定义双字大小的变量
格式:
asmvarname db|dw|dd|dq|dt valuevarname表示变量名db、dw、dd、dq、dt是伪指令,分别表示不同位宽的数据(具体含义见Part 1)value表示初始值
dup运算符:用于生成重复(duplicate) 的数据,格式为:asmvarname db|dw|dd|dq|dt n dup(x1[, x2, ..., xm])其中
n表示重复的次数,x1, x2, ..., xm表示重复项,可以有 1 个或多个,且允许嵌套???+ example "例子"
```asm y db 2 dup('A', 3 dup('B'), 'C') ; 等价于 y db 'A', 'B', 'B', 'B', 'C', 'A', 'B', 'B', 'B', 'C' ```
数据宽度
以下情况,指令中的数据宽度是确定的:
- 指令中的变量有变量名时(因为变量定义时已指明宽度),比如
mov s[1], 0 - 指令中的另一个操作数有明确宽度时(寄存器的宽度是已知的),比如
mov ds:[bx], ax
若指令中有多个操作数,且操作数的宽度都是未知的,则需要使用宽度修饰词来指明变量 / 内存单元(不能修饰常数)的宽度,有以下几种修饰词:
byte ptr:宽度为8位(1字节)word ptr:宽度为16位(1字)dword ptr:宽度为32位(1双字)fword ptr:宽度为48位(一般用于32位系统的远指针)
栈
结合段寄存器
ss和偏移地址寄存器sp,ss:sp指向栈顶元素- 在空栈中,
ss:sp指向栈空间最高地址单元的下一个单元
  - 在空栈中,
具体操作(
op的宽度为字(2字节)或双字(4字节)):- 入栈:
push op(ss:sp减小) - 出栈:
pop op(ss:sp增大)
  - 可以结合这两条指令实现
mov指令或xchg指令的等价操作 - 这两个操作本质上只是修改了寄存器
sp,因此栈顶变化范围为0-FFFFH,栈的容量最大为64KB
- 入栈:
栈顶越界:对空栈执行
pop操作,或对满栈执行push操作时发生- 8086CPU 不会处理越界问题,因此在编程时需要我们格外注意
堆栈本质上是一块内存空间,但有着特殊的访问方式
应用:暂存数据,比如多次使用
loop指令,编写递归函数等,这些情况下可能不得不多次使用同一个寄存器,但又希望保留其原来的值
端口
我们在基础部分提到过CPU不会直接访问外部设备,而是通过接口卡进行间接访问的。而CPU会把外设的寄存器视为端口,对它们进行统一编址,从而建立一个统一的端口地址空间,每个端口在这个地址空间中有一个地址。
在8086PC中,CPU最多可以访问64KB,即65536个不同的端口,对应的端口地址仅有16位偏移地址,无段地址,取值范围为[0000h, 0FFFFh]。
端口访问相关的指令:
in reg, port_num:从port_num号端口读取数据到regout port_num, reg:向port_num号端口写入位于reg内的数据
其中reg和port_num具体指:
- 访问8位端口:
reg表示寄存器al,port_num表示8位立即数 - 访问16位端口:
reg表示寄存器ax,port_num表示dx
常用的端口号:
70h和71h:分别是CMOS RAM的地址端口和数据端口,因此访问CMOS RAM时需要同时处理这两个端口的信号60h:键盘输入