ИЗ АРХИВА ЖУРНАЛА PRAGPUB АПРЕЛЬ 2011 ГОДА

Расширенный взлом Arduino: использование серьезных инструментов и методов разработчика для Arduino, популярной одноплатной платформы

Майк Шмидт

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

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

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

В этой статье я попытаюсь восполнить некоторые из этих пробелов. Вы узнаете, как профессионально разрабатывать программное обеспечение для Arduino. Например, вы увидите, как управлять своими проектами Arduino с помощью старого доброго Makefile, который можно легко интегрировать в вашу любимую IDE. Кроме того, вы узнаете, что платформа Arduino поддерживает почти все функции текущего стандарта C++ и что эти функции удобно использовать для программирования встраиваемых систем.

Но сначала мы внимательно рассмотрим внутренности Arduino IDE. Затем, как только мы увидим, как он превращает наши наброски в исполняемый код, мы создадим Makefile, чтобы полностью обойти его.

Что не так с Arduino IDE?

Одной из причин успеха Arduino является удобная для начинающих интегрированная среда разработки (IDE). Для людей, которые никогда не писали никакого программного обеспечения, Arduino IDE является идеальной отправной точкой. Он предлагает только те функции, которые вам абсолютно необходимы: скомпилируйте программу, перенесите ее на плату Arduino и отслеживайте последовательный порт. Кроме того, в IDE есть базовая поддержка группировки набора файлов в проект, поддерживается подсветка синтаксиса, а также помогает быстро находить примеры проектов и документацию.

Все это полезно для начинающих и полезно для небольших проектов, но как только ваши проекты станут более сложными, вам понадобится более сложная среда программирования. Возможно, вы захотите использовать свой любимый текстовый редактор или IDE, и вам может понадобиться более гибкий процесс сборки. Например, если вы разрабатываете видеоигру для одного из замечательных игровых щитов Arduino, вам понадобятся инструменты, которые превращают растровую графику в исходный код C/C++. Эти инструменты должны быть частью вашего автоматического процесса сборки, и их сложно добавить в исходную IDE.

Итак, давайте посмотрим, как Arduino IDE преобразует ваши скетчи в двоичный код для платы Arduino.

За кулисами

Иногда кажется, что людей немного раздражает язык, на котором программируется Arduino. Это происходит главным образом потому, что типичные примеры скетчей выглядят так, как будто они написаны на языке, который был разработан исключительно для программирования Arduino. Но это не так — это старый добрый C++ (из чего следует, что он поддерживает и C). Чтобы превратить код C++ в машинный код, который может выполнять Arduino, нам нужен подходящий компилятор.

Каждый Arduino использует микроконтроллер AVR, разработанный компанией Atmel. (Atmel говорит, что название AVR ничего не означает.) Эти микроконтроллеры очень популярны, и они используются во многих аппаратных проектах. Одной из причин их популярности является превосходный набор инструментов, основанный на инструментах компилятора GNU C++ и оптимизированный для генерации кода для микроконтроллеров AVR.

Это означает, что вы передаете код C/C++ компилятору AVR, и он преобразует его в машинный код не для вашего компьютера, а для микроконтроллера AVR. Это называется кросс-компиляция и является обычным способом программирования встроенных устройств.

Почти для всех инструментов разработки GNU, таких как gcc, ld или as, есть AVR pendant: avr-gcc, avr-ld и так далее. Вы можете найти их в каталоге hardware/tools/bin среды разработки Arduino. IDE в основном представляет собой графическую оболочку, которая помогает избежать прямого использования инструментов командной строки. Всякий раз, когда вы компилируете или загружаете программу с помощью IDE, она делегирует всю работу инструментам AVR.

Как серьезный разработчик программного обеспечения, вы должны включить подробный вывод IDE, чтобы вы могли видеть все вызовы инструментов командной строки. Загрузите произвольный эскиз и, удерживая нажатой клавишу Shift, щелкните Verify/Compile на панели инструментов IDE. Вывод на панели сообщений должен выглядеть примерно так, как на картинке.

Поначалу вызовы команд выглядят немного странно из-за имен множества создаваемых временных файлов. Вы по-прежнему должны быть в состоянии идентифицировать все шаги компиляции и компоновки, которые необходимы для создания даже простого скетча, такого как пример с мигающим светодиодом. Это самое важное, что сделала команда Arduino: они спрятали все эти неприятные детали за IDE, поэтому даже люди без опыта разработки программного обеспечения могут программировать Arduino.

Таким образом, для самостоятельной компиляции и загрузки программ Arduino вам необходимо выполнить те же действия, что и в среде IDE; то есть вы должны вызывать инструменты AVR в правильном порядке и для правильных файлов. В принципе, вы можете сделать это вручную, но гораздо эффективнее использовать для этой задачи Makefile.

Сделай сам

Теперь, когда мы знаем, как работает Arduino IDE внутри, мы обойдем его и будем использовать make для компиляции, загрузки и мониторинга наших программ. Make — это инструмент управления проектами, который существует уже несколько десятилетий и помогает вам создавать исполняемые файлы из исходных файлов. Например, он знает множество правил для превращения файлов C/C++ в объектные файлы, и у него есть несколько умных алгоритмов для вычисления зависимостей между файлами вашего проекта, поэтому он компилирует исходные файлы только в случае необходимости.

Напротив, Arduino IDE всегда перекомпилирует ваш скетч и все необходимые системные библиотеки. Обычно на современном компьютере это происходит очень быстро, но в этом нет необходимости, а в некоторых случаях это может даже раздражать. Кроме того, с помощью IDE сложно автоматизировать задачи. Например, вы могли завершить успешный проект, и теперь вам нужно перенести то же самое программное обеспечение на кучу Arduino. В этом случае вы хотите скомпилировать свою программу только один раз, а затем загрузить ее на любое количество устройств.

В типичном цикле разработки программы Arduino вы компилируете программу и загружаете ее на плату Arduino. Затем вы обычно запускаете последовательный монитор, чтобы увидеть, что происходит. Итак, наш идеальный Makefile должен поддерживать следующие цели:

  • все: это цель по умолчанию почти во всех файлах Makefile на этой планете, и она строит весь проект.
  • очистить: иногда полезно начать с нуля, и эта цель удаляет все артефакты сборки.
  • загрузить: загрузить программное обеспечение на плату Arduino и при необходимости скомпилировать его.
  • monitor: эта цель открывает последовательный монитор и подключает его к Arduino.
  • upload_monitor: это вспомогательная задача, которая загружает программу и открывает последовательный монитор.

Писать Makefile не весело, но, к счастью, Алан Берлисон сделал всю тяжелую работу уже поработал за нас. Он создал основной Makefile, который вы можете включить в свой собственный Makefile, так что вам нужно только настроить некоторые параметры. Мне пришлось внести небольшие изменения в основной Makefile, и вы можете скачать мою версию (вместе со всем остальным кодом, который я использую в этой статье) с GitHub.

Давайте посмотрим, как на самом деле использовать make для компиляции простой программы Arduino.

Привет, мир!

Одна из самых простых программ для Arduino выглядит следующим образом:

void setup() { 
  Serial.begin(9600);
}
void loop() { 
  Serial.println("Hello, world!"); 
  delay(1000);
}

Он инициализирует последовательный порт, а затем выводит текст «Hello, world!» бесконечно в петле. Вот Makefile нашего проекта:

# Your Arduino environment.
ARD_REV = 22
ARD_HOME = /Applications/Arduino.app/Contents/Resources/Java AVR_HOME = $(ARD_HOME)/hardware/tools/avr
ARD_BIN = $(AVR_HOME)/bin AVRDUDE = $(ARD_BIN)/avrdude
AVRDUDE_CONF = $(AVR_HOME)/etc/avrdude.conf
# Your favorite serial monitor.
MON_CMD = screen MON_SPEED = 9600
# Board settings.
BOARD = diecimila
PORT = /dev/tty.usbserial-A60061a3 
PROGRAMMER = stk500v1
# Where to find header files and libraries.
INC_DIRS = ./inc
LIB_DIRS = $(addprefix $(ARD_HOME)/libraries/, $(LIBS)) 
LIBS =
include ../Makefile.master

Как видите, вам нужно определить всего несколько переменных: ARD_REV указывает версию установленной вами Arduino IDE. Хотя вам больше не нужно использовать IDE для компиляции своих программ, вам по-прежнему необходимо установить IDE, чтобы иметь доступ ко всем инструментам и библиотекам. ARD_HOME должен указывать на каталог установки IDE.

Если ваша программа использует последовательный порт, установите MON_CMD на ваш любимый последовательный монитор. Кроме того, вы должны установить MON_SPEED на скорость передачи данных, которую вы используете в своей программе. К сожалению, последовательные мониторы часто различаются по синтаксису вызова, поэтому вам, возможно, придется изменить главный Makefile, чтобы запустить предпочитаемый вами последовательный монитор. Вот соответствующая часть главного Makefile:

upload : all
    - pkill -f '$(MON_CMD).*$(PORT)'
    - sleep 1
    - stty -f $(PORT) hupcl
    - $(AVRDUDE) -V -C$(AVRDUDE_CONF) -p$(MCU) -c$(PROGRAMMER) \
    -P$(PORT) -b$(UPLOAD_SPEED) -D -Uflash:w:$(IMAGE).hex:i
monitor :
    $(MON_CMD) $(PORT) $(MON_SPEED)

Настройте цель монитора в соответствии с вашими потребностями и обратите внимание, что цель загрузки удаляет все запущенные процессы последовательного монитора из списка процессов, используя pkill. В зависимости от вашего варианта UNIX вам также может потребоваться изменить вызовы pkill и stty. Например, в Mac OS X обычно нет команды pkill, но ее можно установить. Также переключатель команд stty ‘-f’ называется ‘-F’ в системах Linux.

Вернемся к конфигурации нашего основного Makefile. С помощью переменной BOARD вы определяете, какой тип платы Arduino вы используете. Список всех поддерживаемых плат можно найти в файле hardware/boards.txt в каталоге установки IDE. PORT содержит имя последовательного порта, к которому вы подключили Arduino.

Наконец, вы можете указать компилятору, где искать заголовочные файлы и библиотеки, используя INC_DIRS и LIB_DIRS. С помощью LIBS вы можете определить набор библиотек, которые должны быть связаны с вашим проектом. Если вам нужны библиотеки для LCD и EEPROM, например, установите LIBS на “LiquidCrystal EEPROM”. Вы можете найти много полезных библиотек в папке библиотек в каталоге установки IDE.

Не забудьте включить основной Makefile в конце, а затем запустите ваш Makefile в первый раз:

maik> make
...
avr-size build/HelloWorld.hex
text     data     bss     dec     hex     filename
0        2106     0       2106    83a     build/HelloWorld.hex

Если все пойдет хорошо, вы увидите много вывода, очень похожего на подробный вывод IDE. На самом деле, это почти то же самое. Основное отличие состоит в том, что в конце вы увидите вывод команды avr-size. Этот инструмент сообщает вам, сколько памяти ваша программа будет использовать на Arduino, а также какие части памяти она занимает. Это может быть полезным средством отладки, если вы используете слишком много памяти. И поверьте: рано или поздно вы это сделаете!

Вы могли заметить, что мы не указали цель при вызове make. Если вы не укажете цель, make по умолчанию запустит все цели. В нашем случае эта цель строит весь проект. Он создает каталог с именем build в каталоге вашего проекта. Там вы можете найти все артефакты, которые make создали в процессе сборки. Большинство файлов являются системными файлами и библиотеками, необходимыми для всех программ Ardunio, и они не отличаются от проекта к проекту. Только файлы, начинающиеся с “HelloWorld”, были собраны специально для нашего проекта.

Все файлы, заканчивающиеся на eep, elf и lst , являются двоичными файлами, которые вы, вероятно, знаете из других процессов сборки. Только HelloWorld.hex может быть для вас новым, и вы можете задаться вопросом, где находится окончательный исполняемый файл, такой как a.out? Ответ прост: HelloWorld.hex — это окончательный исполняемый файл. Он содержит все данные, которые мы будем загружать в Arduino.

Теперь запустите цель загрузки, и вы должны увидеть что-то вроде этого (для ясности я сократил некоторые пути):

maik> make upload
pkill -f 'screen.*/dev/tty.usbserial-A60061a3' 
sleep 1
stty -f /dev/tty.usbserial-A60061a3 hupcl
avrdude -V -Cavrdude.conf -patmega168 -cstk500v1 -b19200 \
-P/dev/tty.usbserial-A6 -D -Uflash:w:build/HelloWorld.hex:i 
avrdude: AVR device initialized and ready to accept instructions
Reading | ############################################### | 100% 0.02s
avrdude: Device signature = 0x1e9406
avrdude: reading input file "build/HelloWorld.hex" 
avrdude: writing
flash (2106 bytes):
Writing | ############################################### | 100% 1.60s
avrdude: 2106 bytes of flash written 
avrdude: safemode: Fuses OK
avrdude done. Thank you.

Здесь вы можете увидеть команду avrdude в действии. Этот инструмент отвечает за загрузку кода в Arduino, и вы также можете использовать его для программирования многих других устройств. Внимательно изучите вывод, чтобы увидеть, где make использует наши прежние определения переменных, такие как PORT.

Наконец, мы должны проверить, действительно ли наша программа делает то, что должна делать. Вызовите make monitor, и ваш последовательный монитор должен открыться и напечатать множество “Hello, world!” сообщений.

Вот и все! Вы скомпилировали и загрузили свою первую программу для Arduino, не запуская Arduino IDE. Кроме того, вы отслеживали вывод своей программы из командной строки и не использовали серийный монитор IDE. Вы хорошо подготовлены к следующим шагам!

Итак, давайте создадим более сложный проект: систему контроля дистанции в парке. Мы будем использовать некоторые расширенные функции C++, такие как пространства имен и программирование шаблонов, и рассмотрим динамическое управление памятью на Arduino.

Система контроля дистанции при парковке

Многие новые автомобили оснащены системой контроля дистанции при парковке (PDC), которая помогает вам въезжать на тесные парковочные места или покидать их. Всякий раз, когда вы собираетесь столкнуться с препятствием, например, с другим автомобилем, PDC подаст вам акустическое (а иногда и визуальное) предупреждение. Обычно PDC начинает издавать звуковой сигнал, если обнаруживает объект рядом с автомобилем, и увеличивает частоту сигналов по мере приближения. Если вы подходите слишком близко, он издает непрерывный предупреждающий сигнал.

Если мы собираемся построить собственный PDC, нам понадобится устройство для генерации акустических сигналов. Для наших целей идеально подходит пьезозуммер. Он дешевый и простой в использовании, потому что у него всего два контакта. Подключите один к земле Arduino, а другой к цифровому контакту № 13 Arduino (см. Рисунок). Неважно, какой из контактов зуммера вы подключаете к земле.

Также нам нужен датчик для измерения расстояния до ближайшего объекта, и у нас есть несколько вариантов. В коммерческих PDC используются ультразвуковые датчики, поскольку они обеспечивают высокую точность и большой диапазон. Для нашего PDC я выбрал инфракрасный датчик приближения SHARP GP2Y0A21YK0F, потому что он намного дешевле большинства ультразвуковых датчиков. Датчик излучает инфракрасный свет и измеряет время, за которое отраженный свет возвращается к датчику. Он прост в использовании, но имеет очень ограниченный радиус действия (от 10 до 80 см). Вы не могли бы использовать его для PDC реального автомобиля, но для небольшого робота или радиоуправляемой машины этого достаточно.

Инфракрасный датчик приближения представляет собой аналоговое устройство, выдающее напряжение, соответствующее расстоянию обнаружения. Подключите его сигнальный контакт к одному из аналоговых контактов Arduino, например, A0, соедините контакт заземления с землей Arduino, а его контакт Vcc — с контактом 5V Arduino (см. иллюстрацию).

Подключить схему нашего PDC было легко. Теперь давайте напишем код, чтобы оживить наше оборудование.

Использование пьезозуммера

Начнем с кода для пьезоизлучателя, и без лишних слов я представляю класс Speaker:

#include <stdint.h> 
#include "WProgram.h" 
  namespace arduino {
    namespace actuators {
       class Speaker {
         public:
           static const uint16_t DEF_FREQUENCY = 1000; 
           static const uint32_t DEF_DURATION = 200; 
           Speaker(const uint8_t speaker_pin) :
             _speaker_pin(speaker_pin) {}
           void beep(
             const uint16_t frequency = DEF_FREQUENCY,
             const uint32_t duration = DEF_DURATION) const
           {
             tone(_speaker_pin, frequency, duration);
           }
           private:
           uint8_t _speaker_pin;
    };
  }
}

Если вы не очень хорошо знакомы с C++, этот код поначалу может показаться немного шокирующим, но мы разберем его по частям. Прежде всего, мы подключаем два заголовочных файла. stdint.h определяет переносимые целочисленные типы, например uint16_t для 16-битного целого числа без знака. Эти типы выгодны по нескольким причинам, но самое главное, они делают ваш код более предсказуемым, поскольку длина типов данных в C/C++ строго не определена. Кроме того, они избавляют вас от набора текста, что всегда хорошо.

Заголовочный файл WProgram.h поставляется вместе с Arduino IDE и определяет все его основные константы и функции, такие как OUTPUT или digitalWrite. Мы должны включить его, потому что позже мы будем использовать функцию тона.

После того, как мы включили все необходимые файлы заголовков, мы объявляем вложенное пространство имен, используя ключевое слово namespace. Пространства имен помогают предотвратить конфликты имен при использовании кода из разных источников. Вы должны отделить вложенное пространство имен двойным двоеточием (::), поэтому все, что мы здесь определяем, принадлежит пространству имен arduino::actuators.

Затем мы определяем класс Speaker и начинаем с двух констант с именами DEF_FREQUENCY и DEF_DURATION. Они содержат частоту по умолчанию (в герцах) и продолжительность по умолчанию (в миллисекундах) нашего предупреждающего сигнала. Обратите внимание, что мы впервые используем типы из stdint.h.

Вы также должны отметить, что мы используем ключевое слово const для определения констант. Многие до сих пор используют директиву препроцессора #define для определения констант, и я настоятельно рекомендую вам избавиться от этой привычки! Компилятор никогда не увидит имя константы, определенной с помощью #define, поэтому это может привести к странным сообщениям об ошибках и длительным сеансам отладки. Кроме того, в зависимости от типа вашей константы, компилятор часто будет создавать более эффективный код при использовании правильных определений констант с использованием const.

Вы заметили, что в современном C++ можно определять статические константы (они же константы класса), такие как DEF_DURATION, непосредственно в объявлении класса? Это очень красивая функция, которая также помогает поддерживать чистоту пространств имен.

Конструктор нашего класса Speaker ожидает один аргумент (номер цифрового контакта, к которому он подключен) и инициализирует закрытый член класса с именем _speaker_pin. Имена частных членов не обязательно должны начинаться с символа подчеркивания в C++, но я думаю, что при определенных обстоятельствах это делает код более читаемым.

Вам может быть интересно, как мы инициализируем _speaker_pin без использования присваивания. Мы использовали список инициализации элементов, который в C++ является предпочтительным способом инициализации членов класса. В большинстве случаев инициализация члена более эффективна, чем операторы присваивания, а в некоторых случаях, например, для постоянных членов или ссылок, вам даже придется использовать ее. В целом, это никогда не является недостатком и часто более эффективно.

Затем мы определяем метод с именем beep, который принимает два аргумента: частоту и продолжительность. Оба аргумента имеют значения по умолчанию, поэтому, если вы, например, не передадите длительность, она будет автоматически установлена ​​на DEF_DURATION. Тело метода простое, потому что оно делегирует всю тяжелую работу функции тона Arduino.

Однако стоит упомянуть одну вещь: мы объявляем метод beep как const. Это сообщает компилятору, что вызов этого метода не меняет состояние класса. Это помогает компилятору оптимизировать машинный код, а также помогает обнаруживать ошибки при передаче константных объектов. Используйте const везде, где это возможно!

Определение класса Speaker завершено, и вы можете использовать его следующим образом:

#include "speaker.h"
using namespace arduino::actuators; Speaker speaker(13); speaker.beep(1000, 500);

Этот фрагмент кода использует пьезозуммер, подключенный к контакту 13, и выдает тон с частотой 1000 Гц в течение 500 миллисекунд. Обратите внимание, что мы импортируем arduino::actuators namespace с использованием директивы пространства имен. Без него вам пришлось бы использовать полное имя arduino::actuators::Speaker.

Даже в нашем небольшом классе Speaker мы можем воспользоваться многими полезными функциями C++. Мы использовали пространства имен, константы классов, объявления константных функций и списки инициализации членов. Мы будем использовать еще больше возможностей, когда начнем управлять инфракрасным датчиком приближения.

Использование инфракрасного датчика приближения

Наш инфракрасный датчик приближения является аналоговым устройством, поэтому выдает напряжение, соответствующее текущему расстоянию обнаружения. Его техническое описание содержит несколько графиков, объясняющих, как определенное напряжение может быть сопоставлено с расстоянием. Мы можем упростить вычисление расстояния, потому что расстояние примерно равно 27 В * см.

У вас может возникнуть соблазн прочитать сигнал датчика с аналогового контакта A0 с помощью analogRead и немедленно преобразовать его в расстояние. К сожалению, реальный мир немного сложнее, потому что сигналы датчиков часто подвержены джиттеру и искажениям. Таким образом, гораздо лучше постоянно добавлять данные датчика в небольшой буфер и вычислять их среднее значение. Если буфер заполнен и поступает новое значение датчика, самое старое значение будет удалено из буфера. Мы называем такую ​​структуру данных кольцевым буфером.

После того, как я написал бесчисленное количество реализаций кольцевого буфера с использованием массивов фиксированной длины различных типов, я решил найти повторно используемое решение. Вот мой динамический класс RingBuffer для всех целочисленных типов C++:

namespace arduino { namespace util {
  template<typename T>
    class RingBuffer {
      private:
        T* _samples; 
        uint16_t _sample_pos; 
        uint16_t _buffer_size;
      public:
        static const uint16_t DEF_SIZE = 16; 
        RingBuffer(const uint16_t buffer_size = DEF_SIZE) {
          _sample_pos = 0;
          _buffer_size = buffer_size != 0 ? buffer_size : DEF_SIZE;
          _samples = static_cast<T*>( 
            malloc(sizeof(T) * _buffer_size)
          );
        }
        RingBuffer(const RingBuffer& rhs) {
          *this = rhs;
        }
        RingBuffer& operator=(const RingBuffer& rhs) {
          if (this != &rhs) {
            _sample_pos = rhs._sample_pos;
            _buffer_size = rhs._buffer_size;
            _samples = static_cast<T*>( 
              malloc(sizeof(T) * _buffer_size)
            );
          for (uint16_t i = 0; i < _buffer_size; i++)
            _samples[i] = rhs._samples[i];
        }
        return *this;
      }
      ~RingBuffer() { 
        free((void*)_samples);
      }
      void addValue(const T value) {
        _samples[_sample_pos] = value;
        _sample_pos = (_sample_pos + 1) % _buffer_size;
      }
      T getAverageValue() const {
        float sum = 0.0;
        for (uint16_t i = 0; i < _buffer_size; i++) 
          sum += _samples[i];
        return round(sum / _buffer_size);
      }
      uint16_t getBufferSize() const {
        return _buffer_size;
      }
    };
  }
}

Этот класс использует множество функций, которые мы уже видели в нашем классе Speaker. Тем не менее, у нас есть кое-что новое: классы шаблонов. Вы можете делать много интересных вещей, используя классы-шаблоны, но в основном вы будете использовать их для создания классов для разных типов. В нашем случае это помогает нам заставить RingBuffer работать для всех целочисленных типов C++, таких как int или long.

Определить классы шаблонов легко. Просто поместите объявление template<typename T> перед объявлением класса. Теперь вы можете использовать «T» в качестве заполнителя для каждого типа C++ в вашем классе (вы можете выбрать любое имя вместо «T», но это широко распространенное соглашение).

Мы используем заполнитель в первый раз, чтобы объявить член выборки. Здесь мы объявляем указатель на реальный буфер памяти, который будем использовать. В нешаблонном классе это объявление было бы чем-то вроде uint16_t*_samples;, но с помощью шаблонов мы можем быть более общими. Затем мы определяем еще две переменные-члены. _sample_pos сохраняет нашу текущую позицию в буфере, поэтому мы знаем, когда нам нужно удалить старые выборки данных. _buffer_size содержит размер нашего буфера сэмплов.

Открытый интерфейс нашего класса начинается с константы класса с именем DEF_SIZE, которая указывает размер по умолчанию для кольцевого буфера. Конструктор — это тонкий фрагмент кода. Он принимает размер буфера в качестве аргумента и инициализирует все закрытые члены. В этом случае мы не можем использовать списки инициализации членов, потому что мы не можем использовать чистые операторы присваивания для инициализации переменных-членов. Прежде всего, мы устанавливаем _sample_pos на 0. Затем инициализируем _buffer_size и убеждаемся, что размер буфера больше 0. Если это не так, мы используем размер по умолчанию.

Вы можете подумать, что в таком случае было бы более уместно создать исключение, и я полностью с вами согласен. К сожалению, мы обнаружили одно из основных ограничений при программировании Arduino: он не поддерживает исключения, потому что их накладные расходы во время выполнения были бы слишком велики. Поэтому нам пришлось найти компромисс, и использование размера по умолчанию в данном случае является разумным подходом. Если вам нужно больше контроля, вы должны добавить отдельный метод инициализации, который возвращает код ошибки.

В следующей строке мы инициализируем буфер сэмплов и находим еще одно ограничение. Обычно оператор new в C++ используется для динамического выделения объектов, а оператор delete используется для возврата занятой памяти. Оба оператора не поддерживаются на Arduino, поэтому нам пришлось использовать старый добрый malloc. Для наших целей это не имеет значения, потому что мы будем выделять память только для целочисленных типов. Когда вы выделяете память для «настоящих» объектов, это имеет большое значение, потому что оператор new автоматически вызывает конструктор каждого объекта, а malloc — нет. То же самое верно и для возвращения памяти. delete автоматически вызывает все деструкторы, а free — нет.

Обратите внимание, что мы используем блестящий новый оператор static_cast C++, чтобы превратить указатель void, возвращаемый malloc, в указатель на наши общие объекты T. Внимательные читатели наверняка заметили, что мы не проверяли, возвращает ли malloc NULL. Мы должны были это сделать, и в следующей версии этого класса я обязательно добавлю метод инициализации!

Следующие три метода вы найдете почти в каждом классе C++, который инкапсулирует указатели на динамическую память. Мы определяем конструктор копирования, оператор присваивания и деструктор. Эти методы гарантируют, что вы можете безопасно копировать объекты и передавать их методам, не нарушая динамическую память.

Наконец, мы реализуем бизнес-логику кольцевого буфера. addValue принимает значение данных, имеющее общий тип, и добавляет его к кольцевому буферу. Он использует оператор модуля, чтобы убедиться, что мы не превышаем границы буфера данных, и автоматически отбросить старые значения, если это необходимо. getAverageValue вычисляет среднее значение текущего буфера выборки и возвращает его как универсальный тип.

Теперь, когда у нас есть общая реализация кольцевого буфера, давайте воспользуемся ею для реализации класса InfraredSensor:

#include <stdint.h>
#include  "ring_buffer.h" using namespace arduino::util; 
  namespace arduino {
    namespace sensors {
      class InfraredSensor {
        private:
          uint8_t _pin;
          RingBuffer<uint16_t> _buffer;
        public:
          static const float SUPPLY_VOLTAGE = 4.7;
          static const float VOLTS_PER_CM = 27.0; 
            InfraredSensor(const uint8_t pin);
            void update(void);
            float getDistance(void) const;
       };
    }
}

Этот класс имеет только два члена данных. В _pin мы сохраняем номер аналогового вывода, к которому мы подключили датчик, а _buffer — это кольцевой буфер, который мы собираемся использовать для устранения джиттера сигнала датчика. Здесь мы должны впервые создать конкретную реализацию нашего шаблонного класса. Когда компилятор встречает объявление RingBuffer<uint16_t>, он заменяет все вхождения универсального типа «T» на uint16_t и после этого компилирует файл. Если в вашем следующем проекте вы работаете с датчиком, который выдает меньшие значения, может быть достаточно, например, использовать RingBuffer<uint8_t>.

Остальная часть объявления класса довольно проста. Мы определяем конструктор и два метода для обновления датчика и для получения расстояния до ближайшего объекта. Их реализация проста:

#include <WProgram.h>
#include <stdint.h>
#include "infrared_sensor.h"
namespace arduino { 
  namespace sensors {
    InfraredSensor::InfraredSensor(const uint8_t pin) : _pin(pin) {
      for (uint16_t i = 0; i < _buffer.getBufferSize(); i++)
        update();
    }
    void InfraredSensor::update(void) {
      _buffer.addValue(analogRead(_pin));
    }
    float InfraredSensor::getDistance(void) const {
      const float voltage =
        _buffer.getAverageValue() * SUPPLY_VOLTAGE / 1024.0;
      return VOLTS_PER_CM / voltage;
    }
  }
}

Конструктор использует список инициализации членов для инициализации переменной _pin, а затем он инициализирует кольцевой буфер, поэтому у нас есть некоторые данные, доступные с самого начала. update считывает текущий выходной сигнал датчика с помощью функции Arduino analogRead и добавляет его в кольцевой буфер. Затем мы используем простую формулу для расчета расстояния до ближайшего объекта в getDistance.

Разве этот код не прекрасен? Это в основном потому, что мы разделили задачи и потому, что мы могли сделать все методы очень маленькими. Также RingBuffer полностью независим от всех других классов и даже от всех вещей Arduino. Таким образом, вы можете легко протестировать его и даже использовать в проектах, не связанных с Arduino.

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

Создание приложения PDC

Единственное, чего не хватает, так это бизнес-логики PDC, но сейчас это совсем несложно. Мы уже реализовали все основные классы, и на иллюстрации вы можете увидеть диаграмму классов нашего конечного продукта. Не хватает только класса ParkDistanceControl и вот его интерфейс:

#include "infrared_sensor.h"
#include "speaker.h"
using namespace arduino::sensors; 
using namespace arduino::actuators; 
namespace arduino {
  class ParkDistanceControl {
    private:
      InfraredSensor  _ir_sensor; 
      Speaker         _speaker;
      float           _mounting_gap;
    public:
      static const float MIN_DISTANCE = 8.0; 
      static const float MAX_DISTANCE = 80.0; 
      ParkDistanceControl(
        const InfraredSensor& ir_sensor,
        const Speaker&        speaker,
        const float           mounting_gap = 0.0) :
        _ir_sensor(ir_sensor),
        _speaker(speaker),
        _mounting_gap(mounting_gap) {}
      void check(void);
  };
}

Как показано на диаграмме классов, этот класс использует объект InfraredSensor и экземпляр класса Speaker. Кроме того, он сохраняет монтажный зазор. Это необходимо, поскольку при расчете расстояния до ближайшего объекта приходится учитывать, что датчик обычно не крепится непосредственно к поверхности автомобиля.

Конструктор класса только инициализирует все элементы данных, поэтому мы могли бы реализовать его в определении класса. С помощью метода check вы можете проверить текущее состояние PDC, и его реализация выглядит так:

#include <WProgram.h> 
#include "pdc.h" 
namespace arduino {
  void ParkDistanceControl::check(void) {
    _ir_sensor.update();
    const float distance =
      _ir_sensor.getDistance() - _mounting_gap;
    if (distance <= MIN_DISTANCE) { 
     Serial.println("Too close!");
     _speaker.beep();
    } else if (distance >= MAX_DISTANCE) { 
      Serial.println("OK.");
    } else { 
      Serial.print(distance); 
      Serial.println(" cm");
    }
  }
}

Здесь нет больших сюрпризов: мы обновляем инфракрасный датчик, а затем вычисляем расстояние до ближайшего объекта. Затем мы проверяем, меньше ли расстояние, чем минимально допустимое расстояние. Если да, мы печатаем соответствующее сообщение на последовательный порт в целях отладки и издаем звуковой сигнал нашего динамика. Если расстояние больше, чем диапазон датчика, мы печатаем «ОК» в последовательный порт, а в остальных случаях мы печатаем текущее расстояние.

Наконец, мы создаем минималистичный скетч Arduino для нашего проекта:

#include <stdint.h>
#include "pdc.h"
const uint16_t BAUD_RATE = 57600; 
const uint8_t IR_SENSOR_PIN = A0; 
const uint8_t SPEAKER_PIN = 13; 
const float MOUNTING_GAP = 3.0; 
arduino::ParkDistanceControl pdc(
  InfraredSensor(IR_SENSOR_PIN), 
  Speaker(SPEAKER_PIN), 
  MOUNTING_GAP
);
void setup(void) { 
  Serial.begin(BAUD_RATE);
}
void loop(void) { 
  pdc.check(); 
  delay(50);
}

Мы инициализируем глобальный объект ParkDistanceControl и передаем ему экземпляр InfraredSensor, объект Speaker и монтажный зазор. Функция настройки только инициализирует последовательный порт, и в цикле мы проверяем состояние PDC каждые 50 миллисекунд. Были сделаны!

Вы можете использовать следующий Makefile для создания, загрузки и мониторинга PDC:

ARD_REV = 22
ARD_HOME = /Applications/Arduino.app/Contents/Resources/Java AVR_HOME = $(ARD_HOME)/hardware/tools/avr
ARD_BIN = $(AVR_HOME)/bin 
AVRDUDE = $(ARD_BIN)/avrdude
AVRDUDE_CONF = $(AVR_HOME)/etc/avrdude.conf 
PROGRAMMER = stk500v1
MON_CMD = screen 
MON_SPEED = 57600
PORT = /dev/tty.usbmodemfa141 
BOARD = uno
LIB_DIRS = $(addprefix $(ARD_HOME)/libraries/, $(LIBS)) include ../Makefile.master

Makefile автоматически компилирует все файлы, заканчивающиеся на pde, c и cpp (в настоящее время он игнорирует файлы, заканчивающиеся на cc). Во время работы последовательного монитора поставьте руки перед датчиком и посмотрите, как изменится выходной сигнал.

Что насчет производительности?

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

Кроме того, правильный код C++ стоит не так дорого, как вы думаете, потому что современные компиляторы массово оптимизируют свой вывод. Большинство функций, которые вы видели в этой статье, вообще не влияют на производительность или память и могут даже помочь уменьшить размер вашей программы.

Репозиторий кода статьи содержит бесклассовую реализацию PDC в одном файле. Он максимально прост и делает то же самое, что и наша объектно-ориентированная версия. Для этого требуется 5690 байт, а для объектно-ориентированной версии — 6646 байт. Это небольшие накладные расходы, особенно если учесть, что обычно гораздо проще рефакторить и оптимизировать объектно-ориентированную версию.

Интересно, что размер кода составляет 6756 байт, когда я использую IDE для сборки проекта. Очевидно, что IDE использует разные настройки для компилятора и компоновщика, что приводит к последней важной проблеме: переносимости. Если вы планируете выпустить свой проект для широкой публики, вы должны иметь в виду, что люди будут ожидать, что ваш проект будет работать с Arduino IDE.

Для этого вам нужно всего лишь следовать нескольким соглашениям: главная папка вашего проекта должна иметь то же имя, что и ваш файл pde (в нашем случае это park_distance_control). Все файлы должны находиться в одном каталоге, поэтому вы не можете помещать файлы заголовков в отдельную включаемую папку, например. Не используйте файл cpp с тем же именем, что и ваш файл pde. В нашем случае у вас не должно быть файла с именем, например, park_distance_control.cpp. Если вы следуете этим правилам, ваш проект должен работает нормально с IDE и вашим Makefile.

В целом, я думаю, стоит относиться к каждому проекту Arduino как к серьезному проекту разработки программного обеспечения. Тот факт, что кодовая база может быть небольшой, не означает, что она должна быть небрежной.

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

О Майке Шмидте

Майк Шмидт более 20 лет работает разработчиком программного обеспечения и зарабатывает на жизнь созданием сложных решений для крупных предприятий. Помимо основной работы, он пишет рецензии на книги и статьи для журналов по информатике. Он является автором книг Arduino: краткое руководство, второе издание и Raspberry Pi: краткое руководство, второе издание, доступных на The Pragmatic Bookshelf.