x86汇编语言-从实模式到保护模式笔记实模式

编写主引导扇区代码

Posted by BINBIN Blog on October 21, 2019

主引导扇区

在前面的预备知识里,我们已经知道,处理器加电或者复位之后,如果硬盘是首选的启动设备,那么, ROM-BIOS 将试图读取硬盘的 0 面 0 道 1 扇区。传统上,这就是主引导扇区(Main Boot Sector, MBR)。 读取的主引导扇区数据有 512 字节, ROM-BIOS 程序将它加载到逻辑地址 0x0000:0x7c00 处,也就是物理地址 0x07c00 处,然后判断它是否有效。 一个有效的主引导扇区,其最后两字节应当是 0x55 和 0xAA。 ROM-BIOS 程序首先检测这两个标志,如果主引导扇区有效, 则以一个段间转移指令 jmp 0x0000:0x7c00 跳到那里继续执行。 一般来说,主引导扇区是由操作系统负责的。正常情况下,一段精心编写的主引导扇区代码将检测用来启动计算机的操作系统,并计算出它所在的硬盘位置。然后,它把操作系统的自举代码加载到内存,也用 jmp 指令跳转到那里继续执行,直到操作系统完全启动。 在本章中,我们将试图写一段程序,把它编译之后写入硬盘的主引导扇区,然后让处理器执行。 当然,仅仅执行还不够,还必须在屏幕上显示点什么,要不然的话,谁知道我们的程序是不是成功运行了呢? 通过本章的学习,我们可以对处理器如何执行指令、如何访问内存以及如何进行算术逻辑运算有一个最基本的认知。 注释必须以分号“;”开始!

在屏幕上显示文字

本程序首先要做的事是在屏幕上显示一行文字。当然,要想在屏幕上显示文字,就需要先了解文字是如何显示在屏幕上的。 为了显示文字,通常需要两种硬件,一是显示器,二是显卡。显卡的职责是为显示器提供内容,并控制显示器的显示模式和状态,显示器的职责是将那些内容以视觉可见的方式呈现在屏幕上。 显卡控制显示器的最小单位是像素,一个像素对应着屏幕上的一个点。屏幕上通常有数十万乃至更多的像素,通过控制每个像素的明暗和颜色,我们就能让这大量的像素形成文字和美丽的图像。

那么如何来控制这些像素呢? 答案是显卡都有自己的存储器,因为它位于显卡上,故称显示存储器(Video RAM: VRAM),简称显存,要显示的内容都预先写入显存。和其他半导体存储器一样,显存并没有什么特殊的地方,也是一个按字节访问的存储器件。

对显示器来说,显示黑白图像是最简单的,因为只需要控制每个像素是亮,还是不亮。如果把不亮当成比特“0”,亮看成比特“1”,那就好办了。因为,只要将显存里的每个比特和显示器上的每个像素对应起来,就能实现这个目标。 如图 5-1 所示,显存的第 1 个字节对应着屏幕左上角连续的 8 个像素;第 2 个字节对应着屏幕上后续的 8 个像素, 后面的依次类推。

显卡的工作是周期性地从显存中提取这些比特,并把它们按顺序显示在屏幕上。如果是比特“0”,则像素保持原来的状态不变,因为屏幕本来就是黑的;如果是比特“1”,则点亮对应的像素。

继续观察图 5-1,假设显存中,第 1 个字节的内容是 11110000,第 2 个字节的内容是 11111111,其他所有的字节都是 00000000。在这种情况下,屏幕左上角先是显示 4 个亮点,再显示 4 个黑点,然后再显示 8 个亮点。因为像素是紧挨在一起的,所以我们看到的先是一条白短线,隔着一定距离(4 个像素)又是一条白长线。

黑色和白色只需要 1 个比特就能表示,但要显示更多的颜色, 1 个比特就不够了。现在最流行的,是用 24 个比特,即 3 个字节,来对应一个像素。因为 224=16777216,所以在这种模式下,同屏可以显示 16777216 种颜色,这称为真彩色。

