Как стать автором
Обновить

Контроллер CH579. Начинаем работу и избавляемся от закрытой сетевой библиотеки

Время на прочтение 14 мин
Количество просмотров 13K


Сегодняшняя статья – не восклицание: «Смотрите, какой мне красивый проц попался». Это скорее просто упорядочивание накопленных сведений о конкретном процессоре CH579. Вдруг кому пригодится. Ну, и, если через годы мне потребуется, я сам буду восстанавливать знания по этой статье. Просто так получилось, что по проекту Заказчик велел освоить его… Это недорогой микроконтроллер на базе ядра Cortex M0. После освоения Заказчик же и сказал, что мы всё будем делать на китайском клоне STM32. Характеристики же самобытного CH579 он признал недостаточными.

Но с другой стороны… Сегодня эта микросхема стоит 120-150 рублей за штуку на Ali Express. А у неё имеется не только встроенный PHY для десятимегабитного Ethernet, но и всё для работы по BLE. По-моему, не самая плохая цена. Кажется, для Интернета вещей может пригодиться. Но это цены за микросхему. Макетки безобразно дороги.

Ещё на многих форумах народ возмущается, что сетевая библиотека для этого контроллера поставляется не в виде исходных кодов. Китайцы отвечают, что они не собираются ничего менять. Наш Заказчик тоже возмущался. Поэтому в статье я покажу, каким путём мы шли, чтобы сделать исходный код. Любой сможет повторить этот путь буквально за пару дней. Там скорее обидно, чем сложно.

В общем, сегодня мы пробежимся по работе с китайским контроллером CH579. Будет немного практических сведений и немного мемуаров, как пришлось вскрывать библиотеки.

1. Включаем JTAG


Я не люблю современные процессоры, которые невозможно интерактивно отлаживать. Был у меня цикл статей, в которых я рассматривал просто обалденную макетную плату… Но в итоге, я её забраковал именно за то, что разработчик принципиально не вывел разъём JTAG. Мало того, он сделал всё, чтобы никто к этому каналу не подключился. Ну и как хочет! Хоть процессор и сверхмощный, а библиотеки там – очень вкусные, но без интерактивной отладки работа превращается в мучение. Ссылки на тот цикл – под катом.


Подключив макетку с CH579, я очень испугался, что меня ждёт что-то такое. Причём отладочный порт-то на ней имеется. Но он не работал. Порывшись по форумам, я узнал, что китайские производители очень переживают, как бы мы, пользователи не убили контроллер, именно поэтому они отключили потенциально опасный порт… Но дали возможность включить его.

Самый простой путь – найти программу WCHISPTool и всё сделать в ней… Правда, самый-то он самый, но помучиться всё равно придётся. Не знаю, как сейчас, а осенью, когда я вёл работы… Заходим на страничку, посвящённую контроллеру CH579, скачиваем оттуда WCHISPTool… И получаем старую версию, у которой нужный нам пункт меню всегда отключён. К счастью, программа универсальна, поэтому её можно скачать и со страниц других контроллеров той же фирмы. Точно работает версия 3.3.



Подключаем макетку через имеющийся на ней порт USB при зажатой кнопке Program (только так, нажать Program и стукнуть по Reset, как это сделано у большинства производителей, тут не сработает). Программа сразу определяет, что появилось устройство, готовое к прошиванию. Никаких действий для этого от нас не требуется.



Чтобы включить JTAG, надо взвести флажок Enable Sumilat Interface (орфография оригинала сохранена). В старых версиях программы этот флажок всегда серый, взвести его было нельзя. В версии 3.3, он активен.



Взвели – появилась новая кнопка. В ней уже орфография более привычная.



Нажимаем её, даём согласие на выполнение действий… Всё! Теперь можно прошивать и отлаживать кристалл через отладчик SWD. Ура! Но работа через ISP (In System Programming, то есть, через разъём USB на макетке) не пропадает. Конкретно сейчас мы с коллегой осваиваем работу с BLE, и заливаем скачанные с GitHub готовые HEX файлы именно силами ISP. То есть, наличие SWD не запрещает работу через ISP. Больше вариантов, хороших и разных!

2. Выбор отладочного адаптера


Сами китайцы поставляют готовые примеры, которые могут быть собраны и отлажены в среде Keil через любой адаптер. Вот тут я работаю через старый добрый ST LINK:



Отладка первого попавшегося китайского примера прекрасно идёт:



Всё здорово, кроме одного. Наш Заказчик любит Linux. А Keil может работать только под Windows. Ну, и открытый исходный код наш Заказчик любит. Поэтому пришлось искать другую конфигурацию. К слову, мне самому Keil вполне нравится, но китайские примеры, работающие с Bluetooth, в разы превышают бесплатный размер 32К кода. Так что даже в этом случае будет нужно что-то бесплатное.

При поиске альтернативного решения в голове надо держать две независимые задачи. Отладка – ну, у нас ядро Cortex M0. Отлаживать можно через тот же OpenOCD и любой поддерживаемый им адаптер. Но беда в том, что перед отладкой, код надо «прошить» во флэш. А процесс прошивки хоть и хорошо документирован, но совершенно не совместим с прочими контроллерами. То есть, близок локоть, да не укусишь. Нет в том же OpenOCD готового загрузчика, который бы залил код во флэшку. По крайней мере, на момент работ (осень 2022) не было.

Большинство форумов пестрит идеей: «Напишите свой загрузчик за пару дней, да внедрите в OpenOCD». Идея абсолютно верная и стопроцентно выигрышная, но Заказчик часы на эти работы не одобрил (пара дней-то — пара дней, но это при наличии опыта написания загрузчиков, так что с учётом накопления опыта, вышло бы чуть больше). Поэтому вычёркиваем.

В целом, производитель предлагает среду разработки MounRiver Studio, которая является доработкой Eclipse и позволяет вести отладку через тот же адаптер WCH-Link. Но так получилось, что никто не подумал, что без этого адаптера не обойтись, поэтому он не был заказан ни нами, ни Заказчиком, а сроки поджимали.

Оказалось, что OpenOCD внутри этой среды модифицирована. И если найти её исходники, то выяснится, что при работе с CH579 там жёстко вбиты USB-команды именно от адаптера WCH-Link. Менять нельзя. По крайней мере, осенью было так. Если что – исходники живут тут, все желающие могут перепроверить мои слова: GitHub — kprasadvnsi/riscv-openocd-wch: OpenOCD source code for CH32V series MCUs released by Mounriver IDE.

Можно было взять готовый загрузчик флэша из этого кода, но модифицировать его так, чтобы он попадал в контроллер через любой JTAG/SWD адаптер. Если бы Заказчик одобрил часы, разумеется… Но был найден более интересный вариант. В целом, его суть описана тут: iot-fan_at_cnblogs: iot-fan 在cnblogs 的档案仓库 — Gitee.com.

Там рассказывается, как модифицировать конфигурационные файлы программной поддержки адаптера JLINK. Там же странице есть готовые двоичные файлы загрузчиков, на которые ссылаются модифицированные конфигурационные файлы. После того, как файлы на диске модифицированы, JLINK начинает работать с контроллером CH579 без каких-либо проблем. Вот при такой конфигурации, обычная Eclipse начинает процесс прошивки и отладки:



А так как JLINK – универсальный отладчик, проблему можно объявить решённой. У меня этих адаптеров три штуки, разных версий. У Заказчика он тоже имеется.

Итак, кроме весьма специфичного WCH-Link, отлаживать можно или в Keil через любой адаптер, или через JLINK, немного модифицировав ему конфигурационные файлы. Ну, а JLINK понимают многие среды разработки. На всякий случай, полный архив с необходимыми файлами и подробной документацией, можно скачать здесь.

3. Библиотеки


Ну, ладно. Мы теперь уверены, что сможем загрузить и отладить «прошивку». А раз так, то можно начинать её делать. Если использовать Keil, то просто ставим в него пакет для поддержки нашей системы да начинаем открывать примеры. Это не тема для статьи на Хабре. В статье на Хабре должно быть что-то этакое. Вот наш Заказчик хотел кроссплатформенную работу, о ней и поговорим. В целом, процессорное ядро там Cortex M0, так что подойдёт любая среда разработки, которая умеет собирать код под него. Скажем, Eclipse.

Интереснее, как подключить заголовочные файлы, описывающие оборудование. Есть такой репозиторий на GitHub: GitHub — SoCXin/CH579: S4 L2 R3: WCH Cortex-M0 ETH/BLE SoC(CH579/CH578/CH577). Всё можно добыть на нём. В частности, код для работы с аппаратурой живёт тут.



