Приветствую всех читающих это продолжение недавней статьи https://habr.com/ru/articles/1001968/ про мои изыскания в мире программирования отечественного микроконтроллера К1946ВК035 в качестве регулятора оборотов бесколлекторных двигателей.

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

Если классический DSHOT — это просто набор из 16 импульсов разной длительности, которые задают уровень тяги регулятору оборотов (в простонародье — газ или throttle), а ещё по этому протоколу можно передавать служебные команды, такие как изменение направления вращения мотора, то двухсторонний DSHOT (biDShot) подразумевает общение в полудуплексном формате между приёмником и отправителем, деля одну линию передачи данных на двоих. Есть ещё одна интересная особенность: biDShot — это инвертированная версия обычного DShot, то есть сигнал просто «перевернули»: где был ноль, там теперь единица, и наоборот.

Вот так это выглядит на бумаге (на логанализаторе).

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

В логике программы это выглядит примерно так: этот код вызывается после получения всей посылки, т.е. когда линия уже в высоком состоянии и по ней не ведётся никаких передач.

  if (dshot_telemetry == 0) { //Если мы ещё не определили какой протокол
      if (getInputPinState()) { // Проверям состояние линии: High или Low
          high_pin_count++; // Инкрементим счётчик обнаруженных состояний High
          if (high_pin_count > 100) { // Если их больше 100
              dshot_telemetry = 1; // Определяем протокол как двухсторонний
          }
      }
  }

Но зачем нам вообще нужно понимать, какой тип протокола у нас задействован? Ну, во-первых, чтобы начать «отвечать на сообщения», а во-вторых, чтобы иначе считать CRC (да, в каждую посылку DSHOT встроена контрольная сумма).

/* обычный CRC*/
uint8_t calcCRC = ((dpulse[0] ^ dpulse[4] ^ dpulse[8]) << 3 |
                   (dpulse[1] ^ dpulse[5] ^ dpulse[9]) << 2 |
                   (dpulse[2] ^ dpulse[6] ^ dpulse[10]) << 1 |
                   (dpulse[3] ^ dpulse[7] ^ dpulse[11]));
/* инвертированный CRC*/
if (dshot_telemetry) {
    checkCRC = ~checkCRC + 16;
}

Движемся дальше: сообщение мы приняли, тип протокола определили, CRC посчитали. Теперь надо что-то ответить — и ещё так, чтобы нас поняли на той стороне. В интернете достаточно мало информации о том, как должно выглядеть это самое сообщение, но, собирая информацию по крупицам и задействовав навыки реверс-инжиниринга, можно составить полную картину.

BiDshot telemetry. Изображение было найдено на одном китайском сайте — мало что понятно, но общее представление даёт.
BiDshot telemetry. Изображение было найдено на одном китайском сайте — мало что понятно, но общее представление даёт.

Да, как вы уже заметили, ответное сообщение имеет мало общего с тем, что мы получаем.

Итак, на этом этапе стоит сделать небольшое отступление: «ответных сообщений» может быть несколько типов. Это может быть классическое eRPM — измеренное время коммутации (в микросекундах). А это может быть, так называемый, extended biDShot (или расширенный biDShot), в котором мы передаём информацию о температуре, напряжении и потребляемом токе, измеренных на регуляторе.

В логике программы за тем, какое сообщение необходимо отправлять, следит telem_scheduler(в простонародии шедуллер - планировщик), который представляет из себя, по сути, просто машину состояний. Но чтобы включить функционал «расширенной DShot телеметрии», нужна команда от полётного контроллера.

Двигаемся дальше. Какие предосторожности нужно соблюсти, чтобы всё прошло «гладко»? Ведь нам нужно избегать случая, когда мы хотим начать передачу на линии сигнала, по которой уже идёт передача в этот момент.
После получения посылки имеется приблизительно 30 мкс (все эти тайминги будут указаны для DShot300; для более быстрого протокола, например DShot600, всё будет в 2 раза быстрее), чтобы перевести линию с приёма на передачу и начать передачу.

Первый бит нашей посылки должен быть равен 0, указывая на начало передачи, поэтому он пропускается. Затем, согласно методу кодирования GCR, каждый переход уровня соответствует данным 1, в противном случае — данным 0.

Теперь по порядку попробуем в этом разобраться. Что такое метод кодирования GCR?

Для надежности передачи 16 бит преобразуются в 20 бит с помощью GCR-таблицы (Group Coded Recording). Это помогает избежать длинных последовательностей нулей или единиц, что облегчает синхронизацию приема.
Код разбивает 16-битное слово на четыре группы по 4 бита.
Каждая 4-битная группа используется как индекс в таблице gcr_encode_table для получения соответствующего 5-битного GCR-кода.
Эти четыре 5-битных кода собираются в одно 20-битное число gcrnumber.

Схожая кодировка есть у протокола Power Delivery — там данные передаются по CC линии USB-C кабеля. Там, правда, ещё сверху потом идёт так называемый BMC encoder, но об этом в другой раз.

Взято из USB_PD_R3_2 V1.1
Взято из USB_PD_R3_2 V1.1

Цели у этих кодировок разные, но смысл один - повышение надёжности. Как минимум, когда 4 бита информацию трансформиются в пять, то битые данные можно обнаружить уже на этапе приёма данных даже без подсчёта контрольной суммы, ведь некоторых "чисел", например 0b10101 может вообще не существовать в вашей таблице кодировки, а значит, оно заведомо невалидное. Но это здесь это не главное.

Теперь поговорим про так называемый physical layer — про то, как мы будем отправлять наши данные в виде логических сигналов.
Начало передачи DShot Telemetry всегда ознаменуется нулём — это наш start bit. Он не несёт никакой смысловой нагрузки, кроме как обозначить старт передачи.

Теперь главное: ноль — это не низкое напряжение, а единица — не высокое. Оно, конечно, так, но не в нашем случае. Здесь обозначением логической единицы является переход из нуля в единицу или наоборот. Ничего не напоминает? Да, можно опять вспомнить тот самый Power Delivery и BMC encoding, но там чуть иначе — там больше переходов. Но, как и обещал, сейчас его разбирать не будем. Также похожая кодировка нулей и единиц используется в LTC протоколе (Linear Time Code), но это тоже не совсем как у нас.

Linear Time Code. "Широкие" переходы это ноль, а 2 узких это единица.
Linear Time Code. "Широкие" переходы это ноль, а 2 узких это единица.

А как у нас — сейчас расскажу. Возьмём некий промежуток времени. В нашем случае для DShot300 это ~2,5 микросекунды — это наш «такт». Как только приёмник получил наш start bit (который всегда ноль, т.е. мы уронили уровень сигнала из high в low), приёмник начал отсчёт времени. И если по истечении ~2,5 микросекунд уровень сигнала не изменил своё состояние, то в соответствующий бит итогового числа записывается ноль. Потом начинаем новый отсчёт в ~2,5 микросекунды, и если уровень сигнала изменился, то записываем единичку. И так далее.

Хорошо, теперь пришло время воплотить все эти идеи в программу.
Сам листинг логики GCR encoding можно посмотреть в репозитории, который мелькал в оригинальной статье. А вот как это всё реализовать «на физическом уровне» — мы сейчас разберём.

Заново настраивать тактирование шины GPIOA и базовые настройки DMA мы не будем — это всё мы сделали в прошлый раз. Сейчас нам нужно переключить шину с приёма на передачу. Для этого мы возьмём уже знакомый нам модуль ECAP, который умеет работать не только на приём, но и на передачу, формируя ШИМ заданной формы. Но тут нас опять ожидает одна неприятность: ECAP не имеет связи с DMA и не может по событию UPDATE или COMPARE стриггерить DMA. Поэтому на помощь нам приходит смекалка и встроенный таймер, который как раз уже умеет формировать запросы к DMA. Мы заведём его на то же время, что и период ШИМа у ECAP. Стартуют эти модули одновременно и на одинаковой частоте. Таким образом, при правильной настройке мы получим запрос к DMA от внутреннего таймера на передачу из заранее сформированного GCR-кодированием буфера значения регистра сравнения ECAP ровно тогда, когда нам нужно.

  RCU->PCLKCFG_bit.ECAP1EN = 1;  //тактирование ECAP
  RCU->PRSTCFG_bit.ECAP1EN = 1;  //отключаем сброс ECAP

  ECAP1->ECCTL1_bit.CAPAPWM = 1; //Режим PWM ECAP
  ECAP1->ECCTL1_bit.APWMPOL = 1; //Полярность шима
  ECAP1->PRD = 256; // Период шима
  ECAP1->CMP = 0; // Заполнение шима

  DMA->ENSET_bit.CH12 = 1; // включаем TMR3 канал DMA
  DMA->ENSET_bit.CH8 = 0; // отключаем GPIOA канал DMA
  DMA_ChannelMuxConfig(DMA_ChannelMux_12, DMA_ChannelMux_12_TMR3); // настройка микшера на TMR3
  
  NVIC_DisableIRQ(DMA_CH8_IRQn); // отключаем прерывание GPIOA канала DMA

  NVIC_EnableIRQ(DMA_CH12_IRQn); // включаем прерывание канала TMR3 DMA
  NVIC_SetPriority(DMA_CH12_IRQn, 0x3); // выставляем нужный приоритет

  DMA_CONFIGDATA.PRM_DATA.CH[12].SRC_DATA_END_PTR = (uint32_t)&(gcr[36]); //Адрес источника данных 
  DMA_CONFIGDATA.PRM_DATA.CH[12].CHANNEL_CFG_bit.SRC_SIZE = DMA_CHANNEL_CFG_SRC_SIZE_Word; //Разрядность данных источника
  DMA_CONFIGDATA.PRM_DATA.CH[12].CHANNEL_CFG_bit.SRC_INC =  DMA_CHANNEL_CFG_DST_INC_Word; // Инкрементируем на 4 байта

  DMA_CONFIGDATA.PRM_DATA.CH[12].DST_DATA_END_PTR = (uint32_t )(&IC_TIMER_REGISTER->CMP); //Адрес конца данных приемника
  DMA_CONFIGDATA.PRM_DATA.CH[12].CHANNEL_CFG_bit.DST_SIZE = DMA_CHANNEL_CFG_SRC_SIZE_Word; //Разрядность данных приемника
  DMA_CONFIGDATA.PRM_DATA.CH[12].CHANNEL_CFG_bit.DST_INC = DMA_CHANNEL_CFG_DST_INC_None; //Не инкрементируем

  DMA_CONFIGDATA.PRM_DATA.CH[12].CHANNEL_CFG_bit.R_POWER = 0x0; // Количество передач до переарбитрации
  DMA_CONFIGDATA.PRM_DATA.CH[12].CHANNEL_CFG_bit.N_MINUS_1 = 37-1; //Общее количество передач DMA
  DMA_CONFIGDATA.PRM_DATA.CH[12].CHANNEL_CFG_bit.CYCLE_CTRL = DMA_CHANNEL_CFG_CYCLE_CTRL_Basic; //Задание типа цикла DMA
 
  TMR3->CTRL_bit.ON = 0; // Отключили таймер для настройки
  TMR3->VALUE = periodTime / 2; // Значени начальной загрузки таймера, оно такое, чтобы задать правильное смещение
  TMR3->LOAD = periodTime; // Значение после обновления
  TMR3->DMAREQ_bit.EN = 1; // Вкл запрос к DMA
  ECAP1->ECCTL1_bit.CONTOST = 1; //Постоянная генерация ШИМ
  ECAP1->ECCTL1_bit.TSCTRSTOP = 1; //Включили счёт ECAP
  SIU->REMAPAF_bit.ECAP1EN = 1; //Ремапнули GPIO для ECAP
  GPIOA->ALTFUNCSET_bit.PIN5 = 1; //Альтернативная функция GPIO5
  GPIOA->DMAREQCLR_bit.PIN5 = 1; //Выкл запросы к DMA у GPIO
  DMA->CFG_bit.MASTEREN = 1; //Бит разрешения работы контролера DMA
  TMR3->CTRL_bit.ON = 1; //Запустили внутренний займер

Когда DMA закончит работу и отправит все 37 значений из буфера, мы получим прерывание — так называемое Transfer Complete.

Надо ещё понимать одну вещь: DMA завершит свою работу, когда передаст последнее значение, но это не означает, что у нас «отработал» последний импульс ШИМа — он только начался. И если мы сейчас отключим таймер или переконфигурируем GPIO, то можем всё испортить. Поэтому мы пишем в теневой регистр сравнения ноль, чтобы «линия» перешла в высокое состояние после всей передачи — ведь значение из теневого регистра сравнения «применится» лишь после обновления таймера ECAP:

ECAP1->CMPSHDW = 0;

Ну вот и всё — job is done, сообщение мы отправили. Мы молодцы.
На этом цикл статей о борьбе со злосчастным DSHOT заканчивается. Но дальше хуже (читай: лучше). Я думаю, что если моё творчество найдёт отклик у какого-то количества читателей, то дальше я напишу про борьбу с паразитным запитыванием у К1946ВК035 — и там не всё так просто.

Спойлер: нужно будет и пописать программы, и поработать со схемой.