Jun's Blog

汇编语言之实模式

· Jun

基础知识

在进入主题前,先总结下关于二进制的一些知识点,我个人认为这非常的令人困惑:

  • 1 bit, 指1位,0或者1
  • 1 byte, 指1个字节,有8位
  • 1 word, 指1个字,有2个字节,16位
  • 1 dword, 指1个双字,有4个字节,32位
  • 1 qword, 指1个四字,有8个字节,64位

8086 处理器

8086是 Intel 公司的第一款16位处理器,诞生于1978年。(对没错,下面的知识源于上个世纪70年代)

通用寄存器

8086处理器内部有8个16位通用寄存器,如下:

  • AX
  • BX
  • CX
  • DX
  • SI
  • DI
  • BP
  • SP

其中,AX, BX, CX, DX 又可以各自拆成2个8位的寄存器来使用:

1
2
3
4
5
15  8 7   0
| AH | AL | -> AX
| BH | BL | -> BX
| CH | CL | -> CX
| DH | DL | -> DX

内存对于软件来说可以认为是一个由0和1构成的线性的表,而处理器简单来说会从内存中读取内容并执行指令。这就会造成一个问题,也就是程序与数据的二义性,或者说在没有上下文的情况下给你一段内存中的内容,你不知道这到底是数据还是指令。

我们可以将数据和指令分开各自存放在一块连续的区域,这样处理器只要知道指令区块的起始地址,便可以一直执行下去。而如果需要拿到某个具体的数据或者跳转到一个特定的指令,只需要给出其“段起始地址:段偏移量”即可。

8086内部有4个段寄存器,分别为:

  • CS (Code Segment) 用来指向代码段的起始地址
  • DS (Data Segment) 用来指向数据段的起始地址
  • ES (Extra Segment) 附加段,用来指向额外的数据段,在大程序中可能会有用
  • SS (Stack Segment) 用来指向栈段的起始地址

栈段

栈是一个具有后进先出(LIFO)的数据结构,只能从一端进行入栈(push)和出栈(pop)操作,具体细节不赘述。首先有一点要明确的是,栈段和其他的段本质上没有区别,栈只是人为创造的概念,而下面的 pushpop 指令也只是处理器为了配合这一概念所作出的支持,你仍然可以对栈段中的任意位置进行访问,这两个指令可以看作是处理器操作栈的 shortcuts。

定义栈需要初始化栈寄存器 SS 和栈指针 SP

当我们使用 push 指令时,本质上是把操作数的内容存到了栈顶,相当于暂存了他的内容。以下指令是等价的:

1
push ax ; 将寄存器AX入栈。
1
mov cx, ax ; 将寄存器AX的内容放到CX中。

当我们使用 pop 指令时,本质上是把当前栈顶中的内容赋给了操作数。以下指令的意图是等价的:

1
pop dx ; 将寄存器DX出栈。
1
mov dx, cx ; 将寄存器CX中的内容放到DX中。

寻址

需要注意的是,8086处理器可访问最大1MB的内存,也就是 2^20 字节,但是它的段寄存器和指令指针寄存器(IP)都是16位的,也就是最大只能访问 2^16 字节。为了实现这一目标,处理器在形成物理地址时,会先将16位段寄存器的内容左移4位,然后再和16位的偏移地址相加,便形成了20位的物理地址。

寄存器寻址

操作执行时,操作的数在寄存器中

1
2
3
mov ax, cx
add bx, 0xf000
inc dx

立即寻址

操作执行时,操作的数是一个立即数

1
2
add bx, 0xf000
mov dx, label_a

内存寻址

操作执行时,操作的数是一个偏移地址

1
2
3
mov ax, [0x5c0f] ; 物理地址由DS:0x5c0f组成
add word [0x0230], 0x5000 ; 物理地址由DS:0x0230组成
xor byte [es:label_b], 0x05 ; 物理地址由ES:label_b组成

基址寻址

在指令的地址部分使用基址寄存器 BX 或者 BP 来提供偏移地址

1
2
3
mov [bx], dx
add byte [bx], 0x55
; 简而言之就是偏移量放在基址寄存器中,增加了灵活性

基址寄存器也可以使用 BP。但与上面的例子不同的是,在形成20位的物理地址时,段寄存器使用的 SS 而不是默认的 DS。这意味着它常用于访问栈中的内容。

基址寻址还允许在基址寄存器上使用一个偏移量。

1
mov dx, [bp-2]

处理器会将段寄存器(bx 时是 DS, bp 时是 SS)左移4位加上基址寄存器中的值并再加上或减去偏移量。

变址寻址

变址寻址与基址寻址类似,唯一的不同在于它使用的是变址寄存器 SI 或 DI, 而不是 基址寄存器 BX 或 BP。