工程师们想出了一个办法。就像一个二进制数既可以是一个普通的数,也可以代表一条处理器指令一样,他们认为每个字符也可以表示成一个数。比如,数字 0x4C 就代表字符“L”,这个数被称为是字符“L”的 ASCII 代码,后面会讲到。 如图 5-2 所示,可以将字符的代码存放到显存里,第 1 个代码对应着屏幕左上角第 1 个字符,第 2 个代码对应着屏幕左上角第 2 个字符,后面的依次类推。剩下的工作是如何用代码来控制屏幕上的像素,使它们或明或暗以构成字符的轮廓,这是字符发生器和控制电路的事情。 传统上,这种专门用于显示字符的工作方式称为文本模式。文本模式和图形模式是显卡的两种基本工作模式,可以用指令访问显卡,设置它的显示模式。在不同的工作模式下,显卡对显存内容的解释是不同的!

处理器为了直接访问显存,将显存映射到处理器的寻址空间中。如下图:

我们知道8086可以访问1M的内存空间。其中0x00000-0x9FFFF属于常规内存,由内存条提供。0xF0000-0xFFFFF由主板上的ROM-BIOS提供。

中间还剩余的320KB的空洞,即0xA0000-0xEFFFF,这段空间就由外设来提供,其中就包括显卡的显存部分。

由于历史原因,一直以来0xB8000-0xBFFFF这段物理地址空间,是留给显卡的。

分析主引导扇区代码

如下是主引导代码: 代码后面有详细分析:

1          ;代码清单5-1 
  2          ;文件名:c05_mbr.asm
  3          ;文件说明:硬盘主引导扇区代码
  4          ;创建日期:2011-3-31 21:15 
  5  
  6          mov ax,0xb800                 ;指向文本模式的显示缓冲区,显存的段地址,
  7          mov es,ax                                      ;一般用DS段寄存器,但是DS有其他用处,这里我们使用ES寄存器
  8 
  9          ;以下是显示字符串"Label offset:"
 10          mov byte [es:0x00],'L'
 11          mov byte [es:0x01],0x07
 12          mov byte [es:0x02],'a'
 13          mov byte [es:0x03],0x07
 14          mov byte [es:0x04],'b'
 15          mov byte [es:0x05],0x07
 16          mov byte [es:0x06],'e'
 17          mov byte [es:0x07],0x07
 18          mov byte [es:0x08],'l'
 19          mov byte [es:0x09],0x07
 20          mov byte [es:0x0a],' '
 21          mov byte [es:0x0b],0x07
 22          mov byte [es:0x0c],"o"
 23          mov byte [es:0x0d],0x07
 24          mov byte [es:0x0e],'f'
 25          mov byte [es:0x0f],0x07
 26          mov byte [es:0x10],'f'
 27          mov byte [es:0x11],0x07
 28          mov byte [es:0x12],'s'
 29          mov byte [es:0x13],0x07
 30          mov byte [es:0x14],'e'
 31          mov byte [es:0x15],0x07
 32          mov byte [es:0x16],'t'
 33          mov byte [es:0x17],0x07
 34          mov byte [es:0x18],':'
 35          mov byte [es:0x19],0x07
 36 
 37          mov ax,number                 ;取得标号number的偏移地址
 38          mov bx,10
 39 
 40          ;设置数据段的基地址,只是在同一个段,偏移地址是不一样的
 41          mov cx,cs
 42          mov ds,cx
 43 
 44          ;求个位上的数字
 45          mov dx,0
 46          div bx
 47          mov [0x7c00+number+0x00],dl   ;保存个位上的数字
 48 
 49          ;求十位上的数字
 50          xor dx,dx
 51          div bx
 52          mov [0x7c00+number+0x01],dl   ;保存十位上的数字
 53 
 54          ;求百位上的数字
 55          xor dx,dx
 56          div bx
 57          mov [0x7c00+number+0x02],dl   ;保存百位上的数字
 58 
 59          ;求千位上的数字
 60          xor dx,dx
 61          div bx
 62          mov [0x7c00+number+0x03],dl   ;保存千位上的数字
 63 
 64          ;求万位上的数字 
 65          xor dx,dx
 66          div bx
 67          mov [0x7c00+number+0x04],dl   ;保存万位上的数字
 68 
 69          ;以下用十进制显示标号的偏移地址
 70          mov al,[0x7c00+number+0x04]
 71          add al,0x30
 72          mov [es:0x1a],al              ;将al寄存器中的ASCII数字传送到显示缓冲区
 73          mov byte [es:0x1b],0x04             ;下一字节存放显示属性,0x04代表:黑底红字,无闪烁,无加亮
 74 
 75          mov al,[0x7c00+number+0x03]
 76          add al,0x30
 77          mov [es:0x1c],al
 78          mov byte [es:0x1d],0x04
 79 
 80          mov al,[0x7c00+number+0x02]
 81          add al,0x30
 82          mov [es:0x1e],al
 83          mov byte [es:0x1f],0x04
 84 
 85          mov al,[0x7c00+number+0x01]
 86          add al,0x30
 87          mov [es:0x20],al
 88          mov byte [es:0x21],0x04
 89 
 90          mov al,[0x7c00+number+0x00]
 91          add al,0x30
 92          mov [es:0x22],al
 93          mov byte [es:0x23],0x04
 94 
 95          mov byte [es:0x24],'D'
 96          mov byte [es:0x25],0x07
 97 
 98    infi: jmp near infi                 ;无限循环,防止处理器再接着取下面的数据,数据当成指令取执行会导致错误或运行不正常
 99 
