
Керамический пьезоизлучатель (buzzer) — простая деталь, наравне со светодиодом требующая минимального набора ресурсов для управления и настолько же легко подключаемая к микроконтроллеру. Как и светодиоду с возможностью плавной регулировки яркости, от микроконтроллера ему требуется не более одного канала таймера и внешний вывод.
Много в интернете уроков «Подключаем пищалку к ардуино», только вот заканчиваются они проигрыванием «В траве сидел кузнечик» или озвучкой срабатывания RFID датчика. Наверное тем, кто занят этим профессионально и серьезно, не до ведения блогов и записи видеоуроков.
А ведь миниатюрный керамический динамик — шаг в сторону более дружелюбного интерфейса с человеком. Нажатия кнопок, касания сенсорной панели, реакция на различные события… Такая вот обратная связь в виде звукового отклика!
Под катом попробуем сделать с этим что нибудь, а именно напишем драйвер пьезодинамика и заставим его параллельно озвучивать несколько разных внешних событий.
Железки
Использовать будем самодельную плату с микроконтроллером stm32f103 в 144-ногом корпусе и пьезоизлучатель PKLCS1212E40A1-R1 фирмы Murata.

Этот несложный элемент представляет собой керамическую пластину, к обкладкам которой подается сигнал некоторой частоты. В результате пластина колеблется сама и колеблет воздух, а мы слышим звук. Схему платы приводить смысла нет, а вот подключение пищалки показать стоит:
Пьезодинамик включен через транзистор и сделано это для большей громкости звучания (раскачивается амплитудой 5V), хотя можно вешать напрямую на ногу микроконтроллера (3.3V). Документация на него содержит АЧХ, из ко��орого видно, что максимальная амплитуда достигается при входном сигнале 4 кГц. Да и в парт-номере компонента (PKLCS1212E40A1-R1) это отражено (Expressed resonant frequency by two-digit alphanumerics. The unit is in 100 hertz (Hz.) 4kHz (4000Hz) is denoted as «40.»).
Работать мы будем со звуком и тут я не рискну рассказывать что-то глубже основ, так как сам имею знания на минимальном уровне: есть частоты, которые динамик может воспроизводить, есть октавная система, с помощью которой можно сгруппировать, дать названия основным частотам, и закинуть эти данные в массив. С ним и будем работать:
u16 GL_BuzzerAllNotes[] = {
261, 277, 294, 311, 329, 349, 370, 392, 415, 440, 466, 494,
523, 554, 587, 622, 659, 698, 740, 784, 831, 880, 932, 988,
1046, 1109, 1175, 1245, 1319, 1397, 1480, 1568, 1661, 1760, 1865, 1976,
2093, 2217, 2349, 2489, 2637, 2794, 2960, 3136, 3322, 3520, 3729, 3951,
4186, 4434, 4699, 4978, 5274, 5588, 5920, 6272, 6645, 7040, 7459, 7902};
#define OCTAVE_ONE_START_INDEX (0)
#define OCTAVE_TWO_START_INDEX (OCTAVE_ONE_START_INDEX + 12)
#define OCTAVE_THREE_START_INDEX (OCTAVE_TWO_START_INDEX + 12)
#define OCTAVE_FOUR_START_INDEX (OCTAVE_THREE_START_INDEX + 12)
#define OCTAVE_FIVE_START_INDEX (OCTAVE_FOUR_START_INDEX + 12)
#define BUZZER_DEFAULT_FREQ (4186) //C8 - 5th octave "Do"
#define BUZZER_DEFAULT_DURATION (20) //20ms
#define BUZZER_VOLUME_MAX (10)
#define BUZZER_VOLUME_MUTE (0)Драйвер пьезодинамика
Пьезодинамик — не светодиод, широтно-импульсной модуляцией с постоянной частотой и переменной скважностью импульсов тут не отделаешься. Ножку, на которой висит управляющий транзистор (PA15, TIM2, CH1), настраиваем в режиме PWM:
void BuzzerConfig(void)
void BuzzerConfig(void)
{
GPIO_InitTypeDef GPIO_Options;
TIM_TimeBaseInitTypeDef TIM_BaseOptions;
TIM_OCInitTypeDef TIM_PWM_Options;
RCC_APB2PeriphClockCmd(BUZZER_CLK_PINS | RCC_APB2Periph_AFIO, ENABLE);
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);
GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable, ENABLE);
GPIO_PinRemapConfig(GPIO_PartialRemap1_TIM2, ENABLE);
//PA.15 TIM2_CH1, BUZZER
GPIO_Options.GPIO_Pin = BUZZER_PIN;
GPIO_Options.GPIO_Speed = GPIO_Speed_10MHz;
GPIO_Options.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_Init(BUZZER_PORT, &GPIO_Options);
TIM_BaseOptions.TIM_Period = 2 * BUZZER_VOLUME_MAX - 1;
TIM_BaseOptions.TIM_ClockDivision = TIM_CKD_DIV1;
TIM_BaseOptions.TIM_CounterMode = TIM_CounterMode_Up;
TIM_TimeBaseInit(TIM2, &TIM_BaseOptions);
TIM_PWM_Options.TIM_OCMode = TIM_OCMode_PWM1;
TIM_PWM_Options.TIM_OutputState = TIM_OutputState_Enable;
TIM_PWM_Options.TIM_OutputNState = TIM_OutputNState_Disable;
TIM_PWM_Options.TIM_OCPolarity = TIM_OCPolarity_High;
TIM_PWM_Options.TIM_Pulse = 0;
TIM_OC1Init(TIM2, &TIM_PWM_Options);
TIM_OC1PreloadConfig(TIM2, TIM_OCPreload_Enable);
TIM_ARRPreloadConfig(TIM2, ENABLE);
TIM_Cmd(TIM2, ENABLE);
}В коде не присутствует важный параметр настройки таймера — предделитель тактового сигнала. Его будем изменять динамически, чем и добьемся генерации звука нужной частоты.
Очевидно, что смена частоты сигнала приводит к изменению звучания, а вот как быть со скважностью импульсов? Я не нашел ничего полезного по этому вопросу в документации, но было предположение, что изменение скважности влечёт за собой смену громкости. Если это правда, то меандр (скважность = 50%) будет давать максимальную громкость, а схождение к 0% (или симметрично, к 100%) ослабит громкость, в конце концов, до нуля. Реально это работает так себе, поэтому я только включаю и выключаю пищалку, используя два следующих макроса:
#define BUZZER_VOLUME_MAX 10
#define BUZZER_VOLUME_MUTE 0BUZZER_VOLUME_MAX — это такое количество импульсов, которое дважды уложится в необходи��ый период работы, который обратно пропорционален частоте. Нужную частоту (установку) мы знаем, период тоже понятен (x2), а значит и предделитель для таймера найти не составит труда. В STM32 это любое число от 1 до 0xFFFF.
Оборачиваем все действия в функцию установки частоты:
void BuzzerSetFreq(u16 freq)
{
TIM2->PSC = (SYSCLK_FREQ / (2 * BUZZER_VOLUME_MAX * freq)) - 1; //prescaller
}
И смена скважности для задания громкости:
void BuzzerSetVolume(u16 volume)
{
if(volume > BUZZER_VOLUME_MAX)
volume = BUZZER_VOLUME_MAX;
TIM2->CCR1 = volume;
}
Всё, драйвер пищалки готов. Можно сыграть что-нибудь, предварительно создав массив частот (и длительностей неплохо бы).
Happy Birthday
u32 HappyBirthday[] = {
262, 262, 294, 262, 349, 330, 262,
262, 294, 262, 392, 349, 262, 262,
523, 440, 349, 330, 294, 466, 466,
440, 349, 392, 349};
for(i = 0; i < sizeof(HappyBirthday) / sizeof(u32); i++)
{
BuzzerSetFreq(HappyBirthday[i]);
BuzzerSetVolume(BUZZER_VOLUME_MAX);
DelayTime(400);
BuzzerSetVolume(BUZZER_VOLUME_MUTE);
}
Пьезодинамик, как совместно используемый ресурс
Глобальная идея состоит в создании удобного интерфейса псевдопараллельного доступа различных задач к аппаратному модулю пьезодинамика средствами FreeRTOS. О самой FreeRTOS рассказывать не буду, эта тема не для одной статьи, которых уже очень не мало (в том числе и неплохая онлайн документация на www.freertos.org. На русском могу посоветовать этот ресурс).
Создадим составной тип данных, описывающий минимальный набор необходимых параметров для однократного воспроизведения звука определенной частоты и громкости в течение определенного времени. Звучит страшновато, но это лишь структура:
typedef struct
{
u16 freq;
u16 volume;
u16 duration;
} BuzzerParameters_t;
Для использования пищалки в качестве ресурса, которому любая задача может отдать на «озвучивание» какие-то данные, будем использовать стандартный механизм межзадачной коммуникации и синхронизации FreeRTOS — очередь.
Очередь хранит в себе конечное множество элементов данных фиксированного размера и представляет собой FIFO буфер, в который задачи могут как записывать данные, так и забирать — с последующим удалением (или без, по желанию). Любое количество задач может записать в очередь свои данные, а вот читать из неё будет только задача пьезодинамика.
Создадим очередь длиной 10 элементов, состоящую из кирпичиков типа BuzzerParameters_t:
#define BUZZER_QUEUE_LEN 10
QueueHandle_t BuzzerQueue = xQueueCreate(BUZZER_QUEUE_LEN, sizeof(BuzzerParameters_t);Обработкой событий пищалки будет заниматься задача динамика. Задачи во FreeRTOS — это маленькие подпрограммы, имеющие точку входа и бесконечный цикл, return из которого запрещен (допускается либо приостановка задачи, либо удаление). До начала выполнения задачу нужно создать, передав первым параметром указатель на функцию задачи, а последним — необязательный хендл.
TaskHandle_t BuzzerHandle;
xTaskCreate(vTask_BuzzerBeep, "BuzzerBeep", configMINIMAL_STACK_SIZE, NULL, tskIDLE_PRIORITY + 2, &BuzzerHandle);
В бесконечном цикле задача будет ждать появления данных в очереди. Параметр portMAX_DELAY означает, что задача заблокирована планировщиком до тех пор, пока очередь пуста. Как только это становится не так, драйвер пищалки инициализируется переданными через очередь параметрами, а считанный элемент удаляется из очереди (если удалять не требуется, есть функция xQueuePeek()).
Вместо задержки, основанной на бездействии микроконтроллера в течение какого-то времени, используется функция vTaskDelay(), блокирующая задачу на заданное количество времени в миллисекундах (на самом деле, на количество системных тиков ОСРВ, но у меня 1 тик = 1 мс). Таким образом, задача блокируется снова на время воспроизведения звука, а по истечении времени блокировки прекращает его генерацию.
void vTask_BuzzerBeep(void *pvParameters)
{
BuzzerParameters_t buzzerParameters;
for(;;)
{
xQueueReceive(BuzzerQueue, &buzzerParameters, portMAX_DELAY);
BuzzerSetFreq(buzzerParameters.freq);
BuzzerSetVolume(buzzerParameters.volume);
vTaskDelay(buzzerParameters.duration);
BuzzerSetVolume(BUZZER_VOLUME_MUTE);
}
}Выглядит несложно и логично, в отличие от шаманства с таймерами, прерываниями и флагами без использования ОСРВ. Попробуем теперь этот механизм в деле.
Дано:
- Кнопка. Неплохо бы различать длинные и короткие нажатия.
- Механический квадратурный энкодер. Можно крутить по часовой стрелке, против часовой, а так же нажимать на кнопку по центру. Для кнопки короткие и длинные нажатия тоже актуальны.
Начнём с кнопки. Она может находится в одном из трёх состояний:
typedef enum
{
BUTTON_RELEASED = 0,
BUTTON_SHORT_PRESSED,
BUTTON_LONG_PRESSED
} BUTTON_PARAMETERS_t;Инициализируем ножку микроконтроллера, настроим прерывание:
void StartButtonConfig(void)
void StartButtonConfig(void)
{
GPIO_InitTypeDef GPIO_Options;
EXTI_InitTypeDef EXTI_Options;
NVIC_InitTypeDef NVIC_Options;
RCC_APB2PeriphClockCmd(START_BUTTON_CLK_PINS | RCC_APB2Periph_AFIO, ENABLE);
GPIO_Options.GPIO_Pin = START_BUTTON_PIN;
GPIO_Options.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(START_BUTTON_PORT, &GPIO_Options);
GPIO_EXTILineConfig(START_BUTTON_PORTSOURCE, START_BUTTON_PINSOURCE);
EXTI_Options.EXTI_Line = START_BUTTON_EXTI_LINE;
EXTI_Options.EXTI_Mode = EXTI_Mode_Interrupt;
EXTI_Options.EXTI_Trigger = EXTI_Trigger_Rising;
EXTI_Options.EXTI_LineCmd = ENABLE;
EXTI_Init(&EXTI_Options);
NVIC_Options.NVIC_IRQChannel = EXTI2_IRQn;
NVIC_Options.NVIC_IRQChannelPreemptionPriority = 13;
NVIC_Options.NVIC_IRQChannelSubPriority = 0;
NVIC_Options.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_Options);
}Первым событием, которое произойдет при нажатии кнопки, будет вход в обработчик:
void EXTI2_IRQHandler(void)
void EXTI2_IRQHandler(void)
{
static portBASE_TYPE xHigherPriorityTaskWoken = pdFALSE;
EXTI_InitTypeDef EXTI_Options;
EXTI_ClearITPendingBit(EXTI_Line2);
EXTI_Options.EXTI_Line = EXTI_Line2;
EXTI_Options.EXTI_Mode = EXTI_Mode_Interrupt;
EXTI_Options.EXTI_Trigger = EXTI_Trigger_Rising;
EXTI_Options.EXTI_LineCmd = DISABLE;
EXTI_Init(&EXTI_Options);
xSemaphoreGiveFromISR(StartButtonSemaphore, &xHigherPriorityTaskWoken);
if(xHigherPriorityTaskWoken == pdTRUE)
{
portEND_SWITCHING_ISR(xHigherPriorityTaskWoken);
}
}В нём мы стандартно сбрасываем флаг случившегося события и вырубаем генерацию прерывания на этой ноге (такой вот у меня антидребезг, работает офигенно). С помощью семафора говорим задаче обработки кнопки vTask_GetStartButton(), что пора и ей поработать. Выходим из прерывания.
К этому времени задача vTask_GetStartButton() с хендлом StartButtonHandle уже должна быть создана и заблокирована функцией xSemaphoreTake(), ожидающей семафора из прерывания. Логика работы следующая:
- Ждем, пока xSemaphoreTake() получит желаемое из прерывания
- Пикаем динамиком (с помощью очереди, ага!) и блокируем задачу на 1/4 секунды
- Пикаем каждые 100 мс в течение 300 мс, если кнопка в зажатом состоянии. Используем разные ноты в сторону повышения частоты из массива GL_BuzzerAllNotes[]
- В бесконечном цикле ждем, пока кнопку отпустят окончательно (обязательно внутри делаем задержку средствами ОСРВ, иначе ожидание заберет все процессорное время себе — а вдруг пользователь поставит бутылку виски на кнопку, как это было в Silicon Valley =) )
- Определяем по переменной notePointer, как долго удерживали кнопку (BUTTON_LONG_PRESSED или BUTTON_SHORT_PRESSED)
- Пикаем в последний раз, возобновляем реакцию на прерывание
Но лучше прочесть комментарии в коде — они более последовательны:
void vTask_GetStartButton(void *pvParameters)
void vTask_GetStartButton(void *pvParameters)
{
BuzzerParameters_t buzzerLocalParameters;
u32 localStartButtonState;
EXTI_InitTypeDef EXTI_Options;
u32 notePointer = 0;
EXTI_Options.EXTI_Line = START_BUTTON_EXTI_LINE;
EXTI_Options.EXTI_Mode = EXTI_Mode_Interrupt;
EXTI_Options.EXTI_Trigger = EXTI_Trigger_Rising;
EXTI_Options.EXTI_LineCmd = ENABLE;
buzzerLocalParameters.volume = BUZZER_VOLUME_MAX;
buzzerLocalParameters.duration = BUZZER_DEFAULT_DURATION;
/*
* first semaphore take after creation (NEED!! it issued after power up)
*/
xSemaphoreTake(StartButtonSemaphore, portMAX_DELAY);
for(;;)
{
/*
* take semaphore from button interrupt
*/
xSemaphoreTake(StartButtonSemaphore, portMAX_DELAY);
/*
* buzzer "pick" on button click and wait
*/
buzzerLocalParameters.freq = NOTE_C7;
xQueueSend(BuzzerQueue, (void *)&buzzerLocalParameters, portMAX_DELAY);
vTaskDelay(250);
/*
* "pick" new note while button pressed, but not more 3 times
*/
while(GPIO_ReadInputDataBit(START_BUTTON_PORT, START_BUTTON_PIN) == 1)
{
buzzerLocalParameters.freq = GL_BuzzerAllNotes[OCTAVE_FOUR_START_INDEX + notePointer];
xQueueSend(BuzzerQueue, (void *)&buzzerLocalParameters, portMAX_DELAY);
vTaskDelay(100);
if(notePointer++ >= 3)
break;
}
/*
* wait while button pressed
*/
while(GPIO_ReadInputDataBit(START_BUTTON_PORT, START_BUTTON_PIN) == 1)
{
vTaskDelay(100);
}
localStartButtonState = (notePointer >= 3) ? (BUTTON_LONG_PRESSED) : (BUTTON_SHORT_PRESSED);
xQueueSend(StartButtonQueue, (void *)&localStartButtonState, 0);
/*
* "pick" the last time and re-enable interrupt on click
*/
buzzerLocalParameters.freq = NOTE_C8;
xQueueSend(BuzzerQueue, (void *)&buzzerLocalParameters, portMAX_DELAY);
EXTI_Init(&EXTI_Options); //Enable interrupt (disabled in interrupts.c)
notePointer = 0;
vTaskDelay(100);
}
} Результат нажатия складываем в заранее созданную очередь для кнопки размером в один элемент:
StartButtonQueue = xQueueCreate(1, sizeof(u32));После обработки нажатия очередь будет хранить результат до тех пор, пока какая-либо задача не считает его оттуда.
Тут стоит отдельно заострить внимание на политике добавления в очередь данных. Нам в помощь третий параметр функции xQueueSend(). Если это 0 и очередь заполнена, то игнорируем запись и идем дальше по коду. portMAX_DELAY наоборот же, позволяет блокировать выполнение задачи, пока в очереди не будет свободен хотя бы один элемент для записи. В общем случае этот параметр есть время, на которое нужно блокировать задачу для ожидания появления свободного места. Нажатие кнопки, например, можно и проигнорировать, но вот озвучить это надо всегда, учитывая, что озвучка не занимает много времени при разумном па��аметре duration.
То же самое делаем с кнопкой энкодера (отдельное прерывание, отдельная очередь EncoderButtonQueue, отдельная задача обработки, отправляющая данные в общую очередь динамика)
Теперь энкодер. Хочу, что бы каждый щелчёк был озвучен, а еще на слух понятно, случился инкремент или декремент. Не будем создавать отдельную задачу, обработаем все в прерывании. Оно настроено только на один канал, но и по фронту и по спаду (никогда, никогда не используйте встроенный в этот микроконтроллер аппаратный обработчик энкодера — он ужасен):
void EncoderConfig(void)
void EncoderConfig(void)
{
GPIO_InitTypeDef GPIO_Options;
EXTI_InitTypeDef EXTI_Options;
RCC_APB2PeriphClockCmd(ENCODER_CLK_PINS | RCC_APB2Periph_AFIO, ENABLE);
GPIO_Options.GPIO_Pin = ENCODER_A_PIN | ENCODER_B_PIN;
GPIO_Options.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(ENCODER_PORT, &GPIO_Options);
GPIO_EXTILineConfig(ENCODER_PORTSOURCE, ENCODER_PINSOURCE); //Only one line interrupt!
EXTI_Options.EXTI_Line = ENCODER_EXTI_LINE;
EXTI_Options.EXTI_Mode = EXTI_Mode_Interrupt;
EXTI_Options.EXTI_Trigger = EXTI_Trigger_Rising_Falling;
EXTI_Options.EXTI_LineCmd = ENABLE;
EXTI_Init(&EXTI_Options);
}По входу в прерывание определим, куда же повернули вал: по часовой стрелке или против:
void EXTI0_IRQHandler(void)
u32 localEncoderAction;
if(GPIO_ReadInputDataBit(ENCODER_PORT, ENCODER_A_PIN) == 1)
{
if(GPIO_ReadInputDataBit(ENCODER_PORT, ENCODER_B_PIN) == 1)
{
localEncoderAction = ENCODER_WAS_INCR;
}
else
{
localEncoderAction = ENCODER_WAS_DECR;
}
}
else
{
if(GPIO_ReadInputDataBit(ENCODER_PORT, ENCODER_B_PIN) == 1)
{
localEncoderAction = ENCODER_WAS_DECR;
}
else
{
localEncoderAction = ENCODER_WAS_INCR;
}
}
EXTI_ClearITPendingBit(EXTI_Line0);Все в той же функции обработки прерывания, на основании информации о направлении поворота будем изменять переменную buzzerRotationCounter, которая определяет индекс проигрываемой ноты из массива GL_BuzzerAllNotes[]. Вращая энкодер, получим увеличение или уменьшение частоты звучания на +-15 едениц от значения 25. Далее формируем и отправляем элемент в очередь динамика, семафорим о событии энкодера и выходим из прерывания:
void EXTI0_IRQHandler(void), продолжение
static portBASE_TYPE xHigherPriorityTaskWoken = pdFALSE;
static TickType_t xLastTime;
static s32 buzzerRotationCounter = 15;
BuzzerParameters_t localParameters;
if((xTaskGetTickCount() - xLastTime) > 300)
{
buzzerRotationCounter = 25;
}
if(localEncoderAction == ENCODER_WAS_INCR)
{
buzzerRotationCounter++;
if(buzzerRotationCounter > 39)
{
buzzerRotationCounter = 39;
}
}
else //ENCODER_WAS_DECR
{
buzzerRotationCounter--;
if(buzzerRotationCounter < 10)
{
buzzerRotationCounter = 10;
}
}
xLastTime = xTaskGetTickCount();
localParameters.duration = 10;//BUZZER_DEFAULT_DURATION;
localParameters.freq = GL_BuzzerAllNotes[buzzerRotationCounter];
localParameters.volume = BUZZER_VOLUME_MAX;
xQueueSendFromISR(BuzzerQueue, (void *)&localParameters, &xHigherPriorityTaskWoken);
xQueueSendFromISR(EncoderQueue, (void *)&localEncoderAction, &xHigherPriorityTaskWoken);
if(xHigherPriorityTaskWoken == pdTRUE)
{
portEND_SWITCHING_ISR(xHigherPriorityTaskWoken);
}Не описать алгоритм работы словами я не мог, но лучше все же
Ну и зачем всё это?
Не то, что бы вышеописанное очень сложно и обязательно надо было разобрать это по шагам. Серьезно, суть публикации глобально можно свести к предложению — создадим задаче динамика очередь и согласно придуманным алгоритмам будем запихивать туда данные. Однако мне показалось, что подобный пример будет не плох для демонстрации распараллеливания доступа различных задач к аппаратным ресурсам железа средствами FreeRTOS. То же самое, но сделанное своими руками на флагах и прерываниях с таймерами хоть и кушало памяти меньше, чем ОСРВ — но в плане читабельности, переносимости и удобства использования было на порядок хуже.
Ну и конечно же — устройства, которые мы проектируем, в первую очередь должны быть удобными в применении и не вызывать чувства ненависти у пользователя. Надеюсь, производители моего электрочайника когда-нибудь это поймут, а вызывающие кровь из ушей звуки уйдут в прошлое наравне с ослепляющими светодиодами. Спасибо за внимание!
