Bytecode Dalvik

Antes conviene leer «Formato DEX»

referencia técnica · Actualizado el 25 de septiembre de 2026

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 → §11 · code_item»). 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. v1 puede ser un int en una parte del método y una referencia a un objeto en otra. Sin debug_info_item no hay nombres ni tipos declarados («Formato DEX → §12 · debug_info_item»), y el tipo de cada registro se infiere del uso, exactamente como hace el verificador de ART («sección 10 · La verificación de bytecode de ART»).
  • Las constantes literales pequeñas se funden en la instrucción. iconst_2 + imul se convierten en un solo mul-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, 4 o 5. La longitud la da el primer dígito del identificador de formato («sección 4.1 · Cómo se lee el identificador»); las de cuatro unidades son solo 45cc y 4rcc —invoke-polymorphic e invoke-polymorphic/range, opcodes fa y fb, añadidas en la versión 038 del DEX— y la única de cinco es 51l.
  • 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: vN y vN+1 valen aunque N sea 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_size del code_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 int de float.
  • 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 · Los payloads»). Nunca deben alcanzarse en el flujo normal de ejecución y deben estar alineadas a 4 bytes, para lo cual el generador inserta un nop de 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 this como 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 10 · La verificación de bytecode de ART»).

3.1 Verificado sobre el muestrario

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 r en 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 —22cs, 35ms, 35mi, 3rms, 3rmi— son los formatos sugeridos de enlazado que se mencionan en 4.1. 20bc es aparte: la especificación lo da como «suggested format for statically determined verification errors».

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 muestrario

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 · Los payloads»)
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 · Los payloads»)
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 siete 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 · Las instrucciones de invocación»)
73 — (sin usar)
74–78 3rc Los cinco invoke-*/range Invocación con rango de hasta 255 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 · invoke-polymorphic e invoke-custom»)
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 255 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 «muestrario» 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 → §7 · Descriptores de tipo y shorty» —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 va por clases, no por instrucciones: la hace dex2oat al compilar y, para la clase que entonces no quedó verificada, el runtime, la primera vez que la inicializa. Por eso un DEX editado a mano que no la supera produce un fallo de carga de la clase —típicamente un VerifyError— y no un comportamiento erróneo en ejecución. Pero ese fallo llega cuando la clase se usa por primera vez, que puede ser mucho después del arranque y justo al llamar al método. Y los fallos «blandos» —un método o un campo que no se resuelve, un acceso no permitido— no rechazan la clase: saltan como excepción al ejecutar la instrucción. Es una diferencia importante para diagnosticar: que la aplicación arranque y el método falle al llamarlo no descarta la verificación; lo que la delata es un VerifyError que nombra la clase.

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
«target dex pc … is not at instruction start» 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
«copyRes1 v… <- result0 type=…», o «invalid use of MOVE_RESULT as branch target» si un salto cae en él Se insertó algo entre el invoke-* y su move-result

Los textos entrecomillados de la columna izquierda están literalmente en runtime/verifier/method_verifier.cc de android-17.0.0_r1 —líneas 718, 723, 809, 819-823 y 838— y en register_line.cc, el de move-result; todos fallan con VERIFY_ERROR_BAD_CLASS_HARD, que rechaza la clase entera. En el mensaje real llevan detrás los valores concretos, con la forma «register index out of range (5 >= 5)». La fila del tipo incorrecto describe el síntoma, no cita un mensaje. No se han reproducido en un dispositivo.

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                              baksmali
─────────────────────────────────────────────────────────────────────────────────
da01 0102            0000: mul-int/lit8 v1, v1, #int 2       mul-int/lit8 p1, p1, 0x2
b010                 0002: add-int/2addr v0, v1              add-int/2addr p0, p1
0f00                 0003: return v0                         return p0

Y la envoltura que baksmali pone alrededor, ejecutando de verdad los tres pasos —javac, d8 y baksmali 3.0.7— sobre un Sum.java de cuatro líneas:

BT=~/Library/Android/sdk/build-tools/37.0.0
javac -d . Sum.java
$BT/d8 --release --min-api 21 --output . Sum.class
baksmali disassemble classes.dex -o out
cat out/Sum.smali
.class public LSum;
.super Ljava/lang/Object;
.source "Sum.java"


# direct methods
.method public constructor <init>()V
    .registers 1

    .line 1
    invoke-direct {p0}, Ljava/lang/Object;-><init>()V

    return-void
.end method

.method public static f(II)I
    .registers 2

    mul-int/lit8 p1, p1, 0x2

    add-int/2addr p0, p1

    return p0
.end method

