朱燚

--书到读透处,酒于微醺时

  博客园 :: 首页 :: 博问 :: 闪存 :: 新随笔 :: 联系 :: 订阅 订阅 :: 管理 ::

 

准备你的行囊----建立环境

为了让大家更为轻松,除非迫不得已,我们尽量使用系统上已经安装的工具,在这一章里,下面两个外部工具是必须的

  • 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,来看看我们到底输入了什么

image

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种之多,而且操作码的长度还有可能不同,这些操作码都可以在表<x86操作码查询表>中对应的查到,不需要记忆,下面我们就来了解机器语言指令的格式

自己设计机器语言指令格式

在阅读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,表<x86操作码查询表>可以查到,注意所查的结果中已经包含了后面的ModR/M字节

    ModR/M和SIB:

    这两个字节共同决定了寻址方式,ModR/M包含三个部分oo|rrr|mmm:这三个部分联合表示了寻址方式,oo指示了寻址模式,rrr:指明所用寄存器,注意使用<x86操作码查询表>查询得到的结果里已经包含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的关键字,表明存入内存的操作数是一个双字,在内存中占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 $        ;保证程序不会立即退出,好让我们看到结果

    image

    从上面的图上我们可以清晰的看到机器码和汇编指令的对应关系,不再赘述

    add al,'0',是把结果转化成ascii,'0'的值为30h,2+30h=32h,是'2'这个字符的ascii值,当然这是个非常粗糙的转换,一旦数字大过9,就会输出奇怪的结果,这样作是为了机器码尽量简单,方便大家输入

    通过上面的二进制编码与汇编代码的对比,我们大概能知道汇编和机器指令是一一对应的,但是由于机器指令实在是太不方便人类记忆,写起来也非常繁琐,所以需要汇编语言,也就是说汇编语言实际上是机器语言的助记符号

     

    总结

     

  • 我们会算1+1了

     

     

     

  • posted on 2009-03-13 20:28  朱燚:-)  阅读(7672)  评论(24编辑  收藏  举报