为了让大家更为轻松,除非迫不得已,我们尽量使用系统上已经安装的工具,在这一章里,以下两个外部工具是必须的
- nasm: 作为汇编环境,官方站点http://www.nasm.us/
- UltraEdit:作为16进制文本编辑器
同一时候,读者应该略微具备的汇编知识,不用太多,知道以下这些指令的意义和使用方法就可以
MOV 数据传送指令
ADD 加法指令
PUSH,POP 堆栈指令
CMP 比較指令
LEA 取地址指令
XOR 异或指令
全部的转移指令:JMP,JZ,JE
假设你还想进一步了解机器码的规范,能够下载 http://download.csdn.net/source/1103630,里面有Intel的文档,以及本文用到的操作码查询表
用0和1敲代码
以前有人发给我一张图片,说世界上"最牛程序猿"的键盘,键盘上一共两个键,01,当时年少无知,崇拜到抓狂,今天就让我们当回"顶尖高手",用01直接敲代码
请打开一个十六进制编辑器比方UltraEdit
把以下的二进制代码化为16进制输入进去(主要无法直接输入二进制代码)
1011 1000 0000 0001 0000 0000 0000 0101 0000 0001 0000 0000
十六进制为B8 01 00 05 01 00
将文件保存为test.com文件,恭喜你,你刚刚完毕了一个伟大"壮举",你成功的让CPU计算出了1+1等于几,假设你兴匆匆的执行它,什么结果都看不到,那是由于为了保证代码简单,还没有告诉CPU输出结果的缘故,你愿意的话,能够执行cmd,切换到保存test.com的文件夹,通过执行debug test.com,来看看我们究竟输入了什么
1011 1000 代表 MOV ax
0000 0001 0000 0000 代表1
0000 0101 代表 ADD ax
0000 0001 0000 0000 代表 0001h
全文加起来表示
MOV ax,01h
ADD ax,01h
能够看出,我们的代码相应了两条机器指令,每一个指令分成两个部分,比方MOV ax,1的二进制代码,1011 1000 代表 MOV ax他指定了本条指令的操作,叫做指令操作码(Opcode),0000 0001 0000 0000 代表1,指定了操作的操作数,能够看出机器码是有自己固定的格式,仅仅要掌握了这个格式,查询相应的操作码,应该就能够掌握机器语言了
当然,事情也有复杂的一面,同一条汇编指令其操作码可能依据寻址方式或寄存器或操作数的位数的变化发生变化,比方相同是MOV指令,MOV al,1 和MOV ax,1中Mov的操作码分别为B0(1011 0000)和B8(1011 1000),而MOV ax,[ES:100]操作码会变成26 A1(前面26是段超越前缀,如今不用细致追究),Intel8086规定的MOV指令就有12种之多,并且操作码的长度还有可能不同,这些操作码都能够在表<>中相应的查到,不须要记忆,以下我们就来了解机器语言指令的格式
自己设计机器语言指令格式
在阅读Intel公司的实现前,为了不让您陷入一堆的解释和说明中迷惘无助,我们先来热热身,做点有趣的事情---思考一下假设让你自己来设计机器语言指令的格式,那么你会做出如何的设计,以下是我的设计思路
首先汇编代码和机器代码是相应的,所以让我们来看看一条典型x86汇编指令:
MOV ax,1
这条指令由三个部分组成:指令,目的操作数,源操作数
指令为Mov,目的操作数ax,源操作数1,
ADD bx,2
指令为Add,目的操作数bx,源操作数2
相相应的我们能够考虑把机器指令格式也分成三个部分:指令码,目的操作数,原操作数
因为寄存器的数目是有限的,我们能够列个寄存器机器码指令表,这样代码中的寄存器就能够被替换为例如以下的机器代码,比方
000 AX 001 CX 010 DX 011 BX 100 SP 101 BP 110 SI 111 DI
然后我们再列一个指令码表,比方
MOV=00000000
ADD=00000001
AND=00000010
.
.
.
则MOV ax,1就能够变成 00000000 00000000 00000001(ax是000)
可是这样简单清晰的三个部分会出现一些问题mov bx,0,和mov bx,ax就有可能混淆了,由于ax的代码是000,和马上数0同样
所以我们须要一个标志位来确定是那种操作数,操作数有以下5种可能
目的操作数和原操作数的大小就比較难了,由于操作数可能是
1)一个马上数 比方1
2)一个寄存器 ax,bx,cx,dx
3)一个内存地址 [StringLable]
4)一个由一个或多个寄存器组成的内存地址
[ebx],[ebx+esi],[es:ebx+esi]
5)一个由一个或多个寄存器再加上一个偏移量组成的内存地址
[ebx+esi]
显然我们须要两个标志字段,每一个5个值,(每一个操作数一个)来标定自己是哪种操作数,每一个标志字段仅仅要3位就够了,我把这两个标志字段放到一个字节里,放在两个操作数前面
格式一:
指令码 | 保留2位|标志1|标志2| | 操作数1 | 操作数2 | |
Mov ax,1 | 00000000 | 00|001|000 | 00000000 | 00000001 |
标志的意义
000:马上数
001:寄存器
010:内存地址
011:多个寄存器
100:多个寄存器加偏移量
问题又出来了,当标志位为100,这时,操作数应该是多个寄存器+偏移量,如果每一个寄存器占3位,两个就是6位,留给我们的偏移量的空间仅仅有两位,也就是说偏移量最大仅仅有3,这显然是不够的,所以我们必须加上一个字节表示偏移量,而当不须要偏移量的时候,这两个字段能够不存在,也就是说表格变成了
格式二:
指令码 | 00|标志1|标志2 | 操作数1 | 偏移量 | 00|操作数2 bbb|iii | 偏移量 | |
Mov ax,[bp+si+5] | 00000000 | 00|001|100 | 00000000 | 00|101|110 | 00000110 |
怎么样,有点像样子了吧,固定长度8位的指令码可能有256种指令,我想最主要的操作,AND,OR,XOR,ADD,SHR,SHL等等不会太多,而其它的操作都能够由这些操作组合而成,比方减法是补码的加法,乘法是反复相加等
似乎大部分问题都已经攻克了,可是略微熟悉x86汇编的朋友就会知道,不可能有不论什么指令的两个操作数都是内存,也就是永远不会出现
MOV [dx+di],[ex+si]这种语句,要想实现这种移动我们必需要把源操作数移动到一个寄存器里,然后再从寄存器里移动到目的地
反应在我们的设计上,我们就会发现两个偏移量是多余的,不论什么情况下最多会有一个被使用到,所以表格能够改动成这样
格式三:
指令码 | 00|标志1|标志2 | 偏移量 | 操作数1 | 操作数2 00|bbb|iii | |
MOV ax,[bp+si+5] | 00000000 | 00|001|100 | 00000110 | 00000000 | 00|101|110 |
MOV ax,bx | 00000000 | 00|001|001 | 无 | 00000000 | 00000011 |
事实上看看上表的第二条语句,我们就会发现一个非常重大的问题,那就是空间浪费,第二行中全部黑体的部分都是被浪费掉的空间,浪费了12位,总共才32位的代码,竟然就浪费了12位,心疼啊,并且看看标志字段,占了三位,总共能够表示8个标志,确仅仅用了5个,我们能不能想办法把这些空间利用起来呢?
我们又一次细致考虑第二个字节,也就是标志字节,把最高位的两位利用起来,称作寄存器标志,他的值例如以下表
00:操作数中没有寄存器
01:操作数的后一个为寄存器
10:操作数的前一个为寄存器
11:两个操作数都是寄存器
假设此位指明某操作数为寄存器,则后面的标志位直接为寄存器值,假设为00,则后面的操作数仅仅可能为 (内存,马上数) 形式,这样MOV ax,bx的机器码就变成了以下的样子
格式四:
指令码 | 寄存器标志|标志1|标志2 | 偏移量 | 操作数1 | 操作数2 00|bbb|iii | |
MOV ax,bx | 00000000 | 11|000|011 | 无 | 无 | 无 |
好了,指令系统的雏形已经出来了,尽管和Intel的实现有非常多不同,而且本身还有各种问题,比方依旧有浪费空间的情况,功能也不太健全,只是基本体现了指令格式的特点:
- 分成几个字段表示不允许义
- 尽量短小精干
- 不能浪费不论什么一位
以下让我们来看看Intel公司的实现方法
让书写机器码像填表一样简单
从上面的叙述,我们已经大概能看出点门道,每条指令分为几个部分,表示不同的含义.Intel规定,机器指令都能够被表示成六个部分,Prefix,Opcode,ModR/M,SIB,Displacement,Immediate,除了Opcode部分是必须的外,其它部分都有可能不存在
好像有点复杂不是?不要着急,我们稍作解释就能够把书写机器指令变得像填写表格一样简单
以下我们把几条命令依照六个部分进行切割,填写到这张表里,后面会解释六个部分的含义
Prefix 前缀 0-4个前缀,每一个1字节 可选 | Opcode 操作码 1-2字节 一定存在 | ModR/M 寻址与寄存器 1个字节 可选 | SIB 内存寻址模式 一个字节 可选 | Displayment 偏移量 1,2或4个字节 可选 | Immeidate 马上数 1,2或4个字节 可选 | |
oo|rrr|mmm | cc|iii|bbb | |||||
MOV ax,1 | 无 | 1011 1000 | 无 | 无 | 无 | 0001 0000 |
ADD ax,1 | 无 | 0000 0101 | 无 | 无 | 无 | 0001 0000 |
MOV ax,[ES:0100h] | 0010 0110(26h代表es的段超越前缀) | 1010 0001 | 无 | 无 | 无 | 0000 0000 0001 0000 |
mov ax,[ebx+esi*2+1] | 0110 0111 (67h,代表使用了32位 | 1000 1011 | 01 000 100 | 01 110 011 | 0000 0001 | 无 |
mov [ebx+esi*2+1],01h | 67 | 1100 0111 | 01 000 100 | 01 110 011 | 0000 0001 | 0000 00001 |
仅仅要会填这个表,我们就能够写出全部的机器代码.
能够看到,Intel的格式中并没有明白的标出两个操作数,而是把偏移量和马上数单独拿了出来,并且同一条指令的操作码会依据寻址方式的不同而变化,不像我们的设计,MOV就是MOV,全部的MOV指令都相应相同的操作码,Prefix部分也是我们的设计所没有的
以下简单的解释下这六个部分,每一个部分的详细含义和使用,后面的样例里会逐步阐述
prefix:
指令前缀,为了一些特殊的定义或者操作而存在,仅仅有10个可能的值,能够在下表里面查到,我们大致了解下就是了
• 锁(Lock)和反复前缀:
锁前缀用于多CPU环境中对共享存储的排他訪问。反复前缀用于字符串的反复操作。他能够获得比软件循环方法更快的速度。
— F0H—LOCK 前缀.
— F2H—REPNE/REPNZ 前缀.
— F3H—REP 前缀
— F3H—REPE/REPZ prefix (used only with string instructions).
• Segment override:
依据指令的定义和程序的上下文,一条指令所使用的段寄存器名称能够不出如今指令格式中,这称为段缺省规则。当要求一条指令不按缺省规则使用某个段寄存器时,必须以段代替前缀明白指明此段寄存器。
— 2EH—CS 段前缀
— 36H—SS 段前缀.
— 3EH—DS 段前缀.
— 26H—ES 段前缀.
— 64H—FS 段前缀.
— 65H—GS 段前缀.
• 操作大小前缀 66H 和 地址长度前缀 67H
Opcode:
操作码,这个操作码指定了详细的操作,他的值能够在下表查到,注意查表时候要依据操作类型,操作数类型和寻址方式来查询,比方Mov指令有12种操作操作码,我们须要依据操作数的类型,比方Mov bx,1,的两个操作数一个是寄存器,一个是马上数,即Reg,Imm,查下表,应为1011wrrr
MemOfs,Acc 1010001w
Acc,MemOfs 1010000w
Reg,Imm 1011wrrr
Mem,Imm 1100011woo000mmm
Reg,Reg 1000101woorrrmmm
Reg,Mem 1000101woorrrmmm
Mem,Reg 1000100woorrrmmm
Reg16,Seg 10001100oosssmmm
Seg,Reg16 10001110oosssmmm
Mem16,Seg 10001100oosssmmm
Seg,Mem16 10001110oosssmmm
Reg32,CRn 000011110010000011sssrrr
CRn,Reg32 000011110010001011sssrrr
Reg32,DRn 000011110010000111sssrrr
DRn,Reg32 000011110010001111sssrrr
Reg32,TRn 000011110010010011sssrrr
TRn,Reg32 000011110010011011sssrrr
表中rrr,w,mmm,oo都能够看做几个变量, 会依据寄存器,和寻址方式的变化而变化,假设使用4位寄存器,比方al,ah,bl,bh等,则其值为0,否则为1,表<>能够查到,注意所查的结果中已经包括了后面的ModR/M字节
ModR/M和SIB:
这两个字节共同决定了寻址方式,ModR/M包括三个部分oo|rrr|mmm:这三个部分联合表示了寻址方式,oo指示了寻址模式,rrr:指明所用寄存器,注意使用<>查询得到的结果里已经包括ModR/M字节,而SIB是辅助的寻址方式确定位,也包括三个部分
- ss:放大倍数
- iii:变址寄存器
- bbb:基址寄存器
比方假设用到这种地址[ebp+5*esi],则ebp为基址寄存器,esi为变址寄存器,5为放大倍数
Displayment偏移量位:寻址方式中的偏移量,如[ebp+5]中的5
Immediate:马上数,操作数中的马上数
一起练练手:人肉翻译汇编代码
一) mov bx,cx
查询其操作码为1000 100w,因为使用16位寄存器,则w=1 得到100010001即16进制的89H
ModR/M 包括三个部分oo|rrr|mmm:这三个部分联合表示了寻址方式,这里因为没有内存寻址,查表得,oo=11,rrr和mmm各表示一个寄存器,那么问题来了:哪个表示目的寄存器bx,哪个表示源寄存器cx呢?翻文档太累了,不如用nasm汇编一下这条指令瞧瞧.得到的ModR/M字节为相应寄存器代码能够看出来,rrr表示的是源寄存器bx,则这一个字节为:11 001 011,即16进制CBH
因为这条语句没有内存寻址,SIB列为空,也没有偏移量列Displayment,这条语句也没有马上数作为操作数,所以Immediate列为空
至于Prefix列,我们略微看下Prefix的说明和他的值表就能知道,Prefix列仅仅有少数的几种情况才干出现,比方段超越啊,16位/32位切换啊,锁定啊,像mov bx,cx这样普通的语句自然也没有Prefix列
所以我们能够得到mov bx,cx的终于代码为
Prefix | Opcode | ModR/M oo|rrr|mmm | SIB ss|iii|bbb | Displayment | Immeidate | |
mov bx,cx | 100010001 | 11 001 011 | ||||
mov cx,bx | ||||||
mov cl,bl |
既然已经掌握了mov bx,cx,那么mov cx,bx呢?
mov cl,bl呢?大家自己想想
假设认为上面样例还是太简单了,毕竟6列仅仅用了2列,那么我们就来挑战一个有点难度的怎么样
二) mov [ebx+esi+1],dword 00h
word是nasm的keyword,表明存入内存的操作数是一个双字,在内存中占32位,即4个字节
查询Opcode,得1100011w,w=1,即C7
如今来看ModR/M,这里会有些变化了,我们要细致分析我们的内存寻址方式ebx+esi+1,有一个8位的偏移量1,所以oo=01,后面的rrr和 mmm该指明用于寻址的两个寄存器,ebp和esi,查询rrr表,应该各自是011,110,则rrr=011,mmm=110,可是我偏偏不这样作, 我设置rrr为000(EAX),mmm为100(ESP),于是代码变为了01000100,44h
奇怪?明明是ebx+esi,怎么偏偏让你给变成了eax+esp了?
事实上在查询mmm的时候,我们不应该查询rrr表,应该查询iii表,iii表是专门查询变址寄存器号码的,rrr表和iii表基本上全然同样,仅仅是 rrr表中100代表ESP,而iii表中呢.....no index....,这不是表示没有变址寄存器,而是表示设置两个寄存器的工作交给后面的SIB来做,44h能够看做是个特殊的数字,这个数字就表明寻址方式所用的寄存器会让SIB位来完毕.
上面的做法不是我别出心裁,事实上假设你用nasm编译这句话,也会得到这个结果,让SIB来设置内存寻址,我想至少有两个优点,
一是能够更加灵活一些,毕竟人家SIB有整整一个字节专门来作这件事情,比方假设寻址模式位改为ebx+esi*2+1,SIB里专门有两位ss,表示这个倍数,而ModR/M里呢,对不起,没地方放了
二是能够让汇编编译器简单一些:统一成一种格式方便处理
ok,那么假设我们严格依照寄存器查表的结果(ebx=011,esi=110)能不能执行呢,大家自己去试试吧
SIB
ss:没有倍数,ss=00
iii:刚才查过了esi=110
bbb:ebx=011
合起来是00110011即33
后面是8位的偏移量,01h,最后是马上数00h,注意这里是个双字,所以占4个字节
填在表里
Prefix | Opcode | ModR/M oo|rrr|mmm | SIB ss|iii|bbb | Displayment | Immeidate | |
mov [ebx+esi+1],dword 00h | 67,66 | C7 | 44 | 33 | 01 | 0000 |
你可能用nasm汇编了一下这条语句,发现前面多了个67,66,恭喜你,67和66正是Prefix,因为你是在16位环境下汇编的,所以假设某条指令使用到32位的数据和地址,指令前面就会出现前缀,67表示使用了32位地址,66表示使用了32位数据.消除的方法是在文件头上加上[BITS 32]
推荐一个好的机器码入门<老罗的OPCODE教程:http://www.luocong.com/learningopcode.htm>,x86 OPCODE规范下载<>
让人迷惑的倒置 -LittleEndian
參见上面的代码,MOV到ax的操作数为16位二进制的一,即0001h(h表示16进制)但是从这里看上去,是0100h,这是为什么呢?
事实上这是著名的Little Endian存储格式捣的鬼,Little Endian的意思是高位在高地址,低位在低地址,比方0100 0011 0010 0001这个二进制数(十六进制为4321h),在内存里类似
位置 | 00 | 01 | 02 | 03 | 04 | 05 | 06 | 07 | 08 | 09 | 10 | 11 | 12 | 13 | 14 | 15 |
值 | 1 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 1 | 1 | 0 | 0 | 0 | 0 | 1 | 0 |
显示的时候,显示程序一般都以一个字节为总体显示这个数,即先解析处0-7位,为数字21h,显示在前面,然后解析8-16位,为数据43h,显示在后面,则变为了21h 43h,假设显示程序能依照字为总体解析并显示,就能没有这个倒装了,可是显示是不会知道你究竟须要怎么显示的,比方你能够定义一个32位数据,也可能定义64位数据,即使是依照16位,也仍然会有倒装发生,所以如今一般显示程序都简单依照字节显示
除了LittleEndian反过来当然也有BigEndian,这样的存储格式就和咱平时的数字理解习惯没有冲突了
LittleEndian 是Intel x86(8086/8088,80286,80x86,PentiumX)系列CPU所採用的格式,而BigEndian是Motorola的 PowerPC系列CPU所採用的标准,网络传输也採用BigEndian,二者各有优缺点,有兴趣的读者能够參考1980年的著名论文<On Holy Wars and a Plea for Peace>
别看LittleEndian这个是个细节,却绊倒了不少刚開始学习的人的腿,比方你刚打开Windbg,想尝试利用调试工具改动某个游戏角色的体力值,从 157110改动为100000000,157110的16进制为265B6,而你在内存里怎么都找不到02 65 B6这个序列,那就是LittleEndian搞的鬼
据Jargon File记载。endian这个词来源于Jonathan Swift在1726年写的讽刺小说 "Gulliver's Travels"(《格利佛游记》)。该小说在描写叙述Gulliver畅游小人国时碰到了例如以下的一个场景。
在小人国里的小人由于很小(身高6英寸)所以总是碰到一些意想不到的问题。有一次由于对水煮蛋该从大的一端(Big-End)剥开还是小的一端(Little-End)剥开的争论而引发了一场战争,并形成了两支截然对立的队伍:支持从Big- End剥开的人Swift就称作Big-Endians而支持从Little-End剥开的人就称作Little-Endians……(后缀ian表明的就是支持某种观点的人:-)。
Endian这个词由此而来。
1980年。Danny Cohen在其著名的论文"On Holy Wars and a Plea for Peace"中为了平息一场关于在消息中字节该以什么样的顺序进行传送的争论而引用了该词。
该文中。Cohen很形象贴切地把支持从一个消息序列的 MSB開始传送的那伙人叫做Big-Endians。支持从LSB開始传送的相相应地叫做Little-Endians。
此后Endian这个词便随着这篇论文而被广为採用。
思考:指令的起止
既然每条指令都可能不一样常,我们的CPU怎么知道每条指令从哪里開始,到哪里结束?
要知道变长指令的起止,系统就必须自己知道各个指令的长度,能够说系统内部有个登记簿,登记了每一个指令的长度.
程序运行的时候,系统会把eip指向的指令载入到cpu,cpu会尝试翻译指令,这样系统会知道这条指令的长度,比方长度为6,则将eip添加6,指向下一条语句.怎样正确计算指令长度本身是採用CISC(复杂指令集)计算机特有的问题,由于使用RISC(精简指令集)的 cpu,他的指令长度是固定的,让指令变长的优势在于能够节省空间,也方便以后的扩展,缺点是cpu实现会比較复杂
输出结果
或许你认为尽管cpu已经运行了我们的工作,可是因为看不到结果,不能满足我们小小的虚荣心,那么以下我们就告诉系统,让他把结果展示在屏幕上
打开刚才建立的test.com,在刚才的程序后面附加上以下这段
04 30 88 C2 B4 02 CD 21 E9 FD FF
程序变为:
B8 01 00 05 01 00 04 30 88 C2 B4 02 CD 21 E9 FD FF
保存执行一下看看是不是输出了结果
感觉好多了吧,至少看见了自己劳动的结晶,后面附加的那段机器码是调用了Dos的int 21中断输出了一个字符,我们直接给出他相应的汇编代码
mov ax,1
add ax,1
add al,'0' ;数字到ascii的粗糙转换
mov dl,al ;-----|
mov ah,02h;-----|--调用中断
int 21h ;-----|
jmp $ ;保证程序不会马上退出,好让我们看到结果
从上面的图上我们能够清晰的看到机器码和汇编指令的相应关系,不再赘述
add al,'0',是把结果转化成ascii,'0'的值为30h,2+30h=32h,是'2'这个字符的ascii值,当然这是个很粗糙的转换,一旦数字大过9,就会输出奇怪的结果,这样作是为了机器码尽量简单,方便大家输入
通过上面的二进制编码与汇编代码的对照,我们大概能知道汇编和机器指令是一一相应的,可是因为机器指令实在是太不方便人类记忆,写起来也很繁琐,所以须要汇编语言,也就是说汇编语言实际上是机器语言的助记符号
总结
我们会算1+1了