
Durante l'incontro 0x0A DC7831 Il 16 febbraio abbiamo presentato un discorso sui principi di base dell'emulazione di codice binario e sul nostro sviluppo - l'emulatore di piattaforme hardware .
Nell'articolo forniremo una descrizione dell'avvio del firmware del dispositivo nell'emulatore, dimostreremo l'interazione con il debugger e eseguiremo una piccola analisi dinamica del firmware.
Antefatti
Un tempo in una galassia lontana lontana
Qualche anno fa, nel nostro laboratorio si è reso necessario studiare il firmware di un dispositivo. Il firmware era compresso, veniva estratto dal bootloader. Lo faceva in modo piuttosto complesso, riposizionando più volte i dati in memoria. Inoltre, il firmware interagiva attivamente con le periferiche. E tutto questo su un nucleo MIPS.
Gli emulatori esistenti non ci soddisfacevano per motivi oggettivi, ma volevamo comunque eseguire il codice. Così abbiamo deciso di realizzare il nostro emulatore, che facesse il minimo necessario per estrarre il firmware principale. Abbiamo provato - e ci siamo riusciti. Abbiamo pensato, e se aggiungessimo le periferiche, in modo da eseguire anche il firmware principale. Non è stato molto complicato - e anche questo è riuscito. Abbiamo pensato di nuovo e abbiamo deciso di realizzare un emulatore completo.
Alla fine abbiamo ottenuto un emulatore di sistemi di calcolo .

Perché Kopycat?
Si tratta di un gioco di parole.
- copycat (inglese, sost. [ˈkɒpɪkæt]) - imitatore, copiato
- cat (inglese, sost. [ˈkæt]) - gatto, il tuo animale domestico preferito di uno dei creatori del progetto
- La lettera «K» - dal linguaggio di programmazione Kotlin
Kopycat
Quando è stato creato l'emulatore, sono stati stabiliti obiettivi ben precisi:
- la possibilità di creare rapidamente una nuova periferica, modulo, nucleo di processore;
- la possibilità di assemblare un dispositivo virtuale da vari moduli;
- la possibilità di caricare in memoria del dispositivo virtuale qualsiasi dato binario (firmware);
- la possibilità di lavorare con gli snapshot (istantanee dello stato del sistema);
- la possibilità di interagire con l'emulatore tramite il debugger integrato;
- un linguaggio moderno e piacevole per lo sviluppo.
Alla fine, per l'implementazione è stato scelto Kotlin, un'architettura a bus (quando i moduli si collegano tra loro tramite bus virtuali dati), e JSON - come formato di descrizione del dispositivo, e GDB RSP - come protocollo di interazione con il debugger.
Lo sviluppo è in corso da poco più di due anni e continua attivamente. Durante questo periodo sono stati realizzati i core del processore MIPS, x86, V850ES, ARM, PowerPC.
Il progetto sta crescendo, ed è giunto il momento di presentarlo al grande pubblico. Una descrizione dettagliata del progetto sarà fornita in seguito, per ora concentriamoci sull'utilizzo di Kopycat.
Per i più impazienti — la versione promozionale dell'emulatore può essere scaricata da .
Rinoceronte nell'emulatore
Ricordiamo che in precedenza per la conferenza SMARTRHINO-2018 è stato creato un dispositivo di test "Rinoceronte" per insegnare abilità di reverse engineering. Il processo di analisi statica del firmware è stato descritto in .
Ora cerchiamo di aggiungere un po' di "dinamismo" e avviamo il firmware nell'emulatore.
Avremo bisogno di:
1) Java 1.8
2) Python e il modulo per utilizzare Python all'interno dell'emulatore. Il pacchetto WHL del modulo Jep per Windows può essere .
Per Windows:
1)
2)
Per Linux:
1) socat
Come client GDB, puoi utilizzare Eclipse, IDA Pro o radare2.
Come funziona?
Per eseguire il firmware nell'emulatore, è necessario "assemblare" un dispositivo virtuale che rappresenta un'analogia di un dispositivo reale.
Il dispositivo reale ("rinoceronte") può essere mostrato nello schema strutturale:

