32位处理器的指令操作尺寸
在32位处理器中,源操作数既可以是32位的,也可以是16位的,还可以是8位的,目的操作数同理,并且在实模式下,也可以用32位寄存器来访问内存,那么是不是说实模式下也可以访问全部4G字节内存呢?通常来讲,是不行的,因为32位处理器的描述符高速缓存中,除了有线性基地址,还有段界限,在实模式下计算机会将其预置为0xFFFF,也就是64KB,如果超过这个界限也会被处理器阻止,这样以保证程序在实模式下和8086的表现相同。比如下面这样:
mov cx, 0x2000
mov ds, cx ;将逻辑段地址0x2000加载到ds中
mov eax, 0xFFFF
mov bl, [eax] ;从偏移量为0xFFFF的地方取一个字节
mov bx, [eax] ;从偏移量为0xFFFF的地方取一个字
如果你在bochs虚拟机中调试上面那段代码,执行倒数第二行指令是没问题的,因为它只在段界限处取一个字节,等于但并未超出界限,而再执行下面一条指令时,虚拟机会报错,因为下面取了两个字节,超出了段界限。
但刚刚之所以说通常,是因为某些情况下,它又是可以访问全部4G字节内存的,32位处理器还有一种Unreal模式,它的描述如下:
在这种模式下,处理器以实模式运行,但段大小不再限于64KB。
x86的指令格式
操作码和立即数部分
x86有些指令格式比较简单,只包含操作码和立即数,比如:
mov al, 3
mov cx, 3
mov edx, 3
上述三条指令转换成机器码应该如何表示?我们来参考一下《x86架构软件开发手册卷二》。
在MOV指令参考中,我们找到符合第一条指令的描述:
其中第二列MOV r8, imm8,imm8表示一个8位立即数,r8表示8位寄存器,我们看到操作码部分是B0+rb,其中rb代表一个8位寄存器的编号,如何知道这个编号呢?参考Table2-1的表头:
寄存器编号就是倒数第二行,我们找到AL对应的编号是0,B0+0还是B0,所以mov al, 3的机器码是B003
。
同理,我们再找到符合第二条指令的描述:
第二列,imm16表示16位立即数,r16表示16位寄存器,操作码部分是B8+rw,rw同样是寄存器编号,根据Table2-1的表头,我们得知cx的编号是1,B8+1=B9,再加上立即数0003得到机器码B90300
。
Intel处理器采用小端序,所以立即数部分是0300,而非0003
再找到第三条指令的描述:
imm32是32位立即数,r32是32位寄存器,操作码部分B8+rd,rd还是寄存器编号,我们找到edx编号为2,B8+2=BA,再加上立即数部分得到BA03000000
。
ModR/M和偏移量部分
有些处理器指令比较复杂,需要在寄存器与寄存器之间,或者寄存器与内存之间进行操作,此时在操作码后面需要一个寻址方式部分,称之为ModR/M,如图:
ModR/M占一个字节长,其中mod和r/m部分需要组合使用。
e.g. 有如下三条指令:
mov al, cl
mov ax, [bx]
mov cx, [bx+si]
我们分别求三条指令的机器码,先看第一条指令,在《x86架构软件开发手册卷二》中我们找到最符合它的描述:
88 /r是它的操作码,其中/r表示指令中的纯寄存器,也就是后面的r8,而前面的 / 表示操作数的扩展,需要将寄存器的编号填充到ModR/M的中间reg/opcode部分,根据前面的经验,cl的编号是001。
如何找到该指令的mod和r/m部分?我们还看Table2-1,找到其中寄存器的部分:
可以看出,mod部分是11,而r/m部分是al所对应的000,所以整个ModR/M部分是11001000,转换为十六进制为C8,再加上前面的操作码部分,整条指令的机器码为88C8
。
再看第二条指令,找到与之最符合的描述:
/r指代r16,同时/表示需要将寄存器编号填充到ModR/M中间部分,第二条指令的纯寄存器是ax,编号000,再看mod和r/m部分:
[bx]所对应的mod、r/m分别是00、111,所以ModR/M是00000111,对应十六进制07,再加上操作码部分就是8B07
。
接下来第三条指令的描述与上一条一样,但是/r变成了cx,编号001,mod和r/m部分也变成:
也就是00、000,所以整个ModR/M是00001000,对应十六进制08,整条指令机器码为8B08
。
为了方便理解,我们做一个表格:
汇编指令 | 操作码 | mod | reg | r/m | ModR/M | 机器码 |
---|---|---|---|---|---|---|
mov al, cl | 88 | 11 | 001 | 000 | C8 | 88C8 |
mov ax, [bx] | 8B | 00 | 000 | 111 | 07 | 8B07 |
mov cx, [bx+si] | 8B | 00 | 001 | 000 | 08 | 8B08 |
根据指令的复杂程度,在ModR/M后面还可以有偏移量部分和立即数部分,比如:
汇编指令 | 操作码 | mod | reg | r/m | ModR/M | 偏移量 | 立即数 | 机器码 |
---|---|---|---|---|---|---|---|---|
mov ax, [bx+3] | 8B | 01 | 000 | 111 | 47 | 03 | 8B4703 | |
mov word [bx+si+3], 0x55AA | C7 | 01 | 000 | 000 | 40 | 03 | 55AA | C74003AA55 |
其中第二条指令是将一个立即数传送到一个内存地址中,描述如下:
操作码中的/0表示将数字0扩展到ModR/M的中间部分,所以第二条指令的reg一栏填000,同时Intel采用小端序,55AA在指令中表示为AA55。
SIB部分
32位处理器有一种特有的寻址方式:基址寄存器 + 索引寄存器 * 倍率 + 偏移量
,其中基址寄存器是全部32位寄存器,索引寄存器是除ESP以外的32位寄存器,倍率可以是(1,2,4,8)中的一个,偏移量是8位或者32位。
为了支持这种寻址方式,需要在ModR/M后面再加一个SIB部分,即Scale(倍率),Index(索引寄存器编号),Base(基址寄存器编号),SIB也占一个字节:
来看一个指令:
add edx, [eax+ebx*2]
在《x86架构软件开发手册卷二》找到符合该条指令的描述:
操作码为03,/r说明要将寄存器编号填充到ModR/M的中间部分,edx的编号为010,那么Mod和R/M部分是哪个呢?
根据下面注释的描述:
[–][–]意味着ModR/M后面跟随SIB字节,所以,Mod为00,R/M为100,SIB部分又该如何确定呢?
我们看表2-3:
符合该指令的是[EBX*2],SS是01,Index是011,而基址寄存器EAX的编号是000:
确定了以上内容,我们再来画个表格:
所以最终的机器码是031458
。
再来看一个例子:
add word [eax+ebx*8+0x3c00], 0x55AA
找到符合它的描述:
操作码为81,/0表示ModR/M中间部分是000,Mod和R/M部分是10、100:
再来找SIB部分:
可以看出SS部分是11,Index部分是011,Base是基址寄存器eax的编号000,确定了这些,我们再补充一个表格:
指令前缀部分
数据/地址尺寸反转
除了ModR/M和SIB,根据需要,x86指令格式还可以添加一个指令前缀,比如段超越前缀,数据尺寸反转前缀,地址尺寸反转前缀等,就拿尺寸反转前缀来讲,为了方便说明问题,我们来看三组指令及其对应的机器码:
汇编指令 | 机器码 | 数据/地址尺寸 |
---|---|---|
mov al, dl | 88 D0 | 8位数据 |
mov ax, dx | 89 D0 | 16位数据 |
mov eax, edx | 66 89 D0 | 32位数据 |
mov [bx+di], dh | 88 31 | 8位数据,16位地址 |
mov [bx+di], si | 89 31 | 16位数据,16位地址 |
mov [bx+di], esi | 66 89 31 | 32位数据,16位地址 |
mov [ecx], esi | 66 67 89 31 | 32位数据,32位地址 |
mov [ecx], si | 67 89 31 | 16位数据,32位地址 |
movsb | A4 | 8位数据 |
movsw | A5 | 16位数据 |
movsd | 66 A5 | 32位数据 |
默认16位操作尺寸
观察表格中第二组指令,你会发现机器码的ModR/M部分都是相同的31,根据Intel手册,表2-1和2-2 ModR/m是31的分别是[BX+DI]和[ECX],分别对应了16位寻址方式和32位寻址方式。
尽管指令的形式和ModR/M都一样,但毕竟是不同的指令,终归得产生不同的机器码,在16位处理器时代,也就是8086和80286时代,Intel为不同的数据尺寸设计了不同的操作码,比如mov [bx+di], dh的操作码是88,mov [bx+di], si的操作码是89。
而到了32位处理器时代,数据尺寸和地址尺寸都得到了扩展,为了不增加指令复杂性,Intel决定,如果ModR/M相同,操作尺寸不同,使用同一个操作码,利用指令前缀来区分尺寸,在上表中看到的66、67就是指令前缀。而且通过观察很容易看出,在默认操作尺寸是16位的情况下,如果操作的数据是32位,则会添加66前缀,如果有效地址是32位,则会添加67前缀,如果数据和地址都是32位,66和67都会被添加。
相反,如果默认操作尺寸是32位的呢?
汇编指令 | 机器码 | 数据/地址尺寸 |
---|---|---|
mov al, dl | 88 D0 | 8位数据 |
mov ax, dx | 66 89 D0 | 16位数据 |
mov eax, edx | 89 D0 | 32位数据 |
mov [bx+di], dh | 67 88 31 | 8位数据,16位地址 |
mov [bx+di], si | 66 67 89 31 | 16位数据,16位地址 |
mov [bx+di], esi | 67 89 31 | 32位数据,16位地址 |
mov [ecx], esi | 89 31 | 32位数据,32位地址 |
mov [ecx], si | 66 89 31 | 16位数据,32位地址 |
movsb | A4 | 8位数据 |
movsw | 66 A5 | 16位数据 |
movsd | A5 | 32位数据 |
可以看出,32位尺寸下指令前缀刚好和16位相反,如果使用了16位数据则会添加66前缀,如果使用了16位地址则会使用67前缀。
描述符中的D位
处理器在执行指令时,如何判断当前代码该用16位还是32位呢?在段存储器描述符中,有一个D/B位,如果S=1,X=1,表示该段是代码段,此时D/B是D位。如果D=0,处理器使用16位尺寸处理数据及寻址,如果D=1,处理器使用32位尺寸处理数据和寻址。
到此为止,x86的所有指令格式就全部涵盖了,这里有一张Intel手册中的指令格式截图:
切换32位操作尺寸
虽然在保护模式中,使用16位操作尺寸也可以,但32位处理器还是使用32位尺寸更加得心应手,在代码中,我们可以添加一行bits指令:
[bits 32]
只要有bits 32一条声明,之后所有的指令都会按照32位尺寸来进行编译,但是通常在实模式下执行16位代码时,声明为32bits的很多指令已经进入了流水线,显然按照16位尺寸去看待提前进入流水线的32位指令,是错误的,所以这时我们需要清空流水线。
在x86中,只要执行了jmp指令,必然会清空流水线,不过,保护模式下的jmp和实模式下的jmp有些不同,实模式下jmp指令是jmp 逻辑段地址:偏移地址
,而保护模式需要你指定一个段选择子,像这样jmp 描述符选择子:段内偏移量
,一旦执行了jmp指令,处理器会从GDT中取出指定的描述符并缓存到cs的描述符高速缓存器中,然后用缓存中的线性基地址+段内偏移地址计算出目标指令位置,并跳转到该处执行,此时流水线中错误的指令已经清空。
e.g.
jmp dword 0000000000001_0_00B:flush ;16位的描述符选择子:32位偏移
;清流水线并串行化处理器
[bits 32]
flush:
mov cx,0000000000010_0_00B ;加载数据段选择子(0x10)
mov ds,cx
话说mov ds, eax
在32位处理器中,往段寄存器中传送数据可以这样写:
mov ds, ax
mov ds, eax
mov ds, edx
mov cs, eax
mov ss, eax
mov gs, eax
mov fs, eax
上述指令中,第一条没问题,但是后面的所有指令你会感觉很奇怪,怎么能用32位通用寄存器向16位段寄存器中传输数据呢?然而这些指令编译都可以通过,在Intel的手册第二卷中,有这么一段描述:
翻译过来大概意思是:
在32位操作尺寸下,向段寄存器和通用寄存器之间传送数据,IA-32处理器不要求必须使用0x66尺寸反转前缀,但是大部分编译器会遵照标准格式来插入这个前缀,(例如mov ds, ax)。处理器当然能够正确执行这条指令,但是将会额外消耗一个时钟周期。在大部分汇编器中,使用mov ds, eax可以避免不必要的0x66前缀。当处理器用32位通用寄存器作为源操作数或者目的操作数时,仅低16位有效。如果寄存器是目的操作数,高位2字节结果依赖处理器实现,例如奔腾4,Intel至强,P6家用处理器,高位2字节填充0;其它32位处理器没有定义。
也就是说,之所以出现这种奇怪的写法,是为了避免不必要的0x66数据尺寸反转前缀,不过你要是使用NASM编译器的话,是没有这种问题的,NASM无论哪种写法,都不会添加0x66前缀。