反汇编

第七部分 立即数

     立即数是Intel指令的中操作对象的一种(加上寄存器、内存共三种)。作为指令的一部分,立即数必须是直接可用的数字,这是立即数跟其他两种操作对象的主要区别(寄存器操作对象,CPU需要首先定位到寄存器,然后再读或写该寄存器中的对象。CPU指令的执行过程可粗分为:取指令,指令解码,取操作对象, ALU运算,存结果,取下一条指令……,立即数跟其他两种操作对象的区别体现在“取操作对象”这一步。立即数需要0个时钟周期,解码之后,立即数就可以使用了,立即立即,这就是立即的涵义所在;寄存器操作对象需要1个时钟周期;内存操作对象根据寻址方式的不同可能需要2-3或更多个时钟周期)。

7.1 立即数的解析

    立即数的解析相对来很简单。立即数有一些很明显的特性:
    1 立即数只有大小的分别。一般情况,我们也把改变EIP的指令中的常数作为立即数来处理。比如jmp rel8/16 或者 call 16:32……,这里相对偏移地址的大小与EIP/IP大小不一致的时候,一般需要进行符号位扩展。(前面曾经提到过指令的操作数大小一般必须相同,这点有特殊情况存在的,操作数大小扩展指令的操作对象就不一样,例如movcx, movzx, cwd, cdq等等指令)。

    
2 立即数只能做为源操作数而不能作为目的操作数。这点是很明确的,目的操作对象只能是寄存器或者内存。

    
立即数的解析只需要知道需要解析的立即数的大小就可以了,是几个字节读几个字节就行了。这里需要注意的一个重要的问题就是立即数需不需要进行符号为扩展的问题,或者更简单点来说,就是立即数是signed类型的还是unsigned的类型的。这就需要对指令的另外一个特殊的位进行解析了,那就是s位。

7.2 s

先来看看下面的一组指令:

    00401000      80C0 F8             add     al, 0F8
    00401003      81C0 78563412       add     eax, 12345678
    00401009      82C0 F8             add     al, -8    
实际为: add al, F8
    0040100C      83C0 F8             add     eax, -8    
实际为: add eax, FFFFFFF8

    
看看80818283指令的定义:(括号中表示ModR/M中的Reg/Opcode辅助编码部分)
    800  add  r/m8,    imm8
    81
0  add  r/m16/32/64,  imm16/32
    82
0  add  r/m8,    imm8
    83
0  add  r/m16/32/64,  imm8
    
    16/32
位的立即数不存在扩展之分,这里主要的区别在8位的立即数。很明显,82中的立即数是signed类型的,而80的是unsigned类型的,83实际为add eax, FFFFFFF8(因为指令的操作对象必须大小一致),需要把8位的立即数进行符号位扩展成32位。我们看看他们的编码区别:

    
指令   编码       非立即数操作对象大小  立即数
    ----------------------------------------------
    80  1000 00[0]0  8
    8(unsigned)   
    81  1000 00[0]1  16/32
    16/32
    82  1000 00[1]0  8    8(signed)    
    83  1000 00[1]1  16/32
    8(signed extern to 32)

    
这里我们首先能很明显地看到w位的存在,w位决定了内存编码的操作对象的大小,这里就毋须多提了。我们可以看到,立即数需不需要进行符号位扩展,跟指令的第二位密切相关,上面已经用方括号标记出来了。当指令的第二位为1的时候,指令中的立即数需要进行符号位扩展(扩展到多少位,就看另外一个操作数的大小了,比如上面82,另外一个操作数就8位,立即数需要作0位扩展,不变,但是最高位就是符号位,此时的立即数变成了一个8位的符号数。83,另外一个操作数为32位,那么8位的立即数就需要符号扩展成32位,即FFFFFFF8),否则就不进行符号位扩展。这个位就是s位。

    
这里我们看到,8183实际上进行的是相同的操作,例如:

    0040100F      81C0 F8FFFFFF       add     eax, -8

    