100    number db 0,0,0,0,0
101 
102    times 203 db 0
103             db 0x55,0xaa


  1. 首先我们是要在屏幕上显示字符串,所以需要将需要显示的字符串的字符传送到显存中!
  2. 从第六行开始分析,我们得知显存的寻址地址是0xB8000处,所以段地址是0xb800,在这里我们使用ES寄存器作为显存段基址(也可以用DS,但是DS用作其他用处!)
  3. 10行-35行:显示字符串”Label offset:” 后面0x07是字符属性! 包括字符的颜色和背景色! 10行-35行依次将字符写入到缓存中,后面依次写入字符的属性。这很好理解!!!

在8086下,80x25文本模式下的颜色表如下:

由以上可知,我们显示的字符属性是0x07,黑底白字,无闪烁,无加亮。也就是黑底白字。

  1. 37行:取得标号number的汇编地址。本代码不光想在屏幕上显示字符串Label offset:,还想将number的汇编地址显示出来。number是一个标号,标号是它所在的地方的汇编地址(其实你可以理解C 语言里面的定义全局变量的地址!)。什么是汇编地址? 实际上一个程序经过编译后,编译器会给每一条代码一个汇编地址,这个汇编地址实际上是从0开始。

在分段机制中,偏移地址也是从0开始。实际上,这个汇编地址就是与偏移地址是对应的。你可以从“.lst”为扩展名的列表文件看出汇编地址,如下图:

