Пульт вентиляции в мастерской требовал датчика, которого не существует в природе: счёт импульсов от геркона на крыльчатке, две кнопки, флаг открытой форточки. Покупной чип с такой картой не продаётся, тянуть пучок проводов через потолок лениво — однажды я насчитал семь жил с запасом и бросил эту затею на середине лестницы, — а радиомодули остались в другом городе. Решение выросло из пустяка: вторая Uno объявляет себя устройством на адресе 0x08 и притворяется датчиком — держит карту регистров, отвечает по запросу, и мастер опрашивает её так же, как купленный чип. Снаружи пары «мастер — плата» и «мастер — датчик» неразличимы.
Первые шаги двух плат собрал урок про обмен между двумя платами — там Wire.begin(0x08) и первые обработчики; взрослый протокол со словарём команд разобрал урок про слейва по-взрослому. Здесь третья ступень: слейв, неотличимый снаружи от настоящего чипа, — с картой регистров, указателем и чтением по запросу. Отличие от словаря команд принципиальное: словарь живёт только в моих двух прошивках, а карту регистров понимает любой чужой мастер — свою плату можно опросить и телефонным адаптером I2C. Это тот же контракт, по которому я читал BH1750 и MPU6050, только вместо чипа — соседняя плата: код мастера меняется нулями строк.
План: карта регистров; две двери слейва — Wire.onReceive и Wire.onRequest — и их жёсткие правила; мастер-опрос без единой библиотеки; и честный разговор о том, что симулятор умеет, а что принципиально нет. Слейв-часть — это два code-блока для живых плат, в симуляторе поедет мастер-сторона, и почему именно так — в конце урока.
Карта регистров: слейв подписывает договор чипа
Карта регистров — общий язык мастеров и чипов: урок про карту регистров объяснял её на чужих даташитах, здесь плата подписывает тот же договор сама. Моя карта умещается в пять ячеек, и каждая выбрана под задачу пульта: регистр 0x00 — статус «я жив», всегда 0xAA; регистры 0x01–0x02 — счётчик импульсов геркона, шестнадцать бит, старший байт первым; 0x03 — флаг кнопки, 0 или 1; 0x04 — резерв. Мастер пишет указатель однобайтовой посылкой beginTransmission(0x08), write(0x01), endTransmission — и следом читает: requestFrom(0x08, 2) возвращает содержимое счётчика. Двухходовка та же, что у настоящих датчиков, и менять её под свою плату нельзя: соседний скетч, написанный под MPU6050, работает с этим слейвом после замены адреса и номера регистра.
На стороне слейва две двери, и обе открывает мастер. Wire.onReceive ловит посылку в ваш адрес — первый байт становится указателем регистра; Wire.onRequest срабатывает на requestFrom и раздаёт байты с указателя через Wire.write. Обе функции библиотека вызывает сама, из прерывания, между проходами loop, — и держат они правила из урока про прерывания attachInterrupt: без delay, без Serial, без тяжёлых расчётов — переложил байты и вернулся. Переменные, которыми обработчики обмениваются с loop, объявляются volatile, иначе цикл закэширует старьё и будет вечно читать прошлое число. Буфер Wire один на приём и передачу — 32 байта, про его анатомию писал отдельный урок.
Правила дверей жёсткие не из педантизма. Serial внутри обработчика виснет намертво: печать сама работает через прерывание, а мы уже внутри прерывания — выше приоритетов никого нет, и скетч стоит. delay внутри onRequest стоит мастеру паузы, которую он не заказывал: шина ждёт, слейв молчит, чужие таймауты сгорают. Отсюда и мой шаблон: обработчик только перекладывает байты между буфером Wire и volatile-переменными, а вся арифметика и вся печать живут в loop, где им и место.
| Регистр | Что лежит | Размер |
|---|---|---|
| 0x00 | статус: 0xAA значит «жив» | 1 байт |
| 0x01-0x02 | счётчик импульсов геркона, старший байт первым | 2 байта |
| 0x03 | флаг кнопки: 0 или 1 | 1 байт |
| 0x04 | резерв под следующий параметр карты | 1 байт |
Мастер-сторона: опрос как у настоящего датчика
Мастер-скетч повторяет опрос любого чипа по одному образцу: указатель, следом запрос, проверка числа пришедших байтов. Раз в 300–500 мс такой опрос шину не душит: две посылки по паре байтов, а геркон крыльчатки даёт десятки импульсов в секунду максимум — чаще счётчик опрашивать незачем. Проверка обязательна: вторую плату кто-то перезагрузит, шлейф отойдёт — и requestFrom вернёт ноль байтов, что честнее показывать вердиктом «слейв молчит», чем мусорным числом на экране. Сборку слова из пары байтов я веду в переменных hi и lo, а не прямо в выражении: порядок вычисления операндов компилятор не обещает, и однажды половины молча поменяются местами.
Тонкое место протокола — атомарность пары, и здесь я потерял свой первый вечер со слейвами. Счётчик шестнадцатибитный, а обновляется в loop между прерываниями: если обработчик onRequest стрельнет посреди записи новой величины, мастер прочитает половину старого числа и половину нового. Лечение на стороне слейва: обновление счётчика брать в noInterrupts()/interrupts() или держать теневую копию, которую обработчик копирует одним куском. И на стороне мастера: читайте пару одним запросом requestFrom(0x08, 2), а не двумя поодиночке — два отдельных запроса получают байты из разных мгновений, и никакая теневая копия этого не исправит. Правило на оба конца сразу: одна посылка — одно мгновение, половины счётчика едут только вместе.
#include <Wire.h> // Слейв на 0x08 притворяется датчиком вентиляции.// Карта: 0x00 - статус 0xAA, 0x01-0x02 - счётчик (старший первым),// 0x03 - флаг кнопки на D3. Геркон на D2 считаем в loop. const byte ADRES = 0x08;volatile byte regPointer = 0;volatile uint16_t schetchik = 0;volatile byte knopka = 0; void priem(int n) { // onReceive: ловим указатель while (Wire.available()) { regPointer = Wire.read(); break; // интересен только первый байт }} void otvet() { // onRequest: раздаём по указателю switch (regPointer) { case 0x00: Wire.write(0xAA); // «я жив» break; case 0x01: { noInterrupts(); byte hi = (schetchik >> 8) & 0xFF; byte lo = schetchik & 0xFF; interrupts(); Wire.write(hi); Wire.write(lo); break; } case 0x03: Wire.write(knopka); break; default: Wire.write(0x00); }} void setup() { Wire.begin(ADRES); // адрес - значит слейв Wire.onReceive(priem); Wire.onRequest(otvet); pinMode(2, INPUT_PULLUP); // геркон pinMode(3, INPUT_PULLUP); // кнопка Serial.begin(9600); Serial.println("Sleiv na 0x08, karta registr gotova");} void loop() { if (digitalRead(2) == LOW) { // импульс от геркона noInterrupts(); schetchik++; // защищаем 16-битное обновление interrupts(); delay(30); // антидребезг геркона } knopka = digitalRead(3) == LOW ? 1 : 0;}В скетче слейва три узла, на которых всё держится. Первый — noInterrupts() вокруг schetchik++ в loop: шестнадцатибитное обновление на Uno занимает несколько тактов, и обработчик, стрельнув посередине, прочитал бы рваное число; тот же замок стоит в ветке 0x01, где пара байтов копируется одним куском. Второй — указатель живёт в volatile-переменной: его пишет onReceive, а читает onRequest, и без volatile компилятор вправе закэшировать значение. Третий — в onRequest нет ни одной медленной операции: два-три Wire.write и возврат, потому что мастер в этот момент стоит и ждёт. Смысловая нагрузка у веток при этом нулевая: все значения уже посчитаны в loop и лежат в volatile-переменных, обработчик только выдаёт их наружу, как кассир за готовым чеком.
Полезная привычка — регистр статуса 0x00 с константой 0xAA. Мастер при старте читает его и за одну посылку различает три состояния: «плата жива», «плата молчит», «плата отвечает мусором» — третье означает, что на адресе кто-то другой. Без этой ячейки я один раз полчаса опрашивал мёртвый шлейф, получая нули, и принимал их за честный ноль счётчика. Проверку статуса мастер делает до первого чтения данных, и порядок тут не случайность: сначала живость, потом протокол.
#include <Wire.h> const byte SLEIV = 0x08; void setup() { Wire.begin(); Serial.begin(9600); Serial.println("Master opitivaet sleiva na 0x08");} void loop() { delay(500); Wire.beginTransmission(SLEIV); Wire.write(0x01); // указатель на счётчик Wire.endTransmission(); int n = Wire.requestFrom(SLEIV, 2); if (n != 2) { Serial.println("Sleiv molchit"); return; } int hi = Wire.read(); int lo = Wire.read(); unsigned int schetchik = (hi << 8) | lo; Serial.print("Schetchik: "); Serial.println(schetchik);}Мастер-скетч нарочно скуп: три вызова Wire и одна проверка. Скупость здесь дисциплина, а не лень: каждый лишний вызов между endTransmission и requestFrom — место, где две платы могут разойтись во мнении, кто и когда начал передачу. Пауза delay(500) для старта проекта простительна; когда рядом появятся экран и кнопки, опрос переедет на millis — и счётчик при этом не потеряет ни импульса, потому что считает слейв, а не мастер.
Вердикт «слейв молчит» в живом проекте я на печать не вывожу, а считаю: три отказа подряд — флаг на экране мастера. Одна неудача случается и на здоровой паре, если мастер застал слейва в момент перезагрузки; три подряд уже статистика. Такой счётчик отказов добавляет пять строк и заменяет половину утренних ритуалов «а жива ли плата». И у него есть бонус: сброс флага после восстановления происходит сам, без перезагрузки мастера — следующий удачный опрос обнуляет счётчик.
Честные пределы симулятора проговариваю отдельно: движок исполняет только мастер-сторону Wire, обработчики onReceive и onRequest в виртуальной схеме не срабатывают. Поэтому слейв-скетч выше — для двух живых плат, а в симуляторе крутится опрос и его логика: двухходовка, проверка числа байтов, сборка слова. Мастер-часть при этом исполняется движком в полном объёме — beginTransmission, write, requestFrom, те же вызовы, что у датчиков из прошлых уроков. Кадры обмена подлинные — указатель, запрос, два байта, — и именно этот трафик виден в панели шины. Расхождение с платой проговорено в подписи блока, а не спрятано.
Проверить слейва на живой плате можно и без мастера: сканер из урока про сканер адресов покажет 0x08 в списке — плата слушает шину. Если адреса нет, первый подозреваемый — Wire.begin() без аргумента: без адреса плата становится мастером и перестаёт отвечать как слейв; второй — общая земля: две платы от своих USB без общего минуса дают адрес, то видимый, то нет, и битые байты. Отладку пары плат я веду через печать из loop слейва: приёмы из урока про отладку через Serial подходят один в один, только не из обработчиков — там Serial ждёт нельзя.
- 1. Как превратить вторую Uno в слейв на адресе 0x08?
- 2. Что делает Wire.onReceive на слейве?
- 3. Мастер читает счётчик двумя запросами по одному байту и ловит слова из разных мгновений. Что делать?
- 4. Чего нельзя делать внутри onRequest?
- 5. Сканер на шине не видит 0x08. Первый подозреваемый?
- Wire.begin(0x08) превращает Uno в слейва: чистые адреса для слейвов начинаются с 0x08
- Карта регистров как у чипа: 0x00 статус 0xAA, 0x01-0x02 счётчик, 0x03 кнопка; мастер пишет указатель и читает
- onReceive ловит указатель, onRequest отдаёт байты; оба из прерывания - без delay, без Serial, volatile обязателен
- Счётчик 16 бит рвётся на границе прерывания: noInterrupts() вокруг обновления и копирования пары
- Симулятор исполняет только мастер-сторону: слейв-скетч проверяйте на двух живых платах, сканер покажет 0x08
Частые вопросы по теме урока
- Как превратить вторую Uno в слейв на адресе 0x08?
- Wire.begin(0x08) делает плату ведомым устройством. Без аргумента та же функция делает её мастером — и слейв молчит. Wire.begin(0x08) с адресом в аргументе
- Что делает Wire.onReceive на слейве?
- onReceive срабатывает, когда мастер завершил запись в ваш адрес; первый байт удобно хранить как указатель регистра. Отдачей по запросу ведает onRequest. ловит посылку мастера в адрес слейва
- Мастер читает счётчик двумя запросами по одному байту и ловит слова из разных мгновений. Что делать?
- Два отдельных запроса получают байты из разных мгновений. Один запрос двух байтов обслуживается обработчиком за раз и приходит одним куском. читать оба байта одним requestFrom(0x08, 2)
- Чего нельзя делать внутри onRequest?
- Обработчик выполняется из прерывания, мастер стоит и ждёт ответа. Задержка там стоит байтов мусора на шине; только Wire.write и возврат. delay и Serial-печать
- Сканер на шине не видит 0x08. Первый подозреваемый?
- Без аргумента Wire.begin() делает плату мастером, и она перестаёт слушать шину. Проверьте аргумент адреса — и сканер покажет 0x08. Wire.begin() без адреса на слейве