由于32位的无所谓扩展问题,83只是81的子集,然而83有着更短的指令编码。

    
一般来说,当指令中存在立即数,而且立即数作为运算对象的时候,s位才有定义。

    
比如:
    0040101E      CA 12FF             retf    0FF12
    CA  rentf  imm16
    s
 = 1但是并无扩展之说。

7.3 立即数的解析代码

    立即数的解析相对来说很容易,这里需要知道待解析的立即数的大小,立即数的s位的值,和是否考虑s位进行符号位扩展。需要说明的是,立即数的解析部分只处理立即数,而相对偏移地址等,在指令的识别/解析过程中进行。

    
相关代码下载:

    
immediate.rar
    
    
测试结果:

    


    od
中检验一下:

    

   
    
在做32位的运算时,FFFFFFF8  -8是没有任何区别的,FFFFFFF8实际上是-8在计算机中的补码表示,这里如何翻译,就取决于需要何种表示了。

 

最后一部分:指令识别

指令格式通过简单的实践是很容易搞明白的,但是指令识别就需要花费一番功夫了。这里的功夫并不是理解上的功夫而是数据结构设计、程序结构设计以及敲键盘的功夫。而不同的汇编引擎最主要的差别也是在指令识别这个部分。

指令表是一张复杂的表,我看到有用“geek”来形容intel的指令表的。也的确,如果既要考虑效率,又要考虑编程方便或者说程序的优雅的话,的确得花一番功夫。下面我把我了解到的一些指令表的知识总结一下:

8.1 switch...case

很老土,很笨重,能理解的指令扩展和错误处理相对麻烦一点(一般反汇编引擎这种简单的代码也就是几个人在维护,这点倒不是最主要的硬伤,最最主要的硬伤个人愚见还是这种方式只能用于反汇编,汇编几乎不能从这些代码中获取任何有用的信息,而一般反汇编过程总需要一些汇编的功能),但效率高。所以如果单单想打造一个反汇编引擎(或虚拟机指令识别系统)的话,要我选择我铁定会选择switch...case这种架构方式。高级语言switch...case一般都用跳转表的方式实现,毋须查表系统,而且指令处理过程极其灵活,intel指令中定义的所有技巧都可以在这种构建得到充分的体现。

switch...case
很简单:

代码:

switch(opcode)

{

    case 0xXX:

    {

       /* 这里相当于已经知道了指令定义(什么指令,有些什么格式的参数),调用指令格式的各个解析部分,传入相应的参数就可以很容易解决了 */

 

       Mnemonic = "";

       operand1 = ParseModeRM(...)

       ....

 

       break;

    }

 

   default:

   {

      Mnemonic = "undefined";

   }

}

我总结在第四部分的各种规律,都可以很容易,很方便地应用在指令的解析过程中,这里给出我使用的运算操作和跳转操作的代码(解析过程有错误但是旨在表明思路):

首先是指令的规律,下面的指令组:

00  00[00 0]000  add  r/m8   r8
01  00[00 0]001  add  r/m16/32/64  r16/32/64
02  00[00 0]010  add  r8  r/m8
03  00[00 0]011  add  r16/32/64  r/m16/32/64
04  00[00 0]100  add  al  imm8
05  00[00 0]101  add  rax  imm16/32

08  00[00 1]000  or  r/m8   r8
09  00[00 1]001  or  r/m16/32/64  r16/32/64
0A  00[00 1]010  or  r8  r/m8
0B  00[00 1]011  or  r16/32/64  r/m16/32/64
0C  00[00 1]100  or  al  imm8
0D  00[00 1]101  or  rax  imm16/32

10  00[01 0]000  adc  r/m8   r8
11  00[01 0]001  adc  r/m16/32/64  r16/32/64
12  00[01 0]010  adc  r8  r/m8
13  00[01 0]011  adc  r16/32/64  r/m16/32/64
14  00[01 0]100  adc  al  imm8
15  00[01 0]101  adc  rax  imm16/32