;代码清单 5-1
2 ;文件名: c05_mbr.asm
3 ;文件说明:硬盘主引导扇区代码
4 ;创建日期: 2011-3-31 21:15
5
6 00000000 B800B8 mov ax,0xb800 ;指向文本模式的显示缓冲区
7 00000003 8EC0 mov es,ax
8 
9                               ;以下显示字符串"Label offset:"
10 00000005 26C60600004C mov byte [es:0x00],'L'
11 0000000B 26C606010007 mov byte [es:0x01],0x07
12 00000011 26C606020061 mov byte [es:0x02],'a'
13 00000017 26C606030007 mov byte [es:0x03],0x07
14 0000001D 26C606040062 mov byte [es:0x04],'b'
15 00000023 26C606050007 mov byte [es:0x05],0x07
16 00000029 26C606060065 mov byte [es:0x06],'e'
17 0000002F 26C606070007 mov byte [es:0x07],0x07
18 00000035 26C60608006C mov byte [es:0x08],'l'
19 0000003B 26C606090007 mov byte [es:0x09],0x07
20 00000041 26C6060A0020 mov byte [es:0x0a],' '
21 00000047 26C6060B0007 mov byte [es:0x0b],0x07
22 0000004D 26C6060C006F mov byte [es:0x0c],"o"
23 00000053 26C6060D0007 mov byte [es:0x0d],0x07
24 00000059 26C6060E0066 mov byte [es:0x0e],'f'
25 0000005F 26C6060F0007 mov byte [es:0x0f],0x07
26 00000065 26C606100066 mov byte [es:0x10],'f'
27 0000006B 26C606110007 mov byte [es:0x11],0x07
28 00000071 26C606120073 mov byte [es:0x12],'s'
29 00000077 26C606130007 mov byte [es:0x13],0x07
30 0000007D 26C606140065 mov byte [es:0x14],'e'
31 00000083 26C606150007 mov byte [es:0x15],0x07
32 00000089 26C606160074 mov byte [es:0x16],'t'
33 0000008F 26C606170007 mov byte [es:0x17],0x07
34 00000095 26C60618003A mov byte [es:0x18],':'
35 0000009B 26C606190007 mov byte [es:0x19],0x07
36
37 000000A1 B8[2E01] mov ax,number ;取得标号 number 的偏移地址
38 000000A4 BB0A00 mov bx,10
39
40 ;设置数据段的基地址
41 000000A7 8CC9 mov cx,cs
42 000000A9 8ED9 mov ds,cx
43
44 ;求个位上的数字
45 000000AB BA0000 mov dx,0
46 000000AE F7F3 div bx
47 000000B0 8816[2E7D] mov [0x7c00+number+0x00],dl ;保存个位上的数字
48
49 ;求十位上的数字
50 000000B4 31D2 xor dx,dx
51 000000B6 F7F3 div bx
52 000000B8 8816[2F7D] mov [0x7c00+number+0x01],dl ;保存十位上的数字
53
54 ;求百位上的数字
55 000000BC 31D2 xor dx,dx
56 000000BE F7F3 div bx
57 000000C0 8816[307D] mov [0x7c00+number+0x02],dl ;保存百位上的数字
58
59 ;求千位上的数字
60 000000C4 31D2 xor dx,dx
61 000000C6 F7F3 div bx
62 000000C8 8816[317D] mov [0x7c00+number+0x03],dl ;保存千位上的数字
63
64 ;求万位上的数字
65 000000CC 31D2 xor dx,dx
66 000000CE F7F3 div bx
67 000000D0 8816[327D] mov [0x7c00+number+0x04],dl ;保存万位上的数字
68
69 ;以下用十进制显示标号的偏移地址
70 000000D4 A0[327D] mov al,[0x7c00+number+0x04]
71 000000D7 0430 add al,0x30
72 000000D9 26A21A00 mov [es:0x1a],al
73 000000DD 26C6061B0004 mov byte [es:0x1b],0x04
74
75 000000E3 A0[317D] mov al,[0x7c00+number+0x03]
76 000000E6 0430 add al,0x30
77 000000E8 26A21C00 mov [es:0x1c],al
78 000000EC 26C6061D0004 mov byte [es:0x1d],0x04
79
80 000000F2 A0[307D] mov al,[0x7c00+number+0x02]
81 000000F5 0430 add al,0x30
82 000000F7 26A21E00 mov [es:0x1e],al
83 000000FB 26C6061F0004 mov byte [es:0x1f],0x04
84
85 00000101 A0[2F7D] mov al,[0x7c00+number+0x01]
86 00000104 0430 add al,0x30
87 00000106 26A22000 mov [es:0x20],al
88 0000010A 26C606210004 mov byte [es:0x21],0x04
89
90 00000110 A0[2E7D] mov al,[0x7c00+number+0x00]
91 00000113 0430 add al,0x30
92 00000115 26A22200 mov [es:0x22],al
93 00000119 26C606230004 mov byte [es:0x23],0x04
94
95 0000011F 26C606240044 mov byte [es:0x24],'D'
96 00000125 26C606250007 mov byte [es:0x25],0x07
97
98 0000012B E9FDFF infi: jmp near infi ;无限循环
99
100 0000012E 0000000000 number db 0,0,0,0,0
101
102 00000133 00<rept> times 203 db 0
103 000001FE 55AA db 0x55,0xaa

