Прямо сейчас занимаюсь достаточно интересным проектом, который задействует большое количество пинов микроконтроллера, и, наверное, впервые количество требующихся пинов превзошло количество имеющихся у микроконтроллера. Так что же делать в таком случае?
Решил я это всё своеобразным образом, который и описан ниже. Возможно, кому-то ещё будет интересно и полезно...
Дело в том, что этот проект я строю на базе esp32 ввиду того, что данные микроконтроллеры у меня валяются по дому коробками — и это не фигура речи, а буквально так.
Однако проект требует достаточно большого количества подключаемых устройств — восемь цифровых датчиков Холла (KY-003), один аналоговый датчик расстояния (тоже Холла, KY-035), три шаговых двигателя Nema 17, один коллекторный двигатель.
Дело с двигателями осложняется также тем, что каждый из шаговых двигателей требует по три контакта (pulse, direction, enable), так как мне пришлось вынужденно использовать драйвер ТВ6600. Хотел изначально использовать также имеющиеся в наличии L298N, однако они не потянули шаговые двигатели Nema 17 — работали на пределе и раскалялись буквально за 6 секунд работы с последующим температурным отказом.
Да, многие хают ТВ6600, и советуют выбирать более профессиональные версии, тем не менее его цена в моём случае решает: 500 руб. против 2500 руб. Да и задача у меня не такая уж сложная и не требующая круглосуточной работы, наподобие фрезерования 3D-картин, так что мне сойдёт…
Итак, у нас проблема — не хватает пинов (весьма наглядное описание пинов есть здесь). Как её решать? Наверное, можно поискать какую-либо плату расширения, допускающую подключение большого количества периферии — например, у меня с незапамятных времён лежит такая для Arduino:
Но что-то не захотелось мне с этим возиться и разбираться, и пришла в голову мысль сделать всё гораздо более простым способом: с помощью соединения микроконтроллеров через шину I2C.
Природная лень заставила меня поискать готовые решения, однако здесь пришлось натолкнуться на практически полное их отсутствие (связь двух и более esp32 между собой), таким образом, «беглый гуглинг» практически ничего не дал (что, однако, не исключает их наличия где-либо — это говорит только о том, что я их не нашёл). Ну ок, начинаем вникать…
Шина I2C требует для реализации связи всего двух проводов: SCL (Serial Clock) — сигнал тактирования и SDA (Serial Data) — передаваемые данные (но, вообще говоря, в реальности больше, так как требуется ещё как минимум заземление, а ещё желательно и питание).
Эта шина относительно низкоскоростная (стандартно — 100 кбит/сек, на современных устройствах — до 400 кбит/сек), тем не менее она полностью удовлетворяет моим требованиям, так как рамки моего проекта не требуют передачи каких-то тяжёлых данных.
Работа в рамках I2C подразумевает, что любое подключённое к этим линиям (SCL, SDA) устройство может занимать одну из двух позиций: ведущую (master) или ведомую (slave). Обычно, говоря об этой шине, подразумевают работу в ней одного master-a и ряда slave-ов, хотя не запрещается и работа нескольких master-ов (насколько я понял, реализовано это через использующуюся master-ом функцию endTransmission(), которая, если вызывается с параметром true (endTransmission(true)), после передачи сообщения посылает стоп-сообщение, освобождая линию I2C, и она по сути свободна для использования каким угодно ещё master-ом).
Общение между устройствами в рамках этой шины всегда инициирует master, вызывая соответствующее ведомое устройство, обращаясь по его адресу, который является семибитным, и в сети одновременно могут присутствовать 128 устройств с адресами, обозначенными числами от 0 до 127, где 0 согласно спецификации — адрес общего вызова, а ведомым можно назначать адреса от 1 до 127 (это последнее уже не по спецификации, а «по факту», если использовать библиотеку wire). Хотя на самом деле всё несколько сложнее, и если строго следовать спецификации (ссылка на которую выше), то это оставляет нам только 112 адресов:
Но если вы сами проектируете систему и вообще «правила не для вас», то 127. И, вроде как при сильном желании даже 128 :-) Но тут сделаю ремарку: используя библиотеку wire, я пытался обращаться на нулевой адрес. С «нулевым» же результатом. Видимо, если только самому библиотеку писать, так как люди вроде как используют и его… Но тут «дальше мои полномочия всё»… :-0)
И кстати говоря, почему стандарт такой странный — семибитный? А вот почему, всё оттуда же, из спецификации:
А почему 127 устройств? Для этого нам всего лишь нужно открыть стандартный калькулятор Windows и переключить его в режим «программист»:
Затем мы переключимся в десятичный режим (показано ниже стрелкой слева) и забьём число 127. После чего мы увидим в битовом выражении наверху справа, что число 127 для компьютера кодируется семью знаками:
Теперь, если мы попробуем стереть это число и забить туда 128, то увидим наверху справа, что в битовом выражении это число кодируется уже восемью знаками. А наша шина — семибитовая (т. е. можно сказать семизнаковая), и этот адрес просто не поместится в рамках её стандарта (тут следует сделать оговорку — возможно, это не шина семибитовая, а библиотека wire, но я глубже не копал, честно признаюсь):
Кстати говоря, это подробное описание я привёл не просто так — оно может быть полезно для тех, кто не дружит с шестнадцатеричной системой счисления (потому что в стандартных примерах библиотеки wire.h показано использование шестнадцатеричных чисел в качестве адресов).
То есть если вы захотите, так же как и я, увеличить количество пинов, соединив между собой одну или более плат esp32, то вам понадобится назначить каждой из них адрес в рамках этой шины (всем, кроме master-устройства(в), так как, насколько я понимаю, им система неявно сама выдаёт master-адрес при инициализации).
Делается это крайне просто:
- Запускаете калькулятор Windows.
- Переключаетесь в десятичный режим (Dec).
- Вбиваете любое число от 1 до 127.
- Переключаетесь в шестнадцатеричный режим (Hex).
- Копируете получившееся число из окошка наверху и вставляете с припиской «0х...» в свой код (например, 0х7F).
И это всё! Теперь, когда у вашего ведомого устройства есть адрес, к нему легко может обратиться ведущее…
Кстати говоря, это (программное назначение адреса) касается только моего случая, так как, насколько мне известно, имеющиеся в продаже устройства (I2C ЖК-экраны и т. д.) уже содержат жёстко прошитые I2C-адреса (у некоторых их можно менять перемычкой).
Что ещё нам нужно знать о шине I2C? Один из самых важных моментов состоит в том, что она требует для своей работы подтягивающие к питанию (pull-up) резисторы:
Картинка: randomnerdtutorials.com
Об этом даже упоминает и производитель esp32:
Хорошая статья на тему, что такое подтягивающие резисторы, и зачем они нужны для esp32, есть вот здесь.
Тем не менее, насколько я знаю, ряд пинов esp32 уже содержит подтягивающие резисторы. Обратимся к документации производителя esp32:
И мы увидим, что если явным образом программно не определено, пины, имеющие подтягивающие резисторы (45 кОм при подтягивании к питанию, и такой же на 45 кОм при подтягивании к земле — это я уже в даташите глянул), находятся в высокоимпедансном состоянии. Разбираемся, что это за состояние… Вот что нам говорит Вики по этому поводу:
Высокоимпедансное состояние, высокоомное состояние, Z-состояние или состояние «Выключено» — состояние выхода цифровой микросхемы, при котором он «отключается» от сигнальной шины, к которой подключены несколько передатчиков сигналов. Таким образом, сопротивление между её внутренней схемой, формирующей выходной сигнал, и внешней схемой, очень большое. Вывод микросхемы, переведённый в состояние «Выключено», ведёт себя как не подключённый к ней. Внешние устройства (микросхемы), подключённые к этому выводу, могут изменять напряжение на нём по своему усмотрению (в некоторых рамках), не влияя на работу микросхемы. И наоборот — схема не мешает внешним устройствам менять напряжение на выводе микросхемы.
И ещё: Состояние «Выключено» применяется, когда устройству приходится временно отключаться от шины — например, в программаторах, мультиплексорах, многоточечных интерфейсах передачи данных наподобие JTAG, I2C или USB и т. д.
То есть получается, что такая «подтяжка» важна для работы в рамках шины I2C. А как её реализовать, учитывая, что у нас вроде как уже есть резисторы на борту платы?
Реализуется это с помощью функции pinMode. Например, так:
pinMode (21, INPUT_PULLUP)
pinMode (22, INPUT_PULLUP)
Хорошая статья про функции подтягивания есть здесь. В ходе экспериментов пробовал вешать эти функции и на master, и на slave (несмотря на то, что это не совсем входы, а двунаправленное общение как бы). Разницы не увидел никакой.
Приходилось видеть рассуждения в сети, что на пинах интерфейса I2C такая подтяжка (программная) не работает, и при желании использовать именно внутренние подтягивающие резисторы люди включали подтягивание на других пинах и соединяли их с I2C-пинами с помощью перемычек. Забегая вперёд – у меня всё хорошо работает и без этого (возможно, это связано с тем, что у меня весьма малое расстояние – всего 30 см между платами). Или же оно реализовано аппаратно платой (на пинах, определяемых для I2C).
По крайней мере, из того, что мне удалось выяснить:
- Если расстояния между устройствами малы, то особого смысла в подтягивающих резисторах нет, и все устройства используют один и тот же источник питания.
- Внутренние встроенные в плату pull-up резисторы слишком большого номинала. Да, их можно использовать, но это не позволит достаточно быстро осуществлять подтягивание: «Передача/Приём сигналов осуществляется прижиманием линии в 0, в единичку устанавливается сама за счёт подтягивающих резисторов. Их ставить обязательно всегда! Стандарт! Резисторы на 10к оптимальны. Чем больше резистор, тем дольше линия восстанавливается в единицу (идёт перезаряд паразитной ёмкости между проводами), и тем сильней заваливаются фронты импульсов, а значит скорость передачи падает. Именно поэтому у I2C скорость передачи намного ниже, чем у SPI. Обычно IIC работает либо на скорости 10кбит/с — в медленном режиме, либо на 100кбит/с в быстром. Но в реальности можно плавно менять скорость вплоть до нуля.»
В любом случае, было бы интересно прочитать в комментах, если вы знаете что-то больше по этому вопросу…
Мы же рискнём соединить платы просто напрямую. Теперь — куда нам необходимо подключаться?
Если мы посмотрим на распиновку, то увидим, что интерфейс расположен на 22 (SCL) и 21 (SDA) пинах:
Картинка: circuits4you.com
И именно сюда же и предлагает подключаться производитель:
Картинка: docs.espressif.com
Тем не менее, плата поддерживает два таких интерфейса, они могут быть конфигурированы на любом из универсальных портов ввода-вывода.
Для работы с этой шиной будем использовать стандартную библиотеку wire.h.
Здесь есть один любопытный момент, который касается назначения пинов под I2C: если мы обратимся к описанию библиотеки wire.h, на сайте Arduino, то среди функций этой библиотеки
мы не увидим важнейших, которые мы могли бы использовать (о них ниже).
И даже на сайте espressif упоминается о возможности назначения любых пинов, только для master-устройств:
Приходилось видеть и другой вариант, но также для master-устройств, для назначения на любые пины, например 18-19:
Wire.begin(18,19)
Тем не менее, существует и ещё один вариант, который гораздо более универсален и подходит как для master-a, так и для slave-a — мне он кажется самым оптимальным и нравится больше всего:
Итак, попробуем соединить платы, как было описано выше, кроме того, соединим у обеих плат GND и Vin (пробовал и без этого, тоже работает, но идёт сильная потеря пакетов).
Загрузим на каждую из esp32 свою часть примера библиотеки wire.h: на левую — сканер адресов подключённых устройств, на правую — slave. И мы видим, что slave-устройство отлично обнаруживается:
А теперь попробуем установить на ведущее устройство код master-части. Всё работает ОК, потерь пакетов не наблюдается:
Теперь переключим монитор порта на slave-устройство и посмотрим, что пишет оно. Тоже потерь пакетов не наблюдается:
UPD: после долгого тестирования обнаружил, что в некоторых ситуациях всё-таки наблюдаются потери пакетов, однако от этого удалось избавиться очень простым способом: провода I2C и провода питания (Vin, GND) смотал друг с другом, наподобие витой пары. После этого потери пакетов прекратились. Видно, как отчитывается функция endTransmission(), где 0 означает успешную передачу:
Теперь остаётся только для спортивного интереса повесить на одну esp32 датчик, а на другую попробуем повесить шаговый двигатель, чтобы он запускался от срабатывания датчика.
В качестве такого датчика возьмём уже упомянутый цифровой (то есть который выдаёт только значения LOW и HIGH) датчик Холла KY-003, а в качестве шагового двигателя также упомянутый выше Nema 17 и драйвер двигателя ТВ6600:
Всё работает как и должно! Таким вот нехитрым образом вы можете увеличить количество пинов esp32, соединив между собой n-плат. В показанном выше видео код опроса датчика помещён прямо в цикле loop, что есть не совсем правильно и сделано это для ускорения демонстрации. В реальном проекте, чтобы не занимать процессорное время, датчик надо подключать через функцию attachInterrupt(interrupt, function, mode). В своём проекте собираюсь сделать именно так. Мало того — чтобы постоянно не занимать линию опросами датчиков, все датчики повешу на master, а все двигатели (ну или почти все) — на slave.
Здесь я выложил упрощённый пример показанного в видео, где код управления шаговым двигателем заменён на код светодиода (срабатывание датчика на master-e зажигает встроенный светодиод на slave-e).
Я не ставил целью строить распределённую систему (одна esp32 — в одной комнате, другая — в другой и т. д.), поэтому для моих целей (простое расширение количества пинов) всё работает хорошо. Если вам нужна будет именно распределённая система, возможно, вам придётся углубиться в изучение этой темы дальше. Этот пост не претендует на идеальное знание темы и «учебника» по ней. Скорее это история про то, как у меня возникла проблема и как я её решил.
Соединение между платами по беспроводным каналам (wifi, bluetooth) не рассматривал, так как в моём случае это будет в высшей мере странно — «забивание гвоздей микроскопом» :-)
Автор:
DAN_SEA