2. 从哪里开始,到哪里结束

Everything that has a beginning... Has an end.

-- Neo, The Movie - "The Matrix: Revolutions"(2003)

开始

“从哪里开始,到哪里结束”,这是几乎所有的OpCode初学者都会问到的问题,具体来说,就是给定一串OpCode,计算机是如何知道哪里是某一条指令的开始,到哪里才是它的结束呢?

让我们照例从实例开始说明:

EB:imm8

这是一条近跳转指令,翻译成mnemonic就是jmpimm8表示一个8位的立即数,整条指令的意思是jmpimm8的偏移后的地址去。

从上一章的内容中我们可以知道,这条OpCode的域格式是这样的:

{code}{Immediate}

一共是2个字节。

问题来了,我们用肉眼一看就知道,这代表2个字节,我们也知道这条指令应该是从EB开始,总长度是2个字节,到imm8为结束,可是计算机是怎么知道这一点的呢?假如有一串OpCode发送给处理器,例如“90EB0090”,让它从中找到这个jmp指令,它会不会认不出来呢?

又或者,传送一串OpCode给处理器,例如“EB1234”,它会不会把后面的34也算进了jmp的跳转范围呢?

答案是,不会的。

CPU有个寄存器叫做EIP,它储存了内存中的某个地址,这个地址会告诉CPU,哪里是当前指令的开始;但是,在CPU没有对OpCode进行解码之前,它并不会知道哪里才是指令的结束。

让我们来举个例子:

00401000    90    NOP

第一列表示的是内存中的地址,在这里是00401000,它同时也是EIP的值,此时EIP = 00401000
第二列表示的是OpCode,第三列表示的是mnemonic,相信不必多说,读者也能明白它们的意思:90对应NOP

由于EIP = 00401000,所以CPU会知道当前的指令应该是从内存单元中的00401000开始,在这里,储存了一个OpCode:90,接下来CPU会对90进行解码:

OpCode:90
域格式:{code}

只有1个字节。所以CPU就会知道,OpCode“90”是从内存地址“00401000”开始,到“00401001”结束。

明白了吗?不过还有一种特殊情况:如果CPU遇到了无效的指令,它就会无法解析,例如OpCode“FFFF”,在运行的时候,会产生一个异常。

再来看本章开头的:

EB:imm8

EB是域{code},当EIP遇到内存中的EB的地址时,CPU就会知道第1个字节后面会跟着一个imm8立即数,总长度是2个字节。

至此,我们可以给出:

初步的结论

1. 开始:处理器认为当前EIP指向的内存单元中的第一个字节就是指令的开始。
2. 结束:处理器通过对OpCode进行解码(大多数情况下是根据{code}域),从而知道哪里是结束。

不过,不得不提的一点是:

举个例子:

00401000    EB 00    JMP 00000002
00401002    90       NOP

此时EIP = 00401000EB00翻译成mnemonic就是JMP 00000002。为什么呢?因为EB:imm8是2个字节的OpCode,在这里imm8的值是00,所以00(imm8) + 02(本条OpCode的长度) = 02(应该跳转的地址),也就是跳转到相对偏移为02的地方去。

因此,EB00运行完后,EIP的值应该是00401002,也就是指向90的地址,下一步处理器将会执行指令“NOP”。

好,再看:

00401000    EB FE    JMP 00000000
00401002    90       NOP

此时EIP = 00401000,但是为什么EBFE会是JMP 00000000呢?想想看?

答案:
FE + 02 = 100

由于imm8的关系(8位只表示一个字节),100其实只取00100其实是2个字节了——其高位为0,即0100
所以这条指令运行后,EIP应该还是00401000,没有改变!原因是这条指令的跳转地址是它本身!后面00401002处的“90”永远都不会执行!

真正的底层程序员应该会理解指令的本质,而不仅仅是从指令的字面上去理解它的意思。例如,cmp,从字面上来看,表示compare一些东西。但是真正的底层程序员不会这样说,他会说,cmp表示的是用第一个操作数减去第二个操作数,由此来设置相应的标志位。同时,我们关心的只是标志位,并不关心减操作后的结果,所以不需要把减操作的结果储存到第一个操作数中。

让我们回到正题。再来看一些应用:

OpCode:04 AC

00401000    04 AC    ADD AL, 0AC

我们已经知道,AC是助记符lodsb的OpCode,00401000是OpCode 04AC的开始地址,而00401002将会是它的结束(这个指令只有2个字节的关系)。但是,我们一直以来都没讨论的是:如果把这条OpCode从中间截断!即从00401001地址处开始的指令会是什么呢?

如果我们把寄存器EIP的内容设置成00401001,我们就会发现:
处理器会把AC看作lodsb,而不是:
ADD AL, 0AC
04:imm8(AL+imm8)中的imm8

应用这个原理,我们来看一个小例子,假设要实现下面的算法:

IF zf = 0
	lodsb
ELSE
	add al, 0AC

试试写成助记符?不知道读者朋友们会怎么写——我会写成这样:

jnz $+1
add al, 0AC

解释如下:

如果标志位zf等于0,则EIP会指向add al, 0AC的第2个字节,也就是AC——我们知道AC表示助记符lodsb
明白了吗?使人惊奇的是,整个算法的实现只用了区区4个字节!

这个算法的OpCode:

00401000    75 01    JNZ SHORT 3
00401002    04 AC    ADD AL, 0AC

让我们来看看每个字节表示什么意思:

75:imm87501 的域格式
75JNZ的OpCode,imm8在这里是01,会加到EIP里面去,整个7501表示如果这条指令被执行了,则EIP会指向下一条指令的第2个字节的地址。

04AC的域格式:
04:imm8 其中:
04 - {code}
AC - {Immediate}

整个算法实现的思路如下:

如果zf=07501这条指令就会把下一条指令的起始地址+1(75后面的操作数就是需要跳的字节数:0不跳,1跳一个,n就跳n个……但是字节是有符号的,负的就往后跳……所以jnz short xxx是有最大的跳跃限制的),然后把跳跃后的地址赋值给EIP——也就是00401003,从而迫使处理器认为AC所在的地址才是下一条指令的开始(跳过了OpCode 04),这时,AC会被当成{code}。

否则,EIP会指向04AC所在的地址00401002,所以下一条指令的开始就会从04开始算起,处理器会认出域格式:
04:imm8(add al, imm8)
这时,AC会被当成{Immediate},而不是{code}。

呵呵,是不是有点儿迷糊了?

为了加深理解,最后再给大家看一个算法及其实现:

IF zf = 0
	inc eax
ELSE
	mov al, 40

答案:

00401000    75 01    JNZ SHORT 3
00401002    B0 40    MOV AL, 40

嗯……提示一下:40表示的是inc eax……聪明的你,明白了吗?

结束

本章到这里已经结束了,但是……OpCode的学习只是刚刚开始而已,请大家打好精神,为后面的旅程作好准备!



(注:如果出现链接打不开的情况,请去掉IE浏览器的“工具->Internet选项->高级->总是以UTF-8发送URL”前面的勾。谢谢!)