从该表得知,number就代表那个地址的值。我这里已经提前知道这个地址是:0x012E也就是十进制302。所以ax的值就是302!

由之前的学习内容知道直接将302传送到显存的话,是不可能在屏幕上显示302的。我们只能将302进行拆分,将每一个数位都拆解出来,一个一个传送给显存。如何拆解?每次除以10…太简单了就不写了。

  1. 38行:将bx寄存器赋值为10 ,作为下面除以10 的时候的除数。

可以用处理器提供的除法指令来分解一个数的各个数位,但是每次除法操作后得到的数位需要临时保存起来以备后用。使用寄存器不太现实,因为它的数量很少,且还要在后续的指令中使用。因此,最好的办法是在内存中专门留出一些空间来保存这些数位。尽管我们的目的仅仅是分配一些空间,但是,要达到这个目的必须初始化一些初始数据来“占位”。这就好比是排队买火车票,你可以派任何无关的人去帮你占个位置,真正轮到你买的时候,你再出现。源程序的第 100 行用于声明并初始化这些数据,而标号 number 则代表了这些数据的起始汇编地址。

要放在程序中的数据是用 DB 指令来声明(Declare)的, DB 的意思是声明字节(Declare Byte),所以,跟在它后面的操作数都占一个字节的长度(位置)。注意,如果要声明超过一个以上的数据,各个操作数之间必须以逗号隔开。

除此之外, DW(Declare Word)用于声明字数据, DD(Declare Double Word)用于声明双字(两个字)数据, DQ(Declare Quad Word)用于声明四字数据。 DB、 DW、 DD 和 DQ 并不是处理器指令,它只是编译器提供的汇编指令,所以称做伪指令(pseudo Instruction)。 在第100行中,我们在此声明了 5 个字节,并将它们的值都初始化 为 0。当然也可以初始化其他的数据,我们常用的都是初始化位0!

我们既然想将number的汇编地址分解为一个个数位,就得找一个地方,将分解后的数字先咱是存起来。你可以想到用寄存器先存起来,但是寄存器,毕竟就那么8个通用的寄存器,而且本段代码也用了好几个了,所以这里无法使用寄存器来暂时存我们的数据。

一个办法就是在内存找到一个地方,来存储。这里,我们的主引导扇区是512字节,我们写的代码很少不到300字节,所以我们选择在主引导扇区的最后先开辟一个空间用于存储number的分解后的数字。

  1. 41-42行:我们将DS寄存器指向代码段,就是让数据段寄存器DS与代码段寄存器CS保持一致。因为我们这里将数据与代码都放到一个段里面了,所以数据段与代码段是一个段(正常不能不放到一个段,我们初学,先这么写,后面会分段) 其实用CS来访问数据也可以,但是我们还是习惯用DS来访问数据,所以这里就有这么两句赋值代码。(可能代码里面没有提到CS,因为主引导扇区的内容被加载到内存中并开始执行时,CS=0x0000,IP=0x7C00。复习前面章节,复位的时候跳转到7c00处 )

44-67行:求numberi的各个数位的数字,然后存到我们预先开辟好的空间中。

div 指令可以做两种类型的除法!

