首页资源分类嵌入式开发嵌入式系统 > x86汇编语言-从实模式到保护模式

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

已有 456763个资源

下载专区

上传者其他资源

文档信息举报收藏

标    签: x86汇编语言

分    享:

文档简介

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

文档预览

本书出版时间:2012 年 10 月底 11 月初。任何疑问,请 到新浪微博@均陵鼠侠,或者加群 92033881。 x86 汇编语言: 从实模式到保护模式 李 忠 王晓波 余 洁 著 Publishing House of Electronics Industry 北京·BEIJING x86 汇编语言:从实模式到保护模式 内容简介 每一种处理器都有它自己的机器指令集,而汇编语言的发明则是为了方便这些机器指令的记忆和书写。 尽管汇编语言已经较少用于大型软件程序的开发,但从学习者的角度来看,要想真正理解计算机的工作原理, 掌握它内部的运行机制,学习汇编语言是必不可少的。 这套图书分为两册,采用开源的 NASM 汇编语言编译器和 VirtualBox 虚拟机软件,以个人计算机广泛采 用的 Intel 处理器为基础,详细讲解了 Intel 处理器的指令系统和工作模式,以大量的代码演示了 16/32/64 位软件的开发方法。上册集中介绍处理器的 16 位实模式和 32 位保护模式,以及基本的指令系统;下册侧重于 介绍 64 位工作模式、多处理器管理、高速缓存控制、温度和电源管理、高级可编程中断控制器、多媒体支持 等。 这是一本有趣的书,它没有把篇幅花在计算一些枯燥的数学题上。相反,它教你如何直接控制硬件,在 不借助于 BIOS、DOS、Windows、Linux 或者任何其他软件支持的情况下来显示字符、读取硬盘数据、控制其 他硬件等。本书可作为大专院校相关专业学生和计算机编程爱好者的教程。 未经许可,不得以任何方式复制或抄袭本书之部分或全部内容。 版权所有,侵权必究。 图书在版编目(CIP)数据 /主编. —北京: 电子工业出版社,2012.9 ISBN 978-7-121-0-0 Ⅰ. ①汇… Ⅱ. ①… Ⅲ. ① Ⅳ. ① 中国版本图书馆 CIP 数据核字(2012)第 号 责任编辑:董亚峰 印 刷: 装 订: 出版发行:电子工业出版社 北京市海淀区万寿路 173 信箱 邮编 100036 开 本:787×1 092 1/16 印张: 字数: 千字 印 次:2012 年 9 月第 1 次印刷 定 价:00.00 元 凡所购买电子工业出版社图书有缺损问题,请向购买书店调换。若书店售缺,请与本社发行部联系,联系 及邮购电话:(010)88254888。 质量投诉请发邮件至 zlts@phei.com.cn,盗版侵权举报请发邮件至 dbqq@phei.com.cn。 服务热线:(010)88258888。 4 第 1 章 十六进制计数法 前言 尽管汇编语言也是一种计算机语言,但却是与众不同的,与它的同类们格格不入。一方面, 处理器的工作是执行指令,用它所做的一切都是执行指令并获得结果;另一方面,汇编语言为 每一种指令提供了简单好记、易于书写的符号化表示形式。 一直以来,人们对于汇编语言的认识和评价可以分为两种,一种是觉得它非常简单,另一 种是觉得它学习起来非常困难。 你认为我会赞同哪一种?说汇编语言难学,这没有道理。学习任何一门计算机语言,都需 要一些数制和数制转换的知识,也需要大体上懂得计算机是怎么运作的。在这个前提下,汇编 语言是最贴近硬件实体的,也是最自然和最朴素的。最朴素的东西反而最难掌握,这实在说不 过去。因此,原因很可能出在我们的教科书上,那些一上来就搞一大堆寻址方式的书,往往以 最快的速度打败了本来激情高昂的初学者。 但是,说汇编语言好学,也同样有些荒谬。据我的观察,很多人掌握了若干计算机指令, 会编写一个从键盘输入数据,然后进行加减乘除或者归类排序的程序后,就认为自己掌握了汇 编语言。还有,直到现在,我还经常在网上看到学生们使用 DOS 中断编写程序,他们讨论的 也大多是实模式,而非 32 位或者 64 位保护模式。他们知道如何编译源程序,也知道在命令行 输入文件名,程序就能运行了,使用一个中断,就能显示字符。至于这期间发生了什么,程序 是如何加载到内存中的,又是怎么重定位的,似乎从来不关汇编语言的事。这样做的结果,就 是让人以为汇编语言不过如此,没有大用,而且非常枯燥。 很难说我已经掌握了汇编语言的要义。但至少我知道,尽管汇编语言不适合用来编写大型 程序,但它对于理解计算机原理很有帮助,特别是处理器的工作原理和运行机制。就算是为了 这个目的,也应该让汇编语言回归它的本位,那就是访问和控制硬件(包括处理器),而不仅 仅是编写程序,输入几个数字,找出正数有几个、负数有几个,大于 30 的有几个。 事实上,汇编语言对学习和理解高级语言,比如 C 语言,也有极大的帮助。老教授琢磨了 好几天,终于想到一个好的比喻来帮助学生理解什么是指针,实际上,这对于懂得汇编语言的 学生来说,根本就不算个事儿,并因此能够使老教授省下时间来喝茶。 对于一个国家来说,不能没有人来研究基础学科,尽管它们不能直接产生效益;而对于一 个人来说,也不能没有常识。尽管常识不能直接挣钱吃饭,但它影响谈吐,影响你的判断力和 决断力,决定着你接受新事物和新知识的程度。相应地,汇编语言就是计算机语言里的常识和 基础。 这是继《穿越计算机的迷雾》之后,我写的第二本书。这本书与上本书有两点不同,第一, 上一本花了 4 年才完成,而这本只用了一年,速度之快,令我自己咂舌;第二,上本书属于科 普性质,漫谈计算机原理,这本书就相对专业了。那些还想把我的书当小说看的人,这回要失 望了。 很多人可能会问我,为什么要写这样一本书。我只能说,我第一次学汇编的经历实在是太 深刻了。我第一次学汇编语言是在 1993 年,手中的教材不能说不好,但学习起来实在很吃力。 要知道,在那个年代,没有网络,要买到好书,还得到大武汉。就这样,我抱着两本书,反反 复复地看,直到半年之后才懂得汇编语言是个什么东西。后来,虽然有心写一本汇编语言的书, 一本不一样的汇编语言书,但始终没有时间和精力。 时间过得真快,转眼 20 年过去了。猛回头,我发现同学们依然在走我的老路,他们所用 5 x86 汇编语言:从实模式到保护模式 的教材,都还是我那个年代的,至少区别不大,都还在讲 8086 处理器的实模式。保护模式是 从哪个处理器开始引入的?当然是 80286。它是哪个年代的产品?1982 年!可是,直到现在, 市面上也找不到太多能够把保护模式讲得比较清楚的图书。 也许我应该做点什么。不,事实上,我已经做了,那就是你手中的这本图书。王晓波和湖 北经济学院的余洁共同参与了本书的创作。 在计划写这本书的时候,我就给自己画了几条线。首先不能走老路,一上来就讲指令、寻 址方式,采用任务驱动方式来写,每一章都要做点事情,最好是比较有趣,足够引起读者的事 情。在解决问题的过程中,引入一个个的指令,并进行讲解。一句话,我希望是润物细无声式 的。 其次,汇编语言和硬件并举,完全抛弃 BIOS 中断和 DOS 中断,直接访问硬件,发挥汇 编语言的长处。这样,读者才会深刻体会到汇编的妙处。 这套图书主要讲述 16 位实模式、32 位保护模式和 Intel-64 架构。引入虚拟 8086 模式是为 了兼容传统的 8086 程序,现在看来已经完全过时,不再进行讲述。至于增强的 32 位模式 IA-32e, 读者可以在读完这本书之后自学,也予以省略。 书中配套的程序清单和源代码以及可能用到的程序软件,感兴趣的读者可到电子工业出版 社华信教育资源网下载(待定)。 本书原来有 18 章,后来,考虑到实模式的内容过多,而去掉了一章。这一章的标题是《聆 听数字的声音》,讲述如何通过直接访问和控制 Sound Blaster 16 声卡来播放声音,对此感兴趣 的朋友可以在配书光盘中找到它。 特别感谢长春电视台的王志强台长和台长助理周武军,上本书《穿越计算机的迷雾》出版 后,台长王志强亲自过问出版情况,并给予我特别的奖励,希望大家同样能从这本书中读到他 们对我的关怀和鼓励;同时也要感谢我的母亲、我爱人和我的女儿,她们是我的精神支柱。好 友王南洋、桑国伟、刘维钊、蒋胜友、邱海龙、万利等负责了本书的一部分校对工作;好友周 卫平帮我验证配书代码是否能够在他的机器上正常工作;如果想调试本书中的程序,可以使用 bochs 软件,它的视频教程是由王南洋制作的,在这里向他们表示感谢。在阅读本书的过程中, 如果有任何问题,可以按以下电子邮件地址给我写信:leechung@126.com;或者进入我的博客 参与讨论。博客地址是 http://blog.163.com/leechung@126 6 第 1 章 十六进制计数法 目录 第 1 部分 预备知识 第 1 章 十六进制计数法 ···························································································3 1.1 二进制计数法回顾··················································································3 1.1.1 关于二进制计数法 ···········································································3 1.1.2 二进制到十进制的转换 ·····································································3 1.1.3 十进制到二进制的转换 ·····································································4 1.2 十六进制计数法·····················································································4 1.2.1 十六进制计数法的原理 ·····································································4 1.2.2 十六进制到十进制的转换 ··································································5 1.2.3 十进制到十六进制的转换 ··································································6 1.3 为什么需要十六进制···············································································6 本章习题 ····································································································7 第 2 章 处理器、内存和指令 ····················································································8 2.1 最早的处理器························································································8 2.2 寄存器和算术逻辑部件············································································8 2.3 内存储器····························································································10 2.4 指令和指令集······················································································11 2.5 古老的 Intel 8086 处理器 ········································································13 2.5.1 8086 的通用寄存器·········································································13 2.5.2 程序的重定位难题 ·········································································14 2.5.3 内存分段机制 ···············································································17 2.5.4 8086 的内存分段机制······································································18 本章习题 ··································································································21 第 3 章 汇编语言和汇编软件 ···················································································22 3.1 汇编语言简介······················································································22 3.2 NASM 编译器 ·····················································································24 3.2.1 从网上下载 NASM 安装程序 ····························································24 3.2.2 安装 NASM 编译器 ········································································25 3.2.3 下载配书源码和工具 ······································································26 3.2.4 用 Nasmide 体验代码的书写和编译过程 ··············································28 3.2.5 用 HexView 观察编译后的机器代码····················································29 本章习题 ··································································································30 7 x86 汇编语言:从实模式到保护模式 第 4 章 虚拟机的安装和使用 ·········································································31 4.1 计算机的启动过程················································································31 4.1.1 如何将编译好的程序提交给处理器 ····················································31 4.1.2 计算机的加电和复位 ······································································31 4.1.3 基本输入输出系统 ·········································································32 4.1.4 硬盘及其工作原理 ·········································································33 4.1.5 一切从主引导扇区开始 ···································································35 4.2 创建和使用虚拟机················································································35 4.2.1 别害怕,虚拟机是软件 ···································································35 4.2.2 下载 Oracle VM VirtualBox ·······························································36 4.2.3 安装 Oracle VM VirtualBox ·······························································36 4.2.4 创建一台虚拟 PC ···········································································37 4.2.5 虚拟硬盘简介 ···············································································42 4.2.6 练习使用 FixVhdWr 工具向虚拟硬盘写数据 ·········································43 第 2 部分 16 位处理器下的实模式 第 5 章 编写主引导扇区代码 ···················································································49 5.1 欢迎来到主引导扇区·············································································49 5.2 注释 ·································································································49 5.3 在屏幕上显示文字················································································50 5.3.1 显卡和显存 ··················································································50 5.3.2 初始化段寄存器 ············································································52 5.3.3 显存的访问和 ASCII 代码 ································································53 5.3.4 显示字符 ·····················································································55 5.4 显示标号的汇编地址·············································································56 5.4.1 标号 ···························································································56 5.4.2 如何显示十进制数字 ······································································60 5.4.3 在程序中声明并初始化数据 ·····························································61 5.4.4 分解数的各个数位 ·········································································61 5.4.5 显示分解出来的各个数位 ································································65 5.5 使程序进入无限循环状态·······································································66 5.6 完成并编译主引导扇区代码····································································67 5.6.1 主引导扇区有效标志 ······································································67 5.6.2 代码的保存和编译 ·········································································68 5.7 加载和运行主引导扇区代码····································································68 5.7.1 把编译后的指令写入主引导扇区 ·······················································68 5.7.2 启动虚拟机观察运行结果 ································································70 5.7.3 程序的调试 ··················································································70 8 第 1 章 十六进制计数法 本章习题 ··································································································71 第 6 章 相同的功能,不同的代码·············································································72 6.1 代码清单 6-1 ·······················································································72 6.2 跳过非指令的数据区·············································································72 6.3 在数据声明中使用字面值·······································································72 6.4 段地址的初始化···················································································73 6.5 段之间的批量数据传送··········································································74 6.6 使用循环分解数位················································································75 6.7 计算机中的负数···················································································76 6.7.1 无符号数和有符号数 ······································································76 6.7.2 处理器视角中的数据类型 ································································80 6.8 数位的显示·························································································82 6.9 其他标志位和条件转移指令····································································83 6.9.1 奇偶标志位 PF ··············································································83 6.9.2 进位标志 CF ·················································································83 6.9.3 溢出标志 OF·················································································84 6.9.4 现有指令对标志位的影响 ································································84 6.9.5 条件转移指令 ···············································································85 6.10 NASM 编译器的$和$$标记 ···································································87 6.11 观察运行结果 ····················································································87 本章习题 ··································································································88 第 7 章 比高斯更快的计算·······················································································89 7.1 从 1 加到 100 的故事·············································································89 7.2 代码清单 7-1 ·······················································································89 7.3 显示字符串·························································································89 7.4 计算 1 到 100 的累加和··········································································90 7.5 累加和各个数位的分解与显示·································································90 7.5.1 堆栈和堆栈段的初始化 ···································································90 7.5.2 分解各个数位并压栈 ······································································92 7.5.3 出栈并显示各个数位 ······································································94 7.5.4 进一步认识堆栈 ············································································95 7.6 程序的编译和运行················································································96 7.7 8086 处理器的寻址方式 ·········································································96 7.7.1 寄存器寻址 ··················································································96 7.7.2 立即寻址 ·····················································································97 7.7.3 内存寻址 ·····················································································97 本章习题 ·································································································101 9 x86 汇编语言:从实模式到保护模式 第 8 章 硬盘和显卡的访问与控制···········································································102 8.1 本章代码清单·····················································································102 8.1.1 本章意图 ····················································································102 8.1.2 代码清单 8-1················································································103 8.2 用户程序的结构··················································································103 8.2.1 分段、段的汇编地址和段内汇编地址·················································103 8.2.2 用户程序头部 ··············································································106 8.3 加载程序(器)的工作流程···································································109 8.3.1 初始化和决定加载位置 ··································································109 8.3.2 准备加载用户程序 ········································································110 8.3.3 外围设备及其接口 ········································································111 8.3.4 I/O 端口和端口访问·······································································112 8.3.5 通过硬盘控制器端口读扇区数据 ······················································114 8.3.6 过程调用 ····················································································116 8.3.7 加载用户程序 ··············································································121 8.3.8 用户程序重定位 ···········································································122 8.3.9 将控制权交给用户程序 ··································································126 8.3.10 8086 处理器的无条件转移指令 ·······················································126 8.4 用户程序的工作流程············································································128 8.4.1 初始化段寄存器和堆栈切换 ····························································128 8.4.2 调用字符串显示例程 ·····································································129 8.4.3 过程的嵌套 ·················································································130 8.4.4 屏幕光标控制 ··············································································131 8.4.5 取当前光标位置 ···········································································131 8.4.6 处理回车和换行字符 ·····································································132 8.4.7 显示可打印字符 ···········································································133 8.4.8 滚动屏幕内容 ··············································································134 8.4.9 重置光标 ····················································································134 8.4.10 切换到另一个代码段中执行 ···························································135 8.4.11 访问另一个数据段 ·······································································135 8.5 编译和运行程序并观察结果···································································135 本章习题 ·································································································136 第 9 章 中断和动态时钟显示 ·················································································137 9.1 外部硬件中断·····················································································137 9.1.1 非屏蔽中断 ·················································································138 9.1.2 可屏蔽中断 ·················································································138 9.1.3 实模式下的中断向量表 ··································································140 9.1.4 实时时钟、CMOS RAM 和 BCD 编码 ················································141 10 第 1 章 十六进制计数法 9.1.5 代码清单 9-1················································································145 9.1.6 初始化 8259、RTC 和中断向量表 ·····················································145 9.1.7 使处理器进入低功耗状态 ·······························································147 9.1.8 实时时钟中断的处理过程 ·······························································148 9.1.9 代码清单 9-1 的编译和运行 ·····························································150 9.2 内部中断···························································································150 9.3 软中断 ·····························································································151 9.3.1 常用的 BIOS 中断 ·········································································151 9.3.2 代码清单 9-2················································································155 9.3.3 从键盘读字符并显示 ·····································································155 9.3.4 代码清单 9-2 的编译和运行 ·····························································155 本章习题 ·································································································156 第 3 部分 32 位保护模式 第 10 章 32 位 Intel 微处理器编程架构 ··································································159 10.1.2 基本的工作模式 ··········································································162 10.1.3 线性地址···················································································163 10.2 现代处理器的结构和特点 ····································································164 10.2.1 流水线······················································································164 10.2.2 高速缓存···················································································165 10.2.3 乱序执行···················································································165 10.2.4 寄存器重命名·············································································166 10.2.5 分支目标预测·············································································167 10.3 32 位模式的指令系统 ·········································································168 10.3.1 32 位处理器的寻址方式 ································································168 10.3.2 操作数大小的指令前缀 ·································································169 10.3.3 一般指令的扩展 ··········································································171 本章习题 ·································································································174 第 11 章 进入保护模式··························································································175 11.1 代码清单 11-1 ···················································································175 11.2 全局描述符表 ···················································································175 11.3 存储器的段描述符 ·············································································177 11.4 安装存储器的段描述符并加载 GDTR······················································180 11.5 关于第 21 条地址线 A20 的问题 ····························································182 11.6 保护模式下的内存访问 ·······································································184 11.7 清空流水线并串行化处理器 ·································································188 11.8 保护模式下的堆栈 ·············································································189 11.8.1 关于堆栈段描述符中的界限值 ························································189 11 x86 汇编语言:从实模式到保护模式 11.8.2 检验 32 位下的堆栈操作 ·······························································191 11.9 程序的编译和运行 ·············································································191 本章习题 ·································································································192 第 12 章 存储器的保护 ·························································································193 12.1 代码清单 12-1 ···················································································193 12.2 进入 32 位保护模式············································································193 12.2.1 话说 mov ds,ax 和 mov ds,eax··························································193 12.2.2 创建 GDT 并安装段描述符 ····························································194 12.3 修改段寄存器时的保护 ·······································································196 12.4 地址变换时的保护 ·············································································198 12.4.1 代码段执行时的保护 ····································································198 12.4.2 堆栈操作时的保护 ·······································································199 12.4.3 数据访问时的保护 ·······································································201 12.5 使用别名访问代码段对字符排序(xchg)················································202 12.6 程序的编译和运行 ·············································································204 本章习题 ·································································································204 第 13 章 程序的动态加载和执行 ············································································205 13.1 本章代码清单 ···················································································205 13.2 内核的结构、功能和加载 ····································································206 13.2.1 内核的结构················································································206 13.2.2 内核的加载················································································207 13.2.3 安装内核的段描述符 ····································································209 13.3 在内核中执行 ···················································································212 13.4 用户程序的加载和重定位 ····································································214 13.4.1 用户程序的结构 ··········································································214 13.4.2 计算用户程序占用的扇区数 ···························································216 13.4.3 简单的动态内存分配 ····································································217 13.4.4 段的重定位和描述符的创建 ···························································218 13.4.5 重定位用户程序内的符号地址 ························································221 13.5 执行用户程序 ···················································································225 13.6 代码的编译、运行和调试 ····································································227 本章习题 ·································································································228 第 14 章 任务和特权级保护···················································································229 14.1 任务的隔离和特权级保护 ····································································229 14.1.1 任务、任务的 LDT 和 TSS·····························································229 14.1.2 全局空间和局部空间 ····································································232 14.1.3 特权级保护概述 ··········································································233 12 第 1 章 十六进制计数法 14.2 代码清单 14-1 ···················································································240 14.3 内核程序的初始化 ·············································································240 14.3.1 调用门······················································································241 14.3.2 调用门的安装和测试 ····································································243 14.4 加载用户程序并创建任务 ····································································246 14.4.1 任务控制块和 TCB 链···································································246 14.4.2 使用堆栈传递过程参数 ·································································248 14.4.3 加载用户程序·············································································250 14.4.4 创建局部描述符表 ·······································································250 14.4.5 重定位 U-SALT 表 ·······································································251 14.4.6 创建 0、1 和 2 特权级的堆栈 ·························································252 14.4.7 安装 LDT 描述符到 GDT 中 ···························································253 14.4.8 任务状态段 TSS 的格式 ································································254 14.4.9 创建任务状态段 TSS ····································································257 14.4.10 安装 TSS 描述符到 GDT 中 ··························································258 14.4.11 带参数的过程返回指令 ·······························································258 14.5 用户程序的执行 ················································································260 14.5.1 通过调用门转移控制的完整过程 ·····················································260 14.5.2 进入 3 特权级的用户程序的执行 ·····················································263 14.5.3 检查调用者的请求特权级 RPL························································265 本章习题 ·································································································267 第 15 章 任务切换 ································································································268 15.1 本章代码清单 ···················································································268 15.2 任务切换前的设置 ·············································································268 15.3 任务切换的方法 ················································································270 15.4 用 call/jmp/iret 指令发起任务切换的实例 ·················································273 15.5 处理器在实施任务切换时的操作 ···························································277 15.6 程序的编译和运行 ·············································································279 本章习题 ·································································································280 第 16 章 分页机制和动态页面分配·········································································281 16.1 分页机制概述 ···················································································281 16.1.1 简单的分页模型 ··········································································281 16.1.2 页目录、页表和页 ·······································································286 16.1.3 地址变换的具体过程 ····································································288 16.2 本章代码清单 ···················································································289 16.3 使内核在分页机制下工作 ····································································289 16.3.1 创建内核的页目录和页表 ······························································289 16.3.2 任务全局空间和局部空间的页面映射 ···············································294 13 x86 汇编语言:从实模式到保护模式 16.4 创建内核任务 ···················································································300 16.4.1 内核的虚拟内存分配 ····································································300 16.4.2 页面位映射串和空闲页的查找 ························································301 16.4.3 创建页表并登记分配的页 ······························································303 16.4.4 创建内核任务的 TSS ····································································304 16.5 用户任务的创建和切换 ·······································································305 16.5.1 多段模型和段页式内存管理 ···························································305 16.5.2 平坦模型和用户程序的结构 ···························································307 16.5.3 用户任务的虚拟地址空间分配 ························································308 16.5.4 用户程序的加载 ··········································································309 16.5.5 段描述符的创建(平坦模型) ························································312 16.5.6 重定位 U-SALT 并复制页目录表 ·····················································313 16.5.7 切换到用户任务执行 ····································································315 16.6 程序的编译和执行 ·············································································317 本章习题 ·································································································317 第 17 章 中断和异常的处理···················································································318 17.1 中断和异常 ······················································································318 17.1.1 中断和异常概述 ··········································································318 17.1.2 中断描述符表、中断门和陷阱门 ·····················································321 17.1.3 中断和异常处理程序的保护 ···························································323 17.1.4 中断任务···················································································324 17.1.5 错误代码···················································································325 17.2 本章代码清单 ···················································································326 17.3 内核的加载和初始化 ··········································································327 17.3.1 彻底终结多段模型 ·······································································327 17.3.2 创建中断描述符表 ·······································································330 17.3.3 用定时中断实施任务切换 ······························································331 17.3.4 8259A 芯片的初始化 ····································································337 17.3.5 平坦模型下的字符串显示例程 ························································339 17.4 内核任务的创建 ················································································340 17.4.1 创建内核任务的 TCB ···································································340 17.4.2 宏汇编技术················································································341 17.5 用户任务的创建 ················································································343 17.5.1 准备加载用户程序 ·······································································343 17.5.2 转换后援缓冲器的刷新 ·································································344 17.5.3 用户任务的创建和初始化 ······························································346 17.6 程序的编译和执行 ·············································································348 本章习题 ·································································································348 14 第 1 章 十六进制计数法 1 第 部分 预备知识 15 x86 汇编语言:从实模式到保护模式 第 1 章 十六进制计数法 1.1 二进制计数法回顾 1.1.1 关于二进制计数法 在《穿越计算机的迷雾》那本书里我们已经知道,计算机也是一台机器,唯一不同的地方 在于它能计算数学题,且具有逻辑判断能力。 与此同时,我们也已经在那本书里学到,机器在做数学题的时候,也面临着一个如何表示 数字的问题,比如你采用什么办法来将加数和被加数送到机器里。 同样是在那本书里,我们揭晓了答案,那就是用高、低两种电平的组合来表示数字。如图 1-1 所示,参与计算的数字通过电线送往计算机器,高电平被认为是“1”,低电平被认为是“0”, 这样就形成了一个序列“11111010”,这就是一个二进制数,在数值上等于我们所熟知的二百 五,换句话说,等于十进制数 250。 图 1-1 在计算机里,二进制数字对应着高低电平的组合 从数学的角度来看,二进制计数法是现代主流计算机的基础。一方面,它简化了硬件设计, 因为它只有两个符号“0”和“1”,要得到它们,可以用最少的电路元件来接通或者关断电路就行 了;另一方面,二进制数与我们熟悉的十进制数之间有着一对一的关系,任何一个十进制数都对 应着一个二进制数,不管它有多大。比如,十进制数 5,它所对应的二进制数是 101,而十进制 数 5785478965147 则 对 应 着 一 长 串 “ 0 ” 和 “ 1 ” 的 组 合 , 即 1010100001100001001011010110010011110011011。 组成二进制数的每一个数位,称为一个比特(bit),而一个二进制数也可以看成是一个比 特串。很明显,它的数值越大,这个比特串就越长,这是二进制计数法不好的一面。 1.1.2 二进制到十进制的转换 每一种计数法都有自己的符号(数符)。比如,十进制有 0、1、2、3、4、5、6、7、8、9 这十个符号;二进制呢,则只有 0、1 这两个符号。这些数字符号的个数称为基数。也就是说, 16 第 1 章 十六进制计数法 十进制有 10 个基数,而二进制只有两个。 二进制和十进制都是进位计数法。进位计数法的一个特点是,符号的值和它在这个数中所 处的位置有关。比如十进制数 356,6 处在个位上,所以是“6 个”;5 处在十位上,所以是“50”; 3 处在百位上,所以是“300”。即: 百位 3、十位 5、个位 6=3×102+5×101+6×100=356 这就是说,由于所处的位置不同,每个数位都有一个不同的放大倍数,这称为“权”。每 个数位的权是这样计算的(这里仅讨论整数):从右往左开始,以基数为底,指数从 0 开始递 增的幂。正如上面的公式所清楚表明的那样,“6”在最右边,所以它的权是以 10 为底,指数 为 0 的幂 100;而 3 呢,它的权则是以 10 为底,指数为 2 的幂 102。 上面的算式是把十进制数“翻译”成十进制数。从十进制数又算回到十进制数,这看起来 有些可笑,注意这个公式是可以推广的,可以用它来将二进制数转换成十进制数。 比如一个二进制数 10110001,它的基数是 2,所以要这样来计算与它等值的十进制数: 10110001B=1×27+0×26+1×25+1×24+0×23+0×22+0×21+1×20=177D 在上面的公式里,10110001B 里的“B”表示这是一个二进制数,“D”则表示 177 是个十 进制数。“B”和“D”分别是英语单词 Binary 和 Decimal 的头一个字母,这两个单词分别表示 二进位和十进位的意思。 讲到这里,也请你算一算,二进制数 10000000 和 1101101100011011 分别等于十进制数的多少? 1.1.3 十进制到二进制的转换 为了将一个十进制数转换成二进制数,可以采用将它不停地除以二进制的基数 2,直到商 为 0,然后将每一步得到的余数串起来即可。如图 1-2 所示,如果要将十进制数 26 转换成二进 制数,那么可采用如下方法: 图 1-2 将十进制数 26 转换成二进制数 第 1 步,将 26 除以 2,商为 13,余数为 0; 第 2 步,用 13 除以 2,商为 6,余数为 1; 第 3 步,用 6 除以 2,商为 3,余数为 0; 第 4 步,用 3 除以 2,商为 1,余数为 1; 第 5 步,用 1 除以 2,商为 0,余数为 1,结束。 然后,从下往上,将每一步得到的余数串起来,从左往右书写,就是我们所要转换的二进制 数。 1.2 十六进制计数法 17 x86 汇编语言:从实模式到保护模式 1.2.1 十六进制计数法的原理 二进制数和计算机电路有着近乎直观的联系。电路的状态,可以用二进制数来直观地描述, 而一个二进制数,也容易使我们仿佛观察到了每根电线上的电平变化。所以,我们才形象地说, 二进制是计算机的官方语言。 即使是在平时的学习和研究中,使用二进制也是必需的。一个数字电路输入什么,输出什 么,电路的状态变了,是哪一位发生了变化,研究这些,肯定要精确到每一个比特。这个时候, 采用二进制是最直观的。 但是,二进制也有它的缺点。眼下看来,它最主要的缺点就是写起来太长,一点也不方便。 为此,人们发明了十六进制计数法。至于为什么要发明另外一套计数方法,而不是依旧采用我 们熟悉的十进制,下面就要为大家解释。 一旦知道二进制有两个数符“0”和“1”,十进制有十个数符“0”到“9”,那么我们就会 很自然地认为十六进制一定有 16 个数符。 一点没错,完全正确。这 16 个数符分别是 0、1、2、3、4、5、6、7、8、9、A、B、C、 D、E、F。 你可能会觉得惊讶,字母怎么可以当做数字来使用?这样的话,那些熟悉的英语单词,像 Face (脸)、Bad(坏的)、Bed(床)就都成了数。 这又有什么奇怪的?你觉得“0”、“5”、“9”是数字,而“A”、“B”不是数字,这是因为 你已经从小习惯了这种做法。 对于自然数里的前 10 个,十进制和十六进制的表示方法是一致的。但是,9 之后的数,两 者的表示方法就大相径庭了,如表 1-1 所示。 十进制数 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 表 1-1 部分十进制数和十六进制数对照表 十六进制数 十进制数 0 17 1 18 2 19 3 20 4 21 5 22 6 23 7 24 8 25 9 26 A 27 B 28 C 29 D 30 E 31 F 32 10 33 十六进制数 11 12 13 14 15 16 17 18 19 1A 1B 1C 1D 1E 1F 20 21 很显然,一旦某个数位增加到 9 之后,下一次,它将变成 A,而不是向前进位,因为这里 是逢 16 才进位的。进位只发生在某个数位原先是 F 的情况下,比如 1F,它加一后将会变成 20。 18 1.2.2 十六进制到十进制的转换 第 1 章 十六进制计数法 要把一个十六进制数转换成我们熟悉的十进制数,可以采用和前面一样的方法。只不过, 计算各个数位的权时,幂的底数是 16。比如将十六进制数 125 转换成十进制数的方法如下: 125H=1×162+2×161+5×160=293D 上式里,125 后面的“H”用于表明这是一个十六进制数,它是英语单词 Hexadecimal 的头 一个字母,这个单词的意思是十六进制。 1.2.3 十进制到十六进制的转换 如图 1-3 所示,相应地,要把一个十进制数转换成十六进制数,则可以采取不停地除以 16 并取其余数的策略。 第 1 次,将 293 除以 16,商为 18,余 5; 第 2 次,用 18 除以 16,商为 1,余 2; 第 3 次,再用 1 除以 16,商为 0,余 1,结束。 然后,从下往上,将每次的余数 1、2、5 列出来,得到 125,这就是所要的结果。 图 1-3 将十进制数 293 转换成十六进制数 1.3 为什么需要十六进制 为什么我们要发明十六进制计数法?为什么我们要学习它? 提出这样的问题,在我看来很有趣,也很有意义,但似乎从来没有人在书上正面回答过。 这样一来,可怜的学子们只能在掌握了十六进制若干年之后,在某一天里自己恍然大悟。 为了搞清楚这个问题,我们不妨来列张表(表 1-2),看看十进制数、二进制数和十六进制 数之间,都有些什么有趣的规律和特点。 十进制数 0 1 2 3 4 5 6 7 表 1-2 部分十进制数、二进制数和十六进制数对照表 二进制数 十六进制数 十进制数 二进制数 0000 0 10 1010 0001 1 11 1011 0010 2 12 1100 0011 3 13 1101 0100 4 14 1110 0101 5 15 1111 0110 6 16 0001 0000 0111 7 17 0001 0001 十六进制数 A B C D E F 10 11 19 x86 汇编语言:从实模式到保护模式 8 1000 8 9 1001 9 55 0011 0111 37 195 1100 0011 C3 在上面这张表里(表 1-2),每一个二进制数在排版的时候,都经过了“艺术加工”,全都 以 4 比特为一组的形式出现。不足 4 比特的,前面都额外加了“0”,比如 10,被写成 0010 的 形式。就像十进制数一样,在一个二进制数的前面加多少个零,都不会改变它的值。 注意观察这张表并开动脑子,4 比特的二进制数,可以表示的数是 0000 到 1111,也就是 十进制的 0~15,这正好对应于十六进制的 0~F。 在这个时候,如果将它们都各自加 1,那么,下一个二进制数是 0001 0000,与此同时,它 对应的十六进制数则是 10,你会发现,它们有着如图 1-4 所示的奇妙对应关系。 图 1-4 十六进制的每一位与二进制数每 4 比特为一组的对应关系 再比如图 1-4 中的二进制数 1100 0011,它与等值的十六进制数 C3 也有着相同的对应关系。 也就是说,如果将一个二进制数从右往左,分成 4 比特为一组的形式,分别将每一组的值 转换成十六进制数,就可以得到这个二进制数所对应的十六进制数。 这样一来,如果我们稍加努力,将 0~F 这 16 个数所对应的二进制数背熟,并能换算自如 的话,那么,当我们看到一个十六进制数 3F8 时,我们就知道,因为 3 对应的二进制数为 0011, F 对应的二进制数是 1111,8 对应的二进制数是 1000,所以 3F8H=0011 1111 1000B。 同理,如果一个二进制数是 1101 0010 0101 0001,那么,将它们按 4 比特为一组,分别换 算成十六进制数,就得到了 D251。 正如前面所说的,从事计算机的学习和研究(包括咱们马上就要进行的汇编语言程序设 计),不可避免地要与二进制数打交道,而且有时还必须针对其中某些比特进行特殊处理。这 个时候,如果想保留二进制数的直观性,同时还要求写起来简短,十六进制数是最好的选择。 本章习题 1. 口算: 5H=___D 12D=___H 0FH=___D=___B 0CH=___D=___B 0AH=___D=___B 8D=___H=___B 0BH=___D=___B 2. 口算: 0EH=___D=___B 10H=___D=___B 10010B=___H 15H=___B 8FH=___B 200H=___B 111111111B=___H 20 第 2 章 处理器、内存和指令 2.1 最早的处理器 1947 年,美国贝尔实验室的肖克利和同事们一起发明了晶体管。1958 年,也许是受够了 在一大堆晶体管里连接那些杂乱无章的导线,另一个美国人杰克·基尔比发明了集成电路。接 着,1971 年,在为日本人设计计算器芯片的过程中,受到启发的 Intel 公司生产了世界上第一 个处理器 4004。 图 2-1 所示的是 4004 和它的设计者弗德里科·法金。 图 2-1 Intel 第一块处理器 4004 和它的设计者弗德里科·法金 今天我们已经知道,处理器(Processor)是一台电子计算机的核心,它会在振荡器脉冲的 激励下,从内存中获取指令,并发起一系列由该指令所定义的操作。当这些操作结束后,它接 着再取下一条指令。通常情况下,这个过程是连续不断、循环往复的。 2.2 寄存器和算术逻辑部件 为什么处理器能够自动计算,这个问题已经在我的上一本书《穿越计算机的迷雾》里讲过 了,不过这些原理讲起来很费劲,花了整整一本书的篇幅。当然,如果你没看过这本书,也没 关系,下面就来简单回顾一下。回顾这些知识很有用,因为只有这样你才能知道如何安排处理 器做事情。 电子计算机能做很多事情。你能够知道明天出门要穿厚一点才不挨冻,是因为电子计算机 算出了天气。除此之外,它还能让你看电影、听音乐、写文章、上网。尽管表面上看来,这些 用处和算数学题没什么关系,但实质上,这些功能都是以数学计算为基础的。正是因为如此, 人们才会把“计算”这个词挂在嘴边,什么“云计算”、“网络计算”、“64 位计算”,等等。 处理器不是法师手里的仙器,它之所以能计算数学题,是因为其特殊的设计。处理器是一个 x86 汇编语言:从实模式到保护模式 “器”,即器件,不太大,有的是长方形,有的是正方形,就像饼干。实际上,它是一块集成电 路。 如图 2-2 所示,在处理器的底部或者四周,有大 量的引脚,可以接受从外面来的电信号,或者向外发 出电信号。每个引脚都有自己的用处,在往电路板上 安装的时候,不能接错。所以,如图中所示,处理器 在生产的时候,都会故意缺一个角,这是一个参照标 志,可以确保安装的人不会弄错。当然,并不是所有 的处理器都会缺一个角,这不是一个固定不变的做法。 处理器的引脚很多,其中有一部分是用来将参与 运算的数字送入处理器内部。假如现在要进行加法运 算,那么我们要重复使用这些引脚,来依次将被加数 图 2-2 处理器进行数学运算的简单原理 和加数送入。 一旦被加数通过引脚送入处理器,代表这个二进制数字的一组电信号就会出现在与引脚相 连的内部线路上。这是一排高低电平的组合,代表着二进制数中的每一位。这时候,必须用一 个称为寄存器(Register)的电路锁住。之所以要这样做,是因为相同的引脚和线路马上还要 用于输入加数。也正是因为这个原因,这些内部线路称为处理器内部总线。 同样地,加数也要锁进另一个寄存器中。如图 2-2 所示,寄存器 RA 和 RB 将分别锁存参 与运算的被加数和加数。此后,RA 和 RB 中的内容不再受外部数据线的影响。 寄存器是双向器件,可以在一端接受输入并加以锁存,同时,它也会在另一端产生一模一 样的输出。与寄存器 RA 和 RB 相连的,是算术逻辑单元,或者算术逻辑部件(Arithmetic Logic Unit,ALU),也就是图 2-2 中的桶形部分。它是专门负责运算的电路,可以计算加法、减法或 者乘法,也可做逻辑运算。在这里,我们要求它做一次加法。 一旦寄存器 RA 和 RB 锁存了参与运算的两个数,算术逻辑部件就会输出相加的结果,这 个结果可以临时用另外一个寄存器 RC 锁存,稍后再通过处理器数据总线送到处理器外面,或 者再次送入 RA 或 RB。 处理器总是很繁忙的,在它操作的过程中,所有数据在寄存器里面都只能是临时存在一会 儿,然后再被送往别处,这就是为什么它被叫做“寄存器”的原因。早期的处理器,它的寄存 器只能保存 4 比特、8 比特或 16 比特,它们分别叫做 4 位、8 位和 16 位寄存器。现在的处理 器,寄存器一般都是 32 位、64 位甚至更多。 如图 2-3 所示,8 位寄存器可以容纳 8 比特(bit),或者说 1 字节(Byte),这是因为 1 byte = 8 bit 另外,我们还要为这个字节的每一位编上号,编号是从右往左进行的,从 0 开始,分别是 0、1、2、3、4、5、6、7。在这里,位 0(第 1 位)是最低位,在最右边;位 7(第 8 位)是 最高位,在最左边。 为了更好地理解上面这些概念,图中假定 8 位寄存器里存放的是二进制数 10001101,即十 六进制的 8D。这时,它的最低位和最高位都是 1。 16 位寄存器可以存放 2 个字节,这称为 1 个字(word),各个数位的编号分别是 0~15, 其中 0~7 是低字节,8~15 是高字节。实际上,“字”的概念出现得很早,也并非指 16 个比特。 只是到了后来,才特指 16 个二进制位的长度。 32 位寄存器可以存放 4 个字节,这称为 1 个双字(double word),各个数位的编号分别 是 0~31,其中 0~15 是低字,16~31 是高字。 22 第 2 章 处理器、内存和指令 尽管图中没有画出,但是 64 位寄存器可以容纳更多的比特,也就是 8 个字节,或者 4 个 字。位数越多,寄存器所能保存的数越大,这是显而易见的。 图 2-3 寄存器数据宽度示意 2.3 内 存 储 器 前面已经讲过,处理器的计算过程,实际上是借助于寄存器和算术逻辑部件进行的。那么, 参与计算的数是从哪里来的呢?答案是一个可以保存很多数字的电路,叫做存储器(Storage 或 Memory)。 存储器的种类实际上是很多的,包括大家都知道的硬盘和 U 盘等。甚至寄存器就是存储器 的一种。不过,我们现在所要讲到的存储器,则是另外一种东西。 如图 2-4 所示,这是所有个人计算机里都会用到的存储器,我们平时把它叫做内存条。这个 概念是这么来的,首先,它是计算机内部最主要的存储器,通常只和处理器相连,所以叫做内存 储器或者主存储器,简称内存或主存。其次,它一般被设计成扁平的条状电路板,所以叫内存条。 图 2-4 个人计算机里使用的内存条 如图 2-5 所示,和寄存器不同,内存用于保存更多的比特。对于用得最多的个人计算机来 说,内存按字节来组织,单次访问的最小单位是 1 字节,这是最基本的存储单元。如图中所示, 每个存储单元中,各位的编号分别是 0~7。 内存中的每字节都对应着一个地址,如图 2-5 所示,第 1 个字节的地址是 0000H,第 2 个 字节的地址是 0001H,第 3 个字节的地址是 0002H,其他依次类推。注意,这里采用的是十六 进制表示法。作为一个例子,因为这个内存的容量是 65536 字节,所以最后一个字节的地址是 FFFFH。 为了访问内存,处理器需要给出一个地址。访问包括读和写,为此,处理器还要指明,本 次访问是读访问还是写访问。如果是写访问,则还要给出待写入的数据。 8 位处理器包含 8 位的寄存器和算术逻辑部件,16 位处理器拥有 16 位的寄存器和算术逻 23 x86 汇编语言:从实模式到保护模式 辑部件,64 位处理器则包含 64 位的寄存器和算术逻辑部件。尽管内存的最小组成单位是字节, 但是,经过精心的设计和安排,它能够按字节、字、双字和四字进行访问。换句话说,仅通过 单次访问就能处理 8 位、16 位、32 位或者 64 位的二进制数。注意,我说的是单次访问,而不 是一个一个地取出每个字节,然后加以组合。 图 2-5 内存和内存访问示意图 如图 2-5 所示,处理器发出字长控制信号,以指示本次访问的字长是 8、16、32 还是 64。 如果字长是 8,而且给出的地址是 0002H,那么,本次访问只会影响到内存的一字节;如果字 长是 16,给出的地址依然是 0002H,那么实际访问的将是地址 0002H 处的一个字,低 8 位在 0002H 中,高 8 位在 0003H 中。 2.4 指令和指令集 从一开始,设计处理器的目标之一就是使它成为一种可以自动进行操作的器件。另外,还 需要提供一种机制,来允许工程技术人员决定进行何种操作。 处理器何以能够自动进行操作,这不是本书的话题,大学里有这样的课程,《穿越计算机 的迷雾》这本书也给出了通俗化的答案。 简单地说,处理器的设计者用某些数来指示处理器所进行的操作,这称为指令(Instruction), 或者叫机器指令,因为只有处理器才认得它们。比如,指令 F4H 表示让处理器停机,当处理 器取到并执行这条指令后,就停止工作。指令是集中存放在内存里的,一条接着一条,处理器 的工作是自动按顺序取出并加以执行。 如图 2-6 所示,从内存地址 0000H 开始(也就是内存地址的最低端)连续存放了一些指令。 同时,假定执行这些指令的是一个 16 位处理器,拥有两个 16 位的寄存器 RA 和 RB。 一般来说,指令由操作码和操作数构成,但也有小部分指令仅有操作码,而不含操作数。 如图 2-6 所示,停机指令仅包含 1 字节的操作码 F4,而没有操作数。指令的长度不定,短的指 令仅有 1 字节,而长的指令则有可能达到 15 字节。 24 第 2 章 处理器、内存和指令 对处理器来说,指令的操作码隐含了如何执行该指令的信息,比如它是做什么的,以及 怎么去做。第一条指令的操作码是 B8,这表明,该指令是一条传送指令,第一个操作数是寄 存器,第二个操作数是直接包含在指令中的,紧跟在操作码之后,可以立即从指令中取得, 所以叫做立即数(Immediate Operand)。同时,操作码还直接指出该寄存器是 RA。RA 是 16 位寄存器,这条指令将按字进行操作。所以,当这条指令执行之后,该指令的操作数(立即 数)005DH 就被传送到 RA 中。 图 2-6 处理器指令在内存中的布局 既然操作码中隐含了这么多的信息,那么,处理器就可以“知道”每条指令的长度。这样, 当它执行第一条指令 B8 5D 00 的时候,就已经知道,这是一个 3 字节指令,下一条指令位于 3 个字节之后,即内存地址 0003H 处。 注意字数据在内存中的存放特点。地址 0001H 和 0002H 里的内容分别是 5D 和 00,如果 每次读一个字节,则从地址 0001H 里读出的是 5D,从 0002H 里读出的是 00。但如果以字的方 式来访问地址 0001H,读到的就会是 005DH。这种差别,跟处理器和内存之间的数据线连接方 式有关。对于 Intel 处理器来说,如果访问内存中的一个字,那么,它规定高字节位于高地址 部分,低字节位于低地址部分,这称为低端字节序(Little Endian)。至于其他公司的处理器, 则可能情况正好相反,称为高端字节序。 对于复杂一些的指令来说,1 个字节的操作码可能不会够用。所以,第 2 条指令的操作码 为 8B 1E,它隐含的意思是,这是一条传送指令,第一个操作数是寄存器,而且是 RB 寄存器, 第二个操作数是内存地址,要传送到 RB 寄存器中的数存放在该地址中。同时,这是一个字操 25 x86 汇编语言:从实模式到保护模式 作指令,应当从第二个操作数指定的地址中取出一个字。 该指令的操作数部分是 3F 00,指定了一个内存地址 003FH。它相当于高级语言里的指针, 当处理器执行这条指令时,会再次用 003FH 作为地址去访问内存,从那里取出一个字(1002H), 然后将它传送到寄存器 RB。注意,“传送”这个词带有误导性。其实,传送的意思更像是“复 制”,传送之后,003FH 单元里的数据还保持原样。 通过这两条指令的比较,很容易分清指令中的“立即数”是什么意思。指令执行和操作的 对象是数。如果这个数已经在指令中给出了,不需要再次访问内存,那这个数就是立即数,比 如第一条指令中的 005DH;相反,如果指令中给出的是地址,真正的数还需要用这个地址访问 内存才能得到,那它就不能称为立即数,比如第二条指令中的 003FH。 如图 2-6 所示,余下的三条指令,旁边都有注解,这里就不再一一解释了。如果一开始内 存地址 003FH 中存放的是 1002H,那么,当所有这些指令执行完后,003FH 里就是最终的结 果 105FH。 指令和非指令的普通二进制数是一模一样的,在组成内存的电路中,都是一些高低电平的 组合。因为处理器是自动按顺序取指令并加以执行的,在指令中混杂了非指令的数据会导致处 理器不能正常工作。为此,指令和数据要分开存放,分别位于内存中的不同区域,存放指令的 区域叫代码区,存放数据的区域叫数据区。为了让处理器正确识别和执行指令,工程技术人员 必须精心安排,并告诉处理器要执行的指令位于内存中的什么位置。 还是那句话,并非每一个二进制数都代表着一条指令。每种处理器在设计的时候,也只能 拥有有限的指令,从几十条到几百条不等。一个处理器能够识别的指令的集合,称为该处理器 的指令集。 2.5 古老的 Intel 8086 处理器 任何时候,一旦提到 Intel 公司的处理器,就不能不说 8086。8086 是 Intel 公司第一款 16 位处理器,诞生于 1978 年,所以说它很古老。 但是,在 Intel 公司的所有处理器中,它占有很重要的地位,是整个 Intel 32 位架构处理器 (IA-32)的开山鼻祖。首先,最重要的一点是,它是一款非常成功的产品,设计先进,功能 很强,卖得很好。 其次,8086 的成功使得市场上出现了大量针对它开发的软件产品。这样,当 Intel 公司要 设计新的处理器时,它不得不考虑到兼容性的问题。要使得老的软件也能在新的处理器上很 好地运行,必须要具备指令集和工作模式上的兼容性和一致性。Intel 公司很清楚,如果新处 理器和老处理器不兼容,那么,新处理器越多,它扔掉的拥趸也就越多,要不了多久,这公 司就不用再开了。 所以,当我们讲述处理器的时候,必须要从 8086 开始;而且,要学习汇编语言,针对 8086 的汇编技术也是必不可少的。 2.5.1 8086 的通用寄存器 26 第 2 章 处理器、内存和指令 8086 处理器内部有 8 个 16 位的通用寄存器,分别被命名为 AX、BX、CX、DX、SI、DI、 BP、SP。“通用”的意思是,它们之中的大部分都可以根据需要用于多种目的。 如图 2-7 所示,因为这 8 个寄存器都是 16 位的,所以通常用于进行 16 位的操作。比如, 可以在这 8 个寄存器之间互相传送数据,它们之间也可以进行算术逻辑运算;也可以在它们和 内存单元之间进行 16 位的数据传送或者算术逻辑运算。 同时,如图 2-7 所示,这 8 个寄存器中的前 4 个,即 AX、BX、CX 和 DX,又各自可以拆 分成两个 8 位的寄存器来使用,总共可以提供 8 个 8 位的寄存器 AH、AL、BH、BL、CH、CL、 DH 和 DL。这样一来,当需要在寄存器和寄存器之间,或者寄存器和内存单元之间进行 8 位 的数据传送或者算术逻辑运算时,使用它们就很方便。 图 2-7 8086 的通用寄存器 将一个 16 位的寄存器当成两个 8 位的寄存器来用时,对其中一个 8 位寄存器的操作不会 影响到另一个 8 位寄存器。举个例子来说,当你操作寄存器 AL 时,不会影响到 AH 中的内 容。 2.5.2 程序的重定位难题 我们知道,处理器是自动化的器件,在给出了起始地址之后,它将从这个地址开始,自动 地取出每条指令并加以执行。只要每条指令都正确无误,它就能准确地知道下一条指令的地址。 这就意味着,完成某个工作的所有指令,必须集中在一起,处于内存的某个位置,形成一个段, 叫做代码段。事情是明摆着的,要是指令并没有一条挨着一条存放,中间夹杂了其他非指令的 数据,处理器将因为不能识别而出错。 为了做某件事而编写的指令,它们一起形成了我们平时所说的程序。程序总要操作大量的 数据,这些数据也应该集中在一起,位于内存中的某个地方,形成一个段,叫做数据段。 段在内存中的位置并不重要,因为处理器是可控的,我们可以让它从内存的任何位置开始 取指令并加以执行。这里有一个例子,如图 2-8 所示,我们有一大堆数字,现在想把它们加起 来求出一个总和。 假定我们有 16 个数要相加,这些数都是 16 位的二进制数,分别是 0005H、00A0H、 00FFH、…。为了让处理器把它们加起来,我们应该先在内存中定义一个数据段,将这些数字 写进去。数据段可以起始于内存中的任何位置,既然如此,我们将它定在 0100H 处。这样一来, 第一个要加的数位于地址 0100H,第二个要加的数位于地址 0102H,最后一个数的地址是 011EH。 一旦定义了数据段,我们就知道了每个数的内存地址。然后,紧挨着数据段,我们从内存 地址 0120H 处定义代码段。严格地说,数据段和代码段是不需要连续的,但这里把它们挨在一 27 x86 汇编语言:从实模式到保护模式 起更自然一些。为了区别数据段和代码段,我们使用了不同的底色。 代码段是从内存地址 0120H 处开始的,第一条指令是 A1 00 01,其功能是将内存单元 0100H 里的字传送到 AX 寄存器。指令执行后,AX 的内容为 0005H。 第二条指令是 03 06 02 01,功能是将 AX 中的内容和内存单元 0102H 里的字相加,结果在 AX 中。由于 AX 的内容为 0005H,而内存地址 0102H 里的数是 00A0H,这条指令执行后,AX 的内容为 00A5H。 第三条指令是 03 06 04 01,功能是将 AX 中的内容和内存单元 0104H 里的字相加,结果在 AX 中。此时,由于 AX 里的内容是 00A5H,内存地址 0104H 里的数是 00FFH,本指令执行后, AX 的内容为 01A4H。 图 2-8 程序的代码段和数据段示例 后面的指令没有列出,但和前 2 条指令相似,依次用 AX 的内容和下一个内存单元里的字 28 第 2 章 处理器、内存和指令 相加,一直到最后,在 AX 中得到总的累加和。在这个例子中,我们没有考虑 AX 寄存器容纳 不下结果的情况。当累加的总和超出了 AX 所能表示的数的范围(最大为 FFFFH,即十进制的 65535)时,就会产生进位,但这个进位被丢弃。 在内存中定义了数据段和代码段之后,我们就可以命令处理器从内存地址 0120H 处开始执 行。当所有的指令执行完后,就能在 AX 寄存器中得到最后的结果。 看起来没有什么问题,一切都很完美,不是吗?那本节标题中所说的难题又从何而来呢? 这里确实有一个难题。 在前面的例子中,所有在执行时需要访问内存单元的指令,使用的都是真实的内存地址。 比如 A1 00 01,这条指令的意思是从地址为 0100H 的内存单元里取出一个字,并传送到寄存器 AX。在这里,0100H 是一个真实的内存地址,又称物理地址。 整个程序(包括代码段和数据段)在内存中的位置,是由我们自己定的。我们把数据段定 在 0100H,把代码段定在 0120H。 问题是,大多数时候,整个程序(包括代码段和数据段)在内存中的位置并不是我们能够 决定的。请想一想你平时是怎么使用计算机的,你所用的程序,包括那些用来调整计算机性能 的工具、小游戏、音乐和视频播放器等, 都是从网上下载的,位于你的硬盘、U 盘 或光盘中。即使有些程序是你自己编写的, 那又如何?当你双击它们的图标,使它们 在 Windows 里启动之前,内存已经被塞了 很多东西,就算你是刚刚打开计算机, Windows 自己已经占用了很多内存空间, 不然的话,你怎么可能在它上面操作呢? 在这种情况下,你所运行的程序,在 内存中被加载的位置完全是随机的,哪里 有空闲的地方,它就会被加载到哪里,并 从那里开始被处理器执行。所以,前面那 段程序不可能恰好如你所愿,被加载到内 存地址 0100H,它完全可能被加载到另一 个不同的位置,比如 1000H。但是,同样 是那个程序,一旦它在内存中的位置发生 了改变,灾难就出现了。 如图 2-9 所示,因为程序现在是从内存 地址 1000H 处被加载的,所以,数据段的 起始地址为 1000H。这就是说,第一个要 加的数,其地址为 1000H,第二个则为 1002H,其他依次类推。代码段依然紧挨着 数据段之后,起始地址相应地是 1020H。 只要所有的指令都是连续存放的,代 码段位于内存中的什么地方都可以正常 执行。所以,处理器可以按你的要求,从 图 2-9 在指令中使用绝对内存地址的程序是不可重定位的 29 x86 汇编语言:从实模式到保护模式 内存地址 1020H 处连续执行,但结果完全不是你想要的。 请看第一条指令 A1 00 01,它的意思是从内存地址 0100 处取得一个字,将其传送到寄存 器 AX。但是,由于程序刚刚改变了位置,它要取的那个数,现在实际上位于 1000H,它取的 是别人地盘里的数! 这能怪谁呢?发生这样的事情,是因为我们在指令中使用了绝对内存地址(物理地址), 这样的程序是无法重定位的。为了让你写的程序在卖给别人之后,可以在内存中的任何地方正 确执行,就只能在编写程序的时候使用相对地址或者逻辑地址了,而不能使用真实的物理地址。 当程序加载时,这些相对地址还要根据程序实际被加载的位置重新计算。 在任何时候,程序的重定位都是非常棘手的事情。当然,也有好几种解决的办法。在 8086 处理器上,这个问题特别容易解决,因为该处理器在访问内存时使用了分段机制,我们可以借 助该机制。 2.5.3 内存分段机制 如图 2-10 所示,整个内存空间就像长长的纸条,在内存中分段,就像从长纸条中裁下一小 段来。根据需要,段可以开始于内存中的任何位置,比如图中的内存地址 A532H 处。 在这个例子中,分段开始于地址为 A532H 的内存单元处,这个起始地址就是段地址。 这个分段包含了 6 个存储单元。在分段之前,它们在整个内存空间里的物理地址分别是 A532H、A533H、A534H、A535H、A536H、A537H。 但是,在分段之后,它们的地址可以只相对于自己所在的段。这样,它们相对于段开始处 的距离分别为 0、1、2、3、4、5,这叫做偏移地址。 于是,当采用分段策略之后,一个内存单元的地址实际上就可以用“段:偏移”或者 “段 地址:偏移地址”来表示,这就是通常所说的逻辑地址。比如,在图 2-10 中,段内第 1 个存 储单元的地址为 A532H:0000H,第 3 个存储单元的地址为 A532H:0002H,而本段最后一个存 储单元的地址则是 A532H:0005H。 30 第 2 章 处理器、内存和指令 图 2-10 段地址和偏移地址示意图 为了在硬件一级提供对“段地址:偏移地址”内存访问模式的支持,处理器至少要提供两 个段寄存器,分别是代码段(Code Segment,CS)寄存器和数据段(Data Segment,DS)寄存 器。 对 CS 内容的改变将导致处理器从新的代码段开始执行。同样,在开始访问内存中的数据 之前,也必须首先设置好 DS 寄存器,使之指向数据段。 除此之外,最重要的是,当处理器访问内存时,它把指令中指定的内存地址看成是段内的 偏移地址,而不是物理地址。这样,一旦处理器遇到一条访问内存的指令,它将把 DS 中的数 据段起始地址和指令中提供的段内偏移相加,来得到访问内存所需要的物理地址。 如图 2-11 所示,代码段的段地址为 1020H,数据段的段地址为 1000H。在代码段中有一条 指令 A1 02 00,它的功能是将地址 0002H 处的一个字传送到寄存器 AX。在这里,处理器将 0002H 看成是段内的偏移地址,段地址在 DS 中,应该在执行这条指令之前就已经用别的指令传送到 DS 中了。 31 x86 汇编语言:从实模式到保护模式 图 2-11 从逻辑地址到物理地址的转换过程 当执行指令 A1 02 00 时,处理器将把 DS 中的内容和指令中指定的地址 0002H 相加,得到 1002H。这是一个物理地址,处理器用它来访问内存,就可以得到所需要的数 00A0H。 如果一下次执行这个程序时,代码段和数据段在内存中的位置发生了变化,只要把它们的 段地址分别传送到 CS 和 DS,它也能够正确执行。 2.5.4 8086 的内存分段机制 前面讲了如何从逻辑地址转换到物理地址,以使得程序的运行和它在内存中的位置无关。 这种策略在很多处理器中得到了支持,包括 8086 处理器。但是,由于 8086 自身的局限性,它 的做法还要复杂一些。 如图 2-12 所示,8086 内部有 8 个 16 位的通用寄存器,分别是 AX、BX、CX、DX、SI、 32 第 2 章 处理器、内存和指令 DI、BP、SP。其中,前四个寄存器中的每一个,都还可以当成两个 8 位的寄存器来使用,分 别是 AH、AL、BH、BL、CH、CL、DH、DL。 图 2-12 8086 处理器内部组成框图 在进行数据传送或者算术逻辑运算的时候,使用算术逻辑部件(ALU)。比如,将 AX 的 内容和 CX 的内容相加,结果仍在 AX 中,那么,在相加的结果返回到 AX 之前,需要通过一 个叫数据暂存器的寄存器中转。 处理器能够自动运行,这是控制器的功劳。为了加快指令执行速度,8086 内部有一个 6 字节的指令预取队列,在处理器忙着执行那些不需要访问内存的指令时,指令预取部件可以趁 机访问内存预取指令。这时,多达 6 个字节的指令流可以排队等待解码和执行。 8086 内部有 4 个段寄存器。其中,CS 是代码段寄存器,DS 是数据段寄存器,ES 是附加 段(Extra Segment)寄存器。附加段的意思是,它是额外赠送的礼物,当需要在程序中同时使 用两个数据段时,DS 指向一个,ES 指向另一个。可以在指令中指定使用 DS 和 ES 中的哪一 个,如果没有指定,则默认是使用 DS。SS 是栈段寄存器,以后会讲到,而且非常重要。 IP 是指令指针(Instruction Pointer)寄存器,它只和 CS 一起使用,而且只有处理器才能直 接改变它的内容。当一段代码开始执行时,CS 指向代码段的起始地址,IP 则指向段内偏移。 这样,由 CS 和 IP 共同形成逻辑地址,并由总线接口部件变换成物理地址来取得指令。然后, 处理器会自动根据当前指令的长度来改变 IP 的值,使它指向下一条指令。 当然,如果在指令的执行过程中需要访问内存单元,那么,处理器将用 DS 的值和指令中 提供的偏移地址相加,来形成访问内存所需的物理地址。 8086 的段寄存器和 IP 寄存器都是 16 位的,如果按照原先的方式,把段寄存器的内容和偏 移地址直接相加来形成物理地址的话,也只能得到 16 位的物理地址。麻烦的是,8086 却提供 了 20 根地址线。换句话说,它提供的是 20 位的物理地址。 提供 20 位地址线的原因很简单,16 位的物理地址只能访问 64KB 的内存,地址范围是 0000H~FFFFH,共 65536 个字节。这样的容量,即使是在那个年代,也显得捉襟见衬。注意, 这里提到了一个表示内存容量的单位“KB”。为了方便,我们通常使用更大的单位来描述内存 容量,比如千字节(KB)、兆字节(MB)和吉字节(GB),它们之间的换算关系如下: 1 KB = 1024 Byte 1 MB = 1024 KB 33 x86 汇编语言:从实模式到保护模式 1 GB = 1024 MB 所以,65536 个字节就是 64KB,而 20 位的物理地址则可以访问多达 1MB 的内存,地址 范围从 00000H 到 FFFFFH。问题是,16 位的段地址和 16 位的偏移地址相加,只能形成 16 位 的物理地址,怎么得到这 20 位的物理地址呢? 为了解决这个问题,8086 处理器在形成物理地址时,先将段寄存器的内容左移 4 位(相当 于乘以十六进制的 10,或者十进制的 16),形成 20 位的段地址,然后再同 16 位的偏移地址相 加,得到 20 位的物理地址。比如,对于逻辑地址 F000H:052DH,处理器在形成物理地址时, 将段地址 F000H 左移 4 位,变成 F0000H,再加上偏移地址 052DH,就形成了 20 位的物理地 址 F052DH。 这样,因为段寄存器是 16 位的,在段不重叠的情况下,最多可以将 1MB 的内存分成 65536 个段,段地址分别是 0000H、0001H、0002H、0003H,……,一直到 FFFFH。在这种情况下, 如图 2-13 所示,每个段正好 16 个字节,偏移地址从 0000H 到 000FH。 …… …… …… 逻辑地址 :0002 :0001 0001:0000 :000F :000E :000D :000C :000B :000A :0009 :0008 :0007 :0006 :0005 :0004 :0003 :0002 :0001 0000:0000 00012 00011 00010 0000F 0000E 0000D 0000C 0000B 0000A 00009 00008 00007 00006 00005 00004 00003 00002 00001 00000 连续的物理地址 图 2-13 1MB 内存可以划分为 65536 个 16 字节的段 同样在不允许段之间重叠的情况下,每个段的最大长度是 64KB,因为偏移地址也是 16 位 的,从 0000H 到 FFFFH。在这种情况下,1MB 的内存,最多只能划分成 16 个段,每段长 64KB, 段地址分别是 0000H、1000H、2000H、3000H,…,一直到 F000H。 以上所说的只是两种最典型的情况。通常情况下,段地址的选择取决于内存中哪些 34 第 2 章 处理器、内存和指令 区域是空闲的。举个例子来说,假如从物理地址 00000H 开始,一直到 82251H 处都被其他程 序占用着,而后面一直到 FFFFFH 的地址空间都是自由的,那么,你可以从物理内存地址 82251H 之后的地方加载你的程序。 接着,你的任务是定义段地址并设置处理器的段寄存器,其中最重要的是段地址的选取。 因为偏移地址总是要求从 0000H 开始,而 82260H 是第一个符合该条件的物理地址,因为它恰 好对应着逻辑地址 8226H:0000H,符合偏移地址的条件,所以完全可以将段地址定为 8226H。 但是,举个例子来说,如果你从物理内存地址 82255H 处加载程序,由于它根本无法表示 成一个偏移地址为 0000H 的逻辑地址,所以不符合要求,段不能从这里开始划分。这里面的区 别在于,82260H 可以被十进制数 16(或者十六进制数 10H)整除,而 82255H 不能。通过这 个例子可以看出,8086 处理器的逻辑分段,起始地址都是 16 的倍数,这称为是按 16 字节对齐 的。 段的划分是自由的,它可以起始于任何 16 字节对齐的位置,也可以是任意长度,只要不 超过 64KB。比如,段地址可以是 82260H,段的长度可以是 64KB。在这种情况下,该段所对 应的逻辑地址范围是 8226H:0000H~8226H:FFFFH,其所对应的物理地址范围是 82260~ 9225FH。 同时,正是由于段的划分非常自由,使得 8086 的内存访问也非常随意。同一个物理地址, 或者同一片内存区域,根据需要,可以随意指定一个段来访问它,前提是那个物理地址位于该 段的 64KB 范围内。也就是说,同一个物理地址,实际上对应着多个逻辑地址。 本章习题 数据段寄存器 DS 的值为25BCH 时,计算 Intel 8086可以访问的物理地址范围。 35 第 3 章 汇编语言和汇编软件 3.1 汇编语言简介 在前面的章节里,我们讲到了处理器,也讲了处理器是如何进行算术逻辑运算的。为了实 现自动计算,处理器必须从内存中取得指令,并执行这些指令。 指令和被指令引用的数据在内存中都是一些或高或低的电平,每一个电平都可以看成是一 个二进制位(0 或者 1),8 个二进制位形成一字节。 要解读内存中的东西,最好的办法就是将它们按字节转换成数字的形式。比如,下面这些 数字就是存放在内存中的 8086 指令,我们用的是十六进制: B8 3F 00 01 C3 01 C1 对于大多数人来说,他们很难想象上面那一排数字对应着下面几条 8086 指令: 将立即数 003FH 传送到寄存器 AX; 将寄存器 BX 的内容和寄存器 AX 的内容相加,结果在 BX 中; 将寄存器 CX 的内容和寄存器 AX 的内容相加,结果在 CX 中。 即使是很有经验的技术人员,要想用这种方式来编写指令,也是很困难的,而且很容易出 错。所以,在第一个处理器诞生之后不久,如何使指令的编写变得更容易,就提上了日程。 为了克服机器指令难以书写和理解的缺点,人们想到可以用一些容易理解和记忆的符号, 也就是助记符,来描述指令的功能和操作数的类型,这就产生了汇编语言(Assembly Language)。 这样,上面那些指令就可以写成: mov ax,3FH add bx,ax add cx,ax 对于那些有点英语基础的人来说,理解这些汇编语言指令并不困难。比如这句 mov ax,3FH 首先,mov 是 move 的简化形式,意思是“移动”或者“传送”。至于“ax”,很明显,指 的就是 AX 寄存器。传送指令需要两个操作数,分别是目的操作数和源操作数,它们之间要用 逗号隔开。在这里,AX 是目的操作数,源操作数是 3FH。汇编语言对指令的大小写没有特别 的要求。所以你完全可以这样写: MOV AX,3FH mov ax,3fh MOV ax,3FH mov AX,3fh 在很多高级语言中,如果要指示一个数是十六进制数,通常不采用在后面加“H”的做法, 第 3 章 汇编语言和汇编软件 而是为它添加一个“0x”前缀。像这样: mov ax,0x3f 你可能想问一下,为什么会是这样,为什么会是“0x”?答案是不知道,不知道在什么时 候,为什么就这样用了。这不得不让人怀疑,它肯定是一个非常随意的决定,并在以后形成了 惯例。如果你知道确切的答案,不妨写封电子邮件告诉我。注意,为了方便,我们将在本书中 采用这种形式。 在汇编语言中,使用十进制数是最自然的。因为 3FH 等于十进制数 63,所以你可以直接 这样写: mov ax,63 当然,如果你喜欢,也可以使用二进制数来这样写: mov ax,00111111B 一定要看清楚,在那串“0”和“1”的组合后面,跟着字母“B”,以表明它是一个二进制 数。 至于这句: add bx,ax 情况也是一样。add 的意思是把一个数和另一个数相加。在这里,是把 BX 寄存器的内容和 AX 寄存器的内容相加。相加的结果在 BX 中,但 AX 的内容并不改变。 像上面那样,用汇编语言提供的符号书写的文本,叫做汇编语言源程序。为此,你需要一 个字处理器软件,比如 Windows 记事本,来编辑这些内容。如图 3-1 所示,相信这些软件的使 用都是你已经非常熟悉的。 图 3-1 用 Windows 记事本来书写汇编语言源程序 有了汇编语言所提供的符号,这只是方便了你自己。相反地,对人类来说通俗易懂的东西, 处理器是无法识别的。所以,还需要将汇编语言源程序转换成机器指令,这个过程叫做编译 (Compile)。 编译肯定还需要依靠一个软件,称为编译器,或编译软件。因为如果需要人类自己去做,还费 这周折干嘛。另一方面,想想看,一个帮助人类生产软件的工具,自己居然也是一个软件,这很有 意思。 从字处理器软件生成的是汇编语言源程序文件。编译软件的任务是读取这些文件,将那些符号 转变成二进制形式的机器指令代码。它把这些机器代码存放到另一个文件中,叫做二进制文件或者 可执行文件,比如 Windows 里以“.exe”为扩展名的文件,就是可执行文件。当需要用处理器执行 的时候,再加载到内存里。 37 x86 汇编语言:从实模式到保护模式 3.2 NASM 编译器 3.2.1 从网上下载 NASM 安装程序 每种处理器都可能会有自己的汇编语言编译器,而对于同一款处理器来说,针对不同的平 台(比如 Windows 和 Linux),也会有不同版本的汇编语言编译器。 现存的汇编语言编译器有多种,用得比较多的有 MASM、FASM、TASM、AS86、GASM 等,每种汇编器都有自己的特色和局限性。特别是,有些还需要付费才能使用。不同于前面所 列举的这些,在本书中,我们用的是另一款叫做 NASM 的汇编语言编译器。 NASM 的全称是 Netwide Assembler,它是可免费使用的开源软件。下面是它的下载地址: http://sourceforge.net/projects/nasm/files/ 我写这本书的时候,用的是微软 Windows 操作系统。而且,这本书的读者最好也使用 Windows,否则这本书所配套的工具软件可能无法使用。随着后 PC 时代的来临,Windows 正 在逐渐受到平板电脑和手机的威胁,市场份额已经跌至 82%,创 20 年新低。但即使是在它占 据绝大部分市场的年代,也还有很多其他操作系统运行在 Intel 处理器上,而且不乏拥趸,比 如 DOS、OS/2、Linux 等。 理论上,不管用的是什么操作系统,Windows 也好,DOS 也好,Linux 也好,只要是针对 Intel 处理器开发的软件,底层的机器指令代码都是相同的,没有理由说某个软件只能在 Windows 操作系统上运行,而不能在 Linux 上运行。 事实上,仅仅具有一致的底层机器代码还远远不够。别忘了,这些代码要被处理器来依次 执行,首先需要加载到内存并实施重定位。在这种情况下,除了那些真正用于做事的机器指令 之外,软件还需要一些额外的信息来告诉操作系统,如何加载自己。更有甚者,Windows 会建 议为它开发的软件应当包含一些图标或者图片。这就是为什么每个 Windows 软件都会显示一 个图标的原因。 在这种情况下,因为每种操作系统都会根据自身的工作特点,定义自己所能识别的软件可 执行文件格式,而缺乏通用性,尽管在这些软件里,真正用于计算 5+6 的机器指令都一模一样。 作为一款汇编软件,没有理由只考虑 Windows 用户而忽略其他操作系统平台上的人们。所 以,如图 3-2 所示,在使用任意一款互联网浏览器转到上面指示的链接时,网页上将显示一些 目录(文件夹)。基本上,每个目录的名字都指示出一个操作系统平台的名字,但也有个别例 外。比如,“nasm documentation”目录包含的是各个版本的 NASM 开发与帮助文档。 因为我们需要在 Windows 上学习汇编语言编程技术,所以可单击“Win32 binaries”,意思 是针对 32 位 Windows 的二进制文件。在特定的场合,比如这里,“二进制文件”和“程序文 件”以及“可执行文件”是一个意思。 单击之后,在另一个网页里,会出现更多的目录,这些都是历史版本。毕竟一款软件需要 不停地改进,每改进一次,都会形成一个最新的版本。在这些页面里,可以单击列在最顶端的 那个版本,通常它是最新的版本。 这样,你就会来到最后一个页面,看起来可能像图 3-3 那样。 38 第 3 章 汇编语言和汇编软件 图 3-2 sourceforge 网站展示了针对各种操作系统平台所开发的 NASM 汇编器 图 3-3 选择最终所要下载的 NASM 安装文件 前面的网页可能都一模一样,唯独这个页面。原因在于我制作这幅图片的时候,最新的 NASM 版本是 2.07。等到你拿起这本书,也到网上下载时,可能会有更新的版本。 无 论 如 何 , 就 以 图 3-2 为 例 , 虽 然 两 个 文 件 下 载 哪 个 都 可 以 , 但 建 议 下 载 nasm-2.07-install.exe,原因在于这是一个安装程序,可以自动把它自己安装到你的计算机里。 对于现在的人们来说,下载是很简单的事情。即使你不学习汇编语言,也经常下载 MP3 歌曲、 高清电影、手机铃声或者手机游戏。 上面所说的,是如何从 NASM 的官网上下载它。当然,如果你愿意,也可以用搜索引擎, 比如百度,以关键词“NASM”来搜索它。剩下的事情,就是从搜索结果里找出一个合适的下载 链接。 39 x86 汇编语言:从实模式到保护模式 3.2.2 安装 NASM 编译器 一旦顺利将 NASM 安装文件下载到计算机里,我们就可以立即运行它来自动完成整个安装过 程。这个过程很简单,不过有两个步骤需要说明一下,第一个就是选择安装文件夹,如图 3-4 所示。 图 3-4 为在计算机上安装 NASM 准备文件夹 在这个安装环节的界面上,给出了默认的安装文件夹。你可以接受这个默认路径,也可以 单击“Browse”(浏览)来选择一个不同的路径。无论如何,你一定要记住这个路径,因为以 后还要用到它。 第二个步骤是安装选项。如图 3-5 所示,“Netwide Assembler 2.07”是必选的,因为你安装 的目的就是为了使用它。“Start Menu Shortcuts”用于在桌面的开始菜单里安装一个菜单项,这 个很有用,应当保持默认的勾选状态不变。至于“Desktop Icons”,则是用来在 Windows 桌面 上建立应用程序图标。如果你不希望它破坏了漂亮的桌面,可以取消对它的选中。 如果你的操作系统是 Windows 7,某些版本的 NASM 编译器安装程序会提示软件兼容性的 问题。在本书编写的时候,Windows 7 还是个新生事物,有些软件的更新还跟不上它的步伐。 不过,这不是什么了不得的事情,你应该忽略这个善意的提醒,NASM 会工作得很好。 图 3-5 NASM 编译器安装选项界面 3.2.3 下载配书源码和工具 和你已经司空见惯的其他 Windows 应用程序不同,NASM 在运行之后并不会显示一个图形用 户界面。相反地,它只能通过命令行使用。 比如,我们可以用 Windows 记事本编写一个汇编语言源程序,并把它保存到 NASM 工作 40 第 3 章 汇编语言和汇编软件 目录下(就是在前面安装 NASM 时所用的安装文件夹),文件名为 exam.asm。作为惯例,汇编 语言源程序文件的扩展名是“.asm”,不过,你当然可以使用其他扩展名。 一旦有了一个源程序,下一步就是将它的内容编译成机器代码。为此,可以从开始菜单里找 到“Netwide Assembler 2.07”,然后选择其下的“Nasm Shell”,这将打开一个命令行窗口。 接着,在命令行提示符后输入“nasm –f bin exam.asm –o exam.bin”并按 Enter 键,如图 3-6 所示。 图 3-6 在命令行方式下使用 NASM 编译一个汇编源程序 NASM 需要一系列参数才能正常工作。-f 参数的作用是指定输出文件的格式(Format)。这样, -f bin 就是要求 NASM 生成的文件只包含“纯二进制”的内容。换句话说,除了处理器能够识别的 机器代码外,别的任何东西都不包含。这样一来,因为缺少操作系统所需要的加载和重定位信息, 它就很难在 Windows、DOS 和 Linux 上作为一个普通的应用程序运行。不过,这正是本书所需要的。 紧接着,exam.asm 是源程序的文件名,它是将要被编译的对象。 -o 参数指定编译后输出(Output)的文件名。在这里,我们要求 NASM 生成输出文件 exam.bin。 用来编写汇编语言源程序,Windows 记事本并不是一个好工具。同时,在命令行编译源程序也 令很多人迷糊。毕竟,很多年轻的朋友都是用着 Windows 成长起来的,他们缺少在 DOS 和 UNIX 下工作的经历。 为了写这本书,我一直想找一个自己中意的汇编语言编辑软件。互联网是个大宝库,上面有很 多这样的工具软件,但大多都包含了太多的功能,用起来自然也很复杂。我的愿望很简单,能够方 便地书写汇编指令即可,同时还具有编译功能。毕竟我自己也不喜欢在命令行和图形用户界面之间 来回切换。 在经历了一系列的失望之后,我决定自己写一个,于是就有了 Nasmide 这个小程序。不过 遗憾的是,这个小程序却并非是用汇编语言书写的。 本书不配光盘,所以书中的所有源代码连同我自己写的小工具都只有通过网络下载方能使 用。这是一个压缩文件,名字叫 booktool.zip,可以通过下面这个链接来下载它: http://download.csdn.net/detail/sholber/4561356 如果此链接不可用,可以到电子工业出版社的网站上下载,或者直接给我写信,我会以最 快的时间给予支持。 下载 booktool.zip 之后,需要先把它解压缩到一个文件夹里。文件并不大,所以解压缩的 过程很快。完成之后,打开这个文件夹,里面的内容看起来应该像图 3-7 所示的那个样子: 41 x86 汇编语言:从实模式到保护模式 其中,像 c05、c06、c07,…这些文件夹,包含的是各章的汇编源代码;nasmide.exe 就是 我们现在所说的汇编小工具。 图 3-7 解压缩之后的配书源代码及工具 3.2.4 用 Nasmide 体验代码的书写和编译过程 现在,你可以双击 nasmide.exe 来运行它。启动之后,如图 3-8 所示,Nasmide 的软件界面 分为三个部分。顶端是菜单,可以用来新建文件、打开文件、保存文件或者调用 NASM 来编 译当前文档。 图 3-8 Nasmide 程序的基本界面 中间最大的空白区域是编辑区,用来书写汇编语言源代码。 窗口底部那个窄的区域是消息显示区。在编译当前文档时,不管是编译成功,还是发现了 文档中的错误,都会显示在这里。 基本上,你现在已经可以在 Nasmide 里书写汇编语句了。不过,在此之前你最好先做一件 事情。Nasmide 只是一个文本编辑工具,它自己没有编译能力。不过,它可以在后台调用 NASM 来编译当前文档,前提是它必须知道 NASM 安装在什么地方。 为此,你需要在菜单上选择“选项”→“编译环境设置”来打开如图 3-9 所示的对话框。 如图 3-9 所示,这个路径就是你在前面安装 NASM 时,指定的安装路径,包括可执行文件 名 nasm.exe。 42 第 3 章 汇编语言和汇编软件 不同于其他汇编语言编译器,NASM 最让我喜欢的一个特点是允许在源程序中只包含指令, 如图 3-10 所示。用过微软公司 MASM 的人都知道,在真正开始书写汇编指令前,先要穿靴戴帽, 在源程序中定义很多东西,比如代码段和数据段等,弄了半天,实际上连一条指令还没开始写呢。 图 3-9 为 Nasmide 指定 NASM 编译器所在路径 图 3-10 NASM 允许在源文件中只包含指令 如图 3-10 所示,用 Nasmide 程序编辑源程序时,它会自动在每一行内容的左边显示行号。 对于初学者来说,一开始可能会误以为行号也会出现在源程序中。不要误会,行号并非源程序 的一部分,当保存源程序的时候,也不会出现在文件内容中。 让 Nasmide 显示行号,这是一个聪明的决定。一方面,我在书中讲解源程序时,可以说第 几行到第几行是做什么用的;另一方面,当编译源程序的时候,如果发现了错误,错误信息中 也会说明是第几行有错。这样,因为 Nasmide 显示了行号,这就很容易快速找到出错的那一行。 在汇编源程序中,可以为每一行添加注释。注释的作用是说明某条指令或者某个符号的含 义和作用。注释也是源程序的组成部分,但在编译的时候会被编译器忽略。如图 3-10 所示, 为了告诉编译器注释是从哪里开始的,注释需要以英文字母的分号“;”开始。 当源程序书写完毕之后,就可以进行编译了,方法是在 Nasmide 中选择菜单“文件”→“编译 本文档”。这时,Nasmide 将会在后台调用 NASM 来完成整个编译过程,不需要你额外操心。如图 3-10所示,即使只有三行的程序也能通过编译。编译完成后,会在窗口底部显示一条消息。 3.2.5 用 HexView 观察编译后的机器代码 编译成功完成之后,Nasmide 会在编辑窗口的底部显示相应的消息,同时显示了源文件名 43 x86 汇编语言:从实模式到保护模式 称和编译之后的文件名称(含路径)。 尽管我们强调源文件和编译之后的文件具有不同的内容,但如果能用工具看一看,相信印 象更为深刻。在前面下载的配书源码和工具里,有一个名为 HexView 的小程序,可以实现这 个愿望。HexView 用于打开任意一个文件,以十六进制的形式从头到尾显示它每个字节的内容。 双击启动 HexView,然后选择菜单“文件”→“打开文件以显示”,在文件选择对话框里 找到你在 3.2.4 节里编辑并保存的源程序文件。 如图 3-11 所示,文件选择之后,HexView 程序将以十六进制的形式显示刚刚选择的文件。 图 3-11 用 HexView 程序显示源程序文件的内容 在 HexView 中,文件的内容以十六进制的形式显示在窗口中间,以 16 个字节为一行,字 节之间以空白分隔,所以看起来很稀疏。如果文件较大的话,则会分成很多行。 作为对照,每个字节还会以字符的形式显示在窗口右侧,如果它确实可显示为一个字符的 话。如果该字节并非一个可以显示的字符,则显示一个替代的字符“.”。因为源程序中还有汉 字注释,所以,如果细心的话,从图中可以算出每个汉字的编码是两个字节,比如“将”字的 编码是 0xBD 0xAB。由于 HexView 以单字节的形式来显示每个字符,所以无法显示汉字。 左边的数字,是每一行第一个字节相对于文件头部的距离(偏移),也是以十六进制 数 显示的。 源程序很长,但是,编译之后的机器指令却很简短。 如图 3-12 所示,编译之后的文件只有 7 个字节,这才是处理器可以识别并执行的机器指令。 图 3-12 用 HexView 显示编译之后的文件内容 44 第 3 章 汇编语言和汇编软件 本章习题 如图 3-11 所示,请问: (1)源程序共有 3 行,每一行第一个字符的偏移分别是多少? (2)该源程序文件的大小是多少字节? 45 第 4 章 虚拟机的安装和使用 4.1 计算机的启动过程 4.1.1 如何将编译好的程序提交给处理器 对于绝大多数编译好的程序来说,要想得到处理器的光顾,让它执行一下,必须借助于操作系 统。就拿 Windows 来说,它为你显示每个程序的图标,允许你双击来运行它。在内部你看不见的层 面上,它必须给将要运行的程序分配空闲的内存空间,并在适当的时候将程序提交给处理器执行。 每种操作系统都对它所管理的程序提出了种种格式上的要求。比如,它要求编译好的程序必须 在文件的开始部分包含编译日期,是针对哪种操作系统编译的,程序的版本,第一条指令从哪里开 始,数据段从哪里开始、有多长,代码段从哪里开始、有多长,等等,Windows 甚至建议你在文件 中包含至少一个用于显示的图标。如果你不按它的要求来,它也不会给你面子,并直截了当地弹出 一个对话框,如图 4-1 所示,告诉你它不准备,也没办法将你的程序提交给处理器。 图 4-1 每种操作系统都会定义它自己的可执行文件格式 每种编译器都有能力针对不同的操作系统来生成不同格式的二进制文件,程序员所要做的,就 是在源程序中加入一些相关的信息,比如指定每个段的开始和结束,并在编译时指定适当的参数。 如果你对此感兴趣,可以阅读 NASM 文档。这是一个 PDF 文件,在安装 NASM 的时候,它也会被 安装。 在特定的操作系统上开发软件肯定不是一件容易的事情。但换个角度考虑一下,操作系统也是 一个需要在处理器上运行的软件,只不过比起一般的程序而言,体积更为庞大,功能更为复杂而已。 如果我们能绕过它,或者代替它,让计算机一开机的时候直接执行我们自己的软件,岂不更简单? 好,这个主意完全可行。那就让我们慢慢开始吧。 4.1.2 计算机的加电和复位 在处理器众多的引脚中,有一个是 RESET,用于接受复位信号。每当处理器加电,或者 RESET 引脚的电平由低变高时①,处理器都会执行一个硬件初始化,以及一个可选的内部自测 ① 比如,当你按下主机箱面板上的 RESET 按钮时,就会导致 RESET 引脚电平的变化,从而使计算机热启 x86 汇编语言:从实模式到保护模式 试(Build-in Self-Test,BIST),然后将内部所有寄存器的内容初始到一个预置的状态。 比如,对于 Intel 8086 来说,复位将使代码段寄存器(CS)的内容为 0xFFFF,其他所有寄 存器的内容都为 0x0000,包括指令指针寄存器(IP)。8086 之后的处理器并未延续这种设计, 但毫无疑问,无论怎么设计,都是有目的的。 处理器的主要功能是取指令和执行指令,加电或者复位之后,它就会立刻尝试去做这样的 工作。不过,在这个时候,内存中还没有任何有意义的指令和数据,它该怎么办呢? 在揭开谜底之前,我们先来看看内存的特点。 为了节约成本,并提高容量和集成度,在内存中,每个比特的存储都是靠一个极其微小的 晶体管,外加一个同样极其微小的电容来完成的。可以想象,这样微小的电容,其泄漏电荷的 速度当然也非常快。所以,个人计算机中使用的内存需要定期补充电荷,这称为刷新,所以这 种存储器也称为动态随机访问存储器(Dynamic Random Access Memory,DRAM)。随机访问 的意思是,访问任何一个内存单元的速度和它的位置(地址)无关。举个例子来说,从头至尾 在一盘录音带上找某首歌曲,它越靠前,找到它所花的时间就越短。但内存就不一样,读写地 址为 0x00001 的内存单元,和读写地址为 0xFFFF0 的内存单元,所需要的时间是一样的。 在内存刷新期间,处理器将无法访问它。这还不是最麻烦的,最麻烦的是,在它断电之后, 所有保存的内容都会统统消失。所以,每当处理器加电之后,它无法从内存中取得任何指令。 4.1.3 基本输入输出系统 Intel 8086 可以访问 1MB 的内存空间,地址范围为 0x00000 到 0xFFFFF。出于各方面的考 虑,计算机系统的设计者将这 1MB 的内存空间从物理上分为几个部分。 8086 有 20 根地址线,但并非全都用来访问 DRAM,也就是内存条。事实上,这些地址 线经过分配,大部分用于访问 DRAM,剩余的部分给了只读存储器 ROM 和外围的板卡,如 图 4-2 所示。 图 4-2 8086 系统的内存空间分配 与 DRAM 不同,只读存储器(Read Only Memory,ROM)不需要刷新,它的内容是预先 写入的,即使掉电也不会消失,但也很难改变。这个特点很有用,比如,可以将一些程序指令 动。 50 第 5 章 编写主引导扇区代码 固化在 ROM 中,使处理器在每次加电时都自动执行。处理器醒来后不能饿着,这是很重要的。 在以 Intel 8086 为处理器的系统中,ROM 占据着整个内存空间顶端的 64KB,物理地址范 围是 0xF0000~0xFFFFF,里面固化了开机时要执行的指令;DRAM 占据着较低端的 640KB, 地址范围是 0x00000~0x9FFFF;中间还有一部分,分给了其他外围设备,这个以后再说。因 为 8086 加电或者复位时,CS=0xFFFF,IP=0x0000,所以,它取的第一条指令位于物理地址 0xFFFF0,正好位于 ROM 中,那里固化了开机时需要执行的指令。 处理器取指令执行的自然顺序是从内存的低地址往高低地址推进。如果从 0xFFFF0 开始执行, 这个位置离 1MB 内存的顶端(物理地址 0xFFFFF)只有 16 个字节的长度,一旦 IP 寄存器的值超 过 0x000F,比如 IP=0x0011,那么,它与 CS 一起形成的物理地址将因为溢出而变成 0x00001,这 将回绕到 1MB 内存的最低端。 所以,ROM 中位于物理地址 0xFFFF0 的地方,通常是一个跳转指令,它通过改变 CS 和 IP 的 内容,使处理器从 ROM 中的较低地址处开始取指令执行。在 NASM 汇编语言里,一个典型的跳转 指令像这样: jmp 0xf000:0xe05b 在这里,“jmp”是跳转(jump)的简化形式;0xf000 是要跳转到的段地址,用来改变 CS 寄存器的内容;0xe05b 是目标代码段内的偏移地址,用来改变 IP 寄存器的内容。一旦执行这 条指令,处理器将开始从指定的“段: 偏移”处开始重新取指令执行。 到了本书第 5 章我们就能接触跳转指令了,现在,我们只需要知道,指令的执行并非总是顺序 的,有时候不得不根据某些条件来选择执行哪些指令,不执行哪些指令。这个时候,跳转指令是很 有用的。 这块 ROM 芯片中的内容包括很多部分,主要是进行硬件的诊断、检测和初始化。所谓初始化, 就是让硬件处于一个正常的、默认的工作状态。最后,它还负责提供一套软件例程,让人们在不必了 解硬件细节的情况下从外围设备(比如键盘)获取输入数据,或者向外围设备(比如显示器)输出数 据。设备当然是很多的,所以这块 ROM 芯片只针对那些最基本的、对于使用计算机而言最重要的设 备,而它所提供的软件例程,也只包含最基本、最常规的功能。正因为如此,这块芯片又叫基本输入 输出系统(Base Input & Output System,BIOS)ROM。在读者缺乏基础知识的情况下讲述 ROM-BIOS 的工作只会越讲越糊涂,所以这些知识将会分散在各个章节里予以讲解。 ROM-BIOS 的容量是有限的,当它完成自己的使命后,最后所要做的,就是从辅助存储设备读 取指令数据,然后转到那里开始执行。基本上,这相当于接力赛中的交接棒。 4.1.4 硬盘及其工作原理 历史上,有多种辅助存储设备,比如软盘、光盘、硬盘、U 盘等,相对于内存,它们就是人们 常说的“外存”,即外存储器(设备)。 从软盘(Floppy Disk)启动计算机,这已经是过去的事了。软盘的尺寸比烟盒稍大一点,但是 比较薄,采用塑料作为基片,上面是一层磁性物质,可以用来记录二进制位。这种塑料介质比较柔 软,所以称为软盘。 在数据记录原理上和软盘很相似的设备是硬盘(Hard Disk,HDD),而且它们几乎是同一 个时代的产物。但是,与软盘不同,硬盘是多盘片、密封、高转速的,采用铝合金作为基片, 并在表面涂上磁性物质来记录二进制位。这就使得它的盘片具有较高的硬度,故称为硬盘。 如图 4-3 所示,这是一块被拆开的硬盘,中间是用于记录数据的铝合金盘片,固定在中心 51 x86 汇编语言:从实模式到保护模式 的轴上,由一个高速旋转的马达驱动。附着在盘片表面的扁平锥状物,就是用于在盘片上读写 数据的磁头。 为了进一步搞清楚硬盘的内部构造,图 4-4 给出了更为详细的图示。 图 4-3 一块被拆开密封盖的硬盘 图 4-4 硬盘的结构示意图 硬盘可以只有一个盘片(这称为单碟),也可能有好几个盘片。但无论如何,它们都串在同一 个轴上,由电动机带动着一起高速旋转。一般来说,转速可以达到每分钟 3600 转或者 7200 转,有 的能达到一万多转,这个参数就是我们常说的“转/分钟”(Round Per Minute,RPM)。 每个盘片都有两个磁头(Head),上面一个,下面一个,所以经常用磁头来指代盘面。磁头都 有编号,第 1 个盘片,上面的磁头编号为 0,下面的磁头编号为 1;第 2 个盘片,上面的磁头编号 为 2,下面的磁头编号为 3,依次类推。 每个磁头不是单独移动的。相反,它们都通过磁头臂固定在同一个支架上,由步进电动机带动 着一起在盘片的中心和边缘之间来回移动。也就是说,它们是同进退的。步进电动机由脉冲驱动, 每次可以旋转一个固定的角度,即可以步进一次。 可以想象,当盘片高速旋转时,磁头每步进一次,都会从它所在的位置开始,绕着圆心“画” 出一个看不见的圆圈,这就是磁道(Track)。磁道是数据记录的轨迹。因为所有磁头都是联动的, 故每个盘面上的同一条磁道又可以形成一个虚拟的圆柱,称为柱面(Cylinder)。 磁道,或者柱面,也要编号。编号是从盘面最边缘的那条磁道开始,向着圆心的方向,从 0 开 始编号。 柱面是一个用来优化数据读写的概念。初看起来,用硬盘来记录数据时,应该先将一个盘面填 满后,再填写另一个盘面。实际上,移动磁头是一个机械动作,看似很快,但对处理器来说,却很 漫长,这就是寻道时间。为了加速数据在硬盘上的读写,最好的办法就是尽量不移动磁头。这样, 当 0 面的磁道不足以容纳要写入的数据时,应当把剩余的部分写在 1 面的同一磁道上。如果还写不 下,那就继续把剩余的部分写在 2 面的同一磁道上。换句话说,在硬盘上,数据的访问是以柱面来 组织的。 实际上,磁道还不是硬盘数据读写的最小单位,磁道还要进一步划分为扇区(Sector)。磁道很 窄,也看不见,但在想象中,它仍呈带状,占有一定的宽度。将它划分许多分段之后,每一部分都 呈扇形,这就是扇区的由来。 每条磁道能够划分为几个扇区,取决于磁盘的制造者,但通常为 63 个。而且,每个扇区都有 一个编号,与磁头和磁道不同,扇区的编号是从 1 开始的。 扇区与扇区之间以间隙(空白)间隔开来,每个扇区以扇区头开始,然后是 512 个字节的 52 第 5 章 编写主引导扇区代码 数据区。扇区头包含了每个扇区自己的信息,主要有本扇区的磁道号、磁头号和扇区号,用来 供硬盘定位机构使用。现代的硬盘还会在扇区头部包括一个指示扇区是否健康的标志,以及用 来替换该扇区的扇区地址。用于替换扇区的,是一些保留和隐藏的磁道。 4.1.5 一切从主引导扇区开始 尽管我们使用硬盘的历史很长,但它一直没能退出舞台,这主要是因为它总能通过不断提高自 己的容量来打败那些竞争者。20 世纪 90 年代初,40MB 的硬盘算是常见的,能拥有 200MB 的硬盘 很让人羡慕。看看现在,500GB 的硬盘也不算稀罕,而且价钱也很便宜。 前面说到,当 ROM-BIOS 完成自己的使命之前,最后要做的一件事是从外存储设备读取更多 的指令来交给处理器执行。现实的情况是,绝大多数时候,对于 ROM-BIOS 来说,硬盘都是首选 的外存储设备。 硬盘的第一个扇区是 0 面 0 道 1 扇区,或者说是 0 头 0 柱 1 扇区,这个扇区称为主引导扇区。 如果计算机的设置是从硬盘启动,那么,ROM-BIOS 将读取硬盘主引导扇区的内容,将它加载到内 存地址 0x0000:0x7c00 处(也就是物理地址 0x07C00),然后用一个 jmp 指令跳到那里接着执行: jmp 0x0000:0x7c00 为什么偏偏是 0x7c00 这个地方?还不太清楚。反正当初定下这个方案的家伙已经被人说了很 多坏话,我也就不准备再多说什么了。 通常,主引导扇区的功能是继续从硬盘的其他部分读取更多的内容加以执行。像 Windows 这样 的操作系统,就是采用这种接力的方法一步一步把自己运行起来的。 说到这里,我们可以想象,如果我们把自己编译好的程序写到主引导扇区,不也能够让处理器 执行吗? 对于这种想法,我有一个好消息和一个坏消息要告诉你。 好消息是,这是可以的,而且这几乎是在不依赖操作系统的情况下,让我们的程序可以执行的 唯一方法。 不过,坏消息是,如果你改写了硬盘的主引导扇区,那么,Windows 和 Linux,以及任何你正 在使用的操作系统都会瘫痪,无法启动了。 那么,我们该怎么办呢?答案是在你现有的计算机上,再虚拟出一台计算机来。 4.2 创建和使用虚拟机 4.2.1 别害怕,虚拟机是软件 对于第一次听说虚拟机(Virtual Machine,VM)的人来说,可能以为还要再花钱买一台计算机, 这恐怕是他们最担心的。所谓虚拟机,就是在你的计算机上再虚拟出另一台计算机来。这台虚拟出来 的计算机,和真正的计算机一样,可以启动,可以关闭,还可以安装操作系统、安装和运行各种各样 的软件,或者访问网络。总之,你在真实的计算机上能做什么,在它里面一样可以那么做。使用虚拟 机,你会发现,在 Windows 操作系统里,居然又可以拥有另一套 Windows。然而本质上,它只是运 53 x86 汇编语言:从实模式到保护模式 行在物理计算机上的一个软件程序。 如图 4-5 所示,整个大的背景,是 Windows 7 的桌面,它安装在一台真实的计算机上。图中的 小窗口,正是虚拟机,运行的是 Windows Server 2003。像这样,我们就得到了两台“计算机”,而 且它们都可以操作。 虚拟机仅仅是一个软件,运行在各种主流的操作系统上。它以自己运行的真实计算机为模板, 虚拟出另一套处理器、内存和外围设备来。它的处理能力,完全来自于背后那台真实的计算机。 尤其重要的是,针对某种真实处理器所写的任何指令代码,都可以正确无误地在该处理器的虚 拟机上执行。实际上,这也是虚拟机具有广泛应用价值的原因所在。 图 4-5 虚拟机的实例 在过去的若干年里,虚拟机得到了广泛应用。为了研制防病毒软件、测试最新的操作系 统或者软件产品,软件公司通常需要多台用于做实验的计算机。采用虚拟机,就可以避免反 复重装软件系统的麻烦,当这些软件系统崩溃时,崩溃的只是虚拟机,而真实的物理计算机 丝毫不受影响。 利用虚拟机来教学,本书不是第一个,国内外都流行这种教学方式。虚拟机利用软件来模拟完 整的计算机系统,无须添加任何新的设备,而且与主计算机系统是隔离的,在虚拟机上的任何操作 都不会影响到物理计算机上的操作系统和软件,这对拥有大量计算机的培训机构来说,可以极大地 节省维护上的成本。 4.2.2 下载 Oracle VM VirtualBox 主流的虚拟机软件包括 VMWare、Virtual PC 和 VirtualBox,但只有 VirtualBox 是开源和免费 的。 要使用 VirtualBox,首先必须从网上下载并安装它。这里是它的主页: https://www.virtualbox.org/ 通过这个主页,你可以找到最新的版本并下载它。为了方便,下面给出下载页面的链接: https://www.virtualbox.org/wiki/Downloads 这个链接将带你到达类似于图 4-6 所示的这个页面。通常,我们应该在运行着 Windows 的主机上 安装使用 VirtualBox,所以应当选择“VirtualBox 4.1.6 for Windows hosts”。当然,当本书出版的时候, 54 第 5 章 编写主引导扇区代码 版本号可能已经不是 4.1.6 了,这个数字无关紧要,要选择最新的版本。 4.2.3 安装 Oracle VM VirtualBox 相对于前面安装的 NASM,VirtualBox 安装程序稍大些,4.1.6 版本有 90MB。安装过程也 很简单,唯一需要说明的是软件特性的选择和安装路径,如图 4-7 所示。 图 4-6 VirtualBox 下载页面 图 4-7 VirtualBox 安装选项 在这里,“VirtualBox Application”是虚拟机的主体部分,当然是必选的。通用串行总线(Universal Serial Bus,USB)控制器也是必须安装的,我们可能要针对 USB 设备编写汇编语言程序,没有这 个虚拟的“芯片”可不行。所以,应当选择完全安装“VirtualBox USB Support”(VirtualBox USB 支持)。 “VirtualBox Networking”特性用于使虚拟机提供对网络的支持。如果仅仅是通过本书学习汇编 语言,不干别的,这个特性可以不用安装。但如果你想在虚拟机里安装其他操作系统,探索虚拟机 的功能,还想在虚拟机里上网,也可以选择安装。 除了手工操作之外,VirtualBox 允许通过编程来完全控制虚拟机的行为。就像所有在 Windows 上运行的软件都可以调用操作系统提供的例程和服务一样,VirtualBox 也提供这样的手段。但是, 不像 C++这样的编程语言,Python 这样的脚本语言接口并没有内置于虚拟机中。所以,如果你想用 55 x86 汇编语言:从实模式到保护模式 Python 脚本语言来访问虚拟机,那么,就应当选择安装“VirtualBox Python 2.x Support”。当然,对 于本书的读者来说,可以选择不安装这个特性。 4.2.4 创建一台虚拟 PC 安装之后,第一次启动时的 VirtualBox 如图 4-8 所示。 图 4-8 第一次启动时的 VirtualBox 你可能以为这个界面就是虚拟出来的计算机,其实不是。 这只是 VirtualBox 的控制台。要知道,VirtualBox 可以虚拟出多台计算机,而不仅仅是一台。 所以,现在的任务是不花一分钱,不用走出家门,来安装一台“全新的计算机”。 要创建一台新的虚拟计算机,应该单击控制台界面上的“新建”按钮,或者选择菜单“控制” →“新建”。这时,会出现“欢迎使用新建虚拟电脑向导”,此时可单击“下一步”按钮。 如图 4-9 所示,紧接着,向导程序将询问这台计算机的名称和将要采用的操作系统。 图 4-9 填写计算机名称,并选择要在这台计算机上安装的操作系统 正如向导界面上的文字所描述的那样,计算机名称用来唯一地标识一台虚拟计算机。因为我们 安装虚拟机的目的是学习汇编语言,那么,我们可以为这台计算机起个名字,叫“LEARN-ASM”。 事实上,你可以取别的名字,只要你喜欢,这没有什么关系。 56 第 5 章 编写主引导扇区代码 操作系统类型和版本的选择部分容易让人产生误解,以为 VirtualBox 会根据你的选择来安装一 个现成的操作系统。实际上,这不可能。让你选择操作系统的唯一目的,是想根据你的选择,在后 面的步骤中为你提供合理的硬件配置,比如内存容量和硬盘大小等。实际上,我们不准备安装任何 操作系统,所以在“操作系统”一栏里选择“Other”(其他);在“版本”一栏里选择“Other/Unknown” (其他/未知)。 一旦做出这种选择之后,紧接着,在下一步里,向导程序会结合真实主机的内存容量,以及你 所选择的操作系统,来给出一个建议的内存容量配置。 如图 4-10 所示,在我的计算机上,它给出的建议值是 64MB 内存,因为我的主机上只有 1GB 的物理内存容量(从图中就可以看出)。当然,它允许你拖动滑块来调整这个数值。 调整好虚拟机的内存容量后,继续下一步。 图 4-10 调整虚拟计算机的内存容量 和真实的计算机一样,虚拟机也需要一个或几个辅助存储器(磁盘、光盘、U 盘等)才能工作。 不过,为它配备的并非真正的盘片,而是一个特殊的文件,故称为虚拟盘。这样,当一个软件程序 在虚拟机里读写硬盘或者光盘时,虚拟机将把它转换成对文件的操作,而软件程序还以为自己真的 是在读写物理盘片。在需要的时候随时创建,不需要时可以随时删除,这真是非常神奇的磁盘。 现在,当调整好虚拟机的内存容量后,下一步,将要为虚拟机配备虚拟盘。如图 4-11 所示,因 为在正常情况下,所有的计算机都习惯从硬盘启动,故这个界面默认的是选择一个虚拟硬盘(图 4-11)。 图 4-11 为虚拟机配备虚拟盘 在这个界面上,你有两种选择,创建新的虚拟硬盘,或者使用现有的虚拟硬盘。基本上,你采 57 x86 汇编语言:从实模式到保护模式 用哪种方式都可以。注意,那个复选框“Start-up Disk”用于指定是否从该硬盘启动。如果选择了 它,那么,ROM-BIOS 程序将在开机自检后从这个硬盘里读取主引导扇区的内容。 如果你要创建新的虚拟硬盘,只需要单击“下一步”按钮。 58 第 5 章 编写主引导扇区代码 除此之外,你还有另一个选择。前面你已经从网上下载了与本书配套的源码和工具,那是 个压缩文件。解压之后,里面有一个现成的虚拟硬盘文件,文件名是 LEECHUNG.VHD,这是 给你额外准备的,而且经过了测试,可以在你无法创建虚拟硬盘的时候派上用场。要选用这个 虚拟硬盘,可以选择“使用现有的虚拟硬盘”,然后单击下拉列表框右边的小图标,在弹出的 文件选择对话框里找到 LEECHUNG.VHD,并选择它。 当然,如果你选择的是“创建新的虚拟硬盘”,那后面的事情就要麻烦得多,一旦进入下一个 步骤,向导程序将询问你想创建什么类型的虚拟硬盘,如图 4-12 所示。 图 4-12 虚拟硬盘类型选择 正如前面所说的,市面上有好几种流行的虚拟机软件,而每种虚拟机软件都企图制定自己的虚 拟硬盘标准。因为虚拟硬盘实际是一个文件,所以,所谓虚拟硬盘标准,实际上就是该文件的格式。 正是因为这样,虚拟硬盘类型说白了就是你准备采用哪家的虚拟硬盘文件格式。 因为虚拟硬盘实际上是一个文件,所以,通常来说,它的格式体现在它的文件扩展名上。比如 上面的 LEECHUNG.VHD,采用的就是微软公司的 VHD 虚拟硬盘规范。VHD 规范最早起源于 Connectix 公司的虚拟机软件 Connectix Virtual PC,2003 年,微软公司收购了它并改名为 Microsoft Virtual PC。2006 年,微软公司正式发布了 VHD 虚拟硬盘格式规范。在本书配套的源代码和工具包 里,有该规范的文档。 VDI 是 VirtualBox 自己的虚拟硬盘规范,VMDK 是 VMWare 的虚拟硬盘规范。采用哪个公司、 哪个虚拟机软件的虚拟硬盘格式,对于普通的应用来说,这没什么关系,它们都能很好地工作。但 是,对于本书和本书配套的工具来说,你必须选择“VHD(Virtual Hard Disk)”。具体原因,我们 将在下一节讲述。 事实上,即使是 VHD,也分为两种类型:固定尺寸的和动态分配的。一个固定尺寸的 VHD, 它对应的文件尺寸和该虚拟硬盘的容量是相同的,或者说是一次性分配够了的。比如,一个 2GB 的 VHD 虚拟硬盘,它对应的文件大小也是 2GB。 与此相反,一个动态分配的 VHD,它的文件尺寸是根据需要不断增长的,它的大小等于实际 写入该虚拟硬盘上的数据量。 如图 4-13 所示,本书以及本书配套的工具仅支持固定尺寸的 VHD,所以你应该在进入这个界 面之后选择“Fixed size”。 59 x86 汇编语言:从实模式到保护模式 图 4-13 选择 VHD 硬盘类型 在选择使用 VHD 之后,还要指定该 VHD 的容量。如图 4-14 所示,你可以拖动滑块,在 4MB 和 2TB 之间随意指定一个容量。注意,1TB = 1024GB。 图 4-14 指定 VHD 的容量 不得不提醒你的是,应当指定 50MB 以上的硬盘大小,这是本书对你的要求。不过,也不需要 太大,毕竟你的物理硬盘空间也一定很紧张。 除了指定虚拟硬盘的容量,另一个值得特别注意的问题是该虚拟硬盘的创建位置。默认情况下, 它会被放在 Windows 用户文件夹下,而且对于初学者来说很不容易找到。其实,把它创建在配书工 具所在的文件夹里是最方便的,因为我们以后要反复对它进行写入操作。为此,如图 4-14 所示, 请在“位置”一栏,单击文本框右边的小图标,来选择一个容易找到的位置,比如配书工具所在的 文件夹。 以上就是创建一台虚拟机要经历的步骤。当结束向导程序时,刚刚创建的虚拟机 LEARN-ASM 就会显示在 VirtualBox 控制台里,如图 4-15 所示。基本上,你现在就可以单击控制台界面上的“开 始”来启动这台虚拟机。但是,别忙,你的虚拟硬盘里还没有东西呢。 60 第 5 章 编写主引导扇区代码 图 4-15 通过向导程序创建的 LEARN-ASM 虚拟机 4.2.5 虚拟硬盘简介 坦白地说,之所以要采用固定尺寸的 VHD 虚拟硬盘,是因为其简单性。我们知道,虚拟硬盘 实际上是一个文件。固定尺寸的 VHD 虚拟硬盘是一个具有“.vhd”扩展名的文件,它仅包括两个 部分,前面是数据区,用来模拟实际的硬盘空间,后面跟着一个 512 字节的结尾(2004 年前的规范 里只有 511 字节)。 要访问硬盘,运行中的程序必须至少向硬盘控制器提供 4 个参数,分别是磁头号、磁道号、扇 区号,以及访问意图(是读还是写)。 硬盘的读写是以扇区为最小单位的。所以,无论什么时候,要从硬盘读数据,或者向硬盘写数 据,至少得是 1 个扇区。 你可能想,我只有 2 字节的数据,不足以填满一个扇区,怎么办呢? 这是你自己的事。你可以用无意义的废数字来填充,凑够一个扇区的长度,然后写入。读取的 时候也是这样,你需要自己跟踪和把握从扇区里读到的数据,哪些是你真正想要的。换句话说,硬 盘只是机械和电子的组合,它不会关心你都写了些什么。要是手机像人类一样智能,它一定会在坏 人使用它的时候无法开机。 在 VHD 规范里,每个扇区是 512 字节。VHD 文件一开始的 512 字节,就对应着物理硬盘的 0 面 0 道 1 扇区。然后,VHD 文件的第二个 512 字节,对应着 0 面 0 道 2 扇区,后面的依次类推, 一直对应到 0 面 0 道 n 扇区。这里,n 等于每磁道的扇区数。 再往后,因为硬盘的访问是按柱面进行的,所以,在 VHD 文件中,紧接着前面的数据块,下 一个数据块对应的是 1 面 0 道 1 扇区,就这样一直往后排列,当把第一个柱面全部对应完后,再从 第二个柱面开始对应。 如图 4-16 所示,为了标志一个文件是 VHD 格式的虚拟硬盘,并为使用它的虚拟机提供该硬盘 的参数,在 VHD 文件的结尾,包含了 512 字节的格式信息。为了观察这些信息,我们使用了前面 已经介绍过的配书工具 HexView。 如图 4-16 所示,文件尾信息是以一个字符串“conectix”开始的。这个标志用来告诉试图打开 它的虚拟机,这的确是一个合法的 VHD 文件。该标志称为 VHD 创建者标识,就是说,该公司 61 x86 汇编语言:从实模式到保护模式 (conectix)创建了 VHD 文件格式的最初标准。 图 4-16 VHD 文件的格式信息 从这个标志开始,后面的数据包含了诸如文件的创建日期、VHD 的版本、创建该文件的应用 程序名称和版本、创建该文件的应用程序所属的操作系统、该虚拟硬盘的参数(磁头数、每面磁道 数、每磁道扇区数)、VHD 类型(固定尺寸还是动态增长)、虚拟硬盘容量等。 说到这里,也许你已经明白我为什么要在书中使用固定尺寸的 VHD。是的,因为它简单。为 了学习汇编语言,我们不得不在硬盘上直接写入程序。因为 VHD 格式简单,所以我只花了很少的 时间就开发了一个虚拟硬盘写入程序,作为配书工具让大家使用,这就是下一节将要介绍的 FixVhdWr。 至于为什么要使用 VirtualBox 虚拟机,是因为它支持 VHD,而且是免费的。先前版本的 VirtualBox 可以识别 VHD,但不支持创建新的 VHD,尽管微软公司很早就公开了 VHD 规范。好消 息是现在的 VirtualBox 也可以创建 VHD 了。 4.2.6 练习使用 FixVhdWr 工具向虚拟硬盘写数据 通常,VHD 是由虚拟机 VirtualBox 使用的。应用程序像往常一样,直接针对硬盘进行操作, 而在底层,虚拟机将这些硬件访问转化为对文件的读写。 为了在处理器加电或者复位之后能够执行我们写的程序,势必要将这些程序写到硬盘的主引导 扇区里,也就是 0 面 0 道 1 扇区,即使是在虚拟机工作环境中,也是这样。 为了做到这一点,需要一个专门针对虚拟硬盘进行读写的工具。我自己写了一个,就在配书源 代码和工具里,名叫 FixVhdWr。 FixVhdWr 只针对固定尺寸的 VHD。当它启动之后,首先需要选择要读写的 VHD 文件。如 图 4-17 所示,一旦这是个合法的 VHD 文件,它将读取该文件的结尾,并显示该虚拟硬盘的信息。 注意,因为 FixVhdWr 只针对固定尺寸的 VHD,所以,如果它检测到该 VHD 是一个动态虚拟 硬盘,则“下一步”按钮处于禁止状态。 第二步是选择要写入虚拟硬盘的数据文件。毕竟,在任何操作系统中,数据都是以文件的方式 组织的,如图 4-18 所示。 62 第 5 章 编写主引导扇区代码 图 4-17 打开 VHD 文件并显示该虚拟硬盘的信息 图 4-18 选择要写入虚拟硬盘的文件 最后一个界面,是执行写入操作,如图 4-19 所示,你应该选择第一种写入方式,即“LBA 连 续直写模式”,并指定起始的逻辑扇区号。 图 4-19 指定数据写入时的起始逻辑扇区号 通常,一个扇区的尺寸是 512 字节,可以看成一个数据块。所以,从这个意义上来说,硬盘是 一个典型的块(Block)设备。 63 x86 汇编语言:从实模式到保护模式 采用磁头、磁道和扇区这种模式来访问硬盘的方法称为 CHS 模式,但不是很方便。想想看, 如果有一大堆数据要写,还得注意磁头号、磁道号和扇区号不要超过界限。所以,后来引入了逻辑 块地址(Logical Block Address,LBA)的概念。现在市场上销售的硬盘,无论是哪个厂家生产的, 都支持 LBA 模式。 LBA 模式是由硬盘控制器在硬件一级上提供支持,所以效率很高,兼容性很好。LBA 模式不 考虑扇区的物理位置(磁头号、磁道号),而是把它们全部组织起来统一编号。在这种编址方式下, 原先的物理扇区被组织成逻辑扇区,且都有唯一的逻辑扇区号, 比如,某硬盘有 6 个磁头,每面有 1000 个磁道,每磁道有 17 个扇区。那么: 逻辑 0 扇区对应着 0 面 0 道 1 扇区; 逻辑 1 扇区对应着 0 面 0 道 2 扇区; …… 逻辑 16 扇区对应着 0 面 0 道 17 扇区; 逻辑 17 扇区对应着 1 面 0 道 1 扇区; 逻辑 18 扇区对应着 1 面 0 道 2 扇区; …… 逻辑 33 扇区对应着 1 面 0 道 17 扇区; 逻辑 34 扇区对应着 2 面 0 道 1 扇区; 逻辑 35 扇区对应着 2 面 0 道 2 扇区; …… 逻辑 101999 扇区对应着 5 面 999 道 17 扇区,这也是整个硬盘上最后一个物理扇区。 这里面的计算方法是: LBA = C×磁头总数×每道扇区数+H×每道扇区数+(S-1) 这里,LBA 是逻辑扇区号,C、H、S 是想求得逻辑扇区号的那个物理扇区所在的磁道、磁头 和扇区号。 采用 LBA 模式的好处是简化了程序的操作,使得程序员不用关心数据在硬盘上的具体位置。 对于本书来说,VHD 文件是按 LBA 方式组织的,一开始的 512 字节就是逻辑 0 扇区,然后是逻 辑 1 扇区;最后一个逻辑扇区排在文件的最后(最后 512 个字节除外,那是 VHD 文件的标识部 分)。 64 第 5 章 编写主引导扇区代码 2 第 部分 8086 模式 65 x86 汇编语言:从实模式到保护模式 第 5 章 编写主引导扇区代码 5.1 欢迎来到主引导扇区 本章有配套的汇编语言源程序,并围绕这些源程序进行讲解,请对照阅读。 本章代码清单:5-1(主引导扇区程序) 源程序文件:c05_mbr.asm 在前面的预备知识里,我们已经知道,处理器加电或者复位之后,如果硬盘是首选的启动 设备,那么,ROM-BIOS 将试图读取硬盘的 0 面 0 道 1 扇区。传统上,这就是主引导扇区(Main Boot Sector,MBR)。 读取的主引导扇区数据有 512 字节,ROM-BIOS 程序将它加载到逻辑地址 0x0000:0x7c00 处,也就是物理地址 0x07c00 处,然后判断它是否有效。 一个有效的主引导扇区,其最后两字节应当是 0x55 和 0xAA。ROM-BIOS 程序首先检测这两 个标志,如果主引导扇区有效,则以一个段间转移指令 jmp 0x0000:0x7c00 跳到那里继续执行。 一般来说,主引导扇区是由操作系统负责的。正常情况下,一段精心编写的主引导扇区代码将检 测用来启动计算机的操作系统,并计算出它所在的硬盘位置。然后,它把操作系统的自举代码加载到 内存,也用 jmp 指令跳转到那里继续执行,直到操作系统完全启动。 在本章中,我们将试图写一段程序,把它编译之后写入硬盘的主引导扇区,然后让处理器执行。 当然,仅仅执行还不够,还必须在屏幕上显示点什么,要不然的话,谁知道我们的程序是不是成功 运行了呢? 通过本章的学习,我们可以对处理器如何执行指令、如何访问内存以及如何进行算术逻辑运算 有一个最基本的认知。 5.2 注 释 如本章代码清单 5-1 所展示的那样,在汇编语言源程序里,注释用于说明本程序的用途和 编写时间等,可以单独成行,也可以放在每条指令的后面,解释本指令的目的和功能。注释不 但有助于其他编程人员理解当前程序的编写思路和工作原理,而且也能帮助你自己在以后的某 个时间重拾这些记忆。 注释必须以分号“;”开始。 在源程序编译阶段,编译器将忽略所有注释。因此,在编译之后,这些和生成机器代码无 关的内容都统统消失了。 66 第 5 章 编写主引导扇区代码 5.3 在屏幕上显示文字 5.3.1 显卡和显存 本程序首先要做的事是在屏幕上显示一行文字。当然,要想在屏幕上显示文字,就需要先了解 文字是如何显示在屏幕上的。 为了显示文字,通常需要两种硬件,一是显示器,二是显卡。显卡的职责是为显示器提供内 容,并控制显示器的显示模式和状态,显示器的职责是将那些内容以视觉可见的方式呈现在屏幕 上。 一般来说,显卡都是独立生产、销售的部件,需要插在主板上才能工作。当然,像处理器、内 存这样的东西,也位于主板上。每台计算机都有主板,它就在机箱内部,有时间你可以打开机箱来 观察一下。 当然,显卡未必一定是独立的插卡。为了节省使用者的成本,有的显卡会直接做在主板上,这 样的显卡也有个名字,叫集成显卡。 显卡控制显示器的最小单位是像素,一个像素对应着屏幕上的一个点。屏幕上通常有数十 万乃至更多的像素,通过控制每个像素的明暗和颜色,我们就能让这大量的像素形成文字和美 丽的图像。 不过,一个很容易想到的问题是,如何来控制这些像素呢? 答案是显卡都有自己的存储器,因为它位于显卡上,故称显示存储器(Video RAM: VRAM),简称显存,要显示的内容都预先写入显存。和其他半导体存储器一样,显存并没有 什么特殊的地方,也是一个按字节访问的存储器件。 对显示器来说,显示黑白图像是最简单的,因为只需要控制每个像素是亮,还是不亮。如果把 不亮当成比特“0”,亮看成比特“1”,那就好办了。因为,只要将显存里的每个比特和显示器上的 每个像素对应起来,就能实现这个目标。 如图 5-1 所示,显存的第 1 个字节对应着屏幕左上角连续的 8 个像素;第 2 个字节对应着 屏幕上后续的 8 个像素,后面的依次类推。 图 5-1 显存内容和显示器内容之间的对应关系 显卡的工作是周期性地从显存中提取这些比特,并把它们按顺序显示在屏幕上。如果是比特 67 x86 汇编语言:从实模式到保护模式 “0”,则像素保持原来的状态不变,因为屏幕本来就是黑的;如果是比特“ 1”,则点亮对应的像 素。 继续观察图 5-1,假设显存中,第 1 个字节的内容是 11110000,第 2 个字节的内容是 11111111, 其他所有的字节都是 00000000。在这种情况下,屏幕左上角先是显示 4 个亮点,再显示 4 个黑点, 然后再显示 8 个亮点。因为像素是紧挨在一起的,所以我们看到的先是一条白短线,隔着一定距离 (4 个像素)又是一条白长线。 黑色和白色只需要 1 个比特就能表示,但要显示更多的颜色,1 个比特就不够了。现在最流行 的,是用 24 个比特,即 3 个字节,来对应一个像素。因为 224=16777216,所以在这种模式下,同 屏可以显示 16777216 种颜色,这称为真彩色。有关颜色的显示和它们与字长的关系,在《穿越计 算机的迷雾》一书中有详细的介绍,这里不再赘述。 上面所讨论的,是人们常说的图形模式。图形模式是最容易理解的,同时对显示器来说也是最 自然的模式。 现在是图形的时代,就连手机的屏幕都是五彩缤纷的。时光倒退到几十年前,在那个时代,真彩 色还没有出现,显示器只能提供有限的色彩,处理器也不够强劲(以今天的眼光来看)。在这种情况 下,人们不太可能认为图形显示技术有多么重要,因为他们不看高清电影,也没有数码相机,用计算 机制作动画片更是不能想象的事。那个时候,人们的愿望很简单,只要能显示文字就行。 不管是显示图片,还是文字,对显示器来说没有什么不同,因为所有的内容都是由像素组成的, 区别仅仅在于这些像素组成的是什么。有时候,人们会说,哦,显示的是一棵树;有时候,人们会 说,哦,显示的是一个字母“H”。 问题是,操作显存里的比特,使得屏幕上能显示出字符的形状,是非常麻烦、非常繁重的工作, 因为你必须计算该字符所对应的比特位于显存里的什么位置。 为了方便,工程师们想出了一个办法。就像一个二进制数既可以是一个普通的数,也可以代表 一条处理器指令一样,他们认为每个字符也可以表示成一个数。比如,数字 0x4C 就代表字符“L”, 这个数被称为是字符“L”的 ASCII 代码,后面会讲到。 如图 5-2 所示,可以将字符的代码存放到显存里,第 1 个代码对应着屏幕左上角第 1 个字符, 第 2 个代码对应着屏幕左上角第 2 个字符,后面的依次类推。剩下的工作是如何用代码来控制屏幕 上的像素,使它们或明或暗以构成字符的轮廓,这是字符发生器和控制电路的事情。 传统上,这种专门用于显示字符的工作方式称为文本模式。文本模式和图形模式是显卡的两种 基本工作模式,可以用指令访问显卡,设置它的显示模式。在不同的工作模式下,显卡对显存内容 的解释是不同的。 图 5-2 字符在屏幕上的显示原理 为了给出要显示的字符,处理器需要访问显存,把字符的 ASCII 码写进去。但是,显存是位于 68 第 5 章 编写主引导扇区代码 显卡上的,访问显存需要和显卡这个外围设备打交道。同时,多一道手续自然是不好的,这当中最 重要的考量是速度和效率。想想看,你让人传话给父母,和自己亲自往家里打电话,花费的时间是 不一样的。为了实现一些快速的游戏动画效果,或者播放高码率的电影,不直接访问显存是办不到 的。 为此,计算机系统的设计者们,这些敢想敢干的人,决定把显存映射到处理器可以直接访问的 地址空间里,也就是内存空间里。 如图 5-3 所示,我们知道,8086 可以访问 1MB 内存。其中,0x00000~9FFFF 属于常规内存, 由内存条提供;0xF0000~0xFFFFF 由主板上的一个芯片提供,即 ROM-BIOS。 图 5-3 文本模式下显存到内存的映射 这样一来,中间还有一个 320KB 的空洞,即 0xA0000~0xEFFFF。传统上,这段地址空间 由特定的外围设备来提供,其中就包括显卡。因为显示功能对于现代计算机来说实在是太重要 了。 由于历史的原因,所有在个人计算机上使用的显卡,在加电自检之后都会把自己初始化到 80×25 的文本模式。在这种模式下,屏幕上可以显示 25 行,每行 80 个字符,每屏总共 2000 个 字符。 所以,如图 5-3 所示,一直以来,0xB8000~0xBFFFF 这段物理地址空间,是留给显卡的,由 显卡来提供,用来显示文本。除非显卡出了毛病,否则这段空间总是可以访问的。如果显卡出了毛 病怎么办呢?很简单,计算机一定不会通过加电自检过程,这就是传说中的严重错误,计算机是无 法启动的,更不要说加载并执行主引导扇区的内容了。 5.3.2 初始化段寄存器 和访问主内存一样,为了访问显存,也需要使用逻辑地址,也就是采用“段地址:偏移地址” 的形式,这是处理器的要求。考虑到文本模式下显存的起始物理地址是 0xB8000,这块内存可以看 成是段地址为 0xB800,偏移地址从 0x0000 延伸到 0xFFFF 的区域,因此我们可以把段地址定为 0xB800。 69 x86 汇编语言:从实模式到保护模式 访问内存可以使用段寄存器 DS,但这不是强制性的,也可以使用 ES。因为 DS 还有别的用处, 所以在这里我们使用 ES 来指向显存所在的段。 源程序第 6、7 行,首先把立即数 0xB800 传送到 AX,然后再把 AX 的值传送到 ES。这样,附 加段寄存器 ES 就指向 0xb800 段(段基地址为 0xB800)。 你可能会想,为什么不直接这样写: mov es,0xb800 而要用寄存器 AX 来中转呢? 原因是不存在这样的指令,Intel 的处理器不允许将一个立即数传送到段寄存器,它只允许这样 的指令: mov 段寄存器,通用寄存器 mov 段寄存器,内存单元 没有人能够说清楚这里面的原因,Intel 公司似乎也从没有提到过这件事,尽管从理论上,这是可 行的。我们只能想,也许 Intel 是出于好心,避免我们无意中犯错,毕竟,段地址一旦改变,后面对 内存的访问都会受到影响。理论上,麻烦一点的方法,可以保证你确实知道自己在做什么。 5.3.3 显存的访问和 ASCII 代码 一旦将显存映射到处理器的地址空间,那么,我们就可以使用普通的传送指令(mov)来读写它, 这无疑是非常方便的,但需要首先将它作为一个段来看待,并将它的基地址传送到段寄存器。 为此,源程序的第 10、11 行,我们把 0xB800 作为段地址传送到附加段寄存器 ES,以后就用 ES 来读写显存。这样,段内偏移为 0 的位置就对应着屏幕左上角的字符。 在计算机中,每个用来显示在屏幕上的字符,都有一个二进制代码。这些代码和普通的二进制 数字没有什么不同,唯一的区别在于,发送这些数字的硬件和接收这些数字的硬件把它们解释为字 符,而不是指令或者用于计算的数字。 这就是说,在计算机中,所有的东西都是无差别的数字,它们的意义,只取决于生成者和使用 者之间的约定。为了在终端和大型主机,以及主机和打印机、显示器之间交换信息,1967 年,美国 国家标准学会制定了美国信息交换标准代码(American Standard Code for Information Interchange, ASCII),如表 5-1 所示。 表 5-1 ASCII 表 b6b5b4 000 001 010 011 100 101 110 111 b3b2b1b0 0000 NUL DLE SPACE 0 @ P ` p 0001 SOH DC1 ! 1 A Q a q 0010 STX DC2 “ 2 B R b r 0011 ETX DC3 # 3 C S c s 0100 EOT DC4 $ 4 D T d t 0101 ENQ NAK % 5 E U e u 0110 ACK SYN & 6 F V f v 0111 BEL ETB ‘ 7 G W g w 1000 BS CAN ( 8 H X h x 1001 HT EM ) 9 I Y i y 1010 LF SUB * : J Z j z 1011 VT ESC + ; K [ k { 70 第 5 章 编写主引导扇区代码 1100 FF FS , < L \ l | 1101 CR GS - = M ] m } 1110 SO RS . > N ^ n ~ 1111 SI US / ? O _ o DEL 在不同设备之间,或者在同一设备的不同模块之间有一个信息传递标准是非常必要的。想想看, 当你用手机向朋友发送短消息时,这些文字当然被编码成二进制数字。如果对方的手机使用了不同 的编码,那么他将无法正确还原这些消息,而很可能显示为乱码。 值得注意的是,ASCII 是 7 位代码,只用了一个字节中的低 7 比特,最高位通常置 0。这意味 着,ASCII 只包含 128 个字符的编码。所以,在表中,水平方向给出了代码的高 3 比特,而垂直方 向给出了代码的低 4 比特。比如字符“*”,它的代码是二进制数的 010 1010,即 0x2A。 ASCII 表中有相当一部分代码是不可打印和显示的,它们用于控制通信过程。比如,LF 是换行; CR 是回车;DEL 和 BS 分别是删除和退格,在我们平时用的键盘上也是有的;BEL 是振铃(使远 方的终端响铃,以引起注意);SOH 是文头;EOT 是文尾;ACK 是确认,等等。 注意,一定要遵从约定。比如,你在处理器上编写程序算了一道数学题 2+3,你也希望把结果 5 显示在屏幕上。这个时候,算出的结果是 0000 0101,即 0x05。但是,数字 5 和字符 5 是不同的, 显卡在任何时候都认为你发送的是 ASCII 码。所以,你不应该发送 0x05,而应该发送 0x35。 屏幕上的每个字符对应着显存中的两个连续字节,前一个是字符的 ASCII 代码,后面是字符的 显示属性,包括字符颜色(前景色)和底色(背景色)。如图 5-4 所示,字符“H”的 ASCII 代码是 0x48,其显示属性是 0x07;字符“e”的 ASCII 代码是 0x65,其显示属性是 0x07。 如图 5-4 所示,字符的显示属性(1 字节)分为两部分,低 4 位定义的是前景色,高 4 位定 义的是背景色。色彩主要由 R、G、B 这 3 位决定,毕竟我们知道,可以由红(R)、绿(G)、蓝 (B)三原色来配出其他所有颜色。K 是闪烁位,为 0 时不闪烁,为 1 时闪烁;I 是亮度位,为 0 时正常亮度,为 1 时呈高亮。表 5-2 给出了背景色和前景色的所有可能值。 R G B 0 0 0 0 0 1 0 1 0 0 1 1 图 5-4 字符代码及字符属性示意图 表 5-2 80×25 文本模式下的颜色表 背景色 K=0 时不闪烁,K=1 时闪烁 I=0 黑 黑 蓝 蓝 绿 绿 青 青 前景色 I=1 灰 浅蓝 浅绿 浅青 71 x86 汇编语言:从实模式到保护模式 1 0 0 红 红 浅红 1 0 1 品(洋)红 品(洋)红 浅品(洋)红 1 1 0 棕 棕 黄 1 1 1 白 白 亮白 从表 5-2 来看,图 5-4 中的字符属性 0x07 可以解释为黑底白字,无闪烁,无加亮。 你可能觉得奇怪,当屏幕上一片漆黑,什么内容都没有的时候,显存里会是什么内容呢? 实际上,这个时候,屏幕上显示的全是黑底白字的空白字符,也叫空格字符(Space),ASCII 代码是 0x20,当你用大拇指按动键盘上最长的那个键时,就产生这个字符。因为它是空白,自然就 无法在黑底上看到任何痕迹了。 5.3.4 显示字符 从源程序的第 10 行开始,到第 35 行,目的是显示一串字符“Label offset:”。为此,需要把它 们每一个的 ASCII 码顺序写到显存中。 为了方便,多数汇编语言编译器允许在指令中直接使用字符的字面值来代替数值形式的 ASCII 码,比如源程序第 10 行: mov byte [es:0x00],'L' 这等效于 mov byte [es:0x00],0x4c 尽管通过查表可以知道字符“L”的 ASCII 代码是 0x4C,但毕竟费事。不过,要在指令中使用 字符的字面值,这个字符必须用引号围起来,就像上面一样。在源程序的编译阶段,汇编语言编译 器会将它转换成 ASCII 码的形式。 当前的 mov 指令是将立即数传送到内存单元,目的操作数是内存单元,源操作数是立即数 (ASCII 代码)。为了访问内存单元,只需要在指令中给出偏移地址,在这里,偏移地址是 0x00。 一般情况下,如果没有附加任何指示,段地址默认在段寄存器 DS 中。比如: mov byte [0x00],’L’ 当执行这条指令后,处理器把段寄存器 DS 的内容左移 4 位(相当于乘以十进制数 16 或者十六 进制数 0x10),加上这里的偏移地址 0x00,就得到了物理地址。 但是实际上,显存的段地址位于段寄存器 ES 中,我们希望使用 ES 来访问内存。因此,这里 使用了段超越前缀“es:”。这就是说,我们明确要求处理器在生成物理地址时,使用段寄存器 ES, 而不是默认情况下的 DS。 因为指令中给出的偏移地址是 0x00,且 ES 的值已经在前面被设为 0xB800,故它指向 ES 段中, 偏移地址为 0 的内存单元,即 0xB800:0x0000,也就是物理地址 0xB8000,这个内存单元对应着屏 幕左上角第一个字符的位置。 还需要注意的是,因为目的操作数给出的是一个内存地址,我们要用源操作数来修改这个地址 里的内容,所以,目的操作数必须用方括号围起来,以表明它是一个地址,处理器应该用这个地址 再次访问内存,将源操作数写进这个单元。实际上,这类似于高级语言里的指针。 最后,关键字“byte”用来修饰目的操作数,指出本次传送是以字节的方式进行的。在 16 位的 处理器上,单次操作的数据宽度可以是 8 位,也可以是 16 位。到底是 8 位,还是 16 位,可以根据 目的操作数或者源操作数来判断。遗憾的是,在这里,目的操作数是偏移地址 0x00,它可以是字节 单元,也可以是字单元,到底是哪一种,无法判断;而源操作数呢,是立即数 0x4C,它既可以解 72 第 5 章 编写主引导扇区代码 释为 8 位的 0x4C,也可以解释为 16 位的 0x004C。在这种情况下,编译器将无法搞懂你的真实意 图,只能报告错误,所以必须用“byte”或者“word”进行修饰(明确指示)。于是,一旦目的操 作数被指明是“byte”的,那么,源操作数的宽度也就明确了。相反地,下面的指令就不需要任何 修饰: mov [0x00],AL ;按字节操作 mov AX,[0x02] ;按字操作 因为屏幕上的一个字符对应着内存中的两个字节:ASCII 代码和属性,所以,源程序第 11 行 的功能是将属性值 0x07 传送到下一个内存单元,即偏移地址 0x01 处。这个属性可以解释为黑底白 字,无闪烁,也无加亮,请参阅表 5-2。 后面,从第 12 行开始,到第 35 行,用于向显示缓冲区填充剩余部分的字符。注意,在这个过 程中,偏移地址一直是递增的。 5.4 显示标号的汇编地址 5.4.1 标号 处理器访问内存时,采用的是“段地址:偏移地址”的模式。对于任何一个内存段来说,段地 址可以开始于任何 16 字节对齐的地方,偏移地址则总是从 0x0000 开始递增。 为了支持这种内存访问模式,在源程序的编译阶段,编译器会把源程序 5-1 整体上作为一个独 立的段来处理,并从 0 开始计算和跟踪每一条指令的地址。因为该地址是在编译期间计算的,故称 为 汇 编 地 址 。 汇 编 地 址 是 在 源 程 序 编 译 期 间 , 编 译 器 为 每 条 指 令 确 定 的 汇 编 位 置 ( Assembly Position),也就是每条指令相对于整个程序开头的偏移量,以字节计。当编译后的程序装入物理内 存后,它又是该指令在内存段内的偏移地址。 如表 5-3 所示,在用我们的配书工具 Nasmide 书写并编译代码清单 5-1 后,除了生成一个以“.bin” 为扩展名的二进制文件,还会生成一个以“.lst”为扩展名的列表文件。这张表列出的,就是本章代 码清单 5-1 编译后生成的列表文件内容。 表 5-3 共分五栏,从左到右依次是行号、指令的汇编地址、指令编译后的机器代码、源程序 代码和注释。可以看出,第一条指令 mov ax,0xb800 的汇编地址是 0x00000000,对应的机器代码为 B8 00 B8;第二条指令 mov es,ax 的汇编地址是 0x00000003,机器代码为 8E C0。 表 5-3 代码清单 5-1 编译后的列表文件内容 1 2 3 4 5 ;代码清单 5-1 ;文件名:c05_mbr.asm ;文件说明:硬盘主引导扇区代码 ;创建日期:2011-3-31 21:15 73 x86 汇编语言:从实模式到保护模式 6 00000000 7 00000003 8 9 10 00000005 11 0000000B 12 00000011 13 00000017 14 0000001D 15 00000023 16 00000029 17 0000002F 18 00000035 19 0000003B 20 00000041 21 00000047 22 0000004D 23 00000053 24 00000059 25 0000005F 26 00000065 27 0000006B 28 00000071 29 00000077 30 0000007D 31 00000083 32 00000089 33 0000008F 34 00000095 35 0000009B 36 37 000000A1 38 000000A4 39 40 41 000000A7 42 000000A9 43 44 45 000000AB 46 000000AE 47 000000B0 48 74 B800B8 8EC0 26C60600004C 26C606010007 26C606020061 26C606030007 26C606040062 26C606050007 26C606060065 26C606070007 26C60608006C 26C606090007 26C6060A0020 26C6060B0007 26C6060C006F 26C6060D0007 26C6060E0066 26C6060F0007 26C606100066 26C606110007 26C606120073 26C606130007 26C606140065 26C606150007 26C606160074 26C606170007 26C60618003A 26C606190007 B8[2E01] BB0A00 8CC9 8ED9 BA0000 F7F3 8816[2E7D] mov ax,0xb800 mov es,ax ;指向文本模式的显示缓冲区 ;以下显示字符串"Label offset:" mov byte [es:0x00],'L' mov byte [es:0x01],0x07 mov byte [es:0x02],'a' mov byte [es:0x03],0x07 mov byte [es:0x04],'b' mov byte [es:0x05],0x07 mov byte [es:0x06],'e' mov byte [es:0x07],0x07 mov byte [es:0x08],'l' mov byte [es:0x09],0x07 mov byte [es:0x0a],' ' mov byte [es:0x0b],0x07 mov byte [es:0x0c],"o" mov byte [es:0x0d],0x07 mov byte [es:0x0e],'f' mov byte [es:0x0f],0x07 mov byte [es:0x10],'f' mov byte [es:0x11],0x07 mov byte [es:0x12],'s' mov byte [es:0x13],0x07 mov byte [es:0x14],'e' mov byte [es:0x15],0x07 mov byte [es:0x16],'t' mov byte [es:0x17],0x07 mov byte [es:0x18],':' mov byte [es:0x19],0x07 mov ax,number mov bx,10 ;取得标号 number 的偏移地址 ;设置数据段的基地址 mov cx,cs mov ds,cx ;求个位上的数字 mov dx,0 div bx mov [0x7c00+number+0x00],dl ;保存个位上的数字 49 50 000000B4 51 000000B6 52 000000B8 53 54 55 000000BC 56 000000BE 57 000000C0 58 59 60 000000C4 61 000000C6 62 000000C8 63 64 65 000000CC 66 000000CE 67 000000D0 68 69 70 000000D4 71 000000D7 72 000000D9 73 000000DD 74 75 000000E3 76 000000E6 77 000000E8 78 000000EC 79 80 000000F2 81 000000F5 82 000000F7 83 000000FB 84 85 00000101 86 00000104 87 00000106 88 0000010A 89 90 00000110 91 00000113 31D2 F7F3 8816[2F7D] 31D2 F7F3 8816[307D] 31D2 F7F3 8816[317D] 31D2 F7F3 8816[327D] A0[327D] 0430 26A21A00 26C6061B0004 A0[317D] 0430 26A21C00 26C6061D0004 A0[307D] 0430 26A21E00 26C6061F0004 A0[2F7D] 0430 26A22000 26C606210004 A0[2E7D] 0430 第 5 章 编写主引导扇区代码 ;求十位上的数字 xor dx,dx div bx mov [0x7c00+number+0x01],dl ;保存十位上的数字 ;求百位上的数字 xor dx,dx div bx mov [0x7c00+number+0x02],dl ;保存百位上的数字 ;求千位上的数字 xor dx,dx div bx mov [0x7c00+number+0x03],dl ;保存千位上的数字 ;求万位上的数字 xor dx,dx div bx mov [0x7c00+number+0x04],dl ;保存万位上的数字 ;以下用十进制显示标号的偏移地址 mov al,[0x7c00+number+0x04] add al,0x30 mov [es:0x1a],al mov byte [es:0x1b],0x04 mov al,[0x7c00+number+0x03] add al,0x30 mov [es:0x1c],al mov byte [es:0x1d],0x04 mov al,[0x7c00+number+0x02] add al,0x30 mov [es:0x1e],al mov byte [es:0x1f],0x04 mov al,[0x7c00+number+0x01] add al,0x30 mov [es:0x20],al mov byte [es:0x21],0x04 mov al,[0x7c00+number+0x00] add al,0x30 75 x86 汇编语言:从实模式到保护模式 92 00000115 93 00000119 94 95 0000011F 96 00000125 97 98 0000012B 99 100 0000012E 101 102 00000133 103 000001FE 26A22200 26C606230004 26C606240044 26C606250007 E9FDFF 0000000000 00 55AA mov [es:0x22],al mov byte [es:0x23],0x04 mov byte [es:0x24],'D' mov byte [es:0x25],0x07 infi: jmp near infi number db 0,0,0,0,0 times 203 db 0 db 0x55,0xaa ;无限循环 从表 5-3 中可以看出,在编译阶段,每条指令都被计算并赋予了一个汇编地址,就像它们已经 被加载到内存中的某个段里一样。实际上,如图 5-5 所示,当编译好的程序加载到物理内存后,它 在段内的偏移地址和它在编译阶段的汇编地址是相等的。 图 5-5 汇编地址和偏移地址的关系 正如图 5-5 所示,编译后的程序是整体加载到内存中某个段的,交叉箭头用于指示它们之间的 映射关系。之所以箭头是交叉的,是因为源程序的编译是从上往下的,而内存地址的增长是从下往 上的(从低地址往高地址方向增长)。 图 5-5 中假定程序是从内存物理地址 0x60000 开始加载的。因为该物理地址也对应着逻辑地址 0x6000:0x0000,因此我们可以说,该程序位于段 0x6000 内。 在编译阶段,源程序的第一条指令 mov ax,0xb800 的汇编地址是 0x00000000,而它在整个程 序装入内存后,在段内的偏移地址是 0x0000,即逻辑地址 0x6000:0000,两者的偏移地址是一致 的。 再看源程序的第二条指令,是 mov es,ax,它在编译阶段的汇编地址是 0x00000003。在整 个程序装入内存后,它在段内的偏移地址是 0x0003,也没有变化。 这就很好地说明了汇编地址和偏移地址之间的对应关系。理解这一点,对后面的编程很重要。 76 第 5 章 编写主引导扇区代码 在 NASM 汇编语言里,每条指令的前面都可以拥有一个标号,以代表和指示该指令的汇编地 址。毕竟,由我们自己来计算和跟踪每条指令所在的汇编地址是极其困难的。这里有一个很好的例 子,比如源程序第 98 行: infi: jmp near infi 在这里,行首带冒号的是标号是“infi”。请看表 5-3,这条指令的汇编地址是 0x0000012D,故 infi 就代表数值 0x0000012D,或者说是 0x0000012D 的符号化表示。 标号之后的冒号是可选的。所以下面的写法也是正确的: infi jmp near infi 标号并不是必需的,只有在我们需要引用某条指令的汇编地址时,才使用标号。正是因为这样, 本章源程序中的绝大多数指令都没有标号。 标号可以单独占用一行的位置,像这样: infi: jmp near infi 这种写法和第 98 行相比,效果并没有什么不同,因为 infi 所在的那一行没有指令,它的地址 就是下一行的地址,换句话说,和下一行的地址是相同的。 标号可以由字母、数字、“_”、“$”、“#”、“@”、“~”、“.”、“?”组成,但必须以字母、“.”、 “_”和“?”中的任意一个打头。 5.4.2 如何显示十进制数字 我们已经知道,标号可以用来代表指令的汇编地址。现在,我们要编写指令,在屏幕上把这个 地址的数值显示出来。为此,源程序的第 37 行用于获取标号所代表的汇编地址: mov ax,number 标号“number”位于源程序的第 100 行,只不过后面没有跟着冒号“:”。你当然可以加上冒号, 但这无关紧要。注意,传送到寄存器 AX 的值是在源程序编译时确定的,在编译阶段,编译器会将 标号 number 转换成立即数。如表 5-3 所示,标号 number 处的汇编地址是 0x012E,因此,这条语句 其实就是 mov ax,0x012E 问题在于,如果不是借助于别的工具和手段,你不可能知道此处的汇编地址是 0x012E。所以, 在汇编语言中使用标号的好处是不必关心这些。 因此,当这条指令编译后,得到的机器指令为 B8[2E01],或者 B8 2E 01。B8 是操作码,后面 是字操作数 0x012E,只不过采用的是低端字节序。 十六进制数 0x012E 等于十进制数 302,但是,通过前面对字符显示原理的介绍,我们应该清 楚,直接把寄存器 AX 中的内容传送到显示缓冲区,是不可能在屏幕上出现“302”的。 解决这个问题的办法是将它的每个数位单独拆分出来,这需要不停地除以 10。 考虑到寄存器 AX 是 16 位的,可以表示的数从二进制的 0000000000000000 到 1111111111111111, 也就是十进制的 0~65535,故它可以容纳最大 5 个数位的十进制数,从个位到万位,比如 61238。 那么,假如你并不知道它是多少,只知道它是一个 5 位数,那么,如何通过分解得到它的每个数位 呢? 首先,用 61238 除以 10,商为 6123,余 8,本次相除的余数 8 就是个位数字; 然后,把上一次的商数 6123 作为被除数,再次除以 10,商为 612,余 3,余数 3 就是十位上的 77 x86 汇编语言:从实模式到保护模式 数字; 接着,再用上一次的商数 612 除以 10,商为 61,余 2,余数 2 就是百位上的数字; 同上,再用 61 除以 10,商为 6,余 1,余数 1 就是千位上的数字; 最后,用 6 除以 10,商为 0,余 6,余数 6 就是万位上的数字。 很显然,只要把 AX 的内容不停地除以 10,只需要 5 次,把每次的余数反向组合到一起,就是 原来的数字。同样,如果反向把每次的余数显示到屏幕上,应该就能看见这个十进制数是多少了。 不过,即使是得到了单个的数位,也还是不能在屏幕上显示,因为它们是数字,而非 ASCII 代码。比如,数字 0x05 和字符“5”是不同的,后者实际上是数字 0x35。 观察表 5-1,你会发现,字符“0”的 ASCII 代码是 0x30,字符“1”的 ASCII 代码是 0x31,字 符“9”的 ASCII 代码是 0x39。这就是说,把每次相除得到的余数加上 0x30,在屏幕上显示就没问 题了。 5.4.3 在程序中声明并初始化数据 可以用处理器提供的除法指令来分解一个数的各个数位,但是每次除法操作后得到的数位需要 临时保存起来以备后用。使用寄存器不太现实,因为它的数量很少,且还要在后续的指令中使用。 因此,最好的办法是在内存中专门留出一些空间来保存这些数位。 尽管我们的目的仅仅是分配一些空间,但是,要达到这个目的必须初始化一些初始数据来“占 位”。这就好比是排队买火车票,你可以派任何无关的人去帮你占个位置,真正轮到你买的时候, 你再出现。源程序的第 100 行用于声明并初始化这些数据,而标号 number 则代表了这些数据的起 始汇编地址。 要放在程序中的数据是用 DB 指令来声明(Declare)的,DB 的意思是声明字节(Declare Byte), 所以,跟在它后面的操作数都占一个字节的长度(位置)。注意,如果要声明超过一个以上的数据, 各个操作数之间必须以逗号隔开。 除此之外,DW(Declare Word)用于声明字数据,DD(Declare Double Word)用于声明 双字(两个字)数据,DQ(Declare Quad Word)用于声明四字数据。DB、DW、DD 和 DQ 并 不是处理器指令,它只是编译器提供的汇编指令,所以称做伪指令(pseudo Instruction)。伪指 令是汇编指令的一种,它没有对应的机器指令,所以它不是机器指令的助记符,仅仅在编译阶 段由编译器执行,编译成功后,伪指令就消失了,所以在程序执行时,伪指令是得不到处理器 光顾的,实际上,程序执行时,伪指令已不存在。 声明的数据可以是任何值,只要不超过伪指令所指示的大小。比如,用 DB 声明的数据,不能 超过一个字节所能表示的数的大小,即 0xFF。我们在此声明了 5 个字节,并将它们的值都初始化 为 0。 和指令不同,对于在程序中声明的数值,在编译阶段,编译器会在它们被声明的汇编地址处原 样保留。 按照标准的做法,程序中用到的数据应当声明在一个独立的段,即数据段中。但是在这里,为 方便起见,数据和指令代码是放在同一个段中的。不过,方便是方便了,但也带来了一个隐患,如 果安排不当,处理器就有可能执行到那些非指令的数据上。尽管有些数碰巧和某些指令的机器码相 同,也可以顺利执行,但毕竟不是我们想要的结果,违背了我们的初衷。 好在我们很小心,在本程序中把数据声明在所有指令之后,在这个地方,处理器的执行流程无 法到达。 78 5.4.4 分解数的各个数位 第 5 章 编写主引导扇区代码 源程序第 41、42 行,是把代码段寄存器 CS 的内容传送到通用寄存器 CX,然后再从 CX 传送 到数据段寄存器 DS。在此之后,数据段和代码段都指向同一个段。之所以这么做,是因为我们刚 才声明的数据是和指令代码混在一起的,可以认为是位于代码段中。尽管在指令中访问这些数据可 以使用段超越前缀“CS:”,但习惯上,通过数据段来访问它们更自然一些。 前面已经说过,要分解一个数的各个数位,需要做除法。8086 处理器提供了除法指令 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] 话又说回来了,在一个源程序中,通常不可能知道汇编地址的具体数值,只能使用标号。所以, 指令中的地址部分更常见的形式是使用标号。比如: dividnd dw 0x3f0 divisor db 0x3f …… mov ax,[dividnd] div byte [divisor] 上面的程序很有意思,首先,声明了标号 dividnd 并初始化了一个字 0x3f0 作为被除数;然后, 又声明了标号 divisor 并初始化一个字节 0x3f 作为除数。 在后面的 mov 和 div 指令中,是用标号 dividnd 和 divisor 来代替被除数和除数的汇编地址。在 编译阶段,编译器用具体的数值取代括号中的标号 dividnd 和 divisor。现在,假设 dividnd 和 divisor 所代表的汇编地址分别是 0xf000 和 0xf002,那么,在编译阶段,编译器在生成这两条指令的机器 码之前,会先将它们转换成以下的形式: mov ax,[0xf000] div byte [0xf002] 当第一条指令执行时,处理器用 0xf000 作为偏移地址,去访问数据段(段地址在段寄存器 DS 中),来取得内存中的一个字 0x3F0,并把它传送到寄存器 AX 中。 79 x86 汇编语言:从实模式到保护模式 当第二条指令执行时,处理器采用同样的方法 取得内存中的一个字节 0x3F,用它来和寄存器 AX 十进制数2218367590等于以下二进制数 中的内容做除法。当然,除法指令 div 的功能你是 知道的。 10000100001110011001101001100110 寄存器DX 寄存器AX 1000010000111001 1001101001100110 图 5-6 用 DX:AX 分解 32 位二进制数示意图 说了这么多,其实是在强调标号和汇编地址的 对应关系,以及如何在指令中使用符号化的偏移地 址。 第二种类型是用 32 位的二进制数除以 16 位的二 进制数。在这种情况下,因为 16 位的处理器无法直 接提供 32 位的被除数,故要求被除数的高 16 位在 DX 中,低 16 位在 AX 中。 这里有一个例子,如图 5-6 所示,假如被除 数是十进制数 2218367590,那么,它对应着一个 32 位的二进制数 10000100001110011001101001100110。在做除法之前,先要分成两段进行“切割”,以分别装 入寄存器 DX 和 AX。为了方便,我们通常用“DX:AX”来描述 32 位的被除数。 同时,除数可以由 16 位的通用寄存器或者内存单元提供,指令执行后,商在 AX 中,余数在 DX 中。比如下面的指令: div cx div word [0x0230] 源程序第 45 行把 0 传送到 DX 寄存器,这意味着,我们是想把 DX:AX 作为被除数,即被 除数的高 16 位是全零。至于被除数的低 16 位,已经在第 37 行的代码中被置为标号 number 的 汇编地址。 回到前面的第 38 行,该指令把 10 作为除数传送到通用寄存器 BX 中。 一切都准备好了,源程序第 46 行,div 指令用 DX:AX 作为被除数,除以 BX 的内容,执行后 得到的商在 AX 中,余数在 DX 中。因为除数是 10,余数自然比 10 小,我们可以从 DL 中取得。 第 1 次相除得到的余数是个位上的数字,我们要将它保存到声明好的数据区中。所以,源程序 第 47 行,我们又一次用到了传送指令,把寄存器 DL 中的余数传送到数据段。 可以看到,指令中没有使用段超越前缀,所以处理器在执行时,默认地使用段寄存器 DS 来访 问内存。偏移地址是由标号 number 提供的,它是数据区的首地址,也可以说是数据区中第一个数 据的地址。因此,number 和 number+0x00 是一样的,没有区别。 因为我们访问的是 number 所指向的内存单元,故要用中括号围起来,表明这是一个地址。 令 人 不 解 的是 , 第 47 行 中 , 偏 移地 址 并 非理论 上 的 number+0x00 ,而 是 0x7c00+ number+0x00。这个 0x7c00 是从哪里来的呢? 标号 number 所代表的汇编地址,其数值是在源程序编译阶段确定的,而且是相对于整个程序 的开头,从 0 开始计算的。请看一下表 5-3 的第 37 行,这个在编译阶段计算出来的值是 0x012E。 在运行的时候,如果该程序被加载到某个段内偏移地址为 0 的地方,这不会有什么问题,因为它们 是一致的。 但是,事实上,如图 5-7 所示,这里显示的是整个 0x0000 段,其中深色部分为主引导扇区 所处的位置。主引导扇区代码是被加载到 0x0000:0x7C00 处的,而非 0x0000:0000。对于程序 的执行来说,这不会有什么问题,因为主引导扇区的内容被加载到内存中并开始执行时, 80 第 5 章 编写主引导扇区代码 CS=0x0000,IP=0x7C00。 加载位置的改变不会对处理器执行指令造成任何困扰,但会给数据访问带来麻烦。要知道,当前数 据段寄存器 DS 的内容是 0x0000,因此,number 的偏移地址实际上是 0x012E+0x7C00=0x7D2E。当 正在执行的指令仍然用 0x012E 来访问数据,灾难就发生了。 所以,在编写主引导扇区程序时,我们就要考虑到这一点,必须把代码写成 mov [0x7c00+number+0x00],dl 指令中的目的操作数是在编译阶段确定的,因此,在编译阶段,编译器同样会首先将它转换成 以下的形式,再进一步生成机器码: mov [0x7d2e],dl 81 x86 汇编语言:从实模式到保护模式 段:0000 64KB ┇ number: ┇ :7C00 012E 7C00+012E=7D2E 0000:0000 图 5-7 主引导程序加载到内存后的地址变化 这样,如表 5-3 的第 47 行所示,在编译后,编译器就会将这条指令编译成 88 16 2E 7D,其中 前两个字节是操作码,后两个字节是低端字节序的 0x7D2E。当这条指令执行时,处理器将段寄存 器 DS 的内容(和 CS 一样,是 0x0000)左移 4 位,再加上指令中提供的偏移地址 0x7D2E,就得 到了实际的物理地址(0x07D2E)。 关于这条指令的另外一个问题是,虽然目的操作数也是一个内存单元地址,但并没有用关键字 “byte”来修饰。这是因为源操作数是寄存器 DL,编译器可以据此推断这是一个字节操作,不存在 歧义。 现在已经得到并保存了个位上的数字,下一步是计算十位上的数字,方法是用上一次得到 的商作为被除数,继续除以 10。恰好,AX 已经是被除数的低 16 位,现在只需要把 DX 的内 容清零即可。 为此,代码清单 5-1 第 50 行,用了一个新的指令 xor 来将 DX 寄存器的内容清零。 xor,在数字逻辑里是异或(eXclusive OR)的意思,或者叫互斥或、互斥的或运算。《在穿越 计算机的迷雾》里,已经花了大量的篇幅讲解数字逻辑。在数字逻辑里,如果 0 代表假,1 代表真, 那么 0 xor 0 = 0 0 xor 1 = 1 1 xor 0 = 1 1 xor 1 = 0 xor 指令的目的操作数可以是通用寄存器和内存单元,源操作数可以是通用寄存器、内存单元 和立即数(不允许两个操作数同时为内存单元)。而且,异或操作是在两个操作数相对应的比特之 间单独进行的。 82 第 5 章 编写主引导扇区代码 一般地,xor 指令的两个操作数应当具有相同的数据宽度。因此,其指令格式可以总结为 以下几种情况: xor 8 位通用寄存器,8 位立即数,例如:xor al,0x55 xor 8 位通用寄存器,指向 8 位实际操作数的内存地址,例如:xor cl,[0x2000] xor 8 位通用寄存器,8 位通用寄存器,例如:xor bl,dl xor 16 位通用寄存器,16 位立即数,例如:xor ax,0xf033 xor 16 位通用寄存器, 指向 16 位实际操作数的内存地址,例如:xor bx,[0x2002] xor 16 位通用寄存器,16 位通用寄存器,例如:xor dx,bx xor 指向 8 位实际操作数的内存地址,8 位立即数,例如:xor byte[0x3000],0xf0 xor 指向 8 位实际操作数的内存地址,8 位通用寄存器,例如:xor [0x06],al xor 指向 16 位实际操作数的内存地址,16 位立即数,例如:xor word [0x2002],0x55aa xor 指向 16 位实际操作数的内存地址,16 位通用寄存器,例如:xor [0x20],dx 因为异或操作是在两个操作数相对应的比特之间单独进行,故,以下指令执行后,AX 寄存器 中的内容为 0xF0F3。 mov ax,0000_0000_0000_0010B xor ax,1111_0000_1111_0001B ;AX←1111_0000_1111_0011B,即,0xf0f3 注意,这两条指令的源操作数都采用了二进制数的写法,NASM 编译器允许使用下画线来分开 它们,好处是可以更清楚地观察到那些感兴趣的比特。 回到当前程序中,因为指令 xor dx,dx 中的目的操作数和源操作数相同,那么,不管 DX 中的内 容是什么,两个相同的数字异或,其结果必定为 0,故这相当于将 DX 清零。 值得一提的是,尽管都可以用于将寄存器清零,但是编译后,mov dx,0 的机器码是 BA 00 00; 而 xor dx,dx 的机器码则是 31 D2,不但较短,而且,因为 xor dx,dx 的两个操作数都是通用寄存器, 所以执行速度最快。 第二次相除的结果可以求得十位上的数字,源程序第 52 行用来将十位上的数字保存到从 number 开始的第 2 个存储单元里,即 number+0x01。 从源程序第 55 行开始,一直到第 67 行,做的都是和前面相同的事情,即,分解各位上的数字, 并予以保存,这里不再赘述。 5.4.5 显示分解出来的各个数位 经过 5 次除法操作,可以将寄存器 AX 中的数分解成单独的数位,下面的任务是将这些数位显示 出来,方法是从 DS 指向的数据段依次取出这些数位,并写入 ES 指向的附加段(显示缓冲区)。 因为在分解并保存各个数位的时候,顺序是“个、十、百、千、万”位,当在屏幕上显示时, 却要反过来,先显示万位,再显示千位,等等,因为屏幕显示是从左往右进行的。所以,源程序 第 70 行,先从数据段中,偏移地址为 number+0x04 处取得万位上的数字,传送到 AL 寄存器。 当然,因为程序是加载到 0x0000:0x7C00 处的,所以正确的偏移地址是 0x7C00+number+0x04。 然后,源程序第 71 行,将 AL 中的内容加上 0x30,以得到与该数字对应的 ASCII 代码。在这 里,add 是加法指令,用于将一个数与另一个数相加。 83 x86 汇编语言:从实模式到保护模式 add 指令需要两个操作数,目的操作数可以是 8 位或者 16 位的通用寄存器,或者指向 8 位或者 16 位实际操作数的内存地址;源操作数可以是相同数据宽度的 8 位或者 16 位通用寄存器、指向 8 位或者 16 位实际操作数的内存地址,或者立即数,但不允许两个操作数同时为内存单元。相加后, 结果保存在目的操作数中。比如: add al,cl add ax,0x123f add [label_a],cx add ax,[label_a] add byte [label_a],0x08 源程序第 72 行,将要显示的 ASCII 代码传送到显示缓冲区偏移地址为 0x1A 的位置,该位置紧 接着前面的字符串“Label offset:”。显示缓冲区是由段寄存器 ES 指向的,因此使用了段超越前缀。 源程序第 73 行,将该字符的显示属性写入下一个内存位置 0x1B。属性值 0x04 的意思是黑底 红字,无闪烁,无加亮。 从源程序的第 75 行开始,到第 93 行,用于显示其他 4 个数位。 源程序第 95、96 行,用于以黑底白字显示字符“D”,意思是所显示的数字是十进制的。 5.5 使程序进入无限循环状态 数字显示完成后,原则上整个程序就结束了,但对处理器来说,它并不知道。对它来说,取指 令、执行是永无止境的。程序有大小,执行无停息,它这么做的结果,就是会执行到后面非指令的 数据上,然后…… 问题在于我们现在的确无事可做。为避免发生问题,源程序第 98 行,安排了一个无限循环: infi: jmp near infi jmp 是转移指令,用于使处理器脱离当前的执行序列,转移到指定的地方执行,关键字 near 表 示目标位置依然在当前代码段内。上面这条指令唯一特殊的地方在于它不是转移到别处,而是转移 到自己。也就是说,它将会不停地重复执行自己。不要觉得奇怪,这是允许的。 处理器取指令、执行指令是依赖于段寄存器 CS 和指令指针寄存器 IP 的,8086 处理器取指令 时,把 CS 的内容左移 4 位,加上 IP 的内容,形成 20 位的物理地址,取得指令,然后执行,同时 把 IP 的内容加上当前指令的长度,以指向下一条指令的偏移地址。 但是,一旦处理器取到的是转移指令,情况就完全变了。 很容易想到,指令 jmp near infi 的意图是转移到标号 infi 所在的位置执行。可是,正如我们前 面所说的,程序在内存中的加载位置是 0x0000:0x7C00,所以,这条指令应当写成 jmp near 0x7c00+infi 实际上,不加还好,加上了 0x7C00,就完全错了。 jmp 指令有多种格式。最典型地,它的操作数可以是直接给出的段地址和偏移地址,这称 为绝对地址。比如: jmp 0x5000:0xf0c0 此时,要转移到的目标位置是非常明确的,即,段地址为 0x5000,段内偏移地址为 0xf0c0。 在这种情况下,指令的操作码为 0xEA,故完整的机器指令是: 84 第 5 章 编写主引导扇区代码 EA C0 F0 00 50 处理器执行时,发现操作码为 0xEA,于是,将指令中给出的段地址传送到段寄存器 CS; 将偏移地址传送到指令指针寄存器 IP,从而转移到目标位置处接着执行。 但是,在此处,jmp 指令使用了关键字“near”,且操作数是以标号(infi)的形式给出。 这很容易让我们想到,这又是另一种形式的转移指令,转移的目标位置处在当前代码段内,指 令中的操作数应当是目标位置的偏移地址。实际上,这是不正确的。 实际上,这是一个 3 字节指令,操作码是 0xE9,后跟一个 16 位(两字节)的操作数。但 是,该操作数并非目标位置的偏移地址,而是目标位置相对于当前指令处的偏移量(以字节为 单位)。在编译阶段,编译器是这么做的:用标号(目标位置)处的汇编地址减去当前指令的 汇编地址,再减去当前指令的长度(3),就得到了 jmp near infi 指令的实际操作数。也不是编 译器愿意费这个事,这是处理器的要求。 这样看来,jmp near infi 的机器指令格式和它的汇编指令格式完全不同,颇具迷惑性,所以一定 要认清它的本质。这种转移是相对的,操作数是一个相对量,如果你人为地加上 0x7C00,那反而不 对了。 在指令执行阶段,处理器用指令指针寄存器 IP 的内容加上该指令的操作数,再加上该指令的 长度(3),就得到了要转移的实际偏移地址,同时 CS 寄存器的内容不变。因为改变了 IP 的内容, 这直接导致处理器的指令执行流程转向目标位置。 jmp 指令具有多种格式,我们现在所用的,只是其中的一种,叫做相对近转移。有关其他格式, 以及这些格式之间的差异,我们将在后面的章节里结合具体的实例进行讲解。 5.6 完成并编译主引导扇区代码 5.6.1 主引导扇区有效标志 主引导扇区在系统启动过程中扮演着承上启下的角色,但并非是唯一的选择。如果硬盘的主引 导扇区不可用,系统还有其他选择,比如可以从光盘和 U 盘启动。 然而,如果不试试水的深浅就一个猛子扎下池塘,这并非一个明智之举。同样地,如果主 引导扇区是无效的,上面并非是一些处理器可以识别的指令,而处理器又不加鉴别地执行了它, 其结果是陷入宕机状态,更不要提从其他设备启动了。 为此,计算机的设计者们决定,一个有效的主引导扇区,其最后两个字节的数据必须是 0x55 和 0xAA。否则,这个扇区里保存的就不是一些有意而为的数据。 定义这两个字节很简单,伪指令 db 和 dw 就可以实现。源程序第 103 行就是 db 版本的实现, 但没有标号。标号的作用是提供当前位置的汇编(偏移)地址供其他指令引用,如果没有任何指令 引用这个地址,标号可以省略。这是两个单独的字节,所以 0x55 在前,0xAA 在后,即使编译之后 也是这个顺序。 但是,如果采用 dw 版本,应该这样写: dw 0xaa55 因为,在 Intel 处理器上,将一个字写入内存时,是采用低端字节序的,低字节 0x55 置入低地 85 x86 汇编语言:从实模式到保护模式 址端(在前),高字节 0xAA 在高地址端(在后)。 麻烦在于,如何使这两个字节正好位于 512 字节的最后。前面的代码有多少个字节我们不知道, 那是由 NASM 编译器计算和跟踪的。 我们当然有非常好的办法,但还不宜在这里说明。但是,经过计算和尝试,我知道,在前面的 内容和结尾的 0xAA55 之间,有 203 字节的空洞。因此,源程序的第 102 行,用于声明 203 为 0 的 数值来填补。 为了方便,伪指令 times 可用于重复它后面的指令若干次。比如 times 20 mov ax,bx 将在编译时重复生成 mov ax,bx 指令 20 次,即重复该指令的机器码(89 D8)20 次。 因此 times 203 db 0 将会在编译时保留 203 个为 0 的字节。 5.6.2 代码的保存和编译 本章的代码是现成的,配书源代码解压缩之后,可以在文件夹“c05”里找到,文件名为 c05_mbr.asm。打开该文件,将其编译成 c05_mbr.bin。 该文件的大小为 512 字节,可以用配书工具 HexView 来查看其内容,如图 5-8 所示。 图 5-8 用配书工具 HexView 查看 c05_mbr.bin 的内容 显而易见,在编译之后,源程序中的标号、注释、伪指令都统统消失了,只剩下纯粹的机器指 令和数据。那些需要在编译阶段决定的内容,也都有了确切的值。 5.7 加载和运行主引导扇区代码 86 5.7.1 把编译后的指令写入主引导扇区 第 5 章 编写主引导扇区代码 在第 4 章,我们已经安装了 VirtualBox 虚拟机软件,并在它里面创建了一台名为 LEARN-ASM 的虚拟计算机。除此之外,还为它创建了一块虚拟硬盘。 虚拟硬盘其实是一个扩展名为“.vhd”的 Windows 文件,具体的文件名和创建位置只有你 自己知道。但是,无论如何,你现在都可以将我们刚刚编译好的代码写入这个虚拟硬盘的主引 导扇区里。 如图 5-9 所示,你首先要启动配书工具 FixVhdWr,在第一个界面内选择要写入的虚拟硬盘文 件。取决于你的实际情况,虚拟硬盘的文件名和路径可能与图中不同,仅供参考。 87 x86 汇编语言:从实模式到保护模式 图 5-9 选择虚拟硬盘文件(参考图例) 然后,在下一个界面,选择刚刚编译好的二进制文件,如图 5-10 所示。 图 5-10 选择编译好的二进制文件(参考图例) 如图 5-11 所示,在最后一个界面,保持默认的选择(即选择“LBA 连续直写模式”),然后单 击“写入文件”。当出现红色字体的“数据写入完成,本次共操作了 1 个扇区”时,说明数据的写 入已经成功完成。 图 5-11 将二进制文件写入虚拟硬盘 最后要交待一句,千万不要在虚拟计算机 LEARN-ASM 运行的时候进行数据写入操作,因 88 第 5 章 编写主引导扇区代码 为虚拟硬盘文件正被 VirtualBox 以独占的方式使用。否则的话,会导致数据写入失败。 5.7.2 启动虚拟机观察运行结果 在 Virtual Box 软件的主界面上,选择“LEARN-ASM”计算机,然后单击“运行”按钮。 如果你是第一次运行虚拟计算机,有可能会出现“首次运行向导”。如图 5-12 所示,这个向导 程序的目的是指引你安装一个操作系统,比如 Windows 之类的。如果真的出现这么一个向导的话, 直接单击“取消”按钮即可。 图 5-12 VirtualBox 虚拟机的首次运行向导 另外,取决于你的物理主机安装了什么类型的声卡。如果安装的是高清晰度音频( High Definition Audio,HDA),那么,虚拟机也会以此为样例创建一个虚拟声卡。 问题是,HDA 太过于智能化了,它甚至能够检测到你的扬声器和话筒是否已经插上。如果 没有插上,VirtualBox 虚拟计算机在启动的过程中也会弹出一个问题报告对话框,大致的意思是 有些设备不能打开,依赖于这些设备的程序可能会被挂起(待在内存里,不会得到处理器的光顾)。 如果发生这样的事情,请选中“不要再显示这个信息”,然后直接单击“确定”按钮,如图 5-13 所示。 最后,如果一切顺利的话,程序的运行效果如图 5-14 所示。 图 5-13 高清晰度音频的问题报告对话框 图 5-14 本章程序在虚拟计算机中的运行效果 5.7.3 程序的调试 程序员的工作就像是在历险,困难重重,途中不可避免地要遇上暗礁。有时候,少了一个字符, 89 x86 汇编语言:从实模式到保护模式 或者多了一个字符,或者拼错了字符,程序就无法成功编译;有时候,尽管能够编译,但程序中存 在逻辑错误,少写了语句,算法不对,运行的时候也得不到正确结果。 有时候,错误的原因很简单,就是因为马虎和误操作,但很难知道问题出在哪里。等到你终于 发现的时候,一天,甚至几天的时间已经花掉了。在这种情况下,没有调试工具来找到程序中隐藏 的错误是不行的。有时候,即使有调试工具的帮助,也会令人筋疲力尽,不过有总比没有好。 调试工具并不是智能到可以自动发现程序中的错误,这是不可能的。但是,它可以单步执行你 的程序(每执行一条指令后就停下来),或者允许你在程序中设置断点,当它执行到断点位置时就 停下来。这时,它可以显示处理器各个寄存器的内容,或者内存单元里的内容。因此,你可以根据 机器的状态来判断程序的执行结果是否达到了预期。通过这种方式,你可以逐步逼近出现问题的地 方,直到最终发现问题的所在。市面上有多种流行的程序调试工具软件,但它们通常都象你用的其 它软件一样工作在操作系统之上。麻烦的是,本书中的程序全都只能运行在没有操作系统的祼机下。 这意味着,所有流行的调试工具都不可用。不过,好消息是,一款叫做 bochs 的软件可以帮助你。 bochs 是开源软件,是你唯一可选择的调试器。开源意味着,你不用花钱购买就可以使用它。 它用软件来模拟处理器取指令和执行指令的过程,以及整个计算机硬件。当它开始运行时,就直接 模拟计算机的加电启动过程。正是因为如此,它才有可能做一些调试工作。 很重要的一点是,它本身就是一个虚拟机,类似于 VirtualBox。因此,它也就很容易让你单步 跟踪硬盘的启动过程,查看寄存器的内容和机器状态。在本书中,我们的程序都是直接从 BIOS 那 里接管处理器的控制权,因此,bochs 的这个特点正好能够用来完成调试工作。不像本书中使用的 其他工具,bochs 的使用方法在网上很容易搜索到。网友王南洋为本书制作了一个 bochs 的简易教 程,它的下载地址是: http://download.csdn.net/detail/sholber/4622176 bochs 本身的下载地址是: http://bochs.sourceforge.net/ 最后要特别说明的是,bochs 要求的虚拟硬盘文件是 img 格式的,但允许你使用 VHD 文件,只 是在启动时,会有一个小小的警告,不影响正常工作。 本章习题 1.试找出以下程序片断中隐藏的问题并进行修正: mov ax,21015 mov bl,10 div bl and cl,0xf0 2.本章的程序在内存中的加载地址是 0x0000:0x7C00,此时,指令 jmp near infi 在段内的偏移 地址是多少?试修改本章的源程序以显示该值。 3.汇编语言编译器采用助记符来方便指令的书写和阅读。比如,mov 是传送指令,div 是除法 指令。假如 Intel 公司新推出一款处理器,该处理器新增了一条指令,其机器码为 CD 88。因为是新 指令,你的 NASM 编译器肯定没有一个助记符与之相对应。在这种情况下,如何在你的程序中使 用该指令? 90 第 6 章 相同的功能,不同的代码 汇编语言是最有效率的计算机语言,由于直接面向处理器编程,编译后的机器代码执行起来速 度也是最快的。为了进一步讲解汇编语言的指令和语法,在本章里,我们采用不同的方法来实现和 上一章相同的功能。总是一成不变地做事情是不对的,在生活中,我们需要根据不同的情况分别做 出不同的应对,在计算机中,指令的执行并非总是按照它们的自然排列顺序来进行的,其执行流程 也会因为各种原因发生变化。在本章,我们就来学习三种不同的程序流程控制方法。 不同的方法需要不同的指令,甚至还要引入更多的指令。与此同时,让大家见识并比较它们的 不同之处,相信随着经验的增长,孰优孰劣,自有判断。 6.1 代码清单 6-1 本章有配套的汇编语言源程序,并围绕这些源程序进行讲解,请对照阅读。 本章代码清单:6-1(主引导扇区程序) 源程序文件:c06_mbr.asm 6.2 跳过非指令的数据区 如代码清单 6-1 所示,从源程序第 8 行到第 10 行,声明了非指令的数据。在程序的开始部 分声明这些不可执行的内容是不安全的,为此,在这些数据之前,源程序的第 6 行,是一条转 移指令 jmp near start,用来使处理器的执行流越过这些不可执行的数据,转移到后面的代码处 执行。 正如我们在上一章里讲到的,像 jmp near start 这种指令,机器指令的操作码是 0xE9,操作 数是一个 16 位的相对偏移量。 6.3 在数据声明中使用字面值 在第 5 章中,显示字符串“Label offset:”的方法是将每个字符的 ASCII 码包含在每条指令中, 即它们是作为每条指令的操作数出现的。这种方法很原始,也很笨拙。而且,如果要改变显示的内 容,则必须重新编写指令,很不方便。 在本章中,我们将要改变这种做法,使得显示字符串的手段更灵活,具体做法是专门定义一个 存放字符串的数据区,当要显示它们的时候,再用指令取出来,一个一个地传送到显示缓冲区。这 x86 汇编语言:从实模式到保护模式 样一来,负责在屏幕上显示的指令就和要显示的内容无关了。 源程序的第 8、9 行,这两行的目的是声明要显示的内容。在 NASM 里,“\”是续行符,当一 行写不下时,可以在行尾使用这个符号,以表明下一行与当前行应该合并为一行。 和上一章相同,在用伪指令 db 声明字符的 ASCII 码数据时也可以使用字面值。在编译阶段, 编译器将把’L’、’a’等转换成与它们等价的 ASCII 代码。 除了 ASCII 码,这里还声明了每个字符的显示属性值 0x07,都是已经讲过的知识,相信很好 理解。 6.4 段地址的初始化 汇编语言源程序的编译符合一种假设,即编译后的代码将从某个内存段中,偏移地址为 0 的地 方开始加载。这样一来,如果有一个标号“label_a”,它在编译时计算的汇编地址是 0x05,那么, 当程序被加载到内存后,它在段内的偏移地址仍然是 0x05,任何使用这个标号来访问内存的指令都 不会产生问题。 但是,如果程序加载时,不是从段内偏移地址为 0 的地方开始的,而是 0x7c00,那么,label_a 的实际偏移地址就是 0x7c05。这时,所有访问 label_a 的指令仍然会访问偏移地址 0x05,因为这是 在编译时就决定了的。实际上,这样的问题在上一章就遇到过。在那里,因为我们已经知道程序将 来的加载位置是 0x0000:0x7c00,所以才有了这样古怪的写法: mov [0x7c00+number+0x00],dl 不得不说,0x7c00 就是理论和现实之间的差距。 在主引导程序中,访问内存的指令很多,如果都要加上 0x7c00 无疑是很麻烦的,这个我们已 经看到了。其实,产生这个问题的根源,就是因为程序在加载时,没有从段内偏移地址为 0 的地方 开始。 好在 Intel 处理器的分段策略还是很灵活的,逻辑地址 0x0000:0x7c00 对应的物理地址是 0x07c00,该地址又是段 0x07C0 的起始地址。因此,这个物理地址其实还对应着另一个逻辑地址 0x07c0:0000,如图 6-1 所示。 92 第 6 章 相同的功能,不同的代码 图 6-1 以两个逻辑段的视角看待同一个内存区域 看到了吧?我们可以把这 512 字节的区域看成一个单独的段,段的基地址是 0x07C0,段长 512 字节。注意,该段的最大长度可以为 64KB。尽管 BIOS 将主引导扇区加载到物理地址 0x07c00 处, 但我们却可以认为它是从 0x07c0:0x0000 处开始加载的。 在这种情况下,如果执行指令 mov [0x05],dl 那么,处理器将把数据段寄存器 DS 的内容(0x07c0)左移 4 位,加上指令中指定的偏移地址(0x05), 形成物理内存地址 0x07c05,并将寄存器 DL 中的内容传送到该处。 所以,源程序第 13、14 行,通过传送指令将数据段寄存器 DS 的内容设置为 0x07c0。和以前 一样,源程序第 16、17 行,使附加段寄存器 ES 的内容指向显示缓冲区所在的段 0xb800。 6.5 段之间的批量数据传送 在本章中,要在屏幕上显示的内容,连同它们的显示属性值,都集中声明在一起。想显示它们? 那就要将它们“搬”到 0xB800 段。有多种方法可以做到这一点,但 8086 处理器提供了最好的方法, 那就是使用 movsb 或者 movsw 指令。 这两个指令通常用于把数据从内存中的一个地方批量地传送(复制)到另一个地方,处理器把它 们看成是数据串。但是,movsb 的传送是以字节为单位的,而 movsw 的传送是以字为单位的。 movsb 和 movsw 指令执行时,原始数据串的段地址由 DS 指定,偏移地址由 SI 指定,简写为 DS:SI;要传送到的目的地址由 ES:DI 指定;传送的字节数(movsb)或者字数(movsw)由 CX 指 定。除此之外,还要指定是正向传送还是反向传送,正向传送是指传送操作的方向是从内存区域的 低地址端到高地址端;反向传送则正好相反。正向传送时,每传送一个字节(movsb)或者一个字 (movsw),SI 和 DI 加 1 或者加 2;反向传送时,每传送一个字节(movsb)或者一个字(movsw) 时,SI 和 DI 减去 1 或者减去 2。不管是正向传送还是反向传送,也不管每次传送的是字节还是字, 每传送一次,CX 的内容自动减一。 如图 6-2 所示,在 8086 处理器里,有一个特殊的寄存器,叫做标志寄存器 FLAGS。作为一个 例子,它的第 6 位是 ZF(Zero Flag),即零标志。当处理器执行一条算术或者逻辑运算指令后,算 术逻辑部件送出的结果除了送到指令中指定位置(目的操作数指定的位置)外,还送到一个或非门。 学过逻辑电路课程,或者看过《穿越计算机的迷雾》这本书的人都知道,或非门的输入全为 0 时, 输出为 1;输入不全为 0,或者全部为 1 时,输出为 0。或非门的输出送到一个触发器,这就是标志 寄存器的 ZF 位。这就是说,如果计算结果为 0,这一位被置成 1,表示计算结果为零是“真”的; 否则清除此位(0)。 除此之外,它也允许通过指令设置一些标志,来改变处理器的运行状态。比如,第 10 位是方向 标志 DF(Direction Flag),通过将这一位清零或者置 1,就能控制 movsb 和 movsw 的传送方向。 源程序第 19 行是方向标志清零指令 cld。这是个无操作数指令,与其相反的是置方向标志指令 std。cld 指令将 DF 标志清零,以指示传送是正方向的。 源程序第 20 行,设置 SI 寄存器的内容到源串的首地址,也就是标号 mytext 处的汇编地址。 源程序第 21 行,设置目的地的首地址到 DI 寄存器。屏幕上第一个字符的位置对应着 0xB800 段的开始处,所以设置 DI 的内容为 0。 第 22 行,设置要批量传送的字节数到 CX 寄存器。因为数据串是在两个标号 number 和 mytext 93 x86 汇编语言:从实模式到保护模式 之间声明的,而且标号代表的是汇编地址,所以,汇编语言允许将它们相减并除以 2 来得到这个数 值。需要说明的是,这个计算过程是在编译阶段进行的,而不是在指令执行的时候。除以 2 的原因 是每个要显示的字符实际上占两字节:ASCII 码和属性,而 movsw 每次传送一个字。 图 6-2 8086 处理器的标志寄存器 第 23 行,是 movsw 指令,操作码是 0xA5,该指令没有操作数。使用 movsw 而不是 movsb 的 原因是每次需要传送一个字(ASCII 码和属性)。单纯的 movsw 只能执行一次,如果希望处理器自 动地反复执行,需要加上指令前缀 rep(repeat),意思是 CX 不为零则重复。rep movsw 的操作码是 0xF3 0xA5,它将重复执行 movsw 直到 CX 的内容为零。 6.6 使用循环分解数位 为了显示标号 number 所代表的汇编地址,源程序第 26 行用于将它的数值传送到寄存器 AX, 这个和以前是一样的。 声明标号 number 并从此处开始初始化 5 字节的目的主要是保存数位,但同时我们还想显示它 的汇编地址。为了访问标号 number 处的数位,需要获取它在内存段中的偏移地址。 为此,源程序第 29 行,通过将 AX 的内容传送到 BX,来使 BX 指向该处的偏移地址。实际上, 这等效于 mov bx,number 只不过用寄存器传递来得更快,更方便。 第 29~37 行依旧做的是分解数位的事,但用了和以往不同的方法。简单地说,就是循环。循 环依靠的是循环指令 loop,该指令出现在源程序的第 37 行: loop digit loop 指令的功能是重复执行一段相同的代码,处理器在执行它的时候会顺序做两件事: 将寄存器 CX 的内容减一; 如果 CX 的内容不为零,转移到指定的位置处执行,否则顺序执行后面的指令。 和源程序第 6 行的 jmp near start 一样,loop digit 指令也是颇具迷惑性的指令,它的机器指令操 94 第 6 章 相同的功能,不同的代码 作码是 0xE2,后面跟着一个字节的操作数,而且也是相对于标号处的偏移量,是在编译阶段,编 译器用标号 digit 所在位置的汇编地址减去 loop 指令的汇编地址,再减去 loop 指令的长度(2)来 得到的。 为了使 loop 指令能正常工作,需要一些准备。源程序第 30 行,将循环次数传送到 CX 寄存器。 因为分解 AX 中的数需要循环 5 次,故传送的值是 5。 源程序第 31 行,将除数 10 传送到寄存器 SI。 源程序第 33~37 行是循环体,每次循环都会执行这些代码,主要是做除法并保存每次得到的余 数。每次除法之前都要先将 DX 清零以得到被除数的高 16 位,这是源程序第 33 行所做的事情。 做完除法之后,第 35 行,将 DL 中得到的余数传送到由 BX 所指示的内存单元中去。这是我们 第一次接触到偏移地址来自于寄存器的情况,而在此之前,我们仅仅是使用类似于下面的指令: mov [0x05],dl mov [number],al mov [number+0x02],cl 尽管方式不同,但 mov [bx],dl 做相同的事情,那就是把 DL 中的内容,传送到以 DS 的内容为 段地址,以 BX 的内容为偏移地址的内存单元中去。注意,指令中的中括号是必需的,否则就是传 送到 BX 中,而不是 BX 的内容所指示的内存单元了。 在 8086 处理器上,如果要用寄存器来提供偏移地址,只能使用 BX、SI、DI、BP,不能使用其 他寄存器。所以,以下指令都是非法的: mov [ax],dl mov [dx],bx 原因很简单,寄存器 BX 最初的功能之一就是用来提供数据访问的基地址,所以又叫基址寄存 器(Base Address Register)。之所以不能用 SP、IP、AX、CX、DX,这是一种硬性规定,说不上有 什么特别的理由。而且,在设计 8086 处理器时,每个寄存器都有自己的特殊用途,比如 AX 是累 加器(Accumulator),与它有关的指令还会做指令长度上的优化(较短);CX 是计数器(Counter); DX 是数据(Data)寄存器,除了作为通用寄存器使用外,还专门用于和外设之间进行数据传送; SI 是源索引寄存器(Source Index);DI 是目标索引寄存器(Destination Index),用于数据传送操作, 我们已经在 movsb 和 movsw 指令的用法中领略过了。 做完一次除法,并保存了数位之后,源程序第 36 行,用于将 BX 中的内容加一,以指向下一 个内存单元。inc 是加一指令,操作数可以是 8 位或者 16 位的寄存器,也可以是字节或者字内存单 元。从功能上讲,它和 add bx,1 是一样的,但前者的机器码更短,速度更快。下面是两个例子: inc al inc word [label_a] 这上面的第 2 条指令,使用了关键字“word”,表明它操作的是内存中的一个字,段地址在段 寄存器 DS 中,偏移地址等于标号 label_a 在编译阶段的汇编地址。 源程序第 37 行,正是 loop 指令。就像我们刚才说的,它将 CX 的内容减一,并判断是否为零。 如果不为零,则跳转到标号 digit 所在的位置处执行。 很显然,在指令的地址部分使用寄存器,而不是数值或者标号(其实标号是数值的等价形式, 在编译后也是数值)有一个明显的好处,那就是可以在循环体里方便地改变偏移地址,如果使用数 值就不能做到这一点。 95 x86 汇编语言:从实模式到保护模式 6.7 计算机中的负数 6.7.1 无符号数和有符号数 为了讲解后面的内容时能够顺利一些,现在我们离开源程序,来介绍一些题外的知识。 从本书的开篇到现在,我们一直没有提到负数,就好象世界上根本没有负数一样。计算机当然 要处理负数,要不然它将没有多少实用价值。 在计算机中使用负数,这是一个容易令人产生迷惑的话题。不信?现在就开始了。 尽管我们从来没有考虑过数的正负问题,但是,事实上,我们在编写程序的时候,既可以使 用正数,也可以使用负数。如图 6-3 所示,我们在程序中用伪指令 db 声明了一些正数和一些负 数。 图 6-3 在汇编源程序中使用负数的例子 图 6-4 显示了编译后的结果。用伪指令 db 声明的数据都只有一个字节的长度, 所以很容 易在这两幅图的各个数之间建立对应关系。 图 6-4 正数和负数编译后的结果 前面的正数都很好理解,十进制数 128 对应的二进制数是 10000000,对应的十六进制数是 0x80; 十进制数 0 对应的二进制数是 00000000,对应的十六进制数是 0x00。为什么我们对此不感到新鲜? 因为这显得非常自然,从本书一开始到现在,我们就是这样工作的。 真正的麻烦在于后面的负数,比如-1,它在编译的时候,编译器会怎么做呢? 它很笨,但也很聪明。因为-1 其实等于 0-1,它就知道可以做一次减法。当然,这个减法, 96 第 6 章 相同的功能,不同的代码 不是你已经熟悉的十进制减法,这没有用,你得做二进制的减法,也就是用二进制数 0 减去二进制 数 1,结果是 …1111111111111111111111111111111111 注意左边的省略号,这是因为在相减的过程中,不停地向左边借位的结果。因此,可以说,这 个数字是很长的,取决于你什么时候停止借位。 再比如十进制数-2,可以用 0-2 来得到,在二进制的世界里,该减法是二进制数 0 减去二进 制数 10,结果是 …1111111111111111111111111111111110 97 x86 汇编语言:从实模式到保护模式 同样,相减的过程要向左借位,所以这个数字相当长。但是,最右边那一位是 0。 在计算机中,数字保存在寄存器里,而在 16 位处理器里,寄存器通常是 8 位和 16 位的。因此, 以上相减的结果,只能保留最右边的 8 位或者 16 位。举个例子,十进制数-1 在寄存器 AL 中的二 进制形式是 11111111 即 0xFF;十进制数-2 在寄存器 AL 中的二进制形式是 11111110 即 0xFE。如果是 16 位的寄存器,则相应地,要保留相减结果的最右边 16 位。因此,十进制数 -1 在 AX 寄存器中的二进制形式是 1111111111111111 即 0xFFFF;十进制数-2 在寄存器 AX 中的二进制形式是 1111111111111110 即 0xFFFE。 当然,数据还可以保存在内存中,或者编译后的二进制文件中。在二进制文件中,数据是用伪 指令 db 或者 dw 等定义的。但是,数据的表示形式和它们在寄存器中的形式相同,以下代码片断很 清楚地说明了这一点。 data0 db -1 ;初始化为 0xFF data1 db -2 ;初始化为 0xFE data2 dw -1 ;初始化为 0xFFFF data3 dw -2 ;初始化为 0xFFFE 这是很令人吃惊的。因为我们知道,0xFF 等于十进制数 255,但现在它又是十进制数-1,哪 一个才是正确的呢?我们应该以哪一个为准呢? 好吧,假设这勉强能接受的话,那么,对照一下图 6-3 和图 6-4,你会发现,0x80 既是十进制 数 128,又是十进制数-128,到底哪一个是正确的呢? 这真是令人头疼的问题,不单单是对我们,对几十年前那些计算机工程师们来说也是如此。 一个良好的解决方案是,将计算机中的数分成两大类:无符号数和有符号数。无符号数的意思是 我们不关心这些数的符号,因此也就无所谓正负,反正它们就是数而已,就像小学生一样,眼中只有 自然数。在 8 位的字节运算中,无符号数的范围是 00000000~11111111,即十进制的 0~255;在 16 位的字运算中,无符号数的范围是 0000000000000000~1111111111111111,即十进制的 0~65535; 在 将 来 要 讲 到 的 32 位 运 算 中 , 无 符 号 数 的 范 围 是 000000000000000000000000 ~ 11111111111111111111111111111111,即十进制的 0~4294967295。很显然,我们以前使用的一直是 无符号数。 相反地,有符号数是分正、负的,而且规定,数的正负要通过它的最高位来辨别。如果最高 位是 0,它就是正数;如果是 1,就是负数。如此一来,在 8 位的字节运算环境中,正数的范围 是 00000000~01111111,即十进制的 0~127;负数的范围是 10000000~11111111,即十进制的- 128~-1。 正的有符号数,和与它同值的无符号数相同,这没什么好说的,毕竟它们形式上相同,按相同 的方式处理最为方便。但是,负数就不同了,在这里,10000000~11111111 这些负数,都是用 0 减 去它们相对应的正数得到的。想知道它们各自对应的正数是谁吗?很简单,因为“负数的负数”是 正数,所以只需要用 0 减去这个负数就行。所以,你可以试试看,因为 00000000-10000000=10000000(十进制数 128) 98 第 6 章 相同的功能,不同的代码 00000000-11111111=00000001(十进制数 1) 所以,10000000~11111111 这个范围内的有符号数,对应着十进制数-128~-1。 顺便说一下,在 8086 处理器中,有一条指令专门做这件事,它就是 neg。neg 指令带有一个操 作数,可以是 8 位或者 16 位的寄存器,或者内存单元。如 neg al neg dx neg word [label_a] 它的功能很简单,用 0 减去指令中指定的操作数。例子:如果 AL 中的内容是 00001000(十进 制数 8),执行 neg al 后,AL 中的内容变为 11111000(十进制数-8);如果 AL 中的内容为 11000100 (十进制数-60),执行 neg al 后,AL 中的内容为 00111100(十进制数 60)。 相应地,在 16 位的字运算环境中,正数的范围是 0000000000000000~0111111111111111,即十进 制的 0~32767,负数的范围是 1000000000000000~1111111111111111,即十进制的-32768~-1。 不要给计算机和编译器添麻烦。既然你已经知道一个字节可以容纳的数据范围是十进制的- 128~127,就不要这样写: mov al,-200 寄存器 AL 只有 8 位,因此,编译后,-200 将被截断,机器码为 B0 38。你可以这样写: mov ax,-200 这时,编译后的机器码为 B8 38 FF。 同样的规则也适用于伪指令 db 和 dw。举例(以下均为十进制数): db 255 ;正确,可以看成声明无符号数 db -125 ;正确,数据未超范围 db -240 ;错误,超过字节所能容纳的数据范围,会被截断 dw -240 ;正确,数据未超范围 dw -30001 ;正确,数据未超范围 32 位有符号数是 16 位和 8 位有符号数的超集,16 位有符号数又是 8 位有符号数的超集, 它们互相之间有重叠的部分。正数还好说,十进制数 15,在 8 位运算环境中是 00001111,在 16 位运算环境中是 0000000000001111,没有什么区别。但是,同一个负数,其表现形式略有 差别。比如十进制数-3,它在 8 位运算中是 11111101,即 0xFD;在 16 位运算中,则是 1111111111111101,即 0xFFFD。这种差别的来源很简单,我们已经讲过了,在计算机中,- 3 是用 0 减去 3 得到的,在 8 位运算中只能保留结果的低 8 位,即 11111101(0xFD);在 16 位运算中只能保留结果的低 16 位,即 1111111111111101(0xFFFD)。 很显然,一个 8 位的有符号数,要想用 16 位的形式来表示,只需将其最高位,也就是用来辨 别符号的那一位(几乎所有的书上都称之为符号位,实际上这并不严谨),扩展到高 8 位即可。为 了方便,处理器专门设计了两条指令来做这件事:cbw(Convert Byte to Word)和 cwd(Convert Word to Double-word)。 cbw 没有操作数,操作码为 98。它的功能是,将寄存器 AL 中的有符号数扩展到整个 AX。举 个例子,如果 AL 中的内容为 01001111,那么执行该指令后,AX 中的内容为 0000000001001111; 如果 AL 中的内容为 10001101,执行该指令后,AX 中的内容为 1111111110001101。 cwd 也没有操作数,操作码为 99。它的功能是,将寄存器 AX 中的有符号数扩展到 DX:AX。 举 个 例 子 , 如 果 AX 中 的 内 容 为 0100111101111001 , 那 么 执 行 该 指 令 后 , DX 中 的 内 容 为 0000000000000000,AX 中的内容不变;如果 AX 中的内容为 1000110110001011,那么执行该指令 99 x86 汇编语言:从实模式到保护模式 后,DX 中的内容为 1111111111111111,AX 中的内容同样不变。 尽管有符号数的最高位通常称为符号位,但并不意味着它仅仅用来表示正负号。事实上,通过 上面的讲述和实例可以看出,它既是数的一部分,和其他比特一起共同表示数的大小,同时又用来 判断数的正负。 6.7.2 处理器视角中的数据类型 无符号数和有符号数的划分并没有从根本上打消我们的疑虑,即假如寄存器 AX 中的内容是 0xB23C,那么,它到底是无符号数 45628 呢,还是应当将其看成是-19908? 答案是,这是你自己的事,取决于你怎么看待它。对于处理器的多数指令来说,执行的结果和 操作数的类型没有关系。换句话说,无论你是从无符号数的角度来看,还是从有符号数的角度来看, 指令的执行结果都是正确无误的。比如 mov ah,al 这条指令显然根本不考虑操作数的类型。再比如 mov ah,0xf0 inc ah 在这里,0xf0 的二进制形式是 11110000,它既可以解释为无符号数 240(十进制),也可以解 释为有符号数-16,毕竟它的符号位是 1。无论如何,inc 是加一指令,这条指令执行后,AH 中的 内容是二进制数 11110001,既是无符号数 241,也是有符号数-15。 再考虑加法运算。比如 mov ax,0x8c03 add ax,0x05 0x8c03 的二进制形式是 1000110000000011,既可以看做是无符号数 35843(十进制),也可以 看成是有符号数-29693(十进制)。在运算过程中,数的视角要统一,如果把 0x8c03 看成是无符 号数,那么 0x05 也是无符号数;如果 0x8c03 是有符号数,那么 0x05 也是有符号数。 关键是运算后的结果。很幸运的是,add 指令同样适用于无符号数和有符号数。所以,这两条 指令执行后,AX 中的内容是 0x8c08,分别可以看成是无符号数 35848 和有符号数-29688。 再来考虑一下减法。考虑一下,如果要计算 10-3,这其实可以看成是 10+(-3)。因此,使 用以下三条指令就可以完成减法运算: mov ah,10 mov al,-3 add ah,al 正是因为这个原因,很多处理器内部不构造减法电路,而是使用加法电路来做减法。 尽管如此,为了方便起见,处理器还是提供了减法指令 sub,该指令和加法指令 add 相似,目 的操作数可以是 8 位或者 16 位通用寄存器,也可以是 8 位或者 16 位的内存单元;源操作数可以是 通用寄存器,也可以是内存单元或者立即数(不允许两个操作数同时为内存单元)。比如 sub ah,al sub dx,ax sub [label_a],ch 因为处理器没有减法运算电路,所以,举例来说,sub ah,al 指令实际上等效于下面两条指令: neg al 100 第 6 章 相同的功能,不同的代码 add ah,al 可以这么说,几乎所有的处理器指令既能操作无符号数,又能操作有符号数。但是,有几条指 令除外,比如除法指令和乘法指令。 我们已经学过除法指令 div。严格地说,它应该叫做无符号除法指令(Unsigned Divide),因为 这条指令只能工作于无符号数。换句话说,只有从无符号数的角度来解释它的执行结果才能说得通。 举个例子: mov ax,0x0400 mov bl,0xf0 div bl ;执行后,AL 中的内容为 0x04,即十进制数 4 从无符号数的角度来看,0x0400 等于十进制数 1024,0xf0 等于十进制数 240。相除后,寄存 器 AL 中的商为 0x04,即十进制数 4,完全正确。 但是,从有符号数的角度来看,0x0400 等于十进制数 1024,0xf0 等于十进制数-16。理论上, 相除后,寄存器 AL 中结果应当是 0xc0。因其最高位是“1”,故为负数,即十进制数为-64。 为了解决这个问题,处理器专门提供了一个有符号数除法指令 idiv(Signed Divide)。idiv 的指 令格式和 div 相同,除了它是专门用于计算有符号数的。如果你决定要进行有符号数的计算,必须 采用如下代码: mov ax,0x0400 mov bl,0xf0 idiv bl ;执行后,AL 中的内容为 0xc0,即十进制数-64 在用 idiv 指令做除法时,需要小心。比如用 0xf0c0 除以 0x10,也就是十进制数的除法-3904÷ 16。你的做法可能会是这样的: mov ax,0xf0c0 mov bl,0x10 idiv bl 以上的代码是 16 位二进制数除法,结果在寄存器 AL 中。除法的结果应当是十进制数-244, 遗憾的是,这样的结果超出了寄存器 AL 所能表示的范围,必然因为溢出而不正确。为此,你可能 会用 32 位的除法来代替以前的做法: xor dx,dx ;如此一来,DX:AX 中的数成了正数 mov ax,0xf0c0 mov bx,0x10 idiv bl 很遗憾,这依然是错的。十进制数-3904 的 16 位二进制形式和 32 位二进制形式是不同的。前 者是 0xf0c0,后者是 0xfffff0c0。还记得 cwd 吗?你应该用这条指令把寄存器 AX 中数的符号扩展 到 DX。所以,完全正确的写法是这样的: mov ax,0xf0c0 cwd mov bx,0x10 idiv bx 以上指令全部执行后,寄存器 AX 中的内容为 0xff0c,即十进制数-244。 主动权在你自己手上,在写程序的时候,你要做什么,什么目的,你自己最清楚。如果是 无符号数计算,必须使用 div 指令;如果你是在做有符号数计算,就应当使用 idiv 指令。 101 x86 汇编语言:从实模式到保护模式 6.8 数位的显示 一旦各个数位都分解出来了,下面的工作就是在屏幕上显示它们。源程序第 40 行,将保存有 各个数位的数据区首地址传送到基址寄存器 BX。 一共有 5 个数字要显示,其偏移地址分别是 BX(BX+0)、BX+1、BX+2、BX+3、BX+4。在 这里,BX 是基地址,一般保持不变,如果令寄存器 SI 的内容为 4,并使 SI 的内容每次减一,则可 以通过 BX+SI 来连续访问这 5 个数字。在这里,SI 的作用相当于索引,因此它被称为索引寄存器 (Index Register),或者叫变址寄存器。另一个常用的变址寄存器是 DI。 因此,源程序第 41 行,把初始的索引值 4 传送到 SI 寄存器,这是由于要先显示万位 上的数字。 源程序第 43 行,从指定的内存单元取出一个字节,传送到 AL 寄存器,偏移地址是 BX+SI。但 是,它们之间的运算并非是在编译阶段进行的,而是在指令实际执行的时候,由处理器完成的。 源程序第 44 行,将 AL 中的数字加上 0x30,以得到它对应的 ASCII 码。 源程序第 45 行,将数字 0x04 传送到寄存器 AH。0x04 是显示属性,即前面讲过的黑底红字, 无加亮,无闪烁。到此,AX 中是一个完整的字,前 8 位是显示属性值,后 8 位是字符的 ASCII 码。 源程序第 46 行,将 AX 中的内容传送到由段寄存器 ES 所指向的显示缓冲区中,偏移地址由 DI 指定。还记得吗,在前面使用 movsw 传送字符串“Label offset:”到显示缓冲区时,也使用了 DI, 当时 DI 是指向显示缓冲区首地址的(0),而且每传送一次就自动加 2。传送结束后,DI 正好指向 字符“:”的下一个存储单元。之后,DI 一直没用过,还保持着原先的内容。 注意,如图 6-5 所示,数据的传送是按低端字节序的,寄存器的低字节传送到显示缓冲区 的低地址部分(字节),寄存器的高字节传送到显示缓冲区的高地址部分(字节)。 源程序第 47 行,将 DI 的内容加上 2,以指向显示缓冲区的下一个单元。 源程序第 48 行,将 SI 的内容减 1,使得下一次的 BX+SI 指向千位数字。dec 是减一指令,和 inc 指令一样,后面跟一个操作数,可以是 8 位或者 16 位的通用寄存器或者内存单元。 B800:FFFF 寄存器AX 属性 ASCII …… 高字节 低字节 ES:DI 属性 ASCII …… B800:0000 图 6-5 低端字节序的字传送示意图 源程序第 49 行,指令 jns show 的意思是,如果未设置符号位,则转移到标号“show”所在的 位置处执行。如图 6-2 所示,Intel 处理器的标志寄存器里有符号位 SF(Sign Flag),很多算术逻辑 运算都会影响到该位,比如这里的 dec 指令。如果计算结果的最高位是比特“0”,处理器把 SF 位 102 第 6 章 相同的功能,不同的代码 置“0”,否则 SF 位置“1”。 处理器的任务是忠实地执行指令,多数时候,它不会知道你的意图,也不会知道你进行的是有 符号数运算,还是无符号数运算。如果运算结果的最高位是“1”,它唯一所能做的,就是将 SF 标 志置“1”,以示提醒,剩下的事,你自己看着办,它已经尽力了。 由于 SI 的初始值为 4,故第一次执行 dec si 后,si 的内容为 3,即二进制数 0000000000000011,符 号位是比特“0”,处理器将标志寄存器的 SF 位清“0”。于是,当执行 jns show 时,符合条件,于 是转移到标号“show”所在的位置处执行,等于是开始显示下一个数位。 当显示完最后一个数位后,SI 的内容是零。执行 dec si 指令后,由于产生了借位,实际的运算 结果是 0xffff(SI 只能容纳 16 个比特),因其最高位是“1”,故处理器将标志位 SF 置“1”,表明当 前 SI 中的结果可以理解为一个负数(-1)。于是,执行 jns show 时,条件不满足,接着执行后面 第 51 行的指令。 jns 是条件转移指令,处理器在执行它的时候要参考标志寄存器的 SF 位。除了只是在符合条件 的时候才转移之外,它和 jmp 指令很相似,它也是相对转移指令,编译后的机器指令操作数也是一 个相对偏移量,是用标号处的汇编地址减去当前指令的汇编地址,再减去当前指令的长度得到的。 6.9 其他标志位和条件转移指令 在处理器内进行的很多算术逻辑运算,都会影响到标志寄存器的某些位。比如我们已经学过的 加法指令 add、逻辑运算指令 xor 等。在下面的讲述中,请自行参考图 6-2。 6.9.1 奇偶标志位 PF 当运算结果出来后,如果最低 8 位中,有偶数个为 1 的比特,则 PF=1;否则 PF=0。例如: mov ax,1000100100101110B ;ax <- 0x892e xor ax,3 ;结果为 0x892d 顺序执行以上两条指令后,因为结果是 1000100100101101B,低 8 位是 00101110B,有偶数个 1,所以 PF=1。 再如: mov ah,00100110B ;ah <- 0x26 mov al,10000001B ;al <- 0x81 add ah,al ;ah <- 0xa7 以上,因为最后 ah 的内容是 0xa7(10100111B),包含奇数个 1,故 PF=0。 6.9.2 进位标志 CF 当处理器进行算术操作时,如果最高位有向前进位或借位的情况发生,则 CF=1;否则 CF=0。 比如: mov al,10000000B ;al <- 0x80 add al,al ;al <- 0x00 这里,寄存器 AL 自己和自己做加法运算,并因为最高位是 1 而产生进位。结果是,进位被丢 103 x86 汇编语言:从实模式到保护模式 弃,AL 中的最终结果为零。进位的产生,使得 CF=1。同时,ZF=1,PF=1。 下面是因有借位而使得 CF 为 1 的例子: mov ax,0 sub ax,1 CF 标志始终忠实地记录进位或者借位是否发生,但少数指令除外(如 inc 和 dec)。 6.9.3 溢出标志 OF 在所有的情况下,处理器都不知道你的意图,不知道你进行的是无符号数运算,还是有符号数 运算。为此,它提供了这个标志。该标志的意思是,假定你进行的是有符号数运算,如果结果超出 了目标操作数所能容纳的范围,OF=1;否则,OF=0。例如: mov ah,0xff add ah,2 执行以上两条指令后,进位标志 CF 为 1,这是肯定的了,因为最高位有进位。 寄存器 AH 可以容纳的数据范围是十进制的-128~127,假如上面的运算是有符号数运算,那 么,这实际上是在计算-1+2(十进制),AH 中的最终的结果是 1,没有超出 AH 所能表示的数的 范围范围,因此 OF=0。 再看一个例子: mov ah,0x70 add ah,ah 首先,本次相加,用二进制数来说就是 01110000+01110000=11100000,最高位没有进位,故 CF=0。 其次,从无符号数的角度来看(十进制),即 112+112=224,并未超出一个字节所能容纳的数 值上限 255,结果是正确的。 但是,从有符号数运算的角度来看(十进制),即 112+112=-32,明显是错的。错误的原因 是相加的结果(224)超出了一个字节所能容纳有符号数范围(十进制的-128~127),所以破坏了 符号位,使得结果变成了负数(-32)。在这种情况下,OF=1。 换句话说,在有符号数运算当中,溢出就意味着一个错误的计算结果。 既然如此,可以使用 16 位寄存器 AX,毕竟它能容纳的数据范围更大一些: mov ax,0x70 add ax,ax 这次,无论它是有符号数运算,还是无符号数运算,结果都是正确的。故 CF=0,OF=0。 6.9.4 现有指令对标志位的影响 由于是刚刚接触标志位,现将前面学过的指令对标志位的影响一一列举如下。在往后的学 习中,但凡遇到新的指令时,除了讲解指令的功能和用法,也会说明其对标志位的影响。 add OF、SF、ZF、AF、CF 和 PF 的状态依计算结果而定。 and OF=0,CF=0;对 SF、ZF 和 PF 的影响依计算结果而定。 cbw 不影响任何标志位。 cld DF=0,CF、OF、ZF、SF、AF 和 PF 未定义。未定义的意思是到目前为止还不 打算让该指令影响到这些标志,因此,不要在程序中依赖这些标志。 cwd 不影响任何标志位。 dec CF 标志不受影响,因为该指令通常在程序中用于循环计数,而且在循环体内通 104 第 6 章 相同的功能,不同的代码 div/idiv inc mov/movs neg std sub xor 常有依赖 CF 标志的指令,故不希望它打扰 CF 标志;对 OF、SF、ZF、AF 和 PF 的影响依计算结果而定。 对 CF、OF、SF、ZF、AF 和 PF 的影响未定义。 CF 标志不受影响,对 OF、SF、ZF、AF 和 PF 的影响依计算结果而定。 这类指令不影响任何标志位。 如果操作数为 0,则 CF=0,否则 CF=1;对 OF、SF、ZF、AF 和 PF 的影响依计 算结果而定。 DF=1,不影响其他标志位。 对 OF、SF、ZF、AF、PF 和 CF 的影响依计算结果而定。 OF=0,CF=0;对 SF、ZF 和 PF 依计算结果而定;对 AF 的影响未定义。 6.9.5 条件转移指令 “jcc”不是一条指令,而是一个指令族(簇),功能是根据某些条件进行转移,比如前面讲过的 jns,意思是 SF≠1(那就是 SF=0 了)则转移。方便起见,处理器一般提供相反的指令,如 js,意 思是 SF=1 则转移。爱上网的朋友们容易把它理解成“奸商”。 在汇编语言源代码里,条件转移指令的操作数是标号。编译成机器码后,操作数是一个立即数, 是相对于目标指令的偏移量。在 16 位处理器上,偏移量可以是 8 位(短转移)或者 16 位(相对近 转移)。 相似地,jz 的意思是 ZF 标志为 1 则转移;jnz 的意思是 ZF 标志不为 1(为 0)则转移。 jo 的意思是 OF 标志为 1 则转移,jno 的意思是 OF 标志不为 1(为 0)则转移。 jc 的意思是 CF 标志为 1 则转移,jnc 的意思是 CF 标志不为 1(为 0)则转移。 jp 的意思是 PF 标志为 1 则转移,jnp 的意思是 PF 标志不为 1(为 0)则转移。爱上网的朋友们 注意了,jp 可不是“极品”的意思。 转移指令必须出现在影响标志的指令之后,比如: dec si jns show 经验证明,像这种水到渠成的情况是很少的,多数时候,你会遇到一些和标志位关系不太明显的 问题,比如,当 AX 寄存器里的内容为 0x30 的时候转移,或者当 AX 寄存器里的内容小于 0xf0 的时 候转移,再或者,当 AX 寄存器里的内容大于寄存器 BX 里的内容时转移,这该怎么办呢? 好在处理器提供了比较指令 cmp,它需要两个操作数,目的操作数可以是 8 位或者 16 位通用 寄存器,也可以是 8 位或者 16 位内存单元;源操作数可以是与目的操作数宽度一致的通用寄存器、 内存单元或者立即数,但两个操作数同时为内存单元的情况除外。比如: cmp al,0x08 cmp dx,bx cmp [label_a],cx cmp 指令在功能上和 sub 指令相同,唯一不同之处在于,cmp 指令仅仅根据计算的结果设置相 应的标志位,而不保留计算结果,因此也就不会改变两个操作数的原有内容。cmp 指令将会影响到 CF、OF、SF、ZF、AF 和 PF 标志位。 比较是拿目的操作数和源操作数比,重点关心的是目的操作数。拿指令 cmp ax,bx 来说,我们关 心的是 AX 中的内容是否等于 BX 中的内容,AX 中的内容是否大于 BX 中的内容,AX 中的内容是否 105 x86 汇编语言:从实模式到保护模式 小于 BX 中的内容,等等,AX 是被测量的对象,BX 是测量的基准。比较的结果如表 6-1 所示。 比较结果 等于 不等于 大于 大于等于 不大于 不大于等于 小于 小于等于 不小于 不小于等于 高于 高于等于 不高于 不高于等于 低于 低于等于 不低于 不低于等于 校验为偶 检验为奇 表 6-1 各种比较结果和相应的条件转移指令 英文描述 指令 相关标志位的状态 Equal je 相减结果为零才成立,故要求 ZF=1 Not Equal jne 相减结果不为零才成立,故要求 ZF=0 适用于有符号数比较 Greater 要求:ZF=0(两个数不同,相减的结果不为零),并且 SF=OF jg (如果相减后溢出,则结果必须是负数,说明目的操作数大;如果 相减后未溢出,则结果必须是正数,也表明目的操作数大些) Greater or Equal 适用于有符号数的比较 jge 要求: SF=OF 适用于有符号数的比较 Not Greater 要求:ZF=1(两个数相同,相减的结果为零),或者 SF≠OF(如 jng 果相减后溢出,则结果必须是正数,说明源操作数大;如果相减后 未溢出,则结果必须是负数,同样表明源操作数大些) Not Greater or Equal 适用于有符号数的比较 jnge 要求:SF≠OF 适用于有符号数的比较,等同于“不大于等于” Less jl 要求:SF≠OF 适用于有符号数的比较,等同于“不大于” Less or Equal 要求:ZF=1(两个数相同,相减的结果为零),并且 SF≠OF(如 jle 果相减后溢出,则结果必须是正数,说明源操作数大;如果相减后 未溢出,则结果必须是负数,同样表明源操作数大些) Not Less 适用于有符号数的比较,等同于“大于等于” jnl 要求:SF=OF 适用于有符号数的比较,等同于“大于” Not Less or Equal 要求:ZF=0(两个数不同,相减的结果不为零),并且 SF=OF jnle (如果相减后溢出,则结果必须是负数,说明目的操作数大;如果 相减后未溢出,则结果必须是正数,也表明目的操作数大些) Above Above or Equal Not Above Not Above or Equal Below Below or Equal Not Below Not Below or Equal 适用于无符号数的比较 ja 要求:CF=0(没有进位或借位)而且 ZF=0(两个数不相同) 适用于无符号数的比较 jae 要求:CF=0(目的操作数大些,不需要借位) 适用于无符号数的比较,等同于“低于等于”(见后) jna 要求:CF=1 或者 ZF=1 适用于无符号数的比较,等同于“低于”(见后) jnae 要求:CF=1 适用于无符号数的比较 jb 要求:CF=1 适用于无符号数的比较 jbe 要求:CF=1 或者 ZF=1 适用于无符号数的比较,等同于“高于等于” jnb 要求:CF=0 适用于无符号数的比较,等同于“高于” jnbe 要求:CF=0 而且 ZF=0 Parity Even jpe 要求:PF=1 Parity Odd jpo 要求:PF=0 106 第 6 章 相同的功能,不同的代码 非常显而易见的是,如果你英语基础比较好,认识上面那些单词的话,这些指令都可以在短时 间内轻松记住。英语基础不太好的人也不要灰心,事实上,根本不需要记住这些指令和它们的测试 条件,因为我们平时很少用得了这么多。需要的时候再回过头来查查,这是个好办法,时间一长, 自然就记住了。 最后一个要讲述的条件转移指令是 jcxz(jump if CX is zero),意思是当 CX 寄存器的内容为零 时则转移。执行这条指令时,处理器先测试寄存器 CX 是否为零。例如: jcxz show 这里,“show”是程序中的一个标号。执行这条指令时,如果 CX 寄存器的内容为零,则转移; 否则不转移,继续往下执行。 6.10 NASM 编译器的$和$$标记 源程序第 51 行,用于在显示了各个数位之后,再显示一个字符“D”。目的地址是由 ES:DI 给出的,源操作数是立即数 0x0744,其中,高字节 0x07 是黑底白字的显示属性,低字节 0x44 是字符“D”的 ASCII 码。字的写入是按低端字节序的,请自行参照图 6-5。 整个程序到此结束。为了使处理器还有事做,源程序第 53 行,是一个无限循环。NASM 编译器提供了一个标记“$”,该标记等同于标号,你可以把它看成是一个隐藏在当前行行首的 标号。因此,jmp near $的意思是,转移到当前指令继续执行,它和 infi: jmp near infi 是一样的,没有区别,但不需要使用标号,更不必为给标号起一个有意义的名字而伤脑筋。 和第 5 章一样,为了得到不多不少,正好 512 字节的编译结果,同时最后两个字节还必须是 0x55 和 0xAA,需要在所有指令的后面填充一些无用的数据。 源程序第 55 行,用于重复伪指令“db 0”若干次。重复的次数是由 510-($-$$)得到的, 除去 0x55 和 0xAA 后,剩余的主引导扇区内容是 510 字节;$是当前行的汇编地址;$$是 NASM 编译器提供的另一个标记,代表当前汇编节(段)的起始汇编地址。当前程序没有定义节或 段,就默认地自成一个汇编段,而且起始的汇编地址是 0(程序起始处)。这样,用当前汇编 地址减去程序开头的汇编地址(0),就是程序实体的大小。再用 510 减去程序实体的大小, 就是需要填充的字节数。 就像处理器把内存划分成逻辑上的分段一样,源程序也应当按段来组织,划分成独立的代 码段、数据段等。从本书第 8 章开始,将引入这方面的内容。 6.11 观察运行结果 编译本章的源程序,并用 FixVhdWr 将编译后的二进制文件写入虚拟硬盘的主引导扇 区,然后启动 VirtualBox,观察运行后的结果。在你的程序无错的情况下,显示的效果应当 如图 6-6 所示。 107 x86 汇编语言:从实模式到保护模式 图 6-6 本章程序的运行结果 本章习题 1.在某程序中声明和初始化了以下的有符号数。请问,正数和负数各有多少? data1 db 0x05,0xff,0x80,0xf0,0x97,0x30 data2 dw 0x90,0xfff0,0xa0,0x1235,0x2f,0xc0,0xc5bc 2.如果可能的话,尝试编写一个主引导扇区程序来做上面的工作。 3.请问下面的循环将执行多少次: mov cx,0 delay: loop delay 108 第 7 章 比高斯更快的计算 7.1 从 1 加到 100 的故事 伟大的数学家高斯在 9 岁那年,用很短的时间完成了从 1 到 100 的累加。那原本是老师给学生 们出的难题,希望他们能老老实实地待在教室里。 高斯的方法很简单,他发现这是 50 个 101 的求和:100+1、99+2、98+3、…、50+51,于 是他很快算出结果是 101×50=5050。 1796 年,他 19 岁。有一天,他在导师每天布置的作业中遇到了一个前所未有的难题,那道题 的要求是用圆规和一把没有刻度的直尺做出正 17 边形。这道题比老师平时布置的作业都难,但年 青人不愿让自己的导师失望,花了一个晚上才解决了这个问题。 第二天,当导师看到这个结果时,惊呆了。“这道题原本不是给你的,只是一不小心夹在了给 你的作业中。”导师激动地说,“你知不知道,你解开了一道有两千多年历史的数学难题,阿基米德 没有做出来,牛顿也没有做出来,你竟然一个晚上就做出来了!你真是个天才! ” 多年以后,高斯回忆起这一幕时说:“如果有人告诉我,这是一道有两千多年历史的数学难题, 我不可能在一个晚上解决它。” 言归正传。从 1 加到 100,高斯发现了其中的规律,当然很快就能算出结果。但是计算机很蠢, 它不懂什么规律,只能从 1 老老实实地加到 100。不过,它的强项就是速度,而且不怕麻烦,当高 斯还在审题的时候,它就累加出结果了。 7.2 代码清单 7-1 本章有配套的汇编语言源程序,并围绕这些源程序进行讲解,请对照阅读。 本章代码清单:7-1(主引导扇区程序) 源程序文件:c07_mbr.asm 7.3 显示字符串 源程序第 8 行,声明并初始化了一串字符(字符串),它的最终用途是要显示在屏幕上。我们 可以直接用单引号把一串字符围起来,在编译阶段,编译器将把它们拆开,以形成一个个单独的字 节。 为了跳过没有指令的数据区,源程序第 6 行是 jmp near start 指令。 x86 汇编语言:从实模式到保护模式 源程序第 11~15 行用于初始化数据段寄存器 DS 和附加段寄存器 ES。 源程序第 18~28 行同样用于显示字符串,但采用了不同的方法,首先是用索引寄存器 SI 指向 DS 段内待显示字符串的首地址,即标号“message”所代表的汇编地址。然后,再用另一个索引寄 存器 DI 指向 ES 段内的偏移地址 0 处,ES 是指向 0xB800 段的。 字符串的显示需要依赖循环。本次采用的是循环指令 loop。loop 指令的工作又依赖于 CX 寄存 器,所以,源程序第 20 行,用于在编译阶段计算一个循环次数,该循环次数等于字符串的长度(字 符个数)。 循环体是从源程序第 22 行开始的。首先从数据段中,逻辑地址为 DS:SI 的地方取得第一个字 符,将其传送到逻辑地址 ES:DI,后者指向显示缓冲区。 紧接着,源程序第 24 行,将 DI 的内容加一,以指向该字符在显示缓冲区内的属性字节;第 25 行,在该位置写入属性值 0x07,即黑底白字。 源程序第 26、27 行,分别将寄存器 SI 和 DI 的内容加一,以指向源位置和目标位置的下一个 单元。 源程序第 28 行,执行循环。loop 指令在执行时先将 CX 的内容减一,然后根据 CX 是否为零来 决定是否开始下一轮循环。当 CX 为 0 的时候,说明所有的字符已经显示完毕。 7.4 计算 1 到 100 的累加和 接下来就是计算 1 到 100 的累加和了。处理器还没有智能到可以理解题意的程度,具体的计算 方法和计算步骤只能由人来给出。 要计算 1 到 100 的累加和,可以采取这样的办法:先将寄存器 AX 清零,再用 AX 的内容和 1 相加,结果在 AX 中;接着,再用 AX 的内容和 2 相加,结果依旧在 AX 中,……,就这样一直加 到 100。 为此,源程序第 31 行,用 xor 指令将寄存器 AX 清零;源程序第 32 行,将第一个被累加的数 “1”传送到寄存器 CX。 源程序第 34 行就开始累加了,每次相加之后,源程序第 35 行,将 CX 的内容加一,以得到下 一个将要累加的数。 源程序第 36 行,将 CX 的内容同 100 进行比较,看是不是已经累加到 100 了。如果小于等于 100,则继续重复累加过程,如果大于 100,就不再累加,直接往下执行。 最后,AX 中将得到最终的累加和。需要特别说明的是,AX 可以容纳的无符号数最大是 65535, 再大就不行了。由于我们已经知道最终的结果是 5050,所以很放心地使用了寄存器 AX。要是你从 1 加到 1000,就得考虑使用两个寄存器来计算了。 7.5 累加和各个数位的分解与显示 7.5.1 堆栈和堆栈段的初始化 110 第 7 章 比高斯更快的计算 得到了累加和之后,下面的工作是将它的各个数位分解出来,并准备在屏幕上显示,好让我们 知道这个数到底是多少。 和前两章不同,分解出来的各个数位并不保存在数据段中,而保存在一个叫做堆栈的地方。 堆栈(Stack)是一种特殊的数据存储结构,数据的存取 只能从一端进行。这样,最先进去的数据只能最后出来,最后 进去的数据倒是最先出来,这称为后进先出(Last In First Out, LIFO)。如图 7-1 所示,可以把堆栈看成一个一端开口的塑料 瓶,1 号球最先放进去,3 号球最后放进去,只能在 3 号球和 2 号球分别取出后,才能把 1 号球取出来。 听起来像是在讲如何往盒子里放东西,或者从盒子里取东 西。实际上,我们还是在讲内存,只不过是另一种特殊的读写 方式而已。 和代码段、数据段和附加段一样,堆栈也被定义成一个内 存段,叫堆栈段(Stack Segment),由段寄存器 SS 指向。 针对堆栈的操作有两种,分别是将数据推进堆栈(push) 和从堆栈中弹出数据(pop)。简单地说,就是压栈和出栈。压 图 7-1 一个说明堆栈工作原理的类比 栈和出栈只能在一端进行,所以需要用堆栈指针寄存器 SP (Stack Pointer)来指示下一个数据应当压入堆栈内的什么位置,或者数据从哪里出栈。 定义堆栈需要两个连续的步骤,即初始化段寄存器 SS 和堆栈指针 SP 的内容。源程序第 40~ 42 行用于将堆栈段的段地址设置为 0x0000,堆栈指针的内容设置为 0x0000。 到目前为止,我们已经定义了 3 个段,图 7-2 是当前的内存布局。总的内存容量是 1MB,物理 地址的范围是 0x00000~0xFFFFF,其中,假定数据段的长度是 64KB(实际上它的长度无关紧要), 占据了物理地址 0x07C00~0x17BFF,对应的逻辑地址范围是 0x07C0:0x0000~0x07C0:0xFFFF;代 码 段 和 堆 栈 段 是 同 一 个 段 , 占 据 着 物 理 地 址 0x00000 ~ 0x0FFFF , 对 应 的 逻 辑 地 址 范 围 是 0x0000:0x0000~0x0000:0xFFFF。 111 x86 汇编语言:从实模式到保护模式 图 7-2 本章程序的内存布局 虽然代码段和堆栈段在本质上指向同一块内存区域,但是不要担心,主引导程序只占据着中间 的一小部分,我们有办法让它们互不干扰。 7.5.2 分解各个数位并压栈 数位的分解还是得靠做除法。源程序第 44 行用于把除数 10 传送到寄存器 BX。 以往分解寄存器 AX 中的数时,固定是分解 5 次,得到 5 个数位。但这也存在一个缺点,如果 AX 中的数很小时,在屏幕上显示的数左边都是“0”,这当然是很别扭的。为此,本章的源程序做 了改善,每次除法结束后,都做一次判断,如果商为 0 的话,分解过程可以提前结束。 但是,由于每次得到的数位是压入堆栈的,将来还要反序从堆栈中弹出,为此,必须记住实际 上到底有多少个数位。源程序第 45 行,将寄存器 CX 清零,并在后面的代码中用于累计有多少个 数位。 源程序第 47~53 行也是一个循环体,每执行一次,分解出一个数位。每次分解时,CX 加一, 表明数位又多了一个,这是源程序第 47 行所做的事。 源程序第 48、49 行,将 DX 清零,并和 AX 一起形成 32 位的被除数。 分解出的数位将来要显示在屏幕上,为了方便,源程序第 50 行,直接将 AL 中的商“加上” 0x30,以得到该数字所对应的 ASCII 码。 注意上一段话中的引号。这并不是真正的加法,or 并不是相加的指令,但由于此处的特殊情况, 使得 or 指令的执行结果和相加是一样的。 与 xor 一样,or 也是逻辑运算指令。不同之处在于,or 执行的是逻辑“或”。数字逻辑中的“或” 用于表示两个命题并列的情况。如果 0 代表假,1 代表真,那么: 0 or 0 = 0 112 第 7 章 比高斯更快的计算 0 or 1 = 1 1 or 0 = 1 1 or 1 = 1 在处理器内部,or 指令的目的操作数可以是 8 位或者 16 位的通用寄存器,或者包含 8/16 位实际操作数的内存地址,源操作数可以是与目的操作数数据宽度相同的通用寄存器、内存单 元或者立即数。比如: or al,cl or ax,dx or [label_a],bx or byte [label_a],0x55 or 指令不允许目的操作数和源操作数都是内存单元的情况。当 or 指令执行时,两个操作数相 对应的比特之间分别进行各自的逻辑“或”运算,结果位于目的操作数中。举个例子,以下指令执 行后,寄存器 AL 中的内容是 0xff。 mov al,0x55 or al,0xaa 再来看源程序第 50 行,因为每次是除以 10,所以在寄存器 DL 中得到的余数,其高 4 位必 定为 0。又由于 0x30 的低 4 位是 0,高 4 位是 3,所以,DL 中的内容和 0x30 执行逻辑“或”后, 相当于是将 DL 中的内容和 0x30 相加。这是用逻辑“或”指令做加法的一个特例。 or 指令对标志寄存器的影响是:OF 和 CF 位被清零,SF、ZF、PF 位的状态依计算结果而定, AF 位的状态未定义。 与 or 相对应的是逻辑与“and”。如果 0 代表假,1 代表真,那么 0 and 0 = 0 0 and 1 = 0 1 and 0 = 0 1 and 1 = 1 相应地,处理器设计了 and 指令。在 16 位处理器上,and 指令的两个操作数都应当是字节或者 字。其中,目的操作数可以是通用寄存器和内存单元;源操作数可以是通用寄存器、内存单元或者 立即数,但不允许两个操作数同时为内存单元,而且它们在数据宽度上应当一致。比如: and al,0x55 and ch,cl and ax,dx and [label_a],ah and word [label_a],0xf0f0 and dx,[label_a] 注意,“label_a”是一个标号,下同。 当这些指令执行时,两个操作数对应的各个比特位分别进行逻辑“与”,结果保存在目的 操作数中。因此,下面的这些指令执行后,寄存器 AX 中的结果是二进制数 1000000000000100, 即 0x8004: mov ax,1001_0111_0000_0100B and ax,1000_0000_1111_0111B and 指令执行后,OF 和 CF 位被清零,SF、ZF、PF 位的状态依计算结果而定,AF 位的状 113 x86 汇编语言:从实模式到保护模式 态未定义。各个数位的 ASCII 码是压入堆栈中的。源程序第 51 行,push 指令的作用是将寄存 器 DX 的内容压入堆栈中。在 16 位的处理器上,push 指令的操作数可以是 16 位的寄存器或者 内存单元。例如: push ax push word [label_a] 你可能觉得奇怪,push 指令只接受 16 位的操作数,为什么要对内存操作数使用关键字“word”。 事实上,8086 处理器只能压入一个字;但其后的处理器允许压入字、双字或者四字,因此,关键字 是必不可少的。 就 8086 处理器来说,因为压入堆栈的内容必须是字,所以,下面的指令都是非法的: push al push byte [label_a] 处理器在执行 push 指令时,首先将堆栈指针寄存器 SP 的内容减去操作数的字长(以字节为单 位的长度,在 16 位处理器上是 2),然后,把要压入堆栈的数据存放到逻辑地址 SS:SP 所指向的内 存位置(和其他段的读写一样,把堆栈段寄存器 SS 的内容左移 4 位,加上堆栈指针寄存器 SP 提供 的偏移地址)。 如图 7-3 所示,代码段和堆栈段是同一个段,所以段寄存器 CS 和 SS 的内容都是 0x0000。而 且,堆栈指针寄存器 SP 的内容在源程序第 42 行被置为 0。所以,当 push 指令第一次执行时,SP 的内容减 2,即 0x0000-0x0002=0xFFFE,借位被忽略。于是,被压入堆栈的数据,在内存中的位 置实际上是 0x0000:0xFFFE。push 指令的操作数是字,而且 Intel 处理器是使用低端字节序的,故低 字节在低地址部分,高字节在高地址部分,正好占据了堆栈段的最高两个字节位置。 这只是第一次压栈操作时的情况。以后每次压栈时,SP 都要依次减 2。很明显,不同于代码段, 代码段在处理器上执行时,是由低地址端向高地址端推进的,而压栈操作则正好相反,是从高地址 端向低地址端推进的。 push 指令不影响任何标志位。 0000:FFFF 0000:FFFE 高字节 低字节 第一次压栈时,SP=0xFFFE 堆栈的推进方向 …… 0000:7C00 主引导扇区的内容 0000:0000 114 程序的执行方向 第 7 章 比高斯更快的计算 图 7-3 第一次执行压栈操作时的内存状态 源程序第 52、53 行,判断本次除法结束后,商是否为 0。如果不为零,则再循环一次;如果为 零,则表明不需要再继续分解了。 7.5.3 出栈并显示各个数位 压栈的次数(数位的个数)取决于 AX 中的数有多大,位于寄存器 CX 中。数位是按“个位”、 “十位”、“百位”、“千位”、“万位”的顺序依次压栈的(实际情况取决于数的大小),出栈正好相反。 所以,可以顺序将它们弹出堆栈并显示在屏幕上。 源程序第 57 行,pop dx 指令的功能是将逻辑地址 SS:SP 处的一个字弹出到寄存器 DX 中,并 将 SP 的内容加上操作数的字长(2)。 和 push 指令一样,pop 指令的操作数可以是 16 位的寄存器或者内存单元。例如: pop ax pop word [label_a] pop 指令执行时,处理器将堆栈段寄存器 SS 的内容左移 4 位,再加上堆栈指针寄存器 SP 的内 容,形成 20 位的物理地址访问内存,取得所需的数据。然后,将 SP 的内容加操作数的字长,以指 向下一个堆栈位置。 pop 指令不影响任何标志位。 源程序第 58 行将弹出的数据写入显示缓冲区。索引寄存器 DI 的内容是在前面显示字符串时用 过的,期间一直没有改变过,它现在指向显示缓冲区中字符串之后的位置。 接着,源程序第 59~61 行,将字符显示属性写入字符之后的单元,并再次递增 DI 以指向显示 缓冲区中下一个字符的位置。 源程序第 62 行,每次执行 loop 指令时,处理器都是先将寄存器 CX 减一。当所有的数位都弹 出和显示以后,CX 必定为零,这将导致退出循环。 当处理器最后一次执行出栈操作后,堆栈指针寄存器 SP 的内容将恢复到最开始设置时的状态, 即它的内容重新为 0。 7.5.4 进一步认识堆栈 关于堆栈,这里有几点说明。 第一,push 指令的操作数可以是 16 位寄存器或者指向 16 位实际操作数的内存单元地址, push 指令执行后,压入堆栈中的仅仅是该寄存器或者内存单元里的数值,与该寄存器或内存单 元不再相干。所以,下面的指令是合法而且正确的: push cs pop ds 这两条指令的意思是,将代码段寄存器的内容压栈,并弹出到数据段寄存器 DS。如此一来, 代码段和数据段将属于同一个内存段。实际上,这两条指令的执行结果,和以下指令的执行结果相 同: mov ax,cx mov ds,ax 第二,堆栈在本质上也只是普通的内存区域,之所以要用 push 和 pop 指令来访问,是因为你 115 x86 汇编语言:从实模式到保护模式 把它看成堆栈而已。实际上,如果你把它看成是普通的数据段而忘掉它是一个堆栈,那么它将不再 神秘。 引入堆栈和 push、pop 指令只是为了方便程序开发。临时保存一个数值到堆栈中,使用 push 指令是最简洁、最省事的,但如果你不怕麻烦,可以不使用它。所以,下面的代码可以用来取代 push ax 指令: sub sp,2 mov bx,sp mov [ss:bx],ax 同样,pop ax 指令的执行结果和下面的代码相同: mov bx,sp mov ax,[ss:bx] add sp,2 但是显而易见,push 和 pop 指令更方便,毕竟与堆栈访问有关的一切都是由处理器自动维护 的。 第三,要注意保持堆栈平衡,防止数据访问越界。尤其是在编写程序前,必须充分估计所需要 的堆栈空间,以防止破坏有用的数据。特别是在堆栈段和其他段属于同一个段的时候。如图 7-3 所 示,堆栈段和代码段属于同一个内存段,段地址都是 0x0000,段的长度都是 64KB。主引导程序的 长度是 512(0x200)字节,从偏移地址 0x7c00 延伸到 0x7e00。堆栈是向下增长的,它们之间有 0xffff -0x7e00+1=0x8200 字节的空档。通常来说,我们的程序是安全的,因为不可能压入这么多的数 据。 但是,不能掉以轻心,堆栈定义得过小,而且程序编写不当,导致堆栈破坏了有用数据的情况 也时有发生。尽管不能完全阻止程序中的错误,但是,通过将堆栈定义到一个单独的 64KB 段,可 能会好一点。这样,无论任何时候,即使是 push 指令位于一个无限循环中,堆栈指针寄存器 SP 的 内容也永远只会在 0x0000~0xFFFF 之间来回滚动,不会影响到其他内存段。 116 第 7 章 比高斯更快的计算 7.6 程序的编译和运行 编译源程序 7-1,然后将生成的二进制文件 c07_mbr.bin 写入虚拟硬盘的主引导扇区,启动虚拟 机观察程序运行结果。如果程序无误,结果应当如图 7-4 所示。 图 7-4 本章程序在虚拟机中的运行结果 7.7 8086 处理器的寻址方式 处理器的一生,是忙碌的一生,只要它工作着,就必定是在取指令和执行指令。它就像勤劳的 牛,吃的是电,挤出来的还是电,不过是另一种形式的电。 多数指令操作的是数值。比如: mov ax,0x55aa 这条指令执行时,把 0x55aa 传送到寄存器 AX。再如: add dx,cx 这是把寄存器 DX 中的数据和寄存器 CX 中的数据相加,并把结果保留在 DX 中,同时保持 CX 中原有的内容不变。 所以,如果你问处理器整天忙什么,它一定会说:“还能有什么,就是和数打交道!” 既然操作和处理的是数值,那么,必定涉及数值从哪里来,处理后送到哪里去,这称为寻址方 式(Addressing Mode)。简单地说,寻址方式就是如何找到要操作的数据,以及如何找到存放操作 结果的地方。 实际上,大多数的寻址方式我们都已经使用过,现在所做的只是一个完整的总结。当然,这里 的讲解仅限于 16 位的处理器。 7.7.1 寄存器寻址 最简单的寻址方式是寄存器寻址。就是说,指令执行时,操作的数位于寄存器中,可以从寄存 器里取得。这种寻址方式的例子还是很多的,比如: mov ax,cx 117 x86 汇编语言:从实模式到保护模式 add bx,0xf000 inc dx 以上,第一条指令的两个操作数都是寄存器,是典型的寄存器寻址;第二条指令的目的操作数 是寄存器,因此,该操作数也是寄存器寻址;第三条指令就更不用说了。 7.7.2 立即寻址 立即寻址又叫立即数寻址。也就是说,指令的操作数是一个立即数。比如: add bx,0xf000 mov dx,label_a 以上,第一条指令的目的操作数采用了寄存器寻址方式,用于提供被加数;第二个操作数(源 操作数)用于给出加数 0xf000。这是一个直接给出的数值,是立即在指令中给出的,最终参与加法 运算的就是它,不需要通过其他方式寻找,故称为立即数。这也是一种寻址方式,称为立即寻址。 在第二条指令中,目的操作数也采用的是寄存器寻址方式。尽管源操作数是一个标号,但是, 标号是数值的等价形式,代表了它所在位置的汇编地址。因此,在编译阶段,它会被转化为一个立 即数。因此,该指令的源操作数也采用了立即寻址方式。 7.7.3 内存寻址 寄存器寻址的操作数位于寄存器中,立即寻址的操作数位于指令中,是指令的一部分。传统上, 这是两种速度较快的寻址方式。但是,它们也有局限性。一方面,我们不可能总是知道要操作的数 是多少,因此也就不可能总是在指令中使用立即数;另一方面,寄存器的数量有限,不可能总指望 在寄存器之间来回倒腾。 考虑到内存容量巨大,所以,在指令中使用内存地址,来操作内存中的数据,是最理想不过了。 正是因为内存访问如此重要,处理器才拥有好几种内存寻址方式。 我们知道,8086 处理器访问内存时,采用的是段地址左移 4 位,然后加上偏移地址,来形成 20 位物理地址的模式,段地址由 4 个段寄存器之一来提供,偏移地址要由指令来提供。 因此,所谓的内存寻址,实际上就是寻找偏移地址,这称为有效地址(Effective Address,EA)。 换句话说,就是如何在指令中提供偏移地址,供处理器访问内存时使用。 1.直接寻址 使用该寻址方式的操作数是一个偏移地址,而且给出了该偏移地址的具体数值。比如: mov ax,[0x5c0f] add word [0x0230],0x5000 xor byte [es:label_b],0x05 但凡是表示内存地址的,都必须用中括号括起来。 以上,在第一条指令中,源操作数使用的是直接寻址方式,当这条指令执行时,处理器将数据 段寄存器 DS 的内容左移 4 位,加上这里的 0x5c0f,形成 20 位物理地址。接着,从该物理地址处取 得一个字,传送到寄存器 AX 中。 在第二条指令中,目的操作数采用的是直接寻址方式。当这条指令执行时,处理器用同样的方 法,访问由段寄存器 DS 指向的数据段,并把指令中的立即数加到该段中偏移地址为 0x0230 的字单 118 第 7 章 比高斯更快的计算 元里。 尽管在第三条指令中,目的操作数使用了标号和段超越前缀,但它依然属于直接寻址方式。原 因很简单,标号是数值的等价形式,在指令编译阶段,会被转换成数值;而段超越前缀仅仅用来改 变默认的数据段。 2.基址寻址 很多时候,我们会有一大堆的数据要处理,而且它们通常都是挨在一起,顺序存放的。比如: buffer dw 0x20,0x100,0x0f,0x300,0xff00 假如要将这些数据统统加一,那么,使用直接寻址的指令序列肯定是这样的: inc word [buffer] inc word [buffer+2] inc word [buffer+4] … 这样做好吗?当然,程序本身是没有问题的。但是,考虑到它的效率和代码的简洁性,特别是 这些工作用循环来完成会更好,可以使用基址寻址。所谓基址寻址,就是在指令的地址部分使用基 址寄存器 BX 或者 BP 来提供偏移地址。比如: mov [bx],dx add byte [bx],0x55 以上,第一条指令中的目的操作数采用了基址寻址。在指令执行时,处理器将数据段寄存器 DS 的内容左移 4 位,加上基址寄存器 BX 中的内容,形成 20 位的物理地址。然后,把寄存器 DX 中的内容传送到该地址处的字单元里。 第二条指令中的目的操作数也采用的是基址寻址。指令执行时,将数据段寄存器 DS 的内容左 移 4 位,加上寄存器 BX 中的内容,形成 20 位的物理地址。然后,将指令中的立即数 0x55 加到该 地址处的字节单元里。 使用基址寻址可以使代码变得简洁高效。比如,可以用以下的代码来处理上面的批量加一任务: mov bx,buffer mov cx,4 lpinc: inc word [bx] add bx,2 loop lpinc 基址寻址的寄存器也可以是 BP。比如: mov ax,[bp] 这条指令的源操作数采用了基址寻址方式。但是,与前面的指令相比,它稍微有些特殊。原因 在于,它采用是基址寄存器 BP,在形成 20 位的物理地址时,默认的段寄存器是 SS。也就是说, 它经常用于访问堆栈。这条指令执行时,处理器将堆栈段寄存器 SS 的内容左移 4 位,加上寄存器 BP 的内容,形成 20 位的物理地址,并将该地址处的一个字传送到寄存器 AX 中。 我们知道,堆栈是后进先出的数据结构,访问堆栈的一般方法是使用 push 和 pop 指令。比如 我们用以下的指令压入两个数据: mov ax,0x5000 push ax 119 x86 汇编语言:从实模式到保护模式 mov ax,0x7000 push ax 很显然,如果要用 pop 指令弹出数据,就必须先弹出 0x7000,才能弹出 0x5000,除非你改变 了堆栈指针 SP 的内容,否则这个顺序是不可能改变的。 但是,有时候我们希望,而且必须得越过这种限制,去访问栈中的内容,还不能破坏堆栈的状 态,特别是堆栈指针寄存器 SP 的内容,使得 push 和 pop 操作能正常进行。一个典型的例子是高级 语言里的函数调用,所有的参数都位于堆栈中。为了能访问到那些被压在栈底的参数,这时,BP 就能派上用场: mov ax,0x5000 push ax mov bp,sp mov ax,0x7000 push ax mov dx,[bp] ;dx 中的内容为 0x5000 以上,在压入 0x5000 之后,立即将堆栈指针 SP 保存到 BP。后面,尽管栈顶的数据 0x7000 没 有出栈,但依然可以用 BP 取出压在堆栈下面的 0x5000。如此一来,正常的 push 和 pop 操作照样 进行,同时,还能访问到栈中的参数。 基址寻址允许在基址寄存器的基础上使用一个偏移量。有时候,这使得它更加灵活。比如: mov dx,[bp-2] 处理器在执行时,将段寄存器 SS 的内容左移 4 位,加上 BP 的内容,再减去偏移量 2 以形成 物理地址。这样一来,在保持基址寄存器 BP 内容不变的情况下,就可以访问栈中的任何元素。 这里,偏移量仅用于在指令执行时形成有效地址,不会改变寄存器 BP 的原有内容。 这种增加偏移量的做法也适用于基址寄存器 BX。以下代码是前面那个批量加一任务的新版 本: xor bx,bx mov cx,4 lpinc: inc word [bx+buffer] add bx,2 loop lpinc 以上代码和前一个版本相比,没有太大变化,区别仅仅在于,BX 现在是从 0 开始递增的,inc 指令操作数的偏移地址由 BX 和标号 buffer 所代表的值相加得到。相加操作在指令执行时进行,仅 用于形成有效偏移地址,不会影响到 BX 寄存器的内容。 3.变址寻址 变址寻址类似于基址寻址,唯一不同之处在于这种寻址方式使用的是变址寄存器(或称索引寄 存器)SI 和 DI。例如: mov [si],dx add ax,[di] xor word [si],0x8000 和基址寻址一样,当带有这种操作数的指令执行时,除非使用了段超越前缀,处理器会访问由 120 第 7 章 比高斯更快的计算 段寄存器 DS 指向的数据段,偏移地址由寄存器 SI 或者 DI 提供。 同样地,变址寻址方式也允许带一个偏移量: mov [si+0x100],al and byte [di+label_a],0x80 以上第二条指令中,尽管使用的是标号,但本质上属于一个编译阶段确定的数值。 4.基址变址寻址 让处理器支持多种寻址方式会增加硬件上的复杂性,但可以增强它的数据处理能力,这么做是 值得的。说到数据处理,下面是一个稍微复杂一些的任务: string db ’abcdefghijklmnopqrstuvwxyz’ 以上声明了标号“string”并初始化了 26 个字节的数据。现在,你的任务是,将这 26 字节的数 据在原地反向排列。 这个问题不难,所以你可能很快想到使用堆栈,先将这 26 个数据压栈,再反向出栈,因为堆 栈是后进先出的,正好符合要求。代码是这样的(代码段、堆栈段初始化的代码统统省略): mov cx,26 ;循环次数,从 26 到 1,共 26 次 mov bx,string ;数据区首地址(基地址) lppush: mov al,[bx] push ax inc bx loop lppush ;循环压栈 mov cx,26 mov bx,string lppop: pop ax mov [bx],al inc bx loop lppop ;循环出栈 这的确是个好办法。不过,8086 处理器也支持一种基址加变址的寻址方式,简称基址变址寻址, 可能用起来更方便。 使用基址变址的操作数可以使用一个基址寄存器(BX 或者 BP),外加一个变址寄存器(SI 或 者 DI)。它的基本形式是这样的: mov ax,[bx+si] add word [bx+di],0x3000 以上,第一条指令的源操作数采用了基址变址寻址。当处理器执行这条指令时,把数据段寄存 器 DS 的内容左移 4 位,加上基址寄存器 BX 的内容,再加上变址寄存器 SI 的内容,共同形成 20 位的物理地址。然后,从该地址处取得一个字,传送到寄存器 AX 中。 第二条指令与第一条指令类似,只不过是加法指令,它的目的操作数采用了基址变址寻址,源 操作数采用的是立即寻址。这条指令执行时,处理器访问由段寄存器 DS 指向的数据段,加上由 BX 和 DI 相加形成的偏移地址,共同形成 20 位的物理地址,然后将立即数 0x3000 加到该地址处的字 121 x86 汇编语言:从实模式到保护模式 单元里。 采用基址变址寻址方式的排序代码如下: mov bx,string mov si,0 mov di,25 ;数据区首地址 ;正向索引 ;反向索引 order: mov ah,[bx+si] mov al,[bx+di] mov [bx+si],al mov [bx+di],ah ;以上 4 行用于交换首尾数据 inc si dec di cmp si,di jl order ;首尾没有相遇,或者没有超越,继续 和前面使用堆栈的代码相比,指令的数量没有明显减少,这说明任务还不够复杂,也许只能这 么解释了。但是,它同样很方便,很有效,不是吗? 同样地,基址变址寻址允许在基址寄存器和变址寄存器的基础上带一个偏移量。比如: mov [bx+si+0x100],al and byte [bx+di+label_a],0x80 本章习题 1.修改代码清单 7-1 的第 31~37 行,使用 loop 指令来计算累加和。要求:CX 寄存器既用来 控制循环次数,同时还用来作为被累加的数。 2.在 16 位的处理器上,做加法的指令是 add,但它每次只能做 8 位或 16 位的加法。除此之外, 还有一个带进位加法指令 adc(Add With Carry),它的指令格式和 add 一样,目的操作数可以是 8 位 或 16 位的通用寄存器和内存单元,源操作数可以是与目的操作数宽度一致的通用寄存器、内存单元 和立即数(但目的操作数和源操作数同为内存单元的除外)。不过,adc 指令在执行的时候,除了将目 的操作数和源操作数相加,还要加上当前标志寄存器的 CF 位。也就是说,视 CF 位的状态,还要再 加 0 或者加 1。这样一来,用 adc 指令配合 add 指令,就可以计算 16 位以上的加法。 adc 指令对 OF、SF、ZF、AF、CF 和 PF 的影响视计算结果而定。 现在,请编写一段主引导扇区程序,计算 1 到 1000 的累加和,并在屏幕上显示结果。 122 第 8 章 硬盘和显卡的访问与控制 8.1 本章代码清单 8.1.1 本章意图 我很想一口吃成个胖子,但这很不现实,汇编语言和处理器知识的学习也不例外。 一直以来,我在每一章都会介绍一些新的处理器指令,以及一些处理器工作方式的内容,比如 分段、堆栈操作、循环等,本章当然也不例外。不得不说的是,因为涉及的东西较多,本章的学习 任务尤其繁重。 总是把目光放在一个小小的主引导扇区上,这没什么意思。现在,是我们离开它,向自由天地 迈进的时候了。但是,应该迈向哪里呢? 主引导扇区是处理器迈向广阔天地的第一块跳板。离开主引导扇区之后,前方通常就是操作系 统的森林,也就是我们经常听说的 DOS、Windows、Linux、UNIX 等。 操作系统也是由一大堆指令组成的,之所以将其比作“森林”,是因为它包含了更多的指令, 也许是几万条、几十万条,甚至几千万条的指令。相比之下,我们在前面编写的那些指令代码则相 形见绌了。 和主引导扇区程序一样,操作系统也位于硬盘上。操作系统是需要安装到硬盘上的,这个 安装过程不但要把操作系统的指令和数据写入硬盘,通常还要更新主引导扇区的内容,好让这 块跳板直接连着操作系统。不像我们,一直用主引导扇区来显示字符和做加法。 在个人计算机中,最流行的操作系统无疑是 Microsoft Windows,它简单易用,功能强大,最 主要的,还是一个图形化的软件。相比之下,年轻一代的人们很少知道 MS-DOS,它是 Windows 出现之前,最流行的个人计算机操作系统。不过,它不是图形界面的,而是 80×25 的字符显示模 式。即使是这样,它也曾经是个人计算机最主要、最基本的软件配置,统治了个人计算机 15 年, 直到 Windows 95 发布。 操作系统通常肩负着处理器管理、内存分配、程序加载、进程(即已经位于内存中的程序)调 度、外围设备(显卡、硬盘、声卡等)的控制和管理等任务。举个例子来说,你每天都要使用的 Windows,它可以让你看到计算机内都有几块硬盘,都安装了哪些程序(通过图标来显示),并允许 你双击图标运行这些程序,这都是托了操作系统(Windows)的福。要不然的话,这都是不可能的 事。 凭个人之力,写一个非常完善的操作系统,这几乎是不可能的事。但是,写个小程序,模拟一 x86 汇编语言:从实模式到保护模式 下它的某个功能,还是可以的。我们知道,编译好的程序通常都存放在像硬盘这样的载体上,需要 加载到内存之后才能执行。这个过程并不简单,首先要读取硬盘,然后决定把它加载到内存的什么 位置。最重要的是,程序通常是分段的,载入内存之后,还要重新计算段地址,这叫做段的重定位。 程序可以有千千万万个,但加载过程却是固定的。在本章,我们把主引导扇区改造成一个程序 加载器,或者说是一个加载程序,它的功能是加载用户程序,并执行该程序(将处理器的控制权交 给该程序)。 说到这里,我相信你一定很好奇,早就想知道 BIOS 是怎么把主引导扇区读到内存中的,又是 怎么让它从 0x0000:0x7c00 处开始执行的。好吧,本章就可以满足你的好奇心。同时我相信,通过 在本章里做这件事,可以加深你对处理器、操作系统,以及软件开发过程(包含用高级语言进行软 件编写)的理解。 8.1.2 代码清单 8-1 本章有配套的汇编语言源程序,并围绕这些源程序进行讲解,请对照阅读。 本章代码清单:8-1(主引导扇区程序/加载器),源程序文件:c08_mbr.asm 本章代码清单:8-2(被加载的用户程序),源程序文件:c08.asm 8.2 用户程序的结构 8.2.1 分段、段的汇编地址和段内汇编地址 处理器的工作模式是将内存分成逻辑上的段,指令的获取和数据的访问一律按“段地址:偏移 地址”的方式进行。相对应地,一个规范的程序,应当包括代码段、数据段、附加段和堆栈段。这 样一来,段的划分和段与段之间的界限在程序加载到内存之前就已经准备好了。 和我们以前面编写的源程序不同,代码清单 8-2 很长。当然,真正的不同之处在于,代码和数 据是以段的形式组织的。当然,因为清单很长,看起来并不是非常明显。为了清楚起见,图 8-1 给 出了整个源程序的组织结构。 NASM 编译器使用汇编指令“SECTION”或者“SEGMENT”来定义段。它的一般格式是 SECTION 段名称 或者 SEGMENT 段名称 每个段都要求给出名字,这就是段名称,它主要用来引用一个段,可以是任意名字,只要它们 彼此之间不会重复和混淆。 NASM 编译器不关心段的用途,可能也根本不知道段的用途,不知道它是数据段,还是代码段, 或是堆栈段。事实上,这都不重要,段只用来分隔程序中的不同内容。 不过,话又说回来了,作为程序员,每个段的用途,你自己是清楚的。所以,为每个段起一个 直观好记的名字,那是应该的。如图 8-1 所示,第一个段的名字是“header”,表明它是整个程序的 开头部分;第二个段的名字是“code”,表明这是代码段;第三个段的名字是“data”,表明这是数 124 据段。 第 8 章 硬盘和显卡的访问和控制 125 x86 汇编语言:从实模式到保护模式 图 8-1 用户程序的一般结构 比较重要的是,一旦定义段,那么,后面的内容就都属于该段,除非又出现了另一个段的定义。 另外,如图 8-2 所示,有时候,程序并不以段定义语句开始。在这种情况下,这些内容默认地自成一 个段。最为典型的情况是,整个程序中都没有段定义语句。这时,整个程序自成一个段。 NASM 对段的数量没有限制。一些大的程序,可能拥有不止一个代码段和数据段。 Intel 处理器要求段在内存中的起始物理地址起码是 16 字节对齐的。这句话的意思是,必须是 16 的倍数,或者说该物理地址必须能被 16 整除。 相应地,汇编语言源程序中定义的各个段,也有对齐方面的要求。具体做法是,在段定义中使 用“align=”子句,用于指定某个 SECTION 的汇编地址对齐方式。比如说,“align=16”就表示段 是 16 字节对齐的,“align=32”就表示段是 32 字节对齐的。 126 第 8 章 硬盘和显卡的访问和控制 图 8-2 程序并非以段定义开始的情况 在源程序编译阶段,编译器将根据 align 子句确定段的起始汇编地址。如图 8-3 所示,这 里定义了三个段,分别是 data1、data2 和 data3,每个段里只有一个字节的数据,分别是 0x55、 0xaa 和 0x99。 图 8-3 align 子句对段的影响(编译之前的源代码) 理论上,如果不考虑段的对齐方式,那么段 data1 的汇编地址是 0,段 data2 的汇编地址是 1, 段 data3 的汇编地址是 2。 但是,在这里,每个段的定义中都包含了要求 16 字节对齐的子句,情况便不同了。如图 8-4 所示,这是编译后的结果,因为在段 data1 之前没有任何内容,故段 data1 的起始汇编地址是 0(在 图中是 0x00000000),而且地址 0 本身就是 16 字节对齐的,符合 align 子句的要求。 段的汇编地址其实就是段内第一个元素(数据、指令)的汇编地址。因此,在段 data1 中声明 和初始化的 0x55 位于汇编地址 0x00000000 处。 段 data2 也要求是 16 字节对齐的。问题是,从汇编地址 0x00000001 开始,只有 0x00000010(十 进制的 16)才能被 16 整除。于是,编译器将 0x00000010 作为段 data2 的汇编地址,并在两个段之 间填充 15 字节的 0x00(段 data1 只有 1 字节的长度)。 段 data3 的处理与前面两个段相同。因为段 data2 只有 1 字节,故也需要在它们之间填充 15 字 节。这样,段 data3 的汇编地址就是 0x00000020(十进制的 32)。段 data3 也只有 1 字节(0x99), 所以,汇编地址 0x00000020 处是 0x99,这也是编译结果中的最后一字节。 127 x86 汇编语言:从实模式到保护模式 图 8-4 align 子句对段的影响(编译之后的二进制文件) 正如我们刚刚讨论过的,每个段都有一个汇编地址,它是相对于整个程序开头(0)的。为了 方便取得该段的汇编地址,NASM 编译器提供了以下的表达式,可以用在你的程序中: section.段名称.start 如图 8-1 所示,段“header”相对于整个程序开头的汇编地址是 section.header.start,段“code” 相对于整个程序开头的汇编地址是 section.code.start。在这个例子中,因为段“header”是在程序的 一开始定义的,它的前面没有其他内容,故 section.header.start=0。 如图 8-1 所示,段定义语句还可以包含“vstart=”子句。尽管定义了段,但是,引用某个标 号时,该标号处的汇编地址依然是从整个程序的开头计算的,而不是从段的开头处计算的。 这就很麻烦(有时候也很有用)。因此,vstart 可以解决这个问题。如图 8-1 所示,“putch”是 段 code 中的一个标号,原则上,该标号代表的汇编地址应该从程序开头计算。但是,因为段 code 的定义中有“vstart=0”子句,所以,标号“putch”的汇编地址要从它所在段的开头计算,而且从 0 开始计算。 如图 8-1 所示,同样的情形也出现在段 data 中。段 data 的定义中也有“vstart=0”子句,因此, 当我们在段 code 中引用段 data 中的标号“string”时(mov ax,string),尽管在图中没有标明,标号 “string”所代表的汇编地址是相对于其所在段 data 的。也就是说,传送到寄存器 AX 中的数值是标 号 string 相对于段 data 起始处的长度。 但是,图中最后一个段 trail 的定义中没有包含“vstart=0”子句。那就对不起了,该段内有一 个标号“program_end”,它的汇编地址就要从整个程序开头计算。因为它是整个程序中的最后一 行,从这个意义上来说,它所代表的汇编地址就是整个程序的大小(以字节计)。 8.2.2 用户程序头部 在上面,我们已经知道如何在用户程序中分段,也知道各种段定义子句对段的起始汇编地址和 段内汇编地址的影响。现在,让我们结合本章中的实例来进一步加深认识。 浏览一下本章代码清单 8-2,你会发现,本章的用户程序实际上定义了 7 个段,分别是第 7 行定义的段 header、第 27 行定义的段 code_1、第 163 行定义的段 code_2、第 173 行定义的段 data_1、 第 194 行定义的段 data_2、第 201 行定义的段 stack 和第 208 行定义的段 trail。 一般来说,加载器和用户程序是在不同的时间、不同的地方,由不同的人或公司开发的。这就 意味着,它们彼此并不了解对方的结构和功能。事实上,也不需要了解。 如图 8-5 所示,它们彼此看对方都是一个黑盒子,并不了解对方是怎么编写的,是做什么的。 但是,也不能完全是黑的,加载器必须了解一些必要的信息,虽然不是很多,但足以知道如何加载 128 用户程序。 第 8 章 硬盘和显卡的访问和控制 图 8-5 加载器与用户程序之间的协议部分示意图 这就涉及加载器的编写者,以及用户程序的编写者,他们之间是怎么协商的。他们之间必须有 一个协议,或者说协定,比如说,在用户程序内部的某个固定位置,包含一些基本的结构信息,每 个用户程序都必须把自己的情况放在这里,而加载器也固定在这个位置读取。经验表明,把这个约 定的地点放在用户程序的开头,对双方,特别是对加载器来说比较方便,这就是用户程序头部。 头部需要在源程序以一个段的形式出现。这就是代码清单 8-2 的第 7 行: SECTION header vstart=0 而且,因为它是“头部”,所以,该段当然必须是第一个被定义的段,且总是位于整个源程序 的开头。 用户程序头部起码要包含以下信息。 ① 用户程序的尺寸,即以字节为单位的大小。这对加载器来说是很重要的,加载器需要根 据这一信息来决定读取多少个逻辑扇区(在本书中,所有程序在硬盘上所占用的逻辑扇区都是连 续的)。 代码清单 8-2 中第 8 行,伪指令 dd 用于声明和初始化一个双字,即一个 32 位的数据。用户程 序可能很大,16 位的长度不足以表示 65535 以上的数值。 程序的长度取自程序中的一个标号“program_end”,这是允许的。在编译阶段,编译器将该标 号所代表的汇编地址填写在这里。该标号位于整个源程序的最后,从属于段“trail”。由于该段并没 有 vstart 子句,所以,标号“program_end”所代表的汇编地址是从整个程序的开头计算的。换句话 说,program_end 所代表的汇编地址,在数值上等于整个程序的长度。 双字在内存中的存放也是按低端序的。如图 8-6 所示,低字保存在低地址,高字保存在高地址。 同时,每个字又按低端字节序,低字节在低地址,高字节在高地址。 ② 应用程序的入口点,包括段地址和偏移地址。加载器并不清楚用户程序的分段情况, 更不知道第一条要执行的指令在用户程序中的位置。因此,必须在头部给出第一条指令的段地 址和偏移地址,这就是所谓的应用程序入口点(Entry Point)。 理想情况下,当用户程序开始运行时,执行的第一条指令是其代码段内的第一条指令。换 句话说,入口点位于其代码段内偏移地址为 0 的地方。但是,情况并非总是如此。尤其是,很 多程序并非只有一个代码段,比如本章源代码清单 8-2 就包含了两个代码段。所以,需要在用 户程序头部明确给出用户程序在刚开始运行时,第一条指令的位置,也就是第一条指令在用户 程序代码段内的偏移地址。 129 x86 汇编语言:从实模式到保护模式 图 8-6 双字数据在内存中的布局 代码清单 8-2 第 11、12 行,依次声明并初始化了入口点的偏移地址和段地址。偏移地址取自代 码段 code_1 中的标号“start”,段地址是用表达式 section.code_1.start 得到的。 代码段 code_1 是在代码清单 8-2 的第 27 行定义的: SECTION code_1 align=16 vstart=0 显而易见的是,因为段定义中包含了“vstart=0”子句,故标号 start 所代表的汇编地址是相对 于当前代码段 code_1 的起始位置,从 0 开始计算的。 入口点的段地址是用伪指令 dd 声明的,并初始化为汇编地址 section.code_1.start,这是一 个 32 位的地址。不过,它仅仅是编译阶段确定的汇编地址,在用户程序加载到内存后,需要 根据加载的实际位置重新计算(浮动)。 尽管在 16 位的环境中,一个段最长为 64KB,但它却可以起始于任何 20 位的物理地址处。你 不可能用 16 位的单元保存 20 位的地址,所以,只能保存为 32 位的形式。 ③ 段重定位表。用户程序可能包含不止一个段,比较大的程序可能会包含多个代码段和多个 数据段。这些段如何使用,是用户程序自己的事,但前提是程序加载到内存后,每个段的地址必须 重新确定一下。 段的重定位是加载器的工作,它需要知道每个段在用户程序内的位置,即它们分别位于用户程 序内的多少字节处。为此,需要在用户程序头部建立一张段重定位表。 用户程序可以定义的段在数量上是不确定的,因此,段重定位表的大小,或者说表项数是不确 定的。为此,代码清单 8-2 第 14 行,声明并初始化了段重定位表的项目数。因为段重定位表位于 两个标号 header_end 和 code_1_segment 之间,而且每个表项占用 4 字节,故实际的表项数为 (header_end – code_1_segment) / 4 这个值是在程序编译阶段计算的,先用两个标号所代表的汇编地址相减,再除以每个表项的长 度 4。 紧接着表项数的,是实际的段重定位表,每个表项用伪指令 dd 声明并初始化为 1 个双字。代 码清单 8-2 一共定义了 5 个段,所以这里有 5 个表项,依次计算段开始汇编地址的表达式并进行初 始化。 130 第 8 章 硬盘和显卡的访问和控制 8.3 加载程序(器)的工作流程 8.3.1 初始化和决定加载位置 从大的方面来说,加载器要加载一个用户程序,并使之开始执行,需要决定两件事。第一, 看看内存中的什么地方是空闲的,即从哪个物理内存地址开始加载用户程序;第二,用户程序位 于硬盘上的什么位置,它的起始逻辑扇区号是多少。如果你连它在哪里都不知道,怎么找得到它 呢! 现在,让我们把目光转移到代码清单 8-1,来看看加载器都做了哪些工作。 代码清单 8-1 第 6 行,加载器程序的一开始声明了一个常数(const): app_lba_start equ 100 常数是用伪指令 equ 声明的,它的意思是“等于”。本语句的意思是,用标号 app_lba_start 来 代表数值 100,今后,当我们要用到 100 的时候,不这样写: mov al,100 而是这样写: mov al,app_lba_start 你可能会说,这样不是更麻烦吗? 不会的,实际上这很方便。用某些教材上的话说,程序中不该使用“不可思议的数”。想想看, 如果在程序中的多个地方直接使用数值 100,那么,以后要修改它们,把它们改成 500,还得找到 所有使用这个数值的位置,一一修改,万一漏掉一个呢?如果使用常量 app_lba_start,则只需要重 新把这个常数的声明语句改成下面的形式,并重新编 译即可。 app_lba_start equ 500 常数的意思是在程序运行期间不变的数。和其他 伪指令 db、dw、dd 不同,用 equ 声明的数值不占用 任何汇编地址,也不在运行时占用任何内存位置。它 仅仅代表一个数值,就这么简单。 加载用户程序需要确定一个内存物理地址,这是 在代码清单 8-1 第 151 行用伪指令 dd 声明的,并初始 化为 0x10000 的。和前面一样,是用 32 位的单元来容 纳一个 20 位的地址: phy_base dd 0x10000 尽管我们用了一个好看的数 0x10000,但你完全 可以把用户程序加载到其他地方,只要它是空闲的。 比如,可以将这个数值改成 0x12340,唯一的要求是 该地址的最低 4 位必须是 0,换句话说,加载的起始 地址必须是 16 字节对齐的,这样将来才能形成一个 图 8-7 可用于加载用户程序的空间范1围31 x86 汇编语言:从实模式到保护模式 有效的段地址。 如图 8-7 所示,物理地址 0x0FFFF 以下,是加载器及其堆栈的势力范围;物理地址 A0000 以上,是 BIOS 和外围设备的势力范围,有很多传统的老式设备将自己的存储器和只读存储 器映射到这个空间。 如此一来,可用的空间就位于 0x10000~9FFFF,差不多 500 多 KB。事实上,如果将低端的内 存空间合理安排一下,还可以腾出更多空间,但是没有必要,我们用不了多少。 8.3.2 准备加载用户程序 和以往不同,我们将主引导扇区程序定义成一个段。代码清单 8-1 第 9 行: SECTION mbr align=16 vstart=0x7c00 整个程序只定义了这一个段,所以它略显多余。之所以这么说,是因为,即使你不定义这个段, 编译器也会自动把整个程序看成一个段。 但是,因为该定义中有“vstart=0x7c00”子句,所以,它就不那么多余了。一旦有了该子句, 段内所有元素的汇编地址都将从 0x7c00 开始计算。否则,因为主引导程序的实际加载地址是 0x0000:0x7c00,当我们引用一个标号时,还得手工加上那个落差 0x7c00。 代码清单 8-1 第 12~14 行,用于初始化堆栈段寄存器 SS 和堆栈指针 SP。之后,堆栈的段地址 是 0x0000,段的长度是 64KB,堆栈指针将在段内 0xFFFF 和 0x0000 之间变化。 代码清单 8-1 第 16、17 行,用于取得一个地址,用户程序将要从这个地址处开始加载。 该地址实际上是保存在标号 phy_base 处的一个双字单元里。这是一个 32 位的数,在 16 位的处 理器上,只能用两个寄存器存放。如图 8-8 所示,32 位数内存中的存放是按低端序列的,高 16 位 处在 phy_base+0x02 处,可以放在寄存器 DX 中;低 16 位处在 phy_base 处,可以用寄存器 AX 存 放。 这两条指令中都使用了段超越前缀“cs:”。这是允许的,意味着在访问内存单元时,使用 CS 的内容作为段基址。之所以没有使用 DS 和 ES,是因为它们另有安排。 另外注意,因为段寄存器 CS 的内容是 0x0000,而且主引导扇区是位于 0x0000:0x7c00 处的, 所以,理论上指令中的偏移地址应当是 0x7c00+phy_base。不过,因为我们定义段 mbr 的时候,使 用了“vstart=0x7c00”子句,故段内所有汇编地址都是在 0x7c00 的基础上增加的,就不用再加上这 个 0x7c00 了,直接是 132 第 8 章 硬盘和显卡的访问和控制 图 8-8 获取用于加载用户程序的物理地址 mov ax,[cs:phy_base] mov dx,[cs:phy_base+0x02] 紧接着,代码清单 8-1 第 18~21 行,用于将该物理地址变成 16 位的段地址,并传送到 DS 和 ES 寄存器。因为该物理地址是 16 字节对齐的,直接右移 4 位即可。实际上,右移 4 位相当于除以 16(0x10),所以程序中的做法将这个 32 位物理地址(DX:AX)除以 16(在寄存器 BX 中),寄存 器 AX 中的商就是得到的段地址(在本程序中是 0x1000)。 8.3.3 外围设备及其接口 加载器的下一个工作是从硬盘读取用户程序,说白了就是访问其他硬件。和处理器打交道的硬件 很多,不单单是硬盘,还有显示器、网络设备、扬声器(喇叭)和话筒(麦克风)、键盘、鼠标等。 有时候,根据应用的场合,还会接一些你不认识和没见过的东西。 所有这些和计算机主机连接的设备,都围绕在主机周围,争着跟计算机说话,叫做外围设备 (Peripheral Equipment)。一般来说,我们把这些设备分成两种,一种是输入设备,比如键盘、鼠标、 麦克风、摄像头等;另一种是输出设备,比如显示器、打印机、扬声器等。输入设备和输出设备统 称输入输出(Input/Output,I/O)设备。 每一种设备都有自己的怪脾气,都有和别的设备不一样的工作方式。比如,扬声器需要的是模 拟信号,每个扬声器需要两根线,用的插头也是无线电行业里的标准,话筒也是如此;老式键盘只 用一根线向主机传送按键的 ASCII 码,而且一直采用 PS/2 标准;新式的 USB 键盘尽管也使用串行 方式工作,但信号却和老式键盘完全不同。至于网络设施,现在流行的是里面有 8 根线芯的五类双 绞线,里面的信号也有专门的标准。 一句话,不同的设备,有不同的连线数量,线里面传送的信号也不一样,而且各自的插头和插 孔也千差万别,这该如何让处理器跟它打交道? 话虽这么说,但这些东西不让处理器访问和控制却不行。很明显,这里需要一些信号转换器 和变速齿轮,这就是 I/O 接口。举几个例子,麦克风和扬声器需要一个 I/O 接口,即声卡,才能 与处理器沟通;显示器也需要一个 I/O 接口,即显卡,才能与处理器沟通;USB 键盘同样需要一 133 x86 汇编语言:从实模式到保护模式 个 I/O 接口,即 USB 接口,才能与处理器沟通。很显然,不同的外围设备,都有各自不同的 I/O 接口。 I/O 接口可以是一个电路板,也可能是一块小芯片,这取决于它有多复杂。无论如何,它是一 个典型的变换器,或者说是一个翻译器,在一边,它按处理器的信号规程工作,负责把处理器的信 号转换成外围设备能接受的另一种信号;在另一边,它也做同样的工作,把外围设备的信号变换成 处理器可以接受的形式。 这还没完,后面还有两个麻烦的问题。 ① 不可能将所有的 I/O 接口直接和处理器相连,设备那么多,还有些设备现在没有发明出来, 将来一定会有。你怎么办? ② 每个设备的 I/O 接口都抢着和处理器说话,不发生冲突都难。你怎么办? 对第 1 个问题的解答是采用总线技术。总线可以认为是一排电线,所有的外围设备,包括处理 器,都连接到这排电线上。但是,每个连接到这排电线上的器件都必须有拥有电子开关,以使它们 随时能够同这排电线连接,或者从这排电线上断开(脱离)。这就好比是公共车道,当路面上有车 时,你就必须退避一下,不能硬冲上去。因此,这排公共电线就称为总线(Bus)。 对第 2 个问题的解答是使用输入输出控制设备集中器(I/O Controller Hub,ICH)芯片,该芯 片的作用是连接不同的总线,并协调各个 I/O 接口对处理器的访问。在个人计算机上,这块芯片就 是所谓的南桥。 如图 8-9 所示,处理器通过局部总线连接到 ICH 内部的处理接口电路。然后,在 ICH 内部,又 通过总线与各个 I/O 接口相连。 图 8-9 计算机内部总线系统示意图 在 ICH 内部,集成了一些常规的外围设备接口,如 USB、PATA(IDE)、SATA、老式总线接 口(LPC)、时钟等,这些东西对计算机来说必不可少,故直接集成在 ICH 内,我们后面还会详细 介绍它们的功能。 除了这些常用的、必不可少的设备之外,有些设备你可能暂时用不上,也有些设备还没有发 明出来,但迟早有可能连在计算机上。不管是什么设备,都必须通过它自己的 I/O 接口电路同 ICH 相连。为了方便,最好是在主板上做一些插槽,同时,每个设备的 I/O 接口电路都设计成插卡。 这样,想接上该设备时,就把它的 I/O 接口卡插上,不需要时,随时拔下。 为了实现这个目的,或者说为了支持更多的设备,ICH 还提供了对 PCI 或者 PCI Express 总线的 支持,该总线向外延伸,连接着主板上的若干个扩展槽,就是刚才说的插槽。举个实例,如果你想连 134 第 8 章 硬盘和显卡的访问和控制 接显示器,那么就要先插入显卡,然后再把显示器接到显卡上。 除了局部总线和 PCI Express 总线,每个 I/O 接口卡可能连接不止一个设备。比如 USB 接口, 就有可能连接一大堆东西:键盘、鼠标、U 盘等。因为同类型的设备较多,也涉及线路复用和仲裁 的问题,故它们也有自己的总线体系,称为通信总线或者设备总线。比如图 8-9 所示的 USB 总线和 SATA 总线。 当处理器想同某个设备说话时,ICH 会接到通知。然后,它负责提供相应的传输通道和其他辅 助支持,并命令所有其他无关设备闭嘴。同样,当某个设备要跟处理器说话,情况也是一样。 8.3.4 I/O 端口和端口访问 外围设备和处理器之间的通信是通过相应的 I/O 接口进行的。当然,这么说太过于笼统,所以 必须具体到细节上来讲这件事。 具体地说,处理器是通过端口(Port)来和外围设备打交道的。本质上,端口就是一些寄存器, 类似于处理器内部的寄存器。不同之处仅仅在于,这些叫做端口的寄存器位于 I/O 接口电路中。 端口是处理器和外围设备通过 I/O 接口交流的窗口,每一个 I/O 接口都可能拥有好几个端口, 分别用于不同的目的。比如,连接硬盘的 PATA/SATA 接口就有几个端口,分别是命令端口(当向 该端口写入 0x20 时,表明是从硬盘读数据;写入 0x30 时,表明是向硬盘写数据)、状态端口(处 理器根据这个端口的数据来判断硬盘工作是否正常,操作是否成功,发生了哪种错误)、参数端口 (处理器通过这些端口告诉硬盘读写的扇区数量,以及起始的逻辑扇区号)和数据端口(通过这个 端口连续地取得要读出的数据,或者通过这个端口连续地发送要写入硬盘的数据)。 端口只不过是位于 I/O 接口上的寄存器,所以,每个端口有自己的数据宽度。在早期的系统中, 端口可以是 8 位的,也可以是 16 位的,现在有些端口会是 32 位的。到底是 8 位还是 16 位,这是 设备和 I/O 接口制造者的自由。比如,PATA/STAT 接口中的数据端口就是 16 位的,这有助于加快 数据传输速率,提高传输效率。 端口在不同的计算机系统中有着不同的实现方式。在一些计算机系统中,端口号是映射到内 存地址空间的。比如,0x00000~0xE0000 是真实的物理内存地址,而 0xE0001~0xFFFFF 是从很 多 I/O 接口那里映射过来的,当访问这部分地址时,实际上是在访问 I/O 接口。 而在另一些计算机系统中,端口是独立编址的,不和内存发生关系。如图 8-10 所示,在这种 计算机中,处理器的地址线既连接内存,也连接每一个 I/O 接口。但是,处理器还有一个特殊的引 脚 M/IO#,在这里,“#”表示低电平有效。也就是说,当处理器访问内存时,它会让 M/IO#引脚呈 高电平,这里,和内存相关的电路就会打开;相反,如果处理器访问 I/O 端口,那么 M/IO#引脚呈 低平,内存电路被禁止。与此同时,处理器发出的地址和 M/IO#信号一起用于打个某个 I/O 接口, 如果该 I/O 接口分配的端口号与处理器地址相吻合的话。 Intel 处理器,早期是独立编址的,现在既有内存映射的,也有独立编址的。在本章中,我们只 讲独立编址的端口。 所有端口都是统一编号的,比如 0x0001、0x0002、0x0003、…。每个 I/O 接口电路都分配了若 干个端口,比如,I/O 接口 A 有 3 个端口,端口号分别是 0x0021~0x0023;I/O 接口 B 需要 5 个端 口,端口号分别是 0x0303~0x0307。 一个现实的例子是个人计算机中的 PATA/SATA 接口(图 8-9),每个 PATA 和 SATA 接口分配了 8 个端口。但是,ICH 芯片内部通常集成了两个 PATA/SATA 接口,分别是主硬盘接口和副硬盘接口。这 样一来,主硬盘接口分配的端口号是 0x1f0~0x1f7,副硬盘接口分配的端口号是 0x170~0x177。 135 x86 汇编语言:从实模式到保护模式 图 8-10 端口的访问和 M/IO#引脚 在 Intel 的系统中,只允许 65536(十进制数)个端口存在,端口号从 0 到 65535(0x0000~0xffff)。 因为是独立编址,所以,端口的访问不能使用类似于 mov 这样的指令,取而代之的是 in 和 out 指 令。 in 指令是从端口读,它的一般形式是 或者 in al,dx in ax,dx 这就是说,in 指令的目的操作数必须是寄存器 AL 或者 AX,当访问 8 位的端口时,使用寄存 器 AL;访问 16 位的端口时,使用 AX。in 指令的源操作数应当是寄存器 DX。 in al,dx 的机器指令码是 0xEC,in ax,dx 的机器指令码是 0xED,都是一字节的。之所以如此 简短,是因为 in 指令不允许使用别的通用寄存器,也不允许使用内存单元作为操作数。 也许是为了方便,in 指令还有两字节的形式。此时,前一字节是操作码 0xE4 或者 0xE5,分别 用于指示 8 位或者 16 位端口访问;后一字节是立即数,指示端口号。 因此,机器指令 E4 F0 就相当于汇编语言指令 in al,0xf0 而机器指令 E5 03 就相当于汇编语言指令 in ax,0x03 很显然,因为这种指令形式的操作数部分只允许一字节,故只能访问 0~255(0x00~0xff)号 端口,不允许访问大于 255 的端口号。所以,下面的汇编语言指令就是非法的: in ax,0x5fd in 指令不影响任何标志位。 相应地,如果要通过端口向外围设备发送数据,则必须通过 out 指令。 out 指令正好和 in 指令相反,目的操作数可以是 8 位立即数或者寄存器 DX,源操作数必须是 寄存器 AL 或者 AX。下面是一些例子: out 0x37,al ;写 0x37 号端口(这是一个 8 位端口) out 0xf5,ax ;写 0xf5 号端口(这是一个 16 位端口) out dx,al ;这是一个 8 位端口,端口号在寄存器 DX 中 out dx,ax ;这是一个 16 位端口,端口号在寄存器 DX 中 和 in 指令一样,out 指令不影响任何标志位。 136 8.3.5 通过硬盘控制器端口读扇区数据 第 8 章 硬盘和显卡的访问和控制 现在,让我们来看看硬盘。 硬盘读写的基本单位是扇区。就是说,要读就至少读一个扇区,要写就至少写一个扇区,不可 能仅读写一个扇区中的几个字节。这样一来,就使得主机和硬盘之间的数据交换是成块的,所以硬 盘是典型的块设备。 从硬盘读写数据,最经典的方式是向硬盘控制器分别发送磁头号、柱面号和扇区号(扇区在某 个柱面上的编号),这称为 CHS 模式。这种方法最原始,最自然,也最容易理解。 实际上,在很多时候,我们并不关心扇区的物理位置,所以希望所有的扇区都能统一编址。这 就是逻辑扇区,它把硬盘上所有可用的扇区都一一从 0 编号,而不管它位于哪个盘面,也不管它属 于哪个柱面。 关于硬盘和逻辑扇区的知识前面已经有所介绍,这里不再赘述。最早的逻辑扇区编址方法是 LBA28,使用 28 个比特来表示逻辑扇区号,从逻辑扇区 0x0000000 到 0xFFFFFFF,共可以表示 228 =268435456 个扇区。每个扇区有 512 字节,所以 LBA28 可以管理 128 GB 的硬盘。 硬盘技术发展得非常快,最新的硬盘已经达到几百个吉字节的容量,LBA28 已经落后了。在这 种情况下,业界又共同推出了 LBA48,采用 48 个比特来表示逻辑扇区号。如此一来,就可以管理 131072 TB 的硬盘容量了。 1TB = 1024GB 在本章中,我们将采用 LBA28 来访问硬盘。 前面说过,个人计算机上的主硬盘控制器被分配了 8 位端口,端口号从 0x1f0 到 0x1f7。假设 现在要从硬盘上读逻辑扇区,那么,整个过程如下。 第 1 步,设置要读取的扇区数量。这个数值要写入 0x1f2 端口。这是个 8 位端口,因此每次只 能读写 255 个扇区: mov dx,0x1f2 mov al,0x01 ;1 个扇区 out dx,al 注意,如果写入的值为 0,则表示要读取 256 个扇区。每读一个扇区,这个数值就减一。因此, 如果在读写过程中发生错误,该端口包含着尚未读取的扇区数。 第 2 步,设置起始 LBA 扇区号。扇区的读写是连续的,因此只需要给出第一个扇区的编号就 可以了。28 位的扇区号太长,需要将其分成 4 段,分别写入端口 0x1f3、0x1f4、0x1f5 和 0x1f6 号 端口。其中,0x1f3 号端口存放的是 0~7 位;0x1f4 号端口存放的是 8~15 位;0x1f5 号端口存放的 是 16~23 位,最后 4 位在 0x1f6 号端口。假定我们要读写的起始逻辑扇区号为 0x02,可编写代码 如下: mov dx,0x1f3 mov al,0x02 out dx,al ;LBA 地址 7~0 inc dx ;0x1f4 mov al,0x00 out dx,al ;LBA 地址 15~8 inc dx out dx,al ;0x1f5 ;LBA 地址 23~16 137 x86 汇编语言:从实模式到保护模式 inc dx mov al,0xe0 ;0x1f6 ;LBA 模式,主硬盘,以及 LBA 地址 27~24 out dx,al 注意以上代码的最后 4 行,在现行的体系下,每个 PATA/SATA 接口允许挂接两块硬盘,分别是 主盘(Master)和从盘(Slave)。如图 8-11 所示,0x1f6 端口的低 4 位用于存放逻辑扇区号的 24~27 位,第 4 位用于指示硬盘号,0 表示主盘,1 表示从盘。高 3 位是“111”,表示 LBA 模式。 图 8-11 端口 1f6 各位的含义 第 3 步,向端口 0x1f7 写入 0x20,请求硬盘读。这也是一个 8 位端口: mov dx,0x1f7 mov al,0x20 ;读命令 out dx,al 第 4 步,等待读写操作完成。端口 0x1f7 既是命令端口,又是状态端口。在通过这个端口发送 读写命令之后,硬盘就忙乎开了。如图 8-12 所示,在它内部操作期间,它将 0x1f7 端口的第 7 位置 “1”,表明自己很忙。一旦硬盘系统准备就绪,它再将此位清零,说明自己已经忙完了,同时将第 3 位置“1”,意思是准备好了,请求主机发送或者接收数据(图 8-12)。完成这一步的典型代码如下: mov dx,0x1f7 .waits: in al,dx and al,0x88 cmp al,0x08 jnz .waits ;不忙,且硬盘已准备好数据传输 图 8-12 端口 0x1f7 部分状态位的含义 来看看指令 and al,0x88。0x88 的二进制形式是 10001000,这意味着我们想用这条指令保留住 寄存器 AL 中的第 7 位和第 3 位,其他无关的位都清零。此时,如果寄存器 AL 中的二进制数是 00001000(0x08),那就说明可以退出等待状态,继续往下操作,否则继续等待。 138 第 8 章 硬盘和显卡的访问和控制 第 5 步,连续取出数据。0x1f0 是硬盘接口的数据端口,而且还是一个 16 位端口。一旦硬盘控 制器空闲,且准备就绪,就可以连续从这个端口写入或者读取数据。下面的代码假定是从硬盘读一 个扇区(512 字节,或者 256 字节),读取的数据存放到由段寄存器 DS 指定的数据段,偏移地址由 寄存器 BX 指定: mov cx,256 ;总共要读取的字数 mov dx,0x1f0 .readw: in ax,dx mov [bx],ax add bx,2 loop .readw 最后,0x1f1 端口是错误寄存器,包含硬盘驱动器最后一次执行命令后的状态(错误原因)。 8.3.6 过程调用 读写硬盘是经常要做的事,尤其对于操作系统来说。即使是在本章的程序中,也多次发生。如果 每次读写硬盘都按上面的 5 个步骤写一堆代码,程序势必很大,也会令人烦恼。 好在处理器支持一种叫过程调用的指令执行机制。过程(Procedure)又叫例程,或者子程序、 子过程、子例程(Sub-routine),不管怎么称呼,实质都一样,都是一段普通的代码。处理器可以用 过程调用指令转移到这段代码执行,在遇到过程返回指令时重新返回到调用处的下一条指令接着执 行。 如图 8-13 所示,这是过程和过程调用的示意图。下面结合本章代码清单来具体说明。 图 8-13 过程和过程调用示意图 在 8.3.1 节里,我们已经定义了常量 app_lba_start,它代表的值是 100,也就是用户程序在硬盘 上的起始逻辑扇区号。现在,代码清单 8-1 的第 24~27 行用于从硬盘上读取这个扇区的内容。这 很好理解,因为不知道用户程序到底有多大,到底占用了多少个扇区,所以,可以先读它的第一个 扇区。该扇区包含了用户程序的头部,而用户程序头部又包含了该程序的大小、入口点和段重定位 表。所以,通过分析头部,就知道接着还要再读多少个扇区才能完全加载用户程序。 因为要多次读取硬盘,而每次的步骤又都差不多,所以,我们精心设计了一段通用的代码,它 从代码清单 8-1 的第 79 行开始,一直到第 131 行结束,这就是我们所说的过程。 139 x86 汇编语言:从实模式到保护模式 要调用过程,需要该过程的地址。一般来说,过程的第一条指令需要一个标号,以方便引用该 过程。所以,代码清单 8-1 第 79 行是一个标号“read_hard_disk_0”,意思是读(第一个硬盘控制器 的)主盘,当然,什么意思并不重要。 编写过程的好处是只用编写一次,以后只需要“调用”即可。所以,代码的灵活性和通用性尤 其重要。具体到这里,就是每次读硬盘时的起始逻辑扇区号和数据保存位置都不相同,这就涉及所 谓的参数传递。 参数传递最简单的办法是通过寄存器。在这里,主程序把起始逻辑扇区号的高 16 位存放在寄 存器 DI 中(只有低 12 位是有效的,高 4 位必须保证为“0”),低 16 位存放在寄存器 SI 中(没办 法,16 位的处理器无法直接处理 28 位的数据);并约定将读出来的数据存放到由段寄存器 DS 指向 的数据段中,起始偏移地址在寄存器 BX 中。 在调用过程前,程序会用到一些寄存器,在过程返回之后,可能还要继续使用。为了不失连续性, 在过程的开头,应当将本过程要用到(内容肯定会被破坏)的寄存器临时压栈,并在返回到调用点之 前出栈恢复。代码清单 8-1 的第 82~85 行,用于将过程中用到的寄存器压入堆栈保存。 后面的指令都很好理解,第 87~89 行,是向 0x1f2 端口写入要读取的扇区数。显而易见,每 次读的扇区数是 1 个。 第 91~101 行,用于向硬盘接口写入起始逻辑扇区号的低 24 位。低 16 位在寄存器 SI 中,高 12 位在寄存器 DI 中,需要不停地倒换到寄存器 AL 中,以方便端口写入。 第 105 行,程序执行到这里时,寄存器 AH 的低 4 位是起始逻辑扇区号的 27~24 位,高 4 位 是全“0”;寄存器 AL 中是 0xe0。执行 or 指令后,将会在寄存器 AL 中得到它们的组合值,高 4 位是 0xe,低 4 位是逻辑扇区号的 27~24 位。 第 118~124 行,用于反复从硬盘接口那里取得 512 字节的数据,并传送到段寄存器 DS 所指向 的数据区中。每传送一个字,BX 的值就增 2,以指向下一个偏移位置。 第 126~129 行,用于把调用过程前各个寄存器的内容从堆栈中恢复。 最后,因为处理器是没有大脑的,所以需要一个明确的指令 ret 促使它离开过程,从哪里来回 哪里去,这条指令稍后就会讲到。 有关过程的情况就是这些,下面回到前面,看看过程调用是如何发生的。 代码清单 8-1 第 24、25 行,用于指定用户程序在硬盘上的起始逻辑扇区号。我们定义的过程 要求用 DI:SI 来提供这个扇区号,既然它是常数 100,很小的数值,可以直接传送到寄存器 SI,并 将 DI 清零即可。 第 26 行用于指定存放数据的内存地址。前面几条指令已经将段寄存器 DS 设置好了,现在只 需要将寄存器 BX 清零,以指向该段内偏移地址为 0 的地方,这就是当前指令要做的事。 一切都准备好了,第 27 行,开始调用过程 read_hard_disk_0。以后,我们将把过程所在的标号 做为过程的名字,即过程名。 调用过程的指令是“call”。8086 处理器支持四种调用方式。 第一种是 16 位相对近调用。近调用的意思是被调用的目标过程位于当前代码段内,而非另一 个不同的代码段,所以只需要得到偏移地址即可。 16 位相对近调用是三字节指令,操作码为 0xE8,后跟 16 位的操作数,因为是相对调用,故该 操作数是当前 call 指令相对于目标过程的偏移量。计算过程如下:用目标过程的汇编地址减去当前 call 指令的汇编地址,再减去当前 call 指令以字节为单位的长度(3),保留 16 位的结果。举个例子: call near proc_1 近调用的特征是在指令中使用关键字“near”。“proc_1”是程序中的一个标号。在编译阶段, 140 第 8 章 硬盘和显卡的访问和控制 编译器用标号 proc_1 处的汇编地址减去本指令的汇编地址,再减去 3,作为机器指令的操作数。 关键字“near”不是必需的,如果 call 指令中没有提供任何关键字,则编译器认为该指令是近 调用。因此,上面的指令与这条指令等效: call proc_1 因为 16 位相对近调用的操作数是两个汇编地址相减的相对量,所以,如果被调用过程在当前 指令的前方,也就是说,论汇编地址,它比 call 指令的要大,那么该相对量是一个正数;反之,就 是一个负数。所以,它的机器指令操作数是一个 16 位的有符号数。换句话说,被调用过程的首地 址必须位于距离当前 call 指令-32768~32767 字节的地方。 在指令执行阶段,处理器看到操作码 0xE8,就知道它应当调用一个过程。于是,它用指令指 针寄存器 IP 的当前内容加上指令中的操作数,再加上 3,得到一个新的偏移地址。接着,将 IP 的 原有内容压入堆栈。最后,用刚才计算出的偏移地址取代 IP 原有的内容。这直接导致处理器的执 行流转移到目标位置处。 再看一个例子: call 0x0500 很多人认为 0x0500 会原封不动地出现在该指令编译后的机器码中,我相信这只是他们一时糊 涂。在 call 指令后跟一个标号,和跟一个数值没有什么不同。标号是数值的等价形式,是代表标号 处的汇编地址。在指令编译阶段,它首先会被转化成数值。 所以,你在 call 指令后跟一个数值,只是帮了编译器的忙,帮它省了一个转化步骤,它依然会 用这个数值减去当前指令的汇编地址,来得到一个偏移量。 第二种是 16 位间接绝对近调用。这种调用也是近调用,只能调用当前代码段内的过程,指令 中的操作数不是偏移量,而是被调用过程的真实偏移地址,故称为绝对地址。不过,这个偏移地址 不是直接出现在指令中,而是由 16 位的通用寄存器或者 16 位的内存单元给出。比如: call cx ;目标地址在 CX 中。省略了关键字“near”,下同 call [0x3000] ;要先访问内存才能取得目标偏移地址 call [bx] ;要先访问内存才能取得目标偏移地址 call [bx+si+0x02] ;要先访问内存才能取得目标偏移地址 以上,第一条指令的机器码为 FF D1,被调用过程的偏移地址位于寄存器 CX 内,在指令执行 的时候由处理器从该寄存器取得,并直接取代指令指针寄存器 IP 原有的内容。 第二条指令的机器码为 FF 16 00 30。当这条指令执行时,处理器访问数据段(使用段寄存器 DS),从偏移地址 0x3000 处取得一个字,作为目标过程的真实偏移地址,并用它取代指令指针寄存 器 IP 原有的内容。 后面两条指令没什么好说的,只是寻址方式不同而已。 间接绝对近调用指令在执行时,处理器首先按以上的方法计算被调用过程的偏移地址,然后将 指令指针寄存器 IP 的当前值压栈,最后用计算出来的偏移地址取代寄存器 IP 原有的内容。 由于间接绝对近调用的机器指令操作数是 16 位的绝对地址,因此,它可以调用当前代码段任 何位置处的过程。 第三种是 16 位直接绝对远调用。这种调用属于段间调用,即调用另一个代码段内的过程,所 以称为远调用(Far Call)。很容易想到,远调用既需要被调用过程所在的段地址,也需要该过程在 段内的偏移地址。 “16 位”是针对偏移地址来说的,而不用于限定段地址;“直接”的意思是,段地址和偏移地址 直接在 call 指令中给出了。当然,这里的地址也是绝对地址。比如: 141 x86 汇编语言:从实模式到保护模式 call 0x2000:0x0030 这条指令编译后的机器码为 9A 30 00 00 20,0x9A 是操作码,后面跟着的两个字分别是偏移地 址和段地址,按规定,偏移地址在前,段地址在后。 处理器在执行时,首先将代码段寄存器 CS 的当前内容压栈,接着再把指令指针寄存器 IP 的当 前内容压栈。紧接着,用指令中给出的段地址代替 CS 原有的内容,用指令中给出的偏移地址代替 IP 原有的内容。这直接导致处理器从新的位置开始执行。 处理器是没有脑子的。如果被调用过程位于当前代码段内,而你又用这种指令格式来调用它, 那么,处理器也会不折不扣地从当前代码段“转移”到当前代码段。 第四种是 16 位间接绝对远调用。这也属于段间调用,被调用过程位于另一个代码段内,而且, 被调用过程所在的段地址和偏移地址是间接给出的。还有,这里的“16 位”同样是用来限定偏移地 址的。下面是这种调用方式的几个例子: call far [0x2000] call far [proc_1] call far [bx] call far [bx+si] 间接远调用必须使用关键字“far”,这一点务必牢记。 因为是远调用,也就是段间调用,所以,必须给出被调用过程的段地址和偏移地址。但是,段 地址和偏移地址在内存中的其他位置,指令中仅仅给出的是该位置的偏移地址,需要处理器在执行 指令的时候自行按图索骥,找到它们。 以上,前两条指令是等效的,不同之处仅仅在于,第一条指令直接给出的是数值,而第二条指 令用的是标号。但这无关紧要,在编译后,标号也会变成数值。 为了进一步说清间接远调用是怎么发生的,下面是一个实例。 假如在数据段内声明了标号 proc_1 并初始化了两个字: proc_1 dw 0x0102,0x2000 这两个字分别是某个过程的段地址和偏移地址。按处理器的要求,偏移地址在前,段地址在后。 也就是说,0x0102 是偏移地址; 0x2000 是段地址。 那么,为了调用该过程,可以在代码段内使用这条指令: call far [proc_1] 当这条指令执行时,处理器访问由段寄存器 DS 指向的数据段,从指令中指定的偏移地址处取 得两个字(分别是段地址 0x2000 和偏移地址 0x0102);接着,将代码段寄存器 CS 和指令指针寄存 器 IP 的当前内容分别压栈;最后,用刚才取得的段地址和偏移地址分别取代 CS 和 IP 的原值。 至于后面的两条指令 call far [bx]和 call far [bx+si],仅仅是寻址方式上有所区别,指令执行过程 大体上是一样的。 接着回到代码清单 8-1 第 27 行,很明显, call read_hard_disk_0 就是我们刚刚讲的 16 位相对近调用,编译后的机器指令操作数是一个相对偏移量。由于这是段内 调用,处理器执行这条指令时,用指令指针寄存器 IP 的内容加上指令中的偏移量,以及当前指令 的长度,算出被调用过程的绝对偏移地址。接着,将 IP 的现行值压栈。最后,用刚刚计算出的偏 移地址替代 IP 的当前内容。 过程 read_hard_disk_0 的功能和工作流程前面已经讲过了,不再赘述。这里只关心一个最重要 的问题,那就是过程返回。 142 第 8 章 硬盘和显卡的访问和控制 “过程”就是例行公事,可以随时根据需要调用,但过程执行完了呢,还得返回到调用点继续 执行下一条指令,这称为过程返回(Procedure Return)。 处理器是个大笨蛋,你不提醒它,它就一直稀里糊涂地闷头工作。幸好,处理器的发明者们设 计了返回指令 ret 和 retf。 ret 和 retf 经常用做 call 和 call far 的配对指令。ret 是近返回指令,当它执行时,处理器只做一 件事,那就是从堆栈中弹出一个字到指令指针寄存器 IP 中。 retf 是远返回指令(return far),它的工作稍微复杂一点点。当它执行时,处理器分别从堆栈中 弹出两个字到指令指针寄存器 IP 和代码段寄存器 CS 中。 如图 8-14 所示,在 call read_hard_disk_0 执行前,堆栈指针位于箭头①所指示的位置;call 指 令执行后,由于压入了 IP 的内容,故堆栈指针移动到箭头②所指示的位置处;进入过程后,出于 保护现场的目的,压入了 4 个通用寄存器 AX、BX、CX、DX,此时,堆栈指针继续向低地址方向 推进到箭头③所指示的位置。 在过程的最后,是恢复现场,连续反序弹出 4 个通用寄存器的内容。此时,堆栈指针又回到刚 进入过程内部时的位置,即箭头②处。最后,ret 指令执行时,由于处理器自动弹出一个字到 IP, 故,过程返回后的瞬间,堆栈指针仍旧回到过程调用前,即箭头①所指示的位置。 图 8-14 过程调用前后的堆栈变化 需要说明的是,尽管 call 指令通常需要 ret/retf 和它 配对,遥相呼应,但 ret/retf 指令却并不依赖于 call 指令, 这一点你马上就会看到。 call 指令在执行过程调用时不影响任何标志位, ret/retf 指令对标志位也没有任何影响。 8.3.7 加载用户程序 图 8-15 用户程序头部结构示意图143 x86 汇编语言:从实模式到保护模式 第一次读硬盘将得到用户程序最开始的 512 字节,这 512 字节包括最开始的用户程序头部,以 及一部分实际的指令和数据。 为了将用户程序全部读入内存,需要知道它的大小,然后再进一步转换成它所用的扇区数。如图 8-15 所示,用户程序最开始的双字,就是整个程序的大小。 为此,代码清单 8-1 第 30、31 行,分别将该数值的高 16 位和低 16 位传送到寄存器 DX 和 AX。 第 32 行,因为每扇区有 512 字节,故将 512 传送到 BX 寄存器,并在第 33 行用它来做除法运算。 在凑巧的情况下,用户程序的大小正好是 512 的整数倍,做完除法后,在寄存器 AX 中是用户 程序实际占用的扇区数。但是,绝大多数情况下,这个除法会有余数。有余数意味着,最后一个扇 区因为没有填满而落下了,没有纳入总扇区数。 关于这个问题,我们稍微解释一下。硬盘的读写是以扇区为单位的,如果要写入 513 字节,那 么,它将只能填满一个扇区,还剩一字节。硬盘不管这些,它每次总是说:“来,给我 512 字节!”为 此,软件的责任是,保证给硬盘的是 512 字节,如果不够,凑也要凑够。因此,513 字节会占用两个 扇区,第二个扇区只有一字节是有用的,其他 511 字节都是用来填充的。至于某个扇区里,哪些数据 是有用的,哪些是填充的,不是硬盘的责任,是软件的责任。就像本章的用户程序一样,通过构造一 个头部,自行来跟踪自己的大小。 所以,代码清单 8-1 第 34 行,判断是否除尽。如果没有除尽,则转移到后面的代码,去读剩 余的扇区;如果除尽了,则总扇区数减一。 为什么?为什么除不尽不管,除尽了还要减一?因为刚才已经预读了一个扇区。 注意,用户程序的长度有可能小于 512 字节,或者恰好等于 512 字节。在这两种情况下, 当程序执行到第 38 行时,寄存器 AX 中的内容必然为零。所以,第 38 行是算术比较指令 cmp, 第 39 行是条件转移指令,当寄存器 AX 中的内容为零时,就意味着用户程序已经全部读取, 不再继续读了,毕竟用户程序只占用一个扇区,而刚才也已经读过了。 用户程序被加载的位置是由 DS 和 ES 所指向的逻辑段。一个逻辑段最大也才 64KB,当用 户程序特别大的时候,根本容纳不下。想想看,段内偏移地址从 0x0000 开始,一直延伸到最 大值 0xffff。再大的话,又绕回到 0x0000,以致于把最开始加载的内容给覆盖掉了。 其实,要解决这个问题最好的办法是,每次往内存中加载一个扇区前,都重新在前面的数 据尾部构造一个新的逻辑段,并把要读取的数据加载到这个新段内。如此一来,因为每个段的 大小是 512 字节,即,十六进制的 0x200,右移 4 位(相当于除以 16 或者 0x10)后是 0x20, 这就是各个段地址之间的差值。每次构造新段时,只需要在前面段地址的基础上增加 0x20 即 可得到新段的段地址。 这种做法好有一比,尺子很短,树很高,想只量一次是不可能的,于是只好分几次量,每量一 次,将尺子往下挪一挪。 段地址的改变是临时的,毕竟只是为了读取硬盘,所以,代码清单 8-1 第 42 行,将当前数据 段寄存器 DS 的内容压栈保存。 第 44 行,将用户程序剩余的扇区数传送到寄存器 CX,供后面的 loop 指令使用,因为我们准 备采用循环的办法来读完用户程序。 第 46~48 行,将当前数据段寄存器 DS 的内容在原来的基础上增加 0x20,以构造出下一个逻 辑段,为从硬盘上读取下一个 512 字节的数据做准备。 第 50 行,将寄存器 BX 清零。BX 被用做数据传输时的段内偏移,而且每次传输都是在一个新 的段内进行,故偏移地址在每次传输前都应当是零。 第 51 行,每次读硬盘前,将寄存器 SI 的内容加一,以指向下一个逻辑扇区。 144 第 8 章 硬盘和显卡的访问和控制 第 52~53 行,调用读硬盘的过程 read_hard_disk_0,并开始下一轮循环,直到所有的扇区都读 完(寄存器 CX 的内容为 0)。 8.3.8 用户程序重定位 用户程序在编写的时候是分段的。因此,加载器下一步的工作是计算和确定每个段的段地址。 如图 8-16 所示,用户程序定义了 6 个段,在编译阶段,编译器为每个段计算了一个汇编地址。 第一个段 header 位于整个程序的开头,所以其汇编地址为 0。从第二个段开始,每个段的汇编地址 都是其相对于整个程序开头的偏移量,以字节为单位。因为我们不知道各个段的汇编地址到底是多 少,故用字母来表示。这样,第二个段 code_1 的汇编地址是 v,第三个段 code_2 的汇编地址是 w,……, 最后一个段 stack 的汇编地址是 z。 145 x86 汇编语言:从实模式到保护模式 图 8-16 段的偏移地址和它在内存中的物理地址 现在,用户程序已经全部加载到内存里了,而且是从物理地址 phy_base 开始的。如此一来,每 个段在内存中的物理地址都是基于 phy_base 的,第一个段 header 在内存中的起始物理地址是 phy_base(phy_base+0),第二个段在内存中的起始物理地址是 phy_base+v,……,最后一个段 stack 则是 phy_base+z。 用于加载用户程序的物理地址 phy_base 是 16 字节对齐的,而用户程序中,每个段的汇编地址 也是 16 字节对齐的。因此,每个段在内存中的起始地址也是 16 字节对齐的,将它们分别右移 4 位, 146 第 8 章 硬盘和显卡的访问和控制 就是它们各自的逻辑段地址。 为此,代码清单 8-1 第 55 行,从堆栈中恢复数据段寄存器 DS 的内容,使其指向用户程序被加 载的起始位置,也就是用户程序头部。 第 58~62 行用于重定位用户程序入口点的代码段。请参考图 8-15,用户程序头部内,偏移 为 0x06 处的双字,存放的是入口点代码段的汇编地址。加载器首先将高字和低字分别传送到寄 存器 DX 和 AX,然后调用过程 calc_segment_base 来计算该代码段在内存中的段地址。 过程 calc_segment_base(计算段基址)是在代码清单 8-1 的第 134 行定义的。它接受一个 32 位的汇编地址(位于寄存器 DX:AX 中),并在计算完成后向主程序返回一个 16 位的逻辑段地址(位 于寄存器 AX 中)。 因为计算过程中要破坏寄存器 DX 的内容,因此,第 137 行用于将其压栈保存。 在 16 位的处理器上,每次只能进行 16 位数的运算。第 139 行,先将用户程序在内存中物理起 始地址的低 16 位加到寄存器 AX 中。该指令的地址部分使用了段超越前缀“cs:”,而且也没有加上 0x7c00。原因前面已经解释过了,在本程序中,数据段和代码段是分离的,而且当前代码段的定义 部分使用了“vstart=0x7c00”子句。 然后,第 140 行,再将该起始地址的高 16 位加到寄存器 DX 中。adc 是带进位加法,它将目的 操作数和源操作数相加,然后再加上标志寄存器 CF 位的值(0 或者 1)。这样,分两步就可以完成 32 位数的加法运算。 现在,我们已经在 DX:AX 中得到了入口点代码段的起始物理地址,只需要将这个 32 位数右移 4 位即可得到逻辑段地址。麻烦在于它们分别在两个寄存器中,如何移动? 答案是分别移动,然后拼接。代码清单 8-1 第 141 行,使用逻辑右移指令 sh(r SHift logical Right) 将寄存器 AX 中的内容右移 4 位。 如图 8-17 所示,逻辑右移指令执行时,会将操作数连续地向右移动指定的次数,每移动一次, “挤”出来的比特被移到标志寄存器的 CF 位,左边空出来的位置用比特“0”填充。 图 8-17 逻辑右移示意图 shr 指令的目的操作数可以是 8 位或 16 位的通用寄存器或者内存单元,源操作数可以是数字 1、 8 位立即数或者寄存器 CL。我们已经介绍过寻址方式,往后,我们要用新的方法来表示指令的格式。 就当前指令来说,该指令的格式为: shr r/m8,1 shr r/m16,1 shr r/m8,imm8 shr r/m16,imm8 shr r/m8,cl shr r/m16,cl 以上,第一种指令格式的意思是,目的操作数可以是 8 位寄存器,或者指向 8 位实际操作数的 147 x86 汇编语言:从实模式到保护模式 内存地址;源操作数是 1。对于内存地址的情况,可以使用任何一种我们讲过的内存寻址方式。举 三个例子: shr ah,1 shr byte [0x2000],1 shr byte [bx+si+0x02],1 第二种指令格式和第一种相似,只是目的操作数的长度不一样。注意,源操作数为 1 的逻辑右 移指令是特殊设计的优化指令,比如以上的 shr ax,1,它的机器码是 D1 E8;而类似的指令 shr ax,5 则拥有完全不同的机器码 C1 E8 05。 第三种指令格式的意思是,目的操作数可以是 8 位寄存器,或者指向 8 位实际操作数的内存地 址;源操作数是 8 位立即数。下面是两个例子: shr al,0x20 ;右移 32(0x20)次 shr byte [bx+0x06],0x05 ;右移 5 次 第四种指令格式和第二种类似,只是数据宽度不同。 第五种指令格式的目的操作数可以是 8 位的寄存器,或者指向 8 位实际操作数的内存地址;源 操作数在寄存器 CL 中。如果 shr 指令的源操作数是寄存器,则只能使用 CL。和一般的指令不同, 寄存器 CL 只用来提供移动次数,而不用于限定和暗示目的操作数的字长。因此,对于目的操作数 是内存地址的情况,必须用关键字 byte 或者 word 等来加以限定。比如: shr al,cl shr byte [bx],cl 最后一种指令格式适用于目的操作数的长度为字的情况。 注意,和 8086 处理器不同,80286 之后的 IA-32 处理器在执行本指令时,会先将源操作数的高 3 位清零。也就是说,最大的移位次数是 31。 shr 的配对指令是逻辑左移指令 shl(SHift logical Left),它的指令格式和 shr 相同,只不过它是 向左移动。 尽管 DX:AX 中是 32 位的用户程序起始物理内存地址,理论上,它只有 20 位是有效的,低 16 位在寄存器 AX 中,高 4 位在寄存器 DX 的低 4 位。寄存器 AX 经右移后,高 4 位已经空出,只要将 DX 的最低 4 位挪到这里,就可以得到我们所需要的逻辑段地址。为此,可以使用以下指令: shl dx,12 or ax,dx 很显然,代码清单 8-1 并不是这么做的,为的是演示另一个不同的指令 ror(第 142 行),也就 是循环右移(ROtate Right)。如图 8-18 所示,循环右移指令执行时,每右移一次,移出的比特既送 到标志寄存器的 CF 位,也送进左边空出的位。 ror 的配对指令是循环左移指令