18  00[01 1]000  sbb  r/m8   r8
19  00[01 1]001  sbb  r/m16/32/64  r16/32/64
1A  00[01 1]010  sbb  r8  r/m8
1B  00[01 1]011  sbb  r16/32/64  r/m16/32/64
1C  00[01 1]100  sbb  al  imm8
1D  00[01 1]101  sbb  rax  imm16/32

20  00[10 0]000  and  r/m8   r8
21  00[10 0]001  and  r/m16/32/64  r16/32/64
22  00[10 0]010  and  r8  r/m8
23  00[10 0]011  and  r16/32/64  r/m16/32/64
24  00[10 0]100  and  al  imm8
25  00[10 0]101  and  rax  imm16/32

28  00[10 1]000  sub  r/m8   r8
29  00[10 1]001  sub  r/m16/32/64  r16/32/64
2A  00[10 1]010  sub  r8  r/m8
2B  00[10 1]011  sub  r16/32/64  r/m16/32/64
2C  00[10 1]100  sub  al  imm8
2D  00[10 1]101  sub  rax  imm16/32

30  00[11 0]000  xor  r/m8   r8
31  00[11 0]001  xor  r/m16/32/64  r16/32/64
32  00[11 0]010  xor  r8  r/m8
33  00[11 0]011  xor  r16/32/64  r/m16/32/64
34  00[11 0]100  xor  AL  imm8
35  00[11 0]101  xor  rAX  imm16/32

38  00[11 1]000  cmp  r/m8   r8
39  00[11 1]001  cmp  r/m16/32/64  r16/32/64
3A  00[11 1]010  cmp  r8  r/m8
3B  00[11 1]011  cmp  r16/32/64  r/m16/32/64
3C  00[11 1]100  cmp  AL  imm8
3D  00[11 1]101  cmp  rAX  imm16/32

这里我们先定义:

代码:

const char *ArithmeticMnemonic[] = {"add", "or", "adc", "sbb", "and", "sub", "xor", "cmp" };

可以看到指令的中间3bit索引这上面数组中的指令名称,而每组织指令满足d, w位的条件(先不考虑al, rax的指令),这样我们很容易就解决掉这些指令:

代码:

case 0x00: case 0x01: case 0x02: case 0x03: 

case 0x08: case 0x09: case 0x0A: case 0x0B: 

case 0x10: case 0x11: case 0x12: case 0x13: 

case 0x18: case 0x19: case 0x1A: case 0x1B: 

case 0x20: case 0x21: case 0x22: case 0x23: 

case 0x28: case 0x29: case 0x2A: case 0x2B: 

case 0x30: case 0x31: case 0x32: case 0x33: 

case 0x38: case 0x39: case 0x3A: case 0x3B: 

{

  Instruction->Opcode = *currentCode;

  Instruction->dFlag = (*currentCode >> 1) & 1;

  Instruction->wFlag = (*currentCode) & 1;

 

  sprintf(mnemonic, ArithmeticMnemonic[(*currentCode >> 3) & 0x1F]);

      

  currentCode++;

  currentCode = ParseRegModRM(currentCode, Instruction, operand1, operand2);

      

  break;

}

对反汇编引擎,这样就可以完成对这些指令的识别,对虚拟机系统,由于这些指令的操作是相同的(加法器),所以也可以用中间3bit作指令操作的识别。

同样对跳转指令:

00401000 >  - 70 FE             jo      short 00401000  0111 [000]0
00401002    - 72 FE             jb      short 00401002  0111 [001]0   
00401004    - 74 FE             je      short 00401004  0111 [010]0
00401006    - 76 FE             jbe     short 00401006  0111 [011]0
00401008    - 78 FE             js      short 00401008  0111 [100]0
0040100A      7A FE             jpe     short 0040100A  0111 [101]0
0040100C    - 7C FE             jl      short 0040100C  0111 [110]0                  
0040100E    - 7E FE             jle     short 0040100E  0111 [111]0                  
    
00401011    - 71 FE             jno     short 00401011  0111 [000]1
00401013    - 73 FE             jnb     short 00401013  0111 [001]1           
00401015    - 75 FE             jnz     short 00401015  0111 [010]1
00401017    - 77 FE             ja      short 00401017  0111 [011]1                  
00401019    - 79 FE             jns     short 00401019  0111 [100]1                  
0040101B      7B FE             jpo     short 0040101B  0111 [101]1            
0040101D    - 7D FE             jge     short 0040101D  0111 [110]1            
0040101F    - 7F FE             jg      short 0040101F  0111 [111]1 

我们也可以用相似的方法处理:

代码:

const char *JxxxMnemonic[] = {"jo", "jb", "jz", "jbe", "js", "jp", "jl", "jle"};

const char *JnxxMnemonic[] = {"jno", "jnb", "jnz", "ja", "jns", "jnp", "jge", "jg"};

识别最后一个bit来看指令名称在那个数组中,识别接着的三个nnn来决定是什么指令。

代码:

case 0x70: case 0x71: case 0x72: case 0x73: case 0x74: case 0x75: case 0x76: case 0x77:

case 0x78: case 0x79: case 0x7A: case 0x7B: case 0x7C: case 0x7D: case 0x7E: case 0x7F:

{

  Instruction->Opcode = *currentCode;

  

  sprintf(mnemonic, "%s", *currentCode & 1 ? JnxxMnemonic[(*currentCode >> 1) & 7] : JxxxMnemonic[(*currentCode >> 1) & 7]);

  currentCode++;

  sprintf(operand1, "short %X", Instruction->LinearAddress + *((char*)currentCode) + currentCode - Code + 1);

  currentCode++;

 

  break;

}

后面可以看到一些反汇编引擎使用的表格设计,添加表项内容的“努力”不会比写这样的代码简单到哪里去。这里还需要说明的是第四部分总结的“规律”其实在Intel 64 and IA-32 Architectures Software Developer's Manual 2B Instruction Set Reference 的附录B中都有官方定义,而且B中总结地更全面(这里为前面的无知羞愧一下)。

8.2 查表

汇编/反汇编过程就是一个查表过程,这里不同的汇编/反汇编程序使用的方式各不相同。但是所有的指令表都来自于官方定义的那几张表格,我学习了这些怪异的表格(要说效率性,intel把这张表设计得很高;说指令信息的完整性,无疑这些表最全;说指令范围,这些表覆盖了intel所有已定义和尚未定义的指令,但是这些的代价就是表格的诡异,把这些表格用通用的数据结构表示,是对数据结构设计的很好的考验),下面我把相关部分Intel 64 and IA-32 Architectures Software Developer's Manual 2B Instruction Set Reference附录总结(翻译)一下:

intel
的这些Opcode map按照1-byte2-byte3-byteOpcode extension(Mod/RMreg部分用于编码的情况intel单独提出来做了一张表)escape(0F)开头的浮点指令表这种方式组织的。

8.2.1 1-byte, 2-byte, 3-byte指令表 

先来看看1byte指令的查找:

 

表格行用这一个byte Opcode的低4位来索引,列用高4位来索引:以030500000000为例子。
(1) 找到上面表格的03列:ADD 指令的两个参数Gv, Ev
(2) 
理解参数GvEv的涵义:

intel
对指令参数的设计了一种表示方式,格式是Zz一个大写字母加上一个小写字母。其中大写字母表示寻找方式编码(寄存器,ModR/M,跳转相对地址,16:32地址格式等等),小写字母表示操作数的类型(byteworddword,不定根据cpu模式或改写指令决定,fword等等),Z总共从A-Z26种模式,z有大约十几种表示方式,他们的组合再加上纯寄存器表示了intel的所有操作对象。每个参数的具体涵义intel在附录的前面给出了详细地说明。从编写代码的角度,只要解析了所有的这些组合就可以实现所有指令的识别。

