参考资料
https://source.android.com/devices/tech/dalvik/dalvik-bytecode.html
https://source.android.com/devices/tech/dalvik/instruction-formats.html
http://www.milk.com/kodebase/dalvik-docs-mirror/docs/dalvik-bytecode.html
什么是Dalvik字节码?
dalvik 的虚拟机指令代码。dalvik字节码之于dalvik VM,就像可执行程序的机器码之于CPU一样。
dalvik 字节码是一种基于寄存器的虚拟机指令系统。它的含义是,每一个可执行函数都有一个Frame,Frame的大小是固定,包括固定数目大小的虚拟寄存器。
dalvik VM 会为这个函数的参数、本地变量、临时变量都分配一个虚拟寄存器。所有的运算都是在寄存器之间完成的。
当然,我说的,变量与寄存器不是一对一的关系,而是多对一的关系。因为变量的生命周期并不完全一样,有些变量之间生命周期不重合,就会出现共用一个寄存器的情况。
在下面的描述中,我们用v0, v1, ... vN 表示寄存器,一共有N+1寄存器。
除了常用的寄存器外,还有两个隐含的寄存器:result和exception。当函数返回时,返回值被写入到result寄存器,而抛出异常时,异常对象会被放入exception寄存器。
在dalvik的实现中,这两个寄存器都是放在Thread对象中的。一个Thread对象代表一个java线程,一个java线程同时只需要一个result和exception寄存器。
dalvik字节码指令分成几大部分:
- const 指令:负责将常量值放入到虚拟寄存器中;
- mov指令:在虚拟寄存器之间拷贝数据;
- 比较指令:比较两个寄存器的值,有部分指令是直接与0比较的;
- 分支指令:包括if系列指令如if-eq, if-ge等等,以及switch指令,goto指令。switch指令作为一个单独指令存在的;
- invoke指令:函数调用指令,分成invoke-direct, invoke-static, invoke-native, invoke-virtual和invoke-interface几大系列;
- field操作指令:包括put/get,针对静态域和非静态域;
- class相关的指令:比如new-instance, instance-of,checkcast等等
- 异常处理指令:包括throw, move-exception这样的指令;
- 算术、位移等指令:不多说了;
- 其他指令
字节码的格式?
dalvik字节码是以一个字(两字节)为单位,必须是一个字的整数倍。所以,你看到dexdump出的字节指令,其长度都必须x2才能得到其字节长度。
不同指令的字节码长度不一样,相同操作类型的指令长度是一样的,根据指令操作类型,就可以确定指令的长度。比如 mov v1, v2,指令长度是1字。
字节码在dex文件中存储时,都是小端的。因此,第一个字节必须是操作类型(操作符),是一个0~255范围内的一个数字,根据操作符,我们可以确定后续有多少个字以及表示什么意思。
每个操作符都有一个格式(format),根据格式,我们可以知道该指令的长度,参数有哪些、每个参数的长度、参数是如何存储的,这些重要的信息。不同的指令可以有相同的格式,因此,dalvik将他们分类,并给每个格式以不同的描述。每种格式被称为Format ID。
下表详细说明了dalvik字节码的格式(来自https://source.android.com/devices/tech/dalvik/instruction-formats.html)。这个表分为4列。其中前3列分别是Format, ID, Syntax。
Format列
"op" 表示操作符,8位1字节无符号整数。大写字母"A","B","C"等表示4位数;"?" 表示空,占4位,必须为0;
每个部分用‘|‘分割,表示一个逻辑上独立的部分。
排列最小单位是字(两字节),中间不分割。例如B|A|op,这个长度是1个字(两字节),但是包括3部分,op操作符和
A, B两个参数; 而AA|opBBBB
包含两个字(4字节),其中"AA|op"是第一个字,而"BBBB"则是在第二字。
因为在实际存储时,是以小端方式,而在描述时,则以大端方式。阅读时要注意。比如 ??|op
, 在实际存储时,“op”在第0字节,而“??”在第1字节。在如
AA|opBBBB
则是 op在第0字节,AA在第1字节,BBBB 占用第2,3字节。
ID列
ID由3部分组成: <长度><寄存器个数><类型描述>。 例如 "10x" "1"表示指令长度为1, "0"表示用到0个寄存器,"x"表示类型描述; “21t“ 表示指令长度为2,用到1个寄存器,并需要跳转("t"表示分支跳转)。
最后类型描述部分各个字符表示的意思如下表:
Mnemonic | Bit Sizes | Meaning |
---|---|---|
b | 8 | 有符号1字节立即数 |
c | 16, 32 | 常量池索引 |
f | 16 | interface相关的常量 |
h | 16 | 16位有符号立即数,表示32/64位中的最高16位 |
i | 32 | 32位有符号立即数,或者32位的浮点数 |
l | 64 | 64位有符号立即数,或者双精度浮点数 |
m | 16 | method的常量索引值 |
n | 4 | 4位有符号立即数 |
s | 16 | 16位有符号立即数 |
t | 8, 16, 32 | 分支目标偏移值 |
x | 0 | 无更多附加数 |
Syntax列
字节码可以反汇编,形成类似汇编代码的助记符形式,方便人类阅读。一般形式是 op Arg1[, Arg2, [,Arg3 ...]] 这种形式。
你在Format列看到的字符会反映到Syntax列。例如,如果在Foramt列看到了"AA",那么在Syntax列看到的“AA”所指的是同一个概念。
在Syntax,会给它加不同前缀,形成不同的格式,代表不同含义:
- "vX": 前缀"v"表示这是一个虚拟寄存器,"X"对应Foramt列中的参数,"X"的值就是虚拟寄存器的索引;
- "#+X":前缀“#+”表示一个有符号的立即数;
- "+X": 前缀“+"表示一个相对偏移地址;
- "[email protected]" :前缀"kind" 表示类型(只能取"type","meth", "field", "string",分别表示解析表中class,method,feild,string的索引),“X”是索引值
- 另外[X=N]这样的格式出现在某条指令之前,表示X满足N时有效。例如[A=2] 表示A的值为2时。
Format | ID | Syntax | Notable Opcodes Covered |
---|---|---|---|
N/A | 00x |
N/A |
针对一些不使用的指令 |
??|op | 10x |
op |
|
B|A|op | 12x |
op vA, vB |
|
11n |
op vA, #+B |
||
AA|op | 11x |
op vAA |
|
10t |
op +AA |
goto | |
??|opAAAA | 20t |
op +AAAA |
goto/16 |
AA|opBBBB | 20bc |
op AA, [email protected] |
目前没有该格式的指令 |
AA|opBBBB | 22x |
op vAA, vBBBB |
|
21t |
op vAA, +BBBB |
||
21s |
op vAA, #+BBBB |
||
21h |
op vAA, #+BBBB0000
|
||
21c |
op vAA, [email protected]
|
check-cast
const-class const-string |
|
AA|opCC|BB | 23x |
op vAA, vBB, vCC |
|
22b |
op vAA, vBB, #+CC |
||
B|A|opCCCC | 22t |
op vA, vB, +CCCC |
|
22s |
op vA, vB, #+CCCC |
||
22c |
op vA, vB, [email protected]
|
instance-of | |
22cs |
op vA, vB, [email protected] |
suggested format for statically linked field access instructions of format 22c | |
??|opAAAAloAAAAhi | 30t |
op +AAAAAAAA |
goto/32 |
??|opAAAA BBBB | 32x |
op vAAAA, vBBBB |
|
AA|opBBBBloBBBBhi | 31i |
op vAA, #+BBBBBBBB |
|
31t |
op vAA, +BBBBBBBB |
||
31c |
op vAA, [email protected] |
const-string/jumbo | |
A|G|opBBBB F|E|D|C | 35c |
[A=5 ] op {vC, vD, vE, vF, vG}, [email protected]
[ [ [ [ [ [ The unusual choice in lettering here reflects a desire to make the count and the reference index have the same label as in format 3rc. |
|
35ms |
[A=5 ] op {vC, vD, vE, vF, vG}, [email protected]
[ [ [ [ The unusual choice in lettering here reflects a desire to make the count and the reference index have the same label as in format 3rms. |
suggested format for statically linked invoke-virtual and invoke-super instructionsof format 35c |
|
35mi |
[A=5 ] op {vC, vD, vE, vF, vG}, [email protected]
[ [ [ [ The unusual choice in lettering here reflects a desire to make the count and the reference index have the same label as in format 3rmi. |
suggested format for inline linked invoke-static and invoke-virtual instructionsof format 35c |
|
AA|opBBBB CCCC | 3rc |
op {vCCCC .. vNNNN}, [email protected]
where |
|
3rms |
op {vCCCC .. vNNNN}, [email protected]
where |
suggested format for statically linked invoke-virtual and invoke-super instructionsof format 3rc |
|
3rmi |
op {vCCCC .. vNNNN}, [email protected]
where |
suggested format for inline linked invoke-static and invoke-virtual instructionsof format 3rc |
|
AA|opBBBBloBBBB BBBB BBBBhi | 51l |
op vAA, #+BBBBBBBBBBBBBBBB |
const-wide |
常量池与常量解析表是什么?
java的很多操作,都需要类、方法、域和字符串。这些反映在字节码中,都是以字符串的形式存在的。
所有的类名、方法名、域名、域和方法的签名已经常量字符串全部存储在常量池中。常量池是一个字符串池,包含所有字符串。
同时为了访问这些常量字符串,必须通过一些解析表来进行映射。这些解析表包括type表--保存class名字的索引;field表,包括field所属类的解析表索引、field名字的索引、field签名的索引;method表,包括method所属类的索引、method名字的索引、签名的列表;string表保存的则是常量池中的索引。
用下面的图表示的更加清楚:
图中的每一项在Dex文件中都是以表的方式存在的。
这些表对程序的运行至关重要。如果指令中含有“[email protected]”这种格式的参数时,那么就要从StringId表中取出一个StringId项,然后通过其offset,从常量池中得到对应的字符串;对于"[email protected]"这种格式,就是从MethodId的表中得到一个MethodId项,从而读取到各项信息。
但是要注意一点,上图描绘的是dex文件中存在的关系,不是运行时的关系。当我们执行一条指令,比如 "const/string vA, [email protected]", 寄存器vA中保存的不是“BBBB”的值,也不是从常量池中得到的字符串地址,而是一个和该字符串对应的java.lang.String对象;同理,const/class vA, [email protected]这样的指令,vA保存的是BBBB所描述的class的java.lang.Class对象。但是 invoke指令和iput,iget等field指令,在运行时并不取得对应的java.reflect.Method和java.reflect.Field对象,而是Dalvik内部的Method和Field对象。这些内部对象只能在dalvik内部使用,不会反映在java层的代码中,java代码也无法使用。
之所以出现这些差别,是因为string,class这种对象,是必须能够让java层代码访问的,而field和method对java代码是透明的。
Dalvik为了应对这种差别,都会做一个与这些表大小一样,索引一致的cache表,这个cache表内放置了已经解析好的对象,需要的时候直接从cache中取,不需要这样来回查找了。
当然,这些表内防止的是所有java代码能够访问到的类、field、method和字符串。比如,你要访问一个android.content.Content类及其方法,那么他们的名字就会放在表内。
和类、对象操作相关的指令有哪些?
对于java语言中一些特有的表达式,它们对应的指令关系做一个深入的探讨。
new 语句对应的指令
new语句对应的是new-instance指令和一个invoke-direct指令。new-instance负责创建一个新的对象,而invoke-direct指令则是用于调用构造函数。
不是所有的new语句都会产生invoke-direct指令,如果new的时候调用的构造函数没有参数,而且被new的类也没有定义不带参数的构造函数时,它就不会产生invoke-direct指令了。
instance of语句对应的指令
对应instance-of指令
check cast指令
这种情况只发生在down cast的时候。如果你将一个父类类型转换为子类类型,就会默认生成一条check-cast指令。
<Class>.class语句
当我们要取一个类的class时候,就会产生一条 const/class指令。例如
const-class v1, Ljava/lang/Integer;
Field访问指令
Field访问指令包括get和put。Field分为static和instance两种类型。因此,对static的访问,都是sget/sput开头,而对instance类型的访问,都是iget/iput。
根据数据类型的不同,以iget为例,有iget, iget-object, iget-wide, iget-boolean, iget-char, iget-short, iget-byte。虽然有这么多种类型,但是在dalvik的实现中,其实只有iget和iget-wide两种区别。除了iget-wide之外的其他iget指令,都是以4字节为单位,占用一个虚拟寄存器;而iget-wide则是8字节为单位,占用两个寄存器。
例如 iget vA, vB, [email protected]。表示将vA内容放到vB的CCCCfield中,即便是一个char类型的,也是4字节操作,即vB的数据是4字节长度,Field在object中占用的空间也是4字节长度;而对于iget-wide,则是使用连着的两个寄存器存储数据。按照小端原则。
因为这个缘故,在做odex优化时,iget指令被优化成iget-quick/iget-object-quick和iget-wide-quick。因为object比较特殊,所以将iget-quick分化出一个iget-object-quick来。quick优化后面章节有专门描述。
如何调用和返回函数
在dalvik中,调用函数是通过invoke指令来的。invoke指令按照参数多少,可以分为invoke和invoke/range两种类型。dalvik所有的参数都是通过虚拟寄存器传递的。当参数少时,直接用invoke,这种情况下,将所有用来传递参数的虚拟寄存器都放到指令中取;而参数较多时,就需要用到range方式,这种方式下,invoke指令中包含了参数个数和第一个参数用到的寄存器的标号。
按照调用函数的类型不同,分为:
- invoke-direct
- invoke-virtual
- invoke-super
- invoke-interface
- invoke-static
其中,direct类的函数,包括除了static外的非虚函数,例如构造函数、prive函数等。static函数是按照direct函数对待的。那么,native函数是否也是direct函数呢?其实,这是按照实现方法来区分一个函数是native还是非native的,native方法也可以是 virtual,甚至是可以实现interface的一个方法,所以,native方法的调用,可能是上面任意一种指令。对native方法的调用与普通方法是一样的,所不同的是调用时,函数入口所有区别而已; 那么final方法是否也是direct呢?实际上final方法只是为了防止进一步继承,而不能确定它一定就是direct的,即不可能存在继承和覆盖的情况。所以fianl方法是按照virtual方法来对待的。final方法与普通方法的不同不是体现在字节码上,而是体现在类和函数的定义上,dalvik虚拟机会在加载类的时候进行验证(verify),其中一项是检查当前类的父类是否是final的,或者是否包含final方法。
invoke的指令编码是:
6e..72 35c |
invoke-kind {vC, vD, vE, vF, vG}, [email protected]
6e: invoke-virtual 6f: invoke-super 70: invoke-direct 71: invoke-static 72: invoke-interface |
A: argument word count (4 bits)
|
invoke的返回值是放在result寄存器中的。要取得结果需要用move-result vN的方法来取得。 |
74..78 3rc |
invoke-kind/range {vCCCC .. vNNNN}, [email protected]
74: invoke-virtual/range 75: invoke-super/range 76: invoke-direct/range 77: invoke-static/range 78: invoke-interface/range |
A: argument word count (8 bits)
|
Call the indicated method. See firstinvoke-kind description above for details, caveats, and suggestions. |
在参数传递上,对于非static函数,第一个参数是this对象。
switch相关指令是怎么回事?
switch有两种指令类型:packed-switch和spare-switch。这些是指令和数据放在一起的,case分支信息是放在switch指令指定的payload数据中。
指令的数据是:
2b 31t |
packed-switch vAA, +BBBBBBBB (with supplemental data as specified below in "packed-switch-payload Format") |
A: register to test
|
B所指出的数据就是 switch的payload数据,记录了case分支信息。如果没有对应的case分支,就会走到下条指令来执行。
下条指令是default分支的数据 |
2c 31t |
sparse-switch vAA, +BBBBBBBB (with supplemental data as specified below in "sparse-switch-payload Format") |
A: register to test
|
同上 |
那么payload的数据格式是什么呢?
Type | packed-switch | spare-switch |
ushort | 0x0100 | 0x0200 |
ushort | size | size |
int first_key
int[] targets |
int[] keys
int[] targets |
packed-switch是一种压缩的switch,它给出一个first_key,用vAA-first_key得到的索引,然后从targets中取得偏移,这个偏移是相对于switch的指令的。用伪代码就是
int idx = value_of(vAA) - first_key; if (idx >= size) target_pc = current_pc + PACKED_SWITCH_SIZE; else target_pc = current_pc + targets[idx];
注意一点: targets中的数据偏移是以16位(2字节)为偏移单位的。
那么,同理,spare-switch有keys和targets两个数组,需要先和keys比较,得到对应的索引,然后再找targets中对应的偏移。用伪代码表示就是
int idx = find_key(keys, value_of(vAA)); if (idx >= size) target_pc = current_pc + PACKED_SWITCH_SIZE; else target_pc = current_pc + targets[idx];
quick类型的指令有那些,起到什么作用?
dalvik有一项优化,即将dex文件转换为odex文件。那么,odex与dex有什么区别呢?在文件结构上,odex文件会创建一个hash表,以类名为key,帮助系统快速找到class,从而快速加载;在字节码上,引入了一些quick指令,这些quick指令,能够加快执行速度。
quick指令是针对iget/iput,以及invoke-virtual的。iget/iput指令总共被分成3种:iget/iput-quick, iget/iput-object-quick, iget/iput-wide-quick。它们主要是类型与长度不同(见上一节),实际原理都是一样的。iget/iput的原理,是将原iget/iput中记录field在解析表中的索引,直接换成了这个field在对象中的偏移。虚拟机只要用this对象+偏移就可以获取到field在object对象中的位置,从而进行读写。
另外一个invoke-virtual-quick和invoke-virtual-quick/range,同样的,它将invoke-virtual指令内包含的method在解析表内的索引,换成了对应的虚函数在对象的虚函数表中的偏移,通过 this->class->vtable + offset的方式取得method指针,然后进行调用。
除了这些外,还有一个execute-inline指令。这个指令其实是将很多非常基础的类的函数换成dalvik内部指针。比如String类的很多函数,如charAt等,换成dalvik内部用c语言编写的 java_lang_String_charAt函数,在运行时,直接调用内部函数,速度会提升很多。execute-inline会将对应的内部函数的索引放在指令里面。
字节码的程序结构如何?
在dex文件内部,一个方法的代码数据由2大部分组成:代码数据和Try-catch数据块。代码数据包括:代码和payload数据。每个块之间都必须4字节对齐
用下面的表表示:
代码数据 |
align pending (如果不是4字节对齐,增加一个0x0000的pending) |
payload数据(switch, fill-array-data指令的数据,如果有) |
align pending (如果不是4字节对齐,增加一个0x0000的pending) |
try-catch数据数组 |
代码数据部分都是可执行的代码。与我们通常看到的代码不同,davlik字节码使用了大量的goto指令,用于实现各种分支和循环。
try-catch数据结构是什么?
try-catch数据结构,是放在代码的后面。在dalvik用CodeItem这个结构来保存code数据,其中的insns_size_in_code_units_是以2字节为单位保存从Method代码开始位置到TryItem数组的偏移。
try catch是用TryItem数组保存的,一个TryItem的结构如下
struct TryItem { uint32_t start_addr_; uint16_t insn_count_; uint16_t handler_off_; private: DISALLOW_COPY_AND_ASSIGN(TryItem); };
start_addr_是try块开始位置,相对于代码开始位置的偏移
insn_count_记录try块涉及的代码指令数;
handler_off_ 是catch块的数据,这个偏移是相对与代码开始位置。它的数据是经过LEB128编码的,数据可以被解析成一个CatchHandlerItem结构
CatchHandlerItem结构定义如下
struct CatchHandlerItem { uint16_t type_idx_; // type index of the caught exception type uint32_t address_; // handler address } handler_;
type_idx_ 是exception type的索引,同样是type解析表的索引;
address_ 是处理代码的入口。
HandleItem可能不止一个,会有多个。
TryItem是一个数组,数组的长度可以0,1,... N个。当异常发出时,得到异常发出位置的dex pc,用这个dex pc去匹配TryItem中start_addr_和insn_count_,看dex pc落在哪个TryItem的区间内。如果找到了TryItem块,就找它下面的CatchHandleItem,去匹配抛出的exception类型,与CatchHandlerItem定义的类型是否一致,如果一致就找到了对应的处理,如果不一致,则继续查找。
如果本函数内已经没有可以处理的Try-catch了,就向上找调用者函数,依次取查找。
当然,我们知道,java的异常,是 try-catch-finally结构,所以,如果有 finally的话,它是最后一个CatchHandleItem了。
一个TryItem的CatchHandlerItem信息结构,可以用下表来描述(数据都是经过LEB128加密的)
剩余handler_catch 个数(int型) |
catch的exception type index |
catch块的addr偏移 |
剩余handler_catch 个数(int型) |
...... |
剩余handler_catch (<0 表示还有一个final块, 0表示没有了) |
finally块的addr偏移(如果<0) |
(因为个人水平限制和时间限制,内容很乱,没有很好组织,请读者原谅,我力图将dalvik的比较全面的知识告诉大家,如果有什么不正确的,请指出。谢谢)