Bytecode Dalvik
1. Qué es y por qué existe
El bytecode Dalvik es el juego de instrucciones que vive dentro del array insns de cada
code_item de un fichero DEX (Formato DEX, sección 11). Es lo que
ART interpreta o compila con dex2oat, y lo que un decompilador tiene que remontar hasta
Java.
Su rasgo definitorio es que está basado en registros y no en pila. La JVM es una máquina de pila: cada operación saca operandos de una pila y empuja el resultado. Dalvik da a cada método un marco de registros de tamaño fijo, decidido en tiempo de compilación, y cada instrucción nombra explícitamente los registros que lee y el que escribe. La motivación es la de siempre en 2008: menos instrucciones que despachar significa menos ciclos por operación en un intérprete, que era la única forma de ejecutar código en los primeros teléfonos.
La diferencia se ve mejor en el mismo código. Este método:
public static int f(int a, int b) { return a + b * 2; }
compilado a .class y volcado con javap -c, y luego pasado por d8 y volcado con
dexdump -d:
JVM (pila, 6 instrucciones) Dalvik (registros, 3 instrucciones)
0: iload_0 0000: mul-int/lit8 v1, v1, #int 2
1: iload_1 0002: add-int/2addr v0, v1
2: iconst_2 0003: return v0
3: imul
4: iadd
5: ireturn
Tres cosas que esto le cambia a quien decompila:
- No hay pila que reconstruir, pero sí un grafo de definiciones y usos. El decompilador no tiene que simular una pila; tiene que averiguar qué valor lógico vive en cada registro en cada punto, lo que en la práctica significa reconstruir una forma SSA.
- Los registros se reutilizan.
v1puede ser uninten una parte del método y una referencia a un objeto en otra. Sindebug_info_itemno hay nombres ni tipos declarados (Formato DEX, sección 12), y el tipo de cada registro se infiere del uso, exactamente como hace el verificador deART(sección 9). - Las constantes literales pequeñas se funden en la instrucción.
iconst_2+imulse convierten en un solomul-int/lit8. La estructura del código fuente se pierde antes de lo que se pierde en la JVM.
El diseño se declara «pensado para imitar de forma aproximada arquitecturas reales y convenciones de llamada al estilo de C», y de ahí salen las reglas del modelo de registros.
2. Reglas generales del modelo de ejecución
- La unidad de almacenamiento del flujo de instrucciones es una cantidad de 16 bits sin signo. Toda instrucción ocupa un número entero de esas unidades: 1, 2, 3 o 5.
- Los registros son de 32 bits cuando llevan valores de bits. Los de 64 bits usan pares
de registros adyacentes, y no hay requisito de alineación para esos pares:
vNyvN+1valen aunqueNsea impar. - Cuando llevan referencias a objeto, un registro es lo bastante ancho para contener
exactamente una. En representación de bits,
(Object) null == (int) 0. - Los marcos son de tamaño fijo desde su creación:
registers_sizedelcode_item. - Hay pools de constantes separados e indexados por separado para cadenas, tipos, campos y métodos. Los datos literales de bits van en línea en el flujo de instrucciones.
- Las instrucciones no están limitadas a un tipo sin necesidad: las que mueven 32 bits sin
interpretarlos no distinguen
intdefloat. - El orden de los argumentos es destino primero, fuentes después.
- Existen pseudoinstrucciones que solo transportan datos de longitud variable
(
packed-switch-payload,sparse-switch-payload,fill-array-data-payload, sección 8). Nunca deben alcanzarse en el flujo normal de ejecución y deben estar alineadas a 4 bytes, para lo cual el generador inserta unnopde relleno si hiciera falta.
3. Modelo de registros
Un método dispone de registers_size registros, numerados v0 a v(registers_size − 1).
La regla que lo gobierna todo es:
Los N argumentos de un método caen en los últimos N registros del marco, en orden. Los argumentos anchos consumen dos registros. A los métodos de instancia se les pasa una referencia
thiscomo primer argumento.
De ahí los tres campos del code_item:
| Campo | Qué cuenta |
|---|---|
registers_size |
Total de registros del marco: locales más argumentos |
ins_size |
Palabras de argumento de entrada. Incluye el this implícito y cuenta 2 por cada argumento ancho |
outs_size |
Palabras de argumento de salida: el ancho del mayor invoke-* que aparece en el cuerpo. Es 0 si el método no invoca a nadie |
Los registros de locales son entonces v0 … v(registers_size − ins_size − 1), y los de
argumentos, los ins_size últimos.
La sintaxis smali da a esos últimos un segundo nombre: p0, p1, … pN, donde p0
es el primer argumento —el this en un método de instancia— y así sucesivamente. Es un
alias, no otro registro:
método LMyObject;->callMe(II)V con .registers 5
v0 ────────── primera local
v1 ────────── segunda local
v2 ═══ p0 ═══ referencia this
v3 ═══ p1 ═══ primer parámetro int
v4 ═══ p2 ═══ segundo parámetro int
Las dos directivas de smali que declaran el tamaño del marco cuentan cosas distintas:
.registers da el total y .locals da solo los no parámetros. Confundirlas es el
error clásico al editar smali a mano, y produce un code_item con registers_size
insuficiente que la verificación de ART rechaza (sección 9).
3.1 Verificado sobre el corpus
com.termux.app.TermuxApplication.setLogLevel:()V es un método de instancia sin
parámetros, y dexdump da registers: 4, ins: 1, outs: 3. Es decir: v0, v1 y v2
son locales; v3 es el único argumento de entrada, o sea p0, o sea this. Y en efecto la
primera instrucción del cuerpo es invoke-virtual {v3}, …getApplicationContext().
androidx.core.graphics.TypefaceCompatUtil.closeQuietly:(Ljava/io/Closeable;)V es
estático con un parámetro: registers: 1, ins: 1, outs: 1. No hay locales; v0 es
el único registro y es el parámetro, p0.
Sum.f(II)I, estático con dos parámetros, da registers: 2, ins: 2, outs: 0. v0 = a
= p0, v1 = b = p1, y outs vale 0 porque el método no invoca a nadie.
3.2 Por qué existen las variantes /from16, /16 y /range
Como en la práctica es raro que un método necesite más de 16 registros, y bastante común que
necesite más de ocho, muchas instrucciones solo pueden direccionar los 16 primeros
registros (campos de 4 bits). Cuando es razonable, el formato admite hasta 256 (campos de
8 bits). Y unas pocas variantes llegan al rango completo v0–v65535. Si una operación
necesita un registro alto y su instrucción no lo alcanza, el compilador emite un move de
copia antes o después:
0x1865e0: 6e10 f000 0300 invoke-virtual {v3}, … ← 35c: registro de 4 bits
0x08ceca: 0509 1400 move-wide/from16 v9, v20 ← 22x: destino 8 bits, origen 16
4. Formatos de instrucción
4.1 Cómo se lee el identificador
Un identificador de formato tiene tres caracteres —dos dígitos y una letra— y se lee así:
- Primer dígito: número de unidades de 16 bits que ocupa la instrucción.
- Segundo dígito: número máximo de registros que el formato contiene. La letra
ren su lugar significa que se codifica un rango de registros. - Letra final: de forma semimnemónica, el tipo de dato extra que lleva.
Así, 21t es de dos unidades, contiene una referencia a registro y lleva además un destino
de salto. Los identificadores de cuatro caracteres que acaban en s o i son formatos
sugeridos de enlazado estático o inline que el runtime puede generar al instalar; no
aparecen en un DEX distribuido, y su implementación no es obligatoria.
| Letra | Tamaño en bits | Significado |
|---|---|---|
b |
8 | Byte inmediato con signo |
c |
16, 32 | Índice de pool de constantes |
f |
16 | Constantes de interfaz (solo en formatos enlazados estáticamente) |
h |
16 | «Hat» inmediato con signo: los bits altos de un valor de 32 o 64 bits, con los bajos a 0 |
i |
32 | Entero inmediato con signo, o float de 32 bits |
l |
64 | long inmediato con signo, o double de 64 bits |
m |
16 | Constantes de método (solo en formatos enlazados estáticamente) |
n |
4 | Nibble inmediato con signo |
s |
16 | short inmediato con signo |
t |
8, 16, 32 | Destino de salto |
x |
0 | Sin datos adicionales |
En la sintaxis, vX es un registro, #+X un literal, +X un desplazamiento relativo de
dirección y kind@X un índice de pool, donde kind es string, type, field, meth,
proto, method_handle o site.
4.2 Layout de bits
Cada «palabra» separada por espacios es una unidad de 16 bits. Cada carácter representa
cuatro bits, leídos de los altos a los bajos; op marca el opcode de ocho bits y ØØ un
nibble que debe valer cero. Las letras nombran los campos.
layout id sintaxis
─────────────────────────────────────────────────────────────────────────────
(ninguno) 00x pseudoformato de los opcodes sin usar
ØØ|op 10x op
B|A|op 12x op vA, vB
B|A|op 11n op vA, #+B
AA|op 11x op vAA
AA|op 10t op +AA
ØØ|op AAAA 20t op +AAAA
AA|op BBBB 22x op vAA, vBBBB
AA|op BBBB 21t op vAA, +BBBB
AA|op BBBB 21s op vAA, #+BBBB
AA|op BBBB 21h op vAA, #+BBBB0000 | #+BBBB000000000000
AA|op BBBB 21c op vAA, type@BBBB · field@BBBB · string@BBBB
· proto@BBBB · method_handle@BBBB
AA|op CC|BB 23x op vAA, vBB, vCC
AA|op CC|BB 22b op vAA, vBB, #+CC
B|A|op CCCC 22t op vA, vB, +CCCC
B|A|op CCCC 22s op vA, vB, #+CCCC
B|A|op CCCC 22c op vA, vB, type@CCCC · field@CCCC
ØØ|op AAAAlo AAAAhi 30t op +AAAAAAAA
ØØ|op AAAA BBBB 32x op vAAAA, vBBBB
AA|op BBBBlo BBBBhi 31i op vAA, #+BBBBBBBB
AA|op BBBBlo BBBBhi 31t op vAA, +BBBBBBBB
AA|op BBBBlo BBBBhi 31c op vAA, string@BBBBBBBB
A|G|op BBBB F|E|D|C 35c [A=5] op {vC,vD,vE,vF,vG}, meth@BBBB · site@BBBB
· type@BBBB; A=0..5 fija cuántos se usan
AA|op BBBB CCCC 3rc op {vCCCC .. vNNNN}, meth@BBBB
NNNN = CCCC+AA-1; AA es la cuenta (0..255)
A|G|op BBBB F|E|D|C HHHH 45cc op {vC..vG}, meth@BBBB, proto@HHHH
AA|op BBBB CCCC HHHH 4rcc op {vCCCC..vNNNN}, meth@BBBB, proto@HHHH
AA|op BBBBlo BBBB BBBB BBBBhi 51l op vAA, #+BBBBBBBBBBBBBBBB
El resto de identificadores que aparecen en la especificación —20bc, 22cs, 35ms,
35mi, 3rms, 3rmi— son los formatos sugeridos de enlazado que se mencionan en 4.1.
La rareza de 35c merece explicación: las letras no van en orden de bits. A es la
cuenta de palabras de argumento y está en el nibble alto de la primera unidad; G es el
quinto registro y está en el nibble bajo; y C, D, E y F van en la tercera unidad, de
los bits bajos a los altos. Se hizo así para que la cuenta y el índice de referencia lleven
la misma letra que en 3rc.
4.3 Cómo se empaquetan, con instrucciones reales del corpus
11x 0f00 → return v0
byte0 = 0f = opcode byte1 = 00 = AA = 0 → vAA = v0
11n 1201 → const/4 v1, #int 0
byte0 = 12 = opcode byte1 = 01: A = nibble bajo = 1 → vA = v1
B = nibble alto = 0 → #+0
12x b010 → add-int/2addr v0, v1
byte0 = b0 = opcode byte1 = 10: A = 0 → v0 B = 1 → v1
21t 3900 0300 → if-nez v0, +0003
unidad0 = 0x0039: op = 39, AA = 00 → v0
unidad1 = 0x0003: BBBB = +3
21c 1f01 ea01 → check-cast v1, type@01ea
unidad0 = 0x011f: op = 1f, AA = 01 → v1
unidad1 = 0x01ea: BBBB = índice 0x01ea en type_ids
22b da01 0102 → mul-int/lit8 v1, v1, #int 2
unidad0 = 0x01da: op = da, AA = 01 → vAA = v1
unidad1 = 0x0201: BB = byte bajo = 01 → vBB = v1
CC = byte alto = 02 → #+2
22c 5b01 941f → iput-object v1, v0, field@1f94
unidad0 = 0x015b: op = 5b, byte1 = 01: A = 1 → v1, B = 0 → v0
unidad1 = 0x1f94: CCCC = índice 0x1f94 en field_ids
35c 6e30 0b4b 1002 → invoke-virtual {v0, v1, v2}, meth@4b0b
unidad0 = 0x306e: op = 6e, A = 3 (tres palabras), G = 0
unidad1 = 0x4b0b: BBBB = meth@4b0b
unidad2 = 0x0210: C = 0, D = 1, E = 2, F = 0 → se usan C, D y E
3rc 7406 210f 0100 → invoke-virtual/range {v1 .. v6}, meth@0f21
unidad0 = 0x0674: op = 74, AA = 06 → seis registros
unidad1 = 0x0f21: BBBB = meth@0f21
unidad2 = 0x0001: CCCC = 1 → v1 .. v(1+6-1) = v6
51l 1801 00da 626d dc00 0000 → const-wide v1, #000000dc6d62da00
unidad0 = 0x0118: op = 18, AA = 01 → v1
unidades 1..4, de la baja a la alta: 0xda00 0x6d62 0x00dc 0x0000
5. Sufijos de las instrucciones
Hay dos clases de sufijo y se distinguen por el separador.
Sufijos de tipo, unidos con guion. Marcan sobre qué opera la instrucción:
| Sufijo | Significado |
|---|---|
| (ninguno) | Opcode genérico de 32 bits: no distingue int de float |
-wide |
Opcode genérico de 64 bits: usa pares de registros |
-object |
Referencia a objeto |
-boolean, -byte, -char, -short, -int, -long, -float, -double |
El tipo concreto |
-string, -class, -void |
El tipo concreto, en las instrucciones donde aplica |
Que move y move-object sean instrucciones distintas aunque muevan los mismos 32 bits no
es redundancia: el verificador necesita saber si el registro destino pasa a contener una
referencia, y el recolector de basura necesita saber qué registros escanear.
Sufijos de variante, unidos con barra. No cambian la semántica, cambian la codificación:
/from16 y /16 amplían el ancho de una referencia a registro, /32 y /jumbo amplían el
de un literal o un índice, /range cambia una lista de registros por un rango, /lit8 y
/lit16 fijan el ancho de una constante inmediata, y /2addr indica que el destino es
también el primer operando fuente. Existen «sobre todo para que haya una correspondencia uno
a uno con las constantes estáticas del código que genera e interpreta ejecutables».
6. La tabla de opcodes
Los 256 valores del opcode, agrupados por familia. (sin usar) marca los rangos reservados.
| Opcodes | Formato | Mnemónicos | Qué hace la familia |
|---|---|---|---|
00 |
10x |
nop |
Gasta ciclos. También etiqueta las pseudoinstrucciones de datos (sección 8) |
01–09 |
12x, 22x, 32x |
move, move/from16, move/16, move-wide, move-wide/from16, move-wide/16, move-object, move-object/from16, move-object/16 |
Copia entre registros; tres anchos de referencia a registro por cada tipo |
0a–0d |
11x |
move-result, move-result-wide, move-result-object, move-exception |
Recogen el resultado del invoke-* o del filled-new-array inmediatamente anterior, o la excepción capturada. Solo son válidos en esa posición exacta |
0e–11 |
10x, 11x |
return-void, return, return-wide, return-object |
Retorno |
12–19 |
11n, 21s, 31i, 21h, 51l |
const/4, const/16, const, const/high16, const-wide/16, const-wide/32, const-wide, const-wide/high16 |
Constantes numéricas. /high16 carga los 16 bits altos y rellena el resto de ceros |
1a–1c |
21c, 31c |
const-string, const-string/jumbo, const-class |
Referencias a cadena y a clase. jumbo es el único índice de 32 bits del juego |
1d–1e |
11x |
monitor-enter, monitor-exit |
Bloqueo. Es como se implementa synchronized en un método no nativo |
1f–20 |
21c, 22c |
check-cast, instance-of |
Comprobación de tipo |
21, 23–26 |
12x, 22c, 35c, 3rc, 31t |
array-length, new-array, filled-new-array, filled-new-array/range, fill-array-data |
Arrays: longitud, creación y relleno |
22 |
21c |
new-instance |
Reserva la instancia; el constructor se llama aparte con invoke-direct |
27 |
11x |
throw |
Lanza la excepción del registro |
28–2a |
10t, 20t, 30t |
goto, goto/16, goto/32 |
Salto incondicional. El desplazamiento no puede ser 0 salvo en goto/32 |
2b–2c |
31t |
packed-switch, sparse-switch |
Salto por tabla; los datos van en un payload aparte (sección 8) |
2d–31 |
23x |
cmpl-float, cmpg-float, cmpl-double, cmpg-double, cmp-long |
Comparación de tres valores: 0, 1 o −1. El sesgo l/g fija qué devuelven ante NaN |
32–37 |
22t |
if-eq, if-ne, if-lt, if-ge, if-gt, if-le |
Salto condicional comparando dos registros |
38–3d |
21t |
if-eqz, if-nez, if-ltz, if-gez, if-gtz, if-lez |
Salto condicional comparando con cero |
3e–43 |
— | (sin usar) | |
44–51 |
23x |
aget, aget-wide, aget-object, aget-boolean, aget-byte, aget-char, aget-short, y los ocho aput-* correspondientes |
Lectura y escritura de elemento de array: valor, array, índice |
52–5f |
22c |
iget…iget-short, iput…iput-short |
Campo de instancia; el operando es field@CCCC |
60–6d |
21c |
sget…sget-short, sput…sput-short |
Campo estático; el operando es field@BBBB |
6e–72 |
35c |
invoke-virtual, invoke-super, invoke-direct, invoke-static, invoke-interface |
Invocación con lista de hasta cinco registros (sección 7) |
73 |
— | (sin usar) | |
74–78 |
3rc |
Los cinco invoke-*/range |
Invocación con rango de hasta 256 registros |
79–7a |
— | (sin usar) | |
7b–8f |
12x |
neg-int, not-int, neg-long, not-long, neg-float, neg-double, y las quince conversiones int-to-long … int-to-short |
Operaciones unarias y conversión de tipo |
90–af |
23x |
add/sub/mul/div/rem/and/or/xor/shl/shr/ushr-int, ídem -long, y add/sub/mul/div/rem -float y -double |
Binarias con tres registros |
b0–cf |
12x |
Las mismas con sufijo /2addr |
Binarias donde el destino es el primer operando |
d0–d7 |
22s |
add-int/lit16, rsub-int, mul-int/lit16, div-int/lit16, rem-int/lit16, and-int/lit16, or-int/lit16, xor-int/lit16 |
Binarias contra literal de 16 bits |
d8–e2 |
22b |
add-int/lit8 … xor-int/lit8, shl-int/lit8, shr-int/lit8, ushr-int/lit8 |
Binarias contra literal de 8 bits |
e3–f9 |
— | (sin usar) | |
fa–fd |
45cc, 4rcc, 35c, 3rc |
invoke-polymorphic, invoke-polymorphic/range, invoke-custom, invoke-custom/range |
Desde v038 (sección 7.2) |
fe–ff |
21c |
const-method-handle, const-method-type |
Desde v039 |
Dos detalles que se olvidan y producen bugs. shl-long, shr-long y ushr-long, así
como sus variantes /2addr, toman un par de registros para el valor a desplazar pero un
solo registro para la distancia de desplazamiento, al contrario que el resto de operaciones
-long. Y rsub-int no lleva sufijo porque es el opcode principal de su familia: resta
al revés, resultado = literal − registro.
7. Las instrucciones de invocación
7.1 Las cinco clásicas
Todas comparten el formato 35c, donde A es el número de palabras de argumento —no de
argumentos: uno ancho cuenta dos— y BBBB es el índice en method_ids. La variante
/range (3rc) cambia la lista de hasta cinco registros por un rango vCCCC … vNNNN con
NNNN = CCCC + AA − 1, lo que permite hasta 256 palabras. El resultado, si lo hay, se
recoge con un move-result* inmediatamente después.
| Instrucción | Cuándo se usa |
|---|---|
invoke-virtual |
Método virtual normal: ni estático, ni privado, ni constructor |
invoke-super |
La versión de la superclase más cercana, en vez de la del method_id de la clase llamante. Desde la versión 037 del DEX, si el method_id apunta a un método de interfaz, invoca la versión más específica no sobrescrita de esa interfaz —el mecanismo de los métodos default—. Antes de 037 un method_id de interfaz aquí era ilegal e indefinido |
invoke-direct |
Método directo no estático: instancia privada o constructor. Es el único que puede llamar a un método cuyo nombre empieza por < |
invoke-static |
Método estático, que siempre se considera directo |
invoke-interface |
Método de interfaz sobre un objeto cuya clase concreta no se conoce |
El par new-instance + invoke-direct <init> es la huella de un new de Java, y aparece
separado porque la reserva y la construcción son dos pasos distintos en el bytecode.
7.2 invoke-polymorphic e invoke-custom
invoke-polymorphic (fa, formato 45cc) y su /range (fb, 4rcc) invocan un
método polimórfico de firma, típicamente MethodHandle.invoke o MethodHandle.invokeExact.
Llevan dos índices: meth@BBBB al método e proto@HHHH al prototipo que describe los
tipos de los argumentos realmente pasados y el retorno esperado. El primer registro es el
receptor. Presentes desde la versión 038 del DEX.
invoke-custom (fc, formato 35c) y su /range (fd, 3rc) son el equivalente de
invokedynamic de la JVM. Su operando es call_site@BBBB, un índice en call_site_ids. Se
ejecuta en dos fases: resolución, en la que se comprueba si el call site ya tiene un
java.lang.invoke.CallSite asociado y, si no, se invoca el método bootstrap linker con los
argumentos que trae el call_site_item del DEX; e invocación, que se hace sobre el
MethodHandle objetivo del CallSite resuelto, como si fuera un invoke-polymorphic. Si el
bootstrap falla, devuelve null, o el MethodHandle no es del tipo pedido, se lanza
java.lang.BootstrapMethodError.
En un APK real esto es raro de ver: D8 desazucara las lambdas a clases sintéticas
por defecto, incluso con --min-api 26. En los dos DEX del corpus examinados a fondo
—com.termux_1002.apk y com.looker.droidify_710.apk— no hay ni una sola
invoke-custom. Se puede producir una a propósito:
BT=~/Library/Android/sdk/build-tools/37.0.0
# Demo.run() devuelve s.getAsInt() sobre un IntSupplier definido como lambda
$BT/d8 --min-api 26 --no-desugaring --output . Demo.class
$BT/dexdump -d classes.dex
0001a8: fc00 0000 0000 |0000: invoke-custom {}, call_site@0000
0001ae: 0c00 |0003: move-result-object v0
0001b0: 7210 0500 0000 |0004: invoke-interface {v0}, L…/IntSupplier;.getAsInt:()I
0001b6: 0a00 |0007: move-result v0
0001b8: 0f00 |0008: return v0
…
Method handle #0: type: invoke-static target: LDemo; lambda$run$0 target_type: ()I
Method handle #1: type: invoke-static target: LambdaMetafactory metafactory
Call site #0:
link_argument[0] : 1 (MethodHandle) ← el bootstrap: LambdaMetafactory.metafactory
link_argument[1] : getAsInt (String) ← el nombre a resolver
link_argument[2] : ()Ljava/util/function/IntSupplier; (MethodType)
link_argument[3] : ()I (MethodType)
link_argument[4] : 0 (MethodHandle) ← la implementación: Demo.lambda$run$0
link_argument[5] : ()I (MethodType)
La codificación de la instrucción es fc00 0000 0000: op = fc, A = 0 (cero argumentos),
BBBB = 0x0000 (call_site@0000) y la tercera unidad a cero porque no hay registros. Se ve
también que el desugaring de D8 es lo que hace desaparecer el call_site_ids de un
APK normal: sin --no-desugaring, el mismo fuente produce una clase sintética LDemo$0;
que implementa IntSupplier y un invoke-static corriente.
8. Los payloads
Tres pseudoinstrucciones transportan datos de longitud variable. Se identifican por una
primera unidad de 16 bits cuyo byte bajo es 00 —el opcode nop— y cuyo byte alto indica de
qué payload se trata. Deben estar alineadas a 4 bytes y nunca deben alcanzarse en el flujo
normal de ejecución; en la práctica los generadores las colocan al final del método.
packed-switch-payload sparse-switch-payload fill-array-data-payload
├── ident ushort = 0x0100 ├── ident ushort = 0x0200 ├── ident ushort = 0x0300
├── size ushort ├── size ushort ├── element_width ushort
├── first_key int ├── keys int[size] ├── size uint
└── targets int[size] └── targets int[size] └── data ubyte[]
unidades = (size * 2) + 4 unidades = (size * 4) + 2 unidades =
(size*element_width+1)/2+4
En packed-switch las claves son consecutivas desde first_key; en sparse-switch van
explícitas y ordenadas de menor a mayor. En ambos, los destinos son relativos a la
dirección del opcode del switch, no a la de la tabla, que es el error más común al
implementar el desensamblador.
Un packed-switch-payload real de androidx.appcompat.widget.DrawableUtils.parseTintMode,
en com.termux_1002.apk:
0a8110: 2b01 1600 0000 |000a: packed-switch v1, 00000020 // +00000016
…
0a813c: 0001 0300 0e00 0000 0a00 0000 0700 0000 0400 0000
ident=0x0100 size=3 first_key=14 targets = 10, 7, 4
Diez unidades de 16 bits, que es 3 × 2 + 4. Los destinos se suman a 0x000a, la dirección
del packed-switch: la clave 14 salta a 0x0014, la 15 a 0x0011 y la 16 a 0x000e, que
es exactamente donde el desensamblado coloca los sget-object de MULTIPLY, SCREEN y
ADD.
9. Los descriptores en las instrucciones
Los operandos type@, field@, meth@ y string@ son índices, no texto: la instrucción
solo lleva el número, y el desensamblador lo resuelve contra las tablas del DEX. Los
descriptores son los mismos que documenta Formato DEX, sección 7 —I
para int, [I para un array de int, Lcom/foo/Bar; para una clase—, y dexdump los
imprime ya resueltos detrás de un comentario con el índice original:
1f01 ea01 check-cast v1, Landroidx/activity/Cancellable; // type@01ea
5b01 941f iput-object v1, v0, L…$LocalBinder;.this$0:L…Service; // field@1f94
6e10 f000 0300 invoke-virtual {v3}, L…;.getApplicationContext:()L…; // method@00f0
1a00 2024 const-string v0, "Starting Application" // string@2420
Esa doble salida —forma legible más índice— es lo que hace de dexdump un buen oráculo: se
puede comprobar la resolución de índices de un parser propio comparando ambas columnas.
10. La verificación de bytecode de ART
Antes de ejecutar, ART verifica. La verificación ocurre al cargar la clase, no al
llegar a la instrucción problemática, y por eso un DEX editado a mano que no la supera
produce un fallo de carga —típicamente un VerifyError o un rechazo en la instalación— y no
un comportamiento erróneo en ejecución. Es una diferencia importante para diagnosticar: si
la aplicación arranca y el método falla al llamarlo, el problema no es de verificación.
El verificador trabaja en dos pasadas, tal como las describe el propio código de AOSP.
Estática, instrucción a instrucción, con las comprobaciones heredadas de la
especificación de la JVM: que el destino de cada salto y de cada switch sea válido; que los
operandos que referencian entradas del pool de constantes sean válidos; que los operandos de
instanceof, checkcast y new sean válidos; que new-array no pase de 255 dimensiones;
que no se use new sobre una clase de array; que los índices de registro caigan dentro del
rango asignado; que los saltos caigan dentro del array de código; que todo destino de
control de flujo sea el principio de una instrucción; que el código no acabe en mitad de
una instrucción; que la ejecución no se salga por el final; y que, para cada handler de
excepción, el área try empiece y acabe en el principio de una instrucción y el handler
empiece en una instrucción válida.
De flujo de datos, que es la cara: se marca la primera instrucción como «cambiada», se
procesa, se propagan los tipos a sus sucesores, se fusionan en los puntos de confluencia y se
repite hasta que no queda nada cambiado. Lo que se comprueba es que los registros contengan
los tipos correctos de valores, que los métodos se invoquen con los argumentos
adecuados, que a los campos se les asignen valores del tipo apropiado y que los opcodes
reciban en sus registros de operando los tipos que esperan. Además, ART verifica de forma
específica que sea imposible ejecutar new-instance cuando un registro ya contiene una
instancia sin inicializar creada por esa misma instrucción, que es su forma de resolver el
problema que la especificación de la JVM ataja prohibiendo los saltos hacia atrás con
referencias sin inicializar.
De ahí salen los errores típicos de un DEX editado a mano, todos con la misma causa
—romper una invariante que el compilador mantenía sin que el editor se diera cuenta—:
| Síntoma | Causa habitual |
|---|---|
| «register index out of range» | Se añadieron instrucciones que usan un registro nuevo sin subir .registers |
| «wide register index out of range» | Se usó vN como par de 64 bits siendo N+1 el último registro |
| Tipo incorrecto en un registro | Se reutilizó un registro que en esa rama traía una referencia, o al revés |
| «branch offset of zero not allowed» | Se dejó un salto a sí mismo al borrar instrucciones |
| Destino de salto que no es inicio de instrucción | Se editaron bytes sin recalcular offsets, o se olvidó que los desplazamientos van en unidades de 16 bits |
| «unaligned table» | Un payload dejó de estar alineado a 4 bytes al insertar o quitar una instrucción y falta el nop de relleno |
move-result fuera de sitio |
Se insertó algo entre el invoke-* y su move-result |
⚠️ sin verificar: los textos de la columna izquierda son los mensajes que emite
method_verifier.cc de AOSP, no se han reproducido en un dispositivo porque adb no está
disponible en esta máquina y el corpus no contiene ningún DEX inválido.
11. Correspondencia con smali
smali es la sintaxis de texto del bytecode Dalvik y baksmali el desensamblador que la
produce; ver Desensambladores smali. Los
tres niveles del mismo método, Sum.f(II)I:
hexadecimal dexdump -d smali
─────────────────────────────────────────────────────────────────────────────────
da01 0102 0000: mul-int/lit8 v1, v1, #int 2 mul-int/lit8 v1, v1, 0x2
b010 0002: add-int/2addr v0, v1 add-int/2addr v0, v1
0f00 0003: return v0 return v0
Y la envoltura que baksmali pone alrededor:
.class public LSum;
.super Ljava/lang/Object;
.source "Sum.java"
.method public static f(II)I
.registers 2
mul-int/lit8 v1, v1, 0x2
add-int/2addr v0, v1
return v0
.end method
⚠️ no ejecutado localmente: baksmali no está instalado, así que el bloque smali está
reconstruido a partir de la sintaxis documentada del proyecto y de la salida real de
dexdump. En particular, la elección entre .registers y .locals y entre notación v y
p depende de las opciones con que se invoque a baksmali y puede no coincidir con lo
mostrado.
Las tres diferencias sistemáticas entre las dos vistas:
dexdumpimprime la dirección de cada instrucción en unidades de 16 bits al principio de la línea;smalino, y usa etiquetas (:cond_0,:goto_0,:try_start_0) como destinos simbólicos de salto, lo que permite insertar y borrar instrucciones sin recalcular desplazamientos a mano. Es la razón principal por la que se edita ensmaliy no en hexadecimal.dexdumpimprime los literales en decimal con el hexadecimal en un comentario (#int 2 // #02);smalilos escribe en hexadecimal (0x2).- Los bloques
try/catchson estructura ensmali(:try_start_0…:try_end_0más una directiva.catch), mientras que en elDEXson la tablatry_itemde fuera del array de instrucciones (Formato DEX, sección 11).
12. Receta: dexdump -d comentado instrucción a instrucción
BT=~/Library/Android/sdk/build-tools/37.0.0
CORPUS=~/corpus-apk
DEXDIR=$(mktemp -d)
unzip -o -q $CORPUS/com.termux_1002.apk classes.dex -d $DEXDIR
$BT/dexdump -d $DEXDIR/classes.dex
Salida real para com.termux.app.TermuxApplication.setLogLevel:()V, recortada a lo esencial:
registers : 4
ins : 1
outs : 3
insns size : 25 16-bit code units
1865d0: |[1865d0] com.termux.app.TermuxApplication.setLogLevel:()V
1865e0: 6e10 f000 0300 |0000: invoke-virtual {v3}, L…;.getApplicationContext:()L…;
1865e6: 0c00 |0003: move-result-object v0
1865e8: 7110 f84a 0000 |0004: invoke-static {v0}, L…/TermuxAppSharedPreferences;.build:(L…;)L…;
1865ee: 0c00 |0007: move-result-object v0
1865f0: 3900 0300 |0008: if-nez v0, 000b // +0003
1865f4: 0e00 |000a: return-void
1865f6: 1201 |000b: const/4 v1, #int 0 // #0
1865f8: 6e10 ff4a 0000 |000c: invoke-virtual {v0}, L…;.getLogLevel:()I
1865fe: 0a02 |000f: move-result v2
186600: 6e30 0b4b 1002 |0010: invoke-virtual {v0, v1, v2}, L…;.setLogLevel:(L…/Context;I)V
186606: 1a00 2024 |0013: const-string v0, "Starting Application" // string@2420
18660a: 7110 1b4a 0000 |0015: invoke-static {v0}, L…/Logger;.logDebug:(L…/String;)V
186610: 0e00 |0018: return-void
positions :
0x0000 line=23
0x000c line=25
0x0015 line=26
Lectura instrucción a instrucción:
| Dir. | Instrucción | Qué ocurre |
|---|---|---|
0000 |
invoke-virtual {v3}, …getApplicationContext() |
registers 4 e ins 1 ⇒ v3 es el único argumento, o sea p0, o sea this. Formato 35c con A = 1 |
0003 |
move-result-object v0 |
Recoge la referencia devuelta. Tiene que ir aquí: entre un invoke-* y su move-result no cabe nada |
0004 |
invoke-static {v0}, …build(Context) |
Pasa el Context recién obtenido |
0007 |
move-result-object v0 |
Sobrescribe v0, que pasa de Context a TermuxAppSharedPreferences. Reutilizar el registro con otro tipo es legal; es el verificador quien lleva la cuenta |
0008 |
if-nez v0, 000b |
Formato 21t: AA = 00 → v0, BBBB = +3. Salto relativo a esta dirección: 0x0008 + 3 = 0x000b |
000a |
return-void |
La rama del null |
000b |
const/4 v1, #int 0 |
Formato 11n, una sola unidad: cabe un literal de 4 bits. Este 0 es el null que se pasa como Context |
000c |
invoke-virtual {v0}, …getLogLevel()I |
|
000f |
move-result v2 |
Sin -object: el resultado es un int |
0010 |
invoke-virtual {v0, v1, v2}, …setLogLevel(Context, I)V |
Tres palabras de argumento. De aquí sale outs 3: es la mayor llamada del método |
0013 |
const-string v0, "Starting Application" |
Formato 21c, índice string@2420 en string_ids |
0015 |
invoke-static {v0}, …logDebug(String)V |
|
0018 |
return-void |
Y de la tabla positions sale la correspondencia con el fuente: 0x0000 es la línea 23,
0x000c la 25 y 0x0015 la 26. Sin debug_info_item esa tabla estaría vacía y el
decompilador no podría dar números de línea
(Formato DEX, sección 12).
Fuentes
- Dalvik bytecode format — https://source.android.com/docs/core/runtime/dalvik-bytecode Consultado el 12 de agosto de 2026 («Last updated 2025-01-07 UTC»). De aquí salen el diseño general del modelo de ejecución (sección 2), los sufijos (sección 5), la tabla completa de opcodes (sección 6), la descripción de las instrucciones de invocación (sección 7) y los tres formatos de payload (sección 8).
- Dalvik executable instruction formats — https://source.android.com/docs/core/runtime/instruction-formats Consultado el 12 de agosto de 2026 («Last updated 2024-08-26 UTC»). De aquí salen la nomenclatura de los identificadores de formato, la tabla de letras de tipo y la tabla completa de layouts de bits (sección 4).
- Dalvik executable format — https://source.android.com/docs/core/runtime/dex-format
Consultado el 12 de agosto de 2026 («Last updated 2025-10-03 UTC»). De aquí salen los
campos
registers_size,ins_sizeyouts_sizedelcode_item(sección 3) y los descriptores de tipo (sección 9). - AOSP
art/runtime/verifier/method_verifier.cc— https://android.googlesource.com/platform/art/+/refs/heads/main/runtime/verifier/method_verifier.cc Consultado el 12 de agosto de 2026. De aquí sale íntegra la sección 10: los comentarios deVerifyInstructionyCodeFlowVerifyMethodenumeran las comprobaciones estáticas y de flujo de datos, y los mensajes deFail(VERIFY_ERROR_BAD_CLASS_HARD)son los de la tabla de errores típicos. - Registers · wiki del proyecto smali — https://github.com/JesusFreke/smali/wiki/Registers
Consultado el 12 de agosto de 2026. De aquí salen la notación
p, la correspondenciapN→vNdel diagrama de la sección 3 y la diferencia entre.registersy.locals. - TypesMethodsAndFields · wiki del proyecto smali —
https://github.com/JesusFreke/smali/wiki/TypesMethodsAndFields
Consultado el 12 de agosto de 2026. De aquí sale la sintaxis de descriptores, métodos y
campos usada en el bloque
smalide la sección 11. - Mediciones sobre el corpus de verificación y el SDK — ejecutadas el 12 de agosto de 2026
con
build-tools37.0.0 (dexdump,d89.2.4-dev),javacde OpenJDK 21.0.10 yjavap. De aquí salen la comparación JVM/Dalvik de la sección 1, los tamaños de marco de la sección 3.1, todas las decodificaciones de bits de la sección 4.3, el ejemplo deinvoke-customde la sección 7.2, el payload de la sección 8 y el desensamblado comentado de la sección 12.