对这个例子:
G: 表示这个参数是Mod/RM部分的reg部分选择的寄存器。
E: 表示这个参数是由该Opcode后面紧跟着的Mod/RM索引的寄存器或内存,实际的地址由当前段寄存器和Mod/RM或后续的SIB以及偏移地址决定。(总之,一句话这个参数由Mod/RM编码)

v: word, dword
qword(64位模式下),由当前操作数属性决定(当前CPU模式,操作数大小改写指令前缀)。

这样我们就能很容易地理解了这个ADD后面有两个参数,由ModRM决定。ModRM = 05, Gv = reg(ModRM) = reg(00[00 0]101) = 000 = EAX(由于没有operand-prefix出现,v表示32)mod(ModRM) = 00 RM(ModRM) = 101 表示只有一个32位偏移地址因此 Ev = dword ptr[0x00000000]。因此030500000000表示:
ADD EAX, dword ptr[0x00000000]

2byte
3byte指令的查找方法是相同的,所不同的只是2byte0x0F开头,而3byte的以0x0F38H0x0F3AH开头,去掉这些头部,Opcode部分的查表示完全相同的。

 

8.2.3 扩展表

扩展表是指ModRM部分的reg/opcode部分也用来表示指令,这里intel把这些指令单独提出来做表。看上面1-byte指令表中的绿色部分,这里的Grp 11A)就是表示查找扩展表的Grp1。上标(1A)表示需要查扩展表,这里还有一些其他的上标定义,具体的涵义参考同一个附录的定义部分。



可以看到这个扩展表示根据ModRM的各个部分来定义的,对有些group来说,Mod的两个bit还有内存和11b(寄存器)的区别,reg/opcode部分当然是用来区分该组的不同指令了。

比如下面的指令:80C012

(1) 
查找1-byte指令表80 = Grp1(1A),两个参数Eb, Ib
(2) 查找扩展表 Group1[reg(C0) = reg(11[00 0]000) = 000],为ADD指令。
(2) E 操作数由ModRM编码
    I 立即数

    b
 不管operand-size属性是什么,都是1byte

    
这样Eb = ModRM(C0) = ModRM(11[00 0]000) = alIb = 12

所以80C012表示:ADD al, 12

这里只列举这两种表格的查询,浮点表的格式也是类似的。总之,intelOpcode Map是指令最全,定义最完整的,也可以看到这里的指令查找是采用索引的方式,效率是没得说的。但是如果你仔细研究一下这些表格你就会发现,这些表是为手工查询设计的,比如有些指令由于前缀的出现会有不同的助记符(比如90 = nop,而F3 90 = pause等等),而且有些上缀指令信息也必须包含进去,但是这些信息并没有统一的格式和位置。

如果要写一个反汇编引擎,

Intel 64 and IA-32 Architectures Software Developer's Manual 2B Instruction Set Reference的附录AB是必须完整地学习和了解的。水平有限(intel手册上讲解得更专业明白),我就不再多指手划脚了。感兴趣的朋友看官方手册能更明白。

 

8.3 “好”的反汇编引擎

从机器码到汇编指令助记格式不是一个太难得过程(可能需要一些时间编码),但是打造一个“好”的反汇编引擎可能需要花费些心思,我理解的“好”如下:
(1)能反编译个中各种CPU模式及子模式下的代码:IA-32, IA-32E(64位和兼容模式)。为了过程的简化(其实是水平有限)在过去的一些总结报告中我并没有提到IA-32E的两种子模式,这部分的知识官方手册Instruction Set Reference A上讲解得很明白。一个完整的反汇编引擎是必须首先考虑到这些模式问题的。
(2)效率高,绝对不能使“搜索”表。汇编(此法分析以后)和反汇编过程有相同性,指令表信息冗余小。
(3)解析的结果中能包含所有的信息(指令各部分的值,各部分的属性等等),并能提供不同格式的反汇编串(intel格式AT&T等)。

你可能感兴趣的:(汇编,编码)