KatWalk C2: ч.4: поиграемся с прошивкой

от автора

Для тех, кто только подключился — я рассказываю про платформу для VR игр, как с ней интегрироваться и как добраться до ее сенсоров напрямую.

Я уже заметил некоторые глюки и проблемы с сенсорами — значит, неплохо было бы их поправить. Но чтобы их поправить, надо понять что они за зверь, и можно ли их как-то поменять. Из-за огромного размера статьи, пришлось разделить и без того маленький рефератик на еще более маленькие кусочки — поэтому сегодня мы просто заглянем внутрь сенсора и научимся менять ему прошивку в простом и удобном виде.

Вскрытие покажет

Изучение чего либо обычно представляет собой хаотичный процесс с заходами с разных сторон, так называемый «метод научного тыка». Никогда заранее не скажешь, где идеальный путь, пока не посмотришь на итог процесса на ретроспективе. Поэтому не следует воспринимать данный опус как руководство к действию, скорее как небольшой обзор на что можно смотреть, да где искать крупицы полезностей. Как правило, всё множество путей помогают друг другу и в конце концов приводят к пониманию.

Вскроем...

Вскроем…

Но что точно хорошо почти всегда (когда есть возможность) — это заглядывание внутрь. Если мы заглянем внутрь сенсора для ног, мы сходу заметим большой модуль, подписанный HY-40R201C. Это BLE5.0 модуль, основанный на TI CC2640R2. То есть это процессор (точнее, два) плюс радиомодуль. Может быть использован как сам по себе, загружая свою прошивку в него, так и в тандеме с внешним процессором.

перевернём на животик...

перевернём на животик…

Других процессоров на плате не видно, на обратной стороне ничего, кроме модуля оптической мыши A9800 не обнаружено.

Вывод: модуль используется напрямую как логический процессор. На плате присутствуют два порта для батарей, но подключена только одна. Есть кнопки Reset и Boot. Теперь мы всегда можем открыть даташит, чтобы понять где какие порты и адреса железа лежат, да знаем что это ARM Cortex M3 с ПЗУ на 128кб, рам на 8кб. Не очень много, но явно хватает.

В отличие от Nordic модуля, которым я воспользовался, поддержка USB напрямую отсутствует. Зато на плате виден CH9326, который совпадает по названию с лежащей в папке гейтвея DLLке.

Таким образом, мы либо узнали, либо подтвердили, что сенсоры общаются через USB-HID конвертер-чип, работают на ARM, и основаны на Texas Instruments SIMPLELINK-CC2640R2-SDK. К SDK еще идёт «Academy», в котором в сжатой форме можно почтитать про BLE, как его готовить внутри СДК, да немного рассмотрено примеров. В любом случае, так как девкита нет, на самом сенсоре поиграться сходу не получится, в остальном придётся пользоваться полнотекстовым поиском внутри установленного SDK.

Что в ROMушке тебе моём

Внешний осмотр это хорошо, но понять, что внутри, просто осмотром не получится. На плате есть пятачки подписанные «V R C M G», где G явно Ground, всё остальное — вопрос. Можно прозвонить, конечно, но… JTAG у меня всё равно нет.

Тут, к счастью, можно вспомнить, что гейтвей умеет прошивать сенсоры. Я когда первый раз их подключал, он мне говорил, что прошивка старая, и надо залить новую. И залил. Вывод — прошивка гейтвею доступна. Обзор глазами файлов в папке гейтвея:

C:\>dir /b /d "C:\Program Files (x86)\KAT Gateway\*bin" loco_ankle_by_embeded.bin loco_receiver_by_embeded.bin loco_sensor_group_application_foot_release_by_embeded_engineer.bin loco_sensor_group_application_waist_release_by_embeded_engineer.bin loco_s_ankle_by_embeded.bin loco_s_foot_by_embeded.bin loco_s_receiver_by_embeded.bin loco_s_waist_by_embeded.bin loco_waist_by_embeded.bin walk_c_foot_by_embeded.bin walk_c_hall_by_embeded.bin walk_c_receiver_by_embeded.bin walk_c_v2_foot_by_embeded.bin walk_c_v2_hall_by_embeded.bin walk_c_v2_receiver_by_embeded.bin

Прикольно, а где C2?!

C:\>dir /b /d "C:\Program Files (x86)\KAT Gateway\*hex" katvr_direction.hex katvr_foot.hex katvr_receiver.hex

