В скетче моей погодной станции за температурой BMP280 охотился кусок на тридцать строк: beginTransmission, запись номера регистра, requestFrom, склейка трёх байтов через сдвиги, вычитание смещения, деление. Через полгода я делал второй проект с тем же датчиком и честно скопировал кусок. Через год — третий, и в третьем копия не завелась: при переносе потерялась строка записи номера регистра, и полчаса я искал, почему вместо градусов приходят нули. Третья копипаста одной и той же логики — верный сигнал, что её пора завернуть в класс.
Драйвер — это класс, который прячет от вызывающего всю кухню Wire: адрес, номера регистров, порядок байтов, пересчёт сырого кода в градусы. Наружу торчат обычно три-четыре понятных метода: begin(), readTemperature(), при желании readPressure(). В этом уроке напишем свой мини-драйвер BMP280 на голом Wire по образцу взрослых библиотек, а потом прогоним ту же логику в симуляторе, где пользовательские классы не парсятся и логику придётся разложить по функциям с тем же контрактом.
Анатомия драйвера: что прячем и что показываем
У взрослой библиотеки я считаю три признака, и все три скопируем. Первый: адрес принимается один раз — в конструкторе или в begin() — и больше нигде в коде не мелькает. Второй: begin() возвращает bool, и false означает конкретную вещь: «датчик не подтвердил, что он тот, за кого себя выдаёт». Проверка дешёвая: в регистре 0xD0 у BMP280 лежит заводской идентификатор 0x58, у MPU6050 в регистре 0x75 живёт 0x68 — два байта, и вы уже точно знаете, что разговариваете именно с тем чипом, которым думали. Третий признак: методы возвращают готовые величины — градусы, паскали, перегрузки в g — а не сырые байты; пересчёт спрятан внутри класса.
Есть и то, чего в драйвере быть не должно: Serial.print. Я поначалу лепил отладочные печати прямо в методы, а потом полдень выковыривал их из «библиотечного» кода, который поехал в рабочий проект. Правило простое: драйвер молчит, журнал ведёт вызывающий код — обёртки из урока про сниффер I2C вешаются снаружи и печатают те же кадры. Разделение мелкое, но оно спасает от проекта, где три датчика и три разных стиля логов, а половина печатей забыта внутри чужого файла.
#include <Wire.h> class Bmp280Lite { byte addr;public: Bmp280Lite(byte a) { addr = a; } bool begin() { Wire.beginTransmission(addr); if (Wire.endTransmission() != 0) return false; // на адресе никого Wire.beginTransmission(addr); Wire.write(0xD0); // паспортный chip_id Wire.endTransmission(); Wire.requestFrom(addr, 1); return Wire.read() == 0x58; // паспорт BMP280 } float readTemperature() { Wire.beginTransmission(addr); Wire.write(0xFA); // temp_msb Wire.endTransmission(); Wire.requestFrom(addr, 3); long raw = 0; while (Wire.available()) raw = (raw << 8) | Wire.read(); // упрощённый пересчёт без калибровки; точная компенсация — // через константы из регистров 0x88-0xA1, см. урок про BMP280 return (raw - 524288) / 4096.0; }}; Bmp280Lite bmp(0x76); // SDO датчика на земле void setup() { Wire.begin(); Serial.begin(9600); if (bmp.begin() == false) { Serial.println("BMP280 не ответил: адрес, питание, земля?"); while (true) {} } Serial.println("BMP280 на шине, идентификатор сошёлся");} void loop() { Serial.print("t="); Serial.println(bmp.readTemperature(), 1); delay(1000);}Разбор — по двум местам, где чаще всего косят. begin() не верит на слово: сначала endTransmission проверяет, что на адресе вообще кто-то жив, затем читается паспортный регистр, и только совпадение обоих шагов даёт true. Четыре строки, которые экономили мне уже несколько вечеров: модуль из другой партии, адрес с другой ножкой — вы узнаёте об этом в setup(), а не по бредовым градусам в полночь. Карты регистров и список паспортных идентификаторов собраны в уроке про карты регистров — держите его рядом, когда заводите незнакомый чип.
Второе место — длина запросов. Наш класс просит три байта на температуру и укладывается в буфер Wire с запасом: на Uno буфер передачи — 32 байта, и потолок всплывает, когда драйверу поручают читать сразу весь банк регистров. Держите запросы короткими и читайте порциями: три кадра по три байта лучше одного на девять, если между ними ничего не изменится. Полная компенсация BMP280 — с калибровочными константами из регистров 0x88–0xA1 — умышленно осталась за кадром: она по формулам разобрана в уроке про BMP280, здесь нас интересует каркас.
Та же логика в симуляторе: функции вместо классов
Честно про инструмент: парсер симулятора этого сайта понимает только встроенные классы вроде LiquidCrystal_I2C и RTC_DS3231, свои классы в sim-блоке не заведутся. Поэтому виртуальная версия драйвера живёт на функциях с тем же контрактом: bmpInit() — аналог begin(), bmpReadTemperature() — аналог метода чтения. Логика один в один: пинг адреса, паспортный регистр 0xD0, двухходовка с записью 0xFA и чтением трёх байтов. На живой плате берите класс из блока выше — а симулятор покажет саму механику шины, панели I2C и журнала.
Кстати, тот же приём — логика в функциях, обёртка в класс — я использую и вне симулятора: прототип драйвера пишу функциями, гоняю в деле, а классом оборачиваю, когда интерфейс устоялся. Если вы только приходите к функциям как способу структурировать скетч, начните с урока про свои функции — там про параметры, возврат значений и почему функция должна делать одно дело. Как найти паспортные регистры в даташите за пять минут — в уроке про чтение даташитов.
| Подход | Код в loop() | Переиспользование | Когда годится |
|---|---|---|---|
| магические числа прямо в loop | 30 строк и больше | только копипастой | разовый эксперимент на вечер |
| функции-обёртки | 3–5 строк | переносится между скетчами | небольшие проекты, симулятор |
| класс-драйвер | 1–2 вызова | едет в любой проект одной вкладкой | постоянные проекты, обмен с другими |
Отдельно про контракт значений — что метод возвращает, когда шина мертва. Первый соблазн — вернуть ноль, но ноль для термометра правдоподобен, и логика над ним не заметит подвоха. Мой выбор для Lite-драйверов: begin() возвращает bool и при отказе выставляет флаг, а методы чтения возвращают сырые значения, которые вызывающий код печатает как есть; в больших библиотеках для этого заведены отрицательные коды и константы вроде NAN. Что точно не стоит делать — зависать в бесконечном цикле прямо в методе: пусть решение «работать дальше или остановиться» принимает скетч, а не драйвер. И помните про таймауты: если слейв может зависнуть на шине, вызывающий код обязан переживать это — рецепт в уроке про зависание шины.
Наглядный пример, зачем контракт на практике. На станции у меня компас отъезжал от макетки при каждом задевании провода: библиотека возвращала мусор, а логика печатала «курс 3 градуса» так уверенно, что я полчаса сверялся с телефоном. После этого любые показания без успешного ACK я считаю несуществующими: драйвер отдаёт значение — а вызывающий код сначала смотрит, прошёл ли кадр, и только потом доверяет цифрам. Дешёвая дисциплина, которая однажды спасёт ваш лог от ряда одинаковых выдуманных значений.
Куда растить драйвер дальше. Первым делом — давление: оно читается с того же блока регистров, начиная с 0xF7, тремя байтами, и пересчитывается вместе с температурой через калибровочные константы — полный разбор у нас в уроке про BMP280. Второе — измерение по событию: датчик живёт в спящем режиме и будится командой в регистр 0xF4 только на время опроса; так меньше самонагрев и ток, что для батареечной станции — экономия в единицы миллиампер. Третье — драйвер в отдельной вкладке Arduino IDE: меню справа от вкладки скетча, «Новая вкладка», имя вида Bmp280Lite.h — и код скетча сжимается до пары вызовов.
И последнее — про имя. Я называю драйверы по даташиту с приставкой Lite: Bmp280Lite, Mpu6050Lite. Через год в папке проектов лежит десяток таких вкладок, и имя, совпадающее с языком даташита, экономит время поиска. Суффикс честно говорит, что это моя урезанная версия, а не полная библиотека, — и не претендует на чужую. И не стесняйтесь версий: Bmp280LiteV2 с компенсацией давления лежит в папке рядом с первой — переименовывать старую не нужно, два старых проекта уже завязаны на её интерфейс.
- Драйвер прячет адрес, регистры и пересчёт; наружу — begin() и методы с готовыми величинами вроде readTemperature()
- begin() проверяет два уровня: ответ на адрес через endTransmission и паспортный идентификатор (BMP280: регистр 0xD0 = 0x58)
- В драйвере нет Serial: журнал ведёт вызывающий код обёртками из урока про сниффер
- Запросы держим короткими: буфер Wire на Uno — 32 байта; три кадра по три байта лучше одного на девять
- В симуляторе классы не парсятся: та же логика раскладывается по функциям с тем же контрактом, полный класс — в code-блоке
- 1. Что проверяет begin() перед тем, как вернуть true?
- 2. В каком регистре у BMP280 лежит заводской идентификатор 0x58?
- 3. Почему в методах драйвера нет Serial.print?
- 4. Какой размер буфера передачи Wire на Uno?
- 5. Почему в sim-блоке драйвер написан на функциях?
Частые вопросы по теме урока
- Что проверяет begin() перед тем, как вернуть true?
- Два уровня проверки: endTransmission подтверждает, что на адресе кто-то жив, а паспортный регистр — что живёт там именно тот чип. ответ на адрес и паспортный идентификатор чипа
- В каком регистре у BMP280 лежит заводской идентификатор 0x58?
- 0xD0 — chip_id у BMP280; у MPU6050 паспортный WHO_AM_I живёт в 0x75 и равен 0x68. 0xD0
- Почему в методах драйвера нет Serial.print?
- Печати внутри драйвера не вытащить наружу и не выключить; журнал — обязанность скетча, обёртки из урока про сниффер. журнал ведёт вызывающий код: драйвер обязан работать с любым планировщиком
- Какой размер буфера передачи Wire на Uno?
- Буфер — 32 байта (BUFFER_LENGTH); запросы длиннее упираются в потолок, поэтому читайте регистры порциями. 32 байта
- Почему в sim-блоке драйвер написан на функциях?
- Симулятор понимает только встроенные классы вроде LiquidCrystal_I2C; полный класс с тем же контрактом лежит в code-блоке урока. парсер симулятора не понимает пользовательские классы