La diferencia de notación entre las dos columnas no es cosmética y conviene entenderla. dexdump numera todos los registros como vN en el orden del code_item; baksmali usa pN para los que a la entrada del método contienen un parámetro. Los parámetros ocupan siempre los últimos registros del marco, tantos como diga el campo ins del code_item («sección 11 de Formato DEX · code_item»), así que la correspondencia es:

pN  =  v(registers_size − ins + N)

En static f(II)I con registers_size = 2 e ins = 2 no queda ningún registro local, así que v0 y v1 de dexdump son exactamente p0 y p1. En cuanto hay un local dejan de coincidir, y ahí es donde una lectura descuidada se equivoca de registro. Un método de instancia real del classes.dex de com.looker.droidify_710.apk, con registers_size = 2 e ins = 1 —solo this—, quitadas las directivas .line para que se lea. El tratamiento completo de la equivalencia, incluidos los tipos anchos que ocupan dos registros, está en la «sección 3.4 de Desensambladores smali · v frente a p»:

.method public final run()Ljava/lang/Object;
    .registers 2

    iget-object p0, p0, Lorg/slf4j/LoggerFactory$$ExternalSyntheticLambda0;->f$0:Ljava/lang/ClassLoader;

    const-class v0, Lorg/slf4j/helpers/SubstituteServiceProvider;

    invoke-static {v0, p0}, Ljava/util/ServiceLoader;->load(Ljava/lang/Class;Ljava/lang/ClassLoader;)Ljava/util/ServiceLoader;

    move-result-object p0

    return-object p0
.end method

p0 es v1 y el local es v0. Quien lea el mismo método en dexdump verá v1 donde aquí pone p0.

Es el comportamiento por defecto, y se apaga:

--parameter-registers,--preg,--pr <boolean> - Use the pNN syntax for registers that refer
    to a method parameter on method entry. True by default, use --parameter-registers=false
    to disable. (default: True)

Lo mismo pasa con .registers, que es lo que se emite por defecto, frente a .locals, que cuenta solo los no parámetros y se pide con -l. Sobre el mismo fichero:

baksmali disassemble -l classes.dex -o out-l
grep -E '\.locals|mul-int|add-int|return' out-l/Sum.smali
    .locals 0
    mul-int/lit8 p1, p1, 0x2
    add-int/2addr p0, p1
    return p0

.locals 0 y .registers 2 describen el mismo método: cero locales más dos parámetros. Un fichero .smali que se vaya a reensamblar debe llevar una de las dos, no las dos.

Las tres diferencias sistemáticas entre las dos vistas:

  • dexdump imprime la dirección de cada instrucción en unidades de 16 bits al principio de la línea; smali no, 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 en smali y no en hexadecimal.
  • dexdump imprime los literales en decimal con el hexadecimal en un comentario (#int 2 // #02); smali los escribe en hexadecimal (0x2).
  • Los bloques try/catch son estructura en smali (:try_start_0 … :try_end_0 más una directiva .catch), mientras que en el DEX son la tabla try_item de fuera del array de instrucciones («Formato DEX → §11 · code_item»).

12. Receta: dexdump -d comentado instrucción a instrucción

BT=~/Library/Android/sdk/build-tools/37.0.0
MUESTRAS=~/muestras-apk
DEXDIR=$(mktemp -d)
unzip -o -q $MUESTRAS/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 → §12 · debug_info_item»).

Fuentes

  1. 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 · Reglas generales del modelo de ejecución»), los sufijos («sección 5 · Sufijos de las instrucciones»), la tabla completa de opcodes («sección 6 · La tabla de opcodes»), la descripción de las instrucciones de invocación («sección 7 · Las instrucciones de invocación») y los tres formatos de payload («sección 8 · Los payloads»).
  2. 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 · Formatos de instrucción»).
  3. 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_size y outs_size del code_item («sección 3 · Modelo de registros») y los descriptores de tipo («sección 9 · Los descriptores en las instrucciones»).
  4. AOSP art/runtime/verifier/method_verifier.cc — https://android.googlesource.com/platform/art/+/refs/tags/android-17.0.0_r1/runtime/verifier/method_verifier.cc Consultado el 25 de septiembre de 2026. De aquí sale íntegra la «sección 10 · La verificación de bytecode de ART»: los comentarios de VerifyInstruction y CodeFlowVerifyMethod enumeran las comprobaciones estáticas y de flujo de datos, y los mensajes de Fail(VERIFY_ERROR_BAD_CLASS_HARD) son los de la tabla de errores típicos; el de move-result está en register_line.cc, en el mismo directorio.
  5. 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 correspondencia pN → vN del diagrama de la «sección 3 · Modelo de registros» y la diferencia entre .registers y .locals.
  6. 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 smali de la «sección 11 · Correspondencia con smali».