第一种类型是用 16 位的二进制数除以 8 位的二进制数。在这种情况下,被除数必须在寄存器AX 中,必须事先传送到 AX 寄存器里。除数可以由 8 位的通用寄存器或者内存单元提供。指令执行后,商在寄存器 AL 中,余数在寄存器 AH 中。比如: div cl div byte [0x0023] 前一条指令中,寄存器 CL 用来提供 8 位的除数。假如 AX 中的内容是 0x0005, CL 中的内容是 0x02,指令执行后, CL 中的内容不变, AL 中的商是 0x02, AH 中的余数是 0x01。

后一条指令中,除数位于数据段内偏移地址为 0x0023 的内存单元里。这条指令执行时,处理器将数据段寄存器 DS 的内容左移 4 位,加上偏移地址 0x0023 以形成物理地址。然后,处理器再次访问内存,从此处取得一个字节,作为除数同寄存器 AX 做一次除法。

任何时候,只要是在指令中涉及内存地址的,都允许使用段超越前缀。比如: div byte [cs:0x0023] div byte [es:0x0023]

为了方便,我们通常用“DX:AX”来描述 32 位的被除数。 同时,除数可以由 16 位的通用寄存器或者内存单元提供,指令执行后,商在 AX 中,余数在DX 中。比如下面的指令: div cx div word [0x0230]

源程序第 46 行, div 指令用 DX:AX 作为被除数,除以 BX 的内容,执行后得到的商在 AX 中,余数在 DX 中。因为除数是 10,余数自然比 10 小,我们可以从 DL 中取得。

70-93行:先将各个数位转化成十进制显示,然后送入到显存,在每一个字符后面写入显示属性0x04,代表黑底红字。

95-96行:显示字符D 以代表我们前面显示的number地址是10进制显示的。黑底白字。

98行:无限循环,防止处理器再接着取下面的数据,数据当成指令取与执行会导致错误或运行不正常

102行:由于主引导扇区是512字节的,我们写的程序并没有达到512字节。所以我们应该将主引导扇区未满的地方填满。我们这里采取了一些特殊手段得知有203字节未填满,搜易我们了连续声明203个字节用于存储0. 至于使用了什么特殊手段,不必要知道,因为后面的学习中会学习使用正常的的手段来得知这个未填满的字节有多少。

103行:一个有效的主引导扇区,它的最后必须是0x55和0xaa。

编译主引导扇区代码并加载运行

我们已经安装了VirtualBox 虚拟机软件,并在里面创建了一台名为LEARNASM的虚拟计算机。除此之外,还为它创建了一块虚拟硬盘。

然后我们参考书上4.2.4节的内容,将我们汇编代码编译好的二进制bin文件写到虚拟硬盘的主引导扇区中。启动虚拟机,就会运行我们写的代码,运行结果如下:

###本 章 习 题 1. 试找出以下程序片断中隐藏的问题并进行修正: mov ax,21015 mov bl,10 div bl and cl,0xf0

答案:cl未定义数据!

2.本章的程序在内存中的加载地址是 0x0000:0x7C00, 此时,指令 jmp near infi 在段内的偏移地址是多少? 试修改本章的源程序以显示该值。 可得之偏移地址是0x12B,按照如上代码第37 行,直接赋值mov ax 0x12b

3. 汇编语言编译器采用助记符来方便指令的书写和阅读。比如, mov 是传送指令, div 是除法 指令。假如 Intel 公司新推出一款处理器,该处理器新增了一条指令,其机器码为 CD 88。因为是新指令,你的 NASM 编译器肯定没有一个助记符与之相对应。在这种情况下,如何在你的程序中使用该指令?

可以直接用 db 语句吧,如在需要那个指令的行上直接写成 db 0cdh, 88h, … 用的多的话,可以将其写成一个宏定义 !因为没有jmp near infi ,程序还会继续执行,程序读取到CD 88 当做机器码直接执行!