ELECTRONICS

Своя библиотека Arduino: файлы .h и .cpp и зачем #include <Arduino.h>

Библиотека Arduino — это не что-то особенное, а обычная папка с двумя-тремя файлами на C++: заголовок .h говорит, что библиотека умеет, файл .cpp — как именно. Всё остальное — описание для среды, чтобы она показала библиотеку в меню и в менеджере.

Ниже — путь от вкладки в скетче до настоящей библиотеки с примером в меню «Файл → Примеры». По дороге разберём, что такое Arduino.h, почему в скетче его писать не надо, а в своём файле — обязательно, и пять ошибок, на которых застревают почти все. Весь код собран arduino-cli под Arduino UNO, тексты ошибок — дословные.

Зачем выносить код в библиотеку

Пока скетч один, библиотека не нужна. Она появляется, когда один и тот же кусок кода кочует из проекта в проект: опрос кнопки с защитой от дребезга, мигание без delay(), работа с самодельным датчиком. Копировать его руками — значит чинить одну и ту же ошибку в пяти местах.

Библиотека делает из этого кода одно место. Исправили — исправилось везде. А ещё она прячет подробности: снаружи видно led.update(), а сколько там миллисекунд и флагов внутри — дело самой библиотеки.

Что такое .h и .cpp

Заголовок .h — это оглавление: какие функции и классы есть и какие у них аргументы. Кода внутри функций в нём обычно нет. Файл .cpp — реализация: те же функции, но уже с телом.

Строка #include буквально вставляет текст файла в то место, где она стоит. Когда скетч подключает led.h, компилятор узнаёт, что функция ledOn существует и какие у неё аргументы, — и спокойно собирает вызов. Саму функцию он найдёт в led.cpp на следующем шаге, при сборке.

Скобки важны. #include <Servo.h> — искать среди установленных библиотек. #include "led.h" — сначала рядом с текущим файлом. Свои файлы из папки скетча подключают в кавычках.

Что такое Arduino.h и когда его подключать

Arduino.h — заголовок самой платформы. В нём объявлено всё, что вы привыкли считать частью языка: pinMode, digitalWrite, millis, HIGH, тип byte. Скачивать его не нужно и неоткуда: он приходит вместе с поддержкой платы.

В файле .ino его никто не пишет, потому что среда добавляет строку #include <Arduino.h> сама, перед сборкой. А вот в .h и .cpp она этого не делает. Свой файл без этой строки не знает ни byte, ни digitalWrite, и сборка падает с ошибкой 'byte' was not declared in this scope. Поэтому первые две строки любого заголовка библиотеки одинаковы:

#pragma once
#include <Arduino.h>

Шаг первый: вкладка в скетче

Начинать удобно без всякой библиотеки. В Arduino IDE откройте меню справа от вкладок — три точки в IDE 2 или стрелка в IDE 1.8 — выберите «Новая вкладка» и назовите её led.h, затем ещё одну — led.cpp. Файлы лягут в папку скетча рядом с .ino.

led.h

#pragma once
#include <Arduino.h>

void ledOn(byte pin);

led.cpp

#include "led.h"

void ledOn(byte pin) {
  pinMode(pin, OUTPUT);
  digitalWrite(pin, HIGH);
}

сам скетч

#include "led.h"   // свой файл — в кавычках

void setup() {
  ledOn(13);
}

void loop() {}

Для одного проекта этого уже достаточно. Библиотекой код станет, когда переедет из папки скетча в общую папку libraries — тогда его увидит любой скетч.

Шаг второй: класс вместо функций

Почти все библиотеки устроены как класс: вы создаёте объект, а он хранит своё состояние. Возьмём задачу, которая встречается в каждом втором проекте, — мигать светодиодом без delay(), чтобы программа не останавливалась. Если светодиодов два и у каждого свой ритм, отдельные переменные для каждого быстро превращаются в кашу. Класс прячет их внутрь объекта.

В заголовке — объявление класса. Раздел public — то, что можно вызывать снаружи. Раздел private — внутреннее состояние, к нему из скетча не добраться; подчёркивание в начале имени — просто договорённость, чтобы отличать поля от аргументов.

Blinker.h

#pragma once
#include <Arduino.h>

class Blinker {
  public:
    Blinker(uint8_t pin, unsigned long interval);
    void begin();
    void update();
    void setInterval(unsigned long interval);
    bool isOn() const;

  private:
    uint8_t _pin;
    unsigned long _interval;
    unsigned long _last = 0;
    bool _on = false;
};

В реализации перед каждым методом стоит имя класса и два двоеточия: Blinker::update — это метод update класса Blinker. Строка после двоеточия в конструкторе — список инициализации: так поля получают значения сразу при создании объекта.

Blinker.cpp

#include "Blinker.h"

Blinker::Blinker(uint8_t pin, unsigned long interval)
  : _pin(pin), _interval(interval) {}

void Blinker::begin() {
  pinMode(_pin, OUTPUT);
  digitalWrite(_pin, LOW);
}

void Blinker::update() {
  if (millis() - _last >= _interval) {
    _last = millis();
    _on = !_on;
    digitalWrite(_pin, _on ? HIGH : LOW);
  }
}

void Blinker::setInterval(unsigned long interval) {
  _interval = interval;
}

bool Blinker::isOn() const {
  return _on;
}

Сравнение millis() - _last >= _interval записано именно так, через вычитание, не случайно: оно продолжает работать, когда через 49 дней счётчик millis() переполнится и начнёт с нуля.

Можно и без .cpp: библиотека из одного файла

Если класс маленький, его пишут целиком в заголовке: тела методов прямо внутри объявления класса. Такую библиотеку проще носить — это один файл, который достаточно положить рядом со скетчем. Методы, написанные внутри класса, компилятор считает inline, поэтому заголовок можно подключать в нескольких файлах без ошибки о повторном определении — мы проверили сборкой.

Debounce.h

#pragma once
#include <Arduino.h>

// кнопка с защитой от дребезга: весь класс в одном файле, .cpp не нужен
class Debounce {
  public:
    explicit Debounce(uint8_t pin) : _pin(pin) {}

    void begin() { pinMode(_pin, INPUT_PULLUP); }

    // true — ровно один раз на каждое нажатие
    bool pressed() {
      bool now = digitalRead(_pin) == LOW;
      if (now && !_was && millis() - _t > 30) {
        _t = millis();
        _was = true;
        return true;
      }
      if (!now) _was = false;
      return false;
    }

  private:
    uint8_t _pin;
    bool _was = false;
    unsigned long _t = 0;
};

Цена удобства: каждый файл, который подключает заголовок, заново разбирает весь код класса, и при правке любой строки пересобирается всё. Для сотни строк это незаметно; когда класс вырастает — время делить на .h и .cpp.

Защита от двойного подключения

Первая строка заголовка, #pragma once, говорит компилятору: «вставляй этот файл один раз, даже если его подключили дважды». Без неё класс, подключённый из скетча и из другой библиотеки, окажется объявлен два раза — и сборка остановится.

В старых библиотеках вместо неё встречается конструкция из трёх строк — #ifndef BLINKER_H, #define BLINKER_H в начале и #endif в конце. Делает то же самое; #pragma once короче и понимается всеми компиляторами, которые собирают скетчи Arduino.

Одна переменная на несколько файлов: extern

Функции разложили по файлам — следующим захочется так же разложить переменную: счётчик нажатий увеличивается в одном файле, а печатается в скетче. Первое, что приходит в голову, — объявить её в заголовке вместе с функцией:

// counter.h — так нельзя
#pragma once
#include <Arduino.h>

int pressCount = 0;   // определение прямо в заголовке
void countPress();

Заголовок подключён и в скетче, и в counter.cpp — значит, переменная создана дважды, и сборка останавливается на multiple definition of `pressCount'. #pragma once тут не помогает: он защищает от двойного подключения внутри одного файла, а файлов два.

Правильно — разделить так же, как функцию. В заголовке слово extern обещает, что переменная существует, но ничего не создаёт. Создаётся она ровно один раз, в одном .cpp:

// counter.h
#pragma once
#include <Arduino.h>

extern int pressCount;   // «где-то такая переменная есть»
void countPress();
// counter.cpp
#include "counter.h"

int pressCount = 0;      // а живёт она здесь, ровно в одном месте

void countPress() {
  pressCount++;
}

Теперь pressCount одна на всю программу: counter.cpp её увеличивает, скетч читает. Правило простое — в заголовке только обещания (объявления), всё, что занимает память или содержит код, — в .cpp.

static: что видно только внутри файла

Обратная задача: у библиотеки есть вспомогательная функция или переменная, которой незачем торчать наружу. Если в двух файлах окажутся две функции helper, сборка упадёт с тем же multiple definition — даже если никто не подключал заголовков. Слово static перед функцией или глобальной переменной ограничивает её одним файлом:

// a.cpp
static int helper() { return 1; }   // видна только внутри a.cpp
int fromA() { return helper(); }

// b.cpp
static int helper() { return 2; }   // своя, другая — конфликта нет
int fromB() { return helper(); }

С static этот код собирается, без него — нет. Внутри класса те же задачи решает раздел private, поэтому в библиотеках-классах static нужен реже.

Имена: чтобы не столкнуться с чужой библиотекой

В скетче живут десяток библиотек одновременно, и все их имена лежат в одном общем пространстве. Если ваша библиотека объявит функцию begin или константу LED, рано или поздно найдётся соседняя с таким же именем. Два приёма снимают проблему почти целиком.

Приставка к макросам. Всё, что объявлено через #define, не подчиняется никаким областям видимости — поэтому у макросов библиотеки должна быть приставка с её именем: BLINKER_DEFAULT_INTERVAL, а не DEFAULT_INTERVAL. А ещё лучше вместо макроса написать обычную константу.

Пространство имён. Свободные функции и константы заворачиваются в namespace, и снаружи к ним обращаются с приставкой. Классам это нужно реже — у них уже есть своё имя, но для набора функций это лучший способ.

#pragma once
#include <Arduino.h>

namespace blinker {
  const unsigned long DEFAULT_INTERVAL = 500;
  void blinkOnce(uint8_t pin, unsigned long ms = DEFAULT_INTERVAL);
}
#include "blink_tools.h"

void setup() {
  pinMode(LED_BUILTIN, OUTPUT);
}

void loop() {
  blinker::blinkOnce(LED_BUILTIN);   // приставка blinker:: — не спутать с чужим
  delay(1000);
}

Превращаем в библиотеку: папка и где она лежит

Библиотека — это папка с таким устройством. Главное правило: файл library.properties лежит прямо в папке библиотеки, а исходники — в подпапке src.

libraries/
└── Blinker/
    ├── library.properties
    ├── keywords.txt
    ├── src/
    │   ├── Blinker.h
    │   └── Blinker.cpp
    └── examples/
        └── TwoLeds/
            └── TwoLeds.ino

Сама папка libraries находится в папке скетчей: в Windows и macOS это Документы/Arduino/libraries, в Linux — ~/Arduino/libraries. Точный путь видно в Arduino IDE: Файл → Параметры → «Размещение папки скетчей». После того как папка библиотеки легла туда, среду нужно перезапустить — список библиотек она читает при старте.

library.properties: паспорт библиотеки

Простой текстовый файл: по строке на поле. По нему среда понимает, что библиотека устроена по-современному и исходники лежат в src. Без него она считает библиотеку старого формата и ищет файлы прямо в корне папки — при раскладке с src этот файл обязателен.

name=Blinker
version=1.0.0
author=Ivan Petrov
maintainer=Ivan Petrov <ivan@example.com>
sentence=Blink LEDs without delay().
paragraph=Several LEDs blink at their own pace and never block the sketch.
category=Signal Input/Output
url=https://example.com/blinker
architectures=*
полезачем
nameИмя в менеджере и в меню «Подключить библиотеку». Латиница, без пробелов в начале.
versionВерсия из трёх чисел: 1.0.0. Меняйте при каждом изменении — по ней среда понимает, что вышла новая.
author, maintainerКто написал и кто поддерживает. У maintainer принято указывать почту.
sentence, paragraphОдна строка и абзац описания — их показывает менеджер.
categoryРаздел каталога: Display, Sensors, Communication, Signal Input/Output и другие из списка Arduino.
architecturesДля каких плат: * — для любых, avr — только UNO, Nano, Mega; через запятую можно несколько.
dependsНеобязательно: какие библиотеки нужны этой. Менеджер предложит поставить их вместе.

Пример в меню «Файл → Примеры»

Каждая подпапка в examples — отдельный скетч, и имя файла .ino должно совпадать с именем папки. После перезапуска среды пример появится в меню Файл → Примеры → Blinker. Это лучшая документация к библиотеке: человек открывает готовый скетч и сразу видит, как ей пользоваться.

Обратите внимание: из скетча библиотека подключается уже в угловых скобках — она больше не лежит рядом, среда ищет её среди установленных.

examples/TwoLeds/TwoLeds.ino

#include <Blinker.h>

Blinker led(LED_BUILTIN, 500);   // встроенный светодиод, полсекунды
Blinker second(9, 150);          // второй светодиод на девятом выводе

void setup() {
  led.begin();
  second.begin();
}

void loop() {
  led.update();      // каждый мигает в своём ритме,
  second.update();   // и ни один не останавливает программу
}

keywords.txt: подсветка в редакторе

Необязательный файл, но с ним имена из библиотеки подсвечиваются цветом, как встроенные команды. Одно имя на строку, между именем и типом — табуляция, а не пробелы: KEYWORD1 — классы, KEYWORD2 — методы и функции, LITERAL1 — константы.

Blinker	KEYWORD1
begin	KEYWORD2
update	KEYWORD2
setInterval	KEYWORD2
isOn	KEYWORD2

Как поделиться библиотекой

Упакуйте папку Blinker целиком в ZIP-архив. На другом компьютере: Скетч → Подключить библиотеку → Добавить .ZIP библиотеку и указать архив. Среда сама разложит его в папку libraries.

Проверьте одно: внутри архива сразу должна лежать папка библиотеки, а в ней — library.properties. Если между ними оказалась ещё одна папка, среда библиотеку не увидит. Так чаще всего бывает с архивами, скачанными с GitHub: там папка называется Blinker-main, а настоящая библиотека лежит на уровень глубже.

Положите рядом с library.properties файл README.md: зачем библиотека, как подключить, короткий пример и список методов. Это первое, что человек увидит на странице репозитория, — без него хорошую библиотеку просто не поймут.

Как попасть в общий менеджер библиотек

Для своих проектов хватит архива. Если библиотека пригодится другим, её можно добавить в реестр Arduino — тогда она будет находиться по имени в менеджере, как Servo или FastLED. Порядок такой:

  1. Выложите библиотеку в открытый репозиторий на GitHub. Файл library.properties должен лежать в корне репозитория.
  2. Создайте релиз — тег с номером версии, совпадающим с полем version. Менеджер берёт только отмеченные тегом версии, а не последнее состояние кода.
  3. В репозитории arduino/library-registry добавьте адрес своего репозитория в файл repositories.txt и отправьте изменение на рассмотрение. Робот проверит структуру библиотеки и напишет, что поправить.
  4. После принятия библиотека появляется в менеджере в течение суток. Каждая следующая версия — это новое значение version и новый тег; менеджер подхватит её сам.

Как ставить чужие библиотеки и где их брать, — на общей странице про библиотеки.

Пять ошибок, на которых застревают все

'byte' was not declared in this scope

В своём .h или .cpp нет строки #include <Arduino.h>. В скетче она добавляется сама, в остальных файлах — нет, поэтому типы и функции Arduino там неизвестны. Рядом обычно стоит ещё одна строка — variable or field 'ledOn' declared void: это последствие той же причины. Добавьте подключение второй строкой заголовка.

fatal error: util.h: No such file or directory

Свой файл из папки скетча подключён в угловых скобках. <util.h> ищется среди установленных библиотек, а там его нет. Для файлов рядом со скетчем — кавычки: #include "util.h".

fatal error: Blinker.h: No such file or directory — хотя библиотека в папке

Лишний уровень вложенности: libraries/Blinker-main/Blinker/…. Среда смотрит только на один уровень внутрь libraries. Переложите папку с library.properties прямо туда и перезапустите среду.

multiple definition of `twice(int)'

Функция написана целиком, с телом, прямо в .h, а заголовок подключён в двух файлах. Каждый из них получил свою копию, и при сборке их оказалось две. Либо перенесите тело функции в .cpp, а в заголовке оставьте только объявление, либо допишите перед ней слово inline:

#pragma once

// inline разрешает одинаковому определению встречаться в нескольких файлах
inline int twice(int x) {
  return x * 2;
}

multiple definition of `pressCount'

То же самое, но с переменной: она создана в заголовке, а заголовок подключён в двух файлах. В заголовке оставьте extern int pressCount;, а саму переменную создайте в одном .cpp — как в разделе про extern выше.

Что дальше

Библиотека Blinker из этого урока собрана arduino-cli под Arduino UNO вместе с примером; ошибки получены сборкой намеренно сломанных вариантов.