DevBoy: робимо генератор сигналів
Привіт, друзі!
У минулих статтях я розповідав про свій проект та про його програмну частину. У цій статті я розповім як зробити простенький генератор сигналів на 4 канали — два аналогових каналу і два PWM каналу.
Аналогові канали
Мікроконтролер STM32F415RG має у своєму складі 12-тибитный DAC (digital-to-analog) перетворювач на два незалежних канали, що дозволяє генерувати різні сигнали. Можна безпосередньо завантажувати дані в регістри перетворювача, але для генерації сигналів це не дуже підходить. Краще рішення — використовувати масив, в який генерувати одну хвилю сигналу, а потім запускати DAC з тригером від таймера і DMA. Змінюючи частоту таймера можна змінювати частоту генерованого сигналу.
“Класичні” форми хвилі включають: синусоїдальна, меандр, трикутна і пилообразная хвилі.
Функція генерації даних хвиль в буфері має наступний вигляд
// *****************************************************************************
// *** GenerateWave ********************************************************
// *****************************************************************************
Result Application::GenerateWave(uint16_t* dac_data, uint32_t dac_data_cnt, uint8_t duty, WaveformType waveform)
{
Result result;
uint32_t max_val = (DAC_MAX_VAL * duty) / 100U;
uint32_t shift = (DAC_MAX_VAL - max_val) / 2U;
switch(waveform)
{
case WAVEFORM_SINE:
for(uint32_t i = 0U; i < dac_data_cnt; i++)
{
dac_data[i] = (uint16_t)((sin((2.0 F * i * PI) / (dac_data_cnt + 1)) + 1.0 F) * max_val) >> 1U;
dac_data[i] += shift;
}
break;
case WAVEFORM_TRIANGLE:
for(uint32_t i = 0U; i < dac_data_cnt; i++)
{
if(i <= dac_data_cnt / 2U)
{
dac_data[i] = (max_val * i) / (dac_data_cnt / 2U);
}
else
{
dac_data[i] = (max_val * (dac_data_cnt - i)) / (dac_data_cnt / 2U);
}
dac_data[i] += shift;
}
break;
case WAVEFORM_SAWTOOTH:
for(uint32_t i = 0U; i < dac_data_cnt; i++)
{
dac_data[i] = (max_val * i) / (dac_data_cnt - 1U);
dac_data[i] += shift;
}
break;
case WAVEFORM_SQUARE:
for(uint32_t i = 0U; i < dac_data_cnt; i++)
{
dac_data[i] = (i < dac_data_cnt / 2U) ? max_val : 0x000;
dac_data[i] += shift;
}
break;
default:
result = Result::ERR_BAD_PARAMETER;
break;
}
return result;
}
Функції потрібно передати вказівник на початку масиву, розмір масиву, максимальне значення і необхідну форму хвилі. Після виклику масив буде заповнений семплами для однієї хвилі необхідної форми і можна запускати таймер для періодичного завантаження нового значення в DAC.
DAC в даному мікроконтролері має обмеження: типове settling time (час від завантаження нового значення в DAC і його появою на виході) становить 3 ms. Але не все так однозначно — даний час є максимальним, тобто зміна від мінімуму до максимуму і навпаки. При спробі вивести меандр ці завалені фронти дуже добре видно:
Якщо ж вивести синусоїдальну хвилю то завал фронтів вже не так помітний з-за форми сигналу. Проте якщо збільшувати частоту синусоїдальний сигнал перетворюється в трикутний, а при подальшому збільшенні зменшується амплітуда сигналу.
Генерація на 1 KHz (90% амплітуда):
Генерація на 10 KHz (90% амплітуда):
Генерація на 100 KHz (90% амплітуда):
Вже видно сходинки, тому що завантаження нових даних в DAC здійснюється з частотою 4 МГц.
Крім того, задній фронт пилоподібного сигналу завалений і знизу сигнал не доходить до того значення, до якого повинен. Це відбувається тому, що сигнал не встигає досягати заданого низького рівня, а ПО завантажує нові значення
Генерація на 200 KHz (90% амплітуда):
Тут вже видно як всі хвилі перетворилися в трикутник.
Цифрові канали
З цифровими каналами все набагато простіше — практично в будь-якому мікроконтролері є таймери дозволяють вивести PWM сигнал на виводи мікроконтролера. Використовувати найкраще 32-х бітний таймер — в такому разі не потрібно перераховувати преддетилель таймера, достатньо в один регістр завантажувати період, а в інший регістр завантажувати необхідну шпаруватість.
User Interface
Організувати інтерфейс було вирішено в чотири прямокутника, кожен має картинку виведеного сигналу, частоту і амплітуду/шпаруватість. Для вибраного каналу текстові дані виведені білим шрифтом, для інших — сірим.
Управління було вирішено робити на энкодерах: лівий відповідає за частоту і поточний вибраний канал (змінюється при натисканні на кнопку), правий відповідає за амплітуду/шпаруватість і форму хвилі (змінюється при натисканні на кнопку).
Крім того, реалізована підтримка сенсорного екрану — при натисканні на неактивний канал він стає активним, при натисканні на активний канал змінюється форма хвилі.
Звичайно ж використовується DevCore для здійснення всього цього. Код ініціалізації інтерфейсу і оновлення даних на екрані виглядає так:
Структура містить всі об’єкти UI
// *************************************************************************
// *** Structure for describes all visual elements for the channel *****
// *************************************************************************
struct ChannelDescriptionType
{
// UI data
UiButton box;
Image img;
String freq_str;
String duty_str;
char freq_str_data[64] = {0};
char duty_str_data[64] = {0};
// Data Generator
...
};
// Visual channel descriptions
ChannelDescriptionType ch_dsc[CHANNEL_CNT];
Код ініціалізації інтерфейсу
// Create and show UI
int32_t half_scr_w = display_drv.GetScreenW() / 2;
int32_t half_scr_h = display_drv.GetScreenH() / 2;
for(uint32_t i = 0U; i < CHANNEL_CNT; i++)
{
// Data Generator
...
// UI data
int32_t start_pos_x = half_scr_w * (i%2);
int32_t start_pos_y = half_scr_h * (i/2);
ch_dsc[i].box.SetParams(nullptr, start_pos_x, start_pos_y, half_scr_w, half_scr_h, true);
ch_dsc[i].box.SetCallback(&Callback, this, nullptr, i);
ch_dsc[i].freq_str.SetParams(ch_dsc[i].freq_str_data, start_pos_x + 4, start_pos_y + 64, COLOR_LIGHTGREY, String::FONT_8x12);
ch_dsc[i].duty_str.SetParams(ch_dsc[i].duty_str_data, start_pos_x + 4, start_pos_y + 64 + 12, COLOR_LIGHTGREY, String::FONT_8x12);
ch_dsc[i].img.SetImage(waveforms[ch_dsc[i].waveform]);
ch_dsc[i].img.Move(start_pos_x + 4, start_pos_y + 4);
ch_dsc[i].box.Show(1);
ch_dsc[i].img.Show(2);
ch_dsc[i].freq_str.Show(3);
ch_dsc[i].duty_str.Show(3);
}
Код оновлення даних на екрані
for(uint32_t i = 0U; i < CHANNEL_CNT; i++)
{
ch_dsc[i].img.SetImage(waveforms[ch_dsc[i].waveform]);
snprintf(ch_dsc[i].freq_str_data, NumberOf(ch_dsc[i].freq_str_data), "Freq: %7lu Hz", ch_dsc[i].frequency);
if(IsAnalogChannel(i)) snprintf(ch_dsc[i].duty_str_data, NumberOf(ch_dsc[i].duty_str_data), "Ampl: %7d %%", ch_dsc[i].duty);
else snprintf(ch_dsc[i].duty_str_data, NumberOf(ch_dsc[i].duty_str_data), "Duty: %7d %%", ch_dsc[i].duty);
// Set gray color to all channels
ch_dsc[i].freq_str.SetColor(COLOR_LIGHTGREY);
ch_dsc[i].duty_str.SetColor(COLOR_LIGHTGREY);
}
// Set white color to selected channel
ch_dsc[channel].freq_str.SetColor(COLOR_WHITE);
ch_dsc[channel].duty_str.SetColor(COLOR_WHITE);
// Update display
display_drv.UpdateDisplay();
Цікаво реалізована обробка натискання кнопки (являє собою прямокутник поверх якого малюються інші елементи). Якщо ви дивилися код, то повинні були помітити таку штуку: ch_dsc[i].box.SetCallback (&Callback, this, nullptr, i); спричинюється в циклі. Це завдання функції зворотного виклику, яка буде викликатися при натисканні на кнопку. У функцію передаються: адреса статичної функції статичної функції класу, вказівник this, і два користувальницьких параметрів, які будуть передані у функцію зворотного виклику — покажчик (не використовується в даному разі — передається nullptr) і число (передається номер каналу).
Ще з університетської лави я пам’ятаю постулат: “Статичні функції не мають доступу до не статичним членам класу“. Так ось це не відповідає дійсності. Оскільки статична функція є членом класу, то вона має доступ до всіх членів класу, якщо має посилання/вказівник на цей клас. Тепер поглянемо на функцію зворотного виклику:
// *****************************************************************************
// *** Callback for the buttons *********************************************
// *****************************************************************************
void Application::Callback(void* ptr, void* param_ptr, uint32_t param)
{
Application& app = *((Application*)ptr);
ChannelType channel = app.channel;
if(channel == param)
{
// Second click - change wave type
...
}
else
{
app.channel = (ChannelType)param;
}
app.update = true;
}
У першому рядку цієї функції відбувається “магія” після чого можна звертатися до будь-яких членів класу, включаючи приватні.
До речі, виклик цієї функції відбувається в іншій задачі (відтворення екрану), так що всередині цієї функції треба подбати про синхронізації. У цьому простенькому проекті “пари вечорів” я цього не зробив, тому що в даному конкретному випадку це не суттєво.
Вихідний код генератора завантажений на GitHub: https://github.com/nickshl/WaveformGenerator
DevCore тепер виділений в окремий репозиторій і включений як субмодуль.