基于栈的指令集架构
在汇编语言中,除直接内存操作的指令外,其它指令的执行都依赖寄存器,如跳转指令、循环指令、加减法指令等。汇编指令集是由硬件直接支持的,不同架构的CPU提供的汇编指令集也会不一样[1]。
以一个经典的++i面试题为例,使用c语言编写的实现如下。
int m = ++i;
反汇编后对应的32位x86 CPU的汇编指令如下。
inc dword ptr [ebp-44h] mov eax,dword prt [ebp-44h] mov dword ptr [ebp-4ch],eax
这三条指令的意思是,先将[ebp-44h]指向的内存块的值加1,dword ptr相当于c语言中的类型声明。接着将自增后[ebp-44h]指向的内存块的值放入eax寄存器,最后将eax寄存器的值放到[ebp-4ch]指向的内存块,也就是赋值给变量m。由于i和m是在栈上分配的内存,因此[ebp-44h]对应i的内存地址,[ebp-4ch]对应m的内存地址。
汇编指令不能直接操作将一块内存的值赋值给另一块内存,必须要通过寄存器。32位x86 CPU包括8个通用寄存器,EAX、EBX、ECX、EDX、ESP、EBP、ESI、EDI,其中EBP、ESP用做指针寄存器,存放堆栈内存储单元的偏移量[2]。这些看不懂没关系,这也不是本书的重点。
上述++i的例子使用java代码实现如下。
public static void main(String[] args) { int a = 10; int result = ++a; System.out.println(result); }
使用javap命令输出这段代码的字节码如下。
public static void main(java.lang.String[]); Code: 0: bipush 10 2: istore_1 3: iinc 1, 1 6: iload_1 7: istore_2 8: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 11: iload_2 12: invokevirtual #3 // Method java/io/PrintStream.println:(I)V 15: return
字节码指令前面的编号我们暂时理解为行号。在本例中,行号0到7的字节码指令完成的工作是将变量a自增后的值赋值给result变量。下面将详细分析这几条指令的执行过程。
1. bipush指令是将立即数10放入到操作数栈顶,如图1.10所示。
图1.10 bipush指令执行过程
2. istore_1指令是将操作数栈顶的元素从操作数栈出弹出,并存放到局部变量表中索引为1的Slot,也就是赋值给变量a。如图1.11所示。
图1.11 istore指令执行过程
3. iinc这条字节码指令比较特别,它可以直接操作局部变量表的变量,而不需要经过操作数栈。该指令是将局部变量表中索引为1的Slot所存储的整数值自增1,也就是将局部变量a自增1。如图1.12所示。
图1.12 iinc指令执行过程
4. iload_1指令是将自增后的变量a放入操作数栈的栈顶,如图1.13所示。
图1.13 iload_1指令执行过程
5. 最后,istore_2指令是将当前操作数栈顶的元素从操作数栈弹出,并存放到局部变量表中索引为2的Slot,也就是给result变量赋值。如图1.14所示。
图1.14 istore_2指令执行过程
从++i的例子中,我们可以看出,字节码是依赖操作数栈工作的。在虚拟机上执行的字节码指令虽然最终也是编译为机器码执行,但编写字节码指令时并不需要我们考虑使用哪些寄存器的问题,这些交由JVM去实现。
使用汇编指令编写代码,我们需要考虑CPU的架构,有多少个寄存器可选,了解硬件,需要关心每条指令操作多少个字节,在使用寄存器之前需要考虑是否要备份寄存器的当前值,指令执行完之后是否需要恢复寄存器的值。而使用依赖栈工作的字节码指令编写代码,我们只需要关心每条字节码指令需要多少个参数,按顺序将参数push到操作数栈顶。如果指令执行完有返回值,操作数栈顶就是返回值。
注释:
[1] cpu架构是cpu厂商给属于同一系列的cpu产品定的一个规范
[2] 王爽.《汇编语言》