基址变址寻址

基址变址寻址的操作数可以使用一个基址寄存器 (BX 或 BP), 外加一个变址寄存器 (SI 或 DI), 基本形式为:

1
2
mov ax, [bx+si]
add word [bx+di], 0x3000

可以将它类比为 C 语言中指针(基址寄存器)和偏移量(变址寄存器)的运算。

用户程序的加载与运行

现在我们知道,一个在内存中的程序,是分为很多个区块的(段),而处理器的一系列段寄存器保存了各个段的基本信息(段起始地址)。但是,程序不可能一直存在于内存中,为了执行它,我们一般要首先将它从硬盘上加载到内存中。而在刚加载完成后,我们的段寄存器是没有这个程序的相关信息的,也就是说我们分不清这一大串0和1到底那一块是代码那一块是数据,也不知道从哪里开始执行。为此,我们可以约定一个程序的前N字节为程序头,而其中就包含了我们所需要的基本信息。一个示范的用户头如下:

1
2
3
4
1. 程序总长度
2. 程序入口点,即第一条指令的段地址和偏移量
3. 段重定位表项数
4. 段重定位表

注意这并不一定就真实世界用户程序的对应格式,只是为了讲解!

外部设备的访问

处理器通过总线与各种 IO 设备交流。总线 (Bus) 可以认为是一排电线,所有的外围设备如键盘,显示器等包括处理器都连在总线上,而输入输出控制设备集中器( I/O Control Hub)则负责管理总线,决定哪个外围设备与处理器进行通信。

外围设备和处理器之间的通信是通过相应的 I/O 接口进行的。具体地说,处理器是通过端口(port)来和外围设备打交道的。本质上,端口就是一些寄存器,不过位于 I/O 接口电路中。

端口在不同的计算机系统中有不同的实现方式。在一些计算机系统中,端口号被映射到内存地址空间中。比如,0x00000 ~ 0xe0000 为真实内存地址,而 0xe0001 - 0xfffff 为 I/O 端口。而在另一些计算机系统中,端口是独立编址的,同时通过一个引脚来控制当前访问的是真实内存地址还是 I/O 端口。

下面通过独立编址,以个人计算机 PATA/SATA 接口为例,简要说明访问 I/O 端口的过程:

PATA/SATA 接口用于访问硬盘,有好几个端口,分别为命令端口,状态端口,参数端口和数据端口。而 ICH 芯片(一般意义上的南桥)通常集成了两个接口,分别为主硬盘和副硬盘。主硬盘分配的端口号是0x1f0~0x1f7,副硬盘分配的端口号是0x170~0x177。

读数据:

1
2
in al, dx
in ax, dx

in 指令的目的操作数必须是寄存器 AL 或者为 AX,当访问8位端口时,使用寄存器 AL, 访问16位端口时使用 AX。注意我们只能访问0~255号端口,下面指令时无效的!

1
in ax, 0x5fd

写数据:

1
2
3
4
out 0x37, al ; 写0x37端口(8位端口)
out 0xf5, ax ; 写0x37端口(16位端口)
out dx, al ; 写端口,具体数值在 dx 寄存器中(8位端口)
out dx, ax ; 写端口,具体数值在 dx 寄存器中(16位端口)

out 指令的要求与 in 类似,不在赘述。

中断

以通俗的话解释中断,就是在一些事件被触发后,处理器暂停当前的任务,并跳转到对应事件的处理程序中。当处理完毕时,再跳转回来之前的状态继续执行。

这些事件具体有哪些我们不做过多讨论,我们主要关注下处理器是如何处理这些中断的,因为某些情况下我们可能会想要复写这些中断处理程序。

实模式下的中断向量表 (Interrupt Vector Table)

处理器可以识别256个中断,理论上就有256段处理程序。我们在物理地址起始处,即 0x00000 处开始的1KB空间内设置一个中断向量表,表有256项,每一项占2个字,即4个字节,刚好1KB。表的索引即为对应的中断号,而表项中的内容即为对应中断处理程序的实际地址。这样,当发生对应中断时,处理器就可以通过中断向量表找到对应中断处理程序的实际地址,并跳转过去执行。

中断发生的过程

  1. 保护中断的现场。首先将标志寄存器压栈,并清除它的 IF 位和 TF 位。然后将代码寄存器 CS 和指令指针寄存器 IP 压栈。
  2. 执行中断处理程序。将终端号乘以4就得到了中断处理程序在表中的偏移地址。将中断程序的偏移地址和短地址分别传送给 IP 和 CS, 开始处理中断。
  3. 返回中断点继续执行。当执行到中断处理程序的最后一条指令 iret 时,处理器从栈中弹出 IP, CS 和 标志寄存器原本的内容,自此恢复执行。