Koosoleku osana 0x0A DC7831
Selles artiklis kirjeldame, kuidas käivitada seadme püsivara emulaatoris, demonstreerida koostoimet siluriga ja teha püsivara väikest dünaamilist analüüsi.
eelajalugu
Kaua aega tagasi kaugel kaugel galaktikas
Paar aastat tagasi tekkis meie laboris vajadus uurida seadme püsivara. Püsivara tihendati ja pakiti lahti alglaaduriga. Ta tegi seda väga keerulisel viisil, nihutades mälus olevaid andmeid mitu korda. Ja püsivara ise suhtles seejärel aktiivselt välisseadmetega. Ja seda kõike MIPS-i tuumal.
Objektiivsetel põhjustel saadaolevad emulaatorid meile ei sobinud, kuid tahtsime siiski koodi käivitada. Seejärel otsustasime teha oma emulaatori, mis teeks minimaalselt ja võimaldaks meil põhilise püsivara lahti pakkida. Proovisime ja see töötas. Mõtlesime, et mis siis, kui lisame välisseadmed, et täita ka peamist püsivara. See ei teinud väga haiget – ja see läks ka korda. Mõtlesime uuesti ja otsustasime teha täisväärtusliku emulaatori.
Tulemuseks oli arvutisüsteemide emulaator
Miks Kopycat?
Toimub sõnademäng.
- copycat (inglise keeles, nimisõna [ˈkɒpɪkæt]) - jäljendaja, jäljendaja
- kass (inglise keeles, nimisõna [ˈkæt]) - kass, kass - ühe projekti looja lemmikloom
- Täht “K” pärineb Kotlini programmeerimiskeelest
Copycat
Emulaatori loomisel seati väga konkreetsed eesmärgid:
- võimalus kiiresti luua uusi välisseadmeid, mooduleid, protsessori tuumasid;
- võimalus kokku panna erinevatest moodulitest virtuaalne seade;
- võimalus laadida virtuaalse seadme mällu mis tahes binaarandmeid (püsivara);
- võime töötada hetktõmmistega (süsteemi oleku hetktõmmised);
- võimalus emulaatoriga suhelda sisseehitatud siluri kaudu;
- kena kaasaegne keel arenguks.
Selle tulemusena valiti juurutamiseks Kotlin, siiniarhitektuur (see on siis, kui moodulid suhtlevad üksteisega virtuaalsete andmesiinide kaudu), JSON seadme kirjelduse vorminguks ja GDB RSP kui siluriga suhtlemise protokoll.
Areng on kestnud veidi üle kahe aasta ja käib aktiivselt. Selle aja jooksul võeti kasutusele MIPS, x86, V850ES, ARM ja PowerPC protsessorituumad.
Projekt kasvab ja on aeg seda laiemale avalikkusele tutvustada. Projekti üksikasjaliku kirjelduse teeme hiljem, kuid praegu keskendume Kopycati kasutamisele.
Kõige kannatamatumate jaoks saab emulaatori promoversiooni alla laadida aadressilt
Ninasarvik emulaatoris
Meenutagem, et varem loodi konverentsi SMARTRHINO-2018 tarbeks testseade “Ninasarvik” pöördprojekteerimise oskuste õpetamiseks. Staatilise püsivara analüüsi protsessi kirjeldati artiklis
Proovime nüüd lisada "kõlareid" ja käivitada emulaatoris püsivara.
Meil on vaja:
1) Java 1.8
2) Python ja moodul
Windowsi jaoks:
1)
2)
Linuxi jaoks:
1) sot
GDB kliendina saate kasutada Eclipse'i, IDA Pro või radare2.
Kuidas see toimib?
Emulaatoris püsivara tegemiseks on vaja "kokku panna" virtuaalne seade, mis on reaalse seadme analoog.
Tegelikku seadet (ninasarvikut) saab näidata plokkskeemil:
Emulaatoril on modulaarne struktuur ja lõplikku virtuaalset seadet saab kirjeldada JSON-failis.
JSON 105 rida
{
"top": true,
// Plugin name should be the same as file name (or full path from library start)
"plugin": "rhino",
// Directory where plugin places
"library": "user",
// Plugin parameters (constructor parameters if jar-plugin version)
"params": [
{ "name": "tty_dbg", "type": "String"},
{ "name": "tty_bt", "type": "String"},
{ "name": "firmware", "type": "String", "default": "NUL"}
],
// Plugin outer ports
"ports": [ ],
// Plugin internal buses
"buses": [
{ "name": "mem", "size": "BUS30" },
{ "name": "nand", "size": "4" },
{ "name": "gpio", "size": "BUS32" }
],
// Plugin internal components
"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" }
],
// Plugin connection between components
"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"]
]
}
Pöörake tähelepanu parameetrile püsivara lõik parameetrid on faili nimi, mille saab püsivarana virtuaalseadmesse laadida.
Virtuaalset seadet ja selle koostoimet peamise operatsioonisüsteemiga saab kujutada järgmise diagrammiga:
Emulaatori praegune testeksemplar hõlmab suhtlemist peamise OS-i COM-portidega (Bluetooth-mooduli UART-i ja UART-i silumine). Need võivad olla tõelised pordid, millega seadmed on ühendatud, või virtuaalsed COM-pordid (selleks vajate lihtsalt com0com/socat).
Praegu on emulaatoriga väljastpoolt suhtlemiseks kaks peamist viisi:
- GDB RSP protokoll (vastavalt on seda protokolli toetavad tööriistad Eclipse / IDA / radare2);
- sisemise emulaatori käsurida (Argparse või Python).
Virtuaalsed COM-pordid
Kohalikus masinas asuva virtuaalseadme UART-iga suhtlemiseks terminali kaudu peate looma paar seotud virtuaalset COM-porti. Meie puhul kasutab üht porti emulaator ja teist terminaliprogramm (PuTTY või ekraan):
Kasutades com0com
Virtuaalsed COM-pordid konfigureeritakse com0com komplekti häälestusutiliidi abil (konsooli versioon - C:Program Files (x86)com0comsetupс.exe, või GUI versioon - C:Program Files (x86)com0comsetupg.exe):
Märkige ruudud lubada puhvri ülekoormus kõigi loodud virtuaalportide jaoks, vastasel juhul ootab emulaator COM-pordi vastust.
Kasutades socat
UNIX-süsteemides loob emulaator automaatselt virtuaalsed COM-pordid, kasutades selleks utiliiti socat, emulaatori käivitamisel määrake pordi nimes eesliide socat:
.
Sisemine käsurea liides (Argparse või Python)
Kuna Kopycat on konsoolirakendus, pakub emulaator oma objektide ja muutujatega suhtlemiseks kahte käsurea liidese valikut: Argparse ja Python.
Argparse on Kopycati sisse ehitatud CLI ja on alati kõigile kättesaadav.
Alternatiivne CLI on Pythoni tõlk. Selle kasutamiseks tuleb installida Jep Pythoni moodul ja seadistada emulaator Pythoniga töötama (kasutatakse kasutaja põhisüsteemi installitud Pythoni interpretaatorit).
Pythoni mooduli Jep installimine
Linuxi all saab Jepi installida pipi kaudu:
pip install jep
Jepi installimiseks Windowsi peate esmalt installima Windowsi SDK ja vastava Microsoft Visual Studio. Oleme selle teie jaoks veidi lihtsamaks teinud ja
pip install jep-3.8.2-cp27-cp27m-win_amd64.whl
Jepi installimise kontrollimiseks peate käivitama käsureal:
python -c "import jep"
Vastuseks tuleks saada järgmine sõnum:
ImportError: Jep is not supported in standalone Python, it must be embedded in Java.
Teie süsteemi emulaatori pakkfailis (copycat.bat - Windowsi jaoks, kopeeriv - Linuxi jaoks) parameetrite loendisse DEFAULT_JVM_OPTS
lisage täiendav parameeter Djava.library.path
— see peab sisaldama installitud Jep-mooduli teed.
Windowsi tulemus peaks olema selline rida:
set DEFAULT_JVM_OPTS="-XX:MaxMetaspaceSize=256m" "-XX:+UseParallelGC" "-XX:SurvivorRatio=6" "-XX:-UseGCOverheadLimit" "-Djava.library.path=C:/Python27/Lib/site-packages/jep"
Kopycati käivitamine
Emulaator on konsooli JVM-i rakendus. Käivitamine toimub operatsioonisüsteemi käsurea skripti (sh/cmd) kaudu.
Käsk Windowsi all käitamiseks:
binkopycat -g 23946 -n rhino -l user -y library -p firmware=firmwarerhino_pass.bin,tty_dbg=COM26,tty_bt=COM28
Käsk Linuxi all käitamiseks, kasutades utiliiti 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
— TCP-port, mis on avatud juurdepääsuks GDB serverile;-n rhino
— süsteemi põhimooduli (kokkupandud seadme) nimi;-l user
— raamatukogu nimi, kust põhimoodulit otsida;-y library
— seadmesse kuuluvate moodulite otsimise tee;firmwarerhino_pass.bin
— püsivara faili tee;- COM26 ja COM28 on virtuaalsed COM-pordid.
Selle tulemusena kuvatakse viip Python >
(Või Argparse >
):
18:07:59 INFO [eFactoryBuilder.create ]: Module top successfully created as top
18:07:59 INFO [ Module.initializeAndRes]: Setup core to top.u1_stm32.cortexm0.arm for top
18:07:59 INFO [ Module.initializeAndRes]: Setup debugger to top.u1_stm32.dbg for top
18:07:59 WARN [ Module.initializeAndRes]: Tracer wasn't found in top...
18:07:59 INFO [ Module.initializeAndRes]: Initializing ports and buses...
18:07:59 WARN [ Module.initializePortsA]: ATTENTION: Some ports has warning use printModulesPortsWarnings to see it...
18:07:59 FINE [ ARMv6CPU.reset ]: Set entry point address to 08006A75
18:07:59 INFO [ Module.initializeAndRes]: Module top is successfully initialized and reset as a top cell!
18:07:59 INFO [ Kopycat.open ]: Starting virtualization of board top[rhino] with arm[ARMv6Core]
18:07:59 INFO [ GDBServer.debuggerModule ]: Set new debugger module top.u1_stm32.dbg for GDB_SERVER(port=23946,alive=true)
Python >
Koostoime IDA Pro-ga
Testimise lihtsustamiseks kasutame vormis IDA analüüsi lähtefailina Rhino püsivara
Peamist püsivara saate kasutada ka ilma metainfota.
Pärast Kopycati käivitamist IDA Pro-s avage siluri menüüs üksus "Vaheta silurit…" ja valige "GDB kaugsiluja". Järgmisena seadistage ühendus: menüü Siluja – protsessi valikud…
Määra väärtused:
- Rakendus - mis tahes väärtus
- Hostinimi: 127.0.0.1 (või selle kaugmasina IP-aadress, kus Kopycat töötab)
- Port: 23946
Nüüd on silumisnupp saadaval (klahv F9):
Klõpsake seda, et luua ühendus emulaatori silurimooduliga. IDA läheb silumisrežiimi, saadaval on täiendavad aknad: teave registrite, virna kohta.
Nüüd saame kasutada kõiki siluri standardfunktsioone:
- juhiste samm-sammult täitmine (Sisse astuma и Astu üle — klahvid vastavalt F7 ja F8);
- täitmise alustamine ja peatamine;
- katkestuspunktide loomine nii koodi kui ka andmete jaoks (klahv F2).
Siluriga ühenduse loomine ei tähenda püsivara koodi käivitamist. Praegune täitmiskoht peab olema aadress 0x08006A74
— funktsiooni algus Reset_Handler. Kui kerite kirjet allapoole, näete funktsioonikutset põhiline. Saate asetada kursori sellele reale (aadress 0x08006ABE
) ja tehke operatsioon Käivitage kursorini (klahv F4).
Järgmisena võite funktsiooni sisenemiseks vajutada F7 põhiline.
Esli vypolnit käsk Jätkake protsessi (klahv F9), siis ilmub ühe nupuga aken "Palun oodake". Peatama:
Kui vajutate Peatama püsivara koodi täitmine on peatatud ja seda saab jätkata samalt aadressilt koodis, kus see katkestati.
Kui jätkate koodi täitmist, näete virtuaalsete COM-portidega ühendatud terminalides järgmisi ridu:
"Oleku möödaviigu" rea olemasolu näitab, et virtuaalne Bluetooth-moodul on lülitunud kasutaja COM-pordist andmete vastuvõtmise režiimi.
Nüüd saate Bluetooth terminalis (pildil COM29) sisestada käske vastavalt Rhino protokollile. Näiteks käsk "MEOW" tagastab Bluetoothi terminalile stringi "mur-mur":
Emuleeri mind mitte täielikult
Emulaatori ehitamisel saate valida konkreetse seadme detailsuse/emulatsiooni taseme. Näiteks saab Bluetooth-moodulit emuleerida mitmel viisil:
- seade on täielikult emuleeritud täieliku käskude komplektiga;
- AT-käsud emuleeritakse ja andmevoog võetakse vastu põhisüsteemi COM-pordist;
- virtuaalne seade tagab täieliku andmete ümbersuunamise reaalsesse seadmesse;
- lihtsa tüngana, mis tagastab alati "OK".
Emulaatori praegune versioon kasutab teist lähenemisviisi - virtuaalne Bluetooth-moodul teostab konfiguratsiooni, mille järel lülitub see põhisüsteemi COM-pordist emulaatori UART-porti andmete puhverserveri režiimile.
Vaatleme võimalust koodi lihtsaks instrumenteerimiseks juhuks, kui mõni perifeeria osa jääb realiseerimata. Näiteks kui DMA-le andmeedastuse juhtimise eest vastutavat taimerit pole loodud (kontroll tehakse funktsioonis ws2812b_wait, raspolojennoy po aadress 0x08006840
), siis jääb püsivara alati ootama lipu lähtestamist hõivatudasub 0x200004C4
mis näitab DMA andmeliini hõivatust:
Saame sellest olukorrast mööda minna lipu käsitsi lähtestamisega hõivatud kohe pärast selle paigaldamist. IDA Pro-s saate luua Pythoni funktsiooni ja kutsuda seda katkestuspunktis ning panna murdepunkti enda koodi pärast lipule väärtuse 1 kirjutamist hõivatud.
Murdepunktide töötleja
Kõigepealt loome IDA-s Pythoni funktsiooni. Menüü Fail – skriptikäsk...
Lisage vasakpoolsesse loendisse uus jupp, andke sellele nimi (näiteks BPT),
Parempoolsele tekstiväljale sisestage funktsiooni kood:
def skip_dma():
print "Skipping wait ws2812..."
value = Byte(0x200004C4)
if value == 1:
PatchDbgByte(0x200004C4, 0)
return False
Pärast seda klõpsake nuppu jooks ja sulgege skripti aken.
Läheme nüüd koodi juurde aadressil 0x0800688A
, määrake katkestuspunkt (klahv F2), muutke seda (kontekstimenüü Muuda murdepunkti...), ärge unustage seada skripti tüübiks Python:
Kui praegune lipu väärtus hõivatud võrdub 1-ga, siis peaksite funktsiooni käivitama skip_dma skripti real:
Kui käivitate püsivara täitmiseks, on murdepunkti töötleja koodi käivitamine näha IDA aknas Väljund rea järgi Skipping wait ws2812...
. Nüüd ei oota püsivara lipu lähtestamist hõivatud.
Koostoime emulaatoriga
Emuleerimine emuleerimise eesmärgil ei tekita tõenäoliselt rõõmu ega rõõmu. Palju huvitavam on see, kui emulaator aitab uurijal näha mälus olevaid andmeid või luua lõimede interaktsiooni.
Näitame teile, kuidas luua dünaamiliselt interaktsiooni RTOS-i ülesannete vahel. Peaksite esmalt peatama koodi täitmise, kui see töötab. Kui lähete funktsiooni bluetooth_task_entry käsu "LED" töötlemisharusse (aadress 0x080057B8
), siis näete, mis esmalt luuakse ja seejärel süsteemijärjekorda saadetakse ledControlQueueHandle mingi sõnum.
Muutujale juurdepääsuks peaksite määrama katkestuspunkti ledControlQueueHandle, raspolojennoy po aadress 0x20000624
ja jätkake koodi täitmist:
Selle tulemusena peatub esmalt aadressil 0x080057CA
enne funktsiooni kutsumist osMailAlloc, seejärel aadressil 0x08005806
enne funktsiooni kutsumist osMailPut, siis mõne aja pärast - aadressile 0x08005BD4
(enne funktsiooni kutsumist osMailGet), mis kuulub funktsiooni leds_task_entry (LED-ülesanne), st ülesanded vahetusid ja nüüd sai LED-ülesanne juhtimise.
Sel lihtsal viisil saate kindlaks teha, kuidas RTOS-i ülesanded üksteisega suhtlevad.
Muidugi võib tegelikkuses ülesannete koostoime olla keerulisem, kuid emulaatori abil muutub selle interaktsiooni jälgimine vähem töömahukaks.
Käivitage Radare2-ga
Ei saa ignoreerida sellist universaalset tööriista nagu Radare2.
Emulaatoriga ühenduse loomiseks r2 abil näeb käsk välja järgmine:
radare2 -A -a arm -b 16 -d gdb://localhost:23946 rhino_fw42k6.elf
Käivitamine on kohe saadaval (dc
) ja peatage täitmine (Ctrl+C).
Kahjuks on hetkel r2-l probleeme riistvaralise gdb-serveri ja mälupaigutusega töötamisel, mistõttu katkestuspunktid ja sammud ei tööta (käsk ds
). Loodame, et see lahendatakse peagi.
Eclipse'iga jooksmine
Üks emulaatori kasutamise võimalustest on arendatava seadme püsivara silumine. Selguse huvides kasutame ka Rhino püsivara. Saate alla laadida püsivara allikad
Kasutame komplekti kuuluvat Eclipse'i IDE-na
Selleks, et emulaator laadiks otse Eclipse'is kompileeritud püsivara, peate lisama parameetri firmware=null
emulaatori käivituskäsku:
binkopycat -g 23946 -n rhino -l user -y modules -p firmware=null,tty_dbg=COM26,tty_bt=COM28
Silumiskonfiguratsiooni seadistamine
Valige rakenduses Eclipse menüü Käivita – silumise konfiguratsioonid... Avanevas aknas jaotises GDB riistvara silumine peate lisama uue konfiguratsiooni, seejärel määrake vahekaardil Peamine silumiseks praegune projekt ja rakendus:
Vahekaardil „Siluja” peate määrama GDB käsu:
${openstm32_compiler_path}arm-none-eabi-gdb
Ja sisestage ka GDB serveriga ühenduse loomiseks vajalikud parameetrid (host ja port):
Vahekaardil "Käivitamine" peate määrama järgmised parameetrid:
- lubada märkeruut Laadi pilt (nii et kokkupandud püsivara pilt laaditakse emulaatorisse);
- lubada märkeruut Laadige sümbolid;
- lisa käivituskäsk:
set $pc = *0x08000004
(seadke arvutiregistri väärtus mälust aadressil0x08000004
- aadress on sinna salvestatud ResetHandler).
Обратите внимание, kui te ei soovi püsivara faili Eclipse'ist alla laadida, siis valikud Laadi pilt и Käivita käsud pole vaja näidata.
Pärast nupul Silu klõpsamist saate töötada silurirežiimis.
- samm-sammult koodi täitmine
- murdepunktidega suhtlemine
Märkus. Eclipse'il on, hmm... mõned veidrused... ja nendega tuleb elada. Näiteks kui siluri käivitamisel kuvatakse teade "0x0 jaoks pole allikat saadaval", käivitage käsk Step (F5)
Selle asemel, et järeldus
Omakoodi emuleerimine on väga huvitav asi. Seadme arendajal on võimalik püsivara siluda ilma tõelise seadmeta. Teadlase jaoks on see võimalus teha dünaamilist koodianalüüsi, mis pole alati võimalik isegi seadmega.
Soovime pakkuda spetsialistidele tööriista, mis on mugav, mõõdukalt lihtne ning mille seadistamine ja käitamine ei nõua palju vaeva ja aega.
Kirjutage kommentaaridesse oma kogemustest riistvaraemulaatorite kasutamisel. Kutsume teid arutlema ja vastame hea meelega küsimustele.
Küsitluses saavad osaleda ainult registreerunud kasutajad.
Milleks sa emulaatorit kasutad?
-
Arendan (silun) püsivara
-
Uurin püsivara
-
Käivitan mänge (Dendi, Sega, PSP)
-
midagi muud (kirjutage kommentaaridesse)
7 kasutajat hääletas. 2 kasutajat jäi erapooletuks.
Millist tarkvara kasutate omakoodi emuleerimiseks?
-
QEMU
-
Ükssarviku mootor
-
Proteus
-
midagi muud (kirjutage kommentaaridesse)
6 kasutajat hääletas. 2 kasutajat jäi erapooletuks.
Mida sooviksite kasutatavas emulaatoris täiustada?
-
Ma tahan kiirust
-
Soovin lihtsat seadistamist/käivitamist
-
Soovin rohkem võimalusi emulaatoriga suhtlemiseks (API, konksud)
-
Olen kõigega rahul
-
midagi muud (kirjutage kommentaaridesse)
8 kasutajat hääletas. 1 kasutaja jäi erapooletuks.
Allikas: www.habr.com