DevBoy: делаем генератор сигналов

от автора

Привет, друзья!

В прошлых статьях я рассказывал про свой проект и про его программную часть. В этой статье я расскажу как простенький генератор сигналов на 4 канала — два аналоговых канала и два PWM канала.

Аналоговые каналы

Микроконтроллер STM32F415RG имеет в своем составе 12-тибитный DAC(digital-to-analog) преобразователь на два независимых канала, что позволяет генерировать разные сигналы. Можно напрямую загружать данные в регистры преобразователя, но для генерации сигналов это не очень подходит. Лучшее решение — использовать массив, в который генерировать одну волну сигнала, а затем запускать DAC с триггером от таймера и DMA. Изменяя частоту таймера можно изменять частоту генерируемого сигнала.

«Классические» формы волны включают: синусоидальная, меандр, треугольная и пилообразная волны.
image

Функция генерации данных волн в буфере имеет следующий вид

// ***************************************************************************** // ***   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.0F * i * PI) / (dac_data_cnt + 1)) + 1.0F) * 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};       // Generator data       ...     };     // 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++)   {     // Generator data     ...     // 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 теперь выделена в отдельный репозиторий и включена как субмодуль.

Ну а зачем мне нужен генератор сигналов, будет уже в следующей(или одной из следующих) статье.


ссылка на оригинал статьи https://habr.com/post/425409/