L'emulatore ha una struttura modulare e il dispositivo virtuale finale può essere descritto in un file JSON.
JSON su 105 righe
{
"top": true,
// Il nome del plugin dovrebbe essere lo stesso del nome del file (o del percorso completo a partire dalla libreria)
"plugin": "rhino",
// Directory in cui il plugin posiziona
"library": "user",
// Parametri del plugin (parametri del costruttore se versione jar-plugin)
"params": [
{ "name": "tty_dbg", "type": "String"},
{ "name": "tty_bt", "type": "String"},
{ "name": "firmware", "type": "String", "default": "NUL"}
],
// Porte esterne del plugin
"ports": [ ],
// Bus interni del plugin
"buses": [
{ "name": "mem", "size": "BUS30" },
{ "name": "nand", "size": "4" },
{ "name": "gpio", "size": "BUS32" }
],
// Componenti interni del plugin
"modules": [
{
"name": "u1_stm32",
"plugin": "STM32F042",
"library": "mcu",
"params": {
"firmware:String": "params.firmware"
}
},
{
"name": "usart_debug",
"plugin": "UartSerialTerminal",
"library": "terminals",
"params": {
"tty": "params.tty_dbg"
}
},
{
"name": "term_bt",
"plugin": "UartSerialTerminal",
"library": "terminals",
"params": {
"tty": "params.tty_bt"
}
},
{
"name": "bluetooth",
"plugin": "BT",
"library": "mcu"
},
{ "name": "led_0", "plugin": "LED", "library": "mcu" },
{ "name": "led_1", "plugin": "LED", "library": "mcu" },
{ "name": "led_2", "plugin": "LED", "library": "mcu" },
{ "name": "led_3", "plugin": "LED", "library": "mcu" },
{ "name": "led_4", "plugin": "LED", "library": "mcu" },
{ "name": "led_5", "plugin": "LED", "library": "mcu" },
{ "name": "led_6", "plugin": "LED", "library": "mcu" },
{ "name": "led_7", "plugin": "LED", "library": "mcu" },
{ "name": "led_8", "plugin": "LED", "library": "mcu" },
{ "name": "led_9", "plugin": "LED", "library": "mcu" },
{ "name": "led_10", "plugin": "LED", "library": "mcu" },
{ "name": "led_11", "plugin": "LED", "library": "mcu" },
{ "name": "led_12", "plugin": "LED", "library": "mcu" },
{ "name": "led_13", "plugin": "LED", "library": "mcu" },
{ "name": "led_14", "plugin": "LED", "library": "mcu" },
{ "name": "led_15", "plugin": "LED", "library": "mcu" }
],
// Connessione del plugin tra componenti
"connections": [
[ "u1_stm32.ports.usart1_m", "usart_debug.ports.term_s"],
[ "u1_stm32.ports.usart1_s", "usart_debug.ports.term_m"],
[ "u1_stm32.ports.usart2_m", "bluetooth.ports.usart_m"],
[ "u1_stm32.ports.usart2_s", "bluetooth.ports.usart_s"],
[ "bluetooth.ports.bt_s", "term_bt.ports.term_m"],
[ "bluetooth.ports.bt_m", "term_bt.ports.term_s"],
[ "led_0.ports.pin", "u1_stm32.buses.pin_output_a", "0x00"],
[ "led_1.ports.pin", "u1_stm32.buses.pin_output_a", "0x01"],
[ "led_2.ports.pin", "u1_stm32.buses.pin_output_a", "0x02"],
[ "led_3.ports.pin", "u1_stm32.buses.pin_output_a", "0x03"],
[ "led_4.ports.pin", "u1_stm32.buses.pin_output_a", "0x04"],
[ "led_5.ports.pin", "u1_stm32.buses.pin_output_a", "0x05"],
[ "led_6.ports.pin", "u1_stm32.buses.pin_output_a", "0x06"],
[ "led_7.ports.pin", "u1_stm32.buses.pin_output_a", "0x07"],
[ "led_8.ports.pin", "u1_stm32.buses.pin_output_a", "0x08"],
[ "led_9.ports.pin", "u1_stm32.buses.pin_output_a", "0x09"],
[ "led_10.ports.pin", "u1_stm32.buses.pin_output_a", "0x0A"],
[ "led_11.ports.pin", "u1_stm32.buses.pin_output_a", "0x0B"],
[ "led_12.ports.pin", "u1_stm32.buses.pin_output_a", "0x0C"],
[ "led_13.ports.pin", "u1_stm32.buses.pin_output_a", "0x0D"],
[ "led_14.ports.pin", "u1_stm32.buses.pin_output_a", "0x0E"],
[ "led_15.ports.pin", "u1_stm32.buses.pin_output_a", "0x0F"]
]
}Fai attenzione al parametro firmware nella sezione params — è il nome del file che può essere caricato nel dispositivo virtuale come firmware.
Un dispositivo virtuale e la sua interazione con il sistema operativo principale possono essere rappresentati in questo modo:

L'attuale istanza di test dell'emulatore implica l'interazione con le porte COM del sistema operativo principale (UART di debug e UART per il modulo Bluetooth). Queste possono essere porte reali, a cui sono connessi i dispositivi, oppure porte COM virtuali (per questo serve esattamente com0com / socat).
Per interagire con l'emulatore dall'esterno, attualmente esistono due principali modalità:
- il protocollo GDB RSP (pertanto, gli strumenti che supportano questo protocollo — Eclipse / IDA / radare2);
- la riga di comando interna dell'emulatore (Argparse o Python).
Porte COM virtuali
Per interagire con l'UART del dispositivo virtuale sulla macchina locale tramite terminale, è necessario creare una coppia di porte COM virtuali collegate. Nel nostro caso, una porta è utilizzata dall'emulatore, e l'altra dal programma terminale (PuTTY o screen):

Utilizzo di com0com
Le porte COM virtuali vengono configurate utilizzando l'utilità di setup del pacchetto com0com (versione console — C:Program Files (x86)com0comsetupс.exe, oppure la versione GUI — C:Program Files (x86)com0comsetupg.exe):

È necessario spuntare enable buffer overrun per tutte le porte virtuali create, altrimenti l'emulatore attenderà una risposta dalla porta COM.
Utilizzo di socat
Su sistemi UNIX, le porte COM virtuali vengono create automaticamente dall'emulatore utilizzando l'utilità socat, è sufficiente specificare il prefisso socat:.
Interfaccia interna della riga di comando (Argparse o Python)
Poiché Kopycat è un'applicazione console, per interagire con i propri oggetti e variabili, l'emulatore offre due opzioni di interfaccia della riga di comando: Argparse e Python.
Argparse è il CLI integrato in Kopycat, ed è sempre disponibile per tutti.
Il CLI alternativo è l'interprete Python. Per utilizzarlo, è necessario installare il modulo Python Jep e configurare l'emulatore per lavorare con Python (verrà utilizzato l'interprete Python installato nel sistema principale dell'utente).
Installazione del modulo Python Jep
Su Linux, Jep può essere installato tramite pip:
pip install jepPer installare Jep su Windows, è necessario prima installare Windows SDK e il corrispondente Microsoft Visual Studio. Abbiamo semplificato un po' il vostro compito e abbiamo fatto JEP per le versioni attuali di Python per Windows, quindi il modulo può essere installato da file:
pip install jep-3.8.2-cp27-cp27m-win_amd64.whlPer verificare l'installazione di Jep, è necessario eseguire nel prompt dei comandi:
python -c "import jep"La risposta dovrebbe fornire il messaggio:
ImportError: Jep non è supportato in Python standalone, deve essere incorporato in Java.Nel file di comando dell'emulatore per il tuo sistema (kopycat.bat — per Windows, kopycat — per Linux) aggiungi al elenco dei parametri DEFAULT_JVM_OPTS un parametro aggiuntivo Djava.library.path — deve contenere il percorso fino al modulo Jep installato.
Di conseguenza, per Windows dovrebbe risultare una stringa del tipo:
set DEFAULT_JVM_OPTS="-XX:MaxMetaspaceSize=256m" "-XX:+UseParallelGC" "-XX:SurvivorRatio=6" "-XX:-UseGCOverheadLimit" "-Djava.library.path=C:/Python27/Lib/site-packages/jep"Esecuzione di Kopycat
L'emulatore è un'applicazione console JVM. L'avvio avviene tramite uno script della riga di comando del sistema operativo (sh/cmd).
Il comando per l'avvio su Windows:
binkopycat -g 23946 -n rhino -l user -y library -p firmware=firmwarerhino_pass.bin,tty_dbg=COM26,tty_bt=COM28Il comando per l'avvio su Linux utilizzando l'utilità socat:
./bin/kopycat -g 23946 -n rhino -l user -y library -p firmware=./firmware/rhino_pass.bin, tty_dbg=socat:./COM26,tty_bt=socat:./COM28-g 23646— porta TCP che sarà aperta per l'accesso al server GDB;-n rhino— nome del modulo principale del sistema (dispositivo assemblato);-l user— nome della libreria per cercare il modulo principale;-y library— percorso per cercare i moduli inclusi nel dispositivo;firmwarerhino_pass.bin— percorso del file di firmware;- COM26 e COM28 — porte COM virtuali.
Di conseguenza, verrà mostrato un prompt Python > (o Argparse >):
18:07:59 INFO [eFactoryBuilder.create ]: Modulo top creato con successo come top
18:07:59 INFO [ Module.initializeAndRes]: Impostazione core su top.u1_stm32.cortexm0.arm per top
18:07:59 INFO [ Module.initializeAndRes]: Impostazione debugger su top.u1_stm32.dbg per top
18:07:59 WARN [ Module.initializeAndRes]: Tracer non trovato in top...
18:07:59 INFO [ Module.initializeAndRes]: Inizializzazione porte e bus...
18:07:59 WARN [ Module.initializePortsA]: ATTENZIONE: Alcune porte hanno avvisi, usa printModulesPortsWarnings per vederlo...
18:07:59 FINE [ ARMv6CPU.reset ]: Imposta l'indirizzo del punto di ingresso a 08006A75
18:07:59 INFO [ Module.initializeAndRes]: Modulo top inizializzato e ripristinato con successo come cellula top!
18:07:59 INFO [ Kopycat.open ]: Avvio della virtualizzazione della scheda top[rhino] con arm[ARMv6Core]
18:07:59 INFO [ GDBServer.debuggerModule ]: Imposta il nuovo modulo debugger top.u1_stm32.dbg per GDB_SERVER(port=23946,alive=true)
Python >Interazione con IDA Pro
Come file sorgente per l'analisi in IDA per semplificare il testing, utilizziamo il firmware "Rhinoceros" in formato (qui è salvata la meta-informazione).
Puoi anche utilizzare il firmware principale senza meta-informazioni.
Dopo aver avviato Kopycat in IDA Pro, nel menu Debugger andiamo su "Switch debugger…» e scegliamo «Debugger GDB remoto«. Successivamente configuriamo la connessione: menu Debugger — Opzioni processo…
Impostiamo i valori:
- Applicazione — qualsiasi valore
- Hostname: 127.0.0.1 (o IP dell macchina remota dove è in esecuzione Kopycat)
- Porta: 23946

Ora è disponibile il pulsante di avvio del debug (tasto F9):
![]()
Facciamo clic su di esso — avviene la connessione al modulo debugger nell'emulatore. IDA entra in modalità di debug, e si aprono finestre aggiuntive: informazioni sui registri, sullo stack.
Ora possiamo utilizzare tutte le funzionalità standard del debugger:
- esecuzione passo dopo passo delle istruzioni (Entra nel passo e Salta il passo — tasti F7 e F8, rispettivamente);
- avvio e sospensione dell'esecuzione;
- creazione di punti di interruzione sia su codice che su dati (tasto F2).
La connessione al debugger non significa l'avvio del codice del firmware. La posizione attuale per l'esecuzione deve essere l'indirizzo 0x08006A74 — inizio della funzione Reset_Handler. Scorrendo il listato più in basso, possiamo vedere la chiamata della funzione main. Possiamo posizionare il cursore su questa riga (indirizzo 0x08006ABE) ed eseguire l'operazione Esegui fino al cursore (tasto F4).

Poi possiamo premere F7 per entrare nella funzione main.
Se eseguiamo il comando Continua processo (tasto F9), comparirà la finestra "Attendere prego" con il solo pulsante Sospendi:

Premendo Sospendi l'esecuzione del codice del firmware viene sospesa e può essere ripresa dallo stesso indirizzo del codice dove è stata interrotta.
Se si continua l'esecuzione del codice, nei terminali collegati alle porte COM virtuali si possono vedere le seguenti righe:


La presenza della riga "state bypass" indica che il modulo Bluetooth virtuale è passato alla modalità di ricezione dei dati dalla porta COM dell'utente.
Ora nel terminale Bluetooth (nell'immagine — COM29) è possibile inserire comandi secondo il protocollo "Rinoceronte". Ad esempio, per il comando "MEOW" il terminale Bluetooth restituirà la stringa "mur-mur":

Non emulami completamente
Nella costruzione dell'emulatore è possibile scegliere il grado di dettaglio/emulazione di un certo dispositivo. Ad esempio, il modulo Bluetooth può essere emulato in vari modi:
- si emula completamente il dispositivo con un set completo di comandi;
- si emulano i comandi AT, e il flusso di dati viene ricevuto dalla porta COM del sistema principale;
- il dispositivo virtuale garantisce un completo reindirizzamento dei dati al dispositivo reale;
- come un semplice stub, che restituisce sempre "OK".
Nell'attuale versione dell'emulatore viene utilizzato il secondo approccio: un modulo Bluetooth virtuale esegue la configurazione e poi passa alla modalità di "proxy" dei dati dalla porta COM del sistema principale alla porta UART dell'emulatore.

Consideriamo la possibilità di una semplice strumentazione del codice nel caso in cui non sia stata realizzata qualche parte della periferia. Ad esempio, se non è stato creato un timer per controllare il trasferimento dei dati nel DMA (il controllo viene effettuato nella funzione ws2812b_wait, situata all'indirizzo 0x08006840), il firmware attenderà sempre il reset del flag busy, situato all'indirizzo 0x200004C4, che indica l'occupazione della linea dati del DMA:

Possiamo ovviare a tale situazione eseguendo un reset "manuale" del flag busy subito dopo la sua impostazione. In IDA Pro è possibile creare una funzione Python e chiamarla in un breakpoint, collocando il breakpoint stesso nel codice dopo la scrittura del valore 1 nel flag busy.
Gestore del breakpoint
Iniziamo creando una funzione Python in IDA. Menu File — Script command…
Aggiungiamo un nuovo snippet nell'elenco a sinistra, dandogli un nome (ad esempio, BPT),
nella casella di testo a destra immettiamo il codice della funzione:
def skip_dma():
print "Saltando l'attesa ws2812..."
value = Byte(0x200004C4)
if value == 1:
PatchDbgByte(0x200004C4, 0)
return False 
Dopo di che premiamo Esegui e chiudiamo la finestra degli script.
Ora passiamo al codice all'indirizzo 0x0800688A, impostiamo un breakpoint (tasto F2), modifichiamolo (menu contestuale Modifica breakpoint…), non dimentichiamo di impostare il tipo di script – Python:


Se il valore attuale del flag busy è 1, allora dobbiamo eseguire la funzione skip_dma nella riga degli script:

Se avviamo l'esecuzione del firmware, possiamo vedere l'attivazione del codice del gestore del breakpoint in IDA nella finestra Output alla riga Saltando l'attesa ws2812.... Ora il firmware non attenderà il reset del flag busy.
Interazione con l'emulatore
L'emulazione fine a se stessa probabilmente non susciterà entusiasmo e gioia. È molto più interessante se l'emulatore aiuti il ricercatore a vedere i dati in memoria o stabilire interazioni tra i flussi.
Mostriamo come stabilire un'interazione dei task RTOS in dinamica. Prima di tutto, è necessario fermare l'esecuzione del codice, se è in esecuzione. Se si accede alla funzione bluetooth_task_entry nella sezione di gestione del comando "LED" (indirizzo 0x080057B8), si può notare che prima viene creato e poi inviato nella coda di sistema ledControlQueueHandle , un certo messaggio.

Bisogna impostare un breakpoint sull'accesso alla variabile ledControlQueueHandle, situata all'indirizzo 0x20000624 e continuare l'esecuzione del codice:

Di conseguenza, inizialmente ci sarà un'interruzione all'indirizzo 0x080057CA prima della chiamata della funzione osMailAlloc, poi — all'indirizzo 0x08005806 prima della chiamata della funzione osMailPut, quindi, dopo un po' di tempo — all'indirizzo 0x08005BD4 (prima della chiamata della funzione osMailGet), che appartiene alla funzione leds_task_entry (compito LED), cioè si è verificato uno switch dei task, e ora il controllo è passato al task LED.

In questo modo semplice è possibile stabilire come i task RTOS interagiscono tra di loro.
Certo, nella realtà, l'interazione tra i task può essere organizzata in modo più complesso, ma utilizzare un emulatore rende più semplice monitorare questa interazione.
puoi guardare un breve video sull'avvio dell'emulatore e l'interazione con IDA Pro.
Avvio con Radare2
Non si può ignorare uno strumento così versatile come Radare2.
Per connettersi all'emulatore utilizzando r2, il comando sarà:
radare2 -A -a arm -b 16 -d gdb://localhost:23946 rhino_fw42k6.elfOra sono disponibili l'avvio (dc) e la pausa dell'esecuzione (Ctrl+C).
Sfortunatamente, attualmente in r2 ci sono problemi quando si utilizza un server gdb hardware e la mappatura della memoria, per questo motivo i punti di interruzione e i passi (comando ds) non funzionano. Speriamo che questo venga risolto a breve.
Avvio con Eclipse
Una delle opzioni per utilizzare l'emulatore è il debug del firmware del dispositivo in fase di sviluppo. Per essere più chiari, utilizzeremo anche il firmware "Rhinoceros". Puoi scaricare i sorgenti del firmware .
Come IDE useremo Eclipse dal pacchetto .
Per fare in modo che l'emulatore carichi il firmware direttamente compilato in Eclipse, è necessario aggiungere il parametro firmware=null al comando di avvio dell'emulatore:
binkopycat -g 23946 -n rhino -l user -y modules -p firmware=null,tty_dbg=COM26,tty_bt=COM28Configurazione del debug
In Eclipse, seleziona il menu Esegui — Configurazioni di debug… Nella finestra che si apre, nella sezione GDB Hardware Debugging devi aggiungere una nuova configurazione, quindi nella scheda "Main" specifica il progetto corrente e l'applicazione per il debug:

Nella scheda "Debugger" devi specificare il comando GDB:
${openstm32_compiler_path}arm-none-eabi-gdb
E inserire i parametri per la connessione al server GDB (host e porta):

Nella scheda "Startup" è necessario specificare i seguenti parametri:
- spunta la casella Load image (affinché il caricamento dell'immagine del firmware compilato venga eseguito nell'emulatore);
- spunta la casella Load symbols;
- aggiungi il comando di avvio:
set $pc = *0x08000004(impostare nel registro PC il valore dalla memoria all'indirizzo0x08000004— lì è memorizzato l'indirizzo ResetHandler'a).
Si prega di notare, se non desideri caricare il file di firmware da Eclipse, non è necessario specificare i parametri Load image e Eseguire comandi non è necessario.

Dopo aver premuto Debug, puoi lavorare in modalità di debug:
- esecuzione passo passo del codice

- interazione con i punti di interruzione

Nota. In Eclipse ci sono, hm… alcune peculiarità… e bisogna convivere con esse. Ad esempio, se durante l'avvio del debugger appare il messaggio «No source available for «0x0»», esegui il comando Step (F5)

In conclusione
L'emulazione del codice nativo è un'azione piuttosto interessante. Per il progettista di dispositivi, si presenta l'opportunità di fare debug del firmware senza un dispositivo reale. Per il ricercatore, vi è la possibilità di condurre un'analisi dinamica del codice, cosa non sempre possibile anche in presenza di un dispositivo.
Vogliamo fornire ai professionisti uno strumento che sia comodo, relativamente semplice e che non richieda troppo impegno e tempo per la sua configurazione e avvio.
Scrivi nei commenti la tua esperienza con gli emulatori hardware. Ti invitiamo a partecipare alla discussione e saremo lieti di rispondere alle domande.
Solo gli utenti registrati possono partecipare al sondaggio. , per favore.
Per cosa utilizzi l'emulatore?
sviluppo (debug) di firmware
esplorazione di firmware
esecuzione di giochi (Dendi, Sega, PSP)
qualcos'altro (scrivi nei commenti)
Hanno votato 7 utenti. Si sono astenuti 2 utenti.
Quale software utilizzi per l'emulazione del codice nativo?
QEMU
Motore Unicorn
Proteus
qualcos'altro (scrivi nei commenti)
Hanno votato 6 utenti. Si sono astenuti 2 utenti.
Cosa ti piacerebbe migliorare nell'emulatore utilizzato?
vorrei più velocità
vorrei una configurazione/avvio più comodi
vorrei più possibilità di interazione con l'emulatore (API, hook)
sono soddisfatto di tutto
qualcos'altro (scrivi nei commenti)
Hanno votato 8 utenti. Si è astenuto 1 utente.
Fonte: habr.com