Рядом есть также startup код, скрипт компоновщика и другие полезные вещи:



Startup файл там специфически, от Кейла, но они все похожи. Отличие обычно состоит в перечне векторов прерываний, можно просто скопировать их отсюда в любой другой типовой от вашей любимой среды, оставив остальное прежним. В качестве основы, подойдёт любой Startup от процессора на базе Cortex M0.

Мы с некоторых пор вообще заменяем все ассемблерные startup файлы сишным вариантом. Дело в том, что при включённой опции Link Time Optimization (LTO) вектора прерываний, описанные в ассемблерном Startup файле, плохо перекрываются нашими собственными версиями.

Добавляешь свой – не забудь закомментировать исходный. Неудобно. Пробовал искать решения – все про проблему пишут, но никто не говорит, как её решить. А кто говорит — те решения не помогают. Weak-функции же, написанные на чистых Сях прекрасно перекрываются хоть при отключённой, хоть при включённой опции LTO. В этом случае даже тип процессора не важен. Только специфичные вектора прерываний помещаем (как я уже сказал, их мы берём из того Кейловского startup кода), а остальное компилятор подгонит по месту сам.

4. Работа с Ethernet


4.1 Общие сведения


Ну всё. Хоть через Keil, хоть через иную среду разработки, совместимую с OpenOCD или JLINK, мы можем работать. Пора приступать к поставленным задачам. А задача была простая – освоить работу с протоколом UDP, причём всенепременно так, чтобы всё было с исходным кодом. Прекрасно! Вернее, не совсем прекрасно. Возвращаемся к рисунку с перечнем выданных нам аппаратных библиотек и не видим там ничего, связанного с сетью. В заголовочных файлах тоже ничего не встречаем. Но вот же целый каталог сетевых примеров!



Как же они работают с сетью? А вот так:



Все сетевые функции поставляются в виде закрытой библиотеки! Заглянем внутрь:



Добрые китайцы собрали для нас библиотеку lwIP… Библиотека, конечно, мощная и надёжная. Но обратной стороной универсальности получаем хорошую среднюю оптимизацию, но не пиковую. Чтобы выжать все соки, библиотека обычно конфигурируется на уровне исходного кода, который нам не выдан.

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

В общем, если выше я жаловался на то, что Заказчик не согласовывал часы на разработку собственных загрузчиков, так как считал, что надо найти готовое решение, то вот тут – наоборот. Готовые решения совершенно неприемлемы для него. Отлично! Лезем смотреть, что к чему.

4.2 Где черпаем вдохновение


Документация на Ethernet у STM32 занимает много страниц. Вот я сейчас проверил. Для STM32F4 – целых 118 листов. Число регистров там просто зашкаливает. Число возможных комбинаций настроек – просто запредельное. При этом, там совершенно ничего не говорится про микросхему PHY. Её документацию надо изучать отдельно. Чтобы неподготовленному человеку всё понять, нужно потратить много времени и провести массу экспериментов.

Для CH579 же картина совершенно другая. Весь уровень Ethernet, включая работу с PHY, описан на семи листах. Обладая сегодняшними знаниями, я скажу, что описание там вполне себе достаточное. Но на поиск некоторых вещей ушло бы достаточно много времени, поэтому было решено осмотреть фирменную библиотеку изнутри.

В быту я люблю дизассемблер IDA, но тут не быт, а работа. В общем, чего-то захотелось поиграть в Гидру. Во-первых, она официально бесплатная, а во-вторых, на выходе там можно получить набор Сишных функций, так как у неё первичен не дизассемблер, а декомпилятор. Отчего бы не попробовать? В целом, кто хочет сделать быстрый старт, я нашёл очень полезное видео, которое показывает, как же эту Гидру установить и запустить: https://www.youtube.com/watch?v=Vm_VDJ5nxDk

4.3 Осмотр библиотеки


Как оказалось, библиотека внутри имеет многоуровневую структуру. Есть уровень, обслуживающий работу с железом, есть промежуточный уровень, а уже lwIP находится поверх него. Ну и замечательно! Нам надо только подглядеть инициализацию, да приём с передачей. Это всё живёт тут:



Остальные объектные файлы находятся на более высоком уровне и обращаются к функциям из модуля eth.o. Ну и замечательно! Смотрим его!

Там сразу видны функции, которые нам предстоит разобрать:



Код каждой функции занимает экран-другой. И сравнивая их с документацией, мы сразу видим, что именно авторы документации нам недосказали.

4.4 Очередь приёма


Кто работал с Ethernet в STM32, тот знает, что там как передача, так и приём могут обслуживаться в режиме очереди. Причём на передачу я не видел, чтобы кто-то её использовал, а вот на приём – да. Выделяется память, адреса буферов кладутся в дескрипторы, дальше прикладной уровень потихонечку входящие данные обрабатывает, а аппаратура в это время ведёт приём следующих пакетов. Красота!

В CH579 такой красоты нет. Давайте посмотрим на примере приёмного буфера. Он один, и всё тут!



Заглянув в библиотеку, мы выясним, что очередь организуется чисто программно. Внутри обработчика события «пришёл пакет» (а это событие вызывается из обработчика прерывания) мы видим такой код:

void CH57xMACRxSuccHandle(void)

{
  int iVar1;
  uint uVar2;
  
  *(undefined4 *)(RxCtrl + RxCtrl._184_4_ * 4) = 2;
                    /* Receive Length Register */
  *(uint *)(RxCtrl + RxCtrl._184_4_ * 4 + 0x3c) = (uint)_R16_ETH_ERXLN;
  iVar1 = CH57xCfg;
  *(undefined **)(RxCtrl + RxCtrl._184_4_ * 4 + 0x78) = &CH57xMACRxBuf + CH57xCfg * RxCtrl._184_4_;
  RxCtrl._188_4_ = RxCtrl._188_4_ + 1;
  RxCtrl._184_4_ = RxCtrl._184_4_ + 1;
  uVar2 = (uint)(byte)RxQueueEntries;
  if (uVar2 <= RxCtrl._184_4_) {
    RxCtrl._184_4_ = 0;
  }
  *(undefined4 *)(RxCtrl + RxCtrl._184_4_ * 4) = 1;
  if (uVar2 * 3 < RxCtrl._188_4_) {
    RxCtrl._188_4_ = uVar2;
  }
  RxCtrl._192_4_ = 1;
  _R16_ETH_ERXST__R32_ETH_RX = (short)iVar1 * (short)RxCtrl._184_4_ + 0x1008;
  return;
}

В течение функции вычисляется адрес нового приёмного буфера, а последняя строка – настраивает аппаратуру на новый адрес. Проблемы «Не успеем настроиться, а новый пакет уже поедет» не возникает. Сеть десятимегабитная, времени на переключение у нас достаточно. Кстати, обратите внимание, адрес – шестнадцатиразрядный! Так что буфер может располагаться только в ОЗУ. То же касается и буфера на передачу, так что константы из области кода посылать нельзя.



4.5 Больно умный декомпилятор


Будьте осторожны. Гидра будет что-то додумывать за вас. Взял я, не глядя, код инициализации. Собрал, запустил – не работает. Полдня пробился, пока заметил. Вот так выглядит код, полученный от Гидры с настройками по умолчанию:



А вот так – дизассемблированный код, из которого он получился:



И только сильно ниже имеется:

movs       r3,#0x0
strb       r3,[r1,#0x0]=>R8_SAFE_ACCESS_SIG

Перед нами ни что иное, как выдача в аппаратуру хитрого пароля, открывающего на некоторое время особо критичные порты на запись. Надо послать именно последовательность 0x57, 0xa8. Тогда ряд портов откроется. А закроются они хоть после записи любого другого значения в порт R8_SAFE_ACCESS_SIG, хоть по прошествии скольки-то там тактов. А наш код сразу туда кладёт ноль. Нет пароля – нет настройки ряда портов. Вот ничего и не работает! Почему так вышло?

Этот больно умный декомпилятор понимает, что первые два занесения констант 0x57 и 0xa8 не нужны. Он считает, что нужна только последняя запись в переменную. Я бы ещё понял, если бы мне это сказал компилятор, да и то при ненулевом уровне оптимизации… Но тут… Тут декомпилятор! Какое твоё декомпиляторово дело? Восстанавливай мне логику программы и не выделывайся! Но нет.

В общем, пришлось области портов присвоить дополнительный атрибут volatile. Выкидывать строки Гидра перестала, но зато восстановленный код стал выглядеть вот так:



Читаемость просто нулевая! Ну, да ладно! Для случая, когда у нас функции занимают один-два экрана, можно потерпеть. Но в целом – я возмущён. Только кто меня спрашивает?

Собственно, после того, как функции инициализации, чтения и записи (а функция чтения частично живёт в обработчике прерывания, так как именно там переключаются буфера входной очереди) готовы, можно применять любую готовую сетевую библиотеку. Из опыта, на подготовку достаточно пары дней.

4.6 Пара слов о заголовочных файлах


Хоть для контроллера не прилагается никаких заголовочных файлов для работы с Ethernet, базовые порты описаны в файле CH579-master\src\EXAM\SRC\StdPeriphDriver\inc\CH579SFR.h.



Для комфортного написания своей библиотеки, этого более-менее достаточно. Хотелось бы большего, но будем довольствоваться тем, что есть… Для BLE, кстати, и такого нет.

5. Горе от выравнивания


Кто работает с STM32F1XX или STM32F4, тот про проблему выравнивания наверняка и не слышал. Нет там такой проблемы, так как ядра Cortex M3 и Cortex M4, если нужно, автоматически заменят одну транзакцию шины на несколько.

Когда мы осваивали контроллер CH32V307 с ядром RISC-V, нам пришлось поправить несколько мест, где разработчик нашей UDP-библиотеки весьма вольно берёт 32-битные значения из полей, адреса которых не выровнены на 32 бита. Он писал её для STM32F4, ему было проще. Но в целом, у CH32V307 Ethernet контроллер совместим с STM32, поэтому глобальной проблемы выравнивания тоже не возникает. Только мелочи со служебной частью сетевого пакета.

В CH579 эта проблема вылезает в полный рост. Дело в том, что сеть проектировалась во времена 16-битных ЭВМ. Поэтому у UDP-пакетов блок Payload (данные пользователя) смещён на 42 байта от его начала. Ну, то есть, на 0x2A. То есть, если пакет принят в память с выравниванием на 32 бита, блок данных пользователя гарантированно не будет выровнен на 32 бита.

Ну, скажем… Приняли мы пакет на адрес 0x1234. Тогда с 0x1234 по 0x125D будут идти служебные поля, а с адреса 0x125E (ну, то есть, 0x1234 + 0x2A) пойдут пользовательские данные. Этот адрес выровнен на 16 бит, но не выровнен на 32.

Чтобы избежать такой проблемы, в STM32 можно было выделить буфер вот так:

uint8_t tx_buf_aligned[1524 + 2] __attribute__((aligned(4)));
uint8_t *tx_buf = tx_buf_aligned + 2; // Aligns Ethernet frame payload at 4 bytes

И тогда служебные поля окажутся сдвинуты относительно 32-битных слов… Но многие из них – вполне себе 16-битные, так что это не страшно. Зато все данные пользователя будут выровнены именно на 32 бита. Красота!

В CH579 так делать нельзя. Особенности DMA таковы, что все Ethernet пакеты должны быть выровнены на 32 бита. Это явно прописано в документации. А процессорное ядро там Cortex M0. У него проблемы с выравниванием точно такие же, как и у RISC-V. Хочешь считать 32-битное слово – обращайся к адресам, которые делятся нацело на 4! А блок данных пользователя точно будет сдвинут…

В общем, всё грустно. Если используется какой-то свой протокол, то можно выкрутиться, учтя данную особенность. Размещать данные со сдвигом относительно блока Payload. Но это же костыль! Привязка к конкретной архитектуре!

Ну, и можно просто не выделываться, а копировать данные пользователя в другой буфер, уже обеспечивая выравнивание. Только в этом случае, обычный Loopback тест даёт скорость около трёх мегабит в секунду. С особыми ухищрениями, нам удалось поднять скорость до восьми-девяти мегабит в секунду, но не для любого случая, и всё равно даже не до десяти. Это связано с тем, что копирование буфера занимает достаточно много времени. А он копируется и после приёма, и перед передачей.

Вот так выглядит на осциллографе процесс передачи одного пакета от ПК в устройство и отправки его назад в режиме «запрос-ответ».



0,108 мс – это задержка на стороне ПК (работа тестовой программы). Жёлтый луч – сигнал на линии Rx устройства (по ней данные бегут от ПК к устройству). 1,36 мс – задержка внутри нашего устройства. Ну, и голубой луч – линия Tx (данные от устройства к ПК). При такой схеме (отсутствие конвейеризации), конечно, производительность выше пяти мегабит не получим даже без задержек. При нулевых задержках половину времени данные бегут туда, половину – обратно.

Но как видим, больше трети времени занимает не просто обработка, а банальное копирование данных. Это было выявлено в результате детального профилирования всех участков «прошивки». И наши «не более пяти» превращаются в «не более 10/3 = 3.3». Увы.

Конвейеризация улучшает работу, но не всегда допустима протоколом. И всё равно не позволяет занулить просадку скорости даже при десятимегабитной сети. Опять же, даже если снаружи всё красиво, то пока идёт копирование, прошивка не может заниматься полезными делами. Она занята копированием!

Хотя… Это нашему Заказчику была нужна производительность. Скажем, умной розетке она нужна не сильно, а вот каждые 100 рублей цены – критичны (десять розеток – уже тысяча рублей, а разница может оказаться и больше). В общем, не всегда описанная проблема является таковой, но мы теперь знаем, для каких задач контроллер рассматривать точно нельзя.

6. Требуемая периферия


6.1 Для Ethernet


Давайте рассмотрим, что надо докупить, чтобы подключить контроллер к Ethernet.



Достаточно разъёма со встроенными трансформаторами и двух токоограничивающих резисторов, если мы соберёмся использовать светодиоды. Ну, на схеме ещё конденсатор по питанию стоит. Всё!

6.2 Для Bluetooth и прочих радиозадач


Антенна (в том числе, печатная) подключается напрямую к выводу микросхемы.





6.3 Для подсистемы питания


Встроенные в микросхему стабилизаторы требуют обязательного наличия некоторых элементов в обвязке. Вот так это выглядит на схеме:



А вот так – выглядят требования в документе:



На этом какие-то специфичные требования заканчиваются. Я до сих пор не могу понять, почему макетки на базе CH579 стоят так дорого. Ну, кроме банального «халява кончилась». Я помню времена, когда все макетки стоили безумных денег. Потом на рынок вышли наши китайские друзья, и стало хорошо. Похоже, ситуация меняется. Здесь микросхема стоит от 120 до 150 рублей, а простейшая макетка – от полутора до трёх тысяч. Не подходит ситуация под привычные каноны! Обвязки для контроллера надо минимум! В общем, когда увидите такие цены — вы уже будете готовы… Не надо пугаться. Но надо ли брать — вопрос открытый.

7. Заключение


Из статьи видно, что рассмотренный контроллер не предназначен для весьма производительной работы. Поэтому дальше проектные работы с ним были свёрнуты. На новогодних праздниках я поиграл на нём в BLE… Может быть, когда-нибудь сделаю статью, но она будет сильно не хвалебная. Весь стек там спрятан в библиотеку, причём в той же библиотеке закопана и RTOS. А знаем мы, какая оптимальность у RTOS, которая поставляется не в исходниках.

Но так или иначе. Мне довелось пощупать (не по своей инициативе) этот контроллер, я описал результаты. Может, кому-то они пригодятся при выборе аппаратуры. А кому-то, кто тоже получает оборудование не по своей воле, может, будет легче начать с этим контроллером работу. Опять же, теперь все знают, как отказаться от закрытой библиотеки Ethernet и перейти на что-то своё.

Что до меня, то и тут труды не пропали даром. Я подзавёлся на изучение стандарта BLE. Но, поиграв на праздниках в CH579, теперь хочу попробовать осваивать всё на контроллерах других производителей. Если раскопаю что-то интересное – обязательно напишу. Например, уже нашёл интересное решение, где взять сниффер, не покупая дополнительного оборудования (Microsoft Bluetooth Test Platform — BTVS — Windows drivers | Microsoft Learn). Просто в большинстве случаев, все советуют купить специальную плату от Nordic Semiconductor. А с btvs.exe – всё на штатном работает.
Теги:
Хабы:
+46
Комментарии 29
Комментарии Комментарии 29

Публикации

Истории

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн