Добрый день, уважаемые хабровчане! Хочу представить общественности мой проект — небольшая отладочная плата на базе STM32, но в форм-факторе Raspberry Pi. От других отладочных плат она отличается тем, что имеет совместимую с корпусами от Raspberry Pi геометрию и наличие ESP8266-модуля в качестве беспроводного модема. А также приятные дополнения в виде разъёма для micro-SD карты и стерео-усилителя. Для использования всего этого богатства я разработал высокоуровневую библиотеку и демонстрационную программу (на C++11). В статье я хочу подробно описать как аппаратную, так и программную части этого проекта.
Кому этот проект может быть полезен? Наверное, только тем, кто захочет спаять эту плату сам, так как никакие варианты даже мелкосерийного производства я не рассматриваю. Это чисто хобби. На мой взгляд, плата покрывает достаточно широкий спектр задач, которые могут возникнуть в рамках небольших домашних поделок, использующих WiFi и звук.
Для начала, попытаюсь ответить на вопрос, зачем это все. Основные мотиваторы этого проекта выглядят так:
- Выбор платформы STM32 обусловлен чисто эстетическими соображениями — нравится соотношение цена/производительность, плюс широкий спектр периферии, плюс большая и удобная экосистема разработки от производителя контроллеров (sw4stm, cubeMX, HAL library).
- Конечно, есть много отладочных плат как от самого производителя контроллеров (Discovery, Nucleo), так и от сторонних производителей (например, Olimex). Но повторить многие из них в домашних условиях в своём форм-факторе является проблематичным, для меня, по крайней мере. В моём же варианте имеем несложную двухслойную топологию и удобные для ручной пайки компоненты.
- Для своих устройств хочется иметь достойные корпуса, дабы замаскировать низкое качество электроники внутри. Есть по крайней мере две популярные платформы, для которых имеется огромное количество самых разнообразных корпусов: Ардуино и Raspberry Pi. Вторая из них мне показалась более удобной с точки зрения расположения вырезок под разъёмы. Поэтому в качестве донора для геометрии платы я выбрал именно её.
- Выбранный мной контроллер на борту имеет USB, SDIO, I2S, сеть. С другой стороны, эти же интерфейсы полезны и для домашней хобби-платформы. Именно поэтому, помимо самого контроллера со стандартной обвязкой, я добавил USB разъём, SD карточку, звуковой тракт (цифро-аналоговый конвертор и усилитель), а также беспроводной модуль на базе ESP8266.
Схема и компоненты
Как мне кажется, получилась достаточно симпатичная плата со следующими характеристиками и компонентами:
- Контроллер STM32F405RG: ARM 32-bit Cortex -M4 с математическим сопроцессором, частота до 168 MHz, 1 Mb флаш-памяти, 196 Kb оперативной памяти.
- Разъём SWD для программирования контроллера (6 контактов).
- Кнопка Reset для перезагрузки.
- Трёхцветный светодиод. С одной стороны, три вывода контроллера потеряны. С другой стороны, они бы все равно потерялись в силу ограниченности контактов на разъёмах GPIO, а для отладки такой светодиод вещь очень полезная.
- Высокочастотный HSE (16 MHz для тактирования ядра) и низкочастотный LSE (32.7680 kHz для часов реального времени) кварцы.
- Контакты GPIO с шагом 2.54 мм совместимы с макетными платами.
- На месте 3.5 мм звукового разъёма Raspberry Pi я расположил разъём питания 5 вольт. На первый взгляд, решение спорное. Но есть доводы "за". Питание с разъёма USB опционально присутствует (подробности ниже), но для отладки схемы это плохой вариант, так как время до сжигания USB порта компьютера в этом случае может быть довольно коротким.
- Разъём mini-USB. С одной стороны, он подключён через микросхему защиты STF203-22.TCT к порту USB-OTG контроллера. С другой стороны, контакт питания VBUS выведен на разъём GPIO. Если соединить его с контактом +5V, то плата будет запитана от USB порта.
- Разъём карты памяти micro-SD с обвязкой: подтягивающие резисторы на 47 kΩ, транзистор управления питанием (P-channel MOSFET BSH205) и маленький зелёненький светодиод на линии питания.
Затвор транзистора подключён к контакту PA15 контроллера. Это системный контакт JTDI контроллера, который интересен тем, что в исходном положении он сконфигурирован как выход с высоким уровнем (pull-up) напряжения. Так как вместо JTAG для программирования задействован SWD, данный контакт остаётся свободным, и его можно использовать для других целей, например, управления транзистором. Это удобно — при подаче питания на плату карта памяти обесточена, для её включения нужно подать низкий уровень на контакт PA15.
- Цифро-аналоговый преобразователь на основе UDA1334. Этой микросхеме не нужен внешний тактовый сигнал, что облегчает её использование. Данные передаются по шине I2S. С другой стороны, Datasheet рекомендует использовать аж 5 полярных конденсаторов на 47 μF. Размер в данном случае важен. Самые маленькие, которые получилось купить, это танталовые с размером 1411, которые очень даже не дешёвые. Впрочем, про цену я напишу подробней чуть ниже. Для аналогового питания используется свой линейный стабилизатор, питание цифровой части включается/выключается сдвоенным транзистором.
- Двухканальный усилитель на основе двух микросхем 31AP2005. Их основное преимущество — малое количество компонент обвязки (только фильтры питания и входной фильтр). Аудиовыход — 4 площадки с шагом 2.54 мм. Для себя я так пока не определился, что лучше — такой кустарный вариант или, как на малинке, 3.5 мм штекер. Как правило, 3.5 мм ассоциируется с наушниками, в нашем же случае речь идёт о подключении динамиков.
- Последний модуль — платка ESP11 с обвязкой (питание, разъём для программирования) в качестве WiFi модема. Выводы UART платы подключены к контроллеру и одновременно выведены на внешний разъём (для работы с платой напрямую с терминала и программирования). Есть переключатель питания (постоянное внешнее или управление с микроконтроллера). Есть дополнительный светодиод для индикации питания и разъём «FLASH» для перевода платы в режим программирования.
Конечно, ESP8266 сам по себе неплохой контроллер, но он всё-таки уступает STM32F4 как по производительности, так и по периферии. Да и размер с ценой этого модуля так и намекают, что это прямо-таки вылитый модемный блок для своего более старшего собрата. Модуль управляется по USRT с использованием текстового AT протокола.
Подготовка модуля ESP11
ESP8266 — вещь известная. Я уверен, что многие с ней уже знакомы, поэтому подробное руководство будет здесь лишним. В силу схематических особенностей подключения модуля ESP11 к плате, приведу только краткое руководство для тех, кто желает поменять его прошивку:
- Для работы с ESP буду использовать утилиту esptool. В отличие от стандартной утилиты от производителя, esptool является платформенно-независимой.
- Для начала, включаем режим внешнего питания перемычкой ESP-PWR (замыкаем контакты 1 и 2), и подключаем модуль к компьютеру через любой USART-USB адаптер. Адаптер подключается к контактам GRD/RX/TD. Подаём питание на плату:
- Убеждаемся, что адаптер опознан операционной системой. В моём примере, я использую адаптер на базе FT232, поэтому с списке устройств он должен быть виден как FT232 Serial (UART) IC:
> lsusb ... Bus 001 Device 010: ID 0483:3748 STMicroelectronics ST-LINK/V2 Bus 001 Device 009: ID 0403:6001 Future Technology Devices International, Ltd FT232 Serial (UART) IC ...
- Сами ESP8266 различаются объёмом флэш-памяти. На практике, в одном и том же модуле ESP11, я встречал как 512 KB (4 Mbit), так и 1 MB (8 Mbit). Так что первое, что нужно проверить — сколько памяти в используемом экземпляре модуля. Отключаем питание с платы, и переводим модуль в режим программирования, замыкая перемычку "FLASH":
- Включаем питание, запускаем esptool со следующими параметрами
> esptool.py --port /dev/ttyUSB0 flash_id
Connecting....
Detecting chip type... ESP8266
Chip is ESP8266EX
Uploading stub...
Running stub...
Stub running...
Manufacturer: e0
Device: 4014
Detected flash size: 1MB
Hard resetting...
- esptool сообщает, что, в данном случае, мы имеем дело с модулем с 1 MB памяти.
- Для версии с 1 MB можно использовать актуальную прошивку, например, ESP8266 AT Bin V1.6.1. Но она не подходит для версии с 4 Mbit, для которой нужно использовать что-нибудь постарее, например, эту. Прошивка состоит из нескольких файлов, стартовые адреса каждого файла указаны в официальном документе ESP8266 AT Instruction Set. Эти стартовые адреса используются как параметры утилиты esptool. Например, для модуля с 1 MB параметры esptool будут выглядеть так (все необходимые файлы нужно предварительно извлечь из архива прошивки и собрать в рабочем каталоге)
> esptool.py --port /dev/ttyUSB0 write_flash 0x00000 boot.bin 0x01000 user1.1024.new.2.bin 0x7E000 blank.bin 0xFB000 blank.bin 0xFC000 esp_init_data_default.bin 0xFE000 blank.bin
- Подаём питание на плату, запускаем esptool с указанными параметрами.
- После завершения работы скрипта отключаем питание от платы, размыкаем перемычку "FLASH", включаем управление питанием с микроконтроллера. Модуль к работе готов.
Программное обеспечение
На github находится тестовая программа. Она делает следующее:
- выводит контроллер на максимальную частоту (168 MHz)
- активирует часы реального времени
- активирует SD карту и читает с неё сетевую конфигурацию. Для работы с файловой системой используется библиотека FatFS
- устанавливает соединение с заданной сетью WLAN
- соединяется с заданным NTP сервером и запрашивает с него текущее время. Подводит часы.
- контролирует состояние нескольких заданных портов. Если их состояние изменилось, посылает текстовое сообщение на заданный TCP сервер.
- при нажатии на внешнюю кнопку читает заданный *.wav файл с SD карты и воспроизводит его в асинхронном режиме (I2S с использованием DMA контроллера).
- работа с ESP11 также реализована в асинхронном режиме (пока без DMA, просто на прерываниях)
- осуществляет логирование через USART1 (контакты PB6/PB7)
- ну и, конечно же, мигает светодиодом.
На Хабре было много статей, посвящённых программированию STM32 на достаточно низком уровне (только управлением регистров или CMSIS). Например, из относительно последних: раз, два, три. Статьи, безусловно, очень качественные, но моё субъективное мнение — для разовой разработки какого-либо продукта этот подход, быть может, себя и оправдывает. Но вот для длительного хобби-проекта, когда хочется, чтобы всё было красиво и расширяемо, этот подход уж слишком низкоуровневый. Одна из причин популярности Ардуино именно как программной платформы, на мой взгляд, заключается в том, что авторы Ардуино ушли с такого низкого уровня на объектно-ориентированную архитектуру. Поэтому я решил пойти в этом же направлении и надстроить над библиотекой HAL достаточно высокоуровневую объектно-ориентированную прослойку.
Таким образом, получается три уровня программы:
- Библиотеки производителя (HAL, FatFS, в будущем USB-OTG) образуют фундамент
- На этом фундаменте базируется моя библиотека StmPlusPlus. Она включает в себя набор базовых классов (типа System, IOPort, IOPin, Timer, RealTimeClock, Usart, Spi, I2S), набор классов-драйверов внешних устройств (типа SdCard, Esp11, DcfReceiver, Dac_MCP49x1, AudioDac_UDA1334 и тому подобное), а также сервисные классы типа асинхронного проигрывателя WAV.
- На базе библиотеки StmPlusPlus строится уже само приложение.
Что касается диалекта языка. Пока я несколько старомоден — остаюсь на C++11. Этот стандарт имеет несколько фишек, особенно полезных для разработки встроенного ПО: классы-перечисления (enum class), вызов конструкторов при помощи фигурных скобок для контроля типов передаваемых параметров, статические контейнеры типа std::array. Кстати, на Хабре есть замечательная статья на эту тему.
Библиотека StmPlusPlus
Полный код библиотеки можно посмотреть на github. Здесь же я приведу только несколько небольших примеров, чтобы показать структуру, идею и проблемы, этой идеей порождённые.
Первый пример — класс для периодического опроса состояния пина (например, кнопки) и вызова обработчика при изменении этого состояния:
class Button : IOPin
{
public:
class EventHandler
{
public:
virtual void onButtonPressed (const Button *, uint32_t numOccured) =0;
};
Button (PortName name, uint32_t pin, uint32_t pull, const RealTimeClock & _rtc, duration_ms _pressDelay = 50, duration_ms _pressDuration = 300);
inline void setHandler (EventHandler * _handler)
{
handler = _handler;
}
void periodic ();
private:
const RealTimeClock & rtc;
duration_ms pressDelay, pressDuration;
time_ms pressTime;
bool currentState;
uint32_t numOccured;
EventHandler * handler;
};
Конструктор определяет все параметры кнопки:
Button::Button (PortName name, uint32_t pin, uint32_t pull, const RealTimeClock & _rtc, duration_ms _pressDelay, duration_ms _pressDuration):
IOPin{name, pin, GPIO_MODE_INPUT, pull, GPIO_SPEED_LOW},
rtc{_rtc},
pressDelay{_pressDelay},
pressDuration{_pressDuration},
pressTime{INFINITY_TIME},
currentState{false},
numOccured{0},
handler{NULL}
{
// empty
}
Если обработка таких событий не является приоритетной задачей, то использование прерываний здесь явно лишнее. Поэтому различные сценарии нажатия (например, одиночное нажатие или удержание) реализованы в процедуре periodic, которая должна периодически вызываться из основного кода программы. periodic анализирует изменение состояния и синхронно вызывает виртуальный обработчик onButtonPressed, который должен быть реализован в основной программе:
void Button::periodic ()
{
if (handler == NULL)
{
return;
}
bool newState = (gpioParameters.Pull == GPIO_PULLUP)? !getBit() : getBit();
if (currentState == newState)
{
// state is not changed: check for periodical press event
if (currentState && pressTime != INFINITY_TIME)
{
duration_ms d = rtc.getUpTimeMillisec() - pressTime;
if (d >= pressDuration)
{
handler->onButtonPressed(this, numOccured);
pressTime = rtc.getUpTimeMillisec();
++numOccured;
}
}
}
else if (!currentState && newState)
{
pressTime = rtc.getUpTimeMillisec();
numOccured = 0;
}
else
{
duration_ms d = rtc.getUpTimeMillisec() - pressTime;
if (d < pressDelay)
{
// nothing to do
}
else if (numOccured == 0)
{
handler->onButtonPressed(this, numOccured);
}
pressTime = INFINITY_TIME;
}
currentState = newState;
}
Основной плюс такого подхода — разнесение логики и кода детектирования события от его обработки. Для отсчёта времени здесь используется не HAL_GetTick, который в силу своего типа (uint32_t) сбрасывается по переполнению каждые 2^32 миллисекунд (каждые 49 дней). Я реализовал собственный класс RealTimeClock, который отсчитывает миллисекунды со старта программы, или включения контроллера, как uint64_t, что даёт примерно 5^8 лет.
Второй пример — работа с аппаратным интерфейсом, которых в контроллере несколько. Например, SPI. С точки зрения основной программы, очень удобно выбрать только нужный интерфейс (SPI1/SPI2/SPI3), а всё остальные параметры, которые зависят от этого интерфейса, сконфигурирует конструктор класса.
class Spi
{
public:
const uint32_t TIMEOUT = 5000;
enum class DeviceName
{
SPI_1 = 0,
SPI_2 = 1,
SPI_3 = 2,
};
Spi (DeviceName _device,
IOPort::PortName sckPort, uint32_t sckPin,
IOPort::PortName misoPort, uint32_t misoPin,
IOPort::PortName mosiPort, uint32_t mosiPin,
uint32_t pull = GPIO_NOPULL);
HAL_StatusTypeDef start (uint32_t direction, uint32_t prescaler, uint32_t dataSize = SPI_DATASIZE_8BIT, uint32_t CLKPhase = SPI_PHASE_1EDGE);
HAL_StatusTypeDef stop ();
inline HAL_StatusTypeDef writeBuffer (uint8_t *pData, uint16_t pSize)
{
return HAL_SPI_Transmit(hspi, pData, pSize, TIMEOUT);
}
private:
DeviceName device;
IOPin sck, miso, mosi;
SPI_HandleTypeDef *hspi;
SPI_HandleTypeDef spiParams;
void enableClock();
void disableClock();
};
Параметры пинов и параметры интерфейса хранятся локально в классе. К сожалению, я выбрал не совсем удачный вариант реализации, когда настройка параметров в зависимости от конкретного интерфейса реализуется напрямую:
Spi::Spi (DeviceName _device,
IOPort::PortName sckPort, uint32_t sckPin,
IOPort::PortName misoPort, uint32_t misoPin,
IOPort::PortName mosiPort, uint32_t mosiPin,
uint32_t pull):
device(_device),
sck(sckPort, sckPin, GPIO_MODE_AF_PP, pull, GPIO_SPEED_HIGH, false),
miso(misoPort, misoPin, GPIO_MODE_AF_PP, pull, GPIO_SPEED_HIGH, false),
mosi(mosiPort, mosiPin, GPIO_MODE_AF_PP, pull, GPIO_SPEED_HIGH, false),
hspi(NULL)
{
switch (device)
{
case DeviceName::SPI_1:
#ifdef SPI1
sck.setAlternate(GPIO_AF5_SPI1);
miso.setAlternate(GPIO_AF5_SPI1);
mosi.setAlternate(GPIO_AF5_SPI1);
spiParams.Instance = SPI1;
#endif
break;
...
case DeviceName::SPI_3:
#ifdef SPI3
sck.setAlternate(GPIO_AF6_SPI3);
miso.setAlternate(GPIO_AF6_SPI3);
mosi.setAlternate(GPIO_AF6_SPI3);
spiParams.Instance = SPI3;
#endif
break;
}
spiParams.Init.Mode = SPI_MODE_MASTER;
spiParams.Init.DataSize = SPI_DATASIZE_8BIT;
spiParams.Init.CLKPolarity = SPI_POLARITY_HIGH;
spiParams.Init.CLKPhase = SPI_PHASE_1EDGE;
spiParams.Init.FirstBit = SPI_FIRSTBIT_MSB;
spiParams.Init.TIMode = SPI_TIMODE_DISABLE;
spiParams.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE;
spiParams.Init.CRCPolynomial = 7;
spiParams.Init.NSS = SPI_NSS_SOFT;
}
По этой же схеме реализованы процедуры enableClock и disableClock, что плохо расширяемо и плохо переносимо на другие контроллеры. В данном случае лучше использовать шаблоны, где параметром шаблона являются HAL имя интерфейса (SPI1, SPI2, SPI3), параметры пинов (GPIO_AF5_SPI1), и что-то, что управляет включением/выключением тактирования. Здесь есть интересная статья по этой теме, хотя в ней рессматриваются контроллеры AVR, что, впрочем, принципиальной разницы не имеет.
Начало и окончание передачи контролируются двумя методами start/stop:
HAL_StatusTypeDef Spi::start (uint32_t direction, uint32_t prescaler, uint32_t dataSize, uint32_t CLKPhase)
{
hspi = &spiParams;
enableClock();
spiParams.Init.Direction = direction;
spiParams.Init.BaudRatePrescaler = prescaler;
spiParams.Init.DataSize = dataSize;
spiParams.Init.CLKPhase = CLKPhase;
HAL_StatusTypeDef status = HAL_SPI_Init(hspi);
if (status != HAL_OK)
{
USART_DEBUG("Can not initialize SPI " << (size_t)device << ": " << status);
return status;
}
/* Configure communication direction : 1Line */
if (spiParams.Init.Direction == SPI_DIRECTION_1LINE)
{
SPI_1LINE_TX(hspi);
}
/* Check if the SPI is already enabled */
if ((spiParams.Instance->CR1 & SPI_CR1_SPE) != SPI_CR1_SPE)
{
/* Enable SPI peripheral */
__HAL_SPI_ENABLE(hspi);
}
USART_DEBUG("Started SPI " << (size_t)device
<< ": BaudRatePrescaler = " << spiParams.Init.BaudRatePrescaler
<< ", DataSize = " << spiParams.Init.DataSize
<< ", CLKPhase = " << spiParams.Init.CLKPhase
<< ", Status = " << status);
return status;
}
HAL_StatusTypeDef Spi::stop ()
{
USART_DEBUG("Stopping SPI " << (size_t)device);
HAL_StatusTypeDef retValue = HAL_SPI_DeInit(&spiParams);
disableClock();
hspi = NULL;
return retValue;
}
Работа с аппаратным интерфейсом с использованием прерываний. Класс реализует I2S интерфейс с использованием DMA-контроллера. I2S (Inter-IC Sound) — это программно-аппаратная надстройка над SPI, которая сама, например, осуществляет подбор тактовой частоты и управление каналами в зависимости от аудио-протокола и битрейта.
В данном случае, класс I2S наследуется от класса «порт», то есть I2S — это порт со специальными свойствами. Некоторые данные хранятся в структурах HAL (плюс к удобству, минус к объёму данных). Некоторые данные передаются из основного кода по ссылкам (например, структура irqPrio).
class I2S : public IOPort
{
public:
const IRQn_Type I2S_IRQ = SPI2_IRQn;
const IRQn_Type DMA_TX_IRQ = DMA1_Stream4_IRQn;
I2S (PortName name, uint32_t pin, const InterruptPriority & prio);
HAL_StatusTypeDef start (uint32_t standard, uint32_t audioFreq, uint32_t dataFormat);
void stop ();
inline HAL_StatusTypeDef transmit (uint16_t * pData, uint16_t size)
{
return HAL_I2S_Transmit_DMA(&i2s, pData, size);
}
inline void processI2SInterrupt ()
{
HAL_I2S_IRQHandler(&i2s);
}
inline void processDmaTxInterrupt ()
{
HAL_DMA_IRQHandler(&i2sDmaTx);
}
private:
I2S_HandleTypeDef i2s;
DMA_HandleTypeDef i2sDmaTx;
const InterruptPriority & irqPrio;
};
Его конструктор задаёт все статические параметры:
I2S::I2S (PortName name, uint32_t pin, const InterruptPriority & prio):
IOPort{name, GPIO_MODE_INPUT, GPIO_NOPULL, GPIO_SPEED_FREQ_LOW, pin, false},
irqPrio{prio}
{
i2s.Instance = SPI2;
i2s.Init.Mode = I2S_MODE_MASTER_TX;
i2s.Init.Standard = I2S_STANDARD_PHILIPS; // will be re-defined at communication start
i2s.Init.DataFormat = I2S_DATAFORMAT_16B; // will be re-defined at communication start
i2s.Init.MCLKOutput = I2S_MCLKOUTPUT_DISABLE;
i2s.Init.AudioFreq = I2S_AUDIOFREQ_44K; // will be re-defined at communication start
i2s.Init.CPOL = I2S_CPOL_LOW;
i2s.Init.ClockSource = I2S_CLOCK_PLL;
i2s.Init.FullDuplexMode = I2S_FULLDUPLEXMODE_DISABLE;
i2sDmaTx.Instance = DMA1_Stream4;
i2sDmaTx.Init.Channel = DMA_CHANNEL_0;
i2sDmaTx.Init.Direction = DMA_MEMORY_TO_PERIPH;
i2sDmaTx.Init.PeriphInc = DMA_PINC_DISABLE;
i2sDmaTx.Init.MemInc = DMA_MINC_ENABLE;
i2sDmaTx.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD;
i2sDmaTx.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD;
i2sDmaTx.Init.Mode = DMA_NORMAL;
i2sDmaTx.Init.Priority = DMA_PRIORITY_LOW;
i2sDmaTx.Init.FIFOMode = DMA_FIFOMODE_ENABLE;
i2sDmaTx.Init.FIFOThreshold = DMA_FIFO_THRESHOLD_FULL;
i2sDmaTx.Init.MemBurst = DMA_PBURST_SINGLE;
i2sDmaTx.Init.PeriphBurst = DMA_PBURST_SINGLE;
}
Начало передачи данных контролируются методам start, который отвечают за настройку параметров порта, тактирование интерфейса, настройку прерываний, старт DMA, старт самого интерфейса с заданными параметрами передачи.
HAL_StatusTypeDef I2S::start (uint32_t standard, uint32_t audioFreq, uint32_t dataFormat)
{
i2s.Init.Standard = standard;
i2s.Init.AudioFreq = audioFreq;
i2s.Init.DataFormat = dataFormat;
setMode(GPIO_MODE_AF_PP);
setAlternate(GPIO_AF5_SPI2);
__HAL_RCC_SPI2_CLK_ENABLE();
HAL_StatusTypeDef status = HAL_I2S_Init(&i2s);
if (status != HAL_OK)
{
USART_DEBUG("Can not start I2S: " << status);
return HAL_ERROR;
}
__HAL_RCC_DMA1_CLK_ENABLE();
__HAL_LINKDMA(&i2s, hdmatx, i2sDmaTx);
status = HAL_DMA_Init(&i2sDmaTx);
if (status != HAL_OK)
{
USART_DEBUG("Can not initialize I2S DMA/TX channel: " << status);
return HAL_ERROR;
}
HAL_NVIC_SetPriority(I2S_IRQ, irqPrio.first, irqPrio.second);
HAL_NVIC_EnableIRQ(I2S_IRQ);
HAL_NVIC_SetPriority(DMA_TX_IRQ, irqPrio.first + 1, irqPrio.second);
HAL_NVIC_EnableIRQ(DMA_TX_IRQ);
return HAL_OK;
}
Процедура stop делает всё наоборот:
void I2S::stop ()
{
HAL_NVIC_DisableIRQ(I2S_IRQ);
HAL_NVIC_DisableIRQ(DMA_TX_IRQ);
HAL_DMA_DeInit(&i2sDmaTx);
__HAL_RCC_DMA1_CLK_DISABLE();
HAL_I2S_DeInit(&i2s);
__HAL_RCC_SPI2_CLK_DISABLE();
setMode(GPIO_MODE_INPUT);
}
Здесь есть несколько интересных особенностей:
- Используемые прерывания в данном случае определены как статические константы. Это минус к переносимости на другие контроллеры.
- Подобная организация кода позволяет гарантировать, что пины порта всегда, когда нет передачи, находятся в состоянии GPIO_MODE_INPUT. Это плюс.
- Приоритетность прерываний передаётся извне, то есть имеется хорошая возможность задать в одном месте основного кода карту приоритетов прерываний. Это тоже плюс.
- Процедура stop отключает тактирование DMA1. В данном случае это упрощение может иметь очень негативные последствия, если кто-то другой продолжает использовать DMA1. Проблема решается созданием централизованного регистра потребителей подобных устройств, который и будет отвечать за тактирование.
- Ещё одно упрощение — процедура start не приводит интерфейс в изначальное состояние в случае ошибки (это минус, но легко поправимый). В то же время, ошибки логируются более подробно, что является плюсом.
- Основной код должен при использовании этого класса перехватывать прерывания SPI2_IRQn и DMA1_Stream4_IRQn и обеспечить вызов соответствующих обработчиков processI2SInterrupt и processDmaTxInterrupt.
Основная программа
Основная программа пишется с использованием вышеописанной библиотеки достаточно просто:
int main (void)
{
HAL_Init();
IOPort defaultPortA(IOPort::PortName::A, GPIO_MODE_INPUT, GPIO_PULLDOWN);
IOPort defaultPortB(IOPort::PortName::B, GPIO_MODE_INPUT, GPIO_PULLDOWN);
IOPort defaultPortC(IOPort::PortName::C, GPIO_MODE_INPUT, GPIO_PULLDOWN);
// System frequency 168MHz
System::ClockDiv clkDiv;
clkDiv.PLLM = 16;
clkDiv.PLLN = 336;
clkDiv.PLLP = 2;
clkDiv.PLLQ = 7;
clkDiv.AHBCLKDivider = RCC_SYSCLK_DIV1;
clkDiv.APB1CLKDivider = RCC_HCLK_DIV8;
clkDiv.APB2CLKDivider = RCC_HCLK_DIV8;
clkDiv.PLLI2SN = 192;
clkDiv.PLLI2SR = 2;
do
{
System::setClock(clkDiv, FLASH_LATENCY_3, System::RtcType::RTC_EXT);
}
while (System::getMcuFreq() != 168000000L);
MyApplication app;
appPtr = &app;
app.run();
}
Здесь мы инициализируем библиотеку HAL, все пины контроллера конфигурируем по умолчанию на вход (GPIO_MODE_INPUT/PULLDOWN). Устанавливаем частоту контроллера, запускаем тактирование (включая часы реального времени от внешнего кварца). После этого, немного в стиле Java, создаём экземпляр нашего приложения и вызываем его метод run, который реализует всю логику приложения.
Отдельной секцией мы должны определить все используемые прерывания. Так как мы пишем на C++, а прерывания — это вещи из мира C, то их нужно соответственно маскировать:
extern "C"
{
void SysTick_Handler (void)
{
HAL_IncTick();
if (appPtr != NULL)
{
appPtr->getRtc().onMilliSecondInterrupt();
}
}
void DMA2_Stream3_IRQHandler (void)
{
Devices::SdCard::getInstance()->processDmaRxInterrupt();
}
void DMA2_Stream6_IRQHandler (void)
{
Devices::SdCard::getInstance()->processDmaTxInterrupt();
}
void SDIO_IRQHandler (void)
{
Devices::SdCard::getInstance()->processSdIOInterrupt();
}
void SPI2_IRQHandler(void)
{
appPtr->getI2S().processI2SInterrupt();
}
void DMA1_Stream4_IRQHandler(void)
{
appPtr->getI2S().processDmaTxInterrupt();
}
void HAL_I2S_TxCpltCallback(I2S_HandleTypeDef *channel)
{
appPtr->processDmaTxCpltCallback(channel);
}
...
}
Класс MyApplication декларирует все используемые устройства, вызывает конструкторы для всех этих устройств, а также реализует необходимые обработчики событий:
class MyApplication : public RealTimeClock::EventHandler, class MyApplication : public RealTimeClock::EventHandler, WavStreamer::EventHandler, Devices::Button::EventHandler
{
public:
static const size_t INPUT_PINS = 8; // Number of monitored input pins
private:
UsartLogger log;
RealTimeClock rtc;
IOPin ledGreen, ledBlue, ledRed;
PeriodicalEvent heartbeatEvent;
IOPin mco;
// Interrupt priorities
InterruptPriority irqPrioI2S;
InterruptPriority irqPrioEsp;
InterruptPriority irqPrioSd;
InterruptPriority irqPrioRtc;
// SD card
IOPin pinSdPower, pinSdDetect;
IOPort portSd1, portSd2;
SdCard sdCard;
bool sdCardInserted;
// Configuration
Config config;
// ESP
Esp11 esp;
EspSender espSender;
// Input pins
std::array<IOPin, INPUT_PINS> pins;
std::array<bool, INPUT_PINS> pinsState;
// I2S2 Audio
I2S i2s;
AudioDac_UDA1334 audioDac;
WavStreamer streamer;
Devices::Button playButton;
...
То есть, по сути, все используемы устройства декларируются статически, что потенциально ведёт к увеличению используемой памяти, но сильно упрощает доступ к данным. В конструкторе класса MyApplication необходимо вызвать конструкторы всех устройств, после чего, к моменту запуска процедуры run, все используемые устройства микроконтроллера будут инициализированы:
MyApplication::MyApplication () :
// logging
log(Usart::USART_1, IOPort::B, GPIO_PIN_6, GPIO_PIN_7, 115200),
// RTC
rtc(),
ledGreen(IOPort::C, GPIO_PIN_1, GPIO_MODE_OUTPUT_PP),
ledBlue(IOPort::C, GPIO_PIN_2, GPIO_MODE_OUTPUT_PP),
ledRed(IOPort::C, GPIO_PIN_3, GPIO_MODE_OUTPUT_PP),
heartbeatEvent(rtc, 10, 2),
mco(IOPort::A, GPIO_PIN_8, GPIO_MODE_AF_PP),
// Interrupt priorities
irqPrioI2S(6, 0), // I2S DMA interrupt priority: 7 will be also used
irqPrioEsp(5, 0),
irqPrioSd(3, 0), // SD DMA interrupt priority: 4 will be also used
irqPrioRtc(2, 0),
// SD card
pinSdPower(IOPort::A, GPIO_PIN_15, GPIO_MODE_OUTPUT_PP, GPIO_PULLDOWN, GPIO_SPEED_HIGH, true, false),
pinSdDetect(IOPort::B, GPIO_PIN_3, GPIO_MODE_INPUT, GPIO_PULLUP),
portSd1(IOPort::C,
/* mode = */GPIO_MODE_OUTPUT_PP,
/* pull = */GPIO_PULLUP,
/* speed = */GPIO_SPEED_FREQ_VERY_HIGH,
/* pin = */GPIO_PIN_8 | GPIO_PIN_9 | GPIO_PIN_10 | GPIO_PIN_11 | GPIO_PIN_12,
/* callInit = */false),
portSd2(IOPort::D,
/* mode = */GPIO_MODE_OUTPUT_PP,
/* pull = */GPIO_PULLUP,
/* speed = */GPIO_SPEED_FREQ_VERY_HIGH,
/* pin = */GPIO_PIN_2,
/* callInit = */false),
sdCard(pinSdDetect, portSd1, portSd2),
sdCardInserted(false),
// Configuration
config(pinSdPower, sdCard, "conf.txt"),
//ESP
esp(rtc, Usart::USART_2, IOPort::A, GPIO_PIN_2, GPIO_PIN_3, irqPrioEsp, IOPort::A, GPIO_PIN_1),
espSender(rtc, esp, ledRed),
// Input pins
pins { { IOPin(IOPort::A, GPIO_PIN_4, GPIO_MODE_INPUT, GPIO_PULLUP),
IOPin(IOPort::A, GPIO_PIN_5, GPIO_MODE_INPUT, GPIO_PULLUP),
IOPin(IOPort::A, GPIO_PIN_6, GPIO_MODE_INPUT, GPIO_PULLUP),
IOPin(IOPort::A, GPIO_PIN_7, GPIO_MODE_INPUT, GPIO_PULLUP),
IOPin(IOPort::C, GPIO_PIN_4, GPIO_MODE_INPUT, GPIO_PULLUP),
IOPin(IOPort::C, GPIO_PIN_5, GPIO_MODE_INPUT, GPIO_PULLUP),
IOPin(IOPort::B, GPIO_PIN_0, GPIO_MODE_INPUT, GPIO_PULLUP),
IOPin(IOPort::B, GPIO_PIN_1, GPIO_MODE_INPUT, GPIO_PULLUP)
} },
// I2S2 Audio Configuration
// PB10 --> I2S2_CK
// PB12 --> I2S2_WS
// PB15 --> I2S2_SD
i2s(IOPort::B, GPIO_PIN_10 | GPIO_PIN_12 | GPIO_PIN_15, irqPrioI2S),
audioDac(i2s,
/* power = */ IOPort::B, GPIO_PIN_11,
/* mute = */ IOPort::B, GPIO_PIN_13,
/* smplFreq = */ IOPort::B, GPIO_PIN_14),
streamer(sdCard, audioDac),
playButton(IOPort::B, GPIO_PIN_2, GPIO_PULLUP, rtc)
{
mco.activateClockOutput(RCC_MCO1SOURCE_PLLCLK, RCC_MCODIV_5);
}
В качестве примера обработчик события нажатия кнопки, по которой запускается/останавливается воспроизведение WAV файла:
virtual void MyApplication::onButtonPressed (const Devices::Button * b, uint32_t numOccured)
{
if (b == &playButton)
{
USART_DEBUG("play button pressed: " << numOccured);
if (streamer.isActive())
{
USART_DEBUG(" Stopping WAV");
streamer.stop();
}
else
{
USART_DEBUG(" Starting WAV");
streamer.start(AudioDac_UDA1334::SourceType:: STREAM, config.getWavFile());
}
}
}
Ну и, наконец, основной метод run завершает настройку устройств (например, устанавливает MyApplication в качестве обработчика событий), и запускает бесконечный цикл, где периодически обращается к тем устройствам, которые требуют периодического внимания:
void MyApplication::run ()
{
log.initInstance();
USART_DEBUG("Oscillator frequency: "
<< System::getExternalOscillatorFreq() << ", MCU frequency: " << System::getMcuFreq());
HAL_StatusTypeDef status = HAL_TIMEOUT;
do
{
status = rtc.start(8 * 2047 + 7, RTC_WAKEUPCLOCK_RTCCLK_DIV2, irqPrioRtc, this);
USART_DEBUG("RTC start status: " << status);
}
while (status != HAL_OK);
sdCard.setIrqPrio(irqPrioSd);
sdCard.initInstance();
if (sdCard.isCardInserted())
{
updateSdCardState();
}
USART_DEBUG("Input pins: " << pins.size());
pinsState.fill(true);
USART_DEBUG("Pin state: " << fillMessage());
esp.assignSendLed(&ledGreen);
streamer.stop();
streamer.setHandler(this);
streamer.setVolume(1.0);
playButton.setHandler(this);
bool reportState = false;
while (true)
{
updateSdCardState();
playButton.periodic();
streamer.periodic();
if (isInputPinsChanged())
{
USART_DEBUG("Input pins change detected");
ledBlue.putBit(true);
reportState = true;
}
espSender.periodic();
if (espSender.isOutputMessageSent())
{
if (reportState)
{
espSender.sendMessage(config, "TCP", config.getServerIp(), config.getServerPort(), fillMessage());
reportState = false;
}
if (!reportState)
{
ledBlue.putBit(false);
}
}
if (heartbeatEvent.isOccured())
{
ledGreen.putBit(heartbeatEvent.occurance() == 1);
}
}
}
Немного экспериментов
Интересный факт — микроконтроллер поддаётся частичному оверклокингу. Его максимальная частота — 168 MHz. Однако, играя параметрами тактирования, мне удавалось запускать его на 172 MHz и на 180 MHz, то есть инициализация тактирования с такой частотой выполняется без ошибок, и эта частота видна на осциллографе, если его подключить к пину выходного тактового сигнала MCO. Но при этом контроллер зависает, если использовать USART или I2S, что, быть может, просто программная проблема на уровне HAL.
Цена
Это самый больной вопрос во всей этой работе. На github есть список всех компонент платы. Чтобы получить хоть какую-то цифру, я скрупулёзно собрал цены всех компонент из этого списка с сайта Mouser (не сочтите за рекламу). На получившуюся цифру в 37 Евро без слёз смотреть не получается. К ней нужно ещё прибавить стоимость изготовления платы и время на пайку. То есть, по сравнению с массовыми демонстрационными платами от STM или Olimex, моя плата получилась очень уж дорогой.
Проблемы и перспективы
Наверняка я реализовал многие вещи очень неэффективным способом. В текущей версии я сам вижу проблемы, которые нужно исправлять:
- Полигон земли высокочастотного кварца разведён с ошибкой (выведен на основную землю). Вместо этого, его бы соединить с земляной ногой микроконтроллера, но вот только подобраться к ней на двухслойной плате не пока получилось. В комментариях уже подсказали и возможное решение: понизить частоту кварца до 4 или 8 МГц. Нужная частота ядра все равно получаются после PLL, а меньшая частота кварца позволила бы нивелировать проблемы разводки.
- Эксперименты с осциллографом показали, что линия питания контроллера очень сильно зашумлена. Дополнительный входной фильтрующий конденсатор на 47 μF ситуацию улучшает. Наверное, нужно будет его добавить.
- Расположение разъёма SWD для программирования контроллера не очень удобное. Если сверху примостить какую-нибудь плату расширения, то до него не добраться. Нужно переносить в другое место.
- Расположение трёхцветного светодиода также неудачное. Хочу заменить его на SMD светодиод, вынести на край платы. В комментариях также уже подсказали добавить 3 резистора на каждый из анодов светодиода отдельно.
Документация
Проект опубликован на github под лицензией GPL v3:
Спасибо за внимание!