Хм. Грузим dotPeek опять, Ctrl+Alt+T, «.hex»… Ха!

поиск по точкагексу

поиск по точкагексу
    byte index = 0;      int num1 = (int) KatvrFirmwareHelper.ch9326_find();     assert(num1 != 0)      int num2 = (int) KatvrFirmwareHelper.ch9326_open(Update_Firmware_Upgrading_Form.vid, Update_Firmware_Upgrading_Form.pid);     assert(num2 != 0)      int num21 = KatvrFirmwareHelper.ch9326_set_gpio(index, (byte) 15, (byte) 15)     assert(num21 != 0)      int num3 = (int) KatvrFirmwareHelper.ch9326_connected(index);     assert(num3 != 0)      int num4 = (int) KatvrFirmwareHelper.flash(_hex_path, device_type, device_state);     assert(num4 == 1)      KatvrFirmwareHelper.ch9326_ClearThreadData();     KatvrFirmwareHelper.close_ch9326();      /* Write MACs of sensors into receiver if we updated receiver */     if (Update_Firmware_Upgrading_Form.deviceType == C2FirmwareUpdaeManager.C2DeviceType.Receiver) {         KATSDKInterfaceHelper.WriteSensorPair(...)     }

Прекрасно, то есть для прошивки нам потребуется пара вызовов из KatvrFirmwareHelper и гекс прошивки. Но в дизасм как правило надо грузить BIN, а не HEX. Впрочем, это решается множеством способов. Я просто запустил WSL:

$ cd /mnt/c/Program\ Files\ \(x86\)/KAT\ Gateway/ $ for k in foot direction receiver; do objcopy --input-target=ihex --output-target=binary katvr_$k.hex katvr_$k.bin; done $ ls -la kat*bin -rwxrwxrwx 1 datacompboy datacompboy 131072 Mar 29 21:46 katvr_direction.bin -rwxrwxrwx 1 datacompboy datacompboy 131072 Mar 29 21:46 katvr_foot.bin -rwxrwxrwx 1 datacompboy datacompboy 131072 Mar 29 21:46 katvr_receiver.bin

Обращаем внимание, что все файлы ровно 128кБ, при этом hex разных размеров, значит, прошивка из нескольких секций, и, вероятно, части в ней отсутствуют или использованы для настроек, или еще что. Просто учтём на будущее.

Кстати, если мы еще не вскрывали сенсор, и не знаем что за процессор, можно попробовать потыкать в бинарничек через binwalk:

$ binwalk --disasm ./katvr_direction.bin   DECIMAL       HEXADECIMAL     DESCRIPTION -------------------------------------------------------------------------------- 0             0x0             ARM executable code, 16-bit (Thumb), little endian, at least 1624 valid instructions

или cpu_rec:

$ python cpu_rec.py ./katvr_direction.bin  ./katvr_direction.bin       full(0x20000)  None        chunk(0x10000;32)   ARMhf

Где мы убеждаемся, что да, это ARM и в основном в Thumb режиме.

Потыкав дополнительно через strings:

$ strings -n 10 katvr_direction.bin inputNormal FinputGyroRv executable N]_]>CNUW]>@FUm `(i0a(}0uh}pu [USQOMKIFCA?<:8 p>"`hBp!`h k +# p(F#p !i"hQ\)pch!i pGpGpGpGpG {unknown-instance-name} {empty-instance-name} F{static-instance-name}
Заметки на полях

Поиск полезных данных никогда не бывает лишним. Например, вот этот вывод strings, в котором есть странные строки — «inputGyroRv» и «inputNormal». Поиск по ним на github дал сходу интересную вещь, что позволило разметить часть функций и структур, которыми пользуется сенсор направления. Для ног однако подобной фкусности не обнаружилось.

Заливка прошивки на ходу

Разглядывание документации на Serial Boot Loader показывает, что бутлоадер просто готов принимать прошивку если в него перезагрузиться, 0x55 синхронизует скорость, в общем, всё как всегда. Мы, впрочем, не можем пользоваться родным прошивальщиком, так как у нас нет последовательного интерфейса — у нас есть HID2Serial, так что прошивка должна идти через него. Но, как мы уже выше выяснили, функции прошивки экспортированы наружу, так что можно просто попробовать вызвать их.

Создадим простой C# проект, куда импортируем KatvrFirmwareHelper и katvr_firmware.dll на которую он ссылается:

    static void Main(string[] args)     {         uint vid = 0xC4F4u;         byte device_state = 0;         byte index = 0;         uint pid = 28471u;         byte device_type = 3;         string hex_path = "C:\\Program Files (x86)\\KAT Gateway\\katvr_foot.hex";          if (KatvrFirmwareHelper.ch9326_find() == 0)         {             Console.WriteLine("ch9326_find failed");             return;         }          if (KatvrFirmwareHelper.ch9326_open(vid, pid) == 0)         {             Console.WriteLine("ch9326_open failed");             return;         }          if (KatvrFirmwareHelper.ch9326_set_gpio(index, (byte)15, (byte)15) == 0)         {             Console.WriteLine("ch9326_set_gpio failed");             return;         }          if (KatvrFirmwareHelper.ch9326_connected(index) == 0)         {             Console.WriteLine("ch9326_connected failed");             return;         }          if (KatvrFirmwareHelper.flash(hex_path, device_type, device_state) != 1)         {             Console.WriteLine("KatvrFirmwareHelper.flash failed");             Console.ReadKey();             return;         }          KatvrFirmwareHelper.ch9326_ClearThreadData();         KatvrFirmwareHelper.close_ch9326();     }

Запустим — в консоли какие-то отладочные распечатки, ошибка. Эм… А, ну да, зажимаем Flash кнопку, тыкаем Reset, запускаем опять — побежали точки по экрану. Через минуты полторы — готово. Сенсор всё еще жив. Правда, мигает левой лампочкой вместо правой, как мигал до этого. Упс. Настройки стерлись.

Патчим прошивку

Но хотелось бы комфортного патчинга. У гидры есть возможность экспортировать текущий файл как Hex или Raw, но экспортируется всё, включая рам и области, которых не было в исходном hex’е. Вывод — надо патчить прямо HEX файл, а как?

Для начала — на чем-то надо тренироваться. Проще всего сделать простой бинарный патч — поменяем «KATVR» строку на «KAT-F», то есть устройство будет себя анонсировать как «KAT-F» (типа Foot/нога). Открываем любым hex редактором, например, WinHex, и правим. Затем берём дифф:

> fc.exe /b .\katvr_foot_orig.bin .\katvr_foot.bin Comparing files .\katvr_foot_orig.bin and .\KATVR_FOOT.BIN 000129E9: 56 2D 000129EA: 52 46

Отлично, у нас есть патч, который легко прочитать как есть или сконвертировать с C#:

    static readonly PatchEntry[] PatchFoot = {         ( 0x000129e9, 0x56, 0x2D ), // V => -         ( 0x000129ea, 0x52, 0x46 ), // R => F     };

Из вкусных трюков, которые удалось найти для C# (я очень редко его трогаю) — приведение тупла к структуре, позволяющая сократить многабукав в статических массивах. Просто структуру автоматически он не разбирает, но мы можем добавить конструктор и implicit operator который вызовет приведение:

    struct PatchEntry {         readonly public int addr;         readonly public byte orig;         readonly public byte patch;         public PatchEntry(int addr, byte orig, byte patch) {             this.addr = addr;             this.orig = orig;             this.patch = patch;          }         public static implicit operator PatchEntry((int addr, uint orig, uint patch) tuple) {             return new PatchEntry(tuple.addr, (byte)tuple.orig, (byte)tuple.patch);          }     };

Теперь, когда у нас есть патч в удобном машино-читаемом виде, его надо наложить на HEX.

Возьмём HexIO библиотеку и накидаем фиксилку. Знать надо не много: следим за текущим адресом, если прочитанная строка включает в себя адрес патча — исправляем. К сожалению, HexIO не следит за изменениями в структуре, и не обновляет контрольную сумму — пришлось выкрутиться через пересоздание записи. Не очень красиво, но быстро и работает:

    static string PatchHex(string input, PatchEntry[] patch)     {         string output = System.IO.Path.GetTempFileName() + ".hex";          IIntelHexStreamReader hexInput = new IntelHexStreamReader(input);         using (StreamWriter hexOutput = new StreamWriter(output))         {             uint offset = 0;             var patch_i = 0;             do             {                 IntelHexRecord rec = hexInput.ReadHexRecord();                 if (rec.RecordType == IntelHexRecordType.Data)                 {                     while (patch_i < patch.Length) {                         var pe = patch[patch_i];                         if (pe.addr >= offset + rec.Offset)                         {                             long idx = pe.addr - offset - rec.Offset;                             if (idx >= rec.RecordLength)                             {                                 break;                             }                             if (rec.Data[(int)idx] != pe.orig)                             {                                 Console.WriteLine("File data doesn't match expected.");                                 throw new InvalidDataException();                             }                             rec.Data[(int)idx] = pe.patch;                             rec = new IntelHexRecord(rec.Offset, rec.RecordType, rec.Data);                             patch_i++;                         }                         else                         {                             Console.WriteLine("Can't apply patch to a gap.");                             throw new InvalidDataException();                         }                     };                 }                 else if (rec.RecordType == IntelHexRecordType.ExtendedLinearAddress && rec.RecordLength == 2)                 {                     offset = (uint)((rec.Data[0] << 8 | rec.Data[1]) << 16);                 }                 else if (rec.RecordType == IntelHexRecordType.EndOfFile)                 {                     if (patch_i < patch.Length)                     {                         Console.WriteLine("Not all patch was applied!");                         throw new InvalidDataException();                     }                 }                 else                 {                     Console.WriteLine(rec.ToString());                     throw new InvalidDataException();                 }                 hexOutput.WriteLine(rec.ToHexRecordString());             } while (!hexInput.State.Eof);         };          return output;     }

Теперь применим патч и используем патченный hex вместо оригинала:

    static void Main(string[] args)     {         string orig_hex = "C:\\Program Files (x86)\\KAT Gateway\\katvr_foot.hex";         string hex_path = PatchHex(orig_hex, PatchFoot);         ...     }

Прошиваем, смотрим, что видно в Bluetooth окружении: ха! есть «KAT-F» устройство.

Заметки на полях:

Когда позднее потребовалось добавить немного кода в прошивку, пришлось повозиться со вставкой новых строк. Для этого вместо ошибки в Else вместо ошибки про Gap добавил кусок, напрямую конструирующий новые записи и выводящий их сразу:

    } else {         int start = pe.addr;         List<byte> data = new List<byte>();         do         {             data.Add(patch[patch_i++].patch);         } while (patch_i < patch.Length &&                     patch[patch_i].addr - 1 == patch[patch_i-1].addr &&                     patch[patch_i].addr - start < 0x20);         if (start - offset >= 0x10000)         {             Console.WriteLine("Can't inject a record: cross boundary");             throw new InvalidDataException();         }         var newrec = new IntelHexRecord((ushort)(start - offset), rec.RecordType, data);         hexOutput.WriteLine(newrec.ToHexRecordString());     }

User-friendly патчинг

Так как я хочу сделать патчи, которыми могут пользоваться люди, нужно оформить прошивку в виде скрипта.

В принципе, как уже было сказано, C# переносится в PowerShell тривиально:

param (     [string]$firmware = "",     [int]$dvid = 0xC4F4,     [int]$dpid = 28471,     [int]$type = 3,     [int]$index = 0 )  Add-Type -Path "C:\Program Files (x86)\KAT Gateway\KAT_WalkC2_Dx.dll"  if ($firmware -eq "") {   $firmware = $katPath + "C:\Program Files (x86)\KAT Gateway\katvr_foot.hex" } Write-Host "Want to flash $firmware"  if ([KAT_WalkC2_Dx.KatvrFirmwareHelper]::ch9326_find() -eq 0) {     throw "ch9326_find failed" } if ([KAT_WalkC2_Dx.KatvrFirmwareHelper]::ch9326_open($dvid, $dpid) -eq 0) {     throw "ch9326_open failed" } if ([KAT_WalkC2_Dx.KatvrFirmwareHelper]::ch9326_set_gpio($index, 15, 15) -eq 0) {     throw "ch9326_set_gpio failed" } if ([KAT_WalkC2_Dx.KatvrFirmwareHelper]::ch9326_connected($index) -eq 0) {     throw "ch9326_connected failed" } if ([KAT_WalkC2_Dx.KatvrFirmwareHelper]::flash($firmware, $type, 0) -ne 1) {     throw "KatvrFirmwareHelper.flash failed" } [KAT_WalkC2_Dx.KatvrFirmwareHelper]::ch9326_ClearThreadData() [KAT_WalkC2_Dx.KatvrFirmwareHelper]::close_ch9326()

Уже неплохо, позволяет прошить любой патч или откатить прошивку. Но вот после прошивки сенсор теряет его настройку (левый-правый), значит, надо сделать восстановление режима спаривания.

Позаимствовав код определения спаривания из прошлых скриптов, по сути надо только добавить поиск сенсора и отправку команды

Причем и ReadDeviceId и WriteDeviceId уже есть готовые:

... $id = -1 [IBizLibrary.KATSDKInterfaceHelper]::ReadDeviceId($dev.serialNumber, [ref]$id) $sensor = New-Object IBizLibrary.KATSDKInterfaceHelper+sensorInformation [IBizLibrary.KATSDKInterfaceHelper]::GetSensorInformation([ref]$sensor, $dev.serialNumber) $leftmac = [IBizLibrary.KATSDKInterfaceHelper]::receiverPairingInfoSave.ReceiverPairingByte[7..12] $rightmac = [IBizLibrary.KATSDKInterfaceHelper]::receiverPairingInfoSave.ReceiverPairingByte[13..19] if(-not(Compare-Object $leftmac $sensor.mac)) {     [IBizLibrary.KATSDKInterfaceHelper]::WriteDeviceId($dev.serialNumber, 2)     Write-Host "Made the sensor to be Left Foot" } elseif(-not(Compare-Object $rightmac $sensor.mac)) {     [IBizLibrary.KATSDKInterfaceHelper]::WriteDeviceId($dev.serialNumber, 3)     Write-Host "Made the sensor to be Right Foot" } else {     throw "The sensor's mac is not paired to the treadmill" }

Если добавить еще cmd батнички:

:: restore-foot.cmd powershell.exe -ExecutionPolicy Bypass -File flush-feet-sensor.ps1  :: update-foot.cmd powershell.exe -ExecutionPolicy Bypass -File flush-feet-sensor.ps1 --firmware .\my-foot.hex

То пользователю надо будет только запустить скрипт.

Патчим патчи, чтобы патчить патчи

Строго говоря мы не владеем исходной прошивкой, а потому распространять её как-то не оч. С другой стороны, у всех пользователей уже стоит гейтвей, то есть мы можем просто использовать исходную прошивку — надо только наложить таки патч поверх неё. Хотелось бы избежать бинарников или слишком сложного скрипта (можно было бы перенести всю логику HEX патча с C# на PowerShell). Вывод — надо сделать простой патчинг.

Иначе говоря, чтобы пропатчить патч, надо наложить патч. Ну вы поняли, да?

We need to go deeper

We need to go deeper

У нас теперь есть два HEXа: оригинальный, и полученный после наложения на него бинарного патча. Можно взять текстовый diff между ними:

> fc.exe /l /n 'C:\Program Files (x86)\KAT Gateway\katvr_foot.hex' patch.hex Comparing files C:\PROGRAM FILES (X86)\KAT GATEWAY\katvr_foot.hex and PATCH.HEX ***** C:\PROGRAM FILES (X86)\KAT GATEWAY\katvr_foot.hex  2463:  :2029C80000010203AEC2E6ED0001C3AA9D00F802CF01060302040F02013F050609FF4B41D2  2464:  :2029E8005456520512089F000900020A039F1902AF9607B039043D4FF703636505771064CC  2465:  :202A0800115ED1F9E86567676215F40000E979FA175FBD027BA63075FFFF79BF19070080C2 ***** PATCH.HEX  2463:  :2029C80000010203AEC2E6ED0001C3AA9D00F802CF01060302040F02013F050609FF4B41D2  2464:  :2029E800542D460512089F000900020A039F1902AF9607B039043D4FF70363650577106401  2465:  :202A0800115ED1F9E86567676215F40000E979FA175FBD027BA63075FFFF79BF19070080C2 *****

Вот только как его наложить? Ну то есть diff есть (fc.exe) а вот patch нету! Что ж, так как формат патча прост и для нашего случая не требуется интеллектуального наложения (нужно чтобы он применился 1-в-1, это еще подтвердит что исходная прошивка правильная), то можно сделать просто: читаем файл, для каждой строки в исходном проверяем равенство, для каждой строки выходной просто печатаем её.

А можно вообще, превратить этот дифф в скрипт, который будет прямо патчить. Не знаю что проще, если честно, но я пошел вторым путём (если честно — чтобы не возиться с чтением двух файлов, а просто работать как обработчик пайпа). Конвертер делается простой стейт машиной:

  • «Comparing files» строка => выводим заголовок, переходим в ожидание патча

  • «*****» в ожидании патча => переходим в ожидание строк исходного

  • «*****» в ожидании строк исходного => переходим в ожидание патченных строк

  • другая строка в ожидании строк исходного => печатаем if на номер строки и её содержимое

  • «*****» в ожидании патченных строк => переходим в ожидание патча

  • срока в ожидании патченных строк => печатаем её

Вроде просто.

Из важных вещей с которыми пришлось познакомиться: по умолчанию редирект работает не в той кодировке, что было на входе и не в utf8, а в utf-16. Так что приходится редирект отсылать в Out-File -Encoding Ascii. Но это не сильно мешает.

Итак, теперь можно скормить ему патч, и посмотреть, что получилось:

> fc.exe /l /n 'C:\Program Files (x86)\KAT Gateway\katvr_foot.hex' patch.hex | .\fc-text-to-patcher.ps1 | Out-File -Filepath foot-patch.ps1 -Encoding Ascii > cat .\foot-patch.ps1 $in_line = 0 $Input | ForEach-Object {   $in_line++   $skip = 0   if ($in_line -eq  2463) {     if ($_ -ne ':2029C80000010203AEC2E6ED0001C3AA9D00F802CF01060302040F02013F050609FF4B41D2') { throw 'File content mismatch'; }     $skip = 1   }   if ($in_line -eq  2464) {     if ($_ -ne ':2029E8005456520512089F000900020A039F1902AF9607B039043D4FF703636505771064CC') { throw 'File content mismatch'; }     $skip = 1   }   if ($in_line -eq  2465) {     if ($_ -ne ':202A0800115ED1F9E86567676215F40000E979FA175FBD027BA63075FFFF79BF19070080C2') { throw 'File content mismatch'; }     $skip = 1   }   if ($in_line -eq  2465) {     Write-Output ':2029C80000010203AEC2E6ED0001C3AA9D00F802CF01060302040F02013F050609FF4B41D2'   }   if ($in_line -eq  2465) {     Write-Output ':2029E800542D460512089F000900020A039F1902AF9607B039043D4FF70363650577106401'   }   if ($in_line -eq  2465) {     Write-Output ':202A0800115ED1F9E86567676215F40000E979FA175FBD027BA63075FFFF79BF19070080C2'   }   if ($skip -eq 0) {     Write-Output $_   } }

Отлично! Проверим:

> cat 'C:\Program Files (x86)\KAT Gateway\katvr_foot.hex' | .\foot-patch.ps1 | Out-File -Filepath out.hex -Encoding ascii > fc.exe .\out.hex .\patch.hex Comparing files .\out.hex and .\PATCH.HEX FC: no differences encountered

Последние штрихи

Поправим скрипт, добавим вызов восстановления и код для наложения патча. Поймём, что в папке scripts образовалась каша, так что… Время рефакторинга! оставляем нормальные имена файлов для .cmd — для пользователей, чтоб в начале были.

Оба рабочих скрипта (прошивки и восстановления настроек) перенесём в конец, обозвав их y-$script.ps1. А патчи положим в z_patch_$sensor.ps1.

Еще заметка на полях:

Вызвать скрипт косвенно через переменную — амперсанд. Ну то есть наложение патча идёт через:

$newfw = $ENV:TEMP + "\katvr_" + $orig + "_patch.hex" $patchscript = ".\z_patch_" + $patch + ".ps1" Get-Content $firmware | & $patchscript | Out-File -FilePath $newfw -Encoding ascii

Полученную кашицу уже можно считать финалом.

В следующей серии

Теперь, когда мы подготовили материальную базу, можно заняться самой интересной частью — разбором паршивок и правкой их по-настоящему, а не просто прямой правкой констант. Не переключайтесь!

Ссылки

  • Часть 1: «Играем с платформой» на [Habr], [Medium] и [LinkedIn].

  • Часть 2: «Начинаем погружение» на [Habr], [Medium] и [LinkedIn].

  • Часть 3: «Отрезаем провод» на [Habr], [Medium] и [LinkedIn].

  • Часть 4: «Играемся с прошивкой» на [Habr], [Medium] и [LinkedIn].

  • Часть 5: «Оверклокинг и багфиксинг» на [Habr], [Medium] и [LinkedIn].


ссылка на оригинал статьи https://habr.com/ru